Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b9a8d5ee8 | ||
|
|
a3e9d4c2a8 | ||
|
|
f0a4fa64e8 | ||
|
|
522d5337f4 | ||
|
|
acb3a1eaad | ||
|
|
53733a9800 | ||
|
|
1a4f8cd630 | ||
|
|
1bfecb8f60 | ||
|
|
b9e676bed4 | ||
|
|
63d1b87506 |
@@ -1,3 +0,0 @@
|
|||||||
[submodule "tax_api"]
|
|
||||||
path = tax_api
|
|
||||||
url = https://github.com/mechjook/DemostrationTaxAPI.git
|
|
||||||
+1
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
+17
-20
@@ -1,14 +1,15 @@
|
|||||||
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
|
||||||
@@ -16,18 +17,17 @@ services:
|
|||||||
- 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
|
||||||
@@ -36,7 +36,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
mem_limit: 512m
|
mem_limit: 512m
|
||||||
|
|
||||||
celery:
|
celery:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -50,22 +49,20 @@ services:
|
|||||||
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:
|
||||||
|
- controlmc_proxy
|
||||||
- internal
|
- 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
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -186,27 +186,61 @@ 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
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
@@ -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.")
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
-1
Submodule tax_api deleted from 60c958e96b
+57
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.pytest_cache/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
[](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)
|
||||||
@@ -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>⚖</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">📄 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">💰 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">📝 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">📈 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">📋 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">⚙ 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 —
|
||||||
|
<a href="https://github.com/mechjook" target="_blank">@mechjook</a>
|
||||||
|
— 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>
|
||||||
@@ -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
|
||||||
@@ -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"}
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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;">×</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>
|
||||||
|
|||||||
@@ -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;">
|
||||||
|
<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 }}
|
{{ 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');
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user