-- ============================================================================ -- SCHEMA: users -- TABLE: kyc_verifications -- DESCRIPTION: Verificacion de identidad KYC (Know Your Customer) -- VERSION: 1.0.0 -- CREATED: 2026-01-16 -- SPRINT: Sprint 1 - DDL Implementation Roadmap Q1-2026 -- ============================================================================ -- Enum para estado de verificacion KYC DO $$ BEGIN CREATE TYPE users.kyc_status AS ENUM ( 'not_started', -- No ha iniciado proceso 'pending', -- Documentos enviados, pendiente revision 'under_review', -- En proceso de revision manual 'approved', -- Aprobado completamente 'rejected', -- Rechazado (puede reintentar) 'expired', -- Verificacion expirada, requiere re-verificacion 'suspended' -- Suspendido por actividad sospechosa ); EXCEPTION WHEN duplicate_object THEN null; END $$; -- Enum para nivel de verificacion DO $$ BEGIN CREATE TYPE users.kyc_level AS ENUM ( 'none', -- Sin verificacion 'basic', -- Email + telefono verificado 'standard', -- Documento ID verificado 'enhanced', -- ID + prueba de direccion 'full' -- Verificacion completa con video ); EXCEPTION WHEN duplicate_object THEN null; END $$; -- Tabla de Verificaciones KYC CREATE TABLE IF NOT EXISTS users.kyc_verifications ( -- Identificadores id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, -- Estado actual status users.kyc_status NOT NULL DEFAULT 'not_started', level users.kyc_level NOT NULL DEFAULT 'none', -- Verificacion de Email email_verified BOOLEAN NOT NULL DEFAULT FALSE, email_verified_at TIMESTAMPTZ, email_verification_method VARCHAR(50), -- 'code', 'link' -- Verificacion de Telefono phone_verified BOOLEAN NOT NULL DEFAULT FALSE, phone_verified_at TIMESTAMPTZ, phone_verification_method VARCHAR(50), -- 'sms', 'call' -- Verificacion de Documento ID id_document_verified BOOLEAN NOT NULL DEFAULT FALSE, id_document_verified_at TIMESTAMPTZ, id_document_type VARCHAR(50), -- 'passport', 'national_id', 'drivers_license' id_document_number_hash VARCHAR(255), -- Hash del numero para verificacion id_document_country VARCHAR(100), id_document_expiry DATE, id_document_front_url TEXT, -- URL segura al documento (encriptado) id_document_back_url TEXT, id_selfie_url TEXT, -- Selfie con documento -- Verificacion de Direccion address_verified BOOLEAN NOT NULL DEFAULT FALSE, address_verified_at TIMESTAMPTZ, address_document_type VARCHAR(50), -- 'utility_bill', 'bank_statement', 'tax_document' address_document_url TEXT, address_document_date DATE, -- Fecha del documento (max 3 meses) -- Verificacion de Video (enhanced) video_verified BOOLEAN NOT NULL DEFAULT FALSE, video_verified_at TIMESTAMPTZ, video_url TEXT, video_liveness_score DECIMAL(5, 4), -- 0.0000 - 1.0000 -- Proveedor de verificacion externo external_provider VARCHAR(50), -- 'sumsub', 'onfido', 'jumio' external_verification_id VARCHAR(255), external_status VARCHAR(50), external_risk_score DECIMAL(5, 4), -- Informacion de revision reviewer_id UUID, reviewed_at TIMESTAMPTZ, review_notes TEXT, rejection_reason TEXT, rejection_codes JSONB, -- Array de codigos de rechazo -- AML/PEP checks aml_checked BOOLEAN NOT NULL DEFAULT FALSE, aml_checked_at TIMESTAMPTZ, aml_result VARCHAR(50), -- 'clear', 'match', 'potential_match' aml_details JSONB, pep_checked BOOLEAN NOT NULL DEFAULT FALSE, pep_checked_at TIMESTAMPTZ, pep_result VARCHAR(50), pep_details JSONB, sanctions_checked BOOLEAN NOT NULL DEFAULT FALSE, sanctions_checked_at TIMESTAMPTZ, sanctions_result VARCHAR(50), sanctions_details JSONB, -- Expiracion expires_at TIMESTAMPTZ, reminder_sent_at TIMESTAMPTZ, -- Intentos verification_attempts INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 3, locked_until TIMESTAMPTZ, -- Metadata ip_address INET, user_agent TEXT, device_fingerprint VARCHAR(255), metadata JSONB DEFAULT '{}'::JSONB, -- Timestamps started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Constraints CONSTRAINT kyc_unique_user UNIQUE (user_id) ); COMMENT ON TABLE users.kyc_verifications IS 'Registro de verificacion KYC de usuarios para compliance regulatorio'; COMMENT ON COLUMN users.kyc_verifications.level IS 'Nivel de verificacion alcanzado: none, basic, standard, enhanced, full'; COMMENT ON COLUMN users.kyc_verifications.external_risk_score IS 'Score de riesgo del proveedor externo (0-1, menor es mejor)'; -- Indices CREATE INDEX IF NOT EXISTS idx_kyc_user_id ON users.kyc_verifications(user_id); CREATE INDEX IF NOT EXISTS idx_kyc_tenant_id ON users.kyc_verifications(tenant_id); CREATE INDEX IF NOT EXISTS idx_kyc_status ON users.kyc_verifications(status); CREATE INDEX IF NOT EXISTS idx_kyc_level ON users.kyc_verifications(level); CREATE INDEX IF NOT EXISTS idx_kyc_expires_at ON users.kyc_verifications(expires_at) WHERE expires_at IS NOT NULL AND status = 'approved'; CREATE INDEX IF NOT EXISTS idx_kyc_pending_review ON users.kyc_verifications(created_at) WHERE status IN ('pending', 'under_review'); CREATE INDEX IF NOT EXISTS idx_kyc_aml_matches ON users.kyc_verifications(tenant_id, aml_result) WHERE aml_result IN ('match', 'potential_match'); -- Trigger para updated_at DROP TRIGGER IF EXISTS kyc_updated_at ON users.kyc_verifications; CREATE TRIGGER kyc_updated_at BEFORE UPDATE ON users.kyc_verifications FOR EACH ROW EXECUTE FUNCTION users.update_user_timestamp(); -- Funcion para actualizar nivel de KYC automaticamente CREATE OR REPLACE FUNCTION users.update_kyc_level() RETURNS TRIGGER AS $$ BEGIN -- Calcular nivel basado en verificaciones completadas IF NEW.video_verified AND NEW.address_verified AND NEW.id_document_verified AND NEW.phone_verified AND NEW.email_verified THEN NEW.level := 'full'; ELSIF NEW.address_verified AND NEW.id_document_verified AND NEW.phone_verified AND NEW.email_verified THEN NEW.level := 'enhanced'; ELSIF NEW.id_document_verified AND (NEW.phone_verified OR NEW.email_verified) THEN NEW.level := 'standard'; ELSIF NEW.email_verified AND NEW.phone_verified THEN NEW.level := 'basic'; ELSE NEW.level := 'none'; END IF; -- Actualizar status si todas las verificaciones del nivel estan completas IF NEW.level = 'full' AND OLD.status IN ('pending', 'under_review') THEN NEW.status := 'approved'; NEW.completed_at := NOW(); -- Set expiration (typically 1-2 years) NEW.expires_at := NOW() + INTERVAL '2 years'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS kyc_level_update ON users.kyc_verifications; CREATE TRIGGER kyc_level_update BEFORE UPDATE ON users.kyc_verifications FOR EACH ROW EXECUTE FUNCTION users.update_kyc_level(); -- RLS Policy para multi-tenancy ALTER TABLE users.kyc_verifications ENABLE ROW LEVEL SECURITY; CREATE POLICY kyc_tenant_isolation ON users.kyc_verifications FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -- Policy especial para revisores (pueden ver todos los pendientes) CREATE POLICY kyc_reviewer_access ON users.kyc_verifications FOR SELECT USING ( current_setting('app.is_kyc_reviewer', true)::boolean = true AND status IN ('pending', 'under_review') ); -- Grants GRANT SELECT, INSERT, UPDATE ON users.kyc_verifications TO trading_app; GRANT SELECT ON users.kyc_verifications TO trading_readonly;