feat(email): per-variant paid/unpaid body templates in settings

The previous single body field could not distinguish 'use the default' from
'save the pre-filled default', so the first settings save locked the hint in
as a custom override for ALL orders — the paid/unpaid default logic never ran.

Give each payment state its own editable body field (emailBodyHtmlDePaid /
emailBodyHtmlEnPaid) with its own empty-fallback. A body submitted unchanged
from its built-in default is stored as empty, so 'use default' survives saves
and future default updates propagate.
This commit is contained in:
Gerhard Scheikl
2026-08-24 16:35:09 +02:00
parent ff5c435bd4
commit 602b336a94
5 changed files with 76 additions and 32 deletions
+3 -1
View File
@@ -2,6 +2,8 @@
"chat.tools.terminal.autoApprove": { "chat.tools.terminal.autoApprove": {
"setopt": true, "setopt": true,
"npx shopify": true, "npx shopify": true,
"npx tsx": true "npx tsx": true,
"./update_dev_system.sh": true,
"./show_dev_logs.sh": true
} }
} }
+54 -16
View File
@@ -17,8 +17,10 @@ import {
import { encryptField } from "../services/crypto/fieldCrypto.server"; import { encryptField } from "../services/crypto/fieldCrypto.server";
import { RichTextEditor } from "../components/RichTextEditor"; import { RichTextEditor } from "../components/RichTextEditor";
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 "../services/invoice/emailTemplates"; } from "../services/invoice/emailTemplates";
@@ -80,6 +82,15 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return Number.isFinite(n) ? n : null; return Number.isFinite(n) ? n : null;
}; };
// Email bodies are pre-filled with the built-in default in the editor. If the
// submitted value still equals that default, store an empty string instead so
// "empty = use built-in default" survives a save (and future default updates
// propagate). Only a genuinely edited body is persisted as a custom override.
const tpl = (k: string, builtIn: string) => {
const v = str(k);
return normaliseHtml(v) === normaliseHtml(builtIn) ? "" : v;
};
const vatId = str("vatId").toUpperCase(); const vatId = str("vatId").toUpperCase();
if (vatId && !isValidAtVatId(vatId)) { if (vatId && !isValidAtVatId(vatId)) {
errors.vatId = "Expected format: ATU followed by 8 digits (e.g. ATU12345678)."; errors.vatId = "Expected format: ATU followed by 8 digits (e.g. ATU12345678).";
@@ -214,9 +225,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
smtpFromEmail: str("smtpFromEmail"), smtpFromEmail: str("smtpFromEmail"),
smtpReplyTo: str("smtpReplyTo"), smtpReplyTo: str("smtpReplyTo"),
emailSubjectDe: str("emailSubjectDe"), emailSubjectDe: str("emailSubjectDe"),
emailBodyHtmlDe: str("emailBodyHtmlDe"), emailBodyHtmlDe: tpl("emailBodyHtmlDe", DEFAULT_EMAIL_BODY_DE_UNPAID),
emailSubjectEn: str("emailSubjectEn"), emailSubjectEn: str("emailSubjectEn"),
emailBodyHtmlEn: str("emailBodyHtmlEn"), emailBodyHtmlEn: tpl("emailBodyHtmlEn", DEFAULT_EMAIL_BODY_EN_UNPAID),
emailBodyHtmlDePaid: tpl("emailBodyHtmlDePaid", DEFAULT_EMAIL_BODY_DE_PAID),
emailBodyHtmlEnPaid: tpl("emailBodyHtmlEnPaid", DEFAULT_EMAIL_BODY_EN_PAID),
autoEmailOnWireTransferPlaced: bool("autoEmailOnWireTransferPlaced"), autoEmailOnWireTransferPlaced: bool("autoEmailOnWireTransferPlaced"),
autoEmailOnFulfilledNonWireTransfer: bool("autoEmailOnFulfilledNonWireTransfer"), autoEmailOnFulfilledNonWireTransfer: bool("autoEmailOnFulfilledNonWireTransfer"),
}; };
@@ -421,43 +434,59 @@ 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. The There are two body variants per language: the <strong>unpaid</strong>
built-in default has two variants per language: orders that are {" "}body is sent for outstanding orders and includes the
already paid are sent a body <em>without</em> the bank-transfer bank-transfer reference hint; the <strong>paid</strong> body is sent
reference hint, while outstanding (unpaid) orders include it. for orders that are already settled (paid / refunded / voided) and
A custom template you set here is used for both paid and unpaid omits that hint. Leave any field empty to fall back to its built-in
orders — the editor below shows the unpaid default. default.
</s-paragraph> </s-paragraph>
<Field <Field
label="Subject (German)" label="Subject (German)"
name="emailSubjectDe" name="emailSubjectDe"
defaultValue={settings.emailSubjectDe || DEFAULT_EMAIL_SUBJECT_DE} defaultValue={settings.emailSubjectDe || DEFAULT_EMAIL_SUBJECT_DE}
helpText="Variables like {{invoiceNumber}} are substituted at send time." helpText="Variables like {{invoiceNumber}} are substituted at send time. Used for both paid and unpaid."
/> />
<RichTextEditor <RichTextEditor
name="emailBodyHtmlDe" name="emailBodyHtmlDe"
label="Body (German)" label="Body (German, unpaid)"
defaultValue={settings.emailBodyHtmlDe || DEFAULT_EMAIL_BODY_DE} defaultValue={settings.emailBodyHtmlDe || DEFAULT_EMAIL_BODY_DE_UNPAID}
variables={EMAIL_VARS} variables={EMAIL_VARS}
minHeight={220} minHeight={220}
logoDataUrl={logoPreviewDataUrl} logoDataUrl={logoPreviewDataUrl}
/> />
<RichTextEditor
name="emailBodyHtmlDePaid"
label="Body (German, paid)"
defaultValue={settings.emailBodyHtmlDePaid || DEFAULT_EMAIL_BODY_DE_PAID}
variables={EMAIL_VARS}
minHeight={200}
logoDataUrl={logoPreviewDataUrl}
/>
<Field <Field
label="Subject (English)" label="Subject (English)"
name="emailSubjectEn" name="emailSubjectEn"
defaultValue={settings.emailSubjectEn || DEFAULT_EMAIL_SUBJECT_EN} defaultValue={settings.emailSubjectEn || DEFAULT_EMAIL_SUBJECT_EN}
helpText="Variables like {{invoiceNumber}} are substituted at send time." helpText="Variables like {{invoiceNumber}} are substituted at send time. Used for both paid and unpaid."
/> />
<RichTextEditor <RichTextEditor
name="emailBodyHtmlEn" name="emailBodyHtmlEn"
label="Body (English)" label="Body (English, unpaid)"
defaultValue={settings.emailBodyHtmlEn || DEFAULT_EMAIL_BODY_EN} defaultValue={settings.emailBodyHtmlEn || DEFAULT_EMAIL_BODY_EN_UNPAID}
variables={EMAIL_VARS} variables={EMAIL_VARS}
minHeight={220} minHeight={220}
logoDataUrl={logoPreviewDataUrl} logoDataUrl={logoPreviewDataUrl}
/> />
<RichTextEditor
name="emailBodyHtmlEnPaid"
label="Body (English, paid)"
defaultValue={settings.emailBodyHtmlEnPaid || DEFAULT_EMAIL_BODY_EN_PAID}
variables={EMAIL_VARS}
minHeight={200}
logoDataUrl={logoPreviewDataUrl}
/>
</s-stack> </s-stack>
</s-section> </s-section>
@@ -524,6 +553,15 @@ const EMAIL_VARS = [
{ token: "{{shopWebsite}}" }, { token: "{{shopWebsite}}" },
]; ];
/**
* Collapses insignificant differences (whitespace, attribute order is left
* as-is) so a rich-text-editor round-trip of an unchanged default still
* compares equal to the built-in template.
*/
function normaliseHtml(html: string): string {
return html.replace(/\s+/g, " ").trim();
}
interface FieldProps { interface FieldProps {
label: string; label: string;
name: string; name: string;
+12 -15
View File
@@ -91,26 +91,23 @@ 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 // Pick the body variant for the invoice's payment state: orders that are
// are already settled (paid / refunded / voided) get a body WITHOUT the // already settled (paid / refunded / voided) get a body WITHOUT the
// bank-transfer reference hint; outstanding orders (unpaid / partial) keep // bank-transfer reference hint; outstanding orders (unpaid / partial) keep
// it. Legacy rows have an empty paymentStatus → treated as unpaid. // it. Legacy invoice rows have an empty paymentStatus → treated as unpaid.
// A merchant-supplied custom template always wins over the defaults. // Each variant has its own merchant-customisable field; empty falls back to
// the built-in default for that variant.
const isPaid = const isPaid =
invoice.paymentStatus === "paid" || invoice.paymentStatus === "paid" ||
invoice.paymentStatus === "refunded" || invoice.paymentStatus === "refunded" ||
invoice.paymentStatus === "voided"; invoice.paymentStatus === "voided";
const defaultBody = const customBodyHtml = isPaid
language === "en" ? language === "en"
? isPaid ? settings.emailBodyHtmlEnPaid || DEFAULT_EMAIL_BODY_EN_PAID
? DEFAULT_EMAIL_BODY_EN_PAID : settings.emailBodyHtmlDePaid || DEFAULT_EMAIL_BODY_DE_PAID
: DEFAULT_EMAIL_BODY_EN_UNPAID : language === "en"
: isPaid ? settings.emailBodyHtmlEn || DEFAULT_EMAIL_BODY_EN_UNPAID
? DEFAULT_EMAIL_BODY_DE_PAID : settings.emailBodyHtmlDe || DEFAULT_EMAIL_BODY_DE_UNPAID;
: DEFAULT_EMAIL_BODY_DE_UNPAID;
const customBodyHtml =
(language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe) ||
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.
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "ShopSettings" ADD COLUMN "emailBodyHtmlDePaid" TEXT NOT NULL DEFAULT '';
ALTER TABLE "ShopSettings" ADD COLUMN "emailBodyHtmlEnPaid" TEXT NOT NULL DEFAULT '';
+4
View File
@@ -94,10 +94,14 @@ model ShopSettings {
smtpReplyTo String @default("") smtpReplyTo String @default("")
// Email templates (HTML, with {{var}} placeholders). Empty = use defaults. // Email templates (HTML, with {{var}} placeholders). Empty = use defaults.
// The Body fields are the UNPAID (outstanding) variant; the *Paid fields are
// sent for orders that are already settled (paid/refunded/voided).
emailSubjectDe String @default("") emailSubjectDe String @default("")
emailBodyHtmlDe String @default("") emailBodyHtmlDe String @default("")
emailSubjectEn String @default("") emailSubjectEn String @default("")
emailBodyHtmlEn String @default("") emailBodyHtmlEn String @default("")
emailBodyHtmlDePaid String @default("")
emailBodyHtmlEnPaid String @default("")
// Automations (webhook-driven, as a fallback to Shopify Flow which only // Automations (webhook-driven, as a fallback to Shopify Flow which only
// exposes custom-app actions on Plus stores). // exposes custom-app actions on Plus stores).