import * as plugins from '../plugins.js'; import * as xmldom from 'xmldom'; import { BaseDecoder } from './base.decoder.js'; /** * A decoder specifically for XInvoice/XRechnung format. * XRechnung is the German implementation of the European standard EN16931 * for electronic invoices to the German public sector. */ export class XInvoiceDecoder extends BaseDecoder { private xmlDoc: Document | null = null; private namespaces: { [key: string]: string } = { cbc: 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2', cac: 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2', ubl: 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2' }; constructor(xmlString: string) { super(xmlString); // Parse XML to DOM try { const parser = new xmldom.DOMParser(); this.xmlDoc = parser.parseFromString(this.xmlString, 'text/xml'); // Try to detect if this is actually UBL (which XRechnung is based on) if (this.xmlString.includes('oasis:names:specification:ubl')) { // Set up appropriate namespaces this.setupNamespaces(); } } catch (error) { console.error('Error parsing XInvoice XML:', error); } } /** * Set up namespaces from the document */ private setupNamespaces(): void { if (!this.xmlDoc) return; // Try to extract namespaces from the document const root = this.xmlDoc.documentElement; if (root) { // Look for common UBL namespaces for (let i = 0; i < root.attributes.length; i++) { const attr = root.attributes[i]; if (attr.name.startsWith('xmlns:')) { const prefix = attr.name.substring(6); this.namespaces[prefix] = attr.value; } } } } /** * Extract element text by tag name with namespace awareness */ private getElementText(tagName: string): string { if (!this.xmlDoc) { return ''; } try { // Handle namespace prefixes if (tagName.includes(':')) { const [nsPrefix, localName] = tagName.split(':'); // Find elements with this tag name const elements = this.xmlDoc.getElementsByTagNameNS(this.namespaces[nsPrefix] || '', localName); if (elements.length > 0) { return elements[0].textContent || ''; } } // Fallback to direct tag name lookup const elements = this.xmlDoc.getElementsByTagName(tagName); if (elements.length > 0) { return elements[0].textContent || ''; } return ''; } catch (error) { console.error(`Error extracting XInvoice element ${tagName}:`, error); return ''; } } /** * Converts XInvoice/XRechnung XML to a structured letter object */ public async getLetterData(): Promise { try { // Extract invoice ID - typically in cbc:ID or Invoice/cbc:ID let invoiceId = this.getElementText('cbc:ID'); if (!invoiceId) { invoiceId = this.getElementText('Invoice/cbc:ID') || 'Unknown'; } // Extract invoice issue date const issueDateStr = this.getElementText('cbc:IssueDate') || ''; const issueDate = issueDateStr ? new Date(issueDateStr).getTime() : Date.now(); // Extract seller information const sellerName = this.getElementText('cac:AccountingSupplierParty/cac:Party/cac:PartyName/cbc:Name') || this.getElementText('cac:SellerSupplierParty/cac:Party/cac:PartyName/cbc:Name') || 'Unknown Seller'; // Extract seller address const sellerStreet = this.getElementText('cac:AccountingSupplierParty/cac:Party/cac:PostalAddress/cbc:StreetName') || 'Unknown'; const sellerCity = this.getElementText('cac:AccountingSupplierParty/cac:Party/cac:PostalAddress/cbc:CityName') || 'Unknown'; const sellerPostcode = this.getElementText('cac:AccountingSupplierParty/cac:Party/cac:PostalAddress/cbc:PostalZone') || 'Unknown'; const sellerCountry = this.getElementText('cac:AccountingSupplierParty/cac:Party/cac:PostalAddress/cac:Country/cbc:IdentificationCode') || 'Unknown'; // Extract buyer information const buyerName = this.getElementText('cac:AccountingCustomerParty/cac:Party/cac:PartyName/cbc:Name') || this.getElementText('cac:BuyerCustomerParty/cac:Party/cac:PartyName/cbc:Name') || 'Unknown Buyer'; // Create seller contact const seller: plugins.tsclass.business.TContact = { name: sellerName, type: 'company', description: sellerName, address: { streetName: sellerStreet, houseNumber: '0', city: sellerCity, country: sellerCountry, postalCode: sellerPostcode, }, registrationDetails: { vatId: this.getElementText('cac:AccountingSupplierParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID') || 'Unknown', registrationId: this.getElementText('cac:AccountingSupplierParty/cac:Party/cac:PartyLegalEntity/cbc:CompanyID') || 'Unknown', registrationName: sellerName }, foundedDate: { year: 2000, month: 1, day: 1 }, closedDate: { year: 9999, month: 12, day: 31 }, status: 'active' }; // Create buyer contact const buyer: plugins.tsclass.business.TContact = { name: buyerName, type: 'company', description: buyerName, address: { streetName: 'Unknown', houseNumber: '0', city: 'Unknown', country: 'Unknown', postalCode: 'Unknown', }, registrationDetails: { vatId: this.getElementText('cac:AccountingCustomerParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID') || 'Unknown', registrationId: this.getElementText('cac:AccountingCustomerParty/cac:Party/cac:PartyLegalEntity/cbc:CompanyID') || 'Unknown', registrationName: buyerName }, foundedDate: { year: 2000, month: 1, day: 1 }, closedDate: { year: 9999, month: 12, day: 31 }, status: 'active' }; // Extract invoice type let invoiceType = 'debitnote'; const typeCode = this.getElementText('cbc:InvoiceTypeCode'); if (typeCode === '380') { invoiceType = 'debitnote'; // Standard invoice } else if (typeCode === '381') { invoiceType = 'creditnote'; // Credit note } // Create invoice data const invoiceData: plugins.tsclass.finance.IInvoice = { id: invoiceId, status: null, type: invoiceType as 'debitnote' | 'creditnote', billedBy: seller, billedTo: buyer, deliveryDate: issueDate, dueInDays: 30, periodOfPerformance: null, printResult: null, currency: (this.getElementText('cbc:DocumentCurrencyCode') || 'EUR') as plugins.tsclass.finance.TCurrency, notes: [], items: this.extractInvoiceItems(), reverseCharge: false, }; // Return a letter return { versionInfo: { type: 'draft', version: '1.0.0', }, type: 'invoice', date: issueDate, subject: `XInvoice: ${invoiceId}`, from: seller, to: buyer, content: { invoiceData: invoiceData, textData: null, timesheetData: null, contractData: null, }, needsCoverSheet: false, objectActions: [], pdf: null, incidenceId: null, language: null, legalContact: null, logoUrl: null, pdfAttachments: null, accentColor: null, }; } catch (error) { console.error('Error converting XInvoice XML to letter data:', error); return this.createDefaultLetter(); } } /** * Extracts invoice items from XInvoice document */ private extractInvoiceItems(): plugins.tsclass.finance.IInvoiceItem[] { if (!this.xmlDoc) { return [ { name: 'Unknown Item', unitQuantity: 1, unitNetPrice: 0, vatPercentage: 0, position: 0, unitType: 'units', } ]; } try { const items: plugins.tsclass.finance.IInvoiceItem[] = []; // Get all invoice line elements const lines = this.xmlDoc.getElementsByTagName('cac:InvoiceLine'); if (!lines || lines.length === 0) { // Fallback to a default item return [ { name: 'Item from XInvoice XML', unitQuantity: 1, unitNetPrice: 0, vatPercentage: 0, position: 0, unitType: 'units', } ]; } // Process each line for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Extract item details let name = ''; let quantity = 1; let price = 0; let vatRate = 0; // Find description element const descElements = line.getElementsByTagName('cbc:Description'); if (descElements.length > 0) { name = descElements[0].textContent || ''; } // Fallback to item name if description is empty if (!name) { const itemNameElements = line.getElementsByTagName('cbc:Name'); if (itemNameElements.length > 0) { name = itemNameElements[0].textContent || ''; } } // Find quantity const quantityElements = line.getElementsByTagName('cbc:InvoicedQuantity'); if (quantityElements.length > 0) { const quantityText = quantityElements[0].textContent || '1'; quantity = parseFloat(quantityText) || 1; } // Find price const priceElements = line.getElementsByTagName('cbc:PriceAmount'); if (priceElements.length > 0) { const priceText = priceElements[0].textContent || '0'; price = parseFloat(priceText) || 0; } // Find VAT rate - this is a bit more complex in UBL/XRechnung const taxCategoryElements = line.getElementsByTagName('cac:ClassifiedTaxCategory'); if (taxCategoryElements.length > 0) { const rateElements = taxCategoryElements[0].getElementsByTagName('cbc:Percent'); if (rateElements.length > 0) { const rateText = rateElements[0].textContent || '0'; vatRate = parseFloat(rateText) || 0; } } // Add the item to the list items.push({ name: name || `Item ${i+1}`, unitQuantity: quantity, unitNetPrice: price, vatPercentage: vatRate, position: i, unitType: 'units', }); } return items.length > 0 ? items : [ { name: 'Item from XInvoice XML', unitQuantity: 1, unitNetPrice: 0, vatPercentage: 0, position: 0, unitType: 'units', } ]; } catch (error) { console.error('Error extracting XInvoice items:', error); return [ { name: 'Error extracting items', unitQuantity: 1, unitNetPrice: 0, vatPercentage: 0, position: 0, unitType: 'units', } ]; } } }