feat(core): improve in-memory validation, FatturaPA detection coverage, and published type compatibility
This commit is contained in:
+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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user