feat(core): Add EInvoiceCreator class for generating ZUGFeRD/Factur-X XML
This commit is contained in:
parent
78ba483d35
commit
d573d95ebc
@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 2024-12-30 - 1.1.0 - feat(core)
|
||||
Add EInvoiceCreator class for generating ZUGFeRD/Factur-X XML
|
||||
|
||||
- Introduced EInvoiceCreator class to convert invoice data into ZUGFeRD/Factur-X XML.
|
||||
- Updated development dependencies to enhance TypeScript support.
|
||||
- Added SmartXML and TSClass plugins for XML handling and business logic.
|
||||
|
||||
## 2024-07-02 - 1.0.6 - fix(core)
|
||||
Project files committed with initial structure and class implementation
|
||||
|
||||
|
16
package.json
16
package.json
@ -14,15 +14,17 @@
|
||||
"buildDocs": "(tsdoc)"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.1.25",
|
||||
"@git.zone/tsbundle": "^2.0.5",
|
||||
"@git.zone/tsrun": "^1.2.46",
|
||||
"@git.zone/tstest": "^1.0.44",
|
||||
"@push.rocks/tapbundle": "^5.0.15",
|
||||
"@types/node": "^20.8.7"
|
||||
"@git.zone/tsbuild": "^2.2.0",
|
||||
"@git.zone/tsbundle": "^2.1.0",
|
||||
"@git.zone/tsrun": "^1.3.3",
|
||||
"@git.zone/tstest": "^1.0.90",
|
||||
"@push.rocks/tapbundle": "^5.5.4",
|
||||
"@types/node": "^22.10.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/smartfile": "^11.0.14",
|
||||
"@push.rocks/smartfile": "^11.0.23",
|
||||
"@push.rocks/smartxml": "^1.1.1",
|
||||
"@tsclass/tsclass": "^4.2.0",
|
||||
"pdf-lib": "^1.17.1"
|
||||
},
|
||||
"repository": {
|
||||
|
10865
pnpm-lock.yaml
generated
10865
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@fin.cx/xinvoice',
|
||||
version: '1.0.6',
|
||||
version: '1.1.0',
|
||||
description: 'A module for creating, manipulating, and embedding XML data within PDF files for xinvoice packages.'
|
||||
}
|
||||
|
275
ts/classes.encoder.ts
Normal file
275
ts/classes.encoder.ts
Normal file
@ -0,0 +1,275 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
// If you don't already have these imported, ensure they match your real file paths:
|
||||
// import type { IInvoice, IInvoiceItem } from './path/to/IInvoice';
|
||||
// import type { ILetter, IContact } from './path/to/ILetter';
|
||||
// import type { IAddress } from './path/to/IAddress';
|
||||
|
||||
/**
|
||||
* A class to convert a given ILetter with invoice data
|
||||
* into a minimal Factur-X / ZUGFeRD / EN16931-style XML.
|
||||
*/
|
||||
export class EInvoiceCreator {
|
||||
private letter: plugins.tsclass.business.ILetter;
|
||||
|
||||
constructor(letter: plugins.tsclass.business.ILetter) {
|
||||
this.letter = letter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an XML string representing the e-invoice (ZUGFeRD/Factur-X).
|
||||
* Note: This is a *high-level* example. Real compliance requires
|
||||
* correct namespacing, mandatory fields, etc.
|
||||
*/
|
||||
public createZugferdXml(): string {
|
||||
// 1) Get your "SmartXml" or "xmlbuilder2" instance
|
||||
const smartxmlInstance = new plugins.smartxml.SmartXml();
|
||||
|
||||
if (!this.letter.content.invoiceData) {
|
||||
throw new Error('Letter does not contain invoice data.');
|
||||
}
|
||||
|
||||
const invoice: plugins.tsclass.finance.IInvoice = this.letter.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;
|
||||
}
|
||||
}
|
@ -7,9 +7,11 @@ export {
|
||||
|
||||
// @push.rocks scope
|
||||
import * as smartfile from '@push.rocks/smartfile';
|
||||
import * as smartxml from '@push.rocks/smartxml';
|
||||
|
||||
export {
|
||||
smartfile
|
||||
smartfile,
|
||||
smartxml
|
||||
}
|
||||
|
||||
// third party
|
||||
@ -17,4 +19,11 @@ import * as pdfLib from 'pdf-lib';
|
||||
|
||||
export {
|
||||
pdfLib
|
||||
}
|
||||
}
|
||||
|
||||
// tsclass scope
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
|
||||
export {
|
||||
tsclass
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user