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
+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")}