From 130bafdb1fda814ca5412fc6c7880f3525a00346 Mon Sep 17 00:00:00 2001 From: Itodo-S Date: Mon, 28 Sep 2026 09:50:17 +0100 Subject: [PATCH 1/4] feat(backend): add KYC document verification with pluggable providers Validate KYC documents before verification: allowed MIME types and size limits from the kyc upload category, extension and magic-byte checks, document-number patterns (with country-specific formats), identity document expiry and proof-of-address recency. Define a DocumentVerificationProvider interface with a deterministic mock provider for local development and tests. Refs #921 --- backend/src/middleware/file-upload.ts | 9 +- .../__tests__/kyc-verification.test.ts | 259 ++++++++++++ backend/src/services/kyc-verification.ts | 387 ++++++++++++++++++ 3 files changed, 650 insertions(+), 5 deletions(-) create mode 100644 backend/src/services/__tests__/kyc-verification.test.ts create mode 100644 backend/src/services/kyc-verification.ts diff --git a/backend/src/middleware/file-upload.ts b/backend/src/middleware/file-upload.ts index 45dc812e..e155321a 100644 --- a/backend/src/middleware/file-upload.ts +++ b/backend/src/middleware/file-upload.ts @@ -14,9 +14,8 @@ import { createHash, randomUUID } from 'node:crypto'; import { createWriteStream, createReadStream, existsSync, mkdirSync } from 'node:fs'; -import { unlink, stat, readdir, rm } from 'node:fs/promises'; +import { unlink, stat, readdir } from 'node:fs/promises'; import path from 'node:path'; -import { pipeline } from 'node:stream/promises'; import type { Request, Response, NextFunction } from 'express'; // ── Types ───────────────────────────────────────────────────────────────────── @@ -54,13 +53,13 @@ export interface UploadedFile { // ── Config ──────────────────────────────────────────────────────────────────── -const CATEGORY_MAX_BYTES: Record = { +export const CATEGORY_MAX_BYTES: Record = { kyc: 10 * 1024 * 1024, // 10 MB dispute: 20 * 1024 * 1024, // 20 MB general: 5 * 1024 * 1024, // 5 MB }; -const CATEGORY_ALLOWED_TYPES: Record = { +export const CATEGORY_ALLOWED_TYPES: Record = { kyc: ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'], dispute: ['image/jpeg', 'image/png', 'image/webp', 'application/pdf', 'video/mp4'], general: ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'text/plain'], @@ -94,7 +93,7 @@ function detectMimeFromBytes(buffer: Buffer): string | undefined { return undefined; } -function validateMagicBytes(buffer: Buffer, declaredMime: string): boolean { +export function validateMagicBytes(buffer: Buffer, declaredMime: string): boolean { const sig = MAGIC_SIGNATURES.find((s) => s.mimes.includes(declaredMime)); if (!sig) return false; // unknown type → reject if (sig.magic.length === 0) return true; // text/plain — skip magic check diff --git a/backend/src/services/__tests__/kyc-verification.test.ts b/backend/src/services/__tests__/kyc-verification.test.ts new file mode 100644 index 00000000..f399c1fb --- /dev/null +++ b/backend/src/services/__tests__/kyc-verification.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest'; +import { + KYC_MAX_FILE_BYTES, + MockDocumentVerificationProvider, + calculateAge, + getDocumentNumberPattern, + maskDocumentNumber, + normalizeDocumentNumber, + parseIsoDate, + validateKycDocument, + validatePersonalInfo, + type KycDocumentInput, +} from '../kyc-verification.js'; +import type { KycPersonalInfo } from '../kyc.js'; + +const NOW = new Date('2026-06-15T12:00:00.000Z'); + +const pdf = Buffer.concat([Buffer.from('%PDF-1.4\n'), Buffer.alloc(2048, 0x20)]); +const png = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(2048, 0x00), +]); + +function passport(overrides: Partial = {}): KycDocumentInput { + return { + type: 'passport', + documentNumber: 'X1234567', + issuingCountry: 'DE', + issueDate: '2022-01-10', + expiryDate: '2032-01-09', + fileName: 'passport.pdf', + mimeType: 'application/pdf', + fileSize: pdf.length, + fileContent: pdf.toString('base64'), + ...overrides, + }; +} + +function utilityBill(overrides: Partial = {}): KycDocumentInput { + return { + type: 'utility_bill', + issuingCountry: 'DE', + issueDate: '2026-05-20', + fileName: 'bill.png', + mimeType: 'image/png', + fileSize: png.length, + fileContent: png.toString('base64'), + ...overrides, + }; +} + +const person: KycPersonalInfo = { + firstName: 'Ada', + lastName: 'Lovelace', + dateOfBirth: '1990-04-12', + nationality: 'GB', + countryOfResidence: 'GB', + address: { line1: '1 Main St', city: 'London', postalCode: 'N1 9GU', country: 'GB' }, +}; + +describe('kyc-verification helpers', () => { + it('parses strict ISO dates and rejects rollovers', () => { + expect(parseIsoDate('2024-02-29')?.toISOString()).toBe('2024-02-29T00:00:00.000Z'); + expect(parseIsoDate('2023-02-29')).toBeUndefined(); + expect(parseIsoDate('2024-13-01')).toBeUndefined(); + expect(parseIsoDate('12/01/2024')).toBeUndefined(); + }); + + it('calculates age around the birthday boundary', () => { + expect(calculateAge(new Date('2008-06-15T00:00:00Z'), NOW)).toBe(18); + expect(calculateAge(new Date('2008-06-16T00:00:00Z'), NOW)).toBe(17); + }); + + it('normalizes and masks document numbers', () => { + expect(normalizeDocumentNumber(' ab-12 345 ')).toBe('AB12345'); + expect(maskDocumentNumber('X1234567')).toBe('****4567'); + expect(maskDocumentNumber('123')).toBe('123'); + }); + + it('prefers country-specific document number patterns', () => { + expect(getDocumentNumberPattern('passport', 'gb')?.test('123456789')).toBe(true); + expect(getDocumentNumberPattern('passport', 'GB')?.test('X1234567')).toBe(false); + expect(getDocumentNumberPattern('passport', 'DE')?.test('X1234567')).toBe(true); + expect(getDocumentNumberPattern('utility_bill', 'DE')).toBeUndefined(); + }); +}); + +describe('validatePersonalInfo', () => { + it('accepts an adult applicant', () => { + expect(validatePersonalInfo(person, NOW)).toEqual([]); + }); + + it('rejects applicants under 18', () => { + expect(validatePersonalInfo({ ...person, dateOfBirth: '2010-01-01' }, NOW)).toEqual([ + 'Applicant must be at least 18 years old', + ]); + }); + + it('rejects future, implausible, and malformed dates of birth', () => { + expect(validatePersonalInfo({ ...person, dateOfBirth: '2030-01-01' }, NOW)).toEqual([ + 'Date of birth cannot be in the future', + ]); + expect(validatePersonalInfo({ ...person, dateOfBirth: '1850-01-01' }, NOW)).toEqual([ + 'Date of birth is not plausible', + ]); + expect(validatePersonalInfo({ ...person, dateOfBirth: '1990-02-30' }, NOW)).toHaveLength(1); + }); +}); + +describe('validateKycDocument', () => { + it('accepts a valid passport and returns the normalized number and hash', () => { + const result = validateKycDocument(passport({ documentNumber: 'x123-4567' }), NOW); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.normalizedDocumentNumber).toBe('X1234567'); + expect(result.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(result.file?.length).toBe(pdf.length); + }); + + it('accepts a data URL and a remote https file', () => { + expect( + validateKycDocument(passport({ fileContent: `data:application/pdf;base64,${pdf.toString('base64')}` }), NOW).valid, + ).toBe(true); + expect( + validateKycDocument(passport({ fileContent: undefined, fileUrl: 'https://files.example.com/p.pdf' }), NOW).valid, + ).toBe(true); + }); + + it('accepts a recent proof of address without a document number', () => { + const result = validateKycDocument(utilityBill(), NOW); + expect(result.valid).toBe(true); + expect(result.normalizedDocumentNumber).toBeUndefined(); + }); + + it('rejects unsupported MIME types and mismatched extensions', () => { + expect(validateKycDocument(passport({ mimeType: 'image/gif', fileName: 'p.gif' }), NOW).errors[0]).toMatch( + /Unsupported file type/, + ); + expect(validateKycDocument(passport({ fileName: 'passport.png' }), NOW).errors).toContain( + 'File extension ".png" does not match file type application/pdf', + ); + }); + + it('rejects files that are too large or too small', () => { + expect( + validateKycDocument(passport({ fileSize: KYC_MAX_FILE_BYTES + 1, fileContent: undefined, fileUrl: 'https://x.io/a.pdf' }), NOW) + .errors, + ).toContain('File exceeds the 10 MB limit'); + expect( + validateKycDocument(passport({ fileSize: 10, fileContent: undefined, fileUrl: 'https://x.io/a.pdf' }), NOW).errors, + ).toContain('File is too small to be a legible document image'); + }); + + it('rejects content that does not match the declared type or size', () => { + const result = validateKycDocument(passport({ fileContent: png.toString('base64'), fileSize: png.length }), NOW); + expect(result.errors).toContain('File contents are not a valid application/pdf file'); + + const sizeMismatch = validateKycDocument(passport({ fileSize: pdf.length + 5 }), NOW); + expect(sizeMismatch.errors[0]).toMatch(/does not match uploaded content/); + }); + + it('requires a file and rejects invalid base64 or non-https URLs', () => { + expect(validateKycDocument(passport({ fileContent: undefined }), NOW).errors).toContain( + 'Either fileContent or fileUrl is required', + ); + expect(validateKycDocument(passport({ fileContent: 'not base64!' }), NOW).errors).toContain( + 'fileContent must be valid base64', + ); + expect( + validateKycDocument(passport({ fileContent: undefined, fileUrl: 'http://files.example.com/p.pdf' }), NOW).errors, + ).toContain('fileUrl must be an https URL'); + }); + + it('rejects identity documents with missing or malformed numbers', () => { + expect(validateKycDocument(passport({ documentNumber: undefined }), NOW).errors).toContain( + 'Document number is required for identity documents', + ); + expect(validateKycDocument(passport({ documentNumber: 'AB1' }), NOW).errors).toContain( + 'Document number format is not valid for a DE passport', + ); + expect(validateKycDocument(passport({ issuingCountry: 'GB', documentNumber: 'X1234567' }), NOW).valid).toBe(false); + }); + + it('rejects expired identity documents and warns when expiry is close', () => { + expect(validateKycDocument(passport({ expiryDate: '2026-06-15' }), NOW).errors).toContain( + 'Document expired on 2026-06-15', + ); + + const expiringSoon = validateKycDocument(passport({ expiryDate: '2026-07-01' }), NOW); + expect(expiringSoon.valid).toBe(true); + expect(expiringSoon.warnings).toEqual(['Document expires within 30 days']); + }); + + it('rejects missing, malformed, and inconsistent identity dates', () => { + expect(validateKycDocument(passport({ expiryDate: undefined }), NOW).errors).toContain( + 'Expiry date is required for identity documents', + ); + expect(validateKycDocument(passport({ expiryDate: '2031-02-30' }), NOW).errors).toContain( + 'Expiry date must be a valid date in YYYY-MM-DD format', + ); + expect(validateKycDocument(passport({ expiryDate: '2060-01-01' }), NOW).errors).toContain( + 'Expiry date is too far in the future', + ); + expect(validateKycDocument(passport({ issueDate: '2027-01-01' }), NOW).errors).toContain( + 'Issue date cannot be in the future', + ); + }); + + it('rejects stale or undated proof of address', () => { + expect(validateKycDocument(utilityBill({ issueDate: undefined }), NOW).errors).toContain( + 'Issue date is required for proof-of-address documents', + ); + expect(validateKycDocument(utilityBill({ issueDate: '2026-01-01' }), NOW).errors).toContain( + 'Proof of address must be issued within the last 90 days', + ); + expect(validateKycDocument(utilityBill({ issueDate: '2026-07-01' }), NOW).errors).toContain( + 'Issue date cannot be in the future', + ); + }); + + it('rejects unknown document types and bad country codes', () => { + const result = validateKycDocument( + passport({ type: 'selfie' as KycDocumentInput['type'], issuingCountry: 'DEU' }), + NOW, + ); + expect(result.errors).toContain('Unsupported document type "selfie"'); + expect(result.errors).toContain('Issuing country must be a 2-letter ISO country code'); + }); +}); + +describe('MockDocumentVerificationProvider', () => { + const provider = new MockDocumentVerificationProvider(); + const base = { + userId: 'user-1', + documentId: 'doc-1', + type: 'passport' as const, + issuingCountry: 'DE', + mimeType: 'application/pdf', + }; + + it('approves ordinary documents', async () => { + const result = await provider.verify({ ...base, documentNumber: 'X1234567' }); + expect(result.decision).toBe('approved'); + expect(result.reasons).toEqual([]); + expect(result.reference).toMatch(/^mock_/); + }); + + it('rejects numbers ending in 000 with a reason', async () => { + const result = await provider.verify({ ...base, documentNumber: 'X1234000' }); + expect(result.decision).toBe('rejected'); + expect(result.reasons).toEqual(['Document number is reported as lost or stolen']); + }); + + it('routes numbers ending in 999 to manual review', async () => { + const result = await provider.verify({ ...base, documentNumber: 'X1234999' }); + expect(result.decision).toBe('needs_review'); + }); +}); diff --git a/backend/src/services/kyc-verification.ts b/backend/src/services/kyc-verification.ts new file mode 100644 index 00000000..d7f44fb4 --- /dev/null +++ b/backend/src/services/kyc-verification.ts @@ -0,0 +1,387 @@ +// Issue #921: KYC document verification. +// +// Two layers: +// - Local format checks (file type/size/signature, document-number patterns, +// issue and expiry dates) that run before anything leaves the process. +// - A pluggable `DocumentVerificationProvider` for authenticity checks. The +// default `MockDocumentVerificationProvider` is deterministic so the flow can +// be exercised end-to-end without a third-party vendor. + +import { createHash, randomUUID } from 'node:crypto'; +import { + CATEGORY_ALLOWED_TYPES, + CATEGORY_MAX_BYTES, + validateMagicBytes, +} from '../middleware/file-upload.js'; +import type { DocumentType, KycPersonalInfo } from './kyc.js'; + +// ── Rules ──────────────────────────────────────────────────────────────────── + +export const DOCUMENT_TYPES: DocumentType[] = [ + 'passport', + 'drivers_license', + 'national_id', + 'utility_bill', + 'bank_statement', +]; + +export const IDENTITY_DOCUMENT_TYPES: DocumentType[] = ['passport', 'drivers_license', 'national_id']; +export const ADDRESS_DOCUMENT_TYPES: DocumentType[] = ['utility_bill', 'bank_statement']; + +export const KYC_ALLOWED_MIME_TYPES = CATEGORY_ALLOWED_TYPES.kyc; +export const KYC_MAX_FILE_BYTES = CATEGORY_MAX_BYTES.kyc; +// Anything smaller cannot be a legible scan or photo of a document. +export const KYC_MIN_FILE_BYTES = 1024; + +export const PROOF_OF_ADDRESS_MAX_AGE_DAYS = 90; +export const EXPIRY_WARNING_DAYS = 30; +export const MAX_EXPIRY_YEARS = 20; +export const MINIMUM_AGE = 18; +export const MAXIMUM_AGE = 120; + +const MIME_EXTENSIONS: Record = { + 'image/jpeg': ['jpg', 'jpeg'], + 'image/png': ['png'], + 'image/webp': ['webp'], + 'application/pdf': ['pdf'], +}; + +const DOCUMENT_NUMBER_PATTERNS: Partial> = { + passport: /^[A-Z0-9]{6,9}$/, + national_id: /^[A-Z0-9]{5,20}$/, + drivers_license: /^[A-Z0-9]{4,20}$/, +}; + +// Issuers with a published, fixed number layout override the generic pattern. +const COUNTRY_DOCUMENT_NUMBER_PATTERNS: Record>> = { + US: { passport: /^[A-Z0-9]\d{8}$/ }, + GB: { passport: /^\d{9}$/ }, + IN: { passport: /^[A-Z]\d{7}$/ }, + NG: { passport: /^[A-Z]\d{8}$/, national_id: /^\d{11}$/ }, +}; + +const DAY_MS = 24 * 60 * 60 * 1000; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +export function isIdentityDocument(type: DocumentType): boolean { + return IDENTITY_DOCUMENT_TYPES.includes(type); +} + +export function isAddressDocument(type: DocumentType): boolean { + return ADDRESS_DOCUMENT_TYPES.includes(type); +} + +export function normalizeDocumentNumber(value: string): string { + return value.replace(/[\s-]/g, '').toUpperCase(); +} + +export function maskDocumentNumber(value: string): string { + const normalized = normalizeDocumentNumber(value); + const visible = normalized.slice(-4); + return `${'*'.repeat(Math.max(normalized.length - visible.length, 0))}${visible}`; +} + +export function getDocumentNumberPattern(type: DocumentType, issuingCountry: string): RegExp | undefined { + return COUNTRY_DOCUMENT_NUMBER_PATTERNS[issuingCountry.toUpperCase()]?.[type] ?? DOCUMENT_NUMBER_PATTERNS[type]; +} + +/** Parses a strict `YYYY-MM-DD` calendar date as UTC midnight. */ +export function parseIsoDate(value: string): Date | undefined { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined; + const date = new Date(`${value}T00:00:00.000Z`); + if (Number.isNaN(date.getTime())) return undefined; + // Reject dates JavaScript silently rolls over, e.g. 2024-02-30. + return date.toISOString().slice(0, 10) === value ? date : undefined; +} + +function startOfUtcDay(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +export function calculateAge(dateOfBirth: Date, now: Date = new Date()): number { + let age = now.getUTCFullYear() - dateOfBirth.getUTCFullYear(); + const beforeBirthday = + now.getUTCMonth() < dateOfBirth.getUTCMonth() || + (now.getUTCMonth() === dateOfBirth.getUTCMonth() && now.getUTCDate() < dateOfBirth.getUTCDate()); + if (beforeBirthday) age -= 1; + return age; +} + +function stripDataUrlPrefix(content: string): string { + const match = /^data:[^;,]+;base64,/i.exec(content); + return match ? content.slice(match[0].length) : content; +} + +function decodeBase64(content: string): Buffer | undefined { + const cleaned = stripDataUrlPrefix(content).replace(/\s/g, ''); + if (cleaned.length === 0 || cleaned.length % 4 !== 0) return undefined; + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(cleaned)) return undefined; + return Buffer.from(cleaned, 'base64'); +} + +// ── Personal info ──────────────────────────────────────────────────────────── + +export function validatePersonalInfo(info: KycPersonalInfo, now: Date = new Date()): string[] { + const errors: string[] = []; + const dateOfBirth = parseIsoDate(info.dateOfBirth); + + if (!dateOfBirth) { + errors.push('Date of birth must be a valid date in YYYY-MM-DD format'); + } else if (dateOfBirth > now) { + errors.push('Date of birth cannot be in the future'); + } else { + const age = calculateAge(dateOfBirth, now); + if (age < MINIMUM_AGE) errors.push(`Applicant must be at least ${MINIMUM_AGE} years old`); + if (age > MAXIMUM_AGE) errors.push('Date of birth is not plausible'); + } + + return errors; +} + +// ── Documents ──────────────────────────────────────────────────────────────── + +export interface KycDocumentInput { + type: DocumentType; + documentNumber?: string; + issuingCountry: string; + issueDate?: string; + expiryDate?: string; + fileName: string; + mimeType: string; + fileSize: number; + /** Base64 file contents, optionally as a `data:` URL. */ + fileContent?: string; + /** HTTPS location of a file already held in storage. */ + fileUrl?: string; +} + +export interface DocumentValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; + normalizedDocumentNumber?: string; + file?: Buffer; + sha256?: string; +} + +function validateFile(input: KycDocumentInput, errors: string[]): { file?: Buffer; sha256?: string } { + const mimeType = input.mimeType.toLowerCase(); + + if (!KYC_ALLOWED_MIME_TYPES.includes(mimeType)) { + errors.push(`Unsupported file type "${input.mimeType}". Allowed: ${KYC_ALLOWED_MIME_TYPES.join(', ')}`); + } else { + const extension = input.fileName.split('.').pop()?.toLowerCase() ?? ''; + if (!MIME_EXTENSIONS[mimeType]?.includes(extension)) { + errors.push(`File extension ".${extension}" does not match file type ${mimeType}`); + } + } + + if (input.fileSize > KYC_MAX_FILE_BYTES) { + errors.push(`File exceeds the ${KYC_MAX_FILE_BYTES / (1024 * 1024)} MB limit`); + } else if (input.fileSize < KYC_MIN_FILE_BYTES) { + errors.push('File is too small to be a legible document image'); + } + + if (!input.fileContent && !input.fileUrl) { + errors.push('Either fileContent or fileUrl is required'); + return {}; + } + + if (input.fileUrl && !input.fileContent) { + let protocol = ''; + try { + protocol = new URL(input.fileUrl).protocol; + } catch { + // handled below + } + if (protocol !== 'https:') errors.push('fileUrl must be an https URL'); + return {}; + } + + const file = decodeBase64(input.fileContent ?? ''); + if (!file) { + errors.push('fileContent must be valid base64'); + return {}; + } + if (file.length !== input.fileSize) { + errors.push(`Declared file size (${input.fileSize} bytes) does not match uploaded content (${file.length} bytes)`); + } + if (KYC_ALLOWED_MIME_TYPES.includes(mimeType) && !validateMagicBytes(file, mimeType)) { + errors.push(`File contents are not a valid ${mimeType} file`); + } + + return { file, sha256: createHash('sha256').update(file).digest('hex') }; +} + +function validateIdentityFields( + input: KycDocumentInput, + now: Date, + errors: string[], + warnings: string[], +): string | undefined { + let normalizedNumber: string | undefined; + + if (!input.documentNumber) { + errors.push('Document number is required for identity documents'); + } else { + normalizedNumber = normalizeDocumentNumber(input.documentNumber); + const pattern = getDocumentNumberPattern(input.type, input.issuingCountry); + if (pattern && !pattern.test(normalizedNumber)) { + errors.push(`Document number format is not valid for a ${input.issuingCountry.toUpperCase()} ${input.type.replace(/_/g, ' ')}`); + } + } + + const today = startOfUtcDay(now); + const issueDate = input.issueDate ? parseIsoDate(input.issueDate) : undefined; + if (input.issueDate && !issueDate) errors.push('Issue date must be a valid date in YYYY-MM-DD format'); + if (issueDate && issueDate > today) errors.push('Issue date cannot be in the future'); + + if (!input.expiryDate) { + errors.push('Expiry date is required for identity documents'); + return normalizedNumber; + } + + const expiryDate = parseIsoDate(input.expiryDate); + if (!expiryDate) { + errors.push('Expiry date must be a valid date in YYYY-MM-DD format'); + return normalizedNumber; + } + + if (expiryDate <= today) { + errors.push(`Document expired on ${input.expiryDate}`); + } else if (expiryDate.getTime() - today.getTime() <= EXPIRY_WARNING_DAYS * DAY_MS) { + warnings.push(`Document expires within ${EXPIRY_WARNING_DAYS} days`); + } + + const latestExpiry = new Date(today); + latestExpiry.setUTCFullYear(latestExpiry.getUTCFullYear() + MAX_EXPIRY_YEARS); + if (expiryDate > latestExpiry) errors.push('Expiry date is too far in the future'); + + if (issueDate && issueDate >= expiryDate) errors.push('Issue date must be before the expiry date'); + + return normalizedNumber; +} + +function validateAddressFields(input: KycDocumentInput, now: Date, errors: string[]): void { + if (!input.issueDate) { + errors.push('Issue date is required for proof-of-address documents'); + return; + } + + const issueDate = parseIsoDate(input.issueDate); + if (!issueDate) { + errors.push('Issue date must be a valid date in YYYY-MM-DD format'); + return; + } + + const today = startOfUtcDay(now); + if (issueDate > today) { + errors.push('Issue date cannot be in the future'); + } else if (today.getTime() - issueDate.getTime() > PROOF_OF_ADDRESS_MAX_AGE_DAYS * DAY_MS) { + errors.push(`Proof of address must be issued within the last ${PROOF_OF_ADDRESS_MAX_AGE_DAYS} days`); + } +} + +/** Runs every local format check for a KYC document submission. */ +export function validateKycDocument(input: KycDocumentInput, now: Date = new Date()): DocumentValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + let normalizedDocumentNumber: string | undefined; + + if (!DOCUMENT_TYPES.includes(input.type)) { + errors.push(`Unsupported document type "${input.type}"`); + } + + if (!/^[A-Z]{2}$/i.test(input.issuingCountry)) { + errors.push('Issuing country must be a 2-letter ISO country code'); + } + + const { file, sha256 } = validateFile(input, errors); + + if (isIdentityDocument(input.type)) { + normalizedDocumentNumber = validateIdentityFields(input, now, errors, warnings); + } else if (isAddressDocument(input.type)) { + validateAddressFields(input, now, errors); + } + + return { + valid: errors.length === 0, + errors, + warnings, + normalizedDocumentNumber, + file, + sha256, + }; +} + +// ── Verification providers ─────────────────────────────────────────────────── + +export type ProviderDecision = 'approved' | 'rejected' | 'needs_review'; + +export interface DocumentVerificationRequest { + userId: string; + documentId: string; + type: DocumentType; + issuingCountry: string; + documentNumber?: string; + issueDate?: string; + expiryDate?: string; + mimeType: string; + sha256?: string; + file?: Buffer; + fileUrl?: string; + personalInfo?: KycPersonalInfo; +} + +export interface DocumentVerificationResult { + decision: ProviderDecision; + reasons: string[]; + /** Provider confidence in the decision, 0..1. */ + confidence: number; + /** Provider-side identifier for the check, kept for audit. */ + reference: string; +} + +/** + * Contract for third-party document verification vendors. Implementations + * should return `needs_review` rather than throw when a check is inconclusive. + */ +export interface DocumentVerificationProvider { + readonly name: string; + verify(request: DocumentVerificationRequest): Promise; +} + +/** + * Deterministic sandbox provider. Document numbers ending in `000` are + * rejected as lost/stolen and numbers ending in `999` are routed to manual + * review; everything else that passed local validation is approved. + */ +export class MockDocumentVerificationProvider implements DocumentVerificationProvider { + readonly name = 'mock'; + + async verify(request: DocumentVerificationRequest): Promise { + const reference = `mock_${randomUUID()}`; + const documentNumber = request.documentNumber ?? ''; + + if (documentNumber.endsWith('000')) { + return { + decision: 'rejected', + reasons: ['Document number is reported as lost or stolen'], + confidence: 0.99, + reference, + }; + } + + if (documentNumber.endsWith('999')) { + return { + decision: 'needs_review', + reasons: ['Automated checks were inconclusive; manual review required'], + confidence: 0.5, + reference, + }; + } + + return { decision: 'approved', reasons: [], confidence: 0.95, reference }; + } +} From 9b3d3b7b002b72c48ec2ea44276633fa76b15be9 Mon Sep 17 00:00:00 2001 From: Itodo-S Date: Mon, 28 Sep 2026 09:50:27 +0100 Subject: [PATCH 2/4] feat(backend): add KYC onboarding flow and API Extend KycService with a personal info -> documents -> review flow: provider-backed document checks, duplicate document detection, status transitions (pending, under_review, approved, rejected, expired) with recorded history and reasons, automatic approval for low-risk applications and a manual review queue. Expose it under /api/v1/kyc, allow larger JSON bodies for base64 document uploads and redact document numbers, file contents and dates of birth from audit logs. Also makes the two timestamp-based KycService tests deterministic. Refs #921 --- backend/src/index.ts | 4 + backend/src/middleware/request-size-limit.ts | 2 +- backend/src/routes/__tests__/kyc.test.ts | 250 ++++++++ backend/src/routes/kyc.ts | 140 +++++ backend/src/schemas/kyc.ts | 72 +++ .../services/__tests__/kyc-onboarding.test.ts | 383 +++++++++++++ backend/src/services/__tests__/kyc.test.ts | 35 +- backend/src/services/auditService.ts | 5 +- backend/src/services/kyc.ts | 539 +++++++++++++++++- 9 files changed, 1405 insertions(+), 25 deletions(-) create mode 100644 backend/src/routes/__tests__/kyc.test.ts create mode 100644 backend/src/routes/kyc.ts create mode 100644 backend/src/schemas/kyc.ts create mode 100644 backend/src/services/__tests__/kyc-onboarding.test.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 560192e5..e302d0c4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -56,6 +56,7 @@ import { hedgingRouter } from './routes/hedging.js'; import { complianceRouter } from './routes/compliance.js'; import { gdprRouter } from './routes/gdpr.js'; import { kybRouter } from './routes/kyb.js'; +import { kycRouter } from './routes/kyc.js'; import { batchRouter } from './routes/batch.js'; import { relayerRouter } from './routes/relayer.js'; import { paymentQueueRouter } from './routes/payment-queue.js'; @@ -197,6 +198,8 @@ app.use(httpLogger); // Incoming webhooks: raw body capture before global JSON parser (#393) app.use('/webhooks', webhookHandlersRouter); +// KYC document uploads carry base64 file contents (up to 10 MB decoded) (#921) +app.use('/api/v1/kyc', express.json({ limit: '15mb' })); app.use(express.json()); app.use(express.text({ type: ['text/csv', 'text/plain'] })); @@ -249,6 +252,7 @@ apiV1Router.use('/flags', flagsRouter); apiV1Router.use('/rate-limit', rateLimitAnalyticsRouter); apiV1Router.use('/zk-identity', zkIdentityRouter); apiV1Router.use('/kyb', kybRouter); +apiV1Router.use('/kyc', kycRouter); apiV1Router.use('/batch', batchRouter); apiV1Router.use('/relayer', relayerRouter); apiV1Router.use('/queue/payments', paymentQueueRouter); diff --git a/backend/src/middleware/request-size-limit.ts b/backend/src/middleware/request-size-limit.ts index 3de7eb95..53ecb243 100644 --- a/backend/src/middleware/request-size-limit.ts +++ b/backend/src/middleware/request-size-limit.ts @@ -15,7 +15,7 @@ export interface RequestSizeLimitOptions { export function requestSizeLimit(options: RequestSizeLimitOptions = {}) { const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; const uploadMaxBytes = options.uploadMaxBytes ?? UPLOAD_MAX_BYTES; - const uploadPaths = options.uploadPaths ?? ['/api/v1/uploads', '/api/v1/forms']; + const uploadPaths = options.uploadPaths ?? ['/api/v1/uploads', '/api/v1/forms', '/api/v1/kyc']; return (req: Request, res: Response, next: NextFunction) => { const contentLength = Number(req.headers['content-length'] ?? 0); diff --git a/backend/src/routes/__tests__/kyc.test.ts b/backend/src/routes/__tests__/kyc.test.ts new file mode 100644 index 00000000..09666839 --- /dev/null +++ b/backend/src/routes/__tests__/kyc.test.ts @@ -0,0 +1,250 @@ +import express, { type Express } from 'express'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { AddressInfo } from 'node:net'; +import { kycRouter } from '../kyc.js'; +import { errorHandler } from '../../middleware/errorHandler.js'; + +// Vitest resolves these `.js` specifiers to the stale compiled CommonJS files +// that sit next to the middleware sources; use the TypeScript implementations +// that the build actually ships. +vi.mock('../../middleware/errorHandler.js', () => import('../../middleware/errorHandler.ts')); +vi.mock('../../middleware/validate.js', () => import('../../middleware/validate.ts')); + +let server: import('node:http').Server; +let base = ''; + +const pdf = Buffer.concat([Buffer.from('%PDF-1.4\n'), Buffer.alloc(4096, 0x20)]); + +function isoDaysFromNow(days: number): string { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); +} + +const personalInfo = { + firstName: 'Grace', + lastName: 'Hopper', + dateOfBirth: '1985-12-09', + nationality: 'us', + countryOfResidence: 'US', + address: { line1: '1 Navy Way', city: 'Arlington', state: 'VA', postalCode: '22202', country: 'US' }, +}; + +function passport(documentNumber: string) { + return { + type: 'passport', + documentNumber, + issuingCountry: 'US', + expiryDate: isoDaysFromNow(365 * 5), + fileName: 'passport.pdf', + mimeType: 'application/pdf', + fileSize: pdf.length, + fileContent: pdf.toString('base64'), + }; +} + +const utilityBill = { + type: 'utility_bill', + issuingCountry: 'US', + issueDate: isoDaysFromNow(-10), + fileName: 'bill.pdf', + mimeType: 'application/pdf', + fileSize: pdf.length, + fileContent: pdf.toString('base64'), +}; + +interface ErrorBody { + error: { code: string; message: string; details?: string[] }; +} + +async function call( + method: 'GET' | 'POST' | 'PUT', + path: string, + body?: unknown, +): Promise<{ status: number; body: T }> { + const res = await fetch(`${base}${path}`, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + return { + status: res.status, + body: text ? (JSON.parse(text) as T) : (undefined as T), + }; +} + +describe('kyc http api', () => { + beforeAll(async () => { + const app: Express = express(); + app.use('/api/v1/kyc', express.json({ limit: '15mb' })); + app.use('/api/v1/kyc', kycRouter); + app.use(errorHandler); + server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + it('GET /requirements describes the document rules', async () => { + const res = await call<{ allowedMimeTypes: string[]; maxFileBytes: number; identityDocumentTypes: string[] }>( + 'GET', + '/api/v1/kyc/requirements', + ); + expect(res.status).toBe(200); + expect(res.body.allowedMimeTypes).toContain('application/pdf'); + expect(res.body.maxFileBytes).toBe(10 * 1024 * 1024); + expect(res.body.identityDocumentTypes).toEqual(['passport', 'drivers_license', 'national_id']); + }); + + it('GET /:userId returns 404 before onboarding starts', async () => { + const res = await call('GET', '/api/v1/kyc/nobody'); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe('KYC_NOT_FOUND'); + }); + + it('runs the full onboarding flow to an automatic approval', async () => { + const info = await call<{ profile: { status: string; onboardingStep: string } }>( + 'PUT', + '/api/v1/kyc/user-flow/personal-info', + personalInfo, + ); + expect(info.status).toBe(200); + expect(info.body.profile.status).toBe('pending'); + expect(info.body.profile.onboardingStep).toBe('documents'); + + const id = await call<{ document: { status: string; documentNumberMasked: string } }>( + 'POST', + '/api/v1/kyc/user-flow/documents', + passport('A12345678'), + ); + expect(id.status).toBe(201); + expect(id.body.document.status).toBe('approved'); + expect(id.body.document.documentNumberMasked).toBe('*****5678'); + + const address = await call<{ state: { canSubmit: boolean } }>( + 'POST', + '/api/v1/kyc/user-flow/documents', + utilityBill, + ); + expect(address.status).toBe(201); + expect(address.body.state.canSubmit).toBe(true); + + const submitted = await call<{ profile: { status: string; onboardingStep: string } }>( + 'POST', + '/api/v1/kyc/user-flow/submit', + ); + expect(submitted.status).toBe(200); + expect(submitted.body.profile.status).toBe('approved'); + expect(submitted.body.profile.onboardingStep).toBe('complete'); + }); + + it('rejects malformed personal info with field-level errors', async () => { + const res = await call<{ error: string; details: Array<{ path: string }> }>( + 'PUT', + '/api/v1/kyc/user-bad/personal-info', + { ...personalInfo, nationality: 'USA', dateOfBirth: '09/12/1985' }, + ); + expect(res.status).toBe(400); + expect(res.body.error).toBe('VALIDATION_FAILED'); + expect(res.body.details.map((d) => d.path)).toEqual(expect.arrayContaining(['nationality', 'dateOfBirth'])); + }); + + it('rejects underage applicants with a reason', async () => { + const res = await call('PUT', '/api/v1/kyc/user-young/personal-info', { + ...personalInfo, + dateOfBirth: isoDaysFromNow(-365 * 10), + }); + expect(res.status).toBe(422); + expect(res.body.error.code).toBe('INVALID_PERSONAL_INFO'); + expect(res.body.error.details).toEqual(['Applicant must be at least 18 years old']); + }); + + it('returns validation reasons for an invalid document', async () => { + await call('PUT', '/api/v1/kyc/user-doc/personal-info', personalInfo); + + const res = await call('POST', '/api/v1/kyc/user-doc/documents', { + ...passport('BAD'), + expiryDate: isoDaysFromNow(-1), + mimeType: 'image/png', + }); + expect(res.status).toBe(422); + expect(res.body.error.code).toBe('INVALID_DOCUMENT'); + expect(res.body.error.details).toEqual( + expect.arrayContaining([ + 'File extension ".pdf" does not match file type image/png', + 'File contents are not a valid image/png file', + 'Document number format is not valid for a US passport', + `Document expired on ${isoDaysFromNow(-1)}`, + ]), + ); + }); + + it('requires a file source for document uploads', async () => { + const withoutFile: Partial> = passport('A12345678'); + delete withoutFile.fileContent; + const res = await call<{ error: string; details: Array<{ path: string }> }>( + 'POST', + '/api/v1/kyc/user-doc/documents', + withoutFile, + ); + expect(res.status).toBe(400); + expect(res.body.details[0].path).toBe('fileContent'); + }); + + it('blocks submission until requirements are met', async () => { + await call('PUT', '/api/v1/kyc/user-partial/personal-info', personalInfo); + const res = await call('POST', '/api/v1/kyc/user-partial/submit'); + expect(res.status).toBe(422); + expect(res.body.error.code).toBe('REQUIREMENTS_NOT_MET'); + expect(res.body.error.details).toHaveLength(2); + }); + + it('supports the manual review queue with rejection reasons', async () => { + await call('PUT', '/api/v1/kyc/user-review/personal-info', personalInfo); + const flagged = await call<{ document: { id: string; status: string } }>( + 'POST', + '/api/v1/kyc/user-review/documents', + passport('B12345999'), + ); + expect(flagged.body.document.status).toBe('pending'); + await call('POST', '/api/v1/kyc/user-review/documents', utilityBill); + + const submitted = await call<{ profile: { status: string } }>('POST', '/api/v1/kyc/user-review/submit'); + expect(submitted.body.profile.status).toBe('under_review'); + + const queue = await call<{ items: Array<{ userId: string }> }>('GET', '/api/v1/kyc/review/queue'); + expect(queue.body.items.map((p) => p.userId)).toContain('user-review'); + + const missingReason = await call<{ error: string }>('POST', '/api/v1/kyc/user-review/review', { + decision: 'rejected', + reviewerId: 'admin-1', + }); + expect(missingReason.status).toBe(400); + + const docReview = await call<{ status: string; rejectionReasons: string[] }>( + 'POST', + `/api/v1/kyc/user-review/documents/${flagged.body.document.id}/review`, + { approved: false, reasons: ['Photo page is cropped'] }, + ); + expect(docReview.status).toBe(200); + expect(docReview.body.status).toBe('rejected'); + + const rejected = await call<{ profile: { status: string; rejectionReasons: string[] } }>( + 'POST', + '/api/v1/kyc/user-review/review', + { decision: 'rejected', reviewerId: 'admin-1', reasons: ['Photo page is cropped'] }, + ); + expect(rejected.status).toBe(200); + expect(rejected.body.profile.status).toBe('rejected'); + expect(rejected.body.profile.rejectionReasons).toEqual(['Photo page is cropped']); + + const again = await call('POST', '/api/v1/kyc/user-review/review', { + decision: 'approved', + reviewerId: 'admin-1', + }); + expect(again.status).toBe(409); + expect(again.body.error.code).toBe('INVALID_STATUS_TRANSITION'); + }); +}); diff --git a/backend/src/routes/kyc.ts b/backend/src/routes/kyc.ts new file mode 100644 index 00000000..fca4b141 --- /dev/null +++ b/backend/src/routes/kyc.ts @@ -0,0 +1,140 @@ +// Issue #921: KYC onboarding API. +// +// Applicant flow: personal info → document upload (validated locally, then +// checked by the verification provider) → submit for review → status. +// Reviewer endpoints cover the manual review queue and decisions. + +import { Router, type RequestHandler } from 'express'; +import { validate } from '../middleware/validate.js'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; +import { KycError, kycService } from '../services/kyc.js'; +import { + ADDRESS_DOCUMENT_TYPES, + EXPIRY_WARNING_DAYS, + IDENTITY_DOCUMENT_TYPES, + KYC_ALLOWED_MIME_TYPES, + KYC_MAX_FILE_BYTES, + KYC_MIN_FILE_BYTES, + MINIMUM_AGE, + PROOF_OF_ADDRESS_MAX_AGE_DAYS, +} from '../services/kyc-verification.js'; +import { + kycDocumentReviewSchema, + kycDocumentUploadSchema, + kycPersonalInfoSchema, + kycReviewSchema, +} from '../schemas/kyc.js'; + +export const kycRouter = Router(); + +function param(value: string | string[]): string { + return Array.isArray(value) ? value[0] : value; +} + +// Maps service errors onto the API error envelope. +function kycHandler(handler: Parameters[0]): RequestHandler { + return asyncHandler(async (req, res, next) => { + try { + await handler(req, res, next); + } catch (error) { + if (error instanceof KycError) { + throw new AppError(error.statusCode, error.message, error.code, error.details); + } + throw error; + } + }); +} + +// Document rules for building the upload step client-side +kycRouter.get( + '/requirements', + kycHandler(async (_req, res) => { + res.json({ + identityDocumentTypes: IDENTITY_DOCUMENT_TYPES, + addressDocumentTypes: ADDRESS_DOCUMENT_TYPES, + allowedMimeTypes: KYC_ALLOWED_MIME_TYPES, + maxFileBytes: KYC_MAX_FILE_BYTES, + minFileBytes: KYC_MIN_FILE_BYTES, + proofOfAddressMaxAgeDays: PROOF_OF_ADDRESS_MAX_AGE_DAYS, + expiryWarningDays: EXPIRY_WARNING_DAYS, + minimumAge: MINIMUM_AGE, + }); + }) +); + +// Manual review queue +kycRouter.get( + '/review/queue', + kycHandler(async (_req, res) => { + res.json({ items: kycService.listForReview() }); + }) +); + +// Onboarding state for an applicant +kycRouter.get( + '/:userId', + kycHandler(async (req, res) => { + const state = kycService.getOnboardingState(param(req.params.userId)); + if (!state) throw new AppError(404, 'KYC profile not found', 'KYC_NOT_FOUND'); + res.json(state); + }) +); + +// Step 1: personal information +kycRouter.put( + '/:userId/personal-info', + validate(kycPersonalInfoSchema), + kycHandler(async (req, res) => { + const userId = param(req.params.userId); + kycService.savePersonalInfo(userId, req.body); + res.json(kycService.getOnboardingState(userId)); + }) +); + +// Step 2: document upload and verification +kycRouter.post( + '/:userId/documents', + validate(kycDocumentUploadSchema), + kycHandler(async (req, res) => { + const userId = param(req.params.userId); + const document = await kycService.uploadDocument(userId, req.body); + res.status(201).json({ document, state: kycService.getOnboardingState(userId) }); + }) +); + +// Step 3: submit for review +kycRouter.post( + '/:userId/submit', + kycHandler(async (req, res) => { + const userId = param(req.params.userId); + kycService.submitForReview(userId); + res.json(kycService.getOnboardingState(userId)); + }) +); + +// Reviewer decision on a single document +kycRouter.post( + '/:userId/documents/:documentId/review', + validate(kycDocumentReviewSchema), + kycHandler(async (req, res) => { + const { approved, reasons } = req.body; + const document = kycService.reviewDocument( + param(req.params.userId), + param(req.params.documentId), + approved, + reasons, + ); + res.json(document); + }) +); + +// Reviewer decision on the application +kycRouter.post( + '/:userId/review', + validate(kycReviewSchema), + kycHandler(async (req, res) => { + const userId = param(req.params.userId); + kycService.review(userId, req.body); + res.json(kycService.getOnboardingState(userId)); + }) +); diff --git a/backend/src/schemas/kyc.ts b/backend/src/schemas/kyc.ts new file mode 100644 index 00000000..04cf675c --- /dev/null +++ b/backend/src/schemas/kyc.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; + +const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Format: YYYY-MM-DD'); + +const countryCode = z + .string() + .regex(/^[A-Za-z]{2}$/, 'Use a 2-letter ISO country code') + .transform((value) => value.toUpperCase()); + +export const kycDocumentTypeSchema = z.enum([ + 'passport', + 'drivers_license', + 'national_id', + 'utility_bill', + 'bank_statement', +]); + +export const kycPersonalInfoSchema = z.object({ + firstName: z.string().trim().min(1, 'First name is required').max(100), + lastName: z.string().trim().min(1, 'Last name is required').max(100), + dateOfBirth: isoDate, + nationality: countryCode, + countryOfResidence: countryCode, + address: z.object({ + line1: z.string().trim().min(1, 'Address is required').max(200), + line2: z.string().trim().max(200).optional(), + city: z.string().trim().min(1, 'City is required').max(100), + state: z.string().trim().max(100).optional(), + postalCode: z.string().trim().min(2).max(12), + country: countryCode, + }), +}); + +export const kycDocumentUploadSchema = z + .object({ + type: kycDocumentTypeSchema, + documentNumber: z.string().trim().max(40).optional(), + issuingCountry: countryCode, + issueDate: isoDate.optional(), + expiryDate: isoDate.optional(), + fileName: z.string().trim().min(1, 'File name is required').max(255), + mimeType: z.string().trim().min(1, 'MIME type is required'), + fileSize: z.number().int().positive('File size must be positive'), + fileContent: z.string().min(1).optional(), + fileUrl: z.string().url().optional(), + }) + .refine((data) => data.fileContent !== undefined || data.fileUrl !== undefined, { + message: 'Provide fileContent or fileUrl', + path: ['fileContent'], + }); + +export const kycReviewSchema = z + .object({ + decision: z.enum(['approved', 'rejected']), + reviewerId: z.string().trim().min(1, 'Reviewer ID is required'), + reasons: z.array(z.string().trim().min(1)).optional(), + notes: z.string().trim().max(1000).optional(), + }) + .refine((data) => data.decision === 'approved' || (data.reasons?.length ?? 0) > 0, { + message: 'At least one reason is required when rejecting', + path: ['reasons'], + }); + +export const kycDocumentReviewSchema = z + .object({ + approved: z.boolean(), + reasons: z.array(z.string().trim().min(1)).optional(), + }) + .refine((data) => data.approved || (data.reasons?.length ?? 0) > 0, { + message: 'At least one reason is required when rejecting a document', + path: ['reasons'], + }); diff --git a/backend/src/services/__tests__/kyc-onboarding.test.ts b/backend/src/services/__tests__/kyc-onboarding.test.ts new file mode 100644 index 00000000..d6ee7b87 --- /dev/null +++ b/backend/src/services/__tests__/kyc-onboarding.test.ts @@ -0,0 +1,383 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { KycError, KycService, canTransition, type KycPersonalInfo } from '../kyc.js'; +import type { + DocumentVerificationProvider, + DocumentVerificationRequest, + KycDocumentInput, +} from '../kyc-verification.js'; + +const pdf = Buffer.concat([Buffer.from('%PDF-1.4\n'), Buffer.alloc(2048, 0x20)]); + +const person: KycPersonalInfo = { + firstName: 'Ada', + lastName: 'Lovelace', + dateOfBirth: '1990-04-12', + nationality: 'gb', + countryOfResidence: 'GB', + address: { line1: '1 Main St', city: 'London', postalCode: 'N1 9GU', country: 'gb' }, +}; + +function passport(overrides: Partial = {}): KycDocumentInput { + return { + type: 'passport', + documentNumber: '123456789', + issuingCountry: 'GB', + expiryDate: '2031-01-01', + fileName: 'passport.pdf', + mimeType: 'application/pdf', + fileSize: pdf.length, + fileContent: pdf.toString('base64'), + ...overrides, + }; +} + +function bankStatement(overrides: Partial = {}): KycDocumentInput { + return { + type: 'bank_statement', + issuingCountry: 'GB', + issueDate: '2026-06-01', + fileName: 'statement.pdf', + mimeType: 'application/pdf', + fileSize: pdf.length, + fileContent: pdf.toString('base64'), + ...overrides, + }; +} + +async function expectKycError(promise: Promise | (() => unknown), code: string, statusCode: number) { + let error: unknown; + try { + await (typeof promise === 'function' ? promise() : promise); + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(KycError); + expect((error as KycError).code).toBe(code); + expect((error as KycError).statusCode).toBe(statusCode); + return error as KycError; +} + +describe('KYC onboarding flow', () => { + let service: KycService; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-15T12:00:00.000Z')); + service = new KycService(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('canTransition', () => { + it('allows only the documented status transitions', () => { + expect(canTransition('pending', 'under_review')).toBe(true); + expect(canTransition('under_review', 'approved')).toBe(true); + expect(canTransition('under_review', 'rejected')).toBe(true); + expect(canTransition('rejected', 'pending')).toBe(true); + expect(canTransition('approved', 'expired')).toBe(true); + + expect(canTransition('pending', 'approved')).toBe(false); + expect(canTransition('rejected', 'approved')).toBe(false); + expect(canTransition('approved', 'pending')).toBe(false); + }); + }); + + describe('savePersonalInfo', () => { + it('creates a pending profile and advances to the documents step', () => { + const profile = service.savePersonalInfo('user-1', person); + + expect(profile.status).toBe('pending'); + expect(profile.onboardingStep).toBe('documents'); + expect(profile.personalInfo?.nationality).toBe('GB'); + expect(profile.personalInfo?.address.country).toBe('GB'); + expect(profile.statusHistory).toEqual([ + expect.objectContaining({ from: null, to: 'pending', actor: 'user-1', reason: 'Onboarding started' }), + ]); + }); + + it('rejects underage applicants', async () => { + const error = await expectKycError( + () => service.savePersonalInfo('user-1', { ...person, dateOfBirth: '2010-01-01' }), + 'INVALID_PERSONAL_INFO', + 422, + ); + expect(error.details).toEqual(['Applicant must be at least 18 years old']); + expect(service.getProfile('user-1')).toBeUndefined(); + }); + + it('locks personal info while the application is under review', async () => { + service = new KycService({ autoApprove: false }); + service.savePersonalInfo('user-1', person); + await service.uploadDocument('user-1', passport()); + await service.uploadDocument('user-1', bankStatement()); + service.submitForReview('user-1'); + + await expectKycError(() => service.savePersonalInfo('user-1', person), 'KYC_LOCKED', 409); + }); + }); + + describe('uploadDocument', () => { + it('requires personal info first', async () => { + await expectKycError(service.uploadDocument('user-1', passport()), 'PERSONAL_INFO_REQUIRED', 409); + }); + + it('stores verified document metadata without the raw number or contents', async () => { + service.savePersonalInfo('user-1', person); + const doc = await service.uploadDocument('user-1', passport()); + + expect(doc.status).toBe('approved'); + expect(doc.verified).toBe(true); + expect(doc.documentNumberMasked).toBe('*****6789'); + expect(doc.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(doc.verification).toEqual( + expect.objectContaining({ provider: 'mock', decision: 'approved', confidence: 0.95 }), + ); + expect(JSON.stringify(doc)).not.toContain('123456789'); + expect(JSON.stringify(doc)).not.toContain(pdf.toString('base64')); + }); + + it('returns validation reasons for invalid documents', async () => { + service.savePersonalInfo('user-1', person); + const error = await expectKycError( + service.uploadDocument('user-1', passport({ expiryDate: '2026-01-01' })), + 'INVALID_DOCUMENT', + 422, + ); + expect(error.details).toEqual(['Document expired on 2026-01-01']); + expect(service.getProfile('user-1')!.documents).toHaveLength(0); + }); + + it('records provider rejections with reasons', async () => { + service.savePersonalInfo('user-1', person); + const doc = await service.uploadDocument('user-1', passport({ documentNumber: '123456000' })); + + expect(doc.status).toBe('rejected'); + expect(doc.rejectionReasons).toEqual(['Document number is reported as lost or stolen']); + expect(service.getOnboardingState('user-1')!.requirements[0].satisfied).toBe(false); + }); + + it('keeps inconclusive documents pending with a warning', async () => { + service.savePersonalInfo('user-1', person); + const doc = await service.uploadDocument('user-1', passport({ documentNumber: '123456999' })); + + expect(doc.status).toBe('pending'); + expect(doc.warnings).toContain('Automated checks were inconclusive; manual review required'); + }); + + it('falls back to manual review when the provider fails', async () => { + const failing: DocumentVerificationProvider = { + name: 'failing', + verify: vi.fn().mockRejectedValue(new Error('timeout')), + }; + service = new KycService({ verificationProvider: failing }); + service.savePersonalInfo('user-1', person); + const doc = await service.uploadDocument('user-1', passport()); + + expect(doc.status).toBe('pending'); + expect(doc.verification?.provider).toBe('failing'); + expect(doc.warnings).toContain('Verification provider unavailable; document queued for manual review'); + }); + + it('passes the decoded file and applicant details to the provider', async () => { + const verify = vi.fn(async (_req: DocumentVerificationRequest) => ({ + decision: 'approved' as const, + reasons: [], + confidence: 1, + reference: 'ref-1', + })); + service = new KycService({ verificationProvider: { name: 'custom', verify } }); + service.savePersonalInfo('user-1', person); + await service.uploadDocument('user-1', passport({ documentNumber: '1234 56789' })); + + const request = verify.mock.calls[0][0]; + expect(request.documentNumber).toBe('123456789'); + expect(request.file?.equals(pdf)).toBe(true); + expect(request.personalInfo?.lastName).toBe('Lovelace'); + }); + + it('replaces an earlier upload of the same document type', async () => { + service.savePersonalInfo('user-1', person); + await service.uploadDocument('user-1', passport({ documentNumber: '123456000' })); + const replacement = await service.uploadDocument('user-1', passport()); + + const documents = service.getProfile('user-1')!.documents; + expect(documents).toHaveLength(1); + expect(documents[0].id).toBe(replacement.id); + expect(documents[0].status).toBe('approved'); + }); + + it('prevents one identity document from being used by two accounts', async () => { + service.savePersonalInfo('user-1', person); + service.savePersonalInfo('user-2', person); + await service.uploadDocument('user-1', passport()); + + const error = await expectKycError(service.uploadDocument('user-2', passport()), 'DUPLICATE_DOCUMENT', 409); + expect(error.details).toEqual(['This document is already linked to another account']); + }); + }); + + describe('submitForReview', () => { + it('refuses to submit until every requirement is met', async () => { + service.savePersonalInfo('user-1', person); + await service.uploadDocument('user-1', passport()); + + const error = await expectKycError(() => service.submitForReview('user-1'), 'REQUIREMENTS_NOT_MET', 422); + expect(error.details).toEqual(['Proof of address issued in the last 90 days']); + expect(service.getOnboardingState('user-1')!.canSubmit).toBe(false); + }); + + it('returns 404 for unknown applicants', async () => { + await expectKycError(() => service.submitForReview('ghost'), 'KYC_NOT_FOUND', 404); + }); + + it('auto-approves low-risk applications with verified documents', async () => { + service.savePersonalInfo('user-1', person); + await service.uploadDocument('user-1', passport()); + await service.uploadDocument('user-1', bankStatement()); + expect(service.getOnboardingState('user-1')!.canSubmit).toBe(true); + + const profile = service.submitForReview('user-1'); + + expect(profile.status).toBe('approved'); + expect(profile.onboardingStep).toBe('complete'); + expect(profile.reviewedBy).toBe('system'); + expect(profile.expiresAt).toBe('2027-06-15T12:00:00.000Z'); + expect(service.isKycComplete('user-1')).toBe(true); + expect(profile.statusHistory.map(h => h.to)).toEqual(['pending', 'under_review', 'approved']); + }); + + it('holds applications with unverified documents for manual review', async () => { + service.savePersonalInfo('user-1', person); + await service.uploadDocument('user-1', passport({ documentNumber: '123456999' })); + await service.uploadDocument('user-1', bankStatement()); + + const profile = service.submitForReview('user-1'); + + expect(profile.status).toBe('under_review'); + expect(profile.onboardingStep).toBe('review'); + expect(service.listForReview().map(p => p.userId)).toEqual(['user-1']); + }); + + it('holds high-risk jurisdictions for manual review', async () => { + service.savePersonalInfo('user-1', { ...person, countryOfResidence: 'IR' }); + await service.uploadDocument('user-1', passport()); + await service.uploadDocument('user-1', bankStatement()); + + const profile = service.submitForReview('user-1'); + + expect(profile.status).toBe('under_review'); + expect(service.assessRisk('user-1').factors).toContain('Nationality or residence in a high-risk jurisdiction'); + }); + + it('cannot be submitted twice', async () => { + service = new KycService({ autoApprove: false }); + service.savePersonalInfo('user-1', person); + await service.uploadDocument('user-1', passport()); + await service.uploadDocument('user-1', bankStatement()); + service.submitForReview('user-1'); + + await expectKycError(() => service.submitForReview('user-1'), 'INVALID_STATUS_TRANSITION', 409); + await expectKycError(service.uploadDocument('user-1', bankStatement()), 'KYC_LOCKED', 409); + }); + }); + + describe('review', () => { + beforeEach(async () => { + service = new KycService({ autoApprove: false }); + service.savePersonalInfo('user-1', person); + await service.uploadDocument('user-1', passport()); + await service.uploadDocument('user-1', bankStatement()); + service.submitForReview('user-1'); + }); + + it('approves with reviewer details and a one-year expiry', () => { + const profile = service.review('user-1', { decision: 'approved', reviewerId: 'admin-1', notes: 'Looks good' }); + + expect(profile.status).toBe('approved'); + expect(profile.reviewedBy).toBe('admin-1'); + expect(profile.reviewNotes).toBe('Looks good'); + expect(profile.expiresAt).toBe('2027-06-15T12:00:00.000Z'); + expect(profile.statusHistory.at(-1)).toEqual( + expect.objectContaining({ from: 'under_review', to: 'approved', actor: 'admin-1' }), + ); + }); + + it('rejects with reasons and lets the applicant resubmit', async () => { + const rejected = service.review('user-1', { + decision: 'rejected', + reviewerId: 'admin-1', + reasons: ['Name on bank statement does not match'], + }); + expect(rejected.status).toBe('rejected'); + expect(rejected.rejectionReasons).toEqual(['Name on bank statement does not match']); + expect(rejected.onboardingStep).toBe('documents'); + + await service.uploadDocument('user-1', bankStatement({ fileName: 'statement-2.pdf' })); + const state = service.getOnboardingState('user-1')!; + expect(state.profile.status).toBe('pending'); + expect(state.canSubmit).toBe(true); + expect(state.profile.statusHistory.at(-1)).toEqual( + expect.objectContaining({ from: 'rejected', to: 'pending', reason: 'Documents resubmitted' }), + ); + }); + + it('requires a reason to reject', async () => { + await expectKycError( + () => service.review('user-1', { decision: 'rejected', reviewerId: 'admin-1', reasons: [' '] }), + 'REJECTION_REASON_REQUIRED', + 400, + ); + expect(service.getProfile('user-1')!.status).toBe('under_review'); + }); + + it('does not allow deciding an application twice', async () => { + service.review('user-1', { decision: 'approved', reviewerId: 'admin-1' }); + await expectKycError( + () => service.review('user-1', { decision: 'rejected', reviewerId: 'admin-1', reasons: ['x'] }), + 'INVALID_STATUS_TRANSITION', + 409, + ); + }); + }); + + describe('reviewDocument', () => { + it('lets a reviewer resolve a document flagged for manual review', async () => { + service.savePersonalInfo('user-1', person); + const doc = await service.uploadDocument('user-1', passport({ documentNumber: '123456999' })); + + const rejected = service.reviewDocument('user-1', doc.id, false, ['Photo is blurred']); + expect(rejected.status).toBe('rejected'); + expect(rejected.rejectionReasons).toEqual(['Photo is blurred']); + + const approved = service.reviewDocument('user-1', doc.id, true); + expect(approved.status).toBe('approved'); + expect(approved.rejectionReasons).toBeUndefined(); + }); + + it('returns 404 for documents that belong to someone else', async () => { + service.savePersonalInfo('user-1', person); + service.savePersonalInfo('user-2', person); + const doc = await service.uploadDocument('user-1', passport()); + + await expectKycError(() => service.reviewDocument('user-2', doc.id, true), 'DOCUMENT_NOT_FOUND', 404); + }); + }); + + describe('expiry', () => { + it('expires approvals after a year and allows re-verification', async () => { + service.savePersonalInfo('user-1', person); + await service.uploadDocument('user-1', passport()); + await service.uploadDocument('user-1', bankStatement()); + service.submitForReview('user-1'); + + vi.setSystemTime(new Date('2027-06-16T00:00:00.000Z')); + const state = service.getOnboardingState('user-1')!; + expect(state.profile.status).toBe('expired'); + + service.savePersonalInfo('user-1', person); + expect(service.getProfile('user-1')!.status).toBe('pending'); + }); + }); +}); diff --git a/backend/src/services/__tests__/kyc.test.ts b/backend/src/services/__tests__/kyc.test.ts index 5cfd2624..0a5d2aff 100644 --- a/backend/src/services/__tests__/kyc.test.ts +++ b/backend/src/services/__tests__/kyc.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach } from 'vitest'; +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; import { KycService } from '../kyc.js'; describe('KycService', () => { @@ -8,6 +8,10 @@ describe('KycService', () => { kycService = new KycService(); }); + afterEach(() => { + vi.useRealTimers(); + }); + describe('submitDocument', () => { it('creates a new profile on first submission with status under_review', () => { const doc = kycService.submitDocument('user-1', 'passport', 'https://example.com/doc.pdf'); @@ -76,16 +80,31 @@ describe('KycService', () => { expect(result!.verifiedAt).toBeDefined(); }); + it('records document status and rejection reasons', () => { + const doc = kycService.submitDocument('user-1', 'passport', 'https://example.com/passport.pdf'); + expect(doc.status).toBe('pending'); + + const rejected = kycService.verifyDocument(doc.id, false, ['Document is illegible']); + expect(rejected!.status).toBe('rejected'); + expect(rejected!.rejectionReasons).toEqual(['Document is illegible']); + + const approved = kycService.verifyDocument(doc.id, true); + expect(approved!.status).toBe('approved'); + expect(approved!.rejectionReasons).toBeUndefined(); + }); + it('returns undefined for unknown documentId', () => { const result = kycService.verifyDocument('nonexistent-doc-id', true); expect(result).toBeUndefined(); }); it('updates profile updatedAt when verifying', () => { + vi.useFakeTimers(); const doc = kycService.submitDocument('user-1', 'passport', 'https://example.com/passport.pdf'); const profileBefore = kycService.getProfile('user-1'); const updatedAtBefore = profileBefore!.updatedAt; + vi.advanceTimersByTime(1000); kycService.verifyDocument(doc.id, true); const profileAfter = kycService.getProfile('user-1'); @@ -246,10 +265,24 @@ describe('KycService', () => { expect(kycService.getProfile('user-1')!.expiresAt).toBeUndefined(); }); + it('records each change in the status history', () => { + kycService.submitDocument('user-1', 'passport', 'https://example.com/passport.pdf'); + kycService.updateStatus('user-1', 'rejected', 'Document is illegible'); + + const history = kycService.getProfile('user-1')!.statusHistory; + expect(history.map(h => [h.from, h.to])).toEqual([ + [null, 'under_review'], + ['under_review', 'rejected'], + ]); + expect(history[1].reason).toBe('Document is illegible'); + }); + it('updates the updatedAt timestamp', () => { + vi.useFakeTimers(); kycService.submitDocument('user-1', 'passport', 'https://example.com/passport.pdf'); const before = kycService.getProfile('user-1')!.updatedAt; + vi.advanceTimersByTime(1000); kycService.updateStatus('user-1', 'approved'); const after = kycService.getProfile('user-1')!.updatedAt; diff --git a/backend/src/services/auditService.ts b/backend/src/services/auditService.ts index 243ad09f..f25a4a95 100644 --- a/backend/src/services/auditService.ts +++ b/backend/src/services/auditService.ts @@ -146,7 +146,10 @@ export class AuditService { if (typeof body !== 'object') return body; const sanitized = { ...body as Record }; - const sensitiveFields = ['password', 'token', 'apiKey', 'secret', 'creditCard', 'ssn']; + const sensitiveFields = [ + 'password', 'token', 'apiKey', 'secret', 'creditCard', 'ssn', + 'documentNumber', 'fileContent', 'dateOfBirth', + ]; for (const field of sensitiveFields) { if (field in sanitized) { diff --git a/backend/src/services/kyc.ts b/backend/src/services/kyc.ts index 76b4177c..31a82c80 100644 --- a/backend/src/services/kyc.ts +++ b/backend/src/services/kyc.ts @@ -1,4 +1,17 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; +import { + ADDRESS_DOCUMENT_TYPES, + IDENTITY_DOCUMENT_TYPES, + MockDocumentVerificationProvider, + maskDocumentNumber, + validateKycDocument, + validatePersonalInfo, + type DocumentValidationResult, + type DocumentVerificationProvider, + type DocumentVerificationResult, + type KycDocumentInput, + type ProviderDecision, +} from './kyc-verification.js'; export type KycStatus = 'pending' | 'under_review' | 'approved' | 'rejected' | 'expired'; @@ -11,14 +24,64 @@ export type DocumentType = export type RiskLevel = 'low' | 'medium' | 'high' | 'critical'; +export type KycDocumentStatus = 'pending' | 'approved' | 'rejected'; + +export type KycOnboardingStep = 'personal_info' | 'documents' | 'review' | 'complete'; + +export interface KycAddress { + line1: string; + line2?: string; + city: string; + state?: string; + postalCode: string; + country: string; +} + +export interface KycPersonalInfo { + firstName: string; + lastName: string; + dateOfBirth: string; + nationality: string; + countryOfResidence: string; + address: KycAddress; +} + +export interface KycDocumentVerification { + provider: string; + reference: string; + decision: ProviderDecision; + confidence: number; + checkedAt: string; +} + export interface KycDocument { id: string; userId: string; type: DocumentType; - fileUrl: string; + fileUrl?: string; uploadedAt: string; verified: boolean; verifiedAt?: string; + status: KycDocumentStatus; + rejectionReasons?: string[]; + warnings?: string[]; + documentNumberMasked?: string; + issuingCountry?: string; + issueDate?: string; + expiryDate?: string; + fileName?: string; + mimeType?: string; + fileSize?: number; + sha256?: string; + verification?: KycDocumentVerification; +} + +export interface KycStatusChange { + from: KycStatus | null; + to: KycStatus; + at: string; + actor: string; + reason?: string; } export interface KycProfile { @@ -33,6 +96,52 @@ export interface KycProfile { createdAt: string; updatedAt: string; expiresAt?: string; + personalInfo?: KycPersonalInfo; + onboardingStep: KycOnboardingStep; + rejectionReasons: string[]; + reviewNotes?: string; + submittedAt?: string; + reviewedAt?: string; + reviewedBy?: string; + statusHistory: KycStatusChange[]; +} + +export interface KycRequirement { + id: 'identity_document' | 'proof_of_address'; + label: string; + acceptedTypes: DocumentType[]; + satisfied: boolean; +} + +export interface KycOnboardingState { + profile: KycProfile; + requirements: KycRequirement[]; + canSubmit: boolean; +} + +export interface KycReviewDecision { + decision: 'approved' | 'rejected'; + reviewerId: string; + reasons?: string[]; + notes?: string; +} + +export interface KycServiceOptions { + verificationProvider?: DocumentVerificationProvider; + /** Approve low-risk applications whose documents all passed automated checks. Default true. */ + autoApprove?: boolean; +} + +export class KycError extends Error { + constructor( + message: string, + readonly code: string, + readonly statusCode = 400, + readonly details?: string[], + ) { + super(message); + this.name = 'KycError'; + } } export interface RiskAssessment { @@ -50,8 +159,39 @@ const HIGH_RISK_COUNTRIES = [ const now = (): string => new Date().toISOString(); +const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; + +// Applications move pending → under_review → approved | rejected. Rejected and +// expired applications return to pending when the applicant resubmits. +const ALLOWED_TRANSITIONS: Record = { + pending: ['under_review'], + under_review: ['approved', 'rejected'], + approved: ['expired'], + rejected: ['pending'], + expired: ['pending'], +}; + +const REQUIREMENTS: Array> = [ + { id: 'identity_document', label: 'Government-issued photo ID', acceptedTypes: IDENTITY_DOCUMENT_TYPES }, + { id: 'proof_of_address', label: 'Proof of address issued in the last 90 days', acceptedTypes: ADDRESS_DOCUMENT_TYPES }, +]; + +export function canTransition(from: KycStatus, to: KycStatus): boolean { + return ALLOWED_TRANSITIONS[from].includes(to); +} + export class KycService { private profiles = new Map(); + // Fingerprints of identity document numbers → owning user, to stop one + // document from being reused across accounts. + private documentOwners = new Map(); + private readonly verificationProvider: DocumentVerificationProvider; + private readonly autoApprove: boolean; + + constructor(options: KycServiceOptions = {}) { + this.verificationProvider = options.verificationProvider ?? new MockDocumentVerificationProvider(); + this.autoApprove = options.autoApprove ?? true; + } submitDocument(userId: string, type: DocumentType, fileUrl: string): KycDocument { const profile = this.profiles.get(userId); @@ -63,26 +203,17 @@ export class KycService { fileUrl, uploadedAt: now(), verified: false, + status: 'pending', }; if (!profile) { - const newProfile: KycProfile = { - userId, - status: 'under_review', - documents: [document], - riskScore: 0, - riskLevel: 'low', - amlCheckPassed: false, - sanctionsCheckPassed: false, - pepCheckPassed: false, - createdAt: now(), - updatedAt: now(), - }; - this.profiles.set(userId, newProfile); + const newProfile = this.createProfile(userId, 'under_review', userId, 'Document submitted'); + newProfile.documents.push(document); + newProfile.onboardingStep = 'review'; } else { profile.documents.push(document); if (profile.status === 'pending') { - profile.status = 'under_review'; + this.recordStatus(profile, 'under_review', userId, 'Document submitted'); } profile.updatedAt = now(); } @@ -90,12 +221,14 @@ export class KycService { return document; } - verifyDocument(documentId: string, approved: boolean): KycDocument | undefined { + verifyDocument(documentId: string, approved: boolean, reasons: string[] = []): KycDocument | undefined { for (const profile of this.profiles.values()) { const doc = profile.documents.find(d => d.id === documentId); if (doc) { doc.verified = approved; doc.verifiedAt = now(); + doc.status = approved ? 'approved' : 'rejected'; + doc.rejectionReasons = approved ? undefined : reasons; profile.updatedAt = now(); return doc; } @@ -156,6 +289,11 @@ export class KycService { factors.push('PEP screening not yet completed'); } + if (profile.personalInfo && this.isHighRiskJurisdiction(profile.personalInfo)) { + score += 30; + factors.push('Nationality or residence in a high-risk jurisdiction'); + } + const level = this.getRiskLevel(score); return { @@ -198,16 +336,14 @@ export class KycService { return this.profiles.get(userId); } - updateStatus(userId: string, status: KycStatus): KycProfile | undefined { + updateStatus(userId: string, status: KycStatus, reason?: string): KycProfile | undefined { const profile = this.profiles.get(userId); if (!profile) return undefined; - profile.status = status; - profile.updatedAt = now(); + this.recordStatus(profile, status, 'system', reason); if (status === 'approved') { - const oneYear = 365 * 24 * 60 * 60 * 1000; - profile.expiresAt = new Date(Date.now() + oneYear).toISOString(); + profile.expiresAt = new Date(Date.now() + ONE_YEAR_MS).toISOString(); } return profile; @@ -231,6 +367,365 @@ export class KycService { if (score <= 75) return 'high'; return 'critical'; } + + // ── Onboarding flow (#921) ──────────────────────────────────────────────── + + /** Step 1: create or update the applicant's personal details. */ + savePersonalInfo(userId: string, info: KycPersonalInfo): KycProfile { + const errors = validatePersonalInfo(info); + if (errors.length > 0) { + throw new KycError('Personal information is invalid', 'INVALID_PERSONAL_INFO', 422, errors); + } + + const profile = this.profiles.get(userId) ?? this.createProfile(userId, 'pending', userId, 'Onboarding started'); + this.assertEditable(profile); + this.reopen(profile, userId, 'Personal information updated'); + + profile.personalInfo = { + ...info, + nationality: info.nationality.toUpperCase(), + countryOfResidence: info.countryOfResidence.toUpperCase(), + address: { ...info.address, country: info.address.country.toUpperCase() }, + }; + profile.onboardingStep = this.nextStep(profile); + profile.updatedAt = now(); + return profile; + } + + /** + * Step 2: validate a document locally, then hand it to the verification + * provider. A newer upload replaces an earlier one of the same type. + */ + async uploadDocument(userId: string, input: KycDocumentInput): Promise { + const profile = this.profiles.get(userId); + if (!profile?.personalInfo) { + throw new KycError('Complete personal information before uploading documents', 'PERSONAL_INFO_REQUIRED', 409); + } + this.assertEditable(profile); + + const validation = validateKycDocument(input); + if (!validation.valid) { + throw new KycError('Document failed validation', 'INVALID_DOCUMENT', 422, validation.errors); + } + + const issuingCountry = input.issuingCountry.toUpperCase(); + const fingerprint = validation.normalizedDocumentNumber + ? createHash('sha256').update(`${input.type}:${issuingCountry}:${validation.normalizedDocumentNumber}`).digest('hex') + : undefined; + const owner = fingerprint ? this.documentOwners.get(fingerprint) : undefined; + if (owner && owner !== userId) { + throw new KycError('Document failed validation', 'DUPLICATE_DOCUMENT', 409, [ + 'This document is already linked to another account', + ]); + } + + this.reopen(profile, userId, 'Documents resubmitted'); + + const document: KycDocument = { + id: randomUUID(), + userId, + type: input.type, + fileUrl: input.fileUrl, + uploadedAt: now(), + verified: false, + status: 'pending', + warnings: validation.warnings, + documentNumberMasked: validation.normalizedDocumentNumber + ? maskDocumentNumber(validation.normalizedDocumentNumber) + : undefined, + issuingCountry, + issueDate: input.issueDate, + expiryDate: input.expiryDate, + fileName: input.fileName, + mimeType: input.mimeType.toLowerCase(), + fileSize: input.fileSize, + sha256: validation.sha256, + }; + + const result = await this.runProviderCheck(profile, document, validation); + this.applyProviderResult(document, result); + + profile.documents = profile.documents.filter(d => d.type !== document.type).concat(document); + if (fingerprint) this.documentOwners.set(fingerprint, userId); + profile.onboardingStep = this.nextStep(profile); + profile.updatedAt = now(); + return document; + } + + /** Current profile plus the outstanding requirements for the onboarding UI. */ + getOnboardingState(userId: string): KycOnboardingState | undefined { + const profile = this.profiles.get(userId); + if (!profile) return undefined; + + this.expireIfDue(profile); + const requirements = this.getRequirements(profile); + + return { + profile, + requirements, + canSubmit: + profile.status === 'pending' && + profile.personalInfo !== undefined && + requirements.every(r => r.satisfied), + }; + } + + getRequirements(profile: KycProfile): KycRequirement[] { + return REQUIREMENTS.map(requirement => ({ + ...requirement, + satisfied: profile.documents.some( + d => requirement.acceptedTypes.includes(d.type) && d.status !== 'rejected', + ), + })); + } + + /** + * Step 3: submit for review. Runs screening checks and auto-approves + * low-risk applications whose documents were all verified by the provider; + * anything else waits for a reviewer. + */ + submitForReview(userId: string): KycProfile { + const profile = this.requireProfile(userId); + + if (profile.status !== 'pending') { + throw new KycError( + `KYC cannot be submitted while it is ${profile.status.replace(/_/g, ' ')}`, + 'INVALID_STATUS_TRANSITION', + 409, + ); + } + if (!profile.personalInfo) { + throw new KycError('Complete personal information before submitting', 'PERSONAL_INFO_REQUIRED', 409); + } + + const missing = this.getRequirements(profile).filter(r => !r.satisfied); + if (missing.length > 0) { + throw new KycError('Required documents are missing', 'REQUIREMENTS_NOT_MET', 422, missing.map(r => r.label)); + } + + this.transition(profile, 'under_review', userId, 'Submitted for review'); + profile.submittedAt = now(); + profile.rejectionReasons = []; + + this.runAmlCheck(userId); + this.runSanctionsCheck(userId); + this.runPepCheck(userId); + + const risk = this.assessRisk(userId); + profile.riskScore = risk.score; + profile.riskLevel = risk.level; + + if (this.autoApprove && this.qualifiesForAutoApproval(profile)) { + this.approve(profile, 'system', 'All documents verified automatically'); + } + + profile.onboardingStep = this.nextStep(profile); + return profile; + } + + /** Manual reviewer decision for an application that is under review. */ + review(userId: string, decision: KycReviewDecision): KycProfile { + const profile = this.requireProfile(userId); + + if (!canTransition(profile.status, decision.decision)) { + throw new KycError( + `Cannot ${decision.decision === 'approved' ? 'approve' : 'reject'} KYC while it is ${profile.status.replace(/_/g, ' ')}`, + 'INVALID_STATUS_TRANSITION', + 409, + ); + } + + const reasons = (decision.reasons ?? []).map(r => r.trim()).filter(Boolean); + + if (decision.decision === 'approved') { + this.approve(profile, decision.reviewerId, decision.notes); + } else { + if (reasons.length === 0) { + throw new KycError('At least one reason is required to reject KYC', 'REJECTION_REASON_REQUIRED', 400); + } + this.transition(profile, 'rejected', decision.reviewerId, reasons.join('; ')); + profile.rejectionReasons = reasons; + profile.reviewedAt = now(); + profile.reviewedBy = decision.reviewerId; + } + + profile.reviewNotes = decision.notes; + profile.onboardingStep = this.nextStep(profile); + return profile; + } + + /** Manual decision on a single document, e.g. one the provider flagged for review. */ + reviewDocument(userId: string, documentId: string, approved: boolean, reasons: string[] = []): KycDocument { + const profile = this.requireProfile(userId); + const document = profile.documents.find(d => d.id === documentId); + if (!document) throw new KycError('Document not found', 'DOCUMENT_NOT_FOUND', 404); + + if (profile.status !== 'pending' && profile.status !== 'under_review') { + throw new KycError( + `Documents cannot be reviewed while KYC is ${profile.status.replace(/_/g, ' ')}`, + 'KYC_LOCKED', + 409, + ); + } + + this.verifyDocument(documentId, approved, reasons); + profile.onboardingStep = this.nextStep(profile); + return document; + } + + listForReview(): KycProfile[] { + return Array.from(this.profiles.values()).filter(p => p.status === 'under_review'); + } + + // ── Internals ───────────────────────────────────────────────────────────── + + private createProfile(userId: string, status: KycStatus, actor = 'system', reason?: string): KycProfile { + const profile: KycProfile = { + userId, + status, + documents: [], + riskScore: 0, + riskLevel: 'low', + amlCheckPassed: false, + sanctionsCheckPassed: false, + pepCheckPassed: false, + createdAt: now(), + updatedAt: now(), + onboardingStep: 'personal_info', + rejectionReasons: [], + statusHistory: [{ from: null, to: status, at: now(), actor, reason }], + }; + this.profiles.set(userId, profile); + return profile; + } + + private requireProfile(userId: string): KycProfile { + const profile = this.profiles.get(userId); + if (!profile) throw new KycError('KYC profile not found', 'KYC_NOT_FOUND', 404); + return profile; + } + + private recordStatus(profile: KycProfile, to: KycStatus, actor: string, reason?: string): void { + profile.statusHistory.push({ from: profile.status, to, at: now(), actor, reason }); + profile.status = to; + profile.updatedAt = now(); + } + + private transition(profile: KycProfile, to: KycStatus, actor: string, reason?: string): void { + if (!canTransition(profile.status, to)) { + throw new KycError(`Cannot move KYC from ${profile.status} to ${to}`, 'INVALID_STATUS_TRANSITION', 409); + } + this.recordStatus(profile, to, actor, reason); + } + + private assertEditable(profile: KycProfile): void { + if (profile.status === 'under_review' || profile.status === 'approved') { + throw new KycError( + `KYC cannot be changed while it is ${profile.status.replace(/_/g, ' ')}`, + 'KYC_LOCKED', + 409, + ); + } + } + + /** Rejected and expired applications return to pending when the applicant edits them. */ + private reopen(profile: KycProfile, actor: string, reason: string): void { + if (profile.status === 'rejected' || profile.status === 'expired') { + this.transition(profile, 'pending', actor, reason); + } + } + + private approve(profile: KycProfile, actor: string, reason?: string): void { + this.transition(profile, 'approved', actor, reason); + profile.expiresAt = new Date(Date.now() + ONE_YEAR_MS).toISOString(); + profile.rejectionReasons = []; + profile.reviewedAt = now(); + profile.reviewedBy = actor; + } + + private expireIfDue(profile: KycProfile): void { + if (profile.status === 'approved' && profile.expiresAt && Date.parse(profile.expiresAt) <= Date.now()) { + this.transition(profile, 'expired', 'system', 'Verification period ended'); + profile.onboardingStep = this.nextStep(profile); + } + } + + private nextStep(profile: KycProfile): KycOnboardingStep { + if (profile.status === 'approved') return 'complete'; + if (profile.status === 'under_review') return 'review'; + if (!profile.personalInfo) return 'personal_info'; + if (profile.status === 'pending' && this.getRequirements(profile).every(r => r.satisfied)) return 'review'; + return 'documents'; + } + + private isHighRiskJurisdiction(info: KycPersonalInfo): boolean { + return ( + HIGH_RISK_COUNTRIES.includes(info.nationality.toUpperCase()) || + HIGH_RISK_COUNTRIES.includes(info.countryOfResidence.toUpperCase()) + ); + } + + private qualifiesForAutoApproval(profile: KycProfile): boolean { + return ( + profile.documents.length > 0 && + profile.documents.every(d => d.status === 'approved') && + profile.riskLevel === 'low' && + profile.personalInfo !== undefined && + !this.isHighRiskJurisdiction(profile.personalInfo) + ); + } + + private async runProviderCheck( + profile: KycProfile, + document: KycDocument, + validation: DocumentValidationResult, + ): Promise { + try { + return await this.verificationProvider.verify({ + userId: profile.userId, + documentId: document.id, + type: document.type, + issuingCountry: document.issuingCountry ?? '', + documentNumber: validation.normalizedDocumentNumber, + issueDate: document.issueDate, + expiryDate: document.expiryDate, + mimeType: document.mimeType ?? '', + sha256: validation.sha256, + file: validation.file, + fileUrl: document.fileUrl, + personalInfo: profile.personalInfo, + }); + } catch { + return { + decision: 'needs_review', + reasons: ['Verification provider unavailable; document queued for manual review'], + confidence: 0, + reference: '', + }; + } + } + + private applyProviderResult(document: KycDocument, result: DocumentVerificationResult): void { + const checkedAt = now(); + document.verification = { + provider: this.verificationProvider.name, + reference: result.reference, + decision: result.decision, + confidence: result.confidence, + checkedAt, + }; + + if (result.decision === 'approved') { + document.status = 'approved'; + document.verified = true; + document.verifiedAt = checkedAt; + } else if (result.decision === 'rejected') { + document.status = 'rejected'; + document.rejectionReasons = result.reasons; + } else { + document.warnings = [...(document.warnings ?? []), ...result.reasons]; + } + } } export const kycService = new KycService(); From 2097937240154743e73d503503aa7e712d9da9ef Mon Sep 17 00:00:00 2001 From: Itodo-S Date: Mon, 28 Sep 2026 09:50:27 +0100 Subject: [PATCH 3/4] feat(frontend): add KYC onboarding flow Add a /dashboard/kyc page with a three-step flow (personal info, document upload, review and status) that mirrors the backend document checks in the browser, shows per-document verification results and rejection reasons, and resumes from the step the API reports. Link it from the sidebar and the onboarding wizard's identity step. Refs #921 --- frontend/app/dashboard/kyc/page.tsx | 25 ++ frontend/app/onboarding/wizard/page.tsx | 10 + .../components/kyc/DocumentUploadStep.tsx | 265 ++++++++++++++++ frontend/components/kyc/KycOnboardingFlow.tsx | 175 +++++++++++ frontend/components/kyc/KycStatusBadge.tsx | 20 ++ frontend/components/kyc/PersonalInfoStep.tsx | 139 +++++++++ frontend/components/kyc/ReviewStep.tsx | 118 +++++++ .../kyc/__tests__/KycOnboardingFlow.test.tsx | 286 +++++++++++++++++ frontend/components/layout/Sidebar.tsx | 3 +- frontend/lib/kyc.test.ts | 204 ++++++++++++ frontend/lib/kyc.ts | 295 ++++++++++++++++++ 11 files changed, 1539 insertions(+), 1 deletion(-) create mode 100644 frontend/app/dashboard/kyc/page.tsx create mode 100644 frontend/components/kyc/DocumentUploadStep.tsx create mode 100644 frontend/components/kyc/KycOnboardingFlow.tsx create mode 100644 frontend/components/kyc/KycStatusBadge.tsx create mode 100644 frontend/components/kyc/PersonalInfoStep.tsx create mode 100644 frontend/components/kyc/ReviewStep.tsx create mode 100644 frontend/components/kyc/__tests__/KycOnboardingFlow.test.tsx create mode 100644 frontend/lib/kyc.test.ts create mode 100644 frontend/lib/kyc.ts diff --git a/frontend/app/dashboard/kyc/page.tsx b/frontend/app/dashboard/kyc/page.tsx new file mode 100644 index 00000000..eefd9b87 --- /dev/null +++ b/frontend/app/dashboard/kyc/page.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { KycOnboardingFlow } from '@/components/kyc/KycOnboardingFlow'; +import { useAuthStore } from '@/store/useAuthStore'; + +export default function KycVerificationPage() { + const address = useAuthStore((state) => state.address); + + return ( +
+
+

