News: Zitat § 356 Abs. 5 Nr. 2, Wertersatz-Satz und § 19 UStG statt brutto

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 02:30:38 +00:00
1 parent b20d0464c4
commit 3073fffcf5
10 files changed
+277 -26

No files matched your search

+2 -2
View File
@@ -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?',
+4 -3
View File
@@ -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 <F> = config.termine.avvVersion, beim Anheben von <F> ä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'
+228
View File
@@ -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 = <F>', () => {
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 <F> (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('<td>30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), keine laufenden Kosten</td>'))
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('<!-- ENTWURF, Freigabe Nutzer ausstehend -->'), 'die Freigabe bleibt Sache des Inhabers')
// der Stand der LB ist der Tag der Fassung <F>
const [j, m, t] = config.termine.avvVersion.split('-')
assert.ok(lb.includes(`<p class="meta">Stand ${t}.${m}.${j}</p>`), '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, /<strong>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 id="news-ust-hinweis"><\/span>/)
// ein Hinweis mit Markup bleibt Text
const bose = await renderBuchung({ projects: [frei], ustHinweis: '<img src=x onerror=1>' })
assert.doesNotMatch(bose.innerHTML, /<img/)
assert.equal(bose.els['#news-ust-hinweis'].textContent, '<img src=x onerror=1>')
// 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)
})
+3 -1
View File
@@ -113,7 +113,9 @@ test('News-Buchungsseite: Preis, Knopf und Zustimmungstext aus features.news, se
features: { news: register },
})
assert.match(stage.innerHTML, /<strong>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 id="news-ust-hinweis"><\/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 €</)
assert.equal(stage.els['#news-consent-text'].textContent, 'Registertext der Zustimmung')
assert.equal(knopfAktiv, true)
+2 -2
View File
@@ -156,9 +156,9 @@ test('Register: leistungsbeschreibung und avv des Terminplaners folgen der Konst
assert.deepEqual(t.leistungsbeschreibung, merke)
})
test('News-Rechtsdokumente bleiben unberührt', () => {
test('News: Leistungsbeschreibung folgt der Fassung <F> (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)
+2 -1
View File
@@ -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