Migración desde trading-platform/apps/database - Estándar multi-repo v2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 08:32:52 -06:00
parent 0687e587ad
commit e520268348
26 changed files with 5431 additions and 3 deletions

View File

@ -1,3 +0,0 @@
# trading-platform-database-v2
Database de trading-platform - Workspace V2

View File

@ -0,0 +1,387 @@
-- ============================================================================
-- Audit Schema: Audit Logs Table
-- Comprehensive audit trail for Trading Platform SaaS
-- ============================================================================
-- Create audit schema if not exists
CREATE SCHEMA IF NOT EXISTS audit;
-- Grant usage
GRANT USAGE ON SCHEMA audit TO trading_user;
-- ============================================================================
-- AUDIT_LOGS TABLE
-- Immutable audit trail for all significant actions
-- ============================================================================
CREATE TABLE IF NOT EXISTS audit.audit_logs (
-- Primary key (using BIGSERIAL for high volume)
id BIGSERIAL PRIMARY KEY,
-- Event identification
event_id UUID NOT NULL DEFAULT gen_random_uuid(),
event_type VARCHAR(100) NOT NULL,
event_category VARCHAR(50) NOT NULL,
-- Actor information (who performed the action)
actor_id UUID,
actor_email VARCHAR(255),
actor_type VARCHAR(20) NOT NULL DEFAULT 'user'
CHECK (actor_type IN ('user', 'system', 'api', 'webhook', 'scheduled')),
actor_ip INET,
actor_user_agent TEXT,
-- Tenant context
tenant_id UUID,
-- Target resource
resource_type VARCHAR(100) NOT NULL,
resource_id VARCHAR(255),
resource_name VARCHAR(255),
-- Action details
action VARCHAR(50) NOT NULL,
action_result VARCHAR(20) NOT NULL DEFAULT 'success'
CHECK (action_result IN ('success', 'failure', 'partial', 'pending')),
-- Change tracking
previous_state JSONB,
new_state JSONB,
changes JSONB, -- Computed diff between previous and new state
-- Additional context
metadata JSONB DEFAULT '{}'::jsonb,
request_id VARCHAR(100),
session_id UUID,
-- Error information (for failures)
error_code VARCHAR(50),
error_message TEXT,
-- Timestamps
occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Retention marker
is_archived BOOLEAN NOT NULL DEFAULT false,
archived_at TIMESTAMPTZ
);
-- ============================================================================
-- INDEXES
-- Optimized for common query patterns
-- ============================================================================
-- Time-based queries (most common)
CREATE INDEX IF NOT EXISTS idx_audit_logs_occurred_at ON audit.audit_logs(occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_time ON audit.audit_logs(tenant_id, occurred_at DESC);
-- Actor queries
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_id ON audit.audit_logs(actor_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_email ON audit.audit_logs(actor_email);
-- Resource queries
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit.audit_logs(resource_type, resource_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource_tenant ON audit.audit_logs(tenant_id, resource_type, occurred_at DESC);
-- Event type queries
CREATE INDEX IF NOT EXISTS idx_audit_logs_event_type ON audit.audit_logs(event_type);
CREATE INDEX IF NOT EXISTS idx_audit_logs_event_category ON audit.audit_logs(event_category);
-- Action queries
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit.audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action_result ON audit.audit_logs(action_result) WHERE action_result = 'failure';
-- Request tracking
CREATE INDEX IF NOT EXISTS idx_audit_logs_request_id ON audit.audit_logs(request_id) WHERE request_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_logs_session_id ON audit.audit_logs(session_id) WHERE session_id IS NOT NULL;
-- Archival
CREATE INDEX IF NOT EXISTS idx_audit_logs_archived ON audit.audit_logs(is_archived, occurred_at);
-- ============================================================================
-- PARTITIONING (for high-volume deployments)
-- Uncomment to enable monthly partitions
-- ============================================================================
-- CREATE TABLE audit.audit_logs_partitioned (
-- LIKE audit.audit_logs INCLUDING ALL
-- ) PARTITION BY RANGE (occurred_at);
-- ============================================================================
-- ROW LEVEL SECURITY
-- ============================================================================
ALTER TABLE audit.audit_logs ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see audit logs in their tenant
-- Note: System admins may need broader access
CREATE POLICY audit_logs_tenant_isolation ON audit.audit_logs
FOR SELECT
USING (
tenant_id = current_setting('app.current_tenant_id', true)::uuid
OR current_setting('app.is_system_admin', true)::boolean = true
);
-- Policy: Insert is allowed for logging (no tenant check on insert)
CREATE POLICY audit_logs_insert ON audit.audit_logs
FOR INSERT
WITH CHECK (true);
-- ============================================================================
-- FUNCTION: Log audit event
-- ============================================================================
CREATE OR REPLACE FUNCTION audit.log_event(
p_event_type VARCHAR(100),
p_event_category VARCHAR(50),
p_action VARCHAR(50),
p_resource_type VARCHAR(100),
p_resource_id VARCHAR(255) DEFAULT NULL,
p_resource_name VARCHAR(255) DEFAULT NULL,
p_actor_id UUID DEFAULT NULL,
p_actor_email VARCHAR(255) DEFAULT NULL,
p_actor_type VARCHAR(20) DEFAULT 'user',
p_actor_ip INET DEFAULT NULL,
p_tenant_id UUID DEFAULT NULL,
p_previous_state JSONB DEFAULT NULL,
p_new_state JSONB DEFAULT NULL,
p_action_result VARCHAR(20) DEFAULT 'success',
p_metadata JSONB DEFAULT '{}'::jsonb,
p_request_id VARCHAR(100) DEFAULT NULL,
p_session_id UUID DEFAULT NULL,
p_error_code VARCHAR(50) DEFAULT NULL,
p_error_message TEXT DEFAULT NULL
)
RETURNS BIGINT AS $$
DECLARE
v_log_id BIGINT;
v_changes JSONB;
BEGIN
-- Compute changes if both states provided
IF p_previous_state IS NOT NULL AND p_new_state IS NOT NULL THEN
v_changes := audit.compute_json_diff(p_previous_state, p_new_state);
END IF;
INSERT INTO audit.audit_logs (
event_type, event_category, action,
resource_type, resource_id, resource_name,
actor_id, actor_email, actor_type, actor_ip,
tenant_id,
previous_state, new_state, changes,
action_result, metadata,
request_id, session_id,
error_code, error_message
) VALUES (
p_event_type, p_event_category, p_action,
p_resource_type, p_resource_id, p_resource_name,
p_actor_id, p_actor_email, p_actor_type, p_actor_ip,
p_tenant_id,
p_previous_state, p_new_state, v_changes,
p_action_result, p_metadata,
p_request_id, p_session_id,
p_error_code, p_error_message
)
RETURNING id INTO v_log_id;
RETURN v_log_id;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- FUNCTION: Compute JSON diff
-- ============================================================================
CREATE OR REPLACE FUNCTION audit.compute_json_diff(
p_old JSONB,
p_new JSONB
)
RETURNS JSONB AS $$
DECLARE
v_key TEXT;
v_diff JSONB := '{}'::jsonb;
BEGIN
-- Find changed and added keys
FOR v_key IN SELECT jsonb_object_keys(p_new)
LOOP
IF NOT p_old ? v_key THEN
-- New key added
v_diff := v_diff || jsonb_build_object(v_key, jsonb_build_object(
'action', 'added',
'new', p_new->v_key
));
ELSIF p_old->v_key != p_new->v_key THEN
-- Key modified
v_diff := v_diff || jsonb_build_object(v_key, jsonb_build_object(
'action', 'modified',
'old', p_old->v_key,
'new', p_new->v_key
));
END IF;
END LOOP;
-- Find deleted keys
FOR v_key IN SELECT jsonb_object_keys(p_old)
LOOP
IF NOT p_new ? v_key THEN
v_diff := v_diff || jsonb_build_object(v_key, jsonb_build_object(
'action', 'deleted',
'old', p_old->v_key
));
END IF;
END LOOP;
RETURN v_diff;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- ============================================================================
-- CONVENIENCE FUNCTIONS FOR COMMON EVENTS
-- ============================================================================
-- Log authentication event
CREATE OR REPLACE FUNCTION audit.log_auth_event(
p_action VARCHAR(50), -- 'login', 'logout', 'login_failed', 'password_changed', etc.
p_user_id UUID,
p_user_email VARCHAR(255),
p_tenant_id UUID,
p_ip INET DEFAULT NULL,
p_success BOOLEAN DEFAULT true,
p_metadata JSONB DEFAULT '{}'::jsonb,
p_error_message TEXT DEFAULT NULL
)
RETURNS BIGINT AS $$
BEGIN
RETURN audit.log_event(
'auth.' || p_action,
'authentication',
p_action,
'user',
p_user_id::text,
p_user_email,
p_user_id,
p_user_email,
'user',
p_ip,
p_tenant_id,
NULL, NULL,
CASE WHEN p_success THEN 'success' ELSE 'failure' END,
p_metadata,
NULL, NULL,
CASE WHEN NOT p_success THEN 'AUTH_FAILED' ELSE NULL END,
p_error_message
);
END;
$$ LANGUAGE plpgsql;
-- Log resource CRUD event
CREATE OR REPLACE FUNCTION audit.log_resource_event(
p_action VARCHAR(50), -- 'created', 'updated', 'deleted', 'viewed'
p_resource_type VARCHAR(100),
p_resource_id VARCHAR(255),
p_resource_name VARCHAR(255),
p_actor_id UUID,
p_actor_email VARCHAR(255),
p_tenant_id UUID,
p_previous_state JSONB DEFAULT NULL,
p_new_state JSONB DEFAULT NULL,
p_metadata JSONB DEFAULT '{}'::jsonb
)
RETURNS BIGINT AS $$
BEGIN
RETURN audit.log_event(
p_resource_type || '.' || p_action,
'resource',
p_action,
p_resource_type,
p_resource_id,
p_resource_name,
p_actor_id,
p_actor_email,
'user',
NULL,
p_tenant_id,
p_previous_state,
p_new_state,
'success',
p_metadata
);
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- VIEW: Recent audit logs
-- ============================================================================
CREATE OR REPLACE VIEW audit.v_recent_logs AS
SELECT
id,
event_id,
event_type,
event_category,
actor_email,
actor_type,
actor_ip,
tenant_id,
resource_type,
resource_id,
resource_name,
action,
action_result,
changes,
metadata,
error_message,
occurred_at
FROM audit.audit_logs
WHERE occurred_at > CURRENT_TIMESTAMP - INTERVAL '30 days'
AND is_archived = false
ORDER BY occurred_at DESC;
-- ============================================================================
-- FUNCTION: Archive old logs
-- ============================================================================
CREATE OR REPLACE FUNCTION audit.archive_old_logs(
p_older_than_days INTEGER DEFAULT 90
)
RETURNS INTEGER AS $$
DECLARE
v_count INTEGER;
BEGIN
UPDATE audit.audit_logs
SET is_archived = true,
archived_at = CURRENT_TIMESTAMP
WHERE is_archived = false
AND occurred_at < CURRENT_TIMESTAMP - (p_older_than_days || ' days')::interval;
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- GRANTS
-- ============================================================================
GRANT SELECT ON audit.audit_logs TO trading_user;
GRANT INSERT ON audit.audit_logs TO trading_user;
GRANT SELECT ON audit.v_recent_logs TO trading_user;
GRANT USAGE, SELECT ON SEQUENCE audit.audit_logs_id_seq TO trading_user;
GRANT EXECUTE ON FUNCTION audit.log_event TO trading_user;
GRANT EXECUTE ON FUNCTION audit.log_auth_event TO trading_user;
GRANT EXECUTE ON FUNCTION audit.log_resource_event TO trading_user;
GRANT EXECUTE ON FUNCTION audit.compute_json_diff TO trading_user;
-- ============================================================================
-- COMMENTS
-- ============================================================================
COMMENT ON TABLE audit.audit_logs IS 'Immutable audit trail for all significant actions';
COMMENT ON COLUMN audit.audit_logs.event_type IS 'Full event type e.g., auth.login, user.created';
COMMENT ON COLUMN audit.audit_logs.event_category IS 'High-level category: authentication, resource, system';
COMMENT ON COLUMN audit.audit_logs.actor_type IS 'Type of actor: user, system, api, webhook, scheduled';
COMMENT ON COLUMN audit.audit_logs.changes IS 'Computed diff between previous_state and new_state';
COMMENT ON COLUMN audit.audit_logs.is_archived IS 'Marker for log retention/archival';
COMMENT ON FUNCTION audit.log_event IS 'Main function to log any audit event';
COMMENT ON FUNCTION audit.log_auth_event IS 'Convenience function for authentication events';
COMMENT ON FUNCTION audit.log_resource_event IS 'Convenience function for resource CRUD events';

View File

@ -0,0 +1,150 @@
-- ============================================================================
-- SCHEMA: auth
-- TABLE: sessions
-- DESCRIPTION: Gestion de sesiones de usuario con tracking de dispositivos
-- VERSION: 1.0.0
-- CREATED: 2026-01-10
-- ============================================================================
-- Crear schema si no existe
CREATE SCHEMA IF NOT EXISTS auth;
-- Enum para estado de sesion
DO $$ BEGIN
CREATE TYPE auth.session_status AS ENUM (
'active', -- Sesion activa
'expired', -- Expirada por tiempo
'revoked' -- Revocada manualmente (logout)
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para tipo de dispositivo
DO $$ BEGIN
CREATE TYPE auth.device_type AS ENUM (
'desktop',
'mobile',
'tablet',
'unknown'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de Sesiones
CREATE TABLE IF NOT EXISTS auth.sessions (
-- 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,
-- Token hash (para validacion)
token_hash VARCHAR(255) NOT NULL,
-- Informacion del dispositivo
device_type auth.device_type DEFAULT 'unknown',
device_name VARCHAR(255),
browser VARCHAR(100),
browser_version VARCHAR(50),
os VARCHAR(100),
os_version VARCHAR(50),
-- Ubicacion
ip_address INET,
user_agent TEXT,
-- Estado
status auth.session_status NOT NULL DEFAULT 'active',
-- Timestamps
last_active_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE auth.sessions IS
'Sesiones activas de usuarios con informacion de dispositivo para seguridad';
COMMENT ON COLUMN auth.sessions.token_hash IS
'Hash SHA256 del refresh token para validacion sin almacenar el token';
-- Indices
CREATE INDEX IF NOT EXISTS idx_sessions_user
ON auth.sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_tenant
ON auth.sessions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash
ON auth.sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_status
ON auth.sessions(status) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_sessions_expires
ON auth.sessions(expires_at) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_sessions_last_active
ON auth.sessions(last_active_at DESC);
-- Funcion para limpiar sesiones expiradas
CREATE OR REPLACE FUNCTION auth.cleanup_expired_sessions()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
UPDATE auth.sessions
SET status = 'expired'
WHERE status = 'active'
AND expires_at < NOW();
GET DIAGNOSTICS deleted_count = ROW_COUNT;
-- Eliminar sesiones muy antiguas (> 90 dias)
DELETE FROM auth.sessions
WHERE (status = 'expired' OR status = 'revoked')
AND created_at < NOW() - INTERVAL '90 days';
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION auth.cleanup_expired_sessions() IS
'Limpia sesiones expiradas y elimina registros muy antiguos. Ejecutar periodicamente.';
-- Funcion para revocar todas las sesiones de un usuario
CREATE OR REPLACE FUNCTION auth.revoke_all_user_sessions(
p_user_id UUID,
p_except_session_id UUID DEFAULT NULL
)
RETURNS INTEGER AS $$
DECLARE
revoked_count INTEGER;
BEGIN
UPDATE auth.sessions
SET status = 'revoked',
revoked_at = NOW()
WHERE user_id = p_user_id
AND status = 'active'
AND (p_except_session_id IS NULL OR id != p_except_session_id);
GET DIAGNOSTICS revoked_count = ROW_COUNT;
RETURN revoked_count;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION auth.revoke_all_user_sessions IS
'Revoca todas las sesiones activas de un usuario, opcionalmente excepto una sesion especifica';
-- RLS Policy para multi-tenancy
ALTER TABLE auth.sessions ENABLE ROW LEVEL SECURITY;
CREATE POLICY sessions_tenant_isolation ON auth.sessions
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.sessions TO trading_app;
GRANT SELECT ON auth.sessions TO trading_readonly;

View File

@ -0,0 +1,184 @@
-- ============================================================================
-- SCHEMA: auth
-- TABLE: tokens
-- DESCRIPTION: Tokens de refresh, reset password, verificacion email, etc.
-- VERSION: 1.0.0
-- CREATED: 2026-01-10
-- ============================================================================
-- Enum para tipo de token
DO $$ BEGIN
CREATE TYPE auth.token_type AS ENUM (
'refresh', -- Refresh token para JWT
'password_reset', -- Reset de password
'email_verify', -- Verificacion de email
'phone_verify', -- Verificacion de telefono
'api_key', -- API key para integraciones
'invitation' -- Invitacion a unirse a tenant
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para estado de token
DO $$ BEGIN
CREATE TYPE auth.token_status AS ENUM (
'active', -- Token activo y usable
'used', -- Ya fue usado (one-time tokens)
'expired', -- Expirado por tiempo
'revoked' -- Revocado manualmente
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de Tokens
CREATE TABLE IF NOT EXISTS auth.tokens (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users.users(id) ON DELETE CASCADE, -- NULL para invitations pre-user
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
-- Token info
token_type auth.token_type NOT NULL,
token_hash VARCHAR(255) NOT NULL, -- Hash del token
-- Estado
status auth.token_status NOT NULL DEFAULT 'active',
-- Metadata (flexible por tipo)
metadata JSONB DEFAULT '{}'::JSONB,
-- Para invitations: { "email": "...", "role_id": "..." }
-- Para api_key: { "name": "...", "scopes": [...] }
-- Timestamps
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE auth.tokens IS
'Tokens para diversos propositos: refresh, reset, verificacion, API keys, invitaciones';
COMMENT ON COLUMN auth.tokens.metadata IS
'Metadata especifica por tipo de token (email para invitations, scopes para API keys, etc.)';
-- Indices
CREATE INDEX IF NOT EXISTS idx_tokens_user
ON auth.tokens(user_id) WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_tokens_tenant
ON auth.tokens(tenant_id);
CREATE INDEX IF NOT EXISTS idx_tokens_hash
ON auth.tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_tokens_type_status
ON auth.tokens(token_type, status) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_tokens_expires
ON auth.tokens(expires_at) WHERE status = 'active';
-- Indice para buscar invitations por email
CREATE INDEX IF NOT EXISTS idx_tokens_invitation_email
ON auth.tokens((metadata->>'email'))
WHERE token_type = 'invitation' AND status = 'active';
-- Funcion para crear token de reset de password
CREATE OR REPLACE FUNCTION auth.create_password_reset_token(
p_user_id UUID,
p_tenant_id UUID,
p_token_hash VARCHAR(255),
p_expires_in INTERVAL DEFAULT '1 hour'
)
RETURNS UUID AS $$
DECLARE
v_token_id UUID;
BEGIN
-- Revocar tokens anteriores de reset
UPDATE auth.tokens
SET status = 'revoked', revoked_at = NOW()
WHERE user_id = p_user_id
AND token_type = 'password_reset'
AND status = 'active';
-- Crear nuevo token
INSERT INTO auth.tokens (user_id, tenant_id, token_type, token_hash, expires_at)
VALUES (p_user_id, p_tenant_id, 'password_reset', p_token_hash, NOW() + p_expires_in)
RETURNING id INTO v_token_id;
RETURN v_token_id;
END;
$$ LANGUAGE plpgsql;
-- Funcion para validar y usar token
CREATE OR REPLACE FUNCTION auth.use_token(
p_token_hash VARCHAR(255),
p_token_type auth.token_type
)
RETURNS TABLE (
user_id UUID,
tenant_id UUID,
metadata JSONB,
is_valid BOOLEAN
) AS $$
DECLARE
v_token auth.tokens%ROWTYPE;
BEGIN
-- Buscar token activo
SELECT * INTO v_token
FROM auth.tokens t
WHERE t.token_hash = p_token_hash
AND t.token_type = p_token_type
AND t.status = 'active'
AND t.expires_at > NOW();
IF NOT FOUND THEN
RETURN QUERY SELECT NULL::UUID, NULL::UUID, NULL::JSONB, FALSE;
RETURN;
END IF;
-- Marcar como usado (solo para one-time tokens)
IF p_token_type IN ('password_reset', 'email_verify', 'phone_verify', 'invitation') THEN
UPDATE auth.tokens
SET status = 'used', used_at = NOW()
WHERE id = v_token.id;
END IF;
RETURN QUERY SELECT v_token.user_id, v_token.tenant_id, v_token.metadata, TRUE;
END;
$$ LANGUAGE plpgsql;
-- Funcion para limpiar tokens expirados
CREATE OR REPLACE FUNCTION auth.cleanup_expired_tokens()
RETURNS INTEGER AS $$
DECLARE
updated_count INTEGER;
BEGIN
UPDATE auth.tokens
SET status = 'expired'
WHERE status = 'active'
AND expires_at < NOW();
GET DIAGNOSTICS updated_count = ROW_COUNT;
-- Eliminar tokens muy antiguos (> 30 dias)
DELETE FROM auth.tokens
WHERE status IN ('used', 'expired', 'revoked')
AND created_at < NOW() - INTERVAL '30 days';
RETURN updated_count;
END;
$$ LANGUAGE plpgsql;
-- RLS Policy para multi-tenancy
ALTER TABLE auth.tokens ENABLE ROW LEVEL SECURITY;
CREATE POLICY tokens_tenant_isolation ON auth.tokens
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.tokens TO trading_app;
GRANT SELECT ON auth.tokens TO trading_readonly;

View File

@ -0,0 +1,147 @@
-- ============================================================================
-- SCHEMA: financial
-- TABLE: wallets
-- DESCRIPTION: Sistema de Wallet Virtual con creditos USD equivalentes
-- VERSION: 1.0.0
-- CREATED: 2026-01-10
-- ============================================================================
-- Crear schema si no existe
CREATE SCHEMA IF NOT EXISTS financial;
-- Enum para estado de wallet
DO $$ BEGIN
CREATE TYPE financial.wallet_status AS ENUM (
'active', -- Wallet activo y operativo
'frozen', -- Congelado temporalmente (investigacion)
'suspended', -- Suspendido por violacion de terminos
'closed' -- Cerrado permanentemente
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla principal de Wallets
CREATE TABLE IF NOT EXISTS financial.wallets (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
-- Saldos en creditos (equivalente USD, no dinero real)
balance DECIMAL(15, 4) NOT NULL DEFAULT 0
CHECK (balance >= 0),
reserved DECIMAL(15, 4) NOT NULL DEFAULT 0
CHECK (reserved >= 0),
-- Totales acumulados (para auditoria)
total_credited DECIMAL(15, 4) NOT NULL DEFAULT 0,
total_debited DECIMAL(15, 4) NOT NULL DEFAULT 0,
-- Creditos promocionales (separados del balance principal)
promo_balance DECIMAL(15, 4) NOT NULL DEFAULT 0
CHECK (promo_balance >= 0),
promo_expiry TIMESTAMPTZ,
-- Configuracion
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
status financial.wallet_status NOT NULL DEFAULT 'active',
-- Limites operacionales
daily_spend_limit DECIMAL(15, 4) DEFAULT 1000.0000,
monthly_spend_limit DECIMAL(15, 4) DEFAULT 10000.0000,
single_transaction_limit DECIMAL(15, 4) DEFAULT 500.0000,
-- Tracking de uso
daily_spent DECIMAL(15, 4) NOT NULL DEFAULT 0,
monthly_spent DECIMAL(15, 4) NOT NULL DEFAULT 0,
last_daily_reset DATE DEFAULT CURRENT_DATE,
last_monthly_reset DATE DEFAULT DATE_TRUNC('month', CURRENT_DATE)::DATE,
-- Timestamps
last_transaction_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT wallets_total_balance_check CHECK (balance + reserved >= 0),
CONSTRAINT wallets_unique_user UNIQUE (tenant_id, user_id)
);
-- Columna calculada para balance total disponible
COMMENT ON TABLE financial.wallets IS
'Wallet virtual con creditos USD equivalentes. No es dinero real.';
COMMENT ON COLUMN financial.wallets.balance IS
'Saldo disponible en creditos (1 credito = 1 USD equivalente)';
COMMENT ON COLUMN financial.wallets.reserved IS
'Creditos reservados para operaciones pendientes (ej: fondeo de agentes)';
COMMENT ON COLUMN financial.wallets.promo_balance IS
'Creditos promocionales separados del balance principal';
-- Indices
CREATE INDEX IF NOT EXISTS idx_wallets_tenant_id
ON financial.wallets(tenant_id);
CREATE INDEX IF NOT EXISTS idx_wallets_user_id
ON financial.wallets(user_id);
CREATE INDEX IF NOT EXISTS idx_wallets_status
ON financial.wallets(status);
CREATE INDEX IF NOT EXISTS idx_wallets_created_at
ON financial.wallets(created_at DESC);
-- Trigger para updated_at
CREATE OR REPLACE FUNCTION financial.update_wallet_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS wallet_updated_at ON financial.wallets;
CREATE TRIGGER wallet_updated_at
BEFORE UPDATE ON financial.wallets
FOR EACH ROW
EXECUTE FUNCTION financial.update_wallet_timestamp();
-- Funcion para resetear limites diarios/mensuales
CREATE OR REPLACE FUNCTION financial.reset_wallet_limits()
RETURNS TRIGGER AS $$
BEGIN
-- Reset diario
IF NEW.last_daily_reset < CURRENT_DATE THEN
NEW.daily_spent := 0;
NEW.last_daily_reset := CURRENT_DATE;
END IF;
-- Reset mensual
IF NEW.last_monthly_reset < DATE_TRUNC('month', CURRENT_DATE)::DATE THEN
NEW.monthly_spent := 0;
NEW.last_monthly_reset := DATE_TRUNC('month', CURRENT_DATE)::DATE;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS wallet_reset_limits ON financial.wallets;
CREATE TRIGGER wallet_reset_limits
BEFORE UPDATE ON financial.wallets
FOR EACH ROW
EXECUTE FUNCTION financial.reset_wallet_limits();
-- RLS Policy para multi-tenancy
ALTER TABLE financial.wallets ENABLE ROW LEVEL SECURITY;
CREATE POLICY wallets_tenant_isolation ON financial.wallets
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE ON financial.wallets TO trading_app;
GRANT SELECT ON financial.wallets TO trading_readonly;

View File

@ -0,0 +1,212 @@
-- ============================================================================
-- SCHEMA: financial
-- TABLE: wallet_transactions
-- DESCRIPTION: Registro de todas las transacciones de wallet
-- VERSION: 1.0.0
-- CREATED: 2026-01-10
-- ============================================================================
-- Enum para tipo de transaccion
DO $$ BEGIN
CREATE TYPE financial.transaction_type AS ENUM (
'CREDIT_PURCHASE', -- Compra de creditos con tarjeta
'AGENT_FUNDING', -- Fondeo a Money Manager
'AGENT_WITHDRAWAL', -- Retiro de Money Manager
'AGENT_PROFIT', -- Ganancia de Money Manager
'AGENT_LOSS', -- Perdida de Money Manager
'PRODUCT_PURCHASE', -- Compra de producto (one-time)
'SUBSCRIPTION_CHARGE', -- Cargo de suscripcion
'PREDICTION_PURCHASE', -- Compra de prediccion individual
'REFUND', -- Devolucion
'PROMO_CREDIT', -- Creditos promocionales agregados
'PROMO_EXPIRY', -- Expiracion de creditos promo
'ADJUSTMENT', -- Ajuste manual (admin)
'TRANSFER_IN', -- Transferencia recibida
'TRANSFER_OUT', -- Transferencia enviada
'FEE' -- Comision de plataforma
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para estado de transaccion
DO $$ BEGIN
CREATE TYPE financial.transaction_status AS ENUM (
'pending', -- Pendiente de confirmacion
'completed', -- Completada exitosamente
'failed', -- Fallida
'cancelled', -- Cancelada
'reversed' -- Revertida
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de transacciones
CREATE TABLE IF NOT EXISTS financial.wallet_transactions (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
wallet_id UUID NOT NULL REFERENCES financial.wallets(id) ON DELETE RESTRICT,
tenant_id UUID NOT NULL,
-- Tipo y estado
type financial.transaction_type NOT NULL,
status financial.transaction_status NOT NULL DEFAULT 'completed',
-- Montos
amount DECIMAL(15, 4) NOT NULL,
balance_before DECIMAL(15, 4) NOT NULL,
balance_after DECIMAL(15, 4) NOT NULL,
-- Descripcion
description TEXT,
-- Referencias a entidades relacionadas
reference_type VARCHAR(50),
reference_id UUID,
-- Para compras de creditos (Stripe)
stripe_payment_intent_id VARCHAR(255),
stripe_charge_id VARCHAR(255),
payment_method VARCHAR(50),
-- Para transferencias entre wallets
related_wallet_id UUID REFERENCES financial.wallets(id),
related_transaction_id UUID,
-- Metadata adicional
metadata JSONB DEFAULT '{}',
-- Auditoria
ip_address INET,
user_agent TEXT,
created_by UUID,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ,
-- Indices parciales para busquedas frecuentes
CONSTRAINT valid_amount CHECK (
(type IN ('CREDIT_PURCHASE', 'AGENT_PROFIT', 'PROMO_CREDIT', 'REFUND', 'TRANSFER_IN') AND amount > 0)
OR
(type IN ('AGENT_FUNDING', 'PRODUCT_PURCHASE', 'SUBSCRIPTION_CHARGE', 'PREDICTION_PURCHASE', 'PROMO_EXPIRY', 'TRANSFER_OUT', 'FEE', 'AGENT_LOSS') AND amount < 0)
OR
(type IN ('ADJUSTMENT', 'AGENT_WITHDRAWAL'))
)
);
COMMENT ON TABLE financial.wallet_transactions IS
'Registro inmutable de todas las transacciones de wallet. Cada operacion crea un registro.';
-- Indices
CREATE INDEX IF NOT EXISTS idx_wallet_tx_wallet_id
ON financial.wallet_transactions(wallet_id);
CREATE INDEX IF NOT EXISTS idx_wallet_tx_tenant_id
ON financial.wallet_transactions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_wallet_tx_type
ON financial.wallet_transactions(type);
CREATE INDEX IF NOT EXISTS idx_wallet_tx_status
ON financial.wallet_transactions(status);
CREATE INDEX IF NOT EXISTS idx_wallet_tx_created_at
ON financial.wallet_transactions(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wallet_tx_reference
ON financial.wallet_transactions(reference_type, reference_id)
WHERE reference_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_wallet_tx_stripe
ON financial.wallet_transactions(stripe_payment_intent_id)
WHERE stripe_payment_intent_id IS NOT NULL;
-- Funcion para crear transaccion y actualizar wallet atomicamente
CREATE OR REPLACE FUNCTION financial.create_wallet_transaction(
p_wallet_id UUID,
p_type financial.transaction_type,
p_amount DECIMAL(15, 4),
p_description TEXT DEFAULT NULL,
p_reference_type VARCHAR(50) DEFAULT NULL,
p_reference_id UUID DEFAULT NULL,
p_metadata JSONB DEFAULT '{}'
)
RETURNS UUID AS $$
DECLARE
v_wallet financial.wallets%ROWTYPE;
v_transaction_id UUID;
v_new_balance DECIMAL(15, 4);
BEGIN
-- Lock wallet row
SELECT * INTO v_wallet
FROM financial.wallets
WHERE id = p_wallet_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Wallet not found: %', p_wallet_id;
END IF;
IF v_wallet.status != 'active' THEN
RAISE EXCEPTION 'Wallet is not active: %', v_wallet.status;
END IF;
-- Calcular nuevo balance
v_new_balance := v_wallet.balance + p_amount;
-- Validar que no quede negativo
IF v_new_balance < 0 THEN
RAISE EXCEPTION 'Insufficient balance. Current: %, Required: %',
v_wallet.balance, ABS(p_amount);
END IF;
-- Validar limites si es debito
IF p_amount < 0 THEN
IF ABS(p_amount) > v_wallet.single_transaction_limit THEN
RAISE EXCEPTION 'Exceeds single transaction limit: %',
v_wallet.single_transaction_limit;
END IF;
IF v_wallet.daily_spent + ABS(p_amount) > v_wallet.daily_spend_limit THEN
RAISE EXCEPTION 'Exceeds daily spend limit: %',
v_wallet.daily_spend_limit;
END IF;
END IF;
-- Crear transaccion
INSERT INTO financial.wallet_transactions (
wallet_id, tenant_id, type, amount,
balance_before, balance_after,
description, reference_type, reference_id, metadata
) VALUES (
p_wallet_id, v_wallet.tenant_id, p_type, p_amount,
v_wallet.balance, v_new_balance,
p_description, p_reference_type, p_reference_id, p_metadata
) RETURNING id INTO v_transaction_id;
-- Actualizar wallet
UPDATE financial.wallets SET
balance = v_new_balance,
total_credited = CASE WHEN p_amount > 0 THEN total_credited + p_amount ELSE total_credited END,
total_debited = CASE WHEN p_amount < 0 THEN total_debited + ABS(p_amount) ELSE total_debited END,
daily_spent = CASE WHEN p_amount < 0 THEN daily_spent + ABS(p_amount) ELSE daily_spent END,
monthly_spent = CASE WHEN p_amount < 0 THEN monthly_spent + ABS(p_amount) ELSE monthly_spent END,
last_transaction_at = NOW()
WHERE id = p_wallet_id;
RETURN v_transaction_id;
END;
$$ LANGUAGE plpgsql;
-- RLS Policy
ALTER TABLE financial.wallet_transactions ENABLE ROW LEVEL SECURITY;
CREATE POLICY wallet_tx_tenant_isolation ON financial.wallet_transactions
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT ON financial.wallet_transactions TO trading_app;
GRANT SELECT ON financial.wallet_transactions TO trading_readonly;

View File

@ -0,0 +1,520 @@
-- ============================================================================
-- SCHEMA: investment
-- TABLES: agent_allocations, allocation_transactions, profit_distributions
-- DESCRIPTION: Sistema de fondeo de Money Manager Agents desde Wallet
-- VERSION: 1.0.0
-- CREATED: 2026-01-10
-- DEPENDS: financial.wallets, agents (existing)
-- ============================================================================
-- Crear schema si no existe
CREATE SCHEMA IF NOT EXISTS investment;
-- Enum para tipo de agente
DO $$ BEGIN
CREATE TYPE investment.agent_type AS ENUM (
'ATLAS', -- Conservador - bajo riesgo
'ORION', -- Moderado - riesgo medio
'NOVA' -- Agresivo - alto riesgo
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
-- Enum para estado de allocation
DO $$ BEGIN
CREATE TYPE investment.allocation_status AS ENUM (
'pending', -- Esperando confirmacion
'active', -- Activa y operando
'paused', -- Pausada por usuario
'liquidating', -- En proceso de liquidacion
'closed' -- Cerrada
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
-- Enum para tipo de transaccion de allocation
DO $$ BEGIN
CREATE TYPE investment.allocation_tx_type AS ENUM (
'INITIAL_FUNDING', -- Fondeo inicial
'ADDITIONAL_FUNDING', -- Fondeo adicional
'PARTIAL_WITHDRAWAL', -- Retiro parcial
'FULL_WITHDRAWAL', -- Retiro total
'PROFIT_REALIZED', -- Ganancia realizada
'LOSS_REALIZED', -- Perdida realizada
'FEE_CHARGED', -- Comision cobrada
'REBALANCE' -- Rebalanceo
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
-- Tabla principal de allocations (fondeo a agentes)
CREATE TABLE IF NOT EXISTS investment.agent_allocations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
wallet_id UUID NOT NULL,
-- Agente
agent_type investment.agent_type NOT NULL,
agent_instance_id UUID, -- Referencia a instancia especifica del agente
-- Montos
initial_amount DECIMAL(15, 4) NOT NULL CHECK (initial_amount > 0),
current_amount DECIMAL(15, 4) NOT NULL CHECK (current_amount >= 0),
total_deposited DECIMAL(15, 4) NOT NULL DEFAULT 0,
total_withdrawn DECIMAL(15, 4) NOT NULL DEFAULT 0,
-- Performance
total_profit DECIMAL(15, 4) NOT NULL DEFAULT 0,
total_loss DECIMAL(15, 4) NOT NULL DEFAULT 0,
realized_pnl DECIMAL(15, 4) GENERATED ALWAYS AS (total_profit - total_loss) STORED,
unrealized_pnl DECIMAL(15, 4) DEFAULT 0,
-- Metricas
roi_percentage DECIMAL(10, 4) DEFAULT 0,
max_drawdown DECIMAL(10, 4) DEFAULT 0,
win_rate DECIMAL(5, 2) DEFAULT 0,
total_trades INT DEFAULT 0,
winning_trades INT DEFAULT 0,
losing_trades INT DEFAULT 0,
-- Configuracion
risk_level VARCHAR(20), -- LOW, MEDIUM, HIGH
auto_compound BOOLEAN DEFAULT FALSE,
compound_threshold DECIMAL(15, 4) DEFAULT 100,
-- Limites
max_allocation DECIMAL(15, 4),
stop_loss_percentage DECIMAL(5, 2) DEFAULT 20,
take_profit_percentage DECIMAL(5, 2),
-- Estado
status investment.allocation_status NOT NULL DEFAULT 'pending',
-- Fechas importantes
activated_at TIMESTAMPTZ,
last_trade_at TIMESTAMPTZ,
paused_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ,
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Un usuario puede tener multiples allocations al mismo agente
-- pero con limites
CONSTRAINT positive_performance CHECK (
total_deposited >= total_withdrawn
OR status IN ('closed', 'liquidating')
)
);
COMMENT ON TABLE investment.agent_allocations IS
'Fondeos de usuarios a Money Manager Agents (Atlas, Orion, Nova)';
-- Tabla de transacciones de allocation
CREATE TABLE IF NOT EXISTS investment.allocation_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
allocation_id UUID NOT NULL REFERENCES investment.agent_allocations(id),
tenant_id UUID NOT NULL,
-- Tipo
type investment.allocation_tx_type NOT NULL,
-- Montos
amount DECIMAL(15, 4) NOT NULL,
balance_before DECIMAL(15, 4) NOT NULL,
balance_after DECIMAL(15, 4) NOT NULL,
-- Referencia a wallet transaction
wallet_transaction_id UUID,
-- Para trades
trade_id UUID,
trade_symbol VARCHAR(20),
trade_pnl DECIMAL(15, 4),
-- Descripcion
description TEXT,
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
COMMENT ON TABLE investment.allocation_transactions IS
'Historial de todas las transacciones en allocations de agentes';
-- Tabla de distribuciones de profit
CREATE TABLE IF NOT EXISTS investment.profit_distributions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
allocation_id UUID NOT NULL REFERENCES investment.agent_allocations(id),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
-- Periodo
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
-- Montos
gross_profit DECIMAL(15, 4) NOT NULL,
platform_fee DECIMAL(15, 4) NOT NULL DEFAULT 0,
agent_fee DECIMAL(15, 4) NOT NULL DEFAULT 0,
net_profit DECIMAL(15, 4) NOT NULL,
-- Fee percentages aplicados
platform_fee_rate DECIMAL(5, 4) DEFAULT 0.10, -- 10%
agent_fee_rate DECIMAL(5, 4) DEFAULT 0.20, -- 20%
-- Distribucion
distributed_to_wallet BOOLEAN DEFAULT FALSE,
wallet_transaction_id UUID,
distributed_at TIMESTAMPTZ,
-- Compounding
compounded_amount DECIMAL(15, 4) DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
COMMENT ON TABLE investment.profit_distributions IS
'Distribuciones periodicas de ganancias de agentes a usuarios';
-- Tabla de configuracion de agentes por tenant
CREATE TABLE IF NOT EXISTS investment.agent_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
agent_type investment.agent_type NOT NULL,
-- Limites
min_allocation DECIMAL(15, 4) DEFAULT 100,
max_allocation_per_user DECIMAL(15, 4) DEFAULT 10000,
max_total_allocation DECIMAL(15, 4) DEFAULT 1000000,
-- Fees
platform_fee_rate DECIMAL(5, 4) DEFAULT 0.10,
performance_fee_rate DECIMAL(5, 4) DEFAULT 0.20,
-- Estado
is_active BOOLEAN DEFAULT TRUE,
accepting_new_allocations BOOLEAN DEFAULT TRUE,
-- Descripcion
description TEXT,
risk_disclosure TEXT,
-- Stats agregados
total_users INT DEFAULT 0,
total_allocated DECIMAL(15, 4) DEFAULT 0,
total_profit_generated DECIMAL(15, 4) DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, agent_type)
);
COMMENT ON TABLE investment.agent_configs IS
'Configuracion de Money Manager Agents por tenant';
-- Indices para agent_allocations
CREATE INDEX IF NOT EXISTS idx_alloc_tenant ON investment.agent_allocations(tenant_id);
CREATE INDEX IF NOT EXISTS idx_alloc_user ON investment.agent_allocations(user_id);
CREATE INDEX IF NOT EXISTS idx_alloc_wallet ON investment.agent_allocations(wallet_id);
CREATE INDEX IF NOT EXISTS idx_alloc_agent ON investment.agent_allocations(agent_type);
CREATE INDEX IF NOT EXISTS idx_alloc_status ON investment.agent_allocations(status);
CREATE INDEX IF NOT EXISTS idx_alloc_active ON investment.agent_allocations(status, agent_type)
WHERE status = 'active';
-- Indices para allocation_transactions
CREATE INDEX IF NOT EXISTS idx_alloc_tx_alloc ON investment.allocation_transactions(allocation_id);
CREATE INDEX IF NOT EXISTS idx_alloc_tx_tenant ON investment.allocation_transactions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_alloc_tx_type ON investment.allocation_transactions(type);
CREATE INDEX IF NOT EXISTS idx_alloc_tx_created ON investment.allocation_transactions(created_at DESC);
-- Indices para profit_distributions
CREATE INDEX IF NOT EXISTS idx_profit_dist_alloc ON investment.profit_distributions(allocation_id);
CREATE INDEX IF NOT EXISTS idx_profit_dist_user ON investment.profit_distributions(user_id);
CREATE INDEX IF NOT EXISTS idx_profit_dist_period ON investment.profit_distributions(period_start, period_end);
CREATE INDEX IF NOT EXISTS idx_profit_dist_pending ON investment.profit_distributions(distributed_to_wallet)
WHERE distributed_to_wallet = FALSE;
-- Trigger updated_at
CREATE OR REPLACE FUNCTION investment.update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS alloc_updated ON investment.agent_allocations;
CREATE TRIGGER alloc_updated
BEFORE UPDATE ON investment.agent_allocations
FOR EACH ROW
EXECUTE FUNCTION investment.update_timestamp();
DROP TRIGGER IF EXISTS agent_config_updated ON investment.agent_configs;
CREATE TRIGGER agent_config_updated
BEFORE UPDATE ON investment.agent_configs
FOR EACH ROW
EXECUTE FUNCTION investment.update_timestamp();
-- Funcion para crear allocation (fondear agente)
CREATE OR REPLACE FUNCTION investment.create_allocation(
p_tenant_id UUID,
p_user_id UUID,
p_wallet_id UUID,
p_agent_type investment.agent_type,
p_amount DECIMAL(15, 4),
p_auto_compound BOOLEAN DEFAULT FALSE
)
RETURNS UUID AS $$
DECLARE
v_config investment.agent_configs%ROWTYPE;
v_allocation_id UUID;
v_wallet_tx_id UUID;
BEGIN
-- Get agent config
SELECT * INTO v_config
FROM investment.agent_configs
WHERE tenant_id = p_tenant_id
AND agent_type = p_agent_type;
IF NOT FOUND OR NOT v_config.is_active THEN
RAISE EXCEPTION 'Agent % is not available', p_agent_type;
END IF;
IF NOT v_config.accepting_new_allocations THEN
RAISE EXCEPTION 'Agent % is not accepting new allocations', p_agent_type;
END IF;
IF p_amount < v_config.min_allocation THEN
RAISE EXCEPTION 'Minimum allocation is %', v_config.min_allocation;
END IF;
IF p_amount > v_config.max_allocation_per_user THEN
RAISE EXCEPTION 'Maximum allocation per user is %', v_config.max_allocation_per_user;
END IF;
-- Debit from wallet (this validates balance)
v_wallet_tx_id := financial.create_wallet_transaction(
p_wallet_id,
'AGENT_FUNDING',
-p_amount,
'Funding to ' || p_agent_type || ' agent',
'agent_allocation',
NULL,
jsonb_build_object('agent_type', p_agent_type)
);
-- Create allocation
INSERT INTO investment.agent_allocations (
tenant_id, user_id, wallet_id, agent_type,
initial_amount, current_amount, total_deposited,
auto_compound, risk_level, status
) VALUES (
p_tenant_id, p_user_id, p_wallet_id, p_agent_type,
p_amount, p_amount, p_amount,
p_auto_compound,
CASE p_agent_type
WHEN 'ATLAS' THEN 'LOW'
WHEN 'ORION' THEN 'MEDIUM'
WHEN 'NOVA' THEN 'HIGH'
END,
'active'
) RETURNING id INTO v_allocation_id;
-- Update allocation with wallet tx reference
UPDATE investment.agent_allocations
SET activated_at = NOW(),
metadata = jsonb_build_object('initial_wallet_tx', v_wallet_tx_id)
WHERE id = v_allocation_id;
-- Record allocation transaction
INSERT INTO investment.allocation_transactions (
allocation_id, tenant_id, type,
amount, balance_before, balance_after,
wallet_transaction_id, description
) VALUES (
v_allocation_id, p_tenant_id, 'INITIAL_FUNDING',
p_amount, 0, p_amount,
v_wallet_tx_id, 'Initial funding'
);
-- Update agent config stats
UPDATE investment.agent_configs
SET total_users = total_users + 1,
total_allocated = total_allocated + p_amount
WHERE tenant_id = p_tenant_id AND agent_type = p_agent_type;
RETURN v_allocation_id;
END;
$$ LANGUAGE plpgsql;
-- Funcion para retirar de allocation
CREATE OR REPLACE FUNCTION investment.withdraw_from_allocation(
p_allocation_id UUID,
p_amount DECIMAL(15, 4),
p_full_withdrawal BOOLEAN DEFAULT FALSE
)
RETURNS UUID AS $$
DECLARE
v_alloc investment.agent_allocations%ROWTYPE;
v_wallet_tx_id UUID;
v_withdraw_amount DECIMAL(15, 4);
v_tx_type investment.allocation_tx_type;
BEGIN
-- Lock allocation
SELECT * INTO v_alloc
FROM investment.agent_allocations
WHERE id = p_allocation_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Allocation not found';
END IF;
IF v_alloc.status NOT IN ('active', 'paused') THEN
RAISE EXCEPTION 'Cannot withdraw from allocation in status %', v_alloc.status;
END IF;
-- Determine withdrawal amount
IF p_full_withdrawal THEN
v_withdraw_amount := v_alloc.current_amount;
v_tx_type := 'FULL_WITHDRAWAL';
ELSE
IF p_amount > v_alloc.current_amount THEN
RAISE EXCEPTION 'Insufficient balance. Available: %', v_alloc.current_amount;
END IF;
v_withdraw_amount := p_amount;
v_tx_type := 'PARTIAL_WITHDRAWAL';
END IF;
-- Credit to wallet
v_wallet_tx_id := financial.create_wallet_transaction(
v_alloc.wallet_id,
'AGENT_WITHDRAWAL',
v_withdraw_amount,
'Withdrawal from ' || v_alloc.agent_type || ' agent',
'agent_allocation',
p_allocation_id,
jsonb_build_object('agent_type', v_alloc.agent_type)
);
-- Record transaction
INSERT INTO investment.allocation_transactions (
allocation_id, tenant_id, type,
amount, balance_before, balance_after,
wallet_transaction_id, description
) VALUES (
p_allocation_id, v_alloc.tenant_id, v_tx_type,
-v_withdraw_amount, v_alloc.current_amount,
v_alloc.current_amount - v_withdraw_amount,
v_wallet_tx_id,
CASE WHEN p_full_withdrawal THEN 'Full withdrawal' ELSE 'Partial withdrawal' END
);
-- Update allocation
UPDATE investment.agent_allocations
SET current_amount = current_amount - v_withdraw_amount,
total_withdrawn = total_withdrawn + v_withdraw_amount,
status = CASE WHEN p_full_withdrawal THEN 'closed' ELSE status END,
closed_at = CASE WHEN p_full_withdrawal THEN NOW() ELSE closed_at END
WHERE id = p_allocation_id;
-- Update agent config stats
UPDATE investment.agent_configs
SET total_allocated = total_allocated - v_withdraw_amount,
total_users = CASE WHEN p_full_withdrawal THEN total_users - 1 ELSE total_users END
WHERE tenant_id = v_alloc.tenant_id AND agent_type = v_alloc.agent_type;
RETURN v_wallet_tx_id;
END;
$$ LANGUAGE plpgsql;
-- Funcion para registrar profit/loss de trade
CREATE OR REPLACE FUNCTION investment.record_trade_result(
p_allocation_id UUID,
p_trade_id UUID,
p_symbol VARCHAR(20),
p_pnl DECIMAL(15, 4),
p_is_win BOOLEAN
)
RETURNS VOID AS $$
DECLARE
v_alloc investment.agent_allocations%ROWTYPE;
v_tx_type investment.allocation_tx_type;
BEGIN
SELECT * INTO v_alloc
FROM investment.agent_allocations
WHERE id = p_allocation_id
FOR UPDATE;
v_tx_type := CASE WHEN p_pnl >= 0 THEN 'PROFIT_REALIZED' ELSE 'LOSS_REALIZED' END;
-- Record transaction
INSERT INTO investment.allocation_transactions (
allocation_id, tenant_id, type,
amount, balance_before, balance_after,
trade_id, trade_symbol, trade_pnl
) VALUES (
p_allocation_id, v_alloc.tenant_id, v_tx_type,
p_pnl, v_alloc.current_amount, v_alloc.current_amount + p_pnl,
p_trade_id, p_symbol, p_pnl
);
-- Update allocation
UPDATE investment.agent_allocations
SET current_amount = current_amount + p_pnl,
total_profit = CASE WHEN p_pnl > 0 THEN total_profit + p_pnl ELSE total_profit END,
total_loss = CASE WHEN p_pnl < 0 THEN total_loss + ABS(p_pnl) ELSE total_loss END,
total_trades = total_trades + 1,
winning_trades = CASE WHEN p_is_win THEN winning_trades + 1 ELSE winning_trades END,
losing_trades = CASE WHEN NOT p_is_win THEN losing_trades + 1 ELSE losing_trades END,
win_rate = CASE
WHEN total_trades > 0 THEN
(winning_trades + CASE WHEN p_is_win THEN 1 ELSE 0 END)::DECIMAL * 100 /
(total_trades + 1)
ELSE 0
END,
roi_percentage = CASE
WHEN total_deposited > 0 THEN
((current_amount + p_pnl - total_deposited + total_withdrawn) / total_deposited) * 100
ELSE 0
END,
last_trade_at = NOW()
WHERE id = p_allocation_id;
END;
$$ LANGUAGE plpgsql;
-- RLS
ALTER TABLE investment.agent_allocations ENABLE ROW LEVEL SECURITY;
ALTER TABLE investment.allocation_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE investment.profit_distributions ENABLE ROW LEVEL SECURITY;
ALTER TABLE investment.agent_configs ENABLE ROW LEVEL SECURITY;
CREATE POLICY alloc_tenant ON investment.agent_allocations
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY alloc_tx_tenant ON investment.allocation_transactions
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY profit_tenant ON investment.profit_distributions
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY config_tenant ON investment.agent_configs
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE ON investment.agent_allocations TO trading_app;
GRANT SELECT, INSERT ON investment.allocation_transactions TO trading_app;
GRANT SELECT, INSERT, UPDATE ON investment.profit_distributions TO trading_app;
GRANT SELECT, UPDATE ON investment.agent_configs TO trading_app;
GRANT SELECT ON investment.agent_allocations TO trading_readonly;
GRANT SELECT ON investment.allocation_transactions TO trading_readonly;
GRANT SELECT ON investment.profit_distributions TO trading_readonly;
GRANT SELECT ON investment.agent_configs TO trading_readonly;

View File

@ -0,0 +1,77 @@
-- ============================================================================
-- SCHEMA: investment
-- DESCRIPTION: Agregar columnas faltantes a tablas existentes
-- VERSION: 1.0.0
-- CREATED: 2026-01-10
-- DEPENDS: investment/001_agent_allocations.sql
-- ============================================================================
-- ============================================================================
-- MODIFICACIONES A agent_configs
-- ============================================================================
-- Agregar columna name
ALTER TABLE investment.agent_configs
ADD COLUMN IF NOT EXISTS name VARCHAR(100) DEFAULT '';
-- Agregar columna target_return_percent
ALTER TABLE investment.agent_configs
ADD COLUMN IF NOT EXISTS target_return_percent DECIMAL(5, 2) DEFAULT 0;
-- Agregar columna max_drawdown_percent
ALTER TABLE investment.agent_configs
ADD COLUMN IF NOT EXISTS max_drawdown_percent DECIMAL(5, 2) DEFAULT 0;
-- Comentarios
COMMENT ON COLUMN investment.agent_configs.name IS 'Nombre descriptivo del agente';
COMMENT ON COLUMN investment.agent_configs.target_return_percent IS 'Retorno objetivo anual en porcentaje';
COMMENT ON COLUMN investment.agent_configs.max_drawdown_percent IS 'Drawdown máximo permitido en porcentaje';
-- ============================================================================
-- MODIFICACIONES A agent_allocations
-- ============================================================================
-- Agregar columna total_profit_distributed
ALTER TABLE investment.agent_allocations
ADD COLUMN IF NOT EXISTS total_profit_distributed DECIMAL(15, 4) DEFAULT 0;
-- Agregar columna total_fees_paid
ALTER TABLE investment.agent_allocations
ADD COLUMN IF NOT EXISTS total_fees_paid DECIMAL(15, 4) DEFAULT 0;
-- Agregar columna lock_expires_at
ALTER TABLE investment.agent_allocations
ADD COLUMN IF NOT EXISTS lock_expires_at TIMESTAMPTZ;
-- Comentarios
COMMENT ON COLUMN investment.agent_allocations.total_profit_distributed IS 'Total de ganancias distribuidas al usuario';
COMMENT ON COLUMN investment.agent_allocations.total_fees_paid IS 'Total de comisiones pagadas';
COMMENT ON COLUMN investment.agent_allocations.lock_expires_at IS 'Fecha de expiración del periodo de bloqueo';
-- ============================================================================
-- MODIFICACIONES A ml.prediction_outcomes
-- ============================================================================
-- Agregar columna verification_source
ALTER TABLE ml.prediction_outcomes
ADD COLUMN IF NOT EXISTS verification_source VARCHAR(100);
-- Agregar columna notes
ALTER TABLE ml.prediction_outcomes
ADD COLUMN IF NOT EXISTS notes TEXT;
-- Comentarios
COMMENT ON COLUMN ml.prediction_outcomes.verification_source IS 'Fuente de verificación del resultado (manual, API, etc.)';
COMMENT ON COLUMN ml.prediction_outcomes.notes IS 'Notas adicionales sobre el resultado';
-- ============================================================================
-- INDICES ADICIONALES
-- ============================================================================
-- Indice para buscar allocations con lock activo
CREATE INDEX IF NOT EXISTS idx_alloc_lock ON investment.agent_allocations(lock_expires_at)
WHERE lock_expires_at IS NOT NULL;
-- Indice para buscar outcomes por source
CREATE INDEX IF NOT EXISTS idx_pred_out_source ON ml.prediction_outcomes(verification_source)
WHERE verification_source IS NOT NULL;

View File

@ -0,0 +1,365 @@
-- ============================================================================
-- SCHEMA: ml
-- TABLES: prediction_purchases, prediction_outcomes, prediction_packages
-- DESCRIPTION: Extensiones para marketplace de predicciones ML
-- VERSION: 1.0.0
-- CREATED: 2026-01-10
-- DEPENDS: ml schema (existing), financial.wallets, products.products
-- ============================================================================
-- Asume que ml schema ya existe con predictions table
-- Enum para estado de outcome
DO $$ BEGIN
CREATE TYPE ml.outcome_status AS ENUM (
'pending', -- Esperando resultado
'win', -- Prediccion acertada
'loss', -- Prediccion fallida
'partial', -- Parcialmente correcta
'cancelled', -- Cancelada (mercado cerrado, etc)
'expired' -- Expirada sin resultado
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
-- Enum para tipo de prediccion comprada
DO $$ BEGIN
CREATE TYPE ml.purchase_source AS ENUM (
'INDIVIDUAL', -- Compra individual
'SUBSCRIPTION', -- Incluida en suscripcion
'VIP_ACCESS', -- Acceso VIP
'PACKAGE', -- Parte de un paquete
'PROMO' -- Promocional/gratuita
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
-- Tabla de paquetes de predicciones (productos vendibles)
CREATE TABLE IF NOT EXISTS ml.prediction_packages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificacion
name VARCHAR(200) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
-- Configuracion del paquete
model_id VARCHAR(100) NOT NULL,
model_name VARCHAR(200),
prediction_count INT NOT NULL CHECK (prediction_count > 0),
-- Simbolos incluidos (NULL = todos disponibles)
included_symbols VARCHAR[] DEFAULT NULL,
-- Timeframes incluidos
included_timeframes VARCHAR[] DEFAULT '{H1,H4,D1}',
-- Precios
price DECIMAL(10, 2) NOT NULL CHECK (price >= 0),
compare_price DECIMAL(10, 2),
-- Validez
validity_days INT DEFAULT 30,
-- Stripe
stripe_product_id VARCHAR(255),
stripe_price_id VARCHAR(255),
-- Display
badge VARCHAR(50),
is_popular BOOLEAN DEFAULT FALSE,
sort_order INT DEFAULT 0,
-- Estado
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
COMMENT ON TABLE ml.prediction_packages IS
'Paquetes de predicciones vendibles (ej: 10 predicciones AMD por $29)';
-- Tabla de compras de predicciones
CREATE TABLE IF NOT EXISTS ml.prediction_purchases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
-- Origen de la compra
source ml.purchase_source NOT NULL,
-- Referencias segun origen
package_id UUID REFERENCES ml.prediction_packages(id),
product_purchase_id UUID, -- Ref a products.purchases
vip_subscription_id UUID, -- Ref a vip.subscriptions
-- Modelo y configuracion
model_id VARCHAR(100) NOT NULL,
model_name VARCHAR(200),
-- Creditos de predicciones
predictions_total INT NOT NULL CHECK (predictions_total > 0),
predictions_used INT NOT NULL DEFAULT 0 CHECK (predictions_used >= 0),
predictions_remaining INT GENERATED ALWAYS AS (predictions_total - predictions_used) STORED,
-- Pago
amount_paid DECIMAL(10, 2) NOT NULL DEFAULT 0,
wallet_transaction_id UUID,
-- Validez
valid_from TIMESTAMPTZ DEFAULT NOW(),
valid_until TIMESTAMPTZ,
-- Estado
is_active BOOLEAN DEFAULT TRUE,
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT valid_predictions CHECK (predictions_used <= predictions_total)
);
COMMENT ON TABLE ml.prediction_purchases IS
'Registro de compras/accesos a predicciones por usuario';
-- Tabla de predicciones generadas y sus resultados
CREATE TABLE IF NOT EXISTS ml.prediction_outcomes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
-- Referencia a la compra
purchase_id UUID NOT NULL REFERENCES ml.prediction_purchases(id),
-- Referencia a la prediccion original (si existe en ml.predictions)
prediction_id UUID,
-- Datos de la prediccion
model_id VARCHAR(100) NOT NULL,
symbol VARCHAR(20) NOT NULL,
timeframe VARCHAR(10) NOT NULL,
-- Prediccion
predicted_direction VARCHAR(10) NOT NULL, -- BUY, SELL, NEUTRAL
predicted_price DECIMAL(20, 8),
confidence DECIMAL(5, 4) CHECK (confidence >= 0 AND confidence <= 1),
-- Niveles predichos
entry_price DECIMAL(20, 8),
take_profit DECIMAL(20, 8),
stop_loss DECIMAL(20, 8),
-- Resultado
outcome_status ml.outcome_status NOT NULL DEFAULT 'pending',
actual_direction VARCHAR(10),
actual_price DECIMAL(20, 8),
-- Metricas de resultado
pips_result DECIMAL(10, 2),
percentage_result DECIMAL(10, 4),
hit_tp BOOLEAN,
hit_sl BOOLEAN,
-- Tiempos
predicted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
target_time TIMESTAMPTZ,
resolved_at TIMESTAMPTZ,
-- Metadata adicional del modelo
model_metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
COMMENT ON TABLE ml.prediction_outcomes IS
'Historial de predicciones generadas con sus resultados para validacion';
-- Indices para prediction_packages
CREATE INDEX IF NOT EXISTS idx_pred_pkg_model ON ml.prediction_packages(model_id);
CREATE INDEX IF NOT EXISTS idx_pred_pkg_active ON ml.prediction_packages(is_active) WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_pred_pkg_slug ON ml.prediction_packages(slug);
-- Indices para prediction_purchases
CREATE INDEX IF NOT EXISTS idx_pred_purch_tenant ON ml.prediction_purchases(tenant_id);
CREATE INDEX IF NOT EXISTS idx_pred_purch_user ON ml.prediction_purchases(user_id);
CREATE INDEX IF NOT EXISTS idx_pred_purch_model ON ml.prediction_purchases(model_id);
CREATE INDEX IF NOT EXISTS idx_pred_purch_active ON ml.prediction_purchases(is_active, valid_until)
WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_pred_purch_source ON ml.prediction_purchases(source);
-- Indices para prediction_outcomes
CREATE INDEX IF NOT EXISTS idx_pred_out_tenant ON ml.prediction_outcomes(tenant_id);
CREATE INDEX IF NOT EXISTS idx_pred_out_user ON ml.prediction_outcomes(user_id);
CREATE INDEX IF NOT EXISTS idx_pred_out_purchase ON ml.prediction_outcomes(purchase_id);
CREATE INDEX IF NOT EXISTS idx_pred_out_status ON ml.prediction_outcomes(outcome_status);
CREATE INDEX IF NOT EXISTS idx_pred_out_symbol ON ml.prediction_outcomes(symbol);
CREATE INDEX IF NOT EXISTS idx_pred_out_predicted ON ml.prediction_outcomes(predicted_at DESC);
CREATE INDEX IF NOT EXISTS idx_pred_out_model ON ml.prediction_outcomes(model_id);
-- Trigger updated_at para packages
CREATE OR REPLACE FUNCTION ml.update_pkg_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS pred_pkg_updated ON ml.prediction_packages;
CREATE TRIGGER pred_pkg_updated
BEFORE UPDATE ON ml.prediction_packages
FOR EACH ROW
EXECUTE FUNCTION ml.update_pkg_timestamp();
-- Funcion para consumir una prediccion
CREATE OR REPLACE FUNCTION ml.consume_prediction(
p_purchase_id UUID,
p_symbol VARCHAR(20),
p_timeframe VARCHAR(10),
p_direction VARCHAR(10),
p_confidence DECIMAL(5, 4),
p_entry DECIMAL(20, 8) DEFAULT NULL,
p_tp DECIMAL(20, 8) DEFAULT NULL,
p_sl DECIMAL(20, 8) DEFAULT NULL,
p_target_time TIMESTAMPTZ DEFAULT NULL,
p_metadata JSONB DEFAULT '{}'
)
RETURNS UUID AS $$
DECLARE
v_purchase ml.prediction_purchases%ROWTYPE;
v_outcome_id UUID;
BEGIN
-- Lock and get purchase
SELECT * INTO v_purchase
FROM ml.prediction_purchases
WHERE id = p_purchase_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Purchase not found: %', p_purchase_id;
END IF;
IF NOT v_purchase.is_active THEN
RAISE EXCEPTION 'Purchase is not active';
END IF;
IF v_purchase.valid_until IS NOT NULL AND v_purchase.valid_until < NOW() THEN
RAISE EXCEPTION 'Purchase has expired';
END IF;
IF v_purchase.predictions_used >= v_purchase.predictions_total THEN
RAISE EXCEPTION 'No predictions remaining in this purchase';
END IF;
-- Create outcome record
INSERT INTO ml.prediction_outcomes (
tenant_id, user_id, purchase_id, model_id,
symbol, timeframe, predicted_direction, confidence,
entry_price, take_profit, stop_loss, target_time,
model_metadata
) VALUES (
v_purchase.tenant_id, v_purchase.user_id, p_purchase_id, v_purchase.model_id,
p_symbol, p_timeframe, p_direction, p_confidence,
p_entry, p_tp, p_sl, p_target_time,
p_metadata
) RETURNING id INTO v_outcome_id;
-- Update purchase counter
UPDATE ml.prediction_purchases
SET predictions_used = predictions_used + 1
WHERE id = p_purchase_id;
RETURN v_outcome_id;
END;
$$ LANGUAGE plpgsql;
-- Funcion para resolver outcome
CREATE OR REPLACE FUNCTION ml.resolve_prediction_outcome(
p_outcome_id UUID,
p_status ml.outcome_status,
p_actual_direction VARCHAR(10) DEFAULT NULL,
p_actual_price DECIMAL(20, 8) DEFAULT NULL,
p_pips DECIMAL(10, 2) DEFAULT NULL,
p_percentage DECIMAL(10, 4) DEFAULT NULL,
p_hit_tp BOOLEAN DEFAULT NULL,
p_hit_sl BOOLEAN DEFAULT NULL
)
RETURNS VOID AS $$
BEGIN
UPDATE ml.prediction_outcomes SET
outcome_status = p_status,
actual_direction = p_actual_direction,
actual_price = p_actual_price,
pips_result = p_pips,
percentage_result = p_percentage,
hit_tp = p_hit_tp,
hit_sl = p_hit_sl,
resolved_at = NOW()
WHERE id = p_outcome_id;
END;
$$ LANGUAGE plpgsql;
-- Funcion para obtener estadisticas de predicciones por usuario
CREATE OR REPLACE FUNCTION ml.get_user_prediction_stats(
p_user_id UUID,
p_model_id VARCHAR(100) DEFAULT NULL
)
RETURNS TABLE (
total_predictions BIGINT,
wins BIGINT,
losses BIGINT,
pending BIGINT,
win_rate DECIMAL(5, 2),
avg_pips DECIMAL(10, 2),
avg_confidence DECIMAL(5, 4)
) AS $$
BEGIN
RETURN QUERY
SELECT
COUNT(*)::BIGINT as total_predictions,
COUNT(*) FILTER (WHERE outcome_status = 'win')::BIGINT as wins,
COUNT(*) FILTER (WHERE outcome_status = 'loss')::BIGINT as losses,
COUNT(*) FILTER (WHERE outcome_status = 'pending')::BIGINT as pending,
CASE
WHEN COUNT(*) FILTER (WHERE outcome_status IN ('win', 'loss')) > 0
THEN ROUND(
COUNT(*) FILTER (WHERE outcome_status = 'win')::DECIMAL * 100 /
COUNT(*) FILTER (WHERE outcome_status IN ('win', 'loss')),
2
)
ELSE 0
END as win_rate,
ROUND(AVG(pips_result) FILTER (WHERE pips_result IS NOT NULL), 2) as avg_pips,
ROUND(AVG(confidence), 4) as avg_confidence
FROM ml.prediction_outcomes
WHERE user_id = p_user_id
AND (p_model_id IS NULL OR model_id = p_model_id);
END;
$$ LANGUAGE plpgsql;
-- RLS
ALTER TABLE ml.prediction_packages ENABLE ROW LEVEL SECURITY;
ALTER TABLE ml.prediction_purchases ENABLE ROW LEVEL SECURITY;
ALTER TABLE ml.prediction_outcomes ENABLE ROW LEVEL SECURITY;
-- Packages visibles para todos
CREATE POLICY pred_pkg_read_all ON ml.prediction_packages
FOR SELECT USING (TRUE);
-- Purchases por tenant
CREATE POLICY pred_purch_tenant ON ml.prediction_purchases
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Outcomes por tenant
CREATE POLICY pred_out_tenant ON ml.prediction_outcomes
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Grants
GRANT SELECT ON ml.prediction_packages TO trading_app;
GRANT SELECT, INSERT, UPDATE ON ml.prediction_purchases TO trading_app;
GRANT SELECT, INSERT, UPDATE ON ml.prediction_outcomes TO trading_app;
GRANT SELECT ON ml.prediction_packages TO trading_readonly;
GRANT SELECT ON ml.prediction_purchases TO trading_readonly;
GRANT SELECT ON ml.prediction_outcomes TO trading_readonly;

View File

@ -0,0 +1,188 @@
-- ============================================================================
-- SCHEMA: ml
-- TABLE: predictions
-- DESCRIPTION: Tabla de predicciones individuales generadas
-- VERSION: 1.0.0
-- CREATED: 2026-01-10
-- DEPENDS: ml.prediction_purchases
-- ============================================================================
-- Enum para tipo de prediccion
DO $$ BEGIN
CREATE TYPE ml.prediction_type AS ENUM (
'AMD', -- Accumulation, Manipulation, Distribution
'RANGE', -- Range prediction
'TPSL', -- Take Profit / Stop Loss
'ICT_SMC', -- ICT Smart Money Concepts
'STRATEGY_ENSEMBLE' -- Combined strategies
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
-- Enum para clase de activo
DO $$ BEGIN
CREATE TYPE ml.asset_class AS ENUM (
'FOREX',
'CRYPTO',
'INDICES',
'COMMODITIES'
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
-- Enum para estado de prediccion
DO $$ BEGIN
CREATE TYPE ml.prediction_status AS ENUM (
'pending', -- Generada, esperando entrega
'delivered', -- Entregada al usuario
'expired', -- Expirada sin validar
'validated', -- Validada con resultado
'invalidated' -- Invalidada por error
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
-- Tabla de predicciones individuales
CREATE TABLE IF NOT EXISTS ml.predictions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
-- Referencia a la compra
purchase_id UUID NOT NULL REFERENCES ml.prediction_purchases(id),
-- Tipo y activo
prediction_type ml.prediction_type NOT NULL,
asset VARCHAR(20) NOT NULL,
asset_class ml.asset_class NOT NULL,
timeframe VARCHAR(10) NOT NULL,
-- Predicción
direction VARCHAR(10) NOT NULL CHECK (direction IN ('LONG', 'SHORT', 'NEUTRAL')),
entry_price DECIMAL(20, 8),
target_price DECIMAL(20, 8),
stop_loss DECIMAL(20, 8),
confidence DECIMAL(5, 4) CHECK (confidence >= 0 AND confidence <= 1),
-- Estado
status ml.prediction_status NOT NULL DEFAULT 'pending',
expires_at TIMESTAMPTZ NOT NULL,
delivered_at TIMESTAMPTZ,
-- Datos adicionales
prediction_data JSONB DEFAULT '{}',
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
COMMENT ON TABLE ml.predictions IS
'Predicciones individuales generadas por modelos ML';
COMMENT ON COLUMN ml.predictions.prediction_type IS 'Tipo de modelo usado para la prediccion';
COMMENT ON COLUMN ml.predictions.asset_class IS 'Clase de activo (FOREX, CRYPTO, etc.)';
COMMENT ON COLUMN ml.predictions.status IS 'Estado actual de la prediccion';
COMMENT ON COLUMN ml.predictions.confidence IS 'Nivel de confianza del modelo (0-1)';
-- Indices
CREATE INDEX IF NOT EXISTS idx_pred_tenant ON ml.predictions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_pred_user ON ml.predictions(user_id);
CREATE INDEX IF NOT EXISTS idx_pred_purchase ON ml.predictions(purchase_id);
CREATE INDEX IF NOT EXISTS idx_pred_status ON ml.predictions(status);
CREATE INDEX IF NOT EXISTS idx_pred_type ON ml.predictions(prediction_type);
CREATE INDEX IF NOT EXISTS idx_pred_asset ON ml.predictions(asset);
CREATE INDEX IF NOT EXISTS idx_pred_asset_class ON ml.predictions(asset_class);
CREATE INDEX IF NOT EXISTS idx_pred_created ON ml.predictions(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_pred_expires ON ml.predictions(expires_at)
WHERE status = 'pending';
-- Indice compuesto para busquedas comunes
CREATE INDEX IF NOT EXISTS idx_pred_user_status ON ml.predictions(user_id, status, created_at DESC);
-- RLS
ALTER TABLE ml.predictions ENABLE ROW LEVEL SECURITY;
CREATE POLICY pred_tenant ON ml.predictions
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE ON ml.predictions TO trading_app;
GRANT SELECT ON ml.predictions TO trading_readonly;
-- Actualizar FK en prediction_outcomes para referenciar predictions
DO $$
BEGIN
ALTER TABLE ml.prediction_outcomes
DROP CONSTRAINT IF EXISTS prediction_outcomes_prediction_id_fkey;
EXCEPTION WHEN undefined_object THEN null;
END $$;
ALTER TABLE ml.prediction_outcomes
ADD CONSTRAINT prediction_outcomes_prediction_id_fkey
FOREIGN KEY (prediction_id) REFERENCES ml.predictions(id)
ON DELETE SET NULL;
-- Funcion para generar prediccion desde un purchase
CREATE OR REPLACE FUNCTION ml.generate_prediction(
p_purchase_id UUID,
p_prediction_type ml.prediction_type,
p_asset VARCHAR(20),
p_asset_class ml.asset_class,
p_timeframe VARCHAR(10),
p_direction VARCHAR(10),
p_entry DECIMAL(20, 8) DEFAULT NULL,
p_target DECIMAL(20, 8) DEFAULT NULL,
p_sl DECIMAL(20, 8) DEFAULT NULL,
p_confidence DECIMAL(5, 4) DEFAULT 0.5,
p_expires_hours INT DEFAULT 24,
p_prediction_data JSONB DEFAULT '{}'
)
RETURNS UUID AS $$
DECLARE
v_purchase ml.prediction_purchases%ROWTYPE;
v_prediction_id UUID;
BEGIN
-- Validar purchase
SELECT * INTO v_purchase
FROM ml.prediction_purchases
WHERE id = p_purchase_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Purchase not found: %', p_purchase_id;
END IF;
IF NOT v_purchase.is_active THEN
RAISE EXCEPTION 'Purchase is not active';
END IF;
IF v_purchase.valid_until IS NOT NULL AND v_purchase.valid_until < NOW() THEN
RAISE EXCEPTION 'Purchase has expired';
END IF;
IF v_purchase.predictions_used >= v_purchase.predictions_total THEN
RAISE EXCEPTION 'No predictions remaining';
END IF;
-- Crear prediccion
INSERT INTO ml.predictions (
tenant_id, user_id, purchase_id,
prediction_type, asset, asset_class, timeframe,
direction, entry_price, target_price, stop_loss, confidence,
expires_at, prediction_data
) VALUES (
v_purchase.tenant_id, v_purchase.user_id, p_purchase_id,
p_prediction_type, p_asset, p_asset_class, p_timeframe,
p_direction, p_entry, p_target, p_sl, p_confidence,
NOW() + (p_expires_hours || ' hours')::INTERVAL,
p_prediction_data
) RETURNING id INTO v_prediction_id;
-- Incrementar contador
UPDATE ml.prediction_purchases
SET predictions_used = predictions_used + 1
WHERE id = p_purchase_id;
RETURN v_prediction_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION ml.generate_prediction IS
'Genera una nueva prediccion desde un purchase, validando limites y estado';

View File

@ -0,0 +1,206 @@
-- ============================================================================
-- SCHEMA: products
-- TABLE: products, categories, purchases
-- DESCRIPTION: Sistema de productos y servicios del marketplace
-- VERSION: 1.0.0
-- CREATED: 2026-01-10
-- ============================================================================
-- Crear schema
CREATE SCHEMA IF NOT EXISTS products;
-- Enums
DO $$ BEGIN
CREATE TYPE products.product_type AS ENUM (
'ONE_TIME', -- Pago unico
'SUBSCRIPTION', -- Suscripcion recurrente
'VIP' -- Producto VIP exclusivo
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
CREATE TYPE products.product_category AS ENUM (
'PREDICTION', -- Predicciones ML
'EDUCATION', -- Cursos y materiales
'CONSULTING', -- Asesoria y coaching
'AGENT_ACCESS', -- Acceso a Money Managers
'SIGNAL_PACK', -- Paquetes de senales
'API_ACCESS', -- Acceso a API
'PREMIUM_FEATURE' -- Features premium
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
CREATE TYPE products.billing_interval AS ENUM (
'DAY', 'WEEK', 'MONTH', 'YEAR'
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
CREATE TYPE products.purchase_type AS ENUM (
'WALLET', -- 100% wallet
'STRIPE', -- 100% tarjeta
'COMBINED' -- Wallet + tarjeta
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
CREATE TYPE products.delivery_status AS ENUM (
'pending', 'processing', 'delivered', 'failed', 'refunded'
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
-- Tabla de productos
CREATE TABLE IF NOT EXISTS products.products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificacion
sku VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(200) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
short_description VARCHAR(500),
-- Categorizacion
type products.product_type NOT NULL,
category products.product_category NOT NULL,
-- Precios (en creditos = USD equivalente)
price DECIMAL(10, 2) NOT NULL CHECK (price >= 0),
compare_price DECIMAL(10, 2),
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
-- Para suscripciones
billing_interval products.billing_interval,
billing_interval_count INT DEFAULT 1,
trial_days INT DEFAULT 0,
-- Features y limites
features JSONB NOT NULL DEFAULT '[]',
limits JSONB NOT NULL DEFAULT '{}',
-- Asociacion con ML (para productos de prediccion)
ml_model_id VARCHAR(100),
prediction_type VARCHAR(50),
prediction_count INT,
-- Asociacion con Agentes
agent_type VARCHAR(20),
-- Stripe
stripe_product_id VARCHAR(255),
stripe_price_id VARCHAR(255),
-- Display
image_url VARCHAR(500),
badge VARCHAR(50),
sort_order INT DEFAULT 0,
-- Estado
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_vip BOOLEAN NOT NULL DEFAULT FALSE,
is_featured BOOLEAN NOT NULL DEFAULT FALSE,
-- Disponibilidad
available_from TIMESTAMPTZ,
available_until TIMESTAMPTZ,
stock INT,
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE products.products IS
'Catalogo de productos y servicios disponibles en la plataforma';
-- Tabla de compras
CREATE TABLE IF NOT EXISTS products.purchases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
product_id UUID NOT NULL REFERENCES products.products(id),
-- Tipo de compra
purchase_type products.purchase_type NOT NULL,
-- Montos
product_price DECIMAL(10, 2) NOT NULL,
discount_amount DECIMAL(10, 2) DEFAULT 0,
final_price DECIMAL(10, 2) NOT NULL,
-- Desglose de pago
wallet_amount DECIMAL(10, 2) DEFAULT 0,
stripe_amount DECIMAL(10, 2) DEFAULT 0,
-- Referencias
wallet_transaction_id UUID,
stripe_payment_intent_id VARCHAR(255),
-- Entrega
delivery_status products.delivery_status NOT NULL DEFAULT 'pending',
delivered_at TIMESTAMPTZ,
delivery_data JSONB DEFAULT '{}',
-- Para predicciones compradas
prediction_ids UUID[],
-- Codigo de descuento
discount_code VARCHAR(50),
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE products.purchases IS
'Registro de todas las compras realizadas por usuarios';
-- Indices
CREATE INDEX IF NOT EXISTS idx_products_type ON products.products(type);
CREATE INDEX IF NOT EXISTS idx_products_category ON products.products(category);
CREATE INDEX IF NOT EXISTS idx_products_active ON products.products(is_active) WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_products_featured ON products.products(is_featured) WHERE is_featured = TRUE;
CREATE INDEX IF NOT EXISTS idx_products_vip ON products.products(is_vip) WHERE is_vip = TRUE;
CREATE INDEX IF NOT EXISTS idx_products_slug ON products.products(slug);
CREATE INDEX IF NOT EXISTS idx_purchases_tenant ON products.purchases(tenant_id);
CREATE INDEX IF NOT EXISTS idx_purchases_user ON products.purchases(user_id);
CREATE INDEX IF NOT EXISTS idx_purchases_product ON products.purchases(product_id);
CREATE INDEX IF NOT EXISTS idx_purchases_status ON products.purchases(delivery_status);
CREATE INDEX IF NOT EXISTS idx_purchases_created ON products.purchases(created_at DESC);
-- Trigger updated_at
CREATE OR REPLACE FUNCTION products.update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS products_updated_at ON products.products;
CREATE TRIGGER products_updated_at
BEFORE UPDATE ON products.products
FOR EACH ROW
EXECUTE FUNCTION products.update_timestamp();
-- RLS
ALTER TABLE products.products ENABLE ROW LEVEL SECURITY;
ALTER TABLE products.purchases ENABLE ROW LEVEL SECURITY;
-- Products visible para todos los activos
CREATE POLICY products_read_active ON products.products
FOR SELECT
USING (is_active = TRUE);
CREATE POLICY purchases_tenant_isolation ON products.purchases
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Grants
GRANT SELECT ON products.products TO trading_app;
GRANT SELECT, INSERT ON products.purchases TO trading_app;
GRANT SELECT ON products.products TO trading_readonly;
GRANT SELECT ON products.purchases TO trading_readonly;

View File

@ -0,0 +1,176 @@
-- ============================================================================
-- RBAC Schema: Roles Table
-- Role-Based Access Control for Trading Platform SaaS
-- ============================================================================
-- Create RBAC schema if not exists
CREATE SCHEMA IF NOT EXISTS rbac;
-- Grant usage
GRANT USAGE ON SCHEMA rbac TO trading_user;
-- ============================================================================
-- ROLES TABLE
-- Defines roles within a tenant organization
-- ============================================================================
CREATE TABLE IF NOT EXISTS rbac.roles (
-- Primary key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Tenant relationship (multi-tenancy)
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
-- Role information
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL,
description TEXT,
-- Role type: system (predefined) or custom (tenant-created)
role_type VARCHAR(20) NOT NULL DEFAULT 'custom'
CHECK (role_type IN ('system', 'custom')),
-- Hierarchy level (lower = more permissions, 0 = super admin)
hierarchy_level INTEGER NOT NULL DEFAULT 100,
-- Status
is_active BOOLEAN NOT NULL DEFAULT true,
-- Role settings (JSON for extensibility)
settings JSONB NOT NULL DEFAULT '{}'::jsonb,
-- Audit fields
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES users.users(id),
updated_by UUID REFERENCES users.users(id),
-- Constraints
CONSTRAINT uq_roles_tenant_slug UNIQUE (tenant_id, slug),
CONSTRAINT uq_roles_tenant_name UNIQUE (tenant_id, name)
);
-- ============================================================================
-- INDEXES
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_roles_tenant_id ON rbac.roles(tenant_id);
CREATE INDEX IF NOT EXISTS idx_roles_slug ON rbac.roles(slug);
CREATE INDEX IF NOT EXISTS idx_roles_role_type ON rbac.roles(role_type);
CREATE INDEX IF NOT EXISTS idx_roles_is_active ON rbac.roles(is_active);
CREATE INDEX IF NOT EXISTS idx_roles_hierarchy ON rbac.roles(tenant_id, hierarchy_level);
-- ============================================================================
-- ROW LEVEL SECURITY
-- ============================================================================
ALTER TABLE rbac.roles ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see roles in their tenant
CREATE POLICY roles_tenant_isolation ON rbac.roles
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- ============================================================================
-- TRIGGERS
-- ============================================================================
-- Auto-update updated_at timestamp
CREATE OR REPLACE FUNCTION rbac.update_roles_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_roles_updated_at
BEFORE UPDATE ON rbac.roles
FOR EACH ROW
EXECUTE FUNCTION rbac.update_roles_timestamp();
-- ============================================================================
-- DEFAULT SYSTEM ROLES (inserted per tenant)
-- These will be created when a new tenant is created
-- ============================================================================
-- Function to create default roles for a tenant
CREATE OR REPLACE FUNCTION rbac.create_default_roles(p_tenant_id UUID, p_owner_id UUID)
RETURNS void AS $$
BEGIN
-- Owner role (highest level)
INSERT INTO rbac.roles (tenant_id, name, slug, description, role_type, hierarchy_level, created_by)
VALUES (
p_tenant_id,
'Owner',
'owner',
'Full access to all features and settings. Can manage billing and delete organization.',
'system',
0,
p_owner_id
);
-- Admin role
INSERT INTO rbac.roles (tenant_id, name, slug, description, role_type, hierarchy_level, created_by)
VALUES (
p_tenant_id,
'Admin',
'admin',
'Administrative access. Can manage users, roles, and most settings.',
'system',
10,
p_owner_id
);
-- Manager role
INSERT INTO rbac.roles (tenant_id, name, slug, description, role_type, hierarchy_level, created_by)
VALUES (
p_tenant_id,
'Manager',
'manager',
'Can manage team members and view reports.',
'system',
50,
p_owner_id
);
-- Member role
INSERT INTO rbac.roles (tenant_id, name, slug, description, role_type, hierarchy_level, created_by)
VALUES (
p_tenant_id,
'Member',
'member',
'Standard user access. Can use platform features.',
'system',
100,
p_owner_id
);
-- Viewer role (read-only)
INSERT INTO rbac.roles (tenant_id, name, slug, description, role_type, hierarchy_level, created_by)
VALUES (
p_tenant_id,
'Viewer',
'viewer',
'Read-only access. Can view but not modify.',
'system',
200,
p_owner_id
);
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- GRANTS
-- ============================================================================
GRANT SELECT, INSERT, UPDATE, DELETE ON rbac.roles TO trading_user;
-- ============================================================================
-- COMMENTS
-- ============================================================================
COMMENT ON TABLE rbac.roles IS 'Roles for Role-Based Access Control within tenant organizations';
COMMENT ON COLUMN rbac.roles.role_type IS 'system = predefined roles, custom = tenant-created roles';
COMMENT ON COLUMN rbac.roles.hierarchy_level IS 'Lower value = higher permissions. Owner=0, Admin=10, etc.';
COMMENT ON COLUMN rbac.roles.settings IS 'JSON settings for role customization';

View File

@ -0,0 +1,160 @@
-- ============================================================================
-- RBAC Schema: Permissions Table
-- Granular permissions for Trading Platform SaaS
-- ============================================================================
-- ============================================================================
-- PERMISSIONS TABLE
-- Defines available permissions in the system
-- ============================================================================
CREATE TABLE IF NOT EXISTS rbac.permissions (
-- Primary key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Permission identification
code VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
description TEXT,
-- Categorization
module VARCHAR(50) NOT NULL,
category VARCHAR(50) NOT NULL,
-- Permission type
action VARCHAR(20) NOT NULL
CHECK (action IN ('create', 'read', 'update', 'delete', 'manage', 'execute')),
-- Resource this permission applies to
resource VARCHAR(100) NOT NULL,
-- Is this a system permission (cannot be deleted)
is_system BOOLEAN NOT NULL DEFAULT true,
-- Status
is_active BOOLEAN NOT NULL DEFAULT true,
-- Audit fields
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================================
-- INDEXES
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_permissions_code ON rbac.permissions(code);
CREATE INDEX IF NOT EXISTS idx_permissions_module ON rbac.permissions(module);
CREATE INDEX IF NOT EXISTS idx_permissions_category ON rbac.permissions(category);
CREATE INDEX IF NOT EXISTS idx_permissions_resource ON rbac.permissions(resource);
CREATE INDEX IF NOT EXISTS idx_permissions_is_active ON rbac.permissions(is_active);
-- ============================================================================
-- TRIGGERS
-- ============================================================================
CREATE OR REPLACE FUNCTION rbac.update_permissions_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_permissions_updated_at
BEFORE UPDATE ON rbac.permissions
FOR EACH ROW
EXECUTE FUNCTION rbac.update_permissions_timestamp();
-- ============================================================================
-- DEFAULT PERMISSIONS
-- ============================================================================
-- Organization/Tenant permissions
INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES
('org:read', 'View Organization', 'View organization details and settings', 'organization', 'general', 'read', 'organization'),
('org:update', 'Update Organization', 'Update organization settings', 'organization', 'general', 'update', 'organization'),
('org:delete', 'Delete Organization', 'Delete the organization', 'organization', 'general', 'delete', 'organization'),
('org:billing:manage', 'Manage Billing', 'Manage billing and subscriptions', 'organization', 'billing', 'manage', 'billing')
ON CONFLICT (code) DO NOTHING;
-- User management permissions
INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES
('users:read', 'View Users', 'View user list and details', 'users', 'management', 'read', 'users'),
('users:create', 'Create Users', 'Create new users', 'users', 'management', 'create', 'users'),
('users:update', 'Update Users', 'Update user information', 'users', 'management', 'update', 'users'),
('users:delete', 'Delete Users', 'Delete or deactivate users', 'users', 'management', 'delete', 'users'),
('users:invite', 'Invite Users', 'Send invitations to new users', 'users', 'management', 'execute', 'invitations')
ON CONFLICT (code) DO NOTHING;
-- Role management permissions
INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES
('roles:read', 'View Roles', 'View roles and permissions', 'rbac', 'management', 'read', 'roles'),
('roles:create', 'Create Roles', 'Create custom roles', 'rbac', 'management', 'create', 'roles'),
('roles:update', 'Update Roles', 'Update role permissions', 'rbac', 'management', 'update', 'roles'),
('roles:delete', 'Delete Roles', 'Delete custom roles', 'rbac', 'management', 'delete', 'roles'),
('roles:assign', 'Assign Roles', 'Assign roles to users', 'rbac', 'management', 'execute', 'role_assignments')
ON CONFLICT (code) DO NOTHING;
-- Wallet permissions
INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES
('wallet:read', 'View Wallet', 'View wallet balance and transactions', 'wallet', 'finance', 'read', 'wallet'),
('wallet:deposit', 'Deposit Credits', 'Add credits to wallet', 'wallet', 'finance', 'execute', 'deposits'),
('wallet:withdraw', 'Withdraw Credits', 'Withdraw credits from wallet', 'wallet', 'finance', 'execute', 'withdrawals'),
('wallet:transfer', 'Transfer Credits', 'Transfer credits between wallets', 'wallet', 'finance', 'execute', 'transfers')
ON CONFLICT (code) DO NOTHING;
-- Products/Marketplace permissions
INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES
('products:read', 'View Products', 'View marketplace products', 'products', 'marketplace', 'read', 'products'),
('products:purchase', 'Purchase Products', 'Purchase products from marketplace', 'products', 'marketplace', 'execute', 'purchases'),
('products:create', 'Create Products', 'Create new products (for sellers)', 'products', 'marketplace', 'create', 'products'),
('products:update', 'Update Products', 'Update product information', 'products', 'marketplace', 'update', 'products'),
('products:delete', 'Delete Products', 'Delete products', 'products', 'marketplace', 'delete', 'products')
ON CONFLICT (code) DO NOTHING;
-- VIP/Subscription permissions
INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES
('vip:read', 'View VIP Plans', 'View VIP subscription plans', 'vip', 'subscriptions', 'read', 'vip_plans'),
('vip:subscribe', 'Subscribe to VIP', 'Subscribe to VIP plans', 'vip', 'subscriptions', 'execute', 'subscriptions'),
('vip:manage', 'Manage VIP Plans', 'Create and manage VIP plans', 'vip', 'subscriptions', 'manage', 'vip_plans')
ON CONFLICT (code) DO NOTHING;
-- Investment/Agents permissions
INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES
('agents:read', 'View Agents', 'View trading agents', 'investment', 'trading', 'read', 'agents'),
('agents:allocate', 'Allocate to Agents', 'Allocate funds to agents', 'investment', 'trading', 'execute', 'allocations'),
('agents:manage', 'Manage Agents', 'Create and configure agents', 'investment', 'trading', 'manage', 'agents')
ON CONFLICT (code) DO NOTHING;
-- Predictions permissions
INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES
('predictions:read', 'View Predictions', 'View predictions and packages', 'predictions', 'analytics', 'read', 'predictions'),
('predictions:purchase', 'Purchase Predictions', 'Purchase prediction packages', 'predictions', 'analytics', 'execute', 'purchases'),
('predictions:create', 'Create Predictions', 'Create prediction packages', 'predictions', 'analytics', 'create', 'predictions')
ON CONFLICT (code) DO NOTHING;
-- Audit/Reports permissions
INSERT INTO rbac.permissions (code, name, description, module, category, action, resource) VALUES
('audit:read', 'View Audit Logs', 'View audit trail and logs', 'audit', 'compliance', 'read', 'audit_logs'),
('reports:read', 'View Reports', 'View analytics and reports', 'reports', 'analytics', 'read', 'reports'),
('reports:export', 'Export Reports', 'Export reports and data', 'reports', 'analytics', 'execute', 'exports')
ON CONFLICT (code) DO NOTHING;
-- ============================================================================
-- GRANTS
-- ============================================================================
GRANT SELECT ON rbac.permissions TO trading_user;
-- Only admins should be able to modify permissions
GRANT INSERT, UPDATE, DELETE ON rbac.permissions TO trading_admin;
-- ============================================================================
-- COMMENTS
-- ============================================================================
COMMENT ON TABLE rbac.permissions IS 'System-wide permissions for RBAC';
COMMENT ON COLUMN rbac.permissions.code IS 'Unique permission code in format module:action or module:resource:action';
COMMENT ON COLUMN rbac.permissions.module IS 'Feature module this permission belongs to';
COMMENT ON COLUMN rbac.permissions.action IS 'CRUD action or special action (manage, execute)';
COMMENT ON COLUMN rbac.permissions.resource IS 'Resource this permission applies to';

View File

@ -0,0 +1,180 @@
-- ============================================================================
-- RBAC Schema: Role Permissions Table
-- Maps permissions to roles
-- ============================================================================
-- ============================================================================
-- ROLE_PERMISSIONS TABLE
-- Junction table linking roles to their permissions
-- ============================================================================
CREATE TABLE IF NOT EXISTS rbac.role_permissions (
-- Primary key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Role reference
role_id UUID NOT NULL REFERENCES rbac.roles(id) ON DELETE CASCADE,
-- Permission reference
permission_id UUID NOT NULL REFERENCES rbac.permissions(id) ON DELETE CASCADE,
-- Grant type: allow or deny (for permission inheritance override)
grant_type VARCHAR(10) NOT NULL DEFAULT 'allow'
CHECK (grant_type IN ('allow', 'deny')),
-- Conditions for permission (JSON for field-level or conditional access)
conditions JSONB DEFAULT NULL,
-- Audit fields
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES users.users(id),
-- Constraints
CONSTRAINT uq_role_permission UNIQUE (role_id, permission_id)
);
-- ============================================================================
-- INDEXES
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON rbac.role_permissions(role_id);
CREATE INDEX IF NOT EXISTS idx_role_permissions_permission_id ON rbac.role_permissions(permission_id);
CREATE INDEX IF NOT EXISTS idx_role_permissions_grant_type ON rbac.role_permissions(grant_type);
-- ============================================================================
-- FUNCTION: Assign default permissions to system roles
-- ============================================================================
CREATE OR REPLACE FUNCTION rbac.assign_default_role_permissions(p_tenant_id UUID)
RETURNS void AS $$
DECLARE
v_owner_role_id UUID;
v_admin_role_id UUID;
v_manager_role_id UUID;
v_member_role_id UUID;
v_viewer_role_id UUID;
v_perm RECORD;
BEGIN
-- Get role IDs
SELECT id INTO v_owner_role_id FROM rbac.roles WHERE tenant_id = p_tenant_id AND slug = 'owner';
SELECT id INTO v_admin_role_id FROM rbac.roles WHERE tenant_id = p_tenant_id AND slug = 'admin';
SELECT id INTO v_manager_role_id FROM rbac.roles WHERE tenant_id = p_tenant_id AND slug = 'manager';
SELECT id INTO v_member_role_id FROM rbac.roles WHERE tenant_id = p_tenant_id AND slug = 'member';
SELECT id INTO v_viewer_role_id FROM rbac.roles WHERE tenant_id = p_tenant_id AND slug = 'viewer';
-- Owner gets ALL permissions
FOR v_perm IN SELECT id FROM rbac.permissions WHERE is_active = true
LOOP
INSERT INTO rbac.role_permissions (role_id, permission_id)
VALUES (v_owner_role_id, v_perm.id)
ON CONFLICT (role_id, permission_id) DO NOTHING;
END LOOP;
-- Admin gets all except org:delete and org:billing:manage
FOR v_perm IN
SELECT id FROM rbac.permissions
WHERE is_active = true
AND code NOT IN ('org:delete', 'org:billing:manage')
LOOP
INSERT INTO rbac.role_permissions (role_id, permission_id)
VALUES (v_admin_role_id, v_perm.id)
ON CONFLICT (role_id, permission_id) DO NOTHING;
END LOOP;
-- Manager gets user viewing, role viewing, and standard features
FOR v_perm IN
SELECT id FROM rbac.permissions
WHERE is_active = true
AND code IN (
'org:read',
'users:read', 'users:invite',
'roles:read',
'wallet:read', 'wallet:deposit', 'wallet:withdraw',
'products:read', 'products:purchase',
'vip:read', 'vip:subscribe',
'agents:read', 'agents:allocate',
'predictions:read', 'predictions:purchase',
'reports:read'
)
LOOP
INSERT INTO rbac.role_permissions (role_id, permission_id)
VALUES (v_manager_role_id, v_perm.id)
ON CONFLICT (role_id, permission_id) DO NOTHING;
END LOOP;
-- Member gets standard user features
FOR v_perm IN
SELECT id FROM rbac.permissions
WHERE is_active = true
AND code IN (
'org:read',
'wallet:read', 'wallet:deposit', 'wallet:withdraw',
'products:read', 'products:purchase',
'vip:read', 'vip:subscribe',
'agents:read', 'agents:allocate',
'predictions:read', 'predictions:purchase'
)
LOOP
INSERT INTO rbac.role_permissions (role_id, permission_id)
VALUES (v_member_role_id, v_perm.id)
ON CONFLICT (role_id, permission_id) DO NOTHING;
END LOOP;
-- Viewer gets read-only access
FOR v_perm IN
SELECT id FROM rbac.permissions
WHERE is_active = true
AND code IN (
'org:read',
'wallet:read',
'products:read',
'vip:read',
'agents:read',
'predictions:read'
)
LOOP
INSERT INTO rbac.role_permissions (role_id, permission_id)
VALUES (v_viewer_role_id, v_perm.id)
ON CONFLICT (role_id, permission_id) DO NOTHING;
END LOOP;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- VIEW: Role with permissions
-- ============================================================================
CREATE OR REPLACE VIEW rbac.v_role_permissions AS
SELECT
r.id AS role_id,
r.tenant_id,
r.name AS role_name,
r.slug AS role_slug,
r.hierarchy_level,
p.id AS permission_id,
p.code AS permission_code,
p.name AS permission_name,
p.module,
p.action,
p.resource,
rp.grant_type,
rp.conditions
FROM rbac.roles r
JOIN rbac.role_permissions rp ON r.id = rp.role_id
JOIN rbac.permissions p ON rp.permission_id = p.id
WHERE r.is_active = true AND p.is_active = true;
-- ============================================================================
-- GRANTS
-- ============================================================================
GRANT SELECT, INSERT, DELETE ON rbac.role_permissions TO trading_user;
GRANT SELECT ON rbac.v_role_permissions TO trading_user;
-- ============================================================================
-- COMMENTS
-- ============================================================================
COMMENT ON TABLE rbac.role_permissions IS 'Junction table mapping permissions to roles';
COMMENT ON COLUMN rbac.role_permissions.grant_type IS 'allow = grants permission, deny = explicitly denies (for override)';
COMMENT ON COLUMN rbac.role_permissions.conditions IS 'JSON conditions for field-level or conditional access control';

View File

@ -0,0 +1,288 @@
-- ============================================================================
-- RBAC Schema: User Roles Table
-- Assigns roles to users within a tenant
-- ============================================================================
-- ============================================================================
-- USER_ROLES TABLE
-- Maps users to their assigned roles
-- ============================================================================
CREATE TABLE IF NOT EXISTS rbac.user_roles (
-- Primary key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- User reference
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
-- Role reference
role_id UUID NOT NULL REFERENCES rbac.roles(id) ON DELETE CASCADE,
-- Tenant (denormalized for RLS efficiency)
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
-- Assignment metadata
is_primary BOOLEAN NOT NULL DEFAULT false,
assigned_reason TEXT,
-- Validity period (optional for temporary roles)
valid_from TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
valid_until TIMESTAMPTZ DEFAULT NULL,
-- Status
is_active BOOLEAN NOT NULL DEFAULT true,
-- Audit fields
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
assigned_by UUID REFERENCES users.users(id),
revoked_at TIMESTAMPTZ DEFAULT NULL,
revoked_by UUID REFERENCES users.users(id),
-- Constraints
CONSTRAINT uq_user_role UNIQUE (user_id, role_id),
CONSTRAINT chk_valid_period CHECK (valid_until IS NULL OR valid_until > valid_from)
);
-- ============================================================================
-- INDEXES
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON rbac.user_roles(user_id);
CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON rbac.user_roles(role_id);
CREATE INDEX IF NOT EXISTS idx_user_roles_tenant_id ON rbac.user_roles(tenant_id);
CREATE INDEX IF NOT EXISTS idx_user_roles_is_active ON rbac.user_roles(is_active);
CREATE INDEX IF NOT EXISTS idx_user_roles_is_primary ON rbac.user_roles(user_id, is_primary) WHERE is_primary = true;
CREATE INDEX IF NOT EXISTS idx_user_roles_validity ON rbac.user_roles(valid_from, valid_until) WHERE is_active = true;
-- ============================================================================
-- ROW LEVEL SECURITY
-- ============================================================================
ALTER TABLE rbac.user_roles ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see role assignments in their tenant
CREATE POLICY user_roles_tenant_isolation ON rbac.user_roles
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- ============================================================================
-- TRIGGERS
-- ============================================================================
-- Auto-update updated_at timestamp
CREATE OR REPLACE FUNCTION rbac.update_user_roles_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_user_roles_updated_at
BEFORE UPDATE ON rbac.user_roles
FOR EACH ROW
EXECUTE FUNCTION rbac.update_user_roles_timestamp();
-- Ensure only one primary role per user
CREATE OR REPLACE FUNCTION rbac.ensure_single_primary_role()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.is_primary = true THEN
UPDATE rbac.user_roles
SET is_primary = false
WHERE user_id = NEW.user_id
AND id != NEW.id
AND is_primary = true;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_single_primary_role
BEFORE INSERT OR UPDATE ON rbac.user_roles
FOR EACH ROW
WHEN (NEW.is_primary = true)
EXECUTE FUNCTION rbac.ensure_single_primary_role();
-- ============================================================================
-- VIEW: User with roles and permissions
-- ============================================================================
CREATE OR REPLACE VIEW rbac.v_user_permissions AS
SELECT DISTINCT
ur.user_id,
ur.tenant_id,
u.email,
u.display_name,
r.id AS role_id,
r.name AS role_name,
r.slug AS role_slug,
r.hierarchy_level,
ur.is_primary,
p.code AS permission_code,
p.module,
p.action,
p.resource,
rp.grant_type
FROM rbac.user_roles ur
JOIN users.users u ON ur.user_id = u.id
JOIN rbac.roles r ON ur.role_id = r.id
JOIN rbac.role_permissions rp ON r.id = rp.role_id
JOIN rbac.permissions p ON rp.permission_id = p.id
WHERE ur.is_active = true
AND r.is_active = true
AND p.is_active = true
AND (ur.valid_until IS NULL OR ur.valid_until > CURRENT_TIMESTAMP)
AND ur.valid_from <= CURRENT_TIMESTAMP;
-- ============================================================================
-- FUNCTION: Check if user has permission
-- ============================================================================
CREATE OR REPLACE FUNCTION rbac.user_has_permission(
p_user_id UUID,
p_tenant_id UUID,
p_permission_code VARCHAR(100)
)
RETURNS BOOLEAN AS $$
DECLARE
v_has_allow BOOLEAN;
v_has_deny BOOLEAN;
BEGIN
-- Check for explicit deny first
SELECT EXISTS (
SELECT 1
FROM rbac.v_user_permissions
WHERE user_id = p_user_id
AND tenant_id = p_tenant_id
AND permission_code = p_permission_code
AND grant_type = 'deny'
) INTO v_has_deny;
IF v_has_deny THEN
RETURN false;
END IF;
-- Check for allow
SELECT EXISTS (
SELECT 1
FROM rbac.v_user_permissions
WHERE user_id = p_user_id
AND tenant_id = p_tenant_id
AND permission_code = p_permission_code
AND grant_type = 'allow'
) INTO v_has_allow;
RETURN v_has_allow;
END;
$$ LANGUAGE plpgsql STABLE;
-- ============================================================================
-- FUNCTION: Get user's effective permissions
-- ============================================================================
CREATE OR REPLACE FUNCTION rbac.get_user_permissions(
p_user_id UUID,
p_tenant_id UUID
)
RETURNS TABLE (
permission_code VARCHAR(100),
permission_name VARCHAR(200),
module VARCHAR(50),
action VARCHAR(20),
resource VARCHAR(100)
) AS $$
BEGIN
RETURN QUERY
SELECT DISTINCT
p.code,
p.name,
p.module,
p.action,
p.resource
FROM rbac.user_roles ur
JOIN rbac.roles r ON ur.role_id = r.id
JOIN rbac.role_permissions rp ON r.id = rp.role_id
JOIN rbac.permissions p ON rp.permission_id = p.id
WHERE ur.user_id = p_user_id
AND ur.tenant_id = p_tenant_id
AND ur.is_active = true
AND r.is_active = true
AND p.is_active = true
AND rp.grant_type = 'allow'
AND (ur.valid_until IS NULL OR ur.valid_until > CURRENT_TIMESTAMP)
AND ur.valid_from <= CURRENT_TIMESTAMP
AND NOT EXISTS (
-- Exclude denied permissions
SELECT 1
FROM rbac.user_roles ur2
JOIN rbac.role_permissions rp2 ON ur2.role_id = rp2.role_id
WHERE ur2.user_id = p_user_id
AND ur2.tenant_id = p_tenant_id
AND rp2.permission_id = p.id
AND rp2.grant_type = 'deny'
AND ur2.is_active = true
)
ORDER BY p.module, p.code;
END;
$$ LANGUAGE plpgsql STABLE;
-- ============================================================================
-- FUNCTION: Assign role to user
-- ============================================================================
CREATE OR REPLACE FUNCTION rbac.assign_role_to_user(
p_user_id UUID,
p_role_id UUID,
p_tenant_id UUID,
p_assigned_by UUID,
p_is_primary BOOLEAN DEFAULT false,
p_valid_until TIMESTAMPTZ DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_assignment_id UUID;
BEGIN
INSERT INTO rbac.user_roles (
user_id, role_id, tenant_id, is_primary,
valid_until, assigned_by
) VALUES (
p_user_id, p_role_id, p_tenant_id, p_is_primary,
p_valid_until, p_assigned_by
)
ON CONFLICT (user_id, role_id) DO UPDATE SET
is_active = true,
is_primary = EXCLUDED.is_primary,
valid_until = EXCLUDED.valid_until,
assigned_by = EXCLUDED.assigned_by,
revoked_at = NULL,
revoked_by = NULL,
updated_at = CURRENT_TIMESTAMP
RETURNING id INTO v_assignment_id;
RETURN v_assignment_id;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- GRANTS
-- ============================================================================
GRANT SELECT, INSERT, UPDATE, DELETE ON rbac.user_roles TO trading_user;
GRANT SELECT ON rbac.v_user_permissions TO trading_user;
GRANT EXECUTE ON FUNCTION rbac.user_has_permission TO trading_user;
GRANT EXECUTE ON FUNCTION rbac.get_user_permissions TO trading_user;
GRANT EXECUTE ON FUNCTION rbac.assign_role_to_user TO trading_user;
-- ============================================================================
-- COMMENTS
-- ============================================================================
COMMENT ON TABLE rbac.user_roles IS 'Maps users to their assigned roles within a tenant';
COMMENT ON COLUMN rbac.user_roles.is_primary IS 'Primary role shown in UI, user can have multiple roles';
COMMENT ON COLUMN rbac.user_roles.valid_from IS 'When the role assignment becomes active';
COMMENT ON COLUMN rbac.user_roles.valid_until IS 'When the role assignment expires (NULL = never)';
COMMENT ON VIEW rbac.v_user_permissions IS 'Flattened view of users with their effective permissions';
COMMENT ON FUNCTION rbac.user_has_permission IS 'Check if a user has a specific permission';
COMMENT ON FUNCTION rbac.get_user_permissions IS 'Get all effective permissions for a user';

View File

@ -0,0 +1,222 @@
-- ============================================================================
-- Teams Schema: Team Members Table
-- Manages team/organization membership
-- ============================================================================
-- Create teams schema if not exists
CREATE SCHEMA IF NOT EXISTS teams;
-- Grant usage
GRANT USAGE ON SCHEMA teams TO trading_user;
-- ============================================================================
-- TEAM_MEMBERS TABLE
-- Tracks membership status and details for users in a tenant
-- ============================================================================
CREATE TABLE IF NOT EXISTS teams.team_members (
-- Primary key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Tenant relationship
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
-- User relationship
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
-- Membership status
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('pending', 'active', 'suspended', 'removed')),
-- Member type
member_type VARCHAR(20) NOT NULL DEFAULT 'member'
CHECK (member_type IN ('owner', 'admin', 'member', 'guest')),
-- Join information
joined_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
invited_by UUID REFERENCES users.users(id),
invitation_id UUID, -- Reference to invitation (if joined via invite)
-- Department/Team within organization
department VARCHAR(100),
job_title VARCHAR(100),
-- Member settings
settings JSONB NOT NULL DEFAULT '{}'::jsonb,
-- Notification preferences for team
notifications_enabled BOOLEAN NOT NULL DEFAULT true,
-- Audit fields
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
removed_at TIMESTAMPTZ,
removed_by UUID REFERENCES users.users(id),
removal_reason TEXT,
-- Constraints
CONSTRAINT uq_team_member UNIQUE (tenant_id, user_id)
);
-- ============================================================================
-- INDEXES
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_team_members_tenant_id ON teams.team_members(tenant_id);
CREATE INDEX IF NOT EXISTS idx_team_members_user_id ON teams.team_members(user_id);
CREATE INDEX IF NOT EXISTS idx_team_members_status ON teams.team_members(status);
CREATE INDEX IF NOT EXISTS idx_team_members_member_type ON teams.team_members(member_type);
CREATE INDEX IF NOT EXISTS idx_team_members_department ON teams.team_members(tenant_id, department);
CREATE INDEX IF NOT EXISTS idx_team_members_joined_at ON teams.team_members(joined_at);
-- ============================================================================
-- ROW LEVEL SECURITY
-- ============================================================================
ALTER TABLE teams.team_members ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see members in their tenant
CREATE POLICY team_members_tenant_isolation ON teams.team_members
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- ============================================================================
-- TRIGGERS
-- ============================================================================
-- Auto-update updated_at timestamp
CREATE OR REPLACE FUNCTION teams.update_team_members_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_team_members_updated_at
BEFORE UPDATE ON teams.team_members
FOR EACH ROW
EXECUTE FUNCTION teams.update_team_members_timestamp();
-- ============================================================================
-- VIEW: Team members with user details
-- ============================================================================
CREATE OR REPLACE VIEW teams.v_team_members AS
SELECT
tm.id,
tm.tenant_id,
tm.user_id,
u.email,
u.first_name,
u.last_name,
u.display_name,
u.avatar_url,
u.status AS user_status,
tm.status AS membership_status,
tm.member_type,
tm.department,
tm.job_title,
tm.joined_at,
tm.notifications_enabled,
inviter.email AS invited_by_email,
inviter.display_name AS invited_by_name,
-- Get primary role
(
SELECT r.name
FROM rbac.user_roles ur
JOIN rbac.roles r ON ur.role_id = r.id
WHERE ur.user_id = tm.user_id
AND ur.tenant_id = tm.tenant_id
AND ur.is_primary = true
AND ur.is_active = true
LIMIT 1
) AS primary_role
FROM teams.team_members tm
JOIN users.users u ON tm.user_id = u.id
LEFT JOIN users.users inviter ON tm.invited_by = inviter.id
WHERE tm.status != 'removed';
-- ============================================================================
-- FUNCTION: Add user to team
-- ============================================================================
CREATE OR REPLACE FUNCTION teams.add_team_member(
p_tenant_id UUID,
p_user_id UUID,
p_member_type VARCHAR(20) DEFAULT 'member',
p_invited_by UUID DEFAULT NULL,
p_invitation_id UUID DEFAULT NULL,
p_department VARCHAR(100) DEFAULT NULL,
p_job_title VARCHAR(100) DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_member_id UUID;
BEGIN
INSERT INTO teams.team_members (
tenant_id, user_id, member_type, invited_by,
invitation_id, department, job_title, status
) VALUES (
p_tenant_id, p_user_id, p_member_type, p_invited_by,
p_invitation_id, p_department, p_job_title, 'active'
)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET
status = 'active',
member_type = EXCLUDED.member_type,
department = COALESCE(EXCLUDED.department, teams.team_members.department),
job_title = COALESCE(EXCLUDED.job_title, teams.team_members.job_title),
removed_at = NULL,
removed_by = NULL,
removal_reason = NULL,
updated_at = CURRENT_TIMESTAMP
RETURNING id INTO v_member_id;
RETURN v_member_id;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- FUNCTION: Remove user from team
-- ============================================================================
CREATE OR REPLACE FUNCTION teams.remove_team_member(
p_tenant_id UUID,
p_user_id UUID,
p_removed_by UUID,
p_reason TEXT DEFAULT NULL
)
RETURNS BOOLEAN AS $$
BEGIN
UPDATE teams.team_members
SET
status = 'removed',
removed_at = CURRENT_TIMESTAMP,
removed_by = p_removed_by,
removal_reason = p_reason,
updated_at = CURRENT_TIMESTAMP
WHERE tenant_id = p_tenant_id
AND user_id = p_user_id
AND status = 'active';
RETURN FOUND;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- GRANTS
-- ============================================================================
GRANT SELECT, INSERT, UPDATE, DELETE ON teams.team_members TO trading_user;
GRANT SELECT ON teams.v_team_members TO trading_user;
GRANT EXECUTE ON FUNCTION teams.add_team_member TO trading_user;
GRANT EXECUTE ON FUNCTION teams.remove_team_member TO trading_user;
-- ============================================================================
-- COMMENTS
-- ============================================================================
COMMENT ON TABLE teams.team_members IS 'Tracks team/organization membership for users';
COMMENT ON COLUMN teams.team_members.member_type IS 'Type of membership: owner, admin, member, guest';
COMMENT ON COLUMN teams.team_members.settings IS 'JSON settings for member-specific preferences';
COMMENT ON VIEW teams.v_team_members IS 'Team members with user details and primary role';

View File

@ -0,0 +1,371 @@
-- ============================================================================
-- Teams Schema: Invitations Table
-- Manages team/organization invitations
-- ============================================================================
-- ============================================================================
-- INVITATIONS TABLE
-- Stores pending and processed invitations
-- ============================================================================
CREATE TABLE IF NOT EXISTS teams.invitations (
-- Primary key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Tenant relationship
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
-- Invitation details
email VARCHAR(255) NOT NULL,
token_hash VARCHAR(255) NOT NULL,
-- Invitee info (pre-filled for registration)
first_name VARCHAR(100),
last_name VARCHAR(100),
-- Role to assign upon acceptance
role_id UUID REFERENCES rbac.roles(id) ON DELETE SET NULL,
-- Department/position
department VARCHAR(100),
job_title VARCHAR(100),
-- Invitation message
personal_message TEXT,
-- Status
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'declined', 'expired', 'revoked')),
-- Expiration
expires_at TIMESTAMPTZ NOT NULL,
-- Tracking
sent_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
resent_count INTEGER NOT NULL DEFAULT 0,
last_resent_at TIMESTAMPTZ,
-- Response tracking
responded_at TIMESTAMPTZ,
accepted_user_id UUID REFERENCES users.users(id),
-- Audit fields
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
invited_by UUID NOT NULL REFERENCES users.users(id),
revoked_at TIMESTAMPTZ,
revoked_by UUID REFERENCES users.users(id),
-- Constraints
CONSTRAINT uq_invitation_token UNIQUE (token_hash),
CONSTRAINT uq_pending_invitation UNIQUE (tenant_id, email, status)
WHERE status = 'pending'
);
-- ============================================================================
-- INDEXES
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_invitations_tenant_id ON teams.invitations(tenant_id);
CREATE INDEX IF NOT EXISTS idx_invitations_email ON teams.invitations(email);
CREATE INDEX IF NOT EXISTS idx_invitations_token_hash ON teams.invitations(token_hash);
CREATE INDEX IF NOT EXISTS idx_invitations_status ON teams.invitations(status);
CREATE INDEX IF NOT EXISTS idx_invitations_expires_at ON teams.invitations(expires_at) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_invitations_invited_by ON teams.invitations(invited_by);
-- ============================================================================
-- ROW LEVEL SECURITY
-- ============================================================================
ALTER TABLE teams.invitations ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see invitations in their tenant
CREATE POLICY invitations_tenant_isolation ON teams.invitations
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- ============================================================================
-- TRIGGERS
-- ============================================================================
-- Auto-update updated_at timestamp
CREATE OR REPLACE FUNCTION teams.update_invitations_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_invitations_updated_at
BEFORE UPDATE ON teams.invitations
FOR EACH ROW
EXECUTE FUNCTION teams.update_invitations_timestamp();
-- ============================================================================
-- VIEW: Invitations with details
-- ============================================================================
CREATE OR REPLACE VIEW teams.v_invitations AS
SELECT
i.id,
i.tenant_id,
t.name AS tenant_name,
i.email,
i.first_name,
i.last_name,
i.role_id,
r.name AS role_name,
i.department,
i.job_title,
i.personal_message,
i.status,
i.expires_at,
i.sent_at,
i.resent_count,
i.last_resent_at,
i.responded_at,
i.created_at,
i.invited_by,
inviter.email AS invited_by_email,
inviter.display_name AS invited_by_name,
CASE
WHEN i.status = 'pending' AND i.expires_at < CURRENT_TIMESTAMP THEN true
ELSE false
END AS is_expired
FROM teams.invitations i
JOIN tenants.tenants t ON i.tenant_id = t.id
LEFT JOIN rbac.roles r ON i.role_id = r.id
LEFT JOIN users.users inviter ON i.invited_by = inviter.id;
-- ============================================================================
-- FUNCTION: Create invitation
-- ============================================================================
CREATE OR REPLACE FUNCTION teams.create_invitation(
p_tenant_id UUID,
p_email VARCHAR(255),
p_invited_by UUID,
p_role_id UUID DEFAULT NULL,
p_first_name VARCHAR(100) DEFAULT NULL,
p_last_name VARCHAR(100) DEFAULT NULL,
p_department VARCHAR(100) DEFAULT NULL,
p_job_title VARCHAR(100) DEFAULT NULL,
p_personal_message TEXT DEFAULT NULL,
p_expires_in_days INTEGER DEFAULT 7
)
RETURNS TABLE (
invitation_id UUID,
token VARCHAR(64)
) AS $$
DECLARE
v_token VARCHAR(64);
v_token_hash VARCHAR(255);
v_invitation_id UUID;
BEGIN
-- Generate random token
v_token := encode(gen_random_bytes(32), 'hex');
v_token_hash := encode(sha256(v_token::bytea), 'hex');
-- Revoke any existing pending invitations for this email
UPDATE teams.invitations
SET status = 'revoked',
revoked_at = CURRENT_TIMESTAMP,
revoked_by = p_invited_by
WHERE tenant_id = p_tenant_id
AND email = p_email
AND status = 'pending';
-- Create new invitation
INSERT INTO teams.invitations (
tenant_id, email, token_hash, role_id,
first_name, last_name, department, job_title,
personal_message, expires_at, invited_by
) VALUES (
p_tenant_id, p_email, v_token_hash, p_role_id,
p_first_name, p_last_name, p_department, p_job_title,
p_personal_message,
CURRENT_TIMESTAMP + (p_expires_in_days || ' days')::interval,
p_invited_by
)
RETURNING id INTO v_invitation_id;
RETURN QUERY SELECT v_invitation_id, v_token;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- FUNCTION: Accept invitation
-- ============================================================================
CREATE OR REPLACE FUNCTION teams.accept_invitation(
p_token VARCHAR(64),
p_user_id UUID
)
RETURNS TABLE (
success BOOLEAN,
tenant_id UUID,
role_id UUID,
message TEXT
) AS $$
DECLARE
v_invitation teams.invitations%ROWTYPE;
v_token_hash VARCHAR(255);
BEGIN
v_token_hash := encode(sha256(p_token::bytea), 'hex');
-- Find invitation
SELECT * INTO v_invitation
FROM teams.invitations
WHERE token_hash = v_token_hash;
-- Check if invitation exists
IF NOT FOUND THEN
RETURN QUERY SELECT false, NULL::UUID, NULL::UUID, 'Invalid invitation token';
RETURN;
END IF;
-- Check if already processed
IF v_invitation.status != 'pending' THEN
RETURN QUERY SELECT false, NULL::UUID, NULL::UUID,
'Invitation has already been ' || v_invitation.status;
RETURN;
END IF;
-- Check if expired
IF v_invitation.expires_at < CURRENT_TIMESTAMP THEN
UPDATE teams.invitations
SET status = 'expired', updated_at = CURRENT_TIMESTAMP
WHERE id = v_invitation.id;
RETURN QUERY SELECT false, NULL::UUID, NULL::UUID, 'Invitation has expired';
RETURN;
END IF;
-- Accept invitation
UPDATE teams.invitations
SET status = 'accepted',
responded_at = CURRENT_TIMESTAMP,
accepted_user_id = p_user_id,
updated_at = CURRENT_TIMESTAMP
WHERE id = v_invitation.id;
-- Add user to team
PERFORM teams.add_team_member(
v_invitation.tenant_id,
p_user_id,
'member',
v_invitation.invited_by,
v_invitation.id,
v_invitation.department,
v_invitation.job_title
);
-- Assign role if specified
IF v_invitation.role_id IS NOT NULL THEN
PERFORM rbac.assign_role_to_user(
p_user_id,
v_invitation.role_id,
v_invitation.tenant_id,
v_invitation.invited_by,
true -- Make it primary role
);
END IF;
RETURN QUERY SELECT true, v_invitation.tenant_id, v_invitation.role_id,
'Invitation accepted successfully';
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- FUNCTION: Resend invitation
-- ============================================================================
CREATE OR REPLACE FUNCTION teams.resend_invitation(
p_invitation_id UUID,
p_resent_by UUID
)
RETURNS TABLE (
success BOOLEAN,
token VARCHAR(64),
message TEXT
) AS $$
DECLARE
v_invitation teams.invitations%ROWTYPE;
v_new_token VARCHAR(64);
v_new_token_hash VARCHAR(255);
BEGIN
-- Find invitation
SELECT * INTO v_invitation
FROM teams.invitations
WHERE id = p_invitation_id;
IF NOT FOUND THEN
RETURN QUERY SELECT false, NULL::VARCHAR(64), 'Invitation not found';
RETURN;
END IF;
IF v_invitation.status != 'pending' THEN
RETURN QUERY SELECT false, NULL::VARCHAR(64),
'Cannot resend: invitation is ' || v_invitation.status;
RETURN;
END IF;
-- Generate new token
v_new_token := encode(gen_random_bytes(32), 'hex');
v_new_token_hash := encode(sha256(v_new_token::bytea), 'hex');
-- Update invitation
UPDATE teams.invitations
SET token_hash = v_new_token_hash,
resent_count = resent_count + 1,
last_resent_at = CURRENT_TIMESTAMP,
expires_at = CURRENT_TIMESTAMP + INTERVAL '7 days',
updated_at = CURRENT_TIMESTAMP
WHERE id = p_invitation_id;
RETURN QUERY SELECT true, v_new_token, 'Invitation resent successfully';
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- FUNCTION: Expire old invitations (for scheduled job)
-- ============================================================================
CREATE OR REPLACE FUNCTION teams.expire_old_invitations()
RETURNS INTEGER AS $$
DECLARE
v_count INTEGER;
BEGIN
UPDATE teams.invitations
SET status = 'expired', updated_at = CURRENT_TIMESTAMP
WHERE status = 'pending'
AND expires_at < CURRENT_TIMESTAMP;
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- GRANTS
-- ============================================================================
GRANT SELECT, INSERT, UPDATE, DELETE ON teams.invitations TO trading_user;
GRANT SELECT ON teams.v_invitations TO trading_user;
GRANT EXECUTE ON FUNCTION teams.create_invitation TO trading_user;
GRANT EXECUTE ON FUNCTION teams.accept_invitation TO trading_user;
GRANT EXECUTE ON FUNCTION teams.resend_invitation TO trading_user;
GRANT EXECUTE ON FUNCTION teams.expire_old_invitations TO trading_user;
-- ============================================================================
-- COMMENTS
-- ============================================================================
COMMENT ON TABLE teams.invitations IS 'Team/organization invitations for new members';
COMMENT ON COLUMN teams.invitations.token_hash IS 'SHA256 hash of the invitation token';
COMMENT ON COLUMN teams.invitations.role_id IS 'Role to assign when invitation is accepted';
COMMENT ON COLUMN teams.invitations.resent_count IS 'Number of times invitation was resent';
COMMENT ON VIEW teams.v_invitations IS 'Invitations with related details';
COMMENT ON FUNCTION teams.create_invitation IS 'Create a new team invitation';
COMMENT ON FUNCTION teams.accept_invitation IS 'Accept an invitation and join team';

View File

@ -0,0 +1,165 @@
-- ============================================================================
-- SCHEMA: tenants
-- TABLE: tenants, tenant_settings
-- DESCRIPTION: Sistema de Multi-Tenancy para organizaciones/empresas
-- VERSION: 1.0.0
-- CREATED: 2026-01-10
-- ============================================================================
-- Crear schema si no existe
CREATE SCHEMA IF NOT EXISTS tenants;
-- Enum para estado de tenant
DO $$ BEGIN
CREATE TYPE tenants.tenant_status AS ENUM (
'pending', -- Pendiente de activacion
'active', -- Activo y operativo
'suspended', -- Suspendido (impago o violacion)
'deleted' -- Eliminado (soft delete)
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para estado de subscription
DO $$ BEGIN
CREATE TYPE tenants.subscription_status AS ENUM (
'trialing', -- En periodo de prueba
'active', -- Activo con pagos al dia
'past_due', -- Pago vencido
'canceled', -- Cancelado
'incomplete' -- Configuracion incompleta
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla principal de Tenants (Organizaciones)
CREATE TABLE IF NOT EXISTS tenants.tenants (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Informacion basica
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
domain VARCHAR(255),
logo_url TEXT,
-- Estado
status tenants.tenant_status NOT NULL DEFAULT 'pending',
-- Subscription info (referencia a billing)
plan_id UUID, -- FK a plans.plans (se agrega despues)
subscription_status tenants.subscription_status DEFAULT 'trialing',
trial_ends_at TIMESTAMPTZ DEFAULT (NOW() + INTERVAL '14 days'),
-- Stripe integration
stripe_customer_id VARCHAR(255),
stripe_subscription_id VARCHAR(255),
-- Settings (JSONB flexible)
settings JSONB NOT NULL DEFAULT '{
"timezone": "America/Mexico_City",
"locale": "es-MX",
"currency": "MXN",
"date_format": "DD/MM/YYYY"
}'::JSONB,
-- Metadata adicional
metadata JSONB DEFAULT '{}'::JSONB,
-- Soft delete
deleted_at TIMESTAMPTZ,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE tenants.tenants IS
'Organizaciones/empresas que usan la plataforma. Base del sistema multi-tenant.';
COMMENT ON COLUMN tenants.tenants.slug IS
'Identificador URL-safe unico para el tenant (ej: acme-corp)';
COMMENT ON COLUMN tenants.tenants.settings IS
'Configuracion del tenant: timezone, locale, currency, etc.';
-- Indices
CREATE INDEX IF NOT EXISTS idx_tenants_slug
ON tenants.tenants(slug);
CREATE INDEX IF NOT EXISTS idx_tenants_status
ON tenants.tenants(status) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_tenants_stripe_customer
ON tenants.tenants(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_tenants_subscription_status
ON tenants.tenants(subscription_status);
-- Trigger para updated_at
CREATE OR REPLACE FUNCTION tenants.update_tenant_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS tenant_updated_at ON tenants.tenants;
CREATE TRIGGER tenant_updated_at
BEFORE UPDATE ON tenants.tenants
FOR EACH ROW
EXECUTE FUNCTION tenants.update_tenant_timestamp();
-- ============================================================================
-- TABLA: tenant_settings (configuracion extendida)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tenants.tenant_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
-- Configuracion por categoria
category VARCHAR(50) NOT NULL, -- 'branding', 'notifications', 'security', etc.
key VARCHAR(100) NOT NULL,
value JSONB NOT NULL,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Unique por tenant + category + key
UNIQUE (tenant_id, category, key)
);
COMMENT ON TABLE tenants.tenant_settings IS
'Configuraciones extendidas por tenant, organizadas por categoria';
-- Indices
CREATE INDEX IF NOT EXISTS idx_tenant_settings_tenant
ON tenants.tenant_settings(tenant_id);
CREATE INDEX IF NOT EXISTS idx_tenant_settings_category
ON tenants.tenant_settings(tenant_id, category);
-- Trigger para updated_at
DROP TRIGGER IF EXISTS tenant_settings_updated_at ON tenants.tenant_settings;
CREATE TRIGGER tenant_settings_updated_at
BEFORE UPDATE ON tenants.tenant_settings
FOR EACH ROW
EXECUTE FUNCTION tenants.update_tenant_timestamp();
-- RLS Policy (tenant_settings aislado por tenant)
ALTER TABLE tenants.tenant_settings ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_settings_isolation ON tenants.tenant_settings
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE ON tenants.tenants TO trading_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON tenants.tenant_settings TO trading_app;
GRANT SELECT ON tenants.tenants TO trading_readonly;
GRANT SELECT ON tenants.tenant_settings TO trading_readonly;

View File

@ -0,0 +1,156 @@
-- ============================================================================
-- SCHEMA: users
-- TABLE: users
-- DESCRIPTION: Sistema de usuarios con autenticacion y perfiles
-- VERSION: 1.0.0
-- CREATED: 2026-01-10
-- ============================================================================
-- Crear schema si no existe
CREATE SCHEMA IF NOT EXISTS users;
-- Enum para estado de usuario
DO $$ BEGIN
CREATE TYPE users.user_status AS ENUM (
'pending', -- Pendiente de verificacion de email
'active', -- Activo y verificado
'suspended', -- Suspendido temporalmente
'banned', -- Baneado permanentemente
'deleted' -- Eliminado (soft delete)
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla principal de Usuarios
CREATE TABLE IF NOT EXISTS users.users (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
-- Credenciales
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
-- Perfil
first_name VARCHAR(100),
last_name VARCHAR(100),
display_name VARCHAR(100),
avatar_url TEXT,
phone VARCHAR(20),
-- Estado
status users.user_status NOT NULL DEFAULT 'pending',
is_owner BOOLEAN NOT NULL DEFAULT FALSE, -- Owner del tenant
-- Verificaciones
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
email_verified_at TIMESTAMPTZ,
phone_verified BOOLEAN NOT NULL DEFAULT FALSE,
phone_verified_at TIMESTAMPTZ,
-- MFA
mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE,
mfa_secret VARCHAR(255), -- TOTP secret (encrypted)
mfa_backup_codes JSONB, -- Array de backup codes hasheados
-- Seguridad
password_changed_at TIMESTAMPTZ DEFAULT NOW(),
failed_login_attempts INTEGER NOT NULL DEFAULT 0,
locked_until TIMESTAMPTZ,
last_login_at TIMESTAMPTZ,
last_login_ip INET,
-- Preferencias (JSONB flexible)
preferences JSONB NOT NULL DEFAULT '{
"theme": "dark",
"language": "es",
"notifications": {
"email": true,
"push": true,
"sms": false
}
}'::JSONB,
-- Metadata adicional
metadata JSONB DEFAULT '{}'::JSONB,
-- Soft delete
deleted_at TIMESTAMPTZ,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT users_unique_email_per_tenant UNIQUE (tenant_id, email)
);
COMMENT ON TABLE users.users IS
'Usuarios de la plataforma, aislados por tenant';
COMMENT ON COLUMN users.users.is_owner IS
'Indica si es el owner del tenant (tiene todos los permisos)';
COMMENT ON COLUMN users.users.password_hash IS
'Hash bcrypt del password (cost 10)';
-- Indices
CREATE INDEX IF NOT EXISTS idx_users_tenant
ON users.users(tenant_id);
CREATE INDEX IF NOT EXISTS idx_users_email
ON users.users(email);
CREATE INDEX IF NOT EXISTS idx_users_status
ON users.users(status) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_users_owner
ON users.users(tenant_id, is_owner) WHERE is_owner = TRUE;
CREATE INDEX IF NOT EXISTS idx_users_last_login
ON users.users(last_login_at DESC);
-- Trigger para updated_at
CREATE OR REPLACE FUNCTION users.update_user_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS user_updated_at ON users.users;
CREATE TRIGGER user_updated_at
BEFORE UPDATE ON users.users
FOR EACH ROW
EXECUTE FUNCTION users.update_user_timestamp();
-- Funcion para limpiar failed_login_attempts despues de login exitoso
CREATE OR REPLACE FUNCTION users.clear_failed_logins()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.last_login_at IS DISTINCT FROM OLD.last_login_at THEN
NEW.failed_login_attempts := 0;
NEW.locked_until := NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS user_clear_failed_logins ON users.users;
CREATE TRIGGER user_clear_failed_logins
BEFORE UPDATE ON users.users
FOR EACH ROW
EXECUTE FUNCTION users.clear_failed_logins();
-- RLS Policy para multi-tenancy
ALTER TABLE users.users ENABLE ROW LEVEL SECURITY;
CREATE POLICY users_tenant_isolation ON users.users
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE ON users.users TO trading_app;
GRANT SELECT ON users.users TO trading_readonly;

View File

@ -0,0 +1,256 @@
-- ============================================================================
-- SCHEMA: vip
-- TABLES: tiers, subscriptions, model_access
-- DESCRIPTION: Sistema VIP con tiers y modelos exclusivos
-- VERSION: 1.0.0
-- CREATED: 2026-01-10
-- ============================================================================
-- Crear schema
CREATE SCHEMA IF NOT EXISTS vip;
-- Enums
DO $$ BEGIN
CREATE TYPE vip.tier_type AS ENUM (
'GOLD', -- Tier Gold - $199/mes
'PLATINUM', -- Tier Platinum - $399/mes
'DIAMOND' -- Tier Diamond - $999/mes
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
CREATE TYPE vip.subscription_status AS ENUM (
'trialing', -- En periodo de prueba
'active', -- Activa y pagando
'past_due', -- Pago atrasado
'cancelled', -- Cancelada
'expired' -- Expirada
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
-- Tabla de definicion de Tiers
CREATE TABLE IF NOT EXISTS vip.tiers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificacion
tier vip.tier_type UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
tagline VARCHAR(200),
-- Precios
price_monthly DECIMAL(10, 2) NOT NULL,
price_yearly DECIMAL(10, 2),
currency VARCHAR(3) DEFAULT 'USD',
-- Stripe
stripe_product_id VARCHAR(255),
stripe_price_monthly_id VARCHAR(255),
stripe_price_yearly_id VARCHAR(255),
-- Beneficios (JSONB array)
benefits JSONB NOT NULL DEFAULT '[]',
-- Ejemplo:
-- [
-- {"name": "Modelo Ensemble Pro", "included": true},
-- {"name": "Predicciones VIP", "value": "50/mes"},
-- {"name": "Soporte prioritario", "included": true}
-- ]
-- Limites
limits JSONB NOT NULL DEFAULT '{}',
-- Ejemplo:
-- {
-- "vip_predictions_per_month": 50,
-- "api_calls_per_minute": 30,
-- "exclusive_models": ["ensemble_pro"]
-- }
-- Modelos exclusivos incluidos
included_models VARCHAR[] DEFAULT '{}',
-- Display
color VARCHAR(7),
icon VARCHAR(50),
sort_order INT DEFAULT 0,
is_popular BOOLEAN DEFAULT FALSE,
-- Estado
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
COMMENT ON TABLE vip.tiers IS
'Definicion de los tiers VIP disponibles (Gold, Platinum, Diamond)';
-- Tabla de suscripciones VIP
CREATE TABLE IF NOT EXISTS vip.subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
tier_id UUID NOT NULL REFERENCES vip.tiers(id),
-- Stripe
stripe_subscription_id VARCHAR(255) UNIQUE,
stripe_customer_id VARCHAR(255),
-- Estado
status vip.subscription_status NOT NULL DEFAULT 'trialing',
-- Periodo
interval VARCHAR(10) DEFAULT 'month',
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
-- Trial
trial_start TIMESTAMPTZ,
trial_end TIMESTAMPTZ,
-- Cancelacion
cancel_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
cancel_reason VARCHAR(500),
-- Uso del periodo actual
vip_predictions_used INT DEFAULT 0,
api_calls_this_period INT DEFAULT 0,
last_usage_reset TIMESTAMPTZ DEFAULT NOW(),
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Solo una suscripcion activa por usuario
CONSTRAINT unique_active_vip_subscription
UNIQUE (tenant_id, user_id)
);
COMMENT ON TABLE vip.subscriptions IS
'Suscripciones VIP activas de usuarios';
-- Tabla de acceso a modelos exclusivos
CREATE TABLE IF NOT EXISTS vip.model_access (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subscription_id UUID NOT NULL REFERENCES vip.subscriptions(id) ON DELETE CASCADE,
-- Modelo
model_id VARCHAR(100) NOT NULL,
model_name VARCHAR(200),
-- Uso
predictions_generated INT DEFAULT 0,
last_used_at TIMESTAMPTZ,
-- Limites especificos del modelo
daily_limit INT,
monthly_limit INT,
daily_used INT DEFAULT 0,
monthly_used INT DEFAULT 0,
-- Acceso
granted_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ,
UNIQUE(subscription_id, model_id)
);
COMMENT ON TABLE vip.model_access IS
'Registro de acceso a modelos exclusivos por suscripcion VIP';
-- Indices
CREATE INDEX IF NOT EXISTS idx_vip_tiers_active
ON vip.tiers(is_active) WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_vip_subs_tenant
ON vip.subscriptions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_vip_subs_user
ON vip.subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_vip_subs_status
ON vip.subscriptions(status);
CREATE INDEX IF NOT EXISTS idx_vip_subs_stripe
ON vip.subscriptions(stripe_subscription_id);
CREATE INDEX IF NOT EXISTS idx_vip_access_sub
ON vip.model_access(subscription_id);
CREATE INDEX IF NOT EXISTS idx_vip_access_model
ON vip.model_access(model_id);
-- Trigger updated_at
CREATE OR REPLACE FUNCTION vip.update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS vip_tiers_updated ON vip.tiers;
CREATE TRIGGER vip_tiers_updated
BEFORE UPDATE ON vip.tiers
FOR EACH ROW
EXECUTE FUNCTION vip.update_timestamp();
DROP TRIGGER IF EXISTS vip_subs_updated ON vip.subscriptions;
CREATE TRIGGER vip_subs_updated
BEFORE UPDATE ON vip.subscriptions
FOR EACH ROW
EXECUTE FUNCTION vip.update_timestamp();
-- Funcion para verificar acceso VIP
CREATE OR REPLACE FUNCTION vip.check_vip_access(
p_user_id UUID,
p_model_id VARCHAR(100)
)
RETURNS BOOLEAN AS $$
DECLARE
v_has_access BOOLEAN;
BEGIN
SELECT EXISTS (
SELECT 1
FROM vip.subscriptions s
JOIN vip.model_access ma ON ma.subscription_id = s.id
WHERE s.user_id = p_user_id
AND s.status = 'active'
AND ma.model_id = p_model_id
AND (ma.expires_at IS NULL OR ma.expires_at > NOW())
) INTO v_has_access;
RETURN v_has_access;
END;
$$ LANGUAGE plpgsql;
-- RLS
ALTER TABLE vip.tiers ENABLE ROW LEVEL SECURITY;
ALTER TABLE vip.subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE vip.model_access ENABLE ROW LEVEL SECURITY;
CREATE POLICY tiers_read_all ON vip.tiers
FOR SELECT USING (TRUE);
CREATE POLICY subs_tenant_isolation ON vip.subscriptions
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY access_via_subscription ON vip.model_access
FOR ALL
USING (
subscription_id IN (
SELECT id FROM vip.subscriptions
WHERE tenant_id = current_setting('app.current_tenant_id', true)::UUID
)
);
-- Grants
GRANT SELECT ON vip.tiers TO trading_app;
GRANT SELECT, INSERT, UPDATE ON vip.subscriptions TO trading_app;
GRANT SELECT, INSERT, UPDATE ON vip.model_access TO trading_app;
GRANT SELECT ON vip.tiers TO trading_readonly;
GRANT SELECT ON vip.subscriptions TO trading_readonly;

187
scripts/recreate-db.sh Executable file
View File

@ -0,0 +1,187 @@
#!/bin/bash
# ============================================================================
# Script de Recreación de Base de Datos - Trading Platform
# ============================================================================
# Uso: ./recreate-db.sh [--drop] [--seeds]
#
# Opciones:
# --drop Eliminar y recrear la base de datos
# --seeds Ejecutar archivos de seeds después de DDL
#
# Variables de entorno:
# DB_HOST Host de PostgreSQL (default: localhost)
# DB_PORT Puerto de PostgreSQL (default: 5432)
# DB_NAME Nombre de la base de datos (default: trading_platform)
# DB_USER Usuario de PostgreSQL (default: trading_user)
# DB_PASSWORD Contraseña (default: trading_dev_2025)
# ============================================================================
set -e
# Configuración
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
DB_NAME="${DB_NAME:-trading_platform}"
DB_USER="${DB_USER:-trading_user}"
DB_PASSWORD="${DB_PASSWORD:-trading_dev_2025}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DDL_DIR="$SCRIPT_DIR/../ddl/schemas"
SEEDS_DIR="$SCRIPT_DIR/../seeds"
# Colores
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
# Parse arguments
DROP_DB=false
RUN_SEEDS=false
for arg in "$@"; do
case $arg in
--drop) DROP_DB=true ;;
--seeds) RUN_SEEDS=true ;;
--help)
echo "Uso: ./recreate-db.sh [--drop] [--seeds]"
echo ""
echo "Opciones:"
echo " --drop Eliminar y recrear la base de datos"
echo " --seeds Ejecutar archivos de seeds después de DDL"
exit 0
;;
esac
done
# Export password
export PGPASSWORD="$DB_PASSWORD"
echo ""
echo "=============================================="
echo " Trading Platform - Database Recreation"
echo "=============================================="
echo " Host: $DB_HOST:$DB_PORT"
echo " Database: $DB_NAME"
echo " User: $DB_USER"
echo " Drop DB: $DROP_DB"
echo " Run Seeds: $RUN_SEEDS"
echo "=============================================="
echo ""
# Drop database if requested
if [ "$DROP_DB" = true ]; then
log_warn "Eliminando base de datos $DB_NAME..."
psql -h "$DB_HOST" -p "$DB_PORT" -U postgres -c "DROP DATABASE IF EXISTS $DB_NAME;" 2>/dev/null || true
psql -h "$DB_HOST" -p "$DB_PORT" -U postgres -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;"
log_info "Base de datos recreada"
fi
# Crear schemas base
log_step "Creando schemas base..."
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "
CREATE SCHEMA IF NOT EXISTS tenants;
CREATE SCHEMA IF NOT EXISTS users;
CREATE SCHEMA IF NOT EXISTS auth;
CREATE SCHEMA IF NOT EXISTS rbac;
CREATE SCHEMA IF NOT EXISTS teams;
CREATE SCHEMA IF NOT EXISTS audit;
CREATE SCHEMA IF NOT EXISTS financial;
CREATE SCHEMA IF NOT EXISTS products;
CREATE SCHEMA IF NOT EXISTS vip;
CREATE SCHEMA IF NOT EXISTS investment;
CREATE SCHEMA IF NOT EXISTS ml;
"
# Orden de ejecución de DDL
log_step "Ejecutando archivos DDL..."
SCHEMAS=(
# SaaS Core (debe ir primero por dependencias)
"tenants/tables/001_tenants.sql"
"users/tables/001_users.sql"
"auth/tables/001_sessions.sql"
"auth/tables/002_tokens.sql"
# RBAC (después de users)
"rbac/tables/001_roles.sql"
"rbac/tables/002_permissions.sql"
"rbac/tables/003_role_permissions.sql"
"rbac/tables/004_user_roles.sql"
# Teams (después de rbac)
"teams/tables/001_team_members.sql"
"teams/tables/002_invitations.sql"
# Audit (independiente)
"audit/tables/001_audit_logs.sql"
# Trading Platform
"financial/001_wallets.sql"
"financial/002_wallet_transactions.sql"
"products/001_products.sql"
"vip/001_vip_system.sql"
"investment/001_agent_allocations.sql"
"ml/001_predictions_marketplace.sql"
"ml/002_predictions.sql"
"investment/002_add_columns.sql"
)
for schema_file in "${SCHEMAS[@]}"; do
file_path="$DDL_DIR/$schema_file"
if [ -f "$file_path" ]; then
log_info " Ejecutando $schema_file..."
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f "$file_path" -q
else
log_warn " Archivo no encontrado: $schema_file"
fi
done
# Execute seeds if requested
if [ "$RUN_SEEDS" = true ]; then
log_step "Ejecutando archivos de seeds..."
SEEDS=(
"001_vip_tiers.sql"
"002_products.sql"
"003_prediction_packages.sql"
"004_agent_configs.sql"
)
for seed_file in "${SEEDS[@]}"; do
file_path="$SEEDS_DIR/$seed_file"
if [ -f "$file_path" ]; then
log_info " Ejecutando $seed_file..."
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f "$file_path" -q
else
log_warn " Archivo no encontrado: $seed_file"
fi
done
fi
# Validación
log_step "Validando creación..."
echo ""
log_info "Schemas creados:"
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "
SELECT schema_name FROM information_schema.schemata
WHERE schema_name IN ('tenants', 'users', 'auth', 'rbac', 'teams', 'audit', 'financial', 'products', 'vip', 'investment', 'ml')
ORDER BY schema_name;
"
echo ""
log_info "Tablas creadas:"
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "
SELECT table_schema || '.' || table_name
FROM information_schema.tables
WHERE table_schema IN ('tenants', 'users', 'auth', 'rbac', 'teams', 'audit', 'financial', 'products', 'vip', 'investment', 'ml')
ORDER BY table_schema, table_name;
"
echo ""
echo "=============================================="
log_info "Recreación completada exitosamente"
echo "=============================================="

131
seeds/001_vip_tiers.sql Normal file
View File

@ -0,0 +1,131 @@
-- ============================================================================
-- SEED: VIP Tiers
-- DESCRIPTION: Datos iniciales para los tiers VIP del sistema
-- VERSION: 1.0.0
-- CREATED: 2026-01-10
-- ============================================================================
-- Insertar tiers VIP
INSERT INTO vip.tiers (
tier, name, description, tagline,
price_monthly, price_yearly, currency,
benefits, limits, included_models,
color, icon, sort_order, is_popular, is_active
) VALUES
-- GOLD Tier
(
'GOLD',
'Gold',
'Acceso a predicciones premium y modelos avanzados para traders serios',
'Eleva tu trading al siguiente nivel',
199.00,
1990.00, -- ~17% descuento anual
'USD',
'[
{"name": "Predicciones AMD Detector", "included": true},
{"name": "Predicciones Range Predictor", "included": true},
{"name": "Predicciones VIP", "value": "50/mes"},
{"name": "API Calls", "value": "1000/mes"},
{"name": "Soporte Email", "included": true},
{"name": "Reportes Semanales", "included": true}
]'::JSONB,
'{
"vip_predictions_per_month": 50,
"api_calls_per_month": 1000,
"exclusive_models": ["amd_detector", "range_predictor"],
"priority_support": false,
"custom_alerts": 5
}'::JSONB,
ARRAY['amd_detector', 'range_predictor'],
'#FFD700',
'crown',
1,
FALSE,
TRUE
),
-- PLATINUM Tier
(
'PLATINUM',
'Platinum',
'Suite completa de predicciones con modelos exclusivos y soporte prioritario',
'Para traders profesionales',
399.00,
3990.00, -- ~17% descuento anual
'USD',
'[
{"name": "Todo Gold incluido", "included": true},
{"name": "TPSL Classifier", "included": true},
{"name": "ICT/SMC Detector", "included": true},
{"name": "Predicciones VIP", "value": "150/mes"},
{"name": "API Calls", "value": "5000/mes"},
{"name": "Soporte Prioritario", "included": true},
{"name": "Alertas Personalizadas", "value": "20"},
{"name": "Acceso Beta Features", "included": true}
]'::JSONB,
'{
"vip_predictions_per_month": 150,
"api_calls_per_month": 5000,
"exclusive_models": ["amd_detector", "range_predictor", "tpsl_classifier", "ict_smc_detector"],
"priority_support": true,
"custom_alerts": 20,
"beta_access": true
}'::JSONB,
ARRAY['amd_detector', 'range_predictor', 'tpsl_classifier', 'ict_smc_detector'],
'#E5E4E2',
'gem',
2,
TRUE, -- Popular
TRUE
),
-- DIAMOND Tier
(
'DIAMOND',
'Diamond',
'Acceso ilimitado a todos los modelos incluyendo Strategy Ensemble exclusivo',
'El pináculo del trading algorítmico',
999.00,
9990.00, -- ~17% descuento anual
'USD',
'[
{"name": "Todo Platinum incluido", "included": true},
{"name": "Strategy Ensemble (Exclusivo)", "included": true},
{"name": "Predicciones VIP", "value": "Ilimitadas"},
{"name": "API Calls", "value": "Ilimitadas"},
{"name": "Soporte 24/7 Dedicado", "included": true},
{"name": "Alertas Personalizadas", "value": "Ilimitadas"},
{"name": "Sesiones 1-on-1 Mensuales", "value": "2/mes"},
{"name": "Early Access Features", "included": true},
{"name": "White-glove Onboarding", "included": true}
]'::JSONB,
'{
"vip_predictions_per_month": -1,
"api_calls_per_month": -1,
"exclusive_models": ["amd_detector", "range_predictor", "tpsl_classifier", "ict_smc_detector", "strategy_ensemble"],
"priority_support": true,
"dedicated_support": true,
"custom_alerts": -1,
"beta_access": true,
"early_access": true,
"monthly_sessions": 2
}'::JSONB,
ARRAY['amd_detector', 'range_predictor', 'tpsl_classifier', 'ict_smc_detector', 'strategy_ensemble'],
'#B9F2FF',
'diamond',
3,
FALSE,
TRUE
)
ON CONFLICT (tier) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
tagline = EXCLUDED.tagline,
price_monthly = EXCLUDED.price_monthly,
price_yearly = EXCLUDED.price_yearly,
benefits = EXCLUDED.benefits,
limits = EXCLUDED.limits,
included_models = EXCLUDED.included_models,
color = EXCLUDED.color,
icon = EXCLUDED.icon,
sort_order = EXCLUDED.sort_order,
is_popular = EXCLUDED.is_popular,
updated_at = NOW();

335
seeds/002_products.sql Normal file
View File

@ -0,0 +1,335 @@
-- ============================================================================
-- SEED: Products Catalog
-- DESCRIPTION: Catalogo inicial de productos y servicios
-- VERSION: 1.0.0
-- CREATED: 2026-01-10
-- ============================================================================
-- Productos de Predicciones (One-Time)
INSERT INTO products.products (
sku, name, slug, description, short_description,
type, category, price, compare_price, currency,
features, limits,
ml_model_id, prediction_type, prediction_count,
image_url, badge, sort_order, is_active, is_vip, is_featured
) VALUES
-- Pack AMD Detector
(
'PRED-AMD-10',
'AMD Detector - Pack 10 Predicciones',
'amd-detector-10',
'Detecta patrones de Acumulación, Manipulación y Distribución en el mercado. Ideal para identificar movimientos institucionales.',
'10 predicciones AMD con 85% accuracy histórico',
'ONE_TIME', 'PREDICTION',
29.00, 39.00, 'USD',
'[
{"name": "10 predicciones AMD", "included": true},
{"name": "Válido por 30 días", "included": true},
{"name": "Accuracy histórico 85%", "included": true},
{"name": "Soporte email", "included": true}
]'::JSONB,
'{"predictions": 10, "validity_days": 30}'::JSONB,
'amd_detector', 'AMD', 10,
'/images/products/amd-detector.png',
'Popular',
1, TRUE, FALSE, TRUE
),
(
'PRED-AMD-50',
'AMD Detector - Pack 50 Predicciones',
'amd-detector-50',
'Pack profesional de 50 predicciones AMD. Ahorra 20% comparado con packs individuales.',
'50 predicciones AMD - Mejor valor',
'ONE_TIME', 'PREDICTION',
119.00, 145.00, 'USD',
'[
{"name": "50 predicciones AMD", "included": true},
{"name": "Válido por 60 días", "included": true},
{"name": "20% descuento", "included": true},
{"name": "Soporte prioritario", "included": true}
]'::JSONB,
'{"predictions": 50, "validity_days": 60}'::JSONB,
'amd_detector', 'AMD', 50,
'/images/products/amd-detector-pro.png',
'Ahorra 20%',
2, TRUE, FALSE, FALSE
),
-- Pack Range Predictor
(
'PRED-RANGE-10',
'Range Predictor - Pack 10 Predicciones',
'range-predictor-10',
'Predice rangos de precio con alta precisión. Perfecto para trading de soporte/resistencia.',
'10 predicciones de rango de precio',
'ONE_TIME', 'PREDICTION',
24.00, 34.00, 'USD',
'[
{"name": "10 predicciones de rango", "included": true},
{"name": "Válido por 30 días", "included": true},
{"name": "Incluye niveles S/R", "included": true}
]'::JSONB,
'{"predictions": 10, "validity_days": 30}'::JSONB,
'range_predictor', 'RANGE', 10,
'/images/products/range-predictor.png',
NULL,
3, TRUE, FALSE, FALSE
),
-- Pack TPSL
(
'PRED-TPSL-20',
'TP/SL Classifier - Pack 20 Predicciones',
'tpsl-classifier-20',
'Clasificador avanzado de Take Profit y Stop Loss óptimos basado en condiciones de mercado.',
'Optimiza tus exits con ML',
'ONE_TIME', 'PREDICTION',
49.00, 59.00, 'USD',
'[
{"name": "20 predicciones TP/SL", "included": true},
{"name": "Válido por 45 días", "included": true},
{"name": "Risk/Reward optimizado", "included": true}
]'::JSONB,
'{"predictions": 20, "validity_days": 45}'::JSONB,
'tpsl_classifier', 'TPSL', 20,
'/images/products/tpsl-classifier.png',
NULL,
4, TRUE, FALSE, FALSE
),
-- Pack Combo
(
'PRED-COMBO-STARTER',
'Combo Starter - AMD + Range + TPSL',
'combo-starter',
'Pack combinado perfecto para empezar. Incluye los 3 modelos básicos.',
'Todo lo que necesitas para empezar',
'ONE_TIME', 'PREDICTION',
79.00, 102.00, 'USD',
'[
{"name": "10 predicciones AMD", "included": true},
{"name": "10 predicciones Range", "included": true},
{"name": "10 predicciones TPSL", "included": true},
{"name": "Válido por 30 días", "included": true},
{"name": "22% descuento", "included": true}
]'::JSONB,
'{"predictions_amd": 10, "predictions_range": 10, "predictions_tpsl": 10, "validity_days": 30}'::JSONB,
NULL, 'COMBO', 30,
'/images/products/combo-starter.png',
'Best Value',
5, TRUE, FALSE, TRUE
)
ON CONFLICT (sku) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
price = EXCLUDED.price,
compare_price = EXCLUDED.compare_price,
features = EXCLUDED.features,
limits = EXCLUDED.limits,
badge = EXCLUDED.badge,
is_featured = EXCLUDED.is_featured,
updated_at = NOW();
-- Productos de Acceso a Agentes (Subscription)
INSERT INTO products.products (
sku, name, slug, description, short_description,
type, category, price, currency,
billing_interval, billing_interval_count,
features, limits, agent_type,
image_url, badge, sort_order, is_active, is_vip, is_featured
) VALUES
(
'AGENT-ATLAS-ACCESS',
'Atlas Agent - Acceso Mensual',
'atlas-agent-access',
'Acceso al agente conservador Atlas. Estrategia de bajo riesgo con retornos estables.',
'Trading conservador automatizado',
'SUBSCRIPTION', 'AGENT_ACCESS',
49.00, 'USD',
'MONTH', 1,
'[
{"name": "Acceso completo a Atlas", "included": true},
{"name": "Estrategia conservadora", "included": true},
{"name": "Max drawdown 10%", "included": true},
{"name": "Reportes diarios", "included": true}
]'::JSONB,
'{"max_allocation": 5000, "risk_level": "LOW"}'::JSONB,
'ATLAS',
'/images/agents/atlas.png',
NULL,
10, TRUE, FALSE, FALSE
),
(
'AGENT-ORION-ACCESS',
'Orion Agent - Acceso Mensual',
'orion-agent-access',
'Acceso al agente moderado Orion. Balance óptimo entre riesgo y retorno.',
'Trading balanceado automatizado',
'SUBSCRIPTION', 'AGENT_ACCESS',
79.00, 'USD',
'MONTH', 1,
'[
{"name": "Acceso completo a Orion", "included": true},
{"name": "Estrategia moderada", "included": true},
{"name": "Max drawdown 20%", "included": true},
{"name": "Reportes diarios", "included": true},
{"name": "Alertas en tiempo real", "included": true}
]'::JSONB,
'{"max_allocation": 10000, "risk_level": "MEDIUM"}'::JSONB,
'ORION',
'/images/agents/orion.png',
'Recomendado',
11, TRUE, FALSE, TRUE
),
(
'AGENT-NOVA-ACCESS',
'Nova Agent - Acceso Mensual',
'nova-agent-access',
'Acceso al agente agresivo Nova. Máximo potencial de retorno para traders experimentados.',
'Trading agresivo automatizado',
'SUBSCRIPTION', 'AGENT_ACCESS',
129.00, 'USD',
'MONTH', 1,
'[
{"name": "Acceso completo a Nova", "included": true},
{"name": "Estrategia agresiva", "included": true},
{"name": "Alto potencial de retorno", "included": true},
{"name": "Reportes en tiempo real", "included": true},
{"name": "Soporte prioritario", "included": true}
]'::JSONB,
'{"max_allocation": 25000, "risk_level": "HIGH"}'::JSONB,
'NOVA',
'/images/agents/nova.png',
'Pro',
12, TRUE, FALSE, FALSE
)
ON CONFLICT (sku) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
price = EXCLUDED.price,
features = EXCLUDED.features,
limits = EXCLUDED.limits,
badge = EXCLUDED.badge,
is_featured = EXCLUDED.is_featured,
updated_at = NOW();
-- Productos Educativos
INSERT INTO products.products (
sku, name, slug, description, short_description,
type, category, price, compare_price, currency,
features, limits,
image_url, badge, sort_order, is_active, is_vip, is_featured
) VALUES
(
'EDU-TRADING-101',
'Trading Fundamentals 101',
'trading-fundamentals-101',
'Curso completo de fundamentos de trading. Desde conceptos básicos hasta estrategias avanzadas.',
'Aprende trading desde cero',
'ONE_TIME', 'EDUCATION',
149.00, 199.00, 'USD',
'[
{"name": "20+ horas de video", "included": true},
{"name": "Acceso de por vida", "included": true},
{"name": "Certificado de completación", "included": true},
{"name": "Comunidad privada", "included": true}
]'::JSONB,
'{"lifetime_access": true}'::JSONB,
'/images/courses/trading-101.png',
NULL,
20, TRUE, FALSE, FALSE
),
(
'EDU-ML-TRADING',
'ML for Trading Masterclass',
'ml-trading-masterclass',
'Masterclass avanzado sobre Machine Learning aplicado al trading. Aprende a crear tus propios modelos.',
'Domina ML para trading',
'ONE_TIME', 'EDUCATION',
399.00, 499.00, 'USD',
'[
{"name": "40+ horas de contenido", "included": true},
{"name": "Código fuente incluido", "included": true},
{"name": "Acceso a datasets", "included": true},
{"name": "Mentorías grupales", "value": "4 sesiones"},
{"name": "Acceso de por vida", "included": true}
]'::JSONB,
'{"lifetime_access": true, "mentoring_sessions": 4}'::JSONB,
'/images/courses/ml-trading.png',
'Premium',
21, TRUE, FALSE, TRUE
)
ON CONFLICT (sku) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
price = EXCLUDED.price,
compare_price = EXCLUDED.compare_price,
features = EXCLUDED.features,
badge = EXCLUDED.badge,
is_featured = EXCLUDED.is_featured,
updated_at = NOW();
-- Productos VIP (referencia a tiers)
INSERT INTO products.products (
sku, name, slug, description, short_description,
type, category, price, currency,
billing_interval, billing_interval_count, trial_days,
features, limits,
image_url, badge, sort_order, is_active, is_vip, is_featured
) VALUES
(
'VIP-GOLD-MONTHLY',
'VIP Gold - Mensual',
'vip-gold-monthly',
'Suscripción VIP Gold con acceso a modelos premium',
'Tier Gold - $199/mes',
'VIP', 'PREMIUM_FEATURE',
199.00, 'USD',
'MONTH', 1, 7,
'[
{"name": "Ver todos los beneficios Gold", "included": true}
]'::JSONB,
'{"vip_tier": "GOLD"}'::JSONB,
'/images/vip/gold.png',
NULL,
30, TRUE, TRUE, FALSE
),
(
'VIP-PLATINUM-MONTHLY',
'VIP Platinum - Mensual',
'vip-platinum-monthly',
'Suscripción VIP Platinum con acceso completo',
'Tier Platinum - $399/mes',
'VIP', 'PREMIUM_FEATURE',
399.00, 'USD',
'MONTH', 1, 7,
'[
{"name": "Ver todos los beneficios Platinum", "included": true}
]'::JSONB,
'{"vip_tier": "PLATINUM"}'::JSONB,
'/images/vip/platinum.png',
'Popular',
31, TRUE, TRUE, TRUE
),
(
'VIP-DIAMOND-MONTHLY',
'VIP Diamond - Mensual',
'vip-diamond-monthly',
'Suscripción VIP Diamond - Acceso total ilimitado',
'Tier Diamond - $999/mes',
'VIP', 'PREMIUM_FEATURE',
999.00, 'USD',
'MONTH', 1, 14,
'[
{"name": "Ver todos los beneficios Diamond", "included": true}
]'::JSONB,
'{"vip_tier": "DIAMOND"}'::JSONB,
'/images/vip/diamond.png',
'Exclusive',
32, TRUE, TRUE, FALSE
)
ON CONFLICT (sku) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
price = EXCLUDED.price,
features = EXCLUDED.features,
badge = EXCLUDED.badge,
is_featured = EXCLUDED.is_featured,
updated_at = NOW();

View File

@ -0,0 +1,167 @@
-- ============================================================================
-- SEED: Prediction Packages
-- DESCRIPTION: Paquetes de predicciones ML para marketplace
-- VERSION: 1.0.0
-- CREATED: 2026-01-10
-- ============================================================================
INSERT INTO ml.prediction_packages (
name, slug, description,
model_id, model_name, prediction_count,
included_symbols, included_timeframes,
price, compare_price, validity_days,
badge, is_popular, sort_order, is_active
) VALUES
-- AMD Detector Packages
(
'AMD Detector - Starter',
'amd-starter',
'Pack inicial de 10 predicciones AMD. Perfecto para probar el modelo.',
'amd_detector',
'AMD Detector v2.0',
10,
NULL, -- Todos los símbolos
ARRAY['H1', 'H4', 'D1'],
29.00, 39.00, 30,
NULL, FALSE, 1, TRUE
),
(
'AMD Detector - Pro',
'amd-pro',
'Pack profesional de 50 predicciones AMD. Incluye todos los símbolos y timeframes.',
'amd_detector',
'AMD Detector v2.0',
50,
NULL,
ARRAY['M15', 'H1', 'H4', 'D1'],
119.00, 145.00, 60,
'Best Value', TRUE, 2, TRUE
),
(
'AMD Detector - Enterprise',
'amd-enterprise',
'Pack empresarial de 200 predicciones AMD para traders de alto volumen.',
'amd_detector',
'AMD Detector v2.0',
200,
NULL,
ARRAY['M15', 'H1', 'H4', 'D1', 'W1'],
399.00, 580.00, 90,
'Enterprise', FALSE, 3, TRUE
),
-- Range Predictor Packages
(
'Range Predictor - Starter',
'range-starter',
'Pack inicial de 10 predicciones de rango. Ideal para trading de S/R.',
'range_predictor',
'Range Predictor v1.5',
10,
NULL,
ARRAY['H1', 'H4', 'D1'],
24.00, 34.00, 30,
NULL, FALSE, 4, TRUE
),
(
'Range Predictor - Pro',
'range-pro',
'Pack profesional de 50 predicciones de rango.',
'range_predictor',
'Range Predictor v1.5',
50,
NULL,
ARRAY['M15', 'H1', 'H4', 'D1'],
99.00, 120.00, 60,
NULL, FALSE, 5, TRUE
),
-- TPSL Classifier Packages
(
'TPSL Classifier - Starter',
'tpsl-starter',
'Pack inicial de 20 clasificaciones TP/SL óptimas.',
'tpsl_classifier',
'TPSL Classifier v1.2',
20,
NULL,
ARRAY['H1', 'H4', 'D1'],
49.00, 59.00, 45,
NULL, FALSE, 6, TRUE
),
(
'TPSL Classifier - Pro',
'tpsl-pro',
'Pack profesional de 100 clasificaciones TP/SL.',
'tpsl_classifier',
'TPSL Classifier v1.2',
100,
NULL,
ARRAY['M15', 'H1', 'H4', 'D1'],
199.00, 245.00, 90,
NULL, TRUE, 7, TRUE
),
-- ICT/SMC Detector Packages (VIP Only)
(
'ICT/SMC Detector - Gold',
'ict-smc-gold',
'Pack de 30 detecciones ICT/SMC para traders VIP Gold+.',
'ict_smc_detector',
'ICT/SMC Detector v1.0',
30,
NULL,
ARRAY['H1', 'H4', 'D1'],
149.00, 179.00, 45,
'VIP Only', FALSE, 8, TRUE
),
-- Strategy Ensemble Packages (Diamond Only)
(
'Strategy Ensemble - Diamond',
'ensemble-diamond',
'Pack exclusivo de 50 predicciones Ensemble combinando todos los modelos.',
'strategy_ensemble',
'Strategy Ensemble v1.0',
50,
NULL,
ARRAY['H1', 'H4', 'D1'],
299.00, 399.00, 60,
'Diamond Exclusive', FALSE, 9, TRUE
),
-- Combo Packages
(
'Combo Trader - Básico',
'combo-basico',
'Combo de AMD + Range + TPSL. 30 predicciones totales.',
'combo_basic',
'AMD + Range + TPSL',
30,
NULL,
ARRAY['H1', 'H4', 'D1'],
79.00, 102.00, 30,
'Best Starter', TRUE, 10, TRUE
),
(
'Combo Trader - Avanzado',
'combo-avanzado',
'Combo completo con todos los modelos básicos. 100 predicciones.',
'combo_advanced',
'AMD + Range + TPSL + ICT',
100,
NULL,
ARRAY['M15', 'H1', 'H4', 'D1'],
249.00, 320.00, 60,
'Pro Trader', FALSE, 11, TRUE
)
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
price = EXCLUDED.price,
compare_price = EXCLUDED.compare_price,
prediction_count = EXCLUDED.prediction_count,
validity_days = EXCLUDED.validity_days,
badge = EXCLUDED.badge,
is_popular = EXCLUDED.is_popular,
updated_at = NOW();

140
seeds/004_agent_configs.sql Normal file
View File

@ -0,0 +1,140 @@
-- ============================================================================
-- SEED: Agent Configurations
-- DESCRIPTION: Configuracion inicial de Money Manager Agents por tenant
-- VERSION: 1.0.0
-- CREATED: 2026-01-10
-- NOTE: Este seed debe ejecutarse por tenant o con tenant_id especifico
-- ============================================================================
-- Funcion helper para crear configs de agentes para un tenant
CREATE OR REPLACE FUNCTION investment.seed_agent_configs(p_tenant_id UUID)
RETURNS VOID AS $$
BEGIN
-- Atlas - Conservador
INSERT INTO investment.agent_configs (
tenant_id, agent_type,
name, -- NUEVO
min_allocation, max_allocation_per_user, max_total_allocation,
platform_fee_rate, performance_fee_rate,
target_return_percent, max_drawdown_percent, -- NUEVOS
is_active, accepting_new_allocations,
description, risk_disclosure
) VALUES (
p_tenant_id,
'ATLAS',
'Atlas', -- NUEVO
100.00, -- Min $100
5000.00, -- Max $5,000 por usuario
500000.00, -- Max $500,000 total
0.05, -- 5% platform fee
0.15, -- 15% performance fee
8.00, -- NUEVO: 8% target return
10.00, -- NUEVO: 10% max drawdown
TRUE,
TRUE,
'Atlas es nuestro agente conservador diseñado para preservación de capital con crecimiento estable. ' ||
'Utiliza estrategias de bajo riesgo con drawdown máximo del 10%. Ideal para inversores que priorizan ' ||
'la seguridad sobre altos retornos.',
'ADVERTENCIA: El trading conlleva riesgos. Aunque Atlas utiliza estrategias conservadoras, ' ||
'existe la posibilidad de pérdidas. Los rendimientos pasados no garantizan resultados futuros. ' ||
'Solo invierta capital que pueda permitirse perder. Max drawdown esperado: 10%.'
)
ON CONFLICT (tenant_id, agent_type) DO UPDATE SET
name = EXCLUDED.name,
min_allocation = EXCLUDED.min_allocation,
max_allocation_per_user = EXCLUDED.max_allocation_per_user,
target_return_percent = EXCLUDED.target_return_percent,
max_drawdown_percent = EXCLUDED.max_drawdown_percent,
description = EXCLUDED.description,
updated_at = NOW();
-- Orion - Moderado
INSERT INTO investment.agent_configs (
tenant_id, agent_type,
name,
min_allocation, max_allocation_per_user, max_total_allocation,
platform_fee_rate, performance_fee_rate,
target_return_percent, max_drawdown_percent,
is_active, accepting_new_allocations,
description, risk_disclosure
) VALUES (
p_tenant_id,
'ORION',
'Orion',
250.00, -- Min $250
10000.00, -- Max $10,000 por usuario
1000000.00, -- Max $1,000,000 total
0.08, -- 8% platform fee
0.20, -- 20% performance fee
15.00, -- 15% target return
20.00, -- 20% max drawdown
TRUE,
TRUE,
'Orion es nuestro agente de riesgo moderado que balancea preservación de capital con oportunidades ' ||
'de crecimiento. Combina múltiples estrategias ML para optimizar el ratio riesgo/retorno. ' ||
'Recomendado para traders con experiencia intermedia.',
'ADVERTENCIA: Orion opera con riesgo moderado. Drawdown máximo esperado: 20%. ' ||
'Los resultados pueden variar significativamente según condiciones de mercado. ' ||
'Se recomienda un horizonte de inversión de al menos 3 meses.'
)
ON CONFLICT (tenant_id, agent_type) DO UPDATE SET
name = EXCLUDED.name,
min_allocation = EXCLUDED.min_allocation,
max_allocation_per_user = EXCLUDED.max_allocation_per_user,
target_return_percent = EXCLUDED.target_return_percent,
max_drawdown_percent = EXCLUDED.max_drawdown_percent,
description = EXCLUDED.description,
updated_at = NOW();
-- Nova - Agresivo
INSERT INTO investment.agent_configs (
tenant_id, agent_type,
name,
min_allocation, max_allocation_per_user, max_total_allocation,
platform_fee_rate, performance_fee_rate,
target_return_percent, max_drawdown_percent,
is_active, accepting_new_allocations,
description, risk_disclosure
) VALUES (
p_tenant_id,
'NOVA',
'Nova',
500.00, -- Min $500
25000.00, -- Max $25,000 por usuario
2000000.00, -- Max $2,000,000 total
0.10, -- 10% platform fee
0.25, -- 25% performance fee
25.00, -- 25% target return
35.00, -- 35% max drawdown
TRUE,
TRUE,
'Nova es nuestro agente de alto rendimiento diseñado para traders experimentados que buscan ' ||
'maximizar retornos. Utiliza estrategias agresivas incluyendo Strategy Ensemble y posiciones ' ||
'de mayor tamaño. Solo para inversores que comprenden y aceptan alto riesgo.',
'ALTO RIESGO: Nova opera con estrategias agresivas. Drawdown máximo esperado: 35%. ' ||
'Este agente puede experimentar pérdidas significativas en cortos periodos. ' ||
'SOLO PARA INVERSORES EXPERIMENTADOS que pueden tolerar alta volatilidad. ' ||
'No invierta más del 10% de su capital total en este agente.'
)
ON CONFLICT (tenant_id, agent_type) DO UPDATE SET
name = EXCLUDED.name,
min_allocation = EXCLUDED.min_allocation,
max_allocation_per_user = EXCLUDED.max_allocation_per_user,
target_return_percent = EXCLUDED.target_return_percent,
max_drawdown_percent = EXCLUDED.max_drawdown_percent,
description = EXCLUDED.description,
updated_at = NOW();
END;
$$ LANGUAGE plpgsql;
-- Comentario sobre uso
COMMENT ON FUNCTION investment.seed_agent_configs IS
'Ejecutar con: SELECT investment.seed_agent_configs(''your-tenant-uuid'');';
-- Para desarrollo/demo, crear config para tenant demo si existe
-- DO $$
-- DECLARE
-- v_demo_tenant_id UUID := '00000000-0000-0000-0000-000000000001';
-- BEGIN
-- PERFORM investment.seed_agent_configs(v_demo_tenant_id);
-- END $$;

61
seeds/_INDEX.sql Normal file
View File

@ -0,0 +1,61 @@
-- ============================================================================
-- SEEDS INDEX
-- DESCRIPTION: Orden de ejecución de seeds para trading-platform
-- VERSION: 1.0.0
-- CREATED: 2026-01-10
-- ============================================================================
-- IMPORTANTE: Ejecutar seeds en este orden después de los DDL schemas
-- PASO 1: Asegurarse que todos los DDL están ejecutados
-- \i schemas/financial/001_wallets.sql
-- \i schemas/financial/002_wallet_transactions.sql
-- \i schemas/products/001_products.sql
-- \i schemas/vip/001_vip_system.sql
-- \i schemas/ml/001_predictions_marketplace.sql
-- \i schemas/investment/001_agent_allocations.sql
-- PASO 2: Ejecutar seeds en orden
-- 001: VIP Tiers (sin dependencias)
-- 002: Products (sin dependencias)
-- 003: Prediction Packages (requiere ml schema)
-- 004: Agent Configs (requiere investment schema, ejecutar por tenant)
-- ============================================================================
-- EJECUCIÓN:
-- ============================================================================
-- Opción 1: Ejecutar individualmente
-- \i 001_vip_tiers.sql
-- \i 002_products.sql
-- \i 003_prediction_packages.sql
-- \i 004_agent_configs.sql
-- Opción 2: Ejecutar este archivo (runner)
\echo 'Ejecutando seeds de trading-platform...'
\echo ' [1/4] VIP Tiers...'
\i 001_vip_tiers.sql
\echo ' [2/4] Products...'
\i 002_products.sql
\echo ' [3/4] Prediction Packages...'
\i 003_prediction_packages.sql
\echo ' [4/4] Agent Configs (función creada)...'
\i 004_agent_configs.sql
\echo ''
\echo 'Seeds completados!'
\echo ''
\echo 'NOTA: Para configurar agentes por tenant, ejecutar:'
\echo ' SELECT investment.seed_agent_configs(''tenant-uuid'');'
\echo ''
-- ============================================================================
-- VERIFICACIÓN:
-- ============================================================================
-- SELECT COUNT(*) as vip_tiers FROM vip.tiers;
-- SELECT COUNT(*) as products FROM products.products;
-- SELECT COUNT(*) as prediction_packages FROM ml.prediction_packages;