import PDFDocument from "pdfkit"; import { formatPrice, formatDate } from "@/lib/format"; import { getCompanyInfo } from "@/lib/company-info"; import { getDictionary } from "@/i18n/get-dictionary"; import { invoiceVatBreakdown, type VatBreakdownLine } from "@/lib/vat"; import { groupInvoiceItemsByVatRate, type InvoiceItemGroup } from "@/lib/invoice-groups"; import { INVOICE_COLORS as C, loadInvoiceLogo, drawInvoiceLogo, type InvoiceLogo } from "@/lib/invoice-pdf-brand"; import { buildCiiXml, type EInvoiceResult } from "@/lib/einvoice/cii"; import { embedFacturX, pdfaOptions, registerEmbeddedFonts, xmpSafe } from "@/lib/einvoice/facturx-pdf"; import type { Dictionary } from "@/i18n/dictionary-type"; import type { Locale } from "@/i18n/config"; interface InvoiceItem { productName: string; weightGrams: number; quantity: number; unitPriceCents: number; totalCents: number; /** Satz dieser Position; null bei Bestellungen vor der Steuerausweisung. */ vatRatePercent: number | null; } interface InvoicePdfInput { locale: Locale; invoiceNumber: string; issuedAt: Date; totalCents: number; order: { orderNumber: string; firstName: string; lastName: string; street: string; zip: string; city: string; /** ISO-Laendercode der Rechnungsanschrift; fehlt er, gilt Deutschland. */ country?: string; currency: string; subtotalCents: number; shippingCents: number; discountCents: number; discountCode?: { code: string } | null; items: InvoiceItem[]; }; /** * Gesetzt bei der Stornorechnung: sie hebt die Rechnung invoiceNumber vom * issuedAt auf und traegt alle Betraege mit umgekehrtem Vorzeichen. */ cancellation?: { number: string; issuedAt: Date }; } type Company = Awaited>; interface Ctx { doc: PDFKit.PDFDocument; t: Dictionary["invoicePage"]; /** Betrag mit dem Vorzeichen des Belegs (Stornorechnung negativ). */ money: (cents: number) => string; /** Betrag ohne Vorzeichenumkehr. */ plain: (cents: number) => string; isCancellation: boolean; } /** Sitz des Shops; die Firmenanschrift in den Einstellungen hat kein Land. */ const SELLER_COUNTRY_CODE = "DE"; // A4 in Punkt. Ab FOOTER_TOP beginnt die Fusszeile; was oberhalb von // CONTENT_BOTTOM nicht mehr hinpasst, wandert auf die naechste Seite. const PAGE_WIDTH = 595.28; const PAGE_HEIGHT = 841.89; const MARGIN = 50; const RIGHT = PAGE_WIDTH - MARGIN; const CONTENT_WIDTH = RIGHT - MARGIN; const FOOTER_TOP = 768; const CONTENT_BOTTOM = FOOTER_TOP - 20; // Textboxen der Positionstabelle (x, Breite) — 10 pt Innenabstand zum Rand. const COL = { name: { x: 60, w: 214 }, vat: { x: 278, w: 40 }, qty: { x: 320, w: 40 }, price: { x: 364, w: 84 }, total: { x: 450, w: RIGHT - 10 - 450 }, } as const; /** * 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) * und dieselben Rechenfunktionen (invoiceVatBreakdown, * groupInvoiceItemsByVatRate), damit beide Darstellungen desselben Belegs * uebereinstimmen. * * Gestaltung in den Markenfarben (lib/invoice-pdf-brand.ts): Logo und * Wortmarke im Kopf, Honig-Akzente, Tabellenkopf in Waldgruen. Bei einem * gemischten Korb stehen die Positionen je Steuersatz in eigenen Bloecken * mit Zwischensumme; darunter die Steueraufstellung je Satz (Netto, USt., * Brutto) neben den Summen. */ export async function generateInvoicePdf(input: InvoicePdfInput): Promise { const t = getDictionary(input.locale).invoicePage; const [company, logo] = await Promise.all([getCompanyInfo(), loadInvoiceLogo()]); const title = input.cancellation ? `${t.cancellationTitle} ${input.cancellation.number}` : `${t.documentTitle} ${input.invoiceNumber}`; const doc = new PDFDocument({ ...pdfaOptions(), margin: MARGIN, size: "A4", bufferPages: true, info: { Title: xmpSafe(title), Author: xmpSafe(company.name) }, }); registerEmbeddedFonts(doc); const chunks: Buffer[] = []; doc.on("data", (chunk: Buffer) => chunks.push(chunk)); const done = new Promise((resolve, reject) => { doc.on("end", () => resolve(Buffer.concat(chunks))); doc.on("error", reject); }); const sign = input.cancellation ? -1 : 1; const plain = (cents: number) => formatPrice(cents, input.order.currency); const ctx: Ctx = { doc, t, money: (cents) => plain(sign * cents), plain, isCancellation: Boolean(input.cancellation) }; const breakdown = invoiceVatBreakdown(input.order); drawHeader(ctx, company, logo); const addressBottom = drawRecipient(ctx, input, company); const metaBottom = drawMetaBox(ctx, input, company); let y = drawTableHeader(ctx, Math.max(addressBottom, metaBottom) + 30); y = drawItems(ctx, groupInvoiceItemsByVatRate(input.order.items), y); y = drawSummary(ctx, input, breakdown, y + 22); drawClosing(ctx, input, breakdown, y + 26); drawFooters(ctx, company); const einvoice = eInvoiceXml(input, company); if (einvoice.ok) { embedFacturX(doc, einvoice.xml, input.cancellation?.issuedAt ?? input.issuedAt); } else { console.warn(`[invoice-pdf] ${input.cancellation?.number ?? input.invoiceNumber} ohne E-Rechnung (nur PDF): ${einvoice.reason}`); } doc.end(); return done; } /** * Das XML der E-Rechnung (EN 16931) aus denselben Daten wie das PDF. Die * Stornorechnung ist eine Gutschrift mit Bezug auf die aufgehobene Rechnung. */ function eInvoiceXml(input: InvoicePdfInput, company: Company): EInvoiceResult { const { order, cancellation } = input; return buildCiiXml({ kind: cancellation ? "creditNote" : "invoice", number: cancellation?.number ?? input.invoiceNumber, issuedAt: cancellation?.issuedAt ?? input.issuedAt, currency: order.currency, orderNumber: order.orderNumber, seller: { name: company.name, street: company.address.street, zip: company.address.zip, city: company.address.city, countryCode: SELLER_COUNTRY_CODE, vatId: company.vatId, email: company.email, }, buyer: { name: `${order.firstName} ${order.lastName}`.trim(), street: order.street, zip: order.zip, city: order.city, countryCode: (order.country || SELLER_COUNTRY_CODE).toUpperCase(), }, items: order.items.map((item) => ({ name: item.productName, quantity: item.quantity, totalCents: item.totalCents, vatRatePercent: item.vatRatePercent, })), shippingCents: order.shippingCents, discountCents: order.discountCents, discountCode: order.discountCode?.code ?? null, totalCents: input.totalCents, precedingInvoice: cancellation ? { number: input.invoiceNumber, issuedAt: input.issuedAt } : undefined, paymentNote: cancellation ? "Der Betrag wird über die ursprüngliche Zahlungsart erstattet." : "Bereits bezahlt (Online-Zahlung über Stripe).", }); } function label(ctx: Ctx, text: string, x: number, y: number): void { ctx.doc .font("Helvetica-Bold") .fontSize(7) .fillColor(C.honeyDark) .text(text.toUpperCase(), x, y, { characterSpacing: 1.2, lineBreak: false }); } function hairline(ctx: Ctx, x1: number, x2: number, y: number, color: string = C.hairline): void { ctx.doc.moveTo(x1, y).lineTo(x2, y).lineWidth(0.6).strokeColor(color).stroke(); } function drawHeader(ctx: Ctx, company: Company, logo: InvoiceLogo | null): void { const { doc, t } = ctx; doc.rect(0, 0, PAGE_WIDTH, 6).fill(C.honey); const textX = MARGIN + drawInvoiceLogo(doc, logo, MARGIN, 36, 56, 150) + 14; doc .font("Times-Roman") .fontSize(22) .fillColor(C.forest) .text(company.name.toUpperCase(), textX, 46, { characterSpacing: 3.5, lineBreak: false }); if (company.claim) { doc.font("Helvetica").fontSize(8.5).fillColor(C.honeyDark).text(company.claim, textX, 74, { width: 240, lineBreak: false }); } // "STORNORECHNUNG" ist laenger als "RECHNUNG": die Schrift wird so weit // verkleinert, dass der Titel in einer Zeile rechts oben bleibt. const title = (ctx.isCancellation ? t.cancellationTitle : t.documentTitle).toUpperCase(); const titleX = 300; const titleOptions = { characterSpacing: 2 }; let titleSize = 26; doc.font("Times-Roman").fontSize(titleSize); while (titleSize > 14 && doc.widthOfString(title, titleOptions) > RIGHT - titleX) { doc.fontSize(--titleSize); } doc .fillColor(C.forest) .text(title, titleX, 48 + (26 - titleSize) / 2, { ...titleOptions, width: RIGHT - titleX, align: "right", lineBreak: false }); hairline(ctx, MARGIN, RIGHT, 112, C.honey); } /** Absenderzeile (fuer Fensterumschlaege) und Rechnungsempfaenger. */ function drawRecipient(ctx: Ctx, input: InvoicePdfInput, company: Company): number { const { doc, t } = ctx; const { order } = input; const top = 136; const sender = [company.name, company.address.street, `${company.address.zip} ${company.address.city}`].join(" · "); doc.font("Helvetica").fontSize(7).fillColor(C.sage).text(sender, MARGIN, top, { width: 260, lineBreak: false }); hairline(ctx, MARGIN, MARGIN + 260, top + 11); label(ctx, t.billedToLabel, MARGIN, top + 24); doc.font("Helvetica-Bold").fontSize(10.5).fillColor(C.ink).text(`${order.firstName} ${order.lastName}`, MARGIN, top + 38, { width: 260 }); doc.font("Helvetica").fontSize(10).text(order.street, { width: 260 }).text(`${order.zip} ${order.city}`, { width: 260 }); return doc.y; } /** Rechnungsdaten rechts oben, inklusive USt-IdNr. (§ 14 Abs. 4 Nr. 2 UStG). */ function drawMetaBox(ctx: Ctx, input: InvoicePdfInput, company: Company): number { const { doc, t } = ctx; const documentRows: [string, string][] = input.cancellation ? [ [t.cancellationNumberLabel, input.cancellation.number], [t.cancellationDateLabel, formatDate(input.cancellation.issuedAt)], [t.cancelsInvoiceLabel, input.invoiceNumber], ] : [ [t.invoiceNumberLabel, input.invoiceNumber], [t.invoiceDateLabel, formatDate(input.issuedAt)], ]; const rows: [string, string][] = [ ...documentRows, [t.orderNumberLabel, input.order.orderNumber], ...(company.vatId ? ([[t.vatIdLabel, company.vatId]] as [string, string][]) : []), ]; const x = 330; const top = 132; const rowHeight = 21; const height = 18 + rows.length * rowHeight; doc.rect(x, top, RIGHT - x, height).fill(C.cream); doc.rect(x, top, 3, height).fill(C.honey); rows.forEach(([name, value], index) => { const rowY = top + 13 + index * rowHeight; doc.font("Helvetica").fontSize(8).fillColor(C.sage).text(name, x + 16, rowY + 1, { lineBreak: false }); doc .font("Helvetica-Bold") .fontSize(9.5) .fillColor(C.forest) .text(value, x + 16, rowY, { width: RIGHT - x - 30, align: "right", lineBreak: false }); }); return top + height; } function drawTableHeader(ctx: Ctx, y: number): number { const { doc, t } = ctx; doc.rect(MARGIN, y, CONTENT_WIDTH, 22).fill(C.forest); doc.font("Helvetica-Bold").fontSize(7.5).fillColor(C.cream); const cell = (text: string, col: { x: number; w: number }, align: "left" | "center" | "right") => doc.text(text.toUpperCase(), col.x, y + 8, { width: col.w, align, characterSpacing: 0.8, lineBreak: false }); cell(t.itemHeader, COL.name, "left"); cell(t.vatRateHeader, COL.vat, "center"); cell(t.quantityHeader, COL.qty, "center"); cell(t.priceHeader, COL.price, "right"); cell(t.totalHeader, COL.total, "right"); return y + 22; } /** Neue Seite, wenn der naechste Block nicht mehr ueber die Fusszeile passt. */ function ensureSpace(ctx: Ctx, y: number, needed: number, repeatTableHeader: boolean): number { if (y + needed <= CONTENT_BOTTOM) return y; ctx.doc.addPage(); return repeatTableHeader ? drawTableHeader(ctx, MARGIN) : MARGIN; } function drawItems(ctx: Ctx, groups: InvoiceItemGroup[], startY: number): number { // Blockueberschriften und Zwischensummen nur beim gemischten Korb — bei // einem einheitlichen Satz waeren sie reine Wiederholung. const isMixed = groups.length > 1; let y = startY; for (const group of groups) { if (isMixed && group.ratePercent !== null) y = drawGroupHeading(ctx, group.ratePercent, y); for (const item of group.items) y = drawItemRow(ctx, item, y); if (isMixed && group.ratePercent !== null) y = drawGroupSubtotal(ctx, group, y); } return y; } function drawGroupHeading(ctx: Ctx, ratePercent: number, startY: number): number { // Platz fuer Ueberschrift UND erste Position — eine Ueberschrift allein am // Seitenende waere verwaist. const y = ensureSpace(ctx, startY, 20 + 32, true); ctx.doc.rect(MARGIN, y, CONTENT_WIDTH, 20).fill(C.honeyPale); label(ctx, ctx.t.groupHeading(ratePercent), COL.name.x, y + 7); return y + 20; } function drawItemRow(ctx: Ctx, item: InvoiceItem, startY: number): number { const { doc, money } = ctx; doc.font("Helvetica").fontSize(9.5); const nameHeight = doc.heightOfString(item.productName, { width: COL.name.w }); const rowHeight = Math.max(32, nameHeight + 22); const y = ensureSpace(ctx, startY, rowHeight, true); doc.font("Helvetica").fontSize(9.5).fillColor(C.ink).text(item.productName, COL.name.x, y + 8, { width: COL.name.w }); doc.fontSize(7.5).fillColor(C.sage).text(`${item.weightGrams} g`, COL.name.x, y + 10 + nameHeight, { lineBreak: false }); const cell = (text: string, col: { x: number; w: number }, align: "center" | "right", bold = false) => doc .font(bold ? "Helvetica-Bold" : "Helvetica") .fontSize(9.5) .fillColor(bold ? C.forest : C.ink) .text(text, col.x, y + 8, { width: col.w, align, lineBreak: false }); cell(item.vatRatePercent === null ? "–" : `${item.vatRatePercent} %`, COL.vat, "center"); cell(String(item.quantity), COL.qty, "center"); cell(money(item.unitPriceCents), COL.price, "right"); cell(money(item.totalCents), COL.total, "right", true); hairline(ctx, MARGIN, RIGHT, y + rowHeight); return y + rowHeight; } function drawGroupSubtotal(ctx: Ctx, group: InvoiceItemGroup, startY: number): number { const { doc, t, money } = ctx; const y = ensureSpace(ctx, startY, 24, true); doc .font("Helvetica") .fontSize(8.5) .fillColor(C.sage) .text(t.groupSubtotalLabel(group.ratePercent ?? 0), COL.qty.x, y + 8, { width: COL.price.x + COL.price.w - COL.qty.x, align: "right", lineBreak: false, }); doc .font("Helvetica-Bold") .fontSize(9.5) .fillColor(C.forest) .text(money(group.totalCents), COL.total.x, y + 7, { width: COL.total.w, align: "right", lineBreak: false }); return y + 30; } /** Links die Steueraufstellung je Satz, rechts Summen und Gesamtbetrag. */ function drawSummary(ctx: Ctx, input: InvoicePdfInput, breakdown: VatBreakdownLine[], startY: number): number { const y = ensureSpace(ctx, startY, 160, false); const leftBottom = drawTaxSummary(ctx, breakdown, y); const rightBottom = drawTotals(ctx, input, y); return Math.max(leftBottom, rightBottom); } function drawTotals(ctx: Ctx, input: InvoicePdfInput, top: number): number { const { doc, t, money, plain } = ctx; const { order } = input; const x = 330; const discountName = order.discountCode ? `${t.discountLabel} (${order.discountCode.code})` : t.discountLabel; const rows: [string, string][] = [ [t.subtotalLabel, money(order.subtotalCents)], [t.shippingLabel, order.shippingCents === 0 ? t.freeShipping : money(order.shippingCents)], ...(order.discountCents > 0 ? ([[discountName, `${ctx.isCancellation ? "+" : "–"} ${plain(order.discountCents)}`]] as [string, string][]) : []), ]; let y = top; for (const [name, value] of rows) { doc.font("Helvetica").fontSize(9).fillColor(C.sage).text(name, x + 12, y, { width: 120, lineBreak: false }); doc.fillColor(C.ink).text(value, x, y, { width: RIGHT - x - 12, align: "right", lineBreak: false }); y += 18; } y += 4; doc.rect(x, y, RIGHT - x, 34).fill(C.forest); doc.rect(x, y + 34, RIGHT - x, 3).fill(C.honey); doc.font("Times-Roman").fontSize(13).fillColor(C.cream).text(t.totalLabel, x + 12, y + 11, { lineBreak: false }); doc .font("Helvetica-Bold") .fontSize(13) .fillColor(C.cream) .text(money(input.totalCents), x, y + 11, { width: RIGHT - x - 12, align: "right", lineBreak: false }); return y + 37; } /** * Steuerausweis JE SATZ: eine Bestellung kann Honig (ermaessigt) und eine * Bienenwachskerze (voll) zugleich enthalten. Die Saetze stehen an den * Positionen, nicht in der Umgebung — ein alter Beleg bleibt damit auch nach * einer Umkategorisierung unveraendert. Eine leere Aufstellung heisst: * Bestellung aus der Zeit vor der Steuerausweisung, dann bleibt der Block * weg. Der Hinweis nach § 19 UStG ist bei Kleinunternehmerregelung eine * Pflichtangabe — er steht anstelle der Steuerzeilen, nicht zusaetzlich, und * nur, wenn die GESAMTE Rechnung steuerfrei ist. */ function drawTaxSummary(ctx: Ctx, breakdown: VatBreakdownLine[], top: number): number { const { doc, t, money } = ctx; const width = 250; if (breakdown.length === 0) return top; label(ctx, t.taxSummaryHeading, MARGIN, top); if (breakdown.every((line) => line.ratePercent === 0)) { doc.font("Helvetica").fontSize(8.5).fillColor(C.ink).text(t.smallBusinessNote, MARGIN, top + 16, { width }); return doc.y; } const cols = [ { x: MARGIN + 8, w: 50, align: "left" as const }, { x: MARGIN + 58, w: 62, align: "right" as const }, { x: MARGIN + 120, w: 58, align: "right" as const }, { x: MARGIN + 178, w: width - 186, align: "right" as const }, ]; const row = (cells: string[], y: number, font: string, color: string) => cells.forEach((text, index) => doc .font(font) .fontSize(8.5) .fillColor(color) .text(text, cols[index].x, y, { width: cols[index].w, align: cols[index].align, lineBreak: false }) ); let y = top + 14; doc.rect(MARGIN, y, width, 18).fill(C.cream); row([t.taxRateHeader, t.netHeader, t.vatAmountHeader, t.grossHeader], y + 5, "Helvetica-Bold", C.forest); y += 18; const lines = breakdown.filter((line) => line.ratePercent > 0); for (const line of lines) { row([`${line.ratePercent} %`, money(line.netCents), money(line.vatCents), money(line.grossCents)], y + 5, "Helvetica", C.ink); y += 18; hairline(ctx, MARGIN, MARGIN + width, y); } if (lines.length > 1) { const sum = (key: "netCents" | "vatCents" | "grossCents") => money(lines.reduce((total, line) => total + line[key], 0)); row([t.totalHeader, sum("netCents"), sum("vatCents"), sum("grossCents")], y + 5, "Helvetica-Bold", C.forest); y += 18; } return y; } function drawClosing(ctx: Ctx, input: InvoicePdfInput, breakdown: VatBreakdownLine[], startY: number): void { const { doc, t } = ctx; const y = ensureSpace(ctx, startY, 40, false); if (breakdown.some((line) => line.ratePercent > 0)) { doc.font("Helvetica").fontSize(8).fillColor(C.sage).text(t.pricesIncludeVat, MARGIN, y, { width: CONTENT_WIDTH }); } if (input.cancellation) { doc .font("Helvetica") .fontSize(9.5) .fillColor(C.ink) .text(t.cancellationNote(input.invoiceNumber, formatDate(input.issuedAt)), MARGIN, y + 16, { width: CONTENT_WIDTH }); return; } doc.font("Times-Italic").fontSize(13).fillColor(C.honeyDark).text(t.thankYou, MARGIN, y + 16, { width: CONTENT_WIDTH }); } /** * Fusszeile auf jeder Seite: Anschrift, Kontakt, USt-IdNr. und Seitenzahl. * Der untere Seitenrand wird dafuer kurz auf 0 gesetzt — sonst wuerde pdfkit * Text unterhalb des Rands als Seitenumbruch deuten und leere Seiten anhaengen. */ function drawFooters(ctx: Ctx, company: Company): void { const { doc, t } = ctx; const range = doc.bufferedPageRange(); for (let index = range.start; index < range.start + range.count; index++) { doc.switchToPage(index); const bottomMargin = doc.page.margins.bottom; doc.page.margins.bottom = 0; hairline(ctx, MARGIN, RIGHT, FOOTER_TOP, C.honey); doc.font("Helvetica").fontSize(7).fillColor(C.sage); const options = { width: 160, lineGap: 1.5 }; const address = [company.name, company.address.street, `${company.address.zip} ${company.address.city}`]; const contact = [company.email, company.phone, company.domain].filter(Boolean); const legal = [company.vatId ? `${t.vatIdLabel} ${company.vatId}` : "", t.pageLabel(index - range.start + 1, range.count)].filter(Boolean); doc.text(address.join("\n"), MARGIN, FOOTER_TOP + 9, options); doc.text(contact.join("\n"), 218, FOOTER_TOP + 9, options); doc.text(legal.join("\n"), RIGHT - 160, FOOTER_TOP + 9, { ...options, align: "right" }); doc.fontSize(6.5).text(t.legalNote, MARGIN, FOOTER_TOP + 48, { width: CONTENT_WIDTH, align: "center" }); doc.rect(0, PAGE_HEIGHT - 4, PAGE_WIDTH, 4).fill(C.honey); doc.page.margins.bottom = bottomMargin; } }