Compare commits

...
2 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 f71bcb2b67 feat(inhalte): text placeholder {{USt-IdNr}} for the VAT ID
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m53s
Admin texts can now contain the placeholder {{USt-IdNr}}. Wherever
content is read (lib/content.ts), it is replaced with the VAT ID
maintained under Admin > Einstellungen > Firmendaten, so every text
follows a change automatically. The placeholder is matched case- and
space-insensitively; an empty VAT ID inserts nothing, and unknown
placeholders stay visible so typos show up. Images, videos, select
fields and the source field itself are never rewritten.

The default imprint text uses the placeholder instead of a hard-coded
number, and the settings page lists the available placeholders.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 00:59:04 +02:00
Nick SzaunigandClaude Opus 5.5 77bdd9958c 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]>
2026-10-01 00:49:59 +02:00
16 changed files with 1138 additions and 257 deletions

No files matched your search

+26
View File
@@ -3,10 +3,12 @@ import { BackLink } from "@/components/back-link";
import { AdminBadge } from "@/components/admin-list";
import type { ContentFieldDef } from "@/lib/content-registry";
import { readIntegrationStatus } from "@/lib/integration-status";
import { TEXT_PLACEHOLDERS } from "@/lib/text-placeholders";
import { SETTINGS_PATH, SETTINGS_TABS, parseSettingsTab, type SettingsTabId } from "@/lib/settings-tabs";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import type { Dictionary } from "@/i18n/dictionary-type";
import type { Locale } from "@/i18n/config";
import { GroupForm, fieldsForGroup, loadCurrentValues } from "./content-form";
import { SmtpTestPanel } from "./smtp-test-panel";
@@ -76,6 +78,7 @@ export default async function AdminEinstellungenPage(props: PageProps<"/admin/ei
) : (
<>
<p className="mb-8 max-w-2xl text-sm text-ink/65">{s.intro}</p>
<PlaceholderHint t={t} locale={locale} />
{current
? active.groups.map((group) => (
<GroupForm
@@ -95,6 +98,29 @@ export default async function AdminEinstellungenPage(props: PageProps<"/admin/ei
);
}
/**
* Uebersicht der Textbausteine (lib/text-placeholders.ts) ueber den
* Formularen: welche es gibt und was sie einsetzen. Der Baustein ist per
* select-all markierbar, damit er sich mit einem Klick kopieren laesst.
*/
function PlaceholderHint({ t, locale }: { t: Dictionary; locale: Locale }) {
const s = t.adminContentPage;
return (
<aside className="mb-10 max-w-2xl border-l-[3px] border-honey bg-surface px-5 py-4 text-sm">
<h2 className="text-heading">{s.placeholdersHeading}</h2>
<p className="mt-1 text-ink/65">{s.placeholdersIntro}</p>
<ul className="mt-3 space-y-1.5">
{TEXT_PLACEHOLDERS.map((placeholder) => (
<li key={placeholder.token} className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<code className="select-all rounded bg-background px-1.5 py-0.5 font-mono text-heading">{placeholder.token}</code>
<span className="text-ink/70">{locale === "de" ? placeholder.labelDe : placeholder.labelEn}</span>
</li>
))}
</ul>
</aside>
);
}
function SystemTab({ t }: { t: Dictionary }) {
const s = t.adminContentPage;
const integrations = readIntegrationStatus();
+256 -106
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>
</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>
<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>
{vatLines.some((line) => line.ratePercent > 0) ? (
<p className="mt-10 text-xs text-ink/60">{t.pricesIncludeVat}</p>
) : 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>
<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>
);
}
+23 -8
View File
@@ -235,7 +235,7 @@ export const de: Dictionary = {
{
heading: "Umsatzsteuer-ID",
paragraphs: [
"Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz: DE366479158",
"Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz: {{USt-IdNr}}",
],
},
{
@@ -1496,6 +1496,9 @@ export const de: Dictionary = {
sectionImagesIntro: "Alle Bilder und das Hero-Video, die du hochladen kannst, sortiert nach Seite.",
sectionText: "Texte",
sectionTextIntro: "Alle Textinhalte, sortiert nach Bereich.",
placeholdersHeading: "Textbausteine",
placeholdersIntro:
"Schreibe einen Baustein in einen beliebigen Text, und auf der Website erscheint an dieser Stelle der aktuelle Wert. Ändert sich der Wert, ziehen alle Texte automatisch mit.",
groupBrand: "Logo und Favicon",
groupCompany: "Firmendaten",
groupShipping: "Versand",
@@ -1587,21 +1590,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: {
+23 -8
View File
@@ -235,7 +235,7 @@ export const en: Dictionary = {
{
heading: "VAT ID",
paragraphs: [
"VAT identification number pursuant to § 27a of the German VAT Act (UStG): DE366479158",
"VAT identification number pursuant to § 27a of the German VAT Act (UStG): {{USt-IdNr}}",
],
},
{
@@ -1470,6 +1470,9 @@ export const en: Dictionary = {
sectionImagesIntro: "Every image and the hero video you can upload, grouped by page.",
sectionText: "Text",
sectionTextIntro: "Every text field, grouped by section.",
placeholdersHeading: "Text placeholders",
placeholdersIntro:
"Write a placeholder into any text and the website shows the current value in its place. When the value changes, every text follows automatically.",
groupBrand: "Logo and favicon",
groupCompany: "Company details",
groupShipping: "Shipping",
@@ -1561,21 +1564,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: {
+19 -5
View File
@@ -1032,6 +1032,8 @@ export interface Dictionary {
sectionImagesIntro: string;
sectionText: string;
sectionTextIntro: string;
placeholdersHeading: string;
placeholdersIntro: string;
groupBrand: string;
groupCompany: string;
groupShipping: string;
@@ -1118,21 +1120,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: {
+3 -1
View File
@@ -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"],
+1
View File
@@ -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; in Texten per Baustein {{USt-IdNr}} einfügbar)", labelEn: "VAT identification number (shown on every invoice; insert into texts with the placeholder {{USt-IdNr}})", 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 },
+33 -11
View File
@@ -1,27 +1,49 @@
import { prisma } from "@/lib/prisma";
import { CONTENT_REGISTRY, getContentFieldDef, type ContentFieldDef } from "@/lib/content-registry";
import { PLACEHOLDER_SOURCE_KEYS, containsPlaceholder, resolvePlaceholders } from "@/lib/text-placeholders";
import type { Locale } from "@/i18n/config";
/**
* Liefert den aktuell wirksamen Text fuer einen Registry-Schluessel — den
* Admin-Override aus SiteContent, falls gepflegt, sonst den urspruenglichen
* Dictionary-/site-config-Text. Nie leer/undefined, solange der Schluessel in
* Dictionary-/site-config-Text, mit eingesetzten Textbausteinen
* (lib/text-placeholders.ts). Nie leer/undefined, solange der Schluessel in
* der Registry existiert.
*/
export async function getContentValue(key: string, locale: Locale): Promise<string> {
const def = getContentFieldDef(key);
if (!def) return "";
const row = await prisma.siteContent.findUnique({ where: { key } });
if (row) {
const value = def.localized ? (locale === "de" ? row.valueDe : (row.valueEn ?? row.valueDe)) : row.valueDe;
if (value.trim() !== "") return value;
}
return def.getDefault(locale);
return (await getContentValues([key], locale))[key] ?? "";
}
/** Batch-Variante fuer Seiten, die mehrere Schluessel auf einmal brauchen (ein DB-Roundtrip statt vieler). */
/**
* Batch-Variante fuer Seiten, die mehrere Schluessel auf einmal brauchen (ein
* DB-Roundtrip statt vieler). Enthaelt einer der Texte einen Textbaustein wie
* {{USt-IdNr}}, folgt genau eine weitere Abfrage fuer die Bausteinquellen —
* Texte ohne Baustein kosten nichts extra.
*/
export async function getContentValues(keys: string[], locale: Locale): Promise<Record<string, string>> {
const raw = await readRawValues(keys, locale);
const needsPlaceholders = keys.some((key) => isPlaceholderTarget(key) && containsPlaceholder(raw[key]));
if (!needsPlaceholders) return raw;
// Bausteinquellen sind nicht lokalisierte Firmendaten.
const sources = await readRawValues([...PLACEHOLDER_SOURCE_KEYS], "de");
return Object.fromEntries(
keys.map((key) => [key, isPlaceholderTarget(key) ? resolvePlaceholders(raw[key], sources) : raw[key]])
);
}
/**
* Bausteine gelten nur in Texten. Bild-, Video- und Auswahlfelder tragen
* Pfade bzw. feste Werte; die Bausteinquellen selbst werden nie aufgeloest,
* damit sich kein Baustein im Kreis auf sich selbst beziehen kann.
*/
function isPlaceholderTarget(key: string): boolean {
const def = getContentFieldDef(key);
if (!def || PLACEHOLDER_SOURCE_KEYS.includes(key)) return false;
return def.type !== "image" && def.type !== "video" && def.type !== "select";
}
async function readRawValues(keys: string[], locale: Locale): Promise<Record<string, string>> {
const rows = await prisma.siteContent.findMany({ where: { key: { in: keys } } });
const rowsByKey = new Map(rows.map((r) => [r.key, r]));
+53
View File
@@ -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([]);
});
});
+36
View File
@@ -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),
}));
}
+108
View File
@@ -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;
}
+81
View File
@@ -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
View File
@@ -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;
}
}
+6
View File
@@ -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",
+55
View File
@@ -0,0 +1,55 @@
import { describe, test, expect } from "vitest";
import { TEXT_PLACEHOLDERS, containsPlaceholder, resolvePlaceholders } from "./text-placeholders";
const sources = { "company.vatId": "DE123456789" };
describe("resolvePlaceholders", () => {
test("setzt die USt-IdNr. fuer {{USt-IdNr}} ein", () => {
// Arrange
const text = "Umsatzsteuer-Identifikationsnummer gemäß § 27a UStG: {{USt-IdNr}}";
// Act
const result = resolvePlaceholders(text, sources);
// Assert
expect(result).toBe("Umsatzsteuer-Identifikationsnummer gemäß § 27a UStG: DE123456789");
});
test("erkennt den Baustein unabhaengig von Gross-/Kleinschreibung und Leerzeichen", () => {
expect(resolvePlaceholders("{{ ust-idnr }} / {{USTIDNR}}", sources)).toBe("DE123456789 / DE123456789");
});
test("ersetzt jedes Vorkommen", () => {
expect(resolvePlaceholders("{{USt-IdNr}} und {{USt-IdNr}}", sources)).toBe("DE123456789 und DE123456789");
});
test("laesst Texte ohne Baustein unveraendert", () => {
expect(resolvePlaceholders("Honig aus den Wäldern", sources)).toBe("Honig aus den Wäldern");
});
test("setzt bei leerer Quelle einen leeren Text ein statt den Baustein stehen zu lassen", () => {
expect(resolvePlaceholders("USt-IdNr.: {{USt-IdNr}}", { "company.vatId": "" })).toBe("USt-IdNr.: ");
});
test("laesst unbekannte Bausteine unveraendert", () => {
expect(resolvePlaceholders("{{Unbekannt}}", sources)).toBe("{{Unbekannt}}");
});
});
describe("containsPlaceholder", () => {
test("meldet einen bekannten Baustein", () => {
expect(containsPlaceholder("Nr.: {{USt-IdNr}}")).toBe(true);
});
test("meldet keinen Baustein bei normalem Text oder unbekanntem Baustein", () => {
expect(containsPlaceholder("Nr.: DE123")).toBe(false);
expect(containsPlaceholder("{{Unbekannt}}")).toBe(false);
});
});
describe("TEXT_PLACEHOLDERS", () => {
test("verweist nur auf Quellen, die selbst keinen Baustein aufloesen muessen", () => {
// Eine Quelle, die wiederum Bausteine enthaelt, koennte sich im Kreis aufloesen.
expect(TEXT_PLACEHOLDERS.map((placeholder) => placeholder.sourceKey)).toEqual(["company.vatId"]);
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Textbausteine fuer Admin-Texte (Admin > Einstellungen): wer in einem Text
* {{USt-IdNr}} schreibt, bekommt beim Ausgeben die im Admin gepflegte
* Umsatzsteuer-Identifikationsnummer eingesetzt. So steht die Nummer an genau
* einer Stelle, und Impressum, AGB oder jeder andere Text ziehen bei einer
* Aenderung automatisch mit.
*
* Aufgeloest wird zentral in lib/content.ts, also ueberall, wo Admin-Texte
* gelesen werden. Die Quellen sind bewusst Felder, die selbst NICHT
* aufgeloest werden — ein Baustein, der auf einen Text mit Baustein zeigt,
* koennte sich sonst im Kreis drehen.
*/
export interface TextPlaceholder {
/** So wird der Baustein im Admin angezeigt und in Texte geschrieben. */
token: string;
/** Registry-Schluessel, dessen Wert eingesetzt wird. */
sourceKey: string;
/** Erkennt den Baustein tolerant: Gross-/Kleinschreibung, Leerzeichen, Bindestrich. */
pattern: RegExp;
labelDe: string;
labelEn: string;
}
export const TEXT_PLACEHOLDERS: readonly TextPlaceholder[] = [
{
token: "{{USt-IdNr}}",
sourceKey: "company.vatId",
pattern: /\{\{\s*ust-?idnr\s*\}\}/gi,
labelDe: "Umsatzsteuer-Identifikationsnummer (Firmendaten)",
labelEn: "VAT identification number (company details)",
},
];
export const PLACEHOLDER_SOURCE_KEYS: readonly string[] = TEXT_PLACEHOLDERS.map((placeholder) => placeholder.sourceKey);
export function containsPlaceholder(text: string): boolean {
return TEXT_PLACEHOLDERS.some((placeholder) => new RegExp(placeholder.pattern.source, "i").test(text));
}
/**
* Setzt alle bekannten Bausteine ein. Ist die Quelle leer, wird ein leerer
* Text eingesetzt — ein stehengebliebenes "{{USt-IdNr}}" auf der
* oeffentlichen Seite waere schlimmer. Unbekannte Bausteine bleiben
* unveraendert stehen, damit ein Tippfehler im Admin sichtbar bleibt.
*/
export function resolvePlaceholders(text: string, sources: Readonly<Record<string, string>>): string {
return TEXT_PLACEHOLDERS.reduce(
(result, placeholder) => result.replace(placeholder.pattern, () => (sources[placeholder.sourceKey] ?? "").trim()),
text
);
}