Skip to content
Merged
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
70 changes: 70 additions & 0 deletions apps/api/src/api/controllers/brla.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
10 changes: 4 additions & 6 deletions apps/api/src/api/controllers/brla.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 });
Expand Down
14 changes: 10 additions & 4 deletions apps/api/src/api/services/avenia/avenia-customer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,23 @@ 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,
outcome: VerificationStatus.Approved | VerificationStatus.Rejected,
statusExternal: string
): Promise<void> {
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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
8 changes: 6 additions & 2 deletions docs/security-spec/05-integrations/brla.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading