UNPKG

@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
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, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&apos;'); } 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; } }