From 3073fffcf5d72a22fd0e8da46f380c6eeba331d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:30:38 +0000 Subject: [PATCH] =?UTF-8?q?News:=20Zitat=20=C2=A7=20356=20Abs.=205=20Nr.?= =?UTF-8?q?=202,=20Wertersatz-Satz=20und=20=C2=A7=2019=20UStG=20statt=20br?= =?UTF-8?q?utto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../2026-09-30-funktionen-news-design.md | 13 +- public/funktionen.js | 9 +- .../funktionen-leistungsbeschreibung.html | 10 +- server/config.js | 21 +- test/featureMails.test.js | 4 +- test/featureRegistry.test.js | 7 +- test/funktionenRecht.test.js | 228 ++++++++++++++++++ test/funktionenTiles.test.js | 4 +- test/legalDocs.test.js | 4 +- test/legalTexts.test.js | 3 +- 10 files changed, 277 insertions(+), 26 deletions(-) create mode 100644 test/funktionenRecht.test.js diff --git a/docs/superpowers/specs/2026-09-30-funktionen-news-design.md b/docs/superpowers/specs/2026-09-30-funktionen-news-design.md index 6d349ef..d7222a8 100644 --- a/docs/superpowers/specs/2026-09-30-funktionen-news-design.md +++ b/docs/superpowers/specs/2026-09-30-funktionen-news-design.md @@ -69,13 +69,18 @@ auf `off`). - Grobe Zeiteinschätzung: „meist innerhalb von 1–2 Wochen“ (Text in `config`, kein Versprechen). - Projektauswahl bei mehreren Websites. - Pflicht-Checkbox (Widerruf, analog Abo-Bestellung): „Ich verlange ausdrücklich, dass WEBklar vor Ablauf - der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald - der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).“ Einwilligung wird mit Zeitstempel und - Textfassung am Feature-Dokument gespeichert (wie die Abo-Consent-Metadaten). + der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen + Erbringung des Einbaus erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, + schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).“ Einwilligung + wird mit Zeitstempel und Textfassung am Feature-Dokument gespeichert (wie die Abo-Consent-Metadaten). + *Korrekturvermerk 2026-10-05 (Terminplaner TP2, Task F2): Ursprünglich stand hier „… erlischt, sobald der Einbau + vollständig erbracht ist (§ 356 Abs. 4 BGB).“ Zitat und Wertersatz-Satz sind korrigiert bzw. ergänzt (Entwurf bis + zum Befund der Kanzlei, Kanzlei-Paket Frage Q3). Bestehende Bestellungen behalten den damals gespeicherten Text + und die damalige Fassung.* - Knopf „**Zahlungspflichtig bestellen · 30 €**“ ⇒ `POST /api/funktionen/news/checkout`. **Zahlung**: Stripe Checkout `mode: 'payment'`, einmaliger Preis über `ensurePrice('news_setup')` -(Katalog in `config`/`services/products.js`, 30,00 € brutto), `invoice_creation.enabled = true`, +(Katalog in `config`/`services/products.js`, 30,00 €; *Korrekturvermerk 2026-10-05: ohne Umsatzsteuer gemäß § 19 UStG, nicht „brutto“*), `invoice_creation.enabled = true`, Zahlarten wie beim Abo (Karte, SEPA). Metadaten `{ customerId, projectId, featureKey: 'news' }`. **Stripe-Webhook** `checkout.session.completed` (bzw. `checkout.session.async_payment_succeeded` bei SEPA): diff --git a/public/funktionen.js b/public/funktionen.js index d4a67d6..a8e6889 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -8,6 +8,8 @@ import { HEAT_DEVICES, deviceHtml, heatCaption } from '/geraete.js' import { ZU_VERTRAEGE } from './vertraege-link.js' const PRICE = '30 €' // Rueckfall, wenn die Antwort von /api/funktionen kein features.news (Register) enthaelt +// Rueckfall fuer den Preishinweis, wenn die Antwort von /api/funktionen kein ustHinweis hat (gleicher Wortlaut wie config.ustHinweis) +const UST_HINWEIS = 'ohne Umsatzsteuer gemäß § 19 UStG' const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' const PHOTO_ACCEPT = 'image/jpeg,image/png,image/webp' // iOS wandelt HEIC dann selbst in JPEG um const PHOTO_MAX_BYTES = 10 * 1024 * 1024 @@ -266,7 +268,7 @@ export async function initFunktionen(root) { export async function initNews(root, { projectId } = {}) { if (!root) return if (projectId) selection.projectId = projectId - const { projects = [], setupEstimate = '', consentText = '', features } = await request('/api/funktionen') + const { projects = [], setupEstimate = '', consentText = '', features, ustHinweis } = await request('/api/funktionen') if (!projects.length) { root.innerHTML = '
Noch keine Website-Projekte zugewiesen.
' return @@ -301,7 +303,7 @@ export async function initNews(root, { projectId } = {}) { if (feature.state === 'active') renderEditor(stage, project) else if (isSetup(feature.state)) stage.innerHTML = thanks + statusCard(feature, setupEstimate) else if (feature.state === 'disabled') stage.innerHTML = '

