1034 lines
41 KiB
TypeScript
1034 lines
41 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 as unknown as TInvoice, 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 as unknown as TInvoice, 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 - ensure invoice.date is a valid timestamp
|
|
const issueTimestamp = typeof invoice.date === 'number' ? invoice.date : Date.now();
|
|
const dueDate = new Date(issueTimestamp);
|
|
dueDate.setDate(dueDate.getDate() + (invoice.dueInDays || 30));
|
|
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);
|
|
|
|
// Preserve metadata if available
|
|
this.preserveMetadata(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 - ensure invoice.date is a valid timestamp
|
|
const issueTimestamp = typeof invoice.date === 'number' ? invoice.date : Date.now();
|
|
const dueDate = new Date(issueTimestamp);
|
|
dueDate.setDate(dueDate.getDate() + (invoice.dueInDays || 30));
|
|
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 with 2 decimal places
|
|
this.appendElement(doc, taxCategoryNode, 'cbc:Percent', rate.toFixed(2));
|
|
|
|
// 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 - use description field if available, otherwise use name
|
|
const description = (item as any).description || item.name;
|
|
this.appendElement(doc, itemNode, 'cbc:Description', description);
|
|
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 with 2 decimal places
|
|
this.appendElement(doc, classifiedTaxCategoryNode, 'cbc:Percent', item.vatPercentage.toFixed(2));
|
|
|
|
// 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';
|
|
}
|
|
|
|
/**
|
|
* Preserves metadata from invoice to enhance UBL XML output
|
|
* @param doc XML document
|
|
* @param root Root element
|
|
* @param invoice Invoice data
|
|
*/
|
|
private preserveMetadata(doc: Document, root: Element, invoice: TInvoice): void {
|
|
// Extract metadata if available
|
|
const metadata = (invoice as any).metadata?.extensions;
|
|
if (!metadata) return;
|
|
|
|
// Preserve business references
|
|
this.addBusinessReferencesToUBL(doc, root, metadata.businessReferences);
|
|
|
|
// Preserve payment information
|
|
this.enhancePaymentInformationUBL(doc, root, metadata.paymentInformation);
|
|
|
|
// Preserve date information
|
|
this.addDateInformationUBL(doc, root, metadata.dateInformation);
|
|
|
|
// Enhance party information with contact details
|
|
this.enhancePartyInformationUBL(doc, invoice);
|
|
|
|
// Enhance line items with metadata
|
|
this.enhanceLineItemsUBL(doc, invoice);
|
|
}
|
|
|
|
/**
|
|
* Adds business references from metadata to UBL document
|
|
* @param doc XML document
|
|
* @param root Root element
|
|
* @param businessReferences Business references from metadata
|
|
*/
|
|
private addBusinessReferencesToUBL(doc: Document, root: Element, businessReferences?: any): void {
|
|
if (!businessReferences) return;
|
|
|
|
// Add BuyerReference
|
|
if (businessReferences.buyerReference && !root.getElementsByTagName('cbc:BuyerReference')[0]) {
|
|
const buyerRef = doc.createElement('cbc:BuyerReference');
|
|
buyerRef.textContent = businessReferences.buyerReference;
|
|
|
|
// Insert after DocumentCurrencyCode
|
|
const currencyCode = root.getElementsByTagName('cbc:DocumentCurrencyCode')[0];
|
|
if (currencyCode && currencyCode.parentNode) {
|
|
currencyCode.parentNode.insertBefore(buyerRef, currencyCode.nextSibling);
|
|
}
|
|
}
|
|
|
|
// Add OrderReference
|
|
if (businessReferences.orderReference && !root.getElementsByTagName('cac:OrderReference')[0]) {
|
|
const orderRef = doc.createElement('cac:OrderReference');
|
|
const orderId = doc.createElement('cbc:ID');
|
|
orderId.textContent = businessReferences.orderReference;
|
|
orderRef.appendChild(orderId);
|
|
|
|
// Insert after BuyerReference or DocumentCurrencyCode
|
|
const buyerRef = root.getElementsByTagName('cbc:BuyerReference')[0];
|
|
const currencyCode = root.getElementsByTagName('cbc:DocumentCurrencyCode')[0];
|
|
const insertAfter = buyerRef || currencyCode;
|
|
if (insertAfter && insertAfter.parentNode) {
|
|
insertAfter.parentNode.insertBefore(orderRef, insertAfter.nextSibling);
|
|
}
|
|
}
|
|
|
|
// Add ContractDocumentReference
|
|
if (businessReferences.contractReference && !root.getElementsByTagName('cac:ContractDocumentReference')[0]) {
|
|
const contractRef = doc.createElement('cac:ContractDocumentReference');
|
|
const contractId = doc.createElement('cbc:ID');
|
|
contractId.textContent = businessReferences.contractReference;
|
|
contractRef.appendChild(contractId);
|
|
|
|
// Insert after OrderReference or DocumentCurrencyCode
|
|
const orderRef = root.getElementsByTagName('cac:OrderReference')[0];
|
|
const insertAfter = orderRef || root.getElementsByTagName('cbc:DocumentCurrencyCode')[0];
|
|
if (insertAfter && insertAfter.parentNode) {
|
|
insertAfter.parentNode.insertBefore(contractRef, insertAfter.nextSibling);
|
|
}
|
|
}
|
|
|
|
// Add ProjectReference
|
|
if (businessReferences.projectReference && !root.getElementsByTagName('cac:ProjectReference')[0]) {
|
|
const projectRef = doc.createElement('cac:ProjectReference');
|
|
const projectId = doc.createElement('cbc:ID');
|
|
projectId.textContent = businessReferences.projectReference;
|
|
projectRef.appendChild(projectId);
|
|
|
|
// Insert after ContractDocumentReference or other refs
|
|
const contractRef = root.getElementsByTagName('cac:ContractDocumentReference')[0];
|
|
const orderRef = root.getElementsByTagName('cac:OrderReference')[0];
|
|
const insertAfter = contractRef || orderRef || root.getElementsByTagName('cbc:DocumentCurrencyCode')[0];
|
|
if (insertAfter && insertAfter.parentNode) {
|
|
insertAfter.parentNode.insertBefore(projectRef, insertAfter.nextSibling);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enhances payment information from metadata in UBL document
|
|
* @param doc XML document
|
|
* @param root Root element
|
|
* @param paymentInfo Payment information from metadata
|
|
*/
|
|
private enhancePaymentInformationUBL(doc: Document, root: Element, paymentInfo?: any): void {
|
|
if (!paymentInfo) return;
|
|
|
|
let paymentMeans = root.getElementsByTagName('cac:PaymentMeans')[0];
|
|
|
|
// Create PaymentMeans if it doesn't exist
|
|
if (!paymentMeans) {
|
|
paymentMeans = doc.createElement('cac:PaymentMeans');
|
|
// Insert before TaxTotal
|
|
const taxTotal = root.getElementsByTagName('cac:TaxTotal')[0];
|
|
if (taxTotal && taxTotal.parentNode) {
|
|
taxTotal.parentNode.insertBefore(paymentMeans, taxTotal);
|
|
}
|
|
}
|
|
|
|
// Add PaymentMeansCode
|
|
if (paymentInfo.paymentMeansCode && !paymentMeans.getElementsByTagName('cbc:PaymentMeansCode')[0]) {
|
|
const meansCode = doc.createElement('cbc:PaymentMeansCode');
|
|
meansCode.textContent = paymentInfo.paymentMeansCode;
|
|
paymentMeans.appendChild(meansCode);
|
|
}
|
|
|
|
// Add PaymentID
|
|
if (paymentInfo.paymentID && !paymentMeans.getElementsByTagName('cbc:PaymentID')[0]) {
|
|
const paymentId = doc.createElement('cbc:PaymentID');
|
|
paymentId.textContent = paymentInfo.paymentID;
|
|
paymentMeans.appendChild(paymentId);
|
|
}
|
|
|
|
// Add PaymentDueDate
|
|
if (paymentInfo.paymentDueDate && !paymentMeans.getElementsByTagName('cbc:PaymentDueDate')[0]) {
|
|
const dueDate = doc.createElement('cbc:PaymentDueDate');
|
|
dueDate.textContent = paymentInfo.paymentDueDate;
|
|
paymentMeans.appendChild(dueDate);
|
|
}
|
|
|
|
// Add IBAN and BIC
|
|
if (paymentInfo.iban || paymentInfo.bic) {
|
|
let payeeAccount = paymentMeans.getElementsByTagName('cac:PayeeFinancialAccount')[0];
|
|
if (!payeeAccount) {
|
|
payeeAccount = doc.createElement('cac:PayeeFinancialAccount');
|
|
paymentMeans.appendChild(payeeAccount);
|
|
}
|
|
|
|
// Add IBAN
|
|
if (paymentInfo.iban && !payeeAccount.getElementsByTagName('cbc:ID')[0]) {
|
|
const iban = doc.createElement('cbc:ID');
|
|
iban.textContent = paymentInfo.iban;
|
|
payeeAccount.appendChild(iban);
|
|
}
|
|
|
|
// Add account name (must come after ID but before FinancialInstitutionBranch)
|
|
if (paymentInfo.accountName && !payeeAccount.getElementsByTagName('cbc:Name')[0]) {
|
|
const accountName = doc.createElement('cbc:Name');
|
|
accountName.textContent = paymentInfo.accountName;
|
|
// Insert after ID but before FinancialInstitutionBranch
|
|
const id = payeeAccount.getElementsByTagName('cbc:ID')[0];
|
|
const finInstBranch = payeeAccount.getElementsByTagName('cac:FinancialInstitutionBranch')[0];
|
|
if (finInstBranch) {
|
|
payeeAccount.insertBefore(accountName, finInstBranch);
|
|
} else if (id && id.nextSibling) {
|
|
payeeAccount.insertBefore(accountName, id.nextSibling);
|
|
} else {
|
|
payeeAccount.appendChild(accountName);
|
|
}
|
|
}
|
|
|
|
// Add BIC and bank name
|
|
if (paymentInfo.bic || paymentInfo.bankName) {
|
|
let finInstBranch = payeeAccount.getElementsByTagName('cac:FinancialInstitutionBranch')[0];
|
|
if (!finInstBranch) {
|
|
finInstBranch = doc.createElement('cac:FinancialInstitutionBranch');
|
|
payeeAccount.appendChild(finInstBranch);
|
|
}
|
|
|
|
// Add BIC as branch ID
|
|
if (paymentInfo.bic && !finInstBranch.getElementsByTagName('cbc:ID')[0]) {
|
|
const bicElement = doc.createElement('cbc:ID');
|
|
bicElement.textContent = paymentInfo.bic;
|
|
finInstBranch.appendChild(bicElement);
|
|
}
|
|
|
|
// Add bank name
|
|
if (paymentInfo.bankName && !finInstBranch.getElementsByTagName('cbc:Name')[0]) {
|
|
const bankNameElement = doc.createElement('cbc:Name');
|
|
bankNameElement.textContent = paymentInfo.bankName;
|
|
finInstBranch.appendChild(bankNameElement);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add payment terms with discount if available
|
|
if (paymentInfo.paymentTermsNote && paymentInfo.paymentTermsNote.includes('early payment')) {
|
|
let paymentTerms = root.getElementsByTagName('cac:PaymentTerms')[0];
|
|
if (!paymentTerms) {
|
|
paymentTerms = doc.createElement('cac:PaymentTerms');
|
|
// Insert before PaymentMeans
|
|
const paymentMeans = root.getElementsByTagName('cac:PaymentMeans')[0];
|
|
if (paymentMeans && paymentMeans.parentNode) {
|
|
paymentMeans.parentNode.insertBefore(paymentTerms, paymentMeans);
|
|
}
|
|
}
|
|
|
|
// Update or add note
|
|
let note = paymentTerms.getElementsByTagName('cbc:Note')[0];
|
|
if (!note) {
|
|
note = doc.createElement('cbc:Note');
|
|
paymentTerms.appendChild(note);
|
|
}
|
|
note.textContent = paymentInfo.paymentTermsNote;
|
|
|
|
// Add discount percent if available
|
|
if (paymentInfo.discountPercent && !paymentTerms.getElementsByTagName('cbc:SettlementDiscountPercent')[0]) {
|
|
const discountElement = doc.createElement('cbc:SettlementDiscountPercent');
|
|
discountElement.textContent = paymentInfo.discountPercent;
|
|
paymentTerms.appendChild(discountElement);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds date information from metadata to UBL document
|
|
* @param doc XML document
|
|
* @param root Root element
|
|
* @param dateInfo Date information from metadata
|
|
*/
|
|
private addDateInformationUBL(doc: Document, root: Element, dateInfo?: any): void {
|
|
if (!dateInfo) return;
|
|
|
|
// Add InvoicePeriod
|
|
if ((dateInfo.periodStart || dateInfo.periodEnd) && !root.getElementsByTagName('cac:InvoicePeriod')[0]) {
|
|
const invoicePeriod = doc.createElement('cac:InvoicePeriod');
|
|
|
|
if (dateInfo.periodStart) {
|
|
const startDate = doc.createElement('cbc:StartDate');
|
|
startDate.textContent = dateInfo.periodStart;
|
|
invoicePeriod.appendChild(startDate);
|
|
}
|
|
|
|
if (dateInfo.periodEnd) {
|
|
const endDate = doc.createElement('cbc:EndDate');
|
|
endDate.textContent = dateInfo.periodEnd;
|
|
invoicePeriod.appendChild(endDate);
|
|
}
|
|
|
|
// Insert after business references or DocumentCurrencyCode
|
|
const projectRef = root.getElementsByTagName('cac:ProjectReference')[0];
|
|
const contractRef = root.getElementsByTagName('cac:ContractDocumentReference')[0];
|
|
const orderRef = root.getElementsByTagName('cac:OrderReference')[0];
|
|
const insertAfter = projectRef || contractRef || orderRef || root.getElementsByTagName('cbc:DocumentCurrencyCode')[0];
|
|
if (insertAfter && insertAfter.parentNode) {
|
|
insertAfter.parentNode.insertBefore(invoicePeriod, insertAfter.nextSibling);
|
|
}
|
|
}
|
|
|
|
// Add Delivery with ActualDeliveryDate
|
|
if (dateInfo.deliveryDate && !root.getElementsByTagName('cac:Delivery')[0]) {
|
|
const delivery = doc.createElement('cac:Delivery');
|
|
const deliveryDate = doc.createElement('cbc:ActualDeliveryDate');
|
|
deliveryDate.textContent = dateInfo.deliveryDate;
|
|
delivery.appendChild(deliveryDate);
|
|
|
|
// Insert before PaymentMeans
|
|
const paymentMeans = root.getElementsByTagName('cac:PaymentMeans')[0];
|
|
if (paymentMeans && paymentMeans.parentNode) {
|
|
paymentMeans.parentNode.insertBefore(delivery, paymentMeans);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enhances party information with contact details from metadata
|
|
* @param doc XML document
|
|
* @param invoice Invoice data
|
|
*/
|
|
private enhancePartyInformationUBL(doc: Document, invoice: TInvoice): void {
|
|
// Enhance supplier party
|
|
this.enhancePartyUBL(doc, 'cac:AccountingSupplierParty', invoice.from);
|
|
|
|
// Enhance customer party
|
|
this.enhancePartyUBL(doc, 'cac:AccountingCustomerParty', invoice.to);
|
|
}
|
|
|
|
/**
|
|
* Enhances a party with GLN, additional identifiers, and contact info
|
|
* @param doc XML document
|
|
* @param partySelector Party selector
|
|
* @param partyData Party data
|
|
*/
|
|
private enhancePartyUBL(doc: Document, partySelector: string, partyData: any): void {
|
|
if (!partyData) return;
|
|
|
|
const partyContainer = doc.getElementsByTagName(partySelector)[0];
|
|
if (!partyContainer) return;
|
|
|
|
const party = partyContainer.getElementsByTagName('cac:Party')[0];
|
|
if (!party) return;
|
|
|
|
// Add GLN if available
|
|
if (partyData.gln && !party.getElementsByTagName('cbc:EndpointID')[0]) {
|
|
const endpointNode = doc.createElement('cbc:EndpointID');
|
|
endpointNode.setAttribute('schemeID', '0088'); // GLN scheme ID
|
|
endpointNode.textContent = partyData.gln;
|
|
|
|
// Insert as first child
|
|
if (party.firstChild) {
|
|
party.insertBefore(endpointNode, party.firstChild);
|
|
} else {
|
|
party.appendChild(endpointNode);
|
|
}
|
|
}
|
|
|
|
// Add additional identifiers
|
|
if (partyData.additionalIdentifiers && Array.isArray(partyData.additionalIdentifiers)) {
|
|
for (const identifier of partyData.additionalIdentifiers) {
|
|
const partyId = doc.createElement('cac:PartyIdentification');
|
|
const id = doc.createElement('cbc:ID');
|
|
if (identifier.scheme) {
|
|
id.setAttribute('schemeID', identifier.scheme);
|
|
}
|
|
id.textContent = identifier.value;
|
|
partyId.appendChild(id);
|
|
|
|
// Insert after EndpointID or at beginning
|
|
const endpoint = party.getElementsByTagName('cbc:EndpointID')[0];
|
|
if (endpoint && endpoint.nextSibling) {
|
|
party.insertBefore(partyId, endpoint.nextSibling);
|
|
} else if (party.firstChild) {
|
|
party.insertBefore(partyId, party.firstChild);
|
|
} else {
|
|
party.appendChild(partyId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add contact information from metadata if not already present
|
|
const contactInfo = partyData.metadata?.contactInformation;
|
|
if (contactInfo) {
|
|
this.addContactToPartyUBL(doc, partySelector, contactInfo);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds contact information to a party in UBL document
|
|
* @param doc XML document
|
|
* @param partySelector Party selector
|
|
* @param contactInfo Contact information from metadata
|
|
*/
|
|
private addContactToPartyUBL(doc: Document, partySelector: string, contactInfo?: any): void {
|
|
if (!contactInfo) return;
|
|
|
|
const partyContainer = doc.getElementsByTagName(partySelector)[0];
|
|
if (!partyContainer) return;
|
|
|
|
const party = partyContainer.getElementsByTagName('cac:Party')[0];
|
|
if (!party) return;
|
|
|
|
// Check if Contact already exists
|
|
let contact = party.getElementsByTagName('cac:Contact')[0];
|
|
if (!contact && (contactInfo.name || contactInfo.phone || contactInfo.email)) {
|
|
contact = doc.createElement('cac:Contact');
|
|
|
|
// Insert after PartyName
|
|
const partyName = party.getElementsByTagName('cac:PartyName')[0];
|
|
if (partyName && partyName.parentNode) {
|
|
partyName.parentNode.insertBefore(contact, partyName.nextSibling);
|
|
} else {
|
|
party.appendChild(contact);
|
|
}
|
|
}
|
|
|
|
if (contact) {
|
|
// Add contact name
|
|
if (contactInfo.name && !contact.getElementsByTagName('cbc:Name')[0]) {
|
|
const name = doc.createElement('cbc:Name');
|
|
name.textContent = contactInfo.name;
|
|
contact.appendChild(name);
|
|
}
|
|
|
|
// Add telephone
|
|
if (contactInfo.phone && !contact.getElementsByTagName('cbc:Telephone')[0]) {
|
|
const phone = doc.createElement('cbc:Telephone');
|
|
phone.textContent = contactInfo.phone;
|
|
contact.appendChild(phone);
|
|
}
|
|
|
|
// Add email
|
|
if (contactInfo.email && !contact.getElementsByTagName('cbc:ElectronicMail')[0]) {
|
|
const email = doc.createElement('cbc:ElectronicMail');
|
|
email.textContent = contactInfo.email;
|
|
contact.appendChild(email);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enhances line items with metadata in UBL document
|
|
* @param doc XML document
|
|
* @param invoice Invoice data
|
|
*/
|
|
private enhanceLineItemsUBL(doc: Document, invoice: TInvoice): void {
|
|
const invoiceLines = doc.getElementsByTagName('cac:InvoiceLine');
|
|
|
|
for (let i = 0; i < invoiceLines.length && i < invoice.items.length; i++) {
|
|
const line = invoiceLines[i];
|
|
const item = invoice.items[i];
|
|
const itemMetadata = (item as any).metadata;
|
|
|
|
if (!itemMetadata) continue;
|
|
|
|
// Add OrderLineReference if available
|
|
if (itemMetadata.orderLineReference && !line.getElementsByTagName('cac:OrderLineReference')[0]) {
|
|
const orderLineRef = doc.createElement('cac:OrderLineReference');
|
|
const lineId = doc.createElement('cbc:LineID');
|
|
lineId.textContent = itemMetadata.orderLineReferenceId || '1';
|
|
orderLineRef.appendChild(lineId);
|
|
|
|
if (itemMetadata.orderLineReference) {
|
|
const orderRef = doc.createElement('cac:OrderReference');
|
|
const orderId = doc.createElement('cbc:ID');
|
|
orderId.textContent = itemMetadata.orderLineReference;
|
|
orderRef.appendChild(orderId);
|
|
orderLineRef.appendChild(orderRef);
|
|
}
|
|
|
|
// Insert after ID
|
|
const invoiceLineId = line.getElementsByTagName('cbc:ID')[0];
|
|
if (invoiceLineId && invoiceLineId.nextSibling) {
|
|
line.insertBefore(orderLineRef, invoiceLineId.nextSibling);
|
|
} else {
|
|
// Insert before InvoicedQuantity
|
|
const quantity = line.getElementsByTagName('cbc:InvoicedQuantity')[0];
|
|
if (quantity) {
|
|
line.insertBefore(orderLineRef, quantity);
|
|
}
|
|
}
|
|
}
|
|
|
|
const itemElement = line.getElementsByTagName('cac:Item')[0];
|
|
if (!itemElement) continue;
|
|
|
|
// Add item description
|
|
if (itemMetadata.description && !itemElement.getElementsByTagName('cbc:Description')[0]) {
|
|
const desc = doc.createElement('cbc:Description');
|
|
desc.textContent = itemMetadata.description;
|
|
// Insert before Name
|
|
const name = itemElement.getElementsByTagName('cbc:Name')[0];
|
|
if (name && name.parentNode) {
|
|
name.parentNode.insertBefore(desc, name);
|
|
} else {
|
|
itemElement.appendChild(desc);
|
|
}
|
|
}
|
|
|
|
// Add SellersItemIdentification
|
|
if (item.articleNumber && !itemElement.getElementsByTagName('cac:SellersItemIdentification')[0]) {
|
|
const sellerId = doc.createElement('cac:SellersItemIdentification');
|
|
const id = doc.createElement('cbc:ID');
|
|
id.textContent = item.articleNumber;
|
|
sellerId.appendChild(id);
|
|
itemElement.appendChild(sellerId);
|
|
}
|
|
|
|
// Add BuyersItemIdentification
|
|
if (itemMetadata.buyerItemID && !itemElement.getElementsByTagName('cac:BuyersItemIdentification')[0]) {
|
|
const buyerId = doc.createElement('cac:BuyersItemIdentification');
|
|
const id = doc.createElement('cbc:ID');
|
|
id.textContent = itemMetadata.buyerItemID;
|
|
buyerId.appendChild(id);
|
|
itemElement.appendChild(buyerId);
|
|
}
|
|
|
|
// Add StandardItemIdentification
|
|
if (itemMetadata.standardItemID && !itemElement.getElementsByTagName('cac:StandardItemIdentification')[0]) {
|
|
const standardId = doc.createElement('cac:StandardItemIdentification');
|
|
const id = doc.createElement('cbc:ID');
|
|
id.textContent = itemMetadata.standardItemID;
|
|
standardId.appendChild(id);
|
|
itemElement.appendChild(standardId);
|
|
}
|
|
|
|
// Add CommodityClassification
|
|
if (itemMetadata.commodityClassification && !itemElement.getElementsByTagName('cac:CommodityClassification')[0]) {
|
|
const classification = doc.createElement('cac:CommodityClassification');
|
|
const code = doc.createElement('cbc:ItemClassificationCode');
|
|
code.textContent = itemMetadata.commodityClassification;
|
|
classification.appendChild(code);
|
|
itemElement.appendChild(classification);
|
|
}
|
|
|
|
// Add additional item properties
|
|
if (itemMetadata.additionalProperties) {
|
|
for (const [propName, propValue] of Object.entries(itemMetadata.additionalProperties)) {
|
|
const additionalProp = doc.createElement('cac:AdditionalItemProperty');
|
|
|
|
const nameElement = doc.createElement('cbc:Name');
|
|
nameElement.textContent = propName;
|
|
additionalProp.appendChild(nameElement);
|
|
|
|
const valueElement = doc.createElement('cbc:Value');
|
|
valueElement.textContent = propValue as string;
|
|
additionalProp.appendChild(valueElement);
|
|
|
|
itemElement.appendChild(additionalProp);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} |