Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
18ea3c4
docs(repo): clarify delegated profile request context
gianfra-t Aug 5, 2026
30dbda2
docs(repo): propose unified KYC and KYB API
gianfra-t Aug 5, 2026
6e63cdb
docs(repo): define staged verification flow
gianfra-t Aug 5, 2026
2cbf3e3
Merge branch 'managed-profiles' into streamline-kyc-kyb
ebma Aug 6, 2026
8aa735c
docs(repo): define lean unified verification first iteration
ebma Aug 6, 2026
f1def3a
docs(repo): restore unified verification proposal scope
ebma Aug 6, 2026
1507d09
feat(shared): map Avenia KYB Level 1 API
gianfra-t Aug 6, 2026
f10d2af
feat(api): add Avenia API-based KYB Level 1 flow
gianfra-t Aug 6, 2026
e0440e6
chore(repo): merge managed-profiles into streamline-kyc-kyb
gianfra-t Aug 6, 2026
cf20974
Merge branch 'managed-profiles' into streamline-kyc-kyb
gianfra-t Aug 10, 2026
3de61fc
feat(api): publish onboarding requirements discovery
gianfra-t Aug 11, 2026
6449a57
Merge branch 'managed-profiles' into streamline-kyc-kyb
gianfra-t Aug 11, 2026
ab3f137
docs(api): refresh merged OpenAPI declarations
gianfra-t Aug 11, 2026
64634fb
refactor(api)!: streamline onboarding discovery metadata
gianfra-t Aug 11, 2026
98b89eb
refactor(api): rely on Avenia KYB attempt state
gianfra-t Aug 11, 2026
45e49c1
Merge branch 'managed-profiles' into streamline-kyc-kyb
gianfra-t Aug 11, 2026
d1eda67
fix(shared): tolerate unsettled Avenia attempts without resultMessage
gianfra-t Aug 11, 2026
17923e0
fix(api): bind record-attempt marker to an owned Brazil quote
gianfra-t Aug 11, 2026
e5de51d
feat(api): inherit manager pricing for managed profiles
gianfra-t Aug 12, 2026
d5fa78b
fix(api): enforce Avenia KYC customer type
gianfra-t Aug 12, 2026
89a328b
fix(api): protect Avenia recovery identity
gianfra-t Aug 12, 2026
4410c96
fix(api): keep paid-ramp recovery pollable
gianfra-t Aug 12, 2026
9fc10b7
fix(api): reconcile existing Avenia KYB attempts
gianfra-t Aug 12, 2026
5797a81
fix(api): validate onboarding request metadata
gianfra-t Aug 13, 2026
d70b77d
fix(api): harden Avenia onboarding state
gianfra-t Aug 13, 2026
8ab1f39
fix(api): make Avenia KYB retries safe
gianfra-t Aug 13, 2026
e07ff31
fix(api): restore resumable KYB and sync public contracts
ebma Aug 13, 2026
ee3a123
style(api): apply Biome formatting
ebma Aug 13, 2026
13fb59b
docs(api): propose Avenia Sumsub KYC import
gianfra-t Aug 14, 2026
5c8593b
feat(api): add Sumsub KYC token import
gianfra-t Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/api/src/api/controllers/alfredpay.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
gianfra-t marked this conversation as resolved.

if (!alfredPayCustomer) {
return res.status(404).json({ error: "Alfredpay customer not found" });
Expand Down
1,660 changes: 1,518 additions & 142 deletions apps/api/src/api/controllers/brla.controller.test.ts

Large diffs are not rendered by default.

573 changes: 465 additions & 108 deletions apps/api/src/api/controllers/brla.controller.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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);
});
});
138 changes: 114 additions & 24 deletions apps/api/src/api/controllers/onboarding.controller.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand All @@ -20,6 +36,48 @@ import { getMoneriumStatus, MONERIUM_REAUTHENTICATION_REQUIRED } from "../servic
const PROVIDER_REFRESH_TTL_MS = 60_000;
const lastProviderRefreshAt = new Map<string, number>();

/** 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);
Expand Down Expand Up @@ -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(
Expand All @@ -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.
}
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/api/controllers/quote.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions apps/api/src/api/middlewares/alfredpay.middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
}
});
});
17 changes: 16 additions & 1 deletion apps/api/src/api/middlewares/alfredpay.middleware.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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();
};
37 changes: 36 additions & 1 deletion apps/api/src/api/middlewares/dualAuth.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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();
});
});
Loading
Loading