""" deuda.py — Préstamos recibidos (deudas) con cuota constante: sistema francés. El préstamo se describe desde un punto de partida (lo que dice el banco en una fecha) y el cuadro se calcula, no se guarda cuota a cuota: activo["deuda"] = { "capital": capital pendiente en la fecha de partida, "tin": tipo nominal anual en % (fijo), "cuotas": cuotas que quedan por pagar desde esa fecha, "primera_cuota": fecha de la próxima cuota (AAAA-MM-DD), } fecha_inicio del activo = fecha de partida. Sobre ese punto de partida actúan, por orden de fecha: · movimientos «Amortizacion»: pago anticipado de capital (importe) con modalidad «plazo» (misma cuota, menos cuotas) o «cuota» (mismas cuotas, cuota menor). Si el importe cubre todo, el préstamo queda cancelado. · precios con «precio» = capital pendiente según el banco en esa fecha: corrigen las pequeñas diferencias de redondeo o de cómputo de días. Cada cuota: intereses = pendiente × TIN/12 (redondeado al céntimo), capital = cuota − intereses. La última salda lo que quede. Una amortización a mitad de mes reduce el capital sobre el que se calculan los intereses de la cuota siguiente; si el banco cuenta los días de otra forma, la diferencia se corrige con el saldo del banco. """ from __future__ import annotations import calendar import datetime as dt import math MODALIDADES = ("plazo", "cuota") def _fecha(s) -> dt.date: return s if isinstance(s, dt.date) else dt.date.fromisoformat(s) def _r(x: float) -> float: return round(x + 0.0, 2) def sumar_meses(f: dt.date, meses: int, dia: int) -> dt.date: m = f.month - 1 + meses y, m = f.year + m // 12, m % 12 + 1 return dt.date(y, m, min(dia, calendar.monthrange(y, m)[1])) def cuota_francesa(capital: float, tin: float, cuotas: int) -> float: if cuotas <= 0: return 0.0 i = tin / 100 / 12 if i == 0: return _r(capital / cuotas) return _r(capital * i / (1 - (1 + i) ** -cuotas)) def cuotas_para(capital: float, tin: float, cuota: float) -> int: """Cuotas que hacen falta para devolver el capital con esa cuota.""" if capital <= 0.005: return 0 i = tin / 100 / 12 if i == 0: return math.ceil(capital / cuota - 1e-9) if cuota <= capital * i: raise ValueError("La cuota no cubre ni los intereses.") return math.ceil(-math.log(1 - capital * i / cuota) / math.log(1 + i) - 1e-9) def parametros(activo: dict) -> dict: d = activo.get("deuda") or {} return {"capital": float(d["capital"]), "tin": float(d["tin"]), "cuotas": int(d["cuotas"]), "primera": _fecha(d["primera_cuota"]), "inicio": _fecha(activo["fecha_inicio"])} def cuadro(activo: dict, movimientos: list[dict], saldos: list[dict], extra: list[dict] = ()) -> dict: """Cuadro completo: cuotas pasadas y futuras con los eventos aplicados. movimientos: los del activo (se usan los «Amortizacion» vivos). saldos: [{fecha, precio}] capital pendiente según el banco. extra: amortizaciones hipotéticas (para simular), mismo formato.""" p = parametros(activo) tin, i = p["tin"], p["tin"] / 100 / 12 dia = p["primera"].day eventos = [] for m in list(movimientos) + list(extra): if m.get("tipo") == "Amortizacion" and not m.get("borrado"): eventos.append((_fecha(m["fecha"]), 0, "amortizacion", m)) for s in saldos: if s.get("precio") is not None and not s.get("borrado") and _fecha(s["fecha"]) > p["inicio"]: eventos.append((_fecha(s["fecha"]), 1, "saldo", s)) eventos.sort(key=lambda e: (e[0], e[1])) pendiente, restantes = p["capital"], p["cuotas"] cuota = cuota_francesa(pendiente, tin, restantes) filas, aplicados, n = [], [], 0 k = 0 while restantes > 0 and pendiente > 0.005: vence = sumar_meses(p["primera"], n, dia) # Eventos anteriores o del mismo día que la cuota se aplican antes (el # saldo del banco de ese día ya suele incluir la cuota: va después) while k < len(eventos) and (eventos[k][0] < vence or (eventos[k][0] == vence and eventos[k][2] == "amortizacion")): pendiente, cuota, restantes = _aplicar(eventos[k], pendiente, cuota, restantes, tin, aplicados) k += 1 if restantes <= 0 or pendiente <= 0.005: break intereses = _r(pendiente * i) amortizado = _r(cuota - intereses) if restantes == 1 or amortizado >= pendiente: amortizado = _r(pendiente) pendiente = _r(pendiente - amortizado) restantes -= 1 filas.append({"fecha": vence.isoformat(), "cuota": _r(intereses + amortizado), "intereses": intereses, "capital": amortizado, "pendiente": pendiente}) n += 1 while k < len(eventos) and eventos[k][0] == vence: pendiente, cuota, restantes = _aplicar(eventos[k], pendiente, cuota, restantes, tin, aplicados) k += 1 while k < len(eventos): # eventos tras la última cuota (p. ej. cancelación) pendiente, cuota, restantes = _aplicar(eventos[k], pendiente, cuota, restantes, tin, aplicados) k += 1 return {"filas": filas, "eventos": aplicados} def _aplicar(evento, pendiente, cuota, restantes, tin, aplicados): fecha, _, clase, r = evento antes = pendiente if clase == "saldo": pendiente = _r(r["precio"]) aplicados.append({"fecha": fecha.isoformat(), "tipo": "saldo", "pendiente": pendiente, "diferencia": _r(pendiente - antes)}) if pendiente <= 0.005: restantes = 0 return pendiente, cuota, restantes importe = min(float(r.get("importe") or 0), pendiente) pendiente = _r(pendiente - importe) modalidad = r.get("modalidad") if r.get("modalidad") in MODALIDADES else "plazo" if pendiente <= 0.005: restantes, pendiente = 0, 0.0 elif modalidad == "cuota": cuota = cuota_francesa(pendiente, tin, restantes) else: restantes = min(restantes, cuotas_para(pendiente, tin, cuota)) aplicados.append({"fecha": fecha.isoformat(), "tipo": "amortizacion", "importe": _r(importe), "modalidad": modalidad, "id": r.get("id"), "pendiente": pendiente, "cuota": cuota if restantes else 0.0, "cuotas": restantes}) return pendiente, cuota, restantes def estado(activo: dict, movimientos: list[dict], saldos: list[dict], hoy: dt.date) -> dict: """Situación del préstamo en una fecha.""" c = cuadro(activo, movimientos, saldos) p = parametros(activo) pagadas = [f for f in c["filas"] if _fecha(f["fecha"]) <= hoy] futuras = [f for f in c["filas"] if _fecha(f["fecha"]) > hoy] # Capital pendiente hoy: el último punto de la línea de tiempo, con el mismo # orden que el cuadro (amortización del día → cuota → saldo del banco) # (a igualdad de fecha y clase gana el último aplicado: dos amortizaciones el mismo día) linea = [(p["inicio"], -1, 0, p["capital"])] linea += [(_fecha(f["fecha"]), 1, 0, f["pendiente"]) for f in c["filas"]] linea += [(_fecha(e["fecha"]), 0 if e["tipo"] == "amortizacion" else 2, n, e["pendiente"]) for n, e in enumerate(c["eventos"])] pendiente = max(x for x in linea if x[0] <= hoy)[3] amortizado_extra = sum(e["importe"] for e in c["eventos"] if e["tipo"] == "amortizacion" and _fecha(e["fecha"]) <= hoy) año = str(hoy.year) return { "pendiente": _r(pendiente), "cuota": futuras[0]["cuota"] if futuras else 0.0, "proxima": futuras[0]["fecha"] if futuras else None, "cuotas_restantes": len(futuras), "fin": c["filas"][-1]["fecha"] if c["filas"] else None, "intereses_pagados": _r(sum(f["intereses"] for f in pagadas)), "intereses_año": _r(sum(f["intereses"] for f in pagadas if f["fecha"][:4] == año)), "intereses_pendientes": _r(sum(f["intereses"] for f in futuras)), "amortizado_anticipado": _r(amortizado_extra), "cancelado": pendiente <= 0.005, "futuras": futuras, "pagadas": pagadas, "eventos": c["eventos"], } def simular(activo: dict, movimientos: list[dict], saldos: list[dict], fecha, importe: float) -> dict: """Qué pasa si amortizas «importe» en «fecha» con cada modalidad.""" f = _fecha(fecha) base = cuadro(activo, movimientos, saldos) desde = [x for x in base["filas"] if _fecha(x["fecha"]) >= f] intereses_base = sum(x["intereses"] for x in desde) res = {} for modalidad in MODALIDADES: extra = [{"tipo": "Amortizacion", "fecha": f.isoformat(), "importe": importe, "modalidad": modalidad}] c = cuadro(activo, movimientos, saldos, extra) filas = [x for x in c["filas"] if _fecha(x["fecha"]) >= f] res[modalidad] = { "ahorro_intereses": _r(intereses_base - sum(x["intereses"] for x in filas)), "cuota": filas[0]["cuota"] if filas else 0.0, "fin": filas[-1]["fecha"] if filas else None, "cuotas": len(filas), } res["actual"] = {"cuota": desde[0]["cuota"] if desde else 0.0, "fin": desde[-1]["fecha"] if desde else None, "cuotas": len(desde), "intereses": _r(intereses_base)} return res