From f45bc6ecc734e81de0d7c366ed27024942a45040 Mon Sep 17 00:00:00 2001 From: Abdul-dev-creator Date: Sat, 18 Jul 2026 20:45:21 +0400 Subject: [PATCH] feat: integrate KYC verification provider with identity provider abstraction Adds the first KYC provider (mock-kyc) behind an IIdentityProvider interface, enabling provider-swappable identity verification. - IIdentityProvider abstraction (createSession, getStatus, processWebhook) - MockKycProvider implementation with full state machine - KycModule with POST /kyc/session, GET /kyc/status/:userId, POST /kyc/webhook - kyc_verifications table migration (Supabase/PostgreSQL) - Verification states: pending, in_review, verified, rejected, expired Closes #72 --- .env.example | 3 + scripts/002_create_kyc_verifications.sql | 31 ++++ src/app.module.ts | 2 + src/kyc/dto/kyc.dto.ts | 21 +++ .../interfaces/identity-provider.interface.ts | 29 ++++ src/kyc/interfaces/kyc.types.ts | 31 ++++ src/kyc/kyc.controller.ts | 36 +++++ src/kyc/kyc.module.ts | 14 ++ src/kyc/kyc.service.ts | 139 ++++++++++++++++++ src/kyc/providers/mock-kyc.provider.ts | 82 +++++++++++ 10 files changed, 388 insertions(+) create mode 100644 scripts/002_create_kyc_verifications.sql create mode 100644 src/kyc/dto/kyc.dto.ts create mode 100644 src/kyc/interfaces/identity-provider.interface.ts create mode 100644 src/kyc/interfaces/kyc.types.ts create mode 100644 src/kyc/kyc.controller.ts create mode 100644 src/kyc/kyc.module.ts create mode 100644 src/kyc/kyc.service.ts create mode 100644 src/kyc/providers/mock-kyc.provider.ts diff --git a/.env.example b/.env.example index 723530e..a2adfd3 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,9 @@ SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBh # THALOS_INTERNAL_SECRET. THALOS_INTERNAL_SECRET="xeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30yz" +# ---- KYC Provider (optional, defaults to mock provider) ---- +# KYC_PROVIDER="mock-kyc" + # ---- Trustless Work (required for escrow operations) -------- # Base URL of the Trustless Work API. diff --git a/scripts/002_create_kyc_verifications.sql b/scripts/002_create_kyc_verifications.sql new file mode 100644 index 0000000..745909a --- /dev/null +++ b/scripts/002_create_kyc_verifications.sql @@ -0,0 +1,31 @@ +-- Migration: Create kyc_verifications table for KYC provider integration +-- EXECUTED: Apply to Supabase + +CREATE TYPE kyc_status AS ENUM ('pending', 'in_review', 'verified', 'rejected', 'expired'); + +CREATE TABLE IF NOT EXISTS public.kyc_verifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_verification_id TEXT NOT NULL UNIQUE, + status kyc_status NOT NULL DEFAULT 'pending', + metadata JSONB DEFAULT '{}', + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_kyc_verifications_user_id ON public.kyc_verifications(user_id); +CREATE INDEX IF NOT EXISTS idx_kyc_verifications_provider_verification_id ON public.kyc_verifications(provider_verification_id); +CREATE INDEX IF NOT EXISTS idx_kyc_verifications_status ON public.kyc_verifications(status); + +ALTER TABLE public.kyc_verifications ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view their own KYC verifications" ON public.kyc_verifications FOR SELECT USING (auth.uid() = user_id); +CREATE POLICY "Service role can insert KYC verifications" ON public.kyc_verifications FOR INSERT WITH CHECK (true); +CREATE POLICY "Service role can update KYC verifications" ON public.kyc_verifications FOR UPDATE USING (true); + +COMMENT ON TABLE public.kyc_verifications IS 'Stores KYC verification sessions created through the identity provider abstraction.'; +COMMENT ON COLUMN public.kyc_verifications.provider IS 'Name of the KYC provider that handled this verification'; +COMMENT ON COLUMN public.kyc_verifications.provider_verification_id IS 'Provider-side verification session identifier'; +COMMENT ON COLUMN public.kyc_verifications.status IS 'Current verification status: pending, in_review, verified, rejected, expired'; diff --git a/src/app.module.ts b/src/app.module.ts index f21ce1f..b48aa67 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,7 @@ import { ProfilesModule } from './profiles/profiles.module'; import { WalletsModule } from './wallets/wallets.module'; import { EventsModule } from './events/events.module'; import { WebhooksModule } from './webhooks/webhooks.module'; +import { KycModule } from './kyc/kyc.module'; @Module({ imports: [ @@ -35,6 +36,7 @@ import { WebhooksModule } from './webhooks/webhooks.module'; WalletsModule, EventsModule, WebhooksModule, + KycModule, ], controllers: [RootController], }) diff --git a/src/kyc/dto/kyc.dto.ts b/src/kyc/dto/kyc.dto.ts new file mode 100644 index 0000000..adacff3 --- /dev/null +++ b/src/kyc/dto/kyc.dto.ts @@ -0,0 +1,21 @@ +import { IsOptional, IsString, IsObject } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateKycSessionDto { + @ApiPropertyOptional({ + description: 'Optional metadata to attach to the verification session', + }) + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class KycWebhookDto { + @ApiProperty({ description: 'Provider verification ID' }) + @IsString() + verification_id: string; + + @ApiProperty({ description: 'New verification status' }) + @IsString() + status: string; +} diff --git a/src/kyc/interfaces/identity-provider.interface.ts b/src/kyc/interfaces/identity-provider.interface.ts new file mode 100644 index 0000000..da97bc5 --- /dev/null +++ b/src/kyc/interfaces/identity-provider.interface.ts @@ -0,0 +1,29 @@ +import { KycStatus } from './kyc.types'; + +export interface CreateSessionInput { + userId: string; + metadata?: Record; +} + +export interface VerificationResult { + status: KycStatus; + verifiedAt: string | null; + metadata?: Record; +} + +export interface IIdentityProvider { + readonly name: string; + + createSession(input: CreateSessionInput): Promise<{ + providerVerificationId: string; + sessionUrl?: string; + metadata?: Record; + }>; + + getStatus(providerVerificationId: string): Promise; + + processWebhook(payload: unknown): Promise<{ + providerVerificationId: string; + result: VerificationResult; + }>; +} diff --git a/src/kyc/interfaces/kyc.types.ts b/src/kyc/interfaces/kyc.types.ts new file mode 100644 index 0000000..9a9b66d --- /dev/null +++ b/src/kyc/interfaces/kyc.types.ts @@ -0,0 +1,31 @@ +export enum KycStatus { + Pending = 'pending', + InReview = 'in_review', + Verified = 'verified', + Rejected = 'rejected', + Expired = 'expired', +} + +export interface KycSession { + id: string; + userId: string; + provider: string; + providerVerificationId: string; + status: KycStatus; + metadata: Record; + verifiedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface KycVerificationRow { + id: string; + user_id: string; + provider: string; + provider_verification_id: string; + status: KycStatus; + metadata: Record; + verified_at: string | null; + created_at: string; + updated_at: string; +} diff --git a/src/kyc/kyc.controller.ts b/src/kyc/kyc.controller.ts new file mode 100644 index 0000000..3e4fe69 --- /dev/null +++ b/src/kyc/kyc.controller.ts @@ -0,0 +1,36 @@ +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser, AuthUserCtx } from '../auth/current-user.decorator'; +import { KycService } from './kyc.service'; +import { CreateKycSessionDto, KycWebhookDto } from './dto/kyc.dto'; + +@ApiTags('kyc') +@Controller('kyc') +export class KycController { + constructor(private readonly kycService: KycService) {} + + @Post('session') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearer') + @ApiOperation({ summary: 'Create a new KYC verification session' }) + @ApiResponse({ status: 201, description: 'KYC session created' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async createSession(@CurrentUser() user: AuthUserCtx, @Body() dto: CreateKycSessionDto) { + return this.kycService.createSession(user.userId, dto.metadata); + } + + @Get('status/:userId') + @ApiOperation({ summary: 'Get KYC verification status for a user' }) + @ApiResponse({ status: 200, description: 'KYC status retrieved' }) + async getStatus(@Param('userId') userId: string) { + return this.kycService.getStatus(userId); + } + + @Post('webhook') + @ApiOperation({ summary: 'Receive KYC verification results from provider' }) + @ApiResponse({ status: 201, description: 'Webhook processed' }) + async handleWebhook(@Body() dto: KycWebhookDto) { + return this.kycService.handleWebhook(dto); + } +} diff --git a/src/kyc/kyc.module.ts b/src/kyc/kyc.module.ts new file mode 100644 index 0000000..56ed431 --- /dev/null +++ b/src/kyc/kyc.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { KycController } from './kyc.controller'; +import { KycService } from './kyc.service'; +import { MockKycProvider } from './providers/mock-kyc.provider'; +import { SupabaseModule } from '../supabase/supabase.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [SupabaseModule, AuthModule], + controllers: [KycController], + providers: [KycService, MockKycProvider], + exports: [KycService], +}) +export class KycModule {} diff --git a/src/kyc/kyc.service.ts b/src/kyc/kyc.service.ts new file mode 100644 index 0000000..e10dd05 --- /dev/null +++ b/src/kyc/kyc.service.ts @@ -0,0 +1,139 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { SupabaseService } from '../supabase/supabase.service'; +import { IIdentityProvider } from './interfaces/identity-provider.interface'; +import { MockKycProvider } from './providers/mock-kyc.provider'; +import { KycStatus, KycSession, KycVerificationRow } from './interfaces/kyc.types'; + +@Injectable() +export class KycService { + private readonly logger = new Logger(KycService.name); + private readonly provider: IIdentityProvider; + + constructor( + private readonly supabase: SupabaseService, + mockProvider: MockKycProvider, + ) { + this.provider = mockProvider; + this.logger.log(`Initialized KYC service with provider: ${this.provider.name}`); + } + + get activeProvider(): string { + return this.provider.name; + } + + async createSession(userId: string, metadata?: Record) { + const { providerVerificationId, sessionUrl } = await this.provider.createSession({ + userId, + metadata, + }); + + const row: Omit = { + user_id: userId, + provider: this.provider.name, + provider_verification_id: providerVerificationId, + status: KycStatus.Pending, + metadata: metadata || {}, + verified_at: null, + }; + + const { data, error } = await this.supabase + .getClient() + .from('kyc_verifications') + .insert(row) + .select() + .single(); + + if (error) { + this.logger.error(`Failed to persist KYC session: ${error.message}`); + throw new Error(`Failed to create KYC session: ${error.message}`); + } + + return { + session: this.toSession(data as KycVerificationRow), + sessionUrl, + }; + } + + async getStatus(userId: string) { + const { data, error } = await this.supabase + .getClient() + .from('kyc_verifications') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(); + + if (error) { + throw new Error(`Failed to fetch KYC status: ${error.message}`); + } + + if (!data) { + return { session: null }; + } + + const row = data as KycVerificationRow; + const providerResult = await this.provider.getStatus(row.provider_verification_id); + + if (providerResult.status !== row.status) { + const { data: updated } = await this.supabase + .getClient() + .from('kyc_verifications') + .update({ + status: providerResult.status, + verified_at: providerResult.verifiedAt, + updated_at: new Date().toISOString(), + }) + .eq('id', row.id) + .select() + .single(); + + if (updated) { + return { session: this.toSession(updated as KycVerificationRow) }; + } + } + + return { session: this.toSession(row) }; + } + + async handleWebhook(payload: unknown): Promise { + const { providerVerificationId, result } = await this.provider.processWebhook(payload); + + const { data, error } = await this.supabase + .getClient() + .from('kyc_verifications') + .update({ + status: result.status, + verified_at: result.verifiedAt, + metadata: result.metadata ? { ...result.metadata } : undefined, + updated_at: new Date().toISOString(), + }) + .eq('provider_verification_id', providerVerificationId) + .select() + .single(); + + if (error || !data) { + this.logger.error(`Failed to update KYC session from webhook: ${error?.message}`); + throw new NotFoundException('KYC session not found'); + } + + this.logger.log( + `KYC session ${providerVerificationId} updated via webhook to ${result.status}`, + ); + return this.toSession(data as KycVerificationRow); + } + + private toSession(row: KycVerificationRow): KycSession { + return { + id: row.id, + userId: row.user_id, + provider: row.provider, + providerVerificationId: row.provider_verification_id, + status: row.status, + metadata: row.metadata || {}, + verifiedAt: row.verified_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} diff --git a/src/kyc/providers/mock-kyc.provider.ts b/src/kyc/providers/mock-kyc.provider.ts new file mode 100644 index 0000000..3bc41d9 --- /dev/null +++ b/src/kyc/providers/mock-kyc.provider.ts @@ -0,0 +1,82 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { + IIdentityProvider, + CreateSessionInput, + VerificationResult, +} from '../interfaces/identity-provider.interface'; +import { KycStatus } from '../interfaces/kyc.types'; + +@Injectable() +export class MockKycProvider implements IIdentityProvider { + readonly name = 'mock-kyc'; + private readonly logger = new Logger(MockKycProvider.name); + private readonly sessions = new Map< + string, + { status: KycStatus; verifiedAt: string | null; createdAt: string } + >(); + + createSession(input: CreateSessionInput) { + const providerVerificationId = randomUUID(); + const createdAt = new Date().toISOString(); + + this.sessions.set(providerVerificationId, { + status: KycStatus.Pending, + verifiedAt: null, + createdAt, + }); + + this.logger.log(`Created KYC session ${providerVerificationId} for user ${input.userId}`); + + return Promise.resolve({ + providerVerificationId, + metadata: { created_at: createdAt }, + }); + } + + getStatus(providerVerificationId: string): Promise { + const session = this.sessions.get(providerVerificationId); + if (!session) { + throw new Error(`KYC session ${providerVerificationId} not found`); + } + + return Promise.resolve({ + status: session.status, + verifiedAt: session.verifiedAt, + }); + } + + processWebhook(payload: unknown): Promise<{ + providerVerificationId: string; + result: VerificationResult; + }> { + const body = payload as Record; + const providerVerificationId = body.verification_id as string; + const newStatus = body.status as string; + + const session = this.sessions.get(providerVerificationId); + if (!session) { + throw new Error(`KYC session ${providerVerificationId} not found`); + } + + const validStatuses = Object.values(KycStatus) as string[]; + const status = validStatuses.includes(newStatus) + ? (newStatus as KycStatus) + : KycStatus.Rejected; + + session.status = status; + if (status === KycStatus.Verified) { + session.verifiedAt = new Date().toISOString(); + } + + this.logger.log(`KYC session ${providerVerificationId} status updated to ${status}`); + + return Promise.resolve({ + providerVerificationId, + result: { + status: session.status, + verifiedAt: session.verifiedAt, + }, + }); + } +}