Generate invoice PDFs attached to order confirmation, and send emails on order status changes

Invoice PDFs (§14 UStG line items, tenant-configurable VAT rate) are now
generated at checkout and attached to the confirmation email, plus
available on demand from the order-detail page. Payload-side, orders now
also email the customer on shipped/cancelled/return_requested/returned,
with Stornorechnung/Gutschrift correction PDFs attached for the latter two
so the original invoice's immutable number stays honest.
This commit is contained in:
Marco
2026-07-22 09:49:56 +00:00
parent fa02d95dff
commit 5232b14cdf
15 changed files with 1077 additions and 24 deletions
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
import { generateInvoicePdf } from "../../../../../lib/invoiceData";
// On-demand download for "Rechnung herunterladen" on
// /konto/bestellungen/[orderNumber] — reuses the exact same render call as
// the checkout-time attachment (app/lib/orderEmail.ts), so a re-download
// always matches what was emailed; invoiceNumber itself never changes
// (assigned once, server-side, at order creation — see Orders.ts).
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.invoiceNumber || !order.invoiceIssuedAt) {
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,
});
if (!pdf) return NextResponse.json({ ok: false, reason: "Rechnung konnte nicht erzeugt werden." }, { status: 500 });
return new NextResponse(new Uint8Array(pdf), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="Rechnung-${order.invoiceNumber}.pdf"`,
},
});
}
+15 -3
View File
@@ -88,11 +88,12 @@ 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 }[] = [];
const items: { productId: number; productName: string; quantity: number; unitPrice: number; imageUrl: 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 });
items.push({ productId: product.id, productName: product.name, quantity: line.qty, unitPrice: product.price });
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 });
}
const subtotal = items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0);
@@ -164,7 +165,18 @@ export async function POST(request: Request) {
{
orderNumber: order.orderNumber,
createdAt: order.createdAt,
items: items.map((i) => ({ productName: i.productName, quantity: i.quantity, unitPrice: i.unitPrice })),
invoiceNumber: order.invoiceNumber,
invoiceIssuedAt: order.invoiceIssuedAt,
customerFirstName: body.firstName,
customerLastName: body.lastName,
deliveryMethod: body.deliveryMethod,
street: body.street,
packstationNumber: body.packstationNumber,
postNumber: body.postNumber,
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 })),
subtotal,
shippingCost,
discountAmount,
@@ -4,6 +4,8 @@ import { useLivePreview } from "@payloadcms/live-preview-react";
import {
renderOrderConfirmationHtml,
renderPasswordResetHtml,
renderOrderStatusHtml,
ORDER_STATUS_EMAIL_ICON,
SAMPLE_ORDER,
type EmailTemplateContent,
} from "../../../lib/emailTemplates";
@@ -35,7 +37,14 @@ export function LiveEmailPreviewClient({
const html =
type === "order-confirmation"
? renderOrderConfirmationHtml(data, SAMPLE_ORDER)
: renderPasswordResetHtml(data, "https://einfach-produktiv.mk360.de/konto/passwort-zuruecksetzen?token=beispiel-token");
: type === "password-reset"
? renderPasswordResetHtml(data, "https://einfach-produktiv.mk360.de/konto/passwort-zuruecksetzen?token=beispiel-token")
: renderOrderStatusHtml(
data,
ORDER_STATUS_EMAIL_ICON[type] ?? "✓",
SAMPLE_ORDER.orderNumber,
`https://einfach-produktiv.mk360.de/konto/bestellungen/${SAMPLE_ORDER.orderNumber}`,
);
return (
<div style={{ background: "#f4f2ee", minHeight: "100vh", padding: "32px 0" }}>
+22 -2
View File
@@ -9,7 +9,21 @@ export const metadata: Metadata = {
robots: { index: false, follow: false },
};
const VALID_TYPES: EmailTemplateType[] = ["order-confirmation", "password-reset"];
const VALID_TYPES: EmailTemplateType[] = [
"order-confirmation",
"password-reset",
"order-shipped",
"order-cancelled",
"order-return-requested",
"order-returned",
];
const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
"order-shipped": "Deine Bestellung ist unterwegs",
"order-cancelled": "Deine Bestellung wurde storniert",
"order-return-requested": "Deine Rücksendung wurde angefragt",
"order-returned": "Deine Retoure wurde bearbeitet",
};
// Entered exclusively via EmailTemplates.ts's admin.livePreview.url (a
// Payload-admin-only iframe target, see buildPreviewUrl()/api/preview) —
@@ -23,10 +37,16 @@ export default async function EmailPreviewPage({ params }: { params: Promise<{ t
const emailType = type as EmailTemplateType;
await draftMode();
const fallbackHeading =
emailType === "order-confirmation"
? "Vielen Dank für deine Bestellung!"
: emailType === "password-reset"
? "Passwort zurücksetzen"
: STATUS_TYPE_FALLBACK_HEADING[emailType];
const template = (await getEmailTemplate(emailType, { draft: true })) ?? {
type: emailType,
subject: "",
heading: emailType === "order-confirmation" ? "Vielen Dank für deine Bestellung!" : "Passwort zurücksetzen",
heading: fallbackHeading,
bodyText: "Noch kein Inhalt gespeichert — im Payload-Admin unter E-Mail-Vorlagen anlegen.",
footerText: null,
};
@@ -108,6 +108,15 @@ 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>
)}
{action && <OrderActionButton orderNumber={order.orderNumber} action={action} />}
</Reveal>
</main>
+2
View File
@@ -424,6 +424,8 @@ export async function getCustomerOrders(token: string, customerId: number): Prom
export type CustomerOrderDetail = CustomerOrder & {
id: number;
invoiceNumber: string | null;
invoiceIssuedAt: string | null;
customerFirstName: string;
customerLastName: string;
customerEmail: string;
+50 -5
View File
@@ -116,7 +116,12 @@ function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerT
</body>`;
}
export type OrderConfirmationItem = { productName: string; quantity: number; unitPrice: number };
export type OrderConfirmationItem = {
productName: string;
quantity: number;
unitPrice: number;
imageUrl?: string | null;
};
export type OrderConfirmationData = {
orderNumber: string;
createdAt: string;
@@ -132,8 +137,13 @@ export const SAMPLE_ORDER: OrderConfirmationData = {
orderNumber: "#EP-0001-A7K2",
createdAt: new Date().toISOString(),
items: [
{ productName: "ToDo-Karten Set", quantity: 1, unitPrice: 12.9 },
{ productName: "Wochenplaner Überblick", quantity: 2, unitPrice: 14.9 },
{
productName: "ToDo-Karten Set",
quantity: 1,
unitPrice: 12.9,
imageUrl: "https://payload.mk360.de/api/media/file/product-todo-karten.png",
},
{ productName: "Wochenplaner Überblick", quantity: 2, unitPrice: 14.9, imageUrl: null },
],
subtotal: 42.7,
shippingCost: 0,
@@ -146,7 +156,14 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
const rows = order.items
.map(
(item) => `<tr>
<td style="padding:10px 0;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span></td>
<td width="52" style="padding:10px 0;border-bottom:1px solid ${BORDER};">
${
item.imageUrl
? `<img src="${item.imageUrl}" width="44" height="44" alt="" style="display:block;width:44px;height:44px;border-radius:6px;object-fit:cover;border:1px solid ${BORDER};" />`
: `<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;border-bottom:1px solid ${BORDER};text-align:right;white-space:nowrap;font-size:14px;color:${TEXT_PRIMARY};">${formatPrice(item.quantity * item.unitPrice)}</td>
</tr>`,
)
@@ -158,7 +175,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
const body = `
${paragraphs(template.bodyText, "center")}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:16px;background:${BG_MUTED};border-radius:8px;padding:20px;">
<tr><td colspan="2" style="padding-bottom:10px;font-size:12px;color:${TEXT_MUTED};">Bestellnummer <strong style="color:${TEXT_PRIMARY};">${escapeHtml(order.orderNumber)}</strong> · ${formatDate(order.createdAt)}</td></tr>
<tr><td colspan="3" style="padding-bottom:10px;font-size:12px;color:${TEXT_MUTED};">Bestellnummer <strong style="color:${TEXT_PRIMARY};">${escapeHtml(order.orderNumber)}</strong> · ${formatDate(order.createdAt)}</td></tr>
${rows}
</table>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:16px;">
@@ -177,6 +194,34 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
return emailShell("✓", escapeHtml(template.heading), body, template.footerText);
}
// Icon shown per status — matches the Payload-side send exactly (see
// STATUS_EMAIL in the backend repo's src/collections/Orders.ts), kept here
// only for the Live Preview approximation (the real send happens from
// Payload itself, not this repo — see that file's own comment on why the
// two aren't pixel-identical, same established gap as password-reset).
export const ORDER_STATUS_EMAIL_ICON: Record<string, string> = {
"order-shipped": "→",
"order-cancelled": "✕",
"order-return-requested": "↩",
"order-returned": "✓",
};
export function renderOrderStatusHtml(template: EmailTemplateContent, icon: string, orderNumber: string, orderUrl: 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>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px auto 8px;">
<tr>
<td style="background:${BRAND};border-radius:6px;">
<a href="${orderUrl}" style="display:inline-block;padding:13px 28px;font-weight:700;font-size:15px;color:${TEXT_PRIMARY};text-decoration:none;">Bestellung ansehen</a>
</td>
</tr>
</table>
`;
return emailShell(icon, escapeHtml(template.heading), body, template.footerText);
}
export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string): string {
const body = `
${paragraphs(template.bodyText, "center")}
+15
View File
@@ -0,0 +1,15 @@
import { getInvoiceSettings } from "./payload";
import { renderInvoicePdf, type InvoiceOrder } from "./invoicePdf";
// 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();
if (!seller) {
console.error("generateInvoicePdf: no invoice-settings row found for tenant");
return null;
}
return renderInvoicePdf(order, seller);
}
+218
View File
@@ -0,0 +1,218 @@
import React from "react";
import { Document, Page, View, Text, StyleSheet, renderToBuffer } from "@react-pdf/renderer";
import { formatPrice, 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
// app/api/account/orders/[orderNumber]/invoice/route.ts — same render
// function both times, so a re-download always matches what was emailed.
//
// Built-in Helvetica, not a registered web font — this renders inside the
// 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.
const BRAND = "#f6a701";
const TEXT_MUTED = "#6b6b69";
const BORDER = "#e5e0d8";
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 },
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 },
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 },
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 },
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,
},
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,
},
});
export type InvoiceItem = { productName: string; quantity: number; unitPrice: number };
export type InvoiceOrder = {
orderNumber: string;
invoiceNumber: string;
invoiceIssuedAt: string;
customerFirstName: string;
customerLastName: string;
deliveryMethod: "address" | "packstation";
street?: string | null;
packstationNumber?: string | null;
postNumber?: string | null;
zip: string;
city: string;
country: string;
items: InvoiceItem[];
subtotal: number;
shippingCost: number;
discountAmount: number;
discountCode: string | null;
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 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 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>
<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>
))}
</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>
)}
<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>
</Page>
</Document>
);
}
export async function renderInvoicePdf(order: InvoiceOrder, seller: InvoiceSeller): Promise<Buffer> {
return renderToBuffer(<InvoiceDocument order={order} seller={seller} />);
}
+63 -1
View File
@@ -1,6 +1,26 @@
import { transport } from "./mailer";
import { getEmailTemplate } from "./payload";
import { renderOrderConfirmationHtml, type OrderConfirmationData } from "./emailTemplates";
import { generateInvoicePdf } from "./invoiceData";
import { sendCriticalAlert } from "./alertAdmin";
// Superset of OrderConfirmationData (used for the HTML render) plus the
// address/invoice fields the PDF needs but the email body doesn't —
// avoids threading a second parallel "order" argument through this
// function for what's ultimately one order.
export type OrderConfirmationEmailData = OrderConfirmationData & {
invoiceNumber: string;
invoiceIssuedAt: string;
customerFirstName: string;
customerLastName: string;
deliveryMethod: "address" | "packstation";
street?: string | null;
packstationNumber?: string | null;
postNumber?: string | null;
zip: string;
city: string;
country: string;
};
// Called from app/api/checkout/route.ts right after a successful
// createOrder() — fire-and-forget, must never block or fail the checkout
@@ -9,7 +29,15 @@ import { renderOrderConfirmationHtml, type OrderConfirmationData } from "./email
// nobody's visited the Payload admin yet (mirrors seed-email-templates.ts's
// defaults, kept in sync by hand — there are only two places this wording
// lives, seeding it is a one-time setup step, not a runtime dependency).
export async function sendOrderConfirmationEmail(order: OrderConfirmationData, customerEmail: string): Promise<boolean> {
//
// The invoice PDF is generated here too (not a separate fire-and-forget
// step) so it can ride along as an attachment on this same send, per the
// "always emailed, not just downloadable" checkout decision — but its own
// failure must not sink the confirmation email itself, so it's wrapped in
// its own try/catch and just sends without the attachment if generation
// fails (still alerted, same severity as the frontend's own critical-error
// path for this checkout flow).
export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailData, customerEmail: string): Promise<boolean> {
const template = (await getEmailTemplate("order-confirmation")) ?? {
subject: "Bestellt! Deine Ruhe kann kommen 🎉",
heading: "Geschafft!",
@@ -18,11 +46,45 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationData, c
};
const html = renderOrderConfirmationHtml(template, order);
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,
});
if (pdf) attachments = [{ filename: `Rechnung-${order.invoiceNumber}.pdf`, content: pdf }];
else throw new Error("generateInvoicePdf returned null (missing invoice-settings?)");
} catch (err) {
sendCriticalAlert("Rechnungs-PDF konnte nicht erzeugt werden", {
orderNumber: order.orderNumber,
invoiceNumber: order.invoiceNumber,
error: String(err),
});
}
await transport.sendMail({
from: '"einfach produktiv" <admin@mk360.de>',
to: customerEmail,
subject: template.subject,
html,
attachments,
});
return true;
}
+8 -3
View File
@@ -49,7 +49,7 @@ export type CreateOrderInput = {
total: number;
};
export type CreatedOrder = { orderNumber: string; createdAt: string };
export type CreatedOrder = { orderNumber: string; createdAt: string; invoiceNumber: string; invoiceIssuedAt: string };
export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder | null> {
const tenantId = await resolveTenantId();
@@ -99,6 +99,11 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
return null;
}
const data: { doc: { orderNumber: string; createdAt: string } } = await res.json();
return { orderNumber: data.doc.orderNumber, createdAt: data.doc.createdAt };
const data: { doc: { orderNumber: string; createdAt: string; invoiceNumber: string; invoiceIssuedAt: string } } = await res.json();
return {
orderNumber: data.doc.orderNumber,
createdAt: data.doc.createdAt,
invoiceNumber: data.doc.invoiceNumber,
invoiceIssuedAt: data.doc.invoiceIssuedAt,
};
}
+39 -1
View File
@@ -604,7 +604,13 @@ export async function getLegalPage(type: LegalPageType, options?: { draft?: bool
};
}
export type EmailTemplateType = "order-confirmation" | "password-reset";
export type EmailTemplateType =
| "order-confirmation"
| "password-reset"
| "order-shipped"
| "order-cancelled"
| "order-return-requested"
| "order-returned";
type PayloadEmailTemplate = {
type: EmailTemplateType;
@@ -636,3 +642,35 @@ export async function getEmailTemplate(
const data: { docs?: PayloadEmailTemplate[] } = await res.json();
return data.docs?.[0] ?? null;
}
export type InvoiceSettings = {
sellerName: string;
sellerStreet: string;
sellerZip: string;
sellerCity: string;
sellerCountry: string;
sellerEmail: string;
vatId: string;
taxRatePercent: number;
bankDetails: string | null;
};
// Server-only in practice (only ever called from app/lib/invoiceData.ts),
// but kept in this file rather than a "use server"-only module since every
// other Payload fetcher lives here too — no client component imports it.
// invoice-settings' read access is admin-only plus this same service
// secret (see InvoiceSettings.ts) — it holds bank details, not something
// to leave publicly readable like Products/ShippingSettings.
export async function getInvoiceSettings(): Promise<InvoiceSettings | null> {
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/invoice-settings?${params}`, {
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
cache: "no-store",
});
if (!res.ok) {
console.error(`getInvoiceSettings: Payload returned ${res.status} ${res.statusText}`);
return null;
}
const data: { docs?: InvoiceSettings[] } = await res.json();
return data.docs?.[0] ?? null;
}
+12 -2
View File
@@ -7,10 +7,20 @@
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
export type RawProduct = { id: number; slug: string; name: string; price: number; active: boolean };
export type RawProduct = {
id: number;
slug: string;
name: string;
price: number;
active: boolean;
image: { url: string } | number | null;
};
export async function fetchProductsBySlug(): Promise<Map<string, RawProduct>> {
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "100" });
// 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" });
const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { cache: "no-store" });
const map = new Map<string, RawProduct>();
if (!res.ok) return map;