Verfifactu remediation (subsanaciones)

Alex
2026-04-23T11:01:54Z

subsanaciones_verifactu.md

VERIFACTU — Reporte de Remediación: Contexto, Especificación, Estado y Decisiones

1. Contexto

VERIFACTU es el sistema español de facturación verificable obligatorio para software de facturación (SIF). Cada factura emitida por un tenant genera un registro de facturación (RF) que se envía a la AEAT vía SOAP. La AEAT puede responder con tres estados por registro:

Correcto → aceptado limpiamente. Fin.

AceptadoConErrores → guardado en AEAT, pero con errores admisibles que deben ser corregidos mediante una Subsanación.

Incorrecto (Rechazado) → no guardado en AEAT. Debe reenviarse corregido.

La corrección de un registro erróneo puede tomar dos formas legalmente distintas:

Vía | Cuándo | XML resultante
Subsanación | Error en campos "internos" del RF (hash, clasificación, XML) — la factura entregada al cliente es correcta | Nuevo RegistroAlta con <Subsanacion>S</Subsanacion>
Factura Rectificativa | Error en datos materiales de la factura (importes, NIF destinatario, tipo IVA) | Factura R1–R5 + nuevo RF de alta

La confusión en esta conversación nació de la pregunta: ¿qué hace exactamente el botón "Generar registro correctivo" en el dashboard de incidencias?

2. Lo que dice la especificación AEAT

Errores Admisibles (producen AceptadoConErrores)

El registro existe en AEAT. Se subsana con <Subsanacion>S</Subsanacion> + <RechazoPrevio>N</RechazoPrevio>.

Código | Error | Causa raíz
2000 | Hash del RF incorrecto | Bug en cálculo del SHA-256 o concatenación errónea
2001 | NIF del destinatario no censado en AEAT | NIF válido sintácticamente pero inactivo/extranjero → requiere <IDType>07</IDType>
2002 | Longitud del hash anterior no conforme | Error en encadenamiento
2003 | Contenido del hash anterior incorrecto | Error en encadenamiento
2004 | FechaHoraHusoGenRegistro fuera del margen admitido | Timestamp no actualizado al reenviar
2005 | ImporteTotal no cuadra con el desglose | Redondeo excediendo ±10€ o bug de cálculo
2006 | CuotaTotal no cuadra con el desglose | Idem
2007 | PrimerRegistro=S pero ya existen facturas previas | Error de estado en la cadena
2008 | Hash anterior == hash actual | Rotura de cadena

Errores No Admisibles (producen Incorrecto)

El registro no existe en AEAT. Se subsana con <Subsanacion>S</Subsanacion> + <RechazoPrevio>X</RechazoPrevio> (nunca llegó) o <RechazoPrevio>S</RechazoPrevio> (llegó y fue rechazado).

Serie 41XX — Errores estructurales XSD (rechazo de todo el lote):

4102: XML no cumple el esquema (campo obligatorio ausente)

4106: Formato de fecha incorrecto

4109: Formato de NIF incorrecto

4119: Caracteres no UTF-8

Serie 11XX/12XX — Errores de negocio por registro:

1195/1196: OperacionExenta y CalificacionOperacion informados a la vez (mutuamente excluyentes)

1198: CalificacionOperacion=S2 con TipoImpositivo o CuotaRepercutida distinto de 0

1238: Operación exenta con campos de cuotas/tipos informados

1112: FechaExpedicionFactura superior a la fecha actual

1162–1164: TipoRecargoEquivalencia no compatible con el TipoImpositivo (combinaciones fijas)

3. Estado actual de la implementación

3.1 Flujo general

Factura aprobada
  → VerifactuRecordService::createRegistroAlta()
      → buildRegistrationRecord(document)   ← lee datos del documento
      → validateLibraryRecord()             ← calcula hash temporal para validar
      → persistRegistrationRecord()         ← guarda sin hash definitivo, status=PENDING_SUBMISSION

