diff --git a/src/subdomains/generic/kyc/entities/__tests__/kyc-step.entity.spec.ts b/src/subdomains/generic/kyc/entities/__tests__/kyc-step.entity.spec.ts new file mode 100644 index 0000000000..3f629c9991 --- /dev/null +++ b/src/subdomains/generic/kyc/entities/__tests__/kyc-step.entity.spec.ts @@ -0,0 +1,44 @@ +import { KycStep } from '../kyc-step.entity'; +import { KycStepName } from '../../enums/kyc-step-name.enum'; +import { ReviewStatus } from '../../enums/review-status.enum'; + +describe('KycStep.update', () => { + const buildStep = (overrides: Partial = {}): KycStep => + Object.assign(new KycStep(), { + id: 1, + name: KycStepName.FINANCIAL_DATA, + status: ReviewStatus.IN_PROGRESS, + sequenceNumber: 5, + ...overrides, + }); + + it('leaves status and sequenceNumber unchanged when they are omitted, and omits both from the returned partial', () => { + const step = buildStep(); + + const [, update] = step.update(undefined, [{ key: 'income', value: 'salary' }]); + + expect(step.status).toBe(ReviewStatus.IN_PROGRESS); + expect(step.sequenceNumber).toBe(5); + expect(update).not.toHaveProperty('status'); + expect(update).not.toHaveProperty('sequenceNumber'); + }); + + it('applies an explicit status change and includes it in the returned partial', () => { + const step = buildStep(); + + const [, update] = step.update(ReviewStatus.INTERNAL_REVIEW, [{ key: 'income', value: 'salary' }]); + + expect(step.status).toBe(ReviewStatus.INTERNAL_REVIEW); + expect(update.status).toBe(ReviewStatus.INTERNAL_REVIEW); + }); + + // 0 is a real production value (getNextSequenceNumber floors at 0); a truthy filter would drop it. + it('applies sequenceNumber 0 to the entity and includes it in the returned partial', () => { + const step = buildStep({ sequenceNumber: 5 }); + + const [, update] = step.update(ReviewStatus.INTERNAL_REVIEW, undefined, undefined, 0); + + expect(step.sequenceNumber).toBe(0); + expect(update.sequenceNumber).toBe(0); + }); +}); diff --git a/src/subdomains/generic/kyc/entities/kyc-step.entity.ts b/src/subdomains/generic/kyc/entities/kyc-step.entity.ts index 8f96031946..aa4f01f99f 100644 --- a/src/subdomains/generic/kyc/entities/kyc-step.entity.ts +++ b/src/subdomains/generic/kyc/entities/kyc-step.entity.ts @@ -214,17 +214,20 @@ export class KycStep extends IEntity { } update( - status: ReviewStatus, + status?: ReviewStatus, result?: KycStepResult, comment?: string, sequenceNumber?: number, ): UpdateResult { - const update: Partial = { - status, - result: this.setResult(result), - comment: this.addComment(comment), - sequenceNumber, - }; + // Callers pass undefined to leave a field unchanged; Object.assign would overwrite those properties. + const update = Object.fromEntries( + Object.entries({ + status, + result: this.setResult(result), + comment: this.addComment(comment), + sequenceNumber, + }).filter(([, v]) => v !== undefined), + ) as Partial; Object.assign(this, update); @@ -365,7 +368,7 @@ export class KycStep extends IEntity { return this.result; } - addComment(comment: string): string | undefined { + addComment(comment: string): string { return [this.comment, comment].filter((c) => c).join(';'); } diff --git a/src/subdomains/generic/kyc/services/__tests__/kyc.service.spec.ts b/src/subdomains/generic/kyc/services/__tests__/kyc.service.spec.ts index 6fd243214d..b209489ee9 100644 --- a/src/subdomains/generic/kyc/services/__tests__/kyc.service.spec.ts +++ b/src/subdomains/generic/kyc/services/__tests__/kyc.service.spec.ts @@ -12,6 +12,7 @@ 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 { createCustomUserData } from '../../../user/models/user-data/__mocks__/user-data.entity.mock'; +import { AccountType } from '../../../user/models/user-data/account-type.enum'; import { UserData } from '../../../user/models/user-data/user-data.entity'; import { RiskStatus, UserDataStatus } from '../../../user/models/user-data/user-data.enum'; import { UserDataService } from '../../../user/models/user-data/user-data.service'; @@ -19,6 +20,7 @@ import { UserStatus } from '../../../user/models/user/user.enum'; import { IdentDocument } from '../../dto/ident.dto'; import { KycError } from '../../dto/kyc-error.enum'; import { FileSubType, FileType, KycFileBlob } from '../../dto/kyc-file.dto'; +import { KycStepStatus } from '../../dto/output/kyc-info.dto'; import { SumSubLevelName } from '../../dto/sum-sub.dto'; import { KycFile } from '../../entities/kyc-file.entity'; import { KycStep } from '../../entities/kyc-step.entity'; @@ -853,3 +855,79 @@ describe('KycService checkDfxApproval step promotion', () => { expect(kycStepRepo.update).not.toHaveBeenCalled(); }); }); + +// updateFinancialData / updateFileData call KycStep.update with omitted status or sequenceNumber; +// the in-memory step (and thus the mapped response) must keep those fields. +describe('KycService updateFinancialData incomplete draft', () => { + let service: KycService; + let kycStepRepo: jest.Mocked; + + beforeEach(() => { + kycStepRepo = createMock(); + service = Object.create(KycService.prototype); + (service as any).kycStepRepo = kycStepRepo; + }); + + it('returns status InProgress and the existing sequenceNumber, and keeps the step status', async () => { + const kycStep = Object.assign(new KycStep(), { + id: 11, + name: KycStepName.FINANCIAL_DATA, + status: ReviewStatus.IN_PROGRESS, + sequenceNumber: 3, + }); + const user = createMock({ accountType: AccountType.PERSONAL }); + user.getPendingStepOrThrow.mockReturnValue(kycStep); + jest.spyOn(service as any, 'getUser').mockResolvedValue(user); + jest.spyOn(service as any, 'verify2fa').mockResolvedValue(undefined); + jest.spyOn(service as any, 'updateProgress').mockResolvedValue(user); + + const response = await service.updateFinancialData('hash', '1.2.3.4', 11, { responses: [] }); + + expect(response.status).toBe(KycStepStatus.IN_PROGRESS); + expect(response.sequenceNumber).toBe(3); + expect(kycStep.status).toBe(ReviewStatus.IN_PROGRESS); + expect(kycStep.sequenceNumber).toBe(3); + }); +}); + +describe('KycService updateFileData sequenceNumber', () => { + let service: KycService; + let kycStepRepo: jest.Mocked; + let documentService: jest.Mocked; + + beforeEach(() => { + kycStepRepo = createMock(); + documentService = createMock(); + service = Object.create(KycService.prototype); + (service as any).kycStepRepo = kycStepRepo; + (service as any).documentService = documentService; + }); + + it('returns the step sequenceNumber unchanged after a file upload', async () => { + const kycStep = Object.assign(new KycStep(), { + id: 22, + name: KycStepName.ADDITIONAL_DOCUMENTS, + status: ReviewStatus.IN_PROGRESS, + sequenceNumber: 7, + userData: { kycLevel: 50 }, + }); + const user = createMock(); + user.getPendingStepOrThrow.mockReturnValue(kycStep); + jest.spyOn(service as any, 'getUser').mockResolvedValue(user); + jest.spyOn(service as any, 'createStepLog').mockResolvedValue(undefined); + jest.spyOn(service as any, 'updateProgress').mockResolvedValue(user); + documentService.uploadUserFile.mockResolvedValue({ url: 'https://example.com/file.pdf' } as never); + + const filePayload = ['data:application/pdf;base64', Buffer.from('x').toString('base64')].join(','); + const response = await service.updateFileData( + 'hash', + 22, + KycStepName.ADDITIONAL_DOCUMENTS, + { fileName: 'doc.pdf', file: filePayload }, + FileType.ADDITIONAL_DOCUMENTS, + ); + + expect(response.sequenceNumber).toBe(7); + expect(kycStep.sequenceNumber).toBe(7); + }); +}); diff --git a/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts b/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts index ca5102e599..e2d75290ac 100644 --- a/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts +++ b/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts @@ -47,11 +47,14 @@ import { import { VirtualIban, VirtualIbanStatus } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.entity'; import { VirtualIbanIssuanceIntentStatus } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban-issuance-intent-status.enum'; import { VirtualIbanIssuanceIntent } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban-issuance-intent.entity'; +import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; import { KycStep } from 'src/subdomains/generic/kyc/entities/kyc-step.entity'; import { KycLogType } from 'src/subdomains/generic/kyc/enums/kyc.enum'; import { KycStepName } from 'src/subdomains/generic/kyc/enums/kyc-step-name.enum'; +import { KycStepType } from 'src/subdomains/generic/kyc/enums/kyc.enum'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; import { UserData } from '../user-data.entity'; +import { KycIdentificationType } from '../kyc-identification-type.enum'; import { KycStatus, KycType, UserDataStatus } from '../user-data.enum'; import { UserDataRepository } from '../user-data.repository'; import { @@ -718,6 +721,88 @@ describe('UserDataService', () => { } expect(new Set(assigned.map(([, s]) => s)).size).toBe(assigned.length); }); + + // COMPLETED video ident steps are not in the cancel list, so update() receives status undefined; + // that must not wipe status — a cleared status would be silent data loss on the merge path. + describe.each([KycStepType.VIDEO, KycStepType.SUMSUB_VIDEO])('completed %s slave ident step', (identType) => { + const prepareVideoMerge = async (master: UserData, slave: UserData, videoStep: KycStep): Promise => { + userDataRepo.findOne.mockResolvedValueOnce(master).mockResolvedValueOnce(slave); + transactionService.getAllTransactionsForUserData.mockResolvedValue([]); + userRepo.find.mockResolvedValue([]); + bankDataService.getAllBankDatasForUser.mockResolvedValue([]); + virtualIbanService.getFrickVirtualIbansForAccount.mockResolvedValue([]); + kycAdminService.getKycSteps.mockResolvedValueOnce([]).mockResolvedValueOnce([videoStep]); + documentService.copyFiles.mockResolvedValue(undefined); + jest.spyOn(service, 'updateVolumes').mockResolvedValue(undefined); + jest + .spyOn(service as unknown as { updateBankTxTime: () => Promise }, 'updateBankTxTime') + .mockResolvedValue(undefined); + }; + + const buildCompletedVideoStep = (): KycStep => + Object.assign(new KycStep(), { + id: 1, + name: KycStepName.IDENT, + type: identType, + status: ReviewStatus.COMPLETED, + sequenceNumber: 0, + }); + + // Master empty + slave step at 0 → min floor 0, seed 100 below → first reassigned number is -100. + const expectedMergedSequenceNumber = -100; + + // updateKycStepInternal receives KycStep.update()'s [id, partial]; proves the step entered the renumber loop. + const assignedSequenceFor = (stepId: number): number | undefined => { + const call = kycAdminService.updateKycStepInternal.mock.calls.find((c) => { + const [id] = c[0] as unknown as [number, Partial]; + return id === stepId; + }); + if (!call) return undefined; + const [, update] = call[0] as unknown as [number, Partial]; + return update.sequenceNumber; + }; + + it('keeps the completed video ident step status through the merge', async () => { + const master = buildAccount(1000, 50); + const slave = buildAccount(2000, 20); + const videoStep = buildCompletedVideoStep(); + + await prepareVideoMerge(master, slave, videoStep); + await service.mergeUserData(master.id, slave.id); + + expect(assignedSequenceFor(videoStep.id)).toBe(expectedMergedSequenceNumber); + expect(videoStep.sequenceNumber).toBe(expectedMergedSequenceNumber); + expect(videoStep.status).toBe(ReviewStatus.COMPLETED); + }); + + it('does not derive identificationType or bankTransactionVerification from a completed video ident step', async () => { + const master = buildAccount(1000, 50); + const slave = buildAccount(2000, 20); + const videoStep = buildCompletedVideoStep(); + + await prepareVideoMerge(master, slave, videoStep); + await service.mergeUserData(master.id, slave.id); + + expect(assignedSequenceFor(videoStep.id)).toBe(expectedMergedSequenceNumber); + expect(master.identificationType).toBeUndefined(); + expect(master.bankTransactionVerification).toBeUndefined(); + }); + + it('does not overwrite master identificationType or bankTransactionVerification when already set', async () => { + const master = buildAccount(1000, 50); + master.identificationType = KycIdentificationType.ONLINE_ID; + master.bankTransactionVerification = CheckStatus.PASS; + const slave = buildAccount(2000, 20); + const videoStep = buildCompletedVideoStep(); + + await prepareVideoMerge(master, slave, videoStep); + await service.mergeUserData(master.id, slave.id); + + expect(assignedSequenceFor(videoStep.id)).toBe(expectedMergedSequenceNumber); + expect(master.identificationType).toBe(KycIdentificationType.ONLINE_ID); + expect(master.bankTransactionVerification).toBe(CheckStatus.PASS); + }); + }); }); describe('mergeUserData virtual IBAN reassignment', () => { diff --git a/src/subdomains/generic/user/models/user-data/user-data.service.ts b/src/subdomains/generic/user/models/user-data/user-data.service.ts index 8e4baa54d3..a252d0ca9c 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.service.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.service.ts @@ -68,7 +68,6 @@ import { UserRepository } from '../user/user.repository'; import { AccountType } from './account-type.enum'; import { CreateUserDataDto } from './dto/create-user-data.dto'; import { UpdateUserDataDto } from './dto/update-user-data.dto'; -import { KycIdentificationType } from './kyc-identification-type.enum'; import { UserDataNotificationService } from './user-data-notification.service'; import { UserData } from './user-data.entity'; import { @@ -1562,14 +1561,6 @@ export class UserDataService { master.amlListReactivatedDate = slave.amlListReactivatedDate; master.kycFileId = slave.kycFileId; } - if ( - slave.kycSteps.some( - (k) => (k.type === KycStepType.VIDEO || k.type === KycStepType.SUMSUB_VIDEO) && k.isCompleted, - ) - ) { - master.identificationType = KycIdentificationType.VIDEO_ID; - master.bankTransactionVerification = CheckStatus.UNNECESSARY; - } if (!master.verifiedName && slave.verifiedName) master.verifiedName = slave.verifiedName; const changedMailNotificationMaster = notifyUser && slave.mail && ![slave.mail, mail].includes(master.mail)