diff --git a/apps/api/scripts/reconcile-unmigrated-avenia-status.ts b/apps/api/scripts/reconcile-unmigrated-avenia-status.ts index 21c623876..bd30436dd 100644 --- a/apps/api/scripts/reconcile-unmigrated-avenia-status.ts +++ b/apps/api/scripts/reconcile-unmigrated-avenia-status.ts @@ -41,7 +41,7 @@ type Attempt = { type AveniaClient = { subaccountInfo(subAccountId: string): Promise; getKycAttempts(subAccountId: string): Promise; - getKybAttemptStatus(attemptId: string): Promise; + getKybAttemptStatus(attemptId: string, subAccountId: string): Promise; }; type Options = { @@ -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 { diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index 1d7594f71..e67c3f72f 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -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", @@ -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 @@ -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", @@ -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 } @@ -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" } @@ -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" } diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 1f1304ae8..e4e9bc37e 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -70,6 +70,7 @@ import { assertAveniaHostedKybCanInitiate, createAveniaUboOnce, getOrCreateAveniaKybCase, + isAveniaBusinessKybLevel, requireReadyAveniaDocument, resolveOwnedAveniaBusinessAccount } from "../services/avenia/avenia-kyb.service"; @@ -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) { diff --git a/apps/api/src/api/controllers/onboarding.controller.ts b/apps/api/src/api/controllers/onboarding.controller.ts index b620eaa41..4181c0874 100644 --- a/apps/api/src/api/controllers/onboarding.controller.ts +++ b/apps/api/src/api/controllers/onboarding.controller.ts @@ -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. } }) 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 index a99307a76..2de4fc361 100644 --- a/apps/api/src/api/services/avenia/avenia-kyb.service.test.ts +++ b/apps/api/src/api/services/avenia/avenia-kyb.service.test.ts @@ -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; @@ -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); + }); +}); diff --git a/apps/api/src/api/services/avenia/avenia-kyb.service.ts b/apps/api/src/api/services/avenia/avenia-kyb.service.ts index baf374dee..4e686de1e 100644 --- a/apps/api/src/api/services/avenia/avenia-kyb.service.ts +++ b/apps/api/src/api/services/avenia/avenia-kyb.service.ts @@ -21,6 +21,10 @@ import { isDeterministicProviderRejection } from "./provider-errors"; const kybCaseCreations = new Map>(); +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); @@ -219,7 +223,7 @@ 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 ); @@ -227,7 +231,7 @@ export async function assertAveniaHostedKybCanInitiate( 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({ diff --git a/apps/api/src/api/workers/kyb-status.worker.test.ts b/apps/api/src/api/workers/kyb-status.worker.test.ts index 20bf5c77d..528adf5f1 100644 --- a/apps/api/src/api/workers/kyb-status.worker.test.ts +++ b/apps/api/src/api/workers/kyb-status.worker.test.ts @@ -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 }>)[0]; + const include = (options.include as Array<{ as?: string; where?: Record }>).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 }>).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 @@ -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; @@ -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; diff --git a/apps/api/src/api/workers/kyb-status.worker.ts b/apps/api/src/api/workers/kyb-status.worker.ts index 86e22d7e5..faaa55826 100644 --- a/apps/api/src/api/workers/kyb-status.worker.ts +++ b/apps/api/src/api/workers/kyb-status.worker.ts @@ -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; @@ -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, @@ -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; } diff --git a/apps/api/src/models/kycCase.model.ts b/apps/api/src/models/kycCase.model.ts index e15945288..d15f88319 100644 --- a/apps/api/src/models/kycCase.model.ts +++ b/apps/api/src/models/kycCase.model.ts @@ -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"; @@ -99,6 +100,7 @@ class KycCase extends Model implem // Association helper declare customerEntity?: CustomerEntity; + declare providerCustomer?: ProviderCustomer; } KycCase.init( diff --git a/apps/api/src/tests/notifications-onboarding.integration.test.ts b/apps/api/src/tests/notifications-onboarding.integration.test.ts index cd3d8ce82..556d25d37 100644 --- a/apps/api/src/tests/notifications-onboarding.integration.test.ts +++ b/apps/api/src/tests/notifications-onboarding.integration.test.ts @@ -13,6 +13,7 @@ import { createAlfredpayCustomer } from "../api/services/alfredpay/alfredpay-cus import { reconcileMissedRampCompletedEmails } from "../api/services/email"; import { emitNotification } from "../api/services/notifications/notification.service"; import KybStatusWorker from "../api/workers/kyb-status.worker"; +import logger from "../config/logger"; import ApiCredential from "../models/apiCredential.model"; import CustomerEntity from "../models/customerEntity.model"; import EmailNotification, { NotificationProvider, NotificationStatus, NotificationType } from "../models/emailNotification.model"; @@ -159,13 +160,13 @@ describe("ramp completion notification reconciliation", () => { userId: settled.user.id }); - const polled: string[] = []; + const polled: Array<[string, string]> = []; const getInstance = BrlaApiService.getInstance; BrlaApiService.getInstance = mock( () => ({ - getKybAttemptStatus: mock(async (attemptId: string) => { - polled.push(attemptId); + getKybAttemptStatus: mock(async (attemptId: string, subAccountId: string) => { + polled.push([attemptId, subAccountId]); return { attempt: { id: attemptId, status: KycAttemptStatus.PENDING, updatedAt: "2026-08-06" } }; }) }) as unknown as BrlaApiService @@ -178,7 +179,7 @@ describe("ramp completion notification reconciliation", () => { BrlaApiService.getInstance = getInstance; } - expect(polled).toEqual(["attempt-fresh"]); + expect(polled).toEqual([["attempt-fresh", "kyb-poll-fresh-sub"]]); }); it("tombstones a completed partner-API ramp instead of enqueuing mail", async () => { @@ -923,11 +924,14 @@ describe("GET /v1/onboarding/status", () => { type: "kyb" }); + const getKybAttemptStatus = mock(async () => ({ + attempt: { id: "attempt-1", status: KycAttemptStatus.PENDING } + })); const getInstance = BrlaApiService.getInstance; BrlaApiService.getInstance = mock( () => ({ - getKybAttemptStatus: mock(async () => ({ attempt: { id: "attempt-1", status: KycAttemptStatus.PENDING } })) + getKybAttemptStatus }) as unknown as BrlaApiService ); @@ -947,10 +951,209 @@ describe("GET /v1/onboarding/status", () => { await business.reload(); await kycCase.reload(); + expect(getKybAttemptStatus).toHaveBeenCalledWith("attempt-1", "kyb-subaccount"); expect(business.status).toBe(VerificationStatus.Pending); expect(kycCase.status).toBe(VerificationStatus.Pending); }); + it("reconciles an approved legacy-shaped business attempt through its bound subaccount", async () => { + const { user, token } = await createAuthedUser("avenia-kyb-approved@example.com"); + const business = await createTestTaxId(user.id, { + customerType: "business", + subAccountId: "approved-subaccount", + taxId: "22333444000162" + }); + await business.update({ status: VerificationStatus.InReview, statusExternal: KycAttemptStatus.PROCESSING }); + const kycCase = await KycCase.create({ + customerEntityId: business.customerEntityId, + level: "level_1", + provider: "avenia", + providerCaseId: "approved-attempt", + providerCustomerId: business.id, + status: VerificationStatus.InReview, + statusExternal: KycAttemptStatus.PROCESSING, + type: "kyb" + }); + const getKybAttemptStatus = mock(async () => ({ + attempt: { + id: "approved-attempt", + levelName: "level-1", + result: KycAttemptResult.APPROVED, + status: KycAttemptStatus.COMPLETED, + submissionData: undefined + } + })); + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock(() => ({ getKybAttemptStatus }) as unknown as BrlaApiService); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + entities: Array<{ accounts: Array<{ provider: string; state: string }> }>; + }; + expect(body.entities[0].accounts.find(account => account.provider === "avenia")?.state).toBe("approved"); + } finally { + BrlaApiService.getInstance = getInstance; + } + + expect(getKybAttemptStatus).toHaveBeenCalledWith("approved-attempt", "approved-subaccount"); + await business.reload(); + await kycCase.reload(); + expect(business.status).toBe(VerificationStatus.Approved); + expect(kycCase.status).toBe(VerificationStatus.Approved); + expect(kycCase.approvedAt).toBeInstanceOf(Date); + }); + + it("does not mutate business onboarding when exact polling returns a mismatched attempt", async () => { + const { user, token } = await createAuthedUser("avenia-kyb-mismatch@example.com"); + const business = await createTestTaxId(user.id, { + customerType: "business", + subAccountId: "mismatch-subaccount", + taxId: "33444555000143" + }); + await business.update({ status: VerificationStatus.InReview, statusExternal: "UNCHANGED" }); + const kycCase = await KycCase.create({ + customerEntityId: business.customerEntityId, + level: "level_1", + provider: "avenia", + providerCaseId: "current-attempt", + providerCustomerId: business.id, + status: VerificationStatus.InReview, + statusExternal: "UNCHANGED", + type: "kyb" + }); + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => + ({ + getKybAttemptStatus: mock(async () => ({ + attempt: { + id: "other-attempt", + 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 business.reload(); + await kycCase.reload(); + expect(business.status).toBe(VerificationStatus.InReview); + expect(business.statusExternal).toBe("UNCHANGED"); + expect(kycCase.status).toBe(VerificationStatus.InReview); + expect(kycCase.statusExternal).toBe("UNCHANGED"); + }); + + it("does not downgrade a terminal business outcome with stale provider progress", async () => { + const { user, token } = await createAuthedUser("avenia-kyb-stale-progress@example.com"); + const business = await createTestTaxId(user.id, { + customerType: "business", + subAccountId: "stale-progress-subaccount", + taxId: "55666777000105" + }); + await business.update({ status: VerificationStatus.InReview, statusExternal: KycAttemptStatus.PROCESSING }); + const kycCase = await KycCase.create({ + customerEntityId: business.customerEntityId, + level: "level_1", + provider: "avenia", + providerCaseId: "stale-progress-attempt", + providerCustomerId: business.id, + status: VerificationStatus.InReview, + statusExternal: KycAttemptStatus.PROCESSING, + type: "kyb" + }); + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => + ({ + getKybAttemptStatus: mock(async () => { + await business.update({ status: VerificationStatus.Approved, statusExternal: KycAttemptStatus.COMPLETED }); + await kycCase.update({ + approvedAt: new Date(), + status: VerificationStatus.Approved, + statusExternal: KycAttemptStatus.COMPLETED + }); + return { attempt: { id: "stale-progress-attempt", 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 business.reload(); + await kycCase.reload(); + expect(business.status).toBe(VerificationStatus.Approved); + expect(business.statusExternal).toBe(KycAttemptStatus.COMPLETED); + expect(kycCase.status).toBe(VerificationStatus.Approved); + expect(kycCase.statusExternal).toBe(KycAttemptStatus.COMPLETED); + }); + + it("keeps cached business onboarding state and logs safe identifiers when Avenia is unavailable", async () => { + const { user, token } = await createAuthedUser("avenia-kyb-provider-failure@example.com"); + const business = await createTestTaxId(user.id, { + customerType: "business", + subAccountId: "failure-subaccount", + taxId: "44555666000124" + }); + await business.update({ status: VerificationStatus.InReview, statusExternal: "UNCHANGED" }); + const kycCase = await KycCase.create({ + customerEntityId: business.customerEntityId, + level: "level_1", + provider: "avenia", + providerCaseId: "failure-attempt", + providerCustomerId: business.id, + status: VerificationStatus.InReview, + statusExternal: "UNCHANGED", + type: "kyb" + }); + const getInstance = BrlaApiService.getInstance; + const originalWarn = logger.warn; + const warn = mock(() => logger) as typeof logger.warn; + logger.warn = warn; + BrlaApiService.getInstance = mock( + () => + ({ + getKybAttemptStatus: mock(async () => { + throw new Error("provider unavailable"); + }) + }) 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; + logger.warn = originalWarn; + } + + expect(warn).toHaveBeenCalledWith("Avenia business KYB status refresh failed", { + errorName: "Error", + providerCaseId: "failure-attempt", + providerCustomerId: business.id, + providerStatus: undefined + }); + await business.reload(); + await kycCase.reload(); + expect(business.status).toBe(VerificationStatus.InReview); + expect(business.statusExternal).toBe("UNCHANGED"); + expect(kycCase.status).toBe(VerificationStatus.InReview); + expect(kycCase.statusExternal).toBe("UNCHANGED"); + }); + it("aggregates provider accounts and KYC cases per entity with a normalized state", async () => { const { user, token } = await createAuthedUser("user@example.com"); const avenia = await createTestTaxId(user.id); diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index 0b3d49be8..928881c13 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -165,14 +165,14 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 18. **`/v1/brl/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`, `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. +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`. Every exact-attempt read, including authenticated status, dashboard reconciliation, and the notification fallback worker, MUST send both that attempt ID and the owning business account's `provider_subaccount_id` and MUST reject a mismatched response ID. `GET /v1/brla/kyb/attempt-status` persists normalized status on both the case and provider customer and returns only `status`, `retryable`, optional `result`, and optional normalized `failureReason`; dashboard reconciliation uses the same locked provider-customer-before-case persistence path and cannot downgrade terminal state. 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. 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. +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 company attempt named either current `kyb-level-1` or legacy `level-1` 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. Other level names are excluded. 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. @@ -215,7 +215,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou | **Key-rotation denial of service** | Avenia rotates the signing key; genuine events start failing verification | Key is fetched, never pinned; a failed verify against the cached key triggers exactly one refetch before rejection, so rotation self-heals within one request. | | **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`. | +| **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; every exact lookup checks that binding and scopes the provider request with the owning subaccount before the call, rejects a mismatched response ID, minimizes its response, and accepts 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. Deterministic provider rejections created no attempt, so they are failed/retriable with a new key; other unresolved outcomes remain quarantined and are never automatically replayed. | @@ -282,6 +282,9 @@ Key properties: 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. +- Exact-attempt responses are parsed through the shared Avenia schema. Provider-null optional + fields such as unused `submissionData` are normalized to absent values and are never returned + by the status endpoints. - 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/packages/shared/src/services/brla/brlaApiService.test.ts b/packages/shared/src/services/brla/brlaApiService.test.ts index bdc311f0c..64e915282 100644 --- a/packages/shared/src/services/brla/brlaApiService.test.ts +++ b/packages/shared/src/services/brla/brlaApiService.test.ts @@ -483,9 +483,10 @@ describe("BrlaApiService.sendRequest path templating", () => { const service = Object.create(BrlaApiService.prototype) as BrlaApiService; Object.assign(service, { apiKey: "test-api-key", privateKey }); - await service.getKybAttemptStatus("attempt-9"); + await service.getKybAttemptStatus("attempt-9", "sub account"); expect(requestedUrl).toContain("/v2/kyc/attempts/attempt-9"); + expect(requestedUrl).toContain("subAccountId=sub%20account"); expect(requestedUrl).not.toContain("{attemptId}"); // A hung connection must not stall callers forever — cron workers with // waitForCompletion would otherwise never run another cycle. diff --git a/packages/shared/src/services/brla/brlaApiService.ts b/packages/shared/src/services/brla/brlaApiService.ts index 856acfaf1..d751cba2d 100644 --- a/packages/shared/src/services/brla/brlaApiService.ts +++ b/packages/shared/src/services/brla/brlaApiService.ts @@ -496,15 +496,15 @@ export class BrlaApiService { /** Gets an individual or company verification attempt by its exact provider ID. */ public async getVerificationAttemptStatus( attemptId: string, - subAccountId?: string + subAccountId: string ): Promise { - const query = subAccountId ? `subAccountId=${encodeURIComponent(subAccountId)}` : undefined; + const query = `subAccountId=${encodeURIComponent(subAccountId)}`; return aveniaKybAttemptStatusSchema.parse( await this.sendRequest(Endpoint.GetKybAttempt, "GET", query, undefined, attemptId) ); } - public async getKybAttemptStatus(attemptId: string, subAccountId?: string): Promise { + public async getKybAttemptStatus(attemptId: string, subAccountId: string): Promise { return this.getVerificationAttemptStatus(attemptId, subAccountId); } diff --git a/packages/shared/src/services/brla/schemas.test.ts b/packages/shared/src/services/brla/schemas.test.ts index b0e183b6a..6d779b58f 100644 --- a/packages/shared/src/services/brla/schemas.test.ts +++ b/packages/shared/src/services/brla/schemas.test.ts @@ -203,15 +203,18 @@ describe("Avenia KYB Level 1 response schemas", () => { result: null, resultMessage: null, status: "PENDING", + submissionData: null, updatedAt: "2026-03-19T22:09:52.629984Z" }; expect(aveniaKybAttemptStatusSchema.parse({ attempt: pending }).attempt).toMatchObject({ result: undefined, - resultMessage: undefined + resultMessage: undefined, + submissionData: undefined }); expect(aveniaKycAttemptsSchema.parse({ attempts: [pending] }).attempts[0]).toMatchObject({ result: undefined, - resultMessage: undefined + resultMessage: undefined, + submissionData: undefined }); }); diff --git a/packages/shared/src/services/brla/schemas.ts b/packages/shared/src/services/brla/schemas.ts index f6c76dc7e..89ad4ad3c 100644 --- a/packages/shared/src/services/brla/schemas.ts +++ b/packages/shared/src/services/brla/schemas.ts @@ -227,7 +227,10 @@ const aveniaAttemptSchema = z.looseObject({ .transform(value => value ?? undefined), retryable: z.boolean().optional(), status: z.enum(KycAttemptStatus), - submissionData: z.record(z.string(), z.unknown()).optional(), + submissionData: z + .record(z.string(), z.unknown()) + .nullish() + .transform(value => value ?? undefined), updatedAt: z.string().datetime({ offset: true }) }) satisfies z.ZodType;