feat(invoice): add e-invoice support with XRechnung/ZUGFeRD and advanced export features
Some checks failed
Default (tags) / security (push) Successful in 48s
Default (tags) / test (push) Failing after 4m3s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped

This commit is contained in:
2025-08-12 12:37:01 +00:00
parent 08d7803be2
commit 73b46f7857
19 changed files with 6211 additions and 20 deletions

601
ts/skr.export.pdf.ts Normal file
View File

@@ -0,0 +1,601 @@
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;
}
}
}