Attach the invoice PDF to the payment-method-switched email, brand-tone copy

buildInvoiceAttachment() extracted out of sendOrderConfirmationEmail() so
sendPaymentSwitchedEmail() can reuse the same invoice-PDF generation
instead of duplicating it — the invoiceNumber stays the same, but
paymentMethodTitle now reflects the confirmed instrument, which flips
the PDF's own paid/unpaid display, so re-attaching a fresh copy matters
here even though nothing else about the order changed.

Default fallback copy replaced with brand-toned wording matching the
existing order-confirmation fallback's voice, instead of a flat
system-notice tone.
This commit is contained in:
Marco
2026-07-30 11:14:18 +00:00
parent f0b2d21989
commit 18f5156fd8
4 changed files with 68 additions and 32 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
"order-tracking-added": "Hier ist deine Sendungsnummer",
"order-tracking-corrected": "Korrigierte Sendungsnummer",
"order-delivered": "Dein Paket ist angekommen",
"payment-method-switched": "Zahlung erhalten",
"payment-method-switched": "Erledigt!",
};
// Entered exclusively via EmailTemplates.ts's admin.livePreview.url (a
+51 -27
View File
@@ -55,25 +55,20 @@ export type OrderConfirmationEmailData = OrderConfirmationData & {
// 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 fetchedTemplate = await getEmailTemplate("order-confirmation");
// `active === false` is a deliberate admin decision to suppress this
// email entirely — distinct from `fetchedTemplate` being null (no row
// saved yet), which still sends below with the hardcoded default
// wording. Checked before the fallback is applied, since the fallback
// object has no `active` field of its own (implicitly always on).
if (fetchedTemplate && !fetchedTemplate.active) return false;
const template = fetchedTemplate ?? {
subject: "Bestellt! Deine Ruhe kann kommen 🎉",
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 seller = await getSellerForInvoice();
const html = renderOrderConfirmationHtml(template, order, seller);
let attachments: { filename: string; content: Buffer }[] | undefined;
// Shared by sendOrderConfirmationEmail and sendPaymentSwitchedEmail — both
// need to (re)generate the exact same invoice PDF for the exact same
// order, just with a different email body wrapped around it. Regenerated
// fresh each time rather than cached anywhere, same "deterministic
// regeneration, not file storage" approach as the on-demand download
// routes — this also means a switched-payment send picks up the now
// up-to-date paymentMethodTitle, so the PDF's own "✓ Bereits beglichen"
// vs. Vorkasse-notice branch (see @einfach-produktiv/invoicing's
// isPaidImmediately()) reflects the real, current payment state even
// though invoiceNumber/invoiceIssuedAt never change.
async function buildInvoiceAttachment(
order: OrderConfirmationEmailData,
seller: Awaited<ReturnType<typeof getSellerForInvoice>>,
): Promise<{ filename: string; content: Buffer }[] | undefined> {
try {
const pdf = await generateInvoicePdf(
{
@@ -125,15 +120,36 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
},
seller,
);
if (pdf) attachments = [{ filename: `Rechnung-${order.invoiceNumber}.pdf`, content: pdf }];
else throw new Error("generateInvoicePdf returned null (missing invoice-settings?)");
if (!pdf) throw new Error("generateInvoicePdf returned null (missing invoice-settings?)");
return [{ filename: `Rechnung-${order.invoiceNumber}.pdf`, content: pdf }];
} catch (err) {
sendCriticalAlert("Rechnungs-PDF konnte nicht erzeugt werden", {
orderNumber: order.orderNumber,
invoiceNumber: order.invoiceNumber,
error: String(err),
});
return undefined;
}
}
export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailData, customerEmail: string): Promise<boolean> {
const fetchedTemplate = await getEmailTemplate("order-confirmation");
// `active === false` is a deliberate admin decision to suppress this
// email entirely — distinct from `fetchedTemplate` being null (no row
// saved yet), which still sends below with the hardcoded default
// wording. Checked before the fallback is applied, since the fallback
// object has no `active` field of its own (implicitly always on).
if (fetchedTemplate && !fetchedTemplate.active) return false;
const template = fetchedTemplate ?? {
subject: "Bestellt! Deine Ruhe kann kommen 🎉",
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 seller = await getSellerForInvoice();
const html = renderOrderConfirmationHtml(template, order, seller);
const attachments = await buildInvoiceAttachment(order, seller);
await transport.sendMail({
// SPF confirmed 2026-07-29 for einfach-produktiv.com, and the SMTP
@@ -165,13 +181,13 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
// (icon + admin-editable text + "Bestellung ansehen" button), no item
// table/invoice attachment — the invoice itself didn't change, only how
// it got paid.
export async function sendPaymentSwitchedEmail(orderNumber: string, customerEmail: string): Promise<boolean> {
export async function sendPaymentSwitchedEmail(order: OrderConfirmationEmailData, customerEmail: string): Promise<boolean> {
const fetchedTemplate = await getEmailTemplate("payment-method-switched");
if (fetchedTemplate && !fetchedTemplate.active) return false;
const template = fetchedTemplate ?? {
subject: "Zahlung erhalten — danke!",
heading: "Zahlung erhalten",
bodyText: "Deine Zahlung per Kreditkarte/PayPal ist bei uns eingegangen. An deiner Bestellung selbst ändert sich nichts — sie wird wie gewohnt bearbeitet.",
subject: "Erledigt! Deine Zahlung ist da 🎉",
heading: "Erledigt!",
bodyText: "Deine Zahlung ist gerade bei uns eingetrudelt — ab jetzt läuft alles automatisch weiter, du musst dich um nichts mehr kümmern. Deine aktualisierte Rechnung findest du im Anhang.",
footerText: null,
};
@@ -179,10 +195,17 @@ export async function sendPaymentSwitchedEmail(orderNumber: string, customerEmai
const html = renderOrderStatusHtml(
template,
"💳",
orderNumber,
`https://einfach-produktiv.mk360.de/konto/bestellungen/${encodeURIComponent(orderNumber)}`,
order.orderNumber,
`https://einfach-produktiv.mk360.de/konto/bestellungen/${encodeURIComponent(order.orderNumber)}`,
seller,
);
// Same invoiceNumber as always (never re-issued for a switch, see
// confirmPayment.ts), but paymentMethodTitle now reflects the actually-
// confirmed instrument — worth a fresh PDF, not the original attachment,
// since @einfach-produktiv/invoicing's own isPaidImmediately() check
// reads that title to decide "✓ Bereits beglichen" vs. the Vorkasse
// notice.
const attachments = await buildInvoiceAttachment(order, seller);
await transport.sendMail({
from: `"${seller?.emailFromName || seller?.sellerName || "Björn"}" <${seller?.emailFromAddress || seller?.sellerEmail || "hallo@einfach-produktiv.com"}>`,
@@ -190,6 +213,7 @@ export async function sendPaymentSwitchedEmail(orderNumber: string, customerEmai
to: customerEmail,
subject: template.subject,
html,
attachments,
});
return true;
}
+1 -1
View File
@@ -33,7 +33,7 @@ export async function sendConfirmedPaymentEmail(order: ConfirmPaymentOrderSnapsh
const { customerEmail, ...emailData } = order;
try {
if (order.paymentSwitchedAt) {
await sendPaymentSwitchedEmail(order.orderNumber, customerEmail);
await sendPaymentSwitchedEmail(emailData, customerEmail);
} else {
await sendOrderConfirmationEmail(emailData, customerEmail);
}