From 214d698d6f3d1d7310811608e5303a309fe66105 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:48:24 +0000 Subject: [PATCH] =?UTF-8?q?Abo-Fassung=206:=20Wortlaute=20f=C3=BCr=20Linkz?= =?UTF-8?q?eile=20und=20H=C3=A4kchen,=20Unterlagen-Tabelle=20der=20Funktio?= =?UTF-8?q?nen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/products.js | 62 +++++++++++++++++++++++++++++++++++++ test/aboWortlautV6.test.js | 50 ++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 test/aboWortlautV6.test.js diff --git a/server/services/products.js b/server/services/products.js index 5831a7e..d3ab93b 100644 --- a/server/services/products.js +++ b/server/services/products.js @@ -214,3 +214,65 @@ export function creditMinutesForInvoice(invoice, priceKeys) { } return 0 } + +/* --------------------------------------------------------------------------------------------- + * Fassung 6 (Entwurf bis zur Freigabe, E20): Wortlaute fuer die Bestellseiten und Einschalt-Seiten + * sowie die Unterlagen-Tabelle der Funktionen. Dieses Paket liefert die Konstanten; verdrahtet werden + * sie erst mit P5/P7 (Nachtrag N33). Texte in "du"-Form, ohne Preise. + * ------------------------------------------------------------------------------------------- */ + +/** Fassung der Funktionsunterlagen (Leistungsbeschreibungen, AVV). Entwurfstag; bei der Freigabe das Datum setzen. */ +export const FUNKTIONEN_FASSUNG_V6 = '2026-10-16' + +/** + * Wortlaute der Oberflaeche. linkzeile: genau ein Platzhalter {fassung} (mitFassung fuellt ihn); + * bestellen gilt fuer Abo, Wechsel und Zusatzspeicher. beginn: das Haekchen nur fuer Privatkunden in der Frist. + * hinweis: die Pflichten der alten Haekchen verantwortung, stripe, gesundheit als ein Satz auf der Einschalt-Seite. + */ +export const WORTLAUT_V6 = Object.freeze({ + linkzeile: Object.freeze({ + bestellen: 'Es gelten die Vertragsbedingungen mit Leistungsbeschreibung, Auftragsverarbeitung und Widerrufsbelehrung (Fassung {fassung}). Alles kommt danach auch als PDF per Mail.', + einschaltenMitAvv: 'Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung {fassung}).', + einschaltenOhneAvv: 'Es gilt die Leistungsbeschreibung (Fassung {fassung}).', + }), + beginn: Object.freeze({ + abo: 'Bitte beginnt sofort mit dem Abo. Mir ist klar: Widerrufe ich innerhalb von 14 Tagen, zahle ich den bis dahin erbrachten Teil.', + wechsel: 'Bitte beginnt sofort mit dem Wechsel. Mir ist klar: Widerrufe ich innerhalb von 14 Tagen, zahle ich den bis dahin erbrachten Teil.', + einrichtung: 'Bitte beginnt sofort mit der Einrichtung. Mir ist klar: Mit der vollständigen Einrichtung erlischt mein Widerrufsrecht, und wenn ich vorher widerrufe, zahle ich den bis dahin erbrachten Teil.', + }), + hinweis: Object.freeze({ + shop: 'Du verkaufst in eigenem Namen und bist für deine Produkte, Preise und Rechtstexte verantwortlich. Die Zahlungen deiner Käufer laufen über dein eigenes Stripe-Konto.', + unterkunft: 'Du vermietest in eigenem Namen und bist für Preise, Stornobedingungen, Rechnungen und Meldepflichten verantwortlich. Online-Zahlungen deiner Gäste laufen über dein eigenes Stripe-Konto.', + termine: 'Der Terminplaner ist nicht für den Gesundheitsbereich gedacht. Das fragen wir im Fragebogen ab.', + }), +}) + +/** Ersetzt {fassung} im Wortlaut; wirft bei leerer Fassung oder wenn der Text keinen Platzhalter enthaelt. */ +export function mitFassung(text, fassung) { + if (typeof fassung !== 'string' || fassung.trim() === '') throw new Error('Fassung fehlt oder ist leer') + if (typeof text !== 'string' || !text.includes('{fassung}')) throw new Error('Platzhalter {fassung} fehlt im Text') + return text.split('{fassung}').join(fassung) +} + +/** Ein Dokument der Funktionsunterlagen im Vorschaupfad: Seite, PDF und Dateiname des Mail-Anhangs. */ +function dok(slug, anhang) { + return Object.freeze({ + version: FUNKTIONEN_FASSUNG_V6, + url: `/neu/rechtliches/${slug}-${FUNKTIONEN_FASSUNG_V6}.html`, + pdfFile: `neu/rechtliches/${slug}-${FUNKTIONEN_FASSUNG_V6}.pdf`, + anhangName: `${anhang}-${FUNKTIONEN_FASSUNG_V6}.pdf`, + }) +} + +/** + * Unterlagen je Funktion in Fassung 6: { [key]: { lb, avv } }. avv ist null, wo keine personenbezogenen Daten im + * Auftrag verarbeitet werden (News, Oeffnungszeiten live). Reihenfolge: news, termine, shop, unterkunft, zeiten, analyse. + */ +export const FUNKTIONEN_UNTERLAGEN_V6 = Object.freeze({ + news: Object.freeze({ lb: dok('leistungsbeschreibung-news', 'Leistungsbeschreibung-News-Pinnwand'), avv: null }), + termine: Object.freeze({ lb: dok('leistungsbeschreibung-terminplaner', 'Leistungsbeschreibung-Terminplaner'), avv: dok('avv-terminplaner', 'AVV-Terminplaner') }), + shop: Object.freeze({ lb: dok('leistungsbeschreibung-webshop', 'Leistungsbeschreibung-Webshop'), avv: dok('avv-webshop', 'AVV-Webshop') }), + unterkunft: Object.freeze({ lb: dok('leistungsbeschreibung-unterkunft', 'Leistungsbeschreibung-Buchungskalender'), avv: dok('avv-unterkunft', 'AVV-Buchungskalender') }), + zeiten: Object.freeze({ lb: dok('leistungsbeschreibung-zeiten', 'Leistungsbeschreibung-Oeffnungszeiten-live'), avv: null }), + analyse: Object.freeze({ lb: dok('leistungsbeschreibung-website-analyse', 'Leistungsbeschreibung-Website-Analyse'), avv: dok('avv-website-analyse', 'AVV-Website-Analyse') }), +}) diff --git a/test/aboWortlautV6.test.js b/test/aboWortlautV6.test.js new file mode 100644 index 0000000..d941e97 --- /dev/null +++ b/test/aboWortlautV6.test.js @@ -0,0 +1,50 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +const { config } = await import('../server/config.js') +const { WORTLAUT_V6, FUNKTIONEN_FASSUNG_V6, FUNKTIONEN_UNTERLAGEN_V6, mitFassung } = await import('../server/services/products.js') + +test('Linkzeilen: Wortlaut, Platzhalter wird gefüllt', () => { + assert.equal(mitFassung(WORTLAUT_V6.linkzeile.bestellen, '2026-10-16-v6'), 'Es gelten die Vertragsbedingungen mit Leistungsbeschreibung, Auftragsverarbeitung und Widerrufsbelehrung (Fassung 2026-10-16-v6). Alles kommt danach auch als PDF per Mail.') + assert.equal(mitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv, '2026-10-16'), 'Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung 2026-10-16).') + assert.equal(mitFassung(WORTLAUT_V6.linkzeile.einschaltenOhneAvv, '2026-10-16'), 'Es gilt die Leistungsbeschreibung (Fassung 2026-10-16).') + assert.throws(() => mitFassung(WORTLAUT_V6.linkzeile.bestellen, ''), /Fassung/) + assert.throws(() => mitFassung('ohne Platzhalter', '2026-10-16'), /Platzhalter/) +}) +test('Häkchen beginn: Wortlaut je Fall (nur privat in der Frist)', () => { + assert.deepEqual(WORTLAUT_V6.beginn, { + abo: 'Bitte beginnt sofort mit dem Abo. Mir ist klar: Widerrufe ich innerhalb von 14 Tagen, zahle ich den bis dahin erbrachten Teil.', + wechsel: 'Bitte beginnt sofort mit dem Wechsel. Mir ist klar: Widerrufe ich innerhalb von 14 Tagen, zahle ich den bis dahin erbrachten Teil.', + einrichtung: 'Bitte beginnt sofort mit der Einrichtung. Mir ist klar: Mit der vollständigen Einrichtung erlischt mein Widerrufsrecht, und wenn ich vorher widerrufe, zahle ich den bis dahin erbrachten Teil.', + }) +}) +test('Pflichten aus den alten Häkchen verantwortung, stripe, gesundheit', () => { + assert.deepEqual(WORTLAUT_V6.hinweis, { + shop: 'Du verkaufst in eigenem Namen und bist für deine Produkte, Preise und Rechtstexte verantwortlich. Die Zahlungen deiner Käufer laufen über dein eigenes Stripe-Konto.', + unterkunft: 'Du vermietest in eigenem Namen und bist für Preise, Stornobedingungen, Rechnungen und Meldepflichten verantwortlich. Online-Zahlungen deiner Gäste laufen über dein eigenes Stripe-Konto.', + termine: 'Der Terminplaner ist nicht für den Gesundheitsbereich gedacht. Das fragen wir im Fragebogen ab.', + }) +}) +test('alle Wortlaute: du-Form, kein Preis, nur {fassung} als Platzhalter, tief eingefroren', () => { + const alle = JSON.stringify(WORTLAUT_V6) + assert.doesNotMatch(alle, /€|\bSie\b|\bIhr(e[nmrs]?)?\b/) + assert.deepEqual([...alle.matchAll(/\{[a-z]+\}/g)].map((m) => m[0]).filter((x) => x !== '{fassung}'), []) + for (const t of Object.values(WORTLAUT_V6.linkzeile)) assert.equal((t.match(/\{fassung\}/g) ?? []).length, 1) + for (const o of [WORTLAUT_V6, WORTLAUT_V6.linkzeile, WORTLAUT_V6.beginn, WORTLAUT_V6.hinweis]) assert.ok(Object.isFrozen(o)) +}) +test('Funktionsunterlagen Fassung 6: Reihenfolge, Vorschaupfad, AVV nur wo Daten im Auftrag verarbeitet werden', () => { + assert.equal(FUNKTIONEN_FASSUNG_V6, '2026-10-16') + assert.deepEqual(Object.keys(FUNKTIONEN_UNTERLAGEN_V6), ['news', 'termine', 'shop', 'unterkunft', 'zeiten', 'analyse']) + assert.deepEqual(FUNKTIONEN_UNTERLAGEN_V6.termine.lb, { version: '2026-10-16', url: '/neu/rechtliches/leistungsbeschreibung-terminplaner-2026-10-16.html', pdfFile: 'neu/rechtliches/leistungsbeschreibung-terminplaner-2026-10-16.pdf', anhangName: 'Leistungsbeschreibung-Terminplaner-2026-10-16.pdf' }) + const slugs = Object.values(FUNKTIONEN_UNTERLAGEN_V6).flatMap((u) => [u.lb, u.avv]).filter(Boolean).map((d) => d.url.replace(/^\/neu\/rechtliches\/|-2026-10-16\.html$/g, '')) + assert.deepEqual(slugs, ['leistungsbeschreibung-news', 'leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'leistungsbeschreibung-webshop', 'avv-webshop', 'leistungsbeschreibung-unterkunft', 'avv-unterkunft', 'leistungsbeschreibung-zeiten', 'leistungsbeschreibung-website-analyse', 'avv-website-analyse']) + assert.deepEqual([FUNKTIONEN_UNTERLAGEN_V6.news.avv, FUNKTIONEN_UNTERLAGEN_V6.zeiten.avv], [null, null]) + assert.deepEqual(Object.values(FUNKTIONEN_UNTERLAGEN_V6).flatMap((u) => [u.lb, u.avv]).filter(Boolean).map((d) => d.anhangName.replace('-2026-10-16.pdf', '')), ['Leistungsbeschreibung-News-Pinnwand', 'Leistungsbeschreibung-Terminplaner', 'AVV-Terminplaner', 'Leistungsbeschreibung-Webshop', 'AVV-Webshop', 'Leistungsbeschreibung-Buchungskalender', 'AVV-Buchungskalender', 'Leistungsbeschreibung-Oeffnungszeiten-live', 'Leistungsbeschreibung-Website-Analyse', 'AVV-Website-Analyse']) +}) +test('Verdrahtung mit P5 (läuft erst nach dem Merge von kn/server; rot, bis der Controller verdrahtet hat)', { skip: config.aboFunktionen === undefined && 'config.aboFunktionen fehlt: P5 noch nicht gemerged' }, async () => { + const reg = await import('../server/services/featureRegistry.js') + assert.equal(config.aboFunktionen.fassung, FUNKTIONEN_FASSUNG_V6) + assert.equal(config.aboFunktionen.texte.beginn, WORTLAUT_V6.beginn.einrichtung) + for (const key of ['termine', 'shop', 'unterkunft']) assert.equal(reg.consentAbo(key).items[0].text, mitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv, FUNKTIONEN_FASSUNG_V6), key) + for (const key of ['news', 'zeiten']) assert.equal(reg.consentAbo(key).items[0].text, mitFassung(WORTLAUT_V6.linkzeile.einschaltenOhneAvv, FUNKTIONEN_FASSUNG_V6), key) +})