feat(core): improve in-memory validation, FatturaPA detection coverage, and published type compatibility

This commit is contained in:
2026-04-16 20:30:56 +00:00
parent 55bee02a2e
commit 3f37f6538c
60 changed files with 5723 additions and 6678 deletions
+7
View File
@@ -37,6 +37,13 @@ export abstract class BaseDecoder {
return this.xml;
}
/**
* Gets a parsed XML document when a decoder keeps one around.
*/
public getParsedDocument(): Document | undefined {
return undefined;
}
/**
* Parses a CII date string based on format code
* @param dateStr Date string
+17 -8
View File
@@ -3,6 +3,14 @@ import type { TInvoice, TCreditNote, TDebitNote } from '../../interfaces/common.
import { CII_NAMESPACES, CIIProfile } from './cii.types.js';
import { DOMParser, xpath } from '../../plugins.js';
const ciiParser = new DOMParser();
const ciiNamespaces = {
rsm: CII_NAMESPACES.RSM,
ram: CII_NAMESPACES.RAM,
udt: CII_NAMESPACES.UDT,
};
const ciiSelect = xpath.useNamespaces(ciiNamespaces);
/**
* Base decoder for CII-based invoice formats
*/
@@ -16,22 +24,22 @@ export abstract class CIIBaseDecoder extends BaseDecoder {
super(xml, skipValidation);
// Parse XML document
this.doc = new DOMParser().parseFromString(xml, 'application/xml');
this.doc = ciiParser.parseFromString(xml, 'application/xml');
// Set up namespaces for XPath queries
this.namespaces = {
rsm: CII_NAMESPACES.RSM,
ram: CII_NAMESPACES.RAM,
udt: CII_NAMESPACES.UDT
};
this.namespaces = ciiNamespaces;
// Create XPath selector with namespaces
this.select = xpath.useNamespaces(this.namespaces);
this.select = ciiSelect;
// Detect profile
this.detectProfile();
}
public override getParsedDocument(): Document {
return this.doc;
}
/**
* Decodes CII XML into a TInvoice object
* @returns Promise resolving to a TInvoice object
@@ -93,7 +101,8 @@ export abstract class CIIBaseDecoder extends BaseDecoder {
* @returns Text value or empty string if not found
*/
protected getText(xpathExpr: string, context?: Node): string {
const node = this.select(xpathExpr, context || this.doc)[0];
const result = this.select(xpathExpr, context || this.doc);
const node = Array.isArray(result) ? result[0] : null;
return node ? (node.textContent || '') : '';
}
+18 -13
View File
@@ -4,31 +4,35 @@ import type { ValidationResult } from '../../interfaces/common.js';
import { CII_NAMESPACES, CIIProfile } from './cii.types.js';
import { DOMParser, xpath } from '../../plugins.js';
const ciiValidatorParser = new DOMParser();
const ciiValidatorNamespaces = {
rsm: CII_NAMESPACES.RSM,
ram: CII_NAMESPACES.RAM,
udt: CII_NAMESPACES.UDT,
};
const ciiValidatorSelect = xpath.useNamespaces(ciiValidatorNamespaces);
/**
* Base validator for CII-based invoice formats
*/
export abstract class CIIBaseValidator extends BaseValidator {
protected doc: Document;
protected namespaces: Record<string, string>;
protected select: xpath.XPathSelect;
protected doc!: Document;
protected namespaces!: Record<string, string>;
protected select!: xpath.XPathSelect;
protected profile: CIIProfile = CIIProfile.EN16931;
constructor(xml: string) {
constructor(xml: string, doc?: Document) {
super(xml);
try {
// Parse XML document
this.doc = new DOMParser().parseFromString(xml, 'application/xml');
// Reuse an existing parsed document when available.
this.doc = doc ?? ciiValidatorParser.parseFromString(xml, 'application/xml');
// Set up namespaces for XPath queries
this.namespaces = {
rsm: CII_NAMESPACES.RSM,
ram: CII_NAMESPACES.RAM,
udt: CII_NAMESPACES.UDT
};
this.namespaces = ciiValidatorNamespaces;
// Create XPath selector with namespaces
this.select = xpath.useNamespaces(this.namespaces);
this.select = ciiValidatorSelect;
// Detect profile
this.detectProfile();
@@ -139,7 +143,8 @@ export abstract class CIIBaseValidator extends BaseValidator {
* @returns Text value or empty string if not found
*/
protected getText(xpathExpr: string, context?: Node): string {
const node = this.select(xpathExpr, context || this.doc)[0];
const result = this.select(xpathExpr, context || this.doc);
const node = Array.isArray(result) ? result[0] : null;
return node ? (node.textContent || '') : '';
}
@@ -6,17 +6,16 @@
import * as plugins from '../../plugins.js';
import type { EInvoice } from '../../einvoice.js';
import type { TAccountingDocItem } from '@tsclass/tsclass/dist_ts/finance/index.js';
import { DOMParser } from '@xmldom/xmldom';
/**
* Converter for XML formats to EInvoice - simplified version
* This is a basic converter that extracts essential fields for testing
*/
export class XMLToEInvoiceConverter {
private parser: DOMParser;
private parser: InstanceType<typeof plugins.DOMParser>;
constructor() {
this.parser = new DOMParser();
this.parser = new plugins.DOMParser();
}
/**
@@ -116,7 +115,7 @@ export class XMLToEInvoiceConverter {
console.warn('Error parsing XML:', error);
}
return mockInvoice as EInvoice;
return mockInvoice as unknown as EInvoice;
}
/**
@@ -139,4 +138,4 @@ export class XMLToEInvoiceConverter {
return null;
}
}
}
+7 -2
View File
@@ -16,10 +16,15 @@ export class DecoderFactory {
* Creates a decoder for the specified XML content
* @param xml XML content to decode
* @param skipValidation Whether to skip EN16931 validation
* @param formatHint Optional pre-detected format to avoid re-detecting large XML inputs
* @returns Appropriate decoder instance
*/
public static createDecoder(xml: string, skipValidation: boolean = false): BaseDecoder {
const format = FormatDetector.detectFormat(xml);
public static createDecoder(
xml: string,
skipValidation: boolean = false,
formatHint?: InvoiceFormat,
): BaseDecoder {
const format = formatHint ?? FormatDetector.detectFormat(xml);
switch (format) {
case InvoiceFormat.UBL:
+14 -8
View File
@@ -59,28 +59,34 @@ export class ValidatorFactory {
/**
* Creates a validator for the specified XML content
* @param xml XML content to validate
* @param formatHint Optional pre-detected format to avoid re-detecting large XML inputs
* @param parsedDocument Optional parsed XML document to avoid parsing twice
* @returns Appropriate validator instance
*/
public static createValidator(xml: string): BaseValidator {
public static createValidator(
xml: string,
formatHint?: InvoiceFormat,
parsedDocument?: Document,
): BaseValidator {
try {
const format = FormatDetector.detectFormat(xml);
const format = formatHint ?? FormatDetector.detectFormat(xml);
switch (format) {
case InvoiceFormat.UBL:
return new EN16931UBLValidator(xml);
return new EN16931UBLValidator(xml, parsedDocument);
case InvoiceFormat.XRECHNUNG:
return new XRechnungValidator(xml);
return new XRechnungValidator(xml, parsedDocument);
case InvoiceFormat.CII:
// For now, use Factur-X validator for generic CII
return new FacturXValidator(xml);
return new FacturXValidator(xml, parsedDocument);
case InvoiceFormat.ZUGFERD:
return new ZUGFeRDValidator(xml);
return new ZUGFeRDValidator(xml, parsedDocument);
case InvoiceFormat.FACTURX:
return new FacturXValidator(xml);
return new FacturXValidator(xml, parsedDocument);
case InvoiceFormat.FATTURAPA:
return new FatturaPAValidator(xml);
@@ -131,4 +137,4 @@ class GenericValidator extends BaseValidator {
protected validateBusinessRules(): boolean {
return false;
}
}
}
+50 -16
View File
@@ -169,17 +169,45 @@ export class SemanticModelAdapter {
}
// Set payment options
if (model.paymentInstructions.paymentAccountIdentifier) {
if (
model.paymentInstructions.paymentAccountIdentifier ||
model.paymentInstructions.paymentMeansText ||
model.paymentInstructions.paymentServiceProviderIdentifier
) {
invoice.paymentOptions = {
sepa: {
iban: model.paymentInstructions.paymentAccountIdentifier,
bic: model.paymentInstructions.paymentServiceProviderIdentifier
description: model.paymentInstructions.paymentMeansText,
sepaConnection: {
iban: model.paymentInstructions.paymentAccountIdentifier || '',
bic: model.paymentInstructions.paymentServiceProviderIdentifier || '',
institution: model.paymentInstructions.paymentServiceProviderIdentifier
},
bankInfo: {
accountHolder: model.paymentInstructions.paymentAccountName || '',
institutionName: model.paymentInstructions.paymentServiceProviderIdentifier || ''
payPal: { email: '' }
};
invoice.metadata = {
...invoice.metadata,
paymentMeansCode: model.paymentInstructions.paymentMeansTypeCode,
paymentAccount: {
iban: model.paymentInstructions.paymentAccountIdentifier,
accountName: model.paymentInstructions.paymentAccountName,
bankId: model.paymentInstructions.paymentServiceProviderIdentifier
},
extensions: {
...invoice.metadata?.extensions,
paymentMeans: {
paymentMeansCode: model.paymentInstructions.paymentMeansTypeCode,
paymentMeansText: model.paymentInstructions.paymentMeansText,
remittanceInformation: model.paymentInstructions.remittanceInformation
},
paymentAccount: {
iban: model.paymentInstructions.paymentAccountIdentifier,
accountName: model.paymentInstructions.paymentAccountName,
bankId: model.paymentInstructions.paymentServiceProviderIdentifier,
bic: model.paymentInstructions.paymentServiceProviderIdentifier,
institutionName: model.paymentInstructions.paymentServiceProviderIdentifier
}
}
} as any;
};
}
// Set extensions
@@ -376,15 +404,21 @@ export class SemanticModelAdapter {
*/
private mapPaymentInstructions(invoice: EInvoice): PaymentInstructions {
const paymentMeans = invoice.metadata?.extensions?.paymentMeans;
const paymentAccount = invoice.metadata?.extensions?.paymentAccount;
const paymentAccount = invoice.metadata?.extensions?.paymentAccount || invoice.metadata?.paymentAccount;
const sepaConnection = invoice.paymentOptions?.sepaConnection;
return {
paymentMeansTypeCode: paymentMeans?.paymentMeansCode || '30', // Default to credit transfer
paymentMeansText: paymentMeans?.paymentMeansText,
paymentMeansTypeCode: paymentMeans?.paymentMeansCode || invoice.metadata?.paymentMeansCode || '30',
paymentMeansText: paymentMeans?.paymentMeansText || invoice.paymentOptions?.description,
remittanceInformation: paymentMeans?.remittanceInformation,
paymentAccountIdentifier: paymentAccount?.iban,
paymentAccountIdentifier: paymentAccount?.iban || sepaConnection?.iban,
paymentAccountName: paymentAccount?.accountName,
paymentServiceProviderIdentifier: paymentAccount?.bic || paymentAccount?.institutionName
paymentServiceProviderIdentifier:
paymentAccount?.bic ||
paymentAccount?.institutionName ||
paymentAccount?.bankId ||
sepaConnection?.bic ||
sepaConnection?.institution
};
}
@@ -397,10 +431,10 @@ export class SemanticModelAdapter {
taxExclusiveAmount: invoice.totalNet,
taxInclusiveAmount: invoice.totalGross,
allowanceTotalAmount: invoice.metadata?.extensions?.documentAllowances?.reduce(
(sum, a) => sum + a.amount, 0
(sum: number, a: { amount: number }) => sum + a.amount, 0
),
chargeTotalAmount: invoice.metadata?.extensions?.documentCharges?.reduce(
(sum, c) => sum + c.amount, 0
(sum: number, c: { amount: number }) => sum + c.amount, 0
),
prepaidAmount: invoice.metadata?.extensions?.prepaidAmount,
roundingAmount: invoice.metadata?.extensions?.roundingAmount,
@@ -597,4 +631,4 @@ export class SemanticModelAdapter {
return errors;
}
}
}
+5 -3
View File
@@ -90,7 +90,8 @@ export class EN16931UBLValidator extends UBLBaseValidator {
}
// BR-08: An Invoice shall contain the Seller postal address (BG-5).
const sellerAddress = this.select('//cac:AccountingSupplierParty//cac:PostalAddress', this.doc)[0];
const sellerAddressResult = this.select('//cac:AccountingSupplierParty//cac:PostalAddress', this.doc);
const sellerAddress = Array.isArray(sellerAddressResult) ? sellerAddressResult[0] : null;
if (!sellerAddress || !this.exists('.//cbc:IdentificationCode', sellerAddress)) {
this.addError('BR-08', 'An Invoice shall contain the Seller postal address', '//cac:AccountingSupplierParty//cac:PostalAddress');
valid = false;
@@ -103,7 +104,8 @@ export class EN16931UBLValidator extends UBLBaseValidator {
}
// BR-10: An Invoice shall contain the Buyer postal address (BG-8).
const buyerAddress = this.select('//cac:AccountingCustomerParty//cac:PostalAddress', this.doc)[0];
const buyerAddressResult = this.select('//cac:AccountingCustomerParty//cac:PostalAddress', this.doc);
const buyerAddress = Array.isArray(buyerAddressResult) ? buyerAddressResult[0] : null;
if (!buyerAddress || !this.exists('.//cbc:IdentificationCode', buyerAddress)) {
this.addError('BR-10', 'An Invoice shall contain the Buyer postal address', '//cac:AccountingCustomerParty//cac:PostalAddress');
valid = false;
@@ -213,4 +215,4 @@ export class EN16931UBLValidator extends UBLBaseValidator {
return valid;
}
}
}
+12 -7
View File
@@ -263,6 +263,11 @@ export class UBLEncoder extends UBLBaseEncoder {
* @param invoice Invoice data
*/
private addPaymentMeans(doc: Document, parentElement: Element, invoice: TInvoice): void {
const paymentOptions = invoice.paymentOptions;
if (!paymentOptions) {
return;
}
const paymentMeansNode = doc.createElement('cac:PaymentMeans');
parentElement.appendChild(paymentMeansNode);
@@ -276,26 +281,26 @@ export class UBLEncoder extends UBLBaseEncoder {
this.appendElement(doc, paymentMeansNode, 'cbc:PaymentDueDate', this.formatDate(dueDate.getTime()));
// Add payment channel code if available
if (invoice.paymentOptions.description) {
this.appendElement(doc, paymentMeansNode, 'cbc:InstructionNote', invoice.paymentOptions.description);
if (paymentOptions.description) {
this.appendElement(doc, paymentMeansNode, 'cbc:InstructionNote', paymentOptions.description);
}
// Add payment ID information if available - use invoice ID as payment reference
this.appendElement(doc, paymentMeansNode, 'cbc:PaymentID', invoice.id);
// Add bank account information if available
if (invoice.paymentOptions.sepaConnection && invoice.paymentOptions.sepaConnection.iban) {
if (paymentOptions.sepaConnection && paymentOptions.sepaConnection.iban) {
const payeeFinancialAccountNode = doc.createElement('cac:PayeeFinancialAccount');
paymentMeansNode.appendChild(payeeFinancialAccountNode);
this.appendElement(doc, payeeFinancialAccountNode, 'cbc:ID', invoice.paymentOptions.sepaConnection.iban);
this.appendElement(doc, payeeFinancialAccountNode, 'cbc:ID', paymentOptions.sepaConnection.iban);
// Add financial institution information if BIC is available
if (invoice.paymentOptions.sepaConnection.bic) {
if (paymentOptions.sepaConnection.bic) {
const financialInstitutionNode = doc.createElement('cac:FinancialInstitutionBranch');
payeeFinancialAccountNode.appendChild(financialInstitutionNode);
this.appendElement(doc, financialInstitutionNode, 'cbc:ID', invoice.paymentOptions.sepaConnection.bic);
this.appendElement(doc, financialInstitutionNode, 'cbc:ID', paymentOptions.sepaConnection.bic);
}
}
}
@@ -1038,4 +1043,4 @@ export class UBLEncoder extends UBLBaseEncoder {
}
}
}
}
}
+18 -9
View File
@@ -3,6 +3,13 @@ import type { TInvoice, TCreditNote, TDebitNote } from '../../interfaces/common.
import { UBLDocumentType, UBL_NAMESPACES } from './ubl.types.js';
import { DOMParser, xpath } from '../../plugins.js';
const ublParser = new DOMParser();
const ublNamespaces = {
cbc: UBL_NAMESPACES.CBC,
cac: UBL_NAMESPACES.CAC,
};
const ublSelect = xpath.useNamespaces(ublNamespaces);
/**
* Base decoder for UBL-based invoice formats
*/
@@ -15,16 +22,17 @@ export abstract class UBLBaseDecoder extends BaseDecoder {
super(xml, skipValidation);
// Parse XML document
this.doc = new DOMParser().parseFromString(xml, 'application/xml');
this.doc = ublParser.parseFromString(xml, 'application/xml');
// Set up namespaces for XPath queries
this.namespaces = {
cbc: UBL_NAMESPACES.CBC,
cac: UBL_NAMESPACES.CAC
};
// Set up namespaces for XPath queries
this.namespaces = ublNamespaces;
// Create XPath selector with namespaces
this.select = xpath.useNamespaces(this.namespaces);
// Create XPath selector with namespaces
this.select = ublSelect;
}
public override getParsedDocument(): Document {
return this.doc;
}
/**
@@ -75,7 +83,8 @@ export abstract class UBLBaseDecoder extends BaseDecoder {
* @returns Text value or empty string if not found
*/
protected getText(xpathExpr: string, context?: Node): string {
const node = this.select(xpathExpr, context || this.doc)[0];
const result = this.select(xpathExpr, context || this.doc);
const node = Array.isArray(result) ? result[0] : null;
return node ? (node.textContent || '') : '';
}
+17 -12
View File
@@ -4,29 +4,33 @@ import type { ValidationResult } from '../../interfaces/common.js';
import { UBLDocumentType } from './ubl.types.js';
import { DOMParser, xpath } from '../../plugins.js';
const ublValidatorParser = new DOMParser();
const ublValidatorNamespaces = {
cbc: 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
cac: 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2',
};
const ublValidatorSelect = xpath.useNamespaces(ublValidatorNamespaces);
/**
* Base validator for UBL-based invoice formats
*/
export abstract class UBLBaseValidator extends BaseValidator {
protected doc: Document;
protected namespaces: Record<string, string>;
protected select: xpath.XPathSelect;
protected doc!: Document;
protected namespaces!: Record<string, string>;
protected select!: xpath.XPathSelect;
constructor(xml: string) {
constructor(xml: string, doc?: Document) {
super(xml);
try {
// Parse XML document
this.doc = new DOMParser().parseFromString(xml, 'application/xml');
// Reuse an existing parsed document when available.
this.doc = doc ?? ublValidatorParser.parseFromString(xml, 'application/xml');
// Set up namespaces for XPath queries
this.namespaces = {
cbc: 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
cac: 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2'
};
this.namespaces = ublValidatorNamespaces;
// Create XPath selector with namespaces
this.select = xpath.useNamespaces(this.namespaces);
this.select = ublValidatorSelect;
} catch (error) {
this.addError('UBL-PARSE', `Failed to parse XML: ${error}`, '/');
}
@@ -101,7 +105,8 @@ export abstract class UBLBaseValidator extends BaseValidator {
* @returns Text value or empty string if not found
*/
protected getText(xpathExpr: string, context?: Node): string {
const node = this.select(xpathExpr, context || this.doc)[0];
const result = this.select(xpathExpr, context || this.doc);
const node = Array.isArray(result) ? result[0] : null;
return node ? (node.textContent || '') : '';
}
+41 -28
View File
@@ -194,7 +194,36 @@ export class XRechnungDecoder extends UBLBaseDecoder {
const seller = this.extractParty('//cac:AccountingSupplierParty/cac:Party');
const buyer = this.extractParty('//cac:AccountingCustomerParty/cac:Party');
// Create the common invoice data with metadata for business references
const businessReferences = {
buyerReference,
orderReference,
contractReference,
projectReference
};
const paymentInformation = {
paymentMeansCode,
paymentID,
paymentDueDate,
iban,
bic,
bankName,
accountName,
paymentTermsNote,
discountPercent
};
const dateInformation = {
periodStart,
periodEnd,
deliveryDate
};
const hasBusinessReferences = Object.values(businessReferences).some(value => Boolean(value));
const hasPaymentInformation = Object.values(paymentInformation).some(value => Boolean(value));
const hasDateInformation = Object.values(dateInformation).some(value => Boolean(value));
// Create the common invoice data with metadata only when the source XML actually contains it.
const invoiceData: any = {
type: 'accounting-doc' as const,
accountingDocType: 'invoice' as const,
@@ -216,36 +245,20 @@ export class XRechnungDecoder extends UBLBaseDecoder {
reverseCharge: false,
currency: currencyCode as finance.TCurrency,
notes: notes,
objectActions: [],
metadata: {
objectActions: []
};
if (hasBusinessReferences || hasPaymentInformation || hasDateInformation) {
invoiceData.metadata = {
format: 'xrechnung' as any,
version: '1.0.0',
extensions: {
businessReferences: {
buyerReference,
orderReference,
contractReference,
projectReference
},
paymentInformation: {
paymentMeansCode,
paymentID,
paymentDueDate,
iban,
bic,
bankName,
accountName,
paymentTermsNote,
discountPercent
},
dateInformation: {
periodStart,
periodEnd,
deliveryDate
}
...(hasBusinessReferences ? { businessReferences } : {}),
...(hasPaymentInformation ? { paymentInformation } : {}),
...(hasDateInformation ? { dateInformation } : {}),
}
}
};
};
}
// Validate mandatory EN16931 fields unless validation is skipped
if (!this.skipValidation) {
@@ -255,7 +268,7 @@ export class XRechnungDecoder extends UBLBaseDecoder {
return invoiceData;
} catch (error) {
// Re-throw validation errors
if (error.message && error.message.includes('EN16931 validation failed')) {
if (error instanceof Error && error.message.includes('EN16931 validation failed')) {
throw error;
}
+57 -19
View File
@@ -70,41 +70,81 @@ export class FormatDetector {
* @returns Detected format or UNKNOWN if more analysis is needed
*/
private static quickFormatCheck(xml: string): InvoiceFormat {
const lowerXml = xml.toLowerCase();
// Only scan a small prefix so large payloads do not create another full-size string copy.
const sample = xml.slice(0, 65536);
// Root-element checks avoid a DOM parse for the common invoice formats.
if (/<(?:[A-Za-z_][\w.-]*:)?(?:Invoice|CreditNote)\b/.test(sample)) {
const customizationIdMatch = sample.match(
/<[^>]*CustomizationID[^>]*>\s*([^<]+?)\s*<\/[^>]*CustomizationID>/i,
);
const customizationId = customizationIdMatch?.[1] ?? '';
if (/xrechnung/i.test(customizationId) || /urn:xoev-de:kosit:standard:xrechnung/i.test(customizationId)) {
return InvoiceFormat.XRECHNUNG;
}
return InvoiceFormat.UBL;
}
if (/<(?:[A-Za-z_][\w.-]*:)?CrossIndustryInvoice\b/.test(sample)) {
const guidelineIdMatch = sample.match(
/<[^>]*GuidelineSpecifiedDocumentContextParameter[^>]*>[\s\S]*?<[^>]*ID[^>]*>\s*([^<]+?)\s*<\/[^>]*ID>/i,
);
const guidelineId = guidelineIdMatch?.[1] ?? '';
if (/xrechnung/i.test(guidelineId)) {
return InvoiceFormat.XRECHNUNG;
}
if (/factur-x/i.test(guidelineId) || /urn:cen\.eu:en16931:2017/i.test(guidelineId)) {
return InvoiceFormat.FACTURX;
}
if (/zugferd/i.test(guidelineId) || /urn:ferd:/i.test(guidelineId) || /urn:zugferd/i.test(guidelineId)) {
return InvoiceFormat.ZUGFERD;
}
return InvoiceFormat.CII;
}
if (/<(?:[A-Za-z_][\w.-]*:)?CrossIndustryDocument\b/.test(sample)) {
return InvoiceFormat.ZUGFERD;
}
if (/<FatturaElettronica\b/.test(sample)) {
return InvoiceFormat.FATTURAPA;
}
// Check for obvious Factur-X indicators
if (
lowerXml.includes('factur-x.eu') ||
lowerXml.includes('factur-x.xml') ||
lowerXml.includes('factur-x:') ||
lowerXml.includes('urn:cen.eu:en16931:2017') && lowerXml.includes('factur-x')
/factur-x\.eu/i.test(sample) ||
/factur-x\.xml/i.test(sample) ||
/factur-x:/i.test(sample) ||
(/urn:cen\.eu:en16931:2017/i.test(sample) && /factur-x/i.test(sample))
) {
return InvoiceFormat.FACTURX;
}
// Check for obvious ZUGFeRD indicators
if (
lowerXml.includes('zugferd:') ||
lowerXml.includes('zugferd-invoice.xml') ||
lowerXml.includes('urn:ferd:') ||
lowerXml.includes('urn:zugferd')
/zugferd:/i.test(sample) ||
/zugferd-invoice\.xml/i.test(sample) ||
/urn:ferd:/i.test(sample) ||
/urn:zugferd/i.test(sample)
) {
return InvoiceFormat.ZUGFERD;
}
// Check for obvious XRechnung indicators
if (
lowerXml.includes('xrechnung') ||
lowerXml.includes('urn:xoev-de:kosit:standard:xrechnung')
/xrechnung/i.test(sample) ||
/urn:xoev-de:kosit:standard:xrechnung/i.test(sample)
) {
return InvoiceFormat.XRECHNUNG;
}
// Check for obvious FatturaPA indicators
if (
lowerXml.includes('fatturapa') ||
lowerXml.includes('fattura elettronica') ||
lowerXml.includes('fatturaelettronica')
/fatturapa/i.test(sample) ||
/fattura elettronica/i.test(sample) ||
/fatturaelettronica/i.test(sample)
) {
return InvoiceFormat.FATTURAPA;
}
@@ -198,10 +238,8 @@ export class FormatDetector {
* @returns True if it's a FatturaPA format
*/
private static isFatturaPAFormat(root: Element): boolean {
return (
root.nodeName === 'FatturaElettronica' ||
(root.getAttribute('xmlns') && root.getAttribute('xmlns')!.includes('fatturapa.gov.it'))
);
const xmlns = root.getAttribute('xmlns') || '';
return root.nodeName === 'FatturaElettronica' || xmlns.includes('fatturapa.gov.it');
}
/**
@@ -303,4 +341,4 @@ export class FormatDetector {
// Generic CII if we can't determine more specifically
return InvoiceFormat.CII;
}
}
}
+6 -4
View File
@@ -185,7 +185,8 @@ export class ConformanceTestHarness {
r.source === 'Schematron'
);
} catch (error) {
console.warn(`Schematron not available for ${sample.format}: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Schematron not available for ${sample.format}: ${errorMessage}`);
}
// Aggregate results
@@ -202,12 +203,13 @@ export class ConformanceTestHarness {
result.passed = result.errors.length === 0 === sample.expectedValid;
} catch (error) {
console.error(`Error testing ${sample.name}: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Error testing ${sample.name}: ${errorMessage}`);
result.errors.push({
ruleId: 'TEST-ERROR',
source: 'TestHarness',
severity: 'error',
message: `Test execution failed: ${error.message}`
message: `Test execution failed: ${errorMessage}`
});
}
@@ -588,4 +590,4 @@ export async function runConformanceTests(
// Generate HTML report
await harness.generateHTMLReport();
}
}
}
+20 -10
View File
@@ -27,11 +27,11 @@ export class EN16931Validator {
];
/**
* Validates that an invoice object contains all mandatory EN16931 fields
* Collects mandatory EN16931 field errors without throwing.
* @param invoice The invoice object to validate
* @throws Error if mandatory fields are missing
* @returns List of validation error messages
*/
public static validateMandatoryFields(invoice: any): void {
public static collectMandatoryFieldErrors(invoice: any): string[] {
const errors: string[] = [];
// BR-01: Invoice number is mandatory
@@ -49,7 +49,7 @@ export class EN16931Validator {
errors.push('BR-06: Seller name is mandatory');
}
// BR-07: Buyer name is mandatory
// BR-07: Buyer name is mandatory
if (!invoice.to?.name) {
errors.push('BR-07: Buyer name is mandatory');
}
@@ -67,11 +67,10 @@ export class EN16931Validator {
// BR-05: Invoice currency code is mandatory
if (!invoice.currency) {
errors.push('BR-05: Invoice currency code is mandatory');
} else {
// Validate currency format
if (!this.VALID_CURRENCIES.includes(invoice.currency.toUpperCase())) {
errors.push(`Invalid currency code: ${invoice.currency}. Must be a valid ISO 4217 currency code`);
}
} else if (!this.VALID_CURRENCIES.includes(invoice.currency.toUpperCase())) {
errors.push(
`BR-05: Invalid currency code: ${invoice.currency}. Must be a valid ISO 4217 currency code`
);
}
// BR-08: Seller postal address is mandatory
@@ -84,6 +83,17 @@ export class EN16931Validator {
errors.push('BR-10: Buyer postal address (city, postal code, country) is mandatory');
}
return errors;
}
/**
* Validates that an invoice object contains all mandatory EN16931 fields
* @param invoice The invoice object to validate
* @throws Error if mandatory fields are missing
*/
public static validateMandatoryFields(invoice: any): void {
const errors = this.collectMandatoryFieldErrors(invoice);
if (errors.length > 0) {
throw new Error(`EN16931 validation failed:\n${errors.join('\n')}`);
}
@@ -132,4 +142,4 @@ export class EN16931Validator {
throw new Error(`EN16931 XML validation failed:\n${errors.join('\n')}`);
}
}
}
}
@@ -60,7 +60,8 @@ export class MainValidator {
this.schematronEnabled = true;
console.log(`Schematron validation enabled for ${standard} ${format}`);
} catch (error) {
console.warn(`Failed to initialize Schematron: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Failed to initialize Schematron: ${errorMessage}`);
}
}
@@ -121,7 +122,8 @@ export class MainValidator {
);
results.push(...schematronResults);
} catch (error) {
console.warn(`Schematron validation error: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Schematron validation error: ${errorMessage}`);
}
}
@@ -402,4 +404,4 @@ export async function createValidator(
}
// Export for convenience
export type { ValidationReport, ValidationResult, ValidationOptions } from './validation.types.js';
export type { ValidationReport, ValidationResult, ValidationOptions } from './validation.types.js';
+11 -6
View File
@@ -139,7 +139,8 @@ export class SchematronDownloader {
console.log(`Successfully downloaded: ${fileName}`);
return filePath;
} catch (error) {
throw new Error(`Failed to download ${source.name}: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to download ${source.name}: ${errorMessage}`);
}
}
@@ -160,7 +161,8 @@ export class SchematronDownloader {
const path = await this.download(source);
paths.push(path);
} catch (error) {
console.warn(`Failed to download ${source.name}: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Failed to download ${source.name}: ${errorMessage}`);
}
}
@@ -209,7 +211,8 @@ export class SchematronDownloader {
}
}
} catch (error) {
console.warn(`Failed to list cached files: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Failed to list cached files: ${errorMessage}`);
}
return files;
@@ -230,7 +233,8 @@ export class SchematronDownloader {
console.log('Schematron cache cleared');
} catch (error) {
console.warn(`Failed to clear cache: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Failed to clear cache: ${errorMessage}`);
}
}
@@ -296,9 +300,10 @@ export async function downloadISOSkeletons(targetDir: string = 'assets_downloade
console.log(`Downloaded: ${name}`);
} catch (error) {
console.warn(`Failed to download ${name}: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Failed to download ${name}: ${errorMessage}`);
}
}
console.log('ISO Schematron skeleton download complete');
}
}
@@ -146,7 +146,8 @@ export class IntegratedValidator {
});
results.push(...schematronResults);
} catch (error) {
console.warn(`Schematron validation failed: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Schematron validation failed: ${errorMessage}`);
}
}
@@ -224,7 +225,8 @@ export class IntegratedValidator {
try {
await this.loadSchematron('EN16931', format);
} catch (error) {
console.warn(`Could not load Schematron: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Could not load Schematron: ${errorMessage}`);
}
}
@@ -278,8 +280,9 @@ export async function createStandardValidator(
try {
await validator.loadSchematron(standard, format);
} catch (error) {
console.warn(`Schematron not available for ${standard} ${format}: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Schematron not available for ${standard} ${format}: ${errorMessage}`);
}
return validator;
}
}
+12 -11
View File
@@ -1,5 +1,4 @@
import * as plugins from '../../plugins.js';
import * as SaxonJS from 'saxon-js';
import type { ValidationResult } from './validation.types.js';
/**
@@ -30,9 +29,7 @@ export class SchematronValidator {
*/
public async loadSchematron(source: string, isFilePath: boolean = true): Promise<void> {
if (isFilePath) {
// Load from file
const smartfile = await import('@push.rocks/smartfile');
this.schematronRules = await smartfile.SmartFile.fromFilePath(source).then(f => f.contentBuffer.toString());
this.schematronRules = await plugins.fs.readFile(source, 'utf-8');
} else {
// Use provided string
this.schematronRules = source;
@@ -58,14 +55,15 @@ export class SchematronValidator {
const xslt = this.generateXSLTFromSchematron(this.schematronRules);
// Compile the XSLT with Saxon-JS
this.compiledStylesheet = await SaxonJS.compile({
this.compiledStylesheet = await plugins.SaxonJS.compile({
stylesheetText: xslt,
warnings: 'silent'
});
this.isCompiled = true;
} catch (error) {
throw new Error(`Failed to compile Schematron: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to compile Schematron: ${errorMessage}`);
}
}
@@ -87,7 +85,7 @@ export class SchematronValidator {
try {
// Transform the XML with the compiled Schematron XSLT
const transformResult = await SaxonJS.transform({
const transformResult = await plugins.SaxonJS.transform({
stylesheetInternal: this.compiledStylesheet,
sourceText: xmlContent,
destination: 'serialized',
@@ -108,11 +106,12 @@ export class SchematronValidator {
return results;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
results.push({
ruleId: 'SCHEMATRON-ERROR',
source: 'SCHEMATRON',
severity: 'error',
message: `Schematron validation failed: ${error.message}`,
message: `Schematron validation failed: ${errorMessage}`,
btReference: undefined,
bgReference: undefined
});
@@ -323,7 +322,8 @@ export class HybridValidator {
try {
results.push(...validator.validate(xmlContent));
} catch (error) {
console.warn(`TS validator failed: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`TS validator failed: ${errorMessage}`);
}
}
@@ -333,7 +333,8 @@ export class HybridValidator {
const schematronResults = await this.schematronValidator.validate(xmlContent, options);
results.push(...schematronResults);
} catch (error) {
console.warn(`Schematron validation failed: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Schematron validation failed: ${errorMessage}`);
}
}
@@ -345,4 +346,4 @@ export class HybridValidator {
return true;
});
}
}
}