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 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" }, 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 }, // Every item row shares this same tinted background now — no more // odd/even zebra striping that left alternating rows plain white; the // borderTop is what separates rows visually instead. tableRow: { flexDirection: "row", paddingVertical: 8, paddingHorizontal: 10, borderTopWidth: 1, borderTopColor: BORDER, backgroundColor: BG_MUTED }, // marginRight (not a tableRow `gap`) — explicit and unambiguous across // react-pdf's flexbox implementation for a mixed Image/Text row, rather // than relying on a row-level `gap` to apply consistently between an // empty-when-no-image colImage and colName. colImage: { width: 28, marginRight: 10 }, 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" }, // Netto, right under Gesamt — a bit of top margin to read as its own // line following the total, not crowded against the divider above it. nettoRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2, marginTop: 6 }, // "Enthält X% MwSt.: Y €" annotations under Gesamt — same "contained // within the total, not an additional deduction" framing the website's // own VatBreakdown component already uses on /cart and /checkout. // Deliberately NOT part of the summaryRow additive chain above (Zwischen- // summe → Rabatt → Versand → Gesamt): every row in that chain sums // exactly to Gesamt on its own; the tax-rate breakdown is informational // context about what portion of Gesamt is tax, not a further adjustment. // A previous version showed the same per-rate net/tax figures as their // own additive-looking rows alongside Rabatt/Versand, which double- // counted shipping/discount (already proportionally folded into each // rate's own net/tax by computeTaxBreakdown) — the visible rows never // actually summed to the printed Gesamt. Fixed 2026-07-23. vatNotes: { marginTop: 6 }, vatNoteRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 1 }, vatNoteLabel: { fontSize: 8, color: TEXT_MUTED }, vatNoteValue: { fontSize: 8, color: TEXT_MUTED }, // Moved here (below the summary card) from the top meta row — reads more // naturally right next to the amount it's confirming was paid, and keeps // the meta row itself to just the three reference numbers. Deliberately // a *sibling* of summaryBox, not a row inside it — the confirmation // applies to the whole card's total, so it sits visually outside/below // the card rather than being absorbed as one more line inside it (a // correction made after initially placing it inside the card). Plain // green text, not a pill/badge box — a colored background here read as // too heavy/attention-grabbing for what's just a status note. paidBadgeRow: { alignItems: "flex-end", marginTop: 10 }, // `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. // Centered (not left-aligned) — reads as a formal closing block rather // than a stray left-hanging line once the page's content above is itself // centered around the summary box on the right and the table spanning // full width. footer: { position: "absolute", bottom: 32, left: 32, right: 32, borderTopWidth: 1, borderTopColor: BORDER, paddingTop: 12, fontSize: 8, color: TEXT_MUTED, textAlign: "center", }, }); 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; // Optional B2B recipient fields (Orders.companyName/vatId in the Payload // backend) — when present, shown in the "An" block: companyName as its // own line above the contact person's name, vatId as its own line below // the address. Neither implies the other (see Orders.ts's own comment). companyName?: string | null; vatId?: string | null; // Innergemeinschaftliche Lieferung (§4 Nr. 1b UStG) — decided server-side // at checkout via a live VIES lookup (see the frontend's api/checkout/ // route.ts and lib/vatExemption.ts), never guessed from vatId's mere // presence/format. When true, every item's own taxRatePercent is already // 0 and unitPrice already de-grossed (the actual charged/persisted // figures) — this flag only controls the exemption note shown here, // it doesn't itself change any arithmetic in this file. vatExempt?: boolean; // Kleinunternehmerregelung (§19 UStG) — snapshotted per order at checkout // time (frontend's api/checkout/route.ts, from the seller's own // company-settings.kleinunternehmer), never read live off the seller at // render time: an invoice already issued must keep showing the tax // treatment that actually applied at the moment of sale, even if the // tenant later toggles this setting on/off. Takes precedence over // vatExempt below when both would otherwise apply — a Kleinunternehmer // never charges VAT in the first place, so there is nothing left to // "exempt" via the separate intra-community rule. kleinunternehmer?: boolean; 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, ); } // Packstation deliveries show number and Postnummer on their own lines // (not one combined "Packstation X · Postnummer Y" line) — each is looked // up independently at a DHL Packstation, easier to scan stacked than // split by a middle dot in running text. A plain street address stays a // single line, same as before. function DeliveryLines({ deliveryMethod, street, packstationNumber, postNumber }: { deliveryMethod?: string | null; street?: string | null; packstationNumber?: string | null; postNumber?: string | null }) { if (deliveryMethod === "packstation") { return ( <> Packstation {packstationNumber} Postnummer {postNumber} ); } return {street}; } // 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 addressBlockStyle = order.hasDifferentShippingAddress ? styles.addressBlockThird : styles.addressBlock; return ( einfach produktiv. RECHNUNG Von {seller.sellerName} {seller.sellerStreet} {seller.sellerZip} {seller.sellerCity} {seller.sellerCountry} An {order.companyName && {order.companyName}} {order.customerFirstName} {order.customerLastName} {order.zip} {order.city} {order.country} {order.vatId && USt-IdNr. {order.vatId}} {order.hasDifferentShippingAddress && ( Lieferadresse {order.shippingFirstName} {order.shippingLastName} {order.shippingZip} {order.shippingCity} {order.shippingCountry} )} Rechnungs-Nr. {order.invoiceNumber} Datum {formatDate(order.invoiceIssuedAt)} Bestellnummer {order.orderNumber} Artikel Menge Einzelpreis Betrag {order.items.map((item, i) => ( {item.imageUrl && } {item.productName} {item.variantName ? ` (${item.variantName})` : ""} {item.bundleContents ? {item.bundleContents} : null} {item.quantity} {formatPrice(item.unitPrice)} {formatPrice(item.quantity * item.unitPrice)} ))} {/* Genuinely additive chain — Zwischensumme (raw item gross, unscaled) − Rabatt + Versand always equals Gesamt exactly, unlike the old layout this replaced (see vatNotes' own comment above). */} Zwischensumme {formatPrice(order.subtotal)} {order.discountAmount > 0 && ( Rabatt{order.discountCode ? ` (${order.discountCode})` : ""} -{formatPrice(order.discountAmount)} )} Versand {order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)} Gesamt {formatPrice(order.total)} {/* Exempt orders already have every item at 0% (see this file's own InvoiceOrder.vatExempt comment) — "enthält 0% MwSt.: 0,00 €" would be a meaningless thing to print, so this replaces the whole per-rate breakdown with the actual legal basis instead. Kept directly under Gesamt (not after Netto) — "enthält X% MwSt." describes what's contained in Gesamt; sandwiching Netto between the two made it read as if Netto were what contained the tax, which is backwards (Netto is the figure with tax already taken out). */} {order.kleinunternehmer ? ( Gemäß § 19 UStG wird keine Umsatzsteuer berechnet. ) : order.vatExempt ? ( Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG) ) : ( rateGroups.map((g) => ( enthält {g.rate}% MwSt. {formatPrice(g.tax)} )) )} {/* Netto (Gesamt minus every rate's own tax, summed — a single well-defined figure regardless of how many distinct rates the order actually has) — businesses read this off the invoice directly for their own input-tax deduction instead of having to compute Gesamt minus MwSt by hand. Shown on every invoice, not just B2B/non-exempt ones — on a vatExempt order Netto and Gesamt happen to be the same figure (0% tax), but the row stays for layout consistency across every invoice this shop issues. The one exception is `kleinunternehmer` (built 2026-07-24, at the user's own request): there Netto = Gesamt for a structural reason, not a coincidental one (§19 UStG means there was never a tax component to subtract in the first place), and the row would just look like a second, redundant total sitting directly under the §19 notice above rather than genuinely new information. Placed last (after the MwSt annotation, not before it) — see that annotation's own comment above for why the order matters here. Styled with the same small/muted vatNoteLabel/ vatNoteValue as that annotation, not the larger summaryLabel/summaryValue the build-up rows above Gesamt use — Netto is supplementary detail *below* the hero Gesamt figure, same visual tier as the tax breakdown, not another primary line competing with it. */} {!order.kleinunternehmer && ( Netto {formatPrice(rateGroups.reduce((sum, g) => sum + g.net, 0))} )} {paid && ( ✓ Bereits beglichen ({order.paymentMethodTitle}) )} {seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} {/* Omitted entirely (not "USt-IdNr. " with a blank value) when unset — a Kleinunternehmer (§19 UStG) often has no USt-IdNr. at all, see seller.ts's own comment. */} {seller.vatId ? ` · USt-IdNr. ${seller.vatId}` : ""} {seller.registerCourt && seller.registerNumber ? ` · ${seller.registerCourt} · ${seller.registerNumber}` : ""} {seller.managingDirector ? ` · Geschäftsführung: ${seller.managingDirector}` : ""} {/* 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) && ( Bankverbindung: {[seller.bankName, seller.iban && `IBAN ${seller.iban}`, seller.bic && `BIC ${seller.bic}`].filter(Boolean).join(" · ")} )} ); } export async function renderInvoicePdf(order: InvoiceOrder, seller: InvoiceSeller): Promise { return renderToBuffer(); } // 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 };