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
|
# 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)
|
## 2024-07-02 - 1.0.6 - fix(core)
|
||||||
Project files committed with initial structure and class implementation
|
Project files committed with initial structure and class implementation
|
||||||
|
|
||||||
|
16
package.json
16
package.json
@ -14,15 +14,17 @@
|
|||||||
"buildDocs": "(tsdoc)"
|
"buildDocs": "(tsdoc)"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.1.25",
|
"@git.zone/tsbuild": "^2.2.0",
|
||||||
"@git.zone/tsbundle": "^2.0.5",
|
"@git.zone/tsbundle": "^2.1.0",
|
||||||
"@git.zone/tsrun": "^1.2.46",
|
"@git.zone/tsrun": "^1.3.3",
|
||||||
"@git.zone/tstest": "^1.0.44",
|
"@git.zone/tstest": "^1.0.90",
|
||||||
"@push.rocks/tapbundle": "^5.0.15",
|
"@push.rocks/tapbundle": "^5.5.4",
|
||||||
"@types/node": "^20.8.7"
|
"@types/node": "^22.10.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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"
|
"pdf-lib": "^1.17.1"
|
||||||
},
|
},
|
||||||
"repository": {
|
"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 = {
|
export const commitinfo = {
|
||||||
name: '@fin.cx/xinvoice',
|
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.'
|
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
|
// @push.rocks scope
|
||||||
import * as smartfile from '@push.rocks/smartfile';
|
import * as smartfile from '@push.rocks/smartfile';
|
||||||
|
import * as smartxml from '@push.rocks/smartxml';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
smartfile
|
smartfile,
|
||||||
|
smartxml
|
||||||
}
|
}
|
||||||
|
|
||||||
// third party
|
// third party
|
||||||
@ -17,4 +19,11 @@ import * as pdfLib from 'pdf-lib';
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
pdfLib
|
pdfLib
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tsclass scope
|
||||||
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
|
|
||||||
|
export {
|
||||||
|
tsclass
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user