From 9cacd5bd3b0841fa3ba11c52221d1c65e6c1fcc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:04:29 +0000 Subject: [PATCH] Terminplaner: Einstellungen und Branchen-Vorlagen Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/einstellungen.js | 628 ++++++++++++++++++ server/services/termine/vorlagen.js | 218 +++++++ test/termineEinstellungen.test.js | 788 +++++++++++++++++++++++ 3 files changed, 1634 insertions(+) create mode 100644 server/services/termine/einstellungen.js create mode 100644 server/services/termine/vorlagen.js create mode 100644 test/termineEinstellungen.test.js diff --git a/server/services/termine/einstellungen.js b/server/services/termine/einstellungen.js new file mode 100644 index 0000000..a919307 --- /dev/null +++ b/server/services/termine/einstellungen.js @@ -0,0 +1,628 @@ +import { randomBytes } from 'node:crypto' +import { BUNDESLAENDER } from './feiertage.js' +import { heute, zuUtc } from './zeit.js' + +/** + * Terminplaner-Einstellungen (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 2, termineConfig.einstellung). + * Reine Funktionen ohne Appwrite und ohne config: Pruefen und Normalisieren der Betriebs-Einstellung, Texte fuer + * Knoepfe und Preise, die oeffentliche Sicht fuer GET /config. `now` kommt immer als Parameter. + * Fehler sind { [pfad]: text } mit Pfaden wie 'betrieb.name' oder 'ressourcen.0.plan.1'; Texte in Du-Form (Portal). + */ + +const VORGABE_FARBE = '#7c3aed' +const GRENZEN = { ressourcen: 20, leistungen: 50, abwesenheiten: 100, sonderoeffnungen: 200, zusatzfelder: 5, optionen: 10, json: 200000 } +const RASTER = [5, 10, 15, 20, 30, 60] +const ERINNERUNG_STUNDEN = [2, 12, 24, 48] +const GAST_FELD = ['aus', 'optional', 'pflicht'] +const ZAHLARTEN = { bar: 'bar', ec: 'EC-Karte', kreditkarte: 'Kreditkarte', ueberweisung: 'Überweisung', paypal: 'PayPal' } +const RESERVIERTE_SCHLUESSEL = ['__proto__', 'constructor', 'prototype'] +const VORLAGE_RE = /^[a-z]{1,20}$/ +const FEED_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/ +const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789' +const VORZEITIG_TAGE = 14 + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +const idRe = (praefix) => new RegExp(`^${praefix}_[a-z0-9]{8}$`) + +// ---------------------------------------------------------------- IDs und Token + +/** 'r_' / 's_' / 'a_' + 8 Zeichen aus a-z0-9 (Ressource, Leistung, Abwesenheit). */ +export function neueId(praefix) { + if (!['r', 's', 'a'].includes(praefix)) throw new Error(`Unbekanntes ID-Praefix: ${praefix}`) + let zeichen = '' + while (zeichen.length < 8) { + for (const byte of randomBytes(16)) { + if (byte < 252 && zeichen.length < 8) zeichen += ID_ALPHABET[byte % ID_ALPHABET.length] // 252 = 7 * 36, ohne Verzerrung + } + } + return `${praefix}_${zeichen}` +} + +/** Kalender-Abo-Token einer Ressource: 32 Zufallsbytes, base64url (43 Zeichen). */ +export function neuerFeedToken() { + return randomBytes(32).toString('base64url') +} + +// ---------------------------------------------------------------- Bausteine zum Pruefen + +// ASCII- und C1-Steuerzeichen, Zeilen-/Absatztrenner und Richtungsumschalter (Anzeige-Spoofing in Mails und Widget) +const STEUERZEICHEN = /[\u0000-\u001F\u007F-\u009F\u2028\u2029\u202A-\u202E\u2066-\u2069]/g + +/** Bereinigter Text: ohne Steuerzeichen, getrimmt, auf max Zeichen (Codepoints) gekuerzt. Einzeilig: Umbrueche werden Leerzeichen. */ +function text(wert, max, { mehrzeilig = false } = {}) { + if (typeof wert !== 'string') return '' + let s = wert + if (mehrzeilig) { + s = s.replace(/\r\n?/g, '\n').replace(/\t/g, ' ').replace(STEUERZEICHEN, (z) => (z === '\n' ? z : '')).replace(/\n{3,}/g, '\n\n') + } else { + s = s.replace(/[\r\n\t]+/g, ' ').replace(STEUERZEICHEN, '').replace(/ {2,}/g, ' ') + } + s = s.trim() + const zeichen = [...s] + return zeichen.length > max ? zeichen.slice(0, max).join('').trim() : s +} + +/** Wert in {min..max} (ganzzahlig, Vielfaches von schritt) oder Vorgabe; fehlender Wert ohne Vorgabe ist ein Fehler. */ +function zahl(fehl, pfad, wert, { vorgabe, min, max, schritt = 1, meldung }) { + if (wert === undefined || wert === null) { + if (vorgabe === undefined) fehl(pfad, meldung) + return vorgabe + } + if (!Number.isInteger(wert) || wert < min || wert > max || wert % schritt !== 0) { + fehl(pfad, meldung) + return vorgabe + } + return wert +} + +function wahl(fehl, pfad, wert, erlaubt, vorgabe, meldung) { + if (wert === undefined || wert === null) return vorgabe + if (!erlaubt.includes(wert)) { + fehl(pfad, meldung) + return vorgabe + } + return wert +} + +function wahrheit(fehl, pfad, wert, vorgabe) { + if (wert === undefined || wert === null) return vorgabe + if (typeof wert !== 'boolean') { + fehl(pfad, 'Bitte „ja“ oder „nein“ wählen.') + return vorgabe + } + return wert +} + +/** Ein Postfach: kein Leerraum, genau ein @, keine ,;<>" und >= 1 Punkt in der Domain, hoechstens 254 Zeichen. */ +function emailAdresse(wert) { + if (typeof wert !== 'string') return null + const s = wert.replace(STEUERZEICHEN, '').trim().toLowerCase() + if (s.length === 0 || s.length > 254) return null + if (!/^[^\s@,;<>"]+@[^\s@,;<>".]+(\.[^\s@,;<>".]+)+$/.test(s)) return null + return s +} + +const URL_MELDUNG = 'Bitte gib eine vollständige Adresse mit https:// an (höchstens 300 Zeichen, ohne Zugangsdaten).' + +/** https-URL (<= 300, ohne Zugangsdaten) in kanonischer Schreibweise, leer = null. */ +function urlFeld(fehl, pfad, wert) { + if (wert === undefined || wert === null) return null + if (typeof wert !== 'string') { + fehl(pfad, URL_MELDUNG) + return null + } + const s = wert.replace(STEUERZEICHEN, '').trim() + if (s === '') return null + let url = null + if (s.length <= 300 && !/\s/.test(s)) { + try { url = new URL(s) } catch { url = null } + } + if (!url || url.protocol !== 'https:' || url.username || url.password || !url.hostname || url.href.length > 300) { + fehl(pfad, URL_MELDUNG) + return null + } + return url.href +} + +/** ID eines Eintrags: fehlt sie, entsteht eine neue; sonst Format + Eindeutigkeit je Liste. */ +function idFeld(fehl, pfad, wert, praefix, gesehen) { + let id = wert + if (id === undefined || id === null || id === '') { + do { id = neueId(praefix) } while (gesehen.has(id)) + } else if (typeof id !== 'string' || !idRe(praefix).test(id)) { + fehl(pfad, 'Ungültige ID.') + return null + } else if (gesehen.has(id)) { + fehl(pfad, 'Diese ID kommt doppelt vor.') + return null + } + gesehen.add(id) + return id +} + +function tagGueltig(tag) { + return typeof tag === 'string' && zuUtc(tag, '12:00') !== null +} + +/** 'HH:MM' -> Minuten (5er-Raster, Ende darf 24:00 sein), sonst null. */ +function minuten(wert, istEnde) { + const m = typeof wert === 'string' ? /^(\d{2}):(\d{2})$/.exec(wert) : null + if (!m) return null + const h = Number(m[1]) + const min = Number(m[2]) + if (h > 24 || min > 59 || min % 5 !== 0 || (h === 24 && (min !== 0 || !istEnde))) return null + return h * 60 + min +} + +const hhmm = (min) => `${String(Math.floor(min / 60)).padStart(2, '0')}:${String(min % 60).padStart(2, '0')}` + +/** Liste von Fenstern [['09:00','12:30'], …]: 5er-Raster, aufsteigend, ohne Ueberlappung, Ende > Beginn. */ +function fensterListe(fehl, pfad, wert) { + if (!Array.isArray(wert)) { + fehl(pfad, 'Ungültige Zeitfenster.') + return [] + } + const aus = [] + let vorher = null + for (const fenster of wert) { + const von = Array.isArray(fenster) && fenster.length === 2 ? minuten(fenster[0], false) : null + const bis = von === null ? null : minuten(fenster[1], true) + if (von === null || bis === null) { + fehl(pfad, 'Uhrzeiten bitte als HH:MM in 5-Minuten-Schritten angeben (Ende bis 24:00).') + return [] + } + if (bis <= von) { + fehl(pfad, 'Das Ende muss nach dem Beginn liegen.') + return [] + } + if (vorher && von < vorher.bis) { + fehl(pfad, von < vorher.von ? 'Zeitfenster müssen aufsteigend sortiert sein.' : 'Zeitfenster überlappen sich.') + return [] + } + vorher = { von, bis } + aus.push([hhmm(von), hhmm(bis)]) + } + return aus +} + +// ---------------------------------------------------------------- Abschnitte + +function normalisiereBetrieb(fehl, roh) { + const b = istObjekt(roh) ? roh : {} + const name = text(b.name, 80) + if (!name) fehl('betrieb.name', 'Bitte gib den Namen deines Betriebs an.') + const email = emailAdresse(b.email) + if (!email) fehl('betrieb.email', 'Bitte gib eine gültige E-Mail-Adresse an (genau ein Postfach).') + return { + name, + email: email || '', + telefon: text(b.telefon, 40), + kleinunternehmer: wahrheit(fehl, 'betrieb.kleinunternehmer', b.kleinunternehmer, false), + adresse: text(b.adresse, 200), + impressumUrl: urlFeld(fehl, 'betrieb.impressumUrl', b.impressumUrl), + datenschutzUrl: urlFeld(fehl, 'betrieb.datenschutzUrl', b.datenschutzUrl), + agbUrl: urlFeld(fehl, 'betrieb.agbUrl', b.agbUrl), + websiteUrl: urlFeld(fehl, 'betrieb.websiteUrl', b.websiteUrl), + } +} + +/** Liste mit Obergrenze: undefined/null = leer; Nicht-Liste und zu lang = Fehler am Listenpfad. */ +function liste(fehl, pfad, wert, max, meldung) { + if (wert === undefined || wert === null) return [] + if (!Array.isArray(wert)) { + fehl(pfad, 'Ungültige Liste.') + return [] + } + if (wert.length > max) { + fehl(pfad, meldung) + return [] + } + return wert +} + +function normalisiereWochenplan(fehl, pfad, plan) { + if (plan === undefined || plan === null) return {} + if (!istObjekt(plan)) { + fehl(pfad, 'Ungültiger Wochenplan.') + return {} + } + const aus = {} + for (const tag of Object.keys(plan)) { + if (!/^[1-7]$/.test(tag)) { + fehl(`${pfad}.${tag.slice(0, 20)}`, 'Ungültiger Wochentag (1 = Montag bis 7 = Sonntag).') + continue + } + const fenster = fensterListe(fehl, `${pfad}.${tag}`, plan[tag]) + if (fenster.length) aus[tag] = fenster + } + return aus +} + +function normalisiereRessourcen(fehl, roh, alt) { + const feedTokens = new Map( + (Array.isArray(alt?.ressourcen) ? alt.ressourcen : []) + .filter((r) => typeof r?.id === 'string' && typeof r.feedToken === 'string' && FEED_TOKEN_RE.test(r.feedToken)) + .map((r) => [r.id, r.feedToken]), + ) + const gesehen = new Set() + const aus = [] + liste(fehl, 'ressourcen', roh, GRENZEN.ressourcen, `Es sind höchstens ${GRENZEN.ressourcen} Personen oder Plätze möglich.`).forEach((r, i) => { + const pfad = `ressourcen.${i}` + if (!istObjekt(r)) return fehl(pfad, 'Ungültiger Eintrag.') + const id = idFeld(fehl, `${pfad}.id`, r.id, 'r', gesehen) + const name = text(r.name, 80) + if (!name) fehl(`${pfad}.name`, 'Bitte gib einen Namen an.') + aus.push({ + id, + typ: wahl(fehl, `${pfad}.typ`, r.typ, ['person', 'platz'], 'person', 'Bitte „Person“ oder „Platz“ wählen.'), + name, + aktiv: wahrheit(fehl, `${pfad}.aktiv`, r.aktiv, true), + plan: normalisiereWochenplan(fehl, `${pfad}.plan`, r.plan), + feedToken: (id && feedTokens.get(id)) || neuerFeedToken(), // nie vom Client + }) + }) + return aus +} + +function normalisiereLeistungen(fehl, roh, ressourcen) { + const alleIds = new Set(ressourcen.map((r) => r.id)) + const aktiveIds = new Set(ressourcen.filter((r) => r.aktiv).map((r) => r.id)) + const gesehen = new Set() + const aus = [] + liste(fehl, 'leistungen', roh, GRENZEN.leistungen, `Es sind höchstens ${GRENZEN.leistungen} Leistungen möglich.`).forEach((l, i) => { + const pfad = `leistungen.${i}` + if (!istObjekt(l)) return fehl(pfad, 'Ungültiger Eintrag.') + const id = idFeld(fehl, `${pfad}.id`, l.id, 's', gesehen) + const name = text(l.name, 100) + if (!name) fehl(`${pfad}.name`, 'Bitte gib einen Namen an.') + const dauer = zahl(fehl, `${pfad}.dauer`, l.dauer, { min: 5, max: 480, schritt: 5, meldung: 'Die Dauer muss zwischen 5 und 480 Minuten liegen (in 5-Minuten-Schritten).' }) + const pufferNach = zahl(fehl, `${pfad}.pufferNach`, l.pufferNach, { vorgabe: 0, min: 0, max: 120, schritt: 5, meldung: 'Die Pause danach muss zwischen 0 und 120 Minuten liegen (in 5-Minuten-Schritten).' }) + if (dauer !== undefined && dauer + pufferNach > 500) fehl(`${pfad}.dauer`, 'Dauer und Pause danach dürfen zusammen höchstens 500 Minuten betragen.') + const preisArt = wahl(fehl, `${pfad}.preisArt`, l.preisArt, ['fest', 'ab', 'keiner'], 'keiner', 'Bitte „Festpreis“, „ab“ oder „kein Preis“ wählen.') + const preisCent = preisArt === 'keiner' + ? 0 + : zahl(fehl, `${pfad}.preisCent`, l.preisCent, { vorgabe: 0, min: 0, max: 1000000, meldung: 'Der Preis muss zwischen 0 und 10.000 Euro liegen.' }) + const aktiv = wahrheit(fehl, `${pfad}.aktiv`, l.aktiv, true) + + const zuordnung = [] + if (l.ressourcen !== undefined && l.ressourcen !== null && !Array.isArray(l.ressourcen)) { + fehl(`${pfad}.ressourcen`, 'Ungültige Zuordnung.') + } else { + for (const rid of l.ressourcen || []) { + if (typeof rid !== 'string' || !alleIds.has(rid)) { + fehl(`${pfad}.ressourcen`, 'Die Zuordnung enthält eine unbekannte Person oder einen unbekannten Platz.') + } else if (!zuordnung.includes(rid)) { + zuordnung.push(rid) + } + } + } + if (aktiv && !zuordnung.some((rid) => aktiveIds.has(rid))) { + fehl(`${pfad}.ressourcen`, 'Eine aktive Leistung braucht mindestens eine aktive Person oder einen aktiven Platz.') + } + + aus.push({ + id, + name, + beschreibung: text(l.beschreibung, 300), + dauer, + pufferNach, + preisArt, + preisCent, + buchungsart: wahl(fehl, `${pfad}.buchungsart`, l.buchungsart, ['sofort', 'anfrage'], 'sofort', 'Bitte „sofort buchen“ oder „Anfrage“ wählen.'), + ressourcen: zuordnung, + aktiv, + reihenfolge: i + 1, + }) + }) + return aus +} + +function normalisiereAbwesenheiten(fehl, roh, ressourcen) { + const alleIds = new Set(ressourcen.map((r) => r.id)) + const gesehen = new Set() + const aus = [] + liste(fehl, 'abwesenheiten', roh, GRENZEN.abwesenheiten, `Es sind höchstens ${GRENZEN.abwesenheiten} Abwesenheiten möglich.`).forEach((a, i) => { + const pfad = `abwesenheiten.${i}` + if (!istObjekt(a)) return fehl(pfad, 'Ungültiger Eintrag.') + const id = idFeld(fehl, `${pfad}.id`, a.id, 'a', gesehen) + let ressourceId = null + if (a.ressourceId !== undefined && a.ressourceId !== null) { + if (typeof a.ressourceId === 'string' && alleIds.has(a.ressourceId)) ressourceId = a.ressourceId + else fehl(`${pfad}.ressourceId`, 'Unbekannte Person oder unbekannter Platz.') + } + const vonOk = tagGueltig(a.von) + const bisOk = tagGueltig(a.bis) + if (!vonOk) fehl(`${pfad}.von`, 'Bitte gib ein gültiges Datum an.') + if (!bisOk) fehl(`${pfad}.bis`, 'Bitte gib ein gültiges Datum an.') + if (vonOk && bisOk && a.von > a.bis) fehl(`${pfad}.bis`, 'Das Ende darf nicht vor dem Beginn liegen.') + aus.push({ id, ressourceId, von: vonOk ? a.von : '', bis: bisOk ? a.bis : '', titel: text(a.titel, 80) }) + }) + return aus +} + +function normalisiereSonderoeffnungen(fehl, roh) { + if (roh === undefined || roh === null) return {} + if (!istObjekt(roh)) { + fehl('sonderoeffnungen', 'Ungültige Sonderöffnungen.') + return {} + } + const tage = Object.keys(roh) + if (tage.length > GRENZEN.sonderoeffnungen) { + fehl('sonderoeffnungen', `Es sind höchstens ${GRENZEN.sonderoeffnungen} Sonderöffnungstage möglich.`) + return {} + } + const aus = {} + for (const tag of tage.sort()) { + const pfad = `sonderoeffnungen.${tag.slice(0, 20)}` + if (!tagGueltig(tag)) { + fehl(pfad, 'Bitte gib ein gültiges Datum an.') + continue + } + aus[tag] = fensterListe(fehl, pfad, roh[tag]) // [] = an diesem Tag geschlossen + } + return aus +} + +function normalisiereGastFelder(fehl, roh) { + const g = istObjekt(roh) ? roh : {} + const meldung = 'Bitte „aus“, „optional“ oder „Pflicht“ wählen.' + const zusatz = [] + const schluessel = new Set() + liste(fehl, 'gastFelder.zusatz', g.zusatz, GRENZEN.zusatzfelder, `Es sind höchstens ${GRENZEN.zusatzfelder} Zusatzfelder möglich.`).forEach((z, i) => { + const pfad = `gastFelder.zusatz.${i}` + if (!istObjekt(z)) return fehl(pfad, 'Ungültiger Eintrag.') + const key = typeof z.key === 'string' ? z.key : '' + if (!/^[a-z0-9_]{1,20}$/.test(key) || RESERVIERTE_SCHLUESSEL.includes(key)) { + fehl(`${pfad}.key`, 'Der Schlüssel besteht aus 1 bis 20 Kleinbuchstaben, Ziffern oder Unterstrichen.') + } else if (schluessel.has(key)) { + fehl(`${pfad}.key`, 'Dieser Schlüssel kommt doppelt vor.') + } + schluessel.add(key) + const label = text(z.label, 60) + if (!label) fehl(`${pfad}.label`, 'Bitte gib eine Beschriftung an.') + const typ = wahl(fehl, `${pfad}.typ`, z.typ, ['text', 'auswahl'], 'text', 'Bitte „Text“ oder „Auswahl“ wählen.') + let optionen = [] + if (typ === 'auswahl') { + const roheOptionen = Array.isArray(z.optionen) ? z.optionen : [] + optionen = [...new Set(roheOptionen.map((o) => text(o, 60)).filter(Boolean))] + if (roheOptionen.length > GRENZEN.optionen || optionen.length === 0) { + fehl(`${pfad}.optionen`, `Bitte gib 1 bis ${GRENZEN.optionen} Auswahlmöglichkeiten an.`) + optionen = optionen.slice(0, GRENZEN.optionen) + } + } + zusatz.push({ key, label, typ, optionen, pflicht: wahrheit(fehl, `${pfad}.pflicht`, z.pflicht, false) }) + }) + return { + telefon: wahl(fehl, 'gastFelder.telefon', g.telefon, GAST_FELD, 'optional', meldung), + nachricht: wahl(fehl, 'gastFelder.nachricht', g.nachricht, GAST_FELD, 'aus', meldung), + zusatz, + } +} + +function normalisiereZahlung(fehl, roh) { + const z = istObjekt(roh) ? roh : {} + if (z.modus !== undefined && z.modus !== null && z.modus !== 'vorort') { + fehl('zahlung.modus', z.modus === 'online' || z.modus === 'beides' ? 'Online-Zahlung kommt bald' : 'Bitte „vor Ort“ wählen.') + } + let arten = ['bar', 'ec', 'kreditkarte'] + if (z.vorOrtArten !== undefined && z.vorOrtArten !== null) { + if (!Array.isArray(z.vorOrtArten) || z.vorOrtArten.some((a) => !Object.hasOwn(ZAHLARTEN, a))) { + fehl('zahlung.vorOrtArten', 'Bitte wähle Zahlarten aus der Liste.') + } else { + arten = Object.keys(ZAHLARTEN).filter((a) => z.vorOrtArten.includes(a)) // feste Reihenfolge, ohne Doppelte + } + } + return { modus: 'vorort', vorOrtArten: arten } +} + +/** Naechste Fassung 'YYYY-MM-DD-n': am selben Tag n + 1, sonst 1. */ +function naechsteStornoVersion(bisherige, tag) { + const m = /^(\d{4}-\d{2}-\d{2})-(\d+)$/.exec(bisherige) + return m && m[1] === tag ? `${tag}-${Number(m[2]) + 1}` : `${tag}-1` +} + +function normalisiereStorno(roh, alt, now) { + const neu = text(istObjekt(roh) ? roh.text : '', 2000, { mehrzeilig: true }) + const altText = typeof alt?.text === 'string' ? alt.text : '' + const altVersion = typeof alt?.version === 'string' ? alt.version : '' + // Version nie vom Client: neue Fassung nur bei geaendertem (oder bisher unversioniertem) Text; wird der Text + // entfernt, bleibt die letzte Fassungsnummer stehen, damit sie spaeter nicht erneut vergeben wird. + const version = neu !== '' && (neu !== altText || altVersion === '') ? naechsteStornoVersion(altVersion, heute(now)) : altVersion + return { text: neu, version } +} + +function pruefeEntfernte(fehl, alteListe, neueListe, zaehler, pfad, meldung) { + const bleibt = new Set(neueListe.map((e) => e.id)) + for (const eintrag of Array.isArray(alteListe) ? alteListe : []) { + if (!eintrag || typeof eintrag.id !== 'string' || bleibt.has(eintrag.id)) continue + const n = istObjekt(zaehler) && Object.hasOwn(zaehler, eintrag.id) ? zaehler[eintrag.id] : 0 + if (Number.isInteger(n) && n > 0) fehl(`${pfad}.${eintrag.id}`, meldung(eintrag, n)) + } +} + +const termine = (n) => (n === 1 ? '1 künftigen Termin' : `${n} künftige Termine`) +const MELDUNG_LEISTUNG = (_, n) => `Für diese Leistung gibt es noch ${termine(n)}. Deaktiviere sie stattdessen.` +const MELDUNG_RESSOURCE = (r, n) => (r.typ === 'platz' + ? `Für diesen Platz gibt es noch ${termine(n)}. Deaktiviere ihn stattdessen.` + : `Für diese Person gibt es noch ${termine(n)}. Deaktiviere sie stattdessen.`) + +// ---------------------------------------------------------------- Normalisieren + +/** + * Prueft und normalisiert eine Einstellung (Eingabe vom Client oder aus einer Vorlage). + * bisher = zuletzt gespeicherte Einstellung (Feed-Token, Storno-Fassung, Entfernen-Pruefung); + * kuenftigeTermine = { leistungen: { [id]: Anzahl }, ressourcen: { [id]: Anzahl } } kuenftiger belegender Termine. + * => { einstellung } oder { fehler: { [pfad]: text } }. + */ +export function normalisiereEinstellung(eingabe, { bisher = null, kuenftigeTermine = null, now } = {}) { + if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('normalisiereEinstellung: now fehlt') + const fehler = {} + const fehl = (pfad, meldung) => { if (!Object.hasOwn(fehler, pfad)) fehler[pfad] = meldung } + if (!istObjekt(eingabe)) return { fehler: { _: 'Die Einstellungen sind ungültig.' } } + const alt = istObjekt(bisher) ? bisher : null + + let vorlage = eingabe.vorlage ?? alt?.vorlage ?? null + if (vorlage !== null && !(typeof vorlage === 'string' && VORLAGE_RE.test(vorlage))) { + fehl('vorlage', 'Ungültige Vorlage.') + vorlage = null + } + + const betrieb = normalisiereBetrieb(fehl, eingabe.betrieb) + const ressourcen = normalisiereRessourcen(fehl, eingabe.ressourcen, alt) + const leistungen = normalisiereLeistungen(fehl, eingabe.leistungen, ressourcen) + pruefeEntfernte(fehl, alt?.leistungen, leistungen, kuenftigeTermine?.leistungen, 'leistungen', MELDUNG_LEISTUNG) + pruefeEntfernte(fehl, alt?.ressourcen, ressourcen, kuenftigeTermine?.ressourcen, 'ressourcen', MELDUNG_RESSOURCE) + + const regeln = istObjekt(eingabe.regeln) ? eingabe.regeln : {} + const erinnerung = istObjekt(eingabe.erinnerung) ? eingabe.erinnerung : {} + const recht = istObjekt(eingabe.recht) ? eingabe.recht : {} + + const einstellung = { + schema: 1, + vorlage, + betrieb, + anrede: wahl(fehl, 'anrede', eingabe.anrede, ['sie', 'du'], 'sie', 'Bitte „Sie“ oder „Du“ wählen.'), + farbe: farbe(fehl, eingabe.farbe), + bundesland: wahl(fehl, 'bundesland', eingabe.bundesland, BUNDESLAENDER, 'RP', 'Bitte wähle ein Bundesland.'), + feiertageGeschlossen: wahrheit(fehl, 'feiertageGeschlossen', eingabe.feiertageGeschlossen, true), + sonderoeffnungen: normalisiereSonderoeffnungen(fehl, eingabe.sonderoeffnungen), + abwesenheiten: normalisiereAbwesenheiten(fehl, eingabe.abwesenheiten, ressourcen), + ressourcen, + leistungen, + regeln: { + raster: wahl(fehl, 'regeln.raster', regeln.raster, RASTER, 15, 'Das Raster muss 5, 10, 15, 20, 30 oder 60 Minuten betragen.'), + vorlaufMin: zahl(fehl, 'regeln.vorlaufMin', regeln.vorlaufMin, { vorgabe: 120, min: 0, max: 20160, meldung: 'Die Vorlaufzeit muss zwischen 0 Minuten und 14 Tagen liegen.' }), + horizontTage: zahl(fehl, 'regeln.horizontTage', regeln.horizontTage, { vorgabe: 60, min: 1, max: 365, meldung: 'Der Buchungszeitraum muss zwischen 1 und 365 Tagen liegen.' }), + absageBisStd: zahl(fehl, 'regeln.absageBisStd', regeln.absageBisStd, { vorgabe: 24, min: 0, max: 336, meldung: 'Die Absagefrist muss zwischen 0 und 336 Stunden liegen.' }), + maxOffeneJeEmail: zahl(fehl, 'regeln.maxOffeneJeEmail', regeln.maxOffeneJeEmail, { vorgabe: 3, min: 1, max: 10, meldung: 'Erlaubt sind 1 bis 10 offene Termine je E-Mail-Adresse.' }), + personWaehlbar: wahrheit(fehl, 'regeln.personWaehlbar', regeln.personWaehlbar, true), + }, + gastFelder: normalisiereGastFelder(fehl, eingabe.gastFelder), + erinnerung: { + anbieten: wahrheit(fehl, 'erinnerung.anbieten', erinnerung.anbieten, true), + stundenVorher: wahl(fehl, 'erinnerung.stundenVorher', erinnerung.stundenVorher, ERINNERUNG_STUNDEN, 24, 'Die Erinnerung geht 2, 12, 24 oder 48 Stunden vorher raus.'), + }, + storno: normalisiereStorno(eingabe.storno, alt?.storno, now), + recht: { + gaesteSind: wahl(fehl, 'recht.gaesteSind', recht.gaesteSind, ['verbraucher', 'unternehmer', 'gemischt'], 'verbraucher', 'Bitte „Privatpersonen“, „Unternehmen“ oder „beides“ wählen.'), + freizeit: wahrheit(fehl, 'recht.freizeit', recht.freizeit, false), + }, + zahlung: normalisiereZahlung(fehl, eingabe.zahlung), + aufbewahrungTage: zahl(fehl, 'aufbewahrungTage', eingabe.aufbewahrungTage, { vorgabe: 90, min: 14, max: 365, meldung: 'Die Aufbewahrung muss zwischen 14 und 365 Tagen liegen.' }), + } + + if (Object.keys(fehler).length === 0 && JSON.stringify(einstellung).length > GRENZEN.json) { + fehl('_', 'Die Einstellungen sind zu umfangreich. Bitte entferne Zeitfenster oder Einträge.') + } + return Object.keys(fehler).length ? { fehler } : { einstellung } +} + +function farbe(fehl, wert) { + if (wert === undefined || wert === null) return VORGABE_FARBE + const s = typeof wert === 'string' ? wert.trim().toLowerCase() : '' + if (!/^#[0-9a-f]{6}$/.test(s)) { + fehl('farbe', 'Bitte wähle eine Farbe im Format #rrggbb.') + return VORGABE_FARBE + } + return s +} + +// ---------------------------------------------------------------- Texte und Regeln + +/** Hat die Leistung einen Preis (fest/ab und mehr als 0)? */ +function hatPreis(leistung) { + return !!leistung && leistung.preisArt !== 'keiner' && Number.isInteger(leistung.preisCent) && leistung.preisCent > 0 +} + +/** '45,00 €' | 'ab 45,00 €' | '' (kein Preis). */ +export function preisText(leistung) { + if (!hatPreis(leistung)) return '' + const euro = String(Math.floor(leistung.preisCent / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, '.') + const cent = String(leistung.preisCent % 100).padStart(2, '0') + return `${leistung.preisArt === 'ab' ? 'ab ' : ''}${euro},${cent} €` +} + +/** Knopf im Widget; bestaetigen = Knopf auf der Bestaetigungsseite. */ +export function knopfText(leistung, { bestaetigen = false } = {}) { + if (leistung?.buchungsart === 'anfrage') return bestaetigen ? 'Anfrage absenden' : 'Termin anfragen' + if (hatPreis(leistung)) return bestaetigen ? 'Zahlungspflichtig bestätigen' : 'Zahlungspflichtig buchen' + return bestaetigen ? 'Termin bestätigen' : 'Termin buchen' +} + +/** Gaeste gelten als Verbraucher, ausser der Betrieb hat nur Unternehmen oder bietet eine Freizeitleistung an. */ +export function istVerbraucher(einstellung) { + const recht = einstellung?.recht || {} + return recht.gaesteSind !== 'unternehmer' && !recht.freizeit +} + +/** + * Pflicht-Zustimmung zum vorzeitigen Beginn (§ 356 Abs. 5 Nr. 2 BGB): verbindlich + Preis + Verbraucher und + * Terminbeginn vor now + 14 Tagen. Ein unbrauchbarer Beginn verlangt die Zustimmung lieber. + */ +export function brauchtZustimmungVorzeitig(einstellung, leistung, start, now) { + if (leistung?.buchungsart !== 'sofort' || !hatPreis(leistung) || !istVerbraucher(einstellung)) return false + return !(start.getTime() >= now.getTime() + VORZEITIG_TAGE * 86400000) +} + +export function preisHinweis(einstellung) { + return einstellung?.betrieb?.kleinunternehmer === true ? 'kein Ausweis der USt nach § 19 UStG' : 'inkl. USt' +} + +/** 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte' (§ 312j Abs. 1 BGB: Zahlungsart steht vor der Buchung). */ +export function zahlungText(einstellung) { + const arten = (einstellung?.zahlung?.vorOrtArten || []).filter((a) => Object.hasOwn(ZAHLARTEN, a)).map((a) => ZAHLARTEN[a]) + return arten.length ? `Bezahlung vor Ort: ${arten.join(', ')}` : 'Bezahlung vor Ort' +} + +// ---------------------------------------------------------------- Oeffentliche Sicht + +/** + * Felder fuer GET /config (ohne aktiv, hinweis, texte, formToken – die setzt die Route). Nur aktive Leistungen mit + * mindestens einer aktiven Ressource, nur aktive Ressourcen; nie feedToken, Betriebs-E-Mail oder interne Regeln. + */ +export function oeffentlicheEinstellung(einstellung, now) { + const e = einstellung + const ressourcen = (e.ressourcen || []).filter((r) => r.aktiv) + const aktiveIds = new Set(ressourcen.map((r) => r.id)) + const verbraucher = istVerbraucher(e) + const leistungen = [] + for (const l of e.leistungen || []) { + if (!l.aktiv) continue + const ids = (l.ressourcen || []).filter((id) => aktiveIds.has(id)) + if (ids.length === 0) continue + leistungen.push({ + id: l.id, + name: l.name, + beschreibung: l.beschreibung, + dauer: l.dauer, + preis: preisText(l), + preisArt: l.preisArt, + buchungsart: l.buchungsart, + knopf: knopfText(l), + verbraucher, + ressourcen: ids, + }) + } + return { + heute: heute(now), + betrieb: { + name: e.betrieb.name, + telefon: e.betrieb.telefon, + adresse: e.betrieb.adresse, + impressumUrl: e.betrieb.impressumUrl, + datenschutzUrl: e.betrieb.datenschutzUrl, + }, + anrede: e.anrede, + farbe: e.farbe, + leistungen, + ressourcen: ressourcen.map((r) => ({ id: r.id, name: r.name, typ: r.typ })), + personWaehlbar: e.regeln.personWaehlbar, + regeln: { horizontTage: e.regeln.horizontTage, absageBisStd: e.regeln.absageBisStd }, + zahlung: { modus: 'vorort', text: zahlungText(e) }, + preisHinweis: preisHinweis(e), + storno: e.storno?.text ? { text: e.storno.text, version: e.storno.version } : null, + widerruf: verbraucher && leistungen.some((l) => l.buchungsart === 'sofort'), + erinnerung: { anbieten: e.erinnerung.anbieten, stundenVorher: e.erinnerung.stundenVorher }, + gastFelder: structuredClone(e.gastFelder), + } +} diff --git a/server/services/termine/vorlagen.js b/server/services/termine/vorlagen.js new file mode 100644 index 0000000..d59c352 --- /dev/null +++ b/server/services/termine/vorlagen.js @@ -0,0 +1,218 @@ +import { neueId, normalisiereEinstellung } from './einstellungen.js' + +/** + * Branchen-Vorlagen des Terminplaners (Spezifikation Abschnitt 9, Erstes Oeffnen): Daten, kein Code je Nische. + * Eine Vorlage liefert Leistungen, Wochenplan, Regeln und Stornotext als Vorschlag; Betriebsangaben kommen aus dem + * Kundenkonto (ausVorlage). Alle Preise sind Platzhalter zum Anpassen. Die Fristen im Stornotext stammen aus den + * Regeln (absageBisStd), damit Text und Selbstbedienung der Gaeste uebereinstimmen. TP2 nutzt dieselben Daten fuer den Fragebogen. + */ + +const ZAHLARTEN = ['bar', 'ec', 'kreditkarte'] +const STORNO_PROZENT = 50 + +/** Stornobedingungen Variante 1 (Zahlung vor Ort), recherche-recht-zahlung.md 7.3 F, mit X = Frist und Y = Prozent. */ +const stornoText = (stunden, prozent) => + `Sie können Ihren Termin bis ${stunden} Stunden vorher kostenlos absagen oder verschieben (Link in der Bestätigungsmail). ` + + `Danach oder bei Nichterscheinen berechnen wir für den freigehaltenen Termin ${prozent} % des Preises. ` + + 'Dem Kunden bleibt der Nachweis vorbehalten, dass uns kein oder ein geringerer Aufwand entstanden ist. ' + + 'Ihr gesetzliches Widerrufsrecht bleibt unberührt.' + +/** Wochenplan aus { '1-5': [[von, bis], …], '6': … } (ISO-Wochentage, Pausen = Luecken). */ +function wochenplan(spezifikation) { + const plan = {} + for (const [bereich, fenster] of Object.entries(spezifikation)) { + const [von, bis = von] = bereich.split('-').map(Number) + for (let tag = von; tag <= bis; tag += 1) plan[tag] = fenster + } + return plan +} + +const person = (name, plan) => ({ name, typ: 'person', plan }) +const platz = (name, plan) => ({ name, typ: 'platz', plan }) +const leistung = (name, dauer, pufferNach, preisArt, preisCent, beschreibung = '') => ({ name, beschreibung, dauer, pufferNach, preisArt, preisCent }) + +const MITTAG = [['09:00', '12:00'], ['13:00', '17:00']] +const BUERO = wochenplan({ '1-5': MITTAG }) + +/** + * Eine Vorlage. ressourcen: Wochenplan je Person/Platz; leistungen gelten fuer alle Ressourcen der Vorlage; + * buchungsart gilt fuer alle Leistungen; gastFelder/recht/regeln ueberschreiben nur die Abweichung von den Vorgaben. + */ +function vorlage(key, name, beschreibung, daten) { + const regeln = { raster: 15, vorlaufMin: 120, horizontTage: 60, absageBisStd: 24, ...daten.regeln } + return { + key, + name, + beschreibung, + ressourcen: daten.ressourcen, + leistungen: daten.leistungen, + buchungsart: daten.buchungsart || 'sofort', + regeln, + gastFelder: { telefon: 'optional', nachricht: 'aus', zusatz: [], ...daten.gastFelder }, + recht: { gaesteSind: 'verbraucher', freizeit: false, ...daten.recht }, + storno: stornoText(regeln.absageBisStd, STORNO_PROZENT), + } +} + +function einfrieren(wert) { + if (wert && typeof wert === 'object' && !Object.isFrozen(wert)) { + Object.freeze(wert) + Object.values(wert).forEach(einfrieren) + } + return wert +} + +export const VORLAGEN = einfrieren({ + salon: vorlage('salon', 'Friseur und Salon', 'Haarschnitt, Färben und Bartpflege mit Terminen je Person; Preise als „ab“-Preise zum Anpassen.', { + ressourcen: [person('Stylist/in', wochenplan({ '2-5': [['09:00', '12:30'], ['13:30', '18:00']], '6': [['09:00', '13:00']] }))], + leistungen: [ + leistung('Herrenschnitt', 30, 5, 'fest', 2800), + leistung('Damenschnitt', 45, 5, 'ab', 4500), + leistung('Kinderschnitt', 30, 5, 'fest', 1800), + leistung('Färben', 120, 10, 'ab', 7500), + ], + }), + + kosmetik: vorlage('kosmetik', 'Kosmetik und Nagelstudio', 'Gesichtsbehandlung, Maniküre und Pediküre mit Pause zum Aufräumen.', { + ressourcen: [person('Kosmetiker/in', wochenplan({ '1-5': MITTAG }))], + leistungen: [ + leistung('Gesichtsbehandlung', 60, 10, 'ab', 6500), + leistung('Maniküre', 45, 10, 'fest', 3500), + leistung('Pediküre', 60, 10, 'fest', 4500), + leistung('Augenbrauen zupfen und färben', 20, 5, 'fest', 1800), + ], + }), + + beratung: vorlage('beratung', 'Beratung und Coaching', 'Kennenlerngespräch kostenlos, Beratungstermine mit Preis; für Berater, Coaches und Kanzleien.', { + ressourcen: [person('Berater/in', BUERO)], + leistungen: [ + leistung('Kennenlerngespräch', 30, 5, 'keiner', 0, 'Kostenlos und unverbindlich.'), + leistung('Beratung', 60, 10, 'fest', 9000), + leistung('Folgetermin', 30, 5, 'fest', 4500), + ], + gastFelder: { nachricht: 'optional' }, + recht: { gaesteSind: 'gemischt' }, + }), + + werkstatt: vorlage('werkstatt', 'Werkstatt und Reifenservice', 'Zwei Arbeitsplätze, Termine nach Platz; Kennzeichen wird mit abgefragt.', { + ressourcen: [ + platz('Platz 1', wochenplan({ '1-5': [['08:00', '12:00'], ['13:00', '17:00']], '6': [['08:00', '12:00']] })), + platz('Platz 2', wochenplan({ '1-5': [['08:00', '12:00'], ['13:00', '17:00']], '6': [['08:00', '12:00']] })), + ], + leistungen: [ + leistung('Reifenwechsel', 45, 15, 'ab', 4900), + leistung('Ölwechsel', 45, 15, 'ab', 7900), + leistung('Inspektion', 120, 15, 'ab', 15000), + leistung('Bremsencheck', 30, 5, 'fest', 2900), + ], + regeln: { raster: 30, vorlaufMin: 240, horizontTage: 90 }, + gastFelder: { telefon: 'pflicht', zusatz: [{ key: 'kennzeichen', label: 'Kennzeichen', typ: 'text', pflicht: true }] }, + recht: { gaesteSind: 'gemischt' }, + }), + + handwerk: vorlage('handwerk', 'Handwerk (Besichtigung vor Ort)', 'Besichtigung und Aufmaß als Anfrage; du bestätigst den Termin und planst die Anfahrt.', { + ressourcen: [person('Team', wochenplan({ '1-5': [['08:00', '12:00'], ['13:00', '16:00']] }))], + leistungen: [ + leistung('Besichtigung vor Ort', 60, 30, 'keiner', 0, 'Wir schauen uns die Aufgabe vor Ort an.'), + leistung('Aufmaß', 90, 30, 'keiner', 0), + leistung('Telefonische Beratung', 30, 0, 'keiner', 0), + ], + buchungsart: 'anfrage', + regeln: { raster: 30, vorlaufMin: 1440, horizontTage: 90 }, + gastFelder: { telefon: 'pflicht', nachricht: 'pflicht' }, + recht: { gaesteSind: 'gemischt' }, + }), + + tattoo: vorlage('tattoo', 'Tattoo und Piercing', 'Beratung und Sessions als Anfrage; Motiv, Größe und Stelle schreibt der Gast dazu.', { + ressourcen: [person('Tätowierer/in', wochenplan({ '2-6': [['11:00', '18:00']] }))], + leistungen: [ + leistung('Beratungsgespräch', 30, 10, 'keiner', 0), + leistung('Kleines Tattoo', 120, 30, 'ab', 8000), + leistung('Tattoo-Session', 240, 30, 'ab', 25000), + ], + buchungsart: 'anfrage', + regeln: { raster: 30, vorlaufMin: 1440, horizontTage: 120, absageBisStd: 48 }, + gastFelder: { telefon: 'pflicht', nachricht: 'pflicht' }, + }), + + fotograf: vorlage('fotograf', 'Fotograf', 'Kennenlerngespräch und Shootings als Anfrage, mit längerem Vorlauf.', { + ressourcen: [person('Fotograf/in', wochenplan({ '1-5': [['10:00', '18:00']], '6': [['10:00', '14:00']] }))], + leistungen: [ + leistung('Kennenlerngespräch', 30, 10, 'keiner', 0), + leistung('Porträt-Shooting', 90, 30, 'ab', 15000), + leistung('Familien-Shooting', 120, 30, 'ab', 25000), + leistung('Business-Porträt', 60, 15, 'ab', 12000), + ], + buchungsart: 'anfrage', + regeln: { raster: 30, vorlaufMin: 2880, horizontTage: 120, absageBisStd: 48 }, + gastFelder: { nachricht: 'optional' }, + }), + + hundesalon: vorlage('hundesalon', 'Hundesalon', 'Pflege nach Größe des Hundes; Name und Rasse werden mit abgefragt.', { + ressourcen: [person('Hundefriseur/in', wochenplan({ '2-5': MITTAG, '6': [['09:00', '13:00']] }))], + leistungen: [ + leistung('Kleiner Hund', 60, 10, 'ab', 3500), + leistung('Mittelgroßer Hund', 90, 10, 'ab', 5500), + leistung('Großer Hund', 120, 15, 'ab', 7500), + leistung('Krallen schneiden', 15, 5, 'fest', 1000), + ], + regeln: { vorlaufMin: 240 }, + gastFelder: { zusatz: [{ key: 'hund', label: 'Name und Rasse des Hundes', typ: 'text', pflicht: true }] }, + }), + + sonstiges: vorlage('sonstiges', 'Sonstiges', 'Neutrale Vorlage mit zwei Terminlängen, die du frei umbenennst.', { + ressourcen: [person('Team', BUERO)], + leistungen: [ + leistung('Termin (30 Minuten)', 30, 0, 'keiner', 0), + leistung('Termin (60 Minuten)', 60, 0, 'keiner', 0), + ], + }), +}) + +/** [{ key, name, beschreibung }] in fester Reihenfolge, fuer die Auswahl beim ersten Oeffnen. */ +export function vorlagenListe() { + return Object.values(VORLAGEN).map(({ key, name, beschreibung }) => ({ key, name, beschreibung })) +} + +/** + * Gueltige Einstellung aus einer Vorlage + den Betriebsangaben (name, email, telefon, adresse, kleinunternehmer, + * impressumUrl, datenschutzUrl, agbUrl, websiteUrl, optional bundesland). Neue IDs und Feed-Token bei jedem Aufruf. + * Unbekannte Vorlage oder unbrauchbare Betriebsangaben (kein Name/keine E-Mail) werfen; err.fehler enthaelt dann die Feldfehler. + */ +export function ausVorlage(key, betrieb, now) { + if (typeof key !== 'string' || !Object.hasOwn(VORLAGEN, key)) throw new Error(`Unbekannte Vorlage: ${String(key).slice(0, 40)}`) + const v = VORLAGEN[key] + const b = betrieb || {} + const ressourcen = v.ressourcen.map((r) => ({ id: neueId('r'), name: r.name, typ: r.typ, aktiv: true, plan: structuredClone(r.plan) })) + const eingabe = { + vorlage: key, + betrieb: { + name: b.name, + email: b.email, + telefon: b.telefon, + adresse: b.adresse, + kleinunternehmer: b.kleinunternehmer === true, + impressumUrl: b.impressumUrl, + datenschutzUrl: b.datenschutzUrl, + agbUrl: b.agbUrl, + websiteUrl: b.websiteUrl, + }, + anrede: 'sie', + bundesland: b.bundesland, + ressourcen, + leistungen: v.leistungen.map((l) => ({ ...l, id: neueId('s'), buchungsart: v.buchungsart, aktiv: true, ressourcen: ressourcen.map((r) => r.id) })), + regeln: { ...v.regeln, maxOffeneJeEmail: 3, personWaehlbar: true }, + gastFelder: structuredClone(v.gastFelder), + erinnerung: { anbieten: true, stundenVorher: 24 }, + storno: { text: v.storno }, + recht: { ...v.recht }, + zahlung: { modus: 'vorort', vorOrtArten: [...ZAHLARTEN] }, + } + const ergebnis = normalisiereEinstellung(eingabe, { now }) + if (ergebnis.fehler) { + const err = new Error('Die Vorlage ergibt mit diesen Betriebsangaben keine gültige Einstellung.') + err.fehler = ergebnis.fehler + throw err + } + return ergebnis.einstellung +} diff --git a/test/termineEinstellungen.test.js b/test/termineEinstellungen.test.js new file mode 100644 index 0000000..89f42e0 --- /dev/null +++ b/test/termineEinstellungen.test.js @@ -0,0 +1,788 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + neueId, + normalisiereEinstellung, + oeffentlicheEinstellung, + knopfText, + istVerbraucher, + brauchtZustimmungVorzeitig, + preisText, + preisHinweis, + zahlungText, +} from '../server/services/termine/einstellungen.js' +import { VORLAGEN, vorlagenListe, ausVorlage } from '../server/services/termine/vorlagen.js' +import { BUNDESLAENDER } from '../server/services/termine/feiertage.js' + +const NOW = new Date('2026-10-02T10:00:00.000Z') // Berlin: 2026-10-02 12:00 +const TAG = 86400000 + +const R1 = 'r_aaaaaaa1' +const R2 = 'r_aaaaaaa2' +const S1 = 's_bbbbbbb1' +const S2 = 's_bbbbbbb2' + +/** Kleinste gueltige Eingabe; alles andere kommt aus den Vorgaben. */ +const basis = () => ({ + betrieb: { name: 'Salon Müller', email: 'info@salon.de' }, + ressourcen: [{ id: R1, name: 'Anna', plan: { 1: [['09:00', '12:30'], ['13:30', '18:00']] } }], + leistungen: [{ id: S1, name: 'Damenschnitt', dauer: 45, ressourcen: [R1] }], +}) + +const pruefe = (eingabe, optionen = {}) => normalisiereEinstellung(eingabe, { now: NOW, ...optionen }) +const gueltig = (eingabe, optionen) => { + const erg = pruefe(eingabe, optionen) + assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler)) + return erg.einstellung +} +const fehlerVon = (eingabe, optionen) => { + const erg = pruefe(eingabe, optionen) + assert.equal(erg.einstellung, undefined) + assert.ok(erg.fehler && typeof erg.fehler === 'object') + return erg.fehler +} + +const BETRIEB = { + name: 'Salon Müller', + email: 'Info@Salon.de', + telefon: '06301 1234', + adresse: 'Hauptstr. 1, 67722 Winnweiler', + kleinunternehmer: false, + websiteUrl: 'https://salon.de', + impressumUrl: 'https://salon.de/impressum', + datenschutzUrl: 'https://salon.de/datenschutz', +} + +// ---------------------------------------------------------------- IDs + +test('neueId: Praefix, 8 Zeichen aus a-z0-9, unterschiedlich; unbekannter Praefix wirft', () => { + const ids = new Set() + for (const p of ['r', 's', 'a']) { + for (let i = 0; i < 50; i += 1) { + const id = neueId(p) + assert.match(id, new RegExp(`^${p}_[a-z0-9]{8}$`)) + ids.add(id) + } + } + assert.equal(ids.size, 150) + assert.throws(() => neueId('x')) + assert.throws(() => neueId()) +}) + +// ---------------------------------------------------------------- Vorlagen + +test('Vorlagen: neun Schluessel in fester Reihenfolge, Liste mit Name und Beschreibung', () => { + const keys = ['salon', 'kosmetik', 'beratung', 'werkstatt', 'handwerk', 'tattoo', 'fotograf', 'hundesalon', 'sonstiges'] + assert.deepEqual(Object.keys(VORLAGEN), keys) + const liste = vorlagenListe() + assert.deepEqual(liste.map((v) => v.key), keys) + for (const eintrag of liste) { + assert.deepEqual(Object.keys(eintrag).sort(), ['beschreibung', 'key', 'name']) + assert.ok(eintrag.name.length > 2 && eintrag.beschreibung.length > 10) + } +}) + +test('Vorlagen: jede ergibt eine gueltige, stabile Einstellung', () => { + for (const key of Object.keys(VORLAGEN)) { + const e = ausVorlage(key, BETRIEB, NOW) + assert.equal(e.vorlage, key) + assert.equal(e.betrieb.name, 'Salon Müller') + assert.equal(e.betrieb.email, 'info@salon.de') + assert.equal(e.betrieb.websiteUrl, 'https://salon.de/') + assert.ok(e.leistungen.length >= 2 && e.leistungen.length <= 5, `${key}: ${e.leistungen.length} Leistungen`) + assert.ok(e.ressourcen.length >= 1) + assert.ok(e.leistungen.every((l) => l.aktiv && l.ressourcen.length >= 1)) + assert.ok(e.ressourcen.every((r) => Object.keys(r.plan).length >= 1 && r.feedToken.length === 43)) + assert.equal(e.zahlung.modus, 'vorort') + // erneutes Normalisieren aendert nichts (Token und Storno-Version bleiben) + const nochmal = gueltig(e, { bisher: e }) + assert.deepEqual(nochmal, e, key) + } +}) + +test('Vorlagen: Anfrage bei tattoo/handwerk/fotograf, sonst sofort; Werkstatt mit zwei Plaetzen', () => { + for (const key of Object.keys(VORLAGEN)) { + const e = ausVorlage(key, BETRIEB, NOW) + const erwartet = ['tattoo', 'handwerk', 'fotograf'].includes(key) ? 'anfrage' : 'sofort' + assert.ok(e.leistungen.every((l) => l.buchungsart === erwartet), key) + } + const werkstatt = ausVorlage('werkstatt', BETRIEB, NOW) + assert.equal(werkstatt.ressourcen.length, 2) + assert.ok(werkstatt.ressourcen.every((r) => r.typ === 'platz')) +}) + +test('Vorlagen: Stornotext Variante 1 mit Frist aus den Regeln und 50 Prozent, Widerrufssatz bleibt', () => { + for (const key of Object.keys(VORLAGEN)) { + const e = ausVorlage(key, BETRIEB, NOW) + 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.includes('berechnen wir für den freigehaltenen Termin 50 % des Preises.'), key) + assert.ok(text.includes('Dem Kunden bleibt der Nachweis vorbehalten, dass uns kein oder ein geringerer Aufwand entstanden ist.'), key) + assert.ok(text.endsWith('Ihr gesetzliches Widerrufsrecht bleibt unberührt.'), key) + assert.ok(!text.includes('{{'), key) + assert.equal(e.storno.version, '2026-10-02-1') + } +}) + +test('Vorlagen: zwei Aufrufe ergeben verschiedene IDs und Feed-Token; Kleinunternehmer wird uebernommen', () => { + const a = ausVorlage('salon', BETRIEB, NOW) + const b = ausVorlage('salon', { ...BETRIEB, kleinunternehmer: true }, NOW) + assert.notEqual(a.ressourcen[0].id, b.ressourcen[0].id) + assert.notEqual(a.ressourcen[0].feedToken, b.ressourcen[0].feedToken) + assert.equal(a.betrieb.kleinunternehmer, false) + assert.equal(b.betrieb.kleinunternehmer, true) +}) + +test('Vorlagen: unbekannter Schluessel wirft, fehlende Betriebsangaben werfen mit Feldfehlern', () => { + assert.throws(() => ausVorlage('gibtsnicht', BETRIEB, NOW), /Unbekannte Vorlage/) + assert.throws(() => ausVorlage('__proto__', BETRIEB, NOW), /Unbekannte Vorlage/) + assert.throws(() => ausVorlage('salon', {}, NOW), (err) => { + assert.ok(err.fehler['betrieb.name'] && err.fehler['betrieb.email']) + return true + }) +}) + +test('Vorlagen: VORLAGEN ist eingefroren', () => { + assert.throws(() => { VORLAGEN.salon.leistungen.push({}) }) + assert.throws(() => { VORLAGEN.neu = {} }) +}) + +// ---------------------------------------------------------------- Normalisieren: Grundlagen + +test('Vorgaben werden aufgefuellt', () => { + const e = gueltig(basis()) + assert.equal(e.schema, 1) + assert.equal(e.vorlage, null) + assert.deepEqual(e.betrieb, { + name: 'Salon Müller', email: 'info@salon.de', telefon: '', kleinunternehmer: false, adresse: '', + impressumUrl: null, datenschutzUrl: null, agbUrl: null, websiteUrl: null, + }) + assert.equal(e.anrede, 'sie') + assert.match(e.farbe, /^#[0-9a-f]{6}$/) + assert.ok(BUNDESLAENDER.includes(e.bundesland)) + assert.equal(e.feiertageGeschlossen, true) + assert.deepEqual(e.sonderoeffnungen, {}) + assert.deepEqual(e.abwesenheiten, []) + assert.deepEqual(e.regeln, { raster: 15, vorlaufMin: 120, horizontTage: 60, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true }) + assert.deepEqual(e.gastFelder, { telefon: 'optional', nachricht: 'aus', zusatz: [] }) + assert.deepEqual(e.erinnerung, { anbieten: true, stundenVorher: 24 }) + assert.deepEqual(e.storno, { text: '', version: '' }) + assert.deepEqual(e.recht, { gaesteSind: 'verbraucher', freizeit: false }) + assert.deepEqual(e.zahlung, { modus: 'vorort', vorOrtArten: ['bar', 'ec', 'kreditkarte'] }) + assert.equal(e.aufbewahrungTage, 90) + assert.deepEqual(e.leistungen[0], { + id: S1, name: 'Damenschnitt', beschreibung: '', dauer: 45, pufferNach: 0, preisArt: 'keiner', preisCent: 0, + buchungsart: 'sofort', ressourcen: [R1], aktiv: true, reihenfolge: 1, + }) + assert.deepEqual(e.ressourcen[0].plan, { 1: [['09:00', '12:30'], ['13:30', '18:00']] }) + assert.equal(e.ressourcen[0].typ, 'person') + assert.equal(e.ressourcen[0].aktiv, true) +}) + +test('Eingabe wird nicht veraendert; Nicht-Objekte werden abgelehnt', () => { + const eingabe = basis() + const kopie = structuredClone(eingabe) + gueltig(eingabe) + assert.deepEqual(eingabe, kopie) + for (const unsinn of [null, undefined, 'x', 5, [], true]) { + assert.deepEqual(Object.keys(fehlerVon(unsinn)), ['_']) + } +}) + +test('now ist Pflicht', () => { + assert.throws(() => normalisiereEinstellung(basis(), {}), /now/) + assert.throws(() => normalisiereEinstellung(basis()), /now/) + assert.throws(() => normalisiereEinstellung(basis(), { now: 'heute' }), /now/) +}) + +test('Pflichtfelder: Betriebsname und Betriebs-E-Mail', () => { + const f = fehlerVon({}) + assert.ok(f['betrieb.name']) + assert.ok(f['betrieb.email']) + assert.ok(fehlerVon({ betrieb: { name: ' ', email: 'a@b.de' } })['betrieb.name']) + assert.ok(fehlerVon({ betrieb: { name: 'X' } })['betrieb.email']) +}) + +test('Betriebsname wird auf 80 Zeichen gekuerzt, Steuerzeichen fliegen raus', () => { + const e = gueltig({ ...basis(), betrieb: { name: ` Sa\u0000lon\n\tMüller\u202E ${'x'.repeat(100)}`, email: 'a@b.de' } }) + assert.ok(e.betrieb.name.startsWith('Salon Müller xxx')) + assert.equal([...e.betrieb.name].length, 80) + assert.ok(!/[\u0000-\u001f\u202E]/.test(e.betrieb.name)) +}) + +test('E-Mail: genau ein Postfach, klein und getrimmt', () => { + assert.equal(gueltig({ ...basis(), betrieb: { name: 'X', email: ' Info@Salon.DE ' } }).betrieb.email, 'info@salon.de') + for (const schlecht of ['a@b.de, c@d.de', 'a@b.de;c@d.de', '', '"a"@b.de', 'a b@c.de', 'ohne-at.de', 'a@b', 'a@@b.de', '@b.de', 'a@.de', `${'x'.repeat(250)}@b.de`]) { + assert.ok(fehlerVon({ ...basis(), betrieb: { name: 'X', email: schlecht } })['betrieb.email'], schlecht) + } +}) + +test('URLs: nur https, kein javascript:, keine Zugangsdaten, hoechstens 300 Zeichen', () => { + const mit = (feld, wert) => ({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', [feld]: wert } }) + const f = fehlerVon(mit('impressumUrl', 'javascript:alert(1)')) + assert.ok(f['betrieb.impressumUrl']) + for (const feld of ['impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl']) { + for (const schlecht of ['http://salon.de', 'javascript:alert(1)', 'java\nscript:alert(1)', 'data:text/html,x', 'https://user:pw@salon.de', 'https://user@salon.de', '//salon.de', 'salon.de', `https://salon.de/${'a'.repeat(300)}`, 'https://sa lon.de', 5]) { + assert.ok(fehlerVon(mit(feld, schlecht))[`betrieb.${feld}`], `${feld}: ${schlecht}`) + } + assert.equal(gueltig(mit(feld, 'https://salon.de/impressum')).betrieb[feld], 'https://salon.de/impressum') + assert.equal(gueltig(mit(feld, '')).betrieb[feld], null) + assert.equal(gueltig(mit(feld, null)).betrieb[feld], null) + } +}) + +test('Farbe, Anrede, Bundesland werden geprueft', () => { + assert.equal(gueltig({ ...basis(), farbe: '#AABBCC' }).farbe, '#aabbcc') + for (const schlecht of ['red', '#abc', '#12345g', 'ffffff', 5]) assert.ok(fehlerVon({ ...basis(), farbe: schlecht }).farbe, String(schlecht)) + assert.equal(gueltig({ ...basis(), anrede: 'du' }).anrede, 'du') + assert.ok(fehlerVon({ ...basis(), anrede: 'ihr' }).anrede) + for (const land of BUNDESLAENDER) assert.equal(gueltig({ ...basis(), bundesland: land }).bundesland, land) + for (const schlecht of ['XX', 'rp', 'Bayern', 'BUND', 'ALL', 5]) assert.ok(fehlerVon({ ...basis(), bundesland: schlecht }).bundesland, String(schlecht)) + assert.ok(fehlerVon({ ...basis(), feiertageGeschlossen: 'ja' }).feiertageGeschlossen) +}) + +// ---------------------------------------------------------------- Wochenplan + +test('Fenster: Minuten muessen Vielfache von 5 sein (09:07 => Fehler am Plan-Pfad)', () => { + const eingabe = basis() + eingabe.ressourcen[0].plan = { 1: [['09:07', '12:00']] } + assert.ok(fehlerVon(eingabe)['ressourcen.0.plan.1']) +}) + +test('Fenster: Format, Bereich, Ende nach Beginn, 24:00 nur als Ende', () => { + const mit = (fenster) => { const e = basis(); e.ressourcen[0].plan = { 3: fenster }; return e } + for (const schlecht of [ + [['9:00', '12:00']], [['09:00', '12:00:00']], [['25:00', '26:00']], [['09:60', '12:00']], [['12:00', '12:00']], [['12:00', '09:00']], + [['24:00', '24:00']], [['09:00']], [['09:00', '12:00', '13:00']], ['09:00-12:00'], [[9, 12]], 'montags', [['09:00', '24:05']], + ]) { + assert.ok(fehlerVon(mit(schlecht))['ressourcen.0.plan.3'], JSON.stringify(schlecht)) + } + assert.deepEqual(gueltig(mit([['18:00', '24:00']])).ressourcen[0].plan, { 3: [['18:00', '24:00']] }) + assert.deepEqual(gueltig(mit([['00:00', '00:05']])).ressourcen[0].plan, { 3: [['00:00', '00:05']] }) +}) + +test('Fenster: Ueberlappung und falsche Reihenfolge => Fehler am Pfad ressourcen.0.plan.1', () => { + const mit = (fenster) => { const e = basis(); e.ressourcen[0].plan = { 1: [['08:00', '08:30']], 2: fenster }; return e } + const ueberlappt = basis() + ueberlappt.ressourcen[0].plan = { 1: [['09:00', '12:00'], ['11:00', '15:00']] } + assert.match(fehlerVon(ueberlappt)['ressourcen.0.plan.1'], /überlappen/) + const unsortiert = basis() + unsortiert.ressourcen[0].plan = { 1: [['13:00', '15:00'], ['09:00', '12:00']] } + assert.match(fehlerVon(unsortiert)['ressourcen.0.plan.1'], /aufsteigend/) + // direkt aneinander ist keine Ueberlappung + assert.deepEqual(gueltig(mit([['09:00', '12:00'], ['12:00', '15:00']])).ressourcen[0].plan[2], [['09:00', '12:00'], ['12:00', '15:00']]) +}) + +test('Wochenplan: nur Wochentage 1 bis 7; leere Tage entfallen', () => { + const e = basis() + e.ressourcen[0].plan = { 0: [['09:00', '10:00']] } + assert.ok(fehlerVon(e)['ressourcen.0.plan.0']) + e.ressourcen[0].plan = { 8: [['09:00', '10:00']] } + assert.ok(fehlerVon(e)['ressourcen.0.plan.8']) + e.ressourcen[0].plan = { 1: [], 2: [['09:00', '10:00']] } + assert.deepEqual(gueltig(e).ressourcen[0].plan, { 2: [['09:00', '10:00']] }) + e.ressourcen[0].plan = 'immer' + assert.ok(fehlerVon(e)['ressourcen.0.plan']) +}) + +// ---------------------------------------------------------------- Ressourcen und Leistungen + +test('IDs: Format, Praefix, Eindeutigkeit; Eintraege ohne ID bekommen eine', () => { + const e = basis() + e.ressourcen.push({ name: 'Ben' }) + e.leistungen.push({ name: 'Herrenschnitt', dauer: 30, ressourcen: [R1] }) + const ok = gueltig(e) + assert.match(ok.ressourcen[1].id, /^r_[a-z0-9]{8}$/) + assert.match(ok.leistungen[1].id, /^s_[a-z0-9]{8}$/) + + const falsch = basis() + falsch.ressourcen[0].id = 'x_aaaaaaa1' + assert.ok(fehlerVon(falsch)['ressourcen.0.id']) + const zuKurz = basis() + zuKurz.leistungen[0].id = 's_abc' + assert.ok(fehlerVon(zuKurz)['leistungen.0.id']) + const grossbuchstaben = basis() + grossbuchstaben.leistungen[0].id = 's_BBBBBBB1' + assert.ok(fehlerVon(grossbuchstaben)['leistungen.0.id']) + const doppelt = basis() + doppelt.ressourcen.push({ id: R1, name: 'Doppelt' }) + assert.ok(fehlerVon(doppelt)['ressourcen.1.id']) + const doppelteLeistung = basis() + doppelteLeistung.leistungen.push({ id: S1, name: 'Nochmal', dauer: 30, ressourcen: [R1] }) + assert.ok(fehlerVon(doppelteLeistung)['leistungen.1.id']) +}) + +test('Ressource: Name Pflicht, Typ person|platz, Reihenfolge der Liste bleibt', () => { + const e = basis() + e.ressourcen = [{ id: R1, name: '' }] + e.leistungen = [] + assert.ok(fehlerVon(e)['ressourcen.0.name']) + e.ressourcen = [{ id: R1, name: 'A', typ: 'zimmer' }] + assert.ok(fehlerVon(e)['ressourcen.0.typ']) + e.ressourcen = [{ id: R2, name: 'Platz 2', typ: 'platz' }, { id: R1, name: 'Platz 1', typ: 'platz' }] + assert.deepEqual(gueltig(e).ressourcen.map((r) => r.id), [R2, R1]) +}) + +test('Leistung: Pflichtfelder, Dauer und Pause in 5-Minuten-Schritten, Summe hoechstens 500', () => { + const mit = (aenderung) => { const e = basis(); Object.assign(e.leistungen[0], aenderung); return e } + assert.ok(fehlerVon(mit({ name: '' }))['leistungen.0.name']) + assert.ok(fehlerVon(mit({ dauer: undefined }))['leistungen.0.dauer']) + for (const dauer of [0, 4, 7, 485, 481, -5, 30.5, '30']) assert.ok(fehlerVon(mit({ dauer }))['leistungen.0.dauer'], String(dauer)) + for (const dauer of [5, 480, 45]) assert.equal(gueltig(mit({ dauer })).leistungen[0].dauer, dauer) + for (const pufferNach of [-5, 3, 125, 121, '5']) assert.ok(fehlerVon(mit({ pufferNach }))['leistungen.0.pufferNach'], String(pufferNach)) + for (const pufferNach of [0, 5, 120]) assert.equal(gueltig(mit({ pufferNach })).leistungen[0].pufferNach, pufferNach) + assert.ok(fehlerVon(mit({ dauer: 480, pufferNach: 25 }))['leistungen.0.dauer']) + assert.equal(gueltig(mit({ dauer: 480, pufferNach: 20 })).leistungen[0].pufferNach, 20) +}) + +test('Leistung: Preis, Preisart und Buchungsart', () => { + const mit = (aenderung) => { const e = basis(); Object.assign(e.leistungen[0], aenderung); return e } + assert.ok(fehlerVon(mit({ preisArt: 'gratis' }))['leistungen.0.preisArt']) + assert.ok(fehlerVon(mit({ buchungsart: 'online' }))['leistungen.0.buchungsart']) + for (const preisCent of [-1, 1000001, 4.5, '45']) assert.ok(fehlerVon(mit({ preisArt: 'fest', preisCent }))['leistungen.0.preisCent'], String(preisCent)) + const fest = gueltig(mit({ preisArt: 'fest', preisCent: 4500 })).leistungen[0] + assert.equal(fest.preisCent, 4500) + assert.equal(gueltig(mit({ preisArt: 'ab', preisCent: 1000000 })).leistungen[0].preisCent, 1000000) + // ohne Preis wird die Zahl nicht mitgeschleppt + assert.equal(gueltig(mit({ preisArt: 'keiner', preisCent: 999 })).leistungen[0].preisCent, 0) + assert.equal(gueltig(mit({ buchungsart: 'anfrage' })).leistungen[0].buchungsart, 'anfrage') +}) + +test('Leistung: Beschreibung gekuerzt, Reihenfolge folgt der Liste', () => { + const e = basis() + e.leistungen[0].beschreibung = 'y'.repeat(500) + e.leistungen.push({ id: S2, name: 'Zweite', dauer: 15, ressourcen: [R1] }) + const ok = gueltig(e) + assert.equal([...ok.leistungen[0].beschreibung].length, 300) + assert.deepEqual(ok.leistungen.map((l) => l.reihenfolge), [1, 2]) +}) + +test('Leistung ohne aktive Ressource ist abgelehnt (aktiv, inaktiv, leer, unbekannt)', () => { + const ohne = basis() + ohne.leistungen[0].ressourcen = [] + assert.ok(fehlerVon(ohne)['leistungen.0.ressourcen']) + + const inaktiv = basis() + inaktiv.ressourcen[0].aktiv = false + assert.ok(fehlerVon(inaktiv)['leistungen.0.ressourcen']) + + const unbekannt = basis() + unbekannt.leistungen[0].ressourcen = ['r_zzzzzzz9'] + assert.ok(fehlerVon(unbekannt)['leistungen.0.ressourcen']) + + const gemischt = basis() + gemischt.ressourcen.push({ id: R2, name: 'Ben', aktiv: false }) + gemischt.leistungen[0].ressourcen = [R2, R1, R1] + assert.deepEqual(gueltig(gemischt).leistungen[0].ressourcen, [R2, R1]) + + // eine inaktive Leistung darf ohne aktive Ressource bleiben + const ruhend = basis() + ruhend.leistungen[0].aktiv = false + ruhend.leistungen[0].ressourcen = [] + assert.equal(gueltig(ruhend).leistungen[0].aktiv, false) +}) + +// ---------------------------------------------------------------- Entfernen mit kuenftigen Terminen + +test('Entfernen einer Leistung mit kuenftigen Terminen => Fehler mit Anzahl', () => { + const bisher = gueltig(basis()) + const neu = basis() + neu.leistungen = [] + const f = fehlerVon(neu, { bisher, kuenftigeTermine: { leistungen: { [S1]: 3 }, ressourcen: {} } }) + assert.equal(f[`leistungen.${S1}`], 'Für diese Leistung gibt es noch 3 künftige Termine. Deaktiviere sie stattdessen.') + const einzahl = fehlerVon(neu, { bisher, kuenftigeTermine: { leistungen: { [S1]: 1 }, ressourcen: {} } }) + assert.equal(einzahl[`leistungen.${S1}`], 'Für diese Leistung gibt es noch 1 künftigen Termin. Deaktiviere sie stattdessen.') + // ohne kuenftige Termine darf sie weg, ebenso ohne Angabe + assert.deepEqual(gueltig(neu, { bisher, kuenftigeTermine: { leistungen: { [S1]: 0 }, ressourcen: {} } }).leistungen, []) + assert.deepEqual(gueltig(neu, { bisher }).leistungen, []) + assert.deepEqual(gueltig(neu, { bisher, kuenftigeTermine: { leistungen: { [S2]: 9 } } }).leistungen, []) +}) + +test('Entfernen einer Ressource mit kuenftigen Terminen => Fehler mit Anzahl (Person und Platz)', () => { + const bisher = gueltig(basis()) + const neu = basis() + neu.ressourcen = [] + neu.leistungen[0].aktiv = false + neu.leistungen[0].ressourcen = [] + const f = fehlerVon(neu, { bisher, kuenftigeTermine: { leistungen: {}, ressourcen: { [R1]: 4 } } }) + assert.equal(f[`ressourcen.${R1}`], 'Für diese Person gibt es noch 4 künftige Termine. Deaktiviere sie stattdessen.') + + const platzBasis = basis() + platzBasis.ressourcen[0].typ = 'platz' + const platzBisher = gueltig(platzBasis) + const f2 = fehlerVon(neu, { bisher: platzBisher, kuenftigeTermine: { ressourcen: { [R1]: 2 } } }) + assert.equal(f2[`ressourcen.${R1}`], 'Für diesen Platz gibt es noch 2 künftige Termine. Deaktiviere ihn stattdessen.') + + assert.equal(gueltig(neu, { bisher, kuenftigeTermine: { leistungen: {}, ressourcen: { [R1]: 0 } } }).ressourcen.length, 0) +}) + +test('Deaktivieren ist trotz kuenftiger Termine erlaubt', () => { + const bisher = gueltig(basis()) + const neu = basis() + neu.leistungen[0].aktiv = false + neu.ressourcen.push({ id: R2, name: 'Ben' }) + neu.ressourcen[0].aktiv = false + neu.leistungen[0].ressourcen = [R2] + const e = gueltig(neu, { bisher, kuenftigeTermine: { leistungen: { [S1]: 5 }, ressourcen: { [R1]: 5 } } }) + assert.equal(e.leistungen[0].aktiv, false) + assert.equal(e.ressourcen[0].aktiv, false) +}) + +// ---------------------------------------------------------------- Feed-Token + +test('feedToken: Client-Wert wird ignoriert, bisheriger bleibt, neue Ressourcen bekommen einen', () => { + const eins = gueltig({ ...basis(), ressourcen: [{ ...basis().ressourcen[0], feedToken: 'vom-client' }] }) + assert.notEqual(eins.ressourcen[0].feedToken, 'vom-client') + assert.match(eins.ressourcen[0].feedToken, /^[A-Za-z0-9_-]{43}$/) + + const zwei = basis() + zwei.ressourcen[0].feedToken = 'vom-client' + zwei.ressourcen.push({ id: R2, name: 'Ben', feedToken: eins.ressourcen[0].feedToken }) + const nachher = gueltig(zwei, { bisher: eins }) + assert.equal(nachher.ressourcen[0].feedToken, eins.ressourcen[0].feedToken) + assert.match(nachher.ressourcen[1].feedToken, /^[A-Za-z0-9_-]{43}$/) + assert.notEqual(nachher.ressourcen[1].feedToken, eins.ressourcen[0].feedToken) +}) + +// ---------------------------------------------------------------- Abwesenheiten, Sonderoeffnungen + +test('Abwesenheiten: von <= bis, gueltige Tage, bekannte Ressource oder null', () => { + const mit = (a) => ({ ...basis(), abwesenheiten: [a] }) + const ok = gueltig(mit({ ressourceId: null, von: '2026-12-27', bis: '2026-12-31', titel: 'Betriebsurlaub' })).abwesenheiten + assert.equal(ok.length, 1) + assert.match(ok[0].id, /^a_[a-z0-9]{8}$/) + assert.deepEqual({ ...ok[0], id: 'x' }, { id: 'x', ressourceId: null, von: '2026-12-27', bis: '2026-12-31', titel: 'Betriebsurlaub' }) + assert.equal(gueltig(mit({ ressourceId: R1, von: '2026-12-27', bis: '2026-12-27' })).abwesenheiten[0].ressourceId, R1) + assert.ok(fehlerVon(mit({ von: '2026-12-31', bis: '2026-12-27' }))['abwesenheiten.0.bis']) + assert.ok(fehlerVon(mit({ von: '2026-02-30', bis: '2026-03-01' }))['abwesenheiten.0.von']) + assert.ok(fehlerVon(mit({ von: '27.12.2026', bis: '2026-12-31' }))['abwesenheiten.0.von']) + assert.ok(fehlerVon(mit({ von: '2026-12-01' }))['abwesenheiten.0.bis']) + assert.ok(fehlerVon(mit({ ressourceId: 'r_zzzzzzz9', von: '2026-12-27', bis: '2026-12-31' }))['abwesenheiten.0.ressourceId']) + assert.ok(fehlerVon(mit({ id: 'r_aaaaaaa1', von: '2026-12-27', bis: '2026-12-31' }))['abwesenheiten.0.id']) +}) + +test('Sonderoeffnungen: gueltiger Tag, Fenster wie im Plan, leere Liste = geschlossen', () => { + const mit = (s) => ({ ...basis(), sonderoeffnungen: s }) + assert.deepEqual(gueltig(mit({ '2026-12-24': [['09:00', '13:00']], '2026-12-31': [] })).sonderoeffnungen, { + '2026-12-24': [['09:00', '13:00']], '2026-12-31': [], + }) + assert.deepEqual(gueltig(mit({ '2026-12-24': [['09:00', '24:00']] })).sonderoeffnungen['2026-12-24'], [['09:00', '24:00']]) + assert.ok(fehlerVon(mit({ '2026-13-01': [['09:00', '13:00']] }))['sonderoeffnungen.2026-13-01']) + assert.ok(fehlerVon(mit({ 'morgen': [['09:00', '13:00']] }))['sonderoeffnungen.morgen']) + assert.ok(fehlerVon(mit({ '2026-12-24': [['09:03', '13:00']] }))['sonderoeffnungen.2026-12-24']) + assert.ok(fehlerVon(mit({ '2026-12-24': [['09:00', '13:00'], ['12:00', '14:00']] }))['sonderoeffnungen.2026-12-24']) + assert.ok(fehlerVon(mit([['09:00', '13:00']]))['sonderoeffnungen']) +}) + +// ---------------------------------------------------------------- Regeln, Gastfelder, Erinnerung, Zahlung + +test('Regeln: Wertebereiche', () => { + const mit = (r) => ({ ...basis(), regeln: r }) + assert.deepEqual(gueltig(mit({ raster: 30, vorlaufMin: 0, horizontTage: 365, absageBisStd: 336, maxOffeneJeEmail: 10, personWaehlbar: false })).regeln, + { raster: 30, vorlaufMin: 0, horizontTage: 365, absageBisStd: 336, maxOffeneJeEmail: 10, personWaehlbar: false }) + for (const raster of [5, 10, 15, 20, 30, 60]) assert.equal(gueltig(mit({ raster })).regeln.raster, raster) + for (const raster of [0, 1, 25, 45, 90, '15']) assert.ok(fehlerVon(mit({ raster }))['regeln.raster'], String(raster)) + for (const vorlaufMin of [-1, 20161, 1.5]) assert.ok(fehlerVon(mit({ vorlaufMin }))['regeln.vorlaufMin'], String(vorlaufMin)) + assert.equal(gueltig(mit({ vorlaufMin: 20160 })).regeln.vorlaufMin, 20160) + for (const horizontTage of [0, 366, -1]) assert.ok(fehlerVon(mit({ horizontTage }))['regeln.horizontTage'], String(horizontTage)) + assert.equal(gueltig(mit({ horizontTage: 1 })).regeln.horizontTage, 1) + for (const absageBisStd of [-1, 337]) assert.ok(fehlerVon(mit({ absageBisStd }))['regeln.absageBisStd'], String(absageBisStd)) + assert.equal(gueltig(mit({ absageBisStd: 0 })).regeln.absageBisStd, 0) + for (const maxOffeneJeEmail of [0, 11]) assert.ok(fehlerVon(mit({ maxOffeneJeEmail }))['regeln.maxOffeneJeEmail'], String(maxOffeneJeEmail)) + assert.ok(fehlerVon(mit({ personWaehlbar: 'ja' }))['regeln.personWaehlbar']) +}) + +test('Gastfelder: aus|optional|pflicht, bis zu 5 Zusatzfelder', () => { + const mit = (g) => ({ ...basis(), gastFelder: g }) + assert.deepEqual(gueltig(mit({ telefon: 'pflicht', nachricht: 'optional' })).gastFelder, { telefon: 'pflicht', nachricht: 'optional', zusatz: [] }) + assert.ok(fehlerVon(mit({ telefon: 'vielleicht' }))['gastFelder.telefon']) + assert.ok(fehlerVon(mit({ nachricht: 'an' }))['gastFelder.nachricht']) + + const zusatz = [ + { key: 'kennzeichen', label: 'Kennzeichen', typ: 'text', pflicht: true }, + { key: 'reifen', label: 'Reifen', typ: 'auswahl', optionen: ['Sommer', 'Winter', 'Sommer'], pflicht: false }, + ] + const ok = gueltig(mit({ zusatz })).gastFelder.zusatz + assert.deepEqual(ok, [ + { key: 'kennzeichen', label: 'Kennzeichen', typ: 'text', optionen: [], pflicht: true }, + { key: 'reifen', label: 'Reifen', typ: 'auswahl', optionen: ['Sommer', 'Winter'], pflicht: false }, + ]) + + const sechs = Array.from({ length: 6 }, (_, i) => ({ key: `feld${i}`, label: `Feld ${i}`, typ: 'text' })) + assert.ok(fehlerVon(mit({ zusatz: sechs }))['gastFelder.zusatz']) + assert.equal(gueltig(mit({ zusatz: sechs.slice(0, 5) })).gastFelder.zusatz.length, 5) + + for (const key of ['Kennzeichen', 'a-b', '', 'x'.repeat(21), 'ä', '__proto__', 'constructor', 'prototype']) { + assert.ok(fehlerVon(mit({ zusatz: [{ key, label: 'X', typ: 'text' }] }))['gastFelder.zusatz.0.key'], key) + } + assert.ok(fehlerVon(mit({ zusatz: [{ key: 'a', label: 'X' }, { key: 'a', label: 'Y' }] }))['gastFelder.zusatz.1.key']) + assert.ok(fehlerVon(mit({ zusatz: [{ key: 'a', label: '' }] }))['gastFelder.zusatz.0.label']) + assert.ok(fehlerVon(mit({ zusatz: [{ key: 'a', label: 'X', typ: 'datei' }] }))['gastFelder.zusatz.0.typ']) + assert.ok(fehlerVon(mit({ zusatz: [{ key: 'a', label: 'X', typ: 'auswahl', optionen: [] }] }))['gastFelder.zusatz.0.optionen']) + const elf = Array.from({ length: 11 }, (_, i) => `Option ${i}`) + assert.ok(fehlerVon(mit({ zusatz: [{ key: 'a', label: 'X', typ: 'auswahl', optionen: elf }] }))['gastFelder.zusatz.0.optionen']) + assert.equal(gueltig(mit({ zusatz: [{ key: 'a', label: 'X', typ: 'auswahl', optionen: elf.slice(0, 10) }] })).gastFelder.zusatz[0].optionen.length, 10) +}) + +test('Erinnerung, Recht, Aufbewahrung', () => { + for (const stundenVorher of [2, 12, 24, 48]) assert.equal(gueltig({ ...basis(), erinnerung: { stundenVorher } }).erinnerung.stundenVorher, stundenVorher) + for (const stundenVorher of [1, 6, 36, 72, '24']) assert.ok(fehlerVon({ ...basis(), erinnerung: { stundenVorher } })['erinnerung.stundenVorher'], String(stundenVorher)) + assert.equal(gueltig({ ...basis(), erinnerung: { anbieten: false } }).erinnerung.anbieten, false) + assert.ok(fehlerVon({ ...basis(), erinnerung: { anbieten: 'nein' } })['erinnerung.anbieten']) + + assert.deepEqual(gueltig({ ...basis(), recht: { gaesteSind: 'gemischt', freizeit: true } }).recht, { gaesteSind: 'gemischt', freizeit: true }) + assert.ok(fehlerVon({ ...basis(), recht: { gaesteSind: 'firmen' } })['recht.gaesteSind']) + assert.ok(fehlerVon({ ...basis(), recht: { freizeit: 1 } })['recht.freizeit']) + + for (const tage of [14, 365, 90]) assert.equal(gueltig({ ...basis(), aufbewahrungTage: tage }).aufbewahrungTage, tage) + for (const tage of [13, 366, 0, 90.5]) assert.ok(fehlerVon({ ...basis(), aufbewahrungTage: tage }).aufbewahrungTage, String(tage)) +}) + +test('Zahlung: nur vorort; online/beides => "Online-Zahlung kommt bald"; Zahlarten geprueft', () => { + const mit = (z) => ({ ...basis(), zahlung: z }) + assert.equal(fehlerVon(mit({ modus: 'online' }))['zahlung.modus'], 'Online-Zahlung kommt bald') + assert.equal(fehlerVon(mit({ modus: 'beides' }))['zahlung.modus'], 'Online-Zahlung kommt bald') + assert.ok(fehlerVon(mit({ modus: 'bitcoin' }))['zahlung.modus']) + assert.equal(gueltig(mit({ modus: 'vorort' })).zahlung.modus, 'vorort') + assert.deepEqual(gueltig(mit({ vorOrtArten: ['paypal', 'bar', 'bar', 'ueberweisung', 'ec', 'kreditkarte'] })).zahlung.vorOrtArten, + ['bar', 'ec', 'kreditkarte', 'ueberweisung', 'paypal']) + assert.deepEqual(gueltig(mit({ vorOrtArten: [] })).zahlung.vorOrtArten, []) + assert.ok(fehlerVon(mit({ vorOrtArten: ['bar', 'bitcoin'] }))['zahlung.vorOrtArten']) + assert.ok(fehlerVon(mit({ vorOrtArten: 'bar' }))['zahlung.vorOrtArten']) +}) + +// ---------------------------------------------------------------- Storno + +test('Storno: Version YYYY-MM-DD-n nur bei geaendertem Text (Tag aus now, Berlin)', () => { + const a = gueltig({ ...basis(), storno: { text: 'Absage bis 24 Stunden vorher.', version: 'vom-client' } }) + assert.deepEqual(a.storno, { text: 'Absage bis 24 Stunden vorher.', version: '2026-10-02-1' }) + + // unveraenderter Text: Version bleibt, auch wenn der Client eine andere schickt + const b = gueltig({ ...basis(), storno: { text: 'Absage bis 24 Stunden vorher.', version: '1999-01-01-9' } }, { bisher: a }) + assert.equal(b.storno.version, '2026-10-02-1') + + // geaenderter Text am selben Tag: n + 1 + const c = gueltig({ ...basis(), storno: { text: 'Absage bis 48 Stunden vorher.' } }, { bisher: b }) + assert.equal(c.storno.version, '2026-10-02-2') + + // naechster Tag: wieder 1; kurz nach Mitternacht Berlin zaehlt als neuer Tag + const d = gueltig({ ...basis(), storno: { text: 'Absage bis 12 Stunden vorher.' } }, { bisher: c, now: new Date('2026-10-02T22:30:00.000Z') }) + assert.equal(d.storno.version, '2026-10-03-1') + const e = gueltig({ ...basis(), storno: { text: 'Absage bis 6 Stunden vorher.' } }, { bisher: d, now: new Date('2026-10-03T10:00:00.000Z') }) + assert.equal(e.storno.version, '2026-10-03-2') + + // Text entfernt: Text leer, Version bleibt; spaeter neuer Text zaehlt weiter + const f = gueltig(basis(), { bisher: e, now: new Date('2026-10-03T11:00:00.000Z') }) + assert.deepEqual(f.storno, { text: '', version: '2026-10-03-2' }) + const g = gueltig({ ...basis(), storno: { text: 'Wieder da.' } }, { bisher: f, now: new Date('2026-10-03T12:00:00.000Z') }) + assert.equal(g.storno.version, '2026-10-03-3') +}) + +test('Storno: Zeilenumbrueche bleiben, andere Steuerzeichen nicht; auf 2000 Zeichen gekuerzt', () => { + const e = gueltig({ ...basis(), storno: { text: `Zeile 1\r\nZeile\u0007 2\n\n\n\nZeile 3 ${'x'.repeat(3000)}` } }) + assert.ok(e.storno.text.startsWith('Zeile 1\nZeile 2\n\nZeile 3 xxx')) + assert.equal([...e.storno.text].length, 2000) +}) + +// ---------------------------------------------------------------- Obergrenzen + +test('Obergrenzen: 20 Ressourcen, 50 Leistungen, 100 Abwesenheiten, 200 Sonderoeffnungen', () => { + const ressourcen = (n) => Array.from({ length: n }, (_, i) => ({ name: `R${i}` })) + assert.equal(gueltig({ ...basis(), ressourcen: ressourcen(20), leistungen: [] }).ressourcen.length, 20) + assert.ok(fehlerVon({ ...basis(), ressourcen: ressourcen(21), leistungen: [] }).ressourcen) + + const leistungen = (n) => Array.from({ length: n }, (_, i) => ({ name: `L${i}`, dauer: 30, ressourcen: [R1] })) + assert.equal(gueltig({ ...basis(), leistungen: leistungen(50) }).leistungen.length, 50) + assert.ok(fehlerVon({ ...basis(), leistungen: leistungen(51) }).leistungen) + + const abwesenheiten = (n) => Array.from({ length: n }, () => ({ von: '2026-12-01', bis: '2026-12-02' })) + assert.equal(gueltig({ ...basis(), abwesenheiten: abwesenheiten(100) }).abwesenheiten.length, 100) + assert.ok(fehlerVon({ ...basis(), abwesenheiten: abwesenheiten(101) }).abwesenheiten) + + const tage = (n) => Object.fromEntries(Array.from({ length: n }, (_, i) => [new Date(Date.UTC(2027, 0, 1 + i)).toISOString().slice(0, 10), [['09:00', '12:00']]])) + assert.equal(Object.keys(gueltig({ ...basis(), sonderoeffnungen: tage(200) }).sonderoeffnungen).length, 200) + assert.ok(fehlerVon({ ...basis(), sonderoeffnungen: tage(201) }).sonderoeffnungen) +}) + +test('Obergrenze: Gesamtgroesse hoechstens 200 000 Zeichen (Fehler unter "_")', () => { + const uhr = (min) => `${String(Math.floor(min / 60)).padStart(2, '0')}:${String(min % 60).padStart(2, '0')}` + // 144 Fenster je Tag (jeweils 5 Minuten, dazwischen 5 Minuten Luecke) + const tag = Array.from({ length: 144 }, (_, i) => [uhr(i * 10), uhr(i * 10 + 5)]) + const wochenplan = Object.fromEntries([1, 2, 3, 4, 5, 6, 7].map((n) => [n, tag])) + const ressourcen = (n) => Array.from({ length: n }, (_, i) => ({ name: `R${i}`, plan: wochenplan })) + + const zuGross = { ...basis(), ressourcen: ressourcen(20), leistungen: [] } + const f = fehlerVon(zuGross) + assert.ok(f._) + assert.deepEqual(Object.keys(f), ['_']) + + const noch = { ...basis(), ressourcen: ressourcen(8), leistungen: [] } + const e = gueltig(noch) + assert.ok(JSON.stringify(e).length <= 200000) + assert.ok(JSON.stringify(e).length > 100000) + + // Die Grenzen der Einzellisten zusammen bleiben darunter + const gross = basis() + gross.leistungen = Array.from({ length: 50 }, () => ({ name: 'N'.repeat(100), beschreibung: 'b'.repeat(300), dauer: 30, ressourcen: [R1] })) + gross.abwesenheiten = Array.from({ length: 100 }, () => ({ titel: 't'.repeat(80), von: '2026-12-01', bis: '2026-12-02' })) + gross.storno = { text: 's'.repeat(2000) } + assert.ok(JSON.stringify(gueltig(gross)).length < 200000) +}) + +// ---------------------------------------------------------------- Texte + +test('knopfText: verbindlich + Preis, verbindlich ohne Preis, Anfrage (und Bestaetigungsseite)', () => { + const mitPreis = { buchungsart: 'sofort', preisArt: 'fest', preisCent: 4500 } + const abPreis = { buchungsart: 'sofort', preisArt: 'ab', preisCent: 4500 } + const ohnePreis = { buchungsart: 'sofort', preisArt: 'keiner', preisCent: 0 } + const anfrage = { buchungsart: 'anfrage', preisArt: 'fest', preisCent: 4500 } + assert.equal(knopfText(mitPreis), 'Zahlungspflichtig buchen') + assert.equal(knopfText(abPreis), 'Zahlungspflichtig buchen') + assert.equal(knopfText(ohnePreis), 'Termin buchen') + assert.equal(knopfText(anfrage), 'Termin anfragen') + assert.equal(knopfText(mitPreis, { bestaetigen: true }), 'Zahlungspflichtig bestätigen') + assert.equal(knopfText(ohnePreis, { bestaetigen: true }), 'Termin bestätigen') + assert.equal(knopfText(anfrage, { bestaetigen: true }), 'Anfrage absenden') + assert.equal(knopfText(mitPreis, { bestaetigen: false }), 'Zahlungspflichtig buchen') + // Preis 0 gilt als kein Preis + assert.equal(knopfText({ buchungsart: 'sofort', preisArt: 'fest', preisCent: 0 }), 'Termin buchen') +}) + +test('istVerbraucher: nicht nur Unternehmer und keine Freizeitleistung', () => { + assert.equal(istVerbraucher({ recht: { gaesteSind: 'verbraucher', freizeit: false } }), true) + assert.equal(istVerbraucher({ recht: { gaesteSind: 'gemischt', freizeit: false } }), true) + assert.equal(istVerbraucher({ recht: { gaesteSind: 'unternehmer', freizeit: false } }), false) + assert.equal(istVerbraucher({ recht: { gaesteSind: 'verbraucher', freizeit: true } }), false) +}) + +test('brauchtZustimmungVorzeitig: verbindlich + Preis + Verbraucher + Beginn in weniger als 14 Tagen', () => { + const verbraucher = { recht: { gaesteSind: 'verbraucher', freizeit: false } } + const unternehmer = { recht: { gaesteSind: 'unternehmer', freizeit: false } } + const freizeit = { recht: { gaesteSind: 'verbraucher', freizeit: true } } + const mitPreis = { buchungsart: 'sofort', preisArt: 'fest', preisCent: 4500 } + const inTagen = (n) => new Date(NOW.getTime() + n * TAG) + + assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(3), NOW), true) + assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(20), NOW), false) + assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(14), NOW), false) + assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(inTagen(14).getTime() - 1), NOW), true) + assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(0), NOW), true) + assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...mitPreis, preisArt: 'ab' }, inTagen(3), NOW), true) + // Unternehmer, Freizeit, kein Preis, Anfrage: keine Zustimmung noetig + assert.equal(brauchtZustimmungVorzeitig(unternehmer, mitPreis, inTagen(3), NOW), false) + assert.equal(brauchtZustimmungVorzeitig(freizeit, mitPreis, inTagen(3), NOW), false) + assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...mitPreis, preisArt: 'keiner', preisCent: 0 }, inTagen(3), NOW), false) + assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...mitPreis, buchungsart: 'anfrage' }, inTagen(3), NOW), false) + // ungueltiger Beginn: lieber die Zustimmung verlangen + assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date('kaputt'), NOW), true) +}) + +test('preisText, preisHinweis, zahlungText', () => { + assert.equal(preisText({ preisArt: 'fest', preisCent: 4500 }), '45,00 €') + assert.equal(preisText({ preisArt: 'ab', preisCent: 4500 }), 'ab 45,00 €') + assert.equal(preisText({ preisArt: 'fest', preisCent: 4505 }), '45,05 €') + assert.equal(preisText({ preisArt: 'fest', preisCent: 5 }), '0,05 €') + assert.equal(preisText({ preisArt: 'fest', preisCent: 123456 }), '1.234,56 €') + assert.equal(preisText({ preisArt: 'fest', preisCent: 1000000 }), '10.000,00 €') + assert.equal(preisText({ preisArt: 'keiner', preisCent: 0 }), '') + assert.equal(preisText({ preisArt: 'keiner', preisCent: 4500 }), '') + assert.equal(preisText({ preisArt: 'fest', preisCent: 0 }), '') + + assert.equal(preisHinweis({ betrieb: { kleinunternehmer: false } }), 'inkl. USt') + assert.equal(preisHinweis({ betrieb: {} }), 'inkl. USt') + assert.equal(preisHinweis({ betrieb: { kleinunternehmer: true } }), 'kein Ausweis der USt nach § 19 UStG') + + assert.equal(zahlungText({ zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec', 'kreditkarte'] } }), 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte') + assert.equal(zahlungText({ zahlung: { modus: 'vorort', vorOrtArten: ['ueberweisung', 'paypal'] } }), 'Bezahlung vor Ort: Überweisung, PayPal') + assert.equal(zahlungText({ zahlung: { modus: 'vorort', vorOrtArten: [] } }), 'Bezahlung vor Ort') +}) + +// ---------------------------------------------------------------- Oeffentliche Sicht + +test('oeffentlicheEinstellung: genau die vereinbarten Felder, ohne feedToken und interne Angaben', () => { + const e = ausVorlage('salon', { ...BETRIEB, agbUrl: 'https://salon.de/agb' }, NOW) + const oe = oeffentlicheEinstellung(e, NOW) + assert.deepEqual(Object.keys(oe).sort(), [ + 'anrede', 'betrieb', 'erinnerung', 'farbe', 'gastFelder', 'heute', 'leistungen', 'personWaehlbar', 'preisHinweis', + 'regeln', 'ressourcen', 'storno', 'widerruf', 'zahlung', + ]) + assert.equal(oe.heute, '2026-10-02') + assert.deepEqual(oe.betrieb, { + name: 'Salon Müller', telefon: '06301 1234', adresse: 'Hauptstr. 1, 67722 Winnweiler', + impressumUrl: 'https://salon.de/impressum', datenschutzUrl: 'https://salon.de/datenschutz', + }) + assert.equal(oe.anrede, 'sie') + assert.equal(oe.farbe, e.farbe) + assert.deepEqual(oe.regeln, { horizontTage: e.regeln.horizontTage, absageBisStd: e.regeln.absageBisStd }) + assert.equal(oe.personWaehlbar, e.regeln.personWaehlbar) + assert.deepEqual(oe.zahlung, { modus: 'vorort', text: 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte' }) + assert.equal(oe.preisHinweis, 'inkl. USt') + assert.deepEqual(oe.storno, { text: e.storno.text, version: e.storno.version }) + assert.equal(oe.widerruf, true) + assert.deepEqual(oe.erinnerung, { anbieten: true, stundenVorher: 24 }) + assert.deepEqual(oe.gastFelder, e.gastFelder) + assert.deepEqual(oe.ressourcen, e.ressourcen.map((r) => ({ id: r.id, name: r.name, typ: r.typ }))) + + const leistung = oe.leistungen[0] + assert.deepEqual(Object.keys(leistung).sort(), [ + 'beschreibung', 'buchungsart', 'dauer', 'id', 'knopf', 'name', 'preis', 'preisArt', 'ressourcen', 'verbraucher', + ]) + assert.equal(leistung.knopf, knopfText(e.leistungen[0])) + assert.equal(leistung.preis, preisText(e.leistungen[0])) + assert.equal(leistung.verbraucher, true) + + const json = JSON.stringify(oe) + for (const geheim of ['feedToken', e.ressourcen[0].feedToken, 'info@salon.de', 'agb', 'customerId', 'projectId', 'featureId', 'pufferNach', 'preisCent', 'aufbewahrungTage', 'sonderoeffnungen', 'abwesenheiten', 'feiertage', 'vorOrtArten', 'vorlage', 'kleinunternehmer', 'vorlaufMin', 'maxOffeneJeEmail', 'raster']) { + assert.ok(!json.includes(geheim), `oeffentlich enthaelt ${geheim}`) + } +}) + +test('oeffentlicheEinstellung: nur aktive Leistungen mit aktiver Ressource, nur aktive Ressourcen', () => { + const e = gueltig({ + ...basis(), + ressourcen: [ + { id: R1, name: 'Anna', plan: { 1: [['09:00', '12:00']] } }, + { id: R2, name: 'Ben', typ: 'platz', aktiv: false, plan: { 1: [['09:00', '12:00']] } }, + ], + leistungen: [ + { id: S1, name: 'Beide', dauer: 30, ressourcen: [R2, R1] }, + { id: S2, name: 'Ruht', dauer: 30, ressourcen: [R1], aktiv: false }, + { id: 's_bbbbbbb3', name: 'Nur Ben', dauer: 30, ressourcen: [R2], aktiv: false }, + ], + }) + const oe = oeffentlicheEinstellung(e, NOW) + assert.deepEqual(oe.ressourcen, [{ id: R1, name: 'Anna', typ: 'person' }]) + assert.deepEqual(oe.leistungen.map((l) => l.id), [S1]) + assert.deepEqual(oe.leistungen[0].ressourcen, [R1]) + + // Ressource nachtraeglich deaktiviert (Daten aus dem Speicher): Leistung ohne aktive Ressource verschwindet + const roh = structuredClone(e) + roh.ressourcen[0].aktiv = false + assert.deepEqual(oeffentlicheEinstellung(roh, NOW).leistungen, []) + assert.equal(oeffentlicheEinstellung(roh, NOW).widerruf, false) +}) + +test('oeffentlicheEinstellung: storno null ohne Text, widerruf nur bei sofort + Verbraucher, Kleinunternehmer-Hinweis', () => { + const sofort = gueltig(basis()) + assert.equal(oeffentlicheEinstellung(sofort, NOW).storno, null) + assert.equal(oeffentlicheEinstellung(sofort, NOW).widerruf, true) + + const anfrage = gueltig({ ...basis(), leistungen: [{ id: S1, name: 'A', dauer: 30, ressourcen: [R1], buchungsart: 'anfrage' }] }) + assert.equal(oeffentlicheEinstellung(anfrage, NOW).widerruf, false) + + const unternehmer = gueltig({ ...basis(), recht: { gaesteSind: 'unternehmer' } }) + assert.equal(oeffentlicheEinstellung(unternehmer, NOW).widerruf, false) + assert.equal(oeffentlicheEinstellung(unternehmer, NOW).leistungen[0].verbraucher, false) + + const freizeit = gueltig({ ...basis(), recht: { freizeit: true } }) + assert.equal(oeffentlicheEinstellung(freizeit, NOW).widerruf, false) + + const klein = gueltig({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', kleinunternehmer: true } }) + assert.equal(oeffentlicheEinstellung(klein, NOW).preisHinweis, 'kein Ausweis der USt nach § 19 UStG') +}) + +test('oeffentlicheEinstellung: heute ist der Berliner Tag (kurz nach Mitternacht)', () => { + const e = gueltig(basis()) + assert.equal(oeffentlicheEinstellung(e, new Date('2026-10-02T22:30:00.000Z')).heute, '2026-10-03') + assert.equal(oeffentlicheEinstellung(e, new Date('2026-10-02T21:59:00.000Z')).heute, '2026-10-02') +})