From 77bdd9958c87dce8aea2b49e4932ff13fb6967de Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 1 Oct 2026 00:49:59 +0200 Subject: [PATCH] feat(rechnung): branded invoice with VAT ID and per-rate grouping The invoice PDF and the invoice page now share one branded layout: logo (uploaded brand logo, otherwise the drawn honeycomb mark) and wordmark in the header, honey accents, a forest-green table header, a meta box with invoice number, date, order number and VAT ID, and a footer with address, contact, VAT ID and page numbers on every page. A mixed cart (e.g. honey at 7 % and a beeswax candle at 19 %) lists its items in one block per VAT rate with a subtotal each. Next to the totals, a VAT table shows net, VAT and gross per rate plus a sum row. Long invoices continue on further pages with the table header repeated. The VAT identification number is a new company field (company.vatId), editable in Admin > Inhalte, defaulting to the number already stated in the imprint. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/rechnung/[invoiceNumber]/page.tsx | 366 +++++++++++----- src/i18n/dictionaries/de.ts | 26 +- src/i18n/dictionaries/en.ts | 26 +- src/i18n/dictionary-type.ts | 22 +- src/lib/company-info.ts | 4 +- src/lib/content-registry.ts | 1 + src/lib/invoice-groups.test.ts | 53 +++ src/lib/invoice-groups.ts | 36 ++ src/lib/invoice-pdf-brand.ts | 108 +++++ src/lib/invoice-pdf.test.ts | 81 ++++ src/lib/invoice-pdf.ts | 482 ++++++++++++++++------ src/lib/site-config.ts | 6 + 12 files changed, 965 insertions(+), 246 deletions(-) create mode 100644 src/lib/invoice-groups.test.ts create mode 100644 src/lib/invoice-groups.ts create mode 100644 src/lib/invoice-pdf-brand.ts create mode 100644 src/lib/invoice-pdf.test.ts diff --git a/src/app/rechnung/[invoiceNumber]/page.tsx b/src/app/rechnung/[invoiceNumber]/page.tsx index 6714da3..bab239c 100644 --- a/src/app/rechnung/[invoiceNumber]/page.tsx +++ b/src/app/rechnung/[invoiceNumber]/page.tsx @@ -4,21 +4,38 @@ import { noIndexPrivate } from "@/lib/seo"; import { notFound, redirect } from "next/navigation"; import { Container } from "@/components/ui"; import { BackLink } from "@/components/back-link"; +import { Logomark } from "@/components/logomark"; import { getCurrentCustomer } from "@/lib/auth"; import { getInvoiceForCustomer } from "@/lib/account-data"; import { formatPrice, formatDate } from "@/lib/format"; import { getCompanyInfo } from "@/lib/company-info"; -import { invoiceVatBreakdown } from "@/lib/vat"; +import { getBrandAssets } from "@/lib/brand"; +import { invoiceVatBreakdown, type VatBreakdownLine } from "@/lib/vat"; +import { groupInvoiceItemsByVatRate, type InvoiceItemGroup } from "@/lib/invoice-groups"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; +import type { Dictionary } from "@/i18n/dictionary-type"; // Rechnungen enthalten Name, Anschrift und Kaufhistorie. noIndexPrivate // unterbindet zusaetzlich Cache-Kopie und Snippet (siehe lib/seo.ts). export const metadata: Metadata = { title: "Rechnung", robots: noIndexPrivate }; export const dynamic = "force-dynamic"; +type InvoiceTexts = Dictionary["invoicePage"]; +type Company = Awaited>; +type Money = (cents: number) => string; +type Invoice = NonNullable>>; +type InvoiceItem = Invoice["order"]["items"][number]; + +/** + * HTML-Ansicht der Rechnung — gleicher Aufbau wie das PDF (lib/invoice-pdf.ts): + * Markenkopf mit Logo, Rechnungsdaten samt USt-IdNr., Positionen je + * Steuersatz gruppiert (nur beim gemischten Korb), Steueraufstellung je Satz + * und Gesamtbetrag. Beide nutzen dieselben Rechenfunktionen, damit die zwei + * Darstellungen desselben Belegs uebereinstimmen. + */ export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNumber]">) { - const t = getDictionary(await getLocale()); + const t = getDictionary(await getLocale()).invoicePage; const { invoiceNumber } = await props.params; const customer = await getCurrentCustomer(); if (!customer) redirect(noticeUrl("invoice-login")); @@ -26,120 +43,253 @@ export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNu const invoice = await getInvoiceForCustomer(invoiceNumber, customer.id); if (!invoice) notFound(); - const { order } = invoice; - const company = await getCompanyInfo(); - // Aufstellung je Steuersatz — eine Bestellung kann Honig (ermaessigt) und - // eine Bienenwachskerze (voll) zugleich enthalten. Dieselbe Funktion nutzt - // das PDF, damit beide Darstellungen desselben Belegs uebereinstimmen. - const vatLines = invoiceVatBreakdown(order); - const isSmallBusiness = vatLines.length > 0 && vatLines.every((line) => line.ratePercent === 0); + const [company, brand] = await Promise.all([getCompanyInfo(), getBrandAssets()]); + const money: Money = (cents) => formatPrice(cents, invoice.order.currency); + const vatLines = invoiceVatBreakdown(invoice.order); return (
- + -
-
-

{company.name.toUpperCase()}

-

- {company.address.street} -
- {company.address.zip} {company.address.city} -

+ +
+ + + +
+ +
-
-

{t.invoicePage.invoiceLabel(invoice.invoiceNumber)}

-

{t.invoicePage.orderLabel(order.orderNumber)}

-

{formatDate(invoice.issuedAt)}

-
-
- -
-

{order.firstName} {order.lastName}

-

{order.street}

-

{order.zip} {order.city}

-
- - - - - - - - - - - - {order.items.map((item) => ( - - - - - - - ))} - -
{t.invoicePage.itemHeader}{t.invoicePage.quantityHeader}{t.invoicePage.priceHeader}{t.invoicePage.totalHeader}
{item.productName} ({item.weightGrams} g){item.quantity}{formatPrice(item.unitPriceCents)}{formatPrice(item.totalCents)}
- -
-
-
- {t.invoicePage.subtotalLabel} - {formatPrice(order.subtotalCents, order.currency)} -
-
- {t.invoicePage.shippingLabel} - - {order.shippingCents === 0 ? t.invoicePage.freeShipping : formatPrice(order.shippingCents, order.currency)} - -
- {order.discountCents > 0 ? ( -
- - {t.invoicePage.discountLabel}{order.discountCode ? ` (${order.discountCode.code})` : ""} - - −{formatPrice(order.discountCents, order.currency)} -
- ) : null} - {/* Steuerausweis mit demselben an der Rechnung festgeschriebenen - Satz wie im PDF — beide Darstellungen desselben Belegs muessen - uebereinstimmen. */} - {vatLines - .filter((line) => line.ratePercent > 0) - .map((line) => ( -
-
- {t.invoicePage.netLabel} - {formatPrice(line.netCents, order.currency)} -
-
- {t.invoicePage.vatLabel(line.ratePercent)} - {formatPrice(line.vatCents, order.currency)} -
-
- ))} -
- {t.invoicePage.totalLabel} - {formatPrice(invoice.totalCents, order.currency)} -
- {/* Pflichtangabe bei Kleinunternehmerregelung — steht anstelle der - Steuerzeilen, nicht zusaetzlich. */} - {isSmallBusiness ? ( -

{t.invoicePage.smallBusinessNote}

- ) : null} -
-
- -

{t.invoicePage.legalNote}

+ {vatLines.some((line) => line.ratePercent > 0) ? ( +

{t.pricesIncludeVat}

+ ) : null} +

{t.thankYou}

+ +
); } + +function SectionLabel({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +function InvoiceHeader({ t, company, logo }: { t: InvoiceTexts; company: Company; logo: string | null }) { + return ( +
+
+ +
+

{company.name.toUpperCase()}

+ {company.claim ?

{company.claim}

: null} +
+
+

{t.documentTitle.toUpperCase()}

+
+ ); +} + +/** Absender, Empfaenger und Rechnungsdaten inklusive USt-IdNr. (§ 14 Abs. 4 Nr. 2 UStG). */ +function InvoiceParties({ t, invoice, company }: { t: InvoiceTexts; invoice: Invoice; company: Company }) { + const { order } = invoice; + const metaRows: [string, string][] = [ + [t.invoiceNumberLabel, invoice.invoiceNumber], + [t.invoiceDateLabel, formatDate(invoice.issuedAt)], + [t.orderNumberLabel, order.orderNumber], + ...(company.vatId ? ([[t.vatIdLabel, company.vatId]] as [string, string][]) : []), + ]; + return ( +
+
+

+ {company.name} · {company.address.street} · {company.address.zip} {company.address.city} +

+
+ {t.billedToLabel} +
+

+ {order.firstName} {order.lastName} +

+

{order.street}

+

+ {order.zip} {order.city} +

+
+
+ {metaRows.map(([name, value]) => ( +
+
{name}
+
{value}
+
+ ))} +
+
+ ); +} + +function InvoiceItemsTable({ t, groups, money }: { t: InvoiceTexts; groups: InvoiceItemGroup[]; money: Money }) { + // Blockueberschriften und Zwischensummen nur beim gemischten Korb. + const isMixed = groups.length > 1; + return ( +
+ + + + + + + + + + + {groups.map((group) => ( + + {isMixed && group.ratePercent !== null ? ( + + + + ) : null} + {group.items.map((item) => ( + + + + + + + + ))} + {isMixed && group.ratePercent !== null ? ( + + + + + ) : null} + + ))} +
{t.itemHeader}{t.vatRateHeader}{t.quantityHeader}{t.priceHeader}{t.totalHeader}
+ {t.groupHeading(group.ratePercent)} +
+ {item.productName} + {item.weightGrams} g + + {item.vatRatePercent === null ? "–" : `${item.vatRatePercent} %`} + {item.quantity}{money(item.unitPriceCents)}{money(item.totalCents)}
+ {t.groupSubtotalLabel(group.ratePercent)} + {money(group.totalCents)}
+
+ ); +} + +/** + * Steuerausweis je Satz. Der Hinweis nach § 19 UStG ist bei + * Kleinunternehmerregelung Pflicht und steht anstelle der Steuerzeilen, nicht + * zusaetzlich. Leere Aufstellung = Bestellung vor der Steuerausweisung. + */ +function InvoiceTaxSummary({ t, lines, money }: { t: InvoiceTexts; lines: VatBreakdownLine[]; money: Money }) { + if (lines.length === 0) return
; + const taxed = lines.filter((line) => line.ratePercent > 0); + const sum = (key: "netCents" | "vatCents" | "grossCents") => money(taxed.reduce((total, line) => total + line[key], 0)); + + return ( +
+ {t.taxSummaryHeading} + {taxed.length === 0 ?

{t.smallBusinessNote}

: null} + {taxed.length > 0 ? ( + + + + + + + + + + + {taxed.map((line) => ( + + + + + + + ))} + {taxed.length > 1 ? ( + + + + + + + ) : null} + +
{t.taxRateHeader}{t.netHeader}{t.vatAmountHeader}{t.grossHeader}
{line.ratePercent} %{money(line.netCents)}{money(line.vatCents)}{money(line.grossCents)}
{t.totalHeader}{sum("netCents")}{sum("vatCents")}{sum("grossCents")}
+ ) : null} +
+ ); +} + +function InvoiceTotals({ t, invoice, money }: { t: InvoiceTexts; invoice: Invoice; money: Money }) { + const { order } = invoice; + return ( +
+
+
+ {t.subtotalLabel} + {money(order.subtotalCents)} +
+
+ {t.shippingLabel} + {order.shippingCents === 0 ? t.freeShipping : money(order.shippingCents)} +
+ {order.discountCents > 0 ? ( +
+ + {t.discountLabel} + {order.discountCode ? ` (${order.discountCode.code})` : ""} + + – {money(order.discountCents)} +
+ ) : null} +
+
+ {t.totalLabel} + {money(invoice.totalCents)} +
+
+ ); +} + +function InvoiceFooter({ t, company }: { t: InvoiceTexts; company: Company }) { + return ( +
+

+ {company.name} +
+ {company.address.street} +
+ {company.address.zip} {company.address.city} +

+

+ {company.domain} + {company.phone ? ( + <> +
+ {company.phone} + + ) : null} +

+ {company.vatId ? ( +

+ {t.vatIdLabel} {company.vatId} +

+ ) : null} +

{t.legalNote}

+
+ ); +} diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index 79257ab..37aa3d8 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -1587,21 +1587,33 @@ export const de: Dictionary = { loginRequiredError: "Bitte melde dich an, um die Rechnung zu sehen.", backToOrders: "Zurück zu meinen Bestellungen", downloadPdf: "PDF herunterladen", - invoiceLabel: (invoiceNumber) => `Rechnung ${invoiceNumber}`, - orderLabel: (orderNumber) => `Bestellung ${orderNumber}`, - dateLabel: "Datum:", + documentTitle: "Rechnung", + billedToLabel: "Rechnung an", + invoiceNumberLabel: "Rechnungsnummer", + invoiceDateLabel: "Rechnungsdatum", + orderNumberLabel: "Bestellnummer", + vatIdLabel: "USt-IdNr.", itemHeader: "Artikel", + vatRateHeader: "USt.", quantityHeader: "Menge", - priceHeader: "Preis", + priceHeader: "Einzelpreis", totalHeader: "Summe", + groupHeading: (ratePercent) => `Artikel mit ${ratePercent} % Umsatzsteuer`, + groupSubtotalLabel: (ratePercent) => `Summe ${ratePercent} %`, subtotalLabel: "Zwischensumme", shippingLabel: "Versand", freeShipping: "kostenlos", discountLabel: "Rabatt", - netLabel: "Nettobetrag", - vatLabel: (ratePercent) => `zzgl. ${ratePercent} % Umsatzsteuer`, + taxSummaryHeading: "Enthaltene Umsatzsteuer", + taxRateHeader: "Steuersatz", + netHeader: "Netto", + vatAmountHeader: "USt.", + grossHeader: "Brutto", smallBusinessNote: "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.", - totalLabel: "Gesamt", + totalLabel: "Gesamtbetrag", + pricesIncludeVat: "Alle Preise verstehen sich inklusive der gesetzlichen Umsatzsteuer.", + thankYou: "Vielen Dank, dass du unsere Imkerei unterstützt!", + pageLabel: (page, total) => `Seite ${page} von ${total}`, legalNote: "Diese Rechnung wurde automatisch erzeugt und ist ohne Unterschrift gültig.", }, addressesPage: { diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index b5004ad..8b8d99f 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -1561,21 +1561,33 @@ export const en: Dictionary = { loginRequiredError: "Please sign in to view the invoice.", backToOrders: "Back to my orders", downloadPdf: "Download PDF", - invoiceLabel: (invoiceNumber) => `Invoice ${invoiceNumber}`, - orderLabel: (orderNumber) => `Order ${orderNumber}`, - dateLabel: "Date:", + documentTitle: "Invoice", + billedToLabel: "Billed to", + invoiceNumberLabel: "Invoice number", + invoiceDateLabel: "Invoice date", + orderNumberLabel: "Order number", + vatIdLabel: "VAT ID", itemHeader: "Item", + vatRateHeader: "VAT", quantityHeader: "Qty", - priceHeader: "Price", + priceHeader: "Unit price", totalHeader: "Total", + groupHeading: (ratePercent) => `Items at ${ratePercent}% VAT`, + groupSubtotalLabel: (ratePercent) => `Subtotal ${ratePercent}%`, subtotalLabel: "Subtotal", shippingLabel: "Shipping", freeShipping: "free", discountLabel: "Discount", - netLabel: "Net amount", - vatLabel: (ratePercent) => `plus ${ratePercent}% VAT`, + taxSummaryHeading: "VAT included", + taxRateHeader: "Rate", + netHeader: "Net", + vatAmountHeader: "VAT", + grossHeader: "Gross", smallBusinessNote: "No VAT is charged (small-business scheme, § 19 German VAT Act).", - totalLabel: "Total", + totalLabel: "Total amount", + pricesIncludeVat: "All prices include statutory VAT.", + thankYou: "Thank you for supporting our apiary!", + pageLabel: (page, total) => `Page ${page} of ${total}`, legalNote: "This invoice was generated automatically and is valid without a signature.", }, addressesPage: { diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index 11e0295..012016c 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -1118,21 +1118,33 @@ export interface Dictionary { loginRequiredError: string; backToOrders: string; downloadPdf: string; - invoiceLabel: (invoiceNumber: string) => string; - orderLabel: (orderNumber: string) => string; - dateLabel: string; + documentTitle: string; + billedToLabel: string; + invoiceNumberLabel: string; + invoiceDateLabel: string; + orderNumberLabel: string; + vatIdLabel: string; itemHeader: string; + vatRateHeader: string; quantityHeader: string; priceHeader: string; totalHeader: string; + groupHeading: (ratePercent: number) => string; + groupSubtotalLabel: (ratePercent: number) => string; subtotalLabel: string; shippingLabel: string; freeShipping: string; discountLabel: string; - netLabel: string; - vatLabel: (ratePercent: number) => string; + taxSummaryHeading: string; + taxRateHeader: string; + netHeader: string; + vatAmountHeader: string; + grossHeader: string; smallBusinessNote: string; totalLabel: string; + pricesIncludeVat: string; + thankYou: string; + pageLabel: (page: number, total: number) => string; legalNote: string; }; addressesPage: { diff --git a/src/lib/company-info.ts b/src/lib/company-info.ts index 1f61733..7d884c9 100644 --- a/src/lib/company-info.ts +++ b/src/lib/company-info.ts @@ -11,6 +11,7 @@ const COMPANY_KEYS = [ "company.location", "company.email", "company.phone", + "company.vatId", "company.street", "company.zip", "company.city", @@ -27,7 +28,7 @@ const COMPANY_KEYS = [ * aufgerufen wird: allein das Root-Layout braucht sie zweimal (einmal fuer * generateMetadata, einmal fuer das LocalBusiness-Schema im Rumpf), dazu * kommt fast jede Seite mit einem eigenen Aufruf. Ohne die Huelle war das je - * Aufruf eine eigene Datenbankabfrage ueber dieselben zehn Schluessel — + * Aufruf eine eigene Datenbankabfrage ueber dieselben elf Schluessel — * dreimal dasselbe Ergebnis pro gerenderter Seite. * * cache() ist auf EINEN Request begrenzt und kein serverweiter Zwischen- @@ -54,6 +55,7 @@ export const getCompanyInfo = cache(async () => { // ergibt; das Admin laesst solche Eintraege nicht mehr speichern. email: normalizeContactEmail(v["company.email"]) ?? siteConfig.email, phone: v["company.phone"], + vatId: v["company.vatId"].trim(), address: { ...siteConfig.address, street: v["company.street"], diff --git a/src/lib/content-registry.ts b/src/lib/content-registry.ts index 6f596c9..3139c54 100644 --- a/src/lib/content-registry.ts +++ b/src/lib/content-registry.ts @@ -70,6 +70,7 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [ { key: "company.location", group: "company", labelDe: "Standort (Anzeigetext)", labelEn: "Location (display text)", type: "text", localized: false, getDefault: () => siteConfig.location }, { key: "company.email", group: "company", labelDe: "E-Mail", labelEn: "Email", type: "text", localized: false, getDefault: () => siteConfig.email }, { key: "company.phone", group: "company", labelDe: "Telefon", labelEn: "Phone", type: "text", localized: false, getDefault: () => siteConfig.phone }, + { key: "company.vatId", group: "company", labelDe: "Umsatzsteuer-Identifikationsnummer (erscheint auf jeder Rechnung)", labelEn: "VAT identification number (shown on every invoice)", type: "text", localized: false, getDefault: () => siteConfig.vatId }, { key: "company.street", group: "company", labelDe: "Straße", labelEn: "Street", type: "text", localized: false, getDefault: () => siteConfig.address.street }, { key: "company.zip", group: "company", labelDe: "PLZ", labelEn: "ZIP", type: "text", localized: false, getDefault: () => siteConfig.address.zip }, { key: "company.city", group: "company", labelDe: "Ort", labelEn: "City", type: "text", localized: false, getDefault: () => siteConfig.address.city }, diff --git a/src/lib/invoice-groups.test.ts b/src/lib/invoice-groups.test.ts new file mode 100644 index 0000000..6a8b342 --- /dev/null +++ b/src/lib/invoice-groups.test.ts @@ -0,0 +1,53 @@ +import { describe, test, expect } from "vitest"; +import { groupInvoiceItemsByVatRate } from "./invoice-groups"; + +const honey = { productName: "Blütenhonig", totalCents: 1290, vatRatePercent: 7 }; +const forestHoney = { productName: "Waldhonig", totalCents: 1490, vatRatePercent: 7 }; +const candle = { productName: "Bienenwachskerze", totalCents: 1900, vatRatePercent: 19 }; + +describe("groupInvoiceItemsByVatRate", () => { + test("fasst Positionen je Steuersatz zusammen, aufsteigend nach Satz", () => { + // Arrange: gemischter Korb, die Kerze steht bewusst zwischen den Honigen. + const items = [honey, candle, forestHoney]; + + // Act + const groups = groupInvoiceItemsByVatRate(items); + + // Assert + expect(groups.map((group) => group.ratePercent)).toEqual([7, 19]); + expect(groups[0].items).toEqual([honey, forestHoney]); + expect(groups[1].items).toEqual([candle]); + }); + + test("summiert die Positionen jeder Gruppe", () => { + const groups = groupInvoiceItemsByVatRate([honey, candle, forestHoney]); + + expect(groups[0].totalCents).toBe(1290 + 1490); + expect(groups[1].totalCents).toBe(1900); + }); + + test("ergibt bei einheitlichem Satz genau eine Gruppe", () => { + const groups = groupInvoiceItemsByVatRate([honey, forestHoney]); + + expect(groups).toHaveLength(1); + expect(groups[0].ratePercent).toBe(7); + }); + + test("haelt die Reihenfolge der Positionen innerhalb einer Gruppe", () => { + const groups = groupInvoiceItemsByVatRate([forestHoney, honey]); + + expect(groups[0].items.map((item) => item.productName)).toEqual(["Waldhonig", "Blütenhonig"]); + }); + + test("fuehrt Positionen ohne Satz (Altbestellungen) als eigene Gruppe mit ratePercent null", () => { + const legacy = { productName: "Altbestand", totalCents: 990, vatRatePercent: null }; + + const groups = groupInvoiceItemsByVatRate([legacy]); + + expect(groups).toEqual([{ ratePercent: null, items: [legacy], totalCents: 990 }]); + }); + + test("liefert fuer eine leere Liste keine Gruppe", () => { + expect(groupInvoiceItemsByVatRate([])).toEqual([]); + }); +}); diff --git a/src/lib/invoice-groups.ts b/src/lib/invoice-groups.ts new file mode 100644 index 0000000..1bfa386 --- /dev/null +++ b/src/lib/invoice-groups.ts @@ -0,0 +1,36 @@ +export interface InvoiceItemGroup { + /** Steuersatz der Gruppe; null bei Positionen aus der Zeit vor der Steuerausweisung. */ + ratePercent: number | null; + items: T[]; + /** Summe der Positionen dieser Gruppe (brutto, wie an den Positionen gespeichert). */ + totalCents: number; +} + +/** + * Teilt die Positionen einer Rechnung nach Steuersatz auf, damit ein + * gemischter Korb (Honig 7 %, Bienenwachskerze 19 %) auf der Rechnung in + * getrennten Bloecken steht — jede Position laesst sich so auf einen Blick + * ihrem Satz zuordnen, und die Steueraufstellung darunter (lib/vat.ts, + * invoiceVatBreakdown) hat ein sichtbares Gegenstueck. + * + * Gruppen aufsteigend nach Satz, Positionen innerhalb einer Gruppe in ihrer + * urspruenglichen Reihenfolge. Positionen ohne Satz bilden eine eigene + * Gruppe am Ende, statt einem Satz zugeschlagen zu werden, den sie nie hatten. + * Rechnungsansicht und PDF nutzen dieselbe Funktion. + */ +export function groupInvoiceItemsByVatRate( + items: readonly T[] +): InvoiceItemGroup[] { + const byRate = new Map(); + for (const item of items) { + byRate.set(item.vatRatePercent, [...(byRate.get(item.vatRatePercent) ?? []), item]); + } + + return [...byRate.entries()] + .sort(([a], [b]) => (a ?? Number.POSITIVE_INFINITY) - (b ?? Number.POSITIVE_INFINITY)) + .map(([ratePercent, groupItems]) => ({ + ratePercent, + items: groupItems, + totalCents: groupItems.reduce((sum, item) => sum + item.totalCents, 0), + })); +} diff --git a/src/lib/invoice-pdf-brand.ts b/src/lib/invoice-pdf-brand.ts new file mode 100644 index 0000000..ff5eda7 --- /dev/null +++ b/src/lib/invoice-pdf-brand.ts @@ -0,0 +1,108 @@ +import { readFile } from "node:fs/promises"; +import sharp from "sharp"; +import { getBrandAssets } from "@/lib/brand"; +import { resolveUploadPath } from "@/lib/uploads"; + +/** + * Markenfarben fuer das Rechnungs-PDF — dieselben Werte wie die Tokens in + * app/globals.css (helles Farbschema). pdfkit kennt keine CSS-Variablen, + * deshalb hier als feste Hexwerte; bei einer Aenderung der Markenfarben + * beide Stellen anpassen. + */ +export const INVOICE_COLORS = { + forest: "#24352B", + honey: "#B88A44", + honeyDark: "#8A6531", + honeyPale: "#E4CFA4", + cream: "#F4EFE5", + ink: "#242424", + sage: "#788774", + hairline: "#E6DDCB", +} as const; + +export interface InvoiceLogo { + png: Buffer; + width: number; + height: number; +} + +/** + * Laedt das im Admin hochgeladene Logo (Inhalte > Logo und Favicon) als PNG. + * + * pdfkit bettet nur PNG und JPEG ein, Uploads duerfen aber auch WebP/AVIF + * sein — sharp wandelt deshalb immer nach PNG (behaelt Transparenz). + * Der Pfad ist doppelt abgesichert: getBrandAssets laesst nur das feste + * Upload-Muster durch, resolveUploadPath verhindert das Verlassen von + * UPLOAD_DIR. + * + * Jeder Fehler (Datei fehlt, kaputtes Bild) fuehrt zu null und damit zum + * gezeichneten Waben-Zeichen: eine Rechnung darf nie an einem Logo scheitern. + */ +export async function loadInvoiceLogo(): Promise { + const { logo } = await getBrandAssets(); + if (!logo) return null; + + const filePath = resolveUploadPath([logo.replace(/^\/uploads\//, "")]); + if (!filePath) return null; + + try { + const { data, info } = await sharp(await readFile(filePath)).png().toBuffer({ resolveWithObject: true }); + return { png: data, width: info.width, height: info.height }; + } catch (error) { + console.error(`[invoice-pdf] Logo ${logo} konnte nicht geladen werden — es wird das Waben-Zeichen verwendet.`, error); + return null; + } +} + +/** + * Zeichnet das Waben-Zeichen (components/logomark.tsx) als Vektor: Hexagon in + * Honiggold, darin drei Tannen in Waldgruen. Gleiche Geometrie wie im + * Web-Logo (viewBox 100), skaliert auf `size` Punkt. + */ +export function drawLogomark(doc: PDFKit.PDFDocument, x: number, y: number, size: number): void { + doc.save(); + doc.translate(x, y).scale(size / 100); + doc + .polygon([50, 5], [88.97, 27.5], [88.97, 72.5], [50, 95], [11.03, 72.5], [11.03, 27.5]) + .lineWidth(2.5) + .strokeColor(INVOICE_COLORS.honey) + .stroke(); + + for (const tree of [ + { x: 20, y: 45, scale: 0.7 }, + { x: 38, y: 32, scale: 0.95 }, + { x: 56, y: 45, scale: 0.7 }, + ]) { + doc.save(); + doc.translate(tree.x, tree.y).scale(tree.scale); + doc.lineWidth(1.4).lineJoin("round").strokeColor(INVOICE_COLORS.forest); + doc.polygon([10, 2], [5, 13], [15, 13]).stroke(); + doc.polygon([10, 10], [2, 22], [18, 22]).stroke(); + doc.rect(8, 22, 4, 4).stroke(); + doc.restore(); + } + doc.restore(); +} + +/** + * Setzt das Logo in eine Box der Hoehe `height` und gibt die tatsaechlich + * belegte Breite zurueck — breite Wortmarken werden bis `maxWidth` breit, + * quadratische Zeichen bleiben quadratisch. + */ +export function drawInvoiceLogo( + doc: PDFKit.PDFDocument, + logo: InvoiceLogo | null, + x: number, + y: number, + height: number, + maxWidth: number +): number { + if (!logo) { + drawLogomark(doc, x, y, height); + return height; + } + const scale = Math.min(height / logo.height, maxWidth / logo.width); + const width = logo.width * scale; + doc.image(logo.png, x, y + (height - logo.height * scale) / 2, { width }); + return width; +} diff --git a/src/lib/invoice-pdf.test.ts b/src/lib/invoice-pdf.test.ts new file mode 100644 index 0000000..9dbe755 --- /dev/null +++ b/src/lib/invoice-pdf.test.ts @@ -0,0 +1,81 @@ +import { describe, test, expect, vi } from "vitest"; + +vi.mock("@/lib/company-info", () => ({ + getCompanyInfo: async () => ({ + name: "Wabenhain", + claim: "Honig aus den Westlichen Wäldern.", + email: "hallo@wabenhain.com", + phone: "", + domain: "wabenhain.com", + vatId: "DE000000000", + address: { street: "Musterweg 1", zip: "12345", city: "Musterstadt", country: "Deutschland" }, + }), +})); +vi.mock("@/lib/brand", () => ({ getBrandAssets: async () => ({ logo: null, logoInverted: null, favicon: null }) })); + +import { generateInvoicePdf } from "./invoice-pdf"; + +function item(index: number, vatRatePercent: number) { + return { + productName: `Honig ${index}`, + weightGrams: 500, + quantity: 1, + unitPriceCents: 1000, + totalCents: 1000, + vatRatePercent, + }; +} + +function input(items: ReturnType[]) { + const subtotalCents = items.reduce((sum, entry) => sum + entry.totalCents, 0); + return { + locale: "de" as const, + invoiceNumber: "RE-TEST-1", + issuedAt: new Date("2026-10-01T00:00:00Z"), + totalCents: subtotalCents + 490, + order: { + orderNumber: "WH-TEST-1", + firstName: "Erika", + lastName: "Musterfrau", + street: "Musterstraße 1", + zip: "80331", + city: "München", + currency: "EUR", + subtotalCents, + shippingCents: 490, + discountCents: 0, + discountCode: null, + items, + }, + }; +} + +/** Seitenobjekte im PDF zaehlen ("/Type /Page", nicht "/Type /Pages"). */ +function pageCount(pdf: Buffer): number { + return pdf.toString("latin1").match(/\/Type \/Page\b(?!s)/g)?.length ?? 0; +} + +describe("generateInvoicePdf", () => { + test("erzeugt fuer einen gemischten Korb ein einseitiges PDF", async () => { + // Arrange: Honig (7 %) und Kerze (19 %) — der Fall mit Gruppenbloecken. + const pdf = await generateInvoicePdf(input([item(1, 7), item(2, 19), item(3, 7)])); + + // Assert + expect(pdf.subarray(0, 5).toString()).toBe("%PDF-"); + expect(pageCount(pdf)).toBe(1); + }); + + test("bricht lange Rechnungen auf Folgeseiten um, ohne leere Seiten anzuhaengen", async () => { + // Arrange: 40 Positionen passen nicht auf eine Seite, aber auf zwei bis drei. + const items = Array.from({ length: 40 }, (_, index) => item(index, index % 2 === 0 ? 7 : 19)); + + // Act + const pdf = await generateInvoicePdf(input(items)); + + // Assert: die Fusszeile (unterhalb des Seitenrands gesetzt) darf keine + // zusaetzlichen, leeren Seiten erzeugen. + const pages = pageCount(pdf); + expect(pages).toBeGreaterThanOrEqual(2); + expect(pages).toBeLessThanOrEqual(3); + }); +}); diff --git a/src/lib/invoice-pdf.ts b/src/lib/invoice-pdf.ts index a8a702d..a755d36 100644 --- a/src/lib/invoice-pdf.ts +++ b/src/lib/invoice-pdf.ts @@ -2,9 +2,22 @@ import PDFDocument from "pdfkit"; import { formatPrice, formatDate } from "@/lib/format"; import { getCompanyInfo } from "@/lib/company-info"; import { getDictionary } from "@/i18n/get-dictionary"; -import { invoiceVatBreakdown } from "@/lib/vat"; +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 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; @@ -22,29 +35,61 @@ interface InvoicePdfInput { shippingCents: number; discountCents: number; discountCode?: { code: string } | null; - items: { - productName: string; - weightGrams: number; - quantity: number; - unitPriceCents: number; - totalCents: number; - /** Satz dieser Position; null bei Bestellungen vor der Steuerausweisung. */ - vatRatePercent: number | null; - }[]; + items: InvoiceItem[]; }; } +type Company = Awaited>; + +interface Ctx { + doc: PDFKit.PDFDocument; + t: Dictionary["invoicePage"]; + money: (cents: number) => string; +} + +// 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), - * damit beide Darstellungen derselben Rechnung konsistent bleiben. + * 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 = await getCompanyInfo(); - const doc = new PDFDocument({ margin: 50, size: "A4" }); + const [company, logo] = await Promise.all([getCompanyInfo(), loadInvoiceLogo()]); + const doc = new PDFDocument({ + margin: MARGIN, + size: "A4", + bufferPages: true, + info: { Title: `${t.documentTitle} ${input.invoiceNumber}`, Author: company.name }, + }); const chunks: Buffer[] = []; doc.on("data", (chunk: Buffer) => chunks.push(chunk)); const done = new Promise((resolve, reject) => { @@ -52,113 +97,314 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise 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); - } - // 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 wie bisher. + const ctx: Ctx = { doc, t, money: (cents) => formatPrice(cents, input.order.currency) }; const breakdown = invoiceVatBreakdown(input.order); - for (const line of breakdown) { - if (line.ratePercent <= 0) continue; - y += 16; - doc.fillColor("#666666").text(t.netLabel, 350, y); - doc.fillColor("#242424").text(formatPrice(line.netCents, input.order.currency), columns.total, y); - y += 16; - doc.fillColor("#666666").text(t.vatLabel(line.ratePercent), 350, y); - doc.fillColor("#242424").text(formatPrice(line.vatCents, 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); - - // 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. - if (breakdown.length > 0 && breakdown.every((line) => line.ratePercent === 0)) { - y += 22; - doc.fontSize(9).fillColor("#666666").text(t.smallBusinessNote, 350, y, { width: 195 }); - } - - doc - .fontSize(8) - .fillColor("#999999") - .text(t.legalNote, 50, 760, { width: 495 }); + 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, breakdown, y + 26); + drawFooters(ctx, company); doc.end(); return done; } + +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 }); + } + + doc + .font("Times-Roman") + .fontSize(26) + .fillColor(C.forest) + .text(t.documentTitle.toUpperCase(), 360, 48, { width: RIGHT - 360, align: "right", characterSpacing: 2, 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 rows: [string, string][] = [ + [t.invoiceNumberLabel, input.invoiceNumber], + [t.invoiceDateLabel, formatDate(input.issuedAt)], + [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 } = 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, `– ${money(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, 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 }); + } + 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; + } +} diff --git a/src/lib/site-config.ts b/src/lib/site-config.ts index ed083a5..6afb916 100644 --- a/src/lib/site-config.ts +++ b/src/lib/site-config.ts @@ -31,6 +31,12 @@ export const siteConfig = { * Zeile jetzt ueberall weggelassen (kontakt/page.tsx, impressum/page.tsx). */ phone: "", + /** + * Umsatzsteuer-Identifikationsnummer (§ 27a UStG). Steht auf jeder + * Rechnung (§ 14 Abs. 4 Nr. 2 UStG) und ist im Admin unter Inhalte > + * Firmendaten pflegbar; dieser Wert ist nur die Vorgabe. + */ + vatId: "DE366479158", address: { street: "Buhlweg 10", zip: "86450",