Move invoice/tax-breakdown PDF generation into @einfach-produktiv/invoicing

Phase 0 of the e-invoicing migration plan (see the E-Rechnung planning
session) — moves invoicePdf.tsx, correctionInvoicePdf.tsx, and
taxBreakdown.ts into a new shared package, consumed as a git dependency
by both this repo and the payload backend, instead of hand-duplicating
the correction-invoice logic between them (see that package's own README
for the three real drifts the duplication had already caused).

Consumed as raw TS/TSX source via next.config.ts's transpilePackages, not
a pre-built package. Needs `git` in the Docker deps stage and a
project .npmrc (allow-git=root) to let npm ci fetch a git-URL dependency
at all — npm 12+ disables that by default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-23 09:18:20 +00:00
parent 21e150f177
commit 66ac184a6f
17 changed files with 276 additions and 1048 deletions
@@ -6,7 +6,7 @@ import Image from "next/image";
import type { CartItem } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
import { computeTaxBreakdown } from "../../lib/taxBreakdown";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import { formatPrice, formatDate } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { CheckoutSteps } from "../../components/CheckoutSteps";
+1 -1
View File
@@ -8,7 +8,7 @@ import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { useDiscount, applyDiscount, clearDiscount } from "../../lib/discount";
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
import { computeTaxBreakdown } from "../../lib/taxBreakdown";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import { formatPrice, discountPercent } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
+1 -1
View File
@@ -8,7 +8,7 @@ import { useCart, clearCart, mergeServerCartIntoLocal } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { useDiscount, clearDiscount } from "../../lib/discount";
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
import { computeTaxBreakdown } from "../../lib/taxBreakdown";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import { formatPrice } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
@@ -2,7 +2,7 @@
import dynamic from "next/dynamic";
import { useLivePreview } from "@payloadcms/live-preview-react";
import { InvoiceDocument, SAMPLE_INVOICE_ORDER } from "../../lib/invoicePdf";
import { InvoiceDocument, SAMPLE_INVOICE_ORDER } from "@einfach-produktiv/invoicing";
import type { CompanySettings } from "../../lib/payload";
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
+1 -1
View File
@@ -1,5 +1,5 @@
import { formatPrice } from "../lib/format";
import type { TaxBreakdownGroup } from "../lib/taxBreakdown";
import type { TaxBreakdownGroup } from "@einfach-produktiv/invoicing";
// The actual amount of VAT included in a total — not just a disclosure
// that VAT is included (see cartTotals.ts's effectiveTaxRate() for the
@@ -8,7 +8,7 @@ import { VatBreakdown } from "../../../components/VatBreakdown";
import { formatPrice, formatDate } from "../../../lib/format";
import { getSessionCustomer, getCustomerOrderDetail, customerOrderAction } from "../../../lib/customerAuth";
import { getProductImagesByIds } from "../../../lib/payload";
import { computeTaxBreakdown } from "../../../lib/taxBreakdown";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import { buildTrackingUrl, CARRIER_LABELS } from "../../../lib/tracking";
import { OrderActionButton } from "./components/OrderActionButton";
import { OrderStatusBadge } from "../../components/OrderStatusBadge";
-97
View File
@@ -1,97 +0,0 @@
import { describe, it, expect } from "vitest";
import { __testables, type InvoiceItem, type InvoiceOrder } from "../invoicePdf";
const { isPaidImmediately, groupByTaxRate } = __testables;
const item = (overrides: Partial<InvoiceItem> = {}): InvoiceItem => ({
productName: "ToDo-Karten",
quantity: 2,
unitPrice: 12.9,
taxRatePercent: 19,
bundleContents: null,
...overrides,
});
const order = (overrides: Partial<InvoiceOrder> = {}): InvoiceOrder => ({
orderNumber: "#EP-0001",
invoiceNumber: "RE-0001",
invoiceIssuedAt: new Date().toISOString(),
customerFirstName: "Max",
customerLastName: "Mustermann",
deliveryMethod: "address",
street: "Musterweg 1",
zip: "10115",
city: "Berlin",
country: "Deutschland",
paymentMethodTitle: "Kreditkarte",
items: [item()],
subtotal: 25.8,
shippingCost: 2.9,
discountAmount: 0,
discountCode: null,
total: 28.7,
...overrides,
});
describe("isPaidImmediately", () => {
it("is true for Kreditkarte", () => {
expect(isPaidImmediately("Kreditkarte")).toBe(true);
});
it("is true for PayPal", () => {
expect(isPaidImmediately("PayPal")).toBe(true);
});
it("is false only for Überweisung", () => {
expect(isPaidImmediately("Überweisung")).toBe(false);
});
it("defaults to true for any future/unknown payment method (only Überweisung is the named exception)", () => {
expect(isPaidImmediately("Sofortüberweisung")).toBe(true);
expect(isPaidImmediately("Klarna")).toBe(true);
});
});
describe("groupByTaxRate (original invoice)", () => {
it("reconciles net+tax to gross, and gross to the order total for a single rate", () => {
const o = order({ items: [item({ quantity: 2, unitPrice: 12.9 })], subtotal: 25.8, shippingCost: 2.9, discountAmount: 0, total: 28.7 });
const groups = groupByTaxRate(o, 19);
expect(groups).toHaveLength(1);
const [g] = groups;
expect(g.net + g.tax).toBeCloseTo(g.gross, 6);
expect(g.gross).toBeCloseTo(28.7, 2);
});
it("distributes a discount proportionally, still reconciling to the discounted total", () => {
const o = order({
items: [item({ quantity: 1, unitPrice: 50 })],
subtotal: 50,
shippingCost: 0,
discountAmount: 10,
total: 40,
});
const groups = groupByTaxRate(o, 19);
const total = groups.reduce((sum, g) => sum + g.gross, 0);
expect(total).toBeCloseTo(40, 2);
});
it("splits multiple tax rates into separate groups that each reconcile", () => {
const o = order({
items: [item({ quantity: 1, unitPrice: 20, taxRatePercent: 19 }), item({ quantity: 1, unitPrice: 10, taxRatePercent: 7 })],
subtotal: 30,
shippingCost: 0,
discountAmount: 0,
total: 30,
});
const groups = groupByTaxRate(o, 19);
expect(groups).toHaveLength(2);
for (const g of groups) expect(g.net + g.tax).toBeCloseTo(g.gross, 6);
expect(groups.reduce((sum, g) => sum + g.gross, 0)).toBeCloseTo(30, 2);
});
it("falls back to the tenant default rate when an item has no taxRatePercent", () => {
const o = order({ items: [item({ taxRatePercent: undefined as unknown as number })] });
const groups = groupByTaxRate(o, 7);
expect(groups[0].rate).toBe(7);
});
});
-311
View File
@@ -1,311 +0,0 @@
import React from "react";
import { Document, Page, View, Text, Image, StyleSheet, renderToBuffer } from "@react-pdf/renderer";
import { formatDate } from "./format";
import { computeTaxBreakdown } from "./taxBreakdown";
// Frontend port of the Payload backend's src/lib/correctionInvoicePdf.tsx
// — the *real* Stornorechnung/Gutschrift is generated and emailed from
// Payload's own Orders.ts afterChange hook (that's where the status
// transition and the correction invoice NUMBER are actually assigned).
// This copy exists only so a customer can re-download the same document
// later from /konto/bestellungen/[orderNumber] without it having been
// stored as a file anywhere — same "deterministic regeneration, not file
// storage" approach already used for the original invoice (see
// invoicePdf.tsx): correctionInvoiceNumber/correctionInvoiceIssuedAt are
// immutable once set, so re-rendering from the order's own stored data
// always reproduces the identical document.
const BRAND = "#f6a701";
const TEXT_MUTED = "#6b6b69";
const BORDER = "#e5e0d8";
const BG_MUTED = "#f8f5f1";
const styles = StyleSheet.create({
page: { padding: 0, fontSize: 10, fontFamily: "Helvetica", color: "#1a1a18" },
// A rule, not a filled band — a bold brand-colored line rather than a
// plain 1pt gray divider.
headerBand: {
padding: 32,
paddingBottom: 24,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderBottomWidth: 3,
borderBottomColor: BRAND,
},
wordmark: { fontFamily: "Helvetica-Bold", fontSize: 14 },
kindLabel: { fontFamily: "Helvetica-Bold", fontSize: 22, color: BRAND, letterSpacing: 1 },
body: { padding: 32, paddingBottom: 90 },
refLine: { fontSize: 10, color: TEXT_MUTED, marginBottom: 24 },
addressRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 24 },
addressBlock: { width: "45%" },
addressLabel: { fontSize: 8, color: TEXT_MUTED, marginBottom: 4, textTransform: "uppercase" },
addressLine: { fontSize: 10, lineHeight: 1.5 },
metaRow: { flexDirection: "row", gap: 10, marginBottom: 24 },
metaBox: { borderWidth: 1, borderColor: BORDER, borderRadius: 6, paddingVertical: 8, paddingHorizontal: 12 },
metaLabel: { fontSize: 7, color: TEXT_MUTED, textTransform: "uppercase", marginBottom: 2 },
metaValue: { fontSize: 10, fontFamily: "Helvetica-Bold" },
table: { borderRadius: 6, overflow: "hidden", borderWidth: 1, borderColor: BORDER, marginBottom: 16 },
tableHeader: { flexDirection: "row", backgroundColor: BG_MUTED, paddingVertical: 8, paddingHorizontal: 10 },
tableRow: { flexDirection: "row", paddingVertical: 8, paddingHorizontal: 10, borderTopWidth: 1, borderTopColor: BORDER },
tableRowAlt: { backgroundColor: BG_MUTED },
colImage: { width: 28 },
itemImage: { width: 28, height: 28, borderRadius: 3 },
colName: { flex: 3 },
colQty: { flex: 1, textAlign: "right" },
colPrice: { flex: 1, textAlign: "right" },
colTotal: { flex: 1, textAlign: "right" },
headerCell: { fontSize: 8, color: TEXT_MUTED, textTransform: "uppercase" },
bundleLine: { fontSize: 8, color: TEXT_MUTED, marginTop: 2 },
summary: { alignItems: "flex-end", marginBottom: 24 },
summaryBox: { width: 240, backgroundColor: BG_MUTED, borderRadius: 6, padding: 14 },
summaryRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2 },
summaryLabel: { fontSize: 10, color: TEXT_MUTED },
summaryValue: { fontSize: 10 },
grandTotalRow: { flexDirection: "row", justifyContent: "space-between", paddingTop: 8, marginTop: 6, borderTopWidth: 1, borderTopColor: BORDER },
grandTotalLabel: { fontSize: 12, fontFamily: "Helvetica-Bold" },
grandTotalValue: { fontSize: 12, fontFamily: "Helvetica-Bold" },
footer: {
position: "absolute",
bottom: 32,
left: 32,
right: 32,
borderTopWidth: 1,
borderTopColor: BORDER,
paddingTop: 12,
fontSize: 8,
color: TEXT_MUTED,
},
});
function formatPrice(amount: number): string {
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(amount);
}
export type CorrectionInvoiceKind = "storno" | "gutschrift";
export type CorrectionInvoiceItem = {
productName: string;
quantity: number;
unitPrice: number;
taxRatePercent: number;
bundleContents?: string | null;
variantName?: string | null;
returnQuantity?: number;
imageUrl?: string | null;
};
export type CorrectionInvoiceOrder = {
orderNumber: string;
invoiceNumber: string;
invoiceIssuedAt: string;
correctionInvoiceNumber: string;
correctionInvoiceIssuedAt: string;
customerFirstName: string;
customerLastName: string;
deliveryMethod: "address" | "packstation";
street?: string | null;
packstationNumber?: string | null;
postNumber?: string | null;
zip: string;
city: string;
country: string;
items: CorrectionInvoiceItem[];
subtotal: number;
shippingCost: number;
discountAmount: number;
total: number;
};
export type InvoiceSeller = {
sellerName: string;
sellerStreet: string;
sellerZip: string;
sellerCity: string;
sellerCountry: string;
sellerEmail: string;
vatId: string;
taxRatePercent: number;
bankDetails?: string | null;
// Pflichtangaben in Geschäftsbriefen for registered legal forms (§37a
// HGB / §35a GmbHG) — optional because a sole proprietorship (the
// default legalForm in company-settings) has neither. Mirrors
// invoicePdf.tsx's own InvoiceSeller — see that file's comment.
registerCourt?: string | null;
registerNumber?: string | null;
managingDirector?: string | null;
};
// Stornorechnung: every item at its full ordered quantity (nothing
// shipped, undo everything). Gutschrift: only items with a nonzero
// returnQuantity, at that returned quantity — see this file's port source
// (backend src/lib/correctionInvoicePdf.tsx) for the full policy
// reasoning (no shipping refund, no discount reproration on a Gutschrift).
function resolveLineItems(kind: CorrectionInvoiceKind, items: CorrectionInvoiceItem[]): { item: CorrectionInvoiceItem; effectiveQuantity: number }[] {
if (kind === "storno") return items.map((item) => ({ item, effectiveQuantity: item.quantity }));
return items
.filter((item) => (item.returnQuantity ?? 0) > 0)
.map((item) => ({ item, effectiveQuantity: item.returnQuantity as number }));
}
function groupByTaxRate(
kind: CorrectionInvoiceKind,
lines: { item: CorrectionInvoiceItem; effectiveQuantity: number }[],
order: CorrectionInvoiceOrder,
defaultRate: number,
): { rate: number; net: number; tax: number; gross: number }[] {
// Gutschrift excludes shipping/discount entirely — passing 0 for both
// collapses computeTaxBreakdown's scale factor to 1, same as the old
// kind==='storno' ? ... : 1 branch did explicitly.
return computeTaxBreakdown(
lines.map(({ item, effectiveQuantity }) => ({
quantity: effectiveQuantity,
unitPrice: item.unitPrice,
taxRatePercent: item.taxRatePercent ?? defaultRate,
})),
order.subtotal,
kind === "storno" ? order.discountAmount : 0,
kind === "storno" ? order.shippingCost : 0,
);
}
function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionInvoiceKind; order: CorrectionInvoiceOrder; seller: InvoiceSeller }) {
const kindLabel = kind === "storno" ? "Stornorechnung" : "Gutschrift";
const lines = resolveLineItems(kind, order.items);
const rateGroups = groupByTaxRate(kind, lines, order, seller.taxRatePercent);
const grandTotal = rateGroups.reduce((sum, g) => sum + g.gross, 0);
const refNote =
kind === "storno"
? "vollständige Stornierung des ursprünglichen Rechnungsbetrags (inkl. Versand)."
: "Gutschrift für die zurückgesendeten Artikel — ohne Versandkosten, der ursprüngliche Rabatt bleibt unverändert bei den behaltenen Artikeln.";
const deliveryLine =
order.deliveryMethod === "address" ? order.street : `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`;
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.headerBand}>
<Text style={styles.wordmark}>einfach produktiv.</Text>
<Text style={styles.kindLabel}>{kindLabel.toUpperCase()}</Text>
</View>
<View style={styles.body}>
<Text style={styles.refLine}>
{kindLabel} zu Rechnung Nr. {order.invoiceNumber} vom {formatDate(order.invoiceIssuedAt)} (Bestellung {order.orderNumber}) {refNote}
</Text>
<View style={styles.addressRow}>
<View style={styles.addressBlock}>
<Text style={styles.addressLabel}>Von</Text>
<Text style={styles.addressLine}>{seller.sellerName}</Text>
<Text style={styles.addressLine}>{seller.sellerStreet}</Text>
<Text style={styles.addressLine}>
{seller.sellerZip} {seller.sellerCity}
</Text>
<Text style={styles.addressLine}>{seller.sellerCountry}</Text>
</View>
<View style={styles.addressBlock}>
<Text style={styles.addressLabel}>An</Text>
<Text style={styles.addressLine}>
{order.customerFirstName} {order.customerLastName}
</Text>
<Text style={styles.addressLine}>{deliveryLine}</Text>
<Text style={styles.addressLine}>
{order.zip} {order.city}
</Text>
<Text style={styles.addressLine}>{order.country}</Text>
</View>
</View>
<View style={styles.metaRow}>
<View style={styles.metaBox}>
<Text style={styles.metaLabel}>{kindLabel === "Gutschrift" ? "Gutschrift-Nr." : "Storno-Nr."}</Text>
<Text style={styles.metaValue}>{order.correctionInvoiceNumber}</Text>
</View>
<View style={styles.metaBox}>
<Text style={styles.metaLabel}>Datum</Text>
<Text style={styles.metaValue}>{formatDate(order.correctionInvoiceIssuedAt)}</Text>
</View>
</View>
<View style={styles.table}>
<View style={styles.tableHeader}>
<View style={styles.colImage} />
<Text style={[styles.colName, styles.headerCell]}>Artikel</Text>
<Text style={[styles.colQty, styles.headerCell]}>Menge</Text>
<Text style={[styles.colPrice, styles.headerCell]}>Einzelpreis</Text>
<Text style={[styles.colTotal, styles.headerCell]}>Betrag</Text>
</View>
{lines.map(({ item, effectiveQuantity }, i) => (
<View style={[styles.tableRow, i % 2 === 1 ? styles.tableRowAlt : {}]} key={i}>
<View style={styles.colImage}>
{item.imageUrl && <Image src={item.imageUrl} style={styles.itemImage} />}
</View>
<View style={styles.colName}>
<Text>
{item.productName}
{item.variantName ? ` (${item.variantName})` : ""}
</Text>
{item.bundleContents ? <Text style={styles.bundleLine}>{item.bundleContents}</Text> : null}
</View>
<Text style={styles.colQty}>{effectiveQuantity}</Text>
<Text style={styles.colPrice}>{formatPrice(item.unitPrice)}</Text>
<Text style={styles.colTotal}>-{formatPrice(effectiveQuantity * item.unitPrice)}</Text>
</View>
))}
</View>
<View style={styles.summary}>
<View style={styles.summaryBox}>
{/* Storno reverses the full original invoice, shipping
included (see this file's top-of-file comment on why the
two kinds differ) — shown as its own line instead of
silently folded into the tax-rate groups below, same as
the original invoice's own Versand row. Gutschrift never
reverses shipping, so this never renders for it. */}
{kind === "storno" && order.shippingCost > 0 && (
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Versand</Text>
<Text style={styles.summaryValue}>-{formatPrice(order.shippingCost)}</Text>
</View>
)}
{rateGroups.map((g) => (
<React.Fragment key={g.rate}>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Netto</Text>
<Text style={styles.summaryValue}>-{formatPrice(g.net)}</Text>
</View>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>zzgl. {g.rate}% MwSt.</Text>
<Text style={styles.summaryValue}>-{formatPrice(g.tax)}</Text>
</View>
</React.Fragment>
))}
<View style={styles.grandTotalRow}>
<Text style={styles.grandTotalLabel}>Gesamt</Text>
<Text style={styles.grandTotalValue}>-{formatPrice(grandTotal)}</Text>
</View>
</View>
</View>
</View>
<View style={styles.footer} fixed>
<Text>
{seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "}
{seller.vatId}
{seller.registerCourt && seller.registerNumber ? ` · ${seller.registerCourt} · ${seller.registerNumber}` : ""}
{seller.managingDirector ? ` · Geschäftsführung: ${seller.managingDirector}` : ""}
</Text>
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>Bankverbindung (für Überweisung): {seller.bankDetails}</Text> : null}
</View>
</Page>
</Document>
);
}
export async function renderCorrectionInvoicePdf(kind: CorrectionInvoiceKind, order: CorrectionInvoiceOrder, seller: InvoiceSeller): Promise<Buffer> {
return renderToBuffer(<CorrectionInvoiceDocument kind={kind} order={order} seller={seller} />);
}
// Exported for unit testing (see app/lib/__tests__/correctionInvoicePdf.test.ts)
// — the actual money math, independent of the PDF rendering.
export const __testables = { resolveLineItems, groupByTaxRate };
+1 -1
View File
@@ -1,5 +1,5 @@
import { formatPrice, formatDate } from "./format";
import { computeTaxBreakdown } from "./taxBreakdown";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import type { CompanySettings } from "./payload";
// Pure string-building functions, no server-only or client-only imports —
+7 -2
View File
@@ -1,6 +1,11 @@
import { getCompanySettings, type CompanySettings } from "./payload";
import { renderInvoicePdf, type InvoiceOrder } from "./invoicePdf";
import { renderCorrectionInvoicePdf, type CorrectionInvoiceKind, type CorrectionInvoiceOrder } from "./correctionInvoicePdf";
import {
renderInvoicePdf,
renderCorrectionInvoicePdf,
type InvoiceOrder,
type CorrectionInvoiceKind,
type CorrectionInvoiceOrder,
} from "@einfach-produktiv/invoicing";
export type { CompanySettings };
-375
View File
@@ -1,375 +0,0 @@
import React from "react";
import { Document, Page, View, Text, Image, StyleSheet, renderToBuffer } from "@react-pdf/renderer";
import { formatDate } from "./format";
import { computeTaxBreakdown } from "./taxBreakdown";
// Generated synchronously in the checkout request (see app/lib/orderEmail.ts)
// and attached to the order-confirmation email, plus available on-demand via
// app/api/account/orders/[orderNumber]/invoice/route.ts — same render
// function both times, so a re-download always matches what was emailed.
//
// Built-in Helvetica, not a registered web font — this renders inside the
// checkout request's own fire-and-forget email step; a font-fetch failure
// there is one more way to lose the invoice attachment for no real design
// benefit. Same choice as the Payload-side correction-invoice PDF
// (src/lib/correctionInvoicePdf.tsx in the backend repo, kept visually in
// sync with this file by eye — not shared code, two separate deployments).
const BRAND = "#f6a701";
const TEXT_MUTED = "#6b6b69";
const BORDER = "#e5e0d8";
const BG_MUTED = "#f8f5f1";
const SUCCESS = "#2f8f4e";
const SUCCESS_TINT = "#e7f5eb";
const styles = StyleSheet.create({
page: { padding: 0, fontSize: 10, fontFamily: "Helvetica", color: "#1a1a18" },
// A rule, not a filled band — a bold brand-colored line with a thin
// muted second line underneath, rather than a plain 1pt gray divider.
headerBand: {
padding: 32,
paddingBottom: 24,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderBottomWidth: 3,
borderBottomColor: BRAND,
},
headerRuleThin: { height: 1, backgroundColor: BORDER, marginHorizontal: 32 },
wordmark: { fontFamily: "Helvetica-Bold", fontSize: 14 },
kindLabel: { fontFamily: "Helvetica-Bold", fontSize: 22, color: BRAND, letterSpacing: 1 },
body: { padding: 32, paddingBottom: 90 },
addressRow: { flexDirection: "row", flexWrap: "wrap", justifyContent: "space-between", rowGap: 12, marginBottom: 24 },
addressBlock: { width: "45%" },
// Narrower variant for when a 3rd (shipping) block joins Von/An — three
// of these plus the row's own space-between still fit a Page's width
// without any block cramping its text.
addressBlockThird: { width: "30%" },
addressLabel: { fontSize: 8, color: TEXT_MUTED, marginBottom: 4, textTransform: "uppercase" },
addressLine: { fontSize: 10, lineHeight: 1.5 },
metaRow: { flexDirection: "row", gap: 10, marginBottom: 16, flexWrap: "wrap" },
metaBox: { borderWidth: 1, borderColor: BORDER, borderRadius: 6, paddingVertical: 8, paddingHorizontal: 12 },
metaLabel: { fontSize: 7, color: TEXT_MUTED, textTransform: "uppercase", marginBottom: 2 },
metaValue: { fontSize: 10, fontFamily: "Helvetica-Bold" },
paidBadge: { backgroundColor: SUCCESS_TINT, borderRadius: 6, paddingVertical: 8, paddingHorizontal: 12, justifyContent: "center" },
paidBadgeText: { fontSize: 10, fontFamily: "Helvetica-Bold", color: SUCCESS },
table: { borderRadius: 6, overflow: "hidden", borderWidth: 1, borderColor: BORDER, marginTop: 8, marginBottom: 16 },
tableHeader: { flexDirection: "row", backgroundColor: BG_MUTED, paddingVertical: 8, paddingHorizontal: 10 },
tableRow: { flexDirection: "row", paddingVertical: 8, paddingHorizontal: 10, borderTopWidth: 1, borderTopColor: BORDER },
tableRowAlt: { backgroundColor: BG_MUTED },
colImage: { width: 28 },
itemImage: { width: 28, height: 28, borderRadius: 3 },
colName: { flex: 3 },
colQty: { flex: 1, textAlign: "right" },
colPrice: { flex: 1, textAlign: "right" },
colTotal: { flex: 1, textAlign: "right" },
headerCell: { fontSize: 8, color: TEXT_MUTED, textTransform: "uppercase" },
bundleLine: { fontSize: 8, color: TEXT_MUTED, marginTop: 2 },
summary: { alignItems: "flex-end", marginBottom: 24 },
summaryBox: { width: 240, backgroundColor: BG_MUTED, borderRadius: 6, padding: 14 },
summaryRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2 },
summaryLabel: { fontSize: 10, color: TEXT_MUTED },
summaryValue: { fontSize: 10 },
grandTotalRow: { flexDirection: "row", justifyContent: "space-between", paddingTop: 8, marginTop: 6, borderTopWidth: 1, borderTopColor: BORDER },
grandTotalLabel: { fontSize: 12, fontFamily: "Helvetica-Bold" },
grandTotalValue: { fontSize: 12, fontFamily: "Helvetica-Bold" },
// `fixed` (below, on the element) + absolute positioning — always pinned
// to the bottom of the page regardless of how much content is above it,
// rather than just following wherever the content flow happens to end.
footer: {
position: "absolute",
bottom: 32,
left: 32,
right: 32,
borderTopWidth: 1,
borderTopColor: BORDER,
paddingTop: 12,
fontSize: 8,
color: TEXT_MUTED,
},
});
function formatPrice(amount: number): string {
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(amount);
}
export type InvoiceItem = {
productName: string;
quantity: number;
unitPrice: number;
taxRatePercent: number;
bundleContents?: string | null;
variantName?: string | null;
imageUrl?: string | null;
};
export type InvoiceOrder = {
orderNumber: string;
invoiceNumber: string;
invoiceIssuedAt: string;
customerFirstName: string;
customerLastName: string;
deliveryMethod: "address" | "packstation";
street?: string | null;
packstationNumber?: string | null;
postNumber?: string | null;
zip: string;
city: string;
country: string;
// Optional package destination distinct from the "An" recipient above —
// when set, the invoice shows both addresses (billing stays "An", this
// becomes its own "Lieferadresse" block) instead of implying the order
// shipped to the billing address, which is only true when this is unset.
hasDifferentShippingAddress?: boolean;
shippingFirstName?: string | null;
shippingLastName?: string | null;
shippingDeliveryMethod?: "address" | "packstation" | null;
shippingStreet?: string | null;
shippingPackstationNumber?: string | null;
shippingPostNumber?: string | null;
shippingZip?: string | null;
shippingCity?: string | null;
shippingCountry?: string | null;
paymentMethodTitle: string;
items: InvoiceItem[];
subtotal: number;
shippingCost: number;
discountAmount: number;
discountCode: string | null;
total: number;
};
export type InvoiceSeller = {
sellerName: string;
sellerStreet: string;
sellerZip: string;
sellerCity: string;
sellerCountry: string;
sellerEmail: string;
vatId: string;
taxRatePercent: number;
bankDetails?: string | null;
// Pflichtangaben in Geschäftsbriefen for registered legal forms (§37a
// HGB / §35a GmbHG) — optional because a sole proprietorship (the
// default legalForm in company-settings) has neither. See
// buildLegalFooterLines() in emailTemplates.ts for the same fields'
// equivalent treatment in the email footer.
registerCourt?: string | null;
registerNumber?: string | null;
managingDirector?: string | null;
};
// Used only by /company-settings-preview's Live Preview — a fixed sample
// order so the admin sees a realistic-looking invoice while editing
// company-settings fields, without depending on any real order existing.
export const SAMPLE_INVOICE_ORDER: InvoiceOrder = {
orderNumber: "#EP-0001-A7K2",
invoiceNumber: "RE-0001",
invoiceIssuedAt: new Date().toISOString(),
customerFirstName: "Max",
customerLastName: "Mustermann",
deliveryMethod: "address",
street: "Musterweg 5",
zip: "10115",
city: "Berlin",
country: "Deutschland",
paymentMethodTitle: "Kreditkarte",
items: [
{ productName: "ToDo-Karten Set", quantity: 1, unitPrice: 12.9, taxRatePercent: 19, bundleContents: null },
{ productName: "Wochenplaner Überblick", quantity: 2, unitPrice: 14.9, taxRatePercent: 19, bundleContents: null },
],
subtotal: 42.7,
shippingCost: 0,
discountAmount: 5,
discountCode: "WILLKOMMEN10",
total: 37.7,
};
// "Überweisung" (bank transfer) is the only payment method on this shop
// that ISN'T settled immediately — Kreditkarte/PayPal both capture at
// checkout. Rather than hardcode a list of "immediate" method titles
// (fragile the moment a new one is added in Payload's payment-methods
// collection), the only method that's ever NOT immediate is named
// explicitly — everything else defaults to "paid already".
function isPaidImmediately(paymentMethodTitle: string): boolean {
return paymentMethodTitle !== "Überweisung";
}
// Distributes the order-level discount/shipping proportionally across each
// item's gross line total before computing that line's net/tax — so the
// per-rate summary still reconciles exactly to `order.total` even when a
// discount or shipping cost is present alongside items taxed at different
// rates. Falls back to the seller's default rate for any line that
// predates this field (older orders had no per-item snapshot).
function groupByTaxRate(order: InvoiceOrder, defaultRate: number): { rate: number; net: number; tax: number; gross: number }[] {
return computeTaxBreakdown(
order.items.map((item) => ({ quantity: item.quantity, unitPrice: item.unitPrice, taxRatePercent: item.taxRatePercent ?? defaultRate })),
order.subtotal,
order.discountAmount,
order.shippingCost,
);
}
// Exported (not just used internally by renderInvoicePdf below) so
// /company-settings-preview's client component can mount it directly with
// @react-pdf/renderer's browser-side <PDFViewer> — a live, in-browser
// rendered PDF that re-renders as the admin edits company-settings fields
// via Payload's postMessage-based useLivePreview(), no server round-trip
// needed for each keystroke the way an HTML preview would.
export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller: InvoiceSeller }) {
const rateGroups = groupByTaxRate(order, seller.taxRatePercent);
const paid = isPaidImmediately(order.paymentMethodTitle);
const deliveryLine =
order.deliveryMethod === "address" ? order.street : `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`;
const shippingLine =
order.shippingDeliveryMethod === "packstation"
? `Packstation ${order.shippingPackstationNumber} · Postnummer ${order.shippingPostNumber}`
: order.shippingStreet;
const addressBlockStyle = order.hasDifferentShippingAddress ? styles.addressBlockThird : styles.addressBlock;
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.headerBand}>
<Text style={styles.wordmark}>einfach produktiv.</Text>
<Text style={styles.kindLabel}>RECHNUNG</Text>
</View>
<View style={styles.body}>
<View style={styles.addressRow}>
<View style={addressBlockStyle}>
<Text style={styles.addressLabel}>Von</Text>
<Text style={styles.addressLine}>{seller.sellerName}</Text>
<Text style={styles.addressLine}>{seller.sellerStreet}</Text>
<Text style={styles.addressLine}>
{seller.sellerZip} {seller.sellerCity}
</Text>
<Text style={styles.addressLine}>{seller.sellerCountry}</Text>
</View>
<View style={addressBlockStyle}>
<Text style={styles.addressLabel}>An</Text>
<Text style={styles.addressLine}>
{order.customerFirstName} {order.customerLastName}
</Text>
<Text style={styles.addressLine}>{deliveryLine}</Text>
<Text style={styles.addressLine}>
{order.zip} {order.city}
</Text>
<Text style={styles.addressLine}>{order.country}</Text>
</View>
{order.hasDifferentShippingAddress && (
<View style={addressBlockStyle}>
<Text style={styles.addressLabel}>Lieferadresse</Text>
<Text style={styles.addressLine}>
{order.shippingFirstName} {order.shippingLastName}
</Text>
<Text style={styles.addressLine}>{shippingLine}</Text>
<Text style={styles.addressLine}>
{order.shippingZip} {order.shippingCity}
</Text>
<Text style={styles.addressLine}>{order.shippingCountry}</Text>
</View>
)}
</View>
<View style={styles.metaRow}>
<View style={styles.metaBox}>
<Text style={styles.metaLabel}>Rechnungs-Nr.</Text>
<Text style={styles.metaValue}>{order.invoiceNumber}</Text>
</View>
<View style={styles.metaBox}>
<Text style={styles.metaLabel}>Datum</Text>
<Text style={styles.metaValue}>{formatDate(order.invoiceIssuedAt)}</Text>
</View>
<View style={styles.metaBox}>
<Text style={styles.metaLabel}>Bestellnummer</Text>
<Text style={styles.metaValue}>{order.orderNumber}</Text>
</View>
{paid && (
<View style={styles.paidBadge}>
<Text style={styles.paidBadgeText}> Bereits beglichen ({order.paymentMethodTitle})</Text>
</View>
)}
</View>
<View style={styles.table}>
<View style={styles.tableHeader}>
<View style={styles.colImage} />
<Text style={[styles.colName, styles.headerCell]}>Artikel</Text>
<Text style={[styles.colQty, styles.headerCell]}>Menge</Text>
<Text style={[styles.colPrice, styles.headerCell]}>Einzelpreis</Text>
<Text style={[styles.colTotal, styles.headerCell]}>Betrag</Text>
</View>
{order.items.map((item, i) => (
<View style={[styles.tableRow, i % 2 === 1 ? styles.tableRowAlt : {}]} key={i}>
<View style={styles.colImage}>
{item.imageUrl && <Image src={item.imageUrl} style={styles.itemImage} />}
</View>
<View style={styles.colName}>
<Text>
{item.productName}
{item.variantName ? ` (${item.variantName})` : ""}
</Text>
{item.bundleContents ? <Text style={styles.bundleLine}>{item.bundleContents}</Text> : null}
</View>
<Text style={styles.colQty}>{item.quantity}</Text>
<Text style={styles.colPrice}>{formatPrice(item.unitPrice)}</Text>
<Text style={styles.colTotal}>{formatPrice(item.quantity * item.unitPrice)}</Text>
</View>
))}
</View>
<View style={styles.summary}>
<View style={styles.summaryBox}>
{order.discountAmount > 0 && (
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Rabatt{order.discountCode ? ` (${order.discountCode})` : ""}</Text>
<Text style={styles.summaryValue}>-{formatPrice(order.discountAmount)}</Text>
</View>
)}
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Versand</Text>
<Text style={styles.summaryValue}>{order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)}</Text>
</View>
{rateGroups.map((g) => (
<React.Fragment key={g.rate}>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Netto</Text>
<Text style={styles.summaryValue}>{formatPrice(g.net)}</Text>
</View>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>zzgl. {g.rate}% MwSt.</Text>
<Text style={styles.summaryValue}>{formatPrice(g.tax)}</Text>
</View>
</React.Fragment>
))}
<View style={styles.grandTotalRow}>
<Text style={styles.grandTotalLabel}>Gesamt</Text>
<Text style={styles.grandTotalValue}>{formatPrice(order.total)}</Text>
</View>
</View>
</View>
</View>
<View style={styles.footer} fixed>
<Text>
{seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "}
{seller.vatId}
{seller.registerCourt && seller.registerNumber ? ` · ${seller.registerCourt} · ${seller.registerNumber}` : ""}
{seller.managingDirector ? ` · Geschäftsführung: ${seller.managingDirector}` : ""}
</Text>
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>Bankverbindung (für Überweisung): {seller.bankDetails}</Text> : null}
</View>
</Page>
</Document>
);
}
export async function renderInvoicePdf(order: InvoiceOrder, seller: InvoiceSeller): Promise<Buffer> {
return renderToBuffer(<InvoiceDocument order={order} seller={seller} />);
}
// Exported for unit testing (see app/lib/__tests__/invoicePdf.test.ts) —
// the actual money math and payment-status logic, independent of PDF
// rendering.
export const __testables = { isPaidImmediately, groupByTaxRate };
-36
View File
@@ -1,36 +0,0 @@
// Previously duplicated as groupByTaxRate() independently inside
// invoicePdf.tsx and correctionInvoicePdf.tsx (and, on the Payload backend,
// their own copies) — pulled out so the storefront's own MwSt. breakdowns
// (checkout summary, order confirmation page/email, account order pages)
// can share the exact same math instead of a fourth hand-rolled version
// drifting out of sync with what the actual invoices say.
export type TaxBreakdownLine = { quantity: number; unitPrice: number; taxRatePercent: number };
export type TaxBreakdownGroup = { rate: number; net: number; tax: number; gross: number };
// Groups line items by their effective VAT rate, then scales each group's
// gross total by however much shipping/discount moved the grand total away
// from the raw item subtotal — proportional to that group's own share of
// the subtotal, not a flat split. Passing discountAmount=0 and
// shippingCost=0 (e.g. a Gutschrift, which excludes both) collapses `scale`
// to 1, i.e. no adjustment at all.
export function computeTaxBreakdown(
items: TaxBreakdownLine[],
subtotal: number,
discountAmount: number,
shippingCost: number,
): TaxBreakdownGroup[] {
const groups = new Map<number, number>();
for (const item of items) {
const lineGross = item.quantity * item.unitPrice;
groups.set(item.taxRatePercent, (groups.get(item.taxRatePercent) ?? 0) + lineGross);
}
const scale = subtotal > 0 ? (subtotal - discountAmount + shippingCost) / subtotal : 1;
return Array.from(groups.entries())
.map(([rate, lineGross]) => {
const gross = lineGross * scale;
const net = gross / (1 + rate / 100);
return { rate, net, tax: gross - net, gross };
})
.sort((a, b) => b.rate - a.rate);
}