Widerruf: Terminplaner als eigene Auswahl, Frist und Mails je Funktion

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 03:50:26 +00:00
1 parent 34384c7a39
commit 01bf05cc35
6 files changed
+258 -32

No files matched your search

+36 -2
View File
@@ -6,7 +6,7 @@ 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; 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)
// 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); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9)
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
const EXPECTED = [...ids('G', 8), ...ids('K', 23), ...ids('T', 19), 'T20']
const GUEST = ids('G', 8)
@@ -255,7 +255,7 @@ test('optionale Blöcke: nur eingeschaltete erscheinen', () => {
})
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 values = { email: 'a@example.com', name: 'A', kundennummer: '', produkt: 'Terminplaner', preis: '149 €', 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/)
@@ -596,6 +596,40 @@ test('K04: Folgen des Widerrufs je Bestellung (Abschluss, Wechsel, Zusatzspeiche
assert.match(renderMail('K04', values, { pruefen: true }).text, /prüfen, ob für diese Bestellung eine Widerrufsfrist läuft/)
})
// F9: Widerruf je Funktion - T09 kennt preis, die Blöcke bleiben unverändert; K04/T09 nennen Produkt und Folgen der Funktion
test('F9: T09 deklariert preis, Blöcke von K04 und T09 unverändert', () => {
assert.ok(loadTemplate('T09').placeholders.includes('preis'))
assert.ok(loadTemplate('T09').placeholders.includes('produkt'))
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen'])
assert.deepEqual(loadTemplate('T09').blocks, [
'kundennummer', 'abo_gefunden', 'auf_kuendigung_gesetzt', 'kein_abo_gefunden', 'kuendigung_fehlgeschlagen', 'abschluss', 'wechsel',
'zusatzspeicher', 'funktion', 'keine_frist', 'nachweis_fehlt', 'wechsel_offen', 'abschluss_verspaetet', 'nicht_pruefbar', 'bereits_widerrufen',
])
})
test('F9: K04 und T09 beim Widerruf einer Funktion (begonnene Einrichtung, Wertersatz, Preis, Handgriffe)', () => {
const k04 = { name: 'K', produkt: 'Terminplaner', bestellung: 'Terminplaner (Einrichtung)', eingang_zeitpunkt: '01.10.2026, 10:00', email: 'k@example.invalid', kundennummer: '' }
const kunde = renderMail('K04', k04, { funktion: true })
assert.deepEqual(kunde.problems, [])
assert.equal(kunde.subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Terminplaner')
assert.match(kunde.text, /Widerruf der Bestellung „Terminplaner \(Einrichtung\)“/)
assert.match(kunde.text, /Einrichtung auf Ihren ausdrücklichen Wunsch schon vor Ablauf der Widerrufsfrist begonnen/)
assert.match(kunde.text, /Wertersatz[^.]*\(§ 357a Abs\. 2 BGB\)/)
assert.doesNotMatch(kunde.text, /abgeschlossen/)
assert.match(kunde.text, /Sie erhalten dazu eine gesonderte Abrechnung/)
const t09 = { ...sampleValues(loadTemplate('T09')), produkt: 'Terminplaner', preis: '149 €', ereignisse: 'Studio Anker: in Einrichtung' }
const team = renderMail('T09', t09, { funktion: true })
assert.deepEqual(team.problems, [])
assert.match(team.text, /Widerruf einer Funktion \(Terminplaner, 149 € einmalig\)/)
assert.match(team.text, /NICHTS automatisch geändert/)
assert.match(team.text, /innerhalb von 14 Tagen erstatten/)
assert.match(team.text, /abzüglich Wertersatz für den begonnenen Einbau \(Aufwand im Arbeitsblatt, ⚖ Q5\)/)
assert.match(team.text, /Reiter „Funktionen“/)
assert.match(team.text, /Beim Terminplaner bleibt das Wartungs-Abo unberührt/)
assert.match(team.text, /Studio Anker: in Einrichtung/)
})
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 })
+183 -10
View File
@@ -822,12 +822,12 @@ async function raeumeAnkerTest() {
for (const id of ['fc_bezahlt', 'fc_offen']) if (aw.docs('featureConsents').has(id)) await deleteDocument('featureConsents', id)
}
test('Widerruf Funktion: Fristanker ist die Zeit des Klicks der bezahlten Sitzung (rechtzeitig)', async () => {
test('Widerruf Terminplaner: Fristanker ist die Zeit des Klicks der bezahlten Sitzung (rechtzeitig)', async () => {
await resetSubH({ units: 0 })
const klickAt = daysAgo(13)
seedBezahlteBestellung({ klickAt })
try {
const res = await withdraw({ order: 'funktion' })
const res = await withdraw({ order: 'termine' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const team = t09().text
// Frist aus dem Klick der bezahlten Sitzung, nicht aus orderedAt (vor 1 Tag) und nicht aus der spaeteren Zustimmung
@@ -841,12 +841,12 @@ test('Widerruf Funktion: Fristanker ist die Zeit des Klicks der bezahlten Sitzun
}
})
test('Widerruf Funktion: Fristanker aus dem Klick vor 20 Tagen ergibt NACH Fristende', async () => {
test('Widerruf Terminplaner: Fristanker aus dem Klick vor 20 Tagen ergibt NACH Fristende', async () => {
await resetSubH({ units: 0 })
const klickAt = daysAgo(20)
seedBezahlteBestellung({ klickAt })
try {
const res = await withdraw({ order: 'funktion' })
const res = await withdraw({ order: 'termine' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const team = t09().text
assert.ok(team.includes(fmtDate(withdrawalDeadline(klickAt).endsAt)), team)
@@ -857,21 +857,21 @@ test('Widerruf Funktion: Fristanker aus dem Klick vor 20 Tagen ergibt NACH Frist
}
})
test('Widerruf Funktion: ohne Nachweis zur Sitzung gilt consentAt, dann orderedAt', async () => {
test('Widerruf Terminplaner: ohne Nachweis zur Sitzung gilt consentAt, dann orderedAt', async () => {
await resetSubH({ units: 0 })
aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Studio Anker' })
const base = { customerId: 'c1', projectId: 'pa1', featureKey: 'termine', state: 'setup', enabled: false, unlockedByPurchase: true, stripeSessionId: 'cs_ohne_nachweis' }
try {
// consentAt (Altbestand, News) vor orderedAt
aw.seed('portalFeatures', { ...base, $id: 'f_pa1_termine', orderedAt: daysAgo(1), consentAt: daysAgo(20) })
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
assert.equal((await withdraw({ order: 'termine' })).status, 200)
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(daysAgo(20)).endsAt)))
assert.match(t09().text, /NACH Fristende/)
// weder Nachweis noch consentAt: orderedAt
mails.sent.length = 0
aw.seed('portalFeatures', { ...base, $id: 'f_pa1_termine', orderedAt: daysAgo(3), consentAt: null })
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
assert.equal((await withdraw({ order: 'termine' })).status, 200)
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(daysAgo(3)).endsAt)))
assert.match(t09().text, /RECHTZEITIG/)
} finally {
@@ -879,7 +879,7 @@ test('Widerruf Funktion: ohne Nachweis zur Sitzung gilt consentAt, dann orderedA
}
})
test('Widerruf Funktion: Nachweis-Collection nicht lesbar fällt auf orderedAt zurück, Antwort bleibt 200', async () => {
test('Widerruf Terminplaner: Nachweis-Collection nicht lesbar fällt auf orderedAt zurück, Antwort bleibt 200', async () => {
await resetSubH({ units: 0 })
const orderedAt = daysAgo(2)
seedBezahlteBestellung({ klickAt: daysAgo(20), orderedAt })
@@ -888,7 +888,7 @@ test('Widerruf Funktion: Nachweis-Collection nicht lesbar fällt auf orderedAt z
console.warn = (...args) => warnungen.push(args.join(' '))
try {
aw.failNext({ collection: 'featureConsents', method: 'GET', status: 500 })
const res = await withdraw({ order: 'funktion' })
const res = await withdraw({ order: 'termine' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const team = t09().text
assert.ok(team.includes(fmtDate(withdrawalDeadline(orderedAt).endsAt)), team)
@@ -901,6 +901,178 @@ test('Widerruf Funktion: Nachweis-Collection nicht lesbar fällt auf orderedAt z
}
})
/** Bezahltes Funktionsdokument (Projekt wird mit angelegt) fuer die Tests je Funktion. */
function seedBezahlt({ projectId, projektName, featureKey, orderedAt, state = 'setup' }) {
aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName: projektName })
aw.seed('portalFeatures', {
$id: `f_${projectId}_${featureKey}`,
customerId: 'c1',
projectId,
featureKey,
state,
enabled: false,
unlockedByPurchase: true,
orderedAt,
})
}
async function raeumeBezahlt(...ids) {
for (const id of ids) if (aw.docs('portalFeatures').has(id)) await deleteDocument('portalFeatures', id)
}
test('widerruf_termine_frist_je_funktion: Terminplaner vor 3 Tagen, News vor 20 Tagen bestellt; jede Wahl rechnet mit der eigenen Funktion', async () => {
const newsAt = daysAgo(20)
const termineAt = daysAgo(3)
seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: newsAt, state: 'active' })
seedBezahlt({ projectId: 'pt1', projektName: 'Studio Termine', featureKey: 'termine', orderedAt: termineAt })
try {
await resetSubH({ units: 0 })
const before = st.calls.length
assert.equal((await withdraw({ order: 'termine' })).status, 200)
const termine = t09().text
assert.match(termine, /RECHTZEITIG/)
assert.doesNotMatch(termine, /NACH Fristende/)
assert.ok(termine.includes(fmtDate(withdrawalDeadline(termineAt).endsAt)), 'Fristende des Terminplaners')
assert.match(termine, /Studio Termine: in Einrichtung/)
assert.doesNotMatch(termine, /Bäckerei News/, 'die News-Bestellung gehört nicht zu dieser Wahl')
assert.equal(st.calls.length, before, 'kein Stripe-Aufruf')
assert.equal(aw.docs('portalFeatures').get('f_pt1_termine').state, 'setup', 'nichts automatisch geändert')
// umgekehrt: Widerruf "Funktion" meint die News und rechnet mit deren Frist; der junge Terminplaner rettet sie nicht
await resetSubH({ units: 0 })
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
const news = t09().text
assert.match(news, /NACH Fristende/)
assert.doesNotMatch(news, /RECHTZEITIG/)
assert.ok(news.includes(fmtDate(withdrawalDeadline(newsAt).endsAt)), 'Fristende der News')
assert.match(news, /Bäckerei News: aktiv/)
assert.doesNotMatch(news, /Studio Termine/)
} finally {
await raeumeBezahlt('f_pn1_news', 'f_pt1_termine')
}
})
test('widerruf_termine_frist_je_funktion: mehrere Projekte einer Funktion - spätestes Fristende, jede Zeile mit eigenem Fristende', async () => {
const alt = daysAgo(20)
const jung = daysAgo(3)
seedBezahlt({ projectId: 'pn1', projektName: 'Alt News', featureKey: 'news', orderedAt: alt, state: 'active' })
seedBezahlt({ projectId: 'pn2', projektName: 'Jung News', featureKey: 'news', orderedAt: jung })
try {
await resetSubH({ units: 0 })
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
const team = t09().text
// bisheriges Verhalten: das Maximum der Fristenden entscheidet
assert.match(team, /RECHTZEITIG/)
assert.ok(team.includes(`${fmtDate(withdrawalDeadline(jung).endsAt)}, 24 Uhr`), 'Fristende = spätestes')
assert.match(team, new RegExp(`Alt News: aktiv, bestellt am ${fmtDate(alt)}, Fristende ${fmtDate(withdrawalDeadline(alt).endsAt)}`))
assert.match(team, new RegExp(`Jung News: in Einrichtung, bestellt am ${fmtDate(jung)}, Fristende ${fmtDate(withdrawalDeadline(jung).endsAt)}`))
} finally {
await raeumeBezahlt('f_pn1_news', 'f_pn2_news')
}
})
test('widerruf_termine_mails: K04 nennt „Terminplaner (Einrichtung)“, T09 den Preis und das Projekt; bei der News unverändert', async () => {
seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: daysAgo(3) })
seedBezahlt({ projectId: 'pt1', projektName: 'Studio Termine', featureKey: 'termine', orderedAt: daysAgo(2) })
try {
await resetSubH({ units: 0 })
assert.equal((await withdraw({ order: 'termine', customerNumber: 'KD-9' })).status, 200)
const kunde = k04()
assert.match(kunde.subject, /WEBklar Terminplaner$/)
assert.match(kunde.text, /Widerruf der Bestellung „Terminplaner \(Einrichtung\)“/)
assert.match(kunde.text, /Wir prüfen Ihren Widerruf/)
assert.match(kunde.text, /Einrichtung auf Ihren ausdrücklichen Wunsch schon vor Ablauf der Widerrufsfrist begonnen/)
assert.match(kunde.text, /§ 357a Abs\. 2 BGB/)
assert.doesNotMatch(kunde.text, /Funktion \(z\. B\. News-Pinnwand\)|Es erfolgen keine weiteren Abbuchungen/)
const team = t09().text
assert.match(team, /Terminplaner \(Einrichtung\)/)
assert.ok(team.includes(`Widerruf einer Funktion (${config.features.termine.label.name}, ${config.features.termine.priceLabel} einmalig)`), team)
assert.match(team, /149 €/)
assert.match(team, /Studio Termine: in Einrichtung/)
assert.match(team, /Beim Terminplaner bleibt das Wartungs-Abo unberührt/)
assert.match(team, /NICHTS automatisch geändert/)
// "Funktion (z. B. News-Pinnwand)" bleibt wie bisher: News-Pinnwand, 30 €
await resetSubH({ units: 0 })
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
assert.match(k04().subject, /WEBklar News-Pinnwand$/)
assert.match(k04().text, /Funktion \(z\. B\. News-Pinnwand\)/)
assert.match(k04().text, /Wir prüfen Ihren Widerruf/)
assert.match(t09().text, /Funktion \(z\. B\. News-Pinnwand\)/)
assert.ok(t09().text.includes(`Widerruf einer Funktion (${config.features.news.label.name}, ${config.features.news.priceLabel} einmalig)`), t09().text)
assert.match(t09().text, /Bäckerei News: in Einrichtung/)
assert.doesNotMatch(t09().text, /Studio Termine/)
} finally {
await raeumeBezahlt('f_pn1_news', 'f_pt1_termine')
}
})
test('widerruf_anker_vertragAt: Zustimmung zur bezahlten Sitzung früher als das überschriebene orderedAt - Frist aus vertragAt; je Funktion gefiltert', async () => {
await resetSubH({ units: 0 })
// Terminplaner: Klick vor 13 Tagen, orderedAt (Neubestellung) vor 1 Tag; eine News vor 2 Tagen bestellt (kein Einfluss)
const klickAt = daysAgo(13)
seedBezahlteBestellung({ klickAt })
seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: daysAgo(2) })
try {
assert.equal((await withdraw({ order: 'termine' })).status, 200)
let team = t09().text
assert.ok(team.includes(fmtDate(withdrawalDeadline(klickAt).endsAt)), team)
assert.ok(!team.includes(fmtDate(withdrawalDeadline(daysAgo(1)).endsAt)), 'nicht das Fristende aus orderedAt')
assert.match(team, /RECHTZEITIG/)
// Rückfall ohne featureConsents-Eintrag: orderedAt
mails.sent.length = 0
await deleteDocument('featureConsents', 'fc_bezahlt')
assert.equal((await withdraw({ order: 'termine' })).status, 200)
team = t09().text
assert.ok(team.includes(fmtDate(withdrawalDeadline(daysAgo(1)).endsAt)), team)
assert.match(team, /RECHTZEITIG/)
} finally {
await raeumeAnkerTest()
await raeumeBezahlt('f_pn1_news')
}
})
test('widerruf_unbekannte_funktion: kein bezahltes Dokument dieser Funktion - ungeprüft (keine bezahlte Funktion gefunden)', async () => {
await resetSubH({ units: 0 })
assert.equal((await withdraw({ order: 'termine' })).status, 200)
assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/)
assert.match(t09().text, /keine Frist gefunden/)
assert.ok(k04(), 'K04 geht trotzdem an den Kunden')
// nur eine News bezahlt, Widerruf des Terminplaners: auch dann ungeprüft statt Frist der News
seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: daysAgo(2) })
try {
await resetSubH({ units: 0 })
assert.equal((await withdraw({ order: 'termine' })).status, 200)
assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/)
assert.doesNotMatch(t09().text, /RECHTZEITIG|NACH Fristende/)
// noch nicht bezahlt (ordered) zählt nicht
aw.seed('portalFeatures', { $id: 'f_pt9_termine', customerId: 'c1', projectId: 'pt9', featureKey: 'termine', state: 'ordered', enabled: false, unlockedByPurchase: false, orderedAt: daysAgo(1) })
await resetSubH({ units: 0 })
assert.equal((await withdraw({ order: 'termine' })).status, 200)
assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/)
} finally {
await raeumeBezahlt('f_pn1_news', 'f_pt9_termine')
}
})
test('Widerruf Funktion: Dokument ohne featureKey (Altbestand) zählt als News-Pinnwand', async () => {
await resetSubH({ units: 0 })
aw.seed('websiteProjects', { $id: 'pl1', customerId: 'c1', projectName: 'Altbestand' })
aw.seed('portalFeatures', { $id: 'f_pl1_alt', customerId: 'c1', projectId: 'pl1', state: 'active', enabled: true, unlockedByPurchase: true, orderedAt: daysAgo(3) })
try {
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
assert.match(t09().text, /Altbestand: aktiv/)
assert.match(t09().text, /RECHTZEITIG/)
mails.sent.length = 0
assert.equal((await withdraw({ order: 'termine' })).status, 200)
assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/)
} finally {
await raeumeBezahlt('f_pl1_alt')
}
})
test('Widerruf Abschluss in der Frist: auf Kündigung gesetzt; Altbestand ohne Ereignis zählt ab startedAt (F14)', async () => {
const startedAt = daysAgo(2)
await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } })
@@ -1100,12 +1272,13 @@ test('kuendigen.html: Radioknöpfe contract, Feld packages, Bestätigungstext',
assert.match(src, /fields: \['contract', 'packages', 'kind', 'reason', 'name', 'email', 'customerNumber', 'effective'\]/)
})
test('widerrufen.html: Radioknöpfe order', () => {
test('widerrufen_html_radio: Radioknöpfe order', () => {
const src = html('widerrufen.html')
assert.match(src, /name="order" value="abschluss"[^>]*> Abschluss meines Abos/)
assert.match(src, /name="order" value="wechsel"[^>]*> Wechsel ins Wartungs-Abo/)
assert.match(src, /name="order" value="zusatzspeicher"[^>]*> E-Mail-Zusatzspeicher/)
assert.match(src, /name="order" value="funktion"[^>]*> Funktion \(z\. B\. News-Pinnwand\)/)
assert.match(src, /name="order" value="termine"[^>]*data-text="Terminplaner \(Einrichtung\)"[^>]*> Terminplaner \(Einrichtung\)<\/label>/)
assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/)
assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/)
})