import assert from 'node:assert/strict' import { existsSync, readFileSync, readdirSync } from 'node:fs' import { test } from 'node:test' import { escapeHtml, htmlToText, loadTemplate, renderMail, templateIds } from '../server/services/mailTemplates.js' import { contractAttachmentFor, termsDocFor } from '../server/services/products.js' const DIR = new URL('../server/mail-templates/', import.meta.url) // K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); // Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39) const EXPECTED = [ ...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`), 'K12', 'K13', 'K14', ...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`), 'T12', 'T13', 'T14', ] const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])) const allBlocks = (tpl, on) => Object.fromEntries(tpl.blocks.map((b) => [b, on])) test('alle Vorlagen sind da', () => { assert.deepEqual(templateIds(), EXPECTED) }) for (const id of EXPECTED) { test(`${id}: Kopfkommentar passt zur Vorlage`, () => { const tpl = loadTemplate(id) const source = readFileSync(new URL(tpl.file, DIR), 'utf8') const title = source.match(/([\s\S]*?)<\/title>/)[1] assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich') // F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben) oder [Datenschutz] (Loeschfristen) assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz)\]/.test(tpl.subject) || id === 'T01', 'Team-Hinweise ab T02 mit [Abo], [Speicher] oder [Datenschutz]') const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1])) assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter') const opened = [...tpl.body.matchAll(/<!-- WENN (\w+) -->/g)].map((m) => m[1]) const closed = [...tpl.body.matchAll(/<!-- ENDE (\w+) -->/g)].map((m) => m[1]) assert.deepEqual([...new Set(opened)].sort(), [...tpl.blocks].sort(), 'deklarierte = verwendete Blöcke') assert.deepEqual(opened.slice().sort(), closed.slice().sort(), 'jeder Block wird geschlossen') }) test(`${id}: rendert ohne Reste, mit und ohne optionale Blöcke`, () => { const tpl = loadTemplate(id) for (const on of [true, false]) { const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, on)) assert.deepEqual(mail.problems, []) assert.doesNotMatch(mail.html, /\{\{|WENN|ENDE|Platzhalter:/) assert.match(mail.html, /<!--\[if mso\]>/, 'bedingter Outlook-Kommentar bleibt') assert.equal((mail.html.match(/<!--/g) || []).length, 1, 'nur der Outlook-Kommentar bleibt') assert.match(mail.html, /src="https:\/\/project\.webklar\.com\/webklar-logo\.png"/) assert.doesNotMatch(mail.subject, /\{\{|\n/) assert.doesNotMatch(mail.text, /<[a-z/!]|&[a-z#0-9]+;|\{\{/i, 'Textfassung ohne Tags und Entities') } }) } test('Werte werden HTML-sicher eingesetzt, Zeilenumbrüche werden <br>', () => { const mail = renderMail('T01', { kunde_name: 'Max <b>&"\'', nachricht: 'Zeile 1\nZeile <2>' }) assert.match(mail.html, /Max <b>&"' hat im Kundenportal geschrieben/) assert.match(mail.html, /Zeile 1<br>Zeile <2>/) assert.doesNotMatch(mail.html, /<b>&/) assert.equal(mail.subject, 'Neue Portalnachricht von Max <b>&"\'') assert.match(mail.text, /Zeile 1\nZeile <2>/) assert.equal(escapeHtml('a&b'), 'a&b') }) test('optionale Blöcke: nur eingeschaltete erscheinen', () => { const values = { name: 'Max', produkt: 'Wartungs-Abo', preis: '50,00 € / Monat', angebot_text: 'Erste 2 Monate je 25,00 €', vertragsschluss: '01.10.2026', leistungsbeginn: '15.10.2026', mindestlaufzeit_bis: '15.10.2027', bedingungen_version: 'v3', zustimmung_zeitpunkt: '01.10.2026, 10:00' } const early = renderMail('K01', values, { angebot: false, sofortbeginn: true, beginn_nach_widerrufsfrist: false }) assert.doesNotMatch(early.html, /Ihr Angebot:/) assert.match(early.html, /Sie haben ausdrücklich verlangt/) assert.doesNotMatch(early.html, /erst nach Ablauf der Widerrufsfrist/) const late = renderMail('K01', values, { angebot: true, sofortbeginn: false, beginn_nach_widerrufsfrist: true }) assert.match(late.html, /Ihr Angebot:<\/strong> Erste 2 Monate je 25,00 € \(wie vereinbart\)/) assert.doesNotMatch(late.html, /Sie haben ausdrücklich verlangt/) assert.match(late.html, /Leistungsbeginn und erste Abbuchung: 15\.10\.2026\./) }) test('verschachtelte Blöcke (T09) werden aufgelöst', () => { const values = { email: 'a@example.com', name: 'A', kundennummer: '', stripe_abo_id: 'sub_1', abo_beginn: '01.10.2026', fristende: '15.10.2026', rechtzeitig: 'RECHTZEITIG' } const ok = renderMail('T09', values, { abo_gefunden: true, auf_kuendigung_gesetzt: true }) assert.match(ok.html, /sub_1 <span[^>]*>\(auf Kündigung gesetzt\)<\/span>/) assert.doesNotMatch(ok.html, /KEIN aktives Abo|NICHT automatisch/) const failed = renderMail('T09', values, { abo_gefunden: true, auf_kuendigung_gesetzt: false, kuendigung_fehlgeschlagen: true }) assert.doesNotMatch(failed.html, /auf Kündigung gesetzt\)/) assert.match(failed.html, /Abo sub_1 konnte NICHT automatisch auf Kündigung gesetzt werden/) const none = renderMail('T09', values, { kein_abo_gefunden: true }) assert.doesNotMatch(none.html, /Stripe-Abo|Abo-Beginn|Widerrufsfrist bis/) assert.match(none.html, /KEIN aktives Abo zu dieser E-Mail gefunden/) assert.doesNotMatch(none.html, /WENN|ENDE/) }) test('Anrede ohne Namen, fehlende und unbekannte Werte werden gemeldet', () => { const mail = renderMail('K10', { name: '', antrag_datum: '01.10.2026', antrag_nr: 'AB12CD34', begruendung: 'x', extra: 1 }, { gibtsnicht: true }) assert.match(mail.html, /Guten Tag,<br>/) assert.match(mail.text, /^Guten Tag,$/m) assert.deepEqual(mail.problems.sort(), ['unbekannter Block "gibtsnicht"', 'unbekannter Platzhalter {{extra}}']) const missing = renderMail('K10', { name: 'A', antrag_datum: '01.10.2026', antrag_nr: 'X' }) assert.deepEqual(missing.problems, ['kein Wert für {{begruendung}}']) }) test('Textfassung K01: alle Vertragsangaben lesbar', () => { // F34: Produkt und Preis kommen aus dem Katalog, der Produktblock (hier wartung) bringt die Liste „Enthalten“ const mail = renderMail( 'K01', { name: 'Erika Musterfrau', produkt: 'Wartungs-Abo', preis: '50,00 € / Monat', angebot_text: '', vertragsschluss: '01.10.2026', leistungsbeginn: '01.10.2026', mindestlaufzeit_bis: '01.10.2027', bedingungen_version: '2026-09-25-v3', zustimmung_zeitpunkt: '01.10.2026, 10:07' }, { sofortbeginn: true, wartung: true } ) assert.deepEqual(mail.problems, []) const t = mail.text for (const line of [ 'Guten Tag Erika Musterfrau,', '50,00 € / Monat', 'gemäß § 19 UStG ohne Umsatzsteuer', 'Vertragsschluss: 01.10.2026', 'Leistungsbeginn: 01.10.2026', 'Laufzeit: Mindestlaufzeit 12 Monate bis 01.10.2027. Kündigung zu diesem Termin bis einen Monat vorher. Danach läuft das Abo unbefristet und ist jederzeit zum Ende des Abrechnungsmonats kündbar.', '• 1 Domain', 'Zum Kundenportal: https://project.webklar.com/dashboard.html', '📎 Widerrufsbelehrung mit Muster-Widerrufsformular', 'Ihre Zustimmung zu den Vertragsbedingungen (Version 2026-09-25-v3) einschließlich des Auftragsverarbeitungsvertrags haben wir am 01.10.2026, 10:07 Uhr erhalten.', 'Vertrag widerrufen (https://project.webklar.com/widerrufen.html)', 'Vertrag kündigen (https://project.webklar.com/kuendigen.html)', 'Telefon 0176 23726355 · support@webklar.com', 'Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein', ]) { assert.ok(t.split('\n').includes(line), `Zeile fehlt: ${line}\n---\n${t}`) } assert.doesNotMatch(t, /Ihr Wartungs-Abo ist abgeschlossen\. Alle Vertragsunterlagen/, 'Vorschautext nicht im Text') }) test('htmlToText: Links, Buttons und Leerzeilen', () => { const t = htmlToText('<body><table><tr><td class="px">A <a href="mailto:x@y.de">x@y.de</a> B</td></tr><tr><td class="px"><a href="https://e.de/p" style="display:block">Los →</a></td></tr></table></body>') assert.equal(t, 'A x@y.de B\n\nLos: https://e.de/p\n') }) test('Vorlagen-Ordner enthält nur Vorlagen', () => { assert.deepEqual(readdirSync(DIR).filter((f) => !/^[KT]\d{2} .*\.html$/.test(f)), []) }) test('K09: Gutschrift wird mit dem Abo verrechnet, auch nach einem Wechsel', () => { const tpl = loadTemplate('K09') const mail = renderMail('K09', sampleValues(tpl), allBlocks(tpl, true)) assert.match(mail.text, /mit den nächsten Monatsgebühren Ihres Abos\. Auf Ihren Rechnungen erscheint sie als angerechnetes Guthaben\./) assert.match(mail.text, /Eine Auszahlung ist nicht möglich\. Wechseln Sie in ein anderes Abo, verrechnen wir die Gutschrift weiter; endet Ihr Abo ohne einen solchen Wechsel, entfällt ein noch nicht verrechneter Rest\./) assert.doesNotMatch(mail.html, /Wartungs-Abo|Vertragsende/) }) // Task 7 (Fix 1): konnte das Vertragsereignis "abschluss" dauerhaft nicht gespeichert werden (4xx bei // Appwrite), erfaehrt das Team das im T02-Hinweis statt in einem stillen Webhook-Fehler test('T02: Block nachweis_fehlt weist auf den fehlenden Nachweis hin', () => { const tpl = loadTemplate('T02') const values = sampleValues(tpl) const withBlock = renderMail('T02', values, { nachweis_fehlt: true }) assert.deepEqual(withBlock.problems, []) assert.match(withBlock.text, /Nachweis nicht gespeichert/) assert.match(withBlock.text, /contractEvents/) assert.doesNotMatch(renderMail('T02', values, {}).text, /Nachweis nicht gespeichert/) }) // Task 9 (Rechtsrunde 28.09.2026): Wechsel ins Wartungs-Abo wird erst mit der Zahlung wirksam test('K12, K14, T12: Betreff, Platzhalter und Blöcke des Wechsels', () => { assert.equal(loadTemplate('K12').subject, 'Ihr Wechsel ins WEBklar Wartungs-Abo – Bestätigung') assert.deepEqual(loadTemplate('K12').placeholders, ['name', 'wechseltag', 'betrag_heute', 'monatspreis', 'mindestlaufzeit_bis', 'bedingungen_version', 'zustimmung_zeitpunkt']) assert.equal(loadTemplate('K14').subject, 'Ihr Wechsel ins WEBklar Wartungs-Abo ist nicht zustande gekommen') assert.deepEqual(loadTemplate('K14').placeholders, ['name', 'grund']) assert.equal(loadTemplate('T12').subject, '[Abo] Wechsel ins Wartungs-Abo: {{email}}') // F37: feste Platzhalter fuer T12 assert.deepEqual(loadTemplate('T12').placeholders, ['email', 'name', 'stripe_abo_id', 'wechseltag', 'betrag_heute', 'monatspreis']) for (const block of ['gescheitert', 'lastschrift_gescheitert']) assert.ok(loadTemplate('T12').blocks.includes(block), block) }) test('K12: Block zahlung_offen wörtlich, Block lastschrift mit dem Satz aus § 9 Abs. 3', () => { const values = sampleValues(loadTemplate('K12')) const offen = renderMail('K12', values, { zahlung_offen: true }) assert.ok(offen.text.includes('Der Wechsel wird wirksam, sobald die Zahlung bestätigt ist. Andernfalls läuft Ihr Hosting-Abo mit Preis und Mindestlaufzeit wie bisher weiter.')) assert.doesNotMatch(offen.text, /SEPA-Lastschrift/) const sepa = renderMail('K12', values, { lastschrift: true }) assert.match(sepa.text, /Wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt/) assert.match(sepa.text, /Ihr Abbuchungstag bleibt der Wechseltag/) assert.doesNotMatch(sepa.text, /sobald die Zahlung bestätigt ist/) const plain = renderMail('K12', values, {}) assert.doesNotMatch(plain.text, /sobald die Zahlung bestätigt ist|Wird die Lastschrift nicht eingelöst/) // Vertragsbestaetigung: Preis mit § 19 UStG, Mindestlaufzeit, Unterlagen, Zustimmung zum Sofortbeginn for (const text of ['gemäß § 19 UStG ohne Umsatzsteuer', 'Mindestlaufzeit 12 Monate bis Wert-mindestlaufzeit_bis.', 'Widerrufsbelehrung mit Muster-Widerrufsformular', 'Sie haben ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit dem Wartungs-Abo beginnen']) { assert.ok(plain.text.includes(text), text) } }) test('K14: Hosting-Abo läuft wie vor dem Wechsel weiter; SEPA: Abbuchungstag bleibt', () => { const values = { name: 'Kunde', grund: 'Die SEPA-Lastschrift für die Wechselrechnung wurde nicht eingelöst.' } const card = renderMail('K14', values, {}) assert.deepEqual(card.problems, []) assert.ok(card.text.includes('Ihr Hosting-Abo läuft mit Preis und Mindestlaufzeit wie vor dem Wechsel weiter.')) assert.ok(card.text.includes(values.grund)) assert.doesNotMatch(card.text, /Abbuchungstag/) assert.match(renderMail('K14', values, { lastschrift: true }).text, /Ihr Abbuchungstag bleibt der Tag des Wechsels\./) }) // Nach der Pruefung der Rechtsrunden-Texte: Stripe kann die Frist auch kuerzer setzen test('Wechsel-Mails nennen keine feste Frist von 23 Stunden', () => { for (const id of ['K12', 'K14', 'T12']) { const tpl = loadTemplate(id) assert.doesNotMatch(renderMail(id, sampleValues(tpl), allBlocks(tpl, true)).text, /23 Stunden/, id) } }) // Task 8 (Fix 1): K13 (Kunde) und T13 (Team) fuer den Zusatzspeicher, Aufbau wie K12/T12; Endfassung der Texte in Aufgabe 12 test('K13, T13: Betreff, Platzhalter und Blöcke des Zusatzspeichers', () => { assert.equal(loadTemplate('K13').subject, 'Ihr E-Mail-Zusatzspeicher – Bestätigung') assert.deepEqual(loadTemplate('K13').placeholders, ['name', 'pakete', 'gesamt_gb', 'monatspreis_zusatz', 'wirksam_ab']) // Fix 2 (3): plus = Einrichtung mit anteiliger Berechnung, plus_bezahlt = Plus bis zur schon bezahlten Menge, // bestellung = Unterlagen und Widerruf (bei beiden), minus = Verringerung zum Periodenende assert.deepEqual(loadTemplate('K13').blocks, ['plus', 'plus_bezahlt', 'bestellung', 'minus']) assert.equal(loadTemplate('T13').subject, '[Speicher] {{aktion}}: {{email}}') for (const p of ['aktion', 'email', 'name', 'stripe_abo_id', 'postfaecher_hinweis', 'neue_groesse_gb', 'pakete', 'faellig']) { assert.ok(loadTemplate('T13').placeholders.includes(p), p) } assert.deepEqual([...loadTemplate('T13').blocks].sort(), ['einrichten', 'nachweis_fehlt', 'periodenende', 'teilweise']) const values = sampleValues(loadTemplate('K13')) const plus = renderMail('K13', values, { plus: true, bestellung: true }) assert.deepEqual(plus.problems, []) assert.match(plus.text, /gemäß § 19 UStG ohne Umsatzsteuer/) assert.match(plus.text, /wird eingerichtet/) assert.match(plus.text, /14 Tagen widerrufen/) assert.match(plus.text, /Vertragsunterlagen im Anhang/) assert.doesNotMatch(plus.text, /ohne anteilige Erstattung|keine zusätzlichen Kosten/) const paid = renderMail('K13', values, { plus_bezahlt: true, bestellung: true }) assert.ok(paid.text.includes('Die Pakete sind für den laufenden Monat schon bezahlt; es entstehen keine zusätzlichen Kosten. Ab dem nächsten Abrechnungsmonat gelten Wert-pakete Pakete.')) assert.match(paid.text, /14 Tagen widerrufen/) assert.match(paid.text, /Vertragsunterlagen im Anhang/) assert.doesNotMatch(paid.text, /wird eingerichtet|anteilig/) const minus = renderMail('K13', values, { minus: true }) assert.match(minus.text, /ohne anteilige Erstattung/) assert.match(minus.text, /keine neuen E-Mails/) assert.doesNotMatch(minus.text, /wird eingerichtet|14 Tagen widerrufen|im Anhang|schon bezahlt/) const t = loadTemplate('T13') const teilweise = renderMail('T13', { ...sampleValues(t), pakete: 5 }, { einrichten: true, teilweise: true }) assert.deepEqual(teilweise.problems, []) assert.match(teilweise.text, /Schritt 2 fehlgeschlagen/) assert.match(teilweise.text, /von Hand auf 5 setzen/) assert.match(renderMail('T13', sampleValues(t), { nachweis_fehlt: true }).text, /Nachweis nicht gespeichert/) assert.match(renderMail('T13', sampleValues(t), { periodenende: true }).text, /Periodenende|zum Wert-faellig/) assert.doesNotMatch(renderMail('T13', sampleValues(t), {}).text, /Schritt 2 fehlgeschlagen|Nachweis nicht gespeichert/) }) // Task 10: Kuendigung und Widerruf mit Auswahl - K03/T08 nennen den Vertrag und die gekuendigten Pakete, K04/T09 die // Bestellung; die Handgriffe je Bestellung und "wir pruefen" ohne offene Frist. Endfassung der Texte in Aufgabe 12 test('K03, K04, T08, T09: Vertrag bzw. Bestellung, Pakete und die Blöcke der Auswahl', () => { for (const p of ['vertrag', 'pakete', 'pakete_neu']) assert.ok(loadTemplate('K03').placeholders.includes(p), `K03 ${p}`) for (const b of ['zusatzspeicher', 'pruefen', 'abo']) assert.ok(loadTemplate('K03').blocks.includes(b), `K03 ${b}`) assert.ok(loadTemplate('K04').placeholders.includes('bestellung')) assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'pruefen']) for (const p of ['vertrag', 'pakete', 'fehler']) assert.ok(loadTemplate('T08').placeholders.includes(p), `T08 ${p}`) for (const b of ['zusatzspeicher', 'kein_speicher', 'wechsel_ausstehend', 'speicher_fehlgeschlagen', 'nachweis_fehlt']) assert.ok(loadTemplate('T08').blocks.includes(b), `T08 ${b}`) for (const p of ['bestellung', 'ereignisse']) assert.ok(loadTemplate('T09').placeholders.includes(p), `T09 ${p}`) for (const b of ['abschluss', 'wechsel', 'zusatzspeicher', 'keine_frist', 'nachweis_fehlt']) assert.ok(loadTemplate('T09').blocks.includes(b), `T09 ${b}`) const k03 = { ...sampleValues(loadTemplate('K03')), vertrag: 'E-Mail-Zusatzspeicher zum WEBklar Hosting-Abo', pakete: 2, pakete_neu: 1, vertragsende: '31.10.2026' } const speicher = renderMail('K03', k03, { zusatzspeicher: true }) assert.deepEqual(speicher.problems, []) assert.match(speicher.text, /Vertrag: E-Mail-Zusatzspeicher zum WEBklar Hosting-Abo/) assert.match(speicher.text, /Ihr Zusatzspeicher verringert sich zum\n31\.10\.2026/) assert.match(speicher.text, /um 2 Paket\(e\) gekündigt; danach bleiben 1 Paket\(e\)/) assert.doesNotMatch(speicher.text, /Wartungs-Abo\n|Stundenkontingent|Stundenguthaben|Wir prüfen Ihre Kündigung/) assert.match(renderMail('K03', k03, { pruefen: true }).text, /bestätigen Ihnen den Zeitpunkt, zu dem sie wirkt, gesondert per E-Mail/) // Task 12 (Fix 1, 6): das Stundenkontingent gibt es nur im Wartungs-Abo (Block wartung innerhalb von abo); es ist // kein Geldguthaben (v4 § 4 Abs. 1), darum „nicht genutzte Arbeitszeit“ statt „Stundenguthaben“ assert.match(renderMail('K03', k03, { abo: true, wartung: true }).text, /Nicht genutzte Arbeitszeit aus dem Stundenkontingent verfällt mit Vertragsende\./) assert.doesNotMatch(renderMail('K03', k03, { abo: true }).text, /Stundenkontingent|Stundenguthaben/) assert.doesNotMatch(renderMail('K03', k03, { abo: true, wartung: true }).text, /Stundenguthaben/) assert.match(renderMail('K03', k03, { abo: true }).text, /Domain-AuthCode/) const k04 = { ...sampleValues(loadTemplate('K04')), bestellung: 'Wechsel ins Wartungs-Abo' } assert.match(renderMail('K04', k04, { wechsel: true }).text, /Widerruf der Bestellung „Wechsel ins Wartungs-Abo“/) // Task 12: Wortlaut der Hinweise zum Widerruf (Fassung 4) assert.match(renderMail('K04', k04, { wechsel: true }).text, /zu den bisherigen Bedingungen weiter/) assert.match(renderMail('K04', k04, { zusatzspeicher: true }).text, /Menge vor der Bestellung/) assert.match(renderMail('K04', k04, { abschluss: true }).text, /keine weiteren Abbuchungen/) assert.match(renderMail('K04', k04, { pruefen: true }).text, /prüfen, ob für diese Bestellung eine Widerrufsfrist läuft/) assert.doesNotMatch(renderMail('K04', k04, {}).text, /Wie es weitergeht|WEBklar Wartungs-Abo“/) const t08 = sampleValues(loadTemplate('T08')) assert.match(renderMail('T08', t08, { wechsel_ausstehend: true }).text, /Wechsel ausstehend – Speicher nach Abschluss des Wechsels von Hand reduzieren/) assert.match(renderMail('T08', t08, { speicher_fehlgeschlagen: true }).text, /NICHT automatisch verringert/) assert.match(renderMail('T08', t08, { kein_speicher: true }).text, /kein Zusatzspeicher gebucht/) assert.match(renderMail('T08', t08, { nachweis_fehlt: true }).text, /Nachweis nicht gespeichert/) const t09 = { ...sampleValues(loadTemplate('T09')), ereignisse: 'speicher-1: 0 → 2 Pakete\nspeicher-2: 1 → 3 Pakete' } const speicherT09 = renderMail('T09', t09, { abo_gefunden: true, zusatzspeicher: true }) assert.match(speicherT09.text, /Menge zurück/) assert.match(speicherT09.text, /speicher-1: 0 → 2 Pakete\nspeicher-2: 1 → 3 Pakete/) assert.doesNotMatch(speicherT09.text, /Abo in Stripe sofort beenden/) assert.match(renderMail('T09', t09, { abo_gefunden: true, abschluss: true }).text, /Abo in Stripe sofort beenden/) assert.match(renderMail('T09', t09, { abo_gefunden: true, keine_frist: true }).text, /keine offene Frist gefunden/) assert.match(renderMail('T09', t09, { abo_gefunden: true, wechsel: true }).text, /Preis zurück auf das Hosting-Abo/) // Fix 1: Paketzahl in jedem Speicherpfad (K03/T08), Pruefung der Kuendigungsart, T09-Hinweise assert.match(renderMail('K03', { ...k03, pakete_angabe: 'alle gebuchten Pakete' }, { speicher_angaben: true, pruefen: true }).text, /Pakete: alle gebuchten Pakete/) assert.match(renderMail('K03', { ...k03, kuendigungsart: 'außerordentliche Kündigung' }, { speicher_kind_pruefung: true }).text, /Ihre außerordentliche Kündigung wird geprüft/) const t08a = { ...t08, pakete_angabe: '2 Paket(e)', kuendigungsart: 'Außerordentliche Kündigung' } assert.match(renderMail('T08', t08a, { speicher_angaben: true, wechsel_ausstehend: true }).text, /Pakete: 2 Paket\(e\)/) assert.match(renderMail('T08', t08a, { wechsel_ausstehend: true }).text, /gekündigt: 2 Paket\(e\)/) assert.match(renderMail('T08', t08a, { speicher_kind_pruefung: true }).text, /Außerordentliche Kündigung des Zusatzspeichers/) const t09b = { ...t09, fristende: 'keine Frist gefunden', pruefhinweis: 'Ereignisse nicht lesbar – bitte von Hand prüfen' } assert.match(renderMail('T09', t09b, { abo_gefunden: true }).text, /Widerrufsfrist bis: keine Frist gefunden/) assert.match(renderMail('T09', t09b, { abo_gefunden: true, nicht_pruefbar: true }).text, /Ereignisse nicht lesbar – bitte von Hand prüfen/) assert.match(renderMail('T09', t09b, { abo_gefunden: true, wechsel: true, wechsel_offen: true }).text, /Wechselrechnung noch offen – stornieren/) assert.match(renderMail('T09', t09b, { abo_gefunden: true, abschluss_verspaetet: true }).text, /als ordentliche Kündigung behandeln/) assert.match(renderMail('T09', t09b, { abo_gefunden: true, bereits_widerrufen: true }).text, /schon widerrufen/) }) // --------------------------------------------------------------------------- // Task 12: Mails je Produkt (Hosting-Abo, Wartungs-Abo), Vertragsunterlagen der zugestimmten Fassung, // Paragraph zum Vertragsende je Fassung, Folgen des Widerrufs wie in den Hinweisen der Fassung 4, UptimeRobot // --------------------------------------------------------------------------- test('Neue Vorlagen vorhanden', () => { for (const id of ['K12', 'K13', 'K14', 'T12', 'T13']) assert.ok(templateIds().includes(id), id) }) test('K01 Hosting: 1 Postfach, keine Arbeitsstunde; Wartung: beliebig viele Postfächer', () => { const base = { name: 'K', produkt: 'Hosting-Abo', preis: '15,00 € / Monat', vertragsschluss: '01.10.2026', leistungsbeginn: '01.10.2026', mindestlaufzeit_bis: '01.10.2027', bedingungen_version: '2026-10-05-v4', zustimmung_zeitpunkt: '01.10.2026, 10:00', angebot_text: '' } const h = renderMail('K01', base, { hosting: true, sofortbeginn: true }) assert.equal(h.subject, 'Ihr WEBklar Hosting-Abo – Vertragsbestätigung') assert.ok(h.text.includes('1 E-Mail-Postfach') && !h.text.includes('Stunde Arbeitszeit'), h.text) assert.ok(h.text.includes('15,00 € / Monat') && h.text.includes('gemäß § 19 UStG ohne Umsatzsteuer')) assert.ok(h.text.includes('Ihr Hosting-Abo ist bestätigt.') && h.text.includes('Abschluss Ihres Hosting-Abos')) assert.doesNotMatch(h.text, /Stundenkontingent/) assert.deepEqual(h.problems, []) const w = renderMail('K01', { ...base, produkt: 'Wartungs-Abo', preis: '50,00 € / Monat' }, { wartung: true, sofortbeginn: true }) assert.ok(w.text.includes('beliebig viele Postfächer'), w.text) assert.ok(w.text.includes('1 Stunde Arbeitszeit je bezahltem Monat') && w.text.includes('Stundenkontingent')) assert.doesNotMatch(w.text, /1 E-Mail-Postfach\n/) assert.deepEqual(w.problems, []) }) test('T02 und T07 erinnern an UptimeRobot', () => { // F38: echte Platzhalter, keine Unstimmigkeiten const t02 = renderMail('T02', { produkt: 'Hosting-Abo', email: 'k@example.invalid', kunde_name: 'K', appwrite_id: 'c1', stripe_abo_id: 'sub_1', angebot_text: '' }, {}) assert.deepEqual(t02.problems, []) assert.ok(t02.subject.startsWith('[Abo] Neues Hosting-Abo') && t02.text.includes('Website als Monitor in UptimeRobot anlegen'), t02.text) assert.equal(loadTemplate('T02').file, 'T02 Neues Abo.html') assert.match(renderMail('T02', { produkt: 'Hosting-Abo', email: 'k@example.invalid', kunde_name: 'K', appwrite_id: 'c1', stripe_abo_id: 'sub_1', angebot_text: '' }, { nachweis_fehlt: true }).text, /Nachweis nicht gespeichert/) const t07 = renderMail('T07', { produkt: 'Hosting-Abo', email: 'k@example.invalid', stripe_abo_id: 'sub_1', status: 'canceled', vertragsende_paragraph: '§ 11' }, {}) assert.deepEqual(t07.problems, []) assert.ok(t07.text.includes('Monitor in UptimeRobot entfernen'), t07.text) }) test('Keine Vorlage nennt fest „Wartungs-Abo“ im Betreff außer K12, K14 und T12', () => { for (const id of templateIds()) if (!['K12', 'K14', 'T12'].includes(id)) assert.ok(!loadTemplate(id).subject.includes('Wartungs-Abo'), id) }) test('K01-Anhang: Fassung 3 für v3-Abos, Fassung 4 sonst', () => { // contractAttachmentFor(termsVersion) liefert { filename, path } und wird von sendContractConfirmation benutzt assert.equal(contractAttachmentFor('2026-09-25-v3').filename, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf') assert.equal(contractAttachmentFor('2026-10-05-v4').filename, 'WEBklar-Abo-Vertragsunterlagen.pdf') assert.ok(existsSync(contractAttachmentFor('2026-10-05-v4').path)) assert.ok(existsSync(contractAttachmentFor('2026-09-25-v3').path)) }) // Rechtsrunde: In Fassung 4 ist § 9 der Wechsel, das Vertragsende steht in § 11 (F08) test('K02, K03, T05, T07: Paragraph zum Vertragsende aus der Fassung des Kunden', () => { assert.equal(termsDocFor('2026-09-25-v3').endParagraph, '§ 9') assert.equal(termsDocFor(null).endParagraph, '§ 9') assert.equal(termsDocFor('2026-10-05-v4').endParagraph, '§ 11') for (const id of ['K02', 'K03', 'T05', 'T07']) { const tpl = loadTemplate(id) assert.ok(tpl.placeholders.includes('vertragsende_paragraph'), id) const v4 = renderMail(id, { ...sampleValues(tpl), vertragsende_paragraph: '§ 11' }, allBlocks(tpl, true)) assert.deepEqual(v4.problems, [], id) assert.doesNotMatch(v4.text, /§ 9 der Vertragsbedingungen/, id) assert.match(v4.text, /§ 11 der Vertragsbedingungen/, id) assert.match(renderMail(id, { ...sampleValues(tpl), vertragsende_paragraph: '§ 9' }, allBlocks(tpl, true)).text, /§ 9 der Vertragsbedingungen/, id) } }) test('K02–K06, T02, T05–T09: Produkt aus dem Katalog statt fest Wartungs-Abo', () => { for (const id of ['K02', 'K03', 'K04', 'K05', 'K06', 'T02', 'T05', 'T06', 'T07', 'T08', 'T09']) { assert.ok(loadTemplate(id).placeholders.includes('produkt'), id) } const values = { name: 'K', produkt: 'Hosting-Abo', eingang_zeitpunkt: '01.10.2026, 10:00', vertragsende: '01.10.2027', vertragsende_paragraph: '§ 11' } const k02 = renderMail('K02', values, {}) assert.deepEqual(k02.problems, []) assert.equal(k02.subject, 'Bestätigung Ihrer Kündigung – WEBklar Hosting-Abo') assert.match(k02.text, /Kündigung des WEBklar Hosting-Abos vom 01\.10\.2026, 10:00 Uhr/) assert.doesNotMatch(k02.text, /Stundenkontingent|Abo verwalten|Wartungs-Abo/) const k02w = renderMail('K02', { ...values, produkt: 'Wartungs-Abo' }, { wartung: true }) assert.match(k02w.text, /Nicht genutzte Zeit aus dem Stundenkontingent entfällt mit Vertragsende\./) assert.equal(renderMail('K03', { ...sampleValues(loadTemplate('K03')), produkt: 'Wartungs-Abo' }, {}).subject, 'Eingangsbestätigung Ihrer Kündigung – WEBklar Wartungs-Abo') assert.equal(renderMail('K04', { ...sampleValues(loadTemplate('K04')), produkt: 'Hosting-Abo' }, {}).subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Hosting-Abo') for (const id of ['T05', 'T06', 'T07', 'T08', 'T09']) { const tpl = loadTemplate(id) assert.match(renderMail(id, { ...sampleValues(tpl), produkt: 'Hosting-Abo' }, allBlocks(tpl, true)).text, /^Abo: Hosting-Abo$/m, id) } }) // F36: die Folgen des Widerrufs je Bestellung wie in den Hinweisen zum Widerruf der Fassung 4 test('K04: Folgen des Widerrufs je Bestellung (Abschluss, Wechsel, Zusatzspeicher, wir prüfen)', () => { const values = { name: 'K', produkt: 'Hosting-Abo', bestellung: 'Abschluss Ihres Abos', eingang_zeitpunkt: '01.10.2026, 10:00', email: 'k@example.invalid', kundennummer: '' } const abschluss = renderMail('K04', values, { abschluss: true }) assert.deepEqual(abschluss.problems, []) assert.match(abschluss.text, /keine weiteren Abbuchungen/) assert.match(abschluss.text, /Wertersatz/) assert.match(abschluss.text, /Zusatzspeicher[^.]*erstatten wir vollständig; Wertersatz verlangen wir dafür nicht/) const wechsel = renderMail('K04', { ...values, bestellung: 'Wechsel ins Wartungs-Abo' }, { wechsel: true }).text assert.match(wechsel, /Hosting-Abo läuft zu den bisherigen Bedingungen weiter, als hätte es den Wechsel nicht gegeben, einschließlich seiner ursprünglichen Mindestlaufzeit/) assert.match(wechsel, /Mehrbetrag/) assert.match(wechsel, /abzüglich des Wertersatzes/) assert.match(wechsel, /§ 357a Abs\. 2 BGB/) const speicher = renderMail('K04', { ...values, bestellung: 'E-Mail-Zusatzspeicher' }, { zusatzspeicher: true }).text assert.match(speicher, /Menge vor der Bestellung/) assert.match(speicher, /erstatten wir vollständig/) assert.match(speicher, /Wertersatz verlangen wir nicht/) assert.doesNotMatch(speicher, /abzüglich/) assert.match(renderMail('K04', values, { pruefen: true }).text, /prüfen, ob für diese Bestellung eine Widerrufsfrist läuft/) }) test('K12: Monatspreis mit gebuchtem Zusatzspeicher (Block), Widerruf des Wechsels wie in Fassung 4', () => { const values = sampleValues(loadTemplate('K12')) const mit = renderMail('K12', values, { zusatzspeicher: true }) assert.deepEqual(mit.problems, []) assert.match(mit.text, /einschließlich Ihres gebuchten E-Mail-Zusatzspeichers/) const ohne = renderMail('K12', values, {}) assert.doesNotMatch(ohne.text, /einschließlich Ihres gebuchten/) assert.match(ohne.text, /Widerrufen Sie den Wechsel, läuft Ihr Hosting-Abo zu den bisherigen Bedingungen weiter/) assert.match(ohne.text, /Mehrbetrag/) // K13: Widerruf des Zusatzspeichers ohne Wertersatz const k13 = renderMail('K13', sampleValues(loadTemplate('K13')), { plus: true, bestellung: true }) assert.match(k13.text, /erstatten alles, was Sie dafür gezahlt haben\. Wertersatz verlangen wir nicht\./) }) // T09: die Handgriffe je Bestellung passen zu den Hinweisen zum Widerruf (Zusatzspeicher ohne Wertersatz) test('T09: Handgriffe je Bestellung ohne Wertersatz beim Zusatzspeicher', () => { const t09 = { ...sampleValues(loadTemplate('T09')), produkt: 'Hosting-Abo' } const speicher = renderMail('T09', t09, { abo_gefunden: true, zusatzspeicher: true }).text assert.match(speicher, /vollständig erstatten/) assert.doesNotMatch(speicher, /Wertersatz für die Tage abziehen/) const wechsel = renderMail('T09', t09, { abo_gefunden: true, wechsel: true }).text assert.match(wechsel, /Mehrbetrag/) assert.match(wechsel, /Wertersatz/) const abschluss = renderMail('T09', t09, { abo_gefunden: true, abschluss: true }).text assert.match(abschluss, /Zusatzspeicher[^.]*vollständig/) }) // Task 12, Fix round 1: K01 je Fassung (v3-Zeile „1 E-Mail-Postfach · 1 Stunde Arbeitszeit pro Monat“ im Block wartung_v3), // Zustimmung einschließlich AVV; K12 „Heute fällig“ nennt den Zusatzspeicher nur im Block; T07 nennt die Arbeitszeit nur bei Wartung test('K01: Block wartung_v3 mit der Zeile der Fassung 3, wartung mit der Fassung 4, Zustimmung einschließlich AVV', () => { const base = { name: 'K', produkt: 'Wartungs-Abo', preis: '50,00 € / Monat', angebot_text: '', vertragsschluss: '01.10.2026', leistungsbeginn: '01.10.2026', mindestlaufzeit_bis: '01.10.2027', bedingungen_version: '2026-09-25-v3', zustimmung_zeitpunkt: '01.10.2026, 10:07' } assert.ok(loadTemplate('K01').blocks.includes('wartung_v3'), 'Block wartung_v3 deklariert') const v3 = renderMail('K01', base, { wartung_v3: true, sofortbeginn: true }) assert.deepEqual(v3.problems, []) assert.match(v3.text, /• 1 E-Mail-Postfach/) assert.match(v3.text, /• 1 Stunde Arbeitszeit pro Monat/) assert.match(v3.text, /Dort sehen Sie jederzeit Ihr aktuelles Stundenkontingent\./) assert.doesNotMatch(v3.text, /beliebig viele|je bezahltem Monat|Zusatzspeicher/) assert.match(v3.text, /Ihre Zustimmung zu den Vertragsbedingungen \(Version 2026-09-25-v3\) einschließlich des Auftragsverarbeitungsvertrags haben wir am 01\.10\.2026, 10:07 Uhr erhalten\./) const v4 = renderMail('K01', { ...base, bedingungen_version: '2026-10-05-v4' }, { wartung: true, sofortbeginn: true }) assert.match(v4.text, /• beliebig viele Postfächer/) assert.match(v4.text, /1 Stunde Arbeitszeit je bezahltem Monat/) assert.doesNotMatch(v4.text, /1 E-Mail-Postfach|pro Monat/) const h = renderMail('K01', { ...base, produkt: 'Hosting-Abo', preis: '15,00 € / Monat' }, { hosting: true, sofortbeginn: true }) assert.doesNotMatch(h.text, /Stundenkontingent|Stunde Arbeitszeit/) }) test('K12: „Heute fällig“ nennt den Zusatzspeicher nur im Block zusatzspeicher', () => { const values = sampleValues(loadTemplate('K12')) assert.match(renderMail('K12', values, { zusatzspeicher: true }).text, /Monatsgebühr des Wartungs-Abos mit gebuchtem Zusatzspeicher, abzüglich der Gutschrift/) const ohne = renderMail('K12', values, {}).text assert.match(ohne, /Monatsgebühr des Wartungs-Abos, abzüglich der Gutschrift/) assert.doesNotMatch(ohne, /mit gebuchtem Zusatzspeicher/) }) test('T07: Hinweis auf nicht genutzte Arbeitszeit nur im Block wartung', () => { const values = { produkt: 'Wartungs-Abo', email: 'k@example.invalid', stripe_abo_id: 'sub_1', status: 'canceled', vertragsende_paragraph: '§ 11' } assert.ok(loadTemplate('T07').blocks.includes('wartung'), 'Block wartung deklariert') const w = renderMail('T07', values, { wartung: true }) assert.deepEqual(w.problems, []) assert.match(w.text, /Nicht genutzte Arbeitszeit aus dem Stundenkontingent verfällt mit dem Vertragsende\. Gebuchter Zusatzspeicher endet mit dem Abo\./) const h = renderMail('T07', { ...values, produkt: 'Hosting-Abo' }, {}) assert.deepEqual(h.problems, []) assert.match(h.text, /Gebuchter Zusatzspeicher endet mit dem Abo\./) assert.doesNotMatch(h.text, /Arbeitszeit|Stundenkontingent|Restguthaben/) })