UNPKG

@fin.cx/skr

Version:

SKR03 and SKR04 German accounting standards for double-entry bookkeeping

601 lines (536 loc) 17 kB
import * as plugins from './plugins.js'; import * as path from 'path'; import type { ITrialBalanceReport, IIncomeStatement, IBalanceSheet } from './skr.types.js'; export interface IPdfReportOptions { companyName: string; companyAddress?: string; taxId?: string; registrationNumber?: string; fiscalYear: number; dateFrom: Date; dateTo: Date; preparedBy?: string; preparedDate?: Date; } export class PdfReportGenerator { private exportPath: string; private options: IPdfReportOptions; private pdfInstance: plugins.smartpdf.SmartPdf | null = null; constructor(exportPath: string, options: IPdfReportOptions) { this.exportPath = exportPath; this.options = options; } /** * Initializes the PDF generator */ public async initialize(): Promise<void> { this.pdfInstance = new plugins.smartpdf.SmartPdf(); await this.pdfInstance.start(); } /** * Generates the trial balance PDF report */ public async generateTrialBalancePdf(report: ITrialBalanceReport): Promise<Buffer> { if (!this.pdfInstance) { throw new Error('PDF generator not initialized'); } const html = this.generateTrialBalanceHtml(report); const pdfResult = await this.pdfInstance.getA4PdfResultForHtmlString(html); return Buffer.from(pdfResult.buffer); } /** * Generates the income statement PDF report */ public async generateIncomeStatementPdf(report: IIncomeStatement): Promise<Buffer> { if (!this.pdfInstance) { throw new Error('PDF generator not initialized'); } const html = this.generateIncomeStatementHtml(report); const pdfResult = await this.pdfInstance.getA4PdfResultForHtmlString(html); return Buffer.from(pdfResult.buffer); } /** * Generates the balance sheet PDF report */ public async generateBalanceSheetPdf(report: IBalanceSheet): Promise<Buffer> { if (!this.pdfInstance) { throw new Error('PDF generator not initialized'); } const html = this.generateBalanceSheetHtml(report); const pdfResult = await this.pdfInstance.getA4PdfResultForHtmlString(html); return Buffer.from(pdfResult.buffer); } /** * Generates the comprehensive Jahresabschluss PDF */ public async generateJahresabschlussPdf( trialBalance: ITrialBalanceReport, incomeStatement: IIncomeStatement, balanceSheet: IBalanceSheet ): Promise<Buffer> { if (!this.pdfInstance) { throw new Error('PDF generator not initialized'); } const html = this.generateJahresabschlussHtml(trialBalance, incomeStatement, balanceSheet); const pdfResult = await this.pdfInstance.getA4PdfResultForHtmlString(html); return Buffer.from(pdfResult.buffer); } /** * Generates HTML for trial balance report */ private generateTrialBalanceHtml(report: ITrialBalanceReport): string { const entries = report.entries || []; const tableRows = entries.map(entry => ` <tr> <td>${entry.accountNumber}</td> <td>${entry.accountName}</td> <td class="number">${this.formatGermanNumber(0)}</td> <td class="number">${this.formatGermanNumber(entry.debitBalance)}</td> <td class="number">${this.formatGermanNumber(entry.creditBalance)}</td> <td class="number">${this.formatGermanNumber(entry.netBalance)}</td> </tr> `).join(''); return ` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <style> ${this.getBaseStyles()} </style> </head> <body> ${this.generateHeader('Summen- und Saldenliste')} <table class="report-table"> <thead> <tr> <th>Konto</th> <th>Bezeichnung</th> <th>Anfangssaldo</th> <th>Soll</th> <th>Haben</th> <th>Saldo</th> </tr> </thead> <tbody> ${tableRows} </tbody> <tfoot> <tr class="total-row"> <td colspan="3">Summe</td> <td class="number">${this.formatGermanNumber(report.totalDebits)}</td> <td class="number">${this.formatGermanNumber(report.totalCredits)}</td> <td class="number">${this.formatGermanNumber(report.totalDebits - report.totalCredits)}</td> </tr> </tfoot> </table> ${this.generateFooter()} </body> </html> `; } /** * Generates HTML for income statement report */ private generateIncomeStatementHtml(report: IIncomeStatement): string { const revenueRows = (report.revenue || []).map(entry => ` <tr> <td>${entry.accountNumber}</td> <td>${entry.accountName}</td> <td class="number">${this.formatGermanNumber(entry.amount)}</td> </tr> `).join(''); const expenseRows = (report.expenses || []).map(entry => ` <tr> <td>${entry.accountNumber}</td> <td>${entry.accountName}</td> <td class="number">${this.formatGermanNumber(entry.amount)}</td> </tr> `).join(''); return ` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <style> ${this.getBaseStyles()} </style> </head> <body> ${this.generateHeader('Gewinn- und Verlustrechnung')} <h2>Erträge</h2> <table class="report-table"> <thead> <tr> <th>Konto</th> <th>Bezeichnung</th> <th>Betrag</th> </tr> </thead> <tbody> ${revenueRows} </tbody> <tfoot> <tr class="subtotal-row"> <td colspan="2">Summe Erträge</td> <td class="number">${this.formatGermanNumber(report.totalRevenue)}</td> </tr> </tfoot> </table> <h2>Aufwendungen</h2> <table class="report-table"> <thead> <tr> <th>Konto</th> <th>Bezeichnung</th> <th>Betrag</th> </tr> </thead> <tbody> ${expenseRows} </tbody> <tfoot> <tr class="subtotal-row"> <td colspan="2">Summe Aufwendungen</td> <td class="number">${this.formatGermanNumber(report.totalExpenses)}</td> </tr> </tfoot> </table> <div class="result-section"> <h2>Ergebnis</h2> <table class="summary-table"> <tr> <td>Erträge</td> <td class="number">${this.formatGermanNumber(report.totalRevenue)}</td> </tr> <tr> <td>Aufwendungen</td> <td class="number">- ${this.formatGermanNumber(report.totalExpenses)}</td> </tr> <tr class="total-row"> <td>${report.netIncome >= 0 ? 'Jahresüberschuss' : 'Jahresfehlbetrag'}</td> <td class="number ${report.netIncome >= 0 ? 'positive' : 'negative'}"> ${this.formatGermanNumber(report.netIncome)} </td> </tr> </table> </div> ${this.generateFooter()} </body> </html> `; } /** * Generates HTML for balance sheet report */ private generateBalanceSheetHtml(report: IBalanceSheet): string { const assetRows = [...(report.assets.current || []), ...(report.assets.fixed || [])].map(entry => ` <tr> <td>${entry.accountNumber}</td> <td>${entry.accountName}</td> <td class="number">${this.formatGermanNumber(entry.amount)}</td> </tr> `).join(''); const liabilityRows = [...(report.liabilities.current || []), ...(report.liabilities.longTerm || [])].map(entry => ` <tr> <td>${entry.accountNumber}</td> <td>${entry.accountName}</td> <td class="number">${this.formatGermanNumber(entry.amount)}</td> </tr> `).join(''); const equityRows = (report.equity.entries || []).map(entry => ` <tr> <td>${entry.accountNumber}</td> <td>${entry.accountName}</td> <td class="number">${this.formatGermanNumber(entry.amount)}</td> </tr> `).join(''); return ` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <style> ${this.getBaseStyles()} </style> </head> <body> ${this.generateHeader('Bilanz')} <div class="balance-sheet"> <div class="aktiva"> <h2>Aktiva</h2> <table class="report-table"> <thead> <tr> <th>Konto</th> <th>Bezeichnung</th> <th>Betrag</th> </tr> </thead> <tbody> ${assetRows} </tbody> <tfoot> <tr class="total-row"> <td colspan="2">Summe Aktiva</td> <td class="number">${this.formatGermanNumber(report.assets.totalAssets)}</td> </tr> </tfoot> </table> </div> <div class="passiva"> <h2>Passiva</h2> <h3>Eigenkapital</h3> <table class="report-table"> <tbody> ${equityRows} </tbody> <tfoot> <tr class="subtotal-row"> <td colspan="2">Summe Eigenkapital</td> <td class="number">${this.formatGermanNumber(report.equity.totalEquity)}</td> </tr> </tfoot> </table> <h3>Fremdkapital</h3> <table class="report-table"> <tbody> ${liabilityRows} </tbody> <tfoot> <tr class="subtotal-row"> <td colspan="2">Summe Fremdkapital</td> <td class="number">${this.formatGermanNumber(report.liabilities.totalLiabilities)}</td> </tr> </tfoot> </table> <table class="summary-table"> <tr class="total-row"> <td>Summe Passiva</td> <td class="number">${this.formatGermanNumber(report.liabilities.totalLiabilities + report.equity.totalEquity)}</td> </tr> </table> </div> </div> ${this.generateFooter()} </body> </html> `; } /** * Generates comprehensive Jahresabschluss HTML */ private generateJahresabschlussHtml( trialBalance: ITrialBalanceReport, incomeStatement: IIncomeStatement, balanceSheet: IBalanceSheet ): string { return ` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <style> ${this.getBaseStyles()} .page-break { page-break-after: always; } .cover-page { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100vh; text-align: center; } .cover-page h1 { font-size: 36px; margin-bottom: 20px; } .cover-page h2 { font-size: 24px; margin-bottom: 40px; } .toc { margin-top: 50px; } .toc h2 { margin-bottom: 20px; } .toc ul { list-style: none; padding: 0; } .toc li { margin: 10px 0; font-size: 16px; } </style> </head> <body> <div class="cover-page"> <h1>Jahresabschluss</h1> <h2>${this.options.companyName}</h2> <p>Geschäftsjahr ${this.options.fiscalYear}</p> <p>${this.formatGermanDate(this.options.dateFrom)} bis ${this.formatGermanDate(this.options.dateTo)}</p> <div class="toc"> <h2>Inhalt</h2> <ul> <li>1. Bilanz</li> <li>2. Gewinn- und Verlustrechnung</li> <li>3. Summen- und Saldenliste</li> </ul> </div> </div> <div class="page-break"></div> ${this.generateBalanceSheetHtml(balanceSheet)} <div class="page-break"></div> ${this.generateIncomeStatementHtml(incomeStatement)} <div class="page-break"></div> ${this.generateTrialBalanceHtml(trialBalance)} </body> </html> `; } /** * Generates the report header */ private generateHeader(reportTitle: string): string { return ` <div class="header"> <h1>${this.options.companyName}</h1> ${this.options.companyAddress ? `<p>${this.options.companyAddress}</p>` : ''} ${this.options.taxId ? `<p>Steuernummer: ${this.options.taxId}</p>` : ''} ${this.options.registrationNumber ? `<p>Handelsregister: ${this.options.registrationNumber}</p>` : ''} <hr> <h2>${reportTitle}</h2> <p>Periode: ${this.formatGermanDate(this.options.dateFrom)} bis ${this.formatGermanDate(this.options.dateTo)}</p> </div> `; } /** * Generates the report footer */ private generateFooter(): string { const preparedDate = this.options.preparedDate || new Date(); return ` <div class="footer"> <hr> <p>Erstellt am: ${this.formatGermanDate(preparedDate)}</p> ${this.options.preparedBy ? `<p>Erstellt von: ${this.options.preparedBy}</p>` : ''} <p class="disclaimer"> Dieser Bericht wurde automatisch generiert und ist Teil des revisionssicheren Jahresabschluss-Exports gemäß GoBD. </p> </div> `; } /** * Gets the base CSS styles for all reports */ private getBaseStyles(): string { return ` body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 40px; color: #333; line-height: 1.6; } h1 { color: #2c3e50; margin-bottom: 10px; } h2 { color: #34495e; margin-top: 30px; margin-bottom: 15px; } h3 { color: #7f8c8d; margin-top: 20px; margin-bottom: 10px; } .header { text-align: center; margin-bottom: 40px; } .footer { margin-top: 50px; text-align: center; font-size: 12px; color: #7f8c8d; } .disclaimer { margin-top: 20px; font-style: italic; } table { width: 100%; border-collapse: collapse; margin: 20px 0; } th { background-color: #34495e; color: white; padding: 10px; text-align: left; font-weight: 600; } td { padding: 8px; border-bottom: 1px solid #ecf0f1; } tbody tr:hover { background-color: #f8f9fa; } .number { text-align: right; font-family: 'Courier New', monospace; } .total-row { font-weight: bold; background-color: #ecf0f1; } .subtotal-row { font-weight: 600; background-color: #f8f9fa; } .positive { color: #27ae60; } .negative { color: #e74c3c; } .result-section { margin-top: 40px; padding: 20px; background-color: #f8f9fa; border-radius: 5px; } .summary-table { max-width: 500px; margin: 20px auto; } .balance-sheet { display: flex; gap: 40px; } .aktiva, .passiva { flex: 1; } @media print { body { margin: 20px; } .page-break { page-break-after: always; } } `; } /** * Formats number in German format (1.234,56) */ private formatGermanNumber(value: number): string { return value.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } /** * Formats date in German format (DD.MM.YYYY) */ private formatGermanDate(date: Date): string { return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); } /** * Saves a PDF report to the export directory */ public async savePdfReport(filename: string, pdfBuffer: Buffer): Promise<string> { const reportsDir = path.join(this.exportPath, 'data', 'reports'); await plugins.smartfile.fs.ensureDir(reportsDir); const filePath = path.join(reportsDir, filename); await plugins.smartfile.memory.toFs(pdfBuffer, filePath); return filePath; } /** * Closes the PDF generator */ public async close(): Promise<void> { if (this.pdfInstance) { await this.pdfInstance.stop(); this.pdfInstance = null; } } }