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?
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 |
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:
OperacionExentayCalificacionOperacioninformados a la vez (mutuamente excluyentes) - 1198:
CalificacionOperacion=S2conTipoImpositivooCuotaRepercutidadistinto de 0 - 1238: Operación exenta con campos de cuotas/tipos informados
- 1112:
FechaExpedicionFacturasuperior a la fecha actual - 1162–1164:
TipoRecargoEquivalenciano compatible con elTipoImpositivo(combinaciones fijas)
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
Muestra todos los registros con status IN (rejected, accepted_with_errors).
Para cada uno, VerifactuIncidentClassifierService::classify() determina el tipo de incidente.
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" |
// 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 →
previousHashresuelto desdeVerifactuStatus.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.
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:
- Falsos positivos: clasificar como
TECHNICAL_PLATFORMun error 2001 (NIF no censado) que contiene la palabra "nif" y también keywords de la listaEXTERNAL_CONTRAST. - Falsos negativos: errores con descripciones que no caen en ninguna keyword →
UNKNOWN_NEEDS_REVIEWcuando 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."
| # | 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. |
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.