Docs PayMind
09
Motor Core — PayMind
Motor determinístico de planilla multi-país y motor predictivo de workforce planning
Versión
v1.0
Fecha
Mayo 2026
Audiencia
Founders · Dev · Asesores
Estado
DRAFT
PAYMIND
Inteligencia que cuida a tu equipo. Sin errores, sin sustos, sin sorpresas.
Módulo #4 — Cheryx Suite

[CONSEJO — Doc 09] Contrarian: El motor de planilla de PayMind NO puede ser un motor "inteligente" o "adaptativo". Tiene que ser estúpidamente determinístico: dado el mismo input, produce el mismo output, siempre. La inteligencia va en el motor de workforce planning. En la planilla, la correctitud determinística es el único requisito. First Principles: El cálculo de ISR laboral no es una "estimación" — es una obligación legal con una fórmula oficial. Cualquier resultado distinto al que define la ley es un error, no una aproximación aceptable. El motor de planilla debe implementar la fórmula oficial exactamente como está publicada, con los redondeos oficiales (centavos en CR, pesos exactos en MX, COP exactos en CO). Executor: El workforce planning predictivo es donde está la diferenciación de PayMind, pero su implementación en V1.0 debe ser conservadora. Un false positive ("contrata 3 personas más") que el cliente sigue y resulta en sobrecosto puede destruir la confianza en el producto. Lanzar el workforce planning con bandas amplias de incertidumbre y posicionarlo como "señal de alerta anticipada", no como "instrucción de negocio".


1. Dos motores distintos — separación estricta

Dimensión Motor A: Planilla Motor B: Workforce
Tipo Determinístico Probabilístico
Input Datos del empleado + tabla regulatoria Señales de 4 módulos + historial headcount
Output Número exacto (salario neto, deducciones) Rango probabilístico (P10/P50/P90)
Correctitud Idéntico a la ley — 0 margen de error Estimación con incertidumbre explícita
Reproducibilidad Perfecta (mismo input = mismo output siempre) Estocástico (Monte Carlo)
Audit trail Fórmula completa en calculo_json Confianza + señales usadas

2. Motor A — Planilla determinística multi-país

2.1 Costa Rica

Implementación de la fórmula oficial (basada en Código de Trabajo CR y Decreto 45333-H vigente):

@dataclass
class CalculoISR_CR:
    """Cálculo de ISR laboral CR según tabla progresiva."""

    def calcular(self, salario_mensual: Decimal, tabla: TablaRegulatoriaVigente) -> Decimal:
        tramos = tabla.parametros['tramos_isr_cr']
        # Los tramos están ordenados de menor a mayor
        # Cada tramo: {"desde": X, "hasta": Y, "tasa": T, "base": B}
        for tramo in tramos:
            if salario_mensual <= tramo['hasta']:
                if tramo['tasa'] == 0:
                    return Decimal('0.00')
                exceso = salario_mensual - Decimal(str(tramo['desde']))
                isr = Decimal(str(tramo['base'])) + (exceso * Decimal(str(tramo['tasa'])))
                return isr.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
        # Tramo máximo (sin límite superior)
        ultimo = tramos[-1]
        exceso = salario_mensual - Decimal(str(ultimo['desde']))
        isr = Decimal(str(ultimo['base'])) + (exceso * Decimal(str(ultimo['tasa'])))
        return isr.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)

class MotorPlanillaCR:
    def calcular(self, empleado: Empleado, tabla: TablaRegulatoriaVigente,
                 novedades: list[Novedad], periodo: Periodo) -> CalculoEmpleado:
        dias = self._dias_efectivos(periodo, novedades)
        salario_periodo = (empleado.salario_mensual * dias / 30).quantize(Decimal('0.01'))

        # Horas extra
        horas_extra_ord = sum(n.horas for n in novedades if n.tipo == 'hora_extra_ordinaria')
        valor_he_ord = (empleado.salario_mensual / 30 / 8 *
                        Decimal(str(tabla.parametros['factor_hora_extra_ordinaria'])))
        total_he = (horas_extra_ord * valor_he_ord).quantize(Decimal('0.01'))

        salario_bruto = salario_periodo + total_he

        # CCSS empleado: SEM (5.50%) + IV (1.00%) + CP (1.00%) + otros
        ccss_empleado = (salario_bruto *
                         Decimal(str(tabla.parametros['tasa_ccss_empleado']))).quantize(Decimal('0.01'))
        isr = CalculoISR_CR().calcular(salario_bruto, tabla)

        # Aguinaldo acumulado este período
        aguinaldo_acumulado = (salario_bruto *
                               Decimal(str(tabla.parametros['dias_aguinaldo_mes']))).quantize(Decimal('0.01'))

        # Cargas patronales
        ccss_patronal = (salario_bruto *
                         Decimal(str(tabla.parametros['tasa_ccss_patronal']))).quantize(Decimal('0.01'))

        return CalculoEmpleado(
            salario_bruto=salario_bruto,
            ccss_empleado=ccss_empleado,
            isr=isr,
            salario_neto=salario_bruto - ccss_empleado - isr,
            ccss_patronal=ccss_patronal,
            aguinaldo_acumulado_periodo=aguinaldo_acumulado,
            calculo_json={
                'dias_efectivos': float(dias),
                'salario_periodo': float(salario_periodo),
                'horas_extra_ordinarias': float(horas_extra_ord),
                'valor_hora_extra': float(valor_he_ord),
                'formula_isr': f"tramo_{self._identificar_tramo(salario_bruto, tabla)}",
                'tasa_ccss_empleado': tabla.parametros['tasa_ccss_empleado'],
                'tasa_ccss_patronal': tabla.parametros['tasa_ccss_patronal'],
                'tabla_version': tabla.version
            }
        )

