import * as plugins from './plugins.js'; /** * A class to convert a given ILetter with invoice data * into a minimal Factur-X / ZUGFeRD / EN16931-style XML. */ export class ZugferdXmlEncoder { constructor() {} public createZugferdXml(letterArg: plugins.tsclass.business.ILetter): string { // 1) Get your "SmartXml" or "xmlbuilder2" instance 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.IContact = invoice.billedBy; const billedTo: plugins.tsclass.business.IContact = invoice.billedTo; // 2) Start building the document const doc = smartxmlInstance .create({ version: '1.0', encoding: 'UTF-8' }) .ele('rsm:CrossIndustryInvoice', { 'xmlns:rsm': 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100', 'xmlns:udt': 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100', 'xmlns:qdt': 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100', 'xmlns:ram': 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100' }); // 3) Exchanged Document Context doc.ele('rsm:ExchangedDocumentContext') .ele('ram:TestIndicator') .ele('udt:Indicator') .txt(this.isDraft() ? 'true' : 'false') .up() .up() .up(); // // 4) Exchanged Document (Invoice Header Info) const exchangedDoc = doc.ele('rsm:ExchangedDocument'); exchangedDoc.ele('ram:ID').txt(invoice.id).up(); exchangedDoc .ele('ram:TypeCode') // Usually: '380' = commercial invoice, '381' = credit note .txt(invoice.type === 'creditnote' ? '381' : '380') .up(); exchangedDoc .ele('ram:IssueDateTime') .ele('udt:DateTimeString', { format: '102' }) // Format 'YYYYMMDD' or 'YYYY-MM-DD'? Depending on standard .txt(this.formatDate(this.letter.date, 'yyyyMMdd')) .up() .up(); exchangedDoc.up(); // // 5) Supply Chain Trade Transaction const supplyChainEle = doc.ele('rsm:SupplyChainTradeTransaction'); // 5.1) Included Supply Chain Trade Line Items invoice.items.forEach((item) => { const lineItemEle = supplyChainEle.ele('ram:IncludedSupplyChainTradeLineItem'); lineItemEle.ele('ram:SpecifiedTradeProduct') .ele('ram:Name') .txt(item.name) .up() .up(); // lineItemEle.ele('ram:SpecifiedLineTradeAgreement') .ele('ram:GrossPriceProductTradePrice') .ele('ram:ChargeAmount') .txt(item.unitNetPrice.toFixed(2)) .up() .up() .up(); // lineItemEle.ele('ram:SpecifiedLineTradeDelivery') .ele('ram:BilledQuantity', { '@unitCode': this.mapUnitType(item.unitType) }) .txt(item.unitQuantity.toString()) .up() .up(); // lineItemEle.ele('ram:SpecifiedLineTradeSettlement') .ele('ram:ApplicableTradeTax') .ele('ram:RateApplicablePercent') .txt(item.vatPercentage.toFixed(2)) .up() .up() .ele('ram:SpecifiedTradeSettlementLineMonetarySummation') .ele('ram:LineTotalAmount') .txt( ( item.unitQuantity * item.unitNetPrice * (1 + item.vatPercentage / 100) ).toFixed(2) ) .up() .up() .up(); // }); // 5.2) Applicable Header Trade Agreement const headerTradeAgreementEle = supplyChainEle.ele('ram:ApplicableHeaderTradeAgreement'); // Seller const sellerPartyEle = headerTradeAgreementEle.ele('ram:SellerTradeParty'); sellerPartyEle.ele('ram:Name').txt(billedBy.name).up(); // Example: If it's a company, put company name, etc. const sellerAddressEle = sellerPartyEle.ele('ram:PostalTradeAddress'); sellerAddressEle.ele('ram:PostcodeCode').txt(billedBy.address.postalCode).up(); sellerAddressEle.ele('ram:LineOne').txt(billedBy.address.streetName).up(); sellerAddressEle.ele('ram:CityName').txt(billedBy.address.city).up(); // Typically you'd include 'ram:CountryID' with ISO2 code, e.g. "DE" sellerAddressEle.up(); // sellerPartyEle.up(); // // Buyer const buyerPartyEle = headerTradeAgreementEle.ele('ram:BuyerTradeParty'); buyerPartyEle.ele('ram:Name').txt(billedTo.name).up(); const buyerAddressEle = buyerPartyEle.ele('ram:PostalTradeAddress'); buyerAddressEle.ele('ram:PostcodeCode').txt(billedTo.address.postalCode).up(); buyerAddressEle.ele('ram:LineOne').txt(billedTo.address.streetName).up(); buyerAddressEle.ele('ram:CityName').txt(billedTo.address.city).up(); buyerAddressEle.up(); // buyerPartyEle.up(); // headerTradeAgreementEle.up(); // // 5.3) Applicable Header Trade Delivery const headerTradeDeliveryEle = supplyChainEle.ele('ram:ApplicableHeaderTradeDelivery'); const actualDeliveryEle = headerTradeDeliveryEle.ele('ram:ActualDeliverySupplyChainEvent'); const occurrenceEle = actualDeliveryEle.ele('ram:OccurrenceDateTime') .ele('udt:DateTimeString', { format: '102' }); const deliveryDate = invoice.deliveryDate || this.letter.date; occurrenceEle.txt(this.formatDate(deliveryDate, 'yyyyMMdd')).up(); actualDeliveryEle.up(); // headerTradeDeliveryEle.up(); // // 5.4) Applicable Header Trade Settlement const headerTradeSettlementEle = supplyChainEle.ele('ram:ApplicableHeaderTradeSettlement'); // Tax currency code, doc currency code, etc. headerTradeSettlementEle.ele('ram:InvoiceCurrencyCode').txt(invoice.currency).up(); // Example single tax breakdown const tradeTaxEle = headerTradeSettlementEle.ele('ram:ApplicableTradeTax'); tradeTaxEle.ele('ram:TypeCode').txt('VAT').up(); tradeTaxEle.ele('ram:CalculatedAmount').txt(this.sumAllVat(invoice).toFixed(2)).up(); tradeTaxEle .ele('ram:RateApplicablePercent') .txt(this.extractMainVatRate(invoice.items).toFixed(2)) .up(); tradeTaxEle.up(); // // Payment Terms const paymentTermsEle = headerTradeSettlementEle.ele('ram:SpecifiedTradePaymentTerms'); paymentTermsEle.ele('ram:Description').txt(`Payment due in ${invoice.dueInDays} days.`).up(); paymentTermsEle.up(); // // Monetary Summation const monetarySummationEle = headerTradeSettlementEle.ele('ram:SpecifiedTradeSettlementHeaderMonetarySummation'); monetarySummationEle .ele('ram:LineTotalAmount') .txt(this.calcLineTotalNet(invoice).toFixed(2)) .up(); monetarySummationEle .ele('ram:TaxTotalAmount') .txt(this.sumAllVat(invoice).toFixed(2)) .up(); monetarySummationEle .ele('ram:GrandTotalAmount') .txt(this.calcGrandTotal(invoice).toFixed(2)) .up(); monetarySummationEle.up(); // headerTradeSettlementEle.up(); // supplyChainEle.up(); // doc.up(); // // 6) Return the final XML string return doc.end({ prettyPrint: true }); } /** * Helper: Determine if the letter is in draft or final. */ private isDraft(): boolean { return this.letter.versionInfo?.type === 'draft'; } /** * Helper: Format date to certain patterns (very minimal example). * e.g. 'yyyyMMdd' => '20231231' */ private formatDate(timestampMs: number, pattern: 'yyyyMMdd'): string { const date = new Date(timestampMs); const yyyy = date.getFullYear(); const mm = String(date.getMonth() + 1).padStart(2, '0'); const dd = String(date.getDate()).padStart(2, '0'); return `${yyyy}${mm}${dd}`; } /** * Helper: Map your custom 'unitType' to an ISO code or similar. */ private mapUnitType(unitType: string): string { switch (unitType.toLowerCase()) { case 'hour': return 'HUR'; case 'piece': return 'C62'; default: return 'C62'; // fallback } } /** * Example: Sum all VAT amounts from items. */ private sumAllVat(invoice: plugins.tsclass.finance.IInvoice): number { return invoice.items.reduce((acc, item) => { const net = item.unitNetPrice * item.unitQuantity; const vat = net * (item.vatPercentage / 100); return acc + vat; }, 0); } /** * Example: Extract main (or highest) VAT rate from items as representative. * In reality, you might list multiple 'ApplicableTradeTax' blocks by group. */ private extractMainVatRate(items: plugins.tsclass.finance.IInvoiceItem[]): number { let max = 0; items.forEach((item) => { if (item.vatPercentage > max) max = item.vatPercentage; }); return max; } /** * Example: Sum net amounts (without VAT). */ private calcLineTotalNet(invoice: plugins.tsclass.finance.IInvoice): number { return invoice.items.reduce((acc, item) => { const net = item.unitNetPrice * item.unitQuantity; return acc + net; }, 0); } /** * Example: net + VAT = grand total */ private calcGrandTotal(invoice: plugins.tsclass.finance.IInvoice): number { const net = this.calcLineTotalNet(invoice); const vat = this.sumAllVat(invoice); return net + vat; } }