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