From 602b336a9411009c723960e5fedf918b95fd9c65 Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Mon, 24 Aug 2026 16:35:09 +0200 Subject: [PATCH] feat(email): per-variant paid/unpaid body templates in settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .vscode/settings.json | 4 +- app/routes/app.settings.tsx | 70 ++++++++++++++----- app/services/invoice/email.server.ts | 27 ++++--- .../migration.sql | 3 + prisma/schema.prisma | 4 ++ 5 files changed, 76 insertions(+), 32 deletions(-) create mode 100644 prisma/migrations/20260824010000_add_paid_email_templates/migration.sql diff --git a/.vscode/settings.json b/.vscode/settings.json index 7a5ff45..b370a80 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,8 @@ "chat.tools.terminal.autoApprove": { "setopt": true, "npx shopify": true, - "npx tsx": true + "npx tsx": true, + "./update_dev_system.sh": true, + "./show_dev_logs.sh": true } } \ No newline at end of file diff --git a/app/routes/app.settings.tsx b/app/routes/app.settings.tsx index a46f2e0..955c4b0 100644 --- a/app/routes/app.settings.tsx +++ b/app/routes/app.settings.tsx @@ -17,8 +17,10 @@ import { import { encryptField } from "../services/crypto/fieldCrypto.server"; import { RichTextEditor } from "../components/RichTextEditor"; 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 "../services/invoice/emailTemplates"; @@ -80,6 +82,15 @@ export const action = async ({ request }: ActionFunctionArgs) => { 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(); if (vatId && !isValidAtVatId(vatId)) { 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"), smtpReplyTo: str("smtpReplyTo"), emailSubjectDe: str("emailSubjectDe"), - emailBodyHtmlDe: str("emailBodyHtmlDe"), + emailBodyHtmlDe: tpl("emailBodyHtmlDe", DEFAULT_EMAIL_BODY_DE_UNPAID), 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"), autoEmailOnFulfilledNonWireTransfer: bool("autoEmailOnFulfilledNonWireTransfer"), }; @@ -421,43 +434,59 @@ export default function SettingsRoute() { These templates are used when sending the invoice PDF by email. - 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 without 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. + There are two body variants per language: the unpaid + {" "}body is sent for outstanding orders and includes the + bank-transfer reference hint; the paid body is sent + for orders that are already settled (paid / refunded / voided) and + omits that hint. Leave any field empty to fall back to its built-in + default. + + @@ -524,6 +553,15 @@ const EMAIL_VARS = [ { 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 { label: string; name: string; diff --git a/app/services/invoice/email.server.ts b/app/services/invoice/email.server.ts index 6df5228..69452fd 100644 --- a/app/services/invoice/email.server.ts +++ b/app/services/invoice/email.server.ts @@ -91,26 +91,23 @@ 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 + // Pick the 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. + // it. Legacy invoice rows have an empty paymentStatus → treated as unpaid. + // Each variant has its own merchant-customisable field; empty falls back to + // the built-in default for that variant. 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) || - defaultBody; + const customBodyHtml = isPaid + ? language === "en" + ? settings.emailBodyHtmlEnPaid || DEFAULT_EMAIL_BODY_EN_PAID + : settings.emailBodyHtmlDePaid || DEFAULT_EMAIL_BODY_DE_PAID + : language === "en" + ? settings.emailBodyHtmlEn || DEFAULT_EMAIL_BODY_EN_UNPAID + : settings.emailBodyHtmlDe || DEFAULT_EMAIL_BODY_DE_UNPAID; const body = renderHtmlBody(renderTemplate(customBodyHtml, vars)); // If the rendered body references the inline logo, attach it. diff --git a/prisma/migrations/20260824010000_add_paid_email_templates/migration.sql b/prisma/migrations/20260824010000_add_paid_email_templates/migration.sql new file mode 100644 index 0000000..29294fa --- /dev/null +++ b/prisma/migrations/20260824010000_add_paid_email_templates/migration.sql @@ -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 ''; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4c5bcd0..3ba8233 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -94,10 +94,14 @@ model ShopSettings { smtpReplyTo String @default("") // 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("") emailBodyHtmlDe String @default("") emailSubjectEn String @default("") emailBodyHtmlEn String @default("") + emailBodyHtmlDePaid String @default("") + emailBodyHtmlEnPaid String @default("") // Automations (webhook-driven, as a fallback to Shopify Flow which only // exposes custom-app actions on Plus stores).