Skip to content

Commit 04a5e81

Browse files
authored
Merge pull request #997 from Junirezz/fix/933-backend-implement-kyc-document-upload-and-verification
[#933] [Backend] -- Implement KYC Document Upload and Verification
2 parents 85c4dca + 7856564 commit 04a5e81

7 files changed

Lines changed: 615 additions & 4 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import {
2+
MigrationInterface,
3+
QueryRunner,
4+
Table,
5+
TableForeignKey,
6+
TableIndex,
7+
} from 'typeorm';
8+
9+
export class CreateKycDocumentsTable1800420000000 implements MigrationInterface {
10+
public async up(queryRunner: QueryRunner): Promise<void> {
11+
await queryRunner.createTable(
12+
new Table({
13+
name: 'kyc_documents',
14+
columns: [
15+
{
16+
name: 'id',
17+
type: 'uuid',
18+
isPrimary: true,
19+
generationStrategy: 'uuid',
20+
default: 'uuid_generate_v4()',
21+
},
22+
{ name: 'userId', type: 'uuid', isNullable: false },
23+
{
24+
name: 'documentType',
25+
type: 'enum',
26+
enum: [
27+
'PASSPORT',
28+
'NATIONAL_ID',
29+
'DRIVERS_LICENSE',
30+
'UTILITY_BILL',
31+
'SELFIE',
32+
],
33+
isNullable: false,
34+
},
35+
{ name: 'encryptedStoragePath', type: 'varchar', isNullable: false },
36+
{ name: 'originalFilename', type: 'varchar', isNullable: false },
37+
{ name: 'mimeType', type: 'varchar', isNullable: false },
38+
{
39+
name: 'status',
40+
type: 'enum',
41+
enum: ['UPLOADED', 'PENDING_REVIEW', 'APPROVED', 'REJECTED'],
42+
default: "'UPLOADED'",
43+
isNullable: false,
44+
},
45+
{ name: 'verificationId', type: 'uuid', isNullable: true },
46+
{ name: 'rejectionReason', type: 'text', isNullable: true },
47+
{ name: 'reviewedBy', type: 'varchar', isNullable: true },
48+
{ name: 'reviewedAt', type: 'timestamp', isNullable: true },
49+
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
50+
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
51+
],
52+
}),
53+
true,
54+
);
55+
56+
await queryRunner.createForeignKey(
57+
'kyc_documents',
58+
new TableForeignKey({
59+
columnNames: ['userId'],
60+
referencedTableName: 'users',
61+
referencedColumnNames: ['id'],
62+
onDelete: 'CASCADE',
63+
}),
64+
);
65+
66+
await queryRunner.createForeignKey(
67+
'kyc_documents',
68+
new TableForeignKey({
69+
columnNames: ['verificationId'],
70+
referencedTableName: 'kyc_verifications',
71+
referencedColumnNames: ['id'],
72+
onDelete: 'SET NULL',
73+
}),
74+
);
75+
76+
await queryRunner.createIndex(
77+
'kyc_documents',
78+
new TableIndex({
79+
name: 'IDX_KYC_DOCUMENTS_USER_ID',
80+
columnNames: ['userId', 'createdAt'],
81+
}),
82+
);
83+
}
84+
85+
public async down(queryRunner: QueryRunner): Promise<void> {
86+
await queryRunner.dropTable('kyc_documents');
87+
}
88+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
import { IsIn, IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
3+
import {
4+
KycDocumentType,
5+
KycDocumentStatus,
6+
} from '../entities/kyc-document.entity';
7+
8+
export class UploadKycDocumentDto {
9+
@ApiProperty({ enum: KycDocumentType })
10+
@IsEnum(KycDocumentType)
11+
documentType!: KycDocumentType;
12+
}
13+
14+
export class ReviewKycDocumentDto {
15+
@ApiProperty({
16+
enum: [KycDocumentStatus.APPROVED, KycDocumentStatus.REJECTED],
17+
})
18+
@IsIn([KycDocumentStatus.APPROVED, KycDocumentStatus.REJECTED])
19+
status!: KycDocumentStatus.APPROVED | KycDocumentStatus.REJECTED;
20+
21+
@ApiPropertyOptional()
22+
@IsOptional()
23+
@IsString()
24+
@MaxLength(1000)
25+
rejectionReason?: string;
26+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import {
2+
Entity,
3+
PrimaryGeneratedColumn,
4+
Column,
5+
CreateDateColumn,
6+
UpdateDateColumn,
7+
ManyToOne,
8+
JoinColumn,
9+
Index,
10+
} from 'typeorm';
11+
import { User } from '../../user/entities/user.entity';
12+
import { KycVerification } from './kyc-verification.entity';
13+
14+
export enum KycDocumentType {
15+
PASSPORT = 'PASSPORT',
16+
NATIONAL_ID = 'NATIONAL_ID',
17+
DRIVERS_LICENSE = 'DRIVERS_LICENSE',
18+
UTILITY_BILL = 'UTILITY_BILL',
19+
SELFIE = 'SELFIE',
20+
}
21+
22+
export enum KycDocumentStatus {
23+
UPLOADED = 'UPLOADED',
24+
PENDING_REVIEW = 'PENDING_REVIEW',
25+
APPROVED = 'APPROVED',
26+
REJECTED = 'REJECTED',
27+
}
28+
29+
@Entity('kyc_documents')
30+
@Index(['userId', 'createdAt'])
31+
export class KycDocument {
32+
@PrimaryGeneratedColumn('uuid')
33+
id!: string;
34+
35+
@Column('uuid')
36+
userId!: string;
37+
38+
@Column({ type: 'enum', enum: KycDocumentType })
39+
documentType!: KycDocumentType;
40+
41+
@Column({ type: 'varchar' })
42+
encryptedStoragePath!: string;
43+
44+
@Column({ type: 'varchar' })
45+
originalFilename!: string;
46+
47+
@Column({ type: 'varchar' })
48+
mimeType!: string;
49+
50+
@Column({
51+
type: 'enum',
52+
enum: KycDocumentStatus,
53+
default: KycDocumentStatus.UPLOADED,
54+
})
55+
status!: KycDocumentStatus;
56+
57+
@Column('uuid', { nullable: true })
58+
verificationId!: string | null;
59+
60+
@Column({ type: 'text', nullable: true })
61+
rejectionReason!: string | null;
62+
63+
@Column({ type: 'varchar', nullable: true })
64+
reviewedBy!: string | null;
65+
66+
@Column({ type: 'timestamp', nullable: true })
67+
reviewedAt!: Date | null;
68+
69+
@CreateDateColumn()
70+
createdAt!: Date;
71+
72+
@UpdateDateColumn()
73+
updatedAt!: Date;
74+
75+
@ManyToOne(() => User, { onDelete: 'CASCADE' })
76+
@JoinColumn({ name: 'userId' })
77+
user!: User;
78+
79+
@ManyToOne(() => KycVerification, { onDelete: 'SET NULL', nullable: true })
80+
@JoinColumn({ name: 'verificationId' })
81+
verification!: KycVerification | null;
82+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { getRepositoryToken } from '@nestjs/typeorm';
3+
import { BadRequestException, NotFoundException } from '@nestjs/common';
4+
import { KycDocumentService } from './kyc-document.service';
5+
import {
6+
KycDocument,
7+
KycDocumentType,
8+
KycDocumentStatus,
9+
} from './entities/kyc-document.entity';
10+
import { KycVerification } from './entities/kyc-verification.entity';
11+
import { User } from '../user/entities/user.entity';
12+
import { PiiEncryptionService } from '../../common/services/pii-encryption.service';
13+
14+
const mockRepo = () => ({
15+
create: jest.fn((v) => v),
16+
save: jest.fn(),
17+
find: jest.fn(),
18+
findOne: jest.fn(),
19+
update: jest.fn(),
20+
});
21+
22+
describe('KycDocumentService', () => {
23+
let service: KycDocumentService;
24+
let docRepo: ReturnType<typeof mockRepo>;
25+
let userRepo: ReturnType<typeof mockRepo>;
26+
27+
beforeEach(async () => {
28+
docRepo = mockRepo();
29+
userRepo = mockRepo();
30+
31+
const module: TestingModule = await Test.createTestingModule({
32+
providers: [
33+
KycDocumentService,
34+
{ provide: getRepositoryToken(KycDocument), useValue: docRepo },
35+
{ provide: getRepositoryToken(KycVerification), useValue: mockRepo() },
36+
{ provide: getRepositoryToken(User), useValue: userRepo },
37+
{
38+
provide: PiiEncryptionService,
39+
useValue: { encrypt: jest.fn(() => 'encrypted-data') },
40+
},
41+
],
42+
}).compile();
43+
44+
service = module.get<KycDocumentService>(KycDocumentService);
45+
});
46+
47+
describe('uploadDocument', () => {
48+
it('rejects invalid mime type for SELFIE', async () => {
49+
await expect(
50+
service.uploadDocument('u1', KycDocumentType.SELFIE, {
51+
buffer: Buffer.from('test'),
52+
originalname: 'doc.pdf',
53+
mimetype: 'application/pdf',
54+
size: 100,
55+
}),
56+
).rejects.toThrow(BadRequestException);
57+
});
58+
59+
it('saves document with PENDING_REVIEW status', async () => {
60+
docRepo.save.mockImplementation((d) =>
61+
Promise.resolve({ id: 'doc-1', ...d }),
62+
);
63+
64+
const result = await service.uploadDocument(
65+
'u1',
66+
KycDocumentType.PASSPORT,
67+
{
68+
buffer: Buffer.from('test'),
69+
originalname: 'passport.jpg',
70+
mimetype: 'image/jpeg',
71+
size: 1024,
72+
},
73+
);
74+
75+
expect(result.status).toBe(KycDocumentStatus.PENDING_REVIEW);
76+
expect(userRepo.update).toHaveBeenCalledWith('u1', {
77+
kycStatus: 'PENDING',
78+
});
79+
});
80+
});
81+
82+
describe('reviewDocument', () => {
83+
it('approves pending document', async () => {
84+
docRepo.findOne.mockResolvedValue({
85+
id: 'doc-1',
86+
userId: 'u1',
87+
status: KycDocumentStatus.PENDING_REVIEW,
88+
});
89+
docRepo.save.mockImplementation((d) => Promise.resolve(d));
90+
91+
const result = await service.reviewDocument('doc-1', 'admin-1', {
92+
status: KycDocumentStatus.APPROVED,
93+
});
94+
95+
expect(result.status).toBe(KycDocumentStatus.APPROVED);
96+
});
97+
98+
it('throws when document not found', async () => {
99+
docRepo.findOne.mockResolvedValue(null);
100+
await expect(
101+
service.reviewDocument('bad', 'admin', {
102+
status: KycDocumentStatus.APPROVED,
103+
}),
104+
).rejects.toThrow(NotFoundException);
105+
});
106+
});
107+
});

0 commit comments

Comments
 (0)