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:
@@ -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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user