530 lines
17 KiB
TypeScript
530 lines
17 KiB
TypeScript
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 IContact object
|
|
*/
|
|
private createEmptyContact(): plugins.tsclass.business.TContact {
|
|
return {
|
|
name: '',
|
|
type: 'company',
|
|
description: '',
|
|
address: {
|
|
streetName: '',
|
|
houseNumber: '0',
|
|
city: '',
|
|
country: '',
|
|
postalCode: ''
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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<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');
|
|
}
|
|
|
|
// Store the XML string
|
|
this.xmlString = xmlString;
|
|
|
|
// Detect the format
|
|
this.detectedFormat = this.determineFormat(xmlString);
|
|
|
|
// Initialize the decoder with the XML string using the factory
|
|
this.decoderInstance = DecoderFactory.createDecoder(xmlString);
|
|
|
|
// Initialize the validator with the XML string using the factory
|
|
this.validatorInstance = ValidatorFactory.createValidator(xmlString);
|
|
|
|
// Validate the XML if requested or if validateOnLoad is true
|
|
if (validate || this.options.validateOnLoad) {
|
|
await this.validate(this.options.validationLevel);
|
|
}
|
|
|
|
// Parse XML to ILetter
|
|
const letterData = await this.decoderInstance.getLetterData();
|
|
|
|
// Copy letter data to this object
|
|
this.copyLetterData(letterData);
|
|
}
|
|
|
|
/**
|
|
* Loads PDF data into this XInvoice instance and extracts embedded XML if present
|
|
* @param pdfBuffer PDF buffer
|
|
*/
|
|
public async loadPdf(pdfBuffer: Uint8Array | Buffer): Promise<void> {
|
|
this.pdf = 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<string> {
|
|
if (!this.pdf) {
|
|
throw new Error('No PDF data available');
|
|
}
|
|
|
|
try {
|
|
const pdfDoc = await PDFDocument.load(this.pdf);
|
|
|
|
// 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: 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
|
|
* @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('<Invoice') ||
|
|
xmlContent.includes('ubl:Invoice') ||
|
|
xmlContent.includes('oasis:names:specification:ubl')) {
|
|
|
|
// Check for XRechnung
|
|
if (xmlContent.includes('xrechnung') || xmlContent.includes('XRechnung')) {
|
|
return interfaces.InvoiceFormat.XRECHNUNG;
|
|
}
|
|
|
|
return interfaces.InvoiceFormat.UBL;
|
|
}
|
|
|
|
// Check for FatturaPA
|
|
if (xmlContent.includes('FatturaElettronica') ||
|
|
xmlContent.includes('fatturapa.gov.it')) {
|
|
return interfaces.InvoiceFormat.FATTURAPA;
|
|
}
|
|
|
|
// For unknown formats, return unknown
|
|
return interfaces.InvoiceFormat.UNKNOWN;
|
|
}
|
|
} |