353 lines
17 KiB
Python
353 lines
17 KiB
Python
"""
|
|
Payroll Engine and Legal Exporters for Chilean Labor Regulation (Ley 21.561 40h, Previred, DT LRE, Banking).
|
|
"""
|
|
|
|
from decimal import Decimal, ROUND_HALF_UP
|
|
import hashlib
|
|
from datetime import datetime, date
|
|
from typing import Dict, List, Any
|
|
import io
|
|
import csv
|
|
|
|
class PayrollEngine:
|
|
# 2026 Benchmark Economic Indicators (Chile)
|
|
IMM_2026 = Decimal('500000') # Ingreso Mínimo Mensual
|
|
UTM_2026 = Decimal('67000') # Unidad Tributaria Mensual
|
|
UF_2026 = Decimal('38500') # Unidad de Fomento
|
|
|
|
# Previsional Caps in UF
|
|
AFP_HEALTH_CAP_UF = Decimal('84.3') # Tope Imponible AFP y Salud (84.3 UF)
|
|
AFC_CAP_UF = Decimal('126.6') # Tope Imponible Seguro de Cesantía (126.6 UF)
|
|
|
|
# Employer Rates
|
|
SIS_RATE = Decimal('0.0149') # Seguro de Invalidez y Sobrevivencia (1.49%)
|
|
MUTUAL_RATE = Decimal('0.0093') # Mutual de Seguridad / ACHS (0.93% base)
|
|
AFC_EMPLOYER_INDEF = Decimal('0.024') # AFC Empleador Contrato Indefinido (2.4%)
|
|
AFC_EMPLOYER_FIJO = Decimal('0.030') # AFC Empleador Contrato Plazo Fijo (3.0%)
|
|
AFC_WORKER_RATE = Decimal('0.006') # AFC Trabajador Contrato Indefinido (0.6%)
|
|
|
|
@classmethod
|
|
def calculate_overtime(cls, base_salary: Decimal, weekly_hours: int, overtime_hours: Decimal) -> Decimal:
|
|
"""
|
|
Calculates 50% overtime pay based on weekly hours (Ley 21.561 40h vs 44h vs 45h).
|
|
Formula: (BaseSalary / WeeklyHours) * (7 / 30) * 1.5 * OvertimeHours
|
|
For 40 hours: Factor = (1/40) * (7/30) * 1.5 = 0.00875
|
|
"""
|
|
if overtime_hours <= 0 or base_salary <= 0:
|
|
return Decimal('0')
|
|
|
|
hours = Decimal(str(weekly_hours if weekly_hours > 0 else 40))
|
|
factor = (Decimal('1') / hours) * (Decimal('7') / Decimal('30')) * Decimal('1.5')
|
|
amount = base_salary * factor * overtime_hours
|
|
return amount.quantize(Decimal('1'), rounding=ROUND_HALF_UP)
|
|
|
|
@classmethod
|
|
def calculate_legal_gratification(cls, base_salary: Decimal, overtime_amount: Decimal = Decimal('0'), rd_bonus: Decimal = Decimal('0')) -> Decimal:
|
|
"""
|
|
Calculates Legal Gratification under Art. 50 Código del Trabajo:
|
|
25% of taxable earnings capped at (4.75 * IMM) / 12 per month.
|
|
"""
|
|
monthly_cap = ((Decimal('4.75') * cls.IMM_2026) / Decimal('12')).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
|
|
earnings = (base_salary + overtime_amount + rd_bonus) * Decimal('0.25')
|
|
return min(earnings.quantize(Decimal('1'), rounding=ROUND_HALF_UP), monthly_cap)
|
|
|
|
@classmethod
|
|
def calculate_second_category_tax(cls, taxable_tax_base: Decimal) -> Decimal:
|
|
"""
|
|
Calculates Chilean Second Category Income Tax (Impuesto Único de Segunda Categoría SII).
|
|
Taxable Tax Base = Total Taxable Income - Previsional Deductions (AFP + 7% Health + AFC).
|
|
"""
|
|
if taxable_tax_base <= Decimal('0'):
|
|
return Decimal('0')
|
|
|
|
utm = cls.UTM_2026
|
|
base_in_utm = taxable_tax_base / utm
|
|
|
|
# Official SII Brackets
|
|
# Tramo 1: 0 - 13.5 UTM -> Exento
|
|
if base_in_utm <= Decimal('13.5'):
|
|
return Decimal('0')
|
|
# Tramo 2: 13.5 - 30 UTM -> 4% (rebaja 0.54 UTM)
|
|
elif base_in_utm <= Decimal('30'):
|
|
tax = (taxable_tax_base * Decimal('0.04')) - (Decimal('0.54') * utm)
|
|
# Tramo 3: 30 - 50 UTM -> 8% (rebaja 1.74 UTM)
|
|
elif base_in_utm <= Decimal('50'):
|
|
tax = (taxable_tax_base * Decimal('0.08')) - (Decimal('1.74') * utm)
|
|
# Tramo 4: 50 - 70 UTM -> 13.5% (rebaja 4.49 UTM)
|
|
elif base_in_utm <= Decimal('70'):
|
|
tax = (taxable_tax_base * Decimal('0.135')) - (Decimal('4.49') * utm)
|
|
# Tramo 5: 70 - 90 UTM -> 23% (rebaja 11.14 UTM)
|
|
elif base_in_utm <= Decimal('90'):
|
|
tax = (taxable_tax_base * Decimal('0.23')) - (Decimal('11.14') * utm)
|
|
# Tramo 6: 90 - 120 UTM -> 30.4% (rebaja 17.80 UTM)
|
|
elif base_in_utm <= Decimal('120'):
|
|
tax = (taxable_tax_base * Decimal('0.304')) - (Decimal('17.80') * utm)
|
|
# Tramo 7: 120 - 310 UTM -> 35% (rebaja 23.32 UTM)
|
|
elif base_in_utm <= Decimal('310'):
|
|
tax = (taxable_tax_base * Decimal('0.35')) - (Decimal('23.32') * utm)
|
|
# Tramo 8: > 310 UTM -> 40% (rebaja 38.82 UTM)
|
|
else:
|
|
tax = (taxable_tax_base * Decimal('0.40')) - (Decimal('38.82') * utm)
|
|
|
|
return max(Decimal('0'), tax.quantize(Decimal('1'), rounding=ROUND_HALF_UP))
|
|
|
|
@classmethod
|
|
def compute_salary_slip(cls, employee, month: int, year: int, days_worked: int = 30,
|
|
overtime_hours: Decimal = Decimal('0'), rd_bonus: Decimal = Decimal('0'),
|
|
other_taxable: Decimal = Decimal('0'), other_deductions: Decimal = Decimal('0')) -> Dict[str, Any]:
|
|
"""
|
|
Executes complete salary slip calculation according to Chilean Labor and Tax code.
|
|
"""
|
|
base_salary_pactado = Decimal(str(employee.base_salary_clp))
|
|
days = Decimal(str(days_worked))
|
|
# Base salary proportional to days worked (standard 30 days)
|
|
base_salary_period = (base_salary_pactado * (days / Decimal('30'))).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
|
|
|
|
# Weekly hours determination
|
|
schedule_hours = 40
|
|
if employee.work_schedule == '44H':
|
|
schedule_hours = 44
|
|
elif employee.work_schedule == '45H':
|
|
schedule_hours = 45
|
|
elif employee.work_schedule == '30H_PART_TIME':
|
|
schedule_hours = 30
|
|
elif employee.work_schedule == '20H_PART_TIME':
|
|
schedule_hours = 20
|
|
|
|
# Overtime
|
|
overtime_amount = cls.calculate_overtime(base_salary_period, schedule_hours, overtime_hours)
|
|
|
|
# Legal Gratification Art. 50
|
|
legal_grat = cls.calculate_legal_gratification(base_salary_period, overtime_amount, rd_bonus)
|
|
|
|
# Total Taxable
|
|
total_taxable = base_salary_period + overtime_amount + legal_grat + rd_bonus + other_taxable
|
|
|
|
# Non-taxable items
|
|
allowance_lunch = Decimal(str(employee.colacion_clp or 0))
|
|
allowance_commute = Decimal(str(employee.movilizacion_clp or 0))
|
|
total_non_taxable = allowance_lunch + allowance_commute
|
|
|
|
total_gross = total_taxable + total_non_taxable
|
|
|
|
# Previsional Caps Application
|
|
afp_health_cap_clp = (cls.AFP_HEALTH_CAP_UF * cls.UF_2026).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
|
|
afc_cap_clp = (cls.AFC_CAP_UF * cls.UF_2026).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
|
|
|
|
taxable_for_afp_health = min(total_taxable, afp_health_cap_clp)
|
|
taxable_for_afc = min(total_taxable, afc_cap_clp)
|
|
|
|
# AFP Deduction
|
|
afp_rate = Decimal(str(employee.afp_rate or 10.58)) / Decimal('100')
|
|
afp_deduction = (taxable_for_afp_health * afp_rate).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
|
|
|
|
# Health Deduction
|
|
if employee.health_system == 'ISAPRE' and employee.health_plan_uf > 0:
|
|
pactado_isapre_clp = (Decimal(str(employee.health_plan_uf)) * cls.UF_2026).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
|
|
legal_7_pct = (taxable_for_afp_health * Decimal('0.07')).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
|
|
health_deduction = max(pactado_isapre_clp, legal_7_pct)
|
|
else:
|
|
# Fonasa or 7%
|
|
health_deduction = (taxable_for_afp_health * Decimal('0.07')).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
|
|
|
|
# AFC Worker Deduction (0.6% if indefinite and has AFC)
|
|
if employee.has_afc and employee.contract_type == 'INDEFINIDO':
|
|
afc_deduction = (taxable_for_afc * cls.AFC_WORKER_RATE).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
|
|
else:
|
|
afc_deduction = Decimal('0')
|
|
|
|
# Tax Base for Second Category Tax
|
|
tax_base = total_taxable - afp_deduction - min(health_deduction, (taxable_for_afp_health * Decimal('0.07')).quantize(Decimal('1'))) - afc_deduction
|
|
second_cat_tax = cls.calculate_second_category_tax(tax_base)
|
|
|
|
# Total Deductions
|
|
total_deductions = afp_deduction + health_deduction + afc_deduction + second_cat_tax + other_deductions
|
|
|
|
# Net Salary
|
|
net_salary = total_gross - total_deductions
|
|
|
|
# Employer Charges (Costo Empresa)
|
|
employer_sis = (taxable_for_afp_health * cls.SIS_RATE).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
|
|
employer_mutual = (taxable_for_afp_health * cls.MUTUAL_RATE).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
|
|
|
|
if employee.has_afc:
|
|
if employee.contract_type == 'INDEFINIDO':
|
|
employer_afc = (taxable_for_afc * cls.AFC_EMPLOYER_INDEF).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
|
|
else:
|
|
employer_afc = (taxable_for_afc * cls.AFC_EMPLOYER_FIJO).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
|
|
else:
|
|
employer_afc = Decimal('0')
|
|
|
|
total_employer_charges = employer_sis + employer_mutual + employer_afc
|
|
total_company_cost = total_gross + total_employer_charges
|
|
|
|
return {
|
|
'month': month,
|
|
'year': year,
|
|
'days_worked': days_worked,
|
|
'base_salary': base_salary_period,
|
|
'overtime_hours_50': overtime_hours,
|
|
'overtime_amount_50': overtime_amount,
|
|
'legal_gratification': legal_grat,
|
|
'rd_bonus': rd_bonus,
|
|
'other_taxable': other_taxable,
|
|
'total_taxable_income': total_taxable,
|
|
'allowance_lunch': allowance_lunch,
|
|
'allowance_commute': allowance_commute,
|
|
'total_non_taxable_income': total_non_taxable,
|
|
'total_gross_income': total_gross,
|
|
'afp_deduction': afp_deduction,
|
|
'health_deduction': health_deduction,
|
|
'afc_deduction': afc_deduction,
|
|
'second_category_tax': second_cat_tax,
|
|
'other_deductions': other_deductions,
|
|
'total_deductions': total_deductions,
|
|
'net_salary': net_salary,
|
|
'employer_sis': employer_sis,
|
|
'employer_mutual': employer_mutual,
|
|
'employer_afc': employer_afc,
|
|
'total_employer_charges': total_employer_charges,
|
|
'total_company_cost': total_company_cost,
|
|
}
|
|
|
|
@staticmethod
|
|
def generate_signature_hash(slip_id: int, rut: str, net_amount: Decimal, timestamp: datetime) -> str:
|
|
"""Generates cryptographic SHA-256 digital signature token for Buk-style slip validation."""
|
|
payload = f"EFINTEL-BUK|SLIP:{slip_id}|RUT:{rut}|NET:{net_amount}|TS:{timestamp.isoformat()}"
|
|
return hashlib.sha256(payload.encode('utf-8')).hexdigest()
|
|
|
|
|
|
class BankExportService:
|
|
"""
|
|
Exports payroll disbursements into standard Chilean banking formats (TXT/CSV).
|
|
"""
|
|
@staticmethod
|
|
def export_santander_format(slips: List) -> str:
|
|
"""Generates Banco Santander Nómina CSV/TXT."""
|
|
output = io.StringIO()
|
|
writer = csv.writer(output, delimiter=';')
|
|
writer.writerow(['RUT_BENEFICIARIO', 'NOMBRE_BENEFICIARIO', 'BANCO_DESTINO', 'TIPO_CUENTA', 'NUMERO_CUENTA', 'MONTO_CLP', 'GLOSA'])
|
|
for slip in slips:
|
|
emp = slip.employee
|
|
writer.writerow([
|
|
emp.rut,
|
|
emp.full_name,
|
|
emp.bank_name,
|
|
emp.get_account_type_display(),
|
|
emp.account_number,
|
|
int(slip.net_salary),
|
|
f"Sueldo {slip.month:02d}/{slip.year} EFINTEL"
|
|
])
|
|
return output.getvalue()
|
|
|
|
@staticmethod
|
|
def export_banco_chile_txt(slips: List) -> str:
|
|
"""Generates Banco de Chile flat file format."""
|
|
lines = []
|
|
for slip in slips:
|
|
emp = slip.employee
|
|
clean_rut = emp.rut.replace('.', '').replace('-', '').zfill(10)
|
|
clean_name = emp.full_name[:30].ljust(30)
|
|
account_num = emp.account_number.replace('-', '')[:18].zfill(18)
|
|
amount_str = str(int(slip.net_salary)).zfill(11)
|
|
line = f"01{clean_rut}{clean_name}{account_num}{amount_str}001"
|
|
lines.append(line)
|
|
return "\n".join(lines)
|
|
|
|
|
|
class PreviredExportService:
|
|
"""
|
|
Generates official Previred 105-fields previsional payment file.
|
|
"""
|
|
@staticmethod
|
|
def generate_previred_105(slips: List, company_rut: str = "77.123.456-7") -> str:
|
|
"""
|
|
Builds positional/delimited 105-column dataset for bulk previsional declaration at Previred.
|
|
"""
|
|
output = io.StringIO()
|
|
for slip in slips:
|
|
emp = slip.employee
|
|
rut_parts = emp.rut.replace('.', '').split('-')
|
|
rut_num = rut_parts[0].zfill(9) if len(rut_parts) > 0 else "000000000"
|
|
rut_dv = rut_parts[1].upper() if len(rut_parts) > 1 else "0"
|
|
|
|
# Fields 1 to 105 representation
|
|
row = [
|
|
rut_num, rut_dv, emp.last_name[:30], emp.first_name[:30],
|
|
"M" if "M" in emp.rut else "F", # Gen
|
|
"056", # Pais
|
|
"0", # Tipo trab
|
|
"1", # Tipo pago
|
|
f"{slip.year}{slip.month:02d}", # Periodo
|
|
f"{slip.year}{slip.month:02d}01", # Fecha desde
|
|
f"{slip.year}{slip.month:02d}30", # Fecha hasta
|
|
str(slip.days_worked), # Dias trab
|
|
"0", # Tipo linea
|
|
str(int(slip.total_taxable_income)), # Renta imp
|
|
str(int(slip.total_taxable_income)), # Renta imp AFP
|
|
str(int(slip.afp_deduction)), # Cotiz obligatoria
|
|
str(int(slip.employer_sis)), # SIS
|
|
emp.afp, # Codigo AFP
|
|
"0", # Ahorro voluntario
|
|
str(int(slip.total_taxable_income)), # Renta imp Salud
|
|
str(int(slip.health_deduction)), # Cotiz Salud 7%
|
|
emp.health_system, # Fonasa / Isapre
|
|
str(int(slip.employer_mutual)), # Mutualidad ACHS
|
|
str(int(slip.total_taxable_income)), # Renta imp AFC
|
|
str(int(slip.afc_deduction)), # AFC Trabajador
|
|
str(int(slip.employer_afc)), # AFC Empleador
|
|
str(int(slip.second_category_tax)), # Impuesto Unico
|
|
]
|
|
# Fill remaining columns to complete 105 Previred fields standard
|
|
while len(row) < 105:
|
|
row.append("0")
|
|
|
|
output.write(";".join(row) + "\n")
|
|
return output.getvalue()
|
|
|
|
|
|
class LREExportService:
|
|
"""
|
|
Exports Libro de Remuneraciones Electrónico (LRE) conforming to Dirección del Trabajo (DT).
|
|
"""
|
|
@staticmethod
|
|
def generate_dt_lre_csv(slips: List) -> str:
|
|
output = io.StringIO()
|
|
writer = csv.writer(output, delimiter=';')
|
|
|
|
# DT LRE Header Standard
|
|
header = [
|
|
'RUT_TRABAJADOR', 'DV_TRABAJADOR', 'APELLIDO_PATERNO', 'APELLIDO_MATERNO', 'NOMBRES',
|
|
'SUELDO_BASE_1101', 'SOBRESUELDO_HORAS_EXTRAS_1102', 'GRATIFICACION_LEGAL_1104',
|
|
'BONOS_INCENTIVOS_1106', 'TOTAL_HABERES_IMPONIBLES_1100', 'ASIGNACION_COLACION_2101',
|
|
'ASIGNACION_MOVILIZACION_2102', 'TOTAL_HABERES_NO_IMPONIBLES_2100', 'TOTAL_HABERES_BRUTO',
|
|
'COTIZACION_OBLIGATORIA_AFP_3101', 'SEGURO_INVALIDEZ_SOBREVIVENCIA_SIS', 'COTIZACION_SALUD_3102',
|
|
'COTIZACION_SEGURO_CESANTIA_TRABAJADOR_3103', 'IMPUESTO_UNICO_SEGUNDA_CATEGORIA_3104',
|
|
'TOTAL_DESCUENTOS_PREVISIONALES_3100', 'TOTAL_DESCUENTOS', 'SUELDO_LIQUIDO_ALCANCE_LIQUIDO',
|
|
'APORTE_PATRONAL_MUTUALIDAD', 'APORTE_PATRONAL_AFC', 'COSTO_TOTAL_EMPRESA'
|
|
]
|
|
writer.writerow(header)
|
|
|
|
for s in slips:
|
|
emp = s.employee
|
|
rut_parts = emp.rut.replace('.', '').split('-')
|
|
rut_n = rut_parts[0] if len(rut_parts) > 0 else ""
|
|
rut_d = rut_parts[1] if len(rut_parts) > 1 else ""
|
|
|
|
name_parts = emp.last_name.split(' ')
|
|
pat = name_parts[0] if len(name_parts) > 0 else emp.last_name
|
|
mat = name_parts[1] if len(name_parts) > 1 else ""
|
|
|
|
writer.writerow([
|
|
rut_n, rut_d, pat, mat, emp.first_name,
|
|
int(s.base_salary), int(s.overtime_amount_50), int(s.legal_gratification),
|
|
int(s.rd_bonus), int(s.total_taxable_income), int(s.allowance_lunch),
|
|
int(s.allowance_commute), int(s.total_non_taxable_income), int(s.total_gross_income),
|
|
int(s.afp_deduction), int(s.employer_sis), int(s.health_deduction),
|
|
int(s.afc_deduction), int(s.second_category_tax),
|
|
int(s.afp_deduction + s.health_deduction + s.afc_deduction),
|
|
int(s.total_deductions), int(s.net_salary),
|
|
int(s.employer_mutual), int(s.employer_afc), int(s.total_company_cost)
|
|
])
|
|
return output.getvalue()
|