feat(email): split invoice email into paid/unpaid variants

Persist paymentStatus on the Invoice at generation time and use it at send
time to pick the default email body. Paid orders (paid/refunded/voided) now
get a body without the bank-transfer reference hint; outstanding orders keep
it. Custom merchant templates still override the defaults for both cases.

- Add Invoice.paymentStatus column + migration
- Split DEFAULT_EMAIL_BODY_{DE,EN} into _PAID/_UNPAID variants
- Persist paymentStatus in generateInvoice; select default in email.server
- Document behaviour on the settings page
- Add email-templates test (paid omits hint, unpaid keeps it)
This commit is contained in:
Gerhard Scheikl
2026-08-24 16:16:17 +02:00
parent 60288f6260
commit ff5c435bd4
7 changed files with 138 additions and 26 deletions
+23 -5
View File
@@ -3,10 +3,12 @@ import type { Transporter } from "nodemailer";
import type { ShopSettings } from "@prisma/client";
import db from "../../db.server";
import { getStrings, pickLanguage } from "./i18n";
import { pickLanguage } from "./i18n";
import {
DEFAULT_EMAIL_BODY_DE,
DEFAULT_EMAIL_BODY_EN,
DEFAULT_EMAIL_BODY_DE_PAID,
DEFAULT_EMAIL_BODY_DE_UNPAID,
DEFAULT_EMAIL_BODY_EN_PAID,
DEFAULT_EMAIL_BODY_EN_UNPAID,
DEFAULT_EMAIL_SUBJECT_DE,
DEFAULT_EMAIL_SUBJECT_EN,
} from "./emailTemplates";
@@ -74,7 +76,6 @@ export async function sendInvoiceEmail(
// rendered in, so the email matches the attachment. Caller can still
// override via `customerLocale` if they really want a different language.
const language = pickLanguage(args.customerLocale ?? invoice.language ?? settings.defaultLanguage);
const t = getStrings(language);
const customer = parseCustomer(invoice.customerJson);
const totals = parseTotals(invoice.totalsJson);
const vars = buildTemplateVars({
@@ -90,9 +91,26 @@ export async function sendInvoiceEmail(
(language === "en" ? DEFAULT_EMAIL_SUBJECT_EN : DEFAULT_EMAIL_SUBJECT_DE);
const subject = renderTemplate(customSubject, vars, { html: false });
// Pick the default body variant for the invoice's payment state: orders that
// are already settled (paid / refunded / voided) get a body WITHOUT the
// bank-transfer reference hint; outstanding orders (unpaid / partial) keep
// it. Legacy rows have an empty paymentStatus → treated as unpaid.
// A merchant-supplied custom template always wins over the defaults.
const isPaid =
invoice.paymentStatus === "paid" ||
invoice.paymentStatus === "refunded" ||
invoice.paymentStatus === "voided";
const defaultBody =
language === "en"
? isPaid
? DEFAULT_EMAIL_BODY_EN_PAID
: DEFAULT_EMAIL_BODY_EN_UNPAID
: isPaid
? DEFAULT_EMAIL_BODY_DE_PAID
: DEFAULT_EMAIL_BODY_DE_UNPAID;
const customBodyHtml =
(language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe) ||
(language === "en" ? DEFAULT_EMAIL_BODY_EN : DEFAULT_EMAIL_BODY_DE);
defaultBody;
const body = renderHtmlBody(renderTemplate(customBodyHtml, vars));
// If the rendered body references the inline logo, attach it.
+59 -19
View File
@@ -1,24 +1,27 @@
/**
* Default invoice email templates per language. Used when the user hasn't
* customised them in settings. Variables ({{invoiceNumber}}, etc.) are
* substituted by `renderTemplate` at send time.
* Default invoice email templates per language and per payment state. Used
* when the user hasn't customised them in settings. Variables
* ({{invoiceNumber}}, etc.) are substituted by `renderTemplate` at send time.
*
* There are two body variants per language:
* - "unpaid": includes the bank-transfer payment-reference hint.
* - "paid": identical but with the bank-transfer hint removed (the order
* is already settled, so asking for a transfer reference would
* be confusing).
*
* The shop logo is rendered as an inline attachment with content-id
* `invoice-logo`; the email sender attaches the logo bytes automatically
* when the template (or any custom template) references that cid.
*/
const DE_HTML = `\
const DE_HEAD = `\
<h2 style="margin:0 0 8px;font-family:Arial,Helvetica,sans-serif;"><span style="color:#0883DA">{{companyName}}</span></h2>
<h3 style="margin:0 0 16px;font-family:Arial,Helvetica,sans-serif;"><span style="color:#0883DA">Danke für deinen Einkauf!</span></h3>
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
Die Rechnung befindet sich im Anhang.
</p>
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
Bei Überweisung bitte die Rechnungs-Nummer als Referenz verwenden:
<strong>{{invoiceNumber}}</strong><br>
Besten Dank!
</p>
</p>`;
const DE_FOOT = `\
<p style="margin-top:24px;">
<img src="cid:invoice-logo" alt="{{companyName}}" style="max-height:48px;">
</p>
@@ -27,17 +30,26 @@ Besten Dank!
🌐 <a href="{{shopWebsite}}" style="color:#0883DA;">{{shopWebsite}}</a>
</p>`;
const EN_HTML = `\
const DE_PAYMENT_HINT = `\
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
Bei Überweisung bitte die Rechnungs-Nummer als Referenz verwenden:
<strong>{{invoiceNumber}}</strong><br>
Besten Dank!
</p>`;
const DE_PAID_NOTE = `\
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
Besten Dank!
</p>`;
const EN_HEAD = `\
<h2 style="margin:0 0 8px;font-family:Arial,Helvetica,sans-serif;"><span style="color:#0883DA">{{companyName}}</span></h2>
<h3 style="margin:0 0 16px;font-family:Arial,Helvetica,sans-serif;"><span style="color:#0883DA">Thank you for your purchase!</span></h3>
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
Please find the invoice attached.
</p>
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
When paying by bank transfer, please use the invoice number as the reference:
<strong>{{invoiceNumber}}</strong><br>
Thanks a lot!
</p>
</p>`;
const EN_FOOT = `\
<p style="margin-top:24px;">
<img src="cid:invoice-logo" alt="{{companyName}}" style="max-height:48px;">
</p>
@@ -46,7 +58,35 @@ Thanks a lot!
🌐 <a href="{{shopWebsite}}" style="color:#0883DA;">{{shopWebsite}}</a>
</p>`;
const EN_PAYMENT_HINT = `\
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
When paying by bank transfer, please use the invoice number as the reference:
<strong>{{invoiceNumber}}</strong><br>
Thanks a lot!
</p>`;
const EN_PAID_NOTE = `\
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
Thanks a lot!
</p>`;
// Unpaid (outstanding) bodies — keep the bank-transfer reference hint.
const DE_HTML_UNPAID = `${DE_HEAD}\n${DE_PAYMENT_HINT}\n${DE_FOOT}`;
const EN_HTML_UNPAID = `${EN_HEAD}\n${EN_PAYMENT_HINT}\n${EN_FOOT}`;
// Paid bodies — bank-transfer hint removed.
const DE_HTML_PAID = `${DE_HEAD}\n${DE_PAID_NOTE}\n${DE_FOOT}`;
const EN_HTML_PAID = `${EN_HEAD}\n${EN_PAID_NOTE}\n${EN_FOOT}`;
export const DEFAULT_EMAIL_SUBJECT_DE = "Rechnung {{invoiceNumber}} – {{companyName}}";
export const DEFAULT_EMAIL_SUBJECT_EN = "Invoice {{invoiceNumber}} – {{companyName}}";
export const DEFAULT_EMAIL_BODY_DE = DE_HTML;
export const DEFAULT_EMAIL_BODY_EN = EN_HTML;
// Backwards-compatible aliases (these are the "unpaid" variants, matching the
// historical single-template behaviour).
export const DEFAULT_EMAIL_BODY_DE = DE_HTML_UNPAID;
export const DEFAULT_EMAIL_BODY_EN = EN_HTML_UNPAID;
export const DEFAULT_EMAIL_BODY_DE_UNPAID = DE_HTML_UNPAID;
export const DEFAULT_EMAIL_BODY_EN_UNPAID = EN_HTML_UNPAID;
export const DEFAULT_EMAIL_BODY_DE_PAID = DE_HTML_PAID;
export const DEFAULT_EMAIL_BODY_EN_PAID = EN_HTML_PAID;
@@ -131,6 +131,7 @@ export async function generateInvoice(
const version = latest ? latest.version + 1 : 1;
const totalsJson = JSON.stringify(viewModel.totals);
const paymentStatus = viewModel.paymentStatus;
const customerJson = JSON.stringify({
recipient: viewModel.recipient,
isB2B: viewModel.isB2B,
@@ -148,6 +149,7 @@ export async function generateInvoice(
pdfUrl: upload.url,
totalsJson,
customerJson,
paymentStatus,
issuedAt: new Date(),
status: "issued",
lastError: "",
@@ -167,6 +169,7 @@ export async function generateInvoice(
pdfUrl: upload.url,
totalsJson,
customerJson,
paymentStatus,
status: "issued",
},
});