Skip to content

Guía de Integración Rápida con Node.js

El SDK de Node.js aporta una solución completa Full-Stack minimalista. Consiste en un Servidor Backend ligero (server.js) que funciona como Proxy reverso inyectando la configuración de seguridad (config.ini) y un Cliente Frontend visual (index.html) idéntico a las demostraciones compiladas.

Esta arquitectura es ideal para entender cómo encapsular la API detrás de tu propio Backend B2B aislando así el Token y el NIF.

🛠️ Entorno: Instalación y Ejecución

Para arrancar esta demostración técnica necesitas tener un entorno de ejecución Node.js local:

  1. Instalar Node: Si no lo tienes en tu equipo, descárgalo e instálalo desde nodejs.org.
  2. Ubícate en tu consola (Terminal o PowerShell) dentro de la carpeta donde has extraído los ficheros del SDK.
  3. No se requiere instalar librerías externas (no hay package.json ni requerimientos NPM adicionales).
  4. Arranca el servidor Backend:
    bash
    node server.js
    (Nota: si cuentas con el binario de windows adjunto también puedes invocar .\node.exe server.js)
  5. El servidor te avisará de que está en escucha. Sitúate en tu navegador y ve a: http://localhost:3000

WARNING

Aviso sobre el bloque metadata (JSON de Facturas) En algunos JSON de prueba locales verás un bloque raíz llamado "metadata": { "enviar_aeat": false, "simulacion": true, ... }. La API funciona perfectamente CON y SIN ese bloque. Se introdujo exclusivamente por compatibilidad con futuras versiones, pero se aconseja encarecidamente que prescindas totalmente de él para los procesos y desarrollos actuales, enviando únicamente "cabecera" y "detalle".

Vídeo Demostrativo

TIP

Contexto de la demostración: Observa el panel de administración central del Micro Server en vivo, mientras la interfaz gráfica servida por Node.js ingesta facturas en paralelo y rescata los Acuses de Recibo (ACK) validados.


🏗️ Cómo integrarlo en tu proyecto

La magia de este SDK reside en el enrutamiento y proxy. En el archivo server.js verás cómo atrapamos los endpoints del frontend (como /api/ingesta o /api/pendientes) y los repescamos para firmarlos por debajo y redirigirlos al Micro Server Verifactu en el puerto :8000.

La interacción nativa desde el Frontend HTML (en la carpeta index.html) utiliza peticiones puras fetch:

javascript
// Obtiene el payload validado y se lo arroja a tu backend Node.js
const res = await fetch('/api/ingesta', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(facturaPayload)
});

Este encapsulamiento asegura de forma perimetral que ningún secreto, token o configuración sensible caiga en las herramientas de desarrollo del navegador del cliente final.


📦 Código Fuente del Ecosistema Node.js

A continuación puedes inspeccionar el desglose modular de todos los ficheros involucrados en la arquitectura proporcionada.

config.ini

Configuración centralizada de credenciales y red.

Ver/Ocultar Código Fuente (config.ini)
ini
[api]
; Base URL del Micro Server (con o sin /v1)
;base_url = "http://192.168.1.100:8000"
base_url = "http://127.0.0.1:8000"
:base_url = 

; API Key global del servidor
; Se aplica si está activada la seguridad en el Microserver
;token =vf_sys_fgh_BEwEMUFqXWEfPOBKcY9INWdmaRFoAhLV
token=


; Verificar certificado SSL (true/false) en llamadas HTTPS locales
ssl_verify = true

; Timeout en segundos (NodeJs lo usará en ms)
timeout_sec = 30

[demo]
; NIF del emisor asignado a tu Tenant
nif_emisor = "B12345678"

server.js

Servidor Backend en Node.js puro (con proxy hacia MicroServer).

Ver/Ocultar Código Fuente (server.js)
javascript
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');

const PORT = 3000;

// Simple INI Parser without external dependencies 
let config = { api: {}, demo: {} };
try {
    const iniPath = path.join(__dirname, 'config.ini');
    if (fs.existsSync(iniPath)) {
        const lines = fs.readFileSync(iniPath, 'utf8').split('\n');
        let currentSection = '';
        lines.forEach(line => {
            line = line.trim();
            if (line.startsWith(';') || line.startsWith('#') || line === '') return;
            if (line.startsWith('[') && line.endsWith(']')) {
                currentSection = line.substring(1, line.length - 1);
                if (!config[currentSection]) config[currentSection] = {};
            } else {
                const parts = line.split('=');
                if (parts.length >= 2) {
                    const key = parts[0].trim();
                    let val = parts.slice(1).join('=').trim();
                    if (val.startsWith('"') && val.endsWith('"')) val = val.substring(1, val.length - 1);
                    if (val === 'true') val = true;
                    if (val === 'false') val = false;
                    if (currentSection) config[currentSection][key] = val;
                }
            }
        });
        console.log("Config cargada desde config.ini");
    }
} catch (e) {
    console.log("Aviso: No se pudo leer config.ini, usando valores por defecto.");
}

// Global variables initialized from config
const MICRO_SERVER_URL = config.api.base_url || 'http://127.0.0.1:8000';
let proxyHost = '127.0.0.1';
let proxyPort = 8000;
let proxyProtocol = 'http:';

try {
    const parsedUrl = new URL(MICRO_SERVER_URL);
    proxyHost = parsedUrl.hostname;
    proxyPort = parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80);
    proxyProtocol = parsedUrl.protocol;
} catch (e) { }

