""" Pruebas del motor fiscal (cartera/fiscal/fifo.py): la ganancia o pérdida de cada transmisión por FIFO, que es lo que pide la declaración, frente al coste medio que usa la cartera para enseñar cómo va la inversión. python3 -m unittest discover -s pruebas -v """ from __future__ import annotations import sys import unittest from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from cartera import fiscal, spot_btc # noqa: E402 from cartera.datos import leer # noqa: E402 def activo(aid, tipo, nombre=None, **extra): return {"id": aid, "nombre": nombre or aid, "tipo": tipo, "estado": "abierta", **extra} BTC = activo("C1", "Cripto", "Bitcoin", codigo="bitcoin") LN = activo("C2", "Cripto", "BTC en Lightning", codigo="bitcoin") FONDO_A, FONDO_B = activo("F1", "Fondo", "Fondo A"), activo("F2", "Fondo", "Fondo B") ACCION = activo("A1", "Accion", "Acme") class ConMovimientos(unittest.TestCase): def setUp(self): self.movs = [] def m(self, fecha, aid, tipo, cantidad=None, importe=None, **extra): mov = {"id": f"m{len(self.movs) + 1:03d}", "fecha": fecha, "activo": aid, "tipo": tipo, "cantidad": cantidad, "importe": importe, "orden": len(self.movs), **extra} if cantidad and importe: mov["precio"] = importe / cantidad self.movs.append(mov) return mov def informe(self, año, activos=(BTC, LN, FONDO_A, FONDO_B, ACCION), **opciones): return fiscal.informe(list(activos), self.movs, [], año, opciones) def unica(self, año, **opciones): t = self.informe(año, **opciones)["transmisiones"] self.assertEqual(len(t), 1) return t[0] class TestFifo(ConMovimientos): def test_se_vende_lo_primero_que_se_compro_no_el_coste_medio(self): self.m("2022-01-10", "A1", "Compra", 10, 1000) # a 100 self.m("2023-01-10", "A1", "Compra", 10, 3000) # a 300 self.m("2024-05-01", "A1", "Venta", 10, 2500) t = self.unica(2024) # con coste medio (200) la ganancia sería 500; por FIFO salen las de 100 self.assertEqual((t["valor_transmision"], t["valor_adquisicion"], t["ganancia"]), (2500.0, 1000.0, 1500.0)) self.assertEqual(t["lotes"], [{"fecha": "2022-01-10", "cantidad": 10, "coste": 1000.0}]) def test_una_venta_puede_gastar_varios_lotes_y_dejar_uno_a_medias(self): self.m("2022-01-10", "A1", "Compra", 10, 1000) self.m("2023-01-10", "A1", "Compra", 10, 3000) self.m("2024-05-01", "A1", "Venta", 15, 4500) t = self.unica(2024) self.assertEqual(t["valor_adquisicion"], 2500.0) # 10 a 100 + 5 a 300 self.assertEqual([(x["fecha"], x["cantidad"]) for x in t["lotes"]], [("2022-01-10", 10), ("2023-01-10", 5)]) queda = self.informe(2024)["existencias"][0] self.assertEqual((queda["cantidad"], queda["coste"], queda["primer_lote"]), (5, 1500.0, "2023-01-10")) def test_lo_vendido_en_años_anteriores_ya_no_esta_para_los_siguientes(self): self.m("2022-01-10", "A1", "Compra", 10, 1000) self.m("2023-01-10", "A1", "Compra", 10, 3000) self.m("2023-06-01", "A1", "Venta", 10, 2000) self.m("2024-06-01", "A1", "Venta", 10, 2000) self.assertEqual(self.unica(2023)["ganancia"], 1000.0) self.assertEqual(self.unica(2024)["ganancia"], -1000.0) # ya solo quedaban las caras r = self.informe(2024) self.assertEqual(r["total"], {"ganancias": 0.0, "perdidas": -1000.0, "neto": -1000.0, "aplazadas": 0, "integradas": 0, "computable": -1000.0}) self.assertEqual(r["ejercicios"], [2024, 2023, 2022]) def test_vender_mas_de_lo_comprado_se_avisa_como_error(self): self.m("2023-01-10", "A1", "Compra", 5, 500) self.m("2024-06-01", "A1", "Venta", 8, 1600) r = self.informe(2024) self.assertEqual(r["transmisiones"][0]["avisos"], ["sin_cubrir"]) self.assertEqual(r["tareas"][0]["clase"], "error") def test_una_compra_estimada_deja_tarea_en_la_venta_que_la_gasta(self): self.m("2023-01-10", "A1", "Compra", 5, 500, estimado=True) self.m("2024-06-01", "A1", "Venta", 5, 900) r = self.informe(2024) self.assertEqual(r["transmisiones"][0]["avisos"], ["estimado"]) self.assertEqual(len(r["tareas"]), 1) class TestBitcoin(ConMovimientos): def test_todo_el_bitcoin_es_una_sola_bolsa_este_donde_este(self): self.m("2022-01-10", "C1", "Compra", 0.1, 3000, lugar="cambium") self.m("2023-01-10", "C2", "Compra", 0.1, 2000) # en otro activo de la misma moneda self.m("2023-02-01", "C1", "Traslado", 0.1, None, lugar="cambium", lugar_destino="sparrow") self.m("2024-05-01", "C2", "Venta", 0.1, 6000) # se vende «el del nodo»… t = self.unica(2024) self.assertEqual(t["valor_adquisicion"], 3000.0) # …pero sale el más antiguo self.assertEqual(self.informe(2023)["transmisiones"], []) # el traslado no es una venta def test_pasar_bitcoin_a_otro_activo_de_bitcoin_no_tributa_salvo_la_comision(self): self.m("2022-01-10", "C1", "Compra", 0.1, 3000) self.m("2024-03-10", "C1", "Traspaso_Salida", 0.05, 3000) self.m("2024-03-10", "C2", "Traspaso_Entrada", 0.0499, 3000, coste_traspaso=1500) t = self.unica(2024) cierre = spot_btc.precio("2024-03-10")[0] self.assertEqual((t["concepto"], round(t["cantidad"], 8)), ("Comisión pagada en la propia moneda", 0.0001)) self.assertEqual(t["valor_transmision"], round(0.0001 * cierre, 2)) self.assertEqual(t["valor_adquisicion"], 3.0) queda = self.informe(2024)["existencias"][0] self.assertAlmostEqual(queda["cantidad"], 0.0999) self.assertEqual(queda["coste"], 2997.0) def test_la_comision_de_red_es_una_pequena_transmision_o_nada_segun_el_criterio(self): self.m("2022-01-10", "C1", "Compra", 0.1, 3000) self.m("2024-03-10", "C1", "Comision", 0.001) t = self.unica(2024) self.assertEqual(t["valor_adquisicion"], 30.0) self.assertGreater(t["ganancia"], 0) otro = self.informe(2024, comision="coste") self.assertEqual(otro["transmisiones"], []) self.assertEqual(otro["existencias"][0]["coste"], 3000.0) # el coste se queda en lo que resta self.assertAlmostEqual(otro["existencias"][0]["cantidad"], 0.099) def test_lo_minado_entra_por_su_valor_de_ese_dia(self): self.m("2024-03-10", "C1", "Mineria", 0.01, None, actividad="Minería") cierre = spot_btc.precio("2024-03-10")[0] self.m("2024-09-01", "C1", "Venta", 0.01, 700) r = self.informe(2024) self.assertEqual(r["recibido"][0]["valor"], round(0.01 * cierre, 2)) self.assertEqual(r["total_recibido"], round(0.01 * cierre, 2)) self.assertEqual(r["recibido"][0]["actividad"], "Minería") self.assertEqual(r["transmisiones"][0]["valor_adquisicion"], round(0.01 * cierre, 2)) self.assertEqual(self.unica(2024, sin_desembolso="cero")["valor_adquisicion"], 0.0) class TestFondos(ConMovimientos): def test_un_traspaso_no_tributa_y_conserva_coste_y_fechas(self): self.m("2021-02-01", "F1", "Compra", 100, 10000) self.m("2022-02-01", "F1", "Compra", 50, 7500) self.m("2023-06-03", "F1", "Traspaso_Salida", 150, 19500) self.m("2023-06-03", "F2", "Traspaso_Entrada", 195, 19500) self.assertEqual(self.informe(2023)["transmisiones"], []) self.m("2024-05-01", "F2", "Reembolso", 130, 15600) # las que vienen del primer lote t = self.unica(2024) self.assertEqual((t["valor_adquisicion"], t["ganancia"]), (10000.0, 5600.0)) self.assertEqual(t["lotes"][0]["fecha"], "2021-02-01") # la antigüedad viaja con ellas queda = self.informe(2024)["existencias"][0] self.assertEqual((queda["nombre"], queda["cantidad"], queda["coste"]), ("Fondo B", 65, 7500.0)) def test_el_coste_fiscal_de_la_orden_de_traspaso_manda(self): self.m("2021-02-01", "F1", "Compra", 100, 10000) self.m("2023-06-03", "F1", "Traspaso_Salida", 100, 13000) self.m("2023-06-03", "F2", "Traspaso_Entrada", 130, 13000, coste_traspaso=10400) self.m("2024-05-01", "F2", "Reembolso", 130, 14000) self.assertEqual(self.unica(2024)["valor_adquisicion"], 10400.0) def test_un_traspaso_que_viene_de_fuera_entra_con_su_coste_y_sin_fecha(self): self.m("2023-06-03", "F2", "Traspaso_Entrada", 130, 13000, coste_traspaso=9000) self.m("2024-05-01", "F2", "Reembolso", 130, 14000) t = self.unica(2024) self.assertEqual((t["valor_adquisicion"], t["avisos"]), (9000.0, ["sin_fecha"])) class TestRecompra(ConMovimientos): """La regla «de los dos meses»: la pérdida no se resta si se recompra el mismo valor.""" def test_recomprar_al_mes_aplaza_la_perdida_entera(self): self.m("2023-01-10", "A1", "Compra", 10, 1000) self.m("2024-05-01", "A1", "Venta", 10, 600) self.m("2024-05-20", "A1", "Compra", 10, 650) r = self.informe(2024) t = r["transmisiones"][0] self.assertEqual((t["ganancia"], t["aplazada"], t["computable"]), (-400.0, 400.0, 0.0)) self.assertEqual(t["recompras"], [{"fecha": "2024-05-20", "cantidad": 10, "importe": 400.0}]) self.assertEqual((r["total"]["neto"], r["total"]["aplazadas"], r["total"]["computable"]), (-400.0, 400.0, 0.0)) self.assertEqual(r["pendientes"][0]["pendiente"], 400.0) def test_fuera_de_plazo_la_perdida_cuenta(self): self.m("2023-01-10", "A1", "Compra", 10, 1000) self.m("2024-05-01", "A1", "Venta", 10, 600) self.m("2024-07-02", "A1", "Compra", 10, 650) # dos meses y un día después self.assertEqual(self.unica(2024)["aplazada"], 0.0) def test_si_recompras_menos_se_aplaza_la_parte_proporcional(self): self.m("2023-01-10", "A1", "Compra", 10, 1000) self.m("2024-05-01", "A1", "Venta", 10, 600) self.m("2024-05-20", "A1", "Compra", 4, 260) t = self.unica(2024) self.assertEqual((t["aplazada"], t["computable"]), (160.0, -240.0)) def test_lo_comprado_antes_solo_bloquea_si_sigue_en_cartera(self): # comprar y vender eso mismo al mes con pérdida, sin tener más: no hay recompra self.m("2024-04-10", "A1", "Compra", 10, 1000) self.m("2024-05-01", "A1", "Venta", 10, 600) self.assertEqual(self.unica(2024)["aplazada"], 0.0) def test_una_compra_del_mes_anterior_que_sigue_viva_si_bloquea(self): self.m("2023-01-10", "A1", "Compra", 10, 1000) self.m("2024-04-10", "A1", "Compra", 10, 700) # un mes antes; por FIFO se venden las viejas self.m("2024-05-01", "A1", "Venta", 10, 600) self.assertEqual(self.unica(2024)["aplazada"], 400.0) def test_la_perdida_se_integra_al_vender_lo_recomprado(self): self.m("2023-01-10", "A1", "Compra", 10, 1000) self.m("2024-05-01", "A1", "Venta", 10, 600) self.m("2024-05-20", "A1", "Compra", 10, 650) self.m("2025-03-01", "A1", "Venta", 5, 400) # la mitad de lo recomprado r = self.informe(2025) self.assertEqual(r["transmisiones"][0]["ganancia"], 75.0) self.assertEqual([(x["importe"], x["fecha_venta"]) for x in r["integradas"]], [(-200.0, "2024-05-01")]) self.assertEqual(r["total"]["computable"], -125.0) # 75 de esta venta − 200 que se liberan self.assertEqual(r["pendientes"][0]["pendiente"], 200.0) # la otra mitad sigue esperando def test_una_compra_de_enero_bloquea_una_perdida_de_diciembre(self): self.m("2023-01-10", "A1", "Compra", 10, 1000) self.m("2024-12-20", "A1", "Venta", 10, 600) self.m("2025-01-15", "A1", "Compra", 10, 650) self.assertEqual(self.unica(2024)["aplazada"], 400.0) self.assertEqual(self.informe(2024)["existencias"], []) # a 31 de diciembre no tenía nada def test_cada_recompra_bloquea_una_sola_perdida(self): self.m("2023-01-10", "A1", "Compra", 20, 2000) self.m("2024-05-01", "A1", "Venta", 10, 600) self.m("2024-05-05", "A1", "Venta", 10, 600) self.m("2024-05-20", "A1", "Compra", 10, 650) r = self.informe(2024) self.assertEqual([t["aplazada"] for t in r["transmisiones"]], [400.0, 0.0]) def test_en_bitcoin_no_se_aplica(self): self.m("2023-01-10", "C1", "Compra", 0.1, 5000) self.m("2024-05-01", "C1", "Venta", 0.1, 3000) self.m("2024-05-02", "C1", "Compra", 0.1, 3000) t = self.unica(2024) self.assertEqual((t["ganancia"], t["aplazada"]), (-2000.0, 0.0)) def test_en_fondos_el_plazo_se_elige(self): self.m("2023-01-10", "F1", "Compra", 10, 1000) self.m("2024-05-01", "F1", "Reembolso", 10, 600) self.m("2024-11-01", "F1", "Compra", 10, 650) # seis meses después self.assertEqual(self.unica(2024)["aplazada"], 0.0) # con dos meses, cuenta self.assertEqual(self.unica(2024, plazo_fondos="12")["aplazada"], 400.0) # con un año, se aplaza def test_un_traspaso_no_libera_la_perdida_aplazada(self): self.m("2023-01-10", "F1", "Compra", 10, 1000) self.m("2024-05-01", "F1", "Reembolso", 10, 600) self.m("2024-05-20", "F1", "Compra", 10, 650) self.m("2024-09-01", "F1", "Traspaso_Salida", 10, 700) self.m("2024-09-01", "F2", "Traspaso_Entrada", 20, 700) r = self.informe(2024) self.assertEqual((r["integradas"], r["pendientes"][0]["pendiente"]), ([], 400.0)) self.m("2025-02-01", "F2", "Reembolso", 20, 800) # ahora sí se vende lo recomprado r = self.informe(2025) self.assertEqual([x["importe"] for x in r["integradas"]], [-400.0]) self.assertEqual(r["total"]["computable"], -250.0) # +150 del reembolso − 400 liberados class TestCriterios(unittest.TestCase): def test_una_opcion_desconocida_o_mal_escrita_vuelve_a_la_de_partida(self): self.assertEqual(fiscal.opciones_validas({"comision": "coste", "sin_desembolso": "regalado", "otra": 1}), {"territorio": "comun", "comision": "coste", "sin_desembolso": "mercado", "plazo_fondos": "2", "amortizacion": "informatica"}) self.assertEqual(fiscal.opciones_validas(None)["plazo_fondos"], "2") def test_los_criterios_que_se_ensenan_son_los_que_se_aplican(self): de_partida = {c["id"]: c["titulo"] for c in fiscal.informe([], [], [], 2024)["criterios"]} elegidos = {c["id"]: c["titulo"] for c in fiscal.informe([], [], [], 2024, {"sin_desembolso": "cero"})["criterios"]} self.assertIn("valor de ese día", de_partida["sin_desembolso"]) self.assertIn("coste cero", elegidos["sin_desembolso"]) self.assertEqual(de_partida["fifo"], elegidos["fifo"]) def año(ejercicio, ganancias=0.0, rendimientos=0.0, transmitido=None, incrementos=None, retenciones=0.0): return {"ejercicio": ejercicio, "ganancias": ganancias, "rendimientos": rendimientos, "retenciones": retenciones, "incrementos": max(ganancias, 0.0) if incrementos is None else incrementos, "transmitido": 100000.0 if transmitido is None else transmitido} class TestCuotaNavarra(unittest.TestCase): """La parte del ahorro con las reglas de Navarra (cartera/fiscal/reglas/navarra.json).""" def cuota(self, *años): return fiscal.cuota.ahorro("navarra", list(años)) def test_la_escala_del_ahorro_va_por_tramos(self): c = self.cuota(año(2024, ganancias=20000)) self.assertEqual((c["base"], c["cuota"]), (20000.0, 4580.0)) # 1.200 + 880 + 1.200 + 1.300 self.assertEqual([(x["tipo"], x["base"]) for x in c["tramos"]], [(20, 6000.0), (22, 4000.0), (24, 5000.0), (26, 5000.0)]) self.assertEqual(c["tipo_medio"], 0.229) self.assertTrue(c["verificado"]) def test_los_tramos_altos_solo_existen_desde_2023(self): self.assertEqual(self.cuota(año(2024, ganancias=400000))["cuota"], 106380.0) antes = self.cuota(año(2022, ganancias=400000)) self.assertEqual(antes["cuota"], 103380.0) self.assertFalse(antes["verificado"]) # y se dice que no está comprobado def test_lo_retenido_se_descuenta_de_lo_que_sale(self): c = self.cuota(año(2024, rendimientos=1000, retenciones=190)) self.assertEqual((c["cuota"], c["retenciones"], c["resultado"]), (200.0, 190.0, 10.0)) def test_una_perdida_se_resta_de_las_ganancias_de_los_años_siguientes(self): c = self.cuota(año(2022, ganancias=-3000), año(2023, ganancias=1000), año(2024, ganancias=5000)) self.assertEqual((c["saldo_ganancias"], c["base"], c["cuota"]), (3000.0, 3000.0, 600.0)) self.assertEqual(c["pendientes"], []) medio = self.cuota(año(2022, ganancias=-3000), año(2023, ganancias=1000)) self.assertEqual((medio["base"], medio["pendientes"]), (0.0, [{"clase": "ganancias y pérdidas", "ejercicio": 2022, "importe": 2000.0, "hasta": 2026}])) def test_pasados_cuatro_años_la_perdida_ya_no_vale(self): años = [año(2019, ganancias=-3000)] + [año(y) for y in (2020, 2021, 2022)] ultimo = self.cuota(*años, año(2023, ganancias=1000)) self.assertEqual(ultimo["base"], 0.0) # 2023 es el cuarto año: aún vale self.assertEqual(self.cuota(*años, año(2023))["caducan"], [{"clase": "ganancias y pérdidas", "ejercicio": 2019, "importe": 3000.0}]) tarde = self.cuota(*años, año(2023), año(2024, ganancias=1000)) self.assertEqual((tarde["base"], tarde["pendientes"]), (1000.0, [])) def test_una_perdida_compensa_hasta_el_25_por_ciento_de_los_rendimientos(self): c = self.cuota(año(2024, ganancias=-1000, rendimientos=2000)) self.assertEqual((c["saldo_rendimientos"], c["base"]), (1500.0, 1500.0)) self.assertEqual(c["pendientes"][0]["importe"], 500.0) self.assertIn("25%", c["pasos"][0]["texto"]) despues = self.cuota(año(2024, ganancias=-1000, rendimientos=2000), año(2025, rendimientos=400)) self.assertEqual((despues["base"], despues["pendientes"][0]["importe"]), (300.0, 400.0)) def test_con_pocas_ventas_en_el_año_la_ganancia_pequeña_esta_exenta(self): exenta = self.cuota(año(2024, ganancias=600, transmitido=2000)) self.assertEqual((exenta["exento"], exenta["base"]), (600.0, 0.0)) parte = self.cuota(año(2024, ganancias=1500, transmitido=2000)) # pasa del 50 %: tributa el exceso self.assertEqual((parte["exento"], parte["base"]), (1000.0, 500.0)) self.assertEqual(self.cuota(año(2024, ganancias=600, transmitido=3500))["exento"], 0.0) def test_las_reglas_del_fichero_estan_completas_y_en_orden(self): for territorio in fiscal.cuota.TERRITORIOS: r = fiscal.cuota.reglas(territorio) self.assertEqual([t["desde"] for t in r["ejercicios"]], sorted((t["desde"] for t in r["ejercicios"]), reverse=True)) self.assertEqual(r["ejercicios"][-1]["desde"], 0) for tramo in r["ejercicios"]: topes = [h for h, _ in tramo["escala_ahorro"]] self.assertIsNone(topes[-1]) self.assertEqual(topes[:-1], sorted(topes[:-1])) for campo in ("cita_escala", "cita_compensacion", "nota", "verificado", "años_para_compensar"): self.assertIn(campo, tramo) if r["actividad"].get("amortiza", True): self.assertEqual(set(r["actividad"]["amortizacion"]), {"informatica", "maquinaria"}) self.assertIsNone(r["regularizacion"]["recargos"][-1][0]) for campo in ("texto_recargos", "prescripcion_años", "interes_demora", "fin_plazo_aproximado", "citas", "nota"): self.assertIn(campo, r["regularizacion"]) with self.assertRaises(fiscal.cuota.ErrorReglas): fiscal.cuota.reglas("atlantida") class TestTerritorioComun(ConMovimientos): """Las mismas operaciones con las reglas del resto de España (reglas/comun.json).""" def cuota(self, *años): return fiscal.cuota.ahorro("comun", list(años)) def test_la_escala_del_ahorro_cambia_con_los_años(self): g = 400000 base = 6000 * 0.19 + 44000 * 0.21 + 150000 * 0.23 esperado = {2020: 6000 * 0.19 + 44000 * 0.21 + 350000 * 0.23, 2022: base + 200000 * 0.26, 2024: base + 100000 * 0.27 + 100000 * 0.28, 2025: base + 100000 * 0.27 + 100000 * 0.30} self.assertEqual({y: self.cuota(año(y, ganancias=g))["cuota"] for y in esperado}, {y: round(v, 2) for y, v in esperado.items()}) def test_no_hay_exencion_por_vender_poco(self): c = self.cuota(año(2024, ganancias=1000, transmitido=2500, incrementos=1000)) self.assertEqual((c["exento"], c["cuota"]), (0.0, 190.0)) navarra = fiscal.cuota.ahorro("navarra", [año(2024, ganancias=1000, transmitido=2500, incrementos=1000)]) self.assertEqual(navarra["exento"], 1000.0) def test_el_territorio_se_elige_en_los_criterios(self): self.m("2023-01-10", "A1", "Compra", 10, 1000) self.m("2024-05-01", "A1", "Venta", 10, 11000) # De partida, territorio común; quien declare en Navarra lo elige c = fiscal.completo([ACCION], self.movs, [], 2024)["cuota"] self.assertEqual((c["nombre_territorio"], c["cuota"]), ("Territorio común", 6000 * 0.19 + 4000 * 0.21)) self.assertEqual(fiscal.completo([ACCION], self.movs, [], 2024, {"territorio": "navarra"})["cuota"]["nombre_territorio"], "Navarra") def test_los_gastos_de_dificil_justificacion_tienen_tope(self): self.m("2025-03-01", "C1", "Mineria", 1, None, actividad="Minería", valor_recibido=100000) self.m("2024-07-02", "C1", "Gasto", None, 4000, actividad="Minería", amortizable=True) a = fiscal.completo([BTC], self.movs, [], 2025, {"territorio": "comun"})["actividades"][0] self.assertEqual((a["amortizacion"], a["reduccion"]), (1040.0, 2000.0)) # 26 % al año; el 5 % sería 4.948 self.assertEqual(a["rendimiento"], 100000 - 1040 - 2000) def test_el_recargo_sube_un_punto_por_mes_hasta_el_año(self): import datetime as dt from cartera.fiscal import regularizar reglas, plazo = fiscal.cuota.reglas("comun")["regularizacion"], dt.date(2026, 6, 30) pct = lambda hoy: regularizar.recargo(1000, plazo, hoy, reglas)[0] # noqa: E731 self.assertEqual([pct(dt.date(2026, 7, 15)), pct(dt.date(2026, 7, 30)), pct(dt.date(2026, 10, 4)), pct(dt.date(2027, 6, 29)), pct(dt.date(2027, 6, 30))], [1, 2, 4, 12, 15]) p = fiscal.regularizacion([ACCION], self.movs, [], dt.date(2026, 10, 4), {"territorio": "comun"}) self.assertEqual((p["nombre_territorio"], p["citas"]["recargos"]), ("Territorio común", "Art. 27 Ley 58/2003")) class TestTerritoriosVascos(ConMovimientos): """Álava, Bizkaia y Gipuzkoa (reglas/.json): misma escala, sin compensación cruzada.""" VASCOS = ("alava", "bizkaia", "gipuzkoa") def test_estan_los_cinco_territorios_para_elegir(self): valores = [v for v, _ in fiscal.OPCIONES["territorio"]["valores"]] self.assertEqual(valores, ["comun", "alava", "bizkaia", "gipuzkoa", "navarra"]) self.assertEqual(set(valores), set(fiscal.cuota.TERRITORIOS)) def test_la_escala_del_ahorro_cambia_en_2026(self): # 2025: 2.500 al 20, 7.500 al 21, 5.000 al 22, 15.000 al 23 y 10.000 al 25 antes = 500 + 1575 + 1100 + 3450 + 2500 # 2026: 7.500 al 19, 7.500 al 20, 15.000 al 22 y 10.000 al 24 despues = 1425 + 1500 + 3300 + 2400 for t in self.VASCOS: c = lambda y: fiscal.cuota.ahorro(t, [año(y, ganancias=40000)])["cuota"] # noqa: E731 self.assertEqual((c(2025), c(2026)), (antes, despues), t) def test_rendimientos_y_ganancias_no_se_compensan_entre_si(self): for t in self.VASCOS: c = fiscal.cuota.ahorro(t, [año(2026, ganancias=5000, rendimientos=-1000)]) self.assertEqual(c["base"], 5000.0, t) self.assertEqual(c["pendientes"][0]["clase"], "rendimientos") # en territorio común, el 25 % del saldo positivo self.assertEqual(fiscal.cuota.ahorro("comun", [año(2026, ganancias=5000, rendimientos=-1000)])["base"], 4000.0) def test_en_bizkaia_la_recompra_tambien_aplaza_perdidas_en_bitcoin(self): self.m("2024-01-10", "C1", "Compra", 1, 60000) self.m("2025-05-01", "C1", "Venta", 1, 50000) self.m("2025-05-20", "C1", "Compra", 1, 52000) def aplazada(t): return fiscal.completo([BTC], self.movs, [], 2025, {"territorio": t})["transmisiones"][0]["aplazada"] self.assertEqual({t: aplazada(t) for t in ("bizkaia", "comun", "gipuzkoa", "alava")}, {"bizkaia": 11080.0, "comun": 0.0, "gipuzkoa": 0.0, "alava": 0.0}) # Álava, desde 2026 # (en Bizkaia la pérdida es de 11.080: el coste de 2024 se actualiza con el coeficiente 1,018) criterio = next(c for c in fiscal.completo([BTC], self.movs, [], 2025, {"territorio": "bizkaia"})["criterios"] if c["id"] == "recompra") self.assertIn("criptomonedas también", criterio["texto"]) def test_mover_bitcoin_entre_sitios_tuyos_no_es_recomprar(self): self.m("2024-01-10", "C1", "Compra", 1, 60000) self.m("2025-05-01", "C1", "Venta", 0.5, 25000) self.m("2025-05-10", "C1", "Traspaso_Salida", 0.5, 30000) self.m("2025-05-10", "C2", "Traspaso_Entrada", 0.5, 30000) r = fiscal.completo([BTC, LN], self.movs, [], 2025, {"territorio": "bizkaia"}) self.assertEqual(r["transmisiones"][0]["aplazada"], 0.0) class TestActividadVasca(ConMovimientos): """La minería en la modalidad simplificada vasca: sin amortización aparte y con reducción por tramos.""" def setUp(self): super().setUp() self.m("2025-07-02", "C1", "Gasto", None, 4000, actividad="Minería", amortizable=True, notas="ASIC") self.m("2025-08-01", "C1", "Gasto", None, 1000, actividad="Minería", notas="Hosting") self.m("2025-09-01", "C1", "Mineria", 0.1, None, actividad="Minería", valor_recibido=41000) self.m("2026-08-01", "C1", "Gasto", None, 10000, actividad="Minería", notas="Hosting") self.m("2026-09-01", "C1", "Mineria", 1, None, actividad="Minería", valor_recibido=110000) def ficha(self, territorio, ejercicio): return fiscal.completo([BTC], self.movs, [], ejercicio, {"territorio": territorio})["actividades"][0] def test_el_aparato_no_se_amortiza_aparte(self): a = self.ficha("bizkaia", 2025) self.assertEqual((a["amortiza"], a["amortizacion"], a["aparatos"], a["aparatos_sin_amortizar"]), (False, 0.0, [], 1)) self.assertEqual(a["previo"], 40000.0) self.assertEqual((a["porcentaje_reduccion"], a["reduccion"]), (0.10, 4000.0)) # 2025: el 10 % de siempre def test_gipuzkoa_reduce_por_partes_desde_2025(self): a = self.ficha("gipuzkoa", 2025) self.assertEqual(a["reduccion"], 35000 * 0.20 + 5000 * 0.15) b = self.ficha("gipuzkoa", 2026) # previo 100.000 self.assertEqual(b["reduccion"], 35000 * 0.20 + 50000 * 0.15 + 15000 * 0.10) def test_alava_reduce_por_partes_desde_2026(self): self.assertEqual(self.ficha("alava", 2025)["reduccion"], 4000.0) self.assertEqual(self.ficha("alava", 2026)["reduccion"], 16000.0) def test_bizkaia_elige_el_porcentaje_por_la_diferencia_del_año_anterior(self): a = self.ficha("bizkaia", 2026) # en 2025 la diferencia fue 40.000: entre 35.000 y 85.000 self.assertEqual((a["porcentaje_reduccion"], a["reduccion"]), (0.15, 15000.0)) self.assertIn("año anterior", a["texto_reduccion"]) def test_si_sale_negativo_no_resta_de_tu_trabajo(self): self.m("2027-02-01", "C1", "Gasto", None, 500, actividad="Minería") a = self.ficha("bizkaia", 2027) self.assertEqual(a["rendimiento"], -500.0) self.assertIn("30 años siguientes", a["texto_negativo"]) class TestRegularizarVasco(unittest.TestCase): """Recargos, intereses y prescripción de las normas forales generales tributarias.""" def setUp(self): from cartera.fiscal import cuota, regularizar self.reg, self.reglas = regularizar, lambda t: cuota.reglas(t)["regularizacion"] # noqa: E731 self.dia = __import__("datetime").date self.plazo = self.dia(2025, 6, 30) def coste(self, t, hoy): r = self.reglas(t) return self.reg.recargo(1000, self.plazo, hoy, r)[0], self.reg.intereses(1000, self.plazo, hoy, r) def test_bizkaia_no_tiene_recargo_y_perdona_los_intereses_seis_meses(self): self.assertEqual(self.coste("bizkaia", self.dia(2025, 12, 29)), (0, 0.0)) pct, interes = self.coste("bizkaia", self.dia(2026, 6, 30)) # un año: intereses desde el fin del plazo self.assertEqual(pct, 0) self.assertAlmostEqual(interes, 40.62, delta=0.02) def test_gipuzkoa_dos_cinco_o_diez_y_siempre_intereses(self): self.assertEqual([self.coste("gipuzkoa", self.dia(*d))[0] for d in ((2025, 9, 29), (2025, 10, 1), (2026, 6, 30))], [2, 5, 10]) self.assertGreater(self.coste("gipuzkoa", self.dia(2025, 8, 30))[1], 0) def test_alava_cinco_por_ciento_e_intereses_desde_el_sexto_mes(self): self.assertEqual(self.coste("alava", self.dia(2025, 12, 29)), (5, 0.0)) pct, interes = self.coste("alava", self.dia(2026, 6, 29)) # once meses: intereses desde el 30-dic self.assertEqual(pct, 5) self.assertAlmostEqual(interes, 1000 * 0.040625 * 181 / 365, delta=0.05) self.assertEqual(self.coste("alava", self.dia(2026, 7, 1))[0], 10) def test_en_alava_se_prescribe_a_los_cinco_años(self): self.assertEqual((self.reglas("alava")["prescripcion_años"], self.reglas("bizkaia")["prescripcion_años"]), (5, 4)) class TestCoeficientesVascos(ConMovimientos): """En el País Vasco el coste de lo vendido se actualiza con coeficientes según el año en que se pagó.""" def test_el_coste_se_actualiza_tambien_en_acciones(self): self.m("2020-03-10", "A1", "Compra", 10, 1000) self.m("2026-05-01", "A1", "Venta", 10, 1500) t = lambda terr: fiscal.completo([ACCION], self.movs, [], 2026, {"territorio": terr})["transmisiones"][0] # noqa: E731 self.assertEqual((t("bizkaia")["valor_adquisicion"], t("bizkaia")["ganancia"]), (1249.0, 251.0)) # 1.000 × 1,249 self.assertEqual(t("comun")["ganancia"], 500.0) self.assertEqual(t("navarra")["ganancia"], 500.0) criterios = fiscal.completo([ACCION], self.movs, [], 2026, {"territorio": "gipuzkoa"})["criterios"] self.assertTrue(any(c["id"] == "actualizacion" for c in criterios)) def test_cada_lote_con_el_coeficiente_de_su_año(self): self.m("2015-01-10", "A1", "Compra", 10, 1000) self.m("2025-01-10", "A1", "Compra", 10, 2000) self.m("2025-06-01", "A1", "Venta", 20, 3500) t = fiscal.completo([ACCION], self.movs, [], 2025, {"territorio": "bizkaia"})["transmisiones"][0] self.assertEqual(t["valor_adquisicion"], round(1000 * 1.268 + 2000 * 1.0, 2)) def test_sin_tabla_de_ese_año_no_se_actualiza_y_se_avisa(self): self.m("2015-01-10", "A1", "Compra", 10, 1000) self.m("2023-06-01", "A1", "Venta", 10, 1500) r = fiscal.completo([ACCION], self.movs, [], 2023, {"territorio": "alava"}) self.assertEqual(r["transmisiones"][0]["ganancia"], 500.0) self.assertTrue(any("coeficientes" in x["texto"] for x in r["tareas"])) class TestCuentaBroker(ConMovimientos): """Las cuentas de bróker o trading: su resultado lo da el informe del bróker y entra en la cuota.""" CTA = activo("T1", "CuentaTrading", "Bróker de futuros") def setUp(self): super().setUp() self.m("2025-01-10", "T1", "Compra", None, 10000) self.m("2025-02-01", "A1", "Compra", 10, 1000) self.m("2025-06-01", "A1", "Venta", 10, 2000) # 1.000 de ganancia por ventas def completo(self, trading=None, territorio="comun", año_=2025): return fiscal.completo([self.CTA, ACCION], self.movs, [], año_, {"territorio": territorio}, trading=trading) def test_sin_resultado_queda_fuera_y_se_pide(self): r = self.completo() self.assertEqual(r["bróker"], [{"activo": "T1", "nombre": "Bróker de futuros", "resultado": None}]) self.assertEqual(r["cuota"]["ganancias"], 1000.0) def test_el_resultado_del_broker_se_suma_a_las_ganancias(self): r = self.completo({"2025": {"T1": 2500.0}}) self.assertEqual(r["cuota"]["ganancias"], 3500.0) self.assertEqual(r["bróker"][0]["resultado"], 2500.0) self.assertNotIn("T1", [f["activo"] for f in r["fuera"]]) def test_una_perdida_del_broker_compensa_y_lo_que_sobra_queda_para_otros_años(self): r = self.completo({"2025": {"T1": -4000.0}}) self.assertEqual((r["cuota"]["base"], r["cuota"]["pendientes"][0]["importe"]), (0.0, 3000.0)) # el año siguiente se resta de las ganancias self.m("2026-03-01", "A1", "Compra", 10, 1000) self.m("2026-06-01", "A1", "Venta", 10, 5000) self.assertEqual(self.completo({"2025": {"T1": -4000.0}}, año_=2026)["cuota"]["base"], 1000.0) def test_con_resultado_del_broker_no_se_aplica_la_exencion_de_vender_poco(self): # En Navarra, vender menos de 3.000 € en el año deja exenta parte de la ganancia; con el bróker no se sabe cuánto se vendió self.assertEqual(self.completo(territorio="navarra")["cuota"]["exento"], 1000.0) self.assertEqual(self.completo({"2025": {"T1": 100.0}}, territorio="navarra")["cuota"]["exento"], 0.0) def test_la_cuenta_de_otro_año_no_aparece_antes_de_abrirla(self): self.assertEqual(fiscal.cuentas_bróker([self.CTA], self.movs, 2024, None), []) class TestBrokerEnElServidor(unittest.TestCase): """Guardar y quitar desde la pestaña el resultado de una cuenta de bróker.""" def setUp(self): import tempfile from cartera import servidor from cartera.datos import Datos, escribir from cartera.operaciones import Cartera, ErrorOperacion self.Error = ErrorOperacion t = tempfile.TemporaryDirectory() self.addCleanup(t.cleanup) carpeta = Path(t.name) / "datos" escribir(carpeta, Datos([{"id": "T1", "nombre": "Bróker", "tipo": "CuentaTrading"}, {"id": "F1", "nombre": "Fondo", "tipo": "Fondo"}], [{"id": "m1", "fecha": "2025-01-10", "activo": "T1", "tipo": "Compra", "importe": 5000.0}], [], [])) c = Cartera(carpeta) self.addCleanup(c.cerrar) self.app = servidor.App(c) self.guardar = self.app.rutas_post()["/api/fiscal/trading"] def test_se_guarda_se_ve_y_se_quita(self): self.guardar({"ejercicio": 2025, "activo": "T1", "resultado": "-350,20"}) r = self.app.fiscal("2025") self.assertEqual(r["bróker"][0]["resultado"], -350.2) self.assertEqual(r["cuota"]["pendientes"][0]["importe"], 350.2) self.guardar({"ejercicio": 2025, "activo": "T1", "resultado": ""}) self.assertIsNone(self.app.fiscal("2025")["bróker"][0]["resultado"]) self.assertNotIn("trading", {k for k, v in self.app._ajustes().items() if v}) def test_solo_para_cuentas_de_broker_y_con_un_numero(self): with self.assertRaises(self.Error): self.guardar({"ejercicio": 2025, "activo": "F1", "resultado": "10"}) with self.assertRaises(self.Error): self.guardar({"ejercicio": 2025, "activo": "T1", "resultado": "mucho"}) class TestInformeCompleto(ConMovimientos): def completo(self, año_, activos): return fiscal.completo(list(activos), self.movs, [], año_, {"territorio": "navarra"}) def test_junta_las_ventas_los_rendimientos_y_las_perdidas_de_antes(self): tierra = activo("T1", "Tierra", "Finca") self.m("2022-01-10", "A1", "Compra", 20, 2000) self.m("2023-06-01", "A1", "Venta", 10, 400) # −600 en 2023 self.m("2024-06-01", "A1", "Venta", 10, 5000) # +4.000 en 2024 self.m("2024-04-01", "A1", "Dividendo", None, 81, retencion=19) self.m("2024-09-01", "T1", "Dividendo", None, 500) # un arrendamiento no va al ahorro c = self.completo(2024, (ACCION, tierra))["cuota"] self.assertEqual((c["ganancias"], c["saldo_ganancias"], c["rendimientos"]), (4000.0, 3400.0, 100.0)) self.assertEqual((c["base"], c["cuota"], c["retenciones"], c["resultado"]), (3500.0, 700.0, 19.0, 681.0)) self.assertEqual([x["concepto"] for x in c["fuera_del_ahorro"]], ["Arrendamientos"]) def test_los_criterios_citan_tambien_la_norma_foral(self): fuentes = {c["id"]: c["fuente"] for c in self.completo(2024, (ACCION,))["criterios"]} self.assertTrue(fuentes["fifo"].startswith("Art. 43.2 DFL 4/2008 (Navarra)")) self.assertIn("39.6", fuentes["recompra"]) class TestActividad(ConMovimientos): """La minería como actividad económica: lo que va a la parte general.""" def setUp(self): super().setUp() self.m("2024-07-02", "C1", "Gasto", None, 4000, actividad="Minería", amortizable=True, notas="ASIC") self.m("2024-08-01", "C1", "Gasto", None, 100, actividad="Minería", notas="Hosting") self.m("2024-09-01", "C1", "Mineria", 0.01, None, actividad="Minería") self.m("2025-02-01", "C1", "Gasto", None, 600, actividad="mineria", notas="Hosting") self.m("2025-03-01", "C1", "Mineria", 0.02, None, actividad="Minería") self.m("2025-04-01", "C1", "Gasto", None, 999, notas="No es de la minería") self.valor = lambda fecha, q: round(q * spot_btc.precio(fecha, [])[0], 2) def ficha(self, año, **opciones): fichas = fiscal.completo([BTC], self.movs, [], año, {"territorio": "navarra", **opciones})["actividades"] self.assertEqual(len(fichas), 1) return fichas[0] def test_ingresos_menos_gastos_menos_la_amortizacion_del_aparato(self): a = self.ficha(2025) self.assertEqual((a["ingresos"], a["gastos"], a["amortizacion"]), (self.valor("2025-03-01", 0.02), 600.0, 1000.0)) previo = round(a["ingresos"] - 1600, 2) self.assertEqual(a["previo"], previo) self.assertEqual(a["reduccion"], round(previo * 0.05, 2)) # sale positivo: el 5 % self.assertEqual(a["rendimiento"], round(previo - a["reduccion"], 2)) def test_el_primer_año_el_aparato_se_amortiza_por_los_dias_que_lo_tuviste(self): a = self.ficha(2024) self.assertEqual(a["amortizacion"], round(1000 * 183 / 366, 2)) # del 2 de julio a fin de año, bisiesto self.assertEqual(a["aparatos"][0]["pendiente"], 3500.0) def test_si_sale_negativo_no_hay_reduccion(self): a = self.ficha(2024) self.assertLess(a["previo"], 0) self.assertEqual((a["reduccion"], a["rendimiento"]), (0.0, a["previo"])) def test_el_aparato_se_agota_y_deja_de_restar(self): self.m("2029-01-01", "C1", "Mineria", 0.001, None, actividad="Minería", valor_recibido=50) total = sum(self.aparato(año)["amortizacion"] for año in range(2024, 2030)) self.assertEqual(round(total, 2), 4000.0) self.assertEqual((self.aparato(2028)["amortizacion"], self.aparato(2029)["amortizacion"]), (500.0, 0.0)) def aparato(self, año): from cartera.fiscal import actividad, cuota compra = {"movimiento": "x", "fecha": "2024-07-02", "notas": "", "coste": 4000.0, "estimado": False} return actividad.amortizar(compra, año, cuota.reglas("navarra")["actividad"]["amortizacion"]["informatica"]["coeficiente"]) def test_como_maquinaria_va_mas_despacio(self): self.assertEqual(self.ficha(2025, amortizacion="maquinaria")["amortizacion"], 600.0) def test_lo_devuelto_de_un_aparato_baja_su_precio_y_lo_devuelto_de_un_gasto_baja_el_gasto(self): self.m("2025-05-01", "C1", "Devolucion", None, 1000, actividad="Minería", amortizable=True) self.m("2025-06-01", "C1", "Devolucion", None, 50, actividad="Minería") a = self.ficha(2025) self.assertEqual((a["aparatos"][0]["coste"], a["amortizacion"], a["gastos"]), (3000.0, 750.0, 550.0)) def test_sin_marcar_el_aparato_cuenta_como_gasto_del_año(self): self.movs[0]["amortizable"] = False a = self.ficha(2024) self.assertEqual((a["gastos"], a["amortizacion"], a["aparatos"]), (4100.0, 0.0, [])) def test_no_cambia_las_ganancias_ni_la_cuota_del_ahorro(self): con = fiscal.completo([BTC], self.movs, [], 2025) sin = fiscal.completo([BTC], [{**m, "actividad": ""} for m in self.movs], [], 2025) self.assertEqual(sin["actividades"], []) self.assertEqual((con["total"], con["cuota"]["cuota"]), (sin["total"], sin["cuota"]["cuota"])) def test_marcar_un_gasto_como_aparato_desde_la_cartera(self): import tempfile from cartera.datos import Datos, escribir from cartera.operaciones import Cartera with tempfile.TemporaryDirectory() as carpeta: escribir(Path(carpeta), Datos([dict(BTC, fecha_inicio="2024-01-01")], self.movs, [], [])) c = Cartera(Path(carpeta)) self.addCleanup(c.cerrar) self.assertEqual(c.marcar_amortizable(["m002", "m003", "nada"], True), {"marcadas": 1}) # el minado no es un gasto self.assertTrue(next(m for m in c.datos.vivos("movimientos") if m["id"] == "m002")["amortizable"]) class TestExportar(ConMovimientos): def test_el_libro_lleva_las_mismas_cifras_que_la_pantalla(self): try: from openpyxl import load_workbook except ImportError: self.skipTest("falta openpyxl: solo hace falta para exportar a Excel") import tempfile from cartera.fiscal.exportar import exportar self.m("2023-01-10", "A1", "Compra", 10, 1000) self.m("2024-01-10", "A1", "Compra", 10, 3000) self.m("2025-05-01", "A1", "Venta", 15, 4500) self.m("2025-06-01", "A1", "Dividendo", None, 81, retencion=19) self.m("2024-07-02", "C1", "Gasto", None, 4000, actividad="Minería", amortizable=True, notas="ASIC") self.m("2025-03-01", "C1", "Mineria", 0.02, None, actividad="Minería") r = fiscal.completo([BTC, ACCION], self.movs, [], 2025, {"territorio": "navarra"}) with tempfile.TemporaryDirectory() as carpeta: exportar(r, Path(carpeta) / "fiscal.xlsx") wb = load_workbook(Path(carpeta) / "fiscal.xlsx") self.assertEqual(wb.sheetnames, ["Resumen", "Ventas", "Lotes", "Rendimientos", "Actividad", "Recibido", "Existencias", "Criterios"]) filas = lambda hoja: [[c.value for c in f] for f in wb[hoja].iter_rows(min_row=2)] # noqa: E731 resumen = {f[0]: f[1] for f in filas("Resumen") if f[0]} self.assertEqual(resumen["Lo que cuenta este año"], r["total"]["computable"]) self.assertEqual(resumen["Cuota del ahorro"], r["cuota"]["cuota"]) self.assertEqual(resumen["Minería: rendimiento neto"], r["actividades"][0]["rendimiento"]) venta = filas("Ventas")[0] self.assertEqual((venta[1], venta[5], venta[6], venta[7]), ("Acme", 4500.0, 2500.0, 2000.0)) self.assertEqual([(f[4], f[5]) for f in filas("Lotes")], [(10, 1000.0), (5, 1500.0)]) self.assertEqual(filas("Rendimientos")[0][3:], [100.0, 19.0, 81.0]) self.assertIn(1000.0, [f[4] for f in filas("Actividad")]) # la amortización del ASIC este año class TestRegularizar(ConMovimientos): """Lo que costaría declarar ahora lo que no se declaró.""" HOY = __import__("datetime").date(2026, 10, 4) def setUp(self): super().setUp() from cartera.fiscal import cuota, regularizar self.reg, self.reglas = regularizar, cuota.reglas("navarra")["regularizacion"] self.dia = __import__("datetime").date def plan(self, declarado=None, hoy=None): return fiscal.regularizacion([BTC, ACCION], self.movs, [], hoy or self.HOY, {"territorio": "navarra"}, declarado) def fila(self, año, **k): return next(f for f in self.plan(**k)["filas"] if f["ejercicio"] == año) def test_el_recargo_crece_con_los_meses_de_retraso(self): plazo = self.dia(2026, 6, 30) pct = lambda hoy: self.reg.recargo(1000, plazo, hoy, self.reglas)[0] # noqa: E731 self.assertEqual([pct(self.dia(2026, 7, 15)), pct(self.dia(2026, 8, 15)), pct(self.dia(2026, 10, 4)), pct(self.dia(2027, 6, 29)), pct(self.dia(2027, 6, 30))], [1, 5, 10, 10, 15]) def test_los_intereses_corren_desde_el_año_de_retraso(self): plazo = self.dia(2024, 6, 30) self.assertEqual(self.reg.intereses(1000, plazo, self.dia(2025, 6, 30), self.reglas), 0.0) # del 30-jun-2025 al 30-jun-2026: un año al 4,0625 % self.assertAlmostEqual(self.reg.intereses(1000, plazo, self.dia(2026, 6, 30), self.reglas), 40.62, delta=0.02) def test_una_venta_sin_declarar_paga_cuota_recargo_e_intereses(self): self.m("2023-01-10", "A1", "Compra", 10, 1000) self.m("2024-05-01", "A1", "Venta", 10, 11000) # 10.000 de ganancia en 2024 f = self.fila(2024) cuota = 6000 * 0.20 + 4000 * 0.22 self.assertEqual((f["estado"], f["diferencia"], f["recargo_pct"], f["recargo"]), ("abierto", cuota, 15, round(cuota * 0.15, 2))) self.assertGreater(f["intereses"], 0) # el plazo acabó hace más de un año self.assertEqual(f["total"], round(cuota + f["recargo"] + f["intereses"], 2)) self.assertEqual(self.fila(2024, declarado={"2024": {"pagado": cuota}})["total"], 0.0) def test_lo_prescrito_y_lo_que_aun_esta_en_plazo_no_cuentan(self): self.m("2021-01-10", "A1", "Compra", 30, 3000) self.m("2021-05-01", "A1", "Venta", 10, 5000) self.m("2026-05-01", "A1", "Venta", 10, 5000) p = self.plan() estados = {f["ejercicio"]: f["estado"] for f in p["filas"]} self.assertEqual((estados[2021], estados[2022], estados[2026]), ("prescrito", "abierto", "en_plazo")) self.assertEqual(p["total"]["total"], 0.0) self.assertEqual(p["proxima_prescripcion"], "2027-06-30") # 2021 seguía abierto el día antes de prescribir self.assertEqual(self.fila(2021, hoy=self.dia(2026, 6, 30))["estado"], "abierto") def test_la_mineria_entra_por_tu_tipo_marginal_y_sin_el_se_avisa(self): self.m("2025-03-01", "C1", "Mineria", 0.02, None, actividad="Minería", valor_recibido=2000) sin = self.plan() self.assertTrue(sin["falta_marginal"]) self.assertEqual(self.fila(2025)["diferencia"], 0.0) f = self.fila(2025, declarado={"2025": {"marginal": 40}}) self.assertEqual((f["rendimiento"], f["general"], f["recargo_pct"]), (1900.0, 760.0, 10)) # 2.000 menos el 5 % self.assertEqual(f["total"], 836.0) def test_si_sale_a_devolver_no_hay_recargo(self): self.m("2025-02-01", "C1", "Gasto", None, 1000, actividad="Minería") f = self.fila(2025, declarado={"2025": {"marginal": 40}}) self.assertEqual((f["diferencia"], f["recargo"], f["intereses"], f["total"]), (-400.0, 0.0, 0.0, -400.0)) def test_lo_que_dice_el_usuario_se_sanea(self): self.assertEqual(self.reg.limpiar({"2024": {"pagado": "12.5", "marginal": 99}, "x": {"pagado": 1}, "2023": {"pagado": -1}}), {"2024": {"pagado": 12.5}}) class TestLoDemas(ConMovimientos): def test_los_dividendos_e_intereses_salen_con_su_retencion(self): cuenta = activo("K1", "Cuenta", "Cuenta remunerada") self.m("2024-04-01", "A1", "Dividendo", None, 81, retencion=19) self.m("2024-06-30", "K1", "Dividendo", None, 40.5, retencion=9.5) self.m("2023-06-30", "K1", "Dividendo", None, 10) r = self.informe(2024, activos=(ACCION, cuenta)) self.assertEqual(r["por_concepto"], [ {"concepto": "Dividendos", "bruto": 100.0, "retencion": 19.0, "neto": 81.0}, {"concepto": "Intereses de cuentas", "bruto": 50.0, "retencion": 9.5, "neto": 40.5}]) def test_los_planes_de_pensiones_no_son_ganancia_patrimonial(self): plan = activo("P1", "PlanPensiones", "Mi plan") self.m("2020-01-01", "P1", "Compra", 100, 1000) self.m("2024-01-01", "P1", "Reembolso", 100, 1500) r = self.informe(2024, activos=(plan,)) self.assertEqual(r["transmisiones"], []) self.assertEqual([(f["nombre"], f["cobrado"]) for f in r["fuera"]], [("Mi plan", 1500.0)]) self.assertIn("trabajo", r["fuera"][0]["motivo"]) def test_no_toca_las_operaciones(self): self.m("2022-01-10", "A1", "Compra", 10, 1000) self.m("2024-05-01", "A1", "Venta", 10, 2500) antes = [dict(m) for m in self.movs] self.informe(2024) self.assertEqual(self.movs, antes) class TestCarteraFicticia(unittest.TestCase): """La cartera de mentira que sirve de vara de medir, pasada por el FIFO.""" def test_las_cifras_de_cada_ejercicio(self): d = leer(Path(__file__).resolve().parent / "cartera_ficticia") total = {año: fiscal.informe(d.vivos("activos"), d.vivos("movimientos"), d.vivos("precios"), año)["total"]["neto"] for año in (2024, 2025, 2026)} self.assertEqual(total, {2024: 0, 2025: 1292.31, 2026: 440.77}) if __name__ == "__main__": unittest.main()