Skip to content
Closed
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
10 changes: 5 additions & 5 deletions src/subdomains/generic/kyc/controllers/kyc.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import { Start2faDto } from '../dto/input/start-2fa.dto';
import { Verify2faDto } from '../dto/input/verify-2fa.dto';
import { FileType, KycFileDataDto } from '../dto/kyc-file.dto';
import { KycFinancialOutData } from '../dto/output/kyc-financial-out.dto';
import { KycLevelDto, KycSessionDto, KycStepBase } from '../dto/output/kyc-info.dto';
import { KycLevelDto, KycSessionDto, KycStepBase, KycStepSubmitDto } from '../dto/output/kyc-info.dto';
import { MergedDto } from '../dto/output/kyc-merged.dto';
import { Setup2faDto } from '../dto/output/setup-2fa.dto';
import { SumSubWebhookResult } from '../dto/sum-sub.dto';
Expand Down Expand Up @@ -204,13 +204,13 @@ export class KycController {
}

@Put('data/personal/:id')
@ApiOkResponse({ type: KycStepBase })
@ApiOkResponse({ type: KycStepSubmitDto })
@ApiUnauthorizedResponse(MergedResponse)
async updatePersonalData(
@Headers(CodeHeaderName) code: string,
@Param('id') id: string,
@Body() data: KycPersonalData,
): Promise<KycStepBase> {
): Promise<KycStepSubmitDto> {
return this.kycService.updatePersonalData(code, +id, data);
}

Expand Down Expand Up @@ -438,15 +438,15 @@ export class KycController {
}

@Put('data/financial/:id')
@ApiOkResponse({ type: KycStepBase })
@ApiOkResponse({ type: KycStepSubmitDto })
@ApiUnauthorizedResponse(MergedResponse)
@ApiForbiddenResponse(TfaResponse)
async updateFinancialData(
@Headers(CodeHeaderName) code: string,
@RealIP() ip: string,
@Param('id') id: string,
@Body() data: KycFinancialInData,
): Promise<KycStepBase> {
): Promise<KycStepSubmitDto> {
return this.kycService.updateFinancialData(code, ip, +id, data);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { DfxLogger } from 'src/shared/services/dfx-logger';
import { toPersonalDataMissingFields } from '../personal-data-missing-fields';

describe('toPersonalDataMissingFields', () => {
it('maps entity names to KycPersonalData request paths', () => {
expect(
toPersonalDataMissingFields([
'firstname',
'surname',
'street',
'location',
'zip',
'country',
'organizationStreet',
'organizationLocation',
'organizationZip',
'organizationCountry',
'organizationName',
'phone',
'accountType',
]),
).toEqual([
'firstName',
'lastName',
'address.street',
'address.city',
'address.zip',
'address.country',
'organizationAddress.street',
'organizationAddress.city',
'organizationAddress.zip',
'organizationAddress.country',
'organizationName',
'phone',
'accountType',
]);
});

it('omits mail as a known non-reportable field without treating it as unknown', () => {
const errorSpy = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation();

expect(toPersonalDataMissingFields(['mail', 'firstname', 'phone'])).toEqual(['firstName', 'phone']);
expect(errorSpy).not.toHaveBeenCalled();

errorSpy.mockRestore();
});

it('logs unknown entity fields instead of silently dropping them', () => {
const errorSpy = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation();

expect(toPersonalDataMissingFields(['notARealField', 'phone'])).toEqual(['phone']);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('notARealField'));

errorSpy.mockRestore();
});
});
11 changes: 11 additions & 0 deletions src/subdomains/generic/kyc/dto/mapper/kyc-step.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
KycStepDto,
KycStepReason,
KycStepSessionDto,
KycStepSubmitDto,
} from '../output/kyc-info.dto';

