[CONSEJO — Doc 08] Contrarian: El modelo de datos de PayMind tiene una tensión fundamental: los datos de empleados (salarios, deducciones, situación laboral) son PII de altísima sensibilidad, pero el motor de planilla necesita acceder a ellos con alta frecuencia. La solución no es sacrificar la seguridad por la performance — es column-level encryption con un key separado para datos laborales, y un índice de búsqueda sobre datos no sensibles. First Principles: Las tablas regulatorias (tasas CCSS, tramos ISR, tasas IMSS) son el corazón del motor de planilla. Si una tabla tiene un error, TODAS las planillas calculadas con esa tabla están mal. Las tablas deben ser versionadas, inmutables una vez publicadas, y tener un proceso de validación obligatorio antes de ser marcadas como vigentes. Auditor Seg.: El campo
salario_mensualenpm_empleadoses el dato más sensible del sistema. Solo debe ser visible para Owner y RRHH Manager, no para Viewer ni para el empleado mismo (en algunos países el salario entre colegas es información protegida). El RLS debe incluir filtros por rol además del filtro por tenant.
1. Tablas principales
1.1 pm_empleados — expediente digital del empleado
CREATE TABLE pm_empleados (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES sm_tenants(id) ON DELETE CASCADE,
empresa_id UUID NOT NULL,
-- Datos de identificación
nombre_completo VARCHAR(255) NOT NULL,
cedula_enc BYTEA NOT NULL, -- pgcrypto: cédula/RFC/NIT encriptado
email VARCHAR(255),
telefono VARCHAR(30),
fecha_nacimiento DATE,
-- Datos laborales
cargo VARCHAR(100),
departamento VARCHAR(100),
tipo_contrato VARCHAR(20) NOT NULL, -- 'indefinido', 'temporal', 'obra', 'servicios'
fecha_ingreso DATE NOT NULL,
fecha_fin_contrato DATE, -- NULL si indefinido
pais CHAR(2) NOT NULL, -- 'CR', 'MX', 'CO'
-- Salario — campo más sensible
salario_mensual_enc BYTEA NOT NULL, -- pgcrypto column-level encryption
moneda CHAR(3) NOT NULL,
frecuencia_pago VARCHAR(10) NOT NULL, -- 'mensual', 'quincenal', 'semanal'
-- Datos bancarios (para archivo banco)
iban_enc BYTEA, -- pgcrypto — IBAN/CLABE/cuenta
banco_nombre VARCHAR(100),
-- Estado
estado VARCHAR(20) NOT NULL DEFAULT 'activo',
-- estados: activo, vacaciones, incapacidad, suspendido, liquidado
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_pm_empleados_tenant ON pm_empleados(tenant_id, estado);
ALTER TABLE pm_empleados ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON pm_empleados
USING (tenant_id = current_setting('app.current_tenant')::UUID);
1.2 pm_planillas — cabecera de planilla
CREATE TABLE pm_planillas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
empresa_id UUID NOT NULL,
periodo_inicio DATE NOT NULL,
periodo_fin DATE NOT NULL,
pais CHAR(2) NOT NULL,
estado VARCHAR(20) NOT NULL DEFAULT 'borrador',
-- estados: borrador, confirmada, enviada_banco, contabilizada
tabla_regulatoria_id UUID NOT NULL REFERENCES pm_tablas_regulatorias(id),
total_bruto NUMERIC(15,2) NOT NULL DEFAULT 0,
total_deducciones_emp NUMERIC(15,2) NOT NULL DEFAULT 0,
total_cargas_patronales NUMERIC(15,2) NOT NULL DEFAULT 0,
total_neto NUMERIC(15,2) NOT NULL DEFAULT 0,
confirmada_por UUID, -- user_id que confirmó
confirmada_en TIMESTAMPTZ,
asiento_bk_id UUID, -- asiento en BookMind (post-contabilización)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- Una vez confirmada, es INMUTABLE (igual que bk_asientos)
);
CREATE INDEX idx_pm_planillas_tenant_periodo ON pm_planillas(tenant_id, periodo_fin DESC);
ALTER TABLE pm_planillas ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON pm_planillas
USING (tenant_id = current_setting('app.current_tenant')::UUID);
REVOKE UPDATE ON pm_planillas FROM app_role; -- inmutable post-confirmación
-- Se permite UPDATE solo en columnas de estado antes de confirmar (via stored procedure)
1.3 pm_planilla_detalle — cálculo por empleado
CREATE TABLE pm_planilla_detalle (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
planilla_id UUID NOT NULL REFERENCES pm_planillas(id),
tenant_id UUID NOT NULL,
empleado_id UUID NOT NULL REFERENCES pm_empleados(id),
-- Resultados (encriptados para datos de salario individual)
salario_bruto NUMERIC(15,2) NOT NULL,
-- Deducciones empleado
ccss_empleado NUMERIC(15,2) NOT NULL DEFAULT 0, -- CR: SEM+INV
imss_empleado NUMERIC(15,2) NOT NULL DEFAULT 0, -- MX
salud_empleado NUMERIC(15,2) NOT NULL DEFAULT 0, -- CO
pension_empleado NUMERIC(15,2) NOT NULL DEFAULT 0, -- CO
isr NUMERIC(15,2) NOT NULL DEFAULT 0,
otras_deducciones NUMERIC(15,2) NOT NULL DEFAULT 0,
total_deducciones NUMERIC(15,2) NOT NULL,
salario_neto NUMERIC(15,2) NOT NULL,
-- Cargas patronales
ccss_patronal NUMERIC(15,2) NOT NULL DEFAULT 0,
imss_patronal NUMERIC(15,2) NOT NULL DEFAULT 0,
parafiscales_co NUMERIC(15,2) NOT NULL DEFAULT 0,
total_cargas NUMERIC(15,2) NOT NULL,
-- Acumulados
aguinaldo_acumulado NUMERIC(15,2) NOT NULL DEFAULT 0,
cesantia_acumulada NUMERIC(15,2) NOT NULL DEFAULT 0,
-- Auditoría
tabla_version VARCHAR(20) NOT NULL, -- Versión de tabla regulatoria usada
calculo_json JSONB NOT NULL -- Desglose completo del cálculo
-- {formula_isr: "...", tramo: "...", ...} — para audit trail
);
CREATE INDEX idx_pm_detalle_planilla ON pm_planilla_detalle(planilla_id);
CREATE INDEX idx_pm_detalle_empleado ON pm_planilla_detalle(tenant_id, empleado_id);
ALTER TABLE pm_planilla_detalle ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON pm_planilla_detalle
USING (tenant_id = current_setting('app.current_tenant')::UUID);
REVOKE UPDATE ON pm_planilla_detalle FROM app_role;
REVOKE DELETE ON pm_planilla_detalle FROM app_role;
1.4 pm_tablas_regulatorias — tasas por país y vigencia (inmutables)
CREATE TABLE pm_tablas_regulatorias (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pais CHAR(2) NOT NULL,
version VARCHAR(20) NOT NULL, -- ej: 'CR-2032-v1', 'MX-2032-v2'
vigente_desde DATE NOT NULL,
vigente_hasta DATE, -- NULL = vigente indefinidamente
parametros JSONB NOT NULL,
-- {
-- "tasa_ccss_patronal": 0.2667,
-- "tasa_ccss_empleado": 0.1067,
-- "tramos_isr": [{"desde": 0, "hasta": 941000, "tasa": 0, "base": 0}, ...],
-- "factor_hora_extra_ordinaria": 1.5,
-- "factor_hora_extra_nocturna": 2.0,
-- "dias_aguinaldo_mes": 0.0833,
-- ...
-- }
estado VARCHAR(20) NOT NULL DEFAULT 'vigente',
-- estados: draft, validada, vigente, deprecada
publicada_por VARCHAR(100), -- "Decreto 45333-H" o referencia oficial
fuente_url TEXT, -- URL de la publicación oficial
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- INMUTABLE: las tablas no se modifican, solo se crean nuevas versiones
);
CREATE UNIQUE INDEX idx_pm_tablas_version ON pm_tablas_regulatorias(pais, version);
REVOKE UPDATE ON pm_tablas_regulatorias FROM app_role;
REVOKE DELETE ON pm_tablas_regulatorias FROM app_role;
-- Solo un rol especial puede insertar tablas nuevas (proceso de validación)
1.5 pm_vacaciones — saldos y solicitudes de vacaciones
CREATE TABLE pm_vacaciones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
empleado_id UUID NOT NULL REFERENCES pm_empleados(id),
tipo VARCHAR(20) NOT NULL,
-- tipos: 'acumulacion' (crédito) | 'solicitud' (débito) | 'ajuste'
dias NUMERIC(5,2) NOT NULL, -- positivo = crédito, negativo = débito
fecha_inicio DATE,
fecha_fin DATE,
estado VARCHAR(20) NOT NULL DEFAULT 'pendiente',
-- estados: pendiente, aprobada, rechazada, completada
aprobada_por UUID,
motivo_rechazo TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_pm_vacaciones_empleado ON pm_vacaciones(empleado_id, created_at DESC);
ALTER TABLE pm_vacaciones ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON pm_vacaciones
USING (tenant_id = current_setting('app.current_tenant')::UUID);
1.6 pm_ausencias — ausencias e incapacidades
CREATE TABLE pm_ausencias (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
empleado_id UUID NOT NULL REFERENCES pm_empleados(id),
tipo VARCHAR(30) NOT NULL,
-- tipos: 'incapacidad_ccss' | 'incapacidad_laboral' | 'permiso_con_goce'
-- 'permiso_sin_goce' | 'maternidad' | 'paternidad' | 'duelo'
fecha_inicio DATE NOT NULL,
fecha_fin DATE,
dias_totales NUMERIC(5,2),
numero_incapacidad VARCHAR(30), -- Número de boleta CCSS/IMSS
porcentaje_subsidio NUMERIC(5,2), -- % que paga la seguridad social (CR: 60%)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE pm_ausencias ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON pm_ausencias
USING (tenant_id = current_setting('app.current_tenant')::UUID);
1.7 pm_workforce_signals — señales cross-módulo para workforce planning
CREATE TABLE pm_workforce_signals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
modulo_origen VARCHAR(20) NOT NULL, -- 'stockmind', 'salesmind', 'bookmind'
tipo_senal VARCHAR(30) NOT NULL, -- 'bodega', 'ventas', 'presupuesto'
delta_carga NUMERIC(10,2), -- cambio en carga de trabajo (unidad según tipo)
fecha_estimada DATE, -- cuándo se materializa la carga
procesada BOOLEAN NOT NULL DEFAULT false,
evento_id VARCHAR(100), -- ID del evento Redis original (idempotencia)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE pm_workforce_signals ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON pm_workforce_signals
USING (tenant_id = current_setting('app.current_tenant')::UUID);
2. Queries críticas
2.1 Calcular días de vacaciones disponibles
SELECT
e.id AS empleado_id,
e.nombre_completo,
COALESCE(SUM(CASE WHEN v.tipo = 'acumulacion' THEN v.dias
WHEN v.tipo IN ('solicitud', 'ajuste') THEN v.dias
ELSE 0 END), 0) AS dias_disponibles
FROM pm_empleados e
LEFT JOIN pm_vacaciones v ON v.empleado_id = e.id
AND (v.estado = 'aprobada' OR v.tipo = 'acumulacion')
WHERE e.tenant_id = :tenant_id AND e.estado = 'activo'
GROUP BY e.id, e.nombre_completo
HAVING COALESCE(SUM(v.dias), 0) > 0
ORDER BY dias_disponibles DESC;
2.2 Historial de planillas de un empleado
SELECT
pl.periodo_inicio, pl.periodo_fin,
pd.salario_bruto, pd.salario_neto, pd.isr, pd.ccss_empleado
FROM pm_planilla_detalle pd
JOIN pm_planillas pl ON pl.id = pd.planilla_id
WHERE pd.empleado_id = :empleado_id AND pd.tenant_id = :tenant_id
ORDER BY pl.periodo_fin DESC
LIMIT 24; -- últimas 24 quincenas = 1 año
2.3 Tabla regulatoria vigente para una fecha
SELECT *
FROM pm_tablas_regulatorias
WHERE pais = :pais
AND vigente_desde <= :fecha
AND (vigente_hasta IS NULL OR vigente_hasta >= :fecha)
AND estado = 'vigente'
ORDER BY vigente_desde DESC
LIMIT 1;
3. Cifrado column-level
Los campos más sensibles usan pgcrypto.pgp_sym_encrypt:
# Al guardar un empleado
empleado.cedula_enc = pgp_sym_encrypt(cedula, APP_ENCRYPTION_KEY)
empleado.salario_mensual_enc = pgp_sym_encrypt(str(salario), APP_ENCRYPTION_KEY)
empleado.iban_enc = pgp_sym_encrypt(iban, APP_ENCRYPTION_KEY)
# Al leer (solo desde PayrollService y HRService, nunca desde API pública)
cedula = pgp_sym_decrypt(empleado.cedula_enc, APP_ENCRYPTION_KEY)
salario = Decimal(pgp_sym_decrypt(empleado.salario_mensual_enc, APP_ENCRYPTION_KEY))
4. Convenciones del esquema
| Convención | Regla |
|---|---|
| Prefijo | pm_ en todas las tablas |
| Inmutabilidad | pm_planillas (confirmadas), pm_planilla_detalle, pm_tablas_regulatorias: REVOKE UPDATE/DELETE |
| Cifrado | Campos sensibles (cedula, salario_mensual, iban) en BYTEA con pgcrypto |
| RLS | Habilitado en todas las tablas pm_* |
| Montos | NUMERIC(15,2) — nunca FLOAT |
| Versiones regulatorias | INMUTABLES — nunca overwrite, siempre versión nueva |
| Auditoría | calculo_json en pm_planilla_detalle contiene el desglose completo para auditoría |
Ver también: Doc 07 (SRD Backend — servicios que usan estas tablas) · Doc 09 (Motor Core — algoritmo de cálculo que lee pm_tablas_regulatorias) · Doc 10 (Seguridad — cifrado column-level y RLS de salarios)