@casoon/invoice-generator
Version:
Generate PDF invoices, XRechnung XML, and ZUGFeRD PDF/A-3 from JSON data with TypeScript support
496 lines (436 loc) • 17.2 kB
text/typescript
import { Invoice } from '../../types/invoice.types';
import fs from 'fs';
import path from 'path';
// pdf-lib für echte PDF/A-3 Unterstützung (optional)
// npm install pdf-lib (wird automatisch installiert)
export interface ZUGFeRDPDFLibOptions {
profile?: 'BASIC' | 'COMFORT' | 'EXTENDED';
version?: '2.1.1' | '2.2.0';
conformanceLevel?: 'A' | 'B' | 'U';
outputFormat?: 'PDF/A-3' | 'PDF/A-3b';
}
export class ZUGFeRDPDFLibGenerator {
private invoice: Invoice;
private options: Required<ZUGFeRDPDFLibOptions>;
constructor(invoice: Invoice, options: ZUGFeRDPDFLibOptions = {}) {
this.invoice = invoice;
this.options = {
profile: 'BASIC',
version: '2.1.1',
conformanceLevel: 'A',
outputFormat: 'PDF/A-3',
...options
};
}
/**
* Generiert eine echte ZUGFeRD-konforme PDF/A-3 mit pdf-lib
*
* pdf-lib bietet:
* - PDF/A-3 Unterstützung
* - XML-Attachments
* - Metadaten-Management
* - Erweiterte PDF-Features
*/
public async generateZUGFeRDPDFA3(outputPath: string): Promise<void> {
try {
// Dynamischer Import von pdf-lib (falls installiert)
const { PDFDocument, PDFName, PDFDict, PDFArray, PDFString, PDFHexString } = await this.importPDFLib();
// Erstelle neues PDF/A-3 Dokument
const pdfDoc = await PDFDocument.create();
// Setze PDF/A-3 Metadaten
this.setPDFA3Metadata(pdfDoc);
// Generiere PDF-Inhalt (visuelle Darstellung)
await this.generatePDFContent(pdfDoc);
// Generiere XML-Inhalt
const xmlContent = this.generateZUGFeRDXML();
// Füge XML als Attachment hinzu
await this.addXMLAttachment(pdfDoc, xmlContent);
// Speichere PDF/A-3
const pdfBytes = await pdfDoc.save();
fs.writeFileSync(outputPath, pdfBytes);
console.log(`✅ Echte ZUGFeRD PDF/A-3 erstellt: ${outputPath}`);
} catch (error) {
console.error('❌ Fehler beim Erstellen der PDF/A-3:', error);
throw error;
}
}
/**
* Dynamischer Import von pdf-lib
*/
private async importPDFLib() {
try {
// Versuche pdf-lib zu importieren
const pdfLib = await import('pdf-lib');
return pdfLib;
} catch (error) {
throw new Error(
'pdf-lib ist nicht installiert. Führe aus:\n' +
' npm install pdf-lib\n\n' +
'Alternativ verwende den Standard-ZUGFeRD-Generator:\n' +
' import { ZUGFeRDPDFA3Generator } from "./zugferd-pdfa3-generator"'
);
}
}
/**
* Setzt PDF/A-3 Metadaten
*/
private setPDFA3Metadata(pdfDoc: any): void {
// PDF/A-3 spezifische Metadaten
const metadata = {
Title: 'ZUGFeRD Invoice',
Author: 'Invoice Generator',
Subject: 'Electronic Invoice (ZUGFeRD)',
Creator: 'pdf-lib',
Producer: 'pdf-lib',
CreationDate: new Date(),
ModDate: new Date(),
// PDF/A-3 spezifische Felder
'PDF/A Version': 'A-3',
'ZUGFeRD Version': this.options.version,
'ZUGFeRD Profile': this.options.profile
};
// Setze Metadaten im PDF
Object.entries(metadata).forEach(([key, value]) => {
if (value instanceof Date) {
pdfDoc.setTitle(`${key}: ${value.toISOString()}`);
} else {
pdfDoc.setTitle(`${key}: ${value}`);
}
});
}
/**
* Generiert den PDF-Inhalt (visuelle Darstellung)
*/
private async generatePDFContent(pdfDoc: any): Promise<void> {
// pdf-lib bietet erweiterte Text- und Layout-Features
const page = pdfDoc.addPage([595.28, 841.89]); // A4
// Beispiel für erweiterte Text-Features
const { rgb } = await import('pdf-lib');
// Header
page.drawText('ZUGFeRD Invoice', {
x: 50,
y: 800,
size: 16,
color: rgb(0, 0, 0)
});
// Rechnungsdaten
page.drawText(`Invoice Number: ${this.invoice.details.invoiceNumber}`, {
x: 50,
y: 750,
size: 12
});
page.drawText(`Date: ${this.invoice.details.invoiceDate}`, {
x: 50,
y: 730,
size: 12
});
// Sender
page.drawText('From:', {
x: 50,
y: 700,
size: 12,
color: rgb(0.5, 0.5, 0.5)
});
page.drawText(this.invoice.sender.name, {
x: 50,
y: 680,
size: 10
});
page.drawText(this.invoice.sender.address.street, {
x: 50,
y: 660,
size: 10
});
page.drawText(`${this.invoice.sender.address.postalCode} ${this.invoice.sender.address.city}`, {
x: 50,
y: 640,
size: 10
});
// Recipient
page.drawText('To:', {
x: 300,
y: 700,
size: 12,
color: rgb(0.5, 0.5, 0.5)
});
page.drawText(this.invoice.recipient.name, {
x: 300,
y: 680,
size: 10
});
page.drawText(this.invoice.recipient.address.street, {
x: 300,
y: 660,
size: 10
});
page.drawText(`${this.invoice.recipient.address.postalCode} ${this.invoice.recipient.address.city}`, {
x: 300,
y: 640,
size: 10
});
// Items Table
let yPosition = 550;
// Table Header
page.drawText('Pos', { x: 50, y: yPosition, size: 10 });
page.drawText('Description', { x: 100, y: yPosition, size: 10 });
page.drawText('Qty', { x: 300, y: yPosition, size: 10 });
page.drawText('Price', { x: 350, y: yPosition, size: 10 });
page.drawText('Total', { x: 450, y: yPosition, size: 10 });
yPosition -= 20;
// Table Rows
this.invoice.items.forEach((item, index) => {
page.drawText((index + 1).toString(), { x: 50, y: yPosition, size: 9 });
page.drawText(item.description, { x: 100, y: yPosition, size: 9 });
page.drawText(item.quantity.toString(), { x: 300, y: yPosition, size: 9 });
page.drawText(`${item.unitPrice.toFixed(2)} ${item.currency}`, { x: 350, y: yPosition, size: 9 });
page.drawText(`${item.total.toFixed(2)} ${item.currency}`, { x: 450, y: yPosition, size: 9 });
yPosition -= 15;
});
// Totals
yPosition -= 20;
page.drawText(`Net Amount: ${this.invoice.totals.netAmount.toFixed(2)} ${this.invoice.totals.currency}`, {
x: 350,
y: yPosition,
size: 12
});
yPosition -= 20;
page.drawText(`VAT (${this.invoice.totals.vatRate}%): ${this.invoice.totals.vatAmount.toFixed(2)} ${this.invoice.totals.currency}`, {
x: 350,
y: yPosition,
size: 12
});
yPosition -= 20;
page.drawText(`Total: ${this.invoice.totals.grossAmount.toFixed(2)} ${this.invoice.totals.currency}`, {
x: 350,
y: yPosition,
size: 14,
color: rgb(0, 0, 0)
});
}
/**
* Fügt XML als Attachment zum PDF hinzu (echte PDF/A-3 Funktionalität)
*/
private async addXMLAttachment(pdfDoc: any, xmlContent: string): Promise<void> {
const { PDFName, PDFDict, PDFArray, PDFString, PDFHexString } = await import('pdf-lib');
// Erstelle XML-Attachment
const xmlBytes = new TextEncoder().encode(xmlContent);
// Füge XML als Embedded File hinzu
const embeddedFile = pdfDoc.context.obj({
Type: PDFName.of('EmbeddedFile'),
Subtype: PDFName.of('application/xml'),
Params: pdfDoc.context.obj({
Size: xmlBytes.length,
CreationDate: new Date(),
ModDate: new Date()
})
});
// Erstelle File Specification
const fileSpec = pdfDoc.context.obj({
Type: PDFName.of('Filespec'),
F: PDFString.of('zugferd.xml'),
EF: pdfDoc.context.obj({
F: embeddedFile
}),
AFRelationship: PDFName.of('Alternative')
});
// Füge zum PDF hinzu
if (!pdfDoc.catalog.get(PDFName.of('Names'))) {
pdfDoc.catalog.set(PDFName.of('Names'), pdfDoc.context.obj({}));
}
const names = pdfDoc.catalog.get(PDFName.of('Names'));
if (!names.get(PDFName.of('EmbeddedFiles'))) {
names.set(PDFName.of('EmbeddedFiles'), pdfDoc.context.obj({
Type: PDFName.of('Names'),
Names: PDFArray.withContext(pdfDoc.context)
}));
}
const embeddedFiles = names.get(PDFName.of('EmbeddedFiles'));
const namesArray = embeddedFiles.get(PDFName.of('Names'));
namesArray.push(PDFString.of('zugferd.xml'));
namesArray.push(fileSpec);
}
/**
* 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;
}
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('');
}
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'];
}
private escapeXml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
private formatDate(dateString: string): string {
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];
return `${year}${month}${day}`;
}
return dateString;
}
/**
* 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
};
}
}