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:
- Instalar Node: Si no lo tienes en tu equipo, descárgalo e instálalo desde nodejs.org.
- Ubícate en tu consola (
TerminaloPowerShell) dentro de la carpeta donde has extraído los ficheros del SDK. - No se requiere instalar librerías externas (no hay
package.jsonni requerimientos NPM adicionales). - Arranca el servidor Backend:bash(Nota: si cuentas con el binario de windows adjunto también puedes invocar
node server.js.\node.exe server.js) - 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:
// 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)
[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)
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)
<!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)
{
"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": [
"",
"",
"",
""
]
}
}