""" 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()