diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 50c6e15..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "tax_api"] - path = tax_api - url = https://github.com/mechjook/DemostrationTaxAPI.git diff --git a/tax_api b/tax_api deleted file mode 160000 index 60c958e..0000000 --- a/tax_api +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 60c958e96b66d4dc660948d0b012fab1f2a2571f diff --git a/tax_api/.github/workflows/tax_api_pipeline.yml b/tax_api/.github/workflows/tax_api_pipeline.yml new file mode 100644 index 0000000..be44f2c --- /dev/null +++ b/tax_api/.github/workflows/tax_api_pipeline.yml @@ -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 diff --git a/tax_api/.gitignore b/tax_api/.gitignore new file mode 100644 index 0000000..b94f187 --- /dev/null +++ b/tax_api/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +*.egg-info/ +dist/ +build/ +.venv/ +venv/ +.env diff --git a/tax_api/Dockerfile b/tax_api/Dockerfile new file mode 100644 index 0000000..4f9a1cc --- /dev/null +++ b/tax_api/Dockerfile @@ -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"] diff --git a/tax_api/README.md b/tax_api/README.md new file mode 100644 index 0000000..3e1a65f --- /dev/null +++ b/tax_api/README.md @@ -0,0 +1,93 @@ +# API de Validación Tributaria — Chile + +Microservicio REST para validación y cálculo tributario chileno. Incluye validación de RUT, cálculo de IVA, retención de honorarios, topes imponibles/cotizaciones y simulador de F29. Con Swagger UI, Dockerfile y demo interactiva en GitHub Pages. + +[![Tax API Pipeline](https://github.com/mechjook/DemostrationTaxAPI/actions/workflows/tax_api_pipeline.yml/badge.svg)](https://github.com/mechjook/DemostrationTaxAPI/actions/workflows/tax_api_pipeline.yml) + +## Demo + +Disponible en: **[GitHub Pages](https://mechjook.github.io/DemostrationTaxAPI/)** + +## Arquitectura + +``` +┌────────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ FastAPI + Swagger │───▶│ Lógica │───▶│ Modelos │ +│ 11 endpoints REST │ │ Tributaria │ │ Pydantic │ +└────────────────────┘ └──────────────────┘ └──────────────────┘ + │ + ▼ +┌────────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ Demo HTML │ │ Dockerfile │ │ Tests │ +│ GitHub Pages │ │ Containerización │ │ pytest + httpx │ +└────────────────────┘ └──────────────────┘ └──────────────────┘ +``` + +## Endpoints + +| Método | Endpoint | Descripción | +|--------|----------|-------------| +| POST | `/api/rut/validar` | Validar RUT chileno (dígito verificador módulo 11) | +| POST | `/api/iva/desde-neto` | Calcular IVA y bruto desde monto neto | +| POST | `/api/iva/desde-bruto` | Extraer neto e IVA desde monto bruto | +| POST | `/api/iva/factura` | Desglose completo de factura con items | +| POST | `/api/honorarios/retencion` | Calcular retención desde monto bruto | +| POST | `/api/honorarios/liquido` | Calcular bruto necesario desde líquido | +| POST | `/api/honorarios/anual` | Simulación anual de honorarios | +| POST | `/api/topes/calcular` | Topes imponibles en pesos según UF | +| POST | `/api/topes/cotizaciones` | Cotizaciones previsionales completas | +| GET | `/api/topes/afps` | Listar AFPs con tasas | +| POST | `/api/f29/simular` | Simulador de declaración F29 | + +## Dominios Tributarios + +| Dominio | Descripción | +|---------|-------------| +| **RUT** | Validación con algoritmo módulo 11, formateo, detección de errores | +| **IVA** | Tasa 19%, cálculo neto↔bruto, desglose de facturas | +| **Honorarios** | Retención 2024-2027 (13.75%→17%), cálculo inverso, simulación anual | +| **Topes** | AFP (90 UF), salud (7%), AFC, SIS. 7 AFPs con tasas actualizadas | +| **F29** | Débito/crédito fiscal, remanente, PPM (1%), retenciones | + +## Ejecución Local + +```bash +pip install -r requirements.txt + +# Ejecutar API +uvicorn src.api:app --reload + +# Swagger UI +open http://localhost:8000/docs +``` + +## Docker + +```bash +docker build -t tax-api . +docker run -p 8000:8000 tax-api +``` + +## Tests + +```bash +pytest tests/ -v +``` + +## CI/CD + +El workflow de GitHub Actions ejecuta: +1. **Tests** — pytest con tests unitarios + integración de endpoints +2. **Deploy** — publica la demo interactiva en GitHub Pages (solo en `main`) + +## Stack + +- Python 3.12 +- FastAPI / Pydantic +- pytest / httpx +- Docker +- GitHub Actions + GitHub Pages + +## Autor + +**José Nicolás Candia** — [@mechjook](https://github.com/mechjook) diff --git a/tax_api/docs/index.html b/tax_api/docs/index.html new file mode 100644 index 0000000..1ced6bd --- /dev/null +++ b/tax_api/docs/index.html @@ -0,0 +1,540 @@ + + + + + + API de Validación Tributaria — Chile + + + + + + +
+ + +
+

API de Validación Tributaria — Chile

+

