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:
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
|
||||
import { generateCorrectionInvoicePdf, getSellerForInvoice } from "../../../../../lib/invoiceData";
|
||||
|
||||
// On-demand download for "Stornorechnung/Gutschrift herunterladen" on
|
||||
// /konto/bestellungen/[orderNumber]. The real document was generated once
|
||||
// by Payload's Orders.ts afterChange hook and emailed at the moment of the
|
||||
// status change — this regenerates the identical PDF from the order's own
|
||||
// stored correctionInvoiceNumber/correctionInvoiceIssuedAt (immutable once
|
||||
// set) rather than storing the file anywhere, same approach as the
|
||||
// original invoice's own download route.
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const { orderNumber } = await params;
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
if (!order.correctionInvoiceNumber || !order.correctionInvoiceIssuedAt || !order.invoiceNumber || !order.invoiceIssuedAt) {
|
||||
return NextResponse.json({ ok: false, reason: "Für diese Bestellung liegt keine Korrekturrechnung vor." }, { status: 404 });
|
||||
}
|
||||
const kind = order.status === "returned" ? "gutschrift" : "storno";
|
||||
|
||||
const seller = await getSellerForInvoice();
|
||||
const pdf = await generateCorrectionInvoicePdf(
|
||||
kind,
|
||||
{
|
||||
orderNumber: order.orderNumber,
|
||||
invoiceNumber: order.invoiceNumber,
|
||||
invoiceIssuedAt: order.invoiceIssuedAt,
|
||||
correctionInvoiceNumber: order.correctionInvoiceNumber,
|
||||
correctionInvoiceIssuedAt: order.correctionInvoiceIssuedAt,
|
||||
customerFirstName: order.customerFirstName,
|
||||
customerLastName: order.customerLastName,
|
||||
deliveryMethod: order.deliveryMethod,
|
||||
street: order.street,
|
||||
packstationNumber: order.packstationNumber,
|
||||
postNumber: order.postNumber,
|
||||
zip: order.zip,
|
||||
city: order.city,
|
||||
country: order.country,
|
||||
items: order.items,
|
||||
subtotal: order.subtotal,
|
||||
shippingCost: order.shippingCost,
|
||||
discountAmount: order.discountAmount,
|
||||
total: order.total,
|
||||
},
|
||||
seller,
|
||||
);
|
||||
if (!pdf) return NextResponse.json({ ok: false, reason: "Korrekturrechnung konnte nicht erzeugt werden." }, { status: 500 });
|
||||
|
||||
const filename = kind === "storno" ? `Stornorechnung-${order.correctionInvoiceNumber}.pdf` : `Gutschrift-${order.correctionInvoiceNumber}.pdf`;
|
||||
return new NextResponse(new Uint8Array(pdf), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
|
||||
import { generateInvoicePdf } from "../../../../../lib/invoiceData";
|
||||
import { generateInvoicePdf, getSellerForInvoice } from "../../../../../lib/invoiceData";
|
||||
|
||||
// On-demand download for "Rechnung herunterladen" on
|
||||
// /konto/bestellungen/[orderNumber] — reuses the exact same render call as
|
||||
@@ -18,26 +18,31 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde
|
||||
return NextResponse.json({ ok: false, reason: "Für diese Bestellung liegt noch keine Rechnung vor." }, { status: 404 });
|
||||
}
|
||||
|
||||
const pdf = await generateInvoicePdf({
|
||||
orderNumber: order.orderNumber,
|
||||
invoiceNumber: order.invoiceNumber,
|
||||
invoiceIssuedAt: order.invoiceIssuedAt,
|
||||
customerFirstName: order.customerFirstName,
|
||||
customerLastName: order.customerLastName,
|
||||
deliveryMethod: order.deliveryMethod,
|
||||
street: order.street,
|
||||
packstationNumber: order.packstationNumber,
|
||||
postNumber: order.postNumber,
|
||||
zip: order.zip,
|
||||
city: order.city,
|
||||
country: order.country,
|
||||
items: order.items,
|
||||
subtotal: order.subtotal,
|
||||
shippingCost: order.shippingCost,
|
||||
discountAmount: order.discountAmount,
|
||||
discountCode: order.discountCode,
|
||||
total: order.total,
|
||||
});
|
||||
const seller = await getSellerForInvoice();
|
||||
const pdf = await generateInvoicePdf(
|
||||
{
|
||||
orderNumber: order.orderNumber,
|
||||
invoiceNumber: order.invoiceNumber,
|
||||
invoiceIssuedAt: order.invoiceIssuedAt,
|
||||
customerFirstName: order.customerFirstName,
|
||||
customerLastName: order.customerLastName,
|
||||
deliveryMethod: order.deliveryMethod,
|
||||
street: order.street,
|
||||
packstationNumber: order.packstationNumber,
|
||||
postNumber: order.postNumber,
|
||||
zip: order.zip,
|
||||
city: order.city,
|
||||
country: order.country,
|
||||
paymentMethodTitle: order.paymentMethodTitle,
|
||||
items: order.items,
|
||||
subtotal: order.subtotal,
|
||||
shippingCost: order.shippingCost,
|
||||
discountAmount: order.discountAmount,
|
||||
discountCode: order.discountCode,
|
||||
total: order.total,
|
||||
},
|
||||
seller,
|
||||
);
|
||||
if (!pdf) return NextResponse.json({ ok: false, reason: "Rechnung konnte nicht erzeugt werden." }, { status: 500 });
|
||||
|
||||
return new NextResponse(new Uint8Array(pdf), {
|
||||
|
||||
@@ -21,6 +21,10 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ or
|
||||
if (action !== "cancel" && action !== "request-return") {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültige Aktion." }, { status: 400 });
|
||||
}
|
||||
const returnReason = typeof body?.returnReason === "string" ? body.returnReason.trim() : "";
|
||||
if (action === "request-return" && !returnReason) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte kurz angeben, warum du zurücksenden möchtest." }, { status: 400 });
|
||||
}
|
||||
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
@@ -28,6 +32,6 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ or
|
||||
return NextResponse.json({ ok: false, reason: "Diese Aktion ist für diese Bestellung gerade nicht möglich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await requestOrderStatusChange(session.token, order.id, action);
|
||||
const result = await requestOrderStatusChange(session.token, order.id, action, returnReason || undefined);
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user