Files
einfach-produktiv-invoicing/src/invoicePdf.tsx
T
Marco 4e9b4b9dd4 Add bankName back to InvoiceSeller, shown alongside IBAN/BIC
Dropped by accident when bankDetails split into iban/bic — a bank name
has no fixed format to validate, so it stays free text, but it's still
useful context next to the account numbers in the invoice footer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 10:26:23 +00:00

360 lines
16 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, Image, StyleSheet, renderToBuffer } from "@react-pdf/renderer";
import { formatPrice, formatDate } from "./formatters";
import { computeTaxBreakdown } from "./taxBreakdown";
import type { InvoiceSeller } from "./seller";
export type { InvoiceSeller };
// Rendered by the frontend at checkout time (attached to the order
// confirmation email) and again on demand for "Rechnung herunterladen" —
// same render call both times, so a re-download always matches what was
// emailed.
//
// Built-in Helvetica, not a registered web font — this can render inside a
// fire-and-forget email step; a font-fetch failure there is one more way
// to lose the invoice attachment for no real design benefit.
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,
},
});
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;
};
// 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>
{/* Always shown when set, regardless of payment method — not
gated on "Überweisung" specifically (a customer who paid by
card can still want the seller's bank details for other
reasons, e.g. a refund) — see this package's README. */}
{(seller.iban || seller.bic) && (
<Text style={{ marginTop: 4 }}>
Bankverbindung: {[seller.bankName, seller.iban && `IBAN ${seller.iban}`, seller.bic && `BIC ${seller.bic}`].filter(Boolean).join(" · ")}
</Text>
)}
</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 src/__tests__/invoicePdf.test.ts) — the
// actual money math and payment-status logic, independent of PDF
// rendering.
export const __testables = { isPaidImmediately, groupByTaxRate };