""" 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()