caudal/programa/cartera/fiscal/cuota.py
esetheyers 7e18576f86 Caudal 0.2.0
Taxes for the whole of Spain (common territory, Navarre, Álava, Bizkaia and Gipuzkoa); flats, garages and land valued on their own; Treasury bills, bonds and deposits priced on BME; broker accounts; a guided start; a page per investment with its chart and the tax of selling today; net worth at the top; undo when deleting; Woodpecker CI for the tests and the Linux build.
2026-10-15 16:49:15 -05:00

171 lines
8.5 KiB
Python

"""
cuota.py — Lo que saldría a pagar por la parte del ahorro, con las reglas de
un territorio y de cada ejercicio.
Las reglas NO están aquí: están en reglas/<territorio>.json, por tramos de
ejercicios, cada una con su cita y con una marca de si se ha comprobado contra
la norma vigente ese año. Aquí solo está la mecánica:
1. Los rendimientos del capital mobiliario (dividendos, intereses) y las
ganancias y pérdidas por transmisiones forman dos saldos.
2. Si hay muy pocas transmisiones en el año, puede haber una exención.
3. Cada saldo positivo absorbe primero los negativos de su misma clase que
vengan de los años anteriores (los más antiguos primero, y solo los de
los últimos años que la norma permita).
4. Un saldo negativo se compensa con el positivo del otro, hasta un
porcentaje de ese positivo. Lo que sobre queda pendiente.
5. La suma de los saldos positivos tributa por la escala del ahorro.
Los saldos pendientes se arrastran calculando todos los años desde el primero
con operaciones, COMO SI se hubiera declarado siempre lo que sale aquí. Si lo
declarado de verdad fue otra cosa, mandan las declaraciones.
Es una estimación de UNA parte de la declaración. No ve lo que no está en la
cartera (otras cuentas, inmuebles), ni la parte general de la renta (el
trabajo, la minería como actividad), ni las deducciones.
"""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
CARPETA = Path(__file__).resolve().parent / "reglas"
TERRITORIOS = {"comun": "Territorio común", "alava": "Álava", "bizkaia": "Bizkaia", "gipuzkoa": "Gipuzkoa",
"navarra": "Navarra"}
class ErrorReglas(RuntimeError):
pass
@lru_cache(maxsize=None)
def reglas(territorio: str) -> dict:
if territorio not in TERRITORIOS:
raise ErrorReglas(f"No tengo las reglas de «{territorio}».")
return json.loads((CARPETA / f"{territorio}.json").read_text(encoding="utf-8"))
def del_ejercicio(territorio: str, ejercicio: int) -> dict:
"""Las reglas que rigen ese año: el primer tramo cuyo «desde» no lo supera."""
for tramo in reglas(territorio)["ejercicios"]:
if ejercicio >= tramo["desde"]:
return tramo
raise ErrorReglas(f"No hay reglas de {territorio} para {ejercicio}.")
def por_escala(base: float, escala: list) -> tuple[float, list[dict]]:
"""(cuota, tramos aplicados) de una base por una escala [[hasta, tipo %], …]."""
cuota, desde, tramos = 0.0, 0.0, []
for hasta, tipo in escala:
if base <= desde:
break
trozo = (base if hasta is None else min(base, hasta)) - desde
parte = trozo * tipo / 100
tramos.append({"desde": desde, "hasta": hasta, "tipo": tipo, "base": round(trozo, 2), "cuota": round(parte, 2)})
cuota += parte
if hasta is None:
break
desde = hasta
return round(cuota, 2), tramos
def _eur(v: float) -> str:
"""1234.5 → «1.234,50 €»."""
return f"{v:,.2f}".replace(",", " ").replace(".", ",").replace(" ", ".") + " €"
def _absorber(saldo: float, pendientes: list[dict], limite: float | None = None) -> tuple[float, float]:
"""Resta de un saldo positivo los pendientes, los más antiguos primero.
Devuelve (saldo que queda, lo compensado)."""
usado = 0.0
tope = saldo if limite is None else min(saldo, limite)
for p in sorted(pendientes, key=lambda p: p["ejercicio"]):
if tope - usado <= 0.004:
break
q = min(p["importe"], tope - usado)
p["importe"] = round(p["importe"] - q, 2)
usado += q
return round(saldo - usado, 2), round(usado, 2)
def ahorro(territorio: str, años: list[dict]) -> dict:
"""La liquidación de la parte del ahorro del ÚLTIMO de esos años.
años: en orden, desde el primero con operaciones, cada uno con
ejercicio, rendimientos (bruto que va al ahorro), ganancias (neto de las
transmisiones ya con la regla de la recompra), incrementos (suma de las
ganancias), transmitido (suma de lo cobrado en las ventas) y retenciones.
"""
pendientes = {"rendimientos": [], "ganancias": []}
salida = {}
for a in años:
y = a["ejercicio"]
r = del_ejercicio(territorio, y)
plazo = r["años_para_compensar"]
for lista in pendientes.values(): # lo que ya ha caducado no se puede usar
lista[:] = [p for p in lista if p["importe"] > 0.004 and y - p["ejercicio"] <= plazo]
caducan = [{"clase": k, **p} for k, lista in pendientes.items() for p in lista if y - p["ejercicio"] == plazo]
pasos = []
rend, gan = round(a["rendimientos"], 2), round(a["ganancias"], 2)
# 2. Pocas transmisiones en el año
exento = 0.0
ex = r.get("exencion_pequeñas")
if ex and not a.get("sin_exencion") and 0 < a["transmitido"] <= ex["importe_global"] and a["incrementos"] > 0:
exento = round(min(a["incrementos"], ex["limite_incremento"] * a["transmitido"]), 2)
gan = round(gan - exento, 2)
pasos.append({"texto": f"Vendiste en total {_eur(a['transmitido'])}, no más de {ex['importe_global']} €: "
f"quedan exentos {_eur(exento)} de ganancia.", "cita": r["cita_exencion"]})
saldos = {"rendimientos": rend, "ganancias": gan}
nombres = {"rendimientos": "rendimientos", "ganancias": "ganancias y pérdidas"}
# 3. Negativos de años anteriores de la misma clase
antes = {}
for k in saldos:
if saldos[k] > 0 and pendientes[k]:
saldos[k], antes[k] = _absorber(saldos[k], pendientes[k])
if antes[k]:
pasos.append({"texto": f"Se restan {_eur(antes[k])} de {nombres[k]} negativos de años anteriores.",
"cita": r["cita_compensacion"]})
# 4. Lo negativo de una clase contra lo positivo de la otra, con límite
for k, otra in (("rendimientos", "ganancias"), ("ganancias", "rendimientos")):
if saldos[otra] <= 0:
continue
limite = round(r["compensacion_cruzada"] * saldos[otra], 2)
cruzado = 0.0
if saldos[k] < 0:
cruzado = min(-saldos[k], limite)
saldos[k] = round(saldos[k] + cruzado, 2)
if pendientes[k] and limite - cruzado > 0.004:
_, viejo = _absorber(limite - cruzado, pendientes[k])
cruzado += viejo
if cruzado:
saldos[otra] = round(saldos[otra] - cruzado, 2)
pasos.append({"texto": f"{_eur(cruzado)} de {nombres[k]} negativos se compensan con el saldo positivo de "
f"{nombres[otra]} (como mucho el {r['compensacion_cruzada']:.0%} de ese saldo).",
"cita": r["cita_compensacion"]})
for k in saldos:
if saldos[k] < 0:
pendientes[k].append({"ejercicio": y, "importe": round(-saldos[k], 2)})
base = round(sum(max(v, 0.0) for v in saldos.values()), 2)
cuota, tramos = por_escala(base, r["escala_ahorro"])
salida = {
"ejercicio": y, "territorio": territorio, "nombre_territorio": TERRITORIOS[territorio],
"rendimientos": rend, "ganancias": round(a["ganancias"], 2), "exento": exento,
"saldo_rendimientos": saldos["rendimientos"], "saldo_ganancias": saldos["ganancias"],
"pasos": pasos, "base": base, "cuota": cuota, "tramos": tramos,
"tipo_medio": round(cuota / base, 4) if base else None,
"retenciones": round(a["retenciones"], 2), "resultado": round(cuota - a["retenciones"], 2),
# Lo que queda para restar en años siguientes, y hasta cuándo
"pendientes": [{"clase": nombres[k], "ejercicio": p["ejercicio"], "importe": p["importe"],
"hasta": p["ejercicio"] + plazo}
for k, lista in pendientes.items() for p in lista if p["importe"] > 0.004],
"caducan": [{"clase": nombres[c["clase"]], "ejercicio": c["ejercicio"], "importe": c["importe"]}
for c in caducan if c["importe"] > 0.004],
"verificado": r["verificado"], "nota": r["nota"],
"citas": {"escala": r["cita_escala"], "compensacion": r["cita_compensacion"]},
"nota_fuera_del_ahorro": reglas(territorio).get("nota_fuera_del_ahorro"),
"norma": reglas(territorio)["norma"], "fuente": reglas(territorio)["fuente"],
"consultado": reglas(territorio)["consultado"],
}
return salida