Identity Verification (KYC)

+

+ Verify your identity to unlock payouts and higher payment limits. +

+
+ + {address ? ( + + ) : ( +

Sign in to start identity verification.

+ )} +
+ ); +} diff --git a/frontend/app/onboarding/wizard/page.tsx b/frontend/app/onboarding/wizard/page.tsx index 64936e6b..fb6c8a33 100644 --- a/frontend/app/onboarding/wizard/page.tsx +++ b/frontend/app/onboarding/wizard/page.tsx @@ -13,6 +13,7 @@ */ import React, { useEffect } from 'react'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useOnboardingStore, @@ -74,6 +75,15 @@ function StepContent({ stepId, variant }: { stepId: string; variant: 'A' | 'B' } )} + {stepId === 'kyc_verification' && ( + + Start identity verification → + + )} + {/* Completion screen */} {isComplete && (
diff --git a/frontend/components/kyc/DocumentUploadStep.tsx b/frontend/components/kyc/DocumentUploadStep.tsx new file mode 100644 index 00000000..238e237e --- /dev/null +++ b/frontend/components/kyc/DocumentUploadStep.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { CheckCircle2, Circle, FileText } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { KycStatusBadge } from '@/components/kyc/KycStatusBadge'; +import { + ADDRESS_DOCUMENT_TYPES, + DOCUMENT_TYPE_LABELS, + IDENTITY_DOCUMENT_TYPES, + KYC_ALLOWED_MIME_TYPES, + isIdentityDocument, + readFileAsBase64, + validateDocumentForm, + type FieldErrors, + type KycDocument, + type KycDocumentForm, + type KycDocumentType, + type KycDocumentUpload, + type KycRequirement, +} from '@/lib/kyc'; + +interface DocumentUploadStepProps { + documents: KycDocument[]; + requirements: KycRequirement[]; + defaultCountry?: string; + disabled?: boolean; + /** Resolves to true when the upload was accepted, so the form can reset. */ + onUpload: (upload: KycDocumentUpload) => Promise; + onBack: () => void; + onContinue: () => void; +} + +export function DocumentUploadStep({ + documents, + requirements, + defaultCountry = '', + disabled, + onUpload, + onBack, + onContinue, +}: DocumentUploadStepProps) { + const fileInputRef = useRef(null); + const [form, setForm] = useState({ + type: 'passport', + documentNumber: '', + issuingCountry: defaultCountry, + issueDate: '', + expiryDate: '', + }); + const [file, setFile] = useState(null); + const [errors, setErrors] = useState({}); + const [uploading, setUploading] = useState(false); + + const identity = isIdentityDocument(form.type); + const allSatisfied = requirements.length > 0 && requirements.every((r) => r.satisfied); + + const update = (patch: Partial) => setForm((f) => ({ ...f, ...patch })); + + const handleUpload = async (e: React.FormEvent) => { + e.preventDefault(); + const fieldErrors = validateDocumentForm(form, file); + setErrors(fieldErrors); + if (Object.keys(fieldErrors).length > 0 || !file) return; + + setUploading(true); + try { + const accepted = await onUpload({ + type: form.type, + issuingCountry: form.issuingCountry.toUpperCase(), + ...(identity + ? { documentNumber: form.documentNumber, expiryDate: form.expiryDate } + : { issueDate: form.issueDate }), + ...(identity && form.issueDate ? { issueDate: form.issueDate } : {}), + fileName: file.name, + mimeType: file.type, + fileSize: file.size, + fileContent: await readFileAsBase64(file), + }); + if (accepted) { + setFile(null); + setForm((f) => ({ ...f, documentNumber: '', issueDate: '', expiryDate: '' })); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + } finally { + setUploading(false); + } + }; + + const fieldError = (key: string) => + errors[key] ? ( +

+ {errors[key]} +

+ ) : null; + + return ( +
+
+

+ Required documents +

+
    + {requirements.map((r) => ( +
  • + {r.satisfied ? ( +
  • + ))} +
+
+ + {documents.length > 0 && ( +
+

+ Uploaded documents +

+
    + {documents.map((doc) => ( +
  • +
    +
    + +
  • + ))} +
+
+ )} + +
+

Add a document

+
+
+ + +
+ +
+ + update({ issuingCountry: e.target.value })} + /> + {fieldError('issuingCountry')} +
+ + {identity && ( + <> +
+ + update({ documentNumber: e.target.value })} + /> + {fieldError('documentNumber')} +
+
+ + update({ expiryDate: e.target.value })} + /> + {fieldError('expiryDate')} +
+ + )} + +
+ + update({ issueDate: e.target.value })} + /> + {fieldError('issueDate')} +
+ +
+ + setFile(e.target.files?.[0] ?? null)} + /> + {fieldError('file')} +
+
+ +
+ +
+
+ +
+ + +
+
+ ); +} diff --git a/frontend/components/kyc/KycOnboardingFlow.tsx b/frontend/components/kyc/KycOnboardingFlow.tsx new file mode 100644 index 00000000..53133820 --- /dev/null +++ b/frontend/components/kyc/KycOnboardingFlow.tsx @@ -0,0 +1,175 @@ +'use client'; + +/** + * KYC onboarding flow — Issue #921 + * + * Personal info → document upload (validated in the browser, verified by the + * backend's document verification provider) → review and status. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { DocumentUploadStep } from '@/components/kyc/DocumentUploadStep'; +import { KycStatusBadge } from '@/components/kyc/KycStatusBadge'; +import { PersonalInfoStep } from '@/components/kyc/PersonalInfoStep'; +import { ReviewStep } from '@/components/kyc/ReviewStep'; +import { cn } from '@/lib/utils'; +import { + KycApiError, + kycApi, + type KycDocumentUpload, + type KycOnboardingState, + type KycOnboardingStep, + type KycPersonalInfo, +} from '@/lib/kyc'; + +type StepId = 'personal_info' | 'documents' | 'review'; + +const STEPS: Array<{ id: StepId; label: string }> = [ + { id: 'personal_info', label: 'Personal info' }, + { id: 'documents', label: 'Documents' }, + { id: 'review', label: 'Review & status' }, +]; + +function stepFor(onboardingStep: KycOnboardingStep): StepId { + return onboardingStep === 'complete' ? 'review' : onboardingStep; +} + +function describeError(error: unknown): { message: string; details: string[] } { + if (error instanceof KycApiError) return { message: error.message, details: error.details }; + return { message: error instanceof Error ? error.message : 'Something went wrong', details: [] }; +} + +export function KycOnboardingFlow({ userId }: { userId: string }) { + const [state, setState] = useState(null); + const [step, setStep] = useState('personal_info'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<{ message: string; details: string[] } | null>(null); + + const applyState = useCallback((next: KycOnboardingState) => { + setState(next); + setStep(stepFor(next.profile.onboardingStep)); + }, []); + + useEffect(() => { + let cancelled = false; + kycApi + .getState(userId) + .then((loaded) => { + if (cancelled) return; + if (loaded) applyState(loaded); + }) + .catch((err) => { + if (!cancelled) setError(describeError(err)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [userId, applyState]); + + const run = async (action: () => Promise): Promise => { + setError(null); + try { + await action(); + return true; + } catch (err) { + setError(describeError(err)); + return false; + } + }; + + const handlePersonalInfo = async (info: KycPersonalInfo) => { + await run(async () => applyState(await kycApi.savePersonalInfo(userId, info))); + }; + + const handleUpload = (upload: KycDocumentUpload) => + run(async () => { + const { state: next } = await kycApi.uploadDocument(userId, upload); + setState(next); + }); + + const handleSubmit = async () => { + await run(async () => applyState(await kycApi.submit(userId))); + }; + + if (loading) { + return

Loading verification status...

; + } + + const locked = state?.profile.status === 'under_review' || state?.profile.status === 'approved'; + const currentIndex = STEPS.findIndex((s) => s.id === step); + + return ( + + +
+ Identity verification + {state && } +
+
    + {STEPS.map((s, i) => ( +
  1. currentIndex && 'text-gray-500', + )} + > + {i + 1}. {s.label} +
  2. + ))} +
+
+ + + {error && ( +
+

{error.message}

+ {error.details.length > 0 && ( +
    + {error.details.map((detail) => ( +
  • {detail}
  • + ))} +
+ )} +
+ )} + + {step === 'personal_info' && ( + + )} + + {step === 'documents' && state && ( + setStep('personal_info')} + onContinue={() => setStep('review')} + /> + )} + + {step === 'review' && state && ( + setStep('documents')} + onEditPersonalInfo={() => setStep('personal_info')} + /> + )} +
+
+ ); +} diff --git a/frontend/components/kyc/KycStatusBadge.tsx b/frontend/components/kyc/KycStatusBadge.tsx new file mode 100644 index 00000000..1db2148a --- /dev/null +++ b/frontend/components/kyc/KycStatusBadge.tsx @@ -0,0 +1,20 @@ +import { cn } from '@/lib/utils'; +import type { KycDocumentStatus, KycStatus } from '@/lib/kyc'; + +const STATUS_STYLES: Record = { + pending: 'bg-gray-100 text-gray-700', + under_review: 'bg-yellow-100 text-yellow-800', + approved: 'bg-green-100 text-green-700', + rejected: 'bg-red-100 text-red-700', + expired: 'bg-orange-100 text-orange-700', +}; + +export function KycStatusBadge({ status, className }: { status: KycStatus | KycDocumentStatus; className?: string }) { + return ( + + {status.replace(/_/g, ' ')} + + ); +} diff --git a/frontend/components/kyc/PersonalInfoStep.tsx b/frontend/components/kyc/PersonalInfoStep.tsx new file mode 100644 index 00000000..6386cc87 --- /dev/null +++ b/frontend/components/kyc/PersonalInfoStep.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { validatePersonalInfo, type FieldErrors, type KycPersonalInfo } from '@/lib/kyc'; + +interface PersonalInfoStepProps { + initial?: KycPersonalInfo; + disabled?: boolean; + onSubmit: (info: KycPersonalInfo) => Promise; +} + +const EMPTY: KycPersonalInfo = { + firstName: '', + lastName: '', + dateOfBirth: '', + nationality: '', + countryOfResidence: '', + address: { line1: '', line2: '', city: '', state: '', postalCode: '', country: '' }, +}; + +const PERSON_FIELDS = [ + { name: 'firstName', label: 'First name', autoComplete: 'given-name' }, + { name: 'lastName', label: 'Last name', autoComplete: 'family-name' }, + { name: 'dateOfBirth', label: 'Date of birth', type: 'date', autoComplete: 'bday' }, + { name: 'nationality', label: 'Nationality (e.g. US)', maxLength: 2 }, + { name: 'countryOfResidence', label: 'Country of residence (e.g. US)', maxLength: 2 }, +] as const; + +const ADDRESS_FIELDS = [ + { name: 'line1', label: 'Address line 1', autoComplete: 'address-line1' }, + { name: 'line2', label: 'Address line 2 (optional)', autoComplete: 'address-line2' }, + { name: 'city', label: 'City', autoComplete: 'address-level2' }, + { name: 'state', label: 'State / region (optional)', autoComplete: 'address-level1' }, + { name: 'postalCode', label: 'Postal code', autoComplete: 'postal-code' }, + { name: 'country', label: 'Country (e.g. US)', maxLength: 2 }, +] as const; + +function withoutBlankOptionals(info: KycPersonalInfo): KycPersonalInfo { + const { line2, state, ...address } = info.address; + return { + ...info, + nationality: info.nationality.toUpperCase(), + countryOfResidence: info.countryOfResidence.toUpperCase(), + address: { + ...address, + country: address.country.toUpperCase(), + ...(line2?.trim() ? { line2 } : {}), + ...(state?.trim() ? { state } : {}), + }, + }; +} + +export function PersonalInfoStep({ initial, disabled, onSubmit }: PersonalInfoStepProps) { + const [form, setForm] = useState(() => ({ + ...EMPTY, + ...initial, + address: { ...EMPTY.address, ...initial?.address }, + })); + const [errors, setErrors] = useState({}); + const [saving, setSaving] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const fieldErrors = validatePersonalInfo(form); + setErrors(fieldErrors); + if (Object.keys(fieldErrors).length > 0) return; + + setSaving(true); + try { + await onSubmit(withoutBlankOptionals(form)); + } finally { + setSaving(false); + } + }; + + const renderError = (key: string) => + errors[key] ? ( +

+ {errors[key]} +

+ ) : null; + + return ( +
+
+ About you + {PERSON_FIELDS.map((field) => ( +
+ + setForm((f) => ({ ...f, [field.name]: e.target.value }))} + /> + {renderError(field.name)} +
+ ))} +
+ +
+ Residential address + {ADDRESS_FIELDS.map((field) => { + const key = `address.${field.name}`; + return ( +
+ + + setForm((f) => ({ ...f, address: { ...f.address, [field.name]: e.target.value } })) + } + /> + {renderError(key)} +
+ ); + })} +
+ +
+ +
+
+ ); +} diff --git a/frontend/components/kyc/ReviewStep.tsx b/frontend/components/kyc/ReviewStep.tsx new file mode 100644 index 00000000..44b099d9 --- /dev/null +++ b/frontend/components/kyc/ReviewStep.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { KycStatusBadge } from '@/components/kyc/KycStatusBadge'; +import { DOCUMENT_TYPE_LABELS, type KycOnboardingState } from '@/lib/kyc'; + +interface ReviewStepProps { + state: KycOnboardingState; + onSubmit: () => Promise; + onEditDocuments: () => void; + onEditPersonalInfo: () => void; +} + +const STATUS_MESSAGES = { + pending: 'Check your details, then submit your application for verification.', + under_review: 'Your application is being reviewed. This usually takes one business day.', + approved: 'Your identity is verified. You have full access to AgenticPay.', + rejected: 'Your application was not approved. Fix the issues below and resubmit.', + expired: 'Your verification has expired. Upload current documents to verify again.', +} as const; + +export function ReviewStep({ state, onSubmit, onEditDocuments, onEditPersonalInfo }: ReviewStepProps) { + const { profile, canSubmit } = state; + const [submitting, setSubmitting] = useState(false); + const editable = profile.status !== 'under_review' && profile.status !== 'approved'; + const info = profile.personalInfo; + + const handleSubmit = async () => { + setSubmitting(true); + try { + await onSubmit(); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+

Verification status

+

{STATUS_MESSAGES[profile.status]}

+ {profile.status === 'approved' && profile.expiresAt && ( +

+ Valid until {new Date(profile.expiresAt).toLocaleDateString()} +

+ )} +
+ +
+ + {profile.rejectionReasons.length > 0 && ( +
+

Reasons

+
    + {profile.rejectionReasons.map((reason) => ( +
  • {reason}
  • + ))} +
+
+ )} + + {info && ( +
+
+

+ Personal information +

+ {editable && ( + + )} +
+

+ {info.firstName} {info.lastName} · born {info.dateOfBirth} +

+

+ {info.address.line1}, {info.address.city} {info.address.postalCode}, {info.address.country} +

+
+ )} + +
+
+

+ Documents +

+ {editable && ( + + )} +
+
    + {profile.documents.map((doc) => ( +
  • + + {DOCUMENT_TYPE_LABELS[doc.type]} + {doc.documentNumberMasked ? ` · ${doc.documentNumberMasked}` : ''} + + +
  • + ))} +
+
+ + {profile.status === 'pending' && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/components/kyc/__tests__/KycOnboardingFlow.test.tsx b/frontend/components/kyc/__tests__/KycOnboardingFlow.test.tsx new file mode 100644 index 00000000..5822656a --- /dev/null +++ b/frontend/components/kyc/__tests__/KycOnboardingFlow.test.tsx @@ -0,0 +1,286 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { KycOnboardingFlow } from '../KycOnboardingFlow'; +import type { KycDocument, KycOnboardingState } from '@/lib/kyc'; + +// Rendered with react-dom directly so the suite only needs the frontend's +// declared dependencies (react, react-dom, jsdom). +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const requirements = (identity: boolean, address: boolean): KycOnboardingState['requirements'] => [ + { id: 'identity_document', label: 'Government-issued photo ID', acceptedTypes: ['passport'], satisfied: identity }, + { + id: 'proof_of_address', + label: 'Proof of address issued in the last 90 days', + acceptedTypes: ['utility_bill'], + satisfied: address, + }, +]; + +const personalInfo = { + firstName: 'Ada', + lastName: 'Lovelace', + dateOfBirth: '1990-04-12', + nationality: 'GB', + countryOfResidence: 'GB', + address: { line1: '1 Main St', city: 'London', postalCode: 'N1 9GU', country: 'GB' }, +}; + +function makeState(overrides: Partial = {}, canSubmit = false): KycOnboardingState { + return { + profile: { + userId: 'user-1', + status: 'pending', + onboardingStep: 'documents', + personalInfo, + documents: [], + rejectionReasons: [], + statusHistory: [], + ...overrides, + }, + requirements: requirements(false, false), + canSubmit, + }; +} + +const passportDoc: KycDocument = { + id: 'doc-1', + type: 'passport', + status: 'approved', + uploadedAt: '2026-06-15T00:00:00.000Z', + documentNumberMasked: '*****6789', +}; + +const notFound = () => jsonResponse(404, { error: { code: 'KYC_NOT_FOUND', message: 'KYC profile not found' } }); + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +// ── Minimal DOM helpers ───────────────────────────────────────────────────── + +let container: HTMLDivElement; +let root: Root; +let fetchMock: ReturnType; + +async function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root.render(ui); + }); +} + +const pageText = () => container.textContent ?? ''; + +async function waitFor(check: () => boolean) { + for (let i = 0; i < 100; i++) { + if (check()) return; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } + throw new Error(`Timed out waiting; page text: ${pageText()}`); +} + +function field(label: string | RegExp): HTMLInputElement { + const match = Array.from(container.querySelectorAll('label')).find((l) => + typeof label === 'string' ? l.textContent === label : label.test(l.textContent ?? ''), + ); + const input = match && container.querySelector(`[id="${match.htmlFor}"]`); + if (!input) throw new Error(`No field labelled ${label}`); + return input; +} + +function button(name: string): HTMLButtonElement { + const found = Array.from(container.querySelectorAll('button')).find((b) => b.textContent?.trim() === name); + if (!found) throw new Error(`No button named ${name}`); + return found; +} + +const hasButton = (name: string) => + Array.from(container.querySelectorAll('button')).some((b) => b.textContent?.trim() === name); + +async function type(label: string | RegExp, value: string) { + const input = field(label); + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!; + await act(async () => { + setValue.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); +} + +async function chooseFile(file: File) { + const input = field(/^File/); + Object.defineProperty(input, 'files', { value: [file], configurable: true }); + await act(async () => { + input.dispatchEvent(new Event('change', { bubbles: true })); + }); +} + +async function click(target: HTMLElement) { + await act(async () => { + target.click(); + }); +} + +function requestBody(call: number) { + return JSON.parse(String(fetchMock.mock.calls[call][1].body)); +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe('KycOnboardingFlow', () => { + it('starts on personal info and blocks invalid input before calling the API', async () => { + fetchMock.mockResolvedValueOnce(notFound()); + await render(); + await waitFor(() => hasButton('Save and continue')); + + await type('Date of birth', '2015-01-01'); + await click(button('Save and continue')); + + expect(pageText()).toContain('You must be at least 18 years old'); + expect(pageText()).toContain('First name is required'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('saves personal info and moves to the document step', async () => { + fetchMock.mockResolvedValueOnce(notFound()).mockResolvedValueOnce(jsonResponse(200, makeState())); + await render(); + await waitFor(() => hasButton('Save and continue')); + + await type('First name', 'Ada'); + await type('Last name', 'Lovelace'); + await type('Date of birth', '1990-04-12'); + await type('Nationality (e.g. US)', 'gb'); + await type('Country of residence (e.g. US)', 'gb'); + await type('Address line 1', '1 Main St'); + await type('City', 'London'); + await type('Postal code', 'N1 9GU'); + await type('Country (e.g. US)', 'gb'); + await click(button('Save and continue')); + + await waitFor(() => pageText().includes('Required documents')); + const [url, init] = fetchMock.mock.calls[1]; + expect(String(url)).toMatch(/\/kyc\/user-1\/personal-info$/); + expect(init.method).toBe('PUT'); + expect(requestBody(1)).toMatchObject({ nationality: 'GB', address: { country: 'GB' } }); + expect(requestBody(1).address).not.toHaveProperty('line2'); + }); + + it('validates the document form before uploading', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, makeState())); + await render(); + await waitFor(() => pageText().includes('Required documents')); + + await chooseFile(new File([new Uint8Array(4096)], 'id.gif', { type: 'image/gif' })); + await click(button('Upload and verify')); + + expect(pageText()).toContain('Upload a JPEG, PNG, WebP, or PDF file'); + expect(pageText()).toContain('Document number is required'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('uploads a document and shows the verification result', async () => { + const afterUpload = { ...makeState({ documents: [passportDoc] }), requirements: requirements(true, false) }; + fetchMock + .mockResolvedValueOnce(jsonResponse(200, makeState())) + .mockResolvedValueOnce(jsonResponse(201, { document: passportDoc, state: afterUpload })); + await render(); + await waitFor(() => pageText().includes('Required documents')); + + await type('Document number', '123456789'); + await type('Expiry date', '2031-01-01'); + await chooseFile(new File([new Uint8Array(4096)], 'passport.pdf', { type: 'application/pdf' })); + await click(button('Upload and verify')); + + await waitFor(() => pageText().includes('Passport · *****6789')); + expect(requestBody(1)).toMatchObject({ + type: 'passport', + documentNumber: '123456789', + issuingCountry: 'GB', + expiryDate: '2031-01-01', + mimeType: 'application/pdf', + fileSize: 4096, + }); + expect(requestBody(1).fileContent).toBeTruthy(); + expect(field('Document number').value).toBe(''); + expect(button('Continue to review').disabled).toBe(true); + }); + + it('shows server-side rejection reasons and keeps the form', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, makeState())).mockResolvedValueOnce( + jsonResponse(422, { + error: { + code: 'INVALID_DOCUMENT', + message: 'Document failed validation', + details: ['Document number is reported as lost or stolen'], + }, + }), + ); + await render(); + await waitFor(() => pageText().includes('Required documents')); + + await type('Document number', '123456000'); + await type('Expiry date', '2031-01-01'); + await chooseFile(new File([new Uint8Array(4096)], 'passport.pdf', { type: 'application/pdf' })); + await click(button('Upload and verify')); + + await waitFor(() => container.querySelector('[role="alert"]') !== null); + const alert = container.querySelector('[role="alert"]')!; + expect(alert.textContent).toContain('Document failed validation'); + expect(alert.textContent).toContain('Document number is reported as lost or stolen'); + expect(field('Document number').value).toBe('123456000'); + }); + + it('submits a complete application and shows the approved status', async () => { + const ready = { + ...makeState({ onboardingStep: 'review', documents: [passportDoc] }, true), + requirements: requirements(true, true), + }; + const approved: KycOnboardingState = { + ...ready, + canSubmit: false, + profile: { ...ready.profile, status: 'approved', onboardingStep: 'complete', expiresAt: '2027-06-15T00:00:00.000Z' }, + }; + fetchMock.mockResolvedValueOnce(jsonResponse(200, ready)).mockResolvedValueOnce(jsonResponse(200, approved)); + await render(); + await waitFor(() => hasButton('Submit for verification')); + + await click(button('Submit for verification')); + + await waitFor(() => pageText().includes('Your identity is verified.')); + expect(String(fetchMock.mock.calls[1][0])).toMatch(/\/kyc\/user-1\/submit$/); + expect(hasButton('Submit for verification')).toBe(false); + }); + + it('shows rejection reasons and lets the applicant edit documents', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( + 200, + makeState({ status: 'rejected', onboardingStep: 'review', rejectionReasons: ['Photo page is cropped'] }), + ), + ); + await render(); + await waitFor(() => pageText().includes('Photo page is cropped')); + + const editButtons = Array.from(container.querySelectorAll('button')).filter((b) => b.textContent === 'Edit'); + expect(editButtons).toHaveLength(2); + await click(editButtons[1]); + + expect(pageText()).toContain('Required documents'); + }); +}); diff --git a/frontend/components/layout/Sidebar.tsx b/frontend/components/layout/Sidebar.tsx index 98f15278..b287f86e 100644 --- a/frontend/components/layout/Sidebar.tsx +++ b/frontend/components/layout/Sidebar.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; -import { LayoutDashboard, Folder, FileText, Wallet, Scale, Menu, X, QrCode } from 'lucide-react'; +import { LayoutDashboard, Folder, FileText, Wallet, Scale, Menu, X, QrCode, ShieldCheck } from 'lucide-react'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -14,6 +14,7 @@ const navigation = [ { name: 'Payments', href: '/dashboard/payments', icon: Wallet }, { name: 'QR / NFC Pay', href: '/dashboard/payments/qr', icon: QrCode }, { name: 'Disputes', href: '/dashboard/disputes', icon: Scale }, + { name: 'Identity (KYC)', href: '/dashboard/kyc', icon: ShieldCheck }, ]; export function Sidebar() { diff --git a/frontend/lib/kyc.test.ts b/frontend/lib/kyc.test.ts new file mode 100644 index 00000000..89e7270b --- /dev/null +++ b/frontend/lib/kyc.test.ts @@ -0,0 +1,204 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + KYC_MAX_FILE_BYTES, + KycApiError, + kycApi, + parseIsoDate, + readFileAsBase64, + validateDocumentFile, + validateDocumentForm, + validatePersonalInfo, + type KycDocumentForm, + type KycPersonalInfo, +} from '@/lib/kyc'; + +const NOW = new Date('2026-06-15T12:00:00.000Z'); + +const person: KycPersonalInfo = { + firstName: 'Ada', + lastName: 'Lovelace', + dateOfBirth: '1990-04-12', + nationality: 'GB', + countryOfResidence: 'GB', + address: { line1: '1 Main St', city: 'London', postalCode: 'N1 9GU', country: 'GB' }, +}; + +const passportForm: KycDocumentForm = { + type: 'passport', + documentNumber: '123456789', + issuingCountry: 'GB', + issueDate: '', + expiryDate: '2030-01-01', +}; + +function makeFile(size: number, type = 'application/pdf', name = 'doc.pdf'): File { + return new File([new Uint8Array(size)], name, { type }); +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('parseIsoDate', () => { + it('accepts real calendar dates only', () => { + expect(parseIsoDate('2024-02-29')).toBeInstanceOf(Date); + expect(parseIsoDate('2023-02-29')).toBeUndefined(); + expect(parseIsoDate('')).toBeUndefined(); + }); +}); + +describe('validatePersonalInfo', () => { + it('returns no errors for a complete adult profile', () => { + expect(validatePersonalInfo(person, NOW)).toEqual({}); + }); + + it('flags missing fields, bad country codes, and underage applicants', () => { + const errors = validatePersonalInfo( + { + ...person, + firstName: ' ', + dateOfBirth: '2012-01-01', + nationality: 'GBR', + address: { ...person.address, city: '', country: 'x' }, + }, + NOW, + ); + + expect(errors).toEqual({ + firstName: 'First name is required', + dateOfBirth: 'You must be at least 18 years old', + nationality: 'Use a 2-letter country code, e.g. US', + 'address.city': 'City is required', + 'address.country': 'Use a 2-letter country code, e.g. US', + }); + }); + + it('rejects a future date of birth', () => { + expect(validatePersonalInfo({ ...person, dateOfBirth: '2030-01-01' }, NOW).dateOfBirth).toBe( + 'Date of birth cannot be in the future', + ); + }); +}); + +describe('validateDocumentFile', () => { + it('accepts supported files within the size limits', () => { + expect(validateDocumentFile(makeFile(4096))).toBeUndefined(); + expect(validateDocumentFile(makeFile(4096, 'image/png', 'id.png'))).toBeUndefined(); + }); + + it('rejects missing, unsupported, oversized, and tiny files', () => { + expect(validateDocumentFile(null)).toBe('Choose a file to upload'); + expect(validateDocumentFile(makeFile(4096, 'image/gif', 'id.gif'))).toBe('Upload a JPEG, PNG, WebP, or PDF file'); + expect(validateDocumentFile(makeFile(KYC_MAX_FILE_BYTES + 1))).toBe('File must be 10 MB or smaller'); + expect(validateDocumentFile(makeFile(100))).toBe('File is too small to be a legible document'); + }); +}); + +describe('validateDocumentForm', () => { + it('accepts a valid identity document', () => { + expect(validateDocumentForm(passportForm, makeFile(4096), NOW)).toEqual({}); + }); + + it('requires a number and a future expiry for identity documents', () => { + expect(validateDocumentForm({ ...passportForm, documentNumber: '', expiryDate: '2026-06-01' }, makeFile(4096), NOW)).toEqual({ + documentNumber: 'Document number is required', + expiryDate: 'This document has expired', + }); + expect(validateDocumentForm({ ...passportForm, documentNumber: 'AB#12' }, makeFile(4096), NOW).documentNumber).toBe( + 'Use letters and digits only', + ); + }); + + it('requires a recent issue date for proof of address', () => { + const bill: KycDocumentForm = { ...passportForm, type: 'utility_bill', documentNumber: '', expiryDate: '' }; + + expect(validateDocumentForm({ ...bill, issueDate: '2026-06-01' }, makeFile(4096), NOW)).toEqual({}); + expect(validateDocumentForm(bill, makeFile(4096), NOW).issueDate).toBe('Enter the issue date'); + expect(validateDocumentForm({ ...bill, issueDate: '2026-01-01' }, makeFile(4096), NOW).issueDate).toBe( + 'Must be issued within the last 90 days', + ); + expect(validateDocumentForm({ ...bill, issueDate: '2026-07-01' }, makeFile(4096), NOW).issueDate).toBe( + 'Issue date cannot be in the future', + ); + }); + + it('includes file problems alongside field errors', () => { + expect(validateDocumentForm({ ...passportForm, issuingCountry: '' }, null, NOW)).toEqual({ + issuingCountry: 'Use a 2-letter country code, e.g. US', + file: 'Choose a file to upload', + }); + }); +}); + +describe('readFileAsBase64', () => { + it('returns the base64 payload without the data URL prefix', async () => { + const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }); + await expect(readFileAsBase64(file)).resolves.toBe(btoa('hello')); + }); +}); + +describe('kycApi', () => { + it('returns null when the applicant has not started onboarding', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse(404, { error: { code: 'KYC_NOT_FOUND', message: 'KYC profile not found', status: 404 } }), + ); + await expect(kycApi.getState('user-1')).resolves.toBeNull(); + }); + + it('surfaces service error details', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse(422, { + error: { code: 'INVALID_DOCUMENT', message: 'Document failed validation', details: ['Document expired on 2026-01-01'] }, + }), + ); + + const error = await kycApi.submit('user-1').catch((err: unknown) => err); + expect(error).toBeInstanceOf(KycApiError); + expect(error).toMatchObject({ + status: 422, + code: 'INVALID_DOCUMENT', + details: ['Document expired on 2026-01-01'], + }); + }); + + it('surfaces request validation errors', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse(400, { + error: 'VALIDATION_FAILED', + message: 'Request validation failed', + details: [{ path: 'nationality', message: 'Use a 2-letter ISO country code' }], + }), + ); + + const error = await kycApi.savePersonalInfo('user-1', person).catch((err: unknown) => err); + expect(error).toMatchObject({ + status: 400, + code: 'VALIDATION_FAILED', + details: ['nationality: Use a 2-letter ISO country code'], + }); + }); + + it('sends uploads as JSON to the user-scoped endpoint', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(201, { document: {}, state: {} })); + + await kycApi.uploadDocument('user/1', { + type: 'passport', + issuingCountry: 'GB', + documentNumber: '123456789', + expiryDate: '2030-01-01', + fileName: 'p.pdf', + mimeType: 'application/pdf', + fileSize: 4, + fileContent: 'AAAA', + }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toMatch(/\/kyc\/user%2F1\/documents$/); + expect(init?.method).toBe('POST'); + expect(JSON.parse(String(init?.body))).toMatchObject({ type: 'passport', fileContent: 'AAAA' }); + }); +}); diff --git a/frontend/lib/kyc.ts b/frontend/lib/kyc.ts new file mode 100644 index 00000000..2cba070e --- /dev/null +++ b/frontend/lib/kyc.ts @@ -0,0 +1,295 @@ +import { resolveApiUrl } from '@/lib/api/client'; + +// Types and client-side checks for the KYC onboarding flow (#921). The checks +// mirror backend/src/services/kyc-verification.ts so applicants get feedback +// before uploading; the backend remains the source of truth. + +export type KycStatus = 'pending' | 'under_review' | 'approved' | 'rejected' | 'expired'; +export type KycDocumentStatus = 'pending' | 'approved' | 'rejected'; +export type KycOnboardingStep = 'personal_info' | 'documents' | 'review' | 'complete'; +export type KycDocumentType = + | 'passport' + | 'drivers_license' + | 'national_id' + | 'utility_bill' + | 'bank_statement'; + +export interface KycAddress { + line1: string; + line2?: string; + city: string; + state?: string; + postalCode: string; + country: string; +} + +export interface KycPersonalInfo { + firstName: string; + lastName: string; + dateOfBirth: string; + nationality: string; + countryOfResidence: string; + address: KycAddress; +} + +export interface KycDocument { + id: string; + type: KycDocumentType; + status: KycDocumentStatus; + uploadedAt: string; + fileName?: string; + documentNumberMasked?: string; + issuingCountry?: string; + issueDate?: string; + expiryDate?: string; + rejectionReasons?: string[]; + warnings?: string[]; +} + +export interface KycStatusChange { + from: KycStatus | null; + to: KycStatus; + at: string; + actor: string; + reason?: string; +} + +export interface KycProfile { + userId: string; + status: KycStatus; + onboardingStep: KycOnboardingStep; + personalInfo?: KycPersonalInfo; + documents: KycDocument[]; + rejectionReasons: string[]; + reviewNotes?: string; + submittedAt?: string; + reviewedAt?: string; + expiresAt?: string; + statusHistory: KycStatusChange[]; +} + +export interface KycRequirement { + id: 'identity_document' | 'proof_of_address'; + label: string; + acceptedTypes: KycDocumentType[]; + satisfied: boolean; +} + +export interface KycOnboardingState { + profile: KycProfile; + requirements: KycRequirement[]; + canSubmit: boolean; +} + +export interface KycDocumentForm { + type: KycDocumentType; + documentNumber: string; + issuingCountry: string; + issueDate: string; + expiryDate: string; +} + +export interface KycDocumentUpload { + type: KycDocumentType; + documentNumber?: string; + issuingCountry: string; + issueDate?: string; + expiryDate?: string; + fileName: string; + mimeType: string; + fileSize: number; + fileContent: string; +} + +export type FieldErrors = Record; + +// ── Rules ──────────────────────────────────────────────────────────────────── + +export const IDENTITY_DOCUMENT_TYPES: KycDocumentType[] = ['passport', 'drivers_license', 'national_id']; +export const ADDRESS_DOCUMENT_TYPES: KycDocumentType[] = ['utility_bill', 'bank_statement']; + +export const DOCUMENT_TYPE_LABELS: Record = { + passport: 'Passport', + drivers_license: "Driver's license", + national_id: 'National ID card', + utility_bill: 'Utility bill', + bank_statement: 'Bank statement', +}; + +export const KYC_ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']; +export const KYC_MAX_FILE_BYTES = 10 * 1024 * 1024; +export const KYC_MIN_FILE_BYTES = 1024; +export const PROOF_OF_ADDRESS_MAX_AGE_DAYS = 90; +export const MINIMUM_AGE = 18; + +const DAY_MS = 24 * 60 * 60 * 1000; +const COUNTRY_CODE = /^[A-Za-z]{2}$/; +const DOCUMENT_NUMBER = /^[A-Z0-9]{4,20}$/; + +export function isIdentityDocument(type: KycDocumentType): boolean { + return IDENTITY_DOCUMENT_TYPES.includes(type); +} + +export function parseIsoDate(value: string): Date | undefined { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined; + const date = new Date(`${value}T00:00:00.000Z`); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString().slice(0, 10) === value ? date : undefined; +} + +function startOfUtcDay(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +function ageOn(dateOfBirth: Date, now: Date): number { + const age = now.getUTCFullYear() - dateOfBirth.getUTCFullYear(); + const beforeBirthday = + now.getUTCMonth() < dateOfBirth.getUTCMonth() || + (now.getUTCMonth() === dateOfBirth.getUTCMonth() && now.getUTCDate() < dateOfBirth.getUTCDate()); + return beforeBirthday ? age - 1 : age; +} + +// ── Validation ─────────────────────────────────────────────────────────────── + +export function validatePersonalInfo(info: KycPersonalInfo, now: Date = new Date()): FieldErrors { + const errors: FieldErrors = {}; + + if (!info.firstName.trim()) errors.firstName = 'First name is required'; + if (!info.lastName.trim()) errors.lastName = 'Last name is required'; + + const dateOfBirth = parseIsoDate(info.dateOfBirth); + if (!dateOfBirth) { + errors.dateOfBirth = 'Enter a valid date of birth'; + } else if (dateOfBirth > now) { + errors.dateOfBirth = 'Date of birth cannot be in the future'; + } else if (ageOn(dateOfBirth, now) < MINIMUM_AGE) { + errors.dateOfBirth = `You must be at least ${MINIMUM_AGE} years old`; + } + + if (!COUNTRY_CODE.test(info.nationality)) errors.nationality = 'Use a 2-letter country code, e.g. US'; + if (!COUNTRY_CODE.test(info.countryOfResidence)) { + errors.countryOfResidence = 'Use a 2-letter country code, e.g. US'; + } + if (!info.address.line1.trim()) errors['address.line1'] = 'Address is required'; + if (!info.address.city.trim()) errors['address.city'] = 'City is required'; + if (info.address.postalCode.trim().length < 2) errors['address.postalCode'] = 'Postal code is required'; + if (!COUNTRY_CODE.test(info.address.country)) errors['address.country'] = 'Use a 2-letter country code, e.g. US'; + + return errors; +} + +export function validateDocumentFile(file: File | null): string | undefined { + if (!file) return 'Choose a file to upload'; + if (!KYC_ALLOWED_MIME_TYPES.includes(file.type)) return 'Upload a JPEG, PNG, WebP, or PDF file'; + if (file.size > KYC_MAX_FILE_BYTES) return 'File must be 10 MB or smaller'; + if (file.size < KYC_MIN_FILE_BYTES) return 'File is too small to be a legible document'; + return undefined; +} + +export function validateDocumentForm(form: KycDocumentForm, file: File | null, now: Date = new Date()): FieldErrors { + const errors: FieldErrors = {}; + const today = startOfUtcDay(now); + + if (!COUNTRY_CODE.test(form.issuingCountry)) errors.issuingCountry = 'Use a 2-letter country code, e.g. US'; + + if (isIdentityDocument(form.type)) { + const number = form.documentNumber.replace(/[\s-]/g, '').toUpperCase(); + if (!number) errors.documentNumber = 'Document number is required'; + else if (!DOCUMENT_NUMBER.test(number)) errors.documentNumber = 'Use letters and digits only'; + + const expiry = parseIsoDate(form.expiryDate); + if (!expiry) errors.expiryDate = 'Enter the expiry date'; + else if (expiry <= today) errors.expiryDate = 'This document has expired'; + } else { + const issued = parseIsoDate(form.issueDate); + if (!issued) errors.issueDate = 'Enter the issue date'; + else if (issued > today) errors.issueDate = 'Issue date cannot be in the future'; + else if (today.getTime() - issued.getTime() > PROOF_OF_ADDRESS_MAX_AGE_DAYS * DAY_MS) { + errors.issueDate = `Must be issued within the last ${PROOF_OF_ADDRESS_MAX_AGE_DAYS} days`; + } + } + + const fileError = validateDocumentFile(file); + if (fileError) errors.file = fileError; + + return errors; +} + +export function readFileAsBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = String(reader.result ?? ''); + resolve(result.slice(result.indexOf(',') + 1)); + }; + reader.onerror = () => reject(reader.error ?? new Error('Could not read file')); + reader.readAsDataURL(file); + }); +} + +// ── API ────────────────────────────────────────────────────────────────────── + +export class KycApiError extends Error { + status: number; + code?: string; + details: string[]; + + constructor(message: string, status: number, code?: string, details: string[] = []) { + super(message); + this.name = 'KycApiError'; + this.status = status; + this.code = code; + this.details = details; + } +} + +interface ErrorEnvelope { + error?: string | { code?: string; message?: string; details?: unknown }; + message?: string; + details?: Array<{ path: string; message: string }>; +} + +function toKycApiError(status: number, body: ErrorEnvelope | null): KycApiError { + if (body && typeof body.error === 'object') { + const details = Array.isArray(body.error.details) ? body.error.details.map(String) : []; + return new KycApiError(body.error.message ?? 'Request failed', status, body.error.code, details); + } + const details = (body?.details ?? []).map((d) => `${d.path}: ${d.message}`); + return new KycApiError(body?.message ?? 'Request failed', status, typeof body?.error === 'string' ? body.error : undefined, details); +} + +async function request(path: string, init: RequestInit = {}): Promise { + const response = await fetch(resolveApiUrl(path), { + ...init, + headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) }, + }); + const body = (await response.json().catch(() => null)) as unknown; + if (!response.ok) throw toKycApiError(response.status, body as ErrorEnvelope | null); + return body as T; +} + +const userPath = (userId: string) => `/kyc/${encodeURIComponent(userId)}`; + +export const kycApi = { + /** Resolves to `null` when the applicant has not started onboarding. */ + getState: async (userId: string): Promise => { + try { + return await request(userPath(userId)); + } catch (error) { + if (error instanceof KycApiError && error.status === 404) return null; + throw error; + } + }, + savePersonalInfo: (userId: string, info: KycPersonalInfo) => + request(`${userPath(userId)}/personal-info`, { + method: 'PUT', + body: JSON.stringify(info), + }), + uploadDocument: (userId: string, upload: KycDocumentUpload) => + request<{ document: KycDocument; state: KycOnboardingState }>(`${userPath(userId)}/documents`, { + method: 'POST', + body: JSON.stringify(upload), + }), + submit: (userId: string) => + request(`${userPath(userId)}/submit`, { method: 'POST' }), +}; From 422f75493303fdefcb7b76fb02756a8eb2779724 Mon Sep 17 00:00:00 2001 From: Itodo-S Date: Mon, 28 Sep 2026 09:50:27 +0100 Subject: [PATCH 4/4] docs: document KYC onboarding and document verification Refs #921 --- docs/KYC_ONBOARDING.md | 166 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/KYC_ONBOARDING.md diff --git a/docs/KYC_ONBOARDING.md b/docs/KYC_ONBOARDING.md new file mode 100644 index 00000000..fc8691c3 --- /dev/null +++ b/docs/KYC_ONBOARDING.md @@ -0,0 +1,166 @@ +# KYC Onboarding and Document Verification + +Issue: [#921](https://github.com/Smartdevs17/agenticpay/issues/921) + +A three-step identity verification flow for individual users: personal +information → document upload → review and status. Documents are checked +locally (file type, size, signature, number format, dates) and then by a +pluggable verification provider. Applications move through +`pending → under_review → approved | rejected`, and every decision carries +reasons. + +Business verification (KYB) is separate and lives at `/api/v1/kyb`. + +## Backend + +Service: `backend/src/services/kyc.ts` (`KycService`, extends the existing KYC profile, risk and screening logic) +Verification: `backend/src/services/kyc-verification.ts` (format checks and providers) +Schemas: `backend/src/schemas/kyc.ts` +Routes: `backend/src/routes/kyc.ts` (mounted at `/api/v1/kyc`) + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/requirements` | Document rules for building the upload form | +| `GET` | `/:userId` | Onboarding state: profile, requirements, `canSubmit` | +| `PUT` | `/:userId/personal-info` | Step 1 — save personal details and address | +| `POST` | `/:userId/documents` | Step 2 — upload and verify a document | +| `POST` | `/:userId/submit` | Step 3 — submit for review | +| `GET` | `/review/queue` | Applications waiting for a reviewer | +| `POST` | `/:userId/documents/:documentId/review` | Reviewer decision on one document | +| `POST` | `/:userId/review` | Reviewer decision on the application | + +Service errors use the standard error envelope +(`{ error: { code, message, status, details } }`), with the individual +validation failures in `details`: + +| Code | Status | When | +| --- | --- | --- | +| `INVALID_PERSONAL_INFO` | 422 | Under 18, date of birth in the future or implausible | +| `INVALID_DOCUMENT` | 422 | Any document format check failed | +| `DUPLICATE_DOCUMENT` | 409 | The identity document is already linked to another account | +| `PERSONAL_INFO_REQUIRED` | 409 | Documents uploaded or submitted before step 1 | +| `REQUIREMENTS_NOT_MET` | 422 | Submitted without both required documents | +| `KYC_LOCKED` | 409 | Editing while under review or approved | +| `INVALID_STATUS_TRANSITION` | 409 | A decision that the current status does not allow | +| `REJECTION_REASON_REQUIRED` | 400 | Rejecting without a reason | +| `KYC_NOT_FOUND` | 404 | No profile for the user | + +### Uploading a document + +```http +POST /api/v1/kyc/user-123/documents +Content-Type: application/json + +{ + "type": "passport", + "documentNumber": "A12345678", + "issuingCountry": "US", + "expiryDate": "2031-04-30", + "fileName": "passport.pdf", + "mimeType": "application/pdf", + "fileSize": 482113, + "fileContent": "" +} +``` + +Send the file either as base64 `fileContent` (a `data:` URL prefix is +accepted) or as an `https` `fileUrl` for files already held in storage. The +KYC routes accept JSON bodies up to 15 MB so a 10 MB document fits once +encoded. File contents are hashed (SHA-256) and passed to the provider; they +are not stored. Document numbers are stored masked (`*****5678`), and +`documentNumber`, `fileContent` and `dateOfBirth` are redacted from audit log +request bodies. + +### Requirements + +An application needs one of each before it can be submitted: + +- **Identity document** — `passport`, `drivers_license` or `national_id` +- **Proof of address** — `utility_bill` or `bank_statement` + +A document that was rejected does not count; uploading another document of the +same type replaces it. + +### Document checks + +| Check | Rule | +| --- | --- | +| File type | `image/jpeg`, `image/png`, `image/webp`, `application/pdf` (the `kyc` upload category), extension must match | +| File size | 1 KB – 10 MB; must equal the decoded content length | +| File signature | Magic bytes must match the declared type (shared with the upload middleware) | +| Issuing country | 2-letter ISO code | +| Document number (identity) | Required; letters and digits after removing spaces/hyphens. Passport 6–9, national ID 5–20, driver's license 4–20 characters. Country formats override these: US passport `A12345678`/`123456789`, GB passport 9 digits, IN passport letter + 7 digits, NG passport letter + 8 digits, NG national ID (NIN) 11 digits | +| Expiry (identity) | Required, must be after today and within 20 years; expiring within 30 days adds a warning | +| Issue date | Optional for identity documents (not in the future, before expiry); required for proof of address and must be within the last 90 days | +| Applicant age | At least 18 | + +### Status transitions + +| From | To | Trigger | +| --- | --- | --- | +| — | `pending` | Personal information saved | +| `pending` | `under_review` | Applicant submits | +| `under_review` | `approved` | Reviewer approves, or automatic approval | +| `under_review` | `rejected` | Reviewer rejects (reasons required) | +| `rejected` / `expired` | `pending` | Applicant edits details or uploads a document | +| `approved` | `expired` | One year after approval | + +On submission the existing AML, sanctions and PEP checks run and the risk score +is recalculated. Nationality or residence in a high-risk jurisdiction adds 30 +points. An application is approved automatically when every document was +approved by the provider, the risk level is `low`, and no high-risk +jurisdiction is involved; otherwise it waits in the review queue. Pass +`autoApprove: false` to `KycService` to send everything to manual review. +Every transition is recorded in `statusHistory` with the actor and reason. + +### Verification providers + +```ts +interface DocumentVerificationProvider { + readonly name: string; + verify(request: DocumentVerificationRequest): Promise; +} + +// decision: 'approved' | 'rejected' | 'needs_review' +new KycService({ verificationProvider: new MyVendorProvider() }); +``` + +The provider receives the decoded file (or `fileUrl`), its hash, the +normalized document number, dates and the applicant's personal information. +`approved` verifies the document, `rejected` stores the provider's reasons, +and `needs_review` leaves the document `pending` with the reasons as warnings. +A provider that throws is treated as `needs_review`. + +The default `MockDocumentVerificationProvider` is deterministic, for local +development and tests: + +| Document number ends in | Result | +| --- | --- | +| `000` | Rejected — "Document number is reported as lost or stolen" | +| `999` | Needs review — "Automated checks were inconclusive; manual review required" | +| anything else | Approved | + +## Frontend + +- Page: `frontend/app/dashboard/kyc/page.tsx` (linked from the sidebar as + "Identity (KYC)" and from the onboarding wizard's identity step) +- Flow: `frontend/components/kyc/KycOnboardingFlow.tsx` with + `PersonalInfoStep`, `DocumentUploadStep` and `ReviewStep` +- Client checks and API calls: `frontend/lib/kyc.ts` + +The page resumes from the step the backend reports, runs the same file, number +and date checks in the browser before uploading, and shows per-document +statuses, warnings and rejection reasons. + +## Tests + +- `backend/src/services/__tests__/kyc-verification.test.ts` — format checks and the mock provider +- `backend/src/services/__tests__/kyc-onboarding.test.ts` — flow, transitions, auto-approval, review, expiry +- `backend/src/services/__tests__/kyc.test.ts` — existing KYC service behaviour +- `backend/src/routes/__tests__/kyc.test.ts` — HTTP API end to end +- `frontend/lib/kyc.test.ts` and `frontend/components/kyc/__tests__/KycOnboardingFlow.test.tsx` + +```bash +cd backend && npx vitest run src/services/__tests__/kyc*.test.ts src/routes/__tests__/kyc.test.ts +cd frontend && npx vitest run lib/kyc.test.ts components/kyc +```