La cadena de huellas VERI*FACTU dentro de PostgreSQL: una cadena por emisor, sellada por la propia base, inalterable y verificable, con un helper JS sin dependencias para comprobarla fuera.
Si la huella de un registro no coincide con la que calcula la AEAT, el registro queda «Aceptado con errores» (especificación de la huella, §7). Aquí la huella la calcula y la encadena la base, en el mismo sitio donde se guarda el registro, y un UPDATE o un DELETE no pueden deshacerla por error.
Estado: 0.1.0, primera versión. No se ha usado todavía en producción ni se ha probado contra el entorno de preproducción de la AEAT: revísalo antes de confiarle nada. Conforme a la especificación de la huella de la AEAT v0.1.2 (27/08/2024). Lee el aviso legal antes de usarlo.
- Qué hace y qué no hace
- Cuándo es obligatorio
- Instalación
- Uso
- Los tres ejemplos oficiales
- La especificación, regla a regla
- Errores
- Qué puede y qué no un superusuario
- La hora
- Actualizar
- Pruebas y sabotajes
- Comparativa
- Aviso legal
- Licencia y patrocinio
- English summary
| Hace | No hace |
|---|---|
| Calcula la huella SHA-256 de altas y anulaciones exactamente como la especificación v0.1.2 | Generar el XML ni el código QR. Tampoco firma: en VERI*FACTU los registros no se firman, basta la huella (RRSIF, art. 16.3); la firma XAdES es de la modalidad NO VERI*FACTU |
| Encadena cada registro con el anterior del mismo emisor (una cadena por NIF, altas y anulaciones mezcladas) | Enviar nada a la AEAT |
| Sella la hora con el reloj del servidor, en la zona del emisor, sin retroceder nunca | El registro de eventos (en VERI*FACTU no hace falta) |
| Impide bifurcar la cadena, también con varias emisiones a la vez | Validar la letra de control del NIF (la AEAT valida contra el censo) |
Impide UPDATE, DELETE y TRUNCATE sobre los registros, también al propietario y al superusuario (salvo DDL deliberado: ver superusuario) |
Importar una cadena de otro sistema |
Rechaza en NumSerieFactura lo que la AEAT rechaza: fuera del ASCII imprimible (32–126), ", ', <, > y =; y además & (ver más abajo) |
Defenderse de un administrador que usa DDL a propósito |
Rechaza una FechaExpedicionFactura posterior al día del sellado |
Imponer la fecha mínima de la AEAT (28-10-2024): los ejemplos oficiales son de 2024 y las pruebas los reproducen. Esa validación es tuya |
| Exporta la cadena en JSON con los nombres de campo de la AEAT | Anular una factura sin alta previa en esta cadena (SinRegistroPrevio = S del XSD): da VF005 |
| Verifica la cadena en SQL y en JS y dice el primer eslabón roto y por qué | Sellar con husos distintos para un mismo NIF: hay una zona por emisor. Si facturas desde Canarias y desde la Península con el mismo NIF, la hora de una de las dos no irá en el huso de su territorio (Orden HAC/1177/2024, art. 7.e) |
Sin extensiones: usa sha256() y convert_to() del núcleo, sin pgcrypto. La instalación exige PostgreSQL 14 o posterior y una base en UTF8.
Dónde se ha probado: PostgreSQL 17.11, Node 24 y Deno 2.9, en macOS; y en la CI de GitHub (.github/workflows/pruebas.yml), en verde el 29 sep 2026 sobre PostgreSQL 14, 15, 16, 17 y 18, Node 18, 20, 22 y 24 y Deno (primera pasada). En navegadores, sin probar.
Lo fija la disposición final cuarta del Real Decreto 1007/2023, en la redacción del Real Decreto-ley 15/2025 (a 29-09-2026):
- Contribuyentes del Impuesto sobre Sociedades (art. 3.1.a del Reglamento): sistemas adaptados antes del 1 de enero de 2027.
- El resto de obligados del art. 3.1: sistemas operativos antes del 1 de julio de 2027.
Estas fechas ya han cambiado más de una vez. Compruébalas en el texto consolidado del BOE antes de planificar nada.
psql -X -v ON_ERROR_STOP=1 -d mi_base -f sql/verifactu.sqlEs un script SQL plano, no una extensión: un DROP EXTENSION podría llevarse por delante la tabla con la cadena. Va en una sola transacción y aborta sin dejar nada a medias si el servidor es anterior a PostgreSQL 14, si la base no está en UTF8 o si el esquema verifactu ya existe.
Queda todo en el esquema verifactu, y nadie salvo el propietario tiene permisos: ni PUBLIC ni ningún rol que tuviera privilegios por defecto. Tú concedes lo justo:
-- mi_app y mi_auditoria son tus roles. Si todavía no existen:
-- create role mi_app login; create role mi_auditoria login;
-- la aplicación emite, y nada más
grant usage on schema verifactu to mi_app;
grant execute on function verifactu.emitir_alta(text, text, date, text, numeric, numeric, boolean),
verifactu.emitir_anulacion(text, text, date) to mi_app;
-- la auditoría exporta y verifica
grant usage on schema verifactu to mi_auditoria;
grant execute on function verifactu.exportar_cadena(text, bigint),
verifactu.verificar_cadena(text, bigint, text) to mi_auditoria;Ningún rol toca las tablas directamente: las cuatro funciones de la API son SECURITY DEFINER. Todas las funciones fijan search_path = pg_catalog, pg_temp, que es la pauta de la documentación de PostgreSQL para este tipo de funciones: con un search_path vacío, el esquema temporal de quien llama se consultaría antes que pg_catalog al resolver nombres de tipo; con pg_temp explícito y al final, no.
Como refuerzo, si en esa base ningún rol de la aplicación necesita tablas temporales, quítales el permiso (afecta a toda la base, decídelo tú):
revoke temporary on database mi_base from public;- No expongas el esquema
verifactuen la API (no lo añadas a «Exposed schemas»). - Instálalo con
psqlcontra la cadena de conexión de la base, o pegandosql/verifactu.sqlen el editor SQL. - La app llama a una función tuya en
publicque decide quién emite y con qué NIF, y llama averifactu.emitir_alta(...). Tienes un ejemplo enejemplos/02-supabase-envoltorio.sql: lee el NIF deapp_metadataen el JWT (nunca deuser_metadata, que el usuario puede cambiar), quita el permiso aPUBLICy aanony se lo da aauthenticated. - PostgREST abre una transacción READ COMMITTED por llamada: dos emisiones a la vez se encadenan en serie.
Sin probar contra un proyecto de Supabase real. Las pruebas simulan su peor caso: roles anon, authenticated y service_role con privilegios por defecto globales creados antes de instalar. También simulan auth.jwt() para el envoltorio.
Un solo fichero, js/verifactu.mjs, sin compilación ni dependencias, con sus tipos en js/verifactu.d.ts. Usa WebCrypto y, en Node 18, node:crypto. En la 0.1.0 se distribuye solo por GitHub:
npm install github:mrwolf99/verifactu-postgresO copia el fichero: no tiene nada más.
- Deno: no está en npm, así que
npm:no sirve. Copiajs/verifactu.mjse impórtalo por ruta (import { verificarCadena } from "./verifactu.mjs";). - Navegador:
crypto.subtlesolo existe en un contexto seguro (HTTPS olocalhost). Servido porhttp://desde otra dirección, el helper falla con un error que lo dice.
-- 1. El emisor, una vez. La zona decide el huso de la hora sellada.
insert into verifactu.emisor (nif, zona_horaria) values ('89890001K', 'Europe/Madrid');
-- 2. Emitir. Devuelve la fila sellada entera: seq, huella, huella_anterior, hora...
select seq, huella, fecha_hora_huso_gen_registro
from verifactu.emitir_alta('89890001K', '12345678/G33', date '2024-01-01', 'F1', 12.35, 123.45);
-- Corregir es una subsanación (un alta nueva de la misma factura); retirar es una anulación.
select * from verifactu.emitir_alta('89890001K', '12345678/G33', date '2024-01-01', 'F1', 12.60, 123.70, true);
select * from verifactu.emitir_anulacion('89890001K', '12345678/G33', date '2024-01-01');
-- 3. Exportar: una línea JSON por registro, con los nombres de la AEAT.
select verifactu.exportar_cadena('89890001K');
-- 4. Verificar: el primer eslabón roto y su motivo, u ok.
select * from verifactu.verificar_cadena('89890001K');Usa emitir_* con select ... from. Con la forma select (verifactu.emitir_alta(...)).*, PostgreSQL llama a la función una vez por columna. La segunda llamada la para la propia librería con VF004 (el alta ya está vigente), pero la sentencia falla.
El emisor. El nif no cambia nunca: un NIF nuevo es un emisor nuevo, con su propia cadena. Para dejar de emitir, update verifactu.emisor set activo = false. Zonas admitidas: Europe/Madrid (por defecto), Atlantic/Canary y Africa/Ceuta, una por emisor. Del NIF solo se valida la forma: nueve caracteres, en mayúsculas, sin espacios.
Los importes son numeric y se escriben siempre con dos decimales (12.3 sale 12.30, 7 sale 7.00). Nunca se redondean: con más de dos decimales significativos la emisión falla con VF003 (12.300 vale, 12.345 no).
El número de serie admite de 1 a 60 caracteres del ASCII imprimible (del 32 al 126), como exigen las validaciones de la AEAT (documento de validaciones y errores v1.2.2, IDFactura del alta), salvo ", ', <, >, = y &. Una Ñ, una º, una tilde, un emoji, un espacio duro o un carácter invisible dan VF003, y el mensaje dice cuál (U+00D1 en la posición 5). Así tampoco pueden entrar como facturas distintas dos números que se leen igual. Se le quitan los espacios del principio y del final, igual que en la especificación de la huella; los interiores se conservan.
La fecha de expedición no puede ser posterior al día del sellado en la zona del emisor (la AEAT rechaza el registro). Da VF003 y no se sella nada: una errata de año (2062 por 2026) no queda para siempre en la cadena.
Exportar a un fichero NDJSON:
PGCLIENTENCODING=UTF8 psql -XAt -d mi_base -c "select verifactu.exportar_cadena('89890001K')" > cadena.ndjson- Fija
PGCLIENTENCODING=UTF8. Si no,psqlconvierte la salida a la codificación del terminal, que en Windows no es UTF-8. Con el número de serie limitado a ASCII, la exportación de la 0.1.0 es ASCII entera, pero no dependas de eso. - Con
COPY ... TO STDOUTno: su formato de texto duplica las barras invertidas y el JSON deja de ser el mismo.
Un registro exportado (el caso oficial 2):
{"seq":2,"TipoRegistro":"alta","IDEmisorFactura":"89890001K","NumSerieFactura":"12345679/G34","FechaExpedicionFactura":"01-01-2024","TipoFactura":"F1","CuotaTotal":"12.35","ImporteTotal":"123.45","Subsanacion":"N","Encadenamiento":{"RegistroAnterior":{"IDEmisorFactura":"89890001K","NumSerieFactura":"12345678/G33","FechaExpedicionFactura":"01-01-2024","Huella":"3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60"}},"FechaHoraHusoGenRegistro":"2024-01-01T19:20:35+01:00","TipoHuella":"01","Huella":"F7B94CFD8924EDFF273501B01EE5153E4CE8F259766F88CF6ACB8935802A2B97"}Anclas. verificar_cadena ve una edición o un borrado en medio y un borrado al final. Lo que no puede ver desde dentro es una vuelta atrás coherente, por ejemplo una copia de seguridad vieja restaurada: esa cadena está bien por dentro. Para eso, guarda fuera de la base el último seq y su huella (la columna ultima_huella) y pásalos como ancla:
select * from verifactu.verificar_cadena('89890001K', 15, '...huella del 15...');En modalidad VERI*FACTU la AEAT guarda las huellas que recibe, y eso hace de ancla natural.
import { readFileSync } from "node:fs";
import { verificarCadena } from "verifactu-postgres";
// cadena.ndjson: la exportación de verifactu.exportar_cadena() (ver «Exportar a un fichero NDJSON»).
const registros = readFileSync("cadena.ndjson", "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
// El ancla es opcional: un seq y su huella (ultima_huella) guardados FUERA de la base.
// const r = await verificarCadena(registros, { nif: "89890001K", ancla: { seq: 14, huella: "<64 hexadecimales>" } });
const r = await verificarCadena(registros, { nif: "89890001K" });
if (r.ok) console.log(`ok: ${r.revisados} registros`);
else console.log(`roto en el ${r.seq}: ${r.motivo} (${r.detalle})`);El helper es estricto: no recorta ni redondea nada. Así no puede divergir del SQL por un recorte distinto; el trim() de JS, por ejemplo, quita también el espacio duro y el de Java no.
cadenaAlta,cadenaAnulacionyhuellaRegistrolanzanErrorFormatosi un campo no tiene la forma exacta que escribe la base (12.3, un espacio al final, la hora conZ,f1...). El error nombra el campo ene.campo.verificarCadenano lanza por los datos: devuelve{ ok: false, motivo: "formato" }en el primer registro mal formado. Solo lanzaErrorFormatosi están mal las opciones (nif,ancla,ahora).
verificarCadena da los mismos motivos, en el mismo orden, que verificar_cadena, con dos diferencias a propósito:
- No ve
cola, porque no conoce el cursor del emisor. - Cuenta
revisados(hasta la rotura) donde el SQL daregistros(el total).
Tienes un ejemplo completo en ejemplos/03-verificar-exportacion.mjs.
La última columna relaciona cada motivo con el tipo de anomalía más cercano de la lista L1E del anexo de la Orden HAC/1177/2024 (la del registro de eventos, que en VERI*FACTU no se exige). Es una correspondencia orientativa, no un código que la librería emita.
| Motivo | Qué detecta | L1E |
|---|---|---|
secuencia |
falta un registro, o la cadena no empieza en 1 | 04 |
encadenamiento |
la huella anterior de un registro no es la del registro previo | 07–09 |
emisor |
un registro con otro NIF | — |
formato |
(solo JS) un campo sin la forma exacta | — |
huella |
la huella guardada no es la que sale de los datos | 01 |
fecha_hora_retrocede |
la hora es anterior a la del registro previo | 11 |
fecha_hora_futura |
la hora va más de un minuto por delante del reloj | 13 |
regla_apertura |
anulación o subsanación sin alta vigente, o alta repetida | — |
cola |
(solo SQL) falta el final de la cadena | 05 |
ancla |
el registro anclado no existe o su huella no es la anclada | — |
La especificación (§6) trae tres casos, y aquí se reproducen por tres caminos: las funciones SQL puras, la emisión real con el reloj fijado y el helper JS en Node y en Deno. Los datos exactos están en test/vectores/aeat-huella-v0.1.2.json.
| Caso | Huella |
|---|---|
1 · primer registro, alta 12345678/G33, 19:20:30+01:00 |
3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60 |
2 · alta encadenada 12345679/G34, 19:20:35+01:00 |
F7B94CFD8924EDFF273501B01EE5153E4CE8F259766F88CF6ACB8935802A2B97 |
3 · anulación encadenada de 12345679/G34, 19:20:40+01:00 |
177547C0D57AC74748561D054A9CEC14B4C4EA23D1BEFD6F2E69E3A388F90C68 |
select verifactu.huella_registro('alta', '89890001K', '12345678/G33', date '2024-01-01', 'F1', 12.35, 123.45,
'', '2024-01-01T19:20:30+01:00');
-- 3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60Las tablas dicen qué hace el código y dónde. No dicen que un sistema que lo use cumpla la normativa: eso lo declara quien lo produce (ver el aviso legal).
La huella (especificación v0.1.2):
| Regla | Qué hace la librería |
|---|---|
| Orden de los campos del alta (8) y de la anulación (5), §3 | verifactu.cadena_alta, verifactu.cadena_anulacion; cadenaAlta, cadenaAnulacion en JS |
campo=valor unidos por &, sin codificar |
las mismas |
| Sin espacios al principio ni al final de cada valor | tg_sellar recorta NumSerieFactura; el CHECK registro_num_serie lo exige |
Campo vacío: nombre= sin valor (el primer registro lleva Huella=) |
huella_anterior = '' cuando seq = 1 (CHECK registro_primero) |
SHA-256 sobre los bytes UTF-8, hexadecimal, mayúsculas, 64 caracteres; TipoHuella 01 |
verifactu.sha256_hex; CHECK registro_huella_forma |
Importes: uno o dos decimales valen igual; aquí siempre dos, sin + |
verifactu.formato_importe; CHECK registro_campos |
FechaExpedicionFactura en dd-mm-aaaa |
verifactu.formato_fecha |
FechaHoraHusoGenRegistro en ISO 8601 con huso |
verifactu.formato_fecha_hora; CHECK registro_fecha_hora |
El encadenamiento (Orden HAC/1177/2024, art. 7):
| Regla | Qué hace la librería |
|---|---|
| Cada registro lleva NIF, serie, fecha y huella del anterior (7.a) | RegistroAnterior en exportar_cadena |
| El primer registro se identifica como tal (7.b) | seq = 1; "PrimerRegistro":"S" en la exportación |
| Una cadena por sistema y obligado, con altas y anulaciones mezcladas (7.c y 7.d) | tabla verifactu.emisor; UNIQUE (emisor_id, seq) |
| Fecha y hora exactas del momento de generación, según el territorio de expedición (7.e) | el reloj del servidor al sellar, en la zona del emisor (una por NIF: ver «No hace») |
| La hora incluye el huso aplicado (7.g) | ±hh:mm en FechaHoraHusoGenRegistro |
En modalidad VERI*FACTU, el art. 3 de la Orden deja fuera los arts. 6.b a 6.f, 7.f, 7.h, 7.i, 7.j, 8 y 9. Aun así, la librería hace algunas de esas cosas porque protegen la cadena, no porque se las exija la norma en esa modalidad: comprueba la cola antes de encadenar (VF007) y no deja que la hora retroceda (VF006). Lo que no hace:
- 7.f: que el reloj sea exacto, con un minuto de error como mucho, es obligación del usuario del sistema. Sincroniza el servidor (NTP).
- 7.h: el seguimiento de la cadena hacia delante y hacia atrás es cosa de la interfaz de tu sistema. La librería da la exportación en orden de
seq.
Sobre la inalterabilidad, el art. 8.2.a del Reglamento aprobado por el RD 1007/2023 (RRSIF) pide que un registro no se pueda alterar sin que el sistema lo detecte. En VERI*FACTU, el art. 16.2 del mismo Reglamento presume que eso se cumple por diseño. La librería añade sus propias capas: privilegios, el disparador registro_inalterable y el CHECK que recalcula la huella.
El & en NumSerieFactura. Las validaciones de la AEAT prohíben ", ', <, > y =, pero no &. Aquí se rechaza por prudencia. No es que rompa la huella: como el = está prohibido, el primer = que sigue a NumSerieFactura= es el de &FechaExpedicionFactura=, y la cadena se puede seguir leyendo sin ambigüedad. El motivo es que no se ha comprobado contra la AEAT que un & en el valor dé allí la misma huella. Si algún día se admite, las cadenas ya emitidas siguen valiendo. Al revés no pasaría: prohibirlo después dejaría cadenas con registros que ya no se aceptan.
Las funciones fallan con una excepción, nunca con un resultado a medias: la transacción de quien integra cae entera.
| SQLSTATE | Significa |
|---|---|
VF001 |
inalterable: no se modifica ni se borra |
VF002 |
emisor desconocido o desactivado |
VF003 |
formato o valor no admitido; el mensaje nombra el campo (NumSerieFactura, CuotaTotal, FechaExpedicionFactura...) |
VF004 |
la factura ya tiene un alta vigente (para corregirla, subsanación) |
VF005 |
la factura no tiene un alta vigente (para anularla o subsanarla) |
VF006 |
el reloj del servidor va más de un minuto por detrás del último registro |
VF007 |
la cola de la cadena no cuadra: alguien la tocó por fuera |
VF008 |
quien inserta intenta fijar un campo que pone la base (seq, huella, hora, NIF) |
Además, las de PostgreSQL: 40001 (reintentar la transacción), 23505, 23514 y 42501.
La base no puede defenderse de su administrador. Lo que ofrece es que alterar la cadena exija DDL deliberado, no un UPDATE por error, y que el resultado quede a la vista al verificar.
| Acción | Rol de la aplicación | Propietario | Superusuario |
|---|---|---|---|
UPDATE, DELETE, TRUNCATE (también UPDATE ... where false) |
42501 | VF001 | VF001 |
Lo mismo con session_replication_role = replica |
— | — | VF001 (el disparador es ENABLE ALWAYS) |
INSERT directo |
42501 | se sella igual; si fija huella o seq, VF008 | igual |
TRUNCATE emisor CASCADE / borrar un emisor con registros |
42501 | VF001 / 23503 | igual |
| Cambiar un campo con los disparadores apagados | — | 23514: el CHECK recalcula la huella | igual |
| Meter una rama, o una fecha de expedición futura, con los disparadores apagados | — | 23505 / 23514 | igual |
DISABLE TRIGGER, DROP CONSTRAINT, reemplazar funciones |
— | puede | puede |
| Reescribir la cadena desde un punto, o restaurar una copia vieja | — | puede: solo lo ve un ancla | puede |
| Tocar los ficheros de datos | — | — | puede |
El rol de la aplicación solo llega a las cuatro funciones que le concedas, y esas funciones no resuelven nombres de tipo en su esquema temporal (ver Instalación).
Se detecta al verificar:
- Una edición en medio:
encadenamientoohuella. - Un borrado en medio:
secuencia. - Un borrado al final:
cola. Además, tras un borrado la emisión se bloquea con VF007.
pg_dump y pg_restore dejan la cadena idéntica y no vuelven a sellar nada, porque los disparadores se crean después de cargar los datos.
FechaHoraHusoGenRegistroes el reloj del servidor en el instante de sellar:clock_timestamp(), tomado después del candado y truncado a segundos. No se usanow(), que es la hora delBEGIN: una transacción que espera el candado sellaría con una hora anterior a la del registro que la precede.- Se escribe en la zona del emisor, con
±hh:mmy sinZni fracciones. Los cambios de hora salen bien:2024-10-27T02:59:59+02:00va seguido de2024-10-27T02:00:00+01:00. - Nunca retrocede. Si el reloj va hasta un minuto por detrás del último registro, se sella con la hora de ese registro. Si va más de un minuto por detrás, VF006 y no se emite nada.
- La fecha de expedición se compara con el día del sellado en la zona del emisor: a las 00:30 del día 2 en Madrid todavía es el día 1 en Canarias.
- El servidor tiene que tener el reloj sincronizado (NTP). La AEAT avisa de horas futuras, y
verificar_cadenalas marca a partir de un minuto. - Las emisiones de un mismo emisor van en serie por diseño (el candado es la fila del emisor). Las de emisores distintos no se esperan.
La 0.1.0 es la primera versión: no hay nada que actualizar. Reinstalar no es la vía, porque la instalación aborta si el esquema ya existe, para no tocar la cadena. Cuando haya una versión nueva, traerá su propio script de actualización y el CHANGELOG dirá cómo aplicarlo.
PG_BIN="$(pg_config --bindir)" node test/run.mjs # pasada normal
PG_BIN="$(pg_config --bindir)" node test/run.mjs --sabotajes # cada sabotaje pone en rojo SU prueba
node test/js-solo.mjs # solo el helper JS, sin PostgreSQLSi pg_config no está en el PATH, pon la carpeta a mano: con Postgres.app, PG_BIN=/Applications/Postgres.app/Contents/Versions/17/bin; con Homebrew, PG_BIN=/opt/homebrew/opt/postgresql@17/bin.
Sin dependencias. node test/run.mjs crea un clúster desechable y lo borra al terminar:
initdben un directorio temporal, con un puerto libre al azar.- Solo escucha por socket Unix.
- Quita del entorno las variables
PG*heredadas: no puede apuntar a una base que ya exista.
Cada prueba recibe una base recién instalada. El resumen cuenta aparte las notas NO MIRADO: lo que una prueba no ha podido comprobar se dice, aunque no la ponga en rojo.
| Prueba | Qué comprueba |
|---|---|
| 01 | Los ejemplos oficiales por SQL, cadena carácter a carácter |
| 02 | Los ejemplos oficiales por emitir_* con el reloj fijado; la exportación, byte a byte con el fixture |
| 03 | El helper JS en Node (WebCrypto y el respaldo node:crypto) y en Deno, con el barrido de todos los puntos de código en NumSerieFactura |
| 04 | Cadena rica con dos emisores y toda la puntuación admitida; paridad SQL–JS fila a fila |
| 05–07 | Concurrencia: READ COMMITTED, REPEATABLE READ (40001), dos emisores |
| 08 | Con los disparadores apagados: una rama correcta da 23505; un campo cambiado o una fecha futura, 23514 |
| 09 | Inalterabilidad para la app, el propietario y el superusuario |
| 10 | Hora del sellado, husos, cambios de hora, tope, VF006 y la fecha de expedición según la zona |
| 11 | Detección en SQL y JS: encadenamiento, huella, cola y ancla (copia vieja) |
| 12 | Validaciones: cada entrada mala, su código y su campo; barrido de todos los puntos de código en NumSerieFactura |
| 13 | ROLLBACK, SAVEPOINT y varias filas en una sentencia |
| 14 | Permisos, SECURITY DEFINER, search_path = pg_catalog, pg_temp y el caso Supabase simulado |
| 15 | pg_dump + pg_restore |
| 16 | Instalación por psql, reinstalar, LATIN1 y los ejemplos |
| 17 | Que ni los ficheros ni el historial de git (autor, committer y mensaje de cada commit, y cada fichero de cada commit) lleven NIF, rutas, correos (salvo los noreply) ni claves |
La prueba 17 pasa además una lista negra propia si se le da con VF_LISTA_NEGRA=/ruta/a/lista.txt: un término por línea, sin distinguir mayúsculas, re: delante para una expresión regular. La lista vive fuera del repositorio y el informe nunca imprime sus términos, solo su número de línea. Sin ella, la prueba lo anota como NO MIRADO.
Cada sabotaje rompe a propósito una guarda en una copia (quita el candado, el disparador, la UNIQUE, el tope de la hora, la regla de ASCII, el search_path...) y exige que su prueba caiga con su causa. Antes de creerse un rojo se comprueba que el sabotaje se aplicó: el ancla casa una vez, el texto cambió y el ancla ya no está. Cuando un mismo defecto lo ven dos pruebas (un orden de campos cambiado lo ven los ejemplos oficiales y la paridad SQL–JS), se declara en test/sabotajes.mjs. Un rojo que no esté declarado cuenta como fallo.
Hay librerías que calculan la huella VERI*FACTU en la aplicación, en varios lenguajes. La comparativa proyecto a proyecto queda pendiente: no la publico sin haber comprobado cada fila.
verifactu-postgres es un componente, no un Sistema Informático de Facturación (SIF) ni un sistema VERI*FACTU completo. No genera el XML, no envía nada a la AEAT, no genera el código QR ni el registro de eventos.
El autor no emite declaración responsable de este componente. Quien lo integra en su programa de facturación es el productor de ese sistema y es quien la suscribe. Según la FAQ oficial de la AEAT sobre software de código abierto, la hace quien programa o integra partes de otro software, sea o no de código abierto. En esa declaración indica qué componentes usa, y responde de su funcionamiento, incluidos los sistemas de seguridad. La misma página trata el caso de un sistema con varios componentes de empresas distintas: léela si es el tuyo.
- No se ha probado contra el entorno de preproducción de la AEAT.
- Se ofrece «tal cual», sin garantía (licencia MIT). No es asesoramiento fiscal ni jurídico.
- Es un proyecto independiente, sin relación con la AEAT.
- VERI*FACTU, PostgreSQL, Supabase y GitHub son marcas de sus titulares. Aquí solo se nombran para decir con qué funciona la librería.
MIT © 2026 Samy Haggag.
Si te ahorra trabajo, puedes apoyar su mantenimiento en GitHub Sponsors. El patrocinio no compra soporte, certificación ni garantía de cumplimiento.
Para avisar de un fallo de seguridad, mira SECURITY.md.
verifactu-postgres keeps the Spanish VERI*FACTU invoice-record hash chain inside PostgreSQL:
- Official spec: SHA-256 hashes computed exactly as in AEAT specification v0.1.2, reproducing its three official examples.
- Sealing: one chain per issuer (tax ID), sealed by a trigger with the server clock, which never goes backwards.
- Validation: invoice numbers limited to printable ASCII (32–126) as the tax agency requires; no invoice date after the sealing day.
- Concurrency: no forks under concurrency, in READ COMMITTED or REPEATABLE READ.
- Append-only:
UPDATE,DELETEandTRUNCATEfail even for the owner and the superuser. The database cannot defend itself from its administrator, though: deliberate DDL (disabling triggers, dropping constraints, replacing functions, restoring an old backup) can still alter the chain. The checks make that visible when verifying, and an external anchor catches a restored old backup. - Export and verify: records export as JSON with the AEAT field names, and are verified in SQL or with a dependency-free JS helper (Node, Deno, browsers in a secure context).
It needs no extensions (PostgreSQL 14+, UTF8 database). Tested on PostgreSQL 17.11, Node 24 and Deno 2.9; the CI matrix is configured but has not run yet. It is a component, not a complete invoicing system (SIF): it does not build the XML, send anything to the tax agency, or generate the QR code. Its author issues no declaración responsable for it: whoever integrates it is the producer of their system and is responsible for that declaration.
Run the tests with PG_BIN="$(pg_config --bindir)" node test/run.mjs: they use a throwaway cluster. MIT licensed.