diff --git a/app/api/account/orders/[orderNumber]/correction-invoice/route.ts b/app/api/account/orders/[orderNumber]/correction-invoice/route.ts new file mode 100644 index 0000000..f480d58 --- /dev/null +++ b/app/api/account/orders/[orderNumber]/correction-invoice/route.ts @@ -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}"`, + }, + }); +} diff --git a/app/api/account/orders/[orderNumber]/invoice/route.ts b/app/api/account/orders/[orderNumber]/invoice/route.ts index b170539..7f03129 100644 --- a/app/api/account/orders/[orderNumber]/invoice/route.ts +++ b/app/api/account/orders/[orderNumber]/invoice/route.ts @@ -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), { diff --git a/app/api/account/orders/[orderNumber]/route.ts b/app/api/account/orders/[orderNumber]/route.ts index 3ef6d74..433c397 100644 --- a/app/api/account/orders/[orderNumber]/route.ts +++ b/app/api/account/orders/[orderNumber]/route.ts @@ -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 }); } diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts index 9b16a0f..47a71a9 100644 --- a/app/api/checkout/route.ts +++ b/app/api/checkout/route.ts @@ -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, diff --git a/app/email-preview/[type]/components/LiveEmailPreviewClient.tsx b/app/email-preview/[type]/components/LiveEmailPreviewClient.tsx index 8e97982..f25ea89 100644 --- a/app/email-preview/[type]/components/LiveEmailPreviewClient.tsx +++ b/app/email-preview/[type]/components/LiveEmailPreviewClient.tsx @@ -6,6 +6,7 @@ import { renderPasswordResetHtml, renderOrderStatusHtml, ORDER_STATUS_EMAIL_ICON, + DEFAULT_COMPANY_LINE, SAMPLE_ORDER, type EmailTemplateContent, } from "../../../lib/emailTemplates"; @@ -36,14 +37,15 @@ export function LiveEmailPreviewClient({ const html = type === "order-confirmation" - ? renderOrderConfirmationHtml(data, SAMPLE_ORDER) + ? renderOrderConfirmationHtml(data, SAMPLE_ORDER, DEFAULT_COMPANY_LINE) : type === "password-reset" - ? renderPasswordResetHtml(data, "https://einfach-produktiv.mk360.de/konto/passwort-zuruecksetzen?token=beispiel-token") + ? renderPasswordResetHtml(data, "https://einfach-produktiv.mk360.de/konto/passwort-zuruecksetzen?token=beispiel-token", DEFAULT_COMPANY_LINE) : renderOrderStatusHtml( data, ORDER_STATUS_EMAIL_ICON[type] ?? "✓", SAMPLE_ORDER.orderNumber, `https://einfach-produktiv.mk360.de/konto/bestellungen/${SAMPLE_ORDER.orderNumber}`, + DEFAULT_COMPANY_LINE, ); return ( diff --git a/app/konto/bestellungen/[orderNumber]/components/OrderActionButton.tsx b/app/konto/bestellungen/[orderNumber]/components/OrderActionButton.tsx index 8c51050..8e47f7e 100644 --- a/app/konto/bestellungen/[orderNumber]/components/OrderActionButton.tsx +++ b/app/konto/bestellungen/[orderNumber]/components/OrderActionButton.tsx @@ -6,7 +6,6 @@ import { useRouter } from "next/navigation"; const LABEL = { cancel: "Bestellung stornieren", "request-return": "Rücksendung anfragen" } as const; const CONFIRM = { cancel: "Bestellung wirklich stornieren?", - "request-return": "Rücksendung wirklich anfragen? Wir melden uns mit den nächsten Schritten.", } as const; export function OrderActionButton({ orderNumber, action }: { orderNumber: string; action: "cancel" | "request-return" }) { @@ -15,14 +14,29 @@ export function OrderActionButton({ orderNumber, action }: { orderNumber: string const [error, setError] = useState(null); async function handleClick() { - if (!window.confirm(CONFIRM[action])) return; + let returnReason: string | undefined; + if (action === "cancel") { + if (!window.confirm(CONFIRM.cancel)) return; + } else { + // A short reason helps quality/assortment decisions later (see the + // Payload README's Orders.ts section) — prompt() rather than a + // custom form, same "plain browser dialog" pattern already used for + // cancel's confirm() above. + const input = window.prompt("Kurz gesagt, warum möchtest du die Bestellung zurücksenden?"); + if (input === null) return; + returnReason = input.trim(); + if (!returnReason) { + setError("Bitte kurz einen Grund angeben."); + return; + } + } setLoading(true); setError(null); try { const res = await fetch(`/api/account/orders/${encodeURIComponent(orderNumber)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action }), + body: JSON.stringify({ action, returnReason }), }); const data = await res.json(); if (!data.ok) { diff --git a/app/konto/bestellungen/[orderNumber]/page.tsx b/app/konto/bestellungen/[orderNumber]/page.tsx index a61a136..8b353e9 100644 --- a/app/konto/bestellungen/[orderNumber]/page.tsx +++ b/app/konto/bestellungen/[orderNumber]/page.tsx @@ -67,10 +67,13 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
{order.items.map((item, i) => ( -
-

- {item.quantity} × {item.productName} -

+
+
+

+ {item.quantity} × {item.productName} +

+ {item.bundleContents &&

{item.bundleContents}

} +

{formatPrice(item.quantity * item.unitPrice)}

))} @@ -108,15 +111,32 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
- {order.invoiceNumber && ( - - Rechnung herunterladen ({order.invoiceNumber}) - + {order.returnReason && ( +
+

Grund der Rücksendung

+

{order.returnReason}

+
)} +
+ {order.invoiceNumber && ( + + Rechnung herunterladen ({order.invoiceNumber}) + + )} + {order.correctionInvoiceNumber && ( + + {order.status === "returned" ? "Gutschrift" : "Stornorechnung"} herunterladen ({order.correctionInvoiceNumber}) + + )} +
+ {action && } diff --git a/app/lib/correctionInvoicePdf.tsx b/app/lib/correctionInvoicePdf.tsx new file mode 100644 index 0000000..f0295d0 --- /dev/null +++ b/app/lib/correctionInvoicePdf.tsx @@ -0,0 +1,234 @@ +import React from "react"; +import { Document, Page, View, Text, StyleSheet, renderToBuffer } from "@react-pdf/renderer"; +import { formatDate } from "./format"; + +// Frontend port of the Payload backend's src/lib/correctionInvoicePdf.tsx +// — the *real* Stornorechnung/Gutschrift is generated and emailed from +// Payload's own Orders.ts afterChange hook (that's where the status +// transition and the correction invoice NUMBER are actually assigned). +// This copy exists only so a customer can re-download the same document +// later from /konto/bestellungen/[orderNumber] without it having been +// stored as a file anywhere — same "deterministic regeneration, not file +// storage" approach already used for the original invoice (see +// invoicePdf.tsx): correctionInvoiceNumber/correctionInvoiceIssuedAt are +// immutable once set, so re-rendering from the order's own stored data +// always reproduces the identical document. +const BRAND = "#f6a701"; +const BRAND_TINT = "#fdf1d9"; +const TEXT_MUTED = "#6b6b69"; +const BORDER = "#e5e0d8"; +const BG_MUTED = "#f8f5f1"; + +const styles = StyleSheet.create({ + page: { padding: 0, fontSize: 10, fontFamily: "Helvetica", color: "#1a1a18" }, + headerBand: { backgroundColor: BRAND_TINT, padding: 32, flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, + wordmark: { fontFamily: "Helvetica-Bold", fontSize: 14 }, + kindLabel: { fontFamily: "Helvetica-Bold", fontSize: 22, color: BRAND, letterSpacing: 1 }, + body: { padding: 32 }, + 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 }, + 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" }, + footer: { borderTopWidth: 1, borderTopColor: BORDER, paddingTop: 12, fontSize: 8, color: TEXT_MUTED }, +}); + +function formatPrice(amount: number): string { + return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(amount); +} + +export type CorrectionInvoiceKind = "storno" | "gutschrift"; + +export type CorrectionInvoiceItem = { productName: string; quantity: number; unitPrice: number; taxRatePercent: number; bundleContents?: 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; +}; + +export type InvoiceSeller = { + sellerName: string; + sellerStreet: string; + sellerZip: string; + sellerCity: string; + sellerCountry: string; + sellerEmail: string; + vatId: string; + taxRatePercent: number; + bankDetails?: string | null; +}; + +function groupByTaxRate( + order: CorrectionInvoiceOrder, + defaultRate: number, +): { rate: number; net: number; tax: number; gross: number }[] { + const groups = new Map(); + for (const item of order.items) { + const rate = item.taxRatePercent ?? defaultRate; + const lineGross = item.quantity * item.unitPrice; + groups.set(rate, (groups.get(rate) ?? 0) + lineGross); + } + const scale = order.subtotal > 0 ? (order.subtotal - order.discountAmount + order.shippingCost) / order.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); +} + +function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionInvoiceKind; order: CorrectionInvoiceOrder; seller: InvoiceSeller }) { + const kindLabel = kind === "storno" ? "Stornorechnung" : "Gutschrift"; + const rateGroups = groupByTaxRate(order, seller.taxRatePercent); + const deliveryLine = + order.deliveryMethod === "address" ? order.street : `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`; + + return ( + + + + einfach produktiv. + {kindLabel.toUpperCase()} + + + + + {kindLabel} zu Rechnung Nr. {order.invoiceNumber} vom {formatDate(order.invoiceIssuedAt)} (Bestellung {order.orderNumber}) — vollständige + Stornierung des ursprünglichen Rechnungsbetrags. + + + + + Von + {seller.sellerName} + {seller.sellerStreet} + + {seller.sellerZip} {seller.sellerCity} + + {seller.sellerCountry} + + + An + + {order.customerFirstName} {order.customerLastName} + + {deliveryLine} + + {order.zip} {order.city} + + {order.country} + + + + + + {kindLabel === "Gutschrift" ? "Gutschrift-Nr." : "Storno-Nr."} + {order.correctionInvoiceNumber} + + + Datum + {formatDate(order.correctionInvoiceIssuedAt)} + + + USt-IdNr. + {seller.vatId} + + + + + + Artikel + Menge + Einzelpreis + Betrag + + {order.items.map((item, i) => ( + + + {item.productName} + {item.bundleContents ? {item.bundleContents} : null} + + {item.quantity} + {formatPrice(item.unitPrice)} + -{formatPrice(item.quantity * item.unitPrice)} + + ))} + + + + + {rateGroups.map((g) => ( + + + Netto ({g.rate}%) + -{formatPrice(g.net)} + + + zzgl. {g.rate}% MwSt. + -{formatPrice(g.tax)} + + + ))} + + Gesamt + -{formatPrice(order.total)} + + + + + + + {seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "} + {seller.vatId} + + {seller.bankDetails ? Bankverbindung (für Überweisung): {seller.bankDetails} : null} + + + + + ); +} + +export async function renderCorrectionInvoicePdf(kind: CorrectionInvoiceKind, order: CorrectionInvoiceOrder, seller: InvoiceSeller): Promise { + return renderToBuffer(); +} diff --git a/app/lib/customerAuth.ts b/app/lib/customerAuth.ts index afdc3cb..1e595ea 100644 --- a/app/lib/customerAuth.ts +++ b/app/lib/customerAuth.ts @@ -426,6 +426,8 @@ export type CustomerOrderDetail = CustomerOrder & { id: number; invoiceNumber: string | null; invoiceIssuedAt: string | null; + correctionInvoiceNumber: string | null; + correctionInvoiceIssuedAt: string | null; customerFirstName: string; customerLastName: string; customerEmail: string; @@ -442,7 +444,8 @@ export type CustomerOrderDetail = CustomerOrder & { paymentMethodTitle: string; discountCode: string | null; discountAmount: number; - items: { productName: string; quantity: number; unitPrice: number }[]; + returnReason: string | null; + items: { productName: string; quantity: number; unitPrice: number; taxRatePercent: number; bundleContents: string | null }[]; }; // Access control (Orders.ts) already scopes a customer's own JWT to only @@ -460,8 +463,7 @@ export async function getCustomerOrderDetail(token: string, customerId: number, cache: "no-store", }); if (!res.ok) return null; - const data: { docs?: (Omit & { items: { productName: string; quantity: number; unitPrice: number }[] })[] } = - await res.json(); + const data: { docs?: Omit[] } = await res.json(); const doc = data.docs?.[0]; if (!doc) return null; return { ...doc, itemCount: doc.items.length }; @@ -475,12 +477,20 @@ export async function requestOrderStatusChange( token: string, orderId: number, action: "cancel" | "request-return", + returnReason?: string, ): Promise<{ ok: true } | { ok: false; reason: string }> { const status = action === "cancel" ? "cancelled" : "return_requested"; + // Orders.ts's beforeChange hook only allows a customer-authenticated + // update to touch `status` (plus `returnReason`, but only together with + // this exact transition — see that hook's own comment) — omitting the + // key entirely for `cancel` rather than sending `returnReason: undefined` + // keeps that request shaped exactly like before this field existed. + const body: { status: string; returnReason?: string } = { status }; + if (action === "request-return" && returnReason) body.returnReason = returnReason; const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}`, { method: "PATCH", headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" }, - body: JSON.stringify({ status }), + body: JSON.stringify(body), }); if (!res.ok) { const data = await res.json().catch(() => null); diff --git a/app/lib/emailTemplates.ts b/app/lib/emailTemplates.ts index f29f730..4775527 100644 --- a/app/lib/emailTemplates.ts +++ b/app/lib/emailTemplates.ts @@ -64,11 +64,18 @@ function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } +// Live-Preview-only fallback (no real order/invoice-settings fetch there, +// see /email-preview/[type]) — the actual send always passes the real +// " · " from invoice-settings (see orderEmail.ts). +export const DEFAULT_COMPANY_LINE = "einfach produktiv · admin@mk360.de"; + // `icon`: a single glyph rendered inside the brand-tinted circle up top — // "✓" for order-confirmation, "✉" for password-reset. Same circular // treatment as the confirmation page's own success icon and its delivery- -// status panel icon. -function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerText: string | null): string { +// status panel icon. `companyLine`: sourced from Payload's invoice-settings +// (sellerName/sellerEmail), not hardcoded — same admin-editable business +// data the invoice PDFs already use, see orderEmail.ts. +function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerText: string | null, companyLine: string): string { return ` @@ -109,7 +116,7 @@ function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerT
${footerText ? `

${escapeHtml(footerText)}

` : ""} -

einfach produktiv · admin@mk360.de

+

${escapeHtml(companyLine)}

@@ -121,6 +128,8 @@ export type OrderConfirmationItem = { quantity: number; unitPrice: number; imageUrl?: string | null; + bundleContents?: string | null; + taxRatePercent: number; }; export type OrderConfirmationData = { orderNumber: string; @@ -142,8 +151,10 @@ export const SAMPLE_ORDER: OrderConfirmationData = { quantity: 1, unitPrice: 12.9, imageUrl: "https://payload.mk360.de/api/media/file/product-todo-karten.png", + bundleContents: null, + taxRatePercent: 19, }, - { productName: "Wochenplaner – Überblick", quantity: 2, unitPrice: 14.9, imageUrl: null }, + { productName: "Wochenplaner – Überblick", quantity: 2, unitPrice: 14.9, imageUrl: null, taxRatePercent: 19 }, ], subtotal: 42.7, shippingCost: 0, @@ -152,7 +163,7 @@ export const SAMPLE_ORDER: OrderConfirmationData = { total: 37.7, }; -export function renderOrderConfirmationHtml(template: EmailTemplateContent, order: OrderConfirmationData): string { +export function renderOrderConfirmationHtml(template: EmailTemplateContent, order: OrderConfirmationData, companyLine: string): string { const rows = order.items .map( (item) => ` @@ -163,7 +174,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde : `
` } - ${escapeHtml(item.productName)} × ${item.quantity} + ${escapeHtml(item.productName)} × ${item.quantity}${item.bundleContents ? `
${escapeHtml(item.bundleContents)}` : ""} ${formatPrice(item.quantity * item.unitPrice)} `, ) @@ -191,7 +202,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde `; - return emailShell("✓", escapeHtml(template.heading), body, template.footerText); + return emailShell("✓", escapeHtml(template.heading), body, template.footerText, companyLine); } // Icon shown per status — matches the Payload-side send exactly (see @@ -206,7 +217,13 @@ export const ORDER_STATUS_EMAIL_ICON: Record = { "order-returned": "✓", }; -export function renderOrderStatusHtml(template: EmailTemplateContent, icon: string, orderNumber: string, orderUrl: string): string { +export function renderOrderStatusHtml( + template: EmailTemplateContent, + icon: string, + orderNumber: string, + orderUrl: string, + companyLine: string, +): string { const body = ` ${paragraphs(template.bodyText, "center")}

Bestellnummer ${escapeHtml(orderNumber)}

@@ -219,10 +236,10 @@ export function renderOrderStatusHtml(template: EmailTemplateContent, icon: stri `; - return emailShell(icon, escapeHtml(template.heading), body, template.footerText); + return emailShell(icon, escapeHtml(template.heading), body, template.footerText, companyLine); } -export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string): string { +export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string, companyLine: string): string { const body = ` ${paragraphs(template.bodyText, "center")} @@ -236,5 +253,5 @@ export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl

Der Link ist 1 Stunde gültig.

`; - return emailShell("✉", escapeHtml(template.heading), body, template.footerText); + return emailShell("✉", escapeHtml(template.heading), body, template.footerText, companyLine); } diff --git a/app/lib/invoiceData.ts b/app/lib/invoiceData.ts index 6d287fd..b9e25db 100644 --- a/app/lib/invoiceData.ts +++ b/app/lib/invoiceData.ts @@ -1,15 +1,45 @@ -import { getInvoiceSettings } from "./payload"; +import { getInvoiceSettings, type InvoiceSettings } from "./payload"; import { renderInvoicePdf, type InvoiceOrder } from "./invoicePdf"; +import { renderCorrectionInvoicePdf, type CorrectionInvoiceKind, type CorrectionInvoiceOrder } from "./correctionInvoicePdf"; + +export type { InvoiceSettings }; + +// Fetched once by callers that need seller data for more than one purpose +// in the same request (e.g. orderEmail.ts also needs it for the email +// footer's companyLine) — avoids a second identical invoice-settings round +// trip, unlike calling getInvoiceSettings() again inside each generator. +export async function getSellerForInvoice(): Promise { + return getInvoiceSettings(); +} // Shared by app/lib/orderEmail.ts (checkout — attaches to the confirmation // mail) and app/api/account/orders/[orderNumber]/invoice/route.ts (on-demand -// download) — same seller lookup + render call both times, so what a -// customer downloads later always matches what they were emailed. -export async function generateInvoicePdf(order: InvoiceOrder): Promise { - const seller = await getInvoiceSettings(); +// download) — same render call both times, so what a customer downloads +// later always matches what they were emailed. `seller` is passed in +// rather than fetched here, so a caller that already has it (see above) +// doesn't fetch it twice. +export async function generateInvoicePdf(order: InvoiceOrder, seller: InvoiceSettings | null): Promise { if (!seller) { console.error("generateInvoicePdf: no invoice-settings row found for tenant"); return null; } return renderInvoicePdf(order, seller); } + +// Frontend-side regeneration for the "Stornorechnung/Gutschrift +// herunterladen" download button — the real document was already +// generated once (Payload's Orders.ts afterChange hook) and emailed; this +// reproduces the identical PDF from the order's own stored +// correctionInvoiceNumber/correctionInvoiceIssuedAt, same "deterministic +// regeneration, not file storage" approach as the original invoice. +export async function generateCorrectionInvoicePdf( + kind: CorrectionInvoiceKind, + order: CorrectionInvoiceOrder, + seller: InvoiceSettings | null, +): Promise { + if (!seller) { + console.error("generateCorrectionInvoicePdf: no invoice-settings row found for tenant"); + return null; + } + return renderCorrectionInvoicePdf(kind, order, seller); +} diff --git a/app/lib/invoicePdf.tsx b/app/lib/invoicePdf.tsx index b1aea84..1cbb470 100644 --- a/app/lib/invoicePdf.tsx +++ b/app/lib/invoicePdf.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Document, Page, View, Text, StyleSheet, renderToBuffer } from "@react-pdf/renderer"; -import { formatPrice, formatDate } from "./format"; +import { formatDate } from "./format"; // Generated synchronously in the checkout request (see app/lib/orderEmail.ts) // and attached to the order-confirmation email, plus available on-demand via @@ -11,60 +11,58 @@ import { formatPrice, formatDate } from "./format"; // checkout request's own fire-and-forget email step; a font-fetch failure // there is one more way to lose the invoice attachment for no real design // benefit. Same choice as the Payload-side correction-invoice PDF -// (src/lib/correctionInvoicePdf.tsx in the backend repo) — brand color via -// StyleSheet, not Georgia/Playfair. +// (src/lib/correctionInvoicePdf.tsx in the backend repo, kept visually in +// sync with this file by eye — not shared code, two separate deployments). const BRAND = "#f6a701"; +const BRAND_TINT = "#fdf1d9"; const TEXT_MUTED = "#6b6b69"; const BORDER = "#e5e0d8"; +const BG_MUTED = "#f8f5f1"; +const SUCCESS = "#2f8f4e"; +const SUCCESS_TINT = "#e7f5eb"; const styles = StyleSheet.create({ - page: { padding: 48, fontSize: 10, fontFamily: "Helvetica", color: "#1a1a18" }, - wordmark: { fontFamily: "Helvetica-Bold", fontSize: 13, marginBottom: 32 }, - kindLabel: { fontFamily: "Helvetica-Bold", fontSize: 20, color: BRAND, marginBottom: 24 }, - addressRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 28 }, + page: { padding: 0, fontSize: 10, fontFamily: "Helvetica", color: "#1a1a18" }, + headerBand: { backgroundColor: BRAND_TINT, padding: 32, flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, + wordmark: { fontFamily: "Helvetica-Bold", fontSize: 14 }, + kindLabel: { fontFamily: "Helvetica-Bold", fontSize: 22, color: BRAND, letterSpacing: 1 }, + body: { padding: 32 }, + 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", justifyContent: "space-between", marginBottom: 20 }, - metaLabel: { fontSize: 8, color: TEXT_MUTED }, + 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" }, - table: { marginTop: 8, borderTopWidth: 1, borderTopColor: BORDER }, - tableHeader: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: BORDER, paddingVertical: 6 }, - tableRow: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: BORDER, paddingVertical: 6 }, + 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 }, 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" }, - summary: { marginTop: 16, alignItems: "flex-end" }, - summaryRow: { flexDirection: "row", width: 220, justifyContent: "space-between", paddingVertical: 2 }, + 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", - width: 220, - justifyContent: "space-between", - paddingTop: 8, - marginTop: 6, - borderTopWidth: 1, - borderTopColor: BORDER, - }, + 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" }, - footer: { - position: "absolute", - bottom: 40, - left: 48, - right: 48, - fontSize: 8, - color: TEXT_MUTED, - borderTopWidth: 1, - borderTopColor: BORDER, - paddingTop: 12, - }, + footer: { borderTopWidth: 1, borderTopColor: BORDER, paddingTop: 12, fontSize: 8, color: TEXT_MUTED }, }); -export type InvoiceItem = { productName: string; quantity: number; unitPrice: number }; +function formatPrice(amount: number): string { + return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(amount); +} + +export type InvoiceItem = { productName: string; quantity: number; unitPrice: number; taxRatePercent: number; bundleContents?: string | null }; export type InvoiceOrder = { orderNumber: string; @@ -79,6 +77,7 @@ export type InvoiceOrder = { zip: string; city: string; country: string; + paymentMethodTitle: string; items: InvoiceItem[]; subtotal: number; shippingCost: number; @@ -99,114 +98,159 @@ export type InvoiceSeller = { bankDetails?: string | null; }; +// "Ü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 }[] { + const groups = new Map(); + for (const item of order.items) { + const rate = item.taxRatePercent ?? defaultRate; + const lineGross = item.quantity * item.unitPrice; + groups.set(rate, (groups.get(rate) ?? 0) + lineGross); + } + const scale = order.subtotal > 0 ? (order.subtotal - order.discountAmount + order.shippingCost) / order.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); +} + function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller: InvoiceSeller }) { - const rate = seller.taxRatePercent; - const grossTotal = order.total; - const netTotal = grossTotal / (1 + rate / 100); - const taxTotal = grossTotal - netTotal; + const rateGroups = groupByTaxRate(order, seller.taxRatePercent); + const paid = isPaidImmediately(order.paymentMethodTitle); const deliveryLine = order.deliveryMethod === "address" ? order.street : `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`; return ( - einfach produktiv. - Rechnung - - - - Von - {seller.sellerName} - {seller.sellerStreet} - - {seller.sellerZip} {seller.sellerCity} - - {seller.sellerCountry} - - - An - - {order.customerFirstName} {order.customerLastName} - - {deliveryLine} - - {order.zip} {order.city} - - {order.country} - + + einfach produktiv. + RECHNUNG - - - Rechnungs-Nr. - {order.invoiceNumber} - - - Datum - {formatDate(order.invoiceIssuedAt)} - - - Bestellnummer - {order.orderNumber} - - - USt-IdNr. - {seller.vatId} - - - - - - Artikel - Menge - Einzelpreis - Betrag - - {order.items.map((item, i) => ( - - {item.productName} - {item.quantity} - {formatPrice(item.unitPrice)} - {formatPrice(item.quantity * item.unitPrice)} + + + + Von + {seller.sellerName} + {seller.sellerStreet} + + {seller.sellerZip} {seller.sellerCity} + + {seller.sellerCountry} - ))} - - - - - Zwischensumme - {formatPrice(order.subtotal)} - - {order.discountAmount > 0 && ( - - Rabatt{order.discountCode ? ` (${order.discountCode})` : ""} - -{formatPrice(order.discountAmount)} + + An + + {order.customerFirstName} {order.customerLastName} + + {deliveryLine} + + {order.zip} {order.city} + + {order.country} - )} - - Versand - {order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)} - - Netto - {formatPrice(netTotal)} - - - zzgl. {rate}% MwSt. - {formatPrice(taxTotal)} - - - Gesamt - {formatPrice(grossTotal)} - - - - - {seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "} - {seller.vatId} - - {seller.bankDetails ? {seller.bankDetails} : null} + + + Rechnungs-Nr. + {order.invoiceNumber} + + + Datum + {formatDate(order.invoiceIssuedAt)} + + + Bestellnummer + {order.orderNumber} + + + USt-IdNr. + {seller.vatId} + + {paid && ( + + ✓ Bereits beglichen ({order.paymentMethodTitle}) + + )} + + + + + Artikel + Menge + Einzelpreis + Betrag + + {order.items.map((item, i) => ( + + + {item.productName} + {item.bundleContents ? {item.bundleContents} : null} + + {item.quantity} + {formatPrice(item.unitPrice)} + {formatPrice(item.quantity * item.unitPrice)} + + ))} + + + + + {order.discountAmount > 0 && ( + + Rabatt{order.discountCode ? ` (${order.discountCode})` : ""} + -{formatPrice(order.discountAmount)} + + )} + + Versand + {order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)} + + {rateGroups.map((g) => ( + + + Netto ({g.rate}%) + {formatPrice(g.net)} + + + zzgl. {g.rate}% MwSt. + {formatPrice(g.tax)} + + + ))} + + Gesamt + {formatPrice(order.total)} + + + + + + + {seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "} + {seller.vatId} + + {seller.bankDetails ? Bankverbindung (für Überweisung): {seller.bankDetails} : null} + diff --git a/app/lib/orderEmail.ts b/app/lib/orderEmail.ts index 4c7fc6b..8d4bbe8 100644 --- a/app/lib/orderEmail.ts +++ b/app/lib/orderEmail.ts @@ -1,7 +1,7 @@ import { transport } from "./mailer"; import { getEmailTemplate } from "./payload"; -import { renderOrderConfirmationHtml, type OrderConfirmationData } from "./emailTemplates"; -import { generateInvoicePdf } from "./invoiceData"; +import { renderOrderConfirmationHtml, DEFAULT_COMPANY_LINE, type OrderConfirmationData } from "./emailTemplates"; +import { generateInvoicePdf, getSellerForInvoice } from "./invoiceData"; import { sendCriticalAlert } from "./alertAdmin"; // Superset of OrderConfirmationData (used for the HTML render) plus the @@ -20,6 +20,7 @@ export type OrderConfirmationEmailData = OrderConfirmationData & { zip: string; city: string; country: string; + paymentMethodTitle: string; }; // Called from app/api/checkout/route.ts right after a successful @@ -40,35 +41,47 @@ export type OrderConfirmationEmailData = OrderConfirmationData & { export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailData, customerEmail: string): Promise { const template = (await getEmailTemplate("order-confirmation")) ?? { subject: "Bestellt! Deine Ruhe kann kommen 🎉", - heading: "Geschafft!", + heading: "Bestellt!", bodyText: "Deine Bestellung ist bei uns eingetrudelt — wir kümmern uns schon liebevoll darum, sie für dich zu packen.", footerText: null, }; - const html = renderOrderConfirmationHtml(template, order); + const seller = await getSellerForInvoice(); + const companyLine = seller ? `${seller.sellerName} · ${seller.sellerEmail}` : DEFAULT_COMPANY_LINE; + const html = renderOrderConfirmationHtml(template, order, companyLine); let attachments: { filename: string; content: Buffer }[] | undefined; try { - 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 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.map((i) => ({ + productName: i.productName, + quantity: i.quantity, + unitPrice: i.unitPrice, + taxRatePercent: i.taxRatePercent, + bundleContents: i.bundleContents ?? null, + })), + subtotal: order.subtotal, + shippingCost: order.shippingCost, + discountAmount: order.discountAmount, + discountCode: order.discountCode, + total: order.total, + }, + seller, + ); if (pdf) attachments = [{ filename: `Rechnung-${order.invoiceNumber}.pdf`, content: pdf }]; else throw new Error("generateInvoicePdf returned null (missing invoice-settings?)"); } catch (err) { diff --git a/app/lib/orderServer.ts b/app/lib/orderServer.ts index 89c8e97..a583658 100644 --- a/app/lib/orderServer.ts +++ b/app/lib/orderServer.ts @@ -24,6 +24,8 @@ export type OrderItemInput = { productName: string; quantity: number; unitPrice: number; + taxRatePercent: number; + bundleContents: string | null; }; export type CreateOrderInput = { @@ -83,6 +85,8 @@ export async function createOrder(input: CreateOrderInput): Promise> { - // depth=1 so `image` resolves to { url } instead of just the media id — - // needed for the order-confirmation email's product thumbnails - // (app/lib/orderEmail.ts), not just re-pricing. - const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, depth: "1", limit: "100" }); + // depth=2, not 1 — `image` only needs depth 1 (order-confirmation + // email's product thumbnails, app/lib/orderEmail.ts), but resolving + // `bundleItems.product.name` (needed to build a bundle's readable + // contents snapshot at checkout, see app/api/checkout/route.ts) is a + // relationship nested inside an array field, one level deeper. + const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, depth: "2", limit: "100" }); const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { cache: "no-store" }); const map = new Map(); if (!res.ok) return map;