// ==================== 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, }, }, payoutBankCard: { select: { iban: true, accountHolder: true, bankName: 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 { doc.text('Auszahlung per Überweisung.'); if (company.iban) { doc.fontSize(9).fillColor('#333') .text(`Unsere Bankverbindung: ${[company.bankName, `IBAN ${company.iban}`, company.bic ? `BIC ${company.bic}` : ''].filter(Boolean).join(' · ')}`); } // Auszahlungskonto des Kunden (kann vom Abbuchkonto abweichen). if (cn.payoutBankCard) { const holder = cn.payoutBankCard.accountHolder ? ` (${cn.payoutBankCard.accountHolder})` : ''; doc.fontSize(9).fillColor('#333') .text(`an Bankkonto: ${cn.payoutBankCard.iban}${holder}${cn.payoutBankCard.bankName ? ` · ${cn.payoutBankCard.bankName}` : ''}`); } doc.fillColor('#000'); } // ---- 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) ---- // Position aus der Seitengeometrie berechnen, damit die Fußzeile am // unteren Rand der ERSTEN Seite endet und keine zweite Seite auslöst // (auch wenn sie zweizeilig umbricht). 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); const footerText = footerParts.join(' · '); const footerWidth = 495; doc.fontSize(7).fillColor('#888'); const footerHeight = doc.heightOfString(footerText, { width: footerWidth, align: 'center' }); // Unteren Rand für die Fußzeile kurz verkleinern, damit sie ein Stück // tiefer sitzt, ohne dass pdfkit eine zweite Seite anlegt. const savedBottom = doc.page.margins.bottom; doc.page.margins.bottom = 22; const bottomLimit = doc.page.height - doc.page.margins.bottom; doc.text(footerText, 50, bottomLimit - footerHeight - 2, { width: footerWidth, align: 'center' }); doc.page.margins.bottom = savedBottom; doc.fillColor('#000'); 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 }; }