erp-core-database-v2/ddl/29-cfdi-rls-functions.sql
Adrian Flores Cortes 02ab2caf26 [TASK-2026-02-05-EJECUCION-REMEDIATION-ERP-CORE] feat: DDL fixes and new schemas
- 00-auth-base.sql: Extracted auth.tenants+users from recreate-database.sh
- 03b-core-companies.sql: DDL for auth.companies entity
- 21b-inventory-extended.sql: 7 new tables for inventory entities without DDL
- 24-invoices.sql: billing→operations schema to resolve duplication
- 27/28/29-cfdi: Track existing CFDI DDL files
- recreate-database.sh: Updated ddl_files array (17→43 entries)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:52:22 -06:00

531 lines
17 KiB
PL/PgSQL

-- =============================================================
-- ARCHIVO: 29-cfdi-rls-functions.sql
-- DESCRIPCION: Modulo CFDI - RLS policies, funciones auxiliares,
-- triggers y vistas
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-02-03
-- DEPENDE DE: 27-cfdi-core.sql, 28-cfdi-operations.sql
-- =============================================================
-- =====================
-- ROW LEVEL SECURITY (RLS)
-- =====================
-- cfdi_certificates
ALTER TABLE fiscal.cfdi_certificates ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation_cfdi_certificates ON fiscal.cfdi_certificates;
CREATE POLICY tenant_isolation_cfdi_certificates ON fiscal.cfdi_certificates
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- cfdi_pac_configurations
ALTER TABLE fiscal.cfdi_pac_configurations ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation_cfdi_pac ON fiscal.cfdi_pac_configurations;
CREATE POLICY tenant_isolation_cfdi_pac ON fiscal.cfdi_pac_configurations
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- cfdi_invoices
ALTER TABLE fiscal.cfdi_invoices ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation_cfdi_invoices ON fiscal.cfdi_invoices;
CREATE POLICY tenant_isolation_cfdi_invoices ON fiscal.cfdi_invoices
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- cfdi_cancellation_requests
ALTER TABLE fiscal.cfdi_cancellation_requests ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation_cfdi_cancellations ON fiscal.cfdi_cancellation_requests;
CREATE POLICY tenant_isolation_cfdi_cancellations ON fiscal.cfdi_cancellation_requests
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- cfdi_operation_logs
ALTER TABLE fiscal.cfdi_operation_logs ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation_cfdi_logs ON fiscal.cfdi_operation_logs;
CREATE POLICY tenant_isolation_cfdi_logs ON fiscal.cfdi_operation_logs
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- cfdi_payment_complements
ALTER TABLE fiscal.cfdi_payment_complements ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation_cfdi_payment_comp ON fiscal.cfdi_payment_complements;
CREATE POLICY tenant_isolation_cfdi_payment_comp ON fiscal.cfdi_payment_complements
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- cfdi_stamp_queue
ALTER TABLE fiscal.cfdi_stamp_queue ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation_cfdi_queue ON fiscal.cfdi_stamp_queue;
CREATE POLICY tenant_isolation_cfdi_queue ON fiscal.cfdi_stamp_queue
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- =====================
-- COMENTARIOS RLS
-- =====================
COMMENT ON POLICY tenant_isolation_cfdi_certificates ON fiscal.cfdi_certificates IS 'Aislamiento multi-tenant para certificados CSD';
COMMENT ON POLICY tenant_isolation_cfdi_invoices ON fiscal.cfdi_invoices IS 'Aislamiento multi-tenant para CFDIs';
COMMENT ON POLICY tenant_isolation_cfdi_cancellations ON fiscal.cfdi_cancellation_requests IS 'Aislamiento multi-tenant para cancelaciones';
COMMENT ON POLICY tenant_isolation_cfdi_logs ON fiscal.cfdi_operation_logs IS 'Aislamiento multi-tenant para logs de operaciones';
COMMENT ON POLICY tenant_isolation_cfdi_payment_comp ON fiscal.cfdi_payment_complements IS 'Aislamiento multi-tenant para complementos de pago';
-- =====================
-- FUNCIONES AUXILIARES
-- =====================
-- Funcion: Obtener certificado activo por defecto del tenant
CREATE OR REPLACE FUNCTION fiscal.get_default_certificate(p_tenant_id UUID)
RETURNS UUID AS $$
DECLARE
v_certificate_id UUID;
BEGIN
SELECT id INTO v_certificate_id
FROM fiscal.cfdi_certificates
WHERE tenant_id = p_tenant_id
AND is_default = TRUE
AND status = 'active'
AND deleted_at IS NULL
AND expires_at > CURRENT_TIMESTAMP
LIMIT 1;
RETURN v_certificate_id;
END;
$$ LANGUAGE plpgsql STABLE;
COMMENT ON FUNCTION fiscal.get_default_certificate IS 'Obtiene el ID del certificado CSD activo y vigente por defecto del tenant';
-- Funcion: Obtener configuracion PAC activa del tenant
CREATE OR REPLACE FUNCTION fiscal.get_active_pac(p_tenant_id UUID)
RETURNS TABLE (
pac_id UUID,
pac_code VARCHAR,
environment VARCHAR,
api_url VARCHAR
) AS $$
BEGIN
RETURN QUERY
SELECT
pc.id,
pc.pac_code,
pc.environment,
CASE
WHEN pc.environment = 'production' THEN pc.production_url
ELSE pc.sandbox_url
END as api_url
FROM fiscal.cfdi_pac_configurations pc
WHERE pc.tenant_id = p_tenant_id
AND pc.is_active = TRUE
AND pc.is_default = TRUE
AND pc.deleted_at IS NULL
LIMIT 1;
END;
$$ LANGUAGE plpgsql STABLE;
COMMENT ON FUNCTION fiscal.get_active_pac IS 'Obtiene la configuracion del PAC activo del tenant';
-- Funcion: Validar RFC mexicano
CREATE OR REPLACE FUNCTION fiscal.validate_rfc(p_rfc VARCHAR)
RETURNS BOOLEAN AS $$
DECLARE
v_pattern_fisica VARCHAR := '^[A-Z&N]{4}[0-9]{6}[A-Z0-9]{3}$';
v_pattern_moral VARCHAR := '^[A-Z&N]{3}[0-9]{6}[A-Z0-9]{3}$';
v_rfc VARCHAR;
BEGIN
-- Normalizar RFC (mayusculas, sin espacios)
v_rfc := UPPER(TRIM(p_rfc));
-- RFC generico publico en general
IF v_rfc = 'XAXX010101000' THEN
RETURN TRUE;
END IF;
-- RFC generico extranjero
IF v_rfc = 'XEXX010101000' THEN
RETURN TRUE;
END IF;
-- Validar patron persona fisica (13 caracteres)
IF LENGTH(v_rfc) = 13 AND v_rfc ~ v_pattern_fisica THEN
RETURN TRUE;
END IF;
-- Validar patron persona moral (12 caracteres)
IF LENGTH(v_rfc) = 12 AND v_rfc ~ v_pattern_moral THEN
RETURN TRUE;
END IF;
RETURN FALSE;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
COMMENT ON FUNCTION fiscal.validate_rfc IS 'Valida el formato de un RFC mexicano (persona fisica o moral)';
-- Funcion: Generar folio CFDI
CREATE OR REPLACE FUNCTION fiscal.generate_cfdi_folio(
p_tenant_id UUID,
p_serie VARCHAR DEFAULT NULL
)
RETURNS VARCHAR AS $$
DECLARE
v_next_folio INTEGER;
v_serie VARCHAR;
BEGIN
-- Serie por defecto si no se proporciona
v_serie := COALESCE(p_serie, 'A');
-- Obtener siguiente folio
SELECT COALESCE(MAX(folio::INTEGER), 0) + 1
INTO v_next_folio
FROM fiscal.cfdi_invoices
WHERE tenant_id = p_tenant_id
AND serie = v_serie
AND folio ~ '^\d+$'; -- Solo folios numericos
RETURN v_next_folio::VARCHAR;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION fiscal.generate_cfdi_folio IS 'Genera el siguiente folio para una serie de CFDI';
-- Funcion: Verificar si CFDI puede ser cancelado
CREATE OR REPLACE FUNCTION fiscal.can_cancel_cfdi(p_cfdi_id UUID)
RETURNS TABLE (
can_cancel BOOLEAN,
reason VARCHAR,
requires_acceptance BOOLEAN
) AS $$
DECLARE
v_cfdi RECORD;
v_has_payments BOOLEAN;
v_is_month_old BOOLEAN;
BEGIN
-- Obtener datos del CFDI
SELECT ci.*, bi.total, bi.amount_paid
INTO v_cfdi
FROM fiscal.cfdi_invoices ci
JOIN billing.invoices bi ON ci.invoice_id = bi.id
WHERE ci.id = p_cfdi_id;
IF NOT FOUND THEN
RETURN QUERY SELECT FALSE, 'CFDI no encontrado'::VARCHAR, FALSE;
RETURN;
END IF;
-- Verificar estado
IF v_cfdi.status != 'stamped' THEN
RETURN QUERY SELECT FALSE, 'CFDI no esta timbrado'::VARCHAR, FALSE;
RETURN;
END IF;
-- Verificar si ya tiene solicitud de cancelacion
IF EXISTS (
SELECT 1 FROM fiscal.cfdi_cancellation_requests
WHERE cfdi_invoice_id = p_cfdi_id
AND status NOT IN ('rejected', 'error')
) THEN
RETURN QUERY SELECT FALSE, 'Ya existe solicitud de cancelacion'::VARCHAR, FALSE;
RETURN;
END IF;
-- Verificar si tiene pagos aplicados
v_has_payments := COALESCE(v_cfdi.amount_paid, 0) > 0;
-- Verificar si tiene mas de un mes
v_is_month_old := v_cfdi.stamp_date < (CURRENT_DATE - INTERVAL '1 month');
-- Determinar si requiere aceptacion del receptor
-- Segun reglas SAT 2022+
IF v_cfdi.total > 5000 OR v_is_month_old OR v_has_payments THEN
RETURN QUERY SELECT TRUE, 'Puede cancelar con aceptacion del receptor'::VARCHAR, TRUE;
ELSE
RETURN QUERY SELECT TRUE, 'Puede cancelar sin aceptacion'::VARCHAR, FALSE;
END IF;
END;
$$ LANGUAGE plpgsql STABLE;
COMMENT ON FUNCTION fiscal.can_cancel_cfdi IS 'Verifica si un CFDI puede ser cancelado y si requiere aceptacion del receptor';
-- Funcion: Calcular saldo pendiente de una factura con pagos parciales
CREATE OR REPLACE FUNCTION fiscal.get_invoice_payment_balance(p_cfdi_uuid VARCHAR)
RETURNS TABLE (
total_amount DECIMAL,
total_paid DECIMAL,
remaining_balance DECIMAL,
installment_count INTEGER
) AS $$
BEGIN
RETURN QUERY
SELECT
ci.total,
COALESCE(SUM(pcd.amount_paid), 0) as total_paid,
ci.total - COALESCE(SUM(pcd.amount_paid), 0) as remaining_balance,
COUNT(pcd.id)::INTEGER as installment_count
FROM fiscal.cfdi_invoices ci
LEFT JOIN fiscal.cfdi_payment_complement_documents pcd
ON pcd.related_uuid = ci.uuid
LEFT JOIN fiscal.cfdi_payment_complements pc
ON pc.id = pcd.payment_complement_id
AND pc.status = 'stamped'
WHERE ci.uuid = p_cfdi_uuid
GROUP BY ci.id, ci.total;
END;
$$ LANGUAGE plpgsql STABLE;
COMMENT ON FUNCTION fiscal.get_invoice_payment_balance IS 'Obtiene el saldo pendiente de una factura considerando complementos de pago';
-- Funcion: Obtener resumen de CFDIs por periodo
CREATE OR REPLACE FUNCTION fiscal.get_cfdi_summary(
p_tenant_id UUID,
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
voucher_type fiscal.cfdi_voucher_type,
status fiscal.cfdi_status,
count BIGINT,
total_amount DECIMAL
) AS $$
BEGIN
RETURN QUERY
SELECT
ci.voucher_type,
ci.status,
COUNT(*) as count,
SUM(ci.total) as total_amount
FROM fiscal.cfdi_invoices ci
WHERE ci.tenant_id = p_tenant_id
AND ci.created_at::DATE BETWEEN p_start_date AND p_end_date
AND ci.deleted_at IS NULL
GROUP BY ci.voucher_type, ci.status
ORDER BY ci.voucher_type, ci.status;
END;
$$ LANGUAGE plpgsql STABLE;
COMMENT ON FUNCTION fiscal.get_cfdi_summary IS 'Obtiene resumen de CFDIs agrupados por tipo y estado en un periodo';
-- =====================
-- TRIGGERS
-- =====================
-- Trigger: Actualizar updated_at en cfdi_invoices
CREATE OR REPLACE FUNCTION fiscal.update_cfdi_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_cfdi_invoices_timestamp ON fiscal.cfdi_invoices;
CREATE TRIGGER trg_cfdi_invoices_timestamp
BEFORE UPDATE ON fiscal.cfdi_invoices
FOR EACH ROW EXECUTE FUNCTION fiscal.update_cfdi_timestamp();
DROP TRIGGER IF EXISTS trg_cfdi_certificates_timestamp ON fiscal.cfdi_certificates;
CREATE TRIGGER trg_cfdi_certificates_timestamp
BEFORE UPDATE ON fiscal.cfdi_certificates
FOR EACH ROW EXECUTE FUNCTION fiscal.update_cfdi_timestamp();
DROP TRIGGER IF EXISTS trg_cfdi_pac_timestamp ON fiscal.cfdi_pac_configurations;
CREATE TRIGGER trg_cfdi_pac_timestamp
BEFORE UPDATE ON fiscal.cfdi_pac_configurations
FOR EACH ROW EXECUTE FUNCTION fiscal.update_cfdi_timestamp();
DROP TRIGGER IF EXISTS trg_cfdi_pcomp_timestamp ON fiscal.cfdi_payment_complements;
CREATE TRIGGER trg_cfdi_pcomp_timestamp
BEFORE UPDATE ON fiscal.cfdi_payment_complements
FOR EACH ROW EXECUTE FUNCTION fiscal.update_cfdi_timestamp();
DROP TRIGGER IF EXISTS trg_cfdi_cancel_timestamp ON fiscal.cfdi_cancellation_requests;
CREATE TRIGGER trg_cfdi_cancel_timestamp
BEFORE UPDATE ON fiscal.cfdi_cancellation_requests
FOR EACH ROW EXECUTE FUNCTION fiscal.update_cfdi_timestamp();
-- Trigger: Log automatico de cambios de estado
CREATE OR REPLACE FUNCTION fiscal.log_cfdi_status_change()
RETURNS TRIGGER AS $$
BEGIN
-- Solo si cambio el status
IF OLD.status IS DISTINCT FROM NEW.status THEN
INSERT INTO fiscal.cfdi_operation_logs (
tenant_id,
cfdi_invoice_id,
cfdi_uuid,
operation_type,
status_before,
status_after,
success,
created_by
) VALUES (
NEW.tenant_id,
NEW.id,
NEW.uuid,
CASE
WHEN NEW.status = 'stamped' THEN 'stamp'::fiscal.cfdi_operation_type
WHEN NEW.status = 'cancelled' THEN 'cancel_complete'::fiscal.cfdi_operation_type
WHEN NEW.status = 'error' THEN 'error'::fiscal.cfdi_operation_type
ELSE 'create'::fiscal.cfdi_operation_type
END,
OLD.status::VARCHAR,
NEW.status::VARCHAR,
NEW.status NOT IN ('error'),
NEW.updated_by
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_cfdi_status_log ON fiscal.cfdi_invoices;
CREATE TRIGGER trg_cfdi_status_log
AFTER UPDATE ON fiscal.cfdi_invoices
FOR EACH ROW EXECUTE FUNCTION fiscal.log_cfdi_status_change();
-- Trigger: Validar RFC antes de insertar CFDI
CREATE OR REPLACE FUNCTION fiscal.validate_cfdi_rfc()
RETURNS TRIGGER AS $$
BEGIN
-- Validar RFC emisor
IF NOT fiscal.validate_rfc(NEW.issuer_rfc) THEN
RAISE EXCEPTION 'RFC del emisor invalido: %', NEW.issuer_rfc;
END IF;
-- Validar RFC receptor
IF NOT fiscal.validate_rfc(NEW.receiver_rfc) THEN
RAISE EXCEPTION 'RFC del receptor invalido: %', NEW.receiver_rfc;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_cfdi_validate_rfc ON fiscal.cfdi_invoices;
CREATE TRIGGER trg_cfdi_validate_rfc
BEFORE INSERT OR UPDATE ON fiscal.cfdi_invoices
FOR EACH ROW EXECUTE FUNCTION fiscal.validate_cfdi_rfc();
-- =====================
-- VISTAS
-- =====================
-- Vista: CFDIs pendientes de timbrar
CREATE OR REPLACE VIEW fiscal.v_cfdi_pending_stamp AS
SELECT
ci.id,
ci.tenant_id,
ci.invoice_id,
ci.serie,
ci.folio,
ci.voucher_type,
ci.status,
ci.issuer_rfc,
ci.receiver_rfc,
ci.receiver_name,
ci.total,
ci.created_at,
bi.invoice_number,
bi.invoice_date
FROM fiscal.cfdi_invoices ci
JOIN billing.invoices bi ON ci.invoice_id = bi.id
WHERE ci.status IN ('draft', 'pending')
AND ci.deleted_at IS NULL
ORDER BY ci.created_at;
COMMENT ON VIEW fiscal.v_cfdi_pending_stamp IS 'CFDIs pendientes de timbrar';
-- Vista: Cancelaciones pendientes de respuesta
CREATE OR REPLACE VIEW fiscal.v_cfdi_pending_cancellations AS
SELECT
cr.id,
cr.tenant_id,
cr.cfdi_uuid,
cr.cancellation_reason,
cr.status,
cr.requested_at,
cr.submitted_at,
cr.expires_at,
ci.serie,
ci.folio,
ci.receiver_rfc,
ci.receiver_name,
ci.total
FROM fiscal.cfdi_cancellation_requests cr
JOIN fiscal.cfdi_invoices ci ON cr.cfdi_invoice_id = ci.id
WHERE cr.status IN ('submitted', 'in_process')
ORDER BY cr.expires_at;
COMMENT ON VIEW fiscal.v_cfdi_pending_cancellations IS 'Solicitudes de cancelacion pendientes de respuesta';
-- Vista: Certificados por vencer
CREATE OR REPLACE VIEW fiscal.v_certificates_expiring AS
SELECT
cc.id,
cc.tenant_id,
cc.certificate_number,
cc.rfc,
cc.status,
cc.expires_at,
cc.is_default,
t.name as tenant_name,
(cc.expires_at - CURRENT_TIMESTAMP) as time_to_expire
FROM fiscal.cfdi_certificates cc
JOIN auth.tenants t ON cc.tenant_id = t.id
WHERE cc.deleted_at IS NULL
AND cc.status = 'active'
AND cc.expires_at < (CURRENT_TIMESTAMP + INTERVAL '30 days')
ORDER BY cc.expires_at;
COMMENT ON VIEW fiscal.v_certificates_expiring IS 'Certificados CSD que vencen en los proximos 30 dias';
-- Vista: Resumen de uso de timbres por PAC
CREATE OR REPLACE VIEW fiscal.v_pac_stamp_usage AS
SELECT
pc.tenant_id,
pc.pac_code,
pc.pac_name,
pc.environment,
pc.monthly_stamp_limit,
pc.stamps_used_this_month,
CASE
WHEN pc.monthly_stamp_limit IS NULL THEN NULL
ELSE (pc.stamps_used_this_month::DECIMAL / pc.monthly_stamp_limit * 100)
END as usage_percentage,
pc.contract_expires_at,
t.name as tenant_name
FROM fiscal.cfdi_pac_configurations pc
JOIN auth.tenants t ON pc.tenant_id = t.id
WHERE pc.is_active = TRUE
AND pc.deleted_at IS NULL
ORDER BY pc.tenant_id, pc.pac_code;
COMMENT ON VIEW fiscal.v_pac_stamp_usage IS 'Uso de timbres por PAC y tenant';
-- Vista: Facturas con saldo pendiente (PPD)
CREATE OR REPLACE VIEW fiscal.v_invoices_pending_payment AS
SELECT
ci.id,
ci.tenant_id,
ci.uuid,
ci.serie,
ci.folio,
ci.receiver_rfc,
ci.receiver_name,
ci.total,
ci.stamp_date,
COALESCE(SUM(pcd.amount_paid), 0) as total_paid,
ci.total - COALESCE(SUM(pcd.amount_paid), 0) as remaining_balance,
COUNT(pcd.id) as installment_count
FROM fiscal.cfdi_invoices ci
LEFT JOIN fiscal.cfdi_payment_complement_documents pcd
ON pcd.related_uuid = ci.uuid
LEFT JOIN fiscal.cfdi_payment_complements pc
ON pc.id = pcd.payment_complement_id
AND pc.status = 'stamped'
WHERE ci.payment_method = 'PPD'
AND ci.status = 'stamped'
AND ci.deleted_at IS NULL
GROUP BY ci.id
HAVING ci.total - COALESCE(SUM(pcd.amount_paid), 0) > 0
ORDER BY ci.stamp_date;
COMMENT ON VIEW fiscal.v_invoices_pending_payment IS 'Facturas PPD con saldo pendiente de complemento de pago';
-- =====================
-- FIN DEL ARCHIVO
-- =====================