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 }; 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; }; // 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(); 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 — 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 ( einfach produktiv. RECHNUNG Von {seller.sellerName} {seller.sellerStreet} {seller.sellerZip} {seller.sellerCity} {seller.sellerCountry} An {order.customerFirstName} {order.customerLastName} {deliveryLine} {order.zip} {order.city} {order.country} Rechnungs-Nr. {order.invoiceNumber} Datum {formatDate(order.invoiceIssuedAt)} Bestellnummer {order.orderNumber} USt-IdNr. {seller.vatId} {paid && ( ✓ Bereits beglichen ({order.paymentMethodTitle}) )} Artikel Menge Einzelpreis Betrag {order.items.map((item, i) => ( {item.productName} {item.bundleContents ? {item.bundleContents} : null} {item.quantity} {formatPrice(item.unitPrice)} {formatPrice(item.quantity * item.unitPrice)} ))} {order.discountAmount > 0 && ( Rabatt{order.discountCode ? ` (${order.discountCode})` : ""} -{formatPrice(order.discountAmount)} )} Versand {order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)} {rateGroups.map((g) => ( Netto ({g.rate}%) {formatPrice(g.net)} zzgl. {g.rate}% MwSt. {formatPrice(g.tax)} ))} Gesamt {formatPrice(order.total)} {seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "} {seller.vatId} {seller.bankDetails ? Bankverbindung (für Überweisung): {seller.bankDetails} : null} ); } export async function renderInvoicePdf(order: InvoiceOrder, seller: InvoiceSeller): Promise { return renderToBuffer(); } // 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 };