This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
[submodule "tax_api"]
|
||||
path = tax_api
|
||||
url = https://github.com/mechjook/DemostrationTaxAPI.git
|
||||
-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
|
||||
Reference in New Issue
Block a user