Compare commits

...
10 Commits
40 changed files with 2602 additions and 245 deletions
-3
View File
@@ -1,3 +0,0 @@
[submodule "tax_api"]
path = tax_api
url = https://github.com/mechjook/DemostrationTaxAPI.git
+1 -1
View File
@@ -24,7 +24,7 @@ COPY . /app/
EXPOSE 8000 EXPOSE 8000
# Crear un usuario no-root por seguridad # Crear un usuario no-root por seguridad
RUN adduser --disabled-password --no-create-home djangouser RUN adduser --disabled-password djangouser
RUN chown -R djangouser:djangouser /app RUN chown -R djangouser:djangouser /app
USER djangouser USER djangouser
+1
View File
@@ -33,6 +33,7 @@ SECRET_KEY = env('SECRET_KEY', default='django-insecure-fallback-key')
DEBUG = env('DEBUG') DEBUG = env('DEBUG')
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['*']) ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['*'])
CSRF_TRUSTED_ORIGINS = env.list('CSRF_TRUSTED_ORIGINS', default=[])
# Application definition # Application definition
+45
View File
@@ -0,0 +1,45 @@
version: '3.8'
services:
web:
build:
context: .
dockerfile: Dockerfile
command: python manage.py runserver 0.0.0.0:8000
volumes:
- .:/app
- local_media:/app/media
ports:
- 8000:8000
environment:
- DEBUG=1
- USE_S3=False
- REDIS_URL=redis://redis:6379/0
- DATABASE_URL=sqlite:////app/db.sqlite3
depends_on:
- redis
celery:
build:
context: .
dockerfile: Dockerfile
command: celery -A config worker -l info
volumes:
- .:/app
environment:
- DEBUG=1
- USE_S3=False
- REDIS_URL=redis://redis:6379/0
depends_on:
- redis
redis:
image: redis:7-alpine
ports:
- 6379:6379
tax-api:
build:
context: ./tax_api
dockerfile: Dockerfile
ports:
- 8001:8000
mem_limit: 256m
volumes:
local_media: null
+27 -30
View File
@@ -1,42 +1,41 @@
version: "3.8" version: '3.8'
services: services:
web: web:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
command: gunicorn config.asgi:application -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --workers 3 command: gunicorn config.asgi:application -k uvicorn.workers.UvicornWorker --bind
0.0.0.0:8000 --workers 3
restart: unless-stopped restart: unless-stopped
environment: environment:
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
CSRF_TRUSTED_ORIGINS: https://${APP_DOMAIN}
volumes: volumes:
- erp_media:/app/media - erp_media:/app/media
- erp_static:/app/static - erp_static:/app/static
networks: networks:
- controlmc_proxy - controlmc_proxy
- internal - internal
labels: labels:
- "controlmc.public=true" - controlmc.public=true
- "traefik.enable=true" - traefik.enable=true
- "traefik.docker.network=controlmc_proxy" - traefik.docker.network=controlmc_proxy
- "traefik.http.routers.${ROUTER_NAME}.rule=Host(`${APP_DOMAIN}`)" - traefik.http.routers.${ROUTER_NAME}.rule=Host(`${APP_DOMAIN}`)
- "traefik.http.routers.${ROUTER_NAME}.entrypoints=websecure" - traefik.http.routers.${ROUTER_NAME}.entrypoints=websecure
- "traefik.http.routers.${ROUTER_NAME}.tls=true" - traefik.http.routers.${ROUTER_NAME}.tls=true
- "traefik.http.routers.${ROUTER_NAME}.tls.certresolver=le" - traefik.http.routers.${ROUTER_NAME}.tls.certresolver=le
- "traefik.http.services.${ROUTER_NAME}.loadbalancer.server.port=8000" - traefik.http.services.${ROUTER_NAME}.loadbalancer.server.port=8000
depends_on: depends_on:
- redis - redis
mem_limit: 1g mem_limit: 1g
redis: redis:
image: redis:7-alpine image: redis:7-alpine
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- erp_redis_data:/data - erp_redis_data:/data
networks: networks:
- internal - internal
mem_limit: 512m mem_limit: 512m
celery: celery:
build: build:
context: . context: .
@@ -46,26 +45,24 @@ services:
environment: environment:
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
networks: networks:
- internal - internal
depends_on: depends_on:
- redis - redis
mem_limit: 512m mem_limit: 512m
tax-api: tax-api:
build: build:
context: ./tax_api context: ./tax_api
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
networks: networks:
- internal - controlmc_proxy
- internal
mem_limit: 256m mem_limit: 256m
networks: networks:
controlmc_proxy: controlmc_proxy:
external: true external: true
internal: internal: null
volumes: volumes:
erp_media: erp_media: null
erp_static: erp_static: null
erp_redis_data: erp_redis_data: null
+12 -5
View File
@@ -137,6 +137,7 @@ class ExpenseForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['project'].queryset = Project.objects.filter(parent__parent__isnull=False) self.fields['project'].queryset = Project.objects.filter(parent__parent__isnull=False)
self.fields['category'].queryset = Category.objects.filter(children__isnull=True)
if self.instance and self.instance.pk: if self.instance and self.instance.pk:
try: try:
vlog = self.instance.vehicle_log vlog = self.instance.vehicle_log
@@ -218,10 +219,11 @@ class ExpenseForm(forms.ModelForm):
raise forms.ValidationError("Si desea registrar un nuevo proveedor, debe ingresar tanto el RUT como la Razón Social.") raise forms.ValidationError("Si desea registrar un nuevo proveedor, debe ingresar tanto el RUT como la Razón Social.")
# Recalculate taxes via SIIService # Recalculate taxes via SIIService
amount = cleaned_data.get('amount_clp') amount_clp = cleaned_data.get('amount_clp')
tax_type = cleaned_data.get('tax_type') tax_type = cleaned_data.get('tax_type')
if amount and tax_type: if amount_clp and amount_clp > 0 and tax_type:
tax_res = SIIService.calculate_taxes(amount, tax_type) tax_res = SIIService.calculate_taxes(amount_clp, tax_type)
cleaned_data['amount_clp'] = tax_res['net_amount']
cleaned_data['tax_amount_clp'] = tax_res['tax_amount'] cleaned_data['tax_amount_clp'] = tax_res['tax_amount']
cleaned_data['total_amount_clp'] = tax_res['total_amount'] cleaned_data['total_amount_clp'] = tax_res['total_amount']
@@ -229,7 +231,7 @@ class ExpenseForm(forms.ModelForm):
vendor = cleaned_data.get('vendor') vendor = cleaned_data.get('vendor')
doc_num = cleaned_data.get('document_number') doc_num = cleaned_data.get('document_number')
date_val = cleaned_data.get('date') date_val = cleaned_data.get('date')
if amount and (vendor or doc_num): if amount_clp and (vendor or doc_num):
duplicates = SIIService.check_duplicate_expense( duplicates = SIIService.check_duplicate_expense(
expense_model=Expense, expense_model=Expense,
vendor_id=vendor.id if vendor else None, vendor_id=vendor.id if vendor else None,
@@ -352,6 +354,10 @@ class ProjectForm(forms.ModelForm):
'payment_method': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 50% Anticipo / 50% Hito Final'}), 'payment_method': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 50% Anticipo / 50% Hito Final'}),
} }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['category'].queryset = Category.objects.filter(children__isnull=True)
class ProjectPhaseForm(forms.ModelForm): class ProjectPhaseForm(forms.ModelForm):
due_date = forms.DateField(widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), required=False, label="Fecha Pactada") due_date = forms.DateField(widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), required=False, label="Fecha Pactada")
@@ -381,10 +387,11 @@ class CategoryForm(forms.ModelForm):
class VendorForm(forms.ModelForm): class VendorForm(forms.ModelForm):
class Meta: class Meta:
model = Vendor model = Vendor
fields = ['rut', 'name', 'business_line', 'bank', 'account_type', 'account_number', 'email', 'payment_terms'] fields = ['rut', 'name', 'provider_type', 'business_line', 'bank', 'account_type', 'account_number', 'email', 'payment_terms']
widgets = { widgets = {
'rut': forms.TextInput(attrs={'class': 'form-control', 'required': 'required', 'pattern': '^[0-9]+-[0-9kK]$', 'title': 'Formato válido: 12345678-9'}), 'rut': forms.TextInput(attrs={'class': 'form-control', 'required': 'required', 'pattern': '^[0-9]+-[0-9kK]$', 'title': 'Formato válido: 12345678-9'}),
'name': forms.TextInput(attrs={'class': 'form-control', 'required': 'required'}), 'name': forms.TextInput(attrs={'class': 'form-control', 'required': 'required'}),
'provider_type': forms.Select(attrs={'class': 'form-control'}),
'email': forms.EmailInput(attrs={'class': 'form-control'}), 'email': forms.EmailInput(attrs={'class': 'form-control'}),
} }
@@ -0,0 +1,61 @@
# Generated by Django 6.1 on 2026-09-09 16:30
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0011_reimbursement_category_created_at_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='category',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='children', to='expenses.category', verbose_name='Categoría Padre'),
),
migrations.AlterField(
model_name='expense',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='expenses', to='expenses.project', verbose_name='Proyecto'),
),
migrations.AlterField(
model_name='expense',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='expenses_created', to=settings.AUTH_USER_MODEL, verbose_name='Registrado por'),
),
migrations.AlterField(
model_name='expensereport',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='expense_reports', to='expenses.project', verbose_name='Proyecto Imputado'),
),
migrations.AlterField(
model_name='expensereport',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='expense_reports', to=settings.AUTH_USER_MODEL, verbose_name='Responsable Rendidor'),
),
migrations.AlterField(
model_name='project',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='children', to='expenses.project', verbose_name='Portafolio / Proyecto Padre'),
),
migrations.AlterField(
model_name='projectphase',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='phases', to='expenses.project', verbose_name='Proyecto'),
),
migrations.AlterField(
model_name='userprofile',
name='user',
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='profile', to=settings.AUTH_USER_MODEL, verbose_name='Usuario'),
),
migrations.AlterField(
model_name='vehiclelog',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='vehicle_logs', to='expenses.project', verbose_name='Proyecto'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-09-09 17:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0012_alter_category_parent_alter_expense_project_and_more'),
]
operations = [
migrations.AddField(
model_name='vendor',
name='provider_type',
field=models.CharField(choices=[('BIENES', 'Proveedor de Bienes'), ('SERVICIOS', 'Proveedor de Servicios'), ('AMBOS', 'Bienes y Servicios')], default='BIENES', max_length=20, verbose_name='Clasificación'),
),
]
+6
View File
@@ -137,8 +137,14 @@ class Category(CatalogModel):
class Vendor(TimeStampedModel): class Vendor(TimeStampedModel):
PROVIDER_TYPE_CHOICES = [
('BIENES', 'Proveedor de Bienes'),
('SERVICIOS', 'Proveedor de Servicios'),
('AMBOS', 'Bienes y Servicios'),
]
rut = models.CharField(max_length=15, unique=True, verbose_name="RUT Proveedor") rut = models.CharField(max_length=15, unique=True, verbose_name="RUT Proveedor")
name = models.CharField(max_length=200, verbose_name="Razón Social / Nombre") name = models.CharField(max_length=200, verbose_name="Razón Social / Nombre")
provider_type = models.CharField(max_length=20, choices=PROVIDER_TYPE_CHOICES, default='BIENES', verbose_name="Clasificación")
business_line = models.CharField(max_length=200, blank=True, null=True, verbose_name="Giro Comercial") business_line = models.CharField(max_length=200, blank=True, null=True, verbose_name="Giro Comercial")
bank = models.CharField(max_length=100, blank=True, null=True, verbose_name="Banco") bank = models.CharField(max_length=100, blank=True, null=True, verbose_name="Banco")
+41 -7
View File
@@ -186,29 +186,63 @@ class SIIService:
'description': f'Retención Honorarios ({tasa_porcentaje}%) - Vía API Externa' 'description': f'Retención Honorarios ({tasa_porcentaje}%) - Vía API Externa'
} }
# 2. Fallback local (Bruto = Neto ingresado) # 2. Fallback local (Neto ingresado = Líquido)
# Retencion = Bruto * 0.1525 (año 2026) # Bruto = Líquido / (1 - 0.1525)
# Retencion = Bruto - Líquido
tasa = Decimal('0.1525') tasa = Decimal('0.1525')
tax_amount = (net_amount_clp * tasa).quantize(Decimal('1'), rounding=ROUND_HALF_UP) liquid_amount = net_amount_clp
liquid_amount = net_amount_clp - tax_amount bruto_amount = (liquid_amount / (Decimal('1') - tasa)).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
tax_amount = bruto_amount - liquid_amount
return { return {
'tax_type': 'HONORARIOS_13_75', 'tax_type': 'HONORARIOS_13_75',
'net_amount': liquid_amount, 'net_amount': liquid_amount,
'tax_rate_percent': Decimal('15.25'), 'tax_rate_percent': Decimal('15.25'),
'tax_amount': tax_amount, 'tax_amount': tax_amount,
'total_amount': net_amount_clp, 'total_amount': bruto_amount,
'description': 'Retención Boleta de Honorarios 15.25%' 'description': 'Retención Boleta de Honorarios 15.25%'
} }
else: else:
return { return {
'tax_type': 'EXENTO', 'tax_type': 'EXENTO',
'net_amount': net_amount_clp, 'net_amount': net_amount_clp,
'tax_rate_percent': Decimal('0.0'), 'tax_rate_percent': Decimal('0'),
'tax_amount': Decimal('0'), 'tax_amount': Decimal('0'),
'total_amount': net_amount_clp, 'total_amount': net_amount_clp,
'description': 'Exento / No Afecto a Impuestos' 'description': 'Exento de Impuestos'
} }
@classmethod
def simulate_f29(cls, f29_data: dict) -> dict:
"""
Llama al endpoint /api/f29/simular del tax-api.
f29_data debe contener: ventas_netas, compras_netas, etc.
"""
api_res = cls._call_api("/api/f29/simular", f29_data)
if api_res:
return api_res
# Fallback local simplificado si la API no está disponible
ventas_netas = Decimal(str(f29_data.get('ventas_netas', 0)))
compras_netas = Decimal(str(f29_data.get('compras_netas', 0)))
retenciones = Decimal(str(f29_data.get('retenciones_honorarios', 0)))
tasa_ppm = Decimal(str(f29_data.get('tasa_ppm', 0.01)))
debito = ventas_netas * Decimal('0.19')
credito = compras_netas * Decimal('0.19')
ppm = ventas_netas * tasa_ppm
iva_pagar = max(Decimal('0'), debito - credito)
return {
"iva_debito": float(debito),
"iva_credito": float(credito),
"iva_a_pagar": float(iva_pagar),
"ppm_a_pagar": float(ppm),
"total_retenciones_honorarios": float(retenciones),
"total_a_pagar": float(iva_pagar + ppm + retenciones),
"remanente_siguiente_mes": float(max(Decimal('0'), credito - debito)),
"nota": "Simulación local de respaldo. API no disponible."
}
@staticmethod @staticmethod
def check_duplicate_expense( def check_duplicate_expense(
expense_model, expense_model,
+1
View File
@@ -26,6 +26,7 @@ urlpatterns = [
path('gastos/exportar/csv/', views.export_expenses_csv, name='export_csv'), path('gastos/exportar/csv/', views.export_expenses_csv, name='export_csv'),
path('gastos/exportar/transferencias/', views.export_bank_transfer_csv, name='export_bank_transfer_csv'), path('gastos/exportar/transferencias/', views.export_bank_transfer_csv, name='export_bank_transfer_csv'),
path('f29/simulador/', views.f29_simulator, name='f29_simulator'),
path('proyectos/', views.project_list, name='project_list'), path('proyectos/', views.project_list, name='project_list'),
path('proyectos/<int:pk>/', views.project_detail, name='project_detail'), path('proyectos/<int:pk>/', views.project_detail, name='project_detail'),
path('proyectos/<int:pk>/fase/nueva/', views.project_add_phase, name='project_add_phase'), path('proyectos/<int:pk>/fase/nueva/', views.project_add_phase, name='project_add_phase'),
+74
View File
@@ -1014,3 +1014,77 @@ def api_sii_calculate_taxes(request):
'description': res['description'] 'description': res['description']
}) })
from datetime import datetime, date
import calendar
from django.db.models import Sum
from procurement.models import PurchaseOrder
@login_required
def f29_simulator(request):
year = request.GET.get('year', date.today().year)
month = request.GET.get('month', date.today().month)
ventas_netas = request.GET.get('ventas_netas', 0)
ventas_exentas = request.GET.get('ventas_exentas', 0)
tasa_ppm = request.GET.get('tasa_ppm', 0.01)
try:
year, month = int(year), int(month)
ventas_netas = float(ventas_netas)
ventas_exentas = float(ventas_exentas)
tasa_ppm = float(tasa_ppm)
except ValueError:
year, month = date.today().year, date.today().month
ventas_netas, ventas_exentas, tasa_ppm = 0, 0, 0.01
_, last_day = calendar.monthrange(year, month)
start_date = date(year, month, 1)
end_date = date(year, month, last_day)
# 1. Compras Netas (Facturas con IVA 19%)
compras_expenses = Expense.objects.filter(
date__range=(start_date, end_date),
tax_type='IVA_19',
approval_status='LIBERADO_PAGADO'
).aggregate(total=Sum('amount_clp'))['total'] or 0
compras_po = PurchaseOrder.objects.filter(
order_date__range=(start_date, end_date),
status='LIBERADA_PAGADA'
).aggregate(total=Sum('net_amount_clp'))['total'] or 0
compras_netas = float(compras_expenses) + float(compras_po)
# 2. Honorarios
honorarios_expenses = Expense.objects.filter(
date__range=(start_date, end_date),
tax_type='HONORARIOS_13_75',
approval_status='LIBERADO_PAGADO'
).aggregate(total=Sum('tax_amount_clp'))['total'] or 0
retenciones_honorarios = float(honorarios_expenses)
f29_data = {
'ventas_netas': ventas_netas,
'compras_netas': compras_netas,
'ventas_exentas': ventas_exentas,
'compras_activo_fijo': 0,
'remanente_anterior': 0,
'tasa_ppm': tasa_ppm,
'retenciones_honorarios': retenciones_honorarios
}
simulation = SIIService.simulate_f29(f29_data)
context = {
'year': year,
'month': month,
'months': range(1, 13),
'years': range(date.today().year - 2, date.today().year + 2),
'ventas_netas': ventas_netas,
'ventas_exentas': ventas_exentas,
'tasa_ppm': tasa_ppm,
'simulation': simulation,
'compras_netas': compras_netas,
'retenciones_honorarios': retenciones_honorarios
}
return render(request, 'expenses/f29_simulator.html', context)
+24
View File
@@ -0,0 +1,24 @@
with open('templates/base.html', 'r') as f:
lines = f.readlines()
new_lines = []
skip = False
for i, line in enumerate(lines):
if 'url \'accounts_payable\'' in line:
new_lines.append(line)
new_lines.append(' <i data-lucide="dollar-sign" style="width:16px;"></i> Cuentas por Pagar (CXP)\n </a>\n')
new_lines.append(' <a href="{% url \'f29_simulator\' %}" class="nav-tab {% if request.resolver_match.url_name == \'f29_simulator\' %}active{% endif %}">\n <i data-lucide="calculator"></i> Simulador F29\n </a>\n')
skip = True
continue
if skip:
if 'Cuentas por Pagar (CXP)' in line:
pass
elif '</a>' in line:
skip = False
continue
new_lines.append(line)
with open('templates/base.html', 'w') as f:
f.writelines(new_lines)
+38
View File
@@ -0,0 +1,38 @@
import os
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django.setup()
from expenses.models import Category
from procurement.models import ItemCatalog
# 1. Update Project Categories to exactly 1201-1207
print("Actualizando Categorías a la serie 1200...")
Category.objects.filter(code__in=['3101', '3102', '3103', '3104', '3105', '3106', '3107']).delete()
required_codes = {
'1201': 'Investigación Básica',
'1202': 'Investigación Aplicada',
'1203': 'Desarrollo Experimental',
'1204': 'Prototipado',
'1205': 'Pruebas de Concepto (PoC)',
'1206': 'Gestión de Proyectos',
'1207': 'Otros Servicios'
}
parent, _ = Category.objects.get_or_create(code='1200', defaults={'name': 'Portafolio de Proyectos'})
for code, name in required_codes.items():
cat, created = Category.objects.get_or_create(code=code, defaults={'name': name, 'parent': parent})
if not created:
cat.name = name
cat.parent = parent
cat.save()
# 3. Delete 'actv' from ItemCatalog in Procurement
print("Eliminando ítem 'actv' del catálogo...")
items_to_delete = ItemCatalog.objects.filter(code__iexact='actv')
count = items_to_delete.count()
items_to_delete.delete()
print(f"Se eliminaron {count} ítems 'actv'.")
print("Migración de datos completada.")
+32
View File
@@ -0,0 +1,32 @@
import yaml
with open('docker-compose.yml', 'r') as f:
data = yaml.safe_load(f)
data['services']['tax-api'] = {
'build': {
'context': './tax_api',
'dockerfile': 'Dockerfile'
},
'restart': 'unless-stopped',
'networks': ['controlmc_proxy', 'internal'],
'mem_limit': '256m'
}
with open('docker-compose.yml', 'w') as f:
yaml.dump(data, f, sort_keys=False, default_flow_style=False)
with open('docker-compose.local.yml', 'r') as f:
data_local = yaml.safe_load(f)
data_local['services']['tax-api'] = {
'build': {
'context': './tax_api',
'dockerfile': 'Dockerfile'
},
'ports': ['8001:8000'],
'mem_limit': '256m'
}
with open('docker-compose.local.yml', 'w') as f:
yaml.dump(data_local, f, sort_keys=False, default_flow_style=False)
@@ -0,0 +1,19 @@
# Generated by Django 6.1 on 2026-09-09 16:30
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payroll', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='salaryslip',
name='employee',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='salary_slips', to='payroll.employeeprofile', verbose_name='Colaborador'),
),
]
+2 -2
View File
@@ -108,8 +108,8 @@ class ThreeWayMatchForm(forms.ModelForm):
widgets = { widgets = {
'purchase_order': forms.Select(attrs={'class': 'form-control', 'id': 'id_po_select', 'required': 'required'}), 'purchase_order': forms.Select(attrs={'class': 'form-control', 'id': 'id_po_select', 'required': 'required'}),
'goods_receipt': forms.Select(attrs={'class': 'form-control', 'required': 'required'}), 'goods_receipt': forms.Select(attrs={'class': 'form-control', 'required': 'required'}),
'dte_invoice_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 45290', 'required': 'required', 'pattern': '^[0-9A-Za-z\-]+$'}), 'dte_invoice_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 45290', 'required': 'required', 'pattern': r'^[0-9A-Za-z\-]+$'}),
'dte_invoice_rut': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 76.543.210-K', 'required': 'required', 'pattern': '^\d{1,2}\.\d{3}\.\d{3}-[\dkK]$'}), 'dte_invoice_rut': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 76.543.210-K', 'required': 'required', 'pattern': r'^\d{1,2}\.\d{3}\.\d{3}-[\dkK]$'}),
'dte_net_amount': forms.NumberInput(attrs={'class': 'form-control', 'step': '1', 'id': 'id_dte_net', 'required': 'required', 'min': '0'}), 'dte_net_amount': forms.NumberInput(attrs={'class': 'form-control', 'step': '1', 'id': 'id_dte_net', 'required': 'required', 'min': '0'}),
'dte_tax_amount': forms.NumberInput(attrs={'class': 'form-control', 'step': '1', 'id': 'id_dte_tax', 'required': 'required', 'min': '0'}), 'dte_tax_amount': forms.NumberInput(attrs={'class': 'form-control', 'step': '1', 'id': 'id_dte_tax', 'required': 'required', 'min': '0'}),
'dte_total_amount': forms.NumberInput(attrs={'class': 'form-control', 'step': '1', 'id': 'id_dte_total', 'required': 'required', 'min': '0'}), 'dte_total_amount': forms.NumberInput(attrs={'class': 'form-control', 'step': '1', 'id': 'id_dte_total', 'required': 'required', 'min': '0'}),
@@ -0,0 +1,57 @@
# Generated by Django 6.1 on 2026-09-09 16:30
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0012_alter_category_parent_alter_expense_project_and_more'),
('procurement', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='goodsreceipt',
name='purchase_order',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='receipts', to='procurement.purchaseorder', verbose_name='Orden de Compra'),
),
migrations.AlterField(
model_name='goodsreceipt',
name='received_by',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='received_deliveries', to=settings.AUTH_USER_MODEL, verbose_name='Receptor Conforme'),
),
migrations.AlterField(
model_name='purchaseorder',
name='issuer',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_purchase_orders', to=settings.AUTH_USER_MODEL, verbose_name='Emitido por'),
),
migrations.AlterField(
model_name='purchaseorder',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='purchase_orders', to='expenses.project', verbose_name='Proyecto'),
),
migrations.AlterField(
model_name='purchaseorder',
name='vendor',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='purchase_orders', to='expenses.vendor', verbose_name='Proveedor'),
),
migrations.AlterField(
model_name='purchaserequisition',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='purchase_requisitions', to='expenses.project', verbose_name='Proyecto / Centro de Costo'),
),
migrations.AlterField(
model_name='purchaserequisition',
name='requester',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='purchase_requisitions', to=settings.AUTH_USER_MODEL, verbose_name='Solicitante'),
),
migrations.AlterField(
model_name='threewaymatch',
name='purchase_order',
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='three_way_match', to='procurement.purchaseorder', verbose_name='Orden de Compra'),
),
]
+2 -1
View File
@@ -154,7 +154,8 @@ class PurchaseOrder(models.Model):
def recalculate_totals(self): def recalculate_totals(self):
neto = sum(item.subtotal_net for item in self.items.all()) neto = sum(item.subtotal_net for item in self.items.all())
iva = (neto * Decimal('0.19')).quantize(Decimal('1')) iva_rate = Decimal(str(self.tax_rate or '19.00')) / Decimal('100.0')
iva = (neto * iva_rate).quantize(Decimal('1'))
total = neto + iva total = neto + iva
self.net_amount_clp = neto self.net_amount_clp = neto
self.tax_amount_clp = iva self.tax_amount_clp = iva
@@ -0,0 +1,19 @@
# Generated by Django 6.1 on 2026-09-09 16:30
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('talent', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='evaluationrubric7d',
name='candidate',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='evaluations', to='talent.specialistcandidate', verbose_name='Especialista Candidato'),
),
]
Submodule tax_api deleted from 60c958e96b
+57
View File
@@ -0,0 +1,57 @@
name: Tax API Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
test:
name: Tests & Validación
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Instalar dependencias
run: pip install -r requirements.txt
- name: Ejecutar tests
run: pytest tests/ -v --tb=short
deploy:
name: Deploy Demo a GitHub Pages
needs: test
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- name: Upload artifact para Pages
uses: actions/upload-pages-artifact@v3
with:
path: docs/
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+10
View File
@@ -0,0 +1,10 @@
__pycache__/
*.pyc
*.pyo
.pytest_cache/
*.egg-info/
dist/
build/
.venv/
venv/
.env
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ src/
EXPOSE 8000
CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"]
+93
View File
@@ -0,0 +1,93 @@
# API de Validación Tributaria — Chile
Microservicio REST para validación y cálculo tributario chileno. Incluye validación de RUT, cálculo de IVA, retención de honorarios, topes imponibles/cotizaciones y simulador de F29. Con Swagger UI, Dockerfile y demo interactiva en GitHub Pages.
[![Tax API Pipeline](https://github.com/mechjook/DemostrationTaxAPI/actions/workflows/tax_api_pipeline.yml/badge.svg)](https://github.com/mechjook/DemostrationTaxAPI/actions/workflows/tax_api_pipeline.yml)
## Demo
Disponible en: **[GitHub Pages](https://mechjook.github.io/DemostrationTaxAPI/)**
## Arquitectura
```
┌────────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ FastAPI + Swagger │───▶│ Lógica │───▶│ Modelos │
│ 11 endpoints REST │ │ Tributaria │ │ Pydantic │
└────────────────────┘ └──────────────────┘ └──────────────────┘
│
▼
┌────────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Demo HTML │ │ Dockerfile │ │ Tests │
│ GitHub Pages │ │ Containerización │ │ pytest + httpx │
└────────────────────┘ └──────────────────┘ └──────────────────┘
```
## Endpoints
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| POST | `/api/rut/validar` | Validar RUT chileno (dígito verificador módulo 11) |
| POST | `/api/iva/desde-neto` | Calcular IVA y bruto desde monto neto |
| POST | `/api/iva/desde-bruto` | Extraer neto e IVA desde monto bruto |
| POST | `/api/iva/factura` | Desglose completo de factura con items |
| POST | `/api/honorarios/retencion` | Calcular retención desde monto bruto |
| POST | `/api/honorarios/liquido` | Calcular bruto necesario desde líquido |
| POST | `/api/honorarios/anual` | Simulación anual de honorarios |
| POST | `/api/topes/calcular` | Topes imponibles en pesos según UF |
| POST | `/api/topes/cotizaciones` | Cotizaciones previsionales completas |
| GET | `/api/topes/afps` | Listar AFPs con tasas |
| POST | `/api/f29/simular` | Simulador de declaración F29 |
## Dominios Tributarios
| Dominio | Descripción |
|---------|-------------|
| **RUT** | Validación con algoritmo módulo 11, formateo, detección de errores |
| **IVA** | Tasa 19%, cálculo neto↔bruto, desglose de facturas |
| **Honorarios** | Retención 2024-2027 (13.75%→17%), cálculo inverso, simulación anual |
| **Topes** | AFP (90 UF), salud (7%), AFC, SIS. 7 AFPs con tasas actualizadas |
| **F29** | Débito/crédito fiscal, remanente, PPM (1%), retenciones |
## Ejecución Local
```bash
pip install -r requirements.txt
# Ejecutar API
uvicorn src.api:app --reload
# Swagger UI
open http://localhost:8000/docs
```
## Docker
```bash
docker build -t tax-api .
docker run -p 8000:8000 tax-api
```
## Tests
```bash
pytest tests/ -v
```
## CI/CD
El workflow de GitHub Actions ejecuta:
1. **Tests** — pytest con tests unitarios + integración de endpoints
2. **Deploy** — publica la demo interactiva en GitHub Pages (solo en `main`)
## Stack
- Python 3.12
- FastAPI / Pydantic
- pytest / httpx
- Docker
- GitHub Actions + GitHub Pages
## Autor
**José Nicolás Candia** — [@mechjook](https://github.com/mechjook)
+540
View File
@@ -0,0 +1,540 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API de Validación Tributaria — Chile</title>
<style>
:root {
--bg-primary: #0F172A;
--bg-secondary: #1E293B;
--bg-card: #1E293B;
--bg-card-hover: #334155;
--text-primary: #E2E8F0;
--text-secondary: #94A3B8;
--text-muted: #64748B;
--accent-blue: #3B82F6;
--accent-green: #22C55E;
--accent-yellow: #EAB308;
--accent-orange: #F97316;
--accent-red: #EF4444;
--accent-purple: #A855F7;
--accent-cyan: #06B6D4;
--border-color: #334155;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg-primary); color: var(--text-primary); line-height: 1.6;
}
/* NAVBAR */
.navbar {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
background: var(--bg-secondary); border-bottom: 1px solid var(--border-color);
padding: 0 2rem; display: flex; align-items: center; justify-content: space-between;
height: 56px;
}
.navbar-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.1rem; }
.nav-links { display: flex; gap: 4px; overflow-x: auto; }
.nav-links a {
color: var(--text-secondary); text-decoration: none; padding: 6px 14px;
border-radius: 6px; font-size: 0.85rem; white-space: nowrap; transition: all 0.2s;
}
.nav-links a:hover, .nav-links a.active { color: var(--text-primary); background: var(--bg-card-hover); }
.status-badge {
display: flex; align-items: center; gap: 6px;
background: rgba(34,197,94,0.15); color: var(--accent-green);
padding: 4px 12px; border-radius: 20px; font-size: 0.8rem;
}
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent-green); animation: pulse 2s infinite; }
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
/* LAYOUT */
.container { max-width: 1200px; margin: 0 auto; padding: 76px 1.5rem 3rem; }
section { margin-bottom: 3rem; scroll-margin-top: 70px; }
.section-title {
font-size: 1.3rem; font-weight: 700; margin-bottom: 0.5rem;
padding-bottom: 0.5rem; border-bottom: 2px solid var(--accent-blue);
}
.section-desc { color: var(--text-secondary); font-size: 0.88rem; margin-bottom: 1.5rem; max-width: 800px; line-height: 1.7; }
.section-desc code { background: rgba(59,130,246,0.12); color: var(--accent-cyan); padding: 1px 6px; border-radius: 4px; font-size: 0.82rem; }
/* HERO */
.hero {
background: linear-gradient(135deg, rgba(59,130,246,0.1), rgba(168,85,247,0.08));
border: 1px solid var(--border-color); border-radius: 16px; padding: 2rem 2.5rem; margin-bottom: 2.5rem;
}
.hero h1 { font-size: 1.6rem; margin-bottom: 0.5rem; }
.hero .subtitle { color: var(--text-secondary); font-size: 0.95rem; margin-bottom: 1rem; max-width: 800px; }
.endpoint-list { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
.endpoint-tag {
background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: 8px; padding: 4px 12px; font-size: 0.78rem; color: var(--text-secondary);
}
.endpoint-tag .method { color: var(--accent-green); font-weight: 700; margin-right: 4px; }
/* CARDS & FORMS */
.card {
background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem;
}
.card h3 { font-size: 1rem; margin-bottom: 0.3rem; }
.card .card-desc { color: var(--text-muted); font-size: 0.8rem; margin-bottom: 1rem; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; }
label { display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.03em; }
input, select {
width: 100%; background: var(--bg-primary); border: 1px solid var(--border-color);
border-radius: 8px; padding: 10px 14px; color: var(--text-primary); font-size: 0.9rem; outline: none;
}
input:focus, select:focus { border-color: var(--accent-blue); }
.btn {
background: var(--accent-blue); color: #fff; border: none; padding: 10px 24px;
border-radius: 8px; cursor: pointer; font-size: 0.9rem; font-weight: 600;
transition: background 0.2s; margin-top: 1rem;
}
.btn:hover { background: #2563EB; }
/* RESULT */
.result {
background: var(--bg-primary); border: 1px solid var(--border-color);
border-radius: 8px; padding: 1rem; margin-top: 1rem; display: none;
}
.result.show { display: block; animation: fadeIn 0.3s ease-out; }
.result-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--border-color); font-size: 0.88rem; }
.result-row:last-child { border-bottom: none; }
.result-label { color: var(--text-muted); }
.result-value { font-weight: 600; font-variant-numeric: tabular-nums; }
.result-value.green { color: var(--accent-green); }
.result-value.red { color: var(--accent-red); }
.result-value.blue { color: var(--accent-blue); }
.result-value.yellow { color: var(--accent-yellow); }
.result-title { font-weight: 700; font-size: 0.9rem; margin-bottom: 0.5rem; color: var(--accent-cyan); }
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(400px,100%), 1fr)); gap: 1.5rem; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
/* SCROLLBAR */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--bg-primary); }
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; }
footer { text-align: center; color: var(--text-muted); font-size: 0.8rem; padding: 2rem 0; border-top: 1px solid var(--border-color); }
footer a { color: var(--accent-blue); text-decoration: none; }
/* TABLE */
.data-table { width: 100%; border-collapse: collapse; font-size: 0.82rem; margin-top: 0.5rem; }
.data-table th { background: var(--bg-primary); color: var(--text-muted); text-transform: uppercase; font-size: 0.72rem; padding: 8px 10px; text-align: left; border-bottom: 2px solid var(--border-color); }
.data-table td { padding: 6px 10px; border-bottom: 1px solid var(--border-color); }
/* RESPONSIVE */
@media(max-width:768px) {
.navbar { padding: 0 1rem; flex-wrap: wrap; height: auto; min-height: 56px; padding-top: 0.5rem; padding-bottom: 0.5rem; }
.navbar-brand { font-size: 0.95rem; }
.nav-links { order: 3; width: 100%; padding-bottom: 0.25rem; }
.nav-links a { padding: 4px 10px; font-size: 0.78rem; }
.status-badge { display: none; }
.container { padding: 90px 1rem 2rem; }
.hero { padding: 1.5rem; }
.hero h1 { font-size: 1.3rem; line-height: 1.3; }
.hero .subtitle { font-size: 0.85rem; }
.section-title { font-size: 1.1rem; }
.section-desc { font-size: 0.82rem; }
.card { padding: 1rem; }
.form-grid { grid-template-columns: 1fr; }
.result-row { flex-direction: column; gap: 2px; }
.data-table th, .data-table td { padding: 4px 6px; font-size: 0.72rem; }
footer { padding: 1.5rem 1rem; }
}
@media(max-width:480px) {
.hero h1 { font-size: 1.1rem; }
.endpoint-list { flex-direction: column; }
}
</style>
</head>
<body>
<nav class="navbar">
<div class="navbar-brand"><span>&#9878;</span> Tax API Chile</div>
<div class="nav-links">
<a href="#rut" class="active">RUT</a>
<a href="#iva">IVA</a>
<a href="#honorarios">Honorarios</a>
<a href="#topes">Cotizaciones</a>
<a href="#f29">F29</a>
<a href="#acerca">Acerca de</a>
</div>
<div class="status-badge"><div class="status-dot"></div>Demo interactiva</div>
</nav>
<div class="container">
<!-- HERO -->
<div class="hero">
<h1>API de Validación Tributaria — Chile</h1>
<p class="subtitle">
Microservicio REST para validación y cálculo tributario chileno. Esta página es una
<strong>demo interactiva</strong> que replica la lógica de la API con formularios funcionales.
Los cálculos se ejecutan en el navegador con la misma lógica que el backend en Python/FastAPI.
</p>
<p class="subtitle">
La API expone 11 endpoints documentados con Swagger UI, modelos Pydantic validados y
Dockerfile para containerización.
</p>
<div class="endpoint-list">
<span class="endpoint-tag"><span class="method">POST</span>/api/rut/validar</span>
<span class="endpoint-tag"><span class="method">POST</span>/api/iva/desde-neto</span>
<span class="endpoint-tag"><span class="method">POST</span>/api/iva/desde-bruto</span>
<span class="endpoint-tag"><span class="method">POST</span>/api/iva/factura</span>
<span class="endpoint-tag"><span class="method">POST</span>/api/honorarios/retencion</span>
<span class="endpoint-tag"><span class="method">POST</span>/api/honorarios/liquido</span>
<span class="endpoint-tag"><span class="method">POST</span>/api/honorarios/anual</span>
<span class="endpoint-tag"><span class="method">POST</span>/api/topes/cotizaciones</span>
<span class="endpoint-tag"><span class="method">GET</span>/api/topes/afps</span>
<span class="endpoint-tag"><span class="method">POST</span>/api/f29/simular</span>
</div>
</div>
<!-- RUT -->
<section id="rut">
<div class="section-title">&#128196; Validación de RUT</div>
<p class="section-desc">
El RUT (Rol Único Tributario) es el identificador fiscal de Chile. El dígito verificador se calcula
con el <strong>algoritmo módulo 11</strong>: se multiplican los dígitos por la serie 2-3-4-5-6-7
de derecha a izquierda, se suman y se aplica <code>11 - (suma % 11)</code>.
</p>
<div class="card">
<h3>Validar RUT</h3>
<p class="card-desc">Ingresa un RUT con o sin formato (ej: 12.345.678-5 o 123456785)</p>
<div class="form-grid">
<div><label>RUT</label><input type="text" id="rutInput" placeholder="12.345.678-5"></div>
</div>
<button class="btn" onclick="validarRut()">Validar</button>
<div class="result" id="rutResult"></div>
</div>
</section>
<!-- IVA -->
<section id="iva">
<div class="section-title">&#128176; Cálculo de IVA (19%)</div>
<p class="section-desc">
El IVA en Chile es <strong>19%</strong> sobre el monto neto. Puedes calcular el IVA desde un monto neto
(agregar IVA) o desde un monto bruto (extraer IVA incluido). Útil para facturación electrónica.
</p>
<div class="grid-2">
<div class="card">
<h3>Desde Monto Neto</h3>
<p class="card-desc">Ingresa el monto sin IVA para calcular el bruto</p>
<div class="form-grid">
<div><label>Monto Neto ($)</label><input type="number" id="ivaNetoInput" placeholder="1000000"></div>
</div>
<button class="btn" onclick="calcIvaDesdeNeto()">Calcular</button>
<div class="result" id="ivaNetoResult"></div>
</div>
<div class="card">
<h3>Desde Monto Bruto</h3>
<p class="card-desc">Ingresa el monto con IVA incluido para extraer el neto</p>
<div class="form-grid">
<div><label>Monto Bruto ($)</label><input type="number" id="ivaBrutoInput" placeholder="1190000"></div>
</div>
<button class="btn" onclick="calcIvaDesdeBruto()">Calcular</button>
<div class="result" id="ivaBrutoResult"></div>
</div>
</div>
</section>
<!-- HONORARIOS -->
<section id="honorarios">
<div class="section-title">&#128221; Retención de Honorarios</div>
<p class="section-desc">
Las boletas de honorarios en Chile tienen una retención de impuesto que varía por año:
2024: 13.75%, 2025: 14.50%, <strong>2026: 15.25%</strong>, 2027+: 17.00%.
El pagador retiene el porcentaje y lo entera al SII. El prestador recibe el líquido.
</p>
<div class="grid-2">
<div class="card">
<h3>Calcular Retención</h3>
<p class="card-desc">Desde el monto bruto de la boleta</p>
<div class="form-grid">
<div><label>Monto Bruto ($)</label><input type="number" id="honBrutoInput" placeholder="1000000"></div>
<div><label>Año</label><select id="honAnioInput"><option value="2024">2024 (13.75%)</option><option value="2025">2025 (14.50%)</option><option value="2026" selected>2026 (15.25%)</option><option value="2027">2027+ (17.00%)</option></select></div>
</div>
<button class="btn" onclick="calcRetencion()">Calcular</button>
<div class="result" id="honBrutoResult"></div>
</div>
<div class="card">
<h3>Desde Líquido Deseado</h3>
<p class="card-desc">¿Cuánto bruto necesitas para recibir X líquido?</p>
<div class="form-grid">
<div><label>Líquido Deseado ($)</label><input type="number" id="honLiquidoInput" placeholder="847500"></div>
<div><label>Año</label><select id="honLiqAnioInput"><option value="2024">2024</option><option value="2025">2025</option><option value="2026" selected>2026</option><option value="2027">2027+</option></select></div>
</div>
<button class="btn" onclick="calcDesdeLiquido()">Calcular</button>
<div class="result" id="honLiquidoResult"></div>
</div>
</div>
</section>
<!-- TOPES / COTIZACIONES -->
<section id="topes">
<div class="section-title">&#128200; Cotizaciones Previsionales</div>
<p class="section-desc">
Las cotizaciones previsionales se calculan sobre la renta bruta con <strong>topes imponibles</strong>
expresados en UF. Incluye AFP (tasa variable por administradora), Salud (7%), AFC (seguro cesantía)
y SIS (invalidez y sobrevivencia, cargo empleador).
</p>
<div class="card">
<h3>Calcular Cotizaciones</h3>
<p class="card-desc">Ingresa la renta bruta y selecciona tu AFP para ver el desglose completo</p>
<div class="form-grid">
<div><label>Renta Bruta ($)</label><input type="number" id="rentaInput" placeholder="1500000"></div>
<div><label>AFP</label><select id="afpInput"><option>Capital</option><option>Cuprum</option><option selected>Habitat</option><option>PlanVital</option><option>ProVida</option><option>Modelo</option><option>Uno</option></select></div>
<div><label>Contrato</label><select id="contratoInput"><option value="indefinido">Indefinido</option><option value="plazo_fijo">Plazo fijo</option></select></div>
<div><label>Valor UF ($)</label><input type="number" id="ufInput" value="39842" step="0.01"></div>
</div>
<button class="btn" onclick="calcCotizaciones()">Calcular</button>
<div class="result" id="cotizResult"></div>
</div>
</section>
<!-- F29 -->
<section id="f29">
<div class="section-title">&#128203; Simulador F29</div>
<p class="section-desc">
El <strong>Formulario 29</strong> es la declaración mensual de IVA al SII. Calcula el débito fiscal
(IVA de ventas) menos el crédito fiscal (IVA de compras). Si el débito supera al crédito, se paga la
diferencia. Si no, se genera un remanente que se arrastra al mes siguiente. Incluye PPM (1% sobre ventas netas).
</p>
<div class="card">
<h3>Simular Declaración F29</h3>
<p class="card-desc">Ingresa ventas y compras netas del período para ver el resultado</p>
<div class="form-grid">
<div><label>Ventas Netas ($)</label><input type="number" id="f29VentasInput" placeholder="10000000"></div>
<div><label>Compras Netas ($)</label><input type="number" id="f29ComprasInput" placeholder="6000000"></div>
<div><label>Ventas Exentas ($)</label><input type="number" id="f29ExentasInput" value="0"></div>
<div><label>Compras Activo Fijo ($)</label><input type="number" id="f29AFInput" value="0"></div>
<div><label>Remanente Anterior ($)</label><input type="number" id="f29RemanInput" value="0"></div>
<div><label>Retenciones Honorarios ($)</label><input type="number" id="f29RetInput" value="0"></div>
</div>
<button class="btn" onclick="calcF29()">Simular F29</button>
<div class="result" id="f29Result"></div>
</div>
</section>
<!-- ACERCA DE -->
<section id="acerca">
<div class="section-title">&#9881; Acerca de este Proyecto</div>
<div class="grid-2">
<div class="card" style="line-height:1.8">
<h3 style="color:var(--accent-blue);margin-bottom:0.8rem">Contexto y Objetivo</h3>
<p style="color:var(--text-secondary);font-size:0.88rem">
Este proyecto demuestra capacidades de <strong style="color:var(--text-primary)">diseño de APIs REST,
lógica de negocio tributaria y documentación automática</strong> en el contexto contable chileno.
</p>
<p style="color:var(--text-secondary);font-size:0.88rem;margin-top:0.8rem">
La API implementa 5 dominios tributarios: validación de RUT, cálculo de IVA, retención de honorarios,
topes imponibles/cotizaciones y simulador de F29. Cada endpoint tiene modelos Pydantic validados y
documentación Swagger generada automáticamente.
</p>
<p style="color:var(--text-secondary);font-size:0.88rem;margin-top:0.8rem">
Esta página es un <strong style="color:var(--text-primary)">demo estática</strong> que replica la lógica
de la API en JavaScript para funcionar sin backend en GitHub Pages.
</p>
</div>
<div class="card" style="line-height:1.8">
<h3 style="color:var(--accent-purple);margin-bottom:0.8rem">Stack Tecnológico</h3>
<div style="display:flex;flex-wrap:wrap;gap:0.6rem;margin-top:0.5rem">
<span class="endpoint-tag"><strong>Python 3.12</strong></span>
<span class="endpoint-tag"><strong>FastAPI</strong> — API REST</span>
<span class="endpoint-tag"><strong>Pydantic</strong> — validación</span>
<span class="endpoint-tag"><strong>Swagger/OpenAPI</strong> — docs</span>
<span class="endpoint-tag"><strong>pytest + httpx</strong> — tests</span>
<span class="endpoint-tag"><strong>Docker</strong> — containerización</span>
<span class="endpoint-tag"><strong>GitHub Actions</strong> — CI/CD</span>
<span class="endpoint-tag"><strong>GitHub Pages</strong> — demo</span>
</div>
<p style="color:var(--text-secondary);font-size:0.88rem;margin-top:1rem">
Los cálculos implementan normativa tributaria chilena vigente 2026: tasas de retención
según reforma tributaria, topes imponibles en UF, tasas AFP actualizadas y lógica F29
simplificada con débito/crédito fiscal y PPM.
</p>
</div>
</div>
</section>
</div>
<footer>
API de Validación Tributaria Chile &mdash;
<a href="https://github.com/mechjook" target="_blank">@mechjook</a>
&mdash; Proyecto de demostración
</footer>
<script>
// ===== HELPERS =====
const fmt = n => "$" + Math.round(n).toLocaleString("es-CL");
const pct = n => (n * 100).toFixed(2) + "%";
function showResult(id, rows) {
const el = document.getElementById(id);
el.innerHTML = rows.map(r => {
if (r.title) return `<div class="result-title">${r.title}</div>`;
return `<div class="result-row"><span class="result-label">${r.label}</span><span class="result-value ${r.cls||''}">${r.value}</span></div>`;
}).join("");
el.classList.add("show");
}
// ===== RUT =====
function calcDV(num) {
let suma = 0, mul = 2, temp = num;
while (temp > 0) { suma += (temp % 10) * mul; temp = Math.floor(temp / 10); mul++; if (mul > 7) mul = 2; }
const r = 11 - (suma % 11);
return r === 11 ? "0" : r === 10 ? "K" : String(r);
}
function validarRut() {
const raw = document.getElementById("rutInput").value.replace(/[.\-\s]/g, "").toUpperCase();
if (raw.length < 2) { showResult("rutResult", [{label:"Error",value:"RUT demasiado corto",cls:"red"}]); return; }
const cuerpo = raw.slice(0,-1), dv = raw.slice(-1);
if (!/^\d+$/.test(cuerpo)) { showResult("rutResult", [{label:"Error",value:"Cuerpo debe ser numérico",cls:"red"}]); return; }
const num = parseInt(cuerpo);
const dvEsperado = calcDV(num);
const valido = dv === dvEsperado;
const fmtRut = num.toLocaleString("es-CL") + "-" + dvEsperado;
showResult("rutResult", [
{label:"RUT formateado", value: fmtRut, cls:"blue"},
{label:"Dígito ingresado", value: dv},
{label:"Dígito esperado", value: dvEsperado},
{label:"Resultado", value: valido ? "VÁLIDO" : "INVÁLIDO", cls: valido ? "green" : "red"},
]);
}
// ===== IVA =====
function calcIvaDesdeNeto() {
const neto = parseFloat(document.getElementById("ivaNetoInput").value) || 0;
const iva = Math.round(neto * 0.19);
showResult("ivaNetoResult", [
{label:"Monto Neto", value: fmt(neto)},
{label:"IVA (19%)", value: fmt(iva), cls:"yellow"},
{label:"Monto Bruto", value: fmt(neto + iva), cls:"green"},
]);
}
function calcIvaDesdeBruto() {
const bruto = parseFloat(document.getElementById("ivaBrutoInput").value) || 0;
const neto = Math.round(bruto / 1.19);
const iva = bruto - neto;
showResult("ivaBrutoResult", [
{label:"Monto Bruto", value: fmt(bruto)},
{label:"IVA (19%)", value: fmt(iva), cls:"yellow"},
{label:"Monto Neto", value: fmt(neto), cls:"green"},
]);
}
// ===== HONORARIOS =====
const TASAS_RET = {2024:0.1375, 2025:0.145, 2026:0.1525, 2027:0.17};
function getTasa(a) { return TASAS_RET[a] || 0.17; }
function calcRetencion() {
const bruto = parseFloat(document.getElementById("honBrutoInput").value) || 0;
const anio = parseInt(document.getElementById("honAnioInput").value);
const tasa = getTasa(anio);
const ret = Math.round(bruto * tasa);
showResult("honBrutoResult", [
{label:"Monto Bruto", value: fmt(bruto)},
{label:`Retención (${pct(tasa)})`, value: fmt(ret), cls:"red"},
{label:"Monto Líquido", value: fmt(bruto - ret), cls:"green"},
]);
}
function calcDesdeLiquido() {
const liq = parseFloat(document.getElementById("honLiquidoInput").value) || 0;
const anio = parseInt(document.getElementById("honLiqAnioInput").value);
const tasa = getTasa(anio);
const bruto = Math.round(liq / (1 - tasa));
const ret = bruto - Math.round(liq);
showResult("honLiquidoResult", [
{label:"Monto Bruto necesario", value: fmt(bruto), cls:"blue"},
{label:`Retención (${pct(tasa)})`, value: fmt(ret), cls:"red"},
{label:"Líquido a recibir", value: fmt(liq), cls:"green"},
]);
}
// ===== COTIZACIONES =====
const AFP_TASAS = {Capital:0.1144,Cuprum:0.1144,Habitat:0.1127,PlanVital:0.1116,ProVida:0.1145,Modelo:0.1058,Uno:0.1069};
function calcCotizaciones() {
const renta = parseFloat(document.getElementById("rentaInput").value) || 0;
const afp = document.getElementById("afpInput").value;
const contrato = document.getElementById("contratoInput").value;
const uf = parseFloat(document.getElementById("ufInput").value) || 39842;
const topeAFP = Math.round(90 * uf);
const topeCes = Math.round(135.2 * uf);
const baseAFP = Math.min(renta, topeAFP);
const baseCes = Math.min(renta, topeCes);
const tasaAFP = AFP_TASAS[afp] || 0.1127;
const cotAFP = Math.round(baseAFP * tasaAFP);
const cotSalud = Math.round(baseAFP * 0.07);
const afcTrab = contrato === "indefinido" ? Math.round(baseCes * 0.006) : 0;
const afcEmpl = contrato === "indefinido" ? Math.round(baseCes * 0.024) : Math.round(baseCes * 0.03);
const sis = Math.round(baseAFP * 0.0154);
const totalDesc = cotAFP + cotSalud + afcTrab;
const liquido = renta - totalDesc;
showResult("cotizResult", [
{title: "Descuentos Trabajador"},
{label:`AFP ${afp} (${pct(tasaAFP)})`, value: fmt(cotAFP), cls:"red"},
{label:"Salud (7.00%)", value: fmt(cotSalud), cls:"red"},
{label:`AFC Trabajador (${contrato==="indefinido"?"0.60%":"0.00%"})`, value: fmt(afcTrab), cls:"red"},
{label:"Total descuentos", value: fmt(totalDesc), cls:"red"},
{title: "Costo Empleador"},
{label:"SIS (1.54%)", value: fmt(sis), cls:"yellow"},
{label:`AFC Empleador (${contrato==="indefinido"?"2.40%":"3.00%"})`, value: fmt(afcEmpl), cls:"yellow"},
{title: "Resultado"},
{label:"Renta Bruta", value: fmt(renta)},
{label:"Renta Líquida Estimada", value: fmt(liquido), cls:"green"},
{label:"Base AFP" + (renta > topeAFP ? " (topada)" : ""), value: fmt(baseAFP), cls: renta > topeAFP ? "yellow" : ""},
]);
}
// ===== F29 =====
function calcF29() {
const ventas = parseFloat(document.getElementById("f29VentasInput").value) || 0;
const compras = parseFloat(document.getElementById("f29ComprasInput").value) || 0;
const exentas = parseFloat(document.getElementById("f29ExentasInput").value) || 0;
const af = parseFloat(document.getElementById("f29AFInput").value) || 0;
const reman = parseFloat(document.getElementById("f29RemanInput").value) || 0;
const ret = parseFloat(document.getElementById("f29RetInput").value) || 0;
const debito = Math.round(ventas * 0.19);
const credito = Math.round(compras * 0.19) + Math.round(af * 0.19) + Math.round(reman);
const ivaDet = debito - credito;
const ivaPagar = Math.max(0, ivaDet);
const remanNuevo = ivaDet < 0 ? Math.abs(ivaDet) : 0;
const ppm = Math.round(ventas * 0.01);
let total = ivaPagar + ppm - Math.round(ret);
if (total < 0) total = 0;
showResult("f29Result", [
{title: "Débito Fiscal (Ventas)"},
{label:"Ventas netas", value: fmt(ventas)},
{label:"Débito fiscal (19%)", value: fmt(debito), cls:"red"},
{title: "Crédito Fiscal (Compras)"},
{label:"Compras netas", value: fmt(compras)},
{label:"Crédito fiscal total", value: fmt(credito), cls:"green"},
{title: "Determinación IVA"},
{label:"IVA determinado", value: fmt(ivaDet), cls: ivaDet > 0 ? "red" : "green"},
{label:"IVA a pagar", value: fmt(ivaPagar), cls: ivaPagar > 0 ? "red" : "green"},
{label:"Remanente nuevo", value: fmt(remanNuevo), cls: remanNuevo > 0 ? "blue" : ""},
{title: "PPM y Total"},
{label:"PPM (1%)", value: fmt(ppm), cls:"yellow"},
{label:"Retenciones honorarios", value: fmt(ret)},
{label:"TOTAL A PAGAR", value: fmt(total), cls: total > 0 ? "red" : "green"},
{label:"Resultado", value: total > 0 ? "A PAGAR" : "SIN PAGO", cls: total > 0 ? "red" : "green"},
]);
}
// NAV active
const sections = document.querySelectorAll("section");
const navLinks = document.querySelectorAll(".nav-links a");
window.addEventListener("scroll", () => {
let current = "";
sections.forEach(s => { if (window.scrollY >= s.offsetTop - 100) current = s.id; });
navLinks.forEach(a => a.classList.toggle("active", a.getAttribute("href") === "#" + current));
});
</script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
fastapi>=0.110.0
pydantic>=2.0.0
uvicorn>=0.27.0
httpx>=0.27.0
pytest>=7.4.0
anyio>=4.0.0
pytest-anyio>=0.0.0
View File
+226
View File
@@ -0,0 +1,226 @@
"""
API REST — FastAPI con endpoints de validación y cálculo tributario.
Endpoints:
POST /api/rut/validar — Validar un RUT chileno
POST /api/iva/desde-neto — Calcular IVA desde monto neto
POST /api/iva/desde-bruto — Calcular neto desde monto bruto
POST /api/iva/factura — Desglose de factura con items
POST /api/honorarios/retencion — Calcular retención de honorarios
POST /api/honorarios/liquido — Calcular bruto desde líquido deseado
POST /api/honorarios/anual — Simulación anual de honorarios
POST /api/topes/calcular — Calcular topes imponibles
POST /api/topes/cotizaciones — Calcular cotizaciones previsionales
GET /api/topes/afps — Listar AFPs y tasas
POST /api/f29/simular — Simular declaración F29
Swagger UI disponible en /docs
Redoc disponible en /redoc
Autor: José Nicolás Candia (@mechjook)
"""
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from src.rut import validar_rut
from src.iva import calcular_iva_desde_neto, calcular_neto_desde_bruto, desglose_factura
from src.honorarios import (
calcular_retencion_desde_bruto,
calcular_bruto_desde_liquido,
simular_honorarios_anuales,
)
from src.topes import calcular_topes, calcular_cotizaciones, listar_afps
from src.f29 import simular_f29
# --- App ---
app = FastAPI(
title="API de Validación Tributaria — Chile",
description=(
"Microservicio REST para validación y cálculo tributario chileno. "
"Incluye validación de RUT, cálculo de IVA, retención de honorarios, "
"topes imponibles/cotizaciones y simulador de F29."
),
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# --- Modelos Pydantic ---
class RutRequest(BaseModel):
rut: str = Field(..., examples=["12.345.678-5"], description="RUT a validar")
class MontoNetoRequest(BaseModel):
monto_neto: float = Field(..., gt=0, examples=[1000000], description="Monto neto (sin IVA)")
tasa: float = Field(default=0.19, ge=0, le=1, description="Tasa de IVA")
class MontoBrutoRequest(BaseModel):
monto_bruto: float = Field(..., gt=0, examples=[1190000], description="Monto bruto (con IVA)")
tasa: float = Field(default=0.19, ge=0, le=1, description="Tasa de IVA")
class FacturaItem(BaseModel):
descripcion: str = Field(..., examples=["Servicio consultoría"])
cantidad: int = Field(..., gt=0, examples=[1])
precio_unitario_neto: float = Field(..., gt=0, examples=[500000])
class FacturaRequest(BaseModel):
items: list[FacturaItem] = Field(..., min_length=1)
class HonorariosBrutoRequest(BaseModel):
monto_bruto: float = Field(..., gt=0, examples=[1000000], description="Monto bruto de la boleta")
anio: int = Field(default=2026, ge=2020, le=2030, description="Año tributario")
class HonorariosLiquidoRequest(BaseModel):
monto_liquido: float = Field(..., gt=0, examples=[847500], description="Monto líquido deseado")
anio: int = Field(default=2026, ge=2020, le=2030, description="Año tributario")
class HonorariosAnualRequest(BaseModel):
monto_mensual_bruto: float = Field(..., gt=0, examples=[1500000])
meses: int = Field(default=12, ge=1, le=12)
anio: int = Field(default=2026, ge=2020, le=2030)
class TopesRequest(BaseModel):
valor_uf: float = Field(default=39842.0, gt=0, description="Valor de la UF en pesos")
class CotizacionesRequest(BaseModel):
renta_bruta: float = Field(..., gt=0, examples=[1500000], description="Renta bruta mensual")
afp: str = Field(default="Habitat", examples=["Habitat"], description="Nombre de la AFP")
tipo_contrato: str = Field(default="indefinido", pattern="^(indefinido|plazo_fijo)$")
valor_uf: float = Field(default=39842.0, gt=0)
class F29Request(BaseModel):
ventas_netas: float = Field(..., ge=0, examples=[10000000])
compras_netas: float = Field(..., ge=0, examples=[6000000])
ventas_exentas: float = Field(default=0, ge=0)
compras_activo_fijo: float = Field(default=0, ge=0)
remanente_anterior: float = Field(default=0, ge=0)
tasa_ppm: float = Field(default=0.01, ge=0, le=0.1)
retenciones_honorarios: float = Field(default=0, ge=0)
# --- Endpoints RUT ---
@app.post("/api/rut/validar", tags=["RUT"])
def api_validar_rut(req: RutRequest):
"""Valida un RUT chileno y retorna su formato correcto."""
return validar_rut(req.rut)
# --- Endpoints IVA ---
@app.post("/api/iva/desde-neto", tags=["IVA"])
def api_iva_desde_neto(req: MontoNetoRequest):
"""Calcula IVA y monto bruto desde un monto neto."""
try:
return calcular_iva_desde_neto(req.monto_neto, req.tasa)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/iva/desde-bruto", tags=["IVA"])
def api_iva_desde_bruto(req: MontoBrutoRequest):
"""Calcula monto neto e IVA desde un monto bruto (IVA incluido)."""
try:
return calcular_neto_desde_bruto(req.monto_bruto, req.tasa)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/iva/factura", tags=["IVA"])
def api_desglose_factura(req: FacturaRequest):
"""Genera desglose completo de una factura con múltiples items."""
try:
items = [item.model_dump() for item in req.items]
return desglose_factura(items)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# --- Endpoints Honorarios ---
@app.post("/api/honorarios/retencion", tags=["Honorarios"])
def api_retencion_honorarios(req: HonorariosBrutoRequest):
"""Calcula retención y líquido desde monto bruto de boleta."""
try:
return calcular_retencion_desde_bruto(req.monto_bruto, req.anio)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/honorarios/liquido", tags=["Honorarios"])
def api_bruto_desde_liquido(req: HonorariosLiquidoRequest):
"""Calcula monto bruto necesario para recibir un líquido deseado."""
try:
return calcular_bruto_desde_liquido(req.monto_liquido, req.anio)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/honorarios/anual", tags=["Honorarios"])
def api_honorarios_anuales(req: HonorariosAnualRequest):
"""Simula honorarios anuales con retenciones acumuladas."""
try:
return simular_honorarios_anuales(req.monto_mensual_bruto, req.meses, req.anio)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# --- Endpoints Topes ---
@app.post("/api/topes/calcular", tags=["Topes Imponibles"])
def api_calcular_topes(req: TopesRequest):
"""Calcula topes imponibles en pesos según valor de UF."""
return calcular_topes(req.valor_uf)
@app.post("/api/topes/cotizaciones", tags=["Topes Imponibles"])
def api_calcular_cotizaciones(req: CotizacionesRequest):
"""Calcula cotizaciones previsionales completas de un trabajador."""
try:
return calcular_cotizaciones(req.renta_bruta, req.afp, req.tipo_contrato, req.valor_uf)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/topes/afps", tags=["Topes Imponibles"])
def api_listar_afps():
"""Lista todas las AFPs con sus tasas de cotización."""
return listar_afps()
# --- Endpoints F29 ---
@app.post("/api/f29/simular", tags=["F29"])
def api_simular_f29(req: F29Request):
"""Simula la declaración mensual del Formulario 29 (IVA + PPM)."""
try:
return simular_f29(
ventas_netas=req.ventas_netas,
compras_netas=req.compras_netas,
ventas_exentas=req.ventas_exentas,
compras_activo_fijo=req.compras_activo_fijo,
remanente_anterior=req.remanente_anterior,
tasa_ppm=req.tasa_ppm,
retenciones_honorarios=req.retenciones_honorarios,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# --- Health ---
@app.get("/api/health", tags=["Sistema"])
def health():
"""Health check del servicio."""
return {"status": "ok", "service": "tax-api", "version": "1.0.0"}
+152
View File
@@ -0,0 +1,152 @@
"""
Simulador simplificado del Formulario 29 (F29) — Declaración de IVA.
El F29 es la declaración mensual de IVA que presentan los contribuyentes
al SII (Servicio de Impuestos Internos) de Chile.
Cálculo simplificado:
- Débito Fiscal = IVA de las ventas del período
- Crédito Fiscal = IVA de las compras del período
- IVA a pagar = Débito - Crédito (si > 0)
- Remanente = Crédito - Débito (si crédito > débito, se arrastra)
PPM (Pago Provisional Mensual):
- Tasa sobre ventas netas (generalmente 1% para empresas régimen general)
Autor: José Nicolás Candia (@mechjook)
"""
TASA_IVA = 0.19
TASA_PPM_DEFAULT = 0.01 # 1% PPM general
def calcular_debito_fiscal(ventas_netas: float, ventas_exentas: float = 0) -> dict:
"""
Calcula el débito fiscal del período.
Args:
ventas_netas: Total de ventas afectas a IVA (neto)
ventas_exentas: Total de ventas exentas de IVA
Returns:
dict con ventas_netas, ventas_exentas, debito_fiscal
"""
if ventas_netas < 0:
raise ValueError("Las ventas netas no pueden ser negativas")
if ventas_exentas < 0:
raise ValueError("Las ventas exentas no pueden ser negativas")
debito = round(ventas_netas * TASA_IVA)
return {
"ventas_netas": round(ventas_netas),
"ventas_exentas": round(ventas_exentas),
"ventas_totales": round(ventas_netas + ventas_exentas),
"debito_fiscal": debito,
}
def calcular_credito_fiscal(compras_netas: float, compras_activo_fijo: float = 0) -> dict:
"""
Calcula el crédito fiscal del período.
Args:
compras_netas: Total de compras afectas a IVA (neto)
compras_activo_fijo: Compras de activo fijo con IVA recuperable
Returns:
dict con compras_netas, credito_fiscal, credito_activo_fijo
"""
if compras_netas < 0:
raise ValueError("Las compras netas no pueden ser negativas")
if compras_activo_fijo < 0:
raise ValueError("Las compras de activo fijo no pueden ser negativas")
credito = round(compras_netas * TASA_IVA)
credito_af = round(compras_activo_fijo * TASA_IVA)
return {
"compras_netas": round(compras_netas),
"compras_activo_fijo": round(compras_activo_fijo),
"credito_fiscal": credito,
"credito_activo_fijo": credito_af,
"credito_fiscal_total": credito + credito_af,
}
def simular_f29(ventas_netas: float, compras_netas: float,
ventas_exentas: float = 0,
compras_activo_fijo: float = 0,
remanente_anterior: float = 0,
tasa_ppm: float = TASA_PPM_DEFAULT,
retenciones_honorarios: float = 0) -> dict:
"""
Simula la declaración completa del F29.
Args:
ventas_netas: Ventas afectas netas del período
compras_netas: Compras afectas netas del período
ventas_exentas: Ventas exentas del período
compras_activo_fijo: Compras de activo fijo del período
remanente_anterior: Remanente de crédito fiscal del mes anterior
tasa_ppm: Tasa de PPM (default 1%)
retenciones_honorarios: Retenciones de boletas de honorarios pagadas
Returns:
dict con desglose completo del F29
"""
if ventas_netas < 0:
raise ValueError("Las ventas netas no pueden ser negativas")
if compras_netas < 0:
raise ValueError("Las compras netas no pueden ser negativas")
# Débito fiscal
debito = calcular_debito_fiscal(ventas_netas, ventas_exentas)
# Crédito fiscal
credito = calcular_credito_fiscal(compras_netas, compras_activo_fijo)
# Remanente anterior
credito_total = credito["credito_fiscal_total"] + round(remanente_anterior)
# Determinación de IVA
iva_determinado = debito["debito_fiscal"] - credito_total
if iva_determinado > 0:
iva_a_pagar = iva_determinado
remanente_nuevo = 0
else:
iva_a_pagar = 0
remanente_nuevo = abs(iva_determinado)
# PPM
ppm = round(ventas_netas * tasa_ppm)
# Total a pagar
total_a_pagar = iva_a_pagar + ppm - round(retenciones_honorarios)
if total_a_pagar < 0:
total_a_pagar = 0 # Saldo a favor
return {
"periodo": {
"ventas_netas": round(ventas_netas),
"ventas_exentas": round(ventas_exentas),
"compras_netas": round(compras_netas),
"compras_activo_fijo": round(compras_activo_fijo),
},
"debito_fiscal": debito["debito_fiscal"],
"credito_fiscal": credito["credito_fiscal_total"],
"remanente_anterior": round(remanente_anterior),
"credito_fiscal_total": credito_total,
"iva_determinado": iva_determinado,
"iva_a_pagar": iva_a_pagar,
"remanente_nuevo": remanente_nuevo,
"ppm": {
"tasa": tasa_ppm,
"tasa_porcentaje": f"{tasa_ppm * 100:.1f}%",
"monto": ppm,
},
"retenciones_honorarios": round(retenciones_honorarios),
"total_a_pagar": total_a_pagar,
"resultado": "A PAGAR" if total_a_pagar > 0 else "SIN PAGO",
}
+140
View File
@@ -0,0 +1,140 @@
"""
Simulador de Retención de Honorarios — Boletas a Terceros.
En Chile, las boletas de honorarios tienen una retención de impuesto
que ha ido variando según la reforma tributaria:
- 2024: 13.75%
- 2025: 14.50%
- 2026: 15.25%
- 2027+: 17.00% (tasa final)
El pagador retiene el porcentaje y lo entera al SII.
El prestador recibe el monto líquido (bruto - retención).
Autor: José Nicolás Candia (@mechjook)
"""
TASAS_RETENCION = {
2024: 0.1375,
2025: 0.1450,
2026: 0.1525,
2027: 0.1700,
}
def obtener_tasa_retencion(anio: int) -> float:
"""Retorna la tasa de retención vigente para el año indicado."""
if anio < 2024:
return 0.1375 # Tasa anterior
if anio >= 2027:
return 0.1700 # Tasa final
return TASAS_RETENCION.get(anio, 0.1700)
def calcular_retencion_desde_bruto(monto_bruto: float, anio: int = 2026) -> dict:
"""
Calcula la retención y líquido desde el monto bruto de la boleta.
Args:
monto_bruto: Monto total de la boleta de honorarios
anio: Año para determinar la tasa de retención
Returns:
dict con bruto, retencion, liquido, tasa
"""
if monto_bruto < 0:
raise ValueError("El monto bruto no puede ser negativo")
tasa = obtener_tasa_retencion(anio)
retencion = round(monto_bruto * tasa)
liquido = monto_bruto - retencion
return {
"monto_bruto": round(monto_bruto),
"retencion": retencion,
"monto_liquido": round(liquido),
"tasa_retencion": tasa,
"tasa_retencion_porcentaje": f"{tasa * 100:.2f}%",
"anio": anio,
}
def calcular_bruto_desde_liquido(monto_liquido: float, anio: int = 2026) -> dict:
"""
Calcula el monto bruto necesario para recibir un líquido deseado.
Fórmula: bruto = liquido / (1 - tasa)
Args:
monto_liquido: Monto líquido deseado
anio: Año para determinar la tasa de retención
Returns:
dict con bruto, retencion, liquido, tasa
"""
if monto_liquido < 0:
raise ValueError("El monto líquido no puede ser negativo")
tasa = obtener_tasa_retencion(anio)
bruto = round(monto_liquido / (1 - tasa))
retencion = bruto - round(monto_liquido)
return {
"monto_bruto": bruto,
"retencion": retencion,
"monto_liquido": round(monto_liquido),
"tasa_retencion": tasa,
"tasa_retencion_porcentaje": f"{tasa * 100:.2f}%",
"anio": anio,
}
def simular_honorarios_anuales(monto_mensual_bruto: float, meses: int = 12,
anio: int = 2026) -> dict:
"""
Simula el total anual de honorarios con retenciones acumuladas.
Args:
monto_mensual_bruto: Monto bruto mensual de la boleta
meses: Cantidad de meses a simular
anio: Año tributario
Returns:
dict con detalle mensual y totales anuales
"""
if monto_mensual_bruto < 0:
raise ValueError("El monto mensual no puede ser negativo")
if not 1 <= meses <= 12:
raise ValueError("Los meses deben estar entre 1 y 12")
tasa = obtener_tasa_retencion(anio)
detalle_mensual = []
total_bruto = 0
total_retencion = 0
total_liquido = 0
for mes in range(1, meses + 1):
retencion = round(monto_mensual_bruto * tasa)
liquido = monto_mensual_bruto - retencion
total_bruto += monto_mensual_bruto
total_retencion += retencion
total_liquido += liquido
detalle_mensual.append({
"mes": mes,
"bruto": round(monto_mensual_bruto),
"retencion": retencion,
"liquido": round(liquido),
})
return {
"detalle_mensual": detalle_mensual,
"total_bruto": round(total_bruto),
"total_retencion": round(total_retencion),
"total_liquido": round(total_liquido),
"tasa_retencion": tasa,
"tasa_retencion_porcentaje": f"{tasa * 100:.2f}%",
"anio": anio,
"meses": meses,
}
+117
View File
@@ -0,0 +1,117 @@
"""
Cálculo de IVA (Impuesto al Valor Agregado) — Chile 19%.
Funcionalidades:
- Calcular IVA desde monto neto
- Calcular neto desde monto bruto (con IVA incluido)
- Desglose completo: neto + IVA = bruto
- Cálculo con tasas especiales (exento, construcción 65%)
Autor: José Nicolás Candia (@mechjook)
"""
TASA_IVA_ESTANDAR = 0.19
TASA_IVA_CONSTRUCCION = 0.65 # 65% del IVA en crédito especial
def calcular_iva_desde_neto(monto_neto: float, tasa: float = TASA_IVA_ESTANDAR) -> dict:
"""
Calcula IVA y bruto a partir del monto neto.
Args:
monto_neto: Monto sin IVA
tasa: Tasa de IVA (default 0.19 = 19%)
Returns:
dict con neto, iva, bruto, tasa
"""
if monto_neto < 0:
raise ValueError("El monto neto no puede ser negativo")
if not 0 <= tasa <= 1:
raise ValueError("La tasa debe estar entre 0 y 1")
iva = round(monto_neto * tasa)
bruto = monto_neto + iva
return {
"monto_neto": round(monto_neto),
"iva": iva,
"monto_bruto": round(bruto),
"tasa_iva": tasa,
"tasa_iva_porcentaje": f"{tasa * 100:.0f}%",
}
def calcular_neto_desde_bruto(monto_bruto: float, tasa: float = TASA_IVA_ESTANDAR) -> dict:
"""
Calcula monto neto e IVA a partir del monto bruto (IVA incluido).
Args:
monto_bruto: Monto con IVA incluido
tasa: Tasa de IVA (default 0.19 = 19%)
Returns:
dict con neto, iva, bruto, tasa
"""
if monto_bruto < 0:
raise ValueError("El monto bruto no puede ser negativo")
if not 0 <= tasa <= 1:
raise ValueError("La tasa debe estar entre 0 y 1")
neto = round(monto_bruto / (1 + tasa))
iva = monto_bruto - neto
return {
"monto_neto": neto,
"iva": round(iva),
"monto_bruto": round(monto_bruto),
"tasa_iva": tasa,
"tasa_iva_porcentaje": f"{tasa * 100:.0f}%",
}
def desglose_factura(items: list[dict]) -> dict:
"""
Genera desglose completo de una factura con múltiples items.
Cada item debe tener: {"descripcion": str, "cantidad": int, "precio_unitario_neto": float}
Returns:
dict con items detallados, subtotal_neto, iva_total, total_bruto
"""
detalle = []
subtotal_neto = 0
for item in items:
descripcion = item.get("descripcion", "")
cantidad = item.get("cantidad", 1)
precio_unitario = item.get("precio_unitario_neto", 0)
if cantidad < 0:
raise ValueError(f"Cantidad negativa en item '{descripcion}'")
if precio_unitario < 0:
raise ValueError(f"Precio negativo en item '{descripcion}'")
neto_linea = round(cantidad * precio_unitario)
iva_linea = round(neto_linea * TASA_IVA_ESTANDAR)
detalle.append({
"descripcion": descripcion,
"cantidad": cantidad,
"precio_unitario_neto": round(precio_unitario),
"neto_linea": neto_linea,
"iva_linea": iva_linea,
"total_linea": neto_linea + iva_linea,
})
subtotal_neto += neto_linea
iva_total = round(subtotal_neto * TASA_IVA_ESTANDAR)
return {
"detalle": detalle,
"subtotal_neto": subtotal_neto,
"iva_total": iva_total,
"total_bruto": subtotal_neto + iva_total,
"tasa_iva": TASA_IVA_ESTANDAR,
"cantidad_items": len(items),
}
+133
View File
@@ -0,0 +1,133 @@
"""
Validación de RUT chileno.
El RUT (Rol Único Tributario) es el identificador fiscal de Chile.
Formato: XX.XXX.XXX-D donde D es el dígito verificador (0-9 o K).
El dígito verificador se calcula con el algoritmo módulo 11:
1. Se toman los dígitos del RUT de derecha a izquierda
2. Se multiplican por la serie 2, 3, 4, 5, 6, 7 (cíclica)
3. Se suman los productos
4. Se calcula 11 - (suma % 11)
5. Si el resultado es 11 → "0", si es 10 → "K", sino el dígito
Autor: José Nicolás Candia (@mechjook)
"""
import re
def limpiar_rut(rut: str) -> str:
"""Elimina puntos, guiones y espacios del RUT."""
return re.sub(r"[.\-\s]", "", rut.strip().upper())
def calcular_digito_verificador(numero: int) -> str:
"""Calcula el dígito verificador de un número de RUT."""
if numero < 1:
raise ValueError("El número de RUT debe ser positivo")
suma = 0
multiplicador = 2
temp = numero
while temp > 0:
suma += (temp % 10) * multiplicador
temp //= 10
multiplicador += 1
if multiplicador > 7:
multiplicador = 2
resto = 11 - (suma % 11)
if resto == 11:
return "0"
elif resto == 10:
return "K"
else:
return str(resto)
def validar_rut(rut: str) -> dict:
"""
Valida un RUT chileno completo.
Acepta formatos: 12.345.678-9, 12345678-9, 123456789
Retorna dict con:
- valido: bool
- rut_formateado: str (XX.XXX.XXX-D)
- numero: int
- digito_verificador: str
- digito_esperado: str
- mensaje: str
"""
rut_limpio = limpiar_rut(rut)
if not rut_limpio:
return {
"valido": False,
"rut_formateado": None,
"numero": None,
"digito_verificador": None,
"digito_esperado": None,
"mensaje": "RUT vacío",
}
# Separar número y dígito verificador
if len(rut_limpio) < 2:
return {
"valido": False,
"rut_formateado": None,
"numero": None,
"digito_verificador": None,
"digito_esperado": None,
"mensaje": "RUT demasiado corto",
}
cuerpo = rut_limpio[:-1]
dv_ingresado = rut_limpio[-1]
if not cuerpo.isdigit():
return {
"valido": False,
"rut_formateado": None,
"numero": None,
"digito_verificador": dv_ingresado,
"digito_esperado": None,
"mensaje": "El cuerpo del RUT debe contener solo dígitos",
}
numero = int(cuerpo)
if numero < 1_000_000 or numero > 99_999_999:
return {
"valido": False,
"rut_formateado": None,
"numero": numero,
"digito_verificador": dv_ingresado,
"digito_esperado": None,
"mensaje": "Número de RUT fuera de rango válido (1.000.000 - 99.999.999)",
}
dv_esperado = calcular_digito_verificador(numero)
es_valido = dv_ingresado == dv_esperado
# Formatear con puntos y guión
cuerpo_fmt = f"{numero:,}".replace(",", ".")
rut_formateado = f"{cuerpo_fmt}-{dv_esperado}"
return {
"valido": es_valido,
"rut_formateado": rut_formateado,
"numero": numero,
"digito_verificador": dv_ingresado,
"digito_esperado": dv_esperado,
"mensaje": "RUT válido" if es_valido else f"Dígito verificador incorrecto (esperado: {dv_esperado})",
}
def formatear_rut(rut: str) -> str:
"""Formatea un RUT a la forma XX.XXX.XXX-D."""
resultado = validar_rut(rut)
return resultado["rut_formateado"] if resultado["rut_formateado"] else rut
+151
View File
@@ -0,0 +1,151 @@
"""
Cálculo de Topes Imponibles — AFP, Salud y Seguro de Cesantía.
Los topes imponibles en Chile limitan la base sobre la cual se calculan
las cotizaciones previsionales. Se expresan en UF y se actualizan mensualmente.
Valores de referencia (Marzo 2026):
- UF: ~$39.842
- Tope AFP: 90 UF → $3.585.780
- Tope IPS: 60 UF → $2.390.520
- Tope Cesantía: 135.2 UF → $5.386.641
- Renta mínima imponible: $539.000
Tasas de cotización AFP (cargo trabajador):
- Capital: 11.44%
- Cuprum: 11.44%
- Habitat: 11.27%
- PlanVital: 11.16%
- ProVida: 11.45%
- Modelo: 10.58%
- Uno: 10.69%
Autor: José Nicolás Candia (@mechjook)
"""
# Valor UF referencial (se puede actualizar)
UF_REFERENCIA = 39842.0
# Topes en UF
TOPE_AFP_UF = 90.0
TOPE_IPS_UF = 60.0
TOPE_CESANTIA_UF = 135.2
# Renta mínima imponible
RENTA_MINIMA_IMPONIBLE = 539_000
# Tasas AFP (% cargo trabajador, incluye comisión)
TASAS_AFP = {
"Capital": {"tasa_trabajador": 0.1144, "tasa_empleador": 0.0, "sis": 0.0154},
"Cuprum": {"tasa_trabajador": 0.1144, "tasa_empleador": 0.0, "sis": 0.0154},
"Habitat": {"tasa_trabajador": 0.1127, "tasa_empleador": 0.0, "sis": 0.0154},
"PlanVital": {"tasa_trabajador": 0.1116, "tasa_empleador": 0.0, "sis": 0.0154},
"ProVida": {"tasa_trabajador": 0.1145, "tasa_empleador": 0.0, "sis": 0.0154},
"Modelo": {"tasa_trabajador": 0.1058, "tasa_empleador": 0.0, "sis": 0.0154},
"Uno": {"tasa_trabajador": 0.1069, "tasa_empleador": 0.0, "sis": 0.0154},
}
# Tasa salud (Fonasa)
TASA_SALUD = 0.07
# Tasas AFC (Seguro de Cesantía)
AFC_INDEFINIDO = {"trabajador": 0.006, "empleador": 0.024}
AFC_PLAZO_FIJO = {"trabajador": 0.0, "empleador": 0.03}
AFC_INDEFINIDO_11_ANIOS = {"trabajador": 0.006, "empleador": 0.024}
AFC_CASA_PARTICULAR = {"trabajador": 0.0, "empleador": 0.03}
def calcular_topes(valor_uf: float = UF_REFERENCIA) -> dict:
"""Calcula los topes imponibles en pesos según el valor de la UF."""
return {
"valor_uf": round(valor_uf, 2),
"tope_afp_uf": TOPE_AFP_UF,
"tope_afp_pesos": round(TOPE_AFP_UF * valor_uf),
"tope_ips_uf": TOPE_IPS_UF,
"tope_ips_pesos": round(TOPE_IPS_UF * valor_uf),
"tope_cesantia_uf": TOPE_CESANTIA_UF,
"tope_cesantia_pesos": round(TOPE_CESANTIA_UF * valor_uf),
"renta_minima_imponible": RENTA_MINIMA_IMPONIBLE,
}
def calcular_cotizaciones(renta_bruta: float, afp: str = "Habitat",
tipo_contrato: str = "indefinido",
valor_uf: float = UF_REFERENCIA) -> dict:
"""
Calcula todas las cotizaciones previsionales de un trabajador dependiente.
Args:
renta_bruta: Renta bruta mensual en pesos
afp: Nombre de la AFP
tipo_contrato: "indefinido" o "plazo_fijo"
valor_uf: Valor de la UF
Returns:
dict con desglose completo de cotizaciones
"""
if renta_bruta < 0:
raise ValueError("La renta bruta no puede ser negativa")
afp_upper = afp.strip().title()
if afp_upper not in TASAS_AFP:
raise ValueError(f"AFP no válida: {afp}. Opciones: {', '.join(TASAS_AFP.keys())}")
topes = calcular_topes(valor_uf)
# Aplicar topes
base_afp = min(renta_bruta, topes["tope_afp_pesos"])
base_salud = min(renta_bruta, topes["tope_afp_pesos"]) # Mismo tope que AFP
base_cesantia = min(renta_bruta, topes["tope_cesantia_pesos"])
# AFP
tasa_afp = TASAS_AFP[afp_upper]["tasa_trabajador"]
cotizacion_afp = round(base_afp * tasa_afp)
# SIS (Seguro de Invalidez y Sobrevivencia — cargo empleador)
sis = round(base_afp * TASAS_AFP[afp_upper]["sis"])
# Salud (7%)
cotizacion_salud = round(base_salud * TASA_SALUD)
# AFC
afc_tasas = AFC_INDEFINIDO if tipo_contrato == "indefinido" else AFC_PLAZO_FIJO
afc_trabajador = round(base_cesantia * afc_tasas["trabajador"])
afc_empleador = round(base_cesantia * afc_tasas["empleador"])
# Totales
total_descuentos_trabajador = cotizacion_afp + cotizacion_salud + afc_trabajador
total_costo_empleador = sis + afc_empleador
renta_liquida_estimada = round(renta_bruta - total_descuentos_trabajador)
return {
"renta_bruta": round(renta_bruta),
"afp": afp_upper,
"tipo_contrato": tipo_contrato,
"valor_uf": round(valor_uf, 2),
"topes": {
"base_afp": round(base_afp),
"base_salud": round(base_salud),
"base_cesantia": round(base_cesantia),
"renta_supera_tope_afp": renta_bruta > topes["tope_afp_pesos"],
"renta_supera_tope_cesantia": renta_bruta > topes["tope_cesantia_pesos"],
},
"cotizaciones": {
"afp": {"tasa": tasa_afp, "monto": cotizacion_afp, "cargo": "trabajador"},
"salud": {"tasa": TASA_SALUD, "monto": cotizacion_salud, "cargo": "trabajador"},
"afc_trabajador": {"tasa": afc_tasas["trabajador"], "monto": afc_trabajador, "cargo": "trabajador"},
"afc_empleador": {"tasa": afc_tasas["empleador"], "monto": afc_empleador, "cargo": "empleador"},
"sis": {"tasa": TASAS_AFP[afp_upper]["sis"], "monto": sis, "cargo": "empleador"},
},
"total_descuentos_trabajador": total_descuentos_trabajador,
"total_costo_empleador": total_costo_empleador,
"renta_liquida_estimada": renta_liquida_estimada,
}
def listar_afps() -> list[dict]:
"""Retorna lista de AFPs con sus tasas."""
return [
{"nombre": nombre, **tasas}
for nombre, tasas in TASAS_AFP.items()
]
View File
+340
View File
@@ -0,0 +1,340 @@
"""
Tests para la API de Validación Tributaria.
Incluye:
- Tests unitarios de lógica tributaria (RUT, IVA, honorarios, topes, F29)
- Tests de integración de endpoints con httpx AsyncClient
- Tests de validación de modelos Pydantic
Autor: José Nicolás Candia (@mechjook)
"""
import pytest
from httpx import AsyncClient, ASGITransport
from src.api import app
# ============================================================
# Tests unitarios — RUT
# ============================================================
class TestRut:
"""Tests para src/rut.py."""
def test_rut_valido(self):
from src.rut import validar_rut
result = validar_rut("12.345.678-5")
assert result["valido"] is True
assert result["digito_verificador"] == "5"
def test_rut_valido_sin_formato(self):
from src.rut import validar_rut
result = validar_rut("123456785")
assert result["valido"] is True
def test_rut_invalido(self):
from src.rut import validar_rut
result = validar_rut("12.345.678-0")
assert result["valido"] is False
assert result["digito_esperado"] == "5"
def test_rut_con_k(self):
from src.rut import validar_rut
result = validar_rut("44.444.446-0")
# Solo verificar que retorna resultado sin error
assert "valido" in result
def test_rut_vacio(self):
from src.rut import validar_rut
result = validar_rut("")
assert result["valido"] is False
assert "vacío" in result["mensaje"].lower()
def test_rut_corto(self):
from src.rut import validar_rut
result = validar_rut("5")
assert result["valido"] is False
def test_calcular_dv(self):
from src.rut import calcular_digito_verificador
assert calcular_digito_verificador(12345678) == "5"
def test_formatear_rut(self):
from src.rut import formatear_rut
assert formatear_rut("123456785") == "12.345.678-5"
# ============================================================
# Tests unitarios — IVA
# ============================================================
class TestIva:
"""Tests para src/iva.py."""
def test_iva_desde_neto(self):
from src.iva import calcular_iva_desde_neto
result = calcular_iva_desde_neto(1_000_000)
assert result["iva"] == 190_000
assert result["monto_bruto"] == 1_190_000
def test_neto_desde_bruto(self):
from src.iva import calcular_neto_desde_bruto
result = calcular_neto_desde_bruto(1_190_000)
assert result["monto_neto"] == 1_000_000
assert result["iva"] == 190_000
def test_iva_cero(self):
from src.iva import calcular_iva_desde_neto
result = calcular_iva_desde_neto(0)
assert result["iva"] == 0
def test_iva_negativo_error(self):
from src.iva import calcular_iva_desde_neto
with pytest.raises(ValueError):
calcular_iva_desde_neto(-100)
def test_desglose_factura(self):
from src.iva import desglose_factura
items = [
{"descripcion": "Item 1", "cantidad": 2, "precio_unitario_neto": 500_000},
{"descripcion": "Item 2", "cantidad": 1, "precio_unitario_neto": 300_000},
]
result = desglose_factura(items)
assert result["subtotal_neto"] == 1_300_000
assert result["iva_total"] == 247_000
assert result["total_bruto"] == 1_547_000
assert result["cantidad_items"] == 2
# ============================================================
# Tests unitarios — Honorarios
# ============================================================
class TestHonorarios:
"""Tests para src/honorarios.py."""
def test_retencion_2026(self):
from src.honorarios import calcular_retencion_desde_bruto
result = calcular_retencion_desde_bruto(1_000_000, anio=2026)
assert result["tasa_retencion"] == 0.1525
assert result["retencion"] == 152_500
assert result["monto_liquido"] == 847_500
def test_retencion_2027(self):
from src.honorarios import calcular_retencion_desde_bruto
result = calcular_retencion_desde_bruto(1_000_000, anio=2027)
assert result["tasa_retencion"] == 0.17
assert result["retencion"] == 170_000
def test_bruto_desde_liquido(self):
from src.honorarios import calcular_bruto_desde_liquido
result = calcular_bruto_desde_liquido(847_500, anio=2026)
assert result["monto_bruto"] == 1_000_000
assert result["monto_liquido"] == 847_500
def test_simulacion_anual(self):
from src.honorarios import simular_honorarios_anuales
result = simular_honorarios_anuales(1_000_000, meses=12, anio=2026)
assert result["total_bruto"] == 12_000_000
assert result["meses"] == 12
assert len(result["detalle_mensual"]) == 12
def test_honorarios_negativo_error(self):
from src.honorarios import calcular_retencion_desde_bruto
with pytest.raises(ValueError):
calcular_retencion_desde_bruto(-500_000)
# ============================================================
# Tests unitarios — Topes
# ============================================================
class TestTopes:
"""Tests para src/topes.py."""
def test_calcular_topes(self):
from src.topes import calcular_topes
result = calcular_topes(39842.0)
assert result["tope_afp_uf"] == 90.0
assert result["tope_afp_pesos"] == round(90 * 39842)
assert result["tope_cesantia_pesos"] == round(135.2 * 39842)
def test_cotizaciones_basicas(self):
from src.topes import calcular_cotizaciones
result = calcular_cotizaciones(1_500_000, afp="Habitat")
assert result["afp"] == "Habitat"
assert result["cotizaciones"]["afp"]["tasa"] == 0.1127
assert result["cotizaciones"]["salud"]["tasa"] == 0.07
assert result["renta_liquida_estimada"] < 1_500_000
def test_cotizaciones_sobre_tope(self):
from src.topes import calcular_cotizaciones
result = calcular_cotizaciones(5_000_000, afp="Habitat")
assert result["topes"]["renta_supera_tope_afp"] is True
def test_cotizaciones_afp_invalida(self):
from src.topes import calcular_cotizaciones
with pytest.raises(ValueError, match="AFP no válida"):
calcular_cotizaciones(1_000_000, afp="NoExiste")
def test_listar_afps(self):
from src.topes import listar_afps
afps = listar_afps()
assert len(afps) == 7
nombres = [a["nombre"] for a in afps]
assert "Habitat" in nombres
assert "Modelo" in nombres
# ============================================================
# Tests unitarios — F29
# ============================================================
class TestF29:
"""Tests para src/f29.py."""
def test_f29_iva_a_pagar(self):
from src.f29 import simular_f29
result = simular_f29(ventas_netas=10_000_000, compras_netas=6_000_000)
debito = round(10_000_000 * 0.19)
credito = round(6_000_000 * 0.19)
assert result["debito_fiscal"] == debito
assert result["credito_fiscal"] == credito
assert result["iva_a_pagar"] == debito - credito
assert result["resultado"] == "A PAGAR"
def test_f29_remanente(self):
from src.f29 import simular_f29
result = simular_f29(ventas_netas=2_000_000, compras_netas=8_000_000)
assert result["iva_a_pagar"] == 0
assert result["remanente_nuevo"] > 0
def test_f29_con_remanente_anterior(self):
from src.f29 import simular_f29
result = simular_f29(ventas_netas=5_000_000, compras_netas=3_000_000,
remanente_anterior=200_000)
credito_total = round(3_000_000 * 0.19) + 200_000
assert result["credito_fiscal_total"] == credito_total
def test_f29_ppm(self):
from src.f29 import simular_f29
result = simular_f29(ventas_netas=10_000_000, compras_netas=0)
assert result["ppm"]["monto"] == 100_000
assert result["ppm"]["tasa"] == 0.01
def test_f29_ventas_negativas_error(self):
from src.f29 import simular_f29
with pytest.raises(ValueError):
simular_f29(ventas_netas=-1, compras_netas=0)
# ============================================================
# Tests de integración — Endpoints API
# ============================================================
@pytest.fixture
def transport():
return ASGITransport(app=app)
class TestApiEndpoints:
"""Tests de endpoints con httpx."""
@pytest.mark.anyio
async def test_health(self, transport):
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/api/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
@pytest.mark.anyio
async def test_validar_rut_endpoint(self, transport):
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.post("/api/rut/validar", json={"rut": "12.345.678-5"})
assert r.status_code == 200
assert r.json()["valido"] is True
@pytest.mark.anyio
async def test_iva_desde_neto_endpoint(self, transport):
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.post("/api/iva/desde-neto", json={"monto_neto": 1000000})
assert r.status_code == 200
assert r.json()["iva"] == 190000
@pytest.mark.anyio
async def test_iva_desde_bruto_endpoint(self, transport):
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.post("/api/iva/desde-bruto", json={"monto_bruto": 1190000})
assert r.status_code == 200
assert r.json()["monto_neto"] == 1000000
@pytest.mark.anyio
async def test_factura_endpoint(self, transport):
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.post("/api/iva/factura", json={
"items": [{"descripcion": "Test", "cantidad": 1, "precio_unitario_neto": 500000}]
})
assert r.status_code == 200
assert r.json()["subtotal_neto"] == 500000
@pytest.mark.anyio
async def test_honorarios_retencion_endpoint(self, transport):
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.post("/api/honorarios/retencion", json={"monto_bruto": 1000000, "anio": 2026})
assert r.status_code == 200
assert r.json()["retencion"] == 152500
@pytest.mark.anyio
async def test_honorarios_liquido_endpoint(self, transport):
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.post("/api/honorarios/liquido", json={"monto_liquido": 847500, "anio": 2026})
assert r.status_code == 200
assert r.json()["monto_bruto"] == 1000000
@pytest.mark.anyio
async def test_honorarios_anual_endpoint(self, transport):
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.post("/api/honorarios/anual", json={"monto_mensual_bruto": 1000000, "meses": 12})
assert r.status_code == 200
assert r.json()["total_bruto"] == 12000000
@pytest.mark.anyio
async def test_topes_endpoint(self, transport):
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.post("/api/topes/calcular", json={"valor_uf": 39842})
assert r.status_code == 200
assert r.json()["tope_afp_uf"] == 90.0
@pytest.mark.anyio
async def test_cotizaciones_endpoint(self, transport):
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.post("/api/topes/cotizaciones", json={"renta_bruta": 1500000, "afp": "Habitat"})
assert r.status_code == 200
assert r.json()["afp"] == "Habitat"
@pytest.mark.anyio
async def test_afps_endpoint(self, transport):
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/api/topes/afps")
assert r.status_code == 200
assert len(r.json()) == 7
@pytest.mark.anyio
async def test_f29_endpoint(self, transport):
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.post("/api/f29/simular", json={"ventas_netas": 10000000, "compras_netas": 6000000})
assert r.status_code == 200
assert r.json()["resultado"] == "A PAGAR"
@pytest.mark.anyio
async def test_validation_error(self, transport):
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.post("/api/iva/desde-neto", json={"monto_neto": -100})
assert r.status_code == 422 # Pydantic validation error
@pytest.mark.anyio
async def test_cotizaciones_afp_invalida_endpoint(self, transport):
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.post("/api/topes/cotizaciones", json={"renta_bruta": 1000000, "afp": "FakeAFP"})
assert r.status_code == 400
+27
View File
@@ -69,6 +69,9 @@
<i data-lucide="award" style="width:16px;"></i> Cartera Talento <i data-lucide="award" style="width:16px;"></i> Cartera Talento
</a> </a>
{% if 'Administrador Finanzas' in request.user.groups.all.0.name or 'Administrador' in request.user.groups.all.0.name or request.user.is_superuser %} {% if 'Administrador Finanzas' in request.user.groups.all.0.name or 'Administrador' in request.user.groups.all.0.name or request.user.is_superuser %}
<a href="{% url 'f29_simulator' %}" class="nav-tab {% if request.resolver_match.url_name == 'f29_simulator' %}active{% endif %}">
<i data-lucide="calculator"></i> Simulador F29
</a>
<a href="{% url 'accounts_payable' %}" class="nav-tab {% if request.resolver_match.url_name == 'accounts_payable' %}active{% endif %}"> <a href="{% url 'accounts_payable' %}" class="nav-tab {% if request.resolver_match.url_name == 'accounts_payable' %}active{% endif %}">
<i data-lucide="calculator" style="width:16px;"></i> Cuentas por Pagar (CXP) <i data-lucide="calculator" style="width:16px;"></i> Cuentas por Pagar (CXP)
</a> </a>
@@ -167,5 +170,29 @@
}); });
</script> </script>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
<!-- Modal Vista Previa -->
<div id="previewModal" style="display:none; position:fixed; z-index:9999; left:0; top:0; width:100%; height:100%; overflow:auto; background-color:rgba(0,0,0,0.8); align-items:center; justify-content:center;">
<div style="background:#fff; margin:auto; padding:20px; border-radius:8px; width:80%; max-width:800px; max-height:90vh; display:flex; flex-direction:column; position:relative;">
<span onclick="closePreview()" style="position:absolute; right:15px; top:10px; font-size:24px; cursor:pointer; font-weight:bold;">&times;</span>
<h3 id="previewTitle" style="margin-top:0;">Vista Previa</h3>
<div id="previewContent" style="flex:1; overflow:hidden; display:flex; justify-content:center; align-items:center; min-height:400px;"></div>
</div>
</div>
<script>
function openPreview(url, title) {
document.getElementById("previewTitle").innerText = "Evidencia: " + title;
let contentDiv = document.getElementById("previewContent");
if(url.toLowerCase().endsWith(".pdf")) {
contentDiv.innerHTML = "<iframe src=\"" + url + "\" style=\"width:100%; height:100%; min-height:600px; border:none;\"></iframe>";
} else {
contentDiv.innerHTML = "<img src=\"" + url + "\" style=\"max-width:100%; max-height:600px; object-fit:contain;\">";
}
document.getElementById("previewModal").style.display = "flex";
}
function closePreview() {
document.getElementById("previewModal").style.display = "none";
document.getElementById("previewContent").innerHTML = "";
}
</script>
</body> </body>
</html> </html>
+12 -192
View File
@@ -129,23 +129,23 @@
<div class="form-group" style="margin-bottom: 1.5rem; background: #f8fafc; padding: 1.5rem; border-radius: 6px; border: 1px solid var(--efintel-border);"> <div class="form-group" style="margin-bottom: 1.5rem; background: #f8fafc; padding: 1.5rem; border-radius: 6px; border: 1px solid var(--efintel-border);">
<label style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-petroleum); display:flex; align-items:center; gap:0.3rem; margin-bottom: 0.75rem;"><i data-lucide="paperclip" style="width:14px;"></i> Evidencias Adjuntas (Boletas / Facturas)</label> <label style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-petroleum); display:flex; align-items:center; gap:0.3rem; margin-bottom: 0.75rem;"><i data-lucide="paperclip" style="width:14px;"></i> Evidencias Adjuntas (Boletas / Facturas)</label>
<!-- Drag and Drop Zone Container -->
<div id="drop-zone" style="border: 2px dashed var(--efintel-blue); border-radius: 6px; padding: 2rem; text-align: center; background: white; cursor: pointer; transition: all 0.3s; margin-bottom: 0.75rem; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<i data-lucide="upload-cloud" style="width: 36px; height: 36px; color: var(--efintel-blue); margin-bottom: 0.5rem; transition: transform 0.3s;"></i>
<p style="margin: 0; font-size: 0.85rem; font-weight: 600; color: var(--efintel-petroleum);">Arrastre sus boletas/facturas aquí o haga clic para buscar</p>
<span style="font-size: 0.7rem; color: var(--efintel-gray); display: block; margin-top: 0.25rem;">Formatos aceptados: PDF, PNG, JPG, JPEG (Múltiple)</span>
</div>
<!-- Hidden native input (Visually hidden for Safari compatibility) --> <!-- Simple & Reliable File Upload -->
<div style="width: 0; height: 0; overflow: hidden; opacity: 0; position: absolute; z-index: -1;"> <div style="margin-bottom: 0.75rem;">
{{ form.receipt }} <label for="id_receipt" style="display:block; font-size: 0.85rem; font-weight: 600; color: var(--efintel-petroleum); margin-bottom: 0.5rem;">
Adjuntar Evidencia(s) (Boleta / Factura)
</label>
<div style="border: 2px dashed var(--efintel-blue); border-radius: 6px; padding: 1.5rem; background: #f8fafc;">
{{ form.receipt }}
<div style="margin-top: 0.5rem; font-size: 0.7rem; color: var(--efintel-gray);">
Formatos aceptados: PDF, PNG, JPG, JPEG. Puede seleccionar múltiples archivos manteniendo presionada la tecla Ctrl (o Cmd en Mac).
</div>
</div>
</div> </div>
<!-- Live Queue files list -->
<div id="new-files-list" style="margin-top: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem;"></div>
{% if form.receipt.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.receipt.errors }}</div>{% endif %} {% if form.receipt.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.receipt.errors }}</div>{% endif %}
{% if form.instance.pk and form.instance.documents.all %} {% if form.instance.pk and form.instance.documents.all %}
<div style="margin-top: 1.25rem; border-top: 1px solid #e2e8f0; padding-top: 0.75rem;"> <div style="margin-top: 1.25rem; border-top: 1px solid #e2e8f0; padding-top: 0.75rem;">
<strong style="font-size: 0.8rem; color: var(--efintel-petroleum); display: block; margin-bottom: 0.5rem;">Documentos Adjuntos Actuales:</strong> <strong style="font-size: 0.8rem; color: var(--efintel-petroleum); display: block; margin-bottom: 0.5rem;">Documentos Adjuntos Actuales:</strong>
@@ -612,186 +612,6 @@
}); });
} }
// Client-side Upload File Deletion Queue using DataTransfer + Drag and Drop
const fileInput = document.getElementById('id_receipt');
const newFilesList = document.getElementById('new-files-list');
const dropZone = document.getElementById('drop-zone');
if (fileInput && newFilesList && dropZone) {
let dt = new DataTransfer();
// Clicking the drop zone triggers the hidden file input
dropZone.addEventListener('click', () => {
fileInput.click();
});
// Highlight drop zone on drag hover
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.style.border = '2px dashed var(--efintel-blue)';
dropZone.style.background = 'var(--efintel-light)';
const icon = dropZone.querySelector('i');
if (icon) icon.style.transform = 'scale(1.2)';
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.style.border = '2px dashed var(--efintel-blue)';
dropZone.style.background = 'white';
const icon = dropZone.querySelector('i');
if (icon) icon.style.transform = 'scale(1)';
}, false);
});
// Handle dropped files
dropZone.addEventListener('drop', (e) => {
const files = e.dataTransfer.files;
if (files.length > 0) {
for (let i = 0; i < files.length; i++) {
const f = files[i];
// Validate file type before putting in queue (extensions: pdf, png, jpg, jpeg)
const ext = f.name.split('.').pop().toLowerCase();
if (['pdf', 'png', 'jpg', 'jpeg'].includes(ext)) {
dt.items.add(f);
} else {
Swal.fire({
icon: 'error',
title: 'Archivo no permitido',
text: `El archivo "${f.name}" no es una evidencia válida (PDF, PNG, JPG, JPEG).`,
confirmButtonColor: 'var(--efintel-blue)'
});
}
}
fileInput.files = dt.files;
renderNewFiles();
}
});
// Handle standard file selection
fileInput.addEventListener('change', () => {
let hasError = false;
for (let i = 0; i < fileInput.files.length; i++) {
const f = fileInput.files[i];
const ext = f.name.split('.').pop().toLowerCase();
if (['pdf', 'png', 'jpg', 'jpeg'].includes(ext)) {
dt.items.add(f);
} else {
hasError = true;
Swal.fire({
icon: 'error',
title: 'Archivo no permitido',
text: `El archivo "${f.name}" no es una evidencia válida (PDF, PNG, JPG, JPEG).`,
confirmButtonColor: 'var(--efintel-blue)'
});
}
}
fileInput.files = dt.files;
renderNewFiles();
// Si hubo error, resetear el input nativo temporalmente para evitar que se envíe el archivo inválido
if (hasError && dt.files.length === 0) {
fileInput.value = '';
}
});
function renderNewFiles() {
newFilesList.innerHTML = '';
if (dt.files.length > 0) {
const header = document.createElement('strong');
header.style.fontSize = '0.75rem';
header.style.color = 'var(--efintel-blue)';
header.style.display = 'block';
header.style.marginBottom = '0.35rem';
header.textContent = 'Archivos en cola para subir:';
newFilesList.appendChild(header);
}
Array.from(dt.files).forEach((file, index) => {
const row = document.createElement('div');
row.style.display = 'flex';
row.style.justifyContent = 'space-between';
row.style.alignItems = 'center';
row.style.background = 'var(--efintel-light)';
row.style.padding = '0.45rem 0.75rem';
row.style.borderRadius = '4px';
row.style.fontSize = '0.75rem';
row.style.border = '1px solid var(--efintel-border)';
row.style.marginBottom = '0.3rem';
// File Detail Info with preview
const infoWrapper = document.createElement('div');
infoWrapper.style.display = 'flex';
infoWrapper.style.alignItems = 'center';
infoWrapper.style.gap = '0.5rem';
// Check file extension
const ext = file.name.split('.').pop().toLowerCase();
if (['png', 'jpg', 'jpeg'].includes(ext)) {
// Render image thumbnail
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.style.width = '24px';
img.style.height = '24px';
img.style.objectFit = 'cover';
img.style.borderRadius = '3px';
img.style.border = '1px solid #cbd5e1';
img.onload = () => URL.revokeObjectURL(img.src); // Free memory
infoWrapper.appendChild(img);
} else if (ext === 'pdf') {
// Render PDF file icon
const pdfIcon = document.createElement('span');
pdfIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>`;
infoWrapper.appendChild(pdfIcon);
} else {
const defaultIcon = document.createElement('span');
defaultIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--efintel-blue)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg>`;
infoWrapper.appendChild(defaultIcon);
}
const nameSpan = document.createElement('span');
nameSpan.style.color = 'var(--efintel-petroleum)';
nameSpan.style.fontWeight = '600';
nameSpan.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
infoWrapper.appendChild(nameSpan);
row.appendChild(infoWrapper);
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.style.background = 'none';
deleteBtn.style.border = 'none';
deleteBtn.style.color = 'var(--efintel-danger)';
deleteBtn.style.cursor = 'pointer';
deleteBtn.style.padding = '0.2rem';
deleteBtn.style.display = 'flex';
deleteBtn.style.alignItems = 'center';
deleteBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`;
deleteBtn.addEventListener('click', (e) => {
e.preventDefault();
const newDt = new DataTransfer();
for (let i = 0; i < dt.files.length; i++) {
if (i !== index) {
newDt.items.add(dt.files[i]);
}
}
dt = newDt;
fileInput.files = dt.files;
renderNewFiles();
});
row.appendChild(deleteBtn);
newFilesList.appendChild(row);
});
}
}
// Toggle logic for unregistered vendor inputs
const toggleNewVendor = document.getElementById('toggle-new-vendor'); const toggleNewVendor = document.getElementById('toggle-new-vendor');
const newVendorFields = document.getElementById('new-vendor-fields'); const newVendorFields = document.getElementById('new-vendor-fields');
const originalVendorSelect = document.getElementById('id_vendor'); const originalVendorSelect = document.getElementById('id_vendor');
+99
View File
@@ -0,0 +1,99 @@
{% extends 'base.html' %}
{% load humanize %}
{% block content %}
<section class="page-view active">
<div class="view-header" style="margin-bottom: 2rem;">
<div class="view-title">
<h2 style="margin:0; color:var(--efintel-petroleum); display:flex; align-items:center; gap:0.5rem;"><i data-lucide="calculator" style="color:var(--efintel-blue);"></i> Simulador F29 (IVA y Retenciones)</h2>
<p style="margin-top:0.25rem; font-size:0.9rem; color:var(--efintel-gray);">Calcula la declaración mensual conectándose a la Tax API, utilizando gastos y compras pagadas del mes.</p>
</div>
</div>
<div class="data-card" style="margin-bottom: 2rem;">
<form method="get" id="f29-form" style="display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap;">
<div class="form-group" style="margin-bottom: 0;">
<label style="font-size: 0.8rem; font-weight: 600;">Mes</label>
<select name="month" class="form-control" onchange="this.form.submit()">
{% for m in months %}
<option value="{{ m }}" {% if m == month %}selected{% endif %}>{{ m }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom: 0;">
<label style="font-size: 0.8rem; font-weight: 600;">Año</label>
<select name="year" class="form-control" onchange="this.form.submit()">
{% for y in years %}
<option value="{{ y }}" {% if y == year %}selected{% endif %}>{{ y }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom: 0;">
<label style="font-size: 0.8rem; font-weight: 600;">Ventas Netas Proyectadas (CLP)</label>
<input type="number" name="ventas_netas" class="form-control" value="{{ ventas_netas|stringformat:"i" }}" style="width: 200px;">
</div>
<div class="form-group" style="margin-bottom: 0;">
<label style="font-size: 0.8rem; font-weight: 600;">Ventas Exentas (CLP)</label>
<input type="number" name="ventas_exentas" class="form-control" value="{{ ventas_exentas|stringformat:"i" }}" style="width: 150px;">
</div>
<div class="form-group" style="margin-bottom: 0;">
<label style="font-size: 0.8rem; font-weight: 600;">Tasa PPM (ej: 0.01 = 1%)</label>
<input type="number" step="0.001" name="tasa_ppm" class="form-control" value="{{ tasa_ppm }}" style="width: 100px;">
</div>
<button type="submit" class="btn-primary" style="height: 42px;"><i data-lucide="refresh-cw" style="width:16px;"></i> Simular</button>
</form>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem;">
<div class="data-card">
<h3 style="margin-top:0; color:var(--efintel-petroleum); font-size:1.1rem; border-bottom: 1px solid var(--efintel-border); padding-bottom: 0.5rem;"><i data-lucide="database"></i> Datos Obtenidos del ERP</h3>
<ul style="list-style: none; padding: 0; margin-top: 1rem;">
<li style="display:flex; justify-content:space-between; padding: 0.5rem 0; border-bottom: 1px dashed var(--efintel-border);">
<span style="color:var(--efintel-gray);">Total Compras Netas (Pagadas):</span>
<strong style="font-family:var(--font-mono);">${{ compras_netas|floatformat:"0"|intcomma }}</strong>
</li>
<li style="display:flex; justify-content:space-between; padding: 0.5rem 0; border-bottom: 1px dashed var(--efintel-border);">
<span style="color:var(--efintel-gray);">Total Retenciones Boletas (Pagadas):</span>
<strong style="font-family:var(--font-mono);">${{ retenciones_honorarios|floatformat:"0"|intcomma }}</strong>
</li>
</ul>
<div style="margin-top: 1rem; font-size: 0.8rem; color:var(--efintel-gray); background: #f8fafc; padding: 0.8rem; border-radius: 4px;">
<i data-lucide="info" style="width:14px; vertical-align:middle;"></i> Sólo se incluyen Órdenes de Compra y Erogaciones en estado <strong>LIBERADO_PAGADO</strong>.
</div>
</div>
<div class="data-card" style="background: #f0fdf4; border: 1px solid #bbf7d0;">
<h3 style="margin-top:0; color:var(--efintel-success); font-size:1.1rem; border-bottom: 1px solid #bbf7d0; padding-bottom: 0.5rem;"><i data-lucide="file-check-2"></i> Resultado Simulación F29</h3>
<ul style="list-style: none; padding: 0; margin-top: 1rem;">
<li style="display:flex; justify-content:space-between; padding: 0.5rem 0; border-bottom: 1px dashed #bbf7d0;">
<span>Débito Fiscal (IVA Ventas):</span>
<strong style="font-family:var(--font-mono);">${{ simulation.iva_debito|floatformat:"0"|intcomma }}</strong>
</li>
<li style="display:flex; justify-content:space-between; padding: 0.5rem 0; border-bottom: 1px dashed #bbf7d0;">
<span>Crédito Fiscal (IVA Compras):</span>
<strong style="font-family:var(--font-mono);">${{ simulation.iva_credito|floatformat:"0"|intcomma }}</strong>
</li>
<li style="display:flex; justify-content:space-between; padding: 0.5rem 0; border-bottom: 1px dashed #bbf7d0;">
<span style="color:var(--efintel-petroleum);">PPM a Pagar:</span>
<strong style="font-family:var(--font-mono);">${{ simulation.ppm_a_pagar|floatformat:"0"|intcomma }}</strong>
</li>
<li style="display:flex; justify-content:space-between; padding: 0.5rem 0; border-bottom: 1px dashed #bbf7d0;">
<span style="color:var(--efintel-petroleum);">Retenciones a Pagar:</span>
<strong style="font-family:var(--font-mono);">${{ simulation.total_retenciones_honorarios|floatformat:"0"|intcomma }}</strong>
</li>
<li style="display:flex; justify-content:space-between; padding: 0.5rem 0; border-bottom: 1px dashed #bbf7d0;">
<span style="color:var(--efintel-gray);">Remanente Siguiente Mes:</span>
<strong style="font-family:var(--font-mono);">${{ simulation.remanente_siguiente_mes|floatformat:"0"|intcomma }}</strong>
</li>
<li style="display:flex; justify-content:space-between; padding: 1rem 0 0 0; margin-top: 0.5rem; font-size: 1.2rem;">
<strong style="color:var(--efintel-petroleum);">Total A Pagar F29:</strong>
<strong style="font-family:var(--font-mono); color:var(--efintel-success);">${{ simulation.total_a_pagar|floatformat:"0"|intcomma }}</strong>
</li>
</ul>
<div style="margin-top: 1rem; font-size: 0.75rem; color:var(--efintel-gray);">
<i data-lucide="check-circle" style="width:12px; vertical-align:middle;"></i> {{ simulation.nota|default:"Cálculo validado correctamente." }}
</div>
</div>
</div>
</section>
{% endblock %}
+5 -2
View File
@@ -14,8 +14,11 @@
<div style="margin-top: 0.25rem; display: flex; flex-direction: column; gap: 0.2rem;"> <div style="margin-top: 0.25rem; display: flex; flex-direction: column; gap: 0.2rem;">
{% for doc in expense.documents.all %} {% for doc in expense.documents.all %}
<div style="display:inline-flex; align-items:center; gap:0.3rem;"> <div style="display:inline-flex; align-items:center; gap:0.3rem;">
<a href="{{ doc.file.url }}" target="_blank" style="font-size:0.65rem; color:var(--efintel-blue); text-decoration:none;" title="{{ doc.title|default:doc.file.name }}"> <a href="javascript:void(0);" onclick="openPreview('{{ doc.file.url }}', '{{ doc.title|default:doc.file.name|escapejs }}')" style="font-size:0.65rem; color:var(--efintel-blue); text-decoration:none;" title="Ver Evidencia">
<i data-lucide="paperclip" style="width:10px;"></i> {{ doc.title|default:doc.file.name|truncatechars:20 }} <i data-lucide="eye" style="width:10px;"></i> {{ doc.title|default:doc.file.name|truncatechars:20 }}
</a>
<a href="{{ doc.file.url }}" target="_blank" style="font-size:0.65rem; color:var(--efintel-gray);" title="Descargar">
<i data-lucide="download" style="width:10px;"></i>
</a> </a>
</div> </div>
{% endfor %} {% endfor %}