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

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(" ", ".") + " €"