245 lines
15 KiB
Python
245 lines
15 KiB
Python
"""
|
|
Pruebas de los préstamos recibidos (deudas con cuota constante, sistema francés).
|
|
|
|
python3 -m unittest discover -s pruebas -v
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import importlib.util
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from cartera import deuda as D # noqa: E402
|
|
from cartera.datos import Datos, ErrorDatos, escribir, leer # noqa: E402
|
|
from cartera.operaciones import Cartera, ErrorOperacion # noqa: E402
|
|
from cartera.vista import resumen # noqa: E402
|
|
|
|
# 100.000 € al 3 % a 20 años: cuota de referencia 554,60 €
|
|
PRESTAMO = {"id": "D1", "nombre": "Préstamo de prueba", "tipo": "Deuda", "estado": "abierta",
|
|
"fecha_inicio": "2025-01-15",
|
|
"deuda": {"capital": 100000.0, "tin": 3.0, "cuotas": 240, "primera_cuota": "2025-02-01"}}
|
|
|
|
|
|
def amortizacion(fecha, importe, modalidad, mid="m1"):
|
|
return {"id": mid, "tipo": "Amortizacion", "fecha": fecha, "importe": importe, "modalidad": modalidad}
|
|
|
|
|
|
class TestMotor(unittest.TestCase):
|
|
def test_cuadro_frances_de_referencia(self):
|
|
filas = D.cuadro(PRESTAMO, [], [])["filas"]
|
|
self.assertEqual(len(filas), 240)
|
|
self.assertEqual((filas[0]["cuota"], filas[0]["intereses"], filas[0]["capital"]), (554.6, 250.0, 304.6))
|
|
self.assertEqual(filas[-1]["pendiente"], 0.0)
|
|
self.assertEqual(filas[-1]["fecha"], "2045-01-01")
|
|
self.assertAlmostEqual(sum(f["capital"] for f in filas), 100000.0, places=2)
|
|
self.assertAlmostEqual(sum(f["intereses"] for f in filas), 33103, delta=2)
|
|
self.assertEqual(D.cuota_francesa(1200, 0, 12), 100.0) # sin intereses
|
|
|
|
def test_estado_en_una_fecha(self):
|
|
e = D.estado(PRESTAMO, [], [], dt.date(2026, 1, 20))
|
|
self.assertEqual((e["cuotas_restantes"], e["proxima"], e["cuota"]), (228, "2026-02-01", 554.6))
|
|
enero = D.cuadro(PRESTAMO, [], [])["filas"][11] # 12.ª cuota: 1-ene-2026
|
|
self.assertEqual(enero["fecha"], "2026-01-01")
|
|
self.assertEqual(e["intereses_año"], enero["intereses"]) # solo la cuota de enero
|
|
self.assertEqual(e["intereses_año"], round(e["intereses_año"], 2))
|
|
self.assertAlmostEqual(e["pendiente"], D.cuadro(PRESTAMO, [], [])["filas"][11]["pendiente"])
|
|
self.assertFalse(e["cancelado"])
|
|
|
|
def test_amortizar_reduciendo_plazo_o_cuota(self):
|
|
plazo = D.estado(PRESTAMO, [amortizacion("2026-01-10", 10000, "plazo")], [], dt.date(2026, 1, 20))
|
|
cuota = D.estado(PRESTAMO, [amortizacion("2026-01-10", 10000, "cuota")], [], dt.date(2026, 1, 20))
|
|
sin = D.estado(PRESTAMO, [], [], dt.date(2026, 1, 20))
|
|
self.assertAlmostEqual(plazo["pendiente"], sin["pendiente"] - 10000, places=2)
|
|
self.assertEqual(plazo["cuota"], 554.6) # misma cuota…
|
|
self.assertLess(plazo["cuotas_restantes"], 228) # …menos cuotas
|
|
self.assertEqual(cuota["cuotas_restantes"], 228) # mismas cuotas…
|
|
self.assertLess(cuota["cuota"], 554.6) # …cuota menor
|
|
self.assertEqual(plazo["amortizado_anticipado"], 10000)
|
|
# reducir plazo ahorra más intereses que reducir cuota
|
|
self.assertLess(plazo["intereses_pendientes"], cuota["intereses_pendientes"])
|
|
self.assertLess(cuota["intereses_pendientes"], sin["intereses_pendientes"])
|
|
|
|
def test_simular_coincide_con_amortizar(self):
|
|
s = D.simular(PRESTAMO, [], [], "2026-01-10", 10000)
|
|
hecho = D.estado(PRESTAMO, [amortizacion("2026-01-10", 10000, "plazo")], [], dt.date(2026, 1, 20))
|
|
self.assertEqual((s["plazo"]["cuotas"], s["plazo"]["fin"]), (hecho["cuotas_restantes"], hecho["fin"]))
|
|
self.assertGreater(s["plazo"]["ahorro_intereses"], s["cuota"]["ahorro_intereses"])
|
|
self.assertGreater(s["cuota"]["ahorro_intereses"], 0)
|
|
self.assertEqual(s["actual"]["cuotas"], 228)
|
|
|
|
def test_saldo_del_banco_corrige_y_cancelacion(self):
|
|
e = D.estado(PRESTAMO, [], [{"fecha": "2026-01-01", "precio": 95000.0}], dt.date(2026, 1, 20))
|
|
self.assertEqual(e["pendiente"], 95000.0)
|
|
self.assertEqual(e["eventos"][0]["tipo"], "saldo")
|
|
# el mismo día que una cuota, el saldo del banco va después de la cuota
|
|
sin = D.cuadro(PRESTAMO, [], [])["filas"][11]["pendiente"]
|
|
self.assertAlmostEqual(e["eventos"][0]["diferencia"], 95000.0 - sin, places=2)
|
|
|
|
fin = D.estado(PRESTAMO, [amortizacion("2026-01-10", 10 ** 6, "plazo")], [], dt.date(2026, 1, 20))
|
|
self.assertEqual((fin["pendiente"], fin["cancelado"], fin["cuotas_restantes"], fin["cuota"]), (0.0, True, 0, 0.0))
|
|
|
|
|
|
class TestOperaciones(unittest.TestCase):
|
|
def setUp(self):
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
self.carpeta = Path(self._tmp.name)
|
|
escribir(self.carpeta, Datos([{"id": "F1", "nombre": "Fondo", "tipo": "Fondo", "estado": "abierta",
|
|
"fecha_inicio": "2024-01-01"}], [], [], []))
|
|
self.c = Cartera(self.carpeta)
|
|
self.addCleanup(self._tmp.cleanup)
|
|
self.addCleanup(self.c.cerrar)
|
|
|
|
def alta(self, **kw):
|
|
return self.c.guardar_activo({"tipo": "Deuda", "nombre": "Hipoteca", "fecha_inicio": "2025-01-15",
|
|
"capital": "100000", "tin": "3", "cuotas": "240",
|
|
"primera_cuota": "2025-02-01", **kw})
|
|
|
|
def test_alta_y_validaciones(self):
|
|
a = self.alta()
|
|
self.assertEqual((a["id"], a["deuda"]["cuotas"], a["unidad"]), ("D1", 240, "€"))
|
|
for malo in ({"cuotas": "12,5"}, {"cuotas": "0"}, {"tin": "-1"}, {"capital": ""},
|
|
{"primera_cuota": "2025-01-15"}, {"tin": "45"}):
|
|
with self.subTest(malo=malo), self.assertRaises(ErrorOperacion):
|
|
self.alta(**malo)
|
|
self.assertEqual(leer(self.carpeta).activos[-1]["deuda"]["tin"], 3.0) # se guarda y se relee
|
|
|
|
def test_solo_amortizaciones_y_no_mas_de_lo_que_se_debe(self):
|
|
self.alta()
|
|
with self.assertRaises(ErrorOperacion):
|
|
self.c.guardar_movimiento({"activo": "D1", "tipo": "Compra", "fecha": "2026-01-10", "importe": 5})
|
|
with self.assertRaises(ErrorOperacion):
|
|
self.c.guardar_movimiento({"activo": "F1", "tipo": "Amortizacion", "fecha": "2026-01-10",
|
|
"importe": 5, "modalidad": "plazo"})
|
|
with self.assertRaises(ErrorOperacion): # falta la modalidad
|
|
self.c.guardar_movimiento({"activo": "D1", "tipo": "Amortizacion", "fecha": "2026-01-10", "importe": 5})
|
|
with self.assertRaises(ErrorOperacion): # más de lo pendiente
|
|
self.c.guardar_movimiento({"activo": "D1", "tipo": "Amortizacion", "fecha": "2026-01-10",
|
|
"importe": 200000, "modalidad": "plazo"})
|
|
m = self.c.guardar_movimiento({"activo": "D1", "tipo": "Amortizacion", "fecha": "2026-01-10",
|
|
"importe": 5000, "modalidad": "cuota"})
|
|
self.assertEqual((m["importe"], m["modalidad"], m["cantidad"]), (5000.0, "cuota", None))
|
|
m = self.c.guardar_movimiento({**m, "importe": 10000, "modalidad": "plazo"}) # editar
|
|
self.assertEqual((m["importe"], m["modalidad"]), (10000.0, "plazo"))
|
|
self.assertEqual(self.c.estado_deuda("D1", dt.date(2026, 1, 20))["amortizado_anticipado"], 10000)
|
|
|
|
def test_saldo_del_banco_simulacion_y_cierre(self):
|
|
self.alta()
|
|
self.c.precio_manual("D1", "2026-01-01", "95000")
|
|
self.assertEqual(self.c.estado_deuda("D1", dt.date(2026, 1, 20))["pendiente"], 95000.0)
|
|
s = self.c.simular_amortizacion("D1", "2026-01-10", "5000")
|
|
self.assertGreater(s["plazo"]["ahorro_intereses"], s["cuota"]["ahorro_intereses"])
|
|
with self.assertRaises(ErrorOperacion):
|
|
self.c.simular_amortizacion("D1", "2026-01-10", "96000")
|
|
with self.assertRaises(ErrorOperacion): # aún se debe dinero
|
|
self.c.cerrar_activo("D1", "2026-01-20", None)
|
|
self.c.guardar_movimiento({"activo": "D1", "tipo": "Amortizacion", "fecha": "2026-01-10",
|
|
"importe": 95000, "modalidad": "plazo"})
|
|
self.assertEqual(self.c.cerrar_activo("D1", "2026-01-20", None)["estado"], "cerrada")
|
|
|
|
def test_resumen_patrimonio_neto_sin_tocar_la_rentabilidad(self):
|
|
self.c.guardar_movimiento({"activo": "F1", "tipo": "Compra", "fecha": "2025-01-01", "cantidad": 100, "precio": 10})
|
|
self.c.precio_manual("F1", "2026-01-15", 12)
|
|
antes = resumen(self.c.datos, dt.date(2026, 1, 20))["global"]
|
|
self.alta()
|
|
r = resumen(self.c.datos, dt.date(2026, 1, 20))
|
|
g = r["global"]
|
|
for clave in ("valor", "coste", "ganancia_latente", "tir", "roi", "ytd"):
|
|
self.assertEqual(g[clave], antes[clave], clave) # la deuda no es inversión
|
|
self.assertEqual([f["id"] for f in r["deudas"]], ["D1"])
|
|
self.assertNotIn("D1", [f["id"] for f in r["abiertos"] + r["cerrados"]])
|
|
self.assertAlmostEqual(g["patrimonio_neto"], g["patrimonio"] - r["deudas"][0]["pendiente"])
|
|
self.assertEqual(g["intereses_deuda_año"], r["deudas"][0]["intereses_año"])
|
|
self.assertGreater(g["intereses_deuda_año"], 0)
|
|
self.assertNotIn("Deuda", r["por_tipo"])
|
|
|
|
queda = self.c.estado_deuda("D1", dt.date(2026, 1, 10))["pendiente"]
|
|
self.c.guardar_movimiento({"activo": "D1", "tipo": "Amortizacion", "fecha": "2026-01-10",
|
|
"importe": queda, "modalidad": "plazo"}) # lo cancela entero
|
|
r = resumen(self.c.datos, dt.date(2026, 1, 20))
|
|
self.assertTrue(any(a["tipo"] == "cerrar" and a["activo"] == "D1" for a in r["avisos"]))
|
|
|
|
@unittest.skipIf(importlib.util.find_spec("openpyxl") is None,
|
|
"falta openpyxl: solo hace falta para exportar a Excel, no para la cartera")
|
|
def test_excel_y_rutas_del_servidor(self):
|
|
from openpyxl import load_workbook
|
|
|
|
from cartera import servidor
|
|
from cartera.exportar_xlsx import exportar
|
|
self.c.guardar_movimiento({"activo": "F1", "tipo": "Compra", "fecha": "2025-01-01", "cantidad": 1, "precio": 10})
|
|
self.alta()
|
|
self.c.guardar_movimiento({"activo": "D1", "tipo": "Amortizacion", "fecha": "2026-01-10",
|
|
"importe": 1000, "modalidad": "cuota"})
|
|
cerrada = self.c.guardar_activo({"nombre": "Fondo vendido", "tipo": "Fondo", "unidad": "€"})
|
|
self.c.guardar_movimiento({"activo": cerrada["id"], "tipo": "Compra", "fecha": "2025-01-01",
|
|
"cantidad": 10, "precio": 10})
|
|
self.c.cerrar_activo(cerrada["id"], "2025-06-01", 130)
|
|
cuenta = self.c.guardar_activo({"nombre": "Cuenta", "tipo": "Cuenta", "unidad": "€"})
|
|
self.c.precio_manual(cuenta["id"], "2026-01-15", 500)
|
|
destino = self.carpeta / "cartera.xlsx"
|
|
exportar(self.c.datos, destino, dt.date(2026, 1, 20))
|
|
filas = {f[0]: f for f in load_workbook(destino)["Cartera"].iter_rows(values_only=True)}
|
|
self.assertIn("Préstamos recibidos", filas) # la deuda sale en la hoja...
|
|
self.assertIn("Patrimonio neto", filas) # ...y resta del patrimonio
|
|
self.assertIn("Inversiones cerradas", filas)
|
|
self.assertAlmostEqual(filas["Fondo vendido"][6], 30) # ganancia realizada de la cerrada
|
|
self.assertEqual(filas["Cuenta"][4], 500)
|
|
|
|
rutas = servidor.App(self.c).rutas_post()
|
|
cuadro = rutas["/api/deuda/cuadro"]({"id": "D1"})
|
|
self.assertEqual(cuadro["amortizado_anticipado"], 1000)
|
|
self.assertTrue(cuadro["futuras"])
|
|
s = rutas["/api/deuda/simular"]({"id": "D1", "fecha": "2026-01-15", "importe": 2000})
|
|
self.assertEqual(set(s), {"plazo", "cuota", "actual"})
|
|
with self.assertRaises(ErrorOperacion):
|
|
rutas["/api/deuda/cuadro"]({"id": "F1"}) # no es un préstamo
|
|
|
|
def test_datos_invalidos_no_se_leen(self):
|
|
mal = dict(PRESTAMO, deuda={"capital": 1000, "tin": 2, "cuotas": 0, "primera_cuota": "2025-02-01"})
|
|
with self.assertRaises(ErrorDatos):
|
|
escribir(self.carpeta, Datos([mal], [], [], []))
|
|
with self.assertRaises(ErrorDatos):
|
|
escribir(self.carpeta, Datos([PRESTAMO], [{"id": "x", "activo": "D1", "tipo": "Amortizacion",
|
|
"fecha": "2026-01-01", "importe": 5}], [], []))
|
|
|
|
def test_dos_amortizaciones_el_mismo_dia(self):
|
|
self.alta()
|
|
for importe, modalidad in ((10000, "cuota"), (2000, "plazo")):
|
|
self.c.guardar_movimiento({"activo": "D1", "tipo": "Amortizacion", "fecha": "2025-03-10",
|
|
"importe": importe, "modalidad": modalidad})
|
|
e = self.c.estado_deuda("D1", dt.date(2025, 3, 10))
|
|
antes = D.estado(self.c.activo("D1"), [], [], dt.date(2025, 3, 10))["pendiente"]
|
|
self.assertAlmostEqual(e["pendiente"], antes - 12000, places=2)
|
|
|
|
def test_asistente(self):
|
|
from cartera.interprete import interpretar
|
|
self.alta()
|
|
self.c.guardar_activo({"tipo": "Prestamo", "nombre": "Préstamo P2P", "fecha_inicio": "2025-01-01",
|
|
"importe_final": "1100", "vencimiento": "2027-01-01"})
|
|
hoy = dt.date(2026, 9, 19)
|
|
|
|
def una(frase):
|
|
return interpretar(frase, self.c.datos, hoy)[0]
|
|
|
|
r = una("amorticé 5.000 € del préstamo reduciendo plazo")
|
|
self.assertTrue(r["completa"], r["faltan"])
|
|
self.assertEqual({k: r["movimiento"][k] for k in ("activo", "tipo", "importe", "modalidad", "fecha")},
|
|
{"activo": "D1", "tipo": "Amortizacion", "importe": 5000.0, "modalidad": "plazo",
|
|
"fecha": "2026-09-19"})
|
|
self.c.guardar_movimiento(r["movimiento"]) # se guarda tal cual lo propone
|
|
r = una("ayer amorticé 3000 de la hipoteca para bajar la cuota")
|
|
self.assertEqual((r["movimiento"]["modalidad"], r["movimiento"]["fecha"]), ("cuota", "2026-09-18"))
|
|
self.assertIn("si reduces el plazo o la cuota", una("amortizo 2.000 € de la hipoteca")["faltan"])
|
|
self.assertFalse(una("pagué 500 € de la hipoteca")["completa"]) # las cuotas no se apuntan
|
|
r = una("a la hipoteca le quedan 90.000 € según el banco")
|
|
self.assertEqual((r["accion"], r["saldo"]["activo"], r["saldo"]["valor"]), ("saldo", "D1", 90000.0))
|
|
self.assertEqual(una("presté 1000 € en el préstamo p2p")["movimiento"]["activo"], "PR1")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|