export class KycStepMapper {
Expand All @@ -30,6 +31,16 @@ export class KycStepMapper {
return Object.assign(new KycStepSessionDto(), dto);
}

static toStepSubmit(kycStep: KycStep, complete: boolean, missingFields: string[]): KycStepSubmitDto {
const dto: KycStepSubmitDto = {
...KycStepMapper.toStepBase(kycStep),
complete,
missingFields,
};

return Object.assign(new KycStepSubmitDto(), dto);
}

static toStepBase(kycStep: KycStep): KycStepBase {
return {
name: kycStep.name,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { DfxLogger } from 'src/shared/services/dfx-logger';
import { RequiredKycField } from 'src/subdomains/generic/user/models/user-data/user-data.entity';

const logger = new DfxLogger('PersonalDataMissingFields');

/**
* Maps UserData entity field names to KycPersonalData request paths for submit feedback.
* `string` = request path; `null` = known required field that is not reportable here
* (e.g. mail is owned by the ContactData step).
*
* Typed as Record over RequiredKycField so adding a field to requiredKycFields breaks the build
* until a mapping entry is added.
*/
const PERSONAL_DATA_ENTITY_TO_REQUEST: Record<RequiredKycField, string | null> = {
accountType: 'accountType',
mail: null,
phone: 'phone',
firstname: 'firstName',
surname: 'lastName',
street: 'address.street',
location: 'address.city',
zip: 'address.zip',
country: 'address.country',
organizationName: 'organizationName',
organizationStreet: 'organizationAddress.street',
organizationLocation: 'organizationAddress.city',
organizationZip: 'organizationAddress.zip',
organizationCountry: 'organizationAddress.country',
};

function isRequiredKycField(field: string): field is RequiredKycField {
return Object.prototype.hasOwnProperty.call(PERSONAL_DATA_ENTITY_TO_REQUEST, field);
}

export function toPersonalDataMissingFields(entityFields: readonly string[]): string[] {
const paths: string[] = [];

for (const field of entityFields) {
if (!isRequiredKycField(field)) {
logger.error(`Unknown KYC field in personal-data completeness mapping: ${field}`);
continue;
}

const path = PERSONAL_DATA_ENTITY_TO_REQUEST[field];
// null = known but not reportable on this endpoint (e.g. mail → ContactData)
if (path != null) paths.push(path);
}

return paths;
}
16 changes: 16 additions & 0 deletions src/subdomains/generic/kyc/dto/output/kyc-financial-out.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ export class KycFinancialOption {
text: string;
}

export class KycFinancialCondition {
@ApiProperty({ description: 'Question key this condition depends on' })
question: string;

@ApiProperty({ description: 'Response value that makes the dependent question applicable' })
response: string;
}

export class KycFinancialQuestion {
@ApiProperty({ description: 'Question key' })
key: string;
Expand All @@ -25,6 +33,14 @@ export class KycFinancialQuestion {

@ApiPropertyOptional({ description: 'Response options', type: KycFinancialOption, isArray: true })
options?: KycFinancialOption[];

@ApiPropertyOptional({
description:
'Conditions under which this question applies. If set, the question is only required when at least one condition matches the given responses.',
type: KycFinancialCondition,
isArray: true,
})
conditions?: KycFinancialCondition[];
}

export class KycFinancialOutData extends KycFinancialInData {
Expand Down
16 changes: 16 additions & 0 deletions src/subdomains/generic/kyc/dto/output/kyc-info.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ export class KycStepBase {
sequenceNumber: number;
}

export class KycStepSubmitDto extends KycStepBase {
@ApiProperty({
description:
'Whether the submission fulfills all required fields and advanced the step (e.g. completed or internal review). Draft data is still stored when false.',
})
complete: boolean;

@ApiProperty({
description:
'Request field paths still missing when complete is false; empty when complete is true. Financial: unanswered applicable question keys. Personal data: KycPersonalData paths (e.g. firstName, address.street, organizationAddress.city). The list may be empty while complete is false when the blocking field belongs to another KYC step (e.g. mail via ContactData).',
type: String,
isArray: true,
})
missingFields: string[];
}

export class KycStepDto extends KycStepBase {
@ApiProperty()
isCurrent: boolean;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { KycStepName } from '../../enums/kyc-step-name.enum';
import { ReviewStatus } from '../../enums/review-status.enum';
import { KycStep } from '../kyc-step.entity';

describe('KycStep', () => {
describe('update', () => {
it('does not clear status or sequenceNumber when they are omitted (undefined)', () => {
const step = Object.assign(new KycStep(), {
id: 1,
name: KycStepName.FINANCIAL_DATA,
status: ReviewStatus.IN_PROGRESS,
sequenceNumber: 7,
});

const [, update] = step.update(undefined, [{ key: 'tnc', value: 'accept' }]);

expect(step.status).toBe(ReviewStatus.IN_PROGRESS);
expect(step.sequenceNumber).toBe(7);
expect(step.getResult()).toEqual([{ key: 'tnc', value: 'accept' }]);
expect(update).not.toHaveProperty('status');
expect(update).not.toHaveProperty('sequenceNumber');
expect(update).toHaveProperty('result');
});

it('still applies an explicit status change', () => {
const step = Object.assign(new KycStep(), {
id: 1,
name: KycStepName.FINANCIAL_DATA,
status: ReviewStatus.IN_PROGRESS,
sequenceNumber: 2,
});

step.update(ReviewStatus.OUTDATED, undefined, undefined, 3);

expect(step.status).toBe(ReviewStatus.OUTDATED);
expect(step.sequenceNumber).toBe(3);
});
});
});
16 changes: 10 additions & 6 deletions src/subdomains/generic/kyc/entities/kyc-step.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,16 @@ export class KycStep extends IEntity {
comment?: string,
sequenceNumber?: number,
): UpdateResult<KycStep> {
const update: Partial<KycStep> = {
status,
result: this.setResult(result),
comment: this.addComment(comment),
sequenceNumber,
};
// Drop undefined so callers can omit status/sequenceNumber without wiping in-memory or DB values
// (Object.assign copies undefined; TypeORM skips it on write, the entity would still lose the field).
const update = Object.fromEntries(
Object.entries({
status,
result: this.setResult(result),
comment: this.addComment(comment),
sequenceNumber,
}).filter(([, v]) => v !== undefined),
) as Partial<KycStep>;

Object.assign(this, update);

Expand Down
Loading