diff --git a/backend/src/migrations/1800420000000-CreateKycDocumentsTable.ts b/backend/src/migrations/1800420000000-CreateKycDocumentsTable.ts new file mode 100644 index 000000000..09ce40759 --- /dev/null +++ b/backend/src/migrations/1800420000000-CreateKycDocumentsTable.ts @@ -0,0 +1,88 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, +} from 'typeorm'; + +export class CreateKycDocumentsTable1800420000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.dropTable('kyc_documents'); + } +} diff --git a/backend/src/modules/kyc/dto/kyc-document.dto.ts b/backend/src/modules/kyc/dto/kyc-document.dto.ts new file mode 100644 index 000000000..515b3a80e --- /dev/null +++ b/backend/src/modules/kyc/dto/kyc-document.dto.ts @@ -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; +} diff --git a/backend/src/modules/kyc/entities/kyc-document.entity.ts b/backend/src/modules/kyc/entities/kyc-document.entity.ts new file mode 100644 index 000000000..f625f9556 --- /dev/null +++ b/backend/src/modules/kyc/entities/kyc-document.entity.ts @@ -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; +} diff --git a/backend/src/modules/kyc/kyc-document.service.spec.ts b/backend/src/modules/kyc/kyc-document.service.spec.ts new file mode 100644 index 000000000..2ea52d867 --- /dev/null +++ b/backend/src/modules/kyc/kyc-document.service.spec.ts @@ -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; + let userRepo: ReturnType; + + 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); + }); + + 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); + }); + }); +}); diff --git a/backend/src/modules/kyc/kyc-document.service.ts b/backend/src/modules/kyc/kyc-document.service.ts new file mode 100644 index 000000000..db68cebac --- /dev/null +++ b/backend/src/modules/kyc/kyc-document.service.ts @@ -0,0 +1,200 @@ +import { + Injectable, + BadRequestException, + NotFoundException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; +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'; +import { ReviewKycDocumentDto } from './dto/kyc-document.dto'; + +const ALLOWED_MIME_TYPES: Record = { + [KycDocumentType.PASSPORT]: ['application/pdf', 'image/jpeg', 'image/png'], + [KycDocumentType.NATIONAL_ID]: ['application/pdf', 'image/jpeg', 'image/png'], + [KycDocumentType.DRIVERS_LICENSE]: [ + 'application/pdf', + 'image/jpeg', + 'image/png', + ], + [KycDocumentType.UTILITY_BILL]: [ + 'application/pdf', + 'image/jpeg', + 'image/png', + ], + [KycDocumentType.SELFIE]: ['image/jpeg', 'image/png', 'image/webp'], +}; + +const MAX_FILE_SIZE = 10 * 1024 * 1024; + +@Injectable() +export class KycDocumentService { + private readonly logger = new Logger(KycDocumentService.name); + private readonly storageDir = './uploads/kyc-encrypted'; + + constructor( + @InjectRepository(KycDocument) + private readonly documentRepo: Repository, + @InjectRepository(KycVerification) + private readonly verificationRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + private readonly piiEncryption: PiiEncryptionService, + ) { + this.ensureStorageDir(); + } + + async uploadDocument( + userId: string, + documentType: KycDocumentType, + file: { + buffer: Buffer; + originalname: string; + mimetype: string; + size: number; + }, + ): Promise { + this.validateFile(documentType, file); + + const encryptedPayload = this.piiEncryption.encrypt( + file.buffer.toString('base64'), + ); + const storageFilename = `${randomUUID()}.enc`; + const storagePath = join(this.storageDir, storageFilename); + writeFileSync(storagePath, encryptedPayload, 'utf8'); + + const document = this.documentRepo.create({ + userId, + documentType, + encryptedStoragePath: storagePath, + originalFilename: file.originalname, + mimeType: file.mimetype, + status: KycDocumentStatus.PENDING_REVIEW, + }); + + const saved = await this.documentRepo.save(document); + + await this.userRepo.update(userId, { kycStatus: 'PENDING' }); + + this.logger.log(`KYC document uploaded for user ${userId}: ${saved.id}`); + return saved; + } + + async listUserDocuments(userId: string): Promise { + return this.documentRepo.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + } + + async listPendingReview(): Promise { + return this.documentRepo.find({ + where: { status: KycDocumentStatus.PENDING_REVIEW }, + relations: ['user'], + order: { createdAt: 'ASC' }, + }); + } + + async getDocument(id: string): Promise { + const doc = await this.documentRepo.findOne({ + where: { id }, + relations: ['user', 'verification'], + }); + if (!doc) { + throw new NotFoundException(`KYC document ${id} not found`); + } + return doc; + } + + async reviewDocument( + id: string, + adminId: string, + dto: ReviewKycDocumentDto, + ): Promise { + const doc = await this.getDocument(id); + + if (doc.status !== KycDocumentStatus.PENDING_REVIEW) { + throw new BadRequestException('Document is not pending review'); + } + + if ( + dto.status === KycDocumentStatus.REJECTED && + !dto.rejectionReason?.trim() + ) { + throw new BadRequestException( + 'Rejection reason is required when rejecting a document', + ); + } + + doc.status = dto.status; + doc.reviewedBy = adminId; + doc.reviewedAt = new Date(); + doc.rejectionReason = + dto.status === KycDocumentStatus.REJECTED + ? (dto.rejectionReason ?? null) + : null; + + const saved = await this.documentRepo.save(doc); + + const isApproved = dto.status === KycDocumentStatus.APPROVED; + await this.userRepo.update(doc.userId, { + kycStatus: isApproved ? 'APPROVED' : 'REJECTED', + kycRejectionReason: isApproved ? undefined : dto.rejectionReason, + tier: isApproved ? 'VERIFIED' : 'FREE', + }); + + return saved; + } + + async linkToVerification( + documentId: string, + verificationId: string, + userId: string, + ): Promise { + const doc = await this.getDocument(documentId); + if (doc.userId !== userId) { + throw new BadRequestException('Document does not belong to user'); + } + + const verification = await this.verificationRepo.findOne({ + where: { id: verificationId, userId }, + }); + if (!verification) { + throw new NotFoundException('KYC verification not found'); + } + + doc.verificationId = verificationId; + return this.documentRepo.save(doc); + } + + private validateFile( + documentType: KycDocumentType, + file: { mimetype: string; size: number }, + ): void { + const allowed = ALLOWED_MIME_TYPES[documentType]; + if (!allowed.includes(file.mimetype)) { + throw new BadRequestException( + `Invalid file type for ${documentType}. Allowed: ${allowed.join(', ')}`, + ); + } + if (file.size > MAX_FILE_SIZE) { + throw new BadRequestException('File exceeds maximum size of 10MB'); + } + } + + private ensureStorageDir(): void { + if (!existsSync(this.storageDir)) { + mkdirSync(this.storageDir, { recursive: true }); + } + } +} diff --git a/backend/src/modules/kyc/kyc.controller.ts b/backend/src/modules/kyc/kyc.controller.ts index 156baaff1..e2576777c 100644 --- a/backend/src/modules/kyc/kyc.controller.ts +++ b/backend/src/modules/kyc/kyc.controller.ts @@ -3,13 +3,23 @@ import { Controller, Get, Param, + Patch, Post, Query, Req, UseGuards, + UseInterceptors, + UploadedFile, + ParseFilePipe, + MaxFileSizeValidator, + HttpCode, + HttpStatus, } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, + ApiBody, + ApiConsumes, ApiOperation, ApiResponse, ApiTags, @@ -22,12 +32,21 @@ import { Roles } from '../../common/decorators/roles.decorator'; import { Role } from '../../common/enums/role.enum'; import { InitiateKycDto } from './dto/initiate-kyc.dto'; import { KycWebhookDto } from './dto/kyc-webhook.dto'; +import { + UploadKycDocumentDto, + ReviewKycDocumentDto, +} from './dto/kyc-document.dto'; import { KycService } from './kyc.service'; +import { KycDocumentService } from './kyc-document.service'; +import { KycDocument } from './entities/kyc-document.entity'; @ApiTags('kyc') @Controller() export class KycController { - constructor(private readonly kycService: KycService) {} + constructor( + private readonly kycService: KycService, + private readonly kycDocumentService: KycDocumentService, + ) {} @Post('user/kyc/initiate') @UseGuards(JwtAuthGuard) @@ -38,6 +57,57 @@ export class KycController { return this.kycService.initiateVerification(user.id, dto); } + @Post('user/kyc/documents') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.CREATED) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['documentType', 'document'], + properties: { + documentType: { + type: 'string', + enum: [ + 'PASSPORT', + 'NATIONAL_ID', + 'DRIVERS_LICENSE', + 'UTILITY_BILL', + 'SELFIE', + ], + }, + document: { type: 'string', format: 'binary' }, + }, + }, + }) + @ApiOperation({ summary: 'Upload a KYC document with encryption at rest' }) + @UseInterceptors(FileInterceptor('document')) + async uploadDocument( + @CurrentUser() user: { id: string }, + @Body() dto: UploadKycDocumentDto, + @UploadedFile( + new ParseFilePipe({ + validators: [new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 })], + }), + ) + file: any, + ): Promise { + return this.kycDocumentService.uploadDocument( + user.id, + dto.documentType, + file, + ); + } + + @Get('user/kyc/documents') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'List current user KYC documents' }) + getMyDocuments(@CurrentUser() user: { id: string }) { + return this.kycDocumentService.listUserDocuments(user.id); + } + @Post('webhooks/kyc/status') @ApiOperation({ summary: 'Handle KYC provider webhook status updates' }) @ApiResponse({ status: 201, description: 'Webhook processed' }) @@ -53,6 +123,37 @@ export class KycController { return this.kycService.listUserVerifications(user.id); } + @Get('admin/kyc/documents/pending') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: 'Admin: list documents pending review' }) + listPendingDocuments() { + return this.kycDocumentService.listPendingReview(); + } + + @Get('admin/kyc/documents/:id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: 'Admin: get KYC document details' }) + getDocument(@Param('id') id: string) { + return this.kycDocumentService.getDocument(id); + } + + @Patch('admin/kyc/documents/:id/review') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: 'Admin: approve or reject a KYC document' }) + reviewDocument( + @Param('id') id: string, + @CurrentUser() admin: { id: string }, + @Body() dto: ReviewKycDocumentDto, + ) { + return this.kycDocumentService.reviewDocument(id, admin.id, dto); + } + @Get('admin/kyc/reports') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(Role.ADMIN) diff --git a/backend/src/modules/kyc/kyc.module.ts b/backend/src/modules/kyc/kyc.module.ts index 802335a7d..783edcca1 100644 --- a/backend/src/modules/kyc/kyc.module.ts +++ b/backend/src/modules/kyc/kyc.module.ts @@ -3,15 +3,22 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from '../user/entities/user.entity'; import { KycComplianceReport } from './entities/kyc-compliance-report.entity'; import { KycVerification } from './entities/kyc-verification.entity'; +import { KycDocument } from './entities/kyc-document.entity'; import { KycController } from './kyc.controller'; import { KycService } from './kyc.service'; +import { KycDocumentService } from './kyc-document.service'; @Module({ imports: [ - TypeOrmModule.forFeature([KycVerification, KycComplianceReport, User]), + TypeOrmModule.forFeature([ + KycVerification, + KycComplianceReport, + KycDocument, + User, + ]), ], controllers: [KycController], - providers: [KycService], - exports: [KycService], + providers: [KycService, KycDocumentService], + exports: [KycService, KycDocumentService], }) export class KycModule {}