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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/api/scripts/reconcile-unmigrated-avenia-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ type Attempt = {
type AveniaClient = {
subaccountInfo(subAccountId: string): Promise<unknown>;
getKycAttempts(subAccountId: string): Promise<unknown>;
getKybAttemptStatus(attemptId: string): Promise<unknown>;
getKybAttemptStatus(attemptId: string, subAccountId: string): Promise<unknown>;
};

type Options = {
Expand Down Expand Up @@ -309,7 +309,7 @@ export async function reconcileRow(row: InputRow, client: AveniaClient, retries:
if (row.account_type === "COMPANY") {
if (row.kyc_attempt) {
attempt = parseCompanyAttempt(
await callWithRetry(() => client.getKybAttemptStatus(row.kyc_attempt), retries),
await callWithRetry(() => client.getKybAttemptStatus(row.kyc_attempt, row.sub_account_id), retries),
row.kyc_attempt
);
} else {
Expand Down
12 changes: 6 additions & 6 deletions apps/api/src/api/controllers/brla.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1191,7 +1191,7 @@ describe("Avenia company KYB", () => {
);
});

it("rejects re-initiation when the stored PENDING is stale and Avenia is already processing", async () => {
it("rejects re-initiation when a legacy-level Avenia attempt is already processing", async () => {
mockEntityPerProfile();
ProviderCustomer.findOne = mock(async () => ({
customerEntityId: "entity-user-1",
Expand All @@ -1206,7 +1206,7 @@ describe("Avenia company KYB", () => {
() =>
({
getKycAttempts: mock(async () => ({
attempts: [{ id: "attempt-1", levelName: "kyb-level-1", status: KycAttemptStatus.PROCESSING }]
attempts: [{ id: "attempt-1", levelName: "level-1", status: KycAttemptStatus.PROCESSING }]
})),
initiateKybLevel1: initiateMock
}) as unknown as BrlaApiService
Expand All @@ -1219,7 +1219,7 @@ describe("Avenia company KYB", () => {
expect(initiateMock).not.toHaveBeenCalled();
});

it("rejects re-initiation when Avenia already approved the company", async () => {
it("rejects re-initiation when Avenia already approved the company under its legacy level name", async () => {
mockEntityPerProfile();
ProviderCustomer.findOne = mock(async () => ({
customerEntityId: "entity-user-1",
Expand All @@ -1237,7 +1237,7 @@ describe("Avenia company KYB", () => {
attempts: [
{
id: "attempt-1",
levelName: "kyb-level-1",
levelName: "level-1",
result: KycAttemptResult.APPROVED,
status: KycAttemptStatus.COMPLETED
}
Expand Down Expand Up @@ -2586,7 +2586,7 @@ describe("Avenia API KYB", () => {
{
createdAt: "2026-08-12T10:00:00.000Z",
id: "accepted-attempt",
levelName: "kyb-level-1",
levelName: "level-1",
status: KycAttemptStatus.PENDING,
updatedAt: "2026-08-12T10:00:00.000Z"
}
Expand Down Expand Up @@ -2637,7 +2637,7 @@ describe("Avenia API KYB", () => {
{
createdAt: "2026-08-12T10:00:00.000Z",
id: "conflicting-attempt",
levelName: "kyb-level-1",
levelName: "level-1",
status: KycAttemptStatus.PROCESSING,
updatedAt: "2026-08-12T10:01:00.000Z"
}
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/api/controllers/brla.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
assertAveniaHostedKybCanInitiate,
createAveniaUboOnce,
getOrCreateAveniaKybCase,
isAveniaBusinessKybLevel,
requireReadyAveniaDocument,
resolveOwnedAveniaBusinessAccount
} from "../services/avenia/avenia-kyb.service";
Expand Down Expand Up @@ -963,7 +964,7 @@ async function reconcileActiveAveniaKybAttempt(
const { attempts } = await brlaApiService.getKycAttempts(subAccountId);
const activeAttempts = attempts.filter(
attempt =>
attempt.levelName === "kyb-level-1" &&
isAveniaBusinessKybLevel(attempt.levelName) &&
(attempt.status === KycAttemptStatus.PENDING || attempt.status === KycAttemptStatus.PROCESSING)
);
if (activeAttempts.length === 0) {
Expand Down
54 changes: 36 additions & 18 deletions apps/api/src/api/controllers/onboarding.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,36 +178,54 @@ export async function getOnboardingStatus(req: Request, res: Response): Promise<
customer.customerType === "business" &&
customer.status !== VerificationStatus.Approved &&
customer.status !== VerificationStatus.Rejected &&
!!customer.providerSubaccountId &&
!!kycCasesByProviderCustomer.get(customer.id)?.providerCaseId &&
shouldRefreshProviderStatus(customer.id)
)
.map(async customer => {
const kycCase = kycCasesByProviderCustomer.get(customer.id);
if (!kycCase?.providerCaseId) return;
if (!kycCase?.providerCaseId || !customer.providerSubaccountId) return;
try {
const { attempt } = await BrlaApiService.getInstance().getKybAttemptStatus(kycCase.providerCaseId);
const { attempt } = await BrlaApiService.getInstance().getKybAttemptStatus(
kycCase.providerCaseId,
customer.providerSubaccountId
);
if (attempt.id !== kycCase.providerCaseId) {
throw new Error("Avenia returned a mismatched KYB attempt");
}
const approved = attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.APPROVED;
const rejected =
attempt.status === KycAttemptStatus.EXPIRED ||
(attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.REJECTED);
// A PENDING attempt is one the user never finished (hosted steps not completed) — keep it
// pending so the dashboard offers Continue; in_review only once Avenia is PROCESSING.
const status = approved
? VerificationStatus.Approved
: rejected
? VerificationStatus.Rejected
: attempt.status === KycAttemptStatus.PENDING
? 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 })
]);
} catch {
const refreshed =
approved || rejected
? await updateAveniaKycOutcomeForCustomer(
customer,
approved ? VerificationStatus.Approved : VerificationStatus.Rejected,
attempt.status,
{ id: kycCase.id, providerCaseId: kycCase.providerCaseId }
)
: await updateAveniaKycProgressForCustomer(
customer,
{ id: kycCase.id, providerCaseId: kycCase.providerCaseId },
attempt.status === KycAttemptStatus.PENDING ? VerificationStatus.Pending : VerificationStatus.InReview,
attempt.status
);
customer.set("status", refreshed.status);
customer.set("statusExternal", refreshed.statusExternal);
} catch (error) {
const providerStatus =
error && typeof error === "object" && "status" in error && typeof error.status === "number"
? error.status
: undefined;
logger.warn("Avenia business KYB status refresh failed", {
errorName: error instanceof Error ? error.name : "UnknownError",
providerCaseId: kycCase.providerCaseId,
providerCustomerId: customer.id,
providerStatus
});
// Status aggregation remains available while Avenia is temporarily unavailable.
}
})
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/api/services/avenia/avenia-kyb.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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";
import { createAveniaUboOnce, getOrCreateAveniaKybCase, isAveniaBusinessKybLevel } from "./avenia-kyb.service";

const originalFindOrCreate = KycCase.findOrCreate;
const originalFindByPk = KycCase.findByPk;
Expand Down Expand Up @@ -141,3 +141,11 @@ describe("getOrCreateAveniaKybCase", () => {
expect(findOrCreate).toHaveBeenCalledTimes(2);
});
});

describe("isAveniaBusinessKybLevel", () => {
it("recognizes current and legacy company levels without accepting unrelated attempts", () => {
expect(isAveniaBusinessKybLevel("kyb-level-1")).toBe(true);
expect(isAveniaBusinessKybLevel("level-1")).toBe(true);
expect(isAveniaBusinessKybLevel("sumsub-token-recipient")).toBe(false);
});
});
8 changes: 6 additions & 2 deletions apps/api/src/api/services/avenia/avenia-kyb.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import { isDeterministicProviderRejection } from "./provider-errors";

const kybCaseCreations = new Map<string, Promise<KycCase>>();

export function isAveniaBusinessKybLevel(levelName: string): boolean {
return levelName === "kyb-level-1" || levelName === "level-1";
}

function hashUboValue(value: unknown): string {
const canonicalize = (input: unknown): unknown => {
if (Array.isArray(input)) return input.map(canonicalize);
Expand Down Expand Up @@ -219,15 +223,15 @@ export async function assertAveniaHostedKybCanInitiate(
const { attempts } = await brlaApiService.getKycAttempts(subAccountId);
const hasApprovedKybAttempt = attempts.some(
attempt =>
attempt.levelName === "kyb-level-1" &&
isAveniaBusinessKybLevel(attempt.levelName) &&
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
attempt => isAveniaBusinessKybLevel(attempt.levelName) && attempt.status === KycAttemptStatus.PROCESSING
);
if (hasProcessingKybAttempt) {
throw new APIError({
Expand Down
50 changes: 36 additions & 14 deletions apps/api/src/api/workers/kyb-status.worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,22 @@ describe("KybStatusWorker query window", () => {

it("filters partner-owned entities in the join so they cannot occupy batch slots", async () => {
const options = await captureQuery();
const include = (options.include as Array<{ where?: Record<string, unknown> }>)[0];
const include = (options.include as Array<{ as?: string; where?: Record<string, unknown> }>).find(
association => association.as === "customerEntity"
);

expect(include.where?.profileId).toBeDefined();
expect(include?.where?.profileId).toBeDefined();
});

it("requires the bound Avenia business account and its subaccount in the join", async () => {
const options = await captureQuery();
const include = (options.include as Array<{ as?: string; required?: boolean; where?: Record<string, unknown> }>).find(
association => association.as === "providerCustomer"
);

expect(include?.required).toBe(true);
expect(include?.where).toMatchObject({ customerType: "business", provider: "avenia" });
expect(include?.where?.providerSubaccountId).toBeDefined();
});

// A poll does not modify a still-pending case, so without the cursor the same first
Expand Down Expand Up @@ -100,7 +113,12 @@ describe("KybStatusWorker query window", () => {
// Mirrors the authenticated route's guard: a malformed provider response must not
// enqueue another attempt's outcome for this case's profile.
it("discards a provider response whose attempt id does not match the case", async () => {
const polledCase = { customerEntity: { profileId: "user-1" }, id: "case-1", providerCaseId: "attempt-1" };
const polledCase = {
customerEntity: { profileId: "user-1" },
id: "case-1",
providerCaseId: "attempt-1",
providerCustomer: { providerSubaccountId: "subaccount-1" }
};
KycCase.findAll = (async () => [polledCase]) as unknown as typeof KycCase.findAll;

const realGetInstance = BrlaApiService.getInstance;
Expand All @@ -110,25 +128,29 @@ describe("KybStatusWorker query window", () => {
const enqueueTouched = mock(async () => ({}) as EmailNotification);
EmailNotification.findOne = enqueueTouched as unknown as typeof EmailNotification.findOne;

const respondWith = (id: string) =>
mock(
() =>
({
getKybAttemptStatus: mock(async () => ({
attempt: { id, result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED, updatedAt: "2026-08-07" }
}))
}) as unknown as BrlaApiService
);
const respondWith = (id: string) => {
const getKybAttemptStatus = mock(async () => ({
attempt: { id, result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED, updatedAt: "2026-08-07" }
}));
return {
getInstance: mock(() => ({ getKybAttemptStatus }) as unknown as BrlaApiService),
getKybAttemptStatus
};
};

try {
const worker = new KybStatusWorker() as unknown as TestableWorker;

BrlaApiService.getInstance = respondWith("attempt-OTHER");
const mismatch = respondWith("attempt-OTHER");
BrlaApiService.getInstance = mismatch.getInstance;
await worker.poll();
expect(mismatch.getKybAttemptStatus).toHaveBeenCalledWith("attempt-1", "subaccount-1");
expect(enqueueTouched).not.toHaveBeenCalled();

BrlaApiService.getInstance = respondWith("attempt-1");
const match = respondWith("attempt-1");
BrlaApiService.getInstance = match.getInstance;
await worker.poll();
expect(match.getKybAttemptStatus).toHaveBeenCalledWith("attempt-1", "subaccount-1");
expect(enqueueTouched).toHaveBeenCalledTimes(1);
} finally {
BrlaApiService.getInstance = realGetInstance;
Expand Down
17 changes: 14 additions & 3 deletions apps/api/src/api/workers/kyb-status.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import logger from "../../config/logger";
import CustomerEntity from "../../models/customerEntity.model";
import { NotificationProvider } from "../../models/emailNotification.model";
import KycCase from "../../models/kycCase.model";
import { VerificationStatus } from "../../models/providerCustomer.model";
import ProviderCustomer, { VerificationStatus } from "../../models/providerCustomer.model";
import { enqueueVerificationNotification } from "../services/avenia/verification-notifications";

const MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -65,6 +65,16 @@ class KybStatusWorker {
// Partner-owned entities have no profile to email. Filtered in the join, not
// after the fetch, so they cannot occupy the batch's slots.
where: { profileId: { [Op.not]: null } }
},
{
as: "providerCustomer",
model: ProviderCustomer,
required: true,
where: {
customerType: "business",
provider: "avenia",
providerSubaccountId: { [Op.not]: null }
}
}
],
limit: MAX_CASES_PER_CYCLE,
Expand Down Expand Up @@ -114,12 +124,13 @@ class KybStatusWorker {
try {
// Non-null by the join filter above; kept for type narrowing.
const profileId = kycCase.customerEntity?.profileId;
if (!profileId) {
const subAccountId = kycCase.providerCustomer?.providerSubaccountId;
if (!profileId || !subAccountId) {
continue;
}

// Non-null by the providerCaseId filter in the query above.
const { attempt } = await brlaApiService.getKybAttemptStatus(kycCase.providerCaseId as string);
const { attempt } = await brlaApiService.getKybAttemptStatus(kycCase.providerCaseId as string, subAccountId);
if (!attempt) {
continue;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/models/kycCase.model.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DataTypes, Model, Optional } from "sequelize";
import sequelize from "../config/database";
import type CustomerEntity from "./customerEntity.model";
import type ProviderCustomer from "./providerCustomer.model";
import type { ProviderName, VerificationStatus } from "./providerCustomer.model";

export type KycCaseType = "kyc" | "kyb";
Expand Down Expand Up @@ -99,6 +100,7 @@ class KycCase extends Model<KycCaseAttributes, KycCaseCreationAttributes> implem

// Association helper
declare customerEntity?: CustomerEntity;
declare providerCustomer?: ProviderCustomer;
}

KycCase.init(
Expand Down
Loading
Loading