From 642fb05c763c37f67e94347c290079d8dbd25f36 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 6 Aug 2026 13:32:34 +0200 Subject: [PATCH] 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) --- .../src/controllers/creditNote.controller.ts | 18 ++ backend/src/routes/creditNote.routes.ts | 1 + backend/src/services/creditNote.service.ts | 6 +- backend/src/services/creditNotePdf.service.ts | 166 ++++++++++++++++++ docs/todo.md | 15 +- .../contracts/CreditNotesSection.tsx | 21 ++- frontend/src/services/api.ts | 4 + 7 files changed, 225 insertions(+), 6 deletions(-) create mode 100644 backend/src/services/creditNotePdf.service.ts diff --git a/backend/src/controllers/creditNote.controller.ts b/backend/src/controllers/creditNote.controller.ts index 8b935e1c..571c4358 100644 --- a/backend/src/controllers/creditNote.controller.ts +++ b/backend/src/controllers/creditNote.controller.ts @@ -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 { } } +// PDF (Gutschrift-Dokument) erzeugen. ZUGFeRD-Embedding folgt (Teil 2). +export async function generatePdf(req: AuthRequest, res: Response): Promise { + 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 { diff --git a/backend/src/routes/creditNote.routes.ts b/backend/src/routes/creditNote.routes.ts index 9fee4b18..430bdefd 100644 --- a/backend/src/routes/creditNote.routes.ts +++ b/backend/src/routes/creditNote.routes.ts @@ -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); diff --git a/backend/src/services/creditNote.service.ts b/backend/src/services/creditNote.service.ts index 2a195a07..3049047f 100644 --- a/backend/src/services/creditNote.service.ts +++ b/backend/src/services/creditNote.service.ts @@ -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) { diff --git a/backend/src/services/creditNotePdf.service.ts b/backend/src/services/creditNotePdf.service.ts new file mode 100644 index 00000000..ae96a52f --- /dev/null +++ b/backend/src/services/creditNotePdf.service.ts @@ -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((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 }; +} diff --git a/docs/todo.md b/docs/todo.md index 255241d1..ae453186 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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 diff --git a/frontend/src/components/contracts/CreditNotesSection.tsx b/frontend/src/components/contracts/CreditNotesSection.tsx index 54b82e91..26fb6284 100644 --- a/frontend/src/components/contracts/CreditNotesSection.tsx +++ b/frontend/src/components/contracts/CreditNotesSection.tsx @@ -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 {canEdit && (
+ {cn.pdfPath ? ( + + + + ) : ( + + )} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 9040d245..f6598197 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -301,6 +301,10 @@ export const creditNoteApi = { const res = await api.delete>(`/credit-notes/${id}`); return res.data; }, + generatePdf: async (id: number) => { + const res = await api.post>(`/credit-notes/${id}/pdf`, {}); + return res.data; + }, getNumberRange: async () => { const res = await api.get>(`/credit-notes/number-range`); return res.data;