Vertragsbedingungen Version 4: Korrekturen aus der Prüfung (Widerrufsfrist, Kündigung, Bestellübersicht, Postfächer, AVV, § 19 UStG)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-27 19:41:22 +00:00
1 parent f19187cc16
commit fead8fdd5a
3 files changed
+128 -34

No files matched your search

+95 -1
View File
@@ -12,7 +12,7 @@ const V3_SHA256 = '1c9e4b002dcd5d7896bbda24c573e56544b7123e3a88d154e5db77bd2855b
const file = (rel) => new URL(`../${rel}`, import.meta.url)
const read = (rel) => readFileSync(file(rel), 'utf8')
const sha256 = (rel) => createHash('sha256').update(readFileSync(file(rel))).digest('hex')
const strip = (html) => html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
const strip = (html) => html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim()
// HTML-Ausschnitt von einem Anker bis zum naechsten, danach ohne Tags. Die Inhaltsuebersicht wiederholt
// die Ueberschriften, deshalb wird nicht nach dem ersten Text-Treffer, sondern nach id="..." geschnitten.
@@ -106,3 +106,97 @@ test('PDF der Fassung 4 verlinkt, erzeugt und im Build-Skript', () => {
const erzeugt = build.split('\n').filter((l) => l.startsWith('pdf '))
assert.ok(!erzeugt.some((l) => l.includes('WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')), 'Fassung 3 wird nicht neu erzeugt')
})
// --- Korrekturen aus der Prüfung (Review 1) ---
const v4Section = (fromId, toId) => section(read(V4), 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 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]))
assert.equal(absaetze.length, 6)
assert.ok(absaetze[2].startsWith('Der Vertrag kommt zustande, sobald der Kunde den Vorgang bei Stripe abgeschlossen hat'))
assert.ok(absaetze[4].includes('Mit dem Klick auf „Zahlungspflichtig bestellen“ kommt der Vertrag zustande'))
assert.ok(hinweise.includes('gehören zu diesen Bedingungen, nicht zur gesetzlichen Widerrufsbelehrung'))
assert.ok(hinweise.includes('Mehrbetrag ist, was der Kunde seit dem Wechsel mehr gezahlt hat'))
assert.ok(hinweise.includes('Wertersatz verlangen wir dafür nicht (Nr. 4)'))
})
test('Wechsel, Plus und Minus im Portal nur ohne Kündigung und ohne offene Zahlung', () => {
const wechsel = v4Section('wechsel', 'zusatzspeicher'), speicher = v4Section('zusatzspeicher', 'p11')
assert.ok(wechsel.includes('nicht gekündigt'))
assert.ok(speicher.includes('nicht gekündigt'))
assert.ok(speicher.includes('„Nur den E-Mail-Zusatzspeicher“'))
// Übersicht: kein unbedingtes "jederzeit" mehr bei Wechsel und Zusatzspeicher
const uebersicht = strip(read(V4)).split('Inhalt ')[0]
assert.ok(!/jederzeit im Kundenportal/.test(uebersicht))
assert.ok(!/Jederzeit zum Ende des Abrechnungsmonats reduzierbar/.test(uebersicht))
})
test('Bestellübersichten nennen Laufzeit und Kündigungsmöglichkeit (§ 312j Abs. 2 BGB)', () => {
assert.ok(v4Section('p2', 'p3').includes('sowie Laufzeit und Kündigungsmöglichkeit'))
assert.ok(v4Section('wechsel', 'zusatzspeicher').includes('die Kündigungsmöglichkeit nach Ablauf der neuen Mindestlaufzeit'))
assert.ok(v4Section('zusatzspeicher', 'p11').includes('zum Ende jedes Abrechnungsmonats kündbar'))
})
test('Faire Nutzung der Postfächer, Hetzner benannt, Formulare, AVV-Zusatzleistungen', () => {
const leistungen = v4Section('p3', 'p4')
assert.ok(leistungen.includes('auch unentgeltlich'))
assert.ok(leistungen.includes('Versandgrenzen von Hetzner'))
assert.ok(leistungen.includes('(im Folgenden „Hetzner“)'))
assert.ok(!/\bder Anbieter\b/.test(leistungen))
assert.ok(v4Section('p6', 'p7').includes('für ihn tätige Personen'))
const hosting = v4Section('hosting', 'wartung')
assert.ok(hosting.includes('sind im Hosting-Abo nicht enthalten; eine Lösung dafür bieten wir auf Anfrage gesondert an'))
assert.ok(!hosting.includes('Formulardienst'))
const avv = v4Section('avv', 'tom')
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“'))
})
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
const elemente = [...read(V4).matchAll(/<(li|p)\b[^>]*>([\s\S]*?)<\/\1>/g)].map((m) => strip(m[2]))
const mitPreis = elemente.filter((t) => /\d+(,\d\d)? €/.test(t))
assert.ok(mitPreis.length >= 7, `Preisstellen: ${mitPreis.length}`)
for (const t of mitPreis) assert.ok(t.includes('§ 19 UStG'), `ohne § 19 UStG: ${t.slice(0, 90)}`)
})
test('Alle internen Links und Querverweise treffen', () => {
const html = read(V4)
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)"
const absaetze = {}
for (const [, n, body] of html.matchAll(/<h2 id="[^"]+">§ (\d+) [^<]*<\/h2>([\s\S]*?)(?=<h2 |<p class="anlage-kopf)/g)) {
const h3 = [...body.matchAll(/<h3>\((\d+)\)/g)].map((x) => Number(x[1]))
if (h3.length) { absaetze[n] = Math.max(...h3); continue }
const start = body.indexOf('<ol class="abs">')
let depth = 0, count = 0
if (start >= 0) for (const [, close, tag] of body.slice(start).matchAll(/<(\/?)(ol|ul|li)\b[^>]*>/g)) {
if (tag === 'li') { if (!close && depth === 1) count++; continue }
depth += close ? -1 : 1
if (depth === 0) break
}
absaetze[n] = count
}
assert.equal(Object.keys(absaetze).length, 16)
const text = strip(html)
const refs = [...text.matchAll(/§ (\d+) Abs\. (\d+)/g)]
assert.ok(refs.length >= 15, `Querverweise: ${refs.length}`)
for (const [ref, n, a] of refs) assert.ok(Number(a) <= (absaetze[n] ?? 0), `${ref}: § ${n} hat ${absaetze[n] ?? 0} Absätze`)
// Leistungsbeschreibung A/B Nr. N: nummerierte <h3> der jeweiligen Beschreibung
const nummern = (fromId, toId) => [...html.slice(html.indexOf(`id="${fromId}"`), html.indexOf(`id="${toId}"`)).matchAll(/<h3>(\d+)\. /g)].map((x) => Number(x[1]))
const maxA = Math.max(...nummern('hosting', 'wartung')), maxB = Math.max(...nummern('wartung', 'widerruf'))
for (const [ref, ab, nr] of text.matchAll(/Leistungsbeschreibung ([AB]) Nr\. (\d+)/g)) assert.ok(Number(nr) <= (ab === 'A' ? maxA : maxB), ref)
for (const [ref, nr] of v4Section('hosting', 'wartung').matchAll(/\bNr\. (\d+)/g)) assert.ok(Number(nr) <= maxA, `A: ${ref}`)
for (const [ref, nr] of v4Section('wartung', 'widerruf').matchAll(/\bNr\. (\d+)/g)) assert.ok(Number(nr) <= maxB, `B: ${ref}`)
})