diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 99887a8b9..78922a714 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -261,10 +261,10 @@ export class AlfredpayController { static async alfredpayStatus(req: Request, res: Response) { try { - const { country } = req.query as unknown as AlfredpayStatusRequest; + const { country, type } = req.query as unknown as AlfredpayStatusRequest; const userId = AlfredpayController.getRequiredUserId(req); - const alfredPayCustomer = await findAlfredpayCustomer(userId, country as AlfredPayCountry); + const alfredPayCustomer = await findAlfredpayCustomer(userId, country as AlfredPayCountry, type); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay customer not found" }); diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index f5f86e9fb..7278f6b92 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -1,5 +1,5 @@ -import {AveniaAccountType, AveniaDocumentType, BrlaApiError, BrlaApiService, KycAttemptResult, KycAttemptStatus} from "@vortexfi/shared"; -import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test"; +import {AveniaAccountType, AveniaDocumentType, BrlaApiError, BrlaApiService, FiatToken, KycAttemptResult, KycAttemptStatus} from "@vortexfi/shared"; +import {afterEach, beforeEach, describe, expect, it, mock, spyOn} from "bun:test"; import httpStatus from "http-status"; import sequelize from "../../config/database"; import logger from "../../config/logger"; @@ -7,18 +7,28 @@ import CustomerEntity from "../../models/customerEntity.model"; import EmailNotification, { NotificationProvider, NotificationType } from "../../models/emailNotification.model"; import FinancialOperation from "../../models/financialOperation.model"; import KycCase from "../../models/kycCase.model"; +import ManagedProfile from "../../models/managedProfile.model"; +import ManagedProfileManager from "../../models/managedProfileManager.model"; import PartnerManagedProfile from "../../models/partnerManagedProfile.model"; import ProviderCustomer, {VerificationStatus} from "../../models/providerCustomer.model"; +import QuoteTicket from "../../models/quoteTicket.model"; import User from "../../models/user.model"; +import { hashTaxReference } from "../services/avenia/avenia-customer.service"; import { SupabaseAuthService } from "../services/auth"; import { createSubaccount, + createKybDocument, + createKybUbo, fetchSubaccountKycStatus, getAveniaUser, getKybAttemptStatus, + getSelfieLivenessUrl, getUploadUrls, + importKycToken, initiateKybLevel1, - recordInitialKycAttempt + newKyc, + recordInitialKycAttempt, + submitKybLevel1Api } from "./brla.controller"; function createResponse() { @@ -271,29 +281,28 @@ describe("getAveniaUser", () => { }); describe("recordInitialKycAttempt", () => { - const originalProviderFindOne = ProviderCustomer.findOne; const originalProviderCreate = ProviderCustomer.create; - const originalEntityFindOne = CustomerEntity.findOne; - const originalEntityFindOrCreate = CustomerEntity.findOrCreate; - const originalKycCaseFindOne = KycCase.findOne; - const originalKycCaseCreate = KycCase.create; + const originalQuoteFindByPk = QuoteTicket.findByPk; + + // A Brazil onramp quote owned by user-1, so the ownership + corridor guards pass. + const mockOwnedBrlQuote = () => { + QuoteTicket.findByPk = mock(async () => ({ + inputCurrency: FiatToken.BRL, + outputCurrency: "USDC", + partnerId: null, + userId: "user-1" + })) as unknown as typeof QuoteTicket.findByPk; + }; afterEach(() => { - ProviderCustomer.findOne = originalProviderFindOne; ProviderCustomer.create = originalProviderCreate; - CustomerEntity.findOne = originalEntityFindOne; - CustomerEntity.findOrCreate = originalEntityFindOrCreate; - KycCase.findOne = originalKycCaseFindOne; - KycCase.create = originalKycCaseCreate; + QuoteTicket.findByPk = originalQuoteFindByPk; }); - it("records the first valid Avenia interaction as started", async () => { - mockEntityPerProfile(); - ProviderCustomer.findOne = mock(async () => null) as typeof ProviderCustomer.findOne; - const providerCreate = mock(async (values: Record) => ({ id: "customer-1", ...values })); + it("does not let an attacker-owned quote reserve a victim's valid tax ID", async () => { + mockOwnedBrlQuote(); + const providerCreate = mock(async () => ({ id: "customer-1" })); ProviderCustomer.create = providerCreate as unknown as typeof ProviderCustomer.create; - KycCase.findOne = mock(async () => null) as typeof KycCase.findOne; - KycCase.create = mock(async () => ({})) as unknown as typeof KycCase.create; const res = createResponse(); await recordInitialKycAttempt( @@ -302,7 +311,8 @@ describe("recordInitialKycAttempt", () => { ); expect(res.statusCode).toBe(httpStatus.OK); - expect(providerCreate.mock.calls[0]?.[0]).toMatchObject({ status: VerificationStatus.Started }); + expect(res.body).toEqual({}); + expect(providerCreate).not.toHaveBeenCalled(); }); it("requires a quote id before recording an Avenia interaction", async () => { @@ -313,36 +323,283 @@ describe("recordInitialKycAttempt", () => { expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); expect(res.body).toEqual({ error: "Missing quoteId or taxId body parameter" }); }); + + it("rejects a marker against a quote the caller does not own", async () => { + // quote belongs to a different user: assertQuoteOwnership must reject and no marker is created. + QuoteTicket.findByPk = mock(async () => ({ + inputCurrency: FiatToken.BRL, + outputCurrency: "USDC", + partnerId: null, + userId: "other-user" + })) as unknown as typeof QuoteTicket.findByPk; + const providerCreate = mock(async () => ({ id: "customer-1" })); + ProviderCustomer.create = providerCreate as unknown as typeof ProviderCustomer.create; + + const res = createResponse(); + await recordInitialKycAttempt( + { body: { quoteId: "quote-1", taxId: "08786985906" }, userId: "attacker" } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.FORBIDDEN); + expect(providerCreate).not.toHaveBeenCalled(); + }); + + it("rejects a marker when the owned quote is not a Brazil corridor", async () => { + QuoteTicket.findByPk = mock(async () => ({ + inputCurrency: "USDC", + outputCurrency: FiatToken.ARS, + partnerId: null, + userId: "user-1" + })) as unknown as typeof QuoteTicket.findByPk; + const providerCreate = mock(async () => ({ id: "customer-1" })); + ProviderCustomer.create = providerCreate as unknown as typeof ProviderCustomer.create; + + const res = createResponse(); + await recordInitialKycAttempt( + { body: { quoteId: "quote-1", taxId: "08786985906" }, userId: "user-1" } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(res.body).toEqual({ error: "quoteId does not reference a Brazil onboarding quote" }); + expect(providerCreate).not.toHaveBeenCalled(); + }); +}); + +describe("importKycToken", () => { + const originals = { + caseFindAll: KycCase.findAll, + caseFindByPk: KycCase.findByPk, + customerFindAll: ProviderCustomer.findAll, + customerFindByPk: ProviderCustomer.findByPk, + entityFindByPk: CustomerEntity.findByPk, + entityFindOne: CustomerEntity.findOne, + getInstance: BrlaApiService.getInstance, + loggerError: logger.error, + managerFindByPk: ManagedProfileManager.findByPk, + relationshipFindByPk: ManagedProfile.findByPk, + transaction: sequelize.transaction, + userFindByPk: User.findByPk + }; + + afterEach(() => { + KycCase.findAll = originals.caseFindAll; + KycCase.findByPk = originals.caseFindByPk; + ProviderCustomer.findAll = originals.customerFindAll; + ProviderCustomer.findByPk = originals.customerFindByPk; + CustomerEntity.findByPk = originals.entityFindByPk; + CustomerEntity.findOne = originals.entityFindOne; + BrlaApiService.getInstance = originals.getInstance; + logger.error = originals.loggerError; + ManagedProfileManager.findByPk = originals.managerFindByPk; + ManagedProfile.findByPk = originals.relationshipFindByPk; + sequelize.transaction = originals.transaction; + User.findByPk = originals.userFindByPk; + }); + + function mockImportState(kind: "authenticated" | "managed", providerImport = mock(async () => ({ id: "attempt-1" }))) { + const subjectProfileId = kind === "managed" ? "child-1" : "user-1"; + const entityId = kind === "managed" ? "entity-child-1" : "entity-user-1"; + User.findByPk = mock(async (profileId: string) => + profileId === subjectProfileId ? { activeCustomerEntityId: entityId, kind } : null + ) as unknown as typeof User.findByPk; + CustomerEntity.findOne = mock(async () => ({ id: entityId, status: "active", type: "individual" })) as unknown as typeof CustomerEntity.findOne; + CustomerEntity.findByPk = mock(async () => ({ + id: entityId, + profileId: subjectProfileId, + status: "active", + type: "individual" + })) as unknown as typeof CustomerEntity.findByPk; + ManagedProfileManager.findByPk = mock(async () => ({ + allowedCorridors: ["BR"], + allowedCustomerTypes: ["individual"], + isActive: true + })) as unknown as typeof ManagedProfileManager.findByPk; + ManagedProfile.findByPk = mock(async () => ({ + id: "relationship-1", + managerProfileId: "manager-1", + profileId: "child-1", + status: "active" + })) as unknown as typeof ManagedProfile.findByPk; + const customer = { + country: "BR", + customerEntityId: entityId, + customerType: "individual", + id: "customer-1", + provider: "avenia", + providerSubaccountId: "subaccount-1", + rail: "brl", + status: VerificationStatus.InReview, + update: mock(async () => undefined) + }; + ProviderCustomer.findAll = mock(async () => [customer]) as unknown as typeof ProviderCustomer.findAll; + ProviderCustomer.findByPk = mock(async () => customer) as unknown as typeof ProviderCustomer.findByPk; + const kycCase = { + customerEntityId: entityId, + id: "case-1", + provider: "avenia", + providerCustomerId: "customer-1", + status: VerificationStatus.InReview, + type: "kyc", + update: mock(async (values: Record) => Object.assign(kycCase, values)), + verificationMethod: null as null | "sumsub_share_token", + verificationSubmission: null + }; + KycCase.findAll = mock(async () => [kycCase]) as unknown as typeof KycCase.findAll; + KycCase.findByPk = mock(async () => kycCase) as unknown as typeof KycCase.findByPk; + sequelize.transaction = mock(async callback => + callback({ LOCK: { UPDATE: "UPDATE" } } as never) + ) as unknown as typeof sequelize.transaction; + + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => ({ attempts: [] })), + getUploadedDocuments: mock(async () => ({ documents: [] })), + importKycToken: providerImport + }) as unknown as BrlaApiService + ); + return { kycCase }; + } + + it("derives direct actor and subject profiles and returns 202", async () => { + const { kycCase } = mockImportState("authenticated"); + const res = createResponse(); + + await importKycToken( + { + body: { consentAttested: true, importToken: "secret-token" }, + get: () => "request-1", + userId: "user-1" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.ACCEPTED); + expect(res.body).toEqual({ attemptId: "attempt-1", status: "pending" }); + expect(kycCase.verificationSubmission).toMatchObject({ actorProfileId: "user-1", subjectProfileId: "user-1" }); + }); + + it("derives managed actor and subject profiles and returns 202", async () => { + const { kycCase } = mockImportState("managed"); + const res = createResponse(); + + await importKycToken( + { + body: { consentAttested: true, importToken: "secret-token" }, + get: () => "request-1", + managedProfileContext: { + actorProfileId: "manager-1", + customerEntityId: "entity-child-1", + managedProfileId: "relationship-1", + subjectProfileId: "child-1" + } + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.ACCEPTED); + expect(kycCase.verificationSubmission).toMatchObject({ actorProfileId: "manager-1", subjectProfileId: "child-1" }); + }); + + it("sanitizes unexpected provider import failures", async () => { + mockImportState( + "authenticated", + mock(async () => { + throw new Error("provider leaked secret-token"); + }) + ); + const res = createResponse(); + + await importKycToken( + { + body: { consentAttested: true, importToken: "secret-token" }, + get: () => "request-1", + userId: "user-1" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.BAD_GATEWAY); + expect(res.body).toEqual({ error: "The Avenia token import outcome requires reconciliation" }); + expect(JSON.stringify(res.body)).not.toContain("secret-token"); + }); + + it("never logs the token when an unexpected import error escapes", async () => { + mockImportState("authenticated"); + KycCase.findAll = mock(async () => { + throw new Error("sentinel-secret-token"); + }) as unknown as typeof KycCase.findAll; + const logged: unknown[] = []; + logger.error = mock((...args: unknown[]) => { + logged.push(args); + return logger; + }) as unknown as typeof logger.error; + const res = createResponse(); + + await importKycToken( + { + body: { consentAttested: true, importToken: "sentinel-secret-token" }, + get: () => "request-1", + userId: "user-1" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.INTERNAL_SERVER_ERROR); + expect(JSON.stringify(logged)).not.toContain("sentinel-secret-token"); + expect(JSON.stringify(res.body)).not.toContain("sentinel-secret-token"); + }); }); describe("fetchSubaccountKycStatus", () => { const originalProviderFindOne = ProviderCustomer.findOne; + const originalProviderFindByPk = ProviderCustomer.findByPk; const originalEntityFindOne = CustomerEntity.findOne; const originalEntityFindOrCreate = CustomerEntity.findOrCreate; const originalKycCaseFindOne = KycCase.findOne; + const originalKycCaseFindAll = KycCase.findAll; const originalGetInstance = BrlaApiService.getInstance; + const originalTransaction = sequelize.transaction; afterEach(() => { ProviderCustomer.findOne = originalProviderFindOne; + ProviderCustomer.findByPk = originalProviderFindByPk; CustomerEntity.findOne = originalEntityFindOne; CustomerEntity.findOrCreate = originalEntityFindOrCreate; KycCase.findOne = originalKycCaseFindOne; + KycCase.findAll = originalKycCaseFindAll; BrlaApiService.getInstance = originalGetInstance; + sequelize.transaction = originalTransaction; }); it("maps a missing Avenia attempt to pending", async () => { mockEntityPerProfile(); const update = mock(async () => undefined); - ProviderCustomer.findOne = mock(async () => ({ + const customer = { customerEntityId: "entity-user-1", id: "customer-1", providerSubaccountId: "subaccount-1", status: VerificationStatus.InReview, statusExternal: null, update - })) as unknown as typeof ProviderCustomer.findOne; + }; + ProviderCustomer.findOne = mock(async () => customer) as unknown as typeof ProviderCustomer.findOne; + ProviderCustomer.findByPk = mock(async () => customer) as unknown as typeof ProviderCustomer.findByPk; const kycUpdate = mock(async () => undefined); - KycCase.findOne = mock(async () => ({ update: kycUpdate })) as unknown as typeof KycCase.findOne; + const kycCase = { + approvedAt: null, + id: "case-1", + providerCaseId: null, + rejectedAt: null, + status: VerificationStatus.InReview, + statusExternal: null, + update: kycUpdate, + verificationMethod: "standard" + }; + KycCase.findAll = mock(async () => [kycCase]) as unknown as typeof KycCase.findAll; + sequelize.transaction = mock(async callback => callback({ LOCK: { UPDATE: "UPDATE" } } as never)) as unknown as typeof sequelize.transaction; BrlaApiService.getInstance = mock( () => ({ @@ -355,23 +612,120 @@ describe("fetchSubaccountKycStatus", () => { await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.NOT_FOUND); - expect(update).toHaveBeenCalledWith({ status: VerificationStatus.Pending, statusExternal: null }); - expect(kycUpdate).toHaveBeenCalledWith(expect.objectContaining({ status: VerificationStatus.Pending })); + expect(update).toHaveBeenCalledWith({ status: VerificationStatus.Pending, statusExternal: null }, expect.anything()); + expect(kycUpdate).toHaveBeenCalledWith(expect.objectContaining({ status: VerificationStatus.Pending }), expect.anything()); + }); + + it("does not return approval when persisting a confirmed account fails", async () => { + mockEntityPerProfile(); + const customer = { + customerEntityId: "entity-user-1", + id: "customer-1", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.InReview, + update: mock(async () => { + throw new Error("database unavailable"); + }) + }; + ProviderCustomer.findOne = mock(async () => customer) as unknown as typeof ProviderCustomer.findOne; + ProviderCustomer.findByPk = mock(async () => customer) as unknown as typeof ProviderCustomer.findByPk; + KycCase.findAll = mock(async () => [ + { + id: "case-1", + providerCaseId: null, + status: VerificationStatus.InReview, + verificationMethod: "standard" + } as KycCase + ]) as unknown as typeof KycCase.findAll; + sequelize.transaction = mock(async callback => + callback({ LOCK: { UPDATE: "UPDATE" } } as never) + ) as unknown as typeof sequelize.transaction; + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => ({ attempts: [] })), + subaccountInfo: mock(async () => ({ accountInfo: { identityStatus: "CONFIRMED" } })) + }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.INTERNAL_SERVER_ERROR); + expect(res.body).toEqual({ details: "database unavailable", error: "Server error" }); + expect(res.json).toHaveBeenCalledTimes(1); + }); + + it("requires reconciliation instead of using attempt history for an unbound active standard submission", async () => { + mockEntityPerProfile(); + const customer = { + customerEntityId: "entity-user-1", + id: "customer-1", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.InReview, + update: mock(async () => undefined) + }; + ProviderCustomer.findOne = mock(async () => customer) as unknown as typeof ProviderCustomer.findOne; + ProviderCustomer.findByPk = mock(async () => customer) as unknown as typeof ProviderCustomer.findByPk; + KycCase.findAll = mock(async () => [ + { + id: "case-1", + providerCaseId: null, + status: VerificationStatus.InReview, + verificationMethod: "standard", + verificationSubmission: { + actorProfileId: "user-1", + attemptBaselineIds: [], + status: "ambiguous", + subjectProfileId: "user-1" + } + } as unknown as KycCase + ]) as unknown as typeof KycCase.findAll; + sequelize.transaction = mock(async callback => + callback({ LOCK: { UPDATE: "UPDATE" } } as never) + ) as unknown as typeof sequelize.transaction; + const getKycAttempts = mock(async () => ({ attempts: [{ id: "unbound" }] })); + const subaccountInfo = mock(async () => ({ accountInfo: { identityStatus: "CONFIRMED" } })); + BrlaApiService.getInstance = mock( + () => ({ getKycAttempts, subaccountInfo }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(res.body).toEqual({ error: "The Avenia KYC submission requires reconciliation" }); + expect(getKycAttempts).not.toHaveBeenCalled(); + expect(subaccountInfo).not.toHaveBeenCalled(); }); function mockOwnedRecordWithAttempt(status: VerificationStatus, attempt: unknown) { mockEntityPerProfile(); const update = mock(async () => undefined); - ProviderCustomer.findOne = mock(async () => ({ + const customer = { customerEntityId: "entity-user-1", id: "customer-1", providerSubaccountId: "subaccount-1", status, statusExternal: null, update - })) as unknown as typeof ProviderCustomer.findOne; + }; + ProviderCustomer.findOne = mock(async () => customer) as unknown as typeof ProviderCustomer.findOne; + ProviderCustomer.findByPk = mock(async () => customer) as unknown as typeof ProviderCustomer.findByPk; const kycUpdate = mock(async () => undefined); - KycCase.findOne = mock(async () => ({ update: kycUpdate })) as unknown as typeof KycCase.findOne; + const kycCase = { + approvedAt: null, + id: "case-1", + providerCaseId: null, + rejectedAt: null, + status, + statusExternal: null, + update: kycUpdate, + verificationMethod: "standard" + }; + KycCase.findOne = mock(async () => kycCase) as unknown as typeof KycCase.findOne; + KycCase.findAll = mock(async () => [kycCase]) as unknown as typeof KycCase.findAll; + sequelize.transaction = mock(async callback => callback({ LOCK: { UPDATE: "UPDATE" } } as never)) as unknown as typeof sequelize.transaction; BrlaApiService.getInstance = mock( () => ({ @@ -396,12 +750,46 @@ describe("fetchSubaccountKycStatus", () => { expect(res.statusCode).toBe(httpStatus.OK); expect((res.body as { result: string }).result).toBe(KycAttemptResult.APPROVED); - expect(update).toHaveBeenCalledWith({ status: VerificationStatus.Approved, statusExternal: KycAttemptStatus.COMPLETED }); - expect(kycUpdate).toHaveBeenCalledWith(expect.objectContaining({ status: VerificationStatus.Approved })); + expect(update).toHaveBeenCalledWith( + { status: VerificationStatus.Approved, statusExternal: KycAttemptStatus.COMPLETED }, + expect.anything() + ); + expect(kycUpdate).toHaveBeenCalledWith(expect.objectContaining({ status: VerificationStatus.Approved }), expect.anything()); }); - it("returns a rejected account to in_review while a retried attempt is processing", async () => { - const { update } = mockOwnedRecordWithAttempt(VerificationStatus.Rejected, { + it("rejects a nonterminal standard attempt with a result without mutating state", async () => { + const { kycUpdate, update } = mockOwnedRecordWithAttempt(VerificationStatus.InReview, { + levelName: "KYC_1", + result: KycAttemptResult.APPROVED, + status: KycAttemptStatus.PROCESSING + }); + + const res = createResponse(); + await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.BAD_GATEWAY); + expect(res.body).toEqual({ error: "Avenia returned an inconsistent KYC attempt" }); + expect(update).not.toHaveBeenCalled(); + expect(kycUpdate).not.toHaveBeenCalled(); + }); + + it("rejects a completed standard attempt without a result before mutating state", async () => { + const { kycUpdate, update } = mockOwnedRecordWithAttempt(VerificationStatus.InReview, { + levelName: "KYC_1", + status: KycAttemptStatus.COMPLETED + }); + + const res = createResponse(); + await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.BAD_GATEWAY); + expect(res.body).toEqual({ error: "Avenia returned an inconsistent KYC attempt" }); + expect(update).not.toHaveBeenCalled(); + expect(kycUpdate).not.toHaveBeenCalled(); + }); + + it("does not let a stale processing poll downgrade a rejected account", async () => { + const { kycUpdate, update } = mockOwnedRecordWithAttempt(VerificationStatus.Rejected, { levelName: "KYC_1", result: "", status: KycAttemptStatus.PROCESSING @@ -411,7 +799,53 @@ describe("fetchSubaccountKycStatus", () => { await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.OK); - expect(update).toHaveBeenCalledWith({ status: VerificationStatus.InReview, statusExternal: KycAttemptStatus.PROCESSING }); + expect(update).toHaveBeenCalledWith( + { status: VerificationStatus.Rejected, statusExternal: null }, + expect.anything() + ); + expect(kycUpdate).toHaveBeenCalledWith( + expect.objectContaining({ status: VerificationStatus.Rejected, statusExternal: null }), + expect.anything() + ); + }); + + it("fails imported-token reconciliation without listing attempts or using account identity", async () => { + mockEntityPerProfile(); + const update = mock(async () => undefined); + const customer = { + customerEntityId: "entity-user-1", + id: "customer-1", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.InReview, + update + }; + ProviderCustomer.findOne = mock(async () => customer) as unknown as typeof ProviderCustomer.findOne; + ProviderCustomer.findByPk = mock(async () => customer) as unknown as typeof ProviderCustomer.findByPk; + KycCase.findAll = mock(async () => [ + { + id: "case-1", + providerCaseId: null, + status: VerificationStatus.InReview, + verificationMethod: "sumsub_share_token" + } as KycCase + ]) as unknown as typeof KycCase.findAll; + sequelize.transaction = mock(async callback => + callback({ LOCK: { UPDATE: "UPDATE" } } as never) + ) as unknown as typeof sequelize.transaction; + const getKycAttempts = mock(async () => ({ attempts: [] })); + const subaccountInfo = mock(async () => ({ accountInfo: { identityStatus: "CONFIRMED" } })); + BrlaApiService.getInstance = mock( + () => ({ getKycAttempts, subaccountInfo }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(res.body).toEqual({ error: "The imported Avenia KYC attempt requires reconciliation" }); + expect(getKycAttempts).not.toHaveBeenCalled(); + expect(subaccountInfo).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); }); // Migration 040: the record may live on the profile's legacy individual entity while a @@ -423,13 +857,28 @@ describe("fetchSubaccountKycStatus", () => { ]) as unknown as typeof CustomerEntity.findAll; const strayCreate = mock(async () => [{ id: "entity-user-1-business" }, true]); CustomerEntity.findOrCreate = strayCreate as unknown as typeof CustomerEntity.findOrCreate; - ProviderCustomer.findOne = mock(async () => ({ + const customer = { customerEntityId: "entity-user-1-individual", id: "customer-1", providerSubaccountId: "subaccount-1", status: VerificationStatus.Approved, - statusExternal: null - })) as unknown as typeof ProviderCustomer.findOne; + statusExternal: null, + update: mock(async () => undefined) + }; + ProviderCustomer.findOne = mock(async () => customer) as unknown as typeof ProviderCustomer.findOne; + ProviderCustomer.findByPk = mock(async () => customer) as unknown as typeof ProviderCustomer.findByPk; + const kycCase = { + approvedAt: new Date(), + id: "case-1", + providerCaseId: null, + rejectedAt: null, + status: VerificationStatus.Approved, + statusExternal: null, + update: mock(async () => undefined), + verificationMethod: "standard" + }; + KycCase.findAll = mock(async () => [kycCase]) as unknown as typeof KycCase.findAll; + sequelize.transaction = mock(async callback => callback({ LOCK: { UPDATE: "UPDATE" } } as never)) as unknown as typeof sequelize.transaction; BrlaApiService.getInstance = mock( () => ({ @@ -457,61 +906,202 @@ describe("fetchSubaccountKycStatus", () => { await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.OK); - expect(update).not.toHaveBeenCalled(); - expect(kycUpdate).not.toHaveBeenCalled(); - }); -}); - -describe("Avenia company KYB", () => { - const originalProviderFindOne = ProviderCustomer.findOne; - const originalProviderFindByPk = ProviderCustomer.findByPk; - const originalEntityFindOne = CustomerEntity.findOne; - const originalEntityFindOrCreate = CustomerEntity.findOrCreate; - const originalKycCaseFindOne = KycCase.findOne; - const originalKycCaseCreate = KycCase.create; - const originalGetInstance = BrlaApiService.getInstance; - - afterEach(() => { - ProviderCustomer.findOne = originalProviderFindOne; - ProviderCustomer.findByPk = originalProviderFindByPk; - CustomerEntity.findOne = originalEntityFindOne; - CustomerEntity.findOrCreate = originalEntityFindOrCreate; - KycCase.findOne = originalKycCaseFindOne; - KycCase.create = originalKycCaseCreate; - BrlaApiService.getInstance = originalGetInstance; + expect(update).toHaveBeenCalledWith( + { status: VerificationStatus.Approved, statusExternal: null }, + expect.anything() + ); + expect(kycUpdate).toHaveBeenCalledWith(expect.objectContaining({ status: VerificationStatus.Approved }), expect.anything()); }); - it("binds the initiated provider attempt to the owned KYB case", async () => { + it("fetches a bound imported case by its exact provider attempt id and keeps EXPIRED pending", async () => { mockEntityPerProfile(); const customerUpdate = mock(async () => undefined); - ProviderCustomer.findOne = mock(async () => ({ + const customer = { customerEntityId: "entity-user-1", - customerType: "business", id: "customer-1", providerSubaccountId: "subaccount-1", - statusExternal: null, + status: VerificationStatus.InReview, + statusExternal: KycAttemptStatus.PROCESSING, update: customerUpdate - })) as unknown as typeof ProviderCustomer.findOne; + }; + ProviderCustomer.findOne = mock(async () => customer) as unknown as typeof ProviderCustomer.findOne; + ProviderCustomer.findByPk = mock(async () => customer) as unknown as typeof ProviderCustomer.findByPk; const caseUpdate = mock(async () => undefined); - KycCase.findOne = mock(async () => ({ update: caseUpdate })) as unknown as typeof KycCase.findOne; + const kycCase = { + id: "case-1", + providerCaseId: "attempt-imported", + status: VerificationStatus.InReview, + statusExternal: KycAttemptStatus.PROCESSING, + update: caseUpdate, + verificationMethod: "sumsub_share_token" + }; + KycCase.findAll = mock(async () => [kycCase]) as unknown as typeof KycCase.findAll; + KycCase.findOne = mock(async () => kycCase) as unknown as typeof KycCase.findOne; + sequelize.transaction = mock(async callback => callback({ LOCK: { UPDATE: "UPDATE" } } as never)) as unknown as typeof sequelize.transaction; + const getExactAttempt = mock(async () => ({ + attempt: { + id: "attempt-imported", + levelName: "sumsub-token-recipient", + result: "", + status: KycAttemptStatus.EXPIRED + } + })); + const listAttempts = mock(async () => ({ attempts: [] })); BrlaApiService.getInstance = mock( () => ({ - initiateKybLevel1: mock(async () => ({ - attemptId: "attempt-1", - authorizedRepresentativeUrl: "https://avenia.example/representative", - basicCompanyDataUrl: "https://avenia.example/company" - })) + getKycAttempts: listAttempts, + getVerificationAttemptStatus: getExactAttempt }) as unknown as BrlaApiService ); const res = createResponse(); - await initiateKybLevel1({ query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, res as any); + await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.OK); - // Nothing is submitted until the user finishes the hosted steps: the account stays pending - // (resumable), never in_review, until Avenia reports PROCESSING. - expect(customerUpdate).toHaveBeenCalledWith({ + expect(getExactAttempt).toHaveBeenCalledWith("attempt-imported", "subaccount-1"); + expect(listAttempts).not.toHaveBeenCalled(); + expect(customerUpdate).toHaveBeenCalledWith( + { status: VerificationStatus.Pending, statusExternal: KycAttemptStatus.EXPIRED }, + expect.anything() + ); + expect(caseUpdate).toHaveBeenCalledWith(expect.objectContaining({ status: VerificationStatus.Pending }), expect.anything()); + }); + + function mockImportedApproval(providerTaxId: string) { + mockEntityPerProfile(); + const customerUpdate = mock(async () => undefined); + const customer = { + customerEntityId: "entity-user-1", + id: "customer-1", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.InReview, + statusExternal: KycAttemptStatus.PROCESSING, + taxReferenceHash: hashTaxReference("08786985906"), + update: customerUpdate + }; + ProviderCustomer.findOne = mock(async () => customer) as unknown as typeof ProviderCustomer.findOne; + ProviderCustomer.findByPk = mock(async () => customer) as unknown as typeof ProviderCustomer.findByPk; + const caseUpdate = mock(async () => undefined); + const kycCase = { + id: "case-1", + providerCaseId: "attempt-imported", + status: VerificationStatus.InReview, + statusExternal: KycAttemptStatus.PROCESSING, + update: caseUpdate, + verificationMethod: "sumsub_share_token" + }; + KycCase.findAll = mock(async () => [kycCase]) as unknown as typeof KycCase.findAll; + sequelize.transaction = mock(async callback => + callback({ LOCK: { UPDATE: "UPDATE" } } as never) + ) as unknown as typeof sequelize.transaction; + BrlaApiService.getInstance = mock( + () => + ({ + getVerificationAttemptStatus: mock(async () => ({ + attempt: { + id: "attempt-imported", + levelName: "sumsub-token-recipient", + result: KycAttemptResult.APPROVED, + status: KycAttemptStatus.COMPLETED + } + })), + subaccountInfo: mock(async () => ({ accountInfo: { taxId: providerTaxId } })) + }) as unknown as BrlaApiService + ); + return { caseUpdate, customerUpdate }; + } + + it("does not approve imported KYC when Avenia exposes a different CPF", async () => { + const { caseUpdate, customerUpdate } = mockImportedApproval("111.444.777-35"); + + const res = createResponse(); + await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.INTERNAL_SERVER_ERROR); + expect(customerUpdate).not.toHaveBeenCalled(); + expect(caseUpdate).not.toHaveBeenCalled(); + }); + + it("approves imported KYC when Avenia returns the canonical CPF with formatting", async () => { + const { caseUpdate, customerUpdate } = mockImportedApproval("087.869.859-06"); + + const res = createResponse(); + await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(customerUpdate).toHaveBeenCalledWith( + { status: VerificationStatus.Approved, statusExternal: KycAttemptStatus.COMPLETED }, + expect.anything() + ); + expect(caseUpdate).toHaveBeenCalledWith(expect.objectContaining({ status: VerificationStatus.Approved }), expect.anything()); + }); +}); + +describe("Avenia company KYB", () => { + const originalProviderFindOne = ProviderCustomer.findOne; + const originalProviderFindByPk = ProviderCustomer.findByPk; + const originalEntityFindOne = CustomerEntity.findOne; + 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({ LOCK: { UPDATE: "UPDATE" } } as never) + ) as unknown as typeof sequelize.transaction; + }); + + afterEach(() => { + ProviderCustomer.findOne = originalProviderFindOne; + ProviderCustomer.findByPk = originalProviderFindByPk; + CustomerEntity.findOne = originalEntityFindOne; + 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 () => { + mockEntityPerProfile(); + const customerUpdate = mock(async () => undefined); + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + customerType: "business", + id: "customer-1", + providerSubaccountId: "subaccount-1", + statusExternal: null, + update: customerUpdate + })) as unknown as typeof ProviderCustomer.findOne; + const caseUpdate = mock(async () => undefined); + KycCase.findOne = mock(async () => ({ update: caseUpdate })) as unknown as typeof KycCase.findOne; + BrlaApiService.getInstance = mock( + () => + ({ + initiateKybLevel1: mock(async () => ({ + attemptId: "attempt-1", + authorizedRepresentativeUrl: "https://avenia.example/representative", + basicCompanyDataUrl: "https://avenia.example/company" + })), + getKycAttempts: mock(async () => ({ attempts: [] })) + }) 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.OK); + // Nothing is submitted until the user finishes the hosted steps: the account stays pending + // (resumable), never in_review, until Avenia reports PROCESSING. + expect(customerUpdate).toHaveBeenCalledWith({ status: VerificationStatus.Pending, statusExternal: KycAttemptStatus.PENDING }); @@ -550,7 +1140,8 @@ describe("Avenia company KYB", () => { attemptId: "attempt-1", authorizedRepresentativeUrl: "https://avenia.example/representative", basicCompanyDataUrl: "https://avenia.example/company" - })) + })), + getKycAttempts: mock(async () => ({ attempts: [] })) }) as unknown as BrlaApiService ); @@ -561,7 +1152,7 @@ describe("Avenia company KYB", () => { expect(strayCreate).not.toHaveBeenCalled(); }); - it("re-issues KYB links while the existing attempt is still PENDING, rebinding the case", async () => { + it("re-issues KYB links while Avenia reports a pending KYB attempt", async () => { mockEntityPerProfile(); const customerUpdate = mock(async () => undefined); ProviderCustomer.findOne = mock(async () => ({ @@ -574,11 +1165,7 @@ describe("Avenia company KYB", () => { update: customerUpdate })) as unknown as typeof ProviderCustomer.findOne; const caseUpdate = mock(async () => undefined); - KycCase.findOne = mock(async () => ({ - providerCaseId: "attempt-1", - statusExternal: KycAttemptStatus.PENDING, - update: caseUpdate - })) as unknown as typeof KycCase.findOne; + KycCase.findOne = mock(async () => ({ update: caseUpdate })) as unknown as typeof KycCase.findOne; const initiateMock = mock(async () => ({ attemptId: "attempt-2", authorizedRepresentativeUrl: "https://avenia.example/representative", @@ -587,7 +1174,9 @@ describe("Avenia company KYB", () => { BrlaApiService.getInstance = mock( () => ({ - getKybAttemptStatus: mock(async () => ({ attempt: { id: "attempt-1", status: KycAttemptStatus.PENDING } })), + getKycAttempts: mock(async () => ({ + attempts: [{ id: "attempt-1", levelName: "kyb-level-1", status: KycAttemptStatus.PENDING }] + })), initiateKybLevel1: initiateMock }) as unknown as BrlaApiService ); @@ -596,7 +1185,7 @@ describe("Avenia company KYB", () => { await initiateKybLevel1({ query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.OK); - expect(initiateMock).toHaveBeenCalled(); + expect(initiateMock).toHaveBeenCalledWith("subaccount-1"); expect(caseUpdate).toHaveBeenCalledWith( expect.objectContaining({ providerCaseId: "attempt-2", status: VerificationStatus.Pending }) ); @@ -612,16 +1201,48 @@ describe("Avenia company KYB", () => { status: VerificationStatus.Pending, statusExternal: KycAttemptStatus.PENDING })) as unknown as typeof ProviderCustomer.findOne; - KycCase.findOne = mock(async () => ({ - providerCaseId: "attempt-1", + const initiateMock = mock(async () => ({ attemptId: "attempt-2" })); + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => ({ + attempts: [{ id: "attempt-1", levelName: "kyb-level-1", status: KycAttemptStatus.PROCESSING }] + })), + 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("rejects re-initiation when Avenia already approved the company", async () => { + mockEntityPerProfile(); + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + customerType: "business", + id: "customer-1", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.Pending, statusExternal: KycAttemptStatus.PENDING - })) as unknown as typeof KycCase.findOne; + })) as unknown as typeof ProviderCustomer.findOne; const initiateMock = mock(async () => ({ attemptId: "attempt-2" })); BrlaApiService.getInstance = mock( () => ({ - // The user finished the hosted steps in another tab; our row still says PENDING. - getKybAttemptStatus: mock(async () => ({ attempt: { id: "attempt-1", status: KycAttemptStatus.PROCESSING } })), + getKycAttempts: mock(async () => ({ + attempts: [ + { + id: "attempt-1", + levelName: "kyb-level-1", + result: KycAttemptResult.APPROVED, + status: KycAttemptStatus.COMPLETED + } + ] + })), initiateKybLevel1: initiateMock }) as unknown as BrlaApiService ); @@ -630,10 +1251,11 @@ describe("Avenia company KYB", () => { await initiateKybLevel1({ query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(res.body).toEqual({ error: "This company is already approved" }); 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 () => ({ @@ -645,11 +1267,6 @@ describe("Avenia company KYB", () => { statusExternal: KycAttemptStatus.PENDING, update: customerUpdate })) as unknown as typeof ProviderCustomer.findOne; - KycCase.findOne = mock(async () => ({ - providerCaseId: "attempt-1", - statusExternal: KycAttemptStatus.PENDING, - update: mock(async () => undefined) - })) as unknown as typeof KycCase.findOne; const initiateMock = mock(async () => ({ attemptId: "attempt-2", authorizedRepresentativeUrl: "https://avenia.example/representative", @@ -658,8 +1275,13 @@ describe("Avenia company KYB", () => { BrlaApiService.getInstance = mock( () => ({ - getKybAttemptStatus: mock(async () => { - throw new Error("avenia unavailable"); + getKycAttempts: mock(async () => { + throw new BrlaApiError({ + endpoint: "/v2/kyc/attempts", + method: "GET", + responseBody: "avenia unavailable", + status: httpStatus.INTERNAL_SERVER_ERROR + }); }), initiateKybLevel1: initiateMock }) as unknown as BrlaApiService @@ -668,32 +1290,37 @@ 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("still rejects re-initiation once Avenia is processing the attempt", async () => { + it("allows Avenia to decide whether a terminal KYB attempt may be retried", async () => { mockEntityPerProfile(); ProviderCustomer.findOne = mock(async () => ({ customerEntityId: "entity-user-1", customerType: "business", id: "customer-1", providerSubaccountId: "subaccount-1", - status: VerificationStatus.InReview, - statusExternal: KycAttemptStatus.PROCESSING + status: VerificationStatus.Rejected, + update: mock(async () => undefined) })) as unknown as typeof ProviderCustomer.findOne; - KycCase.findOne = mock(async () => ({ - providerCaseId: "attempt-1", - statusExternal: KycAttemptStatus.PROCESSING - })) as unknown as typeof KycCase.findOne; + KycCase.findOne = mock(async () => ({ update: mock(async () => undefined) })) as unknown as typeof KycCase.findOne; const initiateMock = mock(async () => ({ attemptId: "attempt-2" })); - BrlaApiService.getInstance = mock(() => ({ initiateKybLevel1: initiateMock }) as unknown as BrlaApiService); + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => ({ + attempts: [{ id: "attempt-1", levelName: "kyb-level-1", 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(); + expect(res.statusCode).toBe(httpStatus.OK); + expect(initiateMock).toHaveBeenCalledWith("subaccount-1"); }); it("rejects an attempt owned by another user without querying Avenia", async () => { @@ -730,6 +1357,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; @@ -737,7 +1365,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(); }); @@ -765,13 +1397,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"); @@ -779,6 +1413,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(); @@ -799,16 +1434,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", @@ -823,16 +1462,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(); @@ -849,11 +1489,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", () => { @@ -1213,17 +1890,24 @@ describe("getUploadUrls", () => { const originalProviderFindOne = ProviderCustomer.findOne; const originalEntityFindOrCreate = CustomerEntity.findOrCreate; const originalGetInstance = BrlaApiService.getInstance; + const originalKycCaseFindAll = KycCase.findAll; const originalLoggerError = logger.error; + const originalTransaction = sequelize.transaction; beforeEach(() => { logger.error = mock(() => logger) as typeof logger.error; + sequelize.transaction = mock(async callback => + callback({ LOCK: { UPDATE: "UPDATE" } } as never) + ) as unknown as typeof sequelize.transaction; }); afterEach(() => { ProviderCustomer.findOne = originalProviderFindOne; CustomerEntity.findOrCreate = originalEntityFindOrCreate; BrlaApiService.getInstance = originalGetInstance; + KycCase.findAll = originalKycCaseFindAll; logger.error = originalLoggerError; + sequelize.transaction = originalTransaction; }); const uploadUrlsMock = mock(async () => ({ id: "doc-1", uploadURLBack: "back-url", uploadURLFront: "front-url" })); @@ -1234,9 +1918,9 @@ describe("getUploadUrls", () => { ); } - // Migration 040 attached business rows to the profile's individual entity; the ownership - // check compared against the typed business entity and 403'd the legitimate owner. - it("serves upload URLs for a business row on the legacy individual entity without creating entities", async () => { + // Migration 040 attached rows to the profile's individual entity; the ownership check + // must enumerate existing entities without creating another one. + it("serves upload URLs for an individual row without creating entities", async () => { mockBrlaApi(); uploadUrlsMock.mockClear(); CustomerEntity.findAll = mock(async () => [ @@ -1246,8 +1930,12 @@ describe("getUploadUrls", () => { CustomerEntity.findOrCreate = strayCreate as unknown as typeof CustomerEntity.findOrCreate; ProviderCustomer.findOne = mock(async () => ({ customerEntityId: "entity-user-1-individual", + customerType: "individual", + id: "customer-1", + provider: "avenia", providerSubaccountId: "subaccount-1" })) as unknown as typeof ProviderCustomer.findOne; + KycCase.findAll = mock(async () => [{ id: "case-1", verificationMethod: "standard" }]) as unknown as typeof KycCase.findAll; const res = createResponse(); await getUploadUrls( @@ -1268,6 +1956,7 @@ describe("getUploadUrls", () => { ]) as unknown as typeof CustomerEntity.findAll; ProviderCustomer.findOne = mock(async () => ({ customerEntityId: "entity-victim", + customerType: "individual", providerSubaccountId: "subaccount-1" })) as unknown as typeof ProviderCustomer.findOne; @@ -1280,4 +1969,836 @@ describe("getUploadUrls", () => { expect(res.statusCode).toBe(httpStatus.FORBIDDEN); expect(uploadUrlsMock).not.toHaveBeenCalled(); }); + + it("rejects an owned business customer before requesting upload URLs", async () => { + mockBrlaApi(); + uploadUrlsMock.mockClear(); + CustomerEntity.findAll = mock(async () => [{ id: "entity-business" }]) as unknown as typeof CustomerEntity.findAll; + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-business", + customerType: "business", + providerSubaccountId: "subaccount-1" + })) as unknown as typeof ProviderCustomer.findOne; + + const res = createResponse(); + await getUploadUrls( + { body: { documentType: AveniaDocumentType.ID, taxId: "11222333000181" }, userId: "business-user" } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(uploadUrlsMock).not.toHaveBeenCalled(); + }); + + it("rejects an imported-method case before requesting upload URLs", async () => { + mockBrlaApi(); + uploadUrlsMock.mockClear(); + CustomerEntity.findAll = mock(async () => [{ id: "entity-user-1" }]) as unknown as typeof CustomerEntity.findAll; + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + customerType: "individual", + id: "customer-1", + provider: "avenia", + providerSubaccountId: "subaccount-1" + })) as unknown as typeof ProviderCustomer.findOne; + KycCase.findAll = mock(async () => [{ id: "case-1", verificationMethod: "sumsub_share_token" }]) as unknown as typeof KycCase.findAll; + + const res = createResponse(); + await getUploadUrls( + { body: { documentType: AveniaDocumentType.ID, taxId: "08786985906" }, userId: "user-1" } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(uploadUrlsMock).not.toHaveBeenCalled(); + }); +}); + +describe("newKyc", () => { + const originalProviderFindOne = ProviderCustomer.findOne; + const originalProviderFindByPk = ProviderCustomer.findByPk; + const originalKycCaseFindAll = KycCase.findAll; + const originalKycCaseFindByPk = KycCase.findByPk; + const originalGetInstance = BrlaApiService.getInstance; + const originalEntityFindByPk = CustomerEntity.findByPk; + const originalManagerFindByPk = ManagedProfileManager.findByPk; + const originalRelationshipFindByPk = ManagedProfile.findByPk; + const originalUserFindByPk = User.findByPk; + const originalTransaction = sequelize.transaction; + + afterEach(() => { + ProviderCustomer.findOne = originalProviderFindOne; + ProviderCustomer.findByPk = originalProviderFindByPk; + KycCase.findAll = originalKycCaseFindAll; + KycCase.findByPk = originalKycCaseFindByPk; + BrlaApiService.getInstance = originalGetInstance; + CustomerEntity.findByPk = originalEntityFindByPk; + ManagedProfileManager.findByPk = originalManagerFindByPk; + ManagedProfile.findByPk = originalRelationshipFindByPk; + User.findByPk = originalUserFindByPk; + sequelize.transaction = originalTransaction; + }); + + it("rejects an owned business customer before submitting KYC", async () => { + CustomerEntity.findAll = mock(async () => [{ id: "entity-business" }]) as unknown as typeof CustomerEntity.findAll; + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-business", + customerType: "business", + providerSubaccountId: "subaccount-1" + })) as unknown as typeof ProviderCustomer.findOne; + const getInstance = mock(() => ({} as BrlaApiService)); + BrlaApiService.getInstance = getInstance; + + const res = createResponse(); + await newKyc({ body: { subAccountId: "subaccount-1" }, userId: "business-user" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(getInstance).not.toHaveBeenCalled(); + }); + + it("rejects an imported-method case before provider document or submission calls", async () => { + CustomerEntity.findAll = mock(async () => [{ id: "entity-user-1" }]) as unknown as typeof CustomerEntity.findAll; + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + customerType: "individual", + id: "customer-1", + provider: "avenia", + providerSubaccountId: "subaccount-1" + })) as unknown as typeof ProviderCustomer.findOne; + KycCase.findAll = mock(async () => [{ id: "case-1", verificationMethod: "sumsub_share_token" }]) as unknown as typeof KycCase.findAll; + sequelize.transaction = mock(async callback => + callback({ LOCK: { UPDATE: "UPDATE" } } as never) + ) as unknown as typeof sequelize.transaction; + const getUploadedDocuments = mock(async () => ({ + documents: [ + { id: "document-1", ready: true }, + { id: "selfie-1", ready: true } + ] + })); + const submitKycLevel1 = mock(async () => ({ id: "attempt-new" })); + BrlaApiService.getInstance = mock( + () => ({ getUploadedDocuments, submitKycLevel1 }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await newKyc({ body: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(getUploadedDocuments).not.toHaveBeenCalled(); + expect(submitKycLevel1).not.toHaveBeenCalled(); + }); + + it("allows a direct managed child to bind the exact standard KYC attempt", async () => { + CustomerEntity.findAll = mock(async () => [{ id: "entity-child-1" }]) as unknown as typeof CustomerEntity.findAll; + const customerUpdate = mock(async () => undefined); + const customer = { + customerEntityId: "entity-child-1", + customerType: "individual", + id: "customer-1", + provider: "avenia", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.InReview, + update: customerUpdate + }; + ProviderCustomer.findOne = mock(async () => customer) as unknown as typeof ProviderCustomer.findOne; + ProviderCustomer.findByPk = mock(async () => customer) as unknown as typeof ProviderCustomer.findByPk; + ManagedProfileManager.findByPk = mock(async () => ({ + allowedCorridors: ["BR"], + allowedCustomerTypes: null, + isActive: true + })) as unknown as typeof ManagedProfileManager.findByPk; + ManagedProfile.findByPk = mock(async () => ({ + managerProfileId: "manager-1", + profileId: "child-1", + status: "active" + })) as unknown as typeof ManagedProfile.findByPk; + User.findByPk = mock(async () => ({ activeCustomerEntityId: "entity-child-1", kind: "managed" })) as unknown as typeof User.findByPk; + CustomerEntity.findByPk = mock(async () => ({ + profileId: "child-1", + status: "active", + type: "individual" + })) as unknown as typeof CustomerEntity.findByPk; + const caseUpdate = mock(async (values: object) => Object.assign(kycCase, values)); + const kycCase = { + id: "case-1", + providerCustomerId: "customer-1", + status: VerificationStatus.InReview, + submittedAt: null, + update: caseUpdate, + verificationMethod: "standard", + verificationSubmission: null + }; + KycCase.findAll = mock(async () => [kycCase]) as unknown as typeof KycCase.findAll; + KycCase.findByPk = mock(async () => kycCase) as unknown as typeof KycCase.findByPk; + sequelize.transaction = mock(async callback => + callback({ LOCK: { UPDATE: "UPDATE" } } as never) + ) as unknown as typeof sequelize.transaction; + const getKycAttempts = mock(async () => ({ attempts: [] })); + const getUploadedDocuments = mock(async () => ({ + documents: [ + { id: "document-1", ready: true }, + { id: "selfie-1", ready: true } + ] + })); + const submitKycLevel1 = mock(async () => ({ id: "attempt-exact" })); + BrlaApiService.getInstance = mock( + () => ({ getKycAttempts, getUploadedDocuments, submitKycLevel1 }) as unknown as BrlaApiService + ); + const timeout = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void) => { + callback(); + return 0; + }) as typeof setTimeout); + + try { + const res = createResponse(); + await newKyc( + { + body: { + subAccountId: "subaccount-1", + uploadedDocumentId: "document-1", + uploadedSelfieId: "selfie-1" + }, + managedProfileContext: { + actorProfileId: "child-1", + controllingManagerProfileId: "manager-1", + customerEntityId: "entity-child-1", + managedProfileId: "relationship-1", + subjectProfileId: "child-1" + } + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(res.body).toEqual({ id: "attempt-exact" }); + expect(kycCase.verificationSubmission).toMatchObject({ actorProfileId: "child-1", subjectProfileId: "child-1" }); + expect(caseUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + providerCaseId: "attempt-exact", + status: VerificationStatus.Pending, + statusExternal: KycAttemptStatus.PENDING + }), + expect.anything() + ); + expect(customerUpdate).toHaveBeenCalledWith( + { lastFailureReasons: [], status: VerificationStatus.Pending, statusExternal: KycAttemptStatus.PENDING }, + expect.anything() + ); + } finally { + timeout.mockRestore(); + } + }); +}); + +describe("Avenia API KYB", () => { + const originals = { + caseCreate: KycCase.create, + caseFindOrCreate: KycCase.findOrCreate, + caseFindByPk: KycCase.findByPk, + caseFindOne: KycCase.findOne, + caseUpdate: KycCase.update, + getInstance: BrlaApiService.getInstance, + providerFindOne: ProviderCustomer.findOne, + providerFindByPk: ProviderCustomer.findByPk, + 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.findByPk = originals.caseFindByPk; + KycCase.findOne = originals.caseFindOne; + KycCase.update = originals.caseUpdate; + BrlaApiService.getInstance = originals.getInstance; + ProviderCustomer.findOne = originals.providerFindOne; + ProviderCustomer.findByPk = originals.providerFindByPk; + 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() { + const customerUpdate = mockBusinessAccount(); + const caseUpdate = mock(async () => undefined); + const kycCase = { + id: "case-1", + providerCaseId: null, + providerCustomerId: "customer-1", + update: caseUpdate + }; + KycCase.findOrCreate = mock(async () => [kycCase, false]) as unknown as typeof KycCase.findOrCreate; + ProviderCustomer.findByPk = mock(async () => ({ id: "customer-1", update: customerUpdate })) as unknown as typeof ProviderCustomer.findByPk; + KycCase.findByPk = mock(async () => kycCase) as unknown as typeof KycCase.findByPk; + sequelize.transaction = mock(async callback => + callback({ LOCK: { UPDATE: "UPDATE" } } as never) + ) as unknown as typeof sequelize.transaction; + const submit = mock(async () => ({ id: "attempt-1" })); + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => ({ attempts: [] })), + getUploadedDocument: mock(async (id: string) => documentResponse(id)), + submitKybLevel1: submit + }) as unknown as BrlaApiService + ); + return { caseUpdate, customerUpdate, submit }; + } + + function mockReconciliationLocks(record: Record, kycCase: Record) { + ProviderCustomer.findByPk = mock(async () => ({ id: "customer-1", ...record })) as unknown as typeof ProviderCustomer.findByPk; + KycCase.findByPk = mock(async () => ({ + id: "case-1", + providerCaseId: null, + providerCustomerId: "customer-1", + ...kycCase + })) as unknown as typeof KycCase.findByPk; + } + + 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, 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(customerUpdate).toHaveBeenCalledWith( + expect.objectContaining({ status: VerificationStatus.Pending, statusExternal: KycAttemptStatus.PENDING }), + expect.anything() + ); + expect(caseUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + failureReasons: [], + providerCaseId: "attempt-1", + status: VerificationStatus.Pending + }), + expect.anything() + ); + }); + + it.each([ + [VerificationStatus.InReview, KycAttemptStatus.PROCESSING], + [VerificationStatus.Approved, KycAttemptStatus.COMPLETED] + ])("preserves a concurrently reconciled %s attempt", async (status, statusExternal) => { + const staleCustomerUpdate = mockBusinessAccount(); + const staleCaseUpdate = mock(async () => undefined); + KycCase.findOrCreate = mock(async () => [ + { + id: "case-1", + providerCaseId: null, + providerCustomerId: "customer-1", + update: staleCaseUpdate + }, + false + ]) as unknown as typeof KycCase.findOrCreate; + const lockedCustomerUpdate = mock(async () => undefined); + const lockedCaseUpdate = mock(async () => undefined); + ProviderCustomer.findByPk = mock(async () => ({ + id: "customer-1", + status, + statusExternal, + update: lockedCustomerUpdate + })) as unknown as typeof ProviderCustomer.findByPk; + KycCase.findByPk = mock(async () => ({ + id: "case-1", + providerCaseId: "attempt-1", + providerCustomerId: "customer-1", + status, + statusExternal, + update: lockedCaseUpdate + })) as unknown as typeof KycCase.findByPk; + sequelize.transaction = mock(async callback => + callback({ LOCK: { UPDATE: "UPDATE" } } as never) + ) as unknown as typeof sequelize.transaction; + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => ({ attempts: [] })), + getUploadedDocument: mock(async (id: string) => documentResponse(id)), + submitKybLevel1: mock(async () => ({ id: "attempt-1" })) + }) 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: "attempt-1" }); + expect(staleCustomerUpdate).not.toHaveBeenCalled(); + expect(staleCaseUpdate).not.toHaveBeenCalled(); + expect(lockedCustomerUpdate).not.toHaveBeenCalled(); + expect(lockedCaseUpdate).not.toHaveBeenCalled(); + }); + + it("allows a new attempt only after a provider-confirmed retryable rejection", async () => { + const customerUpdate = mockBusinessAccount(); + const caseUpdate = mock(async () => undefined); + KycCase.findOrCreate = mock(async () => [ + { + id: "case-1", + providerCaseId: "attempt-old", + update: caseUpdate + }, + false + ]) as unknown as typeof KycCase.findOrCreate; + KycCase.update = mock(async () => [1]) as unknown as typeof KycCase.update; + mockReconciliationLocks( + { status: VerificationStatus.Pending, update: customerUpdate }, + { providerCaseId: "attempt-old", status: VerificationStatus.Rejected, update: caseUpdate } + ); + sequelize.transaction = mock(async callback => + callback({ LOCK: { UPDATE: "UPDATE" } } as never) + ) as unknown as typeof sequelize.transaction; + const submit = mock(async () => ({ id: "attempt-new" })); + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => ({ + attempts: [{ id: "attempt-old", levelName: "kyb-level-1", 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("defers retry eligibility for a terminal attempt to Avenia", async () => { + mockBusinessAccount(); + KycCase.findOrCreate = mock(async () => [ + { + id: "case-1", + providerCaseId: "attempt-old" + }, + false + ]) as unknown as typeof KycCase.findOrCreate; + const submit = mock(async () => { + throw new BrlaApiError({ + endpoint: "/v2/kyc/new-level-1/api", + method: "POST", + responseBody: JSON.stringify({ message: "Attempt is not retryable" }), + status: httpStatus.BAD_REQUEST + }); + }); + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => ({ + attempts: [{ id: "attempt-old", levelName: "kyb-level-1", 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.BAD_REQUEST); + expect(submit).toHaveBeenCalledWith(validSubmission, "subaccount-1"); + }); + + it("repairs a missing local binding after Avenia accepted the first submission", async () => { + const customerUpdate = mockBusinessAccount(); + const caseUpdate = mock(async () => undefined); + KycCase.findOrCreate = mock(async () => [ + { id: "case-1", providerCaseId: null, update: caseUpdate }, + false + ]) as unknown as typeof KycCase.findOrCreate; + mockReconciliationLocks( + { status: VerificationStatus.Pending, update: customerUpdate }, + { status: VerificationStatus.Pending, update: caseUpdate } + ); + sequelize.transaction = mock(async callback => + callback({ LOCK: { UPDATE: "UPDATE" } } as never) + ) as unknown as typeof sequelize.transaction; + const getKycAttempts = mock(async () => ({ attempts: [] as any[] })); + const submit = mock(async () => ({ id: "accepted-attempt" })); + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts, + getUploadedDocument: mock(async (id: string) => documentResponse(id)), + submitKybLevel1: submit + }) as unknown as BrlaApiService + ); + + const failedResponse = createResponse(); + caseUpdate.mockImplementationOnce(async () => { + throw new Error("database unavailable"); + }); + await submitKybLevel1Api( + { body: validSubmission, query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, + failedResponse as any + ); + expect(failedResponse.statusCode).toBe(httpStatus.INTERNAL_SERVER_ERROR); + + getKycAttempts.mockImplementation(async () => ({ + attempts: [ + { + createdAt: "2026-08-12T10:00:00.000Z", + id: "accepted-attempt", + levelName: "kyb-level-1", + status: KycAttemptStatus.PENDING, + updatedAt: "2026-08-12T10:00:00.000Z" + } + ] + })); + const repairedResponse = createResponse(); + await submitKybLevel1Api( + { body: validSubmission, query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, + repairedResponse as any + ); + + expect(repairedResponse.statusCode).toBe(httpStatus.OK); + expect(repairedResponse.body).toEqual({ id: "accepted-attempt" }); + expect(getKycAttempts).toHaveBeenLastCalledWith("subaccount-1"); + expect(submit).toHaveBeenCalledTimes(1); + expect(customerUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ status: VerificationStatus.Pending, statusExternal: KycAttemptStatus.PENDING }), + expect.anything() + ); + expect(caseUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ + providerCaseId: "accepted-attempt", + status: VerificationStatus.Pending, + statusExternal: KycAttemptStatus.PENDING + }), + expect.anything() + ); + }); + + it("reconciles one processing attempt after the submit POST conflicts", async () => { + const customerUpdate = mockBusinessAccount(); + const caseUpdate = mock(async () => undefined); + KycCase.findOrCreate = mock(async () => [ + { id: "case-1", providerCaseId: null, update: caseUpdate }, + false + ]) as unknown as typeof KycCase.findOrCreate; + mockReconciliationLocks( + { status: VerificationStatus.Pending, update: customerUpdate }, + { status: VerificationStatus.Pending, update: caseUpdate } + ); + sequelize.transaction = mock(async callback => + callback({ LOCK: { UPDATE: "UPDATE" } } as never) + ) as unknown as typeof sequelize.transaction; + const getKycAttempts = mock() + .mockResolvedValueOnce({ attempts: [] }) + .mockResolvedValueOnce({ + attempts: [ + { + createdAt: "2026-08-12T10:00:00.000Z", + id: "conflicting-attempt", + levelName: "kyb-level-1", + status: KycAttemptStatus.PROCESSING, + updatedAt: "2026-08-12T10:01:00.000Z" + } + ] + }); + const submit = mock(async () => { + throw new BrlaApiError({ + endpoint: "/v2/kyc/new-level-1/api", + method: "POST", + responseBody: JSON.stringify({ message: "Existing attempt" }), + status: httpStatus.CONFLICT + }); + }); + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts, + 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(res.body).toEqual({ id: "conflicting-attempt" }); + expect(getKycAttempts).toHaveBeenCalledTimes(2); + expect(getKycAttempts).toHaveBeenNthCalledWith(1, "subaccount-1"); + expect(getKycAttempts).toHaveBeenNthCalledWith(2, "subaccount-1"); + expect(customerUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ status: VerificationStatus.InReview, statusExternal: KycAttemptStatus.PROCESSING }), + expect.anything() + ); + expect(caseUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ + providerCaseId: "conflicting-attempt", + status: VerificationStatus.InReview, + statusExternal: KycAttemptStatus.PROCESSING + }), + expect.anything() + ); + }); + + it("does not overwrite terminal state with a stale active-attempt response", async () => { + mockBusinessAccount(); + const customerUpdate = mock(async () => undefined); + const caseUpdate = mock(async () => undefined); + KycCase.findOrCreate = mock(async () => [ + { id: "case-1", providerCaseId: "attempt-1", update: caseUpdate }, + false + ]) as unknown as typeof KycCase.findOrCreate; + mockReconciliationLocks( + { status: VerificationStatus.Approved, update: customerUpdate }, + { status: VerificationStatus.Approved, update: caseUpdate } + ); + sequelize.transaction = mock(async callback => + callback({ LOCK: { UPDATE: "UPDATE" } } 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-12T10:00:00.000Z", + id: "attempt-1", + levelName: "kyb-level-1", + status: KycAttemptStatus.PROCESSING + } + ] + })), + 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(customerUpdate).not.toHaveBeenCalled(); + expect(caseUpdate).not.toHaveBeenCalled(); + expect(submit).not.toHaveBeenCalled(); + }); + + it("fails closed when Avenia reports multiple active KYB attempts", async () => { + mockBusinessAccount(); + KycCase.findOrCreate = mock(async () => { + throw new Error("Ambiguous attempts must not be bound"); + }) as unknown as typeof KycCase.findOrCreate; + const submit = mock(async () => ({ id: "duplicate-attempt" })); + const activeAttempt = (id: string, status: KycAttemptStatus) => ({ + createdAt: "2026-08-12T10:00:00.000Z", + id, + levelName: "kyb-level-1", + status, + updatedAt: "2026-08-12T10:00:00.000Z" + }); + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => ({ + attempts: [ + activeAttempt("pending-attempt", KycAttemptStatus.PENDING), + activeAttempt("processing-attempt", KycAttemptStatus.PROCESSING) + ] + })), + 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("propagates an unrelated provider submission failure without reconciliation", async () => { + mockBusinessAccount(); + KycCase.findOrCreate = mock(async () => [ + { id: "case-1", providerCaseId: null, update: mock(async () => undefined) }, + false + ]) as unknown as typeof KycCase.findOrCreate; + const getKycAttempts = mock(async () => ({ attempts: [] })); + const submitError = new BrlaApiError({ + endpoint: "/v2/kyc/new-level-1/api", + method: "POST", + responseBody: JSON.stringify({ message: "Service unavailable" }), + status: httpStatus.SERVICE_UNAVAILABLE + }); + const submit = mock(async () => { + throw submitError; + }); + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts, + 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.BAD_GATEWAY); + expect(res.body).toEqual({ error: "Avenia request failed" }); + expect(getKycAttempts).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index cc32cfece..ce48cb68e 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, @@ -16,11 +21,15 @@ import { BrlaGetUserRemainingLimitResponse, BrlaGetUserRequest, BrlaGetUserResponse, + BrlaImportKycTokenRequest, + BrlaImportKycTokenResponse, BrlaPostRecordInitialKycAttemptRequest, BrlaValidatePixKeyRequest, BrlaValidatePixKeyResponse, + DocumentUploadRequest, + DocumentUploadResponse, + FiatToken, isValidCnpj, - isValidCpf, KybAttemptStatusResponse, KybLevel1Response, KycAttemptResult, @@ -33,23 +42,44 @@ 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"; import ProviderCustomer, { VerificationStatus } from "../../models/providerCustomer.model"; +import QuoteTicket from "../../models/quoteTicket.model"; import { APIError } from "../errors/api-error"; -import { getEffectiveUserId } from "../middlewares/effectiveUser"; +import { getAuthenticatedProfileId, getEffectiveUserId } from "../middlewares/effectiveUser"; +import { assertQuoteOwnership } from "../middlewares/ownershipAuth"; import { accountTypeToCustomerType, + assertAveniaImportedTaxIdentity, customerTypeToAccountType, findAveniaCustomerBySubaccountId, findAveniaCustomerByTaxId, hashTaxReference, hydrateAveniaCompanyName, updateAveniaKycOutcome, + updateAveniaKycProgressForCustomer, upsertAveniaKycCase } from "../services/avenia/avenia-customer.service"; +import { + AVENIA_IDENTITY_DOCUMENT_TYPES, + assertAveniaHostedKybCanInitiate, + createAveniaUboOnce, + getOrCreateAveniaKybCase, + requireReadyAveniaDocument, + resolveOwnedAveniaBusinessAccount +} from "../services/avenia/avenia-kyb.service"; +import { + claimStandardAveniaKycMethod, + importAveniaKycToken, + mapAveniaKycAttemptStatus, + reconcileAveniaIndividualKycStatusMethod +} from "../services/avenia/avenia-kyc-import.service"; +import { submitStandardAveniaKyc } from "../services/avenia/avenia-standard-kyc.service"; import { enqueueVerificationNotification } from "../services/avenia/verification-notifications"; import { resolveAveniaAccountForUser } from "../services/avenia-account"; import { findCustomerEntityIdsForProfile, getOrCreateCustomerEntityForProfile } from "../services/customer-entity.service"; @@ -86,6 +116,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) { @@ -203,39 +243,18 @@ export const recordInitialKycAttempt = async ( ): Promise => { try { const { quoteId, taxId } = req.body; - const effectiveUserId = getEffectiveUserId(req); - if (!quoteId || !taxId) { res.status(httpStatus.BAD_REQUEST).json({ error: "Missing quoteId or taxId body parameter" }); return; } - const existing = await findAveniaCustomerByTaxId(taxId); - - // provider_customers rows always have an owner, so anonymous callers cannot persist a - // Consulted marker (the route requires auth in practice). - if (!existing && effectiveUserId) { - const accountType = isValidCnpj(taxId) - ? AveniaAccountType.COMPANY - : isValidCpf(taxId) - ? AveniaAccountType.INDIVIDUAL - : undefined; - - // Create the entry only if a valid taxId is provided. Otherwise we ignore the request. - if (accountType) { - const entity = await getOrCreateCustomerEntityForProfile(effectiveUserId); - const record = await ProviderCustomer.create({ - country: "BR", - customerEntityId: entity.id, - customerType: accountTypeToCustomerType(accountType), - provider: "avenia", - rail: "brl", - status: VerificationStatus.Started, - taxReference: normalizeTaxId(taxId), - taxReferenceHash: hashTaxReference(taxId) - }); - await upsertAveniaKycCase(record, VerificationStatus.Started); - } + // Keep the legacy preflight endpoint ownership-bound, but do not persist the asserted + // tax ID: quote ownership is not proof of CPF/CNPJ ownership. + await assertQuoteOwnership(req, quoteId); + const quote = await QuoteTicket.findByPk(quoteId); + if (quote?.inputCurrency !== FiatToken.BRL && quote?.outputCurrency !== FiatToken.BRL) { + res.status(httpStatus.BAD_REQUEST).json({ error: "quoteId does not reference a Brazil onboarding quote" }); + return; } res.status(httpStatus.OK).json({}); @@ -245,6 +264,40 @@ export const recordInitialKycAttempt = async ( } }; +export const importKycToken = async ( + req: Request, + res: Response +): Promise => { + try { + const actorProfileId = req.managedProfileContext?.actorProfileId ?? getAuthenticatedProfileId(req); + const subjectProfileId = getEffectiveUserId(req); + const idempotencyKey = req.get("Idempotency-Key"); + if (!actorProfileId || !subjectProfileId || !idempotencyKey) { + res.status(httpStatus.UNAUTHORIZED).json({ error: "Authentication is required" }); + return; + } + + const result = await importAveniaKycToken({ + actorProfileId, + expectedCustomerEntityId: req.managedProfileContext?.customerEntityId, + idempotencyKey, + importToken: req.body.importToken, + managedProfileId: req.managedProfileContext?.managedProfileId, + subjectProfileId + }); + res.status(httpStatus.ACCEPTED).json(result); + } catch (error) { + if (error instanceof APIError) { + res.status(error.status ?? httpStatus.INTERNAL_SERVER_ERROR).json({ error: error.message }); + return; + } + logger.error("Avenia KYC token import failed", { + errorType: error instanceof Error ? error.name : "UnknownError" + }); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ error: "Token import failed" }); + } +}; + export const getAveniaUserRemainingLimit = async ( req: Request, res: Response @@ -545,43 +598,111 @@ export const fetchSubaccountKycStatus = async ( // (mirrors the onboarding aggregation's lazy hydration so KYC-only callers recover too). await hydrateAveniaCompanyName(record); const brlaApiService = BrlaApiService.getInstance(); - const kycAttemptStatuses = await brlaApiService.getKycAttempts(subAccountId); - const kycAttemptStatus = kycAttemptStatuses.attempts[0]; // Get the latest attempt + const kycCases = await KycCase.findAll({ where: { provider: "avenia", providerCustomerId: record.id, type: "kyc" } }); + if (kycCases.length > 1) { + res.status(httpStatus.CONFLICT).json({ error: "Multiple Avenia KYC cases require reconciliation" }); + return; + } + if (!kycCases[0]) { + res.status(httpStatus.CONFLICT).json({ error: "The Avenia KYC case requires reconciliation" }); + return; + } + const reconciled = await reconcileAveniaIndividualKycStatusMethod(record.id, brlaApiService); + const kycCase = reconciled.kycCase; + if (kycCase.verificationMethod === "sumsub_share_token" && !kycCase.providerCaseId) { + res.status(httpStatus.CONFLICT).json({ error: "The imported Avenia KYC attempt requires reconciliation" }); + return; + } + if ( + !kycCase.providerCaseId && + (kycCase.verificationSubmission?.status === "submitted" || kycCase.verificationSubmission?.status === "ambiguous") + ) { + res.status(httpStatus.CONFLICT).json({ error: "The Avenia KYC submission requires reconciliation" }); + return; + } + const kycAttemptStatus = kycCase?.providerCaseId + ? (await brlaApiService.getVerificationAttemptStatus(kycCase.providerCaseId, subAccountId)).attempt + : (await brlaApiService.getKycAttempts(subAccountId)).attempts[0]; + if (kycCase?.providerCaseId && kycAttemptStatus?.id !== kycCase.providerCaseId) { + throw new APIError({ message: "Avenia returned a mismatched KYC attempt", status: httpStatus.BAD_GATEWAY }); + } if (!kycAttemptStatus) { const accountInfo = await brlaApiService.subaccountInfo(subAccountId); if (accountInfo?.accountInfo.identityStatus === "CONFIRMED") { + if (kycCase.verificationMethod === "sumsub_share_token") assertAveniaImportedTaxIdentity(record, accountInfo); + // Also try updating in case we missed the attempt + await updateAveniaKycOutcome(taxId, VerificationStatus.Approved, accountInfo.accountInfo.identityStatus, { + id: kycCase.id, + providerCaseId: kycCase.providerCaseId + }); res.status(httpStatus.OK).json({ level: "KYC_1", result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED, type: "KYC" }); - - // Also try updating in case we missed the attempt - await updateAveniaKycOutcome(taxId, VerificationStatus.Approved, accountInfo.accountInfo.identityStatus); return; } - await record.update({ status: VerificationStatus.Pending, statusExternal: null }); - await upsertAveniaKycCase(record, VerificationStatus.Pending, null); + await updateAveniaKycProgressForCustomer( + record, + { id: kycCase.id, providerCaseId: kycCase.providerCaseId }, + VerificationStatus.Pending, + null + ); res.status(httpStatus.NOT_FOUND).json({ error: "KYC attempt not found" }); return; } - // Update our internal status based on the KYC result. - if (kycAttemptStatus.result === KycAttemptResult.APPROVED) { - await updateAveniaKycOutcome(taxId, VerificationStatus.Approved, kycAttemptStatus.status); + if (kycCase?.verificationMethod === "sumsub_share_token") { + const importedStatus = mapAveniaKycAttemptStatus(kycAttemptStatus); + if (importedStatus === VerificationStatus.Approved || importedStatus === VerificationStatus.Rejected) { + if (importedStatus === VerificationStatus.Approved) { + assertAveniaImportedTaxIdentity(record, await brlaApiService.subaccountInfo(subAccountId)); + } + await updateAveniaKycOutcome(taxId, importedStatus, kycAttemptStatus.status, { + id: kycCase.id, + providerCaseId: kycCase.providerCaseId + }); + } else { + await updateAveniaKycProgressForCustomer( + record, + { id: kycCase.id, providerCaseId: kycCase.providerCaseId }, + importedStatus, + kycAttemptStatus.status + ); + } } - if (kycAttemptStatus.result === KycAttemptResult.REJECTED) { - await updateAveniaKycOutcome(taxId, VerificationStatus.Rejected, kycAttemptStatus.status); + // Update our internal status based on the normal KYC result. + else if ( + (kycAttemptStatus.result && kycAttemptStatus.status !== KycAttemptStatus.COMPLETED) || + (!kycAttemptStatus.result && kycAttemptStatus.status === KycAttemptStatus.COMPLETED) + ) { + throw new APIError({ message: "Avenia returned an inconsistent KYC attempt", status: httpStatus.BAD_GATEWAY }); + } else if (kycAttemptStatus.result === KycAttemptResult.APPROVED) { + await updateAveniaKycOutcome(taxId, VerificationStatus.Approved, kycAttemptStatus.status, { + id: kycCase.id, + providerCaseId: kycCase.providerCaseId + }); + } else if (kycAttemptStatus.result === KycAttemptResult.REJECTED) { + await updateAveniaKycOutcome(taxId, VerificationStatus.Rejected, kycAttemptStatus.status, { + id: kycCase.id, + providerCaseId: kycCase.providerCaseId + }); } // No result yet: mirror the in-flight attempt. This includes a `rejected` account whose // owner retries — the fresh attempt puts it back in review so the outcome poll can decide. - if (!kycAttemptStatus.result && record.status !== VerificationStatus.Approved) { + else if (!kycAttemptStatus.result) { const status = - kycAttemptStatus.status === KycAttemptStatus.EXPIRED ? VerificationStatus.Pending : VerificationStatus.InReview; - await record.update({ status, statusExternal: kycAttemptStatus.status }); - await upsertAveniaKycCase(record, status, kycAttemptStatus.status); + kycAttemptStatus.status === KycAttemptStatus.PENDING || kycAttemptStatus.status === KycAttemptStatus.EXPIRED + ? VerificationStatus.Pending + : VerificationStatus.InReview; + await updateAveniaKycProgressForCustomer( + record, + { id: kycCase.id, providerCaseId: kycCase.providerCaseId }, + status, + kycAttemptStatus.status + ); } res.status(httpStatus.OK).json({ @@ -661,6 +782,12 @@ export const getSelfieLivenessUrl = async ( const brlaApiService = BrlaApiService.getInstance(); + if (record.customerType !== "individual") { + res.status(httpStatus.BAD_REQUEST).json({ error: "Individual KYC requires an individual Avenia customer." }); + return; + } + await claimStandardAveniaKycMethod(record); + const selfieUrl = await brlaApiService.getDocumentUploadUrls( AveniaDocumentType.SELFIE_FROM_LIVENESS, false, @@ -731,9 +858,14 @@ export const getUploadUrls = async ( res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); return; } + if (record.customerType !== "individual") { + res.status(httpStatus.BAD_REQUEST).json({ error: "Individual KYC requires an individual Avenia customer." }); + return; + } const subAccountId = record.providerSubaccountId ?? ""; const brlaApiService = BrlaApiService.getInstance(); + await claimStandardAveniaKycMethod(record); const selfieUrl = await brlaApiService.getDocumentUploadUrls(AveniaDocumentType.SELFIE_FROM_LIVENESS, false, subAccountId); @@ -766,7 +898,6 @@ export const newKyc = async ( res: Response ): Promise => { try { - const brlaApiService = BrlaApiService.getInstance(); const subAccountId = req.body.subAccountId; if (!subAccountId) { @@ -780,22 +911,32 @@ export const newKyc = async ( return; } - const effectiveUserId = getEffectiveUserId(req); - if (!effectiveUserId) { + const actorProfileId = req.managedProfileContext?.actorProfileId ?? getAuthenticatedProfileId(req); + const subjectProfileId = getEffectiveUserId(req); + if (!actorProfileId || !subjectProfileId) { res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); return; } // Profile-level ownership. - const ownedEntityIds = await findCustomerEntityIdsForProfile(effectiveUserId); + const ownedEntityIds = await findCustomerEntityIdsForProfile(subjectProfileId); if (!ownedEntityIds.includes(record.customerEntityId)) { res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); return; } + if (record.customerType !== "individual") { + res.status(httpStatus.BAD_REQUEST).json({ error: "Individual KYC requires an individual Avenia customer." }); + return; + } - // Wait for previously uploaded documents to propagate before submitting KYC - await new Promise(resolve => setTimeout(resolve, 5000)); - await brlaApiService.getUploadedDocuments(subAccountId); - const response = await brlaApiService.submitKycLevel1(req.body); + const response = await submitStandardAveniaKyc({ + actorProfileId, + controllingManagerProfileId: req.managedProfileContext?.controllingManagerProfileId, + expectedCustomerEntityId: req.managedProfileContext?.customerEntityId, + managedProfileId: req.managedProfileContext?.managedProfileId, + payload: req.body, + providerCustomer: record, + subjectProfileId + }); res.status(httpStatus.OK).json(response); } catch (error) { @@ -803,6 +944,229 @@ 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); +} + +async function reconcileActiveAveniaKybAttempt( + brlaApiService: BrlaApiService, + record: ProviderCustomer, + subAccountId: string +): Promise { + const { attempts } = await brlaApiService.getKycAttempts(subAccountId); + const activeAttempts = attempts.filter( + attempt => + attempt.levelName === "kyb-level-1" && + (attempt.status === KycAttemptStatus.PENDING || attempt.status === KycAttemptStatus.PROCESSING) + ); + if (activeAttempts.length === 0) { + return null; + } + if (activeAttempts.length !== 1) { + throw new APIError({ message: "Multiple active KYB attempts found", status: httpStatus.CONFLICT }); + } + + const attempt = activeAttempts[0]; + const status = attempt.status === KycAttemptStatus.PENDING ? VerificationStatus.Pending : VerificationStatus.InReview; + const kycCase = await getOrCreateAveniaKybCase(record); + await sequelize.transaction(async transaction => { + const lockedRecord = await ProviderCustomer.findByPk(record.id, { lock: transaction.LOCK.UPDATE, transaction }); + const lockedCase = await KycCase.findByPk(kycCase.id, { lock: transaction.LOCK.UPDATE, transaction }); + if (!lockedRecord || !lockedCase) throw new Error("KYB state disappeared during reconciliation"); + if ( + [VerificationStatus.Approved, VerificationStatus.Rejected].includes(lockedRecord.status) || + [VerificationStatus.Approved, VerificationStatus.Rejected].includes(lockedCase.status) + ) { + throw new APIError({ message: "This company verification is already terminal", status: httpStatus.CONFLICT }); + } + await lockedRecord.update( + { + lastFailureReasons: [], + status, + statusExternal: attempt.status + }, + { transaction } + ); + await lockedCase.update( + { + approvedAt: null, + failureReasons: [], + providerCaseId: attempt.id, + rejectedAt: null, + status, + statusExternal: attempt.status, + submittedAt: new Date(attempt.createdAt) + }, + { transaction } + ); + }); + return { id: attempt.id }; +} + +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 createAveniaUboOnce(brlaApiService, record, 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(); + if (record.status === VerificationStatus.Approved) { + throw new APIError({ message: "This company is already approved", status: httpStatus.CONFLICT }); + } + const reconciledAttempt = await reconcileActiveAveniaKybAttempt(brlaApiService, record, subAccountId); + if (reconciledAttempt) { + res.status(httpStatus.OK).json(reconciledAttempt); + return; + } + const kycCase = await getOrCreateAveniaKybCase(record); + await Promise.all([ + requireReadyAveniaDocument(brlaApiService, subAccountId, req.body.certificateOfIncorporationDocumentId, [ + AveniaDocumentType.CERTIFICATE_OF_INCORPORATION + ]), + requireReadyAveniaDocument(brlaApiService, subAccountId, req.body.taxIdentificationDocumentId, [ + AveniaDocumentType.COMPANY_TAX_IDENTIFICATION_DOCUMENT + ]) + ]); + + let response: KycLevel1Response; + try { + response = await brlaApiService.submitKybLevel1(req.body, subAccountId); + } catch (error) { + if (!(error instanceof BrlaApiError) || error.status !== httpStatus.CONFLICT) { + throw error; + } + const reconciledConflict = await reconcileActiveAveniaKybAttempt(brlaApiService, record, subAccountId); + if (!reconciledConflict) { + throw error; + } + res.status(httpStatus.OK).json(reconciledConflict); + return; + } + + const now = new Date(); + await sequelize.transaction(async transaction => { + const lockedRecord = await ProviderCustomer.findByPk(record.id, { + lock: transaction.LOCK.UPDATE, + transaction + }); + const lockedCase = await KycCase.findByPk(kycCase.id, { lock: transaction.LOCK.UPDATE, transaction }); + if (!lockedRecord || !lockedCase || lockedCase.providerCustomerId !== lockedRecord.id) { + throw new Error("KYB state disappeared during submission"); + } + // Reconciliation already bound this response and may have advanced it to a newer state. + if (lockedCase.providerCaseId === response.id) return; + if (lockedCase.providerCaseId !== kycCase.providerCaseId) { + throw new APIError({ message: "The KYB attempt binding requires reconciliation", status: httpStatus.CONFLICT }); + } + + await lockedRecord.update( + { + lastFailureReasons: [], + status: VerificationStatus.Pending, + statusExternal: KycAttemptStatus.PENDING + }, + { transaction } + ); + await lockedCase.update( + { + approvedAt: null, + failureReasons: [], + providerCaseId: response.id, + rejectedAt: null, + status: VerificationStatus.Pending, + statusExternal: KycAttemptStatus.PENDING, + submittedAt: now + }, + { transaction } + ); + }); + res.status(httpStatus.OK).json(response); + } catch (error) { + handleApiError(error, res, "submitKybLevel1Api"); + } +}; + /** * Initiates KYB Level 1 verification process using the Web SDK * @@ -854,43 +1218,8 @@ export const initiateKybLevel1 = async ( return; } - const existingKybCase = await KycCase.findOne({ - where: { providerCustomerId: record.id, type: "kyb" } - }); - // 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 && - record.status !== VerificationStatus.Rejected && - existingKybCase.statusExternal !== KycAttemptStatus.EXPIRED && - existingKybCase.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) { - try { - const { attempt } = await brlaApiService.getKybAttemptStatus(existingKybCase.providerCaseId); - const decidedRejected = attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.REJECTED; - const resumable = - attempt.status === KycAttemptStatus.PENDING || attempt.status === KycAttemptStatus.EXPIRED || decidedRejected; - if (!resumable) { - res.status(httpStatus.CONFLICT).json({ error: "A KYB attempt is already in progress" }); - return; - } - } catch { - // Re-initiation is the only path back to the hosted steps; keep it available if the probe fails. - } - } - + await assertAveniaHostedKybCanInitiate(brlaApiService, record, subAccountId); const response = await brlaApiService.initiateKybLevel1(subAccountId); // 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 @@ -950,18 +1279,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 }); @@ -983,29 +1319,63 @@ 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 lockedRecord = await ProviderCustomer.findByPk(record.id, { + lock: transaction.LOCK.UPDATE, + transaction + }); + if (!lockedRecord) throw new Error("Avenia customer disappeared during KYB status persistence"); + 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 lockedRecord.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/controllers/onboarding-requirements.controller.test.ts b/apps/api/src/api/controllers/onboarding-requirements.controller.test.ts new file mode 100644 index 000000000..c1b52f74b --- /dev/null +++ b/apps/api/src/api/controllers/onboarding-requirements.controller.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, mock } from "bun:test"; +import type { Request, Response } from "express"; +import { getOnboardingRequirements } from "./onboarding.controller"; + +function createResponse() { + const response = { + body: undefined as unknown, + statusCode: 200, + json: mock((body: unknown) => { + response.body = body; + return response; + }), + status: mock((statusCode: number) => { + response.statusCode = statusCode; + return response; + }) + }; + return response; +} + +describe("getOnboardingRequirements", () => { + it("returns public requirements case-insensitively", () => { + const response = createResponse(); + + getOnboardingRequirements( + { query: { country: "br", customerType: "BUSINESS" } } as unknown as Request, + response as unknown as Response + ); + + expect(response.statusCode).toBe(200); + expect(response.body).toMatchObject({ + country: "BR", + customerType: "business", + flow: "avenia-br-business-level-1-api-kyb", + provider: "avenia" + }); + expect(response.body).not.toHaveProperty("fields"); + const steps = (response.body as { steps: Array<{ method?: string; operationId?: string }> }).steps; + expect(steps.map(step => step.operationId).filter(Boolean)).toContain("submitAveniaKybLevel1Api"); + expect(steps.some(step => step.method === "GET")).toBe(false); + }); + + it("rejects incomplete queries", () => { + const response = createResponse(); + + getOnboardingRequirements({ query: { country: "BR" } } as unknown as Request, response as unknown as Response); + + expect(response.statusCode).toBe(400); + expect(response.body).toMatchObject({ error: { code: "INVALID_ONBOARDING_REQUIREMENTS_QUERY" } }); + }); + + it("does not advertise unsupported provider flows", () => { + const response = createResponse(); + + getOnboardingRequirements( + { query: { country: "AR", customerType: "business" } } as unknown as Request, + response as unknown as Response + ); + + expect(response.statusCode).toBe(404); + expect(response.body).toMatchObject({ error: { code: "ONBOARDING_REQUIREMENTS_NOT_FOUND" } }); + }); + + it("leaves Monerium outside this discovery proposal", () => { + const response = createResponse(); + + getOnboardingRequirements( + { query: { country: "EU", customerType: "individual" } } as unknown as Request, + response as unknown as Response + ); + + expect(response.statusCode).toBe(404); + }); +}); diff --git a/apps/api/src/api/controllers/onboarding.controller.ts b/apps/api/src/api/controllers/onboarding.controller.ts index 6a7a793d2..3312b07da 100644 --- a/apps/api/src/api/controllers/onboarding.controller.ts +++ b/apps/api/src/api/controllers/onboarding.controller.ts @@ -1,4 +1,11 @@ -import { BrlaApiService, KycAttemptResult, KycAttemptStatus } from "@vortexfi/shared"; +import { + BrlaApiService, + getOnboardingRequirements as findOnboardingRequirements, + KycAttemptResult, + KycAttemptStatus, + ONBOARDING_REQUIREMENTS, + OnboardingRequirementsCountry +} from "@vortexfi/shared"; import { Request, Response } from "express"; import httpStatus from "http-status"; import logger from "../../config/logger"; @@ -10,7 +17,16 @@ import User from "../../models/user.model"; import { APIError } from "../errors/api-error"; import { getEffectiveUserId } from "../middlewares/effectiveUser"; import { refreshAlfredpayCustomerStatus } from "../services/alfredpay/alfredpay-customer.service"; -import { hydrateAveniaCompanyName } from "../services/avenia/avenia-customer.service"; +import { + assertAveniaImportedTaxIdentity, + hydrateAveniaCompanyName, + updateAveniaKycOutcomeForCustomer, + updateAveniaKycProgressForCustomer +} from "../services/avenia/avenia-customer.service"; +import { + mapAveniaKycAttemptStatus, + reconcileAveniaIndividualKycStatusMethod +} from "../services/avenia/avenia-kyc-import.service"; import { selectActiveCustomerEntity } from "../services/customer-entity.service"; import { getMoneriumStatus, MONERIUM_REAUTHENTICATION_REQUIRED } from "../services/monerium/monerium.service"; @@ -20,6 +36,48 @@ import { getMoneriumStatus, MONERIUM_REAUTHENTICATION_REQUIRED } from "../servic const PROVIDER_REFRESH_TTL_MS = 60_000; const lastProviderRefreshAt = new Map(); +/** GET /v1/onboarding/requirements - public metadata for an existing provider-specific flow. */ +export function getOnboardingRequirements(req: Request, res: Response): void { + const country = typeof req.query.country === "string" ? req.query.country.toUpperCase() : ""; + const customerType = typeof req.query.customerType === "string" ? req.query.customerType.toLowerCase() : ""; + + if (!country || (customerType !== "individual" && customerType !== "business")) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "INVALID_ONBOARDING_REQUIREMENTS_QUERY", + message: "country and customerType (individual or business) are required", + status: httpStatus.BAD_REQUEST + } + }); + return; + } + + if (!(country in ONBOARDING_REQUIREMENTS)) { + res.status(httpStatus.NOT_FOUND).json({ + error: { + code: "ONBOARDING_REQUIREMENTS_NOT_FOUND", + message: `No API-driven onboarding requirements are published for ${country} ${customerType}`, + status: httpStatus.NOT_FOUND + } + }); + return; + } + + const requirements = findOnboardingRequirements(country as OnboardingRequirementsCountry, customerType); + if (!requirements) { + res.status(httpStatus.NOT_FOUND).json({ + error: { + code: "ONBOARDING_REQUIREMENTS_NOT_FOUND", + message: `No API-driven onboarding requirements are published for ${country} ${customerType}`, + status: httpStatus.NOT_FOUND + } + }); + return; + } + + res.status(httpStatus.OK).json(requirements); +} + function shouldRefreshProviderStatus(customerId: string): boolean { const now = Date.now(); const last = lastProviderRefreshAt.get(customerId); @@ -155,8 +213,8 @@ export async function getOnboardingStatus(req: Request, res: Response): Promise< }) ); - // Avenia individual KYC: refresh from the latest attempt so an approval/rejection that lands - // after the wizard closed is reflected here — nothing else polls Avenia for individuals. + // Avenia individual KYC: bound cases poll their exact attempt. Legacy standard cases + // without a provider case id retain the list fallback until their next submission. await Promise.all( providerCustomers .filter( @@ -169,31 +227,63 @@ export async function getOnboardingStatus(req: Request, res: Response): Promise< shouldRefreshProviderStatus(customer.id) ) .map(async customer => { - const kycCase = kycCasesByProviderCustomer.get(customer.id); try { - const { attempts } = await BrlaApiService.getInstance().getKycAttempts(customer.providerSubaccountId as string); - const attempt = attempts[0]; + const brlaApiService = BrlaApiService.getInstance(); + const reconciled = await reconcileAveniaIndividualKycStatusMethod(customer.id, brlaApiService); + const kycCase = reconciled.kycCase; + if (kycCase.verificationMethod === "sumsub_share_token" && !kycCase.providerCaseId) return; + if ( + !kycCase.providerCaseId && + (kycCase.verificationSubmission?.status === "submitted" || kycCase.verificationSubmission?.status === "ambiguous") + ) + return; + const attempt = kycCase?.providerCaseId + ? ( + await brlaApiService.getVerificationAttemptStatus( + kycCase.providerCaseId, + customer.providerSubaccountId as string + ) + ).attempt + : (await brlaApiService.getKycAttempts(customer.providerSubaccountId as string)).attempts[0]; if (!attempt) return; + if (kycCase?.providerCaseId && attempt.id !== kycCase.providerCaseId) return; + if (attempt.status === KycAttemptStatus.COMPLETED && !attempt.result) { + throw new Error("Avenia returned an invalid KYC attempt state"); + } const approved = attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.APPROVED; - const rejected = attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.REJECTED; - // A PENDING or EXPIRED attempt is one the user never finished (livecheck not completed) — - // not a rejection: keep it pending so the dashboard offers Continue, mirroring - // fetchSubaccountKycStatus. Only an Avenia decision is terminal. - const status = approved - ? VerificationStatus.Approved - : rejected - ? VerificationStatus.Rejected - : attempt.status === KycAttemptStatus.PENDING || attempt.status === KycAttemptStatus.EXPIRED + const rejected = + (kycCase?.verificationMethod === "sumsub_share_token" && + mapAveniaKycAttemptStatus(attempt) === VerificationStatus.Rejected) || + (attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.REJECTED); + if (approved || rejected) { + if (approved && kycCase.verificationMethod === "sumsub_share_token") { + assertAveniaImportedTaxIdentity( + customer, + await brlaApiService.subaccountInfo(customer.providerSubaccountId as string) + ); + } + const refreshed = await updateAveniaKycOutcomeForCustomer( + customer, + approved ? VerificationStatus.Approved : VerificationStatus.Rejected, + attempt.status, + { id: kycCase.id, providerCaseId: kycCase.providerCaseId } + ); + customer.set("status", refreshed.status); + customer.set("statusExternal", refreshed.statusExternal); + } else { + const progressStatus = + attempt.status === KycAttemptStatus.PENDING || attempt.status === KycAttemptStatus.EXPIRED ? VerificationStatus.Pending : VerificationStatus.InReview; - const lifecycle = { - ...(approved ? { approvedAt: new Date(), rejectedAt: null } : {}), - ...(rejected ? { approvedAt: null, rejectedAt: new Date() } : {}) - }; - await Promise.all([ - customer.update({ status, statusExternal: attempt.status }), - kycCase?.update({ status, statusExternal: attempt.status, ...lifecycle }) - ]); + const refreshed = await updateAveniaKycProgressForCustomer( + customer, + { id: kycCase.id, providerCaseId: kycCase.providerCaseId }, + progressStatus, + attempt.status + ); + customer.set("status", refreshed.status); + customer.set("statusExternal", refreshed.statusExternal); + } } catch { // Status aggregation remains available while Avenia is temporarily unavailable. } diff --git a/apps/api/src/api/controllers/quote.controller.ts b/apps/api/src/api/controllers/quote.controller.ts index bd4910956..e4f327d2d 100644 --- a/apps/api/src/api/controllers/quote.controller.ts +++ b/apps/api/src/api/controllers/quote.controller.ts @@ -50,6 +50,7 @@ export const createQuote = async ( const quote = await quoteService.createQuote({ apiCredentialId: req.credential?.credentialId, apiKey: publicApiKey, + controllingManagerProfileId: req.managedProfileContext?.controllingManagerProfileId, from, inputAmount, inputCurrency, @@ -112,6 +113,7 @@ export const createBestQuote = async ( const quote = await quoteService.createBestQuote({ apiCredentialId: req.credential?.credentialId, apiKey: publicApiKey, + controllingManagerProfileId: req.managedProfileContext?.controllingManagerProfileId, countryCode, from, inputAmount, diff --git a/apps/api/src/api/middlewares/alfredpay.middleware.test.ts b/apps/api/src/api/middlewares/alfredpay.middleware.test.ts new file mode 100644 index 000000000..baaea2da8 --- /dev/null +++ b/apps/api/src/api/middlewares/alfredpay.middleware.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, mock } from "bun:test"; +import type { NextFunction, Request, Response } from "express"; +import { validateAlfredpayCustomerType } from "./alfredpay.middleware"; + +function run(type: unknown) { + const next = mock(() => undefined); + const json = mock(() => undefined); + const status = mock(() => ({ json })); + + validateAlfredpayCustomerType({ query: type === undefined ? {} : { type } } as unknown as Request, { status } as unknown as Response, next as NextFunction); + + return { json, next, status }; +} + +describe("validateAlfredpayCustomerType", () => { + it("accepts an omitted or supported customer type", () => { + expect(run(undefined).next).toHaveBeenCalledTimes(1); + expect(run("INDIVIDUAL").next).toHaveBeenCalledTimes(1); + expect(run("BUSINESS").next).toHaveBeenCalledTimes(1); + }); + + it("rejects unknown and repeated customer types", () => { + for (const type of ["BUSINES", "business", ["BUSINESS", "INDIVIDUAL"]]) { + const { json, next, status } = run(type); + expect(next).not.toHaveBeenCalled(); + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: "Invalid type: expected INDIVIDUAL or BUSINESS" }); + } + }); +}); diff --git a/apps/api/src/api/middlewares/alfredpay.middleware.ts b/apps/api/src/api/middlewares/alfredpay.middleware.ts index a407c113c..9e2db2071 100644 --- a/apps/api/src/api/middlewares/alfredpay.middleware.ts +++ b/apps/api/src/api/middlewares/alfredpay.middleware.ts @@ -1,4 +1,4 @@ -import { AlfredPayCountry } from "@vortexfi/shared"; +import { AlfredPayCountry, AlfredpayCustomerType } from "@vortexfi/shared"; import { NextFunction, Request, Response } from "express"; export const validateResultCountry = (req: Request, res: Response, next: NextFunction) => { @@ -23,3 +23,18 @@ export const validateResultCountry = (req: Request, res: Response, next: NextFun next(); }; + +export const validateAlfredpayCustomerType = (req: Request, res: Response, next: NextFunction) => { + const type = req.query.type; + + if (type === undefined) { + next(); + return; + } + + if (typeof type !== "string" || !Object.values(AlfredpayCustomerType).includes(type as AlfredpayCustomerType)) { + return res.status(400).json({ error: "Invalid type: expected INDIVIDUAL or BUSINESS" }); + } + + next(); +}; diff --git a/apps/api/src/api/middlewares/dualAuth.test.ts b/apps/api/src/api/middlewares/dualAuth.test.ts index d652da4df..9e7b833a0 100644 --- a/apps/api/src/api/middlewares/dualAuth.test.ts +++ b/apps/api/src/api/middlewares/dualAuth.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; import type { NextFunction, Request, Response } from "express"; import { AccessTokenVerificationError, SupabaseAuthService } from "../services/auth"; -import { requirePartnerOrUserAuth } from "./dualAuth"; +import { requirePartnerOrUserAuth, requireProfileBoundPrincipal } from "./dualAuth"; function request(authorization: string): Request { return { @@ -75,3 +75,38 @@ describe("dual authentication Bearer verification", () => { expect(next).toHaveBeenCalledTimes(1); }); }); + +describe("requireProfileBoundPrincipal", () => { + it("accepts a Bearer-authenticated profile", () => { + const next = mock(() => undefined) as NextFunction; + + requireProfileBoundPrincipal({ userId: "user-1" } as Request, response(), next); + + expect(next).toHaveBeenCalledTimes(1); + }); + + it("accepts a profile-bound secret credential", () => { + const next = mock(() => undefined) as NextFunction; + + requireProfileBoundPrincipal({ authenticatedCredentialProfileId: "profile-1" } as Request, response(), next); + + expect(next).toHaveBeenCalledTimes(1); + }); + + it("rejects an ownerless secret credential before downstream validation", () => { + const res = response(); + const next = mock(() => undefined) as NextFunction; + + requireProfileBoundPrincipal({ credential: { profileId: null } } as unknown as Request, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: { + code: "AUTHENTICATION_REQUIRED", + message: "A profile-bound secret key or Bearer token is required.", + status: 401 + } + }); + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/api/middlewares/dualAuth.ts b/apps/api/src/api/middlewares/dualAuth.ts index 9a2f6de26..0f48faa7d 100644 --- a/apps/api/src/api/middlewares/dualAuth.ts +++ b/apps/api/src/api/middlewares/dualAuth.ts @@ -33,6 +33,22 @@ export function optionalPartnerOrUserAuth() { return dualAuthHandler({ requireCredentials: false }); } +/** Requires dual auth to have resolved a concrete profile principal. */ +export function requireProfileBoundPrincipal(req: Request, res: Response, next: NextFunction): void { + if (req.userId || req.authenticatedCredentialProfileId) { + next(); + return; + } + + res.status(401).json({ + error: { + code: "AUTHENTICATION_REQUIRED", + message: "A profile-bound secret key or Bearer token is required.", + status: 401 + } + }); +} + function dualAuthHandler({ requireCredentials }: { requireCredentials: boolean }) { return async (req: Request, res: Response, next: NextFunction) => { try { diff --git a/apps/api/src/api/middlewares/error.test.ts b/apps/api/src/api/middlewares/error.test.ts index f5e0ec0e3..702c583a7 100644 --- a/apps/api/src/api/middlewares/error.test.ts +++ b/apps/api/src/api/middlewares/error.test.ts @@ -1,7 +1,7 @@ -import {describe, expect, it} from "bun:test"; +import { describe, expect, it } from "bun:test"; import httpStatus from "http-status"; -import {APIError} from "../errors/api-error"; -import {handler} from "./error"; +import { APIError } from "../errors/api-error"; +import { converter, handler } from "./error"; function createMockResponse() { const response = { @@ -58,4 +58,18 @@ describe("error middleware", () => { message: "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon." }); }); + + it.each([ + ["entity.parse.failed", httpStatus.BAD_REQUEST, "Invalid JSON payload"], + ["entity.too.large", httpStatus.REQUEST_ENTITY_TOO_LARGE, "Request body too large"] + ])("preserves public body-parser %s errors", (type, status, message) => { + const response = createMockResponse(); + const error = Object.assign(new SyntaxError("unsafe parser detail"), { status, type }); + + converter(error, undefined as never, response as never, undefined as never); + + expect(response.statusCode).toBe(status); + expect(response.body).toMatchObject({ code: status, message, statusCode: status, type }); + expect(JSON.stringify(response.body)).not.toContain("unsafe parser detail"); + }); }); diff --git a/apps/api/src/api/middlewares/error.ts b/apps/api/src/api/middlewares/error.ts index 49fd1058f..8c99a0143 100644 --- a/apps/api/src/api/middlewares/error.ts +++ b/apps/api/src/api/middlewares/error.ts @@ -68,6 +68,14 @@ export const converter = (err: Error | ValidationError, req: Request, res: Respo stack: err.stack, status: err.status }); + } else if (isBodyParserError(err)) { + convertedError = new APIError({ + isPublic: true, + message: err.type === "entity.parse.failed" ? "Invalid JSON payload" : "Request body too large", + stack: err.stack, + status: err.status, + type: err.type + }); } else if (!(err instanceof APIError)) { convertedError = new APIError({ message: err.message, @@ -81,6 +89,14 @@ export const converter = (err: Error | ValidationError, req: Request, res: Respo return handler(convertedError, req, res, next); }; +function isBodyParserError(err: Error | ValidationError): err is Error & { status: 400 | 413; type: string } { + const bodyParserError = err as Error & { status?: number; type?: string }; + return ( + (bodyParserError.type === "entity.parse.failed" && bodyParserError.status === httpStatus.BAD_REQUEST) || + (bodyParserError.type === "entity.too.large" && bodyParserError.status === httpStatus.REQUEST_ENTITY_TOO_LARGE) + ); +} + /** * Catch 404 and forward to error handler * @public diff --git a/apps/api/src/api/middlewares/validators.test.ts b/apps/api/src/api/middlewares/validators.test.ts index d8c647b2b..75ffc89c8 100644 --- a/apps/api/src/api/middlewares/validators.test.ts +++ b/apps/api/src/api/middlewares/validators.test.ts @@ -1,9 +1,16 @@ -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 { + validateAveniaKycTokenImport, + validateAveniaKybDocument, + validateAveniaKybLevel1, + validateAveniaKybUbo, + validateCreateBestQuoteInput, + validateKycSubmission +} from "./validators"; function buildRes() { const res: Partial & { statusCode?: number; body?: unknown } = {}; @@ -94,6 +101,143 @@ 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("validateAveniaKycTokenImport", () => { + function validate(body: unknown, idempotencyKey?: string) { + const req = { + body, + get: mock((header: string) => (header === "Idempotency-Key" ? idempotencyKey : undefined)) + } as unknown as Request; + const res = buildRes(); + const next = mock(() => undefined) as unknown as NextFunction; + + validateAveniaKycTokenImport(req, res, next); + + return { next, res }; + } + + it("requires a visible-ASCII idempotency key", () => { + for (const key of [undefined, "bad key", "é", "x".repeat(129)]) { + const { next, res } = validate({ consentAttested: true, importToken: "token" }, key); + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(next).not.toHaveBeenCalled(); + } + }); + + it("rejects unknown body fields", () => { + const { next, res } = validate({ consentAttested: true, importToken: "token", tokenType: "sumsub" }, "request-1"); + + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(next).not.toHaveBeenCalled(); + }); + + it("requires explicit true consent", () => { + const { next, res } = validate({ consentAttested: false, importToken: "token" }, "request-1"); + + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(next).not.toHaveBeenCalled(); + }); + + it("measures the import token limit in UTF-8 bytes", () => { + const { next, res } = validate({ consentAttested: true, importToken: "é".repeat(513) }, "request-1"); + + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(next).not.toHaveBeenCalled(); + }); + + it("accepts the strict token import shape", () => { + const { next, res } = validate({ consentAttested: true, importToken: "é".repeat(512) }, "request-1"); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBeUndefined(); + }); +}); + 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..42806d3b2 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"; @@ -362,6 +367,32 @@ export const validateSubaccountCreation: RequestHandler = (req, res, next) => { next(); }; +export const validateAveniaKycTokenImport: RequestHandler = (req, res, next) => { + const idempotencyKey = req.get("Idempotency-Key"); + const body = req.body as Record | undefined; + if (!idempotencyKey || !/^[\x21-\x7e]{1,128}$/.test(idempotencyKey)) { + res.status(httpStatus.BAD_REQUEST).json({ error: "Idempotency-Key must contain 1 to 128 visible ASCII characters" }); + return; + } + if (!body || Object.keys(body).some(key => key !== "importToken" && key !== "consentAttested")) { + res.status(httpStatus.BAD_REQUEST).json({ error: "Invalid request body" }); + return; + } + if ( + typeof body.importToken !== "string" || + body.importToken.length === 0 || + Buffer.byteLength(body.importToken, "utf8") > 1024 + ) { + res.status(httpStatus.BAD_REQUEST).json({ error: "importToken must contain between 1 and 1024 bytes" }); + return; + } + if (body.consentAttested !== true) { + res.status(httpStatus.BAD_REQUEST).json({ error: "consentAttested must be true" }); + return; + } + next(); +}; + const validateSupportedFiatCurrency = ( rampType: RampDirection, inputCurrency: unknown, @@ -625,3 +656,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/observability/apiClientEvent.service.test.ts b/apps/api/src/api/observability/apiClientEvent.service.test.ts index 1d45d779d..5c5e2c9f9 100644 --- a/apps/api/src/api/observability/apiClientEvent.service.test.ts +++ b/apps/api/src/api/observability/apiClientEvent.service.test.ts @@ -16,8 +16,10 @@ describe("sanitizeApiClientEvent", () => { metadata: { apiKey: "pk_live_secret", endpoint: "/v1/ramp/start", + importToken: "opaque-share-token", nested: { unsafe: true }, - taxId: "12345678900" + taxId: "12345678900", + tokenFingerprint: "fingerprint" }, operation: "ramp_start", partnerName: "p".repeat(150), diff --git a/apps/api/src/api/observability/apiClientEvent.service.ts b/apps/api/src/api/observability/apiClientEvent.service.ts index 32be8c975..987f12fbb 100644 --- a/apps/api/src/api/observability/apiClientEvent.service.ts +++ b/apps/api/src/api/observability/apiClientEvent.service.ts @@ -13,6 +13,8 @@ const SENSITIVE_METADATA_KEYS = new Set([ "depositqrcode", "ephemeralaccounts", "ibanpaymentdata", + "import_token", + "importtoken", "pixdestination", "presignedtxs", "rawbody", @@ -21,6 +23,7 @@ const SENSITIVE_METADATA_KEYS = new Set([ "signingaccounts", "taxid", "token", + "tokenfingerprint", "walletaddress", "x-api-key" ]); diff --git a/apps/api/src/api/routes/v1/alfredpay.route.ts b/apps/api/src/api/routes/v1/alfredpay.route.ts index 7498ee13a..6e952e9a9 100644 --- a/apps/api/src/api/routes/v1/alfredpay.route.ts +++ b/apps/api/src/api/routes/v1/alfredpay.route.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import multer from "multer"; import { AlfredpayController } from "../../controllers/alfredpay.controller"; -import { validateResultCountry } from "../../middlewares/alfredpay.middleware"; +import { validateAlfredpayCustomerType, validateResultCountry } from "../../middlewares/alfredpay.middleware"; import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; import { authorizeManagedProfile } from "../../middlewares/managedProfileAuth"; import { @@ -17,6 +17,7 @@ router.get( "/alfredpayStatus", requirePartnerOrUserAuth(), validateResultCountry, + validateAlfredpayCustomerType, authorizeManagedProfile(), AlfredpayController.alfredpayStatus ); diff --git a/apps/api/src/api/routes/v1/brla-kyc-import.route.test.ts b/apps/api/src/api/routes/v1/brla-kyc-import.route.test.ts new file mode 100644 index 000000000..07c75a824 --- /dev/null +++ b/apps/api/src/api/routes/v1/brla-kyc-import.route.test.ts @@ -0,0 +1,123 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, mock, spyOn } from "bun:test"; +import express from "express"; +import { converter, handler, notFound } from "../../middlewares/error"; +import { SupabaseAuthService } from "../../services/auth"; +import brlaKycImportRoutes from "./brla-kyc-import.route"; + +describe("POST /v1/brla/kyc/import-token", () => { + let server: ReturnType; + let url: string; + + beforeAll(() => { + const app = express(); + app.use("/v1/brla/kyc/import-token", brlaKycImportRoutes); + app.use(converter); + app.use(notFound); + app.use(handler); + server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Could not bind test server"); + url = `http://127.0.0.1:${address.port}/v1/brla/kyc/import-token`; + }); + + afterEach(() => mock.restore()); + afterAll(() => server.close()); + + it("authenticates before parsing malformed JSON", async () => { + const response = await postMalformedJson(); + + expect(response.status).toBe(401); + expect(await response.json()).toMatchObject({ error: { code: "AUTHENTICATION_REQUIRED", status: 401 } }); + }); + + it("returns 400 for malformed JSON after authentication", async () => { + authenticate(); + + const response = await postMalformedJson({ Authorization: "Bearer valid-token" }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + code: 400, + message: "Invalid JSON payload", + statusCode: 400, + type: "entity.parse.failed" + }); + }); + + it("returns 413 for an authenticated body over the route limit", async () => { + authenticate(); + + const response = await fetch(url, { + body: JSON.stringify({ consentAttested: true, importToken: "a".repeat(17 * 1024) }), + headers: { Authorization: "Bearer valid-token", "Content-Type": "application/json" }, + method: "POST" + }); + + expect(response.status).toBe(413); + expect(await response.json()).toEqual({ + code: 413, + message: "Request body too large", + statusCode: 413, + type: "entity.too.large" + }); + }); + + it("continues to run the existing validator for parsed JSON", async () => { + authenticate(); + + const response = await fetch(url, { + body: JSON.stringify({ consentAttested: true, importToken: "token" }), + headers: { Authorization: "Bearer valid-token", "Content-Type": "application/json" }, + method: "POST" + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Idempotency-Key must contain 1 to 128 visible ASCII characters" }); + }); + + it("allows JSON escaping overhead for a token at the validator limit", async () => { + authenticate(); + + const response = await fetch(url, { + body: JSON.stringify({ consentAttested: false, importToken: "\u0001".repeat(1024) }), + headers: { + Authorization: "Bearer valid-token", + "Content-Type": "application/json", + "Idempotency-Key": "request-1" + }, + method: "POST" + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "consentAttested must be true" }); + }); + + it("does not parse non-JSON request bodies", async () => { + authenticate(); + + const response = await fetch(url, { + body: "importToken=token&consentAttested=true", + headers: { + Authorization: "Bearer valid-token", + "Content-Type": "application/x-www-form-urlencoded", + "Idempotency-Key": "request-1" + }, + method: "POST" + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "importToken must contain between 1 and 1024 bytes" }); + }); + + function authenticate(): void { + spyOn(SupabaseAuthService, "verifyToken").mockResolvedValue({ user_id: "user-1", valid: true }); + } + + function postMalformedJson(headers: Record = {}): Promise { + return fetch(url, { + body: "{", + headers: { ...headers, "Content-Type": "application/json" }, + method: "POST" + }); + } +}); diff --git a/apps/api/src/api/routes/v1/brla-kyc-import.route.ts b/apps/api/src/api/routes/v1/brla-kyc-import.route.ts new file mode 100644 index 000000000..adc78883f --- /dev/null +++ b/apps/api/src/api/routes/v1/brla-kyc-import.route.ts @@ -0,0 +1,21 @@ +import bodyParser from "body-parser"; +import { RequestHandler, Router } from "express"; +import * as brlaController from "../../controllers/brla.controller"; +import { requirePartnerOrUserAuth, requireProfileBoundPrincipal } from "../../middlewares/dualAuth"; +import { authorizeManagedProfile, rejectDirectManagedCredential } from "../../middlewares/managedProfileAuth"; +import { validateAveniaKycTokenImport } from "../../middlewares/validators"; + +const router: Router = Router({ mergeParams: true }); + +router.post( + "/", + requirePartnerOrUserAuth(), + requireProfileBoundPrincipal, + rejectDirectManagedCredential, + authorizeManagedProfile({ corridor: "BR", customerType: "individual" }), + bodyParser.json({ limit: "16kb" }), + validateAveniaKycTokenImport, + brlaController.importKycToken as unknown as RequestHandler +); + +export default router; diff --git a/apps/api/src/api/routes/v1/brla.route.ts b/apps/api/src/api/routes/v1/brla.route.ts index 11c588d43..fb2eb6ca1 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 }); @@ -57,19 +63,56 @@ router .post( validateStartKyc2, requirePartnerOrUserAuth(), - authorizeManagedProfile({ corridor: "BR" }), + authorizeManagedProfile({ corridor: "BR", customerType: "individual" }), brlaController.getUploadUrls ); -router.route("/newKyc").post(requirePartnerOrUserAuth(), authorizeManagedProfile({ corridor: "BR" }), brlaController.newKyc); +router + .route("/newKyc") + .post( + requirePartnerOrUserAuth(), + authorizeManagedProfile({ corridor: "BR", customerType: "individual" }), + brlaController.newKyc + ); 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/routes/v1/onboarding-requirements.route.test.ts b/apps/api/src/api/routes/v1/onboarding-requirements.route.test.ts new file mode 100644 index 000000000..abe39f0f8 --- /dev/null +++ b/apps/api/src/api/routes/v1/onboarding-requirements.route.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "bun:test"; +import express from "express"; +import type { AddressInfo } from "node:net"; +import onboardingRoutes from "./onboarding.route"; + +describe("GET /v1/onboarding/requirements", () => { + it("serves public discovery metadata without authentication", async () => { + const app = express(); + app.use("/v1/onboarding", onboardingRoutes); + const server = app.listen(0); + await new Promise(resolve => server.once("listening", resolve)); + + try { + const { port } = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${port}/v1/onboarding/requirements?country=MX&customerType=business`); + + expect(response.status).toBe(200); + const body = (await response.json()) as Record; + expect(body).toMatchObject({ + country: "MX", + customerType: "business", + flow: "alfredpay-mx-business-api-kyb", + provider: "alfredpay" + }); + expect(body).not.toHaveProperty("fields"); + } finally { + server.close(); + } + }); +}); diff --git a/apps/api/src/api/routes/v1/onboarding.route.ts b/apps/api/src/api/routes/v1/onboarding.route.ts index cd6c31922..3f4d5b13a 100644 --- a/apps/api/src/api/routes/v1/onboarding.route.ts +++ b/apps/api/src/api/routes/v1/onboarding.route.ts @@ -1,11 +1,13 @@ import { Request, Response, Router } from "express"; -import { getOnboardingStatus, putActiveEntity } from "../../controllers/onboarding.controller"; +import { getOnboardingRequirements, getOnboardingStatus, putActiveEntity } from "../../controllers/onboarding.controller"; import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; import { authorizeManagedProfile, rejectManagedProfileSelection } from "../../middlewares/managedProfileAuth"; import { requireAuth } from "../../middlewares/supabaseAuth"; const router: Router = Router({ mergeParams: true }); +router.get("/requirements", getOnboardingRequirements); + /** * GET /v1/onboarding/status * Aggregated per-entity provider/KYC onboarding status for the authenticated profile. diff --git a/apps/api/src/api/services/avenia/avenia-customer.service.test.ts b/apps/api/src/api/services/avenia/avenia-customer.service.test.ts new file mode 100644 index 000000000..bcb1f81cc --- /dev/null +++ b/apps/api/src/api/services/avenia/avenia-customer.service.test.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import sequelize from "../../../config/database"; +import KycCase from "../../../models/kycCase.model"; +import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; +import { updateAveniaKycOutcomeForCustomer, updateAveniaKycProgressForCustomer } from "./avenia-customer.service"; + +const originalTransaction = sequelize.transaction; +const originalCustomerFindByPk = ProviderCustomer.findByPk; +const originalCaseFindAll = KycCase.findAll; + +afterEach(() => { + sequelize.transaction = originalTransaction; + ProviderCustomer.findByPk = originalCustomerFindByPk; + KycCase.findAll = originalCaseFindAll; +}); + +function setup( + customerStatus: VerificationStatus, + caseStatus: VerificationStatus, + options: { + caseUpdateFails?: boolean; + customerStatusExternal?: string; + caseStatusExternal?: string; + providerCaseId?: string | null; + verificationSubmission?: KycCase["verificationSubmission"]; + } = {} +) { + const customer = { + customerEntityId: "entity-1", + customerType: "individual", + id: "customer-1", + status: customerStatus, + statusExternal: options.customerStatusExternal ?? "customer-status", + update: mock(async (values: Partial) => { + Object.assign(customer, values); + return customer; + }) + } as unknown as ProviderCustomer; + const kycCase = { + approvedAt: caseStatus === VerificationStatus.Approved ? new Date("2026-01-01") : null, + id: "case-1", + providerCaseId: options.providerCaseId === undefined ? "attempt-1" : options.providerCaseId, + rejectedAt: caseStatus === VerificationStatus.Rejected ? new Date("2026-01-02") : null, + status: caseStatus, + statusExternal: options.caseStatusExternal ?? "case-status", + verificationSubmission: options.verificationSubmission ?? null, + update: mock(async (values: Partial) => { + if (options.caseUpdateFails) throw new Error("case update failed"); + Object.assign(kycCase, values); + return kycCase; + }) + } as unknown as KycCase; + + ProviderCustomer.findByPk = mock(async () => customer) as unknown as typeof ProviderCustomer.findByPk; + KycCase.findAll = mock(async () => [kycCase]) as unknown as typeof KycCase.findAll; + sequelize.transaction = mock(async callback => { + const customerSnapshot = { status: customer.status, statusExternal: customer.statusExternal }; + const caseSnapshot = { + approvedAt: kycCase.approvedAt, + rejectedAt: kycCase.rejectedAt, + status: kycCase.status, + statusExternal: kycCase.statusExternal + }; + try { + return await callback({ LOCK: { UPDATE: "UPDATE" } } as never); + } catch (error) { + Object.assign(customer, customerSnapshot); + Object.assign(kycCase, caseSnapshot); + throw error; + } + }) as unknown as typeof sequelize.transaction; + + return { customer, kycCase }; +} + +describe("updateAveniaKycOutcomeForCustomer", () => { + it("rolls back the customer update when the canonical case update fails", async () => { + const { customer, kycCase } = setup(VerificationStatus.InReview, VerificationStatus.InReview, { + caseUpdateFails: true + }); + + await expect( + updateAveniaKycOutcomeForCustomer(customer, VerificationStatus.Approved, "COMPLETED", { + id: "case-1", + providerCaseId: "attempt-1" + }) + ).rejects.toThrow("case update failed"); + + expect(customer.status).toBe(VerificationStatus.InReview); + expect(kycCase.status).toBe(VerificationStatus.InReview); + }); + + it("repairs a stale case when the customer is already approved", async () => { + const { customer, kycCase } = setup(VerificationStatus.Approved, VerificationStatus.InReview, { + customerStatusExternal: "COMPLETED" + }); + + await updateAveniaKycOutcomeForCustomer(customer, VerificationStatus.Approved, "COMPLETED", { + id: "case-1", + providerCaseId: "attempt-1" + }); + + expect(customer.status).toBe(VerificationStatus.Approved); + expect(kycCase.status).toBe(VerificationStatus.Approved); + expect(kycCase.statusExternal).toBe("COMPLETED"); + expect(kycCase.approvedAt).toBeInstanceOf(Date); + expect(kycCase.rejectedAt).toBeNull(); + }); + + it("does not downgrade either row for a stale rejection after approval", async () => { + const { customer, kycCase } = setup(VerificationStatus.Approved, VerificationStatus.Approved, { + customerStatusExternal: "COMPLETED", + caseStatusExternal: "COMPLETED" + }); + + await updateAveniaKycOutcomeForCustomer(customer, VerificationStatus.Rejected, "REJECTED", { + id: "case-1", + providerCaseId: "attempt-1" + }); + + expect(customer.status).toBe(VerificationStatus.Approved); + expect(customer.statusExternal).toBe("COMPLETED"); + expect(kycCase.status).toBe(VerificationStatus.Approved); + expect(kycCase.statusExternal).toBe("COMPLETED"); + expect(kycCase.rejectedAt).toBeNull(); + }); + + it("fails closed when multiple cases are linked to the customer", async () => { + const { customer, kycCase } = setup(VerificationStatus.InReview, VerificationStatus.InReview); + KycCase.findAll = mock(async () => [kycCase, { ...kycCase, id: "case-2" } as KycCase]) as unknown as typeof KycCase.findAll; + + await expect( + updateAveniaKycOutcomeForCustomer(customer, VerificationStatus.Approved, "COMPLETED", { + id: "case-1", + providerCaseId: "attempt-1" + }) + ).rejects.toThrow("binding requires reconciliation"); + + expect(customer.status).toBe(VerificationStatus.InReview); + }); + + it("rejects a stale unbound outcome after a submission becomes ambiguous", async () => { + const { customer } = setup(VerificationStatus.InReview, VerificationStatus.InReview, { + providerCaseId: null, + verificationSubmission: { + actorProfileId: "user-1", + attemptBaselineIds: [], + status: "ambiguous", + subjectProfileId: "user-1" + } + }); + + await expect( + updateAveniaKycOutcomeForCustomer(customer, VerificationStatus.Approved, "COMPLETED", { + id: "case-1", + providerCaseId: null + }) + ).rejects.toThrow("binding requires reconciliation"); + expect(customer.status).toBe(VerificationStatus.InReview); + }); +}); + +describe("updateAveniaKycProgressForCustomer", () => { + it("locks the provider before the exact case and preserves a rejection from a stale processing poll", async () => { + const { customer, kycCase } = setup(VerificationStatus.Rejected, VerificationStatus.Rejected, { + caseStatusExternal: "COMPLETED", + customerStatusExternal: "COMPLETED" + }); + const lockOrder: string[] = []; + ProviderCustomer.findByPk = mock(async () => { + lockOrder.push("provider"); + return customer; + }) as unknown as typeof ProviderCustomer.findByPk; + KycCase.findAll = mock(async options => { + lockOrder.push("case"); + expect(options.where).toEqual({ id: "case-1", providerCaseId: "attempt-1", providerCustomerId: "customer-1" }); + return [kycCase]; + }) as unknown as typeof KycCase.findAll; + + const refreshed = await updateAveniaKycProgressForCustomer( + customer, + { id: "case-1", providerCaseId: "attempt-1" }, + VerificationStatus.InReview, + "PROCESSING" + ); + + expect(lockOrder).toEqual(["provider", "case"]); + expect(refreshed.status).toBe(VerificationStatus.Rejected); + expect(refreshed.statusExternal).toBe("COMPLETED"); + expect(kycCase.status).toBe(VerificationStatus.Rejected); + expect(kycCase.statusExternal).toBe("COMPLETED"); + }); + + it("fails closed when the expected attempt is no longer the current case binding", async () => { + const { customer } = setup(VerificationStatus.InReview, VerificationStatus.InReview); + KycCase.findAll = mock(async () => []) as unknown as typeof KycCase.findAll; + + await expect( + updateAveniaKycProgressForCustomer( + customer, + { id: "case-1", providerCaseId: "attempt-old" }, + VerificationStatus.Pending, + "PENDING" + ) + ).rejects.toThrow("binding requires reconciliation"); + + expect(customer.status).toBe(VerificationStatus.InReview); + }); + + it("rejects stale unbound progress after a submission becomes submitted", async () => { + const { customer } = setup(VerificationStatus.InReview, VerificationStatus.InReview, { + providerCaseId: null, + verificationSubmission: { + actorProfileId: "user-1", + attemptBaselineIds: [], + status: "submitted", + subjectProfileId: "user-1" + } + }); + + await expect( + updateAveniaKycProgressForCustomer( + customer, + { id: "case-1", providerCaseId: null }, + VerificationStatus.Pending, + "PENDING" + ) + ).rejects.toThrow("binding requires reconciliation"); + expect(customer.status).toBe(VerificationStatus.InReview); + }); +}); 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 97bfd6daa..3e87ac862 100644 --- a/apps/api/src/api/services/avenia/avenia-customer.service.ts +++ b/apps/api/src/api/services/avenia/avenia-customer.service.ts @@ -1,6 +1,7 @@ import { AveniaAccountType, BrlaApiService, normalizeTaxId } from "@vortexfi/shared"; import crypto from "crypto"; import type { Transaction } from "sequelize"; +import sequelize from "../../../config/database"; import logger from "../../../config/logger"; import CustomerEntity from "../../../models/customerEntity.model"; import KycCase from "../../../models/kycCase.model"; @@ -10,6 +11,16 @@ export function hashTaxReference(taxId: string): string { return crypto.createHash("sha256").update(normalizeTaxId(taxId), "utf8").digest("hex"); } +export function assertAveniaImportedTaxIdentity( + customer: ProviderCustomer, + account: Awaited> +): void { + const taxId = account?.accountInfo.taxId; + if (typeof taxId === "string" && taxId.trim() && hashTaxReference(taxId) !== customer.taxReferenceHash) { + throw new Error("The imported Avenia KYC identity does not match the canonical customer"); + } +} + export function maskTaxReference(taxId: string): string { const normalized = normalizeTaxId(taxId); return "*".repeat(Math.max(normalized.length - 4, 0)) + normalized.slice(-4); @@ -80,7 +91,12 @@ export async function upsertAveniaKycCase( where: { providerCustomerId: record.id } }); if (existing) { - const values = { ...(providerCaseId ? { providerCaseId } : {}), status, statusExternal, ...lifecycle }; + const values = { + ...(providerCaseId ? { providerCaseId } : {}), + status, + statusExternal, + ...lifecycle + }; await (transaction ? existing.update(values, { transaction }) : existing.update(values)); return; } @@ -104,22 +120,130 @@ export async function upsertAveniaKycCase( * is terminal — an Approved account is never downgraded by a stale attempt read — but a * `rejected` account follows a successful retried attempt to Approved (the legacy * `WHERE internal_status = 'Requested'` guard left it stuck in `Rejected`, so the user's - * approved KYC never became ramp-ready). Repeated polls of an unchanged outcome no-op. + * approved KYC never became ramp-ready). Repeated polls also reconcile either canonical row. */ export async function updateAveniaKycOutcome( taxId: string, outcome: VerificationStatus.Approved | VerificationStatus.Rejected, - statusExternal: string + statusExternal: string, + expectedCase: { id: string; providerCaseId: string | null } ): Promise { const record = await findAveniaCustomerByTaxId(taxId); - if (!record || record.status === VerificationStatus.Approved) { - return; - } - if (record.status === outcome && record.statusExternal === statusExternal) { - return; - } - await record.update({ status: outcome, statusExternal }); - await upsertAveniaKycCase(record, outcome, statusExternal); + if (!record) return; + await updateAveniaKycOutcomeForCustomer(record, outcome, statusExternal, expectedCase); +} + +export async function updateAveniaKycOutcomeForCustomer( + record: ProviderCustomer, + outcome: VerificationStatus.Approved | VerificationStatus.Rejected, + statusExternal: string, + expectedCase: { id: string; providerCaseId: string | null } +): Promise { + return sequelize.transaction(async transaction => { + const lockedRecord = await ProviderCustomer.findByPk(record.id, { + lock: transaction.LOCK.UPDATE, + transaction + }); + if (!lockedRecord) throw new Error("Avenia customer disappeared during KYC outcome persistence"); + + const cases = await KycCase.findAll({ + lock: transaction.LOCK.UPDATE, + transaction, + where: { id: expectedCase.id, providerCaseId: expectedCase.providerCaseId, providerCustomerId: lockedRecord.id } + }); + if (cases.length !== 1) throw new Error("The Avenia KYC case binding requires reconciliation"); + const kycCase = cases[0]; + if ( + expectedCase.providerCaseId === null && + (kycCase.verificationSubmission?.status === "submitted" || kycCase.verificationSubmission?.status === "ambiguous") + ) { + throw new Error("The Avenia KYC case binding requires reconciliation"); + } + + const approved = + outcome === VerificationStatus.Approved || + lockedRecord.status === VerificationStatus.Approved || + kycCase.status === VerificationStatus.Approved; + const status = approved ? VerificationStatus.Approved : VerificationStatus.Rejected; + const effectiveStatusExternal = + outcome === VerificationStatus.Approved || !approved + ? statusExternal + : lockedRecord.status === VerificationStatus.Approved + ? lockedRecord.statusExternal + : kycCase.statusExternal; + const now = new Date(); + + await lockedRecord.update({ status, statusExternal: effectiveStatusExternal }, { transaction }); + await kycCase.update( + { + approvedAt: approved ? (kycCase.approvedAt ?? now) : null, + rejectedAt: approved ? null : (kycCase.rejectedAt ?? now), + status, + statusExternal: effectiveStatusExternal + }, + { transaction } + ); + return lockedRecord; + }); +} + +export async function updateAveniaKycProgressForCustomer( + record: ProviderCustomer, + expectedCase: { id: string; providerCaseId: string | null }, + status: VerificationStatus.Pending | VerificationStatus.InReview, + statusExternal: string | null +): Promise { + return sequelize.transaction(async transaction => { + const lockedRecord = await ProviderCustomer.findByPk(record.id, { + lock: transaction.LOCK.UPDATE, + transaction + }); + if (!lockedRecord) throw new Error("Avenia customer disappeared during KYC progress persistence"); + + const cases = await KycCase.findAll({ + lock: transaction.LOCK.UPDATE, + transaction, + where: { + id: expectedCase.id, + providerCaseId: expectedCase.providerCaseId, + providerCustomerId: lockedRecord.id + } + }); + if (cases.length !== 1) throw new Error("The Avenia KYC case binding requires reconciliation"); + const kycCase = cases[0]; + if ( + expectedCase.providerCaseId === null && + (kycCase.verificationSubmission?.status === "submitted" || kycCase.verificationSubmission?.status === "ambiguous") + ) { + throw new Error("The Avenia KYC case binding requires reconciliation"); + } + + const terminalStatus = [lockedRecord.status, kycCase.status].includes(VerificationStatus.Approved) + ? VerificationStatus.Approved + : [lockedRecord.status, kycCase.status].includes(VerificationStatus.Rejected) + ? VerificationStatus.Rejected + : null; + if (terminalStatus) { + const terminalStatusExternal = + lockedRecord.status === terminalStatus ? lockedRecord.statusExternal : kycCase.statusExternal; + const now = new Date(); + await lockedRecord.update({ status: terminalStatus, statusExternal: terminalStatusExternal }, { transaction }); + await kycCase.update( + { + approvedAt: terminalStatus === VerificationStatus.Approved ? (kycCase.approvedAt ?? now) : null, + rejectedAt: terminalStatus === VerificationStatus.Rejected ? (kycCase.rejectedAt ?? now) : null, + status: terminalStatus, + statusExternal: terminalStatusExternal + }, + { transaction } + ); + return lockedRecord; + } + + await lockedRecord.update({ status, statusExternal }, { transaction }); + await kycCase.update({ status, statusExternal }, { transaction }); + return lockedRecord; + }); } /** diff --git a/apps/api/src/api/services/avenia/avenia-kyb.service.test.ts b/apps/api/src/api/services/avenia/avenia-kyb.service.test.ts new file mode 100644 index 000000000..a99307a76 --- /dev/null +++ b/apps/api/src/api/services/avenia/avenia-kyb.service.test.ts @@ -0,0 +1,143 @@ +import { BrlaApiError, BrlaApiService } from "@vortexfi/shared"; +import { afterEach, describe, expect, it, mock } from "bun:test"; +import sequelize from "../../../config/database"; +import KycCase from "../../../models/kycCase.model"; +import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; +import { createAveniaUboOnce, getOrCreateAveniaKybCase } from "./avenia-kyb.service"; + +const originalFindOrCreate = KycCase.findOrCreate; +const originalFindByPk = KycCase.findByPk; +const originalFindAll = KycCase.findAll; +const originalCreate = KycCase.create; +const originalProviderFindByPk = ProviderCustomer.findByPk; +const originalTransaction = sequelize.transaction; + +afterEach(() => { + KycCase.findOrCreate = originalFindOrCreate; + KycCase.findByPk = originalFindByPk; + KycCase.findAll = originalFindAll; + KycCase.create = originalCreate; + ProviderCustomer.findByPk = originalProviderFindByPk; + sequelize.transaction = originalTransaction; +}); + +describe("createAveniaUboOnce", () => { + const account = { + customerEntityId: "entity-1", + id: "provider-customer-1", + status: VerificationStatus.Pending, + statusExternal: null + } as ProviderCustomer; + const payload = { + city: "Sao Paulo", + country: "BRA", + countryOfTaxId: "BRA", + dateOfBirth: "1990-01-01", + documentCountry: "BRA", + fullName: "Test Owner", + percentageOfOwnership: "100", + state: "SP", + streetLine1: "Test Street 1", + taxIdNumber: "12345678901", + uploadedIdentificationId: "identity-1", + zipCode: "01000-000" + }; + + function mockCase(initialSubmissions: KycCase["uboSubmissions"] = {}) { + const kycCase = { + id: "case-1", + uboSubmissions: initialSubmissions, + update: mock(async (values: Partial) => { + Object.assign(kycCase, values); + }) + } as unknown as KycCase; + ProviderCustomer.findByPk = mock(async () => account) as unknown as typeof ProviderCustomer.findByPk; + KycCase.findAll = mock(async () => [kycCase]) as unknown as typeof KycCase.findAll; + KycCase.findByPk = mock(async () => kycCase) as unknown as typeof KycCase.findByPk; + sequelize.transaction = mock(async callback => + callback({ LOCK: { UPDATE: "UPDATE" } } as never) + ) as unknown as typeof sequelize.transaction; + return kycCase; + } + + it("returns the confirmed provider UBO without sending it again", async () => { + const kycCase = mockCase(); + const createUbo = mock(async () => ({ id: "ubo-1" })); + const service = { createUbo } as unknown as BrlaApiService; + + expect(await createAveniaUboOnce(service, account, payload, "subaccount-1")).toEqual({ id: "ubo-1" }); + expect(await createAveniaUboOnce(service, account, payload, "subaccount-1")).toEqual({ id: "ubo-1" }); + + expect(createUbo).toHaveBeenCalledTimes(1); + expect(Object.values(kycCase.uboSubmissions)[0]).toMatchObject({ providerUboId: "ubo-1", status: "confirmed" }); + }); + + it("quarantines an ambiguous provider outcome and blocks retry", async () => { + mockCase(); + const createUbo = mock(async () => { + throw new BrlaApiError({ endpoint: "/v2/kyb/ubos", method: "POST", responseBody: "timeout", status: 0 }); + }); + const service = { createUbo } as unknown as BrlaApiService; + + await expect(createAveniaUboOnce(service, account, payload, "subaccount-1")).rejects.toBeInstanceOf(BrlaApiError); + await expect(createAveniaUboOnce(service, account, payload, "subaccount-1")).rejects.toMatchObject({ status: 409 }); + expect(createUbo).toHaveBeenCalledTimes(1); + }); + + it("allows retry after a deterministic provider rejection", async () => { + mockCase(); + const createUbo = mock() + .mockRejectedValueOnce( + new BrlaApiError({ endpoint: "/v2/kyb/ubos", method: "POST", responseBody: "invalid", status: 400 }) + ) + .mockResolvedValueOnce({ id: "ubo-1" }); + const service = { createUbo } as unknown as BrlaApiService; + + await expect(createAveniaUboOnce(service, account, payload, "subaccount-1")).rejects.toBeInstanceOf(BrlaApiError); + expect(await createAveniaUboOnce(service, account, payload, "subaccount-1")).toEqual({ id: "ubo-1" }); + expect(createUbo).toHaveBeenCalledTimes(2); + }); +}); + +describe("getOrCreateAveniaKybCase", () => { + it("coalesces concurrent creation for one provider customer", async () => { + let resolveCreation: ((value: [KycCase, boolean]) => void) | undefined; + const pendingCreation = new Promise<[KycCase, boolean]>(resolve => { + resolveCreation = resolve; + }); + const kycCase = { id: "case-1" } as KycCase; + const findOrCreate = mock(() => pendingCreation); + KycCase.findOrCreate = findOrCreate as typeof KycCase.findOrCreate; + const account = { + customerEntityId: "entity-1", + id: "provider-customer-1", + status: VerificationStatus.Pending, + statusExternal: null + } as ProviderCustomer; + + const first = getOrCreateAveniaKybCase(account); + const second = getOrCreateAveniaKybCase(account); + resolveCreation?.([kycCase, true]); + + expect(await Promise.all([first, second])).toEqual([kycCase, kycCase]); + expect(findOrCreate).toHaveBeenCalledTimes(1); + }); + + it("allows a retry after creation fails", async () => { + const kycCase = { id: "case-1" } as KycCase; + const findOrCreate = mock() + .mockRejectedValueOnce(new Error("database unavailable")) + .mockResolvedValueOnce([kycCase, true]); + KycCase.findOrCreate = findOrCreate as typeof KycCase.findOrCreate; + const account = { + customerEntityId: "entity-1", + id: "provider-customer-1", + status: VerificationStatus.Pending, + statusExternal: null + } as ProviderCustomer; + + await expect(getOrCreateAveniaKybCase(account)).rejects.toThrow("database unavailable"); + expect(await getOrCreateAveniaKybCase(account)).toBe(kycCase); + expect(findOrCreate).toHaveBeenCalledTimes(2); + }); +}); 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..af8facb14 --- /dev/null +++ b/apps/api/src/api/services/avenia/avenia-kyb.service.ts @@ -0,0 +1,245 @@ +import { + AveniaDocument, + AveniaDocumentType, + AveniaUboPayload, + AveniaUboResponse, + BRLA_PRIVATE_KEY, + BrlaApiError, + BrlaApiService, + KycAttemptResult, + KycAttemptStatus +} from "@vortexfi/shared"; +import crypto from "crypto"; +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"; + +const kybCaseCreations = new Map>(); + +function hashUboValue(value: unknown): string { + const canonicalize = (input: unknown): unknown => { + if (Array.isArray(input)) return input.map(canonicalize); + if (input && typeof input === "object") { + return Object.fromEntries( + Object.entries(input as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, canonicalize(nested)]) + ); + } + return input; + }; + return crypto + .createHmac("sha256", BRLA_PRIVATE_KEY) + .update(JSON.stringify(canonicalize(value)), "utf8") + .digest("hex"); +} + +function uboSubmissionKey(subAccountId: string, payload: AveniaUboPayload): string { + return hashUboValue([subAccountId, payload.countryOfTaxId, payload.taxIdNumber]); +} + +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 inFlight = kybCaseCreations.get(record.id); + if (inFlight) return inFlight; + + const creation = KycCase.findOrCreate({ + defaults: { + customerEntityId: record.customerEntityId, + level: "level_1", + provider: "avenia", + status: record.status, + statusExternal: record.statusExternal, + type: "kyb" + }, + where: { providerCustomerId: record.id } + }).then(([kycCase]) => kycCase); + kybCaseCreations.set(record.id, creation); + try { + return await creation; + } finally { + kybCaseCreations.delete(record.id); + } +} + +export async function createAveniaUboOnce( + brlaApiService: BrlaApiService, + record: ProviderCustomer, + payload: AveniaUboPayload, + subAccountId: string +): Promise { + const key = uboSubmissionKey(subAccountId, payload); + const payloadFingerprint = hashUboValue(payload); + let kycCaseId: string | undefined; + const existing = await sequelize.transaction(async transaction => { + const lockedRecord = await ProviderCustomer.findByPk(record.id, { lock: transaction.LOCK.UPDATE, transaction }); + if (!lockedRecord) throw new Error("Avenia customer disappeared during UBO creation"); + const cases = await KycCase.findAll({ + lock: transaction.LOCK.UPDATE, + transaction, + where: { providerCustomerId: record.id } + }); + if (cases.length > 1) { + throw new APIError({ message: "Multiple KYB cases require reconciliation", status: httpStatus.CONFLICT }); + } + const lockedCase = + cases[0] ?? + (await KycCase.create( + { + customerEntityId: record.customerEntityId, + level: "level_1", + provider: "avenia", + providerCustomerId: record.id, + status: record.status, + statusExternal: record.statusExternal, + type: "kyb" + }, + { transaction } + )); + kycCaseId = lockedCase.id; + const submission = lockedCase.uboSubmissions[key]; + if (submission?.status === "confirmed" && submission.providerUboId) { + if (submission.payloadFingerprint !== payloadFingerprint) { + throw new APIError({ message: "This UBO already exists with different details", status: httpStatus.CONFLICT }); + } + return { id: submission.providerUboId }; + } + if (submission?.status === "prepared" || submission?.status === "ambiguous") { + throw new APIError({ + message: "The previous UBO submission outcome requires reconciliation", + status: httpStatus.CONFLICT + }); + } + await lockedCase.update( + { + uboSubmissions: { + ...lockedCase.uboSubmissions, + [key]: { + attemptedAt: new Date().toISOString(), + payloadFingerprint, + status: "prepared" + } + } + }, + { transaction } + ); + return null; + }); + if (existing) return existing; + if (!kycCaseId) throw new Error("KYB case was not selected during UBO creation"); + + try { + const response = await brlaApiService.createUbo(payload, subAccountId); + await updateUboSubmission(kycCaseId, key, { + confirmedAt: new Date().toISOString(), + providerUboId: response.id, + status: "confirmed" + }); + return response; + } catch (error) { + const deterministicFailure = + error instanceof BrlaApiError && error.status >= 400 && error.status < 500 && ![408, 409, 429].includes(error.status); + await updateUboSubmission(kycCaseId, key, { + ...(error instanceof BrlaApiError ? { httpStatus: error.status } : {}), + status: deterministicFailure ? "failed" : "ambiguous" + }); + throw error; + } +} + +async function updateUboSubmission( + kycCaseId: string, + key: string, + update: Partial +): Promise { + await sequelize.transaction(async transaction => { + const kycCase = await KycCase.findByPk(kycCaseId, { lock: transaction.LOCK.UPDATE, transaction }); + if (!kycCase) throw new Error("KYB case disappeared during UBO creation"); + const submission = kycCase.uboSubmissions[key]; + if (!submission) throw new Error("KYB UBO submission state disappeared"); + await kycCase.update( + { uboSubmissions: { ...kycCase.uboSubmissions, [key]: { ...submission, ...update } } }, + { transaction } + ); + }); +} + +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 assertAveniaHostedKybCanInitiate( + brlaApiService: BrlaApiService, + record: ProviderCustomer, + subAccountId: string +): Promise { + if (record.status === VerificationStatus.Approved) { + throw new APIError({ message: "This company is already approved", status: httpStatus.CONFLICT }); + } + const { attempts } = await brlaApiService.getKycAttempts(subAccountId); + const hasApprovedKybAttempt = attempts.some( + attempt => + attempt.levelName === "kyb-level-1" && + attempt.status === KycAttemptStatus.COMPLETED && + attempt.result === KycAttemptResult.APPROVED + ); + if (hasApprovedKybAttempt) { + throw new APIError({ message: "This company is already approved", status: httpStatus.CONFLICT }); + } + const hasProcessingKybAttempt = attempts.some( + attempt => attempt.levelName === "kyb-level-1" && attempt.status === KycAttemptStatus.PROCESSING + ); + if (hasProcessingKybAttempt) { + throw new APIError({ + message: "A KYB attempt 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/api/services/avenia/avenia-kyc-import.service.test.ts b/apps/api/src/api/services/avenia/avenia-kyc-import.service.test.ts new file mode 100644 index 000000000..eb148105f --- /dev/null +++ b/apps/api/src/api/services/avenia/avenia-kyc-import.service.test.ts @@ -0,0 +1,243 @@ +import { BrlaApiError, BrlaApiService } from "@vortexfi/shared"; +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { createHash } from "node:crypto"; +import sequelize from "../../../config/database"; +import CustomerEntity from "../../../models/customerEntity.model"; +import KycCase from "../../../models/kycCase.model"; +import ManagedProfile from "../../../models/managedProfile.model"; +import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; +import User from "../../../models/user.model"; +import { + claimStandardAveniaKycMethod, + importAveniaKycToken, + mapAveniaKycAttemptStatus, + reconcileAveniaIndividualKycStatusMethod +} from "./avenia-kyc-import.service"; + +const originals = { + caseFindAll: KycCase.findAll, + caseFindByPk: KycCase.findByPk, + customerFindAll: ProviderCustomer.findAll, + customerFindByPk: ProviderCustomer.findByPk, + entityFindByPk: CustomerEntity.findByPk, + entityFindOne: CustomerEntity.findOne, + getInstance: BrlaApiService.getInstance, + managerFindByPk: ManagedProfileManager.findByPk, + relationshipFindByPk: ManagedProfile.findByPk, + transaction: sequelize.transaction, + userFindByPk: User.findByPk +}; + +interface HarnessOptions { + approveDuringImport?: boolean; + attempts?: Array<{ createdAt: string; id: string; levelName: string }>; + baseline?: Array<{ createdAt: string; id: string; levelName: string }>; + boundAttemptIds?: string[]; + importToken?: () => Promise<{ id: string; message: string }>; + verificationMethod?: KycCase["verificationMethod"]; +} + +function harness(options: HarnessOptions = {}) { + const lockOrder: string[] = []; + const providerCustomer = { + customerEntityId: "entity-1", + customerType: "individual", + id: "provider-1", + provider: "avenia", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.InReview, + update: mock(async (values: object) => Object.assign(providerCustomer, values)) + } as unknown as ProviderCustomer; + const kycCase = { + customerEntityId: "entity-1", + id: "case-1", + provider: "avenia", + providerCaseId: null, + providerCustomerId: "provider-1", + status: VerificationStatus.InReview, + submittedAt: null, + type: "kyc", + update: mock(async (values: object) => Object.assign(kycCase, values)), + verificationMethod: options.verificationMethod ?? null, + verificationSubmission: null + } as unknown as KycCase; + let attemptCalls = 0; + const providerImport = mock(async () => { + const result = await (options.importToken ?? (async () => ({ id: "attempt-1", message: "processing" })))(); + if (options.approveDuringImport) { + providerCustomer.status = VerificationStatus.Approved; + kycCase.status = VerificationStatus.Approved; + } + return result; + }); + const getKycAttempts = mock(async () => { + attemptCalls += 1; + return { attempts: attemptCalls === 1 ? (options.baseline ?? []) : (options.attempts ?? []) }; + }); + const getUploadedDocuments = mock(async () => ({ documents: [] })); + BrlaApiService.getInstance = mock( + () => ({ getKycAttempts, getUploadedDocuments, importKycToken: providerImport }) as unknown as BrlaApiService + ); + User.findByPk = mock(async () => ({ activeCustomerEntityId: "entity-1", kind: "authenticated" })) as never; + CustomerEntity.findOne = mock(async () => ({ id: "entity-1", profileId: "subject-1", status: "active", type: "individual" })) as never; + CustomerEntity.findByPk = mock(async () => ({ id: "entity-1", profileId: "subject-1", status: "active", type: "individual" })) as never; + ProviderCustomer.findAll = mock(async () => [providerCustomer]) as never; + ProviderCustomer.findByPk = mock(async (_id: string, query?: { lock?: unknown }) => { + if (query?.lock) lockOrder.push("customer"); + return providerCustomer; + }) as never; + KycCase.findAll = mock(async (query?: { attributes?: string[] }) => + query?.attributes ? (options.boundAttemptIds ?? []).map(providerCaseId => ({ providerCaseId })) : [kycCase] + ) as never; + KycCase.findByPk = mock(async (_id: string, query?: { lock?: unknown }) => { + if (query?.lock) lockOrder.push("case"); + return kycCase; + }) as never; + ManagedProfileManager.findByPk = mock(async () => ({ + allowedCorridors: ["BR"], + allowedCustomerTypes: null, + isActive: true + })) as never; + ManagedProfile.findByPk = mock(async () => ({ + managerProfileId: "manager-1", + profileId: "subject-1", + status: "active" + })) as never; + sequelize.transaction = mock(async callback => callback({ LOCK: { UPDATE: "UPDATE" } } as never)) as never; + return { getKycAttempts, getUploadedDocuments, kycCase, lockOrder, providerCustomer, providerImport }; +} + +const request = { + actorProfileId: "subject-1", + idempotencyKey: "request-key-1", + importToken: "secret-share-token", + subjectProfileId: "subject-1" +}; + +afterEach(() => { + KycCase.findAll = originals.caseFindAll; + KycCase.findByPk = originals.caseFindByPk; + ProviderCustomer.findAll = originals.customerFindAll; + ProviderCustomer.findByPk = originals.customerFindByPk; + CustomerEntity.findByPk = originals.entityFindByPk; + CustomerEntity.findOne = originals.entityFindOne; + BrlaApiService.getInstance = originals.getInstance; + ManagedProfileManager.findByPk = originals.managerFindByPk; + ManagedProfile.findByPk = originals.relationshipFindByPk; + sequelize.transaction = originals.transaction; + User.findByPk = originals.userFindByPk; +}); + +describe("importAveniaKycToken", () => { + it("stores only fingerprints, binds the exact attempt, and replays only the same confirmed key", async () => { + const state = harness(); + expect(await importAveniaKycToken(request)).toEqual({ attemptId: "attempt-1", status: "pending" }); + expect(await importAveniaKycToken(request)).toEqual({ attemptId: "attempt-1", status: "pending" }); + expect(state.providerImport).toHaveBeenCalledTimes(1); + expect(state.kycCase).toMatchObject({ + providerCaseId: "attempt-1", + submittedAt: expect.any(Date), + verificationMethod: "sumsub_share_token", + verificationSubmission: { + attemptBaselineIds: [], + consentAttestations: [ + { + actorProfileId: "subject-1", + attestedAt: expect.any(String), + policyVersion: "sumsub-share-v1", + subjectProfileId: "subject-1" + } + ], + status: "confirmed", + tokenFingerprint: createHash("sha256").update(request.importToken, "utf8").digest("hex") + } + }); + expect(JSON.stringify(state.kycCase.verificationSubmission)).not.toContain(request.importToken); + expect(state.lockOrder.slice(-2)).toEqual(["customer", "case"]); + await expect(importAveniaKycToken({ ...request, idempotencyKey: "another-key" })).rejects.toMatchObject({ status: 409 }); + await expect(importAveniaKycToken({ ...request, importToken: "changed-token" })).rejects.toMatchObject({ status: 409 }); + }); + + it("never reposts an ambiguous claim and reconciles only a unique nonbaseline, unbound attempt", async () => { + const now = new Date().toISOString(); + const state = harness({ + attempts: [ + { createdAt: now, id: "baseline", levelName: "sumsub-token-old" }, + { createdAt: now, id: "bound", levelName: "sumsub-token-other" }, + { createdAt: now, id: "reconciled", levelName: "sumsub-token-current" } + ], + baseline: [{ createdAt: now, id: "baseline", levelName: "sumsub-token-old" }], + boundAttemptIds: ["bound"], + importToken: async () => { + throw new Error("timeout"); + } + }); + await expect(importAveniaKycToken(request)).rejects.toMatchObject({ status: 502 }); + expect(state.kycCase.verificationSubmission).toMatchObject({ attemptBaselineIds: ["baseline"], status: "ambiguous" }); + expect(await importAveniaKycToken(request)).toEqual({ attemptId: "reconciled", status: "pending" }); + expect(state.providerImport).toHaveBeenCalledTimes(1); + }); + + it("treats only 401 as failed and requires a new key before another POST", async () => { + let calls = 0; + const state = harness({ + importToken: async () => { + calls += 1; + if (calls === 1) { + throw new BrlaApiError({ endpoint: "/import", method: "POST", responseBody: "omitted", status: 401 }); + } + return { id: "attempt-2", message: "processing" }; + } + }); + await expect(importAveniaKycToken(request)).rejects.toMatchObject({ status: 412 }); + await expect(importAveniaKycToken(request)).rejects.toMatchObject({ status: 409 }); + expect(await importAveniaKycToken({ ...request, idempotencyKey: "request-key-2" })).toEqual({ + attemptId: "attempt-2", + status: "pending" + }); + expect(state.providerImport).toHaveBeenCalledTimes(2); + expect(state.kycCase.verificationSubmission).toMatchObject({ + consentAttestations: [ + { actorProfileId: "subject-1", policyVersion: "sumsub-share-v1", subjectProfileId: "subject-1" }, + { actorProfileId: "subject-1", policyVersion: "sumsub-share-v1", subjectProfileId: "subject-1" } + ], + status: "confirmed" + }); + expect(JSON.stringify(state.kycCase.verificationSubmission)).not.toContain(request.importToken); + expect(state.kycCase.verificationSubmission).not.toHaveProperty("consentPolicyVersion"); + expect(state.kycCase.verificationSubmission).not.toHaveProperty("consentAttestedAt"); + }); + + it("serializes method selection on the case without provider-history classification", async () => { + const state = harness(); + await expect(claimStandardAveniaKycMethod(state.providerCustomer)).resolves.toBe(state.kycCase); + expect(state.kycCase.verificationMethod).toBe("standard"); + expect(state.getKycAttempts).not.toHaveBeenCalled(); + expect(state.getUploadedDocuments).not.toHaveBeenCalled(); + await expect(importAveniaKycToken(request)).rejects.toMatchObject({ status: 409 }); + }); + + it("makes the runtime status helper default a locked null case to standard without provider reads", async () => { + const state = harness(); + const result = await reconcileAveniaIndividualKycStatusMethod(state.providerCustomer.id); + expect(result.kycCase.verificationMethod).toBe("standard"); + expect(state.getKycAttempts).not.toHaveBeenCalled(); + expect(state.getUploadedDocuments).not.toHaveBeenCalled(); + }); + + it("binds the imported attempt without downgrading approval", async () => { + const state = harness({ approveDuringImport: true }); + + await expect(importAveniaKycToken(request)).resolves.toEqual({ attemptId: "attempt-1", status: "pending" }); + expect(state.kycCase).toMatchObject({ + providerCaseId: "attempt-1", + status: VerificationStatus.Approved, + verificationSubmission: { status: "confirmed" } + }); + }); +}); + +it("keeps expired imported attempts pending", () => { + expect(mapAveniaKycAttemptStatus({ status: "EXPIRED" as never })).toBe(VerificationStatus.Pending); +}); diff --git a/apps/api/src/api/services/avenia/avenia-kyc-import.service.ts b/apps/api/src/api/services/avenia/avenia-kyc-import.service.ts new file mode 100644 index 000000000..e1686449f --- /dev/null +++ b/apps/api/src/api/services/avenia/avenia-kyc-import.service.ts @@ -0,0 +1,517 @@ +import { createHash } from "node:crypto"; +import { BrlaApiError, BrlaApiService, type KycAttempt, KycAttemptResult, KycAttemptStatus } from "@vortexfi/shared"; +import httpStatus from "http-status"; +import { Op, type Transaction } from "sequelize"; +import sequelize from "../../../config/database"; +import CustomerEntity from "../../../models/customerEntity.model"; +import KycCase, { type IndividualKycSubmission } from "../../../models/kycCase.model"; +import ManagedProfile from "../../../models/managedProfile.model"; +import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; +import User from "../../../models/user.model"; +import { APIError } from "../../errors/api-error"; + +const CONSENT_POLICY_VERSION = "sumsub-share-v1"; +const SUBMISSION_WINDOW_MS = 15 * 60 * 1000; +const PROVIDER_CLOCK_SKEW_MS = 2 * 60 * 1000; + +export interface ResolvedAveniaIndividualKycCase { + kycCase: KycCase; + providerCustomer: ProviderCustomer; +} + +export interface ImportAveniaKycTokenArgs { + actorProfileId: string; + subjectProfileId: string; + expectedCustomerEntityId?: string; + idempotencyKey: string; + importToken: string; + managedProfileId?: string; +} + +export interface ImportedAveniaKycToken { + attemptId: string; + status: "pending"; +} + +export interface ReconciledAveniaIndividualKycStatus { + kycCase: KycCase; +} + +function conflict(message: string): APIError { + return new APIError({ isPublic: true, message, status: httpStatus.CONFLICT }); +} + +function managedAccessDenied(): APIError { + return new APIError({ + isPublic: true, + message: "The authenticated profile cannot perform this operation for the requested managed profile", + status: httpStatus.FORBIDDEN + }); +} + +async function assertCurrentImportAuthorization(args: ImportAveniaKycTokenArgs, transaction: Transaction): Promise { + if (!args.managedProfileId && !args.expectedCustomerEntityId) { + if (args.actorProfileId !== args.subjectProfileId) throw managedAccessDenied(); + return; + } + if (!args.managedProfileId || !args.expectedCustomerEntityId) throw managedAccessDenied(); + + const manager = await ManagedProfileManager.findByPk(args.actorProfileId, { lock: transaction.LOCK.UPDATE, transaction }); + const relationship = await ManagedProfile.findByPk(args.managedProfileId, { lock: transaction.LOCK.UPDATE, transaction }); + const subject = await User.findByPk(args.subjectProfileId, { lock: transaction.LOCK.UPDATE, transaction }); + const entity = await CustomerEntity.findByPk(args.expectedCustomerEntityId, { lock: transaction.LOCK.UPDATE, transaction }); + if ( + !manager?.isActive || + !manager.allowedCorridors.includes("BR") || + (manager.allowedCustomerTypes !== null && !manager.allowedCustomerTypes.includes("individual")) || + !relationship || + relationship.managerProfileId !== args.actorProfileId || + relationship.profileId !== args.subjectProfileId || + relationship.status !== "active" || + subject?.kind !== "managed" || + subject.activeCustomerEntityId !== args.expectedCustomerEntityId || + !entity || + entity.profileId !== args.subjectProfileId || + entity.status !== "active" || + entity.type !== "individual" + ) { + throw managedAccessDenied(); + } +} + +async function resolveEligibleCase( + subjectProfileId: string, + expectedCustomerEntityId?: string, + transaction?: Transaction, + allowApproved = false +): Promise { + const profile = await User.findByPk(subjectProfileId, { transaction }); + if (!profile?.activeCustomerEntityId) throw conflict("The subject profile has no active customer entity"); + if (expectedCustomerEntityId) { + if (profile.kind !== "managed" || profile.activeCustomerEntityId !== expectedCustomerEntityId) { + throw conflict("The managed subject does not match the expected customer entity"); + } + } else if (profile.kind !== "authenticated") { + throw conflict("A managed profile requires a managed customer entity context"); + } + + const entity = await CustomerEntity.findOne({ + transaction, + where: { id: profile.activeCustomerEntityId, profileId: subjectProfileId } + }); + if (!entity || entity.status !== "active") throw conflict("The subject customer entity is not active"); + if (entity.type !== "individual") throw conflict("Avenia token import is only available for individuals"); + + const providerCustomers = await ProviderCustomer.findAll({ + transaction, + where: { country: "BR", customerEntityId: entity.id, customerType: "individual", provider: "avenia", rail: "brl" } + }); + if (providerCustomers.length !== 1) { + throw conflict( + providerCustomers.length === 0 + ? "Exactly one active Brazilian individual Avenia customer is required" + : "Multiple Avenia customers require reconciliation" + ); + } + const providerCustomer = providerCustomers[0]; + if (!providerCustomer.providerSubaccountId) throw conflict("The Avenia subaccount is not provisioned"); + if (!allowApproved && providerCustomer.status === VerificationStatus.Approved) { + throw conflict("The Avenia customer is already approved"); + } + + const cases = await KycCase.findAll({ + ...(transaction ? { lock: transaction.LOCK.UPDATE } : {}), + transaction, + where: { customerEntityId: entity.id, provider: "avenia", providerCustomerId: providerCustomer.id, type: "kyc" } + }); + if (cases.length !== 1) { + throw conflict( + cases.length === 0 ? "The canonical Avenia KYC case is missing" : "Multiple Avenia KYC cases require reconciliation" + ); + } + if (!allowApproved && cases[0].status === VerificationStatus.Approved) { + throw conflict("The Avenia KYC case is already approved"); + } + return { kycCase: cases[0], providerCustomer }; +} + +export async function resolveEligibleAveniaIndividualKycCase( + subjectProfileId: string, + expectedCustomerEntityId?: string +): Promise { + return resolveEligibleCase(subjectProfileId, expectedCustomerEntityId); +} + +export async function reconcileAveniaIndividualKycStatusMethod( + providerCustomerId: string, + _brlaApiService?: BrlaApiService +): Promise { + return sequelize.transaction(async transaction => { + const cases = await KycCase.findAll({ + lock: transaction.LOCK.UPDATE, + transaction, + where: { provider: "avenia", providerCustomerId, type: "kyc" } + }); + if (cases.length !== 1) throw conflict("Exactly one canonical Avenia KYC case is required"); + if (!cases[0].verificationMethod) await cases[0].update({ verificationMethod: "standard" }, { transaction }); + return { kycCase: cases[0] }; + }); +} + +export async function claimStandardAveniaKycMethod(providerCustomer: ProviderCustomer): Promise { + if (providerCustomer.provider !== "avenia" || providerCustomer.customerType !== "individual") { + throw conflict("Standard individual Avenia KYC requires an individual Avenia customer"); + } + return sequelize.transaction(async transaction => { + const cases = await KycCase.findAll({ + lock: transaction.LOCK.UPDATE, + transaction, + where: { provider: "avenia", providerCustomerId: providerCustomer.id, type: "kyc" } + }); + if (cases.length !== 1) throw conflict("Exactly one canonical Avenia KYC case is required"); + const kycCase = cases[0]; + const lockedCustomer = await ProviderCustomer.findByPk(providerCustomer.id, { transaction }); + if (!lockedCustomer || lockedCustomer.provider !== "avenia" || lockedCustomer.customerType !== "individual") { + throw conflict("Standard individual Avenia KYC requires an individual Avenia customer"); + } + if (lockedCustomer.status === VerificationStatus.Approved || kycCase.status === VerificationStatus.Approved) { + throw conflict("The Avenia KYC case is already approved"); + } + if (kycCase.verificationMethod === "sumsub_share_token") throw conflict("This KYC case uses Sumsub token import"); + if (!kycCase.verificationMethod) await kycCase.update({ verificationMethod: "standard" }, { transaction }); + return kycCase; + }); +} + +function submissionResult(kycCase: KycCase): ImportedAveniaKycToken { + if (!kycCase.providerCaseId) throw conflict("The confirmed token import is missing its provider attempt"); + return { attemptId: kycCase.providerCaseId, status: "pending" }; +} + +interface PreparedClaim { + kycCaseId: string; + providerCustomer: ProviderCustomer; + replay?: ImportedAveniaKycToken; + reconcile?: boolean; +} + +function sameTokenClaim( + submission: IndividualKycSubmission, + args: ImportAveniaKycTokenArgs, + keyHash: string, + tokenHash: string +) { + return ( + submission.actorProfileId === args.actorProfileId && + submission.subjectProfileId === args.subjectProfileId && + submission.idempotencyKeyHash === keyHash && + submission.tokenFingerprint === tokenHash + ); +} + +async function prepareImportClaim( + args: ImportAveniaKycTokenArgs, + idempotencyKeyHash: string, + tokenFingerprint: string +): Promise { + return sequelize.transaction(async transaction => { + const { kycCase, providerCustomer } = await resolveEligibleCase( + args.subjectProfileId, + args.expectedCustomerEntityId, + transaction, + true + ); + await assertCurrentImportAuthorization(args, transaction); + const existing = kycCase.verificationSubmission; + if (existing?.idempotencyKeyHash === idempotencyKeyHash) { + if (existing.tokenFingerprint !== tokenFingerprint) throw conflict("The idempotency key was used with a different token"); + if (!sameTokenClaim(existing, args, idempotencyKeyHash, tokenFingerprint)) { + throw conflict("The token import does not match this request"); + } + if (existing.status === "confirmed") + return { kycCaseId: kycCase.id, providerCustomer, replay: submissionResult(kycCase) }; + if (existing.status === "failed") throw conflict("A failed token import requires a new idempotency key"); + if (existing.status === "submitted" || existing.status === "ambiguous") { + return { kycCaseId: kycCase.id, providerCustomer, reconcile: true }; + } + return { kycCaseId: kycCase.id, providerCustomer }; + } + if (providerCustomer.status === VerificationStatus.Approved || kycCase.status === VerificationStatus.Approved) { + throw conflict("The Avenia KYC is already approved"); + } + if (existing && existing.status !== "failed") throw conflict("Another token import requires reconciliation"); + if (kycCase.verificationMethod === "standard") throw conflict("This KYC case uses the standard Avenia method"); + if (!kycCase.verificationMethod) await kycCase.update({ verificationMethod: "sumsub_share_token" }, { transaction }); + const consentAttestation = { + actorProfileId: args.actorProfileId, + attestedAt: new Date().toISOString(), + policyVersion: CONSENT_POLICY_VERSION, + subjectProfileId: args.subjectProfileId + }; + await kycCase.update( + { + verificationSubmission: { + actorProfileId: args.actorProfileId, + attemptBaselineIds: [], + consentAttestations: [...(existing?.consentAttestations ?? []), consentAttestation], + idempotencyKeyHash, + status: "prepared", + subjectProfileId: args.subjectProfileId, + tokenFingerprint + } + }, + { transaction } + ); + return { kycCaseId: kycCase.id, providerCustomer }; + }); +} + +async function updateSubmittedClaim( + kycCaseId: string, + values: Partial, + statuses: IndividualKycSubmission["status"][] = ["submitted"] +): Promise { + await sequelize.transaction(async transaction => { + const kycCase = await KycCase.findByPk(kycCaseId, { lock: transaction.LOCK.UPDATE, transaction }); + if (!kycCase?.verificationSubmission || !statuses.includes(kycCase.verificationSubmission.status)) return; + await kycCase.update({ verificationSubmission: { ...kycCase.verificationSubmission, ...values } }, { transaction }); + }); +} + +async function submitPreparedClaim( + claim: PreparedClaim, + args: ImportAveniaKycTokenArgs, + idempotencyKeyHash: string, + tokenFingerprint: string, + attemptBaselineIds: string[] +): Promise { + const result = await sequelize.transaction(async transaction => { + const providerCustomer = await ProviderCustomer.findByPk(claim.providerCustomer.id, { + lock: transaction.LOCK.UPDATE, + transaction + }); + const kycCase = await KycCase.findByPk(claim.kycCaseId, { lock: transaction.LOCK.UPDATE, transaction }); + try { + await assertCurrentImportAuthorization(args, transaction); + } catch (error) { + if (!(error instanceof APIError) || error.status !== httpStatus.FORBIDDEN) throw error; + if ( + kycCase?.verificationSubmission && + sameTokenClaim(kycCase.verificationSubmission, args, idempotencyKeyHash, tokenFingerprint) + ) { + await kycCase.update( + { + verificationSubmission: { + ...kycCase.verificationSubmission, + errorClassification: "authorization_revoked", + status: "failed" + } + }, + { transaction } + ); + } + return error; + } + const submission = kycCase?.verificationSubmission; + if ( + !providerCustomer || + !kycCase || + !submission || + kycCase.providerCustomerId !== providerCustomer.id || + kycCase.verificationMethod !== "sumsub_share_token" + ) { + throw conflict("The token import binding is no longer current"); + } + if (submission.status === "confirmed") return submissionResult(kycCase); + if (providerCustomer.status === VerificationStatus.Approved || kycCase.status === VerificationStatus.Approved) { + throw conflict("The Avenia KYC is already approved"); + } + if (submission.status !== "prepared" || !sameTokenClaim(submission, args, idempotencyKeyHash, tokenFingerprint)) { + throw conflict("The token import was already claimed"); + } + if (!providerCustomer.providerSubaccountId) throw conflict("The Avenia subaccount is not provisioned"); + await kycCase.update( + { + submittedAt: new Date(), + verificationSubmission: { ...submission, attemptBaselineIds, status: "submitted" } + }, + { transaction } + ); + return providerCustomer.providerSubaccountId; + }); + if (result instanceof APIError) throw result; + return result; +} + +async function confirmSubmission( + kycCaseId: string, + providerCustomerId: string, + attemptId: string +): Promise { + if (!attemptId) throw conflict("The Avenia token import attempt is invalid"); + await sequelize.transaction(async transaction => { + const providerCustomer = await ProviderCustomer.findByPk(providerCustomerId, { + lock: transaction.LOCK.UPDATE, + transaction + }); + const kycCase = await KycCase.findByPk(kycCaseId, { lock: transaction.LOCK.UPDATE, transaction }); + const submission = kycCase?.verificationSubmission; + if (!kycCase || !providerCustomer || !submission) throw new Error("Token import binding state disappeared"); + if (submission.status === "confirmed") { + if (kycCase.providerCaseId !== attemptId) throw conflict("The token import attempt requires reconciliation"); + return; + } + if (submission.status !== "submitted" && submission.status !== "ambiguous") { + throw new Error("Token import submission cannot be confirmed"); + } + if (kycCase.providerCaseId && kycCase.providerCaseId !== attemptId) { + throw conflict("The token import attempt requires reconciliation"); + } + await kycCase.update( + { + providerCaseId: attemptId, + ...(kycCase.status === VerificationStatus.Approved + ? {} + : { status: VerificationStatus.Pending, statusExternal: KycAttemptStatus.PENDING }), + verificationSubmission: { ...submission, errorClassification: undefined, status: "confirmed" } + }, + { transaction } + ); + if (providerCustomer.status !== VerificationStatus.Approved) { + await providerCustomer.update( + { lastFailureReasons: [], status: VerificationStatus.Pending, statusExternal: KycAttemptStatus.PENDING }, + { transaction } + ); + } + }); + return { attemptId, status: "pending" }; +} + +async function reconcileSubmission(claim: PreparedClaim, brlaApiService: BrlaApiService): Promise { + const kycCase = await KycCase.findByPk(claim.kycCaseId); + const submission = kycCase?.verificationSubmission; + if (!kycCase || !submission || !kycCase.submittedAt) { + throw conflict("The previous token import outcome requires reconciliation"); + } + if (kycCase.providerCaseId) return confirmSubmission(kycCase.id, claim.providerCustomer.id, kycCase.providerCaseId); + let attempts: KycAttempt[]; + try { + ({ attempts } = await brlaApiService.getKycAttempts(claim.providerCustomer.providerSubaccountId as string)); + } catch { + throw conflict("The previous token import outcome requires reconciliation"); + } + const submittedAt = kycCase.submittedAt.getTime(); + const eligible = attempts.filter(attempt => { + const createdAt = Date.parse(attempt.createdAt); + return ( + Boolean(attempt.id) && + attempt.levelName.startsWith("sumsub-token-") && + Number.isFinite(createdAt) && + createdAt >= submittedAt - PROVIDER_CLOCK_SKEW_MS && + createdAt <= submittedAt + SUBMISSION_WINDOW_MS + PROVIDER_CLOCK_SKEW_MS + ); + }); + const bound = eligible.length + ? await KycCase.findAll({ + attributes: ["providerCaseId"], + where: { id: { [Op.ne]: kycCase.id }, providerCaseId: { [Op.in]: eligible.map(attempt => attempt.id) } } + }) + : []; + const excluded = new Set([...submission.attemptBaselineIds, ...bound.map(row => row.providerCaseId)]); + const candidates = eligible.filter(attempt => !excluded.has(attempt.id)); + if (candidates.length !== 1) throw conflict("The previous token import outcome requires reconciliation"); + return confirmSubmission(kycCase.id, claim.providerCustomer.id, candidates[0].id); +} + +function isFeatureUnavailable(error: unknown): boolean { + return ( + (error instanceof BrlaApiError && error.status === 401) || + (error instanceof Error && error.message === "Authorization error.") + ); +} + +export async function importAveniaKycToken(args: ImportAveniaKycTokenArgs): Promise { + const tokenFingerprint = createHash("sha256").update(args.importToken, "utf8").digest("hex"); + const idempotencyKeyHash = createHash("sha256").update(args.idempotencyKey, "utf8").digest("hex"); + const claim = await prepareImportClaim(args, idempotencyKeyHash, tokenFingerprint); + if (claim.replay) return claim.replay; + const brlaApiService = BrlaApiService.getInstance(); + if (claim.reconcile) return reconcileSubmission(claim, brlaApiService); + + let attemptBaselineIds: string[]; + try { + const { attempts } = await brlaApiService.getKycAttempts(claim.providerCustomer.providerSubaccountId as string); + attemptBaselineIds = [...new Set(attempts.map(attempt => attempt.id))]; + } catch { + await updateSubmittedClaim(claim.kycCaseId, { errorClassification: "pre_provider_check_failed", status: "failed" }, [ + "prepared" + ]); + throw new APIError({ + isPublic: true, + message: "Avenia token import pre-provider checks failed", + status: httpStatus.BAD_GATEWAY + }); + } + + const submitted = await submitPreparedClaim(claim, args, idempotencyKeyHash, tokenFingerprint, attemptBaselineIds); + if (typeof submitted !== "string") return submitted; + let attemptId: string; + try { + attemptId = (await brlaApiService.importKycToken(args.importToken, submitted)).id; + } catch (error) { + if (isFeatureUnavailable(error)) { + await updateSubmittedClaim(claim.kycCaseId, { errorClassification: "feature_unavailable", status: "failed" }); + throw new APIError({ + isPublic: true, + message: "Avenia token import is not enabled", + status: httpStatus.PRECONDITION_FAILED + }); + } + await updateSubmittedClaim(claim.kycCaseId, { errorClassification: "provider_outcome_unknown", status: "ambiguous" }); + throw new APIError({ + isPublic: true, + message: "The Avenia token import outcome requires reconciliation", + status: httpStatus.BAD_GATEWAY + }); + } + try { + return await confirmSubmission(claim.kycCaseId, claim.providerCustomer.id, attemptId); + } catch { + await sequelize + .transaction(async transaction => { + const kycCase = await KycCase.findByPk(claim.kycCaseId, { lock: transaction.LOCK.UPDATE, transaction }); + if (!kycCase?.verificationSubmission) return; + await kycCase.update( + { + providerCaseId: attemptId, + verificationSubmission: { + ...kycCase.verificationSubmission, + errorClassification: "local_confirmation_failed", + status: "ambiguous" + } + }, + { transaction } + ); + }) + .catch(() => undefined); + throw new APIError({ + isPublic: true, + message: "The Avenia token import outcome requires reconciliation", + status: httpStatus.BAD_GATEWAY + }); + } +} + +export function mapAveniaKycAttemptStatus( + attempt: Pick +): VerificationStatus.Pending | VerificationStatus.InReview | VerificationStatus.Approved | VerificationStatus.Rejected { + if (attempt.status === KycAttemptStatus.PENDING) return VerificationStatus.Pending; + if (attempt.status === KycAttemptStatus.PROCESSING) return VerificationStatus.InReview; + if (attempt.status === KycAttemptStatus.EXPIRED) return VerificationStatus.Pending; + if (attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.APPROVED) + return VerificationStatus.Approved; + if (attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.REJECTED) + return VerificationStatus.Rejected; + throw new APIError({ message: "Avenia returned an invalid KYC attempt state", status: httpStatus.BAD_GATEWAY }); +} diff --git a/apps/api/src/api/services/avenia/avenia-standard-kyc.service.test.ts b/apps/api/src/api/services/avenia/avenia-standard-kyc.service.test.ts new file mode 100644 index 000000000..5654687f9 --- /dev/null +++ b/apps/api/src/api/services/avenia/avenia-standard-kyc.service.test.ts @@ -0,0 +1,327 @@ +import { createHash } from "node:crypto"; +import { BrlaApiService, type KycAttempt, KycAttemptResult, KycAttemptStatus, type KycLevel1Payload } from "@vortexfi/shared"; +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import sequelize from "../../../config/database"; +import CustomerEntity from "../../../models/customerEntity.model"; +import KycCase, { type IndividualKycSubmission } from "../../../models/kycCase.model"; +import ManagedProfile from "../../../models/managedProfile.model"; +import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; +import User from "../../../models/user.model"; +import { submitStandardAveniaKyc } from "./avenia-standard-kyc.service"; + +const originals = { + caseFindAll: KycCase.findAll, + caseFindByPk: KycCase.findByPk, + customerFindByPk: ProviderCustomer.findByPk, + entityFindByPk: CustomerEntity.findByPk, + getInstance: BrlaApiService.getInstance, + managerFindByPk: ManagedProfileManager.findByPk, + relationshipFindByPk: ManagedProfile.findByPk, + transaction: sequelize.transaction, + userFindByPk: User.findByPk +}; + +const payload: KycLevel1Payload = { + city: "Sao Paulo", + country: "BR", + countryOfTaxId: "BR", + dateOfBirth: "1990-01-01", + email: "person@example.com", + fullName: "Private Person", + state: "SP", + streetAddress: "Private street", + subAccountId: "subaccount-1", + taxIdNumber: "private-tax-id", + uploadedDocumentId: "private-document", + uploadedSelfieId: "private-selfie", + zipCode: "00000-000" +}; + +function fingerprint(value: KycLevel1Payload): string { + return createHash("sha256") + .update( + JSON.stringify({ + city: value.city, + country: value.country, + countryOfTaxId: value.countryOfTaxId, + dateOfBirth: value.dateOfBirth, + email: value.email, + fullName: value.fullName, + state: value.state, + streetAddress: value.streetAddress, + subAccountId: value.subAccountId, + taxIdNumber: value.taxIdNumber, + uploadedDocumentId: value.uploadedDocumentId, + uploadedSelfieId: value.uploadedSelfieId, + zipCode: value.zipCode + }) + ) + .digest("hex"); +} + +interface HarnessOptions { + approveDuringSubmit?: boolean; + attempts?: KycAttempt[]; + boundAttemptIds?: string[]; + managerActive?: boolean; + providerAttemptId?: string; + submittedAt?: Date | null; + submission?: IndividualKycSubmission; + submitError?: boolean; + verificationMethod?: KycCase["verificationMethod"]; + verificationAttempt?: Partial; +} + +function harness(options: HarnessOptions = {}) { + const lockOrder: string[] = []; + const providerCustomer = { + customerEntityId: "entity-1", + customerType: "individual", + id: "customer-1", + provider: "avenia", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.InReview, + update: mock(async (values: object) => Object.assign(providerCustomer, values)) + } as unknown as ProviderCustomer; + const kycCase = { + customerEntityId: "entity-1", + id: "case-1", + provider: "avenia", + providerCaseId: options.providerAttemptId ?? null, + providerCustomerId: "customer-1", + status: VerificationStatus.InReview, + submittedAt: options.submittedAt ?? null, + type: "kyc", + update: mock(async (values: object) => Object.assign(kycCase, values)), + verificationMethod: options.verificationMethod === undefined ? "standard" : options.verificationMethod, + verificationSubmission: options.submission ?? null + } as unknown as KycCase; + KycCase.findAll = mock(async (query?: { attributes?: string[]; lock?: unknown }) => { + if (query?.lock) lockOrder.push("case"); + return query?.attributes ? (options.boundAttemptIds ?? []).map(providerCaseId => ({ providerCaseId })) : [kycCase]; + }) as never; + KycCase.findByPk = mock(async (_id: string, query?: { lock?: unknown }) => { + if (query?.lock) lockOrder.push("case"); + return kycCase; + }) as never; + ProviderCustomer.findByPk = mock(async (_id: string, query?: { lock?: unknown }) => { + if (query?.lock) lockOrder.push("customer"); + return providerCustomer; + }) as never; + ManagedProfileManager.findByPk = mock(async () => ({ + allowedCorridors: ["BR"], + allowedCustomerTypes: null, + isActive: options.managerActive ?? true + })) as never; + ManagedProfile.findByPk = mock(async () => ({ managerProfileId: "manager-1", profileId: "subject-1", status: "active" })) as never; + User.findByPk = mock(async () => ({ activeCustomerEntityId: "entity-1", kind: "managed" })) as never; + CustomerEntity.findByPk = mock(async () => ({ profileId: "subject-1", status: "active", type: "individual" })) as never; + sequelize.transaction = mock(async callback => callback({ LOCK: { UPDATE: "UPDATE" } } as never)) as never; + const submitKycLevel1 = mock(async () => { + if (options.submitError) throw new Error("response lost"); + if (options.approveDuringSubmit) { + providerCustomer.status = VerificationStatus.Approved; + kycCase.status = VerificationStatus.Approved; + } + return { id: "attempt-1" }; + }); + const getKycAttempts = mock(async () => ({ attempts: options.attempts ?? [] })); + const getVerificationAttemptStatus = mock(async (attemptId: string) => ({ + attempt: { + createdAt: new Date().toISOString(), + id: attemptId, + levelName: "level-1", + status: KycAttemptStatus.PENDING, + updatedAt: new Date().toISOString(), + ...options.verificationAttempt + } + })); + const getUploadedDocuments = mock(async () => ({ + documents: [ + { id: payload.uploadedDocumentId, ready: true }, + { id: payload.uploadedSelfieId, ready: true } + ] + })); + BrlaApiService.getInstance = mock( + () => ({ getKycAttempts, getUploadedDocuments, getVerificationAttemptStatus, submitKycLevel1 }) as unknown as BrlaApiService + ); + return { getKycAttempts, getVerificationAttemptStatus, kycCase, lockOrder, providerCustomer, submitKycLevel1 }; +} + +const request = { actorProfileId: "subject-1", payload, subjectProfileId: "subject-1" }; + +afterEach(() => { + KycCase.findAll = originals.caseFindAll; + KycCase.findByPk = originals.caseFindByPk; + ProviderCustomer.findByPk = originals.customerFindByPk; + CustomerEntity.findByPk = originals.entityFindByPk; + BrlaApiService.getInstance = originals.getInstance; + ManagedProfileManager.findByPk = originals.managerFindByPk; + ManagedProfile.findByPk = originals.relationshipFindByPk; + sequelize.transaction = originals.transaction; + User.findByPk = originals.userFindByPk; + mock.restore(); +}); + +describe("submitStandardAveniaKyc", () => { + it("allows a direct managed child while revalidating its controlling manager and exact relationship", async () => { + const state = harness(); + const timeout = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void) => { + callback(); + return 0; + }) as typeof setTimeout); + + expect( + await submitStandardAveniaKyc({ + ...request, + controllingManagerProfileId: "manager-1", + expectedCustomerEntityId: "entity-1", + managedProfileId: "relationship-1", + providerCustomer: state.providerCustomer + }) + ).toEqual({ id: "attempt-1" }); + expect(ManagedProfileManager.findByPk).toHaveBeenCalledWith("manager-1", expect.objectContaining({ lock: "UPDATE" })); + expect(state.kycCase.verificationSubmission).toMatchObject({ + actorProfileId: "subject-1", + status: "confirmed", + subjectProfileId: "subject-1" + }); + timeout.mockRestore(); + }); + + it("leaves a nullable method unclaimed when managed authorization was revoked", async () => { + const state = harness({ managerActive: false, verificationMethod: null }); + + await expect( + submitStandardAveniaKyc({ + ...request, + actorProfileId: "manager-1", + controllingManagerProfileId: "manager-1", + expectedCustomerEntityId: "entity-1", + managedProfileId: "relationship-1", + providerCustomer: state.providerCustomer + }) + ).rejects.toMatchObject({ status: 403 }); + expect(state.kycCase.verificationMethod).toBeNull(); + expect(state.getKycAttempts).not.toHaveBeenCalled(); + expect(state.submitKycLevel1).not.toHaveBeenCalled(); + expect(state.lockOrder.slice(0, 2)).toEqual(["customer", "case"]); + }); + + it("commits baseline and send time before POST, then stores the exact attempt without payload data", async () => { + const baseline = { + createdAt: new Date().toISOString(), + id: "baseline", + levelName: "level-1", + status: KycAttemptStatus.COMPLETED, + updatedAt: new Date().toISOString() + } as KycAttempt; + const state = harness({ attempts: [baseline, baseline] }); + const timeout = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void) => { + callback(); + return 0; + }) as typeof setTimeout); + state.submitKycLevel1.mockImplementation(async () => { + expect(state.kycCase.submittedAt).toBeInstanceOf(Date); + expect(state.kycCase.verificationSubmission).toMatchObject({ attemptBaselineIds: ["baseline"], status: "submitted" }); + return { id: "attempt-1" }; + }); + expect(await submitStandardAveniaKyc({ ...request, providerCustomer: state.providerCustomer })).toEqual({ id: "attempt-1" }); + expect(state.kycCase).toMatchObject({ + providerCaseId: "attempt-1", + verificationSubmission: { payloadFingerprint: fingerprint(payload), status: "confirmed" } + }); + expect(JSON.stringify(state.kycCase.verificationSubmission)).not.toContain(payload.taxIdNumber); + expect(state.lockOrder.slice(-2)).toEqual(["customer", "case"]); + timeout.mockRestore(); + }); + + it("reuses a confirmed exact attempt and retries only after an exact retryable terminal", async () => { + const confirmed: IndividualKycSubmission = { + actorProfileId: "subject-1", + attemptBaselineIds: [], + payloadFingerprint: fingerprint(payload), + status: "confirmed", + subjectProfileId: "subject-1" + }; + const pending = harness({ providerAttemptId: "attempt-old", submission: confirmed }); + expect(await submitStandardAveniaKyc({ ...request, providerCustomer: pending.providerCustomer })).toEqual({ id: "attempt-old" }); + expect(pending.submitKycLevel1).not.toHaveBeenCalled(); + + const retryable = harness({ + providerAttemptId: "attempt-old", + submission: confirmed, + verificationAttempt: { result: KycAttemptResult.REJECTED, retryable: true, status: KycAttemptStatus.COMPLETED } + }); + const timeout = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void) => { + callback(); + return 0; + }) as typeof setTimeout); + expect(await submitStandardAveniaKyc({ ...request, providerCustomer: retryable.providerCustomer })).toEqual({ id: "attempt-1" }); + expect(retryable.submitKycLevel1).toHaveBeenCalledTimes(1); + expect(retryable.kycCase.verificationSubmission).toMatchObject({ status: "confirmed" }); + timeout.mockRestore(); + }); + + it("does not repost an ambiguous claim and excludes baseline and attempts bound to other cases", async () => { + const submittedAt = new Date(); + const attempt = (id: string): KycAttempt => ({ + createdAt: submittedAt.toISOString(), + id, + levelName: "level-1", + status: KycAttemptStatus.PENDING, + updatedAt: submittedAt.toISOString() + }); + const state = harness({ + attempts: [attempt("baseline"), attempt("bound"), attempt("current")], + boundAttemptIds: ["bound"], + submittedAt, + submission: { + actorProfileId: "subject-1", + attemptBaselineIds: ["baseline"], + payloadFingerprint: fingerprint(payload), + status: "ambiguous", + subjectProfileId: "subject-1" + } + }); + expect(await submitStandardAveniaKyc({ ...request, providerCustomer: state.providerCustomer })).toEqual({ id: "current" }); + expect(state.submitKycLevel1).not.toHaveBeenCalled(); + expect(state.kycCase).toMatchObject({ providerCaseId: "current", verificationSubmission: { status: "confirmed" } }); + }); + + it("quarantines a lost POST response and never automatically reposts it", async () => { + const state = harness({ submitError: true }); + const timeout = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void) => { + callback(); + return 0; + }) as typeof setTimeout); + await expect(submitStandardAveniaKyc({ ...request, providerCustomer: state.providerCustomer })).rejects.toMatchObject({ + status: 502 + }); + expect(state.kycCase.verificationSubmission).toMatchObject({ status: "ambiguous" }); + await expect(submitStandardAveniaKyc({ ...request, providerCustomer: state.providerCustomer })).rejects.toMatchObject({ + status: 409 + }); + expect(state.submitKycLevel1).toHaveBeenCalledTimes(1); + timeout.mockRestore(); + }); + + it("binds the exact attempt when approval wins before local confirmation", async () => { + const state = harness({ approveDuringSubmit: true }); + const timeout = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void) => { + callback(); + return 0; + }) as typeof setTimeout); + + await expect(submitStandardAveniaKyc({ ...request, providerCustomer: state.providerCustomer })).resolves.toEqual({ + id: "attempt-1" + }); + expect(state.kycCase).toMatchObject({ + providerCaseId: "attempt-1", + status: VerificationStatus.Approved, + verificationSubmission: { status: "confirmed" } + }); + timeout.mockRestore(); + }); +}); diff --git a/apps/api/src/api/services/avenia/avenia-standard-kyc.service.ts b/apps/api/src/api/services/avenia/avenia-standard-kyc.service.ts new file mode 100644 index 000000000..bb332778f --- /dev/null +++ b/apps/api/src/api/services/avenia/avenia-standard-kyc.service.ts @@ -0,0 +1,507 @@ +import { createHash } from "node:crypto"; +import { + BrlaApiService, + type KycAttempt, + KycAttemptResult, + KycAttemptStatus, + type KycLevel1Payload, + type KycLevel1Response +} from "@vortexfi/shared"; +import httpStatus from "http-status"; +import { Op, type Transaction } from "sequelize"; +import sequelize from "../../../config/database"; +import CustomerEntity from "../../../models/customerEntity.model"; +import KycCase, { type IndividualKycSubmission } from "../../../models/kycCase.model"; +import ManagedProfile from "../../../models/managedProfile.model"; +import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; +import User from "../../../models/user.model"; +import { APIError } from "../../errors/api-error"; + +const RECONCILIATION_WINDOW_MS = 15 * 60 * 1000; +const PROVIDER_CLOCK_SKEW_MS = 60 * 1000; + +export interface SubmitStandardAveniaKycArgs { + actorProfileId: string; + controllingManagerProfileId?: string; + expectedCustomerEntityId?: string; + managedProfileId?: string; + payload: KycLevel1Payload; + providerCustomer: ProviderCustomer; + subjectProfileId: string; +} + +function conflict(message: string): APIError { + return new APIError({ isPublic: true, message, status: httpStatus.CONFLICT }); +} + +function managedAccessDenied(): APIError { + return new APIError({ + isPublic: true, + message: "The authenticated profile cannot perform this operation for the requested managed profile", + status: httpStatus.FORBIDDEN + }); +} + +function assertAuthorizationShape(args: SubmitStandardAveniaKycArgs): void { + if (!args.controllingManagerProfileId && !args.managedProfileId && !args.expectedCustomerEntityId) { + if (args.actorProfileId !== args.subjectProfileId) throw managedAccessDenied(); + return; + } + if (!args.controllingManagerProfileId || !args.managedProfileId || !args.expectedCustomerEntityId) { + throw managedAccessDenied(); + } + if (args.actorProfileId !== args.controllingManagerProfileId && args.actorProfileId !== args.subjectProfileId) { + throw managedAccessDenied(); + } +} + +async function assertCurrentAuthorization( + args: SubmitStandardAveniaKycArgs, + transaction: Transaction, + providerCustomer?: ProviderCustomer | null +): Promise { + assertAuthorizationShape(args); + if (!args.controllingManagerProfileId || !args.managedProfileId || !args.expectedCustomerEntityId) return; + const manager = await ManagedProfileManager.findByPk(args.controllingManagerProfileId, { + lock: transaction.LOCK.UPDATE, + transaction + }); + const relationship = await ManagedProfile.findByPk(args.managedProfileId, { lock: transaction.LOCK.UPDATE, transaction }); + const subject = await User.findByPk(args.subjectProfileId, { lock: transaction.LOCK.UPDATE, transaction }); + const entity = await CustomerEntity.findByPk(args.expectedCustomerEntityId, { lock: transaction.LOCK.UPDATE, transaction }); + if ( + !manager?.isActive || + !manager.allowedCorridors.includes("BR") || + (manager.allowedCustomerTypes !== null && !manager.allowedCustomerTypes.includes("individual")) || + !relationship || + relationship.managerProfileId !== args.controllingManagerProfileId || + relationship.profileId !== args.subjectProfileId || + relationship.status !== "active" || + subject?.kind !== "managed" || + subject.activeCustomerEntityId !== args.expectedCustomerEntityId || + !entity || + entity.profileId !== args.subjectProfileId || + entity.status !== "active" || + entity.type !== "individual" || + (providerCustomer ?? args.providerCustomer).customerEntityId !== args.expectedCustomerEntityId + ) { + throw managedAccessDenied(); + } +} + +async function claimAuthorizedStandardMethod(args: SubmitStandardAveniaKycArgs): Promise { + return sequelize.transaction(async transaction => { + const providerCustomer = await ProviderCustomer.findByPk(args.providerCustomer.id, { + lock: transaction.LOCK.UPDATE, + transaction + }); + if (!providerCustomer || providerCustomer.provider !== "avenia" || providerCustomer.customerType !== "individual") { + throw conflict("Standard individual Avenia KYC requires an individual Avenia customer"); + } + const cases = await KycCase.findAll({ + lock: transaction.LOCK.UPDATE, + transaction, + where: { provider: "avenia", providerCustomerId: providerCustomer.id, type: "kyc" } + }); + if (cases.length !== 1) throw conflict("Exactly one canonical Avenia KYC case is required"); + const kycCase = cases[0]; + await assertCurrentAuthorization(args, transaction, providerCustomer); + if (providerCustomer.status === VerificationStatus.Approved || kycCase.status === VerificationStatus.Approved) { + throw conflict("The Avenia KYC case is already approved"); + } + if (kycCase.verificationMethod === "sumsub_share_token") throw conflict("This KYC case uses Sumsub token import"); + if (!kycCase.verificationMethod) await kycCase.update({ verificationMethod: "standard" }, { transaction }); + return kycCase; + }); +} + +function reconciliationError(): APIError { + return new APIError({ + isPublic: true, + message: "The Avenia KYC submission outcome requires reconciliation", + status: httpStatus.BAD_GATEWAY + }); +} + +function preProviderCheckError(): APIError { + return new APIError({ isPublic: true, message: "Avenia KYC pre-provider checks failed", status: httpStatus.BAD_GATEWAY }); +} + +function fingerprintPayload(payload: KycLevel1Payload): string { + const canonicalPayload: Record = { + city: payload.city, + country: payload.country, + countryOfTaxId: payload.countryOfTaxId, + dateOfBirth: payload.dateOfBirth, + email: payload.email, + fullName: payload.fullName, + state: payload.state, + streetAddress: payload.streetAddress, + subAccountId: payload.subAccountId, + taxIdNumber: payload.taxIdNumber, + uploadedDocumentId: payload.uploadedDocumentId, + uploadedSelfieId: payload.uploadedSelfieId, + zipCode: payload.zipCode + }; + return createHash("sha256").update(JSON.stringify(canonicalPayload), "utf8").digest("hex"); +} + +function assertSubmissionBinding( + submission: IndividualKycSubmission, + args: SubmitStandardAveniaKycArgs, + payloadFingerprint: string, + allowDifferentPayload = false +): void { + if ( + submission.actorProfileId !== args.actorProfileId || + submission.subjectProfileId !== args.subjectProfileId || + (!allowDifferentPayload && submission.payloadFingerprint !== payloadFingerprint) + ) { + throw conflict("The Avenia KYC submission does not match this request"); + } +} + +async function prepareSubmission( + args: SubmitStandardAveniaKycArgs, + kycCaseId: string, + payloadFingerprint: string +): Promise { + return sequelize.transaction(async transaction => { + const providerCustomer = await ProviderCustomer.findByPk(args.providerCustomer.id, { + lock: transaction.LOCK.UPDATE, + transaction + }); + const kycCase = await KycCase.findByPk(kycCaseId, { lock: transaction.LOCK.UPDATE, transaction }); + if (!kycCase || !providerCustomer || kycCase.verificationMethod !== "standard") { + throw new Error("Standard KYC submission state disappeared"); + } + await assertCurrentAuthorization(args, transaction, providerCustomer); + if (kycCase.status === VerificationStatus.Approved || providerCustomer.status === VerificationStatus.Approved) { + throw conflict("This Avenia customer is already approved"); + } + const existing = kycCase.verificationSubmission; + if (existing && existing.status !== "failed") { + assertSubmissionBinding(existing, args, payloadFingerprint, existing.status === "confirmed"); + return kycCase; + } + await kycCase.update( + { + verificationSubmission: { + actorProfileId: args.actorProfileId, + attemptBaselineIds: [], + payloadFingerprint, + status: "prepared", + subjectProfileId: args.subjectProfileId + } + }, + { transaction } + ); + return kycCase; + }); +} + +async function prepareRetrySubmission( + args: SubmitStandardAveniaKycArgs, + kycCaseId: string, + expectedAttemptId: string, + payloadFingerprint: string +): Promise { + return sequelize.transaction(async transaction => { + const providerCustomer = await ProviderCustomer.findByPk(args.providerCustomer.id, { + lock: transaction.LOCK.UPDATE, + transaction + }); + const kycCase = await KycCase.findByPk(kycCaseId, { lock: transaction.LOCK.UPDATE, transaction }); + const previous = kycCase?.verificationSubmission; + if ( + !providerCustomer || + !kycCase || + !previous || + kycCase.verificationMethod !== "standard" || + previous.status !== "confirmed" || + kycCase.providerCaseId !== expectedAttemptId + ) { + throw reconciliationError(); + } + await assertCurrentAuthorization(args, transaction, providerCustomer); + if (kycCase.status === VerificationStatus.Approved || providerCustomer.status === VerificationStatus.Approved) { + throw conflict("This Avenia customer is already approved"); + } + assertSubmissionBinding(previous, args, payloadFingerprint, true); + await kycCase.update( + { + verificationSubmission: { + actorProfileId: args.actorProfileId, + attemptBaselineIds: [], + payloadFingerprint, + status: "prepared", + subjectProfileId: args.subjectProfileId + } + }, + { transaction } + ); + return kycCase; + }); +} + +async function claimPreparedSubmission( + args: SubmitStandardAveniaKycArgs, + kycCaseId: string, + payloadFingerprint: string, + attemptBaselineIds: string[] +): Promise { + return sequelize.transaction(async transaction => { + const providerCustomer = await ProviderCustomer.findByPk(args.providerCustomer.id, { + lock: transaction.LOCK.UPDATE, + transaction + }); + const kycCase = await KycCase.findByPk(kycCaseId, { lock: transaction.LOCK.UPDATE, transaction }); + try { + await assertCurrentAuthorization(args, transaction, providerCustomer); + } catch (error) { + if (!(error instanceof APIError) || error.status !== httpStatus.FORBIDDEN) throw error; + const submission = kycCase?.verificationSubmission; + if (kycCase && submission?.status === "prepared") { + assertSubmissionBinding(submission, args, payloadFingerprint); + await kycCase.update( + { verificationSubmission: { ...submission, errorClassification: "authorization_revoked", status: "failed" } }, + { transaction } + ); + } + return error; + } + const submission = kycCase?.verificationSubmission; + if (!providerCustomer || !kycCase || !submission || kycCase.verificationMethod !== "standard") { + throw new Error("Standard KYC submission state disappeared"); + } + if (kycCase.status === VerificationStatus.Approved || providerCustomer.status === VerificationStatus.Approved) { + throw conflict("This Avenia customer is already approved"); + } + assertSubmissionBinding(submission, args, payloadFingerprint); + if (submission.status !== "prepared") return false; + await kycCase.update( + { + providerCaseId: null, + submittedAt: new Date(), + verificationSubmission: { ...submission, attemptBaselineIds, status: "submitted" } + }, + { transaction } + ); + return true; + }); +} + +async function updateClaim( + kycCaseId: string, + values: Partial, + statuses: IndividualKycSubmission["status"][] +): Promise { + await sequelize.transaction(async transaction => { + const kycCase = await KycCase.findByPk(kycCaseId, { lock: transaction.LOCK.UPDATE, transaction }); + if (!kycCase?.verificationSubmission || !statuses.includes(kycCase.verificationSubmission.status)) return; + await kycCase.update({ verificationSubmission: { ...kycCase.verificationSubmission, ...values } }, { transaction }); + }); +} + +async function confirmSubmission(kycCaseId: string, providerCustomerId: string, attemptId: string): Promise { + await sequelize.transaction(async transaction => { + const providerCustomer = await ProviderCustomer.findByPk(providerCustomerId, { + lock: transaction.LOCK.UPDATE, + transaction + }); + const kycCase = await KycCase.findByPk(kycCaseId, { lock: transaction.LOCK.UPDATE, transaction }); + const submission = kycCase?.verificationSubmission; + if (!providerCustomer || !kycCase || !submission || kycCase.verificationMethod !== "standard") { + throw new Error("Standard KYC submission claim disappeared"); + } + if (submission.status === "confirmed") { + if (kycCase.providerCaseId !== attemptId) throw conflict("The Avenia KYC attempt requires reconciliation"); + return; + } + if (submission.status !== "submitted" && submission.status !== "ambiguous") { + throw new Error("Standard KYC submission claim disappeared"); + } + await kycCase.update( + { + providerCaseId: attemptId, + ...(kycCase.status === VerificationStatus.Approved + ? {} + : { + approvedAt: null, + failureReasons: [], + rejectedAt: null, + status: VerificationStatus.Pending, + statusExternal: KycAttemptStatus.PENDING + }), + verificationSubmission: { ...submission, errorClassification: undefined, status: "confirmed" } + }, + { transaction } + ); + if (providerCustomer.status !== VerificationStatus.Approved) { + await providerCustomer.update( + { lastFailureReasons: [], status: VerificationStatus.Pending, statusExternal: KycAttemptStatus.PENDING }, + { transaction } + ); + } + }); +} + +async function quarantineSubmission(kycCaseId: string, attemptId?: string): Promise { + await sequelize.transaction(async transaction => { + const kycCase = await KycCase.findByPk(kycCaseId, { lock: transaction.LOCK.UPDATE, transaction }); + if (!kycCase?.verificationSubmission || !["submitted", "ambiguous"].includes(kycCase.verificationSubmission.status)) return; + await kycCase.update( + { + ...(attemptId ? { providerCaseId: attemptId } : {}), + verificationSubmission: { + ...kycCase.verificationSubmission, + errorClassification: attemptId ? "local_confirmation_failed" : "provider_outcome_unknown", + status: "ambiguous" + } + }, + { transaction } + ); + }); +} + +function isReconciliationCandidate(attempt: KycAttempt, submittedAt: Date): boolean { + if (attempt.levelName.startsWith("sumsub-token-")) return false; + const createdAt = Date.parse(attempt.createdAt); + return ( + Number.isFinite(createdAt) && + createdAt >= submittedAt.getTime() - PROVIDER_CLOCK_SKEW_MS && + createdAt <= submittedAt.getTime() + RECONCILIATION_WINDOW_MS + PROVIDER_CLOCK_SKEW_MS + ); +} + +async function reconcileSubmission( + brlaApiService: BrlaApiService, + kycCaseId: string, + args: SubmitStandardAveniaKycArgs, + payloadFingerprint: string +): Promise { + const kycCase = await KycCase.findByPk(kycCaseId); + const submission = kycCase?.verificationSubmission; + if (!kycCase || !submission) throw reconciliationError(); + assertSubmissionBinding(submission, args, payloadFingerprint); + if (!kycCase.submittedAt) throw conflict("The Avenia KYC submission requires manual reconciliation"); + let attempt: KycAttempt; + try { + if (kycCase.providerCaseId) { + const response = await brlaApiService.getVerificationAttemptStatus( + kycCase.providerCaseId, + args.providerCustomer.providerSubaccountId as string + ); + if (response.attempt.id !== kycCase.providerCaseId) throw reconciliationError(); + attempt = response.attempt; + } else { + const { attempts } = await brlaApiService.getKycAttempts(args.providerCustomer.providerSubaccountId as string); + const eligible = attempts.filter(candidate => isReconciliationCandidate(candidate, kycCase.submittedAt as Date)); + const bound = eligible.length + ? await KycCase.findAll({ + attributes: ["providerCaseId"], + where: { id: { [Op.ne]: kycCase.id }, providerCaseId: { [Op.in]: eligible.map(candidate => candidate.id) } } + }) + : []; + const excluded = new Set([...submission.attemptBaselineIds, ...bound.map(row => row.providerCaseId)]); + const candidates = eligible.filter(candidate => !excluded.has(candidate.id)); + if (candidates.length !== 1) throw conflict("The Avenia KYC submission requires manual reconciliation"); + attempt = candidates[0]; + } + } catch (error) { + if (error instanceof APIError) throw error; + await quarantineSubmission(kycCase.id).catch(() => undefined); + throw reconciliationError(); + } + try { + await confirmSubmission(kycCase.id, args.providerCustomer.id, attempt.id); + } catch { + await quarantineSubmission(kycCase.id, attempt.id).catch(() => undefined); + throw reconciliationError(); + } + return { id: attempt.id }; +} + +export async function submitStandardAveniaKyc(args: SubmitStandardAveniaKycArgs): Promise { + assertAuthorizationShape(args); + if (args.providerCustomer.status === VerificationStatus.Approved) throw conflict("This Avenia customer is already approved"); + const claimedCase = await claimAuthorizedStandardMethod(args); + const payloadFingerprint = fingerprintPayload(args.payload); + let kycCase = await prepareSubmission(args, claimedCase.id, payloadFingerprint); + let submission = kycCase.verificationSubmission as IndividualKycSubmission; + const brlaApiService = BrlaApiService.getInstance(); + + if (submission.status === "confirmed") { + if (!kycCase.providerCaseId) throw reconciliationError(); + let attempt: KycAttempt; + try { + const response = await brlaApiService.getVerificationAttemptStatus( + kycCase.providerCaseId, + args.providerCustomer.providerSubaccountId as string + ); + if (response.attempt.id !== kycCase.providerCaseId) throw reconciliationError(); + attempt = response.attempt; + } catch (error) { + if (error instanceof APIError) throw error; + throw reconciliationError(); + } + const retryableTerminal = + attempt.retryable === true && + (attempt.status === KycAttemptStatus.EXPIRED || + (attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.REJECTED)); + if (!retryableTerminal) { + assertSubmissionBinding(submission, args, payloadFingerprint); + return { id: kycCase.providerCaseId }; + } + kycCase = await prepareRetrySubmission(args, kycCase.id, kycCase.providerCaseId, payloadFingerprint); + submission = kycCase.verificationSubmission as IndividualKycSubmission; + } + if (submission.status !== "prepared") { + return reconcileSubmission(brlaApiService, kycCase.id, args, payloadFingerprint); + } + + let attemptBaselineIds: string[]; + try { + await new Promise(resolve => setTimeout(resolve, 5000)); + const [{ documents }, { attempts }] = await Promise.all([ + brlaApiService.getUploadedDocuments(args.payload.subAccountId), + brlaApiService.getKycAttempts(args.payload.subAccountId) + ]); + const requiredDocumentIds = [args.payload.uploadedDocumentId, args.payload.uploadedSelfieId]; + if (!requiredDocumentIds.every(id => documents.some(document => document.id === id && document.ready === true))) { + throw preProviderCheckError(); + } + attemptBaselineIds = [...new Set(attempts.map(attempt => attempt.id))]; + } catch { + await updateClaim(kycCase.id, { errorClassification: "pre_provider_check_failed", status: "failed" }, ["prepared"]); + throw preProviderCheckError(); + } + + const claimed = await claimPreparedSubmission(args, kycCase.id, payloadFingerprint, attemptBaselineIds); + if (claimed instanceof APIError) throw claimed; + if (!claimed) { + const concurrent = await KycCase.findByPk(kycCase.id); + const concurrentSubmission = concurrent?.verificationSubmission; + if (!concurrent || !concurrentSubmission) throw new Error("Standard KYC submission claim disappeared"); + assertSubmissionBinding(concurrentSubmission, args, payloadFingerprint); + if (concurrentSubmission.status === "confirmed" && concurrent.providerCaseId) return { id: concurrent.providerCaseId }; + if (concurrentSubmission.status === "failed") throw preProviderCheckError(); + return reconcileSubmission(brlaApiService, concurrent.id, args, payloadFingerprint); + } + + let attemptId: string; + try { + attemptId = (await brlaApiService.submitKycLevel1(args.payload)).id; + } catch { + await quarantineSubmission(kycCase.id).catch(() => undefined); + throw reconciliationError(); + } + try { + await confirmSubmission(kycCase.id, args.providerCustomer.id, attemptId); + } catch { + await quarantineSubmission(kycCase.id, attemptId).catch(() => undefined); + throw reconciliationError(); + } + return { id: attemptId }; +} diff --git a/apps/api/src/api/services/quote/core/partner-resolution.test.ts b/apps/api/src/api/services/quote/core/partner-resolution.test.ts index 97347abdc..0271ea1a4 100644 --- a/apps/api/src/api/services/quote/core/partner-resolution.test.ts +++ b/apps/api/src/api/services/quote/core/partner-resolution.test.ts @@ -107,6 +107,79 @@ describe("resolveQuotePartner", () => { expect(result.ownerPartnerId).toBeNull(); }); + it("uses the child assignment before the controlling manager assignment", async () => { + const assignmentLookups: string[] = []; + ProfilePartnerAssignment.findOne = mock(async ({ where }: { where: { userId: string } }) => { + assignmentLookups.push(where.userId); + return where.userId === "child-1" ? { partnerId: "child-partner-id" } : { partnerId: "manager-partner-id" }; + }) as unknown as typeof ProfilePartnerAssignment.findOne; + Partner.findOne = mock(async ({ where }: { where: { id?: string } }) => + where.id === "child-partner-id" ? stubPartner("child-partner-id", "ChildPartner") : null + ) as typeof Partner.findOne; + PartnerPricingConfig.findOne = mock(async ({ where }: { where: { partnerId?: string; rampType?: RampDirection } }) => + where.partnerId === "child-partner-id" && where.rampType === RampDirection.BUY + ? stubConfig("child-partner-id", RampDirection.BUY) + : null + ) as typeof PartnerPricingConfig.findOne; + + const result = await resolveQuotePartner({ + ...baseRequest, + controllingManagerProfileId: "manager-1", + userId: "child-1" + }); + + expect(result.source).toBe("profileAssignment"); + expect(result.pricingPartnerId).toBe("child-partner-id"); + expect(result.ownerPartnerId).toBeNull(); + expect(assignmentLookups).toEqual(["child-1"]); + }); + + it("uses the controlling manager assignment when the child has no assignment", async () => { + const assignmentLookups: string[] = []; + ProfilePartnerAssignment.findOne = mock(async ({ where }: { where: { userId: string } }) => { + assignmentLookups.push(where.userId); + return where.userId === "manager-1" ? { partnerId: "manager-partner-id" } : null; + }) as unknown as typeof ProfilePartnerAssignment.findOne; + Partner.findOne = mock(async ({ where }: { where: { id?: string } }) => + where.id === "manager-partner-id" ? stubPartner("manager-partner-id", "ManagerPartner") : null + ) as typeof Partner.findOne; + PartnerPricingConfig.findOne = mock(async ({ where }: { where: { partnerId?: string; rampType?: RampDirection } }) => + where.partnerId === "manager-partner-id" && where.rampType === RampDirection.BUY + ? stubConfig("manager-partner-id", RampDirection.BUY) + : null + ) as typeof PartnerPricingConfig.findOne; + + const result = await resolveQuotePartner({ + ...baseRequest, + controllingManagerProfileId: "manager-1", + userId: "child-1" + }); + + expect(result.source).toBe("managerProfileAssignment"); + expect(result.pricingPartnerId).toBe("manager-partner-id"); + expect(result.ownerPartnerId).toBeNull(); + expect(assignmentLookups).toEqual(["child-1", "manager-1"]); + }); + + it("does not expose manager pricing when an active child assignment is invalid", async () => { + const assignmentLookups: string[] = []; + ProfilePartnerAssignment.findOne = mock(async ({ where }: { where: { userId: string } }) => { + assignmentLookups.push(where.userId); + return where.userId === "child-1" ? { partnerId: null } : { partnerId: "manager-partner-id" }; + }) as unknown as typeof ProfilePartnerAssignment.findOne; + + const result = await resolveQuotePartner({ + ...baseRequest, + controllingManagerProfileId: "manager-1", + userId: "child-1" + }); + + expect(result.source).toBe("none"); + expect(result.pricingPartnerId).toBeNull(); + expect(result.ownerPartnerId).toBeNull(); + expect(assignmentLookups).toEqual(["child-1"]); + }); + it("resolves the sell-direction pricing config for sell users", async () => { ProfilePartnerAssignment.findOne = mock(async () => ({ partnerId: "assigned-id" diff --git a/apps/api/src/api/services/quote/core/partner-resolution.ts b/apps/api/src/api/services/quote/core/partner-resolution.ts index 9b40eb763..b897b3246 100644 --- a/apps/api/src/api/services/quote/core/partner-resolution.ts +++ b/apps/api/src/api/services/quote/core/partner-resolution.ts @@ -7,6 +7,7 @@ import { getTargetFiatCurrency } from "../../phases/blocks/core/helpers"; import type { PartnerPricingSource } from "./types"; type QuotePartnerResolutionRequest = CreateQuoteRequest & { + controllingManagerProfileId?: string; userId?: string; }; @@ -52,7 +53,7 @@ async function findPartnerByIdForRamp( return partner; } -async function findAssignedPartnerId(userId: string, now: Date): Promise { +async function findAssignment(userId: string, now: Date): Promise<{ partnerId: string | null } | null> { const assignment = await ProfilePartnerAssignment.findOne({ order: [["createdAt", "DESC"]], where: { @@ -62,7 +63,7 @@ async function findAssignedPartnerId(userId: string, now: Date): Promise { @@ -74,6 +75,7 @@ export class QuoteService extends BaseRampService { request: CreateBestQuoteRequest & { apiCredentialId?: string; apiKey?: string | null; + controllingManagerProfileId?: string; userId?: string; } ): Promise { @@ -179,6 +181,7 @@ export class QuoteService extends BaseRampService { request: CreateQuoteRequest & { apiCredentialId?: string; apiKey?: string | null; + controllingManagerProfileId?: string; userId?: string; }, skipPersistence = false diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index e9497c3bb..661011056 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -56,6 +56,15 @@ import { BaseRampService } from "./base.service"; import { validateEphemeralAccountsFresh } from "./ephemeral-freshness"; import { getFinalTransactionHashForRampV2 } from "./helpers"; +const CLIENT_WRITABLE_RAMP_STATE_FIELDS = new Set([ + "assethubToPendulumHash", + "squidRouterApproveHash", + "squidRouterNoPermitApproveHash", + "squidRouterNoPermitSwapHash", + "squidRouterNoPermitTransferHash", + "squidRouterSwapHash" +]); + function mergeCompatibilityRecords(label: string, records: readonly unknown[]): Record { const merged: Record = {}; for (const record of records) { @@ -472,6 +481,16 @@ export class RampService extends BaseRampService { RampService.assertStartDeadlineNotExceeded(rampState); + const unsupportedAdditionalDataField = Object.keys(additionalData ?? {}).find( + key => !CLIENT_WRITABLE_RAMP_STATE_FIELDS.has(key) + ); + if (unsupportedAdditionalDataField) { + throw new APIError({ + message: `Ramp additionalData field '${unsupportedAdditionalDataField}' cannot be updated by clients`, + status: httpStatus.BAD_REQUEST + }); + } + // Validate presigned transactions, if some were supplied const ephemerals: { [key in EphemeralAccountType]: string } = { EVM: rampState.state.evmEphemeralAddress, diff --git a/apps/api/src/api/services/ramp/ramp.service.update-additional-data.test.ts b/apps/api/src/api/services/ramp/ramp.service.update-additional-data.test.ts new file mode 100644 index 000000000..0d01a821d --- /dev/null +++ b/apps/api/src/api/services/ramp/ramp.service.update-additional-data.test.ts @@ -0,0 +1,130 @@ +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { EPaymentMethod, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import httpStatus from "http-status"; +import type { Transaction } from "sequelize"; +import { config } from "../../../config/vars"; +import QuoteTicket from "../../../models/quoteTicket.model"; +import RampState from "../../../models/rampState.model"; +import { APIError } from "../../errors/api-error"; +import { RampService } from "./ramp.service"; + +const originalRampFindByPk = RampState.findByPk; +const originalQuoteFindByPk = QuoteTicket.findByPk; + +class TestRampService extends RampService { + protected async withTransaction(callback: (transaction: Transaction) => Promise): Promise { + return callback({} as Transaction); + } +} + +const identities = { + rampA: { aveniaTicketId: "ticket-a", subAccountId: "subaccount-a", taxId: "11144477735" }, + rampB: { aveniaTicketId: "ticket-b", subAccountId: "subaccount-b", taxId: "52998224725" } +}; + +function makeRamp() { + const state = { + ...identities.rampA, + blockState: { aveniaMint: { ...identities.rampA } }, + destinationAddress: "0x1111111111111111111111111111111111111111", + evmEphemeralAddress: "0x2222222222222222222222222222222222222222", + squidRouterApproveHash: "0xexisting", + substrateEphemeralAddress: "" + }; + return { + createdAt: new Date(), + currentPhase: "initial", + flowVariant: config.flowVariant, + from: EPaymentMethod.PIX, + id: "ramp-a", + paymentMethod: EPaymentMethod.PIX, + presignedTxs: [], + quoteId: "quote-a", + state, + to: Networks.Base, + type: RampDirection.BUY, + unsignedTxs: [], + update: mock(async (values: { state?: typeof state }) => { + if (values.state) ramp.state = values.state; + }), + updatedAt: new Date(), + userId: "user-a" + }; +} + +const quote = { + id: "quote-a", + inputAmount: "100", + inputCurrency: FiatToken.BRL, + metadata: { + blocks: {}, + flow: { id: "BrlOnrampBaseDirect" }, + globals: { fees: { usd: {} }, request: {} } + }, + outputAmount: "19", + outputCurrency: "USDC", + rampType: RampDirection.BUY +}; + +let ramp = makeRamp(); + +beforeEach(() => { + ramp = makeRamp(); + RampState.findByPk = mock(async () => ramp) as never; + QuoteTicket.findByPk = mock(async () => quote) as never; +}); + +afterAll(() => { + RampState.findByPk = originalRampFindByPk; + QuoteTicket.findByPk = originalQuoteFindByPk; +}); + +describe("RampService.updateRamp additionalData", () => { + for (const [field, value] of Object.entries({ + aveniaTicketId: identities.rampB.aveniaTicketId, + blockState: { aveniaMint: { ...identities.rampB } }, + subAccountId: identities.rampB.subAccountId, + taxId: identities.rampB.taxId + })) { + it(`rejects client replacement of server-owned ${field}`, async () => { + const service = new TestRampService(); + + await expect( + service.updateRamp({ additionalData: { [field]: value }, presignedTxs: [], rampId: ramp.id }) + ).rejects.toMatchObject({ status: httpStatus.BAD_REQUEST } satisfies Partial); + + expect(ramp.update).not.toHaveBeenCalled(); + expect(ramp.state).toMatchObject({ ...identities.rampA, blockState: { aveniaMint: identities.rampA } }); + }); + } + + for (const field of [ + "assethubToPendulumHash", + "squidRouterApproveHash", + "squidRouterNoPermitApproveHash", + "squidRouterNoPermitSwapHash", + "squidRouterNoPermitTransferHash", + "squidRouterSwapHash" + ] as const) { + it(`preserves partial updates of supported client-reported ${field}`, async () => { + const service = new TestRampService(); + Object.assign(service, { + ephemeralPresignChecksPass: mock(async () => false), + startPersistedFlow: mock(async () => ({})), + tryReleaseDepositQr: mock(async () => false) + }); + + await service.updateRamp({ + additionalData: { [field]: "0xnew" }, + presignedTxs: [], + rampId: ramp.id + }); + + expect(ramp.state).toMatchObject({ + ...identities.rampA, + [field]: "0xnew", + blockState: { aveniaMint: identities.rampA } + }); + }); + } +}); diff --git a/apps/api/src/api/workers/unhandled-payment.worker.test.ts b/apps/api/src/api/workers/unhandled-payment.worker.test.ts index b1a5e1665..99c3c09b5 100644 --- a/apps/api/src/api/workers/unhandled-payment.worker.test.ts +++ b/apps/api/src/api/workers/unhandled-payment.worker.test.ts @@ -1,29 +1,35 @@ import { afterEach, describe, expect, it, mock } from "bun:test"; import { AveniaTicketStatus, EPaymentMethod, Networks } from "@vortexfi/shared"; import ProviderCustomer from "../../models/providerCustomer.model"; +import RampState from "../../models/rampState.model"; import UnhandledPaymentWorker from "./unhandled-payment.worker"; const originalProviderFindOne = ProviderCustomer.findOne; +const originalRampStateFindAll = RampState.findAll; afterEach(() => { ProviderCustomer.findOne = originalProviderFindOne; + RampState.findAll = originalRampStateFindAll; }); -function paidInitialState() { +function paidInitialState(unhandledPaymentAlertSent = false) { return { currentPhase: "initial", id: "ramp-1", state: { aveniaTicketId: "ticket-1", subAccountId: "snapshotted-subaccount", - taxId: "08786985906" + taxId: "08786985906", + unhandledPaymentAlertSent }, update: mock(async () => undefined) }; } -function workerWithPaidTicket(recoverPaidAveniaRamp: (rampId: string) => Promise) { - const getAveniaPayinTickets = mock(async () => [{ id: "ticket-1", status: AveniaTicketStatus.PAID }]); +function workerWithTickets( + recoverPaidAveniaRamp: (rampId: string) => Promise, + getAveniaPayinTickets = mock(async () => [{ id: "ticket-1", status: AveniaTicketStatus.PAID }]) +) { const worker = new UnhandledPaymentWorker("*/15 * * * *", { brlaApiService: { getAveniaPayinTickets } as never, recoverPaidAveniaRamp, @@ -34,11 +40,32 @@ function workerWithPaidTicket(recoverPaidAveniaRamp: (rampId: string) => Promise } describe("UnhandledPaymentWorker paid initial recovery", () => { + it("keeps a pending ticket eligible until a later cycle reports it paid", async () => { + const getTickets = mock() + .mockResolvedValueOnce([{ id: "ticket-1", status: AveniaTicketStatus.PENDING }]) + .mockResolvedValueOnce([{ id: "ticket-1", status: AveniaTicketStatus.PAID }]); + const recover = mock(async () => ({} as never)); + const state = paidInitialState(); + const worker = workerWithTickets(recover, getTickets); + RampState.findAll = mock(async options => { + if (options?.where?.currentPhase !== "initial" || worker.processedStateIds.has(state.id)) { + return []; + } + return [state]; + }) as never; + + await worker.checkUnhandledPayments(); + await worker.checkUnhandledPayments(); + + expect(recover).toHaveBeenCalledTimes(1); + expect(recover).toHaveBeenCalledWith("ramp-1"); + }); + it("starts a provider-confirmed paid initial ramp instead of only alerting", async () => { ProviderCustomer.findOne = mock(async () => ({ providerSubaccountId: "subaccount-1" })) as never; const recover = mock(async () => ({} as never)); const state = paidInitialState(); - const worker = workerWithPaidTicket(recover); + const worker = workerWithTickets(recover); await worker.processStatesForUnhandledPayments([state]); @@ -49,13 +76,41 @@ describe("UnhandledPaymentWorker paid initial recovery", () => { expect(worker.slackNotifier.sendMessage).not.toHaveBeenCalled(); }); + it("recovers a paid initial ramp despite a historical alert flag", async () => { + const recover = mock(async () => ({} as never)); + const state = paidInitialState(true); + const worker = workerWithTickets(recover); + + await worker.processStatesForUnhandledPayments([state]); + + expect(recover).toHaveBeenCalledWith("ramp-1"); + expect(state.update).not.toHaveBeenCalled(); + }); + + it("does not check a successfully recovered initial ramp again", async () => { + const recover = mock(async () => ({} as never)); + const state = paidInitialState(); + const worker = workerWithTickets(recover); + RampState.findAll = mock(async options => { + if (options?.where?.currentPhase !== "initial" || worker.processedStateIds.has(state.id)) { + return []; + } + return [state]; + }) as never; + + await worker.checkUnhandledPayments(); + await worker.checkUnhandledPayments(); + + expect(recover).toHaveBeenCalledTimes(1); + }); + it("alerts but keeps a failed automatic recovery eligible for the next cycle", async () => { ProviderCustomer.findOne = mock(async () => ({ providerSubaccountId: "subaccount-1" })) as never; const recover = mock(async () => { throw new Error("database temporarily unavailable"); }); const state = paidInitialState(); - const worker = workerWithPaidTicket(recover); + const worker = workerWithTickets(recover); await worker.processStatesForUnhandledPayments([state]); await worker.processStatesForUnhandledPayments([state]); @@ -77,7 +132,7 @@ describe("UnhandledPaymentWorker paid initial recovery", () => { to: Networks.AssetHub, unsignedTxs: [] }; - const worker = workerWithPaidTicket(recover); + const worker = workerWithTickets(recover); await worker.processStatesForUnhandledPayments([state]); diff --git a/apps/api/src/api/workers/unhandled-payment.worker.ts b/apps/api/src/api/workers/unhandled-payment.worker.ts index 188469f1e..68dd527f6 100644 --- a/apps/api/src/api/workers/unhandled-payment.worker.ts +++ b/apps/api/src/api/workers/unhandled-payment.worker.ts @@ -168,12 +168,12 @@ class UnhandledPaymentWorker { return; } - // Group states by taxId, filtering for states that have a ticket ID (only pix onramps) - // Also filter out states that have already been alerted for unhandled payments. + // Group states by taxId, filtering for states that have a ticket ID (only pix onramps). + // Historical alerts suppress failed-state notifications, but not initial-ramp recovery. const statesByTaxId: Record = statesToCheck.reduce( (acc, state) => { const { taxId, aveniaTicketId, unhandledPaymentAlertSent } = state.state; - if (taxId && aveniaTicketId && !unhandledPaymentAlertSent) { + if (taxId && aveniaTicketId && (state.currentPhase === "initial" || !unhandledPaymentAlertSent)) { if (!acc[taxId]) { acc[taxId] = []; } @@ -200,7 +200,6 @@ class UnhandledPaymentWorker { const subAccountId = state.state.subAccountId ?? legacySubAccountId; if (!subAccountId) { logger.warn(`No Avenia provider account found for state ${state.id}. Skipping state.`); - this.processedStateIds.add(state.id); continue; } const grouped = statesBySubAccountId.get(subAccountId) ?? []; @@ -218,7 +217,6 @@ class UnhandledPaymentWorker { if (!ticketIdFromState) { // Should not be hit due to the filter in the reducer. logger.warn(`UnhandledPaymentWorker: State ${state.id} is missing an aveniaTicketId. Skipping.`); - this.processedStateIds.add(state.id); continue; } @@ -246,8 +244,6 @@ class UnhandledPaymentWorker { if (state.currentPhase !== "initial") { await this.updateAlertedState(state); } - } else { - this.processedStateIds.add(state.id); } } } diff --git a/apps/api/src/config/corsConfig.ts b/apps/api/src/config/corsConfig.ts index 85e67c211..c2b17316c 100644 --- a/apps/api/src/config/corsConfig.ts +++ b/apps/api/src/config/corsConfig.ts @@ -15,7 +15,8 @@ export const corsOptions: CorsOptions = { "X-Public-Key", "X-Managed-Profile-Id", "X-Request-ID", - "X-Correlation-ID" + "X-Correlation-ID", + "Idempotency-Key" ], credentials: true, exposedHeaders: ["X-Request-ID"], diff --git a/apps/api/src/config/express.test.ts b/apps/api/src/config/express.test.ts new file mode 100644 index 000000000..68e2c6062 --- /dev/null +++ b/apps/api/src/config/express.test.ts @@ -0,0 +1,28 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import app from "./express"; + +describe("full Express app", () => { + let server: ReturnType; + let baseUrl: string; + + beforeAll(() => { + server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Could not bind test server"); + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(() => server.close()); + + it("applies Helmet to the auth-first token import route", async () => { + const response = await fetch(`${baseUrl}/v1/brla/kyc/import-token`, { + body: "{", + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + + expect(response.status).toBe(401); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(await response.json()).toMatchObject({ error: { code: "AUTHENTICATION_REQUIRED", status: 401 } }); + }); +}); diff --git a/apps/api/src/config/express.ts b/apps/api/src/config/express.ts index ef1002b53..f4314828b 100644 --- a/apps/api/src/config/express.ts +++ b/apps/api/src/config/express.ts @@ -12,6 +12,7 @@ import { converter, handler, notFound } from "../api/middlewares/error"; import { requestContext } from "../api/observability/requestContext"; import routes from "../api/routes/v1"; import aveniaWebhookRoutes from "../api/routes/v1/avenia-webhook.route"; +import brlaKycImportRoutes from "../api/routes/v1/brla-kyc-import.route"; import { corsOptions } from "./corsConfig"; import { config } from "./vars"; @@ -50,6 +51,12 @@ app.use(requestContext); // request logging. dev: console | production: file app.use(morgan(logs)); +// secure apps by setting various HTTP headers +app.use(helmet()); + +// Authenticate and authorize this sensitive token-bearing request before buffering JSON. +app.use("/v1/brla/kyc/import-token", brlaKycImportRoutes); + // Mounted ahead of the JSON parser: Avenia signs the raw request body, and a payload // that has been parsed and re-serialised does not reproduce those bytes exactly. // Own, small limit: webhook events are a few KB, and this unauthenticated route should @@ -67,9 +74,6 @@ app.use(compress()); // in places where the client doesn't support it app.use(methodOverride()); -// secure apps by setting various HTTP headers -app.use(helmet()); - // mount api token routes app.use("/v1", routes); diff --git a/apps/api/src/database/066-add-kyc-verification-state.test.ts b/apps/api/src/database/066-add-kyc-verification-state.test.ts new file mode 100644 index 000000000..d0e336729 --- /dev/null +++ b/apps/api/src/database/066-add-kyc-verification-state.test.ts @@ -0,0 +1,191 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import sequelize from "../config/database"; +import CustomerEntity from "../models/customerEntity.model"; +import KycCase from "../models/kycCase.model"; +import ProviderCustomer, { VerificationStatus } from "../models/providerCustomer.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestUser } from "../test-utils/factories"; +import { down, up } from "./migrations/066-add-kyc-verification-state"; + +async function createCase(options: { customerType?: "individual" | "business"; providerCustomer?: boolean; type?: "kyc" | "kyb" } = {}): Promise { + const customerType = options.customerType ?? "individual"; + const user = await createTestUser(); + const entity = await CustomerEntity.create({ country: "BR", profileId: user.id, status: "active", type: customerType }); + const providerCustomer = + options.providerCustomer === false + ? null + : await ProviderCustomer.create({ + country: "BR", + customerEntityId: entity.id, + customerType, + provider: "avenia", + providerCustomerId: `avenia-${crypto.randomUUID()}`, + rail: "brl", + status: VerificationStatus.Pending + }); + return KycCase.create({ + customerEntityId: entity.id, + provider: "avenia", + providerCustomerId: providerCustomer?.id ?? null, + status: VerificationStatus.Pending, + type: options.type ?? "kyc" + }); +} + +describe("066 KYC verification state", () => { + beforeAll(async () => { + await setupTestDatabase(); + await resetTestDatabase(); + }); + + it("backfills every existing Avenia KYC case while leaving new cases nullable and enforcing state", async () => { + const existing = await createCase(); + const existingBusiness = await createCase({ customerType: "business" }); + const existingWithoutCustomer = await createCase({ providerCustomer: false }); + const existingKyb = await createCase({ customerType: "business", type: "kyb" }); + expect(existing.verificationMethod).toBeNull(); + + await down(sequelize.getQueryInterface()); + await up(sequelize.getQueryInterface()); + + expect((await existing.reload()).verificationMethod).toBe("standard"); + expect((await existingBusiness.reload()).verificationMethod).toBe("standard"); + expect((await existingWithoutCustomer.reload()).verificationMethod).toBe("standard"); + expect((await existingKyb.reload()).verificationMethod).toBeNull(); + const [submissionTable] = await sequelize.query( + "SELECT to_regclass('public.kyc_verification_submissions') AS \"tableName\"" + ); + expect((submissionTable[0] as { tableName: string | null }).tableName).toBeNull(); + const createdAfterMigration = await createCase(); + expect(createdAfterMigration.verificationMethod).toBeNull(); + expect(createdAfterMigration.verificationSubmission).toBeNull(); + + await expect(createdAfterMigration.update({ verificationMethod: "sumsub_share_token" })).resolves.toBeInstanceOf(KycCase); + await expect(createdAfterMigration.update({ verificationMethod: "standard" })).rejects.toThrow(); + await expect( + sequelize.query("UPDATE kyc_cases SET verification_submission = '{\"status\":\"invalid\"}' WHERE id = :id", { + replacements: { id: createdAfterMigration.id } + }) + ).rejects.toThrow(); + + const invalidSubmissions = [ + { actorProfileId: "actor", attemptBaselineIds: [], status: null, subjectProfileId: "subject" }, + { actorProfileId: "actor", attemptBaselineIds: [], status: "prepared", subjectProfileId: "subject" }, + { actorProfileId: "actor", attemptBaselineIds: ["valid", null], status: "prepared", subjectProfileId: "subject" }, + { actorProfileId: "actor", attemptBaselineIds: ["valid", 1], status: "prepared", subjectProfileId: "subject" }, + { actorProfileId: "actor", attemptBaselineIds: [""], status: "prepared", subjectProfileId: "subject" }, + { + actorProfileId: "actor", + attemptBaselineIds: [], + consentAttestations: [], + idempotencyKeyHash: "key-hash", + status: "prepared", + subjectProfileId: "subject", + tokenFingerprint: "token-fingerprint" + }, + { + actorProfileId: "actor", + attemptBaselineIds: [], + consentAttestations: [ + { + actorProfileId: "actor", + attestedAt: new Date().toISOString(), + policyVersion: "sumsub-share-v1", + subjectProfileId: "subject" + } + ], + idempotencyKeyHash: "key-hash", + importToken: "raw-token-must-never-be-persisted", + status: "prepared", + subjectProfileId: "subject", + tokenFingerprint: "token-fingerprint" + }, + { + actorProfileId: "actor", + attemptBaselineIds: [], + consentAttestations: [ + { + actorProfileId: "actor", + attestedAt: new Date().toISOString(), + extra: "unknown", + policyVersion: "sumsub-share-v1", + subjectProfileId: "subject" + } + ], + idempotencyKeyHash: "key-hash", + status: "prepared", + subjectProfileId: "subject", + tokenFingerprint: "token-fingerprint" + } + ]; + for (const submission of invalidSubmissions) { + await expect( + sequelize.query("UPDATE kyc_cases SET verification_submission = CAST(:submission AS JSONB) WHERE id = :id", { + replacements: { id: createdAfterMigration.id, submission: JSON.stringify(submission) } + }) + ).rejects.toThrow(); + } + + await createdAfterMigration.update({ + verificationSubmission: { + actorProfileId: crypto.randomUUID(), + attemptBaselineIds: [], + consentAttestations: [ + { + actorProfileId: crypto.randomUUID(), + attestedAt: new Date().toISOString(), + policyVersion: "sumsub-share-v1", + subjectProfileId: crypto.randomUUID() + } + ], + idempotencyKeyHash: "key-hash", + status: "prepared", + subjectProfileId: crypto.randomUUID(), + tokenFingerprint: "token-fingerprint" + } + }); + + const standardCase = await createCase(); + await standardCase.update({ verificationMethod: "standard" }); + await expect( + sequelize.query("UPDATE kyc_cases SET verification_submission = CAST(:submission AS JSONB) WHERE id = :id", { + replacements: { + id: standardCase.id, + submission: JSON.stringify({ + actorProfileId: "actor", + attemptBaselineIds: [], + status: "prepared", + subjectProfileId: "subject" + }) + } + }) + ).rejects.toThrow(); + await expect( + sequelize.query("UPDATE kyc_cases SET verification_submission = CAST(:submission AS JSONB) WHERE id = :id", { + replacements: { + id: standardCase.id, + submission: JSON.stringify({ + actorProfileId: "actor", + attemptBaselineIds: [], + idempotencyKeyHash: "forbidden-token-field", + payloadFingerprint: "payload-fingerprint", + status: "prepared", + subjectProfileId: "subject" + }) + } + }) + ).rejects.toThrow(); + await standardCase.update({ + verificationSubmission: { + actorProfileId: "actor", + attemptBaselineIds: [], + payloadFingerprint: "payload-fingerprint", + status: "prepared", + subjectProfileId: "subject" + } + }); + await expect(down(sequelize.getQueryInterface())).rejects.toThrow( + "Cannot revert KYC verification state while verification state exists" + ); + }); +}); diff --git a/apps/api/src/database/migrations/065-add-kyc-case-ubo-submissions.ts b/apps/api/src/database/migrations/065-add-kyc-case-ubo-submissions.ts new file mode 100644 index 000000000..a8a4be2da --- /dev/null +++ b/apps/api/src/database/migrations/065-add-kyc-case-ubo-submissions.ts @@ -0,0 +1,13 @@ +import { DataTypes, type QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("kyc_cases", "ubo_submissions", { + allowNull: false, + defaultValue: {}, + type: DataTypes.JSONB + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("kyc_cases", "ubo_submissions"); +} diff --git a/apps/api/src/database/migrations/066-add-kyc-verification-state.ts b/apps/api/src/database/migrations/066-add-kyc-verification-state.ts new file mode 100644 index 000000000..d9b687c61 --- /dev/null +++ b/apps/api/src/database/migrations/066-add-kyc-verification-state.ts @@ -0,0 +1,157 @@ +import { DataTypes, type QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.transaction(async transaction => { + await queryInterface.addColumn( + "kyc_cases", + "verification_method", + { + allowNull: true, + type: DataTypes.STRING(32) + }, + { transaction } + ); + await queryInterface.sequelize.query( + `ALTER TABLE kyc_cases + ADD CONSTRAINT chk_kyc_cases_verification_method CHECK ( + verification_method IS NULL + OR verification_method IN ('standard', 'sumsub_share_token') + )`, + { transaction } + ); + await queryInterface.addColumn( + "kyc_cases", + "verification_submission", + { + allowNull: true, + type: DataTypes.JSONB + }, + { transaction } + ); + await queryInterface.sequelize.query( + `ALTER TABLE kyc_cases + ADD CONSTRAINT chk_kyc_cases_verification_submission CHECK ( + verification_submission IS NULL + OR ( + jsonb_typeof(verification_submission) = 'object' + AND verification_submission ? 'status' + AND verification_submission ? 'actorProfileId' + AND verification_submission ? 'subjectProfileId' + AND verification_submission ? 'attemptBaselineIds' + AND jsonb_typeof(verification_submission->'status') = 'string' + AND verification_submission->>'status' IN ('prepared', 'submitted', 'confirmed', 'ambiguous', 'failed') + AND jsonb_typeof(verification_submission->'actorProfileId') = 'string' + AND verification_submission->>'actorProfileId' <> '' + AND jsonb_typeof(verification_submission->'subjectProfileId') = 'string' + AND verification_submission->>'subjectProfileId' <> '' + AND jsonb_typeof(verification_submission->'attemptBaselineIds') = 'array' + AND NOT jsonb_path_exists( + verification_submission, + '$.attemptBaselineIds[*] ? (@.type() != "string" || @ == "")' + ) + AND ( + NOT verification_submission ? 'errorClassification' + OR ( + jsonb_typeof(verification_submission->'errorClassification') = 'string' + AND verification_submission->>'errorClassification' <> '' + ) + ) + AND ( + ( + verification_method = 'sumsub_share_token' + AND verification_submission - ARRAY[ + 'status', 'actorProfileId', 'subjectProfileId', 'attemptBaselineIds', 'errorClassification', + 'idempotencyKeyHash', 'tokenFingerprint', 'consentAttestations' + ] = '{}'::jsonb + AND verification_submission ? 'idempotencyKeyHash' + AND verification_submission ? 'tokenFingerprint' + AND verification_submission ? 'consentAttestations' + AND jsonb_typeof(verification_submission->'idempotencyKeyHash') = 'string' + AND verification_submission->>'idempotencyKeyHash' <> '' + AND jsonb_typeof(verification_submission->'tokenFingerprint') = 'string' + AND verification_submission->>'tokenFingerprint' <> '' + AND jsonb_typeof(verification_submission->'consentAttestations') = 'array' + AND jsonb_array_length(verification_submission->'consentAttestations') > 0 + AND NOT jsonb_path_exists( + verification_submission, + '$.consentAttestations[*] ? (@.type() != "object" || !exists(@.actorProfileId) || @.actorProfileId.type() != "string" || @.actorProfileId == "" || !exists(@.subjectProfileId) || @.subjectProfileId.type() != "string" || @.subjectProfileId == "" || !exists(@.policyVersion) || @.policyVersion.type() != "string" || @.policyVersion == "" || !exists(@.attestedAt) || @.attestedAt.type() != "string" || @.attestedAt == "")' + ) + AND NOT jsonb_path_exists( + verification_submission, + '$.consentAttestations[*].keyvalue() ? (@.key != "actorProfileId" && @.key != "subjectProfileId" && @.key != "policyVersion" && @.key != "attestedAt")' + ) + ) + OR ( + verification_method = 'standard' + AND verification_submission - ARRAY[ + 'status', 'actorProfileId', 'subjectProfileId', 'attemptBaselineIds', 'errorClassification', + 'payloadFingerprint' + ] = '{}'::jsonb + AND verification_submission ? 'payloadFingerprint' + AND jsonb_typeof(verification_submission->'payloadFingerprint') = 'string' + AND verification_submission->>'payloadFingerprint' <> '' + ) + ) + ) + ); + + UPDATE kyc_cases AS kc + SET verification_method = 'standard' + WHERE kc.type = 'kyc' + AND kc.provider = 'avenia';`, + { transaction } + ); + await queryInterface.sequelize.query( + `CREATE FUNCTION enforce_kyc_case_verification_method_immutable() RETURNS trigger AS $$ + BEGIN + IF OLD.verification_method IS NOT NULL + AND OLD.verification_method IS DISTINCT FROM NEW.verification_method THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + CONSTRAINT = 'chk_kyc_cases_verification_method_immutable', + MESSAGE = 'KYC case verification method cannot be cleared or changed after selection'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER trg_kyc_cases_verification_method_immutable + BEFORE UPDATE OF verification_method ON kyc_cases + FOR EACH ROW EXECUTE FUNCTION enforce_kyc_case_verification_method_immutable();`, + { transaction } + ); + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.transaction(async transaction => { + await queryInterface.sequelize.query( + `LOCK TABLE kyc_cases IN ACCESS EXCLUSIVE MODE; + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM kyc_cases + WHERE verification_method IS NOT NULL OR verification_submission IS NOT NULL + ) THEN + RAISE EXCEPTION 'Cannot revert KYC verification state while verification state exists'; + END IF; + END; + $$;`, + { transaction } + ); + await queryInterface.sequelize.query("DROP TRIGGER trg_kyc_cases_verification_method_immutable ON kyc_cases;", { + transaction + }); + await queryInterface.sequelize.query("DROP FUNCTION enforce_kyc_case_verification_method_immutable();", { + transaction + }); + await queryInterface.sequelize.query("ALTER TABLE kyc_cases DROP CONSTRAINT chk_kyc_cases_verification_method;", { + transaction + }); + await queryInterface.sequelize.query("ALTER TABLE kyc_cases DROP CONSTRAINT chk_kyc_cases_verification_submission;", { + transaction + }); + await queryInterface.removeColumn("kyc_cases", "verification_submission", { transaction }); + await queryInterface.removeColumn("kyc_cases", "verification_method", { transaction }); + }); +} diff --git a/apps/api/src/models/kycCase.model.ts b/apps/api/src/models/kycCase.model.ts index d5451ec4a..e15945288 100644 --- a/apps/api/src/models/kycCase.model.ts +++ b/apps/api/src/models/kycCase.model.ts @@ -4,6 +4,36 @@ import type CustomerEntity from "./customerEntity.model"; import type { ProviderName, VerificationStatus } from "./providerCustomer.model"; export type KycCaseType = "kyc" | "kyb"; +export type KycVerificationMethod = "standard" | "sumsub_share_token"; +export type IndividualKycSubmissionStatus = "prepared" | "submitted" | "confirmed" | "ambiguous" | "failed"; + +export interface ConsentAttestation { + actorProfileId: string; + subjectProfileId: string; + policyVersion: string; + attestedAt: string; +} + +export interface IndividualKycSubmission { + status: IndividualKycSubmissionStatus; + actorProfileId: string; + subjectProfileId: string; + idempotencyKeyHash?: string; + tokenFingerprint?: string; + payloadFingerprint?: string; + attemptBaselineIds: string[]; + errorClassification?: string; + consentAttestations?: ConsentAttestation[]; +} + +export interface UboSubmission { + status: "prepared" | "confirmed" | "ambiguous" | "failed"; + payloadFingerprint: string; + attemptedAt: string; + confirmedAt?: string; + providerUboId?: string; + httpStatus?: number; +} // 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). @@ -21,6 +51,9 @@ export interface KycCaseAttributes { submittedAt: Date | null; approvedAt: Date | null; rejectedAt: Date | null; + uboSubmissions: Record; + verificationMethod: KycVerificationMethod | null; + verificationSubmission: IndividualKycSubmission | null; createdAt: Date; updatedAt: Date; } @@ -37,6 +70,9 @@ type KycCaseCreationAttributes = Optional< | "submittedAt" | "approvedAt" | "rejectedAt" + | "uboSubmissions" + | "verificationMethod" + | "verificationSubmission" | "createdAt" | "updatedAt" >; @@ -55,6 +91,9 @@ class KycCase extends Model implem declare submittedAt: Date | null; declare approvedAt: Date | null; declare rejectedAt: Date | null; + declare uboSubmissions: Record; + declare verificationMethod: KycVerificationMethod | null; + declare verificationSubmission: IndividualKycSubmission | null; declare createdAt: Date; declare updatedAt: Date; @@ -145,11 +184,28 @@ KycCase.init( defaultValue: "kyc", type: DataTypes.STRING(8) }, + uboSubmissions: { + allowNull: false, + defaultValue: {}, + field: "ubo_submissions", + type: DataTypes.JSONB + }, updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE + }, + verificationMethod: { + allowNull: true, + field: "verification_method", + type: DataTypes.STRING(32), + validate: { isIn: [["standard", "sumsub_share_token"]] } + }, + verificationSubmission: { + allowNull: true, + field: "verification_submission", + type: DataTypes.JSONB } }, { diff --git a/apps/api/src/tests/avenia-kyc-concurrency.integration.test.ts b/apps/api/src/tests/avenia-kyc-concurrency.integration.test.ts new file mode 100644 index 000000000..386794661 --- /dev/null +++ b/apps/api/src/tests/avenia-kyc-concurrency.integration.test.ts @@ -0,0 +1,352 @@ +import { BrlaApiService, type KycLevel1Payload } from "@vortexfi/shared"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import httpStatus from "http-status"; +import { APIError } from "../api/errors/api-error"; +import { claimStandardAveniaKycMethod, importAveniaKycToken } from "../api/services/avenia/avenia-kyc-import.service"; +import { submitStandardAveniaKyc } from "../api/services/avenia/avenia-standard-kyc.service"; +import sequelize from "../config/database"; +import CustomerEntity from "../models/customerEntity.model"; +import KycCase from "../models/kycCase.model"; +import ProviderCustomer, { VerificationStatus } from "../models/providerCustomer.model"; +import type User from "../models/user.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestUser } from "../test-utils/factories"; + +const originalGetInstance = BrlaApiService.getInstance; + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve: () => void = () => undefined; + const promise = new Promise(resolvePromise => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function bounded(promise: Promise, label: string): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), 8_000); + }) + ]); + } finally { + clearTimeout(timeout); + } +} + +interface Fixture { + kycCase: KycCase; + providerCustomer: ProviderCustomer; + subject: User; +} + +async function createFixture(): Promise { + const subject = await createTestUser(); + const entity = await CustomerEntity.create({ + country: "BR", + profileId: subject.id, + status: "active", + type: "individual" + }); + await subject.update({ activeCustomerEntityId: entity.id }); + const providerCustomer = await ProviderCustomer.create({ + country: "BR", + customerEntityId: entity.id, + customerType: "individual", + provider: "avenia", + providerSubaccountId: "subaccount-1", + rail: "brl", + status: VerificationStatus.Started + }); + const kycCase = await KycCase.create({ + customerEntityId: entity.id, + provider: "avenia", + providerCustomerId: providerCustomer.id, + status: VerificationStatus.Started, + type: "kyc" + }); + return { kycCase, providerCustomer, subject }; +} + +function providerWithBarriers() { + const historyEntered = deferred(); + const releaseHistory = deferred(); + const importEntered = deferred(); + const releaseImport = deferred(); + let blockHistory = true; + const importKycToken = mock(async () => { + importEntered.resolve(); + await releaseImport.promise; + return { id: "import-attempt-1", message: "processing" }; + }); + const submitKycLevel1 = mock(async () => ({ id: "standard-attempt-1" })); + const provider = { + getKycAttempts: mock(async () => { + if (blockHistory) { + blockHistory = false; + historyEntered.resolve(); + await releaseHistory.promise; + } + return { attempts: [] }; + }), + getUploadedDocuments: mock(async () => ({ documents: [] })), + importKycToken, + submitKycLevel1 + } as unknown as BrlaApiService; + BrlaApiService.getInstance = mock(() => provider); + return { historyEntered, importEntered, importKycToken, releaseHistory, releaseImport, submitKycLevel1 }; +} + +function importRequest(subject: User, idempotencyKey: string, importToken: string) { + return { + actorProfileId: subject.id, + idempotencyKey, + importToken, + subjectProfileId: subject.id + }; +} + +const standardPayload: KycLevel1Payload = { + city: "Sao Paulo", + country: "BR", + countryOfTaxId: "BR", + dateOfBirth: "1990-01-01", + email: "person@example.com", + fullName: "Test Person", + state: "SP", + streetAddress: "Test street", + subAccountId: "subaccount-1", + taxIdNumber: "12345678900", + uploadedDocumentId: "document-1", + uploadedSelfieId: "selfie-1", + zipCode: "00000-000" +}; + +describe("Avenia KYC concurrency", () => { + beforeAll(setupTestDatabase); + beforeEach(async () => { + await resetTestDatabase(); + }); + afterEach(() => { + BrlaApiService.getInstance = originalGetInstance; + }); + + it("serializes concurrent token-import claims before the provider POST", async () => { + const fixture = await createFixture(); + const barriers = providerWithBarriers(); + const first = importAveniaKycToken(importRequest(fixture.subject, "request-1", "share-token-1")); + + try { + await bounded(barriers.historyEntered.promise, "the first claim to hold the case lock"); + const second = importAveniaKycToken(importRequest(fixture.subject, "request-2", "share-token-2")).catch(error => error); + barriers.releaseHistory.resolve(); + + await bounded(barriers.importEntered.promise, "the provider import POST"); + const secondResult = await bounded(second, "the competing token claim"); + expect(secondResult).toBeInstanceOf(APIError); + expect(secondResult).toMatchObject({ + message: "Another token import requires reconciliation", + status: httpStatus.CONFLICT + }); + expect(barriers.importKycToken).toHaveBeenCalledTimes(1); + + barriers.releaseImport.resolve(); + await expect(bounded(first, "the winning token import")).resolves.toEqual({ + attemptId: "import-attempt-1", + status: "pending" + }); + expect((await fixture.kycCase.reload()).verificationMethod).toBe("sumsub_share_token"); + expect(await fixture.kycCase.reload()).toMatchObject({ + providerCaseId: "import-attempt-1", + verificationSubmission: { status: "confirmed" } + }); + } finally { + barriers.releaseHistory.resolve(); + barriers.releaseImport.resolve(); + await Promise.allSettled([first]); + } + }); + + it("serializes token import against standard method selection and provider mutation", async () => { + const fixture = await createFixture(); + const barriers = providerWithBarriers(); + const tokenImport = importAveniaKycToken(importRequest(fixture.subject, "request-1", "share-token-1")); + + try { + await bounded(barriers.historyEntered.promise, "the token claim to hold the case lock"); + const standardSubmission = submitStandardAveniaKyc({ + actorProfileId: fixture.subject.id, + payload: standardPayload, + providerCustomer: fixture.providerCustomer, + subjectProfileId: fixture.subject.id + }).catch(error => error); + barriers.releaseHistory.resolve(); + + await bounded(barriers.importEntered.promise, "the token provider mutation"); + const standardResult = await bounded(standardSubmission, "standard method selection"); + expect(standardResult).toBeInstanceOf(APIError); + expect(standardResult).toMatchObject({ + message: "This KYC case uses Sumsub token import", + status: httpStatus.CONFLICT + }); + expect(barriers.importKycToken).toHaveBeenCalledTimes(1); + expect(barriers.submitKycLevel1).not.toHaveBeenCalled(); + + barriers.releaseImport.resolve(); + await expect(bounded(tokenImport, "the token import")).resolves.toEqual({ + attemptId: "import-attempt-1", + status: "pending" + }); + expect((await fixture.kycCase.reload()).verificationMethod).toBe("sumsub_share_token"); + expect((await fixture.kycCase.reload()).verificationSubmission).toMatchObject({ status: "confirmed" }); + } finally { + barriers.releaseHistory.resolve(); + barriers.releaseImport.resolve(); + await Promise.allSettled([tokenImport]); + } + }); + + it("rejects token import before its provider POST when the standard method claim wins", async () => { + const fixture = await createFixture(); + const barriers = providerWithBarriers(); + barriers.releaseHistory.resolve(); + barriers.releaseImport.resolve(); + const standardClaim = claimStandardAveniaKycMethod(fixture.providerCustomer); + const tokenImport = importAveniaKycToken(importRequest(fixture.subject, "request-1", "share-token-1")).catch(error => error); + + try { + await expect(bounded(standardClaim, "the winning standard claim")).resolves.toMatchObject({ + id: fixture.kycCase.id, + verificationMethod: "standard" + }); + const tokenResult = await bounded(tokenImport, "the competing token claim"); + expect(tokenResult).toBeInstanceOf(APIError); + expect(tokenResult).toMatchObject({ + message: "This KYC case uses the standard Avenia method", + status: httpStatus.CONFLICT + }); + expect((await fixture.kycCase.reload()).verificationMethod).toBe("standard"); + expect((await fixture.kycCase.reload()).verificationSubmission).toBeNull(); + } finally { + await Promise.allSettled([standardClaim, tokenImport]); + } + }); + + it("does not POST token import when concurrent approval wins the canonical locks", async () => { + const fixture = await createFixture(); + const baselineEntered = deferred(); + const releaseBaseline = deferred(); + const importKycToken = mock(async () => ({ id: "must-not-import", message: "processing" })); + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => { + baselineEntered.resolve(); + await releaseBaseline.promise; + return { attempts: [] }; + }), + importKycToken + }) as unknown as BrlaApiService + ); + const submission = importAveniaKycToken(importRequest(fixture.subject, "request-1", "share-token-1")).catch( + error => error + ); + + try { + await bounded(baselineEntered.promise, "the token import baseline read"); + await sequelize.transaction(async transaction => { + const providerCustomer = await ProviderCustomer.findByPk(fixture.providerCustomer.id, { + lock: transaction.LOCK.UPDATE, + transaction + }); + const kycCase = await KycCase.findByPk(fixture.kycCase.id, { lock: transaction.LOCK.UPDATE, transaction }); + await providerCustomer?.update({ status: VerificationStatus.Approved }, { transaction }); + await kycCase?.update({ approvedAt: new Date(), status: VerificationStatus.Approved }, { transaction }); + }); + releaseBaseline.resolve(); + + const result = await bounded(submission, "the approval-losing token import"); + expect(result).toBeInstanceOf(APIError); + expect(result).toMatchObject({ message: "The Avenia KYC is already approved", status: httpStatus.CONFLICT }); + expect(importKycToken).not.toHaveBeenCalled(); + } finally { + releaseBaseline.resolve(); + await Promise.allSettled([submission]); + } + }); + + it("does not POST standard KYC when concurrent approval wins the canonical locks", async () => { + const fixture = await createFixture(); + const baselineEntered = deferred(); + const releaseBaseline = deferred(); + const submitKycLevel1 = mock(async () => ({ id: "must-not-submit" })); + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => { + baselineEntered.resolve(); + await releaseBaseline.promise; + return { attempts: [] }; + }), + getUploadedDocuments: mock(async () => ({ + documents: [ + { id: standardPayload.uploadedDocumentId, ready: true }, + { id: standardPayload.uploadedSelfieId, ready: true } + ] + })), + submitKycLevel1 + }) as unknown as BrlaApiService + ); + const originalSetTimeout = globalThis.setTimeout; + const timeout = spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, + delay?: number, + ...args: unknown[] + ) => { + if (delay === 5_000) { + callback(); + return 0; + } + return originalSetTimeout(callback, delay, ...args); + }) as typeof setTimeout); + const submission = submitStandardAveniaKyc({ + actorProfileId: fixture.subject.id, + payload: standardPayload, + providerCustomer: fixture.providerCustomer, + subjectProfileId: fixture.subject.id + }).catch(error => error); + + try { + await bounded( + Promise.race([ + baselineEntered.promise, + submission.then(result => { + throw result; + }) + ]), + "the standard submission baseline read" + ); + await sequelize.transaction(async transaction => { + const providerCustomer = await ProviderCustomer.findByPk(fixture.providerCustomer.id, { + lock: transaction.LOCK.UPDATE, + transaction + }); + const kycCase = await KycCase.findByPk(fixture.kycCase.id, { lock: transaction.LOCK.UPDATE, transaction }); + await providerCustomer?.update({ status: VerificationStatus.Approved }, { transaction }); + await kycCase?.update({ approvedAt: new Date(), status: VerificationStatus.Approved }, { transaction }); + }); + releaseBaseline.resolve(); + + const result = await bounded(submission, "the approval-losing standard submission"); + expect(result).toBeInstanceOf(APIError); + expect(result).toMatchObject({ status: httpStatus.CONFLICT }); + expect(submitKycLevel1).not.toHaveBeenCalled(); + } finally { + releaseBaseline.resolve(); + timeout.mockRestore(); + await Promise.allSettled([submission]); + } + }); +}); 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/apps/api/src/tests/http-surface.invariants.test.ts b/apps/api/src/tests/http-surface.invariants.test.ts index 812c8dba0..efe9d71e9 100644 --- a/apps/api/src/tests/http-surface.invariants.test.ts +++ b/apps/api/src/tests/http-surface.invariants.test.ts @@ -370,6 +370,23 @@ describe("HTTP surface: auth flow, webhooks, history, public routes", () => { const buyIds = (buyMethods.body.paymentMethods as Array<{ id: string }>).map(method => method.id); expect(buyIds.sort()).toEqual(["ach", "pix", "spei"]); + const requirements = await requestJson("/v1/onboarding/requirements?country=BR&customerType=business"); + expect(requirements.status).toBe(200); + expect(requirements.body).toMatchObject({ + country: "BR", + customerType: "business", + flow: "avenia-br-business-level-1-api-kyb", + provider: "avenia" + }); + expect(requirements.body).not.toHaveProperty("fields"); + expect((requirements.body.steps as Array<{ operationId?: string }>).map(step => step.operationId).filter(Boolean)).toContain( + "submitAveniaKybLevel1Api" + ); + expect((requirements.body.steps as Array<{ method?: string }>).some(step => step.method === "GET")).toBe(false); + + const unsupportedRequirements = await requestJson("/v1/onboarding/requirements?country=AR&customerType=business"); + expect(unsupportedRequirements.status).toBe(404); + const mxnMethods = await requestJson("/v1/supported-payment-methods?fiat=MXN"); expect(mxnMethods.status).toBe(200); expect((mxnMethods.body.paymentMethods as Array<{ id: string }>).map(method => method.id)).toEqual(["spei"]); diff --git a/apps/api/src/tests/managed-profile-quote-ramp-lifecycle.integration.test.ts b/apps/api/src/tests/managed-profile-quote-ramp-lifecycle.integration.test.ts index f226636bf..7f3970e42 100644 --- a/apps/api/src/tests/managed-profile-quote-ramp-lifecycle.integration.test.ts +++ b/apps/api/src/tests/managed-profile-quote-ramp-lifecycle.integration.test.ts @@ -86,6 +86,20 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { payoutAddressEvm: DESTINATION, rampType: RampDirection.BUY }); + const managerPricingPartner = await createTestPartner({ + fiatCurrency: FiatToken.BRL, + markupCurrency: FiatToken.BRL, + markupType: "absolute", + markupValue: 2, + payoutAddressEvm: DESTINATION, + rampType: RampDirection.BUY + }); + await ProfilePartnerAssignment.create({ + isActive: true, + partnerId: managerPricingPartner.id, + partnerName: managerPricingPartner.name, + userId: manager.id + }); await ProfilePartnerAssignment.create({ isActive: true, partnerId: pricingPartner.id, @@ -180,6 +194,17 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { expect(Number(directQuoteResponse.partnerFeeFiat)).toBe(5); expect(Number(directQuoteResponse.outputAmount)).toBe(99.9); + const delegatedSiblingQuoteResponse = await createQuote({ + ...managerHeaders, + "X-Managed-Profile-Id": siblingId + }); + const delegatedSiblingQuote = await QuoteTicket.findByPk(delegatedSiblingQuoteResponse.id as string); + expect(delegatedSiblingQuote?.userId).toBe(siblingId); + expect(delegatedSiblingQuote?.partnerId).toBeNull(); + expect(delegatedSiblingQuote?.pricingPartnerId).toBe(managerPricingPartner.id); + expect(delegatedSiblingQuote?.apiCredentialId).toBe(managerCredential.record.id); + expect(Number(delegatedSiblingQuoteResponse.partnerFeeFiat)).toBe(2); + const pendingQuoteResponse = await createQuote({ "Content-Type": "application/json", "X-API-Key": childCredential.secretKey @@ -214,6 +239,12 @@ describe("managed-profile quote and registered-ramp lifecycle", () => { "X-API-Key": siblingCredential.secretKey }); const siblingQuoteId = siblingQuoteResponse.id as string; + const siblingQuote = await QuoteTicket.findByPk(siblingQuoteId); + expect(siblingQuote?.userId).toBe(siblingId); + expect(siblingQuote?.partnerId).toBeNull(); + expect(siblingQuote?.pricingPartnerId).toBe(managerPricingPartner.id); + expect(siblingQuote?.apiCredentialId).toBe(siblingCredential.id); + expect(Number(siblingQuoteResponse.partnerFeeFiat)).toBe(2); const siblingRegistration = await jsonRequest("/v1/ramp/register", { body: JSON.stringify({ additionalData: { destinationAddress: DESTINATION, taxId: "12345678902" }, diff --git a/apps/api/src/tests/notifications-onboarding.integration.test.ts b/apps/api/src/tests/notifications-onboarding.integration.test.ts index cda4db354..e07f6cb12 100644 --- a/apps/api/src/tests/notifications-onboarding.integration.test.ts +++ b/apps/api/src/tests/notifications-onboarding.integration.test.ts @@ -335,6 +335,15 @@ describe("GET /v1/onboarding/status", () => { const { user, token } = await createAuthedUser("avenia-late-approval@example.com"); const customer = await createTestTaxId(user.id, { subAccountId: "sub-late" }); await customer.update({ status: VerificationStatus.InReview }); + await KycCase.create({ + customerEntityId: customer.customerEntityId, + level: "level_1", + provider: "avenia", + providerCustomerId: customer.id, + status: VerificationStatus.InReview, + type: "kyc", + verificationMethod: "standard" + }); const getInstance = BrlaApiService.getInstance; BrlaApiService.getInstance = mock( @@ -362,6 +371,15 @@ describe("GET /v1/onboarding/status", () => { const { user, token } = await createAuthedUser("avenia-kyc-expired@example.com"); const customer = await createTestTaxId(user.id, { subAccountId: "sub-expired" }); await customer.update({ status: VerificationStatus.InReview }); + await KycCase.create({ + customerEntityId: customer.customerEntityId, + level: "level_1", + provider: "avenia", + providerCustomerId: customer.id, + status: VerificationStatus.InReview, + type: "kyc", + verificationMethod: "standard" + }); const getInstance = BrlaApiService.getInstance; BrlaApiService.getInstance = mock( @@ -387,6 +405,15 @@ describe("GET /v1/onboarding/status", () => { const { user, token } = await createAuthedUser("avenia-kyc-unfinished@example.com"); const customer = await createTestTaxId(user.id, { subAccountId: "sub-unfinished" }); await customer.update({ status: VerificationStatus.InReview }); + await KycCase.create({ + customerEntityId: customer.customerEntityId, + level: "level_1", + provider: "avenia", + providerCustomerId: customer.id, + status: VerificationStatus.InReview, + type: "kyc", + verificationMethod: "standard" + }); const getInstance = BrlaApiService.getInstance; BrlaApiService.getInstance = mock( @@ -408,10 +435,457 @@ describe("GET /v1/onboarding/status", () => { expect(customer.status).toBe(VerificationStatus.Pending); }); + it("does not persist a completed individual attempt without a result", async () => { + const { user, token } = await createAuthedUser("avenia-kyc-completed-no-result@example.com"); + const customer = await createTestTaxId(user.id, { subAccountId: "sub-completed-no-result" }); + await customer.update({ status: VerificationStatus.InReview, statusExternal: "UNCHANGED" }); + const kycCase = await KycCase.create({ + customerEntityId: customer.customerEntityId, + level: "level_1", + provider: "avenia", + providerCustomerId: customer.id, + status: VerificationStatus.InReview, + statusExternal: "UNCHANGED", + type: "kyc", + verificationMethod: "standard" + }); + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => ({ attempts: [{ status: KycAttemptStatus.COMPLETED }] })) + }) as unknown as BrlaApiService + ); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + } finally { + BrlaApiService.getInstance = getInstance; + } + + await customer.reload(); + await kycCase.reload(); + expect(customer.status).toBe(VerificationStatus.InReview); + expect(customer.statusExternal).toBe("UNCHANGED"); + expect(kycCase.status).toBe(VerificationStatus.InReview); + expect(kycCase.statusExternal).toBe("UNCHANGED"); + }); + + it("polls an individual case by its exact current attempt id", async () => { + const { user, token } = await createAuthedUser("avenia-kyc-exact@example.com"); + const customer = await createTestTaxId(user.id, { subAccountId: "sub-exact" }); + await customer.update({ status: VerificationStatus.InReview }); + const kycCase = await KycCase.create({ + customerEntityId: customer.customerEntityId, + level: "level_1", + provider: "avenia", + providerCaseId: "attempt-exact", + providerCustomerId: customer.id, + status: VerificationStatus.InReview, + type: "kyc", + verificationMethod: "standard" + }); + const getVerificationAttemptStatus = mock(async () => ({ + attempt: { id: "attempt-exact", status: KycAttemptStatus.PROCESSING } + })); + const getKycAttempts = mock(async () => ({ attempts: [] })); + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => ({ getKycAttempts, getVerificationAttemptStatus }) as unknown as BrlaApiService + ); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + } finally { + BrlaApiService.getInstance = getInstance; + } + + expect(getVerificationAttemptStatus).toHaveBeenCalledWith("attempt-exact", "sub-exact"); + expect(getKycAttempts).not.toHaveBeenCalled(); + await customer.reload(); + await kycCase.reload(); + expect(customer.status).toBe(VerificationStatus.InReview); + expect(kycCase.statusExternal).toBe(KycAttemptStatus.PROCESSING); + }); + + it("does not fall back to attempt lists for an imported case with no attempt id", async () => { + const { user, token } = await createAuthedUser("avenia-import-no-id@example.com"); + const customer = await createTestTaxId(user.id, { subAccountId: "sub-import-no-id" }); + await customer.update({ status: VerificationStatus.InReview, statusExternal: "UNCHANGED" }); + await KycCase.create({ + customerEntityId: customer.customerEntityId, + level: "level_1", + provider: "avenia", + providerCustomerId: customer.id, + status: VerificationStatus.InReview, + statusExternal: "UNCHANGED", + type: "kyc", + verificationMethod: "sumsub_share_token" + }); + const getKycAttempts = mock(async () => ({ + attempts: [{ result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED }] + })); + const getVerificationAttemptStatus = mock(async () => ({ + attempt: { id: "unbound", result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED } + })); + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => ({ getKycAttempts, getVerificationAttemptStatus }) as unknown as BrlaApiService + ); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + } finally { + BrlaApiService.getInstance = getInstance; + } + + expect(getKycAttempts).not.toHaveBeenCalled(); + expect(getVerificationAttemptStatus).not.toHaveBeenCalled(); + await customer.reload(); + expect(customer.status).toBe(VerificationStatus.InReview); + expect(customer.statusExternal).toBe("UNCHANGED"); + }); + + it("skips an unbound active standard submission instead of applying the latest provider attempt", async () => { + const { user, token } = await createAuthedUser("avenia-standard-reconciliation@example.com"); + const customer = await createTestTaxId(user.id, { subAccountId: "sub-standard-reconciliation" }); + await customer.update({ status: VerificationStatus.InReview, statusExternal: "UNCHANGED" }); + await KycCase.create({ + customerEntityId: customer.customerEntityId, + level: "level_1", + provider: "avenia", + providerCustomerId: customer.id, + status: VerificationStatus.InReview, + statusExternal: "UNCHANGED", + type: "kyc", + verificationMethod: "standard", + verificationSubmission: { + actorProfileId: user.id, + attemptBaselineIds: [], + payloadFingerprint: "test-payload-fingerprint", + status: "submitted", + subjectProfileId: user.id + } + }); + const getKycAttempts = mock(async () => ({ + attempts: [{ result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED }] + })); + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock(() => ({ getKycAttempts }) as unknown as BrlaApiService); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + } finally { + BrlaApiService.getInstance = getInstance; + } + + expect(getKycAttempts).not.toHaveBeenCalled(); + await customer.reload(); + expect(customer.status).toBe(VerificationStatus.InReview); + expect(customer.statusExternal).toBe("UNCHANGED"); + }); + + it("locks a nullable case to standard before interpreting provider history", async () => { + const { user, token } = await createAuthedUser("avenia-legacy-import@example.com"); + const customer = await createTestTaxId(user.id, { subAccountId: "sub-legacy-import" }); + await customer.update({ status: VerificationStatus.InReview, statusExternal: "UNCHANGED" }); + const kycCase = await KycCase.create({ + customerEntityId: customer.customerEntityId, + level: "level_1", + provider: "avenia", + providerCustomerId: customer.id, + status: VerificationStatus.InReview, + statusExternal: "UNCHANGED", + type: "kyc", + verificationMethod: null + }); + const getVerificationAttemptStatus = mock(async () => ({ + attempt: { id: "legacy-import", result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED } + })); + const getKycAttempts = mock(async () => ({ attempts: [{ id: "legacy-import", levelName: "sumsub-token-recipient" }] })); + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts, + getUploadedDocuments: mock(async () => ({ documents: [] })), + getVerificationAttemptStatus + }) as unknown as BrlaApiService + ); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + } finally { + BrlaApiService.getInstance = getInstance; + } + + expect(getVerificationAttemptStatus).not.toHaveBeenCalled(); + expect(getKycAttempts).toHaveBeenCalledTimes(1); + await customer.reload(); + await kycCase.reload(); + expect(customer.status).toBe(VerificationStatus.InReview); + expect(customer.statusExternal).toBe("UNCHANGED"); + expect(kycCase.verificationMethod).toBe("standard"); + expect(kycCase.providerCaseId).toBeNull(); + }); + + it("does not use mixed Avenia history to infer a token-import method", async () => { + const { user, token } = await createAuthedUser("avenia-legacy-mixed@example.com"); + const customer = await createTestTaxId(user.id, { subAccountId: "sub-legacy-mixed" }); + await customer.update({ status: VerificationStatus.InReview, statusExternal: "UNCHANGED" }); + const kycCase = await KycCase.create({ + customerEntityId: customer.customerEntityId, + level: "level_1", + provider: "avenia", + providerCustomerId: customer.id, + status: VerificationStatus.InReview, + statusExternal: "UNCHANGED", + type: "kyc", + verificationMethod: null + }); + const getKycAttempts = mock(async () => ({ + attempts: [{ levelName: "level-1" }, { levelName: "sumsub-token-recipient" }] + })); + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts, + getUploadedDocuments: mock(async () => ({ documents: [] })) + }) as unknown as BrlaApiService + ); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + } finally { + BrlaApiService.getInstance = getInstance; + } + + await customer.reload(); + await kycCase.reload(); + expect(customer.status).toBe(VerificationStatus.InReview); + expect(customer.statusExternal).toBe("UNCHANGED"); + expect(kycCase.verificationMethod).toBe("standard"); + expect(getKycAttempts).toHaveBeenCalledTimes(1); + }); + + it("does not approve imported onboarding status when Avenia exposes a different CPF", async () => { + const { user, token } = await createAuthedUser("avenia-import-tax-mismatch@example.com"); + const customer = await createTestTaxId(user.id, { subAccountId: "sub-import-tax-mismatch", taxId: "08786985906" }); + await customer.update({ status: VerificationStatus.InReview }); + const kycCase = await KycCase.create({ + customerEntityId: customer.customerEntityId, + level: "level_1", + provider: "avenia", + providerCaseId: "attempt-imported", + providerCustomerId: customer.id, + status: VerificationStatus.InReview, + type: "kyc", + verificationMethod: "sumsub_share_token" + }); + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => + ({ + getVerificationAttemptStatus: mock(async () => ({ + attempt: { id: "attempt-imported", result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED } + })), + subaccountInfo: mock(async () => ({ accountInfo: { taxId: "111.444.777-35" } })) + }) as unknown as BrlaApiService + ); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + } finally { + BrlaApiService.getInstance = getInstance; + } + + await customer.reload(); + await kycCase.reload(); + expect(customer.status).toBe(VerificationStatus.InReview); + expect(kycCase.status).toBe(VerificationStatus.InReview); + }); + + it("approves imported onboarding status when Avenia returns a formatted matching CPF", async () => { + const { user, token } = await createAuthedUser("avenia-import-tax-match@example.com"); + const customer = await createTestTaxId(user.id, { subAccountId: "sub-import-tax-match", taxId: "08786985906" }); + await customer.update({ status: VerificationStatus.InReview }); + const kycCase = await KycCase.create({ + customerEntityId: customer.customerEntityId, + level: "level_1", + provider: "avenia", + providerCaseId: "attempt-imported", + providerCustomerId: customer.id, + status: VerificationStatus.InReview, + type: "kyc", + verificationMethod: "sumsub_share_token" + }); + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => + ({ + getVerificationAttemptStatus: mock(async () => ({ + attempt: { id: "attempt-imported", result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED } + })), + subaccountInfo: mock(async () => ({ accountInfo: { taxId: "087.869.859-06" } })) + }) as unknown as BrlaApiService + ); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + } finally { + BrlaApiService.getInstance = getInstance; + } + + await customer.reload(); + await kycCase.reload(); + expect(customer.status).toBe(VerificationStatus.Approved); + expect(kycCase.status).toBe(VerificationStatus.Approved); + }); + + it("leaves onboarding unchanged when exact polling returns a mismatched attempt", async () => { + const { user, token } = await createAuthedUser("avenia-attempt-mismatch@example.com"); + const customer = await createTestTaxId(user.id, { subAccountId: "sub-mismatch" }); + await customer.update({ status: VerificationStatus.InReview, statusExternal: "UNCHANGED" }); + const kycCase = await KycCase.create({ + customerEntityId: customer.customerEntityId, + level: "level_1", + provider: "avenia", + providerCaseId: "attempt-current", + providerCustomerId: customer.id, + status: VerificationStatus.InReview, + statusExternal: "UNCHANGED", + type: "kyc", + verificationMethod: "standard" + }); + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => + ({ + getVerificationAttemptStatus: mock(async () => ({ + attempt: { id: "attempt-other", result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED } + })) + }) as unknown as BrlaApiService + ); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + } finally { + BrlaApiService.getInstance = getInstance; + } + + await customer.reload(); + await kycCase.reload(); + expect(customer.status).toBe(VerificationStatus.InReview); + expect(customer.statusExternal).toBe("UNCHANGED"); + expect(kycCase.statusExternal).toBe("UNCHANGED"); + }); + + it("keeps an imported individual case pending when its exact attempt expires", async () => { + const { user, token } = await createAuthedUser("avenia-import-expired@example.com"); + const customer = await createTestTaxId(user.id, { subAccountId: "sub-import-expired" }); + await customer.update({ status: VerificationStatus.InReview }); + const kycCase = await KycCase.create({ + customerEntityId: customer.customerEntityId, + level: "level_1", + provider: "avenia", + providerCaseId: "attempt-imported", + providerCustomerId: customer.id, + status: VerificationStatus.InReview, + type: "kyc", + verificationMethod: "sumsub_share_token" + }); + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => + ({ + getVerificationAttemptStatus: mock(async () => ({ + attempt: { id: "attempt-imported", status: KycAttemptStatus.EXPIRED } + })) + }) as unknown as BrlaApiService + ); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + } finally { + BrlaApiService.getInstance = getInstance; + } + + await customer.reload(); + await kycCase.reload(); + expect(customer.status).toBe(VerificationStatus.Pending); + expect(kycCase.status).toBe(VerificationStatus.Pending); + expect(kycCase.statusExternal).toBe(KycAttemptStatus.EXPIRED); + }); + + it("preserves a rejection committed before stale processing progress is persisted", async () => { + const { user, token } = await createAuthedUser("avenia-stale-processing@example.com"); + const customer = await createTestTaxId(user.id, { subAccountId: "sub-stale-processing" }); + await customer.update({ status: VerificationStatus.InReview }); + const kycCase = await KycCase.create({ + customerEntityId: customer.customerEntityId, + level: "level_1", + provider: "avenia", + providerCaseId: "attempt-stale", + providerCustomerId: customer.id, + status: VerificationStatus.InReview, + type: "kyc", + verificationMethod: "standard" + }); + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => + ({ + getVerificationAttemptStatus: mock(async () => { + await customer.update({ status: VerificationStatus.Rejected, statusExternal: KycAttemptStatus.COMPLETED }); + await kycCase.update({ + rejectedAt: new Date(), + status: VerificationStatus.Rejected, + statusExternal: KycAttemptStatus.COMPLETED + }); + return { attempt: { id: "attempt-stale", status: KycAttemptStatus.PROCESSING } }; + }) + }) as unknown as BrlaApiService + ); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + } finally { + BrlaApiService.getInstance = getInstance; + } + + await customer.reload(); + await kycCase.reload(); + expect(customer.status).toBe(VerificationStatus.Rejected); + expect(customer.statusExternal).toBe(KycAttemptStatus.COMPLETED); + expect(kycCase.status).toBe(VerificationStatus.Rejected); + expect(kycCase.statusExternal).toBe(KycAttemptStatus.COMPLETED); + }); + it("throttles provider refreshes: back-to-back polls hit Avenia only once per customer", async () => { const { user, token } = await createAuthedUser("avenia-refresh-throttle@example.com"); const customer = await createTestTaxId(user.id, { subAccountId: "sub-throttled" }); await customer.update({ status: VerificationStatus.InReview }); + await KycCase.create({ + customerEntityId: customer.customerEntityId, + level: "level_1", + provider: "avenia", + providerCustomerId: customer.id, + status: VerificationStatus.InReview, + type: "kyc", + verificationMethod: "standard" + }); const getKycAttempts = mock(async () => ({ attempts: [{ result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED }] diff --git a/docs/README.md b/docs/README.md index 277051951..897488aa5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,8 @@ The smaller set of general project documents stays directly in `docs/`: | [`operations-testing.md`](operations-testing.md) | Maintained test strategy and suite boundaries | | [`product-dashboard.md`](product-dashboard.md) | Current dashboard product scope and acknowledged gaps | | [`proposal-mcp-server.md`](proposal-mcp-server.md) | Active, non-authoritative discussion draft | +| [`proposal-api-driven-kyc-kyb.md`](proposal-api-driven-kyc-kyb.md) | Proposal for API-driven verification using preserved provider-specific workflows | +| [`proposal-sumsub-kyc-token-sharing.md`](proposal-sumsub-kyc-token-sharing.md) | Implemented and enabled in code on the branch; production readiness still awaits provider, legal, and sandbox confirmation | 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/adr-0003-managed-headless-profiles.md b/docs/adr-0003-managed-headless-profiles.md index 037bdd91e..26325f700 100644 --- a/docs/adr-0003-managed-headless-profiles.md +++ b/docs/adr-0003-managed-headless-profiles.md @@ -27,7 +27,9 @@ than introduce a parallel tenant or impersonation model. non-empty subset. It never expands the canonical matrix. - Provisioning creates an `individual` or `business` child, active entity, immutable external subject ID, normalized provider contact email, and relationship atomically. - Pricing is resolved from the child and remains independent of management. + A managed child defaults to its controlling manager profile's pricing assignment. The + child may have its own profile pricing assignment, administered like any regular + profile assignment, which takes precedence over the manager assignment. - Deletion is logical and idempotent. It revokes child credentials and blocks new child activity while retaining provider, compliance, quote, ramp, callback, and attribution records needed for in-flight processing and reconciliation. diff --git a/docs/api/apidog/page-manifest.json b/docs/api/apidog/page-manifest.json index 7bc1a11ff..fae835852 100644 --- a/docs/api/apidog/page-manifest.json +++ b/docs/api/apidog/page-manifest.json @@ -32,7 +32,12 @@ "/v1/brla/getUser", "/v1/brla/getUserRemainingLimit", "/v1/brla/kyb/attempt-status", + "/v1/brla/kyb/documents", + "/v1/brla/kyb/documents/{documentId}", + "/v1/brla/kyb/new-level-1/api", "/v1/brla/kyb/new-level-1/web-sdk", + "/v1/brla/kyb/ubos", + "/v1/brla/kyc/import-token", "/v1/brla/kyc/record-attempt", "/v1/brla/newKyc", "/v1/brla/validatePixKey", @@ -41,6 +46,8 @@ "/v1/managed-profiles/{profileId}", "/v1/managed-profiles/{profileId}/api-credentials", "/v1/managed-profiles/{profileId}/api-credentials/{credentialId}", + "/v1/onboarding/active-entity", + "/v1/onboarding/requirements", "/v1/onboarding/status", "/v1/public-key", "/v1/quotes", @@ -127,10 +134,11 @@ "secret key", "public key", "partner key", + "KYC token import", "OTP sign-in", "crypto ramp authentication" ], - "metaDescription": "How Vortex authenticates API clients: pk_*/sk_* key pairs, partner-scoped vs user-linked keys, email OTP sign-in, and minting user API keys programmatically.", + "metaDescription": "How Vortex authenticates clients with pk_*/sk_* keys or Supabase sessions, including managed-child delegation and secure BRL KYC token import.", "metaTitle": "Authentication And API Keys — Vortex API" }, "slug": "authentication-and-partner-keys", @@ -237,10 +245,11 @@ "SPEI crypto", "CBU crypto", "BRL on-ramp", + "Sumsub KYC token", "EUR off-ramp", "Brazil Mexico Colombia Argentina crypto" ], - "metaDescription": "Corridor requirements for BRL (PIX), EUR (SEPA), USD (ACH), MXN (SPEI), COP, and ARS (CBU): payment rails, KYC prerequisites, fiat accounts, and limits.", + "metaDescription": "Corridor requirements for BRL, EUR, USD, MXN, COP, and ARS, including payment rails, KYC prerequisites, BRL token import, accounts, and limits.", "metaTitle": "Fiat Corridors — PIX, SEPA, ACH, SPEI, CBU" }, "slug": "fiat-corridors", diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index b54cd0d9e..fe589273b 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -201,7 +201,7 @@ export interface paths { * Mark an Alfredpay redirect finished * @description Records that the effective customer finished the hosted KYC or KYB redirect flow. */ - post: operations["markAlfredpayKycRedirectFinished"]; + post: operations["notifyAlfredpayKycRedirectFinished"]; delete?: never; options?: never; head?: never; @@ -221,7 +221,7 @@ export interface paths { * Mark an Alfredpay redirect opened * @description Records that the effective customer's hosted KYC or KYB redirect was opened. */ - post: operations["markAlfredpayKycRedirectOpened"]; + post: operations["notifyAlfredpayKycRedirectOpened"]; delete?: never; options?: never; head?: never; @@ -630,6 +630,66 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/brla/kyb/documents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Avenia KYB document + * @description Creates an Avenia document and returns presigned upload targets. Upload bytes directly to the returned URLs. + */ + post: operations["createAveniaKybDocument"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/brla/kyb/documents/{documentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Avenia KYB document + * @description Reads readiness and upload status for an owned Avenia KYB document. + */ + get: operations["getAveniaKybDocument"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/brla/kyb/new-level-1/api": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Submit API-driven Avenia KYB + * @description Submits the API-driven Avenia Level 1 KYB attempt after validating the owned corporate documents and UBO references. + */ + post: operations["submitAveniaKybLevel1Api"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/brla/kyb/new-level-1/web-sdk": { parameters: { query?: never; @@ -643,7 +703,53 @@ export interface paths { * Start Avenia hosted KYB * @description Starts or resumes Avenia's hosted KYB level-1 flow for an owned company subaccount. */ - post: operations["initiateAveniaKybLevel1"]; + post: operations["startAveniaKybLevel1Hosted"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/brla/kyb/ubos": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Avenia KYB UBO + * @description Registers a UBO after verifying that referenced identity documents are ready and owned by the company subaccount. + */ + post: operations["createAveniaKybUbo"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/brla/kyc/import-token": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Import an individual Avenia KYC token + * @description Imports an opaque Sumsub share token into the authenticated subject's existing individual Avenia KYC case. This alternative path is enabled by approved Vortex policy despite unresolved legal/consent wording and provider-environment confirmations; no live sandbox verification is claimed. Authentication and profile-bound principal enforcement run before managed-profile authorization and strict body validation. Use either a profile-bound secret `X-API-Key` or a Supabase Bearer session. A controlling manager may add `X-Managed-Profile-Id`; direct managed-child credentials are rejected even without the selector. Public and ownerless credentials are insufficient. + * + * The body accepts only `importToken` and literal `consentAttested: true`; CPF, tax ID, subaccount ID, applicant ID, entity ID, provider-customer ID, profile ID, and other caller identity selectors are forbidden. The provisional server-controlled consent policy is `sumsub-share-v1`. Every token claim appends actor, subject, policy version, and timestamp consent evidence without storing the raw token. + * + * The first normal KYC artifact, status read, or token-import claim permanently selects that case's method. Import the token before reading KYC or onboarding status because a status read selects a nullable method as `standard`. The same idempotency key and token returns a stored confirmed attempt or safely reconciles a durable submitted/ambiguous claim through provider reads, without another provider POST or replaying the token. A different token under the same key returns `409`. A provider `401` means the feature precondition is unavailable, records a failed attempt, returns `412`, and may be retried only with a new idempotency key; the new claim appends consent evidence while preserving prior attestations. Every other post-send provider, transport, malformed-response, timeout, or local-confirmation failure is ambiguous, returns `502`, and is never replayed automatically. + * + * Acceptance is pending only. Vortex polls the exact returned Avenia attempt; `EXPIRED` remains non-approved and locally pending for reconciliation, and its external status is retained. Only Avenia `COMPLETED` plus `APPROVED` completes KYC. The Avenia webhook is notification-only and cannot approve the case. + */ + post: operations["importAveniaKycToken"]; delete?: never; options?: never; head?: never; @@ -661,9 +767,9 @@ export interface paths { put?: never; /** * Record an initial Avenia KYC attempt - * @description Records the first observed KYC attempt for a CPF or CNPJ when no provider-customer record exists yet. + * @description Validates an authenticated BRL onboarding preflight event. The asserted CPF or CNPJ is not persisted because quote ownership does not prove tax-ID ownership. */ - post: operations["recordAveniaInitialKycAttempt"]; + post: operations["recordInitialAveniaKycAttempt"]; delete?: never; options?: never; head?: never; @@ -700,7 +806,7 @@ export interface paths { cookie?: never; }; /** - * Validate Pix key + * Validate PIX key * @description Checks whether a Pix key exists and is valid. The key value itself is intentionally not echoed back in the response for security. * * **Auth:** requires `Authorization: Bearer `. @@ -842,6 +948,46 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/onboarding/active-entity": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Select active customer entity + * @description Selects the authenticated profile's immutable active customer-entity type. Managed-child delegation is not supported. + */ + put: operations["selectActiveCustomerEntity"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/onboarding/requirements": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Discover KYC or KYB requirements + * @description Returns versioned document and ordered action metadata for an existing Avenia or Alfredpay onboarding flow. GET operations, status polling, and readiness checks are intentionally omitted and remain documented in the integration guides and OpenAPI. Request fields and bodies are defined only by the referenced OpenAPI schemas and are not duplicated at the top level. This endpoint does not return profile state or customer PII. Monerium is outside this discovery proposal. + */ + get: operations["getOnboardingRequirements"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/onboarding/status": { parameters: { query?: never; @@ -1336,16 +1482,16 @@ export interface paths { put?: never; /** * Update ramp process - * @description Submits presigned transactions and additional data to an existing ramp process before starting it. + * @description Submits presigned transactions and supported client-reported transaction hashes to an existing ramp process before starting it. * This endpoint can be called many times, and data can be incrementally added to the ramp. * - * Note: For both pre-signed transactions and the generic `additionalData` object, existing properties will be overriden by new values. + * Note: For both pre-signed transactions and `additionalData`, existing properties will be overridden by new values. * * ### Required data for ramps. * The signed counterpart of the initial unsignedTxs object must be provided for all ramps, as required by the object. - * For offramps, the `additionalData` field must contain the confirmation hash corresponding to the inital transaction in which the user sends the funds. - * If the originating chain is `Assethub`, then `assetHubToPendulumHash` must be provided. - * If the originating chain is any `EVM` chain, then `squidRouterSwapHash` must be provided. `squidRouterApproveHash` is only required when an approval transaction was actually submitted; if the wallet already holds a sufficient allowance for the router, it can be omitted. + * For offramps, the `additionalData` field must contain the confirmation hash corresponding to the initial transaction in which the user sends the funds. + * If the originating chain is `AssetHub`, then `assethubToPendulumHash` must be provided. + * If the originating chain is any EVM chain, then `squidRouterSwapHash` must be provided. `squidRouterApproveHash` is only required when an approval transaction was actually submitted; if the wallet already holds a sufficient allowance for the router, it can be omitted. No-permit flows use the corresponding `squidRouterNoPermit*Hash` fields. * * For onramps, no additional data is required after registering the ramp. */ @@ -1894,7 +2040,7 @@ export interface components { type: components["schemas"]["AlfredpayFiatAccountType"]; }; /** @enum {string} */ - AlfredpayCountry: "AR" | "BO" | "BR" | "CL" | "CN" | "CO" | "DO" | "HK" | "MX" | "PE" | "US"; + AlfredpayCountry: "AR" | "CO" | "MX" | "US"; AlfredpayCountryAndCustomerTypeRequest: { country: components["schemas"]["AlfredpayCountry"]; type?: components["schemas"]["AlfredpayCustomerType"]; @@ -1902,6 +2048,9 @@ export interface components { AlfredpayCountryRequest: { country: components["schemas"]["AlfredpayCountry"]; }; + AlfredpayCreateCustomerRequest: { + country: components["schemas"]["AlfredpayCountry"]; + }; AlfredpayCreateCustomerResponse: { /** Format: date-time */ createdAt: string; @@ -1939,7 +2088,14 @@ export interface components { }[]; submissionId: string; }; + AlfredpayKybDetailsResponse: { + relatedPersons: { + idRelatedPerson: string; + }[]; + submissionId: string; + }[]; AlfredpayKybFileUploadRequest: { + /** @enum {string} */ country: components["schemas"]["AlfredpayCountry"]; /** Format: binary */ file: string; @@ -1959,7 +2115,17 @@ export interface components { nationalities: string[]; pep?: boolean; }; + AlfredpayKybRelatedPersonFileUploadRequest: { + /** @enum {string} */ + country: "CO" | "MX"; + /** Format: binary */ + file: string; + /** @enum {string} */ + fileType: "docFront" | "docBack"; + relatedPersonId: string; + }; AlfredpayKycFileUploadRequest: { + /** @enum {string} */ country: components["schemas"]["AlfredpayCountry"]; /** Format: binary */ file: string; @@ -1969,7 +2135,7 @@ export interface components { }; AlfredpayKycStatusResponse: { alfred_pay_id: string; - country: string; + country: components["schemas"]["AlfredpayCountry"]; lastFailure?: string; status: components["schemas"]["AlfredpayStatus"]; /** Format: date-time */ @@ -1981,6 +2147,10 @@ export interface components { /** Format: uri */ verification_url: string; }; + AlfredpayRedirectNotificationRequest: { + country: components["schemas"]["AlfredpayCountry"]; + type?: components["schemas"]["AlfredpayCustomerType"]; + }; AlfredpayRelatedPersonFileUploadRequest: { country: components["schemas"]["AlfredpayCountry"]; /** Format: binary */ @@ -1989,14 +2159,17 @@ export interface components { fileType: "docFront" | "docBack"; relatedPersonId: string; }; + AlfredpayRetryRequest: components["schemas"]["AlfredpayRedirectNotificationRequest"]; + AlfredpayRetryResponse: components["schemas"]["AlfredpayRedirectLinkResponse"] | components["schemas"]["SuccessResponse"]; AlfredpaySendSubmissionRequest: { + /** @enum {string} */ country: components["schemas"]["AlfredpayCountry"]; submissionId: string; }; /** @enum {string} */ AlfredpayStatus: "CONSULTED" | "LINK_OPENED" | "USER_COMPLETED" | "VERIFYING" | "FAILED" | "SUCCESS" | "UPDATE_REQUIRED"; AlfredpayStatusResponse: { - country: string; + country: components["schemas"]["AlfredpayCountry"]; /** Format: date-time */ creationTime: string; status: components["schemas"]["AlfredpayStatus"]; @@ -2099,9 +2272,11 @@ export interface components { message: string; }; /** @enum {string} */ - AveniaDocumentType: "ID" | "DRIVERS-LICENSE" | "PASSPORT" | "SELFIE" | "SELFIE-FROM-LIVENESS"; + AveniaDocumentType: "ID" | "DRIVERS-LICENSE" | "PASSPORT" | "RESIDENCE-PERMIT" | "SELFIE" | "SELFIE-FROM-LIVENESS" | "CERTIFICATE-OF-INCORPORATION" | "COMPANY-TAX-IDENTIFICATION-DOCUMENT"; AveniaKYCDataUploadRequest: { - documentType: components["schemas"]["AveniaDocumentType"]; + /** @enum {string} */ + documentType: "ID" | "DRIVERS-LICENSE"; + isDoubleSided?: boolean; /** @description CPF or CNPJ. */ taxId: string; }; @@ -2109,6 +2284,107 @@ export interface components { idUpload: components["schemas"]["DocumentUploadEntry"]; selfieUpload: components["schemas"]["DocumentUploadEntry"]; }; + AveniaKybAttemptStatusResponse: { + failureReason?: string; + /** @enum {string} */ + result?: "APPROVED" | "REJECTED"; + retryable?: boolean; + /** @enum {string} */ + status: "PENDING" | "PROCESSING" | "COMPLETED" | "EXPIRED"; + }; + AveniaKybDocumentRequest: { + documentType: components["schemas"]["AveniaDocumentType"]; + isDoubleSided?: boolean; + }; + AveniaKybDocumentResponse: { + document: { + documentType: components["schemas"]["AveniaDocumentType"]; + id: string; + ready: boolean; + uploadErrorBack?: string; + uploadErrorFront?: string; + uploadStatusBack?: string; + uploadStatusFront: string; + }; + }; + AveniaKybDocumentUploadResponse: { + id: string; + /** Format: uri */ + livenessUrl?: string; + /** Format: uri */ + uploadURLBack?: string; + /** Format: uri */ + uploadURLFront: string; + validateLivenessToken?: string; + }; + AveniaKybHostedResponse: { + attemptId: string; + /** Format: uri */ + authorizedRepresentativeUrl: string; + /** Format: uri */ + basicCompanyDataUrl: string; + }; + AveniaKybLevel1Payload: { + businessActivityDescription: string; + certificateOfIncorporationDocumentId: string; + companyCity: string; + companyCountry: string; + companyLegalName: string; + companyRegistrationNumber: string; + companyState: string; + companyStreetLine1: string; + companyStreetLine2?: string; + companyStreetLine3?: string; + companyZipCode: string; + countrySubdivisionTaxResidence?: string; + countryTaxResidence: string; + /** Format: email */ + emailPixKey?: string; + /** @enum {string} */ + estimatedAnnualRevenueUsd: "less_than_100k" | "100k_to_1m" | "1m_to_10m" | "10m_to_50m" | "50m_to_100m" | "more_than_100m"; + estimatedMonthlyVolumeUsd: string; + /** @enum {string} */ + numberOfEmployees: "1-10" | "11-50" | "51-200" | "201-500" | "501-1000" | "1001+"; + /** @enum {string} */ + reasonForAccountOpening: "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?: boolean; + /** Format: uri */ + socialMedia?: string; + /** @enum {string} */ + sourceOfFundsAndIncome: "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: string; + taxIdentificationNumberTin: string; + uboIds: string[]; + /** Format: uri */ + website?: string; + }; + /** @enum {string} */ + 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"; + AveniaUboPayload: { + city: string; + country: string; + countryOfTaxId: string; + /** Format: date */ + dateOfBirth: string; + documentCountry: string; + /** Format: email */ + email?: string; + fullName: string; + hasControl?: components["schemas"]["AveniaUboControlRole"]; + percentageOfOwnership: string; + phone?: string; + state: string; + streetLine1: string; + streetLine2?: string; + streetLine3?: string; + taxIdNumber: string; + uploadedIdentificationId: string; + uploadedSelfieId?: string; + zipCode: string; + }; + AveniaUboResponse: { + id: string; + }; BrlaAddress: { cep: string; city: string; @@ -2132,6 +2408,28 @@ export interface components { uploadURLFront: string; validateLivenessToken: string; }; + BrlaImportKycTokenErrorResponse: { + /** + * @description Stable, non-secret token-import error. Provider response bodies and the import token are never returned. + * @enum {string} + */ + error: "Idempotency-Key must contain 1 to 128 visible ASCII characters" | "Invalid request body" | "importToken must contain between 1 and 1024 bytes" | "consentAttested must be true" | "The subject profile has no active customer entity" | "The managed subject does not match the expected customer entity" | "A managed profile requires a managed customer entity context" | "The subject customer entity is not active" | "Avenia token import is only available for individuals" | "Exactly one active Brazilian individual Avenia customer is required" | "Multiple Avenia customers require reconciliation" | "The Avenia subaccount is not provisioned" | "The Avenia customer is already approved" | "The canonical Avenia KYC case is missing" | "Multiple Avenia KYC cases require reconciliation" | "The Avenia KYC case is already approved" | "The confirmed token import is missing its provider attempt" | "The idempotency key was used with a different token" | "The token import does not match this request" | "A failed token import requires a new idempotency key" | "The Avenia KYC is already approved" | "The previous token import outcome requires reconciliation" | "Another token import requires reconciliation" | "This KYC case uses the standard Avenia method" | "The Avenia token import attempt is invalid" | "The token import attempt requires reconciliation" | "The token import was already claimed" | "The token import binding is no longer current" | "The authenticated profile cannot perform this operation for the requested managed profile" | "Avenia token import pre-provider checks failed" | "Avenia token import is not enabled" | "The Avenia token import outcome requires reconciliation" | "Token import failed"; + }; + BrlaImportKycTokenRequest: { + /** + * @description Required provisional attestation recorded under Vortex consent policy `sumsub-share-v1`. This is not a substitute for the caller's legal basis or applicant disclosures. + * @constant + */ + consentAttested: true; + /** @description Opaque Sumsub share token. Must contain 1 to 1024 UTF-8 bytes. Vortex forwards it to Avenia from request memory and never returns or persists the raw value. */ + importToken: string; + }; + BrlaImportKycTokenResponse: { + /** @description The exact Avenia verification attempt bound to this KYC case and used for subsequent polling. */ + attemptId: string; + /** @constant */ + status: "pending"; + }; BrlaManagedBadRequestResponse: components["schemas"]["BrlaErrorResponse"] | components["schemas"]["ManagedSelectorErrorResponse"]; BrlaValidatePixKeyResponse: { valid: boolean; @@ -2216,29 +2514,18 @@ export interface components { to: components["schemas"]["DestinationType"]; }; CreateSubaccountRequest: { - address: components["schemas"]["BrlaAddress"]; - /** - * Format: date - * @description Date must be in format YYYY-MMM-DD. - */ - birthdate: string; - cnpj?: string | null; - companyName?: string | null; - cpf: string; - fullName: string; - phone: string; - /** @description Optional. The quote that triggered onboarding. Omit it for the quote-less KYB deep link (`?kyb` / `?kybLocked` widget entry), where business verification starts before any quote exists. Stored only as onboarding provenance; it is not an authorization input. */ - quoteId?: string | null; - /** - * Format: date - * @description Date must be in format YYYY-MMM-DD. - */ - startDate?: string | null; - taxIdType: components["schemas"]["TaxIdType"]; + /** @enum {string} */ + accountType: "INDIVIDUAL" | "COMPANY"; + /** @description Individual full name or company legal name. */ + name: string; + quoteId?: string; + sessionId?: string; + /** @description CPF for an individual or CNPJ for a company. */ + taxId: string; }; CreateSubaccountResponse: { /** @description The ID of the created or processed subaccount. */ - subaccountId?: string; + subAccountId: string; }; /** * @description Represents either a blockchain network or a traditional payment method. @@ -2272,18 +2559,22 @@ export interface components { }; FlatManagedSelectorErrorResponse: components["schemas"]["FlatErrorResponse"] | components["schemas"]["ManagedSelectorErrorResponse"]; GetKycStatusResponse: { + /** @enum {string} */ + failureReason?: "face" | "name" | "birthdate" | "unknown" | "tax_id"; /** @description The KYC level achieved. */ - level?: number; + level: string; + /** @enum {string} */ + result?: "APPROVED" | "REJECTED"; /** * @description The KYC status. * @enum {string} */ - status?: "PENDING" | "APPROVED" | "REJECTED"; + status: "PENDING" | "PROCESSING" | "COMPLETED" | "EXPIRED"; /** * @description Event type, typically "KYC". * @enum {string} */ - type?: "KYC"; + type: "KYC"; }; GetRampErrorLogsResponse: components["schemas"]["RampErrorLog"][]; GetRampHistoryResponse: { @@ -2320,23 +2611,18 @@ export interface components { GetUserRemainingLimitResponse: { /** * Format: double - * @description The remaining limit for offramp operations. - */ - remainingLimitOfframp?: number; - /** - * Format: double - * @description The remaining limit for onramp operations. + * @description The remaining limit for the requested direction. */ - remainingLimitOnramp?: number; + remainingLimit: number; }; GetUserResponse: { /** @description The user's EVM wallet address. */ - evmAddress?: string; - /** - * @description The user's KYC level. - * @enum {number} - */ - kycLevel?: 1 | 2; + evmAddress: string; + /** @enum {string} */ + identityStatus: "NOT-IDENTIFIED" | "CONFIRMED"; + /** @description The user's KYC level. */ + kycLevel: number; + subAccountId: string; }; GetWidgetUrlLocked: { /** @description The widget will redirect to this callbackUrl after the user successfully created the transaction. */ @@ -2421,6 +2707,16 @@ export interface components { managedProfiles: components["schemas"]["ManagedProfile"][]; pagination: components["schemas"]["ManagedProfilePagination"]; }; + MalformedJsonErrorResponse: { + /** @constant */ + code: 400; + /** @constant */ + message: "Invalid JSON payload"; + /** @constant */ + statusCode: 400; + /** @constant */ + type: "entity.parse.failed"; + }; ManagedProfile: { /** * Format: email @@ -2473,6 +2769,71 @@ export interface components { * @enum {string} */ Networks: "assethub" | "arbitrum" | "avalanche" | "base" | "bsc" | "ethereum" | "polygon" | "moonbeam"; + OnboardingApiErrorResponse: { + error: string | { + code: string; + message: string; + status: number; + }; + }; + OnboardingDocumentRequirement: { + acceptedMediaTypes?: string[]; + /** @enum {string} */ + collection?: "direct-upload" | "hosted"; + description?: string; + required: boolean; + requiredWhen?: string; + type: string; + }; + OnboardingRequirementStep: { + condition?: string; + derivedValues?: { + [key: string]: string; + }; + description: string; + fixedBody?: { + [key: string]: string; + }; + fixedQuery?: { + [key: string]: string; + }; + /** @enum {string} */ + kind: "api" | "direct-upload" | "hosted"; + /** @enum {string} */ + method?: "POST" | "PUT"; + operationId?: string; + order: number; + path?: string; + repeatFor?: string; + requestSchema?: string; + }; + OnboardingRequirementsErrorResponse: { + error: { + /** @enum {string} */ + code: "INVALID_ONBOARDING_REQUIREMENTS_QUERY" | "ONBOARDING_REQUIREMENTS_NOT_FOUND"; + message: string; + /** @enum {integer} */ + status: 400 | 404; + }; + }; + OnboardingRequirementsResponse: { + /** @enum {string} */ + country: "AR" | "BR" | "CO" | "MX" | "US"; + /** @enum {string} */ + customerType: "individual" | "business"; + /** Format: uri */ + documentationUrl: string; + documents: components["schemas"]["OnboardingDocumentRequirement"][]; + flow: string; + /** @enum {string} */ + mode: "api" | "hosted" | "hybrid"; + /** Format: uri */ + openapiUrl: string; + /** @enum {string} */ + provider: "alfredpay" | "avenia"; + requirementsVersion: string; + steps: components["schemas"]["OnboardingRequirementStep"][]; + }; OnboardingStatusErrorResponse: { error: { /** @constant */ @@ -2495,7 +2856,10 @@ export interface components { error: { code: string; message: string; - } | null; + } & (null | { + code: string; + message: string; + }); /** Format: uuid */ id: string; kycCase: { @@ -2512,15 +2876,30 @@ export interface components { submittedAt: string | null; /** @enum {string} */ type: "kyc" | "kyb"; - } | null; - /** @enum {string} */ - provider: "alfredpay" | "avenia" | "monerium" | "mykobo"; - rail: string | null; + } & (null | { + /** Format: date-time */ + approvedAt: string | null; + failureReasons: string[] | null; + level: string | null; + /** Format: date-time */ + rejectedAt: string | null; + /** @enum {string} */ + status: "pending" | "started" | "in_review" | "approved" | "rejected"; + statusExternal: string | null; + /** Format: date-time */ + submittedAt: string | null; + /** @enum {string} */ + type: "kyc" | "kyb"; + }); + /** @enum {string} */ + provider: "alfredpay" | "avenia" | "monerium" | "mykobo"; + rail: string | null; /** @enum {string} */ state: "pending" | "started" | "in_review" | "approved" | "rejected"; /** @enum {string} */ status: "pending" | "started" | "in_review" | "approved" | "rejected"; statusExternal: string | null; + /** @description Business tax ID only; individual tax IDs remain private. */ taxReference: string | null; }[]; /** Format: uuid */ @@ -2535,6 +2914,16 @@ export interface components { }; /** @enum {string} */ OnChainToken: "USDC" | "USDT" | "ETH" | "USDC.E"; + PayloadTooLargeErrorResponse: { + /** @constant */ + code: 413; + /** @constant */ + message: "Request body too large"; + /** @constant */ + statusCode: 413; + /** @constant */ + type: "entity.too.large"; + }; /** @description Data related to the payment for the ramp transaction. */ PaymentData: { /** @@ -2712,6 +3101,11 @@ export interface components { /** @description The address of the source account for SELL, or the address the destination account for BUY transactions. */ walletAddress?: string; }; + RecordInitialKycAttemptRequest: { + quoteId: string; + sessionId?: string; + taxId: string; + }; RegisterRampRequest: { /** * @description Optional additional data for the ramp process. @@ -2753,6 +3147,15 @@ export interface components { type: "EVM" | "Substrate"; }[]; }; + SelectActiveCustomerEntityRequest: { + /** @enum {string} */ + type: "individual" | "business"; + }; + SelectActiveCustomerEntityResponse: { + activeEntityId: string; + /** @enum {string} */ + type: "individual" | "business"; + }; /** @description `PENDING`, `FAILED`, `COMPLETED` */ SimpleStatus: string; StartKYC2Request: { @@ -2765,6 +3168,71 @@ export interface components { StartRampRequest: { rampId: string; }; + SubmitInformationResponse: { + submissionId: string; + }; + SubmitKybInformationRequest: { + accountPurpose: string; + address: string; + businessActivities: string; + businessName: string; + city: string; + complianceScreeningDescription?: string; + conductsComplianceScreening?: boolean; + /** @enum {string} */ + country: "CO" | "MX"; + expectedMonthlyTransactions: number; + expectedMonthlyVolumeUsd: number; + isRegulatedBusiness: boolean; + operatesInSanctionedCountries: boolean; + relatedPersons: components["schemas"]["AlfredpayKybRelatedPerson"][]; + sourceOfFunds: string; + state: string; + taxId: string; + transmitsCustomerFunds: boolean; + walletAddresses: string; + /** Format: uri */ + website: string; + zipCode: string; + } & (unknown & unknown); + SubmitKycInformationRequest: { + address: string; + city: string; + /** @enum {string} */ + country: "AR" | "CO" | "MX"; + countryCode?: string; + cuit?: string; + /** Format: date */ + dateOfBirth: string; + dni: string; + /** Format: email */ + email?: string; + firstName: string; + lastName: string; + nationalities?: string[]; + pep?: boolean; + phoneNumber?: string; + state: string; + typeDocument?: string; + /** @enum {string} */ + typeDocumentAr?: "DNI"; + /** @enum {string} */ + typeDocumentCol?: "CC" | "CE"; + zipCode: string; + } & ({ + /** @constant */ + country?: "MX"; + } | { + /** @constant */ + country?: "CO"; + } | { + /** @constant */ + country?: "AR"; + }); + SuccessResponse: { + /** @constant */ + success: true; + }; /** @enum {string} */ TaxIdType: "CPF" | "CNPJ"; TriggerOfframpRequest: { @@ -2800,19 +3268,21 @@ export interface components { [key: string]: unknown; }; UpdateRampRequest: { - /** @description Optional additional data, like transaction hashes from external services. */ - additionalData?: ({ + /** @description Optional client-reported transaction hashes used to continue the ramp. */ + additionalData?: { /** @description Transaction hash for AssetHub to Pendulum transfer, if applicable. */ - assetHubToPendulumHash?: string | null; - /** @description Signed message to trigger a Monerium offramp. */ - moneriumOfframpSignature: string; + assethubToPendulumHash?: string | null; /** @description Transaction hash for Squid Router approval. Optional: omit when the wallet already holds a sufficient allowance and no approval transaction was submitted. */ squidRouterApproveHash?: string | null; + /** @description Transaction hash for Squid Router no-permit approval, if applicable. */ + squidRouterNoPermitApproveHash?: string | null; + /** @description Transaction hash for Squid Router no-permit swap, if applicable. */ + squidRouterNoPermitSwapHash?: string | null; + /** @description Transaction hash for Squid Router no-permit transfer, if applicable. */ + squidRouterNoPermitTransferHash?: string | null; /** @description Transaction hash for Squid Router swap, if applicable. */ squidRouterSwapHash?: string | null; - } & { - [key: string]: unknown; - }) | null; + } | null; /** @description An array of transactions that have been pre-signed by the user. */ presignedTxs: components["schemas"]["PresignedTx"][]; /** @@ -2913,6 +3383,8 @@ export interface operations { parameters: { query: { country: components["schemas"]["AlfredpayCountry"]; + /** @description Selects the individual or business customer. When omitted, the active customer entity is used for backward compatibility. */ + type?: components["schemas"]["AlfredpayCustomerType"]; }; header?: { /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ @@ -2932,7 +3404,7 @@ export interface operations { "application/json": components["schemas"]["AlfredpayStatusResponse"]; }; }; - /** @description Invalid or missing country, invalid selector UUID, or managed-profile customer-type mismatch. */ + /** @description Invalid or missing country, invalid customer type, invalid selector UUID, or managed-profile customer-type mismatch. */ 400: { headers: { [name: string]: unknown; @@ -2941,6 +3413,7 @@ export interface operations { "application/json": components["schemas"]["AlfredpayManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Alfredpay customer not found. */ @@ -2975,7 +3448,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AlfredpayCountryRequest"]; + "application/json": components["schemas"]["AlfredpayCreateCustomerRequest"]; }; }; responses: { @@ -2997,7 +3470,9 @@ export interface operations { "application/json": components["schemas"]["AlfredpayManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed profile or corridor is not authorized. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description An upstream customer exists with a conflicting country or type. */ 409: { @@ -3040,7 +3515,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AlfredpayCountryRequest"]; + "application/json": components["schemas"]["AlfredpayCreateCustomerRequest"]; }; }; responses: { @@ -3062,7 +3537,9 @@ export interface operations { "application/json": components["schemas"]["AlfredpayManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed profile or corridor is not authorized. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description An upstream customer exists with a conflicting country or type. */ 409: { @@ -3260,7 +3737,7 @@ export interface operations { findAlfredpayKybCustomerAndBusiness: { parameters: { query: { - country: components["schemas"]["AlfredpayCountry"]; + country: "CO" | "MX"; }; header?: { /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ @@ -3277,7 +3754,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AlfredpayKybBusinessSummary"][]; + "application/json": components["schemas"]["AlfredpayKybDetailsResponse"]; }; }; /** @description Invalid country, invalid selector UUID, or managed-profile customer-type mismatch. */ @@ -3289,6 +3766,7 @@ export interface operations { "application/json": components["schemas"]["AlfredpayManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Alfredpay business customer not found. */ @@ -3343,6 +3821,7 @@ export interface operations { "application/json": components["schemas"]["AlfredpayManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Alfredpay business customer not found. */ @@ -3397,6 +3876,7 @@ export interface operations { "application/json": components["schemas"]["AlfredpayManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Alfredpay customer not found. */ @@ -3452,6 +3932,7 @@ export interface operations { "application/json": components["schemas"]["AlfredpayManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Customer or verification attempt not found. */ @@ -3474,7 +3955,7 @@ export interface operations { }; }; }; - markAlfredpayKycRedirectFinished: { + notifyAlfredpayKycRedirectFinished: { parameters: { query?: never; header?: { @@ -3486,7 +3967,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AlfredpayCountryAndCustomerTypeRequest"]; + "application/json": components["schemas"]["AlfredpayRedirectNotificationRequest"]; }; }; responses: { @@ -3496,7 +3977,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AlfredpaySuccessResponse"]; + "application/json": components["schemas"]["SuccessResponse"]; }; }; /** @description Invalid country, invalid selector UUID, or managed-profile customer-type mismatch. */ @@ -3508,6 +3989,7 @@ export interface operations { "application/json": components["schemas"]["AlfredpayManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Alfredpay customer not found. */ @@ -3530,7 +4012,7 @@ export interface operations { }; }; }; - markAlfredpayKycRedirectOpened: { + notifyAlfredpayKycRedirectOpened: { parameters: { query?: never; header?: { @@ -3542,7 +4024,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AlfredpayCountryAndCustomerTypeRequest"]; + "application/json": components["schemas"]["AlfredpayRedirectNotificationRequest"]; }; }; responses: { @@ -3552,7 +4034,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AlfredpaySuccessResponse"]; + "application/json": components["schemas"]["SuccessResponse"]; }; }; /** @description Invalid country, invalid selector UUID, or managed-profile customer-type mismatch. */ @@ -3564,6 +4046,7 @@ export interface operations { "application/json": components["schemas"]["AlfredpayManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Alfredpay customer not found. */ @@ -3598,7 +4081,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AlfredpayCountryAndCustomerTypeRequest"]; + "application/json": components["schemas"]["AlfredpayRetryRequest"]; }; }; responses: { @@ -3608,7 +4091,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AlfredpayRedirectLinkResponse"] | components["schemas"]["AlfredpaySuccessResponse"]; + "application/json": components["schemas"]["AlfredpayRetryResponse"]; }; }; /** @description No failed submission is available, the selector UUID is invalid, or the managed-profile customer type mismatches. */ @@ -3620,6 +4103,7 @@ export interface operations { "application/json": components["schemas"]["AlfredpayManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Alfredpay customer not found. */ @@ -3664,7 +4148,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AlfredpaySuccessResponse"]; + "application/json": components["schemas"]["SuccessResponse"]; }; }; /** @description Invalid country, invalid selector UUID, or managed-profile customer-type mismatch. */ @@ -3676,7 +4160,9 @@ export interface operations { "application/json": components["schemas"]["AlfredpayManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed profile or corridor is not authorized. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Alfredpay business customer not found. */ 404: { @@ -3720,7 +4206,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AlfredpaySuccessResponse"]; + "application/json": components["schemas"]["SuccessResponse"]; }; }; /** @description Invalid country, invalid selector UUID, or managed-profile customer-type mismatch. */ @@ -3732,7 +4218,9 @@ export interface operations { "application/json": components["schemas"]["AlfredpayManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed profile or corridor is not authorized. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Alfredpay customer not found. */ 404: { @@ -3776,7 +4264,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AlfredpaySuccessResponse"]; + "application/json": components["schemas"]["SuccessResponse"]; }; }; /** @description Invalid country, missing file, invalid selector UUID, or managed-profile customer-type mismatch. */ @@ -3788,7 +4276,9 @@ export interface operations { "application/json": components["schemas"]["AlfredpayManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed profile or corridor is not authorized. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Alfredpay business customer not found. */ 404: { @@ -3822,7 +4312,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AlfredpaySubmitKybInformationRequest"]; + "application/json": components["schemas"]["SubmitKybInformationRequest"]; }; }; responses: { @@ -3832,7 +4322,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AlfredpaySubmissionResponse"]; + "application/json": components["schemas"]["SubmitInformationResponse"]; }; }; /** @description Invalid country, company data, questionnaire, selector UUID, or managed-profile customer type. */ @@ -3844,7 +4334,9 @@ export interface operations { "application/json": components["schemas"]["AlfredpayValidationBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed profile or corridor is not authorized. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Alfredpay business customer not found. */ 404: { @@ -3887,7 +4379,7 @@ export interface operations { }; requestBody: { content: { - "multipart/form-data": components["schemas"]["AlfredpayRelatedPersonFileUploadRequest"]; + "multipart/form-data": components["schemas"]["AlfredpayKybRelatedPersonFileUploadRequest"]; }; }; responses: { @@ -3897,7 +4389,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AlfredpaySuccessResponse"]; + "application/json": components["schemas"]["SuccessResponse"]; }; }; /** @description Invalid country, missing file, invalid selector UUID, or managed-profile customer-type mismatch. */ @@ -3909,7 +4401,9 @@ export interface operations { "application/json": components["schemas"]["AlfredpayManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed profile or corridor is not authorized. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Alfredpay business customer not found. */ 404: { @@ -3953,7 +4447,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AlfredpaySuccessResponse"]; + "application/json": components["schemas"]["SuccessResponse"]; }; }; /** @description Invalid country, missing file, invalid selector UUID, or managed-profile customer-type mismatch. */ @@ -3965,7 +4459,9 @@ export interface operations { "application/json": components["schemas"]["AlfredpayManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed profile or corridor is not authorized. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Alfredpay customer not found. */ 404: { @@ -3999,7 +4495,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AlfredpaySubmitKycInformationRequest"]; + "application/json": components["schemas"]["SubmitKycInformationRequest"]; }; }; responses: { @@ -4009,7 +4505,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AlfredpaySubmissionResponse"]; + "application/json": components["schemas"]["SubmitInformationResponse"]; }; }; /** @description Invalid country, KYC fields, selector UUID, or managed-profile customer type. */ @@ -4021,7 +4517,9 @@ export interface operations { "application/json": components["schemas"]["AlfredpayValidationBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed profile or corridor is not authorized. */ 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Alfredpay customer not found. */ 404: { @@ -4396,6 +4894,15 @@ export interface operations { "application/json": components["schemas"]["BrlaErrorResponse"]; }; }; + /** @description The canonical Avenia KYC state requires reconciliation. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; /** @description Internal Server Error (e.g., no KYC events found when expected). */ 500: { headers: { @@ -4405,6 +4912,15 @@ export interface operations { "application/json": components["schemas"]["BrlaErrorResponse"]; }; }; + /** @description Avenia is unavailable or returned an invalid response. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; }; }; brlaGetSelfieLivenessUrl: { @@ -4442,6 +4958,15 @@ export interface operations { }; 401: components["responses"]["ManagedSelectorUnauthorized"]; 403: components["responses"]["BrlaManagedSelectorForbidden"]; + /** @description The immutable KYC method or canonical case state conflicts with liveness creation. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; /** @description Internal server error. */ 500: { headers: { @@ -4451,6 +4976,15 @@ export interface operations { "application/json": components["schemas"]["BrlaErrorResponse"]; }; }; + /** @description Avenia is unavailable or returned an invalid response. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; }; }; brlaGetUploadUrls: { @@ -4489,6 +5023,15 @@ export interface operations { }; 401: components["responses"]["ManagedSelectorUnauthorized"]; 403: components["responses"]["BrlaManagedSelectorForbidden"]; + /** @description The immutable KYC method or canonical case state conflicts with upload creation. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; /** @description Internal server error. */ 500: { headers: { @@ -4498,6 +5041,15 @@ export interface operations { "application/json": components["schemas"]["BrlaErrorResponse"]; }; }; + /** @description Avenia is unavailable or returned an invalid response. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; }; }; getBrlaUser: { @@ -4566,7 +5118,7 @@ export interface operations { * @description Optional ownership-checked Tax ID cross-check. Omit it to derive the canonical Avenia account from the authenticated subject. */ taxId?: string; - /** @description Ramp direction whose remaining BRL limit should be returned. */ + /** @description Ramp direction whose remaining limit should be returned. */ direction: components["schemas"]["RampDirection"]; }; header?: { @@ -4638,7 +5190,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["KybAttemptStatusResponse"]; + "application/json": components["schemas"]["AveniaKybAttemptStatusResponse"]; }; }; /** @description Missing attempt ID, invalid selector UUID, or authentication subject. */ @@ -4650,7 +5202,9 @@ export interface operations { "application/json": components["schemas"]["BrlaManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Attempt does not belong to the effective profile. */ 403: components["responses"]["BrlaManagedSelectorForbidden"]; /** @description KYB attempt or account not found. */ 404: { @@ -4661,6 +5215,13 @@ export interface operations { "application/json": components["schemas"]["BrlaErrorResponse"]; }; }; + /** @description The attempt is no longer the current bound KYB attempt. */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Status refresh failed. */ 500: { headers: { @@ -4670,9 +5231,16 @@ export interface operations { "application/json": components["schemas"]["BrlaErrorResponse"]; }; }; + /** @description Avenia is unavailable or returned an invalid response. */ + 502: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; - initiateAveniaKybLevel1: { + createAveniaKybDocument: { parameters: { query: { subAccountId: string; @@ -4684,18 +5252,22 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AveniaKybDocumentRequest"]; + }; + }; responses: { - /** @description Hosted KYB attempt and step URLs returned. */ - 200: { + /** @description Document upload targets created. */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["KybLevel1Response"]; + "application/json": components["schemas"]["AveniaKybDocumentUploadResponse"]; }; }; - /** @description Missing subaccount, non-company account, invalid selector UUID, customer-type mismatch, or invalid request. */ + /** @description Invalid document request. */ 400: { headers: { [name: string]: unknown; @@ -4704,67 +5276,52 @@ export interface operations { "application/json": components["schemas"]["BrlaManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed profile or corridor is not authorized. */ 403: components["responses"]["BrlaManagedSelectorForbidden"]; /** @description Subaccount not found. */ 404: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; - }; - /** @description Company is approved or a non-resumable KYB attempt is in progress. */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; + content?: never; }; - /** @description KYB initialization failed. */ - 500: { + /** @description Avenia is unavailable or returned an invalid response. */ + 502: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; - }; + content?: never; }; }; }; - recordAveniaInitialKycAttempt: { + getAveniaKybDocument: { parameters: { - query?: never; + query: { + subAccountId: string; + }; header?: { /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - quoteId: string; - sessionId?: string; - taxId: string; - }; + path: { + documentId: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Attempt marker recorded or already present. */ + /** @description Document status. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": Record; + "application/json": components["schemas"]["AveniaKybDocumentResponse"]; }; }; - /** @description Missing tax ID, invalid selector UUID, or managed-profile customer-type mismatch. */ + /** @description Invalid document identifier. */ 400: { headers: { [name: string]: unknown; @@ -4773,22 +5330,31 @@ export interface operations { "application/json": components["schemas"]["BrlaManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; - 403: components["responses"]["ManagedSelectorForbidden"]; - /** @description Attempt recording failed. */ - 500: { + /** @description Document does not belong to the effective profile. */ + 403: components["responses"]["BrlaManagedSelectorForbidden"]; + /** @description Document not found. */ + 404: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; + content?: never; + }; + /** @description Avenia is unavailable or returned an invalid response. */ + 502: { + headers: { + [name: string]: unknown; }; + content?: never; }; }; }; - brlaNewKyc: { + submitAveniaKybLevel1Api: { parameters: { - query?: never; + query: { + subAccountId: string; + }; header?: { /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; @@ -4798,11 +5364,11 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["KycLevel1Payload"]; + "application/json": components["schemas"]["AveniaKybLevel1Payload"]; }; }; responses: { - /** @description KYC submission accepted. */ + /** @description KYB attempt submitted. */ 200: { headers: { [name: string]: unknown; @@ -4811,7 +5377,7 @@ export interface operations { "application/json": components["schemas"]["KycLevel1Response"]; }; }; - /** @description Validation failure. */ + /** @description Invalid submission or document state. */ 400: { headers: { [name: string]: unknown; @@ -4820,42 +5386,71 @@ export interface operations { "application/json": components["schemas"]["BrlaManagedBadRequestResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed profile or corridor is not authorized. */ 403: components["responses"]["BrlaManagedSelectorForbidden"]; - /** @description Internal server error. */ - 500: { + /** @description Subaccount or referenced document not found. */ + 404: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["BrlaErrorResponse"]; + content?: never; + }; + /** @description A different KYB submission is already in progress. */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Avenia is unavailable or returned an invalid response. */ + 502: { + headers: { + [name: string]: unknown; }; + content?: never; }; }; }; - brlaValidatePixKey: { + startAveniaKybLevel1Hosted: { parameters: { query: { - /** @description Pix key to validate (CPF, CNPJ, email, phone, or random key). */ - pixKey: string; + subAccountId: string; + }; + header?: { + /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; }; - header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Validation result. */ + /** @description Hosted KYB attempt and step URLs returned. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BrlaValidatePixKeyResponse"]; + "application/json": components["schemas"]["AveniaKybHostedResponse"]; }; }; - /** @description Missing or invalid pix key. */ + /** @description Missing subaccount, non-company account, invalid selector UUID, customer-type mismatch, or invalid request. */ 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaManagedBadRequestResponse"]; + }; + }; + /** @description Authentication required. */ + 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed profile or corridor is not authorized. */ + 403: components["responses"]["BrlaManagedSelectorForbidden"]; + /** @description Subaccount not found. */ + 404: { headers: { [name: string]: unknown; }; @@ -4863,13 +5458,350 @@ export interface operations { "application/json": components["schemas"]["BrlaErrorResponse"]; }; }; - /** @description Supabase Bearer required. */ - 401: { + /** @description Company is approved or a non-resumable KYB attempt is in progress. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + /** @description KYB initialization failed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + /** @description Avenia is unavailable or returned an invalid response. */ + 502: { headers: { [name: string]: unknown; }; content?: never; }; + }; + }; + createAveniaKybUbo: { + parameters: { + query: { + subAccountId: string; + }; + header?: { + /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AveniaUboPayload"]; + }; + }; + responses: { + /** @description UBO registered. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AveniaUboResponse"]; + }; + }; + /** @description Invalid UBO or document state. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaManagedBadRequestResponse"]; + }; + }; + /** @description Authentication required. */ + 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed profile or corridor is not authorized. */ + 403: components["responses"]["BrlaManagedSelectorForbidden"]; + /** @description Subaccount or referenced document not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description A referenced document is not ready. */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Avenia is unavailable or returned an invalid response. */ + 502: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + importAveniaKycToken: { + parameters: { + query?: never; + header: { + /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; + /** @description Caller-generated key for one token-import attempt. It must contain 1 to 128 visible ASCII characters. Reuse it only with the same token. */ + "Idempotency-Key": string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BrlaImportKycTokenRequest"]; + }; + }; + responses: { + /** @description The exact Avenia attempt is durably bound and pending. This does not mean KYC is approved. */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaImportKycTokenResponse"]; + }; + }; + /** @description Invalid idempotency key, strict body, malformed authenticated JSON, selector UUID, or managed customer type. Authentication is checked first. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaImportKycTokenErrorResponse"] | components["schemas"]["ManagedSelectorErrorResponse"] | components["schemas"]["MalformedJsonErrorResponse"]; + }; + }; + /** @description A valid profile-bound secret key or Supabase session is required. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedSelectorErrorResponse"]; + }; + }; + /** @description The selected child is unauthorized, the caller used direct managed-child credentials, or transactional authorization was revoked before provider submission. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ManagedSelectorErrorResponse"] | components["schemas"]["BrlaImportKycTokenErrorResponse"]; + }; + }; + /** @description The prerequisite setup, immutable method, idempotency input, active submission, or prior ambiguous outcome prevents import. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaImportKycTokenErrorResponse"]; + }; + }; + /** @description Avenia returned provider `401`: token import is not enabled. This failed attempt may be retried with a new idempotency key. */ + 412: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaImportKycTokenErrorResponse"]; + }; + }; + /** @description The authenticated JSON request exceeds the token-import route's 16 KiB body limit. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PayloadTooLargeErrorResponse"]; + }; + }; + /** @description Token import failed before a safe public classification could be returned. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaImportKycTokenErrorResponse"] | components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The post-send outcome is ambiguous and requires reconciliation. Do not retry the token. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaImportKycTokenErrorResponse"]; + }; + }; + /** @description Supabase authentication is temporarily unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiCredentialErrorResponse"]; + }; + }; + }; + }; + recordInitialAveniaKycAttempt: { + parameters: { + query?: never; + header?: { + /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RecordInitialKycAttemptRequest"]; + }; + }; + responses: { + /** @description Preflight event accepted without reserving the asserted tax identity. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + /** @description Missing tax ID, invalid selector UUID, or managed-profile customer-type mismatch. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaManagedBadRequestResponse"]; + }; + }; + /** @description Authentication required. */ + 401: components["responses"]["ManagedSelectorUnauthorized"]; + /** @description Managed profile or corridor is not authorized. */ + 403: components["responses"]["ManagedSelectorForbidden"]; + /** @description Attempt recording failed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + }; + }; + brlaNewKyc: { + parameters: { + query?: never; + header?: { + /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["KycLevel1Payload"]; + }; + }; + responses: { + /** @description KYC submission accepted. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["KycLevel1Response"]; + }; + }; + /** @description Validation failure. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaManagedBadRequestResponse"]; + }; + }; + 401: components["responses"]["ManagedSelectorUnauthorized"]; + 403: components["responses"]["BrlaManagedSelectorForbidden"]; + /** @description The immutable KYC method, approval state, or durable submission state conflicts with this request. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + /** @description Internal server error. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + /** @description Avenia documents are not ready, or the submitted outcome requires reconciliation. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; + }; + }; + brlaValidatePixKey: { + parameters: { + query: { + /** @description Pix key to validate (CPF, CNPJ, email, phone, or random key). */ + pixKey: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Validation result. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaValidatePixKeyResponse"]; + }; + }; + /** @description Missing or invalid pix key. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrlaErrorResponse"]; + }; + }; /** @description Internal server error. */ 500: { headers: { @@ -5452,6 +6384,106 @@ export interface operations { }; }; }; + selectActiveCustomerEntity: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SelectActiveCustomerEntityRequest"]; + }; + }; + responses: { + /** @description Active customer entity selected. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SelectActiveCustomerEntityResponse"]; + }; + }; + /** @description Invalid customer-entity type. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Supabase Bearer authentication required. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No active owned entity of the requested type exists. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The selection conflicts with an existing selection or is ambiguous. */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Selection could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getOnboardingRequirements: { + parameters: { + query: { + country: "AR" | "BR" | "CO" | "MX" | "US"; + customerType: "individual" | "business"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Flow metadata and ordered non-GET action sequence. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OnboardingRequirementsResponse"]; + }; + }; + /** @description Missing or invalid query. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OnboardingRequirementsErrorResponse"]; + }; + }; + /** @description No published flow exists for the country and customer type. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OnboardingRequirementsErrorResponse"]; + }; + }; + }; + }; getOnboardingStatus: { parameters: { query?: never; @@ -5482,6 +6514,7 @@ export interface operations { "application/json": components["schemas"]["ManagedSelectorErrorResponse"]; }; }; + /** @description Authentication required. */ 401: components["responses"]["ManagedSelectorUnauthorized"]; 403: components["responses"]["ManagedSelectorForbidden"]; /** @description Onboarding aggregation failed. */ diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index e8da40a6c..080532135 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -15,7 +15,11 @@ "responses": { "BrlaManagedSelectorForbidden": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" + } + } }, "description": "The selected profile or the authenticated user does not own the requested Avenia resource." }, @@ -40,13 +44,21 @@ }, "ManagedSelectorForbidden": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } }, "description": "The authenticated profile cannot act for the selected managed profile." }, "ManagedSelectorUnauthorized": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } }, "description": "A valid secret API key or Bearer session is required." }, @@ -88,54 +100,120 @@ }, "AlfredpayAddFiatAccountRequest": { "properties": { - "accountBankCode": { "type": "string" }, - "accountName": { "type": "string" }, - "accountNumber": { "type": "string" }, - "accountType": { "type": "string" }, - "bankCity": { "type": "string" }, - "bankCountry": { "type": "string" }, - "bankPostalCode": { "type": "string" }, - "bankState": { "type": "string" }, - "bankStreet": { "type": "string" }, - "beneficiaryCity": { "type": "string" }, - "beneficiaryCountry": { "type": "string" }, - "beneficiaryPostalCode": { "type": "string" }, - "beneficiaryState": { "type": "string" }, - "beneficiaryStreet": { "type": "string" }, - "country": { "$ref": "#/components/schemas/AlfredpayCountry" }, - "documentNumber": { "type": "string" }, - "documentType": { "type": "string" }, - "isExternal": { "type": "boolean" }, - "routingNumber": { "type": "string" }, - "type": { "$ref": "#/components/schemas/AlfredpayFiatAccountType" } + "accountBankCode": { + "type": "string" + }, + "accountName": { + "type": "string" + }, + "accountNumber": { + "type": "string" + }, + "accountType": { + "type": "string" + }, + "bankCity": { + "type": "string" + }, + "bankCountry": { + "type": "string" + }, + "bankPostalCode": { + "type": "string" + }, + "bankState": { + "type": "string" + }, + "bankStreet": { + "type": "string" + }, + "beneficiaryCity": { + "type": "string" + }, + "beneficiaryCountry": { + "type": "string" + }, + "beneficiaryPostalCode": { + "type": "string" + }, + "beneficiaryState": { + "type": "string" + }, + "beneficiaryStreet": { + "type": "string" + }, + "country": { + "$ref": "#/components/schemas/AlfredpayCountry" + }, + "documentNumber": { + "type": "string" + }, + "documentType": { + "type": "string" + }, + "isExternal": { + "type": "boolean" + }, + "routingNumber": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/AlfredpayFiatAccountType" + } }, "required": ["country", "type", "accountNumber"], "type": "object" }, "AlfredpayCountry": { - "enum": ["AR", "BO", "BR", "CL", "CN", "CO", "DO", "HK", "MX", "PE", "US"], + "enum": ["AR", "CO", "MX", "US"], "type": "string" }, "AlfredpayCountryAndCustomerTypeRequest": { "properties": { - "country": { "$ref": "#/components/schemas/AlfredpayCountry" }, - "type": { "$ref": "#/components/schemas/AlfredpayCustomerType" } + "country": { + "$ref": "#/components/schemas/AlfredpayCountry" + }, + "type": { + "$ref": "#/components/schemas/AlfredpayCustomerType" + } }, "required": ["country"], "type": "object" }, "AlfredpayCountryRequest": { - "properties": { "country": { "$ref": "#/components/schemas/AlfredpayCountry" } }, + "properties": { + "country": { + "$ref": "#/components/schemas/AlfredpayCountry" + } + }, + "required": ["country"], + "type": "object" + }, + "AlfredpayCreateCustomerRequest": { + "properties": { + "country": { + "$ref": "#/components/schemas/AlfredpayCountry" + } + }, "required": ["country"], "type": "object" }, "AlfredpayCreateCustomerResponse": { - "properties": { "createdAt": { "format": "date-time", "type": "string" } }, + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + } + }, "required": ["createdAt"], "type": "object" }, "AlfredpayCreateFiatAccountResponse": { - "properties": { "fiatAccountId": { "type": "string" } }, + "properties": { + "fiatAccountId": { + "type": "string" + } + }, "required": ["fiatAccountId"], "type": "object" }, @@ -145,10 +223,19 @@ }, "AlfredpayErrorResponse": { "properties": { - "error": { "type": "string" }, + "error": { + "type": "string" + }, "fields": { "items": { - "properties": { "field": { "type": "string" }, "message": { "type": "string" } }, + "properties": { + "field": { + "type": "string" + }, + "message": { + "type": "string" + } + }, "required": ["field", "message"], "type": "object" }, @@ -161,14 +248,31 @@ "AlfredpayFiatAccount": { "additionalProperties": true, "properties": { - "accountName": { "type": "string" }, - "accountNumber": { "type": "string" }, - "accountType": { "type": "string" }, - "createdAt": { "format": "date-time", "type": "string" }, - "customerId": { "type": "string" }, - "fiatAccountId": { "type": "string" }, - "routingNumber": { "type": "string" }, - "type": { "$ref": "#/components/schemas/AlfredpayFiatAccountType" } + "accountName": { + "type": "string" + }, + "accountNumber": { + "type": "string" + }, + "accountType": { + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "customerId": { + "type": "string" + }, + "fiatAccountId": { + "type": "string" + }, + "routingNumber": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/AlfredpayFiatAccountType" + } }, "required": ["accountNumber", "accountType", "customerId", "fiatAccountId", "type"], "type": "object" @@ -181,21 +285,58 @@ "properties": { "relatedPersons": { "items": { - "properties": { "idRelatedPerson": { "type": "string" } }, + "properties": { + "idRelatedPerson": { + "type": "string" + } + }, "required": ["idRelatedPerson"], "type": "object" }, "type": "array" }, - "submissionId": { "type": "string" } + "submissionId": { + "type": "string" + } }, "required": ["relatedPersons", "submissionId"], "type": "object" }, + "AlfredpayKybDetailsResponse": { + "items": { + "properties": { + "relatedPersons": { + "items": { + "properties": { + "idRelatedPerson": { + "type": "string" + } + }, + "required": ["idRelatedPerson"], + "type": "object" + }, + "type": "array" + }, + "submissionId": { + "type": "string" + } + }, + "required": ["submissionId", "relatedPersons"], + "type": "object" + }, + "type": "array" + }, "AlfredpayKybFileUploadRequest": { "properties": { - "country": { "$ref": "#/components/schemas/AlfredpayCountry" }, - "file": { "format": "binary", "type": "string" }, + "country": { + "$ref": "#/components/schemas/AlfredpayCountry", + "enum": ["CO", "MX"], + "type": "string" + }, + "file": { + "format": "binary", + "type": "string" + }, "fileType": { "enum": [ "taxIdDocument", @@ -207,74 +348,193 @@ ], "type": "string" }, - "submissionId": { "type": "string" } + "submissionId": { + "type": "string" + } }, - "required": ["country", "file", "fileType", "submissionId"], + "required": ["country", "submissionId", "fileType", "file"], "type": "object" }, "AlfredpayKybRelatedPerson": { "properties": { - "cpf": { "type": "string" }, - "dateOfBirth": { "format": "date", "type": "string" }, - "dni": { "type": "string" }, - "email": { "format": "email", "type": "string" }, - "firstName": { "type": "string" }, - "lastName": { "type": "string" }, - "nationalities": { "items": { "type": "string" }, "type": "array" }, - "pep": { "type": "boolean" } + "cpf": { + "type": "string" + }, + "dateOfBirth": { + "format": "date", + "type": "string" + }, + "dni": { + "type": "string" + }, + "email": { + "format": "email", + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "nationalities": { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "pep": { + "type": "boolean" + } }, "required": ["firstName", "lastName", "email", "dateOfBirth", "nationalities"], "type": "object" }, + "AlfredpayKybRelatedPersonFileUploadRequest": { + "properties": { + "country": { + "enum": ["CO", "MX"], + "type": "string" + }, + "file": { + "format": "binary", + "type": "string" + }, + "fileType": { + "enum": ["docFront", "docBack"], + "type": "string" + }, + "relatedPersonId": { + "type": "string" + } + }, + "required": ["country", "relatedPersonId", "fileType", "file"], + "type": "object" + }, "AlfredpayKycFileUploadRequest": { "properties": { - "country": { "$ref": "#/components/schemas/AlfredpayCountry" }, - "file": { "format": "binary", "type": "string" }, - "fileType": { "enum": ["National ID Front", "National ID Back", "Selfie"], "type": "string" }, - "submissionId": { "type": "string" } + "country": { + "$ref": "#/components/schemas/AlfredpayCountry", + "enum": ["AR", "CO", "MX"], + "type": "string" + }, + "file": { + "format": "binary", + "type": "string" + }, + "fileType": { + "enum": ["National ID Front", "National ID Back", "Selfie"], + "type": "string" + }, + "submissionId": { + "type": "string" + } }, - "required": ["country", "file", "fileType", "submissionId"], + "required": ["country", "submissionId", "fileType", "file"], "type": "object" }, "AlfredpayKycStatusResponse": { "properties": { - "alfred_pay_id": { "type": "string" }, - "country": { "type": "string" }, - "lastFailure": { "type": "string" }, - "status": { "$ref": "#/components/schemas/AlfredpayStatus" }, - "updated_at": { "format": "date-time", "type": "string" } + "alfred_pay_id": { + "type": "string" + }, + "country": { + "$ref": "#/components/schemas/AlfredpayCountry", + "type": "string" + }, + "lastFailure": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/AlfredpayStatus" + }, + "updated_at": { + "format": "date-time", + "type": "string" + } }, - "required": ["alfred_pay_id", "country", "status", "updated_at"], + "required": ["status", "updated_at", "alfred_pay_id", "country"], "type": "object" }, "AlfredpayManagedBadRequestResponse": { "oneOf": [ - { "$ref": "#/components/schemas/AlfredpayErrorResponse" }, - { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } + { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + }, + { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } ] }, "AlfredpayRedirectLinkResponse": { "properties": { - "submissionId": { "type": "string" }, - "verification_url": { "format": "uri", "type": "string" } + "submissionId": { + "type": "string" + }, + "verification_url": { + "format": "uri", + "type": "string" + } + }, + "required": ["verification_url", "submissionId"], + "type": "object" + }, + "AlfredpayRedirectNotificationRequest": { + "properties": { + "country": { + "$ref": "#/components/schemas/AlfredpayCountry" + }, + "type": { + "$ref": "#/components/schemas/AlfredpayCustomerType" + } }, - "required": ["submissionId", "verification_url"], + "required": ["country"], "type": "object" }, "AlfredpayRelatedPersonFileUploadRequest": { "properties": { - "country": { "$ref": "#/components/schemas/AlfredpayCountry" }, - "file": { "format": "binary", "type": "string" }, - "fileType": { "enum": ["docFront", "docBack"], "type": "string" }, - "relatedPersonId": { "type": "string" } + "country": { + "$ref": "#/components/schemas/AlfredpayCountry" + }, + "file": { + "format": "binary", + "type": "string" + }, + "fileType": { + "enum": ["docFront", "docBack"], + "type": "string" + }, + "relatedPersonId": { + "type": "string" + } }, "required": ["country", "file", "fileType", "relatedPersonId"], "type": "object" }, + "AlfredpayRetryRequest": { + "$ref": "#/components/schemas/AlfredpayRedirectNotificationRequest" + }, + "AlfredpayRetryResponse": { + "oneOf": [ + { + "$ref": "#/components/schemas/AlfredpayRedirectLinkResponse" + }, + { + "$ref": "#/components/schemas/SuccessResponse" + } + ] + }, "AlfredpaySendSubmissionRequest": { "properties": { - "country": { "$ref": "#/components/schemas/AlfredpayCountry" }, - "submissionId": { "type": "string" } + "country": { + "$ref": "#/components/schemas/AlfredpayCountry", + "enum": ["AR", "CO", "MX"], + "type": "string" + }, + "submissionId": { + "type": "string" + } }, "required": ["country", "submissionId"], "type": "object" @@ -285,15 +545,27 @@ }, "AlfredpayStatusResponse": { "properties": { - "country": { "type": "string" }, - "creationTime": { "format": "date-time", "type": "string" }, - "status": { "$ref": "#/components/schemas/AlfredpayStatus" } + "country": { + "$ref": "#/components/schemas/AlfredpayCountry", + "type": "string" + }, + "creationTime": { + "format": "date-time", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/AlfredpayStatus" + } }, - "required": ["country", "creationTime", "status"], + "required": ["status", "country", "creationTime"], "type": "object" }, "AlfredpaySubmissionResponse": { - "properties": { "submissionId": { "type": "string" } }, + "properties": { + "submissionId": { + "type": "string" + } + }, "required": ["submissionId"], "type": "object" }, @@ -301,45 +573,112 @@ "allOf": [ { "if": { - "properties": { "transmitsCustomerFunds": { "const": true } }, + "properties": { + "transmitsCustomerFunds": { + "const": true + } + }, "required": ["transmitsCustomerFunds"] }, - "then": { "required": ["conductsComplianceScreening"] } + "then": { + "required": ["conductsComplianceScreening"] + } }, { "if": { - "properties": { "conductsComplianceScreening": { "const": true } }, + "properties": { + "conductsComplianceScreening": { + "const": true + } + }, "required": ["conductsComplianceScreening"] }, "then": { "properties": { - "complianceScreeningDescription": { "minLength": 1, "pattern": ".*\\S.*", "type": "string" } + "complianceScreeningDescription": { + "minLength": 1, + "pattern": ".*\\S.*", + "type": "string" + } }, "required": ["complianceScreeningDescription"] } } ], "properties": { - "accountPurpose": { "minLength": 1, "pattern": ".*\\S.*", "type": "string" }, - "address": { "type": "string" }, - "businessActivities": { "minLength": 1, "pattern": ".*\\S.*", "type": "string" }, - "businessName": { "type": "string" }, - "city": { "type": "string" }, - "complianceScreeningDescription": { "type": "string" }, - "conductsComplianceScreening": { "type": "boolean" }, - "country": { "$ref": "#/components/schemas/AlfredpayCountry" }, - "expectedMonthlyTransactions": { "minimum": 0, "type": "integer" }, - "expectedMonthlyVolumeUsd": { "minimum": 0, "type": "number" }, - "isRegulatedBusiness": { "type": "boolean" }, - "operatesInSanctionedCountries": { "type": "boolean" }, - "relatedPersons": { "items": { "$ref": "#/components/schemas/AlfredpayKybRelatedPerson" }, "type": "array" }, - "sourceOfFunds": { "minLength": 1, "pattern": ".*\\S.*", "type": "string" }, - "state": { "type": "string" }, - "taxId": { "type": "string" }, - "transmitsCustomerFunds": { "type": "boolean" }, - "walletAddresses": { "minLength": 1, "pattern": ".*\\S.*", "type": "string" }, - "website": { "type": "string" }, - "zipCode": { "type": "string" } + "accountPurpose": { + "minLength": 1, + "pattern": ".*\\S.*", + "type": "string" + }, + "address": { + "type": "string" + }, + "businessActivities": { + "minLength": 1, + "pattern": ".*\\S.*", + "type": "string" + }, + "businessName": { + "type": "string" + }, + "city": { + "type": "string" + }, + "complianceScreeningDescription": { + "type": "string" + }, + "conductsComplianceScreening": { + "type": "boolean" + }, + "country": { + "$ref": "#/components/schemas/AlfredpayCountry" + }, + "expectedMonthlyTransactions": { + "minimum": 0, + "type": "integer" + }, + "expectedMonthlyVolumeUsd": { + "minimum": 0, + "type": "number" + }, + "isRegulatedBusiness": { + "type": "boolean" + }, + "operatesInSanctionedCountries": { + "type": "boolean" + }, + "relatedPersons": { + "items": { + "$ref": "#/components/schemas/AlfredpayKybRelatedPerson" + }, + "type": "array" + }, + "sourceOfFunds": { + "minLength": 1, + "pattern": ".*\\S.*", + "type": "string" + }, + "state": { + "type": "string" + }, + "taxId": { + "type": "string" + }, + "transmitsCustomerFunds": { + "type": "boolean" + }, + "walletAddresses": { + "minLength": 1, + "pattern": ".*\\S.*", + "type": "string" + }, + "website": { + "type": "string" + }, + "zipCode": { + "type": "string" + } }, "required": [ "businessName", @@ -366,79 +705,182 @@ "AlfredpaySubmitKycInformationRequest": { "allOf": [ { - "if": { "properties": { "country": { "const": "AR" } }, "required": ["country"] }, + "if": { + "properties": { + "country": { + "const": "AR" + } + }, + "required": ["country"] + }, "then": { "properties": { - "cuit": { "pattern": "^\\d{11}$", "type": "string" }, + "cuit": { + "pattern": "^\\d{11}$", + "type": "string" + }, "nationalities": { - "items": { "pattern": "^[A-Z]{2}$", "type": "string" }, + "items": { + "pattern": "^[A-Z]{2}$", + "type": "string" + }, "type": "array" }, - "phoneNumber": { "pattern": "^\\+54", "type": "string" } + "phoneNumber": { + "pattern": "^\\+54", + "type": "string" + } }, "required": ["phoneNumber", "pep"] } } ], "properties": { - "address": { "type": "string" }, - "city": { "type": "string" }, - "country": { "$ref": "#/components/schemas/AlfredpayCountry" }, - "countryCode": { "type": "string" }, - "cuit": { "type": "string" }, - "dateOfBirth": { "format": "date", "type": "string" }, - "dni": { "type": "string" }, - "email": { "format": "email", "type": "string" }, - "firstName": { "type": "string" }, - "lastName": { "type": "string" }, - "nationalities": { "items": { "type": "string" }, "type": "array" }, - "pep": { "type": "boolean" }, - "phoneNumber": { "type": "string" }, - "state": { "type": "string" }, - "typeDocument": { "type": "string" }, - "typeDocumentAr": { "enum": ["DNI"], "type": "string" }, - "typeDocumentCol": { "enum": ["CC", "CE"], "type": "string" }, - "zipCode": { "type": "string" } + "address": { + "type": "string" + }, + "city": { + "type": "string" + }, + "country": { + "$ref": "#/components/schemas/AlfredpayCountry" + }, + "countryCode": { + "type": "string" + }, + "cuit": { + "type": "string" + }, + "dateOfBirth": { + "format": "date", + "type": "string" + }, + "dni": { + "type": "string" + }, + "email": { + "format": "email", + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "nationalities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "pep": { + "type": "boolean" + }, + "phoneNumber": { + "type": "string" + }, + "state": { + "type": "string" + }, + "typeDocument": { + "type": "string" + }, + "typeDocumentAr": { + "enum": ["DNI"], + "type": "string" + }, + "typeDocumentCol": { + "enum": ["CC", "CE"], + "type": "string" + }, + "zipCode": { + "type": "string" + } }, "required": ["firstName", "lastName", "dateOfBirth", "country", "city", "state", "zipCode", "address", "dni"], "type": "object" }, "AlfredpaySuccessResponse": { - "properties": { "success": { "const": true, "type": "boolean" } }, + "properties": { + "success": { + "const": true, + "type": "boolean" + } + }, "required": ["success"], "type": "object" }, "AlfredpayValidationBadRequestResponse": { "oneOf": [ - { "$ref": "#/components/schemas/AlfredpayErrorResponse" }, - { "$ref": "#/components/schemas/ApiValidationErrorResponse" }, - { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } + { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + }, + { + "$ref": "#/components/schemas/ApiValidationErrorResponse" + }, + { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } ] }, "ApiCredential": { "properties": { - "createdAt": { "format": "date-time", "type": "string" }, - "environment": { "enum": ["live", "test"], "type": "string" }, - "expiresAt": { "format": "date-time", "type": "string" }, - "id": { "format": "uuid", "type": "string" }, - "name": { "maxLength": 100, "type": "string" }, - "partnerId": { "format": "uuid", "type": ["string", "null"] }, - "profileId": { "format": "uuid", "type": "string" }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "environment": { + "enum": ["live", "test"], + "type": "string" + }, + "expiresAt": { + "format": "date-time", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "name": { + "maxLength": 100, + "type": "string" + }, + "partnerId": { + "format": "uuid", + "type": ["string", "null"] + }, + "profileId": { + "format": "uuid", + "type": "string" + }, "publicKey": { "description": "Retrievable public half of the credential.", "pattern": "^pk_(live|test)_[a-zA-Z0-9]{32}$", "type": "string" }, - "publicLastUsedAt": { "format": "date-time", "type": ["string", "null"] }, - "revokedAt": { "format": "date-time", "type": ["string", "null"] }, + "publicLastUsedAt": { + "format": "date-time", + "type": ["string", "null"] + }, + "revokedAt": { + "format": "date-time", + "type": ["string", "null"] + }, "secretKeyPrefix": { "description": "Non-secret 16-character lookup/display prefix. The secret value is not retrievable.", "maxLength": 16, "minLength": 16, "type": "string" }, - "secretLastUsedAt": { "format": "date-time", "type": ["string", "null"] }, - "updatedAt": { "format": "date-time", "type": "string" } + "secretLastUsedAt": { + "format": "date-time", + "type": ["string", "null"] + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } }, "required": [ "id", @@ -481,34 +923,59 @@ }, "ApiCredentialManagedSelectorErrorResponse": { "oneOf": [ - { "$ref": "#/components/schemas/ApiCredentialErrorResponse" }, - { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } + { + "$ref": "#/components/schemas/ApiCredentialErrorResponse" + }, + { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } ] }, "ApiValidationErrorResponse": { "properties": { - "code": { "type": "integer" }, + "code": { + "type": "integer" + }, "errors": { "items": { - "properties": { "message": { "type": "string" } }, + "properties": { + "message": { + "type": "string" + } + }, "required": ["message"], "type": "object" }, "type": "array" }, - "message": { "type": "string" } + "message": { + "type": "string" + } }, "required": ["code", "message"], "type": "object" }, "AveniaDocumentType": { - "enum": ["ID", "DRIVERS-LICENSE", "PASSPORT", "SELFIE", "SELFIE-FROM-LIVENESS"], + "enum": [ + "ID", + "DRIVERS-LICENSE", + "PASSPORT", + "RESIDENCE-PERMIT", + "SELFIE", + "SELFIE-FROM-LIVENESS", + "CERTIFICATE-OF-INCORPORATION", + "COMPANY-TAX-IDENTIFICATION-DOCUMENT" + ], "type": "string" }, "AveniaKYCDataUploadRequest": { "properties": { "documentType": { - "$ref": "#/components/schemas/AveniaDocumentType" + "enum": ["ID", "DRIVERS-LICENSE"], + "type": "string" + }, + "isDoubleSided": { + "type": "boolean" }, "taxId": { "description": "CPF or CNPJ.", @@ -530,91 +997,533 @@ "required": ["idUpload", "selfieUpload"], "type": "object" }, - "BrlaAddress": { + "AveniaKybAttemptStatusResponse": { "properties": { - "cep": { - "type": "string" - }, - "city": { + "failureReason": { "type": "string" }, - "complement": { - "type": ["string", "null"] - }, - "district": { + "result": { + "enum": ["APPROVED", "REJECTED"], "type": "string" }, - "number": { - "type": "string" + "retryable": { + "type": "boolean" }, - "state": { + "status": { + "enum": ["PENDING", "PROCESSING", "COMPLETED", "EXPIRED"], "type": "string" + } + }, + "required": ["status"], + "type": "object" + }, + "AveniaKybDocumentRequest": { + "properties": { + "documentType": { + "$ref": "#/components/schemas/AveniaDocumentType" }, - "street": { - "type": "string" + "isDoubleSided": { + "type": "boolean" } }, - "required": ["cep", "city", "state", "street", "number", "district"], + "required": ["documentType"], "type": "object" }, - "BrlaErrorResponse": { + "AveniaKybDocumentResponse": { "properties": { - "details": { - "description": "Detailed error message or object from BRLA API or server.", - "oneOf": [ - { + "document": { + "properties": { + "documentType": { + "$ref": "#/components/schemas/AveniaDocumentType" + }, + "id": { "type": "string" }, - { - "additionalProperties": true, - "type": "object" + "ready": { + "type": "boolean" + }, + "uploadErrorBack": { + "type": "string" + }, + "uploadErrorFront": { + "type": "string" + }, + "uploadStatusBack": { + "type": "string" + }, + "uploadStatusFront": { + "type": "string" } - ], - "type": "null" - }, - "error": { - "description": "A summary of the error.", - "type": "string" + }, + "required": ["id", "documentType", "uploadStatusFront", "ready"], + "type": "object" } }, + "required": ["document"], "type": "object" }, - "BrlaGetSelfieLivenessUrlResponse": { + "AveniaKybDocumentUploadResponse": { "properties": { "id": { "type": "string" }, "livenessUrl": { + "format": "uri", + "type": "string" + }, + "uploadURLBack": { + "format": "uri", "type": "string" }, "uploadURLFront": { + "format": "uri", "type": "string" }, "validateLivenessToken": { "type": "string" } }, - "required": ["id", "livenessUrl", "uploadURLFront", "validateLivenessToken"], + "required": ["id", "uploadURLFront"], "type": "object" }, - "BrlaManagedBadRequestResponse": { - "oneOf": [ - { "$ref": "#/components/schemas/BrlaErrorResponse" }, - { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } - ] - }, - "BrlaValidatePixKeyResponse": { + "AveniaKybHostedResponse": { "properties": { - "valid": { - "type": "boolean" + "attemptId": { + "type": "string" + }, + "authorizedRepresentativeUrl": { + "format": "uri", + "type": "string" + }, + "basicCompanyDataUrl": { + "format": "uri", + "type": "string" } }, - "required": ["valid"], + "required": ["attemptId", "authorizedRepresentativeUrl", "basicCompanyDataUrl"], "type": "object" }, - "CleanupPhase": { + "AveniaKybLevel1Payload": { + "additionalProperties": false, "properties": { - "string": { + "businessActivityDescription": { + "type": "string" + }, + "certificateOfIncorporationDocumentId": { + "type": "string" + }, + "companyCity": { + "type": "string" + }, + "companyCountry": { + "type": "string" + }, + "companyLegalName": { + "type": "string" + }, + "companyRegistrationNumber": { + "type": "string" + }, + "companyState": { + "type": "string" + }, + "companyStreetLine1": { + "type": "string" + }, + "companyStreetLine2": { + "type": "string" + }, + "companyStreetLine3": { + "type": "string" + }, + "companyZipCode": { + "type": "string" + }, + "countrySubdivisionTaxResidence": { + "type": "string" + }, + "countryTaxResidence": { + "type": "string" + }, + "emailPixKey": { + "format": "email", + "type": "string" + }, + "estimatedAnnualRevenueUsd": { + "enum": ["less_than_100k", "100k_to_1m", "1m_to_10m", "10m_to_50m", "50m_to_100m", "more_than_100m"], + "type": "string" + }, + "estimatedMonthlyVolumeUsd": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + }, + "numberOfEmployees": { + "enum": ["1-10", "11-50", "51-200", "201-500", "501-1000", "1001+"], + "type": "string" + }, + "reasonForAccountOpening": { + "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" + ], + "type": "string" + }, + "sandboxReject": { + "type": "boolean" + }, + "socialMedia": { + "format": "uri", + "type": "string" + }, + "sourceOfFundsAndIncome": { + "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" + ], + "type": "string" + }, + "taxIdentificationDocumentId": { + "type": "string" + }, + "taxIdentificationNumberTin": { + "type": "string" + }, + "uboIds": { + "items": { + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "type": "array" + }, + "website": { + "format": "uri", + "type": "string" + } + }, + "required": [ + "uboIds", + "companyLegalName", + "companyRegistrationNumber", + "taxIdentificationNumberTin", + "businessActivityDescription", + "reasonForAccountOpening", + "sourceOfFundsAndIncome", + "numberOfEmployees", + "estimatedAnnualRevenueUsd", + "estimatedMonthlyVolumeUsd", + "countryTaxResidence", + "companyStreetLine1", + "companyCity", + "companyState", + "companyZipCode", + "companyCountry", + "certificateOfIncorporationDocumentId", + "taxIdentificationDocumentId" + ], + "type": "object" + }, + "AveniaUboControlRole": { + "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" + ], + "type": "string" + }, + "AveniaUboPayload": { + "additionalProperties": false, + "properties": { + "city": { + "type": "string" + }, + "country": { + "type": "string" + }, + "countryOfTaxId": { + "type": "string" + }, + "dateOfBirth": { + "format": "date", + "type": "string" + }, + "documentCountry": { + "type": "string" + }, + "email": { + "format": "email", + "type": "string" + }, + "fullName": { + "type": "string" + }, + "hasControl": { + "$ref": "#/components/schemas/AveniaUboControlRole" + }, + "percentageOfOwnership": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "state": { + "type": "string" + }, + "streetLine1": { + "type": "string" + }, + "streetLine2": { + "type": "string" + }, + "streetLine3": { + "type": "string" + }, + "taxIdNumber": { + "type": "string" + }, + "uploadedIdentificationId": { + "type": "string" + }, + "uploadedSelfieId": { + "type": "string" + }, + "zipCode": { + "type": "string" + } + }, + "required": [ + "fullName", + "dateOfBirth", + "countryOfTaxId", + "taxIdNumber", + "percentageOfOwnership", + "uploadedIdentificationId", + "documentCountry", + "streetLine1", + "city", + "state", + "zipCode", + "country" + ], + "type": "object" + }, + "AveniaUboResponse": { + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "type": "object" + }, + "BrlaAddress": { + "properties": { + "cep": { + "type": "string" + }, + "city": { + "type": "string" + }, + "complement": { + "type": ["string", "null"] + }, + "district": { + "type": "string" + }, + "number": { + "type": "string" + }, + "state": { + "type": "string" + }, + "street": { + "type": "string" + } + }, + "required": ["cep", "city", "state", "street", "number", "district"], + "type": "object" + }, + "BrlaErrorResponse": { + "properties": { + "details": { + "description": "Detailed error message or object from BRLA API or server.", + "oneOf": [ + { + "type": "string" + }, + { + "additionalProperties": true, + "type": "object" + } + ], + "type": "null" + }, + "error": { + "description": "A summary of the error.", + "type": "string" + } + }, + "type": "object" + }, + "BrlaGetSelfieLivenessUrlResponse": { + "properties": { + "id": { + "type": "string" + }, + "livenessUrl": { + "type": "string" + }, + "uploadURLFront": { + "type": "string" + }, + "validateLivenessToken": { + "type": "string" + } + }, + "required": ["id", "livenessUrl", "uploadURLFront", "validateLivenessToken"], + "type": "object" + }, + "BrlaImportKycTokenErrorResponse": { + "properties": { + "error": { + "description": "Stable, non-secret token-import error. Provider response bodies and the import token are never returned.", + "enum": [ + "Idempotency-Key must contain 1 to 128 visible ASCII characters", + "Invalid request body", + "importToken must contain between 1 and 1024 bytes", + "consentAttested must be true", + "The subject profile has no active customer entity", + "The managed subject does not match the expected customer entity", + "A managed profile requires a managed customer entity context", + "The subject customer entity is not active", + "Avenia token import is only available for individuals", + "Exactly one active Brazilian individual Avenia customer is required", + "Multiple Avenia customers require reconciliation", + "The Avenia subaccount is not provisioned", + "The Avenia customer is already approved", + "The canonical Avenia KYC case is missing", + "Multiple Avenia KYC cases require reconciliation", + "The Avenia KYC case is already approved", + "The confirmed token import is missing its provider attempt", + "The idempotency key was used with a different token", + "The token import does not match this request", + "A failed token import requires a new idempotency key", + "The Avenia KYC is already approved", + "The previous token import outcome requires reconciliation", + "Another token import requires reconciliation", + "This KYC case uses the standard Avenia method", + "The Avenia token import attempt is invalid", + "The token import attempt requires reconciliation", + "The token import was already claimed", + "The token import binding is no longer current", + "The authenticated profile cannot perform this operation for the requested managed profile", + "Avenia token import pre-provider checks failed", + "Avenia token import is not enabled", + "The Avenia token import outcome requires reconciliation", + "Token import failed" + ], + "type": "string" + } + }, + "required": ["error"], + "type": "object" + }, + "BrlaImportKycTokenRequest": { + "additionalProperties": false, + "properties": { + "consentAttested": { + "const": true, + "description": "Required provisional attestation recorded under Vortex consent policy `sumsub-share-v1`. This is not a substitute for the caller's legal basis or applicant disclosures.", + "type": "boolean" + }, + "importToken": { + "description": "Opaque Sumsub share token. Must contain 1 to 1024 UTF-8 bytes. Vortex forwards it to Avenia from request memory and never returns or persists the raw value.", + "minLength": 1, + "type": "string", + "writeOnly": true, + "x-maxBytes": 1024 + } + }, + "required": ["importToken", "consentAttested"], + "type": "object" + }, + "BrlaImportKycTokenResponse": { + "additionalProperties": false, + "properties": { + "attemptId": { + "description": "The exact Avenia verification attempt bound to this KYC case and used for subsequent polling.", + "type": "string" + }, + "status": { + "const": "pending", + "type": "string" + } + }, + "required": ["attemptId", "status"], + "type": "object" + }, + "BrlaManagedBadRequestResponse": { + "oneOf": [ + { + "$ref": "#/components/schemas/BrlaErrorResponse" + }, + { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + ] + }, + "BrlaValidatePixKeyResponse": { + "properties": { + "valid": { + "type": "boolean" + } + }, + "required": ["valid"], + "type": "object" + }, + "CleanupPhase": { + "properties": { + "string": { "enum": ["moonbeamCleanup", "pendulumCleanup", "stellarCleanup"], "type": "string" } @@ -642,7 +1551,9 @@ }, "CreateApiCredentialResponse": { "allOf": [ - { "$ref": "#/components/schemas/ApiCredential" }, + { + "$ref": "#/components/schemas/ApiCredential" + }, { "properties": { "secretKey": { @@ -716,7 +1627,10 @@ "maxLength": 255, "type": "string" }, - "customerType": { "enum": ["individual", "business"], "type": "string" }, + "customerType": { + "enum": ["individual", "business"], + "type": "string" + }, "externalSubjectId": { "description": "Immutable idempotency key for this subject within the authenticated manager.", "maxLength": 255, @@ -777,52 +1691,36 @@ }, "CreateSubaccountRequest": { "properties": { - "address": { - "$ref": "#/components/schemas/BrlaAddress" - }, - "birthdate": { - "description": "Date must be in format YYYY-MMM-DD.", - "format": "date", + "accountType": { + "enum": ["INDIVIDUAL", "COMPANY"], "type": "string" }, - "cnpj": { - "type": ["string", "null"] - }, - "companyName": { - "type": ["string", "null"] - }, - "cpf": { + "name": { + "description": "Individual full name or company legal name.", "type": "string" }, - "fullName": { + "quoteId": { "type": "string" }, - "phone": { + "sessionId": { "type": "string" }, - "quoteId": { - "description": "Optional. The quote that triggered onboarding. Omit it for the quote-less KYB deep link (`?kyb` / `?kybLocked` widget entry), where business verification starts before any quote exists. Stored only as onboarding provenance; it is not an authorization input.", - "type": ["string", "null"] - }, - "startDate": { - "description": "Date must be in format YYYY-MMM-DD.", - "format": "date", - "type": ["string", "null"] - }, - "taxIdType": { - "$ref": "#/components/schemas/TaxIdType" + "taxId": { + "description": "CPF for an individual or CNPJ for a company.", + "type": "string" } }, - "required": ["phone", "taxIdType", "address", "fullName", "cpf", "birthdate"], + "required": ["accountType", "name", "taxId"], "type": "object" }, "CreateSubaccountResponse": { "properties": { - "subaccountId": { + "subAccountId": { "description": "The ID of the created or processed subaccount.", "type": "string" } }, + "required": ["subAccountId"], "type": "object" }, "DestinationType": { @@ -869,8 +1767,12 @@ }, "ErrorManagedSelectorResponse": { "oneOf": [ - { "$ref": "#/components/schemas/ErrorResponse" }, - { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } + { + "$ref": "#/components/schemas/ErrorResponse" + }, + { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } ] }, "ErrorResponse": { @@ -906,25 +1808,41 @@ "type": "string" }, "FlatErrorResponse": { - "properties": { "error": { "type": "string" } }, + "properties": { + "error": { + "type": "string" + } + }, "required": ["error"], "type": "object" }, "FlatManagedSelectorErrorResponse": { "oneOf": [ - { "$ref": "#/components/schemas/FlatErrorResponse" }, - { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } + { + "$ref": "#/components/schemas/FlatErrorResponse" + }, + { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } ] }, "GetKycStatusResponse": { "properties": { + "failureReason": { + "enum": ["face", "name", "birthdate", "unknown", "tax_id"], + "type": "string" + }, "level": { "description": "The KYC level achieved.", - "type": "number" + "type": "string" + }, + "result": { + "enum": ["APPROVED", "REJECTED"], + "type": "string" }, "status": { "description": "The KYC status.", - "enum": ["PENDING", "APPROVED", "REJECTED"], + "enum": ["PENDING", "PROCESSING", "COMPLETED", "EXPIRED"], "type": "string" }, "type": { @@ -933,6 +1851,7 @@ "type": "string" } }, + "required": ["type", "level", "status"], "type": "object" }, "GetRampErrorLogsResponse": { @@ -1051,17 +1970,13 @@ }, "GetUserRemainingLimitResponse": { "properties": { - "remainingLimitOfframp": { - "description": "The remaining limit for offramp operations.", - "format": "double", - "type": "number" - }, - "remainingLimitOnramp": { - "description": "The remaining limit for onramp operations.", + "remainingLimit": { + "description": "The remaining limit for the requested direction.", "format": "double", "type": "number" } }, + "required": ["remainingLimit"], "type": "object" }, "GetUserResponse": { @@ -1070,12 +1985,19 @@ "description": "The user's EVM wallet address.", "type": "string" }, + "identityStatus": { + "enum": ["NOT-IDENTIFIED", "CONFIRMED"], + "type": "string" + }, "kycLevel": { "description": "The user's KYC level.", - "enum": [1, 2], "type": "number" + }, + "subAccountId": { + "type": "string" } }, + "required": ["evmAddress", "kycLevel", "identityStatus", "subAccountId"], "type": "object" }, "GetWidgetUrlLocked": { @@ -1174,18 +2096,35 @@ }, "KybAttemptStatusResponse": { "properties": { - "failureReason": { "enum": ["face", "name", "birthdate", "unknown", "tax_id"], "type": "string" }, - "result": { "enum": ["APPROVED", "REJECTED"], "type": "string" }, - "status": { "enum": ["PENDING", "PROCESSING", "COMPLETED", "EXPIRED"], "type": "string" } + "failureReason": { + "enum": ["face", "name", "birthdate", "unknown", "tax_id"], + "type": "string" + }, + "result": { + "enum": ["APPROVED", "REJECTED"], + "type": "string" + }, + "status": { + "enum": ["PENDING", "PROCESSING", "COMPLETED", "EXPIRED"], + "type": "string" + } }, "required": ["status"], "type": "object" }, "KybLevel1Response": { "properties": { - "attemptId": { "type": "string" }, - "authorizedRepresentativeUrl": { "format": "uri", "type": "string" }, - "basicCompanyDataUrl": { "format": "uri", "type": "string" } + "attemptId": { + "type": "string" + }, + "authorizedRepresentativeUrl": { + "format": "uri", + "type": "string" + }, + "basicCompanyDataUrl": { + "format": "uri", + "type": "string" + } }, "required": ["attemptId", "authorizedRepresentativeUrl", "basicCompanyDataUrl"], "type": "object" @@ -1263,7 +2202,9 @@ "ListApiCredentialsResponse": { "properties": { "credentials": { - "items": { "$ref": "#/components/schemas/ApiCredential" }, + "items": { + "$ref": "#/components/schemas/ApiCredential" + }, "type": "array" } }, @@ -1273,14 +2214,41 @@ "ListManagedProfilesResponse": { "properties": { "managedProfiles": { - "items": { "$ref": "#/components/schemas/ManagedProfile" }, + "items": { + "$ref": "#/components/schemas/ManagedProfile" + }, "type": "array" }, - "pagination": { "$ref": "#/components/schemas/ManagedProfilePagination" } + "pagination": { + "$ref": "#/components/schemas/ManagedProfilePagination" + } }, "required": ["managedProfiles", "pagination"], "type": "object" }, + "MalformedJsonErrorResponse": { + "additionalProperties": false, + "properties": { + "code": { + "const": 400, + "type": "integer" + }, + "message": { + "const": "Invalid JSON payload", + "type": "string" + }, + "statusCode": { + "const": 400, + "type": "integer" + }, + "type": { + "const": "entity.parse.failed", + "type": "string" + } + }, + "required": ["code", "message", "statusCode", "type"], + "type": "object" + }, "ManagedProfile": { "properties": { "contactEmail": { @@ -1288,19 +2256,40 @@ "format": "email", "type": ["string", "null"] }, - "createdAt": { "format": "date-time", "type": "string" }, - "creationSource": { "enum": ["manager", "vortex"], "type": "string" }, - "customerType": { "enum": ["individual", "business"], "type": "string" }, - "deletedAt": { "format": "date-time", "type": ["string", "null"] }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "creationSource": { + "enum": ["manager", "vortex"], + "type": "string" + }, + "customerType": { + "enum": ["individual", "business"], + "type": "string" + }, + "deletedAt": { + "format": "date-time", + "type": ["string", "null"] + }, "externalSubjectId": { "description": "Immutable manager-scoped subject identifier.", "maxLength": 255, "minLength": 1, "type": "string" }, - "profileId": { "format": "uuid", "type": "string" }, - "status": { "enum": ["active", "deleted"], "type": "string" }, - "updatedAt": { "format": "date-time", "type": "string" } + "profileId": { + "format": "uuid", + "type": "string" + }, + "status": { + "enum": ["active", "deleted"], + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } }, "required": [ "contactEmail", @@ -1323,8 +2312,12 @@ "description": "Machine-readable error code. Managed-profile lifecycle codes include `MANAGED_PROFILE_INVALID_INPUT`, `MANAGED_PROFILE_ACCESS_DENIED`, `MANAGED_PROFILE_NOT_FOUND`, `MANAGED_PROFILE_CONFLICT`, `MANAGED_PROFILE_MANAGER_NOT_FOUND`, and `MANAGED_PROFILE_MANAGER_INACTIVE`. Credential codes include `INVALID_CREDENTIAL_NAME`, `INVALID_CREDENTIAL_EXPIRY`, `CREDENTIAL_ACCESS_DENIED`, `CREDENTIAL_NOT_FOUND`, and `CREDENTIAL_LIMIT_REACHED`. Authentication middleware may return `AUTHENTICATION_REQUIRED`, `INVALID_SECRET_KEY`, `INVALID_API_KEY`, `INVALID_BEARER_TOKEN`, `INVALID_PUBLIC_KEY`, or `CREDENTIAL_MISMATCH`.", "type": "string" }, - "message": { "type": "string" }, - "status": { "type": "integer" } + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } }, "required": ["code", "message", "status"], "type": "object" @@ -1335,16 +2328,28 @@ }, "ManagedProfilePagination": { "properties": { - "limit": { "maximum": 100, "minimum": 1, "type": "integer" }, - "offset": { "minimum": 0, "type": "integer" }, - "total": { "minimum": 0, "type": "integer" } + "limit": { + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "offset": { + "minimum": 0, + "type": "integer" + }, + "total": { + "minimum": 0, + "type": "integer" + } }, "required": ["limit", "offset", "total"], "type": "object" }, "ManagedProfileResponse": { "properties": { - "managedProfile": { "$ref": "#/components/schemas/ManagedProfile" } + "managedProfile": { + "$ref": "#/components/schemas/ManagedProfile" + } }, "required": ["managedProfile"], "type": "object" @@ -1357,8 +2362,12 @@ "description": "Machine-readable middleware code such as `INVALID_MANAGED_PROFILE_ID`, `MANAGED_PROFILE_CUSTOMER_TYPE_MISMATCH`, `AUTHENTICATION_REQUIRED`, `INVALID_SECRET_KEY`, `INVALID_API_KEY`, `INVALID_BEARER_TOKEN`, `CREDENTIAL_MISMATCH`, or `MANAGED_PROFILE_ACCESS_DENIED`.", "type": "string" }, - "message": { "type": "string" }, - "status": { "type": "integer" } + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } }, "required": ["code", "message", "status"], "type": "object" @@ -1372,1234 +2381,3298 @@ "enum": ["assethub", "arbitrum", "avalanche", "base", "bsc", "ethereum", "polygon", "moonbeam"], "type": "string" }, - "OnboardingStatusErrorResponse": { + "OnboardingApiErrorResponse": { "properties": { "error": { - "properties": { - "code": { "const": "INTERNAL_SERVER_ERROR", "type": "string" }, - "message": { "const": "Failed to read onboarding status", "type": "string" }, - "status": { "const": 500, "type": "integer" } - }, - "required": ["code", "message", "status"], - "type": "object" + "oneOf": [ + { + "type": "string" + }, + { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": ["code", "message", "status"], + "type": "object" + } + ] } }, "required": ["error"], "type": "object" }, - "OnboardingStatusResponse": { + "OnboardingDocumentRequirement": { "properties": { - "activeEntityId": { "format": "uuid", "type": ["string", "null"] }, - "entities": { + "acceptedMediaTypes": { "items": { - "properties": { - "accounts": { - "items": { - "properties": { - "companyName": { "type": ["string", "null"] }, - "country": { "type": ["string", "null"] }, - "customerType": { "enum": ["individual", "business"], "type": "string" }, - "error": { - "properties": { "code": { "type": "string" }, "message": { "type": "string" } }, - "required": ["code", "message"], - "type": ["object", "null"] - }, - "id": { "format": "uuid", "type": "string" }, - "kycCase": { - "properties": { - "approvedAt": { "format": "date-time", "type": ["string", "null"] }, - "failureReasons": { "items": { "type": "string" }, "type": ["array", "null"] }, - "level": { "type": ["string", "null"] }, - "rejectedAt": { "format": "date-time", "type": ["string", "null"] }, - "status": { "enum": ["pending", "started", "in_review", "approved", "rejected"], "type": "string" }, - "statusExternal": { "type": ["string", "null"] }, - "submittedAt": { "format": "date-time", "type": ["string", "null"] }, - "type": { "enum": ["kyc", "kyb"], "type": "string" } - }, - "required": [ - "approvedAt", - "failureReasons", - "level", - "rejectedAt", - "status", - "statusExternal", - "submittedAt", - "type" - ], - "type": ["object", "null"] - }, - "provider": { "enum": ["alfredpay", "avenia", "monerium", "mykobo"], "type": "string" }, - "rail": { "type": ["string", "null"] }, - "state": { "enum": ["pending", "started", "in_review", "approved", "rejected"], "type": "string" }, - "status": { "enum": ["pending", "started", "in_review", "approved", "rejected"], "type": "string" }, - "statusExternal": { "type": ["string", "null"] }, - "taxReference": { "type": ["string", "null"] } - }, - "required": [ - "companyName", - "country", - "customerType", - "error", - "id", - "kycCase", - "provider", - "rail", - "state", - "status", - "statusExternal", - "taxReference" - ], - "type": "object" - }, - "type": "array" - }, - "id": { "format": "uuid", "type": "string" }, - "status": { "enum": ["active", "archived", "blocked"], "type": "string" }, - "type": { "enum": ["individual", "business"], "type": "string" } - }, - "required": ["accounts", "id", "status", "type"], - "type": "object" + "type": "string" }, "type": "array" }, - "roles": { "items": { "type": "string" }, "type": "array" }, - "selectionRequired": { "type": "boolean" } - }, - "required": ["activeEntityId", "entities", "roles", "selectionRequired"], - "type": "object" - }, - "OnChainToken": { - "enum": ["USDC", "USDT", "ETH", "USDC.E"], - "type": "string" - }, - "PaymentData": { - "description": "Data related to the payment for the ramp transaction.", - "properties": { - "amount": { - "description": "The amount for the payment.", - "examples": ["0.05"], + "collection": { + "enum": ["direct-upload", "hosted"], "type": "string" }, - "anchorTargetAccount": { - "description": "The target account for an anchor operation.", - "examples": ["GDSDQLBVDD5RZYKNDM2LAX5JDNNQOTSZOKECUYEXYMUZMAPXTMDUJCVF"], + "description": { "type": "string" }, - "memo": { - "description": "The memo content.", - "examples": ["1204asjfnaksf10982e4"], + "required": { + "type": "boolean" + }, + "requiredWhen": { "type": "string" }, - "memoType": { - "description": "Type of memo (e.g., text, id).", - "examples": ["text"], + "type": { "type": "string" } }, + "required": ["type", "required"], "type": "object" }, - "PaymentMethod": { - "description": "`PIX`, `SEPA`, `CBU`", - "type": "string" - }, - "PresignedTx": { - "additionalProperties": true, - "description": "Represents a transaction that has been presigned. Based on UnsignedTx structure.", + "OnboardingRequirementStep": { "properties": { - "meta": { - "additionalProperties": true, - "description": "Any additional metadata associated with the transaction. Can be an empty object.", - "properties": {}, + "condition": { + "type": "string" + }, + "derivedValues": { + "additionalProperties": { + "type": "string" + }, "type": "object" }, - "nonce": { - "description": "Nonce for the transaction, if applicable.", - "format": "int64", - "type": "number" + "description": { + "type": "string" }, - "phase": { - "description": "The phase this transaction belongs to within the ramp logic.", - "enum": ["RampPhase", "CleanupPhase"], + "fixedBody": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "fixedQuery": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "kind": { + "enum": ["api", "direct-upload", "hosted"], "type": "string" }, - "signer": { - "description": "Address of the account that signed/will sign this transaction.", + "method": { + "enum": ["POST", "PUT"], "type": "string" }, - "txData": { - "description": "The presigned transaction payload or relevant data.", - "examples": ["AAAAAKg..."], - "type": "string" - } - }, - "type": "object" - }, - "QuoteResponse": { - "properties": { - "anchorFeeFiat": { - "type": "string" - }, - "anchorFeeUSD": { - "type": "string" - }, - "expiresAt": { - "description": "The timestamp when this quote expires.", - "format": "date-time", - "type": "string" - }, - "feeCurrency": { - "$ref": "#/components/schemas/RampCurrency" - }, - "from": { - "$ref": "#/components/schemas/DestinationType" - }, - "id": { - "description": "Unique identifier for the quote.", - "format": "uuid", - "type": "string" - }, - "inputAmount": { - "description": "The input amount specified in the request.", + "operationId": { "type": "string" }, - "inputCurrency": { - "$ref": "#/components/schemas/RampCurrency" - }, - "networkFeeFiat": { - "type": "string" + "order": { + "minimum": 1, + "type": "integer" }, - "networkFeeUSD": { + "path": { "type": "string" }, - "outputAmount": { - "description": "The calculated output amount after fees and conversions.", + "repeatFor": { "type": "string" }, - "outputCurrency": { - "$ref": "#/components/schemas/RampCurrency" - }, - "partnerFeeFiat": { + "requestSchema": { "type": "string" - }, - "partnerFeeUSD": { + } + }, + "required": ["order", "kind", "description"], + "type": "object" + }, + "OnboardingRequirementsErrorResponse": { + "properties": { + "error": { + "properties": { + "code": { + "enum": ["INVALID_ONBOARDING_REQUIREMENTS_QUERY", "ONBOARDING_REQUIREMENTS_NOT_FOUND"], + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "enum": [400, 404], + "type": "integer" + } + }, + "required": ["code", "message", "status"], + "type": "object" + } + }, + "required": ["error"], + "type": "object" + }, + "OnboardingRequirementsResponse": { + "properties": { + "country": { + "enum": ["AR", "BR", "CO", "MX", "US"], "type": "string" }, - "processingFeeFiat": { + "customerType": { + "enum": ["individual", "business"], "type": "string" }, - "processingFeeUSD": { + "documentationUrl": { + "format": "uri", "type": "string" }, - "rampType": { - "$ref": "#/components/schemas/RampDirection", - "description": "The type of ramp process." - }, - "to": { - "$ref": "#/components/schemas/DestinationType" + "documents": { + "items": { "$ref": "#/components/schemas/OnboardingDocumentRequirement" }, + "type": "array" }, - "totalFeeFiat": { + "flow": { "type": "string" }, + "mode": { + "enum": ["api", "hosted", "hybrid"], "type": "string" }, - "totalFeeUSD": { + "openapiUrl": { + "format": "uri", "type": "string" }, - "vortexFeeFiat": { + "provider": { + "enum": ["alfredpay", "avenia"], "type": "string" }, - "vortexFeeUSD": { - "type": "string" + "requirementsVersion": { "type": "string" }, + "steps": { + "items": { "$ref": "#/components/schemas/OnboardingRequirementStep" }, + "type": "array" } }, "required": [ - "networkFeeFiat", - "networkFeeUSD", - "anchorFeeFiat", - "anchorFeeUSD", - "vortexFeeFiat", - "vortexFeeUSD", - "partnerFeeFiat", - "partnerFeeUSD", - "totalFeeFiat", - "totalFeeUSD", - "processingFeeFiat", - "processingFeeUSD", - "feeCurrency" + "country", + "customerType", + "documentationUrl", + "documents", + "flow", + "mode", + "openapiUrl", + "provider", + "requirementsVersion", + "steps" ], "type": "object" }, - "RampCurrency": { - "description": "Represents supported currencies for ramp operations, including fiat and on-chain tokens.", - "enum": ["EUR", "ARS", "BRL", "USD", "MXN", "COP", "USDC", "USDT", "USDC.E"], - "examples": ["USDC"], - "type": "string" - }, - "RampDirection": { - "enum": ["BUY", "SELL"], - "type": "string" - }, - "RampErrorLog": { + "OnboardingStatusErrorResponse": { "properties": { - "details": { - "type": "string" - }, "error": { - "type": "string" - }, - "phase": { - "$ref": "#/components/schemas/RampPhase" - }, - "recoverable": { - "type": "boolean" - }, - "timestamp": { - "format": "date-time", - "type": "string" - } - }, - "required": ["timestamp", "phase", "error"], - "type": "object" - }, - "RampInfoResponse": { - "properties": { - "corridors": { - "additionalProperties": { - "properties": { - "canBuy": { "type": "boolean" }, - "canSell": { "type": "boolean" }, - "kycStatus": { - "enum": ["not_started", "pending", "approved", "rejected"], - "type": "string" - } + "properties": { + "code": { + "const": "INTERNAL_SERVER_ERROR", + "type": "string" }, - "required": ["kycStatus", "canBuy", "canSell"], - "type": "object" + "message": { + "const": "Failed to read onboarding status", + "type": "string" + }, + "status": { + "const": 500, + "type": "integer" + } }, - "description": "Sanitized eligibility keyed by corridor country code. No exact limits, PII, provider IDs, or failure reasons are returned.", + "required": ["code", "message", "status"], "type": "object" } }, - "required": ["corridors"], + "required": ["error"], "type": "object" }, - "RampPhase": { - "description": "The current phase of the ramp process.", - "enum": [ - "initial", - "timedOut", - "stellarCreateAccount", - "squidrouterApprove", - "squidrouterSwap", - "fundEphemeral", - "nablaApprove", - "nablaSwap", - "moonbeamToPendulum", - "moonbeamToPendulumXcm", - "pendulumToMoonbeam", - "assethubToPendulum", - "pendulumToAssethub", - "spacewalkRedeem", - "stellarPayment", - "subsidizePreSwap", - "subsidizePostSwap", - "brlaTeleport", - "onHoldForComplianceCheck", - "brlaPayoutOnMoonbeam", - "failed" - ], - "type": "string" - }, - "RampProcess": { + "OnboardingStatusResponse": { "properties": { - "anchorFeeFiat": { - "type": "string" - }, - "anchorFeeUSD": { - "type": "string" - }, - "countryCode": { - "$ref": "#/components/schemas/CountryCode" - }, - "createdAt": { - "description": "Timestamp of when the ramp process was created.", - "format": "date-time", - "type": "string" - }, - "currentPhase": { - "$ref": "#/components/schemas/RampPhase" - }, - "depositQrCode": { - "description": "BR Code for PIX payment, if applicable.", + "activeEntityId": { + "format": "uuid", "type": ["string", "null"] }, - "feeCurrency": { - "$ref": "#/components/schemas/RampCurrency" - }, - "from": { - "$ref": "#/components/schemas/DestinationType", - "description": "The source network or payment method." - }, - "id": { - "description": "Unique identifier for the ramp process.", - "type": "string" - }, - "inputAmount": { - "type": "string" - }, - "inputCurrency": { - "type": "string" - }, - "network": { - "$ref": "#/components/schemas/Networks" - }, - "networkFeeFiat": { - "type": "string" - }, - "networkFeeUSD": { - "type": "string" - }, - "outputAmount": { - "type": "string" - }, - "outputCurrency": { - "type": "string" - }, - "partnerFeeFiat": { - "type": "string" - }, - "partnerFeeUSD": { - "type": "string" - }, - "paymentMethod": { - "$ref": "#/components/schemas/PaymentMethod" - }, - "processingFeeFiat": { - "type": "string" - }, - "processingFeeUSD": { - "type": "string" - }, - "quoteId": { - "description": "The quote ID associated with this ramp process.", - "format": "uuid", - "type": "string" - }, - "sessionId": { - "description": "The `externalSessionId` is an optional URL parameter that integrators can provide to track ramp transactions within their own systems. This identifier allows you to correlate Vortex transactions with your internal session or transaction tracking. `externalSessionId` url param is named `sessionId` in the Vortex API.", - "type": "string" + "entities": { + "items": { + "properties": { + "accounts": { + "items": { + "properties": { + "companyName": { + "type": ["string", "null"] + }, + "country": { + "type": ["string", "null"] + }, + "customerType": { + "enum": ["individual", "business"], + "type": "string" + }, + "error": { + "oneOf": [ + { + "type": "null" + }, + { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + } + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": ["object", "null"] + }, + "id": { + "format": "uuid", + "type": "string" + }, + "kycCase": { + "oneOf": [ + { + "type": "null" + }, + { + "properties": { + "approvedAt": { + "format": "date-time", + "type": ["string", "null"] + }, + "failureReasons": { + "items": { + "type": "string" + }, + "type": ["array", "null"] + }, + "level": { + "type": ["string", "null"] + }, + "rejectedAt": { + "format": "date-time", + "type": ["string", "null"] + }, + "status": { + "enum": ["pending", "started", "in_review", "approved", "rejected"], + "type": "string" + }, + "statusExternal": { + "type": ["string", "null"] + }, + "submittedAt": { + "format": "date-time", + "type": ["string", "null"] + }, + "type": { + "enum": ["kyc", "kyb"], + "type": "string" + } + }, + "required": [ + "approvedAt", + "failureReasons", + "level", + "rejectedAt", + "status", + "statusExternal", + "submittedAt", + "type" + ], + "type": "object" + } + ], + "properties": { + "approvedAt": { + "format": "date-time", + "type": ["string", "null"] + }, + "failureReasons": { + "items": { + "type": "string" + }, + "type": ["array", "null"] + }, + "level": { + "type": ["string", "null"] + }, + "rejectedAt": { + "format": "date-time", + "type": ["string", "null"] + }, + "status": { + "enum": ["pending", "started", "in_review", "approved", "rejected"], + "type": "string" + }, + "statusExternal": { + "type": ["string", "null"] + }, + "submittedAt": { + "format": "date-time", + "type": ["string", "null"] + }, + "type": { + "enum": ["kyc", "kyb"], + "type": "string" + } + }, + "required": [ + "approvedAt", + "failureReasons", + "level", + "rejectedAt", + "status", + "statusExternal", + "submittedAt", + "type" + ], + "type": ["object", "null"] + }, + "provider": { + "enum": ["alfredpay", "avenia", "monerium", "mykobo"], + "type": "string" + }, + "rail": { + "type": ["string", "null"] + }, + "state": { + "enum": ["pending", "started", "in_review", "approved", "rejected"], + "type": "string" + }, + "status": { + "enum": ["pending", "started", "in_review", "approved", "rejected"], + "type": "string" + }, + "statusExternal": { + "type": ["string", "null"] + }, + "taxReference": { + "description": "Business tax ID only; individual tax IDs remain private.", + "type": ["string", "null"] + } + }, + "required": [ + "companyName", + "country", + "customerType", + "error", + "id", + "kycCase", + "provider", + "rail", + "state", + "status", + "statusExternal", + "taxReference" + ], + "type": "object" + }, + "type": "array" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "status": { + "enum": ["active", "archived", "blocked"], + "type": "string" + }, + "type": { + "enum": ["individual", "business"], + "type": "string" + } + }, + "required": ["accounts", "id", "status", "type"], + "type": "object" + }, + "type": "array" }, - "status": { - "$ref": "#/components/schemas/SimpleStatus" + "roles": { + "items": { + "type": "string" + }, + "type": "array" }, - "to": { - "$ref": "#/components/schemas/DestinationType", - "description": "The destination network or payment method." + "selectionRequired": { + "type": "boolean" + } + }, + "required": ["activeEntityId", "entities", "roles", "selectionRequired"], + "type": "object" + }, + "OnChainToken": { + "enum": ["USDC", "USDT", "ETH", "USDC.E"], + "type": "string" + }, + "PayloadTooLargeErrorResponse": { + "additionalProperties": false, + "properties": { + "code": { + "const": 413, + "type": "integer" }, - "totalFeeFiat": { + "message": { + "const": "Request body too large", "type": "string" }, - "totalFeeUSD": { - "type": "string" + "statusCode": { + "const": 413, + "type": "integer" }, - "transactionExplorerLink": { - "description": "(BUY-only) A link to a block explorer showing the details for the transaction hash.", + "type": { + "const": "entity.too.large", "type": "string" - }, - "transactionHash": { - "description": "(BUY-only) The hash of the transaction transferring the expected outputAmount to the wallet address. ", + } + }, + "required": ["code", "message", "statusCode", "type"], + "type": "object" + }, + "PaymentData": { + "description": "Data related to the payment for the ramp transaction.", + "properties": { + "amount": { + "description": "The amount for the payment.", + "examples": ["0.05"], "type": "string" }, - "type": { - "$ref": "#/components/schemas/RampDirection", - "description": "Type of ramp process." + "anchorTargetAccount": { + "description": "The target account for an anchor operation.", + "examples": ["GDSDQLBVDD5RZYKNDM2LAX5JDNNQOTSZOKECUYEXYMUZMAPXTMDUJCVF"], + "type": "string" }, - "unsignedTxs": { - "description": "Array of unsigned transactions that need to be signed by the user.", - "items": { - "$ref": "#/components/schemas/UnsignedTx" - }, - "type": "array" + "memo": { + "description": "The memo content.", + "examples": ["1204asjfnaksf10982e4"], + "type": "string" }, - "updatedAt": { - "description": "Timestamp of the last update to the ramp process.", - "format": "date-time", + "memoType": { + "description": "Type of memo (e.g., text, id).", + "examples": ["text"], "type": "string" + } + }, + "type": "object" + }, + "PaymentMethod": { + "description": "`PIX`, `SEPA`, `CBU`", + "type": "string" + }, + "PresignedTx": { + "additionalProperties": true, + "description": "Represents a transaction that has been presigned. Based on UnsignedTx structure.", + "properties": { + "meta": { + "additionalProperties": true, + "description": "Any additional metadata associated with the transaction. Can be an empty object.", + "properties": {}, + "type": "object" }, - "vortexFeeFiat": { + "nonce": { + "description": "Nonce for the transaction, if applicable.", + "format": "int64", + "type": "number" + }, + "phase": { + "description": "The phase this transaction belongs to within the ramp logic.", + "enum": ["RampPhase", "CleanupPhase"], "type": "string" }, - "vortexFeeUSD": { + "signer": { + "description": "Address of the account that signed/will sign this transaction.", "type": "string" }, - "walletAddress": { - "description": "The address of the source account for SELL, or the address the destination account for BUY transactions.", + "txData": { + "description": "The presigned transaction payload or relevant data.", + "examples": ["AAAAAKg..."], "type": "string" } }, - "required": [ - "paymentMethod", - "inputAmount", - "outputAmount", - "inputCurrency", - "outputCurrency", - "networkFeeFiat", - "networkFeeUSD", - "anchorFeeFiat", - "anchorFeeUSD", - "vortexFeeFiat", - "vortexFeeUSD", - "partnerFeeFiat", - "partnerFeeUSD", - "totalFeeFiat", - "totalFeeUSD", - "processingFeeFiat", - "processingFeeUSD", - "feeCurrency" - ], "type": "object" }, - "RegisterRampRequest": { + "QuoteResponse": { "properties": { - "additionalData": { - "additionalProperties": true, - "description": "Optional additional data for the ramp process.\n\nFor Brazil onramps, destinationAddress is required.\n\nFor Brazil offramps, pixDestination is required. The user's taxId is derived from the authenticated account; receiverTaxId is optional and defaults to the user's own tax ID.", - "properties": { - "destinationAddress": { - "description": "Destination address, used for onramp.", - "type": "string" - }, - "moneriumAuthToken": { - "description": "Auth token obtained from Monerium's API, for the current user. Only required for Monerium-related ramps.", - "type": "string" - }, - "paymentData": { - "$ref": "#/components/schemas/PaymentData" - }, - "pixDestination": { - "description": "PIX key for the destination account in an onramp.", - "type": "string" - }, - "receiverTaxId": { - "description": "Tax ID of the receiver for onramp.", - "type": "string" - }, - "taxId": { - "description": "Tax ID of the user.", - "type": "string" - }, - "walletAddress": { - "description": "Wallet address initiating the offramp.", - "type": "string" - } - }, - "required": ["walletAddress", "moneriumAuthToken"], - "type": "object" + "anchorFeeFiat": { + "type": "string" }, - "quoteId": { - "description": "The unique identifier for the quote.", + "anchorFeeUSD": { + "type": "string" + }, + "expiresAt": { + "description": "The timestamp when this quote expires.", + "format": "date-time", + "type": "string" + }, + "feeCurrency": { + "$ref": "#/components/schemas/RampCurrency" + }, + "from": { + "$ref": "#/components/schemas/DestinationType" + }, + "id": { + "description": "Unique identifier for the quote.", "format": "uuid", "type": "string" }, - "signingAccounts": { - "description": "Array of accounts (public addresses) that will be used for signing transactions. Provide one Substrate ephemeral (Pendulum) and one EVM ephemeral; all EVM legs, including Moonbeam, use the EVM account.\n", - "items": { - "properties": { - "address": { - "description": "The account address.", - "type": "string" - }, - "type": { - "description": "The type of the account.", - "enum": ["EVM", "Substrate"], - "type": "string" - } - }, - "required": ["address", "type"], - "type": "object" - }, - "minItems": 1, - "type": "array" + "inputAmount": { + "description": "The input amount specified in the request.", + "type": "string" + }, + "inputCurrency": { + "$ref": "#/components/schemas/RampCurrency" + }, + "networkFeeFiat": { + "type": "string" + }, + "networkFeeUSD": { + "type": "string" + }, + "outputAmount": { + "description": "The calculated output amount after fees and conversions.", + "type": "string" + }, + "outputCurrency": { + "$ref": "#/components/schemas/RampCurrency" + }, + "partnerFeeFiat": { + "type": "string" + }, + "partnerFeeUSD": { + "type": "string" + }, + "processingFeeFiat": { + "type": "string" + }, + "processingFeeUSD": { + "type": "string" + }, + "rampType": { + "$ref": "#/components/schemas/RampDirection", + "description": "The type of ramp process." + }, + "to": { + "$ref": "#/components/schemas/DestinationType" + }, + "totalFeeFiat": { + "type": "string" + }, + "totalFeeUSD": { + "type": "string" + }, + "vortexFeeFiat": { + "type": "string" + }, + "vortexFeeUSD": { + "type": "string" } }, - "required": ["quoteId", "signingAccounts"], + "required": [ + "networkFeeFiat", + "networkFeeUSD", + "anchorFeeFiat", + "anchorFeeUSD", + "vortexFeeFiat", + "vortexFeeUSD", + "partnerFeeFiat", + "partnerFeeUSD", + "totalFeeFiat", + "totalFeeUSD", + "processingFeeFiat", + "processingFeeUSD", + "feeCurrency" + ], "type": "object" }, - "SimpleStatus": { - "description": "`PENDING`, `FAILED`, `COMPLETED`", + "RampCurrency": { + "description": "Represents supported currencies for ramp operations, including fiat and on-chain tokens.", + "enum": ["EUR", "ARS", "BRL", "USD", "MXN", "COP", "USDC", "USDT", "USDC.E"], + "examples": ["USDC"], "type": "string" }, - "StartKYC2Request": { + "RampDirection": { + "enum": ["BUY", "SELL"], + "type": "string" + }, + "RampErrorLog": { "properties": { - "documentType": { - "$ref": "#/components/schemas/KYCDocType" + "details": { + "type": "string" }, - "taxId": { + "error": { + "type": "string" + }, + "phase": { + "$ref": "#/components/schemas/RampPhase" + }, + "recoverable": { + "type": "boolean" + }, + "timestamp": { + "format": "date-time", "type": "string" } }, - "required": ["documentType", "taxId"], - "type": "object" - }, - "StartKYC2Response": { - "properties": { - "uploadUrls": { - "$ref": "#/components/schemas/KYCDataUploadFileFiles" - } - }, + "required": ["timestamp", "phase", "error"], "type": "object" }, - "StartRampRequest": { + "RampInfoResponse": { "properties": { - "rampId": { - "type": "string" + "corridors": { + "additionalProperties": { + "properties": { + "canBuy": { + "type": "boolean" + }, + "canSell": { + "type": "boolean" + }, + "kycStatus": { + "enum": ["not_started", "pending", "approved", "rejected"], + "type": "string" + } + }, + "required": ["kycStatus", "canBuy", "canSell"], + "type": "object" + }, + "description": "Sanitized eligibility keyed by corridor country code. No exact limits, PII, provider IDs, or failure reasons are returned.", + "type": "object" } }, - "required": ["rampId"], + "required": ["corridors"], "type": "object" }, - "TaxIdType": { - "enum": ["CPF", "CNPJ"], + "RampPhase": { + "description": "The current phase of the ramp process.", + "enum": [ + "initial", + "timedOut", + "stellarCreateAccount", + "squidrouterApprove", + "squidrouterSwap", + "fundEphemeral", + "nablaApprove", + "nablaSwap", + "moonbeamToPendulum", + "moonbeamToPendulumXcm", + "pendulumToMoonbeam", + "assethubToPendulum", + "pendulumToAssethub", + "spacewalkRedeem", + "stellarPayment", + "subsidizePreSwap", + "subsidizePostSwap", + "brlaTeleport", + "onHoldForComplianceCheck", + "brlaPayoutOnMoonbeam", + "failed" + ], "type": "string" }, - "TriggerOfframpRequest": { + "RampProcess": { "properties": { - "amount": { - "description": "The amount to offramp.", - "examples": ["100.50"], + "anchorFeeFiat": { + "type": "string" + }, + "anchorFeeUSD": { + "type": "string" + }, + "countryCode": { + "$ref": "#/components/schemas/CountryCode" + }, + "createdAt": { + "description": "Timestamp of when the ramp process was created.", + "format": "date-time", + "type": "string" + }, + "currentPhase": { + "$ref": "#/components/schemas/RampPhase" + }, + "depositQrCode": { + "description": "BR Code for PIX payment, if applicable.", + "type": ["string", "null"] + }, + "feeCurrency": { + "$ref": "#/components/schemas/RampCurrency" + }, + "from": { + "$ref": "#/components/schemas/DestinationType", + "description": "The source network or payment method." + }, + "id": { + "description": "Unique identifier for the ramp process.", + "type": "string" + }, + "inputAmount": { "type": "string" }, - "pixKey": { - "description": "The recipient's PIX key.", - "type": "string" + "inputCurrency": { + "type": "string" + }, + "network": { + "$ref": "#/components/schemas/Networks" + }, + "networkFeeFiat": { + "type": "string" + }, + "networkFeeUSD": { + "type": "string" + }, + "outputAmount": { + "type": "string" + }, + "outputCurrency": { + "type": "string" + }, + "partnerFeeFiat": { + "type": "string" + }, + "partnerFeeUSD": { + "type": "string" + }, + "paymentMethod": { + "$ref": "#/components/schemas/PaymentMethod" + }, + "processingFeeFiat": { + "type": "string" + }, + "processingFeeUSD": { + "type": "string" + }, + "quoteId": { + "description": "The quote ID associated with this ramp process.", + "format": "uuid", + "type": "string" + }, + "sessionId": { + "description": "The `externalSessionId` is an optional URL parameter that integrators can provide to track ramp transactions within their own systems. This identifier allows you to correlate Vortex transactions with your internal session or transaction tracking. `externalSessionId` url param is named `sessionId` in the Vortex API.", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/SimpleStatus" + }, + "to": { + "$ref": "#/components/schemas/DestinationType", + "description": "The destination network or payment method." + }, + "totalFeeFiat": { + "type": "string" + }, + "totalFeeUSD": { + "type": "string" + }, + "transactionExplorerLink": { + "description": "(BUY-only) A link to a block explorer showing the details for the transaction hash.", + "type": "string" + }, + "transactionHash": { + "description": "(BUY-only) The hash of the transaction transferring the expected outputAmount to the wallet address. ", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/RampDirection", + "description": "Type of ramp process." + }, + "unsignedTxs": { + "description": "Array of unsigned transactions that need to be signed by the user.", + "items": { + "$ref": "#/components/schemas/UnsignedTx" + }, + "type": "array" + }, + "updatedAt": { + "description": "Timestamp of the last update to the ramp process.", + "format": "date-time", + "type": "string" + }, + "vortexFeeFiat": { + "type": "string" + }, + "vortexFeeUSD": { + "type": "string" + }, + "walletAddress": { + "description": "The address of the source account for SELL, or the address the destination account for BUY transactions.", + "type": "string" + } + }, + "required": [ + "paymentMethod", + "inputAmount", + "outputAmount", + "inputCurrency", + "outputCurrency", + "networkFeeFiat", + "networkFeeUSD", + "anchorFeeFiat", + "anchorFeeUSD", + "vortexFeeFiat", + "vortexFeeUSD", + "partnerFeeFiat", + "partnerFeeUSD", + "totalFeeFiat", + "totalFeeUSD", + "processingFeeFiat", + "processingFeeUSD", + "feeCurrency" + ], + "type": "object" + }, + "RecordInitialKycAttemptRequest": { + "properties": { + "quoteId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "taxId": { + "type": "string" + } + }, + "required": ["quoteId", "taxId"], + "type": "object" + }, + "RegisterRampRequest": { + "properties": { + "additionalData": { + "additionalProperties": true, + "description": "Optional additional data for the ramp process.\n\nFor Brazil onramps, destinationAddress is required.\n\nFor Brazil offramps, pixDestination is required. The user's taxId is derived from the authenticated account; receiverTaxId is optional and defaults to the user's own tax ID.", + "properties": { + "destinationAddress": { + "description": "Destination address, used for onramp.", + "type": "string" + }, + "moneriumAuthToken": { + "description": "Auth token obtained from Monerium's API, for the current user. Only required for Monerium-related ramps.", + "type": "string" + }, + "paymentData": { + "$ref": "#/components/schemas/PaymentData" + }, + "pixDestination": { + "description": "PIX key for the destination account in an onramp.", + "type": "string" + }, + "receiverTaxId": { + "description": "Tax ID of the receiver for onramp.", + "type": "string" + }, + "taxId": { + "description": "Tax ID of the user.", + "type": "string" + }, + "walletAddress": { + "description": "Wallet address initiating the offramp.", + "type": "string" + } + }, + "required": ["walletAddress", "moneriumAuthToken"], + "type": "object" + }, + "quoteId": { + "description": "The unique identifier for the quote.", + "format": "uuid", + "type": "string" + }, + "signingAccounts": { + "description": "Array of accounts (public addresses) that will be used for signing transactions. Provide one Substrate ephemeral (Pendulum) and one EVM ephemeral; all EVM legs, including Moonbeam, use the EVM account.\n", + "items": { + "properties": { + "address": { + "description": "The account address.", + "type": "string" + }, + "type": { + "description": "The type of the account.", + "enum": ["EVM", "Substrate"], + "type": "string" + } + }, + "required": ["address", "type"], + "type": "object" + }, + "minItems": 1, + "type": "array" + } + }, + "required": ["quoteId", "signingAccounts"], + "type": "object" + }, + "SelectActiveCustomerEntityRequest": { + "additionalProperties": false, + "properties": { + "type": { + "enum": ["individual", "business"], + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + "SelectActiveCustomerEntityResponse": { + "properties": { + "activeEntityId": { + "type": "string" + }, + "type": { + "enum": ["individual", "business"], + "type": "string" + } + }, + "required": ["activeEntityId", "type"], + "type": "object" + }, + "SimpleStatus": { + "description": "`PENDING`, `FAILED`, `COMPLETED`", + "type": "string" + }, + "StartKYC2Request": { + "properties": { + "documentType": { + "$ref": "#/components/schemas/KYCDocType" + }, + "taxId": { + "type": "string" + } + }, + "required": ["documentType", "taxId"], + "type": "object" + }, + "StartKYC2Response": { + "properties": { + "uploadUrls": { + "$ref": "#/components/schemas/KYCDataUploadFileFiles" + } + }, + "type": "object" + }, + "StartRampRequest": { + "properties": { + "rampId": { + "type": "string" + } + }, + "required": ["rampId"], + "type": "object" + }, + "SubmitInformationResponse": { + "properties": { + "submissionId": { + "type": "string" + } + }, + "required": ["submissionId"], + "type": "object" + }, + "SubmitKybInformationRequest": { + "allOf": [ + { + "if": { + "properties": { + "transmitsCustomerFunds": { + "const": true + } + } + }, + "then": { + "required": ["conductsComplianceScreening"] + } + }, + { + "if": { + "properties": { + "conductsComplianceScreening": { + "const": true + } + }, + "required": ["conductsComplianceScreening"] + }, + "then": { + "required": ["complianceScreeningDescription"] + } + } + ], + "properties": { + "accountPurpose": { + "type": "string" + }, + "address": { + "type": "string" + }, + "businessActivities": { + "type": "string" + }, + "businessName": { + "type": "string" + }, + "city": { + "type": "string" + }, + "complianceScreeningDescription": { + "type": "string" + }, + "conductsComplianceScreening": { + "type": "boolean" + }, + "country": { + "enum": ["CO", "MX"], + "type": "string" + }, + "expectedMonthlyTransactions": { + "minimum": 0, + "type": "integer" + }, + "expectedMonthlyVolumeUsd": { + "minimum": 0, + "type": "number" + }, + "isRegulatedBusiness": { + "type": "boolean" + }, + "operatesInSanctionedCountries": { + "type": "boolean" + }, + "relatedPersons": { + "items": { + "$ref": "#/components/schemas/AlfredpayKybRelatedPerson" + }, + "minItems": 1, + "type": "array" + }, + "sourceOfFunds": { + "type": "string" + }, + "state": { + "type": "string" + }, + "taxId": { + "type": "string" + }, + "transmitsCustomerFunds": { + "type": "boolean" + }, + "walletAddresses": { + "type": "string" + }, + "website": { + "format": "uri", + "type": "string" + }, + "zipCode": { + "type": "string" + } + }, + "required": [ + "businessName", + "taxId", + "country", + "address", + "state", + "city", + "zipCode", + "website", + "relatedPersons", + "walletAddresses", + "sourceOfFunds", + "transmitsCustomerFunds", + "operatesInSanctionedCountries", + "isRegulatedBusiness", + "businessActivities", + "accountPurpose", + "expectedMonthlyVolumeUsd", + "expectedMonthlyTransactions" + ], + "type": "object" + }, + "SubmitKycInformationRequest": { + "oneOf": [ + { + "properties": { + "country": { + "const": "MX" + } + }, + "required": ["email"] + }, + { + "properties": { + "country": { + "const": "CO" + } + }, + "required": ["typeDocumentCol", "phoneNumber"] + }, + { + "properties": { + "country": { + "const": "AR" + } + }, + "required": ["email", "phoneNumber", "countryCode", "nationalities", "typeDocumentAr", "pep"] + } + ], + "properties": { + "address": { + "type": "string" + }, + "city": { + "type": "string" + }, + "country": { + "enum": ["AR", "CO", "MX"], + "type": "string" + }, + "countryCode": { + "type": "string" + }, + "cuit": { + "pattern": "^[0-9]{11}$", + "type": "string" + }, + "dateOfBirth": { + "format": "date", + "type": "string" + }, + "dni": { + "type": "string" + }, + "email": { + "format": "email", + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "nationalities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "pep": { + "type": "boolean" + }, + "phoneNumber": { + "type": "string" + }, + "state": { + "type": "string" + }, + "typeDocument": { + "type": "string" + }, + "typeDocumentAr": { + "enum": ["DNI"], + "type": "string" + }, + "typeDocumentCol": { + "enum": ["CC", "CE"], + "type": "string" + }, + "zipCode": { + "type": "string" + } + }, + "required": ["firstName", "lastName", "dateOfBirth", "country", "city", "state", "zipCode", "address", "dni"], + "type": "object" + }, + "SuccessResponse": { + "properties": { + "success": { + "const": true, + "type": "boolean" + } + }, + "required": ["success"], + "type": "object" + }, + "TaxIdType": { + "enum": ["CPF", "CNPJ"], + "type": "string" + }, + "TriggerOfframpRequest": { + "properties": { + "amount": { + "description": "The amount to offramp.", + "examples": ["100.50"], + "type": "string" + }, + "pixKey": { + "description": "The recipient's PIX key.", + "type": "string" + }, + "receiverTaxId": { + "description": "The recipient's Tax ID for validation.", + "type": "string" + }, + "taxId": { + "description": "The sender's Tax ID.", + "type": "string" + } + }, + "required": ["taxId", "pixKey", "amount", "receiverTaxId"], + "type": "object" + }, + "TriggerOfframpResponse": { + "properties": { + "offrampId": { + "description": "The ID of the triggered offramp transaction.", + "type": "string" + } + }, + "type": "object" + }, + "UnsignedTx": { + "additionalProperties": true, + "description": "Represents an unsigned transaction that requires user signature. Actual properties will depend on the transaction type and network.", + "properties": { + "meta": { + "properties": {}, + "type": "object" + }, + "nonce": { + "type": "number" + }, + "phase": { + "enum": ["RampPhase", "CleanupPhase"], + "type": "string" + }, + "signer": { + "type": "string" + }, + "txData": { + "description": "The unsigned transaction payload or relevant data.", + "examples": ["AAAAAKu..."], + "type": "string" + } + }, + "type": "object" + }, + "UpdateRampRequest": { + "properties": { + "additionalData": { + "additionalProperties": false, + "description": "Optional client-reported transaction hashes used to continue the ramp.", + "properties": { + "assethubToPendulumHash": { + "description": "Transaction hash for AssetHub to Pendulum transfer, if applicable.", + "type": ["string", "null"] + }, + "squidRouterApproveHash": { + "description": "Transaction hash for Squid Router approval. Optional: omit when the wallet already holds a sufficient allowance and no approval transaction was submitted.", + "type": ["string", "null"] + }, + "squidRouterNoPermitApproveHash": { + "description": "Transaction hash for Squid Router no-permit approval, if applicable.", + "type": ["string", "null"] + }, + "squidRouterNoPermitSwapHash": { + "description": "Transaction hash for Squid Router no-permit swap, if applicable.", + "type": ["string", "null"] + }, + "squidRouterNoPermitTransferHash": { + "description": "Transaction hash for Squid Router no-permit transfer, if applicable.", + "type": ["string", "null"] + }, + "squidRouterSwapHash": { + "description": "Transaction hash for Squid Router swap, if applicable.", + "type": ["string", "null"] + } + }, + "type": ["object", "null"] + }, + "presignedTxs": { + "description": "An array of transactions that have been pre-signed by the user.", + "items": { + "$ref": "#/components/schemas/PresignedTx" + }, + "type": "array" + }, + "rampId": { + "description": "The unique identifier of the ramp process to start.", + "examples": ["proc_12345"], + "type": "string" + } + }, + "required": ["rampId", "presignedTxs"], + "type": "object" + }, + "UserLimit": { + "properties": { + "corridor": { + "enum": ["AR", "BR", "CO", "MX", "US"], + "type": "string" + }, + "currency": { + "$ref": "#/components/schemas/RampCurrency" + }, + "direction": { + "$ref": "#/components/schemas/RampDirection" + }, + "max": { + "description": "Maximum amount in the returned currency's human units.", + "type": "string" + }, + "period": { + "$ref": "#/components/schemas/UserLimitPeriod" + }, + "used": { + "description": "Amount consumed during the period in the returned currency's human units.", + "type": "string" + } + }, + "required": ["corridor", "currency", "direction", "max", "period", "used"], + "type": "object" + }, + "UserLimitPeriod": { + "properties": { + "endsAt": { + "description": "Exclusive end of the reported period.", + "format": "date-time", + "type": "string" + }, + "startsAt": { + "format": "date-time", + "type": "string" + }, + "type": { + "const": "calendar_month", + "type": "string" + } + }, + "required": ["type", "startsAt", "endsAt"], + "type": "object" + }, + "ValidatePixKeyResponse": { + "properties": { + "valid": { + "description": "Indicates if the PIX key is valid.", + "type": "boolean" + } + }, + "type": "object" + } + }, + "securitySchemes": { + "BearerAuth": { + "bearerFormat": "Supabase JWT", + "scheme": "bearer", + "type": "http" + }, + "PublicApiKey": { + "description": "Public credential value (`pk_live_*` or `pk_test_*`) for attribution and approved low-sensitivity reads.", + "in": "header", + "name": "X-Public-Key", + "type": "apiKey" + }, + "SecretApiKey": { + "description": "Server-side secret credential value (`sk_live_*` or `sk_test_*`).", + "in": "header", + "name": "X-API-Key", + "type": "apiKey" + } + } + }, + "info": { + "description": "Cross-border payments gateway built on the Pendulum blockchain.\n\n**API credentials:** one credential contains a public (`pk_*`) and secret (`sk_*`) value for one profile subject. Send public values through `X-Public-Key` and server-side secret values through `X-API-Key`. If both are sent, they must belong to the same credential or the request returns `403 CREDENTIAL_MISMATCH`. Public capability is limited to attribution and explicitly sanitized reads; secret capability is required for sensitive and state-changing partner operations.\n\n`Authorization: Bearer ` represents a first-party user session. It is required for a profile's own credential lifecycle and is accepted as an alternative to a manager secret key for managed-profile lifecycle operations.\n\n**Webhook signing:** RSA-PSS 2048 / SHA-256. Fetch the signing key from `GET /v1/public-key`.\n", + "title": "Vortex API", + "version": "1.1.0" + }, + "openapi": "3.1.0", + "paths": { + "/v1/alfredpay/alfredpayStatus": { + "get": { + "description": "Returns the local Alfredpay onboarding state after refreshing the latest provider submission when available.", + "operationId": "getAlfredpayStatus", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "in": "query", + "name": "country", + "required": true, + "schema": { + "$ref": "#/components/schemas/AlfredpayCountry" + } + }, + { + "description": "Selects the individual or business customer. When omitted, the active customer entity is used for backward compatibility.", + "in": "query", + "name": "type", + "required": false, + "schema": { + "$ref": "#/components/schemas/AlfredpayCustomerType" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayStatusResponse" + } + } + }, + "description": "Customer status returned." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } + }, + "description": "Invalid or missing country, invalid customer type, invalid selector UUID, or managed-profile customer-type mismatch." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Alfredpay customer not found." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Status refresh failed." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Get Alfredpay customer status", + "tags": ["KYC and KYB", "Account Management"] + } + }, + "/v1/alfredpay/createBusinessCustomer": { + "post": { + "description": "Creates a business Alfredpay customer for the effective profile. Managed profiles use their immutable contact email.", + "operationId": "createAlfredpayBusinessCustomer", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayCreateCustomerRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayCreateCustomerResponse" + } + } + }, + "description": "Business customer created." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } + }, + "description": "Invalid country, unavailable email, existing customer, invalid selector UUID, or managed-profile customer-type mismatch." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed profile or corridor is not authorized." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "An upstream customer exists with a conflicting country or type." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Customer creation failed." + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Alfredpay returned an invalid existing-customer response." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Create a business Alfredpay customer", + "tags": ["KYC and KYB", "Account Management"] + } + }, + "/v1/alfredpay/createIndividualCustomer": { + "post": { + "description": "Creates an individual Alfredpay customer for the effective profile. Managed profiles use their immutable contact email.", + "operationId": "createAlfredpayIndividualCustomer", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayCreateCustomerRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayCreateCustomerResponse" + } + } + }, + "description": "Customer created." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } + }, + "description": "Invalid country, unavailable email, existing customer, invalid selector UUID, or managed-profile customer-type mismatch." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed profile or corridor is not authorized." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "An upstream customer exists with a conflicting country or type." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Customer creation failed." + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Alfredpay returned an invalid existing-customer response." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Create an individual Alfredpay customer", + "tags": ["KYC and KYB", "Account Management"] + } + }, + "/v1/alfredpay/fiatAccounts": { + "get": { + "description": "Lists payout fiat accounts for the effective Alfredpay customer.", + "operationId": "listAlfredpayFiatAccounts", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "in": "query", + "name": "country", + "required": true, + "schema": { + "$ref": "#/components/schemas/AlfredpayCountry" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AlfredpayFiatAccount" + }, + "type": "array" + } + } + }, + "description": "Fiat accounts returned." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } + }, + "description": "Invalid country, credential without an effective user, invalid selector UUID, or managed-profile customer-type mismatch." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized" + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Alfredpay customer not found." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Account lookup failed." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "List Alfredpay fiat accounts", + "tags": ["Account Management"] + }, + "post": { + "description": "Creates a payout fiat account for the effective Alfredpay customer. Required optional fields depend on the selected account type and corridor.", + "operationId": "createAlfredpayFiatAccount", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayAddFiatAccountRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayCreateFiatAccountResponse" + } + } + }, + "description": "Fiat account created." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } + }, + "description": "Invalid country, account details, credential without an effective user, invalid selector UUID, or managed-profile customer-type mismatch." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized" + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Alfredpay customer not found." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Account creation failed." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Create an Alfredpay fiat account", + "tags": ["Account Management"] + } + }, + "/v1/alfredpay/fiatAccounts/{fiatAccountId}": { + "delete": { + "description": "Deletes one payout fiat account belonging to the effective Alfredpay customer.", + "operationId": "deleteAlfredpayFiatAccount", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "in": "path", + "name": "fiatAccountId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "country", + "required": true, + "schema": { + "$ref": "#/components/schemas/AlfredpayCountry" + } + } + ], + "responses": { + "204": { + "description": "Fiat account deleted." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } + }, + "description": "Invalid country, provider rejection, credential without an effective user, invalid selector UUID, or managed-profile customer-type mismatch." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized" + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Alfredpay customer not found." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Account deletion failed." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Delete an Alfredpay fiat account", + "tags": ["Account Management"] + } + }, + "/v1/alfredpay/findKybCustomerAndBusiness": { + "get": { + "description": "Returns only KYB submission IDs and related-person IDs needed for document uploads.", + "operationId": "findAlfredpayKybCustomerAndBusiness", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "in": "query", + "name": "country", + "required": true, + "schema": { + "enum": ["CO", "MX"], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayKybDetailsResponse", + "items": { + "$ref": "#/components/schemas/AlfredpayKybBusinessSummary" + }, + "type": "array" + } + } + }, + "description": "KYB submission and related-person IDs returned." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } + }, + "description": "Invalid country, invalid selector UUID, or managed-profile customer-type mismatch." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Alfredpay business customer not found." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Lookup failed." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Find Alfredpay KYB submission details", + "tags": ["KYC and KYB", "Account Management"] + } + }, + "/v1/alfredpay/getKybRedirectLink": { + "get": { + "description": "Creates a hosted business KYB redirect link when no verification is already in review or complete.", + "operationId": "getAlfredpayKybRedirectLink", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "in": "query", + "name": "country", + "required": true, + "schema": { + "$ref": "#/components/schemas/AlfredpayCountry" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayRedirectLinkResponse" + } + } + }, + "description": "KYB redirect link returned." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } + }, + "description": "Invalid country, KYB already verifying or complete, invalid selector UUID, or managed-profile customer-type mismatch." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Alfredpay business customer not found." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Redirect-link creation failed." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Get an Alfredpay KYB redirect link", + "tags": ["KYC and KYB", "Account Management"] + } + }, + "/v1/alfredpay/getKycRedirectLink": { + "get": { + "description": "Creates a hosted individual KYC redirect link when no verification is already in review or complete.", + "operationId": "getAlfredpayKycRedirectLink", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "in": "query", + "name": "country", + "required": true, + "schema": { + "$ref": "#/components/schemas/AlfredpayCountry" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayRedirectLinkResponse" + } + } + }, + "description": "KYC redirect link returned." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } + }, + "description": "Invalid country, KYC already verifying or complete, invalid selector UUID, or managed-profile customer-type mismatch." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Alfredpay customer not found." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Redirect-link creation failed." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Get an Alfredpay KYC redirect link", + "tags": ["KYC and KYB", "Account Management"] + } + }, + "/v1/alfredpay/getKycStatus": { + "get": { + "description": "Returns and persists the latest KYC or KYB submission status. Omit `type` for individual KYC.", + "operationId": "getAlfredpayKycStatus", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "in": "query", + "name": "country", + "required": true, + "schema": { + "$ref": "#/components/schemas/AlfredpayCountry" + } + }, + { + "in": "query", + "name": "type", + "required": false, + "schema": { + "$ref": "#/components/schemas/AlfredpayCustomerType" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayKycStatusResponse" + } + } + }, + "description": "Verification status returned." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } + }, + "description": "Invalid country, invalid selector UUID, or managed-profile customer-type mismatch." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Customer or verification attempt not found." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Status lookup failed." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Get Alfredpay KYC or KYB status", + "tags": ["KYC and KYB", "Account Management"] + } + }, + "/v1/alfredpay/kycRedirectFinished": { + "post": { + "description": "Records that the effective customer finished the hosted KYC or KYB redirect flow.", + "operationId": "notifyAlfredpayKycRedirectFinished", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayRedirectNotificationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + }, + "description": "Redirect state recorded." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } + }, + "description": "Invalid country, invalid selector UUID, or managed-profile customer-type mismatch." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Alfredpay customer not found." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "State update failed." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Mark an Alfredpay redirect finished", + "tags": ["KYC and KYB", "Account Management"] + } + }, + "/v1/alfredpay/kycRedirectOpened": { + "post": { + "description": "Records that the effective customer's hosted KYC or KYB redirect was opened.", + "operationId": "notifyAlfredpayKycRedirectOpened", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayRedirectNotificationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + }, + "description": "Redirect state recorded." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } + }, + "description": "Invalid country, invalid selector UUID, or managed-profile customer-type mismatch." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Alfredpay customer not found." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "State update failed." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Mark an Alfredpay redirect opened", + "tags": ["KYC and KYB", "Account Management"] + } + }, + "/v1/alfredpay/retryKyc": { + "post": { + "description": "Retries a failed KYC or KYB submission. Hosted flows return a redirect link; API-based MX, CO, and AR individual KYC returns `{ success: true }`.", + "operationId": "retryAlfredpayKyc", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayRetryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayRetryResponse", + "oneOf": [ + { + "$ref": "#/components/schemas/AlfredpayRedirectLinkResponse" + }, + { + "$ref": "#/components/schemas/AlfredpaySuccessResponse" + } + ] + } + } + }, + "description": "Retry initialized." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } + }, + "description": "No failed submission is available, the selector UUID is invalid, or the managed-profile customer type mismatches." }, - "receiverTaxId": { - "description": "The recipient's Tax ID for validation.", - "type": "string" + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." }, - "taxId": { - "description": "The sender's Tax ID.", - "type": "string" + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Alfredpay customer not found." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Retry failed." } }, - "required": ["taxId", "pixKey", "amount", "receiverTaxId"], - "type": "object" - }, - "TriggerOfframpResponse": { - "properties": { - "offrampId": { - "description": "The ID of the triggered offramp transaction.", - "type": "string" + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Retry Alfredpay KYC or KYB", + "tags": ["KYC and KYB", "Account Management"] + } + }, + "/v1/alfredpay/sendKybSubmission": { + "post": { + "description": "Finalizes an API-based business KYB submission.", + "operationId": "sendAlfredpayKybSubmission", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpaySendSubmissionRequest" + } + } + }, + "required": true }, - "type": "object" - }, - "UnsignedTx": { - "additionalProperties": true, - "description": "Represents an unsigned transaction that requires user signature. Actual properties will depend on the transaction type and network.", - "properties": { - "meta": { - "properties": {}, - "type": "object" + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + }, + "description": "KYB submission sent." }, - "nonce": { - "type": "number" + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } + }, + "description": "Invalid country, invalid selector UUID, or managed-profile customer-type mismatch." }, - "phase": { - "enum": ["RampPhase", "CleanupPhase"], - "type": "string" + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." }, - "signer": { - "type": "string" + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed profile or corridor is not authorized." }, - "txData": { - "description": "The unsigned transaction payload or relevant data.", - "examples": ["AAAAAKu..."], - "type": "string" - } - }, - "type": "object" - }, - "UpdateRampRequest": { - "properties": { - "additionalData": { - "additionalProperties": true, - "description": "Optional additional data, like transaction hashes from external services.", - "properties": { - "assetHubToPendulumHash": { - "description": "Transaction hash for AssetHub to Pendulum transfer, if applicable.", - "type": ["string", "null"] - }, - "moneriumOfframpSignature": { - "description": "Signed message to trigger a Monerium offramp.\n", - "type": "string" - }, - "squidRouterApproveHash": { - "description": "Transaction hash for Squid Router approval. Optional: omit when the wallet already holds a sufficient allowance and no approval transaction was submitted.", - "type": ["string", "null"] - }, - "squidRouterSwapHash": { - "description": "Transaction hash for Squid Router swap, if applicable.", - "type": ["string", "null"] + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } } }, - "required": ["moneriumOfframpSignature"], - "type": ["object", "null"] + "description": "Alfredpay business customer not found." }, - "presignedTxs": { - "description": "An array of transactions that have been pre-signed by the user.", - "items": { - "$ref": "#/components/schemas/PresignedTx" + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } }, - "type": "array" - }, - "rampId": { - "description": "The unique identifier of the ramp process to start.", - "examples": ["proc_12345"], - "type": "string" + "description": "Submission finalization failed." } }, - "required": ["rampId", "presignedTxs"], - "type": "object" - }, - "UserLimit": { - "properties": { - "corridor": { - "enum": ["AR", "BR", "CO", "MX", "US"], - "type": "string" - }, - "currency": { - "$ref": "#/components/schemas/RampCurrency" + "security": [ + { + "SecretApiKey": [] }, - "direction": { - "$ref": "#/components/schemas/RampDirection" + { + "BearerAuth": [] + } + ], + "summary": "Send an Alfredpay KYB submission", + "tags": ["KYC and KYB", "Account Management"] + } + }, + "/v1/alfredpay/sendKycSubmission": { + "post": { + "description": "Finalizes an API-based individual KYC submission.", + "operationId": "sendAlfredpayKycSubmission", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpaySendSubmissionRequest" + } + } }, - "max": { - "description": "Maximum amount in the returned currency's human units.", - "type": "string" + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + }, + "description": "KYC submission sent." }, - "period": { - "$ref": "#/components/schemas/UserLimitPeriod" + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } + }, + "description": "Invalid country, invalid selector UUID, or managed-profile customer-type mismatch." }, - "used": { - "description": "Amount consumed during the period in the returned currency's human units.", - "type": "string" - } - }, - "required": ["corridor", "currency", "direction", "max", "period", "used"], - "type": "object" - }, - "UserLimitPeriod": { - "properties": { - "endsAt": { - "description": "Exclusive end of the reported period.", - "format": "date-time", - "type": "string" + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." }, - "startsAt": { - "format": "date-time", - "type": "string" + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed profile or corridor is not authorized." }, - "type": { - "const": "calendar_month", - "type": "string" + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Alfredpay customer not found." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Submission finalization failed." } }, - "required": ["type", "startsAt", "endsAt"], - "type": "object" - }, - "ValidatePixKeyResponse": { - "properties": { - "valid": { - "description": "Indicates if the PIX key is valid.", - "type": "boolean" + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] } - }, - "type": "object" + ], + "summary": "Send an Alfredpay KYC submission", + "tags": ["KYC and KYB", "Account Management"] } }, - "securitySchemes": { - "BearerAuth": { - "bearerFormat": "Supabase JWT", - "scheme": "bearer", - "type": "http" - }, - "PublicApiKey": { - "description": "Public credential value (`pk_live_*` or `pk_test_*`) for attribution and approved low-sensitivity reads.", - "in": "header", - "name": "X-Public-Key", - "type": "apiKey" - }, - "SecretApiKey": { - "description": "Server-side secret credential value (`sk_live_*` or `sk_test_*`).", - "in": "header", - "name": "X-API-Key", - "type": "apiKey" - } - } - }, - "info": { - "description": "Cross-border payments gateway built on the Pendulum blockchain.\n\n**API credentials:** one credential contains a public (`pk_*`) and secret (`sk_*`) value for one profile subject. Send public values through `X-Public-Key` and server-side secret values through `X-API-Key`. If both are sent, they must belong to the same credential or the request returns `403 CREDENTIAL_MISMATCH`. Public capability is limited to attribution and explicitly sanitized reads; secret capability is required for sensitive and state-changing partner operations.\n\n`Authorization: Bearer ` represents a first-party user session. It is required for a profile's own credential lifecycle and is accepted as an alternative to a manager secret key for managed-profile lifecycle operations.\n\n**Webhook signing:** RSA-PSS 2048 / SHA-256. Fetch the signing key from `GET /v1/public-key`.\n", - "title": "Vortex API", - "version": "1.1.0" - }, - "openapi": "3.1.0", - "paths": { - "/v1/alfredpay/alfredpayStatus": { - "get": { - "description": "Returns the local Alfredpay onboarding state after refreshing the latest provider submission when available.", - "operationId": "getAlfredpayStatus", + "/v1/alfredpay/submitKybFile": { + "post": { + "description": "Uploads one business KYB document. Files are buffered in memory and limited to 5 MiB.", + "operationId": "submitAlfredpayKybFile", "parameters": [ - { "$ref": "#/components/parameters/ManagedProfileId" }, - { "in": "query", "name": "country", "required": true, "schema": { "$ref": "#/components/schemas/AlfredpayCountry" } } + { + "$ref": "#/components/parameters/ManagedProfileId" + } ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AlfredpayKybFileUploadRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayStatusResponse" } } }, - "description": "Customer status returned." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + }, + "description": "File uploaded." }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } }, - "description": "Invalid or missing country, invalid selector UUID, or managed-profile customer-type mismatch." + "description": "Invalid country, missing file, invalid selector UUID, or managed-profile customer-type mismatch." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed profile or corridor is not authorized." }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Alfredpay customer not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Alfredpay business customer not found." }, "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Status refresh failed." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Upload failed." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Get Alfredpay customer status", - "tags": ["Account Management"] + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Upload an Alfredpay KYB file", + "tags": ["KYC and KYB", "Account Management"] } }, - "/v1/alfredpay/createBusinessCustomer": { + "/v1/alfredpay/submitKybInformation": { "post": { - "description": "Creates a business Alfredpay customer for the effective profile. Managed profiles use their immutable contact email.", - "operationId": "createAlfredpayBusinessCustomer", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "description": "Creates or updates an API-based business KYB submission, including Alfredpay's compliance questionnaire.", + "operationId": "submitAlfredpayKybInformation", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], "requestBody": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayCountryRequest" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitKybInformationRequest" + } + } + }, "required": true }, "responses": { "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayCreateCustomerResponse" } } }, - "description": "Business customer created." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitInformationResponse" + } + } + }, + "description": "KYB information accepted." }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayValidationBadRequestResponse" + } + } }, - "description": "Invalid country, unavailable email, existing customer, invalid selector UUID, or managed-profile customer-type mismatch." + "description": "Invalid country, company data, questionnaire, selector UUID, or managed-profile customer type." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed profile or corridor is not authorized." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Alfredpay business customer not found." }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, "409": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "An upstream customer exists with a conflicting country or type." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "KYB is already in review or complete." }, "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Customer creation failed." - }, - "502": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Alfredpay returned an invalid existing-customer response." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Submission failed." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Create a business Alfredpay customer", - "tags": ["Account Management"] + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Submit Alfredpay KYB information", + "tags": ["KYC and KYB", "Account Management"] } }, - "/v1/alfredpay/createIndividualCustomer": { + "/v1/alfredpay/submitKybRelatedPersonFile": { "post": { - "description": "Creates an individual Alfredpay customer for the effective profile. Managed profiles use their immutable contact email.", - "operationId": "createAlfredpayIndividualCustomer", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "description": "Uploads the front or back identity document for one KYB related person. Files are limited to 5 MiB.", + "operationId": "submitAlfredpayKybRelatedPersonFile", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], "requestBody": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayCountryRequest" } } }, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AlfredpayKybRelatedPersonFileUploadRequest" + } + } + }, "required": true }, "responses": { "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayCreateCustomerResponse" } } }, - "description": "Customer created." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + }, + "description": "File uploaded." }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } }, - "description": "Invalid country, unavailable email, existing customer, invalid selector UUID, or managed-profile customer-type mismatch." + "description": "Invalid country, missing file, invalid selector UUID, or managed-profile customer-type mismatch." }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, - "409": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "An upstream customer exists with a conflicting country or type." + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." }, - "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Customer creation failed." + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed profile or corridor is not authorized." }, - "502": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Alfredpay returned an invalid existing-customer response." + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Alfredpay business customer not found." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Upload failed." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Create an individual Alfredpay customer", - "tags": ["Account Management"] + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Upload a related-person KYB file", + "tags": ["KYC and KYB", "Account Management"] } }, - "/v1/alfredpay/fiatAccounts": { - "get": { - "description": "Lists payout fiat accounts for the effective Alfredpay customer.", - "operationId": "listAlfredpayFiatAccounts", + "/v1/alfredpay/submitKycFile": { + "post": { + "description": "Uploads one individual KYC document. Files are buffered in memory and limited to 5 MiB.", + "operationId": "submitAlfredpayKycFile", "parameters": [ - { "$ref": "#/components/parameters/ManagedProfileId" }, - { "in": "query", "name": "country", "required": true, "schema": { "$ref": "#/components/schemas/AlfredpayCountry" } } + { + "$ref": "#/components/parameters/ManagedProfileId" + } ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AlfredpayKycFileUploadRequest" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { - "schema": { "items": { "$ref": "#/components/schemas/AlfredpayFiatAccount" }, "type": "array" } + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } } }, - "description": "Fiat accounts returned." + "description": "File uploaded." }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" + } + } }, - "description": "Invalid country, credential without an effective user, invalid selector UUID, or managed-profile customer-type mismatch." + "description": "Invalid country, missing file, invalid selector UUID, or managed-profile customer-type mismatch." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed profile or corridor is not authorized." }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, "description": "Alfredpay customer not found." }, "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Account lookup failed." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Upload failed." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "List Alfredpay fiat accounts", - "tags": ["Account Management"] - }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Upload an Alfredpay KYC file", + "tags": ["KYC and KYB", "Account Management"] + } + }, + "/v1/alfredpay/submitKycInformation": { "post": { - "description": "Creates a payout fiat account for the effective Alfredpay customer. Required optional fields depend on the selected account type and corridor.", - "operationId": "createAlfredpayFiatAccount", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "description": "Creates or resumes an API-based individual KYC submission.", + "operationId": "submitAlfredpayKycInformation", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], "requestBody": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayAddFiatAccountRequest" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitKycInformationRequest" + } + } + }, "required": true }, "responses": { "200": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayCreateFiatAccountResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitInformationResponse" + } + } }, - "description": "Fiat account created." + "description": "KYC information accepted." }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayValidationBadRequestResponse" + } + } }, - "description": "Invalid country, account details, credential without an effective user, invalid selector UUID, or managed-profile customer-type mismatch." + "description": "Invalid country, KYC fields, selector UUID, or managed-profile customer type." }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, - "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Alfredpay customer not found." + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." }, - "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Account creation failed." - } - }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Create an Alfredpay fiat account", - "tags": ["Account Management"] - } - }, - "/v1/alfredpay/fiatAccounts/{fiatAccountId}": { - "delete": { - "description": "Deletes one payout fiat account belonging to the effective Alfredpay customer.", - "operationId": "deleteAlfredpayFiatAccount", - "parameters": [ - { "$ref": "#/components/parameters/ManagedProfileId" }, - { "in": "path", "name": "fiatAccountId", "required": true, "schema": { "type": "string" } }, - { "in": "query", "name": "country", "required": true, "schema": { "$ref": "#/components/schemas/AlfredpayCountry" } } - ], - "responses": { - "204": { "description": "Fiat account deleted." }, - "400": { - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } - }, - "description": "Invalid country, provider rejection, credential without an effective user, invalid selector UUID, or managed-profile customer-type mismatch." + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed profile or corridor is not authorized." }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, "description": "Alfredpay customer not found." }, "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Account deletion failed." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlfredpayErrorResponse" + } + } + }, + "description": "Submission failed." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Delete an Alfredpay fiat account", - "tags": ["Account Management"] + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Submit Alfredpay KYC information", + "tags": ["KYC and KYB", "Account Management"] } }, - "/v1/alfredpay/findKybCustomerAndBusiness": { + "/v1/api-credentials": { "get": { - "description": "Returns only KYB submission IDs and related-person IDs needed for document uploads.", - "operationId": "findAlfredpayKybCustomerAndBusiness", - "parameters": [ - { "$ref": "#/components/parameters/ManagedProfileId" }, - { "in": "query", "name": "country", "required": true, "schema": { "$ref": "#/components/schemas/AlfredpayCountry" } } - ], + "deprecated": false, + "description": "Lists all profile-managed credentials owned by the authenticated profile, newest first. Each item represents one public/secret credential. Public values and safe secret prefixes are included; secret values are never returned.\n\n**Auth:** Supabase Bearer session only.", + "operationId": "listApiCredentials", + "parameters": [], "responses": { "200": { "content": { "application/json": { - "schema": { "items": { "$ref": "#/components/schemas/AlfredpayKybBusinessSummary" }, "type": "array" } + "schema": { + "$ref": "#/components/schemas/ListApiCredentialsResponse" + } } }, - "description": "KYB submission and related-person IDs returned." + "description": "Credentials, newest first, including revoked and expired lifecycle records.", + "headers": {} }, - "400": { + "401": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiCredentialErrorResponse" + } + } }, - "description": "Invalid country, invalid selector UUID, or managed-profile customer-type mismatch." - }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, - "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Alfredpay business customer not found." + "description": "Missing or invalid Bearer token.", + "headers": {} }, "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Lookup failed." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiCredentialErrorResponse" + } + } + }, + "description": "Internal server error.", + "headers": {} } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Find Alfredpay KYB submission details", - "tags": ["Account Management"] - } - }, - "/v1/alfredpay/getKybRedirectLink": { - "get": { - "description": "Creates a hosted business KYB redirect link when no verification is already in review or complete.", - "operationId": "getAlfredpayKybRedirectLink", - "parameters": [ - { "$ref": "#/components/parameters/ManagedProfileId" }, - { "in": "query", "name": "country", "required": true, "schema": { "$ref": "#/components/schemas/AlfredpayCountry" } } + "security": [ + { + "BearerAuth": [] + } ], + "summary": "List API credentials", + "tags": ["Authentication"] + }, + "post": { + "deprecated": false, + "description": "Creates one credential row containing a public value and a hashed secret value for the authenticated profile. The secret is returned only in this response. Expiry defaults to one year and cannot exceed two years. At most five non-revoked, non-expired credentials may exist per profile.\n\n**Auth:** Supabase Bearer session only.", + "operationId": "createApiCredential", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateApiCredentialRequest" + } + } + }, + "required": false + }, "responses": { - "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayRedirectLinkResponse" } } }, - "description": "KYB redirect link returned." + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateApiCredentialResponse" + } + } + }, + "description": "Credential created. Persist `secretKey` immediately; it cannot be retrieved again.", + "headers": {} }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiCredentialErrorResponse" + } + } }, - "description": "Invalid country, KYB already verifying or complete, invalid selector UUID, or managed-profile customer-type mismatch." - }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, - "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Alfredpay business customer not found." - }, - "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Redirect-link creation failed." - } - }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Get an Alfredpay KYB redirect link", - "tags": ["Account Management"] - } - }, - "/v1/alfredpay/getKycRedirectLink": { - "get": { - "description": "Creates a hosted individual KYC redirect link when no verification is already in review or complete.", - "operationId": "getAlfredpayKycRedirectLink", - "parameters": [ - { "$ref": "#/components/parameters/ManagedProfileId" }, - { "in": "query", "name": "country", "required": true, "schema": { "$ref": "#/components/schemas/AlfredpayCountry" } } - ], - "responses": { - "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayRedirectLinkResponse" } } }, - "description": "KYC redirect link returned." + "description": "`INVALID_CREDENTIAL_EXPIRY` or `INVALID_CREDENTIAL_NAME`.", + "headers": {} }, - "400": { + "401": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiCredentialErrorResponse" + } + } }, - "description": "Invalid country, KYC already verifying or complete, invalid selector UUID, or managed-profile customer-type mismatch." + "description": "Missing or invalid Bearer token.", + "headers": {} }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, - "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Alfredpay customer not found." + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiCredentialErrorResponse" + } + } + }, + "description": "`CREDENTIAL_LIMIT_REACHED`: the profile already holds five active non-expired credentials.", + "headers": {} }, "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Redirect-link creation failed." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiCredentialErrorResponse" + } + } + }, + "description": "Internal server error.", + "headers": {} } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Get an Alfredpay KYC redirect link", - "tags": ["Account Management"] + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Create an API credential", + "tags": ["Authentication"] } }, - "/v1/alfredpay/getKycStatus": { - "get": { - "description": "Returns and persists the latest KYC or KYB submission status. Omit `type` for individual KYC.", - "operationId": "getAlfredpayKycStatus", + "/v1/api-credentials/{credentialId}": { + "delete": { + "deprecated": false, + "description": "Sets `revokedAt` on one profile-managed credential owned by the authenticated profile, atomically disabling its public and secret values. No request body or paired key ID is accepted.\n\n**Auth:** Supabase Bearer session only.", + "operationId": "revokeApiCredential", "parameters": [ - { "$ref": "#/components/parameters/ManagedProfileId" }, - { "in": "query", "name": "country", "required": true, "schema": { "$ref": "#/components/schemas/AlfredpayCountry" } }, { - "in": "query", - "name": "type", - "required": false, - "schema": { "$ref": "#/components/schemas/AlfredpayCustomerType" } + "description": "Immutable credential ID to revoke.", + "in": "path", + "name": "credentialId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } } ], "responses": { - "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayKycStatusResponse" } } }, - "description": "Verification status returned." + "204": { + "description": "Credential revoked; both values are immediately unusable.", + "headers": {} }, - "400": { + "401": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiCredentialErrorResponse" + } + } }, - "description": "Invalid country, invalid selector UUID, or managed-profile customer-type mismatch." + "description": "Missing or invalid Bearer token.", + "headers": {} }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Customer or verification attempt not found." - }, - "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Status lookup failed." - } - }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Get Alfredpay KYC or KYB status", - "tags": ["Account Management"] - } - }, - "/v1/alfredpay/kycRedirectFinished": { - "post": { - "description": "Records that the effective customer finished the hosted KYC or KYB redirect flow.", - "operationId": "markAlfredpayKycRedirectFinished", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], - "requestBody": { - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayCountryAndCustomerTypeRequest" } } - }, - "required": true - }, - "responses": { - "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpaySuccessResponse" } } }, - "description": "Redirect state recorded." - }, - "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiCredentialErrorResponse" + } + } }, - "description": "Invalid country, invalid selector UUID, or managed-profile customer-type mismatch." - }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, - "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Alfredpay customer not found." + "description": "`CREDENTIAL_NOT_FOUND`: credential is missing, already revoked, partner-managed, or not owned by the profile.", + "headers": {} }, "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "State update failed." - } - }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Mark an Alfredpay redirect finished", - "tags": ["Account Management"] - } - }, - "/v1/alfredpay/kycRedirectOpened": { - "post": { - "description": "Records that the effective customer's hosted KYC or KYB redirect was opened.", - "operationId": "markAlfredpayKycRedirectOpened", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], - "requestBody": { - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayCountryAndCustomerTypeRequest" } } - }, - "required": true - }, - "responses": { - "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpaySuccessResponse" } } }, - "description": "Redirect state recorded." - }, - "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiCredentialErrorResponse" + } + } }, - "description": "Invalid country, invalid selector UUID, or managed-profile customer-type mismatch." - }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, - "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Alfredpay customer not found." - }, - "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "State update failed." + "description": "Internal server error.", + "headers": {} } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Mark an Alfredpay redirect opened", - "tags": ["Account Management"] + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Revoke an API credential", + "tags": ["Authentication"] } }, - "/v1/alfredpay/retryKyc": { + "/v1/auth/request-otp": { "post": { - "description": "Retries a failed KYC or KYB submission. Hosted flows return a redirect link; API-based MX, CO, and AR individual KYC returns `{ success: true }`.", - "operationId": "retryAlfredpayKyc", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "deprecated": false, + "description": "Sends a 6-digit one-time password to the given email address. Use it with `POST /v1/auth/verify-otp` to obtain a user session.\n\n**Auth:** none.", + "operationId": "requestOTP", + "parameters": [], "requestBody": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayCountryAndCustomerTypeRequest" } } + "application/json": { + "example": { + "email": "user@example.com" + }, + "schema": { + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "locale": { + "description": "Optional locale for the email, e.g. `pt-BR`.", + "type": "string" + } + }, + "required": ["email"], + "type": "object" + } + } }, "required": true }, @@ -2608,680 +5681,958 @@ "content": { "application/json": { "schema": { - "oneOf": [ - { "$ref": "#/components/schemas/AlfredpayRedirectLinkResponse" }, - { "$ref": "#/components/schemas/AlfredpaySuccessResponse" } - ] + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + }, + "required": ["message", "success"], + "type": "object" } } }, - "description": "Retry initialized." + "description": "OTP sent.", + "headers": {} }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } }, - "description": "No failed submission is available, the selector UUID is invalid, or the managed-profile customer type mismatches." - }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, - "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Alfredpay customer not found." + "description": "Email missing or locale not a string.", + "headers": {} }, "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Retry failed." - } - }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Retry Alfredpay KYC or KYB", - "tags": ["Account Management"] - } - }, - "/v1/alfredpay/sendKybSubmission": { - "post": { - "description": "Finalizes an API-based business KYB submission.", - "operationId": "sendAlfredpayKybSubmission", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], - "requestBody": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpaySendSubmissionRequest" } } }, - "required": true - }, - "responses": { - "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpaySuccessResponse" } } }, - "description": "KYB submission sent." - }, - "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } }, - "description": "Invalid country, invalid selector UUID, or managed-profile customer-type mismatch." - }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, - "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Alfredpay business customer not found." - }, - "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Submission finalization failed." + "description": "Failed to send the OTP email.", + "headers": {} } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Send an Alfredpay KYB submission", - "tags": ["Account Management"] + "security": [], + "summary": "Request an email OTP", + "tags": ["Authentication"] } }, - "/v1/alfredpay/sendKycSubmission": { + "/v1/auth/verify-otp": { "post": { - "description": "Finalizes an API-based individual KYC submission.", - "operationId": "sendAlfredpayKycSubmission", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "deprecated": false, + "description": "Verifies the emailed one-time password and returns a user session. First-time sign-ins create the user profile; `user_id` identifies the profile that API keys minted with this session are linked to.\n\n**Auth:** none.", + "operationId": "verifyOTP", + "parameters": [], "requestBody": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpaySendSubmissionRequest" } } }, - "required": true - }, - "responses": { - "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpaySuccessResponse" } } }, - "description": "KYC submission sent." - }, - "400": { - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } - }, - "description": "Invalid country, invalid selector UUID, or managed-profile customer-type mismatch." - }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, - "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Alfredpay customer not found." + "content": { + "application/json": { + "example": { + "email": "user@example.com", + "token": "123456" + }, + "schema": { + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "token": { + "description": "The 6-digit code from the email.", + "type": "string" + } + }, + "required": ["email", "token"], + "type": "object" + } + } }, - "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Submission finalization failed." - } - }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Send an Alfredpay KYC submission", - "tags": ["Account Management"] - } - }, - "/v1/alfredpay/submitKybFile": { - "post": { - "description": "Uploads one business KYB document. Files are buffered in memory and limited to 5 MiB.", - "operationId": "submitAlfredpayKybFile", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], - "requestBody": { - "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/AlfredpayKybFileUploadRequest" } } }, "required": true }, "responses": { "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpaySuccessResponse" } } }, - "description": "File uploaded." + "content": { + "application/json": { + "schema": { + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "user_id": { + "format": "uuid", + "type": "string" + } + }, + "required": ["access_token", "refresh_token", "success", "user_id"], + "type": "object" + } + } + }, + "description": "Session created.", + "headers": {} }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } }, - "description": "Invalid country, missing file, invalid selector UUID, or managed-profile customer-type mismatch." - }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, - "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Alfredpay business customer not found." - }, - "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Upload failed." + "description": "Missing fields, or the OTP is invalid or expired.", + "headers": {} } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Upload an Alfredpay KYB file", - "tags": ["Account Management"] + "security": [], + "summary": "Verify an email OTP", + "tags": ["Authentication"] } }, - "/v1/alfredpay/submitKybInformation": { + "/v1/brla/createSubaccount": { "post": { - "description": "Creates or updates an API-based business KYB submission, including Alfredpay's compliance questionnaire.", - "operationId": "submitAlfredpayKybInformation", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "deprecated": false, + "description": "`companyName`, `startDate` and `cnpj` are only required when taxIdType is `CNPJ`\n\n`quoteId` is optional: pass it in the normal ramp flow, or omit it for the quote-less KYB deep link where business verification starts before any quote exists.\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", + "operationId": "createSubaccount", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], "requestBody": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpaySubmitKybInformationRequest" } } - }, - "required": true + "application/json": { + "examples": {}, + "schema": { + "$ref": "#/components/schemas/CreateSubaccountRequest" + } + } + } }, "responses": { "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpaySubmissionResponse" } } }, - "description": "KYB information accepted." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSubaccountResponse" + } + } + }, + "description": "Subaccount created or KYC retry initiated successfully.", + "headers": {} }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayValidationBadRequestResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" + } + } }, - "description": "Invalid country, company data, questionnaire, selector UUID, or managed-profile customer type." + "description": "Bad Request. Possible reasons:\n- Missing required fields (cpf, cnpj, companyName, startDate)\n- Subaccount already created and KYC level > 0\n- Other invalid request details", + "headers": {} }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, - "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Alfredpay business customer not found." + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "409": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "KYB is already in review or complete." + "403": { + "$ref": "#/components/responses/BrlaManagedSelectorForbidden" }, "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Submission failed." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaErrorResponse" + } + } + }, + "description": "Internal Server Error.", + "headers": {} } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Submit Alfredpay KYB information", + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Create user or retry KYC", "tags": ["Account Management"] } }, - "/v1/alfredpay/submitKybRelatedPersonFile": { - "post": { - "description": "Uploads the front or back identity document for one KYB related person. Files are limited to 5 MiB.", - "operationId": "submitAlfredpayKybRelatedPersonFile", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], - "requestBody": { - "content": { - "multipart/form-data": { "schema": { "$ref": "#/components/schemas/AlfredpayRelatedPersonFileUploadRequest" } } + "/v1/brla/getKycStatus": { + "get": { + "deprecated": false, + "description": "\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", + "operationId": "fetchSubaccountKycStatus", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" }, - "required": true - }, + { + "description": "The user's Tax ID.", + "in": "query", + "name": "taxId", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpaySuccessResponse" } } }, - "description": "File uploaded." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetKycStatusResponse" + } + } + }, + "description": "Successfully retrieved KYC status.", + "headers": {} }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" + } + } }, - "description": "Invalid country, missing file, invalid selector UUID, or managed-profile customer-type mismatch." + "description": "Missing taxId or subaccount not found (returned as 400 from code).", + "headers": {} + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized" + }, + "403": { + "$ref": "#/components/responses/BrlaManagedSelectorForbidden" }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Alfredpay business customer not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaErrorResponse" + } + } + }, + "description": "No KYC process started.", + "headers": {} + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaErrorResponse" + } + } + }, + "description": "The canonical Avenia KYC state requires reconciliation.", + "headers": {} }, "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Upload failed." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaErrorResponse" + } + } + }, + "description": "Internal Server Error (e.g., no KYC events found when expected).", + "headers": {} + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaErrorResponse" + } + } + }, + "description": "Avenia is unavailable or returned an invalid response.", + "headers": {} } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Upload a related-person KYB file", + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Get user's KYC status", "tags": ["Account Management"] } }, - "/v1/alfredpay/submitKycFile": { - "post": { - "description": "Uploads one individual KYC document. Files are buffered in memory and limited to 5 MiB.", - "operationId": "submitAlfredpayKycFile", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], - "requestBody": { - "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/AlfredpayKycFileUploadRequest" } } }, - "required": true - }, + "/v1/brla/getSelfieLivenessUrl": { + "get": { + "deprecated": false, + "description": "Returns the Avenia selfie/liveness-check URL for the subaccount associated with this tax ID.\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", + "operationId": "brlaGetSelfieLivenessUrl", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "description": "CPF or CNPJ.", + "in": "query", + "name": "taxId", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpaySuccessResponse" } } }, - "description": "File uploaded." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaGetSelfieLivenessUrlResponse" + } + } + }, + "description": "Liveness URL returned.", + "headers": {} }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayManagedBadRequestResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" + } + } }, - "description": "Invalid country, missing file, invalid selector UUID, or managed-profile customer-type mismatch." + "description": "Missing taxId or ramp disabled.", + "headers": {} }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, - "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Alfredpay customer not found." + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized" + }, + "403": { + "$ref": "#/components/responses/BrlaManagedSelectorForbidden" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaErrorResponse" + } + } + }, + "description": "The immutable KYC method or canonical case state conflicts with liveness creation.", + "headers": {} }, "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Upload failed." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaErrorResponse" + } + } + }, + "description": "Internal server error.", + "headers": {} + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaErrorResponse" + } + } + }, + "description": "Avenia is unavailable or returned an invalid response.", + "headers": {} } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Upload an Alfredpay KYC file", + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Get selfie liveness URL", "tags": ["Account Management"] } }, - "/v1/alfredpay/submitKycInformation": { + "/v1/brla/getUploadUrls": { "post": { - "description": "Creates or resumes an API-based individual KYC submission.", - "operationId": "submitAlfredpayKycInformation", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "deprecated": false, + "description": "Returns presigned upload URLs for the user's ID document and selfie. Only `ID` and `DRIVERS-LICENSE` are accepted for `documentType` (passport not supported here).\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", + "operationId": "brlaGetUploadUrls", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], "requestBody": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpaySubmitKycInformationRequest" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/AveniaKYCDataUploadRequest" + } + } }, "required": true }, "responses": { "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpaySubmissionResponse" } } }, - "description": "KYC information accepted." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AveniaKYCDataUploadResponse" + } + } + }, + "description": "Upload URLs returned.", + "headers": {} }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayValidationBadRequestResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" + } + } }, - "description": "Invalid country, KYC fields, selector UUID, or managed-profile customer type." + "description": "Missing/invalid documentType or taxId; or ramp disabled for this tax ID.", + "headers": {} }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, - "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Alfredpay customer not found." + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized" + }, + "403": { + "$ref": "#/components/responses/BrlaManagedSelectorForbidden" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaErrorResponse" + } + } + }, + "description": "The immutable KYC method or canonical case state conflicts with upload creation.", + "headers": {} }, "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlfredpayErrorResponse" } } }, - "description": "Submission failed." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaErrorResponse" + } + } + }, + "description": "Internal server error.", + "headers": {} + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaErrorResponse" + } + } + }, + "description": "Avenia is unavailable or returned an invalid response.", + "headers": {} } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Submit Alfredpay KYC information", + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Get KYC document upload URLs", "tags": ["Account Management"] } }, - "/v1/api-credentials": { + "/v1/brla/getUser": { "get": { "deprecated": false, - "description": "Lists all profile-managed credentials owned by the authenticated profile, newest first. Each item represents one public/secret credential. Public values and safe secret prefixes are included; secret values are never returned.\n\n**Auth:** Supabase Bearer session only.", - "operationId": "listApiCredentials", - "parameters": [], + "description": "Fetches the authenticated subject's subaccount information. The response contains only the EVM wallet address and KYC level. Omit the deprecated taxId query to derive the canonical account from the authenticated subject; when supplied, taxId is only an ownership-checked cross-check. Managed-profile selection requires the manager's secret key or Bearer session.", + "operationId": "getBrlaUser", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "deprecated": true, + "description": "Optional ownership-checked Tax ID cross-check. Omit it to derive the canonical Avenia account from the authenticated subject.", + "in": "query", + "name": "taxId", + "required": false, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListApiCredentialsResponse" + "$ref": "#/components/schemas/GetUserResponse" } } }, - "description": "Credentials, newest first, including revoked and expired lifecycle records.", + "description": "Successfully retrieved user information.", + "headers": {} + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" + } + } + }, + "description": "Missing/invalid authentication, ambiguous canonical Avenia account, or invalid KYC state.", "headers": {} }, "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized" + }, + "403": { + "$ref": "#/components/responses/BrlaManagedSelectorForbidden" + }, + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiCredentialErrorResponse" + "$ref": "#/components/schemas/BrlaErrorResponse" } } }, - "description": "Missing or invalid Bearer token.", + "description": "Subaccount not found.", "headers": {} }, "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiCredentialErrorResponse" + "$ref": "#/components/schemas/BrlaErrorResponse" } } }, - "description": "Internal server error.", + "description": "Internal Server Error.", "headers": {} } }, - "security": [{ "BearerAuth": [] }], - "summary": "List API credentials", - "tags": ["Authentication"] - }, - "post": { + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Get user information", + "tags": ["Account Management"] + } + }, + "/v1/brla/getUserRemainingLimit": { + "get": { "deprecated": false, - "description": "Creates one credential row containing a public value and a hashed secret value for the authenticated profile. The secret is returned only in this response. Expiry defaults to one year and cannot exceed two years. At most five non-revoked, non-expired credentials may exist per profile.\n\n**Auth:** Supabase Bearer session only.", - "operationId": "createApiCredential", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateApiCredentialRequest" } + "description": "Returns the authenticated subject's remaining BRL limit for the required ramp direction. Omit the deprecated taxId query to derive the canonical account from the authenticated subject; when supplied, taxId is only an ownership-checked cross-check. Managed-profile selection requires the manager's secret key or Bearer session.", + "operationId": "getBrlaUserRemainingLimit", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "deprecated": true, + "description": "Optional ownership-checked Tax ID cross-check. Omit it to derive the canonical Avenia account from the authenticated subject.", + "in": "query", + "name": "taxId", + "required": false, + "schema": { + "type": "string" } }, - "required": false - }, + { + "description": "Ramp direction whose remaining limit should be returned.", + "in": "query", + "name": "direction", + "required": true, + "schema": { + "$ref": "#/components/schemas/RampDirection" + } + } + ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateApiCredentialResponse" + "$ref": "#/components/schemas/GetUserRemainingLimitResponse" } } }, - "description": "Credential created. Persist `secretKey` immediately; it cannot be retrieved again.", + "description": "Successfully retrieved user's remaining limits.", "headers": {} }, "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiCredentialErrorResponse" + "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" } } }, - "description": "`INVALID_CREDENTIAL_EXPIRY` or `INVALID_CREDENTIAL_NAME`.", + "description": "Missing direction, missing/invalid authentication, ambiguous canonical Avenia account, or other invalid request.", "headers": {} }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiCredentialErrorResponse" - } - } - }, - "description": "Missing or invalid Bearer token.", - "headers": {} + "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "409": { + "403": { + "$ref": "#/components/responses/BrlaManagedSelectorForbidden" + }, + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiCredentialErrorResponse" + "$ref": "#/components/schemas/BrlaErrorResponse" } } }, - "description": "`CREDENTIAL_LIMIT_REACHED`: the profile already holds five active non-expired credentials.", + "description": "Subaccount not found or limits not found.", "headers": {} }, "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiCredentialErrorResponse" + "$ref": "#/components/schemas/BrlaErrorResponse" } } }, - "description": "Internal server error.", + "description": "Internal Server Error.", "headers": {} } }, - "security": [{ "BearerAuth": [] }], - "summary": "Create an API credential", - "tags": ["Authentication"] + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Get user's remaining transaction limits", + "tags": ["Account Management"] } }, - "/v1/api-credentials/{credentialId}": { - "delete": { - "deprecated": false, - "description": "Sets `revokedAt` on one profile-managed credential owned by the authenticated profile, atomically disabling its public and secret values. No request body or paired key ID is accepted.\n\n**Auth:** Supabase Bearer session only.", - "operationId": "revokeApiCredential", + "/v1/brla/kyb/attempt-status": { + "get": { + "description": "Refreshes an owned Avenia KYB attempt and persists its normalized verification state.", + "operationId": "getAveniaKybAttemptStatus", "parameters": [ { - "description": "Immutable credential ID to revoke.", - "in": "path", - "name": "credentialId", + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "in": "query", + "name": "attemptId", "required": true, "schema": { - "format": "uuid", "type": "string" } } ], "responses": { - "204": { - "description": "Credential revoked; both values are immediately unusable.", - "headers": {} + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AveniaKybAttemptStatusResponse" + } + } + }, + "description": "KYB attempt status returned." }, - "401": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiCredentialErrorResponse" + "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" } } }, - "description": "Missing or invalid Bearer token.", - "headers": {} + "description": "Missing attempt ID, invalid selector UUID, or authentication subject." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/BrlaManagedSelectorForbidden", + "description": "Attempt does not belong to the effective profile." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiCredentialErrorResponse" + "$ref": "#/components/schemas/BrlaErrorResponse" } } }, - "description": "`CREDENTIAL_NOT_FOUND`: credential is missing, already revoked, partner-managed, or not owned by the profile.", - "headers": {} + "description": "KYB attempt or account not found." + }, + "409": { + "description": "The attempt is no longer the current bound KYB attempt." }, "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiCredentialErrorResponse" + "$ref": "#/components/schemas/BrlaErrorResponse" } } }, - "description": "Internal server error.", - "headers": {} + "description": "Status refresh failed." + }, + "502": { + "description": "Avenia is unavailable or returned an invalid response." } }, - "security": [{ "BearerAuth": [] }], - "summary": "Revoke an API credential", - "tags": ["Authentication"] + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Get Avenia KYB attempt status", + "tags": ["KYC and KYB", "Account Management"] } }, - "/v1/auth/request-otp": { + "/v1/brla/kyb/documents": { "post": { - "deprecated": false, - "description": "Sends a 6-digit one-time password to the given email address. Use it with `POST /v1/auth/verify-otp` to obtain a user session.\n\n**Auth:** none.", - "operationId": "requestOTP", - "parameters": [], + "description": "Creates an Avenia document and returns presigned upload targets. Upload bytes directly to the returned URLs.", + "operationId": "createAveniaKybDocument", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "in": "query", + "name": "subAccountId", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { - "example": { - "email": "user@example.com" - }, "schema": { - "properties": { - "email": { - "format": "email", - "type": "string" - }, - "locale": { - "description": "Optional locale for the email, e.g. `pt-BR`.", - "type": "string" - } - }, - "required": ["email"], - "type": "object" + "$ref": "#/components/schemas/AveniaKybDocumentRequest" } } }, "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "properties": { - "message": { - "type": "string" - }, - "success": { - "type": "boolean" - } - }, - "required": ["message", "success"], - "type": "object" + "$ref": "#/components/schemas/AveniaKybDocumentUploadResponse" } } }, - "description": "OTP sent.", - "headers": {} + "description": "Document upload targets created." }, "400": { "content": { "application/json": { "schema": { - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"], - "type": "object" + "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" } } }, - "description": "Email missing or locale not a string.", - "headers": {} + "description": "Invalid document request." }, - "500": { - "content": { - "application/json": { - "schema": { - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"], - "type": "object" - } - } - }, - "description": "Failed to send the OTP email.", - "headers": {} + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/BrlaManagedSelectorForbidden", + "description": "Managed profile or corridor is not authorized." + }, + "404": { + "description": "Subaccount not found." + }, + "502": { + "description": "Avenia is unavailable or returned an invalid response." } }, - "security": [], - "summary": "Request an email OTP", - "tags": ["Authentication"] + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Create Avenia KYB document", + "tags": ["KYC and KYB"] } }, - "/v1/auth/verify-otp": { - "post": { - "deprecated": false, - "description": "Verifies the emailed one-time password and returns a user session. First-time sign-ins create the user profile; `user_id` identifies the profile that API keys minted with this session are linked to.\n\n**Auth:** none.", - "operationId": "verifyOTP", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "example": { - "email": "user@example.com", - "token": "123456" - }, - "schema": { - "properties": { - "email": { - "format": "email", - "type": "string" - }, - "token": { - "description": "The 6-digit code from the email.", - "type": "string" - } - }, - "required": ["email", "token"], - "type": "object" - } + "/v1/brla/kyb/documents/{documentId}": { + "get": { + "description": "Reads readiness and upload status for an owned Avenia KYB document.", + "operationId": "getAveniaKybDocument", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "in": "path", + "name": "documentId", + "required": true, + "schema": { + "type": "string" } }, - "required": true - }, + { + "in": "query", + "name": "subAccountId", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "properties": { - "access_token": { - "type": "string" - }, - "refresh_token": { - "type": "string" - }, - "success": { - "type": "boolean" - }, - "user_id": { - "format": "uuid", - "type": "string" - } - }, - "required": ["access_token", "refresh_token", "success", "user_id"], - "type": "object" + "$ref": "#/components/schemas/AveniaKybDocumentResponse" } } }, - "description": "Session created.", - "headers": {} + "description": "Document status." }, "400": { "content": { "application/json": { "schema": { - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"], - "type": "object" + "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" } } }, - "description": "Missing fields, or the OTP is invalid or expired.", - "headers": {} + "description": "Invalid document identifier." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/BrlaManagedSelectorForbidden", + "description": "Document does not belong to the effective profile." + }, + "404": { + "description": "Document not found." + }, + "502": { + "description": "Avenia is unavailable or returned an invalid response." } }, - "security": [], - "summary": "Verify an email OTP", - "tags": ["Authentication"] + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Get Avenia KYB document", + "tags": ["KYC and KYB"] } }, - "/v1/brla/createSubaccount": { + "/v1/brla/kyb/new-level-1/api": { "post": { - "deprecated": false, - "description": "`companyName`, `startDate` and `cnpj` are only required when taxIdType is `CNPJ`\n\n`quoteId` is optional: pass it in the normal ramp flow, or omit it for the quote-less KYB deep link where business verification starts before any quote exists.\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", - "operationId": "createSubaccount", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "description": "Submits the API-driven Avenia Level 1 KYB attempt after validating the owned corporate documents and UBO references.", + "operationId": "submitAveniaKybLevel1Api", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "in": "query", + "name": "subAccountId", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { - "examples": {}, "schema": { - "$ref": "#/components/schemas/CreateSubaccountRequest" + "$ref": "#/components/schemas/AveniaKybLevel1Payload" } } - } + }, + "required": true }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateSubaccountResponse" + "$ref": "#/components/schemas/KycLevel1Response" } } }, - "description": "Subaccount created or KYC retry initiated successfully.", - "headers": {} + "description": "KYB attempt submitted." }, "400": { "content": { @@ -3291,39 +6642,49 @@ } } }, - "description": "Bad Request. Possible reasons:\n- Missing required fields (cpf, cnpj, companyName, startDate)\n- Subaccount already created and KYC level > 0\n- Other invalid request details", - "headers": {} + "description": "Invalid submission or document state." }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/BrlaManagedSelectorForbidden" }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BrlaErrorResponse" - } - } - }, - "description": "Internal Server Error.", - "headers": {} + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/BrlaManagedSelectorForbidden", + "description": "Managed profile or corridor is not authorized." + }, + "404": { + "description": "Subaccount or referenced document not found." + }, + "409": { + "description": "A different KYB submission is already in progress." + }, + "502": { + "description": "Avenia is unavailable or returned an invalid response." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Create user or retry KYC", - "tags": ["Account Management"] + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Submit API-driven Avenia KYB", + "tags": ["KYC and KYB"] } }, - "/v1/brla/getKycStatus": { - "get": { - "deprecated": false, - "description": "\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", - "operationId": "fetchSubaccountKycStatus", + "/v1/brla/kyb/new-level-1/web-sdk": { + "post": { + "description": "Starts or resumes Avenia's hosted KYB level-1 flow for an owned company subaccount.", + "operationId": "startAveniaKybLevel1Hosted", "parameters": [ - { "$ref": "#/components/parameters/ManagedProfileId" }, { - "description": "The user's Tax ID.", + "$ref": "#/components/parameters/ManagedProfileId" + }, + { "in": "query", - "name": "taxId", + "name": "subAccountId", "required": true, "schema": { "type": "string" @@ -3335,12 +6696,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetKycStatusResponse" + "$ref": "#/components/schemas/AveniaKybHostedResponse" } } }, - "description": "Successfully retrieved KYC status.", - "headers": {} + "description": "Hosted KYB attempt and step URLs returned." }, "400": { "content": { @@ -3350,11 +6710,16 @@ } } }, - "description": "Missing taxId or subaccount not found (returned as 400 from code).", - "headers": {} + "description": "Missing subaccount, non-company account, invalid selector UUID, customer-type mismatch, or invalid request." + }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/BrlaManagedSelectorForbidden", + "description": "Managed profile or corridor is not authorized." }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/BrlaManagedSelectorForbidden" }, "404": { "content": { "application/json": { @@ -3363,8 +6728,17 @@ } } }, - "description": "No KYC process started.", - "headers": {} + "description": "Subaccount not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaErrorResponse" + } + } + }, + "description": "Company is approved or a non-resumable KYB attempt is in progress." }, "500": { "content": { @@ -3374,43 +6748,61 @@ } } }, - "description": "Internal Server Error (e.g., no KYC events found when expected).", - "headers": {} + "description": "KYB initialization failed." + }, + "502": { + "description": "Avenia is unavailable or returned an invalid response." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Get user's KYC status", - "tags": ["Account Management"] + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Start Avenia hosted KYB", + "tags": ["KYC and KYB", "Account Management"] } }, - "/v1/brla/getSelfieLivenessUrl": { - "get": { - "deprecated": false, - "description": "Returns the Avenia selfie/liveness-check URL for the subaccount associated with this tax ID.\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", - "operationId": "brlaGetSelfieLivenessUrl", + "/v1/brla/kyb/ubos": { + "post": { + "description": "Registers a UBO after verifying that referenced identity documents are ready and owned by the company subaccount.", + "operationId": "createAveniaKybUbo", "parameters": [ - { "$ref": "#/components/parameters/ManagedProfileId" }, { - "description": "CPF or CNPJ.", + "$ref": "#/components/parameters/ManagedProfileId" + }, + { "in": "query", - "name": "taxId", + "name": "subAccountId", "required": true, "schema": { "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AveniaUboPayload" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BrlaGetSelfieLivenessUrlResponse" + "$ref": "#/components/schemas/AveniaUboResponse" } } }, - "description": "Liveness URL returned.", - "headers": {} + "description": "UBO registered." }, "400": { "content": { @@ -3420,323 +6812,221 @@ } } }, - "description": "Missing taxId or ramp disabled.", - "headers": {} + "description": "Invalid UBO or document state." }, "401": { - "$ref": "#/components/responses/ManagedSelectorUnauthorized" + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." }, - "403": { "$ref": "#/components/responses/BrlaManagedSelectorForbidden" }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BrlaErrorResponse" - } - } - }, - "description": "Internal server error.", - "headers": {} + "403": { + "$ref": "#/components/responses/BrlaManagedSelectorForbidden", + "description": "Managed profile or corridor is not authorized." + }, + "404": { + "description": "Subaccount or referenced document not found." + }, + "409": { + "description": "A referenced document is not ready." + }, + "502": { + "description": "Avenia is unavailable or returned an invalid response." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Get selfie liveness URL", - "tags": ["Account Management"] + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Create Avenia KYB UBO", + "tags": ["KYC and KYB"] } }, - "/v1/brla/getUploadUrls": { + "/v1/brla/kyc/import-token": { "post": { - "deprecated": false, - "description": "Returns presigned upload URLs for the user's ID document and selfie. Only `ID` and `DRIVERS-LICENSE` are accepted for `documentType` (passport not supported here).\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", - "operationId": "brlaGetUploadUrls", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "description": "Imports an opaque Sumsub share token into the authenticated subject's existing individual Avenia KYC case. This alternative path is enabled by approved Vortex policy despite unresolved legal/consent wording and provider-environment confirmations; no live sandbox verification is claimed. Authentication and profile-bound principal enforcement run before managed-profile authorization and strict body validation. Use either a profile-bound secret `X-API-Key` or a Supabase Bearer session. A controlling manager may add `X-Managed-Profile-Id`; direct managed-child credentials are rejected even without the selector. Public and ownerless credentials are insufficient.\n\nThe body accepts only `importToken` and literal `consentAttested: true`; CPF, tax ID, subaccount ID, applicant ID, entity ID, provider-customer ID, profile ID, and other caller identity selectors are forbidden. The provisional server-controlled consent policy is `sumsub-share-v1`. Every token claim appends actor, subject, policy version, and timestamp consent evidence without storing the raw token.\n\nThe first normal KYC artifact, status read, or token-import claim permanently selects that case's method. Import the token before reading KYC or onboarding status because a status read selects a nullable method as `standard`. The same idempotency key and token returns a stored confirmed attempt or safely reconciles a durable submitted/ambiguous claim through provider reads, without another provider POST or replaying the token. A different token under the same key returns `409`. A provider `401` means the feature precondition is unavailable, records a failed attempt, returns `412`, and may be retried only with a new idempotency key; the new claim appends consent evidence while preserving prior attestations. Every other post-send provider, transport, malformed-response, timeout, or local-confirmation failure is ambiguous, returns `502`, and is never replayed automatically.\n\nAcceptance is pending only. Vortex polls the exact returned Avenia attempt; `EXPIRED` remains non-approved and locally pending for reconciliation, and its external status is retained. Only Avenia `COMPLETED` plus `APPROVED` completes KYC. The Avenia webhook is notification-only and cannot approve the case.", + "operationId": "importAveniaKycToken", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "description": "Caller-generated key for one token-import attempt. It must contain 1 to 128 visible ASCII characters. Reuse it only with the same token.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AveniaKYCDataUploadRequest" + "$ref": "#/components/schemas/BrlaImportKycTokenRequest" } } }, "required": true }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AveniaKYCDataUploadResponse" + "$ref": "#/components/schemas/BrlaImportKycTokenResponse" } } }, - "description": "Upload URLs returned.", - "headers": {} + "description": "The exact Avenia attempt is durably bound and pending. This does not mean KYC is approved." }, "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" + "oneOf": [ + { + "$ref": "#/components/schemas/BrlaImportKycTokenErrorResponse" + }, + { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + }, + { + "$ref": "#/components/schemas/MalformedJsonErrorResponse" + } + ] } } }, - "description": "Missing/invalid documentType or taxId; or ramp disabled for this tax ID.", - "headers": {} + "description": "Invalid idempotency key, strict body, malformed authenticated JSON, selector UUID, or managed customer type. Authentication is checked first." }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/BrlaManagedSelectorForbidden" }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BrlaErrorResponse" - } - } - }, - "description": "Internal server error.", - "headers": {} - } - }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Get KYC document upload URLs", - "tags": ["Account Management"] - } - }, - "/v1/brla/getUser": { - "get": { - "deprecated": false, - "description": "Fetches the authenticated subject's subaccount information. The response contains only the EVM wallet address and KYC level. Omit the deprecated taxId query to derive the canonical account from the authenticated subject; when supplied, taxId is only an ownership-checked cross-check. Managed-profile selection requires the manager's secret key or Bearer session.", - "operationId": "getBrlaUser", - "parameters": [ - { "$ref": "#/components/parameters/ManagedProfileId" }, - { - "deprecated": true, - "description": "Optional ownership-checked Tax ID cross-check. Omit it to derive the canonical Avenia account from the authenticated subject.", - "in": "query", - "name": "taxId", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetUserResponse" + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } }, - "description": "Successfully retrieved user information.", - "headers": {} + "description": "A valid profile-bound secret key or Supabase session is required." }, - "400": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" + "oneOf": [ + { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + }, + { + "$ref": "#/components/schemas/BrlaImportKycTokenErrorResponse" + } + ] } } }, - "description": "Missing/invalid authentication, ambiguous canonical Avenia account, or invalid KYC state.", - "headers": {} + "description": "The selected child is unauthorized, the caller used direct managed-child credentials, or transactional authorization was revoked before provider submission." }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/BrlaManagedSelectorForbidden" }, - "404": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BrlaErrorResponse" + "$ref": "#/components/schemas/BrlaImportKycTokenErrorResponse" } } }, - "description": "Subaccount not found.", - "headers": {} + "description": "The prerequisite setup, immutable method, idempotency input, active submission, or prior ambiguous outcome prevents import." }, - "500": { + "412": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BrlaErrorResponse" + "$ref": "#/components/schemas/BrlaImportKycTokenErrorResponse" } } }, - "description": "Internal Server Error.", - "headers": {} - } - }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Get user information", - "tags": ["Account Management"] - } - }, - "/v1/brla/getUserRemainingLimit": { - "get": { - "deprecated": false, - "description": "Returns the authenticated subject's remaining BRL limit for the required ramp direction. Omit the deprecated taxId query to derive the canonical account from the authenticated subject; when supplied, taxId is only an ownership-checked cross-check. Managed-profile selection requires the manager's secret key or Bearer session.", - "operationId": "getBrlaUserRemainingLimit", - "parameters": [ - { "$ref": "#/components/parameters/ManagedProfileId" }, - { - "deprecated": true, - "description": "Optional ownership-checked Tax ID cross-check. Omit it to derive the canonical Avenia account from the authenticated subject.", - "in": "query", - "name": "taxId", - "required": false, - "schema": { - "type": "string" - } + "description": "Avenia returned provider `401`: token import is not enabled. This failed attempt may be retried with a new idempotency key." }, - { - "description": "Ramp direction whose remaining BRL limit should be returned.", - "in": "query", - "name": "direction", - "required": true, - "schema": { "$ref": "#/components/schemas/RampDirection" } - } - ], - "responses": { - "200": { + "413": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetUserRemainingLimitResponse" + "$ref": "#/components/schemas/PayloadTooLargeErrorResponse" } } }, - "description": "Successfully retrieved user's remaining limits.", - "headers": {} + "description": "The authenticated JSON request exceeds the token-import route's 16 KiB body limit." }, - "400": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" + "oneOf": [ + { + "$ref": "#/components/schemas/BrlaImportKycTokenErrorResponse" + }, + { + "$ref": "#/components/schemas/ErrorResponse" + } + ] } } }, - "description": "Missing direction, missing/invalid authentication, ambiguous canonical Avenia account, or other invalid request.", - "headers": {} + "description": "Token import failed before a safe public classification could be returned." }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/BrlaManagedSelectorForbidden" }, - "404": { + "502": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BrlaErrorResponse" + "$ref": "#/components/schemas/BrlaImportKycTokenErrorResponse" } } }, - "description": "Subaccount not found or limits not found.", - "headers": {} + "description": "The post-send outcome is ambiguous and requires reconciliation. Do not retry the token." }, - "500": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BrlaErrorResponse" + "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } }, - "description": "Internal Server Error.", - "headers": {} + "description": "Supabase authentication is temporarily unavailable." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Get user's remaining transaction limits", - "tags": ["Account Management"] - } - }, - "/v1/brla/kyb/attempt-status": { - "get": { - "description": "Refreshes an owned Avenia KYB attempt and persists its normalized verification state.", - "operationId": "getAveniaKybAttemptStatus", - "parameters": [ - { "$ref": "#/components/parameters/ManagedProfileId" }, - { "in": "query", "name": "attemptId", "required": true, "schema": { "type": "string" } } - ], - "responses": { - "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/KybAttemptStatusResponse" } } }, - "description": "KYB attempt status returned." - }, - "400": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" } } }, - "description": "Missing attempt ID, invalid selector UUID, or authentication subject." - }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/BrlaManagedSelectorForbidden" }, - "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BrlaErrorResponse" } } }, - "description": "KYB attempt or account not found." + "security": [ + { + "SecretApiKey": [] }, - "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BrlaErrorResponse" } } }, - "description": "Status refresh failed." + { + "BearerAuth": [] } - }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Get Avenia KYB attempt status", - "tags": ["Account Management"] - } - }, - "/v1/brla/kyb/new-level-1/web-sdk": { - "post": { - "description": "Starts or resumes Avenia's hosted KYB level-1 flow for an owned company subaccount.", - "operationId": "initiateAveniaKybLevel1", - "parameters": [ - { "$ref": "#/components/parameters/ManagedProfileId" }, - { "in": "query", "name": "subAccountId", "required": true, "schema": { "type": "string" } } ], - "responses": { - "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/KybLevel1Response" } } }, - "description": "Hosted KYB attempt and step URLs returned." - }, - "400": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" } } }, - "description": "Missing subaccount, non-company account, invalid selector UUID, customer-type mismatch, or invalid request." - }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/BrlaManagedSelectorForbidden" }, - "404": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BrlaErrorResponse" } } }, - "description": "Subaccount not found." - }, - "409": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BrlaErrorResponse" } } }, - "description": "Company is approved or a non-resumable KYB attempt is in progress." - }, - "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BrlaErrorResponse" } } }, - "description": "KYB initialization failed." - } - }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Start Avenia hosted KYB", - "tags": ["Account Management"] + "summary": "Import an individual Avenia KYC token", + "tags": ["KYC and KYB", "Account Management"] } }, "/v1/brla/kyc/record-attempt": { "post": { - "description": "Records the first observed KYC attempt for a CPF or CNPJ when no provider-customer record exists yet.", - "operationId": "recordAveniaInitialKycAttempt", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "description": "Validates an authenticated BRL onboarding preflight event. The asserted CPF or CNPJ is not persisted because quote ownership does not prove tax-ID ownership.", + "operationId": "recordInitialAveniaKycAttempt", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], "requestBody": { "content": { "application/json": { "schema": { - "properties": { - "quoteId": { "type": "string" }, - "sessionId": { "type": "string" }, - "taxId": { "type": "string" } - }, - "required": ["quoteId", "taxId"], - "type": "object" + "$ref": "#/components/schemas/RecordInitialKycAttemptRequest" } } }, @@ -3744,23 +7034,55 @@ }, "responses": { "200": { - "content": { "application/json": { "schema": { "additionalProperties": false, "type": "object" } } }, - "description": "Attempt marker recorded or already present." + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object" + } + } + }, + "description": "Preflight event accepted without reserving the asserted tax identity." }, "400": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaManagedBadRequestResponse" + } + } + }, "description": "Missing tax ID, invalid selector UUID, or managed-profile customer-type mismatch." }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden", + "description": "Managed profile or corridor is not authorized." + }, "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BrlaErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaErrorResponse" + } + } + }, "description": "Attempt recording failed." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "Record an initial Avenia KYC attempt", - "tags": ["Account Management"] + "tags": ["KYC and KYB", "Account Management"] } }, "/v1/brla/newKyc": { @@ -3768,7 +7090,11 @@ "deprecated": false, "description": "Submits the user's KYC level 1 payload to Avenia after documents have been uploaded via `/v1/brla/getUploadUrls`. Includes a built-in 5-second delay to allow upstream document propagation.\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", "operationId": "brlaNewKyc", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], "requestBody": { "content": { "application/json": { @@ -3802,8 +7128,23 @@ "description": "Validation failure.", "headers": {} }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/BrlaManagedSelectorForbidden" }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized" + }, + "403": { + "$ref": "#/components/responses/BrlaManagedSelectorForbidden" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaErrorResponse" + } + } + }, + "description": "The immutable KYC method, approval state, or durable submission state conflicts with this request.", + "headers": {} + }, "500": { "content": { "application/json": { @@ -3814,9 +7155,27 @@ }, "description": "Internal server error.", "headers": {} + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrlaErrorResponse" + } + } + }, + "description": "Avenia documents are not ready, or the submitted outcome requires reconciliation.", + "headers": {} } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "Submit KYC level 1 data", "tags": ["Account Management"] } @@ -3860,11 +7219,6 @@ "description": "Missing or invalid pix key.", "headers": {} }, - "401": { - "content": {}, - "description": "Supabase Bearer required.", - "headers": {} - }, "500": { "content": { "application/json": { @@ -3878,7 +7232,7 @@ } }, "security": [], - "summary": "Validate Pix key", + "summary": "Validate PIX key", "tags": ["Account Management"] } }, @@ -3887,7 +7241,11 @@ "deprecated": false, "description": "Returns onramp and offramp limits for the authenticated user's requested fiat corridors. Alfredpay usage is calculated from completed Vortex ramps in the current UTC calendar month and may be delayed by the 60-second in-memory cache. Avenia BRL maximums, usage, and period are read from Avenia.\n\n**Auth:** requires either `X-API-Key: sk_*` linked to a user or `Authorization: Bearer `. Unlinked partner keys are rejected.", "operationId": "getUserLimits", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], "requestBody": { "content": { "application/json": { @@ -3911,19 +7269,31 @@ }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/FlatManagedSelectorErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlatManagedSelectorErrorResponse" + } + } }, "description": "Invalid corridor list or no completed provider profile for a requested corridor." }, "401": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } }, "description": "Missing or invalid credentials." }, "403": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/FlatManagedSelectorErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlatManagedSelectorErrorResponse" + } + } }, "description": "The credential is not linked to a user." }, @@ -3931,7 +7301,14 @@ "description": "Provider limits are unavailable or invalid." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "Get user ramp limits", "tags": ["Account Management"] } @@ -3946,62 +7323,106 @@ "in": "query", "name": "limit", "required": false, - "schema": { "default": 50, "maximum": 100, "minimum": 1, "type": "integer" } + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "type": "integer" + } }, { "description": "Number of records to skip.", "in": "query", "name": "offset", "required": false, - "schema": { "default": 0, "minimum": 0, "type": "integer" } + "schema": { + "default": 0, + "minimum": 0, + "type": "integer" + } }, { "description": "Lifecycle records to include.", "in": "query", "name": "status", "required": false, - "schema": { "default": "active", "enum": ["active", "deleted", "all"], "type": "string" } + "schema": { + "default": "active", + "enum": ["active", "deleted", "all"], + "type": "string" + } } ], "responses": { "200": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ListManagedProfilesResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListManagedProfilesResponse" + } + } }, "description": "A page of owned managed profiles and offset pagination metadata." }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`MANAGED_PROFILE_INVALID_INPUT`: invalid pagination or status filter." }, "401": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "Missing, invalid, expired, or revoked manager authentication." }, "403": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`MANAGED_PROFILE_ACCESS_DENIED`: the authenticated profile is not an active manager, or is a direct managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials." }, "409": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`MANAGED_PROFILE_CONFLICT`: a retained child has an invalid customer-entity layout." }, "500": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "Internal server error." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "List managed profiles", "tags": ["Managed Profiles"] }, @@ -4011,55 +7432,94 @@ "parameters": [], "requestBody": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/CreateManagedProfileRequest" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateManagedProfileRequest" + } + } }, "required": true }, "responses": { "200": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileResponse" + } + } }, "description": "Idempotent retry returned the existing active managed profile." }, "201": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileResponse" + } + } }, "description": "Managed profile created." }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`MANAGED_PROFILE_INVALID_INPUT`: required input is missing or invalid." }, "401": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "Missing, invalid, expired, or revoked manager authentication." }, "403": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`MANAGED_PROFILE_MANAGER_NOT_FOUND`, `MANAGED_PROFILE_MANAGER_INACTIVE`, or `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated profile is not an active managed-profile manager, or is a direct managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials." }, "409": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`MANAGED_PROFILE_CONFLICT`: an immutable external subject or contact email is reserved with different profile data." }, "500": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "Internal server error." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "Create a managed profile", "tags": ["Managed Profiles"] } @@ -4074,43 +7534,75 @@ "in": "path", "name": "profileId", "required": true, - "schema": { "format": "uuid", "type": "string" } + "schema": { + "format": "uuid", + "type": "string" + } } ], "responses": { - "204": { "description": "Managed profile is logically deleted and its credentials are revoked." }, + "204": { + "description": "Managed profile is logically deleted and its credentials are revoked." + }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`MANAGED_PROFILE_INVALID_INPUT`: profileId is not a UUID." }, "401": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "Missing, invalid, expired, or revoked manager authentication." }, "403": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`MANAGED_PROFILE_ACCESS_DENIED`: the authenticated profile is not an active manager, or is a direct managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials." }, "404": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`MANAGED_PROFILE_NOT_FOUND`: the child does not exist or is not owned by this manager." }, "500": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "Internal server error." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "Delete a managed profile", "tags": ["Managed Profiles"] }, @@ -4123,54 +7615,92 @@ "in": "path", "name": "profileId", "required": true, - "schema": { "format": "uuid", "type": "string" } + "schema": { + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileResponse" + } + } }, "description": "Owned active or deleted managed profile." }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`MANAGED_PROFILE_INVALID_INPUT`: profileId is not a UUID." }, "401": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "Missing, invalid, expired, or revoked manager authentication." }, "403": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`MANAGED_PROFILE_ACCESS_DENIED`: the authenticated profile is not an active manager, or is a direct managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials." }, "404": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`MANAGED_PROFILE_NOT_FOUND`: the child does not exist or is not owned by this manager." }, "409": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`MANAGED_PROFILE_CONFLICT`: the retained child has an invalid customer-entity layout." }, "500": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "Internal server error." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "Get a managed profile", "tags": ["Managed Profiles"] } @@ -4185,48 +7715,82 @@ "in": "path", "name": "profileId", "required": true, - "schema": { "format": "uuid", "type": "string" } + "schema": { + "format": "uuid", + "type": "string" + } } ], "responses": { "200": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ListApiCredentialsResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListApiCredentialsResponse" + } + } }, "description": "Child credentials without secret values." }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`MANAGED_PROFILE_INVALID_INPUT`: profileId is not a UUID." }, "401": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "Missing, invalid, expired, or revoked manager authentication." }, "403": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`CREDENTIAL_ACCESS_DENIED`: the manager is inactive. `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated credential belongs directly to a managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials." }, "404": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`CREDENTIAL_NOT_FOUND`: the active child does not exist or is not owned by this manager." }, "500": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "Internal server error." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "List a managed profile's API credentials", "tags": ["Managed Profiles"] }, @@ -4239,60 +7803,102 @@ "in": "path", "name": "profileId", "required": true, - "schema": { "format": "uuid", "type": "string" } + "schema": { + "format": "uuid", + "type": "string" + } } ], "requestBody": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/CreateApiCredentialRequest" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateApiCredentialRequest" + } + } }, "required": false }, "responses": { "201": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/CreateApiCredentialResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateApiCredentialResponse" + } + } }, "description": "Credential created. This is the only response containing `secretKey`." }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "Invalid profileId, credential name, or expiry." }, "401": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "Missing, invalid, expired, or revoked manager authentication." }, "403": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`CREDENTIAL_ACCESS_DENIED`: the manager is inactive. `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated credential belongs directly to a managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials." }, "404": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`CREDENTIAL_NOT_FOUND`: the active child does not exist or is not owned by this manager." }, "409": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`CREDENTIAL_LIMIT_REACHED`: the child already holds five active, non-expired credentials." }, "500": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "Internal server error." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "Create a managed profile API credential", "tags": ["Managed Profiles"] } @@ -4307,14 +7913,20 @@ "in": "path", "name": "profileId", "required": true, - "schema": { "format": "uuid", "type": "string" } + "schema": { + "format": "uuid", + "type": "string" + } }, { "description": "Child credential ID.", "in": "path", "name": "credentialId", "required": true, - "schema": { "format": "uuid", "type": "string" } + "schema": { + "format": "uuid", + "type": "string" + } } ], "responses": { @@ -4323,64 +7935,236 @@ }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } }, "description": "`MANAGED_PROFILE_INVALID_INPUT`: profileId or credentialId is not a UUID." }, "401": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "Missing, invalid, expired, or revoked manager authentication." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "`CREDENTIAL_ACCESS_DENIED`: the manager is inactive. `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated credential belongs directly to a managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "`CREDENTIAL_NOT_FOUND`: the active child or credential does not exist, or is not owned by this manager." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedProfileErrorResponse" + } + } + }, + "description": "Internal server error." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Revoke a managed profile API credential", + "tags": ["Managed Profiles"] + } + }, + "/v1/onboarding/active-entity": { + "put": { + "description": "Selects the authenticated profile's immutable active customer-entity type. Managed-child delegation is not supported.", + "operationId": "selectActiveCustomerEntity", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelectActiveCustomerEntityRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelectActiveCustomerEntityResponse" + } + } + }, + "description": "Active customer entity selected." + }, + "400": { + "description": "Invalid customer-entity type." + }, + "401": { + "description": "Supabase Bearer authentication required." + }, + "404": { + "description": "No active owned entity of the requested type exists." + }, + "409": { + "description": "The selection conflicts with an existing selection or is ambiguous." + }, + "500": { + "description": "Selection could not be completed." + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Select active customer entity", + "tags": ["KYC and KYB"] + } + }, + "/v1/onboarding/requirements": { + "get": { + "description": "Returns versioned document and ordered action metadata for an existing Avenia or Alfredpay onboarding flow. GET operations, status polling, and readiness checks are intentionally omitted and remain documented in the integration guides and OpenAPI. Request fields and bodies are defined only by the referenced OpenAPI schemas and are not duplicated at the top level. This endpoint does not return profile state or customer PII. Monerium is outside this discovery proposal.", + "operationId": "getOnboardingRequirements", + "parameters": [ + { + "in": "query", + "name": "country", + "required": true, + "schema": { + "enum": ["AR", "BR", "CO", "MX", "US"], + "type": "string" + } + }, + { + "in": "query", + "name": "customerType", + "required": true, + "schema": { + "enum": ["individual", "business"], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingRequirementsResponse" + } + } }, - "description": "Missing, invalid, expired, or revoked manager authentication." + "description": "Flow metadata and ordered non-GET action sequence." }, - "403": { + "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingRequirementsErrorResponse" + } + } }, - "description": "`CREDENTIAL_ACCESS_DENIED`: the manager is inactive. `MANAGED_PROFILE_ACCESS_DENIED`: the authenticated credential belongs directly to a managed child. `CREDENTIAL_MISMATCH` is returned when public and secret headers identify different credentials." + "description": "Missing or invalid query." }, "404": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } - }, - "description": "`CREDENTIAL_NOT_FOUND`: the active child or credential does not exist, or is not owned by this manager." - }, - "500": { - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedProfileErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingRequirementsErrorResponse" + } + } }, - "description": "Internal server error." + "description": "No published flow exists for the country and customer type." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], - "summary": "Revoke a managed profile API credential", - "tags": ["Managed Profiles"] + "security": [], + "summary": "Discover KYC or KYB requirements", + "tags": ["KYC and KYB", "Reference Data"] } }, "/v1/onboarding/status": { "get": { "description": "Returns the effective profile's customer entities and aggregated provider/KYC state. Non-terminal provider statuses may be refreshed before the response is built.", "operationId": "getOnboardingStatus", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], "responses": { "200": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OnboardingStatusResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingStatusResponse" + } + } + }, "description": "Aggregated onboarding state returned." }, "400": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } + }, "description": "The managed-profile selector is invalid." }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized", + "description": "Authentication required." + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden" + }, "500": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OnboardingStatusErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingStatusErrorResponse" + } + } + }, "description": "Onboarding aggregation failed." } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "Get aggregate onboarding status", - "tags": ["Account Management"] + "tags": ["KYC and KYB", "Account Management"] } }, "/v1/public-key": { @@ -4421,7 +8205,11 @@ "deprecated": false, "description": "Generates a quote for a specified ramp transaction, detailing input and output amounts, fees, and expiration.", "operationId": "createQuote", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], "requestBody": { "content": { "application/json": { @@ -4626,13 +8414,21 @@ }, "401": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorManagedSelectorResponse" + } + } }, "description": "Invalid authentication or managed-profile selection." }, "403": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorManagedSelectorResponse" + } + } }, "description": "Partner authorization or managed-profile authorization failed." }, @@ -4679,7 +8475,18 @@ "headers": {} } }, - "security": [{}, { "PublicApiKey": [] }, { "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + {}, + { + "PublicApiKey": [] + }, + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "Create a new quote", "tags": ["Quotes"] } @@ -4722,7 +8529,11 @@ "deprecated": false, "description": "Generates a new quote for the network that yields the highest output amount for the given parameters. This endpoint compares the output for a given input amount over all supported networks and returns the 'best' quote, defined as the one with the highest output. ", "operationId": "createBestQuote", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], "requestBody": { "content": { "application/json": { @@ -4890,13 +8701,21 @@ }, "401": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorManagedSelectorResponse" + } + } }, "description": "Invalid authentication or managed-profile selection." }, "403": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorManagedSelectorResponse" + } + } }, "description": "Partner authorization or managed-profile authorization failed." }, @@ -4936,7 +8755,18 @@ "headers": {} } }, - "security": [{}, { "PublicApiKey": [] }, { "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + {}, + { + "PublicApiKey": [] + }, + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "Create a quote for the best network", "tags": ["Quotes"] } @@ -4946,18 +8776,28 @@ "deprecated": false, "description": "Returns only sanitized per-corridor KYC state and buy/sell eligibility for the profile derived from the validated API credential. A manager secret may select one directly managed child with `X-Managed-Profile-Id`; public keys cannot use the selector. The endpoint never returns PII, provider/customer IDs, KYC failure reasons, bank/wallet data, ramp history, or exact financial limits. When both public and secret headers are supplied they must belong to the same credential. Supabase Bearer sessions do not authorize this endpoint.\n\n**Auth:** `X-Public-Key` or `X-API-Key`.", "operationId": "getRampInfo", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], "responses": { "200": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/RampInfoResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/RampInfoResponse" + } + } }, "description": "Sanitized corridor eligibility." }, "400": { "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/ApiCredentialManagedSelectorErrorResponse" } + "schema": { + "$ref": "#/components/schemas/ApiCredentialManagedSelectorErrorResponse" + } } }, "description": "Malformed key or wrong key type." @@ -4965,7 +8805,9 @@ "401": { "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/ApiCredentialManagedSelectorErrorResponse" } + "schema": { + "$ref": "#/components/schemas/ApiCredentialManagedSelectorErrorResponse" + } } }, "description": "Missing, invalid, expired, or revoked API credential." @@ -4973,13 +8815,22 @@ "403": { "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/ApiCredentialManagedSelectorErrorResponse" } + "schema": { + "$ref": "#/components/schemas/ApiCredentialManagedSelectorErrorResponse" + } } }, "description": "`CREDENTIAL_MISMATCH`: presented public and secret values belong to different credentials." } }, - "security": [{ "PublicApiKey": [] }, { "SecretApiKey": [] }], + "security": [ + { + "PublicApiKey": [] + }, + { + "SecretApiKey": [] + } + ], "summary": "Get sanitized ramp eligibility", "tags": ["Account Management"] } @@ -4989,7 +8840,9 @@ "deprecated": false, "description": "Fetches an updated ramp process.", "parameters": [ - { "$ref": "#/components/parameters/ManagedProfileId" }, + { + "$ref": "#/components/parameters/ManagedProfileId" + }, { "description": "Ramp ID.", "in": "path", @@ -5160,24 +9013,44 @@ }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } }, "description": "The managed-profile selector is invalid." }, "401": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorManagedSelectorResponse" + } + } }, "description": "Authentication is required for an owned ramp or managed-profile selection." }, "403": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorManagedSelectorResponse" + } + } }, "description": "Ramp ownership or managed-profile authorization failed." } }, - "security": [{}, { "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + {}, + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "Get ramp status", "tags": ["Ramp"] } @@ -5188,7 +9061,9 @@ "description": "Returns the chronological error log for a ramp.\n\n**Auth:** requires either `X-API-Key: sk_*` (partner) OR `Authorization: Bearer ` (user). Ownership is enforced.", "operationId": "getRampErrorLogs", "parameters": [ - { "$ref": "#/components/parameters/ManagedProfileId" }, + { + "$ref": "#/components/parameters/ManagedProfileId" + }, { "description": "Ramp ID.", "in": "path", @@ -5213,20 +9088,32 @@ }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } }, "description": "The managed-profile selector is invalid." }, "401": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorManagedSelectorResponse" + } + } }, "description": "Authentication required.", "headers": {} }, "403": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorManagedSelectorResponse" + } + } }, "description": "Ramp does not belong to authenticated principal.", "headers": {} @@ -5237,7 +9124,15 @@ "headers": {} } }, - "security": [{}, { "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + {}, + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "Get ramp error logs", "tags": ["Ramp"] } @@ -5247,7 +9142,9 @@ "deprecated": false, "description": "Fetches all non-initial ramps owned by the authenticated user across wallet addresses. Requires a Supabase session or user-scoped secret API key. Partner-only credentials are not sufficient.", "parameters": [ - { "$ref": "#/components/parameters/ManagedProfileId" }, + { + "$ref": "#/components/parameters/ManagedProfileId" + }, { "description": "The maximum count of transaction items returned in this query. The maximum value is `100`.", "in": "query", @@ -5283,14 +9180,29 @@ }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } }, "description": "The managed-profile selector is invalid." }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" } + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized" + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden" + } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "Get authenticated user ramp history", "tags": ["Ramp"] } @@ -5300,7 +9212,9 @@ "deprecated": false, "description": "Fetches the transaction history for a given wallet address. The response returns the last 20 items by default. This can be adjusted by using the `limit` and `offset` query parameters. ", "parameters": [ - { "$ref": "#/components/parameters/ManagedProfileId" }, + { + "$ref": "#/components/parameters/ManagedProfileId" + }, { "description": "The wallet address for which the ramp history is queried for.", "in": "path", @@ -5345,14 +9259,29 @@ }, "400": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } }, "description": "The managed-profile selector is invalid." }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, - "403": { "$ref": "#/components/responses/ManagedSelectorForbidden" } + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized" + }, + "403": { + "$ref": "#/components/responses/ManagedSelectorForbidden" + } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "Get ramp history for wallet address", "tags": ["Ramp"] } @@ -5362,7 +9291,11 @@ "deprecated": false, "description": "Initiates a new on-ramp or off-ramp process by providing quote details, signing accounts, and additional data.", "operationId": "registerRamp", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], "requestBody": { "content": { "application/json": { @@ -5645,10 +9578,16 @@ "description": "Bad Request - Invalid input, missing required fields, or validation error.", "headers": {} }, - "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized" }, + "401": { + "$ref": "#/components/responses/ManagedSelectorUnauthorized" + }, "403": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorManagedSelectorResponse" + } + } }, "description": "Quote ownership or managed-profile authorization failed." }, @@ -5667,7 +9606,14 @@ "headers": {} } }, - "security": [{ "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "Register new ramp process", "tags": ["Ramp"] } @@ -5677,7 +9623,11 @@ "deprecated": false, "description": "Starts a ramp process. \n\nIt is assumed all required information from the client has already been sent using the `update` endpoint. This endpoint is only used to tell the backend any external operation (like a bank transfer) has been completed, and the ramp can start.", "operationId": "startRamp", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], "requestBody": { "content": { "application/json": { @@ -5877,7 +9827,9 @@ } } }, - "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } + "schema": { + "$ref": "#/components/schemas/ErrorManagedSelectorResponse" + } } }, "description": "Bad Request. Possible reasons:\n- Missing required fields (rampId, presignedTxs)\n- Invalid additional data format (if provided, must be an object)", @@ -5885,13 +9837,21 @@ }, "401": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorManagedSelectorResponse" + } + } }, "description": "Authentication is required for an owned ramp or managed-profile selection." }, "403": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorManagedSelectorResponse" + } + } }, "description": "Ramp ownership or managed-profile authorization failed." }, @@ -5911,7 +9871,15 @@ "headers": {} } }, - "security": [{}, { "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + {}, + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "Start ramp process ", "tags": ["Ramp"] } @@ -5919,9 +9887,13 @@ "/v1/ramp/update": { "post": { "deprecated": false, - "description": "Submits presigned transactions and additional data to an existing ramp process before starting it. \nThis endpoint can be called many times, and data can be incrementally added to the ramp. \n\nNote: For both pre-signed transactions and the generic `additionalData` object, existing properties will be overriden by new values.\n\n### Required data for ramps.\nThe signed counterpart of the initial unsignedTxs object must be provided for all ramps, as required by the object.\nFor offramps, the `additionalData` field must contain the confirmation hash corresponding to the inital transaction in which the user sends the funds. \nIf the originating chain is `Assethub`, then `assetHubToPendulumHash` must be provided. \nIf the originating chain is any `EVM` chain, then `squidRouterSwapHash` must be provided. `squidRouterApproveHash` is only required when an approval transaction was actually submitted; if the wallet already holds a sufficient allowance for the router, it can be omitted. \n\nFor onramps, no additional data is required after registering the ramp.", + "description": "Submits presigned transactions and supported client-reported transaction hashes to an existing ramp process before starting it. \nThis endpoint can be called many times, and data can be incrementally added to the ramp. \n\nNote: For both pre-signed transactions and `additionalData`, existing properties will be overridden by new values.\n\n### Required data for ramps.\nThe signed counterpart of the initial unsignedTxs object must be provided for all ramps, as required by the object.\nFor offramps, the `additionalData` field must contain the confirmation hash corresponding to the initial transaction in which the user sends the funds. \nIf the originating chain is `AssetHub`, then `assethubToPendulumHash` must be provided. \nIf the originating chain is any EVM chain, then `squidRouterSwapHash` must be provided. `squidRouterApproveHash` is only required when an approval transaction was actually submitted; if the wallet already holds a sufficient allowance for the router, it can be omitted. No-permit flows use the corresponding `squidRouterNoPermit*Hash` fields.\n\nFor onramps, no additional data is required after registering the ramp.", "operationId": "updateRamp", - "parameters": [{ "$ref": "#/components/parameters/ManagedProfileId" }], + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], "requestBody": { "content": { "application/json": { @@ -6134,7 +10106,9 @@ } } }, - "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } + "schema": { + "$ref": "#/components/schemas/ErrorManagedSelectorResponse" + } } }, "description": "Bad Request. Possible reasons:\n- Missing required fields (rampId, presignedTxs)\n- Invalid additional data format (if provided, must be an object)", @@ -6142,13 +10116,21 @@ }, "401": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorManagedSelectorResponse" + } + } }, "description": "Authentication is required for an owned ramp or managed-profile selection." }, "403": { "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorManagedSelectorResponse" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorManagedSelectorResponse" + } + } }, "description": "Ramp ownership or managed-profile authorization failed." }, @@ -6168,14 +10150,22 @@ "headers": {} } }, - "security": [{}, { "SecretApiKey": [] }, { "BearerAuth": [] }], + "security": [ + {}, + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], "summary": "Update ramp process", "tags": ["Ramp"] } }, "/v1/session/create": { "post": { - "description": "Creates a hosted Vortex Widget session and returns the URL to open for the user.\n\nThis single endpoint supports two mutually exclusive request shapes:\n\n- **Fixed quote** (`GetWidgetUrlLocked`) \u2014 pass a `quoteId` you created via `POST /v1/quotes`. The widget uses that exact quote and does not refresh it. If the quote expires before the user finishes, they must close the window and start over.\n\n- **Auto-refresh** (`GetWidgetUrlRefresh`) \u2014 pass the route parameters (`network`, `rampType`, `inputAmount`, plus `fiat` / `cryptoLocked` / `paymentMethod` as relevant for the direction). The widget creates and refreshes quotes on demand for the user.\n\nUse the example switcher below to see the request shape for each mode. `externalSessionId` is required in both modes and is echoed back in webhook payloads.", + "description": "Creates a hosted Vortex Widget session and returns the URL to open for the user.\n\nThis single endpoint supports two mutually exclusive request shapes:\n\n- **Fixed quote** (`GetWidgetUrlLocked`) — pass a `quoteId` you created via `POST /v1/quotes`. The widget uses that exact quote and does not refresh it. If the quote expires before the user finishes, they must close the window and start over.\n\n- **Auto-refresh** (`GetWidgetUrlRefresh`) — pass the route parameters (`network`, `rampType`, `inputAmount`, plus `fiat` / `cryptoLocked` / `paymentMethod` as relevant for the direction). The widget creates and refreshes quotes on demand for the user.\n\nUse the example switcher below to see the request shape for each mode. `externalSessionId` is required in both modes and is echoed back in webhook payloads.", "parameters": [], "requestBody": { "content": { @@ -6331,7 +10321,7 @@ "type": "array" }, "emoji": { - "description": "e.g. \ud83c\udde9\ud83c\uddea", + "description": "e.g. 🇩🇪", "type": "string" }, "name": { @@ -6771,6 +10761,10 @@ "description": "User account, KYC, and BRLA subaccount operations.", "name": "Account Management" }, + { + "description": "Provider-specific identity and business verification operations and discovery metadata.", + "name": "KYC and KYB" + }, { "description": "Email OTP sign-in and user-linked API key provisioning.", "name": "Authentication" diff --git a/docs/api/pages/03-authentication-and-partner-keys.md b/docs/api/pages/03-authentication-and-partner-keys.md index 23a1dfd76..fb98bd9e5 100644 --- a/docs/api/pages/03-authentication-and-partner-keys.md +++ b/docs/api/pages/03-authentication-and-partner-keys.md @@ -17,6 +17,7 @@ Both values share one immutable credential ID, subject profile, optional partner | Ramp register/update/start/status/history/errors | No | Yes | Yes | | Act for an authorized managed child | No | Yes | Yes | | Manage a directly owned child's credentials | No | Yes | Yes | +| Import an Avenia individual-KYC share token | No | Yes | Yes | | Webhook management (non-managed subjects only) | No | Yes | No | | Profile-managed credential lifecycle | No | No | Yes | @@ -37,10 +38,14 @@ X-API-Key: sk_live_... X-Managed-Profile-Id: 00000000-0000-0000-0000-000000000002 ``` -A Supabase Bearer session may replace the secret key. A public `pk_*` value cannot authenticate delegation. Vortex verifies the active manager, direct active child relationship, child's single active customer entity, allowed country, optional customer-type narrowing, and canonical country/type support for corridor-bound mutations. An omitted or null customer-type policy adds no restriction beyond the canonical corridor capability matrix; a configured non-empty list only narrows that matrix. The manager remains the authenticated actor; ownership, KYC/provider lookup, quote pricing, and ramp history resolve from the child subject. +A Supabase Bearer session may replace the secret key. A public `pk_*` value cannot authenticate delegation. Vortex verifies the active manager, direct active child relationship, child's single active customer entity, allowed country, optional customer-type narrowing, and canonical country/type support for corridor-bound mutations. An omitted or null customer-type policy adds no restriction beyond the canonical corridor capability matrix; a configured non-empty list only narrows that matrix. The manager remains the authenticated actor; ownership, KYC/provider lookup, and ramp history resolve from the child subject. Quote pricing uses the child's active profile assignment when present, otherwise the controlling manager profile's active assignment, then default Vortex pricing. This precedence is identical for manager-delegated requests and direct child credentials. The header is supported for quote creation; ramp registration, update, start, status, history, and errors; exact limits and sanitized ramp info; aggregate onboarding status; BR customer/KYC operations; and customer creation, KYC/KYB, and fiat-account operations on the AR, CO, MX, and US corridors. Corridor removal blocks mutations and disallowed exact-limit requests but not quote discovery or historical/status reads. The EUR corridor's flows are bound to a verified login email, so they and all recipient-invitation routes do not support managed children. +`POST /v1/brla/kyc/import-token` is a deliberate exception to direct child credential access. A controlling manager may call it with the manager's secret key or Supabase session plus `X-Managed-Profile-Id`, but a credential owned by the managed child is rejected with `403 MANAGED_PROFILE_ACCESS_DENIED`, even without the selector. Direct non-managed profiles may import for themselves with their own secret key or session. Public keys and ownerless credentials cannot import. + +Authentication, direct-child rejection, and managed authorization run before strict validation of `Idempotency-Key` and the request body. An unauthenticated caller therefore receives an authentication error rather than learning whether a bearer-like personal-data transfer token or attestation is well formed. The request has no profile, user, CPF, subaccount, applicant, entity, or provider-customer selector in its body or query; identity is derived only from the authenticated effective profile. + Webhook registration and deletion do not support managed children. `X-Managed-Profile-Id` returns `400 MANAGED_PROFILE_UNSUPPORTED`, and a direct child credential returns `403 MANAGED_PROFILE_ACCESS_DENIED`. Managed-child integrations must poll the child-scoped ramp status/history endpoints. A manager credential without the selector remains manager-owned and therefore cannot register a webhook for a child-owned quote. `X-Managed-Profile-Id` is only a selector. Supplying another manager's child, an inactive/deleted child, a child with an invalid entity layout, or a disallowed mutation corridor returns `403 MANAGED_PROFILE_ACCESS_DENIED`. diff --git a/docs/api/pages/06-quotes-and-pricing.md b/docs/api/pages/06-quotes-and-pricing.md index 5b1f91569..83850900c 100644 --- a/docs/api/pages/06-quotes-and-pricing.md +++ b/docs/api/pages/06-quotes-and-pricing.md @@ -112,4 +112,6 @@ Quotes are immutable and short-lived. If the user takes too long to confirm, or Pass the credential's public value through `X-Public-Key` to apply partner pricing and attribution. The SDK also retains it in the quote body for compatibility. When `X-Public-Key` and `X-API-Key` are both present, they must belong to the same credential or Vortex returns `403 CREDENTIAL_MISMATCH`. See [Authentication And API Credentials](https://api-docs.vortexfinance.co/authentication-and-partner-keys). +Managed profiles default to the controlling manager profile's pricing assignment. Assigning pricing directly to a managed child overrides the manager's pricing just as a profile assignment does for any regular profile. The same precedence applies whether the manager delegates with `X-Managed-Profile-Id` or the child authenticates with its own credential: child assignment, manager assignment, then default Vortex pricing. + --- diff --git a/docs/api/pages/09-fiat-corridors.md b/docs/api/pages/09-fiat-corridors.md index e9e91abed..d766c93d8 100644 --- a/docs/api/pages/09-fiat-corridors.md +++ b/docs/api/pages/09-fiat-corridors.md @@ -10,6 +10,56 @@ Level 1 onboarding collects basic identity information and enables lower-limit B A normal partner key cannot select an arbitrary user. An enabled managed-profile manager may use a secret `sk_*` key or Supabase session with `X-Managed-Profile-Id` to drive supported BRLA KYC operations for its directly managed child when the manager has the `BR` corridor and the child's immutable type is allowed by both current manager policy and Vortex's BR capability matrix. A null manager customer-type policy adds no further restriction. A public `pk_*` key is insufficient. When possible, use the Vortex application or hosted widget to complete onboarding before ramp execution. Business users can be sent straight into verification with the [KYB Deep Link](https://api-docs.vortexfinance.co/kyb-deep-link). +### Individual KYC By Sumsub Share Token + +An API-only alternative can import a caller-supplied Sumsub share token into an existing individual Avenia account. The path is enabled under approved Vortex policy even though final legal/consent wording, Avenia environment enablement, recipient IDs, and provider retry confirmations remain unresolved. This documentation does not claim that a live sandbox import has been verified. + +The direct profile or controlling manager first provisions exactly one active Brazilian individual Avenia customer through the normal account flow. Import the token before reading KYC or aggregate onboarding status: a status read permanently selects a still-null method as `standard`, after which token import returns `409`. Then call: + +```http +POST /v1/brla/kyc/import-token +X-API-Key: sk_live_... +X-Managed-Profile-Id: 00000000-0000-0000-0000-000000000002 +Idempotency-Key: kyc-import-018f... +Content-Type: application/json + +{ + "importToken": "", + "consentAttested": true +} +``` + +Use either a profile-bound secret key or a Supabase Bearer session. Omit `X-Managed-Profile-Id` for a direct non-managed profile. For a managed child, only its controlling manager may import with the selector; direct managed-child credentials are rejected. Authentication and authorization happen before strict body validation, and Vortex transactionally rechecks the manager's active status, exact active relationship, current BR and individual permissions, and the child's active entity before preparing or submitting the import. Revocation before submission prevents the Avenia import call. The body allows exactly the two fields shown, `importToken` must contain 1 to 1024 UTF-8 bytes, and `consentAttested` must be literal `true`. Do not send CPF, tax ID, `subAccountId`, Sumsub applicant ID, profile/entity IDs, provider-customer IDs, or any other identity selector. + +Vortex records every token-claim attestation in the case's submission JSON as an append-only actor, subject, timestamp, and provisional consent-policy entry. A provider-`401` retry under a new key appends rather than replacing the earlier evidence. These attestations are not a substitute for the caller's legal basis, applicant disclosures, biometric or special-category consent, or cross-organization transfer obligations. + +A token-import claim returns `202 Accepted`: + +```json +{ + "attemptId": "", + "status": "pending" +} +``` + +`pending` means only that Avenia accepted the import. After `202`, clients poll the authenticated aggregate onboarding-status endpoint or `GET /v1/brla/getKycStatus?taxId=`; `attemptId` is correlation data from the accepted response, not public status-poll input. Vortex polls that exact attempt internally rather than the latest account attempt. `PENDING` remains pending, `PROCESSING` is in review, and `COMPLETED + REJECTED` is rejected. `EXPIRED` remains non-approved and locally pending for reconciliation while Vortex retains the external `EXPIRED` status. Only Avenia `COMPLETED + APPROVED` completes KYC. Sumsub status, token possession, the `202` response, client assertions, and the Avenia webhook cannot approve onboarding; the webhook is notification-only. + +#### Method Lock And Safe Retries + +The first normal document/liveness artifact or normal submission permanently selects the `standard` method. A token-import claim permanently selects `sumsub_share_token` and blocks every normal KYC mutation. Vortex serializes this immutable choice on the canonical KYC case. Pre-provider failures mark the submission failed but do not unlock or change the selected method. Standard submission also uses durable `prepared`, `submitted`, `confirmed`, and `ambiguous` state so retries reconcile an existing provider attempt instead of blindly creating another. Its identity payload and echoed provider errors are omitted from logs and public errors. + +- Repeating a confirmed request with the same idempotency key and token returns the stored attempt and does not call Avenia again. +- Reusing a key with another token returns `409 The idempotency key was used with a different token`. +- A prior failed request returns `409 A failed token import requires a new idempotency key`. This includes a failed pre-provider attempt-baseline read even though Vortex did not send the token, and the provider-`401` case described below. +- Repeating the same key and token for a submitted or ambiguous claim may safely reconcile the durable claim through Avenia status/history reads and return the exact attempt; this never repeats the provider token-import POST or sends the token again. +- Concurrent claims and outcomes that cannot be uniquely reconciled return a stable `409` or `502` reconciliation error. Do not switch keys or replay the token. +- Avenia provider `401` returns `412 Avenia token import is not enabled`. This is the sole post-send outcome classified as failed/retriable, and retry requires a new idempotency key. +- Every other initial post-send provider error, timeout, transport failure, malformed success, provider 5xx, or local confirmation failure returns `502 The Avenia token import outcome requires reconciliation`. Never retry the token automatically or under a new key; only a same-key/same-token reconciliation request is safe. + +Other prerequisite and immutable-method conflicts return `409` with a stable, non-secret `error` string. Invalid strict input returns `400`. Authentication and selector failures retain the structured authentication errors documented in [Authentication And API Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys). + +Treat the share token as a secret. Keep it only long enough to make this request. Never place it in a URL, database, file, browser storage, analytics, traces, metrics labels, logs, screenshots, support tickets, or error reports. Vortex keeps it only in request memory for the provider exchange and stores a SHA-256 digest for idempotent input comparison. + ## USD, MXN, COP, ARS (Bank Transfers) These corridors settle through Vortex's local payment partners over domestic banking rails. In quote requests, the rail identifier takes the place of a network in `from` (buy) or `to` (sell): diff --git a/docs/api/pages/10-sandbox.md b/docs/api/pages/10-sandbox.md index cd568cd7e..1d7ea45a4 100644 --- a/docs/api/pages/10-sandbox.md +++ b/docs/api/pages/10-sandbox.md @@ -33,7 +33,7 @@ To simplify testing, we have pre-configured accounts that are already whiteliste ### Euro Onramps - **Login Method**: Sign in using an EVM wallet. -- **Test Wallet**: Use your own wallet and fund it from public testnet faucets. +- **Test Wallet**: Use your own test wallet and fund it from a public testnet faucet. Never use or publish a shared recovery phrase. ### Euro Offramps - **Login Method**: Use an email address. diff --git a/docs/api/scripts/check-openapi.ts b/docs/api/scripts/check-openapi.ts index 81a038300..5166fe814 100644 --- a/docs/api/scripts/check-openapi.ts +++ b/docs/api/scripts/check-openapi.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { ONBOARDING_REQUIREMENTS } from "../../../packages/shared/src/endpoints/onboarding-requirements.endpoints"; const OPENAPI_FILE = "docs/api/openapi/vortex.openapi.json"; const GENERATED_TYPES_FILE = "docs/api/openapi/vortex.openapi.d.ts"; @@ -34,10 +35,17 @@ const REQUIRED_PATHS = [ "/v1/brla/getUser", "/v1/brla/getUserRemainingLimit", "/v1/brla/kyb/attempt-status", + "/v1/brla/kyb/documents", + "/v1/brla/kyb/documents/{documentId}", + "/v1/brla/kyb/new-level-1/api", "/v1/brla/kyb/new-level-1/web-sdk", + "/v1/brla/kyb/ubos", + "/v1/brla/kyc/import-token", "/v1/brla/kyc/record-attempt", "/v1/brla/newKyc", "/v1/brla/validatePixKey", + "/v1/onboarding/requirements", + "/v1/onboarding/active-entity", "/v1/managed-profiles", "/v1/managed-profiles/{profileId}", "/v1/managed-profiles/{profileId}/api-credentials", @@ -76,6 +84,49 @@ const MANAGED_PROFILE_OPERATIONS = [ const MANAGED_PROFILE_SECURITY = [{ SecretApiKey: [] }, { BearerAuth: [] }] as const; +const BRLA_RECONCILIATION_OPERATIONS = [ + ["/v1/brla/getKycStatus", "get"], + ["/v1/brla/getSelfieLivenessUrl", "get"], + ["/v1/brla/getUploadUrls", "post"], + ["/v1/brla/newKyc", "post"] +] as const; + +const BRLA_IMPORT_KYC_TOKEN_ERRORS = [ + "Idempotency-Key must contain 1 to 128 visible ASCII characters", + "Invalid request body", + "importToken must contain between 1 and 1024 bytes", + "consentAttested must be true", + "The subject profile has no active customer entity", + "The managed subject does not match the expected customer entity", + "A managed profile requires a managed customer entity context", + "The subject customer entity is not active", + "Avenia token import is only available for individuals", + "Exactly one active Brazilian individual Avenia customer is required", + "Multiple Avenia customers require reconciliation", + "The Avenia subaccount is not provisioned", + "The Avenia customer is already approved", + "The canonical Avenia KYC case is missing", + "Multiple Avenia KYC cases require reconciliation", + "The Avenia KYC case is already approved", + "The confirmed token import is missing its provider attempt", + "The idempotency key was used with a different token", + "The token import does not match this request", + "A failed token import requires a new idempotency key", + "The Avenia KYC is already approved", + "The previous token import outcome requires reconciliation", + "Another token import requires reconciliation", + "This KYC case uses the standard Avenia method", + "The Avenia token import attempt is invalid", + "The token import attempt requires reconciliation", + "The token import was already claimed", + "The token import binding is no longer current", + "The authenticated profile cannot perform this operation for the requested managed profile", + "Avenia token import pre-provider checks failed", + "Avenia token import is not enabled", + "The Avenia token import outcome requires reconciliation", + "Token import failed" +] as const; + const MANAGED_PROFILE_PATHS = [ "/v1/managed-profiles", "/v1/managed-profiles/{profileId}", @@ -113,7 +164,12 @@ const MANAGED_SELECTOR_SECURITY_OPERATIONS = [ ["/v1/brla/getSelfieLivenessUrl", "get"], ["/v1/brla/getUploadUrls", "post"], ["/v1/brla/kyb/attempt-status", "get"], + ["/v1/brla/kyb/documents", "post"], + ["/v1/brla/kyb/documents/{documentId}", "get"], + ["/v1/brla/kyb/new-level-1/api", "post"], ["/v1/brla/kyb/new-level-1/web-sdk", "post"], + ["/v1/brla/kyb/ubos", "post"], + ["/v1/brla/kyc/import-token", "post"], ["/v1/brla/kyc/record-attempt", "post"], ["/v1/brla/newKyc", "post"], ["/v1/onboarding/status", "get"] @@ -147,7 +203,11 @@ const DELEGATED_OPERATIONS = [ ["/v1/brla/getUser", "get"], ["/v1/brla/getUserRemainingLimit", "get"], ["/v1/brla/kyb/attempt-status", "get"], + ["/v1/brla/kyb/documents", "post"], + ["/v1/brla/kyb/documents/{documentId}", "get"], + ["/v1/brla/kyb/new-level-1/api", "post"], ["/v1/brla/kyb/new-level-1/web-sdk", "post"], + ["/v1/brla/kyb/ubos", "post"], ["/v1/brla/kyc/record-attempt", "post"], ["/v1/brla/newKyc", "post"], ["/v1/limits", "post"], @@ -238,6 +298,44 @@ function transitivelyReferences(value: unknown, target: string, seen = new Set()): boolean { + if (!schema || typeof schema !== "object") return false; + + const schemaObject = schema as JsonObject; + const ref = schemaObject.$ref; + if (typeof ref === "string" && !seen.has(ref)) { + seen.add(ref); + if (schemaHasProperty(valueAtPointer(openapi, ref), property, seen)) return true; + } + + const properties = schemaObject.properties; + if (properties && typeof properties === "object" && property in properties) return true; + + for (const composition of [schemaObject.allOf, schemaObject.anyOf, schemaObject.oneOf]) { + if (Array.isArray(composition) && composition.some(part => schemaHasProperty(part, property, seen))) return true; + } + + return false; +} + +function operationHasQueryParameter(operation: JsonObject, name: string): boolean { + const parameters = operation.parameters; + if (!Array.isArray(parameters)) return false; + + return parameters.some(parameter => { + const resolved = + parameter && typeof parameter === "object" && typeof (parameter as JsonObject).$ref === "string" + ? valueAtPointer(openapi, (parameter as JsonObject).$ref as string) + : parameter; + return Boolean( + resolved && + typeof resolved === "object" && + (resolved as JsonObject).in === "query" && + (resolved as JsonObject).name === name + ); + }); +} + async function checkGeneratedTypes(): Promise { const currentDeclarations = readFileSync(GENERATED_TYPES_FILE, "utf8"); const proc = Bun.spawn(["bun", GENERATOR_FILE], { stderr: "pipe", stdout: "pipe" }); @@ -448,6 +546,25 @@ if ( ); } +const updateRampAdditionalData = ((schemas.UpdateRampRequest as JsonObject).properties as JsonObject) + .additionalData as JsonObject; +const updateRampAdditionalDataProperties = (updateRampAdditionalData.properties ?? {}) as JsonObject; +const expectedUpdateRampAdditionalDataFields = [ + "assethubToPendulumHash", + "squidRouterApproveHash", + "squidRouterNoPermitApproveHash", + "squidRouterNoPermitSwapHash", + "squidRouterNoPermitTransferHash", + "squidRouterSwapHash" +]; +if ( + updateRampAdditionalData.additionalProperties !== false || + JSON.stringify(Object.keys(updateRampAdditionalDataProperties).sort()) !== + JSON.stringify(expectedUpdateRampAdditionalDataFields.sort()) +) { + throw new Error("UpdateRampRequest.additionalData must exactly match the runtime client-writable hash allowlist."); +} + const managedProfileHeaderRef = "#/components/parameters/ManagedProfileId"; if (!pointerExists(openapi, managedProfileHeaderRef)) { throw new Error(`OpenAPI file is missing reusable managed-profile header: ${managedProfileHeaderRef}`); @@ -499,6 +616,14 @@ for (const [path, method] of DELEGATED_OPERATIONS.filter(([path]) => path.starts throw new Error(`${method.toUpperCase()} ${path} must preserve the controller's flat BRLA 400 error shape.`); } } +for (const [path, method] of BRLA_RECONCILIATION_OPERATIONS) { + const responses = operationAt(path, method).responses as JsonObject; + for (const status of ["409", "502"]) { + if (!transitivelyReferences(responses[status], "#/components/schemas/BrlaErrorResponse")) { + throw new Error(`${method.toUpperCase()} ${path} ${status} must preserve the flat BRLA reconciliation error shape.`); + } + } +} for (const [path, method, status, controllerSchema] of [ ["/v1/limits", "post", "400", "#/components/schemas/FlatErrorResponse"], ["/v1/limits", "post", "403", "#/components/schemas/FlatErrorResponse"], @@ -583,14 +708,92 @@ if ( } const recordAttempt = operationAt("/v1/brla/kyc/record-attempt", "post"); -const recordAttemptSchema = ( +const recordAttemptSchemaRef = ( ((recordAttempt.requestBody as JsonObject).content as JsonObject)["application/json"] as JsonObject ).schema as JsonObject; -const recordAttemptRequired = recordAttemptSchema.required as unknown[]; +if (recordAttemptSchemaRef.$ref !== "#/components/schemas/RecordInitialKycAttemptRequest") { + throw new Error("POST /v1/brla/kyc/record-attempt must reference the RecordInitialKycAttemptRequest schema."); +} +const recordAttemptRequired = (schemas.RecordInitialKycAttemptRequest as JsonObject).required as unknown[]; if (!recordAttemptRequired.includes("quoteId") || !recordAttemptRequired.includes("taxId")) { throw new Error("POST /v1/brla/kyc/record-attempt must require the shared quoteId and taxId request fields."); } +const importKycToken = operationAt("/v1/brla/kyc/import-token", "post"); +const importKycTokenParameters = importKycToken.parameters as JsonObject[]; +const importKycTokenIdempotency = importKycTokenParameters.find(parameter => parameter.name === "Idempotency-Key"); +const importKycTokenManagedSelector = importKycTokenParameters.find(parameter => parameter.$ref === managedProfileHeaderRef); +const importKycTokenRequest = schemas.BrlaImportKycTokenRequest as JsonObject; +const importKycTokenRequestProperties = importKycTokenRequest.properties as JsonObject; +const importKycTokenResponses = importKycToken.responses as JsonObject; +const importKycTokenDescription = String(importKycToken.description); +const importKycTokenError = ((schemas.BrlaImportKycTokenErrorResponse as JsonObject).properties as JsonObject) + .error as JsonObject; +const malformedJsonError = schemas.MalformedJsonErrorResponse as JsonObject; +const payloadTooLargeError = schemas.PayloadTooLargeErrorResponse as JsonObject; +if ( + JSON.stringify(importKycToken.security) !== JSON.stringify([{ SecretApiKey: [] }, { BearerAuth: [] }]) || + !importKycTokenManagedSelector || + importKycTokenIdempotency?.in !== "header" || + importKycTokenIdempotency.required !== true || + (importKycTokenIdempotency.schema as JsonObject)?.pattern !== "^[!-~]+$" || + importKycTokenRequest.additionalProperties !== false || + JSON.stringify(Object.keys(importKycTokenRequestProperties).sort()) !== JSON.stringify(["consentAttested", "importToken"]) || + JSON.stringify((importKycTokenRequest.required as string[]).sort()) !== JSON.stringify(["consentAttested", "importToken"]) || + (importKycTokenRequestProperties.consentAttested as JsonObject).const !== true || + (importKycTokenRequestProperties.importToken as JsonObject)["x-maxBytes"] !== 1024 || + "maxLength" in (importKycTokenRequestProperties.importToken as JsonObject) || + JSON.stringify(Object.keys(importKycTokenResponses).sort()) !== + JSON.stringify(["202", "400", "401", "403", "409", "412", "413", "500", "502", "503"]) || + !transitivelyReferences(importKycTokenResponses["202"], "#/components/schemas/BrlaImportKycTokenResponse") || + !transitivelyReferences(importKycTokenResponses["400"], "#/components/schemas/ManagedSelectorErrorResponse") || + !transitivelyReferences(importKycTokenResponses["400"], "#/components/schemas/MalformedJsonErrorResponse") || + !transitivelyReferences(importKycTokenResponses["401"], "#/components/schemas/ManagedSelectorErrorResponse") || + !transitivelyReferences(importKycTokenResponses["403"], "#/components/schemas/ManagedSelectorErrorResponse") || + !transitivelyReferences(importKycTokenResponses["403"], "#/components/schemas/BrlaImportKycTokenErrorResponse") || + !transitivelyReferences(importKycTokenResponses["409"], "#/components/schemas/BrlaImportKycTokenErrorResponse") || + !transitivelyReferences(importKycTokenResponses["412"], "#/components/schemas/BrlaImportKycTokenErrorResponse") || + !transitivelyReferences(importKycTokenResponses["413"], "#/components/schemas/PayloadTooLargeErrorResponse") || + !transitivelyReferences(importKycTokenResponses["500"], "#/components/schemas/BrlaImportKycTokenErrorResponse") || + !transitivelyReferences(importKycTokenResponses["500"], "#/components/schemas/ErrorResponse") || + !transitivelyReferences(importKycTokenResponses["502"], "#/components/schemas/BrlaImportKycTokenErrorResponse") || + JSON.stringify(importKycTokenError.enum) !== JSON.stringify(BRLA_IMPORT_KYC_TOKEN_ERRORS) || + JSON.stringify(malformedJsonError.required) !== JSON.stringify(["code", "message", "statusCode", "type"]) || + ((malformedJsonError.properties as JsonObject).code as JsonObject).const !== 400 || + ((malformedJsonError.properties as JsonObject).message as JsonObject).const !== "Invalid JSON payload" || + ((malformedJsonError.properties as JsonObject).statusCode as JsonObject).const !== 400 || + ((malformedJsonError.properties as JsonObject).type as JsonObject).const !== "entity.parse.failed" || + JSON.stringify(payloadTooLargeError.required) !== JSON.stringify(["code", "message", "statusCode", "type"]) || + ((payloadTooLargeError.properties as JsonObject).code as JsonObject).const !== 413 || + ((payloadTooLargeError.properties as JsonObject).message as JsonObject).const !== "Request body too large" || + ((payloadTooLargeError.properties as JsonObject).statusCode as JsonObject).const !== 413 || + ((payloadTooLargeError.properties as JsonObject).type as JsonObject).const !== "entity.too.large" +) { + throw new Error( + "POST /v1/brla/kyc/import-token must preserve its strict body, idempotency, auth, and stable response contract." + ); +} +for (const requiredStatement of [ + "Authentication and profile-bound principal enforcement run before managed-profile authorization and strict body validation", + "direct managed-child credentials are rejected", + "sumsub-share-v1", + "Import the token before reading KYC or onboarding status", + "preserving prior attestations", + "safely reconciles a durable submitted/ambiguous claim", + "provider `401`", + "Every other post-send", + "never replayed automatically", + "exact returned Avenia attempt", + "`EXPIRED` remains non-approved and locally pending for reconciliation", + "external status is retained", + "notification-only", + "no live sandbox verification is claimed" +]) { + if (!importKycTokenDescription.includes(requiredStatement)) { + throw new Error(`POST /v1/brla/kyc/import-token must document: ${requiredStatement}`); + } +} + function queryParameter(path: string, name: string): JsonObject | undefined { const parameters = operationAt(path, "get").parameters; if (!Array.isArray(parameters)) return undefined; @@ -654,6 +857,72 @@ if (unresolvedRefs.length > 0) { throw new Error(`OpenAPI file has unresolved local refs:\n${unresolvedRefs.join("\n")}`); } +const documentedOperations = new Map(); +for (const [path, pathItem] of Object.entries(openapi.paths as JsonObject)) { + if (!pathItem || typeof pathItem !== "object") continue; + for (const [method, operation] of Object.entries(pathItem as JsonObject)) { + if (!operation || typeof operation !== "object") continue; + const operationId = (operation as JsonObject).operationId; + if (typeof operationId !== "string") continue; + if (documentedOperations.has(operationId)) { + throw new Error(`OpenAPI operationId is duplicated: ${operationId}`); + } + documentedOperations.set(operationId, { method: method.toUpperCase(), path }); + } +} + +for (const flows of Object.values(ONBOARDING_REQUIREMENTS)) { + for (const requirements of Object.values(flows)) { + if (!requirements) continue; + if ("fields" in requirements) { + throw new Error("Onboarding discovery must not duplicate request fields outside OpenAPI."); + } + if (requirements.steps.some(step => step.method === "GET")) { + throw new Error("Onboarding discovery must not advertise GET operations."); + } + for (const step of requirements.steps) { + if (step.kind !== "api" || !step.operationId || !step.method || !step.path) continue; + const operation = documentedOperations.get(step.operationId); + if (!operation) { + throw new Error(`Onboarding discovery references missing OpenAPI operationId: ${step.operationId}`); + } + if (operation.method !== step.method || operation.path !== step.path) { + throw new Error( + `Onboarding discovery operation ${step.operationId} maps to ${step.method} ${step.path}, not ${operation.method} ${operation.path}` + ); + } + if (step.requestSchema && !pointerExists(openapi, step.requestSchema)) { + throw new Error(`Onboarding discovery references missing OpenAPI schema: ${step.requestSchema}`); + } + for (const field of Object.keys(step.fixedBody ?? {})) { + if (!step.requestSchema || !schemaHasProperty(valueAtPointer(openapi, step.requestSchema), field)) { + throw new Error(`Onboarding discovery operation ${step.operationId} fixes unknown body field: ${field}`); + } + } + for (const field of Object.keys(step.fixedQuery ?? {})) { + if (!operationHasQueryParameter(operationAt(step.path, step.method.toLowerCase()), field)) { + throw new Error(`Onboarding discovery operation ${step.operationId} fixes unknown query field: ${field}`); + } + } + for (const target of Object.keys(step.derivedValues ?? {})) { + const [location, field, ...rest] = target.split("."); + const validBodyTarget = + location === "body" && + rest.length === 0 && + step.requestSchema && + schemaHasProperty(valueAtPointer(openapi, step.requestSchema), field); + const validQueryTarget = + location === "query" && + rest.length === 0 && + operationHasQueryParameter(operationAt(step.path, step.method.toLowerCase()), field); + if (!validBodyTarget && !validQueryTarget) { + throw new Error(`Onboarding discovery operation ${step.operationId} derives unknown request target: ${target}`); + } + } + } + } +} + await checkGeneratedTypes(); const manifest = readJson(MANIFEST_FILE); diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index ae2380ddc..294472105 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -127,7 +127,6 @@ AlfredpayGetKybStatusResponse: { AlfredpayGetKycRedirectLinkRequest: { country: string; - type?: AlfredpayCustomerType.BUSINESS | AlfredpayCustomerType.INDIVIDUAL; } AlfredpayGetKycRedirectLinkResponse: { @@ -210,6 +209,7 @@ AlfredpayRetryKycRequest: { AlfredpayStatusRequest: { country: string; + type?: AlfredpayCustomerType.BUSINESS | AlfredpayCustomerType.INDIVIDUAL; } AlfredpayStatusResponse: { @@ -386,7 +386,7 @@ AveniaKYCDataUpload: { } AveniaKYCDataUploadRequest: { - documentType: enum AveniaDocumentType { DRIVERS_LICENSE = "DRIVERS-LICENSE", ID = "ID", PASSPORT = "PASSPORT", SELFIE = "SELFIE", SELFIE_FROM_LIVENESS = "SELFIE-FROM-LIVENESS" }; + documentType: enum AveniaDocumentType { CERTIFICATE_OF_INCORPORATION = "CERTIFICATE-OF-INCORPORATION", COMPANY_TAX_IDENTIFICATION_DOCUMENT = "COMPANY-TAX-IDENTIFICATION-DOCUMENT", DRIVERS_LICENSE = "DRIVERS-LICENSE", ID = "ID", PASSPORT = "PASSPORT", RESIDENCE_PERMIT = "RESIDENCE-PERMIT", SELFIE = "SELFIE", SELFIE_FROM_LIVENESS = "SELFIE-FROM-LIVENESS" }; isDoubleSided?: boolean; taxId: string; } @@ -419,15 +419,13 @@ BrlaErrorResponse: { } BrlaGetKycStatusRequest: { - quoteId: string; - sessionId?: string; taxId: string; } BrlaGetKycStatusResponse: { failureReason?: KycFailureReason.BIRTHDATE | KycFailureReason.FACE | KycFailureReason.NAME | KycFailureReason.TAX_ID | KycFailureReason.UNKNOWN; level: string; - result: enum KycAttemptResult { APPROVED = "APPROVED", REJECTED = "REJECTED" }; + result?: KycAttemptResult.APPROVED | KycAttemptResult.REJECTED; status: enum KycAttemptStatus { COMPLETED = "COMPLETED", EXPIRED = "EXPIRED", PENDING = "PENDING", PROCESSING = "PROCESSING" }; type: "KYC"; } @@ -472,6 +470,16 @@ BrlaGetUserResponse: { subAccountId: string; } +BrlaImportKycTokenRequest: { + consentAttested: true; + importToken: string; +} + +BrlaImportKycTokenResponse: { + attemptId: string; + status: "pending"; +} + BrlaKYCDocType: enum BrlaKYCDocType { CNH = "CNH", RG = "RG" } BrlaPostRecordInitialKycAttemptRequest: { @@ -640,6 +648,47 @@ FiatCurrency: FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | F FlowType: OfframpHandlerType.ASSETHUB_TO_BRLA | OfframpHandlerType.EVM_TO_BRLA | OnrampHandlerType.BRLA_TO_ASSETHUB | OnrampHandlerType.BRLA_TO_EVM +GetOnboardingRequirementsErrorResponse: { + error: { + code: "INVALID_ONBOARDING_REQUIREMENTS_QUERY" | "ONBOARDING_REQUIREMENTS_NOT_FOUND"; + message: string; + status: 400 | 404; + }; +} + +GetOnboardingRequirementsResponse: { + country: "AR" | "BR" | "CO" | "MX" | "US"; + customerType: "business" | "individual"; + documentationUrl: string; + documents: Array<{ + acceptedMediaTypes?: Array; + collection?: "direct-upload" | "hosted"; + description?: string; + required: boolean; + requiredWhen?: string; + type: string; + }>; + flow: string; + mode: "api" | "hosted" | "hybrid"; + openapiUrl: string; + provider: "alfredpay" | "avenia"; + requirementsVersion: string; + steps: Array<{ + condition?: string; + derivedValues?: Record; + description: string; + fixedBody?: Record; + fixedQuery?: Record; + kind: "api" | "direct-upload" | "hosted"; + method?: "POST" | "PUT"; + operationId?: string; + order: number; + path?: string; + repeatFor?: string; + requestSchema?: string; + }>; +} + GetQuoteRequest: { id: string; } @@ -998,8 +1047,71 @@ MoonpayPriceResponse: { totalFee: number; } +ONBOARDING_REQUIREMENTS: Record<"AR" | "BR" | "CO" | "MX" | "US", Partial; + collection?: "direct-upload" | "hosted"; + description?: string; + required: boolean; + requiredWhen?: string; + type: string; + }>; + flow: string; + mode: "api" | "hosted" | "hybrid"; + openapiUrl: string; + provider: "alfredpay" | "avenia"; + requirementsVersion: string; + steps: Array<{ + condition?: string; + derivedValues?: Record; + description: string; + fixedBody?: Record; + fixedQuery?: Record; + kind: "api" | "direct-upload" | "hosted"; + method?: "POST" | "PUT"; + operationId?: string; + order: number; + path?: string; + repeatFor?: string; + requestSchema?: string; + }>; +}>>> + OfframpHandlerType: enum OfframpHandlerType { ASSETHUB_TO_BRLA = "assethub-to-brla", EVM_TO_BRLA = "evm-to-brla" } +OnboardingDocumentRequirement: { + acceptedMediaTypes?: Array; + collection?: "direct-upload" | "hosted"; + description?: string; + required: boolean; + requiredWhen?: string; + type: string; +} + +OnboardingFlowMode: "api" | "hosted" | "hybrid" + +OnboardingRequirementStep: { + condition?: string; + derivedValues?: Record; + description: string; + fixedBody?: Record; + fixedQuery?: Record; + kind: "api" | "direct-upload" | "hosted"; + method?: "POST" | "PUT"; + operationId?: string; + order: number; + path?: string; + repeatFor?: string; + requestSchema?: string; +} + +OnboardingRequirementsCountry: "AR" | "BR" | "CO" | "MX" | "US" + +OnboardingStepKind: "api" | "direct-upload" | "hosted" + OnrampHandlerType: enum OnrampHandlerType { BRLA_TO_ASSETHUB = "brla-to-assethub", BRLA_TO_EVM = "brla-to-evm" } PaymentData: { @@ -2009,7 +2121,6 @@ UnsignedTx: { UpdateRampRequest: { additionalData?: { - [key: string]: unknown; assethubToPendulumHash?: string; squidRouterApproveHash?: string; squidRouterNoPermitApproveHash?: string; @@ -2312,6 +2423,39 @@ WebhookPayloadBase: { transactionType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; } +getOnboardingRequirements: (country: "AR" | "BR" | "CO" | "MX" | "US", customerType: "business" | "individual") => undefined | { + country: "AR" | "BR" | "CO" | "MX" | "US"; + customerType: "business" | "individual"; + documentationUrl: string; + documents: Array<{ + acceptedMediaTypes?: Array; + collection?: "direct-upload" | "hosted"; + description?: string; + required: boolean; + requiredWhen?: string; + type: string; + }>; + flow: string; + mode: "api" | "hosted" | "hybrid"; + openapiUrl: string; + provider: "alfredpay" | "avenia"; + requirementsVersion: string; + steps: Array<{ + condition?: string; + derivedValues?: Record; + description: string; + fixedBody?: Record; + fixedQuery?: Record; + kind: "api" | "direct-upload" | "hosted"; + method?: "POST" | "PUT"; + operationId?: string; + order: number; + path?: string; + repeatFor?: string; + requestSchema?: string; + }>; +} + isEvmTransactionData: (data: Array<{ domain: { chainId?: number; diff --git a/docs/proposal-api-driven-kyc-kyb.md b/docs/proposal-api-driven-kyc-kyb.md new file mode 100644 index 000000000..dc7c8a58d --- /dev/null +++ b/docs/proposal-api-driven-kyc-kyb.md @@ -0,0 +1,263 @@ +# Proposal: API-Driven KYC and KYB + +Status: proposed direction, with the initial requirements discovery contract and KYC/KYB +OpenAPI coverage implemented. This document records the intended direction for API-driven +customer verification while preserving the provider-specific endpoints and workflows used by +current Vortex consumers. Last updated: 2026-08-11. + +Related material: + +- [`Proposal: Managed Headless Profiles`](proposal-headless-profiles-and-pricing-plans.md) +- [`Identity, Customer, and Partner Model`](architecture-identity-model.md) +- [`Vortex API Docs Source`](api/README.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 another Vortex UI where the provider supports an API-driven flow. + +Vortex will preserve the existing provider-specific endpoints, request contracts, and call +sequences. They already support the Dashboard and other first-party consumers, and replacing +them with a provider-neutral execution API would create migration risk without improving the +underlying provider workflows. + +To make these existing flows usable by external API clients, Vortex exposes a discovery +endpoint that identifies the flow, documents, and ordered operations for a country and customer +type. OpenAPI remains authoritative for request schemas. Discovery may additionally identify +fixed discriminator values and values derived from earlier steps so clients can connect those +schemas into an executable sequence; the documentation gate verifies those target fields against +OpenAPI. + +## Scope + +- Keep the current Avenia and Alfredpay endpoint families and their call order. +- Support both self-service profiles and manager-to-child delegated operations. +- Complete API credential support for provider operations that currently depend on a browser + session, where provider capabilities permit a headless flow. +- Publish machine-readable document and operation requirements for each supported country and + customer type, including the fixed and derived values needed to connect ordered operations. +- Keep OpenAPI and corridor-specific integration documentation authoritative for complete + request and response contracts. +- Continue using the existing provider customer and KYC/KYB case records for ownership and + status tracking. +- Do not redesign Monerium or other provider flows as part of this proposal. + +"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 +or liveness steps may still involve a provider-controlled URL, but the flow must not depend on +a Vortex UI. + +## Existing foundation + +The persistence model already separates the customer, provider account, and verification +attempt: + +```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 status. This model remains unchanged. + +The public execution surface is intentionally provider-specific. Avenia and Alfredpay have +different request shapes, document handling, hosted-flow exceptions, statuses, and retry +rules. The Dashboard already orchestrates those sequences through shared KYC/KYB state +machines and reads the aggregated `GET /v1/onboarding/status` view. + +Avenia's API-based Level 1 KYB flow is the first headless business-verification slice. It uses +the existing BRLA route family to create or reuse the company subaccount, create and upload +company and UBO documents, register UBOs, submit the attempt, and track its result. It does not +need to become an adapter behind a new provider-neutral route family. + +Authentication, delegated authorization, and manager-to-child ownership are defined by the +[managed-headless-profiles proposal](proposal-headless-profiles-and-pricing-plans.md). This +proposal applies those controls to the existing provider operations. + +## Endpoint preservation invariants + +- Existing Dashboard, Widget, and other first-party workflows must continue to work without + migrating to a new endpoint family or reordered sequence. +- Existing provider-specific endpoint names and request contracts remain compatibility + contracts. Changes follow the normal public API compatibility policy. +- Discovery references identify the existing operations; discovery does not proxy, combine, or + replace them. +- The authenticated profile, or authorized managed child, remains the owner of every provider + customer, verification case, and uploaded document. +- Delegated operations retain both the manager actor and child subject for authorization and + audit while keeping the child as resource owner. +- Provider-confirmed state remains authoritative. A client completion event cannot mark a + case approved. +- Side-effecting operations must define retry-safe behavior so a client timeout cannot + silently create duplicate provider-side resources or submissions. +- Requirements may differ by country, customer type, provider, and verification level. The + referenced provider-specific OpenAPI operations represent those differences instead of a + common request schema. + +## Requirements discovery + +The discovery operation is: + +```http +GET /v1/onboarding/requirements?country=BR&customerType=business +``` + +`country` uses the ISO 3166-1 alpha-2 country code that selects the current onboarding flow. +`customerType` distinguishes individual KYC from business KYB. If a country supports multiple +verification levels, the final contract must also define how the requested or applicable level +is selected. + +The response contains stable flow/provider metadata, document requirements, and ordered action +steps. API steps identify the OpenAPI `operationId`, method, path, and request-schema reference. +Non-HTTP direct-upload and hosted steps remain explicit so clients can preserve the complete +submission sequence. Discovery intentionally omits every `GET` operation, including initial +resource reads, intermediate readiness checks, redirect/status getters, and final polling. +Integration documentation and OpenAPI remain authoritative for those reads and for determining +completion. + +The response intentionally has no top-level `fields` array or independent request schema. Clients +resolve each step's `requestSchema` against `openapiUrl`; OpenAPI defines the accepted fields and +types. A step may provide `fixedBody` or `fixedQuery` values and `derivedValues` mappings for +fields defined by that operation, allowing clients to connect provider discriminators and prior +step outputs without redefining their schemas. + +For example: + +```json +{ + "country": "BR", + "customerType": "business", + "documentationUrl": "https://api-docs.vortexfinance.co/fiat-corridors", + "flow": "avenia-br-business-level-1-api-kyb", + "mode": "api", + "provider": "avenia", + "requirementsVersion": "2026-08-10", + "openapiUrl": "https://raw.githubusercontent.com/pendulum-chain/vortex/main/docs/api/openapi/vortex.openapi.json", + "documents": [ + { + "collection": "direct-upload", + "required": true, + "type": "CERTIFICATE-OF-INCORPORATION" + } + ], + "steps": [ + { + "order": 1, + "operationId": "createSubaccount", + "method": "POST", + "path": "/v1/brla/createSubaccount", + "requestSchema": "#/components/schemas/CreateSubaccountRequest", + "kind": "api", + "description": "Create the company Avenia subaccount." + }, + { + "order": 2, + "operationId": "createAveniaKybDocument", + "method": "POST", + "path": "/v1/brla/kyb/documents", + "requestSchema": "#/components/schemas/AveniaKybDocumentRequest", + "kind": "api", + "description": "Create an upload target for each company and UBO document." + } + ] +} +``` + +The example is abbreviated. The endpoint does not return customer PII, an independent field +catalog, or duplicate schemas. Clients resolve each API step's request schema and construct the +request from collected data plus any fixed or derived workflow values on the step. + +## Contract authority and synchronization + +The requirements response is an executable workflow index, not a second schema source. OpenAPI +is authoritative for each operation's complete request, response, and error contract. Discovery +adds only sequencing and fixed/derived value bindings to fields that OpenAPI already defines. The +corridor-specific guide remains authoritative for behavioral details such as branching, retries, +custody, and asynchronous completion. + +The OpenAPI document covers the existing Avenia and Alfredpay KYC/KYB operations advertised by +discovery, including API credential and managed-profile authentication. Discovery links to the +reviewed repository document through its stable raw GitHub URL while Apidog remains the +human-facing endpoint catalog. + +Every published non-GET API step must resolve to an operation in the reviewed OpenAPI document, +and every request-schema pointer must resolve. Fixed body/query keys and derived-value targets +must identify fields accepted by that operation in the stated location. The documentation gate +fails when an operation ID, method, path, schema reference, or workflow binding is stale. + +## Authentication and state + +Requirements metadata is public and requires no authentication. Responses contain no private +provider configuration, account state, or PII. + +Requirements discovery is not a profile status API. It selects a static flow for a country and +customer type, not which operations a particular profile has completed. Clients +must use the existing provider status operations and `GET /v1/onboarding/status` where applicable. +Profile-specific next-action guidance is outside this proposal and may be considered later if +static requirements plus documented status behavior prove insufficient. + +## Consequences + +### Benefits + +- Existing first-party consumers avoid a risky endpoint and state-machine migration. +- External API clients can discover the applicable submission actions without reverse + engineering a Dashboard workflow. +- OpenAPI is the authoritative machine-readable source for request-field types and body schemas; + discovery supplies only workflow bindings to those fields. +- Provider-specific differences remain visible and accurately modeled. + +### Costs and constraints + +- Integrators must implement country- and provider-specific operation sequences, statuses, + errors, retries, and hosted-flow branches. Vortex does not provide one portable execution + client across providers. +- Publishing an ordered step makes that route and its position in the flow part of the external + compatibility surface. +- The operation list can drift from provider behavior unless it is reviewed with OpenAPI and + runtime changes. +- Completing and maintaining KYC/KYB OpenAPI coverage becomes a prerequisite for reliable + discovery. +- Discovery does not advertise resource reads, readiness checks, or status polling. Integrators + must use provider-specific documentation, OpenAPI, and status responses for completion. + +## Delivery order + +1. Preserve and complete API credential and managed-profile authorization for each existing + provider-specific operation required by supported headless flows. Implemented. +2. Reconcile the public OpenAPI document with the implemented Avenia and Alfredpay KYC/KYB + endpoints, authentication, request schemas, responses, and errors, then publish it at a + stable machine-readable URL. Implemented. +3. Define the requirements response schema and synchronization checks. Implemented. +4. Implement requirements discovery for Avenia Level 1 KYC/KYB in Brazil using the existing + BRLA operations and sequence. Implemented. +5. Add Alfredpay countries using their existing API-based or hosted-flow operations without + renaming or reordering those routes. Implemented for AR, CO, MX, and US product-supported + customer types. +6. Publish corridor-specific guides and examples, then add SDK discovery conveniences only + where they reduce integration work without hiding provider-specific behavior. + +## Non-goals + +- A provider-neutral KYC/KYB execution endpoint family. +- A common request body, document resource, status vocabulary, or retry operation across all + providers. +- Reordering, combining, proxying, or retiring existing provider-specific operations. +- Migrating the Dashboard, Widget, or shared KYC/KYB state machines to a new workflow. +- Letting callers select arbitrary provider accounts or write compliance decisions. +- Returning customer PII, independent request-field catalogs or schemas, or customer-populated + executable request bodies from requirements discovery. +- Profile-specific next-action orchestration. + +## Open decisions + +- Should discovery use `country=BR`, which matches current onboarding selection, or a fiat + corridor such as `corridor=BRL`? The contract must use one term consistently. +- How is a verification level selected when a country supports more than one level? +- Should operation metadata be generated from a flow definition rather than maintained beside + runtime orchestration? diff --git a/docs/proposal-sumsub-kyc-token-sharing.md b/docs/proposal-sumsub-kyc-token-sharing.md new file mode 100644 index 000000000..fcf2d6cbe --- /dev/null +++ b/docs/proposal-sumsub-kyc-token-sharing.md @@ -0,0 +1,571 @@ +# Proposal: Avenia Sumsub Token Import for Individual KYC + +Status: implemented and enabled in code on this branch. Production readiness remains blocked on +provider and environment confirmations, legal and consent review, and sandbox validation. Vendor +contract checked against public Avenia and Sumsub documentation on 2026-08-14. + +Decision: add an API-only alternative to Avenia's normal individual KYC flow. The caller supplies +a Sumsub applicant share token that it generated outside Vortex. Vortex imports the token into +the caller's or managed child's existing Avenia subaccount, binds the resulting Avenia KYC +attempt, and trusts only Avenia's final decision. + +This proposal is limited to individual KYC. Business verification is out of scope. + +## Summary + +The provider sequence is: + +```text +Create or reuse an INDIVIDUAL Avenia subaccount + -> caller generates a Sumsub share token outside Vortex + -> Vortex imports the token into that Avenia subaccount + -> Avenia creates a KYC attempt + -> Vortex polls that exact attempt + -> Avenia approves or rejects the imported verification +``` + +The token replaces document collection, liveness, and normal KYC submission. It does not +replace Avenia subaccount creation because Avenia requires a target `subAccountId` and Vortex +requires a canonical CPF ownership mapping for BRL operations. + +Vortex does not have a Sumsub entity in this iteration. It does not hold Sumsub credentials, +generate share tokens, call Sumsub APIs, or receive Sumsub webhooks. The caller owns the source +Sumsub applicant and is responsible for generating a token bound to Avenia's configured Sumsub +recipient. + +## Goals + +- Let an active managed-profile manager import individual KYC for a directly managed child. +- Let a direct authenticated profile import its own individual KYC. +- Preserve the existing Vortex profile, customer-entity, provider-customer, and KYC-case + ownership model. +- Make normal Avenia KYC and Sumsub token import mutually exclusive verification methods. +- Keep the token out of storage, logs, telemetry, URLs, and provider error details. +- Bind local state to the exact Avenia attempt returned by token import. +- Make one-time-token submission safe under retries, concurrency, timeouts, and local write + failures. + +## Non-goals + +- Creating a Sumsub account or client for Vortex. +- Accepting or storing caller Sumsub API credentials. +- Generating a Sumsub share token on behalf of a caller. +- Calling Sumsub's applicant-reuse API directly. +- Receiving or reconciling Sumsub webhooks. +- Supporting company verification. +- Adding Dashboard, Widget, or shared XState-machine UI in this iteration. +- Allowing a managed child's own credential to import a token. +- Replacing Avenia as the final compliance decision-maker. + +## Avenia Contract + +### 1. Create the target subaccount + +Avenia creates an individual subaccount with: + +```http +POST /v2/account/sub-accounts +Content-Type: application/json + +{ + "accountType": "INDIVIDUAL", + "name": "Jane Doe" +} +``` + +Response: + +```json +{ + "id": "" +} +``` + +Avenia subaccounts are permanent and cannot be deleted. Vortex already wraps this operation in +`POST /v1/brla/createSubaccount`, validates the requested type, associates the CPF and effective +customer entity, and persists the resulting provider customer. + +Subaccount creation alone does not select a KYC verification method. The caller may choose the +normal flow or token import after the subaccount exists. + +### 2. Caller generates the token + +The caller uses its own Sumsub client and credentials: + +```http +POST https://api.sumsub.com/resources/accessTokens/shareToken + +{ + "applicantId": "", + "forClientId": "", + "ttlInSecs": 600 +} +``` + +The source applicant must be an active approved individual with Sumsub `GREEN` review. Sumsub +documents share tokens as recipient-bound, short-lived, opaque, invalidated after use, and up to +1 KiB. The caller sends the resulting token to Vortex; Vortex does not participate in token +generation. For a Brazilian applicant, the caller must populate that applicant's CPF in Sumsub's +TIN field before generating the token; the share-token request has no separate CPF field. + +Avenia does not publish the required recipient `forClientId`. Avenia must provide the sandbox +and production values during feature enablement, and Vortex must publish the applicable value to +approved integrators. Callers cannot choose a different recipient through the Vortex request. + +### 3. Import the token + +Avenia's exact documented operation is: + +```http +POST /v2/kyc/import-token/?subAccountId= +Content-Type: application/json + +{ + "importToken": "" +} +``` + +The trailing slash after `import-token` is required. + +Response: + +```json +{ + "id": "", + "message": "token imported successfully: processing KYC" +} +``` + +The response `id` is an Avenia KYC-attempt ID. Avenia explicitly states that HTTP `200` means +the token was imported and an attempt was created; it does not mean that the user was approved. + +### 4. Observe Avenia's decision + +Avenia emits: + +| Event | Status | Result | +|---|---|---| +| `KYC-STARTED` | `PENDING` | `null` | +| `KYC-PROCESSING` | `PROCESSING` | `null` | +| `KYC-COMPLETED` | `COMPLETED` | `APPROVED` or `REJECTED` | + +The attempt is also available through: + +```http +GET /v2/kyc/attempts/{attemptId}?subAccountId= +``` + +The imported attempt's documented level name is `sumsub-token-{client-id}`. Vortex polls the +exact returned attempt ID. It does not infer the result from Sumsub approval, token acceptance, +the first item in an attempt list, or a client event. + +Canonical mapping remains: + +| Avenia state | Vortex state | +|---|---| +| `PENDING` | `pending` | +| `PROCESSING` | `in_review` | +| `COMPLETED + APPROVED` | `approved` | +| `COMPLETED + REJECTED` | `rejected` | +| `EXPIRED` | `pending` and non-approved pending reconciliation; external `EXPIRED` retained | + +### Provider prerequisites + +- Avenia must enable the `SumsubSharedClient` flag; otherwise import returns `401`. +- The receiving account or subaccount must be `INDIVIDUAL`. +- The source Sumsub applicant must have `GREEN` review. +- The token must be valid, unexpired, unused, and issued for the configured recipient. +- The source Sumsub client and Avenia must have the required sharing relationship. + +### Required imported data + +Avenia reads document-extracted Sumsub `Info` first and falls back to user-entered `FixedInfo`. +Every imported individual requires: + +- full name; +- date of birth; +- country associated with the tax identifier; +- an approved Sumsub review; +- at least one `ID_CARD`, `PASSPORT`, `DRIVERS`, or `RESIDENCE_PERMIT` document. + +Brazilian individuals must have CPF in Sumsub's TIN field; Avenia explicitly does not derive it +from the document number. For other countries Avenia resolves tax identity from TIN, document +`number`, then document `additionalNumber`. Avenia may also import email and address fields. + +The CPF imported by Avenia must agree with the canonical CPF under which Vortex created the +provider customer. Supplying the correct CPF in Sumsub is the caller's responsibility under +RISK-021. Avenia's duplicate-tax-ID and compliance checks remain authoritative; Vortex rejects a +later provider response that exposes an identity mismatch, but currently accepts provider approval +when Avenia omits the tax ID needed for that comparison. + +### Provider errors + +| HTTP | Message | Meaning | +|---|---|---| +| `401` | no documented body | `SumsubSharedClient` is not enabled | +| `400` | `importToken endpoint is only available for INDIVIDUAL users` | Target account has the wrong type | +| `400` | invalid `importToken` field | Token is empty | +| `400` | `token is not valid or already used` | Sumsub rejected or could not authenticate the token | +| `400` | `can only import one token per user` | Sumsub returned a conflict | +| `400` | `another token is currently being processed` | A prior import workflow remains active | + +Asynchronous rejection reasons include an unapproved or non-individual applicant, missing +identity fields or documents, missing tax identity, duplicate tax ID, and Avenia compliance or +Brazilian fraud rejection. The attempt includes `retryable`, but Avenia does not document when a +new token may be submitted after an asynchronous rejection. + +### Provider contract gaps + +- The import page does not explicitly confirm signed API-key authentication for this endpoint. +- Avenia does not publish its sandbox or production recipient `forClientId`. +- Avenia does not define whether `can only import one token per user` means one lifetime import, + one active import, or one successful import. +- Avenia does not document whether every synchronous failure proves the token was unconsumed. +- Avenia does not explicitly prohibit a standard KYC attempt and token-import attempt from + coexisting. + +The last point is addressed by a stronger Vortex invariant rather than delegated to Avenia. + +## Verification Method Invariant + +Each individual Avenia KYC case has one immutable verification method: + +```text +unselected -> standard +unselected -> sumsub_share_token +``` + +There is no transition between `standard` and `sumsub_share_token`. + +The method is selected before the first provider-side KYC side effect: + +- Creating the first normal identity, selfie, or liveness document selects `standard`. +- Creating a normal API or Web SDK attempt selects `standard`. +- Durably claiming token import selects `sumsub_share_token` before Avenia receives the token. +- Entering data in a client form or creating the Avenia subaccount does not select a method. + +Once `standard` is selected, token import returns `409` and never calls Avenia. Once +`sumsub_share_token` is selected, normal document creation, liveness creation, API submission, +and Web SDK initiation return `409` and never call Avenia. + +Rejection, expiration, timeout, managed-profile deletion, or corridor-policy changes do not +permit method switching. A retry may occur only within the selected method and only when Avenia +allows it. An ambiguous token-import result remains locked to `sumsub_share_token` because the +one-time token may already have been consumed. + +Migration 066 classifies every Avenia `kyc` case that exists when the migration runs as +`standard` directly from the case's provider and type, including rows without a provider-customer join. Cases created later remain nullable until +their first method claim. A runtime status read locks a nullable case and selects `standard`; it +does not infer a method or bind an attempt from provider document or attempt history. +Clients intending to import a token must therefore complete the import before any status read. +The migration is forward-only once verification state exists: rollback takes an exclusive case-table +lock and refuses to remove the columns while any method or submission JSON remains. + +## User Story 1: Managed Profile + +1. An enabled manager creates an `individual` managed profile through the existing managed + profile API. +2. The manager creates or reuses the child's Avenia subaccount through + `POST /v1/brla/createSubaccount` with `X-Managed-Profile-Id`. +3. The manager verifies the same individual in its own Sumsub environment and generates an + Avenia-recipient-bound share token outside Vortex. +4. The manager calls the Vortex import endpoint with the same managed-profile selector. +5. Vortex verifies the active manager, direct active relationship, BR corridor, immutable + individual type, active child entity, and child-owned Avenia provider customer. +6. Vortex imports the token, persists the Avenia attempt, and returns accepted/pending. +7. The manager reads the result through existing delegated KYC or onboarding-status reads. + +Only the controlling manager may import for a managed child. A child-owned credential cannot +invoke this operation even though child credentials can authenticate other child-owned routes. + +## User Story 2: Direct Profile + +1. An authenticated non-managed profile creates or reuses its own individual Avenia subaccount. +2. The profile controls an approved individual in its own Sumsub environment and generates an + Avenia-recipient-bound share token outside Vortex. +3. The profile calls the same import endpoint without `X-Managed-Profile-Id`. +4. Vortex resolves the profile's active individual entity and owned Avenia provider customer. +5. Vortex imports the token, persists the exact attempt, and returns accepted/pending. +6. The profile reads the final result through existing KYC or onboarding-status reads. + +Direct profiles may authenticate with their Supabase session or profile-bound secret API key. +Public keys and ownerless partner credentials are insufficient. + +## Vortex API Contract + +```http +POST /v1/brla/kyc/import-token +Authorization: Bearer +X-API-Key: +X-Managed-Profile-Id: +Idempotency-Key: +Content-Type: application/json + +{ + "importToken": "", + "consentAttested": true +} +``` + +The caller sends one authentication mechanism. `X-Managed-Profile-Id` is present only when the +controlling manager acts for a child. + +Accepted response: + +```http +HTTP/1.1 202 Accepted +``` + +```json +{ + "attemptId": "", + "status": "pending" +} +``` + +After `202`, clients poll aggregate onboarding status or `GET /v1/brla/getKycStatus` with an owned +`taxId`. The returned `attemptId` is correlation data, not a public status-poll input. + +The operation does not accept CPF, `subAccountId`, Sumsub applicant ID, customer-entity ID, or +provider-customer ID. It derives the target from the authenticated effective profile and +requires exactly one eligible active individual Avenia provider customer. Missing or ambiguous +provider setup returns a prerequisite conflict without inspecting or forwarding the token. + +`consentAttested` is provisional until legal review defines the required representation and +evidence. Vortex records the server-controlled consent-policy version, actor, subject, and +timestamp; the raw token is never part of that record. + +### Authorization + +The route applies authentication before body validation so unauthenticated requests cannot use +validation behavior to probe a bearer-like personal-data transfer capability. + +For direct profiles, the authenticated actor and subject are the same authenticated profile. +For managed profiles, `X-Managed-Profile-Id` must resolve to an active directly managed child and +the authenticated actor must be its controlling manager. Direct child credentials are rejected. + +Mutations require the manager's current BR corridor, individual-type permission, and canonical +BR individual support. Status reads retain their existing reconciliation behavior after policy +changes, but a removed policy or deleted relationship blocks new imports. + +## Persistence and Submission Safety + +Add an immutable nullable `verification_method` to individual KYC cases with values `standard` +or `sumsub_share_token`. Selecting it and claiming a submission must be serialized under a +database lock on the canonical KYC case. + +Add one nullable `verification_submission` JSON object to the canonical KYC case containing only: + +- status: `prepared`, `submitted`, `confirmed`, `ambiguous`, or `failed`; +- optional idempotency-key hash and token digest for input-consistency checks; +- optional standard-payload digest; +- actor and subject profile IDs; +- the complete pre-send attempt-ID baseline; +- non-secret error classification; +- an append-only consent-attestation array containing actor, subject, policy version, and timestamp for each token claim. + +The exact Avenia attempt remains in `kyc_cases.provider_case_id`, and the provider-send timestamp +remains in `kyc_cases.submitted_at`. The token itself and complete provider request are never +persisted. The locked canonical case row is the sole serialization boundary, so no submission table +or active-submission index is needed. + +The existing normal document/liveness and submission endpoints must participate in the same +method claim. Adding a lock only to token import would leave a race where normal KYC starts after +the import preflight but before Avenia consumes the token. + +### Retry and reconciliation + +- A confirmed retry with the same idempotency key returns the stored attempt ID. +- Reusing an idempotency key with a different token digest returns `409`. +- Concurrent requests result in at most one Avenia import call. +- A definitive pre-provider rejection, including failure to read the attempt baseline, marks the + submission failed and requires a new idempotency key even though the token was not sent. +- A transport failure, timeout, malformed success, or provider 5xx marks the submission + ambiguous and never automatically resubmits. +- Reconciliation lists Avenia attempts for the owned subaccount and binds exactly one matching + `sumsub-token-*` attempt created in the submission window. +- Zero or multiple candidates remain ambiguous and require operator action. +- A provider success followed by a local write failure is repaired from the exact attempt retained + on the canonical case without another provider POST. +- A second token after an asynchronous rejection remains disabled until Avenia confirms the + supported retry contract. + +## Status and Webhooks + +Persist the import response ID in `kyc_cases.provider_case_id`. Individual KYC refresh must query +that exact attempt rather than applying `attempts[0]`. This exact-attempt rule should also be +applied to normal individual submissions when their attempt ID is known. + +The existing Avenia webhook remains signature-verified and notification-only. It does not +approve a KYC case or mutate provider state. Polling the exact provider attempt remains the +authoritative persistence path. + +Only Avenia `COMPLETED + APPROVED` may approve the provider customer and KYC case. Sumsub +`GREEN`, possession of a token, Avenia import acceptance, and client assertions never complete +onboarding. Before persisting that approval for `sumsub_share_token`, Vortex also reads Avenia +subaccount info. When Avenia returns a non-empty `accountInfo.taxId`, its normalized hash must +equal the canonical provider customer's tax-reference hash; a mismatch leaves both rows +unapproved. An absent or empty provider tax ID preserves the existing approval behavior because +Avenia's guarantee that this field is present for imported KYC remains a deployment blocker. +Terminal approval cannot be downgraded by stale responses. + +## Token and Privacy Controls + +- Accept a non-empty opaque token of at most 1 KiB. +- Keep the token only in request memory for the provider exchange. +- Do not return, persist, parse, inspect, or analyze the token. +- Suppress the request body in Avenia client debug logging. +- Sanitize provider errors so Axios request configuration and URLs cannot expose the token. +- Exclude the token from Sentry, analytics, traces, metrics labels, audit payloads, and support + logs. +- Never place the token in a Vortex URL or query parameter. +- Store only a SHA-256 digest when needed to enforce idempotent input consistency. +- Keep actor, subject, case, provider attempt, method, and consent metadata auditable. + +Sharing may transfer identity fields, document images, selfies, biometric-derived checks, and +review results between Sumsub clients. Sumsub's generic consent does not replace each party's +legal basis, applicant disclosures, biometric or special-category consent, international data +transfer controls, or inter-organizational sharing obligations. + +Before deployment enablement, legal and compliance must define what a direct user and a manager +acting for a child attest, the policy text and version, who collects applicant consent, and what +evidence Vortex must retain. Possession of a valid token is not treated as consent evidence. + +## Implementation Areas + +### API and shared Avenia client + +- Add the import route, request validation, authorization guard, and controller/service. +- Add the exact `/v2/kyc/import-token/` Avenia mapping and validated response schema. +- Add a sensitive-body option to the Avenia request client so this payload is never logged. +- Generalize the existing attempt-by-ID client method for both individual KYC and company + verification. + +### Persistence + +- Add the immutable KYC verification method and current durable claim to the canonical KYC case. +- Serialize claim and idempotency transitions by locking that canonical case. +- Store the confirmed Avenia attempt ID on the canonical case. +- Backfill pre-existing individual Avenia cases to the standard method. + +### Existing normal KYC + +- Claim `standard` before creating the first document or liveness resource. +- Reuse the same claim for normal API and Web SDK attempt creation. +- Reject every normal-flow mutation after `sumsub_share_token` is selected. +- Persist normal attempt IDs and poll exact attempts where available. + +### Public contract and maintained documentation + +- Add the endpoint and schemas to OpenAPI and generated declarations. +- Document the two API-only stories and the required Avenia recipient client ID. +- Keep static onboarding requirements on the normal flow initially; token import is an explicit + alternative operation rather than a replacement default. +- Update `docs/security-spec/05-integrations/brla.md` with the method lock, ownership, token + secrecy, attempt binding, and retry invariants. +- Update identity architecture only if the persisted ownership model changes. + +No frontend, dashboard, SDK, or `packages/kyc` machine changes are required for this API-only +delivery. + +## Acceptance Criteria + +### Managed story + +- The controlling manager can import a valid token for an active directly managed individual. +- The provider customer and KYC case remain owned by the child. +- An unrelated manager, deleted relationship, disabled BR corridor, disallowed individual type, + or child-owned credential cannot import. +- The manager can read the resulting child status through existing delegated reads. + +### Direct story + +- An authenticated non-managed profile can import a valid token for its own active individual + Avenia customer. +- The profile cannot select another profile, entity, CPF, subaccount, or provider customer in the + request. +- Public credentials, anonymous requests, and ownerless partner credentials cannot import. + +### Shared behavior + +- Subaccount creation succeeds without choosing a KYC method. +- The first normal KYC provider artifact permanently blocks token import. +- A claimed token import permanently blocks every normal-flow mutation. +- Concurrent and retried requests produce at most one provider import. +- Ambiguous outcomes never cause automatic token replay. +- The token never appears in storage, logs, telemetry, URLs, or errors. +- The exact Avenia attempt controls status. +- Only Avenia approval completes onboarding. + +## Test Plan + +- Direct-user and controlling-manager happy paths. +- Rejection of child credentials, unrelated managers, public keys, anonymous callers, and + foreign provider customers. +- Rejection for company entities, missing subaccounts, approved cases, deleted children, and + disabled manager policy. +- Empty and over-1-KiB token validation after authentication. +- Standard-first and token-first method-lock tests on every affected endpoint. +- Migration backfill of existing individual Avenia cases and nullable method state on cases created later. +- Concurrent imports issue one Avenia request. +- Import racing normal document or attempt creation cannot cross the method lock. +- Idempotent confirmed retry returns the same attempt. +- Different token under the same idempotency key returns `409`. +- Timeout and local persistence failure enter safe reconciliation paths. +- Pending, processing, approved, rejected, and expired exact-attempt mapping. +- Unrelated or stale attempts cannot update the bound case. +- Webhook replay remains notification-idempotent and cannot approve KYC. +- Assertions that request logs, database rows, error responses, and observability events contain + no raw token. +- OpenAPI and documentation synchronization checks. + +## Delivery Order + +1. Confirm Avenia feature enablement, recipient IDs, endpoint authentication, and retry behavior. +2. Complete privacy and consent review for both caller stories. +3. Add the verification-method lock and durable submission persistence. +4. Bring normal KYC document and attempt creation under the same lock. +5. Add the redacted Avenia import client operation. +6. Add the unified Vortex endpoint and actor/subject authorization policy. +7. Bind individual polling to exact attempt IDs and add reconciliation. +8. Publish OpenAPI and integration documentation and update the BRLA security specification. +9. Run the sandbox contract flow for direct and managed profiles before production enablement. + +## Blocking Confirmations + +- Avenia's sandbox and production Sumsub recipient `forClientId` values. +- `SumsubSharedClient` enablement in both environments. +- Signed API-key support on `/v2/kyc/import-token/`. +- Whether and when a new token is permitted after asynchronous rejection. +- Which synchronous errors prove that the token was not consumed. +- How Avenia recommends reconciling an import timeout with no immediate matching attempt. +- Whether `accountInfo.taxId` is guaranteed to be present after imported KYC approval in every + enabled environment. +- Avenia's retention and deletion policy for imported identity data and documents. +- The consent and attestation contract for direct users and managers acting for children. + +## Sources + +### Avenia + +- [KYC via Sumsub Shared Token](https://integration-guide.avenia.io/docs/KYC/kycSumsubSharedToken) +- [KYC Level 1 and attempt polling](https://integration-guide.avenia.io/docs/KYC/kycLevel1) +- [Subaccount management](https://integration-guide.avenia.io/docs/Avenia%20Subaccounts/subAccountManagement) +- [Environments](https://integration-guide.avenia.io/environments) +- [API-key usage](https://integration-guide.avenia.io/docs/Security/apiKeysGuide) +- [Webhook events](https://integration-guide.avenia.io/docs/Webhooks/webhookEvents) +- [Webhook signature verification](https://integration-guide.avenia.io/docs/Webhooks/verifyingWebhookAuthenticity) + +### Sumsub + +- [Reusable KYC Share](https://docs.sumsub.com/docs/reusable-kyc-share) +- [Generate share token](https://docs.sumsub.com/reference/generate-share-token) +- [Manage sharing partners](https://docs.sumsub.com/docs/manage-sharing-partners) +- [Applicant privacy disclosures and consent](https://docs.sumsub.com/docs/applicant-privacy-disclosures-and-consent-requirements) + +## Research Limitations + +The investigation used public documentation only. No authenticated Avenia or Sumsub dashboard, +contract, support correspondence, or live API credentials were available. Avenia did not expose +a public OpenAPI document at common specification paths. The blocking confirmations above remain +required before deployment enablement. diff --git a/docs/security-spec/03-ramp-engine/profile-partner-pricing.md b/docs/security-spec/03-ramp-engine/profile-partner-pricing.md index c7d1e6c1e..8c00827b8 100644 --- a/docs/security-spec/03-ramp-engine/profile-partner-pricing.md +++ b/docs/security-spec/03-ramp-engine/profile-partner-pricing.md @@ -2,7 +2,7 @@ ## What This Does -Profile partner pricing lets an authenticated first-party user receive the custom quote behavior of a partner without exposing partner API credentials in the frontend. An administrator assigns a Supabase profile to a partner name, and the backend resolves that (unique) name once into a stable `partner_id`. When that user creates a quote with a valid Supabase Bearer token, the backend reads the active assignment's `partner_id` and loads the partner's pricing config for the requested ramp type from `partner_pricing_configs` (`UNIQUE(partner_id, ramp_type, COALESCE(fiat_currency, '*'))` — a config scoped to the quote's corridor fiat currency wins over the partner's wildcard `fiat_currency IS NULL` config). +Profile partner pricing lets an authenticated profile receive the custom quote behavior of a partner without exposing partner API credentials. An administrator assigns a profile to a partner name, and the backend resolves that (unique) name once into a stable `partner_id`. When that profile creates a quote through a supported authentication path, the backend reads the applicable active assignment's `partner_id` and loads the partner's pricing config for the requested ramp type from `partner_pricing_configs` (`UNIQUE(partner_id, ramp_type, COALESCE(fiat_currency, '*'))` — a config scoped to the quote's corridor fiat currency wins over the partner's wildcard `fiat_currency IS NULL` config). A managed child uses its own active assignment when present and otherwise inherits the controlling manager profile's active assignment. This feature is intentionally different from partner API-key authentication: @@ -21,16 +21,16 @@ For profile-assigned frontend quotes, `quote_tickets.user_id` is set to the auth ## Security Invariants -1. **Profile assignments MUST be server-side only** - The client MUST NOT be able to choose its assigned partner by passing a request body field, URL parameter, or local storage value. The backend resolves assignments only from the authenticated profile or a verified delegated child subject. +1. **Profile assignments MUST be server-side only** - The client MUST NOT be able to choose its assigned partner by passing a request body field, URL parameter, or local storage value. The backend resolves assignments only from the authenticated effective profile and, for a verified managed child, its controlling manager profile. 2. **Profile assignments MUST NOT authenticate partner ownership** - A Supabase profile assigned to a partner MUST NOT populate `req.authenticatedPartner`, MUST NOT satisfy `enforcePartnerAuth()`, and MUST NOT access partner-owned quotes or ramps. 3. **Explicit partner API-key integrations MUST keep their existing behavior** - Requests that include `partnerId` still require a matching partner secret key. Existing SDK/API clients using partner keys must continue to create partner-owned quotes. -4. **Partner pricing source precedence MUST be deterministic** - Explicit `partnerId` has highest precedence, then validated public API key partner name, then profile assignment, then default `"vortex"` pricing. -5. **Profile-assigned quotes MUST be user-owned** - A quote priced through a profile assignment MUST persist the effective profile in `user_id` and leave `partner_id = NULL`. For delegated requests, the effective profile is the verified child and the manager credential's partner cannot override child pricing. +4. **Partner pricing source precedence MUST be deterministic** - Explicit `partnerId` has highest precedence, then validated public API key partner name, then the effective profile assignment. For a verified managed child with no active, unexpired assignment, the controlling manager profile assignment is next, followed by default `"vortex"` pricing. A managed child's assignment supersedes the manager's for both delegated-manager and direct-child-credential requests. +5. **Profile-assigned quotes MUST be user-owned** - A quote priced through an effective or controlling-manager profile assignment MUST persist the effective profile in `user_id` and leave `partner_id = NULL`. For managed requests, the effective profile is the verified child; the manager credential's partner attribution cannot override assignment precedence or make the quote partner-owned. 6. **The pricing partner MUST be persisted separately** - Any quote that applies non-default partner pricing MUST persist `pricing_partner_id` so downstream fee distribution and dynamic discount state use the same partner row that quote calculation used. 7. **Inactive or expired assignments MUST be ignored** - The assignment resolver must require `is_active = true` and either `expires_at IS NULL` or `expires_at > now()`. 8. **Assignment partner IDs MUST be stable** - Assignment creation may accept a logical partner name for admin convenience, but it MUST persist the resolved `partner_id` and quote resolution MUST use that ID, not a fresh name lookup. Direction and corridor selection happen at quote time via the `(partner_id, ramp_type, fiat_currency)` pricing-config lookup (corridor-scoped config first, wildcard fallback). 9. **Partner-name ambiguity is structurally impossible** - `partners.name` carries a unique constraint (`uniq_partners_name`), so assignment creation resolves at most one row. (The pre-split ambiguity-rejection rule is retired.) -10. **Invalid assignments MUST fail closed to default pricing** - If an assignment points to no active partner row for the requested ramp type, quote creation proceeds without that partner's pricing instead of accepting untrusted client input or fabricating a partner. +10. **Invalid assignments MUST fail closed to default pricing** - If the selected assignment points to no active partner row or applicable pricing config for the requested ramp type and corridor, quote creation proceeds with default pricing instead of accepting untrusted client input or fabricating a partner. An active child assignment remains the selected override when invalid and therefore fails to default pricing rather than revealing the manager's pricing; only the absence of an active, unexpired child assignment permits manager fallback. 11. **Admin active-list semantics MUST match quote-time semantics** - Default assignment listing MUST exclude rows that are inactive or expired; historical listing may include them only when explicitly requested. 12. **Fee distribution MUST use the pricing partner, not only the owner partner** - Partner markup payout uses `pricing_partner_id` when present, with `partner_id` as a backward-compatible fallback for older quotes. 13. **Dynamic discount state MUST use the pricing partner** - Quote consumption adjusts the dynamic discount state for the partner whose pricing was used, not for the quote owner. @@ -47,6 +47,7 @@ For profile-assigned frontend quotes, `quote_tickets.user_id` is set to the auth | **Dropped partner markup payout** | A profile-assigned quote computes a partner markup but downstream fee distribution looks only at `quote.partnerId`, sees `NULL`, and skips partner payout. | Fee distribution resolves payout from `pricing_partner_id ?? partner_id`. | | **Dynamic state drift for the wrong principal** | A profile-assigned quote is consumed but dynamic discount state is decremented for no partner or the wrong partner. | Ramp registration resolves the partner from `pricing_partner_id ?? partner_id` before calling `handleQuoteConsumptionForDiscountState`. | | **Stale assignment remains usable** | A profile's temporary partner entitlement expires but quote creation still applies custom rates. | Resolver filters out assignments with `expires_at <= now()`. | +| **Managed child receives unintended pricing** | A manager credential's partner attribution overrides a child's assignment, or direct-child and delegated requests resolve different defaults. | Both authentication paths use verified managed context and the same server-side precedence: active child assignment, active controlling-manager assignment, then default pricing. Credential partner attribution is suppressed for managed requests. | | **Assignment changes after partner rename** | A partner row is renamed after assignment creation, and future quotes unexpectedly lose or change pricing. | Assignments persist `partner_id`; `partner_name` is display/audit only. | | **Assignment to missing ramp-type config** | A profile is assigned to partner `Acme`, but `partner_pricing_configs` has only an active BUY config and the user requests SELL — or only configs scoped to other fiat corridors (e.g. MXN-only) and the user quotes BRL. | The `(partner_id, SELL)` pricing-config lookup (corridor-scoped first, wildcard fallback) returns nothing; resolver falls back to default pricing for that quote. | | **Expired assignment shown as active** | Admin tooling lists an expired row as active, leading support to assume custom rates still apply. | Default list filtering uses the same active + unexpired predicate as quote resolution; `includeInactive=true` is the historical view. | @@ -61,13 +62,13 @@ For profile-assigned frontend quotes, `quote_tickets.user_id` is set to the auth - [x] Default admin assignment listing excludes expired rows; `includeInactive=true` is required for historical rows. - [x] Admin assignment replacement deactivates the old active row and creates the new row in one transaction after taking a row lock for the target profile. - [x] Active-assignment unique-index collisions return `409 ASSIGNMENT_CONFLICT` instead of a generic server error. -- [x] Quote creation resolves profile assignments only from the authenticated profile or verified delegated child; unauthenticated quotes never use profile assignment pricing. +- [x] Quote creation resolves the effective profile assignment first and, for a verified managed child without one, the controlling manager profile assignment; unauthenticated quotes never use profile assignment pricing. - [x] Profile assignment quote resolution uses the stored `partner_id` plus a `(partner_id, ramp_type, fiat_currency)` pricing-config lookup, not a fresh runtime `partner_name` lookup. - [x] `POST /v1/quotes` and `POST /v1/quotes/best` still reject explicit `partnerId` without matching secret-key authentication. -- [x] Profile-assigned quotes persist `user_id` and `pricing_partner_id`, while leaving `partner_id` `NULL`. +- [x] Quotes priced from either a managed child's or its controlling manager's assignment persist the child in `user_id` and the selected partner in `pricing_partner_id`, while leaving `partner_id` `NULL`. - [x] Existing partner API-key and public-key quote paths preserve their previous `partner_id` behavior. - [x] Fee distribution uses `pricing_partner_id ?? partner_id` for partner markup payout. - [x] Ramp registration updates discount state using `pricing_partner_id ?? partner_id`. - [x] User ownership checks continue to authorize profile-assigned quotes through `user_id`. - [x] Partner ownership checks continue to authorize API-client quotes through `partner_id`. -- [x] Tests cover assigned user quote ownership, ramp-specific partner-ID resolution, quote persistence of `pricing_partner_id`, expired list filtering, and the non-regression path for partner-owned quotes. +- [x] Tests cover assigned user quote ownership, managed child override and manager fallback through delegated and direct-child authentication, ramp-specific partner-ID resolution, quote persistence of `pricing_partner_id`, expired list filtering, and the non-regression path for partner-owned quotes. diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index ef65d4419..4b5a92c31 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -39,7 +39,7 @@ The system maintains an **in-memory** `Map 0`. When applied, the quote snapshots `discountFiat` / `discountUsd` display amounts from the quote-time subsidy component so clients can show the user-facing discount separately from fees without recomputing it later. diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index f8388106f..296cb64d8 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -45,7 +45,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu **Request validation:** Alfredpay middleware (`alfredpay.middleware.ts`) validates the `country` parameter against the `AlfredPayCountry` enum for all Alfredpay-related requests. -**Customer, KYC/KYB, and fiat-account routes:** These routes accept either a Supabase Bearer token or a user-scoped secret API key via `requirePartnerOrUserAuth()`. The controller resolves the effective profile so a manager-selected child or direct child credential uses the child's provider records. Managed-child mutations require the controlling manager's current country corridor, any non-null customer-type narrowing, and canonical corridor/type support. Customer creation uses the child's immutable managed-profile contact email and provisioned entity type; it never inherits the manager's login email. Fiat-account PII is passed directly to Alfredpay and is not persisted in Vortex's database, browser storage, analytics, or notifications. Provider 4xx rejections are sanitized before reaching callers; provider 5xx and transport failures remain opaque. +**Customer, KYC/KYB, and fiat-account routes:** These routes accept either a Supabase Bearer token or a user-scoped secret API key via `requirePartnerOrUserAuth()`. The controller resolves the effective profile so a manager-selected child or direct child credential uses the child's provider records. `GET /alfredpayStatus` accepts an optional `type` selector so headless business flows resolve the business customer explicitly; omitting it retains the active-entity lookup used by existing UI consumers. Managed-child mutations require the controlling manager's current country corridor, any non-null customer-type narrowing, and canonical corridor/type support. Customer creation uses the child's immutable managed-profile contact email and provisioned entity type; it never inherits the manager's login email. Fiat-account PII is passed directly to Alfredpay and is not persisted in Vortex's database, browser storage, analytics, or notifications. Provider 4xx rejections are sanitized before reaching callers; provider 5xx and transport failures remain opaque. ## Security Invariants diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index c0af390af..38e67fe7d 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -74,6 +74,58 @@ and an unknown or partner-owned subaccount is acknowledged without notifying any `attempt.id`, `status`, `result`, `resultMessage` and `updatedAt` are consumed; nothing in the payload updates ramp, quote, or KYC-status state. +### Individual KYC token import (`POST /v1/brla/kyc/import-token`) + +The API-only token path imports an opaque Sumsub share token into one canonical, owned, +individual Avenia KYC case. The code path is enabled under accepted [RISK-021](../RISK-REGISTER.md#current-risks), +but production rollout requires the proposal's [blocking confirmations and sandbox contract +flow](../../proposal-sumsub-kyc-token-sharing.md#blocking-confirmations). Those provider, legal, +consent, and live-sandbox confirmations remain unresolved; source review and tests do not prove +that any confirmation or live verification exists. The caller must place the same CPF used for the +canonical Vortex customer in the source Sumsub applicant's TIN field before generating the token. +Under RISK-021, caller correctness is relied on when Avenia omits `accountInfo.taxId`; a non-empty +provider value is still compared and a mismatch is rejected. + +The route accepts only a profile-bound secret key or Supabase session. A direct profile acts for +itself. A controlling manager may select one directly managed child, but direct child credentials +are explicitly rejected. Authentication and managed authorization precede the route-local JSON +parser and strict validation of the required `Idempotency-Key` and +`{ importToken, consentAttested: true }` body. The parser accepts `application/json` and has a 16 KiB +raw-body limit, which accommodates JSON escaping of the 1 KiB token limit without exposing the +global 20 MB buffer before authentication. No caller identity selector is accepted in the body or query. +The service locks and rereads the manager, exact relationship, subject, and active entity under the +canonical KYC-case transaction before claim reuse or creation, then repeats that authorization before +changing a prepared claim to submitted. The submission transaction locks the provider customer before +the case, then checks authorization, binding, method, claim state, and both approval states. Revocation +or concurrent approval at the second check fails before the provider POST. + +The canonical case has an immutable `standard` or `sumsub_share_token` method. A database lock and +one nullable submission JSON object on that case serialize method selection and provider submission; there is no separate submission table. The raw token is kept +only in request memory; persistence contains a SHA-256 digest, hashed idempotency key, actor, +subject, status, error classification, the complete pre-send attempt-ID baseline, the +provider-submission timestamp stored in the case's existing `submitted_at`, and an append-only consent-attestation array. Each array entry records actor, subject, timestamp, and the provisional server-controlled policy `sumsub-share-v1`. The exact provider attempt is stored only in `provider_case_id`. +The database CHECK rejects unknown top-level or attestation keys and enforces method-specific fields: +token claims require non-empty idempotency/token fingerprints and valid consent entries, while standard +claims require a non-empty payload fingerprint and cannot contain token fields. + +Standard individual KYC submission likewise persists `prepared`, `submitted`, `confirmed`, or +`ambiguous` state before and after the Avenia POST. A retry reconciles the durable claim through the +known attempt or bounded provider history anchored to the persisted provider-submission timestamp +and does not issue another POST while an active claim exists. Provider attempt history reads used +for pre-send baselines and post-send reconciliation exhaust cursor pagination and fail closed on repeated cursors. Every reused claim is bound to +the actor, subject, and a SHA-256 digest of a +fixed canonical representation of the flat identity payload. A mismatch returns a sanitized `409` +before claim/reconciliation replay; only a provider-confirmed retryable terminal attempt releases +the old claim so a new payload fingerprint can be prepared. The full identity payload is sent +through sensitive-body mode; request logging and echoed provider error details are replaced with +fixed non-sensitive text. Unmanaged service calls require the actor and subject to match and forbid +managed selectors. Managed submissions allow either the controlling manager or direct child as actor, +and lock the provider customer before the case, then revalidate the controlling active manager, exact relationship, +`BR` and individual policy, managed subject, and expected active entity during preparation and again +immediately before `prepared -> submitted`; revocation fails only that matching prepared standard +claim and prevents the Level 1 POST. Initial submission method selection performs the same transactional +authorization check before changing a nullable method to `standard`. + Those five fields are runtime-validated before any of them is read (invariant 31). A signed body is still an untrusted shape: the payload is persisted and later rendered into a user's inbox, so a missing `status` or `updatedAt` is rejected `400` rather than queued. @@ -112,19 +164,33 @@ 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-, type-, 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, any current manager customer-type narrowing, and canonical `BR` support for the child's immutable entity type; status and account reads preserve access after policy 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. - -27. **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. -28. **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. -29. **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. -30. **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. -31. **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. -32. **A provider-confirmed paid initial PIX ramp on a runtime-enabled flow MUST be recoverable without current managed-profile authorization** — The client start deadline and current managed corridor/type policy continue to govern public update/start calls. The unhandled-payment worker separately compares the ramp's exact persisted Avenia ticket with the provider's `PAID` tickets. When a signed, still-`initial` ramp is paid, it starts the persisted flow under a row lock without applying the expired client deadline or re-authorizing the now-committed manager policy. A failed automatic attempt remains eligible for later cycles and alerts operations; it is not tombstoned as handled. Moonbeam-dependent AssetHub flows are excluded under RISK-020: the worker does not poll, recover, or alert on them, and operations must reconcile them manually. Startup compatibility checks retain the ramp's persisted flow version while such a payable ticket exists. +26. **Managed BRLA operations MUST remain child-, type-, 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, any current manager customer-type narrowing, and canonical `BR` support for the child's immutable entity type; status and account reads preserve access after policy 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. Individual document-upload and KYC-submission routes MUST require an individual managed profile, and their controllers MUST independently reject a non-individual owned Avenia row before any provider call. `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. +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 retries MUST reconcile an active provider attempt before creating another** — 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. After the POST, the binding transaction locks and rereads the provider customer before the exact case. If concurrent reconciliation already bound the returned attempt, its newer pending, processing, or terminal state remains unchanged; a different concurrent attempt binding fails closed. Before the POST, Vortex lists attempts through the already ownership-verified business account's `provider_subaccount_id`. Exactly one `kyb-level-1` attempt in `PENDING` or `PROCESSING` is transactionally bound to the case and mirrored to both canonical rows, and the endpoint returns that attempt ID without another POST. The same reconciliation runs after a definitive provider `409`. Zero active attempts after a conflict, multiple active attempts, malformed responses, and terminal attempts fail closed; unrelated provider and transport errors are propagated. When no active attempt exists during preflight, terminal attempt retry eligibility remains Avenia's decision on the single subsequent POST. +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. +34. **A provider-confirmed paid initial PIX ramp on a runtime-enabled flow MUST be recoverable without current managed-profile authorization** — The client start deadline and current managed corridor/type policy continue to govern public update/start calls. The unhandled-payment worker separately compares the ramp's exact persisted Avenia ticket with the provider's `PAID` tickets. Runtime-enabled initial ramps remain pollable through the worker's three-day age window when the ticket is absent, has an unknown/non-paid status, or carries a historical unhandled-payment alert flag. When a signed, still-`initial` ramp is paid, the worker starts the persisted flow under a row lock without applying the expired client deadline or re-authorizing the now-committed manager policy. Only successful recovery suppresses later worker cycles; a failed automatic attempt remains eligible and alerts operations. Moonbeam-dependent AssetHub flows are excluded under RISK-020: the worker does not poll, recover, or alert on them, and operations must reconcile them manually. Startup compatibility checks retain the ramp's persisted flow version while such a payable ticket exists. +35. **Ramp updates MUST NOT modify persisted Avenia recovery identity** — `POST /v1/ramp/update` accepts only the documented client-reported transaction-hash fields in `additionalData`. It rejects every other key with `400`, including the registration-owned `taxId`, `subAccountId`, `aveniaTicketId`, and nested `blockState`. The unhandled-payment worker therefore compares paid provider tickets against the immutable identity snapshotted by Avenia registration. +36. **KYC preflight MUST NOT reserve a client-asserted tax identity** — `POST /v1/brla/kyc/record-attempt` may validate authentication, managed BR authorization, quote ownership, and the BRL corridor for compatibility, but MUST NOT create a `provider_customers` or `kyc_cases` row from its client-supplied CPF/CNPJ. Quote ownership proves only quote ownership. The globally unique Avenia tax hash is persisted only by the authenticated subaccount creation flow that establishes the canonical provider account. +37. **Concurrent Avenia KYB case creation within one API process MUST converge on one operation** — `getOrCreateAveniaKybCase` coalesces in-flight creation by provider-customer ID before calling Sequelize, so simultaneous submissions handled by the same process receive the same canonical case. The entry is removed after success or failure so later reads and retries still consult the database. This is intentionally process-local and does not provide a cross-replica database uniqueness guarantee. +38. **UBO creation MUST fail closed after an ambiguous provider outcome** — Before sending an Avenia UBO creation request, Vortex locks the provider customer, requires one canonical KYB case, and records a `prepared` submission using one-way identity and full-payload fingerprints; raw UBO identity payloads and document IDs are not persisted in this state. A parsed provider response records `confirmed` and its UBO ID. Transport errors, timeouts, rate limits, conflicts, and provider failures record `ambiguous`, and subsequent requests for that identity return `409` without another provider POST until an operator reconciles the outcome. Deterministic client rejections record `failed` and may be corrected and retried. +39. **Active-attempt reconciliation MUST NOT overwrite terminal KYB state** — Reconciliation locks and rereads both the provider customer and KYB case before applying `pending` or `in_review`. If either row has become approved or rejected since the provider attempt list was fetched, the stale active response is ignored and terminal status and lifecycle metadata remain intact. +40. **Individual token import MUST be profile-derived and manager-controlled** — The operation accepts only a profile-bound secret credential or Supabase session. A direct authenticated profile may import only for itself and MUST NOT carry an expected managed relationship or entity. Managed import requires the controlling manager, exact active direct relationship, `BR` corridor, individual permission under the null-means-all policy, canonical active entity, canonical BR individual support, and `X-Managed-Profile-Id`; a direct managed-child credential MUST be rejected. Authentication and authorization MUST run before the bounded route-local JSON parser and strict token/body validation. The service MUST lock and reread that managed binding in the canonical KYC-case transaction before claim reuse or creation and again before `prepared -> submitted`; second-check denial MUST fail only the exact still-prepared claim and MUST prevent the provider POST. CPF, tax ID, subaccount, Sumsub applicant, profile, entity, and provider-customer selectors MUST NOT be accepted from the body or query. +41. **Individual Avenia KYC method selection MUST be immutable and serialized** — Migration 066 backfills every existing `kyc_cases` row with `provider = 'avenia'` and `type = 'kyc'` to `standard` without relying on a provider-customer join; cases created later remain nullable until claimed. The first standard document, liveness resource, normal submission, or runtime status read locks a nullable canonical case and selects `standard`; submission locks the provider customer before the case and revalidates current direct or delegated authorization before selecting it. Clients intending token import MUST import before a status read. A token-import claim selects `sumsub_share_token`. Selection cannot be cleared or changed, including after a pre-provider failure. Every normal mutation MUST reject a token-selected case, and token import MUST reject a standard-selected case. Runtime method selection MUST NOT read provider document or attempt history to infer or bind a legacy method. Migration 066 is forward-only after verification state exists: rollback takes an exclusive lock and fails while any method or submission JSON remains. +42. **Token import MUST use durable idempotency without unsafe replay** — Before the provider POST, Vortex persists a claim in the locked canonical case keyed by a hash of the caller's 1-to-128-visible-ASCII `Idempotency-Key`, with a SHA-256 token digest. The JSON CHECK rejects unknown claim keys, requires non-empty actor, subject, idempotency hash, and token digest strings, and requires a non-empty consent array whose objects contain only non-empty actor, subject, policy-version, and timestamp strings. The complete paginated attempt baseline and case `submitted_at` are persisted atomically with the `prepared` to `submitted` transition. Baseline JSON elements MUST be non-empty strings. A failed pre-provider baseline read marks the claim failed and requires a new idempotency key even though the token was not sent. A confirmed same-key/same-token retry returns the case's exact `provider_case_id` without another POST; a changed token returns `409`. A same-key/same-token retry of a submitted or ambiguous claim MAY reconcile through the known attempt or bounded provider history, but history reconciliation MUST use `submitted_at` and exclude baseline attempts and attempts bound to another KYC case's `provider_case_id`. A missing timestamp or non-unique result fails closed, and the token-import POST MUST NOT be repeated or the token resent. Provider `401` is the sole post-send result classified `failed`: it returns `412`, and a retry requires a new idempotency key. Every other initial post-send provider, transport, timeout, malformed-response, 5xx, or local-confirmation failure is `ambiguous`, returns `502`, and MUST NOT automatically replay under any key. +43. **The imported token MUST remain secret** — It is accepted only as a non-empty opaque request-body string of at most 1 KiB, held only in request memory, and sent through the Avenia client's sensitive-body mode. It MUST NOT be returned, persisted, parsed, placed in a URL, or emitted to logs, provider error details, Sentry, analytics, traces, metrics labels, API client events, or support data. Only a SHA-256 digest may be retained for input-consistency checks. +44. **Imported KYC status MUST bind to the exact Avenia attempt and conditionally verify tax identity** — The accepted attempt ID is stored on the canonical case and exact-attempt polling MUST reject a mismatched provider response. `PENDING` maps to pending, `PROCESSING` to in review, and `EXPIRED` remains non-approved and locally pending for reconciliation while the provider value is retained in `status_external`. Only Avenia `COMPLETED + APPROVED` may approve the case and provider customer; token possession, import acceptance, Sumsub assertions, and client assertions never approve KYC. Every transaction that mutates both canonical rows MUST lock `provider_customers` before `kyc_cases` so confirmation and status persistence cannot deadlock each other. Before either individual status path persists imported approval, it MUST fetch Avenia subaccount info. If Avenia exposes a non-empty `accountInfo.taxId`, Vortex MUST normalize and hash it with the canonical tax-reference function and require equality with `provider_customers.tax_reference_hash`; mismatch MUST leave both rows unapproved. If the provider field is absent or empty, approval behavior is unchanged because provider confirmation that it is guaranteed for imported KYC is still a deployment blocker. Provider-read failure prevents that poll from approving. Existing terminal approval MUST NOT be downgraded. +45. **The Avenia webhook MUST remain notification-only for imported KYC** — A signed event may enqueue an idempotent notification but MUST NOT approve or otherwise mutate verification state. Exact-attempt polling remains the authoritative persistence path. +46. **Consent evidence MUST remain explicit and provisional** — The request requires literal `consentAttested: true`; Vortex appends an entry containing actor, subject, timestamp, and server-controlled policy `sumsub-share-v1` to `verification_submission.consentAttestations` without the raw token. Replacing a provider-`401` failed claim with a new idempotency key MUST preserve prior entries and append the new attestation. This policy is enabled provisionally and MUST NOT be represented as replacing legal basis, applicant disclosure, biometric/special-category consent, or data-transfer obligations while legal/provider confirmations remain unresolved. +47. **Standard individual KYC submission MUST be durable, request-bound, authorized at claim time, and privacy-safe** — Vortex MUST persist the submission JSON on the locked canonical case before the Avenia Level 1 POST and atomically persist its complete attempt baseline and case `submitted_at` when claiming it as `submitted`. Unknown or local-confirmation outcomes become `ambiguous`; reconciliation uses that timestamp, excludes baseline and attempts bound to other cases, fails closed when it is absent, and never sends a second POST for an active claim. Prepared, submitted, ambiguous, and confirmed reuse MUST match actor, subject, and the SHA-256 digest of the canonical flat payload before reconciliation or replay; mismatches return a fixed `409` without a provider POST. Unmanaged calls MUST have `actor === subject` with no managed selectors. Managed calls MUST identify the controlling manager separately, allow only `actor === controlling manager` or `actor === subject`, and lock and revalidate the controlling active manager, exact active manager/subject/relationship ID, `BR` permission, null-means-all individual policy, managed subject, and expected active individual entity during preparation and immediately before `prepared -> submitted`. Second-check revocation or a concurrent canonical approval MUST prevent the provider POST. Confirmation MUST bind its exact attempt ID even when approval won concurrently, without downgrading terminal state or timestamps. A provider-confirmed retryable terminal may replace the prior confirmed JSON with a prepared claim carrying the new request fingerprint; confirmation clears prior case approval/rejection timestamps and failure reasons before returning it to pending. A standard provider state of `COMPLETED` without `APPROVED` or `REJECTED` MUST fail closed before either direct-status or onboarding persistence. The standard identity payload MUST use the Avenia client's sensitive-body mode, and raw payloads, digest inputs, request logs, provider error details, and thrown errors MUST NOT contain identity values echoed by Avenia. ## Threat Vectors & Mitigations @@ -149,6 +215,11 @@ 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 KYB attempt while provider processing is active** | A caller starts another API or hosted KYB attempt while Avenia is already processing one for the company. | Both creation paths list attempts for the ownership-verified subaccount. Hosted creation rejects an active attempt. API creation transactionally binds exactly one active attempt and returns it without POSTing; ambiguous multiple-active results fail closed. A provider `409` triggers the same scoped re-query and exact-one reconciliation. Terminal attempts are left to Avenia's retry rules. | +| **Tax-ID reservation through KYC preflight** | An authenticated attacker owns a BRL quote but submits a victim's valid CPF/CNPJ to the initial-attempt endpoint, attempting to occupy the globally unique Avenia tax hash. | The endpoint retains its empty compatibility response and quote checks but performs no identity persistence. Only canonical subaccount creation may create the globally reserving provider-customer row. | +| **Share-token replay or ambiguous duplicate import** | A timeout or malformed provider response causes the caller to resend a bearer-like identity-transfer token, potentially creating multiple attempts or transferring data twice. | A durable pre-send claim and token digest serialize submission. Same-key/same-token retries may reconcile through provider reads without another POST or token send. Only provider `401` is failed/retriable with a new key; other unresolved outcomes remain quarantined and are never automatically replayed. | +| **Share-token disclosure** | Request/error logging, telemetry, provider errors, or support tooling captures the token and enables unauthorized identity-data transfer. | Sensitive-body provider mode, flat sanitized errors, strict observability exclusion, request-memory-only handling, and digest-only persistence prevent raw-token persistence or emission. | +| **KYC completion spoofing** | A caller treats token possession, import acceptance, a Sumsub result, or a webhook as proof of approval. | The canonical case binds one exact Avenia attempt; only exact polling of Avenia `COMPLETED + APPROVED` can approve. `EXPIRED` remains non-approved and pending reconciliation, and the webhook is notification-only. | ## Audit Checklist @@ -159,7 +230,8 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou - [x] On-chain BRLA transfer amount equals the subsidy-adjusted full swap output. **PASS** — `metadata.blocks.aveniaOfframpPayout.transferAmountRaw` is derived from the post-subsidy BRLA phase input and is used unchanged by the payout transaction preparer; the PIX amount remains immutable `quote.outputAmount`. - [x] User CPF/tax ID is validated at ramp registration (not at payout). **PASS** — CPF validation present in registration flow. - [x] Avenia subaccount creation is idempotent. **PASS** — returns an owned canonical account unchanged; otherwise a durable provider-operation claim makes confirmed results replayable and ambiguous results reconciliation-only before the provider call can be repeated. -- [x] Paid initial PIX ramps on runtime-enabled flows are recovered automatically. **PASS** — the unhandled-payment worker starts the persisted flow only after Avenia reports the exact ticket `PAID`; failed attempts remain retryable and alert operations. Moonbeam-dependent AssetHub ramps are skipped without automatic provider polling or alerts and require manual reconciliation under RISK-020. +- [x] Paid initial PIX ramps are recovered automatically. **PASS** — the unhandled-payment worker keeps absent, unknown, non-paid, and historically alerted initial tickets pollable through the three-day age window, starts the persisted flow only after Avenia reports the exact ticket `PAID`, suppresses successful recovery, and retries failed attempts while alerting operations. +- [x] Ramp updates cannot replace Avenia recovery identity. **PASS** — `RampService.updateRamp` allowlists client-reported transaction hashes and rejects registration-owned identity and block state. - [x] Recovery: `payOutTicketId` short-circuits ticket re-creation. **PASS** — verified in `phases/blocks/phases/avenia-offramp-payout/execution.ts`. - [x] Recovery: `brlaPayoutTxHash` short-circuits on-chain transfer re-broadcast. **PASS** — verified in `phases/blocks/phases/avenia-offramp-payout/execution.ts`. - [ ] Avenia API responses are validated (status, amount, ticket ID). **PARTIAL** — ticket status checked for `PAID`/`FAILED`; `PARTIAL-FAILED` is modeled and the rebalancer handles it for Polygon transfer tickets, but API payout handlers still treat only `FAILED` as terminal; no explicit amount cross-check on `getAccountBalance` response shape. @@ -171,11 +243,18 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou - [x] PIX deposit details (QR code) generated server-side. **PASS** — comes from Avenia API response. - [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` requires the public `quoteId` and `taxId` payload fields, uses `requirePartnerOrUserAuth()`, and additionally requires active BR authorization for delegated requests. +- [x] **FINDING F-064 (MEDIUM)**: BRLA KYC callback endpoint requires authentication. **PASS (FIXED)** — `/kyc/record-attempt` requires the public `quoteId` and `taxId` payload fields, uses `requirePartnerOrUserAuth()`, and delegated requests additionally require active BR authorization. The controller asserts the caller owns the referenced quote and that it is a Brazil corridor, then returns the legacy empty response without persisting the asserted tax ID. It cannot create a globally reserving `provider_customers` row from quote ownership alone. +- [x] Avenia API KYB operations enforce effective-profile ownership, company account type, document readiness/type, and reject new attempts while Avenia reports a `PENDING` or `PROCESSING` KYB attempt. - [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. - [x] BRL Base output topology is token-specific. **PASS** — block catalog resolution maps USDC to the no-Squid flow, BRLA to the direct bypass, and USDT/ETH/AXLUSDC/EURC to the one-phase same-chain Squid flow; flow and transaction tests enforce Base construction and contiguous destination nonce ordering. +- [x] Individual token import is auth-first, profile-derived, manager-only for children, and rejects direct child credentials. **PASS** — route middleware ordering and service ownership checks enforce the contract before strict body validation. +- [x] Standard and token KYC methods are immutable and durably serialized. **PASS** — migration 066 adds the method trigger and one submission JSON field; the canonical case row lock serializes every claim. +- [x] Token import does not replay ambiguous outcomes. **PASS** — provider `401` alone becomes failed/retriable with a new key; all other post-send failures become ambiguous. A same-key/same-token retry may reconcile through provider reads, while no retry path repeats the token-import POST. +- [x] Standard KYC submissions are durable, claim-time authorized, and use sensitive provider logging. **PASS** — active case claims reconcile without another Level 1 POST; direct calls are self-only; managed authorization is locked and revalidated before send, with revocation failing only the matching prepared JSON claim before any Level 1 POST; and the shared BRLA client suppresses the request body and echoed provider details. +- [x] Imported KYC uses exact-attempt polling and provider-only approval. **PASS** — the stored provider attempt ID controls status; `EXPIRED` remains pending and non-approved for reconciliation with its external status retained, and only Avenia `COMPLETED + APPROVED` approves. +- [ ] Live Avenia sandbox token import and final legal/provider confirmations. **OPEN (RISK-021)** — source is enabled under the accepted exception, but production rollout requires the proposal's [blocking confirmations and sandbox contract flow](../../proposal-sumsub-kyc-token-sharing.md#blocking-confirmations); none is claimed complete. ## Remediation Notes @@ -195,11 +274,13 @@ Key properties: No masked copy is persisted; masked display is derived with `maskTaxReference` at read time. - `status` uses the shared canonical verification enum. Avenia's unmodified attempt status is mirrored to `status_external` whenever polling returns one; the attempt result determines the - canonical terminal status. The initial `Consulted` interaction maps to `started`, while - `Requested` and active attempt processing map to `in_review`; a missing or expired attempt maps + canonical terminal status. Legacy `Consulted` rows map to `started`, while `Requested` + and active attempt processing map to `in_review`; a missing or expired attempt maps to `pending`. For company KYB, subaccount creation and a still-`PENDING` attempt also map to - `pending` (resumable — the user has not completed the hosted steps); only `PROCESSING` maps to - `in_review` (invariant 22). + `pending`; only `PROCESSING` maps to `in_review` (invariant 22). API submission retries reconcile + exactly one active provider attempt into the local case. Hosted re-initiation remains available while + the provider reports `PENDING`, because continuation URLs are not stored and re-initiation is the only + resume path; a `PROCESSING` attempt blocks re-initiation. - Business rows may store a nullable `company_name`. It is set from the name accepted during subaccount creation and missing legacy values are lazily refreshed from Avenia account info. - Migration 060 permanently deletes `tax_ids`, including ownerless/quarantined rows and any diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index d8ec9f606..b82084d96 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -8,7 +8,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - CORS: Explicit origin whitelist — `app.vortexfinance.co`, `dashboard.vortexfinance.co`, `metrics.vortexfinance.co`, staging Netlify (non-production only, gated on `DEPLOYMENT_ENV`), `localhost` (dev only), plus the optional `DASHBOARD_ORIGINS` env var (comma-separated fixed origins for non-production dashboard deployments; resolved once at boot, wildcard entries dropped) and the optional `DASHBOARD_PREVIEW_SITE` env var (a single Netlify site slug; enables the fixed-shape pattern `https://deploy-preview---.netlify.app` for dashboard deploy previews, non-production only; helpers in `config/corsOrigins.ts`) - Rate limiting: 100 requests per minute per IP (global, all endpoints) - Helmet: Standard HTTP security headers -- Body parser: JSON with **20MB limit** +- Body parser: JSON with **20MB limit**, except auth-first `POST /v1/brla/kyc/import-token`, whose route-local parser has a **16 KiB limit** - Cookie parser: Enabled (for Supabase auth tokens) **Input validation** (`middlewares/validators.ts`): @@ -39,7 +39,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - During an active window, mutable quote/ramp operations return HTTP `503 Service Unavailable` before controller/service work starts. - Rejections include `Retry-After`, `Cache-Control: no-store`, and downtime metadata (`maintenance_start`, `maintenance_end`, affected operations) in the error payload so direct API clients can pause and retry after the window. -**Route structure:** 40 `*.route.ts` files under `api/routes/` (33 under `v1/`), plus `v1/index.ts`, each mounting controllers with appropriate auth middleware. `api/routes/api-surface-inventory.test.ts` derives this count from the tree so the audit inventory cannot silently stale. +**Route structure:** 41 `*.route.ts` files under `api/routes/` (34 under `v1/`), plus `v1/index.ts`, each mounting controllers with appropriate auth middleware. `api/routes/api-surface-inventory.test.ts` derives this count from the tree so the audit inventory cannot silently stale. **Multipart uploads:** Four operations use in-memory Multer buffering. Alfredpay's `POST /v1/alfredpay/submitKycFile`, `submitKybFile`, and `submitKybRelatedPersonFile` allow one file up to 5MB; secret/Bearer authentication and the managed relationship/entity-type gate run before buffering, while multipart country authorization runs after parsing. Mykobo's `POST /v1/mykobo/profiles` is Supabase-authenticated before buffering and accepts up to four named files (`front`, `back`, `face`, `utility_bill`), each up to 10MB. These routes bound individual file size but do not currently configure a MIME/type `fileFilter`; the Mykobo request can buffer up to 40MB in aggregate. @@ -71,6 +71,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api 24. **Managed-profile context MUST be route-authorized** — On supported child-oriented routes, `X-Managed-Profile-Id` is a selector only. A Supabase session or secret API credential establishes the manager actor; middleware verifies active manager configuration, a direct active relationship, the managed child and its single active customer entity, every required corridor, optional manager customer-type narrowing, and canonical corridor capability before attaching an immutable actor/subject context. Public API keys cannot establish the manager actor, raw headers never alter `req.userId`, and ownership checks use the verified child subject. A child-owned credential instead authenticates directly as its child and dynamically derives the same controlling relationship and current policy; it cannot select another child. Manager or relationship deactivation and policy narrowing block authorization decisions begun after the committed change but do not cancel already-authorized requests in flight. The header is explicitly CORS-allowlisted for browser-based manager sessions, and relationship plus route-specific entity-type authorization precedes multipart buffering; multipart country authorization follows body parsing. 25. **Headless profile lifecycle MUST fail closed** — Manager lifecycle routes derive the manager from a Supabase session or profile-bound secret credential and require its current manager configuration to be active. Creation requires an immutable provider contact email separate from the child's null login email; normalized contact emails are unique and permanently reserved within each manager. Child reads, credential management, and deletion are scoped by both manager and child profile IDs so foreign relationships are indistinguishable from missing rows. Only the manager-scoped child-credential route may issue credentials for a managed subject; generic profile-managed and admin partner-managed creation reject them. Credential creation and logical deletion lock the child profile and relationship in a common order; deletion is idempotent, revokes child credentials in the same transaction, and leaves retained provider, KYC, quote, ramp, and callback state intact. Managed profiles cannot create a second customer-entity type after provisioning. 26. **Unsupported managed operations MUST fail explicitly** — Recipient invitation routes reject `X-Managed-Profile-Id` rather than silently applying the request to the manager. Direct child credentials are rejected from webhook and manager lifecycle routes. Managed children have one immutable active customer entity from provisioning, so `PUT /v1/onboarding/active-entity` is not a delegated child operation. +27. **Public onboarding discovery MUST keep OpenAPI authoritative for request schemas** — `GET /v1/onboarding/requirements` is unauthenticated and returns only the reviewed static Avenia/Alfredpay flow identity, document requirements, ordered non-GET API/hosted/upload actions, workflow value bindings, and documentation/OpenAPI links. Initial reads, readiness getters, redirect getters, and status polling MUST NOT be advertised; integration documentation and OpenAPI own those completion details. No top-level field catalog or independent request schema is returned. `fixedBody`, `fixedQuery`, and `derivedValues` may bind provider discriminators or prior step outputs only to body/query fields accepted by the referenced OpenAPI operation. The endpoint MUST NOT inspect profile state, return customer or provider identifiers, accept an owner selector, or advertise unsupported combinations such as AR business or Monerium flows. Every advertised API step, request-schema fragment, and workflow-binding target is checked against the reviewed OpenAPI document so stale mappings fail the documentation gate. ## Threat Vectors & Mitigations @@ -109,6 +110,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - [x] Verify no route accidentally uses `publicKeyAuth` (public key only, no secret key) for operations that should require `apiKeyAuth` (secret key). **PASS** — auth middleware usage reviewed per route. - [ ] Verify controllers do not pass raw `req.body` to database operations — check for Sequelize `.create(req.body)` or `.update(req.body)` patterns. **N/A** — deferred; requires comprehensive Sequelize usage audit. - [x] Verify no endpoint returns `process.env`, server config, or internal paths in responses. **PASS** — no endpoint exposes internal configuration. +- [x] Public onboarding discovery exposes static action metadata without GET operations or an independent request schema, rejects unsupported country/customer-type combinations, and has no authentication-derived response branch. **PASS** — the controller reads only the reviewed shared mapping and the documentation gate verifies every advertised operation, schema reference, and fixed/derived workflow-binding target against OpenAPI. - [ ] Check whether Supabase auth cookies use `SameSite=Strict` or `SameSite=Lax` — and whether CSRF tokens are required for state-changing operations. **PARTIAL** — cookie parser enabled but cookie attributes not explicitly configured for `SameSite`. - [x] Verify the 404 handler does not reveal Express version or framework information. **PASS** — custom 404 handler returns generic JSON error. - [ ] Check every route file for endpoints that accept file uploads — verify file size limits, aggregate limits, authentication order, and type validation. **PARTIAL** — all four current upload operations authenticate before in-memory buffering and bound each file (5MB Alfredpay; 10MB Mykobo), but no Multer MIME/type filter is configured and Mykobo permits four files (40MB aggregate). The derived route/upload inventory is pinned by `api/routes/api-surface-inventory.test.ts`. diff --git a/docs/security-spec/07-operations/client-observability.md b/docs/security-spec/07-operations/client-observability.md index 3bd75650c..44d05f8e9 100644 --- a/docs/security-spec/07-operations/client-observability.md +++ b/docs/security-spec/07-operations/client-observability.md @@ -11,6 +11,8 @@ The observed surface includes: - Ramp register, update, start, status, and error-log retrieval. - Request correlation through `X-Request-ID` / `X-Correlation-ID` and response `X-Request-ID`. +Avenia standard-KYC and token-import controller/provider operations are not currently emitted to `api_client_events`. A sanitized `auth_dual` failure may still be emitted by authentication before the route-local KYC body parser runs. Identity payloads, raw tokens, request bodies, fingerprints, consent data, and provider details remain strictly excluded from all observability channels. + Events are persisted in `api_client_events` and structured logs are emitted through the existing backend logger. The event table is an operational telemetry store, not a source of truth for ramp state. Ramp execution failures remain in `RampState.errorLogs`; client observability events are request-level records used for alerting and incident investigation. Internal operators can inspect these events through `GET /v1/admin/api-client-events`, which is protected by the dedicated `Authorization: Bearer ` middleware. `METRICS_DASHBOARD_SECRET` must be different from `ADMIN_SECRET` to reduce blast radius. @@ -29,6 +31,8 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev 10. **Credential mismatch MUST be observable without exposing values** — `CREDENTIAL_MISMATCH` events may identify the request, endpoint, and safe credential IDs/prefixes, but must not persist either full key value or combine the mismatched contexts into one authoritative subject. 11. **Startup credential failures MUST be operationally visible but fail closed** — missing schema elements, constraints, indexes, or a remaining legacy credential table must be logged without key values; observability failure must not allow the server to listen. 12. **Public `ramp-info` telemetry MUST remain sanitized** — events may record operation, outcome, credential ID/strength, safe prefix, duration, and HTTP status. They must not include the response projection, KYC details, profile selectors, provider identifiers, or exact limits. +13. **KYC token-import telemetry MUST exclude token material** — Token-import and standard-KYC controller/provider operations do not currently emit `api_client_events`; sanitized `auth_dual` failures may be emitted before KYC body parsing. `importToken`, the request body, token fingerprints, consent payloads, identity payloads, provider request/response details, and free-form provider errors MUST NOT enter `api_client_events`, structured logs, traces, Sentry, metrics, or support exports. Any future instrumentation may record only bounded operation/outcome data, HTTP status, duration, request ID, safe authenticated credential attribution, and stable public error classifications. +14. **Standard KYC provider logging MUST omit identity payloads** — Level 1 names, birth dates, tax IDs, emails, addresses, document IDs, selfie IDs, request bodies, and provider errors that may echo those values MUST NOT enter logs, traces, Sentry, metrics, or support exports. The Avenia client may log only endpoint/method context and a fixed sensitive-payload omission marker, and thrown provider errors must contain a fixed sanitized response body. ## Threat Vectors & Mitigations @@ -46,6 +50,8 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev | **BI embed secret leak** — A future Metabase embed is generated in client-side code | Generate signed embed URLs only from the backend. Do not place Metabase signing secrets in publicly exposed environment variables. | | **Mismatch logs leak two credentials** — Error instrumentation records both full presented halves | Emit `CREDENTIAL_MISMATCH` with safe IDs/prefixes only and never attach raw headers or request bodies. | | **Public eligibility telemetry becomes a shadow profile store** — `ramp-info` events persist KYC state or provider details | Record only request outcome metadata; keep the response and all identity/provider details out of events. | +| **KYC share token captured by telemetry** — Generic request summarization stores `importToken`, consent data, a token fingerprint, or a provider error containing request configuration | Token-import controller/provider operations do not currently emit API client events. Authentication may emit a sanitized `auth_dual` failure before body parsing. The shared sanitizer still excludes sensitive fields, raw bodies and nested metadata are forbidden, provider errors are sanitized, and any future instrumentation may observe only stable bounded outcome classifications. | +| **Standard KYC identity echoed into logs** — Request debugging or an Avenia validation error captures names, tax IDs, addresses, document identifiers, or other submitted identity data | Standard Level 1 submission uses sensitive-body mode, which omits the request payload and replaces provider response/error details with fixed text before the error reaches callers or logging. | ## Audit Checklist @@ -63,3 +69,6 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev - [ ] Verify credential events use immutable credential ID/strength and safe prefixes without full `X-Public-Key` or `X-API-Key` values. - [ ] Verify `CREDENTIAL_MISMATCH` records no mixed authoritative subject and no presented key values. - [ ] Verify future `ramp-info` events omit KYC projection data, exact limits, profile selectors, and provider identifiers. +- [x] Verify token-import and standard-KYC controller/provider operations are not emitted to `api_client_events`. **PASS** — neither controller emits client events; sanitized `auth_dual` failures can be emitted before body parsing, the shared sanitizer excludes `importToken`, and tests assert the raw value is absent from sanitized client-event output. +- [ ] Verify production logs, Sentry, traces, metrics, and support exports contain no raw Sumsub token, token fingerprint, provider request configuration, or token-import body. +- [x] Verify shared Avenia standard-KYC request and error logging contains no submitted identity values. **PASS** — the client uses sensitive-body mode and tests assert a sentinel is absent from every logger level and thrown-error representation. diff --git a/docs/security-spec/07-operations/secret-management.md b/docs/security-spec/07-operations/secret-management.md index da08a7f80..f7a35a69e 100644 --- a/docs/security-spec/07-operations/secret-management.md +++ b/docs/security-spec/07-operations/secret-management.md @@ -58,6 +58,7 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a 9. **`MYKOBO_CLIENT_DOMAIN` MUST be set in production** — Not a secret, but operationally critical: when unset, Mykobo silently applies its default fee tier (~5x worse than the negotiated rate). Quote-engine fee defaults will then diverge from what Mykobo actually charges. Deployment automation MUST treat a missing `MYKOBO_CLIENT_DOMAIN` as a hard failure rather than letting it fall through to default-tier fees. 10. **Observability MUST follow the same no-secret rule as logs** — API client events, request correlation logs, metrics, and observability data must not contain full API keys, bearer tokens, provider credentials, private keys, seeds, raw request headers, or raw request bodies. Sanitized request summaries may be stored only when they are allowlisted, scalar, and stripped of secrets or sensitive payment/user data. See `07-operations/client-observability.md`. 11. **Provider endpoint URLs MUST be treated as integrity-sensitive configuration** — Non-secret provider URL env vars such as `FASTFOREX_API_URL` and `MYKOBO_BASE_URL` must not be user-controllable or mutable at runtime by untrusted actors. A malicious URL can redirect outbound provider calls even when no secret is leaked. +12. **Sumsub share tokens MUST be treated as transient secrets** — Raw import tokens may exist only in request memory during the Avenia exchange. They MUST NOT be persisted or emitted to logs, URLs, provider error details, Sentry, analytics, traces, metrics, API client events, support artifacts, or responses. Only a SHA-256 digest may be stored for equality checks. ## Threat Vectors & Mitigations @@ -72,6 +73,7 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a | **Google Sheets credentials** — Access to fee logging spreadsheet | Could expose fee data and ramp metadata. Could manipulate fee records. Lower severity than financial keys but still a data leak. | | **`SUPABASE_SERVICE_KEY` used for all database operations** — No principle of least privilege | The service key bypasses all RLS. If any code path leaks this key, the attacker has unrestricted database access. A more secure approach would use the anon key with RLS for read operations and the service key only for privileged writes. | | **Observability event leak** — Operational telemetry captures secret values or payment/KYC data | Client observability uses a sanitized event schema, 16-character key prefixes only, allowlisted scalar request summaries, scalar metadata filtering, and explicit exclusion of raw headers/bodies, tax IDs, PIX data, KYC data, and private material. | +| **KYC data disclosure** — A bearer-like share token or standard identity payload is logged or retained in operational data | Sensitive provider requests suppress body and response details; observability rejects secret KYC inputs; persistence retains only SHA-256 equality digests. | ## Audit Checklist @@ -91,3 +93,4 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a - [x] Map the full blast radius: if the API server is compromised, list every account, service, and database that becomes accessible. **PASS (comprehensive)** — full blast radius documented in the Secret Inventory table above. - [x] **FINDING F-062 (MEDIUM)**: Verify SDK does not log API keys or secrets to console. **PASS (FIXED)** — removed `console.log("Creating quote with request:", request)` from `ApiService.ts` that was leaking the full request object including API key. - [ ] Verify API client event persistence stores only 16-character key prefixes and never stores full `X-API-Key`, bearer tokens, raw auth headers, or request bodies. +- [x] Verify Avenia share tokens are request-memory-only and provider request/error logging is suppressed. **PASS** — the import client uses sensitive-body mode and persistence stores only a SHA-256 digest. diff --git a/docs/security-spec/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md index 4b92ff93e..175695ac2 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -40,6 +40,8 @@ register and the owning module specification. | RISK-017 | Deferred | High | Operations + Data | Migrations 060-061 permanently delete legacy provider/KYC/credential records and schema objects. Approved legacy-only data has no archive or database down migration; unknown consumers, old processes, incomplete canonical mappings, or an unusable backup could turn deployment into unrecoverable loss or outage. | Fail-closed parity script, maintenance-window hard cutover and process drain, PostgreSQL catalog/external-consumer audit, five-second lock timeout, default `RESTRICT`, and rehearsed pre-migration restore. | Complete and retain every gate in [`operations-legacy-schema-cleanup.md`](../operations-legacy-schema-cleanup.md), verify production cleanup, and confirm the post-deploy observation window is clean. | | RISK-019 | Accepted | High | Product + Compliance | Managed-profile contact email uniqueness is manager-scoped, while Alfredpay uses email as provider identity. Different managers can submit the same normalized email; on an Alfredpay `409`, Vortex may adopt the provider customer returned for that email when country and customer type match, without independent proof that the second manager controls that provider identity. | Manager/child authorization remains isolated; contact email is immutable and unique within one manager; conflict recovery rejects country/type mismatch; the provider customer ID remains globally unique locally. Partners must supply an email identity they are authorized to use, and operations must investigate cross-manager collision errors rather than bypass uniqueness. | Before onboarding managers whose customer-email namespaces may overlap, enforce global or provider-scoped ownership of contact email, or replace email-based adoption with a provider ownership/claim proof and migrate existing relationships. | | RISK-020 | Deferred | High | Cross-chain + Operations | Moonbeam is unavailable. Historical ramps, residual ephemeral funds, and legacy rebalancer state may remain stranded. A successful `moonbeamCleanup` now records retirement acknowledgement rather than an on-chain sweep. | Moonbeam-dependent registration/update/start, phase execution, automatic recovery, status polling, and legacy rebalancing are disabled without deleting persisted flow identities or recovery data. | Reconcile every affected ramp/account and complete a reviewed manual rescue before restoring any Moonbeam runtime path or automatic recovery. | +| RISK-021 | Accepted | High | Product + Compliance + Operations | Individual Avenia token-import code is enabled despite unresolved provider, legal, consent, and live-sandbox production-readiness confirmations. The caller is responsible for placing the canonical CPF in the source Sumsub applicant's TIN field; when Avenia omits `accountInfo.taxId`, Vortex accepts provider approval without independently comparing that CPF. This accepts enabled source behavior, not production readiness or evidence that any confirmation exists. | Auth-first profile binding, immutable KYC method selection, durable no-replay claims, exact-attempt polling, rejection of non-empty CPF mismatches, provisional consent evidence, and sensitive-data redaction. | Production rollout requires every [blocking confirmation and sandbox contract flow](../proposal-sumsub-kyc-token-sharing.md#blocking-confirmations) in the proposal. Add mandatory provider-returned CPF validation if caller responsibility proves insufficient. | +| RISK-022 | Accepted | Medium | Product + Compliance + Operations | Avenia API and hosted KYB submission do not persist a durable pre-send claim. An ambiguous provider success or concurrent retry can therefore leave an unbound or superseded provider attempt. The low current KYB volume does not justify the additional submission-state machinery. | Provider active-attempt preflight, API conflict reconciliation, exact bound-attempt polling, and fail-closed handling of multiple active attempts. | Add a durable submission claim before increasing KYB volume, relying on unattended recovery, or observing duplicate or orphaned attempts operationally. | ## Review cadence diff --git a/packages/shared/src/endpoints/alfredpay.endpoints.ts b/packages/shared/src/endpoints/alfredpay.endpoints.ts index 3ae28695b..4fd5651e9 100644 --- a/packages/shared/src/endpoints/alfredpay.endpoints.ts +++ b/packages/shared/src/endpoints/alfredpay.endpoints.ts @@ -12,6 +12,7 @@ import { // GET /alfredpay/alfredpayStatus?country=:country export interface AlfredpayStatusRequest { country: string; + type?: AlfredpayCustomerType; } export interface AlfredpayStatusResponse { @@ -32,7 +33,6 @@ export interface AlfredpayCreateCustomerResponse { // GET /alfredpay/getKycRedirectLink?country=:country export interface AlfredpayGetKycRedirectLinkRequest { country: string; - type?: AlfredpayCustomerType; } export type AlfredpayGetKycRedirectLinkResponse = GetKycRedirectLinkResponse; diff --git a/packages/shared/src/endpoints/brla.endpoints.ts b/packages/shared/src/endpoints/brla.endpoints.ts index e9313ce18..9de2c3b98 100644 --- a/packages/shared/src/endpoints/brla.endpoints.ts +++ b/packages/shared/src/endpoints/brla.endpoints.ts @@ -42,11 +42,9 @@ export interface BrlaGetRampStatusResponse { status: string; } -// GET /brla/getKycStatus?taxId=:taxId"eId=:quoteId +// GET /brla/getKycStatus?taxId=:taxId export interface BrlaGetKycStatusRequest { taxId: string; - quoteId: string; - sessionId?: string; } export interface BrlaGetSelfieLivenessUrlRequest { @@ -57,7 +55,7 @@ export interface BrlaGetKycStatusResponse { type: "KYC"; level: string; status: KycAttemptStatus; - result: KycAttemptResult; + result?: KycAttemptResult; failureReason?: KycFailureReason; } @@ -103,7 +101,7 @@ export interface BrlaCreateSubaccountRequest { accountType: AveniaAccountType; name: string; taxId: string; - // Optional: the KYB deep link creates a subaccount without a quote. The backend stores it as a nullable initialQuoteId. + // Optional: quote-less onboarding paths can create a subaccount without a quote. quoteId?: string; sessionId?: string; } @@ -112,6 +110,17 @@ export interface BrlaCreateSubaccountResponse { subAccountId: string; } +// POST /brla/kyc/import-token +export interface BrlaImportKycTokenRequest { + importToken: string; + consentAttested: true; +} + +export interface BrlaImportKycTokenResponse { + attemptId: string; + status: "pending"; +} + export interface BrlaErrorResponse { error: string; details?: string; diff --git a/packages/shared/src/endpoints/index.ts b/packages/shared/src/endpoints/index.ts index 4fb011570..0f9425d50 100644 --- a/packages/shared/src/endpoints/index.ts +++ b/packages/shared/src/endpoints/index.ts @@ -5,6 +5,7 @@ export * from "./contact.endpoints"; export * from "./email.endpoints"; export * from "./limits.endpoints"; export * from "./moonbeam.endpoints"; +export * from "./onboarding-requirements.endpoints"; export * from "./payment-methods.endpoints"; export * from "./pendulum.endpoints"; export * from "./price.endpoints"; diff --git a/packages/shared/src/endpoints/onboarding-requirements.endpoints.test.ts b/packages/shared/src/endpoints/onboarding-requirements.endpoints.test.ts new file mode 100644 index 000000000..1ecbb9824 --- /dev/null +++ b/packages/shared/src/endpoints/onboarding-requirements.endpoints.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { ONBOARDING_REQUIREMENTS } from "./onboarding-requirements.endpoints"; + +describe("ONBOARDING_REQUIREMENTS", () => { + test("publishes every supported Avenia and Alfredpay product flow", () => { + expect(Object.fromEntries(Object.entries(ONBOARDING_REQUIREMENTS).map(([country, flows]) => [country, Object.keys(flows).sort()]))).toEqual({ + AR: ["individual"], + BR: ["business", "individual"], + CO: ["business", "individual"], + MX: ["business", "individual"], + US: ["business", "individual"] + }); + }); + + test("uses unique ordered steps and OpenAPI component references without duplicating request fields", () => { + for (const flows of Object.values(ONBOARDING_REQUIREMENTS)) { + for (const requirements of Object.values(flows)) { + expect(requirements).toBeDefined(); + if (!requirements) continue; + + expect("fields" in requirements).toBe(false); + expect(requirements.steps.some(step => step.method === "GET")).toBe(false); + expect(requirements.steps.map(step => step.order)).toEqual( + Array.from({ length: requirements.steps.length }, (_, index) => index + 1) + ); + for (const step of requirements.steps) { + if (step.kind === "api") expect(step.operationId).toBeString(); + if (step.requestSchema) expect(step.requestSchema).toStartWith("#/components/schemas/"); + } + } + } + }); + + test("keeps provider-hosted collection explicit", () => { + expect(ONBOARDING_REQUIREMENTS.US.individual?.mode).toBe("hosted"); + expect(ONBOARDING_REQUIREMENTS.US.business?.documents).toEqual([]); + expect(ONBOARDING_REQUIREMENTS.BR.individual?.mode).toBe("hybrid"); + }); + + test("includes fixed provider discriminators needed to execute action steps", () => { + const mxBusinessCreation = ONBOARDING_REQUIREMENTS.MX.business?.steps.find( + step => step.operationId === "createAlfredpayBusinessCustomer" + ); + expect(mxBusinessCreation?.fixedBody).toEqual({ country: "MX" }); + + const usBusinessOpened = ONBOARDING_REQUIREMENTS.US.business?.steps.find( + step => step.operationId === "notifyAlfredpayKycRedirectOpened" + ); + expect(usBusinessOpened?.fixedBody).toEqual({ country: "US", type: "BUSINESS" }); + }); + + test("returns the complete Avenia business operation sequence", () => { + expect(ONBOARDING_REQUIREMENTS.BR.business?.steps.map(step => step.operationId ?? step.kind)).toEqual([ + "createSubaccount", + "createAveniaKybDocument", + "direct-upload", + "hosted", + "createAveniaKybUbo", + "submitAveniaKybLevel1Api" + ]); + }); +}); diff --git a/packages/shared/src/endpoints/onboarding-requirements.endpoints.ts b/packages/shared/src/endpoints/onboarding-requirements.endpoints.ts new file mode 100644 index 000000000..68edcaded --- /dev/null +++ b/packages/shared/src/endpoints/onboarding-requirements.endpoints.ts @@ -0,0 +1,428 @@ +import type { CorridorCustomerType } from "../corridors"; + +export type OnboardingRequirementsCountry = "AR" | "BR" | "CO" | "MX" | "US"; +export type OnboardingFlowMode = "api" | "hosted" | "hybrid"; +export type OnboardingStepKind = "api" | "direct-upload" | "hosted"; + +export interface OnboardingDocumentRequirement { + type: string; + required: boolean; + acceptedMediaTypes?: string[]; + collection?: "direct-upload" | "hosted"; + description?: string; + requiredWhen?: string; +} + +export interface OnboardingRequirementStep { + order: number; + kind: OnboardingStepKind; + description: string; + operationId?: string; + method?: "POST" | "PUT"; + path?: string; + requestSchema?: string; + condition?: string; + derivedValues?: Record; + fixedBody?: Record; + fixedQuery?: Record; + repeatFor?: string; +} + +export interface GetOnboardingRequirementsResponse { + country: OnboardingRequirementsCountry; + customerType: CorridorCustomerType; + documentationUrl: string; + flow: string; + mode: OnboardingFlowMode; + openapiUrl: string; + provider: "alfredpay" | "avenia"; + requirementsVersion: string; + documents: OnboardingDocumentRequirement[]; + steps: OnboardingRequirementStep[]; +} + +export interface GetOnboardingRequirementsErrorResponse { + error: { + code: "INVALID_ONBOARDING_REQUIREMENTS_QUERY" | "ONBOARDING_REQUIREMENTS_NOT_FOUND"; + message: string; + status: 400 | 404; + }; +} + +const REQUIREMENTS_VERSION = "2026-08-10"; +const OPENAPI_URL = "https://raw.githubusercontent.com/pendulum-chain/vortex/main/docs/api/openapi/vortex.openapi.json"; +const DOCUMENTATION_URL = "https://api-docs.vortexfinance.co/fiat-corridors"; +const ALFREDPAY_MEDIA_TYPES = ["image/jpeg", "image/png", "application/pdf"]; + +const alfredpayInitialSteps = ( + country: OnboardingRequirementsCountry, + customerType: CorridorCustomerType +): OnboardingRequirementStep[] => [ + { + condition: "Run only when no provider customer exists.", + description: `Create the ${customerType} provider customer.`, + fixedBody: { country }, + kind: "api", + method: "POST", + operationId: customerType === "business" ? "createAlfredpayBusinessCustomer" : "createAlfredpayIndividualCustomer", + order: 1, + path: customerType === "business" ? "/v1/alfredpay/createBusinessCustomer" : "/v1/alfredpay/createIndividualCustomer", + requestSchema: "#/components/schemas/AlfredpayCreateCustomerRequest" + } +]; + +const alfredpayIndividualFlow = (country: "AR" | "CO" | "MX"): GetOnboardingRequirementsResponse => ({ + country, + customerType: "individual", + documentationUrl: DOCUMENTATION_URL, + documents: [ + { acceptedMediaTypes: ALFREDPAY_MEDIA_TYPES, required: true, type: "National ID Front" }, + { acceptedMediaTypes: ALFREDPAY_MEDIA_TYPES, required: true, type: "National ID Back" }, + ...(country === "AR" ? [{ acceptedMediaTypes: ALFREDPAY_MEDIA_TYPES, required: true, type: "Selfie" }] : []) + ], + flow: `alfredpay-${country.toLowerCase()}-individual-api-kyc`, + mode: "api", + openapiUrl: OPENAPI_URL, + provider: "alfredpay", + requirementsVersion: REQUIREMENTS_VERSION, + steps: [ + ...alfredpayInitialSteps(country, "individual"), + { + description: "Create the KYC submission with the collected identity data.", + fixedBody: { country }, + kind: "api", + method: "POST", + operationId: "submitAlfredpayKycInformation", + order: 2, + path: "/v1/alfredpay/submitKycInformation", + requestSchema: "#/components/schemas/SubmitKycInformationRequest" + }, + { + derivedValues: { "body.fileType": "current document type", "body.submissionId": "step 2 response submissionId" }, + description: "Upload each required identity document.", + fixedBody: { country }, + kind: "api", + method: "POST", + operationId: "submitAlfredpayKycFile", + order: 3, + path: "/v1/alfredpay/submitKycFile", + repeatFor: "documents", + requestSchema: "#/components/schemas/AlfredpayKycFileUploadRequest" + }, + { + derivedValues: { "body.submissionId": "step 2 response submissionId" }, + description: "Finalize the KYC submission.", + fixedBody: { country }, + kind: "api", + method: "POST", + operationId: "sendAlfredpayKycSubmission", + order: 4, + path: "/v1/alfredpay/sendKycSubmission", + requestSchema: "#/components/schemas/AlfredpaySendSubmissionRequest" + } + ] +}); + +const alfredpayBusinessDocuments: OnboardingDocumentRequirement[] = [ + { acceptedMediaTypes: ALFREDPAY_MEDIA_TYPES, required: true, type: "taxIdDocument" }, + { acceptedMediaTypes: ALFREDPAY_MEDIA_TYPES, required: true, type: "articlesIncorporation" }, + { acceptedMediaTypes: ALFREDPAY_MEDIA_TYPES, required: true, type: "proofAddress" }, + { acceptedMediaTypes: ALFREDPAY_MEDIA_TYPES, required: true, type: "shareholderRegistry" }, + { + acceptedMediaTypes: ALFREDPAY_MEDIA_TYPES, + required: false, + requiredWhen: "isRegulatedBusiness is true", + type: "businessLicense" + }, + { + acceptedMediaTypes: ALFREDPAY_MEDIA_TYPES, + required: false, + requiredWhen: "isRegulatedBusiness is true", + type: "uploadAmlPolicy" + }, + { acceptedMediaTypes: ALFREDPAY_MEDIA_TYPES, required: true, type: "docFront" }, + { acceptedMediaTypes: ALFREDPAY_MEDIA_TYPES, required: true, type: "docBack" } +]; + +const alfredpayBusinessFlow = (country: "CO" | "MX"): GetOnboardingRequirementsResponse => ({ + country, + customerType: "business", + documentationUrl: DOCUMENTATION_URL, + documents: alfredpayBusinessDocuments, + flow: `alfredpay-${country.toLowerCase()}-business-api-kyb`, + mode: "api", + openapiUrl: OPENAPI_URL, + provider: "alfredpay", + requirementsVersion: REQUIREMENTS_VERSION, + steps: [ + ...alfredpayInitialSteps(country, "business"), + { + description: "Create or update the KYB submission with company, representative, and questionnaire data.", + fixedBody: { country }, + kind: "api", + method: "POST", + operationId: "submitAlfredpayKybInformation", + order: 2, + path: "/v1/alfredpay/submitKybInformation", + requestSchema: "#/components/schemas/SubmitKybInformationRequest" + }, + { + derivedValues: { + "body.fileType": "current company document type", + "body.submissionId": "step 2 response submissionId" + }, + description: "Upload each required company document.", + fixedBody: { country }, + kind: "api", + method: "POST", + operationId: "submitAlfredpayKybFile", + order: 3, + path: "/v1/alfredpay/submitKybFile", + repeatFor: "company documents", + requestSchema: "#/components/schemas/AlfredpayKybFileUploadRequest" + }, + { + derivedValues: { + "body.fileType": "current related-person document type" + }, + description: "Upload both identity document sides for each related person.", + fixedBody: { country }, + kind: "api", + method: "POST", + operationId: "submitAlfredpayKybRelatedPersonFile", + order: 4, + path: "/v1/alfredpay/submitKybRelatedPersonFile", + repeatFor: "related persons and their required documents", + requestSchema: "#/components/schemas/AlfredpayKybRelatedPersonFileUploadRequest" + }, + { + derivedValues: { "body.submissionId": "step 2 response submissionId" }, + description: "Finalize the KYB submission.", + fixedBody: { country }, + kind: "api", + method: "POST", + operationId: "sendAlfredpayKybSubmission", + order: 5, + path: "/v1/alfredpay/sendKybSubmission", + requestSchema: "#/components/schemas/AlfredpaySendSubmissionRequest" + } + ] +}); + +const alfredpayHostedFlow = (customerType: CorridorCustomerType): GetOnboardingRequirementsResponse => ({ + country: "US", + customerType, + documentationUrl: DOCUMENTATION_URL, + documents: [], + flow: `alfredpay-us-${customerType}-hosted-${customerType === "business" ? "kyb" : "kyc"}`, + mode: "hosted", + openapiUrl: OPENAPI_URL, + provider: "alfredpay", + requirementsVersion: REQUIREMENTS_VERSION, + steps: [ + ...alfredpayInitialSteps("US", customerType), + { + description: "Open the provider-hosted verification URL as described in the integration documentation.", + kind: "hosted", + order: 2 + }, + { + description: "Record that the provider-hosted session was opened.", + fixedBody: { country: "US", type: customerType === "business" ? "BUSINESS" : "INDIVIDUAL" }, + kind: "api", + method: "POST", + operationId: "notifyAlfredpayKycRedirectOpened", + order: 3, + path: "/v1/alfredpay/kycRedirectOpened", + requestSchema: "#/components/schemas/AlfredpayRedirectNotificationRequest" + }, + { + condition: "Call when the customer confirms that the hosted form is complete.", + description: "Record customer completion without treating it as provider approval.", + fixedBody: { country: "US", type: customerType === "business" ? "BUSINESS" : "INDIVIDUAL" }, + kind: "api", + method: "POST", + operationId: "notifyAlfredpayKycRedirectFinished", + order: 4, + path: "/v1/alfredpay/kycRedirectFinished", + requestSchema: "#/components/schemas/AlfredpayRedirectNotificationRequest" + } + ] +}); + +const AVENIA_INDIVIDUAL: GetOnboardingRequirementsResponse = { + country: "BR", + customerType: "individual", + documentationUrl: DOCUMENTATION_URL, + documents: [ + { collection: "direct-upload", description: "Use ID or DRIVERS-LICENSE.", required: true, type: "identity document" }, + { + collection: "hosted", + description: "Completed through the Avenia liveness URL.", + required: true, + type: "selfie" + } + ], + flow: "avenia-br-individual-level-1-kyc", + mode: "hybrid", + openapiUrl: OPENAPI_URL, + provider: "avenia", + requirementsVersion: REQUIREMENTS_VERSION, + steps: [ + { + condition: "Run only when no Avenia subaccount exists.", + description: "Create the individual Avenia subaccount.", + kind: "api", + method: "POST", + operationId: "createSubaccount", + order: 1, + path: "/v1/brla/createSubaccount", + requestSchema: "#/components/schemas/CreateSubaccountRequest" + }, + { + description: "Create identity-document and selfie upload targets.", + kind: "api", + method: "POST", + operationId: "brlaGetUploadUrls", + order: 2, + path: "/v1/brla/getUploadUrls", + requestSchema: "#/components/schemas/AveniaKYCDataUploadRequest" + }, + { + description: "Upload identity-document bytes to the returned presigned URL.", + kind: "direct-upload", + method: "PUT", + order: 3 + }, + { description: "Open and complete the returned provider-hosted liveness URL.", kind: "hosted", order: 4 }, + { + derivedValues: { + "body.subAccountId": "step 1 response subAccountId", + "body.uploadedDocumentId": "step 2 response idUpload.id", + "body.uploadedSelfieId": "step 2 response selfieUpload.id" + }, + description: "Submit the Level 1 KYC data after both uploads are ready.", + kind: "api", + method: "POST", + operationId: "brlaNewKyc", + order: 5, + path: "/v1/brla/newKyc", + requestSchema: "#/components/schemas/KycLevel1Payload" + } + ] +}; + +const AVENIA_BUSINESS: GetOnboardingRequirementsResponse = { + country: "BR", + customerType: "business", + documentationUrl: DOCUMENTATION_URL, + documents: [ + { collection: "direct-upload", required: true, type: "CERTIFICATE-OF-INCORPORATION" }, + { collection: "direct-upload", required: true, type: "COMPANY-TAX-IDENTIFICATION-DOCUMENT" }, + { + collection: "direct-upload", + description: "Required for each UBO.", + required: true, + type: "ID, DRIVERS-LICENSE, PASSPORT, or RESIDENCE-PERMIT" + }, + { + collection: "hosted", + description: "Optional provider-hosted liveness evidence for a UBO.", + required: false, + type: "SELFIE-FROM-LIVENESS" + } + ], + flow: "avenia-br-business-level-1-api-kyb", + mode: "api", + openapiUrl: OPENAPI_URL, + provider: "avenia", + requirementsVersion: REQUIREMENTS_VERSION, + steps: [ + { + condition: "Run only when no Avenia company subaccount exists.", + description: "Create the company Avenia subaccount.", + kind: "api", + method: "POST", + operationId: "createSubaccount", + order: 1, + path: "/v1/brla/createSubaccount", + requestSchema: "#/components/schemas/CreateSubaccountRequest" + }, + { + derivedValues: { + "body.documentType": "current document type", + "query.subAccountId": "step 1 response subAccountId" + }, + description: "Create an upload target for each company and UBO document.", + kind: "api", + method: "POST", + operationId: "createAveniaKybDocument", + order: 2, + path: "/v1/brla/kyb/documents", + repeatFor: "documents", + requestSchema: "#/components/schemas/AveniaKybDocumentRequest" + }, + { + description: "Upload document bytes to each returned presigned URL.", + kind: "direct-upload", + method: "PUT", + order: 3, + repeatFor: "documents where collection is direct-upload" + }, + { + condition: "Run for each optional SELFIE-FROM-LIVENESS document the integrator chooses to collect.", + description: "Open and complete the provider-hosted liveness URL returned when the document was created.", + kind: "hosted", + order: 4, + repeatFor: "documents where collection is hosted" + }, + { + derivedValues: { + "body.uploadedIdentificationId": "step 2 response id for the current UBO identity document", + "body.uploadedSelfieId": "step 2 response id for the current optional SELFIE-FROM-LIVENESS document", + "query.subAccountId": "step 1 response subAccountId" + }, + description: "Register each UBO using ready identity documents.", + kind: "api", + method: "POST", + operationId: "createAveniaKybUbo", + order: 5, + path: "/v1/brla/kyb/ubos", + repeatFor: "UBOs", + requestSchema: "#/components/schemas/AveniaUboPayload" + }, + { + derivedValues: { + "body.certificateOfIncorporationDocumentId": "step 2 response id for CERTIFICATE-OF-INCORPORATION", + "body.taxIdentificationDocumentId": "step 2 response id for COMPANY-TAX-IDENTIFICATION-DOCUMENT", + "body.uboIds": "step 5 response ids", + "query.subAccountId": "step 1 response subAccountId" + }, + description: "Submit the company Level 1 KYB attempt.", + kind: "api", + method: "POST", + operationId: "submitAveniaKybLevel1Api", + order: 6, + path: "/v1/brla/kyb/new-level-1/api", + requestSchema: "#/components/schemas/AveniaKybLevel1Payload" + } + ] +}; + +export const ONBOARDING_REQUIREMENTS: Record< + OnboardingRequirementsCountry, + Partial> +> = { + AR: { individual: alfredpayIndividualFlow("AR") }, + BR: { business: AVENIA_BUSINESS, individual: AVENIA_INDIVIDUAL }, + CO: { business: alfredpayBusinessFlow("CO"), individual: alfredpayIndividualFlow("CO") }, + MX: { business: alfredpayBusinessFlow("MX"), individual: alfredpayIndividualFlow("MX") }, + US: { business: alfredpayHostedFlow("business"), individual: alfredpayHostedFlow("individual") } +}; + +export function getOnboardingRequirements( + country: OnboardingRequirementsCountry, + customerType: CorridorCustomerType +): GetOnboardingRequirementsResponse | undefined { + return ONBOARDING_REQUIREMENTS[country][customerType]; +} diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index cd6c6db22..e9a08ad7c 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -222,7 +222,6 @@ export interface UpdateRampRequest { squidRouterNoPermitApproveHash?: string; squidRouterNoPermitSwapHash?: string; assethubToPendulumHash?: string; - [key: string]: unknown; }; } diff --git a/packages/shared/src/services/brla/brlaApiService.test.ts b/packages/shared/src/services/brla/brlaApiService.test.ts index b995f37c4..e7b6d0a2f 100644 --- a/packages/shared/src/services/brla/brlaApiService.test.ts +++ b/packages/shared/src/services/brla/brlaApiService.test.ts @@ -1,11 +1,170 @@ -import { afterEach, describe, expect, it, mock } from "bun:test"; +import { afterEach, describe, expect, it, mock, test } from "bun:test"; import { generateKeyPairSync } from "crypto"; -import { BrlaApiService } from "./brlaApiService"; +import * as forge from "node-forge"; +import logger from "../../logger"; +import { BrlaApiError, BrlaApiService } from "./brlaApiService"; +import { Endpoint } from "./mappings"; +import { + AveniaDocumentType, + type AveniaKybLevel1Payload, + type AveniaUboPayload, + type KycLevel1Payload +} from "./types"; const realFetch = globalThis.fetch; +const realLogger = logger.current; afterEach(() => { globalThis.fetch = realFetch; + logger.current = realLogger; +}); + +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, + undefined, + { sensitiveBody: true } + ]); + }); + + test("maps API KYB submission and subaccount-scoped attempt polling", async () => { + const { sendRequest, service } = serviceWithMockedRequest(); + + await service.submitKybLevel1(kyb, "sub-1"); + await service.getVerificationAttemptStatus("attempt-1", "sub-1"); + + expect(sendRequest.mock.calls[0]).toEqual([ + Endpoint.Level1Api, + "POST", + "subAccountId=sub-1", + kyb, + undefined, + { sensitiveBody: true } + ]); + 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", () => { @@ -26,6 +185,268 @@ describe("BrlaApiService.getAveniaPublicKey", () => { }); }); +describe("BrlaApiService.importKycToken", () => { + test("uses the exact trailing-slash URL and signs the sensitive body", async () => { + const keyPair = forge.pki.rsa.generateKeyPair(1024); + const service = Object.create(BrlaApiService.prototype) as BrlaApiService; + Object.assign(service, { apiKey: "test-key", privateKey: forge.pki.privateKeyToPem(keyPair.privateKey) }); + const fetchMock = mock(async () => + new Response(JSON.stringify({ id: "attempt-1", message: "processing KYC" }), { + headers: { "Content-Type": "application/json" }, + status: 200 + }) + ); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(service.importKycToken("share-token", "sub/account")).resolves.toEqual({ + id: "attempt-1", + message: "processing KYC" + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, options] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + const requestUri = "/v2/kyc/import-token/?subAccountId=sub%2Faccount"; + expect(String(url)).toEndWith(requestUri); + expect(options.method).toBe("POST"); + expect(options.body).toBe(JSON.stringify({ importToken: "share-token" })); + const headers = options.headers as Record; + const digest = forge.md.sha256.create(); + digest.update(`${headers["X-API-Timestamp"]}POST${requestUri}${options.body}`, "utf8"); + expect( + keyPair.publicKey.verify(digest.digest().bytes(), forge.util.decode64(headers["X-API-Signature"])) + ).toBe(true); + }); + + test("never logs or throws a sensitive token echoed by the provider", async () => { + const sentinel = "SENTINEL-SUMSUB-TOKEN"; + const sentinelSubaccountId = "SENTINEL-SUBACCOUNT-ID"; + const keyPair = forge.pki.rsa.generateKeyPair(1024); + const service = Object.create(BrlaApiService.prototype) as BrlaApiService; + Object.assign(service, { apiKey: "test-key", privateKey: forge.pki.privateKeyToPem(keyPair.privateKey) }); + const logCalls: Record<"debug" | "error" | "info" | "warn", unknown[][]> = { + debug: [], + error: [], + info: [], + warn: [] + }; + logger.current = { + debug: (...args: unknown[]) => logCalls.debug.push(args), + error: (...args: unknown[]) => logCalls.error.push(args), + info: (...args: unknown[]) => logCalls.info.push(args), + warn: (...args: unknown[]) => logCalls.warn.push(args) + }; + globalThis.fetch = mock(async () => new Response(`invalid token: ${sentinel}`, { status: 400 })) as typeof fetch; + + let thrown: unknown; + try { + await service.importKycToken(sentinel, sentinelSubaccountId); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(BrlaApiError); + expect(logCalls.debug).toEqual([ + [`Sending request to ${Endpoint.ImportKycToken} with method POST; sensitive request details omitted`] + ]); + for (const calls of Object.values(logCalls)) { + expect(JSON.stringify(calls)).not.toContain(sentinel); + expect(JSON.stringify(calls)).not.toContain(sentinelSubaccountId); + } + expect(String(thrown)).not.toContain(sentinel); + expect(JSON.stringify(thrown)).not.toContain(sentinel); + expect(String(thrown)).not.toContain(sentinelSubaccountId); + expect(JSON.stringify(thrown)).not.toContain(sentinelSubaccountId); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); +}); + +describe("BrlaApiService.submitKycLevel1", () => { + test("never logs or throws a sensitive standard KYC payload echoed by the provider", async () => { + const sentinel = "SENTINEL-STANDARD-KYC-PII"; + const payload: KycLevel1Payload = { + city: sentinel, + country: sentinel, + countryOfTaxId: sentinel, + dateOfBirth: sentinel, + email: sentinel, + fullName: sentinel, + state: sentinel, + streetAddress: sentinel, + subAccountId: "sub-1", + taxIdNumber: sentinel, + uploadedDocumentId: sentinel, + uploadedSelfieId: sentinel, + zipCode: sentinel + }; + const keyPair = forge.pki.rsa.generateKeyPair(1024); + const service = Object.create(BrlaApiService.prototype) as BrlaApiService; + Object.assign(service, { apiKey: "test-key", privateKey: forge.pki.privateKeyToPem(keyPair.privateKey) }); + const logCalls: Record<"debug" | "error" | "info" | "warn", unknown[][]> = { + debug: [], + error: [], + info: [], + warn: [] + }; + logger.current = { + debug: (...args: unknown[]) => logCalls.debug.push(args), + error: (...args: unknown[]) => logCalls.error.push(args), + info: (...args: unknown[]) => logCalls.info.push(args), + warn: (...args: unknown[]) => logCalls.warn.push(args) + }; + globalThis.fetch = mock(async () => new Response(`invalid KYC payload: ${sentinel}`, { status: 400 })) as typeof fetch; + + let thrown: unknown; + try { + await service.submitKycLevel1(payload); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(BrlaApiError); + for (const calls of Object.values(logCalls)) { + expect(JSON.stringify(calls)).not.toContain(sentinel); + } + expect(String(thrown)).not.toContain(sentinel); + expect(JSON.stringify(thrown)).not.toContain(sentinel); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); +}); + +describe("BrlaApiService sensitive KYB requests", () => { + test("never logs or throws UBO or company payloads echoed by the provider", async () => { + const sentinel = "SENTINEL-KYB-PII"; + const keyPair = forge.pki.rsa.generateKeyPair(1024); + const service = Object.create(BrlaApiService.prototype) as BrlaApiService; + Object.assign(service, { apiKey: "test-key", privateKey: forge.pki.privateKeyToPem(keyPair.privateKey) }); + const logCalls: Record<"debug" | "error" | "info" | "warn", unknown[][]> = { + debug: [], + error: [], + info: [], + warn: [] + }; + logger.current = { + debug: (...args: unknown[]) => logCalls.debug.push(args), + error: (...args: unknown[]) => logCalls.error.push(args), + info: (...args: unknown[]) => logCalls.info.push(args), + warn: (...args: unknown[]) => logCalls.warn.push(args) + }; + globalThis.fetch = mock(async () => new Response(`invalid KYB payload: ${sentinel}`, { status: 400 })) as typeof fetch; + + const thrown: unknown[] = []; + for (const request of [ + () => service.createUbo({ ...ubo, fullName: sentinel }, "sub-1"), + () => service.submitKybLevel1({ ...kyb, companyLegalName: sentinel }, "sub-1") + ]) { + try { + await request(); + } catch (error) { + thrown.push(error); + } + } + + expect(thrown).toHaveLength(2); + for (const error of thrown) { + expect(error).toBeInstanceOf(BrlaApiError); + expect(String(error)).not.toContain(sentinel); + expect(JSON.stringify(error)).not.toContain(sentinel); + } + expect(logCalls.debug).toEqual([ + [`Sending request to ${Endpoint.Ubos} with method POST; sensitive request details omitted`], + [`Sending request to ${Endpoint.Level1Api} with method POST; sensitive request details omitted`] + ]); + for (const calls of Object.values(logCalls)) { + expect(JSON.stringify(calls)).not.toContain(sentinel); + } + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); +}); + +describe("BrlaApiService paginated provider history", () => { + it("returns every KYC attempt page and encodes the provider cursor", async () => { + const service = Object.create(BrlaApiService.prototype) as BrlaApiService; + const sendRequest = mock(async (_endpoint: Endpoint, _method: string, query: string) => { + const id = sendRequest.mock.calls.length === 1 ? "attempt-1" : "attempt-2"; + return { + attempts: [ + { + createdAt: "2026-08-14T12:00:00.000Z", + id, + levelName: "level-1", + status: "PENDING", + updatedAt: "2026-08-14T12:00:00.000Z" + } + ], + ...(query.includes("cursor=") ? {} : { cursor: "next/page?" }) + }; + }); + Object.assign(service, { sendRequest }); + + await expect(service.getKycAttempts("sub/account")).resolves.toMatchObject({ + attempts: [{ id: "attempt-1" }, { id: "attempt-2" }] + }); + expect(sendRequest.mock.calls.map(call => call[2])).toEqual([ + "subAccountId=sub%2Faccount", + "subAccountId=sub%2Faccount&cursor=next%2Fpage%3F" + ]); + }); + + it("returns every uploaded-document page", async () => { + const service = Object.create(BrlaApiService.prototype) as BrlaApiService; + const sendRequest = mock(async () => ({ + cursor: sendRequest.mock.calls.length === 1 ? "page-2" : null, + documents: [ + { + documentType: AveniaDocumentType.PASSPORT, + id: `document-${sendRequest.mock.calls.length}`, + ready: true, + uploadStatusFront: "PROCESSED" + } + ] + })); + Object.assign(service, { sendRequest }); + + await expect(service.getUploadedDocuments("sub-1")).resolves.toMatchObject({ + documents: [{ id: "document-1" }, { id: "document-2" }] + }); + expect(sendRequest).toHaveBeenCalledTimes(2); + }); + + it("fails closed when Avenia repeats a pagination cursor", async () => { + const service = Object.create(BrlaApiService.prototype) as BrlaApiService; + Object.assign(service, { + sendRequest: mock(async () => ({ attempts: [], cursor: "same-cursor" })) + }); + + await expect(service.getKycAttempts("sub-1")).rejects.toThrow("repeated a cursor"); + }); + + it("stops uploaded-document pagination after exactly 100 requests", async () => { + const service = Object.create(BrlaApiService.prototype) as BrlaApiService; + const sendRequest = mock(async () => ({ + cursor: `cursor-${sendRequest.mock.calls.length}`, + documents: [] + })); + Object.assign(service, { sendRequest }); + + await expect(service.getUploadedDocuments("sub-1")).rejects.toThrow( + "Avenia pagination exceeded the maximum page limit" + ); + expect(sendRequest).toHaveBeenCalledTimes(100); + }); + + it("stops KYC-attempt pagination after exactly 100 requests", async () => { + const service = Object.create(BrlaApiService.prototype) as BrlaApiService; + const sendRequest = mock(async () => ({ + attempts: [], + cursor: `cursor-${sendRequest.mock.calls.length}` + })); + Object.assign(service, { sendRequest }); + + await expect(service.getKycAttempts("sub-1")).rejects.toThrow("Avenia pagination exceeded the maximum page limit"); + expect(sendRequest).toHaveBeenCalledTimes(100); + }); +}); + describe("BrlaApiService.sendRequest path templating", () => { // GetKybAttempt is "/v2/kyc/attempts/{attemptId}". Before templating, the path param // was appended, signing and requesting a literal "/{attemptId}/" URL. @@ -35,10 +456,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..b96d05d14 100644 --- a/packages/shared/src/services/brla/brlaApiService.ts +++ b/packages/shared/src/services/brla/brlaApiService.ts @@ -3,20 +3,37 @@ 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, + aveniaDocumentsSchema, + aveniaDocumentUploadResponseSchema, + aveniaImportKycTokenResponseSchema, + aveniaKybAttemptStatusSchema, + aveniaKybLevel1ResponseSchema, + aveniaKycAttemptsSchema, + aveniaLevel1ResponseSchema, + aveniaUboResponseSchema +} from "./schemas"; import { AccountLimitsResponse, AveniaAccountBalanceResponse, AveniaAccountInfoResponse, AveniaAccountType, AveniaDocumentGetResponse, + AveniaDocumentResponse, AveniaDocumentType, + AveniaImportKycTokenResponse, AveniaKybAttemptStatusResponse, + AveniaKybLevel1Payload, AveniaPayinTicket, AveniaPaymentMethod, AveniaPayoutTicket, AveniaPublicKeyResponse, AveniaQuoteResponse, AveniaSwapTicket, + AveniaUboPayload, + AveniaUboResponse, + AveniaVerificationAttemptResponse, AveniaWebhookRegistration, AveniaWebhooksListResponse, BlockchainSendMethod, @@ -42,10 +59,16 @@ interface CachedQuote { const QUOTE_CACHE_TTL_MS = 3 * 60 * 1000; // 3 minutes const QUOTE_CACHE_MAX_SIZE = 100; // Maximum number of cached entries +const PAGINATION_MAX_PAGES = 100; export const AVENIA_PUBLIC_KEY_TIMEOUT_MS = 10_000; // Bound on every signed API request. A hung connection would otherwise stall callers // indefinitely — cron workers with waitForCompletion never run their next cycle. export const BRLA_REQUEST_TIMEOUT_MS = 30_000; +const SENSITIVE_PROVIDER_RESPONSE = "Sensitive provider response omitted"; + +export interface BrlaRequestOptions { + sensitiveBody?: boolean; +} /** * Error thrown when an Avenia/BRLA HTTP request fails. See {@link ProviderHttpError} for the @@ -121,7 +144,8 @@ export class BrlaApiService { method: M, queryParams?: string, payload?: EndpointRequestBody, - pathParam?: string + pathParam?: string, + requestOptions: BrlaRequestOptions = {} ): Promise> { const timestamp = Date.now().toString(); const body = payload ? JSON.stringify(payload) : ""; @@ -130,7 +154,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}`; @@ -164,7 +191,11 @@ export class BrlaApiService { options.body = body; } const fullUrl = `${BRLA_BASE_URL}${requestUri}`; - logger.current.debug(`Sending request to ${fullUrl} with method ${method} and payload:`, payload); + if (requestOptions.sensitiveBody) { + logger.current.debug(`Sending request to ${endpoint} with method ${method}; sensitive request details omitted`); + } else { + logger.current.debug(`Sending request to ${fullUrl} with method ${method} and payload:`, payload); + } let response: Response; try { @@ -175,7 +206,11 @@ export class BrlaApiService { throw new BrlaApiError({ endpoint: endpoint as string, method: method as string, - responseBody: error instanceof Error ? error.message : String(error), + responseBody: requestOptions.sensitiveBody + ? SENSITIVE_PROVIDER_RESPONSE + : error instanceof Error + ? error.message + : String(error), status: 0 }); } @@ -191,7 +226,7 @@ export class BrlaApiService { throw new BrlaApiError({ endpoint: endpoint as string, method: method as string, - responseBody: await response.text(), + responseBody: requestOptions.sensitiveBody ? SENSITIVE_PROVIDER_RESPONSE : await response.text(), status: response.status }); } @@ -230,12 +265,39 @@ 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 { + const documents: AveniaDocumentGetResponse["documents"] = []; + const seenCursors = new Set(); + let pageCount = 0; + let cursor: string | undefined; + do { + if (pageCount >= PAGINATION_MAX_PAGES) throw new Error("Avenia pagination exceeded the maximum page limit"); + const query = `subAccountId=${encodeURIComponent(subAccountId)}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`; + const page = aveniaDocumentsSchema.parse(await this.sendRequest(Endpoint.Documents, "GET", query, undefined)); + pageCount++; + documents.push(...page.documents); + cursor = page.cursor; + if (cursor && seenCursors.has(cursor)) throw new Error("Avenia document pagination repeated a cursor"); + if (cursor) seenCursors.add(cursor); + } while (cursor); + return { documents }; + } + + public async getUploadedDocument(documentId: string, subAccountId: string): Promise { const query = `subAccountId=${encodeURIComponent(subAccountId)}`; - return await this.sendRequest(Endpoint.Documents, "GET", query, undefined); + 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, undefined, { sensitiveBody: true }) + ); } public async createPayInQuote( @@ -381,12 +443,42 @@ 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, undefined, { sensitiveBody: true }) + ); } - public async getKycAttempts(subAccountId: string): Promise { + public async submitKybLevel1(payload: AveniaKybLevel1Payload, subAccountId: string): Promise { + const query = `subAccountId=${encodeURIComponent(subAccountId)}`; + return aveniaLevel1ResponseSchema.parse( + await this.sendRequest(Endpoint.Level1Api, "POST", query, payload, undefined, { sensitiveBody: true }) + ); + } + + public async importKycToken(importToken: string, subAccountId: string): Promise { const query = `subAccountId=${encodeURIComponent(subAccountId)}`; - return await this.sendRequest(Endpoint.GetKycAttempt, "GET", query, undefined); + const payload = { importToken }; + return aveniaImportKycTokenResponseSchema.parse( + await this.sendRequest(Endpoint.ImportKycToken, "POST", query, payload, undefined, { sensitiveBody: true }) + ); + } + + public async getKycAttempts(subAccountId: string): Promise { + const attempts: GetKycAttemptResponse["attempts"] = []; + const seenCursors = new Set(); + let pageCount = 0; + let cursor: string | undefined; + do { + if (pageCount >= PAGINATION_MAX_PAGES) throw new Error("Avenia pagination exceeded the maximum page limit"); + const query = `subAccountId=${encodeURIComponent(subAccountId)}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`; + const page = aveniaKycAttemptsSchema.parse(await this.sendRequest(Endpoint.GetKycAttempt, "GET", query, undefined)); + pageCount++; + attempts.push(...page.attempts); + cursor = page.cursor; + if (cursor && seenCursors.has(cursor)) throw new Error("Avenia KYC attempt pagination repeated a cursor"); + if (cursor) seenCursors.add(cursor); + } while (cursor); + return { attempts }; } /** @@ -398,16 +490,22 @@ 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)); } - /** - * Gets the status of a KYB attempt - * @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); + /** Gets an individual or company verification attempt by its exact provider ID. */ + public async getVerificationAttemptStatus( + 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 getKybAttemptStatus(attemptId: string, subAccountId?: string): Promise { + return this.getVerificationAttemptStatus(attemptId, subAccountId); } public async listWebhooks(): Promise { diff --git a/packages/shared/src/services/brla/mappings.ts b/packages/shared/src/services/brla/mappings.ts index ae26232c6..3001ca0f6 100644 --- a/packages/shared/src/services/brla/mappings.ts +++ b/packages/shared/src/services/brla/mappings.ts @@ -4,12 +4,18 @@ import { AveniaAccountInfoResponse, AveniaAccountType, AveniaDocumentGetResponse, - AveniaKybAttemptStatusResponse, + AveniaDocumentResponse, + AveniaImportKycTokenRequest, + AveniaImportKycTokenResponse, + AveniaKybLevel1Payload, AveniaPayinTicket, AveniaPayoutTicket, AveniaQuoteResponse, AveniaSubaccount, AveniaSwapTicket, + AveniaUboPayload, + AveniaUboResponse, + AveniaVerificationAttemptResponse, AveniaWebhookRegistration, AveniaWebhooksListResponse, DocumentUploadRequest, @@ -30,12 +36,15 @@ 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", + ImportKycToken = "/v2/kyc/import-token/", 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 +94,9 @@ export interface EndpointMapping { response: undefined; }; }; - [Endpoint.KycLevel1]: { + [Endpoint.Level1Api]: { POST: { - body: KycLevel1Payload; + body: KycLevel1Payload | AveniaKybLevel1Payload; response: KycLevel1Response; }; GET: { @@ -99,6 +108,20 @@ export interface EndpointMapping { response: undefined; }; }; + [Endpoint.ImportKycToken]: { + POST: { + body: AveniaImportKycTokenRequest; + response: AveniaImportKycTokenResponse; + }; + GET: { + body: undefined; + response: undefined; + }; + PATCH: { + body: undefined; + response: undefined; + }; + }; [Endpoint.FixedRateQuote]: { POST: { body: undefined; @@ -157,6 +180,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; @@ -192,7 +243,7 @@ export interface EndpointMapping { }; GET: { body: undefined; - response: AveniaKybAttemptStatusResponse; + response: AveniaVerificationAttemptResponse; }; PATCH: { body: undefined; diff --git a/packages/shared/src/services/brla/schemas.test.ts b/packages/shared/src/services/brla/schemas.test.ts index 6e5ffef4a..ff8fa0853 100644 --- a/packages/shared/src/services/brla/schemas.test.ts +++ b/packages/shared/src/services/brla/schemas.test.ts @@ -3,11 +3,17 @@ import { aveniaAccountBalanceSchema, aveniaAccountInfoSchema, aveniaAccountLimitsSchema, + aveniaDocumentResponseSchema, + aveniaImportKycTokenResponseSchema, + aveniaKybAttemptStatusSchema, + aveniaKycAttemptsSchema, + aveniaLevel1ResponseSchema, aveniaPayinTicketsSchema, aveniaPayoutTicketSchema, aveniaPixInputTicketSchema, aveniaPixKeyDataSchema, aveniaQuoteResponseSchema, + aveniaUboResponseSchema, aveniaWebhookRegistrationSchema, aveniaWebhooksListSchema } from "./schemas"; @@ -140,6 +146,84 @@ 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(); + }); + + test("accepts an unsettled attempt with resultMessage and retryable absent", () => { + // Avenia omits resultMessage and retryable until an attempt settles, so a PENDING poll + // must parse instead of raising a ZodError that would surface as a 502. + const pending = { + createdAt: "2026-03-19T22:09:52.629984Z", + id: "attempt-1", + levelName: "kyb-level-1", + status: "PENDING", + updatedAt: "2026-03-19T22:09:52.629984Z" + }; + expect(() => aveniaKybAttemptStatusSchema.parse({ attempt: pending })).not.toThrow(); + expect(() => aveniaKycAttemptsSchema.parse({ attempts: [pending] })).not.toThrow(); + }); + + test("normalizes documented null result fields on unsettled attempts", () => { + const pending = { + createdAt: "2026-03-19T22:09:52.629984Z", + id: "attempt-1", + levelName: "sumsub-token-recipient", + result: null, + resultMessage: null, + status: "PENDING", + updatedAt: "2026-03-19T22:09:52.629984Z" + }; + expect(aveniaKybAttemptStatusSchema.parse({ attempt: pending }).attempt).toMatchObject({ + result: undefined, + resultMessage: undefined + }); + expect(aveniaKycAttemptsSchema.parse({ attempts: [pending] }).attempts[0]).toMatchObject({ + result: undefined, + resultMessage: undefined + }); + }); +}); + +describe("aveniaImportKycTokenResponseSchema", () => { + test("requires nonempty id and message fields", () => { + expect(() => aveniaImportKycTokenResponseSchema.parse({ id: "attempt-1", message: "processing KYC" })).not.toThrow(); + expect(() => aveniaImportKycTokenResponseSchema.parse({ id: "", message: "processing KYC" })).toThrow(); + expect(() => aveniaImportKycTokenResponseSchema.parse({ id: "attempt-1", message: "" })).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..976ac3aa8 100644 --- a/packages/shared/src/services/brla/schemas.ts +++ b/packages/shared/src/services/brla/schemas.ts @@ -2,7 +2,11 @@ import { z } from "zod"; import { AveniaAccountBalanceResponse, AveniaAccountInfoResponse, + AveniaDocument, + AveniaDocumentGetResponse, + AveniaDocumentType, AveniaFeeType, + AveniaImportKycTokenResponse, AveniaOperationFee, AveniaPayinTicket, AveniaPayoutTicket, @@ -10,9 +14,18 @@ import { AveniaSubaccountAccountInfo, AveniaSubaccountWallet, AveniaTicketStatus, + AveniaUboResponse, + AveniaVerificationAttemptResponse, AveniaWebhook, AveniaWebhookRegistration, AveniaWebhooksListResponse, + DocumentUploadResponse, + GetKycAttemptResponse, + KybLevel1Response, + KycAttempt, + KycAttemptResult, + KycAttemptStatus, + KycLevel1Response, Limit, PixInputTicketOutput, PixKeyData, @@ -140,6 +153,98 @@ 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 }>; + +const aveniaCursorSchema = z + .string() + .min(1) + .nullish() + .transform(value => value ?? undefined); + +/** Paginated document history used by readiness and method reconciliation. */ +export const aveniaDocumentsSchema = z.looseObject({ + cursor: aveniaCursorSchema, + documents: z.array(aveniaDocumentResponseSchema.shape.document) +}) satisfies z.ZodType; + +/** 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 attempt identifier and acknowledgement returned after importing a Sumsub share token. */ +export const aveniaImportKycTokenResponseSchema = z.object({ + id: z.string().min(1), + message: 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) + .nullish() + .transform(value => value ?? undefined), + resultMessage: z + .string() + .nullish() + .transform(value => value ?? undefined), + retryable: z.boolean().optional(), + 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), + cursor: aveniaCursorSchema +}) satisfies z.ZodType; + +/** An individual or company verification attempt returned by GET /v2/kyc/attempts/{attemptId}. */ +export const aveniaVerificationAttemptSchema = z.looseObject({ + attempt: aveniaAttemptSchema +}) satisfies z.ZodType; + +export const aveniaKybAttemptStatusSchema = aveniaVerificationAttemptSchema; + /** 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..19b8b8c24 100644 --- a/packages/shared/src/services/brla/types.ts +++ b/packages/shared/src/services/brla/types.ts @@ -348,6 +348,131 @@ export interface KycLevel1Response { id: string; } +export interface AveniaImportKycTokenRequest { + importToken: string; +} + +export interface AveniaImportKycTokenResponse { + id: string; + message: 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,11 +488,11 @@ export interface KybLevel1Response { export interface AveniaVerificationAttempt { id: string; levelName: string; - submissionData: Record; + submissionData?: Record; status: KycAttemptStatus; result?: KycAttemptResult; resultMessage?: string; - retryable: boolean; + retryable?: boolean; createdAt: string; updatedAt: string; } @@ -375,19 +500,25 @@ export interface AveniaVerificationAttempt { export interface KybAttemptStatusResponse { failureReason?: string; result?: KycAttemptResult; + retryable?: boolean; status: KycAttemptStatus; } -export interface AveniaKybAttemptStatusResponse { +export interface AveniaVerificationAttemptResponse { attempt: AveniaVerificationAttempt; } +export type AveniaKybAttemptStatusResponse = AveniaVerificationAttemptResponse; + 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 +534,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,18 +566,19 @@ export enum KycAttemptResult { export interface KycAttempt { id: string; - levelName: "level-1"; - submissionData: unknown; + levelName: string; + submissionData?: unknown; status: KycAttemptStatus; - result: KycAttemptResult; - resultMessage: string; - retryable: boolean; + result?: KycAttemptResult; + resultMessage?: string; + retryable?: boolean; createdAt: string; updatedAt: string; } export interface GetKycAttemptResponse { attempts: KycAttempt[]; + cursor?: string; } export interface CreateAveniaSubaccountRequest { @@ -438,21 +588,8 @@ 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[]; + cursor?: string; } export interface AveniaAccountBalanceResponse {