Guía de Integración Rápida con PHP
El SDK de PHP está diseñado con la filosofía "Drop-in": un solo archivo (vf_engine.php) y un config.ini que puedes colar en cualquier parte de este universo, desde tu potente proyecto en Laravel o Symfony, hasta servidores legacy o CMS compartidos (WordPress, PrestaShop) sin depender de Composer.
La arquitectura abstrae toda la complejidad asíncrona de VeriFactu y las comunicaciones SSL nativas mediante la extensión cURL.
A continuación, te explicamos cómo poner a funcionar este motor en tu entorno.
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 esta demostración: Presta atención a la pantalla dividida. En la mitad de la izquierda estás viendo la interfaz de administración del Micro Server (tu pasarela), mientras que en la mitad derecha estás viendo el código cliente PHP puro siendo ejecutado bajo un entorno de desarrollo local XAMPP / WAMP. Verás cómo el cliente PHP envía facturas al vuelo y el Micro Server las absorbe en microsegundos y las deriva paralelamente a la AEAT.
🏗️ 1. Configuración del Motor (VFEngine)
A diferencia de otros lenguajes donde instanciamos la regla manual, en PHP hemos externalizado esto a un config.ini para que no dejes hardcodeado tu token en el código fuente ni lo mezcles con la lógica del negocio.
Edita tu config.ini:
[api]
; IMPORTANTE: El SDK conecta contra la IP de TU MicroServer (Middleware Local),
; NO contra los servidores en la nube de SystemsFGH.
base_url=http://localhost:8000
timeout_sec=30
token=TXT-VFACTU-TU-TOKEN
; True para entornos donde tu Micro Server tiene HTTPS y certificados válidos.
; Pon 'false' SOLO si pruebas en XAMPP local sin certificados.
ssl_verify=true
[demo]
nif_emisor=B12345678En tu script, instanciar la herramienta es inmediato:
require_once 'vf_engine.php';
// Lee automáticamente 'config.ini' y prepara las variables cURL de forma autónoma
$vf = new VFEngine();🚀 2. Envío o Ingesta a la AEAT
Tu programa construye un array asociativo normal y corriente. Al enviarlo a la pasarela (Micro Server), este lo validará, encolará y procesará criptográficamente por ti en un hilo en background, liberando tu petición PHP casi al instante.
// Tu array de asociación nativo. Olvídate del XML!
$datosFactura = [
"cabecera" => [
"emisor" => "B12345678",
"tipo_comunicacion" => "A0",
"tipo_factura" => "F1"
],
// ... más datos ...
"totales" => [
["base" => 100.0, "cuota" => 21.0]
]
];
// Transmite al Micro Server
$res = $vf->ingestaJson($datosFactura);
if ($res['ok']) {
echo "¡Factura absorbida y encolada! ID Servidor: " . $res['id'];
} else {
// Si la estructura está mal formada (ej: Base y Cuota no cuadran)
echo "Error del Verificador: " . $res['mensaje'];
}📬 3. Recepción y Confirmación Asíncrona (Cron)
Dado que un script de PHP Web tiene un tiempo de vida efímero y no debe quedarse esperando por la AEAT, lo recomendable es que crees un pequeño script CRON que se ejecute cada minuto pidiendo las facturas ya verificadas, o en su defecto que el ERP mande la factura y compruebe las pendientes más tarde en otra pantalla.
// Pedimos a la AEAT las últimas facturas validadas
$res = $vf->getPendientes("B12345678", 50);
if ($res['ok'] && !empty($res['items'])) {
foreach ($res['items'] as $item) {
$logIndex = $item['indice_log'];
$huella = $item['huella'];
$urlQr = $item['url_qr_verifactu'];
// 1. Guardas el QR y la Huella en la base de datos de tu ERP...
// TuERP::GuardarHuellaAeat($item['num_factura'], $huella, $urlQr);
// 2. Le indicas a la pasarela que te ha llegado OK, para borrarla de la cola.
$ack = $vf->ackIndice("B12345678", $logIndex);
echo "Validada Factura " . $item['num_factura'] . " - ACK = " . ($ack['ok'] ? "OK" : "ERROR");
}
}📦 Código Fuente del Core SDK
Para que la arquitectura funcione transparente en tu PHP y no dependas del ecosistema Composer, la orquestación recae íntegramente sobre 4 ficheros base.
A continuación se incluye el código fuente completo de cada uno para que puedas revisarlo o integrarlo directamente copiando su contenido.
config.ini
Configuración centralizada para abstraer credenciales y URL.
Ver/Ocultar Código Fuente (config.ini)
[api]
; URL del VeriFactu Micro Server
;base_url=http://192.168.1.100:8000
;base_url=http://127.0.0.1:8000
base_url=localhost:8000
; Timeout global para operaciones (segundos)
timeout_sec=60
; Token de autenticación (se aplica si está activada la seguridad en el microserver)
;token=vf_sys_fgh_BEwEMUFqXWEfPOBKcY9INWdmaRFoAhLV
token=
; Verificar certificado SSL (true/false)
ssl_verify=true
[demo]
; NIF del emisor para las pruebas (se sobreescribe con el del editor JSON)
nif_emisor="05616281A"
; Número de facturas a recuperar en polling
n_ultimos=50
; Intervalo de polling (segundos)
poll_interval_sec=0.5vf_engine.php
Clase nativa con cURL y parseo seguro JSON/Array. 100% libre de frameworks.
Ver/Ocultar Código Fuente (vf_engine.php)
<?php
class VFEngine
{
private $baseUrl;
private $token;
private $nifEmisor;
private $timeout;
private $sslVerify;
public function __construct()
{
$config = parse_ini_file(__DIR__ . '/config.ini', true);
$this->baseUrl = rtrim($config['api']['base_url'], '/');
$this->timeout = (int) ($config['api']['timeout_sec'] ?? 30);
$this->token = $config['api']['token'] ?? '';
$this->nifEmisor = $config['demo']['nif_emisor'] ?? '';
// Default true for security if not present
$this->sslVerify = filter_var($config['api']['ssl_verify'] ?? true, FILTER_VALIDATE_BOOLEAN);
}
/**
* Realiza una petición POST al endpoint de ingesta
* @param array|string $jsonData Array o JSON String de la factura
* @return array Respuesta del servidor
*/
public function ingestaJson($jsonData)
{
// Set NIF for headers if present in body
if (is_array($jsonData)) {
$nif = $jsonData['cabecera']['emisor'] ?? $jsonData['cabecera']['nif_emisor'] ?? '';
if (!empty($nif))
$this->nifEmisor = $nif;
}
$url = $this->baseUrl . '/v1/ingesta';
return $this->sendRequest('POST', $url, $jsonData);
}
/**
* Recupera la lista de facturas pendientes de ACK
* @param string $nifEmisor NIF del emisor
* @param int $limit Número máximo de registros
* @return array Lista de pendientes
*/
public function getPendientes($nifEmisor, $limit = 50, $id_envio = null, $linea_log_detalle = null)
{
$url = $this->baseUrl . '/verifactu/pendientes';
$params = [
'nif_emisor' => $nifEmisor
];
// Si queremos buscar una correlación exacta, NO pasamos el n_ultimos para evitar el límite 50
// y saltarnos la restricción de Delphi a nivel de procedimiento almacenado
if ($id_envio !== null && $linea_log_detalle !== null) {
$params['id_envio'] = $id_envio;
$params['linea_log_detalle'] = $linea_log_detalle;
} else {
$params['n_ultimos'] = $limit;
}
// Update instance nifEmisor so sendRequest headers match the parameter
if (!empty($nifEmisor)) {
$this->nifEmisor = $nifEmisor;
}
$url .= '?' . http_build_query($params);
return $this->sendRequest('GET', $url);
}
/**
* Envía confirmación (ACK) de que la factura ha sido procesada por el ERP
* @param string $nifEmisor NIF del emisor
* @param int|string $indiceLog ID único de envío (indice_log)
* @return array Respuesta
*/
public function ackIndice($nifEmisor, $indiceLog)
{
// Update instance nifEmisor
if (!empty($nifEmisor)) {
$this->nifEmisor = $nifEmisor;
}
$url = $this->baseUrl . '/verifactu/ack';
$payload = [
'nif_emisor' => $nifEmisor,
'indice_log' => (int) $indiceLog
];
return $this->sendRequest('POST', $url, $payload);
}
/**
* Comprueba el estado de un envío (Polling ligero)
* Endpoint: /v1/check_status
*/
public function checkStatus($nif, $serie, $num)
{
if (!empty($nif)) {
$this->nifEmisor = $nif;
}
$url = $this->baseUrl . '/v1/check_status';
$params = [
'emisor' => $nif,
'serie' => $serie,
'num' => $num
];
$url .= '?' . http_build_query($params);
return $this->sendRequest('GET', $url);
}
/**
* Obtiene el estado detallado de una factura (Logs completos)
* Endpoint: /v1/facturas/estado
*/
public function getEstadoFactura($nif, $num, $serie = '')
{
if (!empty($nif)) {
$this->nifEmisor = $nif;
}
$url = $this->baseUrl . '/v1/facturas/estado';
$params = [
'emisor' => $nif,
'numfactura' => $num,
'serie' => $serie
];
$url .= '?' . http_build_query($params);
return $this->sendRequest('GET', $url);
}
/**
* Función helper privada para manejar cURL
*/
private function sendRequest($method, $url, $data = null)
{
$ch = curl_init();
// Configuración básica
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
// Configuración SSL
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $this->sslVerify);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $this->sslVerify ? 2 : 0);
// Headers
$headers = [
'Content-Type: application/json',
'Accept: application/json'
];
if (!empty($this->token)) {
$headers[] = 'X-API-Key: ' . $this->token;
}
if (!empty($this->nifEmisor)) {
$headers[] = 'X-Verifactu-Emisor: ' . $this->nifEmisor;
}
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
if ($data !== null) {
// Si ya es un string, lo mandamos tal cual, si es array, lo codificamos
$payload = is_string($data) ? $data : json_encode($data);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
}
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
return [
'ok' => false,
'status' => 'network_error',
'mensaje' => 'cURL Error: ' . $error
];
}
// Intentar decodificar JSON
$jsonResponse = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
// Si funciona pero devuelve HTML (ej: error 500 del servidor web o proxy)
return [
'ok' => false,
'status' => 'parse_error',
'mensaje' => 'Respuesta no válida del servidor (No JSON)',
'raw_response' => substr($response, 0, 500), // Limitamos logs
'http_code' => $httpCode
];
}
// Normalizar respuesta 'ok'
// El microservicio a veces devuelve status=error en JSON con HTTP 4xx
// Consideramos OK si HTTP < 400 y (no existe status o status != error)
$isOk = ($httpCode >= 200 && $httpCode < 300);
// Si el body dice status=error, forzamos ok=false aunque sea 200 (raro pero posible)
if (isset($jsonResponse['status']) && $jsonResponse['status'] === 'error') {
$isOk = false;
}
// Extraer mensaje
if (!$isOk && isset($jsonResponse['detail'])) {
if (is_string($jsonResponse['detail'])) {
$jsonResponse['mensaje'] = $jsonResponse['detail'];
} elseif (is_array($jsonResponse['detail']) && isset($jsonResponse['detail']['mensaje'])) {
$jsonResponse['mensaje'] = $jsonResponse['detail']['mensaje'];
}
}
// Si la respuesta original era un array POSICIONAL de objetos (una lista literal de FastAPI),
// e inyectamos ['ok'] dentro, PHP lo convierte en un Dictionary (Associative Array) numérico.
// Para evitarlo, si detectamos que sus keys son solo numéricas o es una lista pura, la encapsulamos.
// Array_is_list funciona en PHP 8.1+, para ser retrocompatibles hacemos una comprobacion de keys.
$isList = (array_keys($jsonResponse) === range(0, count($jsonResponse) - 1));
if ($isList) {
$jsonResponse = ['items' => $jsonResponse];
}
$jsonResponse['ok'] = $isOk;
$jsonResponse['http_code'] = $httpCode; // Útil para debugging
return $jsonResponse;
}
}index.php
Ejemplo completo de orquestación (HTML Web).
Ver/Ocultar Código Fuente (index.php)
<?php
// index.php - VeriFactu PHP Client (Node.js Port)
require_once 'vf_engine.php';
// --- BACKEND API ROUTER ---
// If 'action' param exists, we act as a JSON API, otherwise we serve the HTML UI.
if (isset($_GET['action'])) {
// Force PHP to report errors in development so we can catch silent failures
error_reporting(E_ALL);
ini_set('display_errors', 1);
// Custom error handler to output JSON instead of HTML
set_error_handler(function($errno, $errstr, $errfile, $errline) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'PHP Error', 'mensaje' => "$errstr in $errfile on line $errline"]);
exit;
});
set_exception_handler(function($e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'PHP Exception', 'mensaje' => $e->getMessage()]);
exit;
});
header('Content-Type: application/json');
// Allow CORS because host machine might run PHP on different port/domain
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
$action = $_GET['action'];
$engine = new VFEngine();
// Helper to get JSON POST body
function getJsonBody()
{
$input = file_get_contents('php://input');
return json_decode($input, true);
}
try {
switch ($action) {
case 'ingesta':
// POST Body is the Invoice JSON
$json = getJsonBody();
if (!$json)
throw new Exception("Invalid JSON Body");
// Add metadata if missing (Node.js client adds it but we double check)
if (!isset($json['metadata']))
$json['metadata'] = [];
// Force correct backend routing if needed, though client usually sets it
// $json['metadata']['enviar_aeat'] = true;
$result = $engine->ingestaJson($json);
echo json_encode($result);
break;
case 'check_status':
$nif = $_GET['emisor'] ?? '';
$serie = $_GET['serie'] ?? '';
$num = $_GET['num'] ?? '';
$result = $engine->checkStatus($nif, $serie, $num);
echo json_encode($result);
break;
case 'pendientes':
case 'get_pendientes':
$nif = $_GET['nif_emisor'] ?? '';
$limit = (int) ($_GET['n_ultimos'] ?? 10);
$id_envio = !empty($_GET['id_envio']) ? (int) $_GET['id_envio'] : null;
$linea_log_detalle = !empty($_GET['linea_log_detalle']) ? (int) $_GET['linea_log_detalle'] : null;
$result = $engine->getPendientes($nif, $limit, $id_envio, $linea_log_detalle);
echo json_encode($result);
break;
case 'ack':
$body = getJsonBody();
// The Node.js client sends { items: [{ nif_emisor, num_serie_factura }] }
// But VFEngine expects simple arguments for a SINGLE ack.
// We will iterate or just handle the first one for this demo.
if (isset($body['items']) && is_array($body['items']) && count($body['items']) > 0) {
$item = $body['items'][0];
// IMPORTANT: VFEngine::ackIndice expects 'indice_log' (ID),
// but the Node.js demo UI sends 'num_serie_factura' (String).
// This implies the Node.js backend might have had logic to lookup ID from Serie-Num
// OR the Node.js UI 'enviarAckManual' is optimistic.
// Let's look at getPendientes result. It returns 'indice_log'.
// If the user clicks ACK in the list, we have ID.
// If the user clicks "Manual ACK", they only have Serie/Num.
// For now, let's assume strict adherence to the Engine which needs ID.
// If we only have Serie/Num, we can't easily ACK without looking it up first.
// However, the Node.js code sends { nif_emisor, num_serie_factura }.
// We will try to pass that as ID (it will fail if backend expects int)
// OR we implement a quick lookup.
// As a fallback for this demo, we'll try to guess or just pass it through.
// Actually, if we look at `getPendientes` in `vf_engine.php`, it returns `indice_log`.
// The Node.js `enviarAckManual` tries to ACK by Serie-Num.
// Let's implement a "lookup and ack" logic here for robust porting.
$nif = $item['nif_emisor'];
$serieNum = $item['num_serie_factura']; // "F2024-100"
// Try to find the log ID for this invoice?
// Or just use the 'checkStatus' to find it? CheckStatus returns details.
// Let's see if checkStatus returns 'id' or 'indice'.
// Inspecting existing code... VFEngine::checkStatus returns status 0/1/etc.
// It doesn't seem to return the ID explicitly in the simplified response.
// Code decision: Pass 0 or handle error gracefully.
// The Node.js client is a "Demo", maybe the python backend accepts serie-num?
// No, `vf_engine.php` casts to `(int)$indiceLog`.
// So Manual ACK by Serie-Num won't work in PHP Engine without a lookup.
// We will leave it as is -> it might fail for manual ACKs, but work for List ACKs if we fix the JS to send ID.
// Actually, let's fix the JS side to send IDs from the list,
// and for manual... we'll mock success or fail.
echo json_encode(['ok' => false, 'mensaje' => 'PHP Engine requires ID for ACK, manual ACK by Serie not supported yet']);
} else {
echo json_encode(['ok' => false, 'mensaje' => 'Invalid Body']);
}
break;
case 'ack_by_id':
// We add this specific action for the List "ACK" button which HAS the ID.
$body = getJsonBody();
$result = $engine->ackIndice($body['nif'], $body['id']);
echo json_encode($result);
break;
default:
http_response_code(400);
echo json_encode(['error' => 'Accion desconocida']);
break;
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
exit;
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>PHP VeriFactu Client (Port)</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;
position: fixed;
top: 0;
left: 0;
height: 100vh;
z-index: 10;
}
main {
margin-left: 400px;
/* Offset the fixed aside */
flex: 1;
padding: 40px;
overflow-y: auto;
box-sizing: border-box;
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>⚡ PHP Client</h2>
<p style="font-size: 0.85rem; color: #94a3b8; margin-bottom: 20px;">
Port directo del cliente Node.js corriendo sobre el motor <code>vf_engine.php</code>.
Misma estética, misma lógica, infraestructura PHP estándar.
</p>
<div
style="background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2); padding: 12px; border-radius: 8px; margin-bottom: 20px; font-size: 0.8rem; line-height: 1.4;">
<strong>💡 Nota:</strong> El sistema ignora (para el envío a la AEAT) cualquier factura que se presente con
una estructura idéntica a otra ya validada.<br><br>
Para administrar todo el sistema, monitorizar el flujo en tiempo real (Live logs), licencias y parámetros
avanzados, visita el panel de control <a href="/admin" target="_blank"
style="color:#60a5fa; font-weight:bold;">Admin.html (Dashboard Completo)</a>.
</div>
<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);
try {
// PHP handles static file serving too, but let's assume factura_ejemplo.json is in the same dir
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);
// Fallback if file not found
document.getElementById('json-editor').value = JSON.stringify({ cabecera: {}, metadata: {} });
}
}
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;
// 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 {
// PHP Port: Point to index.php?action=ingesta explicitly
const res = await fetch('index.php?action=ingesta', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: finalJson
});
const data = await res.json();
if (data.ok) {
log("✅ Respuesta Ingesta (OK):", data);
} else {
log("❌ ERROR en Ingesta: " + (data.mensaje || data.detail || 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 {
// PHP Port: Point to index.php?action=check_status
const res = await fetch(`index.php?action=check_status&emisor=${nif}&serie=${serie}&num=${num}`);
const data = await res.json();
if (data.ok) {
log("Estado Factura:", data);
} else {
log("❌ ERROR CheckStatus: " + (data.mensaje || data.detail || 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('index.php?action=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(`index.php?action=pendientes&nif_emisor=${nif}&n_ultimos=50`);
let data = {};
try { data = await res.json(); } catch (e) { }
let items = [];
if (Array.isArray(data)) items = data;
else if (data.items) items = data.items;
else if (data.data && Array.isArray(data.data)) items = data.data;
else if (data.ok === true && typeof data === 'object') {
items = Object.keys(data)
.filter(key => !isNaN(parseInt(key, 10)) && key == parseInt(key, 10))
.map(key => data[key]);
} else if (typeof data === 'object' && Object.keys(data).length > 0 && !data.hasOwnProperty('ok')) {
items = Object.values(data);
}
if (res.ok && 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('index.php?action=ack_by_id', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nif: nif, id: 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 || JSON.stringify(data)), data);
}
} catch (e) {
log("❌ Error de comunicación:", e);
}
}
// New function for PHP port: ACK by ID
async function enviarAckById(nif, id) {
const serie = document.getElementById('serie').value || '?';
const num = document.getElementById('num').value || '?';
log(`Enviando ACK para ID ${id}...`);
try {
const res = await fetch('index.php?action=ack_by_id', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nif: nif, id: id })
});
const data = await res.json();
if (data.ok || data.status === 'ok') {
log(" > ACK OK");
// Refresh
getPendientes();
} else {
log(" > Error ACK:", data);
}
} catch (e) {
log("Msg Error:", 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(`index.php?action=pendientes&nif_emisor=${nif}&n_ultimos=50`);
const PData = await resStatus.json();
let items = [];
if (Array.isArray(PData)) items = PData;
else if (PData.items) items = PData.items;
else if (PData.data && Array.isArray(PData.data)) items = PData.data;
else if (typeof PData === 'object' && PData.ok === true) {
items = Object.keys(PData)
.filter(key => !isNaN(parseInt(key, 10)) && key == parseInt(key, 10))
.map(key => PData[key]);
} else if (typeof PData === 'object' && !PData.hasOwnProperty('ok')) {
items = Object.values(PData);
}
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 (usando action=ack_by_id en PHP)
log(`2. Enviando ACK para ID ${indiceLog}...`);
try {
const res = await fetch('index.php?action=ack_by_id', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nif: nif, id: parseInt(indiceLog) })
});
const dataAck = await res.json();
if (dataAck.ok) {
log(" > ACK enviado correctamente.", dataAck);
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)...");
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);
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 {
// PHP Port
const iRes = await fetch('index.php?action=ingesta', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: finalJson
});
let iData = {};
try { iData = await iRes.json(); } catch (e) { }
if (iData.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 || JSON.stringify(iData)), iData);
return; // Abort cycle
}
} catch (e) {
log(" ❌ Error de conexión Ingesta:", e);
return;
}
// 3. Polling (Esperar resultado desde pendientes, al estilo Delphi)
log("3. Esperando procesamiento (consultando Pendientes)...");
let finalPendiente = null;
// More aggressive polling for PHP demo
for (let i = 0; i < 15; i++) {
await new Promise(r => setTimeout(r, 1000));
try {
let url = `index.php?action=pendientes&nif_emisor=${nif}&n_ultimos=50`;
// Optimizacion: Buscamos especificamente este ID envio y linea detalle (si los tenemos) -> igual que Delphi
if (idEnvio && lineaDet) {
url = `index.php?action=pendientes&nif_emisor=${nif}&id_envio=${idEnvio}&linea_log_detalle=${lineaDet}`;
}
const pRes = await fetch(url);
const pData = await pRes.json();
let items = [];
if (Array.isArray(pData)) items = pData;
else if (pData.items) items = pData.items;
else if (pData.data && Array.isArray(pData.data)) items = pData.data;
else if (typeof pData === 'object' && pData.ok === true) {
items = Object.keys(pData)
.filter(key => !isNaN(parseInt(key, 10)) && key == parseInt(key, 10))
.map(key => pData[key]);
} else if (typeof pData === 'object' && !pData.hasOwnProperty('ok')) {
items = Object.values(pData);
}
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 a tiempo.");
return;
}
log("4. Factura Encontrada en Pendientes:", finalPendiente);
// Map variables for rendering premium
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('?action=ack_by_id', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nif: nif, id: parseInt(indiceLog) })
});
const dataAck = await ackRes.json();
if (dataAck.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');
const d = state.detalles_aeat || state;
let color = '#22c55e'; // Verde
let title = '✅ Factura Aceptada';
let isError = false;
let s = state.status;
if (s === 'pending') {
color = '#3b82f6';
title = '⏳ Procesando / Pendiente';
} else if (s === 1) {
color = '#f97316';
title = '⚠️ Aceptada con Errores';
} else if (s >= 2) {
color = '#ef4444';
title = '❌ Error (' + s + ')';
isError = true;
} else if (s === 0 || s === 'OK' || s === 200 || s === 201) {
color = '#22c55e';
title = '✅ Factura Aceptada';
} else {
color = '#ef4444';
title = '❌ Estado Desconocido (' + s + ')';
isError = true;
}
let qrHtml = '';
// Prioritize Base64
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 for URLs or old format
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>`;
}
let s_serie = d.serie || d.cab_serie || cabecera?.serie || '';
if (s_serie === 'undefined') s_serie = '';
let s_num = d.factura || d.numfactura || d.cab_factura || cabecera?.numfactura || '';
if (s_num === 'undefined') s_num = '';
let serieFactura = state.num_serie_factura || `${s_serie}-${s_num}`;
if (serieFactura.includes('undefined')) {
serieFactura = `${s_serie}-${s_num}`;
if (serieFactura === '-') serieFactura = 'Desconocida';
}
if (!state.num_serie_factura && s_num && !isNaN(s_num) && s_num !== 'undefined') {
serieFactura = `${s_serie}-${s_num.toString().padStart(5, '0')}`;
}
let total = d.cab_total_factura || state.total || cabecera?.total;
if (total === 'undefined' || total === undefined) {
if (cabecera?.base && cabecera?.totaliva) {
total = (parseFloat(cabecera.base) + parseFloat(cabecera.totaliva)).toFixed(2);
} else {
total = '0.00';
}
}
let fecha = d.cab_fecha_oficial || state.fecha_expedicion_factura || cabecera?.fecha || '';
if (fecha === 'undefined') fecha = '';
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>demo_cli.php
Ejemplo de flujo sin interfaz para tareas CRON o procesos batch.
Ver/Ocultar Código Fuente (demo_cli.php)
<?php
require_once __DIR__ . '/vf_engine.php';
// Ajustar codificación para consola Windows y caracteres especiales
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
exec('chcp 65001');
}
function clearScreen()
{
echo "\033[2J\033[H";
}
function printHeader()
{
clearScreen();
echo "==============================================\n";
echo " VERIFACTU MICRO SERVER - DEMO PHP (CLI) \n";
echo "==============================================\n\n";
}
function pause()
{
echo "\nPresione ENTER para continuar...";
fgets(STDIN);
}
// Cargar configuración para mostrar info
$config = parse_ini_file(__DIR__ . '/config.ini', true);
$engine = new VFEngine();
while (true) {
printHeader();
echo "Configuracion actual:\n";
echo " URL: " . $config['api']['base_url'] . "\n";
echo " NIF: " . $config['demo']['nif_emisor'] . "\n";
echo "----------------------------------------------\n";
echo "1. Ingesta (Enviar factura_ejemplo.json)\n";
echo "2. Consultar Pendientes (Polling)\n";
echo "3. Enviar ACK (Confirmar recepción)\n";
echo "4. Comprobar Estado (Check Status Live)\n";
echo "5. Ver Detalle Completo (Factura/Estado)\n";
echo "6. Salir\n";
echo "----------------------------------------------\n";
echo "Opcion: ";
$opcion = trim(fgets(STDIN));
switch ($opcion) {
case '1':
echo "\n--- INGESTA DE FACTURA ---\n";
$jsonFile = __DIR__ . '/factura_ejemplo.json';
if (!file_exists($jsonFile)) {
echo "Error: No se encuentra $jsonFile\n";
break;
}
$jsonContent = file_get_contents($jsonFile);
$jsonData = json_decode($jsonContent, true);
// Pequeña personalización aleatoria para evitar duplicados
$randomSuffix = rand(1000, 9999);
$jsonData['cabecera']['numfactura'] .= '-' . $randomSuffix;
echo "Enviando factura F-" . $jsonData['cabecera']['numfactura'] . "...\n";
$res = $engine->ingestaJson($jsonData);
if ($res['ok']) {
echo "SUCCESS! Factura aceptada por el backend.\n";
echo "ID Tracking (id_envio): " . $res['tracking']['cab_num_secuencia'] . "\n";
echo "Estado: " . $res['status'] . "\n";
} else {
echo "ERROR: " . $res['mensaje'] . "\n";
if (isset($res['http_code']))
echo "HTTP Code: " . $res['http_code'] . "\n";
}
pause();
break;
case '2':
echo "\n--- CONSULTA DE PENDIENTES ---\n";
$nif = $config['demo']['nif_emisor'];
$limit = $config['demo']['n_ultimos'];
echo "Consultando últimas $limit facturas para NIF $nif...\n";
$res = $engine->getPendientes($nif, $limit);
if ($res['ok']) {
$items = $res['items'];
$count = count($items);
echo "Recuperados $count registros.\n\n";
if ($count > 0) {
echo str_pad("ID ENVIO", 10) . str_pad("SERIE-NUM", 20) . str_pad("STATUS", 8) . "HUELLA / ERROR\n";
echo str_repeat("-", 80) . "\n";
foreach ($items as $item) {
$idEnvio = $item['indice_log']; // Usamos indice_log como ID único para ACK
$serieNum = $item['num_serie_factura'];
$status = $item['status'];
$info = ($status == 1) ? "OK (" . substr($item['huella'], 0, 15) . "...)" : "PEND/ERR";
if ($item['url_qr_verifactu']) {
$info = "LISTO PARA ACK";
}
echo str_pad($idEnvio, 10) . str_pad(substr($serieNum, 0, 18), 20) . str_pad($status, 8) . $info . "\n";
}
}
} else {
echo "ERROR: " . $res['mensaje'] . "\n";
}
pause();
break;
case '3':
echo "\n--- ENVIAR ACK (CONFIRMACION) ---\n";
echo "Ingrese el ID ENVIO (indice_log) a confirmar: ";
$idLog = trim(fgets(STDIN));
if (empty($idLog) || !is_numeric($idLog)) {
echo "ID inválido.\n";
pause();
break;
}
echo "Enviando ACK para ID $idLog...\n";
$res = $engine->ackIndice($config['demo']['nif_emisor'], $idLog);
if ($res['ok']) {
echo "ACK Procesado correctamente. La factura saldrá de pendientes.\n";
} else {
echo "ERROR al enviar ACK: " . ($res['mensaje'] ?? 'Desconocido') . "\n";
}
pause();
break;
case '4':
echo "\n--- CHECK STATUS (Light) ---\n";
echo "Serie (Enter para vacio): ";
$serie = trim(fgets(STDIN));
echo "Numero: ";
$num = trim(fgets(STDIN));
if (empty($num)) {
echo "Numero requerido.\n";
pause();
break;
}
echo "Consultando estado para $serie-$num ...\n";
$res = $engine->checkStatus($config['demo']['nif_emisor'], $serie, $num);
if ($res['ok']) {
echo "Estado: " . $res['status'] . "\n"; // pending, sent_ok, sent_error
echo "Mensaje: " . $res['mensaje'] . "\n";
echo "CSV: " . ($res['csv'] ?: 'N/A') . "\n";
} else {
echo "ERROR: " . ($res['mensaje'] ?? 'Desconocido') . "\n";
}
pause();
break;
case '5':
echo "\n--- ESTADO COMPLETO (Logs) ---\n";
echo "Serie (Enter para vacio): ";
$serie = trim(fgets(STDIN));
echo "Numero: ";
$num = trim(fgets(STDIN));
$res = $engine->getEstadoFactura($config['demo']['nif_emisor'], $num, $serie);
if ($res['ok']) {
$data = $res['data']; // Array con logs
print_r($data); // Dump simple para ver estructura
} else {
echo "ERROR: " . ($res['mensaje'] ?? 'No encontrada o error') . "\n";
}
pause();
break;
case '6':
exit(0);
default:
echo "Opcion no valida.\n";
sleep(1);
}
}
?>