Abo-Bedingungen Fassung 5: Unterstützung in § 4, Fassung 4 archiviert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 15:26:39 +00:00
1 parent 88854aa2b4
commit 8acc743cf7
10 files changed
+538 -41

No files matched your search

+52 -19
View File
@@ -37,7 +37,14 @@ const between = (s, a, b) => {
}
const V3 = 'public/vertragsunterlagen/v3/abo-bedingungen.html'
const V4 = 'public/abo-bedingungen.html'
// Aktuelle Fassung (5) und archivierte Fassung 4
const CUR = 'public/abo-bedingungen.html'
const V4 = 'public/vertragsunterlagen/v4/abo-bedingungen.html'
// = `git show c4fab31:public/abo-bedingungen.html | sha256sum` bzw. des PDFs (Fassung 4, Stand 30.09.2026)
const V4_SHA256 = '4c7fa07cfd8ea04672c2c1a33f7584959108f459ebdacf18dec56303b44096e9'
const V4_PDF_SHA256 = '1461137e033c1d4cafd4866851d64e0784c1335591918b622d198cd25d2adfbf'
const V4_PDF_LINK = 'href="/WEBklar-Abo-Vertragsunterlagen.pdf"'
const V4_ARCHIVE_PDF_LINK = 'href="/vertragsunterlagen/v4/WEBklar-Abo-Vertragsunterlagen.pdf"'
test('Fassung 3 unverändert archiviert (HTML und PDF)', () => {
assert.equal(sha256(V3), V3_SHA256)
@@ -50,7 +57,7 @@ test('Fassung 3 unverändert archiviert (HTML und PDF)', () => {
})
test('Widerrufsbelehrung und Muster-Widerrufsformular wortgleich mit Fassung 3', () => {
const v3 = read(V3), v4 = read(V4)
const v3 = read(V3), v4 = read(CUR)
const muster = (belehrung) => between(belehrung, 'Widerrufsrecht Sie haben das Recht', 'Ende der Widerrufsbelehrung')
const muster3 = muster(section(v3, 'widerruf', 'formular'))
assert.ok(muster3.length > 1500)
@@ -61,7 +68,7 @@ test('Widerrufsbelehrung und Muster-Widerrufsformular wortgleich mit Fassung 3',
})
test('Fassung 4: beide Abos, Postfächer, Zusatzspeicher, Wechsel', () => {
const v4html = read(V4), v4 = strip(v4html)
const v4html = read(CUR), v4 = strip(v4html)
for (const s of ['20,00 €', '50,00 €', 'beliebig viele Postfächer', 'zusammen 5 GB', '1,00 €', 'Zusatzspeicher',
'12 Monate', 'statische', 'Hetzner', '§ 19 UStG', 'Leistungsbeschreibung A', 'Leistungsbeschreibung B',
'„Zahlungspflichtig bestellen“', '„Verträge hier kündigen“', '„Nur den E-Mail-Zusatzspeicher“', '„Speichern“',
@@ -82,7 +89,7 @@ test('Fassung 4: beide Abos, Postfächer, Zusatzspeicher, Wechsel', () => {
})
test('Anker der Fassung 4', () => {
const v4 = read(V4)
const v4 = read(CUR)
for (const id of ['widerruf', 'hosting', 'wartung', 'zusatzspeicher', 'wechsel', 'avv', 'widerruf-hinweise', 'formular', 'tom', 'sub']) {
assert.equal(v4.split(`id="${id}"`).length, 2, `Anker #${id} genau einmal`)
}
@@ -93,15 +100,16 @@ test('Anker der Fassung 4', () => {
})
test('Version und Stand passen zu termsVersion', () => {
const m = /^(\d{4})-(\d{2})-(\d{2})-v4$/.exec(config.abo.termsVersion)
const m = /^(\d{4})-(\d{2})-(\d{2})-v5$/.exec(config.abo.termsVersion)
assert.ok(m, `termsVersion ${config.abo.termsVersion}`)
const [, y, mo, d] = m
assert.ok(strip(read(V4)).includes(`Version 4 · Stand ${d}.${mo}.${y}`))
assert.ok(read(V4).includes(`content: "Version 4 · Stand ${d}.${mo}.${y}"`), 'Kopfzeile im Druck')
assert.ok(strip(read(CUR)).includes(`Version 5 · Stand ${d}.${mo}.${y}`))
assert.ok(read(CUR).includes(`content: "Version 5 · Stand ${d}.${mo}.${y}"`), 'Kopfzeile im Druck')
assert.ok(!strip(read(CUR)).includes('Version 4'))
})
test('PDF der Fassung 4 verlinkt, erzeugt und im Build-Skript', () => {
const v4 = read(V4)
const v4 = read(CUR)
assert.ok(v4.includes('href="/WEBklar-Abo-Vertragsunterlagen.pdf"'))
assert.ok(!v4.includes('WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf'))
const pdf = readFileSync(file('public/WEBklar-Abo-Vertragsunterlagen.pdf'))
@@ -114,14 +122,14 @@ test('PDF der Fassung 4 verlinkt, erzeugt und im Build-Skript', () => {
// --- Korrekturen aus der Prüfung (Review 1) ---
const v4Section = (fromId, toId) => section(read(V4), fromId, toId)
const v4Section = (fromId, toId) => section(read(CUR), fromId, toId)
test('Widerrufsfrist beginnt mit dem Vertragsschluss, nicht mit der Bestellung', () => {
const hinweise = v4Section('widerruf-hinweise', 'avv')
assert.ok(hinweise.includes('mit dem Vertragsschluss (§ 2 Abs. 3 bzw. Abs. 5)'))
assert.ok(!hinweise.includes('mit der Bestellung'))
// § 2 Abs. 3 = Vertragsschluss beim Abo, Abs. 5 = Vertragsschluss bei Wechsel und Zusatzspeicher
const html = read(V4)
const html = read(CUR)
const p2 = html.slice(html.indexOf('id="p2"'), html.indexOf('id="p3"'))
// Absaetze auf oberster Ebene stehen mit genau vier Leerzeichen am Zeilenanfang (die Liste a-e in Abs. 2 mit acht)
const absaetze = [...p2.matchAll(/^ <li>([\s\S]*?)<\/li>$/gm)].map((m) => strip(m[1]))
@@ -135,12 +143,12 @@ test('Widerrufsfrist beginnt mit dem Vertragsschluss, nicht mit der Bestellung',
// Absaetze eines Paragraphen auf oberster Ebene: genau vier Leerzeichen vor <li> (verschachtelte Listen haben acht)
const absaetzeVon = (fromId, toId) => {
const html = read(V4)
const html = read(CUR)
const slice = html.slice(html.indexOf(`id="${fromId}"`), html.indexOf(`id="${toId}"`))
return [...slice.matchAll(/^ <li>([\s\S]*?)<\/li>$/gm)].map((m) => strip(m[1]))
}
// Eine Zeile der Übersicht "Das Wichtigste auf einen Blick"
const uebersichtZeile = (name) => strip(between(read(V4), `<tr><td>${name}</td><td>`, '</td></tr>'))
const uebersichtZeile = (name) => strip(between(read(CUR), `<tr><td>${name}</td><td>`, '</td></tr>'))
test('Wechsel, Plus und Minus im Portal nur ohne Kündigung und ohne offene Zahlung', () => {
const wechsel = absaetzeVon('wechsel', 'zusatzspeicher'), speicher = absaetzeVon('zusatzspeicher', 'p11')
@@ -181,7 +189,7 @@ test('Faire Nutzung der Postfächer, Hetzner benannt, Formulare, AVV-Zusatzleist
assert.ok(avv.includes('sowie für gesondert vereinbarte Zusatzleistungen zum Abo, etwa eine Formularlösung,'))
assert.ok(avv.includes('sowie gesondert vereinbarter Zusatzleistungen'))
assert.ok(avv.includes('zu dem der neue Unterauftragsverarbeiter eingesetzt werden soll'))
assert.ok(strip(read(V4)).includes('über „Abo verwalten & Rechnungen“'))
assert.ok(strip(read(CUR)).includes('über „Abo verwalten & Rechnungen“'))
})
// Text je <li> und <p>, jeweils dem innersten offenen Element zugeordnet. Text hinter einer verschachtelten
@@ -202,7 +210,7 @@ const textJeElement = (html) => {
test('Jeder Preis in Absätzen und Listenpunkten mit Hinweis auf § 19 UStG', () => {
// Die Übersichtstabelle ist ausgenommen (<td>); ihre Zeile "Preise" trägt den Hinweis
assert.ok(uebersichtZeile('Preise').includes('Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.'))
const mitPreis = textJeElement(read(V4)).filter((t) => /\d+(,\d\d)? €/.test(t))
const mitPreis = textJeElement(read(CUR)).filter((t) => /\d+(,\d\d)? €/.test(t))
assert.ok(mitPreis.length >= 7, `Preisstellen: ${mitPreis.length}`)
// Ein Wortlaut je Stelle: Fliesstext in Klammern, Kopfzeilen von A und B mit Punkt, § 7 Abs. 1 mit vollem Satz
const fliesstext = '(Endpreis, gemäß § 19 UStG ohne Umsatzsteuer)'
@@ -211,11 +219,11 @@ test('Jeder Preis in Absätzen und Listenpunkten mit Hinweis auf § 19 UStG', ()
for (const t of mitPreis) {
assert.ok(t.includes(fliesstext) || t.includes(kopfzeile) || t.includes(p7), `ohne einheitlichen § 19-Hinweis: ${t.slice(0, 90)}`)
}
assert.ok(!strip(read(V4)).includes('(gemäß § 19 UStG ohne Umsatzsteuer)'), 'alter Wortlaut ohne „Endpreis“')
assert.ok(!strip(read(CUR)).includes('(gemäß § 19 UStG ohne Umsatzsteuer)'), 'alter Wortlaut ohne „Endpreis“')
})
test('Alle internen Links und Querverweise treffen', () => {
const html = read(V4)
const html = read(CUR)
const ids = [...html.matchAll(/ id="([^"]+)"/g)].map((m) => m[1])
for (const [, ref] of html.matchAll(/href="#([^"]+)"/g)) assert.equal(ids.filter((i) => i === ref).length, 1, `#${ref}`)
// Absätze je Paragraph: <li> der ol.abs auf oberster Ebene; bei § 3 die Überschriften "(M)"
@@ -248,7 +256,7 @@ test('Alle internen Links und Querverweise treffen', () => {
// --- Follow-up vor der Rechtsrunde (Task 1, Fix 2) ---
test('Follow-up: Webmail, Überschrift der Hinweise, § 11 Abs. 3, § 4 Preiswortlaut', () => {
const html = read(V4), text = strip(html)
const html = read(CUR), text = strip(html)
// Webmail als Zugang zu den Postfaechern, einmal genannt (§ 3 Abs. 4); Übersicht und A/B nennen keine Zugangswege
const p3 = html.slice(html.indexOf('id="p3"'), html.indexOf('id="p4"'))
const abs4 = strip(between(p3, '<h3>(4) E-Mail-Postfächer</h3>', '<h3>(5)'))
@@ -288,8 +296,8 @@ test('Datenschutz: Abos, produktneutral, Stand wie termsVersion', () => {
'für das Wartungs-Abo über Stripe', 'Sie sehen diese Ereignisse', 'Unterauftragsverarbeiter']) assert.ok(!ds.includes(s), `noch da: ${s}`)
// Stand = Datum aus termsVersion (JJJJ-MM-TT-v4). Die Kopplung gilt bis zum Livegang: Task 20 trägt in beiden das
// Freigabedatum ein. Danach darf sie gelockert werden, sobald die Erklärung unabhängig von den Bedingungen geändert wird.
const m = /^(\d{4})-(\d{2})-(\d{2})-v4$/.exec(config.abo.termsVersion)
assert.ok(m, `termsVersion nicht im Format JJJJ-MM-TT-v4: ${config.abo.termsVersion}`)
const m = /^(\d{4})-(\d{2})-(\d{2})-v\d+$/.exec(config.abo.termsVersion)
assert.ok(m, `termsVersion nicht im Format JJJJ-MM-TT-vN: ${config.abo.termsVersion}`)
const [, y, mo, d] = m
const stand = /Stand (\d{2}\.\d{2}\.\d{4})/.exec(ds)
assert.ok(stand, 'Stand fehlt')
@@ -425,3 +433,28 @@ test('§ 13 Abs. 3: Datenverlust-Klausel mit Ausnahme für Absatz 1 (Vorsatz, gr
const html = readFileSync(new URL('../public/abo-bedingungen.html', import.meta.url), 'utf8')
assert.ok(html.includes('Außer in den Fällen von Absatz 1 haften wir für den Verlust von Daten'))
})
/* ------------------------------------------------ Fassung 5: Unterstützung --- */
test('Fassung 4 archiviert: HTML bis auf den PDF-Link bytegleich, PDF bytegleich', () => {
const archived = read(V4)
assert.ok(archived.includes(V4_ARCHIVE_PDF_LINK), 'Archiv verlinkt das archivierte PDF')
assert.ok(!archived.includes(V4_PDF_LINK))
const original = archived.split(V4_ARCHIVE_PDF_LINK).join(V4_PDF_LINK)
assert.equal(createHash('sha256').update(original).digest('hex'), V4_SHA256)
assert.equal(sha256('public/vertragsunterlagen/v4/WEBklar-Abo-Vertragsunterlagen.pdf'), V4_PDF_SHA256)
assert.ok(archived.includes('Version 4 · Stand 30.09.2026'))
})
test('Fassung 5: § 4 Abs. 4 regelt die Unterstützung, Abs. 7 verweist auf Satz 1 bis 3', () => {
const text = strip(read(CUR))
assert.ok(text.includes('Angerechnet wird die tatsächlich aufgewendete Zeit je Auftrag, aufgerundet auf volle 15 Minuten. Wirkt an einem Arbeitsschritt eine weitere Person unterstützend mit, etwa bei Gestaltung, Abstimmung oder Prüfung, rechnen wir für sie die Hälfte der Arbeitszeit dieses Arbeitsschritts an, aufgerundet auf volle 15 Minuten; bei mehreren unterstützenden Personen gilt das für jede von ihnen. Im Kundenportal weisen wir jeden Arbeitsschritt mit seiner Arbeitszeit und jede Unterstützung mit Person, Tätigkeit und angerechneter Zeit einzeln aus. Dazu zählen die Umsetzung und die Abstimmung zum Auftrag'))
assert.ok(text.includes('Absatz 4 Satz 1 bis 3 gelten entsprechend'))
assert.ok(!text.includes('Absatz 4 Satz 1 und 2 gelten entsprechend'))
})
test('Fassung 5: Widerrufsbelehrung und Formular wortgleich mit Fassung 4', () => {
const cur = read(CUR), v4 = read(V4)
const muster = (belehrung) => between(belehrung, 'Widerrufsrecht Sie haben das Recht', 'Ende der Widerrufsbelehrung')
assert.equal(muster(section(cur, 'widerruf', 'formular')), muster(section(v4, 'widerruf', 'formular')))
})