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
@@ -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 });
}