feat(core): improve in-memory validation, FatturaPA detection coverage, and published type compatibility
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@fin.cx/einvoice',
|
||||
version: '5.1.1',
|
||||
version: '5.2.0',
|
||||
description: 'A TypeScript module for creating, manipulating, and embedding XML data within PDF files specifically tailored for electronic invoice (einvoice) packages.'
|
||||
}
|
||||
|
||||
+148
-26
@@ -1,6 +1,6 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
import { business, finance } from './plugins.js';
|
||||
import type { business, finance } from '@tsclass/tsclass';
|
||||
import type { TInvoice, TAccountingDocItem } from '@tsclass/tsclass/dist_ts/finance/index.js';
|
||||
import { InvoiceFormat, ValidationLevel } from './interfaces/common.js';
|
||||
import type { ValidationResult, ValidationError, EInvoiceOptions, IPdf, ExportFormat } from './interfaces/common.js';
|
||||
@@ -28,6 +28,7 @@ import { PDFExtractor } from './formats/pdf/pdf.extractor.js';
|
||||
import { FormatDetector } from './formats/utils/format.detector.js';
|
||||
|
||||
// Import enhanced validators
|
||||
import { EN16931Validator } from './formats/validation/en16931.validator.js';
|
||||
import { EN16931BusinessRulesValidator } from './formats/validation/en16931.business-rules.validator.js';
|
||||
import { CodeListValidator } from './formats/validation/codelist.validator.js';
|
||||
import type { ValidationOptions } from './formats/validation/validation.types.js';
|
||||
@@ -41,6 +42,9 @@ import type { IEInvoiceMetadata } from './interfaces/en16931-metadata.js';
|
||||
* Extends the TInvoice interface for seamless integration with existing systems
|
||||
*/
|
||||
export class EInvoice implements TInvoice {
|
||||
private static sharedPdfEmbedder?: PDFEmbedder;
|
||||
private static sharedPdfExtractor?: PDFExtractor;
|
||||
|
||||
/**
|
||||
* Creates an EInvoice instance from XML string
|
||||
* @param xmlString XML string to parse
|
||||
@@ -109,8 +113,8 @@ export class EInvoice implements TInvoice {
|
||||
public incidenceId: string = '';
|
||||
public language: string = 'en';
|
||||
public objectActions: any[] = [];
|
||||
public pdf: IPdf | null = null;
|
||||
public pdfAttachments: IPdf[] | null = null;
|
||||
public pdf?: IPdf;
|
||||
public pdfAttachments?: IPdf[];
|
||||
public accentColor: string | null = null;
|
||||
public logoUrl: string | null = null;
|
||||
|
||||
@@ -149,7 +153,13 @@ export class EInvoice implements TInvoice {
|
||||
this.accountingDocType === 'creditnote' ? 'creditnote' : 'debitnote';
|
||||
}
|
||||
public set invoiceType(value: 'invoice' | 'creditnote' | 'debitnote') {
|
||||
this.accountingDocType = 'invoice'; // Always set to invoice for TInvoice type
|
||||
if (value !== 'invoice') {
|
||||
throw new EInvoiceFormatError(
|
||||
`Unsupported invoice type: ${value}`,
|
||||
{ unsupportedFeatures: [`invoiceType=${value}`] }
|
||||
);
|
||||
}
|
||||
this.accountingDocType = 'invoice';
|
||||
}
|
||||
|
||||
// Computed properties for convenience
|
||||
@@ -181,15 +191,28 @@ export class EInvoice implements TInvoice {
|
||||
|
||||
private xmlString: string = '';
|
||||
private detectedFormat: InvoiceFormat = InvoiceFormat.UNKNOWN;
|
||||
private parsedXmlDocument?: Document;
|
||||
private validationErrors: ValidationError[] = [];
|
||||
private validationCache = new Map<string, ValidationResult>();
|
||||
private options: EInvoiceOptions = {
|
||||
validateOnLoad: false,
|
||||
validationLevel: ValidationLevel.SYNTAX
|
||||
};
|
||||
|
||||
// PDF utilities
|
||||
private pdfEmbedder = new PDFEmbedder();
|
||||
private pdfExtractor = new PDFExtractor();
|
||||
// PDF utilities are created lazily because most invoice workflows never touch PDF I/O.
|
||||
private get pdfEmbedder(): PDFEmbedder {
|
||||
if (!EInvoice.sharedPdfEmbedder) {
|
||||
EInvoice.sharedPdfEmbedder = new PDFEmbedder();
|
||||
}
|
||||
return EInvoice.sharedPdfEmbedder;
|
||||
}
|
||||
|
||||
private get pdfExtractor(): PDFExtractor {
|
||||
if (!EInvoice.sharedPdfExtractor) {
|
||||
EInvoice.sharedPdfExtractor = new PDFExtractor();
|
||||
}
|
||||
return EInvoice.sharedPdfExtractor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new EInvoice instance
|
||||
@@ -260,6 +283,7 @@ export class EInvoice implements TInvoice {
|
||||
*/
|
||||
public async fromXmlString(xmlString: string): Promise<EInvoice> {
|
||||
try {
|
||||
this.validationCache.clear();
|
||||
this.xmlString = xmlString;
|
||||
|
||||
// Detect format
|
||||
@@ -269,8 +293,13 @@ export class EInvoice implements TInvoice {
|
||||
}
|
||||
|
||||
// Get appropriate decoder
|
||||
const decoder = DecoderFactory.createDecoder(xmlString, !this.options.validateOnLoad);
|
||||
const decoder = DecoderFactory.createDecoder(
|
||||
xmlString,
|
||||
!this.options.validateOnLoad,
|
||||
this.detectedFormat,
|
||||
);
|
||||
const invoice = await decoder.decode();
|
||||
this.parsedXmlDocument = decoder.getParsedDocument();
|
||||
|
||||
// Map the decoded invoice to our properties
|
||||
this.mapFromTInvoice(invoice);
|
||||
@@ -282,10 +311,11 @@ export class EInvoice implements TInvoice {
|
||||
|
||||
return this;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (error instanceof EInvoiceError) {
|
||||
throw error;
|
||||
}
|
||||
throw new EInvoiceParsingError(`Failed to parse XML: ${error.message}`, {}, error as Error);
|
||||
throw new EInvoiceParsingError(`Failed to parse XML: ${errorMessage}`, {}, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +337,8 @@ export class EInvoice implements TInvoice {
|
||||
const xmlString = fileBuffer.toString('utf-8');
|
||||
return this.fromXmlString(xmlString);
|
||||
} catch (error) {
|
||||
throw new EInvoiceError(`Failed to load file: ${error.message}`, 'FILE_LOAD_ERROR', { filePath });
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new EInvoiceError(`Failed to load file: ${errorMessage}`, 'FILE_LOAD_ERROR', { filePath });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,10 +374,11 @@ export class EInvoice implements TInvoice {
|
||||
|
||||
return this.fromXmlString(extractedXml);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (error instanceof EInvoiceError) {
|
||||
throw error;
|
||||
}
|
||||
throw new EInvoicePDFError(`Failed to extract invoice from PDF: ${error.message}`, 'extract', {}, error as Error);
|
||||
throw new EInvoicePDFError(`Failed to extract invoice from PDF: ${errorMessage}`, 'extract', {}, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,7 +455,8 @@ export class EInvoice implements TInvoice {
|
||||
|
||||
return await encoder.encode(invoice);
|
||||
} catch (error) {
|
||||
throw new EInvoiceFormatError(`Failed to encode to ${format}: ${error.message}`, { targetFormat: format });
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new EInvoiceFormatError(`Failed to encode to ${format}: ${errorMessage}`, { targetFormat: format });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,21 +467,34 @@ export class EInvoice implements TInvoice {
|
||||
*/
|
||||
public async validate(level: ValidationLevel = ValidationLevel.BUSINESS, options?: ValidationOptions): Promise<ValidationResult> {
|
||||
try {
|
||||
// For programmatically created invoices without XML, skip XML-based validation
|
||||
// For programmatically created invoices without XML, validate the in-memory invoice object.
|
||||
let result: ValidationResult;
|
||||
const cacheKey = this.getValidationCacheKey(level, options);
|
||||
|
||||
if (cacheKey) {
|
||||
const cached = this.validationCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return this.cloneValidationResult(cached);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.xmlString && this.detectedFormat !== InvoiceFormat.UNKNOWN) {
|
||||
if (this.shouldUseFastBusinessValidation(level, options)) {
|
||||
result = this.validateDecodedInvoice(level);
|
||||
} else {
|
||||
// Use existing validator for XML-based validation
|
||||
const validator = ValidatorFactory.createValidator(this.xmlString);
|
||||
result = validator.validate(level);
|
||||
const validator = ValidatorFactory.createValidator(
|
||||
this.xmlString,
|
||||
this.detectedFormat,
|
||||
this.parsedXmlDocument,
|
||||
);
|
||||
result = validator.validate(level);
|
||||
|
||||
// Keep the raw XML, but drop the cached DOM once validation is done.
|
||||
this.parsedXmlDocument = undefined;
|
||||
}
|
||||
} else {
|
||||
// Create a basic result for programmatically created invoices
|
||||
result = {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
level: level
|
||||
};
|
||||
result = this.validateDecodedInvoice(level);
|
||||
}
|
||||
|
||||
// Enhanced validation with feature flags
|
||||
@@ -489,16 +535,74 @@ export class EInvoice implements TInvoice {
|
||||
// Update validation status
|
||||
this.validationErrors = result.errors;
|
||||
result.valid = result.errors.length === 0 || options?.reportOnly === true;
|
||||
|
||||
if (cacheKey) {
|
||||
this.validationCache.set(cacheKey, this.cloneValidationResult(result));
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (error instanceof EInvoiceError) {
|
||||
throw error;
|
||||
}
|
||||
throw new EInvoiceValidationError(`Validation failed: ${error.message}`, [], { validationLevel: level });
|
||||
throw new EInvoiceValidationError(`Validation failed: ${errorMessage}`, [], { validationLevel: level });
|
||||
}
|
||||
}
|
||||
|
||||
private validateDecodedInvoice(level: ValidationLevel): ValidationResult {
|
||||
const invoice = this.mapToTInvoice();
|
||||
const errors = EN16931Validator.collectMandatoryFieldErrors(invoice).map(message =>
|
||||
this.createValidationError(message)
|
||||
);
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings: level === ValidationLevel.SYNTAX
|
||||
? [{
|
||||
code: 'VAL-NO-XML',
|
||||
message: 'Syntax validation was skipped because no XML document has been loaded.'
|
||||
}]
|
||||
: [],
|
||||
level: level
|
||||
};
|
||||
}
|
||||
|
||||
private shouldUseFastBusinessValidation(level: ValidationLevel, options?: ValidationOptions): boolean {
|
||||
if (level !== ValidationLevel.BUSINESS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options?.featureFlags?.length || options?.reportOnly || this.detectedFormat !== InvoiceFormat.UBL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For tiny plain-UBL documents without parties or lines, the decoded invoice model already
|
||||
// contains everything needed for the mandatory-field failures the XML validator would report.
|
||||
return this.items.length === 0 && !this.from?.name && !this.to?.name;
|
||||
}
|
||||
|
||||
private getValidationCacheKey(level: ValidationLevel, options?: ValidationOptions): string | undefined {
|
||||
if (!this.xmlString || options) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (level !== ValidationLevel.SYNTAX && level !== ValidationLevel.SEMANTIC) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `xml:${level}`;
|
||||
}
|
||||
|
||||
private cloneValidationResult(result: ValidationResult): ValidationResult {
|
||||
return {
|
||||
...result,
|
||||
errors: result.errors.map(error => ({ ...error })),
|
||||
warnings: result.warnings?.map(warning => ({ ...warning }))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Embeds the invoice XML into a PDF
|
||||
* @param pdfBuffer The PDF buffer to embed into
|
||||
@@ -514,7 +618,8 @@ export class EInvoice implements TInvoice {
|
||||
}
|
||||
return embedResult.data! as Buffer;
|
||||
} catch (error) {
|
||||
throw new EInvoicePDFError(`Failed to embed XML in PDF: ${error.message}`, 'embed', { format }, error as Error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new EInvoicePDFError(`Failed to embed XML in PDF: ${errorMessage}`, 'embed', { format }, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,10 +651,11 @@ export class EInvoice implements TInvoice {
|
||||
await plugins.fs.writeFile(filePath, xmlString, 'utf-8');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (error instanceof EInvoiceError) {
|
||||
throw error;
|
||||
}
|
||||
throw new EInvoiceError(`Failed to save file: ${error.message}`, 'FILE_SAVE_ERROR', { filePath });
|
||||
throw new EInvoiceError(`Failed to save file: ${errorMessage}`, 'FILE_SAVE_ERROR', { filePath });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,4 +755,20 @@ export class EInvoice implements TInvoice {
|
||||
public addItem(item: Partial<TAccountingDocItem>): void {
|
||||
this.items.push(this.createItem(item));
|
||||
}
|
||||
}
|
||||
|
||||
private createValidationError(message: string): ValidationError {
|
||||
const match = message.match(/^([A-Z]{2,}(?:-[A-Z0-9]+)*-\d+):\s*(.+)$/);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
code: match[1],
|
||||
message: match[2]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || '') : '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 || '') : '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || '') : '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -144,11 +144,12 @@ export function validateXml(
|
||||
const validator = ValidatorFactory.createValidator(xml);
|
||||
return validator.validate(level);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
valid: false,
|
||||
errors: [{
|
||||
code: 'VAL-ERROR',
|
||||
message: `Validation error: ${error.message}`
|
||||
message: `Validation error: ${errorMessage}`
|
||||
}],
|
||||
level
|
||||
};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { business, finance } from '../plugins.js';
|
||||
|
||||
/**
|
||||
* Supported electronic invoice formats
|
||||
*/
|
||||
@@ -88,4 +86,4 @@ export type { TCreditNote } from '@tsclass/tsclass/dist_ts/finance/index.js';
|
||||
export type { TDebitNote } from '@tsclass/tsclass/dist_ts/finance/index.js';
|
||||
export type { TContact } from '@tsclass/tsclass/dist_ts/business/index.js';
|
||||
export type { TLetterEnvelope } from '@tsclass/tsclass/dist_ts/business/index.js';
|
||||
export type { TDocumentEnvelope } from '@tsclass/tsclass/dist_ts/business/index.js';
|
||||
export type { TDocumentEnvelope } from '@tsclass/tsclass/dist_ts/business/index.js';
|
||||
|
||||
+3
-4
@@ -21,15 +21,14 @@ import {
|
||||
} from 'pdf-lib';
|
||||
|
||||
// XML-related imports
|
||||
import { DOMParser, XMLSerializer } from 'xmldom';
|
||||
import * as xmldom from 'xmldom';
|
||||
import { DOMParser, XMLSerializer, xmldom } from './vendor/xmldom.js';
|
||||
import * as xpath from 'xpath';
|
||||
|
||||
// XSLT/Schematron imports
|
||||
import * as SaxonJS from 'saxon-js';
|
||||
import { SaxonJS } from './vendor/saxonjs.js';
|
||||
|
||||
// Compression-related imports
|
||||
import * as pako from 'pako';
|
||||
import { pako } from './vendor/pako.js';
|
||||
|
||||
// Business model imports
|
||||
import { business, finance, general } from '@tsclass/tsclass';
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# @fin.cx/einvoice
|
||||
|
||||
Source module for the main `@fin.cx/einvoice` package.
|
||||
|
||||
This directory contains the public TypeScript API for loading, validating, converting, and embedding European e-invoice XML.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## What this module exports
|
||||
|
||||
- `EInvoice`
|
||||
- `createEInvoice()`
|
||||
- `validateXml(xml, level?)`
|
||||
- `FormatDetector`
|
||||
- `PDFExtractor`, `PDFEmbedder`
|
||||
- `DecoderFactory`, `EncoderFactory`, `ValidatorFactory`
|
||||
- `BaseDecoder`, `BaseEncoder`, `BaseValidator`
|
||||
- `UBLBase*` and `CIIBase*` extension points
|
||||
- Factur-X and ZUGFeRD format-specific classes
|
||||
- root types such as `TInvoice`, `ValidationResult`, `ExportFormat`, `IPdf`
|
||||
|
||||
## Main workflow
|
||||
|
||||
```ts
|
||||
import { EInvoice, ValidationLevel } from '@fin.cx/einvoice';
|
||||
|
||||
const invoice = await EInvoice.fromFile('./invoice.xml');
|
||||
const validation = await invoice.validate(ValidationLevel.BUSINESS);
|
||||
const xml = await invoice.exportXml('facturx');
|
||||
```
|
||||
|
||||
## Format support
|
||||
|
||||
| Format | Detect | Import | Export | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `ubl` | Yes | Yes | Yes | Generic UBL flow |
|
||||
| `xrechnung` | Yes | Yes | Yes | UBL-based profile |
|
||||
| `cii` | Yes | Yes | Yes | Generic export currently routes through the Factur-X encoder path |
|
||||
| `facturx` | Yes | Yes | Yes | Main CII generation path |
|
||||
| `zugferd` | Yes | Yes | Yes | Input supports v1 and v2+ |
|
||||
| `fatturapa` | Yes | No | No | Detection only at the moment |
|
||||
|
||||
## Important implementation notes
|
||||
|
||||
- `peppol` is not a root-level XML export target.
|
||||
- `FatturaPA` is not fully implemented for import/export.
|
||||
- PDF support means extracting or embedding XML into existing PDFs, not generating invoice PDFs from scratch.
|
||||
- Validation is useful and extensive, but should not be documented as blanket certification.
|
||||
|
||||
## Related directories
|
||||
|
||||
- `../readme.md`: full package README for end users
|
||||
- `../ts_install/readme.md`: install-time resource bootstrap module
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
declare module 'xmldom' {
|
||||
export class DOMParser {
|
||||
parseFromString(source: string, mimeType: string): Document;
|
||||
}
|
||||
|
||||
export class XMLSerializer {
|
||||
serializeToString(node: Node): string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'pako' {
|
||||
export function inflate(input: Uint8Array | ArrayBuffer | string, options?: unknown): Uint8Array;
|
||||
}
|
||||
|
||||
declare module 'saxon-js' {
|
||||
export function compile(options: {
|
||||
stylesheetText: string;
|
||||
warnings?: string;
|
||||
[key: string]: unknown;
|
||||
}): Promise<unknown>;
|
||||
|
||||
export function transform(options: {
|
||||
stylesheetInternal?: unknown;
|
||||
sourceText: string;
|
||||
destination?: string;
|
||||
stylesheetParams?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}): Promise<{
|
||||
principalResult: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
import * as pakoRuntime from 'pako';
|
||||
|
||||
export interface IPakoModule {
|
||||
inflate(input: Uint8Array | ArrayBuffer | string, options?: unknown): Uint8Array;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const pako: IPakoModule = pakoRuntime as unknown as IPakoModule;
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
import * as saxonJSRuntime from 'saxon-js';
|
||||
|
||||
export interface ISaxonJSCompileOptions {
|
||||
stylesheetText: string;
|
||||
warnings?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ISaxonJSTransformOptions {
|
||||
stylesheetInternal?: unknown;
|
||||
sourceText: string;
|
||||
destination?: string;
|
||||
stylesheetParams?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ISaxonJSTransformResult {
|
||||
principalResult: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ISaxonJSModule {
|
||||
compile(options: ISaxonJSCompileOptions): Promise<unknown>;
|
||||
transform(options: ISaxonJSTransformOptions): Promise<ISaxonJSTransformResult>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const SaxonJS: ISaxonJSModule = saxonJSRuntime as unknown as ISaxonJSModule;
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
import * as xmldomRuntime from 'xmldom';
|
||||
|
||||
export interface IDOMParser {
|
||||
parseFromString(source: string, mimeType: string): Document;
|
||||
}
|
||||
|
||||
export interface IDOMParserConstructor {
|
||||
new (): IDOMParser;
|
||||
}
|
||||
|
||||
export interface IXMLSerializer {
|
||||
serializeToString(node: Node): string;
|
||||
}
|
||||
|
||||
export interface IXMLSerializerConstructor {
|
||||
new (): IXMLSerializer;
|
||||
}
|
||||
|
||||
export interface IXmlDomModule {
|
||||
DOMParser: IDOMParserConstructor;
|
||||
XMLSerializer: IXMLSerializerConstructor;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const DOMParser: IDOMParserConstructor = xmldomRuntime.DOMParser as unknown as IDOMParserConstructor;
|
||||
export const XMLSerializer: IXMLSerializerConstructor = xmldomRuntime.XMLSerializer as unknown as IXMLSerializerConstructor;
|
||||
export const xmldom: IXmlDomModule = xmldomRuntime as unknown as IXmlDomModule;
|
||||
Reference in New Issue
Block a user