""" 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/.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