import * as plugins from './plugins.js'; import * as interfaces from './interfaces.js'; import { PDFDocument, PDFDict, PDFName, PDFRawStream, PDFArray, PDFString, } from 'pdf-lib'; import { FacturXEncoder } from './formats/facturx.encoder.js'; import { XInvoiceEncoder } from './formats/xinvoice.encoder.js'; import { DecoderFactory } from './formats/decoder.factory.js'; import { BaseDecoder } from './formats/base.decoder.js'; import { ValidatorFactory } from './formats/validator.factory.js'; import { BaseValidator } from './formats/base.validator.js'; /** * Main class for working with electronic invoices. * Supports various invoice formats including Factur-X, ZUGFeRD, UBL, and XRechnung * Implements ILetter interface for seamless integration with existing systems */ export class XInvoice implements plugins.tsclass.business.ILetter { // ILetter interface properties public versionInfo: plugins.tsclass.business.ILetter['versionInfo'] = { type: 'draft', version: '1.0.0' }; public type: plugins.tsclass.business.ILetter['type'] = 'invoice'; public date = Date.now(); public subject: plugins.tsclass.business.ILetter['subject'] = ''; public from: plugins.tsclass.business.TContact; public to: plugins.tsclass.business.TContact; public content: { invoiceData: plugins.tsclass.finance.IInvoice; textData: null; timesheetData: null; contractData: null; }; public needsCoverSheet: plugins.tsclass.business.ILetter['needsCoverSheet'] = false; public objectActions: plugins.tsclass.business.ILetter['objectActions'] = []; public pdf: plugins.tsclass.business.ILetter['pdf'] = null; public incidenceId: plugins.tsclass.business.ILetter['incidenceId'] = null; public language: plugins.tsclass.business.ILetter['language'] = null; public legalContact: plugins.tsclass.business.ILetter['legalContact'] = null; public logoUrl: plugins.tsclass.business.ILetter['logoUrl'] = null; public pdfAttachments: plugins.tsclass.business.ILetter['pdfAttachments'] = null; public accentColor: string | null = null; // XInvoice specific properties private xmlString: string = ''; private encoderFacturX = new FacturXEncoder(); private encoderXInvoice = new XInvoiceEncoder(); private decoderInstance: BaseDecoder | null = null; private validatorInstance: BaseValidator | null = null; // Format of the invoice, if detected private detectedFormat: interfaces.InvoiceFormat = interfaces.InvoiceFormat.UNKNOWN; // Validation errors from last validation private validationErrors: interfaces.ValidationError[] = []; // Options for this XInvoice instance private options: interfaces.XInvoiceOptions = { validateOnLoad: false, validationLevel: interfaces.ValidationLevel.SYNTAX }; /** * Creates a new XInvoice instance * @param options Configuration options */ constructor(options?: interfaces.XInvoiceOptions) { // Initialize empty IContact objects this.from = this.createEmptyContact(); this.to = this.createEmptyContact(); // Initialize empty IInvoice this.content = { invoiceData: this.createEmptyInvoice(), textData: null, timesheetData: null, contractData: null }; // Initialize with default options and override with provided options if (options) { this.options = { ...this.options, ...options }; } } /** * Creates an empty TContact object */ private createEmptyContact(): plugins.tsclass.business.TContact { return { name: '', type: 'company', description: '', address: { streetName: '', houseNumber: '0', city: '', country: '', postalCode: '' }, registrationDetails: { vatId: '', registrationId: '', registrationName: '' }, foundedDate: { year: 2000, month: 1, day: 1 }, closedDate: { year: 9999, month: 12, day: 31 }, status: 'active' }; } /** * Creates an empty IInvoice object */ private createEmptyInvoice(): plugins.tsclass.finance.IInvoice { return { id: '', status: null, type: 'debitnote', billedBy: this.createEmptyContact(), billedTo: this.createEmptyContact(), deliveryDate: Date.now(), dueInDays: 30, periodOfPerformance: null, printResult: null, currency: 'EUR' as plugins.tsclass.finance.TCurrency, notes: [], items: [], reverseCharge: false }; } /** * Static factory method to create XInvoice from XML string * @param xmlString XML content * @param options Configuration options * @returns XInvoice instance */ public static async fromXml(xmlString: string, options?: interfaces.XInvoiceOptions): Promise { const xinvoice = new XInvoice(options); // Load XML data await xinvoice.loadXml(xmlString); return xinvoice; } /** * Static factory method to create XInvoice from PDF buffer * @param pdfBuffer PDF buffer * @param options Configuration options * @returns XInvoice instance */ public static async fromPdf(pdfBuffer: Uint8Array | Buffer, options?: interfaces.XInvoiceOptions): Promise { const xinvoice = new XInvoice(options); // Load PDF data await xinvoice.loadPdf(pdfBuffer); return xinvoice; } /** * Loads XML data into this XInvoice instance * @param xmlString XML content * @param validate Whether to validate */ public async loadXml(xmlString: string, validate: boolean = false): Promise { // Basic XML validation - just check if it starts with { // Create a valid IPdf object this.pdf = { name: 'invoice.pdf', id: `invoice-${Date.now()}`, metadata: { textExtraction: '' }, buffer: Uint8Array.from(pdfBuffer) }; try { // Try to extract embedded XML const xmlContent = await this.extractXmlFromPdf(); // If XML was found, load it if (xmlContent) { await this.loadXml(xmlContent); } } catch (error) { console.error('Error extracting or parsing embedded XML from PDF:', error); throw error; } } /** * Extracts XML from PDF * @returns XML content or null if not found */ private async extractXmlFromPdf(): Promise { if (!this.pdf) { throw new Error('No PDF data available'); } try { const pdfDoc = await PDFDocument.load(this.pdf.buffer); // Get the document's metadata dictionary const namesDictObj = pdfDoc.catalog.lookup(PDFName.of('Names')); if (!(namesDictObj instanceof PDFDict)) { throw new Error('No Names dictionary found in PDF! This PDF does not contain embedded files.'); } const embeddedFilesDictObj = namesDictObj.lookup(PDFName.of('EmbeddedFiles')); if (!(embeddedFilesDictObj instanceof PDFDict)) { throw new Error('No EmbeddedFiles dictionary found! This PDF does not contain embedded files.'); } const filesSpecObj = embeddedFilesDictObj.lookup(PDFName.of('Names')); if (!(filesSpecObj instanceof PDFArray)) { throw new Error('No files specified in EmbeddedFiles dictionary!'); } // Try to find an XML file in the embedded files let xmlFile: PDFRawStream | undefined; let xmlFileName: string | undefined; for (let i = 0; i < filesSpecObj.size(); i += 2) { const fileNameObj = filesSpecObj.lookup(i); const fileSpecObj = filesSpecObj.lookup(i + 1); if (!(fileNameObj instanceof PDFString)) { continue; } if (!(fileSpecObj instanceof PDFDict)) { continue; } // Get the filename as string const fileName = fileNameObj.toString(); // Check if it's an XML file (checking both extension and known standard filenames) if (fileName.toLowerCase().includes('.xml') || fileName.toLowerCase().includes('factur-x') || fileName.toLowerCase().includes('zugferd') || fileName.toLowerCase().includes('xrechnung')) { const efDictObj = fileSpecObj.lookup(PDFName.of('EF')); if (!(efDictObj instanceof PDFDict)) { continue; } const maybeStream = efDictObj.lookup(PDFName.of('F')); if (maybeStream instanceof PDFRawStream) { // Found an XML file - save it xmlFile = maybeStream; xmlFileName = fileName; break; } } } // If no XML file was found, throw an error if (!xmlFile) { throw new Error('No embedded XML file found in the PDF!'); } // Decompress and decode the XML content const xmlCompressedBytes = xmlFile.getContents().buffer; const xmlBytes = plugins.pako.inflate(xmlCompressedBytes); const xmlContent = new TextDecoder('utf-8').decode(xmlBytes); console.log(`Successfully extracted ${this.determineFormat(xmlContent)} XML from PDF file. File name: ${xmlFileName}`); return xmlContent; } catch (error) { console.error('Error extracting or parsing embedded XML from PDF:', error); throw error; } } /** * Copies data from another ILetter object * @param letter Source letter data */ private copyLetterData(letter: plugins.tsclass.business.ILetter): void { this.versionInfo = { ...letter.versionInfo }; this.type = letter.type; this.date = letter.date; this.subject = letter.subject; this.from = { ...letter.from }; this.to = { ...letter.to }; this.content = { invoiceData: letter.content.invoiceData ? { ...letter.content.invoiceData } : this.createEmptyInvoice(), textData: null, timesheetData: null, contractData: null }; this.needsCoverSheet = letter.needsCoverSheet; this.objectActions = [...letter.objectActions]; this.incidenceId = letter.incidenceId; this.language = letter.language; this.legalContact = letter.legalContact; this.logoUrl = letter.logoUrl; this.pdfAttachments = letter.pdfAttachments; this.accentColor = letter.accentColor; } /** * Validates the XML against the appropriate validation rules * @param level Validation level (syntax, semantic, business) * @returns Validation result */ public async validate(level: interfaces.ValidationLevel = interfaces.ValidationLevel.SYNTAX): Promise { if (!this.xmlString) { throw new Error('No XML to validate'); } if (!this.validatorInstance) { // Initialize the validator with the XML string if not already done this.validatorInstance = ValidatorFactory.createValidator(this.xmlString); } // Run validation const result = this.validatorInstance.validate(level); // Store validation errors this.validationErrors = result.errors; return result; } /** * Checks if the document is valid based on the last validation * @returns True if the document is valid */ public isValid(): boolean { if (!this.validatorInstance) { return false; } return this.validatorInstance.isValid(); } /** * Gets validation errors from the last validation * @returns Array of validation errors */ public getValidationErrors(): interfaces.ValidationError[] { return this.validationErrors; } /** * Exports the invoice to XML format * @param format Target format (e.g., 'facturx', 'xrechnung') * @returns XML string in the specified format */ public async exportXml(format: interfaces.ExportFormat = 'facturx'): Promise { format = format.toLowerCase() as interfaces.ExportFormat; // Generate XML based on format switch (format) { case 'facturx': case 'zugferd': return this.encoderFacturX.createFacturXXml(this); case 'xrechnung': case 'ubl': return this.encoderXInvoice.createXInvoiceXml(this); default: // Default to Factur-X return this.encoderFacturX.createFacturXXml(this); } } /** * Exports the invoice to PDF format with embedded XML * @param format Target format (e.g., 'facturx', 'zugferd', 'xrechnung', 'ubl') * @returns PDF object with embedded XML */ public async exportPdf(format: interfaces.ExportFormat = 'facturx'): Promise { format = format.toLowerCase() as interfaces.ExportFormat; if (!this.pdf) { throw new Error('No PDF data available. Use loadPdf() first or set the pdf property.'); } try { // Generate XML based on format const xmlContent = await this.exportXml(format); // Load the PDF const pdfDoc = await PDFDocument.load(this.pdf.buffer); // Convert the XML string to a Uint8Array const xmlBuffer = new TextEncoder().encode(xmlContent); // Determine attachment filename based on format let filename = 'invoice.xml'; let description = 'XML Invoice'; switch (format) { case 'facturx': filename = 'factur-x.xml'; description = 'Factur-X XML Invoice'; break; case 'zugferd': filename = 'zugferd.xml'; description = 'ZUGFeRD XML Invoice'; break; case 'xrechnung': filename = 'xrechnung.xml'; description = 'XRechnung XML Invoice'; break; case 'ubl': filename = 'ubl.xml'; description = 'UBL XML Invoice'; break; } // Make sure filename is lowercase (as required by documentation) filename = filename.toLowerCase(); // Use pdf-lib's .attach() to embed the XML pdfDoc.attach(xmlBuffer, filename, { mimeType: 'application/xml', description: description, }); // Save the modified PDF const modifiedPdfBytes = await pdfDoc.save(); // Update the pdf property with a proper IPdf object this.pdf = { name: this.pdf.name, id: this.pdf.id, metadata: this.pdf.metadata, buffer: modifiedPdfBytes }; return this.pdf; } catch (error) { console.error('Error embedding XML into PDF:', error); throw error; } } /** * Gets the invoice format as an enum value * @returns InvoiceFormat enum value */ public getFormat(): interfaces.InvoiceFormat { return this.detectedFormat; } /** * Checks if the invoice is in a specific format * @param format Format to check * @returns True if the invoice is in the specified format */ public isFormat(format: interfaces.InvoiceFormat): boolean { return this.detectedFormat === format; } /** * Determines the format of an XML document and returns the format enum * @param xmlContent XML content as string * @returns InvoiceFormat enum value */ private determineFormat(xmlContent: string): interfaces.InvoiceFormat { if (!xmlContent) { return interfaces.InvoiceFormat.UNKNOWN; } // Check for ZUGFeRD/CII/Factur-X if (xmlContent.includes('CrossIndustryInvoice') || xmlContent.includes('rsm:') || xmlContent.includes('ram:')) { // Check for specific profiles if (xmlContent.includes('factur-x') || xmlContent.includes('Factur-X')) { return interfaces.InvoiceFormat.FACTURX; } if (xmlContent.includes('zugferd') || xmlContent.includes('ZUGFeRD')) { return interfaces.InvoiceFormat.ZUGFERD; } return interfaces.InvoiceFormat.CII; } // Check for UBL if (xmlContent.includes('