update
This commit is contained in:
335
ts/formats/xinvoice.encoder.ts
Normal file
335
ts/formats/xinvoice.encoder.ts
Normal file
@ -0,0 +1,335 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* A class to convert a given ILetter with invoice data
|
||||
* into an XInvoice/XRechnung compliant XML (based on UBL).
|
||||
*
|
||||
* XRechnung is the German implementation of the European standard EN16931
|
||||
* for electronic invoices to the German public sector.
|
||||
*/
|
||||
export class XInvoiceEncoder {
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Creates an XInvoice compliant XML based on the provided letter data.
|
||||
*/
|
||||
public createXInvoiceXml(letterArg: plugins.tsclass.business.ILetter): string {
|
||||
// Use SmartXml for XML creation
|
||||
const smartxmlInstance = new plugins.smartxml.SmartXml();
|
||||
|
||||
if (!letterArg?.content?.invoiceData) {
|
||||
throw new Error('Letter does not contain invoice data.');
|
||||
}
|
||||
|
||||
const invoice: plugins.tsclass.finance.IInvoice = letterArg.content.invoiceData;
|
||||
const billedBy: plugins.tsclass.business.IContact = invoice.billedBy;
|
||||
const billedTo: plugins.tsclass.business.IContact = invoice.billedTo;
|
||||
|
||||
// Create the XML document
|
||||
const doc = smartxmlInstance
|
||||
.create({ version: '1.0', encoding: 'UTF-8' })
|
||||
.ele('Invoice', {
|
||||
'xmlns': 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
|
||||
'xmlns:cac': 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2',
|
||||
'xmlns:cbc': 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
|
||||
'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance'
|
||||
});
|
||||
|
||||
// UBL Version ID
|
||||
doc.ele('cbc:UBLVersionID').txt('2.1').up();
|
||||
|
||||
// CustomizationID for XRechnung
|
||||
doc.ele('cbc:CustomizationID').txt('urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_2.0').up();
|
||||
|
||||
// ID - Invoice number
|
||||
doc.ele('cbc:ID').txt(invoice.id).up();
|
||||
|
||||
// Issue date
|
||||
const issueDate = new Date(letterArg.date);
|
||||
const issueDateStr = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, '0')}-${String(issueDate.getDate()).padStart(2, '0')}`;
|
||||
doc.ele('cbc:IssueDate').txt(issueDateStr).up();
|
||||
|
||||
// Due date
|
||||
const dueDate = new Date(letterArg.date);
|
||||
dueDate.setDate(dueDate.getDate() + invoice.dueInDays);
|
||||
const dueDateStr = `${dueDate.getFullYear()}-${String(dueDate.getMonth() + 1).padStart(2, '0')}-${String(dueDate.getDate()).padStart(2, '0')}`;
|
||||
doc.ele('cbc:DueDate').txt(dueDateStr).up();
|
||||
|
||||
// Invoice type code
|
||||
const invoiceTypeCode = invoice.type === 'creditnote' ? '381' : '380';
|
||||
doc.ele('cbc:InvoiceTypeCode').txt(invoiceTypeCode).up();
|
||||
|
||||
// Note - optional invoice note
|
||||
if (invoice.notes && invoice.notes.length > 0) {
|
||||
doc.ele('cbc:Note').txt(invoice.notes[0]).up();
|
||||
}
|
||||
|
||||
// Document currency code
|
||||
doc.ele('cbc:DocumentCurrencyCode').txt(invoice.currency).up();
|
||||
|
||||
// Tax currency code - same as document currency in this case
|
||||
doc.ele('cbc:TaxCurrencyCode').txt(invoice.currency).up();
|
||||
|
||||
// Accounting supplier party (seller)
|
||||
const supplierParty = doc.ele('cac:AccountingSupplierParty');
|
||||
const supplierPartyDetails = supplierParty.ele('cac:Party');
|
||||
|
||||
// Seller VAT ID
|
||||
if (billedBy.vatId) {
|
||||
const partyTaxScheme = supplierPartyDetails.ele('cac:PartyTaxScheme');
|
||||
partyTaxScheme.ele('cbc:CompanyID').txt(billedBy.vatId).up();
|
||||
partyTaxScheme.ele('cac:TaxScheme')
|
||||
.ele('cbc:ID').txt('VAT').up()
|
||||
.up();
|
||||
}
|
||||
|
||||
// Seller name
|
||||
supplierPartyDetails.ele('cac:PartyName')
|
||||
.ele('cbc:Name').txt(billedBy.name).up()
|
||||
.up();
|
||||
|
||||
// Seller postal address
|
||||
const supplierAddress = supplierPartyDetails.ele('cac:PostalAddress');
|
||||
supplierAddress.ele('cbc:StreetName').txt(billedBy.address.streetName).up();
|
||||
if (billedBy.address.houseNumber) {
|
||||
supplierAddress.ele('cbc:BuildingNumber').txt(billedBy.address.houseNumber).up();
|
||||
}
|
||||
supplierAddress.ele('cbc:CityName').txt(billedBy.address.city).up();
|
||||
supplierAddress.ele('cbc:PostalZone').txt(billedBy.address.postalCode).up();
|
||||
supplierAddress.ele('cac:Country')
|
||||
.ele('cbc:IdentificationCode').txt(billedBy.address.country || 'DE').up()
|
||||
.up();
|
||||
|
||||
// Seller contact
|
||||
const supplierContact = supplierPartyDetails.ele('cac:Contact');
|
||||
if (billedBy.email) {
|
||||
supplierContact.ele('cbc:ElectronicMail').txt(billedBy.email).up();
|
||||
}
|
||||
if (billedBy.phone) {
|
||||
supplierContact.ele('cbc:Telephone').txt(billedBy.phone).up();
|
||||
}
|
||||
|
||||
supplierParty.up(); // Close AccountingSupplierParty
|
||||
|
||||
// Accounting customer party (buyer)
|
||||
const customerParty = doc.ele('cac:AccountingCustomerParty');
|
||||
const customerPartyDetails = customerParty.ele('cac:Party');
|
||||
|
||||
// Buyer VAT ID
|
||||
if (billedTo.vatId) {
|
||||
const partyTaxScheme = customerPartyDetails.ele('cac:PartyTaxScheme');
|
||||
partyTaxScheme.ele('cbc:CompanyID').txt(billedTo.vatId).up();
|
||||
partyTaxScheme.ele('cac:TaxScheme')
|
||||
.ele('cbc:ID').txt('VAT').up()
|
||||
.up();
|
||||
}
|
||||
|
||||
// Buyer name
|
||||
customerPartyDetails.ele('cac:PartyName')
|
||||
.ele('cbc:Name').txt(billedTo.name).up()
|
||||
.up();
|
||||
|
||||
// Buyer postal address
|
||||
const customerAddress = customerPartyDetails.ele('cac:PostalAddress');
|
||||
customerAddress.ele('cbc:StreetName').txt(billedTo.address.streetName).up();
|
||||
if (billedTo.address.houseNumber) {
|
||||
customerAddress.ele('cbc:BuildingNumber').txt(billedTo.address.houseNumber).up();
|
||||
}
|
||||
customerAddress.ele('cbc:CityName').txt(billedTo.address.city).up();
|
||||
customerAddress.ele('cbc:PostalZone').txt(billedTo.address.postalCode).up();
|
||||
customerAddress.ele('cac:Country')
|
||||
.ele('cbc:IdentificationCode').txt(billedTo.address.country || 'DE').up()
|
||||
.up();
|
||||
|
||||
// Buyer contact
|
||||
if (billedTo.email || billedTo.phone) {
|
||||
const customerContact = customerPartyDetails.ele('cac:Contact');
|
||||
if (billedTo.email) {
|
||||
customerContact.ele('cbc:ElectronicMail').txt(billedTo.email).up();
|
||||
}
|
||||
if (billedTo.phone) {
|
||||
customerContact.ele('cbc:Telephone').txt(billedTo.phone).up();
|
||||
}
|
||||
}
|
||||
|
||||
customerParty.up(); // Close AccountingCustomerParty
|
||||
|
||||
// Payment means
|
||||
if (billedBy.sepaConnection) {
|
||||
const paymentMeans = doc.ele('cac:PaymentMeans');
|
||||
paymentMeans.ele('cbc:PaymentMeansCode').txt('58').up(); // 58 = SEPA credit transfer
|
||||
paymentMeans.ele('cbc:PaymentID').txt(invoice.id).up();
|
||||
|
||||
// IBAN
|
||||
if (billedBy.sepaConnection.iban) {
|
||||
const payeeAccount = paymentMeans.ele('cac:PayeeFinancialAccount');
|
||||
payeeAccount.ele('cbc:ID').txt(billedBy.sepaConnection.iban).up();
|
||||
|
||||
// BIC
|
||||
if (billedBy.sepaConnection.bic) {
|
||||
payeeAccount.ele('cac:FinancialInstitutionBranch')
|
||||
.ele('cbc:ID').txt(billedBy.sepaConnection.bic).up()
|
||||
.up();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Payment terms
|
||||
const paymentTerms = doc.ele('cac:PaymentTerms');
|
||||
paymentTerms.ele('cbc:Note').txt(`Payment due in ${invoice.dueInDays} days`).up();
|
||||
|
||||
// Tax summary
|
||||
// Group items by VAT rate
|
||||
const vatRates: { [rate: number]: plugins.tsclass.finance.IInvoiceItem[] } = {};
|
||||
|
||||
// Collect items by VAT rate
|
||||
invoice.items.forEach(item => {
|
||||
if (!vatRates[item.vatPercentage]) {
|
||||
vatRates[item.vatPercentage] = [];
|
||||
}
|
||||
vatRates[item.vatPercentage].push(item);
|
||||
});
|
||||
|
||||
// Calculate tax subtotals for each rate
|
||||
Object.entries(vatRates).forEach(([rate, items]) => {
|
||||
const taxRate = parseFloat(rate);
|
||||
|
||||
// Calculate base amount for this rate
|
||||
let taxableAmount = 0;
|
||||
items.forEach(item => {
|
||||
taxableAmount += item.unitNetPrice * item.unitQuantity;
|
||||
});
|
||||
|
||||
// Calculate tax amount
|
||||
const taxAmount = taxableAmount * (taxRate / 100);
|
||||
|
||||
// Create tax subtotal
|
||||
const taxSubtotal = doc.ele('cac:TaxTotal')
|
||||
.ele('cbc:TaxAmount').txt(taxAmount.toFixed(2))
|
||||
.att('currencyID', invoice.currency)
|
||||
.up();
|
||||
|
||||
taxSubtotal.ele('cac:TaxSubtotal')
|
||||
.ele('cbc:TaxableAmount')
|
||||
.txt(taxableAmount.toFixed(2))
|
||||
.att('currencyID', invoice.currency)
|
||||
.up()
|
||||
.ele('cbc:TaxAmount')
|
||||
.txt(taxAmount.toFixed(2))
|
||||
.att('currencyID', invoice.currency)
|
||||
.up()
|
||||
.ele('cac:TaxCategory')
|
||||
.ele('cbc:ID').txt('S').up() // Standard rate
|
||||
.ele('cbc:Percent').txt(taxRate.toFixed(2)).up()
|
||||
.ele('cac:TaxScheme')
|
||||
.ele('cbc:ID').txt('VAT').up()
|
||||
.up()
|
||||
.up()
|
||||
.up();
|
||||
});
|
||||
|
||||
// Calculate invoice totals
|
||||
let lineExtensionAmount = 0;
|
||||
let taxExclusiveAmount = 0;
|
||||
let taxInclusiveAmount = 0;
|
||||
let totalVat = 0;
|
||||
|
||||
// Sum all items
|
||||
invoice.items.forEach(item => {
|
||||
const net = item.unitNetPrice * item.unitQuantity;
|
||||
const vat = net * (item.vatPercentage / 100);
|
||||
|
||||
lineExtensionAmount += net;
|
||||
taxExclusiveAmount += net;
|
||||
totalVat += vat;
|
||||
});
|
||||
|
||||
taxInclusiveAmount = taxExclusiveAmount + totalVat;
|
||||
|
||||
// Legal monetary total
|
||||
const legalMonetaryTotal = doc.ele('cac:LegalMonetaryTotal');
|
||||
legalMonetaryTotal.ele('cbc:LineExtensionAmount')
|
||||
.txt(lineExtensionAmount.toFixed(2))
|
||||
.att('currencyID', invoice.currency)
|
||||
.up();
|
||||
|
||||
legalMonetaryTotal.ele('cbc:TaxExclusiveAmount')
|
||||
.txt(taxExclusiveAmount.toFixed(2))
|
||||
.att('currencyID', invoice.currency)
|
||||
.up();
|
||||
|
||||
legalMonetaryTotal.ele('cbc:TaxInclusiveAmount')
|
||||
.txt(taxInclusiveAmount.toFixed(2))
|
||||
.att('currencyID', invoice.currency)
|
||||
.up();
|
||||
|
||||
legalMonetaryTotal.ele('cbc:PayableAmount')
|
||||
.txt(taxInclusiveAmount.toFixed(2))
|
||||
.att('currencyID', invoice.currency)
|
||||
.up();
|
||||
|
||||
// Invoice lines
|
||||
invoice.items.forEach((item, index) => {
|
||||
const invoiceLine = doc.ele('cac:InvoiceLine');
|
||||
invoiceLine.ele('cbc:ID').txt((index + 1).toString()).up();
|
||||
|
||||
// Quantity
|
||||
invoiceLine.ele('cbc:InvoicedQuantity')
|
||||
.txt(item.unitQuantity.toString())
|
||||
.att('unitCode', this.mapUnitType(item.unitType))
|
||||
.up();
|
||||
|
||||
// Line extension amount (net)
|
||||
const lineAmount = item.unitNetPrice * item.unitQuantity;
|
||||
invoiceLine.ele('cbc:LineExtensionAmount')
|
||||
.txt(lineAmount.toFixed(2))
|
||||
.att('currencyID', invoice.currency)
|
||||
.up();
|
||||
|
||||
// Item details
|
||||
const itemEle = invoiceLine.ele('cac:Item');
|
||||
itemEle.ele('cbc:Description').txt(item.name).up();
|
||||
itemEle.ele('cbc:Name').txt(item.name).up();
|
||||
|
||||
// Classified tax category
|
||||
itemEle.ele('cac:ClassifiedTaxCategory')
|
||||
.ele('cbc:ID').txt('S').up() // Standard rate
|
||||
.ele('cbc:Percent').txt(item.vatPercentage.toFixed(2)).up()
|
||||
.ele('cac:TaxScheme')
|
||||
.ele('cbc:ID').txt('VAT').up()
|
||||
.up()
|
||||
.up();
|
||||
|
||||
// Price
|
||||
invoiceLine.ele('cac:Price')
|
||||
.ele('cbc:PriceAmount')
|
||||
.txt(item.unitNetPrice.toFixed(2))
|
||||
.att('currencyID', invoice.currency)
|
||||
.up()
|
||||
.up();
|
||||
});
|
||||
|
||||
// Return the formatted XML
|
||||
return doc.end({ prettyPrint: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Map your custom 'unitType' to an ISO code.
|
||||
*/
|
||||
private mapUnitType(unitType: string): string {
|
||||
switch (unitType.toLowerCase()) {
|
||||
case 'hour':
|
||||
case 'hours':
|
||||
return 'HUR';
|
||||
case 'day':
|
||||
case 'days':
|
||||
return 'DAY';
|
||||
case 'piece':
|
||||
case 'pieces':
|
||||
return 'C62';
|
||||
default:
|
||||
return 'C62'; // fallback for unknown unit types
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user