caudal/programa/cartera/web/app.js
2026-11-01 05:56:12 -06:00

5908 lines
401 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

"use strict";
/* Cartera — interfaz. Sin dependencias externas: todo se dibuja aquí.
Los datos llegan del servidor local (/api/...) con la clave de la sesión. */
// En tu equipo viene en la página; en un servidor está vacío y lo da el inicio de sesión
let TOKEN = document.querySelector('meta[name="token"]').content;
const EN_SERVIDOR = TOKEN === "";
// El proyecto: dónde está el código, cómo escribir y cómo apoyarlo. Lo que esté vacío no se enseña.
const PROYECTO = {
codigo: "https://git.huveme.com/esetheyers/caudal",
contacto: "esetheyers@huveme.com", // correo
bip353: "esetheyers@huveme.com", // nombre@dominio: dirección Lightning, BOLT12 y pagos silenciosos (BIP 353) en una
lightning: "", // dirección Lightning (nombre@dominio), solo si es distinta de la anterior
pagos_silenciosos: "", // dirección de pagos silenciosos (sp1…)
paynym: "", // PayNym (+alias) o código de pago BIP47 (PM8T…), para Ashigaru, Sparrow…
nostr: "", // npub1…
x: "esetheyers", // usuario de X, sin la @
huella_pgp: "3648 41BA B31F 9D0F 3D07 5629 C34F 1F5D 7116 CA10", // clave que firma las versiones (VERIFICAR.md)
};
const TIPOS_ACTIVO = {
Fondo: "Fondo", PlanPensiones: "Plan de pensiones", Cripto: "Cripto", Metal: "Metal",
ETF: "ETF", Accion: "Acción", CuentaTrading: "Cuenta de bróker o trading", Prestamo: "Préstamo concedido", Tierra: "Tierra",
Inmueble: "Inmueble", RentaFija: "Renta fija", Cuenta: "Cuenta (liquidez)", Deuda: "Préstamo recibido (deuda)",
};
const TIPOS_MOV = {
Compra: "Compra", Venta: "Venta", Reembolso: "Reembolso", Comision: "Comisión",
Gasto: "Gasto", Devolucion: "Devolución de un gasto", Mineria: "Minería",
Dividendo: "Dividendo / intereses", Recompensa: "Recompensa (acciones gratis)",
Traspaso_Entrada: "Traspaso (entrada)", Traspaso_Salida: "Traspaso (salida)",
Amortizacion: "Amortización anticipada", Traslado: "Traslado",
};
// Lo cobrado sin vender se llama según lo que sea: intereses de una cuenta, alquiler de un piso…
function nombreTipoMov(m) {
if (m.tipo !== "Dividendo") return TIPOS_MOV[m.tipo] || m.tipo;
const a = estado.datos && estado.datos.activos.find((x) => x.id === m.activo);
const tipo = a ? a.tipo : "";
if (tipo === "Cuenta" || tipo === "Prestamo") return "Intereses";
if (tipo === "RentaFija") return m.cantidad ? "Cobro" : "Cupón o intereses";
if (tipo === "Inmueble") return "Alquiler";
if (tipo === "Accion" || tipo === "Fondo" || tipo === "ETF") return "Dividendo";
return "Cobro";
}
// A este ancho la página pasa a su forma de móvil (el mismo corte que en estilo.css)
const MOVIL = window.matchMedia("(max-width: 640px)");
// Las pestañas que en el móvil no caben en la barra de abajo y van dentro de «Más»
const VISTAS_EN_MAS = ["cerradas", "automaticas"];
const CLASES_LUGAR = { monedero: "Monedero", exchange: "Exchange", lightning: "Nodo Lightning", otro: "Otro" };
function lugaresDe(aid) { return ((activoPorId(aid) || {}).lugares) || []; }
function nombreLugar(aid, lid) {
if (!lid) return "?";
return (lugaresDe(aid).find((x) => x.id === lid) || {}).nombre || lid;
}
const MOV_POR_TIPO_ACTIVO = {
Fondo: ["Compra", "Reembolso", "Dividendo", "Recompensa", "Comision"], // vender un fondo es reembolsarlo
PlanPensiones: ["Compra", "Reembolso", "Comision"],
Cripto: ["Compra", "Venta", "Mineria", "Recompensa", "Gasto", "Devolucion", "Dividendo", "Comision"],
Metal: ["Compra", "Venta", "Gasto", "Devolucion"],
ETF: ["Compra", "Venta", "Dividendo", "Recompensa", "Comision"],
Accion: ["Compra", "Venta", "Dividendo", "Recompensa", "Comision"],
CuentaTrading: ["Compra", "Venta"],
Prestamo: ["Compra", "Reembolso", "Dividendo"],
Tierra: ["Compra", "Venta", "Gasto", "Devolucion", "Dividendo"],
Inmueble: ["Compra", "Dividendo", "Gasto", "Devolucion", "Venta"],
RentaFija: ["Compra", "Dividendo", "Reembolso"],
Cuenta: ["Dividendo"],
Deuda: ["Amortizacion"],
};
const AYUDA_MOV = {
Compra: "Compra o aportación con tu dinero. Rellena dos de los tres datos (cantidad, precio, importe) y se calcula el tercero.",
Recompensa: "Acciones o participaciones que recibes gratis, sin poner tu dinero: bonificaciones del bróker, regalos. Suben la cantidad a coste 0, así que todo su valor cuenta como ganancia. El valor al recibirlo es informativo (útil para la declaración).",
Venta: "Venta de parte o de toda la posición.",
Reembolso: "Dinero que te devuelven (reembolso de un fondo o plan, cobro de un préstamo).",
Comision: "Comisión cobrada en participaciones o en BTC: resta cantidad pero no es dinero que pongas.",
Gasto: "Coste en euros ligado al activo (hosting de mineros, custodia...). Cuenta como dinero invertido.",
Devolucion: "Dinero que te devuelven de un gasto que ya apuntaste (parte del hosting, una fianza). Resta de lo que llevas puesto, así que baja el coste del activo. No es un dividendo: no es una renta, es un gasto que se anula. Si el importe aún es provisional, marca «Cifras aproximadas».",
Mineria: "Cantidad recibida sin pagarla en ese momento (BTC minado).",
Dividendo: "Dinero que te ingresan en cuenta sin vender nada (dividendos, cupones, intereses, arrendamientos). Pon lo cobrado neto; la retención es opcional (útil para la renta). Cuenta en la rentabilidad total y la TIR.",
};
// Lo mismo dicho como lo diría quien lo ha hecho: título y una línea, para elegir sin saber de tipos.
// Las claves con «Tipo:TipoDeActivo» afinan el texto para ese activo.
const QUE_PASO = {
Compra: ["He comprado o aportado", "Has puesto dinero: una compra, una aportación, una suscripción."],
"Compra:Prestamo": ["He prestado dinero", "El dinero que dejaste."],
"Compra:CuentaTrading": ["He metido dinero", "Un ingreso en la cuenta."],
"Compra:RentaFija": ["Lo he comprado o contratado", "Lo que pagaste, con comisiones. La cantidad es el nominal (en una Letra de 10.000 €, 10.000)."],
"Dividendo:RentaFija": ["He cobrado intereses o un cupón", "Lo que te ingresan sin devolverte el capital. La retención es opcional."],
"Reembolso:RentaFija": ["Me lo han devuelto o lo he vendido", "Al vencer, o si lo vendes antes. La cantidad es el nominal que se devuelve."],
"Compra:Inmueble": ["Lo he comprado", "El precio con todos los gastos de la compra: notaría, impuestos, registro, gestoría."],
"Dividendo:Inmueble": ["He cobrado el alquiler", "Lo que te ha pagado el inquilino (una mensualidad o varias juntas)."],
"Gasto:Inmueble": ["He pagado un gasto", "IBI, comunidad, seguro, reparaciones, gestoría… Los intereses de la hipoteca, solo si no la has vinculado al inmueble (entonces salen solos). Una mejora (cocina nueva, reforma) márcala como tal."],
"Venta:Inmueble": ["Lo he vendido", "Lo que te han pagado, ya descontados los gastos de la venta."],
Venta: ["He vendido", "Has vendido una parte o todo y te han dado euros."],
"Venta:CuentaTrading": ["He sacado dinero", "Una retirada de la cuenta."],
Reembolso: ["He sacado dinero", "Un reembolso: te devuelven euros de lo que tenías."],
"Reembolso:Fondo": ["He vendido", "Un reembolso: vendes participaciones y te dan euros."],
"Reembolso:Prestamo": ["Me han devuelto dinero", "Un cobro del préstamo, entero o en parte."],
Dividendo: ["He cobrado sin vender", "Dividendos, intereses, cupones o un alquiler que te ingresan."],
"Dividendo:Cuenta": ["He cobrado intereses", "Lo que te paga la cuenta."],
Recompensa: ["Me han dado participaciones gratis", "Bonificaciones del bróker, regalos: las recibes sin poner dinero."],
Mineria: ["He minado", "Bitcoin que recibes de la minería."],
Gasto: ["He pagado un gasto", "Lo que te cuesta tenerlo: custodia, hosting, mantenimiento."],
Devolucion: ["Me han devuelto un gasto", "Te devuelven dinero de un gasto que ya apuntaste."],
Comision: ["Me han cobrado una comisión en participaciones", "Te quitan cantidad, no euros (comisión de custodia o de red)."],
Traslado: ["Lo he movido de sitio", "De un exchange a tu monedero, de un monedero a otro… Sigue siendo tuyo."],
Traspaso: ["Lo he traspasado a otra inversión", "De un fondo a otro sin vender, o de una moneda a otra equivalente."],
Traspaso_Entrada: ["Traspaso (entrada)", "Lo que llegó de otro fondo."],
Traspaso_Salida: ["Traspaso (salida)", "Lo que salió hacia otro fondo."],
};
const QUE_PASO_PRINCIPALES = ["Compra", "Venta", "Reembolso", "Dividendo"];
function quePaso(tipo, activo) {
return QUE_PASO[`${tipo}:${activo ? activo.tipo : ""}`] || QUE_PASO[tipo] || [TIPOS_MOV[tipo] || tipo, ""];
}
// Mover sin comprar ni vender: entre lugares del mismo activo (Traslado) o a
// otro activo (Traspaso, que guarda la salida y la entrada)
const TRASPASABLES = ["Fondo", "PlanPensiones", "ETF", "Cripto"];
const AYUDA_TRASLADO = "Mover de un sitio tuyo a otro del mismo activo: de un exchange a tu monedero, de tu monedero a un nodo Lightning… "
+ "No es compra ni venta y no cambia el coste. Lo que se pierde por el camino es la comisión, que sí baja la cantidad.";
const AYUDA_TRASPASO = "Sale de este activo y entra en el que elijas, sin vender. Se guardan la salida y la entrada. El coste fiscal es lo que te "
+ "costaron originalmente las participaciones que traspasas (sale en la orden del traspaso); así Hacienda y la rentabilidad cuadran.";
const AYUDA_TRASPASO_CRIPTO = "A otro activo cripto distinto (de BTC a L-BTC, por ejemplo). Si es el mismo bitcoin en otro sitio tuyo (de un exchange a tu "
+ "monedero, a un nodo Lightning…), usa «Traslado». El coste fiscal viaja con él; si dejas vacíos el importe y el coste, pongo el precio de ese día y el coste medio del origen.";
const estado = { datos: null, vista: "resumen", filtros: {}, cargando: false, seleccion: new Set() };
/* ── Utilidades ─────────────────────────────────────────────────────────── */
function h(etiqueta, attrs, ...hijos) {
const el = document.createElement(etiqueta);
for (const [k, v] of Object.entries(attrs || {})) {
if (v === null || v === undefined || v === false) continue;
if (k === "clase") el.className = v;
else if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
else if (k === "estilo") Object.assign(el.style, v);
else el.setAttribute(k, v === true ? "" : v);
}
for (const hijo of hijos.flat()) {
if (hijo === null || hijo === undefined || hijo === false) continue;
el.append(hijo instanceof Node ? hijo : document.createTextNode(String(hijo)));
}
return el;
}
const SVG = "http://www.w3.org/2000/svg";
function s(etiqueta, attrs, ...hijos) {
const el = document.createElementNS(SVG, etiqueta);
for (const [k, v] of Object.entries(attrs || {})) {
if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
else if (v !== null && v !== undefined) el.setAttribute(k, v);
}
for (const hijo of hijos) if (hijo) el.append(hijo instanceof Node ? hijo : document.createTextNode(String(hijo)));
return el;
}
// useGrouping "always": en español 1234 no lleva punto por defecto y la tabla quedaba desigual
const fmtEur = new Intl.NumberFormat("es-ES", { style: "currency", currency: "EUR", maximumFractionDigits: 2, minimumFractionDigits: 2, useGrouping: "always" });
const fmtEur0 = new Intl.NumberFormat("es-ES", { style: "currency", currency: "EUR", maximumFractionDigits: 0, useGrouping: "always" });
const fmtPct = new Intl.NumberFormat("es-ES", { style: "percent", maximumFractionDigits: 2, minimumFractionDigits: 2, signDisplay: "exceptZero" });
const eur = (n) => (n === null || n === undefined ? "—" : fmtEur.format(n));
const eurSigno = (n) => (n === null || n === undefined ? "—" : (n > 0 ? "+" : "") + fmtEur.format(n));
// Sin céntimos, para las cifras grandes del resumen (en tablas, fichas y Fiscal se dejan)
const eur0 = (n) => (n === null || n === undefined ? "—" : fmtEur0.format(n));
const eurSigno0 = (n) => (n === null || n === undefined ? "—" : (Math.round(n) > 0 ? "+" : "") + fmtEur0.format(n));
const pct = (n) => (n === null || n === undefined || !isFinite(n) ? "—" : fmtPct.format(n));
function num(n, max = 8) {
if (n === null || n === undefined) return "—";
return new Intl.NumberFormat("es-ES", { maximumFractionDigits: Math.abs(n) >= 100 ? 2 : max, useGrouping: "always" }).format(n);
}
// Plural según el número que va delante: pl`${n} operación(es) borrada(s)` → «1 operación
// borrada» o «3 operaciones borradas». Cada «(s)» o «(es)» concuerda con el último número anterior.
function pl(trozos, ...valores) {
let salida = trozos[0], n = null;
valores.forEach((v, i) => {
if (typeof v === "number") n = v;
salida += v + trozos[i + 1].replace(/(\p{L}+)\((es|s)\)/gu, (_, palabra, suf) => {
if (n === 1) return palabra;
if (suf === "s") return palabra + "s";
if (palabra === "origen") return "orígenes";
return palabra.replace(/([áéíóú])(n)$/, (x, v2, c) => "aeiou"["áéíóú".indexOf(v2)] + c) + "es";
});
});
return salida;
}
function fecha(iso, corta = false) {
if (!iso) return "—";
const [y, m, d] = iso.slice(0, 10).split("-").map(Number);
// En las tablas («corta»), las de este año sin el año; las demás, con el año entero («19 sept 26» parecía un día)
const sinAño = corta && y === new Date().getFullYear();
return new Date(y, m - 1, d).toLocaleDateString("es-ES", sinAño ? { day: "numeric", month: "short" } : { day: "numeric", month: "short", year: "numeric" });
}
const hoyIso = () => { const d = new Date(); return new Date(d - d.getTimezoneOffset() * 60000).toISOString().slice(0, 10); };
const signo = (n) => (n > 0 ? "pos" : n < 0 ? "neg" : "");
function aNumero(texto) {
let t = String(texto ?? "").trim().replace(/\s|€|\$/g, "");
if (!t) return null;
if (t.includes(",")) t = t.replace(/\./g, "").replace(",", ".");
const n = Number(t);
return isFinite(n) ? n : NaN;
}
// Importes en euros: «100.000» son cien mil (nadie escribe euros con tres decimales).
// En cantidades y precios unitarios no: «1.234» puede ser un decimal.
function aEuros(texto) {
const t = String(texto ?? "").trim().replace(/\s|€|\$/g, "");
return /^-?\d{1,3}(\.\d{3})+$/.test(t) ? aNumero(t.replace(/\./g, "")) : aNumero(t);
}
// «350 + 420,50»: varios pagos de una misma compra, sumados
function aEurosSuma(texto) {
const partes = String(texto ?? "").split("+").map((x) => x.trim()).filter(Boolean);
if (!partes.length) return null;
let total = 0;
for (const parte of partes) {
const v = aEuros(parte);
if (v === null || Number.isNaN(v)) return NaN;
total += v;
}
return Math.round(total * 100) / 100;
}
const colorTipo = (tipo) => `var(--c-${tipo})`;
async function api(ruta, cuerpo) {
const opciones = { headers: { "X-Token": TOKEN } };
if (cuerpo !== undefined) {
opciones.method = "POST";
opciones.headers["Content-Type"] = "application/json";
opciones.body = JSON.stringify(cuerpo);
}
let resp;
try {
resp = await fetch(ruta, opciones);
} catch {
throw new Error(EN_SERVIDOR
? "No hay conexión con el servidor de Caudal. Revisa tu conexión (o el túnel) y vuelve a intentarlo."
: "No hay conexión con Caudal. ¿Sigue abierto en tu equipo?");
}
if ((ruta.startsWith("/api/exportar") || ruta.startsWith("/api/fiscal/exportar") || ruta.startsWith("/api/copia?") || ruta.startsWith("/api/adjunto")) && resp.ok) return resp.blob();
const json = await resp.json().catch(() => ({}));
if (resp.status === 401 && json.sin_sesion) { location.reload(); await new Promise(() => {}); } // a la pantalla de entrada
if (!resp.ok) throw new Error(json.error || `Error ${resp.status}`);
return json;
}
/* ── Entrar con contraseña (solo cuando Caudal está en un servidor) ─────── */
async function pedirAcceso(ruta, cuerpo) {
const opciones = cuerpo === undefined ? {} : { method: "POST", body: JSON.stringify(cuerpo),
headers: { "Content-Type": "application/json", "X-Caudal": "1" } };
let resp;
try { resp = await fetch(ruta, opciones); } catch { throw new Error("No hay conexión con Caudal."); }
const json = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(json.error || `Error ${resp.status}`);
return json;
}
// Qué contraseña se pide y se aconseja. Lo dice el servidor al entrar; estos son los de partida.
const CLAVE = { minimo: 12, recomendado: [20, 32] };
const CONSEJO_CLAVE = () => `Te recomiendo una larga, de ${CLAVE.recomendado[0]} a ${CLAVE.recomendado[1]} caracteres. Vale cualquier carácter: `
+ "mayúsculas, minúsculas, números, símbolos y espacios. Una frase de varias palabras es fácil de recordar y difícil de adivinar.";
// Un campo de contraseña nueva que va diciendo, mientras se escribe, si es larga de sobra
function campoClaveNueva(etiqueta) {
const entradaClave = h("input", { type: "password", autocomplete: "new-password" });
const medida = h("div", { clase: "medida-clave" });
const medir = () => {
const n = entradaClave.value.length;
const nivel = !n ? "" : n < CLAVE.minimo ? "corta" : n < CLAVE.recomendado[0] ? "vale" : "buena";
medida.className = "medida-clave " + nivel;
medida.textContent = !n ? "" : nivel === "corta" ? `${n} caracteres: faltan ${CLAVE.minimo - n} para el mínimo.`
: nivel === "vale" ? `${n} caracteres: vale, pero mejor ${CLAVE.recomendado[0]} o más.` : `${n} caracteres: buena longitud.`;
};
entradaClave.addEventListener("input", medir);
return { input: entradaClave, control: campo(etiqueta, h("div", {}, entradaClave, medida), { completo: true, ayuda: CONSEJO_CLAVE() }) };
}
// La clave de recuperación se enseña UNA vez: no se sigue hasta que se dice que está apuntada
function tarjetaRecuperacion(recuperacion, alSeguir, textoBoton = "Continuar") {
const chk = h("input", { type: "checkbox" });
const seguir = h("button", { type: "button", clase: "btn btn-primario", disabled: true, onclick: alSeguir }, textoBoton);
chk.addEventListener("change", () => { seguir.disabled = !chk.checked; });
return h("div", { clase: "recuperacion" },
h("p", {}, "Tus datos se guardan cifrados con tu contraseña. Si algún día la olvidas, ",
h("b", {}, "esta clave es la única forma de volver a entrar"), " y de abrir tus copias de seguridad."),
h("div", { clase: "clave-recuperacion", "aria-label": "Clave de recuperación" }, recuperacion),
h("button", { type: "button", clase: "btn", onclick: async (e) => {
const boton = e.currentTarget;
if (await copiarTexto(recuperacion)) boton.textContent = "Copiada";
else toast("No se ha podido copiar: apúntala a mano.", true);
} }, "Copiar"),
h("div", { clase: "aviso aviso-grave" }, h("span", {}, h("b", {}, "Sin tu contraseña y sin esta clave, tus datos no se pueden recuperar. "),
"No hay puerta de atrás: nadie puede abrirlos por ti, tampoco quien ha hecho Caudal ni quien administra el servidor.")),
h("p", { clase: "suave" }, "Apúntala en papel o guárdala en un gestor de contraseñas, fuera de este servidor. "
+ "No se volverá a mostrar: si la pierdes, puedes pedir otra desde «Más › Cifrado de los datos» mientras recuerdes la contraseña."),
h("label", { clase: "recuperacion-hecho" }, chk, h("span", {}, "La he guardado, y entiendo que si pierdo la contraseña y esta clave pierdo mis datos.")),
seguir);
}
// Devuelve cuando ya hay sesión: enseña «crea tu contraseña» la primera vez, o «entrar»
async function entrarEnServidor() {
const info = await pedirAcceso("/api/acceso");
Object.assign(CLAVE, { minimo: info.minimo, recomendado: info.recomendado });
if (info.dentro) { TOKEN = info.token; return; }
document.body.classList.add("sin-sesion");
await new Promise((hecho) => {
const nueva = !info.configurado;
const error = h("div", { clase: "error-form oculto", role: "alert" });
const cNueva = nueva ? campoClaveNueva("Contraseña nueva") : null;
const inClave = nueva ? cNueva.input : h("input", { type: "password", autocomplete: "current-password", required: true });
const inRepite = nueva ? h("input", { type: "password", autocomplete: "new-password", required: true }) : null;
const boton = h("button", { type: "submit", clase: "btn btn-primario" }, nueva ? "Crear contraseña y empezar" : "Entrar");
const fallo = (texto) => { error.textContent = texto; error.classList.remove("oculto"); };
const formulario = h("form", { clase: "tarjeta puerta", onsubmit: async (e) => {
e.preventDefault();
error.classList.add("oculto");
if (nueva && inClave.value.length < info.minimo) return fallo(`La contraseña tiene que tener al menos ${info.minimo} caracteres.`);
if (nueva && inClave.value !== inRepite.value) return fallo("Las dos contraseñas no coinciden.");
boton.disabled = true;
try {
const r = await pedirAcceso(nueva ? "/api/acceso/crear" : "/api/acceso/entrar", { clave: inClave.value });
TOKEN = r.token;
const dentro = () => { document.body.classList.remove("sin-sesion"); hecho(); };
if (!r.recuperacion) return dentro();
// Aquí los datos van cifrados desde el principio: antes de seguir, la clave de recuperación
document.getElementById("contenido").replaceChildren(h("div", { clase: "tarjeta puerta" },
h("img", { src: "/web/icono.svg", alt: "", width: 56, height: 56, clase: "puerta-logo" }),
h("h1", {}, "Guarda tu clave de recuperación"), tarjetaRecuperacion(r.recuperacion, dentro)));
} catch (err) { fallo(err.message); inClave.select(); } finally { boton.disabled = false; }
} },
h("img", { src: "/web/icono.svg", alt: "", width: 56, height: 56, clase: "puerta-logo" }),
h("h1", {}, nueva ? "Te doy la bienvenida a Caudal" : "Caudal"),
h("p", { clase: "suave" }, nueva
? "Antes de empezar, crea tu contraseña de Caudal. Es la única que tendrás que recordar: sirve para entrar y para abrir tus copias de seguridad."
: "Escribe tu contraseña de Caudal para entrar."),
error,
nueva ? cNueva.control : campo("Contraseña", inClave, { completo: true }),
nueva ? campo("Repítela", inRepite, { completo: true }) : null,
boton,
nueva ? h("p", { clase: "muy-suave" }, "Apúntala en un sitio seguro: es la llave de tus datos y de tus copias de seguridad.")
: info.recuperable ? h("button", { type: "button", clase: "btn-enlace", onclick: () => pantallaRecuperar() }, "He olvidado la contraseña")
: h("p", { clase: "muy-suave" }, "¿La has olvidado? Se puede poner una nueva desde la ficha de Caudal en tu servidor, sin perder tus datos."));
// Quien olvidó la contraseña: clave de recuperación y contraseña nueva
const pantallaRecuperar = () => {
const errorR = h("div", { clase: "error-form oculto", role: "alert" });
const inRec = h("input", { type: "text", autocomplete: "off", spellcheck: "false", placeholder: "XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" });
const cClave = campoClaveNueva("Contraseña nueva"), inOtra = h("input", { type: "password", autocomplete: "new-password" });
const btn = h("button", { type: "submit", clase: "btn btn-primario" }, "Entrar y cambiar la contraseña");
const falloR = (texto) => { errorR.textContent = texto; errorR.classList.remove("oculto"); };
document.getElementById("contenido").replaceChildren(h("form", { clase: "tarjeta puerta", onsubmit: async (e) => {
e.preventDefault();
errorR.classList.add("oculto");
if (cClave.input.value !== inOtra.value) return falloR("Las dos contraseñas no coinciden.");
btn.disabled = true;
try {
const r = await pedirAcceso("/api/acceso/recuperar", { recuperacion: inRec.value, nueva: cClave.input.value });
TOKEN = r.token;
document.body.classList.remove("sin-sesion");
hecho();
} catch (err) { falloR(err.message); } finally { btn.disabled = false; }
} },
h("h1", {}, "Entrar con la clave de recuperación"),
h("p", { clase: "suave" }, "Escribe la clave de recuperación que guardaste al activar el cifrado y elige una contraseña nueva. Tus datos y tus copias siguen intactos."),
h("div", { clase: "aviso aviso-grave" }, h("span", {}, h("b", {}, "Si tampoco tienes la clave de recuperación, tus datos no se pueden abrir. "),
"Solo queda restaurar una copia de seguridad cuya contraseña recuerdes, en una instalación nueva.")),
errorR, campo("Clave de recuperación", inRec, { completo: true, ayuda: "Da igual con guiones o sin ellos, en mayúsculas o minúsculas." }),
cClave.control, campo("Repítela", inOtra, { completo: true }), btn,
h("button", { type: "button", clase: "btn-enlace", onclick: () => location.reload() }, "Volver")));
inRec.focus();
};
document.getElementById("contenido").replaceChildren(formulario);
inClave.focus();
});
}
function formCambiarClave() {
const error = h("div", { clase: "error-form oculto", role: "alert" });
const clave = (auto) => h("input", { type: "password", autocomplete: auto });
const inActual = clave("current-password"), cNueva = campoClaveNueva("Contraseña nueva"), inNueva = cNueva.input, inRepite = clave("new-password");
const cerrarCambio = modal({
titulo: "Cambiar contraseña",
cuerpo: h("div", {}, error, h("div", { clase: "campos" },
campo("Contraseña actual", inActual, { completo: true }),
cNueva.control, campo("Repítela", inRepite, { completo: true })),
h("div", { clase: "aviso" }, h("span", {}, h("b", {}, "Tus copias de seguridad de antes NO se abrirán con la contraseña nueva. "),
"Cada copia se abre solo con la contraseña que tenías el día que la hiciste. Al terminar te propongo hacer una copia nueva, "
+ "que ya irá con la contraseña nueva; las anteriores, guárdalas apuntando con qué contraseña van, o bórralas.")),
h("p", { clase: "muy-suave" }, "Al cambiarla se cierran las sesiones que tengas abiertas en otros dispositivos.")),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Cambiar", clase: "btn-primario", accion: async () => {
if (inNueva.value !== inRepite.value) { error.textContent = "Las dos contraseñas nuevas no coinciden."; error.classList.remove("oculto"); return; }
const r = await guardarCon(null, error, () => api("/api/acceso/cambiar", { actual: inActual.value, nueva: inNueva.value }));
if (!r) return;
cerrarCambio();
await cargar();
modal({
titulo: "Contraseña cambiada",
cuerpo: h("div", {}, h("p", { estilo: { marginTop: 0 } }, "Desde ahora entras con la contraseña nueva."),
h("div", { clase: "aviso" }, h("span", {}, h("b", {}, "Haz ahora una copia de seguridad nueva. "),
"Las copias que ya tenías solo se abren con la contraseña anterior: si la olvidas, no podrás abrirlas. "
+ "Una copia hecha ahora irá con la contraseña nueva."))),
acciones: [
{ texto: "Más tarde", accion: (c) => c() },
{ texto: "Hacer la copia ahora", clase: "btn-primario", accion: (c) => { c(); ventanaCopia(); } },
],
});
} },
],
});
}
/* ── Acerca de Caudal: licencia, código, contacto y cómo apoyarlo ───────── */
function ventanaAcerca() {
const copiable = (titulo, texto, valor, enlace) => h("div", { clase: "apoyo" },
h("b", {}, titulo), h("span", { clase: "suave" }, texto),
h("code", {}, valor),
h("div", { clase: "acciones-cifrado" },
h("button", { type: "button", clase: "btn", onclick: async (e) => {
const boton = e.currentTarget;
if (await copiarTexto(valor)) boton.textContent = "Copiada";
else toast("No se ha podido copiar: selecciónala a mano.", true);
} }, "Copiar"),
enlace ? h("a", { clase: "btn", href: enlace }, "Abrir en mi monedero") : null));
const apoyos = [
PROYECTO.bip353 && copiable("Una dirección para todo", "Escríbela en tu monedero tal cual y él elige cómo pagar. Vale como "
+ "dirección Lightning de las de siempre, como oferta BOLT12 y, en cadena, como pagos silenciosos: estas dos últimas, "
+ "con monederos que entienden direcciones «₿nombre@dominio» (BIP 353)."
+ (PROYECTO.contacto === PROYECTO.bip353 ? " Es también el correo de contacto." : ""),
PROYECTO.bip353, `lightning:${PROYECTO.bip353}`),
PROYECTO.lightning && copiable("Lightning", "Para cantidades pequeñas, al instante.", PROYECTO.lightning, `lightning:${PROYECTO.lightning}`),
PROYECTO.pagos_silenciosos && copiable("Pagos silenciosos (en cadena)",
"Una sola dirección que no deja rastro entre pagos. Tu monedero tiene que saber enviar a direcciones «sp1…».",
PROYECTO.pagos_silenciosos, `bitcoin:${PROYECTO.pagos_silenciosos}`),
PROYECTO.paynym && copiable("PayNym (en cadena)",
"Si tu monedero no envía a pagos silenciosos: con Ashigaru, Sparrow y otros que usan PayNym, busca este nombre y conéctate a él.",
PROYECTO.paynym),
].filter(Boolean);
const redes = [
PROYECTO.nostr && ["Nostr", h("a", { href: `https://njump.me/${PROYECTO.nostr}`, target: "_blank", rel: "noopener noreferrer" }, PROYECTO.nostr)],
PROYECTO.x && ["X", h("a", { href: `https://x.com/${PROYECTO.x}`, target: "_blank", rel: "noopener noreferrer" }, `@${PROYECTO.x}`)],
].filter(Boolean);
modal({
titulo: "Acerca de Caudal",
cuerpo: h("div", { clase: "acerca" },
h("p", {}, "Caudal es software libre, hecho por esetheyers con Claude. Puedes usarlo, estudiarlo, cambiarlo y compartirlo "
+ "bajo la licencia AGPL-3.0 o posterior, sin ninguna garantía. La parte fiscal es una ayuda para preparar tu declaración, no asesoría fiscal."),
h("div", { clase: "etiqueta-seccion" }, "Fallos, ideas y código"),
h("p", {}, "El código está en ", h("a", { href: PROYECTO.codigo, target: "_blank", rel: "noopener noreferrer" }, PROYECTO.codigo.replace("https://", "")),
". Allí puedes avisar de un fallo o proponer una mejora",
PROYECTO.contacto ? [". Si lo prefieres, escribe a ", h("a", { href: `mailto:${PROYECTO.contacto}` }, PROYECTO.contacto), "."] : "."),
redes.length ? h("p", { clase: "redes" }, "Novedades y conversación: ", redes.flatMap(([n, a], i) => [i ? " · " : "", `${n} `, a])) : null,
h("p", { clase: "suave" }, "Al avisar de un fallo no mandes tus datos ni capturas con tus cifras: con la cartera de ejemplo suele bastar."),
PROYECTO.huella_pgp ? [h("div", { clase: "etiqueta-seccion" }, "Comprobar las descargas"),
h("p", {}, "Cada versión va firmada con esta clave PGP. Compara su huella con la de la página del código antes de fiarte de una descarga:"),
h("code", { clase: "huella" }, PROYECTO.huella_pgp)] : null,
estado.datos && estado.datos.carpeta && !estado.datos.servidor && !estado.datos.ejemplo
? [h("div", { clase: "etiqueta-seccion" }, "Dónde están tus datos"), h("p", { clase: "ruta-datos" }, estado.datos.carpeta)] : null,
apoyos.length ? [h("div", { clase: "etiqueta-seccion" }, "Apoyar el proyecto"),
h("p", {}, "Caudal es gratis y no lleva anuncios ni recoge nada de ti. Si te resulta útil, puedes ayudar a mantenerlo con bitcoin:"),
...apoyos] : null),
acciones: [{ texto: "Cerrar", accion: (c) => c() }],
});
}
/* ── Copia de seguridad: un fichero cifrado con contraseña ──────────────── */
function ficheroEnBase64(fichero) {
return new Promise((bien, mal) => {
const lector = new FileReader();
lector.onload = () => bien(String(lector.result).split(",")[1] || "");
lector.onerror = () => mal(new Error("No se ha podido leer el fichero."));
lector.readAsDataURL(fichero);
});
}
function ventanaCopia() {
if (estado.datos.ejemplo) { toast("Sal de la cartera de ejemplo para hacer o restaurar una copia de la tuya.", true); return; }
const error = h("div", { clase: "error-form oculto", role: "alert" });
const fallo = (texto) => { error.textContent = texto; error.classList.remove("oculto"); };
const clave = (auto) => h("input", { type: "password", autocomplete: auto });
const cClave = campoClaveNueva("Contraseña para la copia");
const inClave = cClave.input, inRepite = clave("new-password"), inAbrir = clave("off");
const conLaDeCaudal = !!estado.datos.servidor; // en un servidor la copia va con tu contraseña de Caudal
const chkJustificantes = h("input", { type: "checkbox", checked: true });
const inFichero = h("input", { type: "file", accept: ".caudal" });
const tamaño = (b) => (b > 1048576 ? `${num(b / 1048576, 1)} MB` : `${Math.max(1, Math.round(b / 1024))} KB`);
let cerrar;
const descargar = async (boton) => {
if (!conLaDeCaudal && inClave.value !== inRepite.value) return fallo("Las dos contraseñas no coinciden.");
const r = await guardarCon(boton, error, () => api("/api/copia/crear", { clave: inClave.value, justificantes: chkJustificantes.checked }));
if (!r) return;
try {
const blob = await api(`/api/copia?id=${encodeURIComponent(r.resultado.id)}`);
guardarFichero(blob, r.resultado.nombre);
inClave.value = inRepite.value = "";
toast(`Copia descargada: ${r.resultado.nombre} (${tamaño(r.resultado.bytes)}). Guárdala fuera de este equipo.`);
} catch (e) { fallo(e.message); }
};
const restaurar = async (boton) => {
if (!inFichero.files.length) return fallo("Elige el fichero de la copia (termina en .caudal).");
let fichero;
try { fichero = await ficheroEnBase64(inFichero.files[0]); } catch (e) { return fallo(e.message); }
const cuerpo = { fichero, clave: inAbrir.value };
const vista = await guardarCon(boton, error, () => api("/api/copia/restaurar", cuerpo));
if (!vista) return;
const t = vista.resultado;
const texto = `La copia${t.creada ? ` es del ${fechaHora(t.creada)} y` : ""} trae ${t.inversiones} inversiones y ${t.operaciones} operaciones`
+ (t.justificantes ? `, con ${t.justificantes} justificantes` : "") + ". "
+ (t.ahora.inversiones || t.ahora.operaciones
? `Ahora tienes ${t.ahora.inversiones} inversiones y ${t.ahora.operaciones} operaciones: se sustituirán por las de la copia (antes guardo una copia local de lo que hay).`
: "Ahora no tienes nada apuntado.");
if (!await confirmar(texto, "Restaurar", true)) return;
const hecho = await guardarCon(boton, error, () => api("/api/copia/restaurar", { ...cuerpo, confirmar: true }));
if (!hecho) return;
cerrar();
await cargar();
toast("Copia restaurada.");
};
cerrar = modal({
titulo: "Copia de seguridad",
cuerpo: h("div", {}, error,
h("h4", { estilo: { marginTop: 0 } }, "Descargar una copia"),
h("p", { clase: "nota-form" }, conLaDeCaudal
? "Todo lo tuyo en un solo fichero cifrado. Se abre con tu contraseña de Caudal de hoy: no hay otra que recordar. "
+ "Guárdalo fuera de este servidor: en un USB, en otro ordenador o en tu nube."
: "Todo lo tuyo en un solo fichero, cifrado con la contraseña que pongas aquí. Guárdalo fuera de este equipo: "
+ "en un USB, en otro ordenador o en tu nube. Sin la contraseña no se puede abrir y no hay forma de recuperarla, así que apúntala."),
h("div", { clase: "campos" },
conLaDeCaudal ? null : cClave.control, conLaDeCaudal ? null : campo("Repítela", inRepite, { completo: true }),
h("label", { clase: "campo completo", estilo: { flexDirection: "row", alignItems: "center", gap: "8px" } },
chkJustificantes, h("span", {}, "Incluir los justificantes (facturas, extractos) que hayas adjuntado."))),
h("button", { type: "button", clase: "btn btn-primario", estilo: { marginTop: "12px" }, onclick: (e) => descargar(e.currentTarget) }, "Descargar la copia"),
h("h4", { estilo: { marginTop: "28px" } }, "Restaurar desde una copia"),
h("p", { clase: "nota-form" }, "Para recuperar tus datos en una instalación nueva o volver a como estaban. "
+ "Restaurar sustituye lo que haya ahora por lo que trae la copia; antes de hacerlo te digo qué trae y te pido confirmación."),
h("div", { clase: "campos" }, campo("Fichero de la copia", inFichero, { completo: true }),
campo("Su contraseña", inAbrir, { completo: true,
ayuda: "La contraseña de Caudal que tenías cuando hiciste esa copia, o la que le pusiste al hacerla." })),
h("button", { type: "button", clase: "btn", estilo: { marginTop: "12px" }, onclick: (e) => restaurar(e.currentTarget) }, "Abrir la copia…")),
acciones: [{ texto: "Cerrar", accion: (c) => c() }],
});
}
/* ── Sincronizar con otro Caudal (el del servidor con el del USB) ───────── */
// El dibujo de un QR a partir de sus filas de «1» y «0», con el margen claro que pide el estándar
function dibujoQr(filas) {
const n = filas.length, margen = 4, lado = n + margen * 2;
let camino = "";
filas.forEach((fila, y) => { for (let x = 0; x < n; x++) if (fila[x] === "1") camino += `M${x + margen},${y + margen}h1v1h-1z`; });
return s("svg", { viewBox: `0 0 ${lado} ${lado}`, role: "img", "aria-label": "Código QR para enlazar", clase: "qr", "shape-rendering": "crispEdges" },
s("rect", { width: lado, height: lado, fill: "#fff" }), s("path", { d: camino, fill: "#000" }));
}
// En el servidor: un código de pocos minutos (escrito y en QR) para enlazar otro equipo
function zonaCodigoEnlace(error) {
const zona = h("div", { clase: "codigo-enlace" });
let reloj = null;
const pedir = async (boton) => {
error.classList.add("oculto");
const r = await guardarCon(boton, error, async () => {
const { resultado } = await api("/api/enlace/codigo", {});
// La dirección de casa que da el servidor (o la escrita a mano); si no hay, la de esta página.
// Un nombre .local no vale: el teléfono no sabe resolverlo
const dir = resultado.direcciones || {};
const direccion = dir.casa || location.origin, tor = dir.tor || "";
const texto = `caudal://enlazar?u=${encodeURIComponent(direccion)}${tor ? `&t=${encodeURIComponent(tor)}` : ""}&c=${resultado.codigo}`;
const qr = await api("/api/qr", { texto });
return { ...resultado, direccion, tor, dir, filas: qr.resultado.filas };
});
if (!r) return;
clearInterval(reloj);
const caduca = Date.now() + r.segundos * 1000;
const cuenta = h("span", { clase: "suave" });
const tic = () => {
const quedan = Math.max(0, Math.round((caduca - Date.now()) / 1000));
if (!zona.isConnected || quedan === 0) {
clearInterval(reloj);
if (zona.isConnected) pintarBoton("El código ha caducado.");
return;
}
cuenta.textContent = `Vale una vez, durante ${Math.floor(quedan / 60)}:${String(quedan % 60).padStart(2, "0")}`;
};
zona.replaceChildren(
h("div", { clase: "codigo-enlace-qr" }, dibujoQr(r.filas)),
h("div", { clase: "codigo-enlace-datos" },
h("div", {}, h("div", { clase: "muy-suave" }, "Dirección en casa"), h("code", {}, r.direccion),
/\.local(:|\/|$)/.test(new URL(r.direccion).host + "/") ? h("div", { clase: "error-form" }, "Un nombre .local no sirve en el teléfono: pon la IP del servidor en «Cambiar las direcciones».") : null),
h("div", {}, h("div", { clase: "muy-suave" }, "Fuera de casa, por Tor"), r.tor ? h("code", {}, r.tor) : h("span", { clase: "suave" }, "sin dirección .onion")),
h("div", {}, h("div", { clase: "muy-suave" }, "Código de enlace"), h("code", { clase: "codigo-grande" }, r.codigo)),
cuenta,
h("p", { clase: "muy-suave" }, "En el otro Caudal: «Sincronizar con otro Caudal». La aplicación del móvil lee el QR y se lleva las dos direcciones; "
+ "en un ordenador, escribe la dirección y el código."),
h("div", { clase: "acciones-cifrado" },
h("button", { type: "button", clase: "btn-enlace", onclick: (e) => pedir(e.currentTarget) }, "Pedir otro código"),
h("button", { type: "button", clase: "btn-enlace", onclick: () => formDirecciones(r.dir, () => pedir()) }, "Cambiar las direcciones"))));
tic();
reloj = setInterval(tic, 1000);
};
// Las direcciones que lleva el QR. En StartOS las da el sistema; en otros servidores se escriben una vez
const formDirecciones = (dir, luego) => {
const sistema = (dir && dir.sistema) || {}, manual = (dir && dir.manual) || {};
const inCasa = entrada({ value: manual.casa || "", placeholder: sistema.casa || "https://192.168.1.50:8080" });
const inTor = entrada({ value: manual.tor || "", placeholder: sistema.tor || "http://xxxxxxxxxxxxxxxx.onion" });
const errorDir = h("div", { clase: "error-form oculto", role: "alert" });
modal({
titulo: "Direcciones para enlazar",
cuerpo: h("div", {},
h("p", { clase: "nota-form", estilo: { marginTop: 0 } }, sistema.casa || sistema.tor
? "Tu servidor ya me da sus direcciones (las que ves en gris). Escribe aquí solo si quieres usar otras."
: "Las que lleva el QR para que el otro Caudal te encuentre. Se escriben una vez."),
h("div", { clase: "campos" },
campo("En casa", inCasa, { completo: true, ayuda: "La IP de este servidor en tu red con su puerto, como la abres en el navegador (no un nombre .local: el teléfono no lo entiende). Vacía: la que da el sistema o la de esta página." }),
campo("Fuera de casa (.onion)", inTor, { completo: true, ayuda: "Si tu servidor le da a Caudal una dirección .onion. El otro Caudal la usa cuando no llega a la de casa." })),
errorDir),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Guardar", clase: "btn-primario", accion: async (c) => {
errorDir.classList.add("oculto");
try {
await api("/api/enlace/direcciones", { casa: inCasa.value.trim(), tor: inTor.value.trim() });
c();
toast("Direcciones guardadas. Pido un código nuevo con ellas.");
luego();
} catch (e) { errorDir.textContent = e.message; errorDir.classList.remove("oculto"); }
} },
],
});
};
const pintarBoton = (nota) => zona.replaceChildren(
nota ? h("p", { clase: "suave" }, nota) : null,
h("button", { type: "button", clase: "btn btn-primario", onclick: (e) => pedir(e.currentTarget) }, "Enlazar un equipo con un código"));
pintarBoton();
return zona;
}
function ventanaEnlace() {
const d = estado.datos;
if (d.ejemplo) { toast("Sal de la cartera de ejemplo para sincronizar la tuya.", true); return; }
const error = h("div", { clase: "error-form oculto", role: "alert" });
const cuerpo = h("div", {});
let cerrar;
const pedir = (boton, ruta, datos) => guardarCon(boton, error, () => api(ruta, datos));
const plural = (n, uno, varios) => `${n} ${n === 1 ? uno : varios}`;
// Este Caudal es el servidor: aquí solo se ve quién está enlazado
if (d.servidor) {
cuerpo.append(error,
h("p", { clase: "nota-form", estilo: { marginTop: 0 } }, "Este es el Caudal de tu servidor. Otro Caudal (el de un USB o de otro ordenador) puede enlazarse con él "
+ "para tener siempre lo mismo: se hace desde ese otro Caudal, en este mismo menú, con la dirección de este servidor y tu contraseña "
+ "o, sin teclear la contraseña, con un código de enlace."),
zonaCodigoEnlace(error),
d.equipos.length
? h("div", {}, h("h4", {}, plural(d.equipos.length, "equipo enlazado", "equipos enlazados")),
h("ul", {}, d.equipos.map((e) => h("li", {}, e.nombre || "Otro Caudal", h("span", { clase: "muy-suave" }, ` · desde el ${fecha(e.creado.slice(0, 10))}`)))),
h("button", { type: "button", clase: "btn", onclick: async (e) => {
if (!await confirmar("Los equipos enlazados dejarán de poder sincronizar hasta que vuelvan a enlazarse con tu contraseña. No se borra ningún dato.", "Retirar el acceso", true)) return;
if (await pedir(e.currentTarget, "/api/enlace/olvidar", {})) { cerrar(); await cargar(); toast("Acceso retirado a los equipos enlazados."); }
} }, "Retirar el acceso a todos"))
: h("p", { clase: "suave" }, "Ahora mismo no hay ningún equipo enlazado."));
cerrar = modal({ titulo: "Sincronizar con otro Caudal", cuerpo, acciones: [{ texto: "Cerrar", accion: (c) => c() }] });
return;
}
// Este Caudal se enlaza con uno de un servidor
const frases = (r) => {
const lista = [];
if (r.llegan_alli || r.cambian_alli) lista.push(`A este Caudal llegan ${plural(r.llegan_alli, "registro nuevo", "registros nuevos")} del servidor (inversiones, operaciones y precios)`
+ (r.cambian_alli ? ` y ${plural(r.cambian_alli, "se actualiza", "se actualizan")}` : "") + ".");
if (r.llegan_aqui || r.cambian_aqui) lista.push(`Al servidor van ${plural(r.llegan_aqui, "registro nuevo", "registros nuevos")} de este Caudal (inversiones, operaciones y precios)`
+ (r.cambian_aqui ? ` y ${plural(r.cambian_aqui, "se actualiza", "se actualizan")}` : "") + ".");
for (const x of r.renombradas) lista.push(`«${x.nombre}» coincide en identificador con otra inversión del servidor: se le da uno nuevo y sus operaciones la siguen. No se pierde nada.`);
if (r.duplicadas) lista.push(`${plural(r.duplicadas, "operación estaba apuntada", "operaciones estaban apuntadas")} en los dos sitios desde el mismo extracto: se queda una sola.`);
if ((r.ajustes_aqui.length || r.ajustes_alli.length) && !lista.length) lista.push("Solo cambian ajustes (monederos, criterios fiscales).");
return lista.length ? lista : ["Los dos Caudal ya tienen lo mismo: no hay nada que mezclar."];
};
const pintarEnlazado = () => {
const resultado = h("div", {});
const sincronizar = async (boton) => {
error.classList.add("oculto");
const s = await pedir(boton, "/api/enlace/sincronizar", { simular: true });
if (!s) return;
const r = s.resultado.resumen;
const nada = !(r.llegan_alli || r.cambian_alli || r.llegan_aqui || r.cambian_aqui || r.renombradas.length || r.duplicadas
|| r.ajustes_aqui.length || r.ajustes_alli.length);
resultado.replaceChildren(...[h("h4", {}, nada ? "Ya está todo igual" : "Esto es lo que va a pasar"),
r.carteras_distintas ? h("div", { clase: "aviso aviso-grave" }, h("span", {}, h("b", {}, "Estas dos carteras no tienen nada en común. "),
"Si sigues se juntarán en una sola. Si lo que quieres es llevar esta cartera a un servidor vacío, o al revés, es lo normal la primera vez; si no, cancela.")) : null,
h("ul", {}, frases(r).map((f) => h("li", {}, f))),
nada ? null : h("p", { clase: "muy-suave" }, "Mezclar no borra lo tuyo: si un apunte está en los dos sitios con datos distintos se queda lo último que cambiaste tú "
+ "(lo que Caudal apunta solo, como las automáticas, nunca pisa lo que tocaste a mano), "
+ "y antes de cambiar nada cada Caudal guarda una copia local de lo que tenía."),
nada ? null : h("button", { type: "button", clase: "btn btn-primario", onclick: async (e) => {
const hecho = await pedir(e.currentTarget, "/api/enlace/sincronizar", {});
if (!hecho) return;
const j = hecho.resultado.justificantes;
cerrar();
await cargar();
toast("Sincronizado: los dos Caudal tienen lo mismo." + (j.subidos || j.bajados ? ` Justificantes: ${j.subidos} enviados, ${j.bajados} recibidos.` : ""));
} }, "Mezclar ahora")].filter(Boolean));
};
cuerpo.replaceChildren(error,
h("p", { clase: "nota-form", estilo: { marginTop: 0 } }, "Este Caudal está enlazado con el de tu servidor. Al sincronizar se mezclan los dos: "
+ "lo que hayas apuntado en cada uno acaba en ambos. Primero te digo qué va a cambiar y no toco nada hasta que lo confirmes."),
h("p", {}, h("b", {}, "Servidor: "), d.enlace.url, h("span", { clase: "muy-suave" },
d.enlace.ultima ? ` · última vez, el ${fechaHora(d.enlace.ultima)}` : " · aún no se ha sincronizado")),
h("p", { clase: "suave" }, h("b", {}, "Fuera de casa: "),
d.enlace.url_tor ? `si no llego a esa dirección, voy por Tor a ${d.enlace.url_tor.replace(/^(https?:\/\/.{10}).*(\.onion.*)$/, "$1…$2")} ` : "solo se sincroniza donde se llegue a esa dirección. ",
h("button", { type: "button", clase: "btn-enlace", onclick: () => formOnion() }, d.enlace.url_tor ? "Cambiar" : "Añadir una dirección .onion")),
h("div", { clase: "acciones-cifrado" },
h("button", { type: "button", clase: "btn btn-primario", onclick: (e) => sincronizar(e.currentTarget) }, "Ver qué cambiaría"),
h("button", { type: "button", clase: "btn", onclick: async (e) => {
if (!await confirmar("Este Caudal dejará de sincronizar con el servidor. No se borra ningún dato en ninguno de los dos.", "Desenlazar")) return;
if (await pedir(e.currentTarget, "/api/enlace/configurar", { desenlazar: true })) { cerrar(); await cargar(); }
} }, "Desenlazar")),
resultado);
};
// La dirección .onion del servidor: el camino de reserva cuando no se está en casa
const formOnion = () => {
const inOnion = entrada({ value: d.enlace.url_tor || "", placeholder: "http://xxxxxxxxxxxxxxxx.onion" });
const errorOnion = h("div", { clase: "error-form oculto", role: "alert" });
modal({
titulo: "Sincronizar fuera de casa",
cuerpo: h("div", {},
h("p", { clase: "nota-form", estilo: { marginTop: 0 } }, "Si tu servidor le da a Caudal una dirección .onion (en StartOS, en la ficha de Caudal, en sus direcciones), ponla aquí. "
+ "Caudal seguirá probando primero la dirección de casa y, si no llega, irá por Tor a esta. Hace falta tener Tor en marcha en este equipo."),
h("div", { clase: "campos" }, campo("Dirección .onion de tu Caudal del servidor", inOnion, { completo: true,
ayuda: "Al guardar la pruebo: por Tor tarda unos segundos. Déjala vacía para quitarla." })),
errorOnion),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Probar y guardar", clase: "btn-primario", accion: async (c, boton) => {
errorOnion.classList.add("oculto");
try {
const { resultado } = await api("/api/enlace/configurar", { url_tor: inOnion.value.trim() });
d.enlace.url_tor = resultado.url_tor;
c();
toast(resultado.url_tor ? "Dirección .onion comprobada y guardada." : "Dirección .onion quitada.");
pintarEnlazado();
} catch (e) { errorOnion.textContent = e.message; errorOnion.classList.remove("oculto"); }
} },
],
});
};
const pintarSinEnlazar = () => {
const inUrl = entrada({ placeholder: "https://caudal.midominio.local" });
const inTor = entrada({ placeholder: "http://xxxxxxxxxxxxxxxx.onion" });
const inClave = h("input", { type: "password", autocomplete: "off" });
const inCert = h("input", { type: "file", accept: ".crt,.pem,.cer" });
const inNombre = entrada({ value: window.CaudalAndroid ? "Mi teléfono" : "Mi USB", maxlength: 60 });
const inCodigo = entrada({ placeholder: "ABCDE-FGHJK", autocapitalize: "characters", maxlength: 16 });
cuerpo.replaceChildren(error,
h("p", { clase: "nota-form", estilo: { marginTop: 0 } }, "Si tienes Caudal en un servidor, enlaza este con él para que los dos tengan siempre lo mismo. "
+ "El código o la contraseña del servidor solo se usan ahora: se cambian por una credencial propia de este equipo, que puedes retirar desde el servidor."),
h("div", { clase: "campos" },
campo("Dirección de tu Caudal del servidor", inUrl, { completo: true, ayuda: "La misma que abres en el navegador, con su https://. Si es una .onion, se va por el Tor de este equipo." }),
campo("Código de enlace", inCodigo, { ayuda: window.CaudalAndroid && window.CaudalAndroid.leerQR
? h("button", { type: "button", clase: "btn-enlace", onclick: () => window.CaudalAndroid.leerQR() }, "Leer el QR del servidor con la cámara")
: "El que enseña el servidor en este mismo menú. Dura unos minutos y vale una vez." }),
campo("Dirección .onion, para fuera de casa (opcional)", inTor, { completo: true, ayuda: window.CaudalAndroid
? "La trae el QR si el servidor la tiene. Se usa cuando no llegas a la de casa, por Tor: hace falta Orbot en el teléfono."
: "Se usa cuando no llegas a la de casa, por Tor: hace falta Tor en marcha en este equipo." }),
campo("…o tu contraseña de Caudal en el servidor", inClave, { ayuda: "Si no tienes un código a mano. Con uno de los dos basta." }),
campo("Cómo llamar a este equipo", inNombre, { ayuda: "Para reconocerlo desde el servidor." }),
campo("Certificado raíz del servidor (opcional)", inCert, { ayuda: "Solo si el servidor usa su propia autoridad, como StartOS: el fichero .crt que descargaste de él." })),
h("button", { type: "button", clase: "btn btn-primario", estilo: { marginTop: "12px" }, onclick: async (e) => {
const boton = e.currentTarget;
let pem = "";
try { pem = inCert.files && inCert.files[0] ? await inCert.files[0].text() : ""; } catch { pem = ""; }
const r = await pedir(boton, "/api/enlace/configurar", { url: inUrl.value, url_tor: inTor.value.trim(), clave: inClave.value, codigo: inCodigo.value.trim(), certificado_pem: pem, nombre: inNombre.value });
if (!r) return;
await cargar();
Object.assign(d, estado.datos);
pintarEnlazado();
} }, "Enlazar"));
};
if (d.enlace.url) pintarEnlazado(); else pintarSinEnlazar();
// La aplicación de Android entrega aquí lo que lee la cámara: «caudal://enlazar?u=<dirección>&c=<código>»
window.caudalQRLeido = (texto) => {
const m = /^caudal:\/\/enlazar\?(.*)$/.exec(String(texto || "").trim());
const p = m ? new URLSearchParams(m[1]) : null;
if (!p || !p.get("u") || !p.get("c")) { toast("Ese QR no es el de enlazar con Caudal.", true); return; }
const campos = cuerpo.querySelectorAll("input");
const url = [...campos].find((x) => x.placeholder && x.placeholder.startsWith("https://")), cod = [...campos].find((x) => x.placeholder === "ABCDE-FGHJK");
const tor = [...campos].find((x) => x.placeholder && x.placeholder.endsWith(".onion"));
if (!url || !cod) return;
url.value = p.get("u"); cod.value = p.get("c");
if (tor) tor.value = p.get("t") || "";
toast(p.get("t") ? "Direcciones y código leídos. Pulsa «Enlazar»." : "Dirección y código leídos. Pulsa «Enlazar».");
};
cerrar = modal({ titulo: "Sincronizar con otro Caudal", cuerpo, ancho: true, acciones: [{ texto: "Cerrar", accion: (c) => c() }] });
}
/* ── Cifrado de los datos en el disco del servidor ──────────────────────── */
function ventanaCifrado() {
const d = estado.datos;
if (d.ejemplo) { toast("Sal de la cartera de ejemplo para cambiar el cifrado.", true); return; }
const error = h("div", { clase: "error-form oculto", role: "alert" });
const inClave = h("input", { type: "password", autocomplete: "current-password" });
const cuerpo = h("div", {});
let cerrar;
const ensenar = (recuperacion) => cuerpo.replaceChildren(h("h4", { estilo: { marginTop: 0 } }, "Guarda tu clave de recuperación"),
tarjetaRecuperacion(recuperacion, async () => { cerrar(); await cargar(); }, "Hecho"));
const pedir = async (boton, ruta, datos, hecho) => {
const r = await guardarCon(boton, error, () => api(ruta, { ...datos, clave: inClave.value }));
if (r) hecho(r.resultado);
};
cuerpo.append(error,
d.disco_cifrado ? h("div", { clase: "aviso bienvenida" }, h("span", {}, h("b", {}, "En este servidor no hace falta. "),
"Tu servidor ya guarda todo su disco cifrado, y los datos de Caudal con él. Activar además el cifrado de Caudal no añade protección frente a quien se lleve el disco; "
+ "solo te obliga a entrar tras cada reinicio y a custodiar una clave de recuperación. Puedes dejarlo como está.")) : null,
h("p", { clase: "nota-form", estilo: { marginTop: d.disco_cifrado ? "12px" : 0 } }, d.cifrado
? "Tus datos se guardan cifrados en el disco de este servidor. Quien se lleve el disco, o una copia de él, no puede leerlos sin tu contraseña."
: d.disco_cifrado ? "Caudal no cifra sus datos por su cuenta: ya los protege el cifrado del disco de tu servidor."
: "Ahora tus datos se guardan sin cifrar en el disco de este servidor. Si el servidor ya cifra todo su disco, no hace falta más. "
+ "Si no lo cifra, conviene activarlo: quien se lleve el disco no podrá leer nada sin tu contraseña."),
h("ul", { clase: "suave" },
h("li", {}, "Con el cifrado activado, cada vez que el servidor se reinicie Caudal queda cerrado hasta que entres: mientras tanto no actualiza precios ni genera las operaciones automáticas."),
h("li", {}, "Si olvidas la contraseña, solo se entra con la clave de recuperación que se te da al activarlo.")),
h("div", { clase: "aviso aviso-grave" }, h("span", {}, h("b", {}, "Con el cifrado activado, sin tu contraseña y sin la clave de recuperación tus datos no se pueden recuperar. "),
"No hay puerta de atrás: nadie puede abrirlos por ti. Guarda las dos cosas en sitios distintos y haz copias de seguridad.")),
campo("Tu contraseña de Caudal", inClave, { completo: true, ayuda: "Para confirmar que eres tú." }),
h("div", { clase: "acciones-cifrado" },
d.cifrado
? h("button", { type: "button", clase: "btn", onclick: (e) => pedir(e.currentTarget, "/api/cifrado", { activar: false },
async () => { cerrar(); await cargar(); toast("Cifrado desactivado: los datos vuelven a guardarse sin cifrar."); }) }, "Desactivar el cifrado")
: h("button", { type: "button", clase: "btn btn-primario", onclick: (e) => pedir(e.currentTarget, "/api/cifrado", { activar: true },
(r) => ensenar(r.recuperacion)) }, "Activar el cifrado"),
d.cifrado || d.recuperacion
? h("button", { type: "button", clase: "btn", title: "La anterior dejará de valer",
onclick: (e) => pedir(e.currentTarget, "/api/cifrado/recuperacion", {}, (r) => ensenar(r.recuperacion)) }, "Nueva clave de recuperación")
: null));
cerrar = modal({ titulo: "Cifrado de los datos", cuerpo, acciones: [{ texto: "Cerrar", accion: (c) => c() }] });
}
async function cerrarSesion() {
try { await pedirAcceso("/api/acceso/salir", {}); } catch { /* da igual: se recarga y pedirá entrar */ }
location.reload();
}
// Entrega al usuario un fichero que la página acaba de recibir. En un navegador, con un enlace
// de descarga; dentro de la aplicación de Android, que no sabe guardar esos enlaces, se lo pasa a ella.
function guardarFichero(blob, nombre) {
if (window.CaudalAndroid && window.CaudalAndroid.guardar) {
const lector = new FileReader();
lector.onload = () => window.CaudalAndroid.guardar(nombre, String(lector.result).split(",")[1]);
lector.onerror = () => toast("No se ha podido preparar el fichero.", true);
lector.readAsDataURL(blob);
return;
}
const url = URL.createObjectURL(blob);
const a = h("a", { href: url, download: nombre });
document.body.append(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
// Copiar al portapapeles. La copia moderna solo existe en páginas seguras (HTTPS o
// el propio equipo); Umbrel sirve las apps por http, y ahí vale la clásica: un
// cuadro de texto oculto, seleccionado, y la orden «copiar» del navegador.
async function copiarTexto(texto) {
if (navigator.clipboard && window.isSecureContext) {
try { await navigator.clipboard.writeText(texto); return true; } catch { /* se prueba la clásica */ }
}
const area = h("textarea", { readonly: true, "aria-hidden": "true",
estilo: { position: "fixed", top: "0", left: "0", width: "1px", height: "1px", opacity: "0" } });
area.value = texto;
const foco = document.activeElement;
document.body.append(area);
area.focus();
area.select();
area.setSelectionRange(0, texto.length);
let hecho = false;
try { hecho = document.execCommand("copy"); } catch { hecho = false; }
area.remove();
if (foco && foco.focus) foco.focus();
return hecho;
}
// «accion»: un botón en el aviso, como «Deshacer» ([texto, función]); entonces dura más
function toast(texto, error = false, accion = null) {
const t = h("div", { clase: "toast" + (error ? " error" : ""), role: error ? "alert" : "status" }, texto,
accion ? h("button", { type: "button", clase: "toast-accion", onclick: () => { t.remove(); accion[1](); } }, accion[0]) : null);
document.getElementById("toasts").append(t);
setTimeout(() => t.remove(), accion ? 9000 : error ? 7000 : 4000);
}
// Deshacer un borrado de operaciones (vuelven tal como estaban)
function deshacerBorrado(ids, alCambiar) {
return ["Deshacer", async () => {
try { await api("/api/movimientos/restaurar", { ids }); toast("Hecho: ha vuelto."); await alCambiar(); } catch (e) { toast(e.message, true); }
}];
}
/* ── Modales ────────────────────────────────────────────────────────────── */
function modal({ titulo, cuerpo, acciones = [], ancho = false, alCerrar }) {
const cerrar = () => { velo.remove(); document.removeEventListener("keydown", tecla); alCerrar && alCerrar(); };
const tecla = (e) => { // Escape cierra solo el modal de arriba
const velos = document.querySelectorAll(".velo");
if (e.key === "Escape" && velos[velos.length - 1] === velo) cerrar();
};
const pie = acciones.length ? h("div", { clase: "modal-pie" }, acciones.map((a) =>
h("button", { type: "button", clase: "btn " + (a.clase || "") + (a.izq ? " izq" : ""), onclick: () => a.accion(cerrar) }, a.texto))) : null;
const velo = h("div", { clase: "velo", onmousedown: (e) => { if (e.target === velo) cerrar(); } },
h("div", { clase: "modal" + (ancho ? " ancho" : ""), role: "dialog", "aria-modal": "true", "aria-label": titulo },
h("div", { clase: "modal-cab" }, h("h3", {}, titulo), h("button", { type: "button", clase: "btn-icono", "aria-label": "Cerrar", onclick: cerrar }, "✕")),
h("div", { clase: "modal-cuerpo" }, cuerpo), pie));
document.body.append(velo);
document.addEventListener("keydown", tecla);
setTimeout(() => { const f = velo.querySelector("input:not([type=hidden]), select"); f && f.focus(); }, 30);
return cerrar;
}
function confirmar(texto, textoBoton = "Confirmar", peligro = false) {
return new Promise((resolver) => {
modal({
titulo: "¿Seguro?", cuerpo: h("p", {}, texto), alCerrar: () => resolver(false),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: textoBoton, clase: peligro ? "btn-peligro" : "btn-primario", accion: (c) => { resolver(true); c(); } },
],
});
});
}
function campo(etiqueta, control, { completo = false, ayuda = null } = {}) {
return h("label", { clase: "campo" + (completo ? " completo" : "") }, etiqueta, control, ayuda ? h("span", { clase: "ayuda" }, ayuda) : null);
}
// Cambia el rótulo y la ayuda de un campo ya creado
function etiquetaCampo(c, texto, ayuda = "") {
c.firstChild.textContent = texto;
let a = c.querySelector(".ayuda");
if (!a) { a = h("span", { clase: "ayuda" }); c.appendChild(a); }
a.textContent = ayuda;
}
const entrada = (attrs) => h("input", { type: "text", autocomplete: "off", ...attrs });
const entradaNum = (attrs) => entrada({ inputmode: "decimal", ...attrs });
function selector(opciones, valor, attrs = {}) {
return h("select", attrs, opciones.map(([v, t]) => h("option", { value: v, selected: v === valor }, t)));
}
async function guardarCon(boton, errorEl, accion) {
errorEl.classList.add("oculto");
boton && (boton.disabled = true);
try {
return await accion();
} catch (e) {
errorEl.textContent = e.message;
errorEl.classList.remove("oculto");
return undefined;
} finally {
boton && (boton.disabled = false);
}
}
/* ── Carga y cabecera ───────────────────────────────────────────────────── */
async function cargar() {
estado.datos = await api("/api/estado");
pintarLanzador();
pintar();
}
// Solo en el programa de escritorio de Linux: Caudal en el menú de aplicaciones del equipo
function lanzadorActual() { const i = estado.datos && estado.datos.instalacion; return i ? i.lanzador : null; }
function pintarLanzador() {
const boton = document.querySelector('[data-accion="lanzador"]'), l = lanzadorActual();
boton.classList.toggle("oculto", !l);
if (l) boton.textContent = l.puesto ? "Quitar del menú de aplicaciones" : "Añadir al menú de aplicaciones";
}
async function cambiarLanzador(poner) {
try {
await api("/api/instalacion/lanzador", { poner });
toast(poner ? "Hecho: busca «Caudal» en el menú de aplicaciones." : "Hecho: Caudal ya no está en el menú de aplicaciones.");
} catch (e) { toast(e.message, true); }
await cargar();
}
function activoPorId(id) { return estado.datos.activos.find((a) => a.id === id); }
function nombreActivo(id) { const a = activoPorId(id); return a ? a.nombre : id; }
function filaResumen(id) {
const r = estado.datos.resumen;
return r.abiertos.find((f) => f.id === id) || r.cerrados.find((f) => f.id === id) || (r.bienes || []).find((f) => f.id === id);
}
function pintar() {
const d = estado.datos;
const r = d.resumen;
const sub = document.getElementById("subtitulo");
sub.textContent = d.ejemplo ? `${fecha(r.hoy)} · cartera de ejemplo`
: `${fecha(r.hoy)} · precios descargados ${d.ultima_descarga_precios ? fecha(d.ultima_descarga_precios) : "nunca"}`
+ (d.sync.configurado ? (d.sync.pendiente ? " · cambios sin sincronizar" : " · sincronizado") : "");
// Cerradas, Automáticas y Fiscal no aparecen hasta que tienen algo que enseñar
const conAlgo = { cerradas: r.cerrados.length > 0, automaticas: d.recurrentes.length > 0, fiscal: d.movimientos.length > 0 };
for (const el of document.querySelectorAll("[data-vista]")) {
el.classList.toggle("oculto", !d.ejemplo && conAlgo[el.dataset.vista] === false);
}
if (!d.ejemplo && conAlgo[estado.vista] === false) estado.vista = "resumen";
for (const p of document.querySelectorAll(".pestana")) {
p.setAttribute("aria-selected", String(p.dataset.vista === estado.vista));
p.querySelector(".contador")?.remove();
}
document.getElementById("pestana-mas").setAttribute("aria-selected", String(VISTAS_EN_MAS.includes(estado.vista)));
const pendientes = d.movimientos.filter((m) => m.pendiente || m.estimado).length;
if (pendientes) document.querySelector('[data-vista="movimientos"]').append(h("span", { clase: "contador" }, pendientes));
const cont = document.getElementById("contenido");
cont.replaceChildren(...[d.ejemplo ? avisoEjemplo() : null, ({ resumen: vistaResumen, activos: vistaActivos, cerradas: vistaCerradas,
movimientos: vistaMovimientos, automaticas: vistaAutomaticas, fiscal: vistaFiscal })[estado.vista]()].filter(Boolean));
pintarRecorrido();
}
// El recorrido por la cartera de ejemplo: cinco notas sobre la pantalla de verdad
const RECORRIDO = [
{ vista: "resumen", sitio: () => ".patrimonio", titulo: "Lo que tienes de verdad",
texto: "Tu patrimonio neto: inversiones y cuentas, menos lo que debes, y cuánto ha cambiado desde tu última visita. Debajo, lo que has ganado y el interés al año que te dan tus inversiones; el «?» de cada cifra la explica en llano." },
{ vista: "activos", sitio: () => ".tabla-inversiones, .lista-movil", titulo: "Cada inversión, en una fila",
texto: "Púlsala y se abre su ficha: lo que vale, lo que has ganado y al año, dónde la tienes y todas sus operaciones, para corregir cualquiera." },
{ vista: "fiscal", sitio: () => "#contenido", titulo: "Lo que hay que declarar",
texto: "Año a año: ganancias y pérdidas, dividendos e intereses, lo del alquiler y lo que te retuvieron, con las reglas de tu territorio y la casilla de la renta donde va cada cosa." },
{ vista: "resumen", sitio: () => (MOVIL.matches ? "#btn-cuentame" : "#btn-asistente"), titulo: "Cuéntame",
texto: "Escribe lo que has hecho como se lo dirías a alguien («compré 0,01 bitcoin por 600 € ayer») y te preparo la operación: solo tienes que revisarla." },
{ vista: "resumen", sitio: () => (MOVIL.matches ? "#pestana-mas" : "#btn-menu"), titulo: "Tus datos, a salvo",
texto: "En «Más» están la copia de seguridad, importar extractos del banco y conectar tu bitcoin. Tus datos viven solo en tu equipo: haz copias a menudo." },
];
function irAlPaso(i) {
estado.recorrido = i >= 0 && i < RECORRIDO.length ? i : null;
if (estado.recorrido !== null) estado.vista = RECORRIDO[i].vista;
pintar();
}
function pintarRecorrido() {
document.querySelector(".recorrido")?.remove();
for (const el of document.querySelectorAll(".resaltado")) el.classList.remove("resaltado");
const i = estado.recorrido;
if (i === null || i === undefined || !estado.datos.ejemplo) return;
const p = RECORRIDO[i];
const sitio = document.querySelector(p.sitio());
if (sitio) { sitio.classList.add("resaltado"); sitio.scrollIntoView?.({ block: "nearest", behavior: "smooth" }); }
document.body.append(h("div", { clase: "recorrido", role: "dialog", "aria-label": "Recorrido por Caudal" },
h("div", { clase: "recorrido-cabeza" }, h("span", { clase: "muy-suave" }, `${i + 1} de ${RECORRIDO.length}`),
h("button", { type: "button", clase: "btn-icono", "aria-label": "Terminar el recorrido", onclick: () => irAlPaso(-1) }, "✕")),
h("b", {}, p.titulo), h("p", {}, p.texto),
h("div", { clase: "recorrido-botones" },
i ? h("button", { type: "button", clase: "btn", onclick: () => irAlPaso(i - 1) }, "Anterior") : null,
h("button", { type: "button", clase: "btn btn-primario", onclick: () => irAlPaso(i + 1 < RECORRIDO.length ? i + 1 : -1) },
i + 1 < RECORRIDO.length ? "Siguiente" : "Terminar"))));
}
/* ── Bienvenida, primeros pasos y cartera de ejemplo ────────────────────── */
async function cambiarEjemplo(entrar) {
try {
await api("/api/ejemplo", { entrar });
estado.vista = "resumen";
estado.filtros = {};
estado.recorrido = entrar ? 0 : null; // al entrar, el recorrido empieza solo
await cargar();
window.scrollTo(0, 0);
} catch (e) { toast(e.message, true); }
}
// Mientras se enseña el ejemplo, siempre a la vista en todas las pestañas
function avisoEjemplo() {
return h("div", { clase: "aviso-ejemplo", role: "status" },
h("span", {}, h("b", {}, "Estás viendo una cartera de ejemplo. "),
h("span", { clase: "no-movil" }, "Los datos son inventados. Toca lo que quieras: nada de esto se guarda ni se mezcla con lo tuyo.")),
h("div", { clase: "aviso-ejemplo-botones" },
estado.recorrido === null || estado.recorrido === undefined ? h("button", { type: "button", clase: "btn", onclick: () => irAlPaso(0) }, "Hacer el recorrido") : null,
h("button", { type: "button", clase: "btn btn-primario", onclick: () => cambiarEjemplo(false) }, "Salir del ejemplo")));
}
// Un paso de la lista: los que faltan llevan sus botones; el primero que falta va resaltado
function paso(n, titulo, texto, { hecho = false, actual = false, botones = [] } = {}) {
return h("div", { clase: "paso" + (hecho ? " hecho" : "") + (actual ? " actual" : "") },
h("div", { clase: "paso-num", "aria-hidden": "true" }, hecho ? "✓" : n),
h("div", { clase: "paso-cuerpo" }, h("h3", {}, titulo, hecho ? h("span", { clase: "muy-suave" }, " hecho") : null),
hecho ? null : h("p", {}, texto),
hecho || !botones.length ? null : h("div", { clase: "paso-botones" }, botones.map(([texto, accion, clase], i) =>
h("button", { type: "button", clase: clase || (i === 0 && actual ? "btn btn-primario" : "btn"), onclick: accion }, texto)))));
}
// Lo que se pregunta al empezar: qué tiene, para hacerle su lista de primeros pasos
const TIENE = [
["bitcoin", "Bitcoin u otras criptomonedas"], ["fondos", "Fondos o ETF"], ["acciones", "Acciones"],
["pension", "Planes de pensiones"], ["rentafija", "Letras, bonos o depósitos"],
["inmuebles", "Pisos, locales o fincas"], ["cuentas", "Cuentas de ahorro"], ["broker", "Cuenta de bróker o trading"],
];
// La lista de primeros pasos: dar de alta lo que dijo que tiene (importando o con el
// asistente antes que con el formulario), apuntar, ver precios y hacer la primera copia.
// Cada paso se marca solo, mirando los datos.
function pasosIniciales(d) {
const pu = d.primer_uso || {};
const hay = (...tipos) => d.activos.some((a) => tipos.includes(a.tipo));
const hayOperacion = d.movimientos.length > 0;
const hayValor = d.resumen.global.valor > 0 || d.resumen.global.liquidez > 0;
const importar = d.extractos && d.extractos.length ? [["Importar un extracto", () => importarExtracto()]] : [];
const alta = (texto) => [texto, () => formActivo()];
const POR_TIPO = {
bitcoin: ["Conecta tu bitcoin", "Desde tu propio nodo, con la clave pública de cada monedero o con sus direcciones: te propongo cada movimiento listo para apuntar. Otras criptomonedas, como una inversión más.",
hay("Cripto") || (d.btc && d.btc.monederos > 0), [["Conectar mi bitcoin", () => asistenteBitcoin()], alta("Otra criptomoneda")]],
fondos: ["Añade tus fondos y ETF", "Si tu banco o tu bróker te da el extracto en CSV, impórtalo: entran el fondo y sus operaciones de una vez. Si no, búscalo por su nombre o su ISIN.",
hay("Fondo", "ETF"), [...importar, alta("Añadir un fondo o ETF")]],
acciones: ["Añade tus acciones", "Búscalas por su nombre o su ticker; el precio se pone al día solo. Si tu bróker da extracto, impórtalo.",
hay("Accion"), [...importar, alta("Añadir acciones")]],
pension: ["Añade tu plan de pensiones", "Con su nombre o su ISIN. Basta con lo aportado y el saldo del último extracto, y las aportaciones de cada mes pueden apuntarse solas.",
hay("PlanPensiones"), [alta("Añadir mi plan")]],
rentafija: ["Añade tus Letras, bonos o depósitos", "Con el nominal, el interés y el vencimiento: los cupones, la devolución y su valor se calculan solos.",
hay("RentaFija"), [alta("Añadir Letras, bonos o un depósito")]],
inmuebles: ["Añade tus inmuebles", "Pisos, garajes, locales y fincas. Su valor se estima solo, el alquiler puede apuntarse cada mes sin tocar nada y sale lo que hay que declarar.",
hay("Inmueble", "Tierra"), [alta("Añadir un inmueble")]],
cuentas: ["Añade tus cuentas", "Basta con el saldo de hoy, para ver tu patrimonio completo. Si tu banco da extracto, impórtalo.",
hay("Cuenta"), [alta("Añadir una cuenta"), ...importar]],
broker: ["Añade tu cuenta de bróker", "Lo que metes, lo que sacas y el saldo de vez en cuando; con eso sale la rentabilidad. Lo fiscal, del informe anual del bróker.",
hay("CuentaTrading"), [alta("Añadir la cuenta")]],
};
const elegidos = (pu.tiene || []).filter((t) => POR_TIPO[t]);
const datos = elegidos.length ? elegidos.map((t) => POR_TIPO[t])
: [["Da de alta tu primera inversión", "Un fondo, unas acciones, bitcoin, un piso, una cuenta… Basta con el nombre y el tipo; lo demás se puede completar después.",
d.activos.length > 0, [...importar, alta("Añadir mi primera inversión")]]];
datos.push(
["Apunta lo que has hecho", "Lo más rápido es contármelo con tus palabras («compré 20 participaciones por 3.000 € el 3 de marzo») y te preparo la operación. Si no lo recuerdas todo, empieza por lo que tienes hoy.",
hayOperacion, [["💬 Cuéntamelo", () => asistente()], ...importar, ["Con el formulario", () => formOperacion({}), "btn-enlace"]]],
["Mira cómo va", "Con el precio de hoy verás cuánto vale, cuánto has ganado y qué rentabilidad llevas, de cada inversión y del conjunto.",
hayValor, [["Actualizar precios", () => actualizarPrecios()]]],
["Haz tu primera copia de seguridad", "Tus datos están solo en este equipo: si se estropea, la copia es lo único que los salva. Guárdala fuera de él, en un USB o en tu nube; va cifrada.",
!!pu.copia || !!(d.sync && d.sync.configurado), [["Hacer una copia", () => ventanaCopia()]]]);
const primero = datos.findIndex((x) => !x[2]);
const hechos = datos.filter((x) => x[2]).length;
return {
completos: primero === -1, hechos, total: datos.length,
lista: datos.map(([titulo, texto, hecho, botones], i) => paso(i + 1, titulo, texto, { hecho, actual: i === primero, botones })),
};
}
// Una pestaña sin nada todavía: qué saldrá aquí y por dónde empezar
function pantallaVacia(titulo, texto, botones) {
return h("div", { clase: "tarjeta pantalla-vacia" }, h("h2", {}, titulo), h("p", {}, texto),
h("div", { clase: "paso-botones" }, botones.map(([t, accion], i) => h("button", { type: "button", clase: i ? "btn" : "btn btn-primario", onclick: accion }, t))));
}
// La tarjeta de primeros pasos del resumen, con su avance y la opción de quitarla
function tarjetaPrimerosPasos(pasos) {
return h("div", { clase: "tarjeta" },
h("h2", {}, "Primeros pasos", h("span", { clase: "suave" }, `${pasos.hechos} de ${pasos.total} hechos`),
h("button", { type: "button", clase: "btn-enlace", estilo: { marginLeft: "auto" }, onclick: async () => {
try { await api("/api/primer_uso", { cerrado: true }); toast("Hecho. Lo que queda está en el menú «Más»."); await cargar(); }
catch (e) { toast(e.message, true); }
} }, "Quitar esta lista")),
h("div", { clase: "progreso", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": pasos.total, "aria-valuenow": pasos.hechos },
h("div", { estilo: { width: `${Math.round(100 * pasos.hechos / pasos.total)}%` } })),
h("div", { clase: "pasos" }, pasos.lista));
}
// Las dos preguntas del principio: qué tiene y dónde declara
function preguntasIniciales(d) {
const pu = d.primer_uso || {};
const marcadas = new Set(pu.tiene || []);
const casillas = TIENE.map(([clave, texto]) => h("label", { clase: "chip-casilla" },
h("input", { type: "checkbox", value: clave, checked: marcadas.has(clave) }), h("span", {}, texto)));
const selTerritorio = selector(Object.entries(d.territorios || {}), d.territorio || "comun", { "aria-label": "Dónde declaras" });
const guardar = async (tiene) => {
try {
await api("/api/primer_uso", { tiene, territorio: selTerritorio.value });
estado.repreguntar = false;
await cargar();
} catch (e) { toast(e.message, true); }
};
return h("div", { clase: "preguntas-iniciales" },
h("h2", {}, "Para empezar, dos preguntas"),
h("div", { clase: "pregunta" }, h("b", {}, "¿Qué tienes?"), h("span", { clase: "suave" }, "Marca todo lo que quieras llevar aquí. Con eso te preparo los primeros pasos."),
h("div", { clase: "chips-casillas" }, casillas)),
h("div", { clase: "pregunta" }, h("b", {}, "¿Dónde haces la declaración de la renta?"),
h("span", { clase: "suave" }, "Cambia cómo se calculan los impuestos. «Territorio común» es el resto de España, también Canarias, Ceuta y Melilla. Se puede cambiar en la pestaña Fiscal."),
selTerritorio),
h("div", { clase: "preguntas-botones" },
h("button", { type: "button", clase: "btn btn-primario", onclick: () => guardar(casillas.map((c) => c.firstChild).filter((x) => x.checked).map((x) => x.value)) }, "Seguir"),
d.hay_ejemplo ? h("button", { type: "button", clase: "btn", onclick: () => cambiarEjemplo(true) }, "Prefiero ver antes una cartera de ejemplo") : null,
h("button", { type: "button", clase: "btn-enlace", onclick: () => guardar([]) }, "Saltar")));
}
// Solo en el programa de escritorio y mientras no haya nada apuntado: dónde se guardan los datos
function eleccionDeCarpeta(ins) {
const opcion = (modo, titulo, texto, ruta, posible = true) => h("label", { clase: "opcion-radio" },
h("input", { type: "radio", name: "carpeta-datos", checked: ins.modo === modo, disabled: !posible, onchange: async () => {
try { await api("/api/instalacion", { modo }); toast("Hecho: tus datos se guardarán ahí."); await cargar(); }
catch (e) { toast(e.message, true); await cargar(); }
} }),
h("span", {}, h("b", {}, titulo), h("span", { clase: "suave" }, texto), h("span", { clase: "muy-suave ruta-datos" }, ruta)));
return h("div", { clase: "bienvenida-carpeta" },
h("b", {}, "¿Dónde guardo tus datos?"),
h("div", { clase: "suave" }, "Se puede cambiar mientras no hayas apuntado nada."),
opcion("personal", "En este equipo", "Lo habitual. Puedes mover o actualizar el programa sin tocar tus datos.", ins.personal),
opcion("portatil", "Junto al programa, para llevarlo en un USB",
ins.portatil_posible ? "Programa y datos viajan juntos en la misma carpeta."
: "Aquí no se puede: la carpeta del programa no deja escribir.", ins.portatil, ins.portatil_posible));
}
function vistaBienvenida() {
const d = estado.datos;
return h("div", { clase: "bienvenida-caja" },
h("div", { clase: "tarjeta bienvenida-tarjeta" },
h("h1", {}, "Te doy la bienvenida a Caudal"),
h("p", { clase: "bienvenida-intro" },
"Aquí llevas todas tus inversiones en un solo sitio: cuánto valen, cuánto has ganado de verdad y qué tendrás que declarar. "
+ "Tus datos se quedan en tu equipo; no se envían a ningún sitio."),
!d.primer_uso || !d.primer_uso.tiene || estado.repreguntar ? preguntasIniciales(d) : h("div", {},
h("div", { clase: "pasos" }, pasosIniciales(d).lista),
h("p", { clase: "muy-suave" }, h("button", { type: "button", clase: "btn-enlace", onclick: () => { estado.repreguntar = true; pintar(); } }, "Cambiar lo que tengo o dónde declaro"))),
d.instalacion && d.instalacion.elegible ? eleccionDeCarpeta(d.instalacion) : null,
lanzadorActual() && !lanzadorActual().puesto ? h("div", { clase: "bienvenida-ejemplo" },
h("div", {}, h("b", {}, "¿Lo quieres en el menú de aplicaciones?"),
h("div", { clase: "suave" }, "Así lo abres como cualquier otro programa, sin buscar el fichero. Se quita cuando quieras desde el menú de Caudal.")),
h("button", { type: "button", clase: "btn", onclick: () => cambiarLanzador(true) }, "Añadir al menú")) : null,
!(d.primer_uso && d.primer_uso.tiene) || estado.repreguntar || d.primer_uso.tiene.includes("bitcoin") ? null : h("div", { clase: "bienvenida-bitcoin" }, h("img", { src: "/web/icono.svg", alt: "", width: 28, height: 28 }),
h("div", {}, h("b", {}, "¿Tienes bitcoin?"),
h("div", { clase: "suave" }, "Caudal lo trata como se merece: lee tus monederos desde tu propio nodo, con su clave pública o con sus direcciones, "
+ "te propone cada movimiento listo para apuntar y prepara lo que toca declarar, minería incluida."),
h("button", { type: "button", clase: "btn", estilo: { marginTop: "10px" }, onclick: () => asistenteBitcoin() }, "Conectar mi bitcoin"))),
d.hay_ejemplo && d.primer_uso && d.primer_uso.tiene ? h("div", { clase: "bienvenida-ejemplo" },
h("div", {}, h("b", {}, "¿Prefieres mirar antes de empezar?"),
h("div", { clase: "suave" }, "Abre una cartera de ejemplo con datos inventados y curiosea sin miedo. No se mezcla con lo tuyo.")),
h("button", { type: "button", clase: "btn", onclick: () => cambiarEjemplo(true) }, "Ver una cartera de ejemplo")) : null,
h("p", { clase: "muy-suave bienvenida-restaurar" }, "¿Ya usabas Caudal? ",
h("button", { type: "button", clase: "btn-enlace", onclick: () => ventanaCopia() }, "Restaura tu copia de seguridad"), ".")));
}
/* ── Resumen ────────────────────────────────────────────────────────────── */
// «ayuda» es la explicación en llano de la cifra: se abre con el botón «?» de la tarjeta
// En el móvil hay dos columnas y poco sitio: las cifras marcadas «kpi-extra» esperan tras un botón,
// y la que se quedaría sola en su fila la ocupa entera.
// La cifra protagonista: lo que tienes de verdad (inversiones y cuentas, menos lo que debes)
// y cuánto ha cambiado desde la última vez que abriste Caudal
// Lo que va a pasar en los próximos dos meses: vencimientos, cuotas, alquileres y aportaciones
function loQueViene(d) {
const hoy = hoyIso(), limite = new Date(Date.now() + 62 * 86400000).toISOString().slice(0, 10);
// El primer día «dia» de un mes que no sea anterior a «desde» (por defecto, hoy)
const proximoDia = (dia, desde = hoy) => {
let [y, m] = desde.split("-").map(Number);
const iso = () => `${y}-${String(m).padStart(2, "0")}-${String(dia).padStart(2, "0")}`;
if (iso() < desde) { m += 1; if (m > 12) { m = 1; y += 1; } }
return iso();
};
const cosas = [];
for (const a of d.activos.filter((x) => x.estado !== "cerrada")) {
if (a.tipo === "RentaFija" && a.renta_fija) cosas.push({ fecha: a.renta_fija.vencimiento, texto: `Vence ${a.nombre}`, cifra: a.renta_fija.nominal ? eur0(a.renta_fija.nominal) : "", clase: "pos", id: a.id });
if (a.tipo === "Prestamo" && a.prestamo) cosas.push({ fecha: a.prestamo.vencimiento, texto: `Te devuelven ${a.nombre}`, cifra: eur0(a.prestamo.importe_final), clase: "pos", id: a.id });
const fi = a.inmueble || {};
if (a.tipo === "Inmueble" && fi.renta_mensual && fi.uso === "alquilado") cosas.push({ fecha: proximoDia(fi.dia_cobro || 1), texto: `Alquiler de ${a.nombre}`, cifra: eur0(fi.renta_mensual), clase: "pos", id: a.id });
}
for (const f of d.resumen.deudas || []) if (f.proxima) cosas.push({ fecha: f.proxima, texto: `Cuota de ${f.nombre}`, cifra: f.cuota ? eur0(-f.cuota) : "", clase: "neg", deuda: f.id });
// Las automáticas: ni pausadas, ni de inversiones cerradas, ni pasada su fecha final
const abiertas = new Set(d.activos.filter((x) => x.estado !== "cerrada").map((x) => x.id));
for (const r of d.recurrentes || []) {
if (r.pausado || !abiertas.has(r.activo)) continue;
const cuando = proximoDia(r.dia, r.desde && r.desde > hoy ? r.desde : hoy);
if (r.hasta && cuando > r.hasta) continue;
const ingreso = r.tipo === "Dividendo";
cosas.push({ fecha: cuando, texto: `${ingreso ? "Cobro" : r.tipo === "Gasto" ? "Gasto" : "Aportación"} · ${nombreActivo(r.activo)}`,
cifra: `${ingreso ? "+" : "−"}${num(r.importe)} ${r.moneda === "USD" ? "$" : "€"}`, clase: ingreso ? "pos" : "", id: r.activo });
}
const proximas = cosas.filter((c) => c.fecha && c.fecha >= hoy && c.fecha <= limite).sort((a, b) => a.fecha.localeCompare(b.fecha)).slice(0, 8);
if (!proximas.length) return null;
return h("div", { clase: "tarjeta seccion" }, h("h2", {}, "Lo que viene", h("span", { clase: "suave" }, "los próximos dos meses")),
h("div", { clase: "lista-viene" }, proximas.map((c) => h("button", { type: "button", clase: "fila-viene", onclick: () => (c.deuda ? detalleDeuda(c.deuda) : detalleActivo(c.id)) },
h("span", { clase: "fecha-viene num" }, fecha(c.fecha, true)), h("span", { clase: "texto-viene" }, c.texto), h("b", { clase: "num " + c.clase }, c.cifra)))));
}
// El periodo de la gráfica de evolución: el último año, tres años o todo
const PERIODOS = [["1", "1 año"], ["3", "3 años"], ["", "Todo"]];
function recortarPeriodo(puntos) {
const años = Number(estado.periodo || 0);
if (!años || !puntos.length) return puntos;
const hoy = new Date(), desde = new Date(hoy.getFullYear() - años, hoy.getMonth(), hoy.getDate()).toISOString().slice(0, 10);
const recorte = puntos.filter((p) => p.fecha >= desde);
return recorte.length >= 2 ? recorte : puntos;
}
function selectorPeriodo() {
return h("div", { clase: "periodos", role: "group", "aria-label": "Periodo" }, PERIODOS.map(([v, t]) =>
h("button", { type: "button", clase: "periodo", "aria-pressed": String((estado.periodo || "") === v), onclick: () => { estado.periodo = v; pintar(); } }, t)));
}
function tarjetaPatrimonio(d) {
const g = d.resumen.global;
const partes = [`inversiones ${eur0(g.valor)}`, g.liquidez ? `cuentas ${eur0(g.liquidez)}` : null,
g.bienes ? `tu casa ${eur0(g.bienes)}` : null].filter(Boolean);
const desglose = (partes.length > 1 || g.deuda) ? partes.join(" + ") + (g.deuda ? ` − deudas ${eur0(g.deuda)}` : "") : null;
const s = d.ultima_sesion;
const antes = s && s.fecha !== d.resumen.hoy ? (s.neto !== undefined ? s.neto : null) : null;
const dif = antes !== null ? g.patrimonio_neto - antes : null;
const ayuda = h("div", { clase: "kpi-ayuda oculto" }, AYUDA_KPI.neto);
return h("div", { clase: "tarjeta patrimonio" },
h("div", { clase: "patrimonio-cifra" },
h("div", { clase: "etiqueta" }, "Tu patrimonio neto",
h("button", { type: "button", clase: "btn-ayuda", "aria-label": "Qué significa «patrimonio neto»", "aria-expanded": "false", onclick: (e) => {
const oculta = ayuda.classList.toggle("oculto"); e.currentTarget.setAttribute("aria-expanded", String(!oculta)); } }, "?")),
h("div", { clase: "patrimonio-valor num" }, eur0(g.patrimonio_neto)),
desglose ? h("div", { clase: "suave" }, desglose[0].toUpperCase() + desglose.slice(1)) : null),
dif !== null && Math.abs(dif) >= 1 ? h("div", { clase: "patrimonio-cambio" },
h("span", { clase: "muy-suave" }, `Desde tu última visita (${fecha(s.fecha)})`),
h("b", { clase: "num " + signo(dif) }, eurSigno0(dif))) : null,
ayuda);
}
function rejillaCifras(tarjetas) {
tarjetas = tarjetas.filter(Boolean);
let fila = [];
const cerrarFila = () => { if (fila.length % 2) fila[fila.length - 1].classList.add("kpi-sola"); fila = []; };
for (const t of tarjetas) { if (t.classList.contains("kpi-principal")) cerrarFila(); else fila.push(t); }
cerrarFila();
const extras = tarjetas.filter((t) => t.classList.contains("kpi-extra")).length;
const rejilla = h("div", { clase: "rejilla rejilla-kpi" + (estado.masCifras ? " todas" : "") }, tarjetas);
const texto = () => (estado.masCifras ? "Ver menos cifras" : `Ver ${extras} cifra${extras === 1 ? "" : "s"} más`);
const boton = extras ? h("button", { type: "button", clase: "btn-enlace ver-cifras", "aria-expanded": String(!!estado.masCifras),
onclick: () => {
estado.masCifras = !estado.masCifras;
rejilla.classList.toggle("todas", estado.masCifras);
boton.setAttribute("aria-expanded", String(estado.masCifras));
boton.textContent = texto();
} }, texto()) : null;
return h("div", { clase: "seccion cifras" }, rejilla, boton);
}
function kpi(etiqueta, valor, detalle, clase = "", extra = "", ayuda = null) {
const explicacion = ayuda ? h("div", { clase: "kpi-ayuda oculto" }, ayuda) : null;
const boton = ayuda ? h("button", { type: "button", clase: "btn-ayuda", "aria-expanded": "false",
"aria-label": `Qué significa «${etiqueta}»`, title: "Qué significa", onclick: (e) => {
const abierta = explicacion.classList.toggle("oculto");
e.currentTarget.setAttribute("aria-expanded", String(!abierta));
} }, "?") : null;
return h("div", { clase: "tarjeta kpi " + extra },
h("div", { clase: "etiqueta" }, etiqueta, boton),
h("div", { clase: "valor num " + clase }, valor),
detalle ? h("div", { clase: "detalle" }, detalle) : null, explicacion);
}
const AYUDA_KPI = {
valor: "Lo que valdría hoy todo lo que tienes invertido si lo vendieras al último precio conocido de cada cosa.",
latente: "Lo que ganarías (o perderías) si vendieras hoy lo que tienes: su valor de hoy menos lo que te costó. "
+ "Mientras no vendas es una ganancia sobre el papel, que sube y baja con los precios.",
tir: "Como el interés de un depósito: el tanto por ciento al año que te están dando tus inversiones, teniendo en cuenta "
+ "cuándo pusiste y sacaste cada euro. Los entendidos la llaman TIR. Sirve para comparar con un depósito o con otra inversión.",
ytd: "Cuánto han subido o bajado tus inversiones desde el 1 de enero, sin que cuenten como ganancia los euros que hayas metido "
+ "ni como pérdida los que hayas sacado.",
cerradas: "Lo que ganaste o perdiste, ya en firme, en las inversiones que vendiste del todo y diste por cerradas. Están en la pestaña «Cerradas».",
liquidez: "El dinero que tienes en cuentas, sin invertir. El patrimonio total suma inversiones y cuentas.",
deuda: "Lo que te queda por devolver de los préstamos que te han concedido.",
neto: "Todo lo que tienes (inversiones y cuentas) menos lo que debes.",
dividendos: "El dinero que tus inversiones te han pagado sin vender nada: dividendos de acciones, intereses, cupones o rentas.",
};
function vistaResumen() {
const d = estado.datos, r = d.resumen, g = r.global;
if (!d.activos.length && !d.movimientos.length) return vistaBienvenida();
const bloques = [];
// Mientras la cartera no esté en marcha, los pasos que faltan van lo primero
const pasos = pasosIniciales(d);
if (!pasos.completos && !d.ejemplo && !(d.primer_uso || {}).cerrado) bloques.push(tarjetaPrimerosPasos(pasos));
const avisos = [];
// Varios avisos de la misma clase se juntan en uno, con los nombres al desplegar
const grupo = (tipos, texto, boton) => {
const suyos = r.avisos.filter((a) => tipos.includes(a.tipo));
if (suyos.length < 2) return [];
const nombres = [...new Set(suyos.map((a) => nombreActivo(a.activo)))];
avisos.push(h("div", { clase: "aviso" }, h("span", {}, texto(suyos.length),
h("div", { clase: "muy-suave" }, nombres.slice(0, 6).join(" · ") + (nombres.length > 6 ? ` y ${nombres.length - 6} más` : ""))), boton));
return suyos;
};
const agrupados = new Set([
...grupo(["precio"], (n) => `${n} inversiones tienen el precio de hace más de una semana.`,
d.ejemplo ? null : h("button", { type: "button", clase: "btn-enlace", onclick: () => actualizarPrecios() }, "Actualizar precios")),
...grupo(["pendiente", "estimado"], (n) => `${n} operaciones tienen cifras aproximadas o sin completar.`,
h("button", { type: "button", clase: "btn-enlace", onclick: () => { estado.vista = "movimientos"; estado.filtros.revisar = true; pintar(); } }, "Verlas")),
]);
for (const a of r.avisos) {
if (agrupados.has(a)) continue;
let boton = null;
if (a.tipo === "cerrar") boton = h("button", { type: "button", clase: "btn-enlace", onclick: () => formCerrar(a.activo) }, "Cerrar inversión");
else if (a.tipo === "precio_manual") boton = h("button", { type: "button", clase: "btn-enlace", onclick: () => formPrecio(a.activo) }, "Poner valoración");
else if (a.tipo === "pendiente" || a.tipo === "estimado") {
const m = d.movimientos.find((x) => x.id === a.movimiento);
boton = h("button", { type: "button", clase: "btn-enlace", onclick: () => formOperacion({ movimiento: m }) }, a.tipo === "pendiente" ? "Completar" : "Revisar");
} else if (a.tipo === "precio") boton = h("button", { type: "button", clase: "btn-enlace", onclick: () => detalleActivo(a.activo) }, "Ver");
else if (a.tipo === "modulo") boton = h("button", { type: "button", clase: "btn-enlace", onclick: () => detalleActivo(a.activo) }, "Ver");
avisos.push(h("div", { clase: "aviso" }, h("span", {}, a.texto), boton));
}
if (d.copia_pendiente) {
avisos.push(h("div", { clase: "aviso" }, h("span", {}, h("b", {}, "Cambiaste la contraseña y aún no tienes una copia de seguridad con la nueva. "),
"Las copias anteriores solo se abren con la contraseña anterior."),
h("button", { type: "button", clase: "btn-enlace", onclick: () => ventanaCopia() }, "Hacer una copia")));
}
const lejos = d.movimientos.filter(primaLejos).length;
if (lejos) {
avisos.push(h("div", { clase: "aviso" }, h("span", {},
pl`${lejos} compra(s) o venta(s) de bitcoin con un precio muy lejos del mercado de ese día: puede ser una errata.`),
h("button", { type: "button", clase: "btn-enlace", onclick: () => ventanaPrimas("lejos") }, "Revisar")));
}
const btc = d.btc && d.btc.ultima;
if (btc && btc.pendientes) {
avisos.push(h("div", { clase: "aviso" }, h("span", {},
pl`Tu nodo ve ${btc.pendientes} operación(es) de bitcoin que no tienes apuntadas.`),
h("button", { type: "button", clase: "btn-enlace", onclick: () => comprobarBitcoin(false) }, "Revisar")));
} else if (btc && (btc.cuadres || []).some(descuadre)) {
const c = btc.cuadres.find(descuadre);
avisos.push(h("div", { clase: "aviso" }, h("span", {},
`${c.nombre_activo || c.activo}: tu nodo ve ${num(c.en_nodo)} BTC y tienes apuntados `
+ `${num(c.apuntado)}.`),
h("button", { type: "button", clase: "btn-enlace", onclick: () => comprobarBitcoin(false) }, "Comprobar")));
}
bloques.push(tarjetaPatrimonio(d));
bloques.push(rejillaCifras([
kpi("Lo que valen tus inversiones", eur0(g.valor), g.coste ? `Te costaron ${eur0(g.coste)}` : null, "", "kpi-principal", AYUDA_KPI.valor),
kpi("Ganancia si vendieras hoy", eurSigno0(g.ganancia_latente), h("span", { clase: signo(g.ganancia_latente) }, pct(g.rentabilidad_latente)), signo(g.ganancia_latente), "", AYUDA_KPI.latente),
kpi("Rentabilidad al año", pct(g.tir), "Como el interés de un depósito", signo(g.tir), "", AYUDA_KPI.tir),
kpi("En lo que va de año", pct(g.ytd), `Este mes: ${pct(g.mes)}`, signo(g.ytd), "kpi-extra", AYUDA_KPI.ytd),
// Sin inversiones cerradas, la tarjeta a cero solo estorba
r.cerrados.length ? kpi("Ganado en inversiones cerradas", eurSigno0(g.ganancia_realizada), `${r.cerrados.length} cerrada${r.cerrados.length === 1 ? "" : "s"}`, signo(g.ganancia_realizada), "kpi-extra", AYUDA_KPI.cerradas) : null,
r.liquidez.length ? kpi("Dinero en cuentas", eur0(g.liquidez), `Patrimonio total: ${eur0(g.patrimonio)}`, "", "kpi-extra", AYUDA_KPI.liquidez) : null,
r.deudas.length ? kpi("Deuda pendiente", eur0(g.deuda),
g.intereses_deuda_año ? `Intereses este año: ${eur0(g.intereses_deuda_año)}` : null, "neg", "kpi-extra", AYUDA_KPI.deuda) : null,
g.dividendos ? kpi("Dividendos e intereses este año", eur0(g.dividendos_año), `Desde el principio: ${eur0(g.dividendos)}`, "pos", "kpi-extra", AYUDA_KPI.dividendos) : null,
]));
if (avisos.length) {
// Los primeros a la vista; el resto, plegados, para que el resumen siga siendo un resumen
const VISIBLES = 3;
const resto = h("div", { clase: "avisos oculto" }, avisos.slice(VISIBLES));
const verMas = avisos.length > VISIBLES ? h("button", { type: "button", clase: "btn-enlace", "aria-expanded": "false", onclick: (e) => {
const plegado = resto.classList.toggle("oculto");
e.currentTarget.setAttribute("aria-expanded", String(!plegado));
e.currentTarget.textContent = plegado ? `Ver ${avisos.length - VISIBLES} más` : "Ver menos";
} }, `Ver ${avisos.length - VISIBLES} más`) : null;
bloques.push(h("div", { clase: "tarjeta seccion" },
h("h2", {}, "Para revisar", h("span", { clase: "suave" }, avisos.length === 1 ? "1 cosa pendiente" : `${avisos.length} cosas pendientes`)),
h("div", { clase: "avisos" }, avisos.slice(0, VISIBLES)), resto, verMas));
}
bloques.push(h("div", { clase: "rejilla rejilla-2 seccion" },
h("div", { clase: "tarjeta" }, h("h2", {}, "Evolución", h("span", { clase: "suave no-movil" }, "valor frente a dinero aportado"), selectorPeriodo()),
graficoEvolucion(recortarPeriodo(r.evolucion))),
h("div", { clase: "tarjeta" }, h("h2", {}, "Reparto", g.liquidez > 0 ? h("span", { clase: "suave" }, "con lo que tienes en cuentas") : null),
graficoReparto(g.liquidez > 0 ? { ...r.por_tipo, Cuenta: g.liquidez } : r.por_tipo))));
if (r.actividades && r.actividades.length) bloques.push(tarjetaActividades(r));
if (r.inmuebles || (r.bienes && r.bienes.length)) bloques.push(tarjetaInmuebles(r));
if (r.liquidez.length) bloques.push(tarjetaCuentas(r));
if (r.deudas.length) bloques.push(tarjetaDeudas(r));
const viene = loQueViene(d);
if (viene) bloques.push(viene);
return h("div", {}, bloques);
}
// En el móvil, las inversiones van en tarjetas: el nombre entero a la izquierda y
// lo que vale y lo que ha ganado a la derecha (en una tabla estrecha se partían)
function listaMovil(filas, { dividendos = false } = {}) {
return h("div", { clase: "lista-movil" }, filas.map((f) => h("button", { type: "button", clase: "fila-movil", onclick: () => detalleActivo(f.id) },
celdaNombre(f),
h("div", { clase: "fila-movil-cifras" }, h("b", { clase: "num" }, eur(f.valor)),
h("span", { clase: "num " + signo(f.ganancia) }, `${eurSigno(f.ganancia)} · ${pct(f.rentabilidad)}`),
dividendos && f.dividendos ? h("span", { clase: "muy-suave" }, `+ ${eur(f.dividendos)} cobrados`) : null))));
}
// Una lista para el móvil: a la izquierda qué es, a la derecha su cifra (las tablas no caben)
function listaFilas(filas) {
return h("div", { clase: "lista-movil" }, filas.map((x) => h("button", { type: "button", clase: "fila-movil", onclick: x.accion },
h("div", { clase: "nombre-activo" }, h("span", { clase: "punto", estilo: { background: colorTipo(x.tipo) } }),
h("div", {}, x.nombre, x.sub ? h("div", { clase: "etiqueta-tipo" }, x.sub) : null)),
h("div", { clase: "fila-movil-cifras" }, h("b", { clase: "num" }, x.cifra), x.detalle ? h("span", { clase: "num " + (x.claseDetalle || "muy-suave") }, x.detalle) : null))));
}
function celdaNombre(f) {
return h("div", { clase: "nombre-activo" }, h("span", { clase: "punto", estilo: { background: colorTipo(f.tipo) } }),
h("div", {}, f.nombre, h("div", { clase: "etiqueta-tipo" }, TIPOS_ACTIVO[f.tipo] || f.tipo)));
}
function barraPeso(peso, tipo) {
return h("div", { estilo: { display: "flex", alignItems: "center", gap: "8px" } },
h("div", { clase: "barra-peso", estilo: { flex: "1" } }, h("div", { estilo: { width: `${Math.max(0, Math.min(100, (peso || 0) * 100))}%`, background: colorTipo(tipo) } })),
h("span", { clase: "num muy-suave" }, new Intl.NumberFormat("es-ES", { style: "percent", maximumFractionDigits: 1 }).format(peso || 0)));
}
/* ── Gráficos ───────────────────────────────────────────────────────────── */
let tooltipEl = null;
function mostrarTooltip(e, contenido) {
if (!tooltipEl) { tooltipEl = h("div", { clase: "tooltip" }); document.body.append(tooltipEl); }
tooltipEl.replaceChildren(...contenido);
tooltipEl.classList.remove("oculto");
const x = Math.min(e.clientX + 12, window.innerWidth - tooltipEl.offsetWidth - 8);
tooltipEl.style.left = `${x}px`;
tooltipEl.style.top = `${e.clientY - tooltipEl.offsetHeight - 10}px`;
}
const ocultarTooltip = () => tooltipEl && tooltipEl.classList.add("oculto");
// «desdeCero»: el eje empieza en cero (la cartera entera). En la ficha de una inversión
// no, para que se vea cómo se mueve aunque cambie poco (una Letra va de 9.760 a 10.000)
function graficoEvolucion(puntos, { desdeCero = true } = {}) {
if (!puntos || puntos.length < 2) return h("p", { clase: "vacio" }, "Aún no hay historia suficiente.");
const W = 640, H = 240, m = { i: 56, d: 10, a: 10, b: 24 };
// El eje, en cifras redondas (5.000, 10.000…): el paso es 1, 2, 2,5 o 5 por una potencia de diez
const alto = Math.max(...puntos.map((p) => Math.max(p.valor, p.aportado)), 1);
const menor = Math.min(...puntos.map((p) => Math.min(p.valor, p.aportado)));
const bajo = desdeCero || menor <= 0 ? Math.min(0, menor) : menor;
const bruto = (alto - bajo) / 4 || Math.abs(alto) / 100 || 1, potencia = 10 ** Math.floor(Math.log10(bruto));
const paso = [1, 2, 2.5, 5, 10].map((k) => k * potencia).find((k) => k >= bruto);
const minV = Math.floor(bajo / paso) * paso, maxV = Math.ceil(alto / paso) * paso;
// Sitio a la izquierda para la etiqueta más larga (en el móvil la letra del gráfico es mayor)
const largo = Math.max(...[minV, maxV].map((v) => (fmtEur0.format(v).replace(/\s?€/, "") + " €").length));
m.i = Math.max(56, Math.round(largo * (MOVIL.matches ? 11.5 : 7.4)) + 10);
const x = (i) => m.i + (i / (puntos.length - 1)) * (W - m.i - m.d);
const y = (v) => m.a + (1 - (v - minV) / (maxV - minV)) * (H - m.a - m.b);
const linea = (campo) => puntos.map((p, i) => `${i ? "L" : "M"}${x(i).toFixed(1)},${y(p[campo]).toFixed(1)}`).join("");
const svg = s("svg", { viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": "Evolución del valor de la cartera" });
for (let v = minV; v <= maxV + paso / 2; v += paso) {
svg.append(s("line", { x1: m.i, x2: W - m.d, y1: y(v), y2: y(v), stroke: "var(--borde)", "stroke-width": 1 }));
svg.append(s("text", { x: m.i - 6, y: y(v) + 4, "text-anchor": "end" }, fmtEur0.format(v).replace(/\s?€/, "") + " €"));
}
const cada = Math.max(1, Math.ceil(puntos.length / 6));
puntos.forEach((p, i) => {
if (i % cada === 0 || i === puntos.length - 1) {
if (i !== puntos.length - 1 && puntos.length - 1 - i < cada * 0.75) return;
svg.append(s("text", { x: x(i), y: H - 6, "text-anchor": "middle" }, new Date(p.fecha).toLocaleDateString("es-ES", { month: "short", year: "2-digit" })));
}
});
const area = `${linea("valor")}L${x(puntos.length - 1)},${y(minV)}L${x(0)},${y(minV)}Z`;
svg.append(s("path", { d: area, fill: "var(--serie-1)", opacity: 0.08 }));
svg.append(s("path", { d: linea("aportado"), fill: "none", stroke: "var(--serie-2)", "stroke-width": 2, "stroke-dasharray": "5 4" }));
svg.append(s("path", { d: linea("valor"), fill: "none", stroke: "var(--serie-1)", "stroke-width": 2.5, "stroke-linejoin": "round" }));
const guia = s("line", { y1: m.a, y2: H - m.b, stroke: "var(--texto-3)", "stroke-width": 1, visibility: "hidden" });
const marca = s("circle", { r: 4, fill: "var(--serie-1)", visibility: "hidden" });
svg.append(guia, marca);
svg.append(s("rect", {
x: m.i, y: 0, width: W - m.i - m.d, height: H, fill: "transparent",
onpointermove: (e) => {
const caja = svg.getBoundingClientRect();
const px = ((e.clientX - caja.left) / caja.width) * W;
const i = Math.max(0, Math.min(puntos.length - 1, Math.round(((px - m.i) / (W - m.i - m.d)) * (puntos.length - 1))));
const p = puntos[i];
guia.setAttribute("x1", x(i)); guia.setAttribute("x2", x(i)); guia.setAttribute("visibility", "visible");
marca.setAttribute("cx", x(i)); marca.setAttribute("cy", y(p.valor)); marca.setAttribute("visibility", "visible");
const gan = p.valor - p.aportado;
mostrarTooltip(e, [h("b", {}, fecha(p.fecha)), h("br"), `Valor: ${eur(p.valor)}`, h("br"), `Aportado: ${eur(p.aportado)}`, h("br"), `Diferencia: ${eurSigno(gan)}`]);
},
onpointerleave: () => { guia.setAttribute("visibility", "hidden"); marca.setAttribute("visibility", "hidden"); ocultarTooltip(); },
}));
return h("div", { clase: "grafico" }, svg,
h("div", { clase: "leyenda" },
h("span", {}, h("i", { estilo: { background: "var(--serie-1)" } }), "Valor"),
h("span", {}, h("i", { estilo: { background: "var(--serie-2)" } }), "Dinero aportado (neto)")));
}
function graficoReparto(porTipo) {
const partes = Object.entries(porTipo).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
// El total es lo que se dibuja: lo que vale en negativo no tiene porción y no puede restar
const total = partes.reduce((t, [, v]) => t + v, 0);
if (!partes.length || total <= 0) return h("p", { clase: "vacio" }, "Sin posiciones.");
const R = 60, r = 38, c = 75;
const svg = s("svg", { viewBox: "0 0 150 150", role: "img", "aria-label": "Reparto por tipo de activo" });
let angulo = -Math.PI / 2;
const punto = (rad, a) => `${(c + rad * Math.cos(a)).toFixed(2)},${(c + rad * Math.sin(a)).toFixed(2)}`;
for (const [tipo, valor] of partes) {
const frac = valor / total;
const fin = angulo + frac * Math.PI * 2 - (partes.length > 1 ? 0.02 : 0.0001);
const grande = fin - angulo > Math.PI ? 1 : 0;
const d = partes.length === 1
? `M${c},${c - R}A${R},${R} 0 1 1 ${c - 0.01},${c - R}L${c - 0.01},${c - r}A${r},${r} 0 1 0 ${c},${c - r}Z`
: `M${punto(R, angulo)}A${R},${R} 0 ${grande} 1 ${punto(R, fin)}L${punto(r, fin)}A${r},${r} 0 ${grande} 0 ${punto(r, angulo)}Z`;
svg.append(s("path", {
d, fill: colorTipo(tipo),
onmousemove: (e) => mostrarTooltip(e, [h("b", {}, TIPOS_ACTIVO[tipo]), h("br"), `${eur(valor)} · ${pct(frac).replace("+", "")}`]),
onmouseleave: ocultarTooltip,
}));
angulo += frac * Math.PI * 2;
}
return h("div", { clase: "reparto" }, svg, h("ul", {}, partes.map(([tipo, valor]) => h("li", {},
h("span", { clase: "punto", estilo: { background: colorTipo(tipo) } }),
h("span", {}, TIPOS_ACTIVO[tipo]),
h("span", { clase: "num suave" }, new Intl.NumberFormat("es-ES", { style: "percent", maximumFractionDigits: 1 }).format(valor / total))))));
}
/* ── Activos ────────────────────────────────────────────────────────────── */
function tarjetaInmuebles(r) {
const listas = estado.datos.inmuebles || { clases: {}, usos: {} };
const suyos = r.abiertos.filter((f) => f.tipo === "Inmueble").sort((a, b) => b.valor - a.valor);
const t = r.inmuebles;
const origen = (f) => f.fuente_precio === "IPV" || f.fuente_valor === "IPV" ? h("span", { clase: "insignia", title: "Estimado con el índice de precios de vivienda del INE" }, "estimado INE")
: f.fuente_precio === "MAPA" || f.fuente_valor === "MAPA" ? h("span", { clase: "insignia", title: "Estimado con el precio de la tierra del Ministerio de Agricultura" }, "estimado MAPA")
: f.fuente_precio === "VPO" || f.fuente_valor === "VPO" ? h("span", { clase: "insignia", title: "Su precio máximo de venta de este año" }, "precio máximo") : null;
const nombre = (f) => h("td", {}, h("div", { clase: "nombre-activo" }, h("span", { clase: "punto", estilo: { background: colorTipo("Inmueble") } }), f.nombre,
f.protegida ? h("span", { clase: "insignia", title: "Vivienda protegida: el valor no pasa del precio máximo de venta" }, "protegida") : null),
h("div", { clase: "muy-suave" }, [listas.clases[f.clase], f.referencia,
f.hipoteca ? `hipoteca pendiente ${eur(f.hipoteca_pendiente)} · tuyo ${eur(f.neto)}` : null].filter(Boolean).join(" · ")));
return h("div", { clase: "tarjeta seccion" },
h("h2", {}, "Inmuebles", t ? h("span", { clase: "suave" },
`alquiler de los últimos 12 meses: ${pct(t.rentabilidad_bruta)} bruta · ${pct(t.rentabilidad_neta)} neta`) : null),
suyos.length && MOVIL.matches ? listaFilas(suyos.map((f) => ({ tipo: "Inmueble", nombre: f.nombre, sub: listas.clases[f.clase], cifra: eur0(f.valor),
detalle: f.alquiler_12m ? `alquiler ${eur0(f.alquiler_12m)} · ${pct(f.rentabilidad_neta)} neta` : `costó ${eur0(f.compra)}`,
claseDetalle: f.alquiler_12m ? signo(f.rentabilidad_neta) : "", accion: () => detalleActivo(f.id) }))) :
suyos.length ? h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, ["Inmueble", "Valor", "Te costó", "Alquiler 12 meses", "Gastos 12 meses", "Rentab. neta"].map((x, i) => h("th", { clase: i ? "d" : "" }, x)))),
h("tbody", {}, suyos.map((f) => h("tr", { clase: "clicable", onclick: () => detalleActivo(f.id) }, nombre(f),
h("td", { clase: "d num" }, eur(f.valor), origen(f)), h("td", { clase: "d num" }, eur(f.compra)),
h("td", { clase: "d num " + (f.alquiler_12m ? "pos" : "") }, f.alquiler_12m ? eur(f.alquiler_12m) : "—"),
h("td", { clase: "d num" }, f.gastos_12m ? eur(f.gastos_12m) : "—"),
h("td", { clase: "d num " + signo(f.rentabilidad_neta) }, f.alquiler_12m ? pct(f.rentabilidad_neta) : "—"))),
suyos.length > 1 ? h("tr", {}, h("td", {}, h("b", {}, `Los ${suyos.length}`)), h("td", { clase: "d num" }, h("b", {}, eur(t.valor))),
h("td", { clase: "d num" }, h("b", {}, eur(t.compra))), h("td", { clase: "d num" }, h("b", {}, eur(t.alquiler_12m))),
h("td", { clase: "d num" }, h("b", {}, eur(t.gastos_12m))), h("td", { clase: "d num" }, h("b", {}, pct(t.rentabilidad_neta)))) : null))) : null,
r.bienes && r.bienes.length ? h("h4", {}, "Tu casa y lo que es de uso propio", h("span", { clase: "muy-suave" }, " · cuenta en tu patrimonio, no como inversión")) : null,
r.bienes && r.bienes.length ? h("div", { clase: "tabla-marco" }, h("table", {}, h("tbody", {}, r.bienes.map((f) => h("tr", { clase: "clicable", onclick: () => detalleActivo(f.id) },
nombre(f), h("td", { clase: "d num" }, eur(f.valor), origen(f)),
h("td", { clase: "d num " + signo(f.revalorizacion) }, f.compra ? `${eurSigno(f.revalorizacion)} desde la compra` : "—")))))) : null,
h("p", { clase: "muy-suave" }, "La rentabilidad del alquiler es lo cobrado (bruta) o lo cobrado menos los gastos (neta) en los últimos doce meses, "
+ "sobre lo que te costó con sus gastos y mejoras. La revalorización y la rentabilidad total de cada uno están en su ficha."));
}
function tarjetaCuentas(r) {
const total = r.liquidez.reduce((t, c) => t + c.saldo, 0);
return h("div", { clase: "tarjeta seccion" },
h("h2", {}, "Cuentas (liquidez)", h("span", { clase: "suave" }, "no cuentan en la rentabilidad"),
h("button", { type: "button", clase: "btn", estilo: { marginLeft: "auto" }, onclick: formSaldos }, "Actualizar saldos")),
MOVIL.matches ? listaFilas(r.liquidez.map((c) => ({ tipo: "Cuenta", nombre: c.nombre, sub: c.fecha_saldo ? `saldo del ${fecha(c.fecha_saldo, true)}` : null,
cifra: eur(c.saldo), detalle: c.intereses_año ? `${eurSigno(c.intereses_año)} de intereses este año` : null, claseDetalle: "pos", accion: () => detalleCuenta(c.id) }))) :
h("div", { clase: "tabla-marco" }, h("table", { clase: "tabla-cuentas" },
h("thead", {}, h("tr", {}, h("th", {}, "Cuenta"), h("th", { clase: "d" }, "Saldo"), h("th", {}, "Actualizado"), h("th", { clase: "d" }, "Intereses este año"))),
h("tbody", {}, r.liquidez.map((c) => h("tr", { clase: "clicable", onclick: () => detalleCuenta(c.id) },
h("td", {}, h("div", { clase: "nombre-activo" }, h("span", { clase: "punto", estilo: { background: colorTipo("Cuenta") } }), c.nombre)),
h("td", { clase: "d num" }, eur(c.saldo)),
h("td", { clase: "num suave" }, c.fecha_saldo ? fecha(c.fecha_saldo, true) : "—"),
h("td", { clase: "d num " + (c.intereses_año ? "pos" : "") }, c.intereses_año ? eurSigno(c.intereses_año) : "—"))),
h("tr", {}, h("td", {}, h("b", {}, "Total")), h("td", { clase: "d num" }, h("b", {}, eur(total))), h("td", {}), h("td", {}))))));
}
function detalleCuenta(id) {
const a = activoPorId(id);
const c = estado.datos.resumen.liquidez.find((x) => x.id === id);
let cerrar;
cerrar = modal({
titulo: a.nombre,
cuerpo: h("div", {},
h("div", { clase: "ficha" },
h("div", {}, h("small", {}, "Saldo"), h("b", { clase: "num" }, eur(c.saldo))),
h("div", {}, h("small", {}, "Actualizado"), h("b", { clase: "num" }, c.fecha_saldo ? fecha(c.fecha_saldo) : "—")),
h("div", {}, h("small", {}, "Intereses cobrados"), h("b", { clase: "num" }, eur(c.intereses)))),
h("div", { clase: "acciones-ficha" },
h("button", { type: "button", clase: "btn btn-primario", onclick: () => { cerrar(); formPrecio(id); } }, "Poner saldo"),
h("button", { type: "button", clase: "btn", onclick: () => { cerrar(); formOperacion({ activo: id }); } }, "Apuntar intereses"),
h("button", { type: "button", clase: "btn", onclick: () => { cerrar(); formActivo(a); } }, "Editar datos")),
h("h4", { estilo: { margin: "8px 0" } }, "Intereses"),
tablaMovimientos(estado.datos.movimientos.filter((m) => m.activo === id), false, async () => { cerrar(); await cargar(); detalleCuenta(id); })),
});
}
function formSaldos() {
const cuentas = estado.datos.resumen.liquidez;
const error = h("div", { clase: "error-form oculto", role: "alert" });
const inFecha = h("input", { type: "date", value: hoyIso(), max: hoyIso() });
const entradas = cuentas.map((c) => ({ cuenta: c, input: entradaNum({ value: c.saldo ? String(c.saldo).replace(".", ",") : "" }) }));
modal({
titulo: "Saldos de tus cuentas",
cuerpo: h("div", {}, error,
h("p", { clase: "nota-form", estilo: { marginTop: 0 } }, "Escribe el saldo de cada cuenta. Se guardan solo las que cambies."),
h("div", { clase: "campos" }, campo("Fecha", inFecha), h("div"),
entradas.map(({ cuenta, input }) => campo(cuenta.nombre, input)))),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Guardar", clase: "btn-primario", accion: async (cerrar) => {
const cambios = entradas.map(({ cuenta, input }) => ({ cuenta, valor: aEuros(input.value) }))
.filter(({ cuenta, valor }) => valor !== null && !Number.isNaN(valor) && valor !== cuenta.saldo);
if (entradas.some(({ input }) => Number.isNaN(aEuros(input.value)))) {
error.textContent = "Hay un saldo mal escrito."; error.classList.remove("oculto"); return;
}
const ok = await guardarCon(null, error, async () => {
for (const { cuenta, valor } of cambios) await api("/api/precio", { activo: cuenta.id, fecha: inFecha.value, valor });
return true;
});
if (ok) { toast(cambios.length ? pl`${cambios.length} saldo(s) actualizado(s).` : "Sin cambios."); cerrar(); cargar(); }
} },
],
});
}
/* ── Préstamos recibidos (deudas) ───────────────────────────────────────── */
function tarjetaDeudas(r) {
return h("div", { clase: "tarjeta seccion" },
h("h2", {}, "Préstamos", h("span", { clase: "suave" }, "lo que debes: resta del patrimonio, no cuenta en la rentabilidad")),
MOVIL.matches ? listaFilas(r.deudas.map((f) => ({ tipo: "Deuda", nombre: f.nombre, sub: f.proxima ? `próxima cuota el ${fecha(f.proxima, true)}` : null,
cifra: eur0(f.pendiente), detalle: f.cuota ? `cuota ${eur(f.cuota)}` : null, accion: () => detalleDeuda(f.id) }))) :
h("div", { clase: "tabla-marco" }, h("table", { clase: "tabla-deudas" },
h("thead", {}, h("tr", {}, h("th", {}, "Préstamo"), h("th", { clase: "d" }, "Pendiente"), h("th", { clase: "d" }, "Cuota"),
h("th", {}, "Próxima"), h("th", {}, "Termina"), h("th", { clase: "d" }, "Intereses este año"))),
h("tbody", {}, r.deudas.map((f) => h("tr", { clase: "clicable", onclick: () => detalleDeuda(f.id) },
h("td", {}, h("div", { clase: "nombre-activo" }, h("span", { clase: "punto", estilo: { background: colorTipo("Deuda") } }), f.nombre)),
h("td", { clase: "d num" }, eur(f.pendiente)),
h("td", { clase: "d num" }, f.cuota ? eur(f.cuota) : "—"),
h("td", { clase: "num" }, f.proxima ? fecha(f.proxima, true) : "—"),
h("td", { clase: "num" }, f.fin ? fecha(f.fin, true) : "—"),
h("td", { clase: "d num" }, eur(f.intereses_año))))))));
}
async function detalleDeuda(id) {
const a = activoPorId(id);
let e;
try { ({ resultado: e } = await api("/api/deuda/cuadro", { id })); } catch (err) { toast(err.message, true); return; }
const f = estado.datos.resumen.deudas.find((x) => x.id === id) || {};
const refrescar = async () => { cerrar(); await cargar(); detalleDeuda(id); };
const filasCuadro = (filas) => filas.map((x) => h("tr", {},
h("td", { clase: "num" }, fecha(x.fecha, true)), h("td", { clase: "d num" }, eur(x.cuota)),
h("td", { clase: "d num" }, eur(x.intereses)), h("td", { clase: "d num" }, eur(x.capital)), h("td", { clase: "d num" }, eur(x.pendiente))));
const cuerpoCuadro = h("tbody", {}, filasCuadro(e.futuras.slice(0, 12)));
const verTodo = e.futuras.length > 12 ? h("button", { type: "button", clase: "btn-enlace", onclick: () => {
cuerpoCuadro.replaceChildren(...filasCuadro(e.futuras)); verTodo.remove();
} }, `Ver las ${e.futuras.length} cuotas que quedan`) : null;
const ajustes = e.eventos.filter((x) => x.tipo === "saldo");
let cerrar;
cerrar = modal({
titulo: a.nombre, ancho: true,
cuerpo: h("div", {},
e.cancelado ? h("div", { clase: "aviso" }, h("span", {}, "Está pagado."),
a.estado !== "cerrada" ? h("button", { type: "button", clase: "btn-enlace", onclick: () => { cerrar(); formCerrar(id); } }, "Darlo por terminado") : null) : null,
h("div", { clase: "ficha" },
h("div", {}, h("small", {}, "Pendiente"), h("b", { clase: "num" }, eur(e.pendiente))),
h("div", {}, h("small", {}, "Cuota"), h("b", { clase: "num" }, e.cuota ? eur(e.cuota) : "—")),
h("div", {}, h("small", {}, "Próxima cuota"), h("b", { clase: "num" }, e.proxima ? fecha(e.proxima) : "—")),
h("div", {}, h("small", {}, "Cuotas que quedan"), h("b", { clase: "num" }, e.cuotas_restantes)),
h("div", {}, h("small", {}, "Termina"), h("b", { clase: "num" }, e.fin ? fecha(e.fin) : "—")),
h("div", {}, h("small", {}, "TIN"), h("b", { clase: "num" }, `${num(a.deuda.tin, 3)} %`)),
h("div", {}, h("small", {}, "Intereses este año"), h("b", { clase: "num" }, eur(e.intereses_año))),
h("div", {}, h("small", {}, "Intereses por pagar"), h("b", { clase: "num" }, eur(e.intereses_pendientes)))),
f.fecha_saldo && Math.abs(f.diferencia_saldo || 0) >= 0.01 ? h("p", { clase: "nota-form" },
`El ${fecha(f.fecha_saldo)} el banco decía ${eurSigno(f.diferencia_saldo)} respecto al cálculo; desde ahí se sigue con su cifra.`) : null,
h("div", { clase: "acciones-ficha" },
e.cancelado ? null : h("button", { type: "button", clase: "btn btn-primario", onclick: () => { cerrar(); formAmortizar({ activo: id }); } }, "Amortizar"),
h("button", { type: "button", clase: "btn", onclick: () => { cerrar(); formPrecio(id); } }, "Pendiente según el banco"),
h("button", { type: "button", clase: "btn", onclick: () => { cerrar(); formActivo(a); } }, "Editar datos")),
h("h4", { estilo: { margin: "8px 0" } }, "Amortizaciones anticipadas"),
tablaMovimientos(estado.datos.movimientos.filter((m) => m.activo === id), false, refrescar),
ajustes.length ? h("p", { clase: "muy-suave" }, `Ajustes con la cifra del banco: ${ajustes.map((x) => `${fecha(x.fecha, true)} (${eurSigno(x.diferencia)})`).join(", ")}.`) : null,
h("h4", { estilo: { margin: "16px 0 8px" } }, "Próximas cuotas"),
e.futuras.length ? h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, h("th", {}, "Fecha"), h("th", { clase: "d" }, "Cuota"), h("th", { clase: "d" }, "Intereses"),
h("th", { clase: "d" }, "Capital"), h("th", { clase: "d" }, "Queda"))), cuerpoCuadro)) : h("p", { clase: "vacio" }, "No quedan cuotas."),
verTodo,
h("p", { clase: "muy-suave" }, "Calculado con el sistema francés (intereses mensuales = pendiente × TIN / 12). Si el banco redondea distinto, pon de vez en cuando lo pendiente según el banco y se ajusta.")),
});
}
function formAmortizar({ activo = null, movimiento = null, borrador = null, alGuardar = cargar }) {
const m = movimiento;
const v = movimiento || borrador; // valores iniciales (editar o propuesta del asistente)
const id = v ? v.activo : activo;
const a = activoPorId(id);
const f = estado.datos.resumen.deudas.find((x) => x.id === id) || {};
const error = h("div", { clase: "error-form oculto", role: "alert" });
const inFecha = h("input", { type: "date", value: v ? v.fecha : hoyIso(), max: hoyIso() });
const inImporte = entradaNum({ value: v && v.importe ? String(v.importe).replace(".", ",") : "" });
const inNotas = entrada({ value: v ? v.notas || "" : "", maxlength: 200 });
let modalidad = v && v.modalidad ? v.modalidad : "plazo";
const opciones = h("div", { clase: "segmentado", role: "group", "aria-label": "Qué reduces" });
const simulacion = h("div", { clase: "nota-form completo" }, "Escribe el importe para ver qué ahorras con cada opción.");
const pintarOpciones = () => opciones.replaceChildren(...[["plazo", "Reducir plazo"], ["cuota", "Reducir cuota"]].map(([v, t]) =>
h("button", { type: "button", "aria-pressed": String(v === modalidad), onclick: () => { modalidad = v; pintarOpciones(); } }, t)));
pintarOpciones();
let temporizador = null;
const simular = () => {
clearTimeout(temporizador);
const importe = aEuros(inImporte.value);
if (!importe || Number.isNaN(importe) || m) { if (!m) simulacion.textContent = "Escribe el importe para ver qué ahorras con cada opción."; return; }
temporizador = setTimeout(async () => {
try {
const { resultado: s } = await api("/api/deuda/simular", { id, fecha: inFecha.value, importe });
const cuotasMenos = s.actual.cuotas - s.plazo.cuotas;
simulacion.replaceChildren(
h("div", {}, h("b", {}, "Reducir plazo: "), `ahorras ${eur(s.plazo.ahorro_intereses)} en intereses; sigues pagando ${eur(s.plazo.cuota)} y terminas en ${fecha(s.plazo.fin)} (${cuotasMenos} cuota${cuotasMenos === 1 ? "" : "s"} menos).`),
h("div", {}, h("b", {}, "Reducir cuota: "), `ahorras ${eur(s.cuota.ahorro_intereses)}; la cuota baja de ${eur(s.actual.cuota)} a ${eur(s.cuota.cuota)} y terminas igual (${fecha(s.cuota.fin)}).`));
} catch (err) { simulacion.textContent = err.message; }
}, 350);
};
inImporte.addEventListener("input", simular);
inFecha.addEventListener("change", simular);
modal({
titulo: m ? "Editar amortización" : `Amortizar «${a.nombre}»`,
cuerpo: h("div", {}, error,
h("p", { clase: "nota-form", estilo: { marginTop: 0 } }, `Pendiente ahora: ${eur(f.pendiente)}. Sin comisión por amortizar.`),
h("div", { clase: "campos" },
campo("Fecha", inFecha), campo("Importe (€)", inImporte),
campo("¿Qué quieres reducir?", opciones, { completo: true }), simulacion,
campo("Notas", inNotas, { completo: true }))),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Guardar", clase: "btn-primario", accion: async (c) => {
const importe = aEuros(inImporte.value);
const r = await guardarCon(null, error, () => api("/api/movimiento", {
id: m ? m.id : undefined, activo: id, tipo: "Amortizacion", fecha: inFecha.value,
importe: Number.isNaN(importe) ? "x" : importe, modalidad, notas: inNotas.value }));
if (r) { toast("Amortización guardada."); c(); await alGuardar(); }
} },
],
});
}
function vistaActivos() {
const r = estado.datos.resumen;
if (!estado.datos.activos.length) return pantallaVacia("Aquí verás cada inversión",
"Una fila por cada una, con lo que vale, lo que te costó, lo que has ganado y la rentabilidad al año. Pulsando una se abre su ficha, con sus operaciones y su gráfica.",
[["+ Nueva inversión", () => formActivo()], ...(estado.datos.extractos.length ? [["Importar un extracto", () => importarExtracto()]] : [])]);
const filas = [...r.abiertos].sort((a, b) => (b.valor || 0) - (a.valor || 0));
return h("div", { clase: "tarjeta" },
h("h2", {}, "Inversiones abiertas", h("span", { clase: "suave" }, "pulsa una fila para ver el detalle"),
h("button", { type: "button", clase: "btn", estilo: { marginLeft: "auto" }, onclick: () => formActivo() }, "+ Nueva inversión")),
filas.length && MOVIL.matches ? listaMovil(filas, { dividendos: true }) :
filas.length ? h("div", { clase: "tabla-marco" }, h("table", { clase: "tabla-inversiones" },
h("thead", {}, h("tr", {}, ["Inversión", "Cantidad", "Precio", "Valor", "Coste", "Ganancia", "Al año", "Este año", "Peso"].map((t, i) => h("th", { clase: i ? "d" : "",
// El reparto del resumen cuenta además las cuentas: sin este aviso, las dos cifras parecen no cuadrar
title: t === "Peso" ? "Sobre lo que valen tus inversiones abiertas, sin el dinero de las cuentas" : null }, t)))),
h("tbody", {}, filas.map((f) => h("tr", { clase: "clicable", onclick: () => detalleActivo(f.id) },
h("td", {}, celdaNombre(f)),
h("td", { clase: "d num" }, num(f.cantidad), h("div", { clase: "muy-suave" }, f.unidad)),
h("td", { clase: "d num" }, f.precio ? eur(f.precio) : "—", h("div", { clase: "muy-suave" }, f.fecha_precio ? fecha(f.fecha_precio, true) : "sin precio")),
h("td", { clase: "d num" }, eur(f.valor)),
h("td", { clase: "d num" }, eur(f.coste)),
h("td", { clase: "d num " + signo(f.ganancia) }, eurSigno(f.ganancia), h("div", { clase: "muy-suave" }, pct(f.rentabilidad)),
f.dividendos ? h("div", { clase: "muy-suave" }, `+ ${eur(f.dividendos)} en dividendos`) : null),
h("td", { clase: "d num " + signo(f.tir) }, pct(f.tir)),
h("td", { clase: "d num " + signo(f.ytd) }, pct(f.ytd)),
h("td", { clase: "d num" }, new Intl.NumberFormat("es-ES", { style: "percent", maximumFractionDigits: 1 }).format(f.peso || 0))))))) :
h("p", { clase: "vacio" }, "No hay inversiones abiertas. Empieza con «+ Nueva inversión»."));
}
function vistaCerradas() {
const filas = [...estado.datos.resumen.cerrados].sort((a, b) => (b.fecha_cierre || "").localeCompare(a.fecha_cierre || ""));
const total = filas.reduce((t, f) => t + f.ganancia, 0);
return h("div", { clase: "tarjeta" },
h("h2", {}, "Inversiones cerradas", h("span", { clase: "suave" }, "siguen contando en la rentabilidad total de la cartera")),
filas.some((f) => f.traspasado) ? h("p", { clase: "nota-form" }, "Lo que salió por traspaso no cuenta como ganancia aquí: su plusvalía sigue en el fondo o plan de destino, dentro de la ganancia sin realizar.") : null,
filas.length ? h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, ["Inversión", "Desde", "Cierre", "Duración", "Invertido", "Recuperado", "Ganancia", "Al año"].map((t, i) => h("th", { clase: i > 3 ? "d" : "" }, t)))),
h("tbody", {}, filas.map((f) => h("tr", { clase: "clicable", onclick: () => detalleActivo(f.id) },
h("td", {}, celdaNombre(f)),
h("td", { clase: "num" }, fecha(f.fecha_inicio, true)),
h("td", { clase: "num" }, fecha(f.fecha_cierre, true)),
h("td", { clase: "num" }, duracion(f.dias)),
h("td", { clase: "d num" }, eur(f.invertido)),
h("td", { clase: "d num" }, eur(f.recuperado), f.traspasado ? h("div", { clase: "muy-suave" }, `${eur(f.traspasado)} por traspaso`) : null),
h("td", { clase: "d num " + signo(f.ganancia) }, eurSigno(f.ganancia), h("div", { clase: "muy-suave" }, pct(f.rentabilidad))),
h("td", { clase: "d num " + signo(f.tir) }, pct(f.tir)))),
h("tr", {}, h("td", { colspan: 6 }, h("b", {}, "Total")), h("td", { clase: "d num " + signo(total) }, h("b", {}, eurSigno(total))), h("td", {}))))) :
h("p", { clase: "vacio" }, "Todavía no has cerrado ninguna inversión. Desde la ficha de una inversión puedes darla por terminada."));
}
function duracion(dias) {
if (dias === null || dias === undefined) return "—";
if (dias < 60) return `${dias} días`;
const meses = Math.round(dias / 30.44);
return meses < 24 ? `${meses} meses` : `${(dias / 365.25).toLocaleString("es-ES", { maximumFractionDigits: 1 })} años`;
}
// Qué significa cada cifra de la ficha de una inversión, en llano
const AYUDA_FICHA = {
"Te costó con intereses": "La compra con sus gastos y mejoras, más los intereses que pagaste de la hipoteca. Es lo que te ha costado de verdad; para Hacienda los intereses no suman al coste de compra.",
"Precio máximo de venta": "Lo más que se puede cobrar por una vivienda protegida. Es lo que vale para Caudal, porque no se puede vender más cara.",
"Por piezas": "Lo que aporta cada pieza al precio máximo: sus metros, por lo que computan, por su coeficiente y por el módulo del año.",
"Valor": "Lo que vale hoy lo que tienes de esta inversión: la cantidad por su último precio conocido.",
"Coste": "Lo que te costó lo que tienes ahora. Si vendiste una parte, es solo el coste de lo que queda.",
"Ganancia": "Valor menos coste: lo que ganarías (o perderías) si vendieras hoy lo que tienes. Sobre el papel, hasta que vendas.",
"Rentabilidad al año": "El tanto por ciento al año que te está dando, teniendo en cuenta cuándo pusiste y sacaste cada euro. Como el interés de un depósito; sirve para comparar inversiones entre sí.",
"Este año": "Cuánto ha subido o bajado desde el 1 de enero, sin contar como ganancia lo que hayas metido ni como pérdida lo que hayas sacado.",
"Este mes": "Lo mismo que «este año», desde el día 1 de este mes.",
"Ganancia contando lo ya vendido y cobrado": "La ganancia de lo que tienes más lo que ya ganaste en firme con esta inversión: ventas hechas y dividendos o intereses cobrados.",
"Ganancia pendiente de tributar que traías de un traspaso": "Al traspasar de un fondo a otro no se pagan impuestos: la ganancia que llevabas viaja al fondo nuevo y tributará cuando lo vendas. Esta es esa ganancia.",
"Dividendos cobrados": "Lo que esta inversión te ha pagado sin vender nada.",
"Revalorización": "Lo que vale hoy menos lo que te costó (precio y gastos de la compra, y las mejoras). El alquiler va aparte.",
"Te costó": "El precio de la compra con sus gastos, más las mejoras que hayas apuntado.",
"Ganancia con lo cobrado": "La revalorización más el alquiler cobrado, menos los gastos que has apuntado.",
"Alquiler cobrado": "Lo que has cobrado de alquiler desde que lo tienes (los gastos van aparte, en el resumen de inmuebles).",
"Intereses cobrados": "Los intereses o cupones que te ha pagado, sin contar la devolución de lo que pusiste.",
"Recibido gratis": "Lo que has recibido sin poner dinero: bonificaciones, regalos. Entra a coste cero.",
"Invertido": "Todo lo que pusiste en esta inversión mientras estuvo abierta.",
"Recuperado": "Todo lo que sacaste de ella: ventas, reembolsos y lo cobrado.",
"Ganancia realizada": "Recuperado menos invertido: lo que ganaste o perdiste en firme.",
"Rentabilidad": "La ganancia como tanto por ciento de lo que pusiste, sin tener en cuenta el tiempo.",
};
// La ficha: cada cifra con su «?», y una sola explicación a lo ancho debajo (la de la última que se pidió)
function fichaConAyuda(ficha) {
const explicacion = h("div", { clase: "kpi-ayuda oculto", "aria-live": "polite" });
let abierta = null;
const celda = ([etiqueta, valor]) => {
const ayuda = AYUDA_FICHA[etiqueta];
const boton = ayuda ? h("button", { type: "button", clase: "btn-ayuda", "aria-expanded": "false", "aria-label": `Qué significa «${etiqueta}»`,
title: "Qué significa", onclick: (e) => {
if (abierta) abierta.setAttribute("aria-expanded", "false");
abierta = abierta === e.currentTarget ? null : e.currentTarget;
explicacion.classList.toggle("oculto", !abierta);
if (abierta) { abierta.setAttribute("aria-expanded", "true"); explicacion.replaceChildren(h("b", {}, `${etiqueta}. `), ayuda); }
} }, "?") : null;
return h("div", {}, h("small", {}, etiqueta, boton), h("b", { clase: "num" }, valor));
};
return [h("div", { clase: "ficha" }, ficha.map(celda)), explicacion];
}
// «1 dirección», «3 direcciones»
function cuantos(n, uno, varios) { return `${n} ${n === 1 ? uno : varios}`; }
function detalleActivo(id) {
const a = activoPorId(id), f = filaResumen(id);
if (!a || !f) return;
if (a.inmueble && a.inmueble.uso === "propio" && a.estado !== "cerrada") return detalleBien(a, f);
const movs = estado.datos.movimientos.filter((m) => m.activo === id);
const cerrada = a.estado === "cerrada";
let cerrarModal;
const refrescar = async () => { cerrarModal(); await cargar(); detalleActivo(id); };
const ficha = cerrada ? [
["Invertido", eur(f.invertido)], ["Recuperado", eur(f.recuperado)],
...(f.traspasado ? [["Salió por traspaso", eur(f.traspasado)]] : []),
["Ganancia realizada", h("span", { clase: signo(f.ganancia) }, eurSigno(f.ganancia))], ["Rentabilidad", pct(f.rentabilidad)],
["Rentabilidad al año", pct(f.tir)], ["Duración", duracion(f.dias)], ["Cerrada el", fecha(f.fecha_cierre)],
] : [
["Valor", eur(f.valor)], ["Cantidad", `${num(f.cantidad)} ${f.unidad}`],
["Precio", f.precio ? eur(f.precio) : "—"], ["Fecha del precio", f.fecha_precio ? fecha(f.fecha_precio) : "—"],
["Coste", eur(f.coste)], ["Ganancia", h("span", { clase: signo(f.ganancia) }, eurSigno(f.ganancia), h("small", {}, pct(f.rentabilidad)))],
["Rentabilidad al año", pct(f.tir)], ["Este año", pct(f.ytd)], ["Este mes", pct(f.mes)],
];
if (f.recompensas) ficha.push(["Recibido gratis", h("span", { clase: "pos" }, `${num(f.recompensas)} ${f.unidad}`,
f.recompensas_valor ? h("small", {}, `${eur(f.recompensas_valor)} al recibirlo`) : null)]);
const cobrado = a.tipo === "Inmueble" ? "Alquiler cobrado" : ["RentaFija", "Cuenta", "Prestamo"].includes(a.tipo) ? "Intereses cobrados" : "Dividendos cobrados";
if (f.dividendos) ficha.push([cobrado, h("span", { clase: "pos" }, eurSigno(f.dividendos), h("small", {}, `este año ${eur(f.dividendos_año)}`))]);
if (!cerrada && (f.dividendos || f.recuperado)) ficha.push(["Ganancia contando lo ya vendido y cobrado", h("span", { clase: signo(f.ganancia_total) }, eurSigno(f.ganancia_total))]);
if (!cerrada && f.dif_fiscal) ficha.push(["Ganancia pendiente de tributar que traías de un traspaso", eur(f.dif_fiscal)]);
if (a.prestamo) ficha.push(["Cobro previsto", `${eur(a.prestamo.importe_final)} el ${fecha(a.prestamo.vencimiento)}`]);
if (a.tipo === "RentaFija" && a.renta_fija) {
const rfx = a.renta_fija;
ficha.push(["Vence", fecha(rfx.vencimiento)]);
if (rfx.nominal) ficha.push(["Nominal", eur(rfx.nominal)]);
if (rfx.interes) ficha.push(["Interés", `${String(rfx.interes).replace(".", ",")} % · ${({ 0: "al vencimiento", 1: "anual", 2: "semestral", 4: "trimestral", 12: "mensual" })[rfx.pagos || 0]}`]);
if (f.fuente_precio === "devengo") ficha.push(["Cómo se valora", "Solo: de lo pagado al nominal hasta el vencimiento, más los intereses ganados"]);
if (f.fuente_precio === "BME") ficha.push(["Cómo se valora", `A precio de mercado en BME (${fecha(f.fecha_precio)}), más el cupón corrido`]);
}
if (a.tipo === "Inmueble" && !cerrada) {
ficha.push(["Alquiler de los últimos 12 meses", f.alquiler_12m ? eur(f.alquiler_12m) : "—"],
["Rentabilidad del alquiler", f.alquiler_12m ? `${pct(f.rentabilidad_bruta)} bruta · ${pct(f.rentabilidad_neta)} neta` : "—"]);
if (f.fuente_precio === "IPV") ficha.push(["Cómo se valora", "Estimado con el índice de precios de vivienda del INE"]);
if (a.inmueble.clase === "terreno_rustico") {
const tierra = (estado.datos.inmuebles || {}).tierra || { cultivos: {} };
const cs = a.inmueble.cultivos || [];
if (a.inmueble.superficie) ficha.push(["Superficie", `${String(a.inmueble.superficie).replace(".", ",")} ha`]);
if (cs.length) ficha.push(["Cultivo", cs.map((c) => (c.desde ? `desde el ${fecha(c.desde)}: ` : "") + (tierra.cultivos[c.cultivo] || c.cultivo)).join(" · ")]);
if (f.fuente_precio === "MAPA") ficha.push(["Cómo se valora", `Con el precio de la tierra de su cultivo y su comunidad (Ministerio de Agricultura, hasta ${tierra.año})`]);
}
ficha.push(...filasProtegida(f), ...filasIntereses(f));
if (f.hipoteca) ficha.push(["Hipoteca pendiente", `${eur(f.hipoteca_pendiente)} (${f.hipoteca_nombre})`],
["Lo tuyo de verdad", eur(f.neto)], ["Intereses de los últimos 12 meses", `${eur(f.intereses_12m)}, ya en los gastos`]);
}
const acciones = cerrada ? [
h("button", { type: "button", clase: "btn", onclick: async () => {
if (await confirmar("La inversión volverá a aparecer como abierta. La operación de cierre no se borra: si sobra, bórrala en la lista.", "Reabrir")) {
try { await api("/api/activo/reabrir", { id }); toast("Inversión reabierta."); refrescar(); } catch (e) { toast(e.message, true); }
}
} }, "Reabrir"),
] : [
h("button", { type: "button", clase: "btn btn-primario", onclick: () => { cerrarModal(); formOperacion({ activo: id }); } }, "+ Operación"),
a.valoracion === "total" || ["Tierra", "CuentaTrading", "Metal", "Prestamo", "Cuenta"].includes(a.tipo) || !a.codigo
? h("button", { type: "button", clase: "btn", onclick: () => { cerrarModal(); formPrecio(id); } },
a.tipo === "CuentaTrading" ? "Poner saldo actual" : a.valoracion === "total" ? "Poner valor actual" : "Poner precio")
: null,
h("button", { type: "button", clase: "btn", onclick: () => { cerrarModal(); formCerrar(id); } }, "Cerrar inversión"),
];
acciones.push(h("button", { type: "button", clase: "btn", onclick: () => { cerrarModal(); formActivo(a); } }, "Editar datos"),
h("button", { type: "button", clase: "btn btn-peligro", onclick: () => borrarInversion(a, cerrarModal) }, "Borrar"));
cerrarModal = modal({
titulo: a.nombre, ancho: true,
cuerpo: h("div", {},
h("p", { clase: "suave", estilo: { marginTop: 0 } }, `${TIPOS_ACTIVO[a.tipo]}${a.codigo ? " · " + a.codigo : ""}${cerrada ? " · cerrada" : ""}`),
...(cerrada ? fichaConAyuda(ficha) : cuerpoFicha(a, f, ficha)),
h("div", { clase: "acciones-ficha" }, acciones),
a.tipo === "Cripto" ? seccionLugares(a, refrescar) : null,
h("h4", { estilo: { margin: "8px 0" } }, `Operaciones (${movs.length})`),
tablaMovimientos(movs, false, refrescar)),
});
}
// La ficha de una inversión abierta: lo principal en grande, su gráfica, lo que
// pasaría al venderla hoy, las cifras de detalle y, aparte, los datos de texto
const FICHA_GRANDE = ["Valor", "Ganancia", "Revalorización", "Rentabilidad al año"];
const FICHA_TEXTO = new Set(["Cómo se valora", "Cultivo", "Vence", "Interés", "Cobro previsto", "Superficie", "Nominal",
"Alquiler de los últimos 12 meses", "Rentabilidad del alquiler", "Precio máximo de venta", "Hipoteca pendiente",
"Lo tuyo de verdad", "Intereses de los últimos 12 meses"]);
function cuerpoFicha(a, f, ficha) {
// Un inmueble: lo que costó y cuánto se ha revalorizado; el alquiler, aparte (en «Coste» y «Ganancia»
// estaba restado y sumado, y un «coste» menor que el precio de la escritura despista)
if (a.tipo === "Inmueble" && f.compra) {
const reval = f.valor - f.compra;
ficha = ficha.map(([e, v]) => e === "Ganancia" ? ["Revalorización", h("span", { clase: signo(reval) }, eurSigno(reval), h("small", {}, pct(reval / f.compra)))]
: e === "Coste" ? ["Te costó", eur(f.compra)] : e === "Ganancia contando lo ya vendido y cobrado" ? ["Ganancia con lo cobrado", v] : [e, v]);
}
const sinCantidad = a.valoracion === "total" || ["Inmueble", "RentaFija", "Tierra", "CuentaTrading"].includes(a.tipo);
const fuera = new Set(["Fecha del precio", ...(sinCantidad ? ["Cantidad", "Precio"] : [])]);
const grande = FICHA_GRANDE.map((k) => ficha.find(([e]) => e === k)).filter(Boolean);
const detalle = ficha.filter(([e]) => !FICHA_GRANDE.includes(e) && !FICHA_TEXTO.has(e) && !fuera.has(e));
const texto = ficha.filter(([e]) => FICHA_TEXTO.has(e));
const origen = { IPV: "estimado con el índice de vivienda del INE", MAPA: "estimado con el precio de la tierra del MAPA",
VPO: "su precio máximo de venta de este año",
devengo: "calculado hasta el vencimiento", BME: "a precio de mercado en BME", manual: "puesto a mano" }[f.fuente_precio];
const grafica = h("div", { clase: "ficha-grafica" });
const venta = h("div", {});
api("/api/activo/evolucion", { id: a.id }).then((r) => {
const puntos = r.resultado || r;
// Solo si el valor se ha movido: una línea plana con el eje ampliado exagera cualquier cobro
const valores = Array.isArray(puntos) ? new Set(puntos.filter((x) => x.valor > 0).map((x) => Math.round(x.valor))) : new Set();
if (puntos.length >= 3 && valores.size > 1) grafica.replaceChildren(graficoEvolucion(puntos, { desdeCero: false }));
}).catch(() => {});
api("/api/activo/si_vendo", { id: a.id }).then((r) => { const v = r.resultado || r; if (v.posible) venta.replaceChildren(cajaSiVendo(a, v)); }).catch(() => {});
return [
h("div", { clase: "ficha-grande" }, ...fichaConAyuda(grande)),
f.fecha_precio ? h("p", { clase: "muy-suave ficha-origen" }, `Valor del ${fecha(f.fecha_precio)}${origen ? ", " + origen : ""}.`) : null,
grafica, venta,
...(detalle.length ? fichaConAyuda(detalle) : []),
texto.length ? h("dl", { clase: "datos-ficha" }, texto.flatMap(([e, v]) => [h("dt", {}, e), h("dd", {}, v)])) : null,
];
}
function revalorizacionDe(a) { const f = filaResumen(a.id); return f && f.compra ? f.valor - f.compra : Infinity; }
// «Si la vendieras hoy»: la ganancia para Hacienda y lo que cambiaría el impuesto de este año
function cajaSiVendo(a, v) {
let frase;
if (v.ganancia > 0.5) {
frase = [`ganarías ${eur(v.ganancia)} para Hacienda y pagarías unos `, h("b", {}, eur(v.impuesto)),
` más de impuesto en ${v.ejercicio}` + (v.tipo_medio ? ` (al ${pct(v.tipo_medio).replace("+", "")} de media, ${v.territorio}).` : ".")];
} else if (v.ganancia < -0.5) {
frase = [`perderías ${eur(-v.ganancia)} para Hacienda. `, v.impuesto < -0.5
? ["Tu impuesto de ", String(v.ejercicio), " bajaría unos ", h("b", {}, eur(-v.impuesto)), ", porque se compensa con lo que has ganado este año."]
: "Este año no tienes ganancias con las que compensarla: se guarda para restarla en los años siguientes."];
} else {
frase = ["no ganarías ni perderías nada para Hacienda."];
}
return h("div", { clase: "si-vendo" },
h("p", {}, h("b", {}, "Si la vendieras hoy, "), ...frase.flat()),
a.tipo === "Fondo" ? h("p", { clase: "suave" }, "Si en vez de venderlo lo traspasas a otro fondo, no pagas nada ahora: la ganancia viaja con él.") : null,
a.tipo === "Inmueble" && v.ganancia > revalorizacionDe(a) + 1 ? h("p", { clase: "suave" }, "Es más que la revalorización porque Hacienda suma lo que amortizaste mientras estuvo alquilado.") : null,
h("p", { clase: "muy-suave" }, "Contando lo que ya llevas este año y las pérdidas que tengas pendientes. Es una estimación, no asesoría fiscal."));
}
// La vivienda protegida en su ficha: el precio máximo de este año y de dónde sale
function filasProtegida(f) {
if (!f.protegida) return [];
const filas = [["Precio máximo de venta", eur(f.tope)]];
if (f.fuente_precio === "VPO" || f.fuente_valor === "VPO") filas.push(["Cómo se valora", "Por su precio máximo de venta, que se pone al día cada 1 de enero"]);
const d = f.tope_desglose;
if (d) {
const n = (x) => String(Math.round(x * 100) / 100).replace(".", ",");
filas.push([`Módulo de ${d.año}`, `${eur(d.modulo)}/m² útil · ${{ oficial: "oficial", tuyo: "el que has puesto", estimado: "estimado con el IPC" }[d.origen]}`],
["Cómo sale", `${n(d.m2_vivienda)} m² × ${n(d.coef_vivienda)}` + (d.m2_anejo ? ` + ${n(d.m2_anejo)} m² de anejos × ${n(d.coef_anejo)}` : "") + " × el módulo"],
["Por piezas", d.piezas.filter((p) => p.m2).map((p) => `${p.nombre}: ${n(p.m2)} m²` + (p.computa !== 100 ? ` al ${n(p.computa)} %` : "") + ` → ${eur(p.importe)}`).join(" · ")]);
}
return filas;
}
// Lo que costó con los intereses de una hipoteca ya cancelada (aparte del coste para Hacienda)
function filasIntereses(f) {
if (!f.intereses_hipoteca) return [];
return [["Intereses de la hipoteca", eur(f.intereses_hipoteca)], ["Te costó con intereses", eur(f.coste_con_intereses)],
["Revalorización con intereses", h("span", { clase: signo(f.revalorizacion_con_intereses) }, eurSigno(f.revalorizacion_con_intereses))]];
}
// Borrar una inversión sin operaciones (las operaciones se borran antes, a la vista)
async function borrarInversion(a, cerrar) {
const n = estado.datos.movimientos.filter((m) => m.activo === a.id).length;
if (n) { toast(`Tiene ${n} operaci${n === 1 ? "ón" : "ones"}: bórralas antes en su lista de operaciones, y luego bórrala.`, true); return; }
if (!(await confirmar(`Se borrará «${a.nombre}» con los valores que tenga apuntados. No se puede deshacer.`, "Borrar", true))) return;
try { await api("/api/activo/borrar", { id: a.id }); cerrar(); toast(`«${a.nombre}» borrada.`); await cargar(); } catch (e) { toast(e.message, true); }
}
function detalleBien(a, f) {
const movs = estado.datos.movimientos.filter((m) => m.activo === a.id);
let cerrarModal;
const refrescar = async () => { cerrarModal(); await cargar(); detalleActivo(a.id); };
cerrarModal = modal({
titulo: a.nombre, ancho: true,
cuerpo: h("div", {},
h("p", { clase: "suave", estilo: { marginTop: 0 } }, `${(estado.datos.inmuebles.clases || {})[a.inmueble.clase] || "Inmueble"} · uso propio`),
...fichaConAyuda([["Valor", eur(f.valor)], ["Fecha del valor", f.fecha_valor ? fecha(f.fecha_valor) : "—"],
["Te costó", eur(f.compra)], ["Revalorización", h("span", { clase: signo(f.revalorizacion) }, eurSigno(f.revalorizacion))],
...filasIntereses(f), ...filasProtegida(f),
...(f.hipoteca ? [["Hipoteca pendiente", eur(f.hipoteca_pendiente)], ["Lo tuyo de verdad", eur(f.neto)]] : [])]),
h("p", { clase: "nota-form" }, "Cuenta en tu patrimonio, pero no en la rentabilidad de tus inversiones. Si lo alquilas, cámbialo en «Editar datos»."),
h("div", { clase: "acciones-ficha" },
h("button", { type: "button", clase: "btn btn-primario", onclick: () => { cerrarModal(); formOperacion({ activo: a.id }); } }, "+ Operación"),
h("button", { type: "button", clase: "btn", onclick: () => { cerrarModal(); formPrecio(a.id); } }, "Poner valor actual"),
h("button", { type: "button", clase: "btn", onclick: () => { cerrarModal(); formActivo(a); } }, "Editar datos"),
h("button", { type: "button", clase: "btn btn-peligro", onclick: () => borrarInversion(a, cerrarModal) }, "Borrar")),
h("h4", { estilo: { margin: "8px 0" } }, `Operaciones (${movs.length})`),
tablaMovimientos(movs, false, refrescar)),
});
}
/* ── Operaciones ────────────────────────────────────────────────────────── */
function insignias(m) {
return [
m.recurrente ? h("span", { clase: "insignia info-i", title: "Creada automáticamente" }, "auto") : null,
txidDe(m) ? h("span", { clase: "insignia", title: `Transacción ${txidDe(m)}` }, "nodo")
: m.extracto ? h("span", { clase: "insignia", title: "Importada de un extracto" }, "extracto") : null,
m.adjuntos && m.adjuntos.length ? h("span", { clase: "insignia", title: m.adjuntos.map((a) => a.nombre).join("\n") }, `📎 ${m.adjuntos.length}`) : null,
m.pendiente ? h("span", { clase: "insignia aviso-i" }, "pendiente") : null,
m.impagado ? h("span", { clase: "insignia aviso-i", title: "No se ha cobrado: no cuenta como dinero recibido. Hacienda sí lo cuenta (ver Fiscal)." }, "impagado") : null,
String(m.id).startsWith("rf-") ? h("span", { clase: "insignia info-i", title: "Creada automáticamente según la ficha" }, "auto") : null,
m.estimado ? h("span", { clase: "insignia aviso-i", title: "Cifras aproximadas (participaciones calculadas con el valor liquidativo, importes puestos a ojo...); confírmalas cuando tengas los datos buenos" }, "estimada") : null,
];
}
// Con «sel» (ver vistaMovimientos) cada fila lleva su casilla y la cabecera la de «todas»
// La prima de una compra o venta de bitcoin: cuánto se aleja su precio del mercado de ese día
const primaDe = (m) => (estado.datos.primas || {})[m.id] || null;
const primaLejos = (m) => { const p = primaDe(m); return !!p && p.lejos && !m.prima_ok && !m.estimado; };
function textoPrima(m) {
const p = primaDe(m);
if (!p || m.estimado) return null;
return h("div", { clase: "muy-suave", estilo: primaLejos(m) ? { color: "var(--negativo)" } : null,
title: `El ${fecha(p.dia)} el bitcoin cerró a ${eur(p.ref)}` },
`${pct(p.prima)} s/ mercado`);
}
// «porMes» intercala una fila con el mes cada vez que cambia (solo se ve en el móvil).
// Lo que mete dinero en tu bolsillo y lo que lo saca (los traspasos y traslados, ni una cosa ni otra)
const ENTRA_DINERO = new Set(["Venta", "Reembolso", "Dividendo", "Devolucion"]);
const SALE_DINERO = new Set(["Compra", "Gasto", "Comision", "Amortizacion"]);
function importeConSigno(m) {
if (m.importe === null || m.importe === undefined) return m.importe_original ? `${num(m.importe_original)} ${m.moneda}` : "—";
if (ENTRA_DINERO.has(m.tipo) && !m.impagado) return h("span", { clase: "pos" }, `+${eur(m.importe)}`);
if (SALE_DINERO.has(m.tipo)) return `−${eur(m.importe)}`;
return eur(m.importe);
}
function tablaMovimientos(movs, conActivo, alCambiar, sel = null, porMes = false) {
let mesAnterior = null;
const filaMes = (m) => {
const mes = m.fecha.slice(0, 7);
if (!porMes || mes === mesAnterior) return null;
mesAnterior = mes;
return h("tr", { clase: "fila-mes" }, h("td", { colspan: 9 },
new Date(mes + "-01").toLocaleDateString("es-ES", { month: "long", year: "numeric", timeZone: "UTC" })));
};
if (!movs.length) return h("p", { clase: "vacio" }, "Sin operaciones.");
return h("div", { clase: "tabla-marco" }, h("table", { clase: "tabla-operaciones" + (sel ? " con-casilla" : "") + (conActivo ? " con-inversion" : "") },
h("thead", {}, h("tr", {}, sel ? h("th", { clase: "casilla" }, sel.todas) : null,
h("th", {}, "Fecha"), conActivo ? h("th", {}, "Inversión") : null, h("th", {}, "Operación"),
h("th", { clase: "d" }, "Cantidad"), h("th", { clase: "d" }, "Precio"), h("th", { clase: "d" }, "Importe"), h("th", {}))),
h("tbody", {}, movs.flatMap((m) => [filaMes(m), h("tr", { clase: sel && sel.marcadas.has(m.id) ? "marcada" : null },
sel ? h("td", { clase: "casilla" }, sel.casilla(m)) : null,
h("td", { clase: "num" }, fecha(m.fecha, true)),
// En el móvil la columna «Operación» no cabe: el tipo va debajo del nombre
conActivo ? h("td", {}, nombreActivo(m.activo),
h("div", { clase: "solo-movil etiqueta-tipo" }, nombreTipoMov(m), insignias(m))) : null,
h("td", {}, nombreTipoMov(m), m.modalidad ? ` (reduce ${m.modalidad})` : "",
m.tipo === "Traslado" ? h("span", { clase: "suave" }, ` · ${nombreLugar(m.activo, m.lugar)} → ${nombreLugar(m.activo, m.lugar_destino)}`)
: m.lugar ? h("span", { clase: "suave" }, ` · ${nombreLugar(m.activo, m.lugar)}`) : null,
m.traslado ? h("span", { clase: "suave" }, " (del traslado)") : null,
insignias(m), m.notas ? h("div", { clase: "nota-mov" }, m.notas) : null),
h("td", { clase: "d num" }, m.cantidad === null || m.cantidad === undefined ? "—" : num(m.cantidad)),
h("td", { clase: "d num" }, m.precio ? eur(m.precio) : "—", textoPrima(m)),
h("td", { clase: "d num" }, importeConSigno(m)),
h("td", { clase: "d", estilo: { whiteSpace: "nowrap" } },
m.estimado && !m.pendiente ? h("button", { type: "button", clase: "btn-icono", title: "Confirmar (ya coincide con el extracto)", onclick: async () => {
try { await api("/api/movimiento/confirmar", { id: m.id }); toast("Operación confirmada."); alCambiar(); } catch (e) { toast(e.message, true); }
} }, "✓") : null,
h("button", { type: "button", clase: "btn-icono", title: "Editar", "aria-label": "Editar", onclick: () => formOperacion({ movimiento: m, alGuardar: alCambiar }) }, "✎"),
// Sin preguntar antes: se puede deshacer desde el aviso
h("button", { type: "button", clase: "btn-icono", title: "Borrar", "aria-label": "Borrar", onclick: async () => {
try {
const { resultado: r } = await api("/api/movimiento/borrar", { id: m.id });
toast(`Borrada: ${nombreTipoMov(m).toLowerCase()} del ${fecha(m.fecha)}.`, false, deshacerBorrado(r.ids, alCambiar));
alCambiar();
} catch (e) { toast(e.message, true); }
} }, "🗑")))]))));
}
function vistaMovimientos() {
const d = estado.datos, f = estado.filtros;
if (!d.movimientos.length) return pantallaVacia("Aquí verás todo lo que has hecho",
"Cada compra, venta, aportación, cobro o gasto, por fechas, para buscarlo y corregirlo. Lo más rápido para apuntar es contármelo con tus palabras: «vendí la mitad del oro el lunes por 2.400 €».",
[["💬 Cuéntamelo", () => asistente()], ["+ Operación", () => formOperacion({})], ...(d.extractos.length ? [["Importar un extracto", () => importarExtracto()]] : [])]);
const años = [...new Set(d.movimientos.map((m) => m.fecha.slice(0, 4)))].sort().reverse();
const actualizar = () => { estado.masMovs = 0; pintar(); };
const filtroSel = (clave, opciones, texto) => selector([["", texto], ...opciones], f[clave] || "", {
"aria-label": texto, onchange: (e) => { f[clave] = e.target.value; actualizar(); },
});
let movs = d.movimientos;
if (f.activo) movs = movs.filter((m) => m.activo === f.activo);
// El lugar es de cada activo: solo se filtra por él con un activo elegido («-» = sin lugar)
const lugaresFiltro = f.activo ? lugaresDe(f.activo) : [];
if (f.lugar && !(f.lugar === "-" ? lugaresFiltro.length : lugaresFiltro.some((x) => x.id === f.lugar))) f.lugar = "";
if (f.lugar) {
const l = f.lugar === "-" ? "" : f.lugar;
movs = movs.filter((m) => (m.lugar || "") === l || (m.tipo === "Traslado" && (m.lugar_destino || "") === l));
}
const saldoLugar = f.lugar ? ((d.lugares || {})[f.activo] || {})[f.lugar === "-" ? "" : f.lugar] || 0 : null;
if (f.tipo) movs = movs.filter((m) => m.tipo === f.tipo);
if (f.año) movs = movs.filter((m) => m.fecha.startsWith(f.año));
if (f.revisar) movs = movs.filter((m) => m.pendiente || m.estimado);
if (f.importadas) movs = movs.filter((m) => m.extracto);
// El buscador: en el nombre, el tipo, las notas y el importe («875» o «875,00»)
const q = (f.buscar || "").trim().toLowerCase();
if (q) movs = movs.filter((m) => [nombreActivo(m.activo), nombreTipoMov(m), m.notas || "", m.importe != null ? eur(m.importe) : ""]
.join(" ").toLowerCase().includes(q));
// En el móvil la lista se carga por tramos; en pantalla grande, de una vez
const TRAMO = 40;
const visibles = movs.slice(0, MOVIL.matches ? TRAMO + (estado.masMovs || 0) : 400);
const sel = seleccionMovimientos(movs);
const activos = ["activo", "lugar", "tipo", "año", "revisar", "importadas", "buscar"].filter((k) => f[k]).length;
const suma = (tipos) => movs.filter((m) => tipos.has(m.tipo) && !m.impagado).reduce((t, m) => t + (m.importe || 0), 0);
const entra = suma(ENTRA_DINERO), sale = suma(SALE_DINERO);
const buscador = entrada({ type: "search", value: f.buscar || "", placeholder: "Buscar…", "aria-label": "Buscar operaciones", clase: "buscar-movs" });
let espera = null;
buscador.addEventListener("input", () => { clearTimeout(espera); espera = setTimeout(() => { f.buscar = buscador.value; estado.enfocarBuscar = true; actualizar(); }, 250); });
if (estado.enfocarBuscar) setTimeout(() => { estado.enfocarBuscar = false; buscador.focus(); buscador.setSelectionRange(buscador.value.length, buscador.value.length); }, 0);
const filtros = [
buscador,
filtroSel("activo", d.activos.map((a) => [a.id, a.nombre]), "Todas las inversiones"),
lugaresFiltro.length ? filtroSel("lugar", [...lugaresFiltro.map((x) => [x.id, x.nombre]), ["-", "Sin lugar"]], "Todos los lugares") : null,
filtroSel("tipo", Object.entries(TIPOS_MOV), "Todos los tipos"),
filtroSel("año", años.map((y) => [y, y]), "Todos los años"),
h("label", { clase: "suave", estilo: { display: "flex", alignItems: "center", gap: "4px" } },
h("input", { type: "checkbox", checked: !!f.revisar, onchange: (e) => { f.revisar = e.target.checked; actualizar(); } }), "Solo por revisar"),
h("label", { clase: "suave", estilo: { display: "flex", alignItems: "center", gap: "4px" } },
h("input", { type: "checkbox", checked: !!f.importadas, onchange: (e) => { f.importadas = e.target.checked; actualizar(); } }), "Solo importadas de extractos")];
const faltan = movs.length - visibles.length;
return h("div", { clase: "tarjeta" },
h("h2", {}, "Operaciones", h("span", { clase: "suave" }, `${movs.length} de ${d.movimientos.length}`
+ (activos ? [entra ? ` · entra ${eur0(entra)}` : "", sale ? ` · sale ${eur0(sale)}` : ""].join("") : "")
+ (f.lugar ? ` · saldo ${f.lugar === "-" ? "sin lugar" : "en " + nombreLugar(f.activo, f.lugar)}: ${num(saldoLugar)} ${(activoPorId(f.activo) || {}).unidad || ""}` : "")),
// En el móvil ya está el de la cabecera, siempre a la vista
h("button", { type: "button", clase: "btn btn-primario no-movil", estilo: { marginLeft: "auto" }, onclick: () => formOperacion(segunFiltros()) }, "+ Operación")),
// En el móvil los filtros esperan plegados, salvo que haya alguno puesto
MOVIL.matches
? h("details", { clase: "filtros-plegados", open: activos > 0 },
h("summary", { clase: "btn" }, activos ? `Filtrar · ${activos} puesto${activos === 1 ? "" : "s"}` : "Filtrar"),
h("div", { clase: "filtros" }, filtros))
: h("div", { clase: "filtros" }, filtros),
sel.barra,
tablaMovimientos(visibles, true, cargar, sel, true),
faltan <= 0 ? null : MOVIL.matches
? h("button", { type: "button", clase: "btn ver-mas-movs", onclick: () => { estado.masMovs = (estado.masMovs || 0) + TRAMO; pintar(); } },
faltan <= TRAMO ? `Ver las ${faltan} que quedan` : `Ver ${TRAMO} más (quedan ${faltan})`)
: h("p", { clase: "muy-suave" }, `Mostrando las ${visibles.length} más recientes; usa los filtros para ver el resto.`));
}
// Una operación nueva desde la lista filtrada nace en ese activo y ese lugar
function segunFiltros() {
const f = estado.filtros;
if (estado.vista !== "movimientos" || !f.activo) return {};
return { activo: f.activo, lugar: f.lugar && f.lugar !== "-" ? f.lugar : null };
}
// ── Selección de operaciones y acciones sobre varias a la vez ──
// «Todas» son todas las que cumplen los filtros, aunque no quepan en pantalla.
// Lo que deja de verse al cambiar un filtro sale de la selección: nunca se
// actúa sobre operaciones que no están a la vista.
function seleccionMovimientos(movs) {
const marcadas = estado.seleccion;
const ids = new Set(movs.map((m) => m.id));
for (const id of [...marcadas]) if (!ids.has(id)) marcadas.delete(id);
const todas = h("input", { type: "checkbox", "aria-label": "Marcar todas las operaciones filtradas" });
const casillas = new Map();
const cuenta = h("strong", {});
const barra = h("div", { clase: "barra-seleccion oculto", role: "region", "aria-label": "Operaciones seleccionadas" });
const refrescar = () => {
todas.checked = marcadas.size > 0 && marcadas.size === ids.size;
todas.indeterminate = marcadas.size > 0 && marcadas.size < ids.size;
for (const [id, c] of casillas) { c.checked = marcadas.has(id); c.closest("tr")?.classList.toggle("marcada", c.checked); }
cuenta.textContent = marcadas.size === 1 ? "1 seleccionada" : `${marcadas.size} seleccionadas`;
barra.classList.toggle("oculto", marcadas.size === 0);
};
todas.addEventListener("change", () => {
if (todas.checked) ids.forEach((id) => marcadas.add(id)); else marcadas.clear();
refrescar();
});
const accion = (texto, fn, clase = "") => h("button", { type: "button", clase: `btn ${clase}`, onclick: fn }, texto);
const terminar = async (mensaje) => { marcadas.clear(); toast(mensaje); await cargar(); };
const lista = () => [...marcadas];
barra.append(cuenta,
accion("Asignar a actividad", () => accionActividad(lista(), terminar)),
accion("Confirmar", async () => {
try {
const { resultado: r } = await api("/api/movimientos/confirmar", { ids: lista() });
await terminar(pl`${r.confirmadas} confirmada(s).` + (r.pendientes ? pl` ${r.pendientes} pendiente(s) necesitan antes sus cifras.` : ""));
} catch (e) { toast(e.message, true); }
}),
accion("Cambiar de activo", () => accionMover(lista(), terminar)),
accion("Borrar", async () => {
const n = marcadas.size;
if (!(await confirmar(`¿Borrar ${n === 1 ? "la operación seleccionada" : `las ${n} operaciones seleccionadas`}? `
+ "Se borran todas o ninguna.", "Borrar", true))) return;
try {
const { resultado: r } = await api("/api/movimientos/borrar", { ids: lista() });
marcadas.clear();
toast(pl`${r.borradas} operación(es) borrada(s).`, false, deshacerBorrado(r.ids, cargar));
await cargar();
} catch (e) { toast(e.message, true); }
}, "btn-peligro"),
h("button", { type: "button", clase: "btn-enlace", onclick: () => { marcadas.clear(); refrescar(); } }, "Quitar selección"));
const casilla = (m) => {
const c = h("input", { type: "checkbox", checked: marcadas.has(m.id), "aria-label": `Seleccionar la operación del ${fecha(m.fecha)}` });
c.addEventListener("change", () => { if (c.checked) marcadas.add(m.id); else marcadas.delete(m.id); refrescar(); });
casillas.set(m.id, c);
return c;
};
refrescar(); // la barra y «todas» nacen ya con la selección que sobrevive a repintar
return { todas, casilla, barra, marcadas };
}
function accionActividad(ids, terminar) {
const act = campoActividad("");
const error = h("div", { clase: "error-form oculto", role: "alert" });
modal({
titulo: pl`Actividad de ${ids.length} operación(es)`,
cuerpo: h("div", {}, error, h("div", { clase: "campos" },
campo("Actividad", act.control, { completo: true, ayuda: "Déjalo vacío para quitársela. No cambia ninguna cifra de la cartera." }))),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Aplicar", clase: "btn-primario", accion: async (c) => {
const r = await guardarCon(null, error, () => api("/api/actividad/etiquetar", { ids, actividad: act.input.value.trim() }));
if (r) { c(); await terminar(r.resultado.actividad ? `Asignadas a «${r.resultado.actividad}».` : "Actividad quitada."); }
} },
],
});
}
function accionMover(ids, terminar) {
const destinos = estado.datos.activos.filter((a) => a.tipo !== "Deuda");
const sel = selector(destinos.map((a) => [a.id, `${a.nombre} · ${TIPOS_ACTIVO[a.tipo] || a.tipo}`]), destinos[0]?.id, {});
const error = h("div", { clase: "error-form oculto", role: "alert" });
modal({
titulo: pl`Cambiar de activo ${ids.length} operación(es)`,
cuerpo: h("div", {}, error,
h("p", { clase: "nota-form", estilo: { marginTop: 0 } },
"Se comprueban igual que si las editaras una a una (por ejemplo, que haya cantidad para las ventas). Si alguna no puede moverse, no se mueve ninguna."),
h("div", { clase: "campos" }, campo("Activo de destino", sel, { completo: true }))),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Mover", clase: "btn-primario", accion: async (c) => {
const r = await guardarCon(null, error, () => api("/api/movimientos/mover", { ids, activo: sel.value }));
if (r) { c(); await terminar(pl`${r.resultado.movidas} operación(es) movida(s) a ${nombreActivo(sel.value)}.`); }
} },
],
});
}
// ── Justificantes y transacción de bitcoin de una operación ──
const txidDe = (m) => (m && m.txid ? m.txid
: m && typeof m.extracto === "string" && m.extracto.startsWith("btc:") ? m.extracto.slice(4) : null);
const tamaño = (b) => (b < 1024 ? `${b} B` : b < 1048576 ? `${Math.round(b / 1024)} KB` : `${(b / 1048576).toFixed(1)} MB`);
const MAX_ADJUNTO = 5 * 1024 * 1024;
const EXTENSIONES_ADJUNTO = ".pdf,.png,.jpg,.jpeg,.webp,.heic,.txt,.csv,.xlsx,.xls,.ods,.eml";
function enBase64(fichero) {
return new Promise((resolver, rechazar) => {
const lector = new FileReader();
lector.onload = () => resolver(String(lector.result).split(",")[1] || "");
lector.onerror = () => rechazar(new Error(`No se pudo leer ${fichero.name}.`));
lector.readAsDataURL(fichero);
});
}
async function subirAdjuntos(mid, ficheros) {
let ultima = null;
for (const f of ficheros) {
if (f.size > MAX_ADJUNTO) { toast(`${f.name}: pasa de 5 MB.`, true); continue; }
try {
ultima = (await api("/api/movimiento/adjuntar", { id: mid, nombre: f.name, contenido: await enBase64(f) })).resultado;
} catch (e) { toast(`${f.name}: ${e.message}`, true); }
}
return ultima;
}
async function descargarAdjunto(mid, a) {
try {
const blob = await api(`/api/adjunto?m=${encodeURIComponent(mid)}&a=${encodeURIComponent(a.id)}`);
guardarFichero(blob, a.nombre);
} catch (e) { toast(e.message, true); }
}
// Devuelve {elemento, pendientes}: en una operación nueva los ficheros elegidos
// se guardan al guardarla (hasta entonces no tiene id al que colgarlos).
function seccionJustificantes(m, alCambiar) {
const caja = h("div", { clase: "completo justificantes" });
const pendientes = [];
const faltan = new Set(estado.datos.adjuntos_faltan || []);
const entradaF = h("input", { type: "file", multiple: true, accept: EXTENSIONES_ADJUNTO, clase: "oculto" });
const pintarCaja = () => {
const txid = txidDe(m);
const filas = [
...((m && m.adjuntos) || []).map((a) => h("li", {},
h("span", { clase: "nombre-adjunto" }, "📎 ", a.nombre), h("span", { clase: "muy-suave" }, ` ${tamaño(a.bytes)}`),
faltan.has(a.id) ? h("span", { clase: "insignia aviso-i", title: "El fichero no está en este equipo: sincroniza para traerlo" }, "falta aquí")
: h("button", { type: "button", clase: "btn-enlace", onclick: () => descargarAdjunto(m.id, a) }, "Descargar"),
h("button", { type: "button", clase: "btn-enlace btn-peligro", onclick: async () => {
if (!(await confirmar(`¿Quitar «${a.nombre}» de esta operación?`, "Quitar", true))) return;
try { m = (await api("/api/movimiento/adjunto/quitar", { id: m.id, adjunto: a.id })).resultado; alCambiar(); pintarCaja(); } catch (e) { toast(e.message, true); }
} }, "Quitar"))),
...pendientes.map((f, i) => h("li", {}, h("span", { clase: "nombre-adjunto" }, "📎 ", f.name),
h("span", { clase: "muy-suave" }, ` ${tamaño(f.size)} · se adjuntará al guardar`),
h("button", { type: "button", clase: "btn-enlace", onclick: () => { pendientes.splice(i, 1); pintarCaja(); } }, "Quitar"))),
];
caja.replaceChildren(...[ // replaceChildren escribiría «null» tal cual
h("div", { clase: "etiqueta-seccion" }, "Justificantes"),
txid ? h("div", { clase: "txid" }, "Transacción de bitcoin: ", h("code", {}, txid),
h("button", { type: "button", clase: "btn-enlace", onclick: async () => {
if (await copiarTexto(txid)) toast("Txid copiado."); else toast("No se pudo copiar: selecciónalo a mano.", true);
} }, "Copiar")) : null,
filas.length ? h("ul", { clase: "lista-adjuntos" }, filas) : h("p", { clase: "muy-suave" },
txid ? "La transacción ya la justifica la cadena de bloques. Puedes añadir el movimiento del banco o la factura."
: "Extracto del banco, factura, contrato… (PDF, imagen u hoja de cálculo, hasta 5 MB)."),
h("button", { type: "button", clase: "btn", onclick: () => entradaF.click() }, "+ Añadir justificante"), entradaF].filter(Boolean));
};
entradaF.addEventListener("change", async () => {
const elegidos = [...entradaF.files];
entradaF.value = "";
if (!m) { pendientes.push(...elegidos); pintarCaja(); return; }
const r = await subirAdjuntos(m.id, elegidos);
if (r) { m = r; toast("Justificante añadido."); alCambiar(); pintarCaja(); }
});
pintarCaja();
return { elemento: caja, pendientes };
}
// «lugar» y «tipo» son los de partida en una operación nueva (el lugar que se
// está filtrando; «Traslado» desde la ficha del activo)
function formOperacion({ activo = null, lugar = null, tipo = null, movimiento = null, borrador = null, alGuardar = cargar }) {
const d = estado.datos;
const m = movimiento; // operación existente (editar)
const v = movimiento || borrador; // valores iniciales (editar o propuesta del asistente)
if ((v && v.tipo === "Amortizacion") || (!v && activo && activoPorId(activo)?.tipo === "Deuda")) {
formAmortizar({ activo: v ? v.activo : activo, movimiento: m, borrador, alGuardar }); return;
}
if (v && v.tipo === "Traslado") { formTraslado({ activo: v.activo, movimiento: m, alGuardar }); return; }
const abiertos = d.activos.filter((a) => a.tipo !== "Deuda" && (a.estado !== "cerrada" || (v && a.id === v.activo)));
if (!abiertos.length) { formActivo(); return; }
if (!v && !abiertos.some((a) => a.id === activo)) activo = null;
const estadoForm = { activo: v ? v.activo : activo || abiertos[0].id, tipo: v ? v.tipo : tipo };
let elegido = !!(v || tipo); // en una operación nueva, primero se pregunta qué ha pasado
const error = h("div", { clase: "error-form oculto", role: "alert" });
const selActivo = selector(Object.entries(TIPOS_ACTIVO).flatMap(([t, nombre]) => {
const grupo = abiertos.filter((a) => a.tipo === t);
return grupo.map((a) => [a.id, `${a.nombre} · ${nombre}`]);
}), estadoForm.activo, {}); // al editar también se puede cambiar de activo
const segmentos = h("div", { clase: "que-paso", role: "group", "aria-label": "Qué ha pasado" });
const ayuda = h("div", { clase: "nota-form completo" });
const inFecha = h("input", { type: "date", value: v ? v.fecha : hoyIso(), max: hoyIso() });
const inCantidad = entradaNum({ value: v && v.cantidad !== null && v.cantidad !== undefined ? String(v.cantidad).replace(".", ",") : "" });
const inPrecio = entradaNum({ value: v && v.precio ? String(v.precio).replace(".", ",") : "" });
const importeInicial = v ? (v.tipo === "Recompensa" ? v.valor_recibido : v.importe) : null;
const inImporte = entradaNum({ value: importeInicial !== null && importeInicial !== undefined ? String(importeInicial).replace(".", ",") : "" });
const inNotas = entrada({ value: v ? v.notas || "" : "", maxlength: 200 });
const inCosteTr = entradaNum({ value: v && v.coste_traspaso ? String(v.coste_traspaso).replace(".", ",") : "" });
const calculado = h("div", { clase: "ayuda completo" });
const btnPrecioDia = h("button", { type: "button", clase: "btn-enlace" }, "usar el precio de ese día");
const cCantidad = campo("Cantidad", inCantidad);
const cPrecio = campo("Precio por unidad (€)", inPrecio, { ayuda: btnPrecioDia });
const cImporte = campo("Importe (€)", inImporte);
const cCosteTr = campo("Coste fiscal traspasado (€)", inCosteTr, { ayuda: "El coste original del fondo de origen." });
// Dónde (solo en activos con lugares: un exchange, un monedero, un nodo Lightning…)
const selLugar = selector([], "");
const cLugar = campo("Dónde", selLugar, { ayuda: "El exchange, monedero o nodo donde ocurrió." });
const pintarLugares = () => {
const ls = lugaresDe(selActivo.value);
selLugar.replaceChildren(...[["", "— sin lugar —"], ...ls.map((x) => [x.id, `${x.nombre} · ${CLASES_LUGAR[x.clase] || x.clase}`])]
.map(([valor, texto]) => h("option", { value: valor }, texto)));
selLugar.value = v ? (v.activo === selActivo.value ? v.lugar || "" : "")
: selActivo.value === activo && ls.some((x) => x.id === lugar) ? lugar : "";
const ops = [["", "— aún no lo sé —"], ...ls.map((x) => [x.id, x.nombre])];
for (const sel of [selDesde, selHacia]) sel.replaceChildren(...ops.map(([valor, texto]) => h("option", { value: valor }, texto)));
selDesde.value = selLugar.value;
};
// Traslado (a otro lugar del mismo activo) y traspaso (a otro activo)
const selDesde = selector([], ""), selHacia = selector([], ""), selDestino = selector([], "");
const inComision = entradaNum({}), inEntra = entradaNum({}), inCosteFiscal = entradaNum({});
const cDesde = campo("Desde", selDesde), cHacia = campo("Hacia", selHacia);
const cComision = campo("Comisión (en la misma unidad)", inComision, { ayuda: "Opcional: lo que te cobraron por moverlo." });
const cDestino = campo("Hacia", selDestino);
const selLugarLlega = selector([], "");
const cLugarLlega = campo("Llega a (lugar)", selLugarLlega);
const cEntra = campo("Cantidad que llega", inEntra), cCosteFiscal = campo("Coste fiscal (€)", inCosteFiscal);
const destinosTraspaso = () => d.activos.filter((a) => a.id !== selActivo.value && a.estado !== "cerrada" && TRASPASABLES.includes(a.tipo));
const pintarDestinos = () => {
const antes = selDestino.value, ops = destinosTraspaso().map((a) => [a.id, `${a.nombre} · ${TIPOS_ACTIVO[a.tipo]}`]);
selDestino.replaceChildren(...ops.map(([valor, texto]) => h("option", { value: valor }, texto)));
if (ops.some(([valor]) => valor === antes)) selDestino.value = antes;
pintarLugarLlega();
};
const pintarLugarLlega = () => {
const antes = selLugarLlega.value, ls = lugaresDe(selDestino.value);
selLugarLlega.replaceChildren(...[["", "— sin lugar —"], ...ls.map((x) => [x.id, x.nombre])]
.map(([valor, texto]) => h("option", { value: valor }, texto)));
if (ls.some((x) => x.id === antes)) selLugarLlega.value = antes;
};
const esCripto = () => [selActivo.value, selDestino.value].some((id) => (activoPorId(id) || {}).tipo === "Cripto");
const inRetencion = entradaNum({ value: v && v.retencion ? String(v.retencion).replace(".", ",") : "" });
const cRetencion = campo("Retención (€, opcional)", inRetencion, { ayuda: "Lo que te retuvieron en origen y en España." });
function tiposPermitidos() {
const a = activoPorId(selActivo.value);
if (a.valoracion === "total") return ["Compra", "Reembolso", "Dividendo", "Gasto", "Devolucion"];
const lista = [...(MOV_POR_TIPO_ACTIVO[a.tipo] || ["Compra", "Venta"])];
if (v && v.tipo && !lista.includes(v.tipo)) lista.push(v.tipo);
if (!v) { // solo en una operación nueva
if (lugaresDe(a.id).length) lista.push("Traslado");
if (TRASPASABLES.includes(a.tipo) && destinosTraspaso().length) lista.push("Traspaso");
}
return lista;
}
function pintarTipos() {
const lista = tiposPermitidos();
const a = activoPorId(selActivo.value);
if (!lista.includes(estadoForm.tipo)) { estadoForm.tipo = lista[0]; if (!v) elegido = false; }
if (lista.length === 1) elegido = true;
campos.classList.toggle("sin-tipo", !elegido);
if (!elegido) {
// Primero la pregunta, con lo corriente a la vista y lo raro plegado
const opcion = (t) => {
const [titulo, texto] = quePaso(t, a);
return h("button", { type: "button", clase: "opcion-paso", onclick: () => { estadoForm.tipo = t; elegido = true; pintarTipos(); } },
h("b", {}, titulo), texto ? h("span", {}, texto) : null);
};
const principales = lista.filter((t) => QUE_PASO_PRINCIPALES.includes(t)), otras = lista.filter((t) => !QUE_PASO_PRINCIPALES.includes(t));
segmentos.replaceChildren(h("div", { clase: "etiqueta-seccion" }, "¿Qué ha pasado?"),
h("div", { clase: "opciones-paso" }, (principales.length ? principales : otras).map(opcion)),
principales.length && otras.length ? h("details", {}, h("summary", {}, "Otra cosa"), h("div", { clase: "opciones-paso" }, otras.map(opcion))) : null);
ayuda.textContent = "";
calculado.textContent = "";
return;
}
const t = estadoForm.tipo;
// Elegido: queda en una línea, con la puerta abierta a cambiarlo (salvo en un traspaso ya guardado)
segmentos.replaceChildren(h("div", { clase: "paso-elegido" }, h("b", {}, quePaso(t, a)[0]),
lista.length > 1 && !(m && m.tipo.startsWith("Traspaso"))
? h("button", { type: "button", clase: "btn-enlace", onclick: () => { elegido = false; pintarTipos(); } }, "Cambiar") : null));
const mover = t === "Traslado" || t === "Traspaso";
for (const c of [cDesde, cHacia, cComision]) c.classList.toggle("oculto", t !== "Traslado");
for (const c of [cDestino, cEntra, cCosteFiscal]) c.classList.toggle("oculto", t !== "Traspaso");
for (const c of [cActividad, cPedirActividad, cEstimado, justificantes.elemento]) c && c.classList.toggle("oculto", mover);
cAparato.classList.toggle("oculto", t !== "Gasto" && t !== "Devolucion");
cClaseGasto.classList.toggle("oculto", a.tipo !== "Inmueble" || (t !== "Gasto" && t !== "Devolucion"));
cImpagado.classList.toggle("oculto", a.tipo !== "Inmueble" || t !== "Dividendo");
cAparato.lastChild.textContent = a.tipo === "Inmueble"
? "Es una mejora (una reforma, una cocina nueva, un ascensor), no un gasto del año: suma a lo que te costó."
: "Es la compra de un aparato de la actividad (un minero), no un gasto del mes: en «Fiscal» se resta poco a poco.";
// En un traspaso, el lugar es de dónde sale; y el de llegada, si el destino tiene lugares
cLugar.classList.toggle("oculto", t === "Traslado" || !lugaresDe(a.id).length);
etiquetaCampo(cLugar, t === "Traspaso" ? "Sale de (lugar)" : "Dónde", t === "Traspaso" ? "" : "El exchange, monedero o nodo donde ocurrió.");
cLugarLlega.classList.toggle("oculto", t !== "Traspaso" || !lugaresDe(selDestino.value).length);
if (mover) {
const cripto = t === "Traspaso" && esCripto(), unidad = a.unidad || "unidades";
ayuda.textContent = t === "Traslado" ? AYUDA_TRASLADO : cripto ? AYUDA_TRASPASO_CRIPTO : AYUDA_TRASPASO;
for (const c of [cPrecio, cRetencion, cCosteTr]) c.classList.add("oculto");
cCantidad.classList.remove("oculto");
cImporte.classList.toggle("oculto", t === "Traslado");
if (t === "Traslado") etiquetaCampo(cCantidad, `Cantidad que llega (${unidad})`);
else {
etiquetaCampo(cCantidad, cripto ? `Cantidad que sale (${unidad})` : "Participaciones que salen", cripto ? "Lo que se descuenta del origen, comisión incluida." : "");
etiquetaCampo(cEntra, cripto ? "Cantidad que llega" : "Participaciones que entran", cripto ? "Vacío = la misma que sale." : "");
etiquetaCampo(cImporte, "Importe traspasado (€)", cripto ? "Opcional: vacío = precio de ese día (queda «estimado»)." : "");
etiquetaCampo(cCosteFiscal, "Coste fiscal (€)", cripto ? "Opcional: vacío = coste medio del origen." : "Opcional, pero recomendable.");
}
calculado.textContent = "";
return;
}
etiquetaCampo(cCantidad, "Cantidad");
etiquetaCampo(cImporte, "Importe (€)");
let texto = AYUDA_MOV[t] || "";
if (a.tipo === "Prestamo" && t === "Compra") texto = "Dinero prestado: pon el importe (cantidad 1).";
if (a.tipo === "Prestamo" && t === "Reembolso") texto = "Cobro del préstamo. Si cobras todo, mejor usa «Cerrar inversión».";
ayuda.textContent = texto;
const soloValor = a.valoracion === "total";
const nominalRF = a.tipo === "RentaFija" && (t === "Compra" || t === "Reembolso");
cCantidad.classList.toggle("oculto", (soloValor && !nominalRF) || ["Gasto", "Devolucion", "Dividendo"].includes(t));
cPrecio.classList.toggle("oculto", soloValor || ["Gasto", "Devolucion", "Mineria", "Dividendo", "Recompensa"].includes(t));
cImporte.classList.toggle("oculto", t === "Mineria");
cImporte.firstChild.textContent = t === "Dividendo" ? "Importe cobrado, neto (€)"
: t === "Recompensa" ? "Valor al recibirlo (€, informativo)" : "Importe (€)";
cCosteTr.classList.toggle("oculto", t !== "Traspaso_Entrada");
cRetencion.classList.toggle("oculto", t !== "Dividendo");
etiquetaCampo(cCantidad, nominalRF ? "Nominal (€, opcional)" : `Cantidad (${a.unidad || "unidades"})`,
nominalRF ? "Si vendes solo una parte, el nominal que vendes. Vacío = todo." : undefined);
recalcular();
}
const soloValorActivo = () => (activoPorId(selActivo.value) || {}).valoracion === "total";
function recalcular() {
const c = aNumero(inCantidad.value), p = aNumero(inPrecio.value), i = aEuros(inImporte.value);
const t = estadoForm.tipo;
let texto = "";
if (t !== "Traslado" && t !== "Traspaso" && !soloValorActivo() && !["Gasto", "Devolucion", "Mineria", "Dividendo", "Recompensa"].includes(t)) {
if (c && p && i === null) texto = `Importe: ${eur(c * p)}`;
else if (c && i !== null && p === null) texto = `Precio: ${eur(i / c)}`;
else if (p && i !== null && c === null) texto = `Cantidad: ${num(i / p)}`;
else if (c && p && i !== null && Math.abs(c * p - i) > Math.max(0.02, Math.abs(i) * 0.005)) texto = `Ojo: cantidad × precio = ${eur(c * p)}, no coincide con el importe.`;
}
calculado.textContent = texto;
}
btnPrecioDia.addEventListener("click", async () => {
try {
const r = await api(`/api/precio?activo=${encodeURIComponent(selActivo.value)}&fecha=${inFecha.value}`);
if (r.precio && r.precio.precio) {
inPrecio.value = String(r.precio.precio).replace(".", ",");
toast(`Precio del ${fecha(r.precio.fecha)}: ${eur(r.precio.precio)}`);
recalcular();
} else toast("No hay precio guardado para esa fecha.", true);
} catch (e) { toast(e.message, true); }
});
for (const el of [inCantidad, inPrecio, inImporte]) el.addEventListener("input", recalcular);
selActivo.addEventListener("change", () => { pintarLugares(); pintarDestinos(); pintarTipos(); });
selDestino.addEventListener("change", () => { pintarLugarLlega(); pintarTipos(); });
const actividadForm = campoActividad(v ? v.actividad : null);
let cambiosFuera = false; // justificantes añadidos o quitados sin pulsar «Guardar»
const justificantes = seccionJustificantes(m, () => { cambiosFuera = true; });
const chkEstimado = h("input", { type: "checkbox", checked: v ? !!v.estimado : false });
const cEstimado = h("label", { clase: "campo completo", estilo: { flexDirection: "row", alignItems: "center", gap: "8px" } },
chkEstimado, h("span", {}, "Cifras aproximadas: que se quede en «Para revisar» hasta que tengas los datos buenos."));
const chkAparato = h("input", { type: "checkbox", checked: v ? !!v.amortizable : false });
const chkImpagado = h("input", { type: "checkbox", checked: v ? !!v.impagado : false });
const cImpagado = h("label", { clase: "campo completo", estilo: { flexDirection: "row", alignItems: "center", gap: "8px" } },
chkImpagado, h("span", {}, "No lo he cobrado: el inquilino no ha pagado. Queda apuntado pero no cuenta como dinero recibido; quita la marca si al final paga."));
const selClaseGasto = selector(Object.entries((estado.datos.inmuebles || {}).clases_gasto || {}), (v && v.clase_gasto) || "otro");
const cClaseGasto = campo("Qué gasto es", selClaseGasto, { completo: true,
ayuda: "Para Hacienda: intereses y reparaciones tienen límite, y en el País Vasco solo cuentan los intereses y el seguro de impago." });
const cAparato = h("label", { clase: "campo completo", estilo: { flexDirection: "row", alignItems: "center", gap: "8px" } },
chkAparato, h("span", {}, "Es la compra de un aparato de la actividad (un minero), no un gasto del mes: en «Fiscal» se resta poco a poco."));
const cActividad = campo("Actividad", actividadForm.control, { completo: true,
ayuda: "Opcional. Agrupa operaciones (la minería, por ejemplo) para ver su rentabilidad aparte. "
+ "No cambia ninguna cifra de la cartera." });
// Quien no usa actividades no ve el campo: solo un enlace para sacarlo
const usaActividades = actividadesExistentes().length > 0 || !!(v && v.actividad);
const cPedirActividad = usaActividades ? null : h("div", { clase: "completo" }, h("button", { type: "button", clase: "btn-enlace",
onclick: (e) => { e.currentTarget.parentElement.remove(); cActividad.classList.remove("nunca"); } }, "Agrupar en una actividad (opcional)"));
if (!usaActividades) cActividad.classList.add("nunca");
const cActivo = campo("¿De qué inversión?", selActivo, { completo: true, ayuda: m ? null : h("button", { type: "button", clase: "btn-enlace", onclick: () => { cerrar(); formActivo(); } }, "¿No está en la lista? Dala de alta") });
const cSegmentos = h("div", { clase: "completo siempre" }, segmentos);
cActivo.classList.add("siempre");
const campos = h("div", { clase: "campos" });
const cuerpo = h("div", {}, error, campos);
campos.append(...[cActivo, cSegmentos, ayuda,
cDesde, cHacia,
campo("Fecha", inFecha), cDestino, cLugar, cLugarLlega,
cCantidad, cEntra, cComision, cPrecio, cImporte, cCosteFiscal, cRetencion, cCosteTr, calculado,
campo("Notas", inNotas, { completo: true }),
cActividad, cPedirActividad, cClaseGasto, cAparato, cImpagado, cEstimado, justificantes.elemento].filter(Boolean));
pintarLugares();
pintarDestinos();
pintarTipos();
let cerrar;
const guardar = async (cerrarModal, otra) => {
if (!elegido) { error.textContent = "Elige primero qué ha pasado."; error.classList.remove("oculto"); return; }
const valores = { c: aNumero(inCantidad.value), p: aNumero(inPrecio.value), i: aEuros(inImporte.value), ct: aEuros(inCosteTr.value), r: aEuros(inRetencion.value),
com: aNumero(inComision.value), ent: aNumero(inEntra.value), cf: aEuros(inCosteFiscal.value) };
if (Object.values(valores).some((x) => Number.isNaN(x))) {
error.textContent = "Hay un número mal escrito (usa coma para decimales, por ejemplo 1234,56)."; error.classList.remove("oculto"); return;
}
if (estadoForm.tipo === "Traslado" || estadoForm.tipo === "Traspaso") {
const traslado = estadoForm.tipo === "Traslado";
const r = await guardarCon(null, error, () => (traslado
? api("/api/movimiento", { tipo: "Traslado", activo: selActivo.value, fecha: inFecha.value, cantidad: valores.c,
comision: valores.com, lugar: selDesde.value, lugar_destino: selHacia.value, notas: inNotas.value })
: api("/api/traspaso", { origen: selActivo.value, destino: selDestino.value, fecha: inFecha.value, cantidad_salida: valores.c,
importe: valores.i, cantidad_entrada: valores.ent, coste_fiscal: valores.cf, notas: inNotas.value,
lugar_origen: lugaresDe(selActivo.value).length ? selLugar.value : "",
lugar_destino: lugaresDe(selDestino.value).length ? selLugarLlega.value : "" })));
if (!r) return;
toast(traslado ? "Traslado guardado." : "Traspaso guardado.");
cerrarModal();
await alGuardar();
if (otra) formOperacion({ activo: selActivo.value, lugar: traslado ? selDesde.value : null, tipo: estadoForm.tipo, alGuardar });
return;
}
const a = activoPorId(selActivo.value);
const cuerpoApi = { id: m ? m.id : undefined, activo: selActivo.value, tipo: estadoForm.tipo, fecha: inFecha.value,
cantidad: valores.c, precio: valores.p, importe: valores.i, coste_traspaso: valores.ct, retencion: valores.r,
notas: inNotas.value, actividad: actividadForm.input.value.trim(), estimado: chkEstimado.checked };
if (estadoForm.tipo === "Gasto" || estadoForm.tipo === "Devolucion") cuerpoApi.amortizable = chkAparato.checked;
if ((estadoForm.tipo === "Gasto" || estadoForm.tipo === "Devolucion") && a.tipo === "Inmueble") cuerpoApi.clase_gasto = selClaseGasto.value;
if (estadoForm.tipo === "Dividendo" && a.tipo === "Inmueble") cuerpoApi.impagado = chkImpagado.checked;
if (lugaresDe(selActivo.value).length) cuerpoApi.lugar = selLugar.value;
if (estadoForm.tipo === "Recompensa") Object.assign(cuerpoApi, { valor_recibido: valores.i, importe: null });
if (borrador && borrador.moneda === "USD") Object.assign(cuerpoApi, { moneda: "USD", importe_original: borrador.importe_original });
const r = await guardarCon(null, error, () => api("/api/movimiento", cuerpoApi));
if (!r) return;
if (justificantes.pendientes.length) await subirAdjuntos(r.resultado.id, justificantes.pendientes);
toast(m ? "Operación actualizada." : "Operación guardada.");
cambiosFuera = false;
cerrarModal();
await alGuardar();
if (otra) formOperacion({ activo: selActivo.value, lugar: selLugar.value, alGuardar });
};
cerrar = modal({
titulo: m ? "Editar operación" : "Nueva operación", cuerpo,
alCerrar: () => { if (cambiosFuera) { cambiosFuera = false; alGuardar(); } },
acciones: [
...(m ? [] : [{ texto: "Guardar y añadir otra", izq: true, accion: (c) => guardar(c, true) }]),
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Guardar", clase: "btn-primario", accion: (c) => guardar(c, false) },
],
});
}
// Editar un traslado entre dos lugares tuyos del mismo activo (de un exchange a tu monedero): una sola
// línea, sin efecto fiscal. Los nuevos se apuntan en «+ Operación», tipo «Traslado».
function formTraslado({ activo = null, movimiento = null, alGuardar = cargar } = {}) {
const m = movimiento;
const conLugares = estado.datos.activos.filter((a) => a.estado !== "cerrada" && (a.lugares || []).length);
if (!conLugares.length) {
toast("Primero crea los lugares en la ficha del activo («Dónde lo tienes»).", true); return;
}
const error = h("div", { clase: "error-form oculto", role: "alert" });
const selActivo = selector(conLugares.map((a) => [a.id, a.nombre]), m ? m.activo : activo || conLugares[0].id);
const selDesde = selector([], ""), selHacia = selector([], "");
const pintarLugares = () => {
const ops = [["", "— aún no lo sé —"], ...lugaresDe(selActivo.value).map((x) => [x.id, x.nombre])];
for (const [sel, valor] of [[selDesde, m ? m.lugar : ""], [selHacia, m ? m.lugar_destino : ""]]) {
sel.replaceChildren(...ops.map(([v, t]) => h("option", { value: v }, t)));
sel.value = valor || "";
}
};
selActivo.addEventListener("change", pintarLugares);
pintarLugares();
const comision = m ? estado.datos.movimientos.find((x) => x.traslado === m.id) : null;
const inFecha = h("input", { type: "date", value: m ? m.fecha : hoyIso(), max: hoyIso() });
const inCantidad = entradaNum({ value: m ? String(m.cantidad).replace(".", ",") : "" });
const inComision = entradaNum({ value: comision ? String(comision.cantidad).replace(".", ",") : "" });
const inNotas = entrada({ value: m ? m.notas || "" : "", maxlength: 200 });
modal({
titulo: m ? "Editar traslado" : "Traslado entre tus lugares", ancho: true,
cuerpo: h("div", {}, error,
h("p", { clase: "nota-form", estilo: { marginTop: 0 } },
"Mover de un sitio tuyo a otro del mismo activo: de un exchange a tu monedero, de tu monedero a un nodo Lightning… "
+ "No es compra ni venta y no cambia el coste. Lo que se pierde por el camino es la comisión, que sí baja la cantidad."),
h("div", { clase: "campos" },
campo("Inversión", selActivo), campo("Fecha", inFecha),
campo("Desde", selDesde), campo("Hacia", selHacia),
campo("Cantidad que llega", inCantidad),
campo("Comisión (en la misma unidad)", inComision, { ayuda: "Opcional: lo que te cobraron por moverlo." }),
campo("Notas", inNotas, { completo: true }))),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Guardar traslado", clase: "btn-primario", accion: async (c) => {
const r = await guardarCon(null, error, () => api("/api/movimiento", {
id: m ? m.id : undefined, tipo: "Traslado", activo: selActivo.value, fecha: inFecha.value,
cantidad: aNumero(inCantidad.value), comision: aNumero(inComision.value),
lugar: selDesde.value, lugar_destino: selHacia.value, notas: inNotas.value }));
if (r) { toast("Traslado guardado."); c(); await alGuardar(); }
} },
],
});
}
// Los lugares de un activo (dónde lo tienes) con su saldo, para la ficha
function seccionLugares(a, refrescar) {
const saldos = (estado.datos.lugares || {})[a.id] || {};
const ls = a.lugares || [];
const filas = ls.map((x) => h("tr", {},
h("td", {}, x.nombre), h("td", { clase: "suave" }, CLASES_LUGAR[x.clase] || x.clase),
h("td", { clase: "d num" }, `${num(saldos[x.id] || 0)} ${a.unidad || ""}`),
h("td", { clase: "d", estilo: { whiteSpace: "nowrap" } },
h("button", { type: "button", clase: "btn-icono", title: "Cambiar nombre o tipo", onclick: () => formLugar(a, x, refrescar) }, "✎"),
ls.length > 1 ? h("button", { type: "button", clase: "btn-icono", title: "Unir con otro lugar (es el mismo sitio)",
onclick: () => formUnirLugar(a, x, refrescar) }, "⇢") : null,
h("button", { type: "button", clase: "btn-icono", title: "Quitar", onclick: async () => {
if (!(await confirmar(`¿Quitar el lugar «${x.nombre}»? Solo se puede si no tiene operaciones.`, "Quitar", true))) return;
try { await api("/api/lugar/borrar", { activo: a.id, id: x.id }); refrescar(); } catch (e) { toast(e.message, true); }
} }, "🗑"))));
if (saldos[""]) filas.push(h("tr", {}, h("td", { clase: "suave" }, "Sin lugar"), h("td", { clase: "suave" }, "lo apuntado sin decir dónde"),
h("td", { clase: "d num" }, `${num(saldos[""])} ${a.unidad || ""}`), h("td")));
const otros = estado.datos.activos.filter((o) => o.id !== a.id && o.tipo === a.tipo && (o.codigo || "") === (a.codigo || "")
&& o.estado !== "cerrada");
// Sin lugares, una línea discreta: la mayoría lo tiene en un solo sitio
if (!ls.length && !saldos[""]) return h("p", { clase: "suave linea-lugares" }, "¿Lo tienes repartido en varios sitios (un exchange, tu monedero…)? ",
h("button", { type: "button", clase: "btn-enlace", onclick: () => formLugar(a, null, refrescar) }, "Añade dónde"),
otros.length ? [" · ", h("button", { type: "button", clase: "btn-enlace", onclick: () => formUnirComoLugar(a, otros, refrescar) }, "Unir otro activo como lugar")] : null,
". Los monederos del nodo y los exchanges de un extracto se crean solos.");
return h("div", {},
h("h4", { estilo: { margin: "8px 0" } }, "Dónde lo tienes"),
ls.length || saldos[""] ? h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, h("th", {}, "Lugar"), h("th", {}), h("th", { clase: "d" }, "Saldo"), h("th", {}))),
h("tbody", {}, filas))) : h("p", { clase: "vacio" }, "Sin lugares todavía: los monederos del nodo y los exchanges del CSV se crean solos."),
h("div", { clase: "acciones-ficha" },
h("button", { type: "button", clase: "btn", onclick: () => formLugar(a, null, refrescar) }, "+ Lugar"),
ls.length ? h("button", { type: "button", clase: "btn", onclick: () => formOperacion({ activo: a.id, tipo: "Traslado", alGuardar: refrescar }) }, "+ Traslado") : null,
otros.length ? h("button", { type: "button", clase: "btn", onclick: () => formUnirComoLugar(a, otros, refrescar) },
"Unir otro activo como lugar…") : null));
}
function formLugar(a, x, refrescar) {
const error = h("div", { clase: "error-form oculto", role: "alert" });
const inNombre = entrada({ value: x ? x.nombre : "", maxlength: 60, placeholder: "Mi exchange, mi monedero…" });
const selClase = selector(Object.entries(CLASES_LUGAR), x ? x.clase : "exchange");
modal({
titulo: x ? "Editar lugar" : `Nuevo lugar de ${a.nombre}`,
cuerpo: h("div", {}, error, h("div", { clase: "campos" }, campo("Nombre", inNombre), campo("Qué es", selClase))),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Guardar", clase: "btn-primario", accion: async (c) => {
const r = await guardarCon(null, error, () => api("/api/lugar", { activo: a.id, id: x ? x.id : undefined,
nombre: inNombre.value, clase: selClase.value }));
if (r) { c(); refrescar(); }
} },
],
});
}
// Dos lugares que son el mismo sitio («BTC en Binance» y «Binance»): todo pasa al que se queda
function formUnirLugar(a, x, refrescar) {
const otros = (a.lugares || []).filter((o) => o.id !== x.id);
// Propone el que se parece por el nombre («BTC en Binance» → «Binance»)
const base = quitarTildes(x.nombre.toLowerCase()).replace(/\b(btc|bitcoin|en|de)\b/g, "").trim();
const parecido = otros.find((o) => quitarTildes(o.nombre.toLowerCase()).includes(base) && base) || otros[0];
const error = h("div", { clase: "error-form oculto", role: "alert" });
const selHacia = selector(otros.map((o) => [o.id, o.nombre]), parecido.id);
modal({
titulo: `Unir «${x.nombre}» con otro lugar`,
cuerpo: h("div", {}, error,
h("p", { clase: "nota-form", estilo: { marginTop: 0 } },
`Para cuando son el mismo sitio. Todas las operaciones de «${x.nombre}» pasan al lugar que elijas y «${x.nombre}» desaparece. `
+ "Un traslado entre los dos ya no mueve nada y se quita, pero su comisión se queda. Los monederos y los exchanges del CSV "
+ "que apuntaban aquí pasan también. No cambia ninguna cifra de la cartera."),
h("div", { clase: "campos" }, campo("Pasar todo a", selHacia, { completo: true }))),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Unir", clase: "btn-primario", accion: async (c) => {
const r = await guardarCon(null, error, () => api("/api/lugar/unir", { activo: a.id, desde: x.id, hacia: selHacia.value }));
if (r) {
const d = r.resultado;
toast(pl`Unidos: ${d.movidas} operación(es) pasadas` + (d.traslados_quitados ? pl`, ${d.traslados_quitados} traslado(s) entre los dos quitados` : "") + ".");
c(); refrescar();
}
} },
],
});
}
// «BTC en mi nodo» (un activo aparte) pasa a ser el lugar «BTC en mi nodo» de este activo
function formUnirComoLugar(a, otros, refrescar) {
const error = h("div", { clase: "error-form oculto", role: "alert" });
const selOtro = selector(otros.map((o) => [o.id, o.nombre]), otros[0].id);
const selClase = selector(Object.entries(CLASES_LUGAR), "lightning");
modal({
titulo: `Unir a «${a.nombre}» como lugar`, ancho: true,
cuerpo: h("div", {}, error,
h("p", { clase: "nota-form", estilo: { marginTop: 0 } },
"Para un activo que en realidad es un sitio donde tienes lo mismo (p. ej. «BTC en mi nodo» dentro de «Bitcoin»). "
+ "Sus operaciones pasan a este activo con ese lugar, sus traspasos con este activo se convierten en traslados, "
+ "sus monederos del nodo también se pasan, y el activo aparte desaparece. Las cifras totales no cambian."),
h("div", { clase: "campos" }, campo("Activo que se une", selOtro), campo("Qué es", selClase))),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Unir", clase: "btn-primario", accion: async (c) => {
const r = await guardarCon(null, error, () => api("/api/activo/convertir_en_lugar",
{ id: selOtro.value, destino: a.id, clase: selClase.value }));
if (r) { toast(`Ahora es el lugar «${r.resultado.lugar.nombre}».`); c(); refrescar(); }
} },
],
});
}
/* ── Activos: alta, cierre, precio ──────────────────────────────────────── */
// Qué se puede dar de alta, dicho en llano: [tipo, título, una línea, valores ya puestos]
const QUE_ANADIR = [
["Cripto", "Bitcoin", "El que tienes en tus monederos, en un exchange o en tu nodo.", { nombre: "Bitcoin", codigo: "bitcoin", unidad: "BTC" }],
["Fondo", "Un fondo de inversión", "Indexados o de gestión activa, con su ISIN."],
["Accion", "Acciones", "De una empresa que cotiza en bolsa."],
["ETF", "Un ETF", "Un fondo que cotiza en bolsa como una acción."],
["PlanPensiones", "Un plan de pensiones", "Individual o de empleo."],
["RentaFija", "Letras, bonos o un depósito a plazo", "Del Tesoro, de un banco o de una empresa. Su valor se calcula solo hasta que vence."],
["Inmueble", "Una vivienda, un garaje, un local o un terreno", "Alquilado, vacío o tu propia casa. El valor de las viviendas y las fincas se estima solo."],
["Cuenta", "Una cuenta corriente o de ahorro", "Dinero sin invertir, para ver tu patrimonio completo."],
];
const QUE_ANADIR_OTROS = [
["Cripto", "Otra criptomoneda", "Cualquiera que no sea bitcoin."],
["Metal", "Oro o plata", "En onzas o en gramos."],
["Prestamo", "Dinero que he prestado", "Un préstamo que has concedido y te van a devolver."],
["Deuda", "Un préstamo que me han dado", "Lo que debes: una hipoteca, un préstamo personal."],
["Inmueble", "Una finca rústica", "Tierra de labor, viñedo, olivar, prados… Su valor se estima solo con el precio de la tierra de su cultivo, en secano o en regadío.", { clase: "terreno_rustico" }],
["CuentaTrading", "Una cuenta de bróker o de trading", "Futuros, forex, CFD, opciones… Apuntas lo que metes, lo que sacas y el saldo de vez en cuando; con eso sale la rentabilidad. Lo fiscal, del informe anual del bróker."],
];
const SE_BUSCAN = ["Fondo", "Accion", "ETF", "Cripto"]; // tipos que el buscador de internet encuentra
function formActivo(a = null) {
const error = h("div", { clase: "error-form oculto", role: "alert" });
const selTipo = selector(Object.entries(TIPOS_ACTIVO), a ? a.tipo : "Fondo");
const inNombre = entrada({ value: a ? a.nombre : "", maxlength: 120 });
const inCodigo = entrada({ value: a ? a.codigo || "" : "", maxlength: 60 });
const esCuentaTipo = () => selTipo.value === "Cuenta";
const inUnidad = entrada({ value: a ? a.unidad || "" : "participaciones", maxlength: 30 });
const inAlias = entrada({ value: a ? a.alias || "" : "", maxlength: 120, placeholder: "ej. msci, world" });
const chkValor = h("input", { type: "checkbox", checked: a ? a.valoracion === "total" : false });
const cValor = h("label", { clase: "campo completo", estilo: { flexDirection: "row", alignItems: "center", gap: "8px" } },
chkValor, h("span", {}, "Seguir solo el valor total (sin participaciones): apuntas las aportaciones en euros y el valor de la posición cuando quieras."));
const inInicio = h("input", { type: "date", value: a && a.fecha_inicio ? a.fecha_inicio : "" });
const inFinal = entradaNum({ value: a && a.prestamo ? String(a.prestamo.importe_final).replace(".", ",") : "" });
const inVenc = h("input", { type: "date", value: a && a.prestamo ? a.prestamo.vencimiento : "" });
const cFinal = campo("Importe total a cobrar (€)", inFinal, { ayuda: "Capital + intereses." });
const cVenc = campo("Vencimiento", inVenc);
const dd = a && a.deuda ? a.deuda : {};
const inCapital = entradaNum({ value: dd.capital ? String(dd.capital).replace(".", ",") : "" });
const inTin = entradaNum({ value: dd.tin !== undefined ? String(dd.tin).replace(".", ",") : "" });
const inCuotas = entradaNum({ value: dd.cuotas ? String(dd.cuotas) : "" });
const inPrimera = h("input", { type: "date", value: dd.primera_cuota || "" });
const camposDeuda = [
campo("Capital pendiente (€)", inCapital, { ayuda: "Lo que queda por devolver en la fecha de estos datos (recibo o app del banco)." }),
campo("Tipo de interés nominal (TIN, %)", inTin, { ayuda: "Fijo. Ej. 2,5" }),
campo("Cuotas que quedan", inCuotas, { ayuda: "Contando la próxima." }),
campo("Próxima cuota", inPrimera, { ayuda: "Fecha del próximo cargo (ej. el día 1 del mes que viene)." })];
// La ficha de un depósito, una Letra o un bono
const rf = (a && a.renta_fija) || {};
const selClaseRF = selector([["letra", "Letra del Tesoro"], ["bono", "Bono u obligación"], ["deposito", "Depósito a plazo"]], rf.clase || "letra");
const inNominal = entradaNum({ value: rf.nominal ? String(rf.nominal).replace(".", ",") : "" });
const inInteres = entradaNum({ value: rf.interes !== undefined ? String(rf.interes).replace(".", ",") : "", placeholder: "ej. 2,75" });
const inVencRF = h("input", { type: "date", value: rf.vencimiento || "" });
const selPagos = selector([["0", "Todo al vencimiento"], ["1", "Una vez al año"], ["2", "Cada seis meses"], ["4", "Cada tres meses"], ["12", "Cada mes"]], String(rf.pagos || 0));
const inEmisor = entrada({ value: rf.emisor || "", maxlength: 120, placeholder: "Tesoro Público, tu banco, una empresa…" });
const cInteres = campo("Tipo de interés anual (%)", inInteres, { ayuda: "El nominal (TIN), o el cupón del bono." });
const cPagos = campo("Cada cuánto paga intereses", selPagos);
const camposRF = [campo("Qué es", selClaseRF),
campo("Nominal (€)", inNominal, { ayuda: "Lo que te devuelven al vencer. En un depósito, el capital." }),
cInteres, campo("Vence el", inVencRF), cPagos, campo("Emisor", inEmisor)];
const ajustarRF = () => {
const es = selTipo.value === "RentaFija";
for (const c of camposRF) c.classList.toggle("oculto", !es);
const letra = selClaseRF.value === "letra";
cInteres.classList.toggle("oculto", !es || letra);
cPagos.classList.toggle("oculto", !es || letra);
};
selClaseRF.addEventListener("change", () => { ajustarRF(); actualizarTipo(); });
// La ficha de un inmueble
const fi = (a && a.inmueble) || {};
const listas = estado.datos.inmuebles || { clases: {}, usos: {}, comunidades: {} };
const aTexto = (v) => (v === undefined || v === null ? "" : String(v).replace(".", ","));
const selClase = selector(Object.entries(listas.clases), fi.clase || "vivienda");
const selUso = selector(Object.entries(listas.usos), fi.uso || "alquilado");
// De partida, la comunidad de su territorio fiscal (casi siempre los inmuebles están donde se declara)
const comunidadTerritorio = { navarra: "navarra", alava: "pais_vasco", bizkaia: "pais_vasco", gipuzkoa: "pais_vasco" }[listas.territorio] || "nacional";
const selComunidad = selector(Object.entries(listas.comunidades), fi.comunidad || comunidadTerritorio);
const inReferencia = entrada({ value: fi.referencia || "", maxlength: 120, placeholder: "Calle Mayor 3, 2.º B · o la referencia catastral" });
const inPrecioCompra = entradaNum({ value: aTexto(fi.precio_compra) });
const chkAuto = h("input", { type: "checkbox", checked: a ? !!fi.auto : true });
const chkProtegida = h("input", { type: "checkbox", checked: !!fi.protegida });
const inMaximo = entradaNum({ value: aTexto(fi.precio_maximo) });
const deudas = estado.datos.activos.filter((x) => x.tipo === "Deuda" && x.estado !== "cerrada");
const selHipoteca = selector([["", "Ninguna"], ...deudas.map((x) => [x.id, x.nombre])], fi.hipoteca || "");
const inCatastral = entradaNum({ value: aTexto(fi.valor_catastral) });
const inConstruccion = entradaNum({ value: aTexto(fi.porcentaje_construccion), placeholder: "ej. 60" });
const modalidades = Object.entries(listas.modalidades || {});
const selContrato = selector(modalidades.length ? modalidades : [["habitual", "Vivienda habitual del inquilino"]], fi.contrato || "habitual");
const reducciones = (listas.reducciones || []).map(([v, t]) => [String(v), t]);
const selReduccion = selector(reducciones.length ? reducciones : [["0", "Ninguna"]], String(fi.reduccion || 0));
const chkRevisado = h("input", { type: "checkbox", checked: !!fi.catastral_revisado });
const inRenta = entradaNum({ value: aTexto(fi.renta_mensual), placeholder: "ej. 750" });
const inDiaCobro = h("input", { type: "number", min: 1, max: 28, value: fi.dia_cobro || 1 });
const inRentaDesde = h("input", { type: "date", value: fi.renta_desde || hoyIso() });
const camposRenta = [campo("Renta mensual (€)", inRenta, { ayuda: "Con ella, el cobro se apunta solo cada mes. Si la subes, cámbiala aquí." }),
campo("Día de cobro", inDiaCobro), campo("Cobrando desde", inRentaDesde, { ayuda: "Se apuntan también los meses pasados desde esta fecha." })];
const cContrato = campo("Cómo lo alquilas", selContrato, { completo: true, ayuda: `Según las reglas de ${listas.nombre_territorio || "tu territorio"}, que es donde declaras.` });
const cReduccion = campo("Reducción por alquiler de vivienda", selReduccion, { completo: true,
ayuda: "Depende de cuándo firmaste el contrato y de sus condiciones. Si dudas, la de tu última declaración." });
const casilla = (chk, texto) => h("label", { clase: "campo completo", estilo: { flexDirection: "row", alignItems: "center", gap: "8px" } }, chk, h("span", {}, texto));
const cRevisado = casilla(chkRevisado, "Los valores catastrales de su municipio se revisaron en los últimos diez años (lo dice el recibo del IBI o la sede del Catastro).");
const cMaximo = campo("Precio máximo de venta (€)", inMaximo, { ayuda: "El que fija la calificación de protección oficial. Es lo que vale la vivienda." });
// Vivienda protegida por metros: piezas × lo que computan × coeficiente × módulo del año
const vpoL = listas.vpo || { regimenes: {}, clases_pieza: {}, piezas_iniciales: [], modulos: {}, año: new Date().getFullYear() };
const selTope = selector([["metros", "Por metros: módulo del año × coeficiente"], ["fijo", "Una cifra fija"]],
fi.tope || (fi.precio_maximo ? "fijo" : "metros"));
const selRegimen = selector(Object.entries(vpoL.regimenes).map(([k, v]) => [k, v.nombre]), fi.regimen || "vpo");
const inCoefViv = entradaNum({ value: aTexto(fi.coef_vivienda), placeholder: "ej. 1,3" });
const inCoefAnejo = entradaNum({ value: aTexto(fi.coef_anejo), placeholder: "ej. 0,52" });
const inModulo = entradaNum({ value: aTexto((fi.modulos || {})[vpoL.año]) });
const resumenTope = h("div", { clase: "ayuda completo" });
const filasPiezas = [];
const cajaPiezas = h("div", { clase: "piezas-vpo" });
const numero = (n) => String(Math.round(n * 100) / 100).replace(".", ",");
const moduloAño = () => {
const propio = aNumero(inModulo.value);
if (propio > 0) return [propio, "el que has puesto"];
const m = vpoL.modulos[selComunidad.value];
return m ? [m.valor, m.origen === "oficial" ? "oficial" : "estimado con el IPC"] : [null, null];
};
const pintarTope = () => {
const r = vpoL.regimenes[selRegimen.value] || {};
const otro = r.vivienda === null || r.vivienda === undefined;
const [cv, ca] = otro ? [aNumero(inCoefViv.value) || 0, aNumero(inCoefAnejo.value) || 0] : [r.vivienda, r.anejo];
let viv = 0, anejo = 0;
for (const f of filasPiezas) {
const m2 = (aNumero(f.m2.value) || 0) * (aNumero(f.computa.value) ?? 100) / 100;
if (f.clase.value === "anejo") anejo += m2; else viv += m2;
}
const [mod, de] = moduloAño();
const m = vpoL.modulos[selComunidad.value];
inModulo.placeholder = m ? `${numero(m.valor)} (${m.origen === "oficial" ? "oficial" : "estimado"})` : "€/m² útil";
resumenTope.textContent = !mod ? `Fuera de Navarra Caudal no trae los módulos: pon el de ${vpoL.año}.`
: !viv ? "Pon los metros útiles: están en la cédula de calificación definitiva o en la escritura."
: `Precio máximo en ${vpoL.año}: ${eur((viv * cv + anejo * ca) * mod)}. Son ${numero(viv)} m² × ${numero(cv)}`
+ (anejo ? ` + ${numero(anejo)} m² de anejos × ${numero(ca)}` : "") + `, por el módulo de ${eur(mod)}/m² (${de}).`;
};
const nuevaPieza = (p = {}) => {
const f = { nombre: entrada({ value: p.nombre || "", maxlength: 40, placeholder: "Pieza", "aria-label": "Nombre de la pieza" }),
m2: entradaNum({ value: aTexto(p.m2), placeholder: "m²", "aria-label": "Metros útiles" }),
clase: selector(Object.entries(vpoL.clases_pieza), p.clase || "vivienda", { "aria-label": "Cómo cuenta" }),
computa: entradaNum({ value: aTexto(p.computa ?? 100), "aria-label": "Porcentaje que computa" }) };
f.el = h("div", { clase: "fila-pieza" }, f.nombre, f.m2, f.clase, h("span", { clase: "pieza-pct" }, f.computa, "%"),
h("button", { type: "button", clase: "btn-enlace", "aria-label": "Quitar esta pieza", onclick: () => {
filasPiezas.splice(filasPiezas.indexOf(f), 1); f.el.remove(); pintarTope(); } }, "Quitar"));
for (const x of [f.m2, f.computa]) x.addEventListener("input", pintarTope);
f.clase.addEventListener("change", pintarTope);
filasPiezas.push(f);
cajaPiezas.append(f.el);
};
(fi.piezas && fi.piezas.length ? fi.piezas : vpoL.piezas_iniciales).forEach((p) => nuevaPieza(p));
const cTope = campo("Cómo se fija su precio máximo", selTope, { completo: true,
ayuda: "En Navarra va por metros: cada año se publica un módulo en €/m² útil y el precio máximo sube con él." });
const cRegimen = campo("Régimen", selRegimen, { completo: true,
ayuda: "Lo dice la cédula de calificación. En una VPO de régimen general: 1,30 veces el módulo para la vivienda y el garaje, y 0,52 para el trastero y otros anejos." });
const cCoefViv = campo("Coeficiente de la vivienda y el garaje", inCoefViv);
const cCoefAnejo = campo("Coeficiente de los anejos", inCoefAnejo);
const cPiezas = h("div", { clase: "campo completo" }, "Metros útiles de cada pieza",
h("div", { clase: "fila-pieza cabecera", "aria-hidden": "true" }, h("span", {}, "Pieza"), h("span", {}, "m² útiles"), h("span", {}, "Cuenta como"), h("span", {}, "Computa"), h("span", {})),
cajaPiezas,
h("button", { type: "button", clase: "btn-enlace", estilo: { alignSelf: "flex-start" }, onclick: () => { nuevaPieza({}); pintarTope(); } }, "+ Añadir pieza"),
h("span", { clase: "ayuda" }, "Los m² útiles de la cédula de calificación definitiva o de la escritura. Si la cédula ya da la superficie útil que computa (con la terraza contada como corresponda), pon esa y la terraza al 100 %, o quítala. Las piezas sin metros no cuentan."));
const cModulo = campo(`Módulo sin ponderar de ${vpoL.año} (€/m² útil)`, inModulo, { completo: true,
ayuda: "Déjalo en blanco para usar el que trae Caudal (los oficiales de Navarra). Si sale estimado, cuando se publique el oficial (en el Boletín Oficial de Navarra, hacia diciembre o enero) ponlo aquí." });
const camposVpo = [cTope, cRegimen, cCoefViv, cCoefAnejo, cPiezas, cModulo, resumenTope];
for (const x of [inCoefViv, inCoefAnejo, inModulo]) x.addEventListener("input", pintarTope);
const inIntereses = entradaNum({ value: aTexto(fi.intereses_hipoteca), placeholder: "ej. 38.500" });
const cIntereses = campo("Intereses pagados de una hipoteca ya cancelada (€)", inIntereses, { completo: true,
ayuda: "El total de toda su vida, del certificado del banco. Verás lo que te costó con intereses; para Hacienda no cuentan como coste. Si la hipoteca sigue viva, vincúlala arriba." });
// Fincas rústicas: superficie y cultivo, para el precio de la tierra del MAPA
const tierra = listas.tierra || { cultivos: {}, origenes: {}, precios: {} };
const cultivosFicha = fi.cultivos || [];
const cultivoActual = cultivosFicha.length ? cultivosFicha[cultivosFicha.length - 1].cultivo : "TAS";
const inSuperficie = entradaNum({ value: aTexto(fi.superficie), placeholder: "ej. 2,5" });
const selCultivo = selector(Object.entries(tierra.cultivos), cultivoActual);
const inCultivoDesde = h("input", { type: "date", value: hoyIso() });
const selOrigen = selector(Object.entries(tierra.origenes), fi.origen || "compra");
const mediaTierra = h("div", { clase: "ayuda completo" });
const cCultivoDesde = campo("Cultivo nuevo desde", inCultivoDesde, { ayuda: "Si te equivocaste al ponerlo, pon la fecha de compra y se corrige desde el principio." });
const camposRustico = [
campo("Superficie (hectáreas)", inSuperficie, { ayuda: "1 ha = 10.000 m². Una robada navarra son 898 m²; una fanega, según la zona, de 3.000 a 6.500 m²." }),
campo("Cultivo", selCultivo, { ayuda: "Como la clasifica el MAPA: el secano y el regadío van por separado." }),
cCultivoDesde,
campo("De dónde parte su valor", selOrigen, { ayuda: "Si la heredaste o no sabes lo que vale, la media. Si la compraste a precio de mercado o la has tasado, lo que costó." }),
mediaTierra,
];
const pintarMedia = () => {
const ha = aNumero(inSuperficie.value);
const propios = tierra.precios[selComunidad.value] || {};
const precio = propios[selCultivo.value] || (tierra.precios.nacional || {})[selCultivo.value];
if (!precio) { mediaTierra.textContent = ""; return; }
const nacional = selComunidad.value === "nacional";
const donde = nacional ? "toda España" : propios[selCultivo.value] ? (listas.comunidades[selComunidad.value] || "su comunidad")
: "toda España (en su comunidad no hay dato de este cultivo)";
const redondo = (n, decenas) => fmtEur0.format(decenas ? Math.round(n / 10) * 10 : n);
mediaTierra.textContent = `Precio medio en ${donde}: ${redondo(precio)}/ha en ${tierra.año}` + (ha ? `; con ${aTexto(ha)} ha, unos ${redondo(ha * precio, true)}.` : ".") +
(nacional ? " Elige su comunidad para afinar." : " Es una media de toda la comunidad: tu zona puede estar por encima o por debajo.");
};
const textoAuto = h("span", {});
const cConstruccion = campo("Parte de construcción (%)", inConstruccion, { ayuda: "Valor catastral de la construcción entre el total, también en el recibo del IBI." });
const cAuto = h("label", { clase: "campo completo", estilo: { flexDirection: "row", alignItems: "center", gap: "8px" } }, chkAuto, textoAuto);
const cProtegida = casilla(chkProtegida, "Es una vivienda protegida (VPO o similar), con precio de venta limitado.");
const camposInmueble = [
campo("Qué es", selClase), campo("Para qué lo usas", selUso),
campo("Dónde está (comunidad)", selComunidad, { ayuda: "Para el índice de precios del INE." }),
campo("Para reconocerlo", inReferencia, { ayuda: "Si tienes varios, la dirección o la referencia catastral." }),
campo("Precio de la escritura (€)", inPrecioCompra, { ayuda: "Sin los gastos de la compra. Es el punto de partida del valor estimado." }),
cAuto, cProtegida,
...camposVpo,
cMaximo,
campo("Su hipoteca", selHipoteca, { ayuda: deudas.length ? "Si la tiene, el préstamo con que la pagas: verás lo que es tuyo de verdad y sus intereses cuentan como gasto del alquiler."
: "Si tiene hipoteca, dala de alta antes como «Un préstamo que me han dado» y vincúlala aquí." }),
cIntereses,
h("details", { clase: "completo mas-opciones" }, h("summary", {}, "Datos para Hacienda (opcionales)"),
h("div", { clase: "campos" },
campo("Valor catastral (€)", inCatastral, { ayuda: "Está en el recibo del IBI." }),
cConstruccion,
cRevisado)),
cContrato, cReduccion, ...camposRenta, ...camposRustico,
];
const ajustarInmueble = () => {
const es = selTipo.value === "Inmueble";
for (const c of camposInmueble) c.classList.toggle("oculto", !es);
const protegida = es && chkProtegida.checked && selClase.value !== "terreno_rustico";
const metros = protegida && selTope.value === "metros";
cMaximo.classList.toggle("oculto", !protegida || metros);
for (const c of camposVpo) c.classList.toggle("oculto", !metros);
if (protegida) cTope.classList.remove("oculto");
const otro = metros && (vpoL.regimenes[selRegimen.value] || {}).vivienda == null;
for (const c of [cCoefViv, cCoefAnejo]) c.classList.toggle("oculto", !otro);
const viviendaAlquilada = es && selClase.value === "vivienda" && selUso.value === "alquilado";
cContrato.classList.toggle("oculto", !viviendaAlquilada || !modalidades.length);
cReduccion.classList.toggle("oculto", !viviendaAlquilada || !reducciones.length || selContrato.value === "temporada");
cRevisado.classList.toggle("oculto", !listas.imputa);
for (const c of camposRenta) c.classList.toggle("oculto", !es || selUso.value !== "alquilado");
const rustico = es && selClase.value === "terreno_rustico";
for (const c of camposRustico) c.classList.toggle("oculto", !rustico);
cCultivoDesde.classList.toggle("oculto", !rustico || !cultivosFicha.length || selCultivo.value === cultivoActual);
for (const c of [cMaximo, cProtegida, ...camposVpo]) if (rustico) c.classList.add("oculto");
cProtegida.classList.toggle("oculto", !es || rustico);
cConstruccion.classList.toggle("oculto", rustico);
textoAuto.textContent = protegida
? "Valorarla solo por su precio máximo de venta, que se pone al día cada 1 de enero con el módulo del año."
: rustico
? `Estimar su valor solo, con el precio de la tierra del MAPA para su cultivo y su comunidad (cada año; ahora hasta ${tierra.año || "el último publicado"}). Si la tasas, apunta el valor y se seguirá desde ahí.`
: "Estimar su valor solo, con el índice de precios de vivienda del INE de su comunidad (cada trimestre). Si lo tasas, apunta el valor y se seguirá desde ahí.";
pintarMedia();
pintarTope();
};
for (const x of [chkProtegida, selClase, selUso, selContrato, selCultivo, selComunidad, selTope, selRegimen]) x.addEventListener("change", ajustarInmueble);
inSuperficie.addEventListener("input", pintarMedia);
selClase.addEventListener("change", () => { if (!a) chkAuto.checked = ["vivienda", "terreno_rustico"].includes(selClase.value); });
const cInicio = campo("Fecha de inicio", inInicio);
const cUnidad = campo("Unidad", inUnidad, { ayuda: "participaciones, BTC, oz, ha..." });
const comprobacion = h("div", { clase: "ayuda completo" });
const ayudaCodigo = h("span", { clase: "ayuda" });
const cCodigo = h("label", { clase: "campo" }, "Código", inCodigo, ayudaCodigo);
const actualizarTipo = () => {
const t = selTipo.value;
cFinal.classList.toggle("oculto", t !== "Prestamo");
cVenc.classList.toggle("oculto", t !== "Prestamo");
const esDeuda = t === "Deuda";
for (const c of camposDeuda) c.classList.toggle("oculto", !esDeuda);
for (const c of [cUnidad, cValor, divComprobar, comprobacion]) c.classList.toggle("oculto", esDeuda || t === "Inmueble" || t === "RentaFija");
ajustarInmueble();
ajustarRF();
cInicio.firstChild.textContent = esDeuda ? "Fecha de estos datos" : "Fecha de inicio";
if (esDeuda && !inInicio.value) inInicio.value = hoyIso();
cCodigo.classList.toggle("oculto", esCuentaTipo() || t === "Deuda" || t === "Inmueble" || (t === "RentaFija" && selClaseRF.value === "deposito"));
if (esCuentaTipo() && (!inUnidad.value || inUnidad.value === "participaciones")) inUnidad.value = "€";
ayudaCodigo.textContent = ({ Fondo: "ISIN (ej. ES0000000000)", PlanPensiones: "ISIN; si no tiene, el código de Morningstar (ej. 0P0000ABCD) o el DGS (ej. N0000)", Cripto: "Identificador de CoinGecko (ej. bitcoin)",
Metal: "XAU (oro) o XAG (plata)", ETF: "Ticker de Yahoo (ej. IWDA.AS)", Accion: "Ticker de Yahoo (ej. IBE.MC, AAPL)",
RentaFija: "ISIN (ej. ES0000012M77): con él, el valor sale del mercado (BME) los días que cotiza" })[t] || "Sin precio automático: lo pondrás a mano.";
};
selTipo.addEventListener("change", actualizarTipo);
const busqueda = entrada({ placeholder: "Búscalo por su nombre, ISIN o ticker (ej. «vanguard global», IE00B4L5Y983, AAPL)" });
const resultados = h("div", { clase: "resultados" });
let temporizador = null, ultima = "";
busqueda.addEventListener("input", () => {
clearTimeout(temporizador);
const q = busqueda.value.trim();
if (q.length < 3) { resultados.replaceChildren(); return; }
temporizador = setTimeout(async () => {
ultima = q;
resultados.replaceChildren(h("p", { clase: "muy-suave" }, h("span", { clase: "girando" }), " Buscando…"));
try {
const r = await api(`/api/buscar?q=${encodeURIComponent(q)}`);
if (q !== ultima) return;
const lista = r.resultados.map((x) => h("button", { type: "button", clase: "resultado", onclick: (e) => {
for (const b of resultados.querySelectorAll(".resultado")) b.setAttribute("aria-pressed", "false");
e.currentTarget.setAttribute("aria-pressed", "true");
selTipo.value = x.tipo; inNombre.value = x.nombre; inCodigo.value = x.codigo; inUnidad.value = x.unidad || "participaciones";
actualizarTipo();
comprobacion.textContent = x.precio ? `Último precio: ${eur(x.precio)} (${fecha(x.fecha_precio)})` : "";
} },
h("span", {}, x.nombre), h("span", { clase: "num" }, x.precio ? eur(x.precio) : ""),
h("small", {}, `${TIPOS_ACTIVO[x.tipo]} · ${x.codigo}`), h("small", {}, x.fuente)));
resultados.replaceChildren(...(lista.length ? lista : [h("p", { clase: "muy-suave" }, "Sin resultados. Puedes rellenar los datos a mano.")]),
...(r.errores.length ? [h("p", { clase: "muy-suave" }, `No se pudo consultar: ${r.errores.join(", ")} (¿sin internet?).`)] : []));
} catch (e) { resultados.replaceChildren(h("p", { clase: "muy-suave" }, e.message)); }
}, 450);
});
const btnComprobar = h("button", { type: "button", clase: "btn", onclick: async () => {
btnComprobar.disabled = true;
comprobacion.textContent = "Consultando el precio…";
try {
const r = await api("/api/activo/comprobar", { tipo: selTipo.value, codigo: inCodigo.value, nombre: inNombre.value });
const res = r.resultado.resultado;
comprobacion.textContent = res ? `✓ Precio encontrado: ${eur(res.precio)} del ${fecha(res.fecha)} (${res.fuente}).` : `✗ No se encontró precio: ${r.resultado.mensaje || "revisa el código"}.`;
} catch (e) { comprobacion.textContent = e.message; }
btnComprobar.disabled = false;
} }, "Comprobar precio");
const divComprobar = h("div", { clase: "completo" }, btnComprobar);
actualizarTipo();
// Al dar de alta, primero se pregunta qué es, en palabras corrientes; los campos salen después
const pregunta = h("div", { clase: "que-paso" });
const cajaBuscador = h("div", { clase: "buscador" }, busqueda, resultados);
const cTipo = campo("Tipo", selTipo);
const cAlias = campo("Otros nombres para el asistente", inAlias, { completo: true, ayuda: "Separados por comas: cómo lo llamas tú al hablar con el asistente." });
const masOpciones = h("details", { clase: "completo mas-opciones", open: !!a }, h("summary", {}, "Más opciones"),
h("div", { clase: "campos" }, cUnidad, cAlias, cValor));
const campos = h("div", { clase: "campos", estilo: { marginTop: a ? "0" : "16px" } },
cTipo, cCodigo, campo("Nombre", inNombre, { completo: true }), cInicio,
cFinal, cVenc, ...camposDeuda, ...camposRF, ...camposInmueble, divComprobar, comprobacion, masOpciones);
const pintarPregunta = (elegida, yaPuesto = false) => {
campos.classList.toggle("sin-tipo", !elegida);
// El buscador, solo para lo que hay que encontrar: bitcoin ya viene con sus datos
cajaBuscador.classList.toggle("oculto", !elegida || yaPuesto || !SE_BUSCAN.includes(selTipo.value));
if (elegida) {
pregunta.replaceChildren(h("div", { clase: "paso-elegido" }, h("b", {}, elegida),
h("button", { type: "button", clase: "btn-enlace", onclick: () => pintarPregunta(null) }, "Cambiar")));
return;
}
const opcion = ([tipo, titulo, texto, puesto]) => h("button", { type: "button", clase: "opcion-paso", onclick: () => {
selTipo.value = tipo;
inNombre.value = (puesto && puesto.nombre) || ""; inCodigo.value = (puesto && puesto.codigo) || "";
inUnidad.value = (puesto && puesto.unidad) || "participaciones";
if (tipo === "Inmueble") { selClase.value = (puesto && puesto.clase) || "vivienda"; chkAuto.checked = ["vivienda", "terreno_rustico"].includes(selClase.value); }
busqueda.value = ""; resultados.replaceChildren(); comprobacion.textContent = "";
actualizarTipo();
pintarPregunta(titulo, !!puesto);
(puesto ? inNombre : SE_BUSCAN.includes(tipo) ? busqueda : inNombre).focus();
} }, h("b", {}, titulo), h("span", {}, texto));
pregunta.replaceChildren(h("div", { clase: "etiqueta-seccion" }, "¿Qué quieres añadir?"),
h("div", { clase: "opciones-paso" }, QUE_ANADIR.map(opcion)),
h("details", {}, h("summary", {}, "Otra cosa"), h("div", { clase: "opciones-paso" }, QUE_ANADIR_OTROS.map(opcion))));
};
if (!a) { cTipo.classList.add("nunca"); pintarPregunta(null); }
modal({
titulo: a ? "Editar inversión" : "Nueva inversión", ancho: !a,
cuerpo: h("div", {}, error, a ? null : pregunta, a ? null : cajaBuscador, campos),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: a ? "Guardar" : "Dar de alta", clase: "btn-primario", accion: async (c) => {
if (campos.classList.contains("sin-tipo")) { error.textContent = "Elige primero qué quieres añadir."; error.classList.remove("oculto"); return; }
const r = await guardarCon(null, error, () => api("/api/activo", {
id: a ? a.id : undefined, tipo: selTipo.value, nombre: inNombre.value, codigo: inCodigo.value, unidad: inUnidad.value, alias: inAlias.value,
fecha_inicio: inInicio.value || null, importe_final: aEuros(inFinal.value), vencimiento: inVenc.value || null,
capital: aEuros(inCapital.value), tin: aNumero(inTin.value), cuotas: aNumero(inCuotas.value), primera_cuota: inPrimera.value || null,
valoracion: chkValor.checked ? "total" : "unitaria",
renta_fija: selTipo.value === "RentaFija" ? {
clase: selClaseRF.value, nominal: aEuros(inNominal.value), interes: aNumero(inInteres.value),
vencimiento: inVencRF.value || null, pagos: Number(selPagos.value), emisor: inEmisor.value } : undefined,
inmueble: selTipo.value === "Inmueble" ? {
clase: selClase.value, uso: selUso.value, comunidad: selComunidad.value, referencia: inReferencia.value,
precio_compra: aEuros(inPrecioCompra.value), auto: chkAuto.checked, protegida: chkProtegida.checked,
precio_maximo: aEuros(inMaximo.value), valor_catastral: aEuros(inCatastral.value), hipoteca: selHipoteca.value || null,
tope: selTope.value, regimen: selRegimen.value, coef_vivienda: aNumero(inCoefViv.value), coef_anejo: aNumero(inCoefAnejo.value),
piezas: filasPiezas.map((f) => ({ nombre: f.nombre.value, m2: aNumero(f.m2.value), clase: f.clase.value, computa: aNumero(f.computa.value) })),
modulos: { ...(fi.modulos || {}), [vpoL.año]: aNumero(inModulo.value) },
intereses_hipoteca: aEuros(inIntereses.value),
contrato: selContrato.value, reduccion: selContrato.value === "temporada" ? 0 : aNumero(selReduccion.value),
catastral_revisado: chkRevisado.checked,
renta_mensual: aEuros(inRenta.value), dia_cobro: aNumero(inDiaCobro.value), renta_desde: inRentaDesde.value || null,
porcentaje_construccion: aNumero(inConstruccion.value),
superficie: aNumero(inSuperficie.value), cultivo: selClase.value === "terreno_rustico" ? selCultivo.value : undefined,
cultivo_desde: inCultivoDesde.value || null, origen: selOrigen.value } : undefined,
visible: a ? a.visible !== false : true }));
if (!r) return;
toast(a ? "Inversión actualizada." : `«${r.resultado.nombre}» dada de alta.`);
c();
await cargar();
if (!a && r.resultado.tipo === "Deuda") detalleDeuda(r.resultado.id);
else if (!a) formOperacion({ activo: r.resultado.id });
} },
],
});
}
function formCerrar(id) {
const a = activoPorId(id), f = filaResumen(id);
const error = h("div", { clase: "error-form oculto", role: "alert" });
const inFecha = h("input", { type: "date", value: hoyIso(), max: hoyIso() });
const quedan = f && Math.abs(f.cantidad || 0) > 1e-6;
const inImporte = entradaNum({ value: "" });
modal({
titulo: `Cerrar «${a.nombre}»`,
cuerpo: h("div", {}, error,
h("p", { clase: "nota-form", estilo: { marginTop: 0 } }, a.tipo === "Deuda" ? "Está pagado: pasará a «Cerradas» como terminado." : quedan
? `Aún figuran ${num(f.cantidad)} ${f.unidad}. Se registrará ${a.tipo === "Prestamo" ? "el cobro" : "la venta"} de todo por el importe que indiques, y la inversión pasará a «Cerradas» con su resultado.`
: "Ya no quedan participaciones: solo se marcará como cerrada (el dinero recibido ya está en sus ventas). Deja el importe vacío."),
h("div", { clase: "campos" }, campo("Fecha de cierre", inFecha),
quedan ? campo("Importe recibido (€)", inImporte, { ayuda: "Lo que entró en tu cuenta. 0 si no recibiste nada." }) : null)),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Cerrar inversión", clase: "btn-primario", accion: async (c) => {
const importe = quedan ? aEuros(inImporte.value) : 0;
if (quedan && (importe === null || Number.isNaN(importe))) { error.textContent = "Indica el importe recibido (0 si nada)."; error.classList.remove("oculto"); return; }
const r = await guardarCon(null, error, () => api("/api/activo/cerrar", { id, fecha: inFecha.value, importe }));
if (r) { toast("Inversión cerrada. La encontrarás en «Cerradas»."); c(); cargar(); }
} },
],
});
}
function formPrecio(id) {
const a = activoPorId(id);
const esSaldo = a.tipo === "CuentaTrading";
const esCuenta = a.tipo === "Cuenta";
const esDeuda = a.tipo === "Deuda";
const esValor = a.valoracion === "total";
const error = h("div", { clase: "error-form oculto", role: "alert" });
const inFecha = h("input", { type: "date", value: hoyIso(), max: hoyIso() });
const inValor = entradaNum({});
modal({
titulo: esDeuda ? `Pendiente de «${a.nombre}» según el banco` : esSaldo || esCuenta ? `Saldo de «${a.nombre}»` : esValor ? `Valor de «${a.nombre}»` : `Precio de «${a.nombre}»`,
cuerpo: h("div", {}, error,
esDeuda ? h("p", { clase: "nota-form", estilo: { marginTop: 0 } }, "El capital que queda por devolver según el recibo o la app del banco, después de la cuota de esa fecha. Corrige las pequeñas diferencias de redondeo.") : null,
h("div", { clase: "campos" }, campo("Fecha", inFecha),
campo(esDeuda ? "Capital pendiente (€)" : esSaldo || esCuenta ? "Saldo (€)" : esValor ? "Valor total de la posición (€)" : `Precio por ${a.unidad || "unidad"} (€)`, inValor))),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Guardar", clase: "btn-primario", accion: async (c) => {
const v = (esDeuda || esSaldo || esCuenta || esValor ? aEuros : aNumero)(inValor.value);
const r = await guardarCon(null, error, () => api("/api/precio", { activo: id, fecha: inFecha.value, valor: Number.isNaN(v) ? "x" : v, valor_total: esSaldo }));
if (r) { toast("Guardado."); c(); cargar(); }
} },
],
});
}
/* ── Automáticas ────────────────────────────────────────────────────────── */
/* ── Fiscal: lo que hay que declarar de cada ejercicio (FIFO) ───────────── */
// Se pide al servidor cada vez que se pinta: las operaciones pueden haber cambiado
function vistaFiscal() {
if (!estado.datos.movimientos.length) return pantallaVacia("Aquí verás lo que hay que declarar",
"Cuando apuntes operaciones, cada año: las ganancias y pérdidas de lo vendido, los dividendos e intereses, lo del alquiler y lo que te retuvieron, con las reglas de tu territorio y la casilla de la declaración donde va cada cosa.",
[["+ Operación", () => formOperacion({})]]);
const caja = h("div", {}, h("div", { clase: "tarjeta" }, h("p", { clase: "vacio" }, "Calculando…")));
const ejercicio = estado.filtros.ejercicio || "";
api(`/api/fiscal?ejercicio=${encodeURIComponent(ejercicio)}`)
.then((r) => {
if (!caja.isConnected) return;
// Arriba, lo esencial; el detalle de cada cosa, plegado hasta que se pida
const bloques = pintarFiscal(r).map((b, i) => (i === 0 || b.dataset.fijo ? b : plegar(b)));
bloques.slice(1).forEach((b) => b.classList.add("seccion"));
caja.replaceChildren(...bloques);
})
.catch((e) => caja.replaceChildren(h("div", { clase: "tarjeta" }, h("p", { clase: "vacio" }, e.message))));
return caja;
}
// Convierte una tarjeta con título en una sección que se abre y se cierra. Recuerda cuáles
// están abiertas, porque la pestaña se vuelve a pintar al cambiar un criterio.
const fiscalAbiertas = new Set();
function plegar(tarjeta) {
const titulo = tarjeta.querySelector("h2");
if (!titulo) return tarjeta;
const clave = titulo.firstChild.textContent.replace(/\d{4}/g, "").trim();
const seccion = h("details", { clase: "tarjeta plegable" }, h("summary", {}, titulo));
seccion.append(...tarjeta.childNodes);
seccion.open = fiscalAbiertas.has(clave);
seccion.addEventListener("toggle", () => { if (seccion.open) fiscalAbiertas.add(clave); else fiscalAbiertas.delete(clave); });
return seccion;
}
function pintarFiscal(r) {
const selAño = selector(r.ejercicios.map((a) => [String(a), String(a)]), String(r.ejercicio), {
"aria-label": "Ejercicio", onchange: (e) => { estado.filtros.ejercicio = e.target.value; pintar(); } });
const movDe = (id) => estado.datos.movimientos.find((m) => m.id === id);
const verMov = (id) => { const m = movDe(id); if (m) formOperacion({ movimiento: m }); };
const bloques = [];
bloques.push(h("div", { clase: "tarjeta" },
h("h2", {}, "Fiscal", h("span", { clase: "suave" }, "ejercicio"), selAño,
h("button", { type: "button", clase: "btn-enlace", title: "Todo el detalle de este ejercicio en una hoja de cálculo",
onclick: () => exportarFiscal(r.ejercicio) }, "Descargar en Excel")),
r.territorio_elegido ? null : h("div", { clase: "aviso aviso-apilado" }, h("span", {}, h("b", {}, "¿Dónde declaras? "),
"Estas cifras están hechas con las reglas de territorio común. Si declaras en Álava, Bizkaia, Gipuzkoa o Navarra, elígelo: cambian la cuota, las compensaciones y los recargos."),
selector(r.opciones_posibles.territorio.valores, r.opciones.territorio, { "aria-label": "Dónde declaras", onchange: async (e) => {
try { await api("/api/fiscal/criterios", { opciones: { territorio: e.target.value } }); pintar(); } catch (err) { toast(err.message, true); }
} }),
h("button", { type: "button", clase: "btn-enlace", onclick: async () => {
try { await api("/api/fiscal/criterios", { opciones: { territorio: r.opciones.territorio } }); pintar(); } catch (err) { toast(err.message, true); }
} }, "Es correcto")),
h("div", { clase: "en-pocas-palabras" }, h("h3", {}, `${r.ejercicio}, en pocas palabras`),
h("ul", {}, frasesFiscal(r).map((f) => h("li", {}, f))),
h("p", { clase: "muy-suave" }, "Es una ayuda para preparar tu declaración, no asesoría fiscal. Debajo tienes el detalle de cada cosa: pulsa un título para abrirlo.")),
h("div", { clase: "rejilla rejilla-kpi" },
kpi("Ganancias y pérdidas por ventas", eurSigno(r.total.computable),
`Ganancias ${eur(r.total.ganancias)} · pérdidas ${eur(r.total.perdidas)}`
+ (r.total.aplazadas ? ` · aplazadas por recompra ${eur(r.total.aplazadas)}` : "")
+ (r.total.integradas ? ` · de años anteriores ${eur(r.total.integradas)}` : ""), signo(r.total.computable)),
kpi("Dividendos, intereses y rentas", eur(r.por_concepto.reduce((s, c) => s + c.bruto, 0)),
`Retenido: ${eur(r.por_concepto.reduce((s, c) => s + c.retencion, 0))}`),
kpi("Minado o recibido sin pagar", eur(r.total_recibido),
r.recibido.length ? pl`${r.recibido.length} entrada(s), a su valor del día` : "Nada este año"),
kpi(`Cuota del ahorro · ${r.cuota.nombre_territorio}`, eur(r.cuota.cuota),
r.cuota.base ? `Estimación: ${eur(r.cuota.base)} al ${pct(r.cuota.tipo_medio).replace("+", "")} de media` : "Sin base este año"))));
if (r.tareas.length) {
const MAX = 12;
bloques.push(h("div", { clase: "tarjeta", "data-fijo": "1" },
h("h2", {}, "Antes de fiarte de estas cifras", h("span", { clase: "suave" }, pl`${r.tareas.length} cosa(s) por resolver`)),
r.tareas.slice(0, MAX).map((x) => h("div", { clase: "aviso" },
h("span", {}, x.clase === "error" ? h("b", {}, "Falta historial. ") : null,
`${fecha(x.fecha)} · ${nombreActivo(x.activo)}: ${x.texto}`),
movDe(x.movimiento) ? h("button", { type: "button", clase: "btn-enlace", onclick: () => verMov(x.movimiento) }, "Ver") : null)),
r.tareas.length > MAX ? h("p", { clase: "muy-suave" }, `…y ${r.tareas.length - MAX} más.`) : null));
}
// Transmisiones: las comisiones de red son muchas y de céntimos; se agrupan
const esComision = (x) => x.concepto.startsWith("Comisión");
const comisiones = r.transmisiones.filter(esComision);
const ventas = r.transmisiones.filter((x) => !esComision(x));
const filaLotes = (x) => h("tr", { clase: "oculto" }, h("td", { colspan: 7, clase: "muy-suave" },
"Sale de: " + x.lotes.map((l) => `${num(l.cantidad)} del ${fecha(l.fecha, true)} (${eur(l.coste)})`).join(" · ")
+ (x.avisos.includes("sin_fecha") ? " · viene de un traspaso de fuera: la fecha de compra original no está apuntada" : "")
+ (x.recompras.length ? " · Pérdida aplazada por recompra: "
+ x.recompras.map((c) => `${eur(c.importe)} por las ${num(c.cantidad)} compradas el ${fecha(c.fecha, true)}`).join(", ") : "")));
const filasVentas = ventas.flatMap((x) => {
const detalle = filaLotes(x);
return [h("tr", { clase: "clicable", title: "Ver de qué compras sale", onclick: () => detalle.classList.toggle("oculto") },
h("td", { clase: "num" }, fecha(x.fecha, true)),
h("td", {}, x.nombre, x.avisos.includes("sin_cubrir") ? h("span", { clase: "insignia aviso-i" }, "falta historial") : null,
x.avisos.includes("estimado") ? h("span", { clase: "insignia aviso-i" }, "estimada") : null),
h("td", {}, x.concepto),
h("td", { clase: "d num" }, num(x.cantidad)),
h("td", { clase: "d num" }, eur(x.valor_transmision)),
h("td", { clase: "d num" }, eur(x.valor_adquisicion)),
h("td", { clase: "d num " + signo(x.ganancia) }, eurSigno(x.ganancia),
x.aplazada ? h("div", { clase: "muy-suave" }, x.computable ? `aplazada ${eur(x.aplazada)}: cuenta ${eur(x.computable)}` : "aplazada entera") : null)), detalle];
});
const suma = (lista, k) => lista.reduce((s, x) => s + x[k], 0);
bloques.push(h("div", { clase: "tarjeta" },
h("h2", {}, "Ganancias y pérdidas por ventas", h("span", { clase: "suave" }, pl`${ventas.length} venta(s) o reembolso(s) · ${eurSigno(r.total.computable)}`)),
h("p", { clase: "nota-form" },
"Lo que ganaste o perdiste en cada venta, contado como lo pide la declaración: se entiende que vendes lo primero que compraste (FIFO). "
+ "Por eso estas cifras no coinciden con la «ganancia» del resumen, que usa el coste medio para decirte cómo va la inversión."),
r.por_clase.length ? h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, ["Clase", "Operaciones", "Valor de transmisión", "Valor de adquisición", "Ganancias", "Pérdidas", "Neto"]
.map((x, i) => h("th", { clase: i ? "d" : "" }, x)))),
h("tbody", {}, r.por_clase.map((c) => h("tr", {},
h("td", {}, c.clase), h("td", { clase: "d num" }, c.transmisiones),
h("td", { clase: "d num" }, eur(c.valor_transmision)), h("td", { clase: "d num" }, eur(c.valor_adquisicion)),
h("td", { clase: "d num" }, eur(c.ganancias)), h("td", { clase: "d num" }, eur(c.perdidas)),
h("td", { clase: "d num " + signo(c.neto) }, h("b", {}, eurSigno(c.neto)))))))) : h("p", { clase: "vacio" }, "No vendiste nada este año."),
r.total.aplazadas || r.total.integradas || r.pendientes.length ? h("div", { clase: "nota-form" },
h("b", {}, "Regla de la recompra. "),
r.total.aplazadas ? `De las pérdidas de este año, ${eur(r.total.aplazadas)} no se pueden restar todavía porque compraste el mismo valor cerca de la venta. ` : "",
r.integradas.length ? "Este año sí se restan " + r.integradas.map((x) =>
`${eur(-x.importe)} de la venta de ${x.nombre_venta} del ${fecha(x.fecha_venta, true)}`).join(", ") + ", porque has vendido lo que recompraste. " : "",
r.pendientes.length ? `Quedan esperando ${eur(r.pendientes.reduce((s, x) => s + x.pendiente, 0))} de `
+ [...new Set(r.pendientes.map((x) => x.nombre))].join(", ") + ": se restarán cuando vendas lo recomprado. " : "",
h("b", {}, `Lo que cuenta este año: ${eurSigno(r.total.computable)}.`)) : null,
ventas.length ? h("h4", {}, "Operación a operación") : null,
ventas.length ? h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, ["Fecha", "Inversión", "Qué", "Cantidad", "Valor de transmisión", "Valor de adquisición", "Ganancia"]
.map((x, i) => h("th", { clase: i > 2 ? "d" : "" }, x)))),
h("tbody", {}, filasVentas,
comisiones.length ? h("tr", {},
h("td", {}), h("td", {}, "Bitcoin"), h("td", {}, pl`${comisiones.length} comisión(es) de red`),
h("td", { clase: "d num" }, num(suma(comisiones, "cantidad"))),
h("td", { clase: "d num" }, eur(suma(comisiones, "valor_transmision"))),
h("td", { clase: "d num" }, eur(suma(comisiones, "valor_adquisicion"))),
h("td", { clase: "d num " + signo(suma(comisiones, "ganancia")) }, eurSigno(suma(comisiones, "ganancia")))) : null))) : null,
ventas.length ? h("p", { clase: "muy-suave" }, "Pulsa una fila para ver de qué compras sale.") : null));
if (r.por_concepto.length) {
bloques.push(h("div", { clase: "tarjeta" },
h("h2", {}, "Dividendos, intereses y rentas cobradas", h("span", { clase: "suave" }, eur(r.por_concepto.reduce((t, x) => t + x.bruto, 0)))),
h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, ["Concepto", "Bruto", "Retención", "Cobrado"].map((x, i) => h("th", { clase: i ? "d" : "" }, x)))),
h("tbody", {}, r.por_concepto.map((c) => h("tr", {}, h("td", {}, c.concepto),
h("td", { clase: "d num" }, eur(c.bruto)), h("td", { clase: "d num" }, eur(c.retencion)),
h("td", { clase: "d num" }, eur(c.neto))))))),
h("p", { clase: "muy-suave" }, "El bruto es lo cobrado más la retención que apuntaste. Si no apuntas la retención, aquí sale lo cobrado como bruto.")));
}
const c = r.cuota;
const linea = (texto, valor, clase = "", nota = null) => h("tr", {}, h("td", {}, texto, nota ? h("div", { clase: "muy-suave" }, nota) : null),
h("td", { clase: "d num " + clase }, valor));
// Cada actividad económica (la minería): ingresos menos gastos, para la parte general
const marcarAparato = async (id, si) => {
try { await api("/api/fiscal/amortizable", { ids: [id], si }); await cargar(); } catch (err) { toast(err.message, true); }
};
for (const a of r.actividades) {
const boton = (id, si) => h("button", { type: "button", clase: "btn-enlace", onclick: () => marcarAparato(id, si) },
si ? "Es un aparato" : "No es un aparato");
bloques.push(h("div", { clase: "tarjeta" },
h("h2", {}, `${a.nombre}: rendimiento de la actividad`, h("span", { clase: "suave" }, `parte general · ${eurSigno(a.rendimiento)}`)),
a.verificado ? null : h("div", { clase: "aviso" }, h("span", {}, h("b", {}, "Reglas sin comprobar. "), a.nota)),
h("div", { clase: "tabla-marco" }, h("table", {}, h("tbody", {},
linea("Ingresos: lo recibido, a su valor de ese día", eur(a.ingresos), "", pl`${a.entradas} entrada(s), ${num(a.cantidad)} en total`),
linea("Gastos del año", eur(-a.gastos), "", a.corrientes.length ? pl`${a.corrientes.length} apunte(s), ya restado lo devuelto` : "Ninguno apuntado en esta actividad"),
a.amortiza
? linea("Amortización de los aparatos", eur(-a.amortizacion), "",
a.aparatos.length ? `${a.tabla.nombre}: ${a.tabla.coeficiente} % al año como mucho (${a.citas.amortizacion})`
: "No hay ningún gasto marcado como aparato")
: linea("Amortización de los aparatos", "—", "",
(a.aparatos_sin_amortizar ? pl`${a.aparatos_sin_amortizar} aparato(s) apuntado(s). ` : "")
+ `No se restan aparte: los cubre la reducción de abajo (${a.citas.amortizacion}).`),
a.reduccion ? linea(`${a.texto_reduccion}, de la estimación directa simplificada`, eur(-a.reduccion), "", a.citas.reduccion) : null,
linea(h("b", {}, "Rendimiento neto de la actividad"), h("b", {}, eurSigno(a.rendimiento)), signo(a.rendimiento),
a.rendimiento < 0 ? (a.texto_negativo || `Al ser negativo, resta del resto de tu parte general de este año (${a.citas.general}).`) : null)))),
a.aparatos.length ? h("h4", {}, "Aparatos") : null,
a.aparatos.length ? h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, ["Comprado", "Qué", "Costó", "Amortizado antes", "Este año", "Queda", ""].map((x, i) => h("th", { clase: i > 1 ? "d" : "" }, x)))),
h("tbody", {}, a.aparatos.map((x) => h("tr", {}, h("td", { clase: "num" }, fecha(x.fecha, true)),
h("td", {}, x.notas || "Aparato", x.estimado ? h("span", { clase: "insignia aviso-i" }, "estimado") : null),
h("td", { clase: "d num" }, eur(x.coste)), h("td", { clase: "d num" }, eur(x.antes)),
h("td", { clase: "d num" }, eur(x.amortizacion)), h("td", { clase: "d num" }, eur(x.pendiente)),
h("td", { clase: "d" }, boton(x.movimiento, false))))))) : null,
a.corrientes.length ? h("details", {}, h("summary", {}, `Los ${a.corrientes.length} gastos del año, uno a uno`),
h("div", { clase: "tabla-marco" }, h("table", {}, h("tbody", {}, a.corrientes.map((x) => h("tr", {},
h("td", { clase: "num" }, fecha(x.fecha, true)),
h("td", {}, x.notas || (x.importe < 0 ? "Devolución" : "Gasto"), x.estimado ? h("span", { clase: "insignia aviso-i" }, "estimado") : null),
h("td", { clase: "d num" }, eur(x.importe)), h("td", { clase: "d" }, boton(x.movimiento, true)))))))) : null,
h("p", { clase: "muy-suave" },
"Minar es una actividad económica: lo minado es un ingreso por lo que valía al recibirlo, y se le restan sus gastos "
+ `(${a.citas.actividad}). Cuentan las operaciones que llevan la actividad «${a.nombre}». La compra de un minero no es un gasto del año: `
+ "márcala como aparato y se resta poco a poco. Aquí no sale la cuota, porque la parte general depende de lo demás que ganes "
+ "(tu trabajo), que la cartera no conoce: esta cifra se suma a eso. Calculado en estimación directa simplificada; no incluye "
+ "cuotas de autónomos ni otros gastos que no estén apuntados."
+ (r.opciones.sin_desembolso === "cero" ? " Tienes elegido que no declaraste lo minado: esto es lo que habría salido de declararlo." : ""))));
}
// El alquiler de cada inmueble, con las reglas del territorio
for (const x of r.alquileres || []) {
const g = x.gastos, citas = x.citas || {};
const vasco = x.bonificacion > 0;
bloques.push(h("div", { clase: "tarjeta" },
h("h2", {}, `${x.nombre}: alquiler`, h("span", { clase: "suave" },
`${x.base === "ahorro" ? "base del ahorro" : "parte general"} · ${eurSigno(x.rendimiento)}`)),
x.verificado ? null : h("div", { clase: "aviso" }, h("span", {}, h("b", {}, "Reglas sin comprobar para este año. "), x.nota)),
...(x.avisos || []).map((t) => h("div", { clase: "aviso" }, h("span", {}, t))),
h("div", { clase: "tabla-marco" }, h("table", {}, h("tbody", {},
linea("Rentas del año (cobradas o no)", eur(x.ingresos), "", x.texto_modalidad),
vasco ? linea("Bonificación sobre lo cobrado", eur(-x.bonificacion), "", citas.bonificacion) : null,
g.intereses ? linea("Intereses del préstamo", eur(-g.intereses), "", "Los de la hipoteca vinculada salen solos de su cuadro.") : null,
!vasco && g.reparacion ? linea("Reparación y conservación", eur(-g.reparacion)) : null,
!vasco && g.otro ? linea("IBI, comunidad, seguro y otros", eur(-g.otro)) : null,
g.seguro_impago ? linea("Seguro de impago", eur(-g.seguro_impago)) : null,
g.dudoso_cobro ? linea("Rentas impagadas (saldo de dudoso cobro)", eur(-g.dudoso_cobro), "",
"Más de seis meses sin cobrar: se toma la fecha de cada renta como la primera gestión de cobro (art. 13 del Reglamento estatal; en Navarra y el País Vasco, sin comprobar).") : null,
!vasco ? linea("Amortización", eur(-x.amortizacion), "", citas.amortizacion) : null,
x.pasos.map((t) => h("tr", {}, h("td", { colspan: 2, clase: "suave" }, "↳ " + t))),
linea("Rendimiento neto", eurSigno(x.previo), signo(x.previo), x.deducido ? `Gastos que se restan: ${eur(x.deducido)}` : null),
x.reduccion ? linea(`Reducción del ${x.porcentaje_reduccion} %`, eur(-x.reduccion), "", citas.reduccion) : null,
linea(h("b", {}, x.base === "ahorro" ? "A la base del ahorro" : "A la parte general"), h("b", {}, eurSigno(x.rendimiento)), signo(x.rendimiento),
citas.base)))),
x.pendiente_siguientes ? h("p", { clase: "nota-form" }, `Quedan ${eur(x.pendiente_siguientes)} de gastos para restar en los próximos años.`) : null,
h("p", { clase: "muy-suave" }, x.base === "ahorro"
? "Este rendimiento ya va en «Lo que saldría a pagar por el ahorro»."
: "Se suma a tu parte general (tu trabajo y lo demás), que la cartera no conoce: por eso aquí no sale la cuota.")));
}
for (const v of r.ventas_inmuebles || []) {
bloques.push(h("div", { clase: "tarjeta" },
h("h2", {}, `${v.nombre}: venta`, h("span", { clase: "suave" }, `ganancia patrimonial · ${eurSigno(v.ganancia)}`)),
...(v.avisos || []).map((t) => h("div", { clase: "aviso" }, h("span", {}, t))),
h("div", { clase: "tabla-marco" }, h("table", {}, h("tbody", {},
linea("Lo que te pagaron (sin los gastos de la venta)", eur(v.valor_transmision), "", fecha(v.fecha)),
linea("Lo que te costó, con gastos y mejoras", eur(-v.pagado)),
v.actualizado !== v.pagado ? linea("Actualización con coeficientes", eur(-(v.actualizado - v.pagado)), "", v.cita) : null,
v.amortizacion ? linea("Amortizaciones ya restadas por el alquiler", eur(v.amortizacion)) : null,
v.pasos.map((t) => h("tr", {}, h("td", { colspan: 2, clase: "suave" }, "↳ " + t))),
linea(h("b", {}, "Ganancia o pérdida (base del ahorro)"), h("b", {}, eurSigno(v.ganancia)), signo(v.ganancia))))),
h("p", { clase: "muy-suave" }, "Ya va en «Lo que saldría a pagar por el ahorro», junto a tus otras ventas.")));
}
if ((r.imputaciones || []).length) {
bloques.push(h("div", { clase: "tarjeta" },
h("h2", {}, "Renta imputada por inmuebles vacíos", h("span", { clase: "suave" }, "parte general")),
h("div", { clase: "tabla-marco" }, h("table", {}, h("tbody", {}, r.imputaciones.map((x) => linea(
x.nombre, x.aviso ? "—" : eur(x.renta), "",
x.aviso || `${eur(x.valor_catastral)} de valor catastral al ${String(x.porcentaje).replace(".", ",")} %, ${x.dias} días · ${x.cita}`))))),
h("p", { clase: "muy-suave" }, "Lo que no es tu vivienda habitual ni está alquilado tributa como si te diera una renta. "
+ (r.imputaciones.some((x) => !x.verificado) ? r.imputaciones.find((x) => !x.verificado).nota : ""))));
}
// Las cuentas de bróker o trading: su resultado sale del informe fiscal del bróker
if (r.bróker.length) {
const fila = (cta) => {
const caja = entradaNum({ value: cta.resultado === null ? "" : String(cta.resultado).replace(".", ","), placeholder: "p. ej. −350,20",
"aria-label": `Resultado de ${cta.nombre} en ${r.ejercicio}` });
const guardar = async (valor) => {
try { await api("/api/fiscal/trading", { ejercicio: r.ejercicio, activo: cta.activo, resultado: valor }); pintar(); }
catch (err) { toast(err.message, true); }
};
return h("tr", {}, h("td", {}, cta.nombre),
h("td", { clase: "d" }, caja),
h("td", { clase: "d" }, h("button", { type: "button", clase: "btn-enlace", onclick: () => guardar(caja.value.trim()) }, "Guardar"),
cta.resultado === null ? null : h("button", { type: "button", clase: "btn-enlace", onclick: () => guardar("") }, "Quitar")));
};
const faltan = r.bróker.filter((x) => x.resultado === null).length;
bloques.push(h("div", { clase: "tarjeta" },
h("h2", {}, "Cuentas de bróker o trading", h("span", { clase: "suave" }, "van con las ganancias y pérdidas")),
h("p", { clase: "nota-form" }, `Lo que ganaste o perdiste dentro de la cuenta en ${r.ejercicio} (futuros, forex, CFD, opciones, acciones…) `
+ "no se ve desde aquí: cópialo del informe fiscal anual de tu bróker, con un menos delante si fue pérdida. Entra en la cuota del ahorro "
+ "junto a tus ventas."),
faltan ? h("div", { clase: "aviso" }, h("span", {}, pl`Falta el resultado de ${faltan} cuenta(s): mientras tanto no entran en la cuota.`)) : null,
h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, ["Cuenta", `Resultado de ${r.ejercicio} (€)`, ""].map((x, i) => h("th", { clase: i ? "d" : "" }, x)))),
h("tbody", {}, r.bróker.map(fila)))),
h("p", { clase: "muy-suave" }, "Si el bróker es extranjero no te retiene nada: lo declaras tú todo. Y si lo que tienes fuera de España "
+ "en cuentas o en valores pasa de 50.000 € a 31 de diciembre, hay que informar de ello aparte (modelo 720 en territorio común, o el "
+ "equivalente de tu Hacienda foral; las criptomonedas en plataformas extranjeras, en el 721).")));
}
// La liquidación de la parte del ahorro, paso a paso
bloques.push(h("div", { clase: "tarjeta" },
h("h2", {}, `Lo que saldría a pagar por el ahorro`, h("span", { clase: "suave" }, `${c.nombre_territorio} · ${eur(c.cuota)}`)),
c.verificado ? null : h("div", { clase: "aviso" }, h("span", {}, h("b", {}, "Reglas sin comprobar para este año. "), c.nota)),
h("div", { clase: "tabla-marco" }, h("table", {}, h("tbody", {},
linea(c.alquiler_ahorro ? "Dividendos, intereses y alquiler de vivienda" : "Dividendos e intereses (bruto)", eur(c.rendimientos),
"", c.alquiler_ahorro ? `El alquiler aporta ${eur(c.alquiler_ahorro)}.` : null),
linea(r.bróker.some((x) => x.resultado !== null) ? "Ganancias y pérdidas (ventas y bróker)" : "Ganancias y pérdidas por ventas",
eurSigno(c.ganancias), signo(c.ganancias),
r.total.aplazadas || r.total.integradas ? "Ya con la regla de la recompra." : null),
c.pasos.map((x) => h("tr", {}, h("td", { colspan: 2, clase: "suave" }, "↳ " + x.texto,
h("span", { clase: "muy-suave" }, ` (${x.cita})`)))),
linea(h("b", {}, "Base del ahorro"), h("b", {}, eur(c.base)), "",
`Rendimientos ${eur(Math.max(c.saldo_rendimientos, 0))} + ganancias ${eur(Math.max(c.saldo_ganancias, 0))}`),
c.tramos.map((x) => linea(h("span", { clase: "suave" }, ` ${eur(x.base)} al ${x.tipo} %`), eur(x.cuota), "suave")),
linea(h("b", {}, "Cuota del ahorro"), h("b", {}, eur(c.cuota)), "", c.citas.escala),
c.retenciones ? linea("Ya te retuvieron", eur(-c.retenciones)) : null,
c.retenciones ? linea(h("b", {}, c.resultado >= 0 ? "Quedaría por pagar" : "Te devolverían"), h("b", {}, eur(Math.abs(c.resultado)))) : null))),
c.pendientes.length ? h("p", { clase: "nota-form" }, h("b", {}, "Te queda para restar en los próximos años: "),
c.pendientes.map((x) => `${eur(x.importe)} de ${x.clase} de ${x.ejercicio} (hasta ${x.hasta})`).join("; ") + ".") : null,
c.caducan.length ? h("div", { clase: "aviso" }, h("span", {}, "Este es el último año para usar: "
+ c.caducan.map((x) => `${eur(x.importe)} de ${x.clase} de ${x.ejercicio}`).join("; ") + ".")) : null,
h("p", { clase: "muy-suave" },
"Es una estimación de UNA parte de tu declaración, hecha solo con lo que hay en la cartera. No cuenta lo que tengas fuera de ella, "
+ "ni la parte general (tu trabajo, y lo minado, que es una actividad económica), ni las deducciones. Las pérdidas de años anteriores "
+ "se arrastran como si siempre hubieras declarado lo que sale aquí: si declaraste otra cosa, mandan tus declaraciones."
+ (c.fuera_del_ahorro.length ? ` Fuera del ahorro: ${c.fuera_del_ahorro.map((x) => `${x.concepto} (${eur(x.bruto)})`).join(", ")}, que van a la parte general.`
+ (c.nota_fuera_del_ahorro ? ` ${c.nota_fuera_del_ahorro}` : "") : "")
+ ` Reglas: ${c.norma}, consultado el ${fecha(c.consultado)}.`)));
if (r.recibido.length) {
const porGrupo = {};
for (const x of r.recibido) {
const k = (x.tipo === "Mineria" ? "Minado" : "Recibido sin pagar") + (x.actividad ? ` · ${x.actividad}` : "") + ` · ${x.nombre}`;
porGrupo[k] = porGrupo[k] || { entradas: 0, cantidad: 0, valor: 0 };
porGrupo[k].entradas += 1; porGrupo[k].cantidad += x.cantidad; porGrupo[k].valor += x.valor;
}
const gastos = suma(r.gastos, "importe");
bloques.push(h("div", { clase: "tarjeta" },
h("h2", {}, "Minado o recibido sin pagar", h("span", { clase: "suave" }, eur(r.total_recibido))),
h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, ["Qué", "Entradas", "Cantidad", "Valor al recibirlo"].map((x, i) => h("th", { clase: i ? "d" : "" }, x)))),
h("tbody", {}, Object.entries(porGrupo).map(([k, g]) => h("tr", {}, h("td", {}, k),
h("td", { clase: "d num" }, g.entradas), h("td", { clase: "d num" }, num(g.cantidad)),
h("td", { clase: "d num" }, eur(g.valor))))))),
h("p", { clase: "muy-suave" }, (r.opciones.sin_desembolso === "cero"
? "Has elegido que no lo declaraste al recibirlo: aquí se ve lo que valía cada entrada ese día, pero su coste es cero y tributará entero cuando se venda. "
+ "Lo que correspondía era declararlo como ingreso al recibirlo (lo minado, como actividad económica)."
: "Cada entrada, a su valor de mercado de ese día: es el ingreso que corresponde declarar al recibirla (lo minado, "
+ "como actividad económica) y pasa a ser su coste para cuando se venda.")
+ (r.gastos.length ? ` Gastos apuntados este año (hosting, etc.): ${eur(gastos)}.` : ""))));
}
if (r.existencias.length) {
bloques.push(h("div", { clase: "tarjeta" },
h("h2", {}, `Lo que tenías a 31 de diciembre de ${r.ejercicio}`),
h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, ["Qué", "Clase", "Cantidad", "Coste (FIFO)", "Valor ese día", "Compras vivas"].map((x, i) => h("th", { clase: i > 1 ? "d" : "" }, x)))),
h("tbody", {}, r.existencias.map((x) => h("tr", {}, h("td", {}, x.nombre), h("td", { clase: "suave" }, x.clase),
h("td", { clase: "d num" }, num(x.cantidad)), h("td", { clase: "d num" }, eur(x.coste)),
h("td", { clase: "d num" }, x.valor === null ? "sin precio" : eur(x.valor)),
h("td", { clase: "d num", title: `La más antigua, del ${fecha(x.primer_lote)}` }, x.lotes)))))),
h("p", { clase: "muy-suave" }, "El valor a 31 de diciembre es el que se usa para el Impuesto sobre el Patrimonio y para saber si toca informar de lo que hay fuera de España.")));
}
bloques.push(tarjetaRegularizacion());
const fuera = r.fuera.filter((x) => x.cobrado);
const elegir = Object.entries(r.opciones_posibles).map(([clave, o]) => campo(o.titulo,
selector(o.valores, r.opciones[clave], { onchange: async (e) => {
try { await api("/api/fiscal/criterios", { opciones: { [clave]: e.target.value } }); pintar(); } catch (err) { toast(err.message, true); }
} })));
bloques.push(h("div", { clase: "tarjeta" },
h("h2", {}, "Con qué criterios está hecho"),
h("p", { clase: "nota-form" }, "Estos se pueden elegir; al cambiarlos se recalcula todo, también los años anteriores. "
+ "Se guardan con tus ajustes y viajan en la copia cifrada."),
h("div", { clase: "campos" }, elegir),
h("ul", {}, r.criterios.map((c) => h("li", {}, h("b", {}, c.titulo + ". "), c.texto,
c.fuente ? h("span", { clase: "muy-suave" }, ` (${c.fuente})`) : null))),
fuera.length ? h("p", { clase: "nota-form" }, "No entran en este cálculo: "
+ fuera.map((x) => `${x.nombre} (cobraste ${eur(x.cobrado)}; ${x.motivo})`).join("; ") + ".") : null));
return bloques;
}
function vistaAutomaticas() {
const d = estado.datos;
return h("div", { clase: "tarjeta" },
h("h2", {}, "Operaciones automáticas", h("span", { clase: "suave" }, "se crean solas al abrir la cartera"),
h("button", { type: "button", clase: "btn", estilo: { marginLeft: "auto" }, onclick: () => formRecurrente() }, "+ Nueva")),
h("p", { clase: "nota-form" }, "Gastos en dólares: se pasan a euros con el cambio de ese día. Aportaciones: las participaciones se estiman con el valor liquidativo y quedan marcadas «estimada» hasta que las confirmes con el extracto."),
d.recurrentes.length ? h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, ["Inversión", "Qué", "Importe", "Día", "Desde", "Hasta", "Estado", ""].map((t, i) => h("th", { clase: i === 2 ? "d" : "" }, t)))),
h("tbody", {}, d.recurrentes.map((r) => h("tr", {},
h("td", {}, nombreActivo(r.activo)),
h("td", {}, ({ Gasto: "Gasto", Dividendo: "Cobro" })[r.tipo] || "Aportación", r.notas ? h("div", { clase: "muy-suave" }, r.notas) : null),
h("td", { clase: "d num" }, `${num(r.importe)} ${r.moneda === "USD" ? "$" : "€"}`),
h("td", { clase: "num" }, `día ${r.dia}`),
h("td", { clase: "num" }, fecha(r.desde, true)),
h("td", { clase: "num" }, r.hasta ? fecha(r.hasta, true) : "—"),
h("td", {}, r.pausado ? h("span", { clase: "insignia" }, "pausada") : h("span", { clase: "insignia info-i" }, "activa")),
h("td", { clase: "d" }, h("button", { type: "button", clase: "btn-icono", "aria-label": "Editar", onclick: () => formRecurrente(r) }, "✎"),
h("button", { type: "button", clase: "btn-icono", "aria-label": "Borrar", onclick: async () => {
if (await confirmar("Se deja de crear esta operación. Las ya creadas se conservan.", "Borrar", true)) {
try { await api("/api/recurrente/borrar", { id: r.id }); cargar(); } catch (e) { toast(e.message, true); }
}
} }, "🗑"))))))) : h("p", { clase: "vacio" }, "Aún no tienes ninguna. Sirven para lo que se repite cada mes —una aportación a un fondo, una cuota— y así no hay que apuntarlo a mano."));
}
function formRecurrente(r = null) {
const d = estado.datos;
const abiertos = d.activos.filter((a) => a.estado !== "cerrada");
if (!abiertos.length) { formActivo(); return; }
const error = h("div", { clase: "error-form oculto", role: "alert" });
const selActivo = selector(abiertos.map((a) => [a.id, a.nombre]), r ? r.activo : abiertos[0].id);
const selTipo = selector([["Compra", "Aportación / compra"], ["Gasto", "Gasto (hosting, custodia...)"], ["Dividendo", "Cobro (un alquiler, intereses…)"]], r ? r.tipo : "Compra");
const inImporte = entradaNum({ value: r ? String(r.importe).replace(".", ",") : "" });
const selMoneda = selector([["EUR", "€"], ["USD", "$"]], r ? r.moneda : "EUR");
const inDia = h("input", { type: "number", min: 1, max: 28, value: r ? r.dia : 1 });
const inDesde = h("input", { type: "date", value: r ? r.desde : hoyIso() });
const inHasta = h("input", { type: "date", value: r && r.hasta ? r.hasta : "" });
const inNotas = entrada({ value: r ? r.notas || "" : "", maxlength: 200 });
const chkPausa = h("input", { type: "checkbox", checked: r ? !!r.pausado : false });
const actividadRec = campoActividad(r ? r.actividad : null);
modal({
titulo: r ? "Editar operación automática" : "Nueva operación automática",
cuerpo: h("div", {}, error, h("div", { clase: "campos" },
campo("Inversión", selActivo, { completo: true }), campo("Tipo", selTipo), campo("Día del mes", inDia),
campo("Importe", inImporte), campo("Moneda", selMoneda),
campo("Desde", inDesde, { ayuda: "Se crearán también las de fechas pasadas desde aquí." }), campo("Hasta (opcional)", inHasta),
campo("Descripción", inNotas, { completo: true }),
campo("Actividad", actividadRec.control, { completo: true,
ayuda: "Opcional: las operaciones que se creen a partir de ahora la heredan." }),
h("label", { clase: "suave completo", estilo: { display: "flex", gap: "6px", alignItems: "center" } }, chkPausa, "En pausa"))),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Guardar", clase: "btn-primario", accion: async (c) => {
const ok = await guardarCon(null, error, () => api("/api/recurrente", {
id: r ? r.id : undefined, activo: selActivo.value, tipo: selTipo.value, importe: aEuros(inImporte.value),
moneda: selMoneda.value, dia: Number(inDia.value), desde: inDesde.value, hasta: inHasta.value || null,
notas: inNotas.value, pausado: chkPausa.checked,
actividad: actividadRec.input.value.trim() }));
if (ok) { toast("Guardada. Se aplicará al actualizar precios."); c(); cargar(); }
} },
],
});
}
/* ── Asistente: frases normales → operaciones propuestas ─────────────────── */
const EJEMPLOS = [
"ayer aporté 100 € al plan",
"he minado 12.345 sats",
"compré 0,0012 btc por 85 € el 3 de octubre",
"pagué 50 $ del hosting el día 1",
"vendí 10 participaciones del fondo b a 250 €",
];
function asistente(textoInicial = "") {
estado.chat = estado.chat || [];
const log = h("div", { clase: "chat-log", "aria-live": "polite" });
const entradaTexto = h("textarea", { rows: 2, placeholder: "Cuéntame qué has hecho… (Intro para enviar, Mayús+Intro para otra línea)", "aria-label": "Qué has hecho" });
const btnEnviar = h("button", { type: "button", clase: "btn btn-primario" }, "Enviar");
const pintarLog = () => {
log.replaceChildren(...(estado.chat.length ? estado.chat.map((x) => x.nodo) : [
h("div", { clase: "burbuja asistente" },
h("p", {}, "Dime con tus palabras qué operación has hecho y te la preparo para que la revises. Nada se guarda hasta que pulses «Aceptar»."),
h("div", { clase: "chips" }, EJEMPLOS.map((e) => h("button", { type: "button", clase: "chip", onclick: () => { entradaTexto.value = e; entradaTexto.focus(); } }, e)))),
]));
log.scrollTop = log.scrollHeight;
};
async function enviar(texto, activoForzado = null, activoNuevo = null) {
texto = texto.trim();
if (!texto) return;
if (!activoForzado && !activoNuevo) estado.chat.push({ nodo: h("div", { clase: "burbuja usuario" }, texto) });
const pensando = h("div", { clase: "burbuja asistente" }, h("span", { clase: "girando" }), " Pensando… (si es un activo nuevo, lo busco en internet)");
estado.chat.push({ nodo: pensando });
pintarLog();
btnEnviar.disabled = true;
try {
if (activoNuevo) pensando.replaceChildren(h("span", { clase: "girando" }), ` Consultando el precio de ${activoNuevo.nombre}…`);
const r = await api("/api/interpretar", { texto, activo: activoForzado, activo_nuevo: activoNuevo });
pensando.replaceChildren(...r.resultado.map((p) => tarjetaPropuesta(p,
(idActivo) => enviar(p.frase, idActivo), (nuevo) => enviar(p.frase, null, nuevo))));
if (!r.resultado.length) pensando.replaceChildren("No he entendido nada que parezca una operación. Prueba con algo como «ayer compré 0,001 btc por 90 €».");
} catch (e) {
pensando.replaceChildren(e.message);
} finally {
btnEnviar.disabled = false;
log.scrollTop = log.scrollHeight;
}
}
const mandar = () => { const t = entradaTexto.value; entradaTexto.value = ""; enviar(t); entradaTexto.focus(); };
btnEnviar.addEventListener("click", mandar);
entradaTexto.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); mandar(); } });
modal({
titulo: "Asistente", ancho: true,
cuerpo: h("div", { clase: "chat" }, log, h("div", { clase: "chat-entrada" }, entradaTexto, btnEnviar)),
});
pintarLog();
if (textoInicial) enviar(textoInicial);
else setTimeout(() => entradaTexto.focus(), 50);
}
function tarjetaPropuesta(p, reintentarCon, reintentarConNuevo) {
const acciones = h("div", { clase: "propuesta-acciones" });
const tarjeta = h("div", { clase: "propuesta" + (p.completa ? "" : " incompleta") },
h("div", { clase: "propuesta-frase" }, `«${p.frase}»`),
p.descripcion ? h("div", { clase: "propuesta-texto" }, p.descripcion) : null,
p.avisos.length ? h("ul", { clase: "propuesta-avisos" }, p.avisos.map((a) => h("li", {}, a))) : null,
(() => {
const faltan = p.nuevos && p.nuevos.length ? p.faltan.filter((f) => f !== "de qué activo se trata") : p.faltan;
return faltan.length ? h("div", { clase: "propuesta-faltan" }, "Me falta: ", faltan.join("; "), ".") : null;
})(),
p.nuevos && p.nuevos.length ? h("div", { clase: "propuesta-nuevos" },
h("div", {}, `No lo tienes en tu cartera. He buscado «${p.busqueda}»: ¿es alguno de estos? Se dará de alta al aceptar la compra.`),
h("div", { clase: "chips" }, p.nuevos.map((x) => h("button", { type: "button", clase: "chip chip-nuevo", onclick: () => reintentarConNuevo({ ...x, alias: p.busqueda }) },
h("b", {}, x.nombre), ` · ${x.codigo} · ${TIPOS_ACTIVO[x.tipo] || x.tipo}`)))) : null,
p.nuevos && !p.nuevos.length && p.busqueda ? h("div", { clase: "propuesta-faltan" }, `Tampoco encuentro «${p.busqueda}» en internet. Puedes darlo de alta a mano.`) : null,
p.opciones.length ? (() => {
const chips = h("div", { clase: "chips" }, p.opciones.map((o) => h("button", { type: "button", clase: "chip", onclick: () => reintentarCon(o.id) }, o.nombre)));
return p.nuevos && p.nuevos.length
? h("details", {}, h("summary", { clase: "muy-suave" }, "…o es uno de los que ya tienes"), chips)
: h("div", {}, h("div", { clase: "muy-suave" }, "¿Cuál de tus activos?"), chips);
})() : null,
acciones);
const hecho = (texto) => acciones.replaceChildren(h("span", { clase: "propuesta-hecha" }, texto));
const descartar = h("button", { type: "button", clase: "btn", onclick: () => hecho("Descartada.") }, "Descartar");
if (p.completa) {
const aceptar = h("button", { type: "button", clase: "btn btn-primario", onclick: async () => {
aceptar.disabled = true;
try {
if (p.accion === "saldo") await api("/api/precio", p.saldo);
else if (p.accion === "cierre") await api("/api/activo/cerrar", p.cierre);
else if (p.accion === "alta") await api("/api/alta_y_operacion", { activo: p.activo_nuevo, movimiento: p.movimiento });
else await api("/api/movimiento", p.movimiento);
hecho(p.accion === "cierre" ? "✓ Guardada y la inversión pasa a «Cerradas»."
: p.accion === "saldo" ? "✓ Saldo actualizado."
: p.accion === "alta" ? `✓ «${p.activo_nuevo.nombre}» dado de alta y compra guardada.` : "✓ Guardada.");
toast("Operación guardada.");
await cargar();
} catch (e) {
aceptar.disabled = false;
toast(e.message, true);
}
} }, p.accion === "cierre" ? "Aceptar y cerrar inversión" : p.accion === "alta" ? "Dar de alta y guardar" : "Aceptar");
acciones.append(aceptar);
}
if (!p.completa && (p.busqueda || p.accion === "alta")) {
acciones.append(h("button", { type: "button", clase: "btn", onclick: () => formActivo() }, "Dar de alta a mano"));
}
if (p.movimiento && p.accion === "movimiento") {
acciones.append(h("button", { type: "button", clase: "btn", onclick: () => formOperacion({
borrador: p.movimiento, alGuardar: async () => { hecho("✓ Guardada (corregida)."); await cargar(); },
}) }, p.completa ? "Corregir" : "Completar en el formulario"));
}
acciones.append(descartar);
return tarjeta;
}
/* ── Importar extractos ─────────────────────────────────────────────────── */
const ESTADOS_EXTRACTO = {
nueva: { texto: "nueva", clase: "info-i" },
afinar: { texto: "afinar la que tienes", clase: "aviso-i" },
ya_estaba: { texto: "ya la tienes", clase: "" },
mover: { texto: "está en otra cuenta: moverla", clase: "aviso-i" },
falta_cuenta: { texto: "falta elegir cuenta", clase: "aviso-i" },
incompleta: { texto: "sin participaciones: apúntala a mano", clase: "aviso-i" },
en_historial: { texto: "se decide en Bitcoin › historial", clase: "" },
poner_lugar: { texto: "ya la tienes: le pongo el lugar", clase: "info-i" },
sin_precio: { texto: "sin precio ese día: apúntala a mano", clase: "aviso-i" },
};
// «A, B o C», para nombrar en una frase lo que haya
function listaEnFrase(nombres) {
return nombres.length > 1 ? `${nombres.slice(0, -1).join(", ")} o ${nombres[nombres.length - 1]}` : nombres[0] || "";
}
function importarExtracto() {
const entradaFichero = h("input", { type: "file", accept: ".csv,.tsv,.txt,text/csv,text/plain" });
const zona = h("div", { clase: "zona-suelta" }, estado.datos.extractos.length
? `Arrastra aquí el CSV de ${listaEnFrase(estado.datos.extractos)}, o el de cualquier otra entidad, o elige el fichero:`
: "Arrastra aquí el CSV de movimientos de tu bróker, banco o gestora, o elige el fichero:", entradaFichero);
const cuerpo = h("div", {},
h("p", { clase: "nota-form", estilo: { marginTop: 0 } },
"Descarga en tu entidad la exportación de movimientos (CSV) y suéltala aquí. Te enseñaré qué operaciones faltan, cuáles ya tienes y qué líneas no son inversión. Nada se guarda hasta que lo aceptes; el fichero no sale de tu equipo."),
zona);
const cerrar = modal({ titulo: "Importar extracto", cuerpo, ancho: true });
const leer = (fichero) => {
if (!fichero) return;
const lector = new FileReader();
lector.onload = async () => {
zona.replaceChildren(h("span", { clase: "girando" }), ` Leyendo ${fichero.name}…`);
try {
const texto = String(lector.result).slice(0, 7000000);
const { resultado: formato } = await api("/api/extracto/columnas", { texto });
if (!formato.reconocido) { // formato desconocido: que diga qué es cada columna
cerrar();
repartirColumnas(formato, fichero.name, texto);
return;
}
const { resultado } = await api("/api/extracto/analizar", { texto });
cerrar();
revisarExtracto(resultado, fichero.name, texto);
} catch (e) {
zona.replaceChildren(h("div", { clase: "error-form" }, e.message), entradaFichero);
}
};
lector.readAsText(fichero);
};
entradaFichero.addEventListener("change", () => leer(entradaFichero.files[0]));
zona.addEventListener("dragover", (e) => { e.preventDefault(); zona.classList.add("soltando"); });
zona.addEventListener("dragleave", () => zona.classList.remove("soltando"));
zona.addEventListener("drop", (e) => { e.preventDefault(); zona.classList.remove("soltando"); leer(e.dataTransfer.files[0]); });
}
// Un CSV que no es de ninguna entidad conocida: se enseña cómo es y se pregunta qué es cada columna
function repartirColumnas(formato, nombreFichero, texto) {
const error = h("div", { clase: "error-form oculto", role: "alert" });
const opciones = [["", "— no está —"], ...formato.columnas.map((c) => [c, c])];
const selectores = Object.fromEntries(formato.papeles.map(([papel]) => [papel, selector(opciones, formato.propuesta[papel] || "")]));
const NOMBRES_CLASE = { Fondo: "Fondos de inversión", PlanPensiones: "Planes de pensiones", ETF: "ETF", Accion: "Acciones", Cripto: "Criptomonedas", Metal: "Metales" };
const selClase = selector(formato.clases.map((c) => [c, NOMBRES_CLASE[c] || c]), formato.clases[0]);
const cerrar = modal({
titulo: "¿Qué es cada columna?",
ancho: true,
cuerpo: h("div", {}, error,
h("p", { clase: "nota-form", estilo: { marginTop: 0 } },
`No conozco el formato de «${nombreFichero}», pero puedo leerlo si me dices qué es cada columna. `
+ "He propuesto un reparto por sus nombres: repásalo. Lo que no pueda entender se quedará fuera, y lo verás antes de guardar nada."),
h("h4", {}, `Así empieza el fichero (${formato.filas} líneas)`),
h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, formato.columnas.map((c) => h("th", {}, c)))),
h("tbody", {}, formato.muestra.map((fila) => h("tr", {}, fila.map((v) => h("td", { clase: "muy-suave" }, v))))))),
h("h4", {}, "El reparto"),
h("div", { clase: "campos" },
formato.papeles.map(([papel, nombre, obligatorio]) => campo(nombre + (obligatorio ? " *" : ""), selectores[papel])),
campo("¿Qué son, sobre todo?", selClase, { ayuda: "Para dar de alta lo que aún no tengas en Caudal." })),
h("p", { clase: "muy-suave" }, "* Hacen falta la fecha, el importe y al menos el nombre o el ISIN. Si no hay columna que diga si es compra o venta, "
+ "lo deduzco del signo: una cantidad positiva (o un importe negativo) es una compra.")),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Leer el fichero", clase: "btn-primario", accion: async () => {
const mapa = { clase: selClase.value };
for (const [papel, sel] of Object.entries(selectores)) if (sel.value) mapa[papel] = sel.value;
const r = await guardarCon(null, error, () => api("/api/extracto/analizar", { texto, mapa }));
if (r) { cerrar(); revisarExtracto(r.resultado, nombreFichero, texto, mapa); }
} },
],
});
}
function revisarExtracto(datos, nombreFichero, texto, mapa = null) {
const r = datos.resumen;
const marcable = (p) => ["nueva", "afinar", "mover", "poner_lugar"].includes(p.estado);
const filas = datos.propuestas.map((p) => ({ p, casilla: h("input", { type: "checkbox", checked: marcable(p), disabled: !marcable(p) }) }));
// Siempre a la vista si hay intereses, para poder cambiar la cuenta propuesta
const selCuenta = datos.hay_intereses
? selector([["", "— elige la cuenta —"], ...datos.cuentas.map((c) => [c.id, c.nombre])], datos.cuenta || "")
: null;
const reanalizar = async (extra) => {
try {
const { resultado } = await api("/api/extracto/analizar", { texto, ...(mapa ? { mapa } : {}), ...extra });
cerrar();
revisarExtracto(resultado, nombreFichero, texto, mapa);
} catch (e) { toast(e.message, true); }
};
if (selCuenta) {
selCuenta.addEventListener("change", () => { if (selCuenta.value) reanalizar({ cuenta: selCuenta.value }); });
}
// Extractos con exchanges: cada exchange es un lugar del activo Bitcoin (se recuerda)
const exchanges = datos.exchanges || [];
const lugaresBtc = (datos.activo_btc && datos.activo_btc.lugares) || [];
const bloqueExchanges = exchanges.length ? h("div", {},
h("p", { clase: "nota-form" },
`Todo va a «${datos.activo_btc ? datos.activo_btc.nombre : "Bitcoin"}»; cada exchange es un lugar (dónde lo tenías). `
+ "Si una compra ya la tenías apuntada, no se duplica: solo se le pone el lugar. Las retiradas se guardan como traslado "
+ "«Exchange → ?» y los depósitos como «? → Exchange»: el historial de Bitcoin completará solo el otro extremo cuando vea llegar "
+ "(o salir) esa cantidad en tus monederos. Si algún «exchange» es en realidad un monedero que ya lee el nodo, elige «No importar». "
+ "Las compras pagadas con otra moneda (USDT, USD…) llevan un importe ESTIMADO con el precio del día: corrígelo si sabes el real."),
h("div", { clase: "campos" }, exchanges.map((ex) => {
const sel = selector([["", `Lugar nuevo «${ex.nombre}»`], ...lugaresBtc.map((x) => [x.id, x.nombre]), ["no", "No importar"]], ex.destino || "");
sel.addEventListener("change", () => reanalizar({ destinos: { [ex.nombre]: sel.value } }));
return campo(`${ex.nombre} (${ex.lineas} operaciones)`, sel);
}))) : null;
const cuerpo = h("div", {},
h("p", { clase: "nota-form", estilo: { marginTop: 0 } },
`${nombreFichero} · ${r.lector} · ${r.lineas} líneas: ${r.nuevas} nuevas, ${r.ya_estaban} que ya tienes, `
+ `${r.afinar} por afinar, ${r.mover ? r.mover + " por mover de cuenta, " : ""}${r.incompletas || 0} sin participaciones y ${r.ignoradas} `
+ ((datos.exchanges || []).length ? "que no se apuntan (otras monedas o exchanges que no importas)."
: "que no son inversión (pagos con tarjeta, traspasos de efectivo…).")),
r.poner_lugar || r.traslados || r.sin_precio || r.estimadas ? h("p", { clase: "nota-form" },
[r.poner_lugar ? `${r.poner_lugar} que ya tenías (solo se les pone el lugar)` : "",
r.traslados ? `${r.traslados} depósitos o retiradas (traslados con un extremo por completar)` : "",
r.sin_precio ? `${r.sin_precio} sin precio de referencia` : "",
r.estimadas ? `${r.estimadas} con importe estimado` : ""].filter(Boolean).join(" · ") + ".") : null,
datos.nuevos_activos.length ? h("div", { clase: "aviso" }, h("span", {},
"Se darán de alta estos activos: " + datos.nuevos_activos.map((a) => a.isin ? `${a.nombre} (${a.isin})` : a.nombre).join(", "))) : null,
bloqueExchanges,
selCuenta ? h("div", { clase: "campos" }, campo("¿En qué cuenta apunto los intereses?", selCuenta, { completo: true })) : null,
h("div", { clase: "tabla-marco", estilo: { maxHeight: "50vh", overflowY: "auto" } }, h("table", {},
h("thead", {}, h("tr", {}, h("th", {}), h("th", {}, "Fecha"), h("th", {}, "Inversión"), h("th", {}, "Operación"),
h("th", { clase: "d" }, "Cantidad"), h("th", { clase: "d" }, "Importe"), h("th", {}, "Estado"),
h("th", {}, "Ya guardada en"))),
h("tbody", {}, filas.map(({ p, casilla }) => h("tr", {},
h("td", {}, casilla),
h("td", { clase: "num" }, fecha(p.fecha, true)),
h("td", {}, p.nombre_activo, p.lugar && p.tipo !== "Traslado" ? h("span", { clase: "suave" }, ` · ${p.lugar}`) : null,
p.activo_nuevo ? h("span", { clase: "insignia info-i" }, "nuevo") : null),
h("td", {}, TIPOS_MOV[p.tipo] || p.tipo, p.tipo === "Traslado" ? h("span", { clase: "suave" }, ` ${p.desde} → ${p.hacia}`) : null),
h("td", { clase: "d num" }, p.cantidad === null || p.cantidad === undefined ? "—" : num(p.cantidad)),
h("td", { clase: "d num" }, p.importe === null || p.importe === undefined ? "—" : eur(p.importe),
p.movimiento && p.movimiento.estimado ? h("span", { clase: "insignia aviso-i", title: "Con el precio de referencia de ese día" }, "estimado") : null),
h("td", {}, h("span", { clase: "insignia " + ESTADOS_EXTRACTO[p.estado].clase }, ESTADOS_EXTRACTO[p.estado].texto)),
h("td", { clase: "suave" }, p.guardada_en || "",
p.dif_importe ? h("div", { clase: "muy-suave" }, `tu importe ${eur(p.importe_tuyo)} (${p.dif_importe > 0 ? "+" : ""}${eur(p.dif_importe)}`
+ (Math.sign(p.dif_importe) === (p.tipo === "Venta" ? -1 : 1) ? ": con comisiones, se queda el tuyo)" : ": se queda el tuyo)")) : null)))))));
let cerrar;
cerrar = modal({
titulo: "Operaciones del extracto", ancho: true, cuerpo,
acciones: [
{ texto: "Marcar todas las nuevas", izq: true, accion: () => filas.forEach(({ p, casilla }) => { if (!casilla.disabled) casilla.checked = true; }) },
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Añadir seleccionadas", clase: "btn-primario", accion: async (c) => {
const elegidas = filas.filter(({ casilla }) => casilla.checked && !casilla.disabled).map(({ p }) => p);
if (!elegidas.length) { toast("No has marcado ninguna.", true); return; }
try {
const { resultado } = await api("/api/extracto/aplicar", { propuestas: elegidas });
c();
toast(pl`${resultado.guardados} operación(es) añadida(s)`
+ (resultado.actualizados ? pl`, ${resultado.actualizados} afinada(s)` : "")
+ (resultado.activos_nuevos ? pl`, ${resultado.activos_nuevos} activo(s) dado(s) de alta` : "") + ".");
if (resultado.fallos && resultado.fallos.length) {
modal({ titulo: "Algunas líneas no se han podido añadir", ancho: true,
cuerpo: h("div", {}, h("p", {}, "El resto sí se ha guardado. Puedes añadirlas a mano:"),
h("ul", {}, resultado.fallos.map((f) => h("li", {}, `${fecha(f.fecha)} · ${TIPOS_MOV[f.tipo] || f.tipo}: ${f.motivo}`)))),
acciones: [{ texto: "Cerrar", clase: "btn-primario", accion: (c2) => c2() }] });
}
await cargar();
} catch (e) { toast(e.message, true); }
} },
],
});
}
/* ── Actividades (la minería, y lo que venga) ───────────────────────────── */
function actividadesExistentes() {
return ((estado.datos && estado.datos.resumen.actividades) || []).map((a) => a.nombre);
}
// Campo para escribir la actividad o elegir una de las que ya hay
function campoActividad(valor) {
const lista = h("datalist", { id: "lista-actividades" },
actividadesExistentes().map((n) => h("option", { value: n })));
const entradaAct = entrada({ value: valor || "", list: "lista-actividades",
placeholder: "ninguna (p. ej. Minería)" });
return { control: h("div", {}, entradaAct, lista), input: entradaAct };
}
function tarjetaActividades(r) {
return h("div", { clase: "seccion" }, r.actividades.map((a) => {
const produccion = a.produccion.map((p) => `${num(p.cantidad)} ${p.unidad}`).join(" · ");
return h("div", { clase: "tarjeta clicable", onclick: () => detalleActividad(a.clave) },
h("h2", {}, a.nombre, h("span", { clase: "suave" }, "rentabilidad de esta actividad, aparte del total")),
h("div", { clase: "rejilla rejilla-kpi" },
kpi("Puesto", eur(a.invertido), a.recuperado ? `Recuperado: ${eur(a.recuperado)}` : null),
produccion ? kpi("Producido", produccion,
a.coste_unidad ? `Te sale a ${eur(a.coste_unidad)} por ${a.produccion[0].unidad.replace(/s$/, "")}` : null) : null,
kpi("Vale hoy", eur(a.valor), a.posiciones.length ? a.posiciones.map((p) => p.nombre).join(", ") : null),
kpi("Ganancia", eurSigno(a.ganancia), pct(a.rentabilidad), signo(a.ganancia)),
kpi("Rentabilidad anual (TIR)", pct(a.tir), "Cuenta cuándo salió cada euro", signo(a.tir))),
a.estimadas ? h("p", { clase: "muy-suave" },
pl`${a.estimadas} operación(es) con importes estimados: confírmalas cuando tengas las cifras buenas.`) : null);
}));
}
function detalleActividad(clave) {
const a = estado.datos.resumen.actividades.find((x) => x.clave === clave);
if (!a) return;
const movs = estado.datos.movimientos.filter((m) => (m.actividad || "").toLowerCase().trim()
&& quitarTildes(m.actividad) === a.clave);
const meses = a.meses.filter((m) => m.gasto || m.producido || m.cobrado);
const cuerpo = h("div", {},
h("div", { clase: "rejilla rejilla-kpi" },
kpi("Puesto", eur(a.invertido), `Desde ${fecha(a.desde)}`),
kpi("Recuperado", eur(a.recuperado)),
kpi("Vale hoy", eur(a.valor)),
kpi("Ganancia", eurSigno(a.ganancia), pct(a.rentabilidad), signo(a.ganancia)),
kpi("Rentabilidad al año", pct(a.tir), null, signo(a.tir))),
a.coste_unidad ? h("div", { clase: "aviso" }, h("span", {},
`Cada ${a.produccion[0].unidad.replace(/s$/, "")} producido te está saliendo a `,
h("b", {}, eur(a.coste_unidad)),
". Compáralo con lo que costaría comprarlo: si es más caro, la actividad pierde dinero.")) : null,
meses.length ? h("div", { clase: "tabla-marco", estilo: { marginTop: "14px" } }, h("table", {},
h("thead", {}, h("tr", {}, h("th", {}, "Mes"), h("th", { clase: "d" }, "Gastado"),
h("th", { clase: "d" }, "Producido"), h("th", { clase: "d" }, "Cobrado"))),
h("tbody", {}, meses.map((m) => h("tr", {},
h("td", { clase: "num" }, m.mes),
h("td", { clase: "d num" }, m.gasto ? eur(m.gasto) : "—"),
h("td", { clase: "d num" }, m.producido ? num(m.producido) : "—"),
h("td", { clase: "d num" }, m.cobrado ? eur(m.cobrado) : "—")))))) : null,
h("h4", { estilo: { marginTop: "18px" } }, `Sus operaciones (${movs.length})`),
tablaMovimientos(movs.slice(0, 60), true, cargarYRepintarActividad(clave)));
let cerrar;
cerrar = modal({
titulo: a.nombre, ancho: true, cuerpo,
acciones: [
{ texto: "Marcar más operaciones", izq: true, accion: () => { cerrar(); marcarEnActividad(a.nombre); } },
{ texto: "Renombrar", izq: true, accion: async () => {
const nombre = await pedirTexto("¿Cómo quieres llamarla?", a.nombre);
if (!nombre || nombre === a.nombre) return;
try {
await api("/api/actividad/renombrar", { viejo: a.nombre, nuevo: nombre });
cerrar();
await cargar();
} catch (e) { toast(e.message, true); }
} },
{ texto: "Cerrar", clase: "btn-primario", accion: (c) => c() },
],
});
}
const cargarYRepintarActividad = (clave) => async () => {
await cargar();
document.querySelectorAll(".velo").forEach((v) => v.remove());
detalleActividad(clave);
};
function quitarTildes(texto) {
return String(texto || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim()
.split(/\s+/).join(" ");
}
function pedirTexto(titulo, valor) {
return new Promise((resolver) => {
const campoTexto = entrada({ value: valor || "" });
modal({
titulo, cuerpo: h("div", { clase: "campos" }, campo("Nombre", campoTexto, { completo: true })),
alCerrar: () => resolver(null),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Guardar", clase: "btn-primario", accion: (c) => { resolver(campoTexto.value.trim()); c(); } },
],
});
});
}
async function marcarEnActividad(nombre) {
let candidatas;
try {
({ resultado: candidatas } = await api("/api/actividad/candidatas", { actividad: nombre }));
} catch (e) { toast(e.message, true); return; }
if (!candidatas.length) {
toast("No queda ninguna operación sin actividad en los activos de esta.");
return;
}
const filas = candidatas.map((m) => ({ m, casilla: h("input", { type: "checkbox", checked: m.probable }) }));
const cuerpo = h("div", {},
h("p", { clase: "nota-form", estilo: { marginTop: 0 } },
`Estas son las operaciones de los activos que ya tienen algo en «${nombre}». Vienen marcadas las que `
+ "más se parecen a lo ya etiquetado (mismo tipo de operación) y las generadas por una operación "
+ "automática de la actividad; repasa el resto y marca lo que sea de aquí —en la minería suelen "
+ "convivir el gasto del hosting, los aparatos, lo minado y las devoluciones. Marcar una operación "
+ "no cambia ninguna cifra de tu cartera: solo hace que cuente también aquí."),
h("div", { clase: "tabla-marco", estilo: { maxHeight: "50vh", overflowY: "auto" } }, h("table", {},
h("thead", {}, h("tr", {}, h("th", {}), h("th", {}, "Fecha"), h("th", {}, "Inversión"),
h("th", {}, "Operación"), h("th", { clase: "d" }, "Cantidad"), h("th", { clase: "d" }, "Importe"),
h("th", {}, "Notas"))),
h("tbody", {}, filas.map(({ m, casilla }) => h("tr", {},
h("td", {}, casilla),
h("td", { clase: "num" }, fecha(m.fecha, true)),
h("td", {}, m.nombre_activo),
h("td", {}, nombreTipoMov(m),
m.de_recurrente ? h("span", { clase: "insignia info-i" }, "automática") : null),
h("td", { clase: "d num" }, m.cantidad === null || m.cantidad === undefined ? "—" : num(m.cantidad)),
h("td", { clase: "d num" }, m.importe === null || m.importe === undefined ? "—" : eur(m.importe)),
h("td", { clase: "suave" }, m.notas)))))));
modal({
titulo: `Añadir operaciones a «${nombre}»`, ancho: true, cuerpo,
acciones: [
{ texto: "Marcar todas", izq: true, accion: () => filas.forEach(({ casilla }) => { casilla.checked = true; }) },
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Marcar las elegidas", clase: "btn-primario", accion: async (c) => {
const ids = filas.filter(({ casilla }) => casilla.checked).map(({ m }) => m.id);
if (!ids.length) { toast("No has marcado ninguna.", true); return; }
try {
const { resultado } = await api("/api/actividad/etiquetar", { ids, actividad: nombre });
c();
toast(pl`${resultado.marcadas} operación(es) añadida(s) a «${nombre}».`);
await cargar();
} catch (e) { toast(e.message, true); }
} },
],
});
}
/* ── Bitcoin desde tu nodo ──────────────────────────────────────────────── */
const TIPOS_DIRECCION = {
p2wpkh: "SegWit nativo (bc1q…)", p2tr: "Taproot (bc1p…)",
"p2sh-p2wpkh": "SegWit compatible (3…)", p2pkh: "Antiguo (1…)",
};
function btcEstado() { return (estado.datos && estado.datos.btc) || { monederos: 0, servidores: 0 }; }
function descuadre(c) { return Math.abs(c.diferencia) > 1e-8; }
// Conectar bitcoin paso a paso: el nodo, el monedero y la comprobación. Para quien llega de nuevas;
// todo lo que hace se puede hacer también a mano desde la ventana de bitcoin.
async function asistenteBitcoin() {
let datos;
try { ({ resultado: datos } = await api("/api/btc/estado", {})); } catch (e) { toast(e.message, true); return; }
const error = h("div", { clase: "error-form oculto", role: "alert" });
const fallo = (texto) => { error.textContent = texto; error.classList.remove("oculto"); };
const cuerpo = h("div", {});
const pasos = ["Tu nodo", "Tu monedero", "Comprobar"];
const cabecera = (n) => h("ol", { clase: "pasos-asistente" }, pasos.map((t, i) =>
h("li", { clase: i < n ? "hecho" : i === n ? "actual" : "" }, h("span", {}, i < n ? "✓" : String(i + 1)), t)));
const pintar = (n, ...contenido) => { error.classList.add("oculto"); cuerpo.replaceChildren(cabecera(n), error, ...contenido.filter(Boolean)); };
let cerrar;
const ocupado = async (boton, texto, tarea) => {
const antes = boton.textContent;
boton.disabled = true; boton.replaceChildren(h("span", { clase: "girando" }), ` ${texto}`);
try { return await tarea(); } catch (e) { fallo(e.message); return undefined; } finally { boton.disabled = false; boton.textContent = antes; }
};
// ── 1. El nodo ──
const pasoNodo = () => {
if (datos.servidores.length) {
const s = datos.servidores[0];
pintar(0, h("p", {}, "Caudal ya sabe a qué nodo preguntar: ", h("b", {}, s.nombre || s.host), h("span", { clase: "muy-suave" }, ` · ${s.host}:${s.puerto}`)),
h("div", { clase: "acciones-cifrado" }, h("button", { type: "button", clase: "btn btn-primario", onclick: pasoMonedero }, "Seguir")));
return;
}
const sug = datos.sugerencia;
const nodo = camposNodo(null, sug);
pintar(0,
h("p", { clase: "nota-form", estilo: { marginTop: 0 } }, "Caudal no le pregunta a nadie de fuera por tu bitcoin: le pregunta a tu propio nodo. Lo normal es hacerlo a través de su servidor Electrum "
+ "(el mismo al que se conectan los monederos de escritorio); si no tienes uno, también puede preguntarle al nodo directamente."),
sug ? h("div", { clase: "aviso bienvenida" }, h("span", {}, h("b", {}, "He encontrado el servidor Electrum de este mismo aparato. "), "Ya está puesto abajo: solo tienes que probarlo.")) : null,
nodo.bloque,
h("div", { clase: "acciones-cifrado" }, h("button", { type: "button", clase: "btn btn-primario", onclick: (e) => ocupado(e.currentTarget, "Probando…", async () => {
const s = nodo.leer("Mi nodo");
const { resultado: prueba } = await api("/api/btc/probar", { servidor: s });
if (prueba.podado) { fallo("Tu nodo está podado (no guarda los bloques antiguos) y no puede repasar el pasado de un monedero. Hace falta un nodo completo o un servidor Electrum."); return; }
({ resultado: datos } = await api("/api/btc/servidores", { servidores: [s] }));
toast(`Conectado a tu nodo (bloque ${prueba.altura}).`);
pasoMonedero();
}) }, "Probar y seguir")));
};
// ── 2. El monedero ──
const mio = { modo: null, nombre: "", clave: "", direcciones: "", tipo: "", visto: false };
const pasoMonedero = () => {
const nombre = entrada({ value: mio.nombre, placeholder: "Mi monedero", maxlength: 60 });
const clave = h("textarea", { rows: 3, spellcheck: "false", clase: "num", placeholder: "xpub6… · ypub6… · zpub6…" });
clave.value = mio.clave;
const direcciones = h("textarea", { rows: 6, spellcheck: "false", clase: "num", placeholder: "bc1q…\nbc1p…\n3…\nuna por línea" });
direcciones.value = mio.direcciones;
const seguir = (boton) => ocupado(boton, "Mirando en tu nodo…", async () => {
Object.assign(mio, { nombre: nombre.value.trim() || "Mi monedero", clave: clave.value.trim().replace(/\s+/g, ""), direcciones: direcciones.value.trim() });
if (mio.modo === "clave") {
if (!mio.clave) { fallo("Pega la clave pública del monedero."); return; }
const { resultado } = await api("/api/btc/detectar", { clave: mio.clave });
Object.assign(mio, { tipo: resultado.tipo, visto: resultado.visto });
} else if (!mio.direcciones) { fallo("Pega al menos una dirección."); return; }
pasoComprobar();
});
const elegir = (modo) => { mio.modo = modo; pasoMonedero(); };
const opcion = (modo, titulo, texto) => h("button", { type: "button", clase: "opcion-paso", onclick: () => elegir(modo) }, h("b", {}, titulo), h("span", {}, texto));
if (!mio.modo) {
pintar(1, h("div", { clase: "que-paso" }, h("div", { clase: "etiqueta-seccion" }, "¿Qué tienes de tu monedero?"),
h("div", { clase: "opciones-paso" },
opcion("clave", "Su clave pública", "Una sola clave (xpub, ypub o zpub) con la que Caudal ve todas sus direcciones, también las futuras. Lo más cómodo."),
opcion("sueltas", "Sus direcciones", "Una o varias direcciones sueltas. Vale para cualquier monedero, también para los que no enseñan su clave."))),
h("p", { clase: "muy-suave" }, "En los dos casos son datos para mirar, no para gastar: Caudal nunca pide ni guarda claves privadas ni palabras semilla."));
return;
}
pintar(1,
h("div", { clase: "paso-elegido" }, h("b", {}, mio.modo === "clave" ? "Con su clave pública" : "Con sus direcciones"),
h("button", { type: "button", clase: "btn-enlace", onclick: () => elegir(null) }, "Cambiar")),
h("div", { clase: "campos", estilo: { marginTop: "12px" } },
campo("Cómo lo llamas", nombre, { completo: true }),
mio.modo === "clave"
? campo("Clave pública del monedero", clave, { completo: true,
ayuda: "Empieza por xpub, ypub o zpub; da igual cuál. No hace falta que sepas qué direcciones usa (bc1q…, bc1p…, 3… o 1…): "
+ "muchos monederos dan una xpub y trabajan con bc1q, así que se lo pregunto a tu nodo. La encontrarás en los ajustes del monedero, como «clave pública extendida» o «exportar xpub»." })
: campo("Direcciones", direcciones, { completo: true,
ayuda: "Una por línea. Vale cualquier tipo: bc1q…, bc1p…, 3…, 1…. Pon todas las que haya usado, también las de cambio "
+ "(al pagar, la vuelta va a una dirección nueva del propio monedero; si falta, parecerá bitcoin que se fue). Podrás añadir más después." })),
h("div", { clase: "acciones-cifrado" }, h("button", { type: "button", clase: "btn btn-primario", onclick: (e) => seguir(e.currentTarget) }, "Seguir")));
};
// ── 3. Comprobar y guardar ──
const pasoComprobar = () => {
const selTipo = selector(Object.entries(TIPOS_DIRECCION), mio.tipo || "p2wpkh");
const selActivo = datos.activos.length ? selector(datos.activos.map((a) => [a.id, a.nombre]), datos.activos[0].id) : null;
// Lo que el monedero ya ha hecho: apuntarlo ahora, o darlo por apuntado. Si aún no hay nada de bitcoin
// apuntado, lo normal es querer apuntarlo.
const idsBtc = new Set(datos.activos.map((a) => a.id));
const yaApuntado = estado.datos.movimientos.some((m) => idsBtc.has(m.activo));
const pasado = (valor, titulo, texto) => {
const radio = h("input", { type: "radio", name: "pasado-btc", value: valor, checked: (valor === "repasar") !== yaApuntado });
return h("label", { clase: "opcion-radio" }, radio, h("span", {}, h("b", {}, titulo), h("span", { clase: "suave" }, texto)));
};
const opcionesPasado = h("div", { clase: "que-paso", estilo: { marginTop: "14px" } },
h("div", { clase: "etiqueta-seccion" }, "¿Y lo que este monedero ya ha hecho hasta hoy?"),
pasado("repasar", "Quiero apuntarlo", "Al guardar te enseño sus movimientos uno a uno, con una propuesta para cada uno, y eliges cuáles apuntar."),
pasado("conocido", "Ya lo tengo apuntado", "Se da por conocido y Caudal te avisará solo de lo que pase a partir de ahora. Podrás repasarlo más adelante."));
pintar(2,
mio.modo === "clave" ? h("div", { clase: mio.visto ? "aviso bienvenida" : "aviso" }, h("span", {}, mio.visto
? h("b", {}, `Tu nodo ha visto movimientos en direcciones de tipo ${TIPOS_DIRECCION[mio.tipo]}. `)
: h("b", {}, "Tu nodo aún no ha visto movimientos de esta clave. "),
mio.visto ? "Es el tipo que usa tu monedero." : `He supuesto ${TIPOS_DIRECCION[mio.tipo]}, que es lo más habitual; si tu monedero usa otras, cámbialo aquí.`)) : null,
h("div", { clase: "campos", estilo: { marginTop: "12px" } },
mio.modo === "clave" ? campo("Tipo de direcciones", selTipo, { completo: true }) : null,
selActivo && datos.activos.length > 1 ? campo("¿A qué inversión corresponde?", selActivo, { completo: true }) : null),
datos.activos.length ? null : h("p", { clase: "suave" }, "Se dará de alta «Bitcoin» entre tus inversiones."),
opcionesPasado,
h("div", { clase: "acciones-cifrado" },
h("button", { type: "button", clase: "btn", onclick: pasoMonedero }, "Atrás"),
h("button", { type: "button", clase: "btn btn-primario", onclick: (e) => ocupado(e.currentTarget, "Guardando y mirando qué tiene…", async () => {
let activo = selActivo ? selActivo.value : null;
if (!activo) {
const alta = await api("/api/activo", { tipo: "Cripto", nombre: "Bitcoin", codigo: "bitcoin", unidad: "BTC" });
activo = alta.resultado.id;
}
const monedero = { nombre: mio.nombre, activo, lugar: "" };
if (mio.modo === "clave") Object.assign(monedero, { clave: mio.clave, tipo: selTipo.value });
else monedero.direcciones = mio.direcciones;
await api("/api/btc/monedero", { monedero });
({ resultado: datos } = await api("/api/btc/revisar_monederos", {}));
await cargar();
if (opcionesPasado.querySelector("input:checked").value === "repasar") { cerrar(); importarHistorialBtc(); return; }
pasoHecho();
}) }, "Guardar y comprobar")));
};
const pasoHecho = () => {
const m = datos.monederos[datos.monederos.length - 1];
pintar(3, h("h4", { estilo: { marginTop: 0 } }, `«${m.nombre}» ya está conectado`),
h("div", {}, textoRevision(m)),
h("p", {}, "A partir de ahora Caudal mirará en tu nodo cada vez que lo abras y te propondrá apuntar cada movimiento nuevo. "
+ "Lo que el monedero hizo hasta hoy queda por conocido, como has elegido; si cambias de idea, repasa el historial."),
h("div", { clase: "acciones-cifrado" },
h("button", { type: "button", clase: "btn btn-primario", onclick: () => cerrar() }, "Terminar"),
h("button", { type: "button", clase: "btn", onclick: () => { cerrar(); importarHistorialBtc(); } }, "Repasar el historial"),
h("button", { type: "button", clase: "btn", onclick: () => { mio.modo = null; Object.assign(mio, { nombre: "", clave: "", direcciones: "" }); pasoMonedero(); } }, "Añadir otro monedero")));
};
cerrar = modal({ titulo: "Conectar tu bitcoin", cuerpo, ancho: true, acciones: [
{ texto: "Hacerlo a mano", izq: true, accion: (c) => { c(); ventanaBitcoin(true); } },
{ texto: "Cerrar", accion: (c) => c() }] });
pasoNodo();
}
async function ventanaBitcoin(aMano = false) {
let datos;
try {
({ resultado: datos } = await api("/api/btc/estado", {}));
} catch (e) { toast(e.message, true); return; }
// Quien llega sin nada conectado empieza por el asistente
if (!aMano && (!datos.servidores.length || !datos.monederos.length)) { asistenteBitcoin(); return; }
const cuerpo = h("div", {});
const pintarPanel = () => {
const sinNada = !datos.servidores.length && !datos.monederos.length;
const casilla = h("input", { type: "checkbox", checked: datos.comprobar_al_abrir });
casilla.addEventListener("change", async () => {
try {
({ resultado: datos } = await api("/api/btc/servidores",
{ servidores: datos.servidores, comprobar_al_abrir: casilla.checked }));
} catch (e) { toast(e.message, true); }
});
cuerpo.replaceChildren(...[
h("p", { clase: "nota-form", estilo: { marginTop: 0 } },
"Compara lo que tienes apuntado en bitcoin con lo que ve tu propio nodo, y te avisa de las "
+ "operaciones nuevas. Solo se guardan claves ", h("b", {}, "públicas"),
" (xpub, ypub o zpub) o las direcciones de los monederos que no enseñan su clave: "
+ "sirven para mirar, nunca para gastar. Las consultas van a tu nodo "
+ "(a su servidor Electrum o a él directamente), por Tor si su dirección acaba en .onion."),
sinNada ? h("div", { clase: "aviso" }, h("span", {},
"Necesitas dos cosas: cómo llegar a tu nodo (su servidor Electrum —electrs o Fulcrum— o el propio Bitcoin Core o Knots) "
+ "y la clave pública de cada monedero, o sus direcciones.")) : null,
h("h4", {}, "Servidores ", h("span", { clase: "suave titulillo" }, "se prueban en este orden; arrastra ⠿ para cambiarlo")),
tablaServidores(datos, pintarPanel),
h("h4", {}, "Monederos"),
tablaMonederos(datos, pintarPanel),
h("label", { clase: "campo completo", estilo: { flexDirection: "row", alignItems: "center", gap: "8px" } },
casilla, "Comprobar al abrir la cartera (una vez al día)"),
datos.ultima ? h("p", { clase: "muy-suave" },
`Última consulta: ${fechaHora(datos.ultima.fecha)} · ${datos.ultima.servidor}`
+ (datos.ultima.pendientes ? ` · ${datos.ultima.pendientes} por revisar` : "")) : null,
h("p", { clase: "muy-suave" }, "Las claves públicas se guardan con tus datos, donde solo las lee Caudal, y viajan en tus copias de seguridad y al sincronizar."),
].filter(Boolean));
};
pintarPanel();
modal({
titulo: "Bitcoin: mi nodo y mis monederos", ancho: true, cuerpo,
acciones: [
{ texto: "Importaciones hechas", izq: true, accion: () => ventanaImportacionesBtc() },
{ texto: "Asistente", izq: true, accion: (c) => { c(); asistenteBitcoin(); } },
{ texto: "Mis monedas", izq: true, accion: () => ventanaMonedasBtc() },
{ texto: "Importes y prima", izq: true, accion: () => ventanaPrimas() },
{ texto: "Cerrar", accion: (c) => c() },
{ texto: "Revisar todo el historial", accion: async (c) => {
if (!datos.monederos.length || !datos.servidores.length) {
toast("Añade primero un servidor y un monedero.", true);
return;
}
c();
importarHistorialBtc();
} },
{ texto: "Comprobar ahora", clase: "btn-primario", accion: async (c) => {
if (!datos.monederos.length || !datos.servidores.length) {
toast("Añade primero un servidor y un monedero.", true);
return;
}
c();
comprobarBitcoin(false);
} },
],
});
}
function tablaServidores(datos, repintar) {
const guardar = async (lista) => {
try {
const { resultado } = await api("/api/btc/servidores", { servidores: lista });
datos.servidores = resultado.servidores;
repintar();
} catch (e) { toast(e.message, true); }
};
// El orden de la lista es el orden en que se prueban: se cambia arrastrando la fila por su asa
// (con el dedo o con el ratón) o, con el foco en el asa, con las flechas del teclado
const mover = (de, a) => {
if (a < 0 || a >= datos.servidores.length || a === de) return;
const lista = [...datos.servidores];
lista.splice(a, 0, lista.splice(de, 1)[0]);
guardar(lista);
};
const asa = (i, nombre) => h("button", {
type: "button", clase: "asa-orden", title: "Arrastra para cambiar el orden", "aria-label": `Cambiar de sitio «${nombre}» (también con las flechas arriba y abajo)`,
onkeydown: (e) => {
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
e.preventDefault();
mover(i, i + (e.key === "ArrowUp" ? -1 : 1));
},
onpointerdown: (e) => {
const tirador = e.currentTarget, fila = tirador.closest("tr"), cuerpoTabla = fila.parentNode;
e.preventDefault();
try { tirador.setPointerCapture(e.pointerId); } catch (_) { /* sin captura también funciona: se escucha en el documento */ }
fila.classList.add("arrastrando");
const alMover = (ev) => {
const debajo = document.elementFromPoint(ev.clientX, ev.clientY);
const sobre = debajo && debajo.closest("tr");
if (!sobre || sobre === fila || sobre.parentNode !== cuerpoTabla) return;
const caja = sobre.getBoundingClientRect();
cuerpoTabla.insertBefore(fila, ev.clientY < caja.top + caja.height / 2 ? sobre : sobre.nextSibling);
};
const alSoltar = () => {
document.removeEventListener("pointermove", alMover);
document.removeEventListener("pointerup", alSoltar);
document.removeEventListener("pointercancel", alSoltar);
fila.classList.remove("arrastrando");
const orden = [...cuerpoTabla.children].map((f) => Number(f.getAttribute("data-i")));
if (orden.some((n, j) => n !== j)) guardar(orden.map((n) => datos.servidores[n]));
};
document.addEventListener("pointermove", alMover);
document.addEventListener("pointerup", alSoltar);
document.addEventListener("pointercancel", alSoltar);
},
}, "⠿");
const varios = datos.servidores.length > 1;
const filas = datos.servidores.map((s, i) => h("tr", { "data-i": String(i) },
varios ? h("td", { clase: "celda-asa" }, asa(i, s.nombre)) : null,
h("td", {}, s.nombre),
h("td", { clase: "suave" }, `${s.host}:${s.puerto}`),
h("td", {}, s.clase === "core" ? h("span", { clase: "insignia info-i" }, "Nodo directo") : null,
s.tor ? h("span", { clase: "insignia info-i" }, "Tor") : null,
s.tls ? h("span", { clase: "insignia info-i" }, "TLS") : null),
h("td", { clase: "d" },
h("button", { type: "button", clase: "btn-enlace", onclick: async (e) => {
const boton = e.target;
boton.textContent = "Probando…";
try {
const { resultado } = await api("/api/btc/probar", { servidor: s });
toast(`${resultado.version} · bloque ${num(resultado.altura, 0)}` + (resultado.podado ? " · PODADO: no puede repasar el pasado" : ""), !!resultado.podado);
} catch (err) { toast(err.message, true); } finally { boton.textContent = "Probar"; }
} }, "Probar"),
h("button", { type: "button", clase: "btn-enlace", onclick: () => formServidorBtc(s, (nuevo) =>
guardar(datos.servidores.map((x, j) => (j === i ? nuevo : x)))) }, "Editar"),
h("button", { type: "button", clase: "btn-enlace", onclick: async () => {
if (await confirmar(`¿Quitar el servidor «${s.nombre}»?`, "Quitar", true)) {
guardar(datos.servidores.filter((_, j) => j !== i));
}
} }, "Quitar"))));
return h("div", {},
filas.length ? h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, varios ? h("th", {}, "") : null, h("th", {}, "Nombre"), h("th", {}, "Dirección"), h("th", {}, ""), h("th", { clase: "d" }, ""))),
h("tbody", {}, filas))) : h("p", { clase: "vacio" }, "Ningún servidor todavía."),
h("button", { type: "button", clase: "btn", onclick: () => formServidorBtc(null, (nuevo) =>
guardar([...datos.servidores, nuevo])) }, "+ Añadir servidor"));
}
// Los campos para decir cómo llegar al nodo: por su servidor Electrum o directamente (Bitcoin Core o Knots por RPC).
// Devuelve el bloque para pintar y «leer()», que da el servidor tal como se guarda.
function camposNodo(s, sug) {
const clase = selector([["electrum", "Por su servidor Electrum (electrs o Fulcrum)"], ["core", "Directamente al nodo (Bitcoin Core o Knots)"]],
s && s.clase === "core" ? "core" : "electrum");
const host = entrada({ value: (s && s.host) || (sug ? sug.host : ""), placeholder: "xxxxxxxx.onion o 192.168.1.50" });
const puerto = entradaNum({ value: s ? s.puerto : sug ? sug.puerto : 50001 });
const usuario = entrada({ value: (s && s.usuario) || "" });
const clave = h("input", { type: "password", autocomplete: "off", placeholder: s && s.con_clave ? "guardada (en blanco, la misma)" : "" });
const tor = h("input", { type: "checkbox", checked: s ? !!s.tor : !!(sug && sug.tor) });
const tls = h("input", { type: "checkbox", checked: s ? !!s.tls : !!(sug && sug.tls) });
host.addEventListener("input", () => { if (host.value.trim().endsWith(".onion")) tor.checked = true; });
const campoHost = campo("Dirección", host, { completo: true });
const campoPuerto = campo("Puerto", puerto, { ayuda: " " });
const campoUsuario = campo("Usuario de RPC", usuario);
const campoClave = campo("Contraseña de RPC", clave);
const nota = h("p", { clase: "muy-suave" });
const ajustar = (alCambiar) => {
const core = clase.value === "core";
etiquetaCampo(campoHost, core ? "Dirección de tu nodo" : "Dirección del servidor Electrum");
etiquetaCampo(campoPuerto, "Puerto", core ? "Lo habitual: 8332." : "Lo habitual: 50001 (sin cifrar) o 50002 (con TLS).");
campoUsuario.classList.toggle("oculto", !core);
campoClave.classList.toggle("oculto", !core);
if (alCambiar && [50001, 50002, 8332].includes(aNumero(puerto.value))) puerto.value = core ? 8332 : 50001;
nota.textContent = core
? "Caudal crea en tu nodo un monedero llamado «caudal» que no tiene claves privadas: solo mira, no puede gastar. La primera vez el nodo repasa "
+ "la cadena entera para encontrar lo tuyo: de unos minutos a varias horas. El usuario y la contraseña son los de RPC de tu nodo (salen en su ficha en StartOS o Umbrel); "
+ "lo prudente es un usuario solo para Caudal. Un nodo podado no sirve para esto."
: "En StartOS y en Umbrel es la aplicación «electrs» o «Fulcrum», y su dirección y su puerto salen en su ficha.";
};
clase.addEventListener("change", () => ajustar(true));
ajustar(false);
const bloque = h("div", {},
h("div", { clase: "campos" },
campo("Cómo llegar a tu nodo", clase, { completo: true }),
campoHost, campoPuerto, campoUsuario, campoClave,
h("div", { clase: "campo" },
h("label", { estilo: { display: "flex", alignItems: "center", gap: "8px" } }, tor, "Ir por Tor (direcciones .onion)"),
h("label", { estilo: { display: "flex", alignItems: "center", gap: "8px", marginTop: "8px" } }, tls, "Usa TLS"))),
nota);
const leer = (nombre) => {
const core = clase.value === "core";
const nuevo = { nombre: nombre || host.value.trim(), host: host.value.trim(),
puerto: aNumero(puerto.value) || (core ? 8332 : 50001), tor: tor.checked, tls: tls.checked };
if (!nuevo.host) throw new Error(core ? "Pon la dirección de tu nodo." : "Pon la dirección del servidor.");
if (core) {
Object.assign(nuevo, { clase: "core", usuario: usuario.value.trim(), clave: clave.value });
if (!nuevo.usuario) throw new Error("Pon el usuario de RPC de tu nodo.");
if (!nuevo.clave && !(s && s.con_clave)) throw new Error("Pon la contraseña de RPC de tu nodo.");
}
return nuevo;
};
return { bloque, leer };
}
function formServidorBtc(s, alGuardar) {
const nombre = entrada({ value: (s && s.nombre) || "", placeholder: "Mi nodo" });
const nodo = camposNodo(s, null);
const error = h("div", { clase: "error-form oculto" });
modal({
titulo: s ? "Editar servidor" : "Nuevo servidor",
cuerpo: h("div", {},
h("div", { clase: "campos" }, campo("Nombre", nombre, { completo: true })),
nodo.bloque,
error),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Guardar", clase: "btn-primario", accion: async (c) => {
let nuevo;
try { nuevo = nodo.leer(nombre.value.trim()); } catch (e) { error.textContent = e.message; error.classList.remove("oculto"); return; }
await alGuardar(nuevo);
c();
} },
],
});
}
// Lo que se encontró en el nodo para un monedero (ver «Comprobar qué tiene cada monedero»)
function textoRevision(m) {
const r = m.revision;
if (!r) return h("div", { clase: "muy-suave" }, "sin revisar qué tiene");
if (r.error) return h("div", { clase: "neg" }, `⚠ No se pudo revisar: ${r.error}`);
if (r.clase === "sueltas") {
const aviso = r.sin_uso.length && r.usadas ? h("div", { clase: "neg", title: r.sin_uso.join("\n") },
`⚠ ${r.sin_uso.length} sin ninguna transacción (pasa el ratón para verlas): ¿errata o no son de este monedero?`) : null;
return h("div", {}, h("div", { clase: r.usadas ? "pos" : "neg" },
r.usadas ? `✓ ${r.usadas} de ${cuantos(r.total, "dirección", "direcciones")} con transacciones (${r.transacciones} en total)`
: r.total === 1 ? "⚠ La dirección no tiene transacciones" : `⚠ Ninguna de las ${r.total} direcciones tiene transacciones`), aviso);
}
const t = r.tipos || {}, elegido = t[r.elegido] || { usadas: 0, transacciones: 0 };
const otros = Object.entries(t).filter(([k, v]) => k !== r.elegido && v.usadas)
.map(([k, v]) => `${TIPOS_DIRECCION[k] || k} (${cuantos(v.usadas, "dirección", "direcciones")})`);
const nombreElegido = TIPOS_DIRECCION[r.elegido] || r.elegido;
if (!elegido.usadas && !otros.length)
return h("div", { clase: "neg" }, "⚠ Sin transacciones en ningún tipo de dirección (probados los cuatro)");
if (!elegido.usadas)
return h("div", { clase: "neg" }, `⚠ Nada como ${nombreElegido}; sí hay en ${otros.join(", ")}: cambia el tipo del monedero`);
return h("div", {}, h("div", { clase: "pos" },
`✓ ${cuantos(elegido.usadas, "dirección", "direcciones")} con transacciones como ${nombreElegido} (${cuantos(elegido.transacciones, "transacción", "transacciones")})`),
otros.length ? h("div", { clase: "neg" }, `⚠ También hay en ${otros.join(", ")}: esas no se están leyendo (añádelo otra vez con ese tipo)`) : null);
}
function tablaMonederos(datos, repintar) {
const filas = datos.monederos.map((m) => h("tr", {},
h("td", {}, m.nombre, h("div", { clase: "muy-suave" },
(m.tipo === "sueltas" ? "Direcciones sueltas" : TIPOS_DIRECCION[m.tipo] || m.tipo)
+ (m.actividad ? ` · ${m.actividad}` : "")
+ (m.parte && m.parte < 1 ? ` · compartido: tuyo el ${num(m.parte * 100, 2)} %` : ""))),
h("td", { clase: "suave" }, h("span", { clase: "num" }, m.clave_corta), textoRevision(m)),
h("td", {}, nombreActivo(m.activo), m.lugar ? h("div", { clase: "muy-suave" }, nombreLugar(m.activo, m.lugar)) : null),
h("td", { clase: "d num" }, m.saldo === null || m.saldo === undefined ? "—" : num(m.saldo) + " BTC",
m.parte && m.parte < 1 && m.saldo ? h("div", { clase: "muy-suave" }, `tuyo: ${num(m.saldo * m.parte)}`) : null,
h("div", { clase: "muy-suave" }, m.comprobado ? fechaHora(m.comprobado) : "sin comprobar")),
h("td", { clase: "d" },
h("button", { type: "button", clase: "btn-enlace", onclick: () => formMonederoBtc(datos, m, repintar) }, "Editar"),
h("button", { type: "button", clase: "btn-enlace", onclick: async () => {
if (await confirmar(`¿Quitar el monedero «${m.nombre}»? Las operaciones que ya guardaste se quedan.`, "Quitar", true)) {
try {
const { resultado } = await api("/api/btc/monedero/borrar", { id: m.id });
datos.monederos = resultado.monederos;
repintar();
} catch (e) { toast(e.message, true); }
}
} }, "Quitar"))));
return h("div", {},
filas.length ? h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, h("th", {}, "Monedero"), h("th", {}, "Clave o direcciones · qué tiene"), h("th", {}, "Inversión"),
h("th", { clase: "d" }, "Saldo"), h("th", { clase: "d" }, ""))),
h("tbody", {}, filas))) : h("p", { clase: "vacio" }, "Ningún monedero todavía."),
h("div", { clase: "acciones-ficha" },
h("button", { type: "button", clase: "btn", onclick: () => formMonederoBtc(datos, null, repintar) },
"+ Añadir monedero"),
filas.length ? h("button", { type: "button", clase: "btn", onclick: async (e) => {
const b = e.currentTarget;
b.disabled = true;
b.replaceChildren(h("span", { clase: "girando" }), " Mirando en el nodo…");
try {
const { resultado } = await api("/api/btc/revisar_monederos", {});
datos.monederos = resultado.monederos;
repintar();
} catch (err) { toast(err.message, true); b.disabled = false; b.textContent = "Comprobar qué tiene cada monedero"; }
} }, "Comprobar qué tiene cada monedero") : null));
}
function formMonederoBtc(datos, m, repintar) {
const cripto = datos.activos;
const nombre = entrada({ value: (m && m.nombre) || "", placeholder: "Mi monedero" });
const clave = h("textarea", { rows: 3, placeholder: m ? "(se conserva la que ya tienes)" : "zpub6r…",
spellcheck: "false", clase: "num" });
const tipo = selector([["", "Adivinarlo al comprobar"], ...Object.entries(TIPOS_DIRECCION)], (m && m.tipo) || "");
const activo = selector(cripto.map((a) => [a.id, a.nombre]), m ? m.activo : (cripto[0] || {}).id);
// Cada monedero es un lugar del activo; por defecto, uno con su nombre
const selLugarMon = selector([], "");
const pintarLugarMon = () => {
const ls = (cripto.find((a) => a.id === activo.value) || {}).lugares || [];
selLugarMon.replaceChildren(...[["", "Uno nuevo con el nombre del monedero"], ...ls.map((x) => [x.id, x.nombre])]
.map(([v, t]) => h("option", { value: v }, t)));
selLugarMon.value = m && m.activo === activo.value && ls.some((x) => x.id === m.lugar) ? m.lugar : "";
};
activo.addEventListener("change", pintarLugarMon);
pintarLugarMon();
const hueco = entradaNum({ value: (m && m.hueco) || 20 });
const actividadMon = campoActividad(m ? m.actividad : null);
const parteMon = entradaNum({ value: String(Math.round(((m && m.parte) || 1) * 10000) / 100).replace(".", ","),
estilo: { width: "8ch" } });
// Monederos que no enseñan su clave extendida: se vigilan sus direcciones una a una
let sueltas = !!(m && m.tipo === "sueltas");
const direcciones = h("textarea", { rows: 7, spellcheck: "false", clase: "num",
estilo: { width: "100%", boxSizing: "border-box" },
placeholder: "bc1q…\n3…\nuna por línea" });
if (m && m.direcciones) direcciones.value = m.direcciones.join("\n");
const cuentaDirecciones = h("div", { clase: "muy-suave" });
const contar = () => {
const n = direcciones.value.split(/[\s,;]+/).filter(Boolean).length;
cuentaDirecciones.textContent = n ? pl`${n} dirección(es)` : "";
};
direcciones.addEventListener("input", contar);
contar();
const error = h("div", { clase: "error-form oculto" });
const botonDetectar = h("button", { type: "button", clase: "btn", onclick: async () => {
const texto = clave.value.trim();
if (!texto) { toast("Pega antes la clave.", true); return; }
botonDetectar.disabled = true;
botonDetectar.replaceChildren(h("span", { clase: "girando" }), " Mirando…");
try {
const { resultado } = await api("/api/btc/detectar", { clave: texto });
tipo.value = resultado.tipo;
toast(`Parece ${TIPOS_DIRECCION[resultado.tipo]}.`);
} catch (e) { toast(e.message, true); } finally {
botonDetectar.disabled = false;
botonDetectar.textContent = "Averiguar el tipo";
}
} }, "Averiguar el tipo");
const bloqueClave = h("div", { clase: "campos" },
campo("Clave pública extendida", clave, { completo: true,
ayuda: m ? "Déjalo vacío si no quieres cambiarla." : "Empieza por xpub, ypub o zpub; da igual cuál: el tipo de direcciones no se supone por eso." }),
campo("Tipo de direcciones", h("div", { estilo: { display: "flex", gap: "8px" } }, tipo, botonDetectar),
{ completo: true, ayuda: "bc1q…, bc1p…, 3… o 1…. Si no lo sabes, déjalo en «adivinarlo»: se le pregunta a tu nodo qué direcciones ha usado esa clave." }),
campo("Direcciones vacías seguidas antes de parar", hueco,
{ ayuda: "20 es lo normal. Súbelo si usas muchas direcciones." }));
const bloqueSueltas = h("div", {},
h("div", { clase: "aviso" }, h("span", {},
"Pon TODAS las direcciones que haya usado el monedero: las de recibir ", h("b", {}, "y las de cambio"),
". Cuando pagaste, la vuelta fue a una dirección nueva del propio monedero; si falta, parecerá bitcoin "
+ "que se fue fuera. Para encontrarla, abre cada envío en tu explorador (mempool del nodo): de las "
+ "salidas, la que no fue a quien pagaste es la tuya. Puedes volver aquí y añadir las que falten.")),
h("div", { clase: "campos" },
campo("Direcciones", h("div", {}, direcciones, cuentaDirecciones), { completo: true,
ayuda: "Una por línea (o separadas por comas). Vale cualquier tipo: bc1q…, bc1p…, 3…, 1…." })));
const modo = selector([["clave", "Con su clave pública (xpub, ypub o zpub)"],
["sueltas", "Con sus direcciones sueltas (vale para cualquier monedero)"]], sueltas ? "sueltas" : "clave");
const pintarModo = () => {
sueltas = modo.value === "sueltas";
bloqueClave.classList.toggle("oculto", sueltas);
bloqueSueltas.classList.toggle("oculto", !sueltas);
};
modo.addEventListener("change", pintarModo);
pintarModo();
if (!cripto.length) {
modal({ titulo: "Falta el activo", cuerpo: h("p", {},
"Primero da de alta el activo de bitcoin en la cartera (Más → Nuevo activo, tipo Cripto)."),
acciones: [{ texto: "Entendido", clase: "btn-primario", accion: (c) => c() }] });
return;
}
modal({
titulo: m ? "Editar monedero" : "Nuevo monedero", ancho: true,
cuerpo: h("div", {},
h("p", { clase: "nota-form", estilo: { marginTop: 0 } },
"En Sparrow: ", h("i", {}, "Settings → Script Policy → Export → xpub"),
". En un monedero del móvil suele estar en «exportar clave pública» o en la copia de seguridad "
+ "del monedero de solo lectura. ", h("b", {}, "Nunca pegues aquí las 12 o 24 palabras "
+ "ni una clave que empiece por «zprv»."), " Con la clave pública, quien la tenga puede ver "
+ "todo tu historial, así que se queda en este equipo."),
m ? null : h("div", { clase: "campos" }, campo("¿Cómo lo leo?", modo, { completo: true })),
h("div", { clase: "campos" },
campo("Nombre", nombre),
campo("Activo de la cartera", activo),
campo("Lugar", selLugarMon, { ayuda: "Dónde está dentro del activo. Dos monederos pueden compartir lugar." }),
campo("Mi parte del monedero (%)", parteMon, { ayuda: "100 si todo es tuyo. Si lo compartes con otra persona, pon tu parte "
+ "(50 = la mitad): de todo lo que entre y salga solo se apuntará esa parte, y lo del otro dueño no entra en tu cartera. "
+ "Si cambias esto con operaciones ya guardadas, «Revisar todo el historial» te dirá cuáles hay que rehacer." })),
bloqueClave, bloqueSueltas,
h("div", { clase: "campos" },
campo("Actividad", actividadMon.control, { completo: true,
ayuda: "Opcional: si es el monedero donde cobras la minería, lo que llegue aquí de ahora "
+ "en adelante se apuntará ya dentro de esa actividad. Las operaciones que ya tienes "
+ "apuntadas no cambian: para esas, usa «Marcar más operaciones» en la tarjeta de la "
+ "actividad." })),
m ? null : h("div", { clase: "aviso" }, h("span", {},
"Lo que ya hay en el monedero se da por apuntado: solo te avisaré de las operaciones nuevas.")),
error),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: m ? "Guardar" : "Añadir", clase: "btn-primario", accion: async (c) => {
const cuerpo = { id: m && m.id, nombre: nombre.value.trim(), activo: activo.value,
tipo: tipo.value || undefined, hueco: aNumero(hueco.value) || 20,
actividad: actividadMon.input.value.trim(), lugar: selLugarMon.value,
parte: (aNumero(parteMon.value) || 100) / 100 };
if (sueltas) {
delete cuerpo.tipo; delete cuerpo.hueco;
cuerpo.direcciones = direcciones.value;
} else if (clave.value.trim()) cuerpo.clave = clave.value.trim();
const ok = await guardarCon(null, error, async () => {
const { resultado } = await api("/api/btc/monedero", { monedero: cuerpo });
datos.monederos = resultado.monederos;
return true;
});
if (ok) { repintar(); c(); }
} },
],
});
}
async function comprobarBitcoin(automatico = false) {
const cerrarEspera = automatico ? null : modal({
titulo: "Consultando tu nodo",
cuerpo: h("p", {}, h("span", { clase: "girando" }),
" Preguntando a tu servidor Electrum por tus monederos. Por Tor puede tardar un minuto."),
});
let r;
try {
({ resultado: r } = await api("/api/btc/comprobar", {}));
} catch (e) {
cerrarEspera && cerrarEspera();
if (!automatico) toast(e.message, true);
return null;
}
cerrarEspera && cerrarEspera();
await cargar();
// Lo nuevo se revisa en la misma ventana que el historial completo
if (r.partes.length || r.ultima.cuadres.some(descuadre)) {
if (automatico && !r.partes.length) return r; // el descuadre ya se ve en el resumen
revisarHistorialBtc(r);
} else if (!automatico) {
toast(r.atrasadas
? `Nada nuevo en tu nodo. Quedan ${r.atrasadas} del pasado sin decidir en «Revisar todo el historial».`
: "Todo cuadra con tu nodo.");
}
return r;
}
/* ── Historial completo de bitcoin ──────────────────────────────────────── */
const CLASE_BTC = {
mineria: "Minado", entrada: "Entra de fuera", salida: "Sale a fuera",
interno: "Entre tus monederos", comision: "Comisión de red",
};
const ACCION_BTC = {
"": "— elige qué fue —", compra: "Compra", cobro: "Cobro (me pagaron en BTC)", regalo: "Regalo recibido",
mineria: "Minado", venta: "Venta", pago: "Pago o regalo (a valor de mercado)",
comision: "Comisión de red", enlazar: "Ya la tengo apuntada: enlazar", no_apuntar: "No apuntar nada",
};
const textoTraspaso = (clase) => (clase === "interno" ? "Traspaso a otro activo" : "Traspaso con otro activo (L-BTC…)");
const textoTraslado = (clase) => (clase === "entrada" ? "Viene de otro lugar mío (exchange, nodo…)"
: clase === "salida" ? "Va a otro lugar mío (exchange, nodo Lightning…)" : "Traslado entre mis monederos");
const textoAccionBtc = (a, clase) => (a === "traspaso" ? textoTraspaso(clase) : a === "traslado" ? textoTraslado(clase) : ACCION_BTC[a]);
const CON_IMPORTE = ["compra", "venta", "pago", "cobro", "regalo"];
// Cuánto cambia cada monedero (activo@lugar) si se guarda la parte con esa acción
const claveBtc = (activo, lugar) => (lugar ? `${activo}@${lugar}` : activo);
function efectoParte(p, accion, otro, enlace) {
const q = p.cantidad, e = {}, aqui = claveBtc(p.activo, p.lugar);
const suma = (k, v) => { if (k) e[k] = (e[k] || 0) + v; };
if (accion === "enlazar") {
// Enlazar no cambia cifras, pero lo apuntado «sin lugar» pasa a estar en este monedero
const c = (p.candidatas || []).find((x) => x.id === enlace);
if (!c) return e;
if (c.tipo === "Traslado") {
if (p.clase === "entrada" && !c.lugar_destino) suma(aqui, c.cantidad);
else if (p.clase === "salida" && !c.lugar) suma(aqui, -c.cantidad);
else if (p.clase === "interno" && !c.lugar) suma(aqui, -c.cantidad);
else if (p.clase === "interno" && !c.lugar_destino) suma(claveBtc(p.destino, p.destino_lugar), c.cantidad);
} else if (!c.lugar && p.lugar) {
suma(aqui, (["Compra", "Mineria", "Recompensa", "Traspaso_Entrada"].includes(c.tipo) ? 1 : -1) * c.cantidad);
}
return e;
}
if (["compra", "cobro", "regalo", "mineria"].includes(accion)) suma(aqui, q);
else if (["venta", "pago", "comision"].includes(accion)) suma(aqui, -q);
else if (accion === "traspaso" || accion === "traslado") {
const alla = p.clase === "interno" ? claveBtc(p.destino, p.destino_lugar)
: accion === "traslado" ? claveBtc(p.activo, otro) : otro;
if (p.clase === "entrada") { suma(aqui, q); suma(alla, -q); } else { suma(aqui, -q); suma(alla, q); }
}
return e;
}
async function importarHistorialBtc(leer = false) {
if (!leer) {
// Primero, lo último leído en este equipo (al instante). Si no hay nada
// guardado o han cambiado los monederos, se pregunta al nodo.
let r;
try { ({ resultado: r } = await api("/api/btc/historial", { leer: false })); } catch { return importarHistorialBtc(true); }
revisarHistorialBtc(r);
return;
}
const cerrarEspera = modal({
titulo: "Leyendo todo tu historial",
cuerpo: h("p", {}, h("span", { clase: "girando" }),
" Pido a tu servidor Electrum las transacciones de todos tus monederos. La primera vez, por Tor y "
+ "con muchos movimientos, puede tardar varios minutos; después solo trae lo nuevo. No se apunta nada "
+ "hasta que lo revises."),
});
let r;
try {
({ resultado: r } = await api("/api/btc/historial", { leer: true }));
} catch (e) { cerrarEspera(); toast(e.message, true); return; }
cerrarEspera();
revisarHistorialBtc(r);
}
// Sirve para todo el historial y para la comprobación diaria (r.diaria), que
// trae solo lo nuevo
function revisarHistorialBtc(r) {
const diaria = !!r.diaria;
const otrosActivos = (id) => r.activos.filter((a) => a.id !== id);
const nombre = (id) => (r.activos.find((a) => a.id === id) || {}).nombre || id;
const filtroClase = selector([["", "Todo"], ...Object.entries(CLASE_BTC)], "");
const filtroEstado = selector([["", "Decididas y sin decidir"], ["sin", "Solo sin decidir"], ["enlazar", "Solo enlaces propuestos"]], "");
const años = [...new Set(r.partes.map((p) => p.fecha.slice(0, 4)))].sort();
const filtroAño = selector([["", "Todos los años"], ...años.map((a) => [a, a])], "");
const progresoEl = h("p", { clase: "muy-suave", estilo: { margin: "4px 0" } });
const pintarProgreso = () => {
if (diaria) {
progresoEl.textContent = `Leído del nodo ${fechaHora(r.leido)}. Lo que no decidas ahora volverá a salir en la próxima comprobación.`
+ (r.atrasadas ? ` Quedan además ${r.atrasadas} del pasado sin decidir: están en «Ver todo el historial».` : "");
return;
}
const { total, hechas } = r.progreso;
progresoEl.textContent = `Llevas ${hechas} de ${cuantos(total, "decisión guardada", "decisiones guardadas")} (${total ? Math.floor(hechas * 100 / total) : 100} %)`
+ ` · leído del nodo ${fechaHora(r.leido)}. Puedes cerrar y seguir otro rato: lo que dejes elegido sin guardar se recuerda.`;
};
// Lo elegido y aún sin guardar se manda al servidor poco a poco
const borradorPendiente = {};
let temporizadorBorrador = null;
const guardarBorrador = (f) => {
borradorPendiente[f.p.id] = { accion: f.accion.value, importe: f.importePropio ? aEuros(f.importe.value) : null,
otro: f.otro ? f.otro.value : null, enlace: f.accion.value === "enlazar" ? f.enlace.value : null };
clearTimeout(temporizadorBorrador);
temporizadorBorrador = setTimeout(async () => {
const envio = { ...borradorPendiente };
for (const k of Object.keys(envio)) delete borradorPendiente[k];
try { await api("/api/btc/historial/borradores", { borradores: envio }); } catch (e) { toast(e.message, true); }
}, 800);
};
const kpis = h("div", { clase: "rejilla rejilla-kpi" });
const cuentaEl = h("strong", {});
const accionBloque = selector([["", "— acción —"], ...Object.entries(ACCION_BTC).filter(([k]) => k && k !== "enlazar"),
["traspaso", "Traspaso con otro activo"]], "");
const destinoBloque = selector([["", "— activo —"], ...r.activos.map((a) => [a.id, a.nombre])], "");
const todas = h("input", { type: "checkbox", "aria-label": "Marcar todas las filas visibles" });
const cuerpoTabla = h("tbody", {});
const filas = r.partes.map((p) => {
const f = { p, marcada: false };
f.casilla = h("input", { type: "checkbox", "aria-label": `Marcar la del ${fecha(p.fecha)}` });
f.accion = selector(p.acciones.map((a) => [a, textoAccionBtc(a, p.clase)])
.concat(p.accion === "" ? [["", ACCION_BTC[""]]] : []), p.accion, { estilo: { maxWidth: "24ch" } });
f.importe = entradaNum({ value: p.importe_ref ?? "", clase: "num", estilo: { width: "10ch" },
"aria-label": "Importe en euros" });
f.importePropio = false;
f.importe.addEventListener("input", () => { f.importePropio = true; });
// El «otro» extremo: otro activo (traspaso) u otro lugar del mismo activo (traslado)
const otrosLugares = () => ((r.lugares || {})[p.activo] || []).filter((x) => x.id !== p.lugar);
f.otro = p.clase === "interno" ? null : selector([], "", { estilo: { maxWidth: "22ch" } });
f.otroDe = null;
f.pintarOtro = () => {
if (!f.otro) return;
const a = f.accion.value, de = a === "traslado" ? "lugar" : "activo";
if (f.otroDe === de) return;
const antes = f.otro.value;
f.otroDe = de;
const ops = de === "lugar" ? otrosLugares().map((x) => [x.id, x.nombre]) : otrosActivos(p.activo).map((a2) => [a2.id, a2.nombre]);
f.otro.replaceChildren(...[["", "— ¿cuál? —"], ...ops].map(([v2, t]) => h("option", { value: v2 }, t)));
if (ops.some(([v2]) => v2 === antes)) f.otro.value = antes;
};
f.enlace = selector(p.candidatas.map((c) => {
const prima = c.importe && p.precio_ref && c.cantidad
? ` · ${pct(c.importe / (c.cantidad * p.precio_ref) - 1)} sobre el cierre` : "";
const que = c.tipo === "Traslado" ? `Traslado ${c.desde} → ${c.hacia}` : (TIPOS_MOV[c.tipo] || c.tipo);
return [c.id, `${c.exacta ? "✓ " : ""}${que} · ${fecha(c.fecha, true)} · `
+ `${num(c.cantidad)} BTC${c.importe ? " · " + eur(c.importe) : ""}${prima}`];
}), p.enlace || (p.candidatas[0] || {}).id, { estilo: { maxWidth: "25ch" } });
f.detalle = h("div", {});
f.fila = h("tr", {});
const b = p.borrador;
if (b) {
if (b.accion && p.acciones.includes(b.accion)) f.accion.value = b.accion;
if (b.importe !== undefined) { f.importe.value = b.importe; f.importePropio = true; }
if (b.otro && f.otro) { f.pintarOtro(); f.otro.value = b.otro; }
if (b.enlace && p.candidatas.some((c) => c.id === b.enlace)) f.enlace.value = b.enlace;
}
const repintarFila = () => {
const a = f.accion.value;
f.pintarOtro();
f.detalle.replaceChildren(...[
CON_IMPORTE.includes(a) ? h("label", { clase: "suave" }, f.importe, " €") : null,
(a === "traspaso" || a === "traslado") && f.otro ? f.otro : null,
a === "traspaso" && p.clase === "interno" ? h("span", { clase: "suave" }, `a ${nombre(p.destino)}`) : null,
a === "traslado" && p.clase === "interno" ? h("span", { clase: "suave" }, `a ${p.nombre_destino || "otro monedero"}`) : null,
a === "traspaso" && f.otro && !otrosActivos(p.activo).length
? h("div", { clase: "muy-suave" }, "No tienes otro activo cripto.") : null,
a === "traslado" && f.otro && !otrosLugares().length
? h("div", { clase: "muy-suave" }, "Crea antes el lugar en la ficha del activo (p. ej. tu nodo Lightning).") : null,
a === "enlazar" ? (p.candidatas.length ? f.enlace
: h("span", { clase: "muy-suave" }, "No veo ninguna operación apuntada parecida.")) : null,
a === "no_apuntar" ? h("span", { clase: "muy-suave" }, "No se guarda nada y no vuelve a salir.") : null,
a === "mineria" || a === "comision" ? h("span", { clase: "muy-suave" }, "Sin euros") : null,
].filter(Boolean));
f.fila.classList.toggle("marcada", f.marcada);
recalcular();
};
f.casilla.addEventListener("change", () => { f.marcada = f.casilla.checked; repintarFila(); });
f.accion.addEventListener("change", () => { repintarFila(); guardarBorrador(f); });
f.otro && f.otro.addEventListener("change", () => { recalcular(); guardarBorrador(f); });
f.importe.addEventListener("change", () => guardarBorrador(f));
f.enlace.addEventListener("change", () => { recalcular(); guardarBorrador(f); });
f.fila.append(
h("td", {}, f.casilla),
h("td", { clase: "num" }, fecha(p.fecha, true), p.confirmada ? null : h("div", { clase: "muy-suave" }, "sin confirmar")),
h("td", {}, h("div", {}, CLASE_BTC[p.clase], p.mixta ? h("span", { clase: "insignia aviso-i",
title: "Con entradas de otros (coinjoin, payjoin) o sin leer entera: la cantidad es el efecto neto" }, "mixta") : null),
h("div", { clase: "muy-suave" }, p.nombre_activo
+ ((p.monederos || []).length && !p.nombre_activo.endsWith(" · " + p.monederos.join(", ")) ? ` · ${p.monederos.join(", ")}` : "")
+ (p.actividad ? ` · ${p.actividad}` : "")),
p.pista ? h("div", { clase: "muy-suave" }, p.pista) : null,
h("div", { clase: "muy-suave num", title: p.txid }, p.txid.slice(0, 16) + "…")),
h("td", { clase: "d num" }, num(p.cantidad),
p.precio_ref ? h("div", { clase: "muy-suave", title: `Cierre del ${fecha(p.dia_precio)}` },
`${eur(p.precio_ref)}/BTC`) : h("div", { clase: "muy-suave" }, "sin precio")),
h("td", {}, f.accion),
h("td", {}, f.detalle));
f.pintar = repintarFila;
return f;
});
const visibles = () => filas.filter((f) => (!filtroClase.value || f.p.clase === filtroClase.value)
&& (!filtroAño.value || f.p.fecha.startsWith(filtroAño.value))
&& (filtroEstado.value !== "sin" || !f.accion.value)
&& (filtroEstado.value !== "enlazar" || f.accion.value === "enlazar"));
function recalcular() {
const marcadas = filas.filter((f) => f.marcada);
const decididas = marcadas.filter((f) => f.accion.value);
cuentaEl.textContent = `${cuantos(marcadas.length, "marcada", "marcadas")}, ${decididas.length} con decisión`;
const efecto = {};
for (const f of decididas) {
for (const [k, v] of Object.entries(efectoParte(f.p, f.accion.value, f.otro && f.otro.value, f.enlace.value))) {
efecto[k] = (efecto[k] || 0) + v;
}
}
// Un cuadro por monedero (lugar): lo que ve el nodo frente a lo apuntado ahí
const sinLugar = Object.entries(r.sin_lugar || {}).filter(([, q]) => Math.abs(q) > 1e-8);
kpis.replaceChildren(...sinLugar.map(([aid, q]) => kpi(`${nombre(aid)} · sin lugar`, `${num(q)} BTC`,
"Lo apuntado sin decir dónde (lo de antes de los lugares) y los traslados de exchanges a los que aún les falta un extremo. Baja al enlazarlos aquí.", "")),
...r.cuadres.map((c) => {
const tras = (c.apuntado ?? 0) + (efecto[c.clave || c.activo] || 0);
const cuadra = Math.abs(tras - c.en_nodo) < 1e-8;
return kpi(`${c.nombre_activo} · en el nodo` + (c.parte ? ` (tu ${num(c.parte * 100, 2)} %)` : ""), `${num(c.en_nodo)} BTC`,
`Apuntado ${num(c.apuntado)} · tras guardar lo marcado ${num(tras)}` + (cuadra ? " · cuadra" : ""),
cuadra ? "pos" : "neg");
}));
const vis = visibles();
todas.checked = vis.length > 0 && vis.every((f) => f.marcada);
todas.indeterminate = !todas.checked && vis.some((f) => f.marcada);
}
const pintarTabla = () => {
const vis = new Set(visibles());
cuerpoTabla.replaceChildren(...filas.filter((f) => vis.has(f)).map((f) => f.fila));
recalcular();
};
todas.addEventListener("change", () => {
for (const f of visibles()) { f.marcada = todas.checked; f.casilla.checked = todas.checked; f.fila.classList.toggle("marcada", f.marcada); }
recalcular();
});
filtroClase.addEventListener("change", pintarTabla);
filtroEstado.addEventListener("change", pintarTabla);
filtroAño.addEventListener("change", pintarTabla);
const aplicarBloque = h("button", { type: "button", clase: "btn", onclick: () => {
const a = accionBloque.value;
if (!a) { toast("Elige la acción.", true); return; }
let hechas = 0, saltadas = 0;
for (const f of filas.filter((x) => x.marcada)) {
if (!f.p.acciones.includes(a)) { saltadas++; continue; }
f.accion.value = a;
if (a === "traspaso" && f.otro && destinoBloque.value && destinoBloque.value !== f.p.activo) f.otro.value = destinoBloque.value;
f.pintar();
guardarBorrador(f);
hechas++;
}
toast(pl`${hechas} cambiada(s)` + (saltadas ? `; ${saltadas} no admiten esa acción.` : "."));
} }, "Aplicar a las marcadas");
// En la diaria son pocas: lo que ya trae propuesta sale marcado, listo para guardar
if (diaria) for (const f of filas) { f.marcada = !!f.accion.value; f.casilla.checked = f.marcada; }
filas.forEach((f) => f.pintar());
const cuerpo = h("div", {},
h("p", { clase: "nota-form", estilo: { marginTop: 0 } },
(diaria ? pl`${r.partes.length} cosa(s) nueva(s) en tus monederos desde la última vez. `
: `${cuantos(r.transacciones, "transacción", "transacciones")} en tus monederos · ${r.ya_apuntadas} ya ${r.ya_apuntadas === 1 ? "guardada" : "guardadas"} desde el nodo · `
+ `${cuantos(r.partes.length, "cosa", "cosas")} por decidir. `)
+ "Todos tus monederos cuentan como uno: pasar bitcoin de uno a otro no es "
+ "ni compra ni venta. Lo que ya tienes apuntado no se toca: como mucho se le enlaza el txid."),
progresoEl,
r.fallos && r.fallos.length ? h("p", { clase: "muy-suave" },
"Servidores que no contestaron: " + r.fallos.join(" · ")) : null,
avisoCambiadas(r.cambiadas || [], () => { cerrarRevision && cerrarRevision(); importarHistorialBtc(false); }),
kpis,
r.cortados && r.cortados.length ? h("div", { clase: "aviso" }, h("span", {},
`He parado de buscar direcciones antes de acabar en: ${r.cortados.join(", ")}. Sube su hueco.`)) : null,
r.ilegibles && r.ilegibles.length ? h("div", { clase: "aviso" }, h("span", {},
pl`${r.ilegibles.length} transacción(es) no se han podido leer; vuelve a intentarlo más tarde.`)) : null,
h("div", { clase: "filtros" }, filtroAño, filtroClase, filtroEstado),
h("div", { clase: "barra-seleccion" }, cuentaEl, accionBloque, destinoBloque, aplicarBloque),
r.partes.length ? h("div", { clase: "tabla-marco", estilo: { maxHeight: "50vh", overflowY: "auto" } }, h("table", {},
h("thead", {}, h("tr", {}, h("th", {}, todas), h("th", {}, "Fecha"), h("th", {}, "Qué ha pasado"),
h("th", { clase: "d" }, "Bitcoin"), h("th", {}, "Qué fue"), h("th", {}, ""))),
cuerpoTabla)) : h("p", { clase: "vacio" }, diaria ? "No hay nada nuevo en tus monederos."
: "No queda nada por decidir: todo tu historial está apuntado."),
h("p", { clase: "muy-suave" },
"Los euros son una estimación con el cierre del día (Bitstamp): en una compra P2P pon lo que pagaste "
+ "de verdad. Lo que guardes queda como «estimada» hasta que lo confirmes, y toda la importación se "
+ "puede deshacer desde «Más › Bitcoin»."));
pintarTabla();
pintarProgreso();
let cerrarRevision;
cerrarRevision = modal({
titulo: diaria ? "Bitcoin: lo nuevo que ve tu nodo" : "Bitcoin: todo tu historial", ancho: true, cuerpo,
acciones: [
diaria ? { texto: "Ver todo el historial", izq: true, accion: (c) => { c(); importarHistorialBtc(false); } }
: { texto: "Volver a leer el nodo", izq: true, accion: (c) => { c(); importarHistorialBtc(true); } },
{ texto: "Cerrar", accion: (c) => c() },
r.partes.length ? { texto: "Guardar las marcadas", clase: "btn-primario", accion: async () => {
const elegidas = filas.filter((f) => f.marcada && f.accion.value);
if (!elegidas.length) { toast("Marca alguna fila con su decisión.", true); return; }
const sinOtro = elegidas.filter((f) => ["traspaso", "traslado"].includes(f.accion.value) && f.otro && !f.otro.value);
if (sinOtro.length) { toast(pl`A ${sinOtro.length} traspaso(s) les falta el otro activo.`, true); return; }
const decisiones = elegidas.map((f) => {
const a = f.accion.value, d = { ...f.p, accion: a };
delete d.candidatas;
if (CON_IMPORTE.includes(a)) { d.importe = aEuros(f.importe.value) || null; d.importe_propio = f.importePropio; }
if (a === "traspaso" && f.otro) d[f.p.clase === "entrada" ? "origen" : "destino"] = f.otro.value;
if (a === "traslado" && f.otro) d.otro_lugar = f.otro.value;
if (a === "enlazar") d.enlace = f.enlace.value || null;
return d;
});
try {
const { resultado: res } = await api("/api/btc/historial/aplicar", { decisiones });
const hechas = new Set(res.partes || []);
for (const f of filas.filter((x) => hechas.has(x.p.id))) filas.splice(filas.indexOf(f), 1);
r.partes = filas.map((f) => f.p);
// Lo apuntado en cada lugar, tal como ha quedado (un enlace también mueve de «sin lugar» al monedero)
try {
const { resultado: ahora } = await api("/api/btc/historial", { leer: false });
r.cuadres = ahora.cuadres;
r.sin_lugar = ahora.sin_lugar;
} catch { /* se verá al volver a abrir */ }
r.progreso.hechas += hechas.size;
pintarTabla();
pintarProgreso();
toast(pl`Guardado: ${res.creados} operación(es) nueva(s), ${res.enlazados} enlazada(s).`);
if (res.fallos && res.fallos.length) {
modal({ titulo: "Algunas no se han podido guardar", ancho: true,
cuerpo: h("div", {}, h("p", {}, "Las demás sí. Estas siguen en la lista:"),
h("ul", {}, res.fallos.map((x) => h("li", {}, `${fecha(x.fecha)} · ${ACCION_BTC[x.tipo] || x.tipo}: ${x.motivo}`)))),
acciones: [{ texto: "Cerrar", clase: "btn-primario", accion: (c2) => c2() }] });
}
await cargar();
if (diaria && !filas.length) cerrarRevision(); // no queda nada nuevo
} catch (e) { toast(e.message, true); }
} } : null,
].filter(Boolean),
});
}
// Decisiones ya guardadas que han dejado de encajar (normalmente, por añadir un monedero)
function avisoCambiadas(cambiadas, reabrir) {
if (!cambiadas.length) return null;
const COMO = { ...ACCION_BTC, traspaso: "traspaso" };
const ids = cambiadas.flatMap((c) => c.antes.map((a) => a.id));
const deshacer = async (lista, pregunta, boton) => {
if (!(await confirmar(pregunta, boton, true))) return;
try {
await api("/api/btc/historial/deshacer_partes", { ids: lista });
toast(lista.length === 1 ? "Deshecha. Vuelve a decidirla en la lista." : `${lista.length} deshechas. Vuelve a decidirlas en la lista.`);
await cargar();
reabrir();
} catch (e) { toast(e.message, true); }
};
const MAX = 15; // con un monedero recién marcado como compartido pueden ser cientos
return h("div", { clase: "aviso" }, h("div", {},
h("b", {}, pl`${cambiadas.length} transacción(es) ya guardada(s) han cambiado`),
" desde que las decidiste: has añadido un monedero (y ahora se ve que el bitcoin no salió a fuera sino a otro "
+ "monedero tuyo) o has cambiado tu parte de un monedero compartido (y la cantidad que es tuya ya no es la que "
+ "guardaste). Deshaz esas decisiones y vuelve a decidirlas. ",
ids.length > 1 ? h("button", { type: "button", clase: "btn", onclick: () => deshacer(ids,
`Se borran las operaciones que crearon esas ${ids.length} decisiones (o se quitan sus enlaces) y vuelven a quedar por decidir en esta lista. Lo demás no se toca.`,
`Deshacer las ${ids.length}`) }, `Deshacer las ${ids.length} decisiones de una vez`) : null,
h("ul", {}, cambiadas.slice(0, MAX).map((c) => h("li", {}, `${fecha(c.fecha)} · ${c.txid.slice(0, 12)}… · `,
c.antes.map((a) => h("span", {}, `${CLASE_BTC[a.id.split(":")[1]] || a.id}: «${COMO[a.como] || a.como}» `,
h("button", { type: "button", clase: "btn-enlace", onclick: () => deshacer([a.id],
"Se borran las operaciones que creó esa decisión (o se quita su enlace) y vuelve a quedar por decidir.",
"Deshacer esa decisión") }, "Deshacer esa decisión")))))),
cambiadas.length > MAX ? h("p", { clase: "muy-suave" }, `…y ${cambiadas.length - MAX} más.`) : null));
}
/* ── Compras y ventas de bitcoin: lo pagado de verdad, la prima y los justificantes ── */
function ventanaPrimas(filtroInicial = null) {
const lista = () => estado.datos.movimientos.filter((m) => primaDe(m));
const FILTROS = {
estimadas: ["Con el importe estimado", (m) => !!m.estimado],
lejos: ["Precio lejos del mercado", primaLejos],
sin: ["Sin justificante", (m) => !(m.adjuntos || []).length],
"": ["Todas", () => true],
};
const inicial = filtroInicial ?? (lista().some(FILTROS.estimadas[1]) ? "estimadas" : lista().some(primaLejos) ? "lejos" : "");
const filtro = selector(Object.entries(FILTROS).map(([k, [texto]]) => [k, texto]), inicial);
const años = [...new Set(lista().map((m) => m.fecha.slice(0, 4)))].sort().reverse();
const filtroAño = selector([["", "Todos los años"], ...años.map((a) => [a, a])], "");
const resumenEl = h("p", { clase: "nota-form", estilo: { marginTop: 0 } });
const cuentaEl = h("span", { clase: "suave" });
const cuerpoTabla = h("tbody", {});
const MAX = 300;
const fila = (m) => {
const p = primaDe(m);
const inImporte = entradaNum({ value: String(m.importe).replace(".", ","), clase: "num", estilo: { width: "10ch" },
"aria-label": "Importe en euros", title: "Puedes sumar varios pagos: 350 + 420,50" });
// Los rótulos son para el móvil, donde cada operación es una ficha y no una fila
const celdaPrima = h("td", { clase: "d num", "data-rotulo": "Prima" });
const acciones = h("div", {});
const hacer = async (ruta, cuerpo, texto) => {
try { await api(ruta, cuerpo); toast(texto); await cargar(); pintar(); } catch (e) { toast(e.message, true); }
};
const repintar = () => {
const importe = aEurosSuma(inImporte.value);
const valido = importe !== null && !Number.isNaN(importe) && importe > 0;
const cambiado = valido && Math.abs(importe - m.importe) > 0.004;
const prima = valido ? importe / (m.cantidad * p.ref) - 1 : null;
const contra = prima === null ? 0 : (m.tipo === "Compra" ? prima : -prima);
const lejos = !cambiado ? primaLejos(m) : (contra > 0.15 || contra < -0.05);
celdaPrima.replaceChildren(...[
h("div", { clase: lejos ? "neg" : "" }, prima === null ? "—" : pct(prima)),
valido ? h("div", { clase: "muy-suave", title: "Lo que pagaste de más (o de menos) frente al cierre de ese día" },
eurSigno(importe - m.cantidad * p.ref)) : null,
valido && inImporte.value.includes("+") ? h("div", { clase: "muy-suave" }, `suma: ${eur(importe)}`) : null,
].filter(Boolean));
acciones.replaceChildren(...[
cambiado ? h("button", { type: "button", clase: "btn btn-primario",
onclick: () => hacer("/api/movimiento/importe", { id: m.id, importe }, "Importe guardado.") }, "Guardar") : null,
!cambiado && m.estimado ? h("button", { type: "button", clase: "btn-enlace", title: "El importe estimado coincide con lo que pagaste",
onclick: () => hacer("/api/movimientos/confirmar", { ids: [m.id] }, "Confirmada.") }, "Es el bueno") : null,
!cambiado && primaLejos(m) ? h("button", { type: "button", clase: "btn-enlace", title: "La cifra es correcta: no avisar más de esta operación",
onclick: () => hacer("/api/movimientos/prima", { ids: [m.id], ok: true }, "No volveré a avisar de esta.") }, "La cifra es buena") : null,
!cambiado && m.prima_ok && p.lejos ? h("button", { type: "button", clase: "btn-enlace",
onclick: () => hacer("/api/movimientos/prima", { ids: [m.id], ok: false }, "Vuelve a contar como rara.") }, "Quitar «revisada»") : null,
h("button", { type: "button", clase: "btn-icono", title: "Editar la operación entera", "aria-label": "Editar",
onclick: () => formOperacion({ movimiento: m, alGuardar: async () => { await cargar(); pintar(); } }) }, "✎"),
].filter(Boolean));
};
inImporte.addEventListener("input", repintar);
const entradaF = h("input", { type: "file", multiple: true, accept: EXTENSIONES_ADJUNTO, clase: "oculto" });
entradaF.addEventListener("change", async () => {
const elegidos = [...entradaF.files];
if (!elegidos.length) return;
await subirAdjuntos(m.id, elegidos);
await cargar();
pintar();
});
repintar();
const n = (m.adjuntos || []).length;
return h("tr", {},
h("td", { clase: "num" }, fecha(m.fecha, true)),
h("td", {}, TIPOS_MOV[m.tipo], m.lugar ? h("span", { clase: "suave" }, ` · ${nombreLugar(m.activo, m.lugar)}`) : null,
m.estimado ? h("span", { clase: "insignia aviso-i" }, "estimada") : null,
m.prima_ok && p.lejos ? h("span", { clase: "insignia info-i" }, "revisada") : null,
m.notas ? h("div", { clase: "muy-suave", title: m.notas,
estilo: { maxWidth: "18ch", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, m.notas) : null),
h("td", { clase: "d num", "data-rotulo": "Bitcoin" }, num(m.cantidad)),
h("td", { clase: "d", "data-rotulo": "Importe €" }, inImporte),
h("td", { clase: "d num", title: `Cierre del ${fecha(p.dia)}`, "data-rotulo": "Mercado ese día" }, eur(p.ref)),
celdaPrima,
h("td", { estilo: { whiteSpace: "normal", minWidth: "17ch" } },
h("div", {}, n ? h("span", { title: (m.adjuntos || []).map((a) => a.nombre).join("\n") }, `📎 ${n} `) : null,
h("button", { type: "button", clase: "btn-enlace", onclick: () => entradaF.click() }, n ? "Añadir más" : "Adjuntar"), entradaF),
acciones));
};
function pintar() {
const todas = lista();
// Lo que pagaste de más en total: solo las compras con importe bueno
const compras = todas.filter((m) => m.tipo === "Compra" && !m.estimado);
const pagado = compras.reduce((s, m) => s + m.importe, 0);
const mercado = compras.reduce((s, m) => s + m.cantidad * primaDe(m).ref, 0);
resumenEl.textContent = (compras.length
? pl`En tus ${compras.length} compra(s) con importe confirmado pagaste ${eur(pagado)} por bitcoin que al cierre de esos días valía `
+ `${eur(mercado)}: una prima media del ${pct(pagado / mercado - 1)}. `
: "")
+ "El importe es lo que pagaste (o cobraste) de verdad, con comisiones; si una compra son varios pagos, escríbelos sumados "
+ "(350 + 420,50) y adjunta un justificante por cada uno. Cambiar el importe aquí no toca la cantidad.";
const vis = todas.filter(FILTROS[filtro.value][1]).filter((m) => !filtroAño.value || m.fecha.startsWith(filtroAño.value));
cuentaEl.textContent = `${vis.length} de ${todas.length}` + (vis.length > MAX ? ` · se ven las ${MAX} más recientes` : "");
cuerpoTabla.replaceChildren(...(vis.length ? vis.slice(0, MAX).map(fila)
: [h("tr", {}, h("td", { colspan: 7, clase: "vacio" }, todas.length ? "Ninguna con ese filtro." : "No tienes compras ni ventas de bitcoin apuntadas."))]));
}
filtro.addEventListener("change", pintar);
filtroAño.addEventListener("change", pintar);
pintar();
modal({
titulo: "Bitcoin: lo que pagaste frente al mercado", ancho: true,
cuerpo: h("div", {}, resumenEl,
h("div", { clase: "filtros" }, filtro, filtroAño, cuentaEl),
h("div", { clase: "tabla-marco marco-primas", estilo: { maxHeight: "55vh", overflowY: "auto" } }, h("table", { clase: "tabla-primas" },
h("thead", {}, h("tr", {}, h("th", {}, "Fecha"), h("th", {}, "Operación"), h("th", { clase: "d" }, "Bitcoin"),
h("th", { clase: "d" }, "Importe €"), h("th", { clase: "d", title: "Cierre del bitcoin ese día" }, "Mercado"), h("th", { clase: "d" }, "Prima"),
h("th", {}, "Justificantes"))),
cuerpoTabla)),
h("p", { clase: "muy-suave" },
"La prima compara tu precio con el cierre del día (Bitstamp). Se avisa cuando sale más de un 15 % peor que el mercado "
+ "o más de un 5 % mejor, que suele ser una errata en el importe, la cantidad o la fecha. Si la cifra es buena, márcalo y no se avisa más.")),
acciones: [{ texto: "Cerrar", clase: "btn-primario", accion: (c) => c() }],
});
}
/* ── Mis monedas: de dónde viene cada una ───────────────────────────────── */
const proporcion = (f) => `${(f * 100).toLocaleString("es-ES", { maximumFractionDigits: 1 })} %`;
function textoOrigen(o) {
if (!o) return "—";
if (o.clase === "ajeno" || o.clase === "desconocido") return o.texto;
if (o.clase === "mineria") return "Minado";
if (o.desde) return `Desde ${o.desde}`;
const como = { compra: "Compra", cobro: "Cobro", regalo: "Regalo recibido", mineria: "Minado",
enlazar: o.tipo ? TIPOS_MOV[o.tipo] || o.tipo : "Apuntada" }[o.como];
return como ? como + (o.notas ? ` · ${o.notas.split(" · del nodo")[0]}` : "") : "Entrada aún sin decidir";
}
async function ventanaMonedasBtc() {
let r;
try { ({ resultado: r } = await api("/api/btc/monedas", {})); } catch (e) { toast(e.message, true); return; }
const porMonedero = selector([["", "Todos los monederos"], ...[...new Set(r.monedas.map((m) => m.monedero))].map((n) => [n, n])], "");
const cuerpoTabla = h("tbody", {});
const pintarLista = () => cuerpoTabla.replaceChildren(...r.monedas.filter((m) => !porMonedero.value || m.monedero === porMonedero.value)
.map((m) => h("tr", {},
h("td", { clase: "num" }, m.fecha ? fecha(m.fecha, true) : "sin confirmar"),
h("td", {}, m.monedero, h("div", { clase: "muy-suave" }, m.nombre_activo)),
h("td", { clase: "d num" }, num(m.cantidad)),
h("td", {}, m.origenes.slice(0, 3).map((o) => h("div", {},
`${proporcion(o.fraccion)} · ${textoOrigen(r.origenes[o.clave])}`,
r.origenes[o.clave] && r.origenes[o.clave].fecha ? h("span", { clase: "muy-suave" }, ` (${fecha(r.origenes[o.clave].fecha, true)})`) : null)),
m.origenes.length > 3 ? h("div", { clase: "muy-suave" }, pl`y ${m.origenes.length - 3} origen(es) más`) : null),
h("td", { clase: "d" }, h("button", { type: "button", clase: "btn-enlace", onclick: () => trazaMonedaBtc(m) }, "De dónde viene")))));
porMonedero.addEventListener("change", pintarLista);
pintarLista();
const total = r.monedas.reduce((a, m) => a + m.cantidad, 0);
modal({
titulo: "Mis monedas (UTXO)", ancho: true,
cuerpo: h("div", {},
h("p", { clase: "nota-form", estilo: { marginTop: 0 } },
`${r.monedas.length} monedas sin gastar, ${num(total)} BTC en total, según lo leído del nodo ${fechaHora(r.leido)}. `
+ "Cada una se sigue hacia atrás hasta donde entró: una compra, un minado o algo que llegó de fuera. Cuando una "
+ "transacción juntó varias monedas, la nueva viene un poco de cada una, en proporción a lo que valía cada una."),
h("div", { clase: "filtros" }, porMonedero),
r.monedas.length ? h("div", { clase: "tabla-marco", estilo: { maxHeight: "55vh", overflowY: "auto" } }, h("table", {},
h("thead", {}, h("tr", {}, h("th", {}, "Llegó"), h("th", {}, "Monedero"), h("th", { clase: "d" }, "Bitcoin"),
h("th", {}, "De dónde viene"), h("th", {}))), cuerpoTabla)) : h("p", { clase: "vacio" }, "No hay monedas sin gastar.")),
acciones: [{ texto: "Cerrar", clase: "btn-primario", accion: (c) => c() }],
});
}
async function trazaMonedaBtc(m) {
let t;
try { ({ resultado: t } = await api("/api/btc/traza", { txid: m.txid, indice: m.indice })); } catch (e) { toast(e.message, true); return; }
const CLASE_NODO = { paso: "Pasó por una transacción tuya", origen: "Entró de fuera", mineria: "Minado",
desconocido: "Transacción que no he podido leer" };
const nodo = (n) => h("li", {},
h("span", {}, `${n.fecha ? fecha(n.fecha, true) : "sin fecha"} · ${CLASE_NODO[n.clase] || n.clase} · `,
h("b", { clase: "num" }, `${num(n.atribuido / 1e8)} BTC`),
n.monedero ? h("span", { clase: "muy-suave" }, ` · ${n.monedero}`) : null,
h("span", { clase: "muy-suave num", title: n.txid }, ` · ${n.txid.slice(0, 10)}…:${n.indice}`)),
n.ajeno ? h("div", { clase: "muy-suave" }, `+ ${num(n.ajeno / 1e8)} BTC de terceros en esa transacción`) : null,
n.hijos.length ? h("ul", {}, n.hijos.map(nodo)) : null,
n.cortado ? h("div", { clase: "muy-suave" }, "… (hay más ramas; el reparto de arriba sí las cuenta todas)") : null);
modal({
titulo: `De dónde viene: ${num(t.sats / 1e8)} BTC`, ancho: true,
cuerpo: h("div", {},
h("h4", {}, "Orígenes"),
h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, h("th", {}, "Fecha"), h("th", {}, "Qué fue"), h("th", { clase: "d" }, "Bitcoin de esta moneda"),
h("th", { clase: "d" }, "%"), h("th", { clase: "d" }, "Pagado €/BTC"), h("th", { clase: "d" }, "Cierre ese día"))),
h("tbody", {}, t.origenes.map((o) => h("tr", {},
h("td", { clase: "num" }, o.fecha ? fecha(o.fecha, true) : "—"),
h("td", {}, textoOrigen(o), o.txid ? h("div", { clase: "muy-suave num", title: o.txid }, o.txid.slice(0, 16) + "…") : null,
o.compras_alli && o.compras_alli.length ? h("div", { clase: "muy-suave" }, `Compras en ${o.desde} hasta ese día: `
+ o.compras_alli.map((c) => `${fecha(c.fecha, true)} ${num(c.cantidad)} BTC${c.importe ? " por " + eur(c.importe) : ""}`).join(" · ")) : null),
h("td", { clase: "d num" }, num(o.sats / 1e8)),
h("td", { clase: "d num" }, proporcion(o.fraccion)),
h("td", { clase: "d num" }, o.importe && o.cantidad ? eur(o.importe / o.cantidad) : "—"),
h("td", { clase: "d num" }, o.valor_dia ? eur(o.valor_dia) : "—")))))),
h("h4", {}, "El camino, desde hoy hacia atrás"),
h("ul", { clase: "arbol-traza" }, nodo(t.arbol)),
h("p", { clase: "muy-suave" }, "Solo informativo: no cambia ninguna cifra ni el cálculo fiscal (el bitcoin se "
+ "trata como un bien homogéneo, por FIFO global).")),
acciones: [{ texto: "Cerrar", clase: "btn-primario", accion: (c) => c() }],
});
}
async function ventanaImportacionesBtc() {
let lista;
try { ({ resultado: lista } = await api("/api/btc/importaciones", {})); } catch (e) { toast(e.message, true); return; }
const cuerpo = h("div", {});
const pintarLista = () => cuerpo.replaceChildren(lista.length ? h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, h("th", {}, "Cuándo"), h("th", { clase: "d" }, "Decisiones"),
h("th", { clase: "d" }, "Creadas"), h("th", { clase: "d" }, "Enlazadas"), h("th", {}))),
h("tbody", {}, lista.map((i) => h("tr", {},
h("td", {}, fechaHora(i.fecha)), h("td", { clase: "d num" }, i.partes),
h("td", { clase: "d num" }, i.creados), h("td", { clase: "d num" }, i.enlazados),
h("td", { clase: "d" }, h("button", { type: "button", clase: "btn-enlace", onclick: async () => {
if (!(await confirmar(pl`Se borran las ${i.creados} operación(es) que creó, se quitan sus ${i.enlazados} enlace(s) `
+ "y sus decisiones vuelven a quedar pendientes. Si ya sincronizaste, el borrado también viaja a tu servidor git.",
"Deshacer", true))) return;
try {
const { resultado: d } = await api("/api/btc/importacion/deshacer", { id: i.id });
toast(pl`Deshecha: ${d.borrados} borrada(s), ${d.desenlazados} desenlazada(s).`);
lista = lista.filter((x) => x.id !== i.id);
pintarLista();
await cargar();
} catch (e) { toast(e.message, true); }
} }, "Deshacer esta importación"))))))) : h("p", { clase: "vacio" }, "Todavía no has guardado nada del historial."));
pintarLista();
modal({ titulo: "Importaciones del historial", ancho: true, cuerpo,
acciones: [
{ texto: "Empezar de cero…", izq: true, accion: (c) => { c(); empezarDeCeroBtc(); } },
{ texto: "Cerrar", clase: "btn-primario", accion: (c) => c() }] });
}
// Quita todo lo del CSV de exchanges y del historial, con la lista delante antes de confirmar
async function empezarDeCeroBtc() {
let p;
try { ({ resultado: p } = await api("/api/btc/empezar_de_cero", {})); } catch (e) { toast(e.message, true); return; }
const li = (n, texto) => n ? h("li", {}, texto) : null;
const cuerpo = h("div", {},
h("p", { estilo: { marginTop: 0 } }, "Deja el bitcoin como estaba antes de importar el CSV de tus exchanges y de revisar el historial. Se quita:"),
h("ul", {},
li(p.del_csv, pl`${p.del_csv} operación(es) creadas por el CSV de exchanges`),
li(p.del_historial, pl`${p.del_historial} operación(es) creadas desde el historial`),
li(p.desenlazar, pl`el txid anotado en ${p.desenlazar} operación(es) tuyas (las operaciones se quedan)`),
li(p.importaciones, pl`${p.importaciones} importación(es) desde el nodo (historial y comprobación diaria) y sus ${p.decisiones} decisión(es) guardadas`),
li(p.borradores, pl`${p.borradores} decisión(es) elegidas sin guardar`),
li(p.activos.length, `los activos ${p.activos.map((a) => "«" + a.nombre + "»").join(", ")}`)),
p.activos_que_se_quedan.length ? h("div", { clase: "aviso" }, h("span", {},
"No se borran porque aún tienen algo tuyo: "
+ p.activos_que_se_quedan.map((a) => `«${a.nombre}» (${a.monedero ? "un monedero apunta ahí" : pl`${a.operaciones} operación(es) tuyas`})`).join(", ")
+ ". Revísalos después.")) : null,
h("p", {}, h("b", {}, "Se quedan: "), "tus monederos, lo que tenías apuntado antes y lo que hayas apuntado a mano."),
h("p", { clase: "muy-suave" }, "Se borra marcando, como todo en la app: al sincronizar, el borrado llega también a la copia de tu servidor git. "
+ "Y al abrir la app se hizo una copia de seguridad en programa/backups/."));
modal({ titulo: "Empezar de cero el bitcoin", ancho: true, cuerpo,
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Quitarlo todo", clase: "btn-peligro", accion: async (c) => {
try {
const { resultado: r } = await api("/api/btc/empezar_de_cero", { confirmar: true });
c();
toast(pl`Hecho: ${r.del_csv + r.del_historial} operación(es) quitadas y ${r.activos.length} activo(s) borrados.`);
await cargar();
} catch (e) { toast(e.message, true); }
} },
] });
}
/* ── Acciones de la cabecera ────────────────────────────────────────────── */
async function actualizarPrecios(automatico = false) {
const btn = document.getElementById("btn-precios");
if (estado.cargando) return;
estado.cargando = true;
btn.disabled = true;
btn.replaceChildren(h("span", { clase: "girando" }), " Actualizando…");
try {
const r = await api("/api/precios/actualizar", {});
const { informe, automaticas } = r.resultado;
const fallos = informe.filter((i) => !i.ok);
let texto = `Precios actualizados: ${informe.length - fallos.length} de ${informe.length}.`;
if (automaticas.creadas) texto += pl` ${automaticas.creadas} operación(es) automática(s) nueva(s).`;
toast(texto);
if (fallos.length) toast(`Sin precio: ${fallos.map((f) => f.nombre).join(", ")}. Se mantiene el último conocido.`, true);
if (automaticas.pendientes) toast(pl`${automaticas.pendientes} operación(es) automática(s) pendiente(s) de completar (falta cambio de divisa o valor liquidativo).`);
} catch (e) {
toast(automatico ? `No se pudieron actualizar los precios: ${e.message}` : e.message, true);
} finally {
estado.cargando = false;
btn.disabled = false;
btn.textContent = "Actualizar precios";
await cargar();
}
}
// Ponerse al día: lo que costaría declarar ahora lo que no se declaró. Se pide aparte
// porque recalcula todos los años.
function tarjetaRegularizacion() {
const cuerpo = h("div", {}, h("p", { clase: "vacio" }, "Calculando…"));
const caja = h("div", { clase: "tarjeta" },
h("h2", {}, "Ponerte al día", h("span", { clase: "suave" }, "lo que costaría declarar ahora lo que no declaraste")), cuerpo);
const guardar = async (ejercicio, clave, valor, fila) => {
try {
const { resultado } = await api("/api/fiscal/declarado", { ejercicio, pagado: fila.pagado || "", marginal: fila.marginal ?? "", [clave]: valor });
pintarPlan(resultado);
} catch (err) { toast(err.message, true); }
};
const pintarPlan = (p) => {
const ESTADO = { prescrito: "Prescrito", en_plazo: "Aún en plazo" };
const filas = p.filas.filter((f) => f.estado !== "prescrito" || f.ahorro || f.rendimiento);
const abierta = (f) => f.estado === "abierto";
const caja_num = (f, clave, titulo) => {
const e = entradaNum({ value: f[clave] ? String(f[clave]).replace(".", ",") : "", "aria-label": titulo, estilo: { width: "5em" } });
e.addEventListener("change", () => guardar(f.ejercicio, clave, e.value.trim() === "" ? "" : aNumero(e.value), f));
return e;
};
cuerpo.replaceChildren(...[
h("p", { clase: "nota-form" },
"Si un año no declaraste lo que sale aquí, puedes presentarlo ahora por tu cuenta. Mientras Hacienda no te lo haya reclamado, "
+ `en vez de sanción hay un recargo que crece con el retraso: ${p.texto_recargos} (${p.citas.recargos}). Pasado un año, además, intereses de demora. `
+ `A los cuatro años de acabar el plazo de cada campaña, el ejercicio prescribe (${p.citas.prescripcion}).`),
p.sin_desembolso === "cero" && filas.some((f) => f.rendimiento) ? h("div", { clase: "aviso" }, h("span", {},
h("b", {}, "Ojo, lo minado está contado dos veces. "), "Tienes elegido «no lo declaré al recibirlo: coste cero», que hace tributar "
+ "entero lo minado cuando se vende. Si lo regularizas como ingreso de la actividad, lo coherente es cambiar ese criterio (abajo) a "
+ "«lo declaré como ingreso al recibirlo»: así al venderlo solo tributa lo que haya subido.")) : null,
h("div", { clase: "tabla-marco" }, h("table", {},
h("thead", {}, h("tr", {}, ["Ejercicio", "Ahorro", "Ya pagado", "Minería", "Tu tipo %",
"Diferencia", "Recargo", "Intereses", "Total"].map((x, i) => h("th", { clase: i ? "d" : "" }, x)))),
h("tbody", {}, filas.map((f) => h("tr", {},
h("td", {}, h("b", {}, f.ejercicio), h("div", { clase: "muy-suave" },
abierta(f) ? `prescribe el ${fecha(f.prescribe, true)}` : ESTADO[f.estado]),
f.verificado ? null : h("span", { clase: "insignia aviso-i", title: "Las reglas de la cuota de este año no están comprobadas" }, "sin comprobar")),
h("td", { clase: "d num" }, eur(f.ahorro)),
h("td", { clase: "d" }, abierta(f) ? caja_num(f, "pagado", `Ya pagado en ${f.ejercicio}`) : ""),
h("td", { clase: "d num " + signo(f.rendimiento) }, f.rendimiento ? eurSigno(f.rendimiento) : "—"),
h("td", { clase: "d" }, abierta(f) && f.rendimiento ? caja_num(f, "marginal", `Tipo marginal de ${f.ejercicio}`) : ""),
...(abierta(f) ? [
h("td", { clase: "d num" }, eurSigno(f.diferencia), f.falta_marginal ? h("div", { clase: "muy-suave" }, "sin la minería: falta tu tipo") : null),
h("td", { clase: "d num" }, f.recargo ? `${eur(f.recargo)} (${f.recargo_pct} %)` : "—"),
h("td", { clase: "d num" }, f.intereses ? eur(f.intereses) : "—"),
h("td", { clase: "d num" }, h("b", {}, f.total < 0 ? `a devolver ${eur(-f.total)}` : eur(f.total)))]
: [h("td", { colspan: 4, clase: "muy-suave d" }, f.estado === "prescrito" ? "Ya no se puede reclamar ni regularizar" : "Se declara en la próxima campaña")]))),
h("tr", {}, h("td", { colspan: 5 }, h("b", {}, "Total de los años abiertos")),
h("td", { clase: "d num" }, h("b", {}, eurSigno(p.total.diferencia))), h("td", { clase: "d num" }, eur(p.total.recargo)),
h("td", { clase: "d num" }, eur(p.total.intereses)), h("td", { clase: "d num" }, h("b", {}, eur(p.total.total))))))),
p.falta_marginal ? h("div", { clase: "aviso" }, h("span", {}, h("b", {}, "Falta tu tipo marginal. "),
"La minería va a la parte general, y lo que pagas por ella depende de lo demás que ganaste ese año. Pon en cada año el tipo de tu último "
+ "tramo (lo ves en tu declaración: es el porcentaje del tramo más alto que alcanzó tu base general). Sin él, la minería no entra en la diferencia.")) : null,
h("p", { clase: "muy-suave" },
`Calculado a día de hoy (${fecha(p.hoy)}). «Ahorro» es lo que sale a pagar por la parte del ahorro: la cuota del ahorro menos las retenciones, como si nunca hubieras declarado `
+ "nada de eso: si declaraste una parte, pon en «Ya pagado» lo que pagaste. «Minería» es el rendimiento neto de la actividad, y lo que pagarías por ella es una aproximación (rendimiento × tu tipo). "
+ "El fin de cada campaña se toma como el 30 de junio; el día exacto cambia cada año y mueve unos días la prescripción y los intereses. "
+ `Intereses: ${p.citas.intereses}. ${p.nota} Reglas: ${p.norma}, consultado el ${fecha(p.consultado)}. `
+ "Regularizar una actividad puede traer otras obligaciones (alta en el censo y en el IAE, y quizá autónomos) que aquí no se calculan: "
+ "con estas cifras delante, merece una consulta con un asesor antes de presentar nada.")].filter(Boolean));
};
api("/api/fiscal/regularizacion").then(pintarPlan)
.catch((e) => cuerpo.replaceChildren(h("p", { clase: "vacio" }, e.message)));
return caja;
}
// Lo esencial del ejercicio dicho en frases, para quien no quiere tablas
function frasesFiscal(r) {
const c = r.cuota, frases = [];
const ventas = r.transmisiones.filter((x) => !x.concepto.startsWith("Comisión")).length;
const neto = r.total.computable;
if (r.transmisiones.length) {
frases.push((ventas ? `Vendiste ${ventas} ${ventas === 1 ? "vez" : "veces"} y, ` : "No vendiste nada, pero pagaste comisiones en bitcoin y, ")
+ `contado como lo pide Hacienda, ${neto >= 0 ? "ganaste" : "perdiste"} ${eur(Math.abs(neto))}.`);
} else frases.push("No vendiste nada: por ventas no hay nada que declarar.");
const bruto = r.por_concepto.reduce((t, x) => t + x.bruto, 0), retenido = r.por_concepto.reduce((t, x) => t + x.retencion, 0);
if (bruto) frases.push(`Cobraste ${eur(bruto)} de dividendos, intereses o rentas` + (retenido ? `, de los que ya te retuvieron ${eur(retenido)}.` : "."));
for (const a of r.actividades) {
frases.push(`${a.nombre}: ${a.rendimiento >= 0 ? "dejó un rendimiento de" : "dio una pérdida de"} ${eur(Math.abs(a.rendimiento))}, `
+ "que se declara junto a tu sueldo y no con las ventas.");
}
const delBróker = r.bróker.filter((x) => x.resultado !== null), sinDato = r.bróker.length - delBróker.length;
if (delBróker.length) {
const t = delBróker.reduce((s, x) => s + x.resultado, 0);
frases.push(`Según tu bróker, tus cuentas de trading ${t >= 0 ? "ganaron" : "perdieron"} ${eur(Math.abs(t))}; ya va en la cuenta del impuesto.`);
}
if (sinDato) frases.push(pl`Falta el resultado de ${sinDato} cuenta(s) de bróker: cópialo de su informe fiscal más abajo.`);
for (const x of r.alquileres || []) {
frases.push(`${x.nombre}: su alquiler deja ${eurSigno(x.rendimiento)} ${x.base === "ahorro" ? "en la base del ahorro (ya va en la cuenta del impuesto)" : "que se declara junto a tu sueldo"}.`);
}
for (const v of r.ventas_inmuebles || []) {
frases.push(`Vendiste ${v.nombre}: ${v.ganancia >= 0 ? "ganaste" : "perdiste"} ${eur(Math.abs(v.ganancia))}, que va con tus ventas en la base del ahorro.`);
}
const imputado = (r.imputaciones || []).reduce((t, x) => t + x.renta, 0);
if (imputado) frases.push(`Por los inmuebles vacíos se imputan ${eur(imputado)} de renta, que van con tu sueldo.`);
if (!r.actividades.length && r.total_recibido) frases.push(`Recibiste sin pagar (minado o regalado) por valor de ${eur(r.total_recibido)}.`);
if (c.base) {
frases.push(`Por las ventas y lo cobrado saldrían unos ${eur(c.cuota)} de impuesto`
+ (c.retenciones ? `; descontando lo que ya te retuvieron, ${c.resultado >= 0 ? "quedarían por pagar" : "te devolverían"} ${eur(Math.abs(c.resultado))}.` : "."));
} else if (r.transmisiones.length || bruto) frases.push("Por las ventas y lo cobrado no saldría nada a pagar este año.");
if (c.pendientes.length) frases.push(`Te quedan ${eur(c.pendientes.reduce((t, x) => t + x.importe, 0))} de pérdidas para restar en los próximos años.`);
if (r.tareas.length) frases.push(pl`Ojo: hay ${r.tareas.length} cosa(s) por resolver antes de fiarte de estas cifras.`);
return frases;
}
async function exportarFiscal(ejercicio) {
try {
toast("Preparando el Excel…");
const blob = await api(`/api/fiscal/exportar?ejercicio=${encodeURIComponent(ejercicio)}`);
guardarFichero(blob, `fiscal_${ejercicio}.xlsx`);
} catch (e) { toast(e.message, true); }
}
async function exportar() {
try {
toast("Preparando el Excel…");
const blob = await api("/api/exportar");
guardarFichero(blob, `cartera_${hoyIso()}.xlsx`);
} catch (e) { toast(e.message, true); }
}
async function cerrarServidor() {
try { await api("/api/salir", {}); } catch { /* el servidor ya se ha ido */ }
document.getElementById("contenido").replaceChildren(h("p", { clase: "vacio" }, "Caudal está cerrado. Ya puedes cerrar esta pestaña."));
}
// Sincroniza con el Caudal del servidor sin preguntar (al abrir y al salir), salvo que
// las dos carteras no tengan nada en común: eso se revisa a mano en «Sincronizar».
// Devuelve true si ha cambiado algo aquí.
async function sincronizarEnlace(momento) {
const d = estado.datos;
if (!d || d.servidor || d.ejemplo || !(d.enlace && d.enlace.url)) return false;
const r = (await api("/api/enlace/sincronizar", { simular: true })).resultado.resumen;
if (r.carteras_distintas) throw new Error("tu cartera y la del servidor no tienen nada en común; revísalo en «Sincronizar»");
const nada = !(r.llegan_alli || r.cambian_alli || r.llegan_aqui || r.cambian_aqui || r.renombradas.length || r.duplicadas
|| r.ajustes_aqui.length || r.ajustes_alli.length);
if (nada) return false;
await api("/api/enlace/sincronizar", {});
toast(`Sincronizado con tu servidor ${momento}.`);
return Boolean(r.llegan_alli || r.cambian_alli || r.renombradas.length || r.duplicadas || r.ajustes_alli.length);
}
async function salir() {
if (EN_SERVIDOR) { cerrarSesion(); return; } // en un servidor Caudal sigue en marcha: solo se cierra la sesión
const d = estado.datos;
if (!d.servidor && !d.ejemplo && d.enlace && d.enlace.url) {
modal({
titulo: "Salir", cuerpo: h("p", {}, "¿Sincronizar con el Caudal de tu servidor antes de salir? Así lo que has hecho aquí estará también allí."),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Salir sin sincronizar", accion: (c) => { c(); cerrarServidor(); } },
{ texto: "Sincronizar y salir", clase: "btn-primario", accion: async (c) => {
c();
try { await sincronizarEnlace("antes de salir"); cerrarServidor(); }
catch (e) { toast(`No he podido sincronizar (${e.message}). No he cerrado: vuelve a intentarlo o sal sin sincronizar.`, true); }
} },
],
});
return;
}
const s = d.sync;
if (s.configurado && s.pendiente) {
modal({
titulo: "Salir", cuerpo: h("p", {}, "Tienes cambios que aún no están en la copia cifrada de tu servidor git. ¿Sincronizar antes de salir?"),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Salir sin sincronizar", accion: (c) => { c(); cerrarServidor(); } },
{ texto: "Sincronizar y salir", clase: "btn-primario", accion: (c) => { c(); ejecutarSync("ejecutar", cerrarServidor); } },
],
});
return;
}
if (await confirmar("Se cerrará la cartera. Tus datos ya están guardados.", "Salir")) cerrarServidor();
}
/* ── Sincronización cifrada ─────────────────────────────────────────────── */
function fechaHora(iso) {
if (!iso) return "nunca";
return new Date(iso).toLocaleString("es-ES", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
}
function ventanaSync() {
const s = estado.datos.sync;
if (!s.configurado) { configurarSync(); return; }
modal({
titulo: "Copia cifrada en tu servidor git",
cuerpo: h("div", {},
h("p", { clase: "nota-form", estilo: { marginTop: 0 } },
"Tus datos se cifran con tu clave GPG y se guardan en tu servidor git, junto con los monederos de Bitcoin y los ajustes de extractos. «Sincronizar» sube tus cambios y trae los de otros equipos sin duplicar nada; solo pide la llave de hardware cuando hay algo nuevo que descifrar."),
h("div", { clase: "ficha" },
h("div", {}, h("small", {}, "Repositorio"), h("b", {}, s.url)),
h("div", {}, h("small", {}, "Cifrada para"), h("b", {}, s.clave || "—")),
h("div", {}, h("small", {}, "Última sincronización"), h("b", {}, fechaHora(s.ultima))),
h("div", {}, h("small", {}, "Estado"), h("b", { clase: s.pendiente ? "" : "pos" }, s.pendiente ? "Hay cambios sin sincronizar" : "✓ Todo sincronizado")))),
acciones: [
{ texto: "Cambiar configuración", izq: true, accion: (c) => { c(); configurarSync(); } },
{ texto: "Comprobar la copia", accion: (c) => { c(); ejecutarSync("comprobar"); } },
{ texto: "Sincronizar ahora", clase: "btn-primario", accion: (c) => { c(); ejecutarSync("ejecutar"); } },
],
});
}
/* ── Fuentes de precios ─────────────────────────────────────────────────── */
async function ventanaFuentes() {
const cuerpo = h("div", { clase: "tabla-marco" }, h("p", { clase: "vacio" }, "Comprobando las fuentes…"));
modal({
titulo: "Fuentes de precios",
ancho: true,
cuerpo: h("div", {},
h("p", { clase: "nota-form", estilo: { marginTop: 0 } },
"Se consulta cada web con un ejemplo público —un fondo, un ETF, bitcoin y el oro— para ver cuál responde hoy. "
+ "No se envía nada de tu cartera."),
cuerpo),
acciones: [{ texto: "Cerrar", clase: "btn-primario", accion: (c) => c() }],
});
let r;
try {
({ resultado: r } = await api("/api/fuentes/comprobar", {}));
} catch (e) {
cuerpo.replaceChildren(h("div", { clase: "error-form" }, e.message));
return;
}
cuerpo.replaceChildren(
r.rotas.length
? h("div", { clase: "error-form" }, r.rotas.length === 1
? `${r.rotas[0]} no responde. Los precios que salen de ahí se quedarán como están.`
: `No responden: ${r.rotas.join(", ")}. Los precios que salen de ahí se quedarán como están.`)
: h("p", { clase: "nota-form", estilo: { marginBottom: "12px" } }, "✓ Todas las fuentes responden."),
h("table", {},
h("thead", {}, h("tr", {}, h("th", {}, "Fuente"), h("th", {}, "Para"), h("th", {}, "Respuesta"))),
h("tbody", {}, ...r.fuentes.map((f) => h("tr", {},
h("td", {}, h("b", { clase: f.ok ? "pos" : (f.esperado ? "" : "neg") }, f.ok ? "✓ " : (f.esperado ? "— " : "✗ ")),
f.nombre),
h("td", { clase: "suave" }, f.para),
h("td", {}, f.detalle))))));
}
async function configurarSync() {
let info;
try { info = await api("/api/sync/claves"); } catch (e) { toast(e.message, true); return; }
const error = h("div", { clase: "error-form oculto", role: "alert" });
const inUrl = entrada({ value: info.config.url || info.sugerencia || "" });
const claves = info.claves;
const selClave = selector(claves.map((c) => [c.huella, `${c.nombre}${c.en_tarjeta ? " · en llave de hardware" : ""} · …${c.huella.slice(-16)}`]),
info.config.huella || (claves.find((c) => c.en_tarjeta) || claves[0] || {}).huella);
modal({
titulo: "Configurar la copia cifrada",
cuerpo: h("div", {}, error,
h("p", { clase: "nota-form", estilo: { marginTop: 0 } },
"Antes, crea en tu servidor git un repositorio privado y vacío llamado «cartera-datos» (sin README). Solo tú tendrás acceso; lo que se sube va cifrado."),
claves.length ? null : h("div", { clase: "error-form" }, "No encuentro ninguna clave GPG con cifrado en este equipo. ¿Está puesta tu llave de hardware y hecho «gpg --card-status» alguna vez?"),
h("div", { clase: "campos" },
campo("Repositorio (SSH)", inUrl, { completo: true, ayuda: "La dirección SSH de tu repositorio de datos." }),
campo("Cifrar para la clave", selClave, { completo: true }))),
acciones: [
{ texto: "Cancelar", accion: (c) => c() },
{ texto: "Guardar y sincronizar", clase: "btn-primario", accion: async (c) => {
const r = await guardarCon(null, error, () => api("/api/sync/configurar", { url: inUrl.value, huella: selClave.value }));
if (r) { c(); await cargar(); ejecutarSync("ejecutar"); }
} },
],
});
}
async function ejecutarSync(accion, despues = null) {
const cerrar = modal({
titulo: accion === "comprobar" ? "Comprobando la copia…" : "Sincronizando…",
cuerpo: h("p", {}, h("span", { clase: "girando" }), " Conectando con tu servidor git. Si tu llave parpadea o te pide el PIN, atiéndela."),
});
try {
const { resultado: r } = await api(`/api/sync/${accion}`, {});
cerrar();
if (accion === "comprobar") {
const ajustes = Object.entries(r.ajustes || {});
const nombres = { "bitcoin.json": "los monederos de Bitcoin", "extractos.json": "los ajustes de extractos" };
const faltan = ajustes.filter(([, a]) => !a.en_la_copia).map(([n]) => nombres[n] || n);
const texto = !r.hay_copia ? "Aún no hay copia en tu servidor git: sincroniza primero."
: r.identica ? `✓ La copia de tu servidor git se descifra bien y es idéntica a lo que tienes aquí (${r.registros} registros`
+ (ajustes.length ? `, y ${ajustes.map(([n]) => nombres[n] || n).join(" y ")}).` : ").")
: `La copia de tu servidor git se descifra bien, pero difiere de lo que tienes aquí`
+ (r.diferencias ? ` en ${r.diferencias} registros` : "")
+ (faltan.length ? ` y todavía no lleva ${faltan.join(" ni ")}` : "")
+ ". Sincroniza para igualarlas.";
modal({ titulo: "Comprobación de la copia", cuerpo: h("p", {}, texto), acciones: [{ texto: "Cerrar", clase: "btn-primario", accion: (c) => c() }] });
} else {
const traidos = (r.recuento.de_remoto || 0) + (r.recuento.actualizados_desde_remoto || 0);
const partes = [];
if (traidos) partes.push(`${traidos} cambio${traidos === 1 ? "" : "s"} traído${traidos === 1 ? "" : "s"} de tu servidor`);
if (r.subido) partes.push("copia cifrada subida");
if ((r.ajustes || []).length) partes.push("ajustes actualizados");
toast(partes.length ? `Sincronizado: ${partes.join(" y ")}.` : "Ya estaba todo sincronizado.");
}
await cargar();
if (despues) despues();
} catch (e) {
cerrar();
toast(e.message, true);
}
}
// El botón de modo claro u oscuro: pasa de «como el sistema» a claro, a oscuro y vuelta
const TEMAS = {
auto: { icono: "◐", nombre: "como el sistema", siguiente: "claro" },
claro: { icono: "☀\uFE0E", nombre: "claro", siguiente: "oscuro" },
oscuro: { icono: "☾", nombre: "oscuro", siguiente: "auto" },
};
function iniciarBotonTema() {
const btn = document.getElementById("btn-tema"), tema = window.caudalTema;
if (!btn || !tema) return;
const pintarBoton = () => {
const t = TEMAS[tema.elegido()] || TEMAS.auto;
btn.textContent = t.icono;
const texto = `Modo ${t.nombre}. Pulsa para cambiar a ${TEMAS[t.siguiente].nombre}.`;
btn.title = texto;
btn.setAttribute("aria-label", texto);
};
btn.addEventListener("click", () => {
tema.elegir((TEMAS[tema.elegido()] || TEMAS.auto).siguiente);
pintarBoton();
});
pintarBoton();
}
function iniciarCabecera() {
// Dentro de la aplicación de Android no hay nada que «salir» (se cierra como cualquier app)
// ni están los programas que usa la copia cifrada en un servidor git
if (window.CaudalAndroid) document.body.classList.add("en-android");
iniciarBotonTema();
document.getElementById("btn-precios").addEventListener("click", () => actualizarPrecios(false));
document.getElementById("btn-operacion").addEventListener("click", () => formOperacion(segunFiltros()));
document.getElementById("btn-asistente").addEventListener("click", () => asistente());
const menu = document.getElementById("menu-lista"), btnMenu = document.getElementById("btn-menu");
const btnMas = document.getElementById("pestana-mas");
const abrirMenu = (abrir) => {
menu.classList.toggle("oculto", !abrir);
// Que acabe justo encima del borde de la pantalla, sea cual sea la altura de la cabecera
// (en el móvil manda la hoja de estilos: la lista sale desde abajo)
menu.style.maxHeight = abrir && !MOVIL.matches
? `${Math.max(160, innerHeight - btnMenu.getBoundingClientRect().bottom - 16)}px` : "";
if (abrir) menu.scrollTop = 0;
for (const b of [btnMenu, btnMas]) b.setAttribute("aria-expanded", String(abrir));
marcarSiHayMas();
};
// Si la lista no cabe, un aviso abajo dice que hay más: muchos sistemas esconden la barra
// de desplazamiento hasta que pasas por encima, y así no se sabía que la lista seguía
const sigue = h("div", { clase: "menu-sigue", "aria-hidden": "true" }, "▾ Más opciones");
menu.append(sigue);
const marcarSiHayMas = () => menu.classList.toggle("hay-mas",
!menu.classList.contains("oculto") && menu.scrollTop + menu.clientHeight < menu.scrollHeight - 4);
menu.addEventListener("scroll", marcarSiHayMas, { passive: true });
addEventListener("resize", marcarSiHayMas);
sigue.addEventListener("click", (e) => {
e.stopPropagation();
menu.scrollBy({ top: menu.clientHeight * 0.7, behavior: "smooth" });
});
for (const b of [btnMenu, btnMas]) b.addEventListener("click", (e) => { e.stopPropagation(); abrirMenu(menu.classList.contains("oculto")); });
document.getElementById("btn-cuentame").addEventListener("click", () => asistente());
// Atajos de teclado: N, nueva operación; C, Cuéntame; /, buscar en las operaciones
// (no mientras se escribe ni con una ventana abierta)
document.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.metaKey || e.altKey || !estado.datos || document.querySelector(".velo")) return;
if (e.target.closest && e.target.closest("input, textarea, select, [contenteditable]")) return;
const k = e.key.toLowerCase();
if (k === "n") { e.preventDefault(); formOperacion(segunFiltros()); }
else if (k === "c") { e.preventDefault(); asistente(); }
else if (k === "/" && estado.datos.movimientos.length) { e.preventDefault(); estado.vista = "movimientos"; estado.enfocarBuscar = true; pintar(); }
});
document.getElementById("btn-operacion").title = "Apuntar una operación (atajo: N)";
document.getElementById("btn-asistente").title = "Cuéntame lo que has hecho y te preparo la operación (atajo: C)";
const irA = (vista) => {
estado.vista = vista;
estado.masMovs = 0;
history.replaceState(null, "", `#${estado.vista}`);
pintar();
window.scrollTo(0, 0);
};
// Al pasar de ancho de móvil a pantalla grande (o al revés) cambian listas y filtros
MOVIL.addEventListener("change", () => { if (estado.datos) pintar(); });
document.addEventListener("click", () => abrirMenu(false));
menu.addEventListener("click", (e) => {
const accion = e.target.dataset.accion;
abrirMenu(false);
if (e.target.dataset.vista) { irA(e.target.dataset.vista); return; }
({ "nuevo-activo": () => formActivo(), recurrente: () => formRecurrente(),
precios: () => actualizarPrecios(false), asistente: () => asistente(), clave: formCambiarClave, copia: ventanaCopia, cifrado: ventanaCifrado, enlace: ventanaEnlace, acerca: ventanaAcerca,
exportar, salir, sincronizar: ventanaSync, extracto: importarExtracto,
bitcoin: ventanaBitcoin, fuentes: ventanaFuentes,
lanzador: () => cambiarLanzador(!lanzadorActual().puesto) })[accion]?.();
});
for (const p of document.querySelectorAll(".pestana")) {
p.addEventListener("click", () => irA(p.dataset.vista));
}
const inicial = location.hash.slice(1);
if (document.querySelector(`.pestana[data-vista="${CSS.escape(inicial)}"]`)) estado.vista = inicial;
}
(async function inicio() {
iniciarCabecera();
try {
if (EN_SERVIDOR) {
document.body.classList.add("en-servidor");
document.querySelector('[data-accion="salir"]').textContent = "Cerrar sesión";
await entrarEnServidor();
}
await cargar();
} catch (e) {
document.getElementById("contenido").replaceChildren(h("p", { clase: "vacio" }, e.message));
return;
}
// Primero, lo del servidor: así las automáticas, los precios y los monederos
// se calculan ya con los mismos datos que tienen las otras copias
if (estado.datos.enlace && estado.datos.enlace.url && !estado.datos.servidor && !estado.datos.ejemplo) {
toast("Sincronizando con tu servidor…");
try {
if (await sincronizarEnlace("al abrir")) await cargar();
} catch (e) {
toast(`No he sincronizado al abrir (${e.message}). Trabajas con lo de este equipo; se mezclará en la próxima sincronización.`, true);
}
}
const d = estado.datos;
if (d.ultima_descarga_precios !== d.resumen.hoy && d.activos.length && !d.ejemplo) actualizarPrecios(true);
const btc = d.btc || {};
if (btc.comprobar_al_abrir && btc.monederos && btc.servidores
&& (!btc.ultima || btc.ultima.fecha.slice(0, 10) !== d.resumen.hoy)) {
comprobarBitcoin(true); // en segundo plano: si hay algo, abre la revisión
}
api("/api/sesion", { valor: d.resumen.global.valor, neto: d.resumen.global.patrimonio_neto }).catch(() => {});
})();