Files
einfach-produktiv-invoicing/src/invoicePdf.tsx
T
Marco ceaa437724
Validate e-invoices / mustang (push) Successful in 24s
Drop the Netto row for Kleinunternehmer invoices
Netto = Gesamt is structural under §19 UStG (no tax component ever
existed), not the coincidental 0%-this-one-sale case vatExempt orders
are in — showing it right under the §19 notice just duplicated Gesamt.
At the user's own request after reviewing a real invoice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 15:18:36 +00:00

492 lines
25 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 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 (
<>
<Text style={styles.addressLine}>Packstation {packstationNumber}</Text>
<Text style={styles.addressLine}>Postnummer {postNumber}</Text>
</>
);
}
return <Text style={styles.addressLine}>{street}</Text>;
}
// 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 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>
{order.companyName && <Text style={styles.addressLine}>{order.companyName}</Text>}
<Text style={styles.addressLine}>
{order.customerFirstName} {order.customerLastName}
</Text>
<DeliveryLines deliveryMethod={order.deliveryMethod} street={order.street} packstationNumber={order.packstationNumber} postNumber={order.postNumber} />
<Text style={styles.addressLine}>
{order.zip} {order.city}
</Text>
<Text style={styles.addressLine}>{order.country}</Text>
{order.vatId && <Text style={styles.addressLine}>USt-IdNr. {order.vatId}</Text>}
</View>
{order.hasDifferentShippingAddress && (
<View style={addressBlockStyle}>
<Text style={styles.addressLabel}>Lieferadresse</Text>
<Text style={styles.addressLine}>
{order.shippingFirstName} {order.shippingLastName}
</Text>
<DeliveryLines
deliveryMethod={order.shippingDeliveryMethod}
street={order.shippingStreet}
packstationNumber={order.shippingPackstationNumber}
postNumber={order.shippingPostNumber}
/>
<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>
</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} 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}>
{/* 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). */}
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Zwischensumme</Text>
<Text style={styles.summaryValue}>{formatPrice(order.subtotal)}</Text>
</View>
{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>
<View style={styles.grandTotalRow}>
<Text style={styles.grandTotalLabel}>Gesamt</Text>
<Text style={styles.grandTotalValue}>{formatPrice(order.total)}</Text>
</View>
<View style={styles.vatNotes}>
{/* 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 ? (
<Text style={styles.vatNoteLabel}>Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</Text>
) : order.vatExempt ? (
<Text style={styles.vatNoteLabel}>Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)</Text>
) : (
rateGroups.map((g) => (
<View style={styles.vatNoteRow} key={g.rate}>
<Text style={styles.vatNoteLabel}>enthält {g.rate}% MwSt.</Text>
<Text style={styles.vatNoteValue}>{formatPrice(g.tax)}</Text>
</View>
))
)}
</View>
{/* 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 && (
<View style={styles.nettoRow}>
<Text style={styles.vatNoteLabel}>Netto</Text>
<Text style={styles.vatNoteValue}>{formatPrice(rateGroups.reduce((sum, g) => sum + g.net, 0))}</Text>
</View>
)}
</View>
{paid && (
<View style={styles.paidBadgeRow}>
<Text style={styles.paidBadgeText}> Bereits beglichen ({order.paymentMethodTitle})</Text>
</View>
)}
</View>
</View>
<View style={styles.footer} fixed>
<Text>
{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}` : ""}
</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 };