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.
362 lines
20 KiB
Python
362 lines
20 KiB
Python
"""
|
|
alquiler.py — Lo que hay que declarar de cada inmueble, año a año: el
|
|
rendimiento de su alquiler y, donde la haya, la renta imputada si estuvo vacío.
|
|
|
|
Las reglas son de cada territorio (reglas/<territorio>.json → «alquiler») y
|
|
cambian mucho de uno a otro:
|
|
|
|
territorio común va a la parte general. Se restan todos los gastos y la
|
|
amortización (3 % del mayor entre el coste y el valor
|
|
catastral, sin el suelo); intereses y reparaciones, como
|
|
mucho lo cobrado, y lo que sobre pasa a los cuatro años
|
|
siguientes. En la vivienda habitual del inquilino, la
|
|
reducción que el usuario elige según su contrato. Los
|
|
inmuebles vacíos imputan una renta.
|
|
Navarra parte general; el total de los gastos, como mucho lo
|
|
cobrado (con arrastre). Reducción según la modalidad.
|
|
País Vasco la vivienda habitual del inquilino va a la base del
|
|
ahorro: una bonificación sobre lo cobrado y solo se restan
|
|
los intereses (y el seguro de impago). Temporada y
|
|
turístico, a la general. Garajes, locales y terrenos:
|
|
gastos y amortización. Nunca sale negativo.
|
|
|
|
Cada gasto de un inmueble lleva su clase (m["clase_gasto"]): «reparacion»,
|
|
«intereses», «seguro_impago» u «otro» (IBI, comunidad, seguro, gestoría…).
|
|
Los intereses de la hipoteca vinculada salen solos de su cuadro. Una mejora
|
|
(gasto «amortizable») no es gasto del año: suma al coste que se amortiza.
|
|
|
|
Simplificaciones, dichas en pantalla: el uso del inmueble es el de su ficha
|
|
para todos los años; si estuvo alquilado, lo estuvo todo el tiempo que fue
|
|
tuyo; los gastos no se reparten entre días alquilados y vacíos.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
|
|
from .. import deuda as motor_deuda
|
|
from ..calculo import Precios
|
|
|
|
CLASES_GASTO = {"otro": "IBI, comunidad, seguro, gestoría u otro", "reparacion": "Reparación o conservación",
|
|
"intereses": "Intereses de un préstamo", "seguro_impago": "Seguro de impago del alquiler"}
|
|
|
|
|
|
def _f(v) -> float:
|
|
try:
|
|
return float(v or 0)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
|
|
def _dias(año: int, desde: str, hasta: str | None) -> int:
|
|
"""Días de ese año entre dos fechas (ambas incluidas)."""
|
|
ini = max(dt.date(año, 1, 1), dt.date.fromisoformat(desde))
|
|
fin = min(dt.date(año, 12, 31), dt.date.fromisoformat(hasta) if hasta else dt.date(año, 12, 31))
|
|
return max((fin - ini).days + 1, 0)
|
|
|
|
|
|
def _del_año(año: int) -> int:
|
|
return (dt.date(año + 1, 1, 1) - dt.date(año, 1, 1)).days
|
|
|
|
|
|
def reglas_del_año(reglas: dict, ejercicio: int) -> dict:
|
|
"""Las reglas del alquiler de ese año: las generales con lo que cambie su tramo de «ejercicios»."""
|
|
base = {k: v for k, v in reglas.items() if k != "ejercicios"}
|
|
for tramo in reglas.get("ejercicios", []):
|
|
if ejercicio >= tramo["desde"]:
|
|
return {**base, **{k: v for k, v in tramo.items() if k != "desde"}}
|
|
return base
|
|
|
|
|
|
def modalidad(a: dict, reglas: dict) -> tuple[str, dict]:
|
|
"""(clave, reglas) de cómo se alquila: la vivienda según su contrato; lo demás, «no_vivienda»."""
|
|
f = a.get("inmueble") or {}
|
|
if f.get("clase") != "vivienda":
|
|
return "no_vivienda", {"texto": "No es una vivienda", "base": "general", "no_vivienda": True}
|
|
clave = f.get("contrato") or "habitual"
|
|
mods = reglas["modalidades"]
|
|
if clave not in mods:
|
|
clave = "habitual"
|
|
return clave, mods[clave]
|
|
|
|
|
|
def intereses_hipoteca(activos: dict, movs_de: dict, precios: Precios, did: str | None) -> dict[int, float]:
|
|
"""Los intereses pagados cada año de la hipoteca vinculada, según su cuadro."""
|
|
d = activos.get(did or "")
|
|
if not d or d["tipo"] != "Deuda":
|
|
return {}
|
|
por_año: dict[int, float] = {}
|
|
for x in motor_deuda.cuadro(d, movs_de.get(did, []), precios.de(did))["filas"]:
|
|
if x["fecha"] <= dt.date.today().isoformat():
|
|
por_año[int(x["fecha"][:4])] = por_año.get(int(x["fecha"][:4]), 0.0) + x["intereses"]
|
|
return por_año
|
|
|
|
|
|
def año_de_dudoso_cobro(fecha: str) -> int:
|
|
"""El año en que una renta impagada se puede restar: el primero que acaba
|
|
más de seis meses después (se toma su fecha como la primera gestión de cobro)."""
|
|
d = dt.date.fromisoformat(fecha)
|
|
return d.year if d + dt.timedelta(days=183) < dt.date(d.year, 12, 31) else d.year + 1
|
|
|
|
|
|
def _amortizacion(a: dict, movs: list[dict], año: int, venta: str | None, reglas: dict) -> tuple[float, str | None]:
|
|
"""(amortización del año, aviso) de lo que costó la construcción y las mejoras."""
|
|
f = a.get("inmueble") or {}
|
|
pc = _f(f.get("porcentaje_construccion")) / 100
|
|
if pc <= 0:
|
|
return 0.0, "Falta la parte de construcción (%) en la ficha: sin ella no se puede amortizar."
|
|
total = 0.0
|
|
piezas = [(m["fecha"], _f(m.get("importe"))) for m in movs if m["tipo"] == "Compra"]
|
|
piezas += [(m["fecha"], _f(m.get("importe"))) for m in movs if m["tipo"] == "Gasto" and m.get("amortizable")]
|
|
catastral = _f(f.get("valor_catastral")) * pc
|
|
compra_inicial = min((p[0] for p in piezas), default=None)
|
|
for fecha, coste in piezas:
|
|
base = coste * pc
|
|
if reglas["amortizacion"]["sobre"] == "mayor_coste_catastral" and fecha == compra_inicial:
|
|
base = max(base, catastral)
|
|
total += base * reglas["amortizacion"]["porcentaje"] / 100 * _dias(año, fecha, venta) / _del_año(año)
|
|
return round(total, 2), None
|
|
|
|
|
|
def calcular(activos: list[dict], movimientos: list[dict], precios: list[dict], ejercicio: int, territorio: dict) -> dict:
|
|
"""Las fichas del alquiler y de la renta imputada de ese ejercicio, y lo que
|
|
va cada año a la base del ahorro y a la general (para arrastrar y regularizar).
|
|
|
|
Devuelve {"fichas": [...], "imputaciones": [...], "ahorro": {año: €},
|
|
"general": {año: [{nombre, rendimiento}]}}."""
|
|
reglas_base = territorio.get("alquiler")
|
|
salida = {"fichas": [], "imputaciones": [], "ahorro": {}, "general": {}, "ventas": {}}
|
|
suyos = [a for a in activos if a["tipo"] == "Inmueble"]
|
|
if not reglas_base or not suyos:
|
|
return salida
|
|
por_id = {a["id"]: a for a in activos}
|
|
movs_de: dict[str, list[dict]] = {}
|
|
for m in sorted(movimientos, key=lambda m: m["fecha"]):
|
|
movs_de.setdefault(m["activo"], []).append(m)
|
|
pr = Precios(precios)
|
|
for a in suyos:
|
|
ms = movs_de.get(a["id"], [])
|
|
compras = [m for m in ms if m["tipo"] == "Compra"]
|
|
if not compras:
|
|
continue
|
|
f = a.get("inmueble") or {}
|
|
inicio = compras[0]["fecha"]
|
|
venta = next((m["fecha"] for m in ms if m["tipo"] == "Venta"), None)
|
|
hipoteca = intereses_hipoteca(por_id, movs_de, pr, f.get("hipoteca"))
|
|
pendientes: list[dict] = [] # gastos que pasan a años siguientes: {año, importe}
|
|
amortizado: dict[int, float] = {} # lo amortizado cada año, que baja el coste al vender
|
|
for año in range(int(inicio[:4]), ejercicio + 1):
|
|
if venta and año > int(venta[:4]):
|
|
break
|
|
r = reglas_del_año(reglas_base, año)
|
|
del_año = [m for m in ms if m["fecha"][:4] == str(año)]
|
|
if f.get("uso") == "alquilado" or any(m["tipo"] == "Dividendo" for m in del_año):
|
|
ficha = _ficha(a, del_año, ms, año, venta, r, hipoteca.get(año, 0.0), pendientes)
|
|
if ficha is None:
|
|
continue
|
|
if ficha["amortizacion"]:
|
|
amortizado[año] = ficha["amortizacion"]
|
|
if ficha["base"] == "ahorro":
|
|
salida["ahorro"][año] = round(salida["ahorro"].get(año, 0.0) + ficha["rendimiento"], 2)
|
|
else:
|
|
salida["general"].setdefault(año, []).append({"nombre": f"Alquiler: {a['nombre']}", "rendimiento": ficha["rendimiento"]})
|
|
if año == ejercicio:
|
|
salida["fichas"].append(ficha)
|
|
elif f.get("uso") == "vacio" and año == ejercicio:
|
|
imp = _imputacion(a, año, inicio, venta, territorio)
|
|
if imp:
|
|
salida["imputaciones"].append(imp)
|
|
if venta and int(venta[:4]) <= ejercicio:
|
|
v = _venta(a, ms, venta, amortizado, territorio)
|
|
salida["ventas"].setdefault(int(venta[:4]), []).append(v)
|
|
# renta imputada de años anteriores, para regularizar
|
|
if f.get("uso") == "vacio":
|
|
for año in range(int(inicio[:4]), ejercicio):
|
|
imp = _imputacion(a, año, inicio, venta, territorio)
|
|
if imp and imp["renta"]:
|
|
salida["general"].setdefault(año, []).append({"nombre": f"Renta imputada: {a['nombre']}", "rendimiento": imp["renta"]})
|
|
_escala_imputacion(salida["imputaciones"], territorio, ejercicio)
|
|
for imp in salida["imputaciones"]:
|
|
if imp["renta"]:
|
|
salida["general"].setdefault(ejercicio, []).append({"nombre": f"Renta imputada: {imp['nombre']}", "rendimiento": imp["renta"]})
|
|
return salida
|
|
|
|
|
|
def _ficha(a, del_año, ms, año, venta, r, intereses_hipoteca_año, pendientes) -> dict | None:
|
|
f = a.get("inmueble") or {}
|
|
clave, mod = modalidad(a, r)
|
|
cobrado = [m for m in del_año if m["tipo"] == "Dividendo"]
|
|
ingresos = round(sum(_f(m.get("importe")) + _f(m.get("retencion")) for m in cobrado), 2)
|
|
gastos = {k: 0.0 for k in CLASES_GASTO}
|
|
for m in del_año:
|
|
if m["tipo"] == "Gasto" and not m.get("amortizable"):
|
|
gastos[m.get("clase_gasto") if m.get("clase_gasto") in CLASES_GASTO else "otro"] += _f(m.get("importe"))
|
|
elif m["tipo"] == "Devolucion" and not m.get("amortizable"):
|
|
gastos[m.get("clase_gasto") if m.get("clase_gasto") in CLASES_GASTO else "otro"] -= _f(m.get("importe"))
|
|
gastos["intereses"] += intereses_hipoteca_año
|
|
gastos = {k: round(v, 2) for k, v in gastos.items()}
|
|
pasos, avisos = [], []
|
|
# Rentas impagadas: se declaran igual (cuentan en lo cobrado) y, donde se restan los gastos necesarios,
|
|
# se deducen como saldo de dudoso cobro el primer año que acaba más de seis meses después de su fecha
|
|
dudoso = 0.0
|
|
if r["gastos_vivienda"] == "todos" or mod.get("no_vivienda"):
|
|
for m in ms:
|
|
if m["tipo"] == "Dividendo" and m.get("impagado") and año_de_dudoso_cobro(m["fecha"]) == año:
|
|
dudoso += _f(m.get("importe")) + _f(m.get("retencion"))
|
|
gastos["dudoso_cobro"] = round(dudoso, 2)
|
|
if dudoso:
|
|
pasos.append(f"{_eur(dudoso)} de rentas impagadas hace más de seis meses se restan como saldo de dudoso cobro.")
|
|
if not ingresos and not any(gastos.values()):
|
|
return None
|
|
caducados = [p for p in pendientes if año - p["año"] > r.get("arrastre_años", 0)]
|
|
pendientes[:] = [p for p in pendientes if p not in caducados and p["importe"] > 0.004]
|
|
vasca = r["gastos_vivienda"] == "intereses" and not mod.get("no_vivienda")
|
|
base = mod.get("base", r["base_vivienda"])
|
|
amortizacion, bonificacion, reduccion, deducido, usado = 0.0, 0.0, 0.0, 0.0, 0.0
|
|
if vasca:
|
|
# Vivienda en el País Vasco: bonificación sobre lo cobrado y, como gasto, solo los intereses
|
|
bonificacion = round(ingresos * mod["bonificacion"] / 100, 2)
|
|
seguro = min(gastos["seguro_impago"], mod.get("seguro_impago") or 0.0)
|
|
deducido = round(gastos["intereses"] + seguro, 2)
|
|
pasos.append(f"Bonificación del {mod['bonificacion']} % sobre lo cobrado: {_eur(bonificacion)}.")
|
|
previo = round(ingresos - bonificacion - deducido, 2)
|
|
if previo < 0:
|
|
pasos.append("La bonificación y los intereses no pueden dejarlo en negativo: se queda en cero.")
|
|
previo = 0.0
|
|
else:
|
|
amortizacion, aviso = _amortizacion(a, ms, año, venta, r)
|
|
if aviso:
|
|
avisos.append(aviso)
|
|
todos = sum(gastos.values()) + amortizacion
|
|
if r.get("limite") == "intereses_reparacion": # territorio común
|
|
limitables = gastos["intereses"] + gastos["reparacion"]
|
|
elif r.get("limite") == "total" or mod.get("no_vivienda"): # Navarra; y en el País Vasco, lo que no es vivienda
|
|
limitables = todos
|
|
else:
|
|
limitables = None
|
|
if limitables is not None:
|
|
libres = round(todos - limitables, 2)
|
|
# Primero los del año; después lo que venía de años anteriores, los más antiguos primero
|
|
hueco = ingresos
|
|
del_año_usado = min(limitables, hueco)
|
|
exceso = round(limitables - del_año_usado, 2)
|
|
hueco -= del_año_usado
|
|
for p in sorted(pendientes, key=lambda p: p["año"]):
|
|
q = min(p["importe"], max(hueco, 0.0))
|
|
p["importe"] = round(p["importe"] - q, 2)
|
|
usado += q
|
|
hueco -= q
|
|
usado = round(usado, 2)
|
|
if exceso > 0.004:
|
|
if r.get("arrastre_años"):
|
|
pendientes.append({"año": año, "importe": exceso})
|
|
pasos.append(f"{_eur(exceso)} de gastos pasan de lo cobrado: se podrán restar en los {r['arrastre_años']} años siguientes.")
|
|
else:
|
|
pasos.append(f"{_eur(exceso)} de gastos pasan de lo cobrado y no pueden dejarlo en negativo: no se restan.")
|
|
if usado:
|
|
pasos.append(f"Se restan {_eur(usado)} de gastos de años anteriores que no cupieron.")
|
|
deducido = round(del_año_usado + libres + usado, 2)
|
|
else:
|
|
deducido = round(todos, 2)
|
|
previo = round(ingresos - deducido, 2)
|
|
if not r.get("negativo", True) and previo < 0:
|
|
previo = 0.0
|
|
# Reducción por alquiler de vivienda (territorio común y Navarra)
|
|
pct = 0.0
|
|
if not mod.get("no_vivienda") and "reduccion" in mod:
|
|
pct = _f(f.get("reduccion")) if mod["reduccion"] == "elegida" else _f(mod["reduccion"])
|
|
if previo > 0 and pct:
|
|
reduccion = round(previo * pct / 100, 2)
|
|
pasos.append(f"Reducción del {pct:.0f} % por alquiler de vivienda: {_eur(reduccion)}.")
|
|
rendimiento = round(previo - reduccion, 2)
|
|
pendiente = round(sum(p["importe"] for p in pendientes), 2)
|
|
return {"inmueble": a["id"], "nombre": a["nombre"], "ejercicio": año, "modalidad": clave, "texto_modalidad": mod.get("texto", ""),
|
|
"base": base, "ingresos": ingresos, "gastos": gastos, "amortizacion": amortizacion, "bonificacion": bonificacion,
|
|
"deducido": deducido, "previo": previo, "reduccion": reduccion, "porcentaje_reduccion": pct, "rendimiento": rendimiento,
|
|
"pendiente_siguientes": pendiente, "pasos": pasos, "avisos": avisos,
|
|
"verificado": r.get("verificado", True), "nota": r.get("nota", ""), "citas": r.get("citas", {})}
|
|
|
|
|
|
def _venta(a: dict, ms: list[dict], fecha: str, amortizado: dict[int, float], territorio: dict) -> dict:
|
|
"""La ganancia o pérdida al vender un inmueble (a la base del ahorro).
|
|
|
|
Valor de adquisición: lo pagado con sus gastos y las mejoras, menos lo
|
|
amortizado; en el País Vasco, cada importe con el coeficiente de su año.
|
|
Valor de transmisión: lo cobrado, que se apunta ya sin los gastos de la venta."""
|
|
coef = territorio.get("coeficientes")
|
|
tabla = (coef or {}).get("tablas", {}).get(fecha[:4]) if coef else None
|
|
primero = min(tabla) if tabla else None
|
|
c = (lambda año: tabla.get(max(str(año), primero)) or 1.0) if tabla else (lambda año: 1.0)
|
|
piezas = [(m["fecha"], _f(m.get("importe"))) for m in ms
|
|
if m["tipo"] == "Compra" or (m["tipo"] == "Gasto" and m.get("amortizable") and m["fecha"] <= fecha)]
|
|
pagado = round(sum(i for _, i in piezas), 2)
|
|
actualizado = round(sum(i * c(d[:4]) for d, i in piezas), 2)
|
|
amort = round(sum(amortizado.values()), 2)
|
|
amort_act = round(sum(v * c(y) for y, v in amortizado.items()), 2)
|
|
adquisicion = round(actualizado - amort_act, 2)
|
|
cobrado = round(sum(_f(m.get("importe")) for m in ms if m["tipo"] == "Venta"), 2)
|
|
pasos = []
|
|
if amort:
|
|
pasos.append(f"Se restan {_eur(amort)} amortizados por el alquiler (bajan lo que te costó).")
|
|
if tabla and actualizado != pagado:
|
|
pasos.append(f"Con los coeficientes de actualización, lo que te costó pasa de {_eur(pagado)} a {_eur(actualizado)}.")
|
|
elif coef and not tabla:
|
|
pasos.append(f"No hay tabla de coeficientes para {fecha[:4]}: el coste va sin actualizar.")
|
|
avisos = []
|
|
if (a.get("inmueble") or {}).get("uso") == "propio":
|
|
avisos.append("Si era tu vivienda habitual, la ganancia puede quedar exenta (reinvirtiendo en otra, o con más de 65 años): "
|
|
"Caudal no lo calcula, compruébalo antes de declarar.")
|
|
if min((d for d, _ in piezas), default="9999") < "1995-01-01" and not coef:
|
|
avisos.append("Comprado antes de 1995: en territorio común parte de la ganancia puede reducirse (coeficientes de abatimiento); "
|
|
"Caudal no lo calcula.")
|
|
return {"inmueble": a["id"], "nombre": a["nombre"], "fecha": fecha, "valor_transmision": cobrado,
|
|
"pagado": pagado, "actualizado": actualizado, "amortizacion": amort, "valor_adquisicion": adquisicion,
|
|
"ganancia": round(cobrado - adquisicion, 2), "pasos": pasos, "avisos": avisos,
|
|
"cita": "Arts. 34 y 35 Ley 35/2006 y sus equivalentes forales" if not coef else coef["cita"]}
|
|
|
|
|
|
def _imputacion(a, año, inicio, venta, territorio) -> dict | None:
|
|
"""La renta imputada de un inmueble vacío (solo donde la norma la tiene)."""
|
|
reglas = (territorio.get("alquiler") or {}).get("imputacion")
|
|
f = a.get("inmueble") or {}
|
|
if not reglas or f.get("clase") in ("terreno_urbano", "terreno_rustico"):
|
|
return None
|
|
tramo = next(t for t in reglas["ejercicios"] if año >= t["desde"])
|
|
dias = _dias(año, inicio, venta)
|
|
catastral = _f(f.get("valor_catastral"))
|
|
salida = {"inmueble": a["id"], "nombre": a["nombre"], "ejercicio": año, "dias": dias, "valor_catastral": catastral,
|
|
"verificado": tramo["verificado"], "nota": tramo["nota"], "cita": territorio["alquiler"]["citas"].get("imputacion", ""),
|
|
"renta": 0.0, "porcentaje": None, "aviso": None}
|
|
if not catastral:
|
|
salida["aviso"] = "Falta el valor catastral en la ficha: sin él no se puede calcular."
|
|
return salida
|
|
if "escala" in tramo:
|
|
salida["escala"] = tramo["escala"] # se aplica sobre la suma de todos (ver _escala_imputacion)
|
|
return salida
|
|
pct = tramo["revisado"] if f.get("catastral_revisado") else tramo["porcentaje"]
|
|
salida.update({"porcentaje": pct, "renta": round(catastral * pct / 100 * dias / _del_año(año), 2)})
|
|
return salida
|
|
|
|
|
|
def _escala_imputacion(imputaciones: list[dict], territorio: dict, ejercicio: int) -> None:
|
|
"""Con escala (desde 2027 en territorio común), la renta sale de la suma de
|
|
los valores catastrales y se reparte entre los inmuebles en proporción."""
|
|
con_escala = [i for i in imputaciones if i.get("escala")]
|
|
if not con_escala:
|
|
return
|
|
pesos = [i["valor_catastral"] * i["dias"] / _del_año(ejercicio) for i in con_escala]
|
|
suma, total, desde = sum(pesos), 0.0, 0.0
|
|
for hasta, pct in con_escala[0]["escala"]:
|
|
if suma <= desde:
|
|
break
|
|
total += ((suma if hasta is None else min(suma, hasta)) - desde) * pct / 100
|
|
if hasta is None:
|
|
break
|
|
desde = hasta
|
|
for i, p in zip(con_escala, pesos):
|
|
i["renta"] = round(total * p / suma, 2) if suma else 0.0
|
|
i["porcentaje"] = round(total / suma * 100, 3) if suma else None
|
|
|
|
|
|
def _eur(v: float) -> str:
|
|
return f"{v:,.2f}".replace(",", " ").replace(".", ",").replace(" ", ".") + " €"
|