News-Pinnwand ist abgeschaltet

Für diese Website ist die Pinnwand im Moment nicht aktiv. , dann klären wir das.

' - else renderBooking(stage, project, { setupEstimate, offer: newsOffer(features, consentText), thanks }) + else renderBooking(stage, project, { setupEstimate, offer: { ...newsOffer(features, consentText), ustHinweis: textOr(ustHinweis, UST_HINWEIS) }, thanks }) } function renderBooking(stage, project, { setupEstimate, offer, thanks }) { @@ -315,7 +317,7 @@ function renderBooking(stage, project, { setupEstimate, offer, thanks }) {
  • Beiträge jederzeit selbst schreiben, ändern und abnehmen
  • Nutzung danach kostenlos, kein Abo
  • -

    ${esc(offer.price)} einmalig, inklusive Mehrwertsteuer

    +

    ${esc(offer.price)} einmalig,

    ${setupEstimate ? `

    Einrichtung: ${esc(setupEstimate)}

    ` : ''} @@ -323,6 +325,7 @@ function renderBooking(stage, project, { setupEstimate, offer, thanks }) {
    ` stage.querySelector('#news-consent-text').textContent = offer.consentText + stage.querySelector('#news-ust-hinweis').textContent = offer.ustHinweis const box = stage.querySelector('#news-consent') const button = stage.querySelector('#news-order') const error = stage.querySelector('#news-order-error') diff --git a/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html b/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html index ba7197b..b4245cb 100644 --- a/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html +++ b/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html @@ -40,15 +40,15 @@
    WEBKLAR

    Leistungsbeschreibung: Funktionen – News-Pinnwand

    -

    Stand 30.09.2026

    +

    Stand 05.10.2026

    Das Wichtigste auf einen Blick - + - +
    LeistungEinbau der News-Pinnwand in Ihre Website durch WEBklar; Pflege der Beiträge durch Sie im Kundenportal
    Preis30,00 € einmalig, inklusive Umsatzsteuer, keine laufenden Kosten
    Preis30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), keine laufenden Kosten
    Dauer des Einbausmeist innerhalb von 1–2 Wochen (keine verbindliche Zusage)
    WiderrufVerbraucher können innerhalb von 14 Tagen widerrufen; das Recht kann nach § 356 Abs. 4 BGB erlöschen (Abschnitt 6)
    WiderrufVerbraucher können innerhalb von 14 Tagen widerrufen; das Recht kann nach § 356 Abs. 5 Nr. 2 BGB erlöschen (Abschnitt 6)
    @@ -57,7 +57,7 @@

    Änderungen an der Website, die über den Einbau der Pinnwand hinausgehen, sind nicht Teil dieser Leistung.

    2. Preis

    -

    Der Einbau kostet 30,00 € einmalig, inklusive Umsatzsteuer. Die Nutzung der Pinnwand danach kostet nichts; es entstehen keine laufenden Kosten und kein Abo. Sie bezahlen bei der Bestellung im Kundenportal über Stripe und erhalten eine Rechnung.

    +

    Der Einbau kostet 30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG). Die Nutzung der Pinnwand danach kostet nichts; es entstehen keine laufenden Kosten und kein Abo. Sie bezahlen bei der Bestellung im Kundenportal über Stripe und erhalten eine Rechnung.

    3. Einbau und Freischaltung

    Mit Ihrer Bestellung legen wir einen Auftrag für den Einbau an. Der Einbau ist erfahrungsgemäß meist innerhalb von 1–2 Wochen abgeschlossen. Das ist eine Einschätzung und keine verbindliche Zusage eines Termins. Nach Abschluss des Einbaus schalten wir die Funktion für Sie frei; ab dann erscheinen Ihre Beiträge auf der Website.

    @@ -70,7 +70,7 @@

    6. Widerrufsrecht

    Sind Sie Verbraucher, können Sie diesen Vertrag innerhalb von 14 Tagen ohne Angabe von Gründen widerrufen. Die Frist beginnt mit dem Vertragsschluss. Zum Widerruf genügt eine eindeutige Erklärung an uns, zum Beispiel über project.webklar.com/widerrufen.html.

    -

    Verlangen Sie ausdrücklich, dass wir vor Ablauf der Widerrufsfrist mit dem Einbau beginnen, müssen Sie uns bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachte Leistung zahlen. Das Widerrufsrecht erlischt nach § 356 Abs. 4 BGB, wenn wir die Leistung vollständig erbracht haben und Sie vorher ausdrücklich zugestimmt haben, dass wir vor Ablauf der Widerrufsfrist beginnen, und Ihre Kenntnis bestätigt haben, dass Sie Ihr Widerrufsrecht bei vollständiger Vertragserfüllung verlieren.

    +

    Verlangen Sie ausdrücklich, dass wir vor Ablauf der Widerrufsfrist mit dem Einbau beginnen, müssen Sie uns bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachte Leistung zahlen (§ 357a Abs. 2 BGB). Das Widerrufsrecht erlischt nach § 356 Abs. 5 Nr. 2 BGB, wenn wir die Leistung vollständig erbracht haben und Sie vorher ausdrücklich zugestimmt haben, dass wir vor Ablauf der Widerrufsfrist beginnen, und Ihre Kenntnis bestätigt haben, dass Sie Ihr Widerrufsrecht bei vollständiger Vertragserfüllung verlieren.

    Anbieter: WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler. Es gelten ergänzend die Vertragsbedingungen und die Datenschutzerklärung.

    diff --git a/server/config.js b/server/config.js index 138887d..5a7e695 100644 --- a/server/config.js +++ b/server/config.js @@ -180,19 +180,30 @@ const features = { label: { name: 'News-Pinnwand', kurz: 'News', keine: 'keine News-Pinnwand' }, priceLabel: '30 €', setupEstimate: 'meist innerhalb von 1–2 Wochen', - // Wortlaut der Zustimmung; wird mit Zeitstempel am Feature-Dokument gespeichert (Widerruf, § 356 Abs. 4 BGB) + // Wortlaut der Zustimmung; wird mit Zeitstempel am Feature-Dokument gespeichert (Widerruf, § 356 Abs. 5 Nr. 2 BGB, + // Wertersatz § 357a Abs. 2 BGB). Aufbau wie der Gaestetext ZUSTIMMUNG_VORZEITIG; Zitat und Wertersatz-Satz sind + // Entwurf bis zur Kanzlei (Kanzlei-Paket, Frage Q3). Die Fassung folgt config.termine.avvVersion (); bestehende + // Bestellungen behalten Text und Fassung, die am Dokument und im Nachweis gespeichert sind. consent: { - version: '2026-09-30', + get version() { + return config.termine.avvVersion + }, knopf: 'Zahlungspflichtig bestellen · 30 €', items: [ { id: 'beginn', haken: true, - text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).', + text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).', }, ], }, - leistungsbeschreibung: { version: '2026-09-30', url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' }, + // Stand der Leistungsbeschreibung (public/vertragsunterlagen/funktionen-leistungsbeschreibung.html) = + leistungsbeschreibung: { + get version() { + return config.termine.avvVersion + }, + url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html', + }, avv: null, widerruf: null, returnHash: 'news', @@ -219,7 +230,7 @@ const features = { knopf: 'Beitrag schreiben', danach: 'Danach kannst Du Beiträge im Portal schreiben.', bereit: 'Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.', - preisHinweis: 'brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo', + preisHinweis: `einmalig, ${ustHinweis}; die Nutzung ist kostenlos, es läuft kein Abo`, anleitung: false, }, requiresWartungsAbo: false, diff --git a/test/featureMails.test.js b/test/featureMails.test.js index 5f194de..cb6a394 100644 --- a/test/featureMails.test.js +++ b/test/featureMails.test.js @@ -145,7 +145,7 @@ test('News-Mails bleiben wörtlich: K15 mit Rechnung', async () => { '', 'Funktion: News-Pinnwand – Einrichtung', 'Projekt: Bäckerei', - 'Einmalig bezahlt: 30,00 € (brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)', + 'Einmalig bezahlt: 30,00 € (einmalig, ohne Umsatzsteuer gemäß § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo)', 'Einrichtung: meist innerhalb von 1–2 Wochen', '', 'Wie geht es weiter?', @@ -180,7 +180,7 @@ test('News-Mails bleiben wörtlich: K15 ohne Rechnung', async () => { '', 'Funktion: News-Pinnwand – Einrichtung', 'Projekt: Café', - 'Einmalig bezahlt: 30,00 € (brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)', + 'Einmalig bezahlt: 30,00 € (einmalig, ohne Umsatzsteuer gemäß § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo)', 'Einrichtung: meist innerhalb von 1–2 Wochen', '', 'Wie geht es weiter?', diff --git a/test/featureRegistry.test.js b/test/featureRegistry.test.js index b820e0d..dca871f 100644 --- a/test/featureRegistry.test.js +++ b/test/featureRegistry.test.js @@ -15,10 +15,11 @@ const reg = await import('../server/services/featureRegistry.js') const sf = await import('../server/services/siteFeatures.js') const { createFeatureTicket } = await import('../server/services/ticketAdmin.js') -// Wortlaut-Pin des News-Satzes und der Fassung (der einzige Literal-Pin auf Texte, die Block F2 ändert) +// Wortlaut-Pin des News-Satzes und der Fassung (der einzige Literal-Pin auf Texte, die Block F2 ändert); die Fassung ist der +// Tag = config.termine.avvVersion, beim Anheben von ändert sich auch dieser Pin const NEWS_SATZ = - 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).' -const NEWS_FASSUNG = '2026-09-30' + 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).' +const NEWS_FASSUNG = '2026-10-05' const SITE_KEY = 'abcdefghijklmnop' diff --git a/test/funktionenRecht.test.js b/test/funktionenRecht.test.js new file mode 100644 index 0000000..44e2dbf --- /dev/null +++ b/test/funktionenRecht.test.js @@ -0,0 +1,228 @@ +import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Rechtstext-Korrekturen der News-Pinnwand (Task F2): Zitat § 356 Abs. 5 Nr. 2, Wertersatz-Satz, § 19 UStG statt "brutto". +// Die Wortlaute sind hier als Literale eingefroren (Wortlaut-Pin); die Fassung folgt config.termine.avvVersion. +const aw = await startAppwriteMock({ + schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents') }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, get, post } = await import('./support/testApp.js') +const { customerCookie, seedFixtures } = await import('./support/fixtures.js') +const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') +const sf = await import('../server/services/siteFeatures.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { mailSink } = await import('./support/mailSink.js') + +const app = await startTestApp({ '/api/funktionen': funktionenRoutes }) +const mails = mailSink() +setMailTransportForTests(mails) +after(() => { + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close(), st.close()]) +}) +seedFixtures(aw) +aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' }) +aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Café' }) +const cookie = customerCookie('c1') + +const root = (rel) => new URL(`../${rel}`, import.meta.url) +const lesen = (rel) => readFileSync(root(rel), 'utf8') + +const NEWS_SATZ = + 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).' +const ALTER_SATZ = + 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).' +const USTHINWEIS = 'ohne Umsatzsteuer gemäß § 19 UStG' + +test('news_zustimmung_wortlaut: Register = Alias = exakter Text mit Abs. 5 Nr. 2 und § 357a Abs. 2, Fassung = ', () => { + const news = config.features.news + assert.deepEqual(news.consent.items, [{ id: 'beginn', haken: true, text: NEWS_SATZ }]) + assert.equal(config.funktionen.consentText, NEWS_SATZ) + assert.equal(news.consent.items[0].text, config.funktionen.consentText) + assert.ok(NEWS_SATZ.includes('Abs. 5 Nr. 2') && NEWS_SATZ.includes('§ 357a Abs. 2')) + assert.ok(!NEWS_SATZ.includes('Abs. 4')) + assert.equal(news.consent.knopf, 'Zahlungspflichtig bestellen · 30 €') + // die Fassung folgt der Konstante (zur Laufzeit), auch die der Leistungsbeschreibung, deren Stand sich mit dem Text ändert + assert.equal(news.consent.version, config.termine.avvVersion) + assert.equal(news.leistungsbeschreibung.version, config.termine.avvVersion) + assert.ok(news.consent.version > '2026-10-02') + const alt = config.termine.avvVersion + config.termine.avvVersion = '2099-01-02' + try { + assert.equal(news.consent.version, '2099-01-02') + assert.equal(news.leistungsbeschreibung.version, '2099-01-02') + } finally { + config.termine.avvVersion = alt + } + assert.equal(news.leistungsbeschreibung.url, '/vertragsunterlagen/funktionen-leistungsbeschreibung.html') + assert.equal(news.mails.preisHinweis, `einmalig, ${USTHINWEIS}; die Nutzung ist kostenlos, es läuft kein Abo`) +}) + +test('waechter_zitat_und_preistext: kein altes Zitat, keine Mehrwertsteuer-Angabe, kein "brutto" in den Kundentexten', () => { + const dateien = [ + 'server/config.js', + 'public/vertragsunterlagen/funktionen-leistungsbeschreibung.html', + 'public/funktionen.js', + 'server/mail-templates/K15 Bestellung Funktion.html', + 'server/services/stripeBilling.js', + ] + for (const datei of dateien) { + const text = lesen(datei) + assert.doesNotMatch(text, /§ 356 Abs\. 4/, `${datei}: altes Zitat`) + assert.doesNotMatch(text, /inklusive (Mehr|Umsatz)steuer/i, `${datei}: inklusive Steuer`) + assert.doesNotMatch(text, /inklusive Mehrwertsteuer/i, `${datei}: inklusive Mehrwertsteuer`) + assert.doesNotMatch(text, /\bbrutto\b/i, `${datei}: brutto`) + } + const lb = lesen('public/vertragsunterlagen/funktionen-leistungsbeschreibung.html') + assert.equal(lb.split(USTHINWEIS).length - 1, 2, 'LB nennt den Hinweis in Kopf-Tabelle und Abschnitt 2') + assert.ok(lb.includes('30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), keine laufenden Kosten')) + assert.ok(lb.includes('Der Einbau kostet 30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG).')) + assert.ok(lb.includes('erlischt nach § 356 Abs. 5 Nr. 2 BGB')) + assert.ok(lb.includes('(§ 357a Abs. 2 BGB)')) + assert.ok(lb.includes(''), 'die Freigabe bleibt Sache des Inhabers') + // der Stand der LB ist der Tag der Fassung + const [j, m, t] = config.termine.avvVersion.split('-') + assert.ok(lb.includes(`

    Stand ${t}.${m}.${j}

    `), 'Stand der LB = config.termine.avvVersion') +}) + +test('bestellseite_hinweis: GET /api/funktionen liefert ustHinweis', async () => { + const body = await get('/api/funktionen', { cookie }) + assert.equal(body.ustHinweis, config.ustHinweis) + assert.equal(config.ustHinweis, USTHINWEIS) + assert.equal(body.consentText, NEWS_SATZ) + assert.equal(body.features.news.consent.items[0].text, NEWS_SATZ) + assert.equal(body.features.news.consent.version, config.termine.avvVersion) +}) + +// ---- Client: public/funktionen.js wirklich laden (absolute Importe auf die Dateien umleiten) und die Buchungsseite rendern +const dir = mkdtempSync(join(tmpdir(), 'fn-recht-')) +const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href +const clientQuelle = lesen('public/funktionen.js') + .replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`) + .replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`) + .replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.js')}'`) +const clientDatei = join(dir, 'funktionen.mjs') +writeFileSync(clientDatei, clientQuelle) +const { initNews } = await import(pathToFileURL(clientDatei).href) + +function fakeEl() { + const handlers = {} + return { innerHTML: '', textContent: '', disabled: false, checked: false, value: '', dataset: {}, classList: { add() {}, remove() {}, toggle() {} }, handlers, addEventListener(ev, fn) { handlers[ev] = fn }, querySelector: () => null, querySelectorAll: () => [] } +} +async function renderBuchung(antwort) { + const echtesFetch = globalThis.fetch + globalThis.window = { location: { hash: '#news', pathname: '/dashboard.html', search: '', href: '' }, history: { replaceState() {} } } + globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => antwort }) + const els = {} + const stage = { innerHTML: '', els, querySelector: (sel) => (els[sel] ||= fakeEl()), querySelectorAll: () => [] } + const rootEl = fakeEl() + rootEl.querySelector = (sel) => (sel === '#news-stage' ? stage : null) + try { + await initNews(rootEl, { projectId: 'p1' }) + return stage + } finally { + delete globalThis.window + globalThis.fetch = echtesFetch + } +} +const frei = { projectId: 'p1', projectName: 'Muster', features: { news: { state: 'off' } } } + +test('bestellseite_hinweis (Client): Preis mit Hinweis aus ustHinweis, per textContent; Rückfall = config.ustHinweis; kein "inklusive Mehrwertsteuer"', async () => { + const stage = await renderBuchung({ projects: [frei], consentText: NEWS_SATZ, ustHinweis: 'Testhinweis § 19' }) + assert.match(stage.innerHTML, /30 €<\/strong>/) + assert.doesNotMatch(stage.innerHTML, /Mehrwertsteuer|inklusive|brutto/i) + assert.doesNotMatch(stage.innerHTML, /Testhinweis/, 'der Hinweis steht nie im HTML-Text, nur als textContent') + assert.equal(stage.els['#news-ust-hinweis'].textContent, 'Testhinweis § 19') + assert.match(stage.innerHTML, /einmalig, <\/span>/) + + // ein Hinweis mit Markup bleibt Text + const bose = await renderBuchung({ projects: [frei], ustHinweis: '' }) + assert.doesNotMatch(bose.innerHTML, /') + + // fehlt der Hinweis in der Antwort, gilt der Rückfall im Client: derselbe Wortlaut wie config.ustHinweis + const rueck = await renderBuchung({ projects: [frei], consentText: NEWS_SATZ }) + assert.equal(rueck.els['#news-ust-hinweis'].textContent, config.ustHinweis) + for (const leer of ['', ' ', 5, null]) { + const r = await renderBuchung({ projects: [frei], ustHinweis: leer }) + assert.equal(r.els['#news-ust-hinweis'].textContent, config.ustHinweis, String(leer)) + } +}) + +// ---- K15 (Bestellbestätigung) der News +const paidEvent = (sessionId, projectId) => ({ + id: `evt_${sessionId}`, + object: 'event', + type: 'checkout.session.completed', + data: { object: { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'paid', metadata: { customerId: 'c1', projectId, featureKey: 'news' } } }, +}) + +test('k15_news: Preiszeile mit § 19 UStG statt "brutto"', async () => { + aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Bistro' }) + await sf.markOrdered({ customerId: 'c1', projectId: 'p3', key: 'news', sessionId: 'cs_k15', consent: {} }) + const vor = mails.sent.length + await sf.handleFeatureCheckoutEvent(paidEvent('cs_k15', 'p3')) + const k15 = mails.sent.slice(vor).filter((m) => m.subject.startsWith('Deine Bestellung')) + assert.equal(k15.length, 1) + assert.ok(k15[0].text.includes(`Einmalig bezahlt: 30,00 € (einmalig, ${USTHINWEIS}; die Nutzung ist kostenlos, es läuft kein Abo)`), k15[0].text) + assert.doesNotMatch(k15[0].text, /brutto/i) + assert.doesNotMatch(k15[0].html, /brutto/i) + assert.ok(k15[0].html.includes(USTHINWEIS)) +}) + +// ---- bestehende Bestellungen behalten Text und Fassung; neue speichern die Register-Werte +test('alte_bestellung_unveraendert: altes Dokument bleibt, neue Bestellung speichert Register-Text und Fassung', async () => { + aw.seed('portalFeatures', { + $id: 'f_p2_news', // feste ID je (Projekt, Funktion) + enabled: true, + unlockedByPurchase: true, + customerId: 'c1', + projectId: 'p2', + featureKey: 'news', + state: 'active', + purchaseStatus: 'paid', + consentText: ALTER_SATZ, + consentAt: '2026-09-30T10:00:00.000Z', + orderedAt: '2026-09-30T10:00:00.000Z', + }) + aw.seed('featureConsents', { + $id: 'fc_alt', + customerId: 'c1', + projectId: 'p2', + featureKey: 'news', + stripeSessionId: 'cs_alt', + acceptedAt: '2026-09-30T10:00:00.000Z', + fassungen: JSON.stringify({ zustimmung: '2026-09-30', leistungsbeschreibung: '2026-09-30' }), + texte: JSON.stringify([{ id: 'beginn', text: ALTER_SATZ, haken: true }]), + }) + const alt = await sf.getFeatureDoc('c1', 'p2', 'news') + assert.equal(alt.consentText, ALTER_SATZ) + const liste = await get('/api/funktionen', { cookie }) + assert.equal(liste.projects.find((p) => p.projectId === 'p2').features.news.state, 'active') + + const res = await post('/api/funktionen/news/checkout', { projectId: 'p1', consent: true }, { cookie }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const neu = await sf.getFeatureDoc('c1', 'p1', 'news') + assert.equal(neu.consentText, NEWS_SATZ) + const nachweise = [...aw.docs('featureConsents').values()].filter((d) => d.projectId === 'p1') + assert.equal(nachweise.length, 1) + assert.deepEqual(JSON.parse(nachweise[0].fassungen), { zustimmung: config.termine.avvVersion, leistungsbeschreibung: config.termine.avvVersion }) + const texte = JSON.parse(nachweise[0].texte) + assert.equal(texte[0].text, NEWS_SATZ) + + // das alte Dokument und sein Nachweis sind unverändert + assert.equal((await sf.getFeatureDoc('c1', 'p2', 'news')).consentText, ALTER_SATZ) + assert.deepEqual(JSON.parse(aw.docs('featureConsents').get('fc_alt').fassungen), { zustimmung: '2026-09-30', leistungsbeschreibung: '2026-09-30' }) + assert.equal(JSON.parse(aw.docs('featureConsents').get('fc_alt').texte)[0].text, ALTER_SATZ) +}) diff --git a/test/funktionenTiles.test.js b/test/funktionenTiles.test.js index b559f7e..bcdafa2 100644 --- a/test/funktionenTiles.test.js +++ b/test/funktionenTiles.test.js @@ -113,7 +113,9 @@ test('News-Buchungsseite: Preis, Knopf und Zustimmungstext aus features.news, se features: { news: register }, }) assert.match(stage.innerHTML, /31 €<\/strong>/) - assert.match(stage.innerHTML, /inklusive Mehrwertsteuer/, 'der Wortlaut bleibt bis Block F') + assert.doesNotMatch(stage.innerHTML, /Mehrwertsteuer/, 'der Preishinweis kommt aus ustHinweis (Block F)') + assert.match(stage.innerHTML, /einmalig, <\/span>/) + assert.equal(stage.els['#news-ust-hinweis'].textContent, 'ohne Umsatzsteuer gemäß § 19 UStG', 'Rückfall ohne ustHinweis in der Antwort') assert.match(stage.innerHTML, /id="news-order" disabled>Jetzt kaufen · 31 € { +test('News: Leistungsbeschreibung folgt der Fassung (Adresse unverändert, kein AVV, kein Getter am Eintrag)', () => { const n = featureConfig('news') - assert.deepEqual(n.leistungsbeschreibung, { version: '2026-09-30', url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' }) + assert.deepEqual(n.leistungsbeschreibung, { version: config.termine.avvVersion, url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' }) assert.equal(n.avv, null) assert.deepEqual(analyse.legalPaths(ALT), legalPaths('analyse', ALT)) assert.equal(Object.getOwnPropertyDescriptor(n, 'leistungsbeschreibung').get, undefined) diff --git a/test/legalTexts.test.js b/test/legalTexts.test.js index 9bba9f1..5de2c11 100644 --- a/test/legalTexts.test.js +++ b/test/legalTexts.test.js @@ -482,7 +482,8 @@ test('legal_datenschutz_news: Abschnitt 6a und Leistungsbeschreibung (Entwurf)', const text = strip(lb) assert.ok(text.includes('Auf Ihren Wunsch schalten wir die Pinnwand ab') && !text.includes('Endet die Zusammenarbeit mit uns, schalten wir')) assert.ok(text.includes('abgeschlossen') && !text.includes('dauert erfahrungsgemäß')) - for (const s of ['30,00 €', '§ 356 Abs. 4 BGB', '1–2 Wochen', 'keine laufenden Kosten']) assert.ok(text.includes(s), s) + for (const s of ['30,00 €', '§ 356 Abs. 5 Nr. 2 BGB', '§ 357a Abs. 2 BGB', '1–2 Wochen', 'keine laufenden Kosten']) assert.ok(text.includes(s), s) + assert.ok(!text.includes('§ 356 Abs. 4') && !/inklusive Umsatzsteuer/.test(text), 'altes Zitat und alter Preistext sind weg') const muster = read('docs/funktionen/website-datenschutz-absatz.md') // 14 Tage Protokolle nur, solange Abschnitt 3 der WEBklar-Erklaerung dieselbe Frist nennt