import * as plugins from '../plugins.js'; /** * A class to convert a given ILetter with invoice data * into an XInvoice/XRechnung compliant XML (based on UBL). * * XRechnung is the German implementation of the European standard EN16931 * for electronic invoices to the German public sector. */ export class XInvoiceEncoder { constructor() {} /** * Creates an XInvoice compliant XML based on the provided letter data. */ public createXInvoiceXml(letterArg: plugins.tsclass.business.ILetter): string { // Use SmartXml for XML creation const smartxmlInstance = new plugins.smartxml.SmartXml(); if (!letterArg?.content?.invoiceData) { throw new Error('Letter does not contain invoice data.'); } const invoice: plugins.tsclass.finance.IInvoice = letterArg.content.invoiceData; const billedBy: plugins.tsclass.business.TContact = invoice.billedBy; const billedTo: plugins.tsclass.business.TContact = invoice.billedTo; // Create the XML document const doc = smartxmlInstance .create({ version: '1.0', encoding: 'UTF-8' }) .ele('Invoice', { 'xmlns': 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2', 'xmlns:cac': 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2', 'xmlns:cbc': 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2', 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance' }); // UBL Version ID doc.ele('cbc:UBLVersionID').txt('2.1').up(); // CustomizationID for XRechnung doc.ele('cbc:CustomizationID').txt('urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_2.0').up(); // ID - Invoice number doc.ele('cbc:ID').txt(invoice.id).up(); // Issue date const issueDate = new Date(letterArg.date); const issueDateStr = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, '0')}-${String(issueDate.getDate()).padStart(2, '0')}`; doc.ele('cbc:IssueDate').txt(issueDateStr).up(); // Due date const dueDate = new Date(letterArg.date); dueDate.setDate(dueDate.getDate() + invoice.dueInDays); const dueDateStr = `${dueDate.getFullYear()}-${String(dueDate.getMonth() + 1).padStart(2, '0')}-${String(dueDate.getDate()).padStart(2, '0')}`; doc.ele('cbc:DueDate').txt(dueDateStr).up(); // Invoice type code const invoiceTypeCode = invoice.type === 'creditnote' ? '381' : '380'; doc.ele('cbc:InvoiceTypeCode').txt(invoiceTypeCode).up(); // Note - optional invoice note if (invoice.notes && invoice.notes.length > 0) { doc.ele('cbc:Note').txt(invoice.notes[0]).up(); } // Document currency code doc.ele('cbc:DocumentCurrencyCode').txt(invoice.currency).up(); // Tax currency code - same as document currency in this case doc.ele('cbc:TaxCurrencyCode').txt(invoice.currency).up(); // Accounting supplier party (seller) const supplierParty = doc.ele('cac:AccountingSupplierParty'); const supplierPartyDetails = supplierParty.ele('cac:Party'); // Seller VAT ID if (billedBy.type === 'company' && billedBy.registrationDetails?.vatId) { const partyTaxScheme = supplierPartyDetails.ele('cac:PartyTaxScheme'); partyTaxScheme.ele('cbc:CompanyID').txt(billedBy.registrationDetails.vatId).up(); partyTaxScheme.ele('cac:TaxScheme') .ele('cbc:ID').txt('VAT').up() .up(); } // Seller name supplierPartyDetails.ele('cac:PartyName') .ele('cbc:Name').txt(billedBy.name).up() .up(); // Seller postal address const supplierAddress = supplierPartyDetails.ele('cac:PostalAddress'); supplierAddress.ele('cbc:StreetName').txt(billedBy.address.streetName).up(); if (billedBy.address.houseNumber) { supplierAddress.ele('cbc:BuildingNumber').txt(billedBy.address.houseNumber).up(); } supplierAddress.ele('cbc:CityName').txt(billedBy.address.city).up(); supplierAddress.ele('cbc:PostalZone').txt(billedBy.address.postalCode).up(); supplierAddress.ele('cac:Country') .ele('cbc:IdentificationCode').txt(billedBy.address.country || 'DE').up() .up(); // Seller contact const supplierContact = supplierPartyDetails.ele('cac:Contact'); if (billedBy.email) { supplierContact.ele('cbc:ElectronicMail').txt(billedBy.email).up(); } if (billedBy.phone) { supplierContact.ele('cbc:Telephone').txt(billedBy.phone).up(); } supplierParty.up(); // Close AccountingSupplierParty // Accounting customer party (buyer) const customerParty = doc.ele('cac:AccountingCustomerParty'); const customerPartyDetails = customerParty.ele('cac:Party'); // Buyer VAT ID if (billedTo.type === 'company' && billedTo.registrationDetails?.vatId) { const partyTaxScheme = customerPartyDetails.ele('cac:PartyTaxScheme'); partyTaxScheme.ele('cbc:CompanyID').txt(billedTo.registrationDetails.vatId).up(); partyTaxScheme.ele('cac:TaxScheme') .ele('cbc:ID').txt('VAT').up() .up(); } // Buyer name customerPartyDetails.ele('cac:PartyName') .ele('cbc:Name').txt(billedTo.name).up() .up(); // Buyer postal address const customerAddress = customerPartyDetails.ele('cac:PostalAddress'); customerAddress.ele('cbc:StreetName').txt(billedTo.address.streetName).up(); if (billedTo.address.houseNumber) { customerAddress.ele('cbc:BuildingNumber').txt(billedTo.address.houseNumber).up(); } customerAddress.ele('cbc:CityName').txt(billedTo.address.city).up(); customerAddress.ele('cbc:PostalZone').txt(billedTo.address.postalCode).up(); customerAddress.ele('cac:Country') .ele('cbc:IdentificationCode').txt(billedTo.address.country || 'DE').up() .up(); // Buyer contact if (billedTo.email || billedTo.phone) { const customerContact = customerPartyDetails.ele('cac:Contact'); if (billedTo.email) { customerContact.ele('cbc:ElectronicMail').txt(billedTo.email).up(); } if (billedTo.phone) { customerContact.ele('cbc:Telephone').txt(billedTo.phone).up(); } } customerParty.up(); // Close AccountingCustomerParty // Payment means if (billedBy.sepaConnection) { const paymentMeans = doc.ele('cac:PaymentMeans'); paymentMeans.ele('cbc:PaymentMeansCode').txt('58').up(); // 58 = SEPA credit transfer paymentMeans.ele('cbc:PaymentID').txt(invoice.id).up(); // IBAN if (billedBy.sepaConnection.iban) { const payeeAccount = paymentMeans.ele('cac:PayeeFinancialAccount'); payeeAccount.ele('cbc:ID').txt(billedBy.sepaConnection.iban).up(); // BIC if (billedBy.sepaConnection.bic) { payeeAccount.ele('cac:FinancialInstitutionBranch') .ele('cbc:ID').txt(billedBy.sepaConnection.bic).up() .up(); } } } // Payment terms const paymentTerms = doc.ele('cac:PaymentTerms'); paymentTerms.ele('cbc:Note').txt(`Payment due in ${invoice.dueInDays} days`).up(); // Tax summary // Group items by VAT rate const vatRates: { [rate: number]: plugins.tsclass.finance.IInvoiceItem[] } = {}; // Collect items by VAT rate invoice.items.forEach(item => { if (!vatRates[item.vatPercentage]) { vatRates[item.vatPercentage] = []; } vatRates[item.vatPercentage].push(item); }); // Calculate tax subtotals for each rate Object.entries(vatRates).forEach(([rate, items]) => { const taxRate = parseFloat(rate); // Calculate base amount for this rate let taxableAmount = 0; items.forEach(item => { taxableAmount += item.unitNetPrice * item.unitQuantity; }); // Calculate tax amount const taxAmount = taxableAmount * (taxRate / 100); // Create tax subtotal const taxSubtotal = doc.ele('cac:TaxTotal') .ele('cbc:TaxAmount').txt(taxAmount.toFixed(2)) .att('currencyID', invoice.currency) .up(); taxSubtotal.ele('cac:TaxSubtotal') .ele('cbc:TaxableAmount') .txt(taxableAmount.toFixed(2)) .att('currencyID', invoice.currency) .up() .ele('cbc:TaxAmount') .txt(taxAmount.toFixed(2)) .att('currencyID', invoice.currency) .up() .ele('cac:TaxCategory') .ele('cbc:ID').txt('S').up() // Standard rate .ele('cbc:Percent').txt(taxRate.toFixed(2)).up() .ele('cac:TaxScheme') .ele('cbc:ID').txt('VAT').up() .up() .up() .up(); }); // Calculate invoice totals let lineExtensionAmount = 0; let taxExclusiveAmount = 0; let taxInclusiveAmount = 0; let totalVat = 0; // Sum all items invoice.items.forEach(item => { const net = item.unitNetPrice * item.unitQuantity; const vat = net * (item.vatPercentage / 100); lineExtensionAmount += net; taxExclusiveAmount += net; totalVat += vat; }); taxInclusiveAmount = taxExclusiveAmount + totalVat; // Legal monetary total const legalMonetaryTotal = doc.ele('cac:LegalMonetaryTotal'); legalMonetaryTotal.ele('cbc:LineExtensionAmount') .txt(lineExtensionAmount.toFixed(2)) .att('currencyID', invoice.currency) .up(); legalMonetaryTotal.ele('cbc:TaxExclusiveAmount') .txt(taxExclusiveAmount.toFixed(2)) .att('currencyID', invoice.currency) .up(); legalMonetaryTotal.ele('cbc:TaxInclusiveAmount') .txt(taxInclusiveAmount.toFixed(2)) .att('currencyID', invoice.currency) .up(); legalMonetaryTotal.ele('cbc:PayableAmount') .txt(taxInclusiveAmount.toFixed(2)) .att('currencyID', invoice.currency) .up(); // Invoice lines invoice.items.forEach((item, index) => { const invoiceLine = doc.ele('cac:InvoiceLine'); invoiceLine.ele('cbc:ID').txt((index + 1).toString()).up(); // Quantity invoiceLine.ele('cbc:InvoicedQuantity') .txt(item.unitQuantity.toString()) .att('unitCode', this.mapUnitType(item.unitType)) .up(); // Line extension amount (net) const lineAmount = item.unitNetPrice * item.unitQuantity; invoiceLine.ele('cbc:LineExtensionAmount') .txt(lineAmount.toFixed(2)) .att('currencyID', invoice.currency) .up(); // Item details const itemEle = invoiceLine.ele('cac:Item'); itemEle.ele('cbc:Description').txt(item.name).up(); itemEle.ele('cbc:Name').txt(item.name).up(); // Classified tax category itemEle.ele('cac:ClassifiedTaxCategory') .ele('cbc:ID').txt('S').up() // Standard rate .ele('cbc:Percent').txt(item.vatPercentage.toFixed(2)).up() .ele('cac:TaxScheme') .ele('cbc:ID').txt('VAT').up() .up() .up(); // Price invoiceLine.ele('cac:Price') .ele('cbc:PriceAmount') .txt(item.unitNetPrice.toFixed(2)) .att('currencyID', invoice.currency) .up() .up(); }); // Return the formatted XML return doc.end({ prettyPrint: true }); } /** * Helper: Map your custom 'unitType' to an ISO code. */ private mapUnitType(unitType: string): string { switch (unitType.toLowerCase()) { case 'hour': case 'hours': return 'HUR'; case 'day': case 'days': return 'DAY'; case 'piece': case 'pieces': return 'C62'; default: return 'C62'; // fallback for unknown unit types } } }