// Helper to serve static files
function serveFile(res, filePath, contentType) {
    fs.readFile(filePath, (err, content) => {
        if (err) {
            res.writeHead(500);
            res.end(`Error loading ${path.basename(filePath)}`);
        } else {
            res.writeHead(200, { 'Content-Type': contentType });
            res.end(content, 'utf-8');
        }
    });
}

// Helper to proxy requests to Micro Server
function proxyRequest(req, res, targetPath, method) {
    const options = {
        hostname: proxyHost,
        port: proxyPort,
        path: targetPath,
        method: method,
        headers: {
            'Content-Type': 'application/json',
            'Accept': 'application/json'
        },
        // ignore local SSL errors if ssl_verify is false
        rejectUnauthorized: config.api?.ssl_verify !== false
    };

    // Seguridad: Leer API Key y NIF del config.ini
    const TOKEN = config.api?.token || process.env.VERIFACTU_API_KEY || '';
    const NIF_EMISOR = config.demo?.nif_emisor || process.env.VERIFACTU_NIF || 'B12345678';

    if (TOKEN) options.headers['X-API-Key'] = TOKEN;
    if (NIF_EMISOR) options.headers['X-Verifactu-Emisor'] = NIF_EMISOR;

    // Transfer Content-Length so the body is properly piped to FastAPI
    if (req.headers['content-length']) {
        options.headers['Content-Length'] = req.headers['content-length'];
    }

    const requestModule = proxyProtocol === 'https:' ? https : http;

    const proxy = requestModule.request(options, (proxyRes) => {
        res.writeHead(proxyRes.statusCode, proxyRes.headers);
        proxyRes.pipe(res, { end: true });
    });

    // Fix: For GET/HEAD, end request immediately. For POST/PUT, pipe body.
    if (method === 'GET' || method === 'HEAD') {
        proxy.end();
    } else {
        req.pipe(proxy, { end: true });
    }

    proxy.on('error', (e) => {
        console.error(`[PROXY ERROR] ${method} ${targetPath}: ${e.message}`);
        res.writeHead(502);
        // Return JSON error that fetch can parse
        res.end(JSON.stringify({ error: 'Micro Server Unreachable', details: e.message }));
    });
}

const server = http.createServer((req, res) => {
    console.log(`${req.method} ${req.url}`);

    // CORS for local dev ease
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type');

    if (req.method === 'OPTIONS') {
        res.writeHead(204);
        res.end();
        return;
    }

    // Serve UI
    if (req.url === '/' || req.url === '/index.html') {
        serveFile(res, path.join(__dirname, 'index.html'), 'text/html');
        return;
    }

    // Serve Example Invoice JSON
    if (req.url === '/factura_ejemplo.json') {
        serveFile(res, path.join(__dirname, 'factura_ejemplo.json'), 'application/json');
        return;
    }

    // API Proxy: /api/ingesta -> /v1/ingesta
    if (req.url === '/api/ingesta' && req.method === 'POST') {
        proxyRequest(req, res, '/v1/ingesta', 'POST');
        return;
    }

    // API Proxy: /api/pendientes -> /verifactu/pendientes
    if (req.url.startsWith('/api/pendientes') && req.method === 'GET') {
        // Strip /api prefix if needed, or map manually
        // Since we want to pass query params, let's reconstruct path
        const target = req.url.replace('/api/pendientes', '/verifactu/pendientes');
        proxyRequest(req, res, target, 'GET');
        return;
    }

    // API Proxy: /api/status -> /v1/check_status
    if (req.url.startsWith('/api/status') && req.method === 'GET') {
        const target = req.url.replace('/api/status', '/v1/check_status');
        proxyRequest(req, res, target, 'GET');
        return;
    }

    // API Proxy: /api/ack -> /verifactu/ack
    if (req.url === '/api/ack' && req.method === 'POST') {
        // Note: Node native proxying of POST body needs stream piping which we did above
        proxyRequest(req, res, '/verifactu/ack', 'POST');
        return;
    }

    // 404
    res.writeHead(404);
    res.end('Not Found');
});

server.listen(PORT, () => {
    console.log(`Server running at http://localhost:${PORT}/`);
    console.log(`Proxying to ${MICRO_SERVER_URL}`);
});

index.html

Frontend del cliente HTML/CSS/JS nativo.

Ver/Ocultar Código Fuente (index.html)
html
<!DOCTYPE html>
<html lang="es">