Worker (VerifactuSubmissionService::createSubmissionBatch)
      → buildRecordFromDocumentRecord()     ← reconstruye el objeto de librería
      → record->calculateHash()            ← calcula hash definitivo
      → guarda hash_value, hash_chain_previous_hash
      → status = SUBMITTED
      → sendSubmission() → AEAT SOAP
      → processResponse() → status = ACCEPTED / ACCEPTED_WITH_ERRORS / REJECTED

3.2 Dashboard de incidencias (VerifactuController::actionRemediation)

Muestra todos los registros con status IN (rejected, accepted_with_errors). Para cada uno, VerifactuIncidentClassifierService::classify() determina el tipo de incidente.

3.3 Clasificador de incidentes (MVP actual)

La clasificación se basa en matching de keywords sobre el texto de los errores devueltos por la AEAT:

Tipo clasificado | Cómo se detecta | Acción disponible
TECHNICAL_PLATFORM | Keywords: hash, huella, cadena, xml, encadenamiento... | Botón "Generar registro correctivo"
OPERATIONAL_RECOVERY | Sin códigos ni descripciones de error | Botón "Generar registro correctivo"
EXTERNAL_CONTRAST | Keywords: NIF no identificado, emisor no registrado... | Botón "Generar registro correctivo"
USER_CONFIGURATION_NON_MATERIAL | AceptadoConErrores sin señal clara | Botón "Generar registro correctivo"
MATERIAL_INVOICE_ERROR | Keywords: importe, cuota, tipo impositivo, destinatario... | Link "Crear rectificación"
UNKNOWN_NEEDS_REVIEW | Ninguna coincidencia | Badge "Contactar soporte"

3.4 Qué hace createRemediation exactamente

// VerifactuRecordService::createRemediation($document, $sourceRecord, $priorRejectionType)
$record = $this->buildRegistrationRecord($document, isRemediation=true, isPriorRejection=true);

Lee todo desde el documento, no desde el registro fallido. El $sourceRecord solo se usa para el FK de trazabilidad (source_remediation_record_id).

Lo que sí se regenera automáticamente:

hashedAt → new DateTimeImmutable() → timestamp fresco (corrige error 2004)

Hash definitivo → recalculado por el worker al construir el batch (corrige 2000, 2002, 2003, 2008)

Cadena → previousHash resuelto desde VerifactuStatus.last_hash (estado sano de la cadena)

Lo que NO cambia:

Datos del documento: issuer_tax_id, document_number, issue_date, importes, items, NIF destinatario

Tipo de factura (tipo_factura)

Clasificación de operación (ClaveRegimen, CalificacionOperacion, etc.)

Esto significa: si el error es de datos del documento (serie 11XX, 20XX no técnicos, o datos materiales), el botón genera exactamente el mismo XML erróneo y obtendrá el mismo rechazo.

3.5 Problema de clasificación: keyword matching

El clasificador actual no usa los códigos de error numéricos de la AEAT. Usa keyword matching sobre las descripciones en texto libre. Esto tiene dos riesgos:

1. Falsos positivos: clasificar como TECHNICAL_PLATFORM un error 2001 (NIF no censado) que contiene la palabra "nif" y también keywords de la lista EXTERNAL_CONTRAST.

2. Falsos negativos: errores con descripciones que no caen en ninguna keyword → UNKNOWN_NEEDS_REVIEW cuando en realidad son auto-corregibles.

El clasificador tiene un NOTE explícito en su docblock reconociendo esto:

"This is an MVP classification. As the full AEAT error code catalog is confirmed, this service should be extended with explicit code mappings."

4. Tabla de Decisiones

