# Diseño — micro-SIF VERI\*FACTU · Fase 1 (registros de alta)

Todas las decisiones van numeradas (**D1**…**D31**). Cada una lleva su justificación y la
**trazabilidad a la fuente oficial local** (fichero + apartado/página). Las decisiones marcadas
**[IRREVERSIBLE]** no se pueden cambiar después de la primera remisión real a producción sin
consecuencias fiscales.

## Convenio de citas

| Abreviatura | Fichero |
|---|---|
| `HALLAZGOS` | `docs/aeat-esquemas/HALLAZGOS.md` |
| `HUELLA` | `docs/aeat-especificaciones/Veri-Factu_especificaciones_huella_hash_registros.pdf` (v0.1.2, 27/08/2024) |
| `QR` | `docs/aeat-especificaciones/DetalleEspecificacTecnCodigoQRfactura.pdf` (v0.5.0, 10/12/2025) |
| `SWEB` | `docs/aeat-especificaciones/Veri-Factu_Descripcion_SWeb.pdf` (v1.0.3) |
| `VAL` | `docs/aeat-especificaciones/Validaciones_Errores_VERIFACTU_1.2.2.pdf` (v1.2.2, 08/04/2026) |
| `FAQ` | `docs/aeat-especificaciones/FAQs-Desarrolladores.pdf` (v1.3, 04/12/2025) |
| `ERRORES` | `docs/aeat-especificaciones/errores.properties` (latin-1) |
| `RD1007` | `docs/boe/BOE-A-2023-24840.pdf` (RD 1007/2023) |
| `ORDEN` | `docs/boe/BOE-A-2024-22138.pdf` (Orden HAC/1177/2024) |
| `HOST` | `/var/www/html/CLAUDE.md` (notas operativas del servidor) |

Todo dato normativo que **no** salga de estos ficheros está marcado **[A VERIFICAR]**.

---

## 0. Panorama

```
   XTRF (192.168.123.150)                     ESTE HOST (192.168.123.250)
   ─────────────────────                      ──────────────────────────────────────────────
   factura → "ready"                          Apache :8089  ──► PHP-FPM 8.3 (pool verifactu)
   categoría "send to verifactu"                    │
            │                                       ▼
            │  ① HTTP POST JSON            ┌──────────────────────┐
            └──────────────────────────►   │  Endpoint de entrada │  valida · idempotencia
                                           └──────────┬───────────┘
                                                      │ ② genera RF bajo cerrojo de cadena
                                                      ▼
                                           ┌──────────────────────┐
                                           │ MariaDB `verifactu`  │  registro inmutable + cola
                                           └──────────┬───────────┘
                                                      │
                        systemd timer (1 min) ──► ┌───┴────────────┐
                                                  │  Worker único  │
                                                  └───┬────┬───┬───┘
                              ③ PDF+QR ◄─────────────┘    │   └──────────► ⑤ Home API XTRF
                                    │                     │                  (categoría)
                                    ▼                     ▼
                    /home/jboss/xtrf/03_Invoices/    ④ AEAT VERI*FACTU
                    Customer_invoices/YYYY/MM/          (mTLS + SOAPAction: "")
                    (CIFS //192.168.123.150/ak)
```

Orden estricto por factura: **② registro → ③ PDF → ④ AEAT → ⑤ XTRF**. La justificación del orden
está en D3.

---

## 1. Arquitectura de proceso

### D1 — El endpoint es **síncrono para validar y generar el registro**, y **asíncrono para todo lo demás**

**Decisión.** `POST /api/v1/facturas` hace, dentro de la petición HTTP: autenticación (cuando se
active), validación del payload, validación fiscal completa, generación del registro de facturación
con su huella y su eslabón de cadena, y persistencia. Devuelve **`202 Accepted`** con el id del
registro. El PDF, el envío a la AEAT y la devolución a XTRF los hace un **worker** disparado por
*systemd timer*.

**Alternativas consideradas.**

| Opción | Pros | Contras |
|---|---|---|
| **A. Todo síncrono** (registro + PDF + envío AEAT + XTRF en la petición) | Respuesta única y completa a XTRF | Imposible: el control de flujo obliga a esperar `TiempoEsperaEnvio` (60 s inicial) entre envíos (`SWEB` apdo. 6.4.4.1). El trigger de XTRF quedaría bloqueado minutos. Un timeout de la AEAT dejaría a XTRF sin saber si el registro existe |
| **B. Todo asíncrono** (el endpoint solo encola el JSON crudo) | Endpoint trivial y rapidísimo; escritor de cadena único por construcción | XTRF no se entera de un JSON inválido hasta minutos después; los errores de mapeo se descubren en una cola que nadie mira. Empeora mucho la operación |
| **C. Híbrida (elegida)** | XTRF recibe `400`/`422` inmediato si el JSON o la fiscalidad están mal, que es cuando el error es barato de corregir. El envío, que es el único paso con control de flujo, va en cola | Requiere serializar la escritura de cadena entre procesos FPM concurrentes (resuelto en D8) |

**Por qué C y no B.** El valor operativo de rechazar en caliente una factura mal mapeada es alto: la
alternativa es descubrirlo en una cola de errores. Y el argumento de "escritor único por
construcción" que favorece a B se consigue igual con un cerrojo explícito de base de datos (D8), que
hay que tener de todos modos como defensa.

**Trazabilidad.** `SWEB` apdo. 6.4.4.1 (control de flujo, `t` inicial 60 s); `FAQ` apdo. 2 (*"los RF
quedarían encolados, pendientes de remisión, con reintentos periódicos, como si se tratara de una
incidencia, sin que ello suponga ningún problema"*) — la AEAT avala explícitamente el patrón de cola
con reintentos.

---

### D2 — El registro de facturación se genera **antes** de que exista la factura legal

**Decisión.** El RF se crea en el paso ②; el PDF (que **es** la factura legal) se genera en el ③. La
factura no está *expedida* hasta que el PDF existe.

**Justificación.** El art. 9 `RD1007` exige generar el registro de alta *"de forma simultánea o
inmediatamente anterior a la expedición de cada factura"*. Con este orden el requisito se cumple por
construcción y no depende de cuánto tarde el worker: mientras no haya PDF, no hay factura expedida,
así que no puede haber "factura sin registro".

**Consecuencia que hay que aceptar.** El PDF que XTRF ya tenía generado **no es la factura legal** y
no debe entregarse al cliente. Esto es una restricción de proceso de negocio, no de software: si
alguien envía al cliente el PDF de XTRF antes de que el SIF lo sustituya, se entrega una factura sin
QR tributario. Ver D20 y `specs/integracion-xtrf/spec.md`.

**Trazabilidad.** `RD1007` art. 9; `FAQ` apdo. 5, nota (2) (*"la generación del RF se produzca de
forma «simultánea» (entiéndase inmediata o sin demora apreciable) a la expedición de la factura"*).

---

### D3 — Orden PDF → AEAT, no AEAT → PDF

**Decisión.** El PDF se genera y se deposita en el share **antes** de remitir el registro a la AEAT.

**Justificación.** El contenido del QR depende **únicamente** de NIF, número de serie, fecha e
importe total (`QR` apdo. 6) — **no** del CSV ni de ninguna respuesta de la AEAT. Por tanto el PDF es
generable de forma determinista en cuanto existe el registro. Ponerlo antes del envío minimiza la
ventana de "registro remitido sin factura expedida", que es exactamente el huérfano que la AEAT
prohíbe.

**Trazabilidad.** `QR` apdo. 6 (los 4 parámetros obligatorios, ninguno procedente de la respuesta);
`FAQ` apdo. 5, nota (1): *"NO deberá poder ocurrir que queden facturas ni registros de facturación
(RF) huérfanos, es decir, facturas emitidas sin su correspondiente RF generado (y remitido), ni RFs
generados (y remitidos) sin su correspondiente factura emitida."*

---

### D4 — Stack: sin framework, cuatro librerías

**Decisión.** Front controller propio + enrutador mínimo, PSR-4 vía Composer. Dependencias:

| Librería | Para qué | Por qué esta |
|---|---|---|
| `mpdf/mpdf` | Generación del PDF | Ver D18 |
| `endroid/qr-code` | Código QR | Decisión del usuario; cubre nivel de corrección M y margen configurable |
| `twig/twig` | Plantillas del PDF **y** de la UI | Autoescapado por defecto, herencia de plantillas para el multiidioma (D19) |
| `monolog/monolog` | Logging PSR-3 | Estándar; canales separados (D28) |

**Alternativas.** Slim 4 o Laravel. Rechazadas: el SIF tiene ~18 rutas y debe poder quedarse quieto
durante años sin tocar dependencias, porque cada cambio de versión del sistema informático tiene
consecuencias documentales (`Version` en el bloque `SistemaInformatico` y declaración responsable por
versión, `RD1007` art. 13.2). Un framework grande arrastra un ciclo de actualizaciones que aquí es un
pasivo, no un activo. No hay estilo de casa que respetar: los demás sitios del host no son
aplicaciones PHP estructuradas (`certlink` es solo un montaje CIFS).

**Contrapartida asumida.** Hay que escribir a mano enrutado, contenedor de dependencias mínimo y
capa PDO. Es ~300 líneas y no cambia.

---

### D5 — Despliegue PHP: pool FPM dedicado, nunca `a2enconf` **[IRREVERSIBLE en la práctica]**

**Decisión.** Pool propio en `/etc/php/8.3/fpm/pool.d/verifactu.conf`, socket
`/run/php/php8.3-fpm-verifactu.sock`, usuario/grupo dedicado `verifactu`. En el vhost, un
`<FilesMatch \.php$>` con ese socket **dentro del `<Directory>`**, más específico que la regla global
de PHP 8.1.

**Antes de instalar cualquier paquete `php8.X-fpm` nuevo**, crear el fichero vacío
`/var/lib/apache2/conf/disabled_by_admin/php8.X-fpm` para que el postinst de sury **no** auto-habilite
la conf global.

**Justificación.** Regla de oro del host (`HOST`, apdo. "Arquitectura Apache + PHP-FPM"): hay una
regla **global** en `/etc/apache2/conf-enabled/php8.1-fpm.conf` que enruta todos los `.php` de todos
los vhosts a PHP 8.1. Un `a2enconf php8.3-fpm` cambiaría silenciosamente **todos** los sitios del
servidor. El patrón de pool dedicado ya está probado en `certlink.abroadlink.com` (puerto 8088, PHP
8.3, socket `php8.3-fpm-certlink.sock`).

**Ajuste obligatorio.** `pm.max_children` del pool no puede quedarse en el 5 por defecto de Debian
(es el error pendiente anotado en `HOST` para el pool de certlink). Valor inicial propuesto: **10**,
con `pm = ondemand`. El SIF tiene concurrencia baja pero mPDF consume memoria: `memory_limit = 256M`
en el pool.

**Trazabilidad.** `HOST`, apdos. "Arquitectura Apache + PHP-FPM" y "Regla de oro".

---

### D6 — Planificación con **systemd timers**, no con cron **[decisión con impacto de host]**

**Decisión.** Tres unidades systemd, todas con `ExecStart=/usr/bin/php8.3` (ruta **explícita**, nunca
`/usr/bin/php`):

| Unidad | Frecuencia | Qué hace |
|---|---|---|
| `verifactu-worker.timer` | cada 1 min | Procesa la cola: PDF, envío AEAT, notificación XTRF |
| `verifactu-integridad.timer` | diaria 03:15 | Verifica la cadena de huellas completa |
| `verifactu-vigilancia.timer` | diaria 08:00 | Caducidad de certificado, último contacto AEAT, incidencias abiertas |

**Justificación.** El crontab de `root` de este host tiene **7 tareas de producción** que llaman a
`/usr/bin/php`, y `/usr/bin/php` está **pinneado manualmente** a PHP 8.1 precisamente para que no
salten de versión. Meter ahí una tarea que necesita PHP 8.3 obliga a mezclar rutas en un fichero
delicado. Un timer con `ExecStart` explícito evita el problema por completo y además da
`Type=oneshot` sin solapamiento, que es justo lo que necesita un worker de escritor único.

**Trazabilidad.** `HOST`, apdo. "`update-alternatives` de PHP — pinneado a propósito".

---

### D7 — Serialización única: la huella se calcula sobre **los mismos valores exactos** que van al XML

**Decisión.** Existe un único objeto de valor, `ValoresRegistroAlta`, que produce un **mapa ordenado
de cadenas ya canonizadas**. De ese mapa se derivan **las dos cosas**: la cadena de la huella y el
XML. No se permite ninguna ruta de código que formatee un valor por separado para uno u otro.

Esta es la decisión que evita el fallo más caro y más común del dominio: que el XML lleve `123.1` y
la huella se haya calculado sobre `123.10`, produciendo un **error 2000** ("El cálculo de la huella
suministrada es incorrecta") que la AEAT acepta pero deja el registro marcado para siempre y obliga a
subsanar.

#### D7.1 Cadena de la huella (registro de alta)

Ocho campos, **en este orden exacto**, con el formato
`nombreCampo1=valor1&nombreCampo2=valor2&…`:

```
IDEmisorFactura=…&NumSerieFactura=…&FechaExpedicionFactura=…&TipoFactura=…
&CuotaTotal=…&ImporteTotal=…&Huella=…&FechaHoraHusoGenRegistro=…
```

Donde `Huella` es la del **registro anterior**
(`RegistroAlta/Encadenamiento/RegistroAnterior/Huella`), y va **vacía** (solo `Huella=`) en el primer
registro.

Reglas de valor: se recortan espacios al inicio y al final; si un campo no aparece o aparece sin
valor, se pone el nombre y el `=` sin nada detrás. La cadena se codifica en **UTF-8** y se le aplica
**SHA-256**. La salida es **hexadecimal en mayúsculas, 64 caracteres**.

**Trazabilidad.** `HUELLA` apdo. 3 (lista de campos y orden), apdo. 3 in fine (recorte de espacios,
campo ausente), apdo. 5 (formato de salida). `HALLAZGOS` tabla §3 campos 22, 23, 26, 29, 30.

#### D7.2 Canonicalización numérica: **siempre 2 decimales**

`CuotaTotal` e `ImporteTotal` se formatean **siempre** con exactamente 2 decimales, punto como
separador, sin separador de miles, signo `-` solo si el valor es negativo.

**Justificación.** `HUELLA` apdo. 3 dice que la AEAT trata *"indistintamente los valores con una o dos
posiciones en los decimales, sin tener relevancia los ceros a la derecha"*, es decir, `123.1` y
`123.10` le valen igual. Pero eso es la tolerancia del **verificador**; el SIF necesita ser
**determinista** para poder recalcular la huella años después y obtener el mismo valor. Fijar 2
decimales elimina la ambigüedad. El patrón del XSD lo admite:
`ImporteSgn12.2Type` = `(\+|-)?\d{1,12}(\.\d{0,2})?` (`HALLAZGOS` §9).

#### D7.3 Formatos de fecha duales — la trampa

| Campo | Formato | Tipo XSD |
|---|---|---|
| `FechaExpedicionFactura`, `FechaOperacion` | **`DD-MM-YYYY`** | `sf:fecha` (string con patrón `\d{2}-\d{2}-\d{4}`) |
| `FechaHoraHusoGenRegistro` | **ISO 8601 con huso**, p. ej. `2026-09-01T10:15:30+02:00` | `xs:dateTime` |

Existe además un tipo `Timestamp` (`DD-MM-YYYY hh:mm:ss`) que **no se usa** en alta ni anulación.
Aplicar ISO a todo provoca rechazo.

**Trazabilidad.** `HALLAZGOS` §0 precisión #6 y §9.

#### D7.4 Vectores de prueba oficiales — **tests obligatorios**

Los tres ejemplos de `HUELLA` apdo. 6 son casos de test de aceptación. **Verificados
computacionalmente el 2026-09-01 durante la redacción de esta especificación: los tres reproducen
exactamente la huella publicada.**

**Caso 1 — primer registro de alta** (`HUELLA` §6.1, pág. 10):

```
IDEmisorFactura=89890001K&NumSerieFactura=12345678/G33&FechaExpedicionFactura=01-01-2024&TipoFactura=F1&CuotaTotal=12.35&ImporteTotal=123.45&Huella=&FechaHoraHusoGenRegistro=2024-01-01T19:20:30+01:00
→ 3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60
```

**Caso 2 — alta con registro anterior** (`HUELLA` §6.2, pág. 11):

```
IDEmisorFactura=89890001K&NumSerieFactura=12345679/G34&FechaExpedicionFactura=01-01-2024&TipoFactura=F1&CuotaTotal=12.35&ImporteTotal=123.45&Huella=3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60&FechaHoraHusoGenRegistro=2024-01-01T19:20:35+01:00
→ F7B94CFD8924EDFF273501B01EE5153E4CE8F259766F88CF6ACB8935802A2B97
```

**Caso 3 — anulación** (`HUELLA` §6.3, pág. 12). **Fuera del alcance funcional de la fase 1**, pero el
test se implementa igualmente porque valida que el motor de huella soporta el segundo juego de
nombres de campo (`IDEmisorFacturaAnulada`, …) y deja la fase 2 preparada:

```
IDEmisorFacturaAnulada=89890001K&NumSerieFacturaAnulada=12345679/G34&FechaExpedicionFacturaAnulada=01-01-2024&Huella=F7B94CFD8924EDFF273501B01EE5153E4CE8F259766F88CF6ACB8935802A2B97&FechaHoraHusoGenRegistro=2024-01-01T19:20:40+01:00
→ 177547C0D57AC74748561D054A9CEC14B4C4EA23D1BEFD6F2E69E3A388F90C68
```

Obsérvese en el caso 1 que `NumSerieFactura` lleva una **barra** (`12345678/G33`) y **no se
URL-codifica** dentro de la cadena de huella. La codificación solo aplica al QR (D22).

---

### D8 — Escritor único de cadena mediante cerrojo de fila en MariaDB

**Decisión.** Existe una tabla `cadena_estado` con **exactamente una fila** (`id=1`). Toda creación de
registro se hace dentro de una transacción que empieza con:

```sql
SELECT ... FROM cadena_estado WHERE id = 1 FOR UPDATE;
```

Dentro del cerrojo: se leen `ultima_huella`/`ultimo_num_serie`/`ultima_fecha_expedicion`, se calcula
la huella nueva, se inserta en `registros_facturacion` y se actualiza `cadena_estado`. `COMMIT`
libera.

**Alternativas.** (a) `GET_LOCK()` de MariaDB: es un cerrojo *asesor*, no sobrevive bien a
reconexiones y no participa de la transacción. (b) Fichero `flock`: no cubre el caso de dos procesos
en máquinas distintas y se desacopla del `COMMIT`. (c) Cola de un solo consumidor sin cerrojo
(opción B de D1): descartada en D1.

El `FOR UPDATE` es la única de las tres que garantiza que **el cerrojo y la escritura son atómicos**:
si la transacción aborta, la cadena no queda medio avanzada.

**Requisito derivado.** Motor **InnoDB** obligatorio en todas las tablas. `AUTOCOMMIT` desactivado en
el bloque de escritura de cadena. Nivel de aislamiento `READ COMMITTED` (suficiente: el `FOR UPDATE`
serializa lo único que importa).

**Trazabilidad.** `RD1007` art. 8.b: los registros *"deberán estar encadenados de manera que pueda
verificarse su rastro siguiendo su secuencia de creación desde el primero al último"*.

---

### D9 — Primer registro de la cadena y error 2007 **[IRREVERSIBLE]**

**Decisión.**

- `Encadenamiento` es un **`<choice>` de dos ramas excluyentes**, no un flag más un bloque. El primer
  registro lleva `<PrimerRegistro>S</PrimerRegistro>`; el resto lleva `<RegistroAnterior>` con
  `IDEmisorFactura` + `NumSerieFactura` + `FechaExpedicionFactura` + `Huella`. **No existe
  `PrimerRegistro=N`.**
- `PrimerRegistro=S` solo se emite si `cadena_estado.inicializada = 0`. Al insertar el primer
  registro, la bandera pasa a `1` **y nunca vuelve atrás por vía automática**. Revertirla requiere una
  acción manual explícita en la UI, registrada en auditoría, con doble confirmación.
- La identidad de la cadena ante la AEAT es la terna **(NIF obligado, `IdSistemaInformatico`,
  `NumeroInstalacion`)**. Los tres valores son **inmutables** una vez enviado el primer registro real.

**Tratamiento del error 2007.** Código admisible ("aceptado con errores"): *"No debe informarse como
primer registro, existen facturas emitidas con el obligado emisión y el sistema informático
actual."* Significa que el SIF creyó empezar una cadena que en la AEAT ya existía — es decir, **se ha
perdido el estado local** (reinstalación, restauración de un backup viejo, base de datos vaciada).

Protocolo obligatorio ante 2007, **sin ninguna acción automática**:

1. El registro queda `ACEPTADO_CON_ERRORES` con `requiere_subsanacion = 1`.
2. Se **pausa la cola entera** (`cola.pausada = 1`) y se abre una incidencia de severidad máxima.
3. Se exige al operador lanzar `ConsultaFactuSistemaFacturacion` para recuperar qué registros tiene
   ya la AEAT de esa terna.
4. La reconstrucción de la cadena y la subsanación son **manuales y guiadas**, nunca automáticas.

**Por qué no se automatiza.** Un reintento automático aquí es precisamente lo que produce el daño: un
alta de subsanación sobre una factura anulada **la reactiva** (`HALLAZGOS` §12.1, consecuencia
"OK (5)"). En fase 1 no hay anulaciones, pero la regla se implanta ahora para que la fase 2 no tenga
que reescribir el motor de reintentos.

**Trazabilidad.** `HALLAZGOS` §3.2 (choice, `PrimerRegistro` con enum de un solo valor `S`);
`HALLAZGOS` §11, código 2007 y su ⚠️ ("es una alarma de diseño directa"); `HALLAZGOS` §12.1
consecuencia OK (5); `VAL` apdo. 4.3.1 (lista de errores admisibles que deben subsanarse).

---

### D10 — Un registro por envío (configurable hasta 1000)

**Decisión.** `envio.max_registros = 1` por defecto. El esquema de datos y el generador de XML
soportan hasta 1000 (`RegistroFactura` es `1..1000`).

**Justificación.** El volumen real medido es de **20 facturas/mes** (agosto 2026). Con
`TiempoEsperaEnvio` de 60 s, la espera domina completamente; agrupar no aporta nada. En cambio,
enviar de uno en uno:

- simplifica radicalmente la correlación respuesta ↔ registro;
- evita que un `SoapFault` por estructura arrastre facturas correctas (el fault tumba **el envío
  entero**, `VAL` apdo. 4.1);
- hace que "aceptación parcial" (`EstadoEnvio=ParcialmenteCorrecto`) sea un caso que en la práctica no
  se da, aunque el código lo trate.

Si algún día se acumulase un atasco (AEAT caída un día → ~30 registros), 30 envíos × 60 s = 30 min de
recuperación. Aceptable. El parámetro está en configuración por si deja de serlo.

**Trazabilidad.** `HALLAZGOS` §2 (`RegistroFactura` 1..1000), §0 precisión #34; `SWEB` apdo. 6.4.4.1;
`VAL` apdo. 4.1; código 4114 ("se ha superado el límite máximo permitido de facturas a registrar").

---

### D11 — Control de flujo: obedecer `TiempoEsperaEnvio` siempre

**Decisión.** Tabla singleton `control_flujo` con `proximo_envio_permitido_at` y
`tiempo_espera_actual_s` (valor inicial **60**). Tras **cada** respuesta de la AEAT se lee
`TiempoEsperaEnvio` y se recalcula `proximo_envio_permitido_at = ahora + t`. El worker no envía nada
antes de esa marca.

**Detalles que importan.** `TiempoEsperaEnvio` está tipado como **string** con patrón `\d{0,4}`
(máximo 9999), no como entero, y el nombre correcto es ese (no `MinutosEsperaEnvio`). Se parsea
defensivamente: si viene vacío o no numérico, se conserva el valor anterior y se registra un aviso.

**Trazabilidad.** `SWEB` apdo. 6.4.4.1 (mecanismo completo, valor inicial 60 s, "la circunstancia que
ocurra primero"); `HALLAZGOS` §5 tabla, campo 4 (`Tipo6Type`, patrón `\d{0,4}`, string) y §0 punto
confirmado sobre el nombre.

---

### D12 — FIFO estricto y **bloqueo de cabecera** ante resultado desconocido

**Decisión.** La cola se consume en orden estricto de `cadena_orden`. Reglas de avance:

| Estado del registro en cabeza | ¿Bloquea la cola? |
|---|---|
| `CORRECTO`, `ACEPTADO_CON_ERRORES`, `RECHAZADO` | **No** — son estados terminales conocidos, se avanza |
| `ESTADO_INDETERMINADO` (timeout, `SoapFault` de `Server`, error de red) | **Sí** — la cola se para hasta resolver por consulta |
| `PENDIENTE_ENVIO`, `ENVIANDO` | Sí (es el trabajo en curso) |

**Justificación.** Un rechazo **no** rompe la cadena: el eslabón es interno al SIF
(`huella_anterior` del registro N+1 es la huella del N **se acepte o no en la AEAT**), y la AEAT solo
valida el *formato* de la huella del registro anterior, no su existencia en sus sistemas (`VAL`
apdo. 18: *"Se validará que la huella del encadenamiento del registro anterior cumpla el formato de
salida del algoritmo SHA-256"*). Por eso un `RECHAZADO` puede quedar atrás y la cola sigue.

En cambio, un resultado **desconocido** sí debe parar todo: no sabemos si la AEAT registró o no ese
alta, y seguir enviando construye estado sobre una incógnita.

**Trazabilidad.** `RD1007` art. 16.1 (remisión *"continuada, segura, correcta, íntegra, automática,
consecutiva, instantánea y fehaciente"* — de ahí el FIFO); `VAL` apdo. 18; `VAL` apdo. 4.1.

---

### D13 — **Nunca reenviar a ciegas**: consulta obligatoria antes de repetir

**Decisión.** Ante cualquier registro en `ESTADO_INDETERMINADO`, el sistema **no reenvía**. Exige
primero `ConsultaFactuSistemaFacturacion` (filtro: `PeriodoImputacion` obligatorio +
`NumSerieFactura`, y `RefExterna` como refuerzo) y decide según lo que la AEAT diga que tiene:

| Resultado de la consulta | Acción |
|---|---|
| `SinDatos` (no existe el registro) | Reenviar como **ALTA** normal (`Subsanacion` ausente/`N`, `RechazoPrevio` ausente/`N`) |
| Existe con `EstadoRegistro=Correcto` | No hacer nada. Marcar `CORRECTO` con el CSV recuperado |
| Existe con `AceptadoConErrores` | Marcar como tal; la subsanación es una decisión manual aparte |
| Existe con `Anulado` | **Parar y alertar.** No tocar. Fuera de alcance en fase 1 |

**Justificación.** El peligro está documentado: un alta de subsanación sobre una factura anulada **la
reactiva** (`HALLAZGOS` §12.1, "OK (5) es un hallazgo importante"). Y un alta normal sobre un registro
existente da **ERROR (2)** / código 3000 "Registro de facturación duplicado". La consulta solo existe
en el binding VERI\*FACTU, que es el nuestro.

**Trazabilidad.** `HALLAZGOS` §12.1 (tabla de consecuencias y nota OK (5)); `HALLAZGOS` §6 (estructura
de la consulta; `PeriodoImputacion` obligatorio); `HALLAZGOS` §0 punto confirmado
(`ConsultaFactuSistemaFacturacion` solo en binding VERI\*FACTU); `HALLAZGOS` §11 códigos 3000/3001/3002.

---

### D14 — Dos vías de error, tratadas por separado

**Decisión.** El cliente HTTP distingue **cuatro** desenlaces, no dos:

| Desenlace | Cómo se detecta | Estado resultante |
|---|---|---|
| **Respuesta normal** | HTTP 200 + XML `RespuestaRegFactuSistemaFacturacion` | Según `EstadoEnvio` / `EstadoRegistro` |
| **`SoapFault` de `Client`** | XML `Fault` con `faultcode` `soapenv:Client` | `RECHAZADO_ESTRUCTURA` — **no reintentar**: el mensaje está mal formado o lleva datos incorrectos. Requiere corrección |
| **`SoapFault` de `Server`** | XML `Fault` con `faultcode` `soapenv:Server` | `ESTADO_INDETERMINADO` — reenviable, pero **solo tras consulta** (D13) |
| **Sin respuesta útil** | Timeout, error TLS, HTTP ≠ 200, cuerpo no parseable | `ESTADO_INDETERMINADO` — ídem |

El WSDL **no declara ningún `wsdl:fault`**, así que el fault llega sin tipar y hay que parsearlo a
mano.

**Trazabilidad.** `VAL` apdo. 4.1 (*"La respuesta se devolverá un mensaje de tipo «SoapFault»"* ante
fallo de esquema o error sintáctico en cabecera); `SWEB` apdo. 5.1 (tabla de casos: `Server` →
"Reenviar mensaje"; `Client` → "El mensaje no está bien formado o contiene información incorrecta.
Compruebe el contenido del elemento «faultstring» … antes de volver a enviar"); `HALLAZGOS` §1
(ausencia de `wsdl:fault`).

---

### D15 — Máquina de estados del registro

```
                      ┌──────────────────┐
                      │ PENDIENTE_ENVIO  │◄──── creado (D1 ②)
                      └────────┬─────────┘
                               │ worker toma lease
                      ┌────────▼─────────┐
                      │    ENVIANDO      │
                      └────────┬─────────┘
        ┌──────────────┬───────┼────────────┬──────────────────┐
        │              │       │            │                  │
   EstadoRegistro  Estado…   Estado…   SoapFault:Client   timeout / red /
   = Correcto    AceptadoCon  Incorrecto                  SoapFault:Server
        │           Errores      │            │                  │
        ▼              ▼         ▼            ▼                  ▼
  ┌──────────┐  ┌─────────────┐ ┌──────────┐ ┌───────────────┐ ┌────────────────────┐
  │ CORRECTO │  │ ACEPTADO_   │ │RECHAZADO │ │  RECHAZADO_   │ │ ESTADO_            │
  │          │  │ CON_ERRORES │ │          │ │  ESTRUCTURA   │ │ INDETERMINADO      │
  └────┬─────┘  └──────┬──────┘ └────┬─────┘ └───────┬───────┘ └─────────┬──────────┘
       │               │             │               │                   │
       │               │ (si requiere_subsanacion)   │        ConsultaFactuSistema…
       │               │             │               │          (manual, D13)
       │               ▼             ▼               ▼                   │
       │        ┌──────────────────────────────────────┐                 │
       │        │  acción MANUAL del operador (D16)    │◄────────────────┘
       │        │  • ALTA DE SUBSANACIÓN               │
       │        │  • ALTA POR RECHAZO                  │
       │        │  • reenvío como ALTA (si SinDatos)   │
       │        └────────────────┬─────────────────────┘
       │                         │ crea un registro NUEVO (nuevo eslabón)
       │                         ▼
       │                 ┌───────────────┐
       └────────────────►│  SUBSANADO    │  (estado terminal del registro antiguo)
                         └───────────────┘
```

**Estados terminales:** `CORRECTO`, `RECHAZADO`, `RECHAZADO_ESTRUCTURA`, `SUBSANADO`.
`ACEPTADO_CON_ERRORES` es terminal **solo si** `requiere_subsanacion = 0`.

**Punto clave:** una subsanación **no muta** el registro anterior. Crea un **registro nuevo**, con su
propio eslabón de cadena, su propia huella y su propio `RefExterna`. El antiguo pasa a `SUBSANADO` y
se conserva íntegro. Esto es obligado por la inmutabilidad (D24) y por el hecho de que la subsanación
se hace *"remitiendo un nuevo registro de facturación (con el mismo identificador de factura)"*.

**Trazabilidad.** `HALLAZGOS` §5 (los tres enums de estado y la regla de composición de `EstadoEnvio`);
`VAL` apdo. 4.3.1 (mecanismo de subsanación); `HALLAZGOS` §12.1 (operativas).

---

### D16 — Las tres operativas de alta admisibles, y solo esas

De las 6 operativas de alta del anexo 6, la fase 1 implementa **3**. Las "sin registro previo" solo
tienen sentido migrando desde un SIF NO VERI\*FACTU, que no es el caso.

| Operativa | `Subsanacion` | `RechazoPrevio` | Cuándo | ¿Automática? |
|---|---|---|---|---|
| **ALTA** | ausente o `N` | ausente o `N` | Alta inicial. El registro no existe ni en el SIF ni en la AEAT | **Sí** (flujo normal) |
| **ALTA POR RECHAZO** | `S` | **`X`** | El alta inicial fue **rechazada**, luego la clave no existe en la AEAT | **No — manual** |
| **ALTA DE SUBSANACIÓN** | `S` | ausente o `N` | Subsanación de un registro **ya remitido y aceptado** | **No — manual** |

Reglas de coherencia que valida el SIF antes de construir el XML:

- `RechazoPrevio=X` **solo** si `Subsanacion=S`.
- `RechazoPrevio=S` **no** puede informarse si `Subsanacion` está ausente o es `N`.
- `RechazoPrevio` en el alta admite **`N`, `S`, `X`** (tres valores) — ojo, en la anulación son solo
  dos. Son **dos tipos XSD distintos con el mismo nombre de elemento**.
- El alta **no tiene** campo `SinRegistroPrevio`; ese caso se expresa con `RechazoPrevio=X`.

**Trazabilidad.** `HALLAZGOS` §12.1 (tabla de las 6 operativas y nota 📄 sobre `SinRegistroPrevio`);
`VAL` apdo. 2 "RechazoPrevio" (las dos reglas de coherencia); `HALLAZGOS` §0 discrepancia #3 (los dos
tipos homónimos); `FAQ` caso 2.b (*"'Subsanacion' = "S" y 'RechazoPrevio'="X""* para el alta por
rechazo).

---

### D17 — `RefExterna` = identidad del registro; la idempotencia va aparte

**Decisión.** Dos claves distintas, que no hay que confundir:

| Clave | Dónde vive | Valor | Para qué |
|---|---|---|---|
| **`RefExterna`** | En el XML, campo 3 de `RegistroAlta` (`TextMax60Type`) | `AL-{id_registro}` (id del registro en el SIF) | **Correlación** respuesta AEAT ↔ registro. La AEAT la devuelve tal cual en `RespuestaLinea` |
| **`clave_idempotencia`** | Solo en la BD, columna `UNIQUE` de `facturas` | Id de la factura en XTRF | **Idempotencia** del endpoint HTTP: XTRF puede repetir el trigger |

**Por qué no reutilizar una sola.** Una misma factura puede generar **varios registros** (el alta y
luego su subsanación). Si `RefExterna` fuese el id de factura, dos registros distintos compartirían
correlación y la respuesta sería ambigua. Con `RefExterna` = id de **registro**, la correlación es
1:1 y exacta.

**Trazabilidad.** `HALLAZGOS` §3 campo 3 (*"campo libre del emisor, ideal para la clave de
idempotencia del SIF"*, maxLen 60); `HALLAZGOS` §5 tabla `RespuestaLinea` campo 3 (*"la AEAT devuelve
la `RefExterna` enviada"*); `HALLAZGOS` §10 punto 10.

---

### D18 — PDF con **mPDF**, no dompdf

**Decisión.** `mpdf/mpdf`.

**Justificación.** El criterio decisivo es el multiidioma real observado en el share: hay facturas con
alemán, checo, polaco, húngaro, lituano, letón, rumano, **griego** y **turco** en la misma tabla de
líneas (verificado en `195_2026-Gebr__Brasseler_GmbH_&_Co__KG.pdf`).

| Criterio | mPDF | dompdf |
|---|---|---|
| Unicode / subsetting de fuentes | Nativo, con DejaVu incluida y selección automática de fuente por script | Requiere registrar fuentes a mano; falla con scripts no cubiertos |
| Griego, turco, diacríticos de Europa central | Cubierto | Frágil |
| Tablas multipágina con cabecera repetida | Soporte directo (`<thead>` repetido) | Limitado |
| Control fino de posición en mm (para el QR de 30–40 mm) | Sí | Menos preciso |
| Consumo de memoria | Alto — obliga a `memory_limit = 256M` (D5) | Menor |

La factura de ejemplo tiene **19 páginas**; el manejo de tablas largas con cabecera repetida no es
opcional.

**Riesgo asumido y su mitigación.** mPDF es pesado y su rendimiento con 19 páginas hay que medirlo. Es
exactamente por eso que la generación del PDF va en el worker y no en la petición HTTP (D1).

---

### D19 — Plantilla PDF traducible con **castellano obligatorio en las leyendas**

**Decisión.** Plantilla Twig única con herencia, más catálogos de traducción por idioma
(`lang/{es,en,de,fr,…}.php`, array clave→literal). El idioma lo indica el JSON de XTRF; si falta o no
hay catálogo, **fallback a `en`**, y si tampoco, a `es`.

**Regla innegociable:** las dos leyendas VERI\*FACTU van **siempre en castellano y literales**,
cualquiera que sea el idioma de la factura:

- Encima del QR: **`QR tributario:`**
- Debajo del QR: **`Factura verificable en la sede electrónica de la AEAT`** (o la forma corta
  **`VERI*FACTU`**).

Si se quiere, puede añadirse debajo una glosa traducida en cuerpo menor, pero **nunca sustituyendo** al
literal castellano.

**Justificación.** El art. 20.1.b de la Orden fija la frase de forma literal y exige que tenga *"un
tipo de letra y tamaño bien visibles, similares a los del resto de datos de la factura"*; el
documento técnico añade que ambos textos deben ser *"legibles, siempre iguales o superiores a los del
resto de datos de la factura"*. No hay habilitación para traducirlos.

**Trazabilidad.** `QR` apdo. 2 (arts. 20.1 y 21 de la Orden) y apdo. 3 (ubicación, textos, tamaños).

**Dependencia.** La fidelidad a la plantilla actual queda **abierta** hasta **DEP-2**. Lo ya
observado y que la plantilla debe reproducir: cabecera con tipo de documento y `# {num}/{año}`,
bloque de cliente con VAT/CIF y dirección, fecha, condiciones de pago, SWIFT/IBAN, tabla de líneas
con columnas *Referencia / Combinación de idiomas / Referencia AL / B.Imp. / IVA / Total*, subtotal,
descuentos y total, y paginación `n / N`.

---

### D20 — Fallo del PDF **después** de un envío correcto

**Decisión.** Estado `pdf_estado` independiente del estado AEAT. Si el PDF falla:

1. El registro **no se toca** (es inmutable y ya es válido).
2. `pdf_estado = PENDIENTE`, con reintentos exponenciales (1 min, 5, 15, 60, 360).
3. Tras 5 fallos → incidencia visible en "Salud del sistema" y acción manual **"Regenerar PDF"**.
4. **La notificación de éxito a XTRF no se envía mientras no haya PDF desplegado** (D21).

**Por qué esto no es un incumplimiento.** La regeneración es determinista: el QR depende solo de datos
ya congelados en el registro (D3). Mientras el PDF no exista, la factura no está expedida (D2), así
que no hay "registro sin factura" en sentido fiscal — hay un registro remitido cuya factura está
pendiente de materializarse. La condición que **sí** habría que incumplir para tener un problema es
entregar la factura al cliente, y eso no puede pasar porque XTRF no recibe el "OK" hasta que el PDF
está en el share.

**El caso inverso está prohibido por diseño:** nunca se genera PDF sin registro previo, porque el PDF
se construye a partir del registro.

**Trazabilidad.** `FAQ` apdo. 5, notas (1) y (3).

---

### D21 — El "éxito" hacia XTRF exige **las dos cosas**

**Decisión.** Se llama a la Home API de XTRF con la **categoría de éxito** solo cuando se cumplen a la
vez:

- estado AEAT ∈ {`CORRECTO`, `ACEPTADO_CON_ERRORES` con `requiere_subsanacion = 0`}, **y**
- `pdf_estado = DESPLEGADO` (fichero escrito y verificado en el share).

En cualquier otro caso terminal se llama con la **categoría de fallo**. Mientras no haya desenlace, no
se llama.

**Ante no-respuesta de XTRF:** reintentos exponenciales (5 intentos, hasta 24 h), luego estado
`XTRF_NOTIFICACION_FALLIDA`, incidencia en la UI y botón manual de reintento. **Nunca bloquea el flujo
AEAT**: el estado en la AEAT es el autoritativo y la categoría en XTRF es un espejo de conveniencia.

---

### D22 — QR: contenido, orden, codificación y colocación

**Decisión.**

- **URL base** (configurable, un solo parámetro cambia de entorno):
  - Pruebas: `https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?`
  - Producción: `https://www2.agenciatributaria.gob.es/wlpl/TIKE-CONT/ValidarQR?`
  - **Ojo:** son hosts **distintos** de los del servicio SOAP (`prewww1.aeat.es` /
    `www1.agenciatributaria.gob.es`). Son dos parámetros de configuración independientes.
- **Cuatro parámetros, en este orden exacto:** `nif`, `numserie`, `fecha`, `importe`.
- **Nunca** se incluye el 5.º parámetro `formato=json` en el QR de la factura (el documento lo
  prohíbe expresamente). El parámetro `idioma` es opcional y **no se usa** en fase 1.
- **Codificación:** `rawurlencode()` sobre **cada valor** (no sobre la URL entera). Debe producir
  `%26` para `&`, no `+` para espacios.
- **Valores:** `nif` = el del obligado (9 caracteres); `numserie` = el `NumSerieFactura` **idéntico**
  al del registro; `fecha` = `DD-MM-YYYY`; `importe` = `ImporteTotal` con **2 decimales y punto**
  (D7.2), idéntico al del registro.
- **Gráfico:** ISO/IEC 18004:2015, **nivel de corrección M**, **40 × 40 mm**, zona muda de **6 mm**
  (el mínimo es 2 mm; se toma el valor recomendado).
- **Colocación:** primera página, arriba, centrado; una sola vez aunque la factura tenga 19 páginas.
  `QR tributario:` encima, leyenda VERI\*FACTU debajo.

**Trazabilidad.** `QR` apdo. 5.1 (URL de sistema verificable, pruebas y producción), apdo. 6 (tabla de
los 4 parámetros obligatorios y sus formatos), apdo. 7.2 (*"este parámetro nunca podrá incorporarse en
la «URL» que va en el código «QR» de la factura"*), apdo. 4 (URL encoding, UTF-8, ASCII 32–126),
apdo. 2 (tamaño 30×30–40×40 mm, nivel M, ISO/IEC 18004:2015), apdo. 3 (zona muda 2 mm mínimo /
6 mm recomendado, posición, textos, una sola vez en la primera página).

---

### D23 — Exposición de `invoices`: **bind mount** + acceso HTTP **denegado**

**Decisión.** Tres piezas:

1. **Bind mount** de `/home/jboss/xtrf/03_Invoices/Customer_invoices` sobre
   `/var/www/html/verifactu/invoices`, declarado en `/etc/fstab` con
   `x-systemd.requires-mounts-for=/home/jboss/xtrf`.
2. En el vhost, un bloque `<Directory /var/www/html/verifactu/invoices>` con **`Require all denied`**.
3. La UI sirve los PDFs por un **controlador PHP** (`GET /registro/{id}/pdf`) que resuelve id →
   ruta y hace *streaming*.

**Bind mount y no symlink.** Un symlink funcionaría (el vhost ya tiene `Options +FollowSymlinks`),
pero: el bind mount presenta una **ruta real**, con lo que las directivas `<Directory>`, un futuro
`open_basedir` y las reglas `Require` se comportan de forma predecible; y no depende de que nadie
cambie `Options` a `SymLinksIfOwnerMatch` en un `.htaccess` (el vhost tiene `AllowOverride All`). El
precedente del host apoya montar en vez de enlazar: `certlink` monta sus shares directamente bajo el
docroot (`/var/www/html/certlink/ak/02_Projects`).

**El montaje es de lectura-escritura**, porque el SIF escribe ahí el PDF.

**Y por qué se deniega el acceso HTTP.** Con la decisión 6 del usuario (UI y endpoint públicos), servir
`/invoices` como directorio estático publicaría **todas las facturas de todos los clientes desde
2013** a cualquiera que alcance el vhost. El controlador PHP mantiene la ruta disponible para la
aplicación —que es lo que pedía el requisito— sin publicarla en Internet. Esta es la única mitigación
de RIESGO-2 que no depende de que se active la autenticación.

---

### D24 — Inmutabilidad del registro, garantizada por trigger

**Decisión.** En `registros_facturacion` se separan **columnas de contenido** (inmutables) de
**columnas de estado** (mutables). Un trigger `BEFORE UPDATE` lanza `SIGNAL` si alguna columna de
contenido cambia:

```sql
CREATE TRIGGER trg_registros_inmutable BEFORE UPDATE ON registros_facturacion
FOR EACH ROW
BEGIN
  IF NEW.huella <> OLD.huella OR NEW.cadena_string <> OLD.cadena_string
     OR NEW.xml_registro <> OLD.xml_registro OR NEW.num_serie_factura <> OLD.num_serie_factura
     OR NEW.fecha_expedicion <> OLD.fecha_expedicion OR NEW.importe_total <> OLD.importe_total
     OR NEW.cuota_total <> OLD.cuota_total OR NEW.huella_anterior <=> OLD.huella_anterior IS FALSE
     OR NEW.fecha_hora_huso_gen <> OLD.fecha_hora_huso_gen OR NEW.cadena_orden <> OLD.cadena_orden
     OR NEW.ref_externa <> OLD.ref_externa OR NEW.tipo_operacion <> OLD.tipo_operacion
  THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'registros_facturacion: contenido inmutable';
  END IF;
END
```

`DELETE` sobre la tabla se bloquea con un trigger equivalente.

**Justificación.** El art. 8 `RD1007` exige integridad, inalterabilidad y trazabilidad, y declara que
*"cualquier funcionalidad o mecanismo que permita alterar u ocultar el rastro de las operaciones
supone un incumplimiento de este requisito"*. Un trigger es una barrera que ni un bug de la
aplicación ni un `UPDATE` manual descuidado saltan.

**Trazabilidad.** `RD1007` art. 8.a y 8.b.

---

### D25 — Modelo de datos (MariaDB 10.11, InnoDB, `utf8mb4_unicode_ci`)

Esquema `verifactu`. Trece tablas.

#### 25.1 Configuración y estado global

```sql
configuracion            -- clave PK, valor, tipo, es_secreto, editable_ui, descripcion, actualizado_at
cadena_estado            -- id=1 (singleton, fila de cerrojo D8)
                         --   inicializada TINYINT, ultimo_registro_id, ultima_huella CHAR(64),
                         --   ultimo_num_serie VARCHAR(60), ultima_fecha_expedicion CHAR(10),
                         --   total_registros BIGINT
control_flujo            -- id=1 (singleton, D11)
                         --   tiempo_espera_actual_s SMALLINT DEFAULT 60,
                         --   proximo_envio_permitido_at DATETIME, ultimo_contacto_aeat_at DATETIME,
                         --   cola_pausada TINYINT, motivo_pausa VARCHAR(255)
```

#### 25.2 Facturas (lo que llega de XTRF)

```sql
facturas
  id BIGINT PK
  clave_idempotencia VARCHAR(100) NOT NULL UNIQUE    -- id de factura en XTRF (D17)
  id_factura_xtrf VARCHAR(100) NOT NULL
  num_serie_factura VARCHAR(60) NOT NULL             -- p.ej. "195/2026"
  fecha_expedicion CHAR(10) NOT NULL                 -- DD-MM-YYYY
  tipo_factura CHAR(2) NOT NULL                      -- F1 | F2
  idioma CHAR(5), moneda CHAR(3)
  cliente_nombre VARCHAR(120), cliente_pais CHAR(2)
  cliente_id_tipo CHAR(2), cliente_id_valor VARCHAR(20)   -- NULL si se identifica por NIF ES
  cliente_nif CHAR(9)
  importe_total DECIMAL(14,2), cuota_total DECIMAL(14,2)
  json_original MEDIUMTEXT NOT NULL                  -- payload crudo, tal cual llegó
  json_hash CHAR(64) NOT NULL
  pdf_estado ENUM('PENDIENTE','GENERADO','DESPLEGADO','FALLIDO') DEFAULT 'PENDIENTE'
  pdf_ruta_share VARCHAR(500), pdf_ruta_archivo VARCHAR(500), pdf_hash CHAR(64)
  pdf_intentos SMALLINT DEFAULT 0, pdf_proximo_intento_at DATETIME
  xtrf_estado ENUM('PENDIENTE','NOTIFICADO_OK','NOTIFICADO_FALLO','FALLIDA') DEFAULT 'PENDIENTE'
  xtrf_intentos SMALLINT DEFAULT 0, xtrf_proximo_intento_at DATETIME
  recibida_at DATETIME NOT NULL
  UNIQUE KEY uk_factura (num_serie_factura, fecha_expedicion)
```

#### 25.3 Registros de facturación (inmutable, encadenado)

```sql
registros_facturacion
  id BIGINT PK
  factura_id BIGINT NOT NULL FK
  cadena_orden BIGINT NOT NULL UNIQUE                -- posición en la cadena, monótona
  ref_externa VARCHAR(60) NOT NULL UNIQUE            -- "AL-{id}" (D17)
  tipo_operacion ENUM('ALTA','ALTA_POR_RECHAZO','ALTA_DE_SUBSANACION') NOT NULL
  -- ↓ contenido del RegistroAlta (INMUTABLE, protegido por trigger D24)
  id_emisor_factura CHAR(9) NOT NULL
  num_serie_factura VARCHAR(60) NOT NULL
  fecha_expedicion CHAR(10) NOT NULL
  tipo_factura CHAR(2) NOT NULL
  subsanacion CHAR(1) NULL                           -- NULL | 'S' | 'N'
  rechazo_previo CHAR(1) NULL                        -- NULL | 'N' | 'S' | 'X'
  cuota_total DECIMAL(14,2) NOT NULL
  importe_total DECIMAL(14,2) NOT NULL
  primer_registro TINYINT NOT NULL DEFAULT 0
  huella_anterior CHAR(64) NULL
  anterior_num_serie VARCHAR(60) NULL
  anterior_fecha_expedicion CHAR(10) NULL
  anterior_id_emisor CHAR(9) NULL
  fecha_hora_huso_gen VARCHAR(30) NOT NULL           -- ISO 8601 con huso, tal cual va al XML
  huella CHAR(64) NOT NULL
  cadena_string TEXT NOT NULL                        -- la cadena EXACTA que se hasheó (D7)
  xml_registro MEDIUMTEXT NOT NULL                   -- fragmento <RegistroAlta> canónico
  -- ↓ estado (MUTABLE)
  estado ENUM('PENDIENTE_ENVIO','ENVIANDO','CORRECTO','ACEPTADO_CON_ERRORES',
              'RECHAZADO','RECHAZADO_ESTRUCTURA','ESTADO_INDETERMINADO','SUBSANADO') NOT NULL
  requiere_subsanacion TINYINT DEFAULT 0
  codigo_error_aeat INT NULL, descripcion_error_aeat VARCHAR(1500) NULL
  csv VARCHAR(255) NULL
  subsanado_por_registro_id BIGINT NULL FK
  intentos SMALLINT DEFAULT 0, lease_hasta DATETIME NULL
  creado_at DATETIME NOT NULL
  KEY idx_cola (estado, cadena_orden)
```

#### 25.4 Envíos y correlación

```sql
envios
  id BIGINT PK, entorno ENUM('PRUEBAS','PRODUCCION'), endpoint VARCHAR(255)
  xml_enviado MEDIUMTEXT NOT NULL                    -- sobre SOAP completo
  http_status SMALLINT NULL, duracion_ms INT NULL
  respuesta_cruda MEDIUMTEXT NULL
  tipo_desenlace ENUM('RESPUESTA','FAULT_CLIENT','FAULT_SERVER','SIN_RESPUESTA')
  fault_code VARCHAR(100), fault_string TEXT
  estado_envio ENUM('Correcto','ParcialmenteCorrecto','Incorrecto') NULL
  csv VARCHAR(255), nif_presentador CHAR(9), timestamp_presentacion DATETIME
  tiempo_espera_envio SMALLINT NULL
  enviado_at DATETIME NOT NULL

envio_registros                                       -- N:M envío ↔ registro
  envio_id, registro_id, estado_registro ENUM('Correcto','AceptadoConErrores','Incorrecto')
  codigo_error INT, descripcion_error VARCHAR(1500)
  dup_id_peticion VARCHAR(20), dup_estado ENUM('Correcta','AceptadaConErrores','Anulada')
  dup_codigo_error INT, dup_descripcion_error VARCHAR(500)
  PRIMARY KEY (envio_id, registro_id)

consultas_aeat                                        -- log de ConsultaFactuSistemaFacturacion (D13)
  id, motivo, registro_id, xml_enviado, respuesta_cruda, resultado ENUM('ConDatos','SinDatos'),
  estado_encontrado VARCHAR(30), realizada_at, realizada_por
```

Nótese `dup_descripcion_error VARCHAR(500)` frente a `descripcion_error VARCHAR(1500)`: **las
longitudes son distintas a propósito**, el XSD declara `TextMax500Type` dentro de `RegistroDuplicado`
y `TextMax1500Type` en el nivel superior.

#### 25.5 Auditoría, incidencias, integridad, errores

```sql
auditoria        -- append-only: id, at, actor, ip, accion, entidad, entidad_id, detalle_json, motivo
incidencias      -- id, severidad, codigo, titulo, detalle, registro_id, abierta_at, cerrada_at, cerrada_por
verificaciones_integridad -- id, ejecutada_at, desde_orden, hasta_orden, registros, resultado, detalle_json
catalogo_errores_aeat     -- codigo PK, descripcion, categoria ENUM('ENVIO','REGISTRO','ADMISIBLE'),
                          -- requiere_subsanacion TINYINT, accion_sugerida VARCHAR(255)
```

`catalogo_errores_aeat` se **precarga desde `errores.properties`**, que está en **latin-1**: la carga
debe convertir a UTF-8 explícitamente (`mb_convert_encoding($linea, 'UTF-8', 'ISO-8859-1')`), o los
acentos entrarán rotos en la UI.

**Trazabilidad.** `HALLAZGOS` §5 (estructura de respuesta y las tres longitudes), §11 (247 códigos en
tres categorías; los 10 admisibles); `docs/README.md` (nota de latin-1 de `errores.properties`).

---

### D26 — Huecos reservados para la fase 2

Cosas que **no** se implementan pero cuyo sitio queda hecho, para que la fase 2 no obligue a migrar
datos ni a reescribir el motor:

| Hueco | Cómo queda reservado |
|---|---|
| Rectificativas R1–R5 | `tipo_factura` es `CHAR(2)`, no un ENUM cerrado a F1/F2. El validador rechaza R\* con un mensaje explícito de "fuera de alcance fase 1", no con un error de tipo |
| Anulación | `tipo_operacion` es un ENUM que se amplía; el motor de huella ya soporta el juego de campos de anulación (test del caso 3, D7.4) |
| Multi-emisor | `id_emisor_factura` está en cada registro, no solo en configuración |
| Estado `Anulado` de consulta | `consultas_aeat.estado_encontrado` es `VARCHAR(30)`, no un ENUM — los tres enums de estado de la AEAT son **distintos entre sí** y no se pueden unificar |

Sobre lo último: hay **tres enumeraciones casi homónimas** — `EstadoRegistro` de suministro
(`Correcto`/`AceptadoConErrores`/`Incorrecto`), `EstadoRegistroDuplicado` (`Correcta`/
`AceptadaConErrores`/`Anulada`, **en femenino**) y `EstadoRegistro` de consulta
(`Correcto`/`AceptadoConErrores`/**`Anulado`**). Un binding que reutilice un solo enum se rompe.

**Trazabilidad.** `HALLAZGOS` §0 precisión #11 y §5 (tabla de enumeraciones de estado).

---

### D27 — Certificado: dónde vive, en qué formato y cómo se vigila

**Decisión.**

- Ubicación **fuera del docroot**: `/etc/verifactu/certs/`.
- El `.p12` original se **archiva** (`0400`, `root:root`) y **no** se usa en caliente. En su lugar se
  derivan, en la instalación, `cliente.crt.pem` y `cliente.key.pem`, con permisos `0400` y propietario
  **`verifactu:verifactu`** (el usuario del pool FPM, D5).
- La contraseña de la clave vive en `/etc/verifactu/verifactu.env` (`0640`, `root:verifactu`), leído
  por el proceso, **nunca** en la BD ni en el docroot ni en el repositorio.
- cURL: `CURLOPT_SSLCERT`, `CURLOPT_SSLKEY`, `CURLOPT_SSLKEYPASSWD`, `CURLOPT_SSLCERTTYPE = 'PEM'`.

**Trampa anticipada [A VERIFICAR con el certificado real].** OpenSSL 3 (Debian 12) rechaza por defecto
los `.p12` cifrados con algoritmos heredados (RC2-40), que es como los emite parte de la PKI
española. Si la conversión falla, hay que usar el proveedor `legacy`:

```
openssl pkcs12 -legacy -in cert.p12 -clcerts -nokeys  -out cliente.crt.pem
openssl pkcs12 -legacy -in cert.p12 -nocerts -nodes   -out cliente.key.pem
```

Este es el motivo de fondo para convertir a PEM en la instalación en lugar de pasarle el `.p12` a cURL
en cada llamada: el problema se resuelve **una vez**, no en cada envío.

**Vigilancia de caducidad.** El timer diario `verifactu-vigilancia` ejecuta el equivalente a
`openssl x509 -enddate -noout`, guarda la fecha en `configuracion` y abre incidencia a **45, 30, 15,
7 y 1 días**. A 0 días la cola se pausa sola, porque seguir intentando solo genera fallos TLS.

**Trazabilidad.** `SWEB` apdo. 4.3 (*"deberán autenticarse con certificado electrónico cualificado
reconocido"*); `VAL` código 4112 (*"El titular del certificado debe ser Obligado Emisión, Colaborador
Social, Apoderado o Sucesor"*) — de ahí que el certificado deba ser de la empresa, no de un tercero.

---

### D28 — Logs

Cuatro canales Monolog en `/var/log/verifactu/`, con `logrotate` (diario, 90 días, comprimido):

| Canal | Fichero | Contenido | Nivel |
|---|---|---|---|
| `app` | `app.log` | Ciclo de vida general | INFO |
| `aeat` | `aeat.log` | Una línea por envío: endpoint, desenlace, estado, código, ms. **No** el XML completo | INFO |
| `http` | `http.log` | Peticiones al endpoint: IP, tamaño, resultado, clave de idempotencia | INFO |
| `error` | `error.log` | WARNING y superiores, con traza | WARNING |

**El XML íntegro (enviado y recibido) vive en la BD**, no en los logs: es prueba fiscal, tiene que
estar en el mismo backup transaccional que el registro y no puede rotarse a los 90 días.

**Regla de PII.** No se loguean NIF de clientes, importes ni nombres. Para eso está la UI, que sí es
consultable con trazabilidad.

---

### D29 — Conservación y backup

**Decisión.**

- **Sin purga automática. Nunca.** El volumen (20 facturas/mes ⇒ ~240 registros/año) hace que el coste
  de conservar indefinidamente sea despreciable frente al riesgo de borrar algo con valor probatorio.
- **Backup:** `mysqldump --single-transaction` diario del esquema `verifactu` a
  `/var/backups/verifactu/`, retención 90 días en local. **El backup local no es suficiente** — debe
  copiarse fuera del host (**DEP-8**).
- **Exportación:** el sistema ofrece una exportación de registros a fichero legible (CSV + XML
  originales) por rango de fechas, descargable desde la UI. **Esto no es una comodidad: es un
  requisito legal.**

**Trazabilidad.** `RD1007` art. 8.c: *"La conservación, durante el plazo previsto en la Ley 58/2003…
El sistema informático deberá contar con un procedimiento de descarga, volcado y archivo seguro de los
registros de facturación generados por él, que deberán poder ser exportados a un almacenamiento
externo en formato electrónico legible."* El plazo de la Ley 58/2003 es el de prescripción, **4 años**
[A VERIFICAR: el RD remite a la Ley 58/2003 sin fijar cifra; los 4 años son el plazo general de
prescripción tributaria, que no consta literalmente en los documentos locales].

---

### D30 — Reloj del sistema

**Decisión.** `Europe/Madrid` en PHP (`date.timezone` en el pool) y NTP obligatorio.
`FechaHoraHusoGenRegistro` se genera con desplazamiento explícito (`+02:00` en verano, `+01:00` en
invierno) — **nunca** en UTC con `Z`, y nunca sin huso.

**Verificado en el host el 2026-09-01:** `System clock synchronized: yes`, `NTP service: active`,
zona `Europe/Madrid (CEST, +0200)`. Correcto tal cual está.

**Por qué importa.** El código **2004** ("aceptado con errores") salta si
`FechaHoraHusoGenRegistro` es mayor que la hora del sistema de la AEAT más un margen. Un reloj
adelantado ensucia todos los registros. La comprobación de sincronía entra en el chequeo diario de
salud.

**Matiz útil:** el 2004 está **exceptuado de la obligación de subsanar**, igual que el 2009. Los otros
ocho códigos admisibles sí obligan a subsanar.

**Trazabilidad.** `HALLAZGOS` §11 (código 2004 y nota del apdo. 4.3.1 sobre las dos excepciones);
`VAL` apdo. 4.3.1; `VAL` apdo. 20.

---

### D31 — Autenticación: construida, desactivada

**Decisión.** Se implementa completo y se deja apagado con `auth.habilitada = false`:

- Middleware `AuthGuard` que **ya envuelve todas las rutas** desde el primer día. Con el flag a
  `false` deja pasar y **registra en auditoría** que pasó sin autenticar.
- Cada ruta **ya declara** el rol que necesitará (`lectura`, `operacion`, `administracion`).
- Tabla `usuarios` (id, login, hash Argon2id, rol, activo) creada y vacía.
- El endpoint de entrada ya lee la cabecera `X-Verifactu-Token` y la compara —cuando el flag esté
  activo— con `endpoint.token` en configuración, con `hash_equals()` (comparación en tiempo
  constante).

Activarlo después es poner el flag a `true` y dar de alta usuarios. **No hay que rehacer nada.**

**Consecuencia mientras está apagado:** RIESGO-1, RIESGO-2 y RIESGO-3 del `proposal.md` §5 están
vivos. Las mitigaciones que sí operan sin autenticación son el `Require all denied` sobre `/invoices`
(D23), la cuota diaria y la pausa de cola.

---

## 2. Construcción del XML — trampas confirmadas

Resumen operativo para quien implemente el generador. Todo verificado contra los XSD/WSDL locales.

1. **Namespaces sin `V1.0`.** El `targetNamespace` de todos los ficheros es
   `https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/…`.
   La URL de descarga lleva `tikeV1.0`; el namespace declarado dentro del fichero, **no**. Prefijos:
   `sfLR` → `…/SuministroLR.xsd` (raíz), `sf` → `…/SuministroInformacion.xsd` (registros).
2. **`elementFormDefault="qualified"`**: todos los elementos hijos van cualificados.
3. **`SOAPAction: ""`** — cabecera HTTP presente y **vacía**. Confirmado literalmente en el WSDL
   (`soapAction=""` en las tres operaciones de los dos bindings). Muchas librerías la omiten o la
   inventan; con cURL hay que ponerla a mano.
4. **`IDVersion` NO va en la cabecera.** `CabeceraType` solo tiene `ObligadoEmision`, `Representante`,
   `RemisionVoluntaria`, `RemisionRequerimiento`. `IDVersion` es el **primer elemento de cada
   `RegistroAlta`**. (La cabecera **de consulta** sí lo lleva — no confundirlas.)
5. **El orden de la `<sequence>` es obligatorio.** El esquema es **posicional**, no un mapa de campos.
   Los 31 elementos de `RegistroAlta` van en el orden de `HALLAZGOS` §3.
6. **Todos los importes son `string` con patrón**, no `decimal`. Todas las fechas son `string`, no
   `xs:date`. Serializar con punto decimal, sin separador de miles.
7. **`Desglose` es obligatorio y admite máximo 12 `DetalleDesglose`.** Es el **desglose fiscal** (por
   impuesto / calificación / tipo), **no las líneas de la factura**. La factura de 19 páginas del
   ejemplo real colapsa en **un solo** `DetalleDesglose`.
8. **`RemisionVoluntaria/Incidencia`** (`S`/`N`) marca que la remisión se vio afectada por una
   incidencia técnica (corte de luz, caída de red, fallo del SIF). Se informa **solo cuando ha
   ocurrido**. El SIF lo pone a `S` en el primer envío posterior a una interrupción registrada.
9. `ds:Signature` es `minOccurs="0"`: **no se firma** (D del alcance, art. 16.3 RD 1007/2023).

**Trazabilidad.** `HALLAZGOS` §0 discrepancias #4 y #5, precisiones #6, #7, #12; `HALLAZGOS` §1
(`soapAction` vacío, líneas 44/53/65 del WSDL); `HALLAZGOS` §2, §3, §9, §10; `ORDEN` pág. 137548
(descripción del campo `Incidencia`); verificación directa de `targetNamespace` y
`elementFormDefault` sobre los XSD locales el 2026-09-01.

---

## 3. Trazabilidad decisión → fuente

| Decisión | Fuente principal |
|---|---|
| D1, D10, D11 | `SWEB` 6.4.4.1 · `FAQ` 2 |
| D2, D3, D20 | `RD1007` art. 9 · `FAQ` 5 notas (1)(2)(3) · `QR` 6 |
| D5, D6 | `HOST` (reglas del servidor) |
| D7 | `HUELLA` 3, 5, 6.1–6.3 · `HALLAZGOS` §9 |
| D8, D24 | `RD1007` art. 8.a/8.b |
| D9 | `HALLAZGOS` §3.2, §11 (2007), §12.1 |
| D12, D14 | `VAL` 4.1, 18 · `SWEB` 5.1 · `RD1007` art. 16.1 |
| D13, D16 | `HALLAZGOS` §12.1 · `VAL` 4.3.1, apdo. 2 · `FAQ` caso 2.b |
| D17 | `HALLAZGOS` §3 (campo 3), §5, §10 |
| D19, D22 | `QR` 2, 3, 4, 5.1, 6, 7.2 |
| D25, D26 | `HALLAZGOS` §5, §11 |
| D27 | `SWEB` 4.3 · `VAL` 4112 |
| D29 | `RD1007` art. 8.c |
| D30 | `VAL` 4.3.1, 20 · `HALLAZGOS` §11 (2004) |