<head>
    <meta charset="UTF-8">
    <title>Node.js VeriFactu Client</title>
    <style>
        :root {
            --bg: #0f172a;
            --surface: #1e293b;
            --primary: #3b82f6;
            --text: #e2e8f0;
            --success: #22c55e;
            --error: #ef4444;
        }

        body {
            margin: 0;
            font-family: system-ui, sans-serif;
            background: var(--bg);
            color: var(--text);
            display: flex;
            height: 100vh;
            overflow: hidden;
        }

        aside {
            width: 400px;
            background: var(--surface);
            padding: 20px;
            border-right: 1px solid #334155;
            overflow-y: auto;
            box-sizing: border-box;
            flex-shrink: 0;
            max-height: 100vh;
        }

        main {
            flex: 1;
            padding: 40px;
            overflow-y: auto;
            box-sizing: border-box;
            min-height: 0;
            max-height: 100vh;
        }

        h1,
        h2 {
            margin-top: 0;
        }

        .card {
            background: var(--surface);
            padding: 20px;
            border-radius: 12px;
            margin-bottom: 20px;
            border: 1px solid #334155;
        }

        button {
            background: var(--primary);
            color: white;
            border: none;
            padding: 10px 15px;
            border-radius: 6px;
            cursor: pointer;
            width: 100%;
            font-weight: 600;
            font-size: 0.9rem;
            transition: filter 0.2s;
            margin-bottom: 10px;
        }

        button:hover {
            filter: brightness(1.1);
        }

        button.secondary {
            background: #475569;
        }

        input {
            background: #0f172a;
            border: 1px solid #334155;
            color: white;
            padding: 8px;
            border-radius: 6px;
            width: 100%;
            margin-bottom: 10px;
            box-sizing: border-box;
        }

        label {
            display: block;
            margin-bottom: 5px;
            font-size: 0.85rem;
            color: #94a3b8;
        }

        #log {
            font-family: 'Fira Code', monospace;
            font-size: 0.85rem;
            color: #a5f3fc;
            white-space: pre-wrap;
        }

        .status-badge {
            display: inline-block;
            padding: 2px 8px;
            border-radius: 99px;
            font-size: 0.75rem;
            font-weight: bold;
        }

        .st-pending {
            background: #f59e0b20;
            color: #fbbf24;
        }

        .st-ok {
            background: #22c55e20;
            color: #4ade80;
        }

        /* Grid */
        .grid-2 {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 10px;
        }
    </style>
</head>

