import * as plugins from './plugins.js'; import type { IInvoice, IInvoiceLine, IInvoiceParty, IVATCategory, IValidationResult, TInvoiceFormat, TInvoiceDirection, TTaxScenario, IAllowanceCharge, IPaymentTerms } from './skr.invoice.entity.js'; /** * Adapter for @fin.cx/einvoice library * Handles parsing, validation, and format conversion of e-invoices */ export class InvoiceAdapter { private logger: plugins.smartlog.ConsoleLog; constructor() { this.logger = new plugins.smartlog.ConsoleLog(); } private readonly MAX_XML_SIZE = 10 * 1024 * 1024; // 10MB max private readonly MAX_PDF_SIZE = 50 * 1024 * 1024; // 50MB max /** * Parse an invoice from file or buffer */ public async parseInvoice( file: Buffer | string, direction: TInvoiceDirection ): Promise { try { // Validate input size if (Buffer.isBuffer(file)) { if (file.length > this.MAX_XML_SIZE) { throw new Error(`Invoice file too large: ${file.length} bytes (max ${this.MAX_XML_SIZE} bytes)`); } } else if (typeof file === 'string' && file.length > this.MAX_XML_SIZE) { throw new Error(`Invoice XML too large: ${file.length} characters (max ${this.MAX_XML_SIZE} characters)`); } // Parse the invoice using @fin.cx/einvoice let einvoice; if (typeof file === 'string') { einvoice = await plugins.einvoice.EInvoice.fromXml(file); } else { // Convert buffer to string first const xmlString = file.toString('utf-8'); einvoice = await plugins.einvoice.EInvoice.fromXml(xmlString); } // Get detected format const format = this.mapEInvoiceFormat(einvoice.format || 'xrechnung'); // Validate the invoice (takes ~2.2ms) const validationResult = await this.validateInvoice(einvoice); // Extract invoice data const invoiceData = einvoice.toObject(); // Map to internal invoice model const invoice = await this.mapToInternalModel( invoiceData, format, direction, validationResult ); // Store original XML content invoice.xmlContent = einvoice.getXml(); // Calculate content hash invoice.contentHash = await this.calculateContentHash(invoice.xmlContent); // Classify tax scenario invoice.taxScenario = this.classifyTaxScenario(invoice); return invoice; } catch (error) { this.logger.log('error', `Failed to parse invoice: ${error}`); throw new Error(`Invoice parsing failed: ${error.message}`); } } /** * Validate an invoice using multi-level validation */ private async validateInvoice(einvoice: any): Promise { // Perform multi-level validation const validationResult = await einvoice.validate(); // Parse validation results into our structure const syntaxResult = { isValid: validationResult.syntax?.valid !== false, errors: validationResult.syntax?.errors || [], warnings: validationResult.syntax?.warnings || [] }; const semanticResult = { isValid: validationResult.semantic?.valid !== false, errors: validationResult.semantic?.errors || [], warnings: validationResult.semantic?.warnings || [] }; const businessResult = { isValid: validationResult.business?.valid !== false, errors: validationResult.business?.errors || [], warnings: validationResult.business?.warnings || [] }; const countryResult = { isValid: validationResult.country?.valid !== false, errors: validationResult.country?.errors || [], warnings: validationResult.country?.warnings || [] }; return { isValid: syntaxResult.isValid && semanticResult.isValid && businessResult.isValid, syntax: { valid: syntaxResult.isValid, errors: syntaxResult.errors || [], warnings: syntaxResult.warnings || [] }, semantic: { valid: semanticResult.isValid, errors: semanticResult.errors || [], warnings: semanticResult.warnings || [] }, businessRules: { valid: businessResult.isValid, errors: businessResult.errors || [], warnings: businessResult.warnings || [] }, countrySpecific: { valid: countryResult.isValid, errors: countryResult.errors || [], warnings: countryResult.warnings || [] }, validatedAt: new Date(), validatorVersion: '5.1.4' }; } /** * Map EN16931 Business Terms to internal invoice model */ private async mapToInternalModel( businessTerms: any, format: TInvoiceFormat, direction: TInvoiceDirection, validationResult: IValidationResult ): Promise { const invoice: IInvoice = { // Identity id: plugins.smartunique.shortId(), direction, format, // EN16931 Business Terms invoiceNumber: businessTerms.BT1_InvoiceNumber, issueDate: new Date(businessTerms.BT2_IssueDate), invoiceTypeCode: businessTerms.BT3_InvoiceTypeCode || '380', currencyCode: businessTerms.BT5_CurrencyCode || 'EUR', taxCurrencyCode: businessTerms.BT6_TaxCurrencyCode, taxPointDate: businessTerms.BT7_TaxPointDate ? new Date(businessTerms.BT7_TaxPointDate) : undefined, paymentDueDate: businessTerms.BT9_PaymentDueDate ? new Date(businessTerms.BT9_PaymentDueDate) : undefined, buyerReference: businessTerms.BT10_BuyerReference, projectReference: businessTerms.BT11_ProjectReference, contractReference: businessTerms.BT12_ContractReference, orderReference: businessTerms.BT13_OrderReference, sellerOrderReference: businessTerms.BT14_SellerOrderReference, // Parties supplier: this.mapParty(businessTerms.BG4_Seller), customer: this.mapParty(businessTerms.BG7_Buyer), payee: businessTerms.BG10_Payee ? this.mapParty(businessTerms.BG10_Payee) : undefined, // Line items lines: this.mapInvoiceLines(businessTerms.BG25_InvoiceLines || []), // Allowances and charges allowances: this.mapAllowancesCharges(businessTerms.BG20_DocumentAllowances || [], true), charges: this.mapAllowancesCharges(businessTerms.BG21_DocumentCharges || [], false), // Amounts lineNetAmount: parseFloat(businessTerms.BT106_SumOfLineNetAmounts || 0), allowanceTotalAmount: parseFloat(businessTerms.BT107_AllowanceTotalAmount || 0), chargeTotalAmount: parseFloat(businessTerms.BT108_ChargeTotalAmount || 0), taxExclusiveAmount: parseFloat(businessTerms.BT109_TaxExclusiveAmount || 0), taxInclusiveAmount: parseFloat(businessTerms.BT112_TaxInclusiveAmount || 0), prepaidAmount: parseFloat(businessTerms.BT113_PrepaidAmount || 0), payableAmount: parseFloat(businessTerms.BT115_PayableAmount || 0), // VAT breakdown vatBreakdown: this.mapVATBreakdown(businessTerms.BG23_VATBreakdown || []), totalVATAmount: parseFloat(businessTerms.BT110_TotalVATAmount || 0), // Payment paymentTerms: this.mapPaymentTerms(businessTerms), paymentMeans: this.mapPaymentMeans(businessTerms.BG16_PaymentInstructions), // Notes invoiceNote: businessTerms.BT22_InvoiceNote, // Processing metadata status: 'validated', // Storage (to be filled later) contentHash: '', // Validation validationResult, // Audit trail createdAt: new Date(), createdBy: 'system', // Metadata metadata: { importedAt: new Date(), parserVersion: '5.1.4', originalFormat: format } }; return invoice; } /** * Map party information */ private mapParty(partyData: any): IInvoiceParty { if (!partyData) { return { id: '', name: '', address: { countryCode: 'DE' } }; } return { id: partyData.BT29_SellerID || partyData.BT46_BuyerID || plugins.smartunique.shortId(), name: partyData.BT27_SellerName || partyData.BT44_BuyerName || '', address: { street: partyData.BT35_SellerStreet || partyData.BT50_BuyerStreet, city: partyData.BT37_SellerCity || partyData.BT52_BuyerCity, postalCode: partyData.BT38_SellerPostalCode || partyData.BT53_BuyerPostalCode, countryCode: partyData.BT40_SellerCountryCode || partyData.BT55_BuyerCountryCode || 'DE' }, vatId: partyData.BT31_SellerVATID || partyData.BT48_BuyerVATID, taxId: partyData.BT32_SellerTaxID || partyData.BT47_BuyerTaxID, email: partyData.BT34_SellerEmail || partyData.BT49_BuyerEmail, phone: partyData.BT33_SellerPhone, bankAccount: this.mapBankAccount(partyData) }; } /** * Map bank account information */ private mapBankAccount(partyData: any): IInvoiceParty['bankAccount'] | undefined { if (!partyData?.BT84_PaymentAccountID) { return undefined; } return { iban: partyData.BT84_PaymentAccountID, bic: partyData.BT86_PaymentServiceProviderID, accountHolder: partyData.BT85_PaymentAccountName }; } /** * Map invoice lines */ private mapInvoiceLines(linesData: any[]): IInvoiceLine[] { return linesData.map((line, index) => ({ lineNumber: index + 1, description: line.BT154_ItemDescription || '', quantity: parseFloat(line.BT129_Quantity || 1), unitPrice: parseFloat(line.BT146_NetPrice || 0), netAmount: parseFloat(line.BT131_LineNetAmount || 0), vatCategory: this.mapVATCategory(line.BT151_ItemVATCategory, line.BT152_ItemVATRate), vatAmount: parseFloat(line.lineVATAmount || 0), grossAmount: parseFloat(line.BT131_LineNetAmount || 0) + parseFloat(line.lineVATAmount || 0), productCode: line.BT155_ItemSellerID, allowances: this.mapLineAllowancesCharges(line.BG27_LineAllowances || [], true), charges: this.mapLineAllowancesCharges(line.BG28_LineCharges || [], false) })); } /** * Map VAT category */ private mapVATCategory(categoryCode: string, rate: string | number): IVATCategory { const vatRate = typeof rate === 'string' ? parseFloat(rate) : rate; return { code: categoryCode || 'S', rate: vatRate || 0, exemptionReason: this.getExemptionReason(categoryCode) }; } /** * Get exemption reason for VAT category */ private getExemptionReason(categoryCode: string): string | undefined { const exemptionReasons: Record = { 'E': 'Tax exempt', 'Z': 'Zero rated', 'AE': 'Reverse charge (§13b UStG)', 'K': 'Intra-EU supply', 'G': 'Export outside EU', 'O': 'Outside scope of tax', 'S': undefined // Standard rate, no exemption }; return exemptionReasons[categoryCode]; } /** * Map VAT breakdown */ private mapVATBreakdown(vatBreakdown: any[]): IInvoice['vatBreakdown'] { return vatBreakdown.map(vat => ({ vatCategory: this.mapVATCategory(vat.BT118_VATCategory, vat.BT119_VATRate), taxableAmount: parseFloat(vat.BT116_TaxableAmount || 0), taxAmount: parseFloat(vat.BT117_TaxAmount || 0) })); } /** * Map allowances and charges */ private mapAllowancesCharges(data: any[], isAllowance: boolean): IAllowanceCharge[] { return data.map(item => ({ reason: item.BT97_AllowanceReason || item.BT104_ChargeReason || '', amount: parseFloat(item.BT92_AllowanceAmount || item.BT99_ChargeAmount || 0), percentage: item.BT94_AllowancePercentage || item.BT101_ChargePercentage, vatCategory: item.BT95_AllowanceVATCategory || item.BT102_ChargeVATCategory ? this.mapVATCategory( item.BT95_AllowanceVATCategory || item.BT102_ChargeVATCategory, item.BT96_AllowanceVATRate || item.BT103_ChargeVATRate ) : undefined, vatAmount: parseFloat(item.allowanceVATAmount || item.chargeVATAmount || 0) })); } /** * Map line-level allowances and charges */ private mapLineAllowancesCharges(data: any[], isAllowance: boolean): IAllowanceCharge[] { return data.map(item => ({ reason: item.BT140_LineAllowanceReason || item.BT145_LineChargeReason || '', amount: parseFloat(item.BT136_LineAllowanceAmount || item.BT141_LineChargeAmount || 0), percentage: item.BT138_LineAllowancePercentage || item.BT143_LineChargePercentage })); } /** * Map payment terms */ private mapPaymentTerms(businessTerms: any): IPaymentTerms | undefined { if (!businessTerms.BT9_PaymentDueDate && !businessTerms.BT20_PaymentTerms) { return undefined; } const paymentTerms: IPaymentTerms = { dueDate: businessTerms.BT9_PaymentDueDate ? new Date(businessTerms.BT9_PaymentDueDate) : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // Default 30 days paymentTermsNote: businessTerms.BT20_PaymentTerms }; // Parse skonto from payment terms note if present if (businessTerms.BT20_PaymentTerms) { paymentTerms.skonto = this.parseSkontoTerms(businessTerms.BT20_PaymentTerms); } return paymentTerms; } /** * Parse skonto terms from payment terms text */ private parseSkontoTerms(paymentTermsText: string): IPaymentTerms['skonto'] { const skontoTerms: IPaymentTerms['skonto'] = []; // Common German skonto patterns: // "2% Skonto bei Zahlung innerhalb von 10 Tagen" // "3% bei Zahlung bis 8 Tage, 2% bis 14 Tage" const skontoPattern = /(\d+(?:\.\d+)?)\s*%.*?(\d+)\s*(?:Tag|Day)/gi; let match; while ((match = skontoPattern.exec(paymentTermsText)) !== null) { skontoTerms.push({ percentage: parseFloat(match[1]), days: parseInt(match[2]), baseAmount: 0 // To be calculated based on invoice amount }); } return skontoTerms.length > 0 ? skontoTerms : undefined; } /** * Map payment means */ private mapPaymentMeans(paymentInstructions: any): IInvoice['paymentMeans'] | undefined { if (!paymentInstructions) { return undefined; } return { code: paymentInstructions.BT81_PaymentMeansCode || '30', // 30 = Bank transfer account: paymentInstructions.BT84_PaymentAccountID ? { iban: paymentInstructions.BT84_PaymentAccountID, bic: paymentInstructions.BT86_PaymentServiceProviderID, accountHolder: paymentInstructions.BT85_PaymentAccountName } : undefined }; } /** * Classify tax scenario based on invoice data */ private classifyTaxScenario(invoice: IInvoice): TTaxScenario { const supplierCountry = invoice.supplier.address.countryCode; const customerCountry = invoice.customer.address.countryCode; const hasVAT = invoice.totalVATAmount > 0; const vatCategories = invoice.vatBreakdown.map(vb => vb.vatCategory.code); // Reverse charge if (vatCategories.includes('AE')) { return 'reverse_charge'; } // Small business exemption if (vatCategories.includes('E') && invoice.invoiceNote?.includes('§19')) { return 'small_business'; } // Export outside EU if (vatCategories.includes('G') || (!this.isEUCountry(customerCountry) && supplierCountry === 'DE')) { return 'export'; } // Intra-EU transactions if (supplierCountry !== customerCountry && this.isEUCountry(supplierCountry) && this.isEUCountry(customerCountry)) { if (invoice.direction === 'outbound') { return 'intra_eu_supply'; } else { return 'intra_eu_acquisition'; } } // Domestic exempt if (!hasVAT && supplierCountry === 'DE' && customerCountry === 'DE') { return 'domestic_exempt'; } // Default: Domestic taxed return 'domestic_taxed'; } /** * Check if country is in EU */ private isEUCountry(countryCode: string): boolean { const euCountries = [ 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE' ]; return euCountries.includes(countryCode); } /** * Map e-invoice format from library format */ private mapEInvoiceFormat(format: string): TInvoiceFormat { const formatMap: Record = { 'xrechnung': 'xrechnung', 'zugferd': 'zugferd', 'factur-x': 'facturx', 'facturx': 'facturx', 'peppol': 'peppol', 'ubl': 'ubl' }; return formatMap[format.toLowerCase()] || 'xrechnung'; } /** * Calculate content hash for the invoice */ private async calculateContentHash(xmlContent: string): Promise { const hash = await plugins.smarthash.sha256FromString(xmlContent); return hash; } /** * Convert invoice to different format */ public async convertFormat( invoice: IInvoice, targetFormat: TInvoiceFormat ): Promise { try { // Load from existing XML const einvoice = await plugins.einvoice.EInvoice.fromXml(invoice.xmlContent!); // Convert to target format (takes ~0.6ms) const convertedXml = await einvoice.exportXml(targetFormat as any); return convertedXml; } catch (error) { this.logger.log('error', `Failed to convert invoice format: ${error}`); throw new Error(`Format conversion failed: ${error.message}`); } } /** * Generate invoice from internal data */ public async generateInvoice( invoiceData: Partial, format: TInvoiceFormat ): Promise<{ xml: string; pdf?: Buffer }> { try { // Create a new invoice instance const einvoice = new plugins.einvoice.EInvoice(); // Set invoice data const businessTerms = this.mapToBusinessTerms(invoiceData); Object.assign(einvoice, businessTerms); // Generate XML in requested format const xml = await einvoice.exportXml(format as any); // Generate PDF if ZUGFeRD or Factur-X let pdf: Buffer | undefined; if (format === 'zugferd' || format === 'facturx') { // Access the pdf property if it exists if (einvoice.pdf && einvoice.pdf.buffer) { pdf = Buffer.from(einvoice.pdf.buffer); } } return { xml, pdf }; } catch (error) { this.logger.log('error', `Failed to generate invoice: ${error}`); throw new Error(`Invoice generation failed: ${error.message}`); } } /** * Map internal invoice to EN16931 Business Terms */ private mapToBusinessTerms(invoice: Partial): any { return { BT1_InvoiceNumber: invoice.invoiceNumber, BT2_IssueDate: invoice.issueDate?.toISOString(), BT3_InvoiceTypeCode: invoice.invoiceTypeCode || '380', BT5_CurrencyCode: invoice.currencyCode || 'EUR', BT7_TaxPointDate: invoice.taxPointDate?.toISOString(), BT9_PaymentDueDate: invoice.paymentDueDate?.toISOString(), // Map other Business Terms... // This would be a comprehensive mapping in production }; } }