import { UBLBaseEncoder } from '../ubl.encoder.js'; import type { TInvoice, TCreditNote, TDebitNote } from '../../../interfaces/common.js'; import { UBLDocumentType } from '../ubl.types.js'; import { DOMParser, XMLSerializer } from '../../../plugins.js'; /** * UBL Encoder implementation * Provides encoding functionality for UBL 2.1 invoice and credit note documents */ export class UBLEncoder extends UBLBaseEncoder { /** * Encodes a credit note into UBL XML * @param creditNote Credit note to encode * @returns UBL XML string */ protected async encodeCreditNote(creditNote: TCreditNote): Promise { // Create XML document from template const xmlString = this.createXmlRoot(UBLDocumentType.CREDIT_NOTE); const doc = new DOMParser().parseFromString(xmlString, 'application/xml'); // Add common document elements this.addCommonElements(doc, creditNote, UBLDocumentType.CREDIT_NOTE); // Add credit note specific data this.addCreditNoteSpecificData(doc, creditNote); // Serialize to string return new XMLSerializer().serializeToString(doc); } /** * Encodes a debit note (invoice) into UBL XML * @param debitNote Debit note to encode * @returns UBL XML string */ protected async encodeDebitNote(debitNote: TDebitNote): Promise { // Create XML document from template const xmlString = this.createXmlRoot(UBLDocumentType.INVOICE); const doc = new DOMParser().parseFromString(xmlString, 'application/xml'); // Add common document elements this.addCommonElements(doc, debitNote, UBLDocumentType.INVOICE); // Add invoice specific data this.addInvoiceSpecificData(doc, debitNote); // Serialize to string return new XMLSerializer().serializeToString(doc); } /** * Adds common document elements to both invoice and credit note * @param doc XML document * @param invoice Invoice or credit note data * @param documentType Document type (Invoice or CreditNote) */ private addCommonElements(doc: Document, invoice: TInvoice, documentType: UBLDocumentType): void { const root = doc.documentElement; // UBL Version ID (2.1 is standard for EN16931) this.appendElement(doc, root, 'cbc:UBLVersionID', '2.1'); // Customization ID - using generic UBL this.appendElement(doc, root, 'cbc:CustomizationID', 'urn:cen.eu:en16931:2017'); // Profile ID - standard billing this.appendElement(doc, root, 'cbc:ProfileID', 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0'); // ID this.appendElement(doc, root, 'cbc:ID', invoice.id); // Issue Date this.appendElement(doc, root, 'cbc:IssueDate', this.formatDate(invoice.date)); // Due Date const dueDate = new Date(invoice.date); dueDate.setDate(dueDate.getDate() + invoice.dueInDays); this.appendElement(doc, root, 'cbc:DueDate', this.formatDate(dueDate.getTime())); // Document Type Code const typeCode = documentType === UBLDocumentType.INVOICE ? '380' : '381'; this.appendElement(doc, root, 'cbc:InvoiceTypeCode', typeCode); // Notes if (invoice.notes && invoice.notes.length > 0) { for (const note of invoice.notes) { this.appendElement(doc, root, 'cbc:Note', note); } } // Document Currency Code this.appendElement(doc, root, 'cbc:DocumentCurrencyCode', invoice.currency); // Add accounting supplier party (seller) this.addParty(doc, root, 'cac:AccountingSupplierParty', invoice.from); // Add accounting customer party (buyer) this.addParty(doc, root, 'cac:AccountingCustomerParty', invoice.to); // Add payment terms this.addPaymentTerms(doc, root, invoice); // Add tax summary this.addTaxTotal(doc, root, invoice); // Add monetary totals this.addLegalMonetaryTotal(doc, root, invoice); // Add line items this.addInvoiceLines(doc, root, invoice); } /** * Adds credit note specific data to the document * @param doc XML document * @param creditNote Credit note data */ private addCreditNoteSpecificData(doc: Document, creditNote: TCreditNote): void { // For now, there's no specific data to add for credit notes // If needed, additional credit note specific fields would be added here } /** * Adds invoice specific data to the document * @param doc XML document * @param invoice Invoice data */ private addInvoiceSpecificData(doc: Document, invoice: TDebitNote): void { // For now, there's no specific data to add for invoices that's not already covered // If needed, additional invoice specific fields would be added here } /** * Adds party information (supplier or customer) * @param doc XML document * @param parentElement Parent element * @param elementName Element name (AccountingSupplierParty or AccountingCustomerParty) * @param party Party data */ private addParty(doc: Document, parentElement: Element, elementName: string, party: any): void { const partyElement = doc.createElement(elementName); parentElement.appendChild(partyElement); const partyNode = doc.createElement('cac:Party'); partyElement.appendChild(partyNode); // Party name const partyNameNode = doc.createElement('cac:PartyName'); partyNode.appendChild(partyNameNode); this.appendElement(doc, partyNameNode, 'cbc:Name', party.name); // Postal address const postalAddressNode = doc.createElement('cac:PostalAddress'); partyNode.appendChild(postalAddressNode); if (party.address.streetName) { this.appendElement(doc, postalAddressNode, 'cbc:StreetName', party.address.streetName); } if (party.address.houseNumber && party.address.houseNumber !== '0') { this.appendElement(doc, postalAddressNode, 'cbc:BuildingNumber', party.address.houseNumber); } if (party.address.city) { this.appendElement(doc, postalAddressNode, 'cbc:CityName', party.address.city); } if (party.address.postalCode) { this.appendElement(doc, postalAddressNode, 'cbc:PostalZone', party.address.postalCode); } // Country if (party.address.country || party.address.countryCode) { const countryNode = doc.createElement('cac:Country'); postalAddressNode.appendChild(countryNode); const countryCode = party.address.countryCode || this.getCountryCode(party.address.country); this.appendElement(doc, countryNode, 'cbc:IdentificationCode', countryCode); if (party.address.country) { this.appendElement(doc, countryNode, 'cbc:Name', party.address.country); } } // Party tax scheme (VAT ID) if (party.registrationDetails && party.registrationDetails.vatId) { const partyTaxSchemeNode = doc.createElement('cac:PartyTaxScheme'); partyNode.appendChild(partyTaxSchemeNode); this.appendElement(doc, partyTaxSchemeNode, 'cbc:CompanyID', party.registrationDetails.vatId); const taxSchemeNode = doc.createElement('cac:TaxScheme'); partyTaxSchemeNode.appendChild(taxSchemeNode); this.appendElement(doc, taxSchemeNode, 'cbc:ID', 'VAT'); } // Party legal entity (registration information) if (party.registrationDetails) { const partyLegalEntityNode = doc.createElement('cac:PartyLegalEntity'); partyNode.appendChild(partyLegalEntityNode); const registrationName = party.registrationDetails.registrationName || party.name; this.appendElement(doc, partyLegalEntityNode, 'cbc:RegistrationName', registrationName); if (party.registrationDetails.registrationId) { this.appendElement(doc, partyLegalEntityNode, 'cbc:CompanyID', party.registrationDetails.registrationId); } } // Contact information if (party.contactDetails) { const contactNode = doc.createElement('cac:Contact'); partyNode.appendChild(contactNode); if (party.contactDetails.name) { this.appendElement(doc, contactNode, 'cbc:Name', party.contactDetails.name); } if (party.contactDetails.telephone) { this.appendElement(doc, contactNode, 'cbc:Telephone', party.contactDetails.telephone); } if (party.contactDetails.email) { this.appendElement(doc, contactNode, 'cbc:ElectronicMail', party.contactDetails.email); } } } /** * Adds payment terms information * @param doc XML document * @param parentElement Parent element * @param invoice Invoice data */ private addPaymentTerms(doc: Document, parentElement: Element, invoice: TInvoice): void { const paymentTermsNode = doc.createElement('cac:PaymentTerms'); parentElement.appendChild(paymentTermsNode); // Payment terms note this.appendElement(doc, paymentTermsNode, 'cbc:Note', `Due in ${invoice.dueInDays} days`); // Add payment means if available if (invoice.paymentOptions) { this.addPaymentMeans(doc, parentElement, invoice); } } /** * Adds payment means information * @param doc XML document * @param parentElement Parent element * @param invoice Invoice data */ private addPaymentMeans(doc: Document, parentElement: Element, invoice: TInvoice): void { const paymentMeansNode = doc.createElement('cac:PaymentMeans'); parentElement.appendChild(paymentMeansNode); // Payment means code - default to credit transfer this.appendElement(doc, paymentMeansNode, 'cbc:PaymentMeansCode', '30'); // Payment due date const dueDate = new Date(invoice.date); dueDate.setDate(dueDate.getDate() + invoice.dueInDays); this.appendElement(doc, paymentMeansNode, 'cbc:PaymentDueDate', this.formatDate(dueDate.getTime())); // Add payment channel code if available if (invoice.paymentOptions.description) { this.appendElement(doc, paymentMeansNode, 'cbc:InstructionNote', invoice.paymentOptions.description); } // Add payment ID information if available - use invoice ID as payment reference this.appendElement(doc, paymentMeansNode, 'cbc:PaymentID', invoice.id); // Add bank account information if available if (invoice.paymentOptions.sepaConnection && invoice.paymentOptions.sepaConnection.iban) { const payeeFinancialAccountNode = doc.createElement('cac:PayeeFinancialAccount'); paymentMeansNode.appendChild(payeeFinancialAccountNode); this.appendElement(doc, payeeFinancialAccountNode, 'cbc:ID', invoice.paymentOptions.sepaConnection.iban); // Add financial institution information if BIC is available if (invoice.paymentOptions.sepaConnection.bic) { const financialInstitutionNode = doc.createElement('cac:FinancialInstitutionBranch'); payeeFinancialAccountNode.appendChild(financialInstitutionNode); this.appendElement(doc, financialInstitutionNode, 'cbc:ID', invoice.paymentOptions.sepaConnection.bic); } } } /** * Adds tax total information * @param doc XML document * @param parentElement Parent element * @param invoice Invoice data */ private addTaxTotal(doc: Document, parentElement: Element, invoice: TInvoice): void { const taxTotalNode = doc.createElement('cac:TaxTotal'); parentElement.appendChild(taxTotalNode); // Calculate total tax amount let totalTaxAmount = 0; 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 itemTaxAmount = itemNetAmount * (item.vatPercentage / 100); const vatRate = item.vatPercentage; totalTaxAmount += itemTaxAmount; // Aggregate by VAT rate const currentAmount = taxCategories.get(vatRate) || 0; taxCategories.set(vatRate, currentAmount + itemNetAmount); } } // Add total tax amount const taxAmountElement = doc.createElement('cbc:TaxAmount'); taxAmountElement.setAttribute('currencyID', invoice.currency); taxAmountElement.textContent = totalTaxAmount.toFixed(2); taxTotalNode.appendChild(taxAmountElement); // Add tax subtotals for (const [rate, baseAmount] of taxCategories.entries()) { const taxSubtotalNode = doc.createElement('cac:TaxSubtotal'); taxTotalNode.appendChild(taxSubtotalNode); // Taxable amount const taxableAmountElement = doc.createElement('cbc:TaxableAmount'); taxableAmountElement.setAttribute('currencyID', invoice.currency); taxableAmountElement.textContent = baseAmount.toFixed(2); taxSubtotalNode.appendChild(taxableAmountElement); // Tax amount const taxAmount = baseAmount * (rate / 100); const subtotalTaxAmountElement = doc.createElement('cbc:TaxAmount'); subtotalTaxAmountElement.setAttribute('currencyID', invoice.currency); subtotalTaxAmountElement.textContent = taxAmount.toFixed(2); taxSubtotalNode.appendChild(subtotalTaxAmountElement); // Tax category const taxCategoryNode = doc.createElement('cac:TaxCategory'); taxSubtotalNode.appendChild(taxCategoryNode); // Determine tax category ID based on reverse charge const categoryId = invoice.reverseCharge ? 'AE' : 'S'; this.appendElement(doc, taxCategoryNode, 'cbc:ID', categoryId); // Add percent this.appendElement(doc, taxCategoryNode, 'cbc:Percent', rate.toString()); // Add tax exemption reason if reverse charge if (invoice.reverseCharge) { this.appendElement(doc, taxCategoryNode, 'cbc:TaxExemptionReasonCode', 'VATEX-EU-IC'); this.appendElement(doc, taxCategoryNode, 'cbc:TaxExemptionReason', 'Reverse charge'); } // Add tax scheme const taxSchemeNode = doc.createElement('cac:TaxScheme'); taxCategoryNode.appendChild(taxSchemeNode); this.appendElement(doc, taxSchemeNode, 'cbc:ID', 'VAT'); } } /** * Adds legal monetary total information * @param doc XML document * @param parentElement Parent element * @param invoice Invoice data */ private addLegalMonetaryTotal(doc: Document, parentElement: Element, invoice: TInvoice): void { const legalMonetaryTotalNode = doc.createElement('cac:LegalMonetaryTotal'); parentElement.appendChild(legalMonetaryTotalNode); // 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; // Line extension amount (sum of line net amounts) const lineExtensionAmountElement = doc.createElement('cbc:LineExtensionAmount'); lineExtensionAmountElement.setAttribute('currencyID', invoice.currency); lineExtensionAmountElement.textContent = totalNetAmount.toFixed(2); legalMonetaryTotalNode.appendChild(lineExtensionAmountElement); // Tax exclusive amount const taxExclusiveAmountElement = doc.createElement('cbc:TaxExclusiveAmount'); taxExclusiveAmountElement.setAttribute('currencyID', invoice.currency); taxExclusiveAmountElement.textContent = totalNetAmount.toFixed(2); legalMonetaryTotalNode.appendChild(taxExclusiveAmountElement); // Tax inclusive amount const taxInclusiveAmountElement = doc.createElement('cbc:TaxInclusiveAmount'); taxInclusiveAmountElement.setAttribute('currencyID', invoice.currency); taxInclusiveAmountElement.textContent = totalGrossAmount.toFixed(2); legalMonetaryTotalNode.appendChild(taxInclusiveAmountElement); // Payable amount const payableAmountElement = doc.createElement('cbc:PayableAmount'); payableAmountElement.setAttribute('currencyID', invoice.currency); payableAmountElement.textContent = totalGrossAmount.toFixed(2); legalMonetaryTotalNode.appendChild(payableAmountElement); } /** * Adds invoice lines * @param doc XML document * @param parentElement Parent element * @param invoice Invoice data */ private addInvoiceLines(doc: Document, parentElement: Element, invoice: TInvoice): void { if (!invoice.items) return; for (const item of invoice.items) { const invoiceLineNode = doc.createElement('cac:InvoiceLine'); parentElement.appendChild(invoiceLineNode); // ID this.appendElement(doc, invoiceLineNode, 'cbc:ID', item.position.toString()); // Invoiced quantity const quantityElement = doc.createElement('cbc:InvoicedQuantity'); quantityElement.setAttribute('unitCode', item.unitType); quantityElement.textContent = item.unitQuantity.toString(); invoiceLineNode.appendChild(quantityElement); // Line extension amount (line net amount) const itemNetAmount = item.unitNetPrice * item.unitQuantity; const lineExtensionAmountElement = doc.createElement('cbc:LineExtensionAmount'); lineExtensionAmountElement.setAttribute('currencyID', invoice.currency); lineExtensionAmountElement.textContent = itemNetAmount.toFixed(2); invoiceLineNode.appendChild(lineExtensionAmountElement); // Item information const itemNode = doc.createElement('cac:Item'); invoiceLineNode.appendChild(itemNode); // Description this.appendElement(doc, itemNode, 'cbc:Description', item.name); this.appendElement(doc, itemNode, 'cbc:Name', item.name); // Seller's item identification if (item.articleNumber) { const sellersItemIdentificationNode = doc.createElement('cac:SellersItemIdentification'); itemNode.appendChild(sellersItemIdentificationNode); this.appendElement(doc, sellersItemIdentificationNode, 'cbc:ID', item.articleNumber); } // Item tax information const classifiedTaxCategoryNode = doc.createElement('cac:ClassifiedTaxCategory'); itemNode.appendChild(classifiedTaxCategoryNode); // Determine tax category ID based on reverse charge const categoryId = invoice.reverseCharge ? 'AE' : 'S'; this.appendElement(doc, classifiedTaxCategoryNode, 'cbc:ID', categoryId); // Tax percent this.appendElement(doc, classifiedTaxCategoryNode, 'cbc:Percent', item.vatPercentage.toString()); // Tax scheme const taxSchemeNode = doc.createElement('cac:TaxScheme'); classifiedTaxCategoryNode.appendChild(taxSchemeNode); this.appendElement(doc, taxSchemeNode, 'cbc:ID', 'VAT'); // Price information const priceNode = doc.createElement('cac:Price'); invoiceLineNode.appendChild(priceNode); // Price amount const priceAmountElement = doc.createElement('cbc:PriceAmount'); priceAmountElement.setAttribute('currencyID', invoice.currency); priceAmountElement.textContent = item.unitNetPrice.toFixed(2); priceNode.appendChild(priceAmountElement); } } /** * Helper method to append a simple element with text content * @param doc XML document * @param parentElement Parent element * @param elementName Element name * @param textContent Text content */ private appendElement(doc: Document, parentElement: Element, elementName: string, textContent: string): void { const element = doc.createElement(elementName); element.textContent = textContent; parentElement.appendChild(element); } /** * Helper method to get country code from country name * Simple implementation that assumes the country name is already a code * @param countryName Country name * @returns Country code (2-letter ISO code) */ private getCountryCode(countryName: string): string { // In a real implementation, this would map country names to ISO codes // For now, just return the first 2 characters or "XX" as fallback if (!countryName) return 'XX'; return countryName.length >= 2 ? countryName.substring(0, 2).toUpperCase() : 'XX'; } }