Skip to content

Encadenamiento y Hash

Jacobo Cantorna Cigarrán edited this page Oct 12, 2026 · 7 revisions

Encadenamiento y hash

VeriFactu exige que cada registro de facturación esté "encadenado" con el anterior mediante su huella (hash SHA-256), formando una cadena inalterable. El SDK gestiona esto a través del segundo parámetro de AeatClient.sendInvoice(invoice, previous).

Primer registro de la cadena

Map<String, Object> response = client.sendInvoice(invoice, null);

Pasar null (o un Map vacío) indica que es el primer registro de la cadena: AeatClient.buildChaining() establece Encadenamiento.PrimerRegistroCadenaType.S y no referencia ningún registro anterior.

Registros siguientes: encadenar con la respuesta anterior

Map<String, Object> firstResponse = client.sendInvoice(first, null);
Map<String, Object> secondResponse = client.sendInvoice(second, firstResponse);

Al pasar el Map de respuesta de la factura anterior como segundo argumento, el SDK rellena Encadenamiento.RegistroAnterior con:

  • IDEmisorFactura = el NIF del emisor actual (issuerVat)
  • NumSerieFactura = previous.get("number")
  • FechaExpedicionFactura = previous.get("date")
  • Huella = previous.get("hash")

Alternativa: persistir el hash tú mismo

Si tu proceso de facturación es asíncrono, tiene reinicios, o simplemente prefieres no mantener el Map de respuesta en memoria entre llamadas, puedes guardar el hash en tu base de datos y fijarlo directamente en la siguiente factura:

invoice.setPreviousHash(hashGuardadoEnBD);
client.sendInvoice(invoice, null); // previous=null, pero el SDK usa invoice.getPreviousHash() como respaldo

AeatClient.sendInvoice() solo lee invoice.getPreviousHash() cuando previous es null o no contiene la clave "hash" — si pasas ambos, previous tiene prioridad.

Cómo se calcula el hash

El hash lo genera HashHelper.generateInvoiceHash(...) internamente, concatenando (con & como separador) los campos IDEmisorFactura, NumSerieFactura, FechaExpedicionFactura, TipoFactura, CuotaTotal, ImporteTotal, Huella (del registro anterior) y FechaHoraHusoGenRegistro, y aplicando SHA-256 en mayúsculas al resultado.

Si necesitas calcular o verificar un hash de forma independiente (fuera de un envío real), HashHelper expone dos variantes públicas equivalentes:

// Variante con Map (valida que no falten ni sobren campos)
Map<String, String> data = new HashMap<>();
data.put("issuer_tax_id", "B12345678");
data.put("invoice_number", "F2026-001");
data.put("issue_date", "16-09-2026");
data.put("invoice_type", "F1");
data.put("total_tax", "21.00");
data.put("total_amount", "121.00");
data.put("previous_hash", ""); // "" para el primer registro
data.put("generated_at", "16-09-2026T12:00:00+02:00");
Map<String, String> result = HashHelper.generateInvoiceHash(data);
String hash = result.get("hash");

// Variante posicional (la que usa internamente AeatClient)
String hash2 = HashHelper.generateInvoiceHash(
    "B12345678", "F2026-001", "16-09-2026", "F1",
    "21.00", "121.00", "", "16-09-2026T12:00:00+02:00");

Ambas producen el mismo resultado; usa la que mejor se adapte a tu contexto (la de Map valida explícitamente que no falte ningún campo requerido).

Ejemplos completos: ChainingExample.java y HashHelperExample.java.

El hash y el encadenamiento son iguales en modo VERI*FACTU y en NO VERI*FACTU

El RD 1007/2023 exige que, aunque el sistema no remita el registro en tiempo real a la AEAT ("Requerimiento" / NO VERI*FACTU), la huella se siga calculando y encadenando exactamente igual que en modo VERI*FACTU — es la misma huella la que se usa en ambos casos. AeatClient.sendInvoice()/sendAnnulment() respetan esto: calculan y encadenan el hash de forma idéntica sea verifactuMode true o false; lo único que cambia es si el registro se transmite por SOAP a la AEAT o se firma con XAdES y se conserva en local (ver Firma digital XAdES y la respuesta devuelta, campo submittedToAeat).

hashInput: la cadena exacta usada para calcular el hash

Desde la versión 1.3.0, tanto la respuesta de sendInvoice/sendAnnulment como las variantes de HashHelper que reciben un Map (generateInvoiceHash(Map), generateAnnulmentHash(Map)) devuelven, además del hash, la clave inputString (o hashInput en la respuesta de AeatClient): la cadena de texto exacta, ya concatenada con los separadores &, sobre la que se calculó el SHA-256. Persistir esta cadena junto al hash es lo que permite una re-verificación posterior fiable e independiente del registro:

Map<String, Object> response = client.sendInvoice(invoice, previousResponse);
String hash = (String) response.get("hash");
String hashInput = (String) response.get("hashInput");
// Persiste ambos (hash + hashInput) junto al registro en tu base de datos.

