update
This commit is contained in:
345
ts/formats/facturx.encoder.ts
Normal file
345
ts/formats/facturx.encoder.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* A class to convert a given ILetter with invoice data
|
||||
* into a Factur-X compliant XML (also compatible with ZUGFeRD and EN16931).
|
||||
*
|
||||
* Factur-X is the French implementation of the European e-invoicing standard EN16931,
|
||||
* which is also implemented in Germany as ZUGFeRD. Both formats are based on
|
||||
* UN/CEFACT Cross Industry Invoice (CII) XML schemas.
|
||||
*/
|
||||
export class FacturXEncoder {
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Alias for createFacturXXml to maintain backward compatibility
|
||||
*/
|
||||
public createZugferdXml(letterArg: plugins.tsclass.business.ILetter): string {
|
||||
return this.createFacturXXml(letterArg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Factur-X compliant XML based on the provided letter data.
|
||||
* This XML is also compliant with ZUGFeRD and EN16931 standards.
|
||||
*/
|
||||
public createFacturXXml(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
|
||||
const docContext = doc.ele('rsm:ExchangedDocumentContext');
|
||||
|
||||
// Add test indicator
|
||||
docContext.ele('ram:TestIndicator')
|
||||
.ele('udt:Indicator')
|
||||
.txt(this.isDraft(letterArg) ? 'true' : 'false')
|
||||
.up()
|
||||
.up();
|
||||
|
||||
// Add Factur-X profile information
|
||||
// EN16931 profile is compliant with both Factur-X and ZUGFeRD
|
||||
docContext.ele('ram:GuidelineSpecifiedDocumentContextParameter')
|
||||
.ele('ram:ID')
|
||||
.txt('urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:en16931')
|
||||
.up()
|
||||
.up();
|
||||
|
||||
docContext.up(); // </rsm:ExchangedDocumentContext>
|
||||
|
||||
// 4) Exchanged Document (Invoice Header Info)
|
||||
const exchangedDoc = doc.ele('rsm:ExchangedDocument');
|
||||
|
||||
// Invoice ID
|
||||
exchangedDoc.ele('ram:ID').txt(invoice.id).up();
|
||||
|
||||
// Document type code
|
||||
// 380 = commercial invoice, 381 = credit note
|
||||
const documentTypeCode = invoice.type === 'creditnote' ? '381' : '380';
|
||||
exchangedDoc.ele('ram:TypeCode').txt(documentTypeCode).up();
|
||||
|
||||
// Issue date
|
||||
exchangedDoc
|
||||
.ele('ram:IssueDateTime')
|
||||
.ele('udt:DateTimeString', { format: '102' })
|
||||
// Format 'YYYYMMDD' as per Factur-X specification
|
||||
.txt(this.formatDate(letterArg.date))
|
||||
.up()
|
||||
.up();
|
||||
|
||||
// Document name - Factur-X recommended field
|
||||
const documentName = invoice.type === 'creditnote' ? 'CREDIT NOTE' : 'INVOICE';
|
||||
exchangedDoc.ele('ram:Name').txt(documentName).up();
|
||||
|
||||
// Optional: Add language indicator (recommended for Factur-X)
|
||||
// Use document language if specified, default to 'en'
|
||||
const languageCode = letterArg.language?.toUpperCase() || 'EN';
|
||||
exchangedDoc
|
||||
.ele('ram:IncludedNote')
|
||||
.ele('ram:Content').txt('Invoice created with Factur-X compliant software').up()
|
||||
.ele('ram:SubjectCode').txt('REG').up() // REG = regulatory information
|
||||
.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')
|
||||
.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 || letterArg.date;
|
||||
occurrenceEle.txt(this.formatDate(deliveryDate)).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');
|
||||
|
||||
// Payment description
|
||||
paymentTermsEle.ele('ram:Description').txt(`Payment due in ${invoice.dueInDays} days.`).up();
|
||||
|
||||
// Due date calculation
|
||||
const dueDate = new Date(letterArg.date);
|
||||
dueDate.setDate(dueDate.getDate() + invoice.dueInDays);
|
||||
|
||||
// Add due date as per Factur-X spec
|
||||
paymentTermsEle
|
||||
.ele('ram:DueDateDateTime')
|
||||
.ele('udt:DateTimeString', { format: '102' })
|
||||
.txt(this.formatDate(dueDate.getTime()))
|
||||
.up()
|
||||
.up();
|
||||
|
||||
// Add payment means if available
|
||||
if (invoice.billedBy.sepaConnection) {
|
||||
// Add SEPA information as per Factur-X standard
|
||||
const paymentMeans = headerTradeSettlementEle.ele('ram:SpecifiedTradeSettlementPaymentMeans');
|
||||
paymentMeans.ele('ram:TypeCode').txt('58').up(); // 58 = SEPA credit transfer
|
||||
|
||||
// Payment reference (for bank statement reconciliation)
|
||||
paymentMeans.ele('ram:Information').txt(`Reference: ${invoice.id}`).up();
|
||||
|
||||
// Payee account (IBAN)
|
||||
if (invoice.billedBy.sepaConnection.iban) {
|
||||
const payeeAccount = paymentMeans.ele('ram:PayeePartyCreditorFinancialAccount');
|
||||
payeeAccount.ele('ram:IBANID').txt(invoice.billedBy.sepaConnection.iban).up();
|
||||
payeeAccount.up();
|
||||
}
|
||||
|
||||
// Bank BIC
|
||||
if (invoice.billedBy.sepaConnection.bic) {
|
||||
const payeeBank = paymentMeans.ele('ram:PayeeSpecifiedCreditorFinancialInstitution');
|
||||
payeeBank.ele('ram:BICID').txt(invoice.billedBy.sepaConnection.bic).up();
|
||||
payeeBank.up();
|
||||
}
|
||||
|
||||
paymentMeans.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(letterArg: plugins.tsclass.business.ILetter): boolean {
|
||||
return letterArg.versionInfo?.type === 'draft';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Format date to certain patterns (very minimal example).
|
||||
* e.g. 'yyyyMMdd' => '20231231'
|
||||
*/
|
||||
private formatDate(timestampMs: number): 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user