+ Microservicio REST para validación y cálculo tributario chileno. Esta página es una + demo interactiva 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. +

+

+ La API expone 11 endpoints documentados con Swagger UI, modelos Pydantic validados y + Dockerfile para containerización. +

+
+ POST/api/rut/validar + POST/api/iva/desde-neto + POST/api/iva/desde-bruto + POST/api/iva/factura + POST/api/honorarios/retencion + POST/api/honorarios/liquido + POST/api/honorarios/anual + POST/api/topes/cotizaciones + GET/api/topes/afps + POST/api/f29/simular +
+
+ + +
+
📄 Validación de RUT
+

+ El RUT (Rol Único Tributario) es el identificador fiscal de Chile. El dígito verificador se calcula + con el algoritmo módulo 11: se multiplican los dígitos por la serie 2-3-4-5-6-7 + de derecha a izquierda, se suman y se aplica 11 - (suma % 11). +

+
+

Validar RUT

+

Ingresa un RUT con o sin formato (ej: 12.345.678-5 o 123456785)

+
+
+
+ +
+
+
+ + +
+
💰 Cálculo de IVA (19%)
+

+ El IVA en Chile es 19% 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. +

+
+
+

Desde Monto Neto

+

Ingresa el monto sin IVA para calcular el bruto

+
+
+
+ +
+
+
+

Desde Monto Bruto

+

Ingresa el monto con IVA incluido para extraer el neto

+
+
+
+ +
+
+
+
+ + +
+
📝 Retención de Honorarios
+

+ Las boletas de honorarios en Chile tienen una retención de impuesto que varía por año: + 2024: 13.75%, 2025: 14.50%, 2026: 15.25%, 2027+: 17.00%. + El pagador retiene el porcentaje y lo entera al SII. El prestador recibe el líquido. +

+
+
+

Calcular Retención

+

Desde el monto bruto de la boleta

+
+
+
+
+ +
+
+
+

Desde Líquido Deseado

+

¿Cuánto bruto necesitas para recibir X líquido?

+
+
+
+
+ +
+
+
+
+ + +
+
📈 Cotizaciones Previsionales
+

+ Las cotizaciones previsionales se calculan sobre la renta bruta con topes imponibles + expresados en UF. Incluye AFP (tasa variable por administradora), Salud (7%), AFC (seguro cesantía) + y SIS (invalidez y sobrevivencia, cargo empleador). +

+
+

Calcular Cotizaciones

+

Ingresa la renta bruta y selecciona tu AFP para ver el desglose completo

+
+
+
+
+
+
+ +
+
+
+ + +
+
📋 Simulador F29
+

+ El Formulario 29 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). +

+
+

Simular Declaración F29

+

Ingresa ventas y compras netas del período para ver el resultado

+
+
+
+
+
+
+
+
+ +
+
+
+ + +
+
⚙ Acerca de este Proyecto
+
+
+

Contexto y Objetivo

+

+ Este proyecto demuestra capacidades de diseño de APIs REST, + lógica de negocio tributaria y documentación automática en el contexto contable chileno. +

+

+ 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. +

+

+ Esta página es un demo estática que replica la lógica + de la API en JavaScript para funcionar sin backend en GitHub Pages. +

+
+
+

Stack Tecnológico

+
+ Python 3.12 + FastAPI — API REST + Pydantic — validación + Swagger/OpenAPI — docs + pytest + httpx — tests + Docker — containerización + GitHub Actions — CI/CD + GitHub Pages — demo +
+

+ 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. +

+
+
+
+ +
+ + + + + + diff --git a/tax_api/requirements.txt b/tax_api/requirements.txt new file mode 100644 index 0000000..62126e4 --- /dev/null +++ b/tax_api/requirements.txt @@ -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 diff --git a/tax_api/src/__init__.py b/tax_api/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tax_api/src/api.py b/tax_api/src/api.py new file mode 100644 index 0000000..ca0d164 --- /dev/null +++ b/tax_api/src/api.py @@ -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"} diff --git a/tax_api/src/f29.py b/tax_api/src/f29.py new file mode 100644 index 0000000..823268f --- /dev/null +++ b/tax_api/src/f29.py @@ -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", + } diff --git a/tax_api/src/honorarios.py b/tax_api/src/honorarios.py new file mode 100644 index 0000000..dbdec3f --- /dev/null +++ b/tax_api/src/honorarios.py @@ -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, + } diff --git a/tax_api/src/iva.py b/tax_api/src/iva.py new file mode 100644 index 0000000..ab76c3e --- /dev/null +++ b/tax_api/src/iva.py @@ -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), + } diff --git a/tax_api/src/rut.py b/tax_api/src/rut.py new file mode 100644 index 0000000..b011c3b --- /dev/null +++ b/tax_api/src/rut.py @@ -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 diff --git a/tax_api/src/topes.py b/tax_api/src/topes.py new file mode 100644 index 0000000..413d314 --- /dev/null +++ b/tax_api/src/topes.py @@ -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() + ] diff --git a/tax_api/tests/__init__.py b/tax_api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tax_api/tests/test_api.py b/tax_api/tests/test_api.py new file mode 100644 index 0000000..e699564 --- /dev/null +++ b/tax_api/tests/test_api.py @@ -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