Initial commit: shared invoice/correction-invoice PDF generation
Consolidates code previously hand-duplicated between the einfach-produktiv frontend and payload backend repos into one canonical implementation, consumed as a git dependency by both instead of being kept in sync by eye. While merging the two correction-invoice copies, found and fixed three real drifts between them: - variantName was silently dropped in the backend's emailed Stornorechnung/ Gutschrift, but present in the frontend's re-download copy - the backend's correction-invoice footer wasn't position:fixed, unlike the original invoice and the frontend's copy - the backend used a numeric date format (03.07.2025) while the original invoice and the frontend's copy both used a spelled-out month (03. Juli 2025) — a re-download didn't visually match what was emailed Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,36 @@
|
||||
# @einfach-produktiv/invoicing
|
||||
|
||||
Shared invoice / correction-invoice (Stornorechnung, Gutschrift) PDF generation and VAT-breakdown math, used by both:
|
||||
|
||||
- `einfach-produktiv` (the Next.js storefront — generates the original invoice at checkout, plus on-demand re-downloads of both document types)
|
||||
- `payload` (the Payload CMS backend — generates the authoritative Stornorechnung/Gutschrift the moment an order's status changes)
|
||||
|
||||
## Why this exists
|
||||
|
||||
Before this package, `taxBreakdown.ts` and `correctionInvoicePdf.tsx` were hand-duplicated between both repos ("kept in sync by eye"). That drifted in three concrete, customer-visible ways before this package fixed it:
|
||||
|
||||
1. The backend's emailed correction invoice silently dropped each line item's `variantName` (the frontend's re-download copy showed it).
|
||||
2. The backend's correction-invoice footer wasn't `position: fixed`, unlike the original invoice and the frontend's copy.
|
||||
3. The backend's correction invoice used a numeric date format (`03.07.2025`); the original invoice and the frontend's re-download copy both used a spelled-out month (`03. Juli 2025`) — so a re-downloaded document didn't match what was originally emailed.
|
||||
|
||||
One canonical implementation, consumed by both repos, makes this class of drift structurally impossible instead of relying on manual vigilance.
|
||||
|
||||
## How this is consumed
|
||||
|
||||
Not published to npm — installed as a git dependency:
|
||||
|
||||
```json
|
||||
"@einfach-produktiv/invoicing": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git"
|
||||
```
|
||||
|
||||
Ships raw TypeScript/TSX source (no build step) via `main`/`types` pointing straight at `src/index.ts`. Each consuming Next.js app must add this package to its own `next.config.ts`'s `transpilePackages` array so its own bundler compiles the source — the same pattern a monorepo tool like Turborepo uses for internal packages, just without the monorepo.
|
||||
|
||||
`react` and `@react-pdf/renderer` are peer dependencies — each consumer supplies its own copy rather than this package pinning a version that could conflict.
|
||||
|
||||
## Layout
|
||||
|
||||
- `taxBreakdown.ts` — `computeTaxBreakdown()`, the per-VAT-rate net/tax grouping math shared by every document type here.
|
||||
- `formatters.ts` — `formatPrice()`/`formatDate()`, canonical formatting for every document.
|
||||
- `invoicePdf.tsx` — the original invoice ("Rechnung"): `InvoiceDocument`, `renderInvoicePdf()`, plus `SAMPLE_INVOICE_ORDER` (used by the frontend's Payload Live Preview for company-settings).
|
||||
- `correctionInvoicePdf.tsx` — Stornorechnung/Gutschrift: `renderCorrectionInvoicePdf()`.
|
||||
- `seller.ts` — the shared `InvoiceSeller` type both document types render in their footer.
|
||||
Generated
+3116
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@einfach-produktiv/invoicing",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Shared invoice / correction-invoice (Stornorechnung, Gutschrift) PDF generation and VAT-breakdown math, consumed as a git dependency by both the einfach-produktiv frontend and the payload backend — not published to npm.",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"@react-pdf/renderer": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"eslint": "^9",
|
||||
"react": "^19.2.4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { __testables, type CorrectionInvoiceItem, type CorrectionInvoiceOrder } from "../correctionInvoicePdf";
|
||||
|
||||
const { resolveLineItems, groupByTaxRate } = __testables;
|
||||
|
||||
const item = (overrides: Partial<CorrectionInvoiceItem> = {}): CorrectionInvoiceItem => ({
|
||||
productName: "ToDo-Karten",
|
||||
quantity: 2,
|
||||
unitPrice: 12.9,
|
||||
taxRatePercent: 19,
|
||||
bundleContents: null,
|
||||
returnQuantity: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const order = (overrides: Partial<CorrectionInvoiceOrder> = {}): CorrectionInvoiceOrder => ({
|
||||
orderNumber: "#EP-0001",
|
||||
invoiceNumber: "RE-0001",
|
||||
invoiceIssuedAt: new Date().toISOString(),
|
||||
correctionInvoiceNumber: "RE-0002",
|
||||
correctionInvoiceIssuedAt: new Date().toISOString(),
|
||||
customerFirstName: "Max",
|
||||
customerLastName: "Mustermann",
|
||||
deliveryMethod: "address",
|
||||
zip: "10115",
|
||||
city: "Berlin",
|
||||
country: "Deutschland",
|
||||
items: [item()],
|
||||
subtotal: 25.8,
|
||||
shippingCost: 2.9,
|
||||
discountAmount: 0,
|
||||
total: 28.7,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("resolveLineItems", () => {
|
||||
it("storno: includes every item at its full ordered quantity, regardless of returnQuantity", () => {
|
||||
const items = [item({ quantity: 3, returnQuantity: 0 }), item({ quantity: 1, returnQuantity: 1 })];
|
||||
const lines = resolveLineItems("storno", items);
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0].effectiveQuantity).toBe(3);
|
||||
expect(lines[1].effectiveQuantity).toBe(1);
|
||||
});
|
||||
|
||||
it("gutschrift: only includes items with returnQuantity > 0, at that quantity", () => {
|
||||
const items = [item({ quantity: 3, returnQuantity: 1 }), item({ quantity: 2, returnQuantity: 0 })];
|
||||
const lines = resolveLineItems("gutschrift", items);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0].effectiveQuantity).toBe(1);
|
||||
});
|
||||
|
||||
it("gutschrift: a full return includes the item at its full quantity", () => {
|
||||
const items = [item({ quantity: 2, returnQuantity: 2 })];
|
||||
const lines = resolveLineItems("gutschrift", items);
|
||||
expect(lines[0].effectiveQuantity).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupByTaxRate", () => {
|
||||
it("storno: reverses the full order total including shipping", () => {
|
||||
const o = order({ items: [item({ quantity: 2, unitPrice: 12.9 })], subtotal: 25.8, shippingCost: 2.9, discountAmount: 0, total: 28.7 });
|
||||
const lines = resolveLineItems("storno", o.items);
|
||||
const groups = groupByTaxRate("storno", lines, o, 19);
|
||||
const grandTotal = groups.reduce((sum, g) => sum + g.gross, 0);
|
||||
expect(grandTotal).toBeCloseTo(28.7, 2);
|
||||
});
|
||||
|
||||
it("storno: net + tax reconcile to gross per rate group", () => {
|
||||
const o = order({ items: [item({ quantity: 2, unitPrice: 12.9, taxRatePercent: 19 })] });
|
||||
const lines = resolveLineItems("storno", o.items);
|
||||
const [group] = groupByTaxRate("storno", lines, o, 19);
|
||||
expect(group.net + group.tax).toBeCloseTo(group.gross, 6);
|
||||
expect(group.net).toBeCloseTo(group.gross / 1.19, 6);
|
||||
});
|
||||
|
||||
it("gutschrift: excludes shipping entirely, even for a full-order return", () => {
|
||||
const fullItem = item({ quantity: 2, unitPrice: 12.9, returnQuantity: 2 });
|
||||
const o = order({ items: [fullItem], subtotal: 25.8, shippingCost: 2.9, discountAmount: 0, total: 28.7 });
|
||||
const lines = resolveLineItems("gutschrift", o.items);
|
||||
const groups = groupByTaxRate("gutschrift", lines, o, 19);
|
||||
const grandTotal = groups.reduce((sum, g) => sum + g.gross, 0);
|
||||
// Only the 2×12.90 item value, not +2.90 shipping.
|
||||
expect(grandTotal).toBeCloseTo(25.8, 2);
|
||||
});
|
||||
|
||||
it("gutschrift: reflects only the returned quantity, not the full ordered quantity", () => {
|
||||
const o = order({ items: [item({ quantity: 3, unitPrice: 10, returnQuantity: 1 })], subtotal: 30, shippingCost: 2.9, total: 32.9 });
|
||||
const lines = resolveLineItems("gutschrift", o.items);
|
||||
const groups = groupByTaxRate("gutschrift", lines, o, 19);
|
||||
const grandTotal = groups.reduce((sum, g) => sum + g.gross, 0);
|
||||
expect(grandTotal).toBeCloseTo(10, 2); // 1 unit, not 3
|
||||
});
|
||||
|
||||
it("gutschrift: is unaffected by discountAmount (policy: discount stays with kept items)", () => {
|
||||
const withDiscount = order({
|
||||
items: [item({ quantity: 1, unitPrice: 20, returnQuantity: 1 })],
|
||||
subtotal: 20,
|
||||
discountAmount: 10,
|
||||
shippingCost: 0,
|
||||
total: 10,
|
||||
});
|
||||
const withoutDiscount = order({
|
||||
items: [item({ quantity: 1, unitPrice: 20, returnQuantity: 1 })],
|
||||
subtotal: 20,
|
||||
discountAmount: 0,
|
||||
shippingCost: 0,
|
||||
total: 20,
|
||||
});
|
||||
const groupsWith = groupByTaxRate("gutschrift", resolveLineItems("gutschrift", withDiscount.items), withDiscount, 19);
|
||||
const groupsWithout = groupByTaxRate("gutschrift", resolveLineItems("gutschrift", withoutDiscount.items), withoutDiscount, 19);
|
||||
const totalWith = groupsWith.reduce((sum, g) => sum + g.gross, 0);
|
||||
const totalWithout = groupsWithout.reduce((sum, g) => sum + g.gross, 0);
|
||||
expect(totalWith).toBeCloseTo(totalWithout, 6);
|
||||
});
|
||||
|
||||
it("groups multiple distinct tax rates separately and each reconciles net+tax=gross", () => {
|
||||
const o = order({
|
||||
items: [item({ quantity: 1, unitPrice: 20, returnQuantity: 1, taxRatePercent: 19 }), item({ quantity: 1, unitPrice: 10, returnQuantity: 1, taxRatePercent: 7 })],
|
||||
});
|
||||
const groups = groupByTaxRate("gutschrift", resolveLineItems("gutschrift", o.items), o, 19);
|
||||
expect(groups).toHaveLength(2);
|
||||
for (const g of groups) {
|
||||
expect(g.net + g.tax).toBeCloseTo(g.gross, 6);
|
||||
}
|
||||
const total = groups.reduce((sum, g) => sum + g.gross, 0);
|
||||
expect(total).toBeCloseTo(30, 2);
|
||||
});
|
||||
|
||||
it("falls back to the tenant default rate when an item has no taxRatePercent", () => {
|
||||
const o = order({ items: [item({ quantity: 1, unitPrice: 10, returnQuantity: 1, taxRatePercent: undefined as unknown as number })] });
|
||||
const groups = groupByTaxRate("gutschrift", resolveLineItems("gutschrift", o.items), o, 7);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].rate).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { __testables, type InvoiceItem, type InvoiceOrder } from "../invoicePdf";
|
||||
|
||||
const { isPaidImmediately, groupByTaxRate } = __testables;
|
||||
|
||||
const item = (overrides: Partial<InvoiceItem> = {}): InvoiceItem => ({
|
||||
productName: "ToDo-Karten",
|
||||
quantity: 2,
|
||||
unitPrice: 12.9,
|
||||
taxRatePercent: 19,
|
||||
bundleContents: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const order = (overrides: Partial<InvoiceOrder> = {}): InvoiceOrder => ({
|
||||
orderNumber: "#EP-0001",
|
||||
invoiceNumber: "RE-0001",
|
||||
invoiceIssuedAt: new Date().toISOString(),
|
||||
customerFirstName: "Max",
|
||||
customerLastName: "Mustermann",
|
||||
deliveryMethod: "address",
|
||||
street: "Musterweg 1",
|
||||
zip: "10115",
|
||||
city: "Berlin",
|
||||
country: "Deutschland",
|
||||
paymentMethodTitle: "Kreditkarte",
|
||||
items: [item()],
|
||||
subtotal: 25.8,
|
||||
shippingCost: 2.9,
|
||||
discountAmount: 0,
|
||||
discountCode: null,
|
||||
total: 28.7,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("isPaidImmediately", () => {
|
||||
it("is true for Kreditkarte", () => {
|
||||
expect(isPaidImmediately("Kreditkarte")).toBe(true);
|
||||
});
|
||||
|
||||
it("is true for PayPal", () => {
|
||||
expect(isPaidImmediately("PayPal")).toBe(true);
|
||||
});
|
||||
|
||||
it("is false only for Überweisung", () => {
|
||||
expect(isPaidImmediately("Überweisung")).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to true for any future/unknown payment method (only Überweisung is the named exception)", () => {
|
||||
expect(isPaidImmediately("Sofortüberweisung")).toBe(true);
|
||||
expect(isPaidImmediately("Klarna")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupByTaxRate (original invoice)", () => {
|
||||
it("reconciles net+tax to gross, and gross to the order total for a single rate", () => {
|
||||
const o = order({ items: [item({ quantity: 2, unitPrice: 12.9 })], subtotal: 25.8, shippingCost: 2.9, discountAmount: 0, total: 28.7 });
|
||||
const groups = groupByTaxRate(o, 19);
|
||||
expect(groups).toHaveLength(1);
|
||||
const [g] = groups;
|
||||
expect(g.net + g.tax).toBeCloseTo(g.gross, 6);
|
||||
expect(g.gross).toBeCloseTo(28.7, 2);
|
||||
});
|
||||
|
||||
it("distributes a discount proportionally, still reconciling to the discounted total", () => {
|
||||
const o = order({
|
||||
items: [item({ quantity: 1, unitPrice: 50 })],
|
||||
subtotal: 50,
|
||||
shippingCost: 0,
|
||||
discountAmount: 10,
|
||||
total: 40,
|
||||
});
|
||||
const groups = groupByTaxRate(o, 19);
|
||||
const total = groups.reduce((sum, g) => sum + g.gross, 0);
|
||||
expect(total).toBeCloseTo(40, 2);
|
||||
});
|
||||
|
||||
it("splits multiple tax rates into separate groups that each reconcile", () => {
|
||||
const o = order({
|
||||
items: [item({ quantity: 1, unitPrice: 20, taxRatePercent: 19 }), item({ quantity: 1, unitPrice: 10, taxRatePercent: 7 })],
|
||||
subtotal: 30,
|
||||
shippingCost: 0,
|
||||
discountAmount: 0,
|
||||
total: 30,
|
||||
});
|
||||
const groups = groupByTaxRate(o, 19);
|
||||
expect(groups).toHaveLength(2);
|
||||
for (const g of groups) expect(g.net + g.tax).toBeCloseTo(g.gross, 6);
|
||||
expect(groups.reduce((sum, g) => sum + g.gross, 0)).toBeCloseTo(30, 2);
|
||||
});
|
||||
|
||||
it("falls back to the tenant default rate when an item has no taxRatePercent", () => {
|
||||
const o = order({ items: [item({ taxRatePercent: undefined as unknown as number })] });
|
||||
const groups = groupByTaxRate(o, 7);
|
||||
expect(groups[0].rate).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
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 };
|
||||
|
||||
// Stornorechnung (order cancelled before shipping) / Gutschrift (order
|
||||
// returned after delivery, full or partial) — the payload repo's Orders.ts
|
||||
// afterChange hook generates and emails this the moment either status is
|
||||
// reached, referencing the original invoice (invoiceNumber/
|
||||
// invoiceIssuedAt, already on the order doc from checkout). The frontend
|
||||
// calls the same render function again on demand for "Stornorechnung/
|
||||
// Gutschrift herunterladen" — same "deterministic regeneration, not file
|
||||
// storage" approach as the original invoice, so a re-download always
|
||||
// matches what was emailed.
|
||||
//
|
||||
// The two kinds reverse different amounts, by design (confirmed with the
|
||||
// business owner, not just an engineering default):
|
||||
// - Stornorechnung (cancelled, always pre-shipping): full reversal of the
|
||||
// entire original invoice, INCLUDING shipping — nothing was ever
|
||||
// shipped, so the whole charge is undone.
|
||||
// - Gutschrift (returned, after delivery — partial or full): only the
|
||||
// RETURNED items' value, using each item's `returnQuantity` (not its
|
||||
// full ordered `quantity`) — shipping is never refunded (the delivery
|
||||
// already happened, that service was rendered), and the original
|
||||
// discount amount is never reprorated (stays with the kept items,
|
||||
// simplest defensible policy — no attempt to re-split a percent/fixed
|
||||
// discount across a partial return).
|
||||
//
|
||||
// Built-in Helvetica, not a registered web font — same reasoning as
|
||||
// invoicePdf.tsx.
|
||||
const BRAND = "#f6a701";
|
||||
const TEXT_MUTED = "#6b6b69";
|
||||
const BORDER = "#e5e0d8";
|
||||
const BG_MUTED = "#f8f5f1";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { padding: 0, fontSize: 10, fontFamily: "Helvetica", color: "#1a1a18" },
|
||||
// A rule, not a filled band — a bold brand-colored line rather than a
|
||||
// plain 1pt gray divider.
|
||||
headerBand: {
|
||||
padding: 32,
|
||||
paddingBottom: 24,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
borderBottomWidth: 3,
|
||||
borderBottomColor: BRAND,
|
||||
},
|
||||
wordmark: { fontFamily: "Helvetica-Bold", fontSize: 14 },
|
||||
kindLabel: { fontFamily: "Helvetica-Bold", fontSize: 22, color: BRAND, letterSpacing: 1 },
|
||||
body: { padding: 32, paddingBottom: 90 },
|
||||
refLine: { fontSize: 10, color: TEXT_MUTED, marginBottom: 24 },
|
||||
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: 24 },
|
||||
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" },
|
||||
table: { borderRadius: 6, overflow: "hidden", borderWidth: 1, borderColor: BORDER, 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` (on the element, see CorrectionInvoiceDocument below) — always
|
||||
// pinned to the bottom of the page regardless of content height above,
|
||||
// same as invoicePdf.tsx's own footer. (One of the two pre-package
|
||||
// copies this file replaces was missing `fixed` — see this package's
|
||||
// README.)
|
||||
footer: {
|
||||
position: "absolute",
|
||||
bottom: 32,
|
||||
left: 32,
|
||||
right: 32,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: BORDER,
|
||||
paddingTop: 12,
|
||||
fontSize: 8,
|
||||
color: TEXT_MUTED,
|
||||
},
|
||||
});
|
||||
|
||||
export type CorrectionInvoiceKind = "storno" | "gutschrift";
|
||||
|
||||
export type CorrectionInvoiceItem = {
|
||||
productName: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
taxRatePercent: number;
|
||||
bundleContents?: string | null;
|
||||
variantName?: string | null;
|
||||
returnQuantity?: number;
|
||||
imageUrl?: string | null;
|
||||
};
|
||||
|
||||
export type CorrectionInvoiceOrder = {
|
||||
orderNumber: string;
|
||||
invoiceNumber: string;
|
||||
invoiceIssuedAt: string;
|
||||
correctionInvoiceNumber: string;
|
||||
correctionInvoiceIssuedAt: string;
|
||||
customerFirstName: string;
|
||||
customerLastName: string;
|
||||
deliveryMethod: "address" | "packstation";
|
||||
street?: string | null;
|
||||
packstationNumber?: string | null;
|
||||
postNumber?: string | null;
|
||||
zip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
items: CorrectionInvoiceItem[];
|
||||
subtotal: number;
|
||||
shippingCost: number;
|
||||
discountAmount: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
// Stornorechnung: every item at its full ordered quantity (nothing
|
||||
// shipped, undo everything). Gutschrift: only items with a nonzero
|
||||
// returnQuantity, at that returned quantity — see this file's top comment
|
||||
// for the full policy reasoning (no shipping refund, no discount
|
||||
// reproration on a Gutschrift).
|
||||
function resolveLineItems(kind: CorrectionInvoiceKind, items: CorrectionInvoiceItem[]): { item: CorrectionInvoiceItem; effectiveQuantity: number }[] {
|
||||
if (kind === "storno") return items.map((item) => ({ item, effectiveQuantity: item.quantity }));
|
||||
return items
|
||||
.filter((item) => (item.returnQuantity ?? 0) > 0)
|
||||
.map((item) => ({ item, effectiveQuantity: item.returnQuantity as number }));
|
||||
}
|
||||
|
||||
// Stornorechnung: distributes the order-level discount/shipping
|
||||
// proportionally across each item's gross line total before computing
|
||||
// net/tax — reconciles exactly to a full reversal of `order.total`.
|
||||
// Gutschrift: no scaling at all — shipping/discount are deliberately
|
||||
// excluded (see top comment), so the grouped total is just the sum of the
|
||||
// returned lines' own gross amounts.
|
||||
function groupByTaxRate(
|
||||
kind: CorrectionInvoiceKind,
|
||||
lines: { item: CorrectionInvoiceItem; effectiveQuantity: number }[],
|
||||
order: CorrectionInvoiceOrder,
|
||||
defaultRate: number,
|
||||
): { rate: number; net: number; tax: number; gross: number }[] {
|
||||
// Gutschrift excludes shipping/discount entirely — passing 0 for both
|
||||
// collapses computeTaxBreakdown's scale factor to 1, same as the old
|
||||
// kind==='storno' ? ... : 1 branch did explicitly.
|
||||
return computeTaxBreakdown(
|
||||
lines.map(({ item, effectiveQuantity }) => ({
|
||||
quantity: effectiveQuantity,
|
||||
unitPrice: item.unitPrice,
|
||||
taxRatePercent: item.taxRatePercent ?? defaultRate,
|
||||
})),
|
||||
order.subtotal,
|
||||
kind === "storno" ? order.discountAmount : 0,
|
||||
kind === "storno" ? order.shippingCost : 0,
|
||||
);
|
||||
}
|
||||
|
||||
function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionInvoiceKind; order: CorrectionInvoiceOrder; seller: InvoiceSeller }) {
|
||||
const kindLabel = kind === "storno" ? "Stornorechnung" : "Gutschrift";
|
||||
const lines = resolveLineItems(kind, order.items);
|
||||
const rateGroups = groupByTaxRate(kind, lines, order, seller.taxRatePercent);
|
||||
const grandTotal = rateGroups.reduce((sum, g) => sum + g.gross, 0);
|
||||
const refNote =
|
||||
kind === "storno"
|
||||
? "vollständige Stornierung des ursprünglichen Rechnungsbetrags (inkl. Versand)."
|
||||
: "Gutschrift für die zurückgesendeten Artikel — ohne Versandkosten, der ursprüngliche Rabatt bleibt unverändert bei den behaltenen Artikeln.";
|
||||
const deliveryLine =
|
||||
order.deliveryMethod === "address" ? order.street : `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`;
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.headerBand}>
|
||||
<Text style={styles.wordmark}>einfach produktiv.</Text>
|
||||
<Text style={styles.kindLabel}>{kindLabel.toUpperCase()}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.body}>
|
||||
<Text style={styles.refLine}>
|
||||
{kindLabel} zu Rechnung Nr. {order.invoiceNumber} vom {formatDate(order.invoiceIssuedAt)} (Bestellung {order.orderNumber}) — {refNote}
|
||||
</Text>
|
||||
|
||||
<View style={styles.addressRow}>
|
||||
<View style={styles.addressBlock}>
|
||||
<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={styles.addressBlock}>
|
||||
<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>
|
||||
</View>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
<View style={styles.metaBox}>
|
||||
<Text style={styles.metaLabel}>{kindLabel === "Gutschrift" ? "Gutschrift-Nr." : "Storno-Nr."}</Text>
|
||||
<Text style={styles.metaValue}>{order.correctionInvoiceNumber}</Text>
|
||||
</View>
|
||||
<View style={styles.metaBox}>
|
||||
<Text style={styles.metaLabel}>Datum</Text>
|
||||
<Text style={styles.metaValue}>{formatDate(order.correctionInvoiceIssuedAt)}</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>
|
||||
{lines.map(({ item, effectiveQuantity }, 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}>{effectiveQuantity}</Text>
|
||||
<Text style={styles.colPrice}>{formatPrice(item.unitPrice)}</Text>
|
||||
<Text style={styles.colTotal}>-{formatPrice(effectiveQuantity * item.unitPrice)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.summary}>
|
||||
<View style={styles.summaryBox}>
|
||||
{/* Storno reverses the full original invoice, shipping
|
||||
included (see this file's top-of-file comment on why the
|
||||
two kinds differ) — shown as its own line instead of
|
||||
silently folded into the tax-rate groups below, same as
|
||||
the original invoice's own Versand row. Gutschrift never
|
||||
reverses shipping, so this never renders for it. */}
|
||||
{kind === "storno" && order.shippingCost > 0 && (
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Versand</Text>
|
||||
<Text style={styles.summaryValue}>-{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(grandTotal)}</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>
|
||||
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>Bankverbindung (für Überweisung): {seller.bankDetails}</Text> : null}
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderCorrectionInvoicePdf(kind: CorrectionInvoiceKind, order: CorrectionInvoiceOrder, seller: InvoiceSeller): Promise<Buffer> {
|
||||
return renderToBuffer(<CorrectionInvoiceDocument kind={kind} order={order} seller={seller} />);
|
||||
}
|
||||
|
||||
// Exported for unit testing (see src/__tests__/correctionInvoicePdf.test.ts)
|
||||
// — the actual money math, independent of the PDF rendering.
|
||||
export const __testables = { resolveLineItems, groupByTaxRate };
|
||||
@@ -0,0 +1,19 @@
|
||||
export function formatPrice(amount: number): string {
|
||||
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(amount);
|
||||
}
|
||||
|
||||
const MONTHS_DE = [
|
||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||
];
|
||||
|
||||
// "03. Juli 2025" — spelled-out German month, not Intl.DateTimeFormat, so
|
||||
// the exact format doesn't depend on the runtime's ICU locale data. The
|
||||
// one canonical date formatter for every invoice/correction-invoice
|
||||
// document — see this package's README for the numeric-vs-spelled-out
|
||||
// drift this consolidation fixed.
|
||||
export function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${day}. ${MONTHS_DE[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export { computeTaxBreakdown, type TaxBreakdownLine, type TaxBreakdownGroup } from "./taxBreakdown";
|
||||
export { formatPrice, formatDate } from "./formatters";
|
||||
export type { InvoiceSeller } from "./seller";
|
||||
export {
|
||||
InvoiceDocument,
|
||||
renderInvoicePdf,
|
||||
SAMPLE_INVOICE_ORDER,
|
||||
type InvoiceItem,
|
||||
type InvoiceOrder,
|
||||
} from "./invoicePdf";
|
||||
export {
|
||||
renderCorrectionInvoicePdf,
|
||||
type CorrectionInvoiceKind,
|
||||
type CorrectionInvoiceItem,
|
||||
type CorrectionInvoiceOrder,
|
||||
} from "./correctionInvoicePdf";
|
||||
@@ -0,0 +1,351 @@
|
||||
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>
|
||||
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>Bankverbindung (für Überweisung): {seller.bankDetails}</Text> : null}
|
||||
</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 };
|
||||
@@ -0,0 +1,21 @@
|
||||
// Shared by both invoicePdf.tsx and correctionInvoicePdf.tsx — every
|
||||
// document type here renders the same seller/"Anbieterkennzeichnung"
|
||||
// footer, sourced from the Payload backend's `company-settings` collection
|
||||
// (see the payload repo's src/lib/sellerInfo.ts).
|
||||
export type InvoiceSeller = {
|
||||
sellerName: string;
|
||||
sellerStreet: string;
|
||||
sellerZip: string;
|
||||
sellerCity: string;
|
||||
sellerCountry: string;
|
||||
sellerEmail: string;
|
||||
vatId: string;
|
||||
taxRatePercent: number;
|
||||
bankDetails?: string | null;
|
||||
// Pflichtangaben in Geschäftsbriefen for registered legal forms (§37a
|
||||
// HGB / §35a GmbHG) — optional because a sole proprietorship (the
|
||||
// default legalForm in company-settings) has neither.
|
||||
registerCourt?: string | null;
|
||||
registerNumber?: string | null;
|
||||
managingDirector?: string | null;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
export type TaxBreakdownLine = { quantity: number; unitPrice: number; taxRatePercent: number };
|
||||
export type TaxBreakdownGroup = { rate: number; net: number; tax: number; gross: number };
|
||||
|
||||
// Groups line items by their effective VAT rate, then scales each group's
|
||||
// gross total by however much shipping/discount moved the grand total away
|
||||
// from the raw item subtotal — proportional to that group's own share of
|
||||
// the subtotal, not a flat split. Passing discountAmount=0 and
|
||||
// shippingCost=0 (e.g. a Gutschrift, which excludes both) collapses `scale`
|
||||
// to 1, i.e. no adjustment at all.
|
||||
export function computeTaxBreakdown(
|
||||
items: TaxBreakdownLine[],
|
||||
subtotal: number,
|
||||
discountAmount: number,
|
||||
shippingCost: number,
|
||||
): TaxBreakdownGroup[] {
|
||||
const groups = new Map<number, number>();
|
||||
for (const item of items) {
|
||||
const lineGross = item.quantity * item.unitPrice;
|
||||
groups.set(item.taxRatePercent, (groups.get(item.taxRatePercent) ?? 0) + lineGross);
|
||||
}
|
||||
const scale = subtotal > 0 ? (subtotal - discountAmount + shippingCost) / 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);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user