Verificar la huella de un registro: HashHelper.verify(hash, inputString)

En modo NO VERI*FACTU, la normativa exige además que el sistema ofrezca la posibilidad de comprobar las huellas de los registros generados. Para eso, HashHelper expone:

boolean esValida = HashHelper.verify(hashAlmacenado, hashInputAlmacenado);

Devuelve true solo si hashAlmacenado es exactamente el SHA-256 de hashInputAlmacenado (comparación insensible a mayúsculas/minúsculas), es decir, si el registro no ha sido alterado desde que se generó. Si cualquiera de los dos argumentos es null, devuelve false.

Comprobación obligatoria del encadenamiento al crear cada nuevo registro

Además de poder verificar huellas sueltas, el RD 1007/2023 exige que, al crear cada nuevo registro, el sistema compruebe obligatoriamente que el encadenamiento con el anterior es correcto: que la huella del registro anterior almacenada localmente coincide con la que realmente le corresponde (es decir, que ese registro anterior tampoco fue alterado). En la práctica, esto significa que antes de llamar a sendInvoice/sendAnnulment para el siguiente registro de la cadena, tu aplicación debería:

  1. Recuperar el último registro persistido de la cadena (hash + hashInput + los campos que lo identifican).
  2. Verificarlo con HashHelper.verify(hash, hashInput).
  3. Solo si es válido, usar ese hash como previous_hash/previous del nuevo registro.

Si la verificación falla, la cadena está corrompida y no se debe generar un nuevo registro hasta resolver la incidencia (es responsabilidad de la aplicación que integra el SDK bloquear la generación y alertar; el SDK no impone esta política por sí solo, solo ofrece la primitiva verify).

Encadenar solo con registros aceptados

Un registro rechazado por la AEAT (aeat_status = Incorrecto) o que dio error no existe en su sistema: en las pruebas contra la preproducción, el siguiente registro encadenado con el último aceptado fue aceptado. La normativa habla del "último registro de facturación generado" (Orden HAC/1177/2024, art. 7); si tu caso es distinto, contrástalo con la AEAT. Pasa como previous el último registro aceptado, no el último enviado. Ver Respuesta de la AEAT.

previous debe estar completo

previous es la respuesta de un registro aceptado y necesita hash, number y date: el XML del encadenamiento (RegistroAnterior) lleva número, fecha y huella. Desde la 1.4.2 el cliente devuelve status=error (validation_errors) si falta alguno, y también si informas setPreviousHash sin pasar previous (antes se calculaba la huella con ese valor y el XML decía PrimerRegistro = S, que la AEAT acepta con errores). Para el primer registro de la cadena, pasa null y no informes previousHash.

Reenviar sin cambiar el registro: fecha de generación fija

La huella incluye FechaHoraHusoGenRegistro. Si un envío falla por comunicación y el reintento calcula la fecha otra vez, el registro y su huella cambian. Para reenviar exactamente el mismo registro, fija la fecha una vez, guárdala con el registro y úsala en cada reintento:

invoice.setGenerationTimestamp(generatedAt);   // ZonedDateTime guardado antes del primer envío
annulment.setGenerationTimestamp(generatedAt); // igual en las anulaciones
  • Sin valor, el cliente usa el instante actual (comportamiento anterior).
  • Se expresa en Europe/Madrid y se descartan las fracciones de segundo; el valor del XML y el de la huella son siempre el mismo.
  • La AEAT exige que la fecha de generación sea la fecha actual de su sistema, con un margen de 240 segundos (código 2004). Fuera del margen, en cualquiera de los dos sentidos, acepta el registro con errores (ParcialmenteCorrecto). Comprobado en preproducción: un registro generado 20 minutos antes de enviarse volvió ParcialmenteCorrecto.
  • El validador avisa si es posterior a la fecha del sistema en más de 240 s. Al remitir, la respuesta trae generation_timestamp_warning si la fecha se aleja más de 240 s de la hora actual.
  • Por eso la fecha fija solo sirve para reenviar un registro cuya respuesta no recibiste. En cualquier otro reintento (un fallo local, un rechazo, un reenvío programado horas después), genera la fecha de nuevo con la hora actual.
  • El previous y el resto de datos también deben ser los mismos del primer intento.

Ejemplo: IdempotentResendExample.java.

Reenviar un registro rechazado

Un registro rechazado no existe en la AEAT y su huella no forma parte de la cadena. Si lo corriges y lo reenvías sin emitir una rectificativa, es un alta por rechazo (Subsanacion = S y RechazoPrevio = X): invoice.setSubsanation(true); invoice.setPreviousRejection("X");. El nuevo registro se encadena con el último registro aceptado. Ver la tabla completa de operaciones en el README.

Vectores oficiales de la huella

El SDK se verifica con los tres ejemplos del documento oficial de la AEAT ("Detalle de las especificaciones técnicas para generación de la huella", v0.1.2). Los valores se recortan de espacios al inicio y al final, y FechaHoraHusoGenRegistro va en ISO 8601 (2024-01-01T19:20:30+01:00).

Clone this wiki locally