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.
131 lines
4.3 KiB
TypeScript
131 lines
4.3 KiB
TypeScript
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;
|
||
}
|