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.
159 lines
7.2 KiB
Python
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"],
|
|
}
|