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:
Marco
2026-07-22 10:31:22 +00:00
parent 04cc69f98b
commit 179b59d73d
15 changed files with 723 additions and 222 deletions
+28 -11
View File
@@ -64,11 +64,18 @@ function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// 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);
}