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
88 changes: 88 additions & 0 deletions backend/src/migrations/1800420000000-CreateKycDocumentsTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableForeignKey,
TableIndex,
} from 'typeorm';

export class CreateKycDocumentsTable1800420000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'kyc_documents',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'uuid_generate_v4()',
},
{ name: 'userId', type: 'uuid', isNullable: false },
{
name: 'documentType',
type: 'enum',
enum: [
'PASSPORT',
'NATIONAL_ID',
'DRIVERS_LICENSE',
'UTILITY_BILL',
'SELFIE',
],
isNullable: false,
},
{ name: 'encryptedStoragePath', type: 'varchar', isNullable: false },
{ name: 'originalFilename', type: 'varchar', isNullable: false },
{ name: 'mimeType', type: 'varchar', isNullable: false },
{
name: 'status',
type: 'enum',
enum: ['UPLOADED', 'PENDING_REVIEW', 'APPROVED', 'REJECTED'],
default: "'UPLOADED'",
isNullable: false,
},
{ name: 'verificationId', type: 'uuid', isNullable: true },
{ name: 'rejectionReason', type: 'text', isNullable: true },
{ name: 'reviewedBy', type: 'varchar', isNullable: true },
{ name: 'reviewedAt', type: 'timestamp', isNullable: true },
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
],
}),
true,
);

await queryRunner.createForeignKey(
'kyc_documents',
new TableForeignKey({
columnNames: ['userId'],
referencedTableName: 'users',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);

await queryRunner.createForeignKey(
'kyc_documents',
new TableForeignKey({
columnNames: ['verificationId'],
referencedTableName: 'kyc_verifications',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);

await queryRunner.createIndex(
'kyc_documents',
new TableIndex({
name: 'IDX_KYC_DOCUMENTS_USER_ID',
columnNames: ['userId', 'createdAt'],
}),
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('kyc_documents');
}
}
26 changes: 26 additions & 0 deletions backend/src/modules/kyc/dto/kyc-document.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
import {
KycDocumentType,
KycDocumentStatus,
} from '../entities/kyc-document.entity';

export class UploadKycDocumentDto {
@ApiProperty({ enum: KycDocumentType })
@IsEnum(KycDocumentType)
documentType!: KycDocumentType;
}

export class ReviewKycDocumentDto {
@ApiProperty({
enum: [KycDocumentStatus.APPROVED, KycDocumentStatus.REJECTED],
})
@IsIn([KycDocumentStatus.APPROVED, KycDocumentStatus.REJECTED])
status!: KycDocumentStatus.APPROVED | KycDocumentStatus.REJECTED;

@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(1000)
rejectionReason?: string;
}
82 changes: 82 additions & 0 deletions backend/src/modules/kyc/entities/kyc-document.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { KycVerification } from './kyc-verification.entity';

export enum KycDocumentType {
PASSPORT = 'PASSPORT',
NATIONAL_ID = 'NATIONAL_ID',
DRIVERS_LICENSE = 'DRIVERS_LICENSE',
UTILITY_BILL = 'UTILITY_BILL',
SELFIE = 'SELFIE',
}

export enum KycDocumentStatus {
UPLOADED = 'UPLOADED',
PENDING_REVIEW = 'PENDING_REVIEW',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}

@Entity('kyc_documents')
@Index(['userId', 'createdAt'])
export class KycDocument {
@PrimaryGeneratedColumn('uuid')
id!: string;

@Column('uuid')
userId!: string;

@Column({ type: 'enum', enum: KycDocumentType })
documentType!: KycDocumentType;

@Column({ type: 'varchar' })
encryptedStoragePath!: string;

@Column({ type: 'varchar' })
originalFilename!: string;

@Column({ type: 'varchar' })
mimeType!: string;

@Column({
type: 'enum',
enum: KycDocumentStatus,
default: KycDocumentStatus.UPLOADED,
})
status!: KycDocumentStatus;

@Column('uuid', { nullable: true })
verificationId!: string | null;

@Column({ type: 'text', nullable: true })
rejectionReason!: string | null;

@Column({ type: 'varchar', nullable: true })
reviewedBy!: string | null;

@Column({ type: 'timestamp', nullable: true })
reviewedAt!: Date | null;

@CreateDateColumn()
createdAt!: Date;

@UpdateDateColumn()
updatedAt!: Date;

@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user!: User;

@ManyToOne(() => KycVerification, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'verificationId' })
verification!: KycVerification | null;
}
107 changes: 107 additions & 0 deletions backend/src/modules/kyc/kyc-document.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { KycDocumentService } from './kyc-document.service';
import {
KycDocument,
KycDocumentType,
KycDocumentStatus,
} from './entities/kyc-document.entity';
import { KycVerification } from './entities/kyc-verification.entity';
import { User } from '../user/entities/user.entity';
import { PiiEncryptionService } from '../../common/services/pii-encryption.service';

const mockRepo = () => ({
create: jest.fn((v) => v),
save: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
update: jest.fn(),
});

describe('KycDocumentService', () => {
let service: KycDocumentService;
let docRepo: ReturnType<typeof mockRepo>;
let userRepo: ReturnType<typeof mockRepo>;

beforeEach(async () => {
docRepo = mockRepo();
userRepo = mockRepo();

const module: TestingModule = await Test.createTestingModule({
providers: [
KycDocumentService,
{ provide: getRepositoryToken(KycDocument), useValue: docRepo },
{ provide: getRepositoryToken(KycVerification), useValue: mockRepo() },
{ provide: getRepositoryToken(User), useValue: userRepo },
{
provide: PiiEncryptionService,
useValue: { encrypt: jest.fn(() => 'encrypted-data') },
},
],
}).compile();

service = module.get<KycDocumentService>(KycDocumentService);
});

describe('uploadDocument', () => {
it('rejects invalid mime type for SELFIE', async () => {
await expect(
service.uploadDocument('u1', KycDocumentType.SELFIE, {
buffer: Buffer.from('test'),
originalname: 'doc.pdf',
mimetype: 'application/pdf',
size: 100,
}),
).rejects.toThrow(BadRequestException);
});

it('saves document with PENDING_REVIEW status', async () => {
docRepo.save.mockImplementation((d) =>
Promise.resolve({ id: 'doc-1', ...d }),
);

const result = await service.uploadDocument(
'u1',
KycDocumentType.PASSPORT,
{
buffer: Buffer.from('test'),
originalname: 'passport.jpg',
mimetype: 'image/jpeg',
size: 1024,
},
);

expect(result.status).toBe(KycDocumentStatus.PENDING_REVIEW);
expect(userRepo.update).toHaveBeenCalledWith('u1', {
kycStatus: 'PENDING',
});
});
});

describe('reviewDocument', () => {
it('approves pending document', async () => {
docRepo.findOne.mockResolvedValue({
id: 'doc-1',
userId: 'u1',
status: KycDocumentStatus.PENDING_REVIEW,
});
docRepo.save.mockImplementation((d) => Promise.resolve(d));

const result = await service.reviewDocument('doc-1', 'admin-1', {
status: KycDocumentStatus.APPROVED,
});

expect(result.status).toBe(KycDocumentStatus.APPROVED);
});

it('throws when document not found', async () => {
docRepo.findOne.mockResolvedValue(null);
await expect(
service.reviewDocument('bad', 'admin', {
status: KycDocumentStatus.APPROVED,
}),
).rejects.toThrow(NotFoundException);
});
});
});
Loading
Loading