caudal/programa/cartera/fiscal/regularizar.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

159 lines
7.2 KiB
Python

"""
regularizar.py — Ponerse al día: lo que costaría declarar ahora, por tu cuenta,
lo que no se declaró en su momento, ejercicio a ejercicio.
Para cada año cuyo plazo de declaración ya pasó y que aún no ha prescrito:
1. Lo que sale por la parte del ahorro (cuota.py), menos lo que ya pagaste
por eso en tu declaración (lo dices tú; si no declaraste nada, cero).
2. El rendimiento de las actividades (actividad.py) por TU tipo marginal de
ese año, que también dices tú: la parte general depende de lo demás que
ganaste y la cartera no lo sabe.
3. Si la suma sale a pagar, el recargo por presentarla tarde SIN que te lo
hayan reclamado (crece con los meses de retraso y sustituye a la
sanción) y los intereses de demora. Cuándo empiezan a correr depende del
territorio y del retraso: «intereses_tramos» = [[hasta tantos meses de
retraso, desde qué mes tras el plazo cuentan (null: no hay)], …]. Sin
esa clave, corren desde «intereses_desde_meses» y solo pasado ese plazo.
Si en un año sale a devolver, no hay recargo: se pide la rectificación.
Los porcentajes, el plazo de prescripción y los tipos de interés están en
reglas/<territorio>.json → «regularizacion», con su cita. Dos cosas son
aproximadas y se dicen en pantalla: el día en que acabó el plazo de cada
campaña y la cuota de la actividad (por el tipo marginal). Es una estimación
para saber de qué orden es la cifra, no la liquidación.
"""
from __future__ import annotations
import datetime as dt
def _mas_años(d: dt.date, años: int) -> dt.date:
try:
return d.replace(year=d.year + años)
except ValueError: # 29 de febrero
return d.replace(year=d.year + años, day=28)
def _meses(desde: dt.date, hasta: dt.date) -> int:
"""Meses completos transcurridos entre dos fechas."""
n = (hasta.year - desde.year) * 12 + hasta.month - desde.month
return n - 1 if hasta.day < desde.day else n
def fin_de_plazo(ejercicio: int, reglas: dict) -> dt.date:
"""El último día para declarar ese ejercicio: el del fichero si está, o el aproximado."""
exacto = reglas.get("fin_plazo", {}).get(str(ejercicio))
mes, dia = (int(x) for x in reglas["fin_plazo_aproximado"].split("-"))
return dt.date.fromisoformat(exacto) if exacto else dt.date(ejercicio + 1, mes, dia)
def recargo(importe: float, plazo: dt.date, hoy: dt.date, reglas: dict) -> tuple[float, float]:
"""(porcentaje, euros) del recargo por declarar hoy algo cuyo plazo acabó en «plazo»."""
meses = _meses(plazo, hoy)
for hasta, pct in reglas["recargos"]:
if hasta is None or meses < hasta:
return pct, round(importe * pct / 100, 2)
return 0.0, 0.0
def carencia(plazo: dt.date, hoy: dt.date, reglas: dict) -> int | None:
"""Desde qué mes tras el fin del plazo cuentan los intereses al declarar hoy; None si no hay."""
tramos = reglas.get("intereses_tramos")
if tramos is None:
n = reglas["intereses_desde_meses"]
tramos = [[n, None], [None, n]]
meses = _meses(plazo, hoy)
for hasta, desde in tramos:
if hasta is None or meses < hasta:
return desde
return None
def intereses(importe: float, plazo: dt.date, hoy: dt.date, reglas: dict) -> float:
"""Intereses de demora: corren desde que pasan los meses de carencia tras el fin del plazo."""
carencia_ = carencia(plazo, hoy, reglas)
if carencia_ is None:
return 0.0
año, mes = divmod(plazo.year * 12 + plazo.month - 1 + carencia_, 12)
try:
desde = dt.date(año, mes + 1, plazo.day)
except ValueError:
desde = dt.date(año, mes + 1, 28)
total, tipos = 0.0, reglas["interes_demora"]
ultimo = tipos[max(tipos)]
d = desde
while d < hoy:
fin = min(dt.date(d.year + 1, 1, 1), hoy)
dias_año = (dt.date(d.year + 1, 1, 1) - dt.date(d.year, 1, 1)).days
total += importe * tipos.get(str(d.year), ultimo) / 100 * (fin - d).days / dias_año
d = fin
return round(total, 2)
def limpiar(declarado: dict | None) -> dict:
"""Lo que el usuario ha dicho de cada año, saneado: {"2023": {"pagado": €, "marginal": %}}."""
salida = {}
for año, d in (declarado or {}).items():
if not (str(año).isdigit() and isinstance(d, dict)):
continue
fila = {}
for k, tope in (("pagado", 1e9), ("marginal", 60.0)):
try:
v = float(d.get(k))
except (TypeError, ValueError):
continue
if 0 <= v <= tope:
fila[k] = round(v, 2)
if fila:
salida[str(año)] = fila
return salida
def plan(años: list[dict], declarado: dict, hoy: dt.date, reglas: dict) -> dict:
"""El plan de regularización.
años: uno por ejercicio, en orden, con «ejercicio», «ahorro» (lo que sale a
pagar por el ahorro, ya restadas las retenciones), «actividades»
([{nombre, rendimiento}]) y «verificado» (las reglas de ese año)."""
declarado = limpiar(declarado)
filas = []
for a in años:
y = a["ejercicio"]
plazo = fin_de_plazo(y, reglas)
prescribe = _mas_años(plazo, reglas["prescripcion_años"])
suyo = declarado.get(str(y), {})
pagado, marginal = suyo.get("pagado", 0.0), suyo.get("marginal")
rendimiento = round(sum(x["rendimiento"] for x in a["actividades"]), 2)
fila = {"ejercicio": y, "fin_plazo": plazo.isoformat(), "prescribe": prescribe.isoformat(),
"ahorro": a["ahorro"], "pagado": pagado, "rendimiento": rendimiento, "marginal": marginal,
"verificado": a["verificado"]}
if hoy <= plazo:
fila["estado"] = "en_plazo"
elif hoy > prescribe:
fila["estado"] = "prescrito"
else:
general = round(rendimiento * marginal / 100, 2) if marginal is not None else None
diferencia = round(a["ahorro"] - pagado + (general or 0.0), 2)
fila.update({"estado": "abierto", "general": general, "diferencia": diferencia,
"falta_marginal": bool(rendimiento) and marginal is None})
if diferencia > 0:
pct, euros = recargo(diferencia, plazo, hoy, reglas)
# Según el retraso, el recargo excluye los intereses o no (ver carencia)
interes = intereses(diferencia, plazo, hoy, reglas)
fila.update({"recargo_pct": pct, "recargo": euros, "intereses": interes,
"total": round(diferencia + euros + interes, 2)})
else:
fila.update({"recargo_pct": 0.0, "recargo": 0.0, "intereses": 0.0, "total": diferencia})
filas.append(fila)
abiertas = [f for f in filas if f["estado"] == "abierto"]
return {
"hoy": hoy.isoformat(), "filas": filas,
"total": {k: round(sum(f[k] for f in abiertas), 2) for k in ("diferencia", "recargo", "intereses", "total")},
"falta_marginal": any(f["falta_marginal"] for f in abiertas),
"proxima_prescripcion": min((f["prescribe"] for f in abiertas), default=None),
"citas": reglas["citas"], "texto_recargos": reglas["texto_recargos"], "nota": reglas["nota"], "norma": reglas["norma"], "fuente": reglas["fuente"],
"consultado": reglas["consultado"],
}