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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<string | null>(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) {
|
||||
|
||||
@@ -67,10 +67,13 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
|
||||
<div className="w-full bg-bg-base border border-border rounded-md p-6 flex flex-col gap-3">
|
||||
{order.items.map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-4 w-full">
|
||||
<p className="flex-1 text-body-sm text-text-primary">
|
||||
{item.quantity} × {item.productName}
|
||||
</p>
|
||||
<div key={i} className="flex items-start gap-4 w-full">
|
||||
<div className="flex-1 flex flex-col gap-0.5">
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{item.quantity} × {item.productName}
|
||||
</p>
|
||||
{item.bundleContents && <p className="text-label text-text-muted">{item.bundleContents}</p>}
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary">{formatPrice(item.quantity * item.unitPrice)}</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -108,15 +111,32 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{order.invoiceNumber && (
|
||||
<a
|
||||
href={`/api/account/orders/${encodeURIComponent(order.orderNumber)}/invoice`}
|
||||
className="text-body-sm text-brand hover:underline"
|
||||
>
|
||||
Rechnung herunterladen ({order.invoiceNumber})
|
||||
</a>
|
||||
{order.returnReason && (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="text-label text-text-muted">Grund der Rücksendung</p>
|
||||
<p className="text-body-sm text-text-primary">{order.returnReason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
{order.invoiceNumber && (
|
||||
<a
|
||||
href={`/api/account/orders/${encodeURIComponent(order.orderNumber)}/invoice`}
|
||||
className="text-body-sm text-brand hover:underline"
|
||||
>
|
||||
Rechnung herunterladen ({order.invoiceNumber})
|
||||
</a>
|
||||
)}
|
||||
{order.correctionInvoiceNumber && (
|
||||
<a
|
||||
href={`/api/account/orders/${encodeURIComponent(order.orderNumber)}/correction-invoice`}
|
||||
className="text-body-sm text-brand hover:underline"
|
||||
>
|
||||
{order.status === "returned" ? "Gutschrift" : "Stornorechnung"} herunterladen ({order.correctionInvoiceNumber})
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action && <OrderActionButton orderNumber={order.orderNumber} action={action} />}
|
||||
</Reveal>
|
||||
</main>
|
||||
|
||||
@@ -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<number, number>();
|
||||
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 (
|
||||
<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}) — vollständige
|
||||
Stornierung des ursprünglichen Rechnungsbetrags.
|
||||
</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 style={styles.metaBox}>
|
||||
<Text style={styles.metaLabel}>USt-IdNr.</Text>
|
||||
<Text style={styles.metaValue}>{seller.vatId}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableHeader}>
|
||||
<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.colName}>
|
||||
<Text>{item.productName}</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}>
|
||||
{rateGroups.map((g) => (
|
||||
<React.Fragment key={g.rate}>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Netto ({g.rate}%)</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 style={styles.footer}>
|
||||
<Text>
|
||||
{seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "}
|
||||
{seller.vatId}
|
||||
</Text>
|
||||
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>Bankverbindung (für Überweisung): {seller.bankDetails}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderCorrectionInvoicePdf(kind: CorrectionInvoiceKind, order: CorrectionInvoiceOrder, seller: InvoiceSeller): Promise<Buffer> {
|
||||
return renderToBuffer(<CorrectionInvoiceDocument kind={kind} order={order} seller={seller} />);
|
||||
}
|
||||
+14
-4
@@ -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<CustomerOrderDetail, "itemCount"> & { items: { productName: string; quantity: number; unitPrice: number }[] })[] } =
|
||||
await res.json();
|
||||
const data: { docs?: Omit<CustomerOrderDetail, "itemCount">[] } = 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);
|
||||
|
||||
+28
-11
@@ -64,11 +64,18 @@ function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").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
|
||||
// "<sellerName> · <sellerEmail>" 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 `<body style="margin:0;padding:32px 16px;background:${BG_BASE};font-family:${FONT_SANS};">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;margin:0 auto;">
|
||||
<tr>
|
||||
@@ -109,7 +116,7 @@ function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerT
|
||||
<tr>
|
||||
<td style="padding-top:24px;text-align:center;">
|
||||
${footerText ? `<p style="margin:0 0 8px;font-size:13px;color:${TEXT_MUTED};">${escapeHtml(footerText)}</p>` : ""}
|
||||
<p style="margin:0;font-size:12px;color:${TEXT_MUTED};">einfach produktiv · admin@mk360.de</p>
|
||||
<p style="margin:0;font-size:12px;color:${TEXT_MUTED};">${escapeHtml(companyLine)}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -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) => `<tr>
|
||||
@@ -163,7 +174,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
|
||||
: `<div style="width:44px;height:44px;border-radius:6px;background:${BG_MUTED};"></div>`
|
||||
}
|
||||
</td>
|
||||
<td style="padding:10px 0 10px 12px;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span></td>
|
||||
<td style="padding:10px 0 10px 12px;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span>${item.bundleContents ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">${escapeHtml(item.bundleContents)}</span>` : ""}</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid ${BORDER};text-align:right;white-space:nowrap;font-size:14px;color:${TEXT_PRIMARY};">${formatPrice(item.quantity * item.unitPrice)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
@@ -191,7 +202,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
|
||||
</table>
|
||||
`;
|
||||
|
||||
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<string, string> = {
|
||||
"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")}
|
||||
<p style="text-align:center;font-size:13px;color:${TEXT_MUTED};margin:0 0 4px;">Bestellnummer ${escapeHtml(orderNumber)}</p>
|
||||
@@ -219,10 +236,10 @@ export function renderOrderStatusHtml(template: EmailTemplateContent, icon: stri
|
||||
</table>
|
||||
`;
|
||||
|
||||
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")}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px auto 8px;">
|
||||
@@ -236,5 +253,5 @@ export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl
|
||||
<p style="text-align:center;font-size:12px;color:${TEXT_MUTED};">Der Link ist 1 Stunde gültig.</p>
|
||||
`;
|
||||
|
||||
return emailShell("✉", escapeHtml(template.heading), body, template.footerText);
|
||||
return emailShell("✉", escapeHtml(template.heading), body, template.footerText, companyLine);
|
||||
}
|
||||
|
||||
+35
-5
@@ -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<InvoiceSettings | null> {
|
||||
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<Buffer | null> {
|
||||
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<Buffer | null> {
|
||||
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<Buffer | null> {
|
||||
if (!seller) {
|
||||
console.error("generateCorrectionInvoicePdf: no invoice-settings row found for tenant");
|
||||
return null;
|
||||
}
|
||||
return renderCorrectionInvoicePdf(kind, order, seller);
|
||||
}
|
||||
|
||||
+174
-130
@@ -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<number, number>();
|
||||
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 (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<Text style={styles.wordmark}>einfach produktiv.</Text>
|
||||
<Text style={styles.kindLabel}>Rechnung</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 style={styles.headerBand}>
|
||||
<Text style={styles.wordmark}>einfach produktiv.</Text>
|
||||
<Text style={styles.kindLabel}>RECHNUNG</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
<View>
|
||||
<Text style={styles.metaLabel}>Rechnungs-Nr.</Text>
|
||||
<Text style={styles.metaValue}>{order.invoiceNumber}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.metaLabel}>Datum</Text>
|
||||
<Text style={styles.metaValue}>{formatDate(order.invoiceIssuedAt)}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.metaLabel}>Bestellnummer</Text>
|
||||
<Text style={styles.metaValue}>{order.orderNumber}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.metaLabel}>USt-IdNr.</Text>
|
||||
<Text style={styles.metaValue}>{seller.vatId}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableHeader}>
|
||||
<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} key={i}>
|
||||
<Text style={styles.colName}>{item.productName}</Text>
|
||||
<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 style={styles.body}>
|
||||
<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>
|
||||
|
||||
<View style={styles.summary}>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Zwischensumme</Text>
|
||||
<Text style={styles.summaryValue}>{formatPrice(order.subtotal)}</Text>
|
||||
</View>
|
||||
{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 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 style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Versand</Text>
|
||||
<Text style={styles.summaryValue}>{order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)}</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Netto</Text>
|
||||
<Text style={styles.summaryValue}>{formatPrice(netTotal)}</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>zzgl. {rate}% MwSt.</Text>
|
||||
<Text style={styles.summaryValue}>{formatPrice(taxTotal)}</Text>
|
||||
</View>
|
||||
<View style={styles.grandTotalRow}>
|
||||
<Text style={styles.grandTotalLabel}>Gesamt</Text>
|
||||
<Text style={styles.grandTotalValue}>{formatPrice(grossTotal)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text>
|
||||
{seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "}
|
||||
{seller.vatId}
|
||||
</Text>
|
||||
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>{seller.bankDetails}</Text> : null}
|
||||
<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>
|
||||
<View style={styles.metaBox}>
|
||||
<Text style={styles.metaLabel}>USt-IdNr.</Text>
|
||||
<Text style={styles.metaValue}>{seller.vatId}</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}>
|
||||
<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.colName}>
|
||||
<Text>{item.productName}</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 ({g.rate}%)</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 style={styles.footer}>
|
||||
<Text>
|
||||
{seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "}
|
||||
{seller.vatId}
|
||||
</Text>
|
||||
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>Bankverbindung (für Überweisung): {seller.bankDetails}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
|
||||
+37
-24
@@ -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<boolean> {
|
||||
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) {
|
||||
|
||||
@@ -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<CreatedOrder
|
||||
productName: i.productName,
|
||||
quantity: i.quantity,
|
||||
unitPrice: i.unitPrice,
|
||||
taxRatePercent: i.taxRatePercent,
|
||||
bundleContents: i.bundleContents,
|
||||
})),
|
||||
subtotal: input.subtotal,
|
||||
shippingCost: input.shippingCost,
|
||||
|
||||
@@ -14,13 +14,17 @@ export type RawProduct = {
|
||||
price: number;
|
||||
active: boolean;
|
||||
image: { url: string } | number | null;
|
||||
taxRatePercent: number | null;
|
||||
bundleItems: { product: { id: number; name: string } | number; quantity: number }[] | null;
|
||||
};
|
||||
|
||||
export async function fetchProductsBySlug(): Promise<Map<string, RawProduct>> {
|
||||
// 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<string, RawProduct>();
|
||||
if (!res.ok) return map;
|
||||
|
||||
Reference in New Issue
Block a user