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:
@@ -421,7 +421,12 @@ export default function SettingsRoute() {
|
|||||||
<s-stack direction="block" gap="base">
|
<s-stack direction="block" gap="base">
|
||||||
<s-paragraph>
|
<s-paragraph>
|
||||||
These templates are used when sending the invoice PDF by email.
|
These templates are used when sending the invoice PDF by email.
|
||||||
Leave a field empty to fall back to the built-in default.
|
Leave a field empty to fall back to the built-in default. The
|
||||||
|
built-in default has two variants per language: orders that are
|
||||||
|
already paid are sent a body <em>without</em> the bank-transfer
|
||||||
|
reference hint, while outstanding (unpaid) orders include it.
|
||||||
|
A custom template you set here is used for both paid and unpaid
|
||||||
|
orders — the editor below shows the unpaid default.
|
||||||
</s-paragraph>
|
</s-paragraph>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import type { Transporter } from "nodemailer";
|
|||||||
import type { ShopSettings } from "@prisma/client";
|
import type { ShopSettings } from "@prisma/client";
|
||||||
|
|
||||||
import db from "../../db.server";
|
import db from "../../db.server";
|
||||||
import { getStrings, pickLanguage } from "./i18n";
|
import { pickLanguage } from "./i18n";
|
||||||
import {
|
import {
|
||||||
DEFAULT_EMAIL_BODY_DE,
|
DEFAULT_EMAIL_BODY_DE_PAID,
|
||||||
DEFAULT_EMAIL_BODY_EN,
|
DEFAULT_EMAIL_BODY_DE_UNPAID,
|
||||||
|
DEFAULT_EMAIL_BODY_EN_PAID,
|
||||||
|
DEFAULT_EMAIL_BODY_EN_UNPAID,
|
||||||
DEFAULT_EMAIL_SUBJECT_DE,
|
DEFAULT_EMAIL_SUBJECT_DE,
|
||||||
DEFAULT_EMAIL_SUBJECT_EN,
|
DEFAULT_EMAIL_SUBJECT_EN,
|
||||||
} from "./emailTemplates";
|
} from "./emailTemplates";
|
||||||
@@ -74,7 +76,6 @@ export async function sendInvoiceEmail(
|
|||||||
// rendered in, so the email matches the attachment. Caller can still
|
// rendered in, so the email matches the attachment. Caller can still
|
||||||
// override via `customerLocale` if they really want a different language.
|
// override via `customerLocale` if they really want a different language.
|
||||||
const language = pickLanguage(args.customerLocale ?? invoice.language ?? settings.defaultLanguage);
|
const language = pickLanguage(args.customerLocale ?? invoice.language ?? settings.defaultLanguage);
|
||||||
const t = getStrings(language);
|
|
||||||
const customer = parseCustomer(invoice.customerJson);
|
const customer = parseCustomer(invoice.customerJson);
|
||||||
const totals = parseTotals(invoice.totalsJson);
|
const totals = parseTotals(invoice.totalsJson);
|
||||||
const vars = buildTemplateVars({
|
const vars = buildTemplateVars({
|
||||||
@@ -90,9 +91,26 @@ export async function sendInvoiceEmail(
|
|||||||
(language === "en" ? DEFAULT_EMAIL_SUBJECT_EN : DEFAULT_EMAIL_SUBJECT_DE);
|
(language === "en" ? DEFAULT_EMAIL_SUBJECT_EN : DEFAULT_EMAIL_SUBJECT_DE);
|
||||||
const subject = renderTemplate(customSubject, vars, { html: false });
|
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 =
|
const customBodyHtml =
|
||||||
(language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe) ||
|
(language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe) ||
|
||||||
(language === "en" ? DEFAULT_EMAIL_BODY_EN : DEFAULT_EMAIL_BODY_DE);
|
defaultBody;
|
||||||
const body = renderHtmlBody(renderTemplate(customBodyHtml, vars));
|
const body = renderHtmlBody(renderTemplate(customBodyHtml, vars));
|
||||||
|
|
||||||
// If the rendered body references the inline logo, attach it.
|
// If the rendered body references the inline logo, attach it.
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* Default invoice email templates per language. Used when the user hasn't
|
* Default invoice email templates per language and per payment state. Used
|
||||||
* customised them in settings. Variables ({{invoiceNumber}}, etc.) are
|
* when the user hasn't customised them in settings. Variables
|
||||||
* substituted by `renderTemplate` at send time.
|
* ({{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
|
* The shop logo is rendered as an inline attachment with content-id
|
||||||
* `invoice-logo`; the email sender attaches the logo bytes automatically
|
* `invoice-logo`; the email sender attaches the logo bytes automatically
|
||||||
* when the template (or any custom template) references that cid.
|
* 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>
|
<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>
|
<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;">
|
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
|
||||||
Die Rechnung befindet sich im Anhang.
|
Die Rechnung befindet sich im Anhang.
|
||||||
</p>
|
</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:
|
const DE_FOOT = `\
|
||||||
<strong>{{invoiceNumber}}</strong><br>
|
|
||||||
Besten Dank!
|
|
||||||
</p>
|
|
||||||
<p style="margin-top:24px;">
|
<p style="margin-top:24px;">
|
||||||
<img src="cid:invoice-logo" alt="{{companyName}}" style="max-height:48px;">
|
<img src="cid:invoice-logo" alt="{{companyName}}" style="max-height:48px;">
|
||||||
</p>
|
</p>
|
||||||
@@ -27,17 +30,26 @@ Besten Dank!
|
|||||||
🌐 <a href="{{shopWebsite}}" style="color:#0883DA;">{{shopWebsite}}</a>
|
🌐 <a href="{{shopWebsite}}" style="color:#0883DA;">{{shopWebsite}}</a>
|
||||||
</p>`;
|
</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>
|
<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>
|
<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;">
|
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
|
||||||
Please find the invoice attached.
|
Please find the invoice attached.
|
||||||
</p>
|
</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:
|
const EN_FOOT = `\
|
||||||
<strong>{{invoiceNumber}}</strong><br>
|
|
||||||
Thanks a lot!
|
|
||||||
</p>
|
|
||||||
<p style="margin-top:24px;">
|
<p style="margin-top:24px;">
|
||||||
<img src="cid:invoice-logo" alt="{{companyName}}" style="max-height:48px;">
|
<img src="cid:invoice-logo" alt="{{companyName}}" style="max-height:48px;">
|
||||||
</p>
|
</p>
|
||||||
@@ -46,7 +58,35 @@ Thanks a lot!
|
|||||||
🌐 <a href="{{shopWebsite}}" style="color:#0883DA;">{{shopWebsite}}</a>
|
🌐 <a href="{{shopWebsite}}" style="color:#0883DA;">{{shopWebsite}}</a>
|
||||||
</p>`;
|
</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_DE = "Rechnung {{invoiceNumber}} – {{companyName}}";
|
||||||
export const DEFAULT_EMAIL_SUBJECT_EN = "Invoice {{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 version = latest ? latest.version + 1 : 1;
|
||||||
const totalsJson = JSON.stringify(viewModel.totals);
|
const totalsJson = JSON.stringify(viewModel.totals);
|
||||||
|
const paymentStatus = viewModel.paymentStatus;
|
||||||
const customerJson = JSON.stringify({
|
const customerJson = JSON.stringify({
|
||||||
recipient: viewModel.recipient,
|
recipient: viewModel.recipient,
|
||||||
isB2B: viewModel.isB2B,
|
isB2B: viewModel.isB2B,
|
||||||
@@ -148,6 +149,7 @@ export async function generateInvoice(
|
|||||||
pdfUrl: upload.url,
|
pdfUrl: upload.url,
|
||||||
totalsJson,
|
totalsJson,
|
||||||
customerJson,
|
customerJson,
|
||||||
|
paymentStatus,
|
||||||
issuedAt: new Date(),
|
issuedAt: new Date(),
|
||||||
status: "issued",
|
status: "issued",
|
||||||
lastError: "",
|
lastError: "",
|
||||||
@@ -167,6 +169,7 @@ export async function generateInvoice(
|
|||||||
pdfUrl: upload.url,
|
pdfUrl: upload.url,
|
||||||
totalsJson,
|
totalsJson,
|
||||||
customerJson,
|
customerJson,
|
||||||
|
paymentStatus,
|
||||||
status: "issued",
|
status: "issued",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Invoice" ADD COLUMN "paymentStatus" TEXT NOT NULL DEFAULT '';
|
||||||
@@ -141,7 +141,12 @@ model Invoice {
|
|||||||
|
|
||||||
// Snapshots (JSON strings on sqlite)
|
// Snapshots (JSON strings on sqlite)
|
||||||
totalsJson String @default("{}")
|
totalsJson String @default("{}")
|
||||||
customerJson String @default("{}")
|
customerJson String @default("")
|
||||||
|
|
||||||
|
// Payment state at generation time ("paid" | "partial" | "unpaid" |
|
||||||
|
// "refunded" | "voided"). Used to pick the paid/unpaid email template at
|
||||||
|
// send time. Empty for legacy rows → treated as "unpaid".
|
||||||
|
paymentStatus String @default("")
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
issuedAt DateTime @default(now())
|
issuedAt DateTime @default(now())
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_EMAIL_BODY_DE_PAID,
|
||||||
|
DEFAULT_EMAIL_BODY_DE_UNPAID,
|
||||||
|
DEFAULT_EMAIL_BODY_EN_PAID,
|
||||||
|
DEFAULT_EMAIL_BODY_EN_UNPAID,
|
||||||
|
} from "../app/services/invoice/emailTemplates";
|
||||||
|
|
||||||
|
describe("invoice email templates (paid vs unpaid)", () => {
|
||||||
|
const HINT_EN = /bank transfer/i;
|
||||||
|
const HINT_DE = /Überweisung/i;
|
||||||
|
|
||||||
|
it("unpaid defaults keep the bank-transfer reference hint", () => {
|
||||||
|
assert.match(DEFAULT_EMAIL_BODY_EN_UNPAID, HINT_EN);
|
||||||
|
assert.match(DEFAULT_EMAIL_BODY_EN_UNPAID, /\{\{invoiceNumber\}\}/);
|
||||||
|
assert.match(DEFAULT_EMAIL_BODY_DE_UNPAID, HINT_DE);
|
||||||
|
assert.match(DEFAULT_EMAIL_BODY_DE_UNPAID, /\{\{invoiceNumber\}\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paid defaults omit the bank-transfer reference hint", () => {
|
||||||
|
assert.doesNotMatch(DEFAULT_EMAIL_BODY_EN_PAID, HINT_EN);
|
||||||
|
assert.doesNotMatch(DEFAULT_EMAIL_BODY_DE_PAID, HINT_DE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paid and unpaid variants otherwise share header/footer structure", () => {
|
||||||
|
for (const tpl of [
|
||||||
|
DEFAULT_EMAIL_BODY_EN_PAID,
|
||||||
|
DEFAULT_EMAIL_BODY_EN_UNPAID,
|
||||||
|
DEFAULT_EMAIL_BODY_DE_PAID,
|
||||||
|
DEFAULT_EMAIL_BODY_DE_UNPAID,
|
||||||
|
]) {
|
||||||
|
assert.match(tpl, /cid:invoice-logo/);
|
||||||
|
assert.match(tpl, /\{\{companyName\}\}/);
|
||||||
|
assert.match(tpl, /\{\{shopEmail\}\}/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user