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/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} 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.