Mails K22, K23 und Hinweis auf das Ende der Funktionen in K02/K03

Terminplaner TP2 E3. K22 "Terminplaner beendet" (Blöcke abo_ende, hosting,
vor_einrichtung, daten) und K23 "Terminplaner läuft wieder" im Layout von K19,
Du-Form, Texte als Entwurf für die Mail-Überarbeitung. K02 und K03 bekommen den
Block funktionen (in K03 innerhalb von abo); routes/abo.js setzt ihn nur beim
Wartungs-Abo und nie bei der Speicher-Kündigung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 14:09:05 +00:00
1 parent eae6eee03c
commit 973135d2de
7 files changed
+282 -8

No files matched your search

+116 -2
View File
@@ -6,9 +6,9 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
const DIR = new URL('../server/mail-templates/', import.meta.url)
// G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb;
// 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); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b)
// 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); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b)
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
const EXPECTED = [...ids('G', 8), ...ids('K', 21), ...ids('T', 19), 'T20']
const EXPECTED = [...ids('G', 8), ...ids('K', 23), ...ids('T', 19), 'T20']
const GUEST = ids('G', 8)
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
@@ -114,6 +114,108 @@ test('T16, T19: Betreff, Platzhalter und Blöcke stehen in der Vorlage', () => {
}
})
// Terminplaner TP2 (E3): K22 „Terminplaner beendet“ je Grund eine Variante, K23 „läuft wieder“; Texte sind Entwurf für die Mail-Überarbeitung
const K22_WERTE = { kunde_name: 'Erika', projekt: 'Praxis Muster', enddatum: '03.10.2026', loeschdatum: '02.11.2026', preis: '149 €', portal_url: 'https://portal.example/#termine' }
const k22 = (blocks) => renderMail('K22', K22_WERTE, blocks)
test('K22, K23: Betreff, Platzhalter und Blöcke stehen in der Vorlage', () => {
const tpl22 = loadTemplate('K22')
assert.equal(tpl22.file, 'K22 Terminplaner beendet.html')
assert.equal(tpl22.subject, 'Dein Terminplaner wurde beendet – {{projekt}}')
assert.deepEqual(tpl22.placeholders, ['kunde_name', 'projekt', 'enddatum', 'loeschdatum', 'preis', 'portal_url'])
assert.deepEqual(tpl22.blocks, ['abo_ende', 'hosting', 'vor_einrichtung', 'daten'])
const tpl23 = loadTemplate('K23')
assert.equal(tpl23.file, 'K23 Terminplaner laeuft wieder.html')
assert.equal(tpl23.subject, 'Dein Terminplaner läuft wieder – {{projekt}}')
assert.deepEqual(tpl23.placeholders, ['kunde_name', 'projekt', 'portal_url'])
assert.deepEqual(tpl23.blocks, [])
assert.equal(k22({}).subject, 'Dein Terminplaner wurde beendet – Praxis Muster')
})
test('K22 abo_ende: nennt das beendete Wartungs-Abo, keine Erstattung', () => {
const mail = k22({ abo_ende: true })
assert.deepEqual(mail.problems, [])
assert.match(mail.text, /Wartungs-Abo/)
assert.match(mail.text, /03\.10\.2026/, 'Enddatum')
assert.doesNotMatch(mail.text, /erstatten/)
assert.doesNotMatch(mail.text, /Hosting-Abo/, 'nicht die Hosting-Variante')
assert.doesNotMatch(mail.text, /02\.11\.2026|Was jetzt gilt/, 'der Kasten kommt nur mit dem Block daten')
})
test('K22 hosting: das Hosting-Abo reicht für den Terminplaner nicht', () => {
const mail = k22({ hosting: true })
assert.deepEqual(mail.problems, [])
assert.match(mail.text, /Hosting-Abo/)
assert.match(mail.text, /Wartungs-Abo/, 'sagt, was der Terminplaner braucht')
assert.doesNotMatch(mail.text, /erstatten/)
assert.doesNotMatch(mail.text, /02\.11\.2026|Was jetzt gilt/)
})
test('K22 vor_einrichtung: Einrichtung wird voll erstattet, nennt den Preis, kein Löschkasten', () => {
const mail = k22({ vor_einrichtung: true })
assert.deepEqual(mail.problems, [])
assert.match(mail.text, /149 €/)
assert.match(mail.text, /erstatten/)
assert.match(mail.text, /vor der Einrichtung/)
assert.doesNotMatch(mail.text, /02\.11\.2026|Was jetzt gilt|Rückkehr|löschen/, 'es gibt noch keine Termindaten, also keinen Löschkasten')
})
test('K22 daten: Kasten „Was jetzt gilt“ mit Löschdatum, Gästen und Rückkehr', () => {
const mail = k22({ daten: true })
assert.deepEqual(mail.problems, [])
const t = mail.text
assert.match(t, /Was jetzt gilt/)
assert.match(t, /Bis 02\.11\.2026 kannst Du Deine Termine im Kundenportal ansehen und als CSV exportieren/)
assert.match(t, /Gäste können ihre Termine weiter absagen/)
assert.match(t, /Bestehende Termine bleiben eingetragen, und die Erinnerungen an Deine Gäste laufen bis zur Löschung weiter/)
assert.match(t, /Bitte informiere Deine Gäste selbst/)
assert.match(t, /Ab 02\.11\.2026 löschen wir die Termindaten/)
assert.match(t, /30 Tage/, 'Löschfrist 30 Tage nach dem Abschalten')
assert.match(t, /Rückkehr: Läuft bis dahin wieder ein Wartungs-Abo, schalten wir den Terminplaner automatisch und ohne Gebühr wieder ein/)
assert.match(t, /Nach der Löschung braucht es einen neuen Fragebogen und die Einrichtung für 149 € noch einmal/)
assert.doesNotMatch(t, /erstatten/)
})
test('K22: Anrede, Projekt und Knopf ins Kundenportal stehen in jeder Variante', () => {
const mail = k22({ abo_ende: true, daten: true })
assert.deepEqual(mail.problems, [])
assert.match(mail.text, /Hallo Erika,/)
assert.match(mail.text, /Praxis Muster/)
assert.match(mail.text, /Zum Kundenportal: https:\/\/portal\.example\/#termine/)
assert.match(mail.html, /href="https:\/\/portal\.example\/#termine"/)
})
test('K23: nennt das Projekt und führt ins Kundenportal', () => {
const mail = renderMail('K23', { kunde_name: 'Erika', projekt: 'Praxis Muster', portal_url: 'https://portal.example/#termine' })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Dein Terminplaner läuft wieder – Praxis Muster')
assert.match(mail.text, /Hallo Erika,/)
assert.match(mail.text, /Praxis Muster/)
assert.match(mail.text, /Wartungs-Abo/)
assert.match(mail.text, /Zum Kundenportal: https:\/\/portal\.example\/#termine/)
assert.match(mail.html, /<a href="https:\/\/portal\.example\/#termine"/)
})
// Terminplaner TP2 (E3): K02/K03 sagen, dass die Funktionen mit dem Wartungs-Abo enden (Sie-Form wie der Rest der Vorlagen)
test('K02, K03: Block funktionen nennt Terminplaner und Löschfrist, nur mit Block (in K03 nur innerhalb von abo)', () => {
const k02 = { name: 'K', produkt: 'Wartungs-Abo', eingang_zeitpunkt: '01.10.2026, 10:00', vertragsende: '01.10.2027', vertragsende_paragraph: '§ 11' }
assert.ok(loadTemplate('K02').blocks.includes('funktionen'))
const an = renderMail('K02', k02, { wartung: true, funktionen: true })
assert.deepEqual(an.problems, [])
assert.match(an.text, /Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm\. Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren\./)
assert.doesNotMatch(renderMail('K02', k02, { wartung: true }).text, /Terminplaner/)
assert.doesNotMatch(renderMail('K02', { ...k02, produkt: 'Hosting-Abo' }, {}).text, /Terminplaner|Wartungs-Abo/, 'Hosting-Abo: kein Hinweis')
const k03 = { ...sampleValues(loadTemplate('K03')), vertragsende_paragraph: '§ 11' }
assert.ok(loadTemplate('K03').blocks.includes('funktionen'))
const abo = renderMail('K03', k03, { abo: true, wartung: true, funktionen: true })
assert.deepEqual(abo.problems, [])
assert.match(abo.text, /Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm\. Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren\./)
assert.doesNotMatch(renderMail('K03', k03, { abo: true, wartung: true }).text, /Terminplaner/)
// Speicher-Kündigung (abo aus): der Block steht innerhalb von abo und fällt mit ihm weg
assert.doesNotMatch(renderMail('K03', k03, { abo: false, funktionen: true }).text, /Terminplaner/)
})
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 &lt;b&gt;&amp;&quot;&#39; hat im Kundenportal geschrieben/)
@@ -816,6 +918,18 @@ test('G08: Eingangsbestätigung mit Datum und Uhrzeit, Blöcke zugeordnet und na
assert.match(ohne.text, /Waren Sie das nicht, können Sie diese Nachricht ignorieren\./)
})
// Terminplaner TP2 (E3): K22 und K23 stehen in Du-Form (K02/K03 bleiben Sie-Form wie ihr Rest)
test('K22, K23: Du-Form fest, kein Sie und kein Ihr, auch mit allen Blöcken', () => {
for (const id of ['K22', 'K23']) {
const tpl = loadTemplate(id)
assert.ok(!tpl.blocks.includes('sie') && !tpl.blocks.includes('du'), `${id}: Du-Form fest, keine Anredeblöcke`)
const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, true))
assert.doesNotMatch(mail.text, SIE_FORM, `${id}: Du-Form\n${mail.text}`)
assert.match(mail.text, /Hallo Wert-kunde_name,/, id)
assert.match(mail.text, DU_FORM, `${id}: spricht den Kunden mit Du an`)
}
})
test('K20 und K21: WEBklar-Layout in Du-Form, Gastdaten maskiert, Blöcke je Fall', () => {
for (const id of ['K20', 'K21']) {
const tpl = loadTemplate(id)
+47 -1
View File
@@ -22,7 +22,7 @@ const aw = await startAppwriteMock({
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post } = await import('./support/testApp.js')
const { startTestApp, post, postWebhook } = await import('./support/testApp.js')
const { hostingDoc, consent, seedFixtures, stripeSubscription } = await import('./support/fixtures.js')
const { default: aboRoutes, resetPublicRateLimit, publicRateLimitState, purgeRateLimit } = await import('../server/routes/abo.js')
const { listContractEvents, recordContractEvent } = await import('../server/services/contractEvents.js')
@@ -147,6 +147,52 @@ test('K03/K04/T08/T09: Produkt des Kunden, Vertragsende nach § 11 der Fassung 4
assert.ok(k03u.text.includes('§ 11 der Vertragsbedingungen'), k03u.text)
})
// Terminplaner TP2 (E3): Website-Analyse und Terminplaner gehören zum Wartungs-Abo; K03 und K02 sagen, dass sie mit ihm enden
const FUNKTIONEN_SATZ = 'Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm.'
const WARTUNG_V4 = { productKey: 'wartung', termsVersion: config.abo.termsVersion }
const k02 = () => bySubject('Bestätigung Ihrer Kündigung')
test('K03: Hinweis auf das Ende der Funktionen nur bei der Kündigung eines Wartungs-Abos', async () => {
await resetSubH({ patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } })
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200)
assert.ok(k03().text.includes(FUNKTIONEN_SATZ), k03().text)
assert.ok(k03().text.includes('Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.'), k03().text)
// Hosting-Abo: weder Analyse noch Terminplaner
await resetSubH()
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200)
assert.doesNotMatch(k03().text, /Terminplaner/)
// nur der Zusatzspeicher eines Wartungs-Abos gekündigt: das Abo läuft weiter, kein Hinweis
await resetSubH({ units: 3, patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } })
assert.equal((await cancel({ contract: 'zusatzspeicher' })).status, 200)
assert.doesNotMatch(k03().text, /Terminplaner/)
// unbekannte E-Mail (kein Abo gefunden): kein Hinweis
await resetSubH({ patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } })
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich', email: 'niemand@example.invalid' })).status, 200)
assert.doesNotMatch(bySubject('Eingangsbestätigung Ihrer Kündigung').text, /Terminplaner/)
})
test('K02: Kündigung im Stripe-Kundenportal, Hinweis auf das Ende der Funktionen nur beim Wartungs-Abo', async () => {
const gekuendigt = (id) => ({
id,
object: 'event',
type: 'customer.subscription.updated',
created: Math.floor(Date.now() / 1000),
data: { object: { id: 'sub_h', object: 'subscription', cancel_at_period_end: true, metadata: {} }, previous_attributes: { cancel_at_period_end: false } },
})
for (const [productKey, mitHinweis] of [['wartung', true], ['hosting', false]]) {
const patch = productKey === 'wartung' ? WARTUNG_V4 : {}
await resetSubH({ patch: { ...patch, cancelAtPeriodEnd: true }, stripe: { productKey, cancel_at_period_end: true, metadata: productKey === 'wartung' ? { termsVersion: config.abo.termsVersion } : {} } })
const res = await postWebhook(gekuendigt(`evt_k02_${productKey}`))
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.ok(k02(), `${productKey}: K02`)
assert.equal(k02().text.includes(FUNKTIONEN_SATZ), mitHinweis, `${productKey}\n${k02().text}`)
assert.equal(/Terminplaner/.test(k02().text), mitHinweis, productKey)
}
})
test('2 von 3 Paketen ergibt Menge 1', async () => {
await resetSubH({ units: 3 })
const res = await cancel({ contract: 'zusatzspeicher', packages: '2' })