2.2 México

class MotorPlanillaMX:
    def calcular(self, empleado: Empleado, tabla: TablaRegulatoriaVigente,
                 novedades: list[Novedad], periodo: Periodo) -> CalculoEmpleado:
        # Salario Diario Integrado (SDI): incluye partes proporcionales
        sdi = self._calcular_sdi(empleado, tabla)

        # IMSS empleado (cuotas según SDI)
        imss_empleado = self._calcular_imss_empleado(sdi, periodo.dias, tabla)
        # INFONAVIT: 5% del salario (solo patronal)
        infonavit_patronal = (empleado.salario_mensual * Decimal('0.05')).quantize(Decimal('0.01'))

        # ISR laboral MX (artículo 96 LISR, tabla mensual)
        isr_mx = self._calcular_isr_mx(empleado.salario_mensual, tabla)
        # Subsidio al empleo (tabla Art. 97-A LISR)
        subsidio = self._calcular_subsidio_empleo(empleado.salario_mensual, tabla)
        isr_neto = max(Decimal('0'), isr_mx - subsidio)

        return CalculoEmpleado(
            salario_bruto=empleado.salario_mensual,
            imss_empleado=imss_empleado,
            isr=isr_neto,
            salario_neto=empleado.salario_mensual - imss_empleado - isr_neto,
            imss_patronal=self._calcular_imss_patronal(sdi, tabla),
            infonavit_patronal=infonavit_patronal,
            calculo_json={
                'sdi': float(sdi),
                'subsidio_empleo': float(subsidio),
                'tabla_version': tabla.version
            }
        )

2.3 Colombia

class MotorPlanillaCO:
    def calcular(self, empleado: Empleado, tabla: TablaRegulatoriaVigente,
                 novedades: list[Novedad], periodo: Periodo) -> CalculoEmpleado:
        salario = empleado.salario_mensual

        # Aportes empleado
        salud_empleado = (salario * Decimal('0.04')).quantize(Decimal('0.01'))
        pension_empleado = (salario * Decimal('0.04')).quantize(Decimal('0.01'))

        # ISR Colombia (retención en la fuente — tabla mensualizada)
        retencion = self._calcular_retencion_fuente_co(salario, tabla)

        # Parafiscales patronales
        sena = (salario * Decimal('0.02')).quantize(Decimal('0.01'))
        icbf = (salario * Decimal('0.03')).quantize(Decimal('0.01'))
        caja = (salario * Decimal('0.04')).quantize(Decimal('0.01'))
        salud_patronal = (salario * Decimal('0.0850')).quantize(Decimal('0.01'))
        pension_patronal = (salario * Decimal('0.12')).quantize(Decimal('0.01'))
        arl = (salario * Decimal(str(tabla.parametros['tasa_arl_co']))).quantize(Decimal('0.01'))

        # Prima de servicios (acumulado)
        prima_semestral = (salario / 2).quantize(Decimal('0.01'))  # 15 días por semestre

        return CalculoEmpleado(
            salario_bruto=salario,
            salud_empleado=salud_empleado,
            pension_empleado=pension_empleado,
            isr=retencion,
            salario_neto=salario - salud_empleado - pension_empleado - retencion,
            parafiscales_patronales=sena + icbf + caja + salud_patronal + pension_patronal + arl,
            prima_acumulada_periodo=(prima_semestral / 6).quantize(Decimal('0.01')),
            calculo_json={'tabla_version': tabla.version}
        )

3. Guardrails del motor de planilla

# Guardrail Descripción
G1 Tabla no vigente Si no existe tabla regulatoria vigente para la fecha del período → ERROR bloqueante, no procesar
G2 Salario cero Si salario_mensual es 0 o negativo → ERROR bloqueante
G3 Período futuro Si el período es futuro (fecha_fin > hoy + 3 días) → advertencia
G4 Horas extra > 48h Si horas extra semanales > 48h/semana → advertencia de posible error
G5 ISR negativo Si ISR calculado < 0 → forzar a 0 (subsidiado)
G6 Salario < salario mínimo Si salario < SMMLV/SMG/SMG del país → advertencia
G7 Período duplicado Si ya existe planilla confirmada para el mismo período + empresa → ERROR bloqueante

4. Motor B — Workforce Planning Predictivo

4.1 Arquitectura del motor

