[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)