Unteren Rand fuer die Fusszeile temporaer auf 22 verkleinert, damit sie naeher am Seitenende sitzt - weiterhin nur 1 Seite (verifiziert). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
192 lines
7.1 KiB
TypeScript
192 lines
7.1 KiB
TypeScript
// ==================== 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<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 {
|
|
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 };
|
|
}
|