#!/usr/bin/env python3 """Flint · Procesador por lotes y carpeta vigilada para asesorías (Norma 43, VERI*FACTU y Facturae). Procesa todos los ficheros .n43, .c43, .q43, .xml y .xsig de una carpeta contra los servidores MCP de KeyFlint usando bytes puros en base64 (preserva ñ/acentos CP850/Latin-1 y firmas XAdES), guarda el detalle auditable (.flint.json) y genera resumen-flint.csv listo para abrir en Excel. Con --vigilar (o --watch), vigila la carpeta continuamente y procesa cada fichero nuevo al instante. """ from __future__ import annotations import argparse import base64 import csv import datetime as dt import json import os import re import shutil import subprocess from pathlib import Path import sys import time import urllib.error import urllib.request SITE = "https://mcp.keyflint.dev" LIC_FILE = Path.home() / ".keyflint" / "license" SUPPORTED_EXTS = (".n43", ".c43", ".q43", ".xml", ".xsig") RETRY_AFTER_S = 600 # un fichero que falla (sin saldo, red, formato) se reintenta a los 10 min, no cada 10 s FAILED: dict[Path, float] = {} AUTOSTART_ID = "dev.keyflint.flint-lote" def get_license(cli_lic: str | None) -> str: lic = (cli_lic or os.environ.get("KEYFLINT_LICENSE") or "").strip().strip("\"'") if not lic and LIC_FILE.exists(): lic = LIC_FILE.read_text(encoding="utf-8", errors="ignore").strip() if not lic: try: lic = input("Pega tu licencia KeyFlint (LIC-...): ").strip().strip("\"'") except EOFError: lic = "" if not lic.startswith("LIC-"): sys.exit("Error: se necesita una licencia válida (empieza por LIC-). Consigue 25 créditos gratis en " + SITE + "/probar/") try: LIC_FILE.parent.mkdir(parents=True, exist_ok=True) LIC_FILE.write_text(lic + "\n", encoding="utf-8") os.chmod(LIC_FILE, 0o600) except OSError: pass return lic def call_mcp(slug: str, tool: str, arguments: dict, license_key: str) -> dict: url = f"{SITE}/{slug}/mcp" payload = json.dumps({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": arguments}, }).encode("utf-8") last_err: Exception | None = None for attempt in range(3): req = urllib.request.Request( url, data=payload, headers={ "content-type": "application/json", "accept": "application/json, text/event-stream", "authorization": f"Bearer {license_key}", "user-agent": "flint-lote/1.1", }, method="POST", ) try: with urllib.request.urlopen(req, timeout=45) as resp: raw = resp.read().decode("utf-8", errors="replace") break except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="replace") if e.code in (429, 502, 503, 504) and attempt < 2: time.sleep(2.0 * (attempt + 1)) last_err = RuntimeError(f"HTTP {e.code}: {body[:240]}") continue raise RuntimeError(f"HTTP {e.code}: {body[:240]}") from e except urllib.error.URLError as e: if attempt < 2: time.sleep(2.0 * (attempt + 1)) last_err = RuntimeError(f"Error de red: {e.reason}") continue raise RuntimeError(f"Error de red: {e.reason}") from e else: raise last_err or RuntimeError("Sin respuesta del servidor.") msg = json.loads(raw) if "error" in msg: raise RuntimeError(str(msg["error"].get("message") or msg["error"])) res = msg.get("result") or {} if res.get("isError"): txt = " ".join(c.get("text", "") for c in (res.get("content") or [])) raise RuntimeError(txt or "La herramienta devolvió un error.") data = res.get("structuredContent") if isinstance(data, dict) and len(data) == 1 and "result" in data: data = data["result"] if not data: content = res.get("content") or [] if content and content[0].get("text"): data = json.loads(content[0]["text"]) return data if isinstance(data, dict) else {"result": data} def is_verifactu_xml(raw_bytes: bytes) -> bool: head = raw_bytes[:16384].decode("utf-8", errors="ignore") return any(tag in head for tag in ("RegFactuSistemaFacturacion", "RegistroAlta", "RegistroAnulacion", "SuministroLR")) def process_file(path: Path, license_key: str) -> dict: ext = path.suffix.lower() raw_bytes = path.read_bytes() b64 = base64.b64encode(raw_bytes).decode("ascii") now_str = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S") if ext in (".n43", ".c43", ".q43"): data = call_mcp("norma43", "parse_norma43", {"content_base64": b64, "include_movements": True}, license_key) acc = (data.get("accounts") or [{}])[0] movs = acc.get("movements") or [] sm = data.get("summary") or {} issues = data.get("issues") or [] valid = bool(data.get("valid", True)) return { "fecha_proceso": now_str, "fichero": path.name, "tipo": "Norma 43", "estado": "OK" if valid else "CON_ERRORES", "iban_o_emisor": acc.get("iban_formatted") or acc.get("iban") or acc.get("ccc") or "", "movimientos_o_facturas": sm.get("movements", len(movs)), "saldo_inicial": acc.get("opening_balance", ""), "saldo_final_o_total": acc.get("closing_balance", ""), "moneda": acc.get("currency", "EUR"), "avisos": len(issues), "raw": data, } if ext == ".xml" and is_verifactu_xml(raw_bytes): data = call_mcp("verifactu", "validate_verifactu_xml", {"content_base64": b64}, license_key) valid = bool(data.get("valid", False)) issues = data.get("issues") or data.get("errors") or [] recs = data.get("records") m = re.search(rb"<(?:\w+:)?IDEmisorFactura(?:Anulada)?>\s*([^<\s]+)", raw_bytes) first_nif = m.group(1).decode("ascii", "ignore") if m else "" return { "fecha_proceso": now_str, "fichero": path.name, "tipo": "VERI*FACTU XML", "estado": "OK" if valid else "CADENA_CON_ERRORES", "iban_o_emisor": first_nif, "movimientos_o_facturas": recs if isinstance(recs, int) else len(recs or []), "saldo_inicial": "", "saldo_final_o_total": str(data.get("last_huella") or "")[:16], "moneda": "EUR", "avisos": len(issues), "raw": data, } if ext in (".xml", ".xsig"): data = call_mcp("verifactu", "validate_facturae", {"content_base64": b64}, license_key) valid = bool(data.get("valid", False)) issues = data.get("issues") or data.get("errors") or [] inv = data.get("invoice") or data.get("summary") or {} seller = inv.get("seller") if isinstance(inv.get("seller"), dict) else {} totals = data.get("totals") if isinstance(data.get("totals"), dict) else inv m_nif = re.search(rb"<(?:\w+:)?SellerParty>[\s\S]*?<(?:\w+:)?TaxIdentificationNumber>\s*([^<\s]+)", raw_bytes) m_tot = re.search(rb"<(?:\w+:)?(?:TotalInvoicesAmount|InvoiceTotals)>[\s\S]*?<(?:\w+:)?(?:TotalAmount|InvoiceTotal)>\s*([^<\s]+)", raw_bytes) m_cur = re.search(rb"<(?:\w+:)?InvoiceCurrencyCode>\s*([^<\s]+)", raw_bytes) invs = data.get("invoices") return { "fecha_proceso": now_str, "fichero": path.name, "tipo": "Facturae 3.2.2", "estado": "OK" if valid else "CON_REPAROS", "iban_o_emisor": seller.get("nif") or inv.get("seller_nif") or (m_nif.group(1).decode("ascii", "ignore") if m_nif else ""), "movimientos_o_facturas": invs if isinstance(invs, int) else data.get("invoice_count", 1), "saldo_inicial": "FIRMADA" if data.get("signed") else "SIN_FIRMA", "saldo_final_o_total": totals.get("invoice_total") or totals.get("total") or (m_tot.group(1).decode("ascii", "ignore") if m_tot else ""), "moneda": inv.get("currency") or (m_cur.group(1).decode("ascii", "ignore") if m_cur else "EUR"), "avisos": len(issues), "raw": data, } raise ValueError(f"Extensión no soportada: {ext}") def append_csv(csv_path: Path, row: dict) -> None: fields = [ "fecha_proceso", "fichero", "tipo", "estado", "iban_o_emisor", "movimientos_o_facturas", "saldo_inicial", "saldo_final_o_total", "moneda", "avisos", ] exists = csv_path.exists() with csv_path.open("a", encoding="utf-8-sig", newline="") as f: w = csv.DictWriter(f, fieldnames=fields, delimiter=";") if not exists: w.writeheader() w.writerow({k: row.get(k, "") for k in fields}) def scan_once(folder: Path, license_key: str, seen: set[Path]) -> int: targets = sorted( p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in SUPPORTED_EXTS and not p.name.endswith(".flint.json") ) count = 0 csv_path = folder / "resumen-flint.csv" for p in targets: out_json = p.with_name(p.name + ".flint.json") if p in seen or out_json.exists(): seen.add(p) continue if time.time() < FAILED.get(p, 0): continue print(f"-> Procesando {p.name} ...", end=" ", flush=True) try: res = process_file(p, license_key) out_json.write_text(json.dumps(res["raw"], ensure_ascii=False, indent=2), encoding="utf-8") append_csv(csv_path, res) seen.add(p) print(f"[{res['estado']}] ({res['movimientos_o_facturas']} reg, {res['avisos']} avisos)") count += 1 except Exception as exc: FAILED[p] = time.time() + RETRY_AFTER_S print(f"[ERROR] {exc} (se reintenta en {RETRY_AFTER_S // 60} min)") return count def client_folders(folder: Path, subfolders: bool) -> list[Path]: """La carpeta y, con --subcarpetas, una subcarpeta por cliente (cada una con su resumen-flint.csv).""" if not subfolders: return [folder] subs = sorted(d for d in folder.iterdir() if d.is_dir() and not d.name.startswith((".", "_"))) return [folder, *subs] def autostart_files(folder: Path, script: Path, uv: str, subfolders: bool, home: Path, platform: str) -> dict[Path, str]: """Ficheros que dejan la carpeta vigilada arrancando sola al iniciar sesión (sin la licencia: va en ~/.keyflint/license).""" args = [uv, "run", "--no-project", "--python", "3.12", "--quiet", str(script), str(folder), "--vigilar"] if subfolders: args.append("--subcarpetas") log = home / ".keyflint" / "lote.log" if platform == "win32": startup = home / "AppData" / "Roaming" / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" cmd = " ".join(f'"{a}"' for a in args) runner_cmd = home / ".keyflint" / "flint-carpeta-vigilada.cmd" vbs_launcher = startup / "flint-carpeta-vigilada.vbs" cmd_content = f"@echo off\r\nrem Flint - Monitor de extractos bancarios en segundo plano\r\n{cmd} >> \"{log}\" 2>&1\r\n" vbs_content = ( 'Set WshShell = CreateObject("WScript.Shell")\r\n' f'WshShell.Run chr(34) & "{runner_cmd}" & chr(34), 0, False\r\n' 'Set WshShell = Nothing\r\n' ) return {vbs_launcher: vbs_content, runner_cmd: cmd_content} if platform == "darwin": items = "".join(f"\n {a.replace('&', '&').replace('<', '<')}" for a in args) plist = f""" Label{AUTOSTART_ID} ProgramArguments{items} RunAtLoad KeepAlive StandardOutPath{log} StandardErrorPath{log} """ return {home / "Library" / "LaunchAgents" / f"{AUTOSTART_ID}.plist": plist} exec_line = " ".join(f'"{a}"' for a in args) unit = f"""[Unit] Description=Flint carpeta vigilada ({folder}) [Service] ExecStart={exec_line} Restart=on-failure RestartSec=30 [Install] WantedBy=default.target """ return {home / ".config" / "systemd" / "user" / "flint-lote.service": unit} def install_autostart(folder: Path, subfolders: bool) -> None: home = Path.home() uv = (shutil.which("uv") or shutil.which("uv.exe") or (str(home / ".local" / "bin" / "uv.exe") if (home / ".local" / "bin" / "uv.exe").exists() else None) or (str(home / ".cargo" / "bin" / "uv.exe") if (home / ".cargo" / "bin" / "uv.exe").exists() else None)) if not uv: sys.exit("Error: no encuentro uv. Ejecuta antes el instalador de " + SITE + "/lote.ps1 o /lote.sh.") home = Path.home() script = home / ".keyflint" / "flint-lote.py" script.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(Path(__file__).resolve(), script) files = autostart_files(folder, script, uv, subfolders, home, sys.platform) for path, text in files.items(): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8", newline="") print(f"Creado: {path}") if sys.platform == "win32": os.startfile(str(next(iter(files)))) # type: ignore[attr-defined] # arranca ya, sin esperar al próximo inicio elif sys.platform == "darwin": plist = next(iter(files)) subprocess.run(["launchctl", "unload", str(plist)], capture_output=True) subprocess.run(["launchctl", "load", "-w", str(plist)], check=False) elif sys.platform != "win32": if not shutil.which("systemctl") or subprocess.run( ["systemctl", "--user", "enable", "--now", "flint-lote.service"], capture_output=True).returncode: print("Aviso: no pude activar el servicio de usuario. Arráncalo a mano: systemctl --user enable --now flint-lote.service") return print(f"Listo: la carpeta {folder} se vigila sola cada vez que inicias sesión. Registro: {home / '.keyflint' / 'lote.log'}") print("Para quitarlo: vuelve a ejecutar con --quitar-inicio.") def remove_autostart() -> None: home = Path.home() for path in autostart_files(Path("."), Path("."), "uv", False, home, sys.platform): if sys.platform == "darwin" and path.exists(): subprocess.run(["launchctl", "unload", "-w", str(path)], capture_output=True) elif sys.platform not in ("win32", "darwin") and shutil.which("systemctl"): subprocess.run(["systemctl", "--user", "disable", "--now", "flint-lote.service"], capture_output=True) if path.exists(): path.unlink() print(f"Quitado: {path}") print("La carpeta vigilada ya no arranca sola.") def main() -> None: ap = argparse.ArgumentParser(description="Procesa extractos Norma 43 (.n43/.c43) y ficheros VERI*FACTU / Facturae (.xml/.xsig) en lote.") ap.add_argument("carpeta", nargs="?", default=".", help="Carpeta con los ficheros (por defecto: carpeta actual)") ap.add_argument("--licencia", "-l", default=None, help="Licencia KeyFlint (LIC-...)") ap.add_argument("--vigilar", "--watch", "-w", action="store_true", help="Vigila la carpeta continuamente cada 10 s") ap.add_argument("--subcarpetas", "--clientes", action="store_true", help="Procesa también cada subcarpeta (una por cliente)") ap.add_argument("--al-iniciar", "--autostart", action="store_true", help="Deja la carpeta vigilada arrancando sola al iniciar sesión") ap.add_argument("--quitar-inicio", "--remove-autostart", action="store_true", help="Quita el arranque automático") args = ap.parse_args() if args.quitar_inicio: remove_autostart() return folder = Path(args.carpeta).expanduser().resolve() if args.al_iniciar: folder.mkdir(parents=True, exist_ok=True) if not folder.is_dir(): sys.exit(f"Error: la carpeta {folder} no existe.") lic = get_license(args.licencia) if args.al_iniciar: install_autostart(folder, args.subcarpetas) return seen: set[Path] = set() print(f"Flint Lote · Carpeta: {folder}" + (" (y subcarpetas de clientes)" if args.subcarpetas else "")) n = sum(scan_once(f, lic, seen) for f in client_folders(folder, args.subcarpetas)) if n: print(f"Listo: {n} fichero(s) procesados. Resumen Excel en cada carpeta: resumen-flint.csv") else: print("No hay ficheros nuevos (.n43, .c43, .q43, .xml, .xsig) pendientes.") if args.vigilar: print("Modo carpeta vigilada activo (Ctrl+C para salir)...") try: while True: time.sleep(10) for f in client_folders(folder, args.subcarpetas): scan_once(f, lic, seen) except KeyboardInterrupt: print("\nCarpeta vigilada detenida.") if __name__ == "__main__": main()