#!/usr/bin/env python3 """ servidor.py — La aplicación de la cartera en el navegador. python3 -m cartera.servidor [--datos datos/] [--puerto 0] [--no-navegador] Seguridad: • Escucha solo en 127.0.0.1 (no es accesible desde la red). • Cada arranque genera una clave aleatoria que va en la dirección que se abre; sin ella la API no responde (otro programa o usuario del equipo no puede leer ni tocar la cartera). • Comprueba la cabecera Host (evita ataques de «DNS rebinding» desde webs). • Nada se envía a ningún servidor salvo las consultas públicas de precios. """ from __future__ import annotations import argparse import base64 import binascii import datetime as dt import gzip import hashlib import hmac import io import json import mimetypes import os import secrets import shutil import sys import tempfile import threading import traceback import webbrowser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, quote, urlparse from . import actividades from . import almacen from . import marcas from .acceso import (DURACION as DURACION_SESION, MINIMO as MINIMO_CLAVE, RECOMENDADO as RECOMENDADO_CLAVE, Acceso, ErrorAcceso, Espera) from .sobres import ErrorCifrado from . import adjuntos from . import fuentes from . import extractos from . import fiscal from . import inmuebles from . import vpo from . import btc from . import electrum from . import cache_btc from . import copia from . import instalacion from . import qr from . import enlace from . import deshacer_btc from . import historial_btc from . import lugares from . import prima from . import spot_btc from . import traza_btc from .calculo import Precios from .datos import FICHERO_VERSION, TIPOS_ACTIVO, ErrorDatos from .operaciones import PREFIJOS, Cartera, CarteraOcupada, ErrorOperacion from .btc import ErrorBitcoin from .electrum import ErrorElectrum from .sincronizar import AJUSTES, ErrorSync, Sincronizador, escribir_ajuste from .vista import cantidad_en, evolucion_activo, resumen WEB = Path(__file__).resolve().parent / "web" PROYECTO = Path(__file__).resolve().parent.parent # La cartera de mentira que se enseña a quien quiere mirar antes de empezar: inversiones # corrientes e inventadas (la fabrica herramientas/generar_ejemplo.py) EJEMPLO = Path(__file__).resolve().parent / "ejemplo" COOKIE = "caudal_sesion" COMPRIMIR_DESDE = 1024 # bytes COMPRIMIBLES = {"application/json", "application/javascript", "text/javascript", "text/css", "text/html", "image/svg+xml", "text/plain"} # Lo pone quien empaqueta la aplicación para un sistema que no cifra el disco CIFRAR_DE_ENTRADA = os.environ.get("CAUDAL_CIFRADO", "").strip().lower() in ("1", "si", "sí", "true") # Lo pone quien empaqueta para un sistema que YA cifra el disco (StartOS): se avisa de que cifrar aquí no hace falta DISCO_YA_CIFRADO = os.environ.get("CAUDAL_DISCO_CIFRADO", "").strip().lower() in ("1", "si", "sí", "true") MAX_CUERPO = 8_000_000 MAX_COPIA = 210_000_000 # una copia de seguridad que se sube para restaurarla (va en base64) # los extractos pueden ser grandes SESION = ".ultima_sesion.json" # Las fechas que se mueven al abrir la cartera de ejemplo, también dentro de las # fichas (vencimientos, cuotas, cultivos, alquileres) _FECHAS_EJEMPLO = {"fecha", "fecha_inicio", "fecha_cierre", "desde", "hasta", "vencimiento", "primera_cuota", "renta_desde"} def _desplazar(r, salto: dt.timedelta): if isinstance(r, list): return [_desplazar(x, salto) for x in r] if not isinstance(r, dict): return r salida = {} for k, v in r.items(): if k in _FECHAS_EJEMPLO and isinstance(v, str) and len(v) == 10: try: v = (dt.date.fromisoformat(v) + salto).isoformat() except ValueError: pass salida[k] = _desplazar(v, salto) return salida class App: def __init__(self, cartera: Cartera | None, acceso: Acceso | None = None, carpeta: Path | None = None): # Sin cartera = BLOQUEADA: los datos están cifrados y no se abren hasta que alguien entre self.cartera = cartera self.carpeta = Path(carpeta) if carpeta else cartera.carpeta # En un servidor se entra con contraseña (ver acceso.py); en tu equipo, con la dirección secreta self.acceso = acceso self.token = secrets.token_urlsafe(24) self.puerto = 0 self.servidor: ThreadingHTTPServer | None = None self._precios_lock = threading.Lock() self._sync_lock = threading.Lock() self._btc_lock = threading.Lock() self.sync = Sincronizador(self.carpeta.parent / "sync", ajustes=self.carpeta.parent) self.ultima_sesion = self._leer_sesion() if cartera else None self._real = None # (cartera, sync, sesión) de verdad mientras se enseña el ejemplo self._copia_lista = None # (id, nombre, fichero) de la copia recién hecha, hasta que se descarga self.llave: bytes | None = None # la llave de la instalación, en memoria desde que se entra (ver acceso.py) # ── copia de seguridad cifrada (ver copia.py) ── def copia_crear(self, cuerpo: dict) -> dict: if self._real is not None: raise ErrorOperacion("Sal de la cartera de ejemplo para hacer una copia de la tuya.") con_justificantes = cuerpo.get("justificantes") is not False if self.acceso is not None and self.llave: # En un servidor va con la llave de la instalación: se abrirá con la contraseña de Caudal de hoy fichero, resumen = copia.crear(self.cartera.carpeta, None, con_justificantes, self.llave, self.acceso.sobres()) self.acceso.anotar_copia() else: fichero, resumen = copia.crear(self.cartera.carpeta, str(cuerpo.get("clave") or ""), con_justificantes) nombre = f"caudal-{dt.date.today().isoformat()}{copia.EXTENSION}" self._copia_lista = (secrets.token_urlsafe(12), nombre, fichero) return {"id": self._copia_lista[0], "nombre": nombre, "bytes": resumen["bytes"], "justificantes": resumen["justificantes"]} def copia_descargar(self, ident: str) -> tuple[str, bytes]: lista = self._copia_lista if not lista or not hmac.compare_digest(lista[0], ident or ""): raise ErrorOperacion("Esa copia ya no está: vuelve a crearla.") self._copia_lista = None # se entrega una vez y no se queda en memoria ajustes = self._ajustes() # para la lista de primeros pasos: ya hay una copia ajustes["primer_uso"] = {**(ajustes.get("primer_uso") or {}), "copia": dt.date.today().isoformat()} self._guardar_ajustes(ajustes) return lista[1], lista[2] def copia_restaurar(self, cuerpo: dict) -> dict: """Sin «confirmar» solo abre la copia y cuenta lo que trae; con él, sustituye lo que hay.""" if self._real is not None: raise ErrorOperacion("Sal de la cartera de ejemplo para restaurar una copia.") try: fichero = base64.b64decode(str(cuerpo.get("fichero") or ""), validate=True) except (binascii.Error, ValueError): raise ErrorOperacion("El fichero no ha llegado bien. Vuelve a elegirlo.") from None abierta = copia.abrir(fichero, str(cuerpo.get("clave") or "")) datos = abierta["datos"] trae = {"creada": abierta["resumen"].get("creada"), "inversiones": len(datos.vivos("activos")), "operaciones": len(datos.vivos("movimientos")), "justificantes": len(abierta["adjuntos"]), "ajustes": sorted(abierta["ajustes"])} ahora = self.cartera.datos trae["ahora"] = {"inversiones": len(ahora.vivos("activos")), "operaciones": len(ahora.vivos("movimientos"))} if not cuerpo.get("confirmar"): return {"restaurada": False, **trae} if trae["ahora"]["inversiones"] or trae["ahora"]["operaciones"]: self._copia_datos() # lo que había queda en la carpeta de copias locales self.cartera.reemplazar(datos) for nombre, contenido in abierta["ajustes"].items(): escribir_ajuste(self.cartera.carpeta.parent / nombre, json.loads(contenido)) for contenido in abierta["adjuntos"].values(): adjuntos.guardar(self.cartera.carpeta, contenido) return {"restaurada": True, **trae} # ── entrar, y el cifrado de los datos en el disco (ver acceso.py y almacen.py) ── def entrar(self, llave: bytes) -> None: """Ya se sabe la contraseña: la llave queda en memoria y, si los datos estaban cerrados con ella, se abren.""" self.llave = llave almacen.poner_llave(llave, self.acceso.cifrado()) if self.cartera is None: self.cartera = Cartera(self.carpeta) self.ultima_sesion = self._leer_sesion() def _ficheros_de_datos(self) -> list[Path]: """Todo lo que Caudal guarda en el disco y hay que cifrar o descifrar.""" raiz = self.carpeta.parent ficheros = [f for f in self.carpeta.rglob("*") if f.is_file() and f.name != ".cerrojo" and not f.name.endswith(".tmp")] ficheros += [raiz / nombre for nombre in (*AJUSTES, cache_btc.FICHERO, enlace.FICHERO_DIRECCIONES)] ficheros += sorted((raiz / "backups").glob("*.tar.gz")) return [f for f in ficheros if f.is_file()] def cifrado_poner(self, cuerpo: dict) -> dict: """Activa o desactiva el cifrado de los datos. Pide la contraseña otra vez.""" if self.acceso is None: raise ErrorOperacion("El cifrado de los datos es para cuando Caudal va en un servidor.") if self._real is not None: raise ErrorOperacion("Sal de la cartera de ejemplo para cambiar el cifrado.") clave = str(cuerpo.get("clave") or "") if cuerpo.get("activar"): return self._cifrar(clave) llave = self.acceso.comprobar(clave) # Primero se deja todo en claro y solo entonces se apunta que ya no va cifrado: # si se corta a medias, al arrancar se sigue pidiendo la contraseña y todo se lee almacen.poner_llave(llave, False) cambiados = almacen.reescribir(self._ficheros_de_datos()) self.acceso.poner_cifrado(False) return {"cifrado": False, "ficheros": cambiados} def _cifrar(self, clave: str) -> dict: llave, recuperacion = self.acceso.recuperacion_nueva(clave) almacen.poner_llave(llave, False) for f in self._ficheros_de_datos(): almacen.leer(f) # si algo no se pudiera leer, mejor saberlo antes de empezar # Primero se apunta que va cifrado y luego se cifra: si se corta a medias, # al arrancar se pide la contraseña y lo que quedó en claro se lee igual self.acceso.poner_cifrado(True) almacen.poner_llave(llave, True) cambiados = almacen.reescribir(self._ficheros_de_datos()) return {"cifrado": True, "recuperacion": recuperacion, "ficheros": cambiados} def recuperacion_nueva(self, cuerpo: dict) -> dict: """Otra clave de recuperación (la anterior deja de valer). Pide la contraseña.""" if self.acceso is None: raise ErrorOperacion("En este equipo Caudal no usa contraseña.") return {"recuperacion": self.acceso.recuperacion_nueva(str(cuerpo.get("clave") or ""))[1]} # ── sincronizar con otro Caudal (ver enlace.py) ── def _sin_ejemplo(self) -> None: if self._real is not None: raise ErrorOperacion("Sal de la cartera de ejemplo para sincronizar la tuya.") def enlace_alta(self, cuerpo: dict) -> dict: """En el servidor: da una credencial a otro Caudal que acaba de entrar con la contraseña.""" if self.acceso is None or not self.llave: raise ErrorOperacion("Solo un Caudal que va en un servidor puede enlazar a otros.") return {"credencial": self.acceso.enlace_nuevo(self.llave, str(cuerpo.get("nombre") or ""))} def enlace_codigo(self) -> dict: """En el servidor: un código de pocos minutos para enlazar otro equipo sin teclear la contraseña.""" if self.acceso is None or not self.llave: raise ErrorOperacion("Solo un Caudal que va en un servidor puede enlazar a otros.") return {**self.acceso.codigo_de_enlace(), "direcciones": enlace.direcciones_de_enlace(self.carpeta)} def enlace_direcciones(self, cuerpo: dict) -> dict: """En el servidor: las direcciones que lleva el QR (lo escrito a mano manda sobre lo del sistema).""" if self.acceso is None or not self.llave: raise ErrorOperacion("Solo un Caudal que va en un servidor puede enlazar a otros.") if "casa" in cuerpo or "tor" in cuerpo: return enlace.poner_direcciones_de_enlace(self.carpeta, str(cuerpo.get("casa") or ""), str(cuerpo.get("tor") or "")) return enlace.direcciones_de_enlace(self.carpeta) def enlace_canje(self, cuerpo: dict) -> dict: """En el servidor: otro Caudal trae el código (sin sesión) y se lleva su credencial.""" return {"credencial": self.acceso.canjear_codigo(str(cuerpo.get("codigo") or ""), self.llave, str(cuerpo.get("nombre") or ""))} def dibujar_qr(self, cuerpo: dict) -> dict: try: return {"filas": qr.filas(str(cuerpo.get("texto") or ""))} except qr.TextoDemasiadoLargo as e: raise ErrorOperacion(str(e)) from None def enlace_olvidar_equipos(self, cuerpo: dict) -> dict: if self.acceso is None: raise ErrorOperacion("Solo un Caudal que va en un servidor tiene equipos enlazados.") return {"olvidados": self.acceso.olvidar_enlaces()} def enlace_configurar(self, cuerpo: dict) -> dict: """En el otro Caudal: se enlaza con un servidor (o deja de estarlo).""" e = enlace.Enlace(self.carpeta) if cuerpo.get("desenlazar"): e.olvidar() return {"enlazado": False} if "url_tor" in cuerpo and not cuerpo.get("url"): # ya enlazado: la dirección .onion para fuera de casa return {"enlazado": True, **e.poner_onion(str(cuerpo.get("url_tor") or ""))} r = e.enlazar(str(cuerpo.get("url") or ""), str(cuerpo.get("clave") or ""), str(cuerpo.get("certificado") or ""), str(cuerpo.get("nombre") or ""), str(cuerpo.get("codigo") or ""), str(cuerpo.get("certificado_pem") or ""), str(cuerpo.get("url_tor") or "")) return {"enlazado": True, "url_tor": r["url_tor"]} def enlace_sincronizar(self, cuerpo: dict) -> dict: self._sin_ejemplo() if not self._sync_lock.acquire(blocking=False): raise ErrorOperacion("Ya hay una sincronización en marcha.") try: return enlace.sincronizar(self, bool(cuerpo.get("simular"))) finally: self._sync_lock.release() def enlace_atender(self, credencial: str, cuerpo: dict) -> dict: """En el servidor: lo que pide un Caudal enlazado, que se identifica con su credencial.""" llave = self.acceso.llave_de_enlace(credencial) self.entrar(llave) # si los datos estaban cerrados, su credencial los abre self._sin_ejemplo() return enlace.atender(self, str(cuerpo.get("orden") or ""), cuerpo, PREFIJOS) def cambiar_clave(self, cuerpo: dict) -> dict: if self.acceso is None: raise ErrorOperacion("En este equipo Caudal no usa contraseña.") # «_sesion» lo pone el manejador: quien la cambia sigue dentro; las demás sesiones se cierran self.acceso.cambiar(str(cuerpo.get("actual") or ""), str(cuerpo.get("nueva") or ""), str(cuerpo.get("_sesion") or "")) return {"cambiada": True} def ejemplo(self, entrar: bool) -> dict: """Entra o sale de la cartera de ejemplo. El ejemplo es una copia en una carpeta temporal: la cartera de verdad no se toca ni se mezcla, y lo que se haga en el ejemplo se tira al salir.""" if entrar and self._real is None: if not EJEMPLO.is_dir(): raise ErrorOperacion("Esta instalación no trae la cartera de ejemplo.") carpeta = Path(tempfile.mkdtemp(prefix="caudal-ejemplo-")) / "datos" shutil.copytree(EJEMPLO, carpeta) # El ejemplo está escrito respecto a una fecha base: se desplaza entero para que acabe hoy # y siempre parezca una cartera al día, se abra cuando se abra try: base = dt.date.fromisoformat(json.loads((carpeta / "base.json").read_text(encoding="utf-8"))["base"]) except (OSError, ValueError, KeyError): base = dt.date.today() salto = dt.timedelta(days=max((dt.date.today() - base).days, 0)) for fichero in carpeta.glob("*.jsonl"): registros = [_desplazar(json.loads(x), salto) for x in fichero.read_text(encoding="utf-8").splitlines() if x.strip()] for r in registros: if fichero.stem == "precios": r["fuente"] = "ejemplo" fichero.write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in registros), encoding="utf-8") (carpeta / "base.json").unlink(missing_ok=True) self._real = (self.cartera, self.sync, self.ultima_sesion) self.cartera = Cartera(carpeta) self.cartera._guardar() # el valor de inmuebles y Letras, la cuota de la hipoteca… ya a las fechas nuevas self.sync = Sincronizador(carpeta.parent / "sync", ajustes=carpeta.parent) self.ultima_sesion = None elif not entrar and self._real is not None: temporal = self.cartera.carpeta.parent self.cartera.cerrar() self.cartera, self.sync, self.ultima_sesion = self._real self._real = None shutil.rmtree(temporal, ignore_errors=True) return {"ejemplo": self._real is not None} # ── dónde viven los datos del instalable de escritorio (ver instalacion.py) ── def instalacion(self) -> dict | None: """None salvo en el instalable. «elegible»: aún no hay nada apuntado y se puede cambiar de sitio.""" if not instalacion.empaquetado() or self.acceso is not None: return None base, modo = instalacion.base_actual() vacia = self._real is None and self.cartera is not None and not ( self.cartera.datos.activos or self.cartera.datos.movimientos) return {"modo": modo, "carpeta": str(base), "elegible": vacia and self.cartera.carpeta.parent == base, "portatil": str(instalacion.base_portatil()), "personal": str(instalacion.base_personal()), "portatil_posible": instalacion.se_puede_escribir_junto_al_programa(), "lanzador": instalacion.lanzador()} def instalacion_lanzador(self, poner: bool) -> dict: """Añade Caudal al menú de aplicaciones del equipo, o lo quita.""" if not self.instalacion() or instalacion.lanzador() is None: raise ErrorOperacion("Aquí no sé añadir Caudal al menú de aplicaciones.") try: if poner: instalacion.poner_lanzador(WEB / "icono.svg") else: instalacion.quitar_lanzador() except (OSError, ValueError) as e: raise ErrorOperacion(f"No he podido cambiar el menú de aplicaciones: {e}") from None return self.instalacion() def instalacion_mover(self, modo: str) -> dict: estado = self.instalacion() if not estado or not estado["elegible"]: raise ErrorOperacion("Los datos ya no se pueden cambiar de sitio desde aquí: haz una copia de seguridad " "y restáurala donde los quieras.") self.cartera.cerrar() try: instalacion.mover(modo) except (OSError, ValueError) as e: raise ErrorOperacion(f"No he podido cambiar los datos de sitio: {e}") from None finally: # Haya ido bien o mal, se vuelve a abrir la cartera donde haya quedado base = instalacion.base_actual()[0] self.carpeta = base / "datos" self.cartera = Cartera(self.carpeta) self.sync = Sincronizador(base / "sync", ajustes=base) return self.instalacion() def _leer_sesion(self) -> dict | None: try: return json.loads(almacen.leer_texto(self.cartera.carpeta / SESION)) except (OSError, ValueError): return None def guardar_sesion(self, valor: float, neto: float = 0.0) -> None: ruta = self.cartera.carpeta / SESION try: almacen.escribir(ruta, json.dumps({"fecha": dt.date.today().isoformat(), "valor": valor, "neto": neto}).encode()) except OSError: pass # ── API ── def estado(self) -> dict: datos = self.cartera.datos hoy = dt.date.today() r = resumen(datos, hoy, vpo.serie(self.carpeta.parent)) precios = datos.vivos("precios") auto = [p for p in precios if p.get("fuente") not in ("manual",) and not str(p.get("fuente", "")).startswith("xlsx")] ajustes = self._ajustes() return { "resumen": r, "inmuebles": self._listas_inmuebles(), "activos": datos.vivos("activos"), "movimientos": sorted(datos.vivos("movimientos"), key=lambda m: (m["fecha"], m.get("orden") or 0), reverse=True), "recurrentes": datos.vivos("recurrentes"), "ultima_descarga_precios": max((p.get("modificado", "")[:10] for p in auto), default=None), "ultima_sesion": self.ultima_sesion, "sync": self.sync.estado(datos), "btc": self.btc_resumen(), # Cuánto hay en cada lugar de los activos que los tienen (ver lugares.py) "lugares": {a["id"]: lugares.saldos(datos.vivos("movimientos"), a["id"], hoy.isoformat()) for a in datos.vivos("activos") if a.get("lugares")}, # Compras y ventas de bitcoin frente al mercado de ese día (ver prima.py) # (en el ejemplo no: sus precios son inventados y no casan con los de ningún día de verdad) "primas": {} if self._real is not None else prima.de_movimientos(datos.vivos("activos"), datos.vivos("movimientos"), precios), "carpeta": str(self.cartera.carpeta), "ejemplo": self._real is not None, # Los formatos de extracto que esta instalación sabe leer (ver extractos.registrar) "extractos": sorted(extractos.NOMBRES.values()), "servidor": self.acceso is not None, # Cambió la contraseña y no hay copia de seguridad con la nueva (ver acceso.py) "copia_pendiente": self.acceso is not None and self.acceso.copia_pendiente(), "cifrado": self.acceso is not None and self.acceso.cifrado(), # Con qué servidor sincroniza este Caudal, y qué equipos sincronizan con él si el servidor es este "enlace": {k: v for k, v in enlace.Enlace(self.carpeta).config().items() if k in ("url", "url_tor", "ultima")}, "equipos": self.acceso.enlaces() if self.acceso is not None else [], "disco_cifrado": DISCO_YA_CIFRADO, "recuperacion": self.acceso is not None and self.acceso.tiene_recuperacion(), "hay_ejemplo": EJEMPLO.is_dir(), "instalacion": self.instalacion(), # El primer uso: qué dijo que tiene, si cerró la lista de primeros pasos y cuándo bajó una copia "primer_uso": ajustes.get("primer_uso") or {}, "territorio": fiscal.opciones_validas(ajustes.get("fiscal"))["territorio"], "territorio_elegido": "territorio" in (ajustes.get("fiscal") or {}), "territorios": fiscal.cuota.TERRITORIOS, # Justificantes apuntados cuyo fichero no está en este equipo "adjuntos_faltan": sorted(h for h in adjuntos.referenciados(datos.movimientos) if not adjuntos.tiene(self.cartera.carpeta, h)), } def _guardar_activo(self, cuerpo: dict) -> dict: """Guarda un activo; si es un inmueble con renta mensual, sus cobros ya vencidos se apuntan en el momento, sin esperar a la siguiente apertura.""" a = self.cartera.guardar_activo(cuerpo) if a["tipo"] == "Inmueble" and (a.get("inmueble") or {}).get("renta_mensual"): self.cartera.generar_recurrentes(dt.date.today(), fuentes.cambio) return a def _listas_inmuebles(self) -> dict: """Lo que la ficha de un inmueble ofrece para elegir, con las modalidades de alquiler del territorio donde declara el usuario.""" from .fiscal.alquiler import CLASES_GASTO territorio = fiscal.opciones_validas(self._ajustes().get("fiscal"))["territorio"] reglas = fiscal.cuota.reglas(territorio).get("alquiler") or {} actuales = fiscal.alquiler.reglas_del_año(reglas, dt.date.today().year) if reglas else {} return {"clases": inmuebles.CLASES, "usos": inmuebles.USOS, "comunidades": inmuebles.comunidades(inmuebles.serie(self.carpeta.parent)), "territorio": territorio, "nombre_territorio": fiscal.cuota.TERRITORIOS[territorio], "modalidades": {k: v["texto"] for k, v in (actuales.get("modalidades") or {}).items()}, "reducciones": reglas.get("reducciones") or [], "imputa": bool(reglas.get("imputacion")), "clases_gasto": CLASES_GASTO, "tierra": self._listas_tierra(), "vpo": self._listas_vpo()} def _listas_vpo(self) -> dict: """Para la ficha de una vivienda protegida: regímenes, piezas de partida y el módulo de este año en cada comunidad que lo trae (oficial o estimado).""" s = vpo.serie(self.carpeta.parent) año = dt.date.today().year modulos = {} for comunidad in s["modulos"]: m = vpo.modulo({"comunidad": comunidad}, año, s) if m: modulos[comunidad] = {"valor": m[0], "origen": m[1]} return {"regimenes": {k: {"nombre": v["nombre"], "vivienda": v["vivienda"], "anejo": v["anejo"]} for k, v in s["regimenes"].items()}, "clases_pieza": vpo.CLASES_PIEZA, "piezas_iniciales": vpo.PIEZAS_INICIALES, "año": año, "modulos": modulos} @staticmethod def _listas_tierra() -> dict: """Para la ficha de una finca rústica: los cultivos y el precio medio del último año publicado por comunidad (la web avisa si sale el nacional).""" from . import tierra s, año = tierra.serie(), tierra.ultimo_año() return {"cultivos": tierra.cultivos(), "origenes": tierra.ORIGENES, "año": año, "fuente": s["fuente"], "precios": {ccaa: {c: tierra.precio(ccaa, c, año)[0] for c in datos} for ccaa, datos in s["precios"].items()}} def fiscal(self, ejercicio: str = "") -> dict: """Lo que hay que mirar para declarar un ejercicio (ver cartera/fiscal). Sin ejercicio: el último año ya cerrado que tenga operaciones.""" datos = self.cartera.datos movimientos = datos.vivos("movimientos") años = fiscal.ejercicios(movimientos) try: año = int(ejercicio) except (TypeError, ValueError): pasado = dt.date.today().year - 1 año = pasado if pasado in años or not años else años[0] if not 1990 <= año <= dt.date.today().year: raise ErrorOperacion("Ese ejercicio no es válido.") ajustes = self._ajustes() elegidos = ajustes.get("fiscal") or {} r = fiscal.completo(datos.vivos("activos"), movimientos, datos.vivos("precios"), año, elegidos, trading=ajustes.get("trading")) # Mientras nadie haya dicho dónde declara, la pantalla lo pregunta: las cifras dependen de ello r["territorio_elegido"] = "territorio" in elegidos return r # Lo que se vende y cómo se apunta, para «si la vendieras hoy» (lo demás no tributa al venderlo: # una cuenta, un préstamo o un plan de pensiones, que se cobra como sueldo) VENDIBLES = {"Cripto": "Venta", "Accion": "Venta", "ETF": "Venta", "Fondo": "Reembolso", "Metal": "Venta", "Tierra": "Venta", "Inmueble": "Venta", "RentaFija": "Reembolso"} def si_vendo(self, aid: str) -> dict: """Si se vendiera hoy toda una inversión al precio de hoy: la ganancia según Hacienda y lo que subiría (o bajaría) la cuota del ahorro de este año, calculado con todo lo demás del año y las pérdidas pendientes.""" datos = self.cartera.datos a = self.cartera.activo(aid) tipo = self.VENDIBLES.get(a["tipo"]) hoy = dt.date.today() fila = next((f for f in resumen(datos, hoy)["abiertos"] if f["id"] == aid), None) if not tipo or fila is None or not fila.get("valor") or (inmuebles.es_propio(a)): return {"posible": False} movs = datos.vivos("movimientos") venta = {"id": "si-vendo", "activo": aid, "tipo": tipo, "fecha": hoy.isoformat(), "importe": round(fila["valor"], 2), "orden": 10 ** 9} if a["tipo"] == "RentaFija": from . import rentafija venta["cantidad"] = round(rentafija.coste_vivo([m for m in movs if m["activo"] == aid], (hoy + dt.timedelta(days=1)).isoformat(), float((a.get("renta_fija") or {}).get("nominal") or 0))[1], 2) elif a.get("valoracion") != "total" and a["tipo"] != "Inmueble": venta.update({"cantidad": fila["cantidad"], "precio": fila.get("precio")}) ajustes = self._ajustes() elegidos = ajustes.get("fiscal") or {} args = (datos.vivos("activos"),) sin = fiscal.completo(*args, movs, datos.vivos("precios"), hoy.year, elegidos, trading=ajustes.get("trading")) con = fiscal.completo(*args, movs + [venta], datos.vivos("precios"), hoy.year, elegidos, trading=ajustes.get("trading")) ganancia = round(con["cuota"]["ganancias"] + con["cuota"]["rendimientos"] - sin["cuota"]["ganancias"] - sin["cuota"]["rendimientos"], 2) return {"posible": True, "valor": round(fila["valor"], 2), "ganancia": ganancia, "impuesto": round(con["cuota"]["cuota"] - sin["cuota"]["cuota"], 2), "tipo_medio": round((con["cuota"]["cuota"] - sin["cuota"]["cuota"]) / ganancia, 4) if ganancia > 0 else None, "territorio": con["cuota"]["nombre_territorio"], "ejercicio": hoy.year, "pendientes_antes": round(sum(p["importe"] for p in sin["cuota"]["pendientes"]), 2)} def fiscal_xlsx(self, ejercicio: str = "") -> tuple[int, bytes]: """(ejercicio, libro) con el informe fiscal de ese año en una hoja de cálculo.""" from .fiscal.exportar import exportar r = self.fiscal(ejercicio) with tempfile.TemporaryDirectory() as t: destino = Path(t) / "fiscal.xlsx" exportar(r, destino) return r["ejercicio"], destino.read_bytes() def fiscal_regularizacion(self) -> dict: """Lo que costaría declarar ahora lo no declarado (ver fiscal/regularizar.py).""" datos, ajustes = self.cartera.datos, self._ajustes() return fiscal.regularizacion(datos.vivos("activos"), datos.vivos("movimientos"), datos.vivos("precios"), dt.date.today(), ajustes.get("fiscal"), ajustes.get("regularizacion"), trading=ajustes.get("trading")) def fiscal_trading(self, cuerpo: dict) -> dict: """Guarda el resultado de un año de una cuenta de bróker, copiado de su informe fiscal (ganancia, o pérdida en negativo). Vacío = quitarlo.""" año, aid, valor = str(cuerpo.get("ejercicio") or ""), str(cuerpo.get("activo") or ""), cuerpo.get("resultado") activo = next((a for a in self.cartera.datos.vivos("activos") if a["id"] == aid), None) if not año.isdigit() or activo is None or activo["tipo"] != "CuentaTrading": raise ErrorOperacion("Petición no válida.") ajustes = self._ajustes() todo = {k: dict(v) for k, v in (ajustes.get("trading") or {}).items()} fila = todo.setdefault(año, {}) if valor in (None, ""): fila.pop(aid, None) else: try: importe = round(float(str(valor).replace(",", ".")), 2) except ValueError: raise ErrorOperacion("Pon el resultado en euros (con un menos delante si fue pérdida).") from None if abs(importe) > 1e9: raise ErrorOperacion("Ese importe no es válido.") fila[aid] = importe if not fila: todo.pop(año) ajustes["trading"] = todo self._guardar_ajustes(ajustes) return {"ok": True} def fiscal_declarado(self, cuerpo: dict) -> dict: """Guarda lo que el usuario dice de un ejercicio: lo que ya pagó por el ahorro y su tipo marginal. Vacío = quitarlo.""" año = str(cuerpo.get("ejercicio") or "") if not año.isdigit(): raise ErrorOperacion("Petición no válida.") ajustes = self._ajustes() todo = dict(ajustes.get("regularizacion") or {}) fila = {k: cuerpo.get(k) for k in ("pagado", "marginal") if cuerpo.get(k) not in (None, "")} nuevo = fiscal.regularizar.limpiar({año: fila}) if len(nuevo.get(año, {})) != len(fila): raise ErrorOperacion("Pon un importe en euros y un tipo entre 0 y 60 %.") todo.pop(año, None) todo.update(nuevo) ajustes["regularizacion"] = todo self._guardar_ajustes(ajustes) return self.fiscal_regularizacion() def fiscal_criterios(self, cuerpo: dict) -> dict: """Guarda los criterios elegidos en la pestaña Fiscal (viajan con los ajustes).""" if not isinstance(cuerpo.get("opciones"), dict): raise ErrorOperacion("Petición no válida.") ajustes = self._ajustes() ajustes["fiscal"] = fiscal.opciones_validas({**(ajustes.get("fiscal") or {}), **cuerpo["opciones"]}) self._guardar_ajustes(ajustes) return ajustes["fiscal"] PRIMER_USO_TIENE = ("bitcoin", "fondos", "acciones", "pension", "rentafija", "inmuebles", "cuentas", "broker") def primer_uso(self, cuerpo: dict) -> dict: """Las respuestas de la bienvenida: qué tiene (para su lista de primeros pasos) y dónde declara; también cerrar esa lista. Viajan con los ajustes.""" ajustes = self._ajustes() p = dict(ajustes.get("primer_uso") or {}) if "tiene" in cuerpo: tiene = cuerpo.get("tiene") if not isinstance(tiene, list): raise ErrorOperacion("Petición no válida.") p["tiene"] = [t for t in self.PRIMER_USO_TIENE if t in tiene] if "cerrado" in cuerpo: p["cerrado"] = bool(cuerpo.get("cerrado")) if cuerpo.get("territorio"): if cuerpo["territorio"] not in fiscal.cuota.TERRITORIOS: raise ErrorOperacion("Ese territorio no lo conozco.") ajustes["fiscal"] = fiscal.opciones_validas({**(ajustes.get("fiscal") or {}), "territorio": cuerpo["territorio"]}) ajustes["primer_uso"] = p self._guardar_ajustes(ajustes) return p def unir_lugares(self, cuerpo: dict) -> dict: """Junta dos lugares; los monederos y los exchanges recordados que apuntaban al que desaparece pasan al que se queda.""" aid, desde, hacia = (str(cuerpo.get(k) or "") for k in ("activo", "desde", "hacia")) with self._btc_lock: r = self.cartera.unir_lugares(aid, desde, hacia) config = self._btc_config() monederos = [m for m in config.get("monederos") or [] if m.get("activo") == aid and m.get("lugar") == desde] for m in monederos: m["lugar"] = hacia if monederos: self._btc_guardar(config) ajustes = self._ajustes() destinos = ajustes.get("destinos_exchange") if isinstance(destinos, dict) and desde in destinos.values(): ajustes["destinos_exchange"] = {k: (hacia if v == desde else v) for k, v in destinos.items()} self._guardar_ajustes(ajustes) return {**r, "monederos": len(monederos)} def convertir_en_lugar(self, cuerpo: dict) -> dict: """Un activo que es un sitio del mismo bitcoin pasa a ser un lugar de otro; sus monederos del nodo pasan también al activo de destino.""" origen, destino = str(cuerpo.get("id") or ""), str(cuerpo.get("destino") or "") with self._btc_lock: lugar = self.cartera.convertir_en_lugar(origen, destino, cuerpo.get("clase") or "otro") config = self._btc_config() movidos = 0 for m in config.get("monederos") or []: if m.get("activo") == origen: m.update({"activo": destino, "lugar": lugar["id"]}) movidos += 1 if movidos: self._btc_guardar(config) return {"lugar": lugar, "monederos": movidos} def adjuntar(self, cuerpo: dict) -> dict: try: contenido = base64.b64decode(str(cuerpo.get("contenido") or ""), validate=True) except (binascii.Error, ValueError): raise ErrorOperacion("El fichero no ha llegado bien; vuelve a elegirlo.") from None return self.cartera.adjuntar(cuerpo.get("id"), cuerpo.get("nombre"), contenido) def actualizar_precios(self) -> dict: if self._real is not None: raise ErrorOperacion("En la cartera de ejemplo los precios son inventados y no se actualizan.") if not self._precios_lock.acquire(blocking=False): raise ErrorOperacion("Ya se están actualizando los precios.") try: informe = fuentes.actualizar(self.cartera.datos.vivos("activos")) # Los inmuebles con valor estimado: el índice del INE más reciente (se guarda junto a los ajustes) if any(a["tipo"] == "Inmueble" and (a.get("inmueble") or {}).get("auto") for a in self.cartera.datos.vivos("activos")): try: nueva = inmuebles.descargar(fuentes._sesion()) (self.carpeta.parent / inmuebles.FICHERO_ACTUALIZADO).write_text( json.dumps(nueva, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") informe.append({"activo": "", "nombre": "Índice de precios de vivienda (INE)", "ok": True, "resultado": None, "mensaje": f"hasta {nueva['series']['nacional']['segunda_mano'][-1][0]}"}) except Exception as e: # sin INE se sigue con la serie que haya informe.append({"activo": "", "nombre": "Índice de precios de vivienda (INE)", "ok": False, "resultado": None, "mensaje": f"no se pudo descargar ({e})"}) # Las viviendas protegidas por metros: el IPC, para estimar el módulo de los años aún sin publicar if any(vpo.es_por_metros(a.get("inmueble") or {}) for a in self.cartera.datos.vivos("activos") if a["tipo"] == "Inmueble"): try: nuevo = vpo.descargar_ipc(fuentes._sesion()) (self.carpeta.parent / vpo.FICHERO_IPC).write_text( json.dumps(nuevo, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") informe.append({"activo": "", "nombre": "IPC (INE), para el módulo de vivienda protegida", "ok": True, "resultado": None, "mensaje": f"hasta {nuevo['datos'][-1][0]}"}) except Exception as e: # sin INE se sigue con el que haya informe.append({"activo": "", "nombre": "IPC (INE), para el módulo de vivienda protegida", "ok": False, "resultado": None, "mensaje": f"no se pudo descargar ({e})"}) self.cartera.aplicar_precios([i["resultado"] for i in informe if i["ok"] and i["resultado"]]) informe += self._precios_renta_fija() gen = self.cartera.generar_recurrentes(dt.date.today(), fuentes.cambio) return {"informe": informe, "automaticas": gen} finally: self._precios_lock.release() def _precios_renta_fija(self) -> list[dict]: """Bonos, obligaciones y Letras con ISIN: su precio en BME (AIAF o SENAF). Sin cotización, su valor sigue calculándose solo (devengo).""" from . import bme, rentafija datos = self.cartera.datos suyos = [a for a in datos.vivos("activos") if a["tipo"] == "RentaFija" and a.get("estado") != "cerrada" and bme.ISIN.match((a.get("codigo") or "").strip().upper())] if not suyos: return [] try: precios = bme.consultar([a["codigo"] for a in suyos], fuentes._sesion()) except Exception as e: # BME caído o cambiado: se sigue con el valor calculado return [{"activo": a["id"], "nombre": a["nombre"], "ok": False, "resultado": None, "mensaje": f"BME no contestó ({e})"} for a in suyos] informe, valores = [], [] for a in suyos: p = precios.get(a["codigo"].strip().upper()) movs = [m for m in datos.vivos("movimientos") if m["activo"] == a["id"]] v = rentafija.valor_de_mercado(a, movs, dt.date.fromisoformat(p["fecha"]), p["precio"]) if p else None if v is None: informe.append({"activo": a["id"], "nombre": a["nombre"], "ok": False, "resultado": None, "mensaje": "no cotiza hoy en BME: sigue el valor calculado"}) continue valores.append({"activo": a["id"], "fecha": p["fecha"], "valor_total": v, "fuente": rentafija.FUENTE_MERCADO}) informe.append({"activo": a["id"], "nombre": a["nombre"], "ok": True, "resultado": None, "mensaje": f"{p['precio']} % ({p['origen']}, {p['mercado']})"}) if valores: self.cartera.aplicar_valores(valores) return informe def comprobar_fuentes(self) -> dict: """Prueba una por una las webs de las que salen los precios. Sale a internet, pero solo con los identificadores públicos de ejemplo que lleva escritos `precios.py`: no consulta ningún activo de la cartera. """ if not self._precios_lock.acquire(blocking=False): raise ErrorOperacion("Espera a que terminen de actualizarse los precios.") try: return fuentes.comprobar() finally: self._precios_lock.release() def interpretar(self, cuerpo: dict) -> list[dict]: from .interprete import interpretar texto = str(cuerpo.get("texto") or "")[:1000] if not texto.strip(): raise ErrorOperacion("Escribe qué has hecho.") cache: dict = {} def cambio(fecha, moneda): # solo consulta internet si hay dólares if (fecha, moneda) not in cache: cache[(fecha, moneda)] = fuentes.cambio(fecha, moneda) return cache[(fecha, moneda)] nuevo = cuerpo.get("activo_nuevo") if nuevo is not None: if not isinstance(nuevo, dict) or nuevo.get("tipo") not in TIPOS_ACTIVO or not nuevo.get("nombre"): raise ErrorOperacion("Activo nuevo no válido.") nuevo = {k: nuevo.get(k) for k in ("nombre", "codigo", "tipo", "unidad", "precio", "fecha_precio", "fuente", "alias")} if not nuevo.get("precio"): encontrado, _ = fuentes.precio({"id": "nuevo", **nuevo}) if encontrado: nuevo.update({"precio": encontrado["precio"], "fecha_precio": encontrado["fecha"], "fuente": encontrado["fuente"]}) propuestas = interpretar(texto, self.cartera.datos, dt.date.today(), cambio, cuerpo.get("activo") or None, nuevo) for p in propuestas: if p.get("busqueda") and nuevo is None: try: encontrados = fuentes.buscar(p["busqueda"]) p["nuevos"] = encontrados["resultados"][:6] if not p["nuevos"] and encontrados["errores"]: p["avisos"].append("No he podido buscar activos nuevos en internet.") except Exception: p["avisos"].append("No he podido buscar activos nuevos en internet.") return propuestas # ── sincronización cifrada ── def sync_claves(self) -> dict: sugerencia = "" try: import subprocess url = subprocess.run(["git", "-C", str(PROYECTO.parent), "remote", "get-url", "origin"], capture_output=True, text=True, timeout=10).stdout.strip() if "cartera-app" in url: sugerencia = url.replace("cartera-app", "cartera-datos") except OSError: pass return {"claves": self.sync.claves(), "sugerencia": sugerencia, "config": self.sync.config()} def _copia_datos(self) -> None: import tarfile copias = self.cartera.carpeta.parent / "backups" copias.mkdir(parents=True, exist_ok=True) nombre = copias / f"datos-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}-sync.tar.gz" # Sincronizar también puede cambiar los ajustes: entran en la copia ficheros = sorted(self.cartera.carpeta.glob("*.jsonl")) + [self.cartera.carpeta / FICHERO_VERSION] \ + [self.cartera.carpeta.parent / ajuste for ajuste in AJUSTES] bruto = io.BytesIO() with tarfile.open(fileobj=bruto, mode="w:gz") as tar: for f in ficheros: if f.exists(): contenido = almacen.leer(f) # dentro va en claro; lo que se cifra, si toca, es la copia entera info = tarfile.TarInfo(f.name) info.size, info.mtime = len(contenido), int(f.stat().st_mtime) tar.addfile(info, io.BytesIO(contenido)) almacen.escribir(nombre, bruto.getvalue()) def sync_ejecutar(self, accion: str) -> dict: if not self._sync_lock.acquire(blocking=False): raise ErrorOperacion("Ya hay una sincronización en marcha.") try: if accion == "comprobar": return self.sync.comprobar(self.cartera.datos) return self.sync.sincronizar(self.cartera, self._copia_datos) finally: self._sync_lock.release() # ── extractos ── def _ajustes(self) -> dict: ruta = self.cartera.carpeta.parent / "extractos.json" try: return json.loads(almacen.leer_texto(ruta)) except (OSError, ValueError): return {} def _guardar_ajustes(self, ajustes: dict) -> None: # Las marcas dicen qué cambió el usuario y cuándo: deciden al sincronizar (ver marcas.py) ajustes = marcas.marcar(self._ajustes() or None, ajustes, "extractos.json") try: escribir_ajuste(self.cartera.carpeta.parent / "extractos.json", ajustes) except OSError: pass def extracto_analizar(self, cuerpo: dict) -> dict: texto = cuerpo.get("texto") or "" if not texto.strip(): raise ErrorOperacion("El fichero está vacío.") # La cuenta de los intereses se recuerda por entidad: los de un banco # no van a la cuenta del bróker ajustes = self._ajustes() por_entidad = ajustes.get("cuenta_intereses") mapa = cuerpo.get("mapa") if isinstance(cuerpo.get("mapa"), dict) else None lector = extractos.detectar(texto) or (extractos.GENERICO if mapa else None) if not isinstance(por_entidad, dict): # Versión anterior: una sola cuenta para todo. Se le queda a la entidad del primer extracto que llegue por_entidad = {lector: por_entidad} if por_entidad and lector else {} if por_entidad: # y se apunta ya, para que no pase a la siguiente ajustes["cuenta_intereses"] = por_entidad self._guardar_ajustes(ajustes) cuenta = cuerpo.get("cuenta") or por_entidad.get(lector) if cuerpo.get("cuenta") and lector: por_entidad[lector] = cuerpo["cuenta"] ajustes["cuenta_intereses"] = por_entidad self._guardar_ajustes(ajustes) # A qué activo va cada exchange del extracto (o «no importar»), también recordado destinos = ajustes.get("destinos_exchange") destinos = dict(destinos) if isinstance(destinos, dict) else {} if isinstance(cuerpo.get("destinos"), dict): destinos.update({str(k)[:80]: str(v)[:40] for k, v in cuerpo["destinos"].items()}) ajustes["destinos_exchange"] = destinos self._guardar_ajustes(ajustes) try: return extractos.analizar(texto, self.cartera.datos, cuenta, destinos, mapa) except ValueError as e: raise ErrorOperacion(str(e)) from None def extracto_columnas(self, cuerpo: dict) -> dict: """Si el extracto es de un formato conocido, lo dice; si no, da lo necesario para que el usuario reparta sus columnas (ver extractos.columnas).""" texto = cuerpo.get("texto") or "" if not texto.strip(): raise ErrorOperacion("El fichero está vacío.") lector = extractos.detectar(texto) if lector: return {"reconocido": extractos.NOMBRES[lector]} try: return {"reconocido": None, **extractos.columnas(texto)} except ValueError as e: raise ErrorOperacion(str(e)) from None def extracto_aplicar(self, cuerpo: dict) -> dict: propuestas = cuerpo.get("propuestas") or [] if not isinstance(propuestas, list): raise ErrorOperacion("Petición no válida.") creados, guardados, actualizados, fallos = {}, 0, 0, [] propuestas = sorted(propuestas, key=lambda p: ((p or {}).get("movimiento") or {}).get("fecha") or "") for p in propuestas[:2000]: if (p or {}).get("estado") in ("en_historial", "sin_precio", "incompleta", "falta_cuenta", "ya_estaba"): continue # no se guardan desde aquí movimiento = dict((p or {}).get("movimiento") or {}) lugar_nuevo = movimiento.pop("lugar_nuevo", None) nuevo = (p or {}).get("activo_nuevo") if nuevo: isin = (nuevo.get("isin") or "").upper() nombre = (nuevo.get("nombre") or "").strip() clave = isin or extractos.sin_tildes(nombre) if clave and clave not in creados: # Los extractos de fondos y planes no siempre traen ISIN: se # busca por código y, si no hay, por nombre ya = next((a for a in self.cartera.datos.vivos("activos") if (isin and (a.get("codigo") or "").upper() == isin) or (not isin and extractos.sin_tildes(a.get("nombre", "")) == clave)), None) if ya is None: tipo = nuevo.get("tipo") or "Accion" codigo = isin or str(nuevo.get("codigo") or "")[:40] ya = self.cartera.guardar_activo({ "nombre": nombre or isin, "tipo": tipo, "codigo": codigo, "unidad": str(nuevo.get("unidad") or "")[:12] or ("acciones" if tipo == "Accion" else "participaciones"), "fecha_inicio": movimiento.get("fecha")}) if codigo: r, _ = fuentes.precio(ya) if r: self.cartera.aplicar_precios([r]) creados[clave] = ya["id"] movimiento["activo"] = creados.get(clave) if not movimiento.get("activo"): continue try: if isinstance(lugar_nuevo, dict) and lugar_nuevo.get("nombre"): lugar = self.cartera.asegurar_lugar(movimiento["activo"], str(lugar_nuevo["nombre"])[:60], lugar_nuevo.get("clase") or "exchange") lado = lugar_nuevo.get("en") if lugar_nuevo.get("en") in ("lugar", "lugar_destino") else "lugar" movimiento[lado] = lugar["id"] if p.get("estado") == "poner_lugar": # Ya estaba apuntada: solo se le pone el lugar (y la línea, si no tenía) self.cartera.poner_lugar([p.get("coincide_con")], movimiento.get("lugar") or "") previo = next((m for m in self.cartera.datos.vivos("movimientos") if m["id"] == p.get("coincide_con")), {}) if not previo.get("extracto") and movimiento.get("extracto"): self.cartera.anotar_movimientos([previo["id"]], {"extracto": movimiento["extracto"]}) actualizados += 1 continue self.cartera.guardar_movimiento(movimiento) except ErrorOperacion as e: # Una línea rara (p. ej. una venta cuyas compras no están) no # debe impedir el resto de la importación fallos.append({"fecha": movimiento.get("fecha"), "tipo": movimiento.get("tipo"), "motivo": str(e)}) continue if movimiento.get("id"): actualizados += 1 else: guardados += 1 return {"guardados": guardados, "actualizados": actualizados, "activos_nuevos": len(creados), "fallos": fallos} # ── bitcoin desde el nodo propio ── def _btc_config(self) -> dict: return btc.cargar(self.cartera.carpeta) def _btc_guardar(self, config: dict) -> None: try: btc.guardar(self.cartera.carpeta, config) except OSError as e: raise ErrorOperacion(f"No he podido guardar la configuración de Bitcoin: {e}") from None def btc_resumen(self) -> dict: """Lo justo para que la aplicación sepa si hay algo que mirar.""" config = self._btc_config() return {"monederos": len(config.get("monederos") or []), "servidores": len(config.get("servidores") or []), "comprobar_al_abrir": bool(config.get("comprobar_al_abrir", True)), "ultima": config.get("ultima")} def btc_estado(self) -> dict: config = self._btc_config() monederos = [{k: v for k, v in m.items() if k not in ("clave", "vistas")} | {"clave_corta": _recortar(m.get("clave")) if m.get("clave") else f"{len(m.get('direcciones') or [])} direcciones sueltas", "vistas": len(m.get("vistas") or [])} for m in config.get("monederos") or []] cripto = [{"id": a["id"], "nombre": a["nombre"], "lugares": lugares.de(a)} for a in self.cartera.datos.vivos("activos") if a["tipo"] == "Cripto"] return {"servidores": btc.sin_claves(config.get("servidores") or []), "monederos": monederos, "proxy": config.get("proxy"), "comprobar_al_abrir": bool(config.get("comprobar_al_abrir", True)), "ultima": config.get("ultima"), "activos": cripto, "tipos": btc.bitcoin.NOMBRES_TIPO, "fichero": str(btc.ruta_config(self.cartera.carpeta)), # El servidor Electrum del propio aparato, si quien empaqueta lo ha dicho (CAUDAL_ELECTRUM=host:puerto) "sugerencia": None if config.get("servidores") else _electrum_del_aparato()} def btc_servidores(self, cuerpo: dict) -> dict: lista = cuerpo.get("servidores") if not isinstance(lista, list): raise ErrorOperacion("Petición no válida.") config = self._btc_config() guardados = config.get("servidores") or [] config["servidores"] = [btc.con_su_clave(btc.normalizar_servidor(s), guardados) for s in lista[:10]] if cuerpo.get("proxy"): proxy = cuerpo["proxy"] config["proxy"] = {"host": str(proxy.get("host") or "127.0.0.1")[:60], "puerto": int(proxy.get("puerto") or 9050)} if "comprobar_al_abrir" in cuerpo: config["comprobar_al_abrir"] = bool(cuerpo["comprobar_al_abrir"]) self._btc_guardar(config) return self.btc_estado() def btc_monedero(self, cuerpo: dict) -> dict: config = self._btc_config() monederos = config.get("monederos") or [] entrada = dict(cuerpo.get("monedero") or {}) if entrada.get("id") and not entrada.get("clave"): anterior = next((m for m in monederos if m.get("id") == entrada["id"]), None) if anterior is None: raise ErrorOperacion("Ese monedero ya no está.") if anterior.get("direcciones"): # Al editar llega la lista entera; si no llega, se conserva if entrada.get("direcciones") is None: entrada["direcciones"] = anterior["direcciones"] else: entrada["clave"] = anterior.get("clave") if entrada.get("clave") and not entrada.get("tipo"): # Sin tipo elegido no se supone por el prefijo de la clave: se le pregunta al nodo qué direcciones usa entrada["tipo"] = self._tipo_de_clave(str(entrada["clave"]).strip().replace(" ", ""), config) nuevo = btc.normalizar_monedero(entrada, monederos) config["monederos"] = [m for m in monederos if m.get("id") != nuevo["id"]] + [nuevo] self._btc_guardar(config) return self.btc_estado() def _tipo_de_clave(self, clave: str, config: dict) -> str: try: extendida = btc.bitcoin.ClaveExtendida(clave) except ValueError: return "" # la clave no vale: que lo diga normalizar_monedero try: cliente, _, _ = btc.conectar([btc.normalizar_servidor(s) for s in config.get("servidores") or []], btc.proxy_de(config)) except (ErrorBitcoin, ErrorElectrum, OSError): return btc.tipo_de_partida(extendida) # sin nodo a mano: el más probable; se puede corregir después try: return btc.detectar(cliente, clave)[0] except (ErrorBitcoin, ErrorElectrum, OSError): return btc.tipo_de_partida(extendida) finally: cliente.cerrar() def btc_borrar_monedero(self, mid: str) -> dict: config = self._btc_config() config["monederos"] = [m for m in (config.get("monederos") or []) if m.get("id") != mid] self._btc_guardar(config) return self.btc_estado() def btc_probar(self, cuerpo: dict) -> dict: return btc.probar(self._btc_config(), cuerpo.get("servidor") or {}) def btc_detectar(self, cuerpo: dict) -> dict: config = self._btc_config() clave = str(cuerpo.get("clave") or "").strip().replace(" ", "") btc.bitcoin.ClaveExtendida(clave) # avisa pronto si la clave está mal cliente, _, _ = btc.conectar([btc.normalizar_servidor(s) for s in config.get("servidores") or []], btc.proxy_de(config)) try: tipo, visto = btc.detectar(cliente, clave) return {"tipo": tipo, "visto": visto} finally: cliente.cerrar() def btc_revisar_monederos(self) -> dict: """Mira en el nodo qué tiene cada monedero (ver btc.revisar) y lo guarda.""" if not self._btc_lock.acquire(blocking=False): raise ErrorOperacion("Ya estoy consultando el nodo.") try: config = self._btc_config() monederos = [m for m in config.get("monederos") or [] if btc.vigilable(m)] if not monederos: raise ErrorOperacion("Todavía no has añadido ningún monedero.") cliente, _, _ = btc.conectar([btc.normalizar_servidor(s) for s in config.get("servidores") or []], btc.proxy_de(config)) try: for m in monederos: try: m["revision"] = btc.revisar(cliente, m) except (ValueError, btc.ErrorElectrum) as e: m["revision"] = {"error": str(e)[:200]} finally: cliente.cerrar() self._btc_guardar(config) finally: self._btc_lock.release() return self.btc_estado() def _lugares_de_monederos(self, config: dict) -> bool: """Cada monedero es un lugar de su activo (el del ordenador, el del móvil…): a los que no tienen, se les crea uno con su nombre. Devuelve si ha cambiado algo.""" cambiado = False activos = {a["id"]: a for a in self.cartera.datos.vivos("activos")} for m in config.get("monederos") or []: a = activos.get(m.get("activo")) if a is None or (m.get("lugar") and m["lugar"] in lugares.por_id(a)): continue m["lugar"] = self.cartera.asegurar_lugar(a["id"], m.get("nombre") or "Monedero", "monedero")["id"] activos[a["id"]] = self.cartera.activo(a["id"]) cambiado = True if cambiado: self._btc_guardar(config) return cambiado def _apuntado_en_monederos(self, config: dict, activo: str, hoy: dt.date) -> float: """Lo apuntado en los lugares que son monederos del nodo (lo de los exchanges o los nodos Lightning no está en el nodo). Lo que no tiene lugar (lo apuntado antes de haber lugares) cuenta como de los monederos.""" suyos = {m.get("lugar") for m in config.get("monederos") or [] if m.get("activo") == activo} suyos.add(lugares.SIN_LUGAR) saldos = lugares.saldos(self.cartera.datos.vivos("movimientos"), activo, hoy.isoformat()) return sum(q for lid, q in saldos.items() if lid in suyos) def _btc_cuadres_por_activo(self, config: dict, r: dict, hoy: dt.date) -> list[dict]: """Lo que ve el nodo frente a lo apuntado, activo a activo (para el aviso del resumen; el detalle por lugar va en la revisión).""" en_nodo: dict[str, float] = {} for c in r["cuadres"]: en_nodo[c["activo"]] = en_nodo.get(c["activo"], 0.0) + c["en_nodo"] salida = [] for aid, q in sorted(en_nodo.items()): apuntado = self._apuntado_en_monederos(config, aid, hoy) suyos = {m.get("nombre") or m.get("id") for m in config.get("monederos") or [] if m.get("activo") == aid} salida.append({"activo": aid, "nombre_activo": self._nombre(aid), "en_nodo": round(q, 9), "apuntado": round(apuntado, 9), "diferencia": round(q - apuntado, 9), "cortados": [n for n in r.get("cortados") or [] if n in suyos]}) return salida def _btc_refrescar_ultima(self, config: dict) -> None: """Tras guardar o deshacer decisiones: lo que queda por revisar de la última comprobación y lo apuntado, sin volver a preguntar al nodo.""" ultima = config.get("ultima") if not ultima: return if "nuevas" in ultima: resueltas = config.get("resueltas") or {} ultima["nuevas"] = [i for i in ultima["nuevas"] if i not in resueltas] ultima["pendientes"] = len(ultima["nuevas"]) hoy = dt.date.today() for c in ultima.get("cuadres") or []: c["apuntado"] = round(self._apuntado_en_monederos(config, c["activo"], hoy), 9) c["diferencia"] = round(c["en_nodo"] - c["apuntado"], 9) def btc_comprobar(self) -> dict: """La comprobación diaria: lo nuevo que ve el nodo, con el mismo motor y la misma revisión que el historial completo.""" if not self._btc_lock.acquire(blocking=False): raise ErrorOperacion("Ya estoy consultando el nodo.") try: config = self._btc_config() self._lugares_de_monederos(config) precios = Precios(self.cartera.datos.vivos("precios")) hoy = dt.date.today() try: r = historial_btc.diaria(config, self.cartera.datos, hoy, lambda a: precios.de(a), self.cartera.carpeta) except OSError as e: raise ErrorOperacion(f"No he podido guardar lo leído del nodo: {e}") from None self._btc_presentar(config, r, hoy) config["ultima"] = r["ultima"] = { "fecha": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"), "servidor": r["servidor"], "pendientes": len(r["partes"]), "nuevas": [p["id"] for p in r["partes"]], "cuadres": self._btc_cuadres_por_activo(config, r, hoy), } self._btc_guardar(config) # saldos, monederos estrenados y fecha return r finally: self._btc_lock.release() def btc_historial(self, cuerpo: dict | None = None) -> dict: """Todo el historial de los monederos, con lo que falta por apuntar. Con «leer»: false usa lo último leído en este equipo, sin preguntar al nodo.""" if not self._btc_lock.acquire(blocking=False): raise ErrorOperacion("Ya estoy consultando el nodo.") try: config = self._btc_config() self._lugares_de_monederos(config) datos = self.cartera.datos precios = Precios(datos.vivos("precios")) hoy = dt.date.today() leer = bool((cuerpo or {}).get("leer", True)) try: r = historial_btc.historial(config, datos, hoy, lambda a: precios.de(a), self.cartera.carpeta, leer=leer) except OSError as e: raise ErrorOperacion(f"No he podido guardar lo leído del nodo: {e}") from None return self._btc_presentar(config, r, hoy) finally: self._btc_lock.release() def _btc_presentar(self, config: dict, r: dict, hoy: dt.date) -> dict: """Completa lo que devuelve el motor con lo que necesita la pantalla: nombres, cuadre de cada lugar y actividad propuesta.""" datos = self.cartera.datos movimientos = datos.vivos("movimientos") activos = {a["id"]: a for a in datos.vivos("activos")} r["cuadres"] = [] for k, q in sorted(r.pop("en_nodo").items()): aid, lid = historial_btc.separar(k) a = activos.get(aid) or {"id": aid, "nombre": aid} apuntado = lugares.saldos(movimientos, aid, hoy.isoformat()).get(lid, 0.0) if lid \ else cantidad_en(movimientos, aid, hoy) # De un monedero compartido, «en el nodo» es solo tu parte partes = {btc.parte_de(m) for m in config.get("monederos") or [] if historial_btc.cuenta(m.get("activo"), m.get("lugar")) == k} c = {"clave": k, "activo": aid, "lugar": lid, "nombre_activo": a["nombre"] + (f" · {lugares.nombre(a, lid)}" if lid else ""), "en_nodo": round(q, 9), "apuntado": round(apuntado, 9)} if partes and min(partes) < 1: c["parte"] = min(partes) r["cuadres"].append(c) r["activos"] = [{"id": a["id"], "nombre": a["nombre"]} for a in datos.vivos("activos") if a["tipo"] == "Cripto"] r["lugares"] = {a["id"]: lugares.de(a) for a in datos.vivos("activos") if a["tipo"] == "Cripto"} r["sin_lugar"] = {aid: lugares.saldos(movimientos, aid, hoy.isoformat()).get("", 0.0) for aid in {c["activo"] for c in r["cuadres"]}} actividades_de = {m.get("id"): m.get("actividad") or "" for m in config.get("monederos") or []} por_nombre = {m.get("nombre") or m.get("id"): m.get("id") for m in config.get("monederos") or []} for p in r["partes"]: a = activos.get(p["activo"]) or {"nombre": p["activo"]} p["nombre_activo"] = a["nombre"] + (f" · {lugares.nombre(a, p['lugar'])}" if p.get("lugar") else "") if p.get("destino_lugar"): p["nombre_destino"] = lugares.nombre(activos.get(p["destino"]) or {}, p["destino_lugar"]) for c in p["candidatas"]: if c["tipo"] == "Traslado": c["desde"] = lugares.nombre(a, c.get("lugar")) if c.get("lugar") else "?" c["hacia"] = lugares.nombre(a, c.get("lugar_destino")) if c.get("lugar_destino") else "?" # Minería y entradas en monederos de una actividad: ya marcadas suyas = {actividades_de.get(por_nombre.get(n)) or "" for n in p["monederos"]} p["actividad"] = suyas.pop() if len(suyas) == 1 and p["clase"] in ("mineria", "entrada") else "" return r def btc_historial_aplicar(self, cuerpo: dict) -> dict: decisiones = cuerpo.get("decisiones") if not isinstance(decisiones, list): raise ErrorOperacion("Petición no válida.") with self._btc_lock: config = self._btc_config() r = historial_btc.aplicar(self.cartera, config, decisiones, dt.date.today()) self._btc_refrescar_ultima(config) self._btc_guardar(config) return r def btc_borradores(self, cuerpo: dict) -> dict: cambios = cuerpo.get("borradores") if not isinstance(cambios, dict): raise ErrorOperacion("Petición no válida.") with self._btc_lock: config = self._btc_config() n = historial_btc.poner_borradores(config, cambios) self._btc_guardar(config) return {"borradores": n} def btc_deshacer_partes(self, ids) -> dict: if not isinstance(ids, list): raise ErrorOperacion("Petición no válida.") with self._btc_lock: config = self._btc_config() r = historial_btc.deshacer_partes(self.cartera, config, ids) self._btc_refrescar_ultima(config) self._btc_guardar(config) return r def btc_deshacer_parte(self, pid: str) -> dict: with self._btc_lock: config = self._btc_config() r = historial_btc.deshacer_parte(self.cartera, config, str(pid or "")) self._btc_refrescar_ultima(config) self._btc_guardar(config) return r def _btc_guardado(self): """Lo último leído del nodo en este equipo: (txs, propios, nombres).""" config = self._btc_config() monederos = [m for m in config.get("monederos") or [] if btc.vigilable(m)] cache = cache_btc.cargar(self.cartera.carpeta) esc = cache_btc.escaneo_guardado(cache, monederos) txs = {t: cache["txs"][t] for t in esc["alturas"] if t in cache["txs"]} return config, txs, esc def btc_monedas(self) -> dict: """Tus monedas (UTXO) de hoy y, de cada una, de dónde viene sobre todo.""" config, txs, esc = self._btc_guardado() trazador = traza_btc.Trazador(txs, esc["propios"]) decididas = traza_btc.decisiones(config, self.cartera.datos.vivos("movimientos")) descritos: dict[str, dict] = {} salida = [] for m in traza_btc.monedas(txs, esc["propios"]): origenes = sorted(trazador.origenes(m["txid"], m["indice"]).items(), key=lambda x: -x[1]) for clave, _ in origenes: if clave not in descritos: descritos[clave] = self._describir_origen(clave, txs, esc, decididas) salida.append({**m, "cantidad": m["sats"] / btc.SATOSHI, "monedero": esc["nombres"].get(m["monedero"], m["monedero"]), "nombre_activo": self._nombre(m["activo"]), "origenes": [{"clave": c, "fraccion": round(f, 6)} for c, f in origenes]}) return {"monedas": salida, "origenes": descritos, "leido": esc["fecha"]} def btc_traza(self, cuerpo: dict) -> dict: config, txs, esc = self._btc_guardado() txid, indice = str(cuerpo.get("txid") or ""), int(cuerpo.get("indice") or 0) tx = txs.get(txid) s = next((x for x in (tx or {}).get("salidas", []) if x["indice"] == indice), None) if s is None: raise ErrorOperacion("No encuentro esa moneda en lo leído del nodo.") trazador = traza_btc.Trazador(txs, esc["propios"]) decididas = traza_btc.decisiones(config, self.cartera.datos.vivos("movimientos")) origenes = sorted(trazador.origenes(txid, indice).items(), key=lambda x: -x[1]) arbol = trazador.arbol(txid, indice, s["satoshis"]) def nombres(n): n["monedero"] = esc["nombres"].get(n["monedero"], n["monedero"]) n["nombre_activo"] = self._nombre(n["activo"]) if n["activo"] else None for hijo in n["hijos"]: nombres(hijo) nombres(arbol) return {"arbol": arbol, "sats": s["satoshis"], "origenes": [{**self._describir_origen(c, txs, esc, decididas), "fraccion": round(f, 6), "sats": round(f * s["satoshis"])} for c, f in origenes]} def _describir_origen(self, clave, txs, esc, decididas) -> dict: d = traza_btc.describir(clave, txs, esc["propios"], decididas) d["valor_dia"] = spot_btc.precio(d["fecha"])[0] if d.get("fecha") else None if d.get("desde"): d["desde"] = self._nombre(d["desde"]) m = (decididas.get(clave) or {}).get("mov") if d.get("desde_lugar") and m: # Vino de un exchange (u otro lugar): las compras de allí hasta ese día a = next((x for x in self.cartera.datos.vivos("activos") if x["id"] == m["activo"]), {}) d["desde"] = lugares.nombre(a, d["desde_lugar"]) d["compras_alli"] = [{"fecha": x["fecha"], "cantidad": x.get("cantidad"), "importe": x.get("importe")} for x in sorted(self.cartera.datos.vivos("movimientos"), key=lambda x: x["fecha"], reverse=True) if x["activo"] == m["activo"] and x.get("lugar") == d["desde_lugar"] and x["tipo"] == "Compra" and x["fecha"] <= m["fecha"]][:5] d["tipo"], d["importe"], d["cantidad"] = None, None, None return d def btc_importaciones(self) -> list[dict]: config = self._btc_config() return [{"id": i["id"], "fecha": i.get("fecha"), "creados": len(i.get("creados") or []), "enlazados": len(i.get("enlazados") or []), "partes": len(i.get("partes") or [])} for i in reversed(config.get("importaciones") or [])] def btc_empezar_de_cero(self, cuerpo: dict) -> dict: """Quita lo del CSV de exchanges y del historial (ver deshacer_btc.py). Sin «confirmar» solo dice lo que haría.""" with self._btc_lock: config = self._btc_config() if not cuerpo.get("confirmar"): return deshacer_btc.plan(self.cartera, config) ajustes = self._ajustes() r = deshacer_btc.aplicar(self.cartera, config, ajustes) self._btc_refrescar_ultima(config) self._btc_guardar(config) self._guardar_ajustes(ajustes) return r def btc_deshacer(self, imp_id: str) -> dict: with self._btc_lock: config = self._btc_config() r = historial_btc.deshacer(self.cartera, config, str(imp_id or "")) self._btc_refrescar_ultima(config) self._btc_guardar(config) return r def _nombre(self, aid: str) -> str: a = next((x for x in self.cartera.datos.vivos("activos") if x["id"] == aid), None) return a["nombre"] if a else aid def exportar_xlsx(self) -> bytes: from .exportar_xlsx import exportar with tempfile.TemporaryDirectory() as t: destino = Path(t) / "cartera.xlsx" exportar(self.cartera.datos, destino, dt.date.today()) return destino.read_bytes() def rutas_post(self) -> dict: c = self.cartera return { "/api/activo": lambda b: self._guardar_activo(b), "/api/activo/cerrar": lambda b: c.cerrar_activo(b.get("id"), b.get("fecha"), b.get("importe")), "/api/activo/evolucion": lambda b: evolucion_activo(c.datos, str(b.get("id") or ""), dt.date.today()), "/api/activo/si_vendo": lambda b: self.si_vendo(str(b.get("id") or "")), "/api/activo/reabrir": lambda b: c.reabrir_activo(b.get("id")), "/api/activo/borrar": lambda b: c.borrar_activo(b.get("id")), "/api/activo/comprobar": lambda b: dict(zip(("resultado", "mensaje"), fuentes.precio( {"id": "prueba", "tipo": b.get("tipo"), "codigo": b.get("codigo"), "nombre": b.get("nombre")}))), "/api/movimiento": lambda b: c.guardar_movimiento(b), "/api/movimiento/borrar": lambda b: c.borrar_movimiento(b.get("id")), "/api/movimiento/confirmar": lambda b: c.confirmar_movimiento(b.get("id")), "/api/movimiento/adjuntar": lambda b: self.adjuntar(b), "/api/movimiento/adjunto/quitar": lambda b: c.quitar_adjunto(b.get("id"), b.get("adjunto")), "/api/movimientos/borrar": lambda b: c.borrar_movimientos(b.get("ids")), "/api/movimientos/restaurar": lambda b: c.restaurar_movimientos(b.get("ids")), "/api/movimientos/confirmar": lambda b: c.confirmar_movimientos(b.get("ids")), "/api/ejemplo": lambda b: self.ejemplo(bool(b.get("entrar"))), "/api/instalacion": lambda b: self.instalacion_mover(str(b.get("modo") or "")), "/api/instalacion/lanzador": lambda b: self.instalacion_lanzador(bool(b.get("poner"))), "/api/extracto/columnas": lambda b: self.extracto_columnas(b), "/api/copia/crear": lambda b: self.copia_crear(b), "/api/copia/restaurar": lambda b: self.copia_restaurar(b), "/api/fiscal/criterios": lambda b: self.fiscal_criterios(b), "/api/primer_uso": lambda b: self.primer_uso(b), "/api/fiscal/declarado": lambda b: self.fiscal_declarado(b), "/api/fiscal/trading": lambda b: self.fiscal_trading(b), "/api/fiscal/amortizable": lambda b: c.marcar_amortizable(b.get("ids") or [], bool(b.get("si"))), "/api/movimiento/importe": lambda b: c.poner_importe(b.get("id"), b.get("importe")), "/api/movimientos/prima": lambda b: c.revisar_prima(b.get("ids"), bool(b.get("ok", True))), "/api/movimientos/mover": lambda b: c.mover_movimientos(b.get("ids"), b.get("activo")), "/api/traspaso": lambda b: c.traspaso(b), "/api/lugar": lambda b: c.guardar_lugar(b.get("activo"), b), "/api/lugar/borrar": lambda b: c.borrar_lugar(b.get("activo"), b.get("id")), "/api/lugar/unir": lambda b: self.unir_lugares(b), "/api/movimientos/lugar": lambda b: c.poner_lugar(b.get("ids"), b.get("lugar") or ""), "/api/activo/convertir_en_lugar": lambda b: self.convertir_en_lugar(b), "/api/deuda/cuadro": lambda b: c.estado_deuda(b.get("id")), "/api/deuda/simular": lambda b: c.simular_amortizacion(b.get("id"), b.get("fecha"), b.get("importe")), "/api/precio": lambda b: c.precio_manual(b.get("activo"), b.get("fecha"), b.get("valor"), bool(b.get("valor_total"))), "/api/precios/actualizar": lambda b: self.actualizar_precios(), "/api/fuentes/comprobar": lambda b: self.comprobar_fuentes(), "/api/interpretar": lambda b: self.interpretar(b), "/api/alta_y_operacion": lambda b: c.alta_con_operacion( b.get("activo") or {}, b.get("movimiento") or {}, {"precio": (b.get("activo") or {}).get("precio"), "fecha": (b.get("activo") or {}).get("fecha_precio"), "fuente": (b.get("activo") or {}).get("fuente")}), "/api/actividad/etiquetar": lambda b: c.etiquetar(b.get("ids") or [], b.get("actividad") or ""), "/api/actividad/renombrar": lambda b: c.renombrar_actividad(b.get("viejo") or "", b.get("nuevo") or ""), "/api/actividad/candidatas": lambda b: actividades.candidatas( c.datos, b.get("actividad") or "", dt.date.today()), "/api/recurrente": lambda b: c.guardar_recurrente(b), "/api/recurrente/borrar": lambda b: c.borrar_recurrente(b.get("id")), "/api/sesion": lambda b: self.guardar_sesion(float(b.get("valor") or 0), float(b.get("neto") or 0)), "/api/sync/configurar": lambda b: self.sync.configurar(b.get("url"), b.get("huella")), "/api/sync/ejecutar": lambda b: self.sync_ejecutar("sincronizar"), "/api/sync/comprobar": lambda b: self.sync_ejecutar("comprobar"), "/api/btc/estado": lambda b: self.btc_estado(), "/api/btc/servidores": lambda b: self.btc_servidores(b), "/api/btc/monedero": lambda b: self.btc_monedero(b), "/api/btc/monedero/borrar": lambda b: self.btc_borrar_monedero(b.get("id")), "/api/btc/probar": lambda b: self.btc_probar(b), "/api/btc/detectar": lambda b: self.btc_detectar(b), "/api/btc/comprobar": lambda b: self.btc_comprobar(), "/api/btc/historial": lambda b: self.btc_historial(b), "/api/btc/historial/borradores": lambda b: self.btc_borradores(b), "/api/btc/historial/deshacer_parte": lambda b: self.btc_deshacer_parte(b.get("id")), "/api/btc/historial/deshacer_partes": lambda b: self.btc_deshacer_partes(b.get("ids")), "/api/btc/monedas": lambda b: self.btc_monedas(), "/api/btc/traza": lambda b: self.btc_traza(b), "/api/btc/historial/aplicar": lambda b: self.btc_historial_aplicar(b), "/api/btc/importaciones": lambda b: self.btc_importaciones(), "/api/btc/empezar_de_cero": lambda b: self.btc_empezar_de_cero(b), "/api/btc/revisar_monederos": lambda b: self.btc_revisar_monederos(), "/api/btc/importacion/deshacer": lambda b: self.btc_deshacer(b.get("id")), "/api/extracto/analizar": lambda b: self.extracto_analizar(b), "/api/extracto/aplicar": lambda b: self.extracto_aplicar(b), # En un servidor «salir» es cerrar la sesión (ruta /api/acceso/salir): Caudal sigue en marcha "/api/salir": lambda b: None if self.acceso is not None else threading.Thread(target=self.servidor.shutdown, daemon=True).start(), "/api/acceso/cambiar": lambda b: self.cambiar_clave(b), "/api/cifrado": lambda b: self.cifrado_poner(b), "/api/enlace/alta": lambda b: self.enlace_alta(b), "/api/enlace/codigo": lambda b: self.enlace_codigo(), "/api/enlace/direcciones": lambda b: self.enlace_direcciones(b), "/api/qr": lambda b: self.dibujar_qr(b), "/api/enlace/olvidar": lambda b: self.enlace_olvidar_equipos(b), "/api/enlace/configurar": lambda b: self.enlace_configurar(b), "/api/enlace/sincronizar": lambda b: self.enlace_sincronizar(b), "/api/cifrado/recuperacion": lambda b: self.recuperacion_nueva(b), } def _recortar(clave: str | None) -> str: """La clave extendida abreviada, para enseñarla sin soltarla entera.""" clave = clave or "" return clave if len(clave) <= 24 else f"{clave[:12]}…{clave[-6:]}" def _electrum_del_aparato() -> dict | None: """El servidor Electrum que vive en el mismo aparato que Caudal: donde diga quien empaqueta (CAUDAL_ELECTRUM=host:puerto, o varios separados por comas; en Umbrel y StartOS, el electrs o Fulcrum de al lado) y, si no, el del propio equipo en sus puertos de siempre. Solo se propone el primero que de verdad contesta.""" lista = [*btc.lista_de_direcciones(os.environ.get("CAUDAL_ELECTRUM", "")), ("127.0.0.1", 50001), ("127.0.0.1", 50002)] for host, puerto in dict.fromkeys(lista): candidato = {"host": host, "puerto": puerto, "tor": host.endswith(".onion"), "tls": False} try: btc.conectar([candidato], btc.proxy_de({}), espera=2)[0].cerrar() except (ErrorElectrum, OSError): continue return candidato return None def crear_manejador(app: App): class Manejador(BaseHTTPRequestHandler): server_version = "Cartera" sys_version = "" def log_message(self, *args): # sin registro de peticiones en la terminal pass def _host_valido(self) -> bool: if app.acceso is not None: return True # en un servidor el nombre lo pone quien lo publica; protege la sesión host = self.headers.get("Host", "") return host in (f"127.0.0.1:{app.puerto}", f"localhost:{app.puerto}") def _id_sesion(self) -> str: for trozo in self.headers.get("Cookie", "").split(";"): nombre, _, valor = trozo.strip().partition("=") if nombre == COOKIE: return valor return "" def _token_valido(self) -> bool: dado = self.headers.get("X-Token", "") if app.acceso is None: return hmac.compare_digest(dado, app.token) # La cookie dice quién eres; el token en la cabecera, que la petición sale de la propia aplicación sesion = app.acceso.sesion(self._id_sesion()) return bool(sesion) and hmac.compare_digest(dado, sesion["token"]) def _cookie(self, sid: str, vida: int) -> dict: seguro = "; Secure" if self.headers.get("X-Forwarded-Proto", "") == "https" else "" return {"Set-Cookie": f"{COOKIE}={sid}; Path=/; HttpOnly; SameSite=Strict; Max-Age={vida}{seguro}"} def _acceso(self, ruta: str, cuerpo: dict | None): """Las rutas de entrar y salir, que son las únicas que se atienden sin sesión.""" acceso = app.acceso if ruta == "/api/acceso" and cuerpo is None: sesion = acceso.sesion(self._id_sesion()) self._json(200, {"configurado": acceso.configurado(), "dentro": bool(sesion), "cifrado": acceso.cifrado(), "recuperable": acceso.tiene_recuperacion(), "token": sesion["token"] if sesion else None, "minimo": MINIMO_CLAVE, "recomendado": list(RECOMENDADO_CLAVE)}) return # Solo desde la propia aplicación: una cabecera que otra web no puede poner if cuerpo is None or self.headers.get("X-Caudal") != "1": self._json(403, {"error": "Prohibido"}) return try: extra = {} if ruta == "/api/acceso/crear": clave = str(cuerpo.get("clave") or "") app.entrar(acceso.crear(clave)) if CIFRAR_DE_ENTRADA: # donde el disco no va cifrado (Umbrel), se cifra desde el principio extra["recuperacion"] = app._cifrar(clave)["recuperacion"] elif ruta == "/api/acceso/entrar": app.entrar(acceso.comprobar(str(cuerpo.get("clave") or ""))) elif ruta == "/api/acceso/recuperar": app.entrar(acceso.recuperar(str(cuerpo.get("recuperacion") or ""), str(cuerpo.get("nueva") or ""))) elif ruta == "/api/acceso/salir": acceso.cerrar(self._id_sesion()) self._enviar(200, b'{"ok": true}', "application/json; charset=utf-8", self._cookie("", 0)) return else: self._json(404, {"error": "No encontrado"}) return except Espera as e: self._json(429, {"error": str(e)}) return except (ErrorAcceso, ErrorCifrado, ErrorDatos, almacen.Bloqueado, CarteraOcupada) as e: self._json(400, {"error": str(e)}) return sid, token = acceso.abrir() self._enviar(200, json.dumps({"ok": True, "token": token, **extra}).encode(), "application/json; charset=utf-8", self._cookie(sid, DURACION_SESION)) def _enviar(self, codigo: int, cuerpo: bytes, tipo: str, extra: dict | None = None, guardable: bool = False): extra = dict(extra or {}) # Texto grande, comprimido: la interfaz baja a la cuarta parte, que se nota por Tor o con poca cobertura if len(cuerpo) >= COMPRIMIR_DESDE and tipo.split(";")[0] in COMPRIMIBLES \ and "gzip" in self.headers.get("Accept-Encoding", "") and "Content-Encoding" not in extra: cuerpo = gzip.compress(cuerpo, 5) extra["Content-Encoding"] = "gzip" self.send_response(codigo) self.send_header("Content-Type", tipo) self.send_header("Content-Length", str(len(cuerpo))) # Los datos nunca se guardan en el navegador. Los ficheros de la interfaz sí, pero preguntando # siempre si han cambiado («no-cache»): tras actualizar Caudal se ve la versión nueva al momento self.send_header("Cache-Control", "no-cache" if guardable else "no-store") self.send_header("Vary", "Accept-Encoding") self.send_header("X-Content-Type-Options", "nosniff") self.send_header("Referrer-Policy", "no-referrer") self.send_header("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; " "script-src 'self'; connect-src 'self'; frame-ancestors 'none'") for k, v in (extra or {}).items(): self.send_header(k, v) self.end_headers() self.wfile.write(cuerpo) def _json(self, codigo: int, obj): self._enviar(codigo, json.dumps(obj, ensure_ascii=False).encode(), "application/json; charset=utf-8") def _rechazo(self): if not self._host_valido(): self._enviar(403, b"Host no permitido", "text/plain") return True return False def do_GET(self): if self._rechazo(): return url = urlparse(self.path) if url.path == "/" and app.acceso is not None: # La página no lleva nada privado: quien no tenga sesión solo verá la pantalla de entrada html = (WEB / "index.html").read_text(encoding="utf-8").replace("__TOKEN__", "") self._enviar(200, html.encode(), "text/html; charset=utf-8") return if url.path == "/": if not hmac.compare_digest(parse_qs(url.query).get("t", [""])[0], app.token): self._enviar(403, "Abre Caudal con la dirección completa que se muestra al arrancarlo.".encode(), "text/plain; charset=utf-8") return html = (WEB / "index.html").read_text(encoding="utf-8").replace("__TOKEN__", app.token) self._enviar(200, html.encode(), "text/html; charset=utf-8") return if url.path.startswith("/web/"): ruta = (WEB / url.path[5:]).resolve() if WEB not in ruta.parents or not ruta.is_file(): self._enviar(404, b"No encontrado", "text/plain") return tipo = mimetypes.guess_type(ruta.name)[0] or "application/octet-stream" if tipo.startswith("text/") or tipo in ("application/javascript",): tipo += "; charset=utf-8" contenido = ruta.read_bytes() marca = '"' + hashlib.sha256(contenido).hexdigest()[:20] + '"' if self.headers.get("If-None-Match") == marca: # el navegador ya tiene esta versión self.send_response(304) self.send_header("ETag", marca) self.send_header("Cache-Control", "no-cache") self.end_headers() return self._enviar(200, contenido, tipo, {"ETag": marca}, guardable=True) return if app.acceso is not None and url.path == "/api/salud": # Para que el sistema que aloja Caudal sepa que está en marcha; no cuenta nada privado self._json(200, {"ok": True, "bloqueada": app.cartera is None}) return if app.acceso is not None and url.path == "/api/acceso": self._acceso(url.path, None) return if not url.path.startswith("/api/") or not self._token_valido(): self._sin_permiso() return try: if url.path == "/api/estado": self._json(200, app.estado()) elif url.path == "/api/precio": from .calculo import Precios q = parse_qs(url.query) reg = Precios(app.cartera.datos.vivos("precios")).ultimo( q.get("activo", [""])[0], dt.date.fromisoformat(q.get("fecha", [""])[0])) self._json(200, {"precio": reg}) elif url.path == "/api/fiscal": self._json(200, app.fiscal(parse_qs(url.query).get("ejercicio", [""])[0])) elif url.path == "/api/sync/claves": self._json(200, app.sync_claves()) elif url.path == "/api/buscar": self._json(200, fuentes.buscar(parse_qs(url.query).get("q", [""])[0])) elif url.path == "/api/adjunto": q = parse_qs(url.query) ref, contenido = app.cartera.leer_adjunto(q.get("m", [""])[0], q.get("a", [""])[0]) # Siempre como descarga y sin tipo propio: un adjunto nunca se # interpreta dentro de la aplicación (un .html podría ejecutar código). self._enviar(200, contenido, "application/octet-stream", {"Content-Disposition": "attachment; filename*=UTF-8''" + quote(ref["nombre"])}) elif url.path == "/api/copia": nombre, fichero = app.copia_descargar(parse_qs(url.query).get("id", [""])[0]) self._enviar(200, fichero, "application/octet-stream", {"Content-Disposition": f'attachment; filename="{nombre}"'}) elif url.path == "/api/fiscal/regularizacion": self._json(200, app.fiscal_regularizacion()) elif url.path == "/api/fiscal/exportar": año, libro = app.fiscal_xlsx(parse_qs(url.query).get("ejercicio", [""])[0]) self._enviar(200, libro, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", {"Content-Disposition": f'attachment; filename="fiscal_{año}.xlsx"'}) elif url.path == "/api/exportar": nombre = f"cartera_{dt.date.today().isoformat()}.xlsx" self._enviar(200, app.exportar_xlsx(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", {"Content-Disposition": f'attachment; filename="{nombre}"'}) else: self._json(404, {"error": "No encontrado"}) except (ErrorOperacion, ErrorDatos, ErrorSync, ErrorBitcoin, ErrorElectrum, copia.ErrorCopia) as e: self._json(400, {"error": str(e)}) except Exception: traceback.print_exc() self._json(500, {"error": "Error interno. Si se repite, el detalle está en el registro de la aplicación."}) def _sin_permiso(self): if app.acceso is not None: self._json(401, {"error": "Tu sesión ha terminado. Vuelve a entrar.", "sin_sesion": True}) else: self._enviar(403, b"Prohibido", "text/plain") def _cuerpo_json(self) -> dict: largo = int(self.headers.get("Content-Length") or 0) if largo > (MAX_COPIA if urlparse(self.path).path in ("/api/copia/restaurar", "/api/enlace") else MAX_CUERPO): raise ErrorOperacion("Petición demasiado grande.") if largo and "application/json" not in self.headers.get("Content-Type", ""): raise ErrorOperacion("Formato de petición no válido.") cuerpo = json.loads(self.rfile.read(largo) or b"{}") if largo else {} if not isinstance(cuerpo, dict): raise ErrorOperacion("Formato de petición no válido.") return cuerpo def do_POST(self): if self._rechazo(): return ruta = urlparse(self.path).path if app.acceso is not None and ruta in ("/api/acceso/crear", "/api/acceso/entrar", "/api/acceso/salir", "/api/acceso/recuperar"): try: self._acceso(ruta, self._cuerpo_json()) except (ErrorOperacion, json.JSONDecodeError, ValueError): self._json(400, {"error": "Formato de petición no válido."}) return if app.acceso is not None and ruta in ("/api/enlace", "/api/enlace/canje"): # Otro Caudal que sincroniza con este: no trae sesión, trae su credencial (o el código para conseguirla) try: if ruta == "/api/enlace/canje": if self.headers.get("X-Caudal") != "1": # como al entrar: que no lo pueda lanzar otra página raise ValueError("falta la cabecera") self._json(200, app.enlace_canje(self._cuerpo_json())) else: self._json(200, app.enlace_atender(self.headers.get("X-Enlace", ""), self._cuerpo_json())) except Espera as e: self._json(429, {"error": str(e)}) except ErrorAcceso as e: self._json(403, {"error": str(e)}) except (enlace.ErrorEnlace, ErrorOperacion, ErrorDatos, almacen.Bloqueado, CarteraOcupada, ErrorCifrado) as e: self._json(400, {"error": str(e)}) except (json.JSONDecodeError, ValueError): self._json(400, {"error": "Formato de petición no válido."}) except Exception: traceback.print_exc() self._json(500, {"error": "Error interno. Si se repite, el detalle está en el registro de la aplicación."}) return if not self._token_valido(): self._sin_permiso() return accion = app.rutas_post().get(ruta) if accion is None: self._json(404, {"error": "No encontrado"}) return try: cuerpo = self._cuerpo_json() cuerpo["_sesion"] = self._id_sesion() # nunca viene del navegador: se pisa siempre self._json(200, {"ok": True, "resultado": accion(cuerpo)}) except (ErrorOperacion, ErrorDatos, ErrorSync, ErrorBitcoin, ErrorElectrum, ErrorAcceso, copia.ErrorCopia, adjuntos.ErrorAdjunto, ErrorCifrado, almacen.Bloqueado, enlace.ErrorEnlace) as e: self._json(400, {"error": str(e)}) except json.JSONDecodeError: self._json(400, {"error": "Formato de petición no válido."}) except Exception: traceback.print_exc() self._json(500, {"error": "Error interno. Si se repite, el detalle está en el registro de la aplicación."}) return Manejador def arrancar(carpeta: Path, puerto: int = 0, escucha: str | None = None) -> App: """«escucha» es la dirección de red en la que atender cuando Caudal va en un servidor (entonces se entra con contraseña). Sin ella, solo este equipo.""" acceso = Acceso(Path(carpeta).resolve().parent) if escucha else None # Con los datos cifrados no se pueden abrir todavía: se abrirán cuando alguien entre con la contraseña bloqueada = acceso is not None and acceso.cifrado() app = App(None if bloqueada else Cartera(carpeta), acceso, Path(carpeta)) electrum.EN_SERVIDOR = acceso is not None # para explicar bien los fallos de conexión con el nodo app.servidor = ThreadingHTTPServer((escucha or "127.0.0.1", puerto), crear_manejador(app)) app.servidor.daemon_threads = True app.puerto = app.servidor.server_address[1] return app def main() -> int: p = argparse.ArgumentParser(description="Cartera de inversiones en el navegador") p.add_argument("--datos", default=None, help="carpeta de datos (por defecto, «datos» junto al programa)") p.add_argument("--puerto", type=int, default=0, help="0 = uno libre al azar") p.add_argument("--no-navegador", action="store_true") p.add_argument("--servidor", nargs="?", const="0.0.0.0", default=os.environ.get("CAUDAL_SERVIDOR") or None, metavar="DIRECCIÓN", help="atender en la red (por defecto 0.0.0.0) y pedir contraseña para entrar") args = p.parse_args() if args.datos is None and instalacion.empaquetado() and not args.servidor: # El instalable: los datos, junto al programa si ahí hay una carpeta suya; si no, en la del usuario base = instalacion.base_actual()[0] args.datos = str(base / "datos") try: instalacion.copia_al_abrir(base) except OSError as e: print(f"⚠ No se pudo hacer la copia de seguridad al abrir: {e}") elif args.datos is None: args.datos = str(PROYECTO / "datos") de_escritorio = instalacion.empaquetado() and not args.servidor try: app = arrancar(Path(args.datos), args.puerto, args.servidor) except CarteraOcupada as e: # El programa de escritorio no tiene ventana propia: si ya estaba abierto, se vuelve a enseñar ya = instalacion.en_marcha(Path(args.datos).parent) if de_escritorio else None if ya and not args.no_navegador: webbrowser.open(ya) return 0 print(f"✗ {e}") return 1 except ErrorDatos as e: print(f"✗ Los datos tienen un error y no se abren para no estropearlos:\n {e}") return 1 url = f"http://127.0.0.1:{app.puerto}/?t={app.token}" if args.servidor: print(f"Caudal atiende en {args.servidor}:{app.puerto}. Se entra con contraseña" + ("." if app.acceso.configurado() else ": la crea quien entre primero."), flush=True) else: print(f"Caudal abierto: {url}") print("Deja esta ventana abierta mientras lo uses. Para salir: botón «Salir» o Ctrl+C.") if not args.no_navegador and not args.servidor: threading.Timer(0.5, lambda: webbrowser.open(url)).start() if de_escritorio: instalacion.anotar_en_marcha(Path(args.datos).parent, url) instalacion.repasar_lanzador(WEB / "icono.svg") try: app.servidor.serve_forever() except KeyboardInterrupt: pass finally: if de_escritorio: instalacion.anotar_en_marcha(app.carpeta.parent, None) # donde estén ahora, por si se cambiaron de sitio app.servidor.server_close() if app.cartera is not None: app.cartera.cerrar() print("Caudal cerrado.") return 0 if __name__ == "__main__": sys.exit(main())