Gutschriften Phase 3b/1: Gutschrift-PDF (pdfkit)

PDF-Erzeugung fuer Gutschriften mit Firmenstammdaten als Absender,
Empfaenger (Kunde+Adresse), Netto/USt/Brutto (bzw. ohne USt-Ausweis),
Sachwert/'Ware erhalten' bzw. Ueberweisungs-Bankverbindung,
Unterschriftsblock, Firmen-Fusszeile.

Endpoint POST /credit-notes/:id/pdf; 'PDF'-Button in CreditNotesSection
(erzeugen + im Tab oeffnen bzw. vorhandenes ansehen). PDF-Pfad wird bei
inhaltlicher Aenderung geleert -> Neu-Erzeugung. Download ueber
fileDownload (credit-notes subDir, Vertrags-Ownership).

Lokal verifiziert (valides %PDF, korrekte Betraege).

ZUGFeRD-XML-Embedding (PDF/A-3, EN 16931) folgt als Teil 2 mit
Validator-Pruefung.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 13:32:34 +02:00
co-authored by Claude Opus 4.8
parent 2c705272ea
commit 642fb05c76
7 changed files with 225 additions and 6 deletions
@@ -4,6 +4,7 @@ import { logChange } from '../services/audit.service.js';
import { ApiError } from '../utils/apiError.js';
import * as creditNoteService from '../services/creditNote.service.js';
import * as numberRangeService from '../services/creditNoteNumberRange.service.js';
import { generateCreditNotePdf } from '../services/creditNotePdf.service.js';
// Gutschriften sind ein reiner Mitarbeiter-/Admin-Bereich (interne
// Subventions-Belege). Portal-Kunden dürfen sie weder sehen noch ändern
@@ -143,6 +144,23 @@ export async function remove(req: AuthRequest, res: Response): Promise<void> {
}
}
// PDF (Gutschrift-Dokument) erzeugen. ZUGFeRD-Embedding folgt (Teil 2).
export async function generatePdf(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const id = idParam(req, res, 'id');
if (id === null) return;
const { pdfPath } = await generateCreditNotePdf(id);
await logChange({
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
label: 'Gutschrift-PDF erzeugt',
});
res.json({ success: true, data: { pdfPath } } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Erzeugen des PDF');
}
}
// ---- Nummernkreis (Einstellungen) ----
export async function getNumberRange(req: AuthRequest, res: Response): Promise<void> {
+1
View File
@@ -10,6 +10,7 @@ router.get('/number-range', authenticate, requirePermission('settings:read'), cr
router.put('/number-range', authenticate, requirePermission('settings:update'), creditNoteController.updateNumberRange);
// Einzelne Gutschrift
router.post('/:id/pdf', authenticate, requirePermission('contracts:update'), creditNoteController.generatePdf);
router.get('/:id', authenticate, requirePermission('contracts:read'), creditNoteController.getOne);
router.put('/:id', authenticate, requirePermission('contracts:update'), creditNoteController.update);
router.delete('/:id', authenticate, requirePermission('contracts:delete'), creditNoteController.remove);
+4 -2
View File
@@ -183,8 +183,10 @@ export async function updateCreditNote(id: number, input: CreateCreditNoteInput)
throw new ApiError(404, 'Gutschrift nicht gefunden');
}
const normalized = validateAndNormalize(input);
// Nummer bleibt unverändert (einmal vergeben = fix).
return prisma.creditNote.update({ where: { id }, data: normalized });
// Nummer bleibt unverändert (einmal vergeben = fix). Ein evtl. schon
// erzeugtes PDF ist nach inhaltlicher Änderung veraltet → Pfad leeren
// (der User erzeugt es bei Bedarf neu).
return prisma.creditNote.update({ where: { id }, data: { ...normalized, pdfPath: null } });
}
export async function deleteCreditNote(id: number) {
@@ -0,0 +1,166 @@
// ==================== GUTSCHRIFT-PDF ====================
// Erzeugt ein Gutschrift-PDF (pdfkit) mit den Firmenstammdaten als Absender.
// Rückgabe: Buffer (für ZUGFeRD-Embedding in Teil 2) + gespeicherter Pfad.
import fs from 'fs';
import path from 'path';
import PDFDocument from 'pdfkit';
import prisma from '../lib/prisma.js';
import { ApiError } from '../utils/apiError.js';
import { getOrCreateProfile } from './companyProfile.service.js';
function euro(n: number, currency = 'EUR'): string {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency }).format(n || 0);
}
function ddmmyyyy(d: Date): string {
return d.toLocaleDateString('de-DE');
}
async function loadData(creditNoteId: number) {
const cn = await prisma.creditNote.findUnique({
where: { id: creditNoteId },
include: {
contract: {
include: {
customer: {
select: {
firstName: true, lastName: true, companyName: true, type: true,
customerNumber: true, vatExempt: true,
},
},
billingAddress: true,
address: true,
},
},
},
});
if (!cn) throw new ApiError(404, 'Gutschrift nicht gefunden');
const company = await getOrCreateProfile();
return { cn, company };
}
export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buffer: Buffer; pdfPath: string }> {
const { cn, company } = await loadData(creditNoteId);
const customer = cn.contract.customer;
const addr = cn.contract.billingAddress || cn.contract.address;
const recipientName =
customer.type === 'BUSINESS' && customer.companyName
? customer.companyName
: `${customer.firstName} ${customer.lastName}`.trim();
const doc = new PDFDocument({ size: 'A4', margin: 50 });
const chunks: Buffer[] = [];
doc.on('data', (c: Buffer) => chunks.push(c));
const done = new Promise<Buffer>((resolve) => doc.on('end', () => resolve(Buffer.concat(chunks))));
// ---- Absender (klein, oben) ----
const senderLine = [
company.name,
`${company.street} ${company.houseNumber}`.trim(),
`${company.postalCode} ${company.city}`.trim(),
].filter(Boolean).join(' · ');
doc.fontSize(8).fillColor('#666').text(senderLine || ' ', 50, 50, { width: 495 });
doc.fillColor('#000');
// ---- Empfänger ----
doc.moveDown(2);
doc.fontSize(11);
doc.text(recipientName, { continued: false });
if (addr) {
doc.text(`${addr.street} ${addr.houseNumber}`.trim());
doc.text(`${addr.postalCode} ${addr.city}`.trim());
}
doc.fontSize(8).fillColor('#666').text(`Kundennr.: ${customer.customerNumber}`);
doc.fillColor('#000');
// ---- Titel + Meta ----
doc.moveDown(2);
doc.fontSize(18).text('Gutschrift', { align: 'left' });
doc.moveDown(0.3);
doc.fontSize(10);
doc.text(`Gutschrift-Nr.: ${cn.number}`);
doc.text(`Datum: ${ddmmyyyy(new Date(cn.creditDate))}`);
// ---- Beschreibung / Betrag ----
doc.moveDown(1.2);
const description =
cn.type === 'SACHWERT'
? `Sachwert / Subvention: ${cn.sachwertDescription || '—'}`
: 'Subvention (Auszahlung per Überweisung)';
doc.fontSize(11).text(description);
doc.moveDown(0.5);
const startX = 50;
const rightX = 545;
const labelW = 300;
const line = (label: string, value: string, bold = false) => {
doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(11);
const y = doc.y;
doc.text(label, startX, y, { width: labelW });
doc.text(value, startX, y, { width: rightX - startX, align: 'right' });
doc.font('Helvetica');
};
if (cn.vatRelevant) {
line('Nettobetrag', euro(cn.amountNet, cn.currency));
line(`Umsatzsteuer ${cn.vatRate}%`, euro(cn.amountVat, cn.currency));
doc.moveDown(0.2);
line('Bruttobetrag', euro(cn.amountGross, cn.currency), true);
} else {
line('Betrag', euro(cn.amountGross, cn.currency), true);
doc.moveDown(0.3);
doc.fontSize(8).fillColor('#666')
.text('Ohne gesonderten Umsatzsteuerausweis.', startX, doc.y, { width: 495 });
doc.fillColor('#000');
}
// ---- Typ-spezifisch ----
doc.moveDown(1.5);
doc.fontSize(10);
if (cn.type === 'SACHWERT') {
doc.text(`Ware erhalten: ${cn.goodsReceived ? 'Ja' : 'Nein'}`);
} else if (company.iban) {
doc.text(`Auszahlung per Überweisung. Bankverbindung: ${company.bankName} · IBAN ${company.iban} · BIC ${company.bic}`.trim());
}
// ---- Unterschriftsblock ----
doc.moveDown(3);
const y = doc.y;
const place = cn.place || company.city || '';
doc.fontSize(10).fillColor('#000');
doc.text('_______________________________', 50, y);
doc.text('_______________________________', 320, y);
doc.fontSize(8).fillColor('#666');
doc.text(`Ort/Datum${place ? ` (${place})` : ''}`, 50, y + 14);
doc.text('Unterschrift', 320, y + 14);
doc.fillColor('#000');
// ---- Fußzeile (Firmendaten) ----
const footerParts = [
company.name,
company.managingDirector ? `GF: ${company.managingDirector}` : '',
company.vatId ? `USt-IdNr: ${company.vatId}` : '',
company.taxNumber ? `St.-Nr.: ${company.taxNumber}` : '',
company.commercialRegister,
company.iban ? `IBAN: ${company.iban}` : '',
].filter(Boolean);
doc.fontSize(7).fillColor('#888')
.text(footerParts.join(' · '), 50, 790, { width: 495, align: 'center' });
doc.end();
const buffer = await done;
// ---- Speichern ----
const dir = path.join(process.cwd(), 'uploads', 'credit-notes');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const filename = `gutschrift-${cn.number.replace(/[^A-Za-z0-9_-]/g, '_')}-${cn.id}.pdf`;
const filepath = path.join(dir, filename);
fs.writeFileSync(filepath, buffer);
const pdfPath = `/uploads/credit-notes/${filename}`;
await prisma.creditNote.update({ where: { id: cn.id }, data: { pdfPath } });
return { buffer, pdfPath };
}
+12 -3
View File
@@ -129,9 +129,18 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
`/upload/credit-notes/:id/receipt`, fileDownload-Ownership ergänzt).
Nummernkreis-Verwaltung als Settings-Seite `/settings/credit-note-number-
range` mit Live-Vorschau. Frontend-Typen + `creditNoteApi`.
- **Offen:** Phase 3 (PDF + ZUGFeRD/PDF-A3, muss gegen ZUGFeRD-Validator
geprüft werden). USt-Einordnung (Vermittlung vs. Abschlussbonus) mit
Steuerberater klären Modell deckt beide über `vatRelevant` ab.
- **Phase 3a (erledigt):** `CompanyProfile` (Firmenstammdaten/Absender) +
Migration + Settings-Seite `/settings/company-profile`.
- **Phase 3b Teil 1 (erledigt):** Gutschrift-**PDF** (pdfkit) mit Absender
aus CompanyProfile, Empfänger, Netto/USt/Brutto, Sachwert/„Ware erhalten"
bzw. Überweisungs-Bankverbindung, Unterschriftsblock, Fußzeile. Endpoint
`POST /credit-notes/:id/pdf`, „PDF"-Button in der Section (Ansehen/Erzeugen).
PDF-Pfad wird bei inhaltlicher Änderung geleert (Neu-Erzeugung). Lokal
verifiziert (valides PDF).
- **Offen:** Phase 3b Teil 2 (ZUGFeRD-XML EN 16931, Typ 381, in PDF/A-3
einbetten **muss gegen ZUGFeRD-Validator** geprüft werden). USt-
Einordnung (Vermittlung vs. Abschlussbonus) mit Steuerberater klären
Modell deckt beide über `vatRelevant` ab.
- [x] **📄➕ Vertrag kopieren (neuer eigenständiger Vertrag aus Vorlage)** (2026-08-03)
- „Kopieren"-Button in der Vertragsansicht (`contracts:create`) → öffnet das
@@ -1,6 +1,6 @@
import { useState, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, Gift, Banknote, Package, Upload, Eye, X, Check } from 'lucide-react';
import { Plus, Edit, Trash2, Gift, Banknote, Package, Upload, Eye, X, Check, FileText } from 'lucide-react';
import toast from 'react-hot-toast';
import { formatDate } from '../../utils/dateFormat';
import { fileUrl } from '../../utils/fileUrl';
@@ -358,6 +358,16 @@ export default function CreditNotesSection({ contractId, canEdit }: { contractId
onError: (err: Error) => toast.error(err.message || 'Löschen fehlgeschlagen'),
});
const pdfMutation = useMutation({
mutationFn: (id: number) => creditNoteApi.generatePdf(id),
onSuccess: (res) => {
toast.success('PDF erzeugt');
refresh();
if (res.data?.pdfPath) window.open(fileUrl(res.data.pdfPath, { inline: true }), '_blank', 'noopener');
},
onError: (err: Error) => toast.error(err.message || 'PDF fehlgeschlagen'),
});
const openCreate = () => { setEditing(null); setShowForm(true); };
const openEdit = (cn: CreditNote) => { setEditing(cn); setShowForm(true); };
@@ -429,6 +439,15 @@ export default function CreditNotesSection({ contractId, canEdit }: { contractId
</div>
{canEdit && (
<div className="flex items-center gap-1">
{cn.pdfPath ? (
<a href={fileUrl(cn.pdfPath, { inline: true })} target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-blue-600 p-1" title="PDF ansehen">
<FileText className="w-4 h-4" />
</a>
) : (
<button onClick={() => pdfMutation.mutate(cn.id)} disabled={pdfMutation.isPending} className="text-gray-400 hover:text-blue-600 p-1" title="PDF erzeugen">
<FileText className="w-4 h-4" />
</button>
)}
<button onClick={() => openEdit(cn)} className="text-gray-400 hover:text-blue-600 p-1" title="Bearbeiten">
<Edit className="w-4 h-4" />
</button>
+4
View File
@@ -301,6 +301,10 @@ export const creditNoteApi = {
const res = await api.delete<ApiResponse<void>>(`/credit-notes/${id}`);
return res.data;
},
generatePdf: async (id: number) => {
const res = await api.post<ApiResponse<{ pdfPath: string }>>(`/credit-notes/${id}/pdf`, {});
return res.data;
},
getNumberRange: async () => {
const res = await api.get<ApiResponse<CreditNoteNumberRange>>(`/credit-notes/number-range`);
return res.data;