ENTRADAS:
├── pm_empleados (headcount actual y capacidad)
├── pm_planillas (costo histórico de planilla)
├── sm: OC aprobadas últimos 90 días (señal de demanda de bodega)
├── sl: Pipeline ganado / forecast CRM (señal de demanda de ventas)
├── bk: Forecast tesorería P10/P50/P90 (restricción de presupuesto)
└── Parámetros: capacidad por empleado por rol, tiempo de ramp-up

PROCESAMIENTO:
├── Paso 1: Calcular carga de trabajo actual por departamento
├── Paso 2: Proyectar carga futura basada en señales cross-módulo
├── Paso 3: Calcular gap headcount (carga proyectada / capacidad por empleado)
├── Paso 4: Calcular costo de resolución (new hire vs. horas extra)
├── Paso 5: Validar contra presupuesto disponible (BookMind P50)
└── Paso 6: Generar recomendación con bandas de confianza

SALIDAS:
├── Recomendaciones de contratación con tiempo estimado (±15 días)
├── Proyección de costo de planilla a 90 días (P10/P50/P90)
└── Alertas si presupuesto BookMind no puede absorber el headcount proyectado

4.2 Modelo simplificado (V1.0)

def calcular_necesidades_headcount(
    tenant_id: UUID,
    departamento: str,
    horizonte_dias: int = 90
) -> WorkforceForecast:
    # Capacidad actual
    headcount_actual = count_empleados_activos(tenant_id, departamento)
    capacidad_actual = headcount_actual * CAPACIDAD_POR_EMPLEADO[departamento]

    # Proyección de carga basada en señales
    senales = get_senales_activas(tenant_id, departamento)
    carga_proyectada_p50 = capacidad_actual * (1 + sum(s.delta_carga for s in senales))
    carga_proyectada_p90 = carga_proyectada_p50 * 1.3  # +30% escenario optimista

    # Gap de headcount
    nuevos_necesarios_p50 = max(0, ceil(carga_proyectada_p50 / CAPACIDAD_POR_EMPLEADO[departamento]) - headcount_actual)
    nuevos_necesarios_p90 = max(0, ceil(carga_proyectada_p90 / CAPACIDAD_POR_EMPLEADO[departamento]) - headcount_actual)

    # Costo proyectado
    costo_nuevo_empleado = SALARIO_PROMEDIO[departamento] * (1 + TASA_CARGAS[tenant_pais])
    costo_total_p50 = costo_nuevo_empleado * nuevos_necesarios_p50

    # Validación contra presupuesto BookMind
    presupuesto_p50 = get_bk_presupuesto_disponible(tenant_id)  # de BookMind forecast
    puede_absorber = presupuesto_p50 > costo_total_p50

    return WorkforceForecast(
        departamento=departamento,
        headcount_actual=headcount_actual,
        nuevos_recomendados=nuevos_necesarios_p50,
        nuevos_escenario_alto=nuevos_necesarios_p90,
        costo_mensual_adicional=costo_total_p50,
        puede_absorber_presupuesto=puede_absorber,
        confianza='media',
        senales_usadas=[s.tipo_senal for s in senales],
        fecha_recomendada_inicio_contratacion=date.today() + timedelta(days=45)
    )

5. Tests obligatorios

# Fixtures oficiales CR — casos de referencia de la CCSS
@pytest.mark.parametrize("caso", [
    {"salario": 850000, "esperado_ccss_emp": 90695, "esperado_isr": 32000, "tabla": "CR-2032-v1"},
    {"salario": 350000, "esperado_ccss_emp": 37345, "esperado_isr": 0, "tabla": "CR-2032-v1"},
    {"salario": 3000000, "esperado_ccss_emp": 320100, "esperado_isr": 258500, "tabla": "CR-2032-v1"},
])
def test_planilla_cr_fixture(caso):
    motor = MotorPlanillaCR()
    tabla = get_tabla('CR', '2032-01-01')
    resultado = motor.calcular(
        empleado=mock_empleado(salario=caso['salario']),
        tabla=tabla, novedades=[], periodo=mock_periodo()
    )
    assert resultado.ccss_empleado == pytest.approx(caso['esperado_ccss_emp'], abs=1)
    assert resultado.isr == pytest.approx(caso['esperado_isr'], abs=1)

# Invariante: salario_bruto = salario_neto + todas las deducciones
def test_planilla_invariante_suma():
    resultado = MotorPlanillaCR().calcular(...)
    assert resultado.salario_bruto == pytest.approx(
        resultado.salario_neto + resultado.ccss_empleado + resultado.isr, abs=0.02
    )

# Reproducibilidad: mismo input → mismo output
def test_planilla_reproducible():
    r1 = MotorPlanillaCR().calcular(...)
    r2 = MotorPlanillaCR().calcular(...)  # mismo input
    assert r1.salario_neto == r2.salario_neto
    assert r1.isr == r2.isr

Ver también: Doc 07 (SRD Backend — servicios que invocan estos motores) · Doc 08 (Modelo de Datos — pm_tablas_regulatorias) · Doc 03 (Riesgos — R2 cambio de tasas) · Doc 13 (KPIs — métricas de correctitud del motor)