From 5545d3012883ef998bead14e61cb5b440166da0f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 17 Jul 2026 18:50:34 +0200 Subject: [PATCH 1/2] fix(api): let a rejected Avenia KYC account become approved on a successful retry updateAveniaKycOutcome only flipped accounts that were in_review (mirroring the legacy WHERE internal_status = 'Requested' guard), and the in-flight poll branch excluded rejected accounts. A user whose first KYC attempt was rejected and whose retry succeeded therefore stayed 'rejected' forever, and ramp registration failed with 'No completed Avenia profile found for this API key user' despite a successful KYC. Approval is now the only terminal state: a stale attempt read never downgrades an approved account, but rejected/pending accounts follow the latest provider attempt, and a processing retry moves a rejected account back to in_review. Repeated polls of an unchanged outcome no-op. --- .../api/controllers/brla.controller.test.ts | 70 +++++++++++++++++++ .../src/api/controllers/brla.controller.ts | 10 ++- .../avenia/avenia-customer.service.ts | 14 ++-- docs/security-spec/05-integrations/brla.md | 8 ++- 4 files changed, 90 insertions(+), 12 deletions(-) diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index a8014aeb7..8a0f99709 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -293,6 +293,76 @@ describe("fetchSubaccountKycStatus", () => { expect(update).toHaveBeenCalledWith({ status: VerificationStatus.Pending, statusExternal: null }); expect(kycUpdate).toHaveBeenCalledWith(expect.objectContaining({ status: VerificationStatus.Pending })); }); + + function mockOwnedRecordWithAttempt(status: VerificationStatus, attempt: unknown) { + mockEntityPerProfile(); + const update = mock(async () => undefined); + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + id: "customer-1", + providerSubaccountId: "subaccount-1", + status, + statusExternal: null, + update + })) as unknown as typeof ProviderCustomer.findOne; + const kycUpdate = mock(async () => undefined); + KycCase.findOne = mock(async () => ({ update: kycUpdate })) as unknown as typeof KycCase.findOne; + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => ({ attempts: [attempt] })) + }) as unknown as BrlaApiService + ); + return { kycUpdate, update }; + } + + // Regression: a rejected account whose retried attempt is approved used to stay `rejected` + // forever (the outcome update only matched `in_review`), so ramp registration failed with + // "No completed Avenia profile found" despite a successful KYC. + it("flips a rejected account to approved when a retried attempt is approved", async () => { + const { kycUpdate, update } = mockOwnedRecordWithAttempt(VerificationStatus.Rejected, { + levelName: "KYC_1", + result: KycAttemptResult.APPROVED, + status: KycAttemptStatus.COMPLETED + }); + + const res = createResponse(); + await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + + 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 })); + }); + + it("returns a rejected account to in_review while a retried attempt is processing", async () => { + const { update } = mockOwnedRecordWithAttempt(VerificationStatus.Rejected, { + levelName: "KYC_1", + result: "", + status: KycAttemptStatus.PROCESSING + }); + + const res = createResponse(); + 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 }); + }); + + it("never downgrades an approved account on a stale rejected attempt read", async () => { + const { kycUpdate, update } = mockOwnedRecordWithAttempt(VerificationStatus.Approved, { + levelName: "KYC_1", + result: KycAttemptResult.REJECTED, + status: KycAttemptStatus.COMPLETED + }); + + const res = createResponse(); + 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", () => { diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index a28bf0974..118cbbfb9 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -420,7 +420,7 @@ export const createSubaccount = async ( // A company has no verification attempt yet at this point — the hosted KYB links are issued in // a follow-up call — so it starts pending (resumable). Individuals keep the legacy Requested - // semantics (updateAveniaKycOutcome only flips accounts that are in_review). + // semantics (in_review until the outcome poll decides). const initialStatus = accountType === AveniaAccountType.COMPANY ? VerificationStatus.Pending : VerificationStatus.InReview; if (existing) { await existing.update({ @@ -521,11 +521,9 @@ export const fetchSubaccountKycStatus = async ( if (kycAttemptStatus.result === KycAttemptResult.REJECTED) { await updateAveniaKycOutcome(taxId, VerificationStatus.Rejected, kycAttemptStatus.status); } - if ( - !kycAttemptStatus.result && - record.status !== VerificationStatus.Approved && - record.status !== VerificationStatus.Rejected - ) { + // 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) { const status = kycAttemptStatus.status === KycAttemptStatus.EXPIRED ? VerificationStatus.Pending : VerificationStatus.InReview; await record.update({ status, statusExternal: kycAttemptStatus.status }); 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 18a631a9e..f8b79744b 100644 --- a/apps/api/src/api/services/avenia/avenia-customer.service.ts +++ b/apps/api/src/api/services/avenia/avenia-customer.service.ts @@ -70,9 +70,12 @@ export async function upsertAveniaKycCase( } /** - * Poll-driven KYC outcome transition: flips a `Requested` account to Accepted/Rejected - * (the only mechanism that makes a subaccount ramp-ready). Idempotent — mirrors the legacy - * `UPDATE ... WHERE internal_status = 'Requested'` guard. + * Poll-driven KYC outcome transition: flips an account to Approved/Rejected based on the + * latest provider attempt (the only mechanism that makes a subaccount ramp-ready). Approval + * 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. */ export async function updateAveniaKycOutcome( taxId: string, @@ -80,7 +83,10 @@ export async function updateAveniaKycOutcome( statusExternal: string ): Promise { const record = await findAveniaCustomerByTaxId(taxId); - if (!record || record.status !== VerificationStatus.InReview) { + if (!record || record.status === VerificationStatus.Approved) { + return; + } + if (record.status === outcome && record.statusExternal === statusExternal) { return; } await record.update({ status: outcome, statusExternal }); diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index 11995a1f8..db02dea93 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -161,5 +161,9 @@ Key properties: `getKybAttemptStatus` resolves that binding and verifies entity ownership before querying Avenia. The browser receives only normalized status/result fields, never provider submission data. - KYC/KYB state transitions update canonical status and provider status on both - `provider_customers` and the account's `kyc_cases` row in the same code path - (`updateAveniaKycOutcome` preserves the idempotent `in_review` transition guard). + `provider_customers` and the account's `kyc_cases` row in the same code path. + `updateAveniaKycOutcome` treats `approved` as terminal (a stale attempt read never + downgrades an approved account) but otherwise follows the latest provider attempt: + in particular, a `rejected` account whose retried attempt succeeds becomes `approved` + (the former `in_review`-only guard left it stuck in `rejected`, so a successfully + retried KYC never became ramp-ready). Repeated polls of an unchanged outcome no-op. From ce558359eaad9720fb7ecd0da87eeff207a507b7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 17 Jul 2026 19:17:45 +0200 Subject: [PATCH 2/2] chore(dashboard): sort Tailwind classes in RecipientsTable Pre-existing useSortedClasses violation on staging that fails 'bun verify' in CI. --- apps/dashboard/src/components/recipients/RecipientsTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/components/recipients/RecipientsTable.tsx b/apps/dashboard/src/components/recipients/RecipientsTable.tsx index 645ddec93..ffcdffeb7 100644 --- a/apps/dashboard/src/components/recipients/RecipientsTable.tsx +++ b/apps/dashboard/src/components/recipients/RecipientsTable.tsx @@ -44,7 +44,7 @@ export function RecipientsTable({ recipients }: { recipients: Recipient[] }) { className={ recipient.isSelf ? undefined - : "cursor-pointer focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring" + : "focus-visible:-outline-offset-2 cursor-pointer focus-visible:outline-2 focus-visible:outline-ring" } initial={{ opacity: 0, y: 8 }} key={recipient.id}