@casoon/invoice-generator
Version:
Generate PDF invoices, XRechnung XML, and ZUGFeRD PDF/A-3 from JSON data with TypeScript support
279 lines (251 loc) • 11.5 kB
text/typescript
import { Invoice } from '../../types/invoice.types';
import fs from 'fs';
import path from 'path';
export interface ZUGFeRDOptions {
profile?: 'BASIC' | 'COMFORT' | 'EXTENDED';
version?: '2.1.1' | '2.2.0';
conformanceLevel?: 'A' | 'B' | 'U';
outputFormat?: 'PDF/A-3' | 'PDF/A-3b';
}
export class ZUGFeRDPDFA3Generator {
private invoice: Invoice;
private options: Required<ZUGFeRDOptions>;
constructor(invoice: Invoice, options: ZUGFeRDOptions = {}) {
this.invoice = invoice;
this.options = {
profile: 'BASIC',
version: '2.1.1',
conformanceLevel: 'A',
outputFormat: 'PDF/A-3',
...options
};
}
/**
* Generiert eine vollständige ZUGFeRD-konforme PDF/A-3 Datei
* Hinweis: Für echte PDF/A-3 Implementierung wird eine spezielle Library benötigt
*/
public async generateZUGFeRDPDFA3(outputPath: string): Promise<void> {
try {
// Generiere XML-Inhalt
const xmlContent = this.generateZUGFeRDXML();
// Speichere XML separat (für Testzwecke)
const xmlPath = outputPath.replace('.pdf', '.xml');
fs.writeFileSync(xmlPath, xmlContent, 'utf8');
// Generiere PDF mit eingebettetem XML
await this.generatePDFWithEmbeddedXML(outputPath, xmlContent);
console.log(`✅ ZUGFeRD PDF/A-3 erfolgreich erstellt: ${outputPath}`);
console.log(`📄 XML-Inhalt gespeichert: ${xmlPath}`);
} catch (error) {
console.error('❌ Fehler beim Erstellen der ZUGFeRD PDF/A-3:', error);
throw error;
}
}
/**
* Generiert das ZUGFeRD-konforme XML
*/
private generateZUGFeRDXML(): string {
const profile = this.getProfileIdentifier();
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rsm:CrossIndustryInvoice xmlns:rsm="urn:ferd:CrossIndustryDocument:invoice:2p0" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
<rsm:ExchangedDocumentContext>
<ram:TestIndicator>
<udt:Indicator>false</udt:Indicator>
</ram:TestIndicator>
<ram:BusinessProcessSpecifiedDocumentContextParameter>
<ram:ID>${profile}</ram:ID>
</ram:BusinessProcessSpecifiedDocumentContextParameter>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:cen.eu:en16931:2017</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>
</rsm:ExchangedDocumentContext>
<rsm:ExchangedDocument>
<ram:ID>${this.escapeXml(this.invoice.details.invoiceNumber)}</ram:ID>
<ram:TypeCode>380</ram:TypeCode>
<ram:IssueDateTime>
<udt:DateTimeString format="102">${this.formatDate(this.invoice.details.invoiceDate)}</udt:DateTimeString>
</ram:IssueDateTime>
</rsm:ExchangedDocument>
<rsm:SupplyChainTradeTransaction>
<ram:IncludedSupplyChainTradeLineItem>
${this.generateLineItems()}
</ram:IncludedSupplyChainTradeLineItem>
<ram:ApplicableHeaderTradeAgreement>
<ram:SellerTradeParty>
<ram:Name>${this.escapeXml(this.invoice.sender.name)}</ram:Name>
<ram:PostalTradeAddress>
<ram:LineOne>${this.escapeXml(this.invoice.sender.address.street)}</ram:LineOne>
<ram:CityName>${this.escapeXml(this.invoice.sender.address.city)}</ram:CityName>
<ram:PostcodeCode>${this.escapeXml(this.invoice.sender.address.postalCode)}</ram:PostcodeCode>
<ram:CountryID>DE</ram:CountryID>
</ram:PostalTradeAddress>
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="FC">${this.escapeXml(this.invoice.sender.vatId || '')}</ram:ID>
</ram:SpecifiedTaxRegistration>
<ram:DefinedTradeContact>
<ram:PersonName>${this.escapeXml(this.invoice.sender.businessOwner || '')}</ram:PersonName>
<ram:TelephoneUniversalCommunication>
<ram:CompleteNumber>${this.escapeXml(this.invoice.sender.contactInfo.phone)}</ram:CompleteNumber>
</ram:TelephoneUniversalCommunication>
<ram:EmailURIUniversalCommunication>
<ram:URIID>${this.escapeXml(this.invoice.sender.contactInfo.email)}</ram:URIID>
</ram:EmailURIUniversalCommunication>
</ram:DefinedTradeContact>
</ram:SellerTradeParty>
<ram:BuyerTradeParty>
<ram:Name>${this.escapeXml(this.invoice.recipient.name)}</ram:Name>
<ram:PostalTradeAddress>
<ram:LineOne>${this.escapeXml(this.invoice.recipient.address.street)}</ram:LineOne>
<ram:CityName>${this.escapeXml(this.invoice.recipient.address.city)}</ram:CityName>
<ram:PostcodeCode>${this.escapeXml(this.invoice.recipient.address.postalCode)}</ram:PostcodeCode>
<ram:CountryID>DE</ram:CountryID>
</ram:PostalTradeAddress>
</ram:BuyerTradeParty>
</ram:ApplicableHeaderTradeAgreement>
<ram:ApplicableHeaderTradeSettlement>
<ram:InvoiceCurrencyCode>${this.invoice.totals.currency}</ram:InvoiceCurrencyCode>
<ram:SpecifiedTradeSettlementPaymentMeans>
<ram:TypeCode>30</ram:TypeCode>
<ram:Information>${this.escapeXml(this.invoice.paymentInfo.bank)}</ram:Information>
<ram:PayeePartyCreditorFinancialAccount>
<ram:IBANID>${this.escapeXml(this.invoice.paymentInfo.iban)}</ram:IBANID>
</ram:PayeePartyCreditorFinancialAccount>
<ram:PayeeSpecifiedCreditorFinancialInstitution>
<ram:BICID>${this.escapeXml(this.invoice.paymentInfo.bic)}</ram:BICID>
</ram:PayeeSpecifiedCreditorFinancialInstitution>
</ram:SpecifiedTradeSettlementPaymentMeans>
<ram:ApplicableTradeTax>
<ram:CalculatedAmount>${this.invoice.totals.vatAmount.toFixed(2)}</ram:CalculatedAmount>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:BasisAmount>${this.invoice.totals.netAmount.toFixed(2)}</ram:BasisAmount>
<ram:ApplicablePercent>${this.invoice.totals.vatRate.toFixed(2)}</ram:ApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradePaymentTerms>
<ram:Description>${this.escapeXml(this.invoice.paymentInfo.paymentTerms)}</ram:Description>
</ram:SpecifiedTradePaymentTerms>
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
<ram:LineTotalAmount>${this.invoice.totals.netAmount.toFixed(2)}</ram:LineTotalAmount>
<ram:TaxBasisTotalAmount>${this.invoice.totals.netAmount.toFixed(2)}</ram:TaxBasisTotalAmount>
<ram:TaxTotalAmount>${this.invoice.totals.vatAmount.toFixed(2)}</ram:TaxTotalAmount>
<ram:GrandTotalAmount>${this.invoice.totals.grossAmount.toFixed(2)}</ram:GrandTotalAmount>
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
</ram:ApplicableHeaderTradeSettlement>
</rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>`;
return xml;
}
/**
* Generiert die einzelnen Rechnungspositionen
*/
private generateLineItems(): string {
return this.invoice.items.map((item, index) => `
<ram:IncludedSupplyChainTradeLineItem>
<ram:AssociatedDocumentLineDocument>
<ram:LineID>${index + 1}</ram:LineID>
</ram:AssociatedDocumentLineDocument>
<ram:SpecifiedTradeProduct>
<ram:Name>${this.escapeXml(item.description)}</ram:Name>
</ram:SpecifiedTradeProduct>
<ram:SpecifiedLineTradeAgreement>
<ram:NetPriceProductTradePrice>
<ram:ChargeAmount>${item.unitPrice.toFixed(2)}</ram:ChargeAmount>
</ram:NetPriceProductTradePrice>
</ram:SpecifiedLineTradeAgreement>
<ram:SpecifiedLineTradeDelivery>
<ram:BilledQuantity>${item.quantity}</ram:BilledQuantity>
</ram:SpecifiedLineTradeDelivery>
<ram:SpecifiedLineTradeSettlement>
<ram:ApplicableTradeTax>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:ApplicablePercent>${item.vatRate.toFixed(2)}</ram:ApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradeSettlementLineMonetarySummation>
<ram:LineTotalAmount>${item.total.toFixed(2)}</ram:LineTotalAmount>
</ram:SpecifiedTradeSettlementLineMonetarySummation>
</ram:SpecifiedLineTradeSettlement>
</ram:IncludedSupplyChainTradeLineItem>`).join('');
}
/**
* Ermittelt den korrekten Profil-Identifier basierend auf ZUGFeRD-Version und Profil
*/
private getProfileIdentifier(): string {
const { profile, version } = this.options;
const profileMap: Record<string, Record<string, string>> = {
'2.1.1': {
'BASIC': 'urn:ferd:CrossIndustryDocument:invoice:2p0:basic',
'COMFORT': 'urn:ferd:CrossIndustryDocument:invoice:2p0:comfort',
'EXTENDED': 'urn:ferd:CrossIndustryDocument:invoice:2p0:extended'
},
'2.2.0': {
'BASIC': 'urn:ferd:CrossIndustryDocument:invoice:2p0:basic',
'COMFORT': 'urn:ferd:CrossIndustryDocument:invoice:2p0:comfort',
'EXTENDED': 'urn:ferd:CrossIndustryDocument:invoice:2p0:extended'
}
};
return profileMap[version]?.[profile] || profileMap['2.1.1']['BASIC'];
}
/**
* Generiert PDF mit eingebettetem XML
* Hinweis: Echte PDF/A-3 Implementierung erfordert spezielle Libraries
*/
private async generatePDFWithEmbeddedXML(outputPath: string, xmlContent: string): Promise<void> {
// Für diese Demo-Implementierung erstellen wir eine normale PDF
// In einer echten Implementierung würde hier eine PDF/A-3 Library verwendet werden
const { generateInvoicePDFToFile } = await import('../pdf-generator');
// Erstelle normale PDF (ohne PDF/A-3 Features)
await generateInvoicePDFToFile(this.invoice, outputPath, {
template: 'default',
language: 'de',
includeLogo: false
});
console.log('⚠️ Hinweis: Echte PDF/A-3 Implementierung erfordert spezielle Libraries wie pdf-lib oder ähnliche');
console.log('📄 XML-Inhalt wurde separat gespeichert und kann manuell in PDF/A-3 eingebettet werden');
}
/**
* Validiert die ZUGFeRD-Konformität
*/
public validateZUGFeRDConformance(): { isValid: boolean; errors: string[] } {
const errors: string[] = [];
// Grundlegende Validierungen
if (!this.invoice.details.invoiceNumber) {
errors.push('Rechnungsnummer ist erforderlich');
}
if (!this.invoice.sender.vatId) {
errors.push('USt-IdNr. des Absenders ist erforderlich');
}
if (!this.invoice.paymentInfo.iban) {
errors.push('IBAN ist erforderlich');
}
if (this.invoice.items.length === 0) {
errors.push('Mindestens eine Rechnungsposition ist erforderlich');
}
// Profil-spezifische Validierungen
if (this.options.profile === 'COMFORT' || this.options.profile === 'EXTENDED') {
if (!this.invoice.sender.businessOwner) {
errors.push('Geschäftsinhaber ist für COMFORT/EXTENDED Profile erforderlich');
}
}
return {
isValid: errors.length === 0,
errors
};
}
private escapeXml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
private formatDate(dateString: string): string {
// Konvertiere DD.MM.YY zu YYYYMMDD
const parts = dateString.split('.');
if (parts.length === 3) {
const day = parts[0].padStart(2, '0');
const month = parts[1].padStart(2, '0');
const year = '20' + parts[2]; // Annahme: 20xx
return `${year}${month}${day}`;
}
return dateString;
}
}