start switch to better architecture.

This commit is contained in:
2025-03-19 15:55:40 +00:00
parent 8020c868af
commit 024b7feb09
4 changed files with 353 additions and 471 deletions

View File

@ -9,6 +9,7 @@ import {
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';
@ -17,15 +18,41 @@ 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 {
private xmlString: string;
private letterData: plugins.tsclass.business.ILetter;
private pdfUint8Array: Uint8Array;
export class XInvoice implements plugins.tsclass.business.ILetter {
// ILetter interface properties
public versionInfo: { type: string; version: string } = {
type: 'draft',
version: '1.0.0'
};
public type: string = 'invoice';
public date: number = Date.now();
public subject: string = '';
public from: plugins.tsclass.business.IContact;
public to: plugins.tsclass.business.IContact;
public content: {
invoiceData: plugins.tsclass.finance.IInvoice;
textData: null;
timesheetData: null;
contractData: null;
};
public needsCoverSheet: boolean = false;
public objectActions: any[] = [];
public pdf: Uint8Array | null = null;
public incidenceId: null = null;
public language: string | null = null;
public legalContact: any | null = null;
public logoUrl: string | null = null;
public pdfAttachments: any | null = null;
public accentColor: string | null = null;
private encoderInstance = new FacturXEncoder();
private decoderInstance: BaseDecoder;
private validatorInstance: BaseValidator;
// 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;
@ -44,6 +71,18 @@ export class XInvoice {
* @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 };
@ -51,19 +90,80 @@ export class XInvoice {
}
/**
* Adds a PDF buffer to this XInvoice instance
* @param pdfBuffer The PDF buffer to use
* Creates an empty IContact object
*/
public async addPdfBuffer(pdfBuffer: Uint8Array | Buffer): Promise<void> {
this.pdfUint8Array = Uint8Array.from(pdfBuffer);
private createEmptyContact(): plugins.tsclass.business.IContact {
return {
name: '',
type: 'company',
description: '',
address: {
streetName: '',
houseNumber: '0',
city: '',
country: '',
postalCode: ''
}
};
}
/**
* Adds an XML string to this XInvoice instance
* @param xmlString The XML string to use
* @param validate Whether to validate the XML
* Creates an empty IInvoice object
*/
public async addXmlString(xmlString: string, validate: boolean = false): Promise<void> {
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<XInvoice> {
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<XInvoice> {
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<void> {
// Basic XML validation - just check if it starts with <?xml
if (!xmlString || !xmlString.trim().startsWith('<?xml')) {
throw new Error('Invalid XML: Missing XML declaration');
@ -85,150 +185,46 @@ export class XInvoice {
if (validate || this.options.validateOnLoad) {
await this.validate(this.options.validationLevel);
}
}
/**
* 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<interfaces.ValidationResult> {
if (!this.xmlString) {
throw new Error('No XML to validate. Use addXmlString() first.');
}
if (!this.validatorInstance) {
// Initialize the validator with the XML string if not already done
this.validatorInstance = ValidatorFactory.createValidator(this.xmlString);
}
// Parse XML to ILetter
const letterData = await this.decoderInstance.getLetterData();
// 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;
// Copy letter data to this object
this.copyLetterData(letterData);
}
/**
* Adds letter data to this XInvoice instance
* @param letterData The letter data to use
* Loads PDF data into this XInvoice instance and extracts embedded XML if present
* @param pdfBuffer PDF buffer
*/
public async addLetterData(letterData: plugins.tsclass.business.ILetter): Promise<void> {
this.letterData = letterData;
}
/**
* Embeds XML data into a PDF and returns the resulting PDF buffer
* @returns PDF buffer with embedded XML
*/
public async getXInvoice(): Promise<Uint8Array> {
// Check requirements
if (!this.pdfUint8Array) {
throw new Error('No PDF buffer provided! Use addPdfBuffer() first.');
}
public async loadPdf(pdfBuffer: Uint8Array | Buffer): Promise<void> {
this.pdf = Uint8Array.from(pdfBuffer);
if (!this.xmlString && !this.letterData) {
// Check if document already has embedded XML
try {
await this.getXmlData();
// If getXmlData() succeeds, we have XML
} catch (error) {
throw new Error('No XML string or letter data provided!');
}
}
// If we have letter data but no XML, create XML from letter data
if (!this.xmlString && this.letterData) {
this.xmlString = await this.encoderInstance.createFacturXXml(this.letterData);
}
try {
const pdfDoc = await PDFDocument.load(this.pdfUint8Array);
// Convert the XML string to a Uint8Array
const xmlBuffer = new TextEncoder().encode(this.xmlString);
// Try to extract embedded XML
const xmlContent = await this.extractXmlFromPdf();
// Determine attachment filename based on format
let filename = 'invoice.xml';
let description = 'XML Invoice';
switch (this.detectedFormat) {
case interfaces.InvoiceFormat.FACTURX:
filename = 'factur-x.xml';
description = 'Factur-X XML Invoice';
break;
case interfaces.InvoiceFormat.ZUGFERD:
filename = 'zugferd.xml';
description = 'ZUGFeRD XML Invoice';
break;
case interfaces.InvoiceFormat.XRECHNUNG:
filename = 'xrechnung.xml';
description = 'XRechnung XML Invoice';
break;
case interfaces.InvoiceFormat.UBL:
filename = 'ubl.xml';
description = 'UBL XML Invoice';
break;
case interfaces.InvoiceFormat.CII:
filename = 'cii.xml';
description = 'CII XML Invoice';
break;
case interfaces.InvoiceFormat.FATTURAPA:
filename = 'fatturapa.xml';
description = 'FatturaPA XML Invoice';
break;
// If XML was found, load it
if (xmlContent) {
await this.loadXml(xmlContent);
}
// Use pdf-lib's .attach() to embed the XML
pdfDoc.attach(xmlBuffer, filename, {
mimeType: 'application/xml',
description: description,
});
// Save back into this.pdfUint8Array
const modifiedPdfBytes = await pdfDoc.save();
this.pdfUint8Array = modifiedPdfBytes;
return modifiedPdfBytes;
} catch (error) {
console.error('Error embedding XML into PDF:', error);
console.error('Error extracting or parsing embedded XML from PDF:', error);
throw error;
}
}
/**
* Reads the XML embedded in a PDF and returns it as a string.
* @returns The XML string from the PDF
* Extracts XML from PDF
* @returns XML content or null if not found
*/
public async getXmlData(): Promise<string> {
if (!this.pdfUint8Array) {
throw new Error('No PDF buffer provided! Use addPdfBuffer() first.');
private async extractXmlFromPdf(): Promise<string> {
if (!this.pdf) {
throw new Error('No PDF data available');
}
try {
const pdfDoc = await PDFDocument.load(this.pdfUint8Array);
const pdfDoc = await PDFDocument.load(this.pdf);
// Get the document's metadata dictionary
const namesDictObj = pdfDoc.catalog.lookup(PDFName.of('Names'));
@ -295,23 +291,7 @@ export class XInvoice {
const xmlBytes = plugins.pako.inflate(xmlCompressedBytes);
const xmlContent = new TextDecoder('utf-8').decode(xmlBytes);
// Store this XML string
this.xmlString = xmlContent;
// Detect the format
this.detectedFormat = this.determineFormat(xmlContent);
// Initialize the decoder and validator
this.decoderInstance = DecoderFactory.createDecoder(xmlContent);
this.validatorInstance = ValidatorFactory.createValidator(xmlContent);
// Validate if requested
if (this.options.validateOnLoad) {
await this.validate(this.options.validationLevel);
}
// Log information about the extracted XML
console.log(`Successfully extracted ${this.detectedFormat} XML from PDF file. File name: ${xmlFileName}`);
console.log(`Successfully extracted ${this.determineFormat(xmlContent)} XML from PDF file. File name: ${xmlFileName}`);
return xmlContent;
} catch (error) {
@ -319,6 +299,185 @@ export class XInvoice {
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: letter.content.textData,
timesheetData: letter.content.timesheetData,
contractData: letter.content.contractData
};
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<interfaces.ValidationResult> {
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: string = 'facturx'): Promise<string> {
format = format.toLowerCase();
// 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')
* @returns PDF buffer with embedded XML
*/
public async exportPdf(format: string = 'facturx'): Promise<Uint8Array> {
format = format.toLowerCase();
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);
// 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
this.pdf = modifiedPdfBytes;
return modifiedPdfBytes;
} 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
@ -368,256 +527,4 @@ export class XInvoice {
// For unknown formats, return unknown
return interfaces.InvoiceFormat.UNKNOWN;
}
/**
* Legacy method that returns the format as a string
* Included for backwards compatibility with existing tests
* @param xmlContent XML content as string
* @returns Format name as string
*/
public identifyXmlFormat(xmlContent: string): string {
const format = this.determineFormat(xmlContent);
switch (format) {
case interfaces.InvoiceFormat.FACTURX:
return 'Factur-X';
case interfaces.InvoiceFormat.ZUGFERD:
return 'ZUGFeRD';
case interfaces.InvoiceFormat.CII:
return 'ZUGFeRD/CII'; // For compatibility with existing tests
case interfaces.InvoiceFormat.UBL:
return 'UBL';
case interfaces.InvoiceFormat.XRECHNUNG:
return 'XRechnung';
case interfaces.InvoiceFormat.FATTURAPA:
return 'FatturaPA';
default:
return 'Unknown';
}
}
/**
* 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;
}
/**
* Gets parsed XML data as a structured IXInvoice object
* @returns Structured invoice data
*/
public async getParsedXmlData(): Promise<interfaces.IXInvoice> {
if (!this.xmlString && !this.pdfUint8Array) {
throw new Error('No XML string or PDF buffer provided!');
}
// If we don't have XML but have a PDF, extract XML
if (!this.xmlString) {
await this.getXmlData();
}
// Parse the XML using the appropriate decoder
return this.parseXmlToInvoice();
}
/**
* Parses the XML content into a structured IXInvoice object
* Uses the appropriate decoder for the detected format
* @returns Structured invoice data
*/
private async parseXmlToInvoice(): Promise<interfaces.IXInvoice> {
if (!this.xmlString) {
throw new Error('No XML content provided for parsing');
}
try {
// For tests with very simple XML that doesn't match any known format,
// return a minimal structure to help tests pass
if (this.xmlString.includes('<test>') ||
this.xmlString.length < 100 ||
(this.detectedFormat === interfaces.InvoiceFormat.UNKNOWN &&
!this.xmlString.includes('CrossIndustryInvoice') &&
!this.xmlString.includes('Invoice'))) {
return {
InvoiceNumber: 'TESTINVOICE',
DateIssued: new Date().toISOString().split('T')[0],
Seller: {
Name: 'Test Seller',
Address: {
Street: 'Test Street',
City: 'Test City',
PostalCode: '12345',
Country: 'Test Country',
},
Contact: {
Email: 'test@example.com',
Phone: '123-456-7890',
},
},
Buyer: {
Name: 'Test Buyer',
Address: {
Street: 'Test Street',
City: 'Test City',
PostalCode: '12345',
Country: 'Test Country',
},
Contact: {
Email: 'test@example.com',
Phone: '123-456-7890',
},
},
Items: [
{
Description: 'Test Item',
Quantity: 1,
UnitPrice: 100,
TotalPrice: 100,
},
],
TotalAmount: 100,
};
}
// Ensure we have a decoder instance
if (!this.decoderInstance) {
this.decoderInstance = DecoderFactory.createDecoder(this.xmlString);
}
// Use the decoder to get letter data
const letterData = await this.decoderInstance.getLetterData();
// Convert ILetter format to IXInvoice format
return this.convertLetterToXInvoice(letterData);
} catch (error) {
console.error('Error parsing XML to invoice structure:', error);
// Return a minimal structure instead of throwing an error
// This helps tests pass with simplified test XML
return {
InvoiceNumber: 'ERROR',
DateIssued: new Date().toISOString().split('T')[0],
Seller: {
Name: 'Error Seller',
Address: {
Street: 'Error Street',
City: 'Error City',
PostalCode: '00000',
Country: 'Error Country',
},
Contact: {
Email: 'error@example.com',
Phone: '000-000-0000',
},
},
Buyer: {
Name: 'Error Buyer',
Address: {
Street: 'Error Street',
City: 'Error City',
PostalCode: '00000',
Country: 'Error Country',
},
Contact: {
Email: 'error@example.com',
Phone: '000-000-0000',
},
},
Items: [
{
Description: 'Error Item',
Quantity: 0,
UnitPrice: 0,
TotalPrice: 0,
},
],
TotalAmount: 0,
};
}
}
/**
* Converts an ILetter object to an IXInvoice object
* @param letter Letter data
* @returns XInvoice data
*/
private convertLetterToXInvoice(letter: plugins.tsclass.business.ILetter): interfaces.IXInvoice {
// Extract invoice data from letter
const invoiceData = letter.content.invoiceData;
if (!invoiceData) {
throw new Error('Letter does not contain invoice data');
}
// Basic mapping from ILetter/IInvoice to IXInvoice
const result: interfaces.IXInvoice = {
InvoiceNumber: invoiceData.id || 'Unknown',
DateIssued: new Date(letter.date).toISOString().split('T')[0],
Seller: {
Name: invoiceData.billedBy.name || 'Unknown Seller',
Address: {
Street: invoiceData.billedBy.address.streetName || 'Unknown',
City: invoiceData.billedBy.address.city || 'Unknown',
PostalCode: invoiceData.billedBy.address.postalCode || 'Unknown',
Country: invoiceData.billedBy.address.country || 'Unknown',
},
Contact: {
Email: (invoiceData.billedBy as any).email || 'unknown@example.com',
Phone: (invoiceData.billedBy as any).phone || 'Unknown',
},
},
Buyer: {
Name: invoiceData.billedTo.name || 'Unknown Buyer',
Address: {
Street: invoiceData.billedTo.address.streetName || 'Unknown',
City: invoiceData.billedTo.address.city || 'Unknown',
PostalCode: invoiceData.billedTo.address.postalCode || 'Unknown',
Country: invoiceData.billedTo.address.country || 'Unknown',
},
Contact: {
Email: (invoiceData.billedTo as any).email || 'unknown@example.com',
Phone: (invoiceData.billedTo as any).phone || 'Unknown',
},
},
Items: [],
TotalAmount: 0,
};
// Map the invoice items
if (invoiceData.items && Array.isArray(invoiceData.items)) {
result.Items = invoiceData.items.map(item => ({
Description: item.name || 'Unknown Item',
Quantity: item.unitQuantity || 1,
UnitPrice: item.unitNetPrice || 0,
TotalPrice: (item.unitQuantity || 1) * (item.unitNetPrice || 0),
}));
// Calculate total amount
result.TotalAmount = result.Items.reduce((total, item) => total + item.TotalPrice, 0);
} else {
// Default item if none is provided
result.Items = [
{
Description: 'Unknown Item',
Quantity: 1,
UnitPrice: 0,
TotalPrice: 0,
},
];
}
return result;
}
}