PDFlibPas, la Delphi PDF Library de losLab, comprueba en Pascal nativo las reglas de negocio de EN 16931, XRechnung 3.0.2 y Peppol BIS Billing 3.0. TPDFlib.ValidateInvoiceBusinessRulesBuiltIn lee el XML incrustado en un PDF Factur-X o ZUGFeRD y devuelve un informe JSON con IDs de reglas oficiales, sin runtime de Java, sin Saxon y sin una cadena de herramientas Schematron. El informe se etiqueta a sí mismo como un subconjunto, y esa etiqueta es el campo más importante que contiene
La presión detrás de esto es sobre todo alemana. Desde enero de 2025 todo negocio nacional en Alemania tiene que poder recibir facturas electrónicas B2B estructuradas, y la obligación de emitirlas llega por fases. De pronto, el lado receptor de una aplicación Delphi de contabilidad o ERP tiene que responder a una pregunta que nunca se había hecho: ¿es el XML dentro de este PDF una factura EN 16931 válida de verdad? La respuesta oficial viene de artefactos Schematron compilados a XSLT 2.0, o del validador KoSIT para XRechnung, y ambos corren sobre la JVM. Distribuir y parchear un JRE junto a cada instalación de escritorio solo para rechazar una factura mala es un coste que muchos equipos prefieren no asumir
¿Qué comprueba de verdad el validador integrado?
El validador nativo mapea el XML de la factura sobre los términos de negocio de EN 16931 y ejecuta un subconjunto documentado de las reglas de los artefactos de validación EN 16931 1.3.16, informando de cada hallazgo bajo su ID de regla oficial. Se soportan las dos sintaxis: UN/CEFACT CII D16B, la que incrustan Factur-X y ZUGFeRD, y OASIS UBL 2.1 Invoice y CreditNote. Las áreas cubiertas son:
- Términos obligatorios: identificador de especificación, número de factura, fecha de emisión, código de tipo, moneda, nombres de vendedor y comprador, direcciones, totales del documento, desglose de IVA y líneas (BR-01 a BR-16, BR-21 a BR-28, BR-CO-04, BR-CO-18, BR-CO-26, BR-45 a BR-48)
- Cálculos: totales de línea, descuentos, cargos, impuestos y total a pagar, además de la base y la cuota de cada categoría de IVA (BR-CO-10 a BR-CO-17, BR-xx-08, BR-xx-09)
- Reglas de categoría de IVA para S, Z, E, AE, K, G, O, L y M: tipos por línea, motivos de exención y los identificadores de IVA que exige cada categoría
- Todas las listas de códigos BR-CL-01 a BR-CL-26, incluidos los más de 2100 códigos de unidad UN/ECE Rec 20/21 que hay detrás de BR-CL-23, y las reglas de dos decimales BR-DEC-01, -05, -09 a -20 y -23
- Reglas de perfil: XRechnung 3.0.2 (BR-DE-1 a BR-DE-31, BR-DEX-01 a BR-DEX-14) sobre CII y UBL, y las reglas core, comunes, de listas de códigos y nacionales de Peppol BIS Billing 3.0.20 sobre UBL
El alcance se informa, no se da por supuesto. Sin un archivo de esquema, la etapa de esquema confirma que el XML está bien formado y cuál es el elemento raíz. Cada etapa lista las reglas fuera del subconjunto en un campo notCovered, y GetBuiltInInvoiceRuleCatalog devuelve todo el mapa como JSON, para que tu UI pueda enseñarle al usuario exactamente qué cubre un resultado verde
Hay otro límite que se pasa por alto con facilidad. Los documentos Factur-X MINIMUM y BASIC WL no son facturas EN 16931, así que el validador no les aplica las reglas EN 16931. En su lugar añade un aviso informativo BuiltIn.Coverage.Profile que lo dice, que es la respuesta honesta y además una pista: bajo las reglas alemanas esos dos perfiles tampoco cuentan como facturas electrónicas
Validar el XML incrustado de un PDF híbrido
ValidateInvoiceBusinessRulesBuiltIn toma un argumento, el nombre del archivo incrustado, y una cadena vacía es la elección normal. PDFlibPas entonces escoge el adjunto que la detección de Factur-X identifica; en un PDF sin metadatos Factur-X recurre al único adjunto .xml. Varios adjuntos .xml candidatos, o dos adjuntos con el nombre pedido, producen un error Invoice.AttachmentAmbiguous en lugar de una apuesta; el decode tiene un tope de 64 MB, y una factura ausente pone LastErrorCode a 401
uses
System.SysUtils, System.JSON, PDFlibrary;
procedure CheckInboundInvoice(const FileName: string);
var
PDF: TPDFlib;
Report: TJSONObject;
begin
PDF := TPDFlib.Create;
try
if PDF.LoadFromFile(FileName, '') <> 1 then
raise Exception.CreateFmt('Cannot open %s (error %d)',
[FileName, PDF.LastErrorCode]);
// Nombre vacío: valida el adjunto Factur-X / ZUGFeRD detectado
Report := TJSONObject.ParseJSONValue(
PDF.ValidateInvoiceBusinessRulesBuiltIn('')) as TJSONObject;
try
Writeln('profile: ', Report.GetValue<string>('profile'));
Writeln('valid: ', Report.GetValue<Boolean>('valid'));
Writeln('coverage: ', Report.GetValue<string>('coverage')); // el valor es "subset"
Writeln('full: ', Report.GetValue<Boolean>('fullConformance')); // nunca True aquí
finally
Report.Free;
end;
finally
PDF.Free;
end;
end;
El nivel superior del informe lleva standard, version, profile, complete, valid, fullConformance, coverage, una etapa schema y otra schematron, y un objeto package que es null en modo integrado. Cada etapa informa un estado, el motor, el número de reglas ejecutadas, su cobertura y un array issues. Cada issue tiene ruleID, fieldPath, severity, category, line y message
Leer los issues por categoría en vez de por mensaje
El campo category es lo que hace el informe usable en una bandeja de entrada real, porque te dice quién tiene que arreglar el problema. Los valores son syntax, schema, cardinality, calculation, vat, codelist, decimal, profile, coverage, processing e integrity. Un hallazgo de calculation suele apuntar al código de redondeo del emisor, uno de codelist a los datos maestros, y uno de profile a un campo XRechnung como la referencia del comprador
procedure ListIssues(Report: TJSONObject);
var
Item: TJSONValue;
Issue: TJSONObject;
begin
for Item in Report.GetValue<TJSONArray>('schematron.issues') do
begin
Issue := Item as TJSONObject;
if Issue.GetValue<string>('severity') = 'info' then
Continue; // avisos de cobertura como BuiltIn.Coverage.Profile
Writeln(Format('[%s] %s at %s: %s', [
Issue.GetValue<string>('category'),
Issue.GetValue<string>('ruleID'),
Issue.GetValue<string>('fieldPath'),
Issue.GetValue<string>('message')]));
end;
end;
Ejecuta el mismo bucle también sobre schema.issues, porque los fallos de buena formación y de elemento raíz caen ahí, y la etapa de reglas de negocio jamás ve un documento que no pudo parsear
¿Por qué la misma factura puede pasar en CII y fallar en UBL?
Los artefactos oficiales no aplican la misma tolerancia numérica en ambas sintaxis, y PDFlibPas copia cada regla desde el XSLT de su propia sintaxis en vez de armonizarlas. BR-CO-17 es el caso más claro. Comprueba que la cuota de la categoría de IVA sea la base por el tipo, redondeado a dos decimales, y ambas sintaxis permiten una diferencia de una unidad de moneda. El artefacto CII lo escribe con <= y >=; el artefacto UBL usa abs con un < estricto
Toma una base de 100.00 al 19 por ciento, con lo que la cuota esperada es 19.00, y una factura que declara 20.00. En CII, 20.00 menos 1 es 19.00, lo que satisface <=, y BR-CO-17 pasa. En UBL la misma comparación es estricta, 19.00 no es menor que 19.00, y BR-CO-17 falla. Nadie manda esa factura a propósito, pero los generadores con un bug de redondeo se sientan justo en esos bordes, y una suite de tests que solo cubre una sintaxis no se entera
Las diferencias llegan más lejos que BR-CO-17. BR-xx-08 exige igualdad exacta tras redondear a dos decimales en CII, mientras UBL permite ±1 y solo se activa cuando la categoría tiene de verdad líneas, descuentos o cargos. BR-O-08 es exacto en ambas. El artefacto CII define BR-AF-09 y BR-AG-09 como true(), así que ahí jamás fallan, mientras UBL los comprueba con ±1. Y cuando un desglose de IVA CII no tiene tipo alguno, las reglas de categorías no-O pasan vacuamente porque XPath every $rate sobre una secuencia vacía es true. El código nativo tiene que saltárselas igual, o reporta errores sobre los archivos de ejemplo oficiales
Una segunda trampa afecta a los IDs de regla. El prefijo de categoría de la regla no es el código de categoría de IVA: K mapea a BR-IC, L a BR-AF y M a BR-AG. Derivar el ID desde el código de categoría produce BR-K-09 y BR-K-10, IDs que no existen en EN 16931. Si tu aplicación mapea IDs de regla a textos de ayuda, toma el ID del informe y nunca lo construyas desde el código de categoría de impuestos
¿Cómo se activan las reglas de XRechnung y Peppol?
El validador integrado elige las reglas de perfil a partir del identificador de especificación (BT-24). Un identificador que contiene xrechnung ejecuta las reglas CIUS de XRechnung 3.0.2 sobre CII o UBL; el identificador de extensión urn:xeinkauf.de:kosit:extension:xrechnung_3.0 activa además las reglas BR-DEX. Los checks de importe de sublínea e IVA (BR-DEX-02, -03 y -09 a -14) corren solo sobre UBL; en CII, BR-DEX-15 informa de que las sublíneas no están disponibles. Un identificador que contiene peppol ejecuta las reglas de Peppol BIS Billing 3.0.20, que Peppol define solo para UBL. Un documento CII que reclame una customización Peppol recibe una nota informativa BuiltIn.Coverage.Profile diciendo que esas reglas no se comprobaron, en vez de un pase silencioso. Para otros perfiles CIUS, usa los artefactos oficiales
Un detalle de UBL conviene conocer: BR-06 y BR-07 aceptan solo PartyLegalEntity/RegistrationName como nombre del vendedor o comprador. Recurrir a PartyName/Name parece indulgente pero esconde fallos reales de BR-06, y por eso el check es estricto
¿Cuándo sigues necesitando el Schematron oficial?
Usa los artefactos oficiales, el Schematron de EN 16931 vía Saxon o el validador KoSIT para XRechnung, siempre que un resultado tenga peso legal o contractual: rechazar la factura de un proveedor, certificar tu propio generador o resolver una disputa. El informe integrado nunca pone fullConformance a true. Esa bandera exige un paquete de reglas verificado, ambas etapas superadas, ambas etapas ejecutadas por un motor que corre las reglas propias del paquete, y nada pendiente en notCovered
ValidateInvoiceBusinessRulesWithPackage recorre ese camino. Un paquete de reglas es un directorio con un manifiesto rulepackage.xml, escrito por CreateInvoiceRulePackage, que nombra el estándar, la versión, el esquema y la hoja de estilos Schematron, y anota el SHA-256 de cada archivo. PDFlibPas verifica cada digest al cargar el paquete y otra vez justo antes de que un motor lea el archivo, lo que estrecha la ventana para un canje tras la verificación. Las rutas no pueden salir del directorio del paquete, y el loader XSD nativo rechaza imports que el manifiesto no lista. Con xmllint configurado, los esquemas importados los lee el propio xmllint, así que solo el check de carga los protege
const
// SHA-256 del rulepackage.xml que revisaste y aprobaste
ApprovedManifestSHA256 = '<64 hex digits from your release review>';
var
Report, Pkg: TJSONObject;
begin
// PDF: un TPDFlib con la factura ya cargada, como en el primer ejemplo
Report := TJSONObject.ParseJSONValue(
PDF.ValidateInvoiceBusinessRulesWithPackage('',
'C:\Rules\EN16931-CII-1.3.16', ApprovedManifestSHA256,
'', // vacío: etapa XSD nativa
'C:\Java\bin\java.exe', 'C:\Saxon\saxon-he.jar',
0)) as TJSONObject; // 0: 30000 ms por herramienta
try
if not (Report.FindValue('package') is TJSONObject) then
raise Exception.Create('No rule package in the report');
Pkg := Report.GetValue<TJSONObject>('package');
if not (Pkg.GetValue<Boolean>('pinned') and Pkg.GetValue<Boolean>('verified')) then
raise Exception.Create('Rule package failed its integrity check');
if Report.GetValue<Boolean>('fullConformance') then
Writeln('Conforms to the complete rule package');
finally
Report.Free;
end;
end;
El digest fijado no es un adorno opcional. Los digests de archivo prueban que los archivos coinciden con el manifiesto, pero nada dentro de un paquete prueba que el manifiesto mismo sea genuino: un atacante que pueda reescribir las reglas puede reescribir con ellas el manifiesto. Solo un digest fijado por quien llama, en código o en configuración firmada, cierra ese hueco, y el informe mantiene pinned y verified separados para que sepas qué garantía tienes de verdad
Si pasas un JavaExecutable vacío, la etapa de esquema sigue validando de forma nativa contra el XSD del paquete, incluidos facets e identity constraints desde v3.672.0, pero las reglas de negocio recurren al subconjunto integrado y fullConformance se queda en false. Los constructores de esquema que el loader nativo no soporta, como los substitution groups, hacen fallar la carga con XSD.Unsupported en lugar de saltárselos
Dónde encajan veraPDF y Mustang
Ninguna de estas funciones mira el contenedor PDF. La conformidad PDF/A-3 bajo ISO 19005-3, el esquema de extensión XMP de Factur-X y la relación de archivo asociado son cuestiones aparte, y veraPDF y Mustang siguen siendo las herramientas de referencia para ellas, como se describe en validar facturas electrónicas con veraPDF y Mustang. El lado productor, desde adjuntar el XML hasta declarar el perfil, queda cubierto en construir facturas híbridas Factur-X y ZUGFeRD en Delphi y esquemas de extensión PDF/A-3 para metadatos Factur-X
Un reparto sensato ejecuta el check integrado en proceso sobre cada factura entrante, donde cuesta milisegundos: una factura UBL de cerca de 1 MB con adjuntos base64 se valida en 15 ms, o 79 ms con el XSD de UBL 2.1. La ejecución empaquetada con Saxon va a la CI y a la ruta de rechazo, y veraPDF cubre el contenedor. Sea cual sea el resultado integrado, describe el subconjunto cubierto, no el estándar, que es justo lo que el campo coverage está ahí para recordarte
Las funciones de validación de facturas, el catálogo de reglas y las herramientas de paquetes de reglas se distribuyen con la Delphi PDF Library para Delphi y C++Builder, junto con las APIs de Factur-X, PDF/A-3 y archivos incrustados usadas arriba