diff --git a/app/routes/app.settings.tsx b/app/routes/app.settings.tsx index 6fdf3dd..a46f2e0 100644 --- a/app/routes/app.settings.tsx +++ b/app/routes/app.settings.tsx @@ -421,7 +421,12 @@ 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. + 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. {{companyName}}

Danke für deinen Einkauf!

Die Rechnung befindet sich im Anhang. -

-

-Bei Überweisung bitte die Rechnungs-Nummer als Referenz verwenden: -{{invoiceNumber}}
-Besten Dank! -

+

`; + +const DE_FOOT = `\

{{companyName}}

@@ -27,17 +30,26 @@ Besten Dank! 🌐 {{shopWebsite}}

`; -const EN_HTML = `\ +const DE_PAYMENT_HINT = `\ +

+Bei Überweisung bitte die Rechnungs-Nummer als Referenz verwenden: +{{invoiceNumber}}
+Besten Dank! +

`; + +const DE_PAID_NOTE = `\ +

+Besten Dank! +

`; + +const EN_HEAD = `\

{{companyName}}

Thank you for your purchase!

Please find the invoice attached. -

-

-When paying by bank transfer, please use the invoice number as the reference: -{{invoiceNumber}}
-Thanks a lot! -

+

`; + +const EN_FOOT = `\

{{companyName}}

@@ -46,7 +58,35 @@ Thanks a lot! 🌐 {{shopWebsite}}

`; +const EN_PAYMENT_HINT = `\ +

+When paying by bank transfer, please use the invoice number as the reference: +{{invoiceNumber}}
+Thanks a lot! +

`; + +const EN_PAID_NOTE = `\ +

+Thanks a lot! +

`; + +// 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; diff --git a/app/services/invoice/generateInvoice.server.tsx b/app/services/invoice/generateInvoice.server.tsx index 8633aba..ac04de4 100644 --- a/app/services/invoice/generateInvoice.server.tsx +++ b/app/services/invoice/generateInvoice.server.tsx @@ -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", }, }); diff --git a/prisma/migrations/20260824000000_add_invoice_payment_status/migration.sql b/prisma/migrations/20260824000000_add_invoice_payment_status/migration.sql new file mode 100644 index 0000000..105187c --- /dev/null +++ b/prisma/migrations/20260824000000_add_invoice_payment_status/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Invoice" ADD COLUMN "paymentStatus" TEXT NOT NULL DEFAULT ''; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5f77eed..4c5bcd0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -141,7 +141,12 @@ model Invoice { // Snapshots (JSON strings on sqlite) 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 issuedAt DateTime @default(now()) diff --git a/tests/email-templates.test.ts b/tests/email-templates.test.ts new file mode 100644 index 0000000..96b67c2 --- /dev/null +++ b/tests/email-templates.test.ts @@ -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\}\}/); + } + }); +});