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