import * as plugins 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'; // Import error classes import { EInvoiceError, EInvoiceParsingError, EInvoiceValidationError, EInvoicePDFError, EInvoiceFormatError, ErrorContext } from './errors.js'; // Import factories import { DecoderFactory } from './formats/factories/decoder.factory.js'; import { EncoderFactory } from './formats/factories/encoder.factory.js'; import { ValidatorFactory } from './formats/factories/validator.factory.js'; // Import PDF utilities import { PDFEmbedder } from './formats/pdf/pdf.embedder.js'; import { PDFExtractor } from './formats/pdf/pdf.extractor.js'; // Import format detector 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'; // Import EN16931 metadata interface import type { IEInvoiceMetadata } from './interfaces/en16931-metadata.js'; /** * Main class for working with electronic invoices. * Supports various invoice formats including Factur-X, ZUGFeRD, UBL, and XRechnung * 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 * @returns EInvoice instance */ public static async fromXml(xmlString: string): Promise { const invoice = new EInvoice(); await invoice.fromXmlString(xmlString); return invoice; } /** * Creates an EInvoice instance from file * @param filePath Path to the file * @returns EInvoice instance */ public static async fromFile(filePath: string): Promise { const invoice = new EInvoice(); await invoice.fromFile(filePath); return invoice; } /** * Creates an EInvoice instance from PDF * @param pdfBuffer PDF buffer * @returns EInvoice instance */ public static async fromPdf(pdfBuffer: Buffer | string): Promise { const invoice = new EInvoice(); if (typeof pdfBuffer === 'string') { // If given a file path await invoice.fromPdfFile(pdfBuffer); } else { // If given a buffer, extract XML and parse it const extractResult = await invoice.pdfExtractor.extractXml(pdfBuffer); if (!extractResult.success || !extractResult.xml) { throw new EInvoicePDFError('No invoice XML found in PDF', 'extract'); } await invoice.fromXmlString(extractResult.xml); } return invoice; } // TInvoice interface properties - accounting document structure public type: 'accounting-doc' = 'accounting-doc'; public accountingDocType: 'invoice' = 'invoice'; public accountingDocId: string = ''; public accountingDocStatus: 'draft' | 'issued' | 'paid' | 'canceled' | 'refunded' = 'issued'; // Business envelope properties public id: string = ''; public date = Date.now(); public status: 'draft' | 'issued' | 'paid' | 'canceled' | 'refunded' = 'issued'; public subject: string = ''; public versionInfo: business.TDocumentEnvelope['versionInfo'] = { type: 'draft', version: '1.0.0' }; // Contact information public from: business.TContact; public to: business.TContact; public legalContact?: business.TContact; // Additional envelope properties public incidenceId: string = ''; public language: string = 'en'; public objectActions: any[] = []; public pdf?: IPdf; public pdfAttachments?: IPdf[]; public accentColor: string | null = null; public logoUrl: string | null = null; // Accounting document specific properties public items: TAccountingDocItem[] = []; public dueInDays: number = 30; public reverseCharge: boolean = false; public currency: finance.TCurrency = 'EUR'; public notes: string[] = []; public periodOfPerformance?: { from: number; to: number }; public deliveryDate?: number; public buyerReference?: string; public electronicAddress?: { scheme: string; value: string }; public paymentOptions?: finance.IPaymentOptionInfo; public relatedDocuments?: Array<{ relationType: 'corrects' | 'replaces' | 'references'; documentId: string; issueDate?: number; }>; public printResult?: { pdfBufferString: string; totalNet: number; totalGross: number; vatGroups: { percentage: number; items: TAccountingDocItem[]; }[]; }; // Backward compatibility properties public get invoiceId(): string { return this.accountingDocId; } public set invoiceId(value: string) { this.accountingDocId = value; } public get invoiceType(): 'invoice' | 'creditnote' | 'debitnote' { return this.accountingDocType === 'invoice' ? 'invoice' : this.accountingDocType === 'creditnote' ? 'creditnote' : 'debitnote'; } public set invoiceType(value: 'invoice' | 'creditnote' | 'debitnote') { if (value !== 'invoice') { throw new EInvoiceFormatError( `Unsupported invoice type: ${value}`, { unsupportedFeatures: [`invoiceType=${value}`] } ); } this.accountingDocType = 'invoice'; } // Computed properties for convenience public get issueDate(): Date { return new Date(this.date); } public set issueDate(value: Date) { this.date = value.getTime(); } public get totalNet(): number { return this.calculateTotalNet(); } public get totalVat(): number { return this.calculateTotalVat(); } public get totalGross(): number { return this.totalNet + this.totalVat; } public get taxBreakdown(): Array<{ taxPercent: number; netAmount: number; taxAmount: number }> { return this.calculateTaxBreakdown(); } // EInvoice specific properties public metadata?: IEInvoiceMetadata; private xmlString: string = ''; private detectedFormat: InvoiceFormat = InvoiceFormat.UNKNOWN; private parsedXmlDocument?: Document; private validationErrors: ValidationError[] = []; private validationCache = new Map(); private options: EInvoiceOptions = { validateOnLoad: false, validationLevel: ValidationLevel.SYNTAX }; // 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 * @param options Configuration options */ constructor(options?: EInvoiceOptions) { // Initialize empty contact objects this.from = this.createEmptyContact(); this.to = this.createEmptyContact(); // Apply options if provided if (options) { this.options = { ...this.options, ...options }; } } /** * Creates an empty TContact object */ private createEmptyContact(): business.TContact { return { type: 'company', name: '', description: '', address: { streetName: '', houseNumber: '', city: '', postalCode: '', country: '' }, registrationDetails: { vatId: '', registrationId: '', registrationName: '' }, status: 'active', foundedDate: { year: new Date().getFullYear(), month: new Date().getMonth() + 1, day: new Date().getDate() } } as business.TCompany; } /** * Exports the invoice as XML in the specified format * @param format The export format * @returns XML string */ public async exportXml(format: ExportFormat): Promise { return this.toXmlString(format); } /** * Loads invoice data from XML (alias for fromXmlString) * @param xmlString The XML string to parse * @returns The EInvoice instance for chaining */ public async loadXml(xmlString: string): Promise { return this.fromXmlString(xmlString); } /** * Loads invoice data from an XML string * @param xmlString The XML string to parse * @returns The EInvoice instance for chaining */ public async fromXmlString(xmlString: string): Promise { try { this.validationCache.clear(); this.xmlString = xmlString; // Detect format this.detectedFormat = FormatDetector.detectFormat(xmlString); if (this.detectedFormat === InvoiceFormat.UNKNOWN) { throw new EInvoiceFormatError('Unknown invoice format', { sourceFormat: 'unknown' }); } // Get appropriate decoder 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); // Validate if requested if (this.options.validateOnLoad) { await this.validate(this.options.validationLevel); } 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: ${errorMessage}`, {}, error as Error); } } /** * Loads invoice data from a file * @param filePath Path to the file to load * @returns The EInvoice instance for chaining */ public async fromFile(filePath: string): Promise { try { const fileBuffer = await plugins.fs.readFile(filePath); // Check if it's a PDF if (filePath.toLowerCase().endsWith('.pdf') || fileBuffer.subarray(0, 4).toString() === '%PDF') { return this.fromPdfFile(filePath); } // Otherwise treat as XML const xmlString = fileBuffer.toString('utf-8'); return this.fromXmlString(xmlString); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new EInvoiceError(`Failed to load file: ${errorMessage}`, 'FILE_LOAD_ERROR', { filePath }); } } /** * Loads invoice data from a PDF file * @param filePath Path to the PDF file * @returns The EInvoice instance for chaining */ public async fromPdfFile(filePath: string): Promise { try { const pdfBuffer = await plugins.fs.readFile(filePath); const extractResult = await this.pdfExtractor.extractXml(pdfBuffer); const extractedXml = extractResult.success ? extractResult.xml : null; if (!extractedXml) { throw new EInvoicePDFError('No invoice XML found in PDF', 'extract', { filePath }); } // Store the PDF for later use this.pdf = { name: plugins.path.basename(filePath), id: plugins.crypto.createHash('md5').update(pdfBuffer).digest('hex'), buffer: new Uint8Array(pdfBuffer), metadata: { textExtraction: '', format: 'PDF/A-3', embeddedXml: { filename: 'factur-x.xml', description: 'Factur-X Invoice' } } }; 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: ${errorMessage}`, 'extract', {}, error as Error); } } /** * Maps data from a TInvoice to this EInvoice instance */ private mapFromTInvoice(invoice: TInvoice): void { // Map all properties from the decoded invoice Object.assign(this, invoice); // Ensure backward compatibility if (!this.id && this.accountingDocId) { this.id = this.accountingDocId; } } /** * Maps this EInvoice instance to a TInvoice */ private mapToTInvoice(): TInvoice { const invoice: any = { type: 'accounting-doc', accountingDocType: this.accountingDocType, accountingDocId: this.accountingDocId || this.id, accountingDocStatus: this.accountingDocStatus, id: this.id, date: this.date, status: this.status, subject: this.subject, versionInfo: this.versionInfo, from: this.from, to: this.to, legalContact: this.legalContact, incidenceId: this.incidenceId, language: this.language, objectActions: this.objectActions, items: this.items, dueInDays: this.dueInDays, reverseCharge: this.reverseCharge, currency: this.currency, notes: this.notes, periodOfPerformance: this.periodOfPerformance, deliveryDate: this.deliveryDate, buyerReference: this.buyerReference, electronicAddress: this.electronicAddress, paymentOptions: this.paymentOptions, relatedDocuments: this.relatedDocuments, printResult: this.printResult }; // Preserve metadata for enhanced spec compliance if ((this as any).metadata) { invoice.metadata = (this as any).metadata; } return invoice; } /** * Exports the invoice to an XML string in the specified format * @param format The target format * @returns The XML string */ public async toXmlString(format: ExportFormat): Promise { try { const encoder = EncoderFactory.createEncoder(format); const invoice = this.mapToTInvoice(); // Import EN16931Validator dynamically to avoid circular dependency const { EN16931Validator } = await import('./formats/validation/en16931.validator.js'); // Validate mandatory fields before encoding EN16931Validator.validateMandatoryFields(invoice); return await encoder.encode(invoice); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new EInvoiceFormatError(`Failed to encode to ${format}: ${errorMessage}`, { targetFormat: format }); } } /** * Validates the invoice * @param level The validation level to use * @returns The validation result */ public async validate(level: ValidationLevel = ValidationLevel.BUSINESS, options?: ValidationOptions): Promise { try { // 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, 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 { result = this.validateDecodedInvoice(level); } // Enhanced validation with feature flags if (options?.featureFlags?.includes('EN16931_BUSINESS_RULES')) { const businessRulesValidator = new EN16931BusinessRulesValidator(); const businessResults = businessRulesValidator.validate(this, options); // Merge results result.errors = result.errors.concat( businessResults .filter(r => r.severity === 'error') .map(r => ({ code: r.ruleId, message: r.message, field: r.field })) ); // Add warnings if not in report-only mode if (!options.reportOnly) { result.warnings = (result.warnings || []).concat( businessResults .filter(r => r.severity === 'warning') .map(r => ({ code: r.ruleId, message: r.message, field: r.field })) ); } } // Code list validation with feature flag if (options?.featureFlags?.includes('CODE_LIST_VALIDATION')) { const codeListValidator = new CodeListValidator(); const codeListResults = codeListValidator.validate(this); // Merge results result.errors = result.errors.concat( codeListResults .filter(r => r.severity === 'error') .map(r => ({ code: r.ruleId, message: r.message, field: r.field })) ); } // 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: ${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 * @param format The format to use for embedding * @returns The PDF buffer with embedded XML */ public async embedInPdf(pdfBuffer: Buffer, format: ExportFormat = 'facturx'): Promise { try { const xmlString = await this.toXmlString(format); const embedResult = await this.pdfEmbedder.embedXml(pdfBuffer, xmlString, 'invoice.xml', `${format} Invoice`); if (!embedResult.success) { throw new EInvoicePDFError('Failed to embed XML in PDF', 'embed', { format }); } return embedResult.data! as Buffer; } catch (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); } } /** * Saves the invoice to a file * @param filePath The path to save to * @param format The format to save in */ public async saveToFile(filePath: string, format?: ExportFormat): Promise { try { // Determine format from file extension if not provided if (!format && filePath.toLowerCase().endsWith('.xml')) { format = this.detectedFormat === InvoiceFormat.UBL ? 'ubl' : this.detectedFormat === InvoiceFormat.ZUGFERD ? 'zugferd' : this.detectedFormat === InvoiceFormat.FACTURX ? 'facturx' : 'xrechnung'; } if (filePath.toLowerCase().endsWith('.pdf')) { // Save as PDF with embedded XML if (!this.pdf) { throw new EInvoiceError('No PDF available to save', 'NO_PDF_ERROR'); } const pdfWithXml = await this.embedInPdf(Buffer.from(this.pdf.buffer), format); await plugins.fs.writeFile(filePath, pdfWithXml); } else { // Save as XML const xmlString = await this.toXmlString(format || 'xrechnung'); 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: ${errorMessage}`, 'FILE_SAVE_ERROR', { filePath }); } } /** * Gets the validation errors * @returns Array of validation errors */ public getValidationErrors(): ValidationError[] { return this.validationErrors; } /** * Checks if the invoice is valid * @returns True if valid, false otherwise */ public isValid(): boolean { return this.validationErrors.length === 0; } /** * Gets the detected format * @returns The detected invoice format */ public getFormat(): InvoiceFormat { return this.detectedFormat; } /** * Gets the original XML string * @returns The XML string */ public getXml(): string { return this.xmlString; } /** * Calculates the total net amount */ private calculateTotalNet(): number { return this.items.reduce((sum, item) => { return sum + (item.unitQuantity * item.unitNetPrice); }, 0); } /** * Calculates the total VAT amount */ private calculateTotalVat(): number { return this.items.reduce((sum, item) => { const net = item.unitQuantity * item.unitNetPrice; return sum + (net * item.vatPercentage / 100); }, 0); } /** * Calculates tax breakdown by rate */ private calculateTaxBreakdown(): Array<{ taxPercent: number; netAmount: number; taxAmount: number }> { const breakdown = new Map(); this.items.forEach(item => { const net = item.unitQuantity * item.unitNetPrice; const tax = net * item.vatPercentage / 100; const current = breakdown.get(item.vatPercentage) || { net: 0, tax: 0 }; breakdown.set(item.vatPercentage, { net: current.net + net, tax: current.tax + tax }); }); return Array.from(breakdown.entries()).map(([rate, amounts]) => ({ taxPercent: rate, netAmount: amounts.net, taxAmount: amounts.tax })); } /** * Creates a new invoice item */ public createItem(data: Partial): TAccountingDocItem { return { position: data.position || this.items.length + 1, name: data.name || '', articleNumber: data.articleNumber, unitType: data.unitType || 'unit', unitQuantity: data.unitQuantity || 1, unitNetPrice: data.unitNetPrice || 0, vatPercentage: data.vatPercentage || 0 }; } /** * Adds an item to the invoice */ public addItem(item: Partial): 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 }; } }