caudal/programa/pruebas/test_fiscal.py
esetheyers 7e18576f86 Caudal 0.2.0
Taxes for the whole of Spain (common territory, Navarre, Álava, Bizkaia and Gipuzkoa); flats, garages and land valued on their own; Treasury bills, bonds and deposits priced on BME; broker accounts; a guided start; a page per investment with its chart and the tax of selling today; net worth at the top; undo when deleting; Woodpecker CI for the tests and the Linux build.
2026-10-15 16:49:15 -05:00

882 lines
49 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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/<territorio>.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()