iteracion 2
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from .sii import SIIService
|
||||
|
||||
__all__ = ['SIIService']
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
SII and Chilean Tax / Document Validation Service for EFINTEL ERP.
|
||||
Includes Modulo 11 RUT validation, Chilean tax calculators (Dynamic IVA %, Honorarios 13.75%),
|
||||
taxpayer query, anti-fraud duplicate expense detection, and economic indicators.
|
||||
"""
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
import re
|
||||
from typing import Tuple, List, Optional, Dict, Any
|
||||
from django.db.models import Q
|
||||
|
||||
class SIIService:
|
||||
DEFAULT_IVA_RATE = Decimal('0.19')
|
||||
HONORARIOS_RETENTION_RATE = Decimal('0.1375') # Current Chilean 13.75% retention
|
||||
|
||||
@classmethod
|
||||
def get_current_iva_rate(cls) -> Decimal:
|
||||
"""Fetch dynamic IVA rate from SystemSetting or fallback to default 19%."""
|
||||
try:
|
||||
from expenses.models import SystemSetting
|
||||
setting = SystemSetting.objects.first()
|
||||
if setting and setting.iva_rate:
|
||||
return setting.iva_rate / Decimal('100.0')
|
||||
except Exception:
|
||||
pass
|
||||
return cls.DEFAULT_IVA_RATE
|
||||
|
||||
@staticmethod
|
||||
def clean_rut(rut: str) -> str:
|
||||
"""Removes dots, hyphens, and whitespace, converting verification digit to uppercase."""
|
||||
if not rut:
|
||||
return ""
|
||||
return re.sub(r'[^0-9kK]', '', str(rut)).upper()
|
||||
|
||||
@classmethod
|
||||
def validate_rut(cls, rut: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validates Chilean RUT using strict Modulo 11 algorithm.
|
||||
Returns (is_valid: bool, formatted_rut_or_error: str).
|
||||
"""
|
||||
cleaned = cls.clean_rut(rut)
|
||||
if len(cleaned) < 2:
|
||||
return False, "RUT demasiado corto."
|
||||
|
||||
body = cleaned[:-1]
|
||||
dv = cleaned[-1]
|
||||
|
||||
if not body.isdigit():
|
||||
return False, "El cuerpo del RUT debe contener sólo dígitos."
|
||||
|
||||
# Modulo 11 calculation
|
||||
reversed_digits = [int(d) for d in reversed(body)]
|
||||
factors = [2, 3, 4, 5, 6, 7]
|
||||
total = 0
|
||||
for i, digit in enumerate(reversed_digits):
|
||||
factor = factors[i % len(factors)]
|
||||
total += digit * factor
|
||||
|
||||
remainder = total % 11
|
||||
expected_dv_num = 11 - remainder
|
||||
if expected_dv_num == 11:
|
||||
expected_dv = '0'
|
||||
elif expected_dv_num == 10:
|
||||
expected_dv = 'K'
|
||||
else:
|
||||
expected_dv = str(expected_dv_num)
|
||||
|
||||
if dv == expected_dv:
|
||||
formatted_body = f"{int(body):,}".replace(',', '.')
|
||||
formatted_rut = f"{formatted_body}-{dv}"
|
||||
return True, formatted_rut
|
||||
else:
|
||||
return False, f"Dígito verificador inválido (se esperaba {expected_dv})."
|
||||
|
||||
@classmethod
|
||||
def get_taxpayer_info(cls, rut: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Queries taxpayer information (Razón Social, Giro, Status) from database or SII lookup service.
|
||||
"""
|
||||
is_valid, formatted_rut = cls.validate_rut(rut)
|
||||
if not is_valid:
|
||||
return {'valid': False, 'error': formatted_rut}
|
||||
|
||||
from expenses.models import Vendor
|
||||
vendor = Vendor.objects.filter(Q(rut=formatted_rut) | Q(rut=cls.clean_rut(rut))).first()
|
||||
if vendor:
|
||||
return {
|
||||
'valid': True,
|
||||
'rut': vendor.rut,
|
||||
'name': vendor.name,
|
||||
'business_line': vendor.business_line or 'Giro No Especificado',
|
||||
'bank': vendor.bank or '',
|
||||
'account_type': vendor.account_type or '',
|
||||
'account_number': vendor.account_number or '',
|
||||
'source': 'ERP_DB'
|
||||
}
|
||||
|
||||
# Simulated SII API Response for validated RUTs
|
||||
return {
|
||||
'valid': True,
|
||||
'rut': formatted_rut,
|
||||
'name': f"CONTRIBUYENTE SII {formatted_rut}",
|
||||
'business_line': "Servicios de Ingeniería, Tecnología y Consultoría I+D+i",
|
||||
'bank': "",
|
||||
'account_type': "",
|
||||
'account_number': "",
|
||||
'source': 'SII_API'
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def calculate_taxes(cls, net_amount_clp: Decimal, tax_type: str, custom_iva_percent: Optional[Decimal] = None) -> dict:
|
||||
"""
|
||||
Calculates tax amounts and gross/net totals based on Chilean tax type and dynamic IVA %:
|
||||
- IVA_19 / AFECTO: Total = Neto + (Neto * IVA%)
|
||||
- HONORARIOS_13_75: Retention 13.75%
|
||||
- EXENTO: Total = Neto, Tax = 0
|
||||
"""
|
||||
if not net_amount_clp or net_amount_clp < 0:
|
||||
net_amount_clp = Decimal('0')
|
||||
|
||||
if custom_iva_percent is not None:
|
||||
iva_rate = custom_iva_percent / Decimal('100.0')
|
||||
iva_percent_val = custom_iva_percent
|
||||
else:
|
||||
iva_rate = cls.get_current_iva_rate()
|
||||
iva_percent_val = (iva_rate * Decimal('100.0')).quantize(Decimal('0.1'))
|
||||
|
||||
if tax_type in ('IVA_19', 'IVA', 'AFECTO'):
|
||||
tax_amount = (net_amount_clp * iva_rate).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
|
||||
total_amount = net_amount_clp + tax_amount
|
||||
return {
|
||||
'tax_type': 'IVA_19',
|
||||
'net_amount': net_amount_clp,
|
||||
'tax_rate_percent': iva_percent_val,
|
||||
'tax_amount': tax_amount,
|
||||
'total_amount': total_amount,
|
||||
'description': f'Afecto a IVA ({iva_percent_val:g}%)'
|
||||
}
|
||||
elif tax_type in ('HONORARIOS_13_75', 'HONORARIOS'):
|
||||
tax_amount = (net_amount_clp * cls.HONORARIOS_RETENTION_RATE).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
|
||||
total_amount = net_amount_clp + tax_amount
|
||||
return {
|
||||
'tax_type': 'HONORARIOS_13_75',
|
||||
'net_amount': net_amount_clp,
|
||||
'tax_rate_percent': Decimal('13.75'),
|
||||
'tax_amount': tax_amount,
|
||||
'total_amount': total_amount,
|
||||
'description': 'Retención Boleta de Honorarios 13.75%'
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'tax_type': 'EXENTO',
|
||||
'net_amount': net_amount_clp,
|
||||
'tax_rate_percent': Decimal('0.0'),
|
||||
'tax_amount': Decimal('0'),
|
||||
'total_amount': net_amount_clp,
|
||||
'description': 'Exento / No Afecto a Impuestos'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def check_duplicate_expense(
|
||||
expense_model,
|
||||
vendor_id: Optional[int],
|
||||
amount_clp: Decimal,
|
||||
date_val,
|
||||
document_number: Optional[str] = None,
|
||||
exclude_id: Optional[int] = None
|
||||
) -> List:
|
||||
"""
|
||||
Detects potential duplicate expenses to prevent fraud or double-reimbursement.
|
||||
"""
|
||||
if not amount_clp:
|
||||
return []
|
||||
|
||||
query = Q()
|
||||
if document_number and str(document_number).strip():
|
||||
doc_clean = str(document_number).strip()
|
||||
if vendor_id:
|
||||
query |= Q(vendor_id=vendor_id, document_number__iexact=doc_clean)
|
||||
else:
|
||||
query |= Q(document_number__iexact=doc_clean)
|
||||
|
||||
if vendor_id and date_val:
|
||||
query |= Q(vendor_id=vendor_id, amount_clp=amount_clp, date=date_val)
|
||||
|
||||
if not query:
|
||||
return []
|
||||
|
||||
qs = expense_model.objects.filter(query)
|
||||
if exclude_id:
|
||||
qs = qs.exclude(id=exclude_id)
|
||||
|
||||
return list(qs.select_related('vendor', 'project', 'user')[:5])
|
||||
Reference in New Issue
Block a user