xinvoice/ts/formats/ubl/generic/ubl.encoder.ts

517 lines
20 KiB
TypeScript

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<string> {
// 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<string> {
// 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<number, number>(); // 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';
}
}