import * as plugins from './plugins.js'; import { business, finance } from './plugins.js'; 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'; /** * 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 { /** * 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 | null = null; public pdfAttachments: IPdf[] | null = null; 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') { this.accountingDocType = 'invoice'; // Always set to invoice for TInvoice type } // 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?: { format?: InvoiceFormat; version?: string; profile?: string; customizationId?: string; extensions?: Record; }; private xmlString: string = ''; private detectedFormat: InvoiceFormat = InvoiceFormat.UNKNOWN; private validationErrors: ValidationError[] = []; private options: EInvoiceOptions = { validateOnLoad: false, validationLevel: ValidationLevel.SYNTAX }; // PDF utilities private pdfEmbedder = new PDFEmbedder(); private pdfExtractor = new PDFExtractor(); /** * 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.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); const invoice = await decoder.decode(); // 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) { if (error instanceof EInvoiceError) { throw error; } throw new EInvoiceParsingError(`Failed to parse XML: ${error.message}`, {}, 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) { throw new EInvoiceError(`Failed to load file: ${error.message}`, '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) { if (error instanceof EInvoiceError) { throw error; } throw new EInvoicePDFError(`Failed to extract invoice from PDF: ${error.message}`, '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) { throw new EInvoiceFormatError(`Failed to encode to ${format}: ${error.message}`, { targetFormat: format }); } } /** * Validates the invoice * @param level The validation level to use * @returns The validation result */ public async validate(level: ValidationLevel = ValidationLevel.BUSINESS): Promise { try { const format = this.detectedFormat || InvoiceFormat.UNKNOWN; if (format === InvoiceFormat.UNKNOWN) { throw new EInvoiceValidationError('Cannot validate: format unknown', []); } const validator = ValidatorFactory.createValidator(this.xmlString); const result = validator.validate(level); this.validationErrors = result.errors; return result; } catch (error) { if (error instanceof EInvoiceError) { throw error; } throw new EInvoiceValidationError(`Validation failed: ${error.message}`, [], { validationLevel: level }); } } /** * 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) { throw new EInvoicePDFError(`Failed to embed XML in PDF: ${error.message}`, '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) { if (error instanceof EInvoiceError) { throw error; } throw new EInvoiceError(`Failed to save file: ${error.message}`, '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)); } }