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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 00:49:59 +02:00
1 parent 1daef79026
commit 77bdd9958c
12 files changed
+965 -246

No files matched your search

+258 -108
View File
@@ -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>
);
}