Terminplaner: Hinweistexte nach Recherche, Storno nur bei Preis

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 04:29:32 +00:00
1 parent 722c686690
commit 6ec8885bfd
11 files changed
+230 -21

No files matched your search

+14 -5
View File
@@ -179,6 +179,14 @@
} }
// @telefon-ende // @telefon-ende
// @storno-anfang (reine Funktion, test/termineEmbedStatic.test.js fuehrt sie einzeln aus)
// Gelten die Stornobedingungen des Betriebs fuer diese Leistung? Nur bei einer Leistung mit Preis (l.preis ist der Preistext des Servers,
// bei kostenlosen Leistungen ''), wie der Server den Haken nur dort verlangt: ohne Preis gibt es weder die Box noch den Haken noch die Pruefung.
function stornoGilt (storno, l) {
return !!storno && !!l && typeof l.preis === 'string' && l.preis !== ''
}
// @storno-ende
function apiBasis (root) { function apiBasis (root) {
var a = String(root.getAttribute('data-api') || '').replace(/\/+$/, '') var a = String(root.getAttribute('data-api') || '').replace(/\/+$/, '')
if (a) return a if (a) return a
@@ -1212,9 +1220,10 @@
// Stornoregel im Klartext, daneben der Link auf die AGB des Betriebs (nur https, wenn hinterlegt) // Stornoregel im Klartext, daneben der Link auf die AGB des Betriebs (nur https, wenn hinterlegt)
var agbUrl = httpsUrl(c.betrieb.agbUrl) var agbUrl = httpsUrl(c.betrieb.agbUrl)
if ((c.storno && istText(c.storno.text)) || agbUrl) { var stornoAn = stornoGilt(c.storno, l)
if ((stornoAn && istText(c.storno.text)) || agbUrl) {
var sb = el('div', 'wkt-box') var sb = el('div', 'wkt-box')
if (c.storno && istText(c.storno.text)) { if (stornoAn && istText(c.storno.text)) {
sb.appendChild(el('h3', '', 'Stornobedingungen')) sb.appendChild(el('h3', '', 'Stornobedingungen'))
sb.appendChild(el('p', '', c.storno.text)) sb.appendChild(el('p', '', c.storno.text))
} }
@@ -1256,7 +1265,7 @@
var form = el('form', 'wkt-form') var form = el('form', 'wkt-form')
form.noValidate = true form.noValidate = true
var refs = {} var refs = {}
if (c.storno) form.appendChild(haken(refs, 'storno', c.texte.storno, dd.storno, true, function (v) { dd.storno = v })) if (stornoAn) form.appendChild(haken(refs, 'storno', c.texte.storno, dd.storno, true, function (v) { dd.storno = v }))
if (S.vorzeitig) form.appendChild(haken(refs, 'vorzeitig', c.texte.vorzeitig, dd.vorzeitig, true, function (v) { dd.vorzeitig = v })) if (S.vorzeitig) form.appendChild(haken(refs, 'vorzeitig', c.texte.vorzeitig, dd.vorzeitig, true, function (v) { dd.vorzeitig = v }))
if (c.erinnerung && c.erinnerung.anbieten === true) form.appendChild(haken(refs, 'erinnerung', c.texte.erinnerung, dd.erinnerung, false, function (v) { dd.erinnerung = v })) if (c.erinnerung && c.erinnerung.anbieten === true) form.appendChild(haken(refs, 'erinnerung', c.texte.erinnerung, dd.erinnerung, false, function (v) { dd.erinnerung = v }))
form.appendChild(honigfeld()) form.appendChild(honigfeld())
@@ -1276,7 +1285,7 @@
e.preventDefault() e.preventDefault()
if (S.sendet) return if (S.sendet) return
var f = {} var f = {}
if (c.storno && !dd.storno) f.storno = T('Bitte bestätigen Sie, dass Sie die Stornobedingungen gelesen haben.', 'Bitte bestätige, dass du die Stornobedingungen gelesen hast.') if (stornoAn && !dd.storno) f.storno = T('Bitte bestätigen Sie, dass Sie die Stornobedingungen gelesen haben.', 'Bitte bestätige, dass du die Stornobedingungen gelesen hast.')
if (S.vorzeitig && !dd.vorzeitig) f.vorzeitig = T('Bitte stimmen Sie der Leistung vor Ablauf der Widerrufsfrist ausdrücklich zu, sonst ist die Buchung nicht möglich.', 'Bitte stimme der Leistung vor Ablauf der Widerrufsfrist ausdrücklich zu, sonst ist die Buchung nicht möglich.') if (S.vorzeitig && !dd.vorzeitig) f.vorzeitig = T('Bitte stimmen Sie der Leistung vor Ablauf der Widerrufsfrist ausdrücklich zu, sonst ist die Buchung nicht möglich.', 'Bitte stimme der Leistung vor Ablauf der Widerrufsfrist ausdrücklich zu, sonst ist die Buchung nicht möglich.')
fehlerBox.textContent = '' fehlerBox.textContent = ''
if (Object.keys(f).length) { if (Object.keys(f).length) {
@@ -1310,7 +1319,7 @@
nachricht: dd.nachricht.trim(), nachricht: dd.nachricht.trim(),
zusatz: zusatz, zusatz: zusatz,
erinnerung: !!dd.erinnerung, erinnerung: !!dd.erinnerung,
zustimmungen: { storno: !!dd.storno, vorzeitig: !!dd.vorzeitig }, zustimmungen: { storno: stornoGilt(S.cfg.storno, aktuelleLeistung()) && !!dd.storno, vorzeitig: !!dd.vorzeitig },
kontakt_url: S.honig, kontakt_url: S.honig,
formToken: S.cfg.formToken formToken: S.cfg.formToken
}).then(function (d) { }).then(function (d) {
+13 -6
View File
@@ -13,14 +13,17 @@ import { abschnitt, allgemeinerFehler, exportLink, feld, gesperrt, h, hakenFeld,
import { nachSpeichern, neuerSpeicher } from './termine-speichern.js' import { nachSpeichern, neuerSpeicher } from './termine-speichern.js'
// Rechtliche Hinweise (Ruling R52): Wortlaut nicht aendern ohne Ruling // Rechtliche Hinweise (Ruling R52): Wortlaut nicht aendern ohne Ruling
const WIDERRUF_HINWEIS = 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). ' export const WIDERRUF_HINWEIS = 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). '
+ 'Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. ' + 'Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. '
+ 'Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. ' + 'Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. '
+ 'Hat ein Gast ausdrücklich verlangt, dass du vor Ablauf der Frist beginnst, und widerruft er danach, kann er für die bis dahin erbrachte Leistung zu einem angemessenen Betrag verpflichtet sein (Wertersatz, § 357a Abs. 2 BGB). '
+ 'Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.' + 'Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.'
const FREIZEIT_HINWEIS = 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend oder ein Escape Room. ' export const FREIZEIT_HINWEIS = 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend, ein Kurs oder ein Escape Room. '
+ 'Friseur, Kosmetik, Massage, Tattoo, Beratung, Fotoshootings und Weiterbildungen sind in der Regel keine Freizeitleistung. ' + 'Nach unserer Einschätzung gilt bei Friseur, Kosmetik, Nagel- und Fußpflege, Tattoo, Fotoshootings, Coaching und Beratung in der Regel ein Widerrufsrecht; bei Massage und Wellness ist die Einordnung unklar, im Zweifel gilt ein Widerrufsrecht. '
+ 'Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. ' + 'Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. '
+ 'Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.' + 'Das ist keine Rechtsberatung: Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.'
// Hinweis neben dem Stornotext (nur im Portal, nicht im Gästetext): der Vorschlag der Vorlage und das Muster sind kein fertiger Rechtstext
export const STORNO_MUSTER_HINWEIS = 'Das ist ein Muster, kein Rechtstext. Bitte prüfe es und passe es an deine Bedingungen an.'
const wahl = (liste, aktuell) => liste.map(([wert, text]) => h('option', { value: wert, selected: String(aktuell) === String(wert) }, text)) const wahl = (liste, aktuell) => liste.map(([wert, text]) => h('option', { value: wert, selected: String(aktuell) === String(wert) }, text))
const zahlenFeld = (name, label, wert, { min, max, schritt = 1, hinweis }) => { const zahlenFeld = (name, label, wert, { min, max, schritt = 1, hinweis }) => {
@@ -262,9 +265,13 @@ export function storno(ctx, e, fristFeld) {
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'muster-uebernehmen', type: 'button', class: 'kb-btn-outline', 'data-aktion': 'muster-uebernehmen',
onclick: () => { textfeld.value = muster.textContent; textfeld.focus() }, onclick: () => { textfeld.value = muster.textContent; textfeld.focus() },
}, 'Muster in das Textfeld übernehmen') }, 'Muster in das Textfeld übernehmen')
const textBlock = feld('storno.text', 'Dein Text', textfeld, s.version ? `Aktuelle Fassung: ${s.version}. Ändert sich der Text, bekommt er beim Speichern eine neue Fassung, und Gäste stimmen immer der Fassung zu, die sie gesehen haben.` : 'Beim ersten Speichern bekommt der Text eine Fassungsnummer.')
// der Hinweis „Muster“ steht unmittelbar unter dem Textfeld, vor dem Hinweis zur Fassung (Spec 7.3): der Text kann der Vorschlag der Vorlage sein
textBlock.insertBefore(h('p', { class: 'tm-hinweis tm-muster-hinweis', role: 'note', 'data-hinweis': 'storno-muster' }, STORNO_MUSTER_HINWEIS), textBlock.querySelector('.tm-hinweis'))
const el = abschnitt('Stornobedingungen', const el = abschnitt('Stornobedingungen',
'Gäste müssen diesen Text vor dem Buchen bestätigen. Lässt du das Feld leer, gibt es keine Stornobedingungen und keinen Haken dazu.', 'Gäste müssen diesen Text vor dem Buchen bestätigen. Lässt du das Feld leer, gibt es keine Stornobedingungen und keinen Haken dazu. '
feld('storno.text', 'Dein Text', textfeld, s.version ? `Aktuelle Fassung: ${s.version}. Ändert sich der Text, bekommt er beim Speichern eine neue Fassung, und Gäste stimmen immer der Fassung zu, die sie gesehen haben.` : 'Beim ersten Speichern bekommt der Text eine Fassungsnummer.'), + 'Die Stornobedingungen gelten nur für Leistungen mit Preis; bei kostenlosen Leistungen fragt der Terminplaner Gäste nicht danach.',
textBlock,
h('div', { class: 'tm-muster', role: 'group', 'aria-label': 'Mustertext' }, h('div', { class: 'tm-muster', role: 'group', 'aria-label': 'Mustertext' },
h('p', null, h('span', { class: 'tm-pille tm-pille-wait' }, 'Muster'), ' Ein Vorschlag, kein fertiger Rechtstext. Prüfe ihn und passe Frist, Prozentsatz und Anrede an deinen Betrieb an. Er ist in der förmlichen Anrede geschrieben. Das gesetzliche Widerrufsrecht deiner privaten Gäste bleibt unberührt.'), h('p', null, h('span', { class: 'tm-pille tm-pille-wait' }, 'Muster'), ' Ein Vorschlag, kein fertiger Rechtstext. Prüfe ihn und passe Frist, Prozentsatz und Anrede an deinen Betrieb an. Er ist in der förmlichen Anrede geschrieben. Das gesetzliche Widerrufsrecht deiner privaten Gäste bleibt unberührt.'),
muster, muster,
+1
View File
@@ -114,6 +114,7 @@
.tm-feld [aria-invalid='true'] { border-color: #f87171; } .tm-feld [aria-invalid='true'] { border-color: #f87171; }
.tm-feld input:focus, .tm-feld select:focus, .tm-feld textarea:focus { border-color: var(--kb-purple-line); box-shadow: 0 0 0 3px var(--kb-purple-soft); } .tm-feld input:focus, .tm-feld select:focus, .tm-feld textarea:focus { border-color: var(--kb-purple-line); box-shadow: 0 0 0 3px var(--kb-purple-soft); }
.tm-hinweis { margin: 0; font-size: 12.5px; color: var(--kb-muted); } .tm-hinweis { margin: 0; font-size: 12.5px; color: var(--kb-muted); }
.tm-feld > .tm-muster-hinweis { margin-top: 6px; font-weight: 600; }
.tm-fehler { margin: 0; font-size: 13px; color: #fca5a5; } .tm-fehler { margin: 0; font-size: 13px; color: #fca5a5; }
.tm-fehler:empty { display: none; } .tm-fehler:empty { display: none; }
.tm-zeiten { display: flex; flex-wrap: wrap; gap: 8px; } .tm-zeiten { display: flex; flex-wrap: wrap; gap: 8px; }
+2 -1
View File
@@ -228,7 +228,8 @@ export function pruefeEingabe(e, roh, now, { manuell = false, bestaetigung = fal
// Zustimmungen // Zustimmungen
const zustimmungen = istObjekt(roh.zustimmungen) ? roh.zustimmungen : {} const zustimmungen = istObjekt(roh.zustimmungen) ? roh.zustimmungen : {}
const stornoNoetig = !manuell && typeof e.storno?.text === 'string' && e.storno.text !== '' // Stornobedingungen gelten nur fuer Leistungen mit Preis: bei kostenlosen Leistungen kein Pflichthaken und kein Nachweis
const stornoNoetig = !manuell && hatPreis(leistung) && typeof e.storno?.text === 'string' && e.storno.text !== ''
if (stornoNoetig && zustimmungen.storno !== true) fehl('storno', MELDUNG.storno) if (stornoNoetig && zustimmungen.storno !== true) fehl('storno', MELDUNG.storno)
const vorzeitigNoetig = !manuell && leistung && start ? brauchtZustimmungVorzeitig(e, leistung, start, now) : false const vorzeitigNoetig = !manuell && leistung && start ? brauchtZustimmungVorzeitig(e, leistung, start, now) : false
if (vorzeitigNoetig && zustimmungen.vorzeitig !== true) fehl('vorzeitig', MELDUNG.vorzeitig) if (vorzeitigNoetig && zustimmungen.vorzeitig !== true) fehl('vorzeitig', MELDUNG.vorzeitig)
+3 -2
View File
@@ -1,4 +1,4 @@
import { neueId, normalisiereEinstellung } from './einstellungen.js' import { hatPreis, neueId, normalisiereEinstellung } from './einstellungen.js'
/** /**
* Branchen-Vorlagen des Terminplaners (Spezifikation Abschnitt 9, Erstes Oeffnen): Daten, kein Code je Nische. * Branchen-Vorlagen des Terminplaners (Spezifikation Abschnitt 9, Erstes Oeffnen): Daten, kein Code je Nische.
@@ -206,7 +206,8 @@ export function ausVorlage(key, betrieb, now) {
regeln: { ...v.regeln, maxOffeneJeEmail: 3, personWaehlbar: true }, regeln: { ...v.regeln, maxOffeneJeEmail: 3, personWaehlbar: true },
gastFelder: structuredClone(v.gastFelder), gastFelder: structuredClone(v.gastFelder),
erinnerung: { anbieten: true, stundenVorher: 24 }, erinnerung: { anbieten: true, stundenVorher: 24 },
storno: { text: v.storno }, // Stornobedingungen gelten nur fuer Leistungen mit Preis: hat keine Leistung der Vorlage einen, gibt es keinen Vorschlag
storno: { text: v.leistungen.some(hatPreis) ? v.storno : '' },
recht: { ...v.recht }, recht: { ...v.recht },
zahlung: { modus: 'vorort', vorOrtArten: [...ZAHLARTEN] }, zahlung: { modus: 'vorort', vorOrtArten: [...ZAHLARTEN] },
} }
+45 -1
View File
@@ -34,7 +34,7 @@ const { zellenFuer, zellenId } = await import('../server/services/termine/zellen
const { RECHTSTEXTE_VERSION, ZUSTIMMUNG_STORNO, ZUSTIMMUNG_VORZEITIG, ZUSTIMMUNG_ERINNERUNG, formularHinweis } = await import( const { RECHTSTEXTE_VERSION, ZUSTIMMUNG_STORNO, ZUSTIMMUNG_VORZEITIG, ZUSTIMMUNG_ERINNERUNG, formularHinweis } = await import(
'../server/services/termine/rechtstexte.js' '../server/services/termine/rechtstexte.js'
) )
const { halte, vorschau, bestaetige } = await import('../server/services/termine/ablauf.js') const { halte, vorschau, bestaetige, pruefeEingabe } = await import('../server/services/termine/ablauf.js')
after(() => setMailTransportForTests(null)) after(() => setMailTransportForTests(null))
const MIN = 60000 const MIN = 60000
@@ -280,6 +280,50 @@ test('halte: Anfrage und Leistung ohne Preis bekommen ihre Knoepfe und brauchen
assert.match(JSON.parse(anfrage.nachweis).widerruf.hash, /^[0-9a-f]{64}$/) assert.match(JSON.parse(anfrage.nachweis).widerruf.hash, /^[0-9a-f]{64}$/)
}) })
test('storno_haken_nur_bei_preis: der Storno-Haken ist nur bei einer Leistung mit Preis Pflicht und steht nur dort im Nachweis', async () => {
const ctx = await speichere()
// mit Preis: ohne Haken wie bisher 400 (Feld storno), nichts angelegt
const err = await fehlerVon(() => halte(ctx, eingabe({ zustimmungen: { storno: false, vorzeitig: false } }), JETZT))
assert.equal(err.status, 400)
assert.deepEqual(Object.keys(err.fehler), ['storno'])
assert.equal(buchungen().length, 0)
// ohne Preis (sofort): ohne Haken gehalten, kein Eintrag storno im Nachweis
await halte(ctx, eingabe({ leistung: S_GRATIS, zeit: '10:00', email: 'a@example.com', zustimmungen: { storno: false, vorzeitig: false } }), JETZT)
// ohne Preis, auch ganz ohne Zustimmungen
await halte(ctx, eingabe({ leistung: S_GRATIS, zeit: '12:00', email: 'b@example.com', zustimmungen: undefined }), JETZT)
// ohne Preis, Anfrage
await halte(ctx, eingabe({ leistung: S_ANFRAGE, zeit: '14:00', email: 'c@example.com', zustimmungen: {} }), JETZT)
// ohne Preis mit storno: true (altes Widget, anderer Client): ebenfalls kein Nachweis des Hakens
await halte(ctx, eingabe({ leistung: S_GRATIS, zeit: '16:00', email: 'd@example.com', zustimmungen: { storno: true, vorzeitig: false } }), JETZT)
assert.equal(buchungen().length, 4)
for (const b of buchungen()) {
assert.equal(b.status, 'gehalten')
const nachweis = JSON.parse(b.nachweis)
assert.equal(nachweis.storno, undefined, `${b.leistungId}: kein Storno-Nachweis ohne Preis`)
assert.equal(Object.hasOwn(nachweis, 'storno'), false)
assert.ok(!b.nachweis.includes(sha256(ZUSTIMMUNG_STORNO)), 'auch der Hash des Haken-Textes fehlt')
}
// mit Preis und Haken: Nachweis wie bisher
await halte(ctx, eingabe({ zeit: '09:00', email: 'e@example.com' }), JETZT)
const mitPreis = buchungen().find((b) => b.leistungId === S_SOFORT)
assert.deepEqual(JSON.parse(mitPreis.nachweis).storno, { version: ctx.einstellung.storno.version, hash: sha256(ctx.einstellung.storno.text), haken: sha256(ZUSTIMMUNG_STORNO), am: iso(JETZT) })
// Anfrage mit Preis: Haken bleibt Pflicht
const anfrageMitPreis = { id: 's_bbbbbbb4', name: 'Beratung', dauer: 30, preisArt: 'fest', preisCent: 3000, buchungsart: 'anfrage', ressourcen: [R1, R2] }
const mitAnfrage = await speichere(einstellung({ leistungen: [anfrageMitPreis] }))
const ohneHaken = await fehlerVon(() => halte(mitAnfrage, eingabe({ leistung: 's_bbbbbbb4', zeit: '11:00', email: 'f@example.com', zustimmungen: { storno: false, vorzeitig: true } }), JETZT))
assert.deepEqual(Object.keys(ohneHaken.fehler), ['storno'])
// Handeintrag (manuell): nie Zustimmungen, mit und ohne Preis
const e = ctx.einstellung
for (const leistung of [S_SOFORT, S_GRATIS]) {
const geprueft = pruefeEingabe(e, eingabe({ leistung, zustimmungen: undefined }), JETZT, { manuell: true })
assert.equal(geprueft.stornoNoetig, false, `Handeintrag ${leistung}`)
}
assert.equal(pruefeEingabe(e, eingabe({ leistung: S_SOFORT }), JETZT).stornoNoetig, true)
assert.equal(pruefeEingabe(e, eingabe({ leistung: S_GRATIS }), JETZT).stornoNoetig, false)
})
test('halte: egal waehlt die Ressource mit den wenigsten Buchungen des Tages, eine feste Ressource bleibt sie', async () => { test('halte: egal waehlt die Ressource mit den wenigsten Buchungen des Tages, eine feste Ressource bleibt sie', async () => {
const ctx = await speichere() const ctx = await speichere()
await halte(ctx, eingabe({ ressource: R1, zeit: '09:00' }), JETZT) await halte(ctx, eingabe({ ressource: R1, zeit: '09:00' }), JETZT)
+27 -2
View File
@@ -12,6 +12,7 @@ import {
zahlungText, zahlungText,
fehlendePflichtangaben, fehlendePflichtangaben,
zeigtWiderruf, zeigtWiderruf,
hatPreis,
} from '../server/services/termine/einstellungen.js' } from '../server/services/termine/einstellungen.js'
import { VORLAGEN, vorlagenListe, ausVorlage } from '../server/services/termine/vorlagen.js' import { VORLAGEN, vorlagenListe, ausVorlage } from '../server/services/termine/vorlagen.js'
import { BUNDESLAENDER } from '../server/services/termine/feiertage.js' import { BUNDESLAENDER } from '../server/services/termine/feiertage.js'
@@ -116,8 +117,12 @@ test('Vorlagen: Anfrage bei tattoo/handwerk/fotograf, sonst sofort; Werkstatt mi
assert.ok(werkstatt.ressourcen.every((r) => r.typ === 'platz')) assert.ok(werkstatt.ressourcen.every((r) => r.typ === 'platz'))
}) })
test('Vorlagen: Stornotext Variante 1 mit Frist aus den Regeln und 50 Prozent, Widerrufssatz bleibt', () => { const MIT_PREIS = (key) => VORLAGEN[key].leistungen.some((l) => l.preisArt !== 'keiner' && l.preisCent > 0)
for (const key of Object.keys(VORLAGEN)) {
test('Vorlagen: Stornotext Variante 1 mit Frist aus den Regeln und 50 Prozent, Widerrufssatz bleibt (Vorlagen mit Preis)', () => {
const mitPreis = Object.keys(VORLAGEN).filter(MIT_PREIS)
assert.ok(mitPreis.length >= 7, 'die meisten Vorlagen haben Preise')
for (const key of mitPreis) {
const e = ausVorlage(key, BETRIEB, NOW) const e = ausVorlage(key, BETRIEB, NOW)
const text = e.storno.text const text = e.storno.text
assert.ok(text.startsWith(`Sie können Ihren Termin bis ${e.regeln.absageBisStd} Stunden vorher kostenlos absagen oder verschieben (Link in der Bestätigungsmail).`), key) assert.ok(text.startsWith(`Sie können Ihren Termin bis ${e.regeln.absageBisStd} Stunden vorher kostenlos absagen oder verschieben (Link in der Bestätigungsmail).`), key)
@@ -129,6 +134,26 @@ test('Vorlagen: Stornotext Variante 1 mit Frist aus den Regeln und 50 Prozent, W
} }
}) })
test('vorlage_ohne_preis_ohne_storno: eine Vorlage, deren Leistungen alle kostenlos sind, bringt keinen Stornotext mit; Vorlagen mit Preisen bleiben unverändert', () => {
const ohnePreis = Object.keys(VORLAGEN).filter((key) => !MIT_PREIS(key))
assert.deepEqual(ohnePreis.sort(), ['handwerk', 'sonstiges'], 'diese beiden Vorlagen haben nur kostenlose Leistungen')
for (const key of ohnePreis) {
const e = ausVorlage(key, BETRIEB, NOW)
assert.ok(e.leistungen.every((l) => !hatPreis(l)), key)
assert.equal(e.storno.text, '', `${key}: kein Stornotext`)
assert.equal(e.storno.version, '', `${key}: und keine Fassungsnummer`)
assert.deepEqual(gueltig(e, { bisher: e }), e, `${key}: stabil`)
}
// mit Preisen: der Vorschlag steht wie bisher im Entwurf (50 % / 24 h für Salon)
for (const key of Object.keys(VORLAGEN).filter(MIT_PREIS)) {
const e = ausVorlage(key, BETRIEB, NOW)
assert.equal(e.storno.text, VORLAGEN[key].storno, key)
assert.equal(e.storno.version, '2026-10-02-1', key)
}
// die Vorlagendaten selbst bleiben unverändert (der Text steht dort weiter, ausVorlage entscheidet)
assert.ok(VORLAGEN.handwerk.storno.includes('50 %'))
})
test('Vorlagen: zwei Aufrufe ergeben verschiedene IDs und Feed-Token; Kleinunternehmer wird uebernommen', () => { test('Vorlagen: zwei Aufrufe ergeben verschiedene IDs und Feed-Token; Kleinunternehmer wird uebernommen', () => {
const a = ausVorlage('salon', BETRIEB, NOW) const a = ausVorlage('salon', BETRIEB, NOW)
const b = ausVorlage('salon', { ...BETRIEB, kleinunternehmer: true }, NOW) const b = ausVorlage('salon', { ...BETRIEB, kleinunternehmer: true }, NOW)
+33
View File
@@ -215,6 +215,39 @@ test('Farbparitaet: Widget und Farbvorschlag des Servers (einbauanleitung.js) wa
assert.equal(anzahl, 4096) assert.equal(anzahl, 4096)
}) })
// ---------------------------------------------------------------- Stornobedingungen nur bei Leistungen mit Preis (TP2 F11)
/** Die reine Funktion stornoGilt des Skripts (zwischen den Marken @storno-anfang und @storno-ende) einzeln ausfuehren. */
function stornoFunktion() {
const m = /\/\/ @storno-anfang[^\n]*\n([\s\S]*?)\/\/ @storno-ende/.exec(QUELLE)
assert.ok(m, 'Marken @storno-anfang/@storno-ende fehlen')
return vm.runInNewContext(`${m[1]}; stornoGilt`, {})
}
test('widget_storno_je_leistung: Box, Haken und Pruefung der Stornobedingungen haengen am Preis der gewaehlten Leistung', () => {
const stornoGilt = stornoFunktion()
const storno = { text: 'Absage bis 24 Stunden vorher.', version: '2026-10-02-1' }
// nur mit Stornotext des Betriebs UND einer Leistung mit Preis (l.preis ist der Preistext des Servers, '' ohne Preis)
assert.equal(stornoGilt(storno, { id: 's1', preis: '45,00 €' }), true)
assert.equal(stornoGilt(storno, { id: 's1', preis: 'ab 45,00 €' }), true)
assert.equal(stornoGilt(storno, { id: 's2', preis: '' }), false, 'kostenlos: weder Box noch Haken noch Pruefung')
assert.equal(stornoGilt(storno, { id: 's2' }), false)
assert.equal(stornoGilt(storno, { id: 's2', preis: null }), false)
assert.equal(stornoGilt(storno, null), false)
assert.equal(stornoGilt(storno, undefined), false)
assert.equal(stornoGilt(null, { id: 's1', preis: '45,00 €' }), false, 'kein Stornotext beim Betrieb')
assert.equal(stornoGilt(undefined, { id: 's1', preis: '45,00 €' }), false)
// im Zusammenfassungs-Schritt: Box, Haken und Pruefung fragen alle stornoGilt(…, l) – nie mehr nur c.storno
assert.match(OHNE_KOMMENTARE, /var stornoAn = stornoGilt\(c\.storno, l\)/)
assert.match(OHNE_KOMMENTARE, /if \(stornoAn && istText\(c\.storno\.text\)\) \{\s*sb\.appendChild\(el\('h3', '', 'Stornobedingungen'\)\)/, 'Box nur mit Stornotext und Preis')
assert.match(OHNE_KOMMENTARE, /if \(\(stornoAn && istText\(c\.storno\.text\)\) \|\| agbUrl\) \{/, 'der AGB-Link bleibt unabhaengig vom Preis')
assert.match(OHNE_KOMMENTARE, /if \(stornoAn\) form\.appendChild\(haken\(refs, 'storno', c\.texte\.storno, dd\.storno, true,/)
assert.match(OHNE_KOMMENTARE, /if \(stornoAn && !dd\.storno\) f\.storno = /)
assert.doesNotMatch(OHNE_KOMMENTARE, /if \(c\.storno\)|if \(c\.storno &&|c\.storno && !dd\.storno/, 'kein Haken und keine Pruefung allein nach c.storno')
// gesendet wird der Haken nur, wenn er gezeigt wurde
assert.match(OHNE_KOMMENTARE, /zustimmungen: \{ storno: stornoGilt\(S\.cfg\.storno, aktuelleLeistung\(\)\) && !!dd\.storno, vorzeitig: !!dd\.vorzeitig \}/)
})
// ---------------------------------------------------------------- Demo und Einbauanleitung // ---------------------------------------------------------------- Demo und Einbauanleitung
test('Demo-Seite: noindex, Ausweichtext, Skript per defer, beide Modi', () => { test('Demo-Seite: noindex, Ausweichtext, Skript per defer, beide Modi', () => {
+85 -1
View File
@@ -10,7 +10,10 @@ import {
aktivSeitText, centZuEuro, eindeutigeId, euroZuCent, ganzzahl, neueId, optionenAusText, optionenText, ordneNachIds, planAusTexten, planTexte, preisAnzeige, verschiebeEintrag, zusatzSchluessel, aktivSeitText, centZuEuro, eindeutigeId, euroZuCent, ganzzahl, neueId, optionenAusText, optionenText, ordneNachIds, planAusTexten, planTexte, preisAnzeige, verschiebeEintrag, zusatzSchluessel,
} from '../public/termine-view.js' } from '../public/termine-view.js'
import { ANREDE_WAHL, stornoMuster } from '../public/termine-gaestetexte.js' import { ANREDE_WAHL, stornoMuster } from '../public/termine-gaestetexte.js'
import { exportUrl, request } from '../public/termine-dom.js' import { exportUrl, h, request } from '../public/termine-dom.js'
import { installFakeDom } from './support/fakeDom.js'
import * as einstellungenTab from '../public/termine-tab-einstellungen.js'
import { baueArchiv } from '../scripts/termine-texte-archiv.mjs'
import { beschrifteFehler } from '../public/termine-speichern.js' import { beschrifteFehler } from '../public/termine-speichern.js'
import { BUNDESLAENDER as SERVER_BUNDESLAENDER } from '../server/services/termine/feiertage.js' import { BUNDESLAENDER as SERVER_BUNDESLAENDER } from '../server/services/termine/feiertage.js'
import { normalisiereEinstellung, preisText, zahlungText } from '../server/services/termine/einstellungen.js' import { normalisiereEinstellung, preisText, zahlungText } from '../server/services/termine/einstellungen.js'
@@ -732,3 +735,84 @@ test('Mustertext Storno (Gästetext): wie die Vorlagen des Servers, Frist einset
assert.equal(normalisiereEinstellung(e, { now: JETZT_SERVER }).einstellung.storno.text, stornoMuster(36), 'der Server ändert den Text nicht') assert.equal(normalisiereEinstellung(e, { now: JETZT_SERVER }).einstellung.storno.text, stornoMuster(36), 'der Server ändert den Text nicht')
assert.deepEqual(ANREDE_WAHL.map(([, name]) => name), ['Sie (förmlich)', 'Du (persönlich)']) assert.deepEqual(ANREDE_WAHL.map(([, name]) => name), ['Sie (förmlich)', 'Du (persönlich)'])
}) })
// ---------------------------------------------------------------- Hinweistexte nach Recherche (TP2 F11)
const mitFakeDom = (fn) => async () => {
const fx = installFakeDom()
try {
await fn(fx)
} finally {
fx.restore()
}
}
const SIE_WOERTER = /\bSie\b|\bIhr(e[nmrs]?)?\b/
test('freizeit_hinweis_text: Hinweis zur Freizeitleistung nach der Recherche (Du-Form, keine Rechtsberatung, kein „in der Regel keine Freizeitleistung“)', mitFakeDom((fx) => {
const text = einstellungenTab.FREIZEIT_HINWEIS
assert.equal(text, 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend, ein Kurs oder ein Escape Room. '
+ 'Nach unserer Einschätzung gilt bei Friseur, Kosmetik, Nagel- und Fußpflege, Tattoo, Fotoshootings, Coaching und Beratung in der Regel ein Widerrufsrecht; bei Massage und Wellness ist die Einordnung unklar, im Zweifel gilt ein Widerrufsrecht. '
+ 'Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. '
+ 'Das ist keine Rechtsberatung: Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.')
assert.ok(!text.includes('Weiterbildung'), 'Weiterbildung trägt die Recherche nicht')
assert.ok(!/keine Freizeitleistung/.test(text), 'keine Liste „keine Freizeitleistung“ mehr')
assert.ok(!/Massage[^.;]*keine Freizeit|Freizeit[^.;]*Massage/.test(text), 'Massage steht nicht als Beispiel für „keine Freizeitleistung“')
assert.ok(text.includes('keine Rechtsberatung'))
assert.ok(text.includes('bei Massage und Wellness ist die Einordnung unklar, im Zweifel gilt ein Widerrufsrecht'))
assert.doesNotMatch(text, SIE_WOERTER, 'Portaltexte in der Du-Form')
assert.match(text, /\bdeine\b/)
// der Reiter zeigt genau diesen Text am Haken „Freizeitangebote“
const wurzel = einstellungenTab.rechtliches({ recht: { gaesteSind: 'verbraucher', freizeit: false } }).el
const klein = fx.finde(wurzel, 'label.tm-haken small')
assert.equal(fx.text(klein), text)
}))
test('widerruf_hinweis_wertersatz: nach dem dritten Satz steht der Satz zum Wertersatz (§ 357a Abs. 2 BGB), Rest unverändert', mitFakeDom((fx) => {
const text = einstellungenTab.WIDERRUF_HINWEIS
assert.equal(text, 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). '
+ 'Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. '
+ 'Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. '
+ 'Hat ein Gast ausdrücklich verlangt, dass du vor Ablauf der Frist beginnst, und widerruft er danach, kann er für die bis dahin erbrachte Leistung zu einem angemessenen Betrag verpflichtet sein (Wertersatz, § 357a Abs. 2 BGB). '
+ 'Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.')
assert.doesNotMatch(text, SIE_WOERTER)
const wurzel = einstellungenTab.rechtliches({ recht: { gaesteSind: 'verbraucher', freizeit: false } }).el
assert.equal(fx.text(fx.finde(wurzel, '.tm-box-info')), text)
}))
test('storno_abschnitt_text: der Abschnitt „Stornobedingungen“ sagt, dass sie nur für Leistungen mit Preis gelten', mitFakeDom((fx) => {
const e = basisEinstellung()
const frist = h('input', { type: 'number', value: '24' })
const { el } = einstellungenTab.storno({ nurLesen: false }, e, frist)
const einleitung = fx.text(fx.finde(el, '.tm-abschnitt-text'))
assert.ok(einleitung.includes('Lässt du das Feld leer, gibt es keine Stornobedingungen und keinen Haken dazu.'), 'bisheriger Text bleibt')
assert.ok(einleitung.endsWith('Die Stornobedingungen gelten nur für Leistungen mit Preis; bei kostenlosen Leistungen fragt der Terminplaner Gäste nicht danach.'))
assert.doesNotMatch(einleitung, SIE_WOERTER)
}))
test('storno_muster_gekennzeichnet: der Hinweis steht wörtlich und in der Du-Form neben dem Stornofeld, nicht in den Gästetexten und nicht im Nachweis', mitFakeDom((fx) => {
const hinweis = einstellungenTab.STORNO_MUSTER_HINWEIS
assert.equal(hinweis, 'Das ist ein Muster, kein Rechtstext. Bitte prüfe es und passe es an deine Bedingungen an.')
assert.doesNotMatch(hinweis, SIE_WOERTER)
const e = basisEinstellung()
const frist = h('input', { type: 'number', value: '24' })
const { el } = einstellungenTab.storno({ nurLesen: false }, e, frist)
const feldEl = fx.finde(el, '.tm-feld')
const textfeld = fx.finde(feldEl, 'textarea')
assert.equal(textfeld.dataset.feld, 'storno.text')
const gekennzeichnet = fx.alle(el, '[data-hinweis="storno-muster"]')
assert.equal(gekennzeichnet.length, 1)
assert.equal(fx.text(gekennzeichnet[0]), hinweis, 'wörtlich, ohne Zusatz')
assert.equal(gekennzeichnet[0].parentNode, feldEl, 'im Block des Stornofelds')
const kinder = [...feldEl.children]
assert.equal(kinder.indexOf(gekennzeichnet[0]), kinder.indexOf(textfeld) + 1, 'unmittelbar beim Textfeld')
// schreibgeschützt (abgeschaltete Funktion) bleibt der Hinweis stehen
assert.equal(fx.alle(einstellungenTab.storno({ nurLesen: true }, e, frist).el, '[data-hinweis="storno-muster"]').length, 1)
// der Hinweis ist ein Portaltext: er steht in keinem Gästetext, in keiner Vorlage und im Widget nicht
for (const gaestetext of [baueArchiv(), lies('server/services/termine/rechtstexte.js'), lies('public/termine-gaestetexte.js'), lies('public/embed/termine.js'), lies('server/services/termine/vorlagen.js')]) {
assert.ok(!gaestetext.includes(hinweis))
}
assert.ok(!stornoMuster(24).includes('ist ein Muster'))
// der Text im Feld und damit der Hash des Nachweises (sha256 von storno.text) bleiben vom Hinweis unberührt
assert.equal(textfeld.value, e.storno.text)
assert.ok(!fx.text(textfeld).includes(hinweis))
}))
+4 -2
View File
@@ -2074,9 +2074,11 @@ async function pruefungen() {
// Hinweise der Spezifikation // Hinweise der Spezifikation
const text = await textVon(page.locator('#tm-panel')) const text = await textVon(page.locator('#tm-panel'))
// Ruling R52: Wortlaut der rechtlichen Hinweise // Ruling R52: Wortlaut der rechtlichen Hinweise
eq(await textVon(page.locator('#tm-panel .tm-box-info', { hasText: 'Privatpersonen können eine Buchung' })), 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.', 'Hinweis: Widerruf schlägt Stornoregel (R55: auch Anfragen)') eq(await textVon(page.locator('#tm-panel .tm-box-info', { hasText: 'Privatpersonen können eine Buchung' })), 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. Hat ein Gast ausdrücklich verlangt, dass du vor Ablauf der Frist beginnst, und widerruft er danach, kann er für die bis dahin erbrachte Leistung zu einem angemessenen Betrag verpflichtet sein (Wertersatz, § 357a Abs. 2 BGB). Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.', 'Hinweis: Widerruf schlägt Stornoregel (R55: auch Anfragen)')
eq(await textVon(page.locator('#tm-panel label.tm-haken small', { hasText: 'Nur ankreuzen, wenn' })), 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend oder ein Escape Room. Friseur, Kosmetik, Massage, Tattoo, Beratung, Fotoshootings und Weiterbildungen sind in der Regel keine Freizeitleistung. Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.', 'Hinweis: Freizeit') eq(await textVon(page.locator('#tm-panel label.tm-haken small', { hasText: 'Nur ankreuzen, wenn' })), 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend, ein Kurs oder ein Escape Room. Nach unserer Einschätzung gilt bei Friseur, Kosmetik, Nagel- und Fußpflege, Tattoo, Fotoshootings, Coaching und Beratung in der Regel ein Widerrufsrecht; bei Massage und Wellness ist die Einordnung unklar, im Zweifel gilt ein Widerrufsrecht. Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. Das ist keine Rechtsberatung: Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.', 'Hinweis: Freizeit')
assert(text.includes('kein fertiger Rechtstext') && await page.locator('.tm-muster .tm-pille', { hasText: 'Muster' }).count() === 1, 'Stornotext-Muster als Muster gekennzeichnet') assert(text.includes('kein fertiger Rechtstext') && await page.locator('.tm-muster .tm-pille', { hasText: 'Muster' }).count() === 1, 'Stornotext-Muster als Muster gekennzeichnet')
eq(await textVon(page.locator('#tm-panel [data-hinweis="storno-muster"]')), 'Das ist ein Muster, kein Rechtstext. Bitte prüfe es und passe es an deine Bedingungen an.', 'Hinweis „Muster“ beim Stornofeld')
assert(text.includes('Die Stornobedingungen gelten nur für Leistungen mit Preis; bei kostenlosen Leistungen fragt der Terminplaner Gäste nicht danach.'), 'Storno nur bei Preis')
assert(text.includes('Bitte keine Gesundheitsangaben.'), 'Hinweis am Nachrichtenfeld') assert(text.includes('Bitte keine Gesundheitsangaben.'), 'Hinweis am Nachrichtenfeld')
assert(text.includes('Aktiv seit 01.10.2026'), 'aktiv seit') assert(text.includes('Aktiv seit 01.10.2026'), 'aktiv seit')
const online = form.getByRole('radio', { name: /Online bezahlen/ }) const online = form.getByRole('radio', { name: /Online bezahlen/ })
+3 -1
View File
@@ -286,7 +286,8 @@ async function pruefungen() {
await dlg.locator('.wkt-zusammen').waitFor() await dlg.locator('.wkt-zusammen').waitFor()
eq(await dlg.locator('input[name=vorzeitig]').count(), 0, 'kein Haken fuer vorzeitigen Beginn ohne Preis') eq(await dlg.locator('input[name=vorzeitig]').count(), 0, 'kein Haken fuer vorzeitigen Beginn ohne Preis')
eq(await dlg.locator('.wkt-form button[type=submit]').innerText(), 'Termin buchen', 'Knopftext ohne Preis') eq(await dlg.locator('.wkt-form button[type=submit]').innerText(), 'Termin buchen', 'Knopftext ohne Preis')
await dlg.locator('input[name=storno]').check() eq(await dlg.locator('input[name=storno]').count(), 0, 'kein Storno-Haken ohne Preis (F11)')
eq(await dlg.locator('.wkt-box h3', { hasText: 'Stornobedingungen' }).count(), 0, 'keine Stornobox ohne Preis (F11)')
await dlg.getByRole('button', { name: 'Termin buchen' }).click() await dlg.getByRole('button', { name: 'Termin buchen' }).click()
await dlg.getByRole('heading', { name: 'Fast geschafft' }).waitFor() await dlg.getByRole('heading', { name: 'Fast geschafft' }).waitFor()
await srv.warteAufVersand() await srv.warteAufVersand()
@@ -871,6 +872,7 @@ async function pruefungen() {
await bisZusammenfassung(page, { leistung: 'Brautfrisur', tag: '2026-10-26' }) await bisZusammenfassung(page, { leistung: 'Brautfrisur', tag: '2026-10-26' })
await wd.locator('details.wkt-belehrung').waitFor() await wd.locator('details.wkt-belehrung').waitFor()
assert((await wd.locator('details.wkt-belehrung').innerText()).includes('Widerrufsbelehrung'), 'Belehrung vor dem Absenden') assert((await wd.locator('details.wkt-belehrung').innerText()).includes('Widerrufsbelehrung'), 'Belehrung vor dem Absenden')
eq(await wd.locator('input[name=storno]').count(), 0, 'Anfrage ohne Preis: kein Storno-Haken (F11)')
await hakenUndBuchen(wd, 'Termin anfragen') await hakenUndBuchen(wd, 'Termin anfragen')
await srv.warteAufVersand() await srv.warteAufVersand()
const buchungen = srv.buchungen() const buchungen = srv.buchungen()