<body>

    <aside>
        <h2>⚡ Node.js Client</h2>
        <p style="font-size: 0.85rem; color: #94a3b8; margin-bottom: 20px;">
            Esta demo utiliza un servidor Node.js ligero (sin frameworks) para servir esta UI y actuar como proxy seguro
            hacia el Micro Server.
        </p>

        <div class="card">
            <h3 style="margin-bottom: 10px;">🚀 Acción: 1 Factura (Individual)</h3>
            <p style="font-size: 0.75rem; color: #94a3b8; margin-top:0; margin-bottom: 15px;">Ciclo completo para la
                factura cargada en el editor.</p>
            <button onclick="cicloCompleto()" style="background: #8b5cf6; margin-bottom: 15px;">✨ Ingesta -> AEAT ->
                Esperar -> Ack</button>
            <div style="display:flex; gap:10px;">
                <button onclick="enviarIngesta()">Solo Ingerir</button>
                <button onclick="checkStatus()" class="secondary">Ver Estado</button>
            </div>

            <div
                style="background: #0ea5e920; padding:10px; border-radius:5px; margin-top:10px; border: 1px solid #0ea5e950">
                <input type="text" id="manual_indice" placeholder="ID (ej. 2480)"
                    style="margin-bottom:5px; background: rgba(0,0,0,0.2)">
                <button onclick="enviarAckManual()" class="secondary"
                    style="background: #0ea5e9; border:none; margin-bottom: 0;">Forzar ACK (ID manual o S-N)</button>
            </div>
        </div>

        <div class="card">
            <h3 style="color:#f59e0b; margin-bottom: 10px;">🔥 Ingesta Automática (Lote)</h3>
            <p style="font-size: 0.75rem; color: #94a3b8; margin-top:0; margin-bottom: 15px;">Emite facturas
                secuencialmente basándose en el editor.</p>
            <div style="display:flex; gap:10px; align-items: center;">
                <input type="number" id="lote_num" min="1" max="50" value="5" style="width: 80px; margin-bottom:0;">
                <button onclick="ingestaEnLote()" style="margin-bottom:0; background: #ea580c;">Emitir N
                    Facturas</button>
            </div>
        </div>

        <div class="card" style="border-color:#10b981;">
            <h3 style="color:#10b981; margin-bottom: 10px;">📬 Bandeja de Entrada AEAT</h3>
            <p style="font-size: 0.75rem; color: #94a3b8; margin-top:0; margin-bottom: 15px;">Obtiene lo último validado
                por AEAT. Haz click repetido para refrescar.</p>
            <button onclick="getPendientes(false)"
                style="margin-bottom:10px; background: #10b981; color: #022c22;">Recoger y Observar (Sin ACK)</button>
            <button onclick="getPendientes(true)" style="margin-bottom:0; background: #059669; color: white;">Recoger y
                Confirmar Masivo (Con ACK)</button>
        </div>

        <div class="card">
            <h3>⚙️ Config</h3>
            <label>NIF Emisor</label>
            <input type="text" id="nif" placeholder="Se extraerá del JSON" value="">
            <label>Serie / Num</label>
            <div class="grid-2">
                <input type="text" id="serie" placeholder="F2024">
                <input type="text" id="num" placeholder="100">
            </div>
        </div>
    </aside>

    <main>
        <div class="card">
            <details>
                <summary style="cursor:pointer; font-weight:bold; color:var(--primary)">📝 Editar JSON de la Factura
                    (Click para desplegar)</summary>
                <p style="font-size:0.8rem; color:#94a3b8; margin:5px 0;">Aquí puedes pegar o modificar el JSON completo
                    que se enviará.</p>
                <textarea id="json-editor" spellcheck="false"
                    style="width:100%; height:300px; background:#0f172a; color:#a5f3fc; font-family:'Fira Code', monospace; border:1px solid #334155; padding:10px; border-radius:6px; resize:vertical;"></textarea>
                <div style="text-align:right; margin-top:5px;">
                    <button onclick="resetJSON()" class="secondary"
                        style="width:auto; padding:5px 10px; font-size:0.8rem;">🔄 Recargar Original</button>
                </div>
            </details>
        </div>

        <div class="card">
            <h2>📡 Live Log / Respuesta</h2>
            <div id="log">Esperando acciones...</div>
        </div>

        <div id="results-container"></div>
    </main>

    <script>
        const LOG = document.getElementById('log');

        // Helper: Log to screen
        function log(msg, data) {
            let text = `[${new Date().toLocaleTimeString()}] ${msg}\n`;
            if (data) {
                if (data instanceof Error) {
                    text += data.toString() + "\\n" + (data.stack || '');
                } else {
                    text += JSON.stringify(data, null, 2);
                }
            }
            LOG.innerText = text;
        }

        // LocalStorage Keys
        const LS_JSON = 'factura_json';
        const LS_NIF = 'factura_nif';
        const LS_SERIE = 'factura_serie';
        const LS_NUM = 'factura_num';

        // Initial Load
        window.addEventListener('DOMContentLoaded', () => {
            // Cargar Inputs
            if (localStorage.getItem(LS_NIF)) document.getElementById('nif').value = localStorage.getItem(LS_NIF);
            if (localStorage.getItem(LS_SERIE)) document.getElementById('serie').value = localStorage.getItem(LS_SERIE);
            if (localStorage.getItem(LS_NUM)) document.getElementById('num').value = localStorage.getItem(LS_NUM);

            // Listeners para guardar inputs al vuelo
            document.getElementById('nif').addEventListener('input', (e) => localStorage.setItem(LS_NIF, e.target.value));
            document.getElementById('serie').addEventListener('input', (e) => localStorage.setItem(LS_SERIE, e.target.value));
            document.getElementById('num').addEventListener('input', (e) => localStorage.setItem(LS_NUM, e.target.value));

            // Listener para guardar JSON al vuelo
            document.getElementById('json-editor').addEventListener('input', (e) => localStorage.setItem(LS_JSON, e.target.value));

            // Cargar JSON: Si existe en LS, usarlo. Si no, Reset.
            const savedJson = localStorage.getItem(LS_JSON);
            if (savedJson) {
                document.getElementById('json-editor').value = savedJson;
                log("Datos recuperados de tu sesión anterior (LocalStorage).");
            } else {
                resetJSON();
            }
        });

        async function resetJSON() {
            // Limpiar LS para obligar carga limpia
            localStorage.removeItem(LS_JSON);
            // No limpiamos nif/serie/num del LS para no molestar al usuario, o sí? 
            // El usuario dijo "prescindir del archivo y guardarlo en memoria hasta que no se sustituya".
            // Entendemos que "Recargar Original" es volver al archivo, borrando cambios.

            try {
                const fRes = await fetch('/factura_ejemplo.json');
                const json = await fRes.json();
                const textInfo = JSON.stringify(json, null, 4);
                document.getElementById('json-editor').value = textInfo;
                localStorage.setItem(LS_JSON, textInfo); // Guardar de nuevo en LS como base
                log("JSON recargado desde plantilla original.");

                // Auto-populate UI if they are empty or we want to force them from JSON
                const nifFromJSON = json?.cabecera?.emisor || json?.cabecera?.nif_emisor || '';
                const serieFromJSON = json?.cabecera?.serie || '';
                const numFromJSON = json?.cabecera?.numfactura || '';

                if (nifFromJSON) { document.getElementById('nif').value = nifFromJSON; localStorage.setItem(LS_NIF, nifFromJSON); }
                if (serieFromJSON) { document.getElementById('serie').value = serieFromJSON; localStorage.setItem(LS_SERIE, serieFromJSON); }
                if (numFromJSON) { document.getElementById('num').value = numFromJSON; localStorage.setItem(LS_NUM, numFromJSON); }

            } catch (e) {
                log("Error cargando plantilla:", e);
            }
        }

        function getJSONFromEditor() {
            const val = document.getElementById('json-editor').value;
            try {
                return JSON.parse(val);
            } catch (e) {
                log("❌ ERROR DE SINTAXIS EN EL JSON MANUAL:", e);
                return null;
            }
        }

        async function enviarIngesta() {
            log("Enviando factura (Tal cual está en el editor)...");

            // 1. Obtener base del Editor
            let factura = getJSONFromEditor();
            if (!factura) return; // Si hay error de sintaxis, paramos

            // Reflejar en la UI lo que haya en el JSON
            const nifFromJSON = factura.cabecera?.emisor || '';
            const serieFromJSON = factura.cabecera?.serie || '';
            const numFromJSON = factura.cabecera?.numfactura || '';

            if (nifFromJSON) document.getElementById('nif').value = nifFromJSON;
            if (serieFromJSON) document.getElementById('serie').value = serieFromJSON;
            if (numFromJSON) document.getElementById('num').value = numFromJSON;

            // Guardar cambios en LS extraidos del JSON
            if (nifFromJSON) localStorage.setItem(LS_NIF, nifFromJSON);
            localStorage.setItem(LS_SERIE, serieFromJSON);
            localStorage.setItem(LS_NUM, numFromJSON);

            const finalJson = JSON.stringify(factura, null, 4);
            localStorage.setItem(LS_JSON, finalJson);

            try {
                const res = await fetch('/api/ingesta', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: finalJson
                });

                // Fallback for empty responses
                let data = {};
                try { data = await res.json(); } catch (e) { }

                if (res.ok) {
                    log("✅ Respuesta Ingesta (OK):", data);
                } else {
                    log("❌ ERROR en Ingesta: " + (data.mensaje || data.detail || data.error || JSON.stringify(data)), data);
                }
            } catch (e) {
                log("❌ Error de comunicación:", e);
            }
        }

        async function checkStatus() {
            const nif = document.getElementById('nif').value;
            const serie = document.getElementById('serie').value;
            const num = document.getElementById('num').value;

            log(`Consultando estado para ${serie}-${num}...`);

            try {
                const res = await fetch(`/api/status?emisor=${nif}&serie=${serie}&num=${num}`);
                let data = {};
                try { data = await res.json(); } catch (e) { }

                if (res.ok) {
                    log("Estado Factura:", data);
                } else {
                    log("❌ ERROR CheckStatus: " + (data.mensaje || data.detail || data.error || JSON.stringify(data)), data);
                }
            } catch (e) {
                log("❌ Error:", e);
            }
        }

        async function ingestaEnLote() {
            let nif = document.getElementById('nif').value;
            let numLote = parseInt(document.getElementById('lote_num').value) || 5;

            log(`--- INICIO INGESTA EN LOTE (${numLote} FACTURAS) ---`);
            let facturaBase = getJSONFromEditor();
            if (!facturaBase) return;

            const baseNum = facturaBase.cabecera?.numfactura || 'LOTE';
            let promises = [];
            let lastNum = baseNum;
            for (let i = 1; i <= numLote; i++) {
                // Clonar factura
                let factura = JSON.parse(JSON.stringify(facturaBase));

                // Variar el numero de factura para evitar colisiones
                let newNum = `${baseNum}-${i}`;
                if (!isNaN(parseInt(baseNum))) {
                    newNum = (parseInt(baseNum) + i).toString();
                }
                lastNum = newNum;

                factura.cabecera.numfactura = newNum;
                // Modificar fecha para emular lotes modernos si es preciso
                // factura.cabecera.fecha_expedicion = new Date().toISOString().split('T')[0];

                if (!factura.metadata) factura.metadata = {};
                factura.metadata.enviar_aeat = true;

                const finalJson = JSON.stringify(factura);
                log(`[${i}/${numLote}] Enviando ${factura.cabecera.serie}-${newNum}...`);

                try {
                    const iRes = await fetch('/api/ingesta', {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: finalJson
                    });

                    if (iRes.ok) {
                        log(`   ✅ Ingesta OK: ${newNum}`);
                    } else {
                        log(`   ❌ Error en Ingesta: ${newNum}`);
                    }
                } catch (e) {
                    log(`   ❌ Error en conexión`);
                }

                // Esperar un poquito entre envíos
                await new Promise(r => setTimeout(r, 100));
            }

            // Actualizar el UI y el JSON base para la proxima tirada
            facturaBase.cabecera.numfactura = lastNum;
            const updatedJson = JSON.stringify(facturaBase, null, 4);
            document.getElementById('json-editor').value = updatedJson;
            localStorage.setItem(LS_JSON, updatedJson);

            document.getElementById('num').value = lastNum;
            localStorage.setItem(LS_NUM, lastNum);

            log(`--- LOTE TERMINADO ---. Factura base actualizada a ${lastNum}. Pulsa ahora a recoger pendientes.`);
        }

        async function getPendientes(autoAck = false) {
            const nif = document.getElementById('nif').value;
            log(`Consultando pendientes... (Auto-ACK: ${autoAck})`);

            try {
                // Usando n_ultimos 50 como maximo para barrer bandejas
                const res = await fetch(`/api/pendientes?nif_emisor=${nif}&n_ultimos=50`);
                let data = {};
                try { data = await res.json(); } catch (e) { }

                if (res.ok) {
                    let items = Array.isArray(data) ? data : (data.items || []);
                    log(`📥 Pendientes encontrados: ${items.length}`);

                    if (items.length > 0) {
                        const container = document.getElementById('results-container');
                        container.innerHTML = `
                        <div class="card" style="overflow-x:auto;">
                            <h3>📋 Últimas Facturas procesadas (Bandeja AEAT)</h3>
                            <table style="width:100%; text-align:left; border-collapse: collapse; font-size: 0.85rem; white-space: nowrap;">
                                <thead><tr style="border-bottom:1px solid #334155; color:#94a3b8">
                                    <th style="padding:8px">ID</th>
                                    <th style="padding:8px">Modo</th>
                                    <th style="padding:8px">Serie-Num</th>
                                    <th style="padding:8px">Fecha</th>
                                    <th style="padding:8px">Importe</th>
                                    <th style="padding:8px">CSV</th>
                                    <th style="padding:8px">Huella (Trunc)</th>
                                    <th style="padding:8px">Enlace QR</th>
                                    <th style="padding:8px">Acción</th>
                                </tr></thead>
                                <tbody>
                                    ${items.map(i => `
                                        <tr style="border-bottom:1px solid #1e293b">
                                            <td style="padding:8px; color:#fbbf24;">${i.indice_log || ''}</td>
                                            <td style="padding:8px; color:#a78bfa;">${i.modo !== undefined ? i.modo : ''}</td>
                                            <td style="padding:8px; font-weight:bold;">${i.num_serie_factura}</td>
                                            <td style="padding:8px">${i.fecha_expedicion_factura || '...'}</td>
                                            <td style="padding:8px">${i.total ? i.total + ' €' : '...'}</td>
                                            <td style="padding:8px; font-family:monospace; color:#a5f3fc">${i.csv || '...'}</td>
                                            <td style="padding:8px; font-family:monospace; font-size: 0.75rem;" title="${i.huella}">${i.huella ? i.huella.substring(0, 16) + '...' : '...'}</td>
                                            <td style="padding:8px">${i.url_qr_verifactu ? `<a href="${i.url_qr_verifactu}" target="_blank" style="color:#3b82f6; text-decoration:none;">🔗 Abrir QR</a>` : '...'}</td>
                                            <td style="padding:8px">
                                                <button onclick="enviarAckById('${nif}', '${i.indice_log}')" style="padding:2px 8px; font-size:0.7rem; width:auto; margin:0;" ${autoAck ? 'disabled' : ''}>ACK</button>
                                            </td>
                                        </tr>
                                    `).join('')}
                                </tbody>
                            </table>
                        </div>
                    `;

                        if (autoAck) {
                            log("⚙️ Ejecutando Auto-ACK masivo sobre la bandeja...");
                            for (let item of items) {
                                if (item.indice_log) {
                                    log(`   - Confirmando ID ${item.indice_log} (${item.num_serie_factura})...`);
                                    await fetch('/api/ack', {
                                        method: 'POST',
                                        headers: { 'Content-Type': 'application/json' },
                                        body: JSON.stringify({ nif_emisor: nif, indice_log: parseInt(item.indice_log) })
                                    });
                                    await new Promise(r => setTimeout(r, 100)); // Be nice to python backend
                                }
                            }
                            log("✅ Auto-ACK finalizado. Recargando la bandeja limpia...");
                            await getPendientes(false); // reload but without auto-ack to see empty state
                        }

                    } else {
                        const container = document.getElementById('results-container');
                        container.innerHTML = `<div class="card" style="text-align:center; padding:30px; color:#94a3b8">🎉 ¡Enhorabuena! No hay facturas pendientes en la cola.<br>Todo está sincronizado.</div>`;
                    }
                } else {
                    log("❌ ERROR Pendientes: " + (data.mensaje || data.detail || data.error || JSON.stringify(data)), data);
                }
            } catch (e) {
                log("❌ Error:", e);
            }
        }

        async function enviarAckById(nif, id) {
            log(`Enviando ACK manual directo para ID ${id}...`);
            try {
                const ackRes = await fetch('/api/ack', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ nif_emisor: nif, indice_log: parseInt(id) })
                });

                let dataAck = {};
                try { dataAck = await ackRes.json(); } catch (e) { }

                if (ackRes.ok && dataAck.ok !== false) {
                    log("   > ACK enviado correctamente.", dataAck);
                    getPendientes(); // auto-refresh
                } else {
                    log(`   > Error en respuesta ACK. Código HTTP: ${ackRes.status}`, dataAck);
                }
            } catch (e) {
                log("   > Error enviando ACK:", e);
            }
        }

        async function enviarAckManual() {
            const nif = document.getElementById('nif').value;
            const manualIdInput = document.getElementById('manual_indice')?.value?.trim();
            const serie = document.getElementById('serie').value;
            const num = document.getElementById('num').value;

            let indiceLog = null;

            if (manualIdInput) {
                // Modo Explícito: El usuario provee el indice_log
                indiceLog = parseInt(manualIdInput);
                log(`Confirmando ID EXPLÍCITO ${indiceLog}...`);
            } else {
                // Fallback: Buscar en la lista de pendientes por Serie-Num
                if (!serie || !num) {
                    log("Error: Debes indicar el 'ID de la lista' o la 'Serie y Número' para confirmar.");
                    return;
                }

                log(`Confirmando ${serie}-${num}...`);
                log("1. Buscando ID interno real en tabla pendientes...");

                try {
                    // Paso 1: Buscar ID en pendientes
                    const resStatus = await fetch(`/api/pendientes?nif_emisor=${nif}&n_ultimos=50`);
                    const PData = await resStatus.json();
                    let items = Array.isArray(PData) ? PData : (PData.items || []);

                    const facturaStr = `${serie}-${num}`;
                    // Identificar la factura por serie/numero
                    const found = items.find(x => x.num_serie_factura === facturaStr || x.num_serie_factura === num);

                    if (!found || !found.indice_log) {
                        log("   > Error: No se encontró la factura en la cola de Pendientes (ultimas 50). Es posible que no haya terminado de procesarse o que YA HAYA SIDO CONFIRMADA (ACK) previamente.");
                        return;
                    }
                    indiceLog = found.indice_log;
                    log(`   > ID encontrado: ${indiceLog}`);

                } catch (e) {
                    log("   > Error buscando factura:", e);
                    return;
                }
            }

            // Paso 2: Enviar ACK con el ID
            log(`2. Enviando ACK para ID ${indiceLog}...`);
            try {
                const res = await fetch('/api/ack', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ nif_emisor: nif, indice_log: parseInt(indiceLog) })
                });

                let dataAck = {};
                try { dataAck = await res.json(); } catch (e) { }

                if (res.ok && dataAck.ok !== false) {
                    log("   > ACK enviado correctamente.", dataAck);
                    // Limpiar el campo para evitar acks dobles accidentales
                    if (document.getElementById('manual_indice')) {
                        document.getElementById('manual_indice').value = '';
                    }
                    getPendientes();
                } else {
                    log("   > Error enviando ACK: " + res.status, dataAck);
                }
            } catch (e) {
                log("   > Error enviando ACK:", e);
            }
        }

        async function cicloCompleto() {
            let nif = document.getElementById('nif').value;

            // 1. Preparar Factura
            log("--- INICIO CICLO COMPLETO ---");
            log("1. Generando/Leyendo factura (Se respeta 100% el JSON del Editor)...");

            // Usar el JSON del editor como base
            let factura = getJSONFromEditor();
            if (!factura) return;

            // Extraer del JSON a UI, NO al revés
            if (factura.cabecera?.emisor) nif = factura.cabecera.emisor;
            const serie = factura.cabecera?.serie || '';
            const num = factura.cabecera?.numfactura || '';

            if (nif) document.getElementById('nif').value = nif;
            document.getElementById('serie').value = serie;
            document.getElementById('num').value = num;

            localStorage.setItem(LS_NIF, nif);
            localStorage.setItem(LS_SERIE, serie);
            localStorage.setItem(LS_NUM, num);

            // FORZAR ENVIO INMEDIATO A AEAT
            if (!factura.metadata) factura.metadata = {};
            factura.metadata.enviar_aeat = true;

            const finalJson = JSON.stringify(factura, null, 4);
            document.getElementById('json-editor').value = finalJson;
            localStorage.setItem(LS_JSON, finalJson);

            // 2. Enviar (Ingesta)
            log(`2. Enviando ${serie}-${num} a AEAT...`);
            let idEnvio = 0;
            let lineaDet = 0;

            try {
                const iRes = await fetch('/api/ingesta', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: finalJson
                });
                let iData = {};
                try { iData = await iRes.json(); } catch (e) { }

                if (iRes.ok) {
                    log("   ✅ Ingesta OK:", iData);
                    if (iData.tracking) {
                        idEnvio = iData.tracking.cab_num_secuencia || 0;
                        lineaDet = iData.tracking.det_linea || 0;
                    }
                } else {
                    log("   ❌ Error en Ingesta: " + (iData.mensaje || iData.detail || iData.error || JSON.stringify(iData)), iData);
                    return;
                }
            } catch (e) {
                log("   ❌ Error en conexión ingesta:", e);
                return;
            }

            // 3. Polling (Esperar resultado desde pendientes, al estilo Delphi)
            log("3. Esperando procesamiento (consultando Pendientes)...");
            let finalPendiente = null;
            for (let i = 0; i < 15; i++) { // 15 intentos
                await new Promise(r => setTimeout(r, 1000)); // Esperar 1s

                try {
                    let url = `/api/pendientes?nif_emisor=${nif}&n_ultimos=50`;
                    if (idEnvio > 0 && lineaDet > 0) {
                        // Optimización: igual que Delphi, pedimos solo esta correlación
                        url = `/api/pendientes?nif_emisor=${nif}&id_envio=${idEnvio}&linea_log_detalle=${lineaDet}`;
                    }

                    const pRes = await fetch(url);
                    const pData = await pRes.json();
                    let items = Array.isArray(pData) ? pData : (pData.items || []);

                    let found;
                    if (idEnvio > 0 && lineaDet > 0) {
                        found = items.find(x => x.id_envio == idEnvio && x.linea_log_detalle == lineaDet);
                    } else {
                        const serieNumStr = `${serie}-${num}`;
                        found = items.find(x => x.num_serie_factura === serieNumStr || x.num_serie_factura === num);
                    }

                    if (found && found.status !== undefined && found.status >= 0 && found.status <= 3) {
                        finalPendiente = found;
                        break;
                    }
                } catch (e) { console.error(e); }
            }

            if (!finalPendiente) {
                log("   > Timeout: No apareció la factura terminada en la cola de Pendientes.");
                return;
            }

            log("4. Factura Encontrada en Pendientes:", finalPendiente);

            // Map variables for rendering
            finalPendiente.detalles_aeat = {
                huella: finalPendiente.huella,
                csv: finalPendiente.csv,
                qr_verifactu: finalPendiente.qr_verifactu,
                url_qr_verifactu: finalPendiente.url_qr_verifactu
            };

            // 4. Mostrar Resultados "Premium"
            renderPremiumResults(finalPendiente, factura.cabecera);

            // 5. Ack (Confirmar auto)
            log("5. Enviando ACK auto...");
            const indiceLog = finalPendiente.indice_log;

            if (!indiceLog) {
                log("   > SKIPPED: No se puede confirmar, no se encontró ID en la respuesta de estado.");
                return;
            }

            try {
                const ackRes = await fetch('/api/ack', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ nif_emisor: nif, indice_log: parseInt(indiceLog) })
                });
                if (ackRes.ok) {
                    log(`   > Ciclo finalizado CORRECTAMENTE (ACK ID: ${indiceLog}).`);
                } else {
                    log(`   > Error en respuesta ACK. Código HTTP: ${ackRes.status}`);
                }
            } catch (e) {
                log("   > Error en ACK (no crítico):", e);
            }
        }

        function renderPremiumResults(state, cabecera) {
            const container = document.getElementById('results-container');
            // Usar 'detalles_aeat' si existe, si no, 'state' directo
            const d = state.detalles_aeat || state;

            // Lógica de colores según usuario:
            // 0: Verde (Correcto)
            // 1: Naranja (Aceptado con Errores/Warning)
            // >= 2: Rojo (Error)

            let color = '#22c55e'; // Verde por defecto (0, OK, 200...)
            let title = '✅ Factura Aceptada';
            let isError = false;

            // Normalizar a número si es posible
            let s = state.status;

            if (s === 'pending') {
                color = '#3b82f6'; // Azul
                title = '⏳ Procesando / Pendiente';
            } else if (s === 1) {
                color = '#f97316'; // Naranja
                title = '⚠️ Aceptada con Errores';
            } else if (s >= 2) {
                color = '#ef4444'; // Rojo
                title = '❌ Error (' + s + ')';
                isError = true;
            } else if (s === 0 || s === 'OK' || s === 200 || s === 201) {
                color = '#22c55e'; // Verde
                title = '✅ Factura Aceptada';
            } else {
                // Fallback para otros códigos desconocidos
                color = '#ef4444';
                title = '❌ Estado Desconocido (' + s + ')';
                isError = true;
            }

            let qrHtml = '';
            // Prioridad: QR en Base64 (cab_qr_verifactu)
            if (d.cab_qr_verifactu) {
                const src = `data:image/png;base64,${d.cab_qr_verifactu}`;
                qrHtml = `<div style="text-align:center; margin:10px;"><img src="${src}" style="width:150px; border-radius:8px; border:4px solid white;"></div>`;
            } else if (state.qr_verifactu) {
                // Fallback antiguo
                const src = state.qr_verifactu.startsWith('http') ? state.qr_verifactu : `data:image/png;base64,${state.qr_verifactu}`;
                qrHtml = `<div style="text-align:center; margin:10px;"><img src="${src}" style="width:150px; border-radius:8px; border:4px solid white;"></div>`;
            }

            // Formatear Serie-Factura (ej: TK-00329)
            let s_serie = d.serie || cabecera?.serie || '';
            let s_num = d.factura || cabecera?.numfactura || '';
            let serieFactura = state.num_serie_factura || `${s_serie}-${s_num}`;

            if (!state.num_serie_factura && s_num && !isNaN(s_num)) {
                serieFactura = `${s_serie}-${s_num.toString().padStart(5, '0')}`;
            }

            // Formatear Total (float o string)
            let total = d.cab_total_factura || state.total || cabecera?.total;
            if (!total && cabecera?.base && cabecera?.totaliva) {
                total = (parseFloat(cabecera.base) + parseFloat(cabecera.totaliva)).toFixed(2);
            }

            // Formatear Fecha
            let fecha = d.cab_fecha_oficial || state.fecha_expedicion_factura || cabecera?.fecha || '';
            // Si viene en formato ISO (YYYY-MM-DD), formatear a ES
            if (fecha && fecha.includes('-') && !fecha.includes('/')) {
                const parts = fecha.split('T')[0].split('-');
                if (parts.length === 3) fecha = `${parts[2]}/${parts[1]}/${parts[0]}`;
            }

            container.innerHTML = `
                 <div class="card" style="border-left: 5px solid ${color};">
                    <h3 style="color:${color}">${title}</h3>
                    
                    <div class="grid-2">
                        <div>
                            <p><strong>Factura:</strong> ${serieFactura}</p>
                            <p><strong>Fecha:</strong> ${fecha}</p>
                            <p><strong>Total:</strong> ${total} €</p>
                            <p><strong>CSV:</strong> <span style="font-family:monospace; background:#334155; padding:2px 5px; border-radius:4px;">${d.cab_csv_verifactu || d.csv || 'N/A'}</span></p>
                        </div>
                        <div>
                            ${qrHtml}
                        </div>
                    </div>

                    ${(d.codigo_error || d.det_cod_error) ? `
                        <div style="background:#ef444420; padding:10px; border-radius:6px; margin-top:10px; border:1px solid #ef4444;">
                            <strong>Error ${d.codigo_error || d.det_cod_error}:</strong> ${d.descripcion_error || d.cab_msj_error || 'Error desconocido'}
                        </div>
                    ` : ''}
                    
                    <div style="margin-top:10px; font-size:0.8rem; color:#94a3b8">
                        Estado Interno: ${state.status} (${state.mensaje || 'Sin mensaje'})
                    </div>
                 </div>
             `;
        }
    </script>

</body>

</html>

factura_ejemplo.json

Plantilla base de factura (JSON).

Ver/Ocultar Código Fuente (factura_ejemplo.json)
json
{
   
    "cabecera": {
        "emisor": "",
        "numfactura": "500",
        "serie": "FGH",
        "rectificativa": "N",
        "tipofacturarectificativa": "",
        "fecharectificada": "",
        "facturarectificada": "",
        "rectificativasustitucion": "",
        "facturaf3": "N",
        "numseriesustituidaf3": "",
        "fechafactsustituidaf3": "",
        "descripcionoperacion": "Tickets de venta cafetería A",
        "eliminacion": "",
        "tipodoc": "02",
        "pais": "ES",
        "nif": "",
        "nombre": "",
        "fecha": "15/02/2026",
        "totaliva": "14.36",
        "totalrecargo": "0.00",
        "base": "271.28",
        "base1": "250.65",
        "piva1": "4.00",
        "iva1": "10.03",
        "precargo1": "",
        "recargo1": "",
        "base2": "20.63",
        "piva2": "21.00",
        "iva2": "4.33",
        "precargo2": "",
        "recargo2": "",
        "base3": "",
        "piva3": "",
        "iva3": "",
        "precargo3": "",
        "recargo3": "",
        "base4": "",
        "piva4": "",
        "iva4": "",
        "precargo4": "",
        "recargo4": ""
    },
    "detalle": {
        "base": [
            "250.65",
            "20.63",
            "",
            ""
        ],
        "piva": [
            "4.00",
            "21.00",
            "",
            ""
        ],
        "iva": [
            "10.03",
            "4.33",
            "",
            ""
        ],
        "precargo": [
            "",
            "",
            "",
            ""
        ],
        "recargo": [
            "",
            "",
            "",
            ""
        ]
    }
}