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.
171 lines
8.5 KiB
Python
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
|