603 lines
18 KiB
TypeScript
603 lines
18 KiB
TypeScript
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<EInvoice> {
|
|
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<EInvoice> {
|
|
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<EInvoice> {
|
|
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<string, any>['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<string, any>;
|
|
};
|
|
|
|
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<string> {
|
|
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<EInvoice> {
|
|
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<EInvoice> {
|
|
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<EInvoice> {
|
|
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<EInvoice> {
|
|
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<string> {
|
|
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<ValidationResult> {
|
|
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<Buffer> {
|
|
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<void> {
|
|
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<number, { net: number; tax: number }>();
|
|
|
|
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>): 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<TAccountingDocItem>): void {
|
|
this.items.push(this.createItem(item));
|
|
}
|
|
} |