# | Criticidad | Decisión a tomar | Qué afecta | Motivo
D1 | 🔴 Alta | Reemplazar keyword matching por clasificación basada en códigos de error numéricos AEAT (serie 20XX, 11XX, 41XX) como fuente primaria; keywords solo como fallback | VerifactuIncidentClassifierService | El clasificador actual puede mostrar el botón "Generar correctivo" para errores que el reenvío no puede corregir (ej. error 2001 NIF no censado, errores 11XX de negocio). Un mal routeo confunde al usuario y genera registros inútiles en la cadena.
D2 | 🔴 Alta | Para el error 2001 (NIF destinatario no censado): el botón no debe ser "Generar correctivo" genérico. Debe mostrar un mini-formulario o instrucción específica para cambiar IDType → 07 antes de reenviar, o en su lugar mostrar "Contactar soporte" si no hay UI de corrección | VerifactuIncidentClassifierService, VerifactuController::actionRemediate, remediation.php | El error 2001 es Admisible (AceptadoConErrores), no un rechazo completo. El registro existe en AEAT. Pero no es auto-corregible: el dato del NIF en el documento es válido sintácticamente, y createRemediation lo reenviaría igual. Sin corrección del dato, genera un loop infinito.
D3 | 🔴 Alta | Para errores de la serie 11XX (negocio: CalificacionOperacion, OperacionExenta, TipoRecargoEquivalencia, etc.): bloquear el botón "Generar correctivo" y mostrar "Contactar soporte". Estos errores son bugs del mapper que deben corregirse en código antes de poder reenviar | VerifactuIncidentClassifierService, VerifactuController | createRemediation lee los mismos campos del documento → el XML es idéntico → mismo error. No hay acción de usuario que lo resuelva sin un deploy de código. Habilitar el botón es engañoso y añade registros fantasma a la cadena.
D4 | 🟡 Media | Para errores de la serie 41XX (XSD, UTF-8, formato): igual que D3. Son bugs del SIF. El botón debe estar bloqueado con mensaje "Error de plataforma — contactar soporte" | VerifactuIncidentClassifierService | Misma razón: el XML se construye de la misma forma. Solo un fix de código + redeployment resuelve estos errores.
D5 | 🟡 Media | Decidir si OPERATIONAL_RECOVERY (sin códigos de error, fallo transitorio) debe ser reintento automático en lugar de acción manual del usuario | VerifactuController, potencialmente el worker | Si no hay error específico de AEAT, probablemente fue un fallo de red o SOAP. En ese caso el worker ya tiene lógica de retry automático con backoff. Exponer esto como acción manual del usuario puede ser redundante o confuso.
D6 | 🟡 Media | Añadir guard de idempotencia más robusto: verificar no solo PENDING_SUBMISSION sino también registros correctivos ya SUBMITTED o ACCEPTED/ACCEPTED_WITH_ERRORS ligados al mismo source_remediation_record_id | VerifactuController::actionRemediate | El guard actual solo bloquea si existe un correctivo en PENDING_SUBMISSION. Si el correctivo ya fue enviado (aunque fallara), el guard no lo detecta y permite crear otro.
D7 | 🟢 Baja | Almacenar y mostrar en el dashboard el aeat_error_codes como columna explícita adicional al texto descriptivo, y usar esos códigos en el clasificador | remediation.php, VerifactuIncidentClassifierService | Actualmente la vista muestra descripciones y códigos juntos, pero el clasificador los convierte a texto y busca keywords. Si clasificamos por código numérico (D1), el code display también será más preciso para el soporte.
D8 | 🟢 Baja | Definir explícitamente qué ocurre con el registro original (sourceRecord) después de que la subsanación es aceptada: ¿debe actualizarse su status? ¿debe retirarse del dashboard? | VerifactuSubmissionService::processResponse, dashboard query | Actualmente el dashboard filtra por status IN (rejected, accepted_with_errors). El registro original no cambia de estado cuando su subsanación tiene éxito. Puede aparecer como incidente activo indefinidamente.

5. Resumen ejecutivo

El flujo de remediación está arquitectónicamente correcto en su estructura (5 capas, classifier, idempotency guard, PreviousRejectionType). El problema real es de granularidad: el clasificador MVP no distingue suficientemente entre errores auto-corregibles y errores que requieren intervención de código o de datos. Como consecuencia, el botón "Generar registro correctivo" está habilitado para tipos de error donde el reenvío producirá exactamente el mismo resultado.

Las decisiones D1 y D2 son bloqueantes para un sistema en producción. Las demás son mejoras de calidad y consistencia.