4e9b4b9dd4
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>
360 lines
16 KiB
TypeScript
360 lines
16 KiB
TypeScript
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 };
|