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
18 changes: 18 additions & 0 deletions src/subdomains/generic/kyc/entities/kyc-step.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { UserData } from '../../user/models/user-data/user-data.entity';
import { KycLevel, KycType, UserDataStatus } from '../../user/models/user-data/user-data.enum';
import { IdentDocumentType, IdentResultData, IdentType } from '../dto/ident-result-data.dto';
import { IdNowResult } from '../dto/ident-result.dto';
import { KycError } from '../dto/kyc-error.enum';
import { ManualIdentResult } from '../dto/manual-ident-result.dto';
import { KycSessionInfoDto } from '../dto/output/kyc-info.dto';
import { IdDocTypeMap, ReviewAnswer, SumsubResult } from '../dto/sum-sub.dto';
Expand Down Expand Up @@ -213,6 +214,23 @@ export class KycStep extends IEntity {
return this.isInReview || this.isCompleted;
}

// Whether this row records an outcome. A cancellation only does so if the step had completed first:
// `complete()` writes `result` and `cancel()` leaves it untouched, so a cancelled step without one was
// never satisfied — it was merely superseded, and says nothing about the attempt it replaced.
//
get hasSettledVerdict(): boolean {
return !this.isInProgress && !(this.isCanceled && !this.result);
}

// Whether this step's outcome was rejected rather than accepted. `restartStep` revokes an outcome without
// erasing it — it calls `fail(undefined, …)` and `setResult(undefined)` keeps the existing value — so a
// completed-then-restarted row still carries a stale `result` and, once cancelled, would otherwise read as a
// clean completion. The RESTARTED_STEP marker survives both writes, so it stays authoritative after the
// status has moved on.
get isRejected(): boolean {
return this.isFailed || (this.comment?.split(';').includes(KycError.RESTARTED_STEP) ?? false);
}

update(
status: ReviewStatus,
result?: KycStepResult,
Expand Down
187 changes: 187 additions & 0 deletions src/subdomains/generic/kyc/services/__tests__/kyc.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Country } from 'src/shared/models/country/country.entity';
import { CountryService } from 'src/shared/models/country/country.service';
import { DfxLogger } from 'src/shared/services/dfx-logger';
import * as processServiceModule from 'src/shared/services/process.service';
import { AccountType } from '../../../user/models/user-data/account-type.enum';
import { createCustomUserData } from '../../../user/models/user-data/__mocks__/user-data.entity.mock';
import { UserData } from '../../../user/models/user-data/user-data.entity';
import { RiskStatus, UserDataStatus } from '../../../user/models/user-data/user-data.enum';
Expand Down Expand Up @@ -795,3 +796,189 @@ describe('KycService checkDfxApproval step promotion', () => {
expect(kycStepRepo.update).not.toHaveBeenCalled();
});
});

// A flow that writes personal data outside the step machinery (RealUnit registration) leaves the
// PERSONAL_DATA step IN_PROGRESS: initiateStep's auto-completion is gated on `!preventDirectEvaluation`,
// which any prior step row sets, so an account that once abandoned the step could never satisfy it again
// and KycInfoMapper kept handing that stale step back as `currentStep`.
describe('KycService completeSatisfiedPersonalDataStep', () => {
let service: KycService;
let kycStepRepo: jest.Mocked<KycStepRepository>;
let userDataService: jest.Mocked<UserDataService>;

const personalStep = (status: ReviewStatus, sequenceNumber = 0, result?: string, comment?: string): KycStep =>
Object.assign(new KycStep(), {
id: 2 + sequenceNumber,
name: KycStepName.PERSONAL_DATA,
status,
sequenceNumber,
result,
comment,
});

// Every field in `requiredKycFields` for a personal account, so `isDataComplete` is true.
const completeUser = (kycSteps: KycStep[], overrides: Partial<UserData> = {}): UserData =>
createCustomUserData({
id: 1,
accountType: AccountType.PERSONAL,
mail: 'test@test.com',
phone: '+41790000000',
firstname: 'Erika',
surname: 'Mueller',
street: 'Bahnhofstrasse 1',
location: 'Zurich',
zip: '8001',
kycSteps,
...overrides,
});

beforeEach(() => {
kycStepRepo = createMock<KycStepRepository>();
userDataService = createMock<UserDataService>();

service = Object.create(KycService.prototype);
(service as any).kycStepRepo = kycStepRepo;
(service as any).userDataService = userDataService;
jest.spyOn(service as any, 'createStepLog').mockResolvedValue(undefined);
jest.spyOn(service as any, 'updateProgress').mockResolvedValue(undefined);
});

const run = async (user: UserData): Promise<void> => {
userDataService.getUserData.mockResolvedValue(user);
// the caller's UserData need not carry `kycSteps`; the method reloads it itself
await service.completeSatisfiedPersonalDataStep(createCustomUserData({ id: user.id }));
};

it('completes a pending step and advances the process', async () => {
const step = personalStep(ReviewStatus.IN_PROGRESS);
await run(completeUser([step]));

expect(userDataService.getUserData).toHaveBeenCalledWith(1, { kycSteps: true });
expect(kycStepRepo.update).toHaveBeenCalledTimes(1);
expect(step.status).toBe(ReviewStatus.COMPLETED);
expect(step.getResult()).toMatchObject({ firstname: 'Erika', surname: 'Mueller', zip: '8001' });
expect((service as any).updateProgress).toHaveBeenCalled();
});

// preventDirectEvaluation exists so a retry does not paper over a prior rejection. A FAILED step must
// keep going through the normal flow rather than being silently resurrected by a registration.
it('leaves a FAILED step untouched', async () => {
const step = personalStep(ReviewStatus.FAILED);
await run(completeUser([step]));

expect(kycStepRepo.update).not.toHaveBeenCalled();
expect(step.status).toBe(ReviewStatus.FAILED);
expect((service as any).updateProgress).not.toHaveBeenCalled();
});

// When Sumsub reports PROBLEMATIC_APPLICANT_DATA, restartStep FAILS the completed step and opens a fresh
// IN_PROGRESS one so the user can correct data that is present but wrong. isDataComplete is a non-null
// check and cannot see that, so without the failed-step guard the retry would be auto-completed with the
// same rejected data and the user stranded on IDENT instead of the correction step.
it('leaves a step re-opened by a rejection alone (FAILED + fresh IN_PROGRESS chain)', async () => {
const failed = personalStep(ReviewStatus.FAILED, 0);
const reopened = personalStep(ReviewStatus.IN_PROGRESS, 1);
await run(completeUser([failed, reopened]));

expect(kycStepRepo.update).not.toHaveBeenCalled();
expect(reopened.status).toBe(ReviewStatus.IN_PROGRESS);
expect((service as any).updateProgress).not.toHaveBeenCalled();
});

// A rejection the user has since remedied ends in a step that COMPLETED and was later cancelled by
// initiateStep. cancel() leaves `result` in place, so that row still carries the proof it was satisfied.
it('still completes when an older rejection was remedied before the step was re-opened', async () => {
const failed = personalStep(ReviewStatus.FAILED, 0);
const remedied = personalStep(ReviewStatus.CANCELED, 1, '{"firstname":"Erika"}');
const pending = personalStep(ReviewStatus.IN_PROGRESS, 2);
await run(completeUser([failed, remedied, pending]));

expect(kycStepRepo.update).toHaveBeenCalledTimes(1);
expect(pending.status).toBe(ReviewStatus.COMPLETED);
});

// The negative twin. initiateStep also cancels a merely PENDING step, so a CANCELED row with no result is
// an untouched retry, not a remediation — the FAILED step behind it must still block.
it('leaves the chain alone when the cancelled step never completed (no result)', async () => {
const failed = personalStep(ReviewStatus.FAILED, 0);
const untouched = personalStep(ReviewStatus.CANCELED, 1);
const pending = personalStep(ReviewStatus.IN_PROGRESS, 2);
await run(completeUser([failed, untouched, pending]));

expect(kycStepRepo.update).not.toHaveBeenCalled();
expect(pending.status).toBe(ReviewStatus.IN_PROGRESS);
});

// Legacy merged-in accounts can carry two IN_PROGRESS steps, the merged-in one at a negative sequence.
// Closing that dead step would leave the live one open and the account still wedged.
it('closes the live pending step and ignores a merged-in one', async () => {
const merged = personalStep(ReviewStatus.IN_PROGRESS, -102);
const live = personalStep(ReviewStatus.IN_PROGRESS, 0);
await run(completeUser([merged, live]));

expect(live.status).toBe(ReviewStatus.COMPLETED);
expect(merged.status).toBe(ReviewStatus.IN_PROGRESS);
});

// Merged-in rows are history, not the account's own chain: a merge seeds them 100 below the floor, and
// batches are ordered chronologically only WITHIN a batch. Left in scope they would both vote on the
// verdict and be eligible for closing — completing a dead row while the account has no live step at all.
it('does nothing when the only pending step is merged-in', async () => {
const mergedPending = personalStep(ReviewStatus.IN_PROGRESS, -100);
await run(completeUser([mergedPending]));

expect(kycStepRepo.update).not.toHaveBeenCalled();
expect(mergedPending.status).toBe(ReviewStatus.IN_PROGRESS);
});

// The shape carried by a number of merged prod accounts: the live chain is already COMPLETED at sequence 0
// and only a merged-in leftover is still pending. Closing that dead row would stamp a COMPLETED verdict and
// a step log onto history inherited from a merged-away account, and leave two COMPLETED rows behind.
it('does nothing when only a merged-in step is pending and the live step is already completed', async () => {
const mergedPending = personalStep(ReviewStatus.IN_PROGRESS, -102);
const live = personalStep(ReviewStatus.COMPLETED, 0, '{"firstname":"Erika"}');
await run(completeUser([mergedPending, live]));

expect(kycStepRepo.update).not.toHaveBeenCalled();
expect(mergedPending.status).toBe(ReviewStatus.IN_PROGRESS);
});

// restartStep calls fail(undefined, …) and setResult(undefined) keeps the existing value, so a
// completed-then-restarted row still carries a stale result. Cancelling it afterwards must not read as a
// clean completion — the RESTARTED_STEP marker survives both writes and says the outcome was withdrawn.
it('leaves the chain alone when a restarted step was later cancelled but kept its stale result', async () => {
const withdrawn = personalStep(
ReviewStatus.CANCELED,
0,
'{"firstname":"Erika"}',
'PersonalDataNotMatching;RestartedStep',
);
const pending = personalStep(ReviewStatus.IN_PROGRESS, 1);
await run(completeUser([withdrawn, pending]));

expect(kycStepRepo.update).not.toHaveBeenCalled();
expect(pending.status).toBe(ReviewStatus.IN_PROGRESS);
});

it('leaves an already completed step untouched', async () => {
const step = personalStep(ReviewStatus.COMPLETED);
await run(completeUser([step]));

expect(kycStepRepo.update).not.toHaveBeenCalled();
});

it('does nothing when the account data is incomplete', async () => {
const step = personalStep(ReviewStatus.IN_PROGRESS);
await run(completeUser([step], { surname: undefined }));

expect(kycStepRepo.update).not.toHaveBeenCalled();
expect(step.status).toBe(ReviewStatus.IN_PROGRESS);
});

it('does nothing when there is no PersonalData step', async () => {
await run(completeUser([]));

expect(kycStepRepo.update).not.toHaveBeenCalled();
expect((service as any).updateProgress).not.toHaveBeenCalled();
});
});
55 changes: 53 additions & 2 deletions src/subdomains/generic/kyc/services/kyc.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,58 @@ export class KycService {
return KycStepMapper.toStepBase(kycStep);
}

/**
* Closes a PERSONAL_DATA step whose data the account already carries, for flows that write personal data
* outside the KYC step machinery (RealUnit registration). Without this the step stays IN_PROGRESS forever:
* the auto-completion in `initiateStep` is gated on `!preventDirectEvaluation`, which any prior step row sets,
* so an account that once abandoned the step can never satisfy it again — and `KycInfoMapper` keeps handing
* that stale step back as `currentStep`.
*
* Only ever closes a step that was abandoned, never one that was RE-OPENED. `isDataComplete` is a non-null
* check, not a validity check, so it cannot tell the two apart on its own: when Sumsub reports
* PROBLEMATIC_APPLICANT_DATA, `restartStep` FAILS the completed step and opens a fresh IN_PROGRESS one so the
* user can correct data that is present but wrong. Auto-completing that retry with the same unchanged data
* would skip the correction step and strand the user on IDENT instead. The rejection marker lives on the
* failed row, not on the pending one, so the pending lookup alone is no protection.
*
* The verdict comes from the most recent SETTLED step, not from the whole history: a rejection the user has
* since remedied ends in a completed step, and that account must stay eligible.
*
* CANCELED alone cannot stand for "remedied": `initiateStep` cancels the previous COMPLETED step in its
* PERSONAL_DATA branch, but its generic pending-step cancel also fires on a merely IN_PROGRESS one, so both a
* remediation and an untouched retry end up CANCELED. `KycStep.hasSettledVerdict` separates them — a
* cancellation only counts once the step had completed, which `result` records durably — and
* `KycStep.isRejected` reads the verdict itself, so a completion later revoked by a restart still blocks
* even after its status has moved past FAILED.
*/
async completeSatisfiedPersonalDataStep(userData: UserData): Promise<void> {
// The caller's UserData is loaded for its own flow and need not carry `kycSteps`; reload so the step
// lookup never reads an undefined relation (same pattern as checkDfxApproval).
const user = await this.userDataService.getUserData(userData.id, { kycSteps: true });

// The account's OWN chain only. A merge seeds the slave's rows 100 below the floor, so merged-in history
// is always negative while own rows start at 0 — and merge batches are ordered chronologically only within
// a batch, so ranking across them would let an older batch outrank a newer rejection.
const steps = user.getStepsWith(KycStepName.PERSONAL_DATA).filter((s) => s.sequenceNumber >= 0);

const lastSettled = Util.maxObj(
steps.filter((s) => s.hasSettledVerdict),
'sequenceNumber',
);
if (lastSettled?.isRejected) return;

const kycStep = Util.maxObj(
steps.filter((s) => s.isInProgress),
'sequenceNumber',
);
if (!kycStep || !user.isDataComplete) return;

await this.kycStepRepo.update(...kycStep.complete(user.kycFieldData));
await this.createStepLog(user, kycStep);

await this.updateProgress(user, false);
}

async updateKycStep(
kycHash: string,
stepId: number,
Expand Down Expand Up @@ -1358,8 +1410,7 @@ export class KycService {
const completedStep = user.getStepsWith(KycStepName.PERSONAL_DATA).find((s) => s.isCompleted);
if (completedStep) await this.kycStepRepo.update(...completedStep.cancel());

const result = user.requiredKycFields.reduce((prev, curr) => ({ ...prev, [curr]: user[curr] }), {});
if (user.isDataComplete && !preventDirectEvaluation) kycStep.complete(result);
if (user.isDataComplete && !preventDirectEvaluation) kycStep.complete(user.kycFieldData);
break;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,11 @@ export class UserData extends IEntity {
return this.requiredKycFields.every((f) => this[f]);
}

// The requiredKycFields projection a completed PERSONAL_DATA step stores as its result.
get kycFieldData(): Record<string, unknown> {
return this.requiredKycFields.reduce((prev, curr) => ({ ...prev, [curr]: this[curr] }), {});
}

get requiredInvoiceFields(): string[] {
return ['accountType'].concat(this.isPersonalAccount ? ['firstname', 'surname'] : ['organizationName']);
}
Expand Down
Loading