Docs PayMind
08
Modelo de Datos — PayMind
Esquema PostgreSQL pm_*, empleados, planilla, vacaciones y tablas regulatorias
Versión
v1.0
Fecha
Mayo 2026
Audiencia
Founders · Dev
Estado
DRAFT
PAYMIND
Inteligencia que cuida a tu equipo. Sin errores, sin sustos, sin sorpresas.
Módulo #4 — Cheryx Suite

[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_mensual en pm_empleados es 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)