Redesign invoice PDFs, add correction-invoice downloads, return reasons, and per-product tax/bundle support

Invoice + Stornorechnung/Gutschrift PDFs get a modern header-band layout,
a "bereits beglichen" badge for immediately-paid orders, labelled bank
details, and a per-tax-rate summary breakdown. Correction invoices can
now be re-downloaded from the account (regenerated deterministically,
not stored as files, same approach as the original invoice). Return
requests capture a reason. Products can define bundles (bundleItems) and
a per-product VAT rate override, both snapshotted onto order items.
This commit is contained in:
Marco
2026-07-22 10:31:22 +00:00
parent 04cc69f98b
commit 179b59d73d
15 changed files with 723 additions and 222 deletions
+46 -6
View File
@@ -1,13 +1,28 @@
import { NextResponse } from "next/server";
import type { CartItem } from "../../lib/cart";
import { getShippingMethods, getPaymentMethods } from "../../lib/payload";
import { getShippingMethods, getPaymentMethods, getInvoiceSettings } from "../../lib/payload";
import { validateDiscountCode, redeemDiscountCode } from "../../lib/discountServer";
import { createOrder } from "../../lib/orderServer";
import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth";
import { fetchProductsBySlug } from "../../lib/productsServer";
import { fetchProductsBySlug, type RawProduct } from "../../lib/productsServer";
import { sendCriticalAlert } from "../../lib/alertAdmin";
import { sendOrderConfirmationEmail } from "../../lib/orderEmail";
// A bundle's `bundleItems` is only ever resolved once, right here, into a
// plain readable string — the order line stores this snapshot
// (`items[].bundleContents`), not a structured sub-list, so a later change
// to the bundle's own composition can never rewrite what a past order
// actually contained. `product.bundleItems[].product` is a relationship
// resolved by fetchProductsBySlug's depth:2 fetch — {id, name} objects
// when populated, a bare id if depth somehow didn't reach it (skipped).
function describeBundleContents(product: RawProduct): string | null {
if (!product.bundleItems || product.bundleItems.length === 0) return null;
return product.bundleItems
.map((line) => (typeof line.product === "object" ? `${line.quantity}× ${line.product.name}` : null))
.filter((s): s is string => Boolean(s))
.join(", ");
}
type CheckoutBody = {
cart: CartItem[];
shippingMethodId: number;
@@ -87,13 +102,30 @@ export async function POST(request: Request) {
}
// Re-price everything server-side — never trust client-submitted prices.
const productsBySlug = await fetchProductsBySlug();
const items: { productId: number; productName: string; quantity: number; unitPrice: number; imageUrl: string | null }[] = [];
const [productsBySlug, invoiceSettings] = await Promise.all([fetchProductsBySlug(), getInvoiceSettings()]);
const defaultTaxRate = invoiceSettings?.taxRatePercent ?? 19;
const items: {
productId: number;
productName: string;
quantity: number;
unitPrice: number;
imageUrl: string | null;
taxRatePercent: number;
bundleContents: string | null;
}[] = [];
for (const line of body.cart) {
const product = productsBySlug.get(line.id);
if (!product) return NextResponse.json({ ok: false, reason: "Ein Artikel im Warenkorb ist nicht mehr verfügbar." }, { status: 400 });
const imageUrl = typeof product.image === "object" && product.image ? product.image.url : null;
items.push({ productId: product.id, productName: product.name, quantity: line.qty, unitPrice: product.price, imageUrl });
items.push({
productId: product.id,
productName: product.name,
quantity: line.qty,
unitPrice: product.price,
imageUrl,
taxRatePercent: product.taxRatePercent ?? defaultTaxRate,
bundleContents: describeBundleContents(product),
});
}
const subtotal = items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0);
@@ -176,7 +208,15 @@ export async function POST(request: Request) {
zip: body.zip,
city: body.city,
country: body.country,
items: items.map((i) => ({ productName: i.productName, quantity: i.quantity, unitPrice: i.unitPrice, imageUrl: i.imageUrl })),
paymentMethodTitle: paymentMethod.title,
items: items.map((i) => ({
productName: i.productName,
quantity: i.quantity,
unitPrice: i.unitPrice,
imageUrl: i.imageUrl,
taxRatePercent: i.taxRatePercent,
bundleContents: i.bundleContents,
})),
subtotal,
shippingCost,
discountAmount,