262 lines
9.7 KiB
TypeScript
262 lines
9.7 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
|
|
/**
|
|
* A class to convert a given ILetter with invoice data
|
|
* into a minimal Factur-X / ZUGFeRD / EN16931-style XML.
|
|
*/
|
|
export class ZugferdXmlEncoder {
|
|
|
|
constructor() {}
|
|
|
|
public createZugferdXml(letterArg: plugins.tsclass.business.ILetter): string {
|
|
// 1) Get your "SmartXml" or "xmlbuilder2" instance
|
|
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;
|
|
|
|
// 2) Start building the document
|
|
const doc = smartxmlInstance
|
|
.create({ version: '1.0', encoding: 'UTF-8' })
|
|
.ele('rsm:CrossIndustryInvoice', {
|
|
'xmlns:rsm': 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100',
|
|
'xmlns:udt': 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100',
|
|
'xmlns:qdt': 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100',
|
|
'xmlns:ram': 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100'
|
|
});
|
|
|
|
// 3) Exchanged Document Context
|
|
doc.ele('rsm:ExchangedDocumentContext')
|
|
.ele('ram:TestIndicator')
|
|
.ele('udt:Indicator')
|
|
.txt(this.isDraft() ? 'true' : 'false')
|
|
.up()
|
|
.up()
|
|
.up(); // </rsm:ExchangedDocumentContext>
|
|
|
|
// 4) Exchanged Document (Invoice Header Info)
|
|
const exchangedDoc = doc.ele('rsm:ExchangedDocument');
|
|
exchangedDoc.ele('ram:ID').txt(invoice.id).up();
|
|
exchangedDoc
|
|
.ele('ram:TypeCode')
|
|
// Usually: '380' = commercial invoice, '381' = credit note
|
|
.txt(invoice.type === 'creditnote' ? '381' : '380')
|
|
.up();
|
|
exchangedDoc
|
|
.ele('ram:IssueDateTime')
|
|
.ele('udt:DateTimeString', { format: '102' })
|
|
// Format 'YYYYMMDD' or 'YYYY-MM-DD'? Depending on standard
|
|
.txt(this.formatDate(this.letter.date, 'yyyyMMdd'))
|
|
.up()
|
|
.up();
|
|
exchangedDoc.up(); // </rsm:ExchangedDocument>
|
|
|
|
// 5) Supply Chain Trade Transaction
|
|
const supplyChainEle = doc.ele('rsm:SupplyChainTradeTransaction');
|
|
|
|
// 5.1) Included Supply Chain Trade Line Items
|
|
invoice.items.forEach((item) => {
|
|
const lineItemEle = supplyChainEle.ele('ram:IncludedSupplyChainTradeLineItem');
|
|
|
|
lineItemEle.ele('ram:SpecifiedTradeProduct')
|
|
.ele('ram:Name')
|
|
.txt(item.name)
|
|
.up()
|
|
.up(); // </ram:SpecifiedTradeProduct>
|
|
|
|
lineItemEle.ele('ram:SpecifiedLineTradeAgreement')
|
|
.ele('ram:GrossPriceProductTradePrice')
|
|
.ele('ram:ChargeAmount')
|
|
.txt(item.unitNetPrice.toFixed(2))
|
|
.up()
|
|
.up()
|
|
.up(); // </ram:SpecifiedLineTradeAgreement>
|
|
|
|
lineItemEle.ele('ram:SpecifiedLineTradeDelivery')
|
|
.ele('ram:BilledQuantity', {
|
|
'@unitCode': this.mapUnitType(item.unitType)
|
|
})
|
|
.txt(item.unitQuantity.toString())
|
|
.up()
|
|
.up(); // </ram:SpecifiedLineTradeDelivery>
|
|
|
|
lineItemEle.ele('ram:SpecifiedLineTradeSettlement')
|
|
.ele('ram:ApplicableTradeTax')
|
|
.ele('ram:RateApplicablePercent')
|
|
.txt(item.vatPercentage.toFixed(2))
|
|
.up()
|
|
.up()
|
|
.ele('ram:SpecifiedTradeSettlementLineMonetarySummation')
|
|
.ele('ram:LineTotalAmount')
|
|
.txt(
|
|
(
|
|
item.unitQuantity *
|
|
item.unitNetPrice *
|
|
(1 + item.vatPercentage / 100)
|
|
).toFixed(2)
|
|
)
|
|
.up()
|
|
.up()
|
|
.up(); // </ram:SpecifiedLineTradeSettlement>
|
|
});
|
|
|
|
// 5.2) Applicable Header Trade Agreement
|
|
const headerTradeAgreementEle = supplyChainEle.ele('ram:ApplicableHeaderTradeAgreement');
|
|
// Seller
|
|
const sellerPartyEle = headerTradeAgreementEle.ele('ram:SellerTradeParty');
|
|
sellerPartyEle.ele('ram:Name').txt(billedBy.name).up();
|
|
// Example: If it's a company, put company name, etc.
|
|
const sellerAddressEle = sellerPartyEle.ele('ram:PostalTradeAddress');
|
|
sellerAddressEle.ele('ram:PostcodeCode').txt(billedBy.address.postalCode).up();
|
|
sellerAddressEle.ele('ram:LineOne').txt(billedBy.address.streetName).up();
|
|
sellerAddressEle.ele('ram:CityName').txt(billedBy.address.city).up();
|
|
// Typically you'd include 'ram:CountryID' with ISO2 code, e.g. "DE"
|
|
sellerAddressEle.up(); // </ram:PostalTradeAddress>
|
|
sellerPartyEle.up(); // </ram:SellerTradeParty>
|
|
|
|
// Buyer
|
|
const buyerPartyEle = headerTradeAgreementEle.ele('ram:BuyerTradeParty');
|
|
buyerPartyEle.ele('ram:Name').txt(billedTo.name).up();
|
|
const buyerAddressEle = buyerPartyEle.ele('ram:PostalTradeAddress');
|
|
buyerAddressEle.ele('ram:PostcodeCode').txt(billedTo.address.postalCode).up();
|
|
buyerAddressEle.ele('ram:LineOne').txt(billedTo.address.streetName).up();
|
|
buyerAddressEle.ele('ram:CityName').txt(billedTo.address.city).up();
|
|
buyerAddressEle.up(); // </ram:PostalTradeAddress>
|
|
buyerPartyEle.up(); // </ram:BuyerTradeParty>
|
|
headerTradeAgreementEle.up(); // </ram:ApplicableHeaderTradeAgreement>
|
|
|
|
// 5.3) Applicable Header Trade Delivery
|
|
const headerTradeDeliveryEle = supplyChainEle.ele('ram:ApplicableHeaderTradeDelivery');
|
|
const actualDeliveryEle = headerTradeDeliveryEle.ele('ram:ActualDeliverySupplyChainEvent');
|
|
const occurrenceEle = actualDeliveryEle.ele('ram:OccurrenceDateTime')
|
|
.ele('udt:DateTimeString', { format: '102' });
|
|
|
|
const deliveryDate = invoice.deliveryDate || this.letter.date;
|
|
occurrenceEle.txt(this.formatDate(deliveryDate, 'yyyyMMdd')).up();
|
|
actualDeliveryEle.up(); // </ram:ActualDeliverySupplyChainEvent>
|
|
headerTradeDeliveryEle.up(); // </ram:ApplicableHeaderTradeDelivery>
|
|
|
|
// 5.4) Applicable Header Trade Settlement
|
|
const headerTradeSettlementEle = supplyChainEle.ele('ram:ApplicableHeaderTradeSettlement');
|
|
// Tax currency code, doc currency code, etc.
|
|
headerTradeSettlementEle.ele('ram:InvoiceCurrencyCode').txt(invoice.currency).up();
|
|
|
|
// Example single tax breakdown
|
|
const tradeTaxEle = headerTradeSettlementEle.ele('ram:ApplicableTradeTax');
|
|
tradeTaxEle.ele('ram:TypeCode').txt('VAT').up();
|
|
tradeTaxEle.ele('ram:CalculatedAmount').txt(this.sumAllVat(invoice).toFixed(2)).up();
|
|
tradeTaxEle
|
|
.ele('ram:RateApplicablePercent')
|
|
.txt(this.extractMainVatRate(invoice.items).toFixed(2))
|
|
.up();
|
|
tradeTaxEle.up(); // </ram:ApplicableTradeTax>
|
|
|
|
// Payment Terms
|
|
const paymentTermsEle = headerTradeSettlementEle.ele('ram:SpecifiedTradePaymentTerms');
|
|
paymentTermsEle.ele('ram:Description').txt(`Payment due in ${invoice.dueInDays} days.`).up();
|
|
paymentTermsEle.up(); // </ram:SpecifiedTradePaymentTerms>
|
|
|
|
// Monetary Summation
|
|
const monetarySummationEle = headerTradeSettlementEle.ele('ram:SpecifiedTradeSettlementHeaderMonetarySummation');
|
|
monetarySummationEle
|
|
.ele('ram:LineTotalAmount')
|
|
.txt(this.calcLineTotalNet(invoice).toFixed(2))
|
|
.up();
|
|
monetarySummationEle
|
|
.ele('ram:TaxTotalAmount')
|
|
.txt(this.sumAllVat(invoice).toFixed(2))
|
|
.up();
|
|
monetarySummationEle
|
|
.ele('ram:GrandTotalAmount')
|
|
.txt(this.calcGrandTotal(invoice).toFixed(2))
|
|
.up();
|
|
monetarySummationEle.up(); // </ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
|
headerTradeSettlementEle.up(); // </ram:ApplicableHeaderTradeSettlement>
|
|
|
|
supplyChainEle.up(); // </rsm:SupplyChainTradeTransaction>
|
|
doc.up(); // </rsm:CrossIndustryInvoice>
|
|
|
|
// 6) Return the final XML string
|
|
return doc.end({ prettyPrint: true });
|
|
}
|
|
|
|
/**
|
|
* Helper: Determine if the letter is in draft or final.
|
|
*/
|
|
private isDraft(): boolean {
|
|
return this.letter.versionInfo?.type === 'draft';
|
|
}
|
|
|
|
/**
|
|
* Helper: Format date to certain patterns (very minimal example).
|
|
* e.g. 'yyyyMMdd' => '20231231'
|
|
*/
|
|
private formatDate(timestampMs: number, pattern: 'yyyyMMdd'): string {
|
|
const date = new Date(timestampMs);
|
|
const yyyy = date.getFullYear();
|
|
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
|
const dd = String(date.getDate()).padStart(2, '0');
|
|
return `${yyyy}${mm}${dd}`;
|
|
}
|
|
|
|
/**
|
|
* Helper: Map your custom 'unitType' to an ISO code or similar.
|
|
*/
|
|
private mapUnitType(unitType: string): string {
|
|
switch (unitType.toLowerCase()) {
|
|
case 'hour':
|
|
return 'HUR';
|
|
case 'piece':
|
|
return 'C62';
|
|
default:
|
|
return 'C62'; // fallback
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Example: Sum all VAT amounts from items.
|
|
*/
|
|
private sumAllVat(invoice: plugins.tsclass.finance.IInvoice): number {
|
|
return invoice.items.reduce((acc, item) => {
|
|
const net = item.unitNetPrice * item.unitQuantity;
|
|
const vat = net * (item.vatPercentage / 100);
|
|
return acc + vat;
|
|
}, 0);
|
|
}
|
|
|
|
/**
|
|
* Example: Extract main (or highest) VAT rate from items as representative.
|
|
* In reality, you might list multiple 'ApplicableTradeTax' blocks by group.
|
|
*/
|
|
private extractMainVatRate(items: plugins.tsclass.finance.IInvoiceItem[]): number {
|
|
let max = 0;
|
|
items.forEach((item) => {
|
|
if (item.vatPercentage > max) max = item.vatPercentage;
|
|
});
|
|
return max;
|
|
}
|
|
|
|
/**
|
|
* Example: Sum net amounts (without VAT).
|
|
*/
|
|
private calcLineTotalNet(invoice: plugins.tsclass.finance.IInvoice): number {
|
|
return invoice.items.reduce((acc, item) => {
|
|
const net = item.unitNetPrice * item.unitQuantity;
|
|
return acc + net;
|
|
}, 0);
|
|
}
|
|
|
|
/**
|
|
* Example: net + VAT = grand total
|
|
*/
|
|
private calcGrandTotal(invoice: plugins.tsclass.finance.IInvoice): number {
|
|
const net = this.calcLineTotalNet(invoice);
|
|
const vat = this.sumAllVat(invoice);
|
|
return net + vat;
|
|
}
|
|
} |