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) <[email protected]>
This commit is contained in:
1 parent
1daef79026
commit
77bdd9958c
12 files changed
+965
-246
No files matched your search
@@ -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<ReturnType<typeof getCompanyInfo>>;
|
||||
type Money = (cents: number) => string;
|
||||
type Invoice = NonNullable<Awaited<ReturnType<typeof getInvoiceForCustomer>>>;
|
||||
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 (
|
||||
<div className="py-16 print:py-0">
|
||||
<Container className="max-w-2xl">
|
||||
<Container className="max-w-3xl">
|
||||
<div className="print:hidden flex items-center justify-between">
|
||||
<BackLink href="/konto/bestellungen" label={t.invoicePage.backToOrders} />
|
||||
<a
|
||||
href={`/rechnung/${invoice.invoiceNumber}/pdf`}
|
||||
className="mb-6 text-sm text-heading underline"
|
||||
>
|
||||
{t.invoicePage.downloadPdf}
|
||||
<BackLink href="/konto/bestellungen" label={t.backToOrders} />
|
||||
<a href={`/rechnung/${invoice.invoiceNumber}/pdf`} className="mb-6 text-sm text-heading underline">
|
||||
{t.downloadPdf}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="text-2xl tracking-[0.2em] font-serif text-heading">{company.name.toUpperCase()}</p>
|
||||
<p className="mt-2 text-sm text-ink/70">
|
||||
{company.address.street}
|
||||
<br />
|
||||
{company.address.zip} {company.address.city}
|
||||
</p>
|
||||
|
||||
<article className="border-t-4 border-b-4 border-honey bg-background px-6 py-10 sm:px-10 shadow-[0_24px_60px_-40px_rgba(36,53,43,0.45)] print:shadow-none">
|
||||
<InvoiceHeader t={t} company={company} logo={brand.logo} />
|
||||
<InvoiceParties t={t} invoice={invoice} company={company} />
|
||||
<InvoiceItemsTable t={t} groups={groupInvoiceItemsByVatRate(invoice.order.items)} money={money} />
|
||||
<div className="mt-10 grid gap-8 sm:grid-cols-2 sm:items-start">
|
||||
<InvoiceTaxSummary t={t} lines={vatLines} money={money} />
|
||||
<InvoiceTotals t={t} invoice={invoice} money={money} />
|
||||
</div>
|
||||
<div className="text-right text-sm text-ink/70">
|
||||
<p className="text-heading">{t.invoicePage.invoiceLabel(invoice.invoiceNumber)}</p>
|
||||
<p>{t.invoicePage.orderLabel(order.orderNumber)}</p>
|
||||
<p>{formatDate(invoice.issuedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 text-sm text-ink/75">
|
||||
<p>{order.firstName} {order.lastName}</p>
|
||||
<p>{order.street}</p>
|
||||
<p>{order.zip} {order.city}</p>
|
||||
</div>
|
||||
|
||||
<table className="mt-10 w-full text-sm">
|
||||
<thead className="text-left text-ink/65 border-b border-ink/10">
|
||||
<tr>
|
||||
<th className="py-2">{t.invoicePage.itemHeader}</th>
|
||||
<th className="py-2 text-right">{t.invoicePage.quantityHeader}</th>
|
||||
<th className="py-2 text-right">{t.invoicePage.priceHeader}</th>
|
||||
<th className="py-2 text-right">{t.invoicePage.totalHeader}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{order.items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="py-2">{item.productName} ({item.weightGrams} g)</td>
|
||||
<td className="py-2 text-right">{item.quantity}</td>
|
||||
<td className="py-2 text-right">{formatPrice(item.unitPriceCents)}</td>
|
||||
<td className="py-2 text-right">{formatPrice(item.totalCents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<div className="w-56 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink/70">{t.invoicePage.subtotalLabel}</span>
|
||||
<span>{formatPrice(order.subtotalCents, order.currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink/70">{t.invoicePage.shippingLabel}</span>
|
||||
<span>
|
||||
{order.shippingCents === 0 ? t.invoicePage.freeShipping : formatPrice(order.shippingCents, order.currency)}
|
||||
</span>
|
||||
</div>
|
||||
{order.discountCents > 0 ? (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink/70">
|
||||
{t.invoicePage.discountLabel}{order.discountCode ? ` (${order.discountCode.code})` : ""}
|
||||
</span>
|
||||
<span>−{formatPrice(order.discountCents, order.currency)}</span>
|
||||
</div>
|
||||
) : 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) => (
|
||||
<div key={line.ratePercent}>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink/70">{t.invoicePage.netLabel}</span>
|
||||
<span>{formatPrice(line.netCents, order.currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink/70">{t.invoicePage.vatLabel(line.ratePercent)}</span>
|
||||
<span>{formatPrice(line.vatCents, order.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between text-heading pt-2 border-t border-ink/10">
|
||||
<span>{t.invoicePage.totalLabel}</span>
|
||||
<span>{formatPrice(invoice.totalCents, order.currency)}</span>
|
||||
</div>
|
||||
{/* Pflichtangabe bei Kleinunternehmerregelung — steht anstelle der
|
||||
Steuerzeilen, nicht zusaetzlich. */}
|
||||
{isSmallBusiness ? (
|
||||
<p className="pt-2 text-xs text-ink/65">{t.invoicePage.smallBusinessNote}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-16 text-xs text-ink/65">{t.invoicePage.legalNote}</p>
|
||||
{vatLines.some((line) => line.ratePercent > 0) ? (
|
||||
<p className="mt-10 text-xs text-ink/60">{t.pricesIncludeVat}</p>
|
||||
) : null}
|
||||
<p className="mt-2 font-serif text-xl italic text-honey-dark">{t.thankYou}</p>
|
||||
<InvoiceFooter t={t} company={company} />
|
||||
</article>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-honey-dark">{children}</p>;
|
||||
}
|
||||
|
||||
function InvoiceHeader({ t, company, logo }: { t: InvoiceTexts; company: Company; logo: string | null }) {
|
||||
return (
|
||||
<header className="flex flex-wrap items-start justify-between gap-6 border-b border-honey/60 pb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Logomark src={logo} className="h-14 w-14" />
|
||||
<div>
|
||||
<p className="font-serif text-2xl tracking-[0.2em] text-heading">{company.name.toUpperCase()}</p>
|
||||
{company.claim ? <p className="mt-1 text-xs text-honey-dark">{company.claim}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-serif text-4xl tracking-[0.08em] text-heading">{t.documentTitle.toUpperCase()}</p>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="mt-8 grid gap-8 sm:grid-cols-2">
|
||||
<div className="text-sm">
|
||||
<p className="border-b border-ink/10 pb-1 text-[11px] text-ink/60">
|
||||
{company.name} · {company.address.street} · {company.address.zip} {company.address.city}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<SectionLabel>{t.billedToLabel}</SectionLabel>
|
||||
</div>
|
||||
<p className="mt-1 font-semibold text-ink">
|
||||
{order.firstName} {order.lastName}
|
||||
</p>
|
||||
<p className="text-ink/80">{order.street}</p>
|
||||
<p className="text-ink/80">
|
||||
{order.zip} {order.city}
|
||||
</p>
|
||||
</div>
|
||||
<dl className="space-y-2 border-l-[3px] border-honey bg-surface px-5 py-4 text-sm">
|
||||
{metaRows.map(([name, value]) => (
|
||||
<div key={name} className="flex justify-between gap-4">
|
||||
<dt className="text-ink/60">{name}</dt>
|
||||
<dd className="font-semibold text-heading tabular-nums">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InvoiceItemsTable({ t, groups, money }: { t: InvoiceTexts; groups: InvoiceItemGroup<InvoiceItem>[]; money: Money }) {
|
||||
// Blockueberschriften und Zwischensummen nur beim gemischten Korb.
|
||||
const isMixed = groups.length > 1;
|
||||
return (
|
||||
<div className="mt-10 overflow-x-auto">
|
||||
<table className="w-full min-w-[32rem] text-sm">
|
||||
<thead className="bg-forest text-[11px] uppercase tracking-[0.12em] text-cream">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 text-left font-semibold">{t.itemHeader}</th>
|
||||
<th className="px-3 py-2.5 text-center font-semibold">{t.vatRateHeader}</th>
|
||||
<th className="px-3 py-2.5 text-center font-semibold">{t.quantityHeader}</th>
|
||||
<th className="px-3 py-2.5 text-right font-semibold">{t.priceHeader}</th>
|
||||
<th className="px-3 py-2.5 text-right font-semibold">{t.totalHeader}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{groups.map((group) => (
|
||||
<tbody key={group.ratePercent ?? "none"} className="divide-y divide-ink/10">
|
||||
{isMixed && group.ratePercent !== null ? (
|
||||
<tr className="bg-honey-pale/70">
|
||||
<td colSpan={5} className="px-3 py-2">
|
||||
<SectionLabel>{t.groupHeading(group.ratePercent)}</SectionLabel>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{group.items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-3 py-3">
|
||||
<span className="text-ink">{item.productName}</span>
|
||||
<span className="block text-xs text-ink/55">{item.weightGrams} g</span>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center tabular-nums">
|
||||
{item.vatRatePercent === null ? "–" : `${item.vatRatePercent} %`}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center tabular-nums">{item.quantity}</td>
|
||||
<td className="px-3 py-3 text-right tabular-nums">{money(item.unitPriceCents)}</td>
|
||||
<td className="px-3 py-3 text-right font-semibold text-heading tabular-nums">{money(item.totalCents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{isMixed && group.ratePercent !== null ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-2.5 text-right text-xs text-ink/60">
|
||||
{t.groupSubtotalLabel(group.ratePercent)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right font-semibold text-heading tabular-nums">{money(group.totalCents)}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <div />;
|
||||
const taxed = lines.filter((line) => line.ratePercent > 0);
|
||||
const sum = (key: "netCents" | "vatCents" | "grossCents") => money(taxed.reduce((total, line) => total + line[key], 0));
|
||||
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<SectionLabel>{t.taxSummaryHeading}</SectionLabel>
|
||||
{taxed.length === 0 ? <p className="mt-2 text-ink/75">{t.smallBusinessNote}</p> : null}
|
||||
{taxed.length > 0 ? (
|
||||
<table className="mt-2 w-full text-xs tabular-nums">
|
||||
<thead className="bg-surface text-heading">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t.taxRateHeader}</th>
|
||||
<th className="px-2 py-1.5 text-right font-semibold">{t.netHeader}</th>
|
||||
<th className="px-2 py-1.5 text-right font-semibold">{t.vatAmountHeader}</th>
|
||||
<th className="px-2 py-1.5 text-right font-semibold">{t.grossHeader}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{taxed.map((line) => (
|
||||
<tr key={line.ratePercent}>
|
||||
<td className="px-2 py-1.5">{line.ratePercent} %</td>
|
||||
<td className="px-2 py-1.5 text-right">{money(line.netCents)}</td>
|
||||
<td className="px-2 py-1.5 text-right">{money(line.vatCents)}</td>
|
||||
<td className="px-2 py-1.5 text-right">{money(line.grossCents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{taxed.length > 1 ? (
|
||||
<tr className="font-semibold text-heading">
|
||||
<td className="px-2 py-1.5">{t.totalHeader}</td>
|
||||
<td className="px-2 py-1.5 text-right">{sum("netCents")}</td>
|
||||
<td className="px-2 py-1.5 text-right">{sum("vatCents")}</td>
|
||||
<td className="px-2 py-1.5 text-right">{sum("grossCents")}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InvoiceTotals({ t, invoice, money }: { t: InvoiceTexts; invoice: Invoice; money: Money }) {
|
||||
const { order } = invoice;
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div className="space-y-1.5 px-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink/65">{t.subtotalLabel}</span>
|
||||
<span className="tabular-nums">{money(order.subtotalCents)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink/65">{t.shippingLabel}</span>
|
||||
<span className="tabular-nums">{order.shippingCents === 0 ? t.freeShipping : money(order.shippingCents)}</span>
|
||||
</div>
|
||||
{order.discountCents > 0 ? (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink/65">
|
||||
{t.discountLabel}
|
||||
{order.discountCode ? ` (${order.discountCode.code})` : ""}
|
||||
</span>
|
||||
<span className="tabular-nums">– {money(order.discountCents)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between border-b-[3px] border-honey bg-forest px-3 py-3 text-cream">
|
||||
<span className="font-serif text-lg">{t.totalLabel}</span>
|
||||
<span className="text-lg font-semibold tabular-nums">{money(invoice.totalCents)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InvoiceFooter({ t, company }: { t: InvoiceTexts; company: Company }) {
|
||||
return (
|
||||
<footer className="mt-12 grid gap-4 border-t border-honey/60 pt-4 text-xs text-ink/60 sm:grid-cols-3">
|
||||
<p>
|
||||
{company.name}
|
||||
<br />
|
||||
{company.address.street}
|
||||
<br />
|
||||
{company.address.zip} {company.address.city}
|
||||
</p>
|
||||
<p>
|
||||
{company.domain}
|
||||
{company.phone ? (
|
||||
<>
|
||||
<br />
|
||||
{company.phone}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
{company.vatId ? (
|
||||
<p className="sm:text-right">
|
||||
{t.vatIdLabel} {company.vatId}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-center sm:col-span-3">{t.legalNote}</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface InvoiceItemGroup<T> {
|
||||
/** 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<T extends { totalCents: number; vatRatePercent: number | null }>(
|
||||
items: readonly T[]
|
||||
): InvoiceItemGroup<T>[] {
|
||||
const byRate = new Map<number | null, T[]>();
|
||||
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),
|
||||
}));
|
||||
}
|
||||
@@ -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<InvoiceLogo | null> {
|
||||
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;
|
||||
}
|
||||
@@ -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: "[email protected]",
|
||||
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<typeof item>[]) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
+364
-118
@@ -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<ReturnType<typeof getCompanyInfo>>;
|
||||
|
||||
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<Buffer> {
|
||||
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<Buffer>((resolve, reject) => {
|
||||
@@ -52,113 +97,314 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer
|
||||
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);
|
||||
}
|
||||
// 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<InvoiceItem>[], 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<InvoiceItem>, 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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user