Files
Wabenhain/src/lib/invoice-pdf.ts
T
Nick Szaunig c7927f2883 feat: Wabenhain Honig-Shop Anwendung
Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei:
Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich
(Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare
Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung,
PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma-
Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
2026-08-17 14:30:28 +02:00

131 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import PDFDocument from "pdfkit";
import { formatPrice, formatDate } from "@/lib/format";
import { getCompanyInfo } from "@/lib/company-info";
import { getDictionary } from "@/i18n/get-dictionary";
import type { Locale } from "@/i18n/config";
interface InvoicePdfInput {
locale: Locale;
invoiceNumber: string;
issuedAt: Date;
totalCents: number;
order: {
orderNumber: string;
firstName: string;
lastName: string;
street: string;
zip: string;
city: string;
currency: string;
subtotalCents: number;
shippingCents: number;
discountCents: number;
discountCode?: { code: string } | null;
items: { productName: string; weightGrams: number; quantity: number; unitPriceCents: number; totalCents: number }[];
};
}
/**
* Erzeugt die Rechnung als PDF (pdfkit — reine JS-Bibliothek ohne
* Headless-Browser/native Abhaengigkeiten, haelt das Docker-Image schlank).
* Wird on-the-fly beim Download erzeugt statt dauerhaft gespeichert. Nutzt
* dieselben Uebersetzungen wie die HTML-Rechnungsansicht (t.invoicePage),
* damit beide Darstellungen derselben Rechnung konsistent bleiben.
*/
export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer> {
const t = getDictionary(input.locale).invoicePage;
const company = await getCompanyInfo();
const doc = new PDFDocument({ margin: 50, size: "A4" });
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
const done = new Promise<Buffer>((resolve, reject) => {
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
});
doc.fontSize(20).fillColor("#24352B").text(company.name.toUpperCase(), { continued: false });
doc
.fontSize(9)
.fillColor("#666666")
.text(`${company.address.street}, ${company.address.zip} ${company.address.city}`);
doc.moveDown(1.5);
doc.fontSize(14).fillColor("#24352B").text(t.invoiceLabel(input.invoiceNumber));
doc
.fontSize(9)
.fillColor("#666666")
.text(t.orderLabel(input.order.orderNumber))
.text(`${t.dateLabel} ${formatDate(input.issuedAt)}`);
doc.moveDown(1);
doc
.fontSize(10)
.fillColor("#242424")
.text(`${input.order.firstName} ${input.order.lastName}`)
.text(input.order.street)
.text(`${input.order.zip} ${input.order.city}`);
doc.moveDown(1.5);
const tableTop = doc.y;
const columns = { name: 50, qty: 320, price: 380, total: 470 };
doc
.fontSize(9)
.fillColor("#666666")
.text(t.itemHeader, columns.name, tableTop)
.text(t.quantityHeader, columns.qty, tableTop)
.text(t.priceHeader, columns.price, tableTop)
.text(t.totalHeader, columns.total, tableTop);
doc
.moveTo(50, tableTop + 14)
.lineTo(545, tableTop + 14)
.strokeColor("#dddddd")
.stroke();
let y = tableTop + 22;
doc.fontSize(10).fillColor("#242424");
for (const item of input.order.items) {
doc.text(`${item.productName} (${item.weightGrams} g)`, columns.name, y, { width: 260 });
doc.text(String(item.quantity), columns.qty, y);
doc.text(formatPrice(item.unitPriceCents, input.order.currency), columns.price, y);
doc.text(formatPrice(item.totalCents, input.order.currency), columns.total, y);
y += 20;
}
y += 10;
doc.moveTo(350, y).lineTo(545, y).strokeColor("#dddddd").stroke();
y += 10;
doc.fontSize(10).fillColor("#666666").text(t.subtotalLabel, 350, y);
doc.fillColor("#242424").text(formatPrice(input.order.subtotalCents, input.order.currency), columns.total, y);
y += 16;
doc.fillColor("#666666").text(t.shippingLabel, 350, y);
doc
.fillColor("#242424")
.text(
input.order.shippingCents === 0 ? t.freeShipping : formatPrice(input.order.shippingCents, input.order.currency),
columns.total,
y
);
if (input.order.discountCents > 0) {
y += 16;
doc
.fillColor("#666666")
.text(input.order.discountCode ? `${t.discountLabel} (${input.order.discountCode.code})` : t.discountLabel, 350, y);
doc
.fillColor("#242424")
.text(`−${formatPrice(input.order.discountCents, input.order.currency)}`, columns.total, y);
}
y += 20;
doc.fontSize(11).fillColor("#24352B").text(t.totalLabel, 350, y);
doc.text(formatPrice(input.totalCents, input.order.currency), columns.total, y);
doc
.fontSize(8)
.fillColor("#999999")
.text(t.legalNote, 50, 760, { width: 495 });
doc.end();
return done;
}