import { CIIBaseEncoder } from '../cii.encoder.js'; import type { TInvoice, TCreditNote, TDebitNote } from '../../../interfaces/common.js'; import { ZUGFERD_PROFILE_IDS } from './zugferd.types.js'; import { CIIProfile } from '../cii.types.js'; import { DOMParser, XMLSerializer } from '../../../plugins.js'; /** * Encoder for ZUGFeRD invoice format */ export class ZUGFeRDEncoder extends CIIBaseEncoder { constructor() { super(); // Set default profile to BASIC this.profile = CIIProfile.BASIC; } /** * Encodes a credit note into ZUGFeRD XML * @param creditNote Credit note to encode * @returns ZUGFeRD XML string */ protected async encodeCreditNote(creditNote: TCreditNote): Promise { // Create base XML const xmlDoc = this.createBaseXml(); // Set document type code to credit note (381) this.setDocumentTypeCode(xmlDoc, '381'); // Add common invoice data this.addCommonInvoiceData(xmlDoc, creditNote); // Serialize to string return new XMLSerializer().serializeToString(xmlDoc); } /** * Encodes a debit note (invoice) into ZUGFeRD XML * @param debitNote Debit note to encode * @returns ZUGFeRD XML string */ protected async encodeDebitNote(debitNote: TDebitNote): Promise { // Create base XML const xmlDoc = this.createBaseXml(); // Set document type code to invoice (380) this.setDocumentTypeCode(xmlDoc, '380'); // Add common invoice data this.addCommonInvoiceData(xmlDoc, debitNote); // Serialize to string return new XMLSerializer().serializeToString(xmlDoc); } /** * Creates a base ZUGFeRD XML document * @returns XML document with basic structure */ private createBaseXml(): Document { // Create XML document from template const xmlString = this.createXmlRoot(); const doc = new DOMParser().parseFromString(xmlString, 'application/xml'); // Add ZUGFeRD profile this.addProfile(doc); return doc; } /** * Adds ZUGFeRD profile information to the XML document * @param doc XML document */ private addProfile(doc: Document): void { // Get root element const root = doc.documentElement; // Create context element if it doesn't exist let contextElement = root.getElementsByTagName('rsm:ExchangedDocumentContext')[0]; if (!contextElement) { contextElement = doc.createElement('rsm:ExchangedDocumentContext'); root.appendChild(contextElement); } // Create guideline parameter element const guidelineElement = doc.createElement('ram:GuidelineSpecifiedDocumentContextParameter'); contextElement.appendChild(guidelineElement); // Add ID element with profile const idElement = doc.createElement('ram:ID'); // Set profile based on the selected profile let profileId = ZUGFERD_PROFILE_IDS.BASIC; if (this.profile === CIIProfile.COMFORT) { profileId = ZUGFERD_PROFILE_IDS.COMFORT; } else if (this.profile === CIIProfile.EXTENDED) { profileId = ZUGFERD_PROFILE_IDS.EXTENDED; } idElement.textContent = profileId; guidelineElement.appendChild(idElement); } /** * Sets the document type code in the XML document * @param doc XML document * @param typeCode Document type code (380 for invoice, 381 for credit note) */ private setDocumentTypeCode(doc: Document, typeCode: string): void { // Get root element const root = doc.documentElement; // Create document element if it doesn't exist let documentElement = root.getElementsByTagName('rsm:ExchangedDocument')[0]; if (!documentElement) { documentElement = doc.createElement('rsm:ExchangedDocument'); root.appendChild(documentElement); } // Add type code element const typeCodeElement = doc.createElement('ram:TypeCode'); typeCodeElement.textContent = typeCode; documentElement.appendChild(typeCodeElement); } /** * Adds common invoice data to the XML document * @param doc XML document * @param invoice Invoice data */ private addCommonInvoiceData(doc: Document, invoice: TInvoice): void { // Get root element const root = doc.documentElement; // Get document element or create it let documentElement = root.getElementsByTagName('rsm:ExchangedDocument')[0]; if (!documentElement) { documentElement = doc.createElement('rsm:ExchangedDocument'); root.appendChild(documentElement); } // Add ID element const idElement = doc.createElement('ram:ID'); idElement.textContent = invoice.id; documentElement.appendChild(idElement); // Add issue date element const issueDateElement = doc.createElement('ram:IssueDateTime'); const dateStringElement = doc.createElement('udt:DateTimeString'); dateStringElement.setAttribute('format', '102'); // YYYYMMDD format dateStringElement.textContent = this.formatDateYYYYMMDD(invoice.date); issueDateElement.appendChild(dateStringElement); documentElement.appendChild(issueDateElement); // Add notes if available if (invoice.notes && invoice.notes.length > 0) { for (const note of invoice.notes) { const noteElement = doc.createElement('ram:IncludedNote'); const contentElement = doc.createElement('ram:Content'); contentElement.textContent = note; noteElement.appendChild(contentElement); documentElement.appendChild(noteElement); } } // Create transaction element if it doesn't exist let transactionElement = root.getElementsByTagName('rsm:SupplyChainTradeTransaction')[0]; if (!transactionElement) { transactionElement = doc.createElement('rsm:SupplyChainTradeTransaction'); root.appendChild(transactionElement); } // Add agreement section with seller and buyer this.addAgreementSection(doc, transactionElement, invoice); // Add delivery section this.addDeliverySection(doc, transactionElement, invoice); // Add settlement section with payment terms and totals this.addSettlementSection(doc, transactionElement, invoice); // Add line items this.addLineItems(doc, transactionElement, invoice); } /** * Adds agreement section with seller and buyer information * @param doc XML document * @param transactionElement Transaction element * @param invoice Invoice data */ private addAgreementSection(doc: Document, transactionElement: Element, invoice: TInvoice): void { // Create agreement element const agreementElement = doc.createElement('ram:ApplicableHeaderTradeAgreement'); transactionElement.appendChild(agreementElement); // Add buyer reference if available if (invoice.buyerReference) { const buyerRefElement = doc.createElement('ram:BuyerReference'); buyerRefElement.textContent = invoice.buyerReference; agreementElement.appendChild(buyerRefElement); } // Add seller const sellerElement = doc.createElement('ram:SellerTradeParty'); this.addPartyInfo(doc, sellerElement, invoice.from); // Add seller electronic address if available if (invoice.electronicAddress && invoice.from.type === 'company') { const contactElement = doc.createElement('ram:DefinedTradeContact'); const uriElement = doc.createElement('ram:URIID'); uriElement.setAttribute('schemeID', invoice.electronicAddress.scheme); uriElement.textContent = invoice.electronicAddress.value; contactElement.appendChild(uriElement); sellerElement.appendChild(contactElement); } agreementElement.appendChild(sellerElement); // Add buyer const buyerElement = doc.createElement('ram:BuyerTradeParty'); this.addPartyInfo(doc, buyerElement, invoice.to); agreementElement.appendChild(buyerElement); } /** * Adds party information to an element * @param doc XML document * @param partyElement Party element * @param party Party data */ private addPartyInfo(doc: Document, partyElement: Element, party: any): void { // Add name const nameElement = doc.createElement('ram:Name'); nameElement.textContent = party.name; partyElement.appendChild(nameElement); // Add postal address const addressElement = doc.createElement('ram:PostalTradeAddress'); // Add address line 1 (street) if (party.address.streetName) { const line1Element = doc.createElement('ram:LineOne'); line1Element.textContent = party.address.streetName; addressElement.appendChild(line1Element); } // Add address line 2 (house number) if present if (party.address.houseNumber && party.address.houseNumber !== '0') { const line2Element = doc.createElement('ram:LineTwo'); line2Element.textContent = party.address.houseNumber; addressElement.appendChild(line2Element); } // Add postal code if (party.address.postalCode) { const postalCodeElement = doc.createElement('ram:PostcodeCode'); postalCodeElement.textContent = party.address.postalCode; addressElement.appendChild(postalCodeElement); } // Add city if (party.address.city) { const cityElement = doc.createElement('ram:CityName'); cityElement.textContent = party.address.city; addressElement.appendChild(cityElement); } // Add country if (party.address.country || party.address.countryCode) { const countryElement = doc.createElement('ram:CountryID'); countryElement.textContent = party.address.countryCode || party.address.country; addressElement.appendChild(countryElement); } partyElement.appendChild(addressElement); // Add VAT ID if available if (party.registrationDetails && party.registrationDetails.vatId) { const taxRegistrationElement = doc.createElement('ram:SpecifiedTaxRegistration'); const taxIdElement = doc.createElement('ram:ID'); taxIdElement.setAttribute('schemeID', 'VA'); taxIdElement.textContent = party.registrationDetails.vatId; taxRegistrationElement.appendChild(taxIdElement); partyElement.appendChild(taxRegistrationElement); } // Add registration ID if available if (party.registrationDetails && party.registrationDetails.registrationId) { const regRegistrationElement = doc.createElement('ram:SpecifiedTaxRegistration'); const regIdElement = doc.createElement('ram:ID'); regIdElement.setAttribute('schemeID', 'FC'); regIdElement.textContent = party.registrationDetails.registrationId; regRegistrationElement.appendChild(regIdElement); partyElement.appendChild(regRegistrationElement); } } /** * Adds delivery section with delivery information * @param doc XML document * @param transactionElement Transaction element * @param invoice Invoice data */ private addDeliverySection(doc: Document, transactionElement: Element, invoice: TInvoice): void { // Create delivery element const deliveryElement = doc.createElement('ram:ApplicableHeaderTradeDelivery'); transactionElement.appendChild(deliveryElement); // Add delivery date if available if (invoice.deliveryDate) { const deliveryDateElement = doc.createElement('ram:ActualDeliverySupplyChainEvent'); const occurrenceDateElement = doc.createElement('ram:OccurrenceDateTime'); const dateStringElement = doc.createElement('udt:DateTimeString'); dateStringElement.setAttribute('format', '102'); // YYYYMMDD format dateStringElement.textContent = this.formatDateYYYYMMDD(invoice.deliveryDate); occurrenceDateElement.appendChild(dateStringElement); deliveryDateElement.appendChild(occurrenceDateElement); deliveryElement.appendChild(deliveryDateElement); } // Add period of performance if available if (invoice.periodOfPerformance) { const periodElement = doc.createElement('ram:BillingSpecifiedPeriod'); // Start date if (invoice.periodOfPerformance.from) { const startDateElement = doc.createElement('ram:StartDateTime'); const startDateStringElement = doc.createElement('udt:DateTimeString'); startDateStringElement.setAttribute('format', '102'); // YYYYMMDD format startDateStringElement.textContent = this.formatDateYYYYMMDD(invoice.periodOfPerformance.from); startDateElement.appendChild(startDateStringElement); periodElement.appendChild(startDateElement); } // End date if (invoice.periodOfPerformance.to) { const endDateElement = doc.createElement('ram:EndDateTime'); const endDateStringElement = doc.createElement('udt:DateTimeString'); endDateStringElement.setAttribute('format', '102'); // YYYYMMDD format endDateStringElement.textContent = this.formatDateYYYYMMDD(invoice.periodOfPerformance.to); endDateElement.appendChild(endDateStringElement); periodElement.appendChild(endDateElement); } deliveryElement.appendChild(periodElement); } } /** * Adds settlement section with payment terms and totals * @param doc XML document * @param transactionElement Transaction element * @param invoice Invoice data */ private addSettlementSection(doc: Document, transactionElement: Element, invoice: TInvoice): void { // Create settlement element const settlementElement = doc.createElement('ram:ApplicableHeaderTradeSettlement'); transactionElement.appendChild(settlementElement); // Add currency const currencyElement = doc.createElement('ram:InvoiceCurrencyCode'); currencyElement.textContent = invoice.currency; settlementElement.appendChild(currencyElement); // Add payment terms const paymentTermsElement = doc.createElement('ram:SpecifiedTradePaymentTerms'); // Add payment instructions if available if (invoice.paymentOptions) { // Add payment instructions as description - this is generic enough to work with any payment type const descriptionElement = doc.createElement('ram:Description'); descriptionElement.textContent = `Due in ${invoice.dueInDays} days. ${invoice.paymentOptions.description || ''}`; paymentTermsElement.appendChild(descriptionElement); } // Add due date const dueDateElement = doc.createElement('ram:DueDateDateTime'); const dateStringElement = doc.createElement('udt:DateTimeString'); dateStringElement.setAttribute('format', '102'); // YYYYMMDD format // Calculate due date const dueDate = new Date(invoice.date); dueDate.setDate(dueDate.getDate() + invoice.dueInDays); dateStringElement.textContent = this.formatDateYYYYMMDD(dueDate.getTime()); dueDateElement.appendChild(dateStringElement); paymentTermsElement.appendChild(dueDateElement); settlementElement.appendChild(paymentTermsElement); // Add payment means if available (using a generic approach) if (invoice.paymentOptions) { const paymentMeansElement = doc.createElement('ram:SpecifiedTradeSettlementPaymentMeans'); // Payment type code (58 for SEPA transfer as default) const typeCodeElement = doc.createElement('ram:TypeCode'); typeCodeElement.textContent = '58'; paymentMeansElement.appendChild(typeCodeElement); // Description (optional) if (invoice.paymentOptions.description) { const infoElement = doc.createElement('ram:Information'); infoElement.textContent = invoice.paymentOptions.description; paymentMeansElement.appendChild(infoElement); } // If payment details are available in a standard format if (invoice.paymentOptions.sepaConnection.iban) { // Payee account const payeeAccountElement = doc.createElement('ram:PayeePartyCreditorFinancialAccount'); const ibanElement = doc.createElement('ram:IBANID'); ibanElement.textContent = invoice.paymentOptions.sepaConnection.iban; payeeAccountElement.appendChild(ibanElement); paymentMeansElement.appendChild(payeeAccountElement); // Payee financial institution if BIC available if (invoice.paymentOptions.sepaConnection.bic) { const institutionElement = doc.createElement('ram:PayeeSpecifiedCreditorFinancialInstitution'); const bicElement = doc.createElement('ram:BICID'); bicElement.textContent = invoice.paymentOptions.sepaConnection.bic; institutionElement.appendChild(bicElement); paymentMeansElement.appendChild(institutionElement); } } settlementElement.appendChild(paymentMeansElement); } // Add tax details this.addTaxDetails(doc, settlementElement, invoice); // Add totals this.addMonetarySummation(doc, settlementElement, invoice); } /** * Adds tax details to the settlement section * @param doc XML document * @param settlementElement Settlement element * @param invoice Invoice data */ private addTaxDetails(doc: Document, settlementElement: Element, invoice: TInvoice): void { // Calculate tax categories and totals const taxCategories = new Map(); // Map of VAT rate to net amount // Calculate from items if (invoice.items) { for (const item of invoice.items) { const itemNetAmount = item.unitNetPrice * item.unitQuantity; const vatRate = item.vatPercentage; const currentAmount = taxCategories.get(vatRate) || 0; taxCategories.set(vatRate, currentAmount + itemNetAmount); } } // Add each tax category for (const [rate, baseAmount] of taxCategories.entries()) { const taxElement = doc.createElement('ram:ApplicableTradeTax'); // Calculate tax amount const taxAmount = baseAmount * (rate / 100); // Add calculated amount const calculatedAmountElement = doc.createElement('ram:CalculatedAmount'); calculatedAmountElement.textContent = taxAmount.toFixed(2); taxElement.appendChild(calculatedAmountElement); // Add type code (VAT) const typeCodeElement = doc.createElement('ram:TypeCode'); typeCodeElement.textContent = 'VAT'; taxElement.appendChild(typeCodeElement); // Add basis amount const basisAmountElement = doc.createElement('ram:BasisAmount'); basisAmountElement.textContent = baseAmount.toFixed(2); taxElement.appendChild(basisAmountElement); // Add category code const categoryCodeElement = doc.createElement('ram:CategoryCode'); categoryCodeElement.textContent = invoice.reverseCharge ? 'AE' : 'S'; taxElement.appendChild(categoryCodeElement); // Add rate const rateElement = doc.createElement('ram:RateApplicablePercent'); rateElement.textContent = rate.toString(); taxElement.appendChild(rateElement); settlementElement.appendChild(taxElement); } } /** * Adds monetary summation to the settlement section * @param doc XML document * @param settlementElement Settlement element * @param invoice Invoice data */ private addMonetarySummation(doc: Document, settlementElement: Element, invoice: TInvoice): void { const monetarySummationElement = doc.createElement('ram:SpecifiedTradeSettlementHeaderMonetarySummation'); // Calculate totals let totalNetAmount = 0; let totalTaxAmount = 0; // Calculate from items if (invoice.items) { for (const item of invoice.items) { const itemNetAmount = item.unitNetPrice * item.unitQuantity; const itemTaxAmount = itemNetAmount * (item.vatPercentage / 100); totalNetAmount += itemNetAmount; totalTaxAmount += itemTaxAmount; } } const totalGrossAmount = totalNetAmount + totalTaxAmount; // Add line total amount const lineTotalElement = doc.createElement('ram:LineTotalAmount'); lineTotalElement.textContent = totalNetAmount.toFixed(2); monetarySummationElement.appendChild(lineTotalElement); // Add tax total amount const taxTotalElement = doc.createElement('ram:TaxTotalAmount'); taxTotalElement.textContent = totalTaxAmount.toFixed(2); taxTotalElement.setAttribute('currencyID', invoice.currency); monetarySummationElement.appendChild(taxTotalElement); // Add grand total amount const grandTotalElement = doc.createElement('ram:GrandTotalAmount'); grandTotalElement.textContent = totalGrossAmount.toFixed(2); monetarySummationElement.appendChild(grandTotalElement); // Add due payable amount const duePayableElement = doc.createElement('ram:DuePayableAmount'); duePayableElement.textContent = totalGrossAmount.toFixed(2); monetarySummationElement.appendChild(duePayableElement); settlementElement.appendChild(monetarySummationElement); } /** * Adds line items to the XML document * @param doc XML document * @param transactionElement Transaction element * @param invoice Invoice data */ private addLineItems(doc: Document, transactionElement: Element, invoice: TInvoice): void { // Add each line item if (invoice.items) { for (const item of invoice.items) { // Create line item element const lineItemElement = doc.createElement('ram:IncludedSupplyChainTradeLineItem'); // Add line ID const lineIdElement = doc.createElement('ram:AssociatedDocumentLineDocument'); const lineIdValueElement = doc.createElement('ram:LineID'); lineIdValueElement.textContent = item.position.toString(); lineIdElement.appendChild(lineIdValueElement); lineItemElement.appendChild(lineIdElement); // Add product information const productElement = doc.createElement('ram:SpecifiedTradeProduct'); // Add name const nameElement = doc.createElement('ram:Name'); nameElement.textContent = item.name; productElement.appendChild(nameElement); // Add article number if available if (item.articleNumber) { const articleNumberElement = doc.createElement('ram:SellerAssignedID'); articleNumberElement.textContent = item.articleNumber; productElement.appendChild(articleNumberElement); } lineItemElement.appendChild(productElement); // Add agreement information (price) const agreementElement = doc.createElement('ram:SpecifiedLineTradeAgreement'); const priceElement = doc.createElement('ram:NetPriceProductTradePrice'); const chargeAmountElement = doc.createElement('ram:ChargeAmount'); chargeAmountElement.textContent = item.unitNetPrice.toFixed(2); priceElement.appendChild(chargeAmountElement); agreementElement.appendChild(priceElement); lineItemElement.appendChild(agreementElement); // Add delivery information (quantity) const deliveryElement = doc.createElement('ram:SpecifiedLineTradeDelivery'); const quantityElement = doc.createElement('ram:BilledQuantity'); quantityElement.textContent = item.unitQuantity.toString(); quantityElement.setAttribute('unitCode', item.unitType); deliveryElement.appendChild(quantityElement); lineItemElement.appendChild(deliveryElement); // Add settlement information (tax) const settlementElement = doc.createElement('ram:SpecifiedLineTradeSettlement'); // Add tax information const taxElement = doc.createElement('ram:ApplicableTradeTax'); // Add tax type code const taxTypeCodeElement = doc.createElement('ram:TypeCode'); taxTypeCodeElement.textContent = 'VAT'; taxElement.appendChild(taxTypeCodeElement); // Add tax category code const taxCategoryCodeElement = doc.createElement('ram:CategoryCode'); taxCategoryCodeElement.textContent = invoice.reverseCharge ? 'AE' : 'S'; taxElement.appendChild(taxCategoryCodeElement); // Add tax rate const taxRateElement = doc.createElement('ram:RateApplicablePercent'); taxRateElement.textContent = item.vatPercentage.toString(); taxElement.appendChild(taxRateElement); settlementElement.appendChild(taxElement); // Add monetary summation const monetarySummationElement = doc.createElement('ram:SpecifiedLineTradeSettlementMonetarySummation'); // Calculate item total const itemNetAmount = item.unitNetPrice * item.unitQuantity; // Add line total amount const lineTotalElement = doc.createElement('ram:LineTotalAmount'); lineTotalElement.textContent = itemNetAmount.toFixed(2); monetarySummationElement.appendChild(lineTotalElement); settlementElement.appendChild(monetarySummationElement); lineItemElement.appendChild(settlementElement); // Add line item to transaction transactionElement.appendChild(lineItemElement); } } } /** * Formats a date as YYYYMMDD * @param timestamp Timestamp to format * @returns Formatted date string */ private formatDateYYYYMMDD(timestamp: number): string { const date = new Date(timestamp); const year = date.getFullYear(); const month = (date.getMonth() + 1).toString().padStart(2, '0'); const day = date.getDate().toString().padStart(2, '0'); return `${year}${month}${day}`; } }