Files
einfach-produktiv/app/lib/invoicePdf.tsx
T
Marco c5500bcc97 Wire up product variants end-to-end, add tracking-number display
Completes the frontend half of the Payload backend's variant/inventory/
tracking work (see that repo's own commit):

- **Cart**: CartItem gained an optional `variant?: string` field — every
  function that used to match a line by `id` alone (addToCart/
  removeFromCart/setQuantity) now matches by `(id, variant)` together via
  a shared sameLine() helper, so two lines for the same product with
  different variants stay separate entries. `variant` undefined on both
  sides (the no-variants case) still matches by simple equality, so every
  pre-existing call site keeps working unchanged.
- **Selection UI**: AddToCartInlineButton renders a <select> above the
  button when given a non-empty `variants` prop (ProductGrid/
  RelatedProducts pass product.variants straight through); defaults to
  the first variant.
- **Pricing**: cartTotals.ts's new effectivePrice(entry, product) — a
  variant's priceOverride wins over the base product price. Every cart/
  checkout/order-confirmation total and per-line price display now goes
  through this instead of reading product.price directly (fixes both a
  wrong-price bug and a duplicate-React-key bug the old `key={product.id}`
  pattern would have had the moment two variants of one product were both
  in the cart).
- **Checkout**: re-validates the requested variant server-side (same
  "never trust the client" reasoning as price re-derivation) — a variant
  name that doesn't exist on that product fails the whole checkout.
  variantName snapshots onto orders.items, shown as a parenthetical next
  to the product name on the confirmation email, both invoice PDF types,
  and the order-detail page.
- **Cross-device cart**: Customers.cart[].variantName (synced via
  /api/account/cart) carries the selection through a login/logout cycle,
  not just the current session.

Also adds tracking-number display: /konto/bestellungen/[orderNumber]
shows a clickable link when orders.trackingNumber is set, built by a new
app/lib/tracking.ts that mirrors the Payload backend's own copy
byte-for-byte close (same carrier set/URL patterns) so what a customer
sees here matches exactly what the order-shipped email already links to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 17:43:47 +00:00

337 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from "react";
import { Document, Page, View, Text, StyleSheet, renderToBuffer } from "@react-pdf/renderer";
import { formatDate } from "./format";
// 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", 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: 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 },
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 };
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;
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 }[] {
const groups = new Map<number, number>();
for (const item of order.items) {
const rate = item.taxRatePercent ?? defaultRate;
const lineGross = item.quantity * item.unitPrice;
groups.set(rate, (groups.get(rate) ?? 0) + lineGross);
}
const scale = order.subtotal > 0 ? (order.subtotal - order.discountAmount + order.shippingCost) / order.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);
}
// 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}`;
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={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}>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>
<View style={styles.metaBox}>
<Text style={styles.metaLabel}>USt-IdNr.</Text>
<Text style={styles.metaValue}>{seller.vatId}</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}>
<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.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 ({g.rate}%)</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 };