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.
882 lines
49 KiB
Python
882 lines
49 KiB
Python
"""
|
||
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()
|