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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user