Compare commits

...
751 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 594aa9196a Datenschutz Weg B: PDF aus der freigegebenen Fassung (Stand 22.10.2026) gedruckt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:01:19 +00:00
ClaudeandClaude Sonnet 5.5 3b5e1d559a Datenschutz Weg B (E4): Forensatz sagt "sind angewiesen" statt "nehmen auf"
Der Hinweis in der E-Mail zu einem Forenbeitrag ist eine Arbeitsanweisung. Der Dienst legt den Baustein nicht in den Pult-Eintrag und
erfasst keine von Hand geschriebene Mail; der Text sagt es darum wie im Absatz davor: Die Mitarbeiter sind angewiesen, den Hinweis
aufzunehmen. Der Test verlangt den neuen Satz und verbietet den alten. Kommentar zur Forenerkennung des Dienstes nachgezogen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:41:20 +00:00
ClaudeandClaude Sonnet 5.5 c6e3c47be7 Datenschutz Weg B (E4): Beiträge aus Frage-Foren beantworten wir nicht im Forum
Abschnitt 11 sagt, was der Dienst seit E4 tut: Für Beiträge in den Frage-Foren (gutefrage.net,
forum-hilfe.de, wer-weiss-was.de, wpde.org) entwirft die KI keine Antwort; ein Mitarbeiter sieht
den Beitrag an und meldet sich höchstens per E-Mail, wenn der Beitrag ausdrücklich um Angebote
per E-Mail bittet (mit dem Hinweis am Ende), sonst nicht. Der Satz über öffentlich sichtbare
Antworten in Frage-Foren entfällt, Abschnitt 14 sagt "höchstens eine Antwort". Der Test pinnt
die Sätze, die Forenliste (gleich in Quelle und Regel) und dass nirgends steht, wir antworteten
im Forum.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:26:18 +00:00
ClaudeandClaude Sonnet 5.5 b73cd340bc Datenschutz Weg B (E2): aussortierte Anfragen spätestens am Tag nach dem Fund kürzen
Abschnitt 11 sagt jetzt, was der Dienst seit Regel C tut: Anfragen, die die KI als nicht
passend aussortiert, werden spätestens am Tag nach dem Fund gekürzt (erster täglicher
Kürzlauf nach dem Berliner Kalendertag des Fundes). 30 Tage gelten nur noch nach einer
Absage, gerechnet ab dem Lesen. Der Zweck "Mitarbeiter sehen nach, ob die KI zu Unrecht
aussortiert hat" und der Satz "Bis dahin speichern wir sie" entfallen. Die Fristenliste und
der Test legal_datenschutz_anfragen sind angepasst (Liste wörtlich, nur noch ein Eintrag mit
30 Tagen, entfallene Sätze verboten).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:24:28 +00:00
ClaudeandClaude Sonnet 5.5 53ae593860 Datenschutz Weg B: zwei Sätze lesbarer (Plattform-Hinweise, Firmenadresse im Widerspruchskasten)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:47:06 +00:00
ClaudeandClaude Sonnet 5.5 fd5994c243 Datenschutz Weg B nach zweiter Prüfung: Firmen-Domain, Postfach, Zwecke, verständlichere Fristen
- Prüfwerte nennen die Firmen-Domain (bei Firmenadressen, nicht bei Freemail); der Widerspruchskasten sagt, dass die Sperre dann auch die Domain trifft
- Postfach: der Dienst lädt jede neue Mail zur Prüfung des Absenders, gespeichert werden nur die Mails der Plattformen
- Name: "Wir lesen Ihren Namen nicht gesondert aus"; Zweck der 30 Tage (Fehlurteile der KI prüfen) und der Reste (Auswertung) genannt
- Hinweis: Mitarbeiter nehmen ihn in jede Antwort auf, auch in selbst geschriebene; Frage-Foren: Antwort öffentlich sichtbar
- Widerspruchskasten nennt die Plattform-Mail im Postfach (bis 90 Tage nach Eingang)
- Verständlich: "Fund" erklärt, Fristen als Liste, robots.txt umschrieben, Absätze höchstens 130 Wörter; Hinweis im Dienst folgt (02a0b3c, Nachfolger)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:45:32 +00:00
ClaudeandClaude Sonnet 5.5 5c49f63616 Datenschutz Weg B: Alarm-Postfach bei Hetzner, Sicherungen bis zu sieben Tage, keine [OFFEN]-Markierung mehr
Abschnitt 11: Das Alarm-Postfach ist support@webklar.com bei der Hetzner Online GmbH (Abschnitt 8); der Dienst liest dort nur den Posteingang
und übernimmt nur die E-Mails der Plattformen; diese löschen wir im Postfach spätestens 90 Tage nach dem Eingang (keine "Postfach-Regel"
versprochen). Gekürzte Daten bleiben in den Datensicherungen bis zu sieben Tage, wie im übrigen Text. Der Hinweis in der Antwort heißt jetzt
"Am Ende unserer Antwort steht ein Hinweis" und nennt, dass die Mitarbeiter angewiesen sind, ihn beim Bearbeiten nicht zu entfernen; er ist
wörtlich auf Claras Hinweis im Dienst abgestimmt. Abschnitt 12: Hetzner nennt das Postfach, der eigene Listenpunkt entfällt.

Tests: jede [OFFEN]-Markierung (auch im Kommentar) und jeder Span class="offen" macht legalTexts rot; Pins für die neuen Sätze. Das Upload-Skript
bricht weiter ab, solange eine Markierung steht. pdf_pruefsumme bleibt rot bis zum Neudruck des PDF am Pushtag.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:18:18 +00:00
ClaudeandClaude Sonnet 5.5 23ab43f9fe Datenschutz Weg B nach Prüfung: Zusagen auf den Dienst begrenzt, [OFFEN]-Markierungen, Upload-Sperren
Abschnitt 11: Sperre und Kürzung ab dem Lesen unserer Antwort, nur je Beitrag und E-Mail-Adresse (neues Gesuch = neuer Beitrag);
aussortierte Anfragen 30 Tage; feste Ausschlussliste der Websuche samt robots.txt; Kriterium für die Dauer der Reste; Kopie der
Standardvertragsklauseln; Hinweis in der ersten Nachricht nennt den Widerspruch; Abschnitt 11 und 14: die KI sortiert aus, ein
Mitarbeiter entscheidet über jede Antwort. Alarm-Postfach-Anbieter und Aufbewahrung der Server-Sicherungen stehen als sichtbare
[OFFEN]-Markierung im Text (auch in Abschnitt 12).

Tests: Pins angepasst, Markierungen nur die zwei erlaubten, neuer Test für Zusagen in Reichweite des Dienstes. Upload-Skript bricht ab,
solange eine [OFFEN]-Markierung steht oder die Prüfsumme neben dem PDF nicht zur HTML passt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:57:18 +00:00
ClaudeandClaude Sonnet 5.5 b3ca84a8e5 Datenschutz-Upload: Vermerk "Abschnitt 11 Interessenten (Antworten auf öffentliche Anfragen)", Test prüft den vollen Wortlaut
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:10:22 +00:00
ClaudeandClaude Sonnet 5.5 6a25df22fb Tests: legal_datenschutz_anfragen (Abschnitt 11 Weg B, Stand 22.10.2026), Titelliste und Stand-Pin angepasst
Drei neue Tests: tragende Wendungen von Abschnitt 11, keine Stufe-2-Reste und keine offenen Markierungen, Abschnitt 2, 12 und 14 passend zu 11.
Der alte Pin "Stand 17.10.2026" prüft nur noch "nicht vor der Reichweitenmessung". Die Titelliste kennt die neuen Überschriften von 11 und 14.
pdf_pruefsumme bleibt rot, bis das PDF neu gedruckt ist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:10:22 +00:00
ClaudeandClaude Sonnet 5.5 e554071739 Datenschutz Stand 22.10.2026: Abschnitt 11 Interessenten nur für den Anfrage-Weg (Weg B), Anker #interessenten
Abschnitt 11 beschreibt nur, was heute läuft: öffentliche Gesuche (Alarm-E-Mails der Plattformen, Websuche), KI bewertet und entwirft, ein
Mitarbeiter sendet einmal von Hand. Daten, Rechtsgrundlage, Anthropic, Fristen (90/30 Tage), Hash-Werte, Kopien und Widerspruch.
Abschnitt 2 (auch Privatpersonen aus Foren), 12 (Anthropic-Zweck, Plattformen) und 14 (keine Einzelentscheidung nach Art. 22) angepasst.
Kein Resend, Cloudflare, Overpass und kein Kaltmail-Weg: die volle Fassung bleibt auf feature/workers-datenschutz für Stufe 2.
PDF und Prüfsumme sind noch nicht neu gedruckt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:10:22 +00:00
Claude 0fbeadd0c1 Rechtliches: Skript zum Hochladen der Datenschutzerklärung als neue Version
Der Ablauf des Vorbilds (hochladen) ist jetzt eine exportierte, parametrisierte Funktion; datenschutz-hochladen.mjs ruft sie mit Karte datenschutz, PDF und HTML auf und bricht ab, wenn der Stand in der HTML nicht der Freigabe entspricht.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
(cherry picked from commit 06676c459d0ac4f534aed7d616e2c0d40ac97b97)
2026-10-22 13:03:30 +00:00
ClaudeandClaude Opus 5.5 dd062b1f15 Analyse-Skript: bei SPA-Seitenwechseln die Verweildauer der vorigen Seite senden
Router-Links (z. B. React Router auf webklar.com) verhindern den Standard-Klick, dann griff onLinkClick nicht
und das en der vorigen Seite ging verloren. Jetzt sendet der Seitenwechsel-Timer zuerst en (nur mit
Einwilligung, einmal je Seite) und dann das pv der neuen Seite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:37:55 +00:00
ClaudeandClaude Opus 5.5 07a4e153bf Datenschutzerklärung Stand 17.10.2026: Abschnitt 4a Reichweitenmessung auf webklar.com, PDF neu
- Abschnitt 4: eigene Website-Analyse auf webklar.com statt "keine Analyse-Werkzeuge", Eintrag wk-consent.
- Abschnitt 4a: Rolle (WEBklar selbst Verantwortlicher), Daten ohne und mit Einwilligung, Rechtsgrundlagen,
  Widerruf, Fristen, Verweis auf webklar.com/datenschutz-analyse.html.
- Abschnitt 5: Kontaktformular-Anfragen gehen seit 17.10.2026 auch per Mail an das eigene Postfach.
- Abschnitt 13: Einwilligung nur für die erweiterte Reichweitenmessung.
- Archiv: Banner-Fassung eigen 2026-10-17 und Kopie der Zusatzseite von webklar.com.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:34:06 +00:00
ClaudeandClaude Opus 5.5 9ed295946c Website-Analyse für webklar.com: Sie-Fassung des Banners, fester Messdienst-Eintrag, Auswertung fürs WOMS
- Messskript: data-variant="eigen" zeigt eine eigene Banner-Fassung (Sie-Form, WEBklar misst selbst,
  BANNER_VERSION_EIGEN 2026-10-17); Kunden-Websites bleiben bei der Fassung 2026-10-02.
- config.analyse.eigeneWebsite: fester Schlüssel und Domains von webklar.com; ensureEigeneWebsite zieht den
  Eintrag im Messdienst täglich nach (aktiv: false schaltet ab). Kein Kunde, kein Abo, keine Tickets.
- /api/admin/analyse-eigen (jeder Mitarbeiter): Kennzahlen 7/30/90 Tage und Heatmap-Link auf webklar.com.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:34:06 +00:00
ClaudeandClaude Sonnet 5.5 f3f8ac3777 Kundenansicht neu: doppelte Klasse .kn-nur-lesen (ansichten.css) durch die vorhandene .kn-nur-leser (huelle.css) ersetzt
Die Stufennamen im Fortschrittsbalken der Zeitleiste (nur für Vorlesegeräte) tragen jetzt .kn-nur-leser; die zweite,
gleichwertige Regel .kn-nur-lesen in ansichten.css ist gestrichen. Tests: DOM- und Quelltext-Test (keine Spur von
.kn-nur-lesen in Stil und Modul), Stil-Test sucht die Klassenregeln auch in huelle.css.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:48:56 +00:00
ClaudeandClaude Sonnet 5.5 ace4c85e72 Kundenansicht neu (Aufträge): Zeitleiste barrierefrei, "mehr anzeigen" mit Auftragstitel im Namen und aria-controls, "Im Chat antworten" bricht nicht um
Der Knopf "mehr anzeigen"/"weniger anzeigen" bekommt einen aria-label mit dem Auftragstitel (der sichtbare Text steht vorn im
Namen, der Titel nur als Text) und aria-controls auf die Liste seines Auftrags (eindeutige Id je Liste); beides zieht beim Umschalten mit.
Der Linktext "Im Chat antworten" trägt white-space: nowrap und bleibt in einer Zeile beisammen.
Tests: DOM-Test für Name, Verknüpfung, Umschalten, fremden Titel und Ersatztitel; CSS-Test für nowrap.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:48:25 +00:00
ClaudeandClaude Sonnet 5.5 14dc2ad1bf Kundenansicht neu (Aufträge): Aufträge mit Rückfrage vor den übrigen laufenden, nie hinter "Ältere Aufträge anzeigen"
sortiereAuftraege stellt unter den laufenden Aufträgen die mit Rückfrage (braucheInfo) nach vorn, danach
wie bisher die jüngste Aktivität, dann die Reihenfolge des Servers. Erledigte und Stornierte bleiben dahinter.
Tests: Reihenfolge mit Rückfragen und Gruppierung (die Rückfrage mit der ältesten Aktivität bleibt sichtbar).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:47:21 +00:00
ClaudeandClaude Sonnet 5.5 6bfcaa892c Kundenansicht neu (Meine): Kurzinfo nur auf Anfrage (GET /api/funktionen?kurzinfo=1)
Der Server berechnet kurzinfo (Terminplaner, Webshop, News, Buchungskalender, Öffnungszeiten) nur noch,
wenn die neue Ansicht ?kurzinfo=1 sendet. Das tut allein die Seite "Meine"; Etagen, Einschalt-Ablauf und
Module fragen ohne den Parameter, dann fehlt das Feld und es gibt keine Abfrage auf Termine, Bestellungen,
Beiträge und Anfragen. Mit dem Parameter bleibt die Antwort wie bisher (Text oder null je Funktion).
Nur der Wert "1" gilt; doppelte oder als Liste gesendete Parameter heißen: ohne. Alte Ansicht unverändert.

Tests: kundenansichtKurzinfo (neuer Fall ohne/mit Parameter, Abfragen und Feld), knFunktionenAnsicht
(die Seite lädt genau einmal ?kurzinfo=1, auch nach "Nochmal versuchen").

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:46:48 +00:00
ClaudeandClaude Opus 5.5 d7724afc02 Merge kn/meine-pilot (Pilot-Feedback 17.10.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:43:07 +00:00
ClaudeandClaude Opus 5.5 5f4e158660 Merge kn/auftraege-pilot (Pilot-Feedback 17.10.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:43:07 +00:00
ClaudeandClaude Opus 5.5 85808fc115 Merge kn/chat-fix (Pilot-Feedback 17.10.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:43:07 +00:00
ClaudeandClaude Sonnet 5.5 1703dabaaf Kundenansicht neu (Aufträge): Rückfrage-Link als Block, bündige Datumsspalte bei Einträgen aus dem Vorjahr
- Browserlauf: display:flex am Link zerlegte den Satz in zwei Spalten; der Link ist jetzt ein Block, die 44 px Tippfläche kommen aus dem Innenabstand.
- Trägt ein Datum das Jahr (TT.MM.JJ), bekommt die Datumsspalte des Auftrags die Breite dafür (Klasse kn-az-mit-jahr), die Texte bleiben bündig.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:31:46 +00:00
ClaudeandClaude Sonnet 5.5 d62f8b805e Kundenansicht neu (Aufträge): Rückfrage-Zeile als ganze Link-Zeile, Einklappen ohne verschachtelte Regel, Balken im Pseudo-Element
- Die Zeile "Wir brauchen eine Info von dir · Im Chat antworten" ist ein Link (#chat) mit großer Tippfläche am Handy; unterstrichen ist nur der zweite Teil.
- Browserlauf: .kn-einklappbar:not(.is-open) [data-extra] verbarg die Zeilen einer aufgeklappten Zeitleiste, wenn der äußere Wrapper zu war.
  Die Wrapper haben jetzt eigene Klassen und Regeln nur für direkte Kinder (mit Regressionstest).
- Die Fläche der Balken-Abschnitte liegt im Pseudo-Element, der Lesetext darunter bleibt auf Weiß (Gate low-contrast).
- Das Modul steht in den Listen der Quelltext- und Stil-Tests der Ansicht-Module.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:28:43 +00:00
ClaudeandClaude Sonnet 5.5 ec79defb2a Plan Kundenansicht neu: Nachtrag N52 (Kurzinfo je Funktion in Meine) und Vertrag V5
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:28:00 +00:00
ClaudeandClaude Sonnet 5.5 670ead3091 Kundenansicht neu Chat-Fix: Chat als Vollbild-Ansicht (Verlauf scrollt in sich, Eingabe unten, Spalte zentriert, "Chat" klein im Kopf)
Pilot-Feedback 17.10.: Der Chat wirkte durchsichtig und verrutscht.
- Handy: Inhalt steht fest zwischen Kopf und Leiste (wie das Blatt); die Eingabe sitzt höchstens 6 px über der Leiste bzw. dem Widerrufsstreifen und bei
  offener Tastatur am unteren Rand des sichtbaren Bereichs. "Chat" steht klein im Kopf (die H1 bleibt nur für Screenreader), der Kopf ist deckend mit Haarlinie,
  der Verlauf beginnt direkt darunter und scrollt in sich.
- Desktop: Spalte in Fensterhöhe, Chat höchstens 760 px breit und mittig, Verlauf füllt die Höhe, Eingabe am unteren Spaltenrand.
- Verlauf steht unten (neueste Nachricht sichtbar), kurze Verläufe unten bei der Eingabe; Blasen fremd #f8f9fb, eigen #ebeef1.
- "Senden" bleibt schwarz, auch bei leerem Feld (gesperrt im Ansichtsmodus weiter gedämpft).
- haltFokus: Druck auf Senden, Anhang oder Mikrofon nimmt dem Textfeld den Fokus nicht; sonst kommt die Leiste zurück, schiebt die feste Eingabe hoch und der
  Tipp auf "Senden" geht bei offener Tastatur verloren (im Browser nachgestellt). public/chat-composer.js und die alte Ansicht bleiben unverändert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:17:26 +00:00
ClaudeandClaude Sonnet 5.5 9aeb9872be Kundenansicht neu (Meine, Live-Detail): Zeile einer abgeschalteten Funktion kürzer ("Im Chat wieder anfragen")
Die Detailzeile umbrach bei mehreren Websites neben der Pille auf drei Zeilen. Der Abo-Ende-Text mit dem
Löschtag bleibt unverändert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:17:13 +00:00
ClaudeandClaude Sonnet 5.5 db62e320ff Kundenansicht neu (Meine, Live-Detail): Zeile mit Detailzeile und Pfeil, Pille nur wenn die Funktion nicht läuft
Jede Zeile in "Meine" zeigt den Funktionsnamen fett, darunter die Kurzinfo des Servers ("3 Termine heute ·
nächster 10:30") oder "Aktiv", rechts einen Pfeil. Eine Status-Pille steht nur noch bei Funktionen, die nicht
laufen (In Einrichtung, Zahlung offen, Angefragt, Abgeschaltet, Abgelehnt, Gesperrt ...); "Aktiv seit ..." entfällt.
Bei mehreren Websites steht der Websitename vorne in der Detailzeile. Die Zeilen für Funktionen, die nicht laufen,
sind kürzer ("Wir richten sie gerade ein", "Anfrage abgelehnt · Im Chat nachfragen"). Die Kurzinfo gilt nur bei
aktiver Funktion, nur als kurzer nicht leerer Text und wird nie als HTML gelesen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:10:43 +00:00
ClaudeandClaude Sonnet 5.5 53a191219e Kundenansicht neu (Meine, Live-Detail): GET /api/funktionen liefert je Projekt und Funktion die Kurzinfo
In der neuen Ansicht trägt jede Funktion unter projects[].features.<key> zusätzlich kurzinfo (Text oder null).
Nur aktive Funktionen bekommen einen Text: Terminplaner "3 Termine heute · nächster 10:30", Webshop "2 neue
Bestellungen", News-Pinnwand "4 Beiträge sichtbar", Buchungskalender "1 offene Anfrage" bzw. "Nächste Anreise
24.10.", Öffnungszeiten live der Status der Website (ohne eigene Abfrage). Berliner Zeit, je Funktion höchstens
zwei schmale Abfragen (limit, select, Index-Felder), alles gleichzeitig; jede Funktion einzeln begrenzt (1,5 s)
und fehlertolerant: Fehler oder Zeitüberschreitung ergibt null. Ohne neue Ansicht bleibt die Antwort wie sie
war und es wird nichts gelesen. Die Analyse hat keine Kurzinfo (Zahlen kommen aus dem Auswertungsdienst).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:09:25 +00:00
ClaudeandClaude Sonnet 5.5 2064bddba6 Kundenansicht neu (Aufträge): "Was wir zuletzt erledigt haben" als Zeitleiste auf der Website-Seite
Je Auftrag eine Überschrift (Titel fett, Status-Pille), ein schmaler Balken mit vier
Abschnitten (Stufennamen als Text für Screenreader), bei Rückfrage die Zeile "Wir brauchen
eine Info von dir · Im Chat antworten" und eine Zeitleiste mit Haarlinie (Datum TT.MM. und
Kurztext, drei sichtbar, "mehr anzeigen"). Fünf Aufträge offen, der Rest hinter
"Ältere Aufträge anzeigen". Die Auftragsnummer steht nicht mehr im Text (ruhiger), bleibt
als data-auftrag. Die alte baueAktivitaet und ihre toten CSS-Regeln entfallen; die
Projektdetails (baueAuftrag, baueArbeiten) bleiben unverändert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:07:34 +00:00
ClaudeandClaude Sonnet 5.5 f231f4b887 Kundenansicht neu (Aufträge): Logik der Zeitleiste (Fortschritt, Reihenfolge, Gruppierung, Kürzen, Kurztexte)
Reine Funktionen in public/neu/auftrags-zeitleiste.js: laufende Aufträge vor erledigten
(jeweils jüngste Aktivität zuerst, Storniertes zuletzt), höchstens fünf Aufträge offen,
vier Fortschrittsabschnitte aus status.step, Zeilen der Zeitleiste neueste zuerst mit
Datum TT.MM. und Kurztext. Test zuerst (rot: Modul fehlte), dann grün.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:05:48 +00:00
ClaudeandClaude Sonnet 5.5 5cd49d6a4a Kundenansicht neu E-Fix F1 (Nachkorrektur): Widerrufsstreifen auf den Bestellseiten ausgeblendet
Auf Abo bestellen, Wechsel, Zusatzspeicher und Übernahmegutschrift fehlt der feste Widerrufsstreifen, damit Kerninfo und Knopf "Zahlungspflichtig bestellen"
auch bei Privatkunden in laufender Frist auf 375x667 auf einem Bildschirm stehen (Spec Abschnitt 4). Auf den Hauptseiten bleibt er (Spec 5.2),
"Vertrag widerrufen" bleibt im Konto und unter Verträge erreichbar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 22:44:20 +00:00
ClaudeandClaude Sonnet 5.5 c5f1d9545c Kundenansicht neu E-Fix F1 (Nachkorrektur): Wechsel nennt "mit 1 Paket" / "mit 2 Paketen" Zusatzspeicher (Dativ)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 22:40:53 +00:00
ClaudeandClaude Sonnet 5.5 0ee5c86809 Kundenansicht neu E-Fix F1 (Politur): "Fällig: erst am TT.MM.JJJJ (50,00 €)" statt doppeltem Doppelpunkt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 22:20:43 +00:00
ClaudeandClaude Sonnet 5.5 be34f1be1d Kundenansicht neu E-Fix F1 (Rechtsrunde): R18 nennt den gekürzten Block der Bestellseiten
Die Wortlaute des Blocks "Was du bestellst" sind seit E-Fix F1 knapp, die ausführlichen Sätze stehen unter dem Knopf. Als Entscheidung R18
(Vorschlag und Alternative, Kanzlei-Frage KQ6) in die Liste der Rechtsrunde aufgenommen; der Pin zählt jetzt R1 bis R18.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 22:07:07 +00:00
ClaudeandClaude Sonnet 5.5 fa79e6e10c Kundenansicht neu E-Fix F1 (Nachtrag): Wechsel mit gebuchtem Zusatzspeicher passt auf 375x667
Mit gebuchtem Zusatzspeicher (eine Block-Zeile mehr) maß der Wechsel 598 px gegen 558 nutzbare. Jetzt steckt der Zusatzspeicher im Preis
("52,00 € pro Monat mit 2 Paketen Zusatzspeicher (+10 GB)") und die gutgeschriebenen Tage in "Fällig"; die Einzelposten stehen ausführlich unter dem
Knopf. Gemessen auf 375x667 (Blockanfang bis Knopfunterkante, nutzbar 558 px): Wechsel Betrieb 448, Wechsel privat mit Häkchen 516, mit
Zusatzspeicher 536 px.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 22:06:36 +00:00
ClaudeandClaude Sonnet 5.5 c92ddd9363 Kundenansicht neu E-Fix W1 (Portal): Team-Endpunkt stundenkonto liefert zusätzlich active
GET /api/team/customers/:id/stundenkonto kannte nur Produkt und Konto; WOMS (stundenkontoView.js) kann bei beendetem Abo nicht "verfällt, wird
nicht berechnet" (V8, E17) statt "wird mit den nächsten Gutschriften verrechnet" zeigen, weil das Feld active fehlte. Jetzt trägt jede Antwort
active (Abo läuft: Status active, trialing oder past_due, auch mit vorgemerkter Kündigung; ohne Abo false). WOMS liest antwort.active schon,
nur ein ausdrückliches false ändert den Satz. Test in teamStundenkonto.test.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:49:50 +00:00
ClaudeandClaude Sonnet 5.5 6ff63a7a11 Kundenansicht neu E-Fix M10: hatLoeschVermerk prüft den strengen Vermerk der Löschjobs
hatLoeschVermerk erkannte gelöschte Daten an /gelöscht am/ in adminNote. Der Vermerk der Jobs ist strenger (Shopdaten bzw. Buchungsdaten
gelöscht am JJJJ-MM-TT), freier Text des Teams ("Domain gelöscht am Kundenwunsch") traf das lockere Muster: das Abo kam zurück, der Abgleich hielt
die Daten für gelöscht und setzte das Dokument auf off statt die Funktion wieder einzuschalten. Jetzt je Funktion das Muster des Jobs mit Datum.
Test in knLoeschVermerk.test.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:49:18 +00:00
ClaudeandClaude Sonnet 5.5 f6fc291dfe Kundenansicht neu E-Fix M9: featureView nennt den Löschtag auch für News, Webshop und Buchungskalender im Abo-Modell
featureView lieferte loeschungAm nur für den Terminplaner und Öffnungszeiten live. Im Abo-Modell werden aber auch News (30 Tage), Webshop und
Buchungskalender (90 Tage) nach dem Ende des Abos tatsächlich gelöscht (K39, Löschjobs); "Meine" und WOMS zeigten kein Datum, der Kommentar
"bei News verspräche das Datum eine Löschung, die nie kommt" stimmte nicht mehr. Jetzt: abo_ende bei einem Dokument des Abo-Modells (istAboModell)
von news, shop und unterkunft ergibt loeschTagNach(disabledAt, loeschFristTage(key)), derselbe Berliner Tag wie in K39. Team-Sperre, Bestand ohne
Abo-Modell und der Terminplaner bleiben unverändert. Test in knLoeschfristAnzeige.test.js. (Der Text in WOMS gehört zum WOMS-Teil W2.)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:49:18 +00:00
ClaudeandClaude Sonnet 5.5 3e32ddfe86 Kundenansicht neu E-Fix M8: Wechselangebot beim Einschalten nur, wenn der Wechsel möglich ist
aboDeckt nennt falsches_abo vor gekuendigt, beginnt_spaeter und nicht_aktiv (die Reihenfolge bleibt gepinnt). Die 409-Antwort setzte wechsel: true
allein aus grund === 'falsches_abo': ein Hosting-Abo in der Probezeit, mit offener Zahlung oder vorgemerkter Kündigung bekam das Angebot, landete auf
#wechsel bei "Ein Wechsel ist derzeit nicht möglich" und verlor den eigentlichen Grund. Jetzt prüft pruefeAboDeckung bei falsches_abo zusätzlich
wechselMoeglich (aboFunktionen.js, dieselben Bedingungen wie canSwitch und assertCanSwitch: Hosting, aktiv, nicht gekündigt, kein offener Wechsel);
ein Lesefehler dabei nimmt nur das Angebot weg. Test in funktionEinschalten.test.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:47:36 +00:00
ClaudeandClaude Sonnet 5.5 29ca12a409 Kundenansicht neu E-Fix M7: Bewegung reduziert ohne Rest-Fahrt der Leisten-Zeilen, #fortschritt unter dem haftenden Kopf
- Die Regel der zwei Fahrstuhl-Zeilen (#kn-zeile-haupt, #kn-zeile-etage, Spezifität 1-2-0) schlug das .kn-rw { transition: none } der Reduced-Motion-
  Regel: mit "Bewegung reduzieren" glitt die alte Zeile noch 550 ms durch die neue (beide Texte überlagert). Jetzt steht im Reduced-Motion-Block
  dieselbe Selektorliste mit transition: none.
- #fortschritt (scrollIntoView block start) ließ den Abschnitt "Was wir zuletzt erledigt haben" hinter dem haftenden Kopf beginnen: scroll-margin-top
  mit Kopfhöhe, Bannerhöhe, Safe-Area und 8 px (Desktop 16 px). Die Gutschrift-Etage scrollt im Blatt, dessen Oberkante schon unter dem Kopf liegt: dort
  ist nichts nötig.
Prüfung: Werkzeug-Ansichten kn-huelle-reduziert (jetzt auch die Zeilen bei offener Etage) und kn-p2-website-fortschritt, Pins in test/knEfixCss.test.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:46:15 +00:00
ClaudeandClaude Sonnet 5.5 140275022d Kundenansicht neu E-Fix M6: Zahlungsknöpfe sind nach der Rückkehr von Stripe wieder bedienbar
Nach "Zahlungspflichtig bestellen" setzt die Seite den Knopf auf "Weiter zur Zahlung…" (gesperrt) und leitet zu Stripe. Bricht der Kunde per
Zurück-Wischgeste ab, stellt Safari die Seite aus dem Bfcache her: der Knopf blieb gesperrt, ein neuer Versuch ging nur nach Neuladen. Nur
ansicht-wechsel.js hatte einen pageshow-Handler. Neuer Helfer beiRueckkehr(el, fn) in ansicht-hilfen.js (pageshow mit persisted, entfernt sich,
sobald das Element nicht mehr im Dokument steht); genutzt von der Bestellseite und von "Zahlungsart aktualisieren" (Verträge).
Speicher und Gutschrift leiten nicht zu Stripe, dort ist nichts zu tun.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:45:15 +00:00
ClaudeandClaude Sonnet 5.5 8bb02f3949 Kundenansicht neu E-Fix M5: Chat hält die Eingabe über der Tastatur (revealChatComposer aus app.js)
Der Chat der neuen Ansicht übernahm nur das Ausblenden der Leiste, nicht das Nachschieben der Eingabe: auf kleinen Handys (iPhone SE, Android mit
hoher Tastatur) konnte die Eingabezeile unter der Tastatur stehen bleiben, und die letzte Nachricht verschwand beim Schrumpfen des Verlaufs.
Jetzt wie in app.js: 350 ms nach dem Fokus im Composer und bei jedem visualViewport-Resize wird die Eingabe in den sichtbaren Bereich geschoben
(composerVersatz, nur wenn verdeckt, 8 px Luft, nur am Handy), und ein ResizeObserver hält die neueste Nachricht sichtbar, wenn man unten war.
Hörer und Beobachter werden beim Neuaufbau abgebaut. Tests für die Rechnung und die Verdrahtung in knAnsichtChat.test.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:44:20 +00:00
ClaudeandClaude Sonnet 5.5 f8962ffa50 Kundenansicht neu E-Fix M4: Mitarbeiter-Banner verschiebt Blatt und Menü nicht mehr unter den Kopf
Der Banner stand im Fluss (relative) und schob den Kopf nach unten, das am Handy feste Blatt begann aber fest bei der Kopfhöhe: es deckte den
unteren Teil des Kopfs samt Profilbild ab (Konto und Abmelden nicht erreichbar); am Desktop blieb das Menü bei fest 36 px stehen, der Banner
ist aber 40 px hoch und beim Scrollen blieb eine Lücke. Jetzt bleibt der Banner oben stehen (sticky), huelle.js setzt --kn-banner-h auf seine
Höhe (auch bei Umbruch am Handy), und Kopf, Menü, Blatt und Konto-Popover beginnen darunter. Werkzeug-Ansicht kn-huelle-banner-blatt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:43:20 +00:00
ClaudeandClaude Sonnet 5.5 4c64edc057 Kundenansicht neu E-Fix M3: Fehlermeldungen der Bestellseiten und Formulare als Live-Region (role="alert")
abo-error, switch-error (und der Fehler der Wechsel-Vorschau), storage-error, tk-error samt Fehlerzeile der Rückfrage-Antwort, der Fehler von
"Zahlungsart aktualisieren" sowie login-error und form-error auf Login, Kündigen und Widerrufen waren einfache Absätze: Screenreader lasen
sie nicht vor, obwohl der Knopf wieder frei wurde. Jetzt role="alert", gepinnt in test/knEfixCss.test.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:42:07 +00:00
ClaudeandClaude Sonnet 5.5 ddaf2879c4 Kundenansicht neu E-Fix M2: aboModellFuerKunde liest bei Modus off kein Kundendokument
aboModellFuerKunde las das Kundendokument auch bei KUNDENANSICHT_NEU=off, obwohl neueAnsichtFuerKunde dort sofort false liefert. Alt-Kunden
bekamen so eine zusätzliche Lesung und einen neuen Fehlerpfad (ein 503 beim Widerruf des Wechsels ließ die Beenden-Mail der Analyse
entfallen, die Team-Aktivierung und GET /api/admin/analyse/:id antworteten mit 500). Jetzt sofort false im Modus off; Pilot und on
lesen wie bisher (kein Dokument: false). Test in analyseAboModell.test.js (eingeplanter 503 wird im Modus off nicht verbraucht).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:41:28 +00:00
ClaudeandClaude Sonnet 5.5 306c53871d Kundenansicht neu E-Fix M1: Widerrufsstreifen und Pille nach Kauf, Wechsel und Speicher-Plus neu laden
ladeFristen lief nur einmal beim Start; eine neue 14-Tage-Frist nach einem Wechsel oder Speicher-Plus erschien erst nach Neuladen, ein
abgelaufener Streifen blieb in einem langen PWA-Tab stehen. Die Hülle lädt die Fristen jetzt bei kn:zaehler-neu (vertraege, alle) und beim
Zurückkehren in den Tab neu (ladeFristenNeu: ein Fehler lässt den Stand stehen, die jüngste Antwort gilt); das Speicher-Plus meldet
kn:zaehler-neu vertraege nach Erfolg, der Wechsel tat es schon.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:40:29 +00:00
ClaudeandClaude Sonnet 5.5 4cac03a2bd Kundenansicht neu E-Fix F6: Pin auf build-legal-pdfs.sh kennt die Modi abo6 und funktionen6
P8 hat dem Skript die Fassungsfunktion funktionen6 und die Entwurfsmodi abo6 und funktionen6 ergänzt; der Pin zählte noch fünf Node-Aufrufe.
Jetzt sechs Aufrufe (60 s, 256 MB, je Fassungsfunktion einer), die zwei Entwurfsmodi sind gepinnt und `all` druckt sie weiterhin nicht mit;
bestehende Modi, Chrome-Grenze (120 s), Usage-Zeile und das Verbot paralleler Läufe bleiben unverändert geprüft.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:39:17 +00:00
ClaudeandClaude Sonnet 5.5 72b5ac5265 Kundenansicht neu E-Fix F5: Fassung 6 ohne PDF behauptet keinen Anhang, das Team bekommt den Hinweis
Die PDFs der Fassung 6 entstehen erst nach der Freigabe. Bis dahin gingen K01, K12 und K13 ohne Anhang raus, sagten aber "im Anhang" bzw.
"beigefügt", und das Team erfuhr es nicht (nur K38/T24 hatten dafür den Block anhang_fehlt).

- Neuer Helfer vertragsAnhang.js: lädt das PDF der zugestimmten Fassung und meldet fehlt nur bei Fassung 6 ohne lesbare Datei.
- K01, K12, K13: Block anhang (Anhangsaussagen) und ohne_anhang (Satz, dass die Unterlagen gesondert kommen).
- T02, T12, T13: Block anhang_fehlt "Unterlagen von Hand nachsenden" (T12 auch beim späteren Wirksamwerden, T13 auch ohne Aufgabe).
- Alte Fassungen unverändert: Hash-Pins auf die Wiedergabe der Vorlagen vor der Änderung, Anhang und Texte wie bisher.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:38:54 +00:00
ClaudeandClaude Sonnet 5.5 9c04f806eb Kundenansicht neu E-Fix F4: Team-Wiedereinschalten ohne gesendete Abrechnung nimmt die gespeicherte (Aufbauphase)
WOMS fragt nach einer Team-Sperre die Abrechnung nicht mehr ab und sendet keine; der Server setzte dann pauschal 'abo', pruefeAboDeckung warf
409 abo_fehlt, und eine in der Aufbauphase ('projekt', noch kein Abo) eingebaute Funktion liess sich nicht wieder einschalten.
Jetzt gilt bei disabled/team die am Dokument gespeicherte einrichtungAbrechnung, sonst wie bisher 'abo'; eine ausdrücklich gesendete
Abrechnung geht vor. Test in funktionenAdminAbo.test.js (Aufbauphase: ein, aus, ohne Angabe wieder ein = 200 mit warnung; mit abo hart).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:32:28 +00:00
ClaudeandClaude Sonnet 5.5 3962074af6 Kundenansicht neu E-Fix F3b: Safe-Area links und rechts in Hülle, öffentlichen Seiten und Chat-Vollbild
dashboard.html und die öffentlichen Seiten setzen viewport-fit=cover, aber kein Rand nutzte env(safe-area-inset-left|right): Im Querformat
am iPhone rutschten die ersten Zeichen unter Notch und Insel. Kopf, Hauptseite, Blatt, Streifen, Konto-Sheet, Fehlerzeile, Leiste, Banner und die
Sperre sowie Login, Rechtsseiten, Auftrag, Offline-Seite und die Kopfzeile des Chat-Vollbilds bauen ihre Ränder jetzt mit
max(Wert, env(safe-area-inset-left|right)). Am Desktop und ohne Notch ändert sich nichts (Inset 0). Pins in test/knEfixCss.test.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:31:33 +00:00
ClaudeandClaude Sonnet 5.5 8e349f6eca Kundenansicht neu E-Fix F3c: 44-px-Tippfläche für Kündigen, Widerrufen, Vertragsbedingungen und Zahlen am Handy
"Verträge hier kündigen", "Vertrag widerrufen", die Vertragsbedingungen und das "Zahlen" der Zeile "Rechnung offen" waren 14-px-Textlinks
von etwa 16 px Höhe. Am Handy jetzt inline-flex mit 44 px Mindesthöhe; das "Zahlen" steht rechts in der Zeile (der Trenner davor ist ein
eigenes Element und am Handy ausgeblendet, textContent unverändert). Pin in test/knEfixCss.test.js, Tippflächen-Prüfung in den
Werkzeug-Ansichten kn-p2-abo-privat und kn-p2-website-rechnung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:30:45 +00:00
ClaudeandClaude Sonnet 5.5 92d7d247e9 Kundenansicht neu E-Fix F3a: Fokusring der Shell-Reiter liegt innen und ist sichtbar
.kn [tabindex]:focus-visible (module.css, 0-3-0, später geladen) und .kn button:focus-visible setzten outline-offset +2px und schlugen
.kn-t:focus-visible und .kn-reiter-t:focus-visible (0-2-0). Der Ring lag außerhalb und wurde von Balken und Reiterzeile abgeschnitten.
Jetzt .kn .kn-t[role="tab"] und .kn .kn-reiter-t[role="tab"] (0-4-0) mit Versatz -2px; der aktive Reiter auf dem schwarzen Gleiter
bekommt vom Gleiter einen hellen Innenring (Rückfall ohne Overlay: weißer Ring am Reiter). Pin in test/knEfixCss.test.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:30:06 +00:00
ClaudeandClaude Sonnet 5.5 112475e873 Kundenansicht neu E-Fix F2: Sperre der Mitarbeiter-Kundenansicht als echte Sperre in der neuen Seite
public/kundenansicht.js hängt bei 409 view_replaced und 401 view_expired .kb-view-overlay an den Body; die Regeln dafür standen nur in der
alten kundenbereich.css. In der neuen Seite entstand ein unformatierter Absatz mit nacktem Knopf unter der Leiste. Jetzt: fixiert, deckend weiß,
über Konto und Leiste, Text und Knopf "Tab schließen" (44 px, schwarz). Pin in test/knEfixCss.test.js, Ansicht kn-huelle-sperre im Werkzeug.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:27:07 +00:00
ClaudeandClaude Sonnet 5.5 d4050f870a Kundenansicht neu E-Fix F1: Kerninfo und Knopf der Bestellseiten passen in den nutzbaren Bereich (390x844, 375x667)
Die Messung im Werkzeug nahm innerHeight; nutzbar ist am Handy nur der Bereich zwischen Kopf und fester Leiste (735 px bei 844, 558 px bei 667).
Gemessen vor der Korrektur (Blockanfang bis Knopfunterkante, 375x667): Abo bestellen privat 799 px, Wechsel privat 888 px, Zusatzspeicher 567 px.

- Block knapp: Leistung als eine Zeile mit Link "Leistungen ansehen" auf die Leistungsbeschreibung, Preis, Fällig, Beginn, Laufzeit und Zahlung in je ein bis zwei Zeilen.
- Die ausführlichen Sätze stehen unter dem Knopf und der Kundenart-Zeile (baueEinzelheiten), ohne Klappe.
- Handy: Beschriftung läuft in der Zeile mit, kleinere Überschrift, knappere Abstände.
- Die Wortlaute des Blocks sind gekürzt und gehören in die Rechtsrunde.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:25:48 +00:00
ClaudeandClaude Sonnet 5.5 779320c080 Kundenansicht neu P7: Block der Bestellseiten kompakter, damit Kerninfo und Knopf auf ein Handy-Fenster passen
Die Wechselseite brauchte bei 390 x 844 bis zu 952 px vom Anfang des Blocks bis zum Knopf. Auf dem Handy stehen die Beschriftungen jetzt klein über dem Wert (weniger Abstand), und die Zeile Wartungs-Abo wiederholt die Leistungen nicht mehr, die schon in der Zeile Leistung stehen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 20:33:59 +00:00
ClaudeandClaude Sonnet 5.5 036bf4b0b9 Kundenansicht neu P7: Kündigen und Widerrufen in du (Fassung 6, C14)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 20:24:31 +00:00
ClaudeandClaude Sonnet 5.5 9166e023c5 Kundenansicht neu P7: bekannte Fehlertexte der Dienste in der neuen Ansicht in du
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 20:22:24 +00:00
ClaudeandClaude Sonnet 5.5 d17520c4f5 Kundenansicht neu P7: Gutschrift-Antrag schlank, Bedingungen als eigene Ansicht statt Klappe
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 20:20:57 +00:00
ClaudeandClaude Sonnet 5.5 9b00771d11 Kundenansicht neu P7: E-Mail-Speicher schlank, Plus mit Linkzeile, Warnung als eine Zeile
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 20:15:36 +00:00
ClaudeandClaude Sonnet 5.5 e7ac625f26 Kundenansicht neu P7: Wechselseite schlank, Sofortbeginn-Häkchen nur privat
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 20:13:49 +00:00
ClaudeandClaude Sonnet 5.5 c6ecb5242c Kundenansicht neu P7: Bestellseite schlank (Block, Linkzeile, Häkchen nur privat)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 20:11:44 +00:00
ClaudeandClaude Sonnet 5.5 fcf357c0b7 Kundenansicht neu P7: gemeinsame Bausteine der Bestellseiten (Block, Linkzeile, Kundenart-Zeile)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 20:09:04 +00:00
ClaudeandClaude Sonnet 5.5 841bc76bbb Kundenansicht neu P7: Gutschrift-Antrag in Fassung 6 mit Nachweis, Pin gegen Kundenart aus dem Body
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 20:07:05 +00:00
ClaudeandClaude Sonnet 5.5 da2fe83d47 Kundenansicht neu P7: Zusatzspeicher-Plus in Fassung 6 mit Wortlaut-Nachweis
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 20:06:04 +00:00
ClaudeandClaude Sonnet 5.5 528ca0875e Kundenansicht neu P7: K01 und K12 behaupten für Betriebe kein Sofortbeginn-Verlangen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 20:05:06 +00:00
ClaudeandClaude Sonnet 5.5 4e0e42f633 Kundenansicht neu P7: Wechsel Fassung 6, Betrieb ohne Sofortbeginn-Zeitstempel
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 20:03:46 +00:00
ClaudeandClaude Sonnet 5.5 99e1987bc4 Kundenansicht neu P7: Bestellung Fassung 6 mit Kundenart vom Server und Wortlaut-Nachweis
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 20:02:04 +00:00
ClaudeandClaude Sonnet 5.5 bf1e968a83 Kundenansicht neu P7: GET /api/abo liefert Fassung 6 und Wortlaut nur der neuen Ansicht
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 19:59:43 +00:00
ClaudeandClaude Sonnet 5.5 4a3b0030d2 Kundenansicht neu P7: Fassungsweiche und Vertragsnachweis (V12)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 19:59:06 +00:00
ClaudeandClaude Sonnet 5.5 71b2543e95 Kundenansicht neu P7: Wortlaut-Entwurf und Nachweistexte der Bestellseiten
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 19:58:05 +00:00
ClaudeandClaude Sonnet 5.5 bfe3895a50 Kundenansicht neu P7: Einschalten zeigt Linkzeile, Häkchen und Unterlagen der Fassung 6 aus P8
Verdrahtet P5 Task 15 und P7 Task 0 (N33): consentAbo liest Linkzeile und Hinweis aus WORTLAUT_V6, unterlagenFuer die fünf Funktionen aus FUNKTIONEN_UNTERLAGEN_V6 (Analyse bleibt bei den Alt-Unterlagen bis zur Freigabe), config.aboFunktionen.texte.hinweis entfällt, pdfAnhaenge liest PDFs der Fassung 6, sichereAdresse lässt /neu/rechtliches/ zu. Der Analyse-Kundenweg setzt wie der Team-Weg den Abo-Marker (unlockedBy und einrichtungAbrechnung abo).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 19:56:40 +00:00
ClaudeandClaude Sonnet 5.5 447129d4e2 zeitenBestellen: Pin auf die Listen in app.js an den Buchungskalender angepasst
VIEW_HASHES, APP_PARENT und FEATURE_LABELS tragen seit dem Buchungskalender
einen Eintrag zwischen Webshop und Zeiten. Die Regexe folgen den echten Listen;
app.js bleibt unverändert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 19:30:54 +00:00
ClaudeandClaude Sonnet 5.5 524ac64d20 Kundenansicht der Mitarbeiter: Einschalten gesperrt statt 403-Text nach dem Tipp
Die Hülle reicht viewAs der Sitzung an die Einschalt-Option der Module durch
(News, Terminplaner, Webshop, Buchungskalender, Zeiten), auch wenn ein Modul es
nicht nennt. In Entdecken ist der Einschalten-Knopf gesperrt, daneben steht
"In der Kundenansicht nicht möglich." Meine bleibt bei gesperrtem Knopf auf der
Bestätigungsseite (jetzt gepinnt).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 19:30:26 +00:00
ClaudeandClaude Sonnet 5.5 0df96611dc module.css: Korrekturen aus der Sichtprüfung, Bild der Analyse in neuer Optik
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 19:23:05 +00:00
ClaudeandClaude Sonnet 5.5 f76749133f module.css: Analyse hell (an-, hs-)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:58:57 +00:00
ClaudeandClaude Sonnet 5.5 a9b50a5542 module.css: Öffnungszeiten und News (zt-, fn-, nw-)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:57:29 +00:00
ClaudeandClaude Sonnet 5.5 b20ac71750 module.css: Webshop und Buchungskalender (sh-, bk-)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:56:07 +00:00
ClaudeandClaude Sonnet 5.5 cbd36c0ae6 module.css: Terminplaner und gemeinsame Bestellbausteine (tm-)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:54:49 +00:00
ClaudeandClaude Sonnet 5.5 a6a656f18a module.css: Gerüst, Prüftest und gemeinsame Bausteine unter .kn
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:53:14 +00:00
ClaudeandClaude Sonnet 5.5 5534655671 Analyse: im Abo-Modell Linkzeile und Zielgruppen-Häkchen, Hosting berechtigt, Reiter in der Etage
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:50:49 +00:00
ClaudeandClaude Sonnet 5.5 c37faabcd3 Öffnungszeiten live: im Abo-Modell Einschalten und kein Behalten-Weg
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:49:08 +00:00
ClaudeandClaude Sonnet 5.5 5f3d8f3e96 Buchungskalender: im Abo-Modell Anfrage ohne Preis und Einschalten nach der Zusage
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:46:42 +00:00
ClaudeandClaude Sonnet 5.5 28da4180bd Webshop: im Abo-Modell Anfrage ohne Unternehmer-Haken, Privat-Hinweis, Einschalten
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:45:17 +00:00
ClaudeandClaude Sonnet 5.5 1cabab2826 News: im Abo-Modell Einschalten statt Bestellen, Projektwahl über dataset
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:40:59 +00:00
ClaudeandClaude Sonnet 5.5 b6f70a502a Terminplaner: im Abo-Modell Einschalten statt Bestellen, ohne Häkchen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:39:54 +00:00
ClaudeandClaude Sonnet 5.5 364ded1662 Funktionsmodule: gemeinsame Einschalt-Seite für das Abo-Modell
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:38:51 +00:00
ClaudeandClaude Sonnet 5.5 a50c5ef6c9 Module: im Abo-Modell Reiter mit Kurzlabels an die Etage melden (C2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:37:12 +00:00
ClaudeandClaude Sonnet 5.5 b91ba1d5cb Funktionen neu: Korrekturen aus den Gates (Hover des Knopfes bleibt #111, Ticket auch am Feature)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:31:08 +00:00
ClaudeandClaude Sonnet 5.5 c87c87e76b Funktionen neu: Bilder für Entdecken aus Testdaten (Einbettungen auf Beispiel-Website)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:25:45 +00:00
ClaudeandClaude Sonnet 5.5 9ca7900213 Funktionen neu: Aussehen (weiß, Pillen, 44-px-Zeilen) mit Quelltext-Prüfung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:14:08 +00:00
ClaudeandClaude Sonnet 5.5 36890c6734 Funktionen neu: Bestätigung als Overlay der Zurück-Geste, Erfolgsmeldung, Rückkehr nach dem Wechsel
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:11:36 +00:00
ClaudeandClaude Sonnet 5.5 f30e72ebbb Funktionen neu: Ansicht mit Meine und Entdecken, Website-Auswahl, Hinweise je Abo-Lage, Rechnungszeile
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:10:02 +00:00
ClaudeandClaude Sonnet 5.5 57112cc51f Funktionen neu: Einschalten senden, 409-Folgen, Wechsel mit gemerkter Funktion, zeigeEinschalten für die Module
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:07:30 +00:00
ClaudeandClaude Sonnet 5.5 906c9ec1f0 Funktionen neu: Bestätigungsseite zum Einschalten (Linkzeile, Häkchen nur für privat in der Frist)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:05:18 +00:00
ClaudeandClaude Sonnet 5.5 c72c9d9f5a Funktionen neu: Modell für Meine und Entdecken (mehrere Websites, Anfragestatus)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:03:22 +00:00
ClaudeandClaude Sonnet 5.5 437947cdf6 Funktionen neu: Einschalt-Logik (Vorprüfung, Fristen, 409-Codes) und Testdaten
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:01:27 +00:00
ClaudeandClaude Opus 5.5 1cd16ff293 Merge feature/kundenansicht-neu (P5, P6, P8, P6b) in kn/ui vor P3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:55:13 +00:00
ClaudeandClaude Sonnet 5.5 01c4f22a84 Kundenansicht neu P2: Test der Dashboard-Weiche läuft den Fall ohne Datei gegen einen leeren Nachbau
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:53:36 +00:00
ClaudeandClaude Sonnet 5.5 04a81a1d46 Kundenansicht neu P2: Feinschliff nach der Sichtprüfung (Kontrast der Menüzahl, Links, Anmelde-Hinweise)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:43:21 +00:00
ClaudeandClaude Sonnet 5.5 eb7c44ac11 Kundenansicht neu P2: Adressen auf derselben Herkunft zulassen (Vorschau, Zahlungsseiten)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:24:27 +00:00
ClaudeandClaude Sonnet 5.5 2ac7d3f64c Kundenansicht neu P2: Aussehen der Ansichten
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:21:31 +00:00
ClaudeandClaude Sonnet 5.5 3c2450dd2e Kundenansicht neu P2: öffentliche Seiten und Auftragsbestätigung im neuen Stil, Text unverändert
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:17:45 +00:00
ClaudeandClaude Sonnet 5.5 ade7e872d9 Kundenansicht neu P2: Verträge mit Reitern
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:14:28 +00:00
ClaudeandClaude Sonnet 5.5 861b74f70e Kundenansicht neu P2: Passwort ändern als Etage
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:12:32 +00:00
ClaudeandClaude Sonnet 5.5 d99c29b6c6 Kundenansicht neu P2: Übernahmegutschrift im Wortlaut
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:11:15 +00:00
ClaudeandClaude Sonnet 5.5 1b66f6aa81 Kundenansicht neu P2: Wechsel im Wortlaut der Fassung 5 mit gemerkter Funktion
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:09:08 +00:00
ClaudeandClaude Sonnet 5.5 27e6c8fe4f Kundenansicht neu P2: Bestellseite im Wortlaut der Fassung 5
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:07:22 +00:00
ClaudeandClaude Sonnet 5.5 4473a746ee Kundenansicht P6b: Team-Weg der Analyse im Abo-Modell (WOMS)
POST/GET /api/admin/analyse/:projectId kennt jetzt das Abo-Modell:
- Gilt aboModellFuerKunde, prüft das Einschalten das Abo über aboDeckt
  (Hosting und Wartung decken die Analyse); fehlt es, 409 mit code
  abo_fehlt, grund, wechsel (und ab) wie bei der Team-Aktivierung der
  anderen Funktionen, bevor etwas geschrieben wird. Lesefehler: 503.
- Das neu eingeschaltete Dokument trägt den Abo-Marker (unlockedBy und
  einrichtungAbrechnung 'abo', istAboModell); activateAnalyse nimmt dazu
  den Parameter abrechnung, setzt ihn im selben Schritt wie den Zustand
  und stellt ihn bei der Rücknahme wieder her.
- GET: Eignung und Text folgen dem Abo-Modell (Hosting-oder-Wartungs-Text).
- Alle anderen Kunden und das Ausschalten laufen unverändert.

Tests: test/adminAnalyseAboModell.test.js (9 Fälle).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:06:33 +00:00
ClaudeandClaude Sonnet 5.5 f9c4fbe9bb Kundenansicht neu P2: E-Mail-Speicher im Wortlaut der Fassung 5
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:05:45 +00:00
ClaudeandClaude Sonnet 5.5 8a23652e07 Kundenansicht neu P2: Stundenkonto mit Vorschuss-Anzeige
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:02:59 +00:00
ClaudeandClaude Sonnet 5.5 989e7be640 Kundenansicht neu P2: Reiter Abo und Abo-Auswahl
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:02:10 +00:00
ClaudeandClaude Sonnet 5.5 cf391eb117 Kundenansicht neu P2: Reiter Rechnungen mit Aufträgen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:00:28 +00:00
ClaudeandClaude Sonnet 5.5 8c8d29657d Kundenansicht neu P2: Ansicht Chat mit dem Composer und lilafreier CSS-Kopie
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:59:28 +00:00
ClaudeandClaude Opus 5.5 72c5fa9fea Merge kn/recht (P8 Rechtsrunde Fassung 6) in feature/kundenansicht-neu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:59:27 +00:00
ClaudeandClaude Sonnet 5.5 9a3ce7c8bf Kundenansicht P6: Zusage-Mails K42 und K43 für Webshop- und Buchungskalender-Anfrage ohne Preis
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:56:52 +00:00
ClaudeandClaude Sonnet 5.5 a3831040ba Kundenansicht neu P2: Ansicht Website mit Projektdetails als Etage
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:55:16 +00:00
ClaudeandClaude Sonnet 5.5 bb1efa5c9e Kundenansicht P6: Ticket-Texte zum Stundenkonto je Abrechnung (Abo, Projekt)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:54:52 +00:00
ClaudeandClaude Sonnet 5.5 e036d24ae5 Kundenansicht neu P2: Bausteine für Projektdetails und Aktivität
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:53:22 +00:00
ClaudeandClaude Sonnet 5.5 df53cd039b Kundenansicht P6: Widerruf des Wechsels beendet Wartungs-Funktionen, die Analyse bleibt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:52:18 +00:00
ClaudeandClaude Sonnet 5.5 166188c069 Kundenansicht neu P2: Installationshinweis
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:51:44 +00:00
ClaudeandClaude Sonnet 5.5 3c44aaea77 Kundenansicht neu P2: Zeile Rechnung offen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:51:10 +00:00
ClaudeandClaude Sonnet 5.5 b77c3291c5 Kundenansicht neu P2: Wortlaut-Orakel für die Texte der Fassung 5
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:50:49 +00:00
ClaudeandClaude Sonnet 5.5 d5a7312896 Kundenansicht P6: Wechsel merkt sich die gewünschte Funktion (details.funktion, wechselFunktion)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:50:24 +00:00
ClaudeandClaude Sonnet 5.5 91e5bca1c5 Kundenansicht neu P2: Hilfen, Testdaten und Quelltext-Regeln
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:49:18 +00:00
ClaudeandClaude Sonnet 5.5 db47f13510 Kundenansicht P6: Abo-Ende-Abgleich im Tagesjob
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:47:12 +00:00
ClaudeandClaude Sonnet 5.5 7a03f073bc Kundenansicht P6: alte Abgleiche lassen Abo-Modell-Dokumente dem neuen Abgleich, Zeiten ohne Behalten
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:46:15 +00:00
ClaudeandClaude Sonnet 5.5 d8c952ac2a Kundenansicht P6: News-Löschschritt und Zurücksetzen nach der Löschung im Abo-Ende-Abgleich
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:44:08 +00:00
ClaudeandClaude Sonnet 5.5 385844192d Kundenansicht neu P1: Vollständigkeitstest der Dateiübersicht
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:43:58 +00:00
ClaudeandClaude Sonnet 5.5 5297a05125 Kundenansicht neu P1: Korrekturen aus der Sichtprüfung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:43:19 +00:00
ClaudeandClaude Sonnet 5.5 92ac61dffd Kundenansicht P6: Abo-Ende-Abgleich für News, Terminplaner, Webshop und Buchungskalender
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:42:06 +00:00
ClaudeandClaude Sonnet 5.5 de8b0c0fca Kundenansicht P6: Team-Aktivierung mit Abo-Prüfung und Abrechnung (V10), Projektstart ohne Abo nur mit Warnung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:39:07 +00:00
ClaudeandClaude Sonnet 5.5 408f7d0cc2 Kundenansicht P6: K38 an den Kunden und T24 ans Team beim Einschalten
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:34:22 +00:00
ClaudeandClaude Sonnet 5.5 cdc8f5c424 Kundenansicht P6: K02 und K03 nennen das Ende der Abo-Funktionen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:31:06 +00:00
ClaudeandClaude Sonnet 5.5 9489a70f0c Kundenansicht P6: Mail-Builder für K38, K39, T24 und die Zeiten-Varianten K40, K41
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:29:34 +00:00
ClaudeandClaude Sonnet 5.5 664043e8e9 Kundenansicht P6: Mailvorlagen K38 bis K41 und T24 für das Abo-Modell
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:28:15 +00:00
ClaudeandClaude Sonnet 5.5 142aa6e9d6 Kundenansicht neu P1: weiße Seite, Manifest und leere Ansichten für P2 und P3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:26:23 +00:00
ClaudeandClaude Sonnet 5.5 3d4d49de82 Kundenansicht P6: Team-Endpunkt Stundenkonto mit Vorschuss
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:24:31 +00:00
ClaudeandClaude Sonnet 5.5 7595844170 Kundenansicht neu P1: Hülle mit Start über /api/auth/me und Modul-Lader
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:24:16 +00:00
ClaudeandClaude Sonnet 5.5 f016d7ae6c Kundenansicht P6: Vorschuss im Stundenkonto (Einrichtung statt Mehraufwand)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:23:39 +00:00
ClaudeandClaude Sonnet 5.5 710c57cf87 Kundenansicht neu P1: Fahrstuhl-Zeile, Desktop-Menü und Tastaturzustand
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:14:55 +00:00
ClaudeandClaude Sonnet 5.5 57d5db2344 Kundenansicht P5: Analyse im Abo-Modell (Hosting, Zielgruppe erzwungen, Nachweis, Tagesprüfung)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:14:35 +00:00
ClaudeandClaude Sonnet 5.5 0432b7e01d Rechtstexte: Hochladen-Skript für WOMS Rechtliches (ein Dokument je Aufruf, nur mit Freigabe)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:13:20 +00:00
ClaudeandClaude Sonnet 5.5 23252df359 Kundenansicht P5: Shop-Anfrage im Abo-Modell (Unternehmer aus Kundenart)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:11:13 +00:00
ClaudeandClaude Sonnet 5.5 99d7e2bdca Rechtstexte: PDF-Bau für Fassung 6 (Modi abo6 und funktionen6, nach Freigabe)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:10:31 +00:00
ClaudeandClaude Sonnet 5.5 b6f2df0d4a Kundenansicht P5: Katalog, Abo-Stand und Anfragestatus in GET /api/funktionen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:09:51 +00:00
ClaudeandClaude Sonnet 5.5 4953c8fd0f Kundenansicht neu P1: Konto mit Avatar, Sheet und Popover
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:09:40 +00:00
ClaudeandClaude Sonnet 5.5 b4b18792c3 Rechtsrunde Fassung 6: Entscheidungsliste, Kanzlei-Fragen, Datenschutz-Prüfpunkte, Ablauf
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:09:34 +00:00
ClaudeandClaude Sonnet 5.5 e30318a470 Kundenansicht P5: POST /api/funktionen/:key/einschalten (nur neue Ansicht)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:07:30 +00:00
ClaudeandClaude Sonnet 5.5 3241aedc3c Kundenansicht neu P1: Widerrufsstreifen mit GET /api/fristen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:06:51 +00:00
ClaudeandClaude Sonnet 5.5 a8b8838d9e AVV Fassung 6: Terminplaner, Webshop, Buchungskalender mit Klick auf die Linkzeile (Entwurf)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:06:02 +00:00
ClaudeandClaude Sonnet 5.5 e144a9611d Kundenansicht neu P1: Zähler für Chat und Verträge nach V3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:05:51 +00:00
ClaudeandClaude Sonnet 5.5 f25b7608b4 Website-Analyse Fassung 6: Hosting- und Wartungs-Abo, Leistungsbeschreibung und AVV (Entwurf)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:04:58 +00:00
ClaudeandClaude Sonnet 5.5 748226d631 Kundenansicht neu P1: Etage 2 mit Zurück, Reiterzeile und Brotkrume
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:04:46 +00:00
ClaudeandClaude Sonnet 5.5 9e7db59326 Leistungsbeschreibungen Fassung 6: Webshop und Buchungskalender (Entwurf)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:03:46 +00:00
ClaudeandClaude Sonnet 5.5 f5b43c55a9 Kundenansicht P5: generisches Einschalten im Abo (einschalten, Heilung, Ticket-Marker)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:03:17 +00:00
ClaudeandClaude Sonnet 5.5 d112fe2326 Leistungsbeschreibungen Fassung 6: Terminplaner, News-Pinnwand, Öffnungszeiten live (Entwurf)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:00:55 +00:00
ClaudeandClaude Sonnet 5.5 a51643c4a5 Kundenansicht neu P1: gemeinsame Reiter mit Balken, Overlay-Rückfall und Zählern
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:00:39 +00:00
ClaudeandClaude Sonnet 5.5 1d149f50b1 Kundenansicht P5: Einrichtungs-Ticket mit Abrechnung, unlockedBy für alle Funktionen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:00:00 +00:00
ClaudeandClaude Sonnet 5.5 819725a3c1 Kundenansicht neu P1: Router mit Anker-Kette, Overlay und Verlaufsstempel
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:56:10 +00:00
ClaudeandClaude Sonnet 5.5 b000dba698 Kundenansicht P5: Kundenart im Zustimmungsnachweis
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:56:00 +00:00
ClaudeandClaude Sonnet 5.5 33b7a4ce0f Rechtstexte: Generator-Satz funktionen6 im neuen Stil
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:55:41 +00:00
ClaudeandClaude Sonnet 5.5 4bf1b67e67 Kundenansicht P5: Katalogfelder, consentAbo mit Hinweis und Unterlagen-Quelle im Funktionen-Register
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:55:19 +00:00
ClaudeandClaude Sonnet 5.5 90b7f10f1a Abo-Bedingungen Fassung 6: Seite im neuen Stil (Entwurf)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:53:49 +00:00
ClaudeandClaude Sonnet 5.5 8e0d2b2736 Kundenansicht P5: gesammelte Widerrufsfristen (sammleFristen, GET /api/fristen)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:53:29 +00:00
ClaudeandClaude Sonnet 5.5 9b177a15a3 Kundenansicht neu P1: Routentabelle mit allen alten Hashes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:51:43 +00:00
ClaudeandClaude Sonnet 5.5 882438df38 Kundenansicht P5: Abo-Zuordnung der Funktionen (aboProdukt, aboDeckt)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:51:38 +00:00
ClaudeandClaude Sonnet 5.5 7f4e84e0b1 Abo-Bedingungen Fassung 6: Entwurf der Änderungen im Wortlaut
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:51:35 +00:00
ClaudeandClaude Sonnet 5.5 6f0808647f Kundenansicht neu P1: h(), api() mit ApiFehler und Quelltext-Regel
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:50:12 +00:00
ClaudeandClaude Sonnet 5.5 5c21023e28 Kundenansicht P5: Kundenart beim Anlegen Pflicht (ab Schalter pilot), beim Ändern optional
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:49:25 +00:00
ClaudeandClaude Sonnet 5.5 46edb7c418 Kundenansicht neu P1: Tokens und Basis-CSS im weißen Stil
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:48:54 +00:00
ClaudeandClaude Sonnet 5.5 214d698d6f Abo-Fassung 6: Wortlaute für Linkzeile und Häkchen, Unterlagen-Tabelle der Funktionen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:48:24 +00:00
ClaudeandClaude Sonnet 5.5 8d3eb73db7 Kundenansicht P5: Kundenart-Helfer und Felder in /api/auth/me
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:48:07 +00:00
ClaudeandClaude Sonnet 5.5 2a8db582cb Abo-Bedingungen Fassung 5: Archiv vorbereitet (erst mit P10 aktiv)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:47:37 +00:00
ClaudeandClaude Sonnet 5.5 65b1389b55 Abo-Fassung 6: Fassungstabelle, aktuelleAboFassung, termsVersionNeu
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:47:05 +00:00
ClaudeandClaude Sonnet 5.5 c5b1333953 Kundenansicht P5: Setup-Skript für Kundenart und Einrichtungs-Marker
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:47:04 +00:00
ClaudeandClaude Sonnet 5.5 c2b7073d99 Kundenansicht neu: Einstieg für Sichtprüfungen über das Werkzeug
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 14:24:06 +00:00
ClaudeandClaude Sonnet 5.5 c6a9a2c4a6 Kundenansicht neu: /api/auth/me meldet ansicht neu oder alt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 13:01:40 +00:00
ClaudeandClaude Sonnet 5.5 f2c9eb107a Kundenansicht neu: Dashboard-Weiche vor den statischen Dateien, Platzhalter-Seite
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 12:59:48 +00:00
ClaudeandClaude Sonnet 5.5 7c4a4a567d Kundenansicht neu: Pilot-Schalter (off/pilot/on) mit Randfall-Tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 12:56:13 +00:00
ClaudeandClaude Opus 5.5 f573d49f0a Plan: Kundenansicht neu – elf Paket-Pläne P0–P10 und Nachträge der Planrunde
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:21:04 +00:00
ClaudeandClaude Opus 5.5 08dd766e9c Plan: Kundenansicht neu – Übersicht und Verträge V1–V13
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:48:32 +00:00
ClaudeandClaude Opus 5.5 d3d28492c0 Spec: Kundenansicht neu (weiß, Fahrstuhl-Zeile, Funktionen im Abo, schlanke Bestätigungen)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:52:02 +00:00
knsoandClaude Opus 5.5 d33a483fd8 Buchungskalender: Freigeben zeigt bei 409 die richtige Meldung
Bisher stand bei jedem 409 „Der Zeitraum ist inzwischen belegt.“, auch wenn der Rechnungsauftrag fehlte (zustimmung_fehlt) oder sich der Stand geändert hatte (status_ungueltig). Jetzt nur bei zeitraum_belegt; sonst der Text aus buchungen-fehler.js. Aufgefallen beim ersten Test mit Donau Home.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:35:40 +00:00
knsoandClaude Opus 5.5 a45f436124 Buchungskalender: Zustimmung per WhatsApp zusammenführen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:13:33 +00:00
knsoandClaude Opus 5.5 1e0eb9f569 Buchungskalender: Preise je Personenzahl (Portal-Editor)
Neuer Haken "Preis hängt von der Personenzahl ab" im Preis-Editor (nur bei Höchstzahl ab 2 Personen). Angeschaltet zeigt jede Staffel k Euro-Felder (1 Person bis k Personen, k = min(Höchstzahl, 10)) statt eines Preises; "Personen im Preis inklusive" und "Aufpreis je weiterer Person" sind dann ausgeblendet, ihre Werte gehen unverändert mit. Speichern sendet personenCent und preisProNachtCent = personenCent[0]. Beim Laden ist der Haken an, wenn personenCent gespeichert sind. Kundenansicht (nur lesen) sperrt Haken und Felder. Felder brechen auf dem Handy um. Tests im Fake-DOM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:06:04 +00:00
knsoandClaude Opus 5.5 02a3732e4a Funktionen: Zustimmung in Textform auch per WhatsApp
Terminplaner, Webshop und Buchungskalender nehmen beim Aktivieren durch das Team den Weg whatsapp an (Nachweis: „per WhatsApp in Textform“). Die Analyse bleibt bei Chat oder E-Mail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:02:49 +00:00
knsoandClaude Opus 5.5 a4ade5f649 Buchungskalender: Preise je Personenzahl (Server)
Jede Staffel darf personenCent tragen (Preis pro Nacht für 1 bis k Personen, k = 2 bis 10), ohne Schemaänderung im JSON-Feld staffeln. pruefePreise prüft Länge, Werte und Gleichheit über alle Staffeln und setzt preisProNachtCent = personenCent[0]. berechnePreis wählt die Spalte der Gästezahl (Posten mit Personenzahl), bei mehr Gästen als Spalten gilt die letzte plus Zusatzperson-Aufpreis. preiseVollstaendig prüft die Konsistenz. Ohne personenCent rechnet alles wie bisher. Tests mit Mimis Preismatrix, Doku in unterkunft-api.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:02:45 +00:00
knsoandClaude Opus 5.5 b264d7f734 Buchungskalender: große Felder als TEXT (Zeilenbreite in Appwrite)
ukAnfragen überschritt mit gast/posten/stornobedingungenText als VARCHAR die MariaDB-Zeilenbreite von 65535 Byte; das Setup brach in Produktion nach 22 Attributen ab. Test sichert die Breite aller Collections ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:57:10 +00:00
knsoandClaude Opus 5.5 69df9e7046 Tests: Aufträge-Rechtstexttests kennen den Satz unterkunft (Merge-Folge)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:49:42 +00:00
knsoandClaude Opus 5.5 a1400ac315 Datenschutz: Abschnitt 6f Buchungskalender
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:47:59 +00:00
knsoandClaude Opus 5.5 3526909d61 Buchungskalender: Mail-Nummern K36, K37, T23 (K31/K32/T22 gehören den Aufträgen)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:47:29 +00:00
knsoandClaude Opus 5.5 fd971309a8 Buchungskalender: origin/main (Aufträge) zusammenführen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:46:31 +00:00
knsoandClaude Opus 5.5 59783ce685 Buchungskalender: Livegang – für Mimi kostenlos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:41:48 +00:00
knsoandClaude Opus 5.5 1c6b6bf8d6 Buchungskalender: Abschluss-Fixwelle B zusammenführen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:34:30 +00:00
knsoandClaude Opus 5.5 a6c6dabaa3 Buchungskalender: Abschluss – Storno-Muster-Zitat bleibt bis zur Angleichung mit dem Portal-Mustertext (Fixwelle B)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:33:49 +00:00
knsoandClaude Opus 5.5 3f5b030a50 Buchungskalender: Abschluss – L5 Livegang-Notiz (Reihenfolge Datenbank/Deploy/Pilot, Indizes, Textform, WOMS-Push, Paragraf 6f)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:32:26 +00:00
knsoandClaude Opus 5.5 592ac20503 Buchungskalender: Abschluss – Mail-Texte G16 und K33
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:32:02 +00:00
knsoandClaude Opus 5.5 f4bc752553 Buchungskalender: Abschluss – L2-L4 Rechtstexte (Hinweis-Mail mit Loeschdatum, Linkgueltigkeit, Kontoloeschung), HTML/PDF/sha256 neu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:32:02 +00:00
knsoandClaude Opus 5.5 1d9515823c Buchungskalender: Abschluss – Kleinigkeiten Server (Betrag/Waehrung, Erstattungs-Webhook, Obergrenze, Zaehler, Cache, Kommentare, Setup-Check)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:29:55 +00:00
knsoandClaude Opus 5.5 6d35674c1e Buchungskalender: Abschluss – L1 Textform-Zustimmung fuer die Aktivierung ohne Zahlung (Server, GET-Feld unterkunft)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:27:50 +00:00
knsoandClaude Opus 5.5 4f9fb6b109 Buchungskalender: Abschluss – S7 Buchungslink-Gueltigkeit (90 Tage nach Abreise, 30 Tage nach Absage), tokenHash beim Anonymisieren leeren
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:26:12 +00:00
knsoandClaude Opus 5.5 17400cac1f Buchungskalender: Abschluss – S6 Belegung laedt nur Relevantes, bis-Indizes, 10-s-Speicher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:25:17 +00:00
ClaudeandClaude Sonnet 5.5 1cbec5b32d Kontrolle: Betreff-Zusaetze Erinnerung und Kopie, Mail-Protokolle ohne Meldung und Adresse
Die Erinnerung beginnt mit 'Erinnerung: ', die Teamkopie mit 'Kopie: ' (der Mailer
nimmt dafuer ein optionales subjectPrefix). Die Protokollzeilen in mails.js nennen
nur Nummer und Fehlerkennung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:24:59 +00:00
knsoandClaude Opus 5.5 ab838d07bc Buchungskalender: Abschluss – S5 Hinweis-Mail K35 vor der Loeschung, Rechnungen im Zustand abgeschaltet lesbar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:23:56 +00:00
knsoandClaude Opus 5.5 1fe1ae31bf Buchungskalender: Abschluss – Preis-Editor löscht Feldfehler beim Tippen, Kalenderraster volle Breite, Export-Link-Feld
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:22:00 +00:00
knsoandClaude Opus 5.5 e2fc4eb676 Buchungskalender: Abschluss – S4 CSS-Variablen im Ticket-Text
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:21:43 +00:00
knsoandClaude Opus 5.5 5bf1504ce8 Buchungskalender: Abschluss – Portal-Fehlertexte für alle Servercodes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:21:33 +00:00
knsoandClaude Opus 5.5 54310d9bae Buchungskalender: Abschluss – S2 Setup-Test kennt den unique-Index
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:21:29 +00:00
knsoandClaude Opus 5.5 727613d0e2 Buchungskalender: Abschluss – S2 Rechnungsnummer unter einer Sperre, unique-Index; S3 Storno mit Ursprungs-Aussteller
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:21:19 +00:00
knsoandClaude Opus 5.5 392b85c1f0 Buchungskalender: Abschluss – Buchungsseite zeigt 409-Codes, nur https-Zahlung, Feldfehler barrierefrei
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:21:13 +00:00
knsoandClaude Opus 5.5 a071e57fb7 Buchungskalender: Abschluss – Widget-CSS ohne @layer, alles unter .wku-root, Reset, Fenster 600 Tage, Serverfehlertexte
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:20:27 +00:00
ClaudeandClaude Sonnet 5.5 b6c1d81342 Kontrolle: Bestaetigen per Link prueft Token und Ablauf in der Sperre erneut
Ein Token-Tausch durch die Erinnerung zwischen Pruefung und Sperre ergibt 404 wie bei
einem ungueltigen Link; das Portal ist nicht betroffen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:20:24 +00:00
knsoandClaude Opus 5.5 435dd0b746 Buchungskalender: Abschluss – S1 keine doppelte Erstattung nach Heilung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:20:23 +00:00
ClaudeandClaude Sonnet 5.5 c2245b6905 Kontrolle: Projektkonto und Rechnungsbau waehlen dieselbe Aufwands-AB
Gemeinsame Auswahl juengsteAufwandAb (gueltig, jueengste nach gesendetAm) im AB-Speicher.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:19:48 +00:00
ClaudeandClaude Sonnet 5.5 4177b76d82 Kontrolle: Schlussrechnung setzt die AB aus dem Projektdatensatz auf abgerechnet
Nach dem Umhaengen gilt die neue AB, nicht die aus den Stripe-Metadaten.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:19:04 +00:00
ClaudeandClaude Sonnet 5.5 1bb172ad91 Kontrolle: Storno einer Anzahlung bei bestehendem Schluss-Entwurf der AB abgewiesen
409 'Erst den Entwurf der Schlussrechnung löschen.'; das Storno einer Anzahlung laeuft
unter der Kundensperre.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:18:46 +00:00
ClaudeandClaude Sonnet 5.5 e3ae152f12 Kontrolle: Senden eines Anzahlungs- oder Schluss-Entwurfs prueft die AB unter der Kundensperre erneut
Unter sperreRechnung(kundenId): AB noch versendet oder bestaetigt, bei Schluss keine
andere festgeschriebene Schlussrechnung, bei Anzahlung Summe aller festgeschriebenen
Rechnungen plus diese nicht ueber der AB-Summe, sonst 409 und nichts wird finalisiert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:18:22 +00:00
ClaudeandClaude Sonnet 5.5 57d1ab82c6 Kontrolle: Anzahlung und Schlussrechnung nehmen das Zahlungsziel der AB
Ohne daysUntilDue gilt bei Anzahlung und Schluss auftrag.zahlungszielTage (Route
und createProjectInvoice), sonst weiterhin 14 Tage; eine Angabe gilt vor der AB.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:17:06 +00:00
ClaudeandClaude Sonnet 5.5 d96d09f589 Kontrolle: Steuernummer steht im Rechnungsfuss der Projektrechnungen
Die Steuernummer aus den Finanz-Einstellungen kommt als Zeile direkt nach dem
§ 19-Satz in den Fuss; ohne Wert bleibt der Fuss unveraendert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:15:56 +00:00
knsoandClaude Opus 5.5 2df56eccc5 Buchungskalender: UI-Durchlauf – Screenshots wie im Webshop, Sichtbarkeitsprüfung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:10:00 +00:00
knsoandClaude Opus 5.5 838bc96d1b Buchungskalender: UI-Durchlauf – Text „null“ in der Summe, Knopf- und Feldgestaltung im Portal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:09:57 +00:00
knsoandClaude Opus 5.5 f5e304ad7a Buchungskalender: UI-Durchlauf – Platzhalter in Stornobedingungen blockieren das Speichern
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:09:57 +00:00
knsoandClaude Opus 5.5 196921fdf0 Buchungskalender: UI-Durchlauf – Feldfehler der Buchungsseite verschwinden beim Ausfüllen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:09:57 +00:00
knsoandClaude Opus 5.5 03ae8c77cd Buchungskalender: UI-Durchlauf (Playwright)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:06:33 +00:00
knsoandClaude Opus 5.5 cb0799a35c Buchungskalender: UI-Durchlauf – Zustimmungs-Hinweis verschwindet nicht
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:05:45 +00:00
knsoandClaude Opus 5.5 e5793fd151 Buchungskalender: Abgleich – G22/K33-Platzhalter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:58:50 +00:00
knsoandClaude Opus 5.5 74543b75c3 Buchungskalender: Abgleich – API-Doku mit den bindenden Formen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:56:03 +00:00
knsoandClaude Opus 5.5 c3eef4747d Buchungskalender: Abgleich – T22/K34 passen zu den Vorlagen, Config-Werte geprüft
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:55:32 +00:00
knsoandClaude Opus 5.5 fbd07b3d8c Buchungskalender: Abgleich – iCal-Export (Escape, aktive Unterkünfte, ab gestern, Rate-Limit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:54:16 +00:00
knsoandClaude Opus 5.5 95cbd473d0 Buchungskalender: Abgleich – gleichzeitige Zweitzahlung wird erstattet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:53:36 +00:00
knsoandClaude Opus 5.5 f01e6def59 Buchungskalender: Abgleich – art monteurzimmer, Zurückziehen-409 mit Meldung, Tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:53:13 +00:00
knsoandClaude Opus 5.5 54442a4682 Buchungskalender: Abgleich – deutsche Fehlertexte der Fachcodes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:53:13 +00:00
knsoandClaude Opus 5.5 b992dd2388 Buchungskalender: Abgleich – Stornomuster wortgleich zur Doku
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:52:42 +00:00
knsoandClaude Opus 5.5 2eb38032d3 Buchungskalender: Abgleich – Rechnungs-PDF 200-Pfad getestet, 501 nur bei fehlendem Modul
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:52:13 +00:00
knsoandClaude Opus 5.5 d27f6d5ddb Buchungskalender: Abgleich – Testprotokoll der Mails und Config-Vorgaben
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:51:44 +00:00
knsoandClaude Opus 5.5 83cdab405b Buchungskalender: Strang ui zusammenführen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:50:03 +00:00
knsoandClaude Opus 5.5 cebde0e070 Buchungskalender: Strang widget zusammenführen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:50:03 +00:00
knsoandClaude Opus 5.5 97dd84fbb8 Buchungskalender: Strang mail zusammenführen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:49:33 +00:00
knsoandClaude Opus 5.5 7b978ad2e4 Buchungskalender: Strang server zusammenführen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:49:27 +00:00
knsoandClaude Opus 5.5 35b0438d00 Buchungskalender: Team-API wie Webshop (anfrage null, Stripe-Status)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:46:30 +00:00
knsoandClaude Opus 5.5 9d01d38a45 Buchungskalender: Bestellung auf Anfrage, Freischaltung und Ticket
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:44:21 +00:00
knsoandClaude Opus 5.5 827ee54875 Buchungskalender: Aufbewahrung – Sperre wie Ablauf, Restpunkte
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:43:44 +00:00
knsoandClaude Opus 5.5 c019d3c3b0 Buchungskalender: Aufbewahrung und Löschfristen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:38:35 +00:00
knsoandClaude Opus 5.5 f57a3d477e Buchungskalender: iCal-Import und -Export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:32:34 +00:00
knsoandClaude Opus 5.5 e3b399e003 Buchungskalender: Zustimmungs-Test streng, Online-Zahlung nur beim Einschalten prüfen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:32:20 +00:00
knsoandClaude Opus 5.5 32cffb3518 Buchungskalender: Verwaltungs-API – Einstellungen, Zustimmung, Online-Zahlung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:30:20 +00:00
knsoandClaude Opus 5.5 5541e0cb60 Buchungskalender: Rechtstexte – automatische Erstattung, Stripe-Rücksprung, Kartenzahlung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:27:19 +00:00
knsoandClaude Opus 5.5 d0ab92658a Buchungskalender: Verwaltungs-API für Vermieter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:26:46 +00:00
knsoandClaude Opus 5.5 05006f1480 Buchungskalender: Stornobedingungen je Unterkunft, Zustimmung und Bestellstatus
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:26:22 +00:00
ClaudeandClaude Sonnet 5.5 aad0a8b807 Tests: Wächter der Kundenansicht sperrt auch die Routen der Auftragsbestätigung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:24:19 +00:00
ClaudeandClaude Sonnet 5.5 d9058ef1a6 Aufträge: Durchlauf-Test über alle Endpunkte (Festbetrag, Aufwand, AB bis Schlussrechnung, Privatperson, Kundenansicht, Schalter)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:23:52 +00:00
knsoandClaude Opus 5.5 b25689a015 Buchungskalender: Rechtstexte, Einbau- und Livegang-Notiz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:23:21 +00:00
knsoandClaude Opus 5.5 efeb99e214 Buchungskalender: Rechnungen, Einstellungen und Funktionen-Kachel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:23:19 +00:00
knsoandClaude Opus 5.5 a94b50fbc1 Buchungskalender: Zahlung – Sitzung wiederverwenden, Zahlung nach Storno erstatten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:23:06 +00:00
ClaudeandClaude Sonnet 5.5 f3b6e00bf7 Merge auftraege/l6-kundenportal: Kundenportal (Strang L6)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:21:18 +00:00
ClaudeandClaude Sonnet 5.5 5d5f6333a4 Merge auftraege/l5-bestaetigung: Bestätigung und Mails (Strang L5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:21:11 +00:00
ClaudeandClaude Sonnet 5.5 8f53b560d5 Merge auftraege/l7-rechtstexte (ohne Datenschutz-Commit): Rechtstexte (Strang L7)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:20:51 +00:00
ClaudeandClaude Sonnet 5.5 bd12892ed2 Merge auftraege/l4-ab-pdf: Auftragsbestätigung PDF (Strang L4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:20:45 +00:00
ClaudeandClaude Sonnet 5.5 095694bc44 Merge auftraege/l3-ab-kern: Auftragsbestätigung Kern (Strang L3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:20:43 +00:00
ClaudeandClaude Sonnet 5.5 e5fc5e2c74 Merge auftraege/l2-rechnungen: Rechnungen (Strang L2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:20:36 +00:00
ClaudeandClaude Sonnet 5.5 743af802c1 Merge auftraege/l1-projektkonto: Projektkonto (Strang L1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:20:30 +00:00
ClaudeandClaude Sonnet 5.5 d2c48ab99b Rechnungen: Routen für Projektrechnungen und Vorschau
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:18:36 +00:00
ClaudeandClaude Sonnet 5.5 d990155df9 Kundenportal Aufträge: Playwright-Prüfung mit Bildschirmfotos (Handy 390 px, Desktop 1440 px, dunkel)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:18:23 +00:00
ClaudeandClaude Sonnet 5.5 88a7305e41 Rechnungen: Rechnungsnummer buchen, AB abgerechnet, Storno und Löschen geben frei
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:17:58 +00:00
ClaudeandClaude Sonnet 5.5 ab3e6938da Rechnungen: Sperre je Kunde gegen doppelte Abrechnung (Aufwand, Anzahlung, Schluss)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:17:11 +00:00
ClaudeandClaude Sonnet 5.5 d264f2af5a Rechnungen: Projektrechnungen bei Stripe (Zusatzfelder, A4, Minuszeilen, Projektdatensatz) und Vorschau
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:16:48 +00:00
ClaudeandClaude Sonnet 5.5 3beba876d6 Aufträge L5: Widerruf eines Projektauftrags auf der Widerrufsseite (nur Privatpersonen in der Frist, Nummer optional)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:16:45 +00:00
ClaudeandClaude Sonnet 5.5 60a51c0f68 Widerrufsseite: Vertragsart Projektauftrag mit freiwilliger Nummer der Auftragsbestätigung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:16:27 +00:00
knsoandClaude Opus 5.5 10fe9a2ae0 Buchungskalender: Portal-Tests mit fester Uhr
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:16:24 +00:00
knsoandClaude Opus 5.5 ca0f762272 Buchungskalender: Buchungsseite, Stripe-Zahlung und Webhook
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:16:17 +00:00
ClaudeandClaude Sonnet 5.5 e680ea3266 AB-Kern: Admin-Routen zum Senden, Erinnern, für neue Fassungen, zum Stornieren und zum Zuordnen eines Projekts
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:16:09 +00:00
knsoandClaude Opus 5.5 4553bc8e92 Buchungskalender: Mails – Link-Pflicht und Testprotokoll nur im Test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:16:04 +00:00
ClaudeandClaude Sonnet 5.5 7d3114d7a3 AB-Kern: Admin-Routen zum Lesen, Anlegen, Ändern, Löschen und für das PDF
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:16:02 +00:00
ClaudeandClaude Sonnet 5.5 c72b252e63 Aufträge L5: K04 und T09 mit Block projekt für den Widerruf eines Projektauftrags
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:16:01 +00:00
ClaudeandClaude Sonnet 5.5 73c5446cf8 Kundenportal Aufträge: Abschnitt im Reiter Rechnungen, Projekt unter der Rechnungsnummer
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:15:56 +00:00
ClaudeandClaude Sonnet 5.5 c116c3cb4f Rechnungen: mapInvoice liefert Projekt, Art und Auftrag
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:15:44 +00:00
ClaudeandClaude Sonnet 5.5 66ec8e0a44 AB-Kern: Projekt nachträglich einer Auftragsbestätigung zuordnen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:15:37 +00:00
ClaudeandClaude Sonnet 5.5 22a7c8123d Aufträge L5: Test – in der Kundenansicht ist das Bestätigen von Aufträgen gesperrt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:15:33 +00:00
ClaudeandClaude Sonnet 5.5 b53f9f67ed Kundenportal Aufträge: Abschnitt „Aufträge“ im Portal (Karten, Bestätigen, Ansichtsmodus gesperrt)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:15:29 +00:00
ClaudeandClaude Sonnet 5.5 4f8e956574 Rechnungen: Anzahlung und Schlussrechnung bauen (Minuszeilen, Entwurfsschutz, Selbstheilung)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:15:25 +00:00
ClaudeandClaude Sonnet 5.5 503337d7ae Aufträge L5: Portal-Routen für Aufträge (eigene gesendete ABs, PDF, Bestätigen)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:15:22 +00:00
ClaudeandClaude Sonnet 5.5 c791146af4 AB-Kern: Stornieren versendeter und bestätigter Aufträge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:15:21 +00:00
ClaudeandClaude Sonnet 5.5 cf87a7e1e0 AB-Kern: Neue Fassung und Ersetzen der alten AB beim Senden
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:15:11 +00:00
ClaudeandClaude Sonnet 5.5 6cb5bfa6c7 Aufträge L5: öffentliche Bestätigung per Link (idempotent, Frühbeginn nur bei Privatpersonen)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:14:59 +00:00
ClaudeandClaude Sonnet 5.5 11531cacb6 Rechnungen: Aufwandsrechnung bauen (Auswahl, Satz aus der AB, Doppelabrechnung, 20 Zeilen)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:14:48 +00:00
ClaudeandClaude Sonnet 5.5 0da5b30119 AB-Kern: Erinnerung mit neuem Token und geprüfter PDF-Datei
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:14:46 +00:00
ClaudeandClaude Sonnet 5.5 aca393d564 Aufträge L5: öffentliche Link-Routen (ansehen, PDF) mit Ratenbegrenzung, Schalter und Statusprüfung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:14:46 +00:00
ClaudeandClaude Sonnet 5.5 cad9ba7e40 Kundenportal Aufträge: auftrag.html und auftrag.css (noindex, Mail-Stil, Fußzeile, Standalone)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:14:45 +00:00
knsoandClaude Opus 5.5 8c8fa05591 Buchungskalender: Preis-Editor prüft Grenzen wie der Server
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:14:38 +00:00
ClaudeandClaude Sonnet 5.5 c74f7eeea7 AB-Kern: Senden unter Sperre, Fehlerpfade, Dateirest und Mail-Fehler
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:14:29 +00:00
ClaudeandClaude Sonnet 5.5 576a17bfa4 Aufträge L5: Auftrag widerrufen (nur Privatpersonen innerhalb der Frist, unter Sperre)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:14:22 +00:00
ClaudeandClaude Sonnet 5.5 335ac60e54 Aufträge L5: Auftrag bestätigen (idempotent, unter Sperre, Frühbeginn nur bei Privatpersonen)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:14:08 +00:00
ClaudeandClaude Sonnet 5.5 ed7567397d AB-Kern: Senden mit Prüfungen vor der Nummer, Nummer, PDF, Ablage, Token und Mail
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:14:02 +00:00
ClaudeandClaude Sonnet 5.5 38734e99aa Kundenportal Aufträge: Seitenlogik der öffentlichen Bestätigungsseite
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:13:59 +00:00
ClaudeandClaude Sonnet 5.5 b78ca971af Rechnungen: baueRechnung für freie Rechnung und Festbetrag, Testhilfe
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:13:52 +00:00
ClaudeandClaude Sonnet 5.5 3464808a40 AB-PDF: Entwurfs-Kennzeichnung für die Vorschau und Hinweis auf die ersetzte AB
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:13:51 +00:00
ClaudeandClaude Sonnet 5.5 19abac0373 Aufträge L5: Mails zur Auftragsbestätigung (K31 ohne BCC mit Teamkopie ohne Link, K32, T22)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:13:46 +00:00
ClaudeandClaude Sonnet 5.5 3a46210e9c AB-Kern: Vorschau-PDF und austauschbare Abhängigkeiten (deps)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:13:45 +00:00
knsoandClaude Opus 5.5 51a36be36a Buchungskalender: Buchungsseite – Ablauf, bezahlt und Wiederholung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:13:40 +00:00
ClaudeandClaude Sonnet 5.5 6f213dca25 AB-PDF: erzeugeAuftragPdf zeichnet Briefkopf, Tabelle über mehrere Seiten, Belehrung und Fuß mit pdfkit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:13:34 +00:00
ClaudeandClaude Sonnet 5.5 0fc2eb4516 AB-Kern: Entwürfe anlegen, ändern und löschen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:13:26 +00:00
ClaudeandClaude Sonnet 5.5 bd87bb115c Kundenportal Aufträge: DOM-Bausteine (Zusammenfassung, Bestätigen mit Häkchen, Sperre)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:13:24 +00:00
ClaudeandClaude Sonnet 5.5 fd189b085d Stripe-Nachbau: Zusatzfelder, Seitengröße und Minuszeilen wie bei Stripe
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:13:17 +00:00
ClaudeandClaude Sonnet 5.5 01406c82e5 AB-PDF: Textleser für PDF-Tests (ToUnicode, nur Node-Bordmittel)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:13:14 +00:00
ClaudeandClaude Sonnet 5.5 f0402a7ea9 Aufträge L5: Mailvorlagen K31 Auftragsbestätigung (mit Teamkopie), K32 Auftrag bestätigt, T22 Hinweis ans Team
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:13:10 +00:00
ClaudeandClaude Sonnet 5.5 f71e3e8345 AB-PDF: Bausteine der Auftragsbestätigung mit Höhen und Zusammenhalt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:13:02 +00:00
ClaudeandClaude Sonnet 5.5 8e643160ec build-legal-pdfs: Modus auftraege, PDFs und Prüfsummen der Widerrufsbelehrung der Projektaufträge und der AGB
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:12:56 +00:00
ClaudeandClaude Sonnet 5.5 2d91a54e4a Rechnungen: Anzahlung und Schlussrechnung mit Minuszeilen (Positionen)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:12:52 +00:00
ClaudeandClaude Sonnet 5.5 4706f232b5 AB-PDF: Maße und Seitenaufteilung mit wiederholtem Tabellenkopf
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:12:48 +00:00
knsoandClaude Opus 5.5 e15486263f Buchungskalender: Mails an Gäste, Vermieter und Team
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:12:47 +00:00
ClaudeandClaude Sonnet 5.5 ef646f12de AB-Kern: Eingabeprüfung für Auftragsbestätigungen (pruefeEingabe)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:12:47 +00:00
ClaudeandClaude Sonnet 5.5 9fa965521a Projektkonto: Team-Route für das Abzeichen abgerechnet am Arbeitsblatt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:12:45 +00:00
ClaudeandClaude Sonnet 5.5 846059b455 Projektkonto: Admin-Routen für Konten je Kunde und Detail je Projekt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:12:40 +00:00
knsoandClaude Opus 5.5 37cd312549 Buchungskalender: Anfragen-Reiter – Erstattung und Fehlermeldungen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:12:37 +00:00
ClaudeandClaude Sonnet 5.5 e6a0fc9acf Kundenportal Aufträge: reine Ansichtslogik (Token, Beträge, Zustände, Status, AB-Nummer)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:12:34 +00:00
ClaudeandClaude Sonnet 5.5 731bc8c05e AB-PDF: Zahlungsplan, Termine, AGB-Satz, Privatperson-Seiten, Fuß und Vorschau in den PDF-Daten
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:12:33 +00:00
ClaudeandClaude Sonnet 5.5 6c1eaf697b Rechnungen: Aufwandszeilen je Tag, Kalenderwochen-Zusammenfassung ab 20 Zeilen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:12:28 +00:00
ClaudeandClaude Sonnet 5.5 32726fa1c2 Projektkonto: Abzeichen abgerechnet je Ticket (Rechnungsnummer oder Entwurf)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:12:25 +00:00
ClaudeandClaude Sonnet 5.5 aeae17b491 build-legal-html: Satz auftraege für die Widerrufsbelehrung der Projektaufträge und die AGB, beide Seiten erzeugt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:12:23 +00:00
ClaudeandClaude Sonnet 5.5 7cec85f888 Projektkonto: Detail mit ABs, Rechnungen und offenen Arbeitsblättern
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:12:19 +00:00
ClaudeandClaude Sonnet 5.5 7ad9c12bca AB-PDF: auftragPdfDaten mit Kopf, Anschrift, Positionen und Prüfung der Pflichtangaben
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:12:18 +00:00
ClaudeandClaude Sonnet 5.5 7553253746 Rechnungen: Positionen-Grundlagen (Stundentext, Kalenderwoche, feste Positionen, Prüfung)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:12:07 +00:00
ClaudeandClaude Sonnet 5.5 8032aa67c6 Projektkonto: Konten je Kunde (Projektwahl, Zeitquelle, Zeitwert je Tag, Ohne Projekt, Schalter, Stripe-Ausfall)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:12:01 +00:00
ClaudeandClaude Sonnet 5.5 edccd4d8af AGB-Kopie: Quelle docs/rechtliches/agb.md wortgleich mit der Website (Fassung 2026-09-30), Tests gegen AGB.tsx
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:11:57 +00:00
ClaudeandClaude Sonnet 5.5 b097fedf5a AB-PDF: Zeichen ohne Glyph werden ersetzt statt als Kästchen gedruckt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:11:54 +00:00
ClaudeandClaude Sonnet 5.5 e3df5383d1 AB-Kern: Nummernvergabe AB-JJJJ-NNNN nach Berliner Jahr
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:11:52 +00:00
knsoandClaude Opus 5.5 db9fc61894 Buchungskalender: Kalender und Unterkünfte im Portal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:11:51 +00:00
ClaudeandClaude Sonnet 5.5 ae6d84b2a4 AB-PDF: Figtree Regular und SemiBold (OFL) mit Schriftlader und Glyphenprüfung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:11:48 +00:00
ClaudeandClaude Sonnet 5.5 445d234695 Rechtstexte der Aufträge: Endfassung der Textbausteine, Bestätigung, Frühbeginn-Erklärung und Widerrufsbelehrung (Fassung 2026-10-06)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:11:47 +00:00
ClaudeandClaude Sonnet 5.5 cf3e0fd52a Aufträge L5: Bestätigungs-Token (32 Zufallsbytes, nur als Hash gespeichert, 60 Tage gültig)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:11:43 +00:00
ClaudeandClaude Sonnet 5.5 10679ae726 Projektkonto: Stripe-Rechnungen lesen (Liste, Einzellesen fehlender, Ausfall)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:11:41 +00:00
ClaudeandClaude Sonnet 5.5 68ffac24c4 AB-Kern: Testhilfe und Vertragsprüfung der L0-Module
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:11:34 +00:00
ClaudeandClaude Sonnet 5.5 3305371111 Projektkonto: reine Rechenregeln (vereinbart, berechnet, offen, Zeitwert je Tag gerundet, Ohne Projekt)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:11:32 +00:00
ClaudeandClaude Sonnet 5.5 987268e2f0 Kundenportal: Rechnungsliste kennt das Projekt (Feld projekt aus den Stripe-Metadaten)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:11:30 +00:00
knsoandClaude Opus 5.5 0b0d8da335 Buchungskalender: Buchungsseite für Gäste
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:10:50 +00:00
knsoandClaude Opus 5.5 40a366b8ae Buchungskalender: ID-Prüfung erlaubt Bindestrich (UUIDs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:10:46 +00:00
knsoandClaude Opus 5.5 bc9b4d9576 Buchungskalender: Widget-Monatsnavigation und Tastaturfokus
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:10:34 +00:00
knsoandClaude Opus 5.5 0c5ca76a4d Buchungskalender: öffentliche API gegen manipulierte IDs und Lookups absichern
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:10:30 +00:00
ClaudeandClaude Opus 5.5 8627ed1124 Aufträge: Router-Gerüste und Einbindung in server/index.js und Team-Router
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:08:37 +00:00
ClaudeandClaude Opus 5.5 4f467fa9c5 Finanzen: Einstellung Steuernummer für die Auftragsbestätigung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:08:06 +00:00
ClaudeandClaude Opus 5.5 e9e198e53c Aufträge: PDF-Ablage mit Pfadprüfung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:07:32 +00:00
ClaudeandClaude Opus 5.5 98eceaf14c Aufträge: Sichten auf die Auftragsbestätigung (Team, öffentlich, Frühbeginn)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:07:15 +00:00
knsoandClaude Opus 5.5 9b509fefbc Buchungskalender: Website-Widget mit Kalender und Anfrageformular
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:06:53 +00:00
ClaudeandClaude Opus 5.5 47a92811a3 Aufträge: Arbeitsblätter laden, normieren und als abgerechnet erkennen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:06:50 +00:00
knsoandClaude Opus 5.5 3ecea05b52 Buchungskalender: Portal-Bereich und Anfragen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:06:48 +00:00
knsoandClaude Opus 5.5 035967f8eb Buchungskalender: öffentliche API für Kalender und Anfrage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:06:30 +00:00
ClaudeandClaude Opus 5.5 9cd5ea3038 Aufträge: Speicher der Projektrechnungen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:06:19 +00:00
knsoandClaude Opus 5.5 118be44847 Buchungskalender: PDF-Writer und Rechnungs-PDF
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:06:11 +00:00
ClaudeandClaude Opus 5.5 a3db7f310a Aufträge: Speicher der Auftragsbestätigungen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:05:49 +00:00
ClaudeandClaude Opus 5.5 4e4feba4c9 Aufträge: vorläufige Textbausteine der Auftragsbestätigung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:05:02 +00:00
ClaudeandClaude Opus 5.5 004e564479 Aufträge: Setup-Skript für Collections und Zusatzfelder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:04:51 +00:00
ClaudeandClaude Opus 5.5 ddffc7a92d Aufträge: Konfiguration und Pilot-Schalter AUFTRAEGE_ENABLED
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:04:48 +00:00
ClaudeandClaude Opus 5.5 0ab3cbbaa3 Aufträge: Appwrite-Schema der Collections auftraege und projektRechnungen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:04:01 +00:00
ClaudeandClaude Opus 5.5 16f08b5ac3 Aufträge: Konstanten und Fehlerklasse (L0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:03:25 +00:00
ClaudeandClaude Opus 5.5 1b5b5b7094 Aufträge: pdfkit und fontkit als Abhängigkeiten, node_modules auch als Symlink ignorieren
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:02:50 +00:00
knsoandClaude Opus 5.5 d7b5b98187 Buchungskalender: Verdrahtung absichern (Outbox, Shop-Webhook, Session)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:02:41 +00:00
knsoandClaude Opus 5.5 a68baf10d9 Buchungskalender: Verdrahtung, Router-Gerüste und API-Vertrag
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:59:38 +00:00
knsoandClaude Opus 5.5 1d2ca2791d Buchungskalender: Storno und Erstattung getrennt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:58:17 +00:00
knsoandClaude Opus 5.5 f7650fe883 Buchungskalender: Storno und Buchung idempotent bei Teilfehlern
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:55:56 +00:00
knsoandClaude Opus 5.5 947e9da2e3 Buchungskalender: Statusmaschine, Token und Rechnungsnummern
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:52:52 +00:00
knsoandClaude Opus 5.5 802b5d3ad4 Buchungskalender: Mengen-Obergrenze für Posten auf 20000
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:50:22 +00:00
knsoandClaude Opus 5.5 bdf191d945 Buchungskalender: Speicher und Belegung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:49:29 +00:00
knsoandClaude Opus 5.5 c38c8e7c13 Buchungskalender: Preisrechnung nachgeschärft (Summe, Kleinunternehmer, Vollständigkeit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:49:20 +00:00
knsoandClaude Opus 5.5 84e7a99f41 Buchungskalender: Preisrechnung und Datumshilfen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:47:41 +00:00
knsoandClaude Opus 5.5 5717a5cdd0 Buchungskalender: Konfiguration, Schalter, Schema und Setup-Skript
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:45:57 +00:00
knsoandClaude Opus 5.5 18369a348c Plan: Buchungskalender (23 Tasks, 4 parallele Stränge)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:41:26 +00:00
knsoandClaude Opus 5.5 668bc62c55 Spec Buchungskalender: Nachträge aus der Code-Prüfung (N1–N6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:36:12 +00:00
knsoandClaude Opus 5.5 5a05bd27e9 Spec: Buchungskalender für Unterkünfte (Funktion unterkunft)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:22:02 +00:00
ClaudeandClaude Opus 5.5 9d82fc579f Umsetzungspläne Aufträge und Rechnungen: Übersicht und 12 Strang-Pläne
Parallele Stränge L0–L7 (Portal), W0–W2 (WOMS), Z (Integration und
Kontrolle); Vertragsfragen der Planrunde entschieden (Abschnitt
„Nachträge nach der Planrunde“ in der Übersicht).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:49:15 +00:00
ClaudeandClaude Opus 5.5 0bb463eb7d Spezifikation nachgeschärft, Plan-Übersicht mit Verträgen der Stränge
Nachträge aus der Planrunde (Fragment-Link, Teamkopie ohne Link, Schalter
AUFTRAEGE_ENABLED, Projekt später zuordnen, Schlussrechnung bis 40 Zeilen,
zusammengesetzte Sperrschlüssel, Zuständigkeiten).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:06:09 +00:00
ClaudeandClaude Opus 5.5 e00b4ae572 Spezifikation: Auftragsbestätigung und projektbezogene Rechnungen
Entwurf aus dem Brainstorming vom 06.10.2026 (Abschnitte im Chat freigegeben):
Projektkonto, Rechnungsarten Anzahlung/Schluss/Aufwand/Festbetrag über Stripe,
Auftragsbestätigung als eigenes PDF mit Bestätigungsklick und Verbraucher-Variante,
zwei Pläne.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:03:25 +00:00
ClaudeandClaude Opus 5.5 8814c4aef1 Webshop: fehlendes Stripe-Plattformprofil klar melden statt 'Stripe nicht erreichbar'
Lehnt Stripe die Kontoanlage ab, weil das Connect-Plattformprofil von WEBklar fehlt (oder Connect nicht aktiv ist),
bekommt der Kunde 503 'plattform_nicht_bereit' mit eigenem Du-Text, und das Log nennt die Ursache (ohne Stripe-Text).
Gefunden im Pilot 2026-10-06.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:58:20 +00:00
ClaudeandClaude Opus 5.5 50f854ef1c Öffnungszeiten: Datenschutz-Abschnitt heißt 6e (6d ist seit dem Webshop vergeben); Härtungspunkt N13
Entwurf umbenannt (zeiten-datenschutz-6e-entwurf.md), Leistungsbeschreibung, Kanzlei-Fragen,
Livegang-Notiz und Test-Pins ziehen mit. N13: Rückgabe-Marker nach dem Löschen fehlt (vor on).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:30:03 +00:00
ClaudeandClaude Opus 5.5 d0691236d5 Öffnungszeiten: Test hält fest, dass ein gescheitertes Löschen der Zeiten den Kunden bis zum nächsten Lauf hält
Seit TP2 bleiben die Abo-Dokumente stehen (held), wenn das Löschen abhängiger Daten scheitert. Der Test für die
Aufbewahrungs-Löschung prüft jetzt auch für die Zeiten: held nennt den Kunden, das Abo-Dokument bleibt, der
nächste Lauf findet den Kunden wieder und erst nach dem Löschen der Zeiten gehen die Abo-Dokumente.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:15:58 +00:00
ClaudeandClaude Opus 5.5 3984d468bf Öffnungszeiten: Datenschutz-Entwurf an den Stand von main angepasst (§ 7-Satz, Nummer 6d)
Der Wächter in zeitenRecht.test.js hat den Drift gefunden: Abschnitt 7 in public/datenschutz.html enthält seit TP2
den Satz zu Bestellungen ohne Abo. Das wörtliche Zitat im Entwurf ist nachgeführt, der Vorschlag ändert wie
bisher nur den Anfang des Satzes. Dazu ein Nachtrag im Entwurf: Abschnitt 6d ist seit dem Webshop-Livegang
vergeben, der Abschnitt der Öffnungszeiten muss beim Übernehmen als 6e eingebaut werden (Anker id="zeiten"
bleibt); die Umnummerierung entscheidet der Inhaber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:15:58 +00:00
ClaudeandClaude Opus 5.5 3e66dfa91c Tests nach dem Zusammenführen mit main: Reihenfolge der Funktionsschlüssel und Importe von funktionen.js
Die Öffnungszeiten stehen jetzt hinter dem Webshop (news, termine, shop, zeiten): Register, FEATURE_KEYS und
ticketFreischaltungKeys in zeitenRegister, die drei Listen in app.js (VIEW_HASHES, APP_PARENT, FEATURE_LABELS)
in zeitenBestellen und shopWiring (APP_PARENT endet mit webshop, zeiten). funktionenZeitenTile prüft die Importe
von funktionen.js gegen den Stand von main (fn-util.js und analyse-view.js); die Kachel der Öffnungszeiten
bringt weiterhin keinen neuen Import mit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:15:58 +00:00
ClaudeandClaude Opus 5.5 e87140e5ba Merge remote-tracking branch 'origin/main' into feature/oeffnungszeiten
Zusammenführung von TP2-Abschluss und Webshop (origin/main cab9c33) mit Öffnungszeiten live. Beide
Funktionen bleiben vollständig: jeder Konflikt ist als Vereinigung gelöst, Reihenfolge der Funktionsschlüssel
überall news, termine, shop, zeiten.

Konfliktdateien (28): public/app.js, dashboard.html, funktionen.js, widerrufen.html; scripts/stripe-products.mjs;
server/config.js, index.js, middleware/kundenansichtGuard.js, routes/abo.js, routes/admin/funktionenAdmin.js,
routes/funktionen.js, services/customerAdmin.js, customerViewLog.js, featureRegistry.js, retention.js,
siteFeatures.js, stripeBilling.js; test/customerViewLog, featureFreischaltung, featureRegistry, funktionenRoutes,
funktionenTermine, kundenansichtGuard, mailTemplates, publicForms, siteFeatures, stripeProductsScript (je
.test.js) und test/support/schema.js.

Entscheidungen: Mailvorlagen G01–G15, K01–K30, T01–T21 lückenlos (K24–K26 Webshop, K27–K30 Öffnungszeiten);
Stripe-Katalog mit shopSetup und zeitenSetup; Widerruf kennt shop und zeiten; ABO_FUNKTIONEN bleibt analyse
und termine (kein zeiten); activate() schreibt die Zeiten-Felder nur über extra; kein Halter von
feature-activate:<id> ruft disable() auf; die Aufbewahrungs-Löschung löscht Zeiten-Daten vor dem Abschalten und
hält den Kunden bei einem Fehler (held); die Admin-Antwort trägt ersatzTicket und warnung; Hash-Routen webshop
und zeiten, Ansichten view-webshop und view-zeiten; assertOwnProject behält die Meldung von main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:12:23 +00:00
ClaudeandClaude Opus 5.5 c7cd436ec2 Öffnungszeiten: Mails K24–K27 heißen jetzt K27–K30, Feld k24At heißt einschaltMailAt
K24–K26 sind seit dem Webshop-Livegang (main) vergeben; der Mail-Lader sucht die Vorlage über den
Dateianfang, doppelte Nummern hätten die falsche Mail geschickt. Das Feld ist noch nicht in der
Produktion angelegt (zeiten-setup.mjs lief noch nicht), daher jetzt ein sprechender Name.
Spec und Plan behalten die alten Nummern (Hinweis oben im Dokument).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:00:17 +00:00
ClaudeandClaude Opus 5.5 cab9c33db6 Webshop nach TP2-Merge: Shop-Mails in der Klasse normal (kein zusätzliches notEqual in der Outbox-Abfrage)
Seit 2c7cd26 kostet jede Art in VORRANG/NACHRANGIG ein notEqual in der Abfrage der Klasse normal; live stößt die URL
bei nginx an (414). G09–G15 laufen deshalb in der Klasse normal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:59:12 +00:00
Claude a4a18a1c89 Merge branch 'feature/terminplaner-tp2' into feature/webshop 2026-10-05 14:56:43 +00:00
ClaudeandClaude Opus 5.5 348ddc927c Analyse-Tests: feste Zeiten vor NOW statt echter Uhr (Reihenfolge kippte ab 2026-10-05 10:00 UTC)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:55:54 +00:00
ClaudeandClaude Sonnet 5.5 9350fccd77 K02/K03: Hinweis auf das Ende von Analyse und Terminplaner nur für Kunden, die eine von beiden haben
Der Absatz "Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm."
stand bei jeder Kündigung eines Wartungs-Abos. Nach der Entscheidung vom 05.10.2026 steht er nur
noch, wenn der Kunde ein Funktionsdokument (portalFeatures, featureKey analyse oder termine) in
einem Zustand ausser off hat.

- abo.js: hatAboFunktionen(sub) liest über listCustomerFeatureDocs (wie die übrigen Funktions-
  Abfragen) die Dokumente des Kunden des Abos; ein Lesefehler zählt als ja (eine Logzeile, die Mail
  geht in jedem Fall raus). Der Block funktionen in K02 (Kündigung im Stripe-Kundenportal) und K03
  (Kündigungsseite, weiterhin nur beim Abo) ist jetzt isWartung(sub) && hatAboFunktionen(sub);
  beim Hosting-Abo wird gar nicht erst gelesen. Die Vorlagentexte bleiben unverändert.
- Tests: K02 und K03 ohne Dokument, mit aktiver Analyse, Terminplaner in setup/ordered/disabled,
  nur off (und ohne Zustand), News oder Dokument eines anderen Kunden, Lesefehler (Hinweis bleibt,
  Mail geht raus, eine Logzeile) und Hosting-Abo (kein Hinweis, kein Lesen).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 14:50:03 +00:00
ClaudeandClaude Sonnet 5.5 2c7cd26532 Termine-Outbox: notEqual mit genau einem Wert (Appwrite 1.8.1), nachrangige Mails kommen wieder dran
Auf der echten Appwrite 1.8.1 lehnt notEqual mit Liste die Abfrage ab (HTTP 400
general_query_invalid, "NotEqual queries require exactly one value"). Die Klasse normal der
Mail-Warteschlange schickte immer eine Liste, jeder Lauf warf dort, und die nachrangige Klasse
(G05, K20, K21) kam nie dran.

- appwriteAdmin: Query.notEqual nimmt genau einen Wert (eine Liste wirft einen TypeError, der
  keinerVon nennt); neu keinerVon(attribute, werte) = je Wert ein notEqual (Appwrite schneidet
  sie); dokumenteUrl() baut die Listen-URL ohne Netz, mit derselben Serialisierung wie die Anfrage.
- outbox: klassenFilter liefert fuer jede Klasse eine Liste von Abfragen (normal = sechs notEqual
  auf art), die Ausschlussliste ist keinerVon('featureId', ...). AUSSCHLUSS_MAX 90 -> 30 (der nginx
  antwortet in dieser Abfrageform ab 46 Funktions-IDs mit 414); der Kommentar "100 Werte je Abfrage"
  entfaellt.
- Nachbau (appwriteMock): notEqual mit Liste oder ohne Wert => 400 wie Appwrite; ein Dokument mit
  Feld null oder ohne Feld passt nicht zu notEqual (SQL-Semantik).
- Tests: Query-Helfer, keinerVon, dokumenteUrl, Nachbau (400, null), ein Lauf bedient alle drei
  Klassen, Ausschlussliste hoechstens 30 und URL unter 6000 Zeichen im schlimmsten Fall.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 14:47:29 +00:00
ClaudeandClaude Opus 5.5 ce84fc849a Webshop-Test: Reiter-Panels abwarten (Node 20 lädt import() langsamer)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:30:02 +00:00
ClaudeandClaude Opus 5.5 bf308a75ae Webshop-Rechtstexte: IP-Hash mit Zahlung oder Storno gelöscht, keine Telefonnummer des Käufers
AVV, Leistungsbeschreibung, Datenschutzerklärung (6d), Datenschutz-Absatz und Kanzlei-Paket an das Verhalten
angepasst; HTML und PDF neu erzeugt (Fassung 2026-10-05 unverändert, nie ausgeliefert).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:26:56 +00:00
ClaudeandClaude Opus 5.5 43473c0879 Webshop: IP-Hash entfällt mit Zahlung oder Storno, keine Käufer-Telefonnummer mehr gespeichert
schliesseAb und verwirf entfernen ipHash aus dem Bestell-JSON, sobald die Bestellung nicht mehr offen ist
(Begrenzung offener Bestellungen je IP bleibt). customer_details.phone wird nicht mehr in kaeufer.telefon
übernommen; Kundenansicht und Bestelldetail geben auch in alten Datensätzen keine Telefonnummer aus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:26:56 +00:00
ClaudeandClaude Opus 5.5 4ab84e31ac Webshop: Playwright-Durchlauf (Anfrage bis Widerruf) mit Nachbauten von Appwrite, Stripe Connect und Mail
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:23:45 +00:00
ClaudeandClaude Opus 5.5 72d79ddba7 Webshop-Rechtstexte an den Code angepasst: Archiv, IP-Hash, keine Telefonnummer, Eingangsmail
AVV (Vorbemerkung Abs. 2, Nr. 2, 3, 8, 9, Anlage), Leistungsbeschreibung (Nr. 2, 5, 6, 10),
Datenschutzerklaerung Abschnitt 6d, Datenschutz-Absatz fuer Haendler und Kanzlei-Paket (F12, F14, F16, F18, F19,
Fundstellen) beschreiben jetzt genau, was gebaut ist: jede bestaetigte Fassung der Rechtstexte liegt im Archiv und wird
mit den Shop-Daten geloescht, G09 zeigt die archivierte Fassung zum Hash der Bestellung; Eingangsmail G15 bei SEPA; die
Stripe-Kasse fragt keine Telefonnummer ab; die Bestellung speichert einen gesalzenen IP-Hash (Missbrauchsschutz,
Pseudonym), der bei Loeschung oder Anonymisierung entfaellt; Freischaltung durch das Team nur mit Zustimmung in Textform
samt Nachweis. Fassung 2026-10-05 bleibt, HTML und PDFs neu erzeugt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:07:36 +00:00
ClaudeandClaude Opus 5.5 52543a99b1 Webshop: Livegang-Anleitung (Stripe Connect, Webhook, Schalter, Schema, Pilot)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:03:55 +00:00
ClaudeandClaude Opus 5.5 578a64782b Shop Review B M-6: Stripe-Kasse ohne Telefonnummer, Rechnungsanschrift nur bei Bedarf
phone_number_collection ist aus, billing_address_collection steht auf auto (Datenminimierung).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:01:05 +00:00
ClaudeandClaude Opus 5.5 c36cb9b4a3 Shop Review B M-3: Versandhinweis bei jedem Preis, nicht bei reinen Abholshops
"zzgl. Versand" steht jetzt unter dem Preis jeder Artikelkarte und im Detail, nur wenn der
Shop versendet. Unter dem Raster bleibt der Steuerhinweis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:00:42 +00:00
ClaudeandClaude Opus 5.5 b2072e4e7c Shop Review B M-2: Widerrufsfunktion laeuft auch bei SHOP_ENABLED=off und ausserhalb des Pilotkreises
GET /:siteKey liefert dann die Widerrufsseite (nurWiderruf), POST /:siteKey/widerruf samt
Preflight geht durch, solange die Funktion active oder disabled ist. Bestellen, Pruefen, Status
und Bilder bleiben gesperrt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:59:58 +00:00
ClaudeandClaude Opus 5.5 08d837d690 Shop Review B I-6, I-7: Archiv der bestaetigten Rechtstexte, G09 ohne Link-Ersatz
I-6: Neue Collection shopRechtstexteArchiv (ID r + 35 Hex aus sha256(shopId + hash)); das
Bestaetigen legt die Fassung samt Versand-/Zahlungstext und Gewaehrleistungstext ab, G09 zeigt
die Fassung zum rechtstexteHash der Bestellung (Rueckfall mit Warnung auf die aktuell
bestaetigte), loescheShopDaten loescht das Archiv mit.

I-7: Grenzen AGB 20000, Belehrung 12000, Formular 3000 plus Pruefung der maskierten Laenge beim
Speichern; G09 ersetzt die AGB nie mehr durch einen Link, sondern scheitert (Marker bleibt
offen, wird wiederholt). Test mit Texten in Hoechstlaenge gegen die Grenzen der Warteschlange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:58:31 +00:00
ClaudeandClaude Opus 5.5 2f4f360a21 Shop Review B I-5: Bestaetigen nur ohne Platzhalter, Musterkopf und mit Adresse der Widerrufsfunktion
pruefeBestaetigbar listet die Probleme in Du-Form; POST /api/shop/rechtstexte/bestaetigen
antwortet 400 nicht_bestaetigbar mit der Liste, GET und PUT liefern sie als probleme mit.
Der Reiter Rechtliches zeigt die Punkte im Bestaetigungsblock und sperrt den Knopf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:53:17 +00:00
ClaudeandClaude Opus 5.5 1f56b86791 Shop Review B I-4: Steuerart und Lieferzeit ohne Vorbelegung
kleinunternehmer ist ab jetzt null (nicht gewaehlt), lieferzeit leer. istVollstaendig verlangt eine
bewusste Wahl (true oder false), der Shop kann sonst nicht geoeffnet werden. Im Reiter
Einstellungen gibt es zwei Pflicht-Radiobuttons ohne Vorauswahl; Preise, Artikel und Muster
rechnen bei null wie bei Regelbesteuerung. Tests und Fixtures angepasst.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:50:54 +00:00
ClaudeandClaude Opus 5.5 7104f97feb Shop Review B I-2, I-3: Gewaehrleistungskasten vor der Zusammenfassung, AGB-Hinweis ueber dem Bestellknopf
Die amtliche Gewaehrleistungsgrafik steht in hervorgehobenem Kasten mit Ueberschrift vor der
Zusammenfassung der Kasse (hoechstens 420 px breit, sofort geladen, Vergroessern oeffnet sie in
einem mit Esc und Knopf schliessbaren Dialog). Direkt ueber "Zahlungspflichtig bestellen" steht
"Es gelten unsere AGB. Informationen zu Ihrem Widerrufsrecht finden Sie in der
Widerrufsbelehrung." mit Links auf beide Texte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:47:28 +00:00
ClaudeandClaude Opus 5.5 72fb37edcc Shop Review B I-1: Muster-AGB § 2 und § 9 passen zum Ablauf, Angaben nach Art. 246c EGBGB
§ 2 beschreibt Bestaetigungsseite, sofortige Bestellbestaetigung bei Karte/Klarna und
Eingangsmail mit spaeterem Vertragsschluss bei SEPA. § 9 nennt technische Schritte,
Berichtigung von Eingabefehlern, Speicherung, Sprache und Verhaltenskodizes. Markdown-Muster
und Test sind angeglichen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:46:19 +00:00
ClaudeandClaude Opus 5.5 77fb8c8f44 Shop Review B I-1: Eingangsmail G15 bei SEPA, G09 nimmt die Bestellung ausdruecklich an
G15 "Wir haben Ihre Bestellung erhalten" geht einmal raus, sobald Stripe die Sitzung
abgeschlossen, die Zahlung aber noch nicht gemeldet hat (meldeEingang, Marker mailsEingangAt,
aus dem Webhook und aus pruefeOffene). G09 sagt ausdruecklich, dass der Kaufvertrag mit der
Mail geschlossen ist, trennt Bestell- und Zahlungsdatum und nennt die Telefonnummer des
Anbieters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:44:48 +00:00
ClaudeandClaude Opus 5.5 1b7aa9ebe3 Shop Review B I-8: Aktivierung des Webshops durch das Team nur mit Zustimmung in Textform
AVV Webshop, Vorbemerkung Abs. 2: Das Team darf den Webshop ohne Online-Bestellung
nur einschalten, wenn der Kunde vorher in Textform zugestimmt hat. Die Textform-Aktivierung
gilt jetzt wie beim Terminplaner auch fuer 'shop' (Modus on, Pilotadressen ausgenommen,
shopTextformNoetig): Nachweis in featureConsents mit Fassung (config.shop.fassung), Datum,
Weg, Mitarbeiter und dem Wortlaut der Erklaerungen der Bestelltexte, K16 mit
Leistungsbeschreibung und AVV als PDF. GET /api/team/funktionen/:kunde liefert zusaetzlich
shop { textformNoetig, avvVersion, links }.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:44:11 +00:00
ClaudeandClaude Opus 5.5 a129bdb0ec Webshop: Test der Shop-Konfiguration kennt formMinSekunden und minAntwortMs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:40:10 +00:00
ClaudeandClaude Opus 5.5 309c98d531 Shop Review A M10: Kontext-Cache des Shops beim Abschalten leeren
disable (und damit disableCustomerFeatures bei Kundenloeschung) leert fuer die Funktion shop den Kontext-Cache dieses siteKeys, per dynamischem Import und best effort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:38:58 +00:00
ClaudeandClaude Opus 5.5 45487d6c7e Shop Review A M3: Eingangsbestaetigung G12 je Empfaengeradresse begrenzen (3 je Stunde)
Ueber die gemeinsame Empfaenger-Kappe (darfEmpfaenger) geht an dieselbe Adresse hoechstens dreimal je Stunde ein G12. Darueber hinaus wird der Widerruf trotzdem gespeichert und mit eingang beantwortet, es wird aber keine weitere Mail eingereiht (bestaetigungAt bleibt leer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:38:16 +00:00
ClaudeandClaude Opus 5.5 c6706f88ff Shop Review A M2: Bestellaktionen, Erstattung und Widerruf erledigt auch bei abgeschaltetem Shop
Die Routen aktion, erstatten und widerrufe/erledigt akzeptieren jetzt auch den Zustand disabled; Einstellungen, Artikel und Oeffnen bleiben gesperrt. Die Oberflaeche laesst Bestellungen und Widerrufe bei abgeschaltetem Shop bearbeitbar (ctx.abwickeln), der Hinweis nennt es.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:37:29 +00:00
ClaudeandClaude Opus 5.5 21805c9a93 Shop Review A M6: Stripe-Status auf 4000 Zeichen begrenzen (30 Eintraege zu 100 Zeichen, notfalls weiter kuerzen)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:35:40 +00:00
ClaudeandClaude Opus 5.5 d4b9e6a5b9 Shop Review A M9: Mindestbetrag 0,50 EUR (Stripe) und Sitzungsende direkt vor dem Stripe-Aufruf nachfuehren
berechne lehnt Gesamtbetraege unter 0,50 EUR mit dem Code mindestbetrag und einem Kaeufertext ab. legeBestellungAn schiebt expires_at um die seit Beginn vergangenen ganzen Sekunden (Warten auf Sperren) weiter und fuehrt ablaufAt auf denselben Wert nach, damit Stripes 30-Minuten-Minimum sicher bleibt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:35:17 +00:00
ClaudeandClaude Opus 5.5 cb6291410d Shop Review A I4: Shop-Limit 300 je Stunde, hoechstens 3 offene Bestellungen je IP und Shop in 30 Minuten
Die IP wird nur als gesalzener Kurz-Hash (sha256, 16 Hex-Zeichen) im Bestell-JSON abgelegt; der Tagesjob entfernt ihn bei der Anonymisierung mit. Die Pruefung laeuft unter der Bestandssperre, Honigtopf und Fehlversuche legen nichts an und zaehlen nicht. Antwort 429 zu_viele_offen mit Retry-After 1800.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:34:03 +00:00
ClaudeandClaude Opus 5.5 55fe1c567f Shop Review A I3: Artikel-Editor ueberschreibt den Bestand nicht mehr (bestandGeladen, Bestand nur bei echter Aenderung schreiben)
Der Editor sendet neben bestand den beim Laden gesehenen Wert (bestandGeladen, je Variantenwert mit id). speichereProdukt behaelt unter der Artikelsperre den aktuell gespeicherten Stand, wenn gesendet gleich geladen ist, und schreibt nur vom Haendler geaenderte Bestaende. bestandGeladen wird nie gespeichert; ohne den Wert (alter Browser) gilt der gesendete Bestand wie bisher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:32:31 +00:00
ClaudeandClaude Opus 5.5 9c2897a0b0 Shop Review A I1/I2: Statuswechsel und Bestandsabzug unter einer Sperre, fehlende Schritte (Bestand, G09, K25) per Marker nachholen
Der Bestandsabzug laeuft jetzt im selben Block unter shop-bestand und shop-produkte wie der Statuswechsel auf bezahlt, zieheBestandAb haelt keine eigene Sperre mehr. Im Bestell-JSON stehen bestandAbgezogenAt, mailsKaeuferAt und mailsHaendlerAt als Marker; schliesseAb (Webhook-Wiederholung, Statusabruf, Minutenjob) holt fehlende Schritte genau einmal nach, Mails hoechstens fuenfmal versucht. Bezahlte Bestellungen ohne Bestandsabzug zaehlen weiter als reserviert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:30:36 +00:00
ClaudeandClaude Opus 5.5 42a6bb5a4a Webshop-Spec: Warenkorb liegt im sessionStorage (wie gebaut)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:27:40 +00:00
ClaudeandClaude Opus 5.5 329b8cc949 Shop J2: Kanzlei-Paket an die freiwillige Bestellnummer im Widerruf angepasst
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:27:13 +00:00
ClaudeandClaude Opus 5.5 c259e45412 Shop J2: Einbauanleitung, Datenschutz-Absatz für die Händler-Website, Kanzlei-Paket Webshop (F1 bis F29) mit Test
Die Fragenliste F1 bis F18 ist ins Paket übernommen und um F19 bis F29 ergänzt; die alte Datei verweist nur noch auf das Paket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:27:01 +00:00
ClaudeandClaude Opus 5.5 dbb2042983 Shop J2: Widerruf der Bestellung Webshop (Einrichtung) im öffentlichen Widerrufsformular und in abo.js, Tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:20:39 +00:00
ClaudeandClaude Opus 5.5 9283ff42c1 Shop J2: Datenschutzerklärung Abschnitt 6d Webshop (Auftragsverarbeitung, Stripe Connect, Fristen), PDF neu, Test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:19:31 +00:00
ClaudeandClaude Opus 5.5 172d9f1778 Shop P1b: Bestellnummer im Widerruf freiwillig, ohne Nummer Bestelldatum und Artikel in der Erklärung (§ 356a BGB)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:18:14 +00:00
ClaudeandClaude Opus 5.5 5693f37d5f Shop J1: Leistungsbeschreibung und AVV Webshop an Entscheidung W5 angepasst (Webshop an den Vertrag über den Betrieb der Website gekoppelt, nicht an ein Abo)
Nr. 1, 5, 9 Abs. 4 und Nr. 10 der Leistungsbeschreibung sowie AVV Nr. 2 und 9 nennen den Vertrag über den Betrieb der Website statt Hosting- oder Wartungs-Abo; HTML, PDF, Prüfsummen und Kanzlei-Frage F9 nachgezogen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:17:51 +00:00
ClaudeandClaude Opus 5.5 16a7f05aa2 Shop P1a: Du-Form im Portal klein (du, dich, dein) wie im übrigen Kundenportal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:17:09 +00:00
ClaudeandClaude Opus 5.5 ea4457e6a4 Öffnungszeiten: Fixrunde Doku (Datenschutz-Entwurf, Einbau-Anleitung, Kanzlei-Fragen, Livegang-Notiz)
- 6d-Entwurf § 7: Stripe-Sitzung nur bei Bestellung, IP-Adresse und Browserkennung bei jeder Zustimmung
  (auch Gratisweg); Offener Punkt 4 beantwortet
- Einbau-Anleitung: CSP braucht auch style-src, Portal mit docker compose up -d neu erstellen, Satz zu
  Einzelseiten-Apps, data-api nur am Script-Tag
- Kanzlei-Fragen: ZQ11 (Team-Aktivierung ohne Zustimmung), ZQ12 (Pflichtinformationen als Haken und in K15);
  ZQ3 und Schlussnotiz an den Stand von Wertersatz-Satz und Widerruf-Seite angepasst
- Livegang-Notiz: Zusammenführung über origin/main mit erwarteten Konflikten, genaue Push-Refs und
  Webhook-Warnung, Sicherungen und Prüfungen vor der Compose-Änderung, Schema-Check nach dem Anlegen,
  Runner-Neustart prüfen, setup-legal als ungetestet, Live-Checks und Rückweg, Handrezept für Testdaten,
  Test-Anpassung bei bald:false, Checkliste nachgeführt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:16:42 +00:00
ClaudeandClaude Opus 5.5 17ad5cbcdc Shop J1: Leistungsbeschreibung und AVV Webshop (Fassung 2026-10-05) als Markdown, HTML, PDF und Prüfsumme, Satz/Modus shop in den Build-Skripten, Kanzlei-Fragenliste
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:15:35 +00:00
ClaudeandClaude Opus 5.5 b8055b336c Shop J3: Tagesjob und Stundenjob, Löschfristen, Kundenlöschung
- shop/jobs.js: taeglicherJob (Käuferdaten bezahlter Bestellungen und Widerrufe 3 Jahre nach Ende des Bestelljahres anonymisieren, Grenze 1. Januar Berlin; unbezahlte/stornierte Bestellungen nach 30 Tagen löschen; Shops seit 90 Tagen disabled samt Artikeln, Bestellungen, Widerrufen, Bildern und shopConfig löschen, Funktionsdokument bleibt disabled mit Vermerk), seitenweise mit Deckel je Lauf, Schritte und Dokumente einzeln abgesichert; stuendlicherJob räumt nie beanspruchte Bilder auf
- shop/loeschen.js: gemeinsamer Baustein zum Löschen aller Daten eines Shops (Tagesjob und Löschfristen)
- shop/kundenLoeschung.js + customerAdmin.js: Kundenlöschung schaltet den Webshop ab (kunde_geloescht) und schließt die Shops
- retention.js: fällige Kunden verlieren auch ihre Shopdaten (vor den Abo-Dokumenten, bei Fehler held)
- server/index.js: Minuten- (75 s), Stunden- (130 s) und Tagesjob (200 s) registriert
- Tests: shopJobs.test.js, retention.test.js erweitert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:15:20 +00:00
ClaudeandClaude Opus 5.5 73820fb1e6 Shop G5: Reiter Bestellungen (Liste mit Filter, Suche, Blättern, Detail, Aktionen, Erstatten mit Rückfrage, Widerrufe, CSV-Export) und Test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:15:19 +00:00
ClaudeandClaude Opus 5.5 cc668d3765 Shop G4: Reiter Artikel mit Liste (Hoch/Runter, Aktiv-Schalter, Löschen mit Rückfrage), Editor (Preis, Grundpreis, Varianten, Bilder mit Upload, Produktsicherheit, Lebensmittel) und Feldfehlern
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:14:05 +00:00
ClaudeandClaude Opus 5.5 648d0e07fe Shop G6: Reiter Einstellungen und Rechtliches (Felder, Versand, Zahlungsarten, Muster einsetzen, Entwurf, Bestätigung mit Hash)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:12:46 +00:00
ClaudeandClaude Opus 5.5 3ed96a5838 Shop G3: Reiter Übersicht mit Checkliste und Öffnen-Schalter, Stripe-Konto, Einbau-Code mit Vorschau und Kennzahlen
Die Platzhalter-Prüfungen in shopView.test.js hängen nicht mehr am Text „Kommt gleich …“.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:10:30 +00:00
ClaudeandClaude Opus 5.5 aa9ee35ab6 Öffnungszeiten: Fixrunde Website-Skript und Editor (JSON-LD, Kopf beim Scrollen, späte Platzhalter, Links, Bestätigung)
- JSON-LD wird ohne "<" geschrieben (<), ein </script> im Firmennamen bricht keinen Block mehr
- verschiebe(): ist die Hinweis-Leiste weggescrollt, bekommen feste Elemente ihren eigenen Inline-top zurück
  (Köpfe, die per Klasse beim Scrollen wegfahren, funktionieren wieder)
- Späte Platzhalter (Einzelseiten-Apps): entprellter MutationObserver auf body zeichnet neue oder wieder
  eingehängte Platzhalter nach
- sichererLink lehnt Zugangsdaten in der Adresse ab; die Adresse der Schnittstelle bestimmt nur das eigene
  Script-Tag, kein Platzhalter mehr (der siteKey am Platzhalter bleibt laut Spec 4.2)
- Editor: die Bestätigung klebt unter der Kopfleiste und bleibt nach dem Speichern eines unteren Abschnitts sichtbar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:10:02 +00:00
ClaudeandClaude Opus 5.5 6d8487633b Shop E3: Händler-Aktionen (versendet, abholbereit, abgeschlossen, storniert), Erstattung unter Sperre, Bestellliste, Detail, CSV und Kundenrouten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:08:55 +00:00
ClaudeandClaude Opus 5.5 eb616ddbe8 Shop H2: Standalone-Seiten GET /shop/:siteKey und /shop/:siteKey/widerruf mit neutraler Seite, Schutz-Headern und Route-Test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:07:48 +00:00
ClaudeandClaude Opus 5.5 583a2d33c2 Shop F2: POST /pruefen, POST /bestellung und GET /bestellung/:id (Herkunft, Honigtopf, Formular-Token, Grenzen, Stripe-Abgleich, maskierte E-Mail)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:06:29 +00:00
ClaudeandClaude Opus 5.5 c13bc94a0f Shop H1: Einbau-Widget public/embed/shop.js (Raster, Detail, Warenkorb, Kasse mit Server-Zusammenfassung, Rückkehr von Stripe, Widerrufsfunktion) und statische Tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:05:08 +00:00
ClaudeandClaude Opus 5.5 738afa2d0e Shop G2: Hülle #webshop mit Anfrage, Bestellseite, Statuskarte und fünf Reitern
Eigene Helfer in shop-dom.js (inkl. euroEingabe, datumText), Anfrageformular und -karten
in shop-anfrage.js, Reiter als lazy geladene Platzhalter-Module für G3 bis G6.
Die Bestellseite zeigt den Hinweis zu „Ändern“ nur noch, wenn es Schritte gibt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:05:01 +00:00
ClaudeandClaude Opus 5.5 7464a4ef55 Shop D3: Connect-Webhook (account.updated, Checkout-Abschluss/Verwerfen, charge.refunded nachtragen)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:04:47 +00:00
ClaudeandClaude Opus 5.5 ecf67d6978 Öffnungszeiten: Fixrunde Server (Schema-Kopplung, K27, Wertersatz, Widerruf, Ticket nachlegen)
- activate() schreibt die Zeiten-Felder (aboEndeAt, behaltenSessionId) nur noch über extra
  (nur Abo-Rückkehr und Team-Aktivierung der Zeiten); News und Terminplaner laufen auch vor zeiten-setup.mjs
- Abo-Abgleich: K27 gilt als offen, solange der Marker vor disabledAt liegt (kein Zurücksetzen nach disable() nötig);
  K27 geht erst nach dem Ersatz-Ticket raus (scheitert es, wiederholt der nächste Lauf beides);
  Löschung setzt auch activatedAt zurück
- Zustimmung "beginn" (bezahlt) mit Wertersatz-Satz (§ 357a Abs. 2 BGB) wie bei News und Terminplaner, Fassung 2026-10-05
- Widerruf: Bestellung "zeiten" in ORDERS, FEATURE_OF_ORDER, orderLabel und widerrufen.html
- Team "Ticket nachlegen" der Zeiten ohne Kauf geht über holeZeitenNach statt über den Zahlungsweg
- Einschalten-Route nimmt nur { zustimmung }, nicht die Altform { consent: true }
- Team-Aktivierung behält kauf/unlockedByPurchase bei einem bisher gekauften Dokument

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:04:05 +00:00
ClaudeandClaude Opus 5.5 987b184aa1 Shop F3: POST /:siteKey/widerruf (Herkunft, Honigtopf, Formular-Token, Mindestantwortzeit), Preflight für POST-Routen, Konfig formMinSekunden und minAntwortMs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:03:06 +00:00
ClaudeandClaude Opus 5.5 bf99f659eb Shop E2: Abschluss (schliesseAb), verwirf, Abgleich offener Bestellungen, Minutenjob
schliesseAb ist die einzige Stelle fuer den Status bezahlt: Sperre je Bestellung, Konto muss zum Shop passen,
idempotent, spaete Zahlung nach Ablauf wird angenommen, SEPA erst bei bezahlt, Bestandsabzug mit Ueberverkauft-Hinweis,
G09 an den Kaeufer und K25 an den Haendler (Mailfehler machen nichts rueckgaengig). pruefeOffene und jobs.js minutenJob
gleichen offene Bestellungen mit Stripe ab. fakeStripe: completeSession.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:01:04 +00:00
ClaudeandClaude Opus 5.5 9de23c7fe2 Webshop E5: Widerrufe (§ 356a BGB) erfassen, G12 sofort, K26 an Händler, Liste und Bearbeitet-Markierung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:00:29 +00:00
ClaudeandClaude Opus 5.5 809da80c9b Shop G1: Verdrahtung #webshop, Webshop-Kachel, Bestellseite mit key und optionaler Zusammenfassung
Dashboard-Abschnitt view-webshop, shop.css und Platzhalter shop.js, app.js (Hash, Refresher, Label, Elternansicht), Kachel in funktionen.js nach shop.verfuegbar, GET /api/funktionen liefert shop.verfuegbar, funktion-bestellen.js nimmt key/name/zusammenfassung (Standard: Terminplaner unverändert).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:57:02 +00:00
ClaudeandClaude Opus 5.5 0ebd2e6663 Webshop E4: Mails (G09-G14 an Käufer, K25/K26 an Händler), shop/mails.js, Mailversand der Warteschlange auch bei Terminplaner off
Käufermails laufen über die Warteschlange des Terminplaners (G09/G12 mit Vorrang),
G09 wird bei Überlänge um den AGB-Text gekürzt (Link statt Text). Der Mailteil des
Minutenjobs und die Aufräumschritte des Tagesjobs laufen auch bei TERMINE_ENABLED=off,
solange SHOP_ENABLED nicht off ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:56:40 +00:00
ClaudeandClaude Opus 5.5 38bd56fe8b Shop D2: Kundenrouten Stripe-Konto (einrichten, Link, aktualisieren) mit Sperre gegen Doppelklick
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:56:04 +00:00
ClaudeandClaude Opus 5.5 98d1e6613d Shop F1: öffentliche Schnittstelle zum Lesen (Konfiguration mit Artikeln, Artikelbilder, Grenzen, Formular-Token)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:54:06 +00:00
ClaudeandClaude Opus 5.5 9722f0a938 Shop C7: Kundenrouten der Verwaltung (Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder)
- routes/shop.js: gemeinsame Pruefung mitShop (Schalter, eigenes Projekt, Funktion setup|active, lesend auch disabled, shopConfig
  bei Bedarf anlegen); GET /status erweitert (Bereitschaft, geoeffnet, Snippet, Vorschau, Stripe ohne Konto-ID, Zahlen);
  Einstellungen, Artikel (mit Bild-Pruefung, Beanspruchen und Aufraeumen), Sortierung, Rechtstexte (Entwurf, Bestaetigen mit Hash),
  Oeffnen/Schliessen, Bild-Upload (octet-stream, 8 MB) und Vorschau; Schreib-Limit 300 je Stunde und Kunde
- services/shop/bilder.js: Artikelbilder nach dem Muster von newsImages.js (1600/480 px WebP, Sidecar, 24-h-Aufraeumen, 40 Uploads/h)
- Kundenansicht: sieben Verwaltungs-Pfade erlaubt und als shop_aenderung protokolliert; Anfrage, Oeffnen, Bestaetigen, Stripe bleiben gesperrt
- Tests: shopRoutes, shopBilder; Erwartungen von kundenansichtGuard, customerViewLog und shopAnfrage angepasst

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:52:53 +00:00
ClaudeandClaude Opus 5.5 9d19a2cbad Shop E1: Bestellung anlegen (Reservierung unter Sperre, Stripe-Kasse auf dem Händlerkonto, Rück-URL-Prüfung)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:48:56 +00:00
ClaudeandClaude Opus 5.5 da43ee2b41 Shop D1: Connect-Dienst (Konto, Onboarding, Checkout, Erstattung, Webhook-Prüfung) und Fake-Stripe für Connect
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:44:10 +00:00
ClaudeandClaude Opus 5.5 03a7f1d877 Webshop: amtliche Gewährleistungsmitteilung (VO (EU) 2025/1960 Anhang I) als Grafik und wörtlicher Text
Grafik aus dem Amtsblatt (ABl. L vom 2.10.2025, S. 5) ausgeschnitten; Text der Grafik wörtlich für Alternativtext
und Bestellbestätigung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:43:43 +00:00
ClaudeandClaude Opus 5.5 0fb84021f8 Webshop B2: Team-Entscheidung über die Anfrage, Übersicht /api/team/shop und Kundenmail K24
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:43:38 +00:00
ClaudeandClaude Opus 5.5 608ff61d3d Shop C6: Kontext per Projekt und siteKey, Bereitschaft und Oeffnungs-Regel
Laden von shopConfig (Einstellungen, Rechtstexte als Entwurf/bestaetigt, Stripe-Status) mit sicherem Parsen,
30-s-Speicher je siteKey mit generation-Zaehler (active und disabled), bereitschaft() mit den fuenf Punkten,
istGeoeffnet() und oeffentlicheRechtstexte().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:43:37 +00:00
ClaudeandClaude Opus 5.5 1febdfaf53 Webshop C4: Warenkorb-Berechnung (preise.js) mit Tests
Reine Funktion berechne(): Positionen zusammenfassen, Bestand und Reservierung,
Lieferart und Land, Mindestbestellwert, Versandkosten, enthaltene USt je Satz
(Versand anteilig) und Kleinunternehmer-Hinweis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:41:57 +00:00
ClaudeandClaude Opus 5.5 83605975b0 Webshop B1: Anfrage-Dienst, Kundenroute /api/shop und Team-Mail T21
Anfrage stellen, zurückziehen und Stand lesen (ensureShopConfig, normalisiereAnfrage,
stelleAnfrage, zieheZurueck, anfrageAnsicht), Router /api/shop mit Rate-Limit 20/h und
Sperre in der Kundenansicht, Vorlage T21 Neue Webshop-Anfrage mit Tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:40:24 +00:00
ClaudeandClaude Opus 5.5 0063c04d8a Shop C5: Muster-Rechtstexte (Widerrufsbelehrung, Formular, AGB), Versand/Zahlung, Gewaehrleistungshinweis, Normalisierung und Hash
Widerrufsbelehrung nach Anlage 1 (Stand 2026, mit Hinweis auf die Widerrufsfunktion), Formular nach Anlage 2,
Muster-AGB ohne OS-Plattform, erzeugter Versand-/Zahlungstext, Gewaehrleistungshinweis (Wortlaut zu pruefen),
Muster-Markdown fuer die Kanzlei und Tests inkl. Abgleich Markdown/Code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:40:23 +00:00
ClaudeandClaude Opus 5.5 bea9f3cfba Shop C3: Produkte normalisieren, Käufer-Sicht und Speichern (shopProducts)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:39:33 +00:00
ClaudeandClaude Opus 5.5 129f8de278 Shop C2: Einstellungen normalisieren, laden und speichern
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:35:37 +00:00
ClaudeandClaude Opus 5.5 0e74f1669c Webshop A3: Register-Eintrag shop, Haken, Rechtstext-Satz und Ticket-Details
Registereintrag config.features.shop (Bestelltexte mit Fassung config.shop.fassung, Rechtsunterlagen-Getter,
Mailtexte, Ticket), STANDARD_HAKEN.shop (Schalter, Bestellpruefung ueber shop/anfrage.js, Unterlagen, Belehrung),
legalDocs-Satz shop, shopTicketDetails sowie Rumpf von anfrageOffenePunkte. Tests angepasst und um Shop-Faelle erweitert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:35:28 +00:00
ClaudeandClaude Opus 5.5 c10e50d0b3 Öffnungszeiten: Livegang-Notiz
Reihenfolge nach Spec 13.6 mit allen LIVEGANG-Hinweisen, Probelauf, Rechtsrunde
und Checkliste der offenen Code-Punkte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:32:59 +00:00
ClaudeandClaude Opus 5.5 6782d99960 Shop C1: Geld- und Grundpreis-Funktionen (euro, parseEuro, grundpreisText)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:32:38 +00:00
ClaudeandClaude Opus 5.5 308268e901 Webshop: Recherche eingearbeitet (kein PayPal bei Connect, Gewährleistungshinweis)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:30:56 +00:00
ClaudeandClaude Opus 5.5 e9ab15bdb5 Webshop A1/A2: Konfiguration, Schalter, Katalog shopSetup, Schema und Setup-Skript
config.shop (SHOP_ENABLED, SHOP_PILOT_EMAILS, SHOP_UPLOAD_DIR, STRIPE_CONNECT_WEBHOOK_SECRET), config.shopSetup (299 € einmalig),
Collection-IDs shopConfig/shopProducts/shopOrders/shopWiderrufe, services/shop/schalter.js, Katalogschluessel shopSetup in
stripeBilling und stripe-products.mjs, services/shop/schema.js, scripts/shop-setup.mjs (--check), schemaKeys fuer die Test-Mocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:30:21 +00:00
ClaudeandClaude Opus 5.5 b168b10485 Webshop: Design und Umsetzungsplan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:26:40 +00:00
ClaudeandClaude Sonnet 5.5 90e8607e26 Öffnungszeiten: Rechtstext-Entwürfe und Einbau-Anleitung
Leistungsbeschreibung „Öffnungszeiten live“ als statische Seite unter
/vertragsunterlagen/zeiten-leistungsbeschreibung.html (Register-Adresse, nur HTML-Kommentar
ENTWURF), Entwürfe für Datenschutz § 6d mit Ergänzung § 6, den Block funktionen in K02/K03
und die Kanzlei-Fragen (⚖ AVV Funktionen, Behalten-Zustimmung), Absatz „Öffnungszeiten auf
dieser Website“ für Variante A und B, Einbau-Anleitung für das Team. Keine Codeänderung,
datenschutz.html und die Mailvorlagen bleiben unverändert. test/zeitenRecht.test.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 07:34:56 +00:00
ClaudeandClaude Opus 5.5 af63b69ec1 Merge origin/main in feature/terminplaner-tp2 (Analyse-Cockpit, Subdomain-Sperre, Projekte)
Konflikte: funktionen.js folgt der Aufteilung aus main (fn-util.js, analyse.js); der Link
'Zu Verträge' der Analyse-Seite kommt jetzt auch in analyse.js aus vertraege-link.js.
Checkout und Team-Ansicht übernehmen assertOwnProject(..., { forNewOrder: true }) und
includeArchived aus main. Test-Pins der absoluten Importe nachgezogen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:11:58 +00:00
ClaudeandClaude Opus 5.5 be007af7d0 K22: Abo-Ende nur bei beendetem Wartungs-Abo (nicht das Datum eines Hosting-Abos)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:04:52 +00:00
ClaudeandClaude Sonnet 5.5 bea07e73b1 TP2 Abschlussprüfung: Rechtstexte (AVV, 6c, News-LB-Archiv, Livegang-Checkliste)
- AVV Vorbemerkung (2): K16 der Textform-Aktivierung trägt nur Leistungsbeschreibung und AVV als PDF
- AVV Nr. 9 Abs. 6, Anlage (Löschung), LB Nr. 8 Abs. 3 und Datenschutz 6c: das Einrichtungs-Ticket bleibt mit einer Kurzfassung der Einstellungen als Arbeitsnachweis (keine Daten von Gästen)
- AVV Nr. 8 Abs. 2: "Gastdaten löschen" löscht nur wartende E-Mails; fehlgeschlagene behalten Adresse und Inhalt bis Nr. 9 Abs. 4
- Datenschutz § 7: Frist für Bestellungen ohne Abo (drei Jahre nach Ablauf des Jahres der Bestellung)
- News-Leistungsbeschreibung vom 30.09.2026 unverändert archiviert (SHA-256-Pin), die geltende Datei verweist darauf
- Kanzlei-Paket: Zitate, Q4/Q9/Q23, Livegang-Checkliste (Abschnitt 7.6/7.7), K23-Hinweis
- build-legal-html: Rückwärtsschrägstrich in Navigationsadressen abgelehnt; TP1-Spezifikation E2 (ohne USt)
- HTML, PDFs und Prüfsummen neu erzeugt (Terminplaner-Unterlagen und Datenschutzerklärung)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 06:54:55 +00:00
ClaudeandClaude Opus 5.5 6a9daa0583 Öffnungszeiten: Urlaub-Frage lässt offene Hinweis-Formulare stehen
„Ja, anlegen“ zeichnet nur noch die Hinweis-Liste neu (offene, ungespeicherte
Formulare und Zeilen bleiben), statt den ganzen Abschnitt zu ersetzen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:54:45 +00:00
ClaudeandClaude Opus 5.5 b3e96d4a56 Öffnungszeiten: Editor für Hinweis- und Kontakt-Leiste, Google und Vorschau
Hinweis-Leiste (Liste, „Hinweis-Leiste anlegen“, Art, Knopf, Zeitraum, Preis-Hinweis),
Kontakt-Leiste (Telefon, Adresse, WhatsApp mit Datenschutz-Haken, R12 in der Kundenansicht),
Google (Für Google kopieren, Profil-Link), Live-Vorschau mit Regler „Uhrzeit testen“
und UI-Prüfung test/ui/zeiten-portal.ui.mjs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:43:22 +00:00
ClaudeandClaude Sonnet 5.5 5f99f08a39 TP2 Abschlussprüfung: Fragebogen und Bestellseite (veralteter Tab, Abo-Hinweis, Zurück aus Stripe)
- PUT /fragebogen: ein Tab mit veralteter Branche bekommt 409 EINSTELLUNG_VERALTET (die Branche wechselt nur die Vorlage), bestaetigt wird mit
  dem gespeicherten Stand vereinigt; die ungeprüften Vorlagenpreise können nicht mehr als geprüft gelten
- GET /einstellungen liefert in off aboHinweis (aboMeldung, auch bei nicht lesbarem Abo); die Hinweiskarte zeigt ihn immer und hat „Neu laden“
- Bestellseite und Statuskarte laden bei pageshow (persisted) neu, wenn der Browser sie nach der Zahlungsseite aus dem bfcache holt
- renderBestellen zeichnet „Ändern“ und „Zum Schritt“ nur mit zumSchritt; nach einem 409 bekommt die Meldung den Fokus
- Zusammenfassung nennt „Kleinunternehmer: ja/nein“

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 06:35:42 +00:00
ClaudeandClaude Sonnet 5.5 2fc48df915 TP2 Abschlussprüfung: Löschung (IP/Browser auf portalFeatures, Erstattungsspur, K22-Datum)
- markOrdered speichert IP und Browser der Bestellung nicht mehr am Funktionsdokument (nur im Nachweis featureConsents, dort mit
  Löschfrist); Altwerte werden bei einer neuen Bestellung geleert
- Aufbewahrungslauf (retention): consentIp, consentUserAgent, consentText, consentAt werden an allen Funktionsdokumenten eines fälligen
  Kunden geleert, auch an abgeschalteten; Fehlschlag hält den Kunden wie die übrigen abhängigen Daten
- Einmal-Skript scripts/feature-consent-felder-leeren.mjs (Standard Probelauf, --apply leert nur, wo featureConsents die Angabe trägt)
- 30-Tage-Reset (jobs.js): auch eine bezahlte Einrichtung mit Grund abo_ende_vor_einrichtung und noch nicht gemeldetem T19 wird nicht
  zurückgesetzt, solange zahlungMeldung nicht gemeldet ist (die Spur der Erstattung bleibt)
- K22: enddatum ist das Abo-Ende (lage.endedAt), sonst der Tag des Abschaltens; loeschdatum bleibt 30 Tage nach dem Abschalten

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 06:26:33 +00:00
ClaudeandClaude Opus 5.5 242fbb01e5 Öffnungszeiten: Plan-löschen-Rückfrage zählt die Ausnahmen beim Öffnen
Der Hinweis „Die N Ausnahmen, die nur für diesen Plan gelten, werden mit gelöscht“
wurde beim Bauen der Karte berechnet und war nach dem Speichern einer Ausnahme
veraltet. Er wird jetzt beim Öffnen der Rückfrage aus dem aktuellen Stand gelesen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:23:56 +00:00
ClaudeandClaude Sonnet 5.5 b974b3a3ec TP2 Abschlussprüfung: Zahlung/Team (Wiederzustellung, K16-Warnung, Sperrschlüssel)
- adoptPaidSession übernimmt ein erneut zugestelltes checkout.session.completed nicht mehr, wenn das Dokument nach 30 Tagen zurückgesetzt wurde
  (off, purchaseStatus zurueckgesetzt, gleiche Sitzung): kein Setup, kein Ticket, keine K15/T16
- meldeFreigeschaltet liefert true/false; die Staff-Aktivierung in Textform vermerkt eine nicht gesendete K16 mit Unterlagen in adminNote und
  antwortet mit warnung: true
- disable() nutzt die Sperre feature-activate:<id> statt feature-disable:<id> (Freischaltung und Abschalten überholen sich nicht)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 06:18:34 +00:00
ClaudeandClaude Opus 5.5 f01ed15247 Öffnungszeiten: Speichern zeichnet nur neu, was sich geändert hat
Ungespeicherte Eingaben in anderen Formularen bleiben stehen: nach dem Speichern
wird nur die Plankarte, die Planliste (mit den Eingaben der übrigen Karten),
Feiertage oder Ausnahmen neu gezeichnet, „Nein, danke“ entfernt nur die Frage.
Während des Speicherns sind auch die Eingabefelder gesperrt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:18:21 +00:00
ClaudeandClaude Opus 5.5 04a3525f79 Öffnungszeiten: Editor für Pläne, Feiertage und Ausnahmen
Speichern über PUT /api/zeiten (Version, 409, Feldfehler per ID-Pfad), Zeitpläne mit
Wochentagen, Hauptplan wechseln und Löschen (R10, R33), Feiertage je Bundesland,
Betriebsurlaub und Sondertage mit Hinweis-Leisten-Frage. Die Hülle behält den Editor
bei einem neuen Stand und zeichnet nur den Kopf neu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:02:31 +00:00
ClaudeandClaude Sonnet 5.5 4dc447ed33 Kanzlei-Paket: Prüfbefunde (genauere Aussagen, Zitate zu Q4/Q8/Q21, Fundstellen)
Genauere Aussagen: Widerrufsfrist je Bestellung mit spätestem Fristende für
"rechtzeitig", Textform beim Wiedereinschalten nur ohne Bestellung, Storno
und kein_widerruf in G02 getrennt beschrieben, keine Ablehnung nach dem
Klick außer aus den Gründen der Leistungsbeschreibung Nr. 4 Abs. 3, Preis
nur mit echten Wortlauten.

Zitate: Q4 mit dem früheren News-Haken und der früheren Leistungsbeschreibung
(Stand 3a0b008, per SHA-256 gepinnt und mit Git abgeglichen), Q8 mit
Leistungsbeschreibung Nr. 9 Abs. 2 und 3, Q19 mit Nr. 5 Abs. 2 und der Preiszeile
von K15, Q21 mit dem gespeicherten Nachweis und der Chat-Vorlage des
Ticketsystems. Begriffe (Betriebsart, Pilot, Kulanz) erklärt, interne
Entscheidungsnummern entfernt, Anlage C (gerenderte Mails K15 und K16) angekündigt.

Tests: Zitate unter 25 Zeichen zählen nicht mehr als geprüft (benannte Liste,
Belehrung und Knopftexte werden streng geprüft), schärfere Fundstellen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 05:53:24 +00:00
ClaudeandClaude Opus 5.5 cee44d6852 Öffnungszeiten: Behalten nur ohne Abo anbieten (kein_abo)
Behalten (30 €) gab es bei jedem inklusive false. nicht_aktiv und gekuendigt heißen aber,
dass das Wartungs-Abo läuft (Server: 409 Abo läuft wieder). Jetzt gilt an allen fünf Stellen
(Kachel, Karte, Behalten-Seite, Weg aus der Kachel, Abgeschaltet-Karte) dasselbe Prädikat:
inklusive false UND grund kein_abo; sonst „Abo läuft wieder“ ohne Bezahlknopf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:41:24 +00:00
ClaudeandClaude Sonnet 5.5 7050b63a37 Kanzlei-Paket Terminplaner: Fragen Q1 bis Q26
Abschnitt 4 mit den 26 Fragen (Frage, Fundstelle im Text, unsere Annahme,
Alternative). Jede Kennung der früheren Listen "Für die Rechtsprüfung offen"
(H1 bis C8) steht in der Fundstelle genau einer Frage oder in Abschnitt 6
mit Begründung; die Hinweise aus den Reviews (Pilot/Kulanz, Rückkehr nur
nach Abo-Ende, Erstattung bei Ablehnung, Löschung auf Weisung, vereinbarte
Fassung in K15, G02 bei kostenlosen Buchungen) sind in den passenden Fragen.

Tests: Fragen lückenlos Q1 bis Q26 mit vier Teilen, jede Frage verwiesen,
Q4 nennt News, § 356 Abs. 3 und Altkunden, Abdeckung der entfernten Listen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 05:31:57 +00:00
ClaudeandClaude Opus 5.5 7bb918390a Öffnungszeiten: Kachel und Bestell-/Einschaltweg im Portal
Kachel „Öffnungszeiten live“ inline in funktionen.js (keine neuen Importe, keine Zusatzabfrage),
Ansicht #zeiten (zeiten.js, zeiten-bestellen.js, zeiten.css) mit Einschalten im Wartungs-Abo,
Bestellen für 30 € (Widerrufsbelehrung vor dem Knopf, Ruling R32), Behalten nach dem Abo-Ende,
Statuskarte je Zustand, Rückkehr von Stripe und Platz für den Editor (Aufgabe 29).
GET /api/funktionen liefert dafür zeiten.belehrung (Haken belehrung der Funktion).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:30:23 +00:00
ClaudeandClaude Sonnet 5.5 6765b14feb Kanzlei-Paket Terminplaner: Aufbau, Texte, Ablauf, Fundstellen
Sieben Abschnitte nach D5: Zweck und Umfang, Produktablauf, Texte in
Endfassung (wörtliche Zitate mit Marker auf die Fragen), Fundstellen im Code,
Nicht Teil der Prüfung (mit Zuordnung der früheren offenen Punkte) und
Checkliste für den Inhaber. Abschnitt 4 (Fragen Q1 bis Q26) folgt.

Der Test prüft Aufbau, jedes Zitat gegen Programm und Dokumente, die
Fundstellen, die Zahlen gegen den Code und die Konsistenz der Abo-Ende-Texte.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 05:24:11 +00:00
ClaudeandClaude Opus 5.5 f20f056c60 Öffnungszeiten: Helfer für den Editor
Reine Client-Helfer (public/zeiten-view.js): Grenzen, neue IDs, Kurznamen, Fensterregeln und
Wochenfehler wie auf dem Server, Hauptplan wechseln, Löschgründe, Hinweis-Vorschlag zum
Betriebsurlaub, Preis-Hinweis, Link-Prüfung. Die Tests halten jede Regel gegen
server/services/zeiten/pruefen.js (zufällige Eingaben, gleiche Ergebnisse).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:05:01 +00:00
ClaudeandClaude Sonnet 5.5 ec320853ce Gaestetexte/Datenschutz: Storno-Regel im Archiv, PDF-Pruefsumme, PDF-Kopfpruefung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 05:03:54 +00:00
ClaudeandClaude Sonnet 5.5 9a2a70b491 Retention: abhaengige Daten vor dem Abo loeschen, Fehlschlag haelt den Kunden fuer den naechsten Lauf
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 05:03:54 +00:00
ClaudeandClaude Opus 5.5 811e054ca4 Öffnungszeiten: Ticket nachlegen lehnt eine Behalten-Zahlung ab (Stillstand an feature-admin)
bezahlteSitzung weist Sitzungen mit metadata.zweck behalten mit 409 ab. Sonst ginge die Sitzung über onFeaturePaid in bucheBehalten, das
feature-admin:<projekt> nimmt, während die Team-Route sie schon hält: Stillstand und dauerhaft belegter Schlüssel. Test in
test/zeitenTeamBehalten.test.js: 409 binnen Sekunden, Sperre frei, folgende Team-Aktion nicht blockiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:58:25 +00:00
ClaudeandClaude Sonnet 5.5 bade4edb74 Terminplaner: Staff-Aktivierung nur mit Zustimmung in Textform (Modus on), Portal
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 04:44:44 +00:00
ClaudeandClaude Sonnet 5.5 019833d385 Öffnungszeiten: Tests und Belehrung nach dem Merge mit TP2
Zeiten nutzen die echte Widerrufsbelehrung wie der Terminplaner (der Platzhalter BELEHRUNG_PLATZHALTER ist
mit TP2 F7 entfallen). Gepinnte Listen als Vereinigung beider Seiten: K15-Blöcke, T16-Blöcke, legal-Schlüssel der
Projektliste, hindernis in der Team-Ansicht für alle Funktionen. Neue Tests: Abnahme (Freischalten) der Zeiten über den
Haken freischaltbar und mit Ersatz-Hash, abnahme gibt für Zeiten nie ab.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 04:43:20 +00:00
ClaudeandClaude Opus 5.5 1d5a565e9f Merge feature/terminplaner-tp2 (a629b25) in feature/oeffnungszeiten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:42:31 +00:00
ClaudeandClaude Sonnet 5.5 5b5b4b90eb Retention: Zustimmungsnachweise der Funktionsbestellung drei Jahre nach Abo-Ende loeschen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 04:38:00 +00:00
ClaudeandClaude Sonnet 5.5 a629b2519b Datenschutz: Abschnitt 6c Terminplaner, Kundenansicht und Nachweise bei Funktionsbestellung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 04:34:16 +00:00
ClaudeandClaude Sonnet 5.5 6ec8885bfd Terminplaner: Hinweistexte nach Recherche, Storno nur bei Preis
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 04:29:32 +00:00
ClaudeandClaude Opus 5.5 ed99234ae4 Öffnungszeiten: Hinweis-Leiste, Kontakt-Leiste und JSON-LD im Website-Skript
zeiten.js zeichnet mit denselben Daten wie die Platzhalter eine Hinweis-Leiste
(Shadow DOM, erstes Kind von body, rückt feste und klebende Köpfe, scroll-padding-top),
eine Kontakt-Leiste nur auf schmalen Schirmen (Abstandhalter, scroll-padding-bottom,
entfällt bei eigener Anruf-Leiste) und ergänzt das JSON-LD der Seite (nur die beiden
Zeiten-Felder, sonst eigener Block). Alle Adressen aus der Schnittstelle werden im
Browser erneut geprüft. Ruling R29: tick mit try/finally, sofort werfender fetch,
erst zeichnen dann laden beim Zurückkehren in den Tab.

Tests: statische Funktionstests der reinen Funktionen und eine Playwright-Prüfung
gegen den UI-Testserver (echte öffentliche Schnittstelle, Nachbau von Appwrite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:27:04 +00:00
ClaudeandClaude Sonnet 5.5 722c686690 Gästetexte: Hinweis-Dokument ohne Entwurf-Wort, Archiv-Schutz
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 04:21:17 +00:00
ClaudeandClaude Sonnet 5.5 495d5ad9ca Gästetexte des Terminplaners: Entwurf-Kennzeichnung entfernt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 04:08:46 +00:00
ClaudeandClaude Sonnet 5.5 c1266fcce7 Gästetexte des Terminplaners: Fassung 2026-10-05, Erinnerungs- und Abo-Ende-Satz, Archiv je Fassung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 04:01:49 +00:00
ClaudeandClaude Opus 5.5 a6a4eae294 Öffnungszeiten: Website-Skript mit Status und Tabelle
public/embed/zeiten.js ruft die öffentliche Zeiten-API einmal je Seite ab und füllt
die Platzhalter [data-webklar-zeiten] (tabelle, status, badge; data-plan, Hauptplan
ohne Angabe). Reine Funktionen (waehleWechsel, naechsterWechsel, waehleHinweis,
jetztMitServer, tabelleDaten) stehen zwischen den Marken @rein-anfang/@rein-ende.
Timer auf den nächsten Wechsel (jede Wartezeit auf 10 Minuten begrenzt), Neuladen
höchstens alle 10 Minuten, Daten älter als 24 Stunden, Fehler oder aktiv:false
lassen das Ersatz-HTML stehen bzw. stellen es wieder her. Testseite zeiten-demo.html.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:01:31 +00:00
ClaudeandClaude Sonnet 5.5 1a91a331b4 Gaestetexte: Archiv-Skript und eingefrorene Fassung 2026-10-02
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 03:54:15 +00:00
ClaudeandClaude Sonnet 5.5 01bf05cc35 Widerruf: Terminplaner als eigene Auswahl, Frist und Mails je Funktion
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 03:50:26 +00:00
ClaudeandClaude Opus 5.5 a2d11ad6c4 Runner: Zeiten-Skript beim Veröffentlichen einsetzen
injectIntoHtml nimmt eine Marke je Tag (geprüft gegen data-wk-[a-z]+),
injectZeiten setzt den data-wk-zeiten-Tag ohne Datenschutzseite ein,
validateJob lässt nur den siteKey durch, ZEITEN_PUBLIC_BASE in der Konfiguration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:46:06 +00:00
ClaudeandClaude Opus 5.5 34384c7a39 AVV Terminplaner: Kleinschreibung nach Semikolon (TOM Trennung)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:43:39 +00:00
ClaudeandClaude Sonnet 5.5 90098726b9 Rechtstexte und K15: Prüfbefunde F5–F8 (AVV genauer, PDF-Prüfsumme, Unterlagen der vereinbarten Fassung)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 03:42:27 +00:00
ClaudeandClaude Opus 5.5 a198c472bd Öffnungszeiten: Feiertags-Erinnerung und Jobs
erinnereFeiertage(now): stündlich, nur bei Schalter pilot/on (pilot: nur
freigegebene Kunden) und nur 08:00 bis 20:00 Berliner Zeit; je Funktion in
setup/active zuerst der Merker sha1(featureId|datum) je Tag (409 = schon
erinnert), danach eine Mail K25 mit allen neuen Tagen ohne BCC ans Team;
Merker älter als 30 Tage werden gelöscht. zeitenAboJob (täglich) und
zeitenErinnerungsJob (stündlich) mit eigener Überlappsperre, ohne Wurf;
in server/index.js nach 240 s bzw. 300 s gestartet, mit unref.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:40:07 +00:00
ClaudeandClaude Opus 5.5 9a14e874c0 Öffnungszeiten: Daten bei Kundenlöschung entfernen
Kunde löschen schaltet die Funktion zeiten wie den Terminplaner ab (Grund
kunde_geloescht) und löscht Zeiten, Erinnerungsmerker und Antwortspeicher
sofort; Fehler halten die Kundenlöschung nicht auf. Die Löschfristen löschen
die Zeiten vor dem Abschalten und zählen sie im Bericht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:30:00 +00:00
ClaudeandClaude Sonnet 5.5 2c059cb3d6 Funktionen: Vertragsunterlagen als K15-Anhang, Belehrung vor dem Bestellknopf
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 03:20:56 +00:00
ClaudeandClaude Opus 5.5 efc6dcf332 Öffnungszeiten: Abo-Abgleich mit 30 Tagen Frist
gleicheZeitenAboAb(now) koppelt die im Wartungs-Abo enthaltenen Zeiten
(unlockedBy abo) an das Abo (Spec 5, Z11): Abo-Ende erkennen (aboEndeAt, K26),
nach 30 Berliner Kalendertagen abschalten (abo_ende, K27, Ersatz-Ticket),
bei Rückkehr des Abos wieder einschalten (Neubau nach dem Abschalten, K24
wieder), 30 Tage nach dem Abschalten Daten löschen und state off.
Lesefehler beim Abo: Kunde überspringen, nie abschalten oder löschen.

R25: activate() leert aboEndeAt und behaltenSessionId; starteBehalten lehnt
bei laufendem Wartungs-Abo mit 409 ab (Lesefehler 503).
R17: k24Mail mit einbau null und wieder schaltet beide Einbau-Blöcke ab,
K24 bekommt dafür den Block weiter um den Kasten "Wie geht es weiter?".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:16:56 +00:00
ClaudeandClaude Sonnet 5.5 03bdeb4461 Bestelltexte des Terminplaners (vier Zustimmungen, Belehrung im Nachweis, Fassung)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 03:10:29 +00:00
ClaudeandClaude Sonnet 5.5 49acd13f84 Rechtstexte: HTML-Generator und PDFs des Terminplaners
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 03:03:40 +00:00
ClaudeandClaude Sonnet 5.5 24cb549e78 Rechtstexte: AVV Terminplaner
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 02:57:05 +00:00
ClaudeandClaude Opus 5.5 53253f0451 Öffnungszeiten: nach Abo-Ende für 30 € behalten
Neuer Modus neben Bestellen und Einschalten (Spec 3.3): POST /api/funktionen/zeiten/behalten
startet einen Checkout mit zweck 'behalten', ohne den Zustand zu ändern; der Webhook-Zweig
bucht vor markPaid/adoptPaidSession unter feature-admin zum Kauf (Frist weg, stripeSessionId =
Sitzung), schaltet eine abgeschaltete Funktion wieder ein (Neubau nach der Sperre), ohne neues
Ticket, mit K15 im Behalten-Wortlaut. Passt die Sitzung nicht, geht T19 nach der Sperre raus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:54:39 +00:00
ClaudeandClaude Opus 5.5 97e6971276 Öffnungszeiten: Team-Aktionen und Ersatz-Ticket beim Abschalten
Team-Route für zeiten: Aktivieren läuft über den Gratis-Pfad (einschaltenZeiten, setup,
unlockedBy team, Neubau, Einbau-Ticket, keine K24), Abschalten setzt disabled mit Grund team
ohne Löschfrist und legt über nachAbschaltung bei geändertem Wochenplan das Ticket
"Ersatz-Tabelle aktualisieren" an. PATCH /:customerId/zeiten/team speichert die
Team-Einstellungen mit teamVersion. Die Team-Ansicht liefert für zeiten zusätzlich plaene,
team, teamVersion und hindernis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:39:43 +00:00
ClaudeandClaude Sonnet 5.5 6c474c46c3 Rechtstexte: Leistungsbeschreibung Terminplaner
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 02:39:28 +00:00
ClaudeandClaude Sonnet 5.5 6b4cbb3204 Rechtstexte: Widerrufsbelehrung der Funktionen aus dem Gesetzesmuster
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 02:35:04 +00:00
ClaudeandClaude Sonnet 5.5 3073fffcf5 News: Zitat § 356 Abs. 5 Nr. 2, Wertersatz-Satz und § 19 UStG statt brutto
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 02:30:38 +00:00
ClaudeandClaude Opus 5.5 11dc4b2c9f Öffnungszeiten: Freischalten erst mit eingetragenen Zeiten
Der 5-Minuten-Job setzt Öffnungszeiten live nach dem Schließen des Tickets
nur noch auf active, wenn der Kunde Zeiten eingetragen hat (Haken
freischaltbar: Hauptplan gespeichert, mindestens ein Tag nicht "zu";
Lesefehler ergeben false, das Dokument bleibt in setup). Neuer Register-Haken
nachFreischaltung: runActivation ruft ihn je freigeschaltetem Dokument vor der
K16 auf, Fehler werden nur geloggt; für die Zeiten merkt er den Ersatz-Hash
(wochenplanHash). Selbstheilung (Spec 3.2): im Lauf des Jobs (nicht in der
Projektliste mit customerId) holt holeZeitenNach für Zeiten in setup fehlendes
Ticket und K24 nach, je Dokument abgesichert und ohne Kundendaten im Log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:28:52 +00:00
ClaudeandClaude Sonnet 5.5 b20d0464c4 Rechtstexte: Dokument-Registry und Fassungskonstante des Terminplaners
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 02:24:42 +00:00
ClaudeandClaude Opus 5.5 47ae572fff Öffnungszeiten: Bezahlen gesperrt, wenn im Abo enthalten
Checkout: neue Stufe 7b pruefeInklusive (Funktionen mit aboInklusive):
laeuft das Wartungs-Abo (auch nicht aktiv), 409 inklusive mit Hinweis aufs
Einschalten; kein Abo, Hosting und gekuendigt zahlen weiter 30 Euro.

Zahlung (markPaid): setup mit unlockedBy kauf, dann Neubau (stosseNeubauAn,
einbau am Dokument), erst danach das Einbau-Ticket mit dem Ergebnis; die
Webhook-Wiederholung baut nicht noch einmal. Spaete Zahlungen aendern ein
vorhandenes unlockedBy abo/team nicht. News und Terminplaner unveraendert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:18:30 +00:00
ClaudeandClaude Opus 5.5 66793d06d5 Öffnungszeiten: Einschalten im Wartungs-Abo
POST /api/funktionen/zeiten/einschalten (Gratisweg, Spec 3.2): Abo frisch
prüfen, Zustimmung (consentFrei) und Nachweis ohne Stripe-Sitzung, Zustand
setup mit unlockedBy abo, dann Neubau, Einbau-Ticket und K24; ein zweiter
Klick holt Fehlendes nach (holeZeitenNach, eigene Sperre). GET /api/funktionen
liefert das Angebot (zeiten), features.zeiten mit unlockedBy, aboEndeAt, einbau,
loeschungAm sowie zeitenStatus und zeitenLeer je Projekt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:03:49 +00:00
ClaudeandClaude Opus 5.5 8d670ca5dc Öffnungszeiten: Einbau-Ticket und Ersatz-HTML für das Team
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:48:20 +00:00
ClaudeandClaude Opus 5.5 302b58cf72 Öffnungszeiten: Neubau anstoßen und Feld im Runner-Auftrag
buildZeiten (einbau.js) liefert dem Runner-Auftrag { siteKey } bei Funktion in setup/active,
null bei Sperrliste und Fehlern. stosseNeubauAn (neubau.js) stößt den Neubau unter der
Sperre zeiten-neubau an und meldet angestossen oder manuell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:41:10 +00:00
ClaudeandClaude Opus 5.5 0042d8dd8f Öffnungszeiten: Mails K24 bis K27
Neue Kundenmails (Du-Form, Skelett von K23):
- K24 eingeschaltet (Blöcke angestossen/manuell nach einbau, wieder nach der Abo-Rückkehr)
- K25 Feiertage prüfen (Blöcke pruefen/mehrere, ohne BCC)
- K26 Abo beendet (abo_ende/hosting/wechsel_widerrufen, Frist, einmalig 30 € behalten)
- K27 abgeschaltet (feste Zeiten, Eingaben bis zum Löschdatum, Wiedereinschalten)

server/services/zeiten/mails.js: Builder k24Mail bis k27Mail und sendeZeitenMail
(wirft nie, ohne Adresse { sent:false, reason:'keine_adresse' }, Log ohne Adresse und Namen).

K15 bekommt den Block behalten (Gegenstück neu für den Einrichtungstext; bestellMail
setzt neu = nicht behalten, Standard unverändert), T16 die Blöcke abo und team
(sendT16 führt sie als Vorgabe aus). Pins in mailTemplates- und featureMails-Tests
angepasst; K26 darf „Wartungs-Abo“ im Betreff führen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:33:04 +00:00
ClaudeandClaude Opus 5.5 d1f9176c99 Öffnungszeiten: öffentliche Route
GET /api/public/zeiten/v1/:siteKey mit CORS *, OPTIONS 204, 405, weichem ETag
ohne stand, Cache-Control max-age=60, Expose-Headers Date, Grenze 120 je Minute
und IP (zeiten/limits.js), 503 ohne Details und 404 für unbekannte Pfade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:19:43 +00:00
ClaudeandClaude Sonnet 5.5 938b7099dd Funktionen: Team-Route fuer die Terminplaner-Einbauanleitung
GET /api/team/funktionen/:customerId/termine/anleitung?projectId= liefert die volle
Einbauanleitung (ohne kurz) fuer das WOMS-Panel: stand, warnungen mit Drift-Warnung
gegen die Version im Ticket, abschnitte, text, state, ticketWoid, ticketVersion und
hindernis (nur in setup). Nur lesend, in jedem Zustand und Modus, ohne customerViewLog.
Die Teamansicht GET /:customerId traegt hindernis je Terminplaner in setup, mit einem
Abo-Lauf je Anfrage und ohne portalFeatures erneut zu lesen. Die Einbau-Doku nennt die
Anleitung je Kunde im WOMS-Panel und die Kurzfassung im Ticket.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 01:18:30 +00:00
ClaudeandClaude Opus 5.5 d4c1be91c7 Öffnungszeiten: wechsel der öffentlichen Antwort trägt wieder den Berliner Tag
Das Website-Skript rechnet keine Zeitzonen und braucht tag (Berliner Kalendertag
bei ab) für „heute“ und die nächsten 14 Tage; wechsel-Einträge sind wieder
{ ab, tag, offen, text, kurz }.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:15:42 +00:00
ClaudeandClaude Opus 5.5 1c9f773e97 Öffnungszeiten: öffentliche Antwort mit Cache
oeffentlich.js baut die Antwort für das Website-Skript (Pläne mit Woche, 60 Tagen
und Statuswechseln, Hinweise, Kontakt-Leiste, JSON-LD, Firma, Team) und merkt sie
30 Sekunden je siteKey. Ungültige Schlüssel und ausgeschalteter Schalter lösen
weder eine Abfrage noch einen Cache-Eintrag aus; die Funktion antwortet schon in
setup. findActiveBySiteKey nimmt dafür { states } entgegen (Vorgabe wie bisher
nur active).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:12:16 +00:00
ClaudeandClaude Sonnet 5.5 f830698dc3 Funktionen: Freischalten nach Abnahme und Ticket nachlegen (Team)
Neue Team-Aktionen abnahme (Freischalten ohne Ticket-Schliessung, K16
einmal, unter feature-activate:<id> mit Haken freischaltHindernis) und
ticket (Ticket nachlegen: Zahlung bei Stripe pruefen und Webhook-Weg,
geloeschtes Ticket ersetzen, vorhandenes unberuehrt). Ticketanlage aus
markPaid in legeTicketAn herausgeloest.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 01:08:03 +00:00
ClaudeandClaude Opus 5.5 7bf2ad2665 Öffnungszeiten: Kundenschnittstelle zum Bearbeiten
GET/PUT /api/zeiten/:projectId und GET .../vorschau für den Editor im Portal.
Zugang nach Funktionszustand (ordered/setup/active lesen und speichern,
disabled mit Daten nur lesen), Version 0 vor dem ersten Speichern, 409
ZEITEN_VERALTET, 60 Speichervorgänge je Kunde und Stunde. Die Kundenansicht darf
speichern (Protokoll zeiten_gespeichert), den WhatsApp-Haken setzt nur der
Kunde selbst: bestaetigtAt kommt vom Server (R12), in der Kundenansicht 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:01:24 +00:00
ClaudeandClaude Sonnet 5.5 8fe14c2fe6 Funktionen: Freischalt-Hindernisse und Freigabe-Mail ausgelagert
freischaltHindernis (Abo zuerst, dann Einstellung; Abo-Lesefehler = abo_unbekannt)
und meldeFreigeschaltet (K16, Fehler geloggt, mit attachments) in
termine/freischaltung.js. Der Standardhaken freischaltbar fuer termine
delegiert an freischaltHindernis, runActivation an meldeFreigeschaltet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 00:56:35 +00:00
ClaudeandClaude Opus 5.5 097036c9a5 Öffnungszeiten: Speicher mit Versionen und Cache
LRU-Speicher mit Ablaufzeit (lru.js), Cache-Instanz der öffentlichen Antwort
(cache.js: 500 Einträge, 30 s) und Appwrite-Speicher mit getrennten Versionen
für Kundendaten und Team-Einstellungen (speicher.js), alle Schreibzugriffe
unter der Sperre zeiten:<projectId>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:50:41 +00:00
ClaudeandClaude Sonnet 5.5 2db3eca357 Terminplaner: Kurzfassung der Einbauanleitung fuer das Einrichtungs-Ticket, T16-Gesundheitsblock
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 00:45:56 +00:00
ClaudeandClaude Opus 5.5 88fb9263f9 Öffnungszeiten: Prüfregeln für Zeiten und Team-Einstellungen
normalisiereZeiten/normalisiereTeam prüfen und normalisieren alles, was Kunde und
Team speichern: Fensterregeln, Ausnahmen, Hinweise, Kontakt, Telefon, Links, Größe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:35:10 +00:00
ClaudeandClaude Sonnet 5.5 90692b646b Terminplaner: Einbauanleitung als reiner Generator (Einbau bis Abrechnung, Warnungen)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 00:22:04 +00:00
ClaudeandClaude Opus 5.5 5f52ef54e8 Öffnungszeiten: Ersatz-Tabelle und Wochenplan-Hash
ersatzHtml liefert die schlichte Wochentabelle eines Plans als reines ASCII
(Entities, maskierter Planname), wochenplanHash den 16-stelligen sha1 der
Tage des Hauptplans. Dafuer wird modusUndFenster aus der Zeitlogik exportiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:19:22 +00:00
ClaudeandClaude Opus 5.5 61c2f72631 Öffnungszeiten: Hinweise, JSON-LD, Google-Text und Erinnerungstage
Reine Funktionen in zeitlogik.js: hinweisZeitraum/gueltigeHinweise (Berliner Kalendertage,
Rang, Beginn in 8 Tagen), jsonLdFuer (Mitternachtsfenster, rund 00:00–23:59, Sonderzeiten
der nächsten 60 Tage, neuerBlock mit @context), googleText, erinnerungsTage und
imErinnerungsfenster (08:00–20:00 Berlin).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:12:20 +00:00
ClaudeandClaude Sonnet 5.5 13bbadc729 Terminplaner: Einbauanleitung als reiner Generator (Modell bis Benachrichtigungen)
einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt, kunde,
entwurf }) setzt die ersten sechs Abschnitte der Team-Anleitung zusammen (modell, leistungen,
ressourcen, regeln, gastfelder, benachrichtigungen, feste Reihenfolge, nicht kopierbar) plus
stand { version, updatedAt } und den Volltext. Rein: kein I/O, config nur beim Aufruf
(Portal-Adresse, Haltezeit); gleiche Eingabe und gleiches now ergeben gleiche Ausgabe, die
Eingabe bleibt unveraendert.

Feldweissliste statt Objektkopie: nie feedToken, keine Betriebs-E-Mail, keine
Leistungsbeschreibung, keine unbekannten Zusatzfelder. Kundentexte stehen unmaskiert als Text,
einzeilige Werte werden auf eine Zeile gebracht (keine vorgetaeuschte Ueberschrift).
Abwesenheiten und Sonderoeffnungen nur ab heute (Berliner Kalendertag), Listen ueber 20
Eintraege enden mit "und N weitere". Hilfen wochenplanText und kuerzeListe sind exportiert.

Warnungen und Kurzfassung bleiben leer (kommen mit D3b und D4); ohne Einstellung entstehen
keine Abschnitte.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 00:07:38 +00:00
ClaudeandClaude Opus 5.5 59b0ea799d Öffnungszeiten: Live-Status, Texte und Wechselzeitpunkte
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:59:20 +00:00
ClaudeandClaude Sonnet 5.5 ee454017b0 Terminplaner: Einbau-Snippet mit Knopf und Farbvorschlag nach WCAG verschoben
einbauSnippet und esc wandern aus routes/termine.js nach services/termine/einbauanleitung.js
(Parameter statt ctx/config). Neu: Modus knopf mit data-knopf (nur bei gesetztem Text, hoechstens
40 Zeichen, Zeilenumbrueche zu Leerzeichen, maskiert; unbekannter Modus gilt als inline), WCAG-
Helfer luminanz/kontrast/textFarbeAuf 1:1 aus dem Widget und cssVorschlag(farbe) mit Warnung,
wenn der Akzent gegen Weiss unter 3 : 1 liegt. VORGABE_FARBE wird exportiert. Die Kundenansicht
bleibt unveraendert (Modus inline). Farbparitaet Widget/Server ueber alle 4096 Farben im
17er-Raster getestet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 23:55:42 +00:00
ClaudeandClaude Sonnet 5.5 06ebcd1b0a Terminplaner: Datenschutz-Absatz als Rechtstext-Baustein
datenschutzAbsatz(einstellung, anrede) liefert den Absatz "Online-Terminbuchung" fuer die
Datenschutzerklaerung der Betriebs-Website als reinen Text (Sie- und Du-Fassung, fuenf bedingte
Saetze: Telefon-Feld, Nachricht/Zusatzfelder, Erinnerung, Widerruf ueber zeigtWiderruf, Telefon des
Betriebs). RECHTSTEXTE_ENTWURF = true. Test pinnt den Wortlaut gegen die Markdown-Quelle in allen
32 Bedingungskombinationen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 23:47:58 +00:00
ClaudeandClaude Opus 5.5 0f25f7cb52 Öffnungszeiten: Tagesbild und Öffnungsintervalle
Reine Zeitlogik in Europe/Berlin: tagesbild (Sondertag > Urlaub > Feiertag > Wochenplan,
planbezogen vor global, später angelegt gewinnt), oeffnungsIntervalle (zusammengefügt,
Fenster über Mitternacht gehören zum Starttag), zuUtcOderDanach (März-Lücke auf 03:00).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:44:28 +00:00
ClaudeandClaude Opus 5.5 2636ce60be Feiertage: Namen und Prüftage im Zeitraum
Neue Exporte feiertagName und feiertageImZeitraum (feiertagejs getHolidays je Jahr,
Namen per translate('de')). Heiligabend, Silvester, Mariä Himmelfahrt in BY und
Fronleichnam in SN/TH (Datum aus RP) sind 'pruefen'; istFeiertag bleibt unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:36:44 +00:00
ClaudeandClaude Sonnet 5.5 1ac142b92f Terminplaner TP2: UI-Testserver Entwurf und Playwright Fragebogen bis Bestellklick
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 23:34:38 +00:00
ClaudeandClaude Opus 5.5 db9f7df747 Öffnungszeiten: Register-Eintrag und Stripe-Katalog
Funktion zeiten in config.features (30 €, aboInklusive, consentFrei, teilweiseLive),
freiweg() und Standard-Haken in featureRegistry.js, Stripe-Katalog zeitenSetup
(Metadaten zeiten-einrichtung) in stripeBilling.js und scripts/stripe-products.mjs,
vorläufige zeitenTicketDetails; Schlüssellisten-Pins um zeiten ergänzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:31:36 +00:00
ClaudeandClaude Opus 5.5 2314f7d940 Öffnungszeiten: Index idx_feature für die Erinnerungsmerker
Task 9 löscht die Merker einer Funktion per featureId; Appwrite verlangt dafür einen Index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:26:30 +00:00
ClaudeandClaude Opus 5.5 4b4cd1058f Öffnungszeiten: Schalter, Collections und Felder
ZEITEN_ENABLED/ZEITEN_PILOT_EMAILS/ZEITEN_KEIN_NEUBAU als config.zeiten (neben config.termine),
zeitenSetup (30 € einmalig), Collection-IDs oeffnungszeiten und zeitenErinnerungen, Schema der beiden
Collections plus fünf neue portalFeatures-Felder, scripts/zeiten-setup.mjs (idempotent, --check),
schemaKeys kennt die neuen Collections und Felder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:25:37 +00:00
ClaudeandClaude Opus 5.5 385f768d76 Öffnungszeiten live: Umsetzungsplan (37 Tasks in Blöcken A bis M)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:19:34 +00:00
ClaudeandClaude Sonnet 5.5 87d267e51a Terminplaner TP2: Ansicht nach Zustand und Rueckkehr von Stripe
Die Ansicht #termine folgt dem Zustand aus GET /api/termine/einstellungen:
off mit laufendem Abo zeigt den Fragebogen samt Bestellseite, ohne Abo den
Hinweis des Servers (termine.hinweis, R111) und bei kein_abo den Link zu
Vertraege aus einer gemeinsamen Quelle (vertraege-link.js); ordered und setup
zeigen die Statuskarte und die Reiter Leistungen, Team & Plaetze und
Einstellungen nur zum Lesen (ohne Reiter Termine); active und disabled
bleiben wie bisher. Rueckkehr von Stripe: #termine?bestellt=1&projekt=<id>
und #termine?projekt=<id> waehlen das Projekt, "Danke!" nur mit bestellt=1,
die Adresse wird einmal gelesen und bereinigt.

R110: GET /einstellungen liefert bestellung und offen auch in ordered (ohne
Abo-Punkt, abo bleibt null); scheitert "Zahlung fortsetzen", zeichnet die
Huelle Liste und Daten neu (bei 409 zustimmung_fehlt mit dem
Bestellformular, sonst nach dem neuen Zustand, die Meldung bleibt am Knopf,
solange die Bestellung offen ist).

Dazu: "null" als Text im lesbaren Reiter Leistungen entfernt, Export und
Status der Einstellungen erst ab der Freischaltung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 23:10:54 +00:00
ClaudeandClaude Opus 5.5 360d218d4e Öffnungszeiten live: Design (Entscheidungen Z1–Z23 des Inhabers, Prüfung gegen den Code eingearbeitet)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:52:38 +00:00
ClaudeandClaude Sonnet 5.5 52456dac76 Terminplaner TP2 C10a: Nachbesserung 1 (Abo-Hinweis vom Server, R111)
GET /api/funktionen liefert bei termine.verfuegbar ein fuenftes Feld hinweis
(aboMeldung mit dem Register des Terminplaners, null bei bestellbar true).
Die Kachel zeigt diesen Text maskiert an; die Browser-Kopie der Abo-Texte und
der Gleichheitstest entfallen. Der Link "Zu Verträge" ist eine gemeinsame
Konstante fuer Analyse und Terminplaner und erscheint bei kein_abo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 22:43:17 +00:00
ClaudeandClaude Sonnet 5.5 6ae0f67457 Terminplaner TP2: Kachel mit Bestellweg, Abo-Felder in der Funktionen-Liste
GET /api/funktionen liefert bei termine.verfuegbar: true zusaetzlich bestellbar
(true | false | null bei Lesefehler im Abo), grund (kein_abo | gekuendigt |
nicht_aktiv | null), preis und einrichtung aus dem Register; nicht verfuegbar
bleibt exakt { verfuegbar: false }. Die Kachel zeigt je Zustand Pille und Knopf:
Aus mit Abo "Terminplaner einrichten · 149 €" bzw. "Fragebogen fortsetzen",
Aus ohne Abo keinen Knopf, sondern den Hinweis zum Abo, Zahlung offen und In
Einrichtung "Status ansehen", Aktiv "Termine oeffnen", Abgeschaltet "Termine
ansehen". Zielprojekt: aktiv, ordered/setup, abgeschaltet, Entwurf, erstes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 22:34:50 +00:00
ClaudeandClaude Sonnet 5.5 8d628ef906 Terminplaner TP2: Fragebogen-Schritte 4 bis 8 und Bestellseite
- public/termine-fragebogen.js: Schritt 4 Zeiten (je Person/Platz wochenplanFeld,
  Bundesland ohne Vorauswahl mit leerer erster Option, "An Feiertagen geschlossen" im
  PATCH, danach PUT mit bundeslandGewaehlt; darunter abwesenheiten und sonderoeffnungen
  mit eigenem Container und eigenem zeichne, damit ungespeicherte Zeiten bleiben),
  Schritt 5 Regeln (Sammelknopf Alle sofort buchbar / Alle nur als Anfrage mit
  buchungsartFuerAlle und buchungsartLage, regeln, storno mit Muster nach Absagefrist,
  rechtliches, zahlung), Schritt 6 Gastfelder, Schritt 7 mit gemeinsamem
  leseAbschnitte, Schritt 8 Website (betrieb nur Telefon und vier Links, Einbauwuensche
  Modus/Knopftext/Stelle/Gestaltung/Bemerkung im PUT, Hinweis auf Mehrwuensche nach
  Stunden); danach renderBestellen. Platzhalter-Schritt entfernt.
- termine.css: Klassen der Schritte 4 bis 8
- Tests: termineView (Importe, PATCH/PUT nur an erlaubten Stellen, Bundesland ohne
  Vorauswahl im Quelltext), termineFragebogenView (Verhalten mit Fake-DOM fuer die
  Schritte 4 bis 8, Schluesselmengen, Bestellseite nach Schritt 8)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 22:18:44 +00:00
ClaudeandClaude Sonnet 5.5 9bf5ec09f8 Terminplaner TP2: Fragebogen-Geruest und Schritte 1 bis 3
- public/termine-fragebogen.js: renderFragebogen(root, ctx) mit Fortschrittsleiste
  (ol, aria-current, Handy: Schritt n von 8), Start beim ersten offenen Schritt
  (alles bestaetigt: Bestellseite), freier Navigation, einem Formular je Schritt,
  Weiter = ein PATCH ueber neuerSpeicher, danach PUT /fragebogen; Fokus auf die
  Ueberschrift; Kundenansicht ohne PUT
- Schritt 1 Branche und Betrieb (Gesundheitsbereich: Erklaertext statt Weiter, keine
  Anfrage; Branche wechseln mit Rueckfrage), Schritt 2 Wer wird gebucht,
  Schritt 3 Leistungen mit Haken Preise geprueft; Schritt 7 als Beispiel eines
  Schritts mit Feldern, Schritte 4, 5, 6 und 8 folgen in C9b
- termine.css: Fortschrittsleiste und Handy-Ansicht
- Tests: termineView (Dateiliste, PUT nur im Fragebogen, nur ueber termine.js geladen),
  termineFragebogenView (Verhalten mit Fake-DOM)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 21:55:51 +00:00
ClaudeandClaude Opus 5.5 f1b43ca2bf Spec: Nachtrag abgeschlossene Tage und 'Heute bisher' (Final-Review F1/F2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:34:43 +00:00
ClaudeandClaude Opus 5.5 fea58fc621 Analyse: Mini-Verlauf zeichnet einen einzelnen Messtag zwischen Lücken als Punkt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:34:43 +00:00
ClaudeandClaude Opus 5.5 0db59d38e9 Analyse: Zeitraum = abgeschlossene Tage, "Heute bisher" getrennt, Feinschliff
Final-Review-Fixwelle der Cockpit-Übersicht:

- F1 Collector: Der Zeitraum sind die range Kalendertage bis gestern (Berliner Tag),
  der Vorzeitraum die range Tage davor; heute steht getrennt in today { day, pv, visitors,
  contacts }. Kein Einbruch mehr am rechten Rand von Diagramm und Mini-Verläufen,
  kein falsches "x % weniger" tagsüber. Portal: weekdayStats bekommt die ganze Reihe
  (completedDays entfällt), "Vergleich ab" = firstDay + 2 x range, Kopfzeile
  "Analyse aktiv · Heute bisher: N Aufrufe · Stand: HH:MM Uhr" (alter Collector ohne today
  bleibt ohne "Heute bisher").
- F2 "direkt kontaktiert" zählt nur Telefon, E-Mail und WhatsApp (auch im Vorzeitraum),
  Links nach außen nicht.
- F3 Mini-Verlauf "Ø Zeit pro Seite": Tage ohne Messung sind Lücken statt 0-Tälern.
- F4 Fehlerfarbe beim Ausschalten rot, --an-faint auf 55 %, Achsenschrift per
  Container-Abfragen auf 11-12 px (y-Rand wächst mit der Stellenzahl, x-Beschriftungen mit
  Mindestabstand), Ranglisten-Anteile an allen Seitenaufrufen, Tippflächen bis 880 px
  mindestens 40 px hoch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:34:43 +00:00
ClaudeandClaude Opus 5.5 eadb768d1a Analyse: verspätete Statistik-Antwort überschreibt den Stand nicht mehr
loadStats schrieb state.stats/range/stamp schon beim Eintreffen der Antwort,
vor der current()-Prüfung der Aufrufer. Eine späte 30-Tage-Antwort überschrieb
so den 7-Tage-Stand, und "Alle anzeigen" oder der Ranglisten-Umschalter malte
danach mit 30-Tage-Zahlen. Jetzt liefert fetchStats nur die Antwort, adopt()
übernimmt sie erst nach current(). Übersicht und Heatmap teilen sich eine
laufende Anfrage je Zeitraum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:34:43 +00:00
ClaudeandClaude Opus 5.5 a9f0711e97 Analyse: Cockpit-Übersicht mit Reitern Übersicht, Heatmap, Einstellungen
analyse.js setzt die Bausteine aus analyse-cards.js zusammen: Kopfleiste mit
Stand und Zeitraum, Reiter (Pfeiltasten), Übersicht mit Ladegerüst und
Fehlerkarte, Diagramm-Blase, Ranglisten-Umschalter, Heatmap erst beim Öffnen
des Reiters, Einstellungen. Einschalten, gesperrt, nicht verfügbar und
unbekannt erscheinen im selben Rahmen, Texte unverändert.

analyse.css (neu) trägt alle Analyse-Regeln unter .an mit eigenem türkisen
Akzent; der Analyse-Abschnitt in funktionen.css entfällt. Der Client-Test des
Banners liest die Banner-Regeln jetzt aus analyse.css.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:34:43 +00:00
ClaudeandClaude Opus 5.5 1964e5fcca Analyse: Farbfeld der Einstellungen zeigt die tatsächliche Banner-Farbe
Das Farbfeld nutzt bannerColor() wie die Website: eine für weiße Schrift
zu helle Akzentfarbe wird zu #111111 statt der gespeicherten Farbe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:34:43 +00:00
ClaudeandClaude Opus 5.5 55e98b038c Analyse: HTML-Bausteine der Cockpit-Übersicht
Reine String-Builder (Kopfleiste, Reiter, Das Wichtigste, Kacheln mit
Mini-Verlauf und Chips, Diagramm, Kontakt-Aktionen, Ranglisten, Datenbasis,
Heatmap-Karte, Laden, Fehler, Einstellungen) für die neue Analyse-Ansicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:34:43 +00:00
ClaudeandClaude Opus 5.5 f119a0cacc Analyse: Wochentag-Hinweis zählt den heutigen, unvollständigen Tag nicht mit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:34:43 +00:00
ClaudeandClaude Opus 5.5 69c94d83d7 Analyse: Vergleich zum Vorzeitraum und Klartext-Regeln als reine Funktionen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:34:43 +00:00
ClaudeandClaude Opus 5.5 9d1691cf6b Analyse: Oberfläche in eigenes Modul analyse.js, gemeinsame Helfer in fn-util.js
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:34:43 +00:00
ClaudeandClaude Opus 5.5 97bff91a99 Analyse: Zeitumstellungs-Test mit Abenduhr und Ereignissen, erkennt naives Vorzeitraum-Fenster
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:34:43 +00:00
ClaudeandClaude Opus 5.5 37bec17d73 Analyse: Vorzeitraum, firstDay, Tageswerte und Durchschnitte in der Statistik; activatedAt in der Ansicht
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:34:43 +00:00
ClaudeandClaude Opus 5.5 c1dada3189 Plan: Analyse-Übersicht neu (Cockpit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:34:43 +00:00
ClaudeandClaude Opus 5.5 2f9dfd8ad5 Spec: Analyse-Übersicht neu (Cockpit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:34:43 +00:00
ClaudeandClaude Sonnet 5.5 20583424a3 Funktionen: Bestellseite und Statuskarten
Terminplaner TP2 C8: public/funktion-bestellen.js mit sicherLink, bestellKoerper,
fortsetzenKoerper, renderBestellen (Zusammenfassung, offene Punkte, Preis,
vollstaendige Widerrufsbelehrung als scrollbarer Text, Unterlagen, Zustimmungs-Haken,
Knopf mit bestellung.consent.knopf) und statusKarte (ordered/setup, Danke).
request() in termine-dom.js behaelt den ganzen Body der Fehlerantwort (err.body),
damit 409 bestellung_offen und fassung_veraltet offen/consent zeigen koennen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 21:27:36 +00:00
ClaudeandClaude Sonnet 5.5 747be432c5 Terminplaner TP2: Reiter-Exporte und wochenplanFeld fuer den Fragebogen
- termine-tab-einstellungen.js: die acht Abschnitte (betrieb, darstellung, regeln,
  gastFelder, erinnerung, storno, rechtliches, zahlung) sind Exporte; betrieb nimmt
  { nur, titel, hinweis } (nur die gewaehlten Felder, lese schreibt nur diese)
- termine-tab-team.js: wochenplanFeld(r, pfad) => { el, lese() => { plan } | { fehler } },
  der Editor einer Person benutzt es selbst
- termine-speichern.js: fehlt kommt aus der Antwort des Servers
- termine.js: baueCtx aus zeigeReiter herausgeloest (Verhalten gleich)
- test/support/fakeDom.js: Minimal-DOM und Fake-fetch fuer die Client-Tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 21:06:42 +00:00
ClaudeandClaude Sonnet 5.5 0090332c70 Terminplaner TP2 C7a: Nachbesserung 1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 20:53:48 +00:00
ClaudeandClaude Sonnet 5.5 edaefd4237 Terminplaner TP2: Fragebogen-Helfer im Client
Reine Helfer in public/termine-view.js: FRAGEBOGEN_SCHRITTE (Spiegel von
SCHRITTE und SCHRITT_TITEL), schrittStatus, ersterOffenerSchritt,
ressourcenAusNamen, buchungsartFuerAlle, buchungsartLage und
zusammenfassung (acht Gruppen, nur Text, Bundesland nur bei ausdruecklicher
Wahl, nie feedToken). Tests in test/termineFragebogenView.test.js, Spiegel-Test
in test/termineView.test.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 20:48:10 +00:00
ClaudeandClaude Sonnet 5.5 91b1a3bcd8 Terminplaner TP2: Entwuerfe nach 90 Tagen und bei Kundenloeschung entfernen
Neu: server/services/termine/entwuerfe.js. Ein Terminplaner im Zustand off ist ein
Entwurf (Fragebogen, Einstellung). loescheEntwuerfe(now) im Tagesjob (nach den
abgeschalteten Funktionen, nur im vollen Lauf): letzte Aenderung = juengerer Zeitpunkt
aus $updatedAt des Funktionsdokuments und termineConfig.updatedAt, aelter als 90 Tage
=> Einstellung weg, das Funktionsdokument nur, wenn es nie in Betrieb oder bezahlt war
(sonst bleibt es wegen des siteKeys; ein von E8 zurueckgesetztes Dokument nie).
Vor jedem Loeschen unter feature-order, feature-admin und termine-cfg frisch lesen
(noch off, Frist, keine Buchung). Seiten zu 100 per cursorAfter, hoechstens 2000
geprueft und 100 geloescht je Lauf, der Rest folgt an Folgetagen; Einzelfehler zaehlen
in fehler. Tagesbericht: Segment "Entwuerfe n geloescht (m offen)".
loescheEntwuerfeVonKunde: Kundenloeschung entfernt alle off-Entwuerfe vollstaendig,
Fehler werden nur protokolliert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 20:33:33 +00:00
ClaudeandClaude Sonnet 5.5 29a38a9592 Terminplaner TP2: Branche und Vorlage im Entwurf, PUT /fragebogen
POST /api/termine/einstellungen/vorlage nimmt jetzt eine Branche (BRANCHEN), optionale
Betriebsfelder (nichtleere Werte ueberschreiben das Konto) und schreibt den Fragebogen mit
der Einstellung in einem Vorgang. Der Gesundheitsbereich ist ausgeschlossen (400
gesundheitsbereich, nichts wird angelegt). Eine bestehende Einstellung ersetzt nur
ersetzen:true mit passender version im Zustand off (neue IDs und Kalender-Token, Fragebogen
neu, Einbauwuensche bleiben). Die Route arbeitet im Entwurf (off, active, ordered/setup nur
in der Kundenansicht, disabled nie).

PUT /api/termine/fragebogen speichert nur den Fragebogen (Version der Einstellung bleibt),
ist in der Kundenansicht gesperrt (Waechter und Router), zaehlt gegen das Schreibbudget und
antwortet mit { fragebogen, offen }.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 20:12:00 +00:00
ClaudeandClaude Sonnet 5.5 1cd74226cb Terminplaner TP2: Entwurf schreiben, PATCH mit fehlt, nie oeffentlich
PATCH /api/termine/einstellungen schreibt den Entwurf nach eigenen Regeln
(withTermine({ schreiben, entwurf }) in routes/termine.js): off und active
ja, ordered und setup nur in der Kundenansicht (sonst 409 gesperrt), disabled
409 abgeschaltet wie bisher. In off zusaetzlich laeuft(customerId): ohne
Wartungs-Abo 409 kein_abo (Text aus aboMeldung), bei Lesefehler 503
abo_pruefung; eine Kuendigung oder offene Zahlung sperrt nur die Bestellung,
nicht das Speichern des Entwurfs. Das Funktionsdokument legt erst der Handler
an (ensureFeatureDoc), nach der Eingabepruefung. PATCH normalisiert mit
entwurf: state !== 'active' und antwortet mit fehlt. Tests: Zustandsmatrix,
Mandanten, Pflichtangaben, Abo, und die Gegenprobe an der oeffentlichen
Schnittstelle (Entwurf und Einbauwuensche nie oeffentlich).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 19:48:57 +00:00
ClaudeandClaude Opus 5.5 75794f14d5 Preview: Deploy auf fremde Subdomain sperren
Ein kopiertes Repo (git clone + push, Vorlage) bringt die
.webklar-preview.json des Originals mit. Sein erster Push ueberschrieb
die Preview des Originals, danach scheiterte der Webhook mit 500
(Unique-Index auf subdomain). Fast passiert bei Olzinger -> HSK-Vorlage,
04.10.2026.

- foreignSubdomainOwner: Subdomain gehoert einem anderen Repo oder einem
  Projekt ohne Repo; ein in Gitea umgezogenes Repo (Weiterleitung) zaehlt
  nicht als fremd
- Webhook: fremde Subdomain -> kein Deploy, Projekt ohne Preview (neu:
  synced, Repo-Name statt fremdem Anzeigenamen); eine schon vergebene
  eigene Preview bleibt, WOMS zeigt den Grund als Preview-Fehler
- runDeploy prueft fuer alle anderen Wege (WOMS, Reaktivieren), vor dem
  Runner, damit ein gesperrter Aufruf keinen laufenden Bau verwaist;
  Appwrite-Fehler bei der Pruefung blockieren dort nicht (Webhook streng)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:46:27 +00:00
ClaudeandClaude Sonnet 5.5 42a3befc5f Terminplaner TP2 C4a: Nachbesserung 1
Die Wahl des Live-Hosts steht jetzt nur noch in liveHost() (kontext.js);
websiteUrl() und der Betriebsvorschlag des Fragebogens (routes/termine.js)
nutzen dieselbe Funktion.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 19:34:37 +00:00
ClaudeandClaude Sonnet 5.5 250fc0c4df Terminplaner TP2: Entwurfszugriff lesen, GET ohne Dokument, GET-Felder
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 19:29:12 +00:00
ClaudeandClaude Sonnet 5.5 31aa80affd Terminplaner TP2: Fragebogen im Kontext und Speicher
kontext.js: ctx.fragebogen (normalisiert; fehlt er oder ist er kaputt, der leere,
kaputt mit einer Logzeile und ohne Wurf), ladeEinstellung liefert zusaetzlich
fragebogen, neuer kontextFuerPortal(feature) fuer das Portal in jedem Zustand.
speichereFragebogen schreibt unter termine-cfg:<id> nur fragebogen, updatedAt und
updatedBy (ohne version; fehlt das Dokument: 409 keine_einstellung). Neue Option
fragebogen bei speichereEinstellung schreibt Einstellung und Fragebogen in
derselben Sperre. kontextPerSiteKey bleibt unveraendert (Entwurf nie oeffentlich),
Tests dazu fuer off, ordered, setup, disabled und nach activate. Der Test
ladeEntwurf in funktionenCheckout.test.js schaltet die neue Logzeile stumm.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 19:14:22 +00:00
ClaudeandClaude Sonnet 5.5 008ad4fb1e Terminplaner TP2: Bestell-Gate und Gesundheits-Stichwortpruefung
fragebogen.js ersetzt den Stub aus Block B: bestellpruefung (Kriterien (a) bis (h),
je Punkt ein Eintrag, nach Schrittfolge sortiert, Abo-Punkt nur bei mitAbo !== false),
SCHRITT_TITEL, gesundheitsWarnungen (Stichworte nur am Wortanfang, hoechstens 10
Meldungen) und bestellungAnsicht fuer die Bestellseite. einstellungen.js exportiert
sichtbareLeistungen und MELDUNG_PFLICHT. Testhelfer bestellbarerStand und entwurfDoc;
der Stub-Test entfaellt, die zwei B-Tests mit dem Stub laufen gegen das echte Gate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 19:00:12 +00:00
ClaudeandClaude Sonnet 5.5 ab47fabcb2 Terminplaner TP2: Fragebogen-Attribut, Branchen, Schritte, Normalisierung
termineConfig bekommt das Attribut fragebogen (Text 20000, Inhalt bis
FRAGEBOGEN_MAX 16000). fragebogen.js: SCHRITTE, BRANCHEN (Vorlagen plus
Gesundheitsbereich), leerFragebogen und normalisiereFragebogen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 18:49:06 +00:00
ClaudeandClaude Sonnet 5.5 36b36bcc4d Terminplaner-Banner und Meldung nach Abschaltgrund mit Löschdatum
GET /api/termine/einstellungen liefert bei abgeschalteter Funktion
abgeschaltet { grund, seit, loeschungAm }. Die 409-Meldung bei
Schreibversuchen (Code abgeschaltet) und der Banner im Portal
unterscheiden Wartungs-Abo beendet, Abo vor der Einrichtung beendet und
die übrigen Gründe und nennen das Löschdatum, wenn es eines gibt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 18:41:03 +00:00
ClaudeandClaude Sonnet 5.5 d66dd60a76 Zahlung nach Abo-Ende: Zusatztests Webhook und Abgleich (T19 nie doppelt)
Nur Tests in test/termineAboAbgleich.test.js, kein Produktionscode: handleFeatureCheckoutEvent
und gleicheAboAb gegeneinander (Review Focus 1, nie Geld ohne Spur, nie doppelt).
- Zahlung nach dem Abschalten: genau ein T19 (zahlung_nach_ende, Sitzung), kein Ticket,
  kein K15/T16; Wiederholung und async_payment_succeeded stumm
- T19-Versand scheitert: Webhook wirft, Zahlung gebucht, Fall offen; erste Wiederholung
  mit funktionierendem Versand sendet genau ein T19
- Folgetag-Abgleich: K22 vor_einrichtung, Marker aboEndeMailAt, kein zweites T19
- Abgleich vor der Webhook-Wiederholung und parallel (Promise.all): insgesamt ein T19
- Grund team: T16 abgeschaltet, kein T19; Abo laeuft (ordered/setup): Ticket, K15, T16
- Rennen Zahlung vor Abgleich: Ticket, T16 und ein T19; Stripe-Retry stumm

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 18:29:10 +00:00
ClaudeandClaude Sonnet 5.5 c5009da570 Abo-Ende: Sofortpfad beim Widerruf des Wechsels, Team-Aktivierung nur mit laufendem Abo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 18:19:48 +00:00
ClaudeandClaude Sonnet 5.5 6bd6aed7d5 Terminplaner TP2 E8: Nachbesserung 1 (offener Zahlungsfall bleibt sichtbar, R108)
setzeFunktionZurueck setzt ein Funktionsdokument mit offenem Zahlungsfall
(zahlungMeldung nicht leer und nicht 'gemeldet') nicht auf off zurueck: Warnung
ohne Kennungen, +1 in lauf.fehler, kein Wurf. Das Dokument bleibt disabled mit
seinen Markern, der Abo-Abgleich holt T19 weiter nach, nach 'gemeldet' setzt der
naechste Lauf zurueck. Buchungen und Einstellung werden wie bisher geloescht.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 18:08:54 +00:00
ClaudeandClaude Sonnet 5.5 30701ba277 Löschung nach 30 Tagen: Abo-Vorprüfung, Funktionsdokument danach auf off
Bei Grund abo_ende prüft die Löschung vor jedem unumkehrbaren Schritt frisch,
ob das Wartungs-Abo wieder läuft (Lesefehler: nichts löschen). Sind alle
Buchungen und die Einstellung weg, wird das Funktionsdokument unter
feature-admin und termine-cfg auf off zurückgesetzt (siteKey bleibt).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 17:57:41 +00:00
ClaudeandClaude Sonnet 5.5 91e7f7d8a3 Tagesjob: Abo-Abgleich vor der Löschung, auch bei TERMINE_ENABLED=off mit bezahlten Dokumenten
taeglicherJob ruft gleicheAboAb(now) nach den Löschfristen und vor der Löschung
abgeschalteter Funktionen auf (Schritt „Abo-Abgleich“, ein Fehler dort stoppt weder
Löschung noch Rest; die Fehler des Abgleichs zählen in fehler). Das Ergebnis trägt
aboAbgleich, tagesbericht hängt „Abo-Abgleich N geprüft / N abgeschaltet / N wieder ein /
N Mails / N ausgenommen“ vor „N Fehler“ an.

fuehreAus nimmt trotzAus: Im Modus off läuft der Tagesjob nur, wenn es ein vom Kauf
freigeschaltetes Termine-Dokument gibt (eine Abfrage, limit 1; bei einem Lesefehler
Warnung und { aus: true }). Dann läuft ein eingeschränkter Lauf (eingeschraenkt: true,
gleiche Überlappungssperre): nur Abo-Abgleich und Löschung abgeschalteter Funktionen,
mit eigener Berichtszeile und den Feldern, die meldeTagesjob und die Statusroute lesen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 17:45:48 +00:00
ClaudeandClaude Sonnet 5.5 1c74c89feb Terminplaner: Erstattungsmeldung unabhaengig vom Abo nachholen (R107)
Der taegliche Abo-Abgleich holt T19 fuer jedes bezahlte, abgeschaltete Dokument mit
Grund abo_ende_vor_einrichtung und zahlungMeldung ungleich gemeldet nach, auch wenn das
Wartungs-Abo wieder laeuft oder nicht lesbar ist (der Nachlauf liest das Abo nie).
Reihenfolge wie bisher: T19, dann K22 (nur wenn aboEndeMailAt leer), dann Marker.
meldeAboEnde sendet bei gesetztem K22-Marker und offener Erstattung nur T19.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 17:31:54 +00:00
ClaudeandClaude Opus 5.5 4fc4c70b7d Archiv/Rechte: Review-Punkte
- archiviertes Projekt bleibt unter Funktionen sichtbar, solange dort eine
  Funktion bestellt/eingerichtet/aktiv ist (z. B. Terminbuchungen)
- auf archivierten Projekten nichts neu bestellen oder einschalten
- Projekttyp Vorlage nur fuer Admins; repo-readme nur fuer bekannte Projekte

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:11:54 +00:00
ClaudeandClaude Opus 5.5 3156bb790a Projekte: alle Mitarbeiter, private Repos, Archiv fuer Kunden ausgeblendet
Entscheidungen vom 04.10.2026:
- Projekt-Routen (website-projects, preview, gitea, services, analyse)
  fuer jeden Mitarbeiter (Label mitarbeiter oder admin); Kunden-,
  Mitarbeiter- und Finanz-Verwaltung bleiben nur fuer Admins
- Neue Repos aus der Vorlage werden privat angelegt
- Archivierte Projekte sieht der Kunde nicht mehr: Projektliste,
  Projektdetails, Preview-Zugang, Funktionen (Mitarbeiter-Ansicht schon)
- Projektliste des Kunden mit Limit (Appwrite liefert sonst nur 25)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:02:02 +00:00
ClaudeandClaude Sonnet 5.5 13523ca5e2 Abo-Abgleich: Rückkehr innerhalb der Frist schaltet wieder ein (K23)
Schritt 3 von gleicheAboAb: Ein abgeschaltetes Dokument mit Grund abo_ende,
dessen Wartungs-Abo wieder läuft, wird unter feature-admin:<projectId> nach
frischem Lesen von Dokument und Abo und mit vorhandener Einstellung über
activate wieder eingeschaltet (Daten und Einstellungen bleiben). K23 geht
nur, wenn K22 ging (aboEndeMailAt vor dem activate gelesen); scheitert K23,
bleibt die Reaktivierung bestehen. Nicht automatisch: off nach der Löschung,
abo_ende_vor_einrichtung, team, kunde_geloescht, Altbestand ohne Grund.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:44:47 +00:00
ClaudeandClaude Sonnet 5.5 addb88d5fd Abo-Abgleich: Abo endet vor der Einrichtung ⇒ abgeschaltet, T19 und K22
Terminplaner TP2 E5. Schritt 1b in gleicheAboAb: Dokumente im Zustand ordered
oder setup werden bei fehlendem Wartungs-Abo unter feature-admin:<projectId> nach
frischem Lesen von Dokument und Abo mit Grund abo_ende_vor_einrichtung abgeschaltet.
Die Meldelogik liegt in meldeAboEnde (auch für den Sofortpfad E9): bei bezahlter
Einrichtung zuerst T19 über meldeErstattung (außerhalb jeder feature-admin-Sperre),
dann K22 mit Block vor_einrichtung, dann der Marker aboEndeMailAt; scheitert T19,
folgen weder K22 noch Marker und der nächste Tag wiederholt beides. Ein unbezahltes
ordered-Dokument wird nur abgeschaltet. Schritt 2 holt auch abo_ende_vor_einrichtung
nach. Die Stripe-Sitzung und das Ticket bleiben unangetastet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:32:14 +00:00
ClaudeandClaude Sonnet 5.5 17f933dbc7 Abo-Abgleich: aktive Terminplaner ohne Wartungs-Abo abschalten, K22 nachholen
gleicheAboAb(now) liest alle nicht abgeschalteten Terminplaner-Funktionen einmal,
bewertet je Kunde die Abo-Lage (aboMemo) und schaltet aktive, bezahlte Funktionen
ohne laufendes Wartungs-Abo ab (disabledReason abo_ende). Geschrieben wird nur
unter feature-admin:<projectId> nach frischem Lesen von Dokument und Abo; bei
Lesefehlern bleibt alles aktiv. Pilot- und Kulanz-Dokumente zaehlen als
ausgenommen. meldeAboEnde sendet K22 und setzt aboEndeMailAt erst nach Erfolg.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:20:57 +00:00
ClaudeandClaude Sonnet 5.5 973135d2de Mails K22, K23 und Hinweis auf das Ende der Funktionen in K02/K03
Terminplaner TP2 E3. K22 "Terminplaner beendet" (Blöcke abo_ende, hosting,
vor_einrichtung, daten) und K23 "Terminplaner läuft wieder" im Layout von K19,
Du-Form, Texte als Entwurf für die Mail-Überarbeitung. K02 und K03 bekommen den
Block funktionen (in K03 innerhalb von abo); routes/abo.js setzt ihn nur beim
Wartungs-Abo und nie bei der Speicher-Kündigung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:09:05 +00:00
ClaudeandClaude Sonnet 5.5 eae6eee03c Terminplaner TP2 E2: Nachbesserung 1 (Löschdatum nur für den Terminplaner)
Ruling R105: featureView setzt loeschungAm nur für featureKey 'termine' (der
30-Tage-Löschlauf löscht nur Terminplaner-Daten); disabledReason und disabledAt
bleiben für alle Funktionen. Test: News ohne Löschdatum, Zeitfenster-Prüfung
entfernt, Teamroute-Ansicht pinnt loeschungAm für den Terminplaner.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:02:57 +00:00
ClaudeandClaude Sonnet 5.5 35c9800856 Abschaltgrund und Loeschfrist in der Funktionsansicht (featureView, frist.js)
featureView liefert zusaetzlich disabledReason, disabledAt und loeschungAm (Berliner
Tag, 30 Tage nach disabledAt); alle drei nur im Zustand disabled, sonst null.
Neu termine/frist.js mit LOESCHFRIST_TAGE, loeschzeit und loeschTag (nur zeit.js/util.js).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:58:06 +00:00
ClaudeandClaude Sonnet 5.5 144b053851 Abo-Baustein aboStatus.js: Wechsel-Regel in laeuft/bestellbar, aboLage; Analyse nutzt ihn
aboStatus.js (Kernversion aus B5 ausgebaut): ein privater Lader lage(customerId, { altbestand })
ueber getCurrentSubscription und listContractEvents, alle Praedikate bauen darauf. laeuft kennt
altbestand (Vorgabe true: productKey || 'wartung' plus Wechsel-Regel; false: die heutige Regel der
Analyse, keine Ereignisabfrage). Wechsel-Regel: sagt der Spiegel "Wartung laeuft" und ist das
juengste wechsel-Ereignis des Abos widerrufen, laeuft das Wartungs-Abo nicht (kein_abo beim
Bestell-Gate, Freischaltung bleibt in setup). Neu aboLage ({ laeuft, art, endedAt }) und
aboMemo().lage; der Memo merkt eine Ablehnung fuer den Lauf. analyse.js nutzt
laeuft(id, { altbestand: false }); Verhalten der Analyse unveraendert (+1 Pin-Test).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:50:51 +00:00
ClaudeandClaude Sonnet 5.5 cf9f9b30a0 Funktionen-Seite: Preis und Bestelltexte aus dem Register
GET /api/funktionen liefert features.news und features.termine (oeffentlicheAnsicht,
auch wenn der Terminplaner noch nicht freigegeben ist) und ustHinweis; setupEstimate und
consentText bleiben als News-Alias. Die News-Kachel und die Buchungsseite lesen Preis,
Knopftext und Zustimmungstext aus features.news und fallen ohne features auf die bisherigen
Werte zurueck. News sendet weiter consent: true an /api/funktionen/news/checkout.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:42:20 +00:00
ClaudeandClaude Opus 5.5 800630ea4b Projekt anlegen/archivieren: Review-Punkte
- Anlegen uebernimmt keinen alten Eintrag gleichen Namens mehr (Repo in
  Gitea geloescht): Platzhalter werden entfernt, ein verknuepftes altes
  Projekt blockiert den Namen mit klarer Meldung, bevor ein Repo entsteht;
  neue Projekte starten mit archived=false
- archiveRepo aendert Gitea und Dokument unter der Repo-Sperre; der Import
  liest den Archiv-Stand vor dem Spiegeln frisch aus Gitea
- Reaktivieren: Preview aus -> synced, Deploy-Fehler -> deploy_failed
  statt dauerhaft 'pending'

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:36:26 +00:00
ClaudeandClaude Sonnet 5.5 f57dad73ca Freischaltung ueber das Register mit Pruefung von Einstellung und Abo
activateClosedTickets oeffnet jetzt alle Funktionen mit ticketFreischaltung im Register
(ticketFreischaltungKeys) statt fest news und fragt unter der Sperre feature-activate:<id>
nach dem frischen Lesen den Haken freischaltbar(doc). Der Standard fuer termine verlangt eine
gespeicherte Einstellung und ein laufendes Wartungs-Abo. false oder Fehler (z. B. Abo nicht
lesbar) lassen das Dokument in setup: Log und genau ein adminNote-Eintrag "Freischaltung
wartet: <Grund>", keine Mail. News bleibt unveraendert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:35:57 +00:00
ClaudeandClaude Opus 5.5 017a9c22f2 Webhook: System-Commits legen kein Auto-Ticket an
Beim Anlegen mit Kunde (ohne Ticket) loeste der Commit der
.webklar-preview.json durch webklar-system ein zusaetzliches Ticket
"Website <name>" aus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:32:51 +00:00
ClaudeandClaude Sonnet 5.5 f4754f573a Zahlungseingang in jedem Zustand: nie Geld ohne Spur, nie doppelt (T16/T19)
onFeaturePaid wertet den Fall aus markPaid aus. Trifft die Zahlung eine Funktion, die
schon freigeschaltet, in Einrichtung oder abgeschaltet ist, geht nur eine Team-Meldung
raus (T16 Block bereits_aktiv, bei Abo-Ende vor der Einrichtung T19), kein Ticket und
keine K15. Beide Meldungen laufen genau einmal je Dokument: unter feature-meldung:<id>
frisch lesen, Marker zahlungMeldung gemeldet erst nach gesendeter Mail. Scheitert der
Versand, wirft onFeaturePaid (Webhook 500), Stripe wiederholt und der naechste Versuch
sendet genau einmal.

meldeErstattung ist die einzige Stelle fuer T19 (Zahlung nach dem Abo-Ende und spaeter
die Meldung des Abo-Abgleichs), wirft nie. Bei Funktionen mit Wartungs-Abo-Pflicht
zeigt T16 nach der Verbuchung den Block kein_abo, wenn das Abo nicht mehr laeuft; ein
Lesefehler laesst den Block aus und blockiert nichts. customerOf und projectName sind
exportiert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:25:26 +00:00
ClaudeandClaude Opus 5.5 d878df96e0 Zuordnungs-Lauf: vom System-Konto angelegte Projekte nicht alle 10 Minuten abfragen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:24:06 +00:00
ClaudeandClaude Opus 5.5 e43cdb667b Projekt-Ablauf im Ticketsystem: Anlegen, Archivieren, Import repariert
- Neues Projekt ohne Subdomain: subdomain null statt '' (Unique-Index
  subdomain_unique, jedes weitere scheiterte mit "Document with the
  requested ID already exists"); ebenso Typ "Internes Projekt"
- Anlegen unter derselben Repo-Sperre wie der Webhook repository/created;
  upsertWebsiteProjectByRepo macht aus einem 409 ein Update
- Gitea-Beschreibung = Anzeigename; Art (kind) beim Anlegen aus dem Ticket
- Reaktivieren setzt status zurueck (vorher blieb 'archived' stehen)
- Import: archivierte Repos als archiviert, Archiv-Stand aus Gitea wird
  gespiegelt, ein fehlerhaftes Repo bricht den Lauf nicht mehr ab
- Abgleich ueberschreibt Projektnamen nicht mehr
- CORS erlaubt PUT (Preview-Variablen speichern)
- Vorlage zieht beim Zuweisen nie in die Org um
- Erinnerungs-Lauf ueberspringt archivierte Projekte
- kaputte Umlaute (U+FFFD) in drei Fehlermeldungen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:22:53 +00:00
ClaudeandClaude Sonnet 5.5 468df49812 Zahlungseingang: markPaid meldet den Fall und merkt ihn im Dokument
markPaid liefert jetzt { doc, created, fall }. Trifft eine Zahlung ein Dokument, das nicht
mehr auf die Bezahlung wartet (active, disabled, setup mit Ticket), wird sie einmalig
verbucht (purchaseStatus paid, unlockedByPurchase, Rechnung, Notiz) und der noch zu
meldende Fall im eigenen Feld zahlungMeldung vermerkt (bereits_aktiv | abgeschaltet |
abo_ende), in einem einzigen Schreibzugriff. Ein Folgeereignis bucht nichts doppelt und
liefert den Fall erneut, bis zahlungMeldung auf gemeldet steht. ordered und fortsetzbares
setup laufen wie bisher, off bleibt unveraendert (Uebernahme ueber adoptPaidSession).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:13:42 +00:00
ClaudeandClaude Sonnet 5.5 992d7358fa Mail T16 je Funktion, neue Vorlage T19 Erstattung faellig
T16 (Funktion bestellt) nennt die Funktion aus dem Register statt fest
"News-Pinnwand": neue Platzhalter funktion, zustand, anleitung_url und
gesundheit_text, neue Bloecke kein_abo, bereits_aktiv, anleitung (Knopf
"Im Ticketsystem oeffnen" zur Einbauanleitung) und gesundheit. teamNotice
und unmatchedNotice fuellen funktion aus featureConfig (ohne bekannten
Schluessel "Funktion"); die neuen Bloecke kein_abo, bereits_aktiv und
gesundheit bleiben aus, bis B10a und D4 sie fuellen. Die News-Mails
bleiben woertlich (HTML, Text und Betreff byte-gleich).

T19 (Erstattung faellig, Team-Hinweis) ist neu: volle Erstattung von
Hand in Stripe, Bloecke zahlung_nach_ende, ticket_offen und rechnung.
Gesendet wird sie spaeter ueber meldeErstattung (B10b).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:06:39 +00:00
ClaudeandClaude Sonnet 5.5 e200efb7e5 Mails K15/K16 je Funktion (News-Wortlaut eingefroren)
K15 (Bestellung eingegangen) und K16 (Funktion bereit) tragen den Wortlaut der
Funktion als Platzhalter (funktion, funktion_gen/akk/nom/pron/dein, preis_hinweis,
danach, bereit, knopf, portal_url) statt fest News-Pinnwand. siteFeatures baut die
Werte mit bestellMail/bereitMail aus dem Register-Eintrag (mails.*, setupEstimate,
returnHash) und dem Katalogpreis je feature.product. K15 geht ohne Anhaenge raus.
Die News-Mails bleiben Zeichen fuer Zeichen gleich (Test featureMails friert
Betreff und Text ein).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 12:56:45 +00:00
ClaudeandClaude Sonnet 5.5 9dd0244537 Funktions-Ticket: Text aus dem Register, Laengendeckel, Notfall-Details und gezielte Wiederholung
markPaid holt Betreff und Details des Einrichtungs-Tickets aus dem Register (ticketTexte), createFeatureTicket
bleibt die einzige Stelle, die das Ticket anlegt. Betreff auf 128, Details auf 7000 Zeichen gekuerzt (surrogatsicher,
Schnitt an der Zeilengrenze, einheitliche Schlusszeile). Wirft die Details-Funktion oder liefert sie keinen Text,
gelten die Notfall-Details (Kopfzeilen und Verweis ins WOMS). Nach einem 400 bei langen Details gibt es genau einen
zweiten Versuch mit den Notfall-Details und derselben woid; 5xx, Netzfehler und kurze Details werden ohne
Wiederholung weitergereicht. Stub termine/ticketDetails.js bis Task D4.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 12:48:02 +00:00
ClaudeandClaude Sonnet 5.5 7fcb268644 Checkout je Funktion: Zustimmungsnachweis vor der Weiterleitung, Zustaende aus dem Register
Rest der Route POST /api/funktionen/:key/checkout unter der Sperre feature-order:<Kunde>:<Projekt>:
Zustand aus dem Register (setup/active, disabled, ordered mit offener Sitzung, "Zahlung fortsetzen"),
Stripe-Sitzung mit der Zeit des Klicks, Zustimmungsnachweis (Serverfassungen und -texte) vor markOrdered
und vor der URL. Standard-Haken belehrung mit BELEHRUNG_PLATZHALTER fuer den Terminplaner.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 12:38:50 +00:00
ClaudeandClaude Sonnet 5.5 1f6ae0f7e0 Terminplaner TP2 B6: Nachbesserung 1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 12:30:44 +00:00
ClaudeandClaude Sonnet 5.5 ded6bb2a77 Checkout je Funktion: Vorpruefungen in der Reihenfolge der Spezifikation
POST /api/funktionen/:key/checkout ersetzt /news/checkout (der News-Pfad bleibt als
key = news erhalten). Stufen ohne Seiteneffekt: 1 Kundenansicht (403), 2 Schluessel
gegen das Register (404), 3 Zustimmung (400 zustimmung_fehlt, 409 fassung_veraltet mit
der Serverfassung; News weiter mit consent: true), 4 Projekt (400/403; bei fortsetzen
muss das Dokument ordered sein), 5 Schalter (409 nicht_verfuegbar), 5b Unterlagen
(503 unterlagen_fehlen), 6 Bestellpruefung (409 bestellung_offen mit offen), 7 Wartungs-Abo
(409 kein_abo | gekuendigt | nicht_aktiv, Lesefehler 500). fail() gibt err.body weiter.

featureOrder.js: pruefeZustimmung, istFortsetzen, aboMeldung (einzige Quelle der
Abo-Hinweistexte), ladeEntwurf, pruefeAbo, orderDeps. featureRegistry.js: featureSperre
und die Standard-Haken verfuegbar, bestellpruefung (mitAbo: false, ohne abo) und
unterlagenBereit des Terminplaners. Der Rest der Route (Sperre, Zustand, Sitzung,
Nachweis) bleibt bis Task B7 nur fuer News; andere Funktionen enden mit 503.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 12:25:09 +00:00
ClaudeandClaude Sonnet 5.5 d568a3798d Abo-Baustein (Kernversion) und Platzhalter fuer die Bestellpruefung
aboStatus.js: laeuft(customerId) (Wartungs-Abo, productKey || 'wartung', Status in
ACTIVE_STATUSES), bestellbar(customerId) mit den Gruenden kein_abo, gekuendigt,
nicht_aktiv in dieser Pruefreihenfolge und aboMemo() (je Aufruf und Kunde nur eine
Lesung). Fail-closed: ein Lesefehler wirft, nie false oder ok:false.

termine/fragebogen.js: Platzhalter bestellpruefung() mit der endgueltigen Signatur,
liefert []; Block C ersetzt ihn.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 12:12:09 +00:00
ClaudeandClaude Sonnet 5.5 786df1230e Zustimmungsnachweis je Funktion (featureConsents) und Fristanker aus dem Klick
server/services/featureConsents.js speichert und liest den Nachweis zur Bestellung
einer Funktion (nur anfuegen, sieben Exporte): speichereZustimmung (Texte und Fassungen
als JSON, SHA-256 ueber die Texte, vollstaendig oder gar nicht), letzteZustimmung,
zustimmungFuerSitzung (leere Sitzung fragt nicht ab), leseZustimmung, texteHash,
zustimmungsfassungen und nachweisTexte (Haken, Belehrung, Knopf aus dem Register).
paidFeatureOrders liefert featureKey und vertragAt (Klick der bezahlten Sitzung, sonst
consentAt, sonst orderedAt; Lesefehler faellt mit Log auf orderedAt zurueck); der Widerruf
einer Funktion rechnet die Frist aus vertragAt. Eine spaetere, unbezahlte Zustimmung
verschiebt den Anker nicht.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 12:05:12 +00:00
ClaudeandClaude Sonnet 5.5 d8e4d79891 Schema: Zustimmungsnachweis featureConsents im Skript feature-setup
scripts/feature-setup.mjs legt neben den portalFeatures-Attributen die Collection
featureConsents (nur Server-Key, ohne Rechte) mit 11 Attributen und den Indizes
idx_session und idx_project_feature an; --check meldet je fehlende Collection,
Attribut und Index eine Zeile "- <id>[.<key>] FEHLT", schreibt nichts und liefert
Exit-Code 1 bei Luecken. config.collections.featureConsents (Env
APPWRITE_COLLECTION_FEATURE_CONSENTS) und schemaKeys('featureConsents') in
test/support/schema.js ziehen mit. setup-abo-collections.mjs bleibt unveraendert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:57:16 +00:00
ClaudeandClaude Sonnet 5.5 98fa03aad7 Stripe: Katalog Terminplaner-Einrichtung (149 €) und Checkout je Funktion aus dem Register
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:53:06 +00:00
ClaudeandClaude Sonnet 5.5 c584d220c8 Funktionen: Register config.features mit News- und Termine-Eintrag
config.features (news, termine) als reine Daten in config.js, config.ustHinweis,
config.funktionen als Alias mit Gettern auf features.news. Neu featureRegistry.js
(featureConfig, featureKeys, featureHaken mit Ueberschreib-Regel, oeffentlicheAnsicht);
FEATURE_KEYS, FEATURE_LABELS und DISABLED_MESSAGE in siteFeatures.js kommen aus dem
Register, Werte unveraendert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:48:06 +00:00
ClaudeandClaude Sonnet 5.5 e2f50407a0 Terminplaner: Minutenjob in Verfall und Mailversand getrennt geschuetzt
fuehreAus nimmt { teil } und sperrt je job:teil; minutenJob startet Verfall
und Mailversand (Warteschlange plus Betriebspruefung) gemeinsam mit
Promise.all. Haengt ein Teil, laeuft der andere im naechsten Takt weiter
(verfallUebersprungen bzw. mailUebersprungen); uebersprungen gilt nur, wenn
beide Teile uebersprungen werden. Die Ueberlappungswarnung nennt den Teil,
das Fehlerlog behaelt minutenJob.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:41:00 +00:00
ClaudeandClaude Sonnet 5.5 aaed273b62 Funktionen: disable() idempotent, disabledReason, Schema-Skript feature-setup
disable() laeuft unter einer Sperre je Dokument und aendert ein schon
abgeschaltetes Dokument nicht mehr (disabledAt als Beginn der 30-Tage-Frist,
Grund und Notiz bleiben); nur Altbestand ohne disabledAt bekommt dieses Feld.
Neu: DISABLED_REASONS und der Parameter reason (Vorgabe team, unbekannt = 400),
disableCustomerFeatures reicht ihn durch, die Kundenloeschung nutzt
kunde_geloescht. activate() leert zusaetzlich disabledReason und aboEndeMailAt
und laesst zahlungMeldung unberuehrt.

scripts/feature-setup.mjs legt portalFeatures.disabledReason, aboEndeMailAt und
zahlungMeldung idempotent an (--check meldet Luecken mit Exit 1);
schemaKeys('portalFeatures') zieht die Schluessel mit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:33:17 +00:00
ClaudeandClaude Sonnet 5.5 39225594e5 Terminplaner: Status-Route fuer das Team
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:24:02 +00:00
ClaudeandClaude Sonnet 5.5 4601c5ef3b Terminplaner: Tagesjob-Meldung, Betriebe-Zaehlung und Bericht
meldeTagesjob meldet am Ende des Tagesjobs ueber den Melder aus A10a2 die Vorfaelle
tagesjob (Fehler oder Abbruch), deckel (Loeschdeckel an 3 Tagesjobs in Folge) und
betriebe (ab 20 aktiven Betrieben, D8) und merkt letzterTagesjob (nur Zaehlungen).
taeglicherJob liefert geloescht und aktiveBetriebe, tagesbericht haengt
"n aktive Betriebe" an.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:17:07 +00:00
ClaudeandClaude Sonnet 5.5 689a21f9ac Terminplaner: Betriebspruefung mit Abkuehlzeit fuer die Team-Mail
pruefeBetrieb (neu: termine/betrieb.js) prueft am Ende jedes Minutenjobs
Mailbudget (ab 70 %), Warteschlange (aeltester faelliger Eintrag > 10 Min
ueberfaellig, neu outbox.warteschlangeAlter) und Anmeldepause
(pausiertBis). Der gemeinsame Melder meldeVorfall schreibt je Schluessel
hoechstens stuendlich ein Warnlog und schickt die Team-Mail T20 beim
Beginn und danach fruehestens nach 6 Stunden; Lesefehler und Mailfehler
stehen nur im Log. T20 steht jetzt auch im Test der Team-Fusszeile.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:01:16 +00:00
ClaudeandClaude Sonnet 5.5 3a3319ec14 Terminplaner: Team-Mail-Vorlage T20 Betriebsmeldung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:26:08 +00:00
ClaudeandClaude Sonnet 5.5 43b3caddde Terminplaner: veraltete G01 verworfen, Index bookingId
Eine G01, die erst nach holdMinutes + 1 Minute faellig wird, ist mit verfallenem Link
nutzlos: sie wird nicht mehr gesendet, sondern verworfen (kein Transportaufruf, kein
Budgetplatz, Felder geleert, kein Zustellfehler, raeumeAuf loescht sie nach 48 Stunden).
Neuer Index idx_booking auf termineOutbox fuer zustellFehler und verwerfeWartende;
verworfeneG01() zaehlt seit Prozessstart.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:21:25 +00:00
ClaudeandClaude Sonnet 5.5 36b94c3035 Terminplaner: Mail-Warteschlange begrenzt und nach Vorrang geordnet
verarbeite liest nicht mehr alle wartenden Eintraege, sondern je Klasse
(vorrang, normal, nachrangig) eine begrenzte Liste (config.termine.mailListe,
Standard 100) mit select und orderAsc(naechsterVersuch). Bei Pause oder
leerem Gesamtbudget endet der Lauf ohne Abfrage, bei erreichter Reserve
entfaellt die nachrangige Klasse. Funktionen mit ausgeschoepftem Kontingent
kommen in eine Ausschlussliste (hoechstens 90 Werte); hoechstens vier weitere
Runden je Klasse, damit keine Funktion hinter einer vollen verhungert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:13:26 +00:00
ClaudeandClaude Sonnet 5.5 4d96565240 Terminplaner: Empfaenger-Kappe ueber Betriebe (G01, Freitext in G04/G06)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:03:39 +00:00
ClaudeandClaude Sonnet 5.5 c35711caad Terminplaner: Mailkontingent je Funktion, Vorrang-Reserve und kleinere Haltungsgrenze
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 09:54:11 +00:00
ClaudeandClaude Sonnet 5.5 978722023f Terminplaner: Zellen-Sweep seitenweise ueber idx_bis mit Deckel
raeumeVerwaisteZellen(now, { deckel = config.termine.zellenDeckel }) liest
Zellen mit bis < jetzt - 1 Tag in Seiten zu 100 ueber idx_bis (aelteste
zuerst, ohne Cursor neu gelesen), gruppiert je Ressourcensperre mit Map und
loescht unter der Sperre nur, was dort noch dieselbe Buchung hat und noch alt
ist. Die Buchungen werden nicht gelesen. Der Lauf endet am Deckel (5000,
gedeckelt wird im Tagesjob gezaehlt und im Log gewarnt), bei einer leeren
Seite oder einer Seite ohne Erfolg; der erste Fehler wird am Ende geworfen.
ungueltigeZellen prueft zuerst das Zeitfenster (auch fuer belegeZellen).
verwaisteZellen, alleZellen und ALLE_ZELLEN_MAX entfallen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 09:44:47 +00:00
ClaudeandClaude Sonnet 5.5 d040a55f89 Terminplaner: CSV-Export seitenweise gestreamt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 09:32:14 +00:00
ClaudeandClaude Sonnet 5.5 a1732613c9 Terminplaner: Loeschung abgeschalteter Funktionen seitenweise, Rest an Folgetagen
loescheAbgeschaltete liest je abgeschalteter Funktion (disabledAt vor 30 Tagen) Seiten zu 100
(neu buchungen.ersteBuchungen: nur $id, limit, nie Gastfelder) und loescht sie ueber arbeiteSeiten
samt Zellen, bis die Seite leer ist. Die Funktion wird nach jedem Lesen einer Seite und alle 50
Buchungen frisch geprueft (nochAbgeschaltet); wird sie wieder eingeschaltet, endet die Loeschung
fuer sie sofort. Deckel config.termine.loeschDeckel je Funktion: am Deckel bleibt die Funktion
disabled mit altem disabledAt, eine Warnung ohne Kennungen steht im Log, der naechste Tag macht
weiter, die uebrigen Funktionen kommen im selben Lauf dran. Die Einstellung (termineConfig) wird
nur nach leerer Seite ohne Fehler und mit letzter Pruefung unter termine-cfg:<id> geloescht.
loescheAbgeschalteteFunktionen liefert { funktionen, buchungen, gedeckelt }; taeglicherJob traegt
gedeckelt.abgeschaltet getrennt vom Loeschzaehler ein (Tagesbericht: Loeschlaeufe am Deckel).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 09:24:33 +00:00
ClaudeandClaude Sonnet 5.5 9b4305e5de Terminplaner: Aufbewahrungs-Loeschung je Funktion mit Deckel, Sicherheitsnetz und Bericht
erledigt/nichterschienen werden jetzt je Funktion (portalFeatures termine, active|disabled)
nach deren aufbewahrungTage seitenweise geloescht; beide Status teilen sich den Deckel
loeschDeckel der Funktion, alle Schritte eines Laufs zusammen loeschDeckelGesamt (neu,
Standard 20000). Ein Sicherheitsnetz loescht erledigt/nichterschienen ohne Funktionsfilter
nach 365 Tagen (Buchungen ohne Funktionsdokument, unlesbare Einstellung). Der Tagesbericht
nennt "Loeschlaeufe am Deckel: n, Rest folgt an den nächsten Tagen" nur bei gedeckelt > 0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 09:09:48 +00:00
ClaudeandClaude Sonnet 5.5 5c95dfc322 Terminplaner: Aufbewahrungs-Loeschung seitenweise (Grundlagen, verfallen/abgesagt/abgelehnt)
loeschKandidaten liest eine Seite (Standard 100, aelteste Bezugszeit zuerst, optional je Funktion)
ohne Gastfelder; die Frist steht in der Abfrage. arbeiteSeiten (Seite lesen, bearbeiten, neu lesen)
endet bei leerer Seite, am Deckel (config.termine.loeschDeckel, 2000 je Status und Lauf, Rest an den
Folgetagen, gezaehlt als gedeckelt) oder bei einer Seite ohne Fortschritt. verfallen, abgesagt und
abgelehnt laufen darueber, unter der Buchungssperre werden Status und Bezugszeit frisch geprueft.
taeglicherJob liefert gedeckelt getrennt vom Loeschzaehler. Neue Indizes idx_status_ends und
idx_status_updated auf termineBookings (scripts/termine-setup.mjs legt sie an).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 08:55:32 +00:00
ClaudeandClaude Sonnet 5.5 2dd9c0dec7 Appwrite-Abfragen: select, notEqual, countDocuments und Nachbau dazu
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 08:38:21 +00:00
ClaudeandClaude Sonnet 5.5 8c1055e8a0 Kundenansicht: Terminplaner-POSTs nur noch ueber eine explizite Liste
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 08:34:29 +00:00
ClaudeandClaude Opus 5.5 a72380cc8b Terminplaner TP2 Verkauf: Umsetzungsplan (Bloecke A, B, E, C, D, F und Livegang-Checkliste G)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:04:49 +00:00
ClaudeandClaude Opus 5.5 6ed0e3bcaf Terminplaner TP2 Verkauf: Design mit den Entscheidungen D1–D8 des Inhabers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:17:11 +00:00
ClaudeandClaude Opus 5.5 3a0b0089dd Terminplaner: eigener Betreff fuer die Ablehnung einer Anfrage (G04)
Eingang (G03) und Ablehnung (G04) hatten denselben Betreff "Ihre Terminanfrage bei {betrieb}".
G04 heisst jetzt "Ihre/Deine Terminanfrage bei {betrieb} – leider abgelehnt".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:40:52 +00:00
ClaudeandClaude Opus 5.5 5077dd5a20 Mitarbeiter-Zugang: --ohne-buckets (Server-Key ohne buckets-Scope, Buckets dann in der Konsole)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:10:09 +00:00
ClaudeandClaude Opus 5.5 10b393687c Mitarbeiter-Zugang: Review-Punkte (fail closed, Kunden-Konto bleibt, Probelauf robuster)
- removeEmployeeEntry: nur 404 heisst "kein Konto", jeder andere Fehler bricht ab
- Mitarbeiter loeschen (Admin-Panel): Konto ohne Mitarbeiter-Label (Kunde mit
  altem employees-Eintrag) bleibt bestehen, nur der Eintrag geht
- Mitarbeiter bearbeiten gibt nie automatisch das Label (der Eintrag kann von
  einem Kunden stammen); nur Neuanlage bekommt "mitarbeiter"
- --rechte: --offen-lassen <ids>, unlesbare Listen als Fehler statt Abbruch
  (--anwenden verweigert dann), --sicherung <datei> + docker-cp-Hinweis,
  Bucket-Einstellung transformations wird mitgeschickt
- getEmployeeByEmail entfernt (unbenutzt)
- Tests: write/users-unverified, zweiter Lauf idempotent, >100 Dokumente,
  Loeschen mit Kunden- und Mitarbeiter-Konto

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:00:48 +00:00
ClaudeandClaude Opus 5.5 2659385205 Mitarbeiter nur noch per Label: Kunden-Konten bekommen keinen Team-Zugang
Kunden haben Konten im selben Appwrite-Projekt wie das Ticketsystem. Bisher
galt als Mitarbeiter, wer einen employees-Eintrag hatte - den legte das
Ticketsystem beim Login jedes Kontos automatisch an, und jedes angemeldete
Konto konnte ihn auch selbst anlegen. Damit kam ein Kunde ueber /api/team an
Chats, Kundenansicht, Angebote und Finanzen.

- staffLabels.js: Mitarbeiter = Label "mitarbeiter" oder "admin" (setzt nur
  der Server-Key)
- Team-Eingang (requireStaffJwt) und Portal-Login-Sperre nur noch per Label;
  kein Abgleich ueber employees-Eintrag oder Mail mehr
- Portal-Login: Konto-Abruf gestoert -> 503 fuer alle (fail closed)
- Mitarbeiter anlegen/bearbeiten setzt das Label "mitarbeiter"
- scripts/mitarbeiter-zugang.mjs: --status (markiert employees-Eintraege von
  Kunden), --label, --eintrag-loeschen (nur Eintrag, nie das Konto), --rechte
  (Probelauf, --anwenden mit Sicherung, --zurueck): any/guests/users ->
  label:mitarbeiter + label:admin in Collections, Buckets und bei Dokument-/
  Datei-Sicherheit auch einzelnen Dokumenten und Dateien

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:54:09 +00:00
ClaudeandClaude Opus 5.5 be7f9b1aa3 Terminplaner: Test an zweiten absoluten Import von funktionen.js (/geraete.js) angepasst
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:18:21 +00:00
ClaudeandClaude Opus 5.5 5bd8ee8457 Terminplaner: Abschluss – kein "null" in abgeschalteten Einstellungen, Kommentar zu disabledAt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 f134ed0032 Terminplaner: Abschluss – Spezifikation und Rechtshinweise an den Stand des Codes angeglichen
- Spezifikation: Abweichungen aus den Abschlussreviews nachgezogen (kontakt_url, kein ETag und formMinSekunden,
  Token-Endpunkte unter /termin/*, Zellen bis blockEndsAt, Outbox-Fristen, Reihenfolge Buchung vor Zellen,
  Widerrufsfrist nach §§ 187/188/193 BGB, Abwesenheit schlägt Sonderöffnung, Kunden-API vollständig,
  Impressum-Adresse) sowie R53 (Wartezeit vor dem Absenden), R54 (Widerruf als Knopf, im Knopfmodus
  dauerhaft) und R55 (Widerruf bei jeder Verbraucher-Buchung, auch Anfragen).
- Neuer Abschnitt "Betrieb und Livegang – Hinweise" in 14 (SESSION_SECRET nie rotieren, Modus off stoppt die
  Löschjobs, Zellbereinigung vor TP2 begrenzen, Warteschlange listet jede Minute alles Fällige).
- Plan: Impressum-Link auf https://webklar.com/impressum.
- Rechtshinweise: "offen 1" durch R14 erledigt, Haken für vorzeitigen Beginn nach R29/R31, Widerrufshinweis
  nach R55 und G02; offene Punkte zu Abo-Ende und Knopfmodus in den Datenschutz-Entwürfen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 794a63c0d7 Terminplaner: Abschluss – Client (Formular-Token, Widerruf prominent, Telefonlink, Einbau-Code, G02)
- R53: Das Widget wartet vor jedem Absenden, bis das Formular-Token formMinSekunden plus 0,5 s alt
  ist ("Einen Moment …", Knopf gesperrt); gilt nach dem Neuladen der Konfiguration und beim Wiederholen
  nach "neu_laden". Kein stilles Erfolgs-201 ohne Buchung mehr.
- R54: "Vertrag widerrufen" im Fuß ist ein zweiter Knopf (44 px). Im Knopfmodus lädt das Widget die
  Konfiguration beim Laden der Seite (ein Abruf) und zeigt bei Widerrufsrecht dauerhaft einen zweiten
  Knopf "Vertrag widerrufen" neben dem Buchungsknopf, der den Dialog beim Widerrufsformular öffnet.
- Telefonlink im Widget und auf der Gästeseite: Text mit Buchstaben oder Mehrfachangaben wird nie zum
  tel:-Link, "(0)" hinter der Landesvorwahl entfällt.
- Portal, Einstellungen: Kasten "Einbau auf deiner Website" mit Einbau-Code und Kopierknopf; Hinweistexte
  zum Widerruf passen zu R55 (auch Anfragen).
- G02: Widerrufssatz nennt, dass der Knopf bis zum Beginn des Termins gilt und danach das Formular auf der
  Website oder die E-Mail an den Betrieb.
- Einbau-Anleitung angepasst (siteKey und Code im Portal, Knopfmodus, Checkliste); Tests und Playwright.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 bd65c9dc58 Terminplaner: Abschluss – Server (Widerruf auch bei Anfragen, Gastdaten löschen vollständig, formMinSekunden)
- R55: Widerrufsbelehrung, Anschrift-Pflicht und Knopf "Vertrag widerrufen" gelten für jede
  Verbraucher-Buchung mit sichtbarer Leistung, auch Anfragen (zeigtWiderruf); der Nachweis
  hält die Belehrung auch bei Anfragen fest. Damit bleibt die Annahme einer Anfrage nie mehr
  still ohne G02.
- "Gastdaten löschen" leert auch absageGrund und löscht noch nicht versandte Mails der Buchung
  (neu: verwerfeWartende in der Warteschlange).
- R53: GET /:siteKey/config nennt formMinSekunden.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 4be886a4de Terminplaner: Portal-Ansicht – Fokus und aria-busy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 3ad056f261 Terminplaner: Portal-Ansicht Einstellungen – Nachbesserungen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 0b75b31009 Terminplaner: Portal-Ansicht Leistungen, Team, Einstellungen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 650919b022 Terminplaner: Portal-Ansicht – Nachbesserungen (2/2)
- Annehmen mit Rueckfrage in der Seite (schliesst den Vertrag, schickt G02)
- Offene Anfragen: alle bis Ende des Buchungszeitraums (Fenster zu je 62 Tagen,
  hoechstens 6 Aufrufe), passend zur Zahl auf Kachel und Banner
- Meldung ins Bild geholt, Fehler mit Fokus; einzige Live-Region
- Netzwerkfehler auf Deutsch; Antwort einer frueheren Website erscheint nie
  unter der neuen (Zaehler je Aufruf); Datumssprung nur mit echtem Tag
- Ende um Mitternacht als 24:00, Statusfilter nach Eintragen auf Alle,
  abgeschaltet ohne Einstellung mit Export-Link, dunkles Datumsfeld
- Playwright: Zeitzone New York/Berlin, ferne Anfragen, Projektwechsel,
  Meldung im Bild, Datumssprung, abgeschaltet ohne Einstellung

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 9c07f6a8c5 Terminplaner: Portal-Ansicht – Nachbesserungen (1/2: Module getrennt)
public/termine.js in Huelle und Module zerlegt, Verhalten unveraendert (R48):
termine-dom.js (Helfer ohne Zustand), termine-tab-termine.js (Liste, Kopf, Zeilen),
termine-aktionen.js (Aktionen mit Rueckfrage), termine-formulare.js (Uhrzeiten,
Termin eintragen, Zeit sperren). Reiter bekommen Zustand und aktuell() nur ueber
ctx; die Huelle haengt sie ueber die Registry REITER ein. Statiktest prueft alle
public/termine*.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 43e4826ae3 Terminplaner: Portal-Ansicht, Kachel, Termine
Kachel in Funktionen (Kommt bald / Aus / Aktiv mit Anfragen-Zahl / Abgeschaltet),
Ansicht #termine als Unterseite von Funktionen: Projektauswahl, Vorlagenauswahl
beim ersten Oeffnen, Reiter-Geruest (Platzhalter fuer Leistungen, Team & Plaetze,
Einstellungen) und der Reiter Termine mit Tag/Woche/Monat, Filtern, Aktionen mit
Rueckfragen in der Seite, Termin eintragen und Zeit sperren.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 70940e02c8 Terminplaner: Gaesteseiten – Nachbesserungen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 734e397e73 Terminplaner: Gaesteseiten Bestaetigen, Verwalten, Widerrufen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 781eccb2d6 Terminplaner: Einbau-Skript – veraltetes Angebot
Fehler an leistung/ressource (beim Absenden und bei Tage/Zeiten) laden zuerst die Konfiguration
neu (ladeNeu): gilt die Auswahl nicht mehr, beginnt die Strecke von vorn mit Hinweis; sonst
steht die Meldung des Servers. Knopfmodus: Aufbaufehler zeigt den Ausweichtext statt "Wird
geladen".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 4465ac7b94 Terminplaner: Einbau-Skript – Nachbesserungen
429 ohne Schleife (Meldung und Erneut-versuchen-Knopf, Formulare bleiben stehen), Schriftfarbe
auf der Betriebsfarbe mit mindestens 4,5 : 1, AGB-Link aus der oeffentlichen Konfiguration
(betrieb.agbUrl), Haken starten bei geaenderter Auswahl ohne Haken, Fehler ohne Feld nicht
verschluckt, Konfiguration fail closed, ladeNeu fuer Neuladen und neu_laden, Tastatur und
Fokus (Modifikatoren, Monatspfeile, Belehrungstext), Aufraeumen. UI-Testserver: Timer,
offene Verbindungen, Hinweis auf sessionCookie.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 5fef7e47f0 Terminplaner: Einbau-Skript, Demo und Einbauanleitung
public/embed/termine.js: Buchungs-Widget fuer Kundenwebsites (inline und Knopf mit dialog,
Monatsraster nach APG, Haken-Texte und Knopf aus der Konfiguration, Honigtopf kontakt_url,
Widerrufsfunktion, Konfiguration nach 100 min neu, Ausweichtext bei Fehlern). Rechnet nie mit
Datum oder Zeitzone des Browsers. Dazu Demo-Seite, Einbauanleitung fuer Mitarbeiter, statischer
Test und Playwright-Skript mit wiederverwendbarem UI-Testserver (test/ui/termineTestServer.mjs).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 bb45d35ec7 Terminplaner: Kunden-Schnittstelle – Nachbesserungen
- GET /zeiten?ohne=<Buchung>: Leistung, Dauer und Person der Buchung wie beim
  Verschieben (verschiebeZeiten exportiert), unbekannte/fremde/Sperre 404 (R42)
- Schreib-Begrenzung als gemeinsamer Baustein (writeLimiter.js) fuer News und
  Terminplaner statt kopierter Funktion (R43)
- Unlesbares Einstellungs-JSON: Kontext traegt die echte Version, Speichern
  ueberschreibt den Schaden statt dauerhaft 409
- U+FEFF als Escape statt als rohes Zeichen
- Tests: Mandantentrennung mit echtem zweitem Kunden, R42, Limiter, kaputtes JSON

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 e3e198fb1a Terminplaner: Kunden-Schnittstelle
/api/termine: Einstellungen (Vorlage, PATCH mit Version, Feed-Link), Buchungen
(Liste, Zeiten, von Hand, Sperre, Aktionen), CSV-Export, Protokoll der
Kundenansicht. Pflichtangaben fuer Gaeste (Telefon immer, Anschrift bei
Widerrufsbelehrung) in normalisiereEinstellung, Entwurf aus Vorlagen,
kontextPerSiteKey ohne Pflichtangaben nicht buchbar; speichereEinstellung
nimmt eine Funktion unter der Sperre.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 9e8fd33713 Funktionen: Terminplaner als zweite Funktion (Team-Route, Kundenansicht)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 ae1886c030 Terminplaner: Zaehler je IP getrennt und Abgelaufene zuerst, Notantwort beim Schreiben, Pruefung aller Antworten auf interne IDs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 2b0c05b8e9 Terminplaner: oeffentliche Schnittstelle v1 mit Missbrauchsschutz
Router /api/public/termine/v1 (config, tage, zeiten, buchungen, widerruf, Token-Seiten /termin/*, Kalender-Abo) mit
eigenem 16-KB-Parser und Fehlermiddleware, CORS nach Herkunft, Honeypot und Formular-Token, Grenzen je IP (IPv6 je /64),
siteKey und Adresse in limits.js, Mindestantwortzeit (TERMINE_MIN_ANTWORT_MS) und 10-Sekunden-Cache fuer tage/zeiten.
Vor dem globalen express.json eingehaengt; /api/public/ ist vom Kundenansicht-Waechter ausgenommen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 43ac05aa06 Terminplaner: Jobs lesen abgeschaltete Funktionen vor dem Löschen neu, Tagesbericht im Log
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 485ff3bc15 Terminplaner: Hintergrund-Jobs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Opus 5.5 efe17551c2 Spec/Plan: Zustimmung vorzeitiger Beginn und Widerrufsfrist nach §§ 187/188/193 BGB (Rulings R29, R31)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 589f5088a6 Terminplaner: Handeintraege verlangen nur den Namen, Bestaetigung braucht eine Adresse
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 be49d38e0c Terminplaner: Aktionen des Betriebs, manuelle Termine, Sperren
Neues Modul ablaufBetrieb.js: aktion (annehmen, ablehnen, absagen, verschieben,
nichterschienen, anonymisieren), manuell, sperre, entsperre. ablauf.js exportiert
die gemeinsamen Bausteine (Pruefungen, Meldungen, mailSicher, pruefeSlot,
verschiebeKern). R31: brauchtZustimmungVorzeitig folgt der Berliner Widerrufsfrist
(withdrawalDeadline ab now + Haltezeit); bei einer Anfrage mit Preis immer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 c4ec24448e Terminplaner: Fix Widerruf nach Terminbeginn, Zustimmung beim Verschieben, Berliner Widerrufsfrist, Grenzen nur ohne Treffer
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 2d9b75b3bf Terminplaner: Absagen, Verschieben, Widerruf durch Gaeste
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 1289b5697b Terminplaner: Bestaetigung prueft vorher, dass die Zellen der Haltung ihr noch gehoeren
bestaetige zieht unter der Buchungssperre fehlende eigene Zellen nach (belegeZellen mit ausser). Hat ein anderer
Gast sie uebernommen, wird die Buchung verfallen und es kommt dieselbe 410-Antwort wie bei abgelaufener Haltung,
ohne Mails.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 d1470b894a Terminplaner: Buchung halten und bestaetigen
Gast-Ablauf in ablauf.js: halte (Eingabe pruefen, Slot neu berechnen, Buchung gehalten vor den Zellen,
Grenze offener Termine mit G01 zu_viele), vorschau (Bestaetigungs- und Verwaltungstoken) und bestaetige
(gehalten -> bestaetigt/angefragt, G02/G03 und K20). einstellungen.js exportiert text und emailAdresse,
mails.js dauerText zur Wiederverwendung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 8e25cd2d13 Terminplaner: websiteUrl verweist auf die Live-Domain statt auf die Vorschau
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 30d40ad6ce Terminplaner: Kontext, Einstellungs-Speicher, Mail-Werte
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 af4029af81 Terminplaner: Mail-Warteschlange mit Stundenbudget
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 70b904260a Terminplaner: G08 Satz ohne Widerspruch zu zugeordnet, Fristhinweis 'Nach unseren Unterlagen'
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 6c25a76e28 Terminplaner: G01 Block bestaetigen, G02 kein_widerruf, G06 grund/nachricht, Hinweise zu Blockpaaren
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 274192e88e Terminplaner: Mailvorlagen G01–G08, K20, K21
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 171b5086b1 Terminplaner: ICS-Faltung prueft Surrogatpaare statt wirkungslosem UTF-8-Decode
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 181445a69d Terminplaner: Kalenderdatei, Gaesteversand, Vorlagen-Engine fuer G-Vorlagen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 0c033ab1d2 Terminplaner: Widerrufsfunktion im Satzrahmen des Musters, Belehrung nur mit Anschrift und Telefon
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 459efff10a Terminplaner: Rechtstext-Bausteine fuer Gaeste (Entwurf)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 9ad8515d60 Terminplaner: Zellen nur unter der Ressourcensperre freigeben, gemeinsame Hilfsfunktionen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 e281db1b2b Terminplaner: Zellen gegen Doppelbuchung, Tokens, Buchungsspeicher
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 4def5c735d Terminplaner: Verfuegbarkeit, Abwesenheit schlaegt Sonderoeffnung, waehleRessource ignoriert abgelaufene Haltungen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 cce6e64dd1 Terminplaner: Verfuegbarkeit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 759be69250 Terminplaner: Einstellungen, URL-Host einschraenken und unsichtbare Formatzeichen entfernen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 9cacd5bd3b Terminplaner: Einstellungen und Branchen-Vorlagen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 310f8434d6 Terminplaner: Zeit (Berlin, Umstellung) und Feiertage
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 a5e12d7f34 Appwrite: Bulk-Create, Bereichsabfragen, seitenweises Laden, genauerer Nachbau
appwriteAdmin: adminFetch kennt die Option raw (Body unverpackt), createDocuments
(Bulk-Create, alles oder nichts, 409 bei vorhandener ID), listAllDocuments (seitenweise
per cursorAfter, sortiert nach $id, Fehler ueber max) und Query.between/greaterThanEqual/
lessThanEqual/cursorAfter.

Nachbau: Bulk-Create mit Alles-oder-nichts (409/400, hoechstens 100), die neuen Abfragen
samt cursorAfter, Zeitpunktvergleich fuer ISO-Datumswerte, Optionen datetimes (Normalisierung
auf +00:00, leeres Datum => 400) und defaultLimit (Standard-Limit wie Appwrite). Ohne die
Optionen unveraendert. testApp exportiert send(method, path, opts).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Opus 5.5 334dc1e669 Terminplaner: Schalter, Konfiguration, Schema
Schalter TERMINE_ENABLED (off|pilot|on) mit TERMINE_PILOT_EMAILS, config.termine
und die vier Collection-IDs, Schema der Termine-Collections als reine Daten
(server/services/termine/schema.js), Setup-Skript scripts/termine-setup.mjs mit
--check und schemaKeys() fuer den Appwrite-Nachbau der Tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Opus 5.5 f63165c158 Rechtliches: Anlage 1 und 2 zu Art. 246a EGBGB als Quelle fuer die Terminplaner-Widerrufsbelehrung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Opus 5.5 f28060198c Plan TP1: Funde der Planpruefung eingearbeitet (Einstellungsfeld, Verwaltungstoken, Verschieben, Testbarkeit, Missbrauchsschutz)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Opus 5.5 e3ff05c804 Plan: Terminplaner TP1 Kern (17 Aufgaben + Livegang), Spec-Feinschliff
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Opus 5.5 275dbcd690 Spec: Terminplaner (TP1 Kern, TP2 Verkauf, TP3 Online-Zahlung)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Opus 5.5 d8328722b3 Zusatzseite Website-Analyse: Keks-Symbol bei Widerruf und Widerspruch genannt
Der Knopf zum Wiederoeffnen des Banners ist jetzt ein Keks-Symbol. Die Zusatzseite
(runner/src/templates/datenschutz-analyse.html) sagt deshalb "Klicke auf
„Datenschutz-Einstellungen“ (das Keks-Symbol, in der Regel unten rechts auf jeder Seite)"
und im Widerspruchsrecht "über „Datenschutz-Einstellungen“ (das Keks-Symbol)".

Fassung bleibt 2026-10-02 (Banner-Text unveraendert). Archiv: neue Seite unter
datenschutz-analyse-2026-10-02.html, die vorige Formulierung (nur auf der WEBklar-Testseite
ausgeliefert) als datenschutz-analyse-2026-10-02-erstfassung.html; README und Banner-Archiv
beschreiben die Aenderung. Die Leistungsbeschreibung bleibt unveraendert ("Schaltflaeche
„Datenschutz-Einstellungen“ unten rechts" stimmt weiter).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:24:07 +00:00
ClaudeandClaude Opus 5.5 0a113c2983 Heatmap in Geraeterahmen (Computer/Tablet + Handy) und Keks statt "Datenschutz-Einstellungen"
Heatmap im Portal (Funktionen -> Analyse): die echte Website laeuft jetzt in realistischen
Geraeterahmen, gross MacBook oder iPad (Umschalter oben), daneben das iPhone, jeweils in echter
Breite (1512 / 820 / 402 px), verkleinert und im Geraet scrollbar, mit den Klicks der
passenden Geraeteklasse als Overlay. MacBook und iPhone stammen aus den Rahmen der neuen
Website (device-mockups), das iPad ist im gleichen Stil nachgebaut (public/geraete.css/.js).

- heatmap-link: embed: true haengt __wk_embed=1 an; w.js reicht das als data-embed an h.js.
- h.js eingebettet (nur in einem Rahmen): keine Leiste, Links und Formulare gesperrt (kein
  Seitenwechsel, also keine Zaehlung und kein Banner), Scrollleisten aus, Klickzahl per
  postMessage nur an die Herkunft von h.js (project.webklar.com).
- iframe mit Sandbox allow-scripts allow-same-origin (keine Formulare, Pop-ups, keine
  Navigation des Portals); Laden erst, wenn die Heatmap in Sicht kommt. Laesst sich eine
  Website nicht einbetten, kommt nach 20 s ein Hinweis mit "In neuem Tab oeffnen" (alter Weg).

Banner-Skript: der Einstellungsknopf nach der Wahl ist jetzt ein angeknabberter Keks unten
rechts (Inline-SVG, 44 px). Er dreht sich langsam, haelt beim Darueberfahren/Fokus an und
zeigt dann "Datenschutz-Einstellungen" (auch sein Name fuer Screenreader, passend zum
Bannertext); bei reduzierter Bewegung dreht er sich nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:09:17 +00:00
ClaudeandClaude Opus 5.5 ff7fe35c06 Rechnungen: Stripe-Rechnungen im Kundenportal anzeigen (Abo, Funktionen, Projektrechnungen)
Der Tab "Rechnungen" las nur InvoiceNinja. Seit dem Finanzen-Livegang laufen Rechnungen
aber ueber Stripe; die bezahlte Abo-Rechnung eines Kunden fehlte deshalb im Portal.

- /api/invoices fuehrt Stripe (alle Stripe-Kunden des Kunden: Abo-Spiegel und Suche nach
  metadata.webklarCustomerId) und InvoiceNinja zusammen, neueste zuerst; Entwuerfe und
  0-EUR-Rechnungen bleiben verborgen. Faellt eine Quelle aus, kommt der Rest mit incomplete.
- /api/invoices/:id/pdf laedt Stripe-PDFs frisch ueber den Server, nur fuer eigene Rechnungen
  (sonst 404) und nur von *.stripe.com.
- Seite: Status-Farben fuer Bezahlt/Offen/Ueberfaellig/Storniert, "Ansehen" statt
  "Ansehen / Zahlen" bei bezahlten Rechnungen, Hinweis bei unvollstaendiger Liste; der
  Abo-Hinweis verweist nur noch fuer die Zahlungsart auf "Abo verwalten & Rechnungen".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:33:22 +00:00
ClaudeandClaude Opus 5.5 df8b952b81 Alte Mail-Links aufs Admin-Panel leiten ins Ticketsystem (Antrag direkt geoeffnet)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 04:05:17 +00:00
ClaudeandClaude Opus 5.5 95e2807dc7 Team-Mails: Links ins Ticketsystem statt Admin-Bereich
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 03:52:54 +00:00
ClaudeandClaude Opus 5.5 7c00633db0 Portal: Admin-Panel, Rechnungsanalyse und alte Admin-Schnittstellen entfernt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 03:50:14 +00:00
ClaudeandClaude Opus 5.5 3a4fa581c0 Portal: Mitarbeiter-Login gesperrt (Hinweis aufs Ticketsystem), alte Mitarbeiter-Sitzungen enden
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 03:44:10 +00:00
ClaudeandClaude Opus 5.5 d0ca3c41cd Plan: Admin-Umzug Etappe 4 (Abschalten)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 03:32:42 +00:00
ClaudeandClaude Opus 5.5 40f0a04908 Gutschriften: Entscheidung und Verrechnung je Antrag gesperrt (gleichzeitige Klicks zweier Mitarbeiter)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 03:15:25 +00:00
ClaudeandClaude Opus 5.5 45203284ee Abo-Status eines Kunden fuer alle Mitarbeiter im Team-Eingang
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:52:38 +00:00
ClaudeandClaude Opus 5.5 0fdd13246c Gutschriften: signierte Links fuer Nachweise im Ticketsystem
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:51:49 +00:00
ClaudeandClaude Opus 5.5 7d93ae0cd9 Team-Eingang: Gutschriften, Angebote, E-Mail-Speicher und Funktionen fuer alle Mitarbeiter, Kundenfilter, Zaehlroute
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:50:55 +00:00
ClaudeandClaude Opus 5.5 a06c3bc8ef Plan: Admin-Umzug Etappe 3 (Gutschriften, Abo & Angebote, E-Mail-Speicher, Funktionen)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:28:18 +00:00
ClaudeandClaude Opus 5.5 a6ac4510cd Chat-Baustein: Option requestHeaders fuer Hochladen und Entfernen (Ticketsystem mit JWT)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:30:29 +00:00
ClaudeandClaude Opus 5.5 48dd19b75a Chat ueber den Team-Eingang: gemeinsamer Router fuer Admin-Panel und Ticketsystem, signierte Anhang-Links
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:29:05 +00:00
ClaudeandClaude Opus 5.5 5f5df7ba1a Signierte Datei-Links (15 min) fuer Anhaenge im Ticketsystem
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:27:38 +00:00
ClaudeandClaude Opus 5.5 acf6ff9ee1 Plan: Admin-Umzug Etappe 2 (Chat)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:10:48 +00:00
ClaudeandClaude Opus 5.5 6718018211 Verwaiste Repo-Platzhalter blockieren keine Zuordnung mehr
Ein in Gitea geloeschtes Org-Repo liess sein unverknuepftes Projekt-Dokument
liegen. Der Unique-Index auf repoFullName ignoriert Gross-/Kleinschreibung,
darum scheiterte beim Zuweisen eines gleich benannten neuen Repos das
Umbenennen des Projekts; alle Pushes landeten im Platzhalter ohne Ticket,
also ohne Arbeitsblatt und ohne Push-Zeitmail (WEBklar/Schmacko-napf).

- repoMove: vor dem Umbenennen ein unverknuepftes fremdes Dokument mit dem
  Zielnamen entfernen (verknuepfte bleiben unangetastet)
- Webhook repository/deleted: unverknuepfte Platzhalter des geloeschten
  Repos entfernen, nur wenn Gitea das Repo wirklich nicht mehr kennt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:58:11 +00:00
ClaudeandClaude Opus 5.5 a64a996291 Kundenansicht: Waechter-Korrekturen aus dem Abschlussreview
- Pfade ohne Ruecksicht auf Gross-/Kleinschreibung pruefen (/API/news umging die Sperre)
- Tab mit Kennung, Cookie ist keine Kundenansicht mehr -> 409 statt fremder Daten
- Cookie lebt 1 h laenger als die Ansicht, damit 'abgelaufen' gemeldet und protokolliert wird

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:54:50 +00:00
ClaudeandClaude Opus 5.5 0ccdde1c28 Datenschutz Abschnitt 6: Kundenansicht mit News, Sperren und Protokoll (12 Monate)
Stand 01.10.2026. Der Test koppelte den Stand bisher an termsVersion; jetzt
genuegt 'nicht vor termsVersion', weil die Erklaerung unabhaengig geaendert wird.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:38:14 +00:00
ClaudeandClaude Opus 5.5 c1fda8a717 Kundenansicht im Dashboard: Kennung bei jeder Anfrage, Banner mit Beenden, Hinweis bei ersetzter oder abgelaufener Ansicht
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:36:55 +00:00
ClaudeandClaude Opus 5.5 c5afd42a5f Kundenansicht: Beenden mit Rueckweg ins Ticketsystem, /me mit Ansichts-Kennung, Team-Routen Portal-Status, Protokoll, Preview-Link
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:32:59 +00:00
ClaudeandClaude Opus 5.5 7d8ba56088 Kundenansicht: News-Aenderungen mit Mitarbeiter und Titel protokollieren
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:30:47 +00:00
ClaudeandClaude Opus 5.5 25f3c1df4d Kundenansicht: zentraler Waechter (2 h, Verwechslungsschutz, Schreibsperre ausser News)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:29:47 +00:00
ClaudeandClaude Opus 5.5 1cd776a2ee Kundenansicht: Einmal-Link aus dem Ticketsystem, Sitzung und Preview-Zugang fuer 2 Stunden, Protokoll
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:27:21 +00:00
ClaudeandClaude Opus 5.5 fdf594706d Kundenansicht: Protokoll-Collection customerViewLog mit Schreiben, Lesen und Loeschen nach 12 Monaten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:25:26 +00:00
ClaudeandClaude Opus 5.5 772095ab19 Team-Eingang: /api/team mit Appwrite-JWT fuer alle Mitarbeiter, CORS-Header fuer Uploads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:23:57 +00:00
ClaudeandClaude Opus 5.5 be65baff01 Plan: Admin-Umzug Etappe 1 (Fundament + Kundenansicht)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:19:57 +00:00
ClaudeandClaude Opus 5.5 5fb39f1a1b Spec: Admin-Panel ins Ticketsystem (Kundenansicht, Protokoll, Team-Schnittstelle, 4 Etappen)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:11:32 +00:00
ClaudeandClaude Opus 5.5 546d3c84d0 Export: Formelschutz nur fuer Text (inkl. Tab/CR), CSV mit Rechten 0600, Runbook
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:50:36 +00:00
ClaudeandClaude Opus 5.5 495c3a2c40 Analyse: Abgleich schaltet neu aktivierte Analysen nicht ab (Sperre + Nachlesen), weitere Abgleich-Tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:50:36 +00:00
ClaudeandClaude Opus 5.5 78170567b4 Analyse: alle Beendigungswege schalten den Messdienst ab (Kunde/Projekt weg, Domain, Löschfrist, Widerruf Wechsel)
Messdienst: POST /a/admin/sites/:key/off setzt active=0 und offSince einmalig; Portal-Client stopSite.
analyse.js: endAnalyse als gemeinsamer Kern, endAnalyseForCustomer, analyseOnProjectChanged,
analyseDisabled (Sperre/Löschfrist) und täglicher Abgleich runAnalyseReconcile (Projekt/Kunde weg,
Domains nachziehen, K18-Wiederholung, Messdienst für off/disabled, fehlende Entfernen-Tickets nachholen).
Entfernen-Ticket je Abschaltung gekennzeichnet (kein Doppelticket, auch wenn das Speichern der ID scheiterte).
Hooks: Kunde löschen, updateWebsiteProject, withdrawOrder (Wechsel), siteFeatures.disable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:50:36 +00:00
ClaudeandClaude Opus 5.5 fbc40252a3 Analyse: K18 mit PDFs und Nachweis, K19 neu, Tickets für Einschalten, Ausschalten und Veröffentlichen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:50:36 +00:00
ClaudeandClaude Opus 5.5 3bac4093af Analyse: Portal-Texte, Verantwortlichen-Vorschau und Bestätigungen nach geprüftem Wortlaut
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:50:36 +00:00
ClaudeandClaude Opus 5.5 ddb91a9950 Analyse: geprüfte Rechtstexte Fassung 2026-10-02 veröffentlichen (HTML, PDF, Zusatzseiten-Vorlage, Archiv)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:50:36 +00:00
ClaudeandClaude Opus 5.5 f401dc14d7 Analyse: Zielgruppen-Bestätigung in Historie, Rücknahme bei Fehler nach Dokument-Update, handelnde Person mit Name und E-Mail
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:50:36 +00:00
ClaudeandClaude Opus 5.5 3d05472b25 Analyse: Einwilligungs-Historie, Nachweis beim Einschalten, Sperre ohne Verantwortlichen-Angaben, Textform für Mitarbeiter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:50:36 +00:00
ClaudeandClaude Opus 5.5 c6824f83a6 Analyse: Skript nur für aktive Websites, leeres Skript sonst, siteKey in der Skript-URL
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:50:26 +00:00
ClaudeandClaude Opus 5.5 97890a8eee Analyse: Tageswerte nach 25 Monaten löschen, secure_delete, CSV-Export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:50:26 +00:00
ClaudeandClaude Opus 5.5 a73e0be952 Analyse: Messdienst: Heatmap-Pfad wie gespeicherter Pfad, Tests für Kodierung und Stunde
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:50:26 +00:00
ClaudeandClaude Opus 5.5 96409c6306 Analyse: Messdienst: Ping ohne Einwilligung, Zeitstempel auf Stunde, Pfade ohne Kennungen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:50:26 +00:00
ClaudeandClaude Opus 5.5 8d64a18d35 Analyse: Client prüft Einwilligung vor dem Senden, Fassung der Banner-Wahl, fetch ohne Cookies
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:50:26 +00:00
Claude c2cbc4156f Plan: Website-Analyse Pflicht-Aenderungen aus der Rechtspruefung 2026-10-01 18:50:26 +00:00
ClaudeandClaude Opus 5.5 9449542686 Rechtliches: Entwurf AVV Website-Analyse aus public/ entfernen (bis zur geprueften Fassung)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:50:40 +00:00
ClaudeandClaude Sonnet 5.5 c0c1a3d7a9 Finanzen: Laendersuche ohne Prototypschluessel, beim Anlegen des Stripe-Kunden keine leeren Adressfelder
- stripeCountry: Object.hasOwn statt Zugriff ueber die Prototypkette ('constructor', '__proto__' ergaben ein Objekt als Land)
- stripeCustomerFields(customer, { forCreate }): beim Anlegen (getOrCreateStripeCustomer) fehlen leere PLZ/Ort ganz;
  das Loeschen per '' bleibt beim Abgleich (Aktualisieren)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 f0b57ebe41 Finanzen: Nachtrag vor Livegang (Kundenabgleich beim Senden, Adress-Sperre, Land und Anschrift sauber an Stripe)
- sendInvoiceNow: Entwurf mit webklarCustomerId laedt den WOMS-Kunden und bringt den Stripe-Kunden vor dem
  Finalisieren auf die aktuellen Daten (Fehler: 502, nichts finalisiert); ohne Kennung kein Abgleich
- Adress-Sperre (§ 14 Abs. 4 Nr. 1 UStG): Senden ueber 250 € ohne Strasse/PLZ/Ort -> 409 vor jedem Stripe-Schreibzugriff
  (Anlegen mit senden=true und Entwurf senden; Text und Grenze wie im WOMS)
- stripeCustomerFields: Land aus deutschen Namen (unbekannt -> weglassen), PLZ/Ort mit Strasse immer mitsenden ('' loescht)
- Stripe-Nachbau: Kunden-Update fuehrt die Anschrift feldweise zusammen, '' loescht ein Feld

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 73811e0300 Finanzen: Stripe-Kunde vor der Rechnung auf die aktuellen Kundendaten bringen
Ein wiederverwendeter Stripe-Kunde (ueber das Abo oder die Metadaten gefunden) trug
noch die alte E-Mail und Anschrift, die Stripe auf die Rechnung schreibt
(Empfaengeranschrift, § 14 Abs. 4 Nr. 1 UStG). createProjectInvoice aktualisiert ihn
jetzt nach getOrCreateStripeCustomer und vor dem ersten Rechnungsaufruf mit E-Mail,
Name (Firma vor Name) und Anschrift (Land DE, wenn keine zweistellige Angabe).
Scheitert das, entsteht keine Rechnung: 502 "Kundendaten konnten nicht an Stripe
uebertragen werden." ohne invoiceId. Die Felder baut stripeCustomerFields fuer das
Anlegen und das Aktualisieren gemeinsam. Der Stripe-Nachbau kennt POST /v1/customers/:id.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 5ed4fbd188 Finanzen: Setup-Skript mit --iban und strenger Pruefung von --valid-until, Enable-Banking-Variablen in .env.example
M5: --iban speichert iban und ibanMasked (erste und letzte vier Zeichen); --valid-until nur als Datum
oder voller ISO-Zeitpunkt mit Zeitzone, sonst Abbruch.
M16: ENABLE_BANKING_APP_ID und ENABLE_BANKING_KEY_PATH auskommentiert dokumentiert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 039fdcaf27 Finanzen: Vorschau, Steuer und Aufgaben genauer (Fixkosten ohne ESt, verspaetete Auszahlungen, Abos nicht ladbar)
M6: fixkosten30Cents zaehlt nur Fixkosten, die ESt-Vorauszahlung bleibt sicherer Posten.
M7: verspaetete Auszahlung (Ankunftstag vorbei, noch nicht bezahlt) kommt heute.
M8: ohne Stripe sind Kleinunternehmer-Zaehler und Anteil unbekannt (null), keine Warnung daraus.
M12: Verbindungsdokument ohne Sitzung zaehlt als keine Verbindung.
M13: Buchungen privat/fixkosten gehen nicht in die Fixkosten-Vorschlaege.
M14: Vorjahresumsatz ueber 25.000 EUR ist eine hohe Aufgabe.
M15: scheitert das Laden der Abos, meldet abos.fehler statt die ganze Uebersicht zu kippen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 83ee9d5a25 Finanzen: Zuordnung zu Rechnungen robuster (Speicherfehler nach pay, Doppelzuordnung, Protokoll)
M1: scheitert Appwrite nach dem Bezahlen, kommt 502 mit klarer Meldung statt eines blanken 500.
M2: Wiederaufnahme nur bei passendem bezahltem Betrag; eine Buchung, die schon an einer anderen
bezahlten Rechnung haengt, wird nicht ein zweites Mal vermerkt und bezahlt (409).
M3: Protokollzeile je erfolgreicher, nicht umkehrbarer Zuordnung (Buchung, Rechnung, Nutzer).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 daa451094a Finanzen: Zeitgrenzen fuer Stripe-Lesen, Schutz vor doppelt angelegten Rechnungen
I2: Leseanfragen an Stripe mit timeout 15 s und einer Wiederholung (auch Folgeseiten), die Uebersicht
wartet hoechstens 20 s auf Stripe und rechnet sonst ohne Stripe weiter.
I3: idempotencyKey vom Aufrufer an stripe.invoices.create, Schutz im Prozess gegen gleichzeitiges und
30 s spaeteres Doppelanlegen (409), 502 mit invoiceId/Nummer wenn die fertige Rechnung nicht lesbar ist.
M9: Warnung, wenn die Rechnungsliste bei 1000 abgeschnitten wird.
M11: Ursache eines 502 an einer bestehenden Rechnung (Art, Status, Code) im Protokoll.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 ef2ce49669 Finanzen: Start-Abruf nur bei altem Stand, unlesbare Buchungen ueberspringen, Mail bei Widerruf des Bankzugangs
I1: shouldRunStartupSync; der Abruf 2 Minuten nach dem Start entfaellt, wenn der letzte erfolgreiche
Abruf weniger als 5,5 Stunden her ist (Deployments verbrauchten das Tageslimit der Bank).
M4: eine unlesbare Buchung wird uebersprungen und gezaehlt, der Abruf endet ok.
M10: 401/403 beim Abruf sendet T18 einmal (Stufe 0 in warnedThresholds).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 3c5e06723f Finanzen: Uebersicht und Rechnungsrouten fuer WOMS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 7dbf2ce75f Finanzen: Steuerruecklage, Kleinunternehmer-Zaehler und Aufgabenliste
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 34dfe181e4 Finanzen: Liquiditaetsvorschau, Abo-Kuendigung: cancelAt allein massgeblich, cancelAtPeriodEnd ohne cancelAt keine Verlaengerung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 9361000a70 Finanzen: Liquiditaetsvorschau 30 Tage (sicher/erwartet)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 34ff97e924 Finanzen: Einstellungen und Fixkostenliste
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 6e43d54fe8 Finanzen: Zuordnung zu Rechnungen erst vermerken, dann bezahlen; Vormerkungen nicht zuordnen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 2bf67cb48d Finanzen: Bankbuchungen Rechnungen, Auszahlungen, Fixkosten und Kategorien zuordnen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 f14a6b7b57 Finanzen: Zuordnungsvorschlaege und Erkennung wiederkehrender Ausgaben
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 3cedb5feef Finanzen: Rechnung senden ohne Doppelanlage (E-Mail vorab, erneut senden, Summengrenze)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 05e3478229 Finanzen: Projektrechnungen in Stripe anlegen, senden, stornieren, Entwurf loeschen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 db243f50bb Finanzen: Stripe-Rechnungen und Auszahlungen lesen (deutsch gemappt, 60-s-Cache)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 6af9834293 Finanzen: Admin-Routen fuer Bankstatus, Abruf, Neuverbindung und Buchungen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 7b35ed67a5 Finanzen: Warnung vor Ablauf des Bankzugangs (T18) und Abruf-Jobs
checkBankConsent mailt bei 30, 14, 3 Tagen und am Ablauftag (Berliner Kalendertage),
merkt sich die erreichten Stufen in warnedThresholds und holt eine nicht versandte
Mail beim naechsten Lauf nach. Jobs nur mit Enable Banking: Abruf nach 2 Minuten und
alle 6 Stunden, Pruefung nach 3 Minuten und taeglich.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:01 +00:00
ClaudeandClaude Sonnet 5.5 cba14ed0f0 Finanzen: Bankzugang aus WOMS neu verbinden (Freigabe, Rueckkehr, Kontowahl)
startBankAuth/completeBankAuth in bankSync.js. Die Rueckkehr prueft und speichert die
Sitzung unter der Sperre der Abrufe (ein laufender Abruf kann den neuen Zugang nicht
mehr ueberschreiben) und ruft danach einmal ab. Nachbau: holdNext haelt einen Aufruf an.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:41:54 +00:00
ClaudeandClaude Sonnet 5.5 75ca8b78c0 Finanzen: Index auf bankTransactions.status, Vormerkungssuche mit Rueckfall
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:41:54 +00:00
ClaudeandClaude Sonnet 5.5 588cc95f5d Finanzen: Bankabruf speichert Buchungen idempotent, Vormerkungen, Ablauf
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:41:54 +00:00
ClaudeandClaude Sonnet 5.5 3dd2fac50b Finanzen: Collections und Setup-Skript mit Sitzungsuebernahme
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:41:54 +00:00
ClaudeandClaude Sonnet 5.5 9329318df5 Finanzen: Enable-Banking-Client (JWT, Freigabe, Sitzung, Salden, Umsaetze)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:41:54 +00:00
ClaudeandClaude Opus 5.5 53b50011e8 Finanzen: Spec und Plaene 1-3 (Bank, Stripe/Auswertung, WOMS/Livegang)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:41:54 +00:00
1104 changed files with 272067 additions and 6742 deletions

No files matched your search

+5
View File
@@ -22,3 +22,8 @@ GITEA_API_TOKEN=
PREVIEW_DEPLOY_ROOT=/var/www/previews
PREVIEW_BASE_HOST=project.webklar.com
ALLOWED_CUSTOMER_STATUSES=active
# Finanzen (Bankabruf Finom über Enable Banking): beides setzen, sonst ist der Bankabruf aus.
# APP_ID = Anwendungs-ID bei Enable Banking, KEY_PATH = Pfad zum privaten Schlüssel (PEM) auf dem Server, nie im Repo.
# ENABLE_BANKING_APP_ID=
# ENABLE_BANKING_KEY_PATH=
+1 -1
View File
@@ -1,4 +1,4 @@
node_modules/
node_modules
.env
.DS_Store
preview-data/
+19 -2
View File
@@ -3,6 +3,15 @@
export const CONSENT_KEY = 'wk-consent';
export const CONSENT_MAX_AGE = 183 * 86400000;
export const BANNER_VERSION = '2026-10-02';
export const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
// Eigene Websites der WEBklar GbR (webklar.com, Skript-Attribut data-variant="eigen"): Sie-Form, WEBklar misst selbst
// (keine Auftragsverarbeitung). Eigene Fassung, damit eine Textaenderung nur dort neu fragt.
export const BANNER_VERSION_EIGEN = '2026-10-17';
export const BANNER_TEXT_EIGEN = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Ihrer Zustimmung messen wir zusätzlich, von welcher Website Sie kommen, wie lange Sie bleiben, wie weit Sie scrollen und wo Sie klicken (Heatmap), mit einer täglich wechselnden Kennung. Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben. Ihre Zustimmung können Sie jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
export const BANNER_LINK_LABEL = 'Datenschutz';
export const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
export const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
export function vpClass(width) {
const w = Number(width);
@@ -83,15 +92,23 @@ export function contactKind(a, ownHost) {
return stripWww(host) === stripWww(own || '') ? null : 'out';
}
export function readConsent(storage, now) {
// Banner-Fassung je Variante: 'eigen' fuer WEBklars eigene Websites, alles andere ist die Kunden-Fassung.
export function bannerFor(variant) {
return variant === 'eigen'
? { version: BANNER_VERSION_EIGEN, text: BANNER_TEXT_EIGEN }
: { version: BANNER_VERSION, text: BANNER_TEXT };
}
export function readConsent(storage, now, version = BANNER_VERSION) {
try {
const raw = storage.getItem(CONSENT_KEY);
if (!raw) return null;
const c = JSON.parse(raw);
if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
if (c.bv !== version) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen
const age = now - c.t;
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
return { v: 1, a: c.a, t: c.t };
return { v: 1, a: c.a, t: c.t, bv: c.bv };
} catch (e) { return null; }
}
+75 -27
View File
@@ -6,6 +6,15 @@
const CONSENT_KEY = 'wk-consent';
const CONSENT_MAX_AGE = 183 * 86400000;
const BANNER_VERSION = '2026-10-02';
const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
// Eigene Websites der WEBklar GbR (webklar.com, Skript-Attribut data-variant="eigen"): Sie-Form, WEBklar misst selbst
// (keine Auftragsverarbeitung). Eigene Fassung, damit eine Textaenderung nur dort neu fragt.
const BANNER_VERSION_EIGEN = '2026-10-17';
const BANNER_TEXT_EIGEN = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Ihrer Zustimmung messen wir zusätzlich, von welcher Website Sie kommen, wie lange Sie bleiben, wie weit Sie scrollen und wo Sie klicken (Heatmap), mit einer täglich wechselnden Kennung. Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben. Ihre Zustimmung können Sie jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
const BANNER_LINK_LABEL = 'Datenschutz';
const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
function vpClass(width) {
const w = Number(width);
@@ -86,15 +95,23 @@ function contactKind(a, ownHost) {
return stripWww(host) === stripWww(own || '') ? null : 'out';
}
function readConsent(storage, now) {
// Banner-Fassung je Variante: 'eigen' fuer WEBklars eigene Websites, alles andere ist die Kunden-Fassung.
function bannerFor(variant) {
return variant === 'eigen'
? { version: BANNER_VERSION_EIGEN, text: BANNER_TEXT_EIGEN }
: { version: BANNER_VERSION, text: BANNER_TEXT };
}
function readConsent(storage, now, version = BANNER_VERSION) {
try {
const raw = storage.getItem(CONSENT_KEY);
if (!raw) return null;
const c = JSON.parse(raw);
if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
if (c.bv !== version) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen
const age = now - c.t;
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
return { v: 1, a: c.a, t: c.t };
return { v: 1, a: c.a, t: c.t, bv: c.bv };
} catch (e) { return null; }
}
@@ -113,6 +130,8 @@ function escapeHtml(s) {
}
// WEBklar Analyse: Heatmap-Overlay (wird von w.js nur mit gueltigem ?__wk_heat=Token geladen).
// data-embed="1" (nur in einem Rahmen): Ansicht im Geraeterahmen des Kundenportals. Dann ohne Leiste,
// Links und Formulare gesperrt, und die Klickzahl geht per postMessage nur an das Portal (Herkunft von h.js).
function main() {
const me = document.currentScript;
@@ -122,21 +141,13 @@ function main() {
if (!token || !base) return;
const de = document.documentElement;
const host = document.createElement('div');
host.style.position = 'fixed'; host.style.left = '0'; host.style.right = '0'; host.style.top = '0'; host.style.zIndex = '2147483647';
const root = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = ':host{all:initial}.bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:#111;color:#fff;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}button{font:600 14px/1 system-ui,sans-serif;padding:8px 14px;border:2px solid #fff;border-radius:6px;background:#fff;color:#111;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}';
const bar = document.createElement('div');
bar.className = 'bar';
const msg = document.createElement('span');
msg.textContent = 'Heatmap wird geladen ...';
const close = document.createElement('button');
close.setAttribute('type', 'button');
close.textContent = 'Schließen';
bar.appendChild(msg); bar.appendChild(close);
root.appendChild(style); root.appendChild(bar);
document.body.appendChild(host);
let portal = '';
try { portal = new URL(base).origin; } catch (e) { /* ohne Portal keine Einbettung */ }
const embed = !!portal && me.getAttribute('data-embed') === '1' && window.parent && window.parent !== window;
function tell(state, count) {
if (!embed) return;
try { window.parent.postMessage({ wk: 'heat', state: state, count: count }, portal); } catch (e) { /* egal */ }
}
const canvas = document.createElement('canvas');
const cs = canvas.style;
@@ -156,6 +167,38 @@ function main() {
return g.getImageData(0, 0, 256, 1).data;
})();
if (embed) {
// Nur ansehen: kein Seitenwechsel (er wuerde zaehlen und das Banner zeigen)
const block = function (e) {
const t = e.target;
if (e.type === 'submit' || (t && t.closest && t.closest('a,area'))) { e.preventDefault(); e.stopPropagation(); }
};
window.addEventListener('click', block, true);
window.addEventListener('submit', block, true);
// Handy und Tablet haben ueberlagernde Scrollleisten; so stimmt die Breite wie auf dem Geraet
const st = document.createElement('style');
st.textContent = 'html{scrollbar-width:none}html::-webkit-scrollbar{display:none}';
(document.head || de).appendChild(st);
start(function (n) { tell('ready', n); }, function () { tell('error', 0); });
return;
}
const host = document.createElement('div');
host.style.position = 'fixed'; host.style.left = '0'; host.style.right = '0'; host.style.top = '0'; host.style.zIndex = '2147483647';
const root = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = ':host{all:initial}.bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:#111;color:#fff;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}button{font:600 14px/1 system-ui,sans-serif;padding:8px 14px;border:2px solid #fff;border-radius:6px;background:#fff;color:#111;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}';
const bar = document.createElement('div');
bar.className = 'bar';
const msg = document.createElement('span');
msg.textContent = 'Heatmap wird geladen ...';
const close = document.createElement('button');
close.setAttribute('type', 'button');
close.textContent = 'Schließen';
bar.appendChild(msg); bar.appendChild(close);
root.appendChild(style); root.appendChild(bar);
document.body.appendChild(host);
function draw() {
const w = Math.max(de.scrollWidth, de.clientWidth, 1);
const h = Math.max(de.scrollHeight, de.clientHeight, 1);
@@ -196,17 +239,22 @@ function main() {
host.remove(); canvas.remove();
}
close.addEventListener('click', stop);
window.addEventListener('resize', draw);
start(function (n) { msg.textContent = 'Heatmap – ' + n + ' Klicks (letzte 30 Tage)'; },
function () { msg.textContent = 'Heatmap-Link abgelaufen oder ungültig'; });
fetch(base + 'heat?token=' + encodeURIComponent(token), { credentials: 'omit' })
.then(function (r) { if (!r.ok) throw new Error('heat'); return r.json(); })
.then(function (j) {
points = (j && Array.isArray(j.points) ? j.points : []).filter(function (p) { return p && p.x >= 0 && p.x <= 1 && p.y >= 0 && p.y <= 1; });
msg.textContent = 'Heatmap – ' + points.length + ' Klicks (letzte 30 Tage)';
draw();
timer = setInterval(redrawIfChanged, 1000);
})
.catch(function () { msg.textContent = 'Heatmap-Link abgelaufen oder ungültig'; });
// Punkte laden, zeichnen und bei Groessenaenderung neu zeichnen
function start(onReady, onError) {
window.addEventListener('resize', draw);
fetch(base + 'heat?token=' + encodeURIComponent(token), { credentials: 'omit' })
.then(function (r) { if (!r.ok) throw new Error('heat'); return r.json(); })
.then(function (j) {
points = (j && Array.isArray(j.points) ? j.points : []).filter(function (p) { return p && p.x >= 0 && p.x <= 1 && p.y >= 0 && p.y <= 1; });
draw();
timer = setInterval(redrawIfChanged, 1000);
onReady(points.length);
})
.catch(function () { onError(); });
}
}
try { main(); } catch (e) { /* nie die Seite stoeren */ }
+132 -35
View File
@@ -6,6 +6,15 @@
const CONSENT_KEY = 'wk-consent';
const CONSENT_MAX_AGE = 183 * 86400000;
const BANNER_VERSION = '2026-10-02';
const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
// Eigene Websites der WEBklar GbR (webklar.com, Skript-Attribut data-variant="eigen"): Sie-Form, WEBklar misst selbst
// (keine Auftragsverarbeitung). Eigene Fassung, damit eine Textaenderung nur dort neu fragt.
const BANNER_VERSION_EIGEN = '2026-10-17';
const BANNER_TEXT_EIGEN = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Ihrer Zustimmung messen wir zusätzlich, von welcher Website Sie kommen, wie lange Sie bleiben, wie weit Sie scrollen und wo Sie klicken (Heatmap), mit einer täglich wechselnden Kennung. Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben. Ihre Zustimmung können Sie jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
const BANNER_LINK_LABEL = 'Datenschutz';
const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
function vpClass(width) {
const w = Number(width);
@@ -86,15 +95,23 @@ function contactKind(a, ownHost) {
return stripWww(host) === stripWww(own || '') ? null : 'out';
}
function readConsent(storage, now) {
// Banner-Fassung je Variante: 'eigen' fuer WEBklars eigene Websites, alles andere ist die Kunden-Fassung.
function bannerFor(variant) {
return variant === 'eigen'
? { version: BANNER_VERSION_EIGEN, text: BANNER_TEXT_EIGEN }
: { version: BANNER_VERSION, text: BANNER_TEXT };
}
function readConsent(storage, now, version = BANNER_VERSION) {
try {
const raw = storage.getItem(CONSENT_KEY);
if (!raw) return null;
const c = JSON.parse(raw);
if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
if (c.bv !== version) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen
const age = now - c.t;
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
return { v: 1, a: c.a, t: c.t };
return { v: 1, a: c.a, t: c.t, bv: c.bv };
} catch (e) { return null; }
}
@@ -113,7 +130,8 @@ function escapeHtml(s) {
}
// WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites.
// Keine Cookies, keine Kennung im Browser. Nur pv ohne Einwilligung; ck/ct/en nur nach Annehmen.
// Keine Cookies, keine Kennung im Browser. Ohne Wahl nur pv {t,site,path}; ref/vp/cx/ck/ct/en nur nach Annehmen;
// nach Ablehnen oder bei GPC/DNT geht nichts mehr raus.
function main() {
const DOC = document;
@@ -127,6 +145,8 @@ function main() {
if (!/^[a-z0-9]{16,40}$/.test(site)) return;
const colorAttr = String(SELF.getAttribute('data-color') || '');
const accent = contrastOk(colorAttr) ? colorAttr : '#111111';
// data-variant="eigen": Banner-Fassung fuer WEBklars eigene Websites (Sie-Form, ohne Auftragsverarbeiter)
const banner = bannerFor(String(SELF.getAttribute('data-variant') || ''));
const src = String(SELF.src || SELF.getAttribute('src') || '').replace(/[?#].*$/, '');
const base = src.replace(/[^/]*$/, '');
const endpoint = base + 'e';
@@ -135,12 +155,16 @@ function main() {
const qs = new URLSearchParams(WIN.location.search);
const heatToken = qs.get('__wk_heat');
if (heatToken) {
// __wk_embed=1: Ansicht im Geraeterahmen des Kundenportals (h.js ohne Leiste, Links gesperrt)
const embed = qs.get('__wk_embed') === '1';
qs.delete('__wk_heat');
qs.delete('__wk_embed');
const rest = qs.toString();
try { WIN.history.replaceState(null, '', WIN.location.pathname + (rest ? '?' + rest : '') + (WIN.location.hash || '')); } catch (e) { /* egal */ }
const s = DOC.createElement('script');
s.setAttribute('src', base + 'h.js');
s.setAttribute('data-token', heatToken);
if (embed) s.setAttribute('data-embed', '1');
(DOC.head || DOC.documentElement).appendChild(s);
return;
}
@@ -149,7 +173,6 @@ function main() {
function send(obj) {
try {
const body = JSON.stringify(obj);
if (navigator.sendBeacon && navigator.sendBeacon(endpoint, body)) return;
fetch(endpoint, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } }).catch(function () {});
} catch (e) { /* egal */ }
}
@@ -157,37 +180,74 @@ function main() {
const vp = () => vpClass(WIN.innerWidth);
const ownHost = () => WIN.location.hostname;
// ---- Seitenaufruf (pv): immer, auch ohne Einwilligung ----
// ---- Widerspruch (GPC/DNT) und Wahl: vor jedem Senden und vor jedem Hook ----
let storage = null;
try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
const dnt = (v) => v === '1' || v === 'yes';
const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
if (optOut) return; // wie Ablehnen: nichts senden, kein Banner, keine Hooks
const stored = readConsent(storage, Date.now(), banner.version);
let memChoice = null;
let tracking = false;
// Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt)
function choiceNow() {
const c = readConsent(storage, Date.now(), banner.version);
return c ? c.a : (memChoice ? memChoice.a : null);
}
const counting = () => choiceNow() !== 'reject';
// ---- Seitenaufruf (pv): ohne Wahl nur Seitenadresse, ref/vp nur nach Annehmen ----
let lastPath = null;
let pvStart = Date.now();
let maxScroll = 0;
let enSent = false;
let pvSent = false;
let cxSent = false;
function sendPv(first) {
if (!counting()) return;
lastPath = cleanPath(WIN.location.pathname);
pvStart = Date.now(); maxScroll = 0; enSent = false;
send({ t: 'pv', site, path: lastPath, ref: first ? refHost(DOC.referrer, ownHost()) : '', vp: vp() });
const ev = { t: 'pv', site, path: lastPath };
if (choiceNow() === 'accept') {
if (first) { ev.ref = refHost(DOC.referrer, ownHost()); cxSent = true; }
ev.vp = vp();
}
pvSent = true;
send(ev);
}
let navTimer = null;
function scheduleNav() {
if (navTimer) return;
if (navTimer || !counting()) return;
navTimer = setTimeout(safe(function () {
navTimer = null;
if (cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
if (!counting() || cleanPath(WIN.location.pathname) === lastPath) return;
// SPA-Seitenwechsel (Router mit preventDefault, onLinkClick greift dann nicht): en der vorigen Seite zuerst
sendEn();
sendPv(false);
}), 300);
}
sendPv(true);
WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); }));
try {
const orig = WIN.history.pushState;
WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; };
WIN.addEventListener('popstate', safe(scheduleNav));
} catch (e) { /* egal */ }
let hooksOn = false;
function ensureHooks() {
if (hooksOn) return;
hooksOn = true;
WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); }));
try {
const orig = WIN.history.pushState;
WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; };
WIN.addEventListener('popstate', safe(scheduleNav));
} catch (e) { /* egal */ }
}
// Nach "Annehmen": Herkunft und Fenstergroesse einmal nachreichen, oder (noch kein pv) pv mit Daten senden
function onAccept() {
ensureHooks();
if (!pvSent) { sendPv(true); return; }
if (cxSent) return;
cxSent = true;
send({ t: 'cx', site, path: lastPath, ref: refHost(DOC.referrer, ownHost()), vp: vp() });
}
if (choiceNow() !== 'reject') { sendPv(true); ensureHooks(); }
// ---- Tracking (nur nach Annehmen erreichbar) ----
let storage = null;
try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
let memChoice = null;
let tracking = false;
const hosts = [];
const scrollPct = () => {
const h = DOC.documentElement.scrollHeight || 1;
@@ -239,8 +299,7 @@ function main() {
// Einwilligung unmittelbar vor jedem Senden pruefen (z.B. in einem anderen Tab widerrufen)
function allowed() {
const c = readConsent(storage, Date.now());
const ok = c ? c.a === 'accept' : !!(memChoice && memChoice.a === 'accept');
const ok = choiceNow() === 'accept';
if (!ok && tracking) stopTracking();
return ok;
}
@@ -264,19 +323,14 @@ function main() {
}
// ---- Einwilligung ----
const dnt = (v) => v === '1' || v === 'yes';
const gpc = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
if (gpc) return; // wie Ablehnen: kein Banner, kein bn, nie Tracking
const stored = readConsent(storage, Date.now());
let bannerHost = null;
let linkHostEl = null;
function choose(a) {
memChoice = null;
try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now() })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ }
try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: banner.version })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ }
send({ t: 'bn', site, a });
if (a === 'accept') startTracking(); else stopTracking();
if (a === 'accept') { startTracking(); onAccept(); } else stopTracking();
closeBanner();
}
@@ -297,6 +351,13 @@ function main() {
'button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' +
'@media (max-width:767px){.bar{display:block;margin:8px;padding:16px;border:1px solid #d0d0d0;border-radius:12px}.r{flex-direction:column;margin-top:12px}button{width:100%}}';
let prevPad = '';
function applyPad() {
if (!bannerHost) return;
const h = Math.ceil(bannerHost.getBoundingClientRect().height || bannerHost.offsetHeight || 0);
DOC.body.style.paddingBottom = h + 'px';
}
const onResize = safe(applyPad);
function openBanner(focus) {
if (bannerHost) return;
const prev = DOC.activeElement;
@@ -306,9 +367,9 @@ function main() {
hosts.push(bannerHost);
const root = bannerHost.attachShadow({ mode: 'open' });
root.appendChild(el('style', {}, CSS));
const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': 'Cookie-Einstellungen' });
const p = el('p', { class: 't' }, 'Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: ');
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, 'Datenschutz'));
const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': BANNER_ARIA });
const p = el('p', { class: 't' }, banner.text);
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL));
const row = el('div', { class: 'r' });
const no = el('button', { type: 'button' }, 'Ablehnen');
const yes = el('button', { type: 'button' }, 'Annehmen');
@@ -318,6 +379,10 @@ function main() {
bar.appendChild(p); bar.appendChild(row);
root.appendChild(bar);
DOC.body.appendChild(bannerHost);
// Platz unten schaffen, damit Footer-Links (Impressum, Datenschutz) erreichbar bleiben
prevPad = DOC.body.style.paddingBottom;
applyPad();
WIN.addEventListener('resize', onResize);
if (linkHostEl) linkHostEl.style.display = 'none';
if (focus === true) no.focus();
}
@@ -327,17 +392,49 @@ function main() {
if (i !== -1) hosts.splice(i, 1);
bannerHost.remove();
bannerHost = null;
WIN.removeEventListener('resize', onResize);
DOC.body.style.paddingBottom = prevPad;
if (linkHostEl) linkHostEl.style.display = '';
}
// Einstellungsknopf: angeknabberter Keks unten rechts, dreht sich langsam (nicht bei reduzierter Bewegung).
// Beim Darueberfahren haelt er an und zeigt "Datenschutz-Einstellungen"; das ist auch sein Name fuer Screenreader.
// Inline-SVG statt data:-Grafik, damit eine strenge CSP der Website (img-src) ihn nicht blockiert.
const SETTINGS_CSS = ':host{all:initial}' +
'.b{position:relative;display:block;width:44px;height:44px;padding:2px;margin:0;border:0;border-radius:50%;background:transparent;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
'.b:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' +
'.c{display:block;width:40px;height:40px;filter:drop-shadow(0 2px 3px rgba(0,0,0,.35));animation:wk-spin 9s linear infinite}' +
'.b:hover .c,.b:focus-visible .c{animation-play-state:paused}' +
'.l{position:absolute;right:52px;top:50%;transform:translate(6px,-50%);padding:7px 12px;border-radius:999px;background:#fff;color:#222;box-shadow:0 2px 10px rgba(0,0,0,.18);font:600 13px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .15s,transform .15s}' +
'.b:hover .l,.b:focus-visible .l{opacity:1;transform:translate(0,-50%)}' +
'@keyframes wk-spin{to{transform:rotate(360deg)}}' +
'@media (prefers-reduced-motion:reduce){.c{animation:none}.l{transition:none}}';
const SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs) {
const n = DOC.createElementNS(SVG_NS, tag);
for (const k in attrs) n.setAttribute(k, attrs[k]);
return n;
}
function cookieSvg() {
const svg = svgEl('svg', { class: 'c', viewBox: '0 0 64 64', 'aria-hidden': 'true', focusable: 'false' });
// Umriss mit Bissspur oben rechts (zwei Bissboegen, dann der grosse Kreis zurueck)
svg.appendChild(svgEl('path', { d: 'M44.84 6A10 10 0 0 0 57.5 19.37A7.5 7.5 0 0 0 60.96 33.5A29 29 0 1 1 44.84 6Z', fill: '#dfa25b', stroke: '#a5652c', 'stroke-width': '3', 'stroke-linejoin': 'round' }));
const chips = [[19, 22, 4.2, 3.3, -20], [31, 14, 3.2, 2.6, 15], [33, 33, 4, 3.2, 30], [46, 41, 3.6, 2.8, -35], [21, 45, 4, 3, 10], [36, 52, 3, 2.4, -10], [11, 34, 2.6, 2.1, 40], [47, 31, 2.4, 2, 0]];
for (const c of chips) svg.appendChild(svgEl('ellipse', { cx: c[0], cy: c[1], rx: c[2], ry: c[3], transform: 'rotate(' + c[4] + ' ' + c[0] + ' ' + c[1] + ')', fill: '#5a3216' }));
const crumbs = [[26, 28, 1.1], [40, 22, 1], [28, 40, 1.2], [14, 26, 0.9], [40, 46, 1]];
for (const c of crumbs) svg.appendChild(svgEl('circle', { cx: c[0], cy: c[1], r: c[2], fill: '#f3c98c' }));
return svg;
}
function addSettingsLink() {
linkHostEl = DOC.createElement('div');
const st = linkHostEl.style;
st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646';
hosts.push(linkHostEl);
const root = linkHostEl.attachShadow({ mode: 'open' });
root.appendChild(el('style', {}, ':host{all:initial}button{font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:6px 10px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}'));
const b = el('button', { type: 'button' }, 'Cookie-Einstellungen');
root.appendChild(el('style', {}, SETTINGS_CSS));
const b = el('button', { type: 'button', class: 'b', 'aria-label': SETTINGS_LABEL });
b.appendChild(el('span', { class: 'l', 'aria-hidden': 'true' }, SETTINGS_LABEL));
b.appendChild(cookieSvg());
b.addEventListener('click', safe(function () { openBanner(true); }));
root.appendChild(b);
DOC.body.appendChild(linkHostEl);
@@ -352,8 +449,8 @@ function main() {
// Einwilligung in anderem Tab geaendert
WIN.addEventListener('storage', safe(function (e) {
if (e.key !== null && e.key !== CONSENT_KEY) return;
const c = readConsent(storage, Date.now());
if (c && c.a === 'accept') startTracking(); else stopTracking();
const c = readConsent(storage, Date.now(), banner.version);
if (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking();
if (c) closeBanner();
}));
+56 -25
View File
@@ -1,4 +1,6 @@
// WEBklar Analyse: Heatmap-Overlay (wird von w.js nur mit gueltigem ?__wk_heat=Token geladen).
// data-embed="1" (nur in einem Rahmen): Ansicht im Geraeterahmen des Kundenportals. Dann ohne Leiste,
// Links und Formulare gesperrt, und die Klickzahl geht per postMessage nur an das Portal (Herkunft von h.js).
function main() {
const me = document.currentScript;
@@ -8,21 +10,13 @@ function main() {
if (!token || !base) return;
const de = document.documentElement;
const host = document.createElement('div');
host.style.position = 'fixed'; host.style.left = '0'; host.style.right = '0'; host.style.top = '0'; host.style.zIndex = '2147483647';
const root = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = ':host{all:initial}.bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:#111;color:#fff;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}button{font:600 14px/1 system-ui,sans-serif;padding:8px 14px;border:2px solid #fff;border-radius:6px;background:#fff;color:#111;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}';
const bar = document.createElement('div');
bar.className = 'bar';
const msg = document.createElement('span');
msg.textContent = 'Heatmap wird geladen ...';
const close = document.createElement('button');
close.setAttribute('type', 'button');
close.textContent = 'Schließen';
bar.appendChild(msg); bar.appendChild(close);
root.appendChild(style); root.appendChild(bar);
document.body.appendChild(host);
let portal = '';
try { portal = new URL(base).origin; } catch (e) { /* ohne Portal keine Einbettung */ }
const embed = !!portal && me.getAttribute('data-embed') === '1' && window.parent && window.parent !== window;
function tell(state, count) {
if (!embed) return;
try { window.parent.postMessage({ wk: 'heat', state: state, count: count }, portal); } catch (e) { /* egal */ }
}
const canvas = document.createElement('canvas');
const cs = canvas.style;
@@ -42,6 +36,38 @@ function main() {
return g.getImageData(0, 0, 256, 1).data;
})();
if (embed) {
// Nur ansehen: kein Seitenwechsel (er wuerde zaehlen und das Banner zeigen)
const block = function (e) {
const t = e.target;
if (e.type === 'submit' || (t && t.closest && t.closest('a,area'))) { e.preventDefault(); e.stopPropagation(); }
};
window.addEventListener('click', block, true);
window.addEventListener('submit', block, true);
// Handy und Tablet haben ueberlagernde Scrollleisten; so stimmt die Breite wie auf dem Geraet
const st = document.createElement('style');
st.textContent = 'html{scrollbar-width:none}html::-webkit-scrollbar{display:none}';
(document.head || de).appendChild(st);
start(function (n) { tell('ready', n); }, function () { tell('error', 0); });
return;
}
const host = document.createElement('div');
host.style.position = 'fixed'; host.style.left = '0'; host.style.right = '0'; host.style.top = '0'; host.style.zIndex = '2147483647';
const root = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = ':host{all:initial}.bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:#111;color:#fff;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}button{font:600 14px/1 system-ui,sans-serif;padding:8px 14px;border:2px solid #fff;border-radius:6px;background:#fff;color:#111;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}';
const bar = document.createElement('div');
bar.className = 'bar';
const msg = document.createElement('span');
msg.textContent = 'Heatmap wird geladen ...';
const close = document.createElement('button');
close.setAttribute('type', 'button');
close.textContent = 'Schließen';
bar.appendChild(msg); bar.appendChild(close);
root.appendChild(style); root.appendChild(bar);
document.body.appendChild(host);
function draw() {
const w = Math.max(de.scrollWidth, de.clientWidth, 1);
const h = Math.max(de.scrollHeight, de.clientHeight, 1);
@@ -82,17 +108,22 @@ function main() {
host.remove(); canvas.remove();
}
close.addEventListener('click', stop);
window.addEventListener('resize', draw);
start(function (n) { msg.textContent = 'Heatmap – ' + n + ' Klicks (letzte 30 Tage)'; },
function () { msg.textContent = 'Heatmap-Link abgelaufen oder ungültig'; });
fetch(base + 'heat?token=' + encodeURIComponent(token), { credentials: 'omit' })
.then(function (r) { if (!r.ok) throw new Error('heat'); return r.json(); })
.then(function (j) {
points = (j && Array.isArray(j.points) ? j.points : []).filter(function (p) { return p && p.x >= 0 && p.x <= 1 && p.y >= 0 && p.y <= 1; });
msg.textContent = 'Heatmap – ' + points.length + ' Klicks (letzte 30 Tage)';
draw();
timer = setInterval(redrawIfChanged, 1000);
})
.catch(function () { msg.textContent = 'Heatmap-Link abgelaufen oder ungültig'; });
// Punkte laden, zeichnen und bei Groessenaenderung neu zeichnen
function start(onReady, onError) {
window.addEventListener('resize', draw);
fetch(base + 'heat?token=' + encodeURIComponent(token), { credentials: 'omit' })
.then(function (r) { if (!r.ok) throw new Error('heat'); return r.json(); })
.then(function (j) {
points = (j && Array.isArray(j.points) ? j.points : []).filter(function (p) { return p && p.x >= 0 && p.x <= 1 && p.y >= 0 && p.y <= 1; });
draw();
timer = setInterval(redrawIfChanged, 1000);
onReady(points.length);
})
.catch(function () { onError(); });
}
}
try { main(); } catch (e) { /* nie die Seite stoeren */ }
+114 -34
View File
@@ -1,6 +1,7 @@
// WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites.
// Keine Cookies, keine Kennung im Browser. Nur pv ohne Einwilligung; ck/ct/en nur nach Annehmen.
import { cleanPath, vpClass, refHost, cssPath, contactKind, linkHost, readConsent, contrastOk, CONSENT_KEY } from './core.js';
// Keine Cookies, keine Kennung im Browser. Ohne Wahl nur pv {t,site,path}; ref/vp/cx/ck/ct/en nur nach Annehmen;
// nach Ablehnen oder bei GPC/DNT geht nichts mehr raus.
import { cleanPath, vpClass, refHost, cssPath, contactKind, linkHost, readConsent, contrastOk, bannerFor, CONSENT_KEY, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from './core.js';
function main() {
const DOC = document;
@@ -14,6 +15,8 @@ function main() {
if (!/^[a-z0-9]{16,40}$/.test(site)) return;
const colorAttr = String(SELF.getAttribute('data-color') || '');
const accent = contrastOk(colorAttr) ? colorAttr : '#111111';
// data-variant="eigen": Banner-Fassung fuer WEBklars eigene Websites (Sie-Form, ohne Auftragsverarbeiter)
const banner = bannerFor(String(SELF.getAttribute('data-variant') || ''));
const src = String(SELF.src || SELF.getAttribute('src') || '').replace(/[?#].*$/, '');
const base = src.replace(/[^/]*$/, '');
const endpoint = base + 'e';
@@ -22,12 +25,16 @@ function main() {
const qs = new URLSearchParams(WIN.location.search);
const heatToken = qs.get('__wk_heat');
if (heatToken) {
// __wk_embed=1: Ansicht im Geraeterahmen des Kundenportals (h.js ohne Leiste, Links gesperrt)
const embed = qs.get('__wk_embed') === '1';
qs.delete('__wk_heat');
qs.delete('__wk_embed');
const rest = qs.toString();
try { WIN.history.replaceState(null, '', WIN.location.pathname + (rest ? '?' + rest : '') + (WIN.location.hash || '')); } catch (e) { /* egal */ }
const s = DOC.createElement('script');
s.setAttribute('src', base + 'h.js');
s.setAttribute('data-token', heatToken);
if (embed) s.setAttribute('data-embed', '1');
(DOC.head || DOC.documentElement).appendChild(s);
return;
}
@@ -36,7 +43,6 @@ function main() {
function send(obj) {
try {
const body = JSON.stringify(obj);
if (navigator.sendBeacon && navigator.sendBeacon(endpoint, body)) return;
fetch(endpoint, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } }).catch(function () {});
} catch (e) { /* egal */ }
}
@@ -44,37 +50,74 @@ function main() {
const vp = () => vpClass(WIN.innerWidth);
const ownHost = () => WIN.location.hostname;
// ---- Seitenaufruf (pv): immer, auch ohne Einwilligung ----
// ---- Widerspruch (GPC/DNT) und Wahl: vor jedem Senden und vor jedem Hook ----
let storage = null;
try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
const dnt = (v) => v === '1' || v === 'yes';
const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
if (optOut) return; // wie Ablehnen: nichts senden, kein Banner, keine Hooks
const stored = readConsent(storage, Date.now(), banner.version);
let memChoice = null;
let tracking = false;
// Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt)
function choiceNow() {
const c = readConsent(storage, Date.now(), banner.version);
return c ? c.a : (memChoice ? memChoice.a : null);
}
const counting = () => choiceNow() !== 'reject';
// ---- Seitenaufruf (pv): ohne Wahl nur Seitenadresse, ref/vp nur nach Annehmen ----
let lastPath = null;
let pvStart = Date.now();
let maxScroll = 0;
let enSent = false;
let pvSent = false;
let cxSent = false;
function sendPv(first) {
if (!counting()) return;
lastPath = cleanPath(WIN.location.pathname);
pvStart = Date.now(); maxScroll = 0; enSent = false;
send({ t: 'pv', site, path: lastPath, ref: first ? refHost(DOC.referrer, ownHost()) : '', vp: vp() });
const ev = { t: 'pv', site, path: lastPath };
if (choiceNow() === 'accept') {
if (first) { ev.ref = refHost(DOC.referrer, ownHost()); cxSent = true; }
ev.vp = vp();
}
pvSent = true;
send(ev);
}
let navTimer = null;
function scheduleNav() {
if (navTimer) return;
if (navTimer || !counting()) return;
navTimer = setTimeout(safe(function () {
navTimer = null;
if (cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
if (!counting() || cleanPath(WIN.location.pathname) === lastPath) return;
// SPA-Seitenwechsel (Router mit preventDefault, onLinkClick greift dann nicht): en der vorigen Seite zuerst
sendEn();
sendPv(false);
}), 300);
}
sendPv(true);
WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); }));
try {
const orig = WIN.history.pushState;
WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; };
WIN.addEventListener('popstate', safe(scheduleNav));
} catch (e) { /* egal */ }
let hooksOn = false;
function ensureHooks() {
if (hooksOn) return;
hooksOn = true;
WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); }));
try {
const orig = WIN.history.pushState;
WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; };
WIN.addEventListener('popstate', safe(scheduleNav));
} catch (e) { /* egal */ }
}
// Nach "Annehmen": Herkunft und Fenstergroesse einmal nachreichen, oder (noch kein pv) pv mit Daten senden
function onAccept() {
ensureHooks();
if (!pvSent) { sendPv(true); return; }
if (cxSent) return;
cxSent = true;
send({ t: 'cx', site, path: lastPath, ref: refHost(DOC.referrer, ownHost()), vp: vp() });
}
if (choiceNow() !== 'reject') { sendPv(true); ensureHooks(); }
// ---- Tracking (nur nach Annehmen erreichbar) ----
let storage = null;
try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
let memChoice = null;
let tracking = false;
const hosts = [];
const scrollPct = () => {
const h = DOC.documentElement.scrollHeight || 1;
@@ -126,8 +169,7 @@ function main() {
// Einwilligung unmittelbar vor jedem Senden pruefen (z.B. in einem anderen Tab widerrufen)
function allowed() {
const c = readConsent(storage, Date.now());
const ok = c ? c.a === 'accept' : !!(memChoice && memChoice.a === 'accept');
const ok = choiceNow() === 'accept';
if (!ok && tracking) stopTracking();
return ok;
}
@@ -151,19 +193,14 @@ function main() {
}
// ---- Einwilligung ----
const dnt = (v) => v === '1' || v === 'yes';
const gpc = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
if (gpc) return; // wie Ablehnen: kein Banner, kein bn, nie Tracking
const stored = readConsent(storage, Date.now());
let bannerHost = null;
let linkHostEl = null;
function choose(a) {
memChoice = null;
try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now() })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ }
try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: banner.version })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ }
send({ t: 'bn', site, a });
if (a === 'accept') startTracking(); else stopTracking();
if (a === 'accept') { startTracking(); onAccept(); } else stopTracking();
closeBanner();
}
@@ -184,6 +221,13 @@ function main() {
'button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' +
'@media (max-width:767px){.bar{display:block;margin:8px;padding:16px;border:1px solid #d0d0d0;border-radius:12px}.r{flex-direction:column;margin-top:12px}button{width:100%}}';
let prevPad = '';
function applyPad() {
if (!bannerHost) return;
const h = Math.ceil(bannerHost.getBoundingClientRect().height || bannerHost.offsetHeight || 0);
DOC.body.style.paddingBottom = h + 'px';
}
const onResize = safe(applyPad);
function openBanner(focus) {
if (bannerHost) return;
const prev = DOC.activeElement;
@@ -193,9 +237,9 @@ function main() {
hosts.push(bannerHost);
const root = bannerHost.attachShadow({ mode: 'open' });
root.appendChild(el('style', {}, CSS));
const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': 'Cookie-Einstellungen' });
const p = el('p', { class: 't' }, 'Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: ');
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, 'Datenschutz'));
const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': BANNER_ARIA });
const p = el('p', { class: 't' }, banner.text);
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL));
const row = el('div', { class: 'r' });
const no = el('button', { type: 'button' }, 'Ablehnen');
const yes = el('button', { type: 'button' }, 'Annehmen');
@@ -205,6 +249,10 @@ function main() {
bar.appendChild(p); bar.appendChild(row);
root.appendChild(bar);
DOC.body.appendChild(bannerHost);
// Platz unten schaffen, damit Footer-Links (Impressum, Datenschutz) erreichbar bleiben
prevPad = DOC.body.style.paddingBottom;
applyPad();
WIN.addEventListener('resize', onResize);
if (linkHostEl) linkHostEl.style.display = 'none';
if (focus === true) no.focus();
}
@@ -214,17 +262,49 @@ function main() {
if (i !== -1) hosts.splice(i, 1);
bannerHost.remove();
bannerHost = null;
WIN.removeEventListener('resize', onResize);
DOC.body.style.paddingBottom = prevPad;
if (linkHostEl) linkHostEl.style.display = '';
}
// Einstellungsknopf: angeknabberter Keks unten rechts, dreht sich langsam (nicht bei reduzierter Bewegung).
// Beim Darueberfahren haelt er an und zeigt "Datenschutz-Einstellungen"; das ist auch sein Name fuer Screenreader.
// Inline-SVG statt data:-Grafik, damit eine strenge CSP der Website (img-src) ihn nicht blockiert.
const SETTINGS_CSS = ':host{all:initial}' +
'.b{position:relative;display:block;width:44px;height:44px;padding:2px;margin:0;border:0;border-radius:50%;background:transparent;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
'.b:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' +
'.c{display:block;width:40px;height:40px;filter:drop-shadow(0 2px 3px rgba(0,0,0,.35));animation:wk-spin 9s linear infinite}' +
'.b:hover .c,.b:focus-visible .c{animation-play-state:paused}' +
'.l{position:absolute;right:52px;top:50%;transform:translate(6px,-50%);padding:7px 12px;border-radius:999px;background:#fff;color:#222;box-shadow:0 2px 10px rgba(0,0,0,.18);font:600 13px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .15s,transform .15s}' +
'.b:hover .l,.b:focus-visible .l{opacity:1;transform:translate(0,-50%)}' +
'@keyframes wk-spin{to{transform:rotate(360deg)}}' +
'@media (prefers-reduced-motion:reduce){.c{animation:none}.l{transition:none}}';
const SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs) {
const n = DOC.createElementNS(SVG_NS, tag);
for (const k in attrs) n.setAttribute(k, attrs[k]);
return n;
}
function cookieSvg() {
const svg = svgEl('svg', { class: 'c', viewBox: '0 0 64 64', 'aria-hidden': 'true', focusable: 'false' });
// Umriss mit Bissspur oben rechts (zwei Bissboegen, dann der grosse Kreis zurueck)
svg.appendChild(svgEl('path', { d: 'M44.84 6A10 10 0 0 0 57.5 19.37A7.5 7.5 0 0 0 60.96 33.5A29 29 0 1 1 44.84 6Z', fill: '#dfa25b', stroke: '#a5652c', 'stroke-width': '3', 'stroke-linejoin': 'round' }));
const chips = [[19, 22, 4.2, 3.3, -20], [31, 14, 3.2, 2.6, 15], [33, 33, 4, 3.2, 30], [46, 41, 3.6, 2.8, -35], [21, 45, 4, 3, 10], [36, 52, 3, 2.4, -10], [11, 34, 2.6, 2.1, 40], [47, 31, 2.4, 2, 0]];
for (const c of chips) svg.appendChild(svgEl('ellipse', { cx: c[0], cy: c[1], rx: c[2], ry: c[3], transform: 'rotate(' + c[4] + ' ' + c[0] + ' ' + c[1] + ')', fill: '#5a3216' }));
const crumbs = [[26, 28, 1.1], [40, 22, 1], [28, 40, 1.2], [14, 26, 0.9], [40, 46, 1]];
for (const c of crumbs) svg.appendChild(svgEl('circle', { cx: c[0], cy: c[1], r: c[2], fill: '#f3c98c' }));
return svg;
}
function addSettingsLink() {
linkHostEl = DOC.createElement('div');
const st = linkHostEl.style;
st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646';
hosts.push(linkHostEl);
const root = linkHostEl.attachShadow({ mode: 'open' });
root.appendChild(el('style', {}, ':host{all:initial}button{font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:6px 10px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}'));
const b = el('button', { type: 'button' }, 'Cookie-Einstellungen');
root.appendChild(el('style', {}, SETTINGS_CSS));
const b = el('button', { type: 'button', class: 'b', 'aria-label': SETTINGS_LABEL });
b.appendChild(el('span', { class: 'l', 'aria-hidden': 'true' }, SETTINGS_LABEL));
b.appendChild(cookieSvg());
b.addEventListener('click', safe(function () { openBanner(true); }));
root.appendChild(b);
DOC.body.appendChild(linkHostEl);
@@ -239,8 +319,8 @@ function main() {
// Einwilligung in anderem Tab geaendert
WIN.addEventListener('storage', safe(function (e) {
if (e.key !== null && e.key !== CONSENT_KEY) return;
const c = readConsent(storage, Date.now());
if (c && c.a === 'accept') startTracking(); else stopTracking();
const c = readConsent(storage, Date.now(), banner.version);
if (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking();
if (c) closeBanner();
}));
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
// CSV-Export einer Website (Tageswerte, Einzelereignisse, Klicks). Öffnet die Datenbank nur lesend.
//
// Ablauf (Runbook): Im Container webklar-analytics ausführen (Code liegt dort unter /analytics, schreibbar ist nur /srv/webklar-analytics):
// docker exec webklar-analytics node /analytics/scripts/export-site.mjs <siteKey> --out /srv/webklar-analytics/exports/<siteKey>
// Die CSV-Dateien enthalten Besucher-Hashes (Rechte 0600) und müssen dem Kunden als Kopie innerhalb von zwei Wochen
// übergeben werden (AVV Nr. 9); danach den Export-Ordner wieder löschen.
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const RE_KEY = /^[a-z0-9]{16,40}$/;
const iso = (ts) => new Date(ts).toISOString();
function cell(v) {
if (v === null || v === undefined) return '';
let s = String(v);
if (typeof v === 'string' && /^[=+\-@\t\r]/.test(s)) s = `'${s}`;
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
const csv = (head, rows) => [head, ...rows].map((r) => r.map(cell).join(',')).join('\r\n') + '\r\n';
export function exportSite({ dbPath, siteKey, outDir }) {
if (!RE_KEY.test(siteKey || '')) throw new Error('Ungültiger Website-Schlüssel');
const db = new DatabaseSync(dbPath, { readOnly: true });
try {
if (!db.prepare('SELECT 1 FROM sites WHERE siteKey = ?').get(siteKey)) throw new Error('Unbekannte Website');
const daily = db.prepare('SELECT day, dim, key, count FROM daily WHERE site = ? ORDER BY day, dim, key').all(siteKey);
const ev = db.prepare('SELECT ts, type, path, vp, ref, country, device, visitor, sec, scroll, kind, host FROM events_raw WHERE site = ? ORDER BY ts, id').all(siteKey);
const cl = db.prepare('SELECT ts, path, vp, x, y, sel FROM clicks WHERE site = ? ORDER BY ts, id').all(siteKey);
mkdirSync(outDir, { recursive: true });
const files = {
[`${siteKey}-tageswerte.csv`]: csv(['day', 'dim', 'key', 'count'], daily.map((r) => [r.day, r.dim, r.key, r.count])),
[`${siteKey}-ereignisse.csv`]: csv(['time', 'type', 'path', 'vp', 'ref', 'country', 'device', 'visitor', 'sec', 'scroll', 'kind', 'host'],
ev.map((r) => [iso(r.ts), r.type, r.path, r.vp, r.ref, r.country, r.device, r.visitor, r.sec, r.scroll, r.kind, r.host])),
[`${siteKey}-klicks.csv`]: csv(['time', 'path', 'vp', 'x', 'y', 'sel'], cl.map((r) => [iso(r.ts), r.path, r.vp, r.x, r.y, r.sel])),
};
for (const [n, c] of Object.entries(files)) writeFileSync(join(outDir, n), c, { encoding: 'utf8', mode: 0o600 });
return Object.keys(files).map((n) => join(outDir, n));
} finally { db.close(); }
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const args = process.argv.slice(2);
const opt = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
const siteKey = args.find((a, i) => !a.startsWith('--') && !(i > 0 && args[i - 1].startsWith('--')));
try {
const files = exportSite({ dbPath: opt('--db', '/srv/webklar-analytics/analytics.db'), siteKey, outDir: opt('--out', '.') });
console.log(files.join('\n'));
} catch (e) {
console.error(`Export fehlgeschlagen: ${e.message}`);
process.exit(1);
}
}
+23 -1
View File
@@ -9,7 +9,10 @@ import crypto from 'node:crypto';
const DEFAULT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist');
const FILES = { '/a/w.js': 'w.js', '/a/h.js': 'h.js' };
export function createAssets({ distDir = DEFAULT_DIR, log = console } = {}) {
const RE_KEY = /^[a-z0-9]{16,40}$/;
const EMPTY = Buffer.from('/* WEBklar Analyse: aus */\n');
export function createAssets({ distDir = DEFAULT_DIR, log = console, isActive = () => true } = {}) {
const assets = new Map();
for (const [path, file] of Object.entries(FILES)) {
try {
@@ -28,6 +31,25 @@ export function createAssets({ distDir = DEFAULT_DIR, log = console } = {}) {
res.end();
return true;
}
if (pathname === '/a/w.js') {
let key = '';
try { key = new URL(req.url, 'http://x').searchParams.get('s') || ''; } catch { /* leer */ }
let on = false;
try { on = RE_KEY.test(key) && !!isActive(key); } catch (e) { log.error('Analytics: isActive:', e.message); }
if (!on) {
res.writeHead(200, {
'Content-Type': 'text/javascript; charset=utf-8',
'Cache-Control': 'public, max-age=60',
Vary: 'Accept-Encoding',
'X-Content-Type-Options': 'nosniff',
'Cross-Origin-Resource-Policy': 'cross-origin',
'Access-Control-Allow-Origin': '*',
'Content-Length': EMPTY.length,
});
res.end(req.method === 'HEAD' ? undefined : EMPTY);
return true;
}
}
const a = assets.get(pathname);
if (!a) {
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+1
View File
@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS agg_state (
export function openDb(path) {
const db = new DatabaseSync(path);
if (path !== ':memory:') db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA secure_delete = ON');
db.exec(SCHEMA);
return db;
}
+3 -3
View File
@@ -1,7 +1,7 @@
// GET /a/heat?token= (öffentlicher Listener): Klickpunkte für das Heatmap-Overlay.
// Token (R3): <b64url(JSON {s,p,v,e})>.<hex HMAC-SHA256(heatSecret, b64part)>, e = Ablauf in Unix-Sekunden.
import crypto from 'node:crypto';
import { hostOf } from './ingest.js';
import { hostOf, cleanPath } from './ingest.js';
const SITE_KEY = /^[a-z0-9]{16,40}$/;
const MAX_AHEAD = 30 * 60; // Sekunden
@@ -35,7 +35,7 @@ export function verifyHeatToken(token, secret, nowMs, { ignoreExpiry = false } =
export function createHeat({ db, config = {}, now }) {
const clock = now || Date.now;
const getSite = db.prepare('SELECT domains, active FROM sites WHERE siteKey = ?');
const getClicks = db.prepare('SELECT x, y FROM clicks WHERE site = ? AND path = ? AND vp = ? AND ts >= ? ORDER BY ts DESC LIMIT ?');
const getClicks = db.prepare('SELECT x, y FROM clicks WHERE site = ? AND path = ? AND vp = ? AND ts >= ? ORDER BY ts DESC, id DESC LIMIT ?');
// CORS: nur die eigenen https-Domains der Website (das Overlay läuft auf der Kundenseite).
function cors(res, req, domains) {
@@ -79,7 +79,7 @@ export function createHeat({ db, config = {}, now }) {
}
cors(res, req, domainsOf(site));
const since = clock() - CLICK_DAYS * 86400 * 1000;
const points = getClicks.all(t.siteKey, t.path, t.vp, since, POINTS).map((r) => ({ x: r.x, y: r.y }));
const points = getClicks.all(t.siteKey, cleanPath(t.path) || t.path, t.vp, since, POINTS).map((r) => ({ x: r.x, y: r.y }));
return end(200, { points });
};
}
+23 -7
View File
@@ -10,6 +10,7 @@ const HOUR = 3600 * 1000;
const MINUTE = 60 * 1000;
const FIELDS = Object.assign(Object.create(null), {
pv: ['t', 'site', 'path', 'ref', 'vp'],
cx: ['t', 'site', 'path', 'ref', 'vp'],
bn: ['t', 'site', 'a'],
en: ['t', 'site', 'path', 'vp', 'sec', 'scroll'],
ck: ['t', 'site', 'path', 'vp', 'x', 'y', 'sel'],
@@ -55,11 +56,19 @@ function anyHostOf(value) {
try { return new URL(value).hostname.toLowerCase(); } catch { return ''; }
}
function cleanPath(p) {
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Kennungsartige Segmente (lange Ziffernfolge, UUID, E-Mail) werden nie gespeichert.
function looksLikeId(seg) {
let d = seg;
try { d = decodeURIComponent(seg); } catch { /* Rohwert */ }
return /\d{6,}/.test(d) || UUID.test(d) || d.includes('@');
}
export function cleanPath(p) {
if (typeof p !== 'string') return null;
const s = p.split(/[?#]/)[0];
if (!s.startsWith('/') || s.length > 300 || /[\u0000-\u001f\u007f]/.test(s)) return null;
return s;
return s.split('/').map((seg) => (looksLikeId(seg) ? ':id' : seg)).join('/');
}
function cleanHost(h) {
@@ -134,12 +143,19 @@ export function createIngest({ db, geo = { lookup: () => '' }, salt, now = Date.
}
const path = cleanPath(e.path);
if (!path) return;
if (e.t === 'pv') {
const hour = Math.floor(t / HOUR) * HOUR;
if (e.t === 'cx') {
const vp = cleanVp(e.vp);
if (!vp) return;
insEvent.run(site, hour, 'cx', path, vp, cleanRef(e.ref, domains), null, null, null, null, null, null, null);
return;
}
if (e.t === 'pv') {
const vp = e.vp === undefined ? null : cleanVp(e.vp);
if (e.vp !== undefined && !vp) return;
const c = String(geo.lookup(ip) || '');
const country = /^[A-Za-z]{2}$/.test(c) ? c.toUpperCase() : '';
insEvent.run(site, t, 'pv', path, vp, cleanRef(e.ref, domains), country, classifyUa(ua).device, null, null, null, null, null);
insEvent.run(site, hour, 'pv', path, vp, vp ? cleanRef(e.ref, domains) : null, country, classifyUa(ua).device, null, null, null, null, null);
return;
}
if (e.t === 'ck') {
@@ -148,7 +164,7 @@ export function createIngest({ db, geo = { lookup: () => '' }, salt, now = Date.
const y = num(e.y);
if (!vp || x === null || y === null) return;
const sel = typeof e.sel === 'string' ? e.sel.slice(0, 200) : null;
insClick.run(site, t, path, vp, Math.round(clamp(x, 0, 1) * 1000) / 1000, Math.round(clamp(y, 0, 1) * 1000) / 1000, sel);
insClick.run(site, hour, path, vp, Math.round(clamp(x, 0, 1) * 1000) / 1000, Math.round(clamp(y, 0, 1) * 1000) / 1000, sel);
return;
}
const visitor = salt.visitorId(site, ip, ua);
@@ -157,14 +173,14 @@ export function createIngest({ db, geo = { lookup: () => '' }, salt, now = Date.
const sec = num(e.sec);
const scroll = num(e.scroll);
if (!vp || sec === null || scroll === null) return;
insEvent.run(site, t, 'en', path, vp, null, null, null, visitor, Math.floor(clamp(sec, 0, 3600)), Math.floor(clamp(scroll, 0, 100)), null, null);
insEvent.run(site, hour, 'en', path, vp, null, null, null, visitor, Math.floor(clamp(sec, 0, 3600)), Math.floor(clamp(scroll, 0, 100)), null, null);
return;
}
if (e.t === 'ct') {
if (!['tel', 'mail', 'wa', 'out'].includes(e.kind)) return;
let host = null;
if (e.kind === 'out') { host = cleanHost(e.host); if (!host) return; }
insEvent.run(site, t, 'ct', path, null, null, null, null, visitor, null, null, e.kind, host);
insEvent.run(site, hour, 'ct', path, null, null, null, null, visitor, null, null, e.kind, host);
}
}
+37 -5
View File
@@ -8,6 +8,17 @@ const WALL_FMT = new Intl.DateTimeFormat('en-GB', {
const DAY_MS = 86400 * 1000;
export const RAW_DAYS = 30;
export const OFF_DAYS = 60;
export const DAILY_MONTHS = 25;
// Berliner Tag von `now` minus DAILY_MONTHS Kalendermonate (Monatsende auf den letzten Tag des Zielmonats begrenzt).
export function dailyCutoffDay(now) {
const [y, m, d] = DAY_FMT.format(now).split('-').map(Number);
const idx = y * 12 + (m - 1) - DAILY_MONTHS;
const ty = Math.floor(idx / 12);
const tm = idx % 12;
const last = new Date(Date.UTC(ty, tm + 1, 0)).getUTCDate();
return `${String(ty).padStart(4, '0')}-${String(tm + 1).padStart(2, '0')}-${String(Math.min(d, last)).padStart(2, '0')}`;
}
// Kalendertag (YYYY-MM-DD) in Europe/Berlin.
export const dayOf = (ts) => DAY_FMT.format(ts);
@@ -19,8 +30,11 @@ export function aggregateRows(rows) {
const visitors = new Set();
for (const r of rows) {
if (r.type === 'pv') {
add('pv_total', ''); add('pv_path', r.path || ''); add('pv_ref', r.ref || '');
add('pv_total', ''); add('pv_path', r.path || '');
if (r.vp != null) add('pv_ref', r.ref || ''); // nur Besucher mit Einwilligung; sonst kommt pv_ref aus cx
add('pv_country', r.country || ''); add('pv_device', r.device || '');
} else if (r.type === 'cx') {
add('pv_ref', r.ref || '');
} else if (r.type === 'en') {
add('en_count', ''); add('en_sec_sum', '', r.sec || 0); add('scroll_sum', '', r.scroll || 0);
if (r.visitor) visitors.add(r.visitor);
@@ -35,17 +49,17 @@ export function aggregateRows(rows) {
return [...m].filter(([, n]) => n > 0).map(([k, n]) => { const [dim, key] = k.split('\u0000'); return [dim, key, n]; });
}
export function runNightly(db, { now = Date.now() } = {}) {
export function runNightly(db, { now = Date.now(), log = console } = {}) {
const today = dayOf(now);
const rawCut = now - RAW_DAYS * DAY_MS;
const offCut = new Date(now - OFF_DAYS * DAY_MS).toISOString();
const siteKeys = db.prepare(`SELECT DISTINCT site FROM events_raw UNION SELECT siteKey FROM sites`).all().map((r) => r.site);
const selRaw = db.prepare('SELECT ts, type, path, ref, country, device, visitor, sec, scroll, kind FROM events_raw WHERE site = ? AND ts < ? ORDER BY ts');
const selRaw = db.prepare('SELECT ts, type, path, vp, ref, country, device, visitor, sec, scroll, kind FROM events_raw WHERE site = ? AND ts < ? ORDER BY ts');
const selDone = db.prepare('SELECT day FROM agg_state WHERE site = ?');
const insDaily = db.prepare(`INSERT INTO daily (site, day, dim, key, count) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(site, day, dim, key) DO UPDATE SET count = count + excluded.count`);
const insDone = db.prepare('INSERT OR IGNORE INTO agg_state (site, day) VALUES (?, ?)');
const stats = { aggregated: 0, sitesDeleted: 0 };
const stats = { aggregated: 0, sitesDeleted: 0, dailyDeleted: 0, orphansMarked: 0, orphanRowsDeleted: 0 };
const tx = (fn) => {
db.exec('BEGIN');
@@ -82,6 +96,24 @@ export function runNightly(db, { now = Date.now() } = {}) {
stats.sitesDeleted++;
});
}
// Tageswerte älter als 25 Monate (alle Websites)
tx(() => {
const cut = dailyCutoffDay(now);
stats.dailyDeleted = Number(db.prepare('DELETE FROM daily WHERE day < ?').run(cut).changes);
db.prepare('DELETE FROM agg_state WHERE day < ?').run(cut);
});
// Ausgeschaltete Websites ohne offSince: Frist beginnt bei Entdeckung.
stats.orphansMarked = Number(db.prepare('UPDATE sites SET offSince = ? WHERE active = 0 AND offSince IS NULL').run(new Date(now).toISOString()).changes);
// Zeilen ohne Website-Eintrag
tx(() => {
for (const t of ['events_raw', 'clicks', 'daily', 'agg_state']) {
stats.orphanRowsDeleted += Number(db.prepare(`DELETE FROM ${t} WHERE site NOT IN (SELECT siteKey FROM sites)`).run().changes);
}
});
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) { log.error('Analytics-Nachtjob: wal_checkpoint:', e.message); }
return stats;
}
@@ -110,7 +142,7 @@ export function scheduleNightly(db, { startDelayMs = 60000, log = console, now =
const run = () => {
try {
const r = job(db, { now: now() });
log.log(`Analytics-Nachtjob: ${r.aggregated} Tage verdichtet, ${r.sitesDeleted} Websites gelöscht`);
log.log(`Analytics-Nachtjob: ${r.aggregated} Tage verdichtet, ${r.sitesDeleted} Websites gelöscht, ${r.dailyDeleted} Tageswerte > ${DAILY_MONTHS} Monate gelöscht, ${r.orphansMarked} Websites markiert, ${r.orphanRowsDeleted} verwaiste Zeilen gelöscht`);
} catch (e) { log.error('Analytics-Nachtjob:', e.message); }
};
const arm = (delay, fn) => { if (!stopped) timer = timers.setTimeout(fn, delay); };
+13 -1
View File
@@ -61,7 +61,7 @@ function validateSite(b) {
function createHandlerServer({ config, db, role, geo, salt, now, onSitesChanged }) {
const ingest = role === 'public' ? createIngest({ db, geo, salt: salt || createSalt({ now }), now }) : null;
const heat = role === 'public' ? createHeat({ db, config, now }) : null;
const assets = role === 'public' ? createAssets({ distDir: config.distDir }) : null;
const assets = role === 'public' ? createAssets({ distDir: config.distDir, isActive: (k) => !!db.prepare('SELECT 1 FROM sites WHERE siteKey = ? AND active = 1').get(k) }) : null;
const clock = now || Date.now;
const server = http.createServer(async (req, res) => {
try {
@@ -110,6 +110,18 @@ function createHandlerServer({ config, db, role, geo, salt, now, onSitesChanged
return send(res, 200, { ok: true });
}
// Beenden ohne Domainliste: active=0 und offSince einmalig (Portal: stopSite)
if (role === 'admin' && method === 'POST' && parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'sites' && parts.length === 5 && parts[4] === 'off') {
if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' });
const key = parts[3];
if (!SITE_KEY.test(key)) return send(res, 400, { error: 'Ungültiger Website-Schlüssel' });
const old = db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(key);
if (!old) return send(res, 404, { error: 'Website nicht gefunden' });
db.prepare('UPDATE sites SET active = 0, offSince = ? WHERE siteKey = ?').run(old.offSince || new Date().toISOString(), key);
onSitesChanged?.();
return send(res, 200, { ok: true });
}
if (role === 'admin' && method === 'GET' && parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'stats' && parts.length === 4) {
if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' });
const key = parts[3];
+95 -27
View File
@@ -1,69 +1,137 @@
// Statistik für das Portal: Vergangenheit aus `daily`, noch nicht verdichtete Tage live aus den Rohdaten.
import { dayOf, aggregateRows } from './jobs.js';
const DAY_MS = 86400 * 1000;
const TOP = 20;
// Liste der Kalendertage (YYYY-MM-DD), oldest first, endend am Berlin-Tag von `now`.
function dayList(now, range) {
const [y, m, d] = dayOf(now).split('-').map(Number);
// Kalendertag (YYYY-MM-DD) um `n` Tage verschoben, rein kalendarisch (UTC-Datumsrechnung, keine Zeitumstellung).
function shiftDay(day, n) {
const [y, m, d] = day.split('-').map(Number);
return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10);
}
// Liste der Kalendertage (YYYY-MM-DD), oldest first, endend am Tag `endDay`.
function dayList(endDay, range) {
const days = [];
for (let i = range - 1; i >= 0; i--) days.push(new Date(Date.UTC(y, m - 1, d - i)).toISOString().slice(0, 10));
for (let i = range - 1; i >= 0; i--) days.push(shiftDay(endDay, -i));
return days;
}
const top = (map) => [...map].map(([key, count]) => ({ key, count })).filter((e) => e.count > 0)
.sort((a, b) => b.count - a.count || (a.key < b.key ? -1 : 1)).slice(0, TOP);
export function computeStats(db, site, range, now = Date.now()) {
const days = dayList(now, range);
// Sammelt die Zähler für eine zusammenhängende Tagesliste: verdichtete Tage aus `daily`,
// Tage ohne agg_state (heute, ggf. gestern vor 02:30) live aus den Rohdaten. Kein Tag zählt doppelt.
function collect(db, site, days, now) {
const first = days[0];
const last = days[days.length - 1];
const inRange = new Set(days);
const pvByDay = new Map(days.map((d) => [d, 0]));
const perDay = new Map(days.map((d) => [d, { pv: 0, visitors: 0, contacts: 0, en: 0, sec: 0, scroll: 0 }]));
const dims = {};
const bump = (dim, key, n) => {
const apply = (day, dim, key, n) => {
const t = perDay.get(day);
if (dim === 'pv_total') t.pv += n;
else if (dim === 'visitors') t.visitors += n;
else if (dim === 'ct_kind') t.contacts += n;
else if (dim === 'en_count') t.en += n;
else if (dim === 'en_sec_sum') t.sec += n;
else if (dim === 'scroll_sum') t.scroll += n;
if (!dims[dim]) dims[dim] = new Map();
dims[dim].set(key, (dims[dim].get(key) || 0) + n);
};
const apply = (day, dim, key, n) => {
if (dim === 'pv_total') pvByDay.set(day, pvByDay.get(day) + n);
bump(dim, key, n);
};
const done = new Set(db.prepare('SELECT day FROM agg_state WHERE site = ? AND day >= ?').all(site, first).map((r) => r.day));
for (const r of db.prepare('SELECT day, dim, key, count FROM daily WHERE site = ? AND day >= ?').all(site, first)) {
const done = new Set(db.prepare('SELECT day FROM agg_state WHERE site = ? AND day >= ? AND day <= ?').all(site, first, last).map((r) => r.day));
for (const r of db.prepare('SELECT day, dim, key, count FROM daily WHERE site = ? AND day >= ? AND day <= ?').all(site, first, last)) {
if (inRange.has(r.day)) apply(r.day, r.dim, r.key, r.count);
}
// Tage ohne agg_state (heute, ggf. gestern vor 02:30) live aus den Rohdaten
// Ein Berliner Tag beginnt 22:00/23:00 UTC des Vortags: zwei Tage Rand reichen auf beiden Seiten, danach filtert dayOf
const [fy, fm, fd] = first.split('-').map(Number);
const [ly, lm, ld] = last.split('-').map(Number);
const lo = Date.UTC(fy, fm - 1, fd - 2);
const hi = Math.min(now, Date.UTC(ly, lm - 1, ld + 2));
const live = new Map();
const from = now - (range + 1) * DAY_MS;
for (const r of db.prepare('SELECT ts, type, path, ref, country, device, visitor, sec, scroll, kind FROM events_raw WHERE site = ? AND ts >= ? AND ts <= ? ORDER BY ts')
.all(site, from, now)) {
for (const r of db.prepare('SELECT ts, type, path, vp, ref, country, device, visitor, sec, scroll, kind FROM events_raw WHERE site = ? AND ts >= ? AND ts <= ? ORDER BY ts')
.all(site, lo, hi)) {
const d = dayOf(r.ts);
if (!inRange.has(d) || done.has(d)) continue;
if (!live.has(d)) live.set(d, []);
live.get(d).push(r);
}
for (const [day, rows] of live) for (const [dim, key, n] of aggregateRows(rows)) apply(day, dim, key, n);
return { days, perDay, dims };
}
const avg = (sum, n) => (n ? Math.round(sum / n) : null);
// Formt gesammelte Zähler zu banner + totals + series (gleiche Form für aktuellen Zeitraum und Vorzeitraum).
function shape({ days, perDay, dims }) {
const sum = (dim) => [...(dims[dim] || new Map()).values()].reduce((a, b) => a + b, 0);
const banner = { shown: sum('bn_shown'), accept: sum('bn_accept'), reject: sum('bn_reject') };
const decided = banner.accept + banner.reject;
const contacts = top(dims.ct_kind || new Map());
const en = sum('en_count');
return {
banner,
totals: {
pv: sum('pv_total'),
// Summe der Tages-Besucher (kein tagesübergreifender Abgleich möglich: Salz wechselt täglich)
visitors: sum('visitors'),
contacts: sum('ct_kind'),
consentRate: decided ? banner.accept / decided : 0,
engaged: en,
avgSec: avg(sum('en_sec_sum'), en),
avgScroll: avg(sum('scroll_sum'), en),
},
series: days.map((day) => {
const t = perDay.get(day);
return { day, pv: t.pv, visitors: t.visitors, contacts: t.contacts, avgSec: avg(t.sec, t.en) };
}),
};
}
// Frühester Berliner Tag mit Daten dieser Website ('' = keine): kleinster Tag aus daily und events_raw.
function firstDayOf(db, site) {
const fromDaily = db.prepare('SELECT MIN(day) AS d FROM daily WHERE site = ?').get(site)?.d || '';
const ts = db.prepare('SELECT MIN(ts) AS t FROM events_raw WHERE site = ?').get(site)?.t;
const fromRaw = ts == null ? '' : dayOf(ts);
if (!fromDaily) return fromRaw;
if (!fromRaw) return fromDaily;
return fromDaily < fromRaw ? fromDaily : fromRaw;
}
// Der Zeitraum sind die `range` abgeschlossenen Kalendertage, die gestern enden (wie GA4 "Letzte 28 Tage"): Der
// angebrochene heutige Tag würde gegen einen vollen Vorzeitraum als Einbruch erscheinen. Heute steht getrennt in `today`.
export function computeStats(db, site, range, now = Date.now()) {
const todayDay = dayOf(now);
const days = dayList(shiftDay(todayDay, -1), range);
const cur = collect(db, site, days, now);
const { banner, totals, series } = shape(cur);
// Heute bis jetzt: live aus den Rohdaten (der Tag ist noch nicht verdichtet)
const t = shape(collect(db, site, [todayDay], now)).series[0];
// Vorzeitraum: die `range` Kalendertage direkt vor dem aktuellen Zeitraum (kalendarisch, nie now - range * 24 h)
const prevDays = dayList(shiftDay(days[0], -1), range);
const prevCollected = collect(db, site, prevDays, now);
const prev = shape(prevCollected);
const firstDay = firstDayOf(db, site);
return {
range,
from: days[0],
to: days[days.length - 1],
firstDay,
totals,
series,
today: { day: todayDay, pv: t.pv, visitors: t.visitors, contacts: t.contacts },
pages: top(cur.dims.pv_path || new Map()),
refs: top(cur.dims.pv_ref || new Map()),
countries: top(cur.dims.pv_country || new Map()),
devices: top(cur.dims.pv_device || new Map()),
contacts: top(cur.dims.ct_kind || new Map()),
banner,
previous: {
from: prevDays[0],
to: prevDays[prevDays.length - 1],
complete: firstDay !== '' && firstDay <= prevDays[0],
totals: prev.totals,
series: prev.series,
contacts: top(prevCollected.dims.ct_kind || new Map()),
},
series: days.map((day) => ({ day, pv: pvByDay.get(day) })),
pages: top(dims.pv_path || new Map()),
refs: top(dims.pv_ref || new Map()),
countries: top(dims.pv_country || new Map()),
devices: top(dims.pv_device || new Map()),
contacts,
banner,
};
}
+50 -6
View File
@@ -11,8 +11,13 @@ import { createPublicServer, createAdminServer } from '../src/server.js';
const DIST = new URL('../client/dist/', import.meta.url);
const KEY = 'abcdef0123456789abcd';
const OFFKEY = 'offoffoffoffoffoffoff';
async function start(config = {}) {
const server = createPublicServer({ config, db: openDb(':memory:') });
const db = openDb(':memory:');
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)').run(KEY, 'p', '[]');
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,0)').run(OFFKEY, 'p', '[]');
const server = createPublicServer({ config, db });
await new Promise((r) => server.listen(0, '127.0.0.1', r));
return { base: `http://127.0.0.1:${server.address().port}`, close: () => server.close() };
}
@@ -35,7 +40,7 @@ for (const file of ['w.js', 'h.js']) {
const s = await start();
const want = readFileSync(new URL(file, DIST));
const etag = `"${crypto.createHash('sha256').update(want).digest('hex')}"`;
const id = await raw(s.base, `/a/${file}`, { 'Accept-Encoding': 'identity' });
const id = await raw(s.base, `/a/${file}${file === 'w.js' ? `?s=${KEY}` : ''}`, { 'Accept-Encoding': 'identity' });
assert.equal(id.status, 200);
assert.deepEqual(id.body, want);
assert.equal(id.headers['content-type'], 'text/javascript; charset=utf-8');
@@ -46,7 +51,7 @@ for (const file of ['w.js', 'h.js']) {
assert.equal(id.headers['cross-origin-resource-policy'], 'cross-origin');
assert.equal(id.headers['access-control-allow-origin'], '*');
assert.equal(id.headers['content-encoding'], undefined);
const gz = await raw(s.base, `/a/${file}`, { 'Accept-Encoding': 'gzip, deflate' });
const gz = await raw(s.base, `/a/${file}${file === 'w.js' ? `?s=${KEY}` : ''}`, { 'Accept-Encoding': 'gzip, deflate' });
assert.equal(gz.status, 200);
assert.equal(gz.headers['content-encoding'], 'gzip');
assert.equal(gz.headers.etag, etag);
@@ -58,13 +63,13 @@ for (const file of ['w.js', 'h.js']) {
test('ETag: 304 bei If-None-Match, 200 bei anderem Wert', async () => {
const s = await start();
const first = await raw(s.base, '/a/w.js');
const hit = await raw(s.base, '/a/w.js', { 'If-None-Match': first.headers.etag });
const first = await raw(s.base, `/a/w.js?s=${KEY}`);
const hit = await raw(s.base, `/a/w.js?s=${KEY}`, { 'If-None-Match': first.headers.etag });
assert.equal(hit.status, 304);
assert.equal(hit.body.length, 0);
assert.equal(hit.headers.etag, first.headers.etag);
assert.equal(hit.headers['cache-control'], 'public, max-age=3600');
assert.equal((await raw(s.base, '/a/w.js', { 'If-None-Match': '"anders"' })).status, 200);
assert.equal((await raw(s.base, `/a/w.js?s=${KEY}`, { 'If-None-Match': '"anders"' })).status, 200);
s.close();
});
@@ -102,3 +107,42 @@ test('fehlende dist-Datei: 503 nur für diesen Pfad, deutscher Fehler im Log', a
assert.equal((await fetch(`${s.base}/a/h.js`)).status, 503);
s.close();
});
const EMPTY = '/* WEBklar Analyse: aus */\n';
for (const [name, q] of [['unbekannter Schlüssel', '?s=unbekannt0123456789ab'], ['ungültiger Schlüssel', '?s=ABC'], ['ohne s', ''], ['ausgeschaltete Website', `?s=${OFFKEY}`]]) {
test(`w.js ${name}: leeres Skript`, async () => {
const s = await start();
const r = await raw(s.base, `/a/w.js${q}`, { 'Accept-Encoding': 'gzip' });
assert.equal(r.status, 200);
assert.equal(r.body.toString(), EMPTY);
assert.equal(r.headers['content-type'], 'text/javascript; charset=utf-8');
assert.equal(r.headers['cache-control'], 'public, max-age=60');
assert.equal(r.headers['x-content-type-options'], 'nosniff');
assert.equal(r.headers['cross-origin-resource-policy'], 'cross-origin');
assert.equal(r.headers['access-control-allow-origin'], '*');
assert.equal(r.headers.vary, 'Accept-Encoding');
assert.equal(r.headers['content-encoding'], undefined);
assert.equal(r.headers.etag, undefined);
s.close();
});
}
test('Ende-zu-Ende: Ausschalten wirkt sofort, Einschalten ebenfalls', async () => {
const db = openDb(':memory:');
const pub = createPublicServer({ config: {}, db });
const adm = createAdminServer({ config: { adminToken: 't' }, db });
await Promise.all([pub, adm].map((x) => new Promise((r) => x.listen(0, '127.0.0.1', r))));
const pb = `http://127.0.0.1:${pub.address().port}`;
const ab = `http://127.0.0.1:${adm.address().port}`;
const put = (active) => fetch(`${ab}/a/admin/sites/${KEY}`, { method: 'PUT', headers: { Authorization: 'Bearer t', 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: 'p', domains: ['abc.project.webklar.com'], active }) });
const get = async () => (await raw(pb, `/a/w.js?s=${KEY}`)).body.toString();
assert.equal(await get(), EMPTY);
assert.equal((await put(true)).status, 200);
assert.notEqual(await get(), EMPTY);
assert.equal((await put(false)).status, 200);
assert.equal(await get(), EMPTY);
assert.equal((await put(true)).status, 200);
assert.notEqual(await get(), EMPTY);
pub.close(); adm.close();
});
+296 -66
View File
@@ -7,8 +7,10 @@ import { dirname, join } from 'node:path';
import {
vpClass, cleanPath, refHost, cssPath, contactKind, readConsent, contrastOk, escapeHtml,
} from '../client/core.js';
import { BANNER_VERSION, BANNER_TEXT, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL, BANNER_VERSION_EIGEN, BANNER_TEXT_EIGEN, bannerFor } from '../client/core.js';
import { buildAll } from '../client/build.js';
const BV = '2026-10-02';
const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist');
// ---------- core ----------
@@ -96,13 +98,17 @@ test('readConsent', () => {
const now = 1_700_000_000_000;
const day = 86400000;
const st = (v) => ({ getItem: () => v });
assert.deepEqual(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now - 10 * day })), now), { v: 1, a: 'accept', t: now - 10 * day });
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'reject', t: now - 182 * day })), now).a, 'reject');
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now - 183 * day })), now), null);
assert.equal(readConsent(st(JSON.stringify({ v: 2, a: 'accept', t: now })), now), null);
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'maybe', t: now })), now), null);
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: 'x' })), now), null);
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now + day })), now), null);
const J = (o) => st(JSON.stringify(o));
assert.equal(BANNER_VERSION, '2026-10-02');
assert.deepEqual(readConsent(J({ v: 1, a: 'accept', bv: BV, t: now - 10 * day }), now), { v: 1, a: 'accept', t: now - 10 * day, bv: BV });
assert.equal(readConsent(J({ v: 1, a: 'reject', bv: BV, t: now - 182 * day }), now).a, 'reject');
assert.equal(readConsent(J({ v: 1, a: 'accept', bv: BV, t: now - 183 * day }), now), null);
assert.equal(readConsent(J({ v: 1, a: 'accept', t: now }), now), null, 'ohne bv');
assert.equal(readConsent(J({ v: 1, a: 'accept', bv: '2026-01-01', t: now }), now), null, 'alte Fassung');
assert.equal(readConsent(J({ v: 2, a: 'accept', bv: BV, t: now }), now), null);
assert.equal(readConsent(J({ v: 1, a: 'maybe', bv: BV, t: now }), now), null);
assert.equal(readConsent(J({ v: 1, a: 'accept', bv: BV, t: 'x' }), now), null);
assert.equal(readConsent(J({ v: 1, a: 'accept', bv: BV, t: now + day }), now), null);
assert.equal(readConsent(st('kaputt{'), now), null);
assert.equal(readConsent(st(null), now), null);
assert.equal(readConsent({ getItem() { throw new Error('blocked'); } }, now), null);
@@ -144,6 +150,7 @@ function makeEl(tag) {
addEventListener(t, f) { (this.listeners[t] ||= []).push(f); },
removeEventListener(t, f) { this.listeners[t] = (this.listeners[t] || []).filter((x) => x !== f); },
focus() { this.focused = true; },
getBoundingClientRect() { return { height: this.fakeHeight || 0 }; },
remove() { this.removed = true; if (this.parentNode) this.parentNode.children = this.parentNode.children.filter((c) => c !== this); },
closest(sel) {
const attr = /^\[([\w-]+)\]$/.exec(sel);
@@ -161,40 +168,43 @@ function fire(node, type, ev) { for (const f of [...(node.listeners[type] || [])
function walk(n, fn) { fn(n); for (const c of n.children || []) walk(c, fn); if (n.shadow) walk(n.shadow, fn); }
function findAll(root, pred) { const out = []; walk(root, (n) => { if (pred(n)) out.push(n); }); return out; }
const btn = (root, label) => findAll(root, (n) => n.tagName === 'BUTTON' && n.textContent === label)[0];
const settingsBtn = (h) => findAll(h.body, (n) => n.tagName === 'BUTTON' && n.attrs['aria-label'] === SETTINGS_LABEL)[0];
function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false } = {}) {
const sent = []; const store = {}; let now = 1_700_000_000_000;
function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false, bannerHeight = 0, bodyPad, variant } = {}) {
const sent = []; const reads = { referrer: 0, innerWidth: 0 }; const beacon = []; const fetches = []; const store = {}; let now = 1_700_000_000_000;
if (stored) store['wk-consent'] = JSON.stringify(stored);
const body = makeEl('body'); const html = makeEl('html');
const body = makeEl('body'); if (bodyPad !== undefined) body.style.paddingBottom = bodyPad; const html = makeEl('html');
const timers = [];
const script = makeEl('script'); script.setAttribute('src', src); script.setAttribute('data-site', 'abcdef0123456789abcd'); script.setAttribute('data-color', '#0a5a8a'); script.src = src;
const script = makeEl('script'); script.setAttribute('src', src); script.setAttribute('data-site', 'abcdef0123456789abcd'); script.setAttribute('data-color', '#0a5a8a'); if (variant !== undefined) script.setAttribute('data-variant', variant); script.src = src;
const docL = {}; const winL = {};
const link = withConsentLink ? makeEl('a') : null; if (link) link.setAttribute('data-webklar-consent', '');
const injected = [];
if (link) link.click = () => { for (const f of [...(docL.click || [])]) f({ type: 'click', target: link, preventDefault() {} }); };
const document = {
currentScript: script, referrer: 'https://www.google.com/', visibilityState: 'visible',
currentScript: script, get referrer() { reads.referrer++; return 'https://www.google.com/'; }, visibilityState: 'visible',
documentElement: Object.assign(html, { scrollWidth: 1000, scrollHeight: 2000 }), body, head: makeEl('head'),
createElement: makeEl, activeElement: null,
createElement(t) { const e = makeEl(t); if (bannerHeight) e.offsetHeight = bannerHeight; return e; }, activeElement: null,
createElementNS(ns, t) { const e = makeEl(t); e.ns = ns; return e; },
querySelector(s) { return s === '[data-webklar-consent]' ? link : null; },
addEventListener(t, f) { (docL[t] ||= []).push(f); },
removeEventListener(t, f) { docL[t] = (docL[t] || []).filter((x) => x !== f); },
};
body.appendChild = function (c) { c.parentNode = this; this.children.push(c); if (c.attrs.src) injected.push(c); return c; };
document.head.appendChild = body.appendChild;
const history = { pushState() {}, replaceState(s, t, u) { this.replaced = u; } };
const origPush = function () {};
const history = { pushState: origPush, replaceState(s, t, u) { this.replaced = u; } };
const win = {
innerWidth: 1200, innerHeight: 800, scrollX: 0, scrollY: 300, document, history,
get innerWidth() { reads.innerWidth++; return 1200; }, innerHeight: 800, scrollX: 0, scrollY: 300, document, history,
location: { hostname: 'mein.de', origin: 'https://mein.de', pathname: '/start', search, href: 'https://mein.de/start' + search, hash: '' },
addEventListener(t, f) { (winL[t] ||= []).push(f); },
removeEventListener(t, f) { winL[t] = (winL[t] || []).filter((x) => x !== f); },
};
const sandbox = {
window: win, document, history, location: win.location, URL, URLSearchParams, JSON, Math, Date: { now: () => now }, String, Number, Array, Object, RegExp, Error, parseInt,
navigator: { globalPrivacyControl: gpc, doNotTrack: dnt, sendBeacon(u, d) { sent.push({ u, d: JSON.parse(d) }); return true; } },
navigator: { globalPrivacyControl: gpc, doNotTrack: dnt, sendBeacon(u, d) { beacon.push({ u, d }); return true; } },
localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },
setTimeout: (f, ms) => { timers.push(f); return timers.length; }, clearTimeout() {},
setInterval: () => 0, clearInterval() {}, Blob: class {}, fetch: () => Promise.resolve(),
setInterval: () => 0, clearInterval() {}, Blob: class {}, fetch: (u, o) => { fetches.push({ u, o }); sent.push({ u, d: JSON.parse(o.body) }); return Promise.resolve(); },
};
win.localStorage = sandbox.localStorage;
sandbox.globalThis = sandbox;
@@ -205,31 +215,46 @@ function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar
const leave = () => { for (const f of [...(winL.pagehide || [])]) f({}); document.visibilityState = 'hidden'; for (const f of [...(docL.visibilitychange || [])]) f({}); };
const rerun = () => vm.runInContext(readFileSync(join(DIST, 'w.js'), 'utf8'), sandbox);
const storage = () => (winL.storage || []).forEach((f) => f({ key: 'wk-consent' }));
return { rerun, storage, sent, store, types, ck, leave, body, docL, winL, win, injected, history, timers, setNow: (n) => { now = n; }, link, document };
const nav = (p) => { win.location.pathname = p; history.pushState(null, '', p); timers.splice(0).forEach((f) => f()); };
return { nav, reads, beacon, fetches, origPush, rerun, storage, sent, store, types, ck, leave, body, docL, winL, win, injected, history, timers, setNow: (n) => { now = n; }, link, document };
}
const target = () => { const b = makeEl('button'); b.parentNode = makeEl('body'); b.attrs.class = 'cta'; return b; };
const hostEl = (h) => h.body.children.find((c) => c.shadow && findAll(c.shadow, (n) => n.attrs && n.attrs.role === 'dialog').length);
test('w.js: ohne Einwilligung nur pv + bn shown, keine ck/ct/en', () => {
const SITE = 'abcdef0123456789abcd';
const ACC0 = { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 };
test('w.js: ohne Einwilligung nur pv {t,site,path} + bn shown, kein Lesen von referrer/innerWidth', () => {
const h = harness();
assert.deepEqual(h.types(), ['pv', 'bn:shown']);
assert.equal(h.sent[0].u, 'https://project.webklar.com/a/e');
assert.deepEqual(h.sent[0].d, { t: 'pv', site: 'abcdef0123456789abcd', path: '/start', ref: 'www.google.com', vp: 'd' });
assert.deepEqual(h.sent[1].d, { t: 'bn', site: 'abcdef0123456789abcd', a: 'shown' });
assert.deepEqual(h.sent[0].d, { t: 'pv', site: SITE, path: '/start' });
assert.deepEqual(Object.keys(h.sent[0].d).sort(), ['path', 'site', 't']);
assert.deepEqual(h.sent[1].d, { t: 'bn', site: SITE, a: 'shown' });
assert.equal(h.reads.referrer, 0);
assert.equal(h.reads.innerWidth, 0);
h.ck(target()); h.leave();
assert.deepEqual(h.types(), ['pv', 'bn:shown']);
assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation
});
test('core: Banner-Konstanten', () => {
assert.match(BANNER_TEXT, /^Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung\./);
assert.match(BANNER_TEXT, /Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast\./);
assert.match(BANNER_TEXT, /über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen\. Details: $/);
assert.equal(BANNER_LINK_LABEL, 'Datenschutz');
assert.equal(BANNER_ARIA, 'Einwilligung zur Reichweitenmessung');
assert.equal(SETTINGS_LABEL, 'Datenschutz-Einstellungen');
});
test('w.js: Banner-Aufbau (Dialog, Texte, gleiche Buttons, Akzent nur Annehmen)', () => {
const h = harness();
const host = hostEl(h);
assert.ok(host, 'Banner im Shadow Root');
const dlg = findAll(host.shadow, (n) => n.attrs.role === 'dialog')[0];
assert.equal(dlg.attrs['aria-label'], 'Cookie-Einstellungen');
assert.equal(dlg.attrs['aria-label'], BANNER_ARIA);
const all = findAll(host.shadow, () => true);
const text = all.map((n) => n.textContent).join(' ');
assert.match(text, /Wir möchten mit deiner Zustimmung anonym auswerten/);
assert.ok(all.find((n) => n.tagName === 'P').textContent.startsWith(BANNER_TEXT), 'Text beginnt mit BANNER_TEXT');
const a = all.find((n) => n.tagName === 'A');
assert.equal(a.attrs.href, '/datenschutz-analyse.html');
assert.equal(a.textContent, 'Datenschutz');
@@ -244,8 +269,7 @@ test('w.js: Banner-Aufbau (Dialog, Texte, gleiche Buttons, Akzent nur Annehmen)'
assert.match(css, /button\{[^}]*background:#0a5a8a[^}]*color:#fff/);
assert.match(css, /button\{[^}]*[; ]width:150px/);
assert.equal((css.match(/(?<![-\w])width:/g) || []).length, 2, 'eine feste Breite fuer beide + 100% mobil');
assert.ok(!/min-width/.test(css));
const portal = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'public', 'funktionen.css'), 'utf8');
const portal = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'public', 'analyse.css'), 'utf8');
assert.match(portal, /\.an-bn-btn \{[^}]*[; ]width: 150px/);
assert.equal((portal.match(/\.an-bn-btn[^{]*\{/g) || []).length, 2, 'Basisregel + mobile Regel');
assert.equal(host.style.zIndex, '2147483647');
@@ -258,8 +282,8 @@ test('w.js: Annehmen => bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort',
const h = harness();
const bh = hostEl(h);
btn(bh.shadow, 'Annehmen').click();
assert.equal(h.types().at(-1), 'bn:accept');
assert.deepEqual(JSON.parse(h.store['wk-consent']), { v: 1, a: 'accept', t: 1_700_000_000_000 });
assert.deepEqual(h.types(), ['pv', 'bn:shown', 'bn:accept', 'cx']);
assert.deepEqual(JSON.parse(h.store['wk-consent']), { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 });
assert.ok(bh.removed && !hostEl(h));
const t = target();
h.ck(t);
@@ -281,7 +305,7 @@ test('w.js: Annehmen => bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort',
assert.equal(en[0].d.scroll, 55); // (300+800)/2000
assert.equal(typeof en[0].d.sec, 'number');
// Widerruf ueber Einstellungs-Link
const settings = findAll(h.body, (x) => x.tagName === 'BUTTON' && x.textContent === 'Cookie-Einstellungen')[0];
const settings = settingsBtn(h);
assert.ok(settings, 'fester Link');
settings.click();
assert.ok(btn(hostEl(h).shadow, 'Ablehnen').focused, 'Fokus beim Wiederoeffnen');
@@ -296,7 +320,7 @@ test('w.js: Annehmen => bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort',
});
test('w.js: gespeicherte Einwilligung => kein Banner, kein bn, Tracking an', () => {
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
assert.deepEqual(h.types(), ['pv']);
assert.equal(hostEl(h), undefined);
h.ck(target());
@@ -304,28 +328,158 @@ test('w.js: gespeicherte Einwilligung => kein Banner, kein bn, Tracking an', ()
});
test('w.js: abgelaufene/abgelehnte Einwilligung', () => {
const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 - 5000 } });
assert.deepEqual(h.types(), ['pv']);
const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 - 5000 } });
assert.deepEqual(h.types(), []);
h.ck(target()); h.leave();
assert.deepEqual(h.types(), ['pv']);
const h2 = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 200 * 86400000 } });
assert.deepEqual(h.types(), []);
const h2 = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 200 * 86400000 } });
assert.deepEqual(h2.types(), ['pv', 'bn:shown']);
});
test('w.js: GPC und DNT => nur pv, kein Banner, nie Tracking', () => {
for (const o of [{ gpc: true }, { dnt: '1' }, { gpc: true, stored: { v: 1, a: 'accept', t: 1_700_000_000_000 } }]) {
test('w.js: GPC und DNT => nichts senden, kein Banner, keine Hooks (T1)', () => {
for (const o of [{ gpc: true }, { dnt: '1' }, { dnt: 'yes' }, { gpc: true, stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 } }]) {
const h = harness(o);
assert.deepEqual(h.types(), ['pv']);
assert.equal(hostEl(h), undefined);
assert.equal(findAll(h.body, (x) => x.textContent === 'Cookie-Einstellungen').length, 0);
h.ck(target()); h.leave();
assert.deepEqual(h.types(), ['pv']);
assert.equal(h.sent.length, 0);
assert.equal(h.winL.pageshow, undefined);
assert.equal(h.winL.popstate, undefined);
assert.equal(h.history.pushState, h.origPush);
assert.equal(h.body.children.length, 0, 'kein Banner, kein Einstellungsknopf');
h.ck(target()); h.leave(); h.nav('/neu');
assert.equal(h.sent.length, 0);
assert.equal(h.reads.referrer + h.reads.innerWidth, 0);
}
});
test('w.js: gespeichertes Ablehnen => nichts senden, nur Einstellungsknopf; spaeteres Annehmen => bn:accept, pv mit ref+vp', () => {
const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } });
assert.equal(h.sent.length, 0);
assert.equal(h.winL.pageshow, undefined);
assert.equal(h.winL.popstate, undefined);
assert.equal(h.history.pushState, h.origPush);
assert.equal(h.body.children.length, 1);
const open = settingsBtn(h);
assert.ok(open);
open.click();
assert.equal(h.sent.length, 0);
btn(hostEl(h).shadow, 'Annehmen').click();
assert.deepEqual(h.types(), ['bn:accept', 'pv']);
assert.deepEqual(h.sent[1].d, { t: 'pv', site: SITE, path: '/start', ref: 'www.google.com', vp: 'd' });
assert.ok(h.winL.pageshow && h.winL.popstate, 'Hooks nachtraeglich installiert');
h.nav('/zwei');
assert.equal(h.types().at(-1), 'pv');
assert.equal(h.sent.at(-1).d.path, '/zwei');
});
test('w.js: Ablehnen => genau ein bn:reject, danach nichts mehr (auch nicht Seitenwechsel)', () => {
const h2 = harness();
btn(hostEl(h2).shadow, 'Ablehnen').click();
assert.deepEqual(h2.types(), ['pv', 'bn:shown', 'bn:reject']);
const n = h2.sent.length;
h2.nav('/zwei'); h2.nav('/drei');
for (const f of h2.winL.pageshow || []) f({ persisted: true });
for (const f of h2.winL.popstate || []) f({});
h2.timers.splice(0).forEach((f) => f());
h2.ck(target()); h2.leave();
for (const f of h2.docL.scroll || []) f({});
assert.equal(h2.sent.length, n);
assert.equal(h2.types().filter((t) => t === 'bn:reject').length, 1);
});
test('w.js: Seitenwechsel-Timer vor dem Ablehnen sendet nichts', () => {
const h = harness();
h.win.location.pathname = '/zwei';
h.history.pushState(null, '', '/zwei'); // Timer geplant
assert.equal(h.timers.length, 1);
btn(hostEl(h).shadow, 'Ablehnen').click();
const n = h.sent.length;
h.timers.splice(0).forEach((f) => f());
assert.equal(h.sent.length, n);
});
test('w.js: Ablehnen in anderem Tab (storage) => danach kein pv beim Seitenwechsel', () => {
const h = harness();
const n = h.sent.length;
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
h.storage();
h.nav('/zwei');
for (const f of h.winL.pageshow || []) f({ persisted: true });
assert.equal(h.sent.length, n);
});
test('w.js: Annehmen auf laufender Seite => bn:accept, genau ein cx mit ref+vp; kein zweites cx', () => {
const h = harness();
assert.deepEqual(h.types(), ['pv', 'bn:shown']);
btn(hostEl(h).shadow, 'Annehmen').click();
assert.deepEqual(h.types(), ['pv', 'bn:shown', 'bn:accept', 'cx']);
assert.deepEqual(h.sent.at(-1).d, { t: 'cx', site: SITE, path: '/start', ref: refHost('https://www.google.com/', 'mein.de'), vp: 'd' });
// Widerruf, erneut annehmen
settingsBtn(h).click();
btn(hostEl(h).shadow, 'Ablehnen').click();
settingsBtn(h).click();
btn(hostEl(h).shadow, 'Annehmen').click();
assert.equal(h.sent.filter((s) => s.d.t === 'cx').length, 1);
});
test('w.js: gespeichertes Annehmen => erstes pv mit ref+vp, kein cx', () => {
const h = harness({ stored: ACC0 });
assert.deepEqual(h.types(), ['pv']);
assert.deepEqual(h.sent[0].d, { t: 'pv', site: SITE, path: '/start', ref: 'www.google.com', vp: 'd' });
});
test('w.js: T15 kein sendBeacon, fetch mit keepalive, omit, POST, text/plain', () => {
const h = harness();
assert.ok(h.fetches.length >= 1);
assert.equal(h.beacon.length, 0);
for (const f of h.fetches) {
assert.equal(f.o.keepalive, true);
assert.equal(f.o.credentials, 'omit');
assert.equal(f.o.method, 'POST');
assert.equal(f.o.headers['Content-Type'], 'text/plain');
}
});
test('w.js: T19 Platz unten solange Banner offen; Knopf mindestens 24px', () => {
const h = harness({ bannerHeight: 90, bodyPad: '5px' });
assert.equal(h.body.style.paddingBottom, '90px');
btn(hostEl(h).shadow, 'Ablehnen').click();
assert.equal(h.body.style.paddingBottom, '5px');
const g = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } });
const css = findAll(g.body, (n) => n.tagName === 'STYLE').map((n) => n.textContent).join('');
assert.match(css, /width:44px;height:44px/);
});
test('w.js: Einstellungsknopf ist ein angeknabberter Keks, der sich dreht (ohne Drehung bei reduzierter Bewegung)', () => {
const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } });
const b = settingsBtn(h);
assert.ok(b, 'Knopf da');
assert.equal(b.attrs.type, 'button');
assert.equal(b.textContent, '', 'kein sichtbarer Text im Knopf selbst');
// Etikett "Datenschutz-Einstellungen" erscheint beim Darueberfahren, fuer Screenreader zaehlt aria-label
const label = findAll(b, (n) => n.textContent === SETTINGS_LABEL)[0];
assert.ok(label);
assert.equal(label.attrs['aria-hidden'], 'true');
// Keks als Inline-SVG (keine data:-Grafik, die eine CSP der Website blockieren koennte)
const svg = findAll(b, (n) => n.tagName === 'SVG')[0];
assert.ok(svg);
assert.equal(svg.ns, 'http://www.w3.org/2000/svg');
assert.equal(svg.attrs['aria-hidden'], 'true');
const paths = findAll(svg, (n) => n.tagName === 'PATH');
assert.ok(paths.length >= 1, 'Umriss mit Bissspur');
assert.match(paths[0].attrs.d, /A/, 'Bogen fuer den Biss');
assert.ok(findAll(svg, (n) => n.tagName === 'ELLIPSE' || n.tagName === 'CIRCLE').length >= 5, 'Schokostueckchen');
const css = findAll(h.body, (n) => n.tagName === 'STYLE').map((n) => n.textContent).join('');
assert.match(css, /@keyframes wk-spin\{to\{transform:rotate\(360deg\)\}\}/);
assert.match(css, /animation:wk-spin \d+s linear infinite/);
assert.match(css, /@media \(prefers-reduced-motion:reduce\)\{[^}]*animation:none/);
assert.match(css, /:hover \.c,[^{]*:focus-visible \.c\{animation-play-state:paused\}/);
assert.ok(!/url\(/.test(css), 'keine externen oder data:-Grafiken');
b.click();
assert.ok(hostEl(h), 'Klick oeffnet das Banner');
});
test('w.js: [data-webklar-consent] oeffnet Banner, kein fester Link', () => {
const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 }, withConsentLink: true });
assert.equal(findAll(h.body, (x) => x.textContent === 'Cookie-Einstellungen').length, 0);
const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 }, withConsentLink: true });
assert.equal(settingsBtn(h), undefined);
assert.equal(hostEl(h), undefined);
h.link.click();
assert.ok(hostEl(h));
@@ -339,18 +493,31 @@ test('w.js: Heatmap-Modus sendet nichts, entfernt Parameter, laedt h.js', () =>
assert.equal(h.injected.length, 1);
assert.equal(h.injected[0].attrs.src, 'https://project.webklar.com/a/h.js');
assert.equal(h.injected[0].attrs['data-token'], 'TOKEN.sig');
assert.equal(h.injected[0].attrs['data-embed'], undefined);
assert.equal((h.docL.click || []).length, 0);
});
test('w.js: Heatmap-Modus mit __wk_embed=1 entfernt beide Parameter und reicht data-embed an h.js', () => {
const h = harness({ search: '?__wk_heat=TOKEN.sig&__wk_embed=1&b=2' });
assert.deepEqual(h.sent, []);
assert.equal(h.history.replaced, '/start?b=2');
assert.equal(h.injected.length, 1);
assert.equal(h.injected[0].attrs['data-embed'], '1');
assert.equal(h.injected[0].attrs['data-token'], 'TOKEN.sig');
// __wk_embed ohne Heatmap-Token: normaler Betrieb (Banner, pv)
const n = harness({ search: '?__wk_embed=1' });
assert.deepEqual(n.types(), ['pv', 'bn:shown']);
});
test('w.js: SPA-Navigation sendet pv nur bei Pfadwechsel', () => {
const h = harness({ gpc: true });
h.win.location.pathname = '/start';
h.history.pushState(null, '', '/start?x=1'); h.timers.splice(0).forEach((f) => f());
assert.equal(h.sent.length, 1);
const h = harness();
h.nav('/start');
assert.equal(h.sent.filter((s) => s.d.t === 'pv').length, 1);
h.win.location.pathname = '/neu';
h.history.pushState(null, '', '/neu'); h.history.pushState(null, '', '/neu');
h.timers.splice(0).forEach((f) => f());
assert.deepEqual(h.sent.map((s) => s.d.path), ['/start', '/neu']);
assert.deepEqual(h.sent.filter((s) => s.d.t === 'pv').map((s) => s.d.path), ['/start', '/neu']);
assert.deepEqual(h.sent.at(-1).d, { t: 'pv', site: SITE, path: '/neu' });
});
test('w.js: wirft nie, auch wenn localStorage/sendBeacon kaputt', () => {
@@ -361,36 +528,33 @@ test('w.js: wirft nie, auch wenn localStorage/sendBeacon kaputt', () => {
});
});
test('w.js: DNT yes / msDoNotTrack => nur pv', () => {
for (const o of [{ dnt: 'yes' }]) { const h = harness(o); assert.deepEqual(h.types(), ['pv']); }
});
test('w.js: doppelt eingebunden => nur ein pv', () => {
const h = harness({ gpc: true });
const h = harness();
h.rerun();
assert.deepEqual(h.types(), ['pv']);
assert.deepEqual(h.types(), ['pv', 'bn:shown']);
});
test('w.js: Widerruf in anderem Tab (storage-Ereignis) stoppt Tracking sofort', () => {
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
h.ck(target());
assert.equal(h.sent.at(-1).d.t, 'ck');
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 });
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
h.storage();
const n = h.sent.length;
h.ck(target()); h.leave();
assert.equal(h.sent.length, n);
assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation
// Annehmen in anderem Tab startet Tracking
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'accept', t: 1_700_000_000_000 });
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 });
h.storage();
h.ck(target());
assert.equal(h.sent.length, n + 1);
assert.equal(h.sent.length, n + 1); // nur ck, kein cx (pv trug ref/vp schon)
assert.ok(!h.sent.some((x) => x.d.t === 'cx'));
});
test('w.js: Einwilligung wird vor jedem Senden neu geprueft (ohne storage-Ereignis)', () => {
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 });
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
const n = h.sent.length;
h.ck(target()); h.leave();
assert.equal(h.sent.length, n);
@@ -398,13 +562,15 @@ test('w.js: Einwilligung wird vor jedem Senden neu geprueft (ohne storage-Ereign
});
test('w.js: SPA-pv bei akzeptierter Einwilligung; nach Widerruf kein en', () => {
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
h.win.location.pathname = '/zwei';
h.history.pushState(null, '', '/zwei'); h.timers.splice(0).forEach((f) => f());
assert.deepEqual(h.sent.map((s) => s.d.path), ['/start', '/zwei']);
// en der vorigen Seite vor dem pv der neuen (Router-Links verhindern den Standard-Klick, onLinkClick greift nicht)
assert.deepEqual(h.sent.map((s) => `${s.d.t}:${s.d.path}`), ['pv:/start', 'en:/start', 'pv:/zwei']);
assert.equal(h.sent[1].d.scroll, 55);
h.ck(target());
assert.equal(h.sent.at(-1).d.path, '/zwei');
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 });
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
h.storage();
const n = h.sent.length;
h.leave();
@@ -412,7 +578,7 @@ test('w.js: SPA-pv bei akzeptierter Einwilligung; nach Widerruf kein en', () =>
});
test('w.js: bfcache-Rueckkehr sendet neues pv und erlaubt neues en', () => {
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
h.leave();
assert.equal(h.sent.filter((s) => s.d.t === 'en').length, 1);
for (const f of h.winL.pageshow || []) f({ persisted: false });
@@ -424,7 +590,7 @@ test('w.js: bfcache-Rueckkehr sendet neues pv und erlaubt neues en', () => {
});
test('w.js: [data-webklar-consent] spaeter hinzugefuegt funktioniert per Delegation', () => {
const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 } });
const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } });
// kein Link beim Laden => fester Link; spaeteres Element oeffnet ebenfalls
const late = makeEl('a'); late.setAttribute('data-webklar-consent', '');
for (const f of [...(h.docL.click || [])]) f({ type: 'click', target: late, preventDefault() {} });
@@ -433,7 +599,7 @@ test('w.js: [data-webklar-consent] spaeter hinzugefuegt funktioniert per Delegat
});
// ---------- Verweildauer bei internem Seitenwechsel ----------
const ACC = { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 };
const ACC = { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 };
const lnk = (href, attrs = {}) => { const a = makeEl('a'); a.setAttribute('href', href); for (const k in attrs) a.setAttribute(k, attrs[k]); a.parentNode = makeEl('body'); return a; };
const enCount = (h) => h.sent.filter((s) => s.d.t === 'en').length;
@@ -473,7 +639,71 @@ test('w.js: ohne Einwilligung oder nach Widerruf kein fruehes en', () => {
h.ck(lnk('/kontakt'));
assert.equal(enCount(h), 0);
const g = harness({ stored: ACC });
g.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 });
g.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
g.ck(lnk('/kontakt'));
assert.equal(enCount(g), 0);
});
// ---------- Variante "eigen" (WEBklars eigene Websites, z. B. webklar.com) ----------
test('core: bannerFor und readConsent je Fassung', () => {
assert.deepEqual(bannerFor('eigen'), { version: BANNER_VERSION_EIGEN, text: BANNER_TEXT_EIGEN });
for (const v of ['', 'kunde', 'EIGEN', 'x', undefined]) assert.deepEqual(bannerFor(v), { version: BANNER_VERSION, text: BANNER_TEXT });
assert.equal(BANNER_VERSION_EIGEN, '2026-10-17');
assert.notEqual(BANNER_VERSION_EIGEN, BANNER_VERSION);
const now = 1_700_000_000_000;
const st = (o) => ({ getItem: () => JSON.stringify(o) });
assert.equal(readConsent(st({ v: 1, a: 'accept', bv: BV, t: now }), now, BANNER_VERSION_EIGEN), null, 'Kunden-Fassung gilt nicht fuer eigen');
assert.equal(readConsent(st({ v: 1, a: 'accept', bv: BANNER_VERSION_EIGEN, t: now }), now, BANNER_VERSION_EIGEN).a, 'accept');
assert.equal(readConsent(st({ v: 1, a: 'accept', bv: BANNER_VERSION_EIGEN, t: now }), now), null, 'ohne Fassung gilt die Kunden-Fassung');
});
test('core: Banner-Text eigen (Sie-Form, ohne Auftragsverarbeiter)', () => {
assert.match(BANNER_TEXT_EIGEN, /^Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung\./);
assert.match(BANNER_TEXT_EIGEN, /Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung\./);
assert.match(BANNER_TEXT_EIGEN, /Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben\./);
assert.match(BANNER_TEXT_EIGEN, /über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen\. Details: $/);
assert.doesNotMatch(BANNER_TEXT_EIGEN, /\b(Du|Dein|Deine|Deiner|Dich|Dir)\b/);
assert.doesNotMatch(BANNER_TEXT_EIGEN, /betreibt für uns/);
});
test('w.js: data-variant="eigen" => Sie-Text, eigene Fassung im Speicher', () => {
const h = harness({ variant: 'eigen' });
const host = hostEl(h);
const p = findAll(host.shadow, (n) => n.tagName === 'P')[0];
assert.ok(p.textContent.startsWith(BANNER_TEXT_EIGEN));
assert.ok(!p.textContent.startsWith(BANNER_TEXT));
btn(host.shadow, 'Annehmen').click();
assert.deepEqual(JSON.parse(h.store['wk-consent']), { v: 1, a: 'accept', bv: BANNER_VERSION_EIGEN, t: 1_700_000_000_000 });
assert.deepEqual(h.types(), ['pv', 'bn:shown', 'bn:accept', 'cx']);
});
test('w.js: eigen fragt neu bei Wahl aus der Kunden-Fassung, gilt mit eigener Fassung', () => {
const alt = harness({ variant: 'eigen', stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
assert.ok(hostEl(alt), 'Banner erscheint erneut');
const ok = harness({ variant: 'eigen', stored: { v: 1, a: 'reject', bv: BANNER_VERSION_EIGEN, t: 1_700_000_000_000 - 1000 } });
assert.equal(hostEl(ok), undefined);
assert.deepEqual(ok.types(), []);
});
test('w.js: unbekannte Variante => Kunden-Fassung', () => {
const h = harness({ variant: 'sonstwas' });
const p = findAll(hostEl(h).shadow, (n) => n.tagName === 'P')[0];
assert.ok(p.textContent.startsWith(BANNER_TEXT));
});
test('w.js: SPA-Wechsel ohne Einwilligung sendet nur pv, kein en', () => {
const h = harness();
h.win.location.pathname = '/zwei';
h.history.pushState(null, '', '/zwei'); h.timers.splice(0).forEach((f) => f());
assert.deepEqual(h.types(), ['pv', 'bn:shown', 'pv']);
});
test('w.js: en nach SPA-Wechsel nur einmal je Seite (Link-Klick und Router)', () => {
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
h.leave();
h.win.location.pathname = '/zwei';
h.history.pushState(null, '', '/zwei'); h.timers.splice(0).forEach((f) => f());
assert.deepEqual(h.sent.map((s) => `${s.d.t}:${s.d.path}`), ['pv:/start', 'en:/start', 'pv:/zwei']);
h.leave();
assert.deepEqual(h.sent.map((s) => `${s.d.t}:${s.d.path}`).slice(-1), ['en:/zwei']);
});
+93
View File
@@ -0,0 +1,93 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import { mkdtempSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { openDb } from '../src/db.js';
import { exportSite } from '../scripts/export-site.mjs';
const KEY = 'abcdef0123456789abcd';
const OTHER = 'zzzzzzzz0123456789zz';
const SCRIPT = new URL('../scripts/export-site.mjs', import.meta.url).pathname;
function setup() {
const dir = mkdtempSync(join(tmpdir(), 'exp-'));
const dbPath = join(dir, 'a.db');
const db = openDb(dbPath);
for (const k of [KEY, OTHER]) db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)').run(k, 'p', '[]');
const ts = Date.UTC(2026, 8, 1, 10);
db.prepare("INSERT INTO daily VALUES (?, '2026-08-31','pv_total','',5)").run(KEY);
db.prepare("INSERT INTO daily VALUES (?, '2026-08-31','pv_path','/a',2)").run(KEY);
db.prepare("INSERT INTO daily VALUES (?, '2026-08-31','pv_total','',9)").run(OTHER);
db.prepare("INSERT INTO events_raw (site, ts, type, path, ref) VALUES (?,?, 'pv', '/x', ?)").run(KEY, ts, 'a,"b"\nc');
db.prepare("INSERT INTO events_raw (site, ts, type, path) VALUES (?,?, 'pv', '/o')").run(OTHER, ts);
db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y, sel) VALUES (?,?,?,?,?,?,?)').run(KEY, ts, '/', 'd', 1.5, 2, '=1+1');
db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y, sel) VALUES (?,?,?,?,?,?,?)').run(OTHER, ts, '/', 'd', 1, 2, 'x');
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
db.close();
return { dir, dbPath };
}
const hash = (p) => crypto.createHash('sha256').update(readFileSync(p)).digest('hex');
test('Export: drei CSV-Dateien mit Kopfzeilen, nur diese Website, Quotes und Formelschutz', () => {
const { dir, dbPath } = setup();
const before = hash(dbPath);
const out = join(dir, 'out');
exportSite({ dbPath, siteKey: KEY, outDir: out });
assert.deepEqual(readdirSync(out).sort(), [`${KEY}-ereignisse.csv`, `${KEY}-klicks.csv`, `${KEY}-tageswerte.csv`]);
const t = readFileSync(join(out, `${KEY}-tageswerte.csv`), 'utf8');
assert.equal(t.split('\r\n')[0], 'day,dim,key,count');
assert.equal(t.split('\r\n').filter(Boolean).length, 3);
const e = readFileSync(join(out, `${KEY}-ereignisse.csv`), 'utf8');
assert.equal(e.split('\r\n')[0], 'time,type,path,vp,ref,country,device,visitor,sec,scroll,kind,host');
assert.ok(e.includes('2026-09-01T10:00:00.000Z,pv,/x,,"a,""b""\nc"'));
assert.ok(!e.includes('/o'));
const c = readFileSync(join(out, `${KEY}-klicks.csv`), 'utf8');
assert.equal(c.split('\r\n')[0], 'time,path,vp,x,y,sel');
assert.equal(c.split('\r\n').filter(Boolean).length, 2);
assert.ok(c.includes(",'=1+1"));
assert.equal(hash(dbPath), before);
});
test('Export: Datenbank ist nur lesend geöffnet', () => {
const { dbPath } = setup();
const ro = new DatabaseSync(dbPath, { readOnly: true });
assert.throws(() => ro.exec("INSERT INTO sites (siteKey, projectId, domains) VALUES ('q','p','[]')"));
ro.close();
});
test('CLI: unbekannter oder ungültiger Schlüssel => Exit 1 ohne Dateien', () => {
const { dir, dbPath } = setup();
for (const k of ['unbekannt0123456789ab', 'ABC']) {
const out = join(dir, 'o-' + k);
const r = spawnSync(process.execPath, [SCRIPT, k, '--db', dbPath, '--out', out]);
assert.equal(r.status, 1);
assert.throws(() => readdirSync(out));
}
const ok = spawnSync(process.execPath, [SCRIPT, KEY, '--db', dbPath, '--out', join(dir, 'ok')]);
assert.equal(ok.status, 0);
assert.equal(readdirSync(join(dir, 'ok')).length, 3);
});
test('Export: Formelschutz nur fuer Text (Tab, CR, Minus), Zahlen bleiben unveraendert, Dateien 0600', () => {
const { dir, dbPath } = setup();
const db = new DatabaseSync(dbPath);
const ts = Date.UTC(2026, 8, 1, 10);
const ins = db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y, sel) VALUES (?,?,?,?,?,?,?)');
ins.run(KEY, ts, '/', 'd', -3, -4.5, '\tcmd');
ins.run(KEY, ts, '/', 'd', 1, 2, '\rcmd');
ins.run(KEY, ts, '/', 'd', 1, 2, '-cmd');
db.close();
const out = join(dir, 'out');
const files = exportSite({ dbPath, siteKey: KEY, outDir: out });
const c = readFileSync(join(out, `${KEY}-klicks.csv`), 'utf8');
assert.ok(c.includes(',-3,-4.5,'), 'negative Zahlen ohne Praefix');
assert.ok(!c.includes("'-3"));
assert.ok(c.includes(",\"'\tcmd\"") || c.includes(",'\tcmd"), 'Tab gesichert');
assert.ok(c.includes(`"'\rcmd"`), 'CR gesichert');
assert.ok(c.includes(",'-cmd"));
for (const f of files) assert.equal(statSync(f).mode & 0o777, 0o600);
});
+22
View File
@@ -136,3 +136,25 @@ test('OPTIONS-Preflight: CORS-Köpfe nur für erlaubte Origins bekannter Website
assert.equal((await pre('abc', 'https://kunde.de')).headers.get('access-control-allow-origin'), null);
s.close();
});
test('Heatmap: gleiche Stunde (ts-Gleichstand), Limit schneidet über die niedrigste id ab', async () => {
const s = await setup();
const ins = s.db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y) VALUES (?,?,?,?,?,?)');
const ts = NOW - 3600000;
ins.run(KEY, ts, '/g', 'd', 0.99, 0.99); // niedrigste id
s.db.exec('BEGIN');
for (let i = 0; i < 5000; i++) ins.run(KEY, ts, '/g', 'd', 0.5, 0.5);
s.db.exec('COMMIT');
const j = await (await s.get(tok({ path: '/g' }))).json();
assert.equal(j.points.length, 5000);
assert.ok(!j.points.some((p) => p.x === 0.99));
s.close();
});
test('Heatmap: Token-Pfad mit Kennung findet die als :id gespeicherten Klicks', async () => {
const s = await setup();
s.db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y) VALUES (?,?,?,?,?,?)').run(KEY, NOW - 1000, '/kunde/:id', 'd', 0.4, 0.6);
const j = await (await s.get(tok({ path: '/kunde/12345678' }))).json();
assert.deepEqual(j, { points: [{ x: 0.4, y: 0.6 }] });
s.close();
});
+129
View File
@@ -0,0 +1,129 @@
// Verhalten von dist/h.js (Heatmap-Overlay) im Fake-DOM: eigenes Fenster und Einbettung im Portal.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const H = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist', 'h.js'), 'utf8');
function makeEl(tag) {
const e = {
tagName: String(tag).toUpperCase(), nodeType: 1, attrs: {}, children: [], listeners: {}, style: { setProperty() {} }, parentNode: null,
textContent: '', removed: false,
setAttribute(n, v) { this.attrs[n] = String(v); },
getAttribute(n) { return n in this.attrs ? this.attrs[n] : null; },
appendChild(c) { c.parentNode = this; this.children.push(c); return c; },
addEventListener(t, f, o) { (this.listeners[t] ||= []).push({ f, o }); },
removeEventListener(t, f) { this.listeners[t] = (this.listeners[t] || []).filter((x) => x.f !== f); },
remove() { this.removed = true; },
attachShadow() { const r = makeEl('shadow'); this.shadow = r; return r; },
closest(sel) {
const tags = sel.split(',').map((t) => t.trim().toUpperCase());
for (let n = this; n && n.tagName; n = n.parentNode) if (tags.includes(n.tagName)) return n;
return null;
},
getContext() {
return {
createLinearGradient: () => ({ addColorStop() {} }),
createRadialGradient: () => ({ addColorStop() {} }),
fillRect() {}, clearRect() {},
getImageData: (x, y, w, hh) => ({ data: new Uint8ClampedArray(Math.max(4, w * hh * 4)) }),
putImageData() {},
};
},
};
return e;
}
async function harness({ embed = false, framed = true, fail = false, points = [{ x: 0.5, y: 0.5 }, { x: 0.1, y: 0.2 }, { x: 2, y: 0 }] } = {}) {
const body = makeEl('body'); const head = makeEl('head'); const html = makeEl('html');
Object.assign(html, { scrollWidth: 402, scrollHeight: 2000, clientWidth: 402, clientHeight: 800 });
const script = makeEl('script');
script.setAttribute('src', 'https://project.webklar.com/a/h.js');
script.setAttribute('data-token', 'TOK.sig');
if (embed) script.setAttribute('data-embed', '1');
script.src = 'https://project.webklar.com/a/h.js';
const fetches = []; const posted = []; const winL = {};
const document = {
currentScript: script, documentElement: html, body, head,
createElement: (t) => makeEl(t),
};
const win = {
document,
addEventListener(t, f, o) { (winL[t] ||= []).push({ f, o }); },
removeEventListener(t, f) { winL[t] = (winL[t] || []).filter((x) => x.f !== f); },
};
const parent = framed ? { postMessage(data, origin) { posted.push({ data, origin }); } } : win;
win.parent = parent;
const sandbox = {
window: win, document, URL, JSON, Math, String, Number, Array, Object, RegExp, Error, Uint8ClampedArray, Promise,
setInterval: () => 1, clearInterval() {},
fetch: (u, o) => {
fetches.push({ u, o });
if (fail) return Promise.resolve({ ok: false, json: () => Promise.resolve({}) });
return Promise.resolve({ ok: true, json: () => Promise.resolve({ points }) });
},
};
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(H, sandbox);
await new Promise((r) => setTimeout(r, 10));
const fire = (type, target) => {
const ev = { type, target, prevented: false, stopped: false, preventDefault() { this.prevented = true; }, stopPropagation() { this.stopped = true; } };
for (const { f } of winL[type] || []) f(ev);
return ev;
};
const shadowHosts = body.children.filter((c) => c.shadow);
return { body, head, fetches, posted, winL, fire, shadowHosts };
}
const linkIn = () => { const a = makeEl('a'); a.setAttribute('href', '/kontakt'); const span = makeEl('span'); span.parentNode = a; return span; };
test('h.js eigenes Fenster: schwarze Leiste mit Klickzahl, keine Nachricht, Links bleiben klickbar', async () => {
const h = await harness();
assert.equal(h.fetches.length, 1);
assert.equal(h.fetches[0].u, 'https://project.webklar.com/a/heat?token=TOK.sig');
assert.equal(h.fetches[0].o.credentials, 'omit');
assert.equal(h.shadowHosts.length, 1, 'Leiste');
const msg = h.shadowHosts[0].shadow.children.find((c) => c.className === 'bar').children[0];
assert.equal(msg.textContent, 'Heatmap – 2 Klicks (letzte 30 Tage)');
assert.deepEqual(h.posted, []);
assert.equal(h.fire('click', linkIn()).prevented, false);
});
test('h.js eingebettet: keine Leiste, meldet Klickzahl nur an project.webklar.com', async () => {
const h = await harness({ embed: true });
assert.equal(h.shadowHosts.length, 0, 'keine Leiste');
assert.ok(h.body.children.some((c) => c.tagName === 'CANVAS'), 'Overlay da');
assert.deepEqual(JSON.parse(JSON.stringify(h.posted)), [{ data: { wk: 'heat', state: 'ready', count: 2 }, origin: 'https://project.webklar.com' }]);
// Scrollleisten ausgeblendet (Handy/Tablet haben ueberlagernde Leisten)
assert.ok(h.head.children.some((c) => c.tagName === 'STYLE' && /scrollbar-width:none/.test(c.textContent)));
});
test('h.js eingebettet: Links und Formulare gesperrt, andere Klicks nicht', async () => {
const h = await harness({ embed: true });
assert.ok((h.winL.click || []).some((l) => l.o === true), 'Klick-Sperre in der Capture-Phase');
const ev = h.fire('click', linkIn());
assert.equal(ev.prevented, true);
assert.equal(ev.stopped, true, 'Router der Seite bekommt den Klick nicht');
const area = h.fire('click', makeEl('area'));
assert.equal(area.prevented, true);
const other = h.fire('click', makeEl('button'));
assert.equal(other.prevented, false);
const sub = h.fire('submit', makeEl('form'));
assert.equal(sub.prevented, true);
});
test('h.js eingebettet: Fehler beim Laden => state error', async () => {
const h = await harness({ embed: true, fail: true });
assert.deepEqual(JSON.parse(JSON.stringify(h.posted)), [{ data: { wk: 'heat', state: 'error', count: 0 }, origin: 'https://project.webklar.com' }]);
});
test('h.js mit data-embed, aber nicht in einem Rahmen: verhaelt sich wie das eigene Fenster', async () => {
const h = await harness({ embed: true, framed: false });
assert.equal(h.shadowHosts.length, 1);
assert.deepEqual(h.posted, []);
assert.equal(h.fire('click', linkIn()).prevented, false);
});
+99
View File
@@ -350,3 +350,102 @@ test('X-Forwarded-For: rechtester Eintrag zählt für das Mengenlimit', async ()
assert.equal(t.rows().length, 121);
t.close();
});
test('T2: pv ohne ref/vp wird gespeichert (vp NULL, ref NULL), Gerät und Land bleiben', async () => {
const t = await setup();
await t.post({ t: 'pv', site: KEY, path: '/' });
const r = t.rows();
assert.equal(r.length, 1);
assert.equal(r[0].type, 'pv');
assert.equal(r[0].vp, null);
assert.equal(r[0].ref, null);
assert.equal(r[0].device, 'm');
assert.equal(r[0].country, 'DE');
await t.post({ t: 'pv', site: KEY, path: '/', vp: 'x' });
assert.equal(t.rows().length, 1);
t.close();
});
test('T2: cx braucht gültiges vp, speichert ref, eigene Domain ergibt NULL', async () => {
const t = await setup();
await t.post({ t: 'cx', site: KEY, path: '/', ref: 'https://www.google.com/x' });
await t.post({ t: 'cx', site: KEY, path: '/', ref: 'google.com', vp: 'x' });
assert.equal(t.rows().length, 0);
await t.post({ t: 'cx', site: KEY, path: '/', ref: 'https://www.google.com/x', vp: 'd' });
await t.post({ t: 'cx', site: KEY, path: '/', ref: 'https://www.kunde.de/x', vp: 'm' });
const r = t.rows();
assert.equal(r.length, 2);
assert.equal(r[0].type, 'cx');
assert.equal(r[0].ref, 'www.google.com');
assert.equal(r[0].vp, 'd');
assert.equal(r[0].country, null);
assert.equal(r[0].device, null);
assert.equal(r[1].ref, null);
t.close();
});
test('T3: alle Zeitstempel auf die volle Stunde', async () => {
const t = await setup();
const hour = Date.UTC(2026, 9, 1, 10, 0, 0);
await t.post(pv());
await t.post({ t: 'cx', site: KEY, path: '/', vp: 'd' });
await t.post({ t: 'en', site: KEY, path: '/', vp: 'd', sec: 5, scroll: 10 });
await t.post({ t: 'ct', site: KEY, path: '/', kind: 'tel' });
await t.post({ t: 'ck', site: KEY, path: '/', vp: 'd', x: 0.5, y: 0.5 });
const r = t.rows();
assert.deepEqual(r.map((x) => x.type), ['pv', 'cx', 'en', 'ct']);
for (const x of r) assert.equal(x.ts, hour);
const c = t.db.prepare('SELECT ts FROM clicks').all();
assert.equal(c.length, 1);
assert.equal(c[0].ts, hour);
assert.equal(t.db.prepare('SELECT COUNT(*) n FROM events_raw WHERE ts % 3600000 != 0').get().n, 0);
t.close();
});
test('T14: kennungsartige Pfadsegmente werden zu :id, alle Typen', async () => {
const t = await setup();
const cases = [
['/kunde/123456789/rechnung', '/kunde/:id/rechnung'],
['/u/3f2b8c1e-5d4a-4e0b-9a7c-1b2c3d4e5f60', '/u/:id'],
['/mail/max%40example.com', '/mail/:id'],
['/mail/max@example.com', '/mail/:id'],
['/preise/2026', '/preise/2026'],
['/blog/12345', '/blog/12345'],
['/', '/'],
['/a/123456?x=1#h', '/a/:id'],
];
for (const [p] of cases) await t.post(pv({ path: p }));
assert.deepEqual(t.rows().map((r) => r.path), cases.map((c) => c[1]));
await t.post({ t: 'cx', site: KEY, path: '/kunde/987654321', vp: 'd' });
await t.post({ t: 'en', site: KEY, path: '/kunde/987654321', vp: 'd', sec: 1, scroll: 1 });
await t.post({ t: 'ct', site: KEY, path: '/kunde/987654321', kind: 'tel' });
await t.post({ t: 'ck', site: KEY, path: '/kunde/987654321', vp: 'd', x: 0.1, y: 0.1 });
const paths = t.rows().slice(cases.length).map((r) => r.path);
assert.deepEqual(paths, ['/kunde/:id', '/kunde/:id', '/kunde/:id']);
assert.equal(t.db.prepare('SELECT path FROM clicks').get().path, '/kunde/:id');
t.close();
});
test('T14: ungültige Prozent-Kodierung fällt auf den Rohwert zurück', async () => {
const t = await setup();
await t.post(pv({ path: '/a/%E0%A4%A/123456' }));
await t.post(pv({ path: '/a/%E0%A4%A/x' }));
await t.post(pv({ path: '/a/%E0%A4%A40/x' }));
assert.deepEqual(t.rows().map((r) => r.path), ['/a/%E0%A4%A/:id', '/a/%E0%A4%A/x', '/a/%E0%A4%A40/x']);
t.close();
});
test('T3: jede gespeicherte Zeile jedes Typs liegt auf der vollen Stunde (ungerade Uhrzeit)', async () => {
const t = await setup({ now: () => Date.UTC(2026, 9, 1, 10, 37, 12, 345) });
await t.post({ t: 'bn', site: KEY, a: 'shown' });
await t.post(pv());
await t.post({ t: 'cx', site: KEY, path: '/', vp: 'd' });
await t.post({ t: 'en', site: KEY, path: '/', vp: 'd', sec: 5, scroll: 10 });
await t.post({ t: 'ct', site: KEY, path: '/', kind: 'tel' });
await t.post({ t: 'ck', site: KEY, path: '/', vp: 'd', x: 0.5, y: 0.5 });
const r = t.rows();
assert.deepEqual([...new Set(r.map((x) => x.type))].sort(), ['bn', 'ct', 'cx', 'en', 'pv']);
for (const x of r) assert.equal(x.ts % 3600000, 0);
for (const x of t.db.prepare('SELECT ts FROM clicks').all()) assert.equal(x.ts % 3600000, 0);
t.close();
});
+92
View File
@@ -0,0 +1,92 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { openDb } from '../src/db.js';
import { runNightly, dailyCutoffDay, DAILY_MONTHS } from '../src/jobs.js';
const KEY = 'abcdef0123456789abcd';
const NOW = Date.UTC(2026, 9, 1, 10);
const D = 86400 * 1000;
test('dailyCutoffDay: 25 Kalendermonate zurück, Monatsende begrenzt', () => {
assert.equal(DAILY_MONTHS, 25);
assert.equal(dailyCutoffDay(Date.UTC(2026, 9, 1, 10)), '2024-09-01');
assert.equal(dailyCutoffDay(Date.UTC(2026, 2, 31, 10)), '2024-02-29');
});
function mk() {
const db = openDb(':memory:');
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)').run(KEY, 'p', '["a.de"]');
return db;
}
test('Tageswerte älter als 25 Monate werden gelöscht, Grenztag bleibt', () => {
const db = mk();
for (const day of ['2024-08-31', '2024-09-01']) {
db.prepare("INSERT INTO daily (site, day, dim, key, count) VALUES (?,?, 'pv_total','',3)").run(KEY, day);
db.prepare('INSERT INTO agg_state (site, day) VALUES (?,?)').run(KEY, day);
}
const r = runNightly(db, { now: NOW });
assert.deepEqual(db.prepare('SELECT day FROM daily').all().map((x) => x.day), ['2024-09-01']);
assert.deepEqual(db.prepare('SELECT day FROM agg_state').all().map((x) => x.day), ['2024-09-01']);
assert.equal(r.dailyDeleted, 1);
});
test('ausgeschaltete Website ohne offSince bekommt offSince, zweiter Lauf ändert nichts', () => {
const db = mk();
db.prepare('UPDATE sites SET active = 0').run();
const r = runNightly(db, { now: NOW });
assert.equal(db.prepare('SELECT offSince FROM sites').get().offSince, new Date(NOW).toISOString());
assert.equal(r.orphansMarked, 1);
runNightly(db, { now: NOW + D });
assert.equal(db.prepare('SELECT offSince FROM sites').get().offSince, new Date(NOW).toISOString());
});
test('Website mit offSince > 60 Tage wird weiter komplett gelöscht', () => {
const db = mk();
db.prepare('UPDATE sites SET active = 0, offSince = ?').run(new Date(NOW - 61 * D).toISOString());
const r = runNightly(db, { now: NOW });
assert.equal(r.sitesDeleted, 1);
assert.equal(db.prepare('SELECT COUNT(*) n FROM sites').get().n, 0);
});
test('Zeilen ohne sites-Eintrag werden gelöscht, andere bleiben', () => {
const db = mk();
const GONE = 'gonegonegonegonegone';
for (const s of [KEY, GONE]) {
db.prepare("INSERT INTO events_raw (site, ts, type) VALUES (?,?, 'pv')").run(s, NOW - 1000);
db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y) VALUES (?,?,?,?,?,?)').run(s, NOW - 1000, '/', 'd', 1, 1);
db.prepare("INSERT INTO daily (site, day, dim, key, count) VALUES (?, '2026-09-01','pv_total','',1)").run(s);
db.prepare("INSERT INTO agg_state (site, day) VALUES (?, '2026-09-01')").run(s);
}
const r = runNightly(db, { now: NOW });
for (const t of ['events_raw', 'clicks', 'daily', 'agg_state']) {
assert.deepEqual(db.prepare(`SELECT DISTINCT site FROM ${t}`).all().map((x) => x.site), [KEY], t);
}
assert.ok(r.orphanRowsDeleted >= 4);
});
test('secure_delete: gelöschter Pfad steht nicht mehr in der Datei', () => {
const dir = mkdtempSync(join(tmpdir(), 'sd-'));
const p = join(dir, 'a.db');
const db = openDb(p);
assert.equal(db.prepare('PRAGMA secure_delete').get().secure_delete, 1);
db.prepare("INSERT INTO events_raw (site, ts, type, path) VALUES ('x', 1, 'pv', 'GEHEIMER-PFAD-4711')").run();
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
db.exec("DELETE FROM events_raw WHERE path = 'GEHEIMER-PFAD-4711'");
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
assert.ok(!readFileSync(p).includes('GEHEIMER-PFAD-4711'));
db.close();
});
test('runNightly leert das WAL', () => {
const dir = mkdtempSync(join(tmpdir(), 'wal-'));
const p = join(dir, 'a.db');
const db = openDb(p);
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)').run(KEY, 'p', '[]');
runNightly(db, { now: NOW });
assert.equal(statSync(p + '-wal').size, 0);
db.close();
});
+41
View File
@@ -166,3 +166,44 @@ test('Datei-DB: zweimal öffnen (idempotent) und WAL', () => {
assert.equal(d2.prepare('PRAGMA journal_mode').get().journal_mode, 'wal');
d2.close();
});
test('POST …/off: 401 ohne Token, 404 unbekannt, setzt active=0 und offSince einmalig', async () => {
const t = await setup();
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`, undefined, null)).status, 401);
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`, undefined, 'falsch')).status, 401);
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`)).status, 404);
assert.equal((await t.call('POST', '/a/admin/sites/kurz/off')).status, 400);
await t.call('PUT', `/a/admin/sites/${KEY}`, valid);
assert.equal(row(t.db).active, 1);
const r = await t.call('POST', `/a/admin/sites/${KEY}/off`);
assert.equal(r.status, 200);
const s1 = row(t.db);
assert.equal(s1.active, 0);
assert.match(s1.offSince, /^\d{4}-\d\d-\d\dT/);
assert.deepEqual(JSON.parse(s1.domains), ['abc.project.webklar.com']);
t.db.prepare('UPDATE sites SET offSince = ? WHERE siteKey = ?').run('2026-01-01T00:00:00.000Z', KEY);
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`)).status, 200);
assert.equal(row(t.db).offSince, '2026-01-01T00:00:00.000Z');
t.close();
});
test('POST …/off: ruft onSitesChanged, danach verwirft /a/e Ereignisse und w.js ist leer', async () => {
const db = openDb(':memory:');
const pub = createPublicServer({ config: {}, db });
const adm = createAdminServer({ config: { adminToken: 't' }, db, onSitesChanged: () => pub.invalidateSites() });
await Promise.all([pub, adm].map((x) => new Promise((r) => x.listen(0, '127.0.0.1', r))));
const pb = `http://127.0.0.1:${pub.address().port}`;
const ab = `http://127.0.0.1:${adm.address().port}`;
await fetch(`${ab}/a/admin/sites/${KEY}`, { method: 'PUT', headers: { Authorization: 'Bearer t', 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: 'p', domains: ['abc.project.webklar.com'], active: true }) });
const ev = () => fetch(`${pb}/a/e`, { method: 'POST', headers: { 'Content-Type': 'text/plain', Origin: 'https://abc.project.webklar.com', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0) Chrome/120' },
body: JSON.stringify({ t: 'pv', site: KEY, path: '/', vp: 'd' }) });
await ev();
const before = db.prepare('SELECT COUNT(*) c FROM events_raw').get().c;
assert.equal(before, 1);
assert.equal((await fetch(`${ab}/a/admin/sites/${KEY}/off`, { method: 'POST', headers: { Authorization: 'Bearer t' } })).status, 200);
await ev();
assert.equal(db.prepare('SELECT COUNT(*) c FROM events_raw').get().c, before);
assert.equal((await (await fetch(`${pb}/a/w.js?s=${KEY}`)).text()).length < 200, true);
pub.close(); adm.close();
});
+202 -15
View File
@@ -115,12 +115,13 @@ async function admin(db, now = () => NOW) {
return { get, close: () => server.close() };
}
test('Stats-Endpunkt: Form, Serie lückenlos, heute live + Vergangenheit aus daily, consentRate', async () => {
test('Stats-Endpunkt: abgeschlossene Tage bis gestern, heute getrennt in today, Vergangenheit aus daily, consentRate', async () => {
const { db, ev } = mk();
const y = NOW - D;
for (let i = 0; i < 3; i++) ev({ ts: y + i, type: 'pv', path: '/a', vp: 'd', ref: 'g.de', country: 'DE', device: 'd' });
ev({ ts: y + 10, type: 'en', path: '/a', vp: 'd', visitor: 'v1', sec: 5, scroll: 5 });
ev({ ts: y + 11, type: 'bn', kind: 'accept' });
ev({ ts: y + 12, type: 'ct', path: '/a', visitor: 'v1', kind: 'tel' });
runNightly(db, { now: NOW }); // gestern -> daily
ev({ ts: NOW - H, type: 'pv', path: '/b', vp: 'm', country: 'AT', device: 'm' }); // heute live
ev({ ts: NOW - H, type: 'ct', path: '/b', visitor: 'v9', kind: 'mail' });
@@ -132,38 +133,193 @@ test('Stats-Endpunkt: Form, Serie lückenlos, heute live + Vergangenheit aus dai
const r = await a.get(`/a/admin/stats/${KEY}?range=7`);
assert.equal(r.status, 200);
const j = await r.json();
assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'pages', 'refs', 'series', 'totals']);
assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'firstDay', 'from', 'pages', 'previous', 'range', 'refs', 'series', 'to', 'today', 'totals']);
// Der Zeitraum sind die 7 abgeschlossenen Tage bis gestern (3.–9.10.); heute (10.10.) steht nur in `today`
assert.equal(j.series.length, 7);
assert.equal(j.series[0].day, '2026-10-04');
assert.equal(j.series[6].day, '2026-10-10');
assert.deepEqual(j.series.map((s) => s.pv), [0, 0, 0, 0, 0, 3, 1]);
assert.deepEqual(j.totals, { pv: 4, visitors: 2, contacts: 1, consentRate: 2 / 4 });
assert.deepEqual(j.pages, [{ key: '/a', count: 3 }, { key: '/b', count: 1 }]);
assert.equal(j.series[0].day, '2026-10-03');
assert.equal(j.series[6].day, '2026-10-09');
assert.deepEqual(j.series.map((s) => s.pv), [0, 0, 0, 0, 0, 0, 3]);
assert.deepEqual(j.totals, { pv: 3, visitors: 1, contacts: 1, consentRate: 1, engaged: 1, avgSec: 5, avgScroll: 5 });
assert.deepEqual(j.series[6], { day: '2026-10-09', pv: 3, visitors: 1, contacts: 1, avgSec: 5 });
assert.deepEqual(j.today, { day: '2026-10-10', pv: 1, visitors: 1, contacts: 1 });
assert.equal(j.from, '2026-10-03'); assert.equal(j.to, '2026-10-09'); assert.equal(j.range, 7);
assert.deepEqual(j.pages, [{ key: '/a', count: 3 }]);
assert.deepEqual(j.refs[0], { key: 'g.de', count: 3 });
assert.deepEqual(j.countries, [{ key: 'DE', count: 3 }, { key: 'AT', count: 1 }]);
assert.deepEqual(j.devices, [{ key: 'd', count: 3 }, { key: 'm', count: 1 }]);
assert.deepEqual(j.contacts, [{ key: 'mail', count: 1 }]);
assert.deepEqual(j.banner, { shown: 1, accept: 2, reject: 2 });
assert.deepEqual(j.countries, [{ key: 'DE', count: 3 }]);
assert.deepEqual(j.devices, [{ key: 'd', count: 3 }]);
assert.deepEqual(j.contacts, [{ key: 'tel', count: 1 }]);
assert.deepEqual(j.banner, { shown: 0, accept: 1, reject: 0 });
// range 90: 90 Tage
const j90 = await (await a.get(`/a/admin/stats/${KEY}?range=90`)).json();
assert.equal(j90.series.length, 90);
assert.equal(j90.totals.pv, 4);
assert.equal(j90.series[89].day, '2026-10-09');
assert.equal(j90.totals.pv, 3);
assert.deepEqual(j90.today, j.today);
a.close();
});
test('Stats: today = heutiger Berliner Tag bis jetzt, auch ohne Ereignisse; Mitternacht in Berlin trennt gestern und heute', async () => {
const { db, ev } = mk();
const a = await admin(db);
assert.deepEqual((await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).today, { day: '2026-10-10', pv: 0, visitors: 0, contacts: 0 });
a.close();
// 11.10. 00:10 Berlin (= 10.10. 22:10 UTC, Sommerzeit): 23:00 Berlin am 10.10. gehört zu gestern, 00:05 Berlin am 11.10. zu heute
const LATE = Date.UTC(2026, 9, 10, 22, 10);
ev({ ts: Date.UTC(2026, 9, 10, 21, 0), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: Date.UTC(2026, 9, 10, 22, 5), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: Date.UTC(2026, 9, 10, 22, 6), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: Date.UTC(2026, 9, 10, 22, 7), type: 'ct', path: '/', visitor: 'v1', kind: 'wa' });
ev({ ts: Date.UTC(2026, 9, 10, 23, 0), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // noch in der Zukunft: zählt nicht
const b = await admin(db, () => LATE);
const j = await (await b.get(`/a/admin/stats/${KEY}?range=7`)).json();
assert.equal(j.to, '2026-10-10');
assert.equal(j.series[6].day, '2026-10-10'); assert.equal(j.series[6].pv, 1);
assert.equal(j.totals.pv, 1);
assert.deepEqual(j.today, { day: '2026-10-11', pv: 2, visitors: 1, contacts: 1 });
assert.equal(j.contacts.length, 0); // die Kontakt-Aktion von 00:07 ist heute
b.close();
});
test('Stats: gestern noch nicht verdichtet wird live gezählt, nie doppelt', async () => {
const { db, ev } = mk();
ev({ ts: NOW - D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: NOW - H, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // heute: nur in today
const a = await admin(db);
assert.equal((await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).totals.pv, 1);
const stats = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json());
assert.equal((await stats()).totals.pv, 1);
runNightly(db, { now: NOW });
assert.equal((await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).totals.pv, 1);
const j = await stats();
assert.equal(j.totals.pv, 1);
assert.equal(j.today.pv, 1);
a.close();
});
test('Stats: Vorzeitraum = die range Kalendertage vor dem aktuellen Zeitraum, complete nach firstDay', async () => {
const { db, ev } = mk();
// aktueller Zeitraum zu range 7 am 10.10.: 03.–09.10.; Vorzeitraum: 26.09.–02.10.
ev({ ts: Date.UTC(2026, 8, 30, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: Date.UTC(2026, 9, 1, 10), type: 'en', path: '/', vp: 'd', visitor: 'v1', sec: 20, scroll: 40 });
ev({ ts: Date.UTC(2026, 9, 2, 10), type: 'ct', path: '/', visitor: 'v2', kind: 'tel' });
const a = await admin(db);
const get = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json());
let j = await get();
assert.equal(j.firstDay, '2026-09-30');
assert.equal(j.previous.complete, false);
// 27.09. liegt noch einen Tag nach dem Beginn des Vorzeitraums (26.09.): weiter unvollständig
ev({ ts: Date.UTC(2026, 8, 27, 10), type: 'pv', path: '/x', vp: 'd', country: 'DE', device: 'd' });
j = await get();
assert.equal(j.firstDay, '2026-09-27');
assert.equal(j.previous.complete, false);
// genau am ersten Tag des Vorzeitraums: vollständig
ev({ ts: Date.UTC(2026, 8, 26, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
j = await get();
assert.equal(j.firstDay, '2026-09-26');
assert.equal(j.previous.complete, true);
// 25.09. liegt vor dem Vorzeitraum und darf nicht mitzählen
ev({ ts: Date.UTC(2026, 8, 25, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
j = await get();
assert.equal(j.firstDay, '2026-09-25');
assert.equal(j.previous.complete, true);
assert.equal(j.previous.from, '2026-09-26'); assert.equal(j.previous.to, '2026-10-02');
assert.equal(j.previous.series.length, 7); assert.equal(j.previous.series[4].day, '2026-09-30');
assert.equal(j.previous.totals.pv, 3); // 26.09., 27.09., 30.09.
assert.equal(j.previous.totals.engaged, 1);
assert.equal(j.previous.totals.avgSec, 20);
assert.equal(j.previous.totals.contacts, 1);
assert.deepEqual(j.previous.series[5], { day: '2026-10-01', pv: 0, visitors: 1, contacts: 0, avgSec: 20 });
assert.deepEqual(j.previous.series[6], { day: '2026-10-02', pv: 0, visitors: 1, contacts: 1, avgSec: null });
assert.deepEqual(j.previous.contacts, [{ key: 'tel', count: 1 }]);
assert.equal(j.totals.pv, 0); // aktueller Zeitraum leer
assert.equal(j.totals.avgSec, null);
a.close();
});
test('Stats: firstDay aus daily und Rohdaten, leer ohne Daten', async () => {
const { db, ev } = mk();
const a = await admin(db);
const first = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json());
let j = await first();
assert.equal(j.firstDay, '');
assert.equal(j.previous.complete, false);
// nur Rohdaten (heute)
ev({ ts: NOW - H, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
assert.equal((await first()).firstDay, '2026-10-10');
// 40 Tage alt: nach dem Nachtlauf nur noch in daily (Rohdaten > 30 Tage gelöscht)
ev({ ts: NOW - 40 * D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
assert.equal((await first()).firstDay, '2026-08-31');
runNightly(db, { now: NOW });
assert.equal(db.prepare('SELECT COUNT(*) c FROM events_raw WHERE ts < ?').get(NOW - 31 * D).c, 0);
j = await first();
assert.equal(j.firstDay, '2026-08-31');
assert.equal(j.previous.complete, true);
a.close();
});
test('Stats: avgSec/avgScroll gerundet, null ohne en; Tageswert avgSec', async () => {
const { db, ev } = mk();
const t = NOW - D; // 9.10.
ev({ ts: t, type: 'en', path: '/', vp: 'd', visitor: 'v1', sec: 30, scroll: 50 });
ev({ ts: t + 1, type: 'en', path: '/a', vp: 'd', visitor: 'v2', sec: 91, scroll: 100 });
const a = await admin(db);
const j = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json();
assert.equal(j.totals.engaged, 2);
assert.equal(j.totals.avgSec, 61); // 60,5 gerundet
assert.equal(j.totals.avgScroll, 75);
assert.equal(j.series[6].avgSec, 61); // 9.10. ist der letzte abgeschlossene Tag
assert.equal(j.series[5].avgSec, null);
// ohne en: beide null, engaged 0
const leer = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json();
assert.equal(leer.previous.totals.engaged, 0);
assert.equal(leer.previous.totals.avgSec, null);
assert.equal(leer.previous.totals.avgScroll, null);
a.close();
});
test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappung', async () => {
// range 30 um 23:30 Berlin: Zeitumstellung am 25.10. liegt im Zeitraum. Spätabends wäre now - 30 * 24 h
// schon der 12.10. (00:30 Berlin) und ein naives Fenster würde sich überlappen; mittags fiele das nicht auf.
const NOV = Date.UTC(2026, 10, 10, 22, 30);
const { db, ev } = mk();
ev({ ts: Date.UTC(2026, 9, 10, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 10.10. mittags: Vorzeitraum
ev({ ts: Date.UTC(2026, 9, 11, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 11.10. mittags: aktueller Zeitraum
ev({ ts: Date.UTC(2026, 10, 10, 12), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 10.11. mittags: heute
const a = await admin(db, () => NOV);
const j = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json();
assert.equal(j.series[0].day, '2026-10-11');
assert.equal(j.series[29].day, '2026-11-09');
assert.equal(j.previous.series[29].day, '2026-10-10');
assert.equal(j.previous.series[0].day, '2026-09-11');
assert.equal(j.from, '2026-10-11'); assert.equal(j.to, '2026-11-09');
assert.equal(j.previous.from, '2026-09-11'); assert.equal(j.previous.to, '2026-10-10');
const all = [...j.series, ...j.previous.series].map((s) => s.day);
assert.equal(all.length, 60);
assert.equal(new Set(all).size, 60);
assert.equal(j.previous.totals.pv, 1); assert.equal(j.previous.series[29].pv, 1);
assert.equal(j.totals.pv, 1); assert.equal(j.series[0].pv, 1);
assert.equal(j.today.day, '2026-11-10'); assert.equal(j.today.pv, 1);
a.close();
});
test('Stats: Vorzeitraum aus daily und live, nie doppelt', async () => {
const { db, ev } = mk();
ev({ ts: Date.UTC(2026, 9, 1, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: Date.UTC(2026, 9, 1, 11), type: 'ct', path: '/', visitor: 'v1', kind: 'mail' });
const a = await admin(db);
const get = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).previous;
const live = await get(); // noch nicht verdichtet: live aus den Rohdaten
assert.equal(live.totals.pv, 1);
runNightly(db, { now: NOW }); // jetzt aus daily
const verdichtet = await get();
assert.equal(verdichtet.totals.pv, 1);
assert.deepEqual(verdichtet, live);
runNightly(db, { now: NOW + H });
assert.deepEqual(await get(), live);
a.close();
});
test('Stats: Top 20 absteigend, consentRate 0 ohne Entscheidungen, Fehlerfälle', async () => {
const { db, ev } = mk();
for (let p = 0; p < 25; p++) for (let i = 0; i <= p; i++) ev({ ts: NOW - 1000, type: 'pv', path: `/p${p}`, vp: 'd', country: 'DE', device: 'd' });
for (let p = 0; p < 25; p++) for (let i = 0; i <= p; i++) ev({ ts: NOW - D - 1000, type: 'pv', path: `/p${p}`, vp: 'd', country: 'DE', device: 'd' });
const a = await admin(db);
const j = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json();
assert.equal(j.pages.length, 20);
@@ -204,3 +360,34 @@ test('scheduleNightly: früh feuernder Timer läuft nicht vorzeitig und nie dopp
s.stop();
assert.equal(pending.length, 0);
});
test('T2: pv ohne Einwilligung + cx: pv_total 1, pv_ref nur {google.com:1}; cx zählt nie pv_total', () => {
const { db, ev } = mk();
const t = NOW - D;
ev({ ts: t, type: 'pv', path: '/', vp: null, ref: null, country: 'DE', device: 'd' });
ev({ ts: t, type: 'cx', vp: 'd', ref: 'google.com' });
ev({ ts: t, type: 'cx', vp: 'd', ref: null });
ev({ ts: t, type: 'pv', path: '/', vp: 'd', ref: null, country: 'DE', device: 'd' });
runNightly(db, { now: NOW });
const d = '2026-10-09';
assert.equal(get(db, d, 'pv_total'), 2);
assert.equal(get(db, d, 'pv_ref', 'google.com'), 1);
assert.equal(get(db, d, 'pv_ref', ''), 2); // pv mit vp und cx ohne ref
const refs = db.prepare("SELECT key FROM daily WHERE site=? AND dim='pv_ref' ORDER BY key").all(KEY).map((r) => r.key);
assert.deepEqual(refs, ['', 'google.com']);
});
test('T2: Stats-Endpunkt refs live und aus daily, nie doppelt', async () => {
const { db, ev } = mk();
ev({ ts: NOW - D, type: 'pv', path: '/', vp: null, country: 'DE', device: 'd' });
ev({ ts: NOW - D, type: 'cx', vp: 'd', ref: 'google.com' });
const a = await admin(db);
const live = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json();
assert.equal(live.totals.pv, 1);
assert.deepEqual(live.refs, [{ key: 'google.com', count: 1 }]);
runNightly(db, { now: NOW });
const agg = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json();
assert.equal(agg.totals.pv, 1);
assert.deepEqual(agg.refs, [{ key: 'google.com', count: 1 }]);
a.close();
});
Binary file not shown.
@@ -0,0 +1 @@
21d406e6836a7d0c1afdb99dc259e4174fb3fe499afcbdac30c8a45a1aa8d605
@@ -0,0 +1,55 @@
# Mustertext: Datenschutzerklärung der Kundenwebsite (Webshop)
**Muster – keine Rechtsberatung.** Der Absatz gehört in die Datenschutzerklärung jeder Kundenwebsite, auf der der Webshop läuft. Der Händler ist Verantwortlicher und verantwortet seine Datenschutzerklärung selbst; WEBklar liefert nur diesen Baustein (Leistungsbeschreibung Webshop Nr. 5 Abs. 10 und Nr. 6). Bau den Shop erst ein, wenn der Absatz in der Datenschutzerklärung steht.
Hinweise für das Team:
- Liegt die Datenschutzerklärung in der Website, die WEBklar betreut, fügen wir den Absatz beim Einbau ein (gehört zur Einrichtung). Liegt sie woanders, bekommt der Händler den Text im Chat und fügt ihn selbst ein.
- Platzhalter in eckigen Klammern durch die Angaben des Händlers ersetzen. Nicht Zutreffendes streichen: Klarna nur, wenn der Händler Klarna in den Zahlungsarten eingeschaltet hat; den Satz zum Versanddienstleister nur, wenn er versendet; die Zeile zur Abholung nur bei Abholung.
- Anrede und Ton an die übrige Erklärung der Website anpassen (meist Sie). Die Nummerierung der Überschrift der Website beachten.
- Wenn der Händler im Impressum oder in der Erklärung einen Datenschutzbeauftragten nennt, dort dazuschreiben.
- Die Löschfristen entsprechen dem AVV Webshop Nr. 9 und Abschnitt 6d der Datenschutzerklärung von WEBklar (project.webklar.com/datenschutz.html). Ändert sich dort etwas, hier mitändern. Die Frist für Server-Protokolle (14 Tage) steht in Abschnitt 3 dieser Erklärung.
- Das Widget setzt keine Cookies. Der Warenkorb liegt in der `sessionStorage` des Tabs, das ist Absatz „Warenkorb im Browser“.
- Im sichtbaren Text der Kundenwebsite die Wünsche des Händlers beachten (etwa keine Gedankenstriche).
---
## [Nummer]. Bestellungen in unserem Webshop
### Verantwortlicher und technischer Betrieb
Verantwortlich für die Verarbeitung Ihrer Daten bei einer Bestellung ist [Name des Händlers, Anschrift, E-Mail-Adresse], also der Verkäufer. Den Webshop betreibt technisch die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, in unserem Auftrag. Sie verarbeitet Ihre Daten nur nach unseren Weisungen und nicht für eigene Zwecke (Auftragsverarbeitung nach Art. 28 DSGVO). Der Server steht bei der Hetzner Online GmbH in Falkenstein (Deutschland); der Versand der E-Mails zu Ihrer Bestellung läuft über den E-Mail-Dienst der Hetzner Online GmbH in Nürnberg (Deutschland).
### Welche Daten wir bei einer Bestellung verarbeiten
Wenn Sie in unserem Webshop bestellen, verarbeiten wir die Artikel in Ihrem Warenkorb und Ihre Bestellung (Artikel, Preise, Versandart, Gesamtbetrag, Bestellnummer, Zeitpunkt), Ihren Namen, Ihre E-Mail-Adresse sowie Ihre Liefer- und Rechnungsanschrift, den Stand der Zahlung und den Stand Ihrer Bestellung (zum Beispiel bezahlt, versendet, abholbereit, erstattet). Name und Anschriften geben Sie auf der Zahlungsseite von Stripe ein (siehe unten); wir erhalten sie von dort, die Rechnungsanschrift nur, wenn Stripe sie für Ihre Zahlungsart abfragt. Eine Telefonnummer verlangen und speichern wir nicht. Die Verarbeitung ist nötig, um den Kaufvertrag mit Ihnen zu schließen und zu erfüllen (Art. 6 Abs. 1 lit. b DSGVO). Ohne diese Angaben können wir Ihre Bestellung nicht ausführen. Außerdem speichern wir in der noch offenen Bestellung einen Hashwert Ihrer IP-Adresse (nicht die IP-Adresse selbst; er wird mit einem geheimen Schlüssel des Servers berechnet, sodass sich daraus keine IP-Adresse ableiten lässt). Wir lassen damit je IP-Adresse höchstens drei offene, noch nicht bezahlte Bestellungen innerhalb von 30 Minuten zu und schützen den Webshop so vor automatisierten Massenbestellungen, die den Bestand blockieren. Das ist unser berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO); Sie können dieser Verarbeitung widersprechen (Art. 21 DSGVO). Wir verwenden den Hashwert nur dafür und löschen ihn, sobald Ihre Bestellung bezahlt oder storniert ist, spätestens mit der Löschung nicht bezahlter Bestellungen nach 30 Tagen.
### Zahlung über Stripe
Die Zahlung wickelt die Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, D02 H210, Irland („Stripe“) ab. Sie werden dazu auf die Zahlungsseite von Stripe weitergeleitet. Karten- und Kontodaten geben Sie nur dort ein; wir und der Betreiber des Webshops erhalten sie nicht. Stripe verarbeitet Ihre Daten teils im Rahmen der Zahlungsabwicklung für uns und teils als eigener Verantwortlicher, zum Beispiel zur Betrugsvorbeugung und zur Erfüllung gesetzlicher Pflichten; dafür gilt die Datenschutzerklärung von Stripe (stripe.com/de/privacy). Dabei können Daten an die Stripe, LLC in den USA übermittelt werden. Die Stripe, LLC ist nach dem EU-US Data Privacy Framework zertifiziert, für das ein Angemessenheitsbeschluss der EU-Kommission besteht. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.
Welche Zahlungsarten angeboten werden, sehen Sie vor dem Kauf im Webshop. Zahlen Sie per SEPA-Lastschrift, gibt Stripe Ihre Kontodaten an die beteiligten Banken weiter und bucht den Betrag nach Ihrem Mandat ab. [Nur wenn Klarna eingeschaltet ist: Wählen Sie Klarna, übermittelt Stripe die zur Zahlung erforderlichen Daten an die Klarna Bank AB (publ), Sveavägen 46, 111 34 Stockholm, Schweden; es gelten die Datenschutzbestimmungen von Klarna, die Ihnen bei der Zahlung angezeigt werden.]
### Bestellbestätigung und weitere E-Mails
Zu Ihrer Bestellung senden wir Ihnen E-Mails an die angegebene E-Mail-Adresse: bei einer Zahlung, die noch aussteht (zum Beispiel SEPA-Lastschrift), zuerst eine Mail über den Eingang der Bestellung, Bestellbestätigung (mit den Vertragsbedingungen, der Widerrufsbelehrung und dem Muster-Widerrufsformular; es sind die Texte, die bei Ihrer Bestellung galten), Hinweise zum Versand [oder zur Abholbereitschaft], Bestätigungen zu einer Erstattung oder zu einem Widerruf und, wenn eine Zahlung nicht gelungen ist, einen Hinweis darauf. Das sind Mitteilungen zur Abwicklung Ihres Vertrags (Art. 6 Abs. 1 lit. b DSGVO), keine Werbung. Bis zum Versand liegt jede E-Mail in einer Warteschlange; nach dem Versand wird Empfängeradresse und Inhalt dort gelöscht, der Eintrag selbst nach sieben Tagen.
### Warenkorb im Browser
Ihren Warenkorb (Artikel und Mengen, keine persönlichen Daten) speichert der Webshop im Sitzungsspeicher (sessionStorage) Ihres Browsers, solange der Tab geöffnet ist. Das ist technisch erforderlich, damit der Warenkorb beim Durchklicken erhalten bleibt (§ 25 Abs. 2 Nr. 2 TDDDG); es werden keine Cookies gesetzt, und der Eintrag verschwindet, wenn Sie den Tab schließen. Beim Aufruf der Seite lädt Ihr Browser das Programm des Webshops und die Artikeldaten von der Adresse project.webklar.com. Dabei werden Ihre IP-Adresse, Datum und Uhrzeit des Aufrufs sowie Angaben zu Ihrem Browser und Betriebssystem übertragen und für 14 Tage in Server-Protokollen gespeichert, um den sicheren und störungsfreien Betrieb zu gewährleisten (Art. 6 Abs. 1 lit. f DSGVO). Zum Schutz vor Missbrauch begrenzen wir die Zahl der Bestellungen und Widerrufe je IP-Adresse; die Zeitpunkte der Aufrufe werden nur im Arbeitsspeicher des Servers gehalten. Mit Ihrer Bestellung oder Ihrem Widerruf wird Ihre IP-Adresse nicht gespeichert.
### Widerruf über die Widerrufsfunktion
Über den Link „Vertrag widerrufen“ können Sie Ihren Vertrag ohne Anmeldung online widerrufen. Dabei verarbeiten wir Ihren Namen, Ihre E-Mail-Adresse, die Bestellnummer, Ihre Erklärung und den Zeitpunkt des Eingangs. Sie erhalten sofort eine Eingangsbestätigung mit Inhalt, Datum und Uhrzeit Ihrer Erklärung. Rechtsgrundlage ist Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB sowie Art. 6 Abs. 1 lit. b DSGVO.
### Weitergabe
[Nur bei Versand: Zur Lieferung geben wir Ihren Namen und Ihre Lieferanschrift an das Versandunternehmen weiter, das wir beauftragen ([Name des Versanddienstleisters]).] Im Übrigen geben wir Ihre Daten nur weiter, soweit das für die Vertragsabwicklung nötig ist (Stripe und die genannten Zahlungsanbieter, Auftragsverarbeiter) oder wir gesetzlich dazu verpflichtet sind (zum Beispiel an das Finanzamt). Eine Weitergabe zu Werbezwecken findet nicht statt.
### Speicherdauer
Bestellungen, die nicht bezahlt oder die storniert wurden, löscht das Webshop-System nach 30 Tagen. Für bezahlte Bestellungen sind wir als Händler verpflichtet, Buchungsbelege und Geschäftsunterlagen nach Handels- und Steuerrecht aufzubewahren (in der Regel sechs bis zehn Jahre, § 257 HGB, § 147 AO; Art. 6 Abs. 1 lit. c DSGVO). Diese Unterlagen führen wir selbst und mit den Belegen von Stripe, nicht im Webshop-System. Im Webshop-System selbst werden Name, E-Mail-Adresse und Anschriften drei Jahre nach dem Ende des Jahres der Bestellung anonymisiert; die Bestellnummer, die Artikel und die Beträge bleiben ohne Personenbezug erhalten. Daten aus einem Widerruf werden ebenso behandelt wie die zugehörige Bestellung.
### Ihre Rechte
Sie haben das Recht auf Auskunft über Ihre Daten (Art. 15 DSGVO), auf Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18), Datenübertragbarkeit (Art. 20) und auf Widerspruch gegen Verarbeitungen, die auf Art. 6 Abs. 1 lit. f DSGVO beruhen (Art. 21). Wenden Sie sich dazu an uns unter [E-Mail-Adresse des Händlers]; wir leiten Ihre Anfrage, soweit nötig, an den technischen Betreiber des Webshops weiter. Außerdem können Sie sich bei einer Datenschutz-Aufsichtsbehörde beschweren, etwa bei der für uns zuständigen Behörde [zuständige Aufsichtsbehörde des Händlers].
+98
View File
@@ -0,0 +1,98 @@
# Webshop auf der Kundenwebsite einbauen
Anleitung für das WEBklar-Team. Das Widget lässt Besucher auf der Website des Kunden („Händler“) Waren bestellen und bezahlen. Artikel, Versand, Zahlungsarten und Rechtstexte pflegt der Händler im Kundenportal (Tab „Funktionen“, Kachel „Webshop“). Den `siteKey` (16 Zeichen, a–z und 2–7) findest Du im Einrichtungs-Ticket und im Ticketsystem (WOMS): Kunde → Reiter „Funktionen“, Zeile Webshop. Im Ticket stehen Snippet und Schritte schon fertig; dieses Dokument ist die Referenz dahinter. Ohne aktive Funktion, ohne verbundenes Stripe-Konto, ohne bestätigte Rechtstexte oder ohne lieferbaren Artikel ist nichts bestellbar: Das Widget zeigt dann „Der Shop ist gerade geschlossen“ (oder den Ausweichtext, siehe Abschnitt 4).
Der Webshop ist an den Vertrag über den Betrieb der Website gekoppelt (Leistungsbeschreibung Webshop Nr. 10), aber **nicht in der Software**: Das Portal liest keine Abo-Daten für den Shop, schaltet ihn nicht von selbst ab, und bestellt werden kann er auch ohne Abo. Das Abschalten zum Vertragsende machen wir von Hand (Abschnitt 8).
## 1. Das Snippet
```html
<div data-webklar-shop="SITEKEY" data-api="https://project.webklar.com">
<p>Unser Shop ist gerade nicht erreichbar. Bitte schreiben Sie uns: info@beispiel.de</p> <!-- Ausweichtext, Abschnitt 4 -->
</div>
<script src="https://project.webklar.com/embed/shop.js" defer></script>
```
Das Skript steht einmal pro Seite, vor `</body>`. Es hat keine Abhängigkeiten, setzt keine Cookies, lädt nichts von Dritten und fragt nur `project.webklar.com` an. Einziger Speicher im Browser ist der Warenkorb (nur Artikelkennungen und Mengen) in der `sessionStorage` des Tabs; er ist technisch erforderlich (§ 25 Abs. 2 Nr. 2 TDDDG) und verschwindet mit dem Tab. Texte des Händlers setzt das Skript nur als Text ein, nie als HTML. Das Widget rechnet nie selbst: Preise, Summen, Versand und Steuerhinweise kommen fertig vom Server.
| Attribut | Pflicht | Bedeutung |
| --- | --- | --- |
| `data-webklar-shop` | ja | der `siteKey` des Projekts |
| `data-api` | nein | Herkunft des Portals, Standard `https://project.webklar.com`. Bei Modul-Import, Tag-Manager oder nachgeladenem Skript ist `data-api` Pflicht. |
| `data-ansicht` | nein | `widerruf` öffnet gleich die Widerrufsfunktion („Vertrag widerrufen“). Die Adresse der Seite kennt außerdem `?ansicht=widerruf` und `?ansicht=agb`. |
## 2. Wohin damit
1. **Eigene Seite „Shop“** anlegen (zum Beispiel `/shop` oder `/shop.html`) und das Snippet an die Stelle des Inhalts setzen. Das `div` braucht Breite (normaler Blockcontainer; in einem Flex- oder Grid-Element ohne Breite schrumpft es auf null). Der Warenkorb liegt nur im Tab, also darf die Seite beim Bestellen nicht in einem iframe liegen und der Händler braucht keine zweite Seite für die Kasse: Kasse, Danke-Seite und Widerruf laufen im selben Widget.
2. **Menüpunkt „Shop“** im Hauptmenü auf diese Seite, auf allen Seiten.
3. Vom Shop zurück: Nach der Zahlung bei Stripe kehrt der Käufer auf die Seite zurück, auf der er bestellt hat (nur Adressen der Website des Projekts sind erlaubt, sonst die Standalone-Seite). Die Seite darf also nicht hinter einer Weiterleitung oder einem Passwortschutz liegen.
4. **Mehrere Widgets** auf einer Seite sind nicht vorgesehen. Der Shop ist ein Widget je Website.
Aussehen: Das Widget nimmt die Akzentfarbe aus den Einstellungen des Händlers. Wie beim Terminplaner stehen die Stile in `@layer wks`, jede Regel der Kundenseite gewinnt also. Anpassen per CSS-Variablen auf dem `div`, in der Seiten-CSS oder auf `:root`, zum Beispiel `[data-webklar-shop]{ --wks-akzent:#0f766e; --wks-akzent-text:#fff; }`:
| Variable | Standard |
| --- | --- |
| `--wks-akzent` und `--wks-akzent-text` (Knöpfe; setze beide zusammen) | Akzentfarbe des Händlers, sonst `#1d4ed8` und `#fff` |
| `--wks-hintergrund` | `#fff` |
| `--wks-text` | `#1f2937` |
| `--wks-rand` | `#858f9e` |
| `--wks-radius` | `10px` |
| `--wks-schrift`, `--wks-schrift-titel` | Schrift der Seite |
Kontrast (WCAG 2.1 AA): Text auf dem Hintergrund und Schrift auf dem Akzent mindestens 4,5 : 1, Rand gegen den Hintergrund mindestens 3 : 1. Prüfe beim Abnehmen, ob globale Regeln des Themes (`button{…}`, `input{…}`) das Widget stören, und überschreibe sie gezielt (`[data-webklar-shop] .wks-btn{ … }`).
## 3. Link „Vertrag widerrufen“ auf jeder Seite (Pflicht)
Seit dem 19.06.2026 muss jeder Online-Shop eine elektronische Widerrufsfunktion bieten (§ 356a BGB). Sie muss **auf jeder Seite** der Website dauerhaft erreichbar sein, nicht nur im Shop:
- In der **Fußzeile jeder Seite** (auch Impressum, Datenschutz und 404-Seite) steht der Link **„Vertrag widerrufen“** auf `https://project.webklar.com/shop/<siteKey>/widerruf` (mit dem echten `siteKey`).
- Der Linktext lautet genau „Vertrag widerrufen“. Er darf nicht ausgeblendet, versteckt oder in einem Aufklappmenü untergebracht werden.
- Der Link braucht keine Anmeldung. Er führt zur Schaltfläche „Vertrag widerrufen“, danach zum Formular und zur Bestätigungsschaltfläche „Widerruf bestätigen“.
- Im Shop-Widget selbst steht der Knopf „Vertrag widerrufen“ zusätzlich in der Fußzeile des Widgets.
Der Händler darf den Link nicht entfernen oder ändern (Leistungsbeschreibung Nr. 5 Abs. 6). Weise ihn bei der Übergabe darauf hin.
## 4. Ausweichtext
Alles, was im `div` steht, gehört der Kundenwebsite. Das Skript ersetzt es nur, wenn die Konfiguration geladen werden konnte. Bei ausgeschaltetem JavaScript, unbekanntem Schlüssel, abgeschalteter Funktion oder Netzfehler bleibt der Text stehen. Schreib dort einen Hinweis und die Kontaktdaten des Händlers hinein. Ist der Shop zwar eingebunden, aber (noch) nicht geöffnet, zeigt das Widget selbst „Der Shop ist gerade geschlossen“.
## 5. Vor der Abnahme prüfen
**Impressum des Händlers.** Der Händler ist der Verkäufer. Sein Impressum muss ihn nennen (Name beziehungsweise Firma, vollständige Anschrift, E-Mail, Telefon, gegebenenfalls Register und USt-IdNr.). Es reicht nicht, wenn nur WEBklar als Betreiber der Website erscheint. Die Adressen von Impressum und Datenschutzerklärung (https) hat der Händler im Portal unter Rechtliches eingetragen; das Widget verlinkt sie in der Fußzeile und in der Kasse. Prüfe, dass beide Adressen auf die echten Seiten der Website führen. Fällt Dir ein offensichtlicher Mangel auf, sag es dem Händler im Chat (Leistungsbeschreibung Nr. 5 Abs. 10: Eine Prüfpflicht entsteht daraus nicht).
**Datenschutz-Absatz.** Liegt die Datenschutzerklärung in der Website, die wir betreuen, fügen wir den Absatz aus `docs/funktionen/shop-datenschutz-absatz.md` dort ein (gehört zur Einrichtung). Liegt sie woanders, bekommt der Händler den Text per Chat und fügt ihn selbst ein. Anrede und Nummerierung an die übrige Erklärung anpassen; die Angaben zur Speicherdauer entsprechen dem AVV Webshop Nr. 9 und Abschnitt 6d unserer Datenschutzerklärung. Ändert sich dort etwas, ändere den Absatz mit.
**Rechtstexte.** AGB, Widerrufsbelehrung, Muster-Widerrufsformular sowie Versand und Zahlung bestätigt der Händler im Portal (Rechtliches). Die Muster liegen in `docs/funktionen/shop-muster-rechtstexte.md`. Ohne Bestätigung öffnet der Shop nicht.
**Artikel.** Wirf einen Blick auf die Pflichtangaben: Preis, Grundpreis (immer je 1 kg, 1 l, 1 m oder 1 m²), Lieferzeit, bei Lebensmitteln die Pflichtangaben und Allergene, die Angaben zur Produktsicherheit und Widerrufsausschlüsse. Das ist Sache des Händlers; ein Hinweis im Chat hilft.
## 6. Vorschau und Testkauf
1. **Vorschau:** `https://project.webklar.com/shop/<siteKey>` zeigt das Widget auf einer neutralen Seite. Prüfe dort Artikel, Warenkorb, Versand und die Fußzeile (AGB, Widerrufsbelehrung, Versand & Zahlung, Vertrag widerrufen, Impressum, Datenschutz). Prüfe danach dasselbe auf der echten Seite der Kundenwebsite, auch auf dem Handy.
2. **Testkauf mit dem Händler.** Lege mit dem Händler einen Artikel für **1 €** an (nur für den Test, danach wieder löschen oder deaktivieren). Der Händler öffnet den Shop (Übersicht → „Shop geöffnet“, alle Haken grün), Du kaufst den Artikel auf der echten Seite mit Deiner E-Mail-Adresse und zahlst echt (das Stripe-Konto des Händlers ist ein Live-Konto). Prüfe: Bestätigungsseite „Danke“, Bestellbestätigungsmail beim Käufer (mit AGB, Widerrufsbelehrung und Muster-Widerrufsformular im Text), Mail „Neue Bestellung“ beim Händler, Bestellung im Portal als bezahlt.
3. **Widerruf testen:** Rufe den Link „Vertrag widerrufen“ aus der Fußzeile auf, erkläre den Widerruf zur Testbestellung und bestätige ihn. Du musst sofort eine Eingangsbestätigung mit Inhalt, Datum und Uhrzeit bekommen; der Händler bekommt die Mitteilung, der Widerruf steht an der Bestellung im Portal.
4. **Erstatten:** Der Händler erstattet den Testkauf im Portal (Bestellung → Erstatten). Prüfe die Erstattungsmail und dass Stripe die Rückzahlung zeigt. Den Testartikel danach löschen.
5. Ist alles in Ordnung, **schließe das Einrichtungs-Ticket**. Das schaltet die Funktion frei (Kunde erhält die Mail „Webshop bereit“). Trag die Zeit im Arbeitsblatt ein; sie wird nicht auf Abo-Stunden angerechnet (Leistungsbeschreibung Nr. 9 Abs. 4).
## 7. Was das Widget zeigt (Kurzüberblick)
1. Ganz oben: Lieferländer und akzeptierte Zahlungsarten (Beginn des Bestellvorgangs, § 312j Abs. 1 BGB).
2. Artikel (Kategorien, Raster), Detail mit Bildern, Beschreibung, Preis, Grundpreis, Varianten, Lieferhinweis, Widerrufsausschluss, Produktsicherheit und Lebensmittelangaben.
3. Warenkorb, Lieferart (Versand oder Abholung).
4. Kasse: Positionen mit Einzel- und Gesamtpreis, Versandkosten, Gesamtsumme, Lieferzeit, Zahlungsarten, Hinweis auf das Widerrufsrecht beziehungsweise seinen Ausschluss, Links zu AGB und Widerrufsbelehrung, **Hinweis auf die gesetzliche Gewährleistung** (Anhang I der Verordnung (EU) 2025/1960, hervorgehoben über dem Knopf) und der Knopf **„Zahlungspflichtig bestellen“**. Erst mit diesem Klick im Widget kommt der Vertrag zustande; Stripe Checkout ist danach nur noch die Zahlung (Name, Telefon und Lieferadresse sammelt Stripe).
5. Danke-Seite mit dem Stand der Bestellung; die Bestellbestätigungsmail geht an den Käufer.
6. Widerruf: Formular (Name, E-Mail, Bestellnummer, optional Erklärung) und „Widerruf bestätigen“.
## 8. Wenn der Vertrag über die Website endet (Abschalten von Hand)
Der Webshop läuft, solange WEBklar die Website des Händlers betreibt (Hosting-Abo, Wartungs-Abo oder ein anderer Vertrag über den Betrieb der Website; Leistungsbeschreibung Nr. 10). Die Software prüft das **nicht**. Du musst es selbst im Blick haben, wenn ein solcher Vertrag endet, gekündigt oder widerrufen wird:
1. **Vorher** schreibst Du dem Händler eine E-Mail (oder Chat): Der Vertrag endet am TT.MM.JJJJ, damit endet auch der Webshop, WEBklar schaltet ihn zum Vertragsende ab. Er soll offene Bestellungen und Widerrufe abarbeiten, seine Bestellungen als CSV exportieren (Aufbewahrungspflichten sind seine Sache) und seine Käufer informieren, dass der Shop nicht mehr angeboten wird.
2. **Zum Vertragsende** im Ticketsystem (WOMS): Kunde → Reiter „Funktionen“ → Webshop → **„Abschalten“**. Danach nimmt der Shop keine Bestellungen mehr an, das Widget zeigt den Ausweichtext, unbezahlte Bestellungen laufen aus.
3. Der Händler kann Bestellungen und Widerrufe danach **90 Tage** lang im Portal lesen und exportieren. Danach löscht der tägliche Job automatisch alle Shopdaten (Artikel, Bilder, Bestellungen, Widerrufe, Einstellungen); das Einrichtungs-Ticket bleibt als Arbeitsnachweis ohne Käuferdaten. Die Widerrufsfunktion bleibt bis zu dieser Löschung erreichbar, solange das Widget eingebunden ist.
4. Nimm nach dem Abschalten den Link „Vertrag widerrufen“ aus der Fußzeile und das Snippet aus der Website, sobald die 90 Tage um sind (oder der Händler den Shop nicht mehr braucht), und entferne den Shop-Menüpunkt.
5. Beginnt **innerhalb der 90 Tage** wieder ein Vertrag über den Betrieb der Website, schaltest Du den Webshop auf Wunsch des Händlers in Textform ohne weitere Kosten wieder ein (WOMS → Funktionen → Webshop → Aktivieren; Daten und Einstellungen sind noch da). Nach der Löschung ist eine neue Anfrage und Einrichtung nötig (neuer Preis).
6. **Erstattung (Leistungsbeschreibung Nr. 10 Abs. 6):** Endet der Vertrag über den Betrieb der Website, **bevor** wir den Webshop freigeschaltet haben (Ticket noch offen, Funktion im Zustand „in Einrichtung“), erstatten wir die 299 € vollständig, in Stripe von Hand. In allen anderen Fällen (Webshop war schon freigeschaltet, Abschalten auf Wunsch nach Abs. 7, Abschalten wegen verbotener Waren) gibt es keine Erstattung; nur der Widerruf der Bestellung richtet sich nach Nr. 9 Abs. 6.
7. Widerruft ein Verbraucher oder Existenzgründer die Bestellung der Einrichtung (Seite „Vertrag widerrufen“, Auswahl „Webshop (Einrichtung)“), kommt die Mitteilung T09 mit Fristende und Preis; es wird nichts automatisch geändert. Du prüfst, erstattest in Stripe innerhalb von 14 Tagen (gegebenenfalls abzüglich Wertersatz für den begonnenen Einbau) und schaltest den Webshop ab. Mit dem Widerruf endet der Webshop.
Das Stripe-Konto des Händlers bleibt in jedem Fall beim Händler; wir rühren es nicht an.
+91
View File
@@ -0,0 +1,91 @@
# Webshop – Livegang
Reihenfolge für den Inhaber. Jeder Schritt braucht seine Freigabe; nichts davon hat Claude gegen Produktion oder das
Live-Stripe-Konto ausgeführt. Code: Portal `feature/webshop` (Worktree `dev-webshop`), WOMS `feature/webshop`
(Worktree `ticket.webklar.com/dev-webshop`). Beide Zweige bauen auf `feature/terminplaner-tp2` auf.
## 0. Voraussetzung
- **TP2 (Terminplaner-Verkauf) ist live.** Der Webshop nutzt das Funktionen-Register aus TP2. Erst TP2 nach `main`
(Portal) bzw. `test` (WOMS), dann diesen Zweig darauf mergen (`git merge origin/main` im Worktree, Tests laufen lassen).
- Der Zweig `feature/oeffnungszeiten` baut ebenfalls auf TP2 auf. Wer zuerst live geht, ist egal; der zweite merged den
ersten nach (Konflikte erwartbar in `server/config.js` features, `public/app.js`, `public/funktionen.js`,
`test/mailTemplates.test.js` (Vorlagen-IDs), `server/index.js`).
## 1. Stripe-Dashboard (WEBklar-Konto, live)
1. **Connect aktivieren**: Einstellungen → Connect → Plattformprofil ausfüllen: „Plattform/Software, deren Nutzer an
eigene Kunden verkaufen“, Geldfluss **Direct Charges**, **keine** Plattformgebühr, Konten mit eigenem vollen
Stripe-Dashboard (Standard-Konten). Danach unter Connect → Branding Name „WEBklar“, Icon und Farbe für das Onboarding.
2. **Connect-Webhook** anlegen: Entwickler → Webhooks → Endpunkt hinzufügen, **„Ereignisse in verbundenen Konten“**,
URL `https://project.webklar.com/api/shop/stripe-webhook`, Ereignisse:
`account.updated`, `checkout.session.completed`, `checkout.session.async_payment_succeeded`,
`checkout.session.async_payment_failed`, `checkout.session.expired`, `charge.refunded`.
Das Signatur-Geheimnis (`whsec_…`) kommt in Schritt 2 in die `.env`.
3. Nichts weiter: Händler-Konten legt das Portal selbst an, wenn ein Händler „Stripe-Konto einrichten“ klickt.
## 2. Server-Konfiguration
In `/home/webklar/apps/.env`:
```
SHOP_ENABLED=pilot
SHOP_PILOT_EMAILS=kenso.gri@gmail.com
STRIPE_CONNECT_WEBHOOK_SECRET=whsec_…
```
In `/home/webklar/apps/docker-compose.yml` beim Dienst `kundenbereich-server` die drei Variablen durchreichen
(`SHOP_ENABLED=${SHOP_ENABLED:-off}`, `SHOP_PILOT_EMAILS=${SHOP_PILOT_EMAILS:-}`,
`STRIPE_CONNECT_WEBHOOK_SECRET=${STRIPE_CONNECT_WEBHOOK_SECRET:-}`) und danach
`docker compose -f /home/webklar/apps/docker-compose.yml up -d --no-deps kundenbereich-server`
(ein `docker restart` liest die `.env` nicht neu). Artikelbilder liegen unter `build/upload-data/shop/` (gitignored,
überleben Deploys wie die News-Fotos).
## 3. Datenbank und Stripe-Preis (vor dem Deploy)
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/build
docker exec -w /app webklar-kundenbereich-server node scripts/shop-setup.mjs --check
docker exec -w /app webklar-kundenbereich-server node scripts/shop-setup.mjs
docker exec -w /app webklar-kundenbereich-server node scripts/stripe-products.mjs --pruefen
docker exec -w /app webklar-kundenbereich-server node scripts/stripe-products.mjs --anlegen # legt shopSetup 299 € an
```
`shop-setup.mjs` legt fünf Collections an (`shopConfig`, `shopProducts`, `shopOrders`, `shopWiderrufe`,
`shopRechtstexteArchiv`). Die Käufer-Mails laufen über `termineOutbox`; die Collection kommt aus dem TP2-Livegang
(`scripts/termine-setup.mjs`). Die Skripte müssen aus dem neuen Code laufen (nach Schritt 4 oder vorher aus dem Worktree
mit derselben Umgebung).
## 4. Deploy
- Portal: `feature/webshop` → `main` pushen (Webhook deployt).
- WOMS: `feature/webshop` → `test` pushen; danach im WOMS „Rechtliches“: `scripts/setup-legal.mjs` laufen lassen (neue
Karten „Leistungsbeschreibung Webshop“, „AVV Webshop“) und die Fassungen 2026-10-05 hochladen
(`public/rechtliches/leistungsbeschreibung-webshop-2026-10-05.pdf`, `avv-webshop-2026-10-05.pdf`).
- Prüfen: `https://project.webklar.com/rechtliches/leistungsbeschreibung-webshop.html` und
`…/avv-webshop.html` antworten 200; `https://project.webklar.com/embed/shop.js` antwortet 200.
## 5. Pilot mit dem Testkonto
1. Im Portal als Testkunde: Funktionen → Webshop → Anfrage stellen (Projekt `test-website`).
2. WOMS → Kunde → Funktionen → Webshop → „Anfrage annehmen“ (Mail K24 kommt an).
3. Einrichtung: entweder bestellen (299 €, danach in Stripe erstatten) oder im WOMS „Aktivieren (ohne Zahlung)“
(Pilot-Adressen brauchen keine Textform).
4. Einbau nach `docs/funktionen/shop-einbau.md` auf der Test-Website, Ticket schließen → Freischaltung, Mail K16.
5. Portal → Webshop → Übersicht → „Stripe-Konto einrichten“ (echtes Onboarding mit den Daten von WEBklar oder einem
Testbetrieb; Stripe prüft Identität und Bankkonto).
6. Einstellungen ausfüllen (Steuerart wählen!), einen Artikel für 1,00 € anlegen, Rechtstexte: Muster einsetzen,
Platzhalter ersetzen, Impressum-/Datenschutz-URL, bestätigen, „Shop öffnen“.
7. Testkauf auf der Website mit echter Karte → Mails G09 (Käufer) und K25 (Händler) prüfen → im Portal „Versendet“
(G10) → „Erstatten“ (G13) → im Stripe-Dashboard des Händlers sichtbar.
8. Widerruf über den Fußzeilen-Link testen (G12 mit Datum und Uhrzeit, K26 an den Händler).
## 6. Freigabe für alle
- Kanzlei-Paket `docs/rechtliches/webshop-kanzlei-paket.md` an die Kanzlei, Befund einarbeiten (neue Fassung, falls
sich Texte ändern; ausgelieferte Fassungen werden nie überschrieben).
- Checkliste in Abschnitt 7 des Kanzlei-Pakets abhaken.
- `SHOP_ENABLED=on`, Compose neu anwenden (wie Schritt 2).
## Zurück
`SHOP_ENABLED=off` blendet die Kachel aus und stoppt neue Bestellungen. Bestehende Shops (Zustand aktiv oder
abgeschaltet) behalten ihre Widerrufsfunktion, ihre Fristen und die Löschjobs laufen weiter. Einzelne Shops schaltet das
Team im WOMS ab (Funktionen → Webshop → „Abschalten“), die Daten werden 90 Tage danach gelöscht.
+166
View File
@@ -0,0 +1,166 @@
# Shop: Muster-Rechtstexte (Widerrufsbelehrung, Widerrufsformular, AGB)
**Muster – keine Rechtsberatung.** Diese Texte sind Muster, die das Kundenportal im Webshop vorbefüllt (`server/services/shop/rechtstexte.js`, dort mit den Angaben aus den Shop-Einstellungen). Der Händler prüft sie, passt sie an und bestätigt sie selbst; WEBklar berät nicht rechtlich. Diese Datei enthält dieselben Texte mit Platzhaltern in eckigen Klammern zur Prüfung durch eine Kanzlei (Plan 2026-10-05, Task C5; Offene Punkte 5 und 9 der Recherche).
Stand: 2026-10-05.
## Quellen und Fundstellen
- Widerrufsbelehrung: Anlage 1 zu Art. 246a § 1 Abs. 2 S. 2 EGBGB, Fassung 2026 mit dem Baustein zur Widerrufsfunktion (https://www.gesetze-im-internet.de/bgbeg/BJNR006049896.html). Ein richtig ausgefülltes Muster erfüllt die Informationspflicht (Art. 246a § 1 Abs. 2 S. 3 EGBGB).
- Elektronische Widerrufsfunktion: § 356a BGB (seit 19.06.2026), Hinweispflicht in der Belehrung nach Art. 246a § 1 Abs. 2 S. 1 Nr. 1 EGBGB.
- Widerrufsformular: Anlage 2 zu Art. 246a § 1 Abs. 2 S. 1 Nr. 1 EGBGB.
- Ausschluss und vorzeitiges Erlöschen: § 312g Abs. 2 Nr. 1, 2, 3 BGB; Information nach Art. 246a § 1 Abs. 3 EGBGB.
- Gewährleistungshinweis: Art. 246a § 1 Abs. 1 S. 1 Nr. 11 EGBGB i. V. m. Durchführungsverordnung (EU) 2025/1960 Anhang I (seit 27.09.2026).
- Streitbeilegung: § 36 VSBG. Die EU-Plattform zur Online-Streitbeilegung wurde am 20.07.2025 eingestellt; es gibt keinen Link darauf.
- Button und Vertragsschluss: § 312j Abs. 3 und 4 BGB, § 312i BGB. Recherche: `/root/reports/webshop-2026-10-05/recherche-recht-stripe.md` (B.1 bis B.10).
## Hinweise zur Prüfung (nicht Teil der Texte)
- Fristbeginn: Die Belehrung enthält beide gesetzlichen Alternativen (eine Ware bzw. die Waren; mehrere Waren einer Bestellung, getrennt geliefert: die letzte Ware), weil der Warenkorb beides erzeugen kann. Teilsendungen und regelmäßige Lieferung sind nicht vorgesehen.
- Rücksendekosten: Standard ist „Käufer trägt“. Variante „Händler trägt“ steht unten.
- Abholung: Bietet der Shop Abholung an, kommt der Satz „Haben Sie die Waren bei uns abgeholt …“ hinzu.
- Ausschlussgründe: Der letzte Absatz der Belehrung nennt § 312g Abs. 2 Nr. 1, 2 und 3 BGB in allgemeiner Form. Ob und für welche Waren er zutrifft, entscheidet der Händler; Einordnung von Lieferung und Abholung von Speisen (§ 312g Abs. 2 Nr. 9, § 312 Abs. 2 Nr. 8 BGB) ist offen (Recherche, Offene Punkte 6).
- Gewährleistungshinweis: amtliche Grafik und wörtlicher Text aus Anhang I eingesetzt; offen bleibt nur der Abgleich der Grafikdatei mit der Originaldatei der Kommission.
- Widerrufsfunktion: Erreichbarkeit ohne Login bei Gastbestellungen, Identifikation der Bestellung und Zeitstempel mit der Kanzlei abstimmen (Recherche, Offene Punkte 7).
## Widerrufsbelehrung
Widerrufsbelehrung
Widerrufsrecht
Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.
Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die Waren in Besitz genommen haben bzw. hat.
Haben Sie mehrere Waren im Rahmen einer einheitlichen Bestellung bestellt und werden diese getrennt geliefert, beträgt die Widerrufsfrist vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die letzte Ware in Besitz genommen haben bzw. hat.
Um Ihr Widerrufsrecht auszuüben, müssen Sie uns ([Name des Händlers], [Straße Nr.], [PLZ] [Ort], E-Mail: [E-Mail-Adresse]) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.
Sie können Ihr Widerrufsrecht auch online unter [Internetadresse der Widerrufsfunktion im Shop] ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.
Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.
Folgen des Widerrufs
Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.
Wir können die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder bis Sie den Nachweis erbracht haben, dass Sie die Waren zurückgesandt haben, je nachdem, welches der frühere Zeitpunkt ist.
Sie haben die Waren unverzüglich und in jedem Fall spätestens binnen vierzehn Tagen ab dem Tag, an dem Sie uns über den Widerruf dieses Vertrags unterrichten, an uns ([Name des Händlers], [Straße Nr.], [PLZ] [Ort]) zurückzusenden oder zu übergeben. Die Frist ist gewahrt, wenn Sie die Waren vor Ablauf der Frist von vierzehn Tagen absenden.
Sie tragen die unmittelbaren Kosten der Rücksendung der Waren.
Sie müssen für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist.
Ausschluss bzw. vorzeitiges Erlöschen des Widerrufsrechts
Das Widerrufsrecht besteht nicht bei Verträgen zur Lieferung von Waren, die nicht vorgefertigt sind und für deren Herstellung eine individuelle Auswahl oder Bestimmung durch den Verbraucher maßgeblich ist oder die eindeutig auf die persönlichen Bedürfnisse des Verbrauchers zugeschnitten sind (§ 312g Abs. 2 Nr. 1 BGB), z. B. Waren mit Wunschaufdruck oder Maßanfertigungen.
Das Widerrufsrecht besteht nicht bei Verträgen zur Lieferung von Waren, die schnell verderben können oder deren Verfallsdatum schnell überschritten würde (§ 312g Abs. 2 Nr. 2 BGB), z. B. frische Lebensmittel.
Das Widerrufsrecht erlischt vorzeitig bei Verträgen zur Lieferung versiegelter Waren, die aus Gründen des Gesundheitsschutzes oder der Hygiene nicht zur Rückgabe geeignet sind, wenn ihre Versiegelung nach der Lieferung entfernt wurde (§ 312g Abs. 2 Nr. 3 BGB).
Ende der Widerrufsbelehrung
### Variante: Händler trägt die Rücksendekosten
Statt „Sie tragen die unmittelbaren Kosten der Rücksendung der Waren.“ steht:
Wir tragen die Kosten der Rücksendung der Waren.
### Variante: Abholung angeboten
Nach dem Absatz zur Rücksendung folgt zusätzlich:
Haben Sie die Waren bei uns abgeholt, übergeben Sie diese zur Rückgabe an unserer Anschrift: [Abholadresse].
## Widerrufsformular
Muster-Widerrufsformular
(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)
An [Name des Händlers], [Straße Nr.], [PLZ] [Ort], E-Mail: [E-Mail-Adresse]:
Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)
– Bestellt am (*)/erhalten am (*)
– Name des/der Verbraucher(s)
– Anschrift des/der Verbraucher(s)
– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)
– Datum
(*) Unzutreffendes streichen.
## Allgemeine Geschäftsbedingungen
Die Kopfzeile „Muster – bitte prüfen und anpassen“ steht im vorbefüllten Text und wird vom Händler nach der Prüfung entfernt. Die Zahlungsarten, Länder und Abholung werden aus den Shop-Einstellungen eingesetzt; die Platzhalter unten zeigen den Standardfall.
Muster – bitte prüfen und anpassen
Allgemeine Geschäftsbedingungen
§ 1 Geltung und Anbieter
Diese Allgemeinen Geschäftsbedingungen gelten für alle Bestellungen von Verbrauchern in unserem Online-Shop. Anbieter und Vertragspartner ist [Name des Händlers], [Straße Nr.], [PLZ] [Ort], E-Mail: [E-Mail-Adresse].
§ 2 Vertragsschluss
Die Darstellung der Waren im Shop ist kein verbindliches Angebot. Mit dem Klick auf die Schaltfläche „Zahlungspflichtig bestellen“ geben Sie eine verbindliche Bestellung ab. Den Eingang Ihrer Bestellung bestätigen wir Ihnen sofort auf einer Bestätigungsseite im Shop. Bei den Zahlungsarten Kreditkarte, Apple Pay, Google Pay, Klarna erhalten Sie die Bestellbestätigung per E-Mail unmittelbar nach erfolgreicher Zahlung; mit dieser E-Mail nehmen wir Ihre Bestellung an und der Vertrag kommt zustande. Bei SEPA-Lastschrift senden wir Ihnen zunächst eine E-Mail über den Eingang Ihrer Bestellung; der Vertrag kommt in diesem Fall erst zustande, wenn wir Ihnen nach Eingang der Zahlung die Bestellbestätigung per E-Mail senden. Bestellungen, die nicht bezahlt werden, werden nach Ablauf der Zahlungsfrist verworfen.
§ 3 Preise und Versandkosten
Es gelten die zum Zeitpunkt der Bestellung im Shop angegebenen Preise in Euro. Alle Preise enthalten die gesetzliche Umsatzsteuer. Versandkosten und etwaige weitere Kosten werden vor Abgabe der Bestellung gesondert ausgewiesen und richten sich nach dem Zielland. Einzelheiten finden Sie unter „Versand & Zahlung“.
§ 4 Zahlung
Die Zahlung erfolgt über den Zahlungsdienstleister Stripe. Es stehen folgende Zahlungsarten zur Verfügung: Kreditkarte, Apple Pay, Google Pay, Klarna, SEPA-Lastschrift. Bei SEPA-Lastschrift versenden wir die Ware erst nach Eingang der Zahlung.
§ 5 Lieferung und Abholung
Wir liefern in folgende Länder: Deutschland. Die Lieferzeit beträgt [Lieferzeit], gerechnet ab Zahlungseingang. Ist eine Ware nicht verfügbar, informieren wir Sie unverzüglich.
§ 6 Eigentumsvorbehalt
Die Waren bleiben bis zur vollständigen Bezahlung unser Eigentum.
§ 7 Widerrufsrecht
Verbrauchern steht ein gesetzliches Widerrufsrecht zu. Einzelheiten ergeben sich aus der Widerrufsbelehrung, die Sie unter „Widerrufsbelehrung“ im Shop finden.
§ 8 Gewährleistung
Es gelten die gesetzlichen Mängelhaftungsrechte. Der Hinweis auf das gesetzliche Gewährleistungsrecht steht vor Abgabe der Bestellung im Shop.
§ 9 Technische Schritte, Vertragstext und Sprache
Zum Vertragsschluss führen folgende Schritte: Sie legen die gewünschten Waren in den Warenkorb, prüfen die Bestellübersicht mit allen Angaben und klicken auf „Zahlungspflichtig bestellen“. Danach zahlen Sie bei unserem Zahlungsdienstleister Stripe. Der Vertrag kommt nach § 2 mit unserer Bestellbestätigung per E-Mail zustande. Vor dem Klick auf „Zahlungspflichtig bestellen“ können Sie Eingabefehler erkennen und berichtigen: Im Warenkorb ändern Sie Mengen oder entfernen Waren, aus der Bestellübersicht gelangen Sie über „Zurück zum Warenkorb“ dorthin zurück, und Ihre Angaben zu Adresse und Zahlung prüfen und korrigieren Sie bei Stripe, bevor Sie die Zahlung abschließen. Wir speichern den Vertragstext nach Vertragsschluss nicht für Sie abrufbar. Ihre Bestelldaten, diese Allgemeinen Geschäftsbedingungen und die Widerrufsbelehrung erhalten Sie mit der Bestellbestätigung per E-Mail; bitte bewahren Sie diese E-Mail auf. Die Vertragssprache ist Deutsch. Wir unterwerfen uns keinen besonderen Verhaltenskodizes.
§ 10 Streitbeilegung
[Name des Händlers] ist nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG).
Zu § 2: Der Satz zur sofortigen Bestellbestätigung nennt nur die Zahlungsarten Karte/Apple Pay/Google Pay/Klarna; der SEPA-Satz erscheint nur, wenn SEPA-Lastschrift aktiv ist. Ohne SEPA entfällt er, bei ausschließlich SEPA entfällt der Satz davor. Der Ablauf im Shop: Bestätigungsseite sofort nach der Bestellung; bei Karte/Klarna folgt die Bestellbestätigung (G09, Vertragsschluss) unmittelbar nach der Zahlung; bei SEPA zuerst die Eingangsmail (G15), die Bestellbestätigung (G09) erst nach Zahlungseingang.
Zu § 9: enthält die Angaben nach Art. 246c EGBGB (technische Schritte, Eingabefehler erkennen und berichtigen, Speicherung des Vertragstextes, Sprache, Verhaltenskodizes).
Bei Kleinunternehmern steht in § 3 statt „Alle Preise enthalten die gesetzliche Umsatzsteuer.“ der Satz „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.“
## Hinweis auf das gesetzliche Gewährleistungsrecht (Kasse und Bestellbestätigung)
Amtliche Grafik nach Durchführungsverordnung (EU) 2025/1960 Anhang I (ABl. L vom 2.10.2025), nicht editierbar, online farbig: `public/img/gewaehrleistung-eu.webp` (aus dem Amtsblatt ausgeschnitten; vor Livegang mit der Originaldatei der Kommission abgleichen, Kanzlei-Frage). Wörtlicher Text der Grafik (Alternativtext, Bestellbestätigung):
GESETZLICHE GEWÄHRLEISTUNG
Mindestens zwei Jahre gesetzliche Gewährleistung der Vertragsmäßigkeit für Waren, die in der Europäischen Union verkauft werden.
Verbraucherinnen und Verbraucher können ihre Rechte im Rahmen des gesetzlichen Gewährleistungsrechts geltend machen, z. B. wenn die Waren nicht der Beschreibung entsprechen, nicht bestimmungsgemäß funktionieren.
Verkäufer haften für jede Vertragswidrigkeit, die zum Zeitpunkt der Lieferung der Waren bestand und innerhalb des Zeitraums der gesetzlichen Gewährleistung erkennbar wird. Verkäufer müssen in solchen Fällen Folgendes anbieten: kostenlose Nachbesserung oder kostenlose Ersatzlieferung, in bestimmten Fällen eine Preisminderung oder eine vollständige Erstattung des Kaufpreises.
In einigen Ländern gilt ein längerer Zeitraum für die gesetzliche Gewährleistung. Für gebrauchte Waren kann ein kürzerer Zeitraum gelten, jedoch nicht weniger als ein Jahr.
Für weitere Informationen zu Ihren Rechten in einem bestimmten Land scannen Sie den nachstehenden QR-Code oder fragen Sie den Verkäufer. europa.eu/youreurope/garantien
Was ist zu tun, wenn Sie vertragswidrige Waren erhalten? 1. Melden Sie dem Verkäufer das Problem so bald wie möglich. 2. Legen Sie einen Kaufnachweis vor, z. B. die Quittung, Rechnung oder einen Kontoauszug.
Verkäufer und Hersteller können auch gewerbliche Garantien gewähren, die unabhängig von der gesetzlichen Gewährleistung gelten. Diese GARAN-Kennzeichnung zeigt beispielsweise, dass der Hersteller eine gewerbliche Haltbarkeitsgarantie ohne zusätzliche Kosten gewährt, die die gesamte Ware abdeckt.
+165
View File
@@ -0,0 +1,165 @@
# Terminplaner auf der Kundenwebsite einbauen
Anleitung für das WEBklar-Team. Das Widget lässt Gäste auf der Website des Kunden Termine buchen. Leistungen, Personen und Öffnungszeiten pflegt der Kunde im Portal (Tab „Funktionen“, Kachel „Terminplaner“). Den `siteKey` (16 Zeichen, a–z und 2–7) und den fertigen Einbau-Code findest Du im Portal: in der Kundenansicht des Kunden unter Terminplaner → Einstellungen, Abschnitt „Einbau auf deiner Website“ (mit Knopf „Code kopieren“). Den `siteKey` zeigt auch das Ticketsystem (WOMS): Kunde → Reiter „Funktionen“, Zeile Terminplaner, mit Kopierknopf. Ohne aktive Funktion, ohne gespeicherte Einstellung und ohne Telefonnummer des Betriebs (bei Verbrauchern auch ohne Anschrift) ist nichts buchbar: Das Widget zeigt dann den Ausweichtext der Seite. Die Anleitung für einen bestimmten Kunden erzeugt das Ticketsystem (WOMS) selbst: Kunde → Reiter „Funktionen“ → Anleitung des Projekts, mit den Einstellungen des Betriebs, dem fertigen Einbau-Code, dem CSS-Vorschlag, den Datenschutz-Absätzen, der Abnahme-Checkliste und den Warnungen. Im Einrichtungs-Ticket steht davon die Kurzfassung (Stand der Einstellungen, Einbau-Code, Schritte) mit dem Link auf diese Anleitung. Dieses Dokument ist die allgemeine Referenz dahinter: Es erklärt Variablen, Stile, Datenschutz und API, die für jeden Betrieb gelten.
## 1. Das Snippet
```html
<div data-webklar-termine="SITEKEY" data-api="https://project.webklar.com" data-modus="inline">
<p>Termine telefonisch unter 06301 1234.</p> <!-- Ausweichtext, siehe Abschnitt 4 -->
</div>
<script src="https://project.webklar.com/embed/termine.js" defer></script>
```
Das Skript steht einmal pro Seite, vor `</body>`. Es hat keine Abhängigkeiten, setzt keine Cookies, speichert nichts im Browser und lädt nichts von Dritten. Texte der Kunden setzt es nur als Text ein, nie als HTML. Es wird 5 Minuten gecacht und darf von jeder Domain geladen werden.
## 2. Variablen (Attribute am `div`)
| Attribut | Pflicht | Bedeutung |
| --- | --- | --- |
| `data-webklar-termine` | ja | der `siteKey` des Projekts |
| `data-api` | nein | Herkunft des Portals, Standard `https://project.webklar.com`. Ohne Angabe nimmt das Skript die Herkunft seiner eigenen Adresse. Das klappt nur bei einem normalen `<script src>`. Bei Modul-Import, Tag-Manager oder nachgeladenem Skript ist `data-api` Pflicht. |
| `data-modus` | nein | `inline` (Standard): die Buchungsstrecke steht direkt in der Seite und lädt beim Aufruf der Seite. `knopf`: ein Knopf öffnet die Strecke in einem Dialog (`<dialog>`). Beim Laden der Seite ruft das Widget einmal die Konfiguration ab (für den Knopf „Vertrag widerrufen“, siehe Abschnitt 5), alles Weitere erst beim Klick. |
| `data-knopf` | nein | Text des Knopfes im Modus `knopf`, Standard „Termin buchen“. |
| `data-leistung` | nein | ID einer Leistung (`s_` plus 8 Zeichen, steht im Portal bei der Leistung). Die Leistung ist vorgewählt, der Schritt „Leistung wählen“ entfällt. Praktisch für einen Knopf neben einer Leistung auf der Seite. Eine unbekannte ID wird ignoriert. |
Alle Klassen beginnen mit `wkt-`. Die Stile stehen einmalig in `<style id="wkt-style">` und zwar in `@layer wkt`. Das heißt: **jede** Regel der Kundenseite (ohne Layer) gewinnt gegen die Standard-Stile. Das ist gewollt, hat aber eine Folge: Hat das Theme der Kundenseite globale Regeln wie `button{…}` oder `input{…}`, greifen sie auch im Widget. Prüfe das beim Abnehmen und überschreibe bei Bedarf gezielt, z. B. `[data-webklar-termine] .wkt-btn{ … }`.
Das Layout richtet sich nach der Breite des `div` (Container Query, braucht aktuelle Browser), nicht nach dem Bildschirm. Das `div` muss eine Breite haben (normaler Blockcontainer; in einem Flex- oder Grid-Element ohne Breite schrumpft es auf null).
### Zwei Widgets auf einer Seite
Das geht. Jedes Widget bekommt eigene IDs, die Stile werden nur einmal eingefügt. Beispiel: zwei Knöpfe neben zwei Leistungen.
```html
<div data-webklar-termine="SITEKEY" data-modus="knopf" data-knopf="Damenschnitt buchen" data-leistung="s_abcd1234"></div>
<div data-webklar-termine="SITEKEY" data-modus="knopf" data-knopf="Färben buchen" data-leistung="s_efgh5678"></div>
```
## 3. Aussehen anpassen (CSS-Variablen)
Setze die Variablen auf dem `div`, in der Seiten-CSS oder auf `:root`, z. B. `[data-webklar-termine]{ --wkt-akzent:#0f766e; }`. Alle sind optional.
| Variable | Standard |
| --- | --- |
| `--wkt-akzent` (Knöpfe, aktive Tage, Rahmen der freien Tage) | die Betriebsfarbe aus den Einstellungen, sonst `#1d4ed8` |
| `--wkt-akzent-text` (Schrift auf dem Akzent) | passend zur Betriebsfarbe (hell oder dunkel), sonst `#fff` |
| `--wkt-hintergrund` | `#fff` |
| `--wkt-text` | `#1f2937` |
| `--wkt-rand` (Eingabefelder, Karten) | `#858f9e` |
| `--wkt-radius` | `10px` |
| `--wkt-schrift` | die Schrift der Seite (`inherit`) |
| `--wkt-schrift-titel` (Überschriften) | wie `--wkt-schrift` |
Die Betriebsfarbe gilt nur, solange `--wkt-akzent` nicht gesetzt ist; die Schrift darauf (Weiß, dunkles Grau oder Schwarz) wählt das Skript so, dass sie mindestens 4,5 : 1 erreicht. Setzt Du `--wkt-akzent`, setze auch `--wkt-akzent-text`. Im Modus `knopf` kennt das Widget die Betriebsfarbe erst nach dem Klick: Der Knopf selbst nimmt `--wkt-akzent` der Seite oder `#1d4ed8`. Setze `--wkt-akzent` dort also immer.
Kontrast (WCAG 2.1 AA): Text auf dem Hintergrund mindestens 4,5 : 1, Schrift auf dem Akzent mindestens 4,5 : 1, `--wkt-rand` gegen den Hintergrund mindestens 3 : 1 (sonst sind Eingabefelder schwer zu erkennen). Der Fokusrahmen nimmt die Textfarbe.
### Stil 1: hell und freundlich (Salon, Kosmetik)
```css
[data-webklar-termine]{
--wkt-akzent:#9d4b6f;
--wkt-akzent-text:#fff;
--wkt-hintergrund:#fff;
--wkt-text:#3b2a33;
--wkt-rand:#8c7580;
--wkt-radius:14px;
--wkt-schrift:"Nunito", system-ui, sans-serif;
--wkt-schrift-titel:"Fredoka", system-ui, sans-serif;
}
```
### Stil 2: dunkel (Tattoo, Studio, Bar)
```css
[data-webklar-termine]{
--wkt-akzent:#f5c542;
--wkt-akzent-text:#111;
--wkt-hintergrund:#18181b;
--wkt-text:#f4f4f5;
--wkt-rand:#8b8b94;
--wkt-radius:4px;
--wkt-schrift:"Inter", system-ui, sans-serif;
}
```
### Stil 3: schlicht und eckig (Kanzlei, Handwerk, Beratung)
```css
[data-webklar-termine]{
--wkt-akzent:#1f3a5f;
--wkt-akzent-text:#fff;
--wkt-hintergrund:#fff;
--wkt-text:#1a1a1a;
--wkt-rand:#767676;
--wkt-radius:0;
--wkt-schrift:Georgia, "Times New Roman", serif;
--wkt-schrift-titel:system-ui, sans-serif;
}
```
Die Schriften lädt das Skript nicht selbst. Sind sie auf der Kundenseite nicht eingebunden, greift die Systemschrift.
## 4. Ausweichtext
Alles, was im `div` steht, gehört der Kundenwebsite. Das Skript ersetzt es nur, wenn die Konfiguration geladen werden konnte. Bei ausgeschaltetem JavaScript, unbekanntem Schlüssel, abgeschalteter Funktion, fehlenden Pflichtangaben des Betriebs oder Netzfehler beim Laden bleibt der Text stehen. Schreib dort die Telefonnummer des Betriebs hinein und gestalte den Text im Stil der Seite.
Im Modus `knopf` ersetzt der Knopf den Text auf der Seite. Der Text erscheint dafür im Dialog, wenn das Laden beim Klick scheitert.
Bricht das Netz mitten in der Strecke ab, zeigt das Widget „Gerade nicht erreichbar. Bitte rufen Sie an: {Telefon des Betriebs}“ (bei Anrede „du“: „Bitte ruf an“).
## 5. Was das Widget zeigt
1. Leistung wählen (Name, Dauer, Preis, Kurzbeschreibung) mit der Zahlungsart („Bezahlung vor Ort: …“). Entfällt bei `data-leistung`.
2. Person wählen („Egal“ zuerst). Nur wenn mehrere Personen infrage kommen und der Betrieb die Wahl erlaubt; bei Plätzen entfällt der Schritt.
3. Tag (Monatsraster, nur Tage mit freien Zeiten sind wählbar) und Uhrzeit. Alle Daten und Uhrzeiten kommen vom Server in Berliner Zeit. Das Widget rechnet nie mit dem Datum oder der Zeitzone des Browsers.
4. Angaben: Name, E-Mail, Telefon, Nachricht und Zusatzfelder nach den Einstellungen des Betriebs.
5. Zusammenfassung mit Beschreibung der Leistung, Stornobedingungen (nur bei einer Leistung mit Preis; daneben der Link „AGB des Betriebs“, wenn im Portal eine https-Adresse hinterlegt ist), Widerrufsbelehrung (aufklappbar, bei Verbraucher-Betrieben), Datenschutz-Hinweis, den Haken (Storno nur bei einer Leistung mit Preis, vorzeitiger Beginn, Erinnerung) und dem Knopf („Zahlungspflichtig buchen“, „Termin buchen“ oder „Termin anfragen“, kommt vom Server).
6. „Fast geschafft“: Der Gast bestätigt den Termin über den Link in der E-Mail. Die Reservierung gilt 30 Minuten.
Im Fuß stehen „Vertrag widerrufen“ als zweiter Knopf (kein bloßer Textlink; für Verbraucher bei jeder Leistung, ob sofort buchbar oder als Anfrage, weil auch ein angenommener Anfrage-Vertrag widerrufen werden kann), „Datenschutz“ (Link auf die Datenschutzerklärung des Betriebs) und klein „Terminbuchung über WEBklar“. Im Modus `knopf` steht der Fuß nur im geöffneten Dialog. Damit die Widerrufsfunktion (§ 356a BGB) trotzdem dauerhaft auffindbar ist, lädt das Widget dort schon beim Laden der Seite die Konfiguration (ein einziger Abruf, sonst nichts) und setzt, wenn der Betrieb ein Widerrufsrecht hat, den Knopf „Vertrag widerrufen“ als zweiten Knopf neben den Buchungsknopf. Er öffnet den Dialog gleich beim Widerrufsformular. Anrede (Sie oder Du) und Betriebsfarbe kommen aus den Einstellungen. Die rechtlich vorgeschriebenen Texte (Haken, Belehrung) stehen immer in der Sie-Form.
Bedienung nur mit der Tastatur: Tab und Umschalt+Tab zwischen den Elementen, im Monatsraster Pfeiltasten (Tag), Pos1 und Ende (Wochenanfang und -ende), Bild ↑ und Bild ↓ (Monat), Eingabe oder Leertaste wählt den Tag. Im Dialog schließt Esc.
## 6. Datenschutzerklärung des Kunden
Das Skript lädt beim Aufruf der Seite die Konfiguration (Texte, Leistungen) von `project.webklar.com`; im Modus `inline` folgen die Termine beim Durchklicken, im Modus `knopf` erst beim Klick auf den Knopf. Dabei sieht der Server die IP-Adresse des Besuchers. Die Datenschutzerklärung der Website braucht deshalb den Absatz „Online-Terminbuchung“ aus `docs/rechtliches/termine-datenschutz-absatz.md` (zwei Fassungen: Sie und Du, nimm die zur Erklärung der Website passende). Die `[[wenn …]]`-Sätze richten sich nach den Einstellungen des Betriebs (Telefon-Feld, Nachricht, Erinnerung, Verbraucher). Bau das Widget erst ein, wenn der Absatz in der Datenschutzerklärung steht. Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar den Absatz beim Einbau ein (Teil der Einrichtung, nicht auf das Stundenkontingent angerechnet); sonst erhält der Kunde den Absatz als Textbaustein per Chat und fügt ihn selbst ein. Die URL der Datenschutzerklärung (und des Impressums) muss in den Betriebsangaben im Portal stehen, sonst fehlt der Link im Fuß.
## 7. Abnahme-Checkliste
- [ ] Funktion im Ticketsystem aktiviert (Zustand „aktiv“), `TERMINE_ENABLED` auf `pilot` oder `on`, Kunde im Pilot. `siteKey` und Einbau-Code aus dem Portal (Terminplaner → Einstellungen → „Einbau auf deiner Website“) übernommen.
- [ ] Einstellungen im Portal gespeichert, Telefon und Anschrift des Betriebs eingetragen (das Portal weist darauf hin, solange etwas fehlt), URLs von Impressum und Datenschutz mit `https://`.
- [ ] Datenschutz-Absatz steht in der Datenschutzerklärung der Website (Abschnitt 6).
- [ ] Ausweichtext prüfen: JavaScript aus, Funktion im Ticketsystem ausgeschaltet oder falscher `siteKey`: Der Text steht, nichts ist kaputt.
- [ ] Komplett durchbuchen bis „Fast geschafft“; die Bestätigungs-E-Mail kommt an, der Link bestätigt, die zweite E-Mail (mit Kalenderdatei) kommt an, im Portal steht der Termin. Danach den Testtermin absagen (Verwaltungslink aus der E-Mail oder im Portal).
- [ ] Bei Verbraucher-Betrieb (auch mit nur Anfrage-Leistungen): Widerrufsbelehrung aufklappbar, bei Preis erscheint der Haken für vorzeitigen Beginn bei Terminen in den nächsten 14 Tagen, „Vertrag widerrufen“ steht als Knopf im Fuß und das Formular funktioniert.
- [ ] Modus `knopf` bei Verbraucher-Betrieb: Neben dem Buchungsknopf steht dauerhaft der Knopf „Vertrag widerrufen“ (nicht erst im Dialog) und öffnet den Dialog gleich beim Widerrufsformular; ohne Widerrufsrecht (Unternehmen, Freizeitleistung) fehlt er.
- [ ] Nur mit der Tastatur bedienen: Fokus immer sichtbar, Monatsraster mit Pfeilen, Pos1/Ende und Bild ↑/↓, Esc schließt den Dialog und der Fokus geht zum Knopf zurück.
- [ ] Handy (390 px Breite): kein seitliches Scrollen, Knöpfe gut treffbar, Dialog passt in den Bildschirm.
- [ ] Zoom 200 % und große Schrift: nichts wird abgeschnitten.
- [ ] Kontrast der gesetzten Variablen geprüft (Abschnitt 3), globale Regeln des Themes (`button`, `input`) stören nicht.
- [ ] Zwei Widgets auf einer Seite (falls geplant) stören sich nicht.
- [ ] Anderer Browser, andere Zeitzone (z. B. im Browser auf New York stellen): dieselben Tage und Uhrzeiten wie in Berlin.
- [ ] Entwicklerwerkzeuge: keine Cookies, nichts in Local/Session Storage, keine Anfrage an fremde Hosts außer dem Portal.
- [ ] Automatische Prüfung gegen die Testseite: `nice node test/ui/termine-widget.ui.mjs` (startet einen eigenen Testserver, Screenshots unter `/root/reports/terminplaner-ui/`).
## 8. API-Kurzreferenz
Alle Pfade unter `https://project.webklar.com/api/public/termine/v1`. Antworten sind JSON. Fehler haben die Form `{ "fehler": { "feld": "Text" }, "code": "…" }` (`_` = Fehler ohne Feld). Ist der `siteKey` unbekannt, die Funktion nicht aktiv oder der Betrieb noch nicht buchbar, antworten alle Pfade unter `/:siteKey` mit `200 { "aktiv": false }`. Appwrite nicht erreichbar: `503 { "fehler": { "_": "Gerade nicht erreichbar" } }`. Die Antworten enthalten nie interne IDs.
| Methode und Pfad | Zweck |
| --- | --- |
| `GET /:siteKey/config` | Betrieb (Name, Telefon, Adresse, URLs von Impressum, Datenschutz und AGB), Anrede, Farbe, Leistungen mit Knopftext, Personen, Regeln, Zahlungsart, Stornotext, `widerruf`, Texte der Haken und der Widerrufsbelehrung, Datenschutz-Hinweis, `formToken`. Eine Minute gecacht; das Widget lädt sie nach 100 Minuten neu. |
| `GET /:siteKey/tage?leistung=&ressource=&monat=YYYY-MM` | Tage mit freien Zeiten plus Kalenderdaten des Monats (`monat`) |
| `GET /:siteKey/zeiten?leistung=&ressource=&tag=YYYY-MM-DD` | freie Startzeiten, `datumLang`, `vorzeitigNoetig` (Haken für vorzeitigen Beginn an diesem Tag) |
| `POST /:siteKey/buchungen` | Formular absenden. Antwort `201 { ok, email: "m***@gmail.com", minuten }`. Honeypot `kontakt_url` und Formular-Token (Fehlercode `neu_laden`) schützen vor Automaten. |
| `POST /:siteKey/widerruf` | Widerruf ohne Link: `{ name, email, nummer }`, Antwort immer gleich `{ ok, eingang: { datum, uhrzeit } }` |
| `POST /termin/vorschau`, `bestaetigen`, `absagen`, `verschieben`, `widerruf`, `tage`, `zeiten` | Seiten der Gäste (Token aus dem Link der E-Mail, `termin.html`) |
| `GET /kalender/:siteKey/:feedToken.ics` | Kalender-Abo des Betriebs je Person (geheimer Link aus dem Portal) |
POST-Anfragen sind nur von Seiten des Betriebs erlaubt (Host des Projekts bzw. `liveDomain` samt `www`-Gegenstück) und vom Portal selbst; andere Herkunft: `403`. Grenzen: 120 Abrufe pro Minute und 10 Buchungen pro 10 Minuten je IP, 60 Buchungen pro Stunde je Funktion. Mehr in `server/routes/publicTermine.js`.
## 9. Testen
`/embed/termine-demo.html` auf dem Portal ist eine nicht verlinkte Testseite (Strecke inline und als Knopf). Sie braucht einen Testbetrieb mit dem `siteKey` `abcdefghijklmnop`. Automatische Prüfungen: `node --test test/termineEmbedStatic.test.js` (Quelltext) und `nice node test/ui/termine-widget.ui.mjs` (Browser, eigener Testserver mit Nachbau der Datenbank; vorher `free -m`).
+46
View File
@@ -0,0 +1,46 @@
# Buchungskalender: API-Vertrag
Verbindlich für Strang 2 (Widget und Buchungsseite, bauen gegen die öffentliche Schnittstelle) und Strang 3 (Portal, baut gegen die Kunden-API). Task 6 bis 8 erfüllen ihn. Fehlerform überall `{ fehler: code, felder?: {feld: text} }`.
## Livegang-Reihenfolge
Vor dem Anheben von `UNTERKUNFT_ENABLED` über `off` zuerst `scripts/unterkunft-setup.mjs` (legt die Collections samt Index `idx_account` an) und das termine-Setup (Attribut `anhang` der Mail-Warteschlange) auf Produktion laufen lassen.
## Öffentlich (`/api/public/unterkunft/v1`)
- `GET /:siteKey` → `{ aktiv:true, anbieter:{ name, ort, datenschutzUrl }, akzentfarbe, formToken, unterkuenfte:[{ id, name, maxPersonen, mindestNaechte, checkIn, checkOut }] }` oder `{ aktiv:false }`.
- `GET /:siteKey/belegung?unterkunft=<id>&von=YYYY-MM-DD&bis=YYYY-MM-DD` → `{ belegt:[{von,bis}], heute:'YYYY-MM-DD', bisMax:'YYYY-MM-DD' }` (Quellen nicht offengelegt). Liegt `bis` hinter `bisMax` (heute + `horizontMonate`), wird es auf `bisMax` gekappt (kein 400); ein Zeitraum mit `bis < von` oder ungültigem Datum ergibt 400 `ungueltig`.
- `POST /:siteKey/anfrage` Body `{ unterkunft, von, bis, personen, name, firma, email, telefon, nachricht, formToken, kontakt_url }` → `201 { nummer }`.
- `GET /:siteKey/buchung/:id?t=` → `{ status, nummer, akzentfarbe, unterkunft:{name,checkIn,checkOut}, von, bis, naechte, personen, posten, summen, steuerart, fristAt, stornobedingungen, anbieter:{firma,inhaber,strasse,plz,ort,email,telefon,datenschutzUrl}, gast:{name,firma}, rechnungsadresse?, zahlarten:['vorort'|'online'], zahlart?, bezahlt:boolean, rechnungNummer? }`.
- `POST /:siteKey/buchung/:id?t=` nach Fristablauf: 410 `{ fehler:'frist_abgelaufen' }`. Body `{ rechnungsadresse:{name,firma,strasse,plz,ort,land}, zahlart }` → `{ status:'gebucht', rechnungNummer, zahlungUrl? }` (bei `online` direkt Checkout-URL).
- `POST /:siteKey/buchung/:id/zahlung?t=` → `{ url }`.
- `GET /:siteKey/buchung/:id/rechnung.pdf?t=&art=rechnung|storno` → PDF.
## Kunde (`/api/unterkunft`, Session, `?projectId=` steht überall in der Query, auch bei POST/PUT)
Antwortformen, die die Portal-Oberfläche erwartet (Geld in Cent, Daten `YYYY-MM-DD`; Fehler überall `{ fehler, felder? }`, nie `tokenHash`/`ipHash`):
- `GET /status` → `{ state, steuerart?, stripe:{ status:'keins'|'onboarding'|'aktiv', vorhandenesShopKonto }, onlineZahlung, unterkuenfte:Zahl, rechnungenVorhanden }`; bei `state:'off'` zusätzlich `anfrage:{ status:'offen'|'abgelehnt'|'angenommen', at, begruendung?, anfrage:{ art, einheiten, portale, nachricht } } | null`.
- `GET /einstellungen` → `{ einstellungen:{ firma, inhaber, strasse, plz, ort, email, telefon, steuernummer, ustId, iban, bic, bank, rechnungsPraefix, akzentfarbe, onlineZahlung:boolean, rechnungAuftrag:boolean }, stripe:{ status, vorhandenesShopKonto }, rechnungenVorhanden }`.
- `PUT /einstellungen` Body = genau diese Felder ohne Wrapper. `rechnungAuftrag` (boolean) leitet der Server aus `rechnungAuftragAt` ab: `true` setzt es, falls leer, `false` widerruft (null). `onlineZahlung` nur `true`, wenn Stripe aktiv ist; der Präfix ist bei vorhandenen Rechnungen nicht änderbar. Feldfehler als `felder.<name>`.
- `GET /unterkuenfte` → `{ unterkuenfte:[{ id, name, beschreibung, maxPersonen, mindestNaechte, checkIn, checkOut, sort, aktiv, icalImporte:[{ id, name, url, letzterAbrufAt, letzterErfolgAt, fehler }], icalExportUrl }] }` (`icalExportUrl` = volle URL mit Token).
- `POST /unterkuenfte`, `PUT /unterkuenfte/:id` Body `{ name, beschreibung, maxPersonen, mindestNaechte, checkIn, checkOut, sort, aktiv }` → `{ unterkunft }`; Deaktivieren = `PUT` mit `aktiv:false`. `DELETE /unterkuenfte/:id`.
- `GET|PUT /unterkuenfte/:id/preise` → `{ preise:{ staffeln:[{abNaechten,preisProNachtCent,personenCent?}], reinigungCent, personenInklusive, aufpreisPersonNachtCent, direktrabattProzent, steuerart:'kleinunternehmer'|'regel', ustUebernachtung, ustReinigung, fristTage, stornobedingungen } | null }`; PUT sendet genau diese Felder.
- Preismodell „pauschal“ (ohne `personenCent`): `preisProNachtCent` gilt für `personenInklusive` Gäste, jede weitere Person kostet `aufpreisPersonNachtCent` je Nacht (Posten „Zusatzperson“).
- Preismodell „je Personenzahl“: jede Staffel trägt `personenCent:[c1,c2,…,ck]` = Preis pro Nacht für die ganze Unterkunft bei 1, 2, …, k Gästen (k = 2 bis 10, Werte ganze Cent 100 bis 100000). Entweder haben alle Staffeln `personenCent` mit gleicher Länge k oder keine; sonst 400 `felder.staffeln`. Der Server setzt `preisProNachtCent = personenCent[0]` (eine mitgeschickte Zahl wird überschrieben). Eine Staffel gilt weiterhin ab ihrer Nächtezahl für alle Nächte. Der Posten heißt „Übernachtung, 7 Nächte, 3 Personen“; `personenInklusive` wird ignoriert. Bei mehr als k Gästen gilt `ck` plus der Posten „Zusatzperson“ (`aufpreisPersonNachtCent`, nur wenn größer 0) für die Gäste über k. Der Direktrabatt gilt auf Übernachtung und Zusatzpersonen.
- Kein neues Schema: `personenCent` liegt im JSON-Text `ukPreise.staffeln` (2000 Zeichen; der Höchstfall 6 Staffeln × 10 Werte braucht rund 800).
- `GET /unterkuenfte/:id/kalender?von&bis` → `{ bereiche:[{ von, bis (Abreisetag, exklusiv), quelle:'gebucht'|'reserviert'|'ical'|'manuell' (auch 'ical:<importId>', 'anfrage:<id>' zählt als gebucht), anfrageNummer?, sperreId?, notiz? }] }`.
- `POST /unterkuenfte/:id/sperren` `{ von, bis, notiz }` → 201, 409 `zeitraum_belegt`; `DELETE /sperren/:id`.
- `POST /unterkuenfte/:id/ical-importe` `{ name, url }` (nur https; Feldfehler `felder.url`) → 201; `DELETE /unterkuenfte/:id/ical-importe/:importId`; `POST /unterkuenfte/:id/ical-export/erneuern`.
- `GET /anfragen?filter=offen|gebucht|alle` → `{ anfragen:[{ id, nummer, von, bis, naechte, personen, gast:{name}, status, summen:{bruttoCent}|null, unterkunftName }] }`.
- `GET /anfragen/:id` → `{ anfrage:{ id, nummer, status, von, bis, naechte, personen, unterkunftId, unterkunftName, gast:{name,firma,email,telefon,nachricht}, rechnungsadresse|null, posten|null, summen|null, steuerart, zahlart|null, bezahltVia|null, bezahltAt|null, erstattetCent, rechnungId|null, rechnungNummer|null, stornoRechnungId|null, fristAt|null, nachrichtVermieter }, vorschlag:{ posten, summen } }` (`vorschlag` = `berechnePreis`).
- `POST /anfragen/:id/freigeben` `{ posten, fristTage, nachricht }` → `{ anfrage, buchungsLink }`; `POST /anfragen/:id/ablehnen` `{ text }`; `POST /anfragen/:id/bezahlt`; `POST /anfragen/:id/stornieren` `{ grund, erstattungCent }` (502 `erstattung_fehlgeschlagen`, wenn die Stornierung galt, die Erstattung aber scheiterte).
- `POST /preisvorschau` `{ unterkunftId, von, bis, personen }` → `{ naechte, posten, summen:{ bruttoCent } }` (rechnet mit den gespeicherten Preisen).
- `GET /rechnungen` → `{ rechnungen:[{ id, nummer, datum, art:'rechnung'|'storno', gastName, bruttoCent (Storno negativ), anfrageNummer? }] }`; `GET /rechnungen/:id.pdf` (nur Einzel-PDF, kein ZIP, Spec N7).
- `POST /stripe/verbinden` → `{ url }`; `POST /stripe/vorhandenes-konto` und `POST /stripe/aktualisieren` → `{ stripe:{ status } }`.
- `POST /anfrage-funktion` `{ art:'ferienwohnung'|'monteurzimmer'|'pension'|'sonstiges' ('monteur' wird als Alias angenommen), einheiten:1-999, portale:Liste aus 'booking'|'airbnb', nachricht }` → `{ anfrage }`; Feldfehler `felder.art|einheiten|portale|nachricht`; 409, wenn schon eine Anfrage läuft. `POST /anfrage-funktion/zurueckziehen`.
## Team (`/api/team/unterkunft`)
- `GET /:customerId` → `{ projects:[{ projectId, name, state, anfrage:{ status:''|'offen'|'angenommen'|'abgelehnt'|'zurueckgezogen', at, art:'ferienwohnung'|'monteurzimmer'|'pension'|'sonstiges', einheiten, portale:boolean, nachricht, entschiedenAt, entschiedenVon, begruendung }, stripe, unterkuenfte:Zahl, offeneAnfragen:Zahl }] }` (Hülle wie `GET /api/team/shop/:customerId`).
- `POST /:customerId/anfrage` `{ projectId, entscheidung:'annehmen'|'ablehnen', begruendung }` → `{ ok, anfrage, mailFehler? }`.
@@ -0,0 +1,57 @@
# Mustertext: Datenschutzerklärung der Kundenwebsite (Buchungskalender)
**Muster – keine Rechtsberatung.** Entwurf, Prüfung durch Kanzlei offen (`docs/rechtliches/unterkunft-kanzlei-fragen.md`, Frage U12). Der Absatz gehört in die Datenschutzerklärung jeder Kundenwebsite, auf der der Buchungskalender läuft. Der Vermieter ist Verantwortlicher und verantwortet seine Datenschutzerklärung selbst; WEBklar liefert nur diesen Baustein (Leistungsbeschreibung Buchungskalender Nr. 5 Abs. 10 und Nr. 6). Bau das Widget erst ein, wenn der Absatz in der Datenschutzerklärung steht.
Hinweise für das Team:
- Liegt die Datenschutzerklärung in der Website, die WEBklar betreut, fügen wir den Absatz beim Einbau ein (gehört zur Einrichtung). Liegt sie woanders, bekommt der Vermieter den Text im Chat und fügt ihn selbst ein.
- Platzhalter in eckigen Klammern durch die Angaben des Vermieters ersetzen. Nicht Zutreffendes streichen: Den Absatz „Zahlung über Stripe“ nur, wenn der Vermieter die Online-Zahlung eingeschaltet hat; den Satz zu anderen Plattformen nur, wenn er Kalender einbindet.
- Anrede und Ton an die übrige Erklärung der Website anpassen (meist Sie). Die Nummerierung der Überschrift der Website beachten.
- Wenn der Vermieter im Impressum oder in der Erklärung einen Datenschutzbeauftragten nennt, dort dazuschreiben.
- Die Löschfristen entsprechen dem AVV Buchungskalender Nr. 9 und Abschnitt 6f der Datenschutzerklärung von WEBklar (project.webklar.com/datenschutz.html, nach dem Livegang). Ändert sich dort etwas, hier mitändern. Die Frist für Server-Protokolle (14 Tage) steht in Abschnitt 3 dieser Erklärung.
- Das Widget setzt keine Cookies.
- Im sichtbaren Text der Kundenwebsite die Wünsche des Vermieters beachten (etwa keine Gedankenstriche).
---
## [Nummer]. Anfragen und Buchungen über unseren Buchungskalender
### Verantwortlicher und technischer Betrieb
Verantwortlich für die Verarbeitung Ihrer Daten bei einer Anfrage oder Buchung ist [Name des Vermieters, Anschrift, E-Mail-Adresse], also der Vermieter. Den Buchungskalender betreibt technisch die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, in unserem Auftrag. Sie verarbeitet Ihre Daten nur nach unseren Weisungen und nicht für eigene Zwecke (Auftragsverarbeitung nach Art. 28 DSGVO). Der Server steht bei der Hetzner Online GmbH in Falkenstein (Deutschland); der Versand der E-Mails zu Ihrer Anfrage und Buchung läuft über den E-Mail-Dienst der Hetzner Online GmbH in Nürnberg (Deutschland).
### Welche Daten wir bei einer Anfrage und Buchung verarbeiten
Wenn Sie über den Buchungskalender anfragen, verarbeiten wir die gewünschte Unterkunft, den Zeitraum und die Zahl der Personen, Ihren Namen, Ihre E-Mail-Adresse und, wenn Sie es angeben, Ihre Firma, Ihre Telefonnummer und Ihre Nachricht. Bitte schreiben Sie keine Angaben zu Ihrer Gesundheit oder anderen besonders schutzbedürftigen Umständen in die Nachricht. Die Verarbeitung ist nötig, um Ihre Anfrage zu beantworten und gegebenenfalls einen Vertrag mit Ihnen zu schließen (Art. 6 Abs. 1 lit. b DSGVO). Ohne diese Angaben können wir Ihre Anfrage nicht bearbeiten. Eine Anfrage ist unverbindlich. Geben wir sie frei, erhalten Sie eine E-Mail mit dem Preis, den Stornobedingungen und einem Link zur Buchungsseite; erst dort buchen Sie verbindlich.
Wenn Sie buchen, verarbeiten wir zusätzlich Ihre Rechnungsadresse (Name oder Firma, Straße, Postleitzahl, Ort, Land), die gewählte Zahlart, die Buchung mit ihrem Stand (zum Beispiel gebucht, bezahlt, storniert) und die Rechnung. Die Rechnung wird in unserem Namen und in unserem Auftrag von der WEBklar GbR erstellt (§ 14 Abs. 2 Satz 4 UStG) und Ihnen als PDF mit der Buchungsbestätigung gesendet.
Außerdem speichern wir in der Anfrage einen Hashwert Ihrer IP-Adresse (nicht die IP-Adresse selbst; er wird mit einem geheimen Schlüssel des Servers berechnet, sodass sich daraus keine IP-Adresse ableiten lässt). Wir begrenzen damit die Zahl der Anfragen je IP-Adresse und schützen den Buchungskalender so vor automatisierten Massenanfragen. Das ist unser berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO); Sie können dieser Verarbeitung widersprechen (Art. 21 DSGVO). Wir verwenden den Hashwert nur dafür und entfernen ihn, sobald wir Ihre Anfrage freigeben oder ablehnen, spätestens nach 30 Tagen.
### Zahlung über Stripe
[Nur aufnehmen, wenn die Online-Zahlung eingeschaltet ist.] Wenn Sie online zahlen, werden Sie auf die Zahlungsseite der Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, D02 H210, Irland („Stripe“) weitergeleitet. Die Zahlung geht direkt auf unser Stripe-Konto. Online können Sie derzeit nur per Karte zahlen (auch mit Apple Pay und Google Pay), nicht per SEPA-Lastschrift. Kartendaten geben Sie nur dort ein; wir und der technische Betreiber des Buchungskalenders erhalten sie nicht. Beim Start der Zahlung übergibt der technische Betreiber Stripe die Bezeichnung des Aufenthalts mit der Rechnungsnummer, den Betrag, die Kennungen der Buchung, Ihre E-Mail-Adresse (zur Vorbelegung der Zahlungsseite) und als Rücksprungadresse den geheimen Link zur Buchungsseite; dieser Link ist damit für Stripe und in unserem Stripe-Dashboard sichtbar, gilt aber nur bis 90 Tage nach der Abreise (bei nicht zustande gekommenen Buchungen 30 Tage) und öffnet nur Ihre eine Buchungsseite. Geht eine Zahlung erst nach einer Stornierung oder nach dem Ablauf der Reservierung ein oder zahlen Sie eine bereits bezahlte Buchung ein zweites Mal, wird der Betrag automatisch in voller Höhe an Sie zurückerstattet. Stripe verarbeitet Ihre Daten teils im Rahmen der Zahlungsabwicklung für uns und teils als eigener Verantwortlicher, zum Beispiel zur Betrugsvorbeugung und zur Erfüllung gesetzlicher Pflichten; dafür gilt die Datenschutzerklärung von Stripe (stripe.com/de/privacy). Dabei können Daten an die Stripe, LLC in den USA übermittelt werden. Die Stripe, LLC ist nach dem EU-US Data Privacy Framework zertifiziert, für das ein Angemessenheitsbeschluss der EU-Kommission besteht. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. Zahlen Sie vor Ort, entfällt dieser Absatz für Sie.
### E-Mails zu Ihrer Anfrage und Buchung
Zu Ihrer Anfrage und Buchung senden wir Ihnen E-Mails an die angegebene E-Mail-Adresse: eine Bestätigung, dass Ihre Anfrage eingegangen ist, die Freigabe mit dem Link zur Buchungsseite oder die Absage, die Buchungsbestätigung mit den Vertragsangaben, den Stornobedingungen und der Rechnung, die Bestätigung eines Zahlungseingangs, einen Hinweis, wenn eine Reservierung abgelaufen ist, und bei einer Stornierung die Stornorechnung. Das sind Mitteilungen zur Abwicklung Ihres Vertrags (Art. 6 Abs. 1 lit. b DSGVO), keine Werbung. Bis zum Versand liegt jede E-Mail in einer Warteschlange; nach dem Versand wird Empfängeradresse und Inhalt dort gelöscht, der Eintrag selbst nach sieben Tagen. Der Link zur Buchungsseite enthält ein geheimes Kennzeichen, das wir nur als Prüfsumme speichern und das bei einer Online-Zahlung an Stripe übergeben wird (siehe oben); behandeln Sie ihn vertraulich.
### Besuch der Seite
Beim Aufruf der Seite lädt Ihr Browser das Programm des Buchungskalenders und die Belegung von der Adresse project.webklar.com. Dabei werden Ihre IP-Adresse, Datum und Uhrzeit des Aufrufs sowie Angaben zu Ihrem Browser und Betriebssystem übertragen und für 14 Tage in Server-Protokollen gespeichert, um den sicheren und störungsfreien Betrieb zu gewährleisten (Art. 6 Abs. 1 lit. f DSGVO). Der Buchungskalender setzt keine Cookies. Zum Schutz vor Missbrauch begrenzen wir die Zahl der Anfragen je IP-Adresse; die Zeitpunkte der Aufrufe werden nur im Arbeitsspeicher des Servers gehalten. Mit Ihrer Anfrage wird Ihre IP-Adresse nicht gespeichert, sondern nur der oben beschriebene Hashwert.
### Abgleich mit anderen Plattformen
[Nur aufnehmen, wenn der Vermieter Kalender einbindet.] Wir gleichen die Belegung unserer Unterkünfte mit [Booking.com, Airbnb, …] über Kalenderdateien (iCal) ab, damit eine Unterkunft nicht doppelt vergeben wird. Dabei werden nur belegte Zeiträume ausgetauscht, keine Namen oder anderen Angaben zu Gästen.
### Weitergabe
Wir geben Ihre Daten nur weiter, soweit das für die Vertragsabwicklung nötig ist (technischer Betreiber des Buchungskalenders als Auftragsverarbeiter, Stripe bei Online-Zahlung) oder wir gesetzlich dazu verpflichtet sind (zum Beispiel an das Finanzamt oder, soweit vorgeschrieben, an die Meldebehörde). Eine Weitergabe zu Werbezwecken findet nicht statt.
### Speicherdauer
Anfragen, aus denen keine Buchung wird, anonymisiert das System nach sechs Monaten. Für gebuchte Aufenthalte sind wir als Vermieter verpflichtet, Rechnungen und Buchungsbelege nach Handels- und Steuerrecht aufzubewahren (in der Regel acht bis zehn Jahre, § 257 HGB, § 147 AO; Art. 6 Abs. 1 lit. c DSGVO); dafür bewahrt das System die Rechnungen und die Rechnungsadresse acht Jahre auf, solange der Buchungskalender läuft. Name, Firma, E-Mail-Adresse, Telefonnummer und Nachricht eines gebuchten Aufenthalts werden drei Jahre nach dem Ende des Jahres Ihrer Abreise anonymisiert.
### Ihre Rechte
Sie haben das Recht auf Auskunft über Ihre Daten (Art. 15 DSGVO), auf Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18), Datenübertragbarkeit (Art. 20) und auf Widerspruch gegen Verarbeitungen, die auf Art. 6 Abs. 1 lit. f DSGVO beruhen (Art. 21). Wenden Sie sich dazu an uns unter [E-Mail-Adresse des Vermieters]; wir leiten Ihre Anfrage, soweit nötig, an den technischen Betreiber des Buchungskalenders weiter. Außerdem können Sie sich bei einer Datenschutz-Aufsichtsbehörde beschweren, etwa bei der für uns zuständigen Behörde [zuständige Aufsichtsbehörde des Vermieters].
+91
View File
@@ -0,0 +1,91 @@
# Buchungskalender auf der Kundenwebsite einbauen
Anleitung für das WEBklar-Team. Das Widget lässt Besucher auf der Website des Kunden („Vermieter“) einen Zeitraum im Kalender wählen und eine unverbindliche Anfrage senden. Unterkünfte, Preise, Anfragen, Rechnungen und die Verbindung zu Stripe pflegt der Vermieter im Kundenportal (Tab „Funktionen“, Kachel „Buchungskalender“, Reiter „Buchungen“). Den `siteKey` (16 Zeichen, a–z und 2–7) findest Du im Einrichtungs-Ticket und im Ticketsystem (WOMS): Kunde → Reiter „Funktionen“, Zeile Buchungskalender. Im Ticket stehen Snippet und Schritte schon fertig; dieses Dokument ist die Referenz dahinter. Ohne aktive Funktion oder ohne freigeschaltete Unterkunft ist nichts buchbar: Das Widget zeigt dann den Ausweichtext (Abschnitt 4). API-Vertrag: `docs/funktionen/unterkunft-api.md`.
Der Buchungskalender ist an den Vertrag über den Betrieb der Website gekoppelt (Leistungsbeschreibung Buchungskalender Nr. 10), aber **nicht in der Software**: Das Portal schaltet ihn nicht von selbst ab. Das Abschalten zum Vertragsende machen wir von Hand (Abschnitt 8).
## 1. Das Snippet
```html
<div data-webklar-unterkunft="SITEKEY" data-api="https://project.webklar.com">
<p>Der Buchungskalender ist gerade nicht erreichbar. Bitte schreiben Sie uns: info@beispiel.de</p> <!-- Ausweichtext, Abschnitt 4 -->
</div>
<script src="https://project.webklar.com/embed/unterkunft.js" defer></script>
```
Das Skript steht einmal pro Seite, vor `</body>`. Es hat keine Abhängigkeiten, setzt keine Cookies, lädt nichts von Dritten und fragt nur `project.webklar.com` an. Texte des Vermieters setzt das Skript nur als Text ein, nie als HTML. Das Widget rechnet nie selbst: Preise, Summen und belegte Nächte kommen fertig vom Server (die Anfrage ist unverbindlich; der Preis wird erst bei der Freigabe festgelegt).
| Attribut | Pflicht | Bedeutung |
| --- | --- | --- |
| `data-webklar-unterkunft` | ja | der `siteKey` des Projekts |
| `data-api` | nein | Herkunft des Portals, Standard `https://project.webklar.com`. Bei Modul-Import, Tag-Manager oder nachgeladenem Skript ist `data-api` Pflicht. |
## 2. Wohin damit
1. Auf der Seite einen **Bereich „Anfrage“** (Anker `#anfrage` oder eine eigene Seite) anlegen und das Snippet an die Stelle des Formulars setzen. Ein bisheriges Anfrageformular wird ersetzt; Telefon und E-Mail des Vermieters stehen unter dem Kalender. Das `div` braucht Breite (normaler Blockcontainer; in einem Flex- oder Grid-Element ohne Breite schrumpft es auf null).
2. Layout: Das Widget zeigt zwei Spalten (Kalender links, Formular rechts) und unter 760 px Breite beide untereinander; der Kalender zeigt einen Monat, ab 1100 px Breite zwei.
3. Menüpunkt oder Knopf „Anfragen“ auf den Bereich verlinken.
4. **Mehrere Widgets** auf einer Seite sind nicht vorgesehen. Der Buchungskalender ist ein Widget je Website; mehrere Unterkünfte wählt der Gast im Widget (bei nur einer Unterkunft entfällt die Auswahl).
Aussehen: Das Widget nimmt die Akzentfarbe aus den Einstellungen des Vermieters. Die Stile stehen in `@layer wku`, jede Regel der Kundenseite gewinnt also. Anpassen per CSS-Variablen auf dem `div`, in der Seiten-CSS oder auf `:root`, zum Beispiel `[data-webklar-unterkunft]{ --wku-akzent:#8a5a2b; --wku-akzent-text:#fff; }`:
| Variable | Standard |
| --- | --- |
| `--wku-akzent` und `--wku-akzent-text` (Auswahl, Knöpfe; setze beide zusammen) | Akzentfarbe des Vermieters, sonst Standardblau und `#fff` |
| `--wku-hintergrund` | `#fff` |
| `--wku-text` | `#1f2937` |
| `--wku-rand` | `#858f9e` |
| `--wku-radius` | `10px` |
| `--wku-schrift`, `--wku-schrift-titel` | Schrift der Seite |
Kontrast (WCAG 2.1 AA): Text auf dem Hintergrund und Schrift auf dem Akzent mindestens 4,5 : 1, Rand gegen den Hintergrund mindestens 3 : 1. Prüfe beim Abnehmen, ob globale Regeln des Themes (`button{…}`, `input{…}`) das Widget stören, und überschreibe sie gezielt (`[data-webklar-unterkunft] .wku-btn{ … }`).
## 3. Datenschutz-Absatz (Pflicht vor dem Einbau)
Bau das Widget erst ein, wenn der Absatz aus `docs/funktionen/unterkunft-datenschutz-absatz.md` in der Datenschutzerklärung der Website steht. Liegt sie in der Website, die WEBklar betreut, fügst Du ihn ein (gehört zur Einrichtung), Platzhalter und nicht Zutreffendes angepasst. Liegt sie woanders, schickst Du ihn dem Vermieter im Chat (Leistungsbeschreibung Nr. 5 Abs. 10). Dasselbe gilt für Impressum und Verlinkung der Datenschutzerklärung: Die Adresse der Datenschutzerklärung trägt der Vermieter in den Einstellungen ein; das Widget und die Buchungsseite verlinken sie.
## 4. Ausweichtext
Der Inhalt des `div` bleibt stehen, solange das Widget nicht lädt oder die Funktion nicht aktiv ist (zum Beispiel vor der Freischaltung, bei `UNTERKUNFT_ENABLED=off`, bei Wartung). Schreib dort Kontaktdaten des Vermieters hinein, damit niemand ohne Möglichkeit zur Anfrage bleibt. Ein aktives Widget ersetzt den Inhalt.
## 5. Vor der Abnahme prüfen
- Im Portal: mindestens eine aktive Unterkunft mit Preisen und Stornobedingungen, Firmendaten vollständig, Steuernummer oder USt-ID, Steuerart gewählt. Sonst ist die Funktion nicht freischaltbar.
- Der Datenschutz-Absatz steht in der Datenschutzerklärung.
- Auf dem Handy und am Desktop: Anreise und Abreise wählbar, belegte Nächte nicht wählbar, Mindestnächte und Höchstzahl der Personen werden geprüft, Anfrage senden zeigt die Nummer.
- Tastatur: Pfeiltasten im Kalender, Enter wählt; jeder Tag hat ein `aria-label` mit Datum und Status.
- Kein Konflikt mit globalen Stilen des Themes (Abschnitt 2).
- Testanfrage mit eigener Adresse: E-Mail G16 (Eingang) beim Gast, K36 beim Vermieter.
## 6. Vorschau und Testanfrage
1. Vorschau-Subdomain des Projekts (`<subdomain>.project.webklar.com`) ansehen.
2. Anfrage senden, im Portal unter „Buchungen → Anfragen“ öffnen, Preis prüfen, **Freigeben** (E-Mail G17 mit Buchungslink).
3. Buchungslink öffnen: Rechnungsadresse eintragen, Zahlart „vor Ort“ wählen, **„Zahlungspflichtig buchen“**. E-Mail G19 mit Rechnungs-PDF prüfen (Rechnung trägt Name und Daten des Vermieters, keinen WEBklar-Hinweis).
4. Online-Zahlung nur testen, wenn das Stripe-Konto verbunden ist (kleiner Betrag, danach im Portal stornieren und erstatten).
5. Im Portal „bezahlt (vor Ort)“ setzen, dann stornieren (Stornorechnung, E-Mail G22).
6. Testanfragen danach im Portal stornieren oder ablehnen; Sperren des Tests im Kalender entfernen.
## 7. Was das Widget zeigt (Kurzüberblick)
- Kalender mit belegten Nächten (durchgestrichen); vergangene Tage und Zeiträume über eine belegte Nacht hinweg sind nicht wählbar. Anreise am Abreisetag eines anderen Gastes ist möglich.
- Zusammenfassung des Zeitraums („12.–30. Nov. · 18 Nächte“), Personenzahl, Name, E-Mail, Telefon und Nachricht (optional), Firma (optional), Datenschutzhinweis.
- Schaltfläche „Anfrage senden“ (unverbindlich). Danach die Anfragenummer und der Hinweis, dass der Vermieter sich meldet.
- Die Buchung selbst läuft nicht im Widget, sondern auf der Buchungsseite bei WEBklar (Link in der E-Mail G17).
## 8. Wenn der Vertrag über die Website endet (Abschalten von Hand)
Der Buchungskalender läuft, solange WEBklar die Website des Vermieters betreibt (Leistungsbeschreibung Nr. 10). Die Software prüft das **nicht**. Du musst es selbst im Blick haben, wenn ein solcher Vertrag endet, gekündigt oder widerrufen wird:
1. **Vorher** schreibst Du dem Vermieter eine E-Mail (oder Chat): Der Vertrag endet am TT.MM.JJJJ, damit endet auch der Buchungskalender. Er soll **seine Rechnungen als PDF herunterladen** (WEBklar löscht sie 90 Tage nach dem Abschalten), offene Anfragen bearbeiten und seine Gäste informieren.
2. **Zum Vertragsende** im Ticketsystem (WOMS): Kunde → Reiter „Funktionen“ → Buchungskalender → **„Abschalten“**. Danach nimmt das Widget keine Anfragen mehr an und zeigt den Ausweichtext; Reservierungen laufen aus.
3. Der Vermieter kann Anfragen, Buchungen und Rechnungen danach **90 Tage** lang im Portal lesen und die Rechnungen herunterladen. Danach löscht der tägliche Job automatisch alle Daten des Buchungskalenders; das Einrichtungs-Ticket bleibt als Arbeitsnachweis ohne Gästedaten.
4. Nimm nach den 90 Tagen das Snippet und den Menüpunkt aus der Website.
5. Beginnt **innerhalb der 90 Tage** wieder ein Vertrag über den Betrieb der Website, schaltest Du den Buchungskalender auf Wunsch des Vermieters in Textform ohne weitere Kosten wieder ein (WOMS → Funktionen → Buchungskalender → Aktivieren).
6. **Erstattung (Leistungsbeschreibung Nr. 10 Abs. 6):** Endet der Vertrag, bevor wir den Buchungskalender freigeschaltet haben, erstatten wir den gezahlten Einrichtungspreis (199 €) vollständig, in Stripe von Hand. Sonst gibt es keine Erstattung.
Das Stripe-Konto des Vermieters bleibt in jedem Fall beim Vermieter; wir rühren es nicht an.
## 9. Kalenderabgleich (iCal) einrichten
Im Portal unter Buchungen → Unterkünfte & Preise → Kalenderabgleich: Der Vermieter trägt die iCal-Adresse von Booking.com oder Airbnb als Import ein (Name und `https`-Adresse) und kopiert die **Export-Adresse** des Buchungskalenders zu Booking.com („Kalender synchronisieren“) oder Airbnb. Der Abgleich ist zeitversetzt (Minuten bis Stunden, Leistungsbeschreibung Nr. 4); sag das dem Vermieter, damit er vor jeder Freigabe seine anderen Plattformen prüft.
+125
View File
@@ -0,0 +1,125 @@
# Buchungskalender – Livegang
Reihenfolge für den Inhaber. Jeder Schritt braucht seine Freigabe; nichts davon hat Claude gegen Produktion, das Live-Stripe-Konto oder die Website Donau Home ausgeführt. Code: Portal `feature/unterkunft`, WOMS `feature/unterkunft`. Erste Kundin ist Mimi (Donau Home, MIMI FEWO, Website `WEBklar/donau-home`, lokaler Klon `/root/reports/donau-home-website`).
**Wichtig beim Pushen:** Den Feature-Zweig nie unter seinem eigenen Namen pushen. Der Portal-Webhook deployt jeden Zweig. Immer mit Zielzweig:
- Portal: `git push origin feature/unterkunft:main`
- WOMS: `git push origin feature/unterkunft:test`
## 0. Voraussetzung
- TP2, Webshop und Öffnungszeiten-Zweig liegen schon auf `origin/main` (Portal) bzw. `test` (WOMS) oder werden vorher gemerged: Das Funktionen-Register, die Mail-Warteschlange (`termineOutbox`) und die Connect-Anbindung stammen von dort. `git merge origin/main` im Worktree, Tests laufen lassen. Erwartbare Konflikte: `server/config.js` (features), `public/funktionen.js`, `test/mailTemplates.test.js` (Vorlagen-IDs: K36 bis K35, G16 bis G22, T23 sind für den Buchungskalender vergeben, vor dem Merge gegen `main` prüfen).
- Die Rechtstexte der Fassung 2026-10-07 liegen als HTML und PDF im Zweig (`public/rechtliches/*-2026-10-07.*`, siehe Schritt 6).
## 1. Inhaber prüft die Texte
Vom Inhaber freizugeben, bevor irgendetwas live geht:
- `docs/rechtliches/leistungsbeschreibung-unterkunft.md` und `avv-unterkunft.md` (beide „Entwurf – Prüfung durch Kanzlei offen“),
- `docs/rechtliches/unterkunft-datenschutz-6f-entwurf.md`,
- `docs/funktionen/unterkunft-datenschutz-absatz.md` und `unterkunft-storno-muster.md`,
- `docs/rechtliches/unterkunft-kanzlei-fragen.md` (Fragen U1 bis U12 für die Kanzlei).
**Achtung Widerrufsbelehrung:** Die Datei `widerrufsbelehrung-funktionen-2026-10-07.*` ist eine Kopie des gemeinsamen Textes der Funktionen (Quelle `docs/rechtliches/widerrufsbelehrung-funktionen.md`). Ändert sich dieser Text (neue Fassung beim Terminplaner oder Webshop), braucht der Buchungskalender eine neue Fassung mit neuem Datum; eine ausgelieferte Datei wird nie an Ort und Stelle neu gebaut.
Ändert sich ein Text, gibt es eine neue Fassung (neues Datum in `config.unterkunft.fassung` und im Zustimmungs-Pin der Bestelltexte); ausgelieferte Fassungen werden nie überschrieben.
## 2. Datenbank (nach dem Portal-Deploy, vor dem Pilot)
**Zeitpunkt:** Die Skripte laufen mit dem NEUEN Code, also erst nach dem Portal-Deploy (Schritt 5) und vor dem Einschalten des Piloten (`UNTERKUNFT_ENABLED=pilot`, Schritt 4 Teil 2), oder vorher aus dem Worktree `dev-unterkunft` (`node scripts/…` mit denselben Umgebungswerten gegen die Produktionsdatenbank). Der Schalter bleibt bis zum grünen `--check` auf `off`. Reihenfolge der Skripte: erst `termine-setup.mjs --check` und anwenden (Attribut `anhang`), dann `unterkunft-setup.mjs`:
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/build
docker exec -w /app webklar-kundenbereich-server node scripts/termine-setup.mjs --check
docker exec -w /app webklar-kundenbereich-server node scripts/termine-setup.mjs # zieht das Attribut anhang der Mail-Warteschlange nach (Rechnung als Anhang)
docker exec -w /app webklar-kundenbereich-server node scripts/unterkunft-setup.mjs --check
docker exec -w /app webklar-kundenbereich-server node scripts/unterkunft-setup.mjs # legt ukUnterkuenfte, ukPreise, ukSperren, ukAnfragen, ukRechnungen, ukEinstellungen samt Indizes an
```
Wichtige Indizes (ohne sie sind Suche, Eindeutigkeit und Abfragen unsicher oder langsam; das Skript legt sie an, `--check` meldet fehlende): `idx_account` (ukEinstellungen, stripeAccountId), `idx_export_token` (ukUnterkuenfte, icalExportToken), `uniq_feature_nummer` (ukRechnungen, featureId+nummer, **unique**: verhindert doppelte Rechnungsnummern), `idx_bis` (ukSperren und ukAnfragen, Belegungsabfrage). Fehlt `termineOutbox.anhang`, meldet `unterkunft-setup.mjs --check` es ebenfalls (Exit 1) und der normale Lauf legt es an.
Die Skripte müssen aus dem neuen Code laufen (siehe Zeitpunkt oben). `termine-setup.mjs` ändert nichts an vorhandenen Daten, es fügt nur fehlende Attribute und Indizes hinzu.
## 3. Stripe-Preis (ebenfalls nach dem Portal-Deploy oder aus dem Worktree, vor dem Pilot)
```bash
docker exec -w /app webklar-kundenbereich-server node scripts/stripe-products.mjs --pruefen
docker exec -w /app webklar-kundenbereich-server node scripts/stripe-products.mjs --anlegen # legt unterkunftSetup (199 €, Lookup-Key webklar_unterkunft_einrichtung_19900) an
docker exec -w /app webklar-kundenbereich-server node scripts/stripe-products.mjs --pruefen # Preis-Lookup nach --anlegen prüfen
```
Der vorhandene Connect-Webhook (`https://project.webklar.com/api/shop/stripe-webhook`) bleibt unverändert; er leitet nach dem Metadatum `wkUk` an den Buchungskalender weiter. Kontrolle im Stripe-Dashboard: Die Ereignisse `account.updated`, `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired` und `charge.refunded` sind schon eingetragen (Webshop-Livegang).
## 4. Server-Konfiguration (Pilot)
Sicherungen zuerst: `cp /home/webklar/apps/docker-compose.yml /home/webklar/apps/docker-compose.yml.bak-2026-10-07-unterkunft` und `(umask 077; cp /home/webklar/apps/.env /home/webklar/apps/.env.bak-2026-10-07-unterkunft)` (`.env` enthält Zugangsdaten).
**Teil 1 (vor dem Push):** In `/home/webklar/apps/.env` zunächst nur
```
UNTERKUNFT_ENABLED=off
UNTERKUNFT_PILOT_EMAILS=kenso.gri@gmail.com,<Mimis Portal-Adresse>
```
In `/home/webklar/apps/docker-compose.yml` beim Dienst `kundenbereich-server` neben `ZEITEN_ENABLED` und `SHOP_ENABLED` zwei Zeilen:
```
- UNTERKUNFT_ENABLED=${UNTERKUNFT_ENABLED:-off}
- UNTERKUNFT_PILOT_EMAILS=${UNTERKUNFT_PILOT_EMAILS:-}
```
**Teil 2 (nach Push, grünem Portal-Deploy und Schritt 2/3):** `UNTERKUNFT_ENABLED=pilot` setzen und Compose neu anwenden (derselbe Befehl).
Prüfen mit `docker compose -f /home/webklar/apps/docker-compose.yml config -q` (keine Ausgabe = gültig), dann übernehmen mit `docker compose -f /home/webklar/apps/docker-compose.yml up -d --no-deps kundenbereich-server`. Ein bloßes `docker restart` liest neue Umgebungswerte nicht. Das alles vor dem Push (Schritt 5): Der Container läuft dann mit dem alten Code und den neuen Werten (die kennt er nicht, harmlos), und der Neustart durch den Deploy nach dem Push ist der einzige mit dem neuen Code. Rückweg: `UNTERKUNFT_ENABLED=off`, Compose neu anwenden; das Schema ist rein additiv, es gibt nichts zurückzubauen.
## 5. Push (durch den Inhaber)
- Portal: `git push origin feature/unterkunft:main` (der Webhook deployt),
- WOMS: `git push origin feature/unterkunft:test` **erst nach grünem Portal-Deploy** (das WOMS ruft die neue Portal-Schnittstelle, z. B. das Feld `unterkunft` der Funktionen-Antwort, auf).
Nie `git push origin feature/unterkunft` (eigener Name). Danach (Portal) prüfen: `https://project.webklar.com/embed/unterkunft.js` antwortet 200; `https://project.webklar.com/rechtliches/leistungsbeschreibung-unterkunft.html` und `…/avv-unterkunft.html` antworten 200.
## 6. Rechtstexte in WOMS „Rechtliches“ hochladen (macht Claude)
Das macht Claude selbst (Wunsch des Inhabers). Dafür legt der Inhaber vorher den **kurzlebigen** Appwrite-Schlüssel mit dem Recht für Rechtliches unter `/root/.appwrite-rechtliches.key` ab und löscht ihn danach. Claude legt (falls nötig, `scripts/setup-legal.mjs` im WOMS) die Karten „Leistungsbeschreibung Buchungskalender“ und „AVV Buchungskalender“ an und lädt die Fassung 2026-10-07 hoch:
- `public/rechtliches/leistungsbeschreibung-unterkunft-2026-10-07.pdf`
- `public/rechtliches/avv-unterkunft-2026-10-07.pdf`
(Die Widerrufsbelehrung `widerrufsbelehrung-funktionen-2026-10-07.pdf` ist ein Anhang der Bestellmail und der Bestellseite; sie ist inhaltlich die der Funktionen.)
## 7. Mimi freischalten und Donau Home einbauen
**Für Mimi ist der Buchungskalender kostenlos** (Entscheidung des Inhabers, 2026-10-07): keine Funktions-Anfrage, keine Bestellung und keine Zahlung der 199 €, kein Stripe-Checkout für die Einrichtung; freigeschaltet wird nur über „Aktivieren (ohne Zahlung)“. In der Textform-Nachricht an Mimi ausdrücklich schreiben, dass die Einrichtung für sie kostenlos ist (abweichend vom Preis in der Leistungsbeschreibung, Abschnitt „9. Preis, Abrechnung und Widerruf“ Abs. 1), damit keine Zahlungspflicht entsteht.
0. **Vorher:** die Zustimmung von Mimi in Textform einholen (Chat im Kundenportal oder E-Mail: Leistungsbeschreibung und AVV Buchungskalender in der Fassung 2026-10-07). Datum und Weg trägt das Team beim Aktivieren im WOMS ein; ohne beides schaltet das Portal nicht ein. Das Portal speichert den Nachweis und schickt Mimi die Unterlagen als PDF (K16).
1. WOMS → Kunde (Mimi) → Funktionen → Buchungskalender → **„Aktivieren (ohne Zahlung)“** (`action: 'activate'`; der Weg für Kunden, die die Funktion als Teil ihres Projekts bekommen; verlangt Datum und Weg der Zustimmung in Textform, auch im Pilot).
2. Mimi richtet im Portal ein: Unterkünfte, Preise, Stornobedingungen (Muster `docs/funktionen/unterkunft-storno-muster.md`), Firmendaten, Steuerart, optional Stripe; mit dem Team zusammen.
3. **siteKey in Donau Home:** den `siteKey` aus dem Ticket oder dem WOMS in `/root/reports/donau-home-website/index.html` im Abschnitt `#anfrage` einsetzen (`data-webklar-unterkunft="SITEKEY"`), das Skript `https://project.webklar.com/embed/unterkunft.js` einbinden, CSS-Variablen (`--wku-*`) auf Palette A (Eiche & Leinen) und Schrift Figtree stellen, den Datenschutz-Absatz in die Datenschutzerklärung der Website einfügen, den Einleitungstext anpassen. Einbau nach `docs/funktionen/unterkunft-einbau.md`.
4. Donau Home pushen (Inhaber, nicht Claude); die Vorschau zeigt das Widget, solange `UNTERKUNFT_ENABLED=pilot` mit Mimis siteKey läuft.
## 8. Pilot mit Mimi
1. Testanfrage im Widget (eigene Adresse): Mails G16 und K36 prüfen.
2. Im Portal freigeben (G17), Buchungslink öffnen, „vor Ort“ buchen (G19 mit Rechnung als PDF, K37).
3. Rechnung prüfen: Mimis Name und Anschrift, Steuernummer oder USt-ID, Nummer lückenlos, Steuerart, kein WEBklar-Hinweis.
4. Mit Stripe-Konto: Online-Zahlung mit kleinem Betrag testen (G20), dann stornieren mit Erstattung (G22, Stornorechnung).
5. Stornieren (Stornorechnung), Testbuchungen wieder entfernen; iCal-Import und -Export mit einem Test-Kalender prüfen.
## 9. Datenschutz § 6f übernehmen (VOR dem Pilotstart, also vor Schritt 8)
Den Abschnitt 6f aus `docs/rechtliches/unterkunft-datenschutz-6f-entwurf.md` in `public/datenschutz.html` übernehmen (hinter 6d, vor Abschnitt 7; Abschnitt 12 anpassen), danach `FORCE=1 scripts/build-legal-pdfs.sh datenschutz`. Erst mit der Freigabe des Inhabers, aber vor dem ersten Gast-Test mit Mimi: Die Anfrageformulare verweisen auf die Datenschutzerklärung.
## 10. Freigabe für alle
- Kanzlei-Fragen (`unterkunft-kanzlei-fragen.md`) beantworten lassen, Befund einarbeiten (neue Fassung, falls sich Texte ändern; neue Texte hochladen wie in Schritt 6).
- Dann `UNTERKUNFT_ENABLED=on` in `.env`, Compose neu anwenden (wie Schritt 4).
## PDFs der Rechtstexte erzeugen
Die PDFs entstehen aus den HTML-Seiten auf dem Host mit Chrome (Memory prüfen, `free -m` > 1500 MB verfügbar):
```bash
node --max-old-space-size=512 scripts/build-legal-html.mjs unterkunft # schreibt die HTML-Seiten
node --max-old-space-size=512 scripts/build-legal-html.mjs unterkunft --check # Exit 0 = ok
nice scripts/build-legal-pdfs.sh unterkunft # druckt die PDFs samt .sha256
```
Änderst Du einen Text, HTML neu erzeugen und die PDFs neu drucken (Prüfsumme entscheidet, was neu gedruckt wird; `FORCE=1` druckt alle neu).
@@ -0,0 +1,36 @@
# Muster: Stornobedingungen für den Buchungskalender
**Vorlage, keine Rechtsberatung.** Entwurf, Prüfung durch Kanzlei offen (`docs/rechtliches/unterkunft-kanzlei-fragen.md`, Frage U11). Der Vermieter ist für seine Stornobedingungen allein verantwortlich (Leistungsbeschreibung Buchungskalender Nr. 5 Abs. 3 und Nr. 6). Das Portal bietet diesen Text in den Einstellungen („Mustertext Stornobedingungen zum Übernehmen“) an; der Vermieter prüft ihn, passt ihn an seinen Betrieb an und kann ihn von einer Rechtsanwältin oder einem Rechtsanwalt prüfen lassen. Ohne Stornobedingungen kann der Buchungskalender nicht freigeschaltet werden.
Hinweise für das Team:
- Die Bedingungen werden bei der Freigabe einer Anfrage als Schnappschuss in der Anfrage gespeichert; die Buchungsseite und die Buchungsbestätigung zeigen genau diese Fassung. Eine spätere Änderung gilt nur für neue Freigaben.
- Der Buchungskalender erstattet eine Online-Zahlung nur auf Anweisung des Vermieters (Stornieren mit Erstattungsbetrag 0 bis Gesamtbetrag); die Staffel unten ist Text, die Software wendet sie nicht von selbst an. Der Vermieter trägt den Betrag beim Stornieren selbst ein.
- Storniert der Vermieter, schließt der Buchungskalender eine noch offene Zahlungsseite bei Stripe. Geht trotzdem eine Zahlung nach der Stornierung oder dem Ablauf der Reservierung ein, und bei einer zweiten Zahlung auf eine bezahlte Buchung, erstattet WEBklar sie automatisch in voller Höhe über das Stripe-Konto des Vermieters (dauerhafte Weisung, der Stand der Buchung bleibt). Das ist keine Erstattung nach Nr. 3 des Musters, sondern die Rückgabe einer irrtümlichen Zahlung.
- Zahlt der Gast vor Ort, entfällt eine Erstattung; die Stornierung stellt die Stornorechnung aus.
- Gäste können nicht selbst stornieren: Sie schreiben dem Vermieter. Der Text sagt das.
- Prozentsätze und Fristen sind Platzhalter und Beispiele; der Vermieter wählt eigene Werte. Pauschalen müssen den üblichen Schaden angemessen berücksichtigen (§ 309 Nr. 5 BGB); bei Verbrauchern sind überhöhte Pauschalen unwirksam.
---
## Stornobedingungen (Muster)
**1. Stornierung durch den Gast.** Eine Stornierung der Buchung ist nur in Textform möglich (E-Mail an [E-Mail-Adresse des Vermieters]) und wird erst mit unserer Bestätigung wirksam; wir stornieren die Buchung in unserem System und senden Ihnen eine Stornorechnung. Maßgeblich für die Frist ist der Eingang Ihrer Nachricht bei uns.
**2. Kosten der Stornierung.** Bei einer Stornierung berechnen wir folgenden Anteil des Gesamtpreises (ohne Endreinigung, soweit der Aufenthalt nicht angetreten wird):
- bis [30] Tage vor der Anreise: kostenfrei,
- [29] bis [14] Tage vor der Anreise: [30] Prozent,
- [13] bis [7] Tage vor der Anreise: [60] Prozent,
- ab [6] Tage vor der Anreise und bei Nichtanreise: [90] Prozent.
Ihnen bleibt der Nachweis gestattet, dass uns kein oder ein geringerer Schaden entstanden ist. Uns bleibt unser Anspruch unberührt, wenn wir die Unterkunft anderweitig vermieten können: In diesem Fall berechnen wir nur den tatsächlichen Ausfall.
**3. Erstattung.** Haben Sie bereits online bezahlt, erstatten wir den Teil des Preises, der nach Nr. 2 nicht anfällt, auf das Zahlungsmittel, mit dem Sie bezahlt haben. Die Erstattung geht über unser Stripe-Konto; die Gutschrift auf Ihrem Konto kann einige Tage dauern. Haben Sie noch nicht bezahlt, zahlen Sie den nach Nr. 2 anfallenden Betrag vor Ort oder nach Rechnung.
**4. Verspätete Anreise und vorzeitige Abreise.** Reisen Sie später an oder früher ab, besteht kein Anspruch auf Erstattung des Preises für die nicht genutzten Nächte, es sei denn, wir können die Unterkunft anderweitig vermieten.
**5. Stornierung durch uns.** Können wir die Unterkunft aus einem Grund, den wir zu vertreten haben, nicht bereitstellen, stornieren wir die Buchung, bieten nach Möglichkeit eine gleichwertige Unterkunft an und erstatten bereits gezahlte Beträge vollständig.
**6. Kein Widerrufsrecht.** Für die Buchung zu einem bestimmten Zeitraum besteht kein gesetzliches Widerrufsrecht (§ 312g Abs. 2 Nr. 9 BGB). Diese Stornobedingungen gelten stattdessen.
**7. Reiserücktrittsversicherung.** Wir empfehlen Ihnen eine Reiserücktrittsversicherung.
+28 -3
View File
@@ -1,6 +1,6 @@
# Mustertext: Datenschutzerklärung der Kundenwebsite (News-Pinnwand)
# Mustertext: Datenschutzerklärung der Kundenwebsite (News-Pinnwand, Öffnungszeiten live)
Entwurf, Freigabe Nutzer ausstehend. Der Absatz gehört in die Datenschutzerklärung jeder Kundenwebsite, auf der die News-Pinnwand läuft. Bau die Pinnwand erst ein, wenn er dort steht.
Entwurf, Freigabe Nutzer ausstehend. Der Absatz gehört in die Datenschutzerklärung jeder Kundenwebsite, auf der die News-Pinnwand läuft. Bau die Pinnwand erst ein, wenn er dort steht. Dasselbe gilt für „Öffnungszeiten live“: Der Absatz „Öffnungszeiten auf dieser Website“ (je Variante unten) gehört in die Erklärung jeder Website mit dieser Funktion; bau die Funktion erst ein, wenn er dort steht (Einbau-Anleitung: `docs/funktionen/zeiten-einbau.md`).
Hinweise für das Team:
@@ -8,7 +8,8 @@ Hinweise für das Team:
- Im sichtbaren Text der Kundenwebsite keine Gedankenstriche (Wunsch von Emma's Schatzkiste). Den Text unverändert übernehmen oder ohne Gedankenstriche anpassen.
- Anrede und Ton an die übrige Erklärung der Website anpassen (meist Sie). `[Überschrift]`-Nummerierung der Website beachten.
- Die Löschfrist von 14 Tagen für Server-Protokolle entspricht Abschnitt 3 der Datenschutzerklärung von WEBklar (project.webklar.com/datenschutz.html). Ändert sich dort die Frist, hier mitändern.
- Beide Varianten setzen voraus, dass das Skript keine Cookies setzt und nichts im Browser speichert. Das gilt für `news.js` (nur lesende Abrufe).
- Beide Varianten setzen voraus, dass das Skript keine Cookies setzt und nichts im Browser speichert. Das gilt für `news.js` (nur lesende Abrufe) und für `zeiten.js` (ein Abruf je Seite, keine Anfragen an Dritte).
- „Öffnungszeiten auf dieser Website“: den Absatz und nur die Bausteine übernehmen, die die Seite wirklich zeigt. Der Baustein „Route“ gehört dazu, wenn die Kontakt-Leiste den Knopf „Route“ hat, der Baustein „WhatsApp“, wenn sie den Knopf „WhatsApp“ hat. Der WhatsApp-Baustein ist der „WhatsApp-Hinweis“, den der Kunde im Portal mit einem Haken bestätigt, bevor die WhatsApp-Schaltfläche erscheint. Beide Dienste öffnen sich erst nach dem Klick des Besuchers. Anschriften von Google und WhatsApp vor der Übernahme prüfen. Auch hier gilt der Entwurfsstatus: erst nach der Freigabe durch den Inhaber (Rechtsrunde „Öffnungszeiten live“) verwenden.
## Variante A: Website wird von WEBklar gehostet
@@ -16,8 +17,32 @@ Hinweise für das Team:
Auf dieser Website zeigen wir unter „Neuigkeiten“ kurze Beiträge mit Text und Fotos. Beim Aufruf der Seite lädt Ihr Browser diese Beiträge und Bilder von dem Server, den die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, in unserem Auftrag betreibt und der bei der Hetzner Online GmbH in Falkenstein (Deutschland) steht. Dabei werden Ihre IP-Adresse, Datum und Uhrzeit des Aufrufs sowie Angaben zu Ihrem Browser und Betriebssystem übertragen und kurzzeitig in Server-Protokollen gespeichert. Die Pinnwand setzt keine Cookies und speichert nichts in Ihrem Browser. Wir brauchen diese Daten, um die Beiträge auszuliefern und den Betrieb sicher zu halten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt in der Darstellung aktueller Informationen und im sicheren, funktionsfähigen Betrieb der Website. Die Protokolle werden nach 14 Tagen gelöscht.
### Öffnungszeiten auf dieser Website
Auf dieser Website zeigen wir unsere Öffnungszeiten und ob wir gerade geöffnet haben. Dazu können Hinweise, zum Beispiel zu Betriebsurlaub, am Seitenanfang und auf dem Handy eine Leiste mit Knöpfen zum Anrufen und für die Route kommen. Beim Aufruf der Seite lädt Ihr Browser ein kleines Programm und unsere Öffnungszeiten von dem Server, den die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, in unserem Auftrag betreibt und der bei der Hetzner Online GmbH in Falkenstein (Deutschland) steht. Dabei werden Ihre IP-Adresse, Datum und Uhrzeit des Aufrufs sowie Angaben zu Ihrem Browser und Betriebssystem übertragen und kurzzeitig in Server-Protokollen gespeichert. Das Programm setzt keine Cookies und speichert nichts in Ihrem Browser. Wir brauchen diese Daten, um die Öffnungszeiten auszuliefern und den Betrieb sicher zu halten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt in der Darstellung aktueller Öffnungszeiten und im sicheren, funktionsfähigen Betrieb der Website. Die Protokolle werden nach 14 Tagen gelöscht. Für Suchmaschinen stehen unsere Öffnungszeiten außerdem in maschinenlesbarer Form im Seitencode.
#### Baustein Route (nur aufnehmen, wenn die Leiste den Knopf „Route“ zeigt)
Der Knopf „Route“ ist ein Link zu Google Maps. Erst wenn Sie darauf klicken, öffnet sich Google Maps (Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Irland) mit unserer Adresse als Ziel. Vorher werden keine Daten an Google übertragen. Nach dem Klick gelten die Datenschutzhinweise von Google.
#### Baustein WhatsApp (nur aufnehmen, wenn die Leiste den Knopf „WhatsApp“ zeigt)
Der Knopf „WhatsApp“ ist ein Link zu WhatsApp. Erst wenn Sie darauf klicken, öffnet sich WhatsApp (WhatsApp Ireland Limited, Merrion Road, Dublin 4, D04 X2K5, Irland, ein Unternehmen von Meta) mit unserer Nummer und gegebenenfalls einem vorgeschlagenen Starttext Ihrer Nachricht. Vorher werden keine Daten an WhatsApp oder Meta übertragen. Die Nachricht senden Sie selbst ab; nach dem Klick gelten die Datenschutzhinweise von WhatsApp.
## Variante B: Website liegt bei einem anderen Anbieter
### Neuigkeiten auf dieser Website
Auf dieser Website zeigen wir unter „Neuigkeiten“ kurze Beiträge mit Text und Fotos. Diese Beiträge und Bilder lädt Ihr Browser beim Aufruf der Seite von project.webklar.com, einem Server der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, der bei der Hetzner Online GmbH in Falkenstein (Deutschland) steht. Dabei werden Ihre IP-Adresse, Datum und Uhrzeit des Aufrufs sowie Angaben zu Ihrem Browser und Betriebssystem an project.webklar.com übertragen und kurzzeitig in Server-Protokollen gespeichert. Die Pinnwand setzt keine Cookies und speichert nichts in Ihrem Browser. Wir nutzen den Dienst, damit wir Ihnen aktuelle Informationen zeigen können, ohne die Website jedes Mal neu zu veröffentlichen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt in der einfachen und aktuellen Darstellung unserer Neuigkeiten. Die Protokolle werden nach 14 Tagen gelöscht. WEBklar verarbeitet die Daten in unserem Auftrag. [Prüfen: AVV mit WEBklar erforderlich]
### Öffnungszeiten auf dieser Website
Auf dieser Website zeigen wir unsere Öffnungszeiten und ob wir gerade geöffnet haben. Dazu können Hinweise, zum Beispiel zu Betriebsurlaub, am Seitenanfang und auf dem Handy eine Leiste mit Knöpfen zum Anrufen und für die Route kommen. Diese Angaben lädt Ihr Browser beim Aufruf der Seite als kleines Programm und als Öffnungszeiten von project.webklar.com, einem Server der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, der bei der Hetzner Online GmbH in Falkenstein (Deutschland) steht. Dabei werden Ihre IP-Adresse, Datum und Uhrzeit des Aufrufs sowie Angaben zu Ihrem Browser und Betriebssystem an project.webklar.com übertragen und kurzzeitig in Server-Protokollen gespeichert. Das Programm setzt keine Cookies und speichert nichts in Ihrem Browser. Wir nutzen den Dienst, damit unsere Öffnungszeiten auf der Website überall gleich und aktuell erscheinen, ohne dass wir die Website bei jeder Änderung neu veröffentlichen müssen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt in der einfachen und aktuellen Darstellung unserer Öffnungszeiten. Die Protokolle werden nach 14 Tagen gelöscht. Für Suchmaschinen stehen unsere Öffnungszeiten außerdem in maschinenlesbarer Form im Seitencode. WEBklar verarbeitet die Daten in unserem Auftrag. [Prüfen: AVV mit WEBklar erforderlich]
#### Baustein Route (nur aufnehmen, wenn die Leiste den Knopf „Route“ zeigt)
Der Knopf „Route“ ist ein Link zu Google Maps. Erst wenn Sie darauf klicken, öffnet sich Google Maps (Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Irland) mit unserer Adresse als Ziel. Vorher werden keine Daten an Google übertragen. Nach dem Klick gelten die Datenschutzhinweise von Google.
#### Baustein WhatsApp (nur aufnehmen, wenn die Leiste den Knopf „WhatsApp“ zeigt)
Der Knopf „WhatsApp“ ist ein Link zu WhatsApp. Erst wenn Sie darauf klicken, öffnet sich WhatsApp (WhatsApp Ireland Limited, Merrion Road, Dublin 4, D04 X2K5, Irland, ein Unternehmen von Meta) mit unserer Nummer und gegebenenfalls einem vorgeschlagenen Starttext Ihrer Nachricht. Vorher werden keine Daten an WhatsApp oder Meta übertragen. Die Nachricht senden Sie selbst ab; nach dem Klick gelten die Datenschutzhinweise von WhatsApp.
+123
View File
@@ -0,0 +1,123 @@
# Öffnungszeiten live auf der Kundenwebsite einbauen
Anleitung für das WEBklar-Team. „Öffnungszeiten live“ zeigt die Zeiten, die der Kunde im Portal einträgt (Tab „Funktionen“, Kachel „Öffnungszeiten live“), auf seiner Website: als Tabelle, als Live-Status und als Kurzanzeige, dazu eine Hinweis-Leiste, auf dem Handy eine Kontakt-Leiste und Google-Angaben (JSON-LD) im Seitencode. Den `siteKey` (16 Zeichen, a–z und 2–7) findest Du im WOMS: Kunde → Reiter „Funktionen“, Zeile „Öffnungszeiten live“ (mit Kopierknopf). Im Einrichtungs-Ticket „Öffnungszeiten live einbauen – <Projekt>“ stehen Einbau-Zeile, Ergebnis des automatischen Einbaus (`einbau`), Beispiele der Platzhalter mit den Plan-Kurznamen und die Prüfliste. Dieses Dokument ist die Referenz dahinter. Die Datenschutz-Absätze für die Kundenseite stehen in `docs/funktionen/website-datenschutz-absatz.md`.
Die Spec ist `docs/superpowers/specs/2026-10-04-oeffnungszeiten-live-design.md` (Abschnitte 4.2, 4.3, 4.5, 4.6).
## 1. Was automatisch geschieht und was Du tust
| Teil | Wer | Wie |
| --- | --- | --- |
| Einbau-Zeile (Skript) | Runner, automatisch | beim nächsten Veröffentlichen in jede HTML-Datei vor `</head>`, nur wenn die Funktion in `setup` oder `active` steht |
| Hinweis-Leiste, Kontakt-Leiste, JSON-LD | Skript | kommen mit den Daten, ohne Platzhalter |
| Platzhalter (Tabelle, Status, Badge) mit Ersatz-HTML | Du | an die Stellen setzen, an denen feste Zeiten stehen, und die festen Zeiten ersetzen |
| Datenschutz-Absatz auf der Website | Du | Abschnitt 8, Punkt 5 |
| Einbau-Zeile bei `einbau: manuell` oder wenn die Seite woanders liegt | Du | Abschnitt 9 |
Der Einbau ist **ein Einbau je Website** und ist im Preis (30 € oder im Wartungs-Abo ohne Aufpreis) enthalten. Er wird nicht auf das Stundenkontingent angerechnet. Sonderwünsche (zusätzliche Stellen, Sondergestaltung) gehen über ein Angebot oder, mit Abo, über Stunden.
## 2. Die Einbau-Zeile
```html
<script data-wk-zeiten src="https://project.webklar.com/embed/zeiten.js" data-site="SITEKEY" defer></script>
```
- Ins `<head>` jeder HTML-Seite, einmal pro Seite. Der Runner setzt sie vor `</head>` ein (liegt kein `</head>` vor, nach `<body>`); eine schon vorhandene Zeile mit `data-wk-zeiten` ersetzt er, der Einbau ist also wiederholbar und doppelt nichts.
- `data-site` ist der `siteKey`. Optional `data-api="https://project.webklar.com"` (nur am Script-Tag, nie an einem Platzhalter); ohne Angabe nimmt das Skript die Herkunft seiner eigenen Adresse (das klappt nur bei einem normalen `<script src>`; bei Tag-Manager oder nachgeladenem Skript ist `data-api` Pflicht). Den siteKey darf auch ein Platzhalter tragen (`data-webklar-zeiten="SITEKEY"`).
- Die Adresse des Skripts kommt aus der Runner-Variable `ZEITEN_PUBLIC_BASE` (Vorgabe `https://project.webklar.com/embed/`, muss mit `https://` beginnen und mit `/` enden). Für die Produktion nichts ändern.
- Das Skript hat keine Abhängigkeiten, setzt keine Cookies (keine Cookies, kein `localStorage`, kein `sessionStorage`), fragt nur `project.webklar.com` ab und lädt nichts von Dritten. Kundentexte setzt es nur als Text ein, nie als HTML. Es wird 5 Minuten gecacht und darf von jeder Domain geladen werden. Ein Abruf je Seite für alle Teile, danach höchstens alle 10 Minuten.
- Hat die Seite eine Content-Security-Policy, muss `project.webklar.com` in `script-src` und `connect-src` stehen, und `style-src` muss Inline-Stile erlauben (`'unsafe-inline'`): das Skript legt ein `<style>`-Element an (Tabelle, Status, Leisten). Ohne diese Erlaubnis erscheinen sie ohne Gestaltung.
## 3. Platzhalter
Ein Platzhalter ist ein Element mit dem Attribut `data-webklar-zeiten`. Innen steht das Ersatz-HTML (Abschnitt 4).
```html
<div data-webklar-zeiten data-modus="tabelle" data-plan="oeffnungszeiten">…Ersatz-HTML…</div>
<div data-webklar-zeiten data-modus="status" data-plan="oeffnungszeiten">Mo–Fr 9–18 Uhr</div>
<span data-webklar-zeiten data-modus="badge" data-plan="oeffnungszeiten">Mo–Fr 9–18 Uhr</span>
```
| `data-modus` | Zeigt | Gedacht für |
| --- | --- | --- |
| `tabelle` | Wochentabelle (echtes `<table>` mit Caption und Zeilenköpfen, heutiger Tag mit `aria-current="date"`), darunter „Abweichungen in den nächsten 14 Tagen“ | Kontaktseite, Footer |
| `status` | Punkt und Text, zum Beispiel „Jetzt geöffnet · schließt um 18 Uhr“, ohne `aria-live` | Kopfbereich, Hero |
| `badge` | Kurzform „Geöffnet“, „Geschlossen“, „Nach Vereinbarung“ | Menü, Header |
- `data-plan` ist der **Kurzname** des Zeitplans (Kleinbuchstaben, Ziffern und Bindestrich, 1–24 Zeichen, nach dem Anlegen unveränderlich). Der Hauptplan heißt `oeffnungszeiten`. Weitere Pläne (zum Beispiel `kueche`, `lieferung`) hat der Kunde im Editor angelegt; die Kurznamen stehen im Editor und im Einrichtungs-Ticket („Pläne (Kurznamen für data-plan)“). Ohne `data-plan` gilt der Hauptplan.
- **Einzelseiten-Apps (Vite/React):** Das Skript beobachtet `<body>` und füllt Platzhalter, die erst nach einem Seitenwechsel auf die Seite kommen, nach etwa 0,2 Sekunden (auch einen wieder eingehängten Knoten). Setze Platzhalter nicht in Teile, die die App beim Rendern entfernt oder neu aufbaut: ein neu gebauter Knoten startet wieder mit seinem Ersatz-HTML, und was das Skript hineingeschrieben hat, kann die App überschreiben.
- Ein **unbekannter** `data-plan` oder `data-modus` lässt das Ersatz-HTML stehen und schreibt eine Warnung in die Konsole. Es gibt kein stilles Umschalten auf den Hauptplan. Bei Fehlern, ohne JavaScript, bei Daten älter als 24 Stunden und nach dem Abschalten bleibt ebenfalls das Ersatz-HTML.
- Das Skript füllt die Platzhalter ab `setup`, damit Du vor dem Schließen des Tickets prüfen kannst.
- Gestaltung: Light DOM, alle Klassen beginnen mit `wkz-`, die Stile stehen in `@layer wkz`. **Jede** Regel der Kundenseite (ohne Layer) gewinnt also gegen die Standard-Stile. Schrift und Farben erbt die Seite. Variablen (alle optional, am Platzhalter oder auf `:root`): `--wkz-offen`, `--wkz-zu`, `--wkz-unklar` (Farben der Punkte), `--wkz-offen-hell`, `--wkz-zu-hell`, `--wkz-unklar-hell`, `--wkz-offen-text`, `--wkz-zu-text`, `--wkz-unklar-text` (Badge), `--wkz-rand`, `--wkz-heute`. Kontrast mindestens 4,5 : 1 prüfen.
- Hat das Theme globale Regeln wie `table{…}` oder `td{…}`, greifen sie auch in der Tabelle. Prüfe das bei der Abnahme und überschreibe gezielt, zum Beispiel `[data-webklar-zeiten] table{ … }`.
## 4. Ersatz-HTML
Jeder Platzhalter behält eine **statische Ersatz-Tabelle**: Sie steht ohne JavaScript, bei einem Fehler und nach der Abschaltung. Ohne sie wäre die Stelle leer.
1. WOMS → Funktionen → Ersatz-HTML kopieren (je Plan ein Knopf; bei mehreren Plänen mit Namen dahinter). Erst nach dem ersten Speichern des Kunden; vorher meldet das WOMS „Der Kunde hat noch keine Zeiten eingetragen“ (die Schnittstelle antwortet 409: `GET /api/team/funktionen/:customerId/zeiten/ersatz?projectId=…&plan=<kurz>`).
2. In jeden Platzhalter einsetzen. Das Ersatz-HTML ist eine schlichte Wochentabelle als reines ASCII mit Entities (`&auml;`, `&#8211;`), also in jeder Dateikodierung sicher. Ein Fensterende um Mitternacht steht als `00:00` („18:00–00:00“), ein Fenster von 00:00 bis 24:00 als „rund um die Uhr“.
3. Die Tabelle zeigt den Wochenplan **zum Zeitpunkt des Einbaus**. Ändert der Kunde seinen Wochenplan später, bleibt das Ersatz-HTML zunächst stehen (mit Skript unsichtbar).
4. **Beim Abschalten** der Funktion, wenn der aktuelle Wochenplan des Hauptplans vom Stand beim Einbau abweicht, entsteht ein WOMS-Ticket **„Ersatz-Tabelle aktualisieren – <Projekt>“** mit dem aktuellen Ersatz-HTML. Ersetze die Tabelle in allen Platzhaltern (gehört zum enthaltenen Einbau, Leistungsbeschreibung Nr. 4 Abs. 3). Die Mail K30 sagt dem Kunden, dass wieder feste Zeiten auf der Seite stehen. Liegt die Seite woanders, schicke dem Kunden den Text.
## 5. Header-Selektor (`navSelektor`)
Die Hinweis-Leiste steht als erstes Kind von `<body>` im Fluss und schiebt den Inhalt nach unten. Feste und klebende Elemente mit `top: 0` rückt das Skript, solange die Leiste sichtbar ist, um `max(0, Leistenhöhe − Scrollposition)` nach unten (Inline-`top`, beim Entfernen der Leiste zurückgesetzt) und erhöht `scroll-padding-top`.
Überlappt die Leiste trotzdem den Kopf der Seite (zum Beispiel ein fester Header mit `top: 12px`, mit `transform` oder in einem Wrapper), setze im WOMS bei Kunde → Funktionen → „Öffnungszeiten live“ das Feld **Header-Selektor** (Team-Einstellung `navSelektor`):
- ein CSS-Selektor des Headers, höchstens 120 Zeichen, erlaubt sind nur `A–Z a–z 0–9`, Leerzeichen und `# . - _ > : [ ] = " '`, nicht erlaubt `<`, `{`, `}` und `;`. Beispiel: `header.site-header` oder `#nav`.
- Das Skript wendet ihn mit `querySelectorAll` in einem `try/catch` an; ein falscher Selektor bricht nichts, er bewirkt nur nichts.
- Leer lassen = Standard (nur `top: 0`). Speichern mit `teamVersion` (bei 409 „Inzwischen geändert – bitte neu laden“).
- Prüfe nach dem Setzen: oben auf der Seite, beim Scrollen (die Leiste scrollt weg, der Header klebt wieder oben) und auf dem Handy.
## 6. Leisten ausschalten
Im selben Bereich (WOMS → Funktionen → „Öffnungszeiten live“) gibt es zwei Haken: **`kontaktleisteAus`** („Kontakt-Leiste aus“) und **`hinweisleisteAus`** („Hinweis-Leiste aus“). Sie gelten je Website.
- Die **Kontakt-Leiste** erscheint nur bei `(max-width: 768px)`, unten fest mit `z-index: 40`, Knöpfe „Anrufen“, „Route“ und, wenn der Kunde sie eingerichtet hat, „WhatsApp“. Hat die Seite selbst ein festes Element am unteren Rand mit einem `a[href^="tel:"]`, bleibt sie **automatisch aus**. Setze den Haken, wenn die Seite eine eigene Anruf-Leiste hat, die das Skript nicht erkennt, oder der Kunde die Leiste nicht möchte.
- WhatsApp und die Route (Google Maps) öffnen erst beim Klick. Den Haken „Ich habe den WhatsApp-Hinweis in meine Datenschutzerklärung aufgenommen“ setzt der Kunde im Portal; in der Kundenansicht setzt ihn kein Mitarbeiter.
- Die **Hinweis-Leiste** zeigt zu jedem Zeitpunkt den gültigen Hinweis mit dem höchsten Rang (Wichtig, Urlaub, Aktion, Info). Sie hat keinen Schließen-Knopf (das Skript speichert nichts im Browser).
## 7. JSON-LD
Das Skript pflegt die Öffnungszeiten im Seitencode: Gibt es einen `application/ld+json`-Block mit einem Knoten vom Typ `LocalBusiness` oder einem Untertyp (auch in `@graph`), ersetzt es dort **nur** `openingHoursSpecification` und `specialOpeningHoursSpecification` (alles andere im Block bleibt); unlesbare Blöcke bleiben unberührt. Gibt es keinen, legt es einen eigenen Block `[data-wkz-jsonld]` mit `name`, `address` und `url` an. Prüfe auf der Seite, ob JSON-LD mit festen Öffnungszeiten an einer anderen Stelle steht (zum Beispiel als Mikrodaten im Footer); das würde dem Skript widersprechen.
## 8. Prüfliste
Das Einrichtungs-Ticket nennt dieselben fünf Punkte:
1. **Feste Zeiten im Repo suchen und ersetzen**: `grep -ril "öffnungszeit" .` und Ähnliches (`geöffnet`, `Mo-Fr`, `Uhr`), auch Footer, Kontaktseite, `<meta>` und eigene Status-Skripte. Jede Stelle wird ein Platzhalter mit Ersatz-HTML oder fällt weg.
2. **Header**: Überlappt die Hinweis-Leiste den Kopf der Seite? Sonst `navSelektor` setzen (Abschnitt 5).
3. **Anruf-Leiste**: Hat die Seite schon eine eigene feste Anruf-Leiste? Dann `kontaktleisteAus` (Abschnitt 6), damit sie nicht doppelt erscheint.
4. **Vorhandenes JSON-LD** (Öffnungszeiten der Seite) prüfen, damit nichts doppelt oder widersprüchlich steht (Abschnitt 7).
5. **Datenschutz-Absatz auf der Website**: den Absatz „Öffnungszeiten auf dieser Website“ aus `docs/funktionen/website-datenschutz-absatz.md` einsetzen (Variante A bei WEBklar-Hosting, Variante B bei Fremdhosting). Die Bausteine „Route“ und „WhatsApp“ nur, wenn die Kontakt-Leiste die Knöpfe zeigt. Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, setzt Du den Absatz ein (gehört zum Einbau, nicht auf das Stundenkontingent anrechnen); liegt sie woanders (externer Generator), schicke dem Kunden den Textbaustein, er fügt ihn selbst ein. Die Texte sind Entwurf, bis die Rechtsrunde sie freigibt.
Zur Abnahme außerdem: Seite in der Vorschau öffnen und mit ausgeschaltetem JavaScript ansehen (Ersatz-Tabelle muss stehen), Konsole auf Warnungen zu `data-plan` prüfen, auf einem Handy (höchstens 768 px Breite) die Kontakt-Leiste, den Regler „Uhrzeit testen“ im Editor gegen die Anzeige halten, Kontrast und Tippflächen (mindestens 44 px) ansehen.
## 9. Sperrliste `ZEITEN_KEIN_NEUBAU` und Seiten ohne automatischen Einbau
**Neubau und Einsetzen.** Bei jedem Übergang in `setup` oder `active` (Einschalten im Abo, Zahlung, Aktivierung durch das Team, Rückkehr des Abos, Behalten) stößt das Portal einen Neubau der Seite an (höchstens einer gleichzeitig). Das Ergebnis steht in `einbau`: `angestossen` (Auftrag angenommen, die Zeile erscheint nach dem Veröffentlichen) oder `einbau: manuell`. Änderungen an Zeiten und Leisten brauchen keinen Neubau.
`einbau: manuell` heißt: kein Repo, Vorschau aus, Dev-Modus, Projekt auf der Sperrliste, Fehler oder Runner ausgelastet. Dann setzt Du die Einbau-Zeile von Hand ins Repo.
**Sperrliste.** `ZEITEN_KEIN_NEUBAU` (Umgebungsvariable des Portals, Liste von Subdomains, durch Komma getrennt): Für diese Projekte gibt es weder einen angestoßenen Neubau noch das automatische Einsetzen bei normalen Deploys; die Funktion zählt sie immer als manuell. Vorgabe ist `emmas-schatzkiste` (Emmas Seiten, deren Dateien teilweise nicht in Git liegen). **Achtung:** Setzt Du die Variable, **ersetzt** sie die Vorgabe. Willst Du eine Seite zusätzlich sperren, nenne `emmas-schatzkiste` mit in der Liste. Änderung in `compose` und `.env` des Portals, danach das Portal mit `docker compose -f /home/webklar/apps/docker-compose.yml up -d --no-deps kundenbereich-server` neu erstellen (kein `docker restart`: es liest geänderte Werte nicht neu ein). Für gesperrte Seiten setzt Du die Zeile und die Platzhalter von Hand ein (im Repo, soweit die Dateien dort liegen, sonst direkt auf dem Server) und prüfst nach jedem Deploy, ob sie noch da sind.
**Seite liegt woanders** (anderer Hoster, keine WEBklar-Veröffentlichung): Das Ticket enthält die Einbau-Zeile; Du setzt sie ein, soweit WEBklar Zugriff auf die Seite hat. Hat WEBklar keinen Zugriff, schicke dem Kunden im Chat Einbau-Zeile, Platzhalter-Beispiele und Ersatz-HTML und bitte ihn, sie selbst oder durch seinen Anbieter einzufügen. Schließe das Ticket erst, wenn die Anzeige auf der Live-Seite stimmt: Öffne die Seite, sieh in den Netzwerk-Aufrufen nach der Antwort von `/api/public/zeiten/v1/<siteKey>` (`"aktiv": true`) und nach der gefüllten Tabelle.
## 10. Freischalten und Ende
- **Freischalten:** Der Job setzt `active` (und sendet K16), wenn das Ticket geschlossen ist und der Kunde Zeiten eingetragen hat (Hauptplan gespeichert, mindestens ein Tag nicht „zu“). Sonst bleibt `setup`, das WOMS zeigt „wartet auf die Zeiten des Kunden“. Solange der Kunde nichts eingetragen hat, kannst Du die Tabelle nicht einbauen.
- **Ende des Abos** (Gratisweg): K29 mit Frist zum Behalten (30 Tage), danach `disabled` und K30, Eingaben noch 30 Tage, dann gelöscht. Das Ticket „Ersatz-Tabelle aktualisieren“ gehört zum Abschalten (Abschnitt 4).
- **Abschalten durch das Team** (Grund `team`): keine automatische Löschung.
## 11. Häufige Fehlerbilder
| Symptom | Meist liegt es an |
| --- | --- |
| Es erscheint nur das Ersatz-HTML | Funktion nicht in `setup`/`active`, `einbau: manuell` und Zeile fehlt, unbekannter `data-plan`, kein JavaScript, Daten älter als 24 Stunden, CSP blockiert `project.webklar.com` |
| Hinweis-Leiste verdeckt den Header | `navSelektor` nicht gesetzt (Abschnitt 5) |
| Zwei Anruf-Leisten auf dem Handy | `kontaktleisteAus` setzen (Abschnitt 6) |
| Kontakt-Leiste fehlt auf dem Handy | Seite hat schon eine feste Anruf-Leiste unten, oder Breite über 768 px, oder `kontaktleisteAus` |
| Öffnungszeiten doppelt im Suchergebnis oder Test | festes JSON-LD an anderer Stelle (Abschnitt 7) |
| Tabelle sieht fremd aus | globale Regeln des Themes (Abschnitt 3) |
+155
View File
@@ -0,0 +1,155 @@
# Abo-Bedingungen Fassung 6 (Entwurf)
Fassung 2026-10-16-v6 · Stand 16.10.2026 · Entwurf – Prüfung durch Kanzlei offen · Entwurf zur Freigabe, keine Rechtsberatung · Basis: Fassung 5 (Stand 30.09.2026, `public/vertragsunterlagen/v5/abo-bedingungen.html`)
Dieser Entwurf listet alle Änderungen gegenüber Fassung 5 im Wortlaut. Die Paragrafenfolge § 1 bis § 16 und alle Anker bleiben, damit Querverweise in Mails und der Paragraf zum Vertragsende (§ 11) stimmen. Die Sätze unter jeder Überschrift sind die Pflichtsätze; sie stehen unverändert in der Seite `public/neu/rechtliches/abo-bedingungen.html`. Zu jeder Stelle nennt die Zeile „Begründung“ die Entscheidung des Inhabers (E…), die Festlegung nach dem Builder-Check (C…) oder den Spec-Abschnitt und verweist bei offenen Punkten auf die Entscheidungsliste (`docs/rechtliches/kundenansicht-entscheidungen.md`, R… und KQ…). Voraussetzung für den Livegang ist die Freigabe durch den Inhaber (E20).
## Das Wichtigste auf einen Blick
Das Hosting-Abo enthält die Website-Analyse, das Wartungs-Abo zusätzlich News-Pinnwand, Terminplaner, Webshop, Buchungskalender und Öffnungszeiten live. Das Einschalten kostet nichts extra; die Einrichtung geht vom Stundenkontingent ab (§ 4 Abs. 8).
**Einordnung.** Neue Zeile „Funktionen“ in der Übersichtsbox, hinter der Zeile „Wartungs-Abo“. Die Box behält ihre Preiszeilen und die Anrede „Ihr“.
**Begründung.** E6 (die Funktionen stecken im Abo: Hosting-Abo mit Analyse, Wartungs-Abo mit allen Funktionen), E7 und E17 (Einrichtung vom Stundenkonto, keine Extra-Rechnung), C10 (Analyse im Hosting-Abo), Spec 5.9. Die Zeile nennt für Funktionen keinen Preis. Offen: Geltung für laufende Abos (R2, KQ1), Anrede der Rechtsdokumente (R12).
## § 1 Abs. 2
Die Bestellseiten richten sich nach der Kundenart, die wir im Kundenkonto hinterlegt haben („Betrieb“ oder „privat“). Wir hinterlegen sie nach den Angaben des Kunden; ist keine hinterlegt, gilt der Kunde als privat. Ob der Kunde Verbraucher oder Unternehmer ist, richtet sich nach dem Zweck, zu dem er den Vertrag abschließt, nicht nach dieser Angabe.
**Einordnung.** Hängt an den bestehenden Absatz 2 an („Sie gelten für Verbraucher und Unternehmer“).
**Begründung.** E2 bis E4 und C13: Die Kundenart legt das Team beim Anlegen fest, leer gilt als privat, und die Bestellseiten richten sich danach. Rechtlich entscheidet der Zweck des Vertrags (§§ 13, 14 BGB), nicht die hinterlegte Angabe; der Satz sagt das ausdrücklich, damit die Angabe nicht als Selbstauskunft oder als Rechtsfolge gelesen wird. Offen: Belastbarkeit der Kundenart (R5, KQ4).
## § 2 Abs. 2 c
Direkt über der Schaltfläche steht der Hinweis, dass diese Bedingungen mit den Leistungsbeschreibungen, dem Auftragsverarbeitungsvertrag (Anlage 1) und der Widerrufsbelehrung gelten, jeweils mit Fassung und Link; die Unterlagen erhält der Kunde nach der Bestellung zusätzlich als PDF per E-Mail. Ist die Kundenart „privat“ hinterlegt, kann der Kunde dort durch ein Kästchen verlangen, dass wir sofort mit der Leistung beginnen; ohne das Kästchen beginnen wir erst nach Ablauf der 14-tägigen Widerrufsfrist. Ist die Kundenart „Betrieb“ hinterlegt, beginnen wir mit Vertragsschluss, ohne dass der Kunde etwas markieren muss.
**Einordnung.** Ersetzt den bisherigen Buchstaben c (Zustimmung zu den Bedingungen und Wahl des Beginns). Die Buchstaben a, b, d und e bleiben.
**Begründung.** Spec 5.6, E1, E3, E4, C4 und recht.md Kurzfassung 3 und 4: Eine Linkzeile über dem Knopf ersetzt die Häkchen für Bedingungen, Leistungsbeschreibung, Auftragsverarbeitung und Belehrung; übrig bleibt das eine Häkchen „Bitte beginnt sofort …“ nur für Privatkunden in der Widerrufsfrist. Bei Betrieben (kein Widerrufsrecht) beginnt die Leistung mit Vertragsschluss. Die Kundenart kommt vom Server (§ 1 Abs. 2). Offen: Wortlaut des Häkchens ohne Normzitat (R6, KQ5), Belehrung nur per Link und PDF (R11, KQ6), AVV per Klick ohne eigenes Häkchen (R10, KQ7).
## § 2 Abs. 6
Wir speichern außerdem den Wortlaut der angezeigten Hinweise und Kästchen, die hinterlegte Kundenart sowie Zeit, IP-Adresse und Browserkennung des Klicks.
**Einordnung.** Hängt an den bestehenden Absatz 6 (Vertragssprache und Speicherung des Vertragstextes) an.
**Begründung.** Nachweise (Global Constraints der Übersicht, N34): Wortlaut, Fassung, Zeit, IP-Adresse, Browserkennung und Konto werden weiter gespeichert, ab jetzt mit der Kundenart zum Klickzeitpunkt. Der Satz macht das für den Kunden sichtbar; die Datenschutzerklärung nennt die Speicherung heute schon (Prüfpunkte D1 bis D3). Offen: Datenschutz-Prüfpunkte (R14).
## § 3 Abs. 8
Zum Abo gehören Funktionen, die der Kunde im Kundenportal unter „Funktionen“ einschaltet. Das Hosting-Abo enthält die Website-Analyse. Das Wartungs-Abo enthält News-Pinnwand, Terminplaner, Webshop, Buchungskalender, Öffnungszeiten live und Website-Analyse. Umfang, Voraussetzungen und Pflichten jeder Funktion beschreiben ihre Leistungsbeschreibung und, soweit die Funktion personenbezogene Daten im Auftrag des Kunden verarbeitet, ihr Auftragsverarbeitungsvertrag, jeweils in der Fassung, die das Kundenportal beim Einschalten anzeigt. Eine Funktion kostet keine zusätzliche Gebühr; die Einrichtung richtet sich nach § 4 Abs. 8 und 9.
Vorbehaltlich § 11 Abs. 4 läuft eine Funktion nur, solange das Abo läuft, das sie enthält. Das gilt auch für eine Funktion, die wir auf Wunsch des Kunden schon bei der Erstellung seiner Website eingebaut haben. Funktionen, die der Kunde vor Veröffentlichung dieser Fassung einzeln gekauft hat, laufen nach den Bedingungen weiter, unter denen er sie gekauft hat.
**Einordnung.** Neuer Absatz „(8) Funktionen“ nach Absatz (7) mit zwei Absätzen.
**Begründung.** E6, E9, E10, E21, C10, C11, C12 und Spec 5.5, 5.9: Funktionen sind Teil des Abos und kosten keine zusätzliche Gebühr; die Einrichtung läuft nach § 4 Abs. 8 und 9. Eine Funktion läuft nur mit dem Abo, das sie enthält, auch wenn sie schon bei der Website-Erstellung eingebaut wurde (E21). Einzeln gekaufte Funktionen behalten ihre Bedingungen (E10). Offen: Geltung für laufende Abos (R2, KQ1), Einrichtung beim Projektstart (R4, KQ8), Ende und Fristen (R8, KQ8), Analyse im Hosting-Abo (R9).
## § 4 Abs. 2
Einen Vorschuss nach Absatz 8 gleichen die folgenden Kontingente zuerst aus.
**Einordnung.** Hängt als letzter Satz an den bestehenden Absatz 2 (Verfügbarkeit und Verfall der Kontingente) an.
**Begründung.** E17: Ein offener Vorschuss wird vor allem anderen aus den folgenden Monatskontingenten ausgeglichen (FIFO, Spec 5.5). Der Satz verknüpft die bestehende Verbrauchsregel mit Absatz 8. Offen: Vorschuss-Regel insgesamt (R3, KQ2).
## § 4 Abs. 8
Die Einrichtung einer Funktion rechnen wir als Arbeitszeit auf das Stundenkontingent an. Angerechnet wird die tatsächlich aufgewendete Zeit nach Absatz 4. Reicht das verfügbare Kontingent nicht aus, führen wir die Einrichtung trotzdem ohne weitere Rückfrage aus. Die fehlende Zeit ist ein Vorschuss, den die folgenden Monatskontingente ausgleichen, sobald sie bereitstehen. Dafür berechnen wir nichts, weder als Mehraufwand nach Absatz 6 noch in einer gesonderten Rechnung. Endet das Wartungs-Abo, solange noch ein Vorschuss offen ist, verfällt der Rest; der Kunde schuldet ihn nicht.
**Einordnung.** Neuer Absatz 8 nach Absatz 7.
**Begründung.** E7, E8 und E17 (E17 ersetzt die Absprache aus E8), Spec 5.5: Die Einrichtung startet ohne Rückfrage und geht mit der echten Arbeitszeit (Absatz 4) vom Stundenkonto ab. Reicht das Kontingent nicht, ist die fehlende Zeit ein Vorschuss, nie Mehraufwand nach Absatz 6; ein Rest bei Vertragsende verfällt zugunsten des Kunden. Normale Arbeit außerhalb von Einrichtungen bleibt beim Mehraufwand nach Absatz 6. Offen: Vorschuss ohne Obergrenze (R3, KQ2).
## § 4 Abs. 9
Im Hosting-Abo ist die Einrichtung der Website-Analyse für den Kunden kostenlos; ein Stundenkontingent gibt es dort nicht (Absatz 7). Lässt der Kunde eine Funktion auf seinen Wunsch schon bei der Erstellung seiner Website einbauen, rechnen wir die Einrichtung nicht über das Stundenkontingent ab, sondern über die Kosten des Projekts, wie wir es mit ihm vereinbart haben.
**Einordnung.** Neuer Absatz 9 nach Absatz 8.
**Begründung.** E9, E21, C6 und C7, Spec 5.5: Im Hosting-Abo gibt es kein Kontingent (Absatz 7), die Einrichtung der Analyse ist dort kostenlos (C6). Bei einer Funktion, die schon bei der Erstellung der Website eingebaut wird, zählen die Einrichtungs-Arbeitsblätter nicht aufs Stundenkonto, sondern laufen über die Projektkosten, wie mit dem Kunden vereinbart (C7). Offen: Einrichtung beim Projektstart (R4, KQ8), Analyse im Hosting-Abo (R9).
## § 6 Abs. 6
Schaltet der Kunde die Website-Analyse ein, ist er für die Messung auf seiner Website Verantwortlicher. Er entfernt oder umgeht das Cookie-Banner nicht, bindet ohne Abstimmung mit uns keine weiteren Mess- oder Tracking-Werkzeuge ein und ergänzt seine Datenschutzerklärung um einen Hinweis auf die von uns erzeugte Zusatzseite „/datenschutz-analyse.html“.
**Einordnung.** Neuer Absatz 6 nach Absatz 5.
**Begründung.** C10 und Spec 5.5 (Analyse): Wer die Website-Analyse einschaltet, ist für die Messung auf seiner Website Verantwortlicher. Der Absatz stellt die Pflichten aus der Abo-Ergänzung Analyse (Cookie-Banner, keine weiteren Mess- oder Tracking-Werkzeuge ohne Abstimmung, Hinweis auf die Zusatzseite) in die Abo-Bedingungen, weil die Analyse jetzt auch im Hosting-Abo liegt. Die Tatsachenangabe zur Zielgruppe bleibt als Zeile mit Häkchen in der Oberfläche (Spec 5.5, N22). Offen: Analyse im Hosting-Abo (R9, KQ8), Ausschlüsse als Vertragsklausel (R13, KQ9).
## § 9 Abs. 6
Will der Kunde im Hosting-Abo eine Funktion einschalten, die nur das Wartungs-Abo enthält, führt ihn das Kundenportal zum Wechsel. Nach Wirksamkeit des Wechsels schaltet er die Funktion in einem eigenen Vorgang ein. Der Wechsel bleibt eine eigene Bestellung nach Absatz 2.
**Einordnung.** Neuer Absatz 6 nach Absatz 5.
**Begründung.** E14 und C5: Wer im Hosting-Abo eine Funktion einschalten will, die nur das Wartungs-Abo enthält, wird zum Wechsel geführt und schaltet danach in einem eigenen Vorgang ein. Der Wechsel bleibt eine eigene Bestellung mit eigener Widerrufsfrist (§ 2 Abs. 5); das Einschalten ist keine Bestellung. Offen: Sofortbeginn des Wechsels nur mit Häkchen bei Privatkunden (R6, KQ5).
## § 11 Abs. 4
Mit dem Ende des Abos, das eine Funktion enthält, endet die Funktion; abweichend davon läuft Öffnungszeiten live noch 30 Tage unverändert weiter. Wir schalten sie ab und löschen ihre Daten nach den Fristen in ihrer Leistungsbeschreibung; die Messdaten der Website-Analyse löschen wir 60 Tage nach der Abschaltung. Beginnt innerhalb dieser Fristen wieder ein Abo, das die Funktion enthält, schalten wir sie mit den vorhandenen Daten wieder ein. Wechselt der Kunde vom Wartungs- ins Hosting-Abo, bleibt die Website-Analyse; die übrigen Funktionen enden. Das Gleiche gilt, wenn der Kunde den Wechsel ins Wartungs-Abo widerruft.
**Einordnung.** Neuer Absatz 4 nach Absatz 3.
**Begründung.** E21, C12 und Nachtrag N31 (mit P4 und P6 abgestimmt): Funktionen enden mit dem Abo, das sie enthält. Terminplaner, News, Webshop und Buchungskalender schalten spätestens am Folgetag ab (Tagesjob); Öffnungszeiten live läuft nach dem Ende noch 30 Tage unverändert weiter, ein „Behalten“ gegen Zahlung gibt es nicht mehr. Gelöscht wird nach den Fristen der Leistungsbeschreibungen: 30 Tage (Terminplaner, News, Öffnungszeiten live), 90 Tage (Webshop, Buchungskalender), 60 Tage für die Messdaten der Analyse. Ein neues passendes Abo in der Frist holt die Funktion zurück. Beim Wechsel ins Hosting-Abo und beim Widerruf des Wechsels bleibt die Analyse (E6, C10). Offen: R8 (KQ8).
## § 14 Abs. 2
Für Funktionen, die personenbezogene Daten im Auftrag des Kunden verarbeiten, gilt zusätzlich der Auftragsverarbeitungsvertrag der Funktion, dem der Kunde beim Einschalten mit dem Klick auf „Einschalten“ zustimmt.
**Einordnung.** Hängt an den bestehenden Absatz 2 (Auftragsverarbeitungsvertrag in Anlage 1) an.
**Begründung.** Spec 5.9 (AVV je Funktion): Option A der AVV-Struktur, die Auftragsverarbeitungsverträge der Funktionen bleiben eigene Verträge mit neuer Vorbemerkung, Anlage 1 bleibt der Rahmen; der Klick auf „Einschalten“ unter der Linkzeile ist die Zustimmung. Offen: Struktur (R10, KQ7), Klick statt eigenes Häkchen (KQ7).
## § 15
Fassung 6 gilt für Abos, die ab ihrer Veröffentlichung abgeschlossen werden. Für Abos, die nach einer früheren Fassung abgeschlossen wurden, gelten diese Fassungen weiter. Schaltet der Kunde mit einem solchen Abo eine Funktion ein, gelten für die Funktion ihre Leistungsbeschreibung und ihr Auftragsverarbeitungsvertrag in der angezeigten Fassung, einschließlich der Regeln zur Einrichtung und zum Stundenkontingent.
**Einordnung.** Neuer zweiter Absatz hinter dem bisherigen Text. Der bisherige Text bleibt wörtlich.
**Begründung.** C11 und E10: Fassung 6 gilt für neue Abschlüsse; laufende Abos der Fassungen 3 bis 5 behalten ihren Vertrag. Beim Einschalten gelten die Leistungsbeschreibung und der Auftragsverarbeitungsvertrag der Funktion über die Linkzeile und den Klick, auch mit den Regeln zur Einrichtung und zum Stundenkonto. Die Änderung ist für laufende Abos vorteilhaft (Spec 5.9), deshalb keine Zustimmungsabfrage nach dem bisherigen Text. Offen: Info-Mail an laufende Abos (R2, KQ1).
## Leistungsbeschreibung A Nr. 1
Website-Analyse im Kundenportal (§ 3 Abs. 8)
**Einordnung.** Neuer Listenpunkt am Ende von Nr. 1 „Umfang“.
**Begründung.** E6 und C10: Das Hosting-Abo enthält die Website-Analyse. Offen: Analyse im Hosting-Abo (R9, KQ8).
## Leistungsbeschreibung B Nr. 1
Funktionen im Kundenportal: News-Pinnwand, Terminplaner, Webshop, Buchungskalender, Öffnungszeiten live und Website-Analyse (§ 3 Abs. 8)
**Einordnung.** Neuer Listenpunkt am Ende von Nr. 1 „Umfang“.
**Begründung.** E6: Das Wartungs-Abo enthält alle Funktionen und als Obermenge die Analyse.
## Widerruf: Hinweise
Das Einschalten einer Funktion ist kein eigener Vertrag, sondern Teil des Abos. Es verlängert die Widerrufsfrist des Abos nicht. Widerruft ein Verbraucher das Abo, endet auch die Funktion (§ 11 Abs. 4).
**Einordnung.** Neue Nummer 5 am Ende der „Hinweise zum Widerruf“. Widerrufsbelehrung und Muster-Widerrufsformular bleiben wortgleich mit Fassung 5.
**Begründung.** Spec 5.5 und 5.9, recht.md § 2: Das Einschalten löst keine neue Zahlungspflicht aus und ist kein eigener Vertrag; deshalb gibt es keine eigene Widerrufsfrist. Bei Widerruf des Abos endet die Funktion (§ 11 Abs. 4). Wie sich die Einrichtungszeit innerhalb der Frist auf den Wertersatz auswirkt, regelt das Häkchen für Privatkunden (§ 2 Abs. 2 c); die Entscheidung dazu steht in R7. Offen: R7 (KQ3), Belehrung per Link (R11, KQ6).
## Anlage 3
Server für die Website-Analyse (Messdienst, nur bei eingeschalteter Funktion)
Deutschland (Rechenzentrum Falkenstein)
**Einordnung.** Neue Tabellenzeile hinter den beiden Hetzner-Zeilen (Unternehmen wie dort, Leistung und Ort wie im Pflichtsatz).
**Begründung.** Spec 5.9 (AVV je Funktion), Art. 28 Abs. 2 DSGVO: Der Server des Messdienstes der Website-Analyse (Hetzner, Rechenzentrum Falkenstein, wie in der Auftragsverarbeitung der Analyse) wird als Unterauftragsverarbeiter genannt, solange die Analyse eingeschaltet ist. Offen: AVV-Struktur (R10, KQ7).
## Was gleich bleibt
- Preise: Hosting-Abo 20,00 € pro Monat, Wartungs-Abo 50,00 € pro Monat, E-Mail-Zusatzspeicher 1,00 € pro Monat je Paket von 5 GB, Mehraufwand 100 € pro Stunde (alle Endpreise, `§ 19 UStG`). Funktionen bekommen keinen Preis.
- Laufzeiten: Mindestlaufzeit zwölf Monate ab Leistungsbeginn, Kündigung zum Ende der Mindestlaufzeit mit einem Monat Frist, danach monatlich (§ 8), Sonderkündigung bei Geschäftsaufgabe.
- Paragrafenfolge § 1 bis § 16 und alle Anker (`p1` bis `p16`, `wechsel`, `zusatzspeicher`, `hosting`, `wartung`, `widerruf`, `formular`, `widerruf-hinweise`, `avv`, `tom`, `sub`).
- Widerrufsbelehrung und Muster-Widerrufsformular: wortgleich mit Fassung 5; die Hinweise zum Widerruf Nr. 1 bis 4 bleiben.
- § 10 E-Mail-Zusatzspeicher mit Absatz 3 (Bereitstellung sofort, kein Wertersatz) und den Hinweisen zum Widerruf Nr. 4.
- Anlage 1 (Auftragsverarbeitungsvertrag) und Anlage 2 (technische und organisatorische Maßnahmen); Anlage 3 behält ihre beiden Hetzner-Zeilen.
- Die Rechtsdokumente bleiben in der bisherigen Anrede („der Kunde“, in der Übersichtsbox „Ihr“); nur Oberflächentexte stehen in „du“ (R12).
+149
View File
@@ -0,0 +1,149 @@
# Allgemeine Geschäftsbedingungen
**Fassung 2026-09-30** · Wortgleiche Kopie der Allgemeinen Geschäftsbedingungen auf webklar.com/agb
## Kaufvertrag – WEBklar (Modularer Projektvertrag)
**zwischen**
WEBklar (im Folgenden „Anbieter“)
**und**
Kunde laut Angebot (im Folgenden „Kunde“)
## 1. Vertragsgegenstand
(1) Gegenstand dieses Vertrages ist die Erbringung der im Angebot definierten Leistungen.
(2) Der Vertrag besteht aus diesem Grundvertrag sowie den ausgewählten Leistungsmodulen.
(3) Maßgeblich ist das jeweils angenommene Angebot von WEBklar.
## 2. Leistungsart
(1) Sämtliche Leistungen von WEBklar stellen Dienst- und Entwicklungsleistungen dar.
(2) Ein bestimmter wirtschaftlicher, technischer oder rechtlicher Erfolg wird nicht geschuldet.
(3) WEBklar erbringt keinen laufenden Betrieb, sofern dieser nicht explizit vereinbart wurde.
## 3. Leistungsmodul A – Webseite (einmalig)
(aktiv, wenn im Angebot enthalten)
(1) WEBklar erstellt eine individuelle Webseite gemäß Angebot.
(2) Die Umsetzung erfolgt nach den vom Kunden gelieferten Inhalten und Vorgaben.
(3) Zusätzliche Leistungen wie Domain, Hosting, Wartung oder SEO sind nicht Bestandteil, sofern sie nicht gesondert beauftragt wurden.
(4) Der Kunde ist nicht berechtigt, Änderungen am Quellcode selbst vorzunehmen.
(5) Änderungen erfolgen ausschließlich durch WEBklar gegen gesonderte Vergütung.
## 4. Leistungsmodul B – Automatisierung / Virtualisierung (einmalig)
(aktiv, wenn im Angebot enthalten)
(1) WEBklar entwickelt individuelle Automatisierungen, Apps oder Virtualisierungssysteme.
(2) Die Leistung stellt eine reine Entwicklungsleistung dar.
(3) Optional kann eine Beratungsleistung Bestandteil des Projektes sein.
(4) Ein laufender Betrieb, Monitoring oder Wartung ist nicht geschuldet, außer dies wurde explizit vereinbart.
(5) Der Kunde entscheidet über Inhalte, Daten und Prozesse und trägt dafür die rechtliche Verantwortung.
## 5. Leistungsmodul C – Hosting
(aktiv, wenn im Angebot enthalten)
(1) Hosting bietet WEBklar als Hosting-Abo an. Für das Hosting-Abo und das Wartungs-Abo gelten die Vertragsbedingungen für die WEBklar-Abos mit Leistungsbeschreibungen; sie gehen diesen AGB vor.
(2) Bestehende Hosting-Verträge mit jährlicher Laufzeit behalten die bei ihrem Abschluss vereinbarten Bedingungen. Abweichend davon können Verbraucher solche Verträge nach einer automatischen Verlängerung jederzeit mit einer Frist von einem Monat kündigen.
(3) WEBklar ist berechtigt, externe Anbieter (z. B. Rechenzentren) einzusetzen.
## 6. Quellcode und Eigentum
### 6.1 Webseite
(1) Der Kunde erhält ein einfaches, zeitlich unbegrenztes Nutzungsrecht an der fertigen Webseite.
(2) Der Quellcode der Webseite wird nur auf ausdrückliche Anfrage und nach Vereinbarung herausgegeben.
(3) Ohne Vereinbarung verbleibt der Quellcode bei WEBklar und wird archiviert.
### 6.2 Apps, Automatisierungen und Backend
(1) Der Quellcode von Apps, Automatisierungen und Backend-Systemen verbleibt vollständig bei WEBklar.
(2) Eine Herausgabe erfolgt ausschließlich nach gesonderter schriftlicher Vereinbarung.
(3) Der Kunde erhält lediglich Zugriff auf die Bedienoberfläche bzw. das Frontend.
## 7. Mitwirkungspflichten des Kunden
(1) Der Kunde stellt alle benötigten Inhalte, Daten und Freigaben rechtzeitig bereit.
(2) Verzögerungen durch fehlende Mitwirkung gehen nicht zu Lasten von WEBklar.
(3) WEBklar ist nicht verpflichtet, rechtliche Prüfungen der Inhalte vorzunehmen.
## 8. Abnahme
(1) Nach Fertigstellung wird dem Kunden die Leistung zur Abnahme bereitgestellt.
(2) Erfolgt innerhalb von 14 Tagen keine Rückmeldung, gilt die Leistung als abgenommen.
(3) Nach Abnahme sind nur noch kostenpflichtige Änderungen möglich.
## 9. Vergütung und Zahlung
(1) Die Vergütung ergibt sich aus dem Angebot.
(2) Wird nach Aufwand abgerechnet, erfasst WEBklar die Arbeitszeit je Arbeitsschritt in Schritten von 15 Minuten; angefangene 15 Minuten werden aufgerundet. Wirkt eine weitere Person unterstützend mit, rechnet WEBklar für sie die Hälfte der Arbeitszeit des Arbeitsschritts an, aufgerundet auf volle 15 Minuten; bei mehreren unterstützenden Personen gilt das für jede. Jeder Arbeitsschritt und jede Unterstützung ist im Kundenportal einsehbar.
(3) Projektleistungen sind nach Vereinbarung fällig.
(4) Für die Abos gelten die Vertragsbedingungen für die WEBklar-Abos.
## 10. Zahlungsverzug
(1) Bei Zahlungsverzug erfolgen bis zu zwei Mahnungen.
(2) Danach ist WEBklar berechtigt:
- Leistungen zu sperren
- Verzugszinsen zu berechnen
- den Vertrag außerordentlich zu kündigen
## 11. Haftung
(1) WEBklar haftet unbeschränkt bei Vorsatz und grober Fahrlässigkeit, bei Verletzung von Leben, Körper oder Gesundheit, nach dem Produkthaftungsgesetz und soweit WEBklar eine Garantie übernommen hat.
(2) Bei leicht fahrlässiger Verletzung einer wesentlichen Vertragspflicht haftet WEBklar nur für den vertragstypischen, vorhersehbaren Schaden. Wesentlich sind Pflichten, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags überhaupt erst ermöglicht und auf deren Einhaltung der Kunde regelmäßig vertrauen darf. Im Übrigen ist die Haftung für leichte Fahrlässigkeit ausgeschlossen.
(3) Der Kunde bewahrt Kopien der Inhalte und Daten auf, die er WEBklar übergibt oder selbst einpflegt. Für den Verlust solcher Inhalte und Daten haftet WEBklar außer in den Fällen von Absatz 1 nur in Höhe des Aufwands, der für ihre Wiederherstellung nötig gewesen wäre, wenn der Kunde diese Kopien aufbewahrt hätte.
(4) Diese Regeln gelten auch zugunsten der Mitarbeiter und Beauftragten von WEBklar.
## 12. Kündigung
(1) Laufzeitverträge können zum Ende der jeweiligen Laufzeit gekündigt werden. Laufzeit und Kündigung der Abos richten sich nach den Vertragsbedingungen für die WEBklar-Abos.
(2) Das Recht zur außerordentlichen Kündigung bleibt unberührt.
## 13. Referenzen
WEBklar darf das Projekt nur nach ausdrücklicher Zustimmung des Kunden als Referenz verwenden.
## 14. Schlussbestimmungen
(1) Es gilt deutsches Recht.
(2) Gerichtsstand ist der Sitz von WEBklar, soweit zulässig.
(3) Sollten einzelne Bestimmungen unwirksam sein, bleibt der Vertrag im Übrigen wirksam.
+42
View File
@@ -0,0 +1,42 @@
# Archiv der Analyse-Texte
Jede ausgelieferte Fassung bleibt hier nachvollziehbar. `BANNER_VERSION` im Client (`analytics/client/core.js`) und `config.analyse.avvVersion` tragen dieselbe Fassung.
| Fassung | gültig ab | Banner-Text | Zusatzseite |
|---|---|---|---|
| 2026-10-02 | offen bis Livegang | [banner-2026-10-02.md](banner-2026-10-02.md) (`BANNER_VERSION` 2026-10-02) | [datenschutz-analyse-2026-10-02.html](datenschutz-analyse-2026-10-02.html) |
Änderung der Zusatzseite am 02.10.2026 (Fassung bleibt 2026-10-02, Banner-Text unverändert): Der Knopf zum Wiederöffnen des Banners ist jetzt ein Keks-Symbol (Name für Screenreader und Beschriftung beim Darüberfahren weiter „Datenschutz-Einstellungen“). Die Zusatzseite nennt deshalb bei Widerruf und Widerspruch zusätzlich „(das Keks-Symbol)“. Die vorige Formulierung war bis dahin nur auf der WEBklar-Testseite (test-website.project.webklar.com) ausgeliefert, keiner Kunden-Website: [datenschutz-analyse-2026-10-02-erstfassung.html](datenschutz-analyse-2026-10-02-erstfassung.html). In WOMS → Rechtliches als neue Version der Karte „datenschutz-website-analyse“ hinterlegt.
## Eigene Website webklar.com
Auf webklar.com läuft dieselbe Messung mit WEBklar als Verantwortlichem (keine AVV). Das Skript-Tag trägt `data-variant="eigen"`; der Banner hat dann eine eigene Sie-Fassung (`BANNER_VERSION_EIGEN`), die Zusatzseite liegt im Repository der Website (`public/datenschutz-analyse.html`), die Kopie hier ist byte-gleich zu der ausgelieferten Fassung. Der Abschnitt 4a der Datenschutzerklärung fasst sie zusammen.
| Fassung | gültig ab | Banner-Text | Zusatzseite |
|---|---|---|---|
| 2026-10-17 | offen bis Livegang | [banner-eigen-2026-10-17.md](banner-eigen-2026-10-17.md) (`BANNER_VERSION_EIGEN` 2026-10-17) | [datenschutz-analyse-webklar-com-2026-10-17.html](datenschutz-analyse-webklar-com-2026-10-17.html) |
AVV und Leistungsbeschreibung je Fassung: `public/rechtliches/avv-website-analyse-<Fassung>.html|pdf` und `leistungsbeschreibung-website-analyse-<Fassung>.html|pdf`. „Gültig ab“ wird beim Livegang eingetragen.
## Terminplaner
Die Texte des Terminplaners (Leistungsbeschreibung, AVV, Widerrufsbelehrung mit Muster-Widerrufsformular) tragen die Fassung `config.termine.avvVersion`. Quelle ist je Dokument eine Markdown-Datei unter `docs/rechtliches/`; `node scripts/build-legal-html.mjs termine` erzeugt daraus die HTML-Seiten, `scripts/build-legal-pdfs.sh termine` die PDFs (die Mail K15 hängt sie an). Eine ausgelieferte Fassung wird nie überschrieben: jede Änderung nach der Übergabe an die Kanzlei oder dem Livegang bekommt ein neues Datum und neue Dateien.
| Fassung | gültig ab | Leistungsbeschreibung | AVV | Widerrufsbelehrung |
|---|---|---|---|---|
| 2026-10-05 | offen bis Livegang | [HTML](../../../public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html) · [PDF](../../../public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf) | [HTML](../../../public/rechtliches/avv-terminplaner-2026-10-05.html) · [PDF](../../../public/rechtliches/avv-terminplaner-2026-10-05.pdf) | [HTML](../../../public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.html) · [PDF](../../../public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.pdf) |
Adressen ohne Fassung (`/rechtliches/leistungsbeschreibung-terminplaner.html`, `/rechtliches/avv-terminplaner.html`, `/rechtliches/widerrufsbelehrung-funktionen.html`) liefern die Seite der aktuellen Fassung; Mails und Zustimmungstexte verlinken nur die Adressen mit Fassung. „Gültig ab“ wird beim Livegang eingetragen.
## Terminplaner-Gästetexte
Die Texte, die ein Gast beim Buchen sieht (Hinweis am Formular, Texte der Haken, Gesundheitshinweis, Knopftexte, Widerrufsbelehrung für Gäste) und der Datenschutz-Absatz für die Website des Betriebs tragen die Fassung `RECHTSTEXTE_VERSION` in `server/services/termine/rechtstexte.js`. Der Nachweis einer Buchung speichert die Fassung und Hashes der Texte, nicht die Texte selbst (AVV Terminplaner Nr. 8 Abs. 5); den Wortlaut je Fassung hält dieses Archiv. `node scripts/termine-texte-archiv.mjs` erzeugt `termine-gaestetexte-<Fassung>.md` aus den Texten (Betriebsangaben als Platzhalter), `--check` vergleicht mit der eingecheckten Datei. Eine Datei einer früheren Fassung wird nie neu erzeugt oder geändert (`test/termineRechtstexteArchiv.test.js` pinnt ihren Hash); jede Textänderung bekommt ein neues Datum und eine neue Datei. Das Archiv bleibt bis drei Jahre nach dem Ende des Terminplaners erhalten.
| Fassung | gültig ab | Archiv |
|---|---|---|
| 2026-10-02 | 2026-10-03 (Pilot) | [termine-gaestetexte-2026-10-02.md](termine-gaestetexte-2026-10-02.md) |
| 2026-10-05 | offen bis Livegang | [termine-gaestetexte-2026-10-05.md](termine-gaestetexte-2026-10-05.md) |
„Gültig ab“ einer neuen Fassung wird beim Livegang eingetragen; ihr Zeitraum endet mit dem „gültig ab“ der nächsten.
Änderungen der Fassung 2026-10-05 gegenüber 2026-10-02 (Textänderungen: nur diese beiden Stellen): Der Haken für die Erinnerung endet mit dem Satz „Diese Einwilligung kann ich jederzeit widerrufen.“ (Art. 7 Abs. 3 DSGVO); der Datenschutz-Absatz sagt bei der Abschaltung: „Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.“ Außerdem ändert sich die Regel zum Haken „Stornobedingungen“, nicht sein Text: Pflicht ist er nur noch bei einer Leistung mit Preis, bei kostenlosen Leistungen fragt der Terminplaner nicht danach (die Fassung 2026-10-02 verlangte ihn bei jeder Leistung, sobald der Betrieb Stornobedingungen hat). Buchungen, die vor dem Livegang der neuen Fassung entstanden sind, behalten im Nachweis die Fassung 2026-10-02.
@@ -0,0 +1,23 @@
# Banner-Text Fassung 2026-10-02
Quelle: `analytics/client/core.js` (Konstanten `BANNER_*`). Wortgleich zu `banner-portal-mailtexte.md` § 1.
- `BANNER_VERSION`: 2026-10-02
## Fließtext (BANNER_TEXT)
Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: [Datenschutz](/datenschutz-analyse.html)
## aria-label (BANNER_ARIA)
Einwilligung zur Reichweitenmessung
## Schaltflächen
„Ablehnen“ · „Annehmen“ (gleich gestaltet, Ablehnen zuerst)
## Einstellungs-Link (SETTINGS_LABEL)
Datenschutz-Einstellungen
Ab 02.10.2026: als angeknabbertes, sich langsam drehendes Keks-Symbol unten rechts (44 px); „Datenschutz-Einstellungen“ ist sein Name (`aria-label`) und erscheint beim Darüberfahren und bei Tastaturfokus. Bei „Bewegung reduzieren“ dreht er sich nicht. Bis dahin: Textknopf „Datenschutz-Einstellungen“.
@@ -0,0 +1,13 @@
# Banner-Text Fassung 2026-10-17 (eigene Websites der WEBklar GbR)
Quelle: `analytics/client/core.js` (Konstanten `BANNER_TEXT_EIGEN`, `BANNER_VERSION_EIGEN`). Gilt nur, wenn das Skript-Tag `data-variant="eigen"` trägt, derzeit nur auf webklar.com. Kunden-Websites behalten die Fassung 2026-10-02 ([banner-2026-10-02.md](banner-2026-10-02.md)).
- `BANNER_VERSION_EIGEN`: 2026-10-17
## Fließtext (BANNER_TEXT_EIGEN)
Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Ihrer Zustimmung messen wir zusätzlich, von welcher Website Sie kommen, wie lange Sie bleiben, wie weit Sie scrollen und wo Sie klicken (Heatmap), mit einer täglich wechselnden Kennung. Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben. Ihre Zustimmung können Sie jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: [Datenschutz](/datenschutz-analyse.html)
## aria-label, Schaltflächen, Einstellungs-Link
Wie in der Kunden-Fassung: „Einwilligung zur Reichweitenmessung“; „Ablehnen“ · „Annehmen“ (gleich gestaltet, Ablehnen zuerst); Keks-Symbol „Datenschutz-Einstellungen“ unten rechts.
@@ -0,0 +1,84 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Datenschutz – Reichweitenmessung</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.6;color:#111;background:#fff;max-width:46rem;margin:0 auto;padding:1.5rem 1rem 16rem}
h1{font-size:1.6rem}h2{font-size:1.15rem;margin-top:2rem}
.stand{color:#555;font-size:.9rem}
ul{padding-left:1.25rem}
a{color:#0b57d0}
.box{border:2px solid #111;border-radius:10px;padding:.25rem 1rem;margin:1.5rem 0}
</style>
</head>
<body>
<p><a href="/">← Zur Startseite</a></p>
<h1>Datenschutz bei der Reichweitenmessung</h1>
<p class="stand">Ergänzung zur Datenschutzerklärung dieser Website · Stand: 02.10.2026</p>
<p>Diese Seite beschreibt nur die Reichweitenmessung. Alle übrigen Datenschutzhinweise findest Du in der Datenschutzerklärung dieser Website; Impressum und Datenschutzerklärung erreichst Du über die Startseite.</p>
<h2>Verantwortlicher</h2>
<p>{{company}}<br>{{#address}}{{address}}<br>{{/address}}E-Mail: <a href="mailto:{{email}}">{{email}}</a></p>
<p>Falls es einen Datenschutzbeauftragten gibt, findest Du seine Kontaktdaten in der Datenschutzerklärung dieser Website.</p>
<h2>Worum es geht</h2>
<p>Wir möchten verstehen, wie diese Website genutzt wird, um sie zu verbessern: Welche Seiten werden gelesen, woher kommen die Besucher, wo wird geklickt? Dafür nutzen wir eine eigene Messung ohne Cookies und ohne Dienste von Google, Meta oder anderen Werbenetzwerken. Wir nutzen die Daten nicht für Werbung und bilden keine Profile einzelner Besucher. Die Messung läuft auf einem Server in Deutschland.</p>
<p>Damit die Messung arbeiten kann, lädt Dein Browser bei jedem Seitenaufruf ein Skript von einem Server der WEBklar GbR. Dabei erhält dieser Server, wie bei jeder Datei, die eine Website lädt, technisch Deine IP-Adresse und Dein Browser-Kennzeichen (User-Agent). Das gilt auch, wenn Du ablehnst oder „Global Privacy Control“ aktiviert hast. Wir speichern und protokollieren beides nicht.</p>
<h2>Solange Du im Banner nichts ausgewählt hast</h2>
<p>Dann zählen wir den Aufruf der Seite. Das Skript übermittelt dafür nur die Adresse der Seite und die Angabe, dass das Banner angezeigt wurde. Gespeichert werden:</p>
<ul>
<li>der Seitenpfad (ohne Suchbegriffe oder Parameter hinter dem „?“),</li>
<li>das Land, das wir aus Deiner IP-Adresse ableiten,</li>
<li>die Geräteklasse (Handy, Tablet oder Computer), die wir aus dem Browser-Kennzeichen ableiten,</li>
<li>der Zeitpunkt, auf die volle Stunde gerundet, und</li>
<li>ein Zähler dafür, dass das Banner angezeigt wurde.</li>
</ul>
<p>IP-Adresse und Browser-Kennzeichen verwenden wir nur kurz im Arbeitsspeicher: um Land und Geräteklasse zu bestimmen, um automatische Abrufe durch Programme (Bots) auszusortieren und um zu verhindern, dass die Messung durch massenhafte Anfragen verfälscht wird. Für diese Mengenbegrenzung bilden wir aus Deiner IP-Adresse einen Prüfwert (Hash), den wir nur im Arbeitsspeicher halten und nicht speichern. Eine Kennung, mit der wir Dich wiedererkennen könnten, speichern wir ohne Deine Einwilligung nicht.</p>
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist, die Nutzung unserer Website in Zahlen zu kennen, das Banner zu betreiben und die Messung vor Missbrauch zu schützen.</p>
<h2>Wenn Du „Ablehnen“ wählst</h2>
<p>Dann zählen wir einmal, dass abgelehnt wurde, und das Skript sendet danach nichts mehr, auch keine Seitenaufrufe. Sendet Dein Browser das Signal „Global Privacy Control“ oder „Do Not Track“, zeigen wir das Banner gar nicht an und zählen nichts.</p>
<h2>Wenn Du „Annehmen“ wählst</h2>
<p>Dann messen wir zusätzlich:</p>
<ul>
<li>von welcher Website Du gekommen bist (nur der Domainname),</li>
<li>die Größenklasse Deines Browserfensters,</li>
<li>eine Besucher-Kennung, die täglich wechselt. Wir berechnen sie aus IP-Adresse und Browser-Kennzeichen mit einem Schlüssel, der jeden Tag neu erzeugt und nicht gespeichert wird. Am selben Tag könnten wir die Kennung damit nachrechnen. Nach dem Wechsel des Schlüssels lässt sie sich nicht mehr aus Deiner IP-Adresse berechnen und nicht mit Kennungen anderer Tage verknüpfen,</li>
<li>wie lange Du auf einer Seite bleibst (ab dem Laden der Seite) und wie weit Du scrollst,</li>
<li>wo auf der Seite geklickt wird: die Klickposition und den Aufbau des angeklickten Elements, ohne Texte und ohne Eingaben in Formulare (für eine „Heatmap“),</li>
<li>ob Du auf eine Telefonnummer, eine E-Mail-Adresse, einen WhatsApp-Link oder einen Link zu einer anderen Website klickst (dann mit deren Domainname, nicht mit der Nummer oder Adresse selbst), und</li>
<li>einen Zähler dafür, dass Du „Annehmen“ gewählt hast.</li>
</ul>
<p>Rechtsgrundlage ist Deine Einwilligung nach § 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO.</p>
<h2>Was in Deinem Browser gespeichert wird</h2>
<p>Wir speichern Deine Auswahl („Annehmen“ oder „Ablehnen“) mit Datum und der Fassung des Banner-Textes im lokalen Speicher Deines Browsers (Eintrag „wk-consent“), damit wir Dich nicht bei jedem Seitenaufruf erneut fragen. Das ist ohne Einwilligung zulässig (§ 25 Abs. 2 Nr. 2 TDDDG). Die Auswahl gilt 183 Tage, danach fragen wir erneut. Du kannst den Eintrag jederzeit selbst löschen, indem Du die Website-Daten in Deinem Browser löschst.</p>
<h2>Speicherdauer</h2>
<p>Einzelne Seitenaufrufe, Ereignisse und Klickpositionen löschen wir nach 30 Tagen. Danach bleiben nur zusammengefasste Tageswerte, also Summen je Tag, etwa Aufrufe je Seite oder je Land, ohne Besucher-Kennung. Wir löschen sie nach 25 Monaten, spätestens rund 60 Tage nach dem Ende der Messung. Unser Dienstleister sichert den Server täglich; gelöschte Daten bleiben dort bis zu sieben Tage erhalten.</p>
<h2>Empfänger</h2>
<p>Die Messung betreibt für uns die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, als Auftragsverarbeiter. WEBklar darf die Daten nur auf unsere Weisung und für unsere Zwecke verwenden. Der Server steht bei der Hetzner Online GmbH in Deutschland, die als weiterer Auftragsverarbeiter eingesetzt wird. Eine Übermittlung in Länder außerhalb der EU bzw. des EWR findet nicht statt.</p>
<h2>Freiwilligkeit</h2>
<p>Du bist weder gesetzlich noch vertraglich verpflichtet, uns diese Daten bereitzustellen. Wenn Du ablehnst oder Deine Einwilligung widerrufst, kannst Du die Website trotzdem uneingeschränkt nutzen. Eine automatisierte Entscheidungsfindung findet nicht statt.</p>
<h2>Einwilligung widerrufen</h2>
<p>Du kannst Deine Einwilligung jederzeit mit Wirkung für die Zukunft widerrufen: Klicke auf „Datenschutz-Einstellungen“ (in der Regel unten rechts auf jeder Seite) und wähle „Ablehnen“. Die Rechtmäßigkeit der Verarbeitung bis zum Widerruf bleibt unberührt.</p>
<div class="box">
<h2>Widerspruchsrecht</h2>
<p>Du kannst der Zählung nach Art. 6 Abs. 1 lit. f DSGVO jederzeit aus Gründen widersprechen, die sich aus Deiner besonderen Situation ergeben (Art. 21 DSGVO). Am einfachsten wählst Du im Banner oder über „Datenschutz-Einstellungen“ „Ablehnen“ oder aktivierst in Deinem Browser das Signal „Global Privacy Control“. Dann sendet das Skript keine Daten mehr. Du kannst Deinen Widerspruch auch an die oben genannte E-Mail-Adresse richten.</p>
</div>
<h2>Deine Rechte</h2>
<p>Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit (Art. 15 bis 18 und 20 DSGVO). In den Messdaten speichern wir keine Namen, keine IP-Adressen und keine Kennung, die länger als einen Tag gilt, und wir runden Zeitpunkte auf die volle Stunde. Deshalb können wir gespeicherte Messdaten in der Regel nicht Dir zuordnen (Art. 11 DSGVO). Anders kann es sein, solange die Zugriffsprotokolle des Webservers noch vorhanden sind (14 Tage). Wenn Du uns Datum, ungefähre Uhrzeit, besuchte Seiten und, wenn Du möchtest, Deine IP-Adresse nennst, prüfen wir, ob eine Zuordnung möglich ist. Ist das nicht möglich, teilen wir Dir das mit.</p>
<p>Mit Deinen Anliegen kannst Du Dich jederzeit an die oben genannte Adresse wenden. Außerdem hast Du das Recht, Dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO).</p>
</body>
</html>
@@ -0,0 +1,84 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Datenschutz – Reichweitenmessung</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.6;color:#111;background:#fff;max-width:46rem;margin:0 auto;padding:1.5rem 1rem 16rem}
h1{font-size:1.6rem}h2{font-size:1.15rem;margin-top:2rem}
.stand{color:#555;font-size:.9rem}
ul{padding-left:1.25rem}
a{color:#0b57d0}
.box{border:2px solid #111;border-radius:10px;padding:.25rem 1rem;margin:1.5rem 0}
</style>
</head>
<body>
<p><a href="/">← Zur Startseite</a></p>
<h1>Datenschutz bei der Reichweitenmessung</h1>
<p class="stand">Ergänzung zur Datenschutzerklärung dieser Website · Stand: 02.10.2026</p>
<p>Diese Seite beschreibt nur die Reichweitenmessung. Alle übrigen Datenschutzhinweise findest Du in der Datenschutzerklärung dieser Website; Impressum und Datenschutzerklärung erreichst Du über die Startseite.</p>
<h2>Verantwortlicher</h2>
<p>{{company}}<br>{{#address}}{{address}}<br>{{/address}}E-Mail: <a href="mailto:{{email}}">{{email}}</a></p>
<p>Falls es einen Datenschutzbeauftragten gibt, findest Du seine Kontaktdaten in der Datenschutzerklärung dieser Website.</p>
<h2>Worum es geht</h2>
<p>Wir möchten verstehen, wie diese Website genutzt wird, um sie zu verbessern: Welche Seiten werden gelesen, woher kommen die Besucher, wo wird geklickt? Dafür nutzen wir eine eigene Messung ohne Cookies und ohne Dienste von Google, Meta oder anderen Werbenetzwerken. Wir nutzen die Daten nicht für Werbung und bilden keine Profile einzelner Besucher. Die Messung läuft auf einem Server in Deutschland.</p>
<p>Damit die Messung arbeiten kann, lädt Dein Browser bei jedem Seitenaufruf ein Skript von einem Server der WEBklar GbR. Dabei erhält dieser Server, wie bei jeder Datei, die eine Website lädt, technisch Deine IP-Adresse und Dein Browser-Kennzeichen (User-Agent). Das gilt auch, wenn Du ablehnst oder „Global Privacy Control“ aktiviert hast. Wir speichern und protokollieren beides nicht.</p>
<h2>Solange Du im Banner nichts ausgewählt hast</h2>
<p>Dann zählen wir den Aufruf der Seite. Das Skript übermittelt dafür nur die Adresse der Seite und die Angabe, dass das Banner angezeigt wurde. Gespeichert werden:</p>
<ul>
<li>der Seitenpfad (ohne Suchbegriffe oder Parameter hinter dem „?“),</li>
<li>das Land, das wir aus Deiner IP-Adresse ableiten,</li>
<li>die Geräteklasse (Handy, Tablet oder Computer), die wir aus dem Browser-Kennzeichen ableiten,</li>
<li>der Zeitpunkt, auf die volle Stunde gerundet, und</li>
<li>ein Zähler dafür, dass das Banner angezeigt wurde.</li>
</ul>
<p>IP-Adresse und Browser-Kennzeichen verwenden wir nur kurz im Arbeitsspeicher: um Land und Geräteklasse zu bestimmen, um automatische Abrufe durch Programme (Bots) auszusortieren und um zu verhindern, dass die Messung durch massenhafte Anfragen verfälscht wird. Für diese Mengenbegrenzung bilden wir aus Deiner IP-Adresse einen Prüfwert (Hash), den wir nur im Arbeitsspeicher halten und nicht speichern. Eine Kennung, mit der wir Dich wiedererkennen könnten, speichern wir ohne Deine Einwilligung nicht.</p>
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist, die Nutzung unserer Website in Zahlen zu kennen, das Banner zu betreiben und die Messung vor Missbrauch zu schützen.</p>
<h2>Wenn Du „Ablehnen“ wählst</h2>
<p>Dann zählen wir einmal, dass abgelehnt wurde, und das Skript sendet danach nichts mehr, auch keine Seitenaufrufe. Sendet Dein Browser das Signal „Global Privacy Control“ oder „Do Not Track“, zeigen wir das Banner gar nicht an und zählen nichts.</p>
<h2>Wenn Du „Annehmen“ wählst</h2>
<p>Dann messen wir zusätzlich:</p>
<ul>
<li>von welcher Website Du gekommen bist (nur der Domainname),</li>
<li>die Größenklasse Deines Browserfensters,</li>
<li>eine Besucher-Kennung, die täglich wechselt. Wir berechnen sie aus IP-Adresse und Browser-Kennzeichen mit einem Schlüssel, der jeden Tag neu erzeugt und nicht gespeichert wird. Am selben Tag könnten wir die Kennung damit nachrechnen. Nach dem Wechsel des Schlüssels lässt sie sich nicht mehr aus Deiner IP-Adresse berechnen und nicht mit Kennungen anderer Tage verknüpfen,</li>
<li>wie lange Du auf einer Seite bleibst (ab dem Laden der Seite) und wie weit Du scrollst,</li>
<li>wo auf der Seite geklickt wird: die Klickposition und den Aufbau des angeklickten Elements, ohne Texte und ohne Eingaben in Formulare (für eine „Heatmap“),</li>
<li>ob Du auf eine Telefonnummer, eine E-Mail-Adresse, einen WhatsApp-Link oder einen Link zu einer anderen Website klickst (dann mit deren Domainname, nicht mit der Nummer oder Adresse selbst), und</li>
<li>einen Zähler dafür, dass Du „Annehmen“ gewählt hast.</li>
</ul>
<p>Rechtsgrundlage ist Deine Einwilligung nach § 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO.</p>
<h2>Was in Deinem Browser gespeichert wird</h2>
<p>Wir speichern Deine Auswahl („Annehmen“ oder „Ablehnen“) mit Datum und der Fassung des Banner-Textes im lokalen Speicher Deines Browsers (Eintrag „wk-consent“), damit wir Dich nicht bei jedem Seitenaufruf erneut fragen. Das ist ohne Einwilligung zulässig (§ 25 Abs. 2 Nr. 2 TDDDG). Die Auswahl gilt 183 Tage, danach fragen wir erneut. Du kannst den Eintrag jederzeit selbst löschen, indem Du die Website-Daten in Deinem Browser löschst.</p>
<h2>Speicherdauer</h2>
<p>Einzelne Seitenaufrufe, Ereignisse und Klickpositionen löschen wir nach 30 Tagen. Danach bleiben nur zusammengefasste Tageswerte, also Summen je Tag, etwa Aufrufe je Seite oder je Land, ohne Besucher-Kennung. Wir löschen sie nach 25 Monaten, spätestens rund 60 Tage nach dem Ende der Messung. Unser Dienstleister sichert den Server täglich; gelöschte Daten bleiben dort bis zu sieben Tage erhalten.</p>
<h2>Empfänger</h2>
<p>Die Messung betreibt für uns die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, als Auftragsverarbeiter. WEBklar darf die Daten nur auf unsere Weisung und für unsere Zwecke verwenden. Der Server steht bei der Hetzner Online GmbH in Deutschland, die als weiterer Auftragsverarbeiter eingesetzt wird. Eine Übermittlung in Länder außerhalb der EU bzw. des EWR findet nicht statt.</p>
<h2>Freiwilligkeit</h2>
<p>Du bist weder gesetzlich noch vertraglich verpflichtet, uns diese Daten bereitzustellen. Wenn Du ablehnst oder Deine Einwilligung widerrufst, kannst Du die Website trotzdem uneingeschränkt nutzen. Eine automatisierte Entscheidungsfindung findet nicht statt.</p>
<h2>Einwilligung widerrufen</h2>
<p>Du kannst Deine Einwilligung jederzeit mit Wirkung für die Zukunft widerrufen: Klicke auf „Datenschutz-Einstellungen“ (das Keks-Symbol, in der Regel unten rechts auf jeder Seite) und wähle „Ablehnen“. Die Rechtmäßigkeit der Verarbeitung bis zum Widerruf bleibt unberührt.</p>
<div class="box">
<h2>Widerspruchsrecht</h2>
<p>Du kannst der Zählung nach Art. 6 Abs. 1 lit. f DSGVO jederzeit aus Gründen widersprechen, die sich aus Deiner besonderen Situation ergeben (Art. 21 DSGVO). Am einfachsten wählst Du im Banner oder über „Datenschutz-Einstellungen“ (das Keks-Symbol) „Ablehnen“ oder aktivierst in Deinem Browser das Signal „Global Privacy Control“. Dann sendet das Skript keine Daten mehr. Du kannst Deinen Widerspruch auch an die oben genannte E-Mail-Adresse richten.</p>
</div>
<h2>Deine Rechte</h2>
<p>Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit (Art. 15 bis 18 und 20 DSGVO). In den Messdaten speichern wir keine Namen, keine IP-Adressen und keine Kennung, die länger als einen Tag gilt, und wir runden Zeitpunkte auf die volle Stunde. Deshalb können wir gespeicherte Messdaten in der Regel nicht Dir zuordnen (Art. 11 DSGVO). Anders kann es sein, solange die Zugriffsprotokolle des Webservers noch vorhanden sind (14 Tage). Wenn Du uns Datum, ungefähre Uhrzeit, besuchte Seiten und, wenn Du möchtest, Deine IP-Adresse nennst, prüfen wir, ob eine Zuordnung möglich ist. Ist das nicht möglich, teilen wir Dir das mit.</p>
<p>Mit Deinen Anliegen kannst Du Dich jederzeit an die oben genannte Adresse wenden. Außerdem hast Du das Recht, Dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO).</p>
</body>
</html>
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Datenschutz bei der Reichweitenmessung – WEBklar</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 12rem; }
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
p, li { margin: 6px 0; }
ul { padding-left: 24px; }
.meta { color: #6b6b70; font-size: 14px; }
.box { background: #fff; border: 2px solid #6d1fd1; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
a { color: #6d1fd1; }
nav.top { font-size: 14px; margin-bottom: 22px; }
</style>
</head>
<body>
<main>
<nav class="top"><a href="/">← Zur Startseite</a> · <a href="https://project.webklar.com/datenschutz.html">Datenschutzerklärung</a> · <a href="/impressum">Impressum</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Datenschutz bei der Reichweitenmessung</h1>
<p class="meta">Ergänzung zu unserer Datenschutzerklärung (Abschnitt 4a) · gilt für webklar.com · Stand 17.10.2026</p>
<p>Diese Seite beschreibt nur die Reichweitenmessung auf webklar.com. Alle übrigen Hinweise finden Sie in unserer <a href="https://project.webklar.com/datenschutz.html">Datenschutzerklärung</a>.</p>
<h2>Verantwortlicher</h2>
<p>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler<br>
E-Mail: <a href="mailto:support@webklar.com">support@webklar.com</a></p>
<p>Einen Datenschutzbeauftragten haben wir nicht benannt, weil wir dazu gesetzlich nicht verpflichtet sind.</p>
<h2>Worum es geht</h2>
<p>Wir möchten verstehen, wie unsere Website genutzt wird, um sie zu verbessern: Welche Seiten werden gelesen, woher kommen die Besucher, wo wird geklickt? Dafür nutzen wir unsere eigene Website-Analyse, dieselbe, die wir auch unseren Kunden anbieten. Sie kommt ohne Cookies und ohne Dienste von Google, Meta oder anderen Werbenetzwerken aus. Wir nutzen die Daten nicht für Werbung und bilden keine Profile einzelner Besucher. Die Messung läuft auf unserem Server in Deutschland.</p>
<p>Damit die Messung arbeiten kann, lädt Ihr Browser bei jedem Seitenaufruf ein Skript von unserem Server (project.webklar.com). Dabei erhält dieser Server, wie bei jeder Datei, die eine Website lädt, technisch Ihre IP-Adresse und Ihr Browser-Kennzeichen (User-Agent). Das gilt auch, wenn Sie ablehnen oder „Global Privacy Control“ aktiviert haben. Beim Abruf des Skripts und bei der Messung speichern und protokollieren wir beides nicht.</p>
<h2>Solange Sie im Banner nichts ausgewählt haben</h2>
<p>Dann zählen wir den Aufruf der Seite. Das Skript übermittelt dafür nur die Adresse der Seite und die Angabe, dass das Banner angezeigt wurde. Gespeichert werden:</p>
<ul>
<li>der Seitenpfad (ohne Suchbegriffe oder Parameter hinter dem „?“),</li>
<li>das Land, das wir aus Ihrer IP-Adresse ableiten,</li>
<li>die Geräteklasse (Handy, Tablet oder Computer), die wir aus dem Browser-Kennzeichen ableiten,</li>
<li>der Zeitpunkt, auf die volle Stunde gerundet, und</li>
<li>ein Zähler dafür, dass das Banner angezeigt wurde.</li>
</ul>
<p>IP-Adresse und Browser-Kennzeichen verwenden wir nur kurz im Arbeitsspeicher: um Land und Geräteklasse zu bestimmen, um automatische Abrufe durch Programme (Bots) auszusortieren und um zu verhindern, dass die Messung durch massenhafte Anfragen verfälscht wird. Für diese Mengenbegrenzung bilden wir aus Ihrer IP-Adresse einen Prüfwert (Hash), den wir nur im Arbeitsspeicher halten und nicht speichern. Eine Kennung, mit der wir Sie wiedererkennen könnten, speichern wir ohne Ihre Einwilligung nicht.</p>
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist, die Nutzung unserer Website in Zahlen zu kennen, das Banner zu betreiben und die Messung vor Missbrauch zu schützen.</p>
<h2>Wenn Sie „Ablehnen“ wählen</h2>
<p>Dann zählen wir einmal, dass abgelehnt wurde, und das Skript sendet danach nichts mehr, auch keine Seitenaufrufe. Sendet Ihr Browser das Signal „Global Privacy Control“ oder „Do Not Track“, zeigen wir das Banner gar nicht an und zählen nichts.</p>
<h2>Wenn Sie „Annehmen“ wählen</h2>
<p>Dann messen wir zusätzlich:</p>
<ul>
<li>von welcher Website Sie gekommen sind (nur der Domainname),</li>
<li>die Größenklasse Ihres Browserfensters,</li>
<li>eine Besucher-Kennung, die täglich wechselt. Wir berechnen sie aus IP-Adresse und Browser-Kennzeichen mit einem Schlüssel, der jeden Tag neu erzeugt und nicht gespeichert wird. Am selben Tag könnten wir die Kennung damit nachrechnen. Nach dem Wechsel des Schlüssels lässt sie sich nicht mehr aus Ihrer IP-Adresse berechnen und nicht mit Kennungen anderer Tage verknüpfen,</li>
<li>wie lange Sie auf einer Seite bleiben (ab dem Laden der Seite) und wie weit Sie scrollen,</li>
<li>wo auf der Seite geklickt wird: die Klickposition und den Aufbau des angeklickten Elements, ohne Texte und ohne Eingaben in Formulare (für eine „Heatmap“),</li>
<li>ob Sie auf eine Telefonnummer, eine E-Mail-Adresse, einen WhatsApp-Link oder einen Link zu einer anderen Website klicken (dann mit deren Domainname, nicht mit der Nummer oder Adresse selbst), und</li>
<li>einen Zähler dafür, dass Sie „Annehmen“ gewählt haben.</li>
</ul>
<p>Rechtsgrundlage ist Ihre Einwilligung nach § 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO.</p>
<h2>Was in Ihrem Browser gespeichert wird</h2>
<p>Wir speichern Ihre Auswahl („Annehmen“ oder „Ablehnen“) mit Datum und der Fassung des Banner-Textes im lokalen Speicher Ihres Browsers (Eintrag „wk-consent“), damit wir Sie nicht bei jedem Seitenaufruf erneut fragen. Das ist ohne Einwilligung zulässig (§ 25 Abs. 2 Nr. 2 TDDDG). Die Auswahl gilt 183 Tage, danach fragen wir erneut. Sie können den Eintrag jederzeit selbst löschen, indem Sie die Website-Daten in Ihrem Browser löschen.</p>
<h2>Speicherdauer</h2>
<p>Einzelne Seitenaufrufe, Ereignisse und Klickpositionen löschen wir nach 30 Tagen. Danach bleiben nur zusammengefasste Tageswerte, also Summen je Tag, etwa Aufrufe je Seite oder je Land, ohne Besucher-Kennung. Wir löschen sie nach 25 Monaten. Beenden wir die Messung, löschen wir alle Messdaten spätestens rund 60 Tage danach. Unser Dienstleister sichert den Server täglich; gelöschte Daten bleiben dort bis zu sieben Tage erhalten.</p>
<h2>Empfänger</h2>
<p>Die Messung betreiben wir selbst. Die Daten sehen nur unsere Mitarbeiter. Der Server steht bei der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, in Deutschland; Hetzner ist für uns als Auftragsverarbeiter tätig. Eine Übermittlung in Länder außerhalb der EU bzw. des EWR findet nicht statt.</p>
<h2>Freiwilligkeit</h2>
<p>Sie sind weder gesetzlich noch vertraglich verpflichtet, uns diese Daten bereitzustellen. Wenn Sie ablehnen oder Ihre Einwilligung widerrufen, können Sie unsere Website trotzdem uneingeschränkt nutzen. Eine automatisierte Entscheidungsfindung findet nicht statt.</p>
<div class="box">
<h2 style="border:0;margin-top:0;padding-top:0">Einwilligung widerrufen</h2>
<p>Sie können Ihre Einwilligung jederzeit mit Wirkung für die Zukunft widerrufen: Klicken Sie auf „Datenschutz-Einstellungen“ (das Keks-Symbol unten rechts auf jeder Seite) und wählen Sie „Ablehnen“. Die Rechtmäßigkeit der Verarbeitung bis zum Widerruf bleibt unberührt.</p>
<h2 style="border:0;padding-top:0">Widerspruchsrecht</h2>
<p>Sie können der Zählung nach Art. 6 Abs. 1 lit. f DSGVO jederzeit aus Gründen widersprechen, die sich aus Ihrer besonderen Situation ergeben (Art. 21 DSGVO). Am einfachsten wählen Sie im Banner oder über „Datenschutz-Einstellungen“ (das Keks-Symbol) „Ablehnen“ oder aktivieren in Ihrem Browser das Signal „Global Privacy Control“. Dann sendet das Skript keine Daten mehr. Sie können Ihren Widerspruch auch an die oben genannte E-Mail-Adresse richten.</p>
</div>
<h2>Ihre Rechte</h2>
<p>Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit (Art. 15 bis 18 und 20 DSGVO). In den Messdaten speichern wir keine Namen, keine IP-Adressen und keine Kennung, die länger als einen Tag gilt, und wir runden Zeitpunkte auf die volle Stunde. Deshalb können wir gespeicherte Messdaten in der Regel nicht Ihnen zuordnen (Art. 11 DSGVO). Anders kann es sein, solange die Zugriffsprotokolle unseres Webservers noch vorhanden sind (Abschnitt 3 der Datenschutzerklärung). Wenn Sie uns Datum, ungefähre Uhrzeit, besuchte Seiten und, wenn Sie möchten, Ihre IP-Adresse nennen, prüfen wir, ob eine Zuordnung möglich ist. Ist das nicht möglich, teilen wir Ihnen das mit.</p>
<p>Mit Ihren Anliegen können Sie sich jederzeit an die oben genannte Adresse wenden. Außerdem haben Sie das Recht, sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO), für uns zuständig ist der Landesbeauftragte für den Datenschutz und die Informationsfreiheit Rheinland-Pfalz.</p>
</main>
</body>
</html>
@@ -0,0 +1,141 @@
# Gästetexte des Terminplaners, Fassung 2026-10-02
Dieses Archiv hält den Wortlaut der Gästetexte fest, die ein Gast in dieser Fassung sieht (AVV Terminplaner Nr. 8 Abs. 5). Der Nachweis einer Buchung speichert die Fassung, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes (SHA-256) der übrigen Texte, nicht die Texte selbst.
Fassung der Gästetexte: **2026-10-02** (`RECHTSTEXTE_VERSION` in `server/services/termine/rechtstexte.js`). Der Zeitraum der Verwendung ergibt sich aus „gültig ab“ dieser Fassung und der nächsten Fassung in der Tabelle „Terminplaner-Gästetexte“ in `README.md` dieses Ordners.
Erzeugt mit `node scripts/termine-texte-archiv.mjs`, geprüft mit `--check`. Nicht von Hand ändern: eine Textänderung bekommt eine neue Fassung und eine neue Datei; diese Datei bleibt, wie sie war.
Die Betriebsangaben stehen als Platzhalter: `{{Name des Betriebs}}`, `{{Anschrift}}`, `{{Telefon}}` und `{{E-Mail}}` stammen aus der Einstellung des Betriebs, `{{Stunden}}` ist die gewählte Vorlaufzeit der Erinnerung (der Nachweis speichert sie), die Aufbewahrung nach dem Termin ist mit der Vorgabe von 90 Tagen dargestellt (einstellbar von 14 bis 365 Tagen).
## 1. Hinweis unter dem Buchungsformular (Datenschutz)
### Sie-Fassung
> Datenschutz. Wir verwenden Ihre Angaben, um Ihren Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). Betrieben wird der Terminkalender in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland). Wir speichern Ihre Daten bis 90 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist. Es werden keine Cookies gesetzt. Bitte machen Sie keine Angaben zu Ihrer Gesundheit. Mehr erfahren Sie in unserer Datenschutzerklärung. Verantwortlich: {{Name des Betriebs}}, {{Anschrift}}.
### Du-Fassung
> Datenschutz. Wir verwenden deine Angaben, um deinen Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). Betrieben wird der Terminkalender in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland). Wir speichern deine Daten bis 90 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist. Es werden keine Cookies gesetzt. Bitte mach keine Angaben zu deiner Gesundheit. Mehr erfährst du in unserer Datenschutzerklärung. Verantwortlich: {{Name des Betriebs}}, {{Anschrift}}.
## 2. Haken
Die Haken stehen immer in der Sie-Form, auch bei Betrieben, die ihre Gäste duzen.
### Vorzeitiger Beginn (Pflicht bei einer Leistung mit Preis für Verbraucher, wenn der Termin vor Ablauf der Widerrufsfrist beginnt, bei Terminanfragen immer)
> Ich verlange ausdrücklich, dass {{Name des Betriebs}} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).
### Stornobedingungen (Pflicht, wenn der Betrieb Stornobedingungen hat; der Text der Bedingungen steht beim Betrieb und trägt eine eigene Version)
> Ich habe die Stornobedingungen gelesen.
### Erinnerung (freiwillig, nie vorangekreuzt, nur wenn der Betrieb Erinnerungen anbietet)
> Ich möchte {{Stunden}} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.
## 3. Hinweis zu Gesundheitsangaben
Steht über dem Nachrichtenfeld des Formulars.
> Bitte keine Gesundheitsangaben.
## 4. Knopftexte
| Leistung | Knopf im Widget | Knopf auf der Bestätigungsseite |
|---|---|---|
| Sofort buchbar, ohne Preis | Termin buchen | Termin bestätigen |
| Sofort buchbar, mit Preis | Zahlungspflichtig buchen | Zahlungspflichtig bestätigen |
| Terminanfrage | Termin anfragen | Anfrage absenden |
Der Text des Buchungsknopfs im Widget steht im Nachweis (`knopf`).
## 5. Widerrufsbelehrung mit Muster-Widerrufsformular für Gäste
Sie-Form, mit den Betriebsangaben als Platzhalter.
> Widerrufsbelehrung
>
> Widerrufsrecht
>
> Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.
>
> Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.
>
> Um Ihr Widerrufsrecht auszuüben, müssen Sie uns ({{Name des Betriebs}}, {{Anschrift}}, Telefon: {{Telefon}}, E-Mail: {{E-Mail}}) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.
>
> Sie können Ihr Widerrufsrecht auch online unter „Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail) ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.
>
> Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.
>
> Folgen des Widerrufs
>
> Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.
>
> Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.
>
> Muster-Widerrufsformular
>
> (Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)
>
> – An {{Name des Betriebs}}, {{Anschrift}}, {{E-Mail}}:
> – Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)
> – Bestellt am (*)/erhalten am (*)
> – Name des/der Verbraucher(s)
> – Anschrift des/der Verbraucher(s)
> – Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)
> – Datum
>
> (*) Unzutreffendes streichen.
## 6. Datenschutz-Absatz „Online-Terminbuchung“ für die Datenschutzerklärung der Website des Betriebs
Hier mit allen bedingten Sätzen (Telefon-Feld, Nachricht oder Zusatzfelder, Erinnerung, Widerruf, Telefonnummer des Betriebs); ein Betrieb ohne die jeweilige Funktion hat den Satz nicht.
### Sie-Fassung
> Online-Terminbuchung
>
> Auf unserer Website können Sie Termine online buchen. Dafür verarbeiten wir die folgenden Daten:
>
> Welche Daten? Ihren Namen, Ihre E-Mail-Adresse, den gewählten Termin, die gewählte Leistung und die gewählte Person oder den gewählten Platz, auf Wunsch Ihre Telefonnummer und die Angaben, die Sie uns im Formular mitteilen. Außerdem speichern wir, wann Sie den Termin bestätigt haben, welche Fassung der Hinweise Sie bei der Buchung gesehen haben und welche Zustimmungen Sie erteilt haben, zum Beispiel ob Sie eine Erinnerung gewünscht haben.
>
> Wofür und auf welcher Grundlage? Wir brauchen die Daten, um Ihren Termin zu verwalten, Ihnen den Termin zu bestätigen und Sie über Änderungen oder Absagen zu informieren. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag über den gebuchten Termin, bei einer Terminanfrage vorvertragliche Maßnahmen auf Ihre Anfrage). Wenn Sie eine Erinnerung vor dem Termin wünschen, beruht diese auf Ihrer Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. Sie können sie jederzeit mit Wirkung für die Zukunft widerrufen, indem Sie uns eine kurze Nachricht senden oder den Termin über den Link in der E-Mail absagen. Widerrufen Sie einen Vertrag über die Schaltfläche „Vertrag widerrufen“, speichern wir Ihre Erklärung mit Datum und Uhrzeit und senden Ihnen eine Eingangsbestätigung (Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB). Zum Schutz vor Missbrauch (zum Beispiel automatisierten Buchungen) und um die Zahl der Anfragen zu begrenzen, merken wir uns Ihre IP-Adresse kurzzeitig nur im Arbeitsspeicher des Servers, nicht dauerhaft (Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere Betrieb der Terminbuchung).
>
> E-Mails. Sie erhalten eine E-Mail, in der Sie Ihre E-Mail-Adresse und den Termin per Link bestätigen, danach eine Bestätigung und, wenn Sie das angekreuzt haben, eine Erinnerung. Sagen wir den Termin ab oder verschieben ihn, informieren wir Sie ebenfalls per E-Mail. Die E-Mails enthalten keine Werbung und keine Tracking-Pixel. Absender ist {{Name des Betriebs}}, Ihre Antworten gehen an uns. Bis zum Versand liegt die E-Mail in einer Warteschlange; nach dem Versand wird dort die Empfängeradresse samt Inhalt gelöscht, nicht zustellbare E-Mails nach 48 Stunden.
>
> Wer verarbeitet die Daten in unserem Auftrag? Den Terminkalender betreibt die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, als unser Auftragsverarbeiter nach Art. 28 DSGVO. Die Daten liegen auf Servern der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, in Deutschland (Rechenzentren Falkenstein und Nürnberg). Der E-Mail-Versand läuft ebenfalls über Hetzner. Eine Übermittlung in Drittländer findet nicht statt.
>
> Beim Aufruf der Terminbuchung. Beim Aufruf dieser Seite lädt Ihr Browser das Programm der Terminbuchung vom Server der WEBklar GbR (project.webklar.com), beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei werden technisch bedingt Ihre IP-Adresse und Angaben zu Ihrem Browser übertragen. Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach 14 Tagen (Art. 6 Abs. 1 lit. f DSGVO; berechtigtes Interesse ist der sichere und funktionsfähige Betrieb). Wir setzen dafür keine Cookies und speichern nichts auf Ihrem Gerät.
>
> Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir 48 Stunden nach Ablauf dieser Frist. Bestätigte Termine löschen wir 90 Tage nach dem Termin, abgesagte oder abgelehnte Termine 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. Wird die Online-Terminbuchung abgeschaltet, löschen wir alle Termindaten 30 Tage nach dem Abschalten. Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.
>
> Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben. Sie können uns auch telefonisch erreichen: {{Telefon}}. Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte machen Sie bei der Buchung keine Angaben zu Ihrer Gesundheit.
>
> Ihre Rechte. Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit sowie das Recht, der Verarbeitung auf Grundlage berechtigter Interessen zu widersprechen (Art. 15 bis 21 DSGVO). Eine erteilte Einwilligung können Sie jederzeit widerrufen. Sie können sich bei einer Datenschutzaufsichtsbehörde beschweren. Verantwortlich ist {{Name des Betriebs}}, {{Anschrift}}, E-Mail {{E-Mail}}.
### Du-Fassung
> Online-Terminbuchung
>
> Auf unserer Website kannst du Termine online buchen. Dafür verarbeiten wir die folgenden Daten:
>
> Welche Daten? Deinen Namen, deine E-Mail-Adresse, den gewählten Termin, die gewählte Leistung und die gewählte Person oder den gewählten Platz, auf Wunsch deine Telefonnummer und die Angaben, die du uns im Formular mitteilst. Außerdem speichern wir, wann du den Termin bestätigt hast, welche Fassung der Hinweise du bei der Buchung gesehen hast und welche Zustimmungen du erteilt hast, zum Beispiel ob du eine Erinnerung gewünscht hast.
>
> Wofür und auf welcher Grundlage? Wir brauchen die Daten, um deinen Termin zu verwalten, dir den Termin zu bestätigen und dich über Änderungen oder Absagen zu informieren. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag über den gebuchten Termin, bei einer Terminanfrage vorvertragliche Maßnahmen auf deine Anfrage). Wenn du eine Erinnerung vor dem Termin wünschst, beruht diese auf deiner Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. Du kannst sie jederzeit mit Wirkung für die Zukunft widerrufen, indem du uns eine kurze Nachricht sendest oder den Termin über den Link in der E-Mail absagst. Widerrufst du einen Vertrag über die Schaltfläche „Vertrag widerrufen“, speichern wir deine Erklärung mit Datum und Uhrzeit und senden dir eine Eingangsbestätigung (Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB). Zum Schutz vor Missbrauch (zum Beispiel automatisierten Buchungen) und um die Zahl der Anfragen zu begrenzen, merken wir uns deine IP-Adresse kurzzeitig nur im Arbeitsspeicher des Servers, nicht dauerhaft (Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere Betrieb der Terminbuchung).
>
> E-Mails. Du erhältst eine E-Mail, in der du deine E-Mail-Adresse und den Termin per Link bestätigst, danach eine Bestätigung und, wenn du das angekreuzt hast, eine Erinnerung. Sagen wir den Termin ab oder verschieben ihn, informieren wir dich ebenfalls per E-Mail. Die E-Mails enthalten keine Werbung und keine Tracking-Pixel. Absender ist {{Name des Betriebs}}, deine Antworten gehen an uns. Bis zum Versand liegt die E-Mail in einer Warteschlange; nach dem Versand wird dort die Empfängeradresse samt Inhalt gelöscht, nicht zustellbare E-Mails nach 48 Stunden.
>
> Wer verarbeitet die Daten in unserem Auftrag? Den Terminkalender betreibt die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, als unser Auftragsverarbeiter nach Art. 28 DSGVO. Die Daten liegen auf Servern der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, in Deutschland (Rechenzentren Falkenstein und Nürnberg). Der E-Mail-Versand läuft ebenfalls über Hetzner. Eine Übermittlung in Drittländer findet nicht statt.
>
> Beim Aufruf der Terminbuchung. Beim Aufruf dieser Seite lädt dein Browser das Programm der Terminbuchung vom Server der WEBklar GbR (project.webklar.com), beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei werden technisch bedingt deine IP-Adresse und Angaben zu deinem Browser übertragen. Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach 14 Tagen (Art. 6 Abs. 1 lit. f DSGVO; berechtigtes Interesse ist der sichere und funktionsfähige Betrieb). Wir setzen dafür keine Cookies und speichern nichts auf deinem Gerät.
>
> Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir 48 Stunden nach Ablauf dieser Frist. Bestätigte Termine löschen wir 90 Tage nach dem Termin, abgesagte oder abgelehnte Termine 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. Wird die Online-Terminbuchung abgeschaltet, löschen wir alle Termindaten 30 Tage nach dem Abschalten. Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.
>
> Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben. Du kannst uns auch telefonisch erreichen: {{Telefon}}. Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte mach bei der Buchung keine Angaben zu deiner Gesundheit.
>
> Deine Rechte. Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit sowie das Recht, der Verarbeitung auf Grundlage berechtigter Interessen zu widersprechen (Art. 15 bis 21 DSGVO). Eine erteilte Einwilligung kannst du jederzeit widerrufen. Du kannst dich bei einer Datenschutzaufsichtsbehörde beschweren. Verantwortlich ist {{Name des Betriebs}}, {{Anschrift}}, E-Mail {{E-Mail}}.
## 7. Nachweis
Der Nachweis speichert Hashes der Texte mit den Betriebsangaben zum Buchungszeitpunkt (⚖ Q26).
@@ -0,0 +1,141 @@
# Gästetexte des Terminplaners, Fassung 2026-10-05
Dieses Archiv hält den Wortlaut der Gästetexte fest, die ein Gast in dieser Fassung sieht (AVV Terminplaner Nr. 8 Abs. 5). Der Nachweis einer Buchung speichert die Fassung, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes (SHA-256) der übrigen Texte, nicht die Texte selbst.
Fassung der Gästetexte: **2026-10-05** (`RECHTSTEXTE_VERSION` in `server/services/termine/rechtstexte.js`). Der Zeitraum der Verwendung ergibt sich aus „gültig ab“ dieser Fassung und der nächsten Fassung in der Tabelle „Terminplaner-Gästetexte“ in `README.md` dieses Ordners.
Erzeugt mit `node scripts/termine-texte-archiv.mjs`, geprüft mit `--check`. Nicht von Hand ändern: eine Textänderung bekommt eine neue Fassung und eine neue Datei; diese Datei bleibt, wie sie war.
Die Betriebsangaben stehen als Platzhalter: `{{Name des Betriebs}}`, `{{Anschrift}}`, `{{Telefon}}` und `{{E-Mail}}` stammen aus der Einstellung des Betriebs, `{{Stunden}}` ist die gewählte Vorlaufzeit der Erinnerung (der Nachweis speichert sie), die Aufbewahrung nach dem Termin ist mit der Vorgabe von 90 Tagen dargestellt (einstellbar von 14 bis 365 Tagen).
## 1. Hinweis unter dem Buchungsformular (Datenschutz)
### Sie-Fassung
> Datenschutz. Wir verwenden Ihre Angaben, um Ihren Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). Betrieben wird der Terminkalender in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland). Wir speichern Ihre Daten bis 90 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist. Es werden keine Cookies gesetzt. Bitte machen Sie keine Angaben zu Ihrer Gesundheit. Mehr erfahren Sie in unserer Datenschutzerklärung. Verantwortlich: {{Name des Betriebs}}, {{Anschrift}}.
### Du-Fassung
> Datenschutz. Wir verwenden deine Angaben, um deinen Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). Betrieben wird der Terminkalender in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland). Wir speichern deine Daten bis 90 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist. Es werden keine Cookies gesetzt. Bitte mach keine Angaben zu deiner Gesundheit. Mehr erfährst du in unserer Datenschutzerklärung. Verantwortlich: {{Name des Betriebs}}, {{Anschrift}}.
## 2. Haken
Die Haken stehen immer in der Sie-Form, auch bei Betrieben, die ihre Gäste duzen.
### Vorzeitiger Beginn (Pflicht bei einer Leistung mit Preis für Verbraucher, wenn der Termin vor Ablauf der Widerrufsfrist beginnt, bei Terminanfragen immer)
> Ich verlange ausdrücklich, dass {{Name des Betriebs}} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).
### Stornobedingungen (Pflicht bei einer Leistung mit Preis, wenn der Betrieb Stornobedingungen hat; bei kostenlosen Leistungen fragt der Terminplaner nicht danach; der Text der Bedingungen steht beim Betrieb und trägt eine eigene Version)
> Ich habe die Stornobedingungen gelesen.
### Erinnerung (freiwillig, nie vorangekreuzt, nur wenn der Betrieb Erinnerungen anbietet)
> Ich möchte {{Stunden}} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.
## 3. Hinweis zu Gesundheitsangaben
Steht über dem Nachrichtenfeld des Formulars.
> Bitte keine Gesundheitsangaben.
## 4. Knopftexte
| Leistung | Knopf im Widget | Knopf auf der Bestätigungsseite |
|---|---|---|
| Sofort buchbar, ohne Preis | Termin buchen | Termin bestätigen |
| Sofort buchbar, mit Preis | Zahlungspflichtig buchen | Zahlungspflichtig bestätigen |
| Terminanfrage | Termin anfragen | Anfrage absenden |
Der Text des Buchungsknopfs im Widget steht im Nachweis (`knopf`).
## 5. Widerrufsbelehrung mit Muster-Widerrufsformular für Gäste
Sie-Form, mit den Betriebsangaben als Platzhalter.
> Widerrufsbelehrung
>
> Widerrufsrecht
>
> Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.
>
> Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.
>
> Um Ihr Widerrufsrecht auszuüben, müssen Sie uns ({{Name des Betriebs}}, {{Anschrift}}, Telefon: {{Telefon}}, E-Mail: {{E-Mail}}) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.
>
> Sie können Ihr Widerrufsrecht auch online unter „Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail) ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.
>
> Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.
>
> Folgen des Widerrufs
>
> Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.
>
> Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.
>
> Muster-Widerrufsformular
>
> (Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)
>
> – An {{Name des Betriebs}}, {{Anschrift}}, {{E-Mail}}:
> – Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)
> – Bestellt am (*)/erhalten am (*)
> – Name des/der Verbraucher(s)
> – Anschrift des/der Verbraucher(s)
> – Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)
> – Datum
>
> (*) Unzutreffendes streichen.
## 6. Datenschutz-Absatz „Online-Terminbuchung“ für die Datenschutzerklärung der Website des Betriebs
Hier mit allen bedingten Sätzen (Telefon-Feld, Nachricht oder Zusatzfelder, Erinnerung, Widerruf, Telefonnummer des Betriebs); ein Betrieb ohne die jeweilige Funktion hat den Satz nicht.
### Sie-Fassung
> Online-Terminbuchung
>
> Auf unserer Website können Sie Termine online buchen. Dafür verarbeiten wir die folgenden Daten:
>
> Welche Daten? Ihren Namen, Ihre E-Mail-Adresse, den gewählten Termin, die gewählte Leistung und die gewählte Person oder den gewählten Platz, auf Wunsch Ihre Telefonnummer und die Angaben, die Sie uns im Formular mitteilen. Außerdem speichern wir, wann Sie den Termin bestätigt haben, welche Fassung der Hinweise Sie bei der Buchung gesehen haben und welche Zustimmungen Sie erteilt haben, zum Beispiel ob Sie eine Erinnerung gewünscht haben.
>
> Wofür und auf welcher Grundlage? Wir brauchen die Daten, um Ihren Termin zu verwalten, Ihnen den Termin zu bestätigen und Sie über Änderungen oder Absagen zu informieren. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag über den gebuchten Termin, bei einer Terminanfrage vorvertragliche Maßnahmen auf Ihre Anfrage). Wenn Sie eine Erinnerung vor dem Termin wünschen, beruht diese auf Ihrer Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. Sie können sie jederzeit mit Wirkung für die Zukunft widerrufen, indem Sie uns eine kurze Nachricht senden oder den Termin über den Link in der E-Mail absagen. Widerrufen Sie einen Vertrag über die Schaltfläche „Vertrag widerrufen“, speichern wir Ihre Erklärung mit Datum und Uhrzeit und senden Ihnen eine Eingangsbestätigung (Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB). Zum Schutz vor Missbrauch (zum Beispiel automatisierten Buchungen) und um die Zahl der Anfragen zu begrenzen, merken wir uns Ihre IP-Adresse kurzzeitig nur im Arbeitsspeicher des Servers, nicht dauerhaft (Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere Betrieb der Terminbuchung).
>
> E-Mails. Sie erhalten eine E-Mail, in der Sie Ihre E-Mail-Adresse und den Termin per Link bestätigen, danach eine Bestätigung und, wenn Sie das angekreuzt haben, eine Erinnerung. Sagen wir den Termin ab oder verschieben ihn, informieren wir Sie ebenfalls per E-Mail. Die E-Mails enthalten keine Werbung und keine Tracking-Pixel. Absender ist {{Name des Betriebs}}, Ihre Antworten gehen an uns. Bis zum Versand liegt die E-Mail in einer Warteschlange; nach dem Versand wird dort die Empfängeradresse samt Inhalt gelöscht, nicht zustellbare E-Mails nach 48 Stunden.
>
> Wer verarbeitet die Daten in unserem Auftrag? Den Terminkalender betreibt die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, als unser Auftragsverarbeiter nach Art. 28 DSGVO. Die Daten liegen auf Servern der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, in Deutschland (Rechenzentren Falkenstein und Nürnberg). Der E-Mail-Versand läuft ebenfalls über Hetzner. Eine Übermittlung in Drittländer findet nicht statt.
>
> Beim Aufruf der Terminbuchung. Beim Aufruf dieser Seite lädt Ihr Browser das Programm der Terminbuchung vom Server der WEBklar GbR (project.webklar.com), beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei werden technisch bedingt Ihre IP-Adresse und Angaben zu Ihrem Browser übertragen. Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach 14 Tagen (Art. 6 Abs. 1 lit. f DSGVO; berechtigtes Interesse ist der sichere und funktionsfähige Betrieb). Wir setzen dafür keine Cookies und speichern nichts auf Ihrem Gerät.
>
> Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir 48 Stunden nach Ablauf dieser Frist. Bestätigte Termine löschen wir 90 Tage nach dem Termin, abgesagte oder abgelehnte Termine 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht. Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.
>
> Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben. Sie können uns auch telefonisch erreichen: {{Telefon}}. Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte machen Sie bei der Buchung keine Angaben zu Ihrer Gesundheit.
>
> Ihre Rechte. Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit sowie das Recht, der Verarbeitung auf Grundlage berechtigter Interessen zu widersprechen (Art. 15 bis 21 DSGVO). Eine erteilte Einwilligung können Sie jederzeit widerrufen. Sie können sich bei einer Datenschutzaufsichtsbehörde beschweren. Verantwortlich ist {{Name des Betriebs}}, {{Anschrift}}, E-Mail {{E-Mail}}.
### Du-Fassung
> Online-Terminbuchung
>
> Auf unserer Website kannst du Termine online buchen. Dafür verarbeiten wir die folgenden Daten:
>
> Welche Daten? Deinen Namen, deine E-Mail-Adresse, den gewählten Termin, die gewählte Leistung und die gewählte Person oder den gewählten Platz, auf Wunsch deine Telefonnummer und die Angaben, die du uns im Formular mitteilst. Außerdem speichern wir, wann du den Termin bestätigt hast, welche Fassung der Hinweise du bei der Buchung gesehen hast und welche Zustimmungen du erteilt hast, zum Beispiel ob du eine Erinnerung gewünscht hast.
>
> Wofür und auf welcher Grundlage? Wir brauchen die Daten, um deinen Termin zu verwalten, dir den Termin zu bestätigen und dich über Änderungen oder Absagen zu informieren. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag über den gebuchten Termin, bei einer Terminanfrage vorvertragliche Maßnahmen auf deine Anfrage). Wenn du eine Erinnerung vor dem Termin wünschst, beruht diese auf deiner Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. Du kannst sie jederzeit mit Wirkung für die Zukunft widerrufen, indem du uns eine kurze Nachricht sendest oder den Termin über den Link in der E-Mail absagst. Widerrufst du einen Vertrag über die Schaltfläche „Vertrag widerrufen“, speichern wir deine Erklärung mit Datum und Uhrzeit und senden dir eine Eingangsbestätigung (Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB). Zum Schutz vor Missbrauch (zum Beispiel automatisierten Buchungen) und um die Zahl der Anfragen zu begrenzen, merken wir uns deine IP-Adresse kurzzeitig nur im Arbeitsspeicher des Servers, nicht dauerhaft (Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere Betrieb der Terminbuchung).
>
> E-Mails. Du erhältst eine E-Mail, in der du deine E-Mail-Adresse und den Termin per Link bestätigst, danach eine Bestätigung und, wenn du das angekreuzt hast, eine Erinnerung. Sagen wir den Termin ab oder verschieben ihn, informieren wir dich ebenfalls per E-Mail. Die E-Mails enthalten keine Werbung und keine Tracking-Pixel. Absender ist {{Name des Betriebs}}, deine Antworten gehen an uns. Bis zum Versand liegt die E-Mail in einer Warteschlange; nach dem Versand wird dort die Empfängeradresse samt Inhalt gelöscht, nicht zustellbare E-Mails nach 48 Stunden.
>
> Wer verarbeitet die Daten in unserem Auftrag? Den Terminkalender betreibt die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, als unser Auftragsverarbeiter nach Art. 28 DSGVO. Die Daten liegen auf Servern der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, in Deutschland (Rechenzentren Falkenstein und Nürnberg). Der E-Mail-Versand läuft ebenfalls über Hetzner. Eine Übermittlung in Drittländer findet nicht statt.
>
> Beim Aufruf der Terminbuchung. Beim Aufruf dieser Seite lädt dein Browser das Programm der Terminbuchung vom Server der WEBklar GbR (project.webklar.com), beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei werden technisch bedingt deine IP-Adresse und Angaben zu deinem Browser übertragen. Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach 14 Tagen (Art. 6 Abs. 1 lit. f DSGVO; berechtigtes Interesse ist der sichere und funktionsfähige Betrieb). Wir setzen dafür keine Cookies und speichern nichts auf deinem Gerät.
>
> Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir 48 Stunden nach Ablauf dieser Frist. Bestätigte Termine löschen wir 90 Tage nach dem Termin, abgesagte oder abgelehnte Termine 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht. Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.
>
> Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben. Du kannst uns auch telefonisch erreichen: {{Telefon}}. Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte mach bei der Buchung keine Angaben zu deiner Gesundheit.
>
> Deine Rechte. Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit sowie das Recht, der Verarbeitung auf Grundlage berechtigter Interessen zu widersprechen (Art. 15 bis 21 DSGVO). Eine erteilte Einwilligung kannst du jederzeit widerrufen. Du kannst dich bei einer Datenschutzaufsichtsbehörde beschweren. Verantwortlich ist {{Name des Betriebs}}, {{Anschrift}}, E-Mail {{E-Mail}}.
## 7. Nachweis
Der Nachweis speichert Hashes der Texte mit den Betriebsangaben zum Buchungszeitpunkt (⚖ Q26).
+145
View File
@@ -0,0 +1,145 @@
# Auftragsverarbeitungsvertrag Terminplaner (Art. 28 DSGVO)
**Fassung 2026-10-16** · Entwurf – Prüfung durch Kanzlei offen
zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“).
## Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen
(1) Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Funktion „Terminplaner“ einschaltet und die dort angezeigte Zeile „Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung 2026-10-16)“ mit dem Klick auf „Einschalten“ bestätigt; diesem Vertrag und der Leistungsbeschreibung „Terminplaner“ („Leistungsbeschreibung“) in der angezeigten Fassung stimmt er damit zu. Das Portal speichert den Wortlaut der angezeigten Zeile, die Fassungen, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte in dieser Fassung als PDF enthält und auf sie verlinkt.
(2) Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform (etwa im Chat des Kundenportals oder per E-Mail) erklärt hat, dass er diesem Vertrag und der Leistungsbeschreibung in der aktuellen Fassung zustimmt. WEBklar speichert die Fassung, das Datum und den Weg dieser Erklärung, den Zeitpunkt des Einschaltens und den Namen des Mitarbeiters. Der Kunde erhält anschließend eine E-Mail mit diesen beiden Texten als PDF.
(3) Dieser Vertrag ist ein eigener Vertrag für den Terminplaner. Er ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Für den Terminplaner geht er Anlage 1 vor; im Übrigen gilt Anlage 1 ergänzend. In Datenschutzfragen geht er den Abo-Bedingungen und der Leistungsbeschreibung vor.
## 1. Gegenstand und Dauer
(1) WEBklar betreibt für die öffentliche Website des Kunden ein Buchungs-Widget mit einer Verwaltung im Kundenportal („Terminplaner“) und verarbeitet dabei personenbezogene Daten der Gäste und der Beschäftigten des Kunden im Auftrag des Kunden. Den Umfang beschreibt die Leistungsbeschreibung.
(2) Der Vertrag beginnt mit dem Einschalten der Funktion. Er endet mit dem Abschalten, spätestens mit dem Ende des Wartungs-Abos; den Terminplaner schaltet WEBklar dann spätestens am folgenden Tag ab (Nr. 8 Abs. 1 der Leistungsbeschreibung). Die Pflichten aus diesem Vertrag gelten fort, bis alle Daten nach Nummer 9 gelöscht sind.
## 2. Art und Zweck der Verarbeitung
(1) Art: Erheben über das Widget auf der Website des Kunden, Übermitteln an den Server von WEBklar, Speichern, Verwalten und Anzeigen im Kundenportal, Versenden von E-Mails, Bereitstellen eines Kalender-Links und einer CSV-Datei und Löschen.
(2) Zwecke: Der Kunde soll Termine seiner Gäste verwalten. Dazu nimmt der Terminplaner Buchungen und Terminanfragen entgegen, verhindert Doppelbuchungen, bestätigt, ändert und sagt Termine ab, erinnert Gäste auf deren Wunsch und benachrichtigt den Kunden. WEBklar versendet die E-Mails im Auftrag des Kunden. Außerdem schützt WEBklar den Terminplaner für den Kunden vor Missbrauch (Mengenbegrenzung, Erkennen automatisierter Buchungen).
(3) WEBklar verwendet die Daten für keine eigenen Zwecke, auch nicht in anonymisierter Form und nicht für Auswertungen über mehrere Kunden. Es findet keine Werbung und keine Zusammenführung mit anderen Daten statt. WEBklar bildet keine Profile einzelner Gäste. Zählungen ohne Personenbezug, die WEBklar für den sicheren Betrieb braucht (etwa die Zahl der gelöschten Buchungen je Tag oder die Länge der Versandwarteschlange), sind keine eigenen Zwecke.
## 3. Datenarten und Betroffene
(1) Daten der Gäste: Name und E-Mail-Adresse; auf Einstellung des Kunden außerdem Telefonnummer, eine Nachricht und Zusatzfelder, die der Kunde im Formular festlegt; die gebuchte Leistung, die Person oder der Platz und der Termin; der Status der Buchung mit den Zeitpunkten (Anlegen, Bestätigung, Annahme, Absage, Widerruf); Absagegrund und Vermerke zu einer Absage oder einem Widerruf (Freitext, auch mit dem Namen aus einer Widerrufserklärung); Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde; der Nachweis der Zustimmungen (Fassung der Gästetexte, Zeitpunkt, Text des Buchungsknopfs, Anrede und Hashes (Prüfsummen) der gezeigten Texte); der Wunsch nach einer Erinnerung; der Zustellstatus der E-Mails an den Gast.
(2) Daten der Beschäftigten des Kunden: Namen oder Bezeichnungen der Personen oder Plätze, deren Arbeitszeiten und Abwesenheiten sowie ein geheimer Kalender-Link je Person oder Platz.
(3) Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht verarbeitet. Der Terminplaner ist nicht für den Gesundheitsbereich bestimmt; der Kunde fordert von seinen Gästen keine Gesundheitsangaben (Nummer 11 Abs. 4).
(4) IP-Adresse und Browser-Kennzeichen (User-Agent) erhält der Server von WEBklar technisch bei jedem Abruf des Widgets und jeder Anfrage. Die Mengenbegrenzung (Missbrauchsschutz) verwendet die IP-Adresse nur im Arbeitsspeicher des Servers; ein Neustart leert sie. Mit der Buchung wird keine IP-Adresse und kein Browser-Kennzeichen gespeichert. In den Server-Protokollen stehen sie 14 Tage (Anlage 2 der Abo-Bedingungen).
(5) Der Terminplaner setzt keine Cookies und speichert nichts im Browser des Gastes.
(6) Betroffene: Gäste, die über die Website des Kunden Termine buchen oder anfragen, und Beschäftigte des Kunden, die im Terminplaner geführt werden.
## 4. Weisungen
(1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, auch in Bezug auf eine Übermittlung in ein Drittland oder an eine internationale Organisation. Das gilt nicht, wenn das Recht der Europäischen Union oder Deutschlands WEBklar zu einer Verarbeitung verpflichtet. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es nicht verbietet.
(2) Mit dem Einschalten weist der Kunde WEBklar an, den Terminplaner nach seinen Einstellungen und der Leistungsbeschreibung zu betreiben. Weitere Weisungen ergeben sich aus den Einstellungen im Kundenportal (Leistungen, Personen oder Plätze, Zeiten, Felder des Formulars, Aufbewahrungsdauer, Erinnerungen, Einschalten und Ausschalten). Sonstige Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Weisungen, die über diese Einstellungen hinausgehen, setzt WEBklar nur um, wenn das technisch vorgesehen ist; sonst kann der Kunde den Terminplaner abschalten lassen (Nr. 8 Abs. 6 der Leistungsbeschreibung). Die Löschung auf Weisung nach Nummer 9 Abs. 6 setzt WEBklar immer um.
(3) Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin. WEBklar darf die Ausführung aussetzen, bis der Kunde die Weisung ändert oder die Rechtslage geklärt ist.
## 5. Vertraulichkeit
WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.
## 6. Technische und organisatorische Maßnahmen
(1) WEBklar trifft die Maßnahmen nach Art. 32 DSGVO, die in der Anlage zu diesem Vertrag beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt.
(2) Für den Server gelten ergänzend die Maßnahmen der Anlage 2 der Abo-Bedingungen, soweit die Anlage zu diesem Vertrag nichts anderes regelt.
## 7. Unterauftragsverarbeiter
(1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Terminplaner läuft, und für dessen Sicherung (Rechenzentrum Falkenstein, Deutschland) sowie für den Versand der Buchungs-E-Mails über den SMTP-Dienst des Webhostings (Rechenzentrum Nürnberg, Deutschland). Eine Übermittlung in Drittländer findet nicht statt.
(2) Will WEBklar einen weiteren Unterauftragsverarbeiter hinzuziehen oder einen ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen. WEBklar setzt den neuen Unterauftragsverarbeiter nicht vor Ablauf dieser Frist ein. Kündigt ein Unterauftragsverarbeiter von WEBklar eine eigene Änderung mit kürzerer Frist an, informiert WEBklar den Kunden unverzüglich, nachdem die Ankündigung eingegangen ist; der Kunde kann dann unverzüglich widersprechen. Einigen sich die Parteien nicht, kann der Kunde den Terminplaner abschalten lassen und WEBklar ihn für diese Website zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt wird. Die Daten werden dann nach Nummer 9 gelöscht, auf Weisung des Kunden früher (Nummer 9 Abs. 6).
(3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf die Datenschutzpflichten nach Art. 28 Abs. 4 DSGVO. Bei der Hetzner Online GmbH ist das deren Vertrag zur Auftragsverarbeitung, den WEBklar abgeschlossen hat. Kommt ein Unterauftragsverarbeiter seinen Datenschutzpflichten nicht nach, haftet WEBklar dem Kunden für die Einhaltung dieser Pflichten (Art. 28 Abs. 4 Satz 2 DSGVO).
(4) Die Zahlung der Einrichtung über Stripe betrifft nur den Vertrag zwischen dem Kunden und WEBklar. Daten von Gästen oder Beschäftigten des Kunden gehen nicht an Stripe. Stripe ist deshalb kein Unterauftragsverarbeiter dieses Vertrags.
## 8. Unterstützung des Kunden, Betroffenenrechte
(1) WEBklar leitet Anfragen von Gästen und Beschäftigten, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter. WEBklar unterstützt ihn mit geeigneten technischen und organisatorischen Maßnahmen bei der Beantwortung.
(2) Dafür bietet das Kundenportal die Aktion „Gastdaten löschen“ und den Export der Termine als CSV-Datei. Mit „Gastdaten löschen“ leert der Kunde Name, E-Mail-Adresse, Telefonnummer, Nachricht und Zusatzfelder einer Buchung; Status und Zeiten bleiben. Dabei löscht WEBklar die E-Mails zu dieser Buchung, die noch auf den Versand warten, und die Links des Gastes werden ungültig. E-Mails, deren Versand endgültig fehlgeschlagen ist, behalten Empfängeradresse und Inhalt, bis WEBklar sie nach Nummer 9 Abs. 4 löscht (48 Stunden nach dem Fehlschlag). Eine noch nicht bestätigte Buchung verfällt von selbst (Nummer 9 Abs. 1).
(3) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.
(4) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO. Dazu gehören die Angaben, die er für sein Verzeichnis von Verarbeitungstätigkeiten und für eine etwaige Datenschutz-Folgenabschätzung braucht.
(5) Die Buchung speichert als Nachweis der Zustimmungen die Fassung der Gästetexte, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes der übrigen Texte, die der Gast gesehen hat, nicht diese Texte selbst. Damit sich der Wortlaut nachweisen lässt, bewahrt WEBklar jede Fassung der Gästetexte (Hinweis am Formular, Texte der Haken, Datenschutz-Absatz und Widerrufsbelehrung für Gäste) mit dem Zeitraum ihrer Verwendung auf, bis drei Jahre nach dem Ende des Terminplaners. WEBklar stellt sie dem Kunden auf Verlangen zur Verfügung.
(6) Die Gäste informiert der Kunde (Art. 13 DSGVO; Nummer 11 Abs. 2).
## 9. Löschung und Rückgabe
(1) Nicht bestätigte Buchungen löscht WEBklar 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten.
(2) Abgesagte und abgelehnte Termine löscht WEBklar 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die der Kunde bis zum angefragten Terminbeginn nicht beantwortet, behandelt der Terminplaner als abgelehnt; die 30 Tage laufen dann ab diesem Zeitpunkt.
(3) Bestätigte und erledigte Termine löscht WEBklar nach der Aufbewahrungsdauer, gerechnet ab dem Ende des Termins. Der Kunde stellt sie im Kundenportal ein; die Vorgabe sind 90 Tage, einstellbar sind 14 bis 365 Tage.
(4) In der Versandwarteschlange für E-Mails leert WEBklar die Empfängeradresse und den Inhalt einer E-Mail nach dem Versand und löscht den Eintrag 7 Tage nach dem Versand. E-Mails, die nicht zugestellt werden können, löscht WEBklar mit Empfängeradresse und Inhalt 48 Stunden nach dem endgültigen Fehlschlag.
(5) Der Löschlauf läuft täglich; gelöscht wird frühestens nach Ablauf der jeweiligen Frist. Ist an einem Tag mehr zu löschen, als ein Lauf schafft, folgt der Rest im nächsten Lauf.
(6) Ist der Terminplaner abgeschaltet oder das Wartungs-Abo beendet, löscht WEBklar alle Termindaten und die Einstellungen 30 Tage danach. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen (zum Beispiel Betriebsangaben, Namen der Leistungen, die ersten acht Namen der Personen oder Plätze und die Wünsche des Kunden zum Einbau) und enthält keine Daten von Gästen; dafür gilt die Datenschutzerklärung von WEBklar (Abschnitt 6). Bis zur Löschung kann der Kunde die Termine im Kundenportal als CSV-Datei exportieren. Beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen wieder ein (Nr. 8 Abs. 4 der Leistungsbeschreibung). Früher löscht der Kunde einzelne Gastdaten selbst mit der Aktion „Gastdaten löschen“ (Nummer 8 Abs. 2); alle Daten löscht WEBklar auf Weisung des Kunden in Textform unverzüglich.
(7) Entwürfe aus dem Fragebogen, aus denen die Funktion nicht eingeschaltet wurde, löscht WEBklar 90 Tage nach der letzten Änderung.
(8) Die Daten sind in der täglichen Vollsicherung des Servers enthalten (§ 3 Abs. 5 der Abo-Bedingungen). Gelöschte Daten bleiben dort bis zu sieben Tage erhalten und werden dann überschrieben. Wird eine Sicherung zurückgespielt, holt der tägliche Löschlauf die fälligen Löschungen nach; Löschungen auf Weisung des Kunden wiederholt WEBklar.
(9) Gesetzliche Aufbewahrungspflichten bleiben unberührt.
## 10. Nachweise und Kontrollen
WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Zertifizierung der Hetzner Online GmbH nach ISO/IEC 27001). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.
## 11. Pflichten des Kunden
(1) Der Kunde ist Verantwortlicher für die Verarbeitung der Daten seiner Gäste und seiner Beschäftigten im Terminplaner.
(2) Der Kunde informiert seine Gäste nach Art. 13 DSGVO. Dazu nimmt er den Datenschutz-Absatz für die Online-Terminbuchung in seine Datenschutzerklärung auf (Nr. 4 Abs. 9 der Leistungsbeschreibung) und lässt den Datenschutz-Hinweis am Buchungsformular unverändert.
(3) Der Kunde informiert seine Beschäftigten darüber, dass ihre Namen oder Bezeichnungen, ihre Arbeitszeiten und Abwesenheiten im Terminplaner stehen und dass es einen geheimen Kalender-Link gibt (Nr. 4 Abs. 7 der Leistungsbeschreibung).
(4) Der Kunde setzt den Terminplaner nicht im Gesundheitsbereich ein und fordert von seinen Gästen keine Angaben zu ihrer Gesundheit, auch nicht über Zusatzfelder oder das Nachrichtenfeld (Nr. 4 Abs. 3 der Leistungsbeschreibung).
(5) Der Kunde verändert den Haken für die Erinnerung und die Texte der übrigen Haken nicht (Nr. 4 Abs. 6 der Leistungsbeschreibung). Erinnerungen versendet der Terminplaner nur, wenn der Gast den Haken gesetzt hat.
## 12. Haftung, Änderungen, Schlussbestimmungen
(1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen.
(2) Eine neue Fassung dieses Vertrags oder der Leistungsbeschreibung, auch wegen geänderter Datenerhebung, teilt WEBklar dem Kunden mindestens sechs Wochen vor ihrem Wirksamwerden in Textform mit. Sie wird wirksam, wenn der Kunde ihr im Kundenportal oder in Textform zustimmt. Stimmt er nicht zu, gilt die bisherige Fassung weiter.
(3) Ist die neue Fassung nötig, weil ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form nicht mehr zulässt, und stimmt der Kunde ihr nicht zu, darf WEBklar den Terminplaner für seine Website nach Nr. 9 Abs. 2 der Leistungsbeschreibung beenden oder nach Nr. 9 Abs. 3 der Leistungsbeschreibung aussetzen. Das Wartungs-Abo bleibt davon unberührt.
(4) Im Übrigen gilt § 16 der Abo-Bedingungen.
## Anlage: Technische und organisatorische Maßnahmen des Terminplaners (Art. 32 DSGVO)
| Bereich | Maßnahmen |
|---|---|
| Übertragung | Das Widget, die Seiten der Gäste und das Kundenportal werden nur über TLS (HTTPS) übertragen. Der Einbau-Code verweist auf eine Adresse mit https://. |
| Speicher im Browser | Es werden keine Cookies gesetzt, und der Terminplaner speichert nichts im Browser des Gastes (kein lokaler Speicher, kein Sitzungsspeicher). Das Widget sendet seine Anfragen ohne Cookies, liest weder den Referrer noch die Zeitzone des Browsers und ruft nur die Schnittstelle des Terminplaners auf. |
| Zugriffskontrolle | Das Kundenportal ist nur nach Anmeldung erreichbar; jeder Kunde sieht nur die Daten seiner eigenen Projekte. Die öffentliche Schnittstelle des Widgets liefert nur für einen eingeschalteten Terminplaner Daten und ist über den Website-Schlüssel (Site-Key) adressiert; Buchungen nimmt sie nur von Seiten des Kunden und des Portals an. Der Kalender-Link enthält ein geheimes Token, das mit einem Vergleich in konstanter Zeit geprüft wird. |
| Missbrauchsschutz | Ein verstecktes Feld (Honeypot), eine Mindestzeit bis zum Absenden des Formulars mit signiertem Formular-Token und eine Mindestantwortzeit schützen vor automatisierten Buchungen. Grenzen je IP-Adresse, je Website und je E-Mail-Adresse sowie das Mailbudget je Stunde (insgesamt und je Funktion) gelten nur im Arbeitsspeicher; ein Neustart leert sie. |
| Datenminimierung | Pflicht sind immer nur Name und E-Mail-Adresse; ob Telefonnummer, Nachricht und Zusatzfelder abgefragt werden, legt der Kunde fest. Mit der Buchung werden keine IP-Adresse und kein Browser-Kennzeichen gespeichert. Der Nachweis der Zustimmungen besteht aus der Fassung der Gästetexte, dem Zeitpunkt, dem Text des Buchungsknopfs, der Anrede und Hashes der übrigen gezeigten Texte, nicht aus diesen Texten. Nach dem Versand einer E-Mail werden Empfängeradresse und Inhalt in der Warteschlange geleert. |
| Trennung | Alle Buchungen, Belegungen und Warteschlangeneinträge tragen die Kennung der Funktion (eines Terminplaners je Website-Projekt); das Kundenportal und die öffentliche Schnittstelle lesen immer nur die Daten der jeweiligen Funktion. Die Hintergrundläufe (Löschung, Versand der E-Mails, Erinnerungen) gehen die Dokumente aller Funktionen nacheinander durch und schreiben jeweils nur in das Dokument, um das es geht. Die Einstellungen liegen je Funktion in einem eigenen Dokument. |
| Löschung | Täglicher Löschlauf für Buchungen, Belegungen, Warteschlange und Entwürfe nach den Fristen der Nummer 9; Termindaten und Einstellungen abgeschalteter Funktionen 30 Tage nach dem Abschalten; das Einrichtungs-Ticket bleibt als Arbeitsnachweis mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6). Vor der Löschung einer Buchung, einer abgeschalteten Funktion oder eines nicht bestellten Fragebogens wird der Zustand frisch gelesen; bei einem Lesefehler wird nichts gelöscht. |
| Protokolle | Die Programmprotokolle des Terminplaners und der Tagesbericht enthalten nur Zählungen und Buchungsnummern, keine Namen, E-Mail-Adressen oder Nachrichten von Gästen. Server-Protokolle des Webservers werden nach 14 Tagen gelöscht (Anlage 2 der Abo-Bedingungen). |
| Datensicherung | Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen). |
| Rechenzentrum | Hetzner Online GmbH, Rechenzentren Falkenstein und Nürnberg (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert; der Geltungsbereich umfasst die Hosting-Dienstleistungen und die Rechenzentren. |
| Organisation | Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nummer 8 Abs. 3), Archiv der Fassungen der Gästetexte (Nummer 8 Abs. 5), Überprüfung der Maßnahmen mindestens einmal im Jahr. |
+145
View File
@@ -0,0 +1,145 @@
# Auftragsverarbeitungsvertrag Terminplaner (Art. 28 DSGVO)
**Fassung 2026-10-05**
zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“).
## Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen
(1) Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Einrichtung des Terminplaners bestellt und dabei per Haken diesem Vertrag und der Leistungsbeschreibung „Terminplaner“ („Leistungsbeschreibung“) in der angezeigten Fassung zustimmt. Das Portal speichert die Fassung, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte und die Widerrufsbelehrung in dieser Fassung als PDF enthält und auf sie verlinkt.
(2) Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform (etwa im Chat des Kundenportals oder per E-Mail) erklärt hat, dass er diesem Vertrag und der Leistungsbeschreibung in der aktuellen Fassung zustimmt. WEBklar speichert die Fassung, das Datum und den Weg dieser Erklärung, den Zeitpunkt des Einschaltens und den Namen des Mitarbeiters. Der Kunde erhält anschließend eine E-Mail mit diesen beiden Texten als PDF.
(3) Dieser Vertrag ist ein eigener Vertrag für den Terminplaner. Er ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Für den Terminplaner geht er Anlage 1 vor; im Übrigen gilt Anlage 1 ergänzend. In Datenschutzfragen geht er den Abo-Bedingungen und der Leistungsbeschreibung vor.
## 1. Gegenstand und Dauer
(1) WEBklar betreibt für die öffentliche Website des Kunden ein Buchungs-Widget mit einer Verwaltung im Kundenportal („Terminplaner“) und verarbeitet dabei personenbezogene Daten der Gäste und der Beschäftigten des Kunden im Auftrag des Kunden. Den Umfang beschreibt die Leistungsbeschreibung.
(2) Der Vertrag beginnt mit dem Einschalten der Funktion. Er endet mit dem Abschalten, spätestens mit dem Ende des Wartungs-Abos; den Terminplaner schaltet WEBklar dann spätestens am folgenden Tag ab (Nr. 8 Abs. 1 der Leistungsbeschreibung). Die Pflichten aus diesem Vertrag gelten fort, bis alle Daten nach Nummer 9 gelöscht sind.
## 2. Art und Zweck der Verarbeitung
(1) Art: Erheben über das Widget auf der Website des Kunden, Übermitteln an den Server von WEBklar, Speichern, Verwalten und Anzeigen im Kundenportal, Versenden von E-Mails, Bereitstellen eines Kalender-Links und einer CSV-Datei und Löschen.
(2) Zwecke: Der Kunde soll Termine seiner Gäste verwalten. Dazu nimmt der Terminplaner Buchungen und Terminanfragen entgegen, verhindert Doppelbuchungen, bestätigt, ändert und sagt Termine ab, erinnert Gäste auf deren Wunsch und benachrichtigt den Kunden. WEBklar versendet die E-Mails im Auftrag des Kunden. Außerdem schützt WEBklar den Terminplaner für den Kunden vor Missbrauch (Mengenbegrenzung, Erkennen automatisierter Buchungen).
(3) WEBklar verwendet die Daten für keine eigenen Zwecke, auch nicht in anonymisierter Form und nicht für Auswertungen über mehrere Kunden. Es findet keine Werbung und keine Zusammenführung mit anderen Daten statt. WEBklar bildet keine Profile einzelner Gäste. Zählungen ohne Personenbezug, die WEBklar für den sicheren Betrieb braucht (etwa die Zahl der gelöschten Buchungen je Tag oder die Länge der Versandwarteschlange), sind keine eigenen Zwecke.
## 3. Datenarten und Betroffene
(1) Daten der Gäste: Name und E-Mail-Adresse; auf Einstellung des Kunden außerdem Telefonnummer, eine Nachricht und Zusatzfelder, die der Kunde im Formular festlegt; die gebuchte Leistung, die Person oder der Platz und der Termin; der Status der Buchung mit den Zeitpunkten (Anlegen, Bestätigung, Annahme, Absage, Widerruf); Absagegrund und Vermerke zu einer Absage oder einem Widerruf (Freitext, auch mit dem Namen aus einer Widerrufserklärung); Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde; der Nachweis der Zustimmungen (Fassung der Gästetexte, Zeitpunkt, Text des Buchungsknopfs, Anrede und Hashes (Prüfsummen) der gezeigten Texte); der Wunsch nach einer Erinnerung; der Zustellstatus der E-Mails an den Gast.
(2) Daten der Beschäftigten des Kunden: Namen oder Bezeichnungen der Personen oder Plätze, deren Arbeitszeiten und Abwesenheiten sowie ein geheimer Kalender-Link je Person oder Platz.
(3) Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht verarbeitet. Der Terminplaner ist nicht für den Gesundheitsbereich bestimmt; der Kunde fordert von seinen Gästen keine Gesundheitsangaben (Nummer 11 Abs. 4).
(4) IP-Adresse und Browser-Kennzeichen (User-Agent) erhält der Server von WEBklar technisch bei jedem Abruf des Widgets und jeder Anfrage. Die Mengenbegrenzung (Missbrauchsschutz) verwendet die IP-Adresse nur im Arbeitsspeicher des Servers; ein Neustart leert sie. Mit der Buchung wird keine IP-Adresse und kein Browser-Kennzeichen gespeichert. In den Server-Protokollen stehen sie 14 Tage (Anlage 2 der Abo-Bedingungen).
(5) Der Terminplaner setzt keine Cookies und speichert nichts im Browser des Gastes.
(6) Betroffene: Gäste, die über die Website des Kunden Termine buchen oder anfragen, und Beschäftigte des Kunden, die im Terminplaner geführt werden.
## 4. Weisungen
(1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, auch in Bezug auf eine Übermittlung in ein Drittland oder an eine internationale Organisation. Das gilt nicht, wenn das Recht der Europäischen Union oder Deutschlands WEBklar zu einer Verarbeitung verpflichtet. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es nicht verbietet.
(2) Mit der Bestellung weist der Kunde WEBklar an, den Terminplaner nach seinen Einstellungen und der Leistungsbeschreibung zu betreiben. Weitere Weisungen ergeben sich aus den Einstellungen im Kundenportal (Leistungen, Personen oder Plätze, Zeiten, Felder des Formulars, Aufbewahrungsdauer, Erinnerungen, Einschalten und Ausschalten). Sonstige Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Weisungen, die über diese Einstellungen hinausgehen, setzt WEBklar nur um, wenn das technisch vorgesehen ist; sonst kann der Kunde den Terminplaner abschalten lassen (Nr. 8 Abs. 6 der Leistungsbeschreibung). Die Löschung auf Weisung nach Nummer 9 Abs. 6 setzt WEBklar immer um.
(3) Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin. WEBklar darf die Ausführung aussetzen, bis der Kunde die Weisung ändert oder die Rechtslage geklärt ist.
## 5. Vertraulichkeit
WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.
## 6. Technische und organisatorische Maßnahmen
(1) WEBklar trifft die Maßnahmen nach Art. 32 DSGVO, die in der Anlage zu diesem Vertrag beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt.
(2) Für den Server gelten ergänzend die Maßnahmen der Anlage 2 der Abo-Bedingungen, soweit die Anlage zu diesem Vertrag nichts anderes regelt.
## 7. Unterauftragsverarbeiter
(1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Terminplaner läuft, und für dessen Sicherung (Rechenzentrum Falkenstein, Deutschland) sowie für den Versand der Buchungs-E-Mails über den SMTP-Dienst des Webhostings (Rechenzentrum Nürnberg, Deutschland). Eine Übermittlung in Drittländer findet nicht statt.
(2) Will WEBklar einen weiteren Unterauftragsverarbeiter hinzuziehen oder einen ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen. WEBklar setzt den neuen Unterauftragsverarbeiter nicht vor Ablauf dieser Frist ein. Kündigt ein Unterauftragsverarbeiter von WEBklar eine eigene Änderung mit kürzerer Frist an, informiert WEBklar den Kunden unverzüglich, nachdem die Ankündigung eingegangen ist; der Kunde kann dann unverzüglich widersprechen. Einigen sich die Parteien nicht, kann der Kunde den Terminplaner abschalten lassen und WEBklar ihn für diese Website zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt wird. Die Daten werden dann nach Nummer 9 gelöscht, auf Weisung des Kunden früher (Nummer 9 Abs. 6).
(3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf die Datenschutzpflichten nach Art. 28 Abs. 4 DSGVO. Bei der Hetzner Online GmbH ist das deren Vertrag zur Auftragsverarbeitung, den WEBklar abgeschlossen hat. Kommt ein Unterauftragsverarbeiter seinen Datenschutzpflichten nicht nach, haftet WEBklar dem Kunden für die Einhaltung dieser Pflichten (Art. 28 Abs. 4 Satz 2 DSGVO).
(4) Die Zahlung der Einrichtung über Stripe betrifft nur den Vertrag zwischen dem Kunden und WEBklar. Daten von Gästen oder Beschäftigten des Kunden gehen nicht an Stripe. Stripe ist deshalb kein Unterauftragsverarbeiter dieses Vertrags.
## 8. Unterstützung des Kunden, Betroffenenrechte
(1) WEBklar leitet Anfragen von Gästen und Beschäftigten, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter. WEBklar unterstützt ihn mit geeigneten technischen und organisatorischen Maßnahmen bei der Beantwortung.
(2) Dafür bietet das Kundenportal die Aktion „Gastdaten löschen“ und den Export der Termine als CSV-Datei. Mit „Gastdaten löschen“ leert der Kunde Name, E-Mail-Adresse, Telefonnummer, Nachricht und Zusatzfelder einer Buchung; Status und Zeiten bleiben. Dabei löscht WEBklar die E-Mails zu dieser Buchung, die noch auf den Versand warten, und die Links des Gastes werden ungültig. E-Mails, deren Versand endgültig fehlgeschlagen ist, behalten Empfängeradresse und Inhalt, bis WEBklar sie nach Nummer 9 Abs. 4 löscht (48 Stunden nach dem Fehlschlag). Eine noch nicht bestätigte Buchung verfällt von selbst (Nummer 9 Abs. 1).
(3) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.
(4) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO. Dazu gehören die Angaben, die er für sein Verzeichnis von Verarbeitungstätigkeiten und für eine etwaige Datenschutz-Folgenabschätzung braucht.
(5) Die Buchung speichert als Nachweis der Zustimmungen die Fassung der Gästetexte, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes der übrigen Texte, die der Gast gesehen hat, nicht diese Texte selbst. Damit sich der Wortlaut nachweisen lässt, bewahrt WEBklar jede Fassung der Gästetexte (Hinweis am Formular, Texte der Haken, Datenschutz-Absatz und Widerrufsbelehrung für Gäste) mit dem Zeitraum ihrer Verwendung auf, bis drei Jahre nach dem Ende des Terminplaners. WEBklar stellt sie dem Kunden auf Verlangen zur Verfügung.
(6) Die Gäste informiert der Kunde (Art. 13 DSGVO; Nummer 11 Abs. 2).
## 9. Löschung und Rückgabe
(1) Nicht bestätigte Buchungen löscht WEBklar 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten.
(2) Abgesagte und abgelehnte Termine löscht WEBklar 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die der Kunde bis zum angefragten Terminbeginn nicht beantwortet, behandelt der Terminplaner als abgelehnt; die 30 Tage laufen dann ab diesem Zeitpunkt.
(3) Bestätigte und erledigte Termine löscht WEBklar nach der Aufbewahrungsdauer, gerechnet ab dem Ende des Termins. Der Kunde stellt sie im Kundenportal ein; die Vorgabe sind 90 Tage, einstellbar sind 14 bis 365 Tage.
(4) In der Versandwarteschlange für E-Mails leert WEBklar die Empfängeradresse und den Inhalt einer E-Mail nach dem Versand und löscht den Eintrag 7 Tage nach dem Versand. E-Mails, die nicht zugestellt werden können, löscht WEBklar mit Empfängeradresse und Inhalt 48 Stunden nach dem endgültigen Fehlschlag.
(5) Der Löschlauf läuft täglich; gelöscht wird frühestens nach Ablauf der jeweiligen Frist. Ist an einem Tag mehr zu löschen, als ein Lauf schafft, folgt der Rest im nächsten Lauf.
(6) Ist der Terminplaner abgeschaltet oder das Wartungs-Abo beendet, löscht WEBklar alle Termindaten und die Einstellungen 30 Tage danach. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen (zum Beispiel Betriebsangaben, Namen der Leistungen, die ersten acht Namen der Personen oder Plätze und die Wünsche des Kunden zum Einbau) und enthält keine Daten von Gästen; dafür gilt die Datenschutzerklärung von WEBklar (Abschnitt 6). Bis zur Löschung kann der Kunde die Termine im Kundenportal als CSV-Datei exportieren. Beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen wieder ein (Nr. 8 Abs. 4 der Leistungsbeschreibung). Früher löscht der Kunde einzelne Gastdaten selbst mit der Aktion „Gastdaten löschen“ (Nummer 8 Abs. 2); alle Daten löscht WEBklar auf Weisung des Kunden in Textform unverzüglich.
(7) Entwürfe aus dem Fragebogen, aus denen keine Bestellung geworden ist, löscht WEBklar 90 Tage nach der letzten Änderung.
(8) Die Daten sind in der täglichen Vollsicherung des Servers enthalten (§ 3 Abs. 5 der Abo-Bedingungen). Gelöschte Daten bleiben dort bis zu sieben Tage erhalten und werden dann überschrieben. Wird eine Sicherung zurückgespielt, holt der tägliche Löschlauf die fälligen Löschungen nach; Löschungen auf Weisung des Kunden wiederholt WEBklar.
(9) Gesetzliche Aufbewahrungspflichten bleiben unberührt.
## 10. Nachweise und Kontrollen
WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Zertifizierung der Hetzner Online GmbH nach ISO/IEC 27001). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.
## 11. Pflichten des Kunden
(1) Der Kunde ist Verantwortlicher für die Verarbeitung der Daten seiner Gäste und seiner Beschäftigten im Terminplaner.
(2) Der Kunde informiert seine Gäste nach Art. 13 DSGVO. Dazu nimmt er den Datenschutz-Absatz für die Online-Terminbuchung in seine Datenschutzerklärung auf (Nr. 4 Abs. 9 der Leistungsbeschreibung) und lässt den Datenschutz-Hinweis am Buchungsformular unverändert.
(3) Der Kunde informiert seine Beschäftigten darüber, dass ihre Namen oder Bezeichnungen, ihre Arbeitszeiten und Abwesenheiten im Terminplaner stehen und dass es einen geheimen Kalender-Link gibt (Nr. 4 Abs. 7 der Leistungsbeschreibung).
(4) Der Kunde setzt den Terminplaner nicht im Gesundheitsbereich ein und fordert von seinen Gästen keine Angaben zu ihrer Gesundheit, auch nicht über Zusatzfelder oder das Nachrichtenfeld (Nr. 4 Abs. 3 der Leistungsbeschreibung).
(5) Der Kunde verändert den Haken für die Erinnerung und die Texte der übrigen Haken nicht (Nr. 4 Abs. 6 der Leistungsbeschreibung). Erinnerungen versendet der Terminplaner nur, wenn der Gast den Haken gesetzt hat.
## 12. Haftung, Änderungen, Schlussbestimmungen
(1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen.
(2) Eine neue Fassung dieses Vertrags oder der Leistungsbeschreibung, auch wegen geänderter Datenerhebung, teilt WEBklar dem Kunden mindestens sechs Wochen vor ihrem Wirksamwerden in Textform mit. Sie wird wirksam, wenn der Kunde ihr im Kundenportal oder in Textform zustimmt. Stimmt er nicht zu, gilt die bisherige Fassung weiter.
(3) Ist die neue Fassung nötig, weil ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form nicht mehr zulässt, und stimmt der Kunde ihr nicht zu, darf WEBklar den Terminplaner für seine Website nach Nr. 9 Abs. 2 der Leistungsbeschreibung beenden oder nach Nr. 9 Abs. 3 der Leistungsbeschreibung aussetzen. Das Wartungs-Abo bleibt davon unberührt.
(4) Im Übrigen gilt § 16 der Abo-Bedingungen.
## Anlage: Technische und organisatorische Maßnahmen des Terminplaners (Art. 32 DSGVO)
| Bereich | Maßnahmen |
|---|---|
| Übertragung | Das Widget, die Seiten der Gäste und das Kundenportal werden nur über TLS (HTTPS) übertragen. Der Einbau-Code verweist auf eine Adresse mit https://. |
| Speicher im Browser | Es werden keine Cookies gesetzt, und der Terminplaner speichert nichts im Browser des Gastes (kein lokaler Speicher, kein Sitzungsspeicher). Das Widget sendet seine Anfragen ohne Cookies, liest weder den Referrer noch die Zeitzone des Browsers und ruft nur die Schnittstelle des Terminplaners auf. |
| Zugriffskontrolle | Das Kundenportal ist nur nach Anmeldung erreichbar; jeder Kunde sieht nur die Daten seiner eigenen Projekte. Die öffentliche Schnittstelle des Widgets liefert nur für einen eingeschalteten Terminplaner Daten und ist über den Website-Schlüssel (Site-Key) adressiert; Buchungen nimmt sie nur von Seiten des Kunden und des Portals an. Der Kalender-Link enthält ein geheimes Token, das mit einem Vergleich in konstanter Zeit geprüft wird. |
| Missbrauchsschutz | Ein verstecktes Feld (Honeypot), eine Mindestzeit bis zum Absenden des Formulars mit signiertem Formular-Token und eine Mindestantwortzeit schützen vor automatisierten Buchungen. Grenzen je IP-Adresse, je Website und je E-Mail-Adresse sowie das Mailbudget je Stunde (insgesamt und je Funktion) gelten nur im Arbeitsspeicher; ein Neustart leert sie. |
| Datenminimierung | Pflicht sind immer nur Name und E-Mail-Adresse; ob Telefonnummer, Nachricht und Zusatzfelder abgefragt werden, legt der Kunde fest. Mit der Buchung werden keine IP-Adresse und kein Browser-Kennzeichen gespeichert. Der Nachweis der Zustimmungen besteht aus der Fassung der Gästetexte, dem Zeitpunkt, dem Text des Buchungsknopfs, der Anrede und Hashes der übrigen gezeigten Texte, nicht aus diesen Texten. Nach dem Versand einer E-Mail werden Empfängeradresse und Inhalt in der Warteschlange geleert. |
| Trennung | Alle Buchungen, Belegungen und Warteschlangeneinträge tragen die Kennung der Funktion (eines Terminplaners je Website-Projekt); das Kundenportal und die öffentliche Schnittstelle lesen immer nur die Daten der jeweiligen Funktion. Die Hintergrundläufe (Löschung, Versand der E-Mails, Erinnerungen) gehen die Dokumente aller Funktionen nacheinander durch und schreiben jeweils nur in das Dokument, um das es geht. Die Einstellungen liegen je Funktion in einem eigenen Dokument. |
| Löschung | Täglicher Löschlauf für Buchungen, Belegungen, Warteschlange und Entwürfe nach den Fristen der Nummer 9; Termindaten und Einstellungen abgeschalteter Funktionen 30 Tage nach dem Abschalten; das Einrichtungs-Ticket bleibt als Arbeitsnachweis mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6). Vor der Löschung einer Buchung, einer abgeschalteten Funktion oder eines nicht bestellten Fragebogens wird der Zustand frisch gelesen; bei einem Lesefehler wird nichts gelöscht. |
| Protokolle | Die Programmprotokolle des Terminplaners und der Tagesbericht enthalten nur Zählungen und Buchungsnummern, keine Namen, E-Mail-Adressen oder Nachrichten von Gästen. Server-Protokolle des Webservers werden nach 14 Tagen gelöscht (Anlage 2 der Abo-Bedingungen). |
| Datensicherung | Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen). |
| Rechenzentrum | Hetzner Online GmbH, Rechenzentren Falkenstein und Nürnberg (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert; der Geltungsbereich umfasst die Hosting-Dienstleistungen und die Rechenzentren. |
| Organisation | Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nummer 8 Abs. 3), Archiv der Fassungen der Gästetexte (Nummer 8 Abs. 5), Überprüfung der Maßnahmen mindestens einmal im Jahr. |
+157
View File
@@ -0,0 +1,157 @@
# Auftragsverarbeitungsvertrag Buchungskalender (Art. 28 DSGVO)
**Fassung 2026-10-16** · Entwurf – Prüfung durch Kanzlei offen
zwischen dem Kunden als Verantwortlichem („Kunde“; in der Leistungsbeschreibung „Vermieter“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“).
## Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen
(1) Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Funktion „Buchungskalender“ einschaltet und die dort angezeigte Zeile „Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung 2026-10-16)“ mit dem Klick auf „Einschalten“ bestätigt; diesem Vertrag und der Leistungsbeschreibung „Buchungskalender“ („Leistungsbeschreibung“) in der angezeigten Fassung stimmt er damit zu. Das Portal speichert den Wortlaut der angezeigten Zeile, die Fassungen, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte in dieser Fassung als PDF enthält und auf sie verlinkt.
(2) Ein Mitarbeiter von WEBklar darf die Funktion auch ohne Bestellung im Portal einschalten (zum Beispiel für einen Kunden, dem WEBklar sie als Teil seines Website-Projekts bereitstellt), aber nur, wenn der Kunde vorher in Textform (etwa im Chat des Kundenportals oder per E-Mail) erklärt hat, dass er diesem Vertrag und der Leistungsbeschreibung in der aktuellen Fassung zustimmt. Das Portal verlangt dafür das Datum und den Weg der Erklärung; fehlt eine Angabe, schaltet es nicht ein. WEBklar speichert vor dem Einschalten einen Zustimmungsnachweis mit der Fassung der beiden Texte, dem Datum und dem Weg dieser Erklärung, dem Zeitpunkt des Einschaltens und dem Namen des Mitarbeiters (ohne IP-Adresse und Browser-Kennzeichen, weil der Kunde nicht im Portal zugestimmt hat). Der Kunde erhält anschließend eine E-Mail mit diesen beiden Texten als PDF.
(3) Dieser Vertrag ist ein eigener Vertrag für den Buchungskalender. Er ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Für den Buchungskalender geht er Anlage 1 vor; im Übrigen gilt Anlage 1 ergänzend. In Datenschutzfragen geht er den Abo-Bedingungen und der Leistungsbeschreibung vor.
(4) Die Rollen: Der Kunde ist Verantwortlicher für die personenbezogenen Daten seiner Gäste im Buchungskalender. WEBklar ist sein Auftragsverarbeiter. Stripe ist nicht Auftragsverarbeiter von WEBklar, sondern Vertragspartner des Kunden (Nummer 7 Abs. 4). Die Plattformen, deren Kalender der Kunde einbindet (zum Beispiel Booking.com oder Airbnb), sind weder Auftragsverarbeiter noch Unterauftragsverarbeiter von WEBklar.
## 1. Gegenstand und Dauer
(1) WEBklar betreibt für die öffentliche Website des Kunden ein Buchungs-Widget mit einer Buchungsseite und einer Verwaltung im Kundenportal („Buchungskalender“) und verarbeitet dabei personenbezogene Daten der Gäste des Kunden im Auftrag des Kunden. Den Umfang beschreibt die Leistungsbeschreibung.
(2) Der Vertrag beginnt mit dem Einschalten der Funktion. Er endet mit dem Abschalten, spätestens mit dem Ende des Wartungs-Abos; den Buchungskalender schaltet WEBklar dann spätestens am folgenden Tag ab (Nr. 10 Abs. 1 der Leistungsbeschreibung). Die Pflichten aus diesem Vertrag gelten fort, bis alle Daten nach Nummer 9 gelöscht sind.
## 2. Art und Zweck der Verarbeitung
(1) Art: Übermitteln der Anfrage und der Buchung vom Widget und von der Buchungsseite an den Server von WEBklar, Speichern, Verwalten und Anzeigen im Kundenportal, Berechnen des Preises, Erzeugen von Rechnungen und Stornorechnungen im Namen des Kunden (Nr. 3 Abs. 6 der Leistungsbeschreibung), Abgleichen von Kalendern, Versenden von E-Mails, Übernehmen von Zahlungsstatus aus Stripe, Weitergeben von Erstattungen an Stripe im Auftrag des Kunden, Anonymisieren und Löschen.
(2) Zwecke: Der Kunde soll Anfragen und Buchungen seiner Gäste abwickeln. Dazu nimmt der Buchungskalender Anfragen entgegen, reserviert Zeiträume nach der Freigabe durch den Kunden, nimmt die verbindliche Buchung des Gastes entgegen, stellt die Rechnung aus, gleicht Online-Zahlungen mit Stripe ab, bildet Zahlungen vor Ort ab, bildet Stornierungen ab und benachrichtigt den Kunden. WEBklar versendet die E-Mails im Auftrag des Kunden. Außerdem schützt WEBklar den Buchungskalender für den Kunden vor Missbrauch (Mengenbegrenzung, Begrenzung der Anfragen je IP-Adresse, Erkennen automatisierter Anfragen).
(3) WEBklar verwendet die Daten für keine eigenen Zwecke, auch nicht in anonymisierter Form und nicht für Auswertungen über mehrere Kunden. Es findet keine Werbung und keine Zusammenführung mit anderen Daten statt. WEBklar bildet keine Profile einzelner Gäste. Zählungen ohne Personenbezug, die WEBklar für den sicheren Betrieb braucht (etwa die Zahl der anonymisierten Anfragen je Tag oder die Länge der Versandwarteschlange), sind keine eigenen Zwecke.
## 3. Datenarten und Betroffene
(1) Daten der Gäste, die WEBklar aus dem Widget, von der Buchungsseite und aus Stripe erhält und speichert:
- bei der Anfrage: Name, E-Mail-Adresse, auf Wunsch Firma, Telefonnummer und eine Nachricht, die gewünschte Unterkunft, der Zeitraum, die Zahl der Personen, die Anfragenummer, ein Hashwert der IP-Adresse (Absatz 4) und der Zeitpunkt;
- bei der Freigabe: die Posten und Summen des Preises (Text, Menge, Einzelbetrag, Steuersatz), die Reservierungsfrist, eine Nachricht des Kunden an den Gast, die Stornobedingungen, die galten, und die Fassung der Rechtstexte, der der Gast gesehen hat, jeweils als Schnappschuss;
- bei der Buchung: die Rechnungsadresse (Name, Firma, Straße, Postleitzahl, Ort, Land), die Zahlart, der Zeitpunkt der Buchung und die Rechnungsnummer;
- bei der Zahlung und der Erstattung: Status, Zeitpunkt, Art (online über Stripe oder vor Ort), die Kennungen der Zahlung bei Stripe, erstattete Beträge und der Grund einer Stornierung;
- die Rechnung und die Stornorechnung als unveränderlicher Schnappschuss (Angaben des Ausstellers, Empfänger, Unterkunft, Zeitraum, Posten, Summen, Steuerart, Bezug), aus dem WEBklar das PDF bei jedem Abruf erzeugt;
- ein Hashwert des geheimen Zugangs zur Buchungsseite (der Link in der E-Mail wird nur als Prüfsumme gespeichert);
- der Zustellstatus der E-Mails an den Gast.
**Kartendaten des Gastes erhält und speichert WEBklar nicht;** die gibt der Gast nur bei Stripe ein. Online-Zahlungen sind derzeit nur per Karte (einschließlich Apple Pay und Google Pay) möglich, nicht per SEPA-Lastschrift. Die Bankverbindung des Kunden selbst (Angabe auf der Rechnung) und seine Firmen- und Steuerangaben gehören nicht zu den Gästedaten.
(2) Von Kalendern anderer Plattformen übernimmt WEBklar nur die belegten Zeiträume (Datum von und bis), nicht Namen, Beschreibungen oder andere Angaben der dortigen Buchungen, auch wenn der Kalender sie enthält. Die iCal-Adresse, die WEBklar dem Kunden bereitstellt, enthält nur belegte Nächte, keine Angaben zu Gästen. Wer die geheime Adresse kennt, sieht die belegten Nächte; der Kunde kann sie jederzeit erneuern.
(3) Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht verarbeitet. Der Kunde fordert von seinen Gästen keine entsprechenden Angaben und bittet sie, solche Angaben nicht in das Nachrichtenfeld zu schreiben (Nummer 11 Abs. 4). Meldedaten nach dem Bundesmeldegesetz (Meldeschein) und Daten zur Kurtaxe verarbeitet der Buchungskalender nicht.
(4) IP-Adresse und Browser-Kennzeichen (User-Agent) erhält der Server von WEBklar technisch bei jedem Abruf des Widgets und jeder Anfrage. Die Mengenbegrenzung (Missbrauchsschutz) verwendet die IP-Adresse im Arbeitsspeicher des Servers; ein Neustart leert sie. Mit der Anfrage werden weder eine IP-Adresse im Klartext noch ein Browser-Kennzeichen gespeichert, sondern nur ein **Hashwert der IP-Adresse („IP-Hash“)**: ein SHA-256-Wert, den WEBklar mit einem geheimen Schlüssel des Servers salzt. Der IP-Hash ist ein pseudonymes technisches Datum und bleibt ein personenbezogenes Datum, weil WEBklar mit dem Schlüssel für dieselbe IP-Adresse denselben Wert berechnen kann; ohne den Schlüssel lässt sich aus ihm keine IP-Adresse ableiten. WEBklar verwendet ihn ausschließlich zum Missbrauchsschutz. Er dient nicht dazu, Gäste zu identifizieren, wiederzuerkennen oder zu profilieren, wird dem Kunden weder angezeigt noch exportiert und steht nur im Datensatz der Anfrage. WEBklar entfernt ihn aus dem Datensatz, sobald der Kunde die Anfrage freigibt oder ablehnt, spätestens nach 30 Tagen (Nummer 9 Abs. 1). In den Server-Protokollen stehen IP-Adresse und Browser-Kennzeichen 14 Tage (Anlage 2 der Abo-Bedingungen).
(5) Der Buchungskalender setzt keine Cookies. Das Widget speichert nichts im Browser. Auch die Buchungsseite legt weder Cookies noch Daten im lokalen oder im Sitzungsspeicher des Browsers ab.
(6) Betroffene: Gäste, die über die Website des Kunden Anfragen stellen oder buchen, und Personen, deren Angaben der Gast in der Anfrage oder Buchung macht (zum Beispiel Mitreisende oder abweichende Rechnungsempfänger). Beschäftigte des Kunden sind von diesem Vertrag nicht betroffen.
## 4. Weisungen
(1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, auch in Bezug auf eine Übermittlung in ein Drittland oder an eine internationale Organisation. Das gilt nicht, wenn das Recht der Europäischen Union oder Deutschlands WEBklar zu einer Verarbeitung verpflichtet. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es nicht verbietet.
(2) Mit dem Einschalten weist der Kunde WEBklar an, den Buchungskalender nach seinen Einstellungen und der Leistungsbeschreibung zu betreiben, in seinem Namen Rechnungen auszustellen und Zahlungen, die nach einer Stornierung oder nach dem Ablauf einer Reservierung eingehen, sowie zweite Zahlungen auf eine bereits bezahlte Buchung ohne weitere Anweisung in voller Höhe über sein Stripe-Konto zu erstatten (dauerhafte Weisung; der Stand der Buchung ändert sich dabei nicht). Bei einer Stornierung schließt WEBklar eine noch offene Zahlungsseite bei Stripe. Weitere Weisungen ergeben sich aus den Einstellungen und Aktionen im Kundenportal (Unterkünfte, Preise, Sperren, Kalenderverbindungen, Freigabe und Ablehnung von Anfragen, Zahlungseingang vor Ort, Stornierungen und Erstattungen, Online-Zahlung, Öffnen und Schließen des Buchungskalenders). Sonstige Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Weisungen, die über diese Einstellungen hinausgehen, setzt WEBklar nur um, wenn das technisch vorgesehen ist; sonst kann der Kunde den Buchungskalender abschalten lassen (Nr. 10 Abs. 7 der Leistungsbeschreibung). Die Anonymisierung und Löschung auf Weisung nach Nummer 8 Abs. 2 und Nummer 9 Abs. 6 setzt WEBklar immer um, soweit Aufbewahrungspflichten dem nicht entgegenstehen (Nummer 9 Abs. 8).
(3) Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin. WEBklar darf die Ausführung aussetzen, bis der Kunde die Weisung ändert oder die Rechtslage geklärt ist.
## 5. Vertraulichkeit
WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.
## 6. Technische und organisatorische Maßnahmen
(1) WEBklar trifft die Maßnahmen nach Art. 32 DSGVO, die in der Anlage zu diesem Vertrag beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt.
(2) Für den Server gelten ergänzend die Maßnahmen der Anlage 2 der Abo-Bedingungen, soweit die Anlage zu diesem Vertrag nichts anderes regelt.
## 7. Unterauftragsverarbeiter
(1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Buchungskalender läuft, und für dessen Sicherung (Rechenzentrum Falkenstein, Deutschland) sowie für den Versand der E-Mails über den SMTP-Dienst des Webhostings (Rechenzentrum Nürnberg, Deutschland). Eine Übermittlung in Drittländer findet nicht statt.
(2) Will WEBklar einen weiteren Unterauftragsverarbeiter hinzuziehen oder einen ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen. WEBklar setzt den neuen Unterauftragsverarbeiter nicht vor Ablauf dieser Frist ein. Kündigt ein Unterauftragsverarbeiter von WEBklar eine eigene Änderung mit kürzerer Frist an, informiert WEBklar den Kunden unverzüglich, nachdem die Ankündigung eingegangen ist; der Kunde kann dann unverzüglich widersprechen. Einigen sich die Parteien nicht, kann der Kunde den Buchungskalender abschalten lassen und WEBklar ihn für diese Website zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt wird. Die Daten werden dann nach Nummer 9 gelöscht, auf Weisung des Kunden früher (Nummer 9 Abs. 6).
(3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf die Datenschutzpflichten nach Art. 28 Abs. 4 DSGVO. Bei der Hetzner Online GmbH ist das deren Vertrag zur Auftragsverarbeitung, den WEBklar abgeschlossen hat. Kommt ein Unterauftragsverarbeiter seinen Datenschutzpflichten nicht nach, haftet WEBklar dem Kunden für die Einhaltung dieser Pflichten (Art. 28 Abs. 4 Satz 2 DSGVO).
(4) **Stripe ist kein Unterauftragsverarbeiter von WEBklar.** Der Kunde verwendet sein eigenes Stripe-Konto und hat den Vertrag darüber unmittelbar mit Stripe geschlossen (Nr. 3 der Leistungsbeschreibung); die Zahlung des Gastes fließt direkt auf dieses Konto. Die Zahlungsseite (Stripe Checkout), die Erhebung von Zahlungs- und Kartendaten (derzeit nur Karte, einschließlich Apple Pay und Google Pay), die Prüfung von Zahlungen und die Auszahlungen verantworten der Kunde und Stripe nach ihrem Vertrag; WEBklar erhält diese Daten nicht. Auf Weisung des Kunden legt WEBklar bei Stripe die Zahlung an, ruft die Angaben zu Zahlungen ab, die der Buchungskalender braucht (Nummer 2 Abs. 1), schließt offene Zahlungsseiten und veranlasst Erstattungen (auch die automatischen nach Nummer 4 Abs. 2). An Stripe gehen dabei von WEBklar nur: die Bezeichnung des Aufenthalts mit Rechnungsnummer als Zahlungsposition, der Betrag, die Kennungen der Buchung und der Zahlung (Metadaten), die E-Mail-Adresse des Gastes zur Vorbelegung der Zahlungsseite, soweit WEBklar sie übergibt, die Erstattung und der **geheime Link zur Buchungsseite mit dem Token als Rücksprungadresse** (bei Erfolg und bei Abbruch). Dieser Link ist damit für Stripe und im Stripe-Dashboard des Kunden sichtbar; Der Link gilt bis 90 Tage nach der Abreise (bei nicht zustande gekommenen Buchungen 30 Tage) und öffnet nur diese eine Buchungsseite. Die Weitergabe von Daten durch Stripe, auch an Stellen außerhalb der Europäischen Union, regeln der Kunde und Stripe; dafür ist WEBklar nicht verantwortlich.
(5) Plattformen, deren iCal-Kalender der Kunde einbindet oder denen er die iCal-Adresse des Buchungskalenders mitteilt, sind Empfänger nach seiner Entscheidung. WEBklar übermittelt ihnen keine Daten von Gästen.
## 8. Unterstützung des Kunden, Betroffenenrechte
(1) WEBklar leitet Anfragen von Gästen, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter. WEBklar unterstützt ihn mit geeigneten technischen und organisatorischen Maßnahmen bei der Beantwortung.
(2) Dafür bietet das Kundenportal die Anzeige der Anfragen und Buchungen, mit denen der Kunde Auskunft erteilen kann, und den Abruf der Rechnungen als PDF. Auf Weisung des Kunden in Textform (etwa im Chat des Kundenportals) anonymisiert WEBklar die Gästedaten einer Anfrage oder Buchung unverzüglich: Name, Firma, E-Mail-Adresse, Telefonnummer und die Freitexte werden gelöscht oder unkenntlich gemacht (ein IP-Hash ist zu diesem Zeitpunkt in der Regel längst entfernt); Anfragenummer, Zeitraum, Beträge und Status bleiben. Rechnungen und Rechnungsadresse einer gebuchten Anfrage bleiben, solange eine Aufbewahrungspflicht besteht (Nummer 9 Abs. 3). Dabei löscht WEBklar die E-Mails zu dieser Anfrage, die noch auf den Versand warten. E-Mails, deren Versand endgültig fehlgeschlagen ist, behalten Empfängeradresse und Inhalt, bis WEBklar sie nach Nummer 9 Abs. 5 löscht (48 Stunden nach dem Fehlschlag).
(3) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.
(4) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO. Dazu gehören die Angaben, die er für sein Verzeichnis von Verarbeitungstätigkeiten und für eine etwaige Datenschutz-Folgenabschätzung braucht.
(5) Die Gäste informiert der Kunde (Art. 13 DSGVO; Nummer 11 Abs. 2).
## 9. Löschung und Rückgabe
(1) Den IP-Hash entfernt WEBklar aus dem Datensatz einer Anfrage, sobald der Kunde sie freigibt oder ablehnt, spätestens 30 Tage nach dem Anlegen.
(2) Anfragen, aus denen keine Buchung wurde (abgelehnt, abgelaufen, oder neu und nicht innerhalb von 60 Tagen entschieden), anonymisiert WEBklar nach 6 Monaten: Name, Firma, E-Mail-Adresse, Telefonnummer und Nachricht werden gelöscht. Anfragenummer, Zeitraum, Status und, soweit vorhanden, der Preisvorschlag bleiben ohne Personenbezug erhalten.
(3) Bei gebuchten Aufenthalten anonymisiert WEBklar die Gästedaten (Name, Firma, E-Mail-Adresse, Telefonnummer und Nachricht) 3 Jahre nach dem Ende des Kalenderjahres, in dem der Gast abgereist ist (Abreisejahr; maßgeblich ist die Zeit in Berlin). **Rechnungen, Stornorechnungen und die Rechnungsadresse bleiben 8 Jahre erhalten**, gerechnet ab dem Ende des Kalenderjahres der Rechnung, solange der Buchungskalender läuft (Buchungsbelege, § 147 AO); sie sind für den Kunden als Aussteller bestimmt. Anfragenummer, Zeitraum, Beträge und Status bleiben ebenfalls.
(4) Die Anonymisierung nach den Absätzen 2 und 3 sowie die Entfernung des IP-Hash laufen im täglichen Löschlauf; anonymisiert oder entfernt wird frühestens nach Ablauf der jeweiligen Frist. Ist an einem Tag mehr zu bearbeiten, als ein Lauf schafft, folgt der Rest im nächsten Lauf.
(5) In der Versandwarteschlange für E-Mails leert WEBklar die Empfängeradresse und den Inhalt einer E-Mail nach dem Versand und löscht den Eintrag 7 Tage nach dem Versand. E-Mails, die nicht zugestellt werden können, löscht WEBklar mit Empfängeradresse und Inhalt 48 Stunden nach dem endgültigen Fehlschlag.
(6) Ist der Buchungskalender abgeschaltet oder das Wartungs-Abo beendet, kann der Kunde die Anfragen, Buchungen und Rechnungen 90 Tage lang im Kundenportal lesen und die Rechnungen einzeln als PDF herunterladen; WEBklar weist ihn nach dem Abschalten per E-Mail mit dem Löschdatum darauf hin, seine Rechnungen zu sichern. Danach löscht WEBklar alle Daten des Buchungskalenders: Unterkünfte, Preise, Anfragen, Buchungen, Rechnungen, Kalenderverbindungen, Sperren und Einstellungen. Damit endet auch die Aufbewahrung der Rechnungen nach Absatz 3; sie bewahrt der Kunde dann selbst auf. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Angaben zur Einrichtung bestehen (zum Beispiel Adresse der Website, Einbau-Code und die Angaben des Kunden aus der Anfrage) und enthält keine Daten von Gästen; dafür gilt die Datenschutzerklärung von WEBklar (Abschnitt 6). Beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schaltet WEBklar den Buchungskalender auf Wunsch des Kunden mit den vorhandenen Daten wieder ein (Nr. 10 Abs. 5 der Leistungsbeschreibung). Früher anonymisiert WEBklar einzelne Daten auf Weisung des Kunden (Nummer 8 Abs. 2); Wird das Kundenkonto gelöscht, schaltet WEBklar den Buchungskalender ab und löscht seine Daten wie nach Absatz 6 (bei Abschaltung 90 Tage später); einen Hinweis per E-Mail gibt es dann nicht, weil mit dem Konto auch die E-Mail-Adresse gelöscht wird. alle Daten des Buchungskalenders löscht WEBklar auf Weisung des Kunden in Textform unverzüglich.
(7) Die Daten sind in der täglichen Vollsicherung des Servers enthalten (§ 3 Abs. 5 der Abo-Bedingungen). Gelöschte oder anonymisierte Daten bleiben dort bis zu sieben Tage erhalten und werden dann überschrieben. Wird eine Sicherung zurückgespielt, holt der tägliche Löschlauf die fälligen Löschungen und Anonymisierungen nach; Löschungen auf Weisung des Kunden wiederholt WEBklar.
(8) Gesetzliche Aufbewahrungspflichten bleiben unberührt. Der Kunde ist für seine eigenen Aufbewahrungspflichten (zum Beispiel für Rechnungen und Buchungsbelege) selbst verantwortlich; WEBklar ist kein Archiv im Sinne der Buchführung. Die Aufbewahrung der Rechnungen nach Absatz 3 geschieht als Dienst für den Kunden und für die Dauer des Buchungskalenders. Der Kunde sichert die Belege, die er braucht, rechtzeitig vor der Löschung nach Absatz 6 durch den Download. Soweit eine gesetzliche Aufbewahrungspflicht besteht, löscht oder anonymisiert WEBklar auch auf Weisung des Kunden nicht früher; stattdessen sperrt es die Daten für die weitere Verarbeitung.
## 10. Nachweise und Kontrollen
WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Zertifizierung der Hetzner Online GmbH nach ISO/IEC 27001). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.
## 11. Pflichten des Kunden
(1) Der Kunde ist Verantwortlicher für die Verarbeitung der Daten seiner Gäste im Buchungskalender.
(2) Der Kunde informiert seine Gäste nach Art. 13 DSGVO. Dazu nimmt er den Datenschutz-Absatz für den Buchungskalender in seine Datenschutzerklärung auf (Nr. 5 Abs. 10 der Leistungsbeschreibung) und nennt in seinen Rechtstexten (Datenschutzerklärung, Impressum) die Angaben, die das Gesetz verlangt.
(3) Der Kunde schließt mit Stripe den Vertrag über sein Stripe-Konto selbst, wenn er die Online-Zahlung nutzt, und verantwortet die Verarbeitung von Daten durch Stripe, soweit sie nicht in diesem Vertrag geregelt ist (Nummer 7 Abs. 4).
(4) Der Kunde fordert von seinen Gästen keine Angaben zu ihrer Gesundheit oder zu anderen besonderen Kategorien personenbezogener Daten und weist sie auf der Website darauf hin, solche Angaben nicht in das Nachrichtenfeld zu schreiben.
(5) Der Kunde verändert den Einbau-Code und die gesetzlich vorgeschriebenen Hinweise des Widgets und der Buchungsseite nicht (Nr. 5 Abs. 9 der Leistungsbeschreibung).
(6) Der Kunde erfüllt seine Pflichten zu Meldeschein, Kurtaxe und sonstigen Meldungen an Behörden selbst und verarbeitet die dafür nötigen Daten außerhalb des Buchungskalenders (Nr. 5 Abs. 6 der Leistungsbeschreibung).
## 12. Haftung, Änderungen, Schlussbestimmungen
(1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen.
(2) Eine neue Fassung dieses Vertrags oder der Leistungsbeschreibung, auch wegen geänderter Datenerhebung, teilt WEBklar dem Kunden mindestens sechs Wochen vor ihrem Wirksamwerden in Textform mit. Sie wird wirksam, wenn der Kunde ihr im Kundenportal oder in Textform zustimmt. Stimmt er nicht zu, gilt die bisherige Fassung weiter.
(3) Ist die neue Fassung nötig, weil ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form nicht mehr zulässt, und stimmt der Kunde ihr nicht zu, darf WEBklar den Buchungskalender für seine Website nach Nr. 12 Abs. 2 der Leistungsbeschreibung beenden oder nach Nr. 12 Abs. 3 der Leistungsbeschreibung aussetzen. Das Abo bleibt davon unberührt.
(4) Im Übrigen gilt § 16 der Abo-Bedingungen.
## Anlage: Technische und organisatorische Maßnahmen des Buchungskalenders (Art. 32 DSGVO)
| Bereich | Maßnahmen |
|---|---|
| Übertragung | Das Widget, die Buchungsseite und das Kundenportal werden nur über TLS (HTTPS) übertragen. Der Einbau-Code verweist auf eine Adresse mit https://. Die Zahlung geschieht auf der Zahlungsseite von Stripe. Kalender anderer Plattformen ruft WEBklar nur über https ab. |
| Speicher im Browser | Es werden keine Cookies gesetzt. Das Widget sendet seine Anfragen ohne Cookies und ruft nur die Schnittstelle des Buchungskalenders auf, keine Dienste Dritter. |
| Zugriffskontrolle | Das Kundenportal ist nur nach Anmeldung erreichbar; jeder Kunde sieht nur die Daten seiner eigenen Projekte. Die öffentliche Schnittstelle des Widgets liefert nur für einen vorhandenen Buchungskalender Daten und ist über den Website-Schlüssel (Site-Key) adressiert; die belegten Nächte nennt sie ohne Quelle und ohne Daten von Gästen. Die Buchungsseite ruft der Gast nur mit einem geheimen Token ab, das nur als Prüfsumme gespeichert und zeitkonstant verglichen wird. Der Token wird nur als Prüfsumme gespeichert; er wird nur als Teil der Rücksprungadresse an Stripe übergeben (Nummer 7 Abs. 4) und öffnet nur diese eine Buchungsseite; er gilt bis 90 Tage nach der Abreise (bei nicht zustande gekommenen Buchungen 30 Tage), danach antwortet die Buchungsseite wie bei einem unbekannten Link. Benachrichtigungen von Stripe werden nur nach Prüfung ihrer Signatur verarbeitet; die Schlüssel für Stripe liegen nur auf dem Server. Die iCal-Adresse des Buchungskalenders ist ein geheimer, erneuerbarer Zufallswert. |
| Missbrauchsschutz | Ein verstecktes Feld (Honeypot), eine Mindestzeit bis zum Absenden des Formulars mit signiertem Formular-Token und Grenzen je IP-Adresse und je Buchungskalender schützen vor automatisierten Anfragen. Die Grenzen gelten im Arbeitsspeicher; ein Neustart leert sie. Mit der Anfrage wird nur ein gesalzener IP-Hash gespeichert (Nummer 3 Abs. 4). Beim Abruf von Kalendern anderer Plattformen sind nur Adressen mit https erlaubt, private und lokale Ziele sind gesperrt, und Größe und Dauer des Abrufs sind begrenzt. |
| Datenminimierung | Kartendaten erhält und speichert WEBklar nicht. Preise berechnet der Server aus den gespeicherten Preisen und Posten, nie aus Angaben des Browsers. Von anderen Kalendern werden nur Zeiträume übernommen. Mit der Anfrage werden keine IP-Adresse im Klartext und kein Browser-Kennzeichen gespeichert; der IP-Hash wird mit der Entscheidung des Kunden entfernt. Die Gästedaten werden nach den Fristen der Nummer 9 anonymisiert. Nach dem Versand einer E-Mail werden Empfängeradresse und Inhalt in der Warteschlange geleert. |
| Integrität der Rechnungen | Rechnungen und Stornorechnungen werden als unveränderlicher Schnappschuss gespeichert; das PDF entsteht bei jedem Abruf aus diesem Schnappschuss. Rechnungsnummern werden unter Sperre lückenlos je Kunde und Jahr vergeben. Freigabe und Buchung prüfen unter Sperre erneut, dass der Zeitraum frei ist. |
| Trennung | Alle Unterkünfte, Anfragen und Rechnungen tragen die Kennung des Kunden, des Projekts und der Funktion; das Kundenportal und die öffentliche Schnittstelle lesen immer nur die Daten des jeweiligen Buchungskalenders. Die Hintergrundläufe (Ablauf von Reservierungen, Kalenderabgleich, Versand der E-Mails, Anonymisierung und Löschung) schreiben jeweils nur in das Dokument, um das es geht. |
| Löschung | Täglicher Löschlauf für Anfragen, Buchungen und Warteschlange nach den Fristen der Nummer 9; die Daten abgeschalteter Buchungskalender 90 Tage nach dem Abschalten; das Einrichtungs-Ticket bleibt als Arbeitsnachweis mit einer Kurzfassung der Angaben zur Einrichtung bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6). |
| Protokolle | Die Programmprotokolle des Buchungskalenders und der Tagesbericht enthalten nur Zählungen und Anfragenummern, keine Namen, E-Mail-Adressen oder Adressen von Gästen. Server-Protokolle des Webservers werden nach 14 Tagen gelöscht (Anlage 2 der Abo-Bedingungen). |
| Datensicherung | Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen). |
| Rechenzentrum | Hetzner Online GmbH, Rechenzentren Falkenstein und Nürnberg (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert; der Geltungsbereich umfasst die Hosting-Dienstleistungen und die Rechenzentren. |
| Organisation | Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nummer 8 Abs. 3), Überprüfung der Maßnahmen mindestens einmal im Jahr. |
+157
View File
@@ -0,0 +1,157 @@
# Auftragsverarbeitungsvertrag Buchungskalender (Art. 28 DSGVO)
**Fassung 2026-10-07** · Entwurf – Prüfung durch Kanzlei offen
zwischen dem Kunden als Verantwortlichem („Kunde“; in der Leistungsbeschreibung „Vermieter“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“).
## Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen
(1) Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Einrichtung des Buchungskalenders bestellt und dabei per Haken diesem Vertrag und der Leistungsbeschreibung „Buchungskalender“ („Leistungsbeschreibung“) in der angezeigten Fassung zustimmt. Das Portal speichert die Fassung, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte und die Widerrufsbelehrung in dieser Fassung als PDF enthält und auf sie verlinkt.
(2) Ein Mitarbeiter von WEBklar darf die Funktion auch ohne Bestellung im Portal einschalten (zum Beispiel für einen Kunden, dem WEBklar sie als Teil seines Website-Projekts bereitstellt), aber nur, wenn der Kunde vorher in Textform (etwa im Chat des Kundenportals oder per E-Mail) erklärt hat, dass er diesem Vertrag und der Leistungsbeschreibung in der aktuellen Fassung zustimmt. Das Portal verlangt dafür das Datum und den Weg der Erklärung; fehlt eine Angabe, schaltet es nicht ein. WEBklar speichert vor dem Einschalten einen Zustimmungsnachweis mit der Fassung der beiden Texte, dem Datum und dem Weg dieser Erklärung, dem Zeitpunkt des Einschaltens und dem Namen des Mitarbeiters (ohne IP-Adresse und Browser-Kennzeichen, weil der Kunde nicht im Portal zugestimmt hat). Der Kunde erhält anschließend eine E-Mail mit diesen beiden Texten als PDF.
(3) Dieser Vertrag ist ein eigener Vertrag für den Buchungskalender. Er ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Für den Buchungskalender geht er Anlage 1 vor; im Übrigen gilt Anlage 1 ergänzend. In Datenschutzfragen geht er den Abo-Bedingungen und der Leistungsbeschreibung vor.
(4) Die Rollen: Der Kunde ist Verantwortlicher für die personenbezogenen Daten seiner Gäste im Buchungskalender. WEBklar ist sein Auftragsverarbeiter. Stripe ist nicht Auftragsverarbeiter von WEBklar, sondern Vertragspartner des Kunden (Nummer 7 Abs. 4). Die Plattformen, deren Kalender der Kunde einbindet (zum Beispiel Booking.com oder Airbnb), sind weder Auftragsverarbeiter noch Unterauftragsverarbeiter von WEBklar.
## 1. Gegenstand und Dauer
(1) WEBklar betreibt für die öffentliche Website des Kunden ein Buchungs-Widget mit einer Buchungsseite und einer Verwaltung im Kundenportal („Buchungskalender“) und verarbeitet dabei personenbezogene Daten der Gäste des Kunden im Auftrag des Kunden. Den Umfang beschreibt die Leistungsbeschreibung.
(2) Der Vertrag beginnt mit dem Einschalten der Funktion. Er endet mit dem Abschalten, spätestens mit dem Ende des Vertrags, aufgrund dessen WEBklar die Website des Kunden betreibt; den Buchungskalender schaltet WEBklar dann zum Vertragsende ab (Nr. 10 Abs. 1 der Leistungsbeschreibung). Die Pflichten aus diesem Vertrag gelten fort, bis alle Daten nach Nummer 9 gelöscht sind.
## 2. Art und Zweck der Verarbeitung
(1) Art: Übermitteln der Anfrage und der Buchung vom Widget und von der Buchungsseite an den Server von WEBklar, Speichern, Verwalten und Anzeigen im Kundenportal, Berechnen des Preises, Erzeugen von Rechnungen und Stornorechnungen im Namen des Kunden (Nr. 3 Abs. 6 der Leistungsbeschreibung), Abgleichen von Kalendern, Versenden von E-Mails, Übernehmen von Zahlungsstatus aus Stripe, Weitergeben von Erstattungen an Stripe im Auftrag des Kunden, Anonymisieren und Löschen.
(2) Zwecke: Der Kunde soll Anfragen und Buchungen seiner Gäste abwickeln. Dazu nimmt der Buchungskalender Anfragen entgegen, reserviert Zeiträume nach der Freigabe durch den Kunden, nimmt die verbindliche Buchung des Gastes entgegen, stellt die Rechnung aus, gleicht Online-Zahlungen mit Stripe ab, bildet Zahlungen vor Ort ab, bildet Stornierungen ab und benachrichtigt den Kunden. WEBklar versendet die E-Mails im Auftrag des Kunden. Außerdem schützt WEBklar den Buchungskalender für den Kunden vor Missbrauch (Mengenbegrenzung, Begrenzung der Anfragen je IP-Adresse, Erkennen automatisierter Anfragen).
(3) WEBklar verwendet die Daten für keine eigenen Zwecke, auch nicht in anonymisierter Form und nicht für Auswertungen über mehrere Kunden. Es findet keine Werbung und keine Zusammenführung mit anderen Daten statt. WEBklar bildet keine Profile einzelner Gäste. Zählungen ohne Personenbezug, die WEBklar für den sicheren Betrieb braucht (etwa die Zahl der anonymisierten Anfragen je Tag oder die Länge der Versandwarteschlange), sind keine eigenen Zwecke.
## 3. Datenarten und Betroffene
(1) Daten der Gäste, die WEBklar aus dem Widget, von der Buchungsseite und aus Stripe erhält und speichert:
- bei der Anfrage: Name, E-Mail-Adresse, auf Wunsch Firma, Telefonnummer und eine Nachricht, die gewünschte Unterkunft, der Zeitraum, die Zahl der Personen, die Anfragenummer, ein Hashwert der IP-Adresse (Absatz 4) und der Zeitpunkt;
- bei der Freigabe: die Posten und Summen des Preises (Text, Menge, Einzelbetrag, Steuersatz), die Reservierungsfrist, eine Nachricht des Kunden an den Gast, die Stornobedingungen, die galten, und die Fassung der Rechtstexte, der der Gast gesehen hat, jeweils als Schnappschuss;
- bei der Buchung: die Rechnungsadresse (Name, Firma, Straße, Postleitzahl, Ort, Land), die Zahlart, der Zeitpunkt der Buchung und die Rechnungsnummer;
- bei der Zahlung und der Erstattung: Status, Zeitpunkt, Art (online über Stripe oder vor Ort), die Kennungen der Zahlung bei Stripe, erstattete Beträge und der Grund einer Stornierung;
- die Rechnung und die Stornorechnung als unveränderlicher Schnappschuss (Angaben des Ausstellers, Empfänger, Unterkunft, Zeitraum, Posten, Summen, Steuerart, Bezug), aus dem WEBklar das PDF bei jedem Abruf erzeugt;
- ein Hashwert des geheimen Zugangs zur Buchungsseite (der Link in der E-Mail wird nur als Prüfsumme gespeichert);
- der Zustellstatus der E-Mails an den Gast.
**Kartendaten des Gastes erhält und speichert WEBklar nicht;** die gibt der Gast nur bei Stripe ein. Online-Zahlungen sind derzeit nur per Karte (einschließlich Apple Pay und Google Pay) möglich, nicht per SEPA-Lastschrift. Die Bankverbindung des Kunden selbst (Angabe auf der Rechnung) und seine Firmen- und Steuerangaben gehören nicht zu den Gästedaten.
(2) Von Kalendern anderer Plattformen übernimmt WEBklar nur die belegten Zeiträume (Datum von und bis), nicht Namen, Beschreibungen oder andere Angaben der dortigen Buchungen, auch wenn der Kalender sie enthält. Die iCal-Adresse, die WEBklar dem Kunden bereitstellt, enthält nur belegte Nächte, keine Angaben zu Gästen. Wer die geheime Adresse kennt, sieht die belegten Nächte; der Kunde kann sie jederzeit erneuern.
(3) Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht verarbeitet. Der Kunde fordert von seinen Gästen keine entsprechenden Angaben und bittet sie, solche Angaben nicht in das Nachrichtenfeld zu schreiben (Nummer 11 Abs. 4). Meldedaten nach dem Bundesmeldegesetz (Meldeschein) und Daten zur Kurtaxe verarbeitet der Buchungskalender nicht.
(4) IP-Adresse und Browser-Kennzeichen (User-Agent) erhält der Server von WEBklar technisch bei jedem Abruf des Widgets und jeder Anfrage. Die Mengenbegrenzung (Missbrauchsschutz) verwendet die IP-Adresse im Arbeitsspeicher des Servers; ein Neustart leert sie. Mit der Anfrage werden weder eine IP-Adresse im Klartext noch ein Browser-Kennzeichen gespeichert, sondern nur ein **Hashwert der IP-Adresse („IP-Hash“)**: ein SHA-256-Wert, den WEBklar mit einem geheimen Schlüssel des Servers salzt. Der IP-Hash ist ein pseudonymes technisches Datum und bleibt ein personenbezogenes Datum, weil WEBklar mit dem Schlüssel für dieselbe IP-Adresse denselben Wert berechnen kann; ohne den Schlüssel lässt sich aus ihm keine IP-Adresse ableiten. WEBklar verwendet ihn ausschließlich zum Missbrauchsschutz. Er dient nicht dazu, Gäste zu identifizieren, wiederzuerkennen oder zu profilieren, wird dem Kunden weder angezeigt noch exportiert und steht nur im Datensatz der Anfrage. WEBklar entfernt ihn aus dem Datensatz, sobald der Kunde die Anfrage freigibt oder ablehnt, spätestens nach 30 Tagen (Nummer 9 Abs. 1). In den Server-Protokollen stehen IP-Adresse und Browser-Kennzeichen 14 Tage (Anlage 2 der Abo-Bedingungen).
(5) Der Buchungskalender setzt keine Cookies. Das Widget speichert nichts im Browser. Auch die Buchungsseite legt weder Cookies noch Daten im lokalen oder im Sitzungsspeicher des Browsers ab.
(6) Betroffene: Gäste, die über die Website des Kunden Anfragen stellen oder buchen, und Personen, deren Angaben der Gast in der Anfrage oder Buchung macht (zum Beispiel Mitreisende oder abweichende Rechnungsempfänger). Beschäftigte des Kunden sind von diesem Vertrag nicht betroffen.
## 4. Weisungen
(1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, auch in Bezug auf eine Übermittlung in ein Drittland oder an eine internationale Organisation. Das gilt nicht, wenn das Recht der Europäischen Union oder Deutschlands WEBklar zu einer Verarbeitung verpflichtet. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es nicht verbietet.
(2) Mit der Bestellung weist der Kunde WEBklar an, den Buchungskalender nach seinen Einstellungen und der Leistungsbeschreibung zu betreiben, in seinem Namen Rechnungen auszustellen und Zahlungen, die nach einer Stornierung oder nach dem Ablauf einer Reservierung eingehen, sowie zweite Zahlungen auf eine bereits bezahlte Buchung ohne weitere Anweisung in voller Höhe über sein Stripe-Konto zu erstatten (dauerhafte Weisung; der Stand der Buchung ändert sich dabei nicht). Bei einer Stornierung schließt WEBklar eine noch offene Zahlungsseite bei Stripe. Weitere Weisungen ergeben sich aus den Einstellungen und Aktionen im Kundenportal (Unterkünfte, Preise, Sperren, Kalenderverbindungen, Freigabe und Ablehnung von Anfragen, Zahlungseingang vor Ort, Stornierungen und Erstattungen, Online-Zahlung, Öffnen und Schließen des Buchungskalenders). Sonstige Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Weisungen, die über diese Einstellungen hinausgehen, setzt WEBklar nur um, wenn das technisch vorgesehen ist; sonst kann der Kunde den Buchungskalender abschalten lassen (Nr. 10 Abs. 7 der Leistungsbeschreibung). Die Anonymisierung und Löschung auf Weisung nach Nummer 8 Abs. 2 und Nummer 9 Abs. 6 setzt WEBklar immer um, soweit Aufbewahrungspflichten dem nicht entgegenstehen (Nummer 9 Abs. 8).
(3) Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin. WEBklar darf die Ausführung aussetzen, bis der Kunde die Weisung ändert oder die Rechtslage geklärt ist.
## 5. Vertraulichkeit
WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.
## 6. Technische und organisatorische Maßnahmen
(1) WEBklar trifft die Maßnahmen nach Art. 32 DSGVO, die in der Anlage zu diesem Vertrag beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt.
(2) Für den Server gelten ergänzend die Maßnahmen der Anlage 2 der Abo-Bedingungen, soweit die Anlage zu diesem Vertrag nichts anderes regelt.
## 7. Unterauftragsverarbeiter
(1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Buchungskalender läuft, und für dessen Sicherung (Rechenzentrum Falkenstein, Deutschland) sowie für den Versand der E-Mails über den SMTP-Dienst des Webhostings (Rechenzentrum Nürnberg, Deutschland). Eine Übermittlung in Drittländer findet nicht statt.
(2) Will WEBklar einen weiteren Unterauftragsverarbeiter hinzuziehen oder einen ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen. WEBklar setzt den neuen Unterauftragsverarbeiter nicht vor Ablauf dieser Frist ein. Kündigt ein Unterauftragsverarbeiter von WEBklar eine eigene Änderung mit kürzerer Frist an, informiert WEBklar den Kunden unverzüglich, nachdem die Ankündigung eingegangen ist; der Kunde kann dann unverzüglich widersprechen. Einigen sich die Parteien nicht, kann der Kunde den Buchungskalender abschalten lassen und WEBklar ihn für diese Website zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt wird. Die Daten werden dann nach Nummer 9 gelöscht, auf Weisung des Kunden früher (Nummer 9 Abs. 6).
(3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf die Datenschutzpflichten nach Art. 28 Abs. 4 DSGVO. Bei der Hetzner Online GmbH ist das deren Vertrag zur Auftragsverarbeitung, den WEBklar abgeschlossen hat. Kommt ein Unterauftragsverarbeiter seinen Datenschutzpflichten nicht nach, haftet WEBklar dem Kunden für die Einhaltung dieser Pflichten (Art. 28 Abs. 4 Satz 2 DSGVO).
(4) **Stripe ist kein Unterauftragsverarbeiter von WEBklar.** Der Kunde verwendet sein eigenes Stripe-Konto und hat den Vertrag darüber unmittelbar mit Stripe geschlossen (Nr. 3 der Leistungsbeschreibung); die Zahlung des Gastes fließt direkt auf dieses Konto. Die Zahlungsseite (Stripe Checkout), die Erhebung von Zahlungs- und Kartendaten (derzeit nur Karte, einschließlich Apple Pay und Google Pay), die Prüfung von Zahlungen und die Auszahlungen verantworten der Kunde und Stripe nach ihrem Vertrag; WEBklar erhält diese Daten nicht. Auf Weisung des Kunden legt WEBklar bei Stripe die Zahlung an, ruft die Angaben zu Zahlungen ab, die der Buchungskalender braucht (Nummer 2 Abs. 1), schließt offene Zahlungsseiten und veranlasst Erstattungen (auch die automatischen nach Nummer 4 Abs. 2). An Stripe gehen dabei von WEBklar nur: die Bezeichnung des Aufenthalts mit Rechnungsnummer als Zahlungsposition, der Betrag, die Kennungen der Buchung und der Zahlung (Metadaten), die E-Mail-Adresse des Gastes zur Vorbelegung der Zahlungsseite, soweit WEBklar sie übergibt, die Erstattung und der **geheime Link zur Buchungsseite mit dem Token als Rücksprungadresse** (bei Erfolg und bei Abbruch). Dieser Link ist damit für Stripe und im Stripe-Dashboard des Kunden sichtbar; Der Link gilt bis 90 Tage nach der Abreise (bei nicht zustande gekommenen Buchungen 30 Tage) und öffnet nur diese eine Buchungsseite. Die Weitergabe von Daten durch Stripe, auch an Stellen außerhalb der Europäischen Union, regeln der Kunde und Stripe; dafür ist WEBklar nicht verantwortlich.
(5) Plattformen, deren iCal-Kalender der Kunde einbindet oder denen er die iCal-Adresse des Buchungskalenders mitteilt, sind Empfänger nach seiner Entscheidung. WEBklar übermittelt ihnen keine Daten von Gästen.
## 8. Unterstützung des Kunden, Betroffenenrechte
(1) WEBklar leitet Anfragen von Gästen, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter. WEBklar unterstützt ihn mit geeigneten technischen und organisatorischen Maßnahmen bei der Beantwortung.
(2) Dafür bietet das Kundenportal die Anzeige der Anfragen und Buchungen, mit denen der Kunde Auskunft erteilen kann, und den Abruf der Rechnungen als PDF. Auf Weisung des Kunden in Textform (etwa im Chat des Kundenportals) anonymisiert WEBklar die Gästedaten einer Anfrage oder Buchung unverzüglich: Name, Firma, E-Mail-Adresse, Telefonnummer und die Freitexte werden gelöscht oder unkenntlich gemacht (ein IP-Hash ist zu diesem Zeitpunkt in der Regel längst entfernt); Anfragenummer, Zeitraum, Beträge und Status bleiben. Rechnungen und Rechnungsadresse einer gebuchten Anfrage bleiben, solange eine Aufbewahrungspflicht besteht (Nummer 9 Abs. 3). Dabei löscht WEBklar die E-Mails zu dieser Anfrage, die noch auf den Versand warten. E-Mails, deren Versand endgültig fehlgeschlagen ist, behalten Empfängeradresse und Inhalt, bis WEBklar sie nach Nummer 9 Abs. 5 löscht (48 Stunden nach dem Fehlschlag).
(3) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.
(4) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO. Dazu gehören die Angaben, die er für sein Verzeichnis von Verarbeitungstätigkeiten und für eine etwaige Datenschutz-Folgenabschätzung braucht.
(5) Die Gäste informiert der Kunde (Art. 13 DSGVO; Nummer 11 Abs. 2).
## 9. Löschung und Rückgabe
(1) Den IP-Hash entfernt WEBklar aus dem Datensatz einer Anfrage, sobald der Kunde sie freigibt oder ablehnt, spätestens 30 Tage nach dem Anlegen.
(2) Anfragen, aus denen keine Buchung wurde (abgelehnt, abgelaufen, oder neu und nicht innerhalb von 60 Tagen entschieden), anonymisiert WEBklar nach 6 Monaten: Name, Firma, E-Mail-Adresse, Telefonnummer und Nachricht werden gelöscht. Anfragenummer, Zeitraum, Status und, soweit vorhanden, der Preisvorschlag bleiben ohne Personenbezug erhalten.
(3) Bei gebuchten Aufenthalten anonymisiert WEBklar die Gästedaten (Name, Firma, E-Mail-Adresse, Telefonnummer und Nachricht) 3 Jahre nach dem Ende des Kalenderjahres, in dem der Gast abgereist ist (Abreisejahr; maßgeblich ist die Zeit in Berlin). **Rechnungen, Stornorechnungen und die Rechnungsadresse bleiben 8 Jahre erhalten**, gerechnet ab dem Ende des Kalenderjahres der Rechnung, solange der Buchungskalender läuft (Buchungsbelege, § 147 AO); sie sind für den Kunden als Aussteller bestimmt. Anfragenummer, Zeitraum, Beträge und Status bleiben ebenfalls.
(4) Die Anonymisierung nach den Absätzen 2 und 3 sowie die Entfernung des IP-Hash laufen im täglichen Löschlauf; anonymisiert oder entfernt wird frühestens nach Ablauf der jeweiligen Frist. Ist an einem Tag mehr zu bearbeiten, als ein Lauf schafft, folgt der Rest im nächsten Lauf.
(5) In der Versandwarteschlange für E-Mails leert WEBklar die Empfängeradresse und den Inhalt einer E-Mail nach dem Versand und löscht den Eintrag 7 Tage nach dem Versand. E-Mails, die nicht zugestellt werden können, löscht WEBklar mit Empfängeradresse und Inhalt 48 Stunden nach dem endgültigen Fehlschlag.
(6) Ist der Buchungskalender abgeschaltet oder der Vertrag über den Betrieb der Website beendet, kann der Kunde die Anfragen, Buchungen und Rechnungen 90 Tage lang im Kundenportal lesen und die Rechnungen einzeln als PDF herunterladen; WEBklar weist ihn nach dem Abschalten per E-Mail mit dem Löschdatum darauf hin, seine Rechnungen zu sichern. Danach löscht WEBklar alle Daten des Buchungskalenders: Unterkünfte, Preise, Anfragen, Buchungen, Rechnungen, Kalenderverbindungen, Sperren und Einstellungen. Damit endet auch die Aufbewahrung der Rechnungen nach Absatz 3; sie bewahrt der Kunde dann selbst auf. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Angaben zur Einrichtung bestehen (zum Beispiel Adresse der Website, Einbau-Code und die Angaben des Kunden aus der Anfrage) und enthält keine Daten von Gästen; dafür gilt die Datenschutzerklärung von WEBklar (Abschnitt 6). Beginnt innerhalb dieser Frist wieder ein Vertrag über den Betrieb der Website, schaltet WEBklar den Buchungskalender auf Wunsch des Kunden mit den vorhandenen Daten wieder ein (Nr. 10 Abs. 5 der Leistungsbeschreibung). Früher anonymisiert WEBklar einzelne Daten auf Weisung des Kunden (Nummer 8 Abs. 2); Wird das Kundenkonto gelöscht, schaltet WEBklar den Buchungskalender ab und löscht seine Daten wie nach Absatz 6 (bei Abschaltung 90 Tage später); einen Hinweis per E-Mail gibt es dann nicht, weil mit dem Konto auch die E-Mail-Adresse gelöscht wird. alle Daten des Buchungskalenders löscht WEBklar auf Weisung des Kunden in Textform unverzüglich.
(7) Die Daten sind in der täglichen Vollsicherung des Servers enthalten (§ 3 Abs. 5 der Abo-Bedingungen). Gelöschte oder anonymisierte Daten bleiben dort bis zu sieben Tage erhalten und werden dann überschrieben. Wird eine Sicherung zurückgespielt, holt der tägliche Löschlauf die fälligen Löschungen und Anonymisierungen nach; Löschungen auf Weisung des Kunden wiederholt WEBklar.
(8) Gesetzliche Aufbewahrungspflichten bleiben unberührt. Der Kunde ist für seine eigenen Aufbewahrungspflichten (zum Beispiel für Rechnungen und Buchungsbelege) selbst verantwortlich; WEBklar ist kein Archiv im Sinne der Buchführung. Die Aufbewahrung der Rechnungen nach Absatz 3 geschieht als Dienst für den Kunden und für die Dauer des Buchungskalenders. Der Kunde sichert die Belege, die er braucht, rechtzeitig vor der Löschung nach Absatz 6 durch den Download. Soweit eine gesetzliche Aufbewahrungspflicht besteht, löscht oder anonymisiert WEBklar auch auf Weisung des Kunden nicht früher; stattdessen sperrt es die Daten für die weitere Verarbeitung.
## 10. Nachweise und Kontrollen
WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Zertifizierung der Hetzner Online GmbH nach ISO/IEC 27001). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.
## 11. Pflichten des Kunden
(1) Der Kunde ist Verantwortlicher für die Verarbeitung der Daten seiner Gäste im Buchungskalender.
(2) Der Kunde informiert seine Gäste nach Art. 13 DSGVO. Dazu nimmt er den Datenschutz-Absatz für den Buchungskalender in seine Datenschutzerklärung auf (Nr. 5 Abs. 10 der Leistungsbeschreibung) und nennt in seinen Rechtstexten (Datenschutzerklärung, Impressum) die Angaben, die das Gesetz verlangt.
(3) Der Kunde schließt mit Stripe den Vertrag über sein Stripe-Konto selbst, wenn er die Online-Zahlung nutzt, und verantwortet die Verarbeitung von Daten durch Stripe, soweit sie nicht in diesem Vertrag geregelt ist (Nummer 7 Abs. 4).
(4) Der Kunde fordert von seinen Gästen keine Angaben zu ihrer Gesundheit oder zu anderen besonderen Kategorien personenbezogener Daten und weist sie auf der Website darauf hin, solche Angaben nicht in das Nachrichtenfeld zu schreiben.
(5) Der Kunde verändert den Einbau-Code und die gesetzlich vorgeschriebenen Hinweise des Widgets und der Buchungsseite nicht (Nr. 5 Abs. 9 der Leistungsbeschreibung).
(6) Der Kunde erfüllt seine Pflichten zu Meldeschein, Kurtaxe und sonstigen Meldungen an Behörden selbst und verarbeitet die dafür nötigen Daten außerhalb des Buchungskalenders (Nr. 5 Abs. 6 der Leistungsbeschreibung).
## 12. Haftung, Änderungen, Schlussbestimmungen
(1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen.
(2) Eine neue Fassung dieses Vertrags oder der Leistungsbeschreibung, auch wegen geänderter Datenerhebung, teilt WEBklar dem Kunden mindestens sechs Wochen vor ihrem Wirksamwerden in Textform mit. Sie wird wirksam, wenn der Kunde ihr im Kundenportal oder in Textform zustimmt. Stimmt er nicht zu, gilt die bisherige Fassung weiter.
(3) Ist die neue Fassung nötig, weil ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form nicht mehr zulässt, und stimmt der Kunde ihr nicht zu, darf WEBklar den Buchungskalender für seine Website nach Nr. 12 Abs. 2 der Leistungsbeschreibung beenden oder nach Nr. 12 Abs. 3 der Leistungsbeschreibung aussetzen. Das Abo bleibt davon unberührt.
(4) Im Übrigen gilt § 16 der Abo-Bedingungen.
## Anlage: Technische und organisatorische Maßnahmen des Buchungskalenders (Art. 32 DSGVO)
| Bereich | Maßnahmen |
|---|---|
| Übertragung | Das Widget, die Buchungsseite und das Kundenportal werden nur über TLS (HTTPS) übertragen. Der Einbau-Code verweist auf eine Adresse mit https://. Die Zahlung geschieht auf der Zahlungsseite von Stripe. Kalender anderer Plattformen ruft WEBklar nur über https ab. |
| Speicher im Browser | Es werden keine Cookies gesetzt. Das Widget sendet seine Anfragen ohne Cookies und ruft nur die Schnittstelle des Buchungskalenders auf, keine Dienste Dritter. |
| Zugriffskontrolle | Das Kundenportal ist nur nach Anmeldung erreichbar; jeder Kunde sieht nur die Daten seiner eigenen Projekte. Die öffentliche Schnittstelle des Widgets liefert nur für einen vorhandenen Buchungskalender Daten und ist über den Website-Schlüssel (Site-Key) adressiert; die belegten Nächte nennt sie ohne Quelle und ohne Daten von Gästen. Die Buchungsseite ruft der Gast nur mit einem geheimen Token ab, das nur als Prüfsumme gespeichert und zeitkonstant verglichen wird. Der Token wird nur als Prüfsumme gespeichert; er wird nur als Teil der Rücksprungadresse an Stripe übergeben (Nummer 7 Abs. 4) und öffnet nur diese eine Buchungsseite; er gilt bis 90 Tage nach der Abreise (bei nicht zustande gekommenen Buchungen 30 Tage), danach antwortet die Buchungsseite wie bei einem unbekannten Link. Benachrichtigungen von Stripe werden nur nach Prüfung ihrer Signatur verarbeitet; die Schlüssel für Stripe liegen nur auf dem Server. Die iCal-Adresse des Buchungskalenders ist ein geheimer, erneuerbarer Zufallswert. |
| Missbrauchsschutz | Ein verstecktes Feld (Honeypot), eine Mindestzeit bis zum Absenden des Formulars mit signiertem Formular-Token und Grenzen je IP-Adresse und je Buchungskalender schützen vor automatisierten Anfragen. Die Grenzen gelten im Arbeitsspeicher; ein Neustart leert sie. Mit der Anfrage wird nur ein gesalzener IP-Hash gespeichert (Nummer 3 Abs. 4). Beim Abruf von Kalendern anderer Plattformen sind nur Adressen mit https erlaubt, private und lokale Ziele sind gesperrt, und Größe und Dauer des Abrufs sind begrenzt. |
| Datenminimierung | Kartendaten erhält und speichert WEBklar nicht. Preise berechnet der Server aus den gespeicherten Preisen und Posten, nie aus Angaben des Browsers. Von anderen Kalendern werden nur Zeiträume übernommen. Mit der Anfrage werden keine IP-Adresse im Klartext und kein Browser-Kennzeichen gespeichert; der IP-Hash wird mit der Entscheidung des Kunden entfernt. Die Gästedaten werden nach den Fristen der Nummer 9 anonymisiert. Nach dem Versand einer E-Mail werden Empfängeradresse und Inhalt in der Warteschlange geleert. |
| Integrität der Rechnungen | Rechnungen und Stornorechnungen werden als unveränderlicher Schnappschuss gespeichert; das PDF entsteht bei jedem Abruf aus diesem Schnappschuss. Rechnungsnummern werden unter Sperre lückenlos je Kunde und Jahr vergeben. Freigabe und Buchung prüfen unter Sperre erneut, dass der Zeitraum frei ist. |
| Trennung | Alle Unterkünfte, Anfragen und Rechnungen tragen die Kennung des Kunden, des Projekts und der Funktion; das Kundenportal und die öffentliche Schnittstelle lesen immer nur die Daten des jeweiligen Buchungskalenders. Die Hintergrundläufe (Ablauf von Reservierungen, Kalenderabgleich, Versand der E-Mails, Anonymisierung und Löschung) schreiben jeweils nur in das Dokument, um das es geht. |
| Löschung | Täglicher Löschlauf für Anfragen, Buchungen und Warteschlange nach den Fristen der Nummer 9; die Daten abgeschalteter Buchungskalender 90 Tage nach dem Abschalten; das Einrichtungs-Ticket bleibt als Arbeitsnachweis mit einer Kurzfassung der Angaben zur Einrichtung bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6). |
| Protokolle | Die Programmprotokolle des Buchungskalenders und der Tagesbericht enthalten nur Zählungen und Anfragenummern, keine Namen, E-Mail-Adressen oder Adressen von Gästen. Server-Protokolle des Webservers werden nach 14 Tagen gelöscht (Anlage 2 der Abo-Bedingungen). |
| Datensicherung | Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen). |
| Rechenzentrum | Hetzner Online GmbH, Rechenzentren Falkenstein und Nürnberg (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert; der Geltungsbereich umfasst die Hosting-Dienstleistungen und die Rechenzentren. |
| Organisation | Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nummer 8 Abs. 3), Überprüfung der Maßnahmen mindestens einmal im Jahr. |
+145
View File
@@ -0,0 +1,145 @@
# Auftragsverarbeitungsvertrag Webshop (Art. 28 DSGVO)
**Fassung 2026-10-16** · Entwurf – Prüfung durch Kanzlei offen
zwischen dem Kunden als Verantwortlichem („Kunde“; in der Leistungsbeschreibung „Händler“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“).
## Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen
(1) Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Funktion „Webshop“ einschaltet und die dort angezeigte Zeile „Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung 2026-10-16)“ mit dem Klick auf „Einschalten“ bestätigt; diesem Vertrag und der Leistungsbeschreibung „Webshop“ („Leistungsbeschreibung“) in der angezeigten Fassung stimmt er damit zu. Das Portal speichert den Wortlaut der angezeigten Zeile, die Fassungen, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte in dieser Fassung als PDF enthält und auf sie verlinkt.
(2) Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform (etwa im Chat des Kundenportals oder per E-Mail) erklärt hat, dass er diesem Vertrag und der Leistungsbeschreibung in der aktuellen Fassung zustimmt. Das Portal verlangt dafür das Datum und den Weg der Erklärung; fehlt eine Angabe, schaltet es nicht ein. WEBklar speichert vor dem Einschalten einen Zustimmungsnachweis mit der Fassung der beiden Texte, dem Datum und dem Weg dieser Erklärung, dem Zeitpunkt des Einschaltens und dem Namen des Mitarbeiters (ohne IP-Adresse und Browser-Kennzeichen, weil der Kunde nicht im Portal zugestimmt hat). Der Kunde erhält anschließend eine E-Mail mit diesen beiden Texten als PDF.
(3) Dieser Vertrag ist ein eigener Vertrag für den Webshop. Er ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Für den Webshop geht er Anlage 1 vor; im Übrigen gilt Anlage 1 ergänzend. In Datenschutzfragen geht er den Abo-Bedingungen und der Leistungsbeschreibung vor.
(4) Die Rollen: Der Kunde ist Verantwortlicher für die personenbezogenen Daten seiner Käufer im Webshop. WEBklar ist sein Auftragsverarbeiter. Stripe ist nicht Auftragsverarbeiter von WEBklar, sondern Vertragspartner des Kunden (Nummer 7 Abs. 4).
## 1. Gegenstand und Dauer
(1) WEBklar betreibt für die öffentliche Website des Kunden ein Shop-Widget mit einer Bestellverwaltung im Kundenportal („Webshop“) und verarbeitet dabei personenbezogene Daten der Käufer des Kunden im Auftrag des Kunden. Den Umfang beschreibt die Leistungsbeschreibung.
(2) Der Vertrag beginnt mit dem Einschalten der Funktion. Er endet mit dem Abschalten, spätestens mit dem Ende des Wartungs-Abos; den Webshop schaltet WEBklar dann spätestens am folgenden Tag ab (Nr. 10 Abs. 1 der Leistungsbeschreibung). Die Pflichten aus diesem Vertrag gelten fort, bis alle Daten nach Nummer 9 gelöscht sind.
## 2. Art und Zweck der Verarbeitung
(1) Art: Übermitteln der Bestellung vom Widget an den Server von WEBklar, Übernehmen der Zahlungs- und Adressangaben aus der Checkout-Sitzung bei Stripe, Speichern, Verwalten und Anzeigen im Kundenportal, Versenden von E-Mails, Bereitstellen einer CSV-Datei, Weitergeben von Erstattungen an Stripe im Auftrag des Kunden, Anonymisieren und Löschen.
(2) Zwecke: Der Kunde soll Bestellungen seiner Käufer abwickeln. Dazu nimmt der Webshop Bestellungen entgegen, reserviert den Bestand, gleicht Zahlungen mit Stripe ab, bestätigt Bestellungen (bei einer Zahlung, die noch aussteht, etwa einer SEPA-Lastschrift, zunächst nur mit einer Eingangsmail ohne Vertragsschluss), bildet Versand und Abholung ab, nimmt Widerrufe entgegen, erstattet Zahlungen im Auftrag des Kunden und benachrichtigt den Kunden. WEBklar versendet die E-Mails im Auftrag des Kunden. Außerdem schützt WEBklar den Webshop für den Kunden vor Missbrauch (Mengenbegrenzung, Begrenzung der offenen Bestellungen je IP-Adresse, Erkennen automatisierter Bestellungen und Widerrufe).
(3) WEBklar verwendet die Daten für keine eigenen Zwecke, auch nicht in anonymisierter Form und nicht für Auswertungen über mehrere Kunden. Es findet keine Werbung und keine Zusammenführung mit anderen Daten statt. WEBklar bildet keine Profile einzelner Käufer. Zählungen ohne Personenbezug, die WEBklar für den sicheren Betrieb braucht (etwa die Zahl der anonymisierten Bestellungen je Tag oder die Länge der Versandwarteschlange), sind keine eigenen Zwecke.
## 3. Datenarten und Betroffene
(1) Daten der Käufer, die WEBklar aus der Checkout-Sitzung bei Stripe und aus dem Widget erhält und speichert: Name, E-Mail-Adresse, Lieferadresse und Rechnungsadresse (die Rechnungsadresse nur, soweit die Zahlungsseite von Stripe sie für die gewählte Zahlungsart abfragt); die Bestellung (Bestellnummer, Positionen mit Namen, Mengen und Preisen zum Zeitpunkt der Bestellung, Versandkosten, Lieferart, Steuerausweis, Gesamtbetrag); der Status der Bestellung mit den Zeitpunkten (bestellt, bezahlt, versendet, abholbereit, abgeschlossen, storniert, erstattet); Zahlungsstatus, Zahlungsart (zum Beispiel Karte oder SEPA-Lastschrift) und Kennungen der Zahlung bei Stripe; Versanddienst und Sendungsnummer, wenn der Kunde sie einträgt; Erstattungen mit Betrag, Zeitpunkt und Grund; die Prüfsumme (Hash) der Rechtstexte des Kunden, die der Käufer bei der Bestellung gesehen hat (den Wortlaut der Fassung verwahrt das Archiv nach Nummer 8 Abs. 5); ein Hashwert der IP-Adresse (Absatz 4); der Zustellstatus der E-Mails an den Käufer, auch der Eingangsmail bei ausstehender Zahlung. **Kartendaten und Kontodaten (IBAN) erhält und speichert WEBklar nicht;** die gibt der Käufer nur bei Stripe ein. **Eine Telefonnummer des Käufers erhebt und speichert WEBklar nicht:** Das Widget hat kein Telefonfeld, die Zahlungsseite von Stripe ist so eingestellt, dass sie keine Telefonnummer verlangt, und eine Telefonnummer, die Stripe in den Kundendaten der Sitzung dennoch übermitteln sollte, übernimmt WEBklar nicht in die Bestellung. Die Telefonnummer des Kunden selbst (Anbieterangaben in seinen Einstellungen) gehört nicht zu den Käuferdaten.
(2) Daten von Personen, die über die Widerrufsfunktion einen Vertrag widerrufen (Käufer und Interessenten, auch ohne passende Bestellung): Name, E-Mail-Adresse, Bestellnummer, die Erklärung als Freitext und der Zeitpunkt des Eingangs, außerdem der Zeitpunkt der Eingangsbestätigung und der Bearbeitung durch den Kunden.
(3) Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht verarbeitet. Waren, aus deren Kauf sich besondere Kategorien ergeben können, etwa Arzneimittel, sind nach Nr. 3 der Leistungsbeschreibung ausgeschlossen; der Kunde fordert von seinen Käufern keine entsprechenden Angaben (Nummer 11 Abs. 4).
(4) IP-Adresse und Browser-Kennzeichen (User-Agent) erhält der Server von WEBklar technisch bei jedem Abruf des Widgets und jeder Anfrage. Die Mengenbegrenzung (Missbrauchsschutz) verwendet die IP-Adresse im Arbeitsspeicher des Servers; ein Neustart leert sie. Mit dem Widerruf wird keine IP-Adresse und kein Browser-Kennzeichen gespeichert. Mit der Bestellung werden ebenfalls weder eine IP-Adresse im Klartext noch ein Browser-Kennzeichen gespeichert, sondern nur ein **Hashwert der IP-Adresse („IP-Hash“)**: die ersten 16 Hexadezimalzeichen eines SHA-256-Werts, den WEBklar mit einem geheimen Schlüssel des Servers salzt. Der IP-Hash ist ein pseudonymes technisches Datum und bleibt ein personenbezogenes Datum, weil WEBklar mit dem Schlüssel für dieselbe IP-Adresse denselben Wert berechnen kann; ohne den Schlüssel lässt sich aus ihm keine IP-Adresse ableiten. WEBklar verwendet ihn ausschließlich zum Missbrauchsschutz: Je IP-Hash und Webshop sind höchstens drei offene (noch nicht bezahlte) Bestellungen innerhalb von 30 Minuten zulässig. Er dient nicht dazu, Käufer zu identifizieren, wiederzuerkennen oder zu profilieren, wird dem Kunden weder angezeigt noch exportiert und steht nur im Datensatz der Bestellung. WEBklar löscht ihn aus dem Datensatz der Bestellung, sobald die Bestellung bezahlt oder storniert wird, spätestens mit der Löschung nicht bezahlter Bestellungen nach 30 Tagen (Nummer 9 Abs. 1); bei bezahlten Bestellungen bleibt er nicht bis zur Anonymisierung (Nummer 9 Abs. 2) erhalten. In den Server-Protokollen stehen IP-Adresse und Browser-Kennzeichen 14 Tage (Anlage 2 der Abo-Bedingungen).
(5) Der Webshop setzt keine Cookies. Das Widget speichert im Browser des Besuchers nur den Warenkorb (Kennungen der Artikel und Mengen, ohne Personenbezug) im Sitzungsspeicher (sessionStorage) des Tabs, solange der Tab geöffnet ist; das ist für den Warenkorb technisch erforderlich. Andere Daten legt das Widget im Browser nicht ab.
(6) Betroffene: Käufer, die über die Website des Kunden Waren bestellen, und Personen, die über die Widerrufsfunktion einen Vertrag widerrufen. Beschäftigte des Kunden sind von diesem Vertrag nicht betroffen.
## 4. Weisungen
(1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, auch in Bezug auf eine Übermittlung in ein Drittland oder an eine internationale Organisation. Das gilt nicht, wenn das Recht der Europäischen Union oder Deutschlands WEBklar zu einer Verarbeitung verpflichtet. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es nicht verbietet.
(2) Mit dem Einschalten weist der Kunde WEBklar an, den Webshop nach seinen Einstellungen und der Leistungsbeschreibung zu betreiben. Weitere Weisungen ergeben sich aus den Einstellungen und Aktionen im Kundenportal (Artikel, Versand, Abholung, Zahlungsarten, Rechtstexte, Bestellstatus, Erstattungen, Öffnen und Schließen des Webshops, Export). Sonstige Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Weisungen, die über diese Einstellungen hinausgehen, setzt WEBklar nur um, wenn das technisch vorgesehen ist; sonst kann der Kunde den Webshop abschalten lassen (Nr. 10 Abs. 7 der Leistungsbeschreibung). Die Anonymisierung und Löschung auf Weisung nach Nummer 8 Abs. 2 und Nummer 9 Abs. 6 setzt WEBklar immer um.
(3) Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin. WEBklar darf die Ausführung aussetzen, bis der Kunde die Weisung ändert oder die Rechtslage geklärt ist.
## 5. Vertraulichkeit
WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.
## 6. Technische und organisatorische Maßnahmen
(1) WEBklar trifft die Maßnahmen nach Art. 32 DSGVO, die in der Anlage zu diesem Vertrag beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt.
(2) Für den Server gelten ergänzend die Maßnahmen der Anlage 2 der Abo-Bedingungen, soweit die Anlage zu diesem Vertrag nichts anderes regelt.
## 7. Unterauftragsverarbeiter
(1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Webshop läuft, und für dessen Sicherung (Rechenzentrum Falkenstein, Deutschland) sowie für den Versand der Bestell-E-Mails über den SMTP-Dienst des Webhostings (Rechenzentrum Nürnberg, Deutschland). Eine Übermittlung in Drittländer findet nicht statt.
(2) Will WEBklar einen weiteren Unterauftragsverarbeiter hinzuziehen oder einen ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen. WEBklar setzt den neuen Unterauftragsverarbeiter nicht vor Ablauf dieser Frist ein. Kündigt ein Unterauftragsverarbeiter von WEBklar eine eigene Änderung mit kürzerer Frist an, informiert WEBklar den Kunden unverzüglich, nachdem die Ankündigung eingegangen ist; der Kunde kann dann unverzüglich widersprechen. Einigen sich die Parteien nicht, kann der Kunde den Webshop abschalten lassen und WEBklar ihn für diese Website zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt wird. Die Daten werden dann nach Nummer 9 gelöscht, auf Weisung des Kunden früher (Nummer 9 Abs. 6).
(3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf die Datenschutzpflichten nach Art. 28 Abs. 4 DSGVO. Bei der Hetzner Online GmbH ist das deren Vertrag zur Auftragsverarbeitung, den WEBklar abgeschlossen hat. Kommt ein Unterauftragsverarbeiter seinen Datenschutzpflichten nicht nach, haftet WEBklar dem Kunden für die Einhaltung dieser Pflichten (Art. 28 Abs. 4 Satz 2 DSGVO).
(4) **Stripe ist kein Unterauftragsverarbeiter von WEBklar.** Der Kunde verwendet sein eigenes Stripe-Konto und hat den Vertrag darüber unmittelbar mit Stripe geschlossen (Nr. 4 der Leistungsbeschreibung). Die Zahlungsseite (Stripe Checkout), die Erhebung von Zahlungs-, Karten- und Kontodaten, die Prüfung von Zahlungen und die Auszahlungen verantworten der Kunde und Stripe nach ihrem Vertrag; WEBklar erhält diese Daten nicht. Auf Weisung des Kunden ruft WEBklar bei Stripe die Angaben zu Zahlungen und Bestellungen ab, die der Webshop braucht (Nummer 2 Abs. 1), und veranlasst Erstattungen. Daten von Käufern gehen dabei nicht von WEBklar an Stripe, außer der Erstattung selbst und der Kennung der Zahlung. Die Weitergabe von Daten durch Stripe, auch an Stellen außerhalb der Europäischen Union, regeln der Kunde und Stripe; dafür ist WEBklar nicht verantwortlich.
## 8. Unterstützung des Kunden, Betroffenenrechte
(1) WEBklar leitet Anfragen von Käufern, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter. WEBklar unterstützt ihn mit geeigneten technischen und organisatorischen Maßnahmen bei der Beantwortung.
(2) Dafür bietet das Kundenportal den Export der Bestellungen als CSV-Datei und die Anzeige der Bestellungen und Widerrufe, mit denen der Kunde Auskunft erteilen kann. Auf Weisung des Kunden in Textform (etwa im Chat des Kundenportals) anonymisiert WEBklar die Käuferdaten einer Bestellung oder eines Widerrufs unverzüglich: Name, E-Mail-Adresse, Liefer- und Rechnungsadresse und die Freitexte werden gelöscht oder unkenntlich gemacht (ein IP-Hash ist zu diesem Zeitpunkt längst gelöscht); Bestellnummer, Positionen, Beträge und Status bleiben. Dabei löscht WEBklar die E-Mails zu dieser Bestellung, die noch auf den Versand warten. E-Mails, deren Versand endgültig fehlgeschlagen ist, behalten Empfängeradresse und Inhalt, bis WEBklar sie nach Nummer 9 Abs. 4 löscht (48 Stunden nach dem Fehlschlag). Gesetzliche Aufbewahrungspflichten des Kunden (Nummer 9 Abs. 8) prüft der Kunde vor seiner Weisung selbst.
(3) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.
(4) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO. Dazu gehören die Angaben, die er für sein Verzeichnis von Verarbeitungstätigkeiten und für eine etwaige Datenschutz-Folgenabschätzung braucht.
(5) Bestätigt der Kunde seine Rechtstexte im Kundenportal (Allgemeine Geschäftsbedingungen, Widerrufsbelehrung, Muster-Widerrufsformular, Versand und Zahlung, Gewährleistungshinweis sowie die Adressen von Impressum und Datenschutzerklärung), legt WEBklar jede bestätigte Fassung einmal im Archiv der Rechtstexte ab: mit ihrer Prüfsumme (Hash), dem Zeitpunkt und der Angabe, welche angemeldete Person des Kunden (Name oder E-Mail-Adresse) sie bestätigt hat. Die Bestellung speichert die Prüfsumme der Fassung, die der Käufer bei der Bestellung gesehen hat. Die Bestellbestätigung (G09) zeigt die archivierte Fassung zu dieser Prüfsumme, nicht die Fassung, die beim Versand der E-Mail gerade gilt; fehlt die archivierte Fassung ausnahmsweise, verwendet WEBklar die aktuell bestätigte Fassung. Das Archiv enthält keine Daten von Käufern. WEBklar löscht es mit den übrigen Daten des Webshops (Nummer 9 Abs. 6), nicht früher. Das Archiv hilft dem Kunden, die Fassung nachzuweisen, die ein Käufer erhalten hat; es ersetzt nicht seine eigene Aufbewahrung (Nummer 9 Abs. 8). Die Bestellbestätigung enthält diese Texte; WEBklar löscht ihren Inhalt nach dem Versand aus der Versandwarteschlange (Nummer 9 Abs. 4).
(6) Die Käufer informiert der Kunde (Art. 13 DSGVO; Nummer 11 Abs. 2).
## 9. Löschung und Rückgabe
(1) Eine Bestellung, die nicht bezahlt wird, verfällt 30 Minuten nach dem Anlegen und gilt als storniert. WEBklar löscht nicht bezahlte Bestellungen (offen oder storniert) 30 Tage nach dem Anlegen. Den IP-Hash löscht WEBklar aus dem Datensatz, sobald die Bestellung bezahlt oder storniert wird, spätestens mit dieser Löschung.
(2) Bei bezahlten Bestellungen anonymisiert WEBklar die Käuferdaten 3 Jahre nach dem Ende des Kalenderjahres, in dem die Bestellung angelegt wurde (Bestelljahr; maßgeblich ist die Zeit in Berlin): Name, E-Mail-Adresse sowie Liefer- und Rechnungsadresse werden gelöscht (der IP-Hash ist mit der Zahlung bereits gelöscht; eine Telefonnummer des Käufers wird nicht gespeichert). Bestellnummer, Positionen, Beträge, Steuerausweis, Status, Versanddienst und Sendungsnummer bleiben für die Buchhaltung des Kunden erhalten.
(3) Widerrufe nach Nummer 3 Abs. 2 behandelt WEBklar wie bezahlte Bestellungen: Name, E-Mail-Adresse und Erklärung werden 3 Jahre nach dem Ende des Kalenderjahres des Eingangs gelöscht; die Bestellnummer, die die Person angegeben hat, und der Zeitpunkt des Eingangs bleiben.
(4) In der Versandwarteschlange für E-Mails leert WEBklar die Empfängeradresse und den Inhalt einer E-Mail nach dem Versand und löscht den Eintrag 7 Tage nach dem Versand. E-Mails, die nicht zugestellt werden können, löscht WEBklar mit Empfängeradresse und Inhalt 48 Stunden nach dem endgültigen Fehlschlag.
(5) Der Löschlauf läuft täglich; gelöscht oder anonymisiert wird frühestens nach Ablauf der jeweiligen Frist. Ist an einem Tag mehr zu bearbeiten, als ein Lauf schafft, folgt der Rest im nächsten Lauf.
(6) Ist der Webshop abgeschaltet oder das Wartungs-Abo beendet, kann der Kunde die Bestellungen und Widerrufe 90 Tage lang im Kundenportal lesen und als CSV-Datei exportieren. Danach löscht WEBklar alle Daten des Webshops: Artikel, Bilder, Bestellungen, Widerrufe, Einstellungen, Rechtstexte und das Archiv der bestätigten Fassungen der Rechtstexte (Nummer 8 Abs. 5). Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Angaben zur Einrichtung bestehen (zum Beispiel Adresse der Website, Einbau-Code und die Angaben des Kunden aus der Anfrage) und enthält keine Daten von Käufern; dafür gilt die Datenschutzerklärung von WEBklar (Abschnitt 6). Beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schaltet WEBklar den Webshop auf Wunsch des Kunden mit den vorhandenen Daten wieder ein (Nr. 10 Abs. 5 der Leistungsbeschreibung). Früher anonymisiert WEBklar einzelne Daten auf Weisung des Kunden (Nummer 8 Abs. 2); alle Daten des Webshops löscht WEBklar auf Weisung des Kunden in Textform unverzüglich.
(7) Die Daten sind in der täglichen Vollsicherung des Servers enthalten (§ 3 Abs. 5 der Abo-Bedingungen). Gelöschte oder anonymisierte Daten bleiben dort bis zu sieben Tage erhalten und werden dann überschrieben. Wird eine Sicherung zurückgespielt, holt der tägliche Löschlauf die fälligen Löschungen und Anonymisierungen nach; Löschungen auf Weisung des Kunden wiederholt WEBklar.
(8) Gesetzliche Aufbewahrungspflichten bleiben unberührt. Der Kunde ist für seine eigenen Aufbewahrungspflichten (zum Beispiel für Buchungsbelege) selbst verantwortlich; WEBklar ist kein Archiv im Sinne der Buchführung, und das Archiv der Rechtstexte dient nur dem Nachweis der Texte. Der Kunde sichert die Belege, die er braucht, rechtzeitig vor der Anonymisierung oder Löschung durch den Export und die Belege von Stripe.
## 10. Nachweise und Kontrollen
WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Zertifizierung der Hetzner Online GmbH nach ISO/IEC 27001). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.
## 11. Pflichten des Kunden
(1) Der Kunde ist Verantwortlicher für die Verarbeitung der Daten seiner Käufer im Webshop.
(2) Der Kunde informiert seine Käufer nach Art. 13 DSGVO. Dazu nimmt er den Datenschutz-Absatz für den Webshop in seine Datenschutzerklärung auf (Nr. 5 Abs. 10 der Leistungsbeschreibung) und nennt in seinen Rechtstexten (Datenschutzerklärung, Impressum) die Angaben, die das Gesetz verlangt.
(3) Der Kunde schließt mit Stripe den Vertrag über sein Stripe-Konto selbst und verantwortet die Verarbeitung von Daten durch Stripe, soweit sie nicht in diesem Vertrag geregelt ist (Nummer 7 Abs. 4).
(4) Der Kunde verkauft keine ausgeschlossenen Waren (Nr. 3 der Leistungsbeschreibung) und fordert von seinen Käufern keine Angaben zu ihrer Gesundheit oder zu anderen besonderen Kategorien personenbezogener Daten, auch nicht im Freitext der Widerrufserklärung.
(5) Der Kunde verändert den Einbau-Code und die gesetzlich vorgeschriebenen Hinweise des Widgets nicht und entfernt den Link „Vertrag widerrufen“ nicht (Nr. 5 Abs. 6 und 9 der Leistungsbeschreibung).
## 12. Haftung, Änderungen, Schlussbestimmungen
(1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen.
(2) Eine neue Fassung dieses Vertrags oder der Leistungsbeschreibung, auch wegen geänderter Datenerhebung, teilt WEBklar dem Kunden mindestens sechs Wochen vor ihrem Wirksamwerden in Textform mit. Sie wird wirksam, wenn der Kunde ihr im Kundenportal oder in Textform zustimmt. Stimmt er nicht zu, gilt die bisherige Fassung weiter.
(3) Ist die neue Fassung nötig, weil ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form nicht mehr zulässt, und stimmt der Kunde ihr nicht zu, darf WEBklar den Webshop für seine Website nach Nr. 11 Abs. 2 der Leistungsbeschreibung beenden oder nach Nr. 11 Abs. 3 der Leistungsbeschreibung aussetzen. Das Abo bleibt davon unberührt.
(4) Im Übrigen gilt § 16 der Abo-Bedingungen.
## Anlage: Technische und organisatorische Maßnahmen des Webshops (Art. 32 DSGVO)
| Bereich | Maßnahmen |
|---|---|
| Übertragung | Das Widget, die Seiten der Käufer und das Kundenportal werden nur über TLS (HTTPS) übertragen. Der Einbau-Code verweist auf eine Adresse mit https://. Die Zahlung geschieht auf der Zahlungsseite von Stripe. |
| Speicher im Browser | Es werden keine Cookies gesetzt. Das Widget speichert nur den Warenkorb (Kennungen der Artikel und Mengen) im Sitzungsspeicher des Tabs. Es sendet seine Anfragen ohne Cookies und ruft nur die Schnittstelle des Webshops auf, keine Dienste Dritter. |
| Zugriffskontrolle | Das Kundenportal ist nur nach Anmeldung erreichbar; jeder Kunde sieht nur die Daten seiner eigenen Projekte. Die öffentliche Schnittstelle des Widgets liefert nur für einen vorhandenen Webshop Daten und ist über den Website-Schlüssel (Site-Key) adressiert; Bestellungen und Widerrufe nimmt sie nur von Seiten des Kunden und des Portals an. Den Stand einer Bestellung ruft der Käufer nur mit einem geheimen Token ab, das nur als Prüfsumme gespeichert wird. Benachrichtigungen von Stripe werden nur nach Prüfung ihrer Signatur verarbeitet; die Schlüssel für Stripe liegen nur auf dem Server. |
| Missbrauchsschutz | Ein verstecktes Feld (Honeypot), eine Mindestzeit bis zum Absenden des Formulars mit signiertem Formular-Token und eine Mindestantwortzeit schützen vor automatisierten Bestellungen und Widerrufen. Grenzen je IP-Adresse und je Webshop gelten im Arbeitsspeicher; ein Neustart leert sie. Die Obergrenze offener Bestellungen je IP-Adresse (drei in 30 Minuten) arbeitet mit einem gesalzenen Hashwert der IP-Adresse in der Bestellung (Nummer 3 Abs. 4). |
| Datenminimierung | Kartendaten und Kontodaten erhält und speichert WEBklar nicht. Preise berechnet der Server aus den gespeicherten Artikeln, nie aus Angaben des Browsers. Eine Telefonnummer wird weder abgefragt noch gespeichert, die Rechnungsadresse nur, soweit Stripe sie für die Zahlungsart braucht. Mit der Bestellung und mit dem Widerruf werden keine IP-Adresse im Klartext und kein Browser-Kennzeichen gespeichert, bei der Bestellung nur der gesalzene IP-Hash, und der nur, solange die Bestellung offen ist (gelöscht mit Zahlung oder Stornierung). Die Käuferdaten werden nach den Fristen der Nummer 9 anonymisiert. Nach dem Versand einer E-Mail werden Empfängeradresse und Inhalt in der Warteschlange geleert. |
| Trennung | Alle Artikel, Bestellungen und Widerrufe tragen die Kennung des Webshops und des Kunden; das Kundenportal und die öffentliche Schnittstelle lesen immer nur die Daten des jeweiligen Webshops. Artikelbilder liegen in einem eigenen Ordner je Webshop. Die Hintergrundläufe (Abgleich offener Bestellungen, Versand der E-Mails, Anonymisierung und Löschung) schreiben jeweils nur in das Dokument, um das es geht. |
| Löschung | Täglicher Löschlauf für Bestellungen, Widerrufe und Warteschlange nach den Fristen der Nummer 9; die Daten abgeschalteter Webshops einschließlich des Archivs der bestätigten Rechtstexte 90 Tage nach dem Abschalten; das Einrichtungs-Ticket bleibt als Arbeitsnachweis mit einer Kurzfassung der Angaben zur Einrichtung bestehen und enthält keine Daten von Käufern (Nummer 9 Abs. 6). |
| Protokolle | Die Programmprotokolle des Webshops und der Tagesbericht enthalten nur Zählungen und Bestellnummern, keine Namen, E-Mail-Adressen oder Adressen von Käufern. Server-Protokolle des Webservers werden nach 14 Tagen gelöscht (Anlage 2 der Abo-Bedingungen). |
| Datensicherung | Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen). |
| Rechenzentrum | Hetzner Online GmbH, Rechenzentren Falkenstein und Nürnberg (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert; der Geltungsbereich umfasst die Hosting-Dienstleistungen und die Rechenzentren. |
| Organisation | Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nummer 8 Abs. 3), Überprüfung der Maßnahmen mindestens einmal im Jahr. |
+145
View File
@@ -0,0 +1,145 @@
# Auftragsverarbeitungsvertrag Webshop (Art. 28 DSGVO)
**Fassung 2026-10-05**
zwischen dem Kunden als Verantwortlichem („Kunde“; in der Leistungsbeschreibung „Händler“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“).
## Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen
(1) Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Einrichtung des Webshops bestellt und dabei per Haken diesem Vertrag und der Leistungsbeschreibung „Webshop“ („Leistungsbeschreibung“) in der angezeigten Fassung zustimmt. Das Portal speichert die Fassung, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte und die Widerrufsbelehrung in dieser Fassung als PDF enthält und auf sie verlinkt.
(2) Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform (etwa im Chat des Kundenportals oder per E-Mail) erklärt hat, dass er diesem Vertrag und der Leistungsbeschreibung in der aktuellen Fassung zustimmt. Das Portal verlangt dafür das Datum und den Weg der Erklärung; fehlt eine Angabe, schaltet es nicht ein. WEBklar speichert vor dem Einschalten einen Zustimmungsnachweis mit der Fassung der beiden Texte, dem Datum und dem Weg dieser Erklärung, dem Zeitpunkt des Einschaltens und dem Namen des Mitarbeiters (ohne IP-Adresse und Browser-Kennzeichen, weil der Kunde nicht im Portal zugestimmt hat). Der Kunde erhält anschließend eine E-Mail mit diesen beiden Texten als PDF.
(3) Dieser Vertrag ist ein eigener Vertrag für den Webshop. Er ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Für den Webshop geht er Anlage 1 vor; im Übrigen gilt Anlage 1 ergänzend. In Datenschutzfragen geht er den Abo-Bedingungen und der Leistungsbeschreibung vor.
(4) Die Rollen: Der Kunde ist Verantwortlicher für die personenbezogenen Daten seiner Käufer im Webshop. WEBklar ist sein Auftragsverarbeiter. Stripe ist nicht Auftragsverarbeiter von WEBklar, sondern Vertragspartner des Kunden (Nummer 7 Abs. 4).
## 1. Gegenstand und Dauer
(1) WEBklar betreibt für die öffentliche Website des Kunden ein Shop-Widget mit einer Bestellverwaltung im Kundenportal („Webshop“) und verarbeitet dabei personenbezogene Daten der Käufer des Kunden im Auftrag des Kunden. Den Umfang beschreibt die Leistungsbeschreibung.
(2) Der Vertrag beginnt mit dem Einschalten der Funktion. Er endet mit dem Abschalten, spätestens mit dem Ende des Vertrags, aufgrund dessen WEBklar die Website des Kunden betreibt; den Webshop schaltet WEBklar dann zum Vertragsende ab (Nr. 10 Abs. 1 der Leistungsbeschreibung). Die Pflichten aus diesem Vertrag gelten fort, bis alle Daten nach Nummer 9 gelöscht sind.
## 2. Art und Zweck der Verarbeitung
(1) Art: Übermitteln der Bestellung vom Widget an den Server von WEBklar, Übernehmen der Zahlungs- und Adressangaben aus der Checkout-Sitzung bei Stripe, Speichern, Verwalten und Anzeigen im Kundenportal, Versenden von E-Mails, Bereitstellen einer CSV-Datei, Weitergeben von Erstattungen an Stripe im Auftrag des Kunden, Anonymisieren und Löschen.
(2) Zwecke: Der Kunde soll Bestellungen seiner Käufer abwickeln. Dazu nimmt der Webshop Bestellungen entgegen, reserviert den Bestand, gleicht Zahlungen mit Stripe ab, bestätigt Bestellungen (bei einer Zahlung, die noch aussteht, etwa einer SEPA-Lastschrift, zunächst nur mit einer Eingangsmail ohne Vertragsschluss), bildet Versand und Abholung ab, nimmt Widerrufe entgegen, erstattet Zahlungen im Auftrag des Kunden und benachrichtigt den Kunden. WEBklar versendet die E-Mails im Auftrag des Kunden. Außerdem schützt WEBklar den Webshop für den Kunden vor Missbrauch (Mengenbegrenzung, Begrenzung der offenen Bestellungen je IP-Adresse, Erkennen automatisierter Bestellungen und Widerrufe).
(3) WEBklar verwendet die Daten für keine eigenen Zwecke, auch nicht in anonymisierter Form und nicht für Auswertungen über mehrere Kunden. Es findet keine Werbung und keine Zusammenführung mit anderen Daten statt. WEBklar bildet keine Profile einzelner Käufer. Zählungen ohne Personenbezug, die WEBklar für den sicheren Betrieb braucht (etwa die Zahl der anonymisierten Bestellungen je Tag oder die Länge der Versandwarteschlange), sind keine eigenen Zwecke.
## 3. Datenarten und Betroffene
(1) Daten der Käufer, die WEBklar aus der Checkout-Sitzung bei Stripe und aus dem Widget erhält und speichert: Name, E-Mail-Adresse, Lieferadresse und Rechnungsadresse (die Rechnungsadresse nur, soweit die Zahlungsseite von Stripe sie für die gewählte Zahlungsart abfragt); die Bestellung (Bestellnummer, Positionen mit Namen, Mengen und Preisen zum Zeitpunkt der Bestellung, Versandkosten, Lieferart, Steuerausweis, Gesamtbetrag); der Status der Bestellung mit den Zeitpunkten (bestellt, bezahlt, versendet, abholbereit, abgeschlossen, storniert, erstattet); Zahlungsstatus, Zahlungsart (zum Beispiel Karte oder SEPA-Lastschrift) und Kennungen der Zahlung bei Stripe; Versanddienst und Sendungsnummer, wenn der Kunde sie einträgt; Erstattungen mit Betrag, Zeitpunkt und Grund; die Prüfsumme (Hash) der Rechtstexte des Kunden, die der Käufer bei der Bestellung gesehen hat (den Wortlaut der Fassung verwahrt das Archiv nach Nummer 8 Abs. 5); ein Hashwert der IP-Adresse (Absatz 4); der Zustellstatus der E-Mails an den Käufer, auch der Eingangsmail bei ausstehender Zahlung. **Kartendaten und Kontodaten (IBAN) erhält und speichert WEBklar nicht;** die gibt der Käufer nur bei Stripe ein. **Eine Telefonnummer des Käufers erhebt und speichert WEBklar nicht:** Das Widget hat kein Telefonfeld, die Zahlungsseite von Stripe ist so eingestellt, dass sie keine Telefonnummer verlangt, und eine Telefonnummer, die Stripe in den Kundendaten der Sitzung dennoch übermitteln sollte, übernimmt WEBklar nicht in die Bestellung. Die Telefonnummer des Kunden selbst (Anbieterangaben in seinen Einstellungen) gehört nicht zu den Käuferdaten.
(2) Daten von Personen, die über die Widerrufsfunktion einen Vertrag widerrufen (Käufer und Interessenten, auch ohne passende Bestellung): Name, E-Mail-Adresse, Bestellnummer, die Erklärung als Freitext und der Zeitpunkt des Eingangs, außerdem der Zeitpunkt der Eingangsbestätigung und der Bearbeitung durch den Kunden.
(3) Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht verarbeitet. Waren, aus deren Kauf sich besondere Kategorien ergeben können, etwa Arzneimittel, sind nach Nr. 3 der Leistungsbeschreibung ausgeschlossen; der Kunde fordert von seinen Käufern keine entsprechenden Angaben (Nummer 11 Abs. 4).
(4) IP-Adresse und Browser-Kennzeichen (User-Agent) erhält der Server von WEBklar technisch bei jedem Abruf des Widgets und jeder Anfrage. Die Mengenbegrenzung (Missbrauchsschutz) verwendet die IP-Adresse im Arbeitsspeicher des Servers; ein Neustart leert sie. Mit dem Widerruf wird keine IP-Adresse und kein Browser-Kennzeichen gespeichert. Mit der Bestellung werden ebenfalls weder eine IP-Adresse im Klartext noch ein Browser-Kennzeichen gespeichert, sondern nur ein **Hashwert der IP-Adresse („IP-Hash“)**: die ersten 16 Hexadezimalzeichen eines SHA-256-Werts, den WEBklar mit einem geheimen Schlüssel des Servers salzt. Der IP-Hash ist ein pseudonymes technisches Datum und bleibt ein personenbezogenes Datum, weil WEBklar mit dem Schlüssel für dieselbe IP-Adresse denselben Wert berechnen kann; ohne den Schlüssel lässt sich aus ihm keine IP-Adresse ableiten. WEBklar verwendet ihn ausschließlich zum Missbrauchsschutz: Je IP-Hash und Webshop sind höchstens drei offene (noch nicht bezahlte) Bestellungen innerhalb von 30 Minuten zulässig. Er dient nicht dazu, Käufer zu identifizieren, wiederzuerkennen oder zu profilieren, wird dem Kunden weder angezeigt noch exportiert und steht nur im Datensatz der Bestellung. WEBklar löscht ihn aus dem Datensatz der Bestellung, sobald die Bestellung bezahlt oder storniert wird, spätestens mit der Löschung nicht bezahlter Bestellungen nach 30 Tagen (Nummer 9 Abs. 1); bei bezahlten Bestellungen bleibt er nicht bis zur Anonymisierung (Nummer 9 Abs. 2) erhalten. In den Server-Protokollen stehen IP-Adresse und Browser-Kennzeichen 14 Tage (Anlage 2 der Abo-Bedingungen).
(5) Der Webshop setzt keine Cookies. Das Widget speichert im Browser des Besuchers nur den Warenkorb (Kennungen der Artikel und Mengen, ohne Personenbezug) im Sitzungsspeicher (sessionStorage) des Tabs, solange der Tab geöffnet ist; das ist für den Warenkorb technisch erforderlich. Andere Daten legt das Widget im Browser nicht ab.
(6) Betroffene: Käufer, die über die Website des Kunden Waren bestellen, und Personen, die über die Widerrufsfunktion einen Vertrag widerrufen. Beschäftigte des Kunden sind von diesem Vertrag nicht betroffen.
## 4. Weisungen
(1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, auch in Bezug auf eine Übermittlung in ein Drittland oder an eine internationale Organisation. Das gilt nicht, wenn das Recht der Europäischen Union oder Deutschlands WEBklar zu einer Verarbeitung verpflichtet. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es nicht verbietet.
(2) Mit der Bestellung weist der Kunde WEBklar an, den Webshop nach seinen Einstellungen und der Leistungsbeschreibung zu betreiben. Weitere Weisungen ergeben sich aus den Einstellungen und Aktionen im Kundenportal (Artikel, Versand, Abholung, Zahlungsarten, Rechtstexte, Bestellstatus, Erstattungen, Öffnen und Schließen des Webshops, Export). Sonstige Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Weisungen, die über diese Einstellungen hinausgehen, setzt WEBklar nur um, wenn das technisch vorgesehen ist; sonst kann der Kunde den Webshop abschalten lassen (Nr. 10 Abs. 7 der Leistungsbeschreibung). Die Anonymisierung und Löschung auf Weisung nach Nummer 8 Abs. 2 und Nummer 9 Abs. 6 setzt WEBklar immer um.
(3) Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin. WEBklar darf die Ausführung aussetzen, bis der Kunde die Weisung ändert oder die Rechtslage geklärt ist.
## 5. Vertraulichkeit
WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.
## 6. Technische und organisatorische Maßnahmen
(1) WEBklar trifft die Maßnahmen nach Art. 32 DSGVO, die in der Anlage zu diesem Vertrag beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt.
(2) Für den Server gelten ergänzend die Maßnahmen der Anlage 2 der Abo-Bedingungen, soweit die Anlage zu diesem Vertrag nichts anderes regelt.
## 7. Unterauftragsverarbeiter
(1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Webshop läuft, und für dessen Sicherung (Rechenzentrum Falkenstein, Deutschland) sowie für den Versand der Bestell-E-Mails über den SMTP-Dienst des Webhostings (Rechenzentrum Nürnberg, Deutschland). Eine Übermittlung in Drittländer findet nicht statt.
(2) Will WEBklar einen weiteren Unterauftragsverarbeiter hinzuziehen oder einen ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen. WEBklar setzt den neuen Unterauftragsverarbeiter nicht vor Ablauf dieser Frist ein. Kündigt ein Unterauftragsverarbeiter von WEBklar eine eigene Änderung mit kürzerer Frist an, informiert WEBklar den Kunden unverzüglich, nachdem die Ankündigung eingegangen ist; der Kunde kann dann unverzüglich widersprechen. Einigen sich die Parteien nicht, kann der Kunde den Webshop abschalten lassen und WEBklar ihn für diese Website zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt wird. Die Daten werden dann nach Nummer 9 gelöscht, auf Weisung des Kunden früher (Nummer 9 Abs. 6).
(3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf die Datenschutzpflichten nach Art. 28 Abs. 4 DSGVO. Bei der Hetzner Online GmbH ist das deren Vertrag zur Auftragsverarbeitung, den WEBklar abgeschlossen hat. Kommt ein Unterauftragsverarbeiter seinen Datenschutzpflichten nicht nach, haftet WEBklar dem Kunden für die Einhaltung dieser Pflichten (Art. 28 Abs. 4 Satz 2 DSGVO).
(4) **Stripe ist kein Unterauftragsverarbeiter von WEBklar.** Der Kunde verwendet sein eigenes Stripe-Konto und hat den Vertrag darüber unmittelbar mit Stripe geschlossen (Nr. 4 der Leistungsbeschreibung). Die Zahlungsseite (Stripe Checkout), die Erhebung von Zahlungs-, Karten- und Kontodaten, die Prüfung von Zahlungen und die Auszahlungen verantworten der Kunde und Stripe nach ihrem Vertrag; WEBklar erhält diese Daten nicht. Auf Weisung des Kunden ruft WEBklar bei Stripe die Angaben zu Zahlungen und Bestellungen ab, die der Webshop braucht (Nummer 2 Abs. 1), und veranlasst Erstattungen. Daten von Käufern gehen dabei nicht von WEBklar an Stripe, außer der Erstattung selbst und der Kennung der Zahlung. Die Weitergabe von Daten durch Stripe, auch an Stellen außerhalb der Europäischen Union, regeln der Kunde und Stripe; dafür ist WEBklar nicht verantwortlich.
## 8. Unterstützung des Kunden, Betroffenenrechte
(1) WEBklar leitet Anfragen von Käufern, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter. WEBklar unterstützt ihn mit geeigneten technischen und organisatorischen Maßnahmen bei der Beantwortung.
(2) Dafür bietet das Kundenportal den Export der Bestellungen als CSV-Datei und die Anzeige der Bestellungen und Widerrufe, mit denen der Kunde Auskunft erteilen kann. Auf Weisung des Kunden in Textform (etwa im Chat des Kundenportals) anonymisiert WEBklar die Käuferdaten einer Bestellung oder eines Widerrufs unverzüglich: Name, E-Mail-Adresse, Liefer- und Rechnungsadresse und die Freitexte werden gelöscht oder unkenntlich gemacht (ein IP-Hash ist zu diesem Zeitpunkt längst gelöscht); Bestellnummer, Positionen, Beträge und Status bleiben. Dabei löscht WEBklar die E-Mails zu dieser Bestellung, die noch auf den Versand warten. E-Mails, deren Versand endgültig fehlgeschlagen ist, behalten Empfängeradresse und Inhalt, bis WEBklar sie nach Nummer 9 Abs. 4 löscht (48 Stunden nach dem Fehlschlag). Gesetzliche Aufbewahrungspflichten des Kunden (Nummer 9 Abs. 8) prüft der Kunde vor seiner Weisung selbst.
(3) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.
(4) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO. Dazu gehören die Angaben, die er für sein Verzeichnis von Verarbeitungstätigkeiten und für eine etwaige Datenschutz-Folgenabschätzung braucht.
(5) Bestätigt der Kunde seine Rechtstexte im Kundenportal (Allgemeine Geschäftsbedingungen, Widerrufsbelehrung, Muster-Widerrufsformular, Versand und Zahlung, Gewährleistungshinweis sowie die Adressen von Impressum und Datenschutzerklärung), legt WEBklar jede bestätigte Fassung einmal im Archiv der Rechtstexte ab: mit ihrer Prüfsumme (Hash), dem Zeitpunkt und der Angabe, welche angemeldete Person des Kunden (Name oder E-Mail-Adresse) sie bestätigt hat. Die Bestellung speichert die Prüfsumme der Fassung, die der Käufer bei der Bestellung gesehen hat. Die Bestellbestätigung (G09) zeigt die archivierte Fassung zu dieser Prüfsumme, nicht die Fassung, die beim Versand der E-Mail gerade gilt; fehlt die archivierte Fassung ausnahmsweise, verwendet WEBklar die aktuell bestätigte Fassung. Das Archiv enthält keine Daten von Käufern. WEBklar löscht es mit den übrigen Daten des Webshops (Nummer 9 Abs. 6), nicht früher. Das Archiv hilft dem Kunden, die Fassung nachzuweisen, die ein Käufer erhalten hat; es ersetzt nicht seine eigene Aufbewahrung (Nummer 9 Abs. 8). Die Bestellbestätigung enthält diese Texte; WEBklar löscht ihren Inhalt nach dem Versand aus der Versandwarteschlange (Nummer 9 Abs. 4).
(6) Die Käufer informiert der Kunde (Art. 13 DSGVO; Nummer 11 Abs. 2).
## 9. Löschung und Rückgabe
(1) Eine Bestellung, die nicht bezahlt wird, verfällt 30 Minuten nach dem Anlegen und gilt als storniert. WEBklar löscht nicht bezahlte Bestellungen (offen oder storniert) 30 Tage nach dem Anlegen. Den IP-Hash löscht WEBklar aus dem Datensatz, sobald die Bestellung bezahlt oder storniert wird, spätestens mit dieser Löschung.
(2) Bei bezahlten Bestellungen anonymisiert WEBklar die Käuferdaten 3 Jahre nach dem Ende des Kalenderjahres, in dem die Bestellung angelegt wurde (Bestelljahr; maßgeblich ist die Zeit in Berlin): Name, E-Mail-Adresse sowie Liefer- und Rechnungsadresse werden gelöscht (der IP-Hash ist mit der Zahlung bereits gelöscht; eine Telefonnummer des Käufers wird nicht gespeichert). Bestellnummer, Positionen, Beträge, Steuerausweis, Status, Versanddienst und Sendungsnummer bleiben für die Buchhaltung des Kunden erhalten.
(3) Widerrufe nach Nummer 3 Abs. 2 behandelt WEBklar wie bezahlte Bestellungen: Name, E-Mail-Adresse und Erklärung werden 3 Jahre nach dem Ende des Kalenderjahres des Eingangs gelöscht; die Bestellnummer, die die Person angegeben hat, und der Zeitpunkt des Eingangs bleiben.
(4) In der Versandwarteschlange für E-Mails leert WEBklar die Empfängeradresse und den Inhalt einer E-Mail nach dem Versand und löscht den Eintrag 7 Tage nach dem Versand. E-Mails, die nicht zugestellt werden können, löscht WEBklar mit Empfängeradresse und Inhalt 48 Stunden nach dem endgültigen Fehlschlag.
(5) Der Löschlauf läuft täglich; gelöscht oder anonymisiert wird frühestens nach Ablauf der jeweiligen Frist. Ist an einem Tag mehr zu bearbeiten, als ein Lauf schafft, folgt der Rest im nächsten Lauf.
(6) Ist der Webshop abgeschaltet oder der Vertrag über den Betrieb der Website beendet, kann der Kunde die Bestellungen und Widerrufe 90 Tage lang im Kundenportal lesen und als CSV-Datei exportieren. Danach löscht WEBklar alle Daten des Webshops: Artikel, Bilder, Bestellungen, Widerrufe, Einstellungen, Rechtstexte und das Archiv der bestätigten Fassungen der Rechtstexte (Nummer 8 Abs. 5). Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Angaben zur Einrichtung bestehen (zum Beispiel Adresse der Website, Einbau-Code und die Angaben des Kunden aus der Anfrage) und enthält keine Daten von Käufern; dafür gilt die Datenschutzerklärung von WEBklar (Abschnitt 6). Beginnt innerhalb dieser Frist wieder ein Vertrag über den Betrieb der Website, schaltet WEBklar den Webshop auf Wunsch des Kunden mit den vorhandenen Daten wieder ein (Nr. 10 Abs. 5 der Leistungsbeschreibung). Früher anonymisiert WEBklar einzelne Daten auf Weisung des Kunden (Nummer 8 Abs. 2); alle Daten des Webshops löscht WEBklar auf Weisung des Kunden in Textform unverzüglich.
(7) Die Daten sind in der täglichen Vollsicherung des Servers enthalten (§ 3 Abs. 5 der Abo-Bedingungen). Gelöschte oder anonymisierte Daten bleiben dort bis zu sieben Tage erhalten und werden dann überschrieben. Wird eine Sicherung zurückgespielt, holt der tägliche Löschlauf die fälligen Löschungen und Anonymisierungen nach; Löschungen auf Weisung des Kunden wiederholt WEBklar.
(8) Gesetzliche Aufbewahrungspflichten bleiben unberührt. Der Kunde ist für seine eigenen Aufbewahrungspflichten (zum Beispiel für Buchungsbelege) selbst verantwortlich; WEBklar ist kein Archiv im Sinne der Buchführung, und das Archiv der Rechtstexte dient nur dem Nachweis der Texte. Der Kunde sichert die Belege, die er braucht, rechtzeitig vor der Anonymisierung oder Löschung durch den Export und die Belege von Stripe.
## 10. Nachweise und Kontrollen
WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Zertifizierung der Hetzner Online GmbH nach ISO/IEC 27001). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.
## 11. Pflichten des Kunden
(1) Der Kunde ist Verantwortlicher für die Verarbeitung der Daten seiner Käufer im Webshop.
(2) Der Kunde informiert seine Käufer nach Art. 13 DSGVO. Dazu nimmt er den Datenschutz-Absatz für den Webshop in seine Datenschutzerklärung auf (Nr. 5 Abs. 10 der Leistungsbeschreibung) und nennt in seinen Rechtstexten (Datenschutzerklärung, Impressum) die Angaben, die das Gesetz verlangt.
(3) Der Kunde schließt mit Stripe den Vertrag über sein Stripe-Konto selbst und verantwortet die Verarbeitung von Daten durch Stripe, soweit sie nicht in diesem Vertrag geregelt ist (Nummer 7 Abs. 4).
(4) Der Kunde verkauft keine ausgeschlossenen Waren (Nr. 3 der Leistungsbeschreibung) und fordert von seinen Käufern keine Angaben zu ihrer Gesundheit oder zu anderen besonderen Kategorien personenbezogener Daten, auch nicht im Freitext der Widerrufserklärung.
(5) Der Kunde verändert den Einbau-Code und die gesetzlich vorgeschriebenen Hinweise des Widgets nicht und entfernt den Link „Vertrag widerrufen“ nicht (Nr. 5 Abs. 6 und 9 der Leistungsbeschreibung).
## 12. Haftung, Änderungen, Schlussbestimmungen
(1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen.
(2) Eine neue Fassung dieses Vertrags oder der Leistungsbeschreibung, auch wegen geänderter Datenerhebung, teilt WEBklar dem Kunden mindestens sechs Wochen vor ihrem Wirksamwerden in Textform mit. Sie wird wirksam, wenn der Kunde ihr im Kundenportal oder in Textform zustimmt. Stimmt er nicht zu, gilt die bisherige Fassung weiter.
(3) Ist die neue Fassung nötig, weil ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form nicht mehr zulässt, und stimmt der Kunde ihr nicht zu, darf WEBklar den Webshop für seine Website nach Nr. 11 Abs. 2 der Leistungsbeschreibung beenden oder nach Nr. 11 Abs. 3 der Leistungsbeschreibung aussetzen. Das Abo bleibt davon unberührt.
(4) Im Übrigen gilt § 16 der Abo-Bedingungen.
## Anlage: Technische und organisatorische Maßnahmen des Webshops (Art. 32 DSGVO)
| Bereich | Maßnahmen |
|---|---|
| Übertragung | Das Widget, die Seiten der Käufer und das Kundenportal werden nur über TLS (HTTPS) übertragen. Der Einbau-Code verweist auf eine Adresse mit https://. Die Zahlung geschieht auf der Zahlungsseite von Stripe. |
| Speicher im Browser | Es werden keine Cookies gesetzt. Das Widget speichert nur den Warenkorb (Kennungen der Artikel und Mengen) im Sitzungsspeicher des Tabs. Es sendet seine Anfragen ohne Cookies und ruft nur die Schnittstelle des Webshops auf, keine Dienste Dritter. |
| Zugriffskontrolle | Das Kundenportal ist nur nach Anmeldung erreichbar; jeder Kunde sieht nur die Daten seiner eigenen Projekte. Die öffentliche Schnittstelle des Widgets liefert nur für einen vorhandenen Webshop Daten und ist über den Website-Schlüssel (Site-Key) adressiert; Bestellungen und Widerrufe nimmt sie nur von Seiten des Kunden und des Portals an. Den Stand einer Bestellung ruft der Käufer nur mit einem geheimen Token ab, das nur als Prüfsumme gespeichert wird. Benachrichtigungen von Stripe werden nur nach Prüfung ihrer Signatur verarbeitet; die Schlüssel für Stripe liegen nur auf dem Server. |
| Missbrauchsschutz | Ein verstecktes Feld (Honeypot), eine Mindestzeit bis zum Absenden des Formulars mit signiertem Formular-Token und eine Mindestantwortzeit schützen vor automatisierten Bestellungen und Widerrufen. Grenzen je IP-Adresse und je Webshop gelten im Arbeitsspeicher; ein Neustart leert sie. Die Obergrenze offener Bestellungen je IP-Adresse (drei in 30 Minuten) arbeitet mit einem gesalzenen Hashwert der IP-Adresse in der Bestellung (Nummer 3 Abs. 4). |
| Datenminimierung | Kartendaten und Kontodaten erhält und speichert WEBklar nicht. Preise berechnet der Server aus den gespeicherten Artikeln, nie aus Angaben des Browsers. Eine Telefonnummer wird weder abgefragt noch gespeichert, die Rechnungsadresse nur, soweit Stripe sie für die Zahlungsart braucht. Mit der Bestellung und mit dem Widerruf werden keine IP-Adresse im Klartext und kein Browser-Kennzeichen gespeichert, bei der Bestellung nur der gesalzene IP-Hash, und der nur, solange die Bestellung offen ist (gelöscht mit Zahlung oder Stornierung). Die Käuferdaten werden nach den Fristen der Nummer 9 anonymisiert. Nach dem Versand einer E-Mail werden Empfängeradresse und Inhalt in der Warteschlange geleert. |
| Trennung | Alle Artikel, Bestellungen und Widerrufe tragen die Kennung des Webshops und des Kunden; das Kundenportal und die öffentliche Schnittstelle lesen immer nur die Daten des jeweiligen Webshops. Artikelbilder liegen in einem eigenen Ordner je Webshop. Die Hintergrundläufe (Abgleich offener Bestellungen, Versand der E-Mails, Anonymisierung und Löschung) schreiben jeweils nur in das Dokument, um das es geht. |
| Löschung | Täglicher Löschlauf für Bestellungen, Widerrufe und Warteschlange nach den Fristen der Nummer 9; die Daten abgeschalteter Webshops einschließlich des Archivs der bestätigten Rechtstexte 90 Tage nach dem Abschalten; das Einrichtungs-Ticket bleibt als Arbeitsnachweis mit einer Kurzfassung der Angaben zur Einrichtung bestehen und enthält keine Daten von Käufern (Nummer 9 Abs. 6). |
| Protokolle | Die Programmprotokolle des Webshops und der Tagesbericht enthalten nur Zählungen und Bestellnummern, keine Namen, E-Mail-Adressen oder Adressen von Käufern. Server-Protokolle des Webservers werden nach 14 Tagen gelöscht (Anlage 2 der Abo-Bedingungen). |
| Datensicherung | Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen). |
| Rechenzentrum | Hetzner Online GmbH, Rechenzentren Falkenstein und Nürnberg (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert; der Geltungsbereich umfasst die Hosting-Dienstleistungen und die Rechenzentren. |
| Organisation | Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nummer 8 Abs. 3), Überprüfung der Maßnahmen mindestens einmal im Jahr. |
+138
View File
@@ -0,0 +1,138 @@
# Auftragsverarbeitungsvertrag Website-Analyse (Art. 28 DSGVO)
**Fassung 2026-10-16** · Entwurf – Prüfung durch Kanzlei offen
zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“).
## Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen
(1) Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Funktion „Website-Analyse“ einschaltet und die dort angezeigte Zeile „Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung 2026-10-16)“ mit dem Klick auf „Einschalten“ bestätigt; diesem Vertrag und der Leistungsbeschreibung „Website-Analyse“ („Leistungsbeschreibung“) in der angezeigten Fassung stimmt er damit zu. Das Portal speichert den Wortlaut der angezeigten Zeile, die Fassungen, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte in dieser Fassung als PDF enthält und auf sie verlinkt.
(2) Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform (etwa im Chat des Kundenportals oder per E-Mail) erklärt hat, dass er diesem Vertrag und der Leistungsbeschreibung in der aktuellen Fassung zustimmt. WEBklar speichert die Fassung, das Datum und den Weg dieser Erklärung, den Zeitpunkt des Einschaltens und den Namen des Mitarbeiters. Der Kunde erhält die E-Mail nach Absatz 1.
(3) Dieser Vertrag ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Für die Website-Analyse geht er Anlage 1 vor; im Übrigen gilt Anlage 1 ergänzend. In Datenschutzfragen geht er den Abo-Bedingungen und der Leistungsbeschreibung „Website-Analyse“ vor.
## 1. Gegenstand und Dauer
(1) WEBklar betreibt für die öffentliche Website des Kunden eine Reichweitenmessung mit Einwilligungsbanner („Website-Analyse“) und verarbeitet dabei personenbezogene Daten der Besucher der Website im Auftrag des Kunden. Den Umfang beschreibt die Leistungsbeschreibung „Website-Analyse“.
(2) Der Vertrag beginnt mit dem Einschalten der Funktion. Er endet mit dem Ausschalten, spätestens mit dem Ende des Abos, das die Website-Analyse enthält (Hosting-Abo oder Wartungs-Abo); endet nur das Wartungs-Abo und läuft ein Hosting-Abo weiter, läuft dieser Vertrag weiter. Den Messdienst schaltet WEBklar bei Vertragsende spätestens am folgenden Tag ab. Die Pflichten aus diesem Vertrag gelten fort, bis alle Daten nach Nummer 9 gelöscht sind.
## 2. Art und Zweck der Verarbeitung
(1) Art: Erheben über ein Skript, das WEBklar beim Veröffentlichen in die Website des Kunden einfügt, Übermitteln an den Messdienst von WEBklar, Speichern, Verdichten zu Tageswerten, Auswerten im Kundenportal (Kennzahlen, Heatmap) und Löschen.
(2) Zwecke: Der Kunde soll erkennen, wie seine Website genutzt wird, um sie zu verbessern. Außerdem schützt WEBklar den Messdienst für den Kunden vor Missbrauch und verfälschten Zahlen (Mengenbegrenzung, Erkennen von Bots).
(3) WEBklar verwendet die Daten für keine eigenen Zwecke, auch nicht in anonymisierter Form und nicht für Auswertungen über mehrere Kunden. Es findet keine Werbung und keine Zusammenführung mit anderen Daten statt. WEBklar bildet keine Profile einzelner Besucher. Die Besucher-Kennung dient nur dazu, die Besucher eines Tages zu zählen.
## 3. Datenarten und Betroffene
(1) Solange der Besucher im Banner keine Wahl getroffen hat und sein Browser kein Signal nach Absatz 2 Satz 2 sendet: Pfad der aufgerufenen Seite (ohne Suchparameter und Anker), auch bei einem Seitenwechsel ohne Neuladen; Land (aus der IP-Adresse abgeleitet); Geräteklasse (Handy, Tablet oder Computer; aus dem Browser-Kennzeichen abgeleitet); Zeitpunkt (auf die volle Stunde gerundet); ein Zähler dafür, dass das Banner angezeigt wurde.
(2) Nach „Ablehnen“ sendet das Skript einmalig einen Zähler „abgelehnt“ und danach nichts mehr. Sendet der Browser die Signale „Global Privacy Control“ oder „Do Not Track“, zeigt das Skript kein Banner und sendet nichts.
(3) Nach „Annehmen“ zusätzlich: Domain der Website, von der der Besucher kam; Größenklasse des Browserfensters; eine täglich wechselnde pseudonyme Besucher-Kennung; Verweildauer (ab dem Laden der Seite) und Scrolltiefe je Seite; Klickpositionen mit einem Elementpfad aus Tag-Namen und Position (ohne Texte, Kennungen und Klassennamen); die Art von Kontakt-Klicks (Telefon, E-Mail, WhatsApp oder Link auf eine andere Website, dann mit deren Domain); ein Zähler „angenommen“.
(4) IP-Adresse und Browser-Kennzeichen (User-Agent) erhält der Server von WEBklar technisch bei jedem Abruf des Skripts und bei jeder Messanfrage, auch nach „Ablehnen“. Der Messdienst verarbeitet sie nur kurz im Arbeitsspeicher, um Land, Geräteklasse, Bots, die Mengenbegrenzung und, nur nach Einwilligung, die Besucher-Kennung zu bestimmen. Er speichert und protokolliert sie nicht. Nicht erhoben werden Cookies, Formularinhalte, Seitentexte, Mausbewegungen, Tastatureingaben und Sitzungsaufzeichnungen.
(5) Tageswerte sind Summen je Tag und Merkmal (etwa Aufrufe je Seite, je Land oder je Geräteklasse), ohne Besucher-Kennung. Enthalten die Pfade der Website personenbezogene Angaben, können auch Tageswerte personenbezogen sein (Nummer 11 Abs. 2).
(6) Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht gezielt verarbeitet. Je nach Inhalt der Website können sich aus Pfaden oder Domains aber Rückschlüsse ergeben (Nummer 11 Abs. 3).
(7) Betroffene: Besucher der Website des Kunden.
## 4. Weisungen
(1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, auch in Bezug auf eine Übermittlung in ein Drittland oder an eine internationale Organisation. Das gilt nicht, wenn das Recht der Europäischen Union oder Deutschlands WEBklar zu einer Verarbeitung verpflichtet. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es nicht verbietet.
(2) Mit dem Einschalten weist der Kunde WEBklar an, die Website-Analyse in der Standardkonfiguration nach der Leistungsbeschreibung zu betreiben. Weitere Weisungen ergeben sich aus den Einstellungen im Kundenportal (Einschalten, Ausschalten, Akzentfarbe des Banners). Sonstige Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Weisungen, die von der Standardkonfiguration abweichen, setzt WEBklar nur um, wenn das technisch vorgesehen ist; sonst kann der Kunde die Funktion ausschalten.
(3) Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin. WEBklar darf die Ausführung aussetzen, bis der Kunde die Weisung ändert oder die Rechtslage geklärt ist.
## 5. Vertraulichkeit
WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.
## 6. Technische und organisatorische Maßnahmen
(1) WEBklar trifft die Maßnahmen nach Art. 32 DSGVO, die in der Anlage zu diesem Vertrag beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt.
(2) Für den Server gelten ergänzend die Maßnahmen der Anlage 2 der Abo-Bedingungen, soweit die Anlage zu diesem Vertrag nichts anderes regelt.
## 7. Unterauftragsverarbeiter
(1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Messdienst läuft, und für dessen Sicherung. Ort der Verarbeitung ist das Rechenzentrum Falkenstein (Deutschland). Eine Übermittlung in Drittländer findet nicht statt.
(2) Will WEBklar einen weiteren Unterauftragsverarbeiter hinzuziehen oder einen ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen. WEBklar setzt den neuen Unterauftragsverarbeiter nicht vor Ablauf dieser Frist ein. Kündigt ein Unterauftragsverarbeiter von WEBklar eine eigene Änderung mit kürzerer Frist an, informiert WEBklar den Kunden unverzüglich, nachdem die Ankündigung eingegangen ist; der Kunde kann dann unverzüglich widersprechen. Einigen sich die Parteien nicht, kann der Kunde die Website-Analyse ausschalten und WEBklar sie für diese Website zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt wird. Die Daten werden dann nach Nummer 9 gelöscht, auf Verlangen des Kunden sofort.
(3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf die Datenschutzpflichten nach Art. 28 Abs. 4 DSGVO. Bei der Hetzner Online GmbH ist das deren Vertrag zur Auftragsverarbeitung, den WEBklar abgeschlossen hat. Kommt ein Unterauftragsverarbeiter seinen Datenschutzpflichten nicht nach, haftet WEBklar dem Kunden für die Einhaltung dieser Pflichten (Art. 28 Abs. 4 Satz 2 DSGVO).
(4) Die Länderzuordnung nutzt eine Datenbank, die WEBklar auf dem eigenen Server speichert („IP to Country Lite“ von DB-IP.com, Lizenz CC BY 4.0). Daten der Besucher werden dabei nicht an DB-IP übermittelt. DB-IP ist kein Unterauftragsverarbeiter.
## 8. Unterstützung des Kunden, Betroffenenrechte
(1) WEBklar leitet Anfragen betroffener Personen, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter. WEBklar unterstützt ihn mit geeigneten technischen und organisatorischen Maßnahmen bei der Beantwortung.
(2) Die gespeicherten Messdaten enthalten keine IP-Adresse, keinen Namen und keine Kennung, die länger als einen Tag gilt; Zeitpunkte sind auf die volle Stunde gerundet. Einzelne Besucher kann WEBklar daher in der Regel nicht identifizieren (Art. 11 DSGVO). Ein Abgleich kann aber möglich sein, solange die Zugriffsprotokolle der Website (Anlage 1 und 2 der Abo-Bedingungen, 14 Tage) vorhanden sind. Nennt eine betroffene Person zusätzliche Angaben, etwa Datum, Uhrzeit, besuchte Seiten oder ihre IP-Adresse, prüft WEBklar auf Weisung des Kunden, auch anhand dieser Protokolle, ob eine Zuordnung möglich ist, und teilt ihm das Ergebnis mit.
(3) Der Messung können Besucher technisch widersprechen (Art. 21 DSGVO): Nach „Ablehnen“ und bei den Browser-Signalen „Global Privacy Control“ und „Do Not Track“ sendet das Skript nichts mehr.
(4) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO. Dazu gehören die Angaben, die er für sein Verzeichnis von Verarbeitungstätigkeiten und für eine etwaige Datenschutz-Folgenabschätzung braucht, und eine kurze Interessenabwägung für die Zählung nach Art. 6 Abs. 1 lit. f DSGVO, die WEBklar mit jeder Fassung dieses Vertrags bereitstellt.
(5) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.
(6) Zum Nachweis der Einwilligungen bewahrt WEBklar jede Fassung des Banner-Textes und der Zusatzseite mit dem Zeitraum ihrer Verwendung auf, bis drei Jahre nach dem Ende der Website-Analyse. WEBklar stellt sie dem Kunden auf Verlangen zur Verfügung. Ein Protokoll je Besucher führt WEBklar nicht.
(7) Die Besucher informiert der Kunde (Art. 13 DSGVO). WEBklar stellt ihm dafür die Zusatzseite „/datenschutz-analyse.html“ als Muster bereit. Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar dort einen Hinweis auf die Zusatzseite ein; sonst erhält der Kunde den Hinweis als Textbaustein (Leistungsbeschreibung Nr. 4 Abs. 4).
## 9. Löschung und Rückgabe
(1) Einzelne Ereignisse und Klickpositionen löscht WEBklar automatisch nach 30 Tagen. Der Löschlauf läuft täglich, die Löschung erfolgt also in der Regel spätestens am 31. Tag; fällt ein Lauf aus, holt der nächste ihn nach.
(2) Tageswerte löscht WEBklar 25 Monate nach dem jeweiligen Tag. Nach dem Ende der Website-Analyse (Ausschalten oder Ende des Abos, das sie enthält) löscht WEBklar alle Daten der Website am 61. Tag. Bis zum 46. Tag nach dem Ende kann der Kunde eine Kopie der Tageswerte und der noch vorhandenen Einzelereignisse verlangen; WEBklar stellt sie innerhalb von zwei Wochen als CSV-Datei bereit. Auf Weisung des Kunden löscht WEBklar die Daten früher.
(3) Die Daten sind in der täglichen Vollsicherung des Servers enthalten (§ 3 Abs. 5 der Abo-Bedingungen). Gelöschte Daten bleiben dort bis zu sieben Tage erhalten und werden dann überschrieben. Wird eine Sicherung zurückgespielt, holt der tägliche Löschlauf die fälligen Löschungen nach; Löschungen auf Weisung des Kunden wiederholt WEBklar.
(4) Gesetzliche Aufbewahrungspflichten bleiben unberührt.
## 10. Nachweise und Kontrollen
WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Zertifizierung der Hetzner Online GmbH nach ISO/IEC 27001). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.
## 11. Pflichten des Kunden
(1) Der Kunde ist Verantwortlicher für die Messung. Er entfernt, verdeckt oder umgeht das Banner nicht. Er bindet ohne Abstimmung mit WEBklar keine weiteren Mess- oder Tracking-Werkzeuge ein.
(2) Enthalten die Pfade seiner Website personenbezogene Angaben, etwa Namen, Kundennummern oder E-Mail-Adressen, teilt der Kunde das WEBklar vor dem Einschalten mit.
(3) Richtet sich die Website auch gezielt an Kinder unter 16 Jahren oder lassen ihre Inhalte auf besondere Kategorien personenbezogener Daten schließen (etwa Gesundheit, Religion oder politische Meinung), gilt Nr. 3 Abs. 4 der Leistungsbeschreibung.
## 12. Haftung, Änderungen, Schlussbestimmungen
(1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen.
(2) Eine neue Fassung dieses Vertrags oder der Leistungsbeschreibung teilt WEBklar dem Kunden mindestens sechs Wochen vor ihrem Wirksamwerden in Textform mit. Sie wird wirksam, wenn der Kunde ihr im Kundenportal oder in Textform zustimmt. Stimmt er nicht zu, gilt die bisherige Fassung weiter.
(3) Ist die neue Fassung nötig, weil ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form nicht mehr zulässt, und stimmt der Kunde ihr nicht zu, darf WEBklar die Website-Analyse für seine Website nach Nr. 8 Abs. 2 der Leistungsbeschreibung beenden oder aussetzen. Das Abo bleibt davon unberührt.
(4) Im Übrigen gilt § 16 der Abo-Bedingungen.
## Anlage: Technische und organisatorische Maßnahmen der Website-Analyse (Art. 32 DSGVO)
| Bereich | Maßnahmen |
|---|---|
| Übertragung | Skript und Messdaten werden nur über TLS (HTTPS) übertragen. |
| Speicher im Browser | Es werden keine Cookies gesetzt, und der Messdienst wertet keine Cookies aus. Messanfragen sendet das Skript ohne Cookies. Im lokalen Speicher des Browsers liegt nur die Banner-Wahl (Eintrag „wk-consent“ mit Wahl, Zeitpunkt und Fassung des Banner-Textes). Sie gilt 183 Tage; danach und bei einer neuen Fassung des Banner-Textes fragt das Banner erneut. |
| Einwilligung | Solange keine Wahl getroffen ist und kein Browser-Signal vorliegt, sendet das Skript nur den Pfad der Seite, auch beim Seitenwechsel ohne Neuladen, und den Zähler „Banner angezeigt“. Nach „Ablehnen“ sendet es einmalig den Zähler „abgelehnt“ und danach nichts mehr, auch nicht aus anderen geöffneten Tabs. Bei „Global Privacy Control“ oder „Do Not Track“ zeigt es kein Banner und sendet nichts. Klick-, Scroll- und Verweildauer-Ereignisse registriert es erst nach „Annehmen“. Die Schaltflächen „Ablehnen“ und „Annehmen“ sind gleich gestaltet. |
| Datenminimierung | IP-Adresse und Browser-Kennzeichen werden weder gespeichert noch protokolliert. Der Messdienst verwendet sie nur im Arbeitsspeicher für Land, Geräteklasse, Bot-Erkennung, Mengenbegrenzung und, nur nach Einwilligung, die Besucher-Kennung. Zeitpunkte werden auf die volle Stunde gerundet gespeichert. Pfade werden ohne Suchparameter und Anker gespeichert; Abschnitte, die wie Kennungen aussehen (lange Ziffernfolgen, UUIDs, E-Mail-Adressen), werden vorher ersetzt. Elementpfade bestehen nur aus Tag-Namen und Position. Formulareingaben und Seitentexte werden nicht erfasst. |
| Pseudonymisierung | Die Besucher-Kennung (nur nach Einwilligung) ist ein auf 64 Bit gekürzter HMAC-SHA256 aus Website-Schlüssel, IP-Adresse und Browser-Kennzeichen mit einem Tagesschlüssel. Auch der Schlüssel der Mengenbegrenzung wird mit dem Tagesschlüssel gebildet. Der Tagesschlüssel liegt nur im Arbeitsspeicher; die Auslagerung auf die Festplatte ist für den Messdienst gesperrt. Er wird beim ersten Ereignis eines neuen Kalendertags (Europe/Berlin) und bei jedem Neustart neu erzeugt und nie gesichert. Ältere Kennungen lassen sich danach nicht mehr zuordnen. |
| Zugriffskontrolle | Die Verwaltungsschnittstelle des Messdienstes (Auswertung, Website-Verwaltung) läuft auf einem internen Port, der nicht öffentlich erreichbar ist, und ist durch ein Zugriffstoken geschützt. Zugriff haben nur das Kundenportal und die Verwaltung von WEBklar. Das Portal zeigt jedem Kunden nur die Daten seiner eigenen Websites. |
| Heatmap | Die Heatmap öffnet ein signierter Link, den nur der angemeldete Kunde im Kundenportal erzeugen kann. Der Link gilt 30 Minuten und liefert nur Klickpunkte der gewählten Seite und Ansicht, ohne Zeitpunkte und Kennungen. |
| Eingangsprüfung | Messdaten werden nur angenommen, wenn die Website registriert und eingeschaltet ist und Origin bzw. Referer zu ihren Domains passen. Bots werden verworfen. Größe und Menge der Anfragen sind je Website und Absender begrenzt (Zähler nur im Arbeitsspeicher). Für ausgeschaltete Websites liefert der Messdienst ein leeres Skript aus. |
| Löschung | Täglicher Löschlauf um 02:30 Uhr (Europe/Berlin): Ereignisse und Klicks nach 30 Tagen, Tageswerte nach 25 Monaten, alle Daten einer Website 60 Tage nach dem Ausschalten. Gelöschte Datensätze werden in der Datenbankdatei überschrieben und aus dem Schreibprotokoll (WAL) entfernt. |
| Trennung | Der Messdienst läuft in einem eigenen Container mit eigenem Datenverzeichnis und hat keinen Zugriff auf andere Kundendaten. Alle Datensätze tragen den Website-Schlüssel; Abfragen erfolgen nur je Website. |
| Protokolle | Der Reverse-Proxy führt kein Zugriffsprotokoll, und der Messdienst protokolliert keine Anfragen. Wird ein Zugriffsprotokoll eingeschaltet, dann nicht für die Adresse des Messdienstes. Meldungen der Container werden nach 14 Tagen gelöscht. |
| Datensicherung | Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen). Einen gesonderten Datenbank-Export des Messdienstes gibt es nicht. |
| Rechenzentrum | Hetzner Online GmbH, Rechenzentrum Falkenstein (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert; der Geltungsbereich umfasst die Hosting-Dienstleistungen und die Rechenzentren. |
| Organisation | Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nummer 8 Abs. 5), Archiv der Fassungen von Banner-Text und Zusatzseite (Nummer 8 Abs. 6), Überprüfung der Maßnahmen mindestens einmal im Jahr. |
+65 -59
View File
@@ -1,47 +1,54 @@
# Auftragsverarbeitungsvertrag Website-Analyse (Art. 28 DSGVO)
> **Entwurf – vor Verwendung anwaltlich prüfen lassen.** Dieser Text ist keine Rechtsberatung. Stellen mit „Prüfpunkt Anwalt“ sind offen.
**Fassung 2026-10-01**
**Fassung 2026-10-02**
zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“).
Dieser Vertrag ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Er wird vereinbart, wenn der Kunde im Kundenportal die Funktion „Website-Analyse“ einschaltet und dabei die Zustimmung zu dieser Fassung bestätigt (Haken im Portal; das Portal speichert Fassung, Zeitpunkt und Person). Schaltet ein Mitarbeiter von WEBklar die Funktion auf Wunsch des Kunden ein, bestätigt der Mitarbeiter im System, dass der Kunde diese Fassung des Vertrags bestätigt hat. Gespeichert werden die Fassung, der Zeitpunkt und der Name des Mitarbeiters. Bei Zustimmung im Kundenportal wird die Zustimmung dem Kundenkonto zugeordnet.
## Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen
<!-- Prüfpunkt Anwalt P11: Wirksamer Abschluss durch Portal-Haken (Textform, Art. 28 Abs. 9 DSGVO) und mündliche Bestätigung durch Mitarbeiter; Nachweis der Fassung. Reicht die Bestätigung durch den Mitarbeiter (nur Fassung, Zeitpunkt, Name; keine Textform-Bestätigung des Kunden gespeichert) für den Abschluss? -->
<!-- Prüfpunkt Anwalt P6: Rollenverteilung. Kunde = Verantwortlicher, WEBklar = Auftragsverarbeiter; keine gemeinsame Verantwortlichkeit (Art. 26), weil WEBklar die Daten nur für den Kunden verwendet. Prüfen, ob die Auswertung in Aggregaten für WEBklar-Produktverbesserung ausgeschlossen bleiben soll (Ja, siehe Nr. 3). -->
(1) Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Funktion „Website-Analyse“ einschaltet und dabei per Haken diesem Vertrag und der Leistungsbeschreibung „Website-Analyse“ in der angezeigten Fassung zustimmt. Das Portal speichert die Fassung, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte in dieser Fassung als PDF enthält und auf sie verlinkt.
(2) Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform (etwa im Chat des Kundenportals oder per E-Mail) erklärt hat, dass er diesem Vertrag und der Leistungsbeschreibung in der aktuellen Fassung zustimmt. WEBklar speichert die Fassung, das Datum und den Weg dieser Erklärung, den Zeitpunkt des Einschaltens und den Namen des Mitarbeiters. Der Kunde erhält die E-Mail nach Absatz 1.
(3) Dieser Vertrag ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Für die Website-Analyse geht er Anlage 1 vor; im Übrigen gilt Anlage 1 ergänzend. In Datenschutzfragen geht er den Abo-Bedingungen und der Leistungsbeschreibung „Website-Analyse“ vor.
## 1. Gegenstand und Dauer
(1) WEBklar betreibt für die öffentliche Website des Kunden eine Reichweitenmessung („Website-Analyse“) und verarbeitet dabei personenbezogene Daten der Besucher der Website im Auftrag des Kunden. Die Leistung ist in der Leistungsbeschreibung „Website-Analyse“ beschrieben.
(1) WEBklar betreibt für die öffentliche Website des Kunden eine Reichweitenmessung mit Einwilligungsbanner („Website-Analyse“) und verarbeitet dabei personenbezogene Daten der Besucher der Website im Auftrag des Kunden. Den Umfang beschreibt die Leistungsbeschreibung „Website-Analyse“.
(2) Der Vertrag beginnt mit dem Einschalten der Funktion und gilt, solange die Funktion eingeschaltet ist. Er endet mit dem Ausschalten, spätestens mit dem Ende des Wartungs-Abos, und gilt darüber hinaus, bis alle Daten nach Nummer 9 gelöscht sind.
(2) Der Vertrag beginnt mit dem Einschalten der Funktion. Er endet mit dem Ausschalten, spätestens mit dem Ende des Wartungs-Abos; den Messdienst schaltet WEBklar dann spätestens am folgenden Tag ab. Die Pflichten aus diesem Vertrag gelten fort, bis alle Daten nach Nummer 9 gelöscht sind.
## 2. Art und Zweck der Verarbeitung
(1) Art: Erheben über ein Skript, das WEBklar in die Website des Kunden einfügt, Übermitteln an den Messdienst von WEBklar, Speichern, Verdichten zu Tageswerten, Auswerten im Kundenportal (Kennzahlen, Heatmap) und Löschen.
(1) Art: Erheben über ein Skript, das WEBklar beim Veröffentlichen in die Website des Kunden einfügt, Übermitteln an den Messdienst von WEBklar, Speichern, Verdichten zu Tageswerten, Auswerten im Kundenportal (Kennzahlen, Heatmap) und Löschen.
(2) Zweck: Der Kunde soll erkennen, wie seine Website genutzt wird, um sie zu verbessern. Andere Zwecke verfolgt WEBklar mit diesen Daten nicht. Es findet kein Profiling, keine Werbung und keine Zusammenführung mit anderen Daten statt.
(2) Zwecke: Der Kunde soll erkennen, wie seine Website genutzt wird, um sie zu verbessern. Außerdem schützt WEBklar den Messdienst für den Kunden vor Missbrauch und verfälschten Zahlen (Mengenbegrenzung, Erkennen von Bots).
(3) WEBklar verwendet die Daten für keine eigenen Zwecke, auch nicht in anonymisierter Form und nicht für Auswertungen über mehrere Kunden. Es findet keine Werbung und keine Zusammenführung mit anderen Daten statt. WEBklar bildet keine Profile einzelner Besucher. Die Besucher-Kennung dient nur dazu, die Besucher eines Tages zu zählen.
## 3. Datenarten und Betroffene
(1) Datenarten ohne Einwilligung des Besuchers: Seitenaufruf mit Pfad (ohne Suchparameter), Domainname der verweisenden Website, Land (abgeleitet aus der IP-Adresse; die IP-Adresse wird nur im Moment der Abfrage verwendet und nicht gespeichert), Geräteklasse (Handy, Tablet, Computer) und Größenklasse des Browserfensters (vp), anonyme Zähler zur Anzeige, Annahme und Ablehnung des Banners.
(1) Solange der Besucher im Banner keine Wahl getroffen hat und sein Browser kein Signal nach Absatz 2 Satz 2 sendet: Pfad der aufgerufenen Seite (ohne Suchparameter und Anker), auch bei einem Seitenwechsel ohne Neuladen; Land (aus der IP-Adresse abgeleitet); Geräteklasse (Handy, Tablet oder Computer; aus dem Browser-Kennzeichen abgeleitet); Zeitpunkt (auf die volle Stunde gerundet); ein Zähler dafür, dass das Banner angezeigt wurde.
(2) Zusätzlich nach Annehmen des Banners durch den Besucher: eine täglich wechselnde pseudonyme Besucher-Kennung (nicht über mehrere Tage verknüpfbar), Verweildauer und Scrolltiefe je Seite, Klickpositionen mit vereinfachtem Elementpfad (Heatmap, keine Formulareingaben), Art des Kontakt-Klicks (Telefon, E-Mail, WhatsApp, Link auf eine andere Website mit Domainname).
(2) Nach „Ablehnen“ sendet das Skript einmalig einen Zähler „abgelehnt“ und danach nichts mehr. Sendet der Browser die Signale „Global Privacy Control“ oder „Do Not Track“, zeigt das Skript kein Banner und sendet nichts.
(3) Nicht gespeichert werden IP-Adressen und Browser-Kennzeichen (User-Agent); sie werden nur kurzzeitig im Arbeitsspeicher verarbeitet (siehe Anlage). Nicht erhoben werden Cookies, Formularinhalte, Seiteninhalte, Mausbewegungen oder Sitzungsaufzeichnungen. Besondere Kategorien nach Art. 9 DSGVO werden nicht gezielt verarbeitet.
(3) Nach „Annehmen“ zusätzlich: Domain der Website, von der der Besucher kam; Größenklasse des Browserfensters; eine täglich wechselnde pseudonyme Besucher-Kennung; Verweildauer (ab dem Laden der Seite) und Scrolltiefe je Seite; Klickpositionen mit einem Elementpfad aus Tag-Namen und Position (ohne Texte, Kennungen und Klassennamen); die Art von Kontakt-Klicks (Telefon, E-Mail, WhatsApp oder Link auf eine andere Website, dann mit deren Domain); ein Zähler „angenommen“.
(4) Betroffene: Besucher der Website des Kunden.
(4) IP-Adresse und Browser-Kennzeichen (User-Agent) erhält der Server von WEBklar technisch bei jedem Abruf des Skripts und bei jeder Messanfrage, auch nach „Ablehnen“. Der Messdienst verarbeitet sie nur kurz im Arbeitsspeicher, um Land, Geräteklasse, Bots, die Mengenbegrenzung und, nur nach Einwilligung, die Besucher-Kennung zu bestimmen. Er speichert und protokolliert sie nicht. Nicht erhoben werden Cookies, Formularinhalte, Seitentexte, Mausbewegungen, Tastatureingaben und Sitzungsaufzeichnungen.
(5) WEBklar verwendet die Daten ausschließlich für den Kunden und gibt sie nicht an Dritte weiter, außer an Unterauftragsverarbeiter nach Nummer 7.
(5) Tageswerte sind Summen je Tag und Merkmal (etwa Aufrufe je Seite, je Land oder je Geräteklasse), ohne Besucher-Kennung. Enthalten die Pfade der Website personenbezogene Angaben, können auch Tageswerte personenbezogen sein (Nummer 11 Abs. 2).
<!-- Prüfpunkt Anwalt P2: Einordnung der Tages-Kennung (pseudonym oder faktisch anonym, EuGH C-413/23 P) und der Klickpositionen; Konsequenzen für Betroffenenrechte (Nr. 8). -->
(6) Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht gezielt verarbeitet. Je nach Inhalt der Website können sich aus Pfaden oder Domains aber Rückschlüsse ergeben (Nummer 11 Abs. 3).
(7) Betroffene: Besucher der Website des Kunden.
## 4. Weisungen
(1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, es sei denn, eine gesetzliche Pflicht verlangt etwas anderes. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es erlaubt.
(1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, auch in Bezug auf eine Übermittlung in ein Drittland oder an eine internationale Organisation. Das gilt nicht, wenn das Recht der Europäischen Union oder Deutschlands WEBklar zu einer Verarbeitung verpflichtet. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es nicht verbietet.
(2) Die Weisungen ergeben sich aus diesem Vertrag, der Leistungsbeschreibung und den Einstellungen des Kunden im Kundenportal (Einschalten und Ausschalten; die Akzentfarbe des Banners wird beim Einschalten gewählt). Weitere Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin und darf die Ausführung aussetzen.
(2) Mit dem Einschalten weist der Kunde WEBklar an, die Website-Analyse in der Standardkonfiguration nach der Leistungsbeschreibung zu betreiben. Weitere Weisungen ergeben sich aus den Einstellungen im Kundenportal (Einschalten, Ausschalten, Akzentfarbe des Banners). Sonstige Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Weisungen, die von der Standardkonfiguration abweichen, setzt WEBklar nur um, wenn das technisch vorgesehen ist; sonst kann der Kunde die Funktion ausschalten.
(3) Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin. WEBklar darf die Ausführung aussetzen, bis der Kunde die Weisung ändert oder die Rechtslage geklärt ist.
## 5. Vertraulichkeit
@@ -51,82 +58,81 @@ WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder e
(1) WEBklar trifft die Maßnahmen nach Art. 32 DSGVO, die in der Anlage zu diesem Vertrag beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt.
(2) Die Maßnahmen der Anlage 2 zu den Abo-Bedingungen gelten ergänzend.
(2) Für den Server gelten ergänzend die Maßnahmen der Anlage 2 der Abo-Bedingungen, soweit die Anlage zu diesem Vertrag nichts anderes regelt.
## 7. Unterauftragsverarbeiter
(1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Messdienst läuft, und für die Datensicherung. Ort der Verarbeitung ist ein Rechenzentrum in Deutschland (Falkenstein). Die Daten werden nicht in Drittländer übermittelt.
(1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Messdienst läuft, und für dessen Sicherung. Ort der Verarbeitung ist das Rechenzentrum Falkenstein (Deutschland). Eine Übermittlung in Drittländer findet nicht statt.
(2) Will WEBklar einen Unterauftragsverarbeiter hinzuziehen oder ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann aus wichtigem datenschutzrechtlichem Grund widersprechen. Einigen sich die Parteien nicht, kann der Kunde die Website-Analyse zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt werden soll.
(2) Will WEBklar einen weiteren Unterauftragsverarbeiter hinzuziehen oder einen ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen. WEBklar setzt den neuen Unterauftragsverarbeiter nicht vor Ablauf dieser Frist ein. Kündigt ein Unterauftragsverarbeiter von WEBklar eine eigene Änderung mit kürzerer Frist an, informiert WEBklar den Kunden unverzüglich, nachdem die Ankündigung eingegangen ist; der Kunde kann dann unverzüglich widersprechen. Einigen sich die Parteien nicht, kann der Kunde die Website-Analyse ausschalten und WEBklar sie für diese Website zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt wird. Die Daten werden dann nach Nummer 9 gelöscht, auf Verlangen des Kunden sofort.
(3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf dieselben Datenschutzpflichten, die sich aus diesem Vertrag ergeben.
(3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf die Datenschutzpflichten nach Art. 28 Abs. 4 DSGVO. Bei der Hetzner Online GmbH ist das deren Vertrag zur Auftragsverarbeitung, den WEBklar abgeschlossen hat. Kommt ein Unterauftragsverarbeiter seinen Datenschutzpflichten nicht nach, haftet WEBklar dem Kunden für die Einhaltung dieser Pflichten (Art. 28 Abs. 4 Satz 2 DSGVO).
(4) Die Länderzuordnung nutzt eine lokal gespeicherte Datenbank („DB-IP Lite Country“, Lizenz CC BY 4.0). Dabei werden keine Daten an den Anbieter der Datenbank übermittelt; er ist kein Unterauftragsverarbeiter.
<!-- Prüfpunkt Anwalt P13: Gültiger Auftragsverarbeitungsvertrag mit Hetzner besteht (laut Datenschutzerklärung ja); Standort Falkenstein bestätigen. P12: Einordnung DB-IP als Datenbank-Lizenz ohne Datenübermittlung. -->
(4) Die Länderzuordnung nutzt eine Datenbank, die WEBklar auf dem eigenen Server speichert („IP to Country Lite“ von DB-IP.com, Lizenz CC BY 4.0). Daten der Besucher werden dabei nicht an DB-IP übermittelt. DB-IP ist kein Unterauftragsverarbeiter.
## 8. Unterstützung des Kunden, Betroffenenrechte
(1) WEBklar leitet Anfragen betroffener Personen, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter und unterstützt ihn bei der Beantwortung, soweit das mit den eingesetzten Systemen möglich ist.
(1) WEBklar leitet Anfragen betroffener Personen, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter. WEBklar unterstützt ihn mit geeigneten technischen und organisatorischen Maßnahmen bei der Beantwortung.
(2) Die gespeicherten Daten enthalten keine IP-Adresse, keinen Namen und keine Kennung, die über einen Tag hinaus gilt. WEBklar kann einzelne Besucher daher in der Regel nicht identifizieren und Auskunfts-, Berichtigungs- oder Löschverlangen zu einzelnen Besuchen nicht umsetzen, solange die betroffene Person keine zusätzlichen Angaben bereitstellt (Art. 11 DSGVO). Der Kunde weist Betroffene darauf in seiner Datenschutzerklärung hin.
(2) Die gespeicherten Messdaten enthalten keine IP-Adresse, keinen Namen und keine Kennung, die länger als einen Tag gilt; Zeitpunkte sind auf die volle Stunde gerundet. Einzelne Besucher kann WEBklar daher in der Regel nicht identifizieren (Art. 11 DSGVO). Ein Abgleich kann aber möglich sein, solange die Zugriffsprotokolle der Website (Anlage 1 und 2 der Abo-Bedingungen, 14 Tage) vorhanden sind. Nennt eine betroffene Person zusätzliche Angaben, etwa Datum, Uhrzeit, besuchte Seiten oder ihre IP-Adresse, prüft WEBklar auf Weisung des Kunden, auch anhand dieser Protokolle, ob eine Zuordnung möglich ist, und teilt ihm das Ergebnis mit.
(3) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO.
(3) Der Messung können Besucher technisch widersprechen (Art. 21 DSGVO): Nach „Ablehnen“ und bei den Browser-Signalen „Global Privacy Control“ und „Do Not Track“ sendet das Skript nichts mehr.
(4) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.
(4) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO. Dazu gehören die Angaben, die er für sein Verzeichnis von Verarbeitungstätigkeiten und für eine etwaige Datenschutz-Folgenabschätzung braucht, und eine kurze Interessenabwägung für die Zählung nach Art. 6 Abs. 1 lit. f DSGVO, die WEBklar mit jeder Fassung dieses Vertrags bereitstellt.
(5) Der Kunde ist dafür verantwortlich, dass die Besucher seiner Website informiert werden (Art. 13 DSGVO). WEBklar stellt dafür die Zusatzseite „/datenschutz-analyse.html“ bereit. Der Kunde ergänzt seine eigene Datenschutzerklärung um einen Hinweis darauf.
(5) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.
<!-- Prüfpunkt Anwalt P18: Betroffenenrechte bei nicht identifizierbaren Daten (Art. 11, Art. 12 Abs. 2); Formulierung von Absatz 2. P15: Reicht die Zusatzseite für Art. 13, wenn der Kunde seine Datenschutzerklärung nicht anpasst? -->
(6) Zum Nachweis der Einwilligungen bewahrt WEBklar jede Fassung des Banner-Textes und der Zusatzseite mit dem Zeitraum ihrer Verwendung auf, bis drei Jahre nach dem Ende der Website-Analyse. WEBklar stellt sie dem Kunden auf Verlangen zur Verfügung. Ein Protokoll je Besucher führt WEBklar nicht.
(7) Die Besucher informiert der Kunde (Art. 13 DSGVO). WEBklar stellt ihm dafür die Zusatzseite „/datenschutz-analyse.html“ als Muster bereit. Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar dort einen Hinweis auf die Zusatzseite ein; sonst erhält der Kunde den Hinweis als Textbaustein (Leistungsbeschreibung Nr. 4 Abs. 4).
## 9. Löschung und Rückgabe
(1) Einzelne Ereignisse und Klickpositionen löscht WEBklar automatisch nach 30 Tagen. Zusammengefasste Tageswerte ohne Besucher-Kennung bleiben bis zum Ende der Website-Analyse (Ausschalten oder Ende des Wartungs-Abos) und weitere 60 Tage gespeichert; danach löscht WEBklar alle Daten der Website. Eine Kopie der Tageswerte stellt WEBklar dem Kunden auf Verlangen in Textform innerhalb dieser Frist zur Verfügung.
(1) Einzelne Ereignisse und Klickpositionen löscht WEBklar automatisch nach 30 Tagen. Der Löschlauf läuft täglich, die Löschung erfolgt also in der Regel spätestens am 31. Tag; fällt ein Lauf aus, holt der nächste ihn nach.
(2) Ob und wie lange Sicherungskopien Daten der Website-Analyse enthalten, ist noch zu klären (Prüfpunkt P8, siehe Anlage); soweit sie enthalten sind, werden sie im Rahmen der regulären Rotation überschrieben.
(2) Tageswerte löscht WEBklar 25 Monate nach dem jeweiligen Tag. Nach dem Ende der Website-Analyse (Ausschalten oder Ende des Wartungs-Abos) löscht WEBklar alle Daten der Website am 61. Tag. Bis zum 46. Tag nach dem Ende kann der Kunde eine Kopie der Tageswerte und der noch vorhandenen Einzelereignisse verlangen; WEBklar stellt sie innerhalb von zwei Wochen als CSV-Datei bereit. Auf Weisung des Kunden löscht WEBklar die Daten früher.
(3) Gesetzliche Aufbewahrungspflichten bleiben unberührt.
(3) Die Daten sind in der täglichen Vollsicherung des Servers enthalten (§ 3 Abs. 5 der Abo-Bedingungen). Gelöschte Daten bleiben dort bis zu sieben Tage erhalten und werden dann überschrieben. Wird eine Sicherung zurückgespielt, holt der tägliche Löschlauf die fälligen Löschungen nach; Löschungen auf Weisung des Kunden wiederholt WEBklar.
<!-- Prüfpunkt Anwalt P9: Aufbewahrung der Tageswerte bis Abo-Ende + 60 Tage (Erforderlichkeit, Art. 28 Abs. 3 lit. g DSGVO: Löschung "nach Abschluss der Erbringung"); ggf. kürzer, falls Tageswerte nicht als personenbezogen gelten. P8: Sicherungen (siehe Anlage). -->
(4) Gesetzliche Aufbewahrungspflichten bleiben unberührt.
## 10. Nachweise und Kontrollen
WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Prüfpunkt P13). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.
WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Zertifizierung der Hetzner Online GmbH nach ISO/IEC 27001). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.
## 11. Pflichten des Kunden
Der Kunde ist Verantwortlicher für die Verarbeitung. Er sorgt dafür, dass das Banner nicht entfernt oder verdeckt wird, dass keine weiteren Messwerkzeuge ohne Abstimmung eingebunden werden, und dass seine Datenschutzerklärung auf die Zusatzseite hinweist (Leistungsbeschreibung „Website-Analyse“).
(1) Der Kunde ist Verantwortlicher für die Messung. Er entfernt, verdeckt oder umgeht das Banner nicht. Er bindet ohne Abstimmung mit WEBklar keine weiteren Mess- oder Tracking-Werkzeuge ein.
## 12. Haftung und Schlussbestimmungen
(2) Enthalten die Pfade seiner Website personenbezogene Angaben, etwa Namen, Kundennummern oder E-Mail-Adressen, teilt der Kunde das WEBklar vor dem Einschalten mit.
(1) Für die Haftung gilt Art. 82 DSGVO. Im Übrigen gelten die Haftungsregeln der Abo-Bedingungen.
(3) Richtet sich die Website auch gezielt an Kinder unter 16 Jahren oder lassen ihre Inhalte auf besondere Kategorien personenbezogener Daten schließen (etwa Gesundheit, Religion oder politische Meinung), gilt Nr. 3 Abs. 4 der Leistungsbeschreibung.
(2) Bei Widersprüchen geht dieser Vertrag in Datenschutzfragen den Abo-Bedingungen und der Leistungsbeschreibung vor.
## 12. Haftung, Änderungen, Schlussbestimmungen
(3) Änderungen dieses Vertrags richten sich nach § 15 der Abo-Bedingungen; eine neue Fassung wird erst wirksam, wenn der Kunde sie im Portal bestätigt hat.
(1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen.
(4) Es gilt deutsches Recht.
(2) Eine neue Fassung dieses Vertrags oder der Leistungsbeschreibung teilt WEBklar dem Kunden mindestens sechs Wochen vor ihrem Wirksamwerden in Textform mit. Sie wird wirksam, wenn der Kunde ihr im Kundenportal oder in Textform zustimmt. Stimmt er nicht zu, gilt die bisherige Fassung weiter.
<!-- Prüfpunkt Anwalt P10: Haftungsverweis auf Abo-Bedingungen und Änderungsmechanismus (AGB-Kontrolle). -->
(3) Ist die neue Fassung nötig, weil ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form nicht mehr zulässt, und stimmt der Kunde ihr nicht zu, darf WEBklar die Website-Analyse für seine Website nach Nr. 8 Abs. 2 der Leistungsbeschreibung beenden oder aussetzen. Das Wartungs-Abo bleibt davon unberührt.
(4) Im Übrigen gilt § 16 der Abo-Bedingungen.
## Anlage: Technische und organisatorische Maßnahmen der Website-Analyse (Art. 32 DSGVO)
| Bereich | Maßnahmen |
|---|---|
| Übertragung | Skript und Messdaten werden nur über TLS (HTTPS) übertragen. |
| Keine Cookies | Es werden keine Cookies gesetzt. Auf dem Gerät des Besuchers wird nur die Banner-Auswahl im lokalen Speicher (Eintrag „wk-consent“) gespeichert; sie gilt sechs Monate. |
| Datenminimierung | IP-Adresse und Browser-Kennzeichen werden nicht gespeichert und nicht protokolliert. Die IP-Adresse wird nur im Moment der Abfrage verwendet für: Länderzuordnung, Schlüssel der Mengenbegrenzung (Hash mit Tagessalz, nur im Arbeitsspeicher) und, nur nach Einwilligung, die tägliche Besucher-Kennung. Das Browser-Kennzeichen wird nur im Moment der Abfrage verwendet für: Geräteklasse, Bot-Erkennung und, nur nach Einwilligung, die tägliche Besucher-Kennung. Pfade ohne Suchparameter, Elementpfade ohne Kennungen und Klassennamen. Keine Formulareingaben. |
| Pseudonymisierung | Die Besucher-Kennung (nur nach Einwilligung) ist ein gekürzter Hash mit Tagessalz. Das Salz liegt nur im Arbeitsspeicher und wird täglich um 00:00 Uhr (Europe/Berlin) neu erzeugt; ältere Kennungen sind danach nicht mehr verknüpfbar. |
| Einwilligung | Ohne „Annehmen“ sendet das Skript nur Seitenaufruf und Banner-Zähler. „Global Privacy Control“ und „Do Not Track“ gelten als Ablehnen. |
| Zugriffskontrolle | Die Verwaltungsschnittstelle des Messdienstes (Auswertung, Website-Verwaltung) läuft auf einem getrennten, internen Port, der nicht öffentlich erreichbar ist, und ist durch ein Zugriffstoken geschützt. Zugriff nur über das Kundenportal und die Verwaltung von WEBklar. Im Portal sieht jeder Kunde nur die Daten seiner eigenen Website. |
| Eingangsprüfung | Messdaten werden nur angenommen, wenn die Website registriert ist und Origin bzw. Referer zu ihren Domains passen; Bots werden verworfen; Größen- und Mengenbegrenzung je Website und Anfrager (nur im Arbeitsspeicher). |
| Heatmap-Zugriff | Die Heatmap wird über einen signierten Link abgerufen, der 30 Minuten gültig ist. |
| Löschung | Täglicher automatischer Job (02:30 Uhr, Europe/Berlin): Rohdaten und Klicks nach 30 Tagen; Websites, die seit mehr als 60 Tagen abgeschaltet sind, werden vollständig gelöscht. |
| Trennung | Daten sind je Website getrennt (Website-Schlüssel); eigener Container und eigenes Datenverzeichnis des Messdienstes; kein Zugriff auf andere Kundendaten. |
| Rechenzentrum | Hetzner Online GmbH, Falkenstein (Deutschland). **[Prüfpunkt: Zertifizierung des Rechenzentrums (ISO/IEC 27001) vor Verwendung belegen, bevor sie genannt wird.]** |
| Protokolle des Reverse-Proxys | Das Zugriffsprotokoll für die Messdienst-Adresse wird ohne Client-IP geführt. **[Prüfpunkt P7: Einstellung beim Livegang verifizieren.]** |
| Datensicherung | Die tägliche Serversicherung (7 Tage) erfasst voraussichtlich auch das Datenverzeichnis des Messdienstes. **[Prüfpunkt P8: ob das Verzeichnis in der Sicherung enthalten ist und wie lange dort gelöschte Daten verbleiben, ist noch zu klären.]** |
| Organisation | Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nr. 8 Abs. 4), Überprüfung der Maßnahmen mindestens einmal im Jahr. |
<!-- Prüfpunkt Anwalt P7 und P8: siehe Tabelle (Reverse-Proxy-Protokoll, Sicherung). -->
<!-- Prüfpunkt Anwalt P4: Einwilligungsnachweis ist nur technisch (ohne Annehmen sendet das Skript nachweislich nichts Weiteres); es gibt kein Einwilligungsprotokoll je Besucher. Reicht das für Art. 7 Abs. 1 DSGVO? -->
| Speicher im Browser | Es werden keine Cookies gesetzt, und der Messdienst wertet keine Cookies aus. Messanfragen sendet das Skript ohne Cookies. Im lokalen Speicher des Browsers liegt nur die Banner-Wahl (Eintrag „wk-consent“ mit Wahl, Zeitpunkt und Fassung des Banner-Textes). Sie gilt 183 Tage; danach und bei einer neuen Fassung des Banner-Textes fragt das Banner erneut. |
| Einwilligung | Solange keine Wahl getroffen ist und kein Browser-Signal vorliegt, sendet das Skript nur den Pfad der Seite, auch beim Seitenwechsel ohne Neuladen, und den Zähler „Banner angezeigt“. Nach „Ablehnen“ sendet es einmalig den Zähler „abgelehnt“ und danach nichts mehr, auch nicht aus anderen geöffneten Tabs. Bei „Global Privacy Control“ oder „Do Not Track“ zeigt es kein Banner und sendet nichts. Klick-, Scroll- und Verweildauer-Ereignisse registriert es erst nach „Annehmen“. Die Schaltflächen „Ablehnen“ und „Annehmen“ sind gleich gestaltet. |
| Datenminimierung | IP-Adresse und Browser-Kennzeichen werden weder gespeichert noch protokolliert. Der Messdienst verwendet sie nur im Arbeitsspeicher für Land, Geräteklasse, Bot-Erkennung, Mengenbegrenzung und, nur nach Einwilligung, die Besucher-Kennung. Zeitpunkte werden auf die volle Stunde gerundet gespeichert. Pfade werden ohne Suchparameter und Anker gespeichert; Abschnitte, die wie Kennungen aussehen (lange Ziffernfolgen, UUIDs, E-Mail-Adressen), werden vorher ersetzt. Elementpfade bestehen nur aus Tag-Namen und Position. Formulareingaben und Seitentexte werden nicht erfasst. |
| Pseudonymisierung | Die Besucher-Kennung (nur nach Einwilligung) ist ein auf 64 Bit gekürzter HMAC-SHA256 aus Website-Schlüssel, IP-Adresse und Browser-Kennzeichen mit einem Tagesschlüssel. Auch der Schlüssel der Mengenbegrenzung wird mit dem Tagesschlüssel gebildet. Der Tagesschlüssel liegt nur im Arbeitsspeicher; die Auslagerung auf die Festplatte ist für den Messdienst gesperrt. Er wird beim ersten Ereignis eines neuen Kalendertags (Europe/Berlin) und bei jedem Neustart neu erzeugt und nie gesichert. Ältere Kennungen lassen sich danach nicht mehr zuordnen. |
| Zugriffskontrolle | Die Verwaltungsschnittstelle des Messdienstes (Auswertung, Website-Verwaltung) läuft auf einem internen Port, der nicht öffentlich erreichbar ist, und ist durch ein Zugriffstoken geschützt. Zugriff haben nur das Kundenportal und die Verwaltung von WEBklar. Das Portal zeigt jedem Kunden nur die Daten seiner eigenen Websites. |
| Heatmap | Die Heatmap öffnet ein signierter Link, den nur der angemeldete Kunde im Kundenportal erzeugen kann. Der Link gilt 30 Minuten und liefert nur Klickpunkte der gewählten Seite und Ansicht, ohne Zeitpunkte und Kennungen. |
| Eingangsprüfung | Messdaten werden nur angenommen, wenn die Website registriert und eingeschaltet ist und Origin bzw. Referer zu ihren Domains passen. Bots werden verworfen. Größe und Menge der Anfragen sind je Website und Absender begrenzt (Zähler nur im Arbeitsspeicher). Für ausgeschaltete Websites liefert der Messdienst ein leeres Skript aus. |
| Löschung | Täglicher Löschlauf um 02:30 Uhr (Europe/Berlin): Ereignisse und Klicks nach 30 Tagen, Tageswerte nach 25 Monaten, alle Daten einer Website 60 Tage nach dem Ausschalten. Gelöschte Datensätze werden in der Datenbankdatei überschrieben und aus dem Schreibprotokoll (WAL) entfernt. |
| Trennung | Der Messdienst läuft in einem eigenen Container mit eigenem Datenverzeichnis und hat keinen Zugriff auf andere Kundendaten. Alle Datensätze tragen den Website-Schlüssel; Abfragen erfolgen nur je Website. |
| Protokolle | Der Reverse-Proxy führt kein Zugriffsprotokoll, und der Messdienst protokolliert keine Anfragen. Wird ein Zugriffsprotokoll eingeschaltet, dann nicht für die Adresse des Messdienstes. Meldungen der Container werden nach 14 Tagen gelöscht. |
| Datensicherung | Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen). Einen gesonderten Datenbank-Export des Messdienstes gibt es nicht. |
| Rechenzentrum | Hetzner Online GmbH, Rechenzentrum Falkenstein (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert; der Geltungsbereich umfasst die Hosting-Dienstleistungen und die Rechenzentren. |
| Organisation | Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nummer 8 Abs. 5), Archiv der Fassungen von Banner-Text und Zusatzseite (Nummer 8 Abs. 6), Überprüfung der Maßnahmen mindestens einmal im Jahr. |
@@ -0,0 +1,23 @@
# Datenschutzerklärung: Prüfpunkte für die Kundenansicht neu
Stand 16.10.2026 · Entwurf – Prüfung durch Kanzlei offen · Entwurf zur Freigabe, keine Rechtsberatung
Diese Liste ändert die Datenschutzerklärung nicht. `public/datenschutz.html` bleibt unverändert (eingefroren); die Stellen unten sind die, die mit der neuen Kundenansicht und den Texten der Fassung 6 geprüft werden müssen. Die Änderung der Datenschutzerklärung erfolgt erst mit dem Umschalten (Entscheidung R14 in `docs/rechtliches/kundenansicht-entscheidungen.md`), als eigene Fassung, sonst würde sie sofort öffentlich. Zeilenangaben beziehen sich auf den Stand von `public/datenschutz.html` am 16.10.2026.
Je Prüfpunkt: **Fundstelle**, was dort steht, was sich ändert, und die Frage an den Inhaber oder die Kanzlei.
**D1** · Fundstelle `public/datenschutz.html:127`. Nachweis bei der Bestellung einer Funktion. Dort steht der „Klick auf ‚Zahlungspflichtig bestellen‘“, die Stripe-Sitzung und eine Funktionsliste nur mit News-Pinnwand und Terminplaner. Künftig löst das Einschalten einer Funktion im Abo keine Zahlung aus und hat keine Stripe-Sitzung; gespeichert werden der Wortlaut der angezeigten Zeilen, die Fassungen, Zeitpunkt des Klicks auf „Einschalten“, IP-Adresse, Browserkennung, Konto, angemeldete Person und die Kundenart (Fassungen: Abo-Bedingungen 2026-10-16-v6, Funktionsunterlagen 2026-10-16). Die Funktionsliste muss alle Funktionen nennen (News-Pinnwand, Terminplaner, Webshop, Buchungskalender, Öffnungszeiten live, Website-Analyse). Frage: Neuer Absatz „Funktion einschalten“ neben dem Absatz für Bestellungen, damit der Bestand (Funktionen, die einzeln gekauft wurden) unverändert beschrieben bleibt?
**D2** · Fundstelle `public/datenschutz.html:126`. Nachweis bei Abo, Wechsel und Zusatzspeicher. Dort stehen „gegebenenfalls Ihre Angaben zum Leistungsbeginn“ sowie Zeitpunkt, Version, IP-Adresse und Browserkennung. Künftig kommen der Wortlaut der angezeigten Zeilen (Linkzeile, Häkchen für Privatkunden) und die Kundenart zum Klickzeitpunkt hinzu (§ 2 Abs. 6 der Abo-Bedingungen Fassung 6). Frage: Genügt es, die Kundenart in den Aufzählungssatz aufzunehmen, oder braucht sie wegen D3 einen eigenen Hinweis?
**D3** · Fundstelle `public/datenschutz.html:88`. Abschnitt 6 „Kundenportal“, Kundenkonto-Absatz in der Zeile darunter. Die Kundenart („Betrieb“ oder „privat“) ist ein neues Datum im Kundenkonto. Das Team legt sie nach den Angaben des Kunden fest (Entscheidung E3); ist sie leer, gilt der Kunde als privat (E4). Zu ergänzen sind Zweck (Bestellseiten richten sich danach, Nachweis), Rechtsgrundlage (Art. 6 Abs. 1 lit. b und f DSGVO), Quelle (Angaben des Kunden an das Team) und Dauer (wie das Kundenkonto; im Nachweis wie in D1). Frage: Eigener Satz im Kundenkonto-Absatz oder in Abschnitt 7?
**D4** · Fundstelle `public/datenschutz.html:128`, ergänzend `public/datenschutz.html:115` und `public/datenschutz.html:121`. Der Stripe-Absatz beschreibt Zahlungen für Abos und Zusatzspeicher; das Einschalten einer Funktion im Abo löst keine Zahlung und keine Übermittlung an Stripe aus. Die Abschnitte zu Stripe Connect beim Webshop und beim Buchungskalender (Zahlungen der Käufer und Gäste auf das eigene Stripe-Konto des Kunden) bleiben inhaltlich wie sie sind. Frage: Genügt ein Satz, dass Funktionen im Abo ohne Zahlung eingeschaltet werden, oder sollen die Abschnitte nur an der Einleitung („bestellen“ statt „einschalten“) angepasst werden?
**D5** · Fundstelle `public/datenschutz.html:102`. Abschnitt zur Website-Analyse: „Kunden mit Wartungs-Abo“ können die Reichweitenmessung einschalten. Künftig steckt die Website-Analyse im Hosting-Abo und im Wartungs-Abo (Leistungsbeschreibung Website-Analyse Nr. 1 Abs. 1). Der Absatz muss beide Abos nennen und beschreiben, dass die Funktion mit dem Abo endet (Löschfrist 60 Tage, AVV Website-Analyse Nr. 9). Frage: Reicht eine Anpassung der Einleitung?
**D6** · Fundstellen `public/datenschutz.html:105` (Terminplaner), `public/datenschutz.html:113` (Webshop), `public/datenschutz.html:119` (Buchungskalender), außerdem der Abschnitt zur News-Pinnwand und zu Öffnungszeiten live. Die Funktionsabschnitte sprechen von „Kunden mit Wartungs-Abo“, vom „Freischalten“ und vom Ende mit dem Vertrag. Künftig gilt: Funktionen sind Teil des Wartungs-Abos, enden mit dem Abo (Terminplaner, News-Pinnwand, Webshop und Buchungskalender spätestens am Folgetag, Öffnungszeiten live nach 30 Tagen Weiterlauf) und werden nach den Fristen der Leistungsbeschreibungen gelöscht (30 Tage Terminplaner, News-Pinnwand und Öffnungszeiten live; 90 Tage Webshop und Buchungskalender). Frage: Die Fristen in den Abschnitten und in Abschnitt 7 (Speicherdauer) müssen übereinstimmen; soll Abschnitt 7 die Fristen künftig als Tabelle führen?
**D7** · Fundstelle `public/datenschutz.html:138`, ergänzend die Beschreibung des Stundenkontingents im Kundenkonto-Absatz. Das Stundenkonto und der Vorschuss für die Einrichtung (§ 4 Abs. 8 der Abo-Bedingungen Fassung 6) sind Abrechnungsdaten: Arbeitszeit, Verbrauch, Vorschuss, Ablaufdaten. Sie stehen heute unter „gegebenenfalls Ihr Stundenkontingent“. Frage: Ist der Vorschuss als Abrechnungsdatum mit demselben Zweck, derselben Rechtsgrundlage (Art. 6 Abs. 1 lit. b DSGVO) und derselben Dauer ausreichend beschrieben, oder braucht er einen eigenen Satz?
**D8** · Fundstelle `public/datenschutz.html:130`. Abschnitt zu den Seiten Kündigen und Widerrufen. Die Seiten erscheinen in der neuen Kundenansicht in der du-Form (Entscheidung R16) und zeigen angemeldeten Kunden die laufenden Widerrufsfristen aus bereits vorhandenen Daten (Sammelabfrage der Fristen im Kundenportal, `GET /api/fristen`). Das ist ein Lesen vorhandener Daten ohne neues Datum und ohne neuen Zweck; der Absatz bleibt inhaltlich unverändert. Frage: Genügt die Feststellung, dass kein neuer Absatz nötig ist, oder soll die Anzeige der Fristen im Konto erwähnt werden?
@@ -0,0 +1,144 @@
# Rechtsrunde Fassung 6: Entscheidungen, Kanzlei-Fragen und Ablauf
Stand 16.10.2026 · Entwurf – Prüfung durch Kanzlei offen · Entwurf zur Freigabe, keine Rechtsberatung
Dieses Blatt ist die Grundlage für die Freigabe der Rechtstexte der Kundenansicht neu durch den Inhaber (Entscheidung E20). Die Kanzlei-Fragen halten den Livegang nicht auf; die Fragen aus den früheren Rechtsrunden stehen in `/root/reports/kundenansicht-neu-2026-10-15/bestand/recht.md` § 4. Die Entwürfe liegen unter `docs/rechtliches/*-v6.md`, die Seiten unter `public/neu/rechtliches/` (Abo-Bedingungen: `abo-bedingungen.html`, Funktionsunterlagen: `<slug>-2026-10-16.html`). Datenschutz-Prüfpunkte D1 bis D8 stehen in `docs/rechtliches/datenschutz-kundenansicht-pruefpunkte.md`.
## Zu entscheiden
| Nr. | Punkt | Vorschlag | Alternative | Kanzlei-Frage |
|---|---|---|---|---|
| R1 | Fassungsnummer und Datum | Abo `2026-10-16-v6`, Funktionen `2026-10-16`; bei Freigabe auf den Freigabetag setzen | Entwurfstag bleibt (keine Umbenennung der Dateien und Fassungen) | – |
| R2 | Geltung für laufende Abos v3 bis v5 (C11) | nur neue Abschlüsse (§ 15 Abs. 2); Funktionen gelten über Linkzeile und Klick, auch mit der Stundenkonto- und Vorschuss-Regel; keine Pflicht-Info-Mail, eine freiwillige Mail später | Info-Mail an laufende Abos mit Bitte um Zustimmung nach § 15 | KQ1 |
| R3 | Vorschuss-Regel (E17) | § 4 Abs. 8 und jede Leistungsbeschreibung außer der Analyse: echte Arbeitszeit, nie Mehraufwand, ein Rest bei Vertragsende verfällt zugunsten des Kunden | Obergrenze (zum Beispiel 3 Stunden) mit Rückfrage darüber | KQ2 |
| R4 | Einrichtung beim Projektstart (E9, E21) | § 4 Abs. 9 und Baustein „projekt“ der Leistungsbeschreibungen: Abrechnung über die Projektkosten, die Funktion läuft nur mit dem passenden Abo | nur in den Leistungsbeschreibungen, nicht in den Abo-Bedingungen | KQ8 |
| R5 | Kundenart als Grundlage (E3, E4) | § 1 Abs. 2: hinterlegt nach Angabe des Kunden, leer gilt als privat, maßgeblich ist der Zweck des Vertrags | Selbstauskunft des Kunden in den Bedingungen | KQ4 |
| R6 | Häkchen-Wortlaut für Abo, Wechsel und Einrichtung | Texte aus `WORTLAUT_V6.beginn` ohne Paragrafenzitate; Betriebe setzen nichts | Zitate §§ 356 Abs. 5 Nr. 2, 357a Abs. 2 BGB im Häkchen | KQ5 |
| R7 | Einrichtung in der Widerrufsfrist | Teil des Abos, kein eigener Vertrag; Widerruf beendet die Funktion; Wertersatz nach aufgewendeter Einrichtungszeit zum Stundensatz des Abos | WEBklar verzichtet auf Wertersatz für die Einrichtung (wie beim Zusatzspeicher), das Häkchen für die Einrichtung entfällt | KQ3 |
| R8 | Ende mit dem Abo, Fristen, kein „Behalten“ (C12); mit P4/P6 abgestimmt | Terminplaner, News, Webshop und Buchungskalender schalten spätestens am Folgetag ab (Tagesjob), die Analyse wie bisher; Öffnungszeiten live läuft nach dem Ende noch 30 Tage unverändert weiter und wird dann abgeschaltet (kein Behalten gegen 30 €); Eingaben danach: Terminplaner, News und Öffnungszeiten 30 Tage, Webshop und Buchungskalender 90, Analyse 60; ein neues Abo in der Frist holt die Funktion zurück | Öffnungszeiten live schalten wie die übrigen spätestens am Folgetag ab (P4 Text Z2, P6 Mail K40 und der Zeiten-Abgleich müssten dafür geändert werden) | KQ8 |
| R9 | Analyse im Hosting-Abo (C10) | in beiden Abos, Einrichtung kostenlos (C6): § 3 Abs. 8, § 6 Abs. 6, Leistungsbeschreibung A | nur im Wartungs-Abo, wie heute | KQ8 |
| R10 | AVV-Struktur | Option A: die AVV je Funktion bleiben eigene Verträge mit neuer Vorbemerkung, Anlage 1 bleibt der Rahmen (§ 14 Abs. 2) | B: Anlagen 4 bis 7 zu den Abo-Bedingungen; C: Rahmen-AVV mit einer Anlage je Funktion | KQ7 |
| R11 | Widerrufsbelehrung der Funktionen | bleibt als Datei für den Bestand, die neuen Texte verlinken sie nicht | bleibt Anlage der Leistungsbeschreibungen | KQ6 |
| R12 | Anrede der Rechtsdokumente (E15, Spec 5.7) | bleiben im bisherigen Stil („der Kunde“, in der Übersichtsbox „Ihr“); nur Oberflächentexte stehen in „du“ | Übersichtsbox und Leistungsbeschreibungen auf „du“ | – |
| R13 | Webshop nur für Betriebe, Terminplaner nicht im Gesundheitsbereich (C15) | steht in den Leistungsbeschreibungen, das Team prüft bei der Freigabe | Webshop auch für Privatkunden mit Hinweis | KQ4, KQ9 |
| R14 | Datenschutzerklärung | Prüfpunkte D1 bis D8; `public/datenschutz.html` wird erst mit dem Umschalten als eigene Fassung geändert (sonst sofort öffentlich) | sofort ändern | – |
| R15 | Zusatzspeicher und Übernahmegutschrift | Zusatzspeicher: Linkzeile ohne Häkchen (§ 10 Abs. 3 und Hinweise zum Widerruf Nr. 4: kein Wertersatz); Gutschrift: Satz über dem Knopf (P7) | das Häkchen bleibt | KQ5 |
| R16 | Du-Anrede der Seiten Kündigen und Widerrufen (N38) | beide Seiten sprechen ab Fassung 6 „du“; sie speichern keinen Wortlaut und haben keine eigene Version (C14); Belehrung und Muster-Formular folgen R12 | Sie-Form bleibt bis zur Freigabe von Belehrung und Formular | KQ6 |
| R17 | Zeilen „Anbieter“ und „Abrechnung“ im Block der Bestellseiten (N37) | beide Zeilen entfallen im Block; der Anbieter steht im Impressum im Konto und in § 1 der Bedingungen, die Abrechnung (monatlich im Voraus per Stripe) in den Zeilen zu Preis und Fälligkeit und in § 7 | beide Zeilen bleiben im Block über dem Knopf | KQ6 |
| R18 | Länge des Blocks „Was du bestellst“ am Handy (Abschlussprüfung F1) | Der Block über dem Knopf ist knapp: Leistung als eine Zeile mit Link „Leistungen ansehen“ auf die Leistungsbeschreibung, Preis, Fällig, Laufzeit, Beginn und Zahlung in je ein bis drei Zeilen; die ausführlichen Sätze (Leistung, Laufzeit mit Kündigungsfrist, Zahlungsweg, Gutschrift und Abbuchungstag beim Wechsel) stehen unter dem Knopf („Im Einzelnen“), ohne Klappe. So stehen Kerninfo und Knopf auch auf 375 × 667 zusammen im nutzbaren Bereich (zwischen Kopf und Leiste) | voller Wortlaut im Block über dem Knopf: am Handy dann nicht mehr auf einem Bildschirm mit dem Knopf | KQ6 |
## Fragen an die Kanzlei
Jede Frage hat vier Teile wie in `docs/rechtliches/zeiten-kanzlei-fragen.md`: **Frage** (was wir wissen möchten), **Fundstelle im Text** (wo der Wortlaut steht), **Unsere Annahme** (der Stand, mit dem wir arbeiten; bitte bestätigen oder korrigieren), **Alternative** (was wir stattdessen täten). Annahmen sind keine Rechtsurteile.
### KQ1: Geltung der Fassung 6 und Information an laufende Abos
**Frage.** Fassung 6 gilt nur für neue Abschlüsse. Wer ein laufendes Abo nach Fassung 3 bis 5 hat und eine Funktion einschaltet, stimmt Leistungsbeschreibung und Auftragsverarbeitung der Funktion in der angezeigten Fassung durch den Klick auf „Einschalten“ zu; dazu gehören die Regeln zur Einrichtung (Stundenkontingent) und zum Vorschuss. Reicht der Klick, um diese Regeln in das laufende Abo einzubeziehen, oder braucht es eine Zustimmung nach dem bisherigen § 15 (Mitteilung mit sechs Wochen Frist, Zustimmung)? Muss WEBklar laufende Kunden über Fassung 6 informieren, obwohl sie für sie nur vorteilhaft ist?
**Fundstelle im Text.** § 15 Abs. 2 der Abo-Bedingungen Fassung 6; § 3 Abs. 8; § 4 Abs. 8; Nr. 1 Abs. 1 der Leistungsbeschreibungen (Baustein „geltung“).
**Unsere Annahme.** Der Klick genügt, weil jede Funktion ein zusätzliches Angebot ohne Gebühr ist und die Änderung des Abos nur für neue Abschlüsse gilt. Eine Info-Mail ist keine Pflicht; wir würden später freiwillig informieren.
**Alternative.** Info-Mail an alle laufenden Abos mit Bitte um Zustimmung nach § 15; Funktionen für Altkunden erst nach dieser Zustimmung.
### KQ2: Vorschuss-Regel für die Einrichtung
**Frage.** Die Einrichtung einer Funktion geht mit der tatsächlich aufgewendeten Zeit vom Stundenkontingent ab. Reicht es nicht, entsteht ein Vorschuss, den die folgenden Monatskontingente ausgleichen; ein Rest bei Vertragsende verfällt zugunsten des Kunden, und es wird nie Mehraufwand berechnet. Ist das transparent und wirksam, auch gegenüber Verbrauchern (Überraschungsklausel, Kontingent als Vorleistung)? Braucht es eine Obergrenze oder eine Rückfrage?
**Fundstelle im Text.** § 4 Abs. 2 und 8 der Abo-Bedingungen Fassung 6; Nr. 5 Abs. 2 (Terminplaner), Nr. 3 Abs. 1 (News-Pinnwand), Nr. 4 Abs. 1 (Öffnungszeiten live), Nr. 9 Abs. 1 (Webshop, Buchungskalender).
**Unsere Annahme.** Die Regel ist für den Kunden günstig (keine zusätzliche Rechnung, Verfall zu seinen Gunsten) und wird im Kundenportal mit „Vorschuss für Einrichtung“ angezeigt.
**Alternative.** Obergrenze (zum Beispiel 3 Stunden) mit Rückfrage darüber; oder Einrichtung nur, soweit das Kontingent reicht.
### KQ3: Einrichtung in der Widerrufsfrist
**Frage.** Das Einschalten einer Funktion ist nach unserem Text Teil des Abos und kein eigener Vertrag; es verlängert die Widerrufsfrist nicht. Ist das richtig, oder ist die Einrichtung eine eigene Leistung mit eigenem Widerrufsrecht? Erlischt das Widerrufsrecht nach § 356 Abs. 5 Nr. 2 BGB bei einem Dauerschuldverhältnis überhaupt durch „vollständige Erbringung“, und wie bemisst sich der Wertersatz nach § 357a Abs. 2 BGB für Einrichtungszeit innerhalb der Frist?
**Fundstelle im Text.** Hinweise zum Widerruf Nr. 5 und § 2 Abs. 2 c der Abo-Bedingungen Fassung 6; Baustein „widerruf“ der Leistungsbeschreibungen; `WORTLAUT_V6.beginn.einrichtung` in `server/services/products.js`.
**Unsere Annahme.** Teil des Abos; Wertersatz nach aufgewendeter Einrichtungszeit zum Stundensatz des Abos; das Häkchen gibt es nur für Privatkunden in der Frist.
**Alternative.** WEBklar verzichtet auf Wertersatz für die Einrichtung (wie beim Zusatzspeicher nach § 10 Abs. 3) und das Häkchen für die Einrichtung entfällt.
### KQ4: Belastbarkeit der Kundenart
**Frage.** Das Team hinterlegt die Kundenart („Betrieb“ oder „privat“) nach den Angaben des Kunden; ist keine hinterlegt, gilt der Kunde als privat; maßgeblich bleibt der Zweck des Vertrags (§ 1 Abs. 2). Wie belastbar ist das, wenn sich später ein Verbraucher als Betrieb geführt herausstellt (Existenzgründer, Vereine, Freiberufler)? Wer trägt das Risiko einer fehlenden Belehrung? Das ist die Frage 5 aus `recht.md` § 4.
**Fundstelle im Text.** § 1 Abs. 2 und § 2 Abs. 2 c der Abo-Bedingungen Fassung 6; `WORTLAUT_V6.beginn`; Nr. 1 Abs. 1 und Nr. 5 Abs. 1 der Leistungsbeschreibung Webshop.
**Unsere Annahme.** Unsere Kunden sind fast nur Betriebe (E2). Widerrufsbelehrung, Muster-Formular und die Funktion „Vertrag widerrufen“ bleiben für alle erreichbar; nur das Häkchen und der Beginn nach der Frist hängen an der Kundenart.
**Alternative.** Selbstauskunft des Kunden bei der Bestellung („Ich bestelle als Unternehmer“) oder das Häkchen für alle Kunden.
### KQ5: Häkchentexte ohne Normzitat, Wechsel nur mit Häkchen
**Frage.** Dürfen die Häkchen für den Sofortbeginn auf zwei bis drei Sätze ohne Paragrafenzitate gekürzt werden, ohne die Information nach Art. 246a § 1 Abs. 3 EGBGB über das Erlöschen und den Wertersatz zu verlieren (Frage 2 aus `recht.md` § 4)? Ist es zulässig, dass der Wechsel ins Wartungs-Abo für Privatkunden nur mit Häkchen möglich ist, weil er sofort wirkt? Genügt beim Zusatzspeicher und bei der Übernahmegutschrift eine Linkzeile ohne Häkchen, weil WEBklar auf Wertersatz verzichtet?
**Fundstelle im Text.** `WORTLAUT_V6.beginn` (Abo, Wechsel, Einrichtung); § 2 Abs. 2 c und § 9 Abs. 6 der Abo-Bedingungen Fassung 6; § 10 Abs. 3 und Hinweise zum Widerruf Nr. 4.
**Unsere Annahme.** Die Wortlaute der Konstante genügen; für Privatkunden ist der Wechsel ohne Häkchen gesperrt; Zusatzspeicher und Gutschrift brauchen kein Häkchen.
**Alternative.** Zitate §§ 356 Abs. 5 Nr. 2, 357a Abs. 2 BGB im Häkchen; Wechsel für Privatkunden mit Beginn nach Ablauf der Frist.
### KQ6: Belehrung und Pflichtinformationen per Link und PDF, Anrede der Seiten Kündigen und Widerrufen
**Frage.** Genügt der Link auf Widerrufsbelehrung und Muster-Formular vor der Bestellung, wenn der Volltext erst im Bestätigungs-PDF steht (Frage 4 aus `recht.md` § 4)? Passen Belehrung und Formular zum Gestaltungshinweis in Anlage 1 EGBGB zu § 356a? Dürfen die Zeilen „Anbieter“ und „Abrechnung“ im Block über dem Knopf entfallen, solange der Anbieter im Impressum und in § 1 steht und die Abrechnung in den Zeilen zu Preis und Fälligkeit? Ändert die Du-Anrede der Seiten Kündigen und Widerrufen etwas an der Wirksamkeit von Kündigung (§ 312k BGB) und Widerruf (§ 356a BGB)?
**Fundstelle im Text.** § 2 Abs. 2 c, Widerrufsbelehrung, Muster-Widerrufsformular und Hinweise zum Widerruf der Abo-Bedingungen Fassung 6; Seiten `kuendigen.html` und `widerrufen.html` in der neuen Ansicht; Block „Was du bestellst“ der Bestellseiten.
**Unsere Annahme.** Der Link genügt, das PDF kommt vor Leistungsbeginn per E-Mail (§ 312f BGB); Anbieter und Abrechnung sind an anderer Stelle erreichbar; die Du-Anrede ändert die Wirksamkeit nicht.
**Alternative.** Volltext der Belehrung auf der Bestellseite; Zeilen „Anbieter“ und „Abrechnung“ bleiben im Block; Sie-Form der beiden Seiten.
### KQ7: AVV per Klick auf die Linkzeile, Struktur A, B oder C
**Frage.** Genügt der Klick auf „Einschalten“ unter der Linkzeile als Abschluss des Auftragsverarbeitungsvertrags nach Art. 28 Abs. 3 und 9 DSGVO („schriftlich, auch elektronisch“), ohne eigenes Häkchen (Frage 3 aus `recht.md` § 4)? Welche Struktur ist am sichersten: A (eigene Verträge je Funktion), B (Anlagen zu den Abo-Bedingungen) oder C (Rahmen-AVV mit Anlage je Funktion)?
**Fundstelle im Text.** § 14 Abs. 2 der Abo-Bedingungen Fassung 6; Vorbemerkung Abs. 1 jedes AVV in `docs/rechtliches/avv-*-v6.md`; Abschnitt „AVV-Struktur“ unten.
**Unsere Annahme.** Der Klick genügt, weil Wortlaut, Fassung, Zeit, Konto und angemeldete Person gespeichert werden; wir bleiben bei Option A.
**Alternative.** Ein eigenes Häkchen für den AVV; Option B oder C.
### KQ8: Funktionen als Teil des Abos, Kopplung an das Abo-Ende, Rückkehr in der Frist
**Frage.** Sind die Funktionen als Teil der Abo-Leistung wirksam geregelt (§ 3 Abs. 8), einschließlich der Kopplung an das Abo (§ 11 Abs. 4: Ende mit dem Abo, Weiterlauf von Öffnungszeiten live für 30 Tage, Löschfristen von 30, 60 und 90 Tagen)? Ist es zulässig, dass ein neues Abo in der Frist die Funktion mit den vorhandenen Daten ohne neue Zustimmung zurückholt? Tragen die Analyse im Hosting-Abo (C10) und die Abrechnung der Einrichtung beim Projektstart über die Projektkosten (E9, E21) ohne weitere Hinweise?
**Fundstelle im Text.** § 3 Abs. 8, § 4 Abs. 9 und § 11 Abs. 4 der Abo-Bedingungen Fassung 6; Abschnitt „Ende der Leistung“ jeder Leistungsbeschreibung; Nr. 10 Abs. 5 der Leistungsbeschreibungen Webshop und Buchungskalender („auf Wunsch in Textform“).
**Unsere Annahme.** Die Regeln sind als Leistungsbeschreibung wirksam; die Rückkehr in der Frist holt die Funktion zurück. Offen ist für uns, ob die Rückkehr bei Webshop und Buchungskalender wie heute „auf Wunsch in Textform“ oder automatisch geschehen soll.
**Alternative.** Öffnungszeiten live wie die übrigen Funktionen am Folgetag abschalten (R8); Rückkehr nur auf Wunsch in Textform; einheitliche Löschfrist von 30 Tagen.
### KQ9: Ausschlüsse als Vertragsklausel
**Frage.** Sind die Ausschlüsse in den Leistungsbeschreibungen als Leistungsgrenzen wirksam: Terminplaner nicht im Gesundheitsbereich, Webshop nur für Betriebe und ohne die ausgeschlossenen Waren, Buchungskalender nur für Unternehmer? Gilt das auch, wenn die Prüfung beim Einschalten nur durch das Team oder durch eine Selbstangabe im Fragebogen erfolgt? Darf WEBklar bei Verstößen abschalten?
**Fundstelle im Text.** Nr. 4 Abs. 3 der Leistungsbeschreibung Terminplaner; Nr. 1 Abs. 1, Nr. 3 und Nr. 5 Abs. 1 der Leistungsbeschreibung Webshop; Nr. 1 Abs. 1 der Leistungsbeschreibung Buchungskalender.
**Unsere Annahme.** Die Ausschlüsse sind als Leistungsbeschreibung wirksam; das Team prüft bei der Freigabe (R13).
**Alternative.** Webshop auch für Privatkunden mit Hinweis (C15 verschoben); keine automatische Abschaltung, nur ein Hinweis in Textform.
## AVV-Struktur
Die Auftragsverarbeitung der Funktionen ist in Fassung 6 so geregelt, dass der Kunde sie mit dem Klick auf „Einschalten“ unter der Linkzeile abschließt. Drei Aufbauten sind möglich; empfohlen wird Option A.
**Option A, empfohlen: eigene Verträge je Funktion, Anlage 1 bleibt der Rahmen.** Die AVV Terminplaner, Webshop, Buchungskalender und Website-Analyse bleiben eigene Dokumente mit einer neuen Vorbemerkung (Abschluss per Klick) und gelten neben Anlage 1 der Abo-Bedingungen (§ 14 Abs. 2). Vorteil: jede Funktion hat ihre eigenen Datenarten, Fristen und Unterauftragsverarbeiter, und die bestehenden Verträge ändern sich kaum. Nachteil: vier Einzelverträge, deren Gleichlauf mit Anlage 1 bei jeder Änderung geprüft werden muss.
**Option B: Anlagen 4 bis 7 zu den Abo-Bedingungen.** Die AVV der Funktionen werden Anlagen der Abo-Bedingungen. Vorteil: ein Dokument mit einer Fassung, der Kunde stimmt den Bedingungen einmal zu. Nachteil: die Abo-Bedingungen werden lang und müssen bei jeder Änderung einer Funktion neu versioniert werden; Funktionen, die ein Kunde nie nutzt, stehen trotzdem in seinem Vertrag.
**Option C: Rahmen-AVV mit einer Anlage je Funktion.** Ein neuer Rahmenvertrag regelt das Gemeinsame (Weisungen, Sicherheit, Löschung), die Anlagen je Funktion nennen nur Daten und Unterauftragsverarbeiter. Vorteil: einheitliche Regeln, bei Änderungen ändert sich nur die Anlage. Nachteil: ein neuer Vertrag neben Anlage 1, abzustimmen mit der Kanzlei, und mehr Arbeit als jetzt nötig.
## Ablauf nach der Freigabe
Die Reihenfolge ist fest; jeder Schritt erst, wenn der vorige grün ist. Die Skripte laufen nur auf ausdrückliche Anweisung des Controllers nach der Freigabe.
1. Freigabe des Inhabers je Punkt (R1 bis R17). Die Antworten auf die Kanzlei-Fragen KQ1 bis KQ9 halten den Livegang nicht auf (E20). Entschiedene Änderungen werden zuerst in den Texten und Tests nachgezogen.
2. Datum der Fassungen auf den Freigabetag setzen: `config.abo.termsVersionNeu` (Abo), `FUNKTIONEN_FASSUNG_V6` in `server/services/products.js`, die Datei- und Testnamen mit dem Datum (`<slug>-2026-10-16`), die Fassungszeilen der Markdown-Dateien, `Version 6 · Stand …` und den Druckkopf in der Abo-Seite.
3. Entwurfsmarke aus allen Dateien entfernen (Fassungszeilen der Markdown-Dateien, der Absatz unter der Kopfzeile und das meta robots in der Abo-Seite), `ENTWURF_V6 = false` in `scripts/build-legal-html.mjs`, und `ENTWURF = false` in `test/aboBedingungenV6.test.js`, `test/funktionenRechtV6.test.js` und `test/kundenansichtEntscheidungen.test.js`. Auch der Vermerk im Kopf dieser Datei und der Prüfpunkte-Datei fällt weg.
4. `node --max-old-space-size=256 scripts/build-legal-html.mjs funktionen6` erzeugt die zehn Seiten neu; `scripts/build-legal-html.mjs funktionen6 --check` meldet danach nichts.
5. `free -m` (mindestens 2000 MB verfügbar), dann `nice -n 10 scripts/build-legal-pdfs.sh abo6` und `nice -n 10 scripts/build-legal-pdfs.sh funktionen6`, einzeln und nie parallel (`FORCE=1` nur nach einer Textänderung). Die PDFs und die `.sha256`-Dateien werden mit committet.
6. `node scripts/rechtliches-hochladen.mjs --probe`. Meldet der Vorflug `STOPP: Karte fehlt: …` (laut `setup-legal.mjs` des WOMS fehlen drei der elf Karten: News, Website-Analyse und AVV Website-Analyse), zuerst `node scripts/rechtliches-hochladen.mjs --karten-anlegen`, danach den Vorflug wiederholen. Ist er grün, folgt ein Aufruf je Dokument (elf Aufrufe): `node scripts/rechtliches-hochladen.mjs <documentKey> --freigabe=JJJJ-MM-TT`. Zuletzt `node scripts/rechtliches-hochladen.mjs --pruefen`: Exit 0 heißt, alle elf Dokumente haben eine aktuelle Version mit passender Quelle; sonst weiter bei Schritt 6.
7. Erst danach `KUNDENANSICHT_NEU` auf `on` (P10); P10 prüft vorher, dass `WORTLAUT_STATUS` auf `'freigegeben'` steht und die Gleichheitstests grün sind (Nachtrag N48). Die Verdrahtung der Analyse (`config.analyse.avvVersion`, `legalPaths`, die Weiche der Mail K18) folgt der Freigabe (Nachtrag N33); P10 verschiebt Seite und PDF der Abo-Bedingungen nach `public/`.
@@ -0,0 +1,45 @@
# Leistungsbeschreibung News-Pinnwand
**Fassung 2026-10-16** · Entwurf – Prüfung durch Kanzlei offen · Zusatzbedingungen zum Wartungs-Abo
## 1. Geltung
(1) Funktion im Sinne dieser Leistungsbeschreibung ist die News-Pinnwand. Diese Leistungsbeschreibung gilt, wenn der Kunde die Funktion im Kundenportal einschaltet und die dort angezeigte Zeile mit dem Klick auf „Einschalten“ bestätigt. Sie gilt für Funktionen, die ab ihrer Veröffentlichung eingeschaltet werden; hat der Kunde die Funktion zuvor einzeln gekauft, gilt für sie weiter die Leistungsbeschreibung, unter der er sie gekauft hat. Die Funktion ist Teil des Wartungs-Abos. Sie setzt ein laufendes Wartungs-Abo voraus; ein Hosting-Abo genügt nicht.
(2) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für die News-Pinnwand diese Leistungsbeschreibung vor.
## 2. Leistung
(1) WEBklar baut die News-Pinnwand in die Website ein, die der Kunde für das gewählte Projekt bei WEBklar im Kundenportal führt. Die Pinnwand zeigt die Beiträge des Kunden als Zettel mit Titel, Text und Foto; pro Seite erscheinen sechs Beiträge, die neuesten zuerst.
(2) Die Beiträge schreibt, ändert und löscht der Kunde selbst im Kundenportal im Bereich „Funktionen“. Er wählt je Beitrag Zettelfarbe und Anzeigedauer; danach verschwindet der Beitrag von seiner Website. Hochgeladene Fotos werden verkleinert, alle Metadaten einschließlich Aufnahmeort (GPS) werden entfernt.
(3) Änderungen an der Website, die über den Einbau der Pinnwand hinausgehen, sind nicht Teil dieser Leistung.
## 3. Einrichtung
(1) Die Einrichtung der Funktion rechnet WEBklar als Arbeitszeit auf das Stundenkontingent des Wartungs-Abos an (§ 4 der Abo-Bedingungen). Reicht das verfügbare Kontingent nicht aus, führt WEBklar die Einrichtung trotzdem aus. Die fehlende Zeit ist ein Vorschuss, den die folgenden Monatskontingente ausgleichen. Eine zusätzliche Rechnung gibt es dafür nicht. Endet das Wartungs-Abo, solange noch ein Vorschuss offen ist, verfällt der Rest zugunsten des Kunden.
(2) Der Einbau ist erfahrungsgemäß meist innerhalb von 1–2 Wochen abgeschlossen. Das ist eine Einschätzung und keine verbindliche Zusage eines Termins. Nach Abschluss des Einbaus schaltet WEBklar die Funktion frei; ab dann erscheinen die Beiträge auf der Website.
(3) Hat WEBklar die Funktion auf Wunsch des Kunden schon bei der Erstellung seiner Website eingebaut, richtet sich die Abrechnung der Einrichtung nach der Vereinbarung zum Projekt und nicht nach dem Stundenkontingent. Auch dann läuft die Funktion nur, solange das Wartungs-Abo läuft.
(4) Arbeiten, die nicht zur Einrichtung gehören, führt WEBklar nur nach Absprache vor der Ausführung und nach Freigabe des Kunden in Textform aus; es gilt § 4 der Abo-Bedingungen.
## 4. Pflichten des Kunden
(1) Der Kunde ist für die Inhalte seiner Beiträge und Fotos verantwortlich. Er versichert, dass er die nötigen Rechte an Texten und Fotos hat und dass die Inhalte keine Rechte Dritter verletzen, etwa Urheberrechte, Persönlichkeitsrechte oder das Recht am eigenen Bild abgebildeter Personen.
## 5. Ende der Leistung
(1) Die Funktion läuft, solange das Wartungs-Abo läuft. Endet es, auch durch einen Wechsel ins Hosting-Abo oder durch den Widerruf eines Wechsels, schaltet WEBklar die Funktion spätestens am folgenden Tag ab. WEBklar informiert den Kunden per E-Mail.
(2) Auf Wunsch des Kunden schaltet WEBklar die Pinnwand jederzeit ab; für den Widerruf gilt Nr. 6.
(3) Beiträge und Fotos bleiben nach dem Abschalten 30 Tage gespeichert und werden danach gelöscht; beginnt in dieser Zeit wieder ein Wartungs-Abo, schaltet WEBklar die Pinnwand mit den vorhandenen Beiträgen ohne weitere Kosten wieder ein. Der Kunde kann seine Beiträge und Fotos jederzeit selbst löschen; andernfalls gelten die Fristen der Datenschutzerklärung (Abschnitt 6a).
## 6. Widerruf
(1) Das Einschalten der Funktion ist Teil des Abos und kein eigener Vertrag. Für den Widerruf gilt die Widerrufsbelehrung der Abo-Bedingungen. Widerruft ein Verbraucher das Abo oder den Wechsel ins Wartungs-Abo, endet die Funktion.
(2) Hat der Kunde die Funktion zuvor einzeln gekauft, bleibt die Widerrufsbelehrung maßgeblich, die er bei der Bestellung erhalten hat.
@@ -0,0 +1,125 @@
# Leistungsbeschreibung Terminplaner
**Fassung 2026-10-16** · Entwurf – Prüfung durch Kanzlei offen · Zusatzbedingungen zum Wartungs-Abo
## 1. Geltung
(1) Funktion im Sinne dieser Leistungsbeschreibung ist der Terminplaner. Diese Leistungsbeschreibung gilt, wenn der Kunde die Funktion im Kundenportal einschaltet und die dort angezeigte Zeile mit dem Klick auf „Einschalten“ bestätigt. Sie gilt für Funktionen, die ab ihrer Veröffentlichung eingeschaltet werden; hat der Kunde die Funktion zuvor einzeln gekauft, gilt für sie weiter die Leistungsbeschreibung, unter der er sie gekauft hat. Die Funktion ist Teil des Wartungs-Abos. Sie setzt ein laufendes Wartungs-Abo voraus; ein Hosting-Abo genügt nicht. Wie die Zustimmung abläuft, regelt die Vorbemerkung des AVV Terminplaner.
(2) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für den Terminplaner diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Terminplaner.
(3) Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Rechtstexte … nicht enthalten“) gilt nicht für den Datenschutz-Absatz nach Nr. 4 Abs. 9 und die Gästetexte nach Nr. 2 Abs. 6. Beides ist Teil des Terminplaners.
## 2. Leistung
(1) Der Terminplaner ist ein Buchungs-Widget für die Website des Kunden mit einer Verwaltung im Kundenportal. WEBklar baut je Website-Projekt ein Widget an einer Stelle der Website ein, entweder eingebettet in die Seite oder hinter einem Knopf. Die Gestaltung richtet sich nach dem Fragebogen, den der Kunde vor dem Einschalten im Kundenportal beantwortet.
(2) Der Kunde legt seine Leistungen, seine Personen oder Plätze, seine Zeiten und seine Regeln für Buchungen fest. Gäste buchen im Widget einen Termin; je nach Einstellung ist die Buchung verbindlich oder eine Anfrage.
(3) Der Terminplaner versendet im Auftrag des Kunden E-Mails an die Gäste: den Bestätigungslink, die Bestätigung, Änderungen, Absagen, auf Wunsch des Gastes eine Erinnerung (nur mit seinem Haken) und die Eingangsbestätigung eines Widerrufs. Den Kunden benachrichtigt er über neue und geänderte Buchungen.
(4) Der Kunde sieht und verwaltet seine Termine im Kundenportal und kann sie als CSV-Datei exportieren. Dazu erhält er einen geheimen Kalender-Link (ICS), mit dem er seine Termine in einem Kalenderprogramm abonnieren kann.
(5) Der Terminplaner enthält einen Missbrauchsschutz gegen automatisierte Buchungen. Die Löschfristen für Buchungsdaten nennt Nummer 9 des AVV Terminplaner.
(6) Bezahlt wird nur vor Ort. Der Hinweis am Buchungsformular, die Texte der Haken, der Datenschutz-Absatz für die Website des Kunden und die Widerrufsbelehrung für Gäste sind Mustertexte der Standardkonfiguration (Nr. 11 Abs. 1).
## 3. Nicht enthalten
(1) Nicht enthalten sind SMS, Online-Zahlung, ein Kalender-Abgleich (es gibt nur das Abonnement des Kalender-Links nach Nr. 2 Abs. 4), Konten für Gäste, Marketing-E-Mails, Gruppentermine, Mehrsprachigkeit und der Einsatz im Gesundheitsbereich (Nr. 4 Abs. 3).
(2) Zusätzliche Wünsche, etwa ein zweites Widget, Knöpfe je Leistung oder ein Sonderdesign, gehören nicht zur Einrichtung nach Nr. 5. WEBklar führt sie nach Stunden aus (§ 4 der Abo-Bedingungen).
## 4. Voraussetzungen und Pflichten des Kunden
(1) Der Kunde hat ein laufendes Wartungs-Abo.
(2) Der Kunde trägt vor dem Einschalten seine Betriebsangaben ein, dazu die Adresse seines Impressums und die Adresse seiner Datenschutzerklärung.
(3) Der Terminplaner ist nicht für Angebote im Gesundheitsbereich bestimmt, weil dort besondere Kategorien personenbezogener Daten anfallen können (Art. 9 DSGVO). Der Kunde setzt ihn dort nicht ein und fordert von seinen Gästen keine Gesundheitsangaben. WEBklar darf die Einrichtung für eine solche Website ablehnen oder den Terminplaner dort abschalten. Der Fragebogen fragt die Branche ab; wählt der Kunde den Gesundheitsbereich, ist der Terminplaner für ihn nicht verfügbar.
(4) Richtet sich das Angebot des Kunden gezielt an Kinder unter 16 Jahren, nutzt der Kunde den Terminplaner nicht.
(5) Der Kunde nennt eine Telefonnummer, unter der Gäste Termine vereinbaren können, wenn das Widget nicht erreichbar ist (Nr. 6 Abs. 2).
(6) Er lässt die Texte der Haken und Hinweise im Widget unverändert.
(7) Er informiert seine Beschäftigten darüber, dass ihre Namen oder Bezeichnungen, ihre Arbeitszeiten und Abwesenheiten im Terminplaner stehen und dass es einen geheimen Kalender-Link gibt.
(8) Die Pflichten gegenüber seinen Gästen trägt der Kunde: Preisangaben, Allgemeine Geschäftsbedingungen und Stornobedingungen sowie die Einstufung seiner Leistungen als Leistung für Verbraucher oder Unternehmer und als Freizeitleistung.
(9) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einbau dort den Datenschutz-Absatz für die Online-Terminbuchung ein; das gehört zur Einrichtung. Liegt sie woanders, etwa bei einem externen Generator, erhält der Kunde den Absatz als Textbaustein und fügt ihn selbst ein. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt der Kunde verantwortlich (§ 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf, weist WEBklar den Kunden darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht.
## 5. Einrichtung
(1) Die Einrichtung umfasst die Einrichtung nach dem Fragebogen, den Einbau des Widgets in die Website, den Datenschutz-Absatz nach Nr. 4 Abs. 9, die Abnahme durch den Kunden und die Freischaltung durch WEBklar.
(2) Die Einrichtung der Funktion rechnet WEBklar als Arbeitszeit auf das Stundenkontingent des Wartungs-Abos an (§ 4 der Abo-Bedingungen). Reicht das verfügbare Kontingent nicht aus, führt WEBklar die Einrichtung trotzdem aus. Die fehlende Zeit ist ein Vorschuss, den die folgenden Monatskontingente ausgleichen. Eine zusätzliche Rechnung gibt es dafür nicht. Endet das Wartungs-Abo, solange noch ein Vorschuss offen ist, verfällt der Rest zugunsten des Kunden.
(3) Die voraussichtliche Dauer der Einrichtung beträgt meist 1–2 Wochen. Sie ist eine Einschätzung und keine verbindliche Zusage eines Termins.
(4) Hat WEBklar die Funktion auf Wunsch des Kunden schon bei der Erstellung seiner Website eingebaut, richtet sich die Abrechnung der Einrichtung nach der Vereinbarung zum Projekt und nicht nach dem Stundenkontingent. Auch dann läuft die Funktion nur, solange das Wartungs-Abo läuft.
(5) Arbeiten, die nicht zur Einrichtung gehören, führt WEBklar nur nach Absprache vor der Ausführung und nach Freigabe des Kunden in Textform aus; es gilt § 4 der Abo-Bedingungen. Für den Betrieb des Terminplaners berechnet WEBklar neben dem Wartungs-Abo nichts weiter; er läuft, solange das Wartungs-Abo läuft (Nr. 8).
## 6. Verfügbarkeit und Störungen
(1) Das Verfügbarkeitsziel nach § 5 der Abo-Bedingungen gilt für die Website, nicht für den Terminplaner. Störungen des Terminplaners beheben wir in angemessener Frist (§ 12 der Abo-Bedingungen).
(2) Ist der Terminplaner nicht erreichbar, zeigt das Widget einen Ausweichtext mit der Telefonnummer des Kunden (Nr. 4 Abs. 5).
(3) Alle Zeiten gelten in der Zeitzone Europe/Berlin.
(4) WEBklar übernimmt keine Gewähr dafür, dass E-Mails zugestellt und gelesen werden. Das hängt vom Postfach des Empfängers und von den Mailgrenzen seines Anbieters ab.
(5) Die gesetzlichen Rechte des Kunden sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt.
## 7. Haftung für verpasste Termine
(1) Der Terminplaner ist ein Hilfsmittel. Die Einhaltung der Termine bleibt Sache des Kunden.
(2) WEBklar übernimmt keine Gewähr für die Zustellung der E-Mails (Nr. 6 Abs. 4).
(3) Für entgangenen Gewinn und Folgeschäden aus verpassten Terminen haftet WEBklar nur im Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen.
(4) Der Haftungsrahmen des Kunden gegenüber seinen Gästen bleibt unberührt.
## 8. Ende der Leistung
(1) Die Funktion läuft, solange das Wartungs-Abo läuft. Endet es, auch durch einen Wechsel ins Hosting-Abo oder durch den Widerruf eines Wechsels, schaltet WEBklar die Funktion spätestens am folgenden Tag ab. WEBklar informiert den Kunden per E-Mail.
(2) Mit dem Abschalten nimmt der Terminplaner keine neuen Buchungen mehr an, und das Widget zeigt den Ausweichtext. Die Termine bleiben im Kundenportal sichtbar, und der Kunde kann sie als CSV-Datei exportieren. Gäste können über ihren Link absagen. Bereits angeforderte Erinnerungen laufen weiter. Der Kunde informiert seine Gäste.
(3) WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6 des AVV Terminplaner).
(4) Beginnt innerhalb dieser 30 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Einrichtung nötig (neuer Fragebogen, Einrichtung nach Nr. 5).
(5) Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, bricht WEBklar die Einrichtung ab; ein Vorschuss nach Nr. 5 Abs. 2 verfällt.
(6) Auf Wunsch des Kunden in Textform schaltet WEBklar den Terminplaner jederzeit ab; für den Widerruf gilt Nr. 10.
## 9. Änderungen und Einstellung
(1) WEBklar darf die Technik des Terminplaners verbessern, solange der Leistungsumfang nicht schlechter wird (§ 3 Abs. 7 der Abo-Bedingungen). Ändert sich dabei, welche Daten erhoben werden, gilt Nummer 12 des AVV Terminplaner.
(2) WEBklar darf den Terminplaner für eine Website mit einer Frist von vier Wochen beenden oder für alle Kunden mit einer Frist von drei Monaten einstellen, wenn
- ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form unzulässig macht oder nur mit wesentlich anderer Technik erlaubt und der Kunde einer dafür nötigen neuen Fassung nicht zustimmt (AVV Terminplaner Nummer 12), oder
- eine Sicherheitslücke sich nicht anders beheben lässt.
Die Mitteilung erfolgt in Textform und nennt den Grund, die Merkmale der Änderung, den Zeitpunkt und bei Verbrauchern das Recht, den Vertrag nach § 327r Abs. 3 BGB zu beenden, mit seiner Frist.
(3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar den Terminplaner ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Kunden darüber unverzüglich in Textform mit dem Grund und nimmt den Betrieb wieder auf, sobald das zulässig und technisch möglich ist.
(4) Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.
## 10. Widerruf
(1) Das Einschalten der Funktion ist Teil des Abos und kein eigener Vertrag. Für den Widerruf gilt die Widerrufsbelehrung der Abo-Bedingungen. Widerruft ein Verbraucher das Abo oder den Wechsel ins Wartungs-Abo, endet die Funktion.
(2) Hat der Kunde die Funktion zuvor einzeln gekauft, bleibt die Widerrufsbelehrung maßgeblich, die er bei der Bestellung erhalten hat.
## 11. Hinweise
(1) **Keine Rechtsberatung.** Der Hinweis am Buchungsformular, die Texte der Haken, der Datenschutz-Absatz und die Widerrufsbelehrung für Gäste sind Mustertexte für die Standardkonfiguration. WEBklar sorgt dafür, dass sie die Technik richtig beschreiben, und passt sie an, wenn sich die Technik ändert. Die Einstufung seiner Leistungen als Leistung für Verbraucher oder Unternehmer und als Freizeitleistung trägt der Kunde. Eine rechtliche Beratung im Einzelfall schuldet WEBklar nicht.
(2) **Barrierefreiheit.** Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen, Fokusführung, Fehlermeldungen als Live-Region). Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu. Ob der Kunde als Dienstleistungserbringer unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Kunde.
@@ -0,0 +1,127 @@
# Leistungsbeschreibung Terminplaner
**Fassung 2026-10-05** · Zusatzbedingungen zum Wartungs-Abo
## 1. Geltung
(1) Diese Leistungsbeschreibung regelt die Funktion „Terminplaner“ im Wartungs-Abo. Sie setzt ein laufendes Wartungs-Abo voraus; ein Hosting-Abo genügt nicht. Sie wird vereinbart, wenn der Kunde die Einrichtung des Terminplaners im Kundenportal bestellt und dabei dieser Leistungsbeschreibung, dem Auftragsverarbeitungsvertrag Terminplaner („AVV Terminplaner“) und der Widerrufsbelehrung zustimmt. Wie die Zustimmung abläuft, regelt die Vorbemerkung des AVV Terminplaner.
(2) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für den Terminplaner diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Terminplaner.
(3) Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Rechtstexte … nicht enthalten“) gilt nicht für den Datenschutz-Absatz nach Nr. 4 Abs. 9 und die Gästetexte nach Nr. 2 Abs. 6. Beides ist Teil des Terminplaners.
## 2. Leistung
(1) Der Terminplaner ist ein Buchungs-Widget für die Website des Kunden mit einer Verwaltung im Kundenportal. WEBklar baut je Website-Projekt ein Widget an einer Stelle der Website ein, entweder eingebettet in die Seite oder hinter einem Knopf. Die Gestaltung richtet sich nach dem Fragebogen, den der Kunde vor der Bestellung im Kundenportal beantwortet.
(2) Der Kunde legt seine Leistungen, seine Personen oder Plätze, seine Zeiten und seine Regeln für Buchungen fest. Gäste buchen im Widget einen Termin; je nach Einstellung ist die Buchung verbindlich oder eine Anfrage.
(3) Der Terminplaner versendet im Auftrag des Kunden E-Mails an die Gäste: den Bestätigungslink, die Bestätigung, Änderungen, Absagen, auf Wunsch des Gastes eine Erinnerung (nur mit seinem Haken) und die Eingangsbestätigung eines Widerrufs. Den Kunden benachrichtigt er über neue und geänderte Buchungen.
(4) Der Kunde sieht und verwaltet seine Termine im Kundenportal und kann sie als CSV-Datei exportieren. Dazu erhält er einen geheimen Kalender-Link (ICS), mit dem er seine Termine in einem Kalenderprogramm abonnieren kann.
(5) Der Terminplaner enthält einen Missbrauchsschutz gegen automatisierte Buchungen. Die Löschfristen für Buchungsdaten nennt Nummer 9 des AVV Terminplaner.
(6) Bezahlt wird nur vor Ort. Der Hinweis am Buchungsformular, die Texte der Haken, der Datenschutz-Absatz für die Website des Kunden und die Widerrufsbelehrung für Gäste sind Mustertexte der Standardkonfiguration (Nr. 11 Abs. 1).
## 3. Nicht enthalten
(1) Nicht enthalten sind SMS, Online-Zahlung, ein Kalender-Abgleich (es gibt nur das Abonnement des Kalender-Links nach Nr. 2 Abs. 4), Konten für Gäste, Marketing-E-Mails, Gruppentermine, Mehrsprachigkeit und der Einsatz im Gesundheitsbereich (Nr. 4 Abs. 3).
(2) Zusätzliche Wünsche, etwa ein zweites Widget, Knöpfe je Leistung oder ein Sonderdesign, gehören nicht zur Einrichtung nach Nr. 5. WEBklar führt sie nach Stunden aus (§ 4 der Abo-Bedingungen).
## 4. Voraussetzungen und Pflichten des Kunden
(1) Der Kunde hat ein laufendes Wartungs-Abo.
(2) Der Kunde trägt vor der Bestellung seine Betriebsangaben ein, dazu die Adresse seines Impressums und die Adresse seiner Datenschutzerklärung.
(3) Der Terminplaner ist nicht für Angebote im Gesundheitsbereich bestimmt, weil dort besondere Kategorien personenbezogener Daten anfallen können (Art. 9 DSGVO). Der Kunde setzt ihn dort nicht ein und fordert von seinen Gästen keine Gesundheitsangaben. WEBklar darf die Einrichtung für eine solche Website ablehnen oder den Terminplaner dort abschalten.
(4) Richtet sich das Angebot des Kunden gezielt an Kinder unter 16 Jahren, nutzt der Kunde den Terminplaner nicht.
(5) Der Kunde nennt eine Telefonnummer, unter der Gäste Termine vereinbaren können, wenn das Widget nicht erreichbar ist (Nr. 6 Abs. 2).
(6) Er lässt die Texte der Haken und Hinweise im Widget unverändert.
(7) Er informiert seine Beschäftigten darüber, dass ihre Namen oder Bezeichnungen, ihre Arbeitszeiten und Abwesenheiten im Terminplaner stehen und dass es einen geheimen Kalender-Link gibt.
(8) Die Pflichten gegenüber seinen Gästen trägt der Kunde: Preisangaben, Allgemeine Geschäftsbedingungen und Stornobedingungen sowie die Einstufung seiner Leistungen als Leistung für Verbraucher oder Unternehmer und als Freizeitleistung.
(9) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einbau dort den Datenschutz-Absatz für die Online-Terminbuchung ein; das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet. Liegt sie woanders, etwa bei einem externen Generator, erhält der Kunde den Absatz als Textbaustein und fügt ihn selbst ein. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt der Kunde verantwortlich (§ 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf, weist WEBklar den Kunden darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht.
## 5. Einrichtung und Preis
(1) Die Einrichtung umfasst die Einrichtung nach dem Fragebogen, den Einbau des Widgets in die Website, den Datenschutz-Absatz nach Nr. 4 Abs. 9, die Abnahme durch den Kunden und die Freischaltung durch WEBklar.
(2) Die Einrichtung kostet 149 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG.
(3) Die voraussichtliche Dauer der Einrichtung nennt die Bestellseite vor der Bestellung. Sie ist eine Einschätzung und keine verbindliche Zusage eines Termins.
(4) Der Kunde bezahlt bei der Bestellung im Kundenportal über Stripe und erhält eine Rechnung.
(5) Der Preis steht neben dem Stundenkontingent des Wartungs-Abos: Die Einrichtung wird weder auf das Kontingent angerechnet noch damit bezahlt. Für den Betrieb des Terminplaners berechnet WEBklar neben dem Wartungs-Abo nichts weiter; er läuft, solange das Wartungs-Abo läuft (Nr. 8).
## 6. Verfügbarkeit und Störungen
(1) Das Verfügbarkeitsziel nach § 5 der Abo-Bedingungen gilt für die Website, nicht für den Terminplaner. Störungen des Terminplaners beheben wir in angemessener Frist (§ 12 der Abo-Bedingungen).
(2) Ist der Terminplaner nicht erreichbar, zeigt das Widget einen Ausweichtext mit der Telefonnummer des Kunden (Nr. 4 Abs. 5).
(3) Alle Zeiten gelten in der Zeitzone Europe/Berlin.
(4) WEBklar übernimmt keine Gewähr dafür, dass E-Mails zugestellt und gelesen werden. Das hängt vom Postfach des Empfängers und von den Mailgrenzen seines Anbieters ab.
(5) Die gesetzlichen Rechte des Kunden sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt.
## 7. Haftung für verpasste Termine
(1) Der Terminplaner ist ein Hilfsmittel. Die Einhaltung der Termine bleibt Sache des Kunden.
(2) WEBklar übernimmt keine Gewähr für die Zustellung der E-Mails (Nr. 6 Abs. 4).
(3) Für entgangenen Gewinn und Folgeschäden aus verpassten Terminen haftet WEBklar nur im Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen.
(4) Der Haftungsrahmen des Kunden gegenüber seinen Gästen bleibt unberührt.
## 8. Ende der Leistung
(1) Mit dem Ende des Wartungs-Abos endet der Terminplaner. WEBklar schaltet ihn spätestens am folgenden Tag ab und informiert den Kunden per E-Mail. Das gilt auch bei einem Wechsel ins Hosting-Abo und beim Widerruf des Abschlusses oder eines Wechsels ins Wartungs-Abo.
(2) Mit dem Abschalten nimmt der Terminplaner keine neuen Buchungen mehr an, und das Widget zeigt den Ausweichtext. Die Termine bleiben im Kundenportal sichtbar, und der Kunde kann sie als CSV-Datei exportieren. Gäste können über ihren Link absagen. Bereits angeforderte Erinnerungen laufen weiter. Der Kunde informiert seine Gäste.
(3) WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6 des AVV Terminplaner).
(4) Beginnt innerhalb dieser 30 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Einrichtung nötig (neuer Fragebogen, neuer Preis nach Nr. 5).
(5) Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.
(6) Auf Wunsch des Kunden in Textform schaltet WEBklar den Terminplaner jederzeit ab. Eine Erstattung des Einrichtungspreises folgt daraus nicht; für den Widerruf gilt Nr. 10.
## 9. Änderungen und Einstellung
(1) WEBklar darf die Technik des Terminplaners verbessern, solange der Leistungsumfang nicht schlechter wird (§ 3 Abs. 7 der Abo-Bedingungen). Ändert sich dabei, welche Daten erhoben werden, gilt Nummer 12 des AVV Terminplaner.
(2) WEBklar darf den Terminplaner für eine Website mit einer Frist von vier Wochen beenden oder für alle Kunden mit einer Frist von drei Monaten einstellen, wenn
- ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form unzulässig macht oder nur mit wesentlich anderer Technik erlaubt und der Kunde einer dafür nötigen neuen Fassung nicht zustimmt (AVV Terminplaner Nummer 12), oder
- eine Sicherheitslücke sich nicht anders beheben lässt.
Die Mitteilung erfolgt in Textform und nennt den Grund, die Merkmale der Änderung, den Zeitpunkt und bei Verbrauchern das Recht, den Vertrag nach § 327r Abs. 3 BGB zu beenden, mit seiner Frist.
(3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar den Terminplaner ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Kunden darüber unverzüglich in Textform mit dem Grund und nimmt den Betrieb wieder auf, sobald das zulässig und technisch möglich ist.
(4) Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.
## 10. Widerruf
(1) Verbraucher können die Bestellung der Einrichtung nach der Widerrufsbelehrung widerrufen. Belehrung und Muster-Widerrufsformular stehen in der Anlage „Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)“.
(2) Ob das Widerrufsrecht erlischt und ob der Kunde einen angemessenen Betrag für die bis dahin erbrachte Leistung schuldet, richtet sich nach der Zustimmung, die der Kunde bei der Bestellung gibt, und nach den Hinweisen zum Widerruf in der Anlage.
(3) Mit dem Widerruf endet der Terminplaner.
## 11. Hinweise
(1) **Keine Rechtsberatung.** Der Hinweis am Buchungsformular, die Texte der Haken, der Datenschutz-Absatz und die Widerrufsbelehrung für Gäste sind Mustertexte für die Standardkonfiguration. WEBklar sorgt dafür, dass sie die Technik richtig beschreiben, und passt sie an, wenn sich die Technik ändert. Die Einstufung seiner Leistungen als Leistung für Verbraucher oder Unternehmer und als Freizeitleistung trägt der Kunde. Eine rechtliche Beratung im Einzelfall schuldet WEBklar nicht.
(2) **Barrierefreiheit.** Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen, Fokusführung, Fehlermeldungen als Live-Region). Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu. Ob der Kunde als Dienstleistungserbringer unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Kunde.
@@ -0,0 +1,192 @@
# Leistungsbeschreibung Buchungskalender
**Fassung 2026-10-16** · Entwurf – Prüfung durch Kanzlei offen · Zusatzbedingungen zum Wartungs-Abo
## 1. Geltung
(1) Diese Leistungsbeschreibung regelt die Funktion „Buchungskalender“. Mit dem Buchungskalender nimmt der Kunde über seine Website Anfragen für die Vermietung von Unterkünften entgegen (Ferienwohnungen, Monteurzimmer, kleine Pensionen) und rechnet die Aufenthalte ab („Vermieter“); Gäste sind die Besucher seiner Website, die im Buchungskalender anfragen und buchen („Gäste“). Die Funktion ist Teil des Wartungs-Abos. Sie setzt ein laufendes Wartungs-Abo voraus; ein Hosting-Abo genügt nicht. Sie richtet sich nur an Unternehmer, die in eigenem Namen und für eigene Rechnung vermieten.
(2) Diese Leistungsbeschreibung gilt, wenn der Vermieter die Funktion im Kundenportal einschaltet und die dort angezeigte Zeile mit dem Klick auf „Einschalten“ bestätigt. Sie gilt für Funktionen, die ab ihrer Veröffentlichung eingeschaltet werden; hat der Vermieter die Funktion zuvor einzeln gekauft, gilt für sie weiter die Leistungsbeschreibung, unter der er sie gekauft hat. Wie die Zustimmung abläuft, regelt die Vorbemerkung des AVV Buchungskalender. Einschalten kann der Vermieter erst, nachdem WEBklar seine Anfrage für den Buchungskalender geprüft und angenommen hat (Nr. 2 Abs. 1). Für einen Vermieter, dem WEBklar die Funktion als Teil seines Website-Projekts bereitstellt, schaltet WEBklar sie nach seiner Zustimmung in Textform frei; die Bedingungen dieser Leistungsbeschreibung gelten dann ebenso, die Einrichtung rechnet WEBklar nach Nr. 9 Abs. 3 ab.
(3) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für den Buchungskalender diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Buchungskalender.
(4) Leistungsbeschreibung A Nr. 2 Abs. 3 und Nr. 4 sowie Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen (Formulare mit Server, „Rechtstexte … nicht enthalten“) stehen dem Buchungskalender nicht entgegen. Das Widget des Buchungskalenders läuft auf dem Server von WEBklar, nicht auf dem Server der Website, und der Muster-Text zur Stornierung nach Nr. 6 ist Teil des Buchungskalenders.
## 2. Leistung
(1) Der Vermieter fragt den Buchungskalender im Kundenportal an und beschreibt seine Unterkünfte (Art der Unterkunft, Zahl der Einheiten, ob er Booking.com oder Airbnb nutzt). WEBklar prüft die Anfrage und nimmt sie an oder lehnt sie mit einer Begründung ab. Ein Anspruch auf Annahme besteht nicht. Erst nach der Annahme kann der Vermieter den Buchungskalender einschalten.
(2) Die Einrichtung umfasst den Einbau eines Kalender-Widgets an einer Stelle der Website des Vermieters, die Bereitstellung der Verwaltung im Kundenportal, den Einbau des Datenschutz-Absatzes für den Buchungskalender (Nr. 5 Abs. 10) und die Freischaltung durch WEBklar. Für jede Website wird ein Buchungskalender eingerichtet; er kann mehrere Unterkünfte desselben Vermieters enthalten. Zusätzlich gibt es für jeden freigegebenen Aufenthalt eine Buchungsseite bei WEBklar, die der Gast über den Link in seiner E-Mail erreicht.
(3) So läuft eine Buchung ab:
- Das Widget zeigt dem Gast die belegten Nächte. Er wählt Anreise und Abreise, gibt Personenzahl, Namen, E-Mail-Adresse und auf Wunsch Firma, Telefonnummer und Nachricht an und sendet eine **unverbindliche Anfrage**. Er erhält eine Eingangsbestätigung, der Vermieter eine Benachrichtigung.
- Der Vermieter prüft die Anfrage im Kundenportal, sieht den vorgerechneten Preis, kann die Posten ändern und gibt die Anfrage mit einer Reservierungsfrist frei oder lehnt sie ab. Gibt er sie frei, reserviert der Buchungskalender die Nächte bis zum Ablauf der Frist, und der Gast erhält eine E-Mail mit Preis, Stornobedingungen und dem Link zur Buchungsseite.
- Auf der Buchungsseite sieht der Gast alle Angaben zum Aufenthalt, trägt seine Rechnungsadresse ein, wählt die Zahlart (online über Stripe, wenn der Vermieter sie eingeschaltet hat, oder vor Ort) und bucht mit der Schaltfläche zur zahlungspflichtigen Buchung. Erst damit kommt der Vertrag zwischen Vermieter und Gast zustande, nicht schon mit der Anfrage oder der Freigabe.
- Mit der Buchung erzeugt der Buchungskalender eine Rechnung mit lückenloser Nummer und sendet dem Gast eine Buchungsbestätigung mit der Rechnung als PDF; der Vermieter wird benachrichtigt.
- Verstreicht die Frist ohne Buchung, läuft die Reservierung ab und die Nächte werden frei; der Gast erhält darüber eine E-Mail.
- Der Vermieter kann einen Aufenthalt stornieren. Dann erhält der Gast eine Stornorechnung mit eigener Nummer und, soweit der Vermieter eine Online-Zahlung erstattet, eine Mitteilung über den Erstattungsbetrag. Eine Stornierung durch den Gast gibt es im Widget nicht; er wendet sich an den Vermieter.
(4) Im Kundenportal verwaltet der Vermieter:
- Unterkünfte mit Beschreibung, Höchstzahl der Personen, Mindestnächten und Check-in- und Check-out-Zeiten,
- Preise (Staffel nach Aufenthaltsdauer, Endreinigung, Zusatzpersonen, Direktbuchungsrabatt, Steuerart und Steuersätze) und seine Stornobedingungen,
- Anfragen und Buchungen mit Status, die Eintragung „bezahlt (vor Ort)“ und Stornierungen,
- Kalender mit manuellen Sperren,
- die Verbindungen zu anderen Kalendern (Nr. 4),
- seine Rechnungen als PDF,
- seine Angaben als Aussteller der Rechnungen (Name, Anschrift, Steuernummer oder Umsatzsteuer-Identifikationsnummer, Bankverbindung, Rechnungspräfix) und die Verbindung mit seinem Stripe-Konto (Nr. 3).
(5) Der Buchungskalender versendet im Namen des Vermieters E-Mails an die Gäste: Eingang der Anfrage, Freigabe mit Buchungslink, Ablehnung, Buchungsbestätigung mit Rechnung, Zahlungseingang, Ablauf der Reservierung und Stornierung. Absender ist die Versandadresse von WEBklar mit dem Namen des Vermieters, als Antwortadresse dient seine E-Mail-Adresse. Den Vermieter benachrichtigt der Buchungskalender über neue Anfragen und neue Buchungen sowie, wenn der Abgleich mit einem anderen Kalender seit mindestens sechs Stunden fehlschlägt.
(6) Der Buchungskalender enthält einen Missbrauchsschutz gegen automatisierte Anfragen (verstecktes Feld, Mindest-Ausfüllzeit, Obergrenzen je IP-Adresse). Dafür speichert WEBklar mit der Anfrage nur einen Hashwert der IP-Adresse, nicht die IP-Adresse selbst; er wird entfernt, sobald der Vermieter die Anfrage freigibt oder ablehnt, spätestens nach 30 Tagen (Nummer 3 Abs. 4 des AVV Buchungskalender).
(7) Nicht enthalten sind:
- der Meldeschein nach dem Bundesmeldegesetz und die Verwaltung der Meldedaten,
- Kurtaxe, Gästebeitrag und Beherbergungsabgaben,
- Anzahlungen und Teilzahlungen (der Gast zahlt den Gesamtbetrag in einer Zahlung, online oder vor Ort),
- Sofortbuchung ohne Freigabe,
- saisonale Preise (die Preise richten sich nur nach der Aufenthaltsdauer),
- Gutscheine, Channel-Manager-Anbindungen außer dem Kalenderabgleich nach Nr. 4 und Mehrsprachigkeit (der Buchungskalender ist deutschsprachig),
- die Buchführung des Vermieters.
Zusätzliche Wünsche, etwa ein Sonderdesign, ein zweiter Buchungskalender oder eine über das übliche Maß hinausgehende Zahl von Unterkünften, gehören nicht zur Einrichtung nach Nr. 9. WEBklar führt sie nach Absprache nach Stunden aus (Nr. 9 Abs. 4).
## 3. Zahlungen über Stripe und Rechnungen im Namen des Vermieters
(1) Online-Zahlungen im Buchungskalender sind optional und laufen über Stripe Connect. Der Vermieter verbindet dazu sein eigenes Stripe-Konto mit dem Buchungskalender. Hat er für den Webshop von WEBklar bereits ein Stripe-Konto verbunden, kann er es auch für den Buchungskalender verwenden. Den Vertrag über dieses Konto schließt der Vermieter unmittelbar mit Stripe; WEBklar ist daran nicht beteiligt. Für das Konto, die Prüfung des Vermieters durch Stripe und die Auszahlungen gelten die Bedingungen von Stripe. Ohne verbundenes und freigeschaltetes Stripe-Konto ist nur die Zahlart „vor Ort“ möglich.
(2) Das Geld der Gäste fließt direkt an den Vermieter (Zahlung unmittelbar auf dem Konto des Vermieters, sogenannte Direct Charge). WEBklar erhält und verwahrt kein Geld der Gäste, leitet keines weiter und beteiligt sich nicht am Umsatz des Vermieters. WEBklar ist kein Zahlungsdienstleister. Die Gebühren von Stripe trägt der Vermieter; sie fallen nur zwischen dem Vermieter und Stripe an.
(3) Der Vermieter prüft selbst, ob sein Stripe-Konto Zahlungen annehmen und Auszahlungen leisten kann. WEBklar zeigt ihm im Kundenportal den Stand, den Stripe meldet, hat aber keinen Einfluss darauf, ob und wann Stripe ein Konto freischaltet, einschränkt oder sperrt, und haftet dafür nicht. Rückbuchungen, Streitfälle mit Gästen und Zahlungsausfälle regeln der Vermieter und Stripe.
(4) Erstattungen bei einer Stornierung veranlasst der Vermieter im Kundenportal; WEBklar führt die Erstattung über das Stripe-Konto des Vermieters aus. Er bestimmt den Betrag bis zur Höhe der Zahlung des Gastes. Storniert der Vermieter einen Aufenthalt, schließt WEBklar eine noch offene Zahlungsseite bei Stripe. **Automatische Erstattung:** Geht eine Zahlung ein, nachdem die Buchung storniert oder die Reservierung abgelaufen ist, oder zahlt der Gast eine bereits bezahlte Buchung ein zweites Mal, erstattet WEBklar den Betrag ohne weitere Anweisung im Namen des Vermieters in voller Höhe über dessen Stripe-Konto. Dazu erteilt der Vermieter WEBklar mit der Zustimmung zum Buchungskalender eine dauerhafte Weisung; der Stand der Buchung ändert sich dadurch nicht. Erstattungen aus dem Stripe-Dashboard trägt WEBklar in die Buchung nach, soweit Stripe sie meldet.
(5) Die Zahlungsseite selbst (Stripe Checkout) betreibt Stripe. Online-Zahlungen sind derzeit nur per Karte möglich, einschließlich Apple Pay und Google Pay; eine SEPA-Lastschrift bietet der Buchungskalender nicht an. Kartendaten gibt der Gast nur dort ein; WEBklar erhält sie nicht. Beim Start der Zahlung übergibt WEBklar Stripe als Rücksprungadresse (bei Erfolg und bei Abbruch) den geheimen Link zur Buchungsseite des Gastes samt Token. Dieser Link ist damit für Stripe und im Stripe-Dashboard des Vermieters sichtbar; Der Link gilt bis 90 Tage nach der Abreise (bei nicht zustande gekommenen Buchungen 30 Tage) und öffnet nur diese eine Buchungsseite.
(6) **Rechnungen im Namen des Vermieters.** Der Vermieter beauftragt WEBklar mit dem Rechnungsauftrag in den Einstellungen des Buchungskalenders, in seinem Namen und für seine Rechnung Rechnungen und Stornorechnungen an seine Gäste auszustellen (Rechnung durch einen Dritten im Namen und im Auftrag des Leistenden, § 14 Abs. 2 Satz 4 UStG). Aussteller der Rechnung ist der Vermieter; sie trägt seinen Namen, seine Anschrift, seine Steuernummer oder Umsatzsteuer-Identifikationsnummer und keinen Hinweis auf WEBklar. WEBklar erzeugt die Rechnung aus den Angaben, die der Vermieter hinterlegt und bei der Freigabe bestätigt hat. Er verantwortet die Richtigkeit seiner Angaben, der Preise, der Steuerart und der Steuersätze; WEBklar prüft sie nicht steuerrechtlich. Die Rechnung wird nach ihrer Ausstellung nicht mehr verändert; Fehler korrigiert der Vermieter durch Stornierung und neue Buchung nach den Möglichkeiten des Kundenportals. Ohne diesen Auftrag stellt WEBklar keine Rechnungen aus. Widerruft er den Auftrag in Textform, stellt der Buchungskalender keine Rechnungen mehr aus und ist für ihn nicht mehr nutzbar.
(7) Die Rechnungsnummern sind je Vermieter und Jahr lückenlos fortlaufend; sie bestehen aus dem Präfix, das der Vermieter vor der ersten Rechnung festlegt, dem Jahr und einer laufenden Nummer.
## 4. Verfügbarkeit und Kalenderabgleich (iCal)
(1) Der Vermieter kann Kalender anderer Plattformen (zum Beispiel Booking.com oder Airbnb) über eine iCal-Adresse einlesen lassen; die eingelesenen Zeiträume blockiert der Buchungskalender. Umgekehrt stellt der Buchungskalender dem Vermieter eine geheime iCal-Adresse bereit, die er bei anderen Plattformen einträgt; sie enthält die belegten Nächte ohne Daten der Gäste. Der Vermieter kann diese Adresse jederzeit erneuern; die bisherige Adresse funktioniert dann nicht mehr.
(2) Der Abgleich läuft regelmäßig, aber zeitversetzt: WEBklar liest die Kalender anderer Plattformen etwa alle 15 Minuten ein, und die anderen Plattformen lesen die Adresse des Buchungskalenders nach ihrem eigenen Takt, oft nur alle paar Stunden. **Zwischen einer Buchung hier und ihrem Eingang dort können deshalb Minuten bis Stunden vergehen, und umgekehrt.** WEBklar sagt keine Echtzeit-Verfügbarkeit zu und übernimmt keine Gewähr dafür, dass eine Unterkunft nicht doppelt gebucht wird.
(3) Dieses Risiko mindert die Freigabe: Jede Anfrage im Buchungskalender braucht die Freigabe des Vermieters. Er prüft vor der Freigabe seine anderen Plattformen und seinen Kalender. Beim Freigeben und beim Buchen prüft der Buchungskalender zusätzlich, ob die Nächte nach seinem Stand frei sind. Eine Doppelbuchung entsteht trotzdem, wenn eine andere Plattform eine Buchung noch nicht gemeldet hat oder der Abgleich ausfällt. Der Vermieter löst eine Doppelbuchung selbst (etwa durch Stornierung) und trägt die Folgen gegenüber seinen Gästen und den Plattformen.
(4) Fehlerhafte oder nicht erreichbare iCal-Adressen kann WEBklar nicht beheben. Schlägt der Abruf fehl, bleibt der zuletzt gelesene Stand erhalten, und der Vermieter wird benachrichtigt, wenn der Abruf seit mindestens sechs Stunden nicht gelungen ist (höchstens einmal je 24 Stunden und Kalender). Eingelesen werden nur Zeiträume von gestern bis 18 Monate in die Zukunft, höchstens 1 Megabyte je Kalender; nur Adressen mit https werden abgerufen.
## 5. Pflichten des Vermieters
(1) Der Vermieter hat ein laufendes Wartungs-Abo und ist Unternehmer. Will er Online-Zahlungen nutzen, stellt er sein eigenes Stripe-Konto bereit und schließt die Anmeldung bei Stripe selbst ab. Der Vermieter vermietet in eigenem Namen; für Preise, Stornobedingungen, Rechnungen und Meldepflichten ist er verantwortlich. Online-Zahlungen seiner Gäste laufen, sofern er sie nutzt, über sein eigenes Stripe-Konto zu den Bedingungen von Stripe; WEBklar erhält und verwahrt kein Geld seiner Gäste und erhebt keine Umsatzbeteiligung. Rechnungen an Gäste stellt WEBklar nur aus, wenn der Vermieter in den Einstellungen des Buchungskalenders den Rechnungsauftrag erteilt hat (§ 14 Abs. 2 Satz 4 UStG); für die Richtigkeit von Preisen und Steuerangaben bleibt er verantwortlich.
(2) Er verantwortet seine Unterkünfte, Preise und Angaben zum Angebot. Dazu gehören richtige Preise einschließlich Steuerausweis (Umsatzsteuer, für die Beherbergung und die Endreinigung nach den dafür geltenden Steuersätzen, oder Hinweis auf § 19 UStG), Check-in- und Check-out-Zeiten, Höchstbelegung, Mindestnächte und alle Angaben, die er seinen Gästen vor der Buchung geben muss. Die Steuerart und die Steuersätze trägt er selbst im Kundenportal ein; WEBklar berät dazu nicht.
(3) Er legt vor der Freischaltung seine Stornobedingungen fest und verantwortet sie. Der Muster-Text nach Nr. 6 ist nur ein Vorschlag.
(4) Er verantwortet die Verfügbarkeit seiner Unterkünfte, pflegt Sperren und Kalenderverbindungen im Kundenportal und prüft regelmäßig auf neue Anfragen und Buchungen. Die Folgen von Angaben, die er setzt (zum Beispiel einer fälschlich freien oder gesperrten Zeit), trägt er.
(5) Er gibt Anfragen nur frei, wenn er die Unterkunft für den Zeitraum zu den angegebenen Bedingungen bereitstellen kann, und erfüllt die gebuchten Aufenthalte. Er trägt alle Pflichten eines Vermieters gegenüber seinen Gästen, auch Gewährleistung, Kundenservice und Beschwerden.
(6) Er erfüllt seine gesetzlichen Pflichten rund um die Beherbergung selbst. Das betrifft insbesondere den Meldeschein nach dem Bundesmeldegesetz, die Kurtaxe und andere kommunale Abgaben, Anzeige- und Genehmigungspflichten für die Vermietung (zum Beispiel Zweckentfremdungs- oder Gewerberecht) sowie die Pflichten aus seinem Steuerrecht. Der Buchungskalender nimmt ihm davon nichts ab.
(7) Anzahlungen und Teilzahlungen bietet der Buchungskalender nicht an. Vereinbart der Vermieter außerhalb des Buchungskalenders etwas anderes mit seinem Gast, ist das nicht Gegenstand dieser Leistungsbeschreibung.
(8) Er erfüllt seine Aufbewahrungspflichten selbst, etwa nach der Abgabenordnung und dem Handelsgesetzbuch. WEBklar ist kein Archiv für die Buchführung und kein Buchführungssystem. WEBklar bewahrt die Rechnungen des Vermieters im Buchungskalender 8 Jahre auf, solange der Buchungskalender läuft und nach Nr. 10 bis zur Löschung, hält sie ihm zum Abruf als PDF bereit und anonymisiert personenbezogene Daten der Gäste nach Nummer 9 des AVV Buchungskalender. Der Vermieter lädt seine Rechnungen deshalb rechtzeitig vor der Löschung nach Nr. 10 Abs. 3 herunter und bewahrt sie selbst auf.
(9) Er hält die Vorgaben dieser Leistungsbeschreibung ein und verändert den Einbau-Code und die gesetzlich vorgeschriebenen Hinweise des Widgets und der Buchungsseite nicht.
(10) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einbau dort den Datenschutz-Absatz für den Buchungskalender ein; das gehört zur Einrichtung. Liegt sie woanders, erhält der Vermieter den Absatz als Textbaustein und fügt ihn selbst ein. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt er verantwortlich (§ 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf, weist WEBklar den Vermieter darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht.
(11) Der Vermieter informiert seine Gäste nach Art. 13 DSGVO. Die Rollen im Datenschutz regelt der AVV Buchungskalender: Der Vermieter ist Verantwortlicher, WEBklar Auftragsverarbeiter, Stripe Vertragspartner des Vermieters.
## 6. Muster-Text zur Stornierung, keine Rechtsberatung
(1) WEBklar stellt dem Vermieter einen Muster-Text für Stornobedingungen bereit. Er ist eine **Vorlage ohne Gewähr** und keine Rechtsberatung (kein Rechtsrat im Sinne des Rechtsdienstleistungsgesetzes). Er passt zur Technik des Buchungskalenders, kann aber auf den Betrieb des Vermieters nicht zugeschnitten sein.
(2) WEBklar empfiehlt, die Stornobedingungen und die Angaben zum Angebot vor der Freischaltung von einer Rechtsanwältin oder einem Rechtsanwalt prüfen zu lassen.
(3) Der Vermieter bleibt für seine Stornobedingungen und alle seine Texte verantwortlich, auch wenn er den Muster-Text unverändert übernimmt (Nr. 5 Abs. 3). Die Stornobedingungen, die bei der Freigabe galten, speichert der Buchungskalender mit der Anfrage; die Buchungsseite und die Buchungsbestätigung zeigen dem Gast diese Fassung.
## 7. Kein Widerrufsrecht des Gastes, Hinweise auf der Buchungsseite
(1) Für die Buchung von Beherbergungsleistungen zu einem bestimmten Zeitpunkt oder in einem bestimmten Zeitraum besteht nach § 312g Abs. 2 Satz 1 Nr. 9 BGB kein gesetzliches Widerrufsrecht. Die Buchungsseite weist den Gast darauf hin; der Vermieter kann ihm vertraglich ein weitergehendes Recht einräumen, der Buchungskalender bietet dafür aber keine Funktion.
(2) Die Buchungsseite zeigt dem Gast vor der Schaltfläche zur zahlungspflichtigen Buchung die wesentlichen Angaben nach Art. 246a EGBGB, soweit der Buchungskalender sie kennt (Anbieter, Unterkunft, Zeitraum, Leistung, Preis insgesamt, Zahlart, Stornobedingungen, Hinweis auf das fehlende Widerrufsrecht). Der Vermieter ergänzt, was darüber hinausgeht, in seinen Texten selbst (Nr. 5 Abs. 2).
(3) Die Buchungsbestätigung erhält der Gast als E-Mail mit der Rechnung als PDF; sie enthält die Angaben zum Vertrag und die Stornobedingungen auf einem dauerhaften Datenträger.
## 8. Verfügbarkeit und Störungen
(1) Das Verfügbarkeitsziel nach § 5 der Abo-Bedingungen gilt für die Website, nicht für den Buchungskalender. WEBklar sagt für den Buchungskalender keine bestimmte Verfügbarkeit zu, die über den Betrieb der Website im Abo hinausgeht. Störungen des Buchungskalenders beheben wir in angemessener Frist (§ 12 der Abo-Bedingungen).
(2) Angekündigte Wartungsfenster (§ 5 der Abo-Bedingungen) können den Buchungskalender kurzzeitig unterbrechen. Während einer Unterbrechung sind Anfragen und Buchungen nicht möglich; das Widget zeigt dann einen Ausweichtext.
(3) Zahlungen übernimmt der Buchungskalender über Benachrichtigungen von Stripe und einen Abgleich beim Aufruf der Buchungsseite. Fällt die Verbindung zu Stripe oder zum Server aus, kann sich die Bestätigung einer Zahlung verzögern. Eine Zahlung, die nach einer Stornierung oder nach dem Ablauf der Reservierung eingeht, und eine zweite Zahlung auf eine bereits bezahlte Buchung erstattet WEBklar automatisch und in voller Höhe (Nr. 3 Abs. 4); die Buchung bleibt dabei unverändert, und die Erstattung braucht einige Tage, bis sie beim Gast ankommt. Ausfälle von Stripe, von Zahlungsarten und von Banken liegen nicht im Einflussbereich von WEBklar.
(4) Alle Zeiten gelten in der Zeitzone Europe/Berlin.
(5) WEBklar übernimmt keine Gewähr dafür, dass E-Mails zugestellt und gelesen werden. Das hängt vom Postfach des Empfängers und von den Mailgrenzen seines Anbieters ab.
(6) Die gesetzlichen Rechte des Vermieters sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt.
## 9. Einrichtung, Abrechnung und Widerruf
(1) Die Einrichtung der Funktion rechnet WEBklar als Arbeitszeit auf das Stundenkontingent des Wartungs-Abos an (§ 4 der Abo-Bedingungen). Reicht das verfügbare Kontingent nicht aus, führt WEBklar die Einrichtung trotzdem aus. Die fehlende Zeit ist ein Vorschuss, den die folgenden Monatskontingente ausgleichen. Eine zusätzliche Rechnung gibt es dafür nicht. Endet das Wartungs-Abo, solange noch ein Vorschuss offen ist, verfällt der Rest zugunsten des Vermieters. Für den Betrieb des Buchungskalenders berechnet WEBklar nichts weiter; es gibt keine laufende Gebühr und keine Beteiligung am Umsatz oder an den Buchungen.
(2) Die Einrichtung dauert nach der Erfahrung von WEBklar meist 1 bis 2 Wochen ab dem Einschalten. Sie hängt davon ab, wie schnell der Vermieter Unterkünfte, Preise und gegebenenfalls das Stripe-Konto bereitstellt und wie umfangreich der Einbau ist. Die Dauer ist eine Einschätzung und keine verbindliche Zusage eines Termins.
(3) Hat WEBklar die Funktion auf Wunsch des Vermieters schon bei der Erstellung seiner Website eingebaut, richtet sich die Abrechnung der Einrichtung nach der Vereinbarung zum Projekt und nicht nach dem Stundenkontingent. Auch dann läuft die Funktion nur, solange das Wartungs-Abo läuft.
(4) Arbeiten, die nicht zur Einrichtung gehören, führt WEBklar nur nach Absprache vor der Ausführung und nach Freigabe des Vermieters in Textform aus; es gilt § 4 der Abo-Bedingungen. Welche Wünsche nicht zur Einrichtung gehören, beschreibt Nr. 2 Abs. 7.
(5) Das Einschalten der Funktion ist Teil des Abos und kein eigener Vertrag. Für den Widerruf gilt die Widerrufsbelehrung der Abo-Bedingungen. Widerruft ein Verbraucher das Abo oder den Wechsel ins Wartungs-Abo, endet die Funktion. Hat der Vermieter die Funktion zuvor einzeln gekauft, bleibt die Widerrufsbelehrung maßgeblich, die er bei der Bestellung erhalten hat. Das Widerrufsrecht gegenüber WEBklar ist vom Widerrufsrecht der Gäste gegenüber dem Vermieter (Nr. 7) zu unterscheiden.
## 10. Laufzeit und Ende
(1) Die Funktion läuft, solange das Wartungs-Abo läuft. Endet es, auch durch einen Wechsel ins Hosting-Abo oder durch den Widerruf eines Wechsels, schaltet WEBklar die Funktion spätestens am folgenden Tag ab. WEBklar informiert den Vermieter per E-Mail und weist ihn darauf hin, seine Rechnungen als PDF herunterzuladen.
(2) Mit dem Abschalten nimmt der Buchungskalender keine neuen Anfragen und Buchungen mehr an, und das Widget zeigt den Ausweichtext. Reservierungen laufen aus. Bereits gebuchte Aufenthalte bleiben Verträge zwischen dem Vermieter und seinen Gästen; der Vermieter erfüllt sie und informiert seine Gäste, wenn er den Buchungskalender nicht mehr anbietet. Anfragen, Buchungen und Rechnungen bleiben im Kundenportal 90 Tage lesbar; Rechnungen kann er einzeln als PDF herunterladen.
(3) WEBklar löscht alle Daten des Buchungskalenders, also Unterkünfte, Preise, Anfragen, Buchungen, Rechnungen, Kalenderverbindungen, Sperren und Einstellungen, 90 Tage nach dem Abschalten. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Angaben zur Einrichtung bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6 des AVV Buchungskalender). Die Aufbewahrungsfrist der Rechnungen (Nr. 5 Abs. 8) endet damit mit der Löschung; der Vermieter bewahrt sie danach selbst auf. Nach dem Abschalten weist WEBklar den Vermieter per E-Mail mit dem Löschdatum darauf hin, seine Rechnungen zu sichern; bis dahin sind sie im Kundenportal abrufbar. Wird das Kundenkonto gelöscht, schaltet WEBklar den Buchungskalender ab und löscht seine Daten wie nach Absatz 3 (90 Tage nach dem Abschalten); einen Hinweis per E-Mail gibt es dann nicht, weil mit dem Konto auch die E-Mail-Adresse gelöscht wird.
(4) Das Stripe-Konto bleibt beim Vermieter. Das Ende des Buchungskalenders berührt weder das Konto noch die Auszahlungen noch den Vertrag mit Stripe. Der Vermieter kann die Verbindung zwischen dem Konto und dem Buchungskalender im Stripe-Dashboard beenden.
(5) Beginnt innerhalb der 90 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Buchungskalender auf Wunsch des Vermieters in Textform mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Anfrage und eine neue Einrichtung nötig (Einrichtung nach Nr. 9).
(6) Endet das Wartungs-Abo, bevor WEBklar den Buchungskalender freigeschaltet hat, bricht WEBklar die Einrichtung ab; ein Vorschuss nach Nr. 9 Abs. 1 verfällt.
(7) Auf Wunsch des Vermieters in Textform schaltet WEBklar den Buchungskalender jederzeit ab; für den Widerruf gilt Nr. 9 Abs. 5.
## 11. Haftung
(1) Der Buchungskalender ist ein Hilfsmittel für die Vermietung des Vermieters. Der Vertrag über die Unterkunft besteht allein zwischen dem Vermieter und dem Gast; WEBklar ist daran nicht beteiligt.
(2) Für entgangenen Gewinn und Folgeschäden aus Störungen des Buchungskalenders, aus Verzögerungen oder Fehlern beim Kalenderabgleich (Nr. 4), aus Doppelbuchungen und aus nicht zugestellten E-Mails haftet WEBklar nur im Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen.
(3) WEBklar haftet nicht für Inhalte des Vermieters, insbesondere nicht für seine Unterkünfte, Preise, Steuerangaben, Stornobedingungen und Rechtstexte, auch nicht für den Muster-Text, den er übernommen hat (Nr. 6), und nicht für die Erfüllung seiner Pflichten gegenüber den Gästen und den Behörden (Nr. 5).
(4) WEBklar haftet nicht für Ausfälle, Sperren oder Entscheidungen von Stripe und der Zahlungsarten sowie für Rückbuchungen und Zahlungsausfälle (Nr. 3 Abs. 3), und nicht für Ausfälle, Änderungen und Entscheidungen anderer Plattformen, deren Kalender der Vermieter einbindet.
(5) Die Haftung nach § 13 Abs. 1 der Abo-Bedingungen (unter anderem bei Vorsatz, grober Fahrlässigkeit, Verletzung von Leben, Körper oder Gesundheit und nach dem Produkthaftungsgesetz) bleibt unberührt. Der Haftungsrahmen des Vermieters gegenüber seinen Gästen bleibt unberührt.
## 12. Änderungen und Einstellung
(1) WEBklar darf die Technik des Buchungskalenders verbessern, solange der Leistungsumfang nicht schlechter wird (§ 3 Abs. 7 der Abo-Bedingungen). Ändert sich dabei, welche Daten erhoben werden, gilt Nummer 12 des AVV Buchungskalender.
(2) WEBklar darf den Buchungskalender für eine Website mit einer Frist von vier Wochen beenden oder für alle Vermieter mit einer Frist von drei Monaten einstellen, wenn
- ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form unzulässig macht oder nur mit wesentlich anderer Technik erlaubt und der Vermieter einer dafür nötigen neuen Fassung nicht zustimmt (AVV Buchungskalender Nummer 12),
- Stripe die Anbindung nach Nr. 3 beendet oder wesentlich ändert, so dass die Online-Zahlung in der bisherigen Form nicht weiterbetrieben werden kann (dann entfällt nur die Online-Zahlung, soweit der Buchungskalender ohne sie weiterläuft), oder
- eine Sicherheitslücke sich nicht anders beheben lässt.
Die Mitteilung erfolgt in Textform und nennt den Grund, die Merkmale der Änderung, den Zeitpunkt und bei Verbrauchern das Recht, den Vertrag nach § 327r Abs. 3 BGB zu beenden, mit seiner Frist.
(3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar den Buchungskalender ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Vermieter darüber unverzüglich in Textform mit dem Grund und nimmt den Betrieb wieder auf, sobald das zulässig und technisch möglich ist.
(4) Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.
## 13. Hinweise
(1) **Keine Rechtsberatung.** Der Buchungskalender und der Muster-Text sind technische Hilfsmittel, keine Rechtsberatung (Nr. 6). Die rechtliche und steuerliche Einordnung seiner Vermietung, seiner Preisangaben und seines Angebots trägt der Vermieter.
(2) **Barrierefreiheit.** Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen der Tage, Bedienung mit Pfeiltasten, Fehlermeldungen als Live-Region). Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu. Ob der Vermieter als Anbieter von Dienstleistungen im elektronischen Geschäftsverkehr unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Vermieter.
@@ -0,0 +1,194 @@
# Leistungsbeschreibung Buchungskalender
**Fassung 2026-10-07** · Entwurf – Prüfung durch Kanzlei offen · Zusatzbedingungen zum Vertrag über den Betrieb der Website
## 1. Geltung
(1) Diese Leistungsbeschreibung regelt die Funktion „Buchungskalender“. Mit dem Buchungskalender nimmt der Kunde über seine Website Anfragen für die Vermietung von Unterkünften entgegen (Ferienwohnungen, Monteurzimmer, kleine Pensionen) und rechnet die Aufenthalte ab („Vermieter“); Gäste sind die Besucher seiner Website, die im Buchungskalender anfragen und buchen („Gäste“). Die Leistungsbeschreibung ist eine Zusatzleistung zum Vertrag über den Betrieb der Website: Sie setzt voraus, dass WEBklar die Website des Vermieters aufgrund eines Vertrags betreibt, etwa im Hosting-Abo, im Wartungs-Abo oder aufgrund eines anderen Vertrags über den Betrieb der Website. Sie richtet sich nur an Unternehmer, die in eigenem Namen und für eigene Rechnung vermieten.
(2) Sie wird vereinbart, wenn der Vermieter die Einrichtung des Buchungskalenders im Kundenportal bestellt und dabei dieser Leistungsbeschreibung, dem Auftragsverarbeitungsvertrag Buchungskalender („AVV Buchungskalender“) und der Widerrufsbelehrung zustimmt. Wie die Zustimmung abläuft, regelt die Vorbemerkung des AVV Buchungskalender. Bestellen kann der Vermieter erst, nachdem WEBklar seine Anfrage für den Buchungskalender geprüft und angenommen hat (Nr. 2 Abs. 1). Für einen Vermieter, dem WEBklar die Funktion als Teil seines Website-Projekts bereitstellt, schaltet WEBklar sie nach seiner Zustimmung in Textform frei; die Bedingungen dieser Leistungsbeschreibung gelten dann ebenso, ein Einrichtungspreis fällt nur an, wenn er vereinbart ist.
(3) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für den Buchungskalender diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Buchungskalender.
(4) Leistungsbeschreibung A Nr. 2 Abs. 3 und Nr. 4 sowie Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen (Formulare mit Server, „Rechtstexte … nicht enthalten“) stehen dem Buchungskalender nicht entgegen. Das Widget des Buchungskalenders läuft auf dem Server von WEBklar, nicht auf dem Server der Website, und der Muster-Text zur Stornierung nach Nr. 6 ist Teil des Buchungskalenders. Das gilt auch im Hosting-Abo.
## 2. Leistung
(1) Der Vermieter fragt den Buchungskalender im Kundenportal an und beschreibt seine Unterkünfte (Art der Unterkunft, Zahl der Einheiten, ob er Booking.com oder Airbnb nutzt). WEBklar prüft die Anfrage und nimmt sie an oder lehnt sie mit einer Begründung ab. Ein Anspruch auf Annahme besteht nicht. Erst nach der Annahme kann der Vermieter die Einrichtung bestellen.
(2) Die Einrichtung umfasst den Einbau eines Kalender-Widgets an einer Stelle der Website des Vermieters, die Bereitstellung der Verwaltung im Kundenportal, den Einbau des Datenschutz-Absatzes für den Buchungskalender (Nr. 5 Abs. 10) und die Freischaltung durch WEBklar. Für jede Website wird ein Buchungskalender eingerichtet; er kann mehrere Unterkünfte desselben Vermieters enthalten. Zusätzlich gibt es für jeden freigegebenen Aufenthalt eine Buchungsseite bei WEBklar, die der Gast über den Link in seiner E-Mail erreicht.
(3) So läuft eine Buchung ab:
- Das Widget zeigt dem Gast die belegten Nächte. Er wählt Anreise und Abreise, gibt Personenzahl, Namen, E-Mail-Adresse und auf Wunsch Firma, Telefonnummer und Nachricht an und sendet eine **unverbindliche Anfrage**. Er erhält eine Eingangsbestätigung, der Vermieter eine Benachrichtigung.
- Der Vermieter prüft die Anfrage im Kundenportal, sieht den vorgerechneten Preis, kann die Posten ändern und gibt die Anfrage mit einer Reservierungsfrist frei oder lehnt sie ab. Gibt er sie frei, reserviert der Buchungskalender die Nächte bis zum Ablauf der Frist, und der Gast erhält eine E-Mail mit Preis, Stornobedingungen und dem Link zur Buchungsseite.
- Auf der Buchungsseite sieht der Gast alle Angaben zum Aufenthalt, trägt seine Rechnungsadresse ein, wählt die Zahlart (online über Stripe, wenn der Vermieter sie eingeschaltet hat, oder vor Ort) und bucht mit der Schaltfläche „Zahlungspflichtig buchen“. Erst damit kommt der Vertrag zwischen Vermieter und Gast zustande, nicht schon mit der Anfrage oder der Freigabe.
- Mit der Buchung erzeugt der Buchungskalender eine Rechnung mit lückenloser Nummer und sendet dem Gast eine Buchungsbestätigung mit der Rechnung als PDF; der Vermieter wird benachrichtigt.
- Verstreicht die Frist ohne Buchung, läuft die Reservierung ab und die Nächte werden frei; der Gast erhält darüber eine E-Mail.
- Der Vermieter kann einen Aufenthalt stornieren. Dann erhält der Gast eine Stornorechnung mit eigener Nummer und, soweit der Vermieter eine Online-Zahlung erstattet, eine Mitteilung über den Erstattungsbetrag. Eine Stornierung durch den Gast gibt es im Widget nicht; er wendet sich an den Vermieter.
(4) Im Kundenportal verwaltet der Vermieter:
- Unterkünfte mit Beschreibung, Höchstzahl der Personen, Mindestnächten und Check-in- und Check-out-Zeiten,
- Preise (Staffel nach Aufenthaltsdauer, Endreinigung, Zusatzpersonen, Direktbuchungsrabatt, Steuerart und Steuersätze) und seine Stornobedingungen,
- Anfragen und Buchungen mit Status, die Eintragung „bezahlt (vor Ort)“ und Stornierungen,
- Kalender mit manuellen Sperren,
- die Verbindungen zu anderen Kalendern (Nr. 4),
- seine Rechnungen als PDF,
- seine Angaben als Aussteller der Rechnungen (Name, Anschrift, Steuernummer oder Umsatzsteuer-Identifikationsnummer, Bankverbindung, Rechnungspräfix) und die Verbindung mit seinem Stripe-Konto (Nr. 3).
(5) Der Buchungskalender versendet im Namen des Vermieters E-Mails an die Gäste: Eingang der Anfrage, Freigabe mit Buchungslink, Ablehnung, Buchungsbestätigung mit Rechnung, Zahlungseingang, Ablauf der Reservierung und Stornierung. Absender ist die Versandadresse von WEBklar mit dem Namen des Vermieters, als Antwortadresse dient seine E-Mail-Adresse. Den Vermieter benachrichtigt der Buchungskalender über neue Anfragen und neue Buchungen sowie, wenn der Abgleich mit einem anderen Kalender seit mindestens sechs Stunden fehlschlägt.
(6) Der Buchungskalender enthält einen Missbrauchsschutz gegen automatisierte Anfragen (verstecktes Feld, Mindest-Ausfüllzeit, Obergrenzen je IP-Adresse). Dafür speichert WEBklar mit der Anfrage nur einen Hashwert der IP-Adresse, nicht die IP-Adresse selbst; er wird entfernt, sobald der Vermieter die Anfrage freigibt oder ablehnt, spätestens nach 30 Tagen (Nummer 3 Abs. 4 des AVV Buchungskalender).
(7) Nicht enthalten sind:
- der Meldeschein nach dem Bundesmeldegesetz und die Verwaltung der Meldedaten,
- Kurtaxe, Gästebeitrag und Beherbergungsabgaben,
- Anzahlungen und Teilzahlungen (der Gast zahlt den Gesamtbetrag in einer Zahlung, online oder vor Ort),
- Sofortbuchung ohne Freigabe,
- saisonale Preise (die Preise richten sich nur nach der Aufenthaltsdauer),
- Gutscheine, Channel-Manager-Anbindungen außer dem Kalenderabgleich nach Nr. 4 und Mehrsprachigkeit (der Buchungskalender ist deutschsprachig),
- die Buchführung des Vermieters.
Zusätzliche Wünsche, etwa ein Sonderdesign, ein zweiter Buchungskalender oder eine über das übliche Maß hinausgehende Zahl von Unterkünften, gehören nicht zur Einrichtung nach Nr. 9. WEBklar führt sie nach Absprache nach Stunden aus (Nr. 9 Abs. 5).
## 3. Zahlungen über Stripe und Rechnungen im Namen des Vermieters
(1) Online-Zahlungen im Buchungskalender sind optional und laufen über Stripe Connect. Der Vermieter verbindet dazu sein eigenes Stripe-Konto mit dem Buchungskalender. Hat er für den Webshop von WEBklar bereits ein Stripe-Konto verbunden, kann er es auch für den Buchungskalender verwenden. Den Vertrag über dieses Konto schließt der Vermieter unmittelbar mit Stripe; WEBklar ist daran nicht beteiligt. Für das Konto, die Prüfung des Vermieters durch Stripe und die Auszahlungen gelten die Bedingungen von Stripe. Ohne verbundenes und freigeschaltetes Stripe-Konto ist nur die Zahlart „vor Ort“ möglich.
(2) Das Geld der Gäste fließt direkt an den Vermieter (Zahlung unmittelbar auf dem Konto des Vermieters, sogenannte Direct Charge). WEBklar erhält und verwahrt kein Geld der Gäste, leitet keines weiter und beteiligt sich nicht am Umsatz des Vermieters. WEBklar ist kein Zahlungsdienstleister. Die Gebühren von Stripe trägt der Vermieter; sie fallen nur zwischen dem Vermieter und Stripe an.
(3) Der Vermieter prüft selbst, ob sein Stripe-Konto Zahlungen annehmen und Auszahlungen leisten kann. WEBklar zeigt ihm im Kundenportal den Stand, den Stripe meldet, hat aber keinen Einfluss darauf, ob und wann Stripe ein Konto freischaltet, einschränkt oder sperrt, und haftet dafür nicht. Rückbuchungen, Streitfälle mit Gästen und Zahlungsausfälle regeln der Vermieter und Stripe.
(4) Erstattungen bei einer Stornierung veranlasst der Vermieter im Kundenportal; WEBklar führt die Erstattung über das Stripe-Konto des Vermieters aus. Er bestimmt den Betrag bis zur Höhe der Zahlung des Gastes. Storniert der Vermieter einen Aufenthalt, schließt WEBklar eine noch offene Zahlungsseite bei Stripe. **Automatische Erstattung:** Geht eine Zahlung ein, nachdem die Buchung storniert oder die Reservierung abgelaufen ist, oder zahlt der Gast eine bereits bezahlte Buchung ein zweites Mal, erstattet WEBklar den Betrag ohne weitere Anweisung im Namen des Vermieters in voller Höhe über dessen Stripe-Konto. Dazu erteilt der Vermieter WEBklar mit der Zustimmung zum Buchungskalender eine dauerhafte Weisung; der Stand der Buchung ändert sich dadurch nicht. Erstattungen aus dem Stripe-Dashboard trägt WEBklar in die Buchung nach, soweit Stripe sie meldet.
(5) Die Zahlungsseite selbst (Stripe Checkout) betreibt Stripe. Online-Zahlungen sind derzeit nur per Karte möglich, einschließlich Apple Pay und Google Pay; eine SEPA-Lastschrift bietet der Buchungskalender nicht an. Kartendaten gibt der Gast nur dort ein; WEBklar erhält sie nicht. Beim Start der Zahlung übergibt WEBklar Stripe als Rücksprungadresse (bei Erfolg und bei Abbruch) den geheimen Link zur Buchungsseite des Gastes samt Token. Dieser Link ist damit für Stripe und im Stripe-Dashboard des Vermieters sichtbar; Der Link gilt bis 90 Tage nach der Abreise (bei nicht zustande gekommenen Buchungen 30 Tage) und öffnet nur diese eine Buchungsseite.
(6) **Rechnungen im Namen des Vermieters.** Der Vermieter beauftragt WEBklar mit der Zustimmung bei der Bestellung, in seinem Namen und für seine Rechnung Rechnungen und Stornorechnungen an seine Gäste auszustellen (Rechnung durch einen Dritten im Namen und im Auftrag des Leistenden, § 14 Abs. 2 Satz 4 UStG). Aussteller der Rechnung ist der Vermieter; sie trägt seinen Namen, seine Anschrift, seine Steuernummer oder Umsatzsteuer-Identifikationsnummer und keinen Hinweis auf WEBklar. WEBklar erzeugt die Rechnung aus den Angaben, die der Vermieter hinterlegt und bei der Freigabe bestätigt hat. Er verantwortet die Richtigkeit seiner Angaben, der Preise, der Steuerart und der Steuersätze; WEBklar prüft sie nicht steuerrechtlich. Die Rechnung wird nach ihrer Ausstellung nicht mehr verändert; Fehler korrigiert der Vermieter durch Stornierung und neue Buchung nach den Möglichkeiten des Kundenportals. Die Zustimmung zu diesem Auftrag gibt der Vermieter bei der Bestellung und bestätigt sie in den Einstellungen des Buchungskalenders. Widerruft er den Auftrag in Textform, stellt der Buchungskalender keine Rechnungen mehr aus und ist für ihn nicht mehr nutzbar.
(7) Die Rechnungsnummern sind je Vermieter und Jahr lückenlos fortlaufend; sie bestehen aus dem Präfix, das der Vermieter vor der ersten Rechnung festlegt, dem Jahr und einer laufenden Nummer.
## 4. Verfügbarkeit und Kalenderabgleich (iCal)
(1) Der Vermieter kann Kalender anderer Plattformen (zum Beispiel Booking.com oder Airbnb) über eine iCal-Adresse einlesen lassen; die eingelesenen Zeiträume blockiert der Buchungskalender. Umgekehrt stellt der Buchungskalender dem Vermieter eine geheime iCal-Adresse bereit, die er bei anderen Plattformen einträgt; sie enthält die belegten Nächte ohne Daten der Gäste. Der Vermieter kann diese Adresse jederzeit erneuern; die bisherige Adresse funktioniert dann nicht mehr.
(2) Der Abgleich läuft regelmäßig, aber zeitversetzt: WEBklar liest die Kalender anderer Plattformen etwa alle 15 Minuten ein, und die anderen Plattformen lesen die Adresse des Buchungskalenders nach ihrem eigenen Takt, oft nur alle paar Stunden. **Zwischen einer Buchung hier und ihrem Eingang dort können deshalb Minuten bis Stunden vergehen, und umgekehrt.** WEBklar sagt keine Echtzeit-Verfügbarkeit zu und übernimmt keine Gewähr dafür, dass eine Unterkunft nicht doppelt gebucht wird.
(3) Dieses Risiko mindert die Freigabe: Jede Anfrage im Buchungskalender braucht die Freigabe des Vermieters. Er prüft vor der Freigabe seine anderen Plattformen und seinen Kalender. Beim Freigeben und beim Buchen prüft der Buchungskalender zusätzlich, ob die Nächte nach seinem Stand frei sind. Eine Doppelbuchung entsteht trotzdem, wenn eine andere Plattform eine Buchung noch nicht gemeldet hat oder der Abgleich ausfällt. Der Vermieter löst eine Doppelbuchung selbst (etwa durch Stornierung) und trägt die Folgen gegenüber seinen Gästen und den Plattformen.
(4) Fehlerhafte oder nicht erreichbare iCal-Adressen kann WEBklar nicht beheben. Schlägt der Abruf fehl, bleibt der zuletzt gelesene Stand erhalten, und der Vermieter wird benachrichtigt, wenn der Abruf seit mindestens sechs Stunden nicht gelungen ist (höchstens einmal je 24 Stunden und Kalender). Eingelesen werden nur Zeiträume von gestern bis 18 Monate in die Zukunft, höchstens 1 Megabyte je Kalender; nur Adressen mit https werden abgerufen.
## 5. Pflichten des Vermieters
(1) Der Vermieter hat einen laufenden Vertrag mit WEBklar über den Betrieb seiner Website (Hosting-Abo, Wartungs-Abo oder ein anderer Vertrag über den Betrieb der Website), ist Unternehmer und vermietet in eigenem Namen. Will er Online-Zahlungen nutzen, stellt er sein eigenes Stripe-Konto bereit und schließt die Anmeldung bei Stripe selbst ab.
(2) Er verantwortet seine Unterkünfte, Preise und Angaben zum Angebot. Dazu gehören richtige Preise einschließlich Steuerausweis (Umsatzsteuer, für die Beherbergung und die Endreinigung nach den dafür geltenden Steuersätzen, oder Hinweis auf § 19 UStG), Check-in- und Check-out-Zeiten, Höchstbelegung, Mindestnächte und alle Angaben, die er seinen Gästen vor der Buchung geben muss. Die Steuerart und die Steuersätze trägt er selbst im Kundenportal ein; WEBklar berät dazu nicht.
(3) Er legt vor der Freischaltung seine Stornobedingungen fest und verantwortet sie. Der Muster-Text nach Nr. 6 ist nur ein Vorschlag.
(4) Er verantwortet die Verfügbarkeit seiner Unterkünfte, pflegt Sperren und Kalenderverbindungen im Kundenportal und prüft regelmäßig auf neue Anfragen und Buchungen. Die Folgen von Angaben, die er setzt (zum Beispiel einer fälschlich freien oder gesperrten Zeit), trägt er.
(5) Er gibt Anfragen nur frei, wenn er die Unterkunft für den Zeitraum zu den angegebenen Bedingungen bereitstellen kann, und erfüllt die gebuchten Aufenthalte. Er trägt alle Pflichten eines Vermieters gegenüber seinen Gästen, auch Gewährleistung, Kundenservice und Beschwerden.
(6) Er erfüllt seine gesetzlichen Pflichten rund um die Beherbergung selbst. Das betrifft insbesondere den Meldeschein nach dem Bundesmeldegesetz, die Kurtaxe und andere kommunale Abgaben, Anzeige- und Genehmigungspflichten für die Vermietung (zum Beispiel Zweckentfremdungs- oder Gewerberecht) sowie die Pflichten aus seinem Steuerrecht. Der Buchungskalender nimmt ihm davon nichts ab.
(7) Anzahlungen und Teilzahlungen bietet der Buchungskalender nicht an. Vereinbart der Vermieter außerhalb des Buchungskalenders etwas anderes mit seinem Gast, ist das nicht Gegenstand dieser Leistungsbeschreibung.
(8) Er erfüllt seine Aufbewahrungspflichten selbst, etwa nach der Abgabenordnung und dem Handelsgesetzbuch. WEBklar ist kein Archiv für die Buchführung und kein Buchführungssystem. WEBklar bewahrt die Rechnungen des Vermieters im Buchungskalender 8 Jahre auf, solange der Buchungskalender läuft und nach Nr. 10 bis zur Löschung, hält sie ihm zum Abruf als PDF bereit und anonymisiert personenbezogene Daten der Gäste nach Nummer 9 des AVV Buchungskalender. Der Vermieter lädt seine Rechnungen deshalb rechtzeitig vor der Löschung nach Nr. 10 Abs. 3 herunter und bewahrt sie selbst auf.
(9) Er hält die Vorgaben dieser Leistungsbeschreibung ein und verändert den Einbau-Code und die gesetzlich vorgeschriebenen Hinweise des Widgets und der Buchungsseite nicht.
(10) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einbau dort den Datenschutz-Absatz für den Buchungskalender ein; das gehört zur Einrichtung. Liegt sie woanders, erhält der Vermieter den Absatz als Textbaustein und fügt ihn selbst ein. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt er verantwortlich (§ 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf, weist WEBklar den Vermieter darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht.
(11) Der Vermieter informiert seine Gäste nach Art. 13 DSGVO. Die Rollen im Datenschutz regelt der AVV Buchungskalender: Der Vermieter ist Verantwortlicher, WEBklar Auftragsverarbeiter, Stripe Vertragspartner des Vermieters.
## 6. Muster-Text zur Stornierung, keine Rechtsberatung
(1) WEBklar stellt dem Vermieter einen Muster-Text für Stornobedingungen bereit. Er ist eine **Vorlage ohne Gewähr** und keine Rechtsberatung (kein Rechtsrat im Sinne des Rechtsdienstleistungsgesetzes). Er passt zur Technik des Buchungskalenders, kann aber auf den Betrieb des Vermieters nicht zugeschnitten sein.
(2) WEBklar empfiehlt, die Stornobedingungen und die Angaben zum Angebot vor der Freischaltung von einer Rechtsanwältin oder einem Rechtsanwalt prüfen zu lassen.
(3) Der Vermieter bleibt für seine Stornobedingungen und alle seine Texte verantwortlich, auch wenn er den Muster-Text unverändert übernimmt (Nr. 5 Abs. 3). Die Stornobedingungen, die bei der Freigabe galten, speichert der Buchungskalender mit der Anfrage; die Buchungsseite und die Buchungsbestätigung zeigen dem Gast diese Fassung.
## 7. Kein Widerrufsrecht des Gastes, Hinweise auf der Buchungsseite
(1) Für die Buchung von Beherbergungsleistungen zu einem bestimmten Zeitpunkt oder in einem bestimmten Zeitraum besteht nach § 312g Abs. 2 Satz 1 Nr. 9 BGB kein gesetzliches Widerrufsrecht. Die Buchungsseite weist den Gast darauf hin; der Vermieter kann ihm vertraglich ein weitergehendes Recht einräumen, der Buchungskalender bietet dafür aber keine Funktion.
(2) Die Buchungsseite zeigt dem Gast vor der Schaltfläche „Zahlungspflichtig buchen“ die wesentlichen Angaben nach Art. 246a EGBGB, soweit der Buchungskalender sie kennt (Anbieter, Unterkunft, Zeitraum, Leistung, Gesamtpreis, Zahlart, Stornobedingungen, Hinweis auf das fehlende Widerrufsrecht). Der Vermieter ergänzt, was darüber hinausgeht, in seinen Texten selbst (Nr. 5 Abs. 2).
(3) Die Buchungsbestätigung erhält der Gast als E-Mail mit der Rechnung als PDF; sie enthält die Angaben zum Vertrag und die Stornobedingungen auf einem dauerhaften Datenträger.
## 8. Verfügbarkeit und Störungen
(1) Das Verfügbarkeitsziel nach § 5 der Abo-Bedingungen gilt für die Website, nicht für den Buchungskalender. WEBklar sagt für den Buchungskalender keine bestimmte Verfügbarkeit zu, die über den Betrieb der Website im Abo hinausgeht. Störungen des Buchungskalenders beheben wir in angemessener Frist (§ 12 der Abo-Bedingungen).
(2) Angekündigte Wartungsfenster (§ 5 der Abo-Bedingungen) können den Buchungskalender kurzzeitig unterbrechen. Während einer Unterbrechung sind Anfragen und Buchungen nicht möglich; das Widget zeigt dann einen Ausweichtext.
(3) Zahlungen übernimmt der Buchungskalender über Benachrichtigungen von Stripe und einen Abgleich beim Aufruf der Buchungsseite. Fällt die Verbindung zu Stripe oder zum Server aus, kann sich die Bestätigung einer Zahlung verzögern. Eine Zahlung, die nach einer Stornierung oder nach dem Ablauf der Reservierung eingeht, und eine zweite Zahlung auf eine bereits bezahlte Buchung erstattet WEBklar automatisch und in voller Höhe (Nr. 3 Abs. 4); die Buchung bleibt dabei unverändert, und die Erstattung braucht einige Tage, bis sie beim Gast ankommt. Ausfälle von Stripe, von Zahlungsarten und von Banken liegen nicht im Einflussbereich von WEBklar.
(4) Alle Zeiten gelten in der Zeitzone Europe/Berlin.
(5) WEBklar übernimmt keine Gewähr dafür, dass E-Mails zugestellt und gelesen werden. Das hängt vom Postfach des Empfängers und von den Mailgrenzen seines Anbieters ab.
(6) Die gesetzlichen Rechte des Vermieters sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt.
## 9. Preis, Abrechnung und Widerruf
(1) Die Einrichtung kostet 199 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG. Für den Betrieb des Buchungskalenders berechnet WEBklar nichts weiter; es gibt keine laufende Gebühr und keine Beteiligung am Umsatz oder an den Buchungen. Der Buchungskalender läuft, solange WEBklar die Website betreibt (Nr. 10).
(2) Die Einrichtung dauert nach der Erfahrung von WEBklar meist 1 bis 2 Wochen ab der Bestellung. Sie hängt davon ab, wie schnell der Vermieter Unterkünfte, Preise und gegebenenfalls das Stripe-Konto bereitstellt und wie umfangreich der Einbau ist. Die Dauer ist eine Einschätzung und keine verbindliche Zusage eines Termins.
(3) Der Vermieter bezahlt bei der Bestellung im Kundenportal über Stripe und erhält eine Rechnung. Für einen Vermieter, dem WEBklar die Funktion als Teil seines Website-Projekts bereitstellt (Nr. 1 Abs. 2), gilt die dafür getroffene Vereinbarung.
(4) Der Preis steht neben den Leistungen eines Abos: Die Einrichtung wird weder auf ein Stundenkontingent angerechnet noch damit bezahlt.
(5) Mehraufwand, der nicht zur Einrichtung gehört (Nr. 2 Abs. 7), führt WEBklar nur nach Absprache vor der Ausführung und nach Freigabe des Vermieters in Textform aus. WEBklar nennt vorher den geschätzten Aufwand und berechnet 100 € je Stunde in Schritten von 15 Minuten, ohne Umsatzsteuer gemäß § 19 UStG (§ 4 der Abo-Bedingungen). Der Mehraufwand wird gesondert berechnet und nicht auf ein Stundenkontingent angerechnet.
(6) Verbraucher können die Bestellung der Einrichtung nach der Widerrufsbelehrung widerrufen. Belehrung und Muster-Widerrufsformular stehen in der Anlage „Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)“. Ob das Widerrufsrecht erlischt und ob der Vermieter einen angemessenen Betrag für die bis dahin erbrachte Leistung schuldet, richtet sich nach der Zustimmung, die er bei der Bestellung gibt, und nach den Hinweisen zum Widerruf in der Anlage. Mit dem Widerruf endet der Buchungskalender. Dieses Widerrufsrecht des Vermieters gegenüber WEBklar ist vom Widerrufsrecht der Gäste gegenüber dem Vermieter (Nr. 7) zu unterscheiden.
## 10. Laufzeit und Ende
(1) Der Buchungskalender läuft, solange WEBklar die Website des Vermieters betreibt. Endet der Vertrag über den Betrieb der Website, endet auch der Buchungskalender; WEBklar schaltet ihn zum Vertragsende ab und informiert den Vermieter vorher per E-Mail und weist ihn darauf hin, seine Rechnungen als PDF herunterzuladen. Das gilt auch beim Widerruf dieses Vertrags.
(2) Mit dem Abschalten nimmt der Buchungskalender keine neuen Anfragen und Buchungen mehr an, und das Widget zeigt den Ausweichtext. Reservierungen laufen aus. Bereits gebuchte Aufenthalte bleiben Verträge zwischen dem Vermieter und seinen Gästen; der Vermieter erfüllt sie und informiert seine Gäste, wenn er den Buchungskalender nicht mehr anbietet. Anfragen, Buchungen und Rechnungen bleiben im Kundenportal 90 Tage lesbar; Rechnungen kann er einzeln als PDF herunterladen.
(3) WEBklar löscht alle Daten des Buchungskalenders, also Unterkünfte, Preise, Anfragen, Buchungen, Rechnungen, Kalenderverbindungen, Sperren und Einstellungen, 90 Tage nach dem Abschalten. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Angaben zur Einrichtung bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6 des AVV Buchungskalender). Die Aufbewahrungsfrist der Rechnungen (Nr. 5 Abs. 8) endet damit mit der Löschung; der Vermieter bewahrt sie danach selbst auf. Nach dem Abschalten weist WEBklar den Vermieter per E-Mail mit dem Löschdatum darauf hin, seine Rechnungen zu sichern; bis dahin sind sie im Kundenportal abrufbar. Wird das Kundenkonto gelöscht, schaltet WEBklar den Buchungskalender ab und löscht seine Daten wie nach Absatz 3 (90 Tage nach dem Abschalten); einen Hinweis per E-Mail gibt es dann nicht, weil mit dem Konto auch die E-Mail-Adresse gelöscht wird.
(4) Das Stripe-Konto bleibt beim Vermieter. Das Ende des Buchungskalenders berührt weder das Konto noch die Auszahlungen noch den Vertrag mit Stripe. Der Vermieter kann die Verbindung zwischen dem Konto und dem Buchungskalender im Stripe-Dashboard beenden.
(5) Beginnt innerhalb der 90 Tage wieder ein Vertrag über den Betrieb der Website, schaltet WEBklar den Buchungskalender auf Wunsch des Vermieters in Textform mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Anfrage und eine neue Einrichtung nötig (neuer Preis nach Nr. 9).
(6) Endet der Vertrag über den Betrieb der Website, bevor WEBklar den Buchungskalender freigeschaltet hat, erstattet WEBklar den gezahlten Einrichtungspreis vollständig.
(7) Auf Wunsch des Vermieters in Textform schaltet WEBklar den Buchungskalender jederzeit ab. Eine Erstattung des Einrichtungspreises folgt daraus nicht; für den Widerruf gilt Nr. 9 Abs. 6.
## 11. Haftung
(1) Der Buchungskalender ist ein Hilfsmittel für die Vermietung des Vermieters. Der Vertrag über die Unterkunft besteht allein zwischen dem Vermieter und dem Gast; WEBklar ist daran nicht beteiligt.
(2) Für entgangenen Gewinn und Folgeschäden aus Störungen des Buchungskalenders, aus Verzögerungen oder Fehlern beim Kalenderabgleich (Nr. 4), aus Doppelbuchungen und aus nicht zugestellten E-Mails haftet WEBklar nur im Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen.
(3) WEBklar haftet nicht für Inhalte des Vermieters, insbesondere nicht für seine Unterkünfte, Preise, Steuerangaben, Stornobedingungen und Rechtstexte, auch nicht für den Muster-Text, den er übernommen hat (Nr. 6), und nicht für die Erfüllung seiner Pflichten gegenüber den Gästen und den Behörden (Nr. 5).
(4) WEBklar haftet nicht für Ausfälle, Sperren oder Entscheidungen von Stripe und der Zahlungsarten sowie für Rückbuchungen und Zahlungsausfälle (Nr. 3 Abs. 3), und nicht für Ausfälle, Änderungen und Entscheidungen anderer Plattformen, deren Kalender der Vermieter einbindet.
(5) Die Haftung nach § 13 Abs. 1 der Abo-Bedingungen (unter anderem bei Vorsatz, grober Fahrlässigkeit, Verletzung von Leben, Körper oder Gesundheit und nach dem Produkthaftungsgesetz) bleibt unberührt. Der Haftungsrahmen des Vermieters gegenüber seinen Gästen bleibt unberührt.
## 12. Änderungen und Einstellung
(1) WEBklar darf die Technik des Buchungskalenders verbessern, solange der Leistungsumfang nicht schlechter wird (§ 3 Abs. 7 der Abo-Bedingungen). Ändert sich dabei, welche Daten erhoben werden, gilt Nummer 12 des AVV Buchungskalender.
(2) WEBklar darf den Buchungskalender für eine Website mit einer Frist von vier Wochen beenden oder für alle Vermieter mit einer Frist von drei Monaten einstellen, wenn
- ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form unzulässig macht oder nur mit wesentlich anderer Technik erlaubt und der Vermieter einer dafür nötigen neuen Fassung nicht zustimmt (AVV Buchungskalender Nummer 12),
- Stripe die Anbindung nach Nr. 3 beendet oder wesentlich ändert, so dass die Online-Zahlung in der bisherigen Form nicht weiterbetrieben werden kann (dann entfällt nur die Online-Zahlung, soweit der Buchungskalender ohne sie weiterläuft), oder
- eine Sicherheitslücke sich nicht anders beheben lässt.
Die Mitteilung erfolgt in Textform und nennt den Grund, die Merkmale der Änderung, den Zeitpunkt und bei Verbrauchern das Recht, den Vertrag nach § 327r Abs. 3 BGB zu beenden, mit seiner Frist.
(3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar den Buchungskalender ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Vermieter darüber unverzüglich in Textform mit dem Grund und nimmt den Betrieb wieder auf, sobald das zulässig und technisch möglich ist.
(4) Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.
## 13. Hinweise
(1) **Keine Rechtsberatung.** Der Buchungskalender und der Muster-Text sind technische Hilfsmittel, keine Rechtsberatung (Nr. 6). Die rechtliche und steuerliche Einordnung seiner Vermietung, seiner Preisangaben und seines Angebots trägt der Vermieter.
(2) **Barrierefreiheit.** Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen der Tage, Bedienung mit Pfeiltasten, Fehlermeldungen als Live-Region). Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu. Ob der Vermieter als Anbieter von Dienstleistungen im elektronischen Geschäftsverkehr unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Vermieter.
@@ -0,0 +1,179 @@
# Leistungsbeschreibung Webshop
**Fassung 2026-10-16** · Entwurf – Prüfung durch Kanzlei offen · Zusatzbedingungen zum Wartungs-Abo
## 1. Geltung
(1) Diese Leistungsbeschreibung regelt die Funktion „Webshop“. Mit dem Webshop verkauft der Kunde Waren über seine Website („Händler“); Käufer sind die Besucher seiner Website, die im Webshop bestellen („Käufer“). Die Funktion ist Teil des Wartungs-Abos. Sie setzt ein laufendes Wartungs-Abo voraus; ein Hosting-Abo genügt nicht. Sie richtet sich nur an Unternehmer, die in eigenem Namen und für eigene Rechnung verkaufen.
(2) Diese Leistungsbeschreibung gilt, wenn der Händler die Funktion im Kundenportal einschaltet und die dort angezeigte Zeile mit dem Klick auf „Einschalten“ bestätigt. Sie gilt für Funktionen, die ab ihrer Veröffentlichung eingeschaltet werden; hat der Händler die Funktion zuvor einzeln gekauft, gilt für sie weiter die Leistungsbeschreibung, unter der er sie gekauft hat. Wie die Zustimmung abläuft, regelt die Vorbemerkung des AVV Webshop. Einschalten kann der Händler erst, nachdem WEBklar seine Anfrage für den Webshop geprüft und freigegeben hat (Nr. 2 Abs. 1).
(3) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für den Webshop diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Webshop.
(4) Leistungsbeschreibung A Nr. 2 Abs. 3 und Nr. 4 sowie Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen (Formulare mit Server, „Rechtstexte … nicht enthalten“) stehen dem Webshop nicht entgegen. Das Widget des Webshops läuft auf dem Server von WEBklar, nicht auf dem Server der Website, und die Muster-Rechtstexte nach Nr. 6 sind Teil des Webshops.
## 2. Leistung
(1) Der Händler fragt den Webshop im Kundenportal an und beschreibt, was er verkauft. WEBklar prüft die Anfrage, vor allem nach Nr. 3, und gibt sie frei oder lehnt sie mit einer Begründung ab. Ein Anspruch auf Freigabe besteht nicht. Erst nach der Freigabe kann der Händler den Webshop einschalten.
(2) Die Einrichtung umfasst den Einbau eines Shop-Widgets an einer Stelle der Website des Händlers, die Seite des Webshops mit einem Verweis im Menü und den Link „Vertrag widerrufen“ in der Fußzeile der Seiten der Website, außerdem die Freischaltung durch WEBklar. Für jede Website wird ein Webshop eingerichtet. Zusätzlich steht dem Händler eine eigenständige Seite des Webshops bei WEBklar zur Verfügung (Standalone-Seite), etwa als Vorschau.
(3) Im Kundenportal verwaltet der Händler:
- Artikel mit Beschreibung, Bildern, Preis, Varianten, Bestand und den Pflichtangaben zum Artikel,
- Versand (nur nach Deutschland und Österreich) und Abholung mit Versandkosten und Lieferzeiten,
- Bestellungen mit Status (zum Beispiel bezahlt, versendet, abholbereit, abgeschlossen), die er als CSV-Datei exportieren kann,
- Erstattungen, ganz oder teilweise, über sein Stripe-Konto (Nr. 4),
- die Widerrufe, die Käufer über die Widerrufsfunktion erklärt haben,
- die Verbindung mit seinem Stripe-Konto, seine Einstellungen und seine Rechtstexte, deren bestätigte Fassungen WEBklar archiviert (Nr. 6 Abs. 5).
(4) Der Webshop enthält die Widerrufsfunktion nach § 356a BGB: Das Widget zeigt in seiner Fußzeile und die Standalone-Seite zeigt unter einer eigenen Adresse die Schaltfläche „Vertrag widerrufen“. Sie führt zu einem Formular und danach zur Bestätigungsschaltfläche „Widerruf bestätigen“. WEBklar speichert den Eingang, sendet dem Erklärenden sofort eine Eingangsbestätigung mit Inhalt, Datum und Uhrzeit und benachrichtigt den Händler. Die Funktion ist ohne Anmeldung erreichbar, auch wenn zur Erklärung keine Bestellung gefunden wird. Der Händler bearbeitet jeden Widerruf selbst (Nr. 5 Abs. 6).
(5) Der Webshop versendet im Namen des Händlers E-Mails an die Käufer: Eingang der Bestellung (bei einer Zahlung, die noch aussteht, etwa einer SEPA-Lastschrift, vor dem Vertragsschluss), Bestellbestätigung, Versand, Abholbereitschaft, Eingangsbestätigung eines Widerrufs, Erstattung und Hinweis auf eine fehlgeschlagene Zahlung. Absender ist die Versandadresse von WEBklar mit dem Namen des Shops, als Antwortadresse dient die E-Mail-Adresse des Händlers. Den Händler benachrichtigt der Webshop über neue Bestellungen und eingegangene Widerrufe.
(6) Der Webshop enthält einen Missbrauchsschutz gegen automatisierte Bestellungen und Widerrufe, darunter eine Obergrenze von drei offenen Bestellungen je IP-Adresse in 30 Minuten. Dafür speichert WEBklar mit der offenen Bestellung nur einen Hashwert der IP-Adresse, nicht die IP-Adresse selbst; er wird gelöscht, sobald die Bestellung bezahlt oder storniert wird (Nummer 3 Abs. 4 des AVV Webshop). Eine Telefonnummer des Käufers wird nicht erhoben und nicht gespeichert. Die Löschfristen für Bestelldaten nennt Nummer 9 des AVV Webshop.
(7) Nicht enthalten sind der Verkauf an Verbraucher außerhalb von Deutschland und Österreich, Rechnungen (Nr. 5 Abs. 7), die Verwaltung mehrerer Lager, Rabattcodes, Marktplatzfunktionen, die Anbindung an Warenwirtschaft oder Versanddienstleister, Mehrsprachigkeit und die Zahlung per PayPal (Nr. 4 Abs. 5). Zusätzliche Wünsche, etwa ein Sonderdesign, ein zweiter Webshop oder eine größere Zahl von Artikeln als vorgesehen, gehören nicht zur Einrichtung nach Nr. 9. WEBklar führt sie nach Absprache nach Stunden aus (Nr. 9 Abs. 4).
## 3. Ausgeschlossene Waren und Leistungen
(1) Über den Webshop dürfen nicht verkauft werden:
- alkoholische Getränke,
- Tabakwaren, E-Zigaretten und Zubehör,
- Arzneimittel und Medizinprodukte,
- Waffen, Munition, Feuerwerk und Sprengstoffe,
- Waren mit erotischem oder pornografischem Inhalt,
- lebende Tiere,
- digitale Inhalte und Downloads,
- Gutscheine, Geschenkkarten und Guthaben,
- Abonnements und andere wiederkehrende Zahlungen,
- Waren und Leistungen, die nach der Liste der verbotenen und eingeschränkten Geschäfte von Stripe nicht erlaubt sind, und
- Waren, deren Verkauf oder Versand nach deutschem, österreichischem oder europäischem Recht verboten ist.
(2) Der Händler liefert nur nach Deutschland und Österreich. Eine Lieferung in andere Länder bietet der Webshop nicht an.
(3) Der Händler bestätigt bei der Anfrage und beim Einschalten, dass er keine dieser Waren und Leistungen verkauft. WEBklar darf eine Anfrage ablehnen, wenn der Händler Waren nach Absatz 1 verkaufen will oder die Angaben dazu nicht ausreichen. Verkauft der Händler später Waren nach Absatz 1, darf WEBklar den Webshop nach einem Hinweis in Textform abschalten; bei einem Verstoß, der Rechte Dritter oder Käufer gefährdet, darf WEBklar ihn auch ohne vorherigen Hinweis abschalten und informiert den Händler unverzüglich. Für das Abschalten gilt Nr. 10 Abs. 2 und 3. Die bereits auf das Stundenkontingent angerechnete Einrichtungszeit wird daraus nicht gutgeschrieben, wenn der Händler den Verstoß zu vertreten hat.
## 4. Zahlungen über Stripe
(1) Zahlungen im Webshop laufen über Stripe Connect. Der Händler verbindet dazu sein eigenes Stripe-Konto mit dem Webshop. Den Vertrag über dieses Konto schließt der Händler unmittelbar mit Stripe; WEBklar ist daran nicht beteiligt. Für das Konto, die Prüfung des Händlers durch Stripe (zum Beispiel zur Identität) und die Auszahlungen gelten die Bedingungen von Stripe.
(2) Das Geld der Käufer fließt direkt an den Händler. WEBklar erhält und verwahrt kein Geld der Käufer, leitet keines weiter und beteiligt sich nicht am Umsatz des Händlers. WEBklar ist kein Zahlungsdienstleister. Die Gebühren von Stripe trägt der Händler; sie fallen nur zwischen dem Händler und Stripe an.
(3) Der Händler prüft selbst, ob sein Stripe-Konto Zahlungen annehmen und Auszahlungen leisten kann. WEBklar zeigt dem Händler im Kundenportal den Stand, den Stripe meldet, hat aber keinen Einfluss darauf, ob und wann Stripe ein Konto freischaltet, einschränkt oder sperrt, und haftet dafür nicht. Rückbuchungen, Streitfälle mit Käufern und Zahlungsausfälle regeln der Händler und Stripe.
(4) Der Händler erstattet Zahlungen im Kundenportal oder im Stripe-Dashboard. WEBklar trägt Erstattungen aus dem Stripe-Dashboard in die Bestellung nach. Eine Erstattung führt der Händler rechtzeitig aus (Nr. 5 Abs. 6).
(5) Der Webshop bietet die Zahlungsarten an, die Stripe im Checkout für das Konto des Händlers bereitstellt und der Händler in seinen Einstellungen wählt, zum Beispiel Karte und SEPA-Lastschrift. PayPal ist über Stripe für diese Art der Anbindung nicht verfügbar und deshalb nicht Teil des Webshops.
(6) Die Zahlungsseite selbst (Stripe Checkout) betreibt Stripe. Kartendaten und Kontodaten gibt der Käufer nur dort ein; WEBklar erhält sie nicht.
## 5. Pflichten des Händlers
(1) Der Händler hat ein laufendes Wartungs-Abo, ist Unternehmer und stellt sein eigenes Stripe-Konto bereit; die Anmeldung bei Stripe schließt er selbst ab. Der Webshop steht nur Kunden zur Verfügung, deren Kundenart „Betrieb“ ist. Der Händler verkauft in eigenem Namen. Für seine Produkte, Preise, Produktangaben und Rechtstexte (Allgemeine Geschäftsbedingungen, Widerrufsbelehrung, Impressum, Datenschutzerklärung) ist er selbst verantwortlich; WEBklar stellt Muster bereit und berät nicht rechtlich. Er verkauft keine der in Nr. 3 ausgeschlossenen Waren. Die Zahlungen seiner Käufer laufen über sein eigenes Stripe-Konto zu den Bedingungen von Stripe; WEBklar erhält und verwahrt kein Geld seiner Käufer und erhebt keine Umsatzbeteiligung.
(2) Er verantwortet seine Rechtstexte: Impressum, Datenschutzerklärung, Allgemeine Geschäftsbedingungen, Widerrufsbelehrung, Muster-Widerrufsformular sowie die Angaben zu Versand und Zahlung. Die Mustertexte nach Nr. 6 prüft er, passt sie an seinen Betrieb an und bestätigt sie im Kundenportal, bevor er den Webshop öffnet. Er trägt vor dem Einschalten die Adressen von Impressum und Datenschutzerklärung seiner Website ein.
(3) Er verantwortet seine Artikel, Preise und Produktangaben. Dazu gehören richtige Preise einschließlich Steuerausweis (Umsatzsteuer oder Hinweis auf § 19 UStG), Grundpreise, Versandkosten und Lieferzeiten, die Pflichtangaben für Lebensmittel (zum Beispiel Zutaten, Allergene, Füllmenge, verantwortlicher Lebensmittelunternehmer), die Angaben zur Produktsicherheit (zum Beispiel Hersteller, Warn- und Sicherheitshinweise), Widerrufsausschlüsse bei einzelnen Artikeln und die Rechte an seinen Texten und Bildern.
(4) Er liefert die Waren, wie bestellt und beschrieben, und trägt alle Pflichten eines Verkäufers gegenüber seinen Käufern, auch Gewährleistung, Kundenservice und Beschwerden.
(5) Er pflegt Bestand, Bestellstatus und Versandstatus im Kundenportal. Die Folgen falscher Angaben oder Statusmeldungen, die der Händler setzt, trägt er.
(6) Er bearbeitet Widerrufe und Erstattungen selbst, fristgerecht und nach dem Gesetz. Er entfernt den Link „Vertrag widerrufen“ nicht und verändert die Widerrufsfunktion nicht. Er prüft das Kundenportal regelmäßig auf neue Bestellungen und Widerrufe und lässt sich über die Benachrichtigungen informieren.
(7) Rechnungen und Belege an die Käufer erstellt der Händler selbst; WEBklar stellt sie nicht aus. Die Quittung von Stripe und die Bestellbestätigung des Webshops ersetzen keine Rechnung nach dem Umsatzsteuergesetz, soweit der Händler eine ausstellen muss.
(8) Er erfüllt seine Aufbewahrungspflichten selbst, etwa nach der Abgabenordnung und dem Handelsgesetzbuch. WEBklar ist kein Archiv für die Buchführung und kein Buchführungssystem (das Archiv der Rechtstexte nach Nr. 6 Abs. 5 dient nur dem Nachweis der Texte): Personenbezogene Käuferdaten werden nach Nummer 9 des AVV Webshop anonymisiert und nach dem Ende des Webshops gelöscht. Der Händler exportiert seine Bestellungen deshalb rechtzeitig als CSV-Datei und bewahrt sie mit den Belegen von Stripe auf.
(9) Er hält die Vorgaben nach Nr. 3 ein und verändert den Einbau-Code und die gesetzlich vorgeschriebenen Hinweise des Widgets nicht.
(10) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einbau dort den Datenschutz-Absatz für den Webshop ein; das gehört zur Einrichtung. Liegt sie woanders, erhält der Händler den Absatz als Textbaustein und fügt ihn selbst ein. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt er verantwortlich (§ 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf, weist WEBklar den Händler darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht.
(11) Der Händler informiert seine Käufer nach Art. 13 DSGVO. Die Rollen im Datenschutz regelt der AVV Webshop: Der Händler ist Verantwortlicher, WEBklar Auftragsverarbeiter, Stripe Vertragspartner des Händlers.
## 6. Muster-Rechtstexte, keine Rechtsberatung
(1) WEBklar stellt dem Händler Mustertexte bereit: eine Muster-Widerrufsbelehrung, ein Muster-Widerrufsformular, Allgemeine Geschäftsbedingungen und den Text zu Versand und Zahlung. Das Widget zeigt außerdem die Hinweise, die für die Kasse und die Bestellbestätigung vorgesehen sind, darunter den Hinweis auf die gesetzliche Gewährleistung.
(2) Die Mustertexte sind **Muster ohne Gewähr**. Sie passen zur Standardkonfiguration des Webshops und können auf den Betrieb des Händlers nicht zugeschnitten sein. WEBklar sorgt dafür, dass sie die Technik des Webshops richtig beschreiben, und passt sie an, wenn sich die Technik ändert. Eine rechtliche Beratung im Einzelfall schuldet WEBklar nicht (kein Rechtsrat im Sinne des Rechtsdienstleistungsgesetzes).
(3) WEBklar empfiehlt, die Rechtstexte vor der Öffnung des Webshops durch eine Rechtsanwältin oder einen Rechtsanwalt prüfen zu lassen, besonders bei besonderen Waren (zum Beispiel Lebensmitteln, Kosmetik, Textilien mit Pflegekennzeichnung, Elektrogeräten).
(4) Der Händler bleibt für seine Rechtstexte verantwortlich, auch wenn er Mustertexte unverändert übernimmt (Nr. 5 Abs. 2).
(5) Bestätigt der Händler seine Rechtstexte im Kundenportal, legt WEBklar jede bestätigte Fassung mit Prüfsumme, Zeitpunkt und der bestätigenden Person im Archiv ab. Die Bestellbestätigung zeigt dem Käufer die archivierte Fassung, die zum Zeitpunkt seiner Bestellung galt. WEBklar löscht das Archiv mit den übrigen Daten des Webshops (Nr. 10 Abs. 3; Nummer 8 Abs. 5 des AVV Webshop). Der Händler bewahrt die Fassungen, die er für eigene Aufbewahrungspflichten braucht, selbst auf (Nr. 5 Abs. 8).
## 7. Verfügbarkeit und Störungen
(1) Das Verfügbarkeitsziel nach § 5 der Abo-Bedingungen gilt für die Website, nicht für den Webshop. WEBklar sagt für den Webshop keine bestimmte Verfügbarkeit zu, die über den Betrieb der Website im Abo hinausgeht. Störungen des Webshops beheben wir in angemessener Frist (§ 12 der Abo-Bedingungen).
(2) Angekündigte Wartungsfenster (§ 5 der Abo-Bedingungen) können den Webshop kurzzeitig unterbrechen. Während einer Unterbrechung sind Bestellungen im Widget nicht möglich; das Widget zeigt dann einen Ausweichtext.
(3) Zahlungen übernimmt der Webshop über Benachrichtigungen von Stripe und einen regelmäßigen Abgleich in die Bestellung. Fällt die Verbindung zu Stripe oder zum Server aus, kann sich die Bestätigung einer Bestellung verzögern. Ausfälle von Stripe, von Zahlungsarten und von Banken liegen nicht im Einflussbereich von WEBklar.
(4) Alle Zeiten gelten in der Zeitzone Europe/Berlin.
(5) WEBklar übernimmt keine Gewähr dafür, dass E-Mails zugestellt und gelesen werden. Das hängt vom Postfach des Empfängers und von den Mailgrenzen seines Anbieters ab.
(6) Die gesetzlichen Rechte des Händlers sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt.
## 8. Haftung
(1) Der Webshop ist ein Hilfsmittel für den Verkauf des Händlers. Der Vertrag über die Ware besteht allein zwischen dem Händler und dem Käufer; WEBklar ist daran nicht beteiligt.
(2) Für entgangenen Gewinn und Folgeschäden aus Störungen des Webshops, aus Fehlern bei Bestand oder Bestellstatus und aus nicht zugestellten E-Mails haftet WEBklar nur im Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen.
(3) WEBklar haftet nicht für Inhalte des Händlers, insbesondere nicht für seine Artikel, Preise, Produktangaben und Rechtstexte, auch nicht für Mustertexte, die er übernommen hat (Nr. 6), und nicht für die Erfüllung seiner Pflichten gegenüber den Käufern (Nr. 5).
(4) WEBklar haftet nicht für Ausfälle, Sperren oder Entscheidungen von Stripe und der Zahlungsarten sowie für Rückbuchungen und Zahlungsausfälle (Nr. 4 Abs. 3).
(5) Die Haftung nach § 13 Abs. 1 der Abo-Bedingungen (unter anderem bei Vorsatz, grober Fahrlässigkeit, Verletzung von Leben, Körper oder Gesundheit und nach dem Produkthaftungsgesetz) bleibt unberührt. Der Haftungsrahmen des Händlers gegenüber seinen Käufern bleibt unberührt.
## 9. Einrichtung, Abrechnung und Widerruf
(1) Die Einrichtung der Funktion rechnet WEBklar als Arbeitszeit auf das Stundenkontingent des Wartungs-Abos an (§ 4 der Abo-Bedingungen). Reicht das verfügbare Kontingent nicht aus, führt WEBklar die Einrichtung trotzdem aus. Die fehlende Zeit ist ein Vorschuss, den die folgenden Monatskontingente ausgleichen. Eine zusätzliche Rechnung gibt es dafür nicht. Endet das Wartungs-Abo, solange noch ein Vorschuss offen ist, verfällt der Rest zugunsten des Händlers. Für den Betrieb des Webshops berechnet WEBklar nichts weiter; es gibt keine laufende Gebühr und keine Beteiligung am Umsatz.
(2) Die Einrichtung dauert nach der Erfahrung von WEBklar meist 2 bis 3 Wochen ab dem Einschalten. Sie hängt davon ab, wie schnell der Händler Stripe-Konto, Artikel und Rechtstexte bereitstellt und wie umfangreich der Einbau ist. Die Dauer ist eine Einschätzung und keine verbindliche Zusage eines Termins.
(3) Hat WEBklar die Funktion auf Wunsch des Händlers schon bei der Erstellung seiner Website eingebaut, richtet sich die Abrechnung der Einrichtung nach der Vereinbarung zum Projekt und nicht nach dem Stundenkontingent. Auch dann läuft die Funktion nur, solange das Wartungs-Abo läuft.
(4) Arbeiten, die nicht zur Einrichtung gehören, führt WEBklar nur nach Absprache vor der Ausführung und nach Freigabe des Händlers in Textform aus; es gilt § 4 der Abo-Bedingungen. Welche Wünsche nicht zur Einrichtung gehören, beschreibt Nr. 2 Abs. 7.
(5) Das Einschalten der Funktion ist Teil des Abos und kein eigener Vertrag. Für den Widerruf gilt die Widerrufsbelehrung der Abo-Bedingungen. Widerruft ein Verbraucher das Abo oder den Wechsel ins Wartungs-Abo, endet die Funktion. Hat der Händler die Funktion zuvor einzeln gekauft, bleibt die Widerrufsbelehrung maßgeblich, die er bei der Bestellung erhalten hat.
## 10. Laufzeit und Ende
(1) Die Funktion läuft, solange das Wartungs-Abo läuft. Endet es, auch durch einen Wechsel ins Hosting-Abo oder durch den Widerruf eines Wechsels, schaltet WEBklar die Funktion spätestens am folgenden Tag ab. WEBklar informiert den Händler per E-Mail.
(2) Mit dem Abschalten nimmt der Webshop keine neuen Bestellungen mehr an, und das Widget zeigt den Ausweichtext. Bestellungen, die noch nicht bezahlt sind, laufen aus. Die Bestellungen und Widerrufe bleiben im Kundenportal 90 Tage lesbar, und der Händler kann sie als CSV-Datei exportieren. Die Widerrufsfunktion bleibt bis zur Löschung nach Absatz 3 erreichbar, soweit das Widget noch eingebunden ist. Der Händler erfüllt seine Pflichten gegenüber den Käufern weiter (Nr. 5) und informiert sie, wenn er den Webshop nicht mehr anbietet.
(3) WEBklar löscht alle Daten des Webshops, also Artikel, Bilder, Bestellungen, Widerrufe, Einstellungen, Rechtstexte und das Archiv der bestätigten Fassungen, 90 Tage nach dem Abschalten. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Angaben zur Einrichtung bestehen und enthält keine Daten von Käufern (Nummer 9 Abs. 6 des AVV Webshop).
(4) Das Stripe-Konto bleibt beim Händler. Das Ende des Webshops berührt weder das Konto noch die Auszahlungen noch den Vertrag mit Stripe. Der Händler kann die Verbindung zwischen dem Konto und dem Webshop im Stripe-Dashboard beenden.
(5) Beginnt innerhalb der 90 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Webshop auf Wunsch des Händlers in Textform mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Anfrage und eine neue Einrichtung nötig (Einrichtung nach Nr. 9).
(6) Endet das Wartungs-Abo, bevor WEBklar den Webshop freigeschaltet hat, bricht WEBklar die Einrichtung ab; ein Vorschuss nach Nr. 9 Abs. 1 verfällt.
(7) Auf Wunsch des Händlers in Textform schaltet WEBklar den Webshop jederzeit ab; für den Widerruf gilt Nr. 9 Abs. 5.
## 11. Änderungen und Einstellung
(1) WEBklar darf die Technik des Webshops verbessern, solange der Leistungsumfang nicht schlechter wird (§ 3 Abs. 7 der Abo-Bedingungen). Ändert sich dabei, welche Daten erhoben werden, gilt Nummer 12 des AVV Webshop.
(2) WEBklar darf den Webshop für eine Website mit einer Frist von vier Wochen beenden oder für alle Händler mit einer Frist von drei Monaten einstellen, wenn
- ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form unzulässig macht oder nur mit wesentlich anderer Technik erlaubt und der Händler einer dafür nötigen neuen Fassung nicht zustimmt (AVV Webshop Nummer 12),
- Stripe die Anbindung nach Nr. 4 beendet oder wesentlich ändert, so dass der Webshop in der bisherigen Form nicht weiterbetrieben werden kann, oder
- eine Sicherheitslücke sich nicht anders beheben lässt.
Die Mitteilung erfolgt in Textform und nennt den Grund, die Merkmale der Änderung, den Zeitpunkt und bei Verbrauchern das Recht, den Vertrag nach § 327r Abs. 3 BGB zu beenden, mit seiner Frist.
(3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar den Webshop ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Händler darüber unverzüglich in Textform mit dem Grund und nimmt den Betrieb wieder auf, sobald das zulässig und technisch möglich ist.
(4) Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.
## 12. Hinweise
(1) **Keine Rechtsberatung.** Der Webshop und die Mustertexte sind technische Hilfsmittel, keine Rechtsberatung (Nr. 6). Die rechtliche Einordnung seiner Waren, seiner Preisangaben und seines Angebots trägt der Händler.
(2) **Barrierefreiheit.** Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen, Fokusführung, Fehlermeldungen als Live-Region). Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu. Ob der Händler als Anbieter von Dienstleistungen im elektronischen Geschäftsverkehr unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Händler.
@@ -0,0 +1,181 @@
# Leistungsbeschreibung Webshop
**Fassung 2026-10-05** · Zusatzbedingungen zum Vertrag über den Betrieb der Website
## 1. Geltung
(1) Diese Leistungsbeschreibung regelt die Funktion „Webshop“. Mit dem Webshop verkauft der Kunde Waren über seine Website („Händler“); Käufer sind die Besucher seiner Website, die im Webshop bestellen („Käufer“). Die Leistungsbeschreibung ist eine Zusatzleistung zum Vertrag über den Betrieb der Website: Sie setzt voraus, dass WEBklar die Website des Händlers aufgrund eines Vertrags betreibt, etwa im Hosting-Abo, im Wartungs-Abo oder aufgrund eines anderen Vertrags über den Betrieb der Website. Sie richtet sich nur an Unternehmer, die in eigenem Namen und für eigene Rechnung verkaufen.
(2) Sie wird vereinbart, wenn der Händler die Einrichtung des Webshops im Kundenportal bestellt und dabei dieser Leistungsbeschreibung, dem Auftragsverarbeitungsvertrag Webshop („AVV Webshop“) und der Widerrufsbelehrung zustimmt. Wie die Zustimmung abläuft, regelt die Vorbemerkung des AVV Webshop. Bestellen kann der Händler erst, nachdem WEBklar seine Anfrage für den Webshop geprüft und freigegeben hat (Nr. 2 Abs. 1).
(3) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für den Webshop diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Webshop.
(4) Leistungsbeschreibung A Nr. 2 Abs. 3 und Nr. 4 sowie Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen (Formulare mit Server, „Rechtstexte … nicht enthalten“) stehen dem Webshop nicht entgegen. Das Widget des Webshops läuft auf dem Server von WEBklar, nicht auf dem Server der Website, und die Muster-Rechtstexte nach Nr. 6 sind Teil des Webshops. Das gilt auch im Hosting-Abo.
## 2. Leistung
(1) Der Händler fragt den Webshop im Kundenportal an und beschreibt, was er verkauft. WEBklar prüft die Anfrage, vor allem nach Nr. 3, und gibt sie frei oder lehnt sie mit einer Begründung ab. Ein Anspruch auf Freigabe besteht nicht. Erst nach der Freigabe kann der Händler die Einrichtung bestellen.
(2) Die Einrichtung umfasst den Einbau eines Shop-Widgets an einer Stelle der Website des Händlers, die Seite des Webshops mit einem Verweis im Menü und den Link „Vertrag widerrufen“ in der Fußzeile der Seiten der Website, außerdem die Freischaltung durch WEBklar. Für jede Website wird ein Webshop eingerichtet. Zusätzlich steht dem Händler eine eigenständige Seite des Webshops bei WEBklar zur Verfügung (Standalone-Seite), etwa als Vorschau.
(3) Im Kundenportal verwaltet der Händler:
- Artikel mit Beschreibung, Bildern, Preis, Varianten, Bestand und den Pflichtangaben zum Artikel,
- Versand (nur nach Deutschland und Österreich) und Abholung mit Versandkosten und Lieferzeiten,
- Bestellungen mit Status (zum Beispiel bezahlt, versendet, abholbereit, abgeschlossen), die er als CSV-Datei exportieren kann,
- Erstattungen, ganz oder teilweise, über sein Stripe-Konto (Nr. 4),
- die Widerrufe, die Käufer über die Widerrufsfunktion erklärt haben,
- die Verbindung mit seinem Stripe-Konto, seine Einstellungen und seine Rechtstexte, deren bestätigte Fassungen WEBklar archiviert (Nr. 6 Abs. 5).
(4) Der Webshop enthält die Widerrufsfunktion nach § 356a BGB: Das Widget zeigt in seiner Fußzeile und die Standalone-Seite zeigt unter einer eigenen Adresse die Schaltfläche „Vertrag widerrufen“. Sie führt zu einem Formular und danach zur Bestätigungsschaltfläche „Widerruf bestätigen“. WEBklar speichert den Eingang, sendet dem Erklärenden sofort eine Eingangsbestätigung mit Inhalt, Datum und Uhrzeit und benachrichtigt den Händler. Die Funktion ist ohne Anmeldung erreichbar, auch wenn zur Erklärung keine Bestellung gefunden wird. Der Händler bearbeitet jeden Widerruf selbst (Nr. 5 Abs. 6).
(5) Der Webshop versendet im Namen des Händlers E-Mails an die Käufer: Eingang der Bestellung (bei einer Zahlung, die noch aussteht, etwa einer SEPA-Lastschrift, vor dem Vertragsschluss), Bestellbestätigung, Versand, Abholbereitschaft, Eingangsbestätigung eines Widerrufs, Erstattung und Hinweis auf eine fehlgeschlagene Zahlung. Absender ist die Versandadresse von WEBklar mit dem Namen des Shops, als Antwortadresse dient die E-Mail-Adresse des Händlers. Den Händler benachrichtigt der Webshop über neue Bestellungen und eingegangene Widerrufe.
(6) Der Webshop enthält einen Missbrauchsschutz gegen automatisierte Bestellungen und Widerrufe, darunter eine Obergrenze von drei offenen Bestellungen je IP-Adresse in 30 Minuten. Dafür speichert WEBklar mit der offenen Bestellung nur einen Hashwert der IP-Adresse, nicht die IP-Adresse selbst; er wird gelöscht, sobald die Bestellung bezahlt oder storniert wird (Nummer 3 Abs. 4 des AVV Webshop). Eine Telefonnummer des Käufers wird nicht erhoben und nicht gespeichert. Die Löschfristen für Bestelldaten nennt Nummer 9 des AVV Webshop.
(7) Nicht enthalten sind der Verkauf an Verbraucher außerhalb von Deutschland und Österreich, Rechnungen (Nr. 5 Abs. 7), die Verwaltung mehrerer Lager, Rabattcodes, Marktplatzfunktionen, die Anbindung an Warenwirtschaft oder Versanddienstleister, Mehrsprachigkeit und die Zahlung per PayPal (Nr. 4 Abs. 5). Zusätzliche Wünsche, etwa ein Sonderdesign, ein zweiter Webshop oder eine größere Zahl von Artikeln als vorgesehen, gehören nicht zur Einrichtung nach Nr. 9. WEBklar führt sie nach Absprache nach Stunden aus (Nr. 9 Abs. 5).
## 3. Ausgeschlossene Waren und Leistungen
(1) Über den Webshop dürfen nicht verkauft werden:
- alkoholische Getränke,
- Tabakwaren, E-Zigaretten und Zubehör,
- Arzneimittel und Medizinprodukte,
- Waffen, Munition, Feuerwerk und Sprengstoffe,
- Waren mit erotischem oder pornografischem Inhalt,
- lebende Tiere,
- digitale Inhalte und Downloads,
- Gutscheine, Geschenkkarten und Guthaben,
- Abonnements und andere wiederkehrende Zahlungen,
- Waren und Leistungen, die nach der Liste der verbotenen und eingeschränkten Geschäfte von Stripe nicht erlaubt sind, und
- Waren, deren Verkauf oder Versand nach deutschem, österreichischem oder europäischem Recht verboten ist.
(2) Der Händler liefert nur nach Deutschland und Österreich. Eine Lieferung in andere Länder bietet der Webshop nicht an.
(3) Der Händler bestätigt bei der Anfrage und bei der Bestellung, dass er keine dieser Waren und Leistungen verkauft. WEBklar darf eine Anfrage ablehnen, wenn der Händler Waren nach Absatz 1 verkaufen will oder die Angaben dazu nicht ausreichen. Verkauft der Händler später Waren nach Absatz 1, darf WEBklar den Webshop nach einem Hinweis in Textform abschalten; bei einem Verstoß, der Rechte Dritter oder Käufer gefährdet, darf WEBklar ihn auch ohne vorherigen Hinweis abschalten und informiert den Händler unverzüglich. Für das Abschalten gilt Nr. 10 Abs. 2 und 3. Eine Erstattung des Einrichtungspreises folgt daraus nicht, wenn der Händler den Verstoß zu vertreten hat.
## 4. Zahlungen über Stripe
(1) Zahlungen im Webshop laufen über Stripe Connect. Der Händler verbindet dazu sein eigenes Stripe-Konto mit dem Webshop. Den Vertrag über dieses Konto schließt der Händler unmittelbar mit Stripe; WEBklar ist daran nicht beteiligt. Für das Konto, die Prüfung des Händlers durch Stripe (zum Beispiel zur Identität) und die Auszahlungen gelten die Bedingungen von Stripe.
(2) Das Geld der Käufer fließt direkt an den Händler. WEBklar erhält und verwahrt kein Geld der Käufer, leitet keines weiter und beteiligt sich nicht am Umsatz des Händlers. WEBklar ist kein Zahlungsdienstleister. Die Gebühren von Stripe trägt der Händler; sie fallen nur zwischen dem Händler und Stripe an.
(3) Der Händler prüft selbst, ob sein Stripe-Konto Zahlungen annehmen und Auszahlungen leisten kann. WEBklar zeigt dem Händler im Kundenportal den Stand, den Stripe meldet, hat aber keinen Einfluss darauf, ob und wann Stripe ein Konto freischaltet, einschränkt oder sperrt, und haftet dafür nicht. Rückbuchungen, Streitfälle mit Käufern und Zahlungsausfälle regeln der Händler und Stripe.
(4) Der Händler erstattet Zahlungen im Kundenportal oder im Stripe-Dashboard. WEBklar trägt Erstattungen aus dem Stripe-Dashboard in die Bestellung nach. Eine Erstattung führt der Händler rechtzeitig aus (Nr. 5 Abs. 6).
(5) Der Webshop bietet die Zahlungsarten an, die Stripe im Checkout für das Konto des Händlers bereitstellt und der Händler in seinen Einstellungen wählt, zum Beispiel Karte und SEPA-Lastschrift. PayPal ist über Stripe für diese Art der Anbindung nicht verfügbar und deshalb nicht Teil des Webshops.
(6) Die Zahlungsseite selbst (Stripe Checkout) betreibt Stripe. Kartendaten und Kontodaten gibt der Käufer nur dort ein; WEBklar erhält sie nicht.
## 5. Pflichten des Händlers
(1) Der Händler hat einen laufenden Vertrag mit WEBklar über den Betrieb seiner Website (Hosting-Abo, Wartungs-Abo oder ein anderer Vertrag über den Betrieb der Website), ist Unternehmer und verkauft in eigenem Namen. Er stellt sein eigenes Stripe-Konto bereit und schließt die Anmeldung bei Stripe selbst ab.
(2) Er verantwortet seine Rechtstexte: Impressum, Datenschutzerklärung, Allgemeine Geschäftsbedingungen, Widerrufsbelehrung, Muster-Widerrufsformular sowie die Angaben zu Versand und Zahlung. Die Mustertexte nach Nr. 6 prüft er, passt sie an seinen Betrieb an und bestätigt sie im Kundenportal, bevor er den Webshop öffnet. Er trägt vor der Bestellung die Adressen von Impressum und Datenschutzerklärung seiner Website ein.
(3) Er verantwortet seine Artikel, Preise und Produktangaben. Dazu gehören richtige Preise einschließlich Steuerausweis (Umsatzsteuer oder Hinweis auf § 19 UStG), Grundpreise, Versandkosten und Lieferzeiten, die Pflichtangaben für Lebensmittel (zum Beispiel Zutaten, Allergene, Füllmenge, verantwortlicher Lebensmittelunternehmer), die Angaben zur Produktsicherheit (zum Beispiel Hersteller, Warn- und Sicherheitshinweise), Widerrufsausschlüsse bei einzelnen Artikeln und die Rechte an seinen Texten und Bildern.
(4) Er liefert die Waren, wie bestellt und beschrieben, und trägt alle Pflichten eines Verkäufers gegenüber seinen Käufern, auch Gewährleistung, Kundenservice und Beschwerden.
(5) Er pflegt Bestand, Bestellstatus und Versandstatus im Kundenportal. Die Folgen falscher Angaben oder Statusmeldungen, die der Händler setzt, trägt er.
(6) Er bearbeitet Widerrufe und Erstattungen selbst, fristgerecht und nach dem Gesetz. Er entfernt den Link „Vertrag widerrufen“ nicht und verändert die Widerrufsfunktion nicht. Er prüft das Kundenportal regelmäßig auf neue Bestellungen und Widerrufe und lässt sich über die Benachrichtigungen informieren.
(7) Rechnungen und Belege an die Käufer erstellt der Händler selbst; WEBklar stellt sie nicht aus. Die Quittung von Stripe und die Bestellbestätigung des Webshops ersetzen keine Rechnung nach dem Umsatzsteuergesetz, soweit der Händler eine ausstellen muss.
(8) Er erfüllt seine Aufbewahrungspflichten selbst, etwa nach der Abgabenordnung und dem Handelsgesetzbuch. WEBklar ist kein Archiv für die Buchführung und kein Buchführungssystem (das Archiv der Rechtstexte nach Nr. 6 Abs. 5 dient nur dem Nachweis der Texte): Personenbezogene Käuferdaten werden nach Nummer 9 des AVV Webshop anonymisiert und nach dem Ende des Webshops gelöscht. Der Händler exportiert seine Bestellungen deshalb rechtzeitig als CSV-Datei und bewahrt sie mit den Belegen von Stripe auf.
(9) Er hält die Vorgaben nach Nr. 3 ein und verändert den Einbau-Code und die gesetzlich vorgeschriebenen Hinweise des Widgets nicht.
(10) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einbau dort den Datenschutz-Absatz für den Webshop ein; das gehört zur Einrichtung. Liegt sie woanders, erhält der Händler den Absatz als Textbaustein und fügt ihn selbst ein. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt er verantwortlich (§ 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf, weist WEBklar den Händler darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht.
(11) Der Händler informiert seine Käufer nach Art. 13 DSGVO. Die Rollen im Datenschutz regelt der AVV Webshop: Der Händler ist Verantwortlicher, WEBklar Auftragsverarbeiter, Stripe Vertragspartner des Händlers.
## 6. Muster-Rechtstexte, keine Rechtsberatung
(1) WEBklar stellt dem Händler Mustertexte bereit: eine Muster-Widerrufsbelehrung, ein Muster-Widerrufsformular, Allgemeine Geschäftsbedingungen und den Text zu Versand und Zahlung. Das Widget zeigt außerdem die Hinweise, die für die Kasse und die Bestellbestätigung vorgesehen sind, darunter den Hinweis auf die gesetzliche Gewährleistung.
(2) Die Mustertexte sind **Muster ohne Gewähr**. Sie passen zur Standardkonfiguration des Webshops und können auf den Betrieb des Händlers nicht zugeschnitten sein. WEBklar sorgt dafür, dass sie die Technik des Webshops richtig beschreiben, und passt sie an, wenn sich die Technik ändert. Eine rechtliche Beratung im Einzelfall schuldet WEBklar nicht (kein Rechtsrat im Sinne des Rechtsdienstleistungsgesetzes).
(3) WEBklar empfiehlt, die Rechtstexte vor der Öffnung des Webshops durch eine Rechtsanwältin oder einen Rechtsanwalt prüfen zu lassen, besonders bei besonderen Waren (zum Beispiel Lebensmitteln, Kosmetik, Textilien mit Pflegekennzeichnung, Elektrogeräten).
(4) Der Händler bleibt für seine Rechtstexte verantwortlich, auch wenn er Mustertexte unverändert übernimmt (Nr. 5 Abs. 2).
(5) Bestätigt der Händler seine Rechtstexte im Kundenportal, legt WEBklar jede bestätigte Fassung mit Prüfsumme, Zeitpunkt und der bestätigenden Person im Archiv ab. Die Bestellbestätigung zeigt dem Käufer die archivierte Fassung, die zum Zeitpunkt seiner Bestellung galt. WEBklar löscht das Archiv mit den übrigen Daten des Webshops (Nr. 10 Abs. 3; Nummer 8 Abs. 5 des AVV Webshop). Der Händler bewahrt die Fassungen, die er für eigene Aufbewahrungspflichten braucht, selbst auf (Nr. 5 Abs. 8).
## 7. Verfügbarkeit und Störungen
(1) Das Verfügbarkeitsziel nach § 5 der Abo-Bedingungen gilt für die Website, nicht für den Webshop. WEBklar sagt für den Webshop keine bestimmte Verfügbarkeit zu, die über den Betrieb der Website im Abo hinausgeht. Störungen des Webshops beheben wir in angemessener Frist (§ 12 der Abo-Bedingungen).
(2) Angekündigte Wartungsfenster (§ 5 der Abo-Bedingungen) können den Webshop kurzzeitig unterbrechen. Während einer Unterbrechung sind Bestellungen im Widget nicht möglich; das Widget zeigt dann einen Ausweichtext.
(3) Zahlungen übernimmt der Webshop über Benachrichtigungen von Stripe und einen regelmäßigen Abgleich in die Bestellung. Fällt die Verbindung zu Stripe oder zum Server aus, kann sich die Bestätigung einer Bestellung verzögern. Ausfälle von Stripe, von Zahlungsarten und von Banken liegen nicht im Einflussbereich von WEBklar.
(4) Alle Zeiten gelten in der Zeitzone Europe/Berlin.
(5) WEBklar übernimmt keine Gewähr dafür, dass E-Mails zugestellt und gelesen werden. Das hängt vom Postfach des Empfängers und von den Mailgrenzen seines Anbieters ab.
(6) Die gesetzlichen Rechte des Händlers sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt.
## 8. Haftung
(1) Der Webshop ist ein Hilfsmittel für den Verkauf des Händlers. Der Vertrag über die Ware besteht allein zwischen dem Händler und dem Käufer; WEBklar ist daran nicht beteiligt.
(2) Für entgangenen Gewinn und Folgeschäden aus Störungen des Webshops, aus Fehlern bei Bestand oder Bestellstatus und aus nicht zugestellten E-Mails haftet WEBklar nur im Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen.
(3) WEBklar haftet nicht für Inhalte des Händlers, insbesondere nicht für seine Artikel, Preise, Produktangaben und Rechtstexte, auch nicht für Mustertexte, die er übernommen hat (Nr. 6), und nicht für die Erfüllung seiner Pflichten gegenüber den Käufern (Nr. 5).
(4) WEBklar haftet nicht für Ausfälle, Sperren oder Entscheidungen von Stripe und der Zahlungsarten sowie für Rückbuchungen und Zahlungsausfälle (Nr. 4 Abs. 3).
(5) Die Haftung nach § 13 Abs. 1 der Abo-Bedingungen (unter anderem bei Vorsatz, grober Fahrlässigkeit, Verletzung von Leben, Körper oder Gesundheit und nach dem Produkthaftungsgesetz) bleibt unberührt. Der Haftungsrahmen des Händlers gegenüber seinen Käufern bleibt unberührt.
## 9. Preis, Abrechnung und Widerruf
(1) Die Einrichtung kostet 299 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG. Für den Betrieb des Webshops berechnet WEBklar nichts weiter; es gibt keine laufende Gebühr und keine Beteiligung am Umsatz. Der Webshop läuft, solange WEBklar die Website betreibt (Nr. 10).
(2) Die Einrichtung dauert nach der Erfahrung von WEBklar meist 2 bis 3 Wochen ab der Bestellung. Sie hängt davon ab, wie schnell der Händler Stripe-Konto, Artikel und Rechtstexte bereitstellt und wie umfangreich der Einbau ist. Die Dauer ist eine Einschätzung und keine verbindliche Zusage eines Termins.
(3) Der Händler bezahlt bei der Bestellung im Kundenportal über Stripe und erhält eine Rechnung.
(4) Der Preis steht neben den Leistungen eines Abos: Die Einrichtung wird weder auf ein Stundenkontingent angerechnet noch damit bezahlt.
(5) Mehraufwand, der nicht zur Einrichtung gehört (Nr. 2 Abs. 7), führt WEBklar nur nach Absprache vor der Ausführung und nach Freigabe des Händlers in Textform aus. WEBklar nennt vorher den geschätzten Aufwand und berechnet 100 € je Stunde in Schritten von 15 Minuten, ohne Umsatzsteuer gemäß § 19 UStG (§ 4 der Abo-Bedingungen). Der Mehraufwand wird gesondert berechnet und nicht auf ein Stundenkontingent angerechnet.
(6) Verbraucher können die Bestellung der Einrichtung nach der Widerrufsbelehrung widerrufen. Belehrung und Muster-Widerrufsformular stehen in der Anlage „Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)“. Ob das Widerrufsrecht erlischt und ob der Händler einen angemessenen Betrag für die bis dahin erbrachte Leistung schuldet, richtet sich nach der Zustimmung, die er bei der Bestellung gibt, und nach den Hinweisen zum Widerruf in der Anlage. Mit dem Widerruf endet der Webshop.
## 10. Laufzeit und Ende
(1) Der Webshop läuft, solange WEBklar die Website des Händlers betreibt. Endet der Vertrag über den Betrieb der Website, endet auch der Webshop; WEBklar schaltet ihn zum Vertragsende ab und informiert den Händler vorher per E-Mail. Das gilt auch beim Widerruf dieses Vertrags.
(2) Mit dem Abschalten nimmt der Webshop keine neuen Bestellungen mehr an, und das Widget zeigt den Ausweichtext. Bestellungen, die noch nicht bezahlt sind, laufen aus. Die Bestellungen und Widerrufe bleiben im Kundenportal 90 Tage lesbar, und der Händler kann sie als CSV-Datei exportieren. Die Widerrufsfunktion bleibt bis zur Löschung nach Absatz 3 erreichbar, soweit das Widget noch eingebunden ist. Der Händler erfüllt seine Pflichten gegenüber den Käufern weiter (Nr. 5) und informiert sie, wenn er den Webshop nicht mehr anbietet.
(3) WEBklar löscht alle Daten des Webshops, also Artikel, Bilder, Bestellungen, Widerrufe, Einstellungen, Rechtstexte und das Archiv der bestätigten Fassungen, 90 Tage nach dem Abschalten. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Angaben zur Einrichtung bestehen und enthält keine Daten von Käufern (Nummer 9 Abs. 6 des AVV Webshop).
(4) Das Stripe-Konto bleibt beim Händler. Das Ende des Webshops berührt weder das Konto noch die Auszahlungen noch den Vertrag mit Stripe. Der Händler kann die Verbindung zwischen dem Konto und dem Webshop im Stripe-Dashboard beenden.
(5) Beginnt innerhalb der 90 Tage wieder ein Vertrag über den Betrieb der Website, schaltet WEBklar den Webshop auf Wunsch des Händlers in Textform mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Anfrage und eine neue Einrichtung nötig (neuer Preis nach Nr. 9).
(6) Endet der Vertrag über den Betrieb der Website, bevor WEBklar den Webshop freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.
(7) Auf Wunsch des Händlers in Textform schaltet WEBklar den Webshop jederzeit ab. Eine Erstattung des Einrichtungspreises folgt daraus nicht; für den Widerruf gilt Nr. 9 Abs. 6.
## 11. Änderungen und Einstellung
(1) WEBklar darf die Technik des Webshops verbessern, solange der Leistungsumfang nicht schlechter wird (§ 3 Abs. 7 der Abo-Bedingungen). Ändert sich dabei, welche Daten erhoben werden, gilt Nummer 12 des AVV Webshop.
(2) WEBklar darf den Webshop für eine Website mit einer Frist von vier Wochen beenden oder für alle Händler mit einer Frist von drei Monaten einstellen, wenn
- ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form unzulässig macht oder nur mit wesentlich anderer Technik erlaubt und der Händler einer dafür nötigen neuen Fassung nicht zustimmt (AVV Webshop Nummer 12),
- Stripe die Anbindung nach Nr. 4 beendet oder wesentlich ändert, so dass der Webshop in der bisherigen Form nicht weiterbetrieben werden kann, oder
- eine Sicherheitslücke sich nicht anders beheben lässt.
Die Mitteilung erfolgt in Textform und nennt den Grund, die Merkmale der Änderung, den Zeitpunkt und bei Verbrauchern das Recht, den Vertrag nach § 327r Abs. 3 BGB zu beenden, mit seiner Frist.
(3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar den Webshop ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Händler darüber unverzüglich in Textform mit dem Grund und nimmt den Betrieb wieder auf, sobald das zulässig und technisch möglich ist.
(4) Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.
## 12. Hinweise
(1) **Keine Rechtsberatung.** Der Webshop und die Mustertexte sind technische Hilfsmittel, keine Rechtsberatung (Nr. 6). Die rechtliche Einordnung seiner Waren, seiner Preisangaben und seines Angebots trägt der Händler.
(2) **Barrierefreiheit.** Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen, Fokusführung, Fehlermeldungen als Live-Region). Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu. Ob der Händler als Anbieter von Dienstleistungen im elektronischen Geschäftsverkehr unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Händler.
@@ -0,0 +1,109 @@
# Leistungsbeschreibung Website-Analyse
**Fassung 2026-10-16** · Entwurf – Prüfung durch Kanzlei offen · Zusatzbedingungen zum Hosting-Abo und zum Wartungs-Abo
## 1. Geltung
(1) Funktion im Sinne dieser Leistungsbeschreibung ist die Website-Analyse. Diese Leistungsbeschreibung gilt, wenn der Kunde die Funktion im Kundenportal einschaltet und die dort angezeigte Zeile mit dem Klick auf „Einschalten“ bestätigt. Sie gilt für Funktionen, die ab ihrer Veröffentlichung eingeschaltet werden; hat der Kunde die Funktion zuvor einzeln gekauft, gilt für sie weiter die Leistungsbeschreibung, unter der er sie gekauft hat. Die Funktion ist in beiden Abos enthalten. Sie setzt ein laufendes Hosting-Abo oder Wartungs-Abo voraus. Sie wird zusammen mit dem Auftragsverarbeitungsvertrag Website-Analyse („AVV Website-Analyse“) vereinbart. Wie die Zustimmung abläuft, regelt die Vorbemerkung des AVV Website-Analyse.
(2) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für die Website-Analyse diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Website-Analyse.
(3) Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Rechtstexte … nicht enthalten“) gilt nicht für die Zusatzseite nach Nr. 2 Abs. 2 und den Hinweis in der Datenschutzerklärung nach Nr. 4 Abs. 4. Beides ist Teil der Website-Analyse.
(4) Das Einschalten der Website-Analyse ist Teil des Abos und kein eigener Vertrag. Für den Widerruf gilt die Widerrufsbelehrung der Abo-Bedingungen.
## 2. Leistung
(1) Die Website-Analyse misst, wie die öffentliche Website des Kunden genutzt wird. Sie ist im Hosting-Abo und im Wartungs-Abo ohne Aufpreis enthalten. Die Einrichtung der Website-Analyse ist für den Kunden kostenlos. WEBklar rechnet sie weder auf ein Stundenkontingent an noch berechnet WEBklar sie gesondert.
(2) Beim Veröffentlichen der Website fügt WEBklar Folgendes ein:
- ein Messskript in jede HTML-Datei der Website,
- ein Einwilligungsbanner am unteren Rand mit den gleich gestalteten Schaltflächen „Ablehnen“ und „Annehmen“ in der Akzentfarbe, die der Kunde beim Einschalten wählt (ist sie für weiße Schrift zu hell, nimmt das Banner Schwarz),
- eine Schaltfläche „Datenschutz-Einstellungen“ unten rechts, über die Besucher ihre Wahl jederzeit ändern können (hat die Website ein eigenes Element mit dem Attribut `data-webklar-consent`, übernimmt dieses Element die Aufgabe),
- eine Zusatzseite „/datenschutz-analyse.html“ mit den Datenschutzhinweisen zur Messung (Verantwortlicher: der Kunde).
(3) Gemessen wird:
- **solange der Besucher keine Wahl getroffen hat und sein Browser kein Signal nach dem nächsten Spiegelstrich sendet:** Seitenaufrufe mit Pfad (ohne Suchparameter), Land (aus der IP-Adresse, die nicht gespeichert wird), Geräteklasse und ein Zähler dafür, dass das Banner angezeigt wurde;
- **bei den Browser-Signalen „Global Privacy Control“ und „Do Not Track“:** nichts, und es erscheint kein Banner;
- **nach „Ablehnen“:** nichts mehr, außer einmal dem Zähler „abgelehnt“;
- **nach „Annehmen“ zusätzlich:** die Domain der Website, von der der Besucher kam, die Größenklasse des Browserfensters, eine täglich wechselnde pseudonyme Besucher-Kennung, Verweildauer, Scrolltiefe, Klickpositionen (Heatmap), die Art von Kontakt-Klicks (Telefon, E-Mail, WhatsApp, Link auf eine andere Website mit deren Domain) und ein Zähler „angenommen“.
Die einzelnen Datenarten nennt der AVV Website-Analyse in Nummer 3.
(4) Es werden keine Cookies gesetzt. Gespeichert wird auf dem Gerät des Besuchers nur seine Banner-Wahl im lokalen Speicher des Browsers, für 183 Tage.
(5) Die Auswertung steht im Kundenportal zur Verfügung: Kennzahlen und Diagramme für die letzten 7, 30 oder 90 Tage und eine Heatmap je Seite und Ansicht (Handy, Tablet, Computer) aus den bis zu 5.000 jüngsten Klicks der letzten 30 Tage. Die Daten liegen auf dem Server von WEBklar bei der Hetzner Online GmbH in Deutschland; sie werden nicht in Drittländer übermittelt. Einzelne Ereignisse und Klicks werden nach 30 Tagen gelöscht, Tageswerte nach 25 Monaten, alle Daten rund 60 Tage nach dem Ende der Website-Analyse (AVV Website-Analyse Nummer 9).
(6) Die Zahlen sind Näherungswerte. Besucher mit Blockern, abgeschaltetem JavaScript, Browser-Signal oder Ablehnung werden nur eingeschränkt oder gar nicht erfasst. Besucherzahlen, Herkunftsseiten, Verweildauer, Scrolltiefe, Klicks und Kontakt-Klicks stammen nur von Besuchern, die „Annehmen“ gewählt haben.
(7) Nicht enthalten sind Sitzungsaufzeichnungen, die Auswertung von Formularinhalten, die Messung von Ladezeiten und Fehlern, Kampagnen- und Trichteranalysen sowie ein Datenexport außer nach Nr. 7 Abs. 3.
(8) Das Banner fragt nur nach der Einwilligung in die Website-Analyse von WEBklar. Andere einwilligungspflichtige Inhalte der Website, etwa eingebettete Karten, Videos oder Schriften von Drittanbietern, deckt es nicht ab.
## 3. Voraussetzungen
(1) Der Kunde hat ein laufendes Hosting-Abo oder Wartungs-Abo.
(2) Die Website ist öffentlich erreichbar und wird bei WEBklar gehostet (Adresse unter project.webklar.com oder eigene Domain, die auf den Server von WEBklar zeigt). Nicht öffentliche Websites und Vorschauen, die nur nach einer Anmeldung erreichbar sind, werden nicht gemessen, ebenso wenig Websites, die im Dev-Modus auf einem Entwicklungsserver laufen. Enthält eine öffentliche Website Bereiche, die erst nach einer Anmeldung zugänglich sind (etwa eine Web-Anwendung mit Kundenkonto), werden auch dort Seitenaufrufe gezählt; dafür gilt Nr. 4 Abs. 5.
(3) Auf der Zusatzseite erscheinen als Verantwortlicher die Firma (sonst der Name), die Anschrift, soweit sie im Kundenkonto hinterlegt ist, und die E-Mail-Adresse aus dem Kundenkonto. Das Portal zeigt diese Angaben vor dem Einschalten an. Der Kunde prüft sie und teilt Änderungen mit. Fehlen Firma und Name oder fehlt eine gültige E-Mail-Adresse, lässt sich die Funktion nicht einschalten.
(4) Richtet sich die Website auch gezielt an Kinder unter 16 Jahren, schaltet der Kunde die Website-Analyse nicht ein, weil eine Einwilligung über das Banner dann nicht genügt (Art. 8 DSGVO). Lassen Inhalte oder Adressen der Website auf besondere Kategorien personenbezogener Daten schließen, etwa Gesundheit, Religion oder politische Meinung, teilt der Kunde das WEBklar vor dem Einschalten mit. WEBklar darf das Einschalten für eine solche Website ablehnen oder die Website-Analyse dort ausschalten.
## 4. Pflichten des Kunden
(1) Der Kunde ist für die Messung auf seiner Website Verantwortlicher im Sinne der DSGVO.
(2) Er entfernt, verdeckt oder umgeht das Banner nicht und verändert das Skript nicht. Text und Gestaltung des Banners legt WEBklar fest.
(3) Er bindet ohne Abstimmung mit WEBklar keine weiteren Mess-, Tracking- oder Marketing-Werkzeuge ein (zum Beispiel Google Analytics oder Meta Pixel), weil sie eine eigene Einwilligung erfordern können.
(4) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einschalten dort einen kurzen Hinweis mit Link auf die Zusatzseite ein und entfernt ihn beim Ausschalten wieder; das wird nicht auf das Stundenkontingent angerechnet. Liegt sie woanders, etwa bei einem externen Generator, erhält der Kunde den Hinweis als Textbaustein und fügt ihn selbst ein und entfernt ihn nach dem Ausschalten selbst. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt der Kunde verantwortlich (§ 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf (etwa „Wir setzen keine Analyse-Werkzeuge ein“), weist WEBklar den Kunden darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht.
(5) Enthalten die Pfade seiner Website personenbezogene Angaben, etwa Namen, Kundennummern oder E-Mail-Adressen, teilt der Kunde das WEBklar vor dem Einschalten mit.
(6) Er nutzt die Auswertung nur für eigene Zwecke, versucht nicht, einzelne Besucher zu identifizieren, und gibt Heatmap-Links nicht weiter.
## 5. Verfügbarkeit und Störungen
(1) Das Verfügbarkeitsziel nach § 5 der Abo-Bedingungen gilt für die Website, nicht für die Messung. Störungen der Messung beheben wir in angemessener Frist (§ 12 der Abo-Bedingungen). Daten, die während einer Störung nicht erfasst wurden, lassen sich nicht nachträglich erheben.
(2) Fällt der Messdienst aus, wird die Website weiter ausgeliefert, nur ohne Banner und Messung.
(3) Die gesetzlichen Rechte des Kunden sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt.
## 6. Hinweis zur Rechtslage
Für die Zählung, solange der Besucher keine Wahl getroffen hat (Nr. 2 Abs. 3), übermittelt das Skript nur die Adresse der aufgerufenen Seite und die Angabe, dass das Banner angezeigt wurde. Land und Geräteklasse bestimmt der Messdienst aus den Daten, die jeder Browser bei einer Anfrage ohnehin mitsendet. Ob auch dafür eine Einwilligung nach § 25 TDDDG nötig ist, haben Gerichte und Aufsichtsbehörden nicht entschieden; die Aufsichtsbehörden legen den Begriff des Zugriffs auf das Endgerät eher weit aus. Ändert sich die Rechtslage, passt WEBklar die Messung an (Nr. 8).
## 7. Ende der Leistung
(1) Der Kunde kann die Website-Analyse jederzeit im Kundenportal ausschalten; das Portal bestätigt die Abschaltung. Die Website-Analyse läuft, solange das Abo läuft, das sie enthält. Endet es, schaltet WEBklar sie spätestens am folgenden Tag ab. Wechselt der Kunde vom Wartungs- ins Hosting-Abo, bleibt die Website-Analyse; das Gleiche gilt, wenn er den Wechsel ins Wartungs-Abo widerruft. WEBklar informiert den Kunden per E-Mail.
(2) Mit dem Ausschalten speichert der Messdienst keine Messdaten mehr, und die Heatmap ist nicht mehr abrufbar. Das Messskript liefert dann kein Banner mehr aus, sobald die Website einmal mit der aktuellen Fassung des Skripts veröffentlicht wurde. Skript, Zusatzseite und, soweit WEBklar ihn eingefügt hat, den Hinweis in der Datenschutzerklärung entfernt WEBklar mit der nächsten Veröffentlichung der Website. WEBklar löst sie in der Regel sofort aus, spätestens innerhalb von fünf Werktagen.
(3) Die gespeicherten Daten löscht WEBklar nach Nummer 9 des AVV Website-Analyse. Bis zum 46. Tag nach dem Ende kann der Kunde eine Kopie der Tageswerte und der noch vorhandenen Einzelereignisse als CSV-Datei verlangen.
## 8. Änderungen und Einstellung
(1) WEBklar darf die Technik der Website-Analyse verbessern, wenn der Leistungsumfang nicht schlechter wird (§ 3 Abs. 7 der Abo-Bedingungen). Ändert sich dabei, welche Daten erhoben werden, gilt Nummer 12 des AVV Website-Analyse.
(2) WEBklar darf die Website-Analyse für eine Website mit einer Frist von vier Wochen beenden oder für alle Kunden mit einer Frist von drei Monaten einstellen, wenn
- ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form unzulässig macht oder nur mit wesentlich anderer Technik erlaubt und der Kunde einer dafür nötigen neuen Fassung nicht zustimmt (AVV Website-Analyse Nummer 12 Abs. 3), oder
- eine Sicherheitslücke sich nicht anders beheben lässt.
Die Mitteilung erfolgt in Textform und nennt den Grund, die Merkmale der Änderung, den Zeitpunkt und bei Verbrauchern das Recht, den Vertrag nach § 327r Abs. 3 BGB zu beenden, mit seiner Frist.
(3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar die Messung ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Kunden darüber unverzüglich in Textform mit dem Grund und nimmt die Messung wieder auf, sobald das zulässig und technisch möglich ist.
(4) Gegenüber Unternehmern darf WEBklar die Website-Analyse außerdem ohne besonderen Grund mit einer Frist von drei Monaten in Textform für alle Kunden einstellen. Der Kunde kann das Abo dann innerhalb von 30 Tagen nach der Mitteilung zum Zeitpunkt der Einstellung kündigen; die Mindestlaufzeit steht dem nicht entgegen.
(5) Weil für die Website-Analyse kein Aufpreis berechnet wird, ändert ihre Einstellung den Preis des Abos nicht. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.
## 9. Hinweise
(1) **Keine Rechtsberatung.** Die Zusatzseite und der Hinweis in der Datenschutzerklärung sind Mustertexte für die Standardkonfiguration. WEBklar sorgt dafür, dass sie die Technik richtig beschreiben, und passt sie an, wenn sich die Technik ändert. Eine Prüfung, ob die Website des Kunden insgesamt datenschutzkonform ist, und eine rechtliche Beratung im Einzelfall schuldet WEBklar nicht. Abstimmungen nach dieser Leistungsbeschreibung betreffen nur die Technik.
(2) **Nachweis der Einwilligung.** Ein Protokoll je Besucher wird nicht geführt. Als Nachweis dienen die Dokumentation der Technik und das Archiv der Fassungen von Banner-Text und Zusatzseite (AVV Website-Analyse Nummer 8 Abs. 6).
(3) Die Länderzuordnung nutzt die Datenbank „IP to Country Lite“ von DB-IP.com (Lizenz CC BY 4.0). Im Kundenportal steht dazu unter der Auswertung der Hinweis „Länderdaten: IP-Geolocation by DB-IP“ mit Link auf db-ip.com.
@@ -1,75 +1,107 @@
# Leistungsbeschreibung Website-Analyse
> **Entwurf – vor Verwendung anwaltlich prüfen lassen.** Dieser Text ist keine Rechtsberatung.
**Fassung 2026-10-02** · Zusatzbedingungen zum Wartungs-Abo
**Fassung 2026-10-01** · Teil der Abo-Bedingungen (Wartungs-Abo)
## 1. Geltung
## 1. Leistung
(1) Diese Leistungsbeschreibung regelt die Funktion „Website-Analyse“ im Wartungs-Abo. Sie wird vereinbart, wenn der Kunde die Funktion einschaltet und ihr zusammen mit dem Auftragsverarbeitungsvertrag Website-Analyse („AVV Website-Analyse“) zustimmt. Wie die Zustimmung abläuft, regelt die Vorbemerkung des AVV Website-Analyse.
(1) Die Website-Analyse misst, wie die öffentliche Website des Kunden genutzt wird. Sie ist im Wartungs-Abo ohne Aufpreis enthalten und wird vom Kunden im Kundenportal eingeschaltet.
(2) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für die Website-Analyse diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Website-Analyse.
(2) WEBklar fügt beim Veröffentlichen der Website ein Messskript und ein Cookie-Banner (Leiste unten, „Ablehnen“ und „Annehmen“ gleichwertig, Akzentfarbe, die beim Einschalten gewählt wird, Link „Cookie-Einstellungen“) ein und erzeugt eine Zusatzseite „/datenschutz-analyse.html“ mit den Datenschutzhinweisen zur Messung (Verantwortlicher: der Kunde).
(3) Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Rechtstexte … nicht enthalten“) gilt nicht für die Zusatzseite nach Nr. 2 Abs. 2 und den Hinweis in der Datenschutzerklärung nach Nr. 4 Abs. 4. Beides ist Teil der Website-Analyse.
## 2. Leistung
(1) Die Website-Analyse misst, wie die öffentliche Website des Kunden genutzt wird. Sie ist im Wartungs-Abo ohne Aufpreis enthalten.
(2) Beim Veröffentlichen der Website fügt WEBklar Folgendes ein:
- ein Messskript in jede HTML-Datei der Website,
- ein Einwilligungsbanner am unteren Rand mit den gleich gestalteten Schaltflächen „Ablehnen“ und „Annehmen“ in der Akzentfarbe, die der Kunde beim Einschalten wählt (ist sie für weiße Schrift zu hell, nimmt das Banner Schwarz),
- eine Schaltfläche „Datenschutz-Einstellungen“ unten rechts, über die Besucher ihre Wahl jederzeit ändern können (hat die Website ein eigenes Element mit dem Attribut `data-webklar-consent`, übernimmt dieses Element die Aufgabe),
- eine Zusatzseite „/datenschutz-analyse.html“ mit den Datenschutzhinweisen zur Messung (Verantwortlicher: der Kunde).
(3) Gemessen wird:
- **ohne Einwilligung des Besuchers:** Seitenaufrufe mit Pfad (ohne Suchparameter), Domainname der verweisenden Website, Land (die IP-Adresse wird nur im Moment der Abfrage verwendet und nicht gespeichert), Geräteklasse sowie anonyme Banner-Zähler;
- **nach „Annehmen“ zusätzlich:** eine täglich wechselnde pseudonyme Besucher-Kennung, Verweildauer, Scrolltiefe, Klickpositionen (Heatmap) und die Art von Kontakt-Klicks (Telefon, E-Mail, WhatsApp, Link auf eine andere Website).
- **solange der Besucher keine Wahl getroffen hat und sein Browser kein Signal nach dem nächsten Spiegelstrich sendet:** Seitenaufrufe mit Pfad (ohne Suchparameter), Land (aus der IP-Adresse, die nicht gespeichert wird), Geräteklasse und ein Zähler dafür, dass das Banner angezeigt wurde;
- **bei den Browser-Signalen „Global Privacy Control“ und „Do Not Track“:** nichts, und es erscheint kein Banner;
- **nach „Ablehnen“:** nichts mehr, außer einmal dem Zähler „abgelehnt“;
- **nach „Annehmen“ zusätzlich:** die Domain der Website, von der der Besucher kam, die Größenklasse des Browserfensters, eine täglich wechselnde pseudonyme Besucher-Kennung, Verweildauer, Scrolltiefe, Klickpositionen (Heatmap), die Art von Kontakt-Klicks (Telefon, E-Mail, WhatsApp, Link auf eine andere Website mit deren Domain) und ein Zähler „angenommen“.
(4) Es werden keine Cookies gesetzt. Gespeichert wird auf dem Gerät des Besuchers nur dessen Banner-Auswahl im lokalen Speicher des Browsers (sechs Monate). Signale „Global Privacy Control“ und „Do Not Track“ gelten als Ablehnen.
Die einzelnen Datenarten nennt der AVV Website-Analyse in Nummer 3.
(5) Die Auswertung (Kennzahlen, Diagramme, Heatmap) steht im Kundenportal zur Verfügung. Die Daten liegen auf Servern der Hetzner Online GmbH in Deutschland; es erfolgt keine Übermittlung in Drittländer. Rohdaten und Klicks werden nach 30 Tagen gelöscht, Tageswerte bis zum Ende der Analyse und weitere 60 Tage.
(4) Es werden keine Cookies gesetzt. Gespeichert wird auf dem Gerät des Besuchers nur seine Banner-Wahl im lokalen Speicher des Browsers, für 183 Tage.
(6) Nicht enthalten sind Sitzungsaufzeichnungen, Auswertung von Formularinhalten, Messung von Ladezeiten und Fehlern, Kampagnen- und Trichteranalyse sowie ein Datenexport (außer nach Nr. 6 Abs. 2).
(5) Die Auswertung steht im Kundenportal zur Verfügung: Kennzahlen und Diagramme für die letzten 7, 30 oder 90 Tage und eine Heatmap je Seite und Ansicht (Handy, Tablet, Computer) aus den bis zu 5.000 jüngsten Klicks der letzten 30 Tage. Die Daten liegen auf dem Server von WEBklar bei der Hetzner Online GmbH in Deutschland; sie werden nicht in Drittländer übermittelt. Einzelne Ereignisse und Klicks werden nach 30 Tagen gelöscht, Tageswerte nach 25 Monaten, alle Daten rund 60 Tage nach dem Ende der Website-Analyse (AVV Website-Analyse Nummer 9).
## 2. Voraussetzungen
(6) Die Zahlen sind Näherungswerte. Besucher mit Blockern, abgeschaltetem JavaScript, Browser-Signal oder Ablehnung werden nur eingeschränkt oder gar nicht erfasst. Besucherzahlen, Herkunftsseiten, Verweildauer, Scrolltiefe, Klicks und Kontakt-Klicks stammen nur von Besuchern, die „Annehmen“ gewählt haben.
(1) Ein aktives Wartungs-Abo. Im Hosting-Abo ist die Website-Analyse nicht enthalten.
(7) Nicht enthalten sind Sitzungsaufzeichnungen, die Auswertung von Formularinhalten, die Messung von Ladezeiten und Fehlern, Kampagnen- und Trichteranalysen sowie ein Datenexport außer nach Nr. 7 Abs. 3.
(2) Die Website ist öffentlich erreichbar und wird bei WEBklar gehostet (Adresse unter project.webklar.com oder eigene Domain, die auf den Server von WEBklar zeigt). Login-geschützte Vorschauseiten werden nicht gemessen.
(8) Das Banner fragt nur nach der Einwilligung in die Website-Analyse von WEBklar. Andere einwilligungspflichtige Inhalte der Website, etwa eingebettete Karten, Videos oder Schriften von Drittanbietern, deckt es nicht ab.
(3) Der Kunde stimmt beim Einschalten dem Auftragsverarbeitungsvertrag Website-Analyse in der aktuellen Fassung zu.
## 3. Voraussetzungen
## 3. Pflichten des Kunden
(1) Der Kunde hat ein laufendes Wartungs-Abo. Im Hosting-Abo ist die Website-Analyse nicht enthalten.
(1) Der Kunde ist Verantwortlicher im Sinne der DSGVO für die Messung auf seiner Website.
(2) Die Website ist öffentlich erreichbar und wird bei WEBklar gehostet (Adresse unter project.webklar.com oder eigene Domain, die auf den Server von WEBklar zeigt). Nicht öffentliche Websites und Vorschauen, die nur nach einer Anmeldung erreichbar sind, werden nicht gemessen, ebenso wenig Websites, die im Dev-Modus auf einem Entwicklungsserver laufen. Enthält eine öffentliche Website Bereiche, die erst nach einer Anmeldung zugänglich sind (etwa eine Web-Anwendung mit Kundenkonto), werden auch dort Seitenaufrufe gezählt; dafür gilt Nr. 4 Abs. 5.
(2) Er entfernt, verdeckt oder umgeht das Banner nicht und verändert das Skript nicht. Er verändert den Banner-Text oder die Gestaltung nur nach Abstimmung mit WEBklar.
(3) Auf der Zusatzseite erscheinen als Verantwortlicher die Firma (sonst der Name), die Anschrift, soweit sie im Kundenkonto hinterlegt ist, und die E-Mail-Adresse aus dem Kundenkonto. Das Portal zeigt diese Angaben vor dem Einschalten an. Der Kunde prüft sie und teilt Änderungen mit. Fehlen Firma und Name oder fehlt eine gültige E-Mail-Adresse, lässt sich die Funktion nicht einschalten.
(3) Er bindet ohne Abstimmung mit WEBklar keine weiteren Mess-, Tracking- oder Marketing-Werkzeuge ein (zum Beispiel Google Analytics, Meta Pixel), weil sie eine eigene Einwilligung und eine Anpassung des Banners erfordern können.
(4) Richtet sich die Website auch gezielt an Kinder unter 16 Jahren, schaltet der Kunde die Website-Analyse nicht ein, weil eine Einwilligung über das Banner dann nicht genügt (Art. 8 DSGVO). Lassen Inhalte oder Adressen der Website auf besondere Kategorien personenbezogener Daten schließen, etwa Gesundheit, Religion oder politische Meinung, teilt der Kunde das WEBklar vor dem Einschalten mit. WEBklar darf das Einschalten für eine solche Website ablehnen oder die Website-Analyse dort ausschalten.
(4) Er pflegt seine Datenschutzerklärung und ergänzt sie um einen Hinweis auf die Zusatzseite „/datenschutz-analyse.html“. Beim Einschalten legt WEBklar dafür ein Ticket an. Der Kunde prüft die Angaben zu Firma, Anschrift und E-Mail-Adresse, die auf der Zusatzseite erscheinen, und teilt Änderungen mit.
## 4. Pflichten des Kunden
(5) Er richtet die Website nicht gezielt an Kinder und verarbeitet über die Website keine besonderen Kategorien personenbezogener Daten in einer Weise, die die Messung berührt, ohne vorher mit WEBklar zu sprechen.
(1) Der Kunde ist für die Messung auf seiner Website Verantwortlicher im Sinne der DSGVO.
(6) Er nutzt die Auswertung nur für eigene Zwecke und versucht nicht, einzelne Besucher zu identifizieren.
(2) Er entfernt, verdeckt oder umgeht das Banner nicht und verändert das Skript nicht. Text und Gestaltung des Banners legt WEBklar fest.
<!-- Prüfpunkt Anwalt P19: Ausschluss von Websites mit Kinderzielgruppe; Datenschutz-Folgenabschätzung (laut Recherche meist nicht nötig). -->
(3) Er bindet ohne Abstimmung mit WEBklar keine weiteren Mess-, Tracking- oder Marketing-Werkzeuge ein (zum Beispiel Google Analytics oder Meta Pixel), weil sie eine eigene Einwilligung erfordern können.
## 4. Verfügbarkeit
(4) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einschalten dort einen kurzen Hinweis mit Link auf die Zusatzseite ein und entfernt ihn beim Ausschalten wieder; das wird nicht auf das Stundenkontingent angerechnet. Liegt sie woanders, etwa bei einem externen Generator, erhält der Kunde den Hinweis als Textbaustein und fügt ihn selbst ein und entfernt ihn nach dem Ausschalten selbst. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt der Kunde verantwortlich (§ 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf (etwa „Wir setzen keine Analyse-Werkzeuge ein“), weist WEBklar den Kunden darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht.
(1) Für die Website-Analyse gilt keine Verfügbarkeitszusage. Ausfälle, Wartung oder Fehler der Messung können zu Lücken in den Daten führen; ein Anspruch auf nachträgliche Messung besteht nicht.
(5) Enthalten die Pfade seiner Website personenbezogene Angaben, etwa Namen, Kundennummern oder E-Mail-Adressen, teilt der Kunde das WEBklar vor dem Einschalten mit.
(2) Ein Ausfall der Website-Analyse beeinträchtigt die Website des Kunden nicht. Die Website wird weiter ausgeliefert, nur ohne Messung.
(6) Er nutzt die Auswertung nur für eigene Zwecke, versucht nicht, einzelne Besucher zu identifizieren, und gibt Heatmap-Links nicht weiter.
(3) Die Zahlen sind Näherungswerte. Besucher mit Blockern, deaktivierten Skripten oder abgelehnter Einwilligung werden nur eingeschränkt oder nicht erfasst. Zahlen zu Besuchern, Verweildauer, Klicks und Kontakt-Klicks stammen nur von Besuchern, die „Annehmen“ gewählt haben.
## 5. Verfügbarkeit und Störungen
<!-- Prüfpunkt Anwalt P10: Haftungsausschluss/Verfügbarkeit in AGB (§§ 307 ff. BGB), Zusammenspiel mit § 5 und § 13 der Abo-Bedingungen. -->
(1) Das Verfügbarkeitsziel nach § 5 der Abo-Bedingungen gilt für die Website, nicht für die Messung. Störungen der Messung beheben wir in angemessener Frist (§ 12 der Abo-Bedingungen). Daten, die während einer Störung nicht erfasst wurden, lassen sich nicht nachträglich erheben.
## 5. Ende der Leistung
(2) Fällt der Messdienst aus, wird die Website weiter ausgeliefert, nur ohne Banner und Messung.
(1) Der Kunde kann die Website-Analyse jederzeit im Kundenportal ausschalten. Mit Ende des Wartungs-Abos (auch bei Wechsel ins Hosting-Abo) endet die Website-Analyse automatisch.
(3) Die gesetzlichen Rechte des Kunden sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt.
(2) Mit dem Abschalten werden keine Messdaten mehr gespeichert und die Heatmap ist nicht mehr abrufbar. Skript, Banner und Zusatzseite bleiben auf der Website sichtbar, bis die Website das nächste Mal veröffentlicht wird; dann entfernt WEBklar sie. Bei Websites ohne automatische Veröffentlichung geschieht das erst bei einer manuellen Veröffentlichung. WEBklar informiert den Kunden per E-Mail über die Abschaltung.
## 6. Hinweis zur Rechtslage
(3) Die gespeicherten Daten der Website löscht WEBklar 60 Tage nach der Abschaltung (Einzelheiten im Auftragsverarbeitungsvertrag). Der Kunde ergänzt seine Datenschutzerklärung nach der Abschaltung entsprechend.
Für die Zählung, solange der Besucher keine Wahl getroffen hat (Nr. 2 Abs. 3), übermittelt das Skript nur die Adresse der aufgerufenen Seite und die Angabe, dass das Banner angezeigt wurde. Land und Geräteklasse bestimmt der Messdienst aus den Daten, die jeder Browser bei einer Anfrage ohnehin mitsendet. Ob auch dafür eine Einwilligung nach § 25 TDDDG nötig ist, haben Gerichte und Aufsichtsbehörden nicht entschieden; die Aufsichtsbehörden legen den Begriff des Zugriffs auf das Endgerät eher weit aus. Ändert sich die Rechtslage, passt WEBklar die Messung an (Nr. 8).
## 6. Hinweise
## 7. Ende der Leistung
(1) **Keine Rechtsberatung.** Die Zusatzseite und die Hinweise von WEBklar sind Muster und Unterstützung. Sie ersetzen keine Rechtsberatung. WEBklar schuldet keine Prüfung, ob die Website des Kunden insgesamt datenschutzkonform ist. Der Kunde lässt seine Datenschutzerklärung bei Bedarf rechtlich prüfen.
(1) Der Kunde kann die Website-Analyse jederzeit im Kundenportal ausschalten; das Portal bestätigt die Abschaltung. Mit dem Ende des Wartungs-Abos endet die Website-Analyse; WEBklar schaltet sie spätestens am folgenden Tag ab und informiert den Kunden per E-Mail. Das gilt auch bei einem Wechsel ins Hosting-Abo und beim Widerruf eines Wechsels ins Wartungs-Abo.
(2) Auf Verlangen in Textform stellt WEBklar dem Kunden vor der Löschung eine Kopie der Tageswerte zur Verfügung.
(2) Mit dem Ausschalten speichert der Messdienst keine Messdaten mehr, und die Heatmap ist nicht mehr abrufbar. Das Messskript liefert dann kein Banner mehr aus, sobald die Website einmal mit der aktuellen Fassung des Skripts veröffentlicht wurde. Skript, Zusatzseite und, soweit WEBklar ihn eingefügt hat, den Hinweis in der Datenschutzerklärung entfernt WEBklar mit der nächsten Veröffentlichung der Website. WEBklar löst sie in der Regel sofort aus, spätestens innerhalb von fünf Werktagen.
(3) Die Länderzuordnung nutzt die Datenbank „DB-IP Lite Country“ von DB-IP.com (CC BY 4.0).
(3) Die gespeicherten Daten löscht WEBklar nach Nummer 9 des AVV Website-Analyse. Bis zum 46. Tag nach dem Ende kann der Kunde eine Kopie der Tageswerte und der noch vorhandenen Einzelereignisse als CSV-Datei verlangen.
<!-- Prüfpunkt Anwalt P17: RDG – Abgrenzung der Mustertexte von Rechtsberatung; Haftung für Mustertext. -->
<!-- Prüfpunkt Anwalt P1: Rechtliche Einordnung der Zählung ohne Einwilligung (§ 25 TDDDG, DSK-Lesart); Aussage in Nr. 1 Abs. 3 und Hinweis an den Kunden auf das Restrisiko. -->
<!-- Prüfpunkt Anwalt P16: Die Leistungsbeschreibung beschreibt den Einwilligungsnachweis nur technisch; P4 siehe AVV. -->
## 8. Änderungen und Einstellung
(1) WEBklar darf die Technik der Website-Analyse verbessern, wenn der Leistungsumfang nicht schlechter wird (§ 3 Abs. 7 der Abo-Bedingungen). Ändert sich dabei, welche Daten erhoben werden, gilt Nummer 12 des AVV Website-Analyse.
(2) WEBklar darf die Website-Analyse für eine Website mit einer Frist von vier Wochen beenden oder für alle Kunden mit einer Frist von drei Monaten einstellen, wenn
- ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form unzulässig macht oder nur mit wesentlich anderer Technik erlaubt und der Kunde einer dafür nötigen neuen Fassung nicht zustimmt (AVV Website-Analyse Nummer 12 Abs. 3), oder
- eine Sicherheitslücke sich nicht anders beheben lässt.
Die Mitteilung erfolgt in Textform und nennt den Grund, die Merkmale der Änderung, den Zeitpunkt und bei Verbrauchern das Recht, den Vertrag nach § 327r Abs. 3 BGB zu beenden, mit seiner Frist.
(3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar die Messung ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Kunden darüber unverzüglich in Textform mit dem Grund und nimmt die Messung wieder auf, sobald das zulässig und technisch möglich ist.
(4) Gegenüber Unternehmern darf WEBklar die Website-Analyse außerdem ohne besonderen Grund mit einer Frist von drei Monaten in Textform für alle Kunden einstellen. Der Kunde kann das Wartungs-Abo dann innerhalb von 30 Tagen nach der Mitteilung zum Zeitpunkt der Einstellung kündigen; die Mindestlaufzeit steht dem nicht entgegen.
(5) Weil für die Website-Analyse kein Aufpreis berechnet wird, ändert ihre Einstellung den Preis des Wartungs-Abos nicht. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.
## 9. Hinweise
(1) **Keine Rechtsberatung.** Die Zusatzseite und der Hinweis in der Datenschutzerklärung sind Mustertexte für die Standardkonfiguration. WEBklar sorgt dafür, dass sie die Technik richtig beschreiben, und passt sie an, wenn sich die Technik ändert. Eine Prüfung, ob die Website des Kunden insgesamt datenschutzkonform ist, und eine rechtliche Beratung im Einzelfall schuldet WEBklar nicht. Abstimmungen nach dieser Leistungsbeschreibung betreffen nur die Technik.
(2) **Nachweis der Einwilligung.** Ein Protokoll je Besucher wird nicht geführt. Als Nachweis dienen die Dokumentation der Technik und das Archiv der Fassungen von Banner-Text und Zusatzseite (AVV Website-Analyse Nummer 8 Abs. 6).
(3) Die Länderzuordnung nutzt die Datenbank „IP to Country Lite“ von DB-IP.com (Lizenz CC BY 4.0). Im Kundenportal steht dazu unter der Auswertung der Hinweis „Länderdaten: IP-Geolocation by DB-IP“ mit Link auf db-ip.com.
@@ -0,0 +1,135 @@
# Leistungsbeschreibung Öffnungszeiten live
**Fassung 2026-10-16** · Entwurf – Prüfung durch Kanzlei offen · Zusatzbedingungen zum Wartungs-Abo
## 1. Geltung
(1) Funktion im Sinne dieser Leistungsbeschreibung ist Öffnungszeiten live. Diese Leistungsbeschreibung gilt, wenn der Kunde die Funktion im Kundenportal einschaltet und die dort angezeigte Zeile mit dem Klick auf „Einschalten“ bestätigt. Sie gilt für Funktionen, die ab ihrer Veröffentlichung eingeschaltet werden; hat der Kunde die Funktion zuvor einzeln gekauft, gilt für sie weiter die Leistungsbeschreibung, unter der er sie gekauft hat. Die Funktion ist Teil des Wartungs-Abos. Sie setzt ein laufendes Wartungs-Abo voraus; ein Hosting-Abo genügt nicht.
(2) Die Funktion ist im Wartungs-Abo ohne Aufpreis enthalten (§ 3 Abs. 8 der Abo-Bedingungen). Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Nicht enthalten“: neue Funktionen und Rechtstexte) gilt dafür nicht: Die Funktion und der Datenschutz-Absatz nach Nr. 6 Abs. 4 sind Teil dieser Leistung.
(3) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für Öffnungszeiten live diese Leistungsbeschreibung vor. Die Haftungsregeln stehen in Nr. 8 ausgeschrieben da, damit sie auch für Abos früherer Fassungen der Abo-Bedingungen gelten.
## 2. Leistung
(1) Der Kunde trägt seine Öffnungszeiten einmal im Kundenportal ein: bis zu fünf benannte Zeitpläne, je Wochentag bis zu zwei Zeitfenster („rund um die Uhr“ und „nach Vereinbarung“ sind möglich), Abweichungen an Feiertagen, Betriebsurlaub und Sondertage. Die Website zeigt diese Zeiten überall gleich an und berechnet daraus den Live-Status („Jetzt geöffnet · schließt um 18 Uhr“).
(2) An den Stellen der Website, die nach Nr. 3 Abs. 2 dafür vorgesehen sind, erscheinen die Zeiten als Tabelle, als Status oder als Kurzanzeige („Geöffnet“, „Geschlossen“, „Nach Vereinbarung“). Dazu gehören, soweit der Kunde sie einrichtet:
- eine Hinweis-Leiste am Seitenanfang für befristete Hinweise (Info, Urlaub, Aktion, Wichtig) mit Beginn und Ende und auf Wunsch einem Knopf (Link oder Anruf),
- auf dem Handy eine Kontakt-Leiste am unteren Rand mit den Knöpfen „Anrufen“, „Route“ und, wenn der Kunde sie einrichtet, „WhatsApp“,
- die Öffnungszeiten im Seitencode in maschinenlesbarer Form (strukturierte Daten), die Suchmaschinen lesen können,
- eine E-Mail vor Feiertagen, die den Kunden bittet, seine Zeiten für diese Tage zu prüfen, und
- im Kundenportal der Knopf „Für Google kopieren“, der die Zeiten in der Reihenfolge von Google in die Zwischenablage legt, damit der Kunde sie in sein Google-Unternehmensprofil einträgt.
(3) Die Funktion rechnet die Feiertage des Bundeslands ein, das der Kunde wählt; je Feiertag kann der Kunde eigene Zeiten eintragen. Feiertage, die nur örtlich gelten, sowie Heiligabend und Silvester zeigt das Kundenportal als „bitte prüfen“. Alle Zeiten gelten in der Zeitzone Europe/Berlin.
(4) Beim Aufruf der Website lädt der Browser des Besuchers ein kleines Programm und die Öffnungszeiten von project.webklar.com, einem Server der WEBklar GbR bei der Hetzner Online GmbH in Falkenstein (Deutschland). Dabei werden keine Cookies gesetzt, und es wird nichts im Browser gespeichert. Das Programm lädt nichts von anderen Anbietern. WhatsApp und Google Maps werden erst nach dem Klick des Besuchers auf „WhatsApp“ oder „Route“ geöffnet; vorher werden keine Daten an diese Dienste übertragen.
(5) Das Team von WEBklar kann die Hinweis-Leiste und die Kontakt-Leiste für eine Website abschalten, etwa wenn die Seite schon eine eigene Anruf-Leiste hat. Auf Wunsch des Kunden können Mitarbeiter die Öffnungszeiten in der Kundenansicht für ihn ändern; ein Anspruch darauf besteht nicht (Nr. 5 Abs. 1). Den Haken für den WhatsApp-Hinweis (Nr. 6 Abs. 3) setzen sie nicht.
## 3. Einbau und Freischaltung
(1) Der Einbau des Programms erfolgt automatisch nur bei Websites, die WEBklar hostet und über den eigenen Veröffentlichungsablauf bereitstellt: Beim nächsten Veröffentlichen setzt WEBklar die Einbau-Zeile in jede Seite ein, und Hinweis-Leiste, Kontakt-Leiste und Angaben für Suchmaschinen erscheinen ohne weiteren Aufwand. In allen anderen Fällen, etwa bei einer Website bei einem anderen Anbieter, setzt das Team die Einbau-Zeile von Hand ein. Hat WEBklar keinen Zugriff auf die Website, erhält der Kunde die Zeile und setzt sie selbst oder durch seinen Anbieter ein.
(2) Die Tabelle, den Status und die Kurzanzeige setzt das Team an den Stellen ein, an denen auf der Website feste Öffnungszeiten stehen oder stehen sollen, und ersetzt dort die fest eingetragenen Zeiten. In jedem dieser Platzhalter bleibt eine statische Ersatz-Tabelle stehen. Sie erscheint, wenn der Browser kein JavaScript ausführt, wenn die Daten nicht erreichbar oder älter als 24 Stunden sind und nachdem die Funktion abgeschaltet wurde. Sie zeigt den Wochenplan zum Zeitpunkt des Einbaus.
(3) WEBklar schaltet die Funktion frei, sobald der Einbau abgeschlossen ist und der Kunde Öffnungszeiten eingetragen hat (mindestens ein geöffneter Tag im Hauptplan). Solange der Kunde keine Zeiten eingetragen hat, kann das Team die Tabelle nicht einbauen.
(4) Der Einbau dauert erfahrungsgemäß meist wenige Tage. Das ist eine Einschätzung und keine verbindliche Zusage eines Termins.
## 4. Einrichtung
(1) Die Einrichtung der Funktion rechnet WEBklar als Arbeitszeit auf das Stundenkontingent des Wartungs-Abos an (§ 4 der Abo-Bedingungen). Reicht das verfügbare Kontingent nicht aus, führt WEBklar die Einrichtung trotzdem aus. Die fehlende Zeit ist ein Vorschuss, den die folgenden Monatskontingente ausgleichen. Eine zusätzliche Rechnung gibt es dafür nicht. Endet das Wartungs-Abo, solange noch ein Vorschuss offen ist, verfällt der Rest zugunsten des Kunden.
(2) Hat WEBklar die Funktion auf Wunsch des Kunden schon bei der Erstellung seiner Website eingebaut, richtet sich die Abrechnung der Einrichtung nach der Vereinbarung zum Projekt und nicht nach dem Stundenkontingent. Auch dann läuft die Funktion nur, solange das Wartungs-Abo läuft.
(3) Im Umfang der Einrichtung ist ein Einbau je Website enthalten, einschließlich der Aktualisierung der Ersatz-Tabelle beim Abschalten (Nr. 9 Abs. 4). Weitere Wünsche, etwa zusätzliche Stellen für die Anzeige, eine Sondergestaltung oder andere Änderungen an der Website, gehören nicht zur Einrichtung. Arbeiten, die nicht zur Einrichtung gehören, führt WEBklar nur nach Absprache vor der Ausführung und nach Freigabe des Kunden in Textform aus; es gilt § 4 der Abo-Bedingungen.
## 5. Nicht enthalten
(1) Nicht enthalten sind:
- die Übertragung der Öffnungszeiten zu Google: WEBklar schreibt nichts automatisch in das Google-Unternehmensprofil des Kunden; der Kunde trägt die Zeiten dort selbst ein, der Knopf „Für Google kopieren“ und die Erinnerung vor Feiertagen helfen dabei,
- Feiertage einer Gemeinde und andere örtliche Besonderheiten (zum Beispiel Brückentage oder Stadtfeste); der Kunde trägt sie als Ausnahmen selbst ein,
- eine Zusage, dass Suchmaschinen die Angaben im Seitencode übernehmen oder wie sie sie anzeigen,
- die Pflege der Zeiten und der Hinweise durch WEBklar und
- Änderungen an der Website, die über den Einbau nach Nr. 3 hinausgehen (Nr. 4 Abs. 3).
## 6. Pflichten des Kunden
(1) Der Kunde prüft seine Zeiten, Feiertage und Hinweistexte vor dem Speichern und hält sie aktuell. Er wählt das richtige Bundesland und trägt örtliche Feiertage und Abweichungen selbst ein.
(2) Preise und Rabatte in Hinweisen gibt er rechtmäßig an, zum Beispiel als Endpreise und, bei Preisermäßigungen für Waren, mit dem niedrigsten Preis der letzten 30 Tage. Hinweise zu Preisen sind Angaben des Kunden, nicht von WEBklar.
(3) Die Kontaktangaben (Telefonnummer, Anschrift, WhatsApp-Nummer) sind auf der Website öffentlich sichtbar. Der Kunde trägt nur Angaben ein, die er veröffentlichen darf. Die WhatsApp-Schaltfläche schaltet er erst ein, nachdem er den WhatsApp-Hinweis in seine Datenschutzerklärung aufgenommen hat, und bestätigt das im Kundenportal mit einem Haken.
(4) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einbau dort den Datenschutz-Absatz zu Öffnungszeiten live ein; das gehört zum Einbau. Liegt sie woanders, etwa bei einem externen Generator, erhält der Kunde den Absatz als Textbaustein und fügt ihn selbst ein. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt der Kunde verantwortlich (§ 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf, weist WEBklar den Kunden darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht.
(5) Der Kunde prüft nach dem Einbau, ob die Anzeige auf seiner Website stimmt, und meldet Fehler unverzüglich im Chat des Kundenportals.
(6) Er entfernt oder verändert das Programm von WEBklar nicht. Wünscht er die Hinweis-Leiste oder die Kontakt-Leiste nicht, teilt er das dem Team mit (Nr. 2 Abs. 5).
## 7. Verfügbarkeit und Aktualität
(1) Das Verfügbarkeitsziel nach § 5 der Abo-Bedingungen gilt für die Website, nicht für die Live-Anzeige. Störungen der Anzeige behebt WEBklar in angemessener Frist.
(2) Ist die Anzeige nicht erreichbar oder sind die Daten älter als 24 Stunden, bleibt auf der Website die Ersatz-Tabelle stehen (Nr. 3 Abs. 2); Hinweis-Leiste und Kontakt-Leiste erscheinen dann nicht.
(3) Änderungen, die der Kunde im Kundenportal speichert, erscheinen nicht sofort auf der Website, sondern mit einer Verzögerung von einigen Minuten (bis zu etwa zehn Minuten). Der Status folgt der Zeitzone Europe/Berlin; geht die Uhr des Geräts des Besuchers um mehr als fünf Minuten falsch, rechnet das Programm mit der Serverzeit von WEBklar.
## 8. Haftung
(1) Die Live-Anzeige ist ein Hilfsmittel. Die Richtigkeit der Zeiten, Ausnahmen, Hinweise und Kontaktangaben liegt beim Kunden: WEBklar zeigt die Angaben des Kunden an und prüft sie nicht auf Richtigkeit.
(2) Die Feiertage stammen aus einer Feiertagsliste für das gewählte Bundesland. WEBklar beseitigt Fehler in den Feiertagsdaten und andere Softwarefehler nach Meldung in angemessener Frist (Nr. 7 Abs. 1). Für Schäden daraus gelten die Absätze 3 bis 5.
(3) Für entgangenen Gewinn und Folgeschäden, etwa weil ein Besucher wegen einer falschen oder fehlenden Angabe vor verschlossener Tür steht, haftet WEBklar nur im Rahmen der Absätze 4 und 5.
(4) WEBklar haftet unbeschränkt bei Vorsatz und grober Fahrlässigkeit, bei Verletzung von Leben, Körper oder Gesundheit, nach dem Produkthaftungsgesetz und soweit WEBklar eine Garantie übernommen hat.
(5) Bei leicht fahrlässiger Verletzung einer wesentlichen Vertragspflicht haftet WEBklar nur für den vertragstypischen, vorhersehbaren Schaden. Wesentlich sind Pflichten, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags überhaupt erst ermöglicht und auf deren Einhaltung der Kunde regelmäßig vertrauen darf. Im Übrigen ist die Haftung für leichte Fahrlässigkeit ausgeschlossen.
(6) Diese Regeln gelten auch zugunsten der Mitarbeiter und Beauftragten von WEBklar. Dieser Abschnitt entspricht § 13 Abs. 1 und 2 der Abo-Bedingungen und gilt unabhängig davon, nach welcher Fassung der Abo-Bedingungen das Wartungs-Abo besteht.
(7) Gesetzliche Rechte des Kunden, die sich nicht ausschließen lassen, bleiben unberührt. Der Haftungsrahmen des Kunden gegenüber seinen Besuchern und Kunden bleibt ebenfalls unberührt.
## 9. Ende der Leistung
(1) Auf Wunsch des Kunden in Textform (im Chat des Kundenportals oder per E-Mail) schaltet WEBklar Öffnungszeiten live jederzeit ab; für den Widerruf gilt Nr. 11.
(2) Die Funktion läuft, solange das Wartungs-Abo läuft. Endet es, auch durch einen Wechsel ins Hosting-Abo, läuft die Funktion noch 30 Tage unverändert weiter; danach schaltet WEBklar sie ab. Das Gleiche gilt beim Widerruf eines Wechsels ins Wartungs-Abo. WEBklar informiert den Kunden per E-Mail. Die Eingaben des Kunden bleiben nach dem Abschalten 30 Tage gespeichert und werden danach gelöscht; beginnt schon in den 30 Tagen Weiterlauf wieder ein Wartungs-Abo, läuft die Funktion einfach weiter, beginnt es in den 30 Tagen danach, schaltet WEBklar die Funktion mit den vorhandenen Eingaben ohne weitere Kosten wieder ein.
(3) Sobald ein neues Wartungs-Abo beginnt, solange die Eingaben des Kunden noch gespeichert sind (Absatz 4), läuft die Funktion mit den vorhandenen Eingaben weiter oder WEBklar schaltet sie ohne weitere Kosten wieder ein.
(4) Mit dem Abschalten verschwinden Hinweis-Leiste, Kontakt-Leiste, Live-Status und die Angaben für Suchmaschinen; auf der Website steht wieder die Ersatz-Tabelle (Nr. 3 Abs. 2). Hat sich der Wochenplan seit dem Einbau geändert, aktualisiert WEBklar die Ersatz-Tabelle einmal beim Abschalten; das gehört zum Einbau nach Nr. 4 Abs. 3. Hat WEBklar keinen Zugriff auf die Website, erhält der Kunde den aktuellen Text und setzt ihn selbst ein. Nach dem Abschalten wegen des Endes des Abos bleiben die Eingaben des Kunden noch weitere 30 Tage gespeichert und werden danach gelöscht; bis dahin kann der Kunde sie im Kundenportal ansehen.
(5) Schaltet WEBklar die Funktion auf Wunsch des Kunden oder aus einem anderen Grund als dem Ende des Abos ab, löscht WEBklar die Eingaben des Kunden auf sein Verlangen in Textform, spätestens mit dem Löschen des Kundenkontos.
## 10. Änderungen und Einstellung
(1) WEBklar darf die Technik von Öffnungszeiten live verbessern, wenn der Leistungsumfang nicht schlechter wird (§ 3 Abs. 7 der Abo-Bedingungen). Ändert sich dabei, welche Daten die Funktion verarbeitet, passt WEBklar die Datenschutzerklärung an und informiert den Kunden, weil sich dann sein Datenschutz-Absatz (Nr. 6 Abs. 4) ändern kann.
(2) WEBklar darf die Funktion für eine Website mit einer Frist von vier Wochen beenden oder für alle Kunden mit einer Frist von drei Monaten einstellen, wenn
- ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form unzulässig macht oder nur mit wesentlich anderer Technik erlaubt und der Kunde einer dafür nötigen neuen Fassung dieser Leistungsbeschreibung nicht zustimmt, oder
- eine Sicherheitslücke sich nicht anders beheben lässt.
Die Mitteilung erfolgt in Textform und nennt den Grund, die Merkmale der Änderung, den Zeitpunkt und bei Verbrauchern das Recht, den Vertrag nach § 327r Abs. 3 BGB zu beenden, mit seiner Frist.
(3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar die Funktion ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Kunden darüber unverzüglich in Textform mit dem Grund und nimmt den Betrieb wieder auf, sobald das zulässig und technisch möglich ist.
(4) Ist der Kunde Unternehmer, darf WEBklar die Funktion außerdem ohne besonderen Grund mit einer Frist von drei Monaten in Textform für alle Kunden einstellen. Der Kunde kann das Wartungs-Abo dann innerhalb von 30 Tagen nach der Mitteilung zum Zeitpunkt der Einstellung kündigen; die Mindestlaufzeit steht dem nicht entgegen.
(5) Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.
## 11. Widerruf
(1) Das Einschalten der Funktion ist Teil des Abos und kein eigener Vertrag. Für den Widerruf gilt die Widerrufsbelehrung der Abo-Bedingungen. Widerruft ein Verbraucher das Abo oder den Wechsel ins Wartungs-Abo, endet die Funktion.
(2) Hat der Kunde die Funktion zuvor einzeln gekauft, bleibt die Widerrufsbelehrung maßgeblich, die er bei der Bestellung erhalten hat.
## 12. Hinweise
(1) **Keine Rechtsberatung.** Der Datenschutz-Absatz, der WhatsApp-Hinweis und die Hinweise des Kundenportals zu Preisangaben sind Mustertexte für die Standardkonfiguration. WEBklar sorgt dafür, dass sie die Technik richtig beschreiben, und passt sie an, wenn sich die Technik ändert. Eine rechtliche Beratung im Einzelfall, etwa zu Preisangaben, zum Wettbewerbsrecht oder zum Datenschutz der Website des Kunden, schuldet WEBklar nicht.
(2) **Datenschutz.** Wie WEBklar im Kundenportal Daten zu Öffnungszeiten live verarbeitet, steht in der Datenschutzerklärung von WEBklar (Abschnitt 6e). Was beim Aufruf der Website des Kunden übertragen wird, beschreibt Nr. 2 Abs. 4; die Information der Besucher gehört in die Datenschutzerklärung der Website (Nr. 6 Abs. 4).
Anbieter: WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler. Es gelten ergänzend die Abo-Bedingungen und die Datenschutzerklärung.
+63
View File
@@ -0,0 +1,63 @@
Quelle: https://www.gesetze-im-internet.de/bgbeg/art_253anlage_1.html (Anlage 1), abgerufen 2026-10-02
(Fundstelle: BGBl. I 2013, S. 3663 – 3664;
bzgl. der einzelnen Änderungen vgl. Fußnote)
Widerrufsbelehrung
Widerrufsrecht
Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.
Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag .
Um Ihr Widerrufsrecht auszuüben, müssen Sie uns ( ) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.
Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.
Folgen des Widerrufs
Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.
Gestaltungshinweise:Fügen Sie einen der folgenden in Anführungszeichen gesetzten Textbausteine ein:
a)
im Falle eines Dienstleistungsvertrags oder eines Vertrags über die Lieferung von Wasser, Gas oder Strom, wenn sie nicht in einem begrenzten Volumen oder in einer bestimmten Menge zum Verkauf angeboten werden, von Fernwärme oder von digitalen Inhalten, die nicht auf einem körperlichen Datenträger geliefert werden: „des Vertragsabschlusses.“;
b)
im Falle eines Kaufvertrags: „, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die Waren in Besitz genommen haben bzw. hat.“;
c)
im Falle eines Vertrags über mehrere Waren, die der Verbraucher im Rahmen einer einheitlichen Bestellung bestellt hat und die getrennt geliefert werden: „, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die letzte Ware in Besitz genommen haben bzw. hat.“;
d)
im Falle eines Vertrags über die Lieferung einer Ware in mehreren Teilsendungen oder Stücken: „, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die letzte Teilsendung oder das letzte Stück in Besitz genommen haben bzw. hat.“;
e)
im Falle eines Vertrags zur regelmäßigen Lieferung von Waren über einen festgelegten Zeitraum hinweg: „, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die erste Ware in Besitz genommen haben bzw. hat.“
Fügen Sie Ihren Namen, Ihre Anschrift, Ihre Telefonnummer und Ihre E-Mail-Adresse ein.
Wenn Sie dazu verpflichtet sind, eine Funktion bereitzustellen, mit der der Verbraucher den online geschlossenen Vertrag widerrufen kann, fügen Sie Folgendes ein: „Sie können Ihr Widerrufsrecht auch online unter … [Internetadresse oder anderen geeigneten Hinweis darüber eingeben, wo die Widerrufsfunktion verfügbar ist] ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs. “ Wenn Sie dem Verbraucher die Wahl einräumen, die Information über seinen Widerruf des Vertrags auf Ihrer Webseite elektronisch auszufüllen und zu übermitteln, fügen Sie Folgendes ein: „Sie können das Muster-Widerrufsformular oder eine andere eindeutige Erklärung auch auf unserer Webseite … [Internet-Adresse einfügen] elektronisch ausfüllen und übermitteln. Machen Sie von dieser Möglichkeit Gebrauch, so werden wir Ihnen unverzüglich (z. B. per E-Mail) eine Bestätigung über den Eingang eines solchen Widerrufs übermitteln. “
Im Falle von Kaufverträgen, in denen Sie nicht angeboten haben, im Falle des Widerrufs die Waren selbst abzuholen, fügen Sie Folgendes ein: „Wir können die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder bis Sie den Nachweis erbracht haben, dass Sie die Waren zurückgesandt haben, je nachdem, welches der frühere Zeitpunkt ist.“
Wenn der Verbraucher Waren im Zusammenhang mit dem Vertrag erhalten hat:
a)
Fügen Sie ein:
–
„Wir holen die Waren ab.“ oder
–
„Sie haben die Waren unverzüglich und in jedem Fall spätestens binnen vierzehn Tagen ab dem Tag, an dem Sie uns über den Widerruf dieses Vertrags unterrichten, an … uns oder an [hier sind gegebenenfalls der Name und die Anschrift der von Ihnen zur Entgegennahme der Waren ermächtigten Person einzufügen] zurückzusenden oder zu übergeben. Die Frist ist gewahrt, wenn Sie die Waren vor Ablauf der Frist von vierzehn Tagen absenden.“
b)
fügen Sie ein:
–
„Wir tragen die Kosten der Rücksendung der Waren.“;
–
„Sie tragen die unmittelbaren Kosten der Rücksendung der Waren.“;
–
Wenn Sie bei einem Fernabsatzvertrag nicht anbieten, die Kosten der Rücksendung der Waren zu tragen, und die Waren aufgrund ihrer Beschaffenheit nicht normal mit der Post zurückgesandt werden können: „Sie tragen die unmittelbaren Kosten der Rücksendung der Waren in Höhe von … EUR [Betrag einfügen].“, oder, wenn die Kosten vernünftigerweise nicht im Voraus berechnet werden können: „Sie tragen die unmittelbaren Kosten der Rücksendung der Waren. Die Kosten werden auf höchstens etwa … EUR [Betrag einfügen] geschätzt.“ oder
–
Wenn die Waren bei einem außerhalb von Geschäftsräumen geschlossenen Vertrag aufgrund ihrer Beschaffenheit nicht normal mit der Post zurückgesandt werden können und zum Zeitpunkt des Vertragsschlusses zur Wohnung des Verbrauchers gebracht worden sind: „Wir holen die Waren auf unsere Kosten ab.“ und
c)
fügen Sie ein: „Sie müssen für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist.“
Im Falle eines Vertrags, der die Erbringung von Dienstleistungen oder die Lieferung von Wasser, Gas oder Strom, wenn sie nicht in einem begrenzten Volumen oder in einer bestimmten Menge zum Verkauf angeboten werden, oder von Fernwärme zum Gegenstand hat, fügen Sie Folgendes ein: „Haben Sie verlangt, dass die Dienstleistungen oder Lieferung von Wasser/Gas/Strom/Fernwärme [Unzutreffendes streichen] während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht. “
+30
View File
@@ -0,0 +1,30 @@
Quelle: https://www.gesetze-im-internet.de/bgbeg/art_253anlage_2.html (Anlage 2), abgerufen 2026-10-02
(Fundstelle: BGBl. I 2013, S. 3665;
bzgl. der einzelnen Änderungen vgl. Fußnote)
Muster-Widerrufsformular
(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)
–
An [hier ist der Name, die Anschrift und die E-Mail-Adresse des Unternehmers durch den Unternehmer einzufügen]:
–
Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)
–
Bestellt am (*)/erhalten am (*)
–
Name des/der Verbraucher(s)
–
Anschrift des/der Verbraucher(s)
–
Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)
–
Datum
(*) Unzutreffendes streichen.
@@ -0,0 +1,103 @@
Normen, auf die sich die Texte der Auftragsbestätigung, der Kundenseiten und der Mails stützen (Auszüge, unverändert, abgerufen 2026-10-06 von gesetze-im-internet.de).
Die Datei dient als Nachweis der Zählung; test/auftraegeRechtstexte.test.js gleicht die Zitate in den Texten mit ihr ab.
Prüfung vom 06.10.2026 (zweiter Abruf von § 649 und § 650 BGB): Der Kostenanschlag steht in § 649 BGB (amtliche Überschrift „Kostenanschlag“). Absatz 2 lautet: „Ist eine solche Überschreitung des Anschlags zu erwarten, so hat der Unternehmer dem Besteller unverzüglich Anzeige zu machen.“ § 650 BGB regelt dagegen den Werklieferungsvertrag und Verbraucherverträge über die Herstellung digitaler Produkte; ein Verweis auf „§ 650“ für den Kostenanschlag ist falsch. Der Satz „Zeichnet sich eine deutliche Überschreitung ab, melden wir uns vorher.“ (AB_TEXTE.aufwand) stützt sich auf § 649 Absatz 2.
Quelle: https://www.gesetze-im-internet.de/bgb/__312f.html (Auszug: § 312f Absatz 1 und 2 BGB)
(1) Bei außerhalb von Geschäftsräumen geschlossenen Verträgen ist der Unternehmer verpflichtet, dem Verbraucher alsbald auf Papier zur Verfügung zu stellen
1.
eine Abschrift eines Vertragsdokuments, das von den Vertragsschließenden so unterzeichnet wurde, dass ihre Identität erkennbar ist, oder
2.
eine Bestätigung des Vertrags, in der der Vertragsinhalt wiedergegeben ist.
Wenn der Verbraucher zustimmt, kann für die Abschrift oder die Bestätigung des Vertrags auch ein anderer dauerhafter Datenträger verwendet werden. Die Bestätigung nach Satz 1 muss die in Artikel 246a des Einführungsgesetzes zum Bürgerlichen Gesetzbuche genannten Angaben nur enthalten, wenn der Unternehmer dem Verbraucher diese Informationen nicht bereits vor Vertragsschluss in Erfüllung seiner Informationspflichten nach § 312d Absatz 1 auf einem dauerhaften Datenträger zur Verfügung gestellt hat.
(2) Bei Fernabsatzverträgen ist der Unternehmer verpflichtet, dem Verbraucher eine Bestätigung des Vertrags, in der der Vertragsinhalt wiedergegeben ist, innerhalb einer angemessenen Frist nach Vertragsschluss, spätestens jedoch bei der Lieferung der Ware oder bevor mit der Ausführung der Dienstleistung begonnen wird, auf einem dauerhaften Datenträger zur Verfügung zu stellen. Die Bestätigung nach Satz 1 muss die in Artikel 246a des Einführungsgesetzes zum Bürgerlichen Gesetzbuche genannten Angaben enthalten, es sei denn, der Unternehmer hat dem Verbraucher diese Informationen bereits vor Vertragsschluss in Erfüllung seiner Informationspflichten nach § 312d Absatz 1 auf einem dauerhaften Datenträger zur Verfügung gestellt.
Quelle: https://www.gesetze-im-internet.de/bgb/__355.html (Auszug: § 355 Absatz 1 BGB)
(1) Wird einem Verbraucher durch Gesetz ein Widerrufsrecht nach dieser Vorschrift eingeräumt, so sind der Verbraucher und der Unternehmer an ihre auf den Abschluss des Vertrags gerichteten Willenserklärungen nicht mehr gebunden, wenn der Verbraucher seine Willenserklärung fristgerecht widerrufen hat. Der Widerruf erfolgt durch Erklärung gegenüber dem Unternehmer. Aus der Erklärung muss der Entschluss des Verbrauchers zum Widerruf des Vertrags eindeutig hervorgehen. Der Widerruf muss keine Begründung enthalten. Zur Fristwahrung genügt die rechtzeitige Absendung des Widerrufs.
Quelle: https://www.gesetze-im-internet.de/bgb/__356.html (Auszug: § 356 Absatz 4 bis 6 BGB)
(4) Das Widerrufsrecht erlischt spätestens zwölf Monate und 14 Tage nach dem in Absatz 2 oder § 355 Absatz 2 Satz 2 genannten Zeitpunkt. Bei Verträgen über Finanzdienstleistungen gilt Satz 1 nicht, wenn der Verbraucher nicht nach Artikel 246b § 1 Absatz 1 Nummer 16 des Einführungsgesetzes zum Bürgerlichen Gesetzbuche über sein Widerrufsrecht belehrt wurde.
(5) Das Widerrufsrecht erlischt bei Verträgen über die Erbringung von Dienstleistungen auch unter folgenden Voraussetzungen:
1.
bei einem Vertrag, der den Verbraucher nicht zur Zahlung eines Preises verpflichtet, wenn der Unternehmer die Dienstleistung vollständig erbracht hat,
2.
bei einem Vertrag, der den Verbraucher zur Zahlung eines Preises verpflichtet, mit der vollständigen Erbringung der Dienstleistung, wenn der Verbraucher vor Beginn der Erbringung
a)
ausdrücklich zugestimmt hat, dass der Unternehmer mit der Erbringung der Dienstleistung vor Ablauf der Widerrufsfrist beginnt,
b)
bei einem außerhalb von Geschäftsräumen geschlossenen Vertrag die Zustimmung nach Buchstabe a auf einem dauerhaften Datenträger übermittelt hat und
c)
seine Kenntnis davon bestätigt hat, dass sein Widerrufsrecht mit vollständiger Vertragserfüllung durch den Unternehmer erlischt,
3.
bei einem Vertrag, bei dem der Verbraucher den Unternehmer ausdrücklich aufgefordert hat, ihn aufzusuchen, um Reparaturarbeiten auszuführen, mit der vollständigen Erbringung der Dienstleistung, wenn der Verbraucher die in Nummer 2 Buchstabe a und b genannten Voraussetzungen erfüllt hat,
4.
bei einem Vertrag über die Erbringung von Finanzdienstleistungen, wenn der Vertrag von beiden Seiten auf ausdrücklichen Wunsch des Verbrauchers vollständig erfüllt ist, bevor der Verbraucher sein Widerrufsrecht ausübt.
(6) Das Widerrufsrecht erlischt bei Verträgen über die Bereitstellung von nicht auf einem körperlichen Datenträger befindlichen digitalen Inhalten auch unter folgenden Voraussetzungen:
1.
bei einem Vertrag, der den Verbraucher nicht zur Zahlung eines Preises verpflichtet, wenn der Unternehmer mit der Vertragserfüllung begonnen hat,
2.
bei einem Vertrag, der den Verbraucher zur Zahlung eines Preises verpflichtet, wenn
a)
der Unternehmer mit der Vertragserfüllung begonnen hat,
b)
der Verbraucher ausdrücklich zugestimmt hat, dass der Unternehmer mit der Vertragserfüllung vor Ablauf der Widerrufsfrist beginnt,
c)
der Verbraucher seine Kenntnis davon bestätigt hat, dass durch seine Zustimmung nach Buchstabe b mit Beginn der Vertragserfüllung sein Widerrufsrecht erlischt, und
d)
der Unternehmer dem Verbraucher eine Bestätigung gemäß § 312f zur Verfügung gestellt hat.
Quelle: https://www.gesetze-im-internet.de/bgb/__356a.html (Auszug: § 356a Absatz 2 und 4 BGB)
(2) Die Widerrufsfunktion muss dem Verbraucher ermöglichen, eine Widerrufserklärung an den Unternehmer zu übermitteln und dem Unternehmer in oder mit der Widerrufserklärung ohne Weiteres folgende Informationen bereitzustellen oder zu bestätigen:
1.
den Namen des Verbrauchers,
2.
Angaben zur Identifizierung des Vertrags oder des Teils des Vertrags, den der Verbraucher widerrufen möchte,
3.
Angaben zum elektronischen Kommunikationsmittel, mit welchem dem Verbraucher eine Eingangsbestätigung für den Widerruf zu übermitteln ist.
(4) Der Unternehmer hat dem Verbraucher, wenn dieser die Bestätigungsfunktion aktiviert hat, auf einem dauerhaften Datenträger unverzüglich eine Eingangsbestätigung zu übermitteln, die zumindest den Inhalt der Widerrufserklärung nach Absatz 2 sowie das Datum und die Uhrzeit ihres Eingangs enthält.
Quelle: https://www.gesetze-im-internet.de/bgb/__357a.html (Auszug: § 357a Absatz 2 BGB)
(2) Der Verbraucher hat Wertersatz für die bis zum Widerruf erbrachten Dienstleistungen, für die der Vertrag die Zahlung eines Preises vorsieht, oder die bis zum Widerruf erfolgte Lieferung von Wasser, Gas oder Strom in nicht bestimmten Mengen oder nicht begrenztem Volumen oder von Fernwärme zu leisten, wenn
1.
der Verbraucher von dem Unternehmer ausdrücklich verlangt hat, dass mit der Leistung vor Ablauf der Widerrufsfrist begonnen werden soll,
2.
bei einem außerhalb von Geschäftsräumen geschlossenen Vertrag der Verbraucher das Verlangen nach Nummer 1 auf einem dauerhaften Datenträger übermittelt hat und
3.
der Unternehmer den Verbraucher nach Artikel 246a § 1 Absatz 2 Satz 1 Nummer 1 und 3 des Einführungsgesetzes zum Bürgerlichen Gesetzbuche ordnungsgemäß informiert hat.
Bei der Berechnung des Wertersatzes ist der vereinbarte Gesamtpreis zu Grunde zu legen. Ist der vereinbarte Gesamtpreis unverhältnismäßig hoch, so ist der Wertersatz auf der Grundlage des Marktwerts der erbrachten Leistung zu berechnen.
Quelle: https://www.gesetze-im-internet.de/bgbeg/art_246a__1.html (Auszug: Artikel 246a § 1 Absatz 3 EGBGB)
(3) Der Unternehmer hat den Verbraucher auch zu informieren, wenn
1.
dem Verbraucher nach § 312g Absatz 2 Nummer 1, 2, 5 und 7 bis 13 des Bürgerlichen Gesetzbuchs ein Widerrufsrecht nicht zusteht, dass der Verbraucher seine Willenserklärung nicht widerrufen kann, oder
2.
das Widerrufsrecht des Verbrauchers nach § 312g Absatz 2 Nummer 3, 4 und 6 sowie § 356 Absatz 5 und 6 des Bürgerlichen Gesetzbuchs vorzeitig erlöschen kann, über die Umstände, unter denen der Verbraucher ein zunächst bestehendes Widerrufsrecht verliert.
Quelle: https://www.gesetze-im-internet.de/bgb/__640.html (Auszug: § 640 Absatz 2 BGB)
(2) Als abgenommen gilt ein Werk auch, wenn der Unternehmer dem Besteller nach Fertigstellung des Werks eine angemessene Frist zur Abnahme gesetzt hat und der Besteller die Abnahme nicht innerhalb dieser Frist unter Angabe mindestens eines Mangels verweigert hat. Ist der Besteller ein Verbraucher, so treten die Rechtsfolgen des Satzes 1 nur dann ein, wenn der Unternehmer den Besteller zusammen mit der Aufforderung zur Abnahme auf die Folgen einer nicht erklärten oder ohne Angabe von Mängeln verweigerten Abnahme hingewiesen hat; der Hinweis muss in Textform erfolgen.
Quelle: https://www.gesetze-im-internet.de/bgb/__649.html (§ 649 BGB, erneut abgerufen 2026-10-06; die amtliche Überschrift lautet „Kostenanschlag“)
§ 649 Kostenanschlag
(1) Ist dem Vertrag ein Kostenanschlag zugrunde gelegt worden, ohne dass der Unternehmer die Gewähr für die Richtigkeit des Anschlags übernommen hat, und ergibt sich, dass das Werk nicht ohne eine wesentliche Überschreitung des Anschlags ausführbar ist, so steht dem Unternehmer, wenn der Besteller den Vertrag aus diesem Grund kündigt, nur der im § 645 Abs. 1 bestimmte Anspruch zu.
(2) Ist eine solche Überschreitung des Anschlags zu erwarten, so hat der Unternehmer dem Besteller unverzüglich Anzeige zu machen.
Quelle: https://www.gesetze-im-internet.de/bgb/__650.html (Auszug: § 650 Absatz 2 BGB, zur Abgrenzung: § 650 ist nicht der Kostenanschlag; er regelt den Werklieferungsvertrag und Verbraucherverträge über digitale Produkte)
§ 650 Werklieferungsvertrag; Verbrauchervertrag über die Herstellung digitaler Produkte
(2) Auf einen Verbrauchervertrag, bei dem der Unternehmer sich verpflichtet,
1.
digitale Inhalte herzustellen,
2.
einen Erfolg durch eine digitale Dienstleistung herbeizuführen oder
3.
einen körperlichen Datenträger herzustellen, der ausschließlich als Träger digitaler Inhalte dient,
sind die §§ 633 bis 639 über die Rechte bei Mängeln sowie § 640 über die Abnahme nicht anzuwenden. An die Stelle der nach Satz 1 nicht anzuwendenden Vorschriften treten die Vorschriften des Abschnitts 3 Titel 2a. Die §§ 641, 644 und 645 sind mit der Maßgabe anzuwenden, dass an die Stelle der Abnahme die Bereitstellung des digitalen Produkts (§ 327b Absatz 3 bis 5) tritt.
Quelle: https://www.gesetze-im-internet.de/ao_1977/__147.html (Auszug: § 147 Absatz 1 Nummer 2 bis 4, Absatz 3 Satz 1 und Absatz 4 AO)
2.
die empfangenen Handels- oder Geschäftsbriefe,
3.
Wiedergaben der abgesandten Handels- oder Geschäftsbriefe,
4.
Buchungsbelege,
(3) Die in Absatz 1 Nummer 1 und 4a aufgeführten Unterlagen sind zehn Jahre, die in Absatz 1 Nummer 4 aufgeführten Unterlagen acht Jahre und die sonstigen in Absatz 1 aufgeführten Unterlagen sechs Jahre aufzubewahren, sofern nicht in anderen Steuergesetzen kürzere Aufbewahrungsfristen zugelassen sind.
(4) Die Aufbewahrungsfrist beginnt mit dem Schluss des Kalenderjahrs, in dem die letzte Eintragung in das Buch gemacht, das Inventar, die Eröffnungsbilanz, der Jahresabschluss oder der Lagebericht aufgestellt, der Handels- oder Geschäftsbrief empfangen oder abgesandt worden oder der Buchungsbeleg entstanden ist, ferner die Aufzeichnung vorgenommen worden ist oder die sonstigen Unterlagen entstanden sind.
@@ -0,0 +1,15 @@
# Datenschutzerklärung von WEBklar: Abschnitt 6c „Online-Terminbuchung auf Websites unserer Kunden“
Fassung 2026-10-05 (Stand der Datenschutzerklärung 05.10.2026). Markdown-Quelle des Abschnitts 6c in `public/datenschutz.html`; der Abschnitt steht dort wörtlich so (`test/legalTexts.test.js` prüft, dass beide übereinstimmen). Er beschreibt, was WEBklar selbst verarbeitet: als Auftragsverarbeiter für die Gäste der Betriebe und als Verantwortlicher für die Betriebsdaten, den Entwurf des Fragebogens und die Bestellung im Kundenportal. Den Text für die Website des Betriebs enthält `termine-datenschutz-absatz.md`.
## Abschnitt (HTML)
```html
<h2 id="termine">6c. Online-Terminbuchung auf Websites unserer Kunden</h2>
<p>Kunden mit Wartungs-Abo können auf ihrer eigenen Website eine Online-Terminbuchung anbieten („Terminplaner“), sobald wir die Funktion für sie freigeschaltet haben. Dabei verarbeiten wir die Daten der Personen, die dort einen Termin buchen („Gäste“), ausschließlich als Auftragsverarbeiter nach den Weisungen des jeweiligen Kunden, der dafür Verantwortlicher ist. Wir verwenden diese Daten nicht für eigene Zwecke. Verantwortliche sind wir für die Betriebsdaten des Kunden im Kundenportal, für den Entwurf seines Fragebogens und für seine Bestellung. Diese Datenschutzerklärung gilt für unsere eigenen Seiten und das Kundenportal. Wer auf der Website eines Kunden einen Termin bucht, findet die Informationen zur Buchung (Zwecke, Rechtsgrundlagen, Speicherdauer, Rechte) in der Datenschutzerklärung dieser Website. Anfragen von Gästen zu ihren Daten leiten wir an den Kunden weiter.</p>
<p>Für den Betrieb des Terminplaners speichern wir im Auftrag des Kunden: Name, E-Mail-Adresse und, wenn der Kunde diese Felder eingeschaltet hat, Telefonnummer, Nachricht und weitere Angaben des Gastes; die gewählte Leistung, Person oder den gewählten Platz und den Termin; den Stand der Buchung (zum Beispiel bestätigt, abgesagt oder widerrufen) mit den Zeitpunkten; als Nachweis den Zeitpunkt der Bestätigung und der Zustimmungen sowie die Fassung der angezeigten Hinweise; gegebenenfalls den Wunsch nach einer Erinnerung. Der Terminplaner ist nicht für Leistungen der Heilkunde und der Gesundheitsversorgung bestimmt; Gäste werden gebeten, keine Gesundheitsangaben zu machen.</p>
<p>Der Kunde sieht, ändert und löscht die Termine im Kundenportal. Dort verarbeiten wir als Verantwortliche seine Einstellungen für den Terminplaner (Betriebsangaben, Leistungen, Namen und Zeiten der gebuchten Personen oder Plätze, Öffnungszeiten, Absagefristen und Texte), wie die übrigen Portal-Daten (Abschnitt 6) auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO. Bevor der Kunde den Terminplaner bestellt, füllt er im Kundenportal einen Fragebogen aus (Branche und Betrieb, Personen oder Plätze, Leistungen, Zeiten, Regeln, Angaben von Gästen, Benachrichtigungen und Website); seine Eingaben speichern wir als Entwurf seiner Einstellungen. Bestellt er nicht, löschen wir den Entwurf 90 Tage nach der letzten Änderung, bei der Löschung seines Kundenkontos zugleich mit dem Konto. Die Bestellung und ihren Nachweis beschreibt Abschnitt 7. Für jede Person oder jeden Platz kann der Kunde einen geheimen Link erzeugen, mit dem er die bestätigten Termine in seinem Kalenderprogramm abonniert; wer den Link kennt, sieht diese Termine. Der Kunde kann den Link jederzeit durch einen neuen ersetzen.</p>
<p>Im Namen des Kunden senden wir E-Mails an Gäste (Bestätigung der E-Mail-Adresse, Bestätigung, Änderung oder Absage eines Termins, Eingangsbestätigung eines Widerrufs und, wenn der Gast das gewählt hat, eine Erinnerung) und an den Kunden (neue Buchung, Absage, Widerruf). Der Versand läuft über den E-Mail-Dienst der Hetzner Online GmbH (Rechenzentrum Nürnberg, Abschnitt 8); als Absender erscheint der Kunde, Antworten gehen an ihn. Wir verschicken keine Werbung, setzen keine Tracking-Pixel ein und erhalten keine Kopie. Bis zum Versand liegt jede E-Mail in einer Warteschlange. Nach dem Versand löschen wir dort Empfängeradresse und Inhalt, den Eintrag selbst nach sieben Tagen; E-Mails, die nicht zugestellt werden konnten, löschen wir nach 48 Stunden.</p>
<p>Beim Aufruf einer Kundenwebsite mit Terminbuchung lädt der Browser das Programm der Terminbuchung von unserem Server (project.webklar.com) und schon beim Laden der Seite die Konfiguration mit den Texten und Leistungen. Ist die Terminbuchung unmittelbar in die Seite eingebaut, holt sie die freien Termine, sobald der Gast durchklickt; sitzt sie hinter einem Knopf, geschieht das erst nach dem Klick auf den Knopf. Dabei gelten die Protokolle in Abschnitt 3. Die Terminbuchung setzt keine Cookies und speichert nichts im Browser des Gastes. Zum Schutz vor Missbrauch und um die Zahl der Anfragen zu begrenzen (höchstens zehn Buchungen je zehn Minuten und je IP-Adresse), merken wir uns die Zeitpunkte der Aufrufe je IP-Adresse nur im Arbeitsspeicher des Servers, nicht dauerhaft; mit der Buchung speichern wir die IP-Adresse nicht. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere und störungsfreie Betrieb.</p>
<p>Löschfristen: Nicht bestätigte Buchungen löschen wir 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten, abgesagte oder abgelehnte Termine nach 30 Tagen, bestätigte Termine nach der Frist, die der Kunde eingestellt hat (Vorgabe 90 Tage, einstellbar von 14 bis 365 Tagen nach dem Termin). Nicht bestellte Entwürfe löschen wir 90 Tage nach der letzten Änderung. Endet das Wartungs-Abo oder schalten wir die Funktion ab, schalten wir den Terminplaner spätestens am folgenden Tag ab und löschen alle Termindaten und Einstellungen 30 Tage danach; beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schalten wir ihn mit den vorhandenen Daten wieder ein. Eine Ausnahme ist das Einrichtungs-Ticket, in dem wir die Einrichtung als Arbeitsnachweis festhalten (Abschnitt 6): Es bleibt mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen. In unseren Datensicherungen bleiben gelöschte Daten noch bis zu sieben Tage erhalten und werden dann überschrieben. Die Daten liegen auf unserem Server bei Hetzner im Rechenzentrum Falkenstein (Deutschland), wie die übrigen Portal-Daten (Abschnitt 3).</p>
```
@@ -0,0 +1,73 @@
# Datenschutz-Absatz „Online-Terminbuchung“ für die Website des Betriebs
Fassung 2026-10-05. Grundlage: `recherche-recht.md` Abschnitt 11.1. Der Absatz gehört in die Datenschutzerklärung der Website des **Betriebs** (der Betrieb ist Verantwortlicher, WEBklar Auftragsverarbeiter). Die Einbauanleitung für Mitarbeiter (`docs/funktionen/termine-einbau.md`, TP2) verweist hierher. Für die Datenschutzerklärung von WEBklar selbst gilt `termine-datenschutz-6c.md`.
Zwei Fassungen weiter unten: Sie und Du. Nimm die Fassung, die zur Datenschutzerklärung der Website passt (in der Regel zur `anrede` des Betriebs).
## Platzhalter und optionale Sätze
- `{{Firma}}`, `{{Anschrift}}`, `{{E-Mail}}`, `{{Telefon}}`: Betriebsangaben aus der Einstellung. `{{Tage}}`: Aufbewahrung nach dem Termin (`aufbewahrungTage`, Vorgabe 90, einstellbar 14 bis 365).
- `[[wenn …: …]]`: Satz oder Satzteil nur aufnehmen, wenn die genannte Einstellung aktiv ist; sonst samt Klammern streichen. Beginnt der Satzteil mit „und“, steht nach dem Doppelpunkt ein zweites Leerzeichen: Es ist das Leerzeichen zwischen dem Vordertext und „und“ und fällt mit dem Satzteil weg.
## Löschfristen (Vorgaben des Löschlaufs, im Text unten verwendet)
| Daten | Frist |
|---|---|
| Nicht bestätigte Buchung (Status „verfallen“) | 48 Stunden nach Ablauf der Bestätigungsfrist (30 Minuten) |
| Abgesagter oder abgelehnter Termin | 30 Tage |
| Bestätigter, erledigter oder nicht wahrgenommener Termin | `aufbewahrungTage` (Vorgabe 90) nach dem Ende des Termins |
| Terminanfrage, deren Beginn verstrichen ist | gilt als abgelehnt (durch das System), dann 30 Tage |
| Funktion abgeschaltet (zum Beispiel Wartungs-Abo beendet) | alle Termindaten 30 Tage nach dem Abschalten |
| Versandwarteschlange für E-Mails | Empfängeradresse und Inhalt werden nach dem Versand geleert, der Eintrag nach 7 Tagen gelöscht; nicht zustellbare Einträge nach 48 Stunden |
## Abweichungen von Abschnitt 11.1 der Recherche
1. **Server-Protokolle**: 11.1 sagt, IP-Adresse und Browserangaben würden „nicht gespeichert“. Der Server von WEBklar protokolliert sie aber kurzzeitig (Datenschutzerklärung von WEBklar Abschnitt 3: 14 Tage). Der Absatz sagt das jetzt offen.
2. **Rechtsgrundlage**: Bei einer sofort verbindlichen Buchung kommt der Vertrag mit der Bestätigung zustande; „vorvertragliche Maßnahmen“ passt nur für Terminanfragen. Beide Fälle stehen im Text. Neu: Widerruf über die Widerrufsfunktion (Art. 6 Abs. 1 lit. c DSGVO, § 356a BGB).
3. **Aufruf**: Das Skript der Terminbuchung wird beim Aufruf der Seite geladen, nicht erst beim Klick auf den Knopf; der Text sagt das.
4. **Nachweise**: Gespeichert werden auch der Zeitpunkt der Zustimmungen und die Fassung der angezeigten Texte (Spezifikation, Feld `nachweis`).
5. **Löschfristen** angepasst (siehe Tabelle); 11.1 kannte nur „nicht bestätigt 48 Stunden“, „abgesagt 30 Tage“ und „bestätigt {{90}} Tage“.
## Fassung Sie
> ### Online-Terminbuchung
>
> Auf unserer Website können Sie Termine online buchen. Dafür verarbeiten wir die folgenden Daten:
>
> **Welche Daten?** Ihren Namen, Ihre E-Mail-Adresse, den gewählten Termin, die gewählte Leistung und die gewählte Person oder den gewählten Platz[[wenn Telefon-Feld aktiv: , auf Wunsch Ihre Telefonnummer]][[wenn Nachricht oder Zusatzfelder aktiv: und die Angaben, die Sie uns im Formular mitteilen]]. Außerdem speichern wir, wann Sie den Termin bestätigt haben, welche Fassung der Hinweise Sie bei der Buchung gesehen haben und welche Zustimmungen Sie erteilt haben[[wenn Erinnerung angeboten: , zum Beispiel ob Sie eine Erinnerung gewünscht haben]].
>
> **Wofür und auf welcher Grundlage?** Wir brauchen die Daten, um Ihren Termin zu verwalten, Ihnen den Termin zu bestätigen und Sie über Änderungen oder Absagen zu informieren. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag über den gebuchten Termin, bei einer Terminanfrage vorvertragliche Maßnahmen auf Ihre Anfrage). [[wenn Erinnerung angeboten: Wenn Sie eine Erinnerung vor dem Termin wünschen, beruht diese auf Ihrer Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. Sie können sie jederzeit mit Wirkung für die Zukunft widerrufen, indem Sie uns eine kurze Nachricht senden oder den Termin über den Link in der E-Mail absagen.]] [[wenn Gäste Verbraucher sein können: Widerrufen Sie einen Vertrag über die Schaltfläche „Vertrag widerrufen“, speichern wir Ihre Erklärung mit Datum und Uhrzeit und senden Ihnen eine Eingangsbestätigung (Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB).]] Zum Schutz vor Missbrauch (zum Beispiel automatisierten Buchungen) und um die Zahl der Anfragen zu begrenzen, merken wir uns Ihre IP-Adresse kurzzeitig nur im Arbeitsspeicher des Servers, nicht dauerhaft (Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere Betrieb der Terminbuchung).
>
> **E-Mails.** Sie erhalten eine E-Mail, in der Sie Ihre E-Mail-Adresse und den Termin per Link bestätigen, danach eine Bestätigung[[wenn Erinnerung angeboten: und, wenn Sie das angekreuzt haben, eine Erinnerung]]. Sagen wir den Termin ab oder verschieben ihn, informieren wir Sie ebenfalls per E-Mail. Die E-Mails enthalten keine Werbung und keine Tracking-Pixel. Absender ist {{Firma}}, Ihre Antworten gehen an uns. Bis zum Versand liegt die E-Mail in einer Warteschlange; nach dem Versand wird dort die Empfängeradresse samt Inhalt gelöscht, nicht zustellbare E-Mails nach 48 Stunden.
>
> **Wer verarbeitet die Daten in unserem Auftrag?** Den Terminkalender betreibt die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, als unser Auftragsverarbeiter nach Art. 28 DSGVO. Die Daten liegen auf Servern der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, in Deutschland (Rechenzentren Falkenstein und Nürnberg). Der E-Mail-Versand läuft ebenfalls über Hetzner. Eine Übermittlung in Drittländer findet nicht statt.
>
> **Beim Aufruf der Terminbuchung.** Beim Aufruf dieser Seite lädt Ihr Browser das Programm der Terminbuchung vom Server der WEBklar GbR (project.webklar.com), beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei werden technisch bedingt Ihre IP-Adresse und Angaben zu Ihrem Browser übertragen. Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach 14 Tagen (Art. 6 Abs. 1 lit. f DSGVO; berechtigtes Interesse ist der sichere und funktionsfähige Betrieb). Wir setzen dafür keine Cookies und speichern nichts auf Ihrem Gerät.
>
> **Wie lange speichern wir?** Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir 48 Stunden nach Ablauf dieser Frist. Bestätigte Termine löschen wir {{Tage}} Tage nach dem Termin, abgesagte oder abgelehnte Termine 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht. Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.
>
> **Pflicht zur Angabe.** Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben. [[wenn Telefon hinterlegt: Sie können uns auch telefonisch erreichen: {{Telefon}}.]] Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte machen Sie bei der Buchung keine Angaben zu Ihrer Gesundheit.
>
> **Ihre Rechte.** Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit sowie das Recht, der Verarbeitung auf Grundlage berechtigter Interessen zu widersprechen (Art. 15 bis 21 DSGVO). Eine erteilte Einwilligung können Sie jederzeit widerrufen. Sie können sich bei einer Datenschutzaufsichtsbehörde beschweren. Verantwortlich ist {{Firma}}, {{Anschrift}}, E-Mail {{E-Mail}}.
## Fassung Du
> ### Online-Terminbuchung
>
> Auf unserer Website kannst du Termine online buchen. Dafür verarbeiten wir die folgenden Daten:
>
> **Welche Daten?** Deinen Namen, deine E-Mail-Adresse, den gewählten Termin, die gewählte Leistung und die gewählte Person oder den gewählten Platz[[wenn Telefon-Feld aktiv: , auf Wunsch deine Telefonnummer]][[wenn Nachricht oder Zusatzfelder aktiv: und die Angaben, die du uns im Formular mitteilst]]. Außerdem speichern wir, wann du den Termin bestätigt hast, welche Fassung der Hinweise du bei der Buchung gesehen hast und welche Zustimmungen du erteilt hast[[wenn Erinnerung angeboten: , zum Beispiel ob du eine Erinnerung gewünscht hast]].
>
> **Wofür und auf welcher Grundlage?** Wir brauchen die Daten, um deinen Termin zu verwalten, dir den Termin zu bestätigen und dich über Änderungen oder Absagen zu informieren. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag über den gebuchten Termin, bei einer Terminanfrage vorvertragliche Maßnahmen auf deine Anfrage). [[wenn Erinnerung angeboten: Wenn du eine Erinnerung vor dem Termin wünschst, beruht diese auf deiner Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. Du kannst sie jederzeit mit Wirkung für die Zukunft widerrufen, indem du uns eine kurze Nachricht sendest oder den Termin über den Link in der E-Mail absagst.]] [[wenn Gäste Verbraucher sein können: Widerrufst du einen Vertrag über die Schaltfläche „Vertrag widerrufen“, speichern wir deine Erklärung mit Datum und Uhrzeit und senden dir eine Eingangsbestätigung (Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB).]] Zum Schutz vor Missbrauch (zum Beispiel automatisierten Buchungen) und um die Zahl der Anfragen zu begrenzen, merken wir uns deine IP-Adresse kurzzeitig nur im Arbeitsspeicher des Servers, nicht dauerhaft (Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere Betrieb der Terminbuchung).
>
> **E-Mails.** Du erhältst eine E-Mail, in der du deine E-Mail-Adresse und den Termin per Link bestätigst, danach eine Bestätigung[[wenn Erinnerung angeboten: und, wenn du das angekreuzt hast, eine Erinnerung]]. Sagen wir den Termin ab oder verschieben ihn, informieren wir dich ebenfalls per E-Mail. Die E-Mails enthalten keine Werbung und keine Tracking-Pixel. Absender ist {{Firma}}, deine Antworten gehen an uns. Bis zum Versand liegt die E-Mail in einer Warteschlange; nach dem Versand wird dort die Empfängeradresse samt Inhalt gelöscht, nicht zustellbare E-Mails nach 48 Stunden.
>
> **Wer verarbeitet die Daten in unserem Auftrag?** Den Terminkalender betreibt die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, als unser Auftragsverarbeiter nach Art. 28 DSGVO. Die Daten liegen auf Servern der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, in Deutschland (Rechenzentren Falkenstein und Nürnberg). Der E-Mail-Versand läuft ebenfalls über Hetzner. Eine Übermittlung in Drittländer findet nicht statt.
>
> **Beim Aufruf der Terminbuchung.** Beim Aufruf dieser Seite lädt dein Browser das Programm der Terminbuchung vom Server der WEBklar GbR (project.webklar.com), beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei werden technisch bedingt deine IP-Adresse und Angaben zu deinem Browser übertragen. Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach 14 Tagen (Art. 6 Abs. 1 lit. f DSGVO; berechtigtes Interesse ist der sichere und funktionsfähige Betrieb). Wir setzen dafür keine Cookies und speichern nichts auf deinem Gerät.
>
> **Wie lange speichern wir?** Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir 48 Stunden nach Ablauf dieser Frist. Bestätigte Termine löschen wir {{Tage}} Tage nach dem Termin, abgesagte oder abgelehnte Termine 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht. Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.
>
> **Pflicht zur Angabe.** Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben. [[wenn Telefon hinterlegt: Du kannst uns auch telefonisch erreichen: {{Telefon}}.]] Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte mach bei der Buchung keine Angaben zu deiner Gesundheit.
>
> **Deine Rechte.** Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit sowie das Recht, der Verarbeitung auf Grundlage berechtigter Interessen zu widersprechen (Art. 15 bis 21 DSGVO). Eine erteilte Einwilligung kannst du jederzeit widerrufen. Du kannst dich bei einer Datenschutzaufsichtsbehörde beschweren. Verantwortlich ist {{Firma}}, {{Anschrift}}, E-Mail {{E-Mail}}.
+76
View File
@@ -0,0 +1,76 @@
# Hinweise, Haken und Knopftexte für Gäste (Terminplaner)
Fassung 2026-10-05. Grundlage: `recherche-recht.md` Abschnitt 11.2 (Hinweis unter dem Formular) und 11.3, `recherche-recht-zahlung.md` Abschnitt 7.3 C bis E (Haken, Widerrufshinweis, Widerrufsfunktion). Die Texte in den Abschnitten 1 bis 3 sind die Fassung aus `server/services/termine/rechtstexte.js` (Platzhalter in doppelten geschweiften Klammern); `test/termineRechtstexte.test.js` prüft, dass sie hier wörtlich stehen. Ändere sie deshalb nur in `rechtstexte.js` und übernimm sie hierher. Die Abschnitte 4 bis 6 geben Texte des Widgets und der Mails wieder (Knopftexte: `knopfText` in `server/services/termine/einstellungen.js`; Widerrufshinweis: Mailvorlage `server/mail-templates/G02 Termin bestaetigt.html`; Eingangsbestätigung: `server/mail-templates/G08 Eingang Widerruf.html`); maßgeblich sind die Mailvorlagen und das Widget, nicht dieses Dokument.
Platzhalter: `{{Firma}}` = Betriebsname, `{{Anschrift}}` = Anschrift des Betriebs, `{{n}}` = Stunden vor dem Termin (`erinnerung.stundenVorher`: 2, 12, 24 oder 48). Die Texte zeigen die Vorgabe von 90 Tagen Aufbewahrung nach dem Termin; der Wert kommt aus `aufbewahrungTage` (14 bis 365) der Einstellung.
## 1. Datenschutz-Hinweis unter dem Formular (Kurzfassung nach Art. 13 DSGVO)
Steht in der Zusammenfassung vor dem Absenden. Sie- oder Du-Fassung nach `anrede` des Betriebs. Neben dem Text zeigt das Widget einen Link auf `betrieb.datenschutzUrl` (die Datenschutzerklärung des Betriebs); die Betriebs-E-Mail steht bewusst nicht im Text. Es gibt keinen Haken dafür.
Fassung Sie:
```text
Datenschutz. Wir verwenden Ihre Angaben, um Ihren Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). Betrieben wird der Terminkalender in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland). Wir speichern Ihre Daten bis 90 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist. Es werden keine Cookies gesetzt. Bitte machen Sie keine Angaben zu Ihrer Gesundheit. Mehr erfahren Sie in unserer Datenschutzerklärung. Verantwortlich: {{Firma}}, {{Anschrift}}.
```
Fassung Du:
```text
Datenschutz. Wir verwenden deine Angaben, um deinen Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). Betrieben wird der Terminkalender in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland). Wir speichern deine Daten bis 90 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist. Es werden keine Cookies gesetzt. Bitte mach keine Angaben zu deiner Gesundheit. Mehr erfährst du in unserer Datenschutzerklärung. Verantwortlich: {{Firma}}, {{Anschrift}}.
```
Gegenüber Abschnitt 11.2 der Recherche: Die Löschfristen sind die des Löschlaufs (nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist, abgesagte Termine 30 Tage, vergangene Termine `aufbewahrungTage` nach dem Termin) und der Verantwortliche nennt zusätzlich die Anschrift.
## 2. Haken (Zustimmungen)
Wortlaut immer in der Sie-Form, auch bei Betrieben, die ihre Gäste duzen: Es sind Rechtstexte. Keiner der Haken ist vorangekreuzt.
| Haken | Wann | Pflicht | Wortlaut |
|---|---|---|---|
| Vorzeitiger Beginn | Verbraucher, mit Preis: bei sofort buchbaren Leistungen, wenn der Terminbeginn nicht nach dem Ende der Widerrufsfrist liegt (14 Tage ab dem Tag nach dem Vertragsschluss, Berliner Kalender, §§ 187, 188, 193 BGB; gerechnet mit dem Zeitpunkt der Buchung plus Haltezeit; R29); bei bepreisten Anfragen immer, weil der Tag der Annahme nicht feststeht (R31) | ja | Ich verlange ausdrücklich, dass {{Firma}} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB). |
| Stornobedingungen | der Betrieb hat Stornobedingungen hinterlegt und die gebuchte Leistung hat einen Preis (bei kostenlosen Leistungen fragt der Terminplaner nicht danach) | ja | Ich habe die Stornobedingungen gelesen. |
| Erinnerung | der Betrieb bietet Erinnerungen an | nein | Ich möchte {{n}} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen. |
## 3. Hinweis über dem Nachrichtenfeld
Bitte keine Gesundheitsangaben.
Der Terminplaner ist nicht für den Gesundheitsbereich bestimmt (Spezifikation, Vorgaben). Der Hinweis steht über jedem Freitextfeld.
## 4. Knopftexte (exakt)
| Fall | Knopf im Widget | Knopf auf der Bestätigungsseite |
|---|---|---|
| verbindlich, mit Preis | Zahlungspflichtig buchen | Zahlungspflichtig bestätigen |
| verbindlich, ohne Preis | Termin buchen | Termin bestätigen |
| Anfrage | Termin anfragen | Anfrage absenden |
| Widerruf (Funktion) | Vertrag widerrufen | Widerruf bestätigen |
## 5. Widerrufshinweis in der Bestätigungs-E-Mail
Er ersetzt nicht die Widerrufsbelehrung (`termine-widerrufsbelehrung-muster.md`), sondern führt zu ihr. Gilt für Buchungen von Verbrauchern, auch für Anfragen: Der Vertrag kommt mit der Annahme zustande, ist aber ebenfalls ein Fernabsatzvertrag, und die Frist läuft ab der Annahme (R55, R29). Die Widerrufsfunktion steht im Widget als zweiter Knopf im Fuß und im Knopfmodus dauerhaft neben dem Buchungsknopf (R54).
**Mit Widerrufsrecht** (Bestätigungs-E-Mail G02, vor der Belehrung; das Widget zeigt dort die ausführliche Belehrung zum Aufklappen):
> **Widerrufsrecht.** Wenn Sie Verbraucher sind, können Sie diesen Vertrag innerhalb von 14 Tagen ab Vertragsschluss ohne Angabe von Gründen widerrufen. Dafür nutzen Sie die Schaltfläche „Vertrag widerrufen“ im Terminbereich auf unserer Website oder den Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail. Die ausführliche Widerrufsbelehrung und das Muster-Widerrufsformular stehen in Ihrer Bestätigungs-E-Mail.
Maßgeblich für die Mail ist die Vorlage G02: Bis zum Beginn des Termins geht der Widerruf über den Knopf „Vertrag widerrufen“; danach über das Formular „Vertrag widerrufen“ auf der Website des Betriebs oder per E-Mail an den Betrieb. Die Verwaltungsseite bietet den Knopf nur bis zum Beginn des Termins an.
**Freizeit-Dienstleistung mit festem Termin** (§ 312g Abs. 2 Nr. 9 BGB; `recht.freizeit`), kein Widerrufsrecht, keine Belehrung, kein Formular, keine Funktion:
> **Für diese Buchung besteht kein Widerrufsrecht.** Ihre Buchung ist verbindlich; Absagen richten sich nach den Stornobedingungen.
Gäste, die nur Unternehmen sind (`recht.gaesteSind = unternehmer`): kein Hinweis.
## 6. Widerrufsfunktion (Texte)
- Schaltfläche oder Link: **Vertrag widerrufen** (im Terminbereich der Website, auf der Verwaltungsseite hinter „Termin verwalten“).
- Formular ohne Anmeldung: Name · Buchungsnummer · E-Mail-Adresse für die Eingangsbestätigung. Schaltfläche: **Widerruf bestätigen**.
- Eingangsbestätigung per E-Mail (G08; die Mailvorlage ist maßgeblich, hier nur der Kern. Die Mail enthält keine Eingaben des Gastes, nur die Buchungsnummer und den Eingang):
> Betreff: Eingangsbestätigung Ihres Widerrufs
>
> Wir bestätigen den Eingang Ihres Widerrufs. Inhalt Ihrer Erklärung: Widerruf des Vertrags zur Buchung {{nummer}}. Eingegangen am {{eingang}} (Datum und Uhrzeit). {{Nur im Block „zugeordnet“: Ihr Termin ist abgesagt.}} Absender: {{Firma}}.
Der Eingang muss mit Datum und Uhrzeit bestätigt werden (§ 356a BGB); das ist auch der Text der Belehrung („Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs“).
+966
View File
@@ -0,0 +1,966 @@
# Kanzlei-Paket Terminplaner (WEBklar)
Dieses Paket gehört zum Verkaufsweg des Terminplaners der WEBklar GbR (Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein). Es zeigt einer Kanzlei, was verkauft wird, wie der Ablauf ist, welche Texte Kunden und Gäste in Endfassung sehen, wo diese Texte im Programm stehen und was wir wissen möchten.
Fassungen: Leistungsbeschreibung, Auftragsverarbeitungsvertrag (AVV), Widerrufsbelehrung und Bestelltexte tragen die Fassung **2026-10-05** (Konstante `config.termine.avvVersion`). Die Gästetexte (Hinweise, Haken, Belehrung und Datenschutz-Absatz für die Gäste unserer Kunden) tragen die Fassung **2026-10-05** (Konstante `RECHTSTEXTE_VERSION`). Beide Konstanten haben heute dasselbe Datum, sind aber getrennt und können sich später unterscheiden.
## 1. Zweck und Umfang
**Zweck.** Der Inhaber lässt die Texte des Terminplaners durch eine Kanzlei prüfen, bevor der erste fremde Betrieb den Terminplaner nutzt. Bis dahin ist der Terminplaner nicht für fremde Betriebe freigeschaltet. Das Paket soll ohne Rückfragen zeigen, was die Kanzlei prüfen soll und von welchen Annahmen wir ausgehen.
**Kernpaket.** Der Inhaber hat festgelegt, dass die Kanzlei dieses Kernpaket prüft und alles Übrige WEBklar selbst prüft (Hausrunde). Die Kanzlei prüft:
- die Widerrufsbelehrung und das Muster-Widerrufsformular der Bestellung der Einrichtung für 149 € (Abschnitt 3.2),
- den Zustimmungssatz zum vorzeitigen Beginn mit Erlöschen des Widerrufsrechts und Wertersatz (Abschnitt 3.1, Haken 3),
- den Auftragsverarbeitungsvertrag Terminplaner (Anlage A, Auszüge in Abschnitt 3.5),
- die Haftungs- und Ende-mit-dem-Abo-Klauseln der Leistungsbeschreibung (Anlage B, Auszüge in Abschnitt 3.4),
- die Gästetexte mit Gesetzlichkeitsfiktion: Widerrufsbelehrung für Gäste und die Hinweise und Haken im Widget (Abschnitte 3.6 bis 3.10).
**Hausrunde.** Die Datenschutzerklärung von WEBklar (Abschnitt 6c und die Ergänzungen in den Abschnitten 6 und 7), die Hinweise im Kundenportal und die Mailtexte prüft WEBklar selbst. Zu einzelnen Punkten daraus stellen wir trotzdem Fragen (Q15 bis Q19 und Q24); was wir nicht fragen, steht in Abschnitt 6.
**Begriffe.** *Betriebsart* (im Programm „Modus“) des Terminplaners: `off` (abgeschaltet), `pilot` (nur Kunden auf einer Liste von Pilotadressen) oder `on` (alle Kunden mit Wartungs-Abo). *Pilot* ist ein Kunde auf dieser Liste; derzeit sind das nur Konten des Inhabers. `on` setzt der Inhaber erst nach Ihrem Befund und seiner Freigabe. *Kulanz* heißt, dass das Team einem Kunden den Terminplaner ohne Bestellung und ohne Zahlung freischaltet (Abschnitt 2.1). *Fassung* ist ein Stand der Texte mit Datum.
**Keine Rechtsberatung durch WEBklar.** Die Texte sind Mustertexte für die Standardkonfiguration und keine Rechtsberatung (Leistungsbeschreibung Nr. 11 Abs. 1). Die rechtlichen Aussagen in diesem Paket (vor allem „unsere Annahme“ bei den Fragen) sind ein Arbeitsstand von WEBklar und kein Rechtsurteil.
**Fassungen und Änderungen.** Eine ausgelieferte Fassung wird nie überschrieben. Ändern wir nach dem Befund einen Text, bekommt er ein neues Fassungsdatum, neue Dateien (HTML und PDF) und ein neues Archiv der Gästetexte; die Zitate in diesem Paket werden dann angepasst. Ein Test prüft jedes Zitat wörtlich gegen den Quelltext, damit Paket und Programm nicht auseinanderlaufen.
**Aufbau.** Abschnitt 2 beschreibt den Produktablauf, Abschnitt 3 enthält die Texte, Abschnitt 4 unsere Fragen Q1 bis Q26, Abschnitt 5 die Fundstellen im Programm, Abschnitt 6 was nicht Teil der Prüfung ist, Abschnitt 7 die Checkliste für den Inhaber. Hinter jedem Zitat in Abschnitt 3 steht „⚖ Q…“ mit den Fragen, die den Text betreffen. Anlagen: **Anlage A** ist der AVV Terminplaner, **Anlage B** die Leistungsbeschreibung Terminplaner, beide in voller Länge als PDF (Dateien in Abschnitt 3.11). **Anlage C** sind die gerenderten Mails K15 (Bestellbestätigung mit den Zustimmungen) und K16 (Terminplaner bereit); sie folgt nach der Mail-Überarbeitung des Inhabers, weil ihr Wortlaut bis dahin noch nicht endgültig ist.
## 2. Produktablauf in Kurzform
Der Terminplaner ist ein Buchungs-Widget für die Website eines Betriebs (zum Beispiel Friseur, Werkstatt oder Studio) mit einer Verwaltung im Kundenportal von WEBklar. Der Betrieb ist Kunde von WEBklar („Kunde“); seine Besucher, die einen Termin buchen, sind „Gäste“. Verkauft wird die Einrichtung für 149 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG. Für den Betrieb berechnet WEBklar neben dem Wartungs-Abo nichts; der Terminplaner läuft, solange das Wartungs-Abo läuft. Bezahlt wird beim Terminplaner nur vor Ort, es gibt keine Online-Zahlung der Gäste.
### 2.1 Bestellung und Einrichtung
| Schritt | Was geschieht | Frage |
|---|---|---|
| 1. Abo-Prüfung | Nur Kunden mit laufendem Wartungs-Abo können bestellen; ein Hosting-Abo genügt nicht. Bestellbar ist der Terminplaner nur, solange das Wartungs-Abo aktiv ist und keine Kündigung vorgemerkt ist. | ⚖ Q25 |
| 2. Fragebogen-Entwurf | Der Kunde beantwortet im Kundenportal einen Fragebogen in acht Schritten. Jeder Schritt wird sofort als Entwurf der späteren Einstellungen gespeichert; ein Entwurf ohne Bestellung wird 90 Tage nach der letzten Änderung gelöscht. Die Branchenfrage kennt den Gesundheitsbereich; wählt der Kunde ihn, ist die Bestellung gesperrt. Zusätzlich warnt eine Stichwortprüfung über Betriebsname, Leistungen und Felder das Team, das die Einrichtung ablehnen kann. | ⚖ Q11, Q24 |
| 3. Bestellung mit vier Haken | „Prüfen und bestellen“: Zusammenfassung, offene Punkte (der Server prüft, ob alles Nötige vorliegt, darunter die Adressen von Impressum und Datenschutzerklärung der Website), volle Widerrufsbelehrung mit Muster-Widerrufsformular, vier Haken und der Knopf „Zahlungspflichtig bestellen · 149 €“ (Abschnitte 3.1 und 3.2). Alle Besteller werden wie Verbraucher behandelt. | ⚖ Q1, Q3, Q19, Q20 |
| 4. Nachweis | Der Server legt zuerst die Zahlungssitzung bei Stripe an und speichert dann den Nachweis: die gezeigten Texte, ihre Fassungen, einen Hash, den Zeitpunkt des Klicks, IP-Adresse und Browser-Kennzeichen, Kundenkonto und angemeldete Person. Erst danach erhält der Browser die Adresse der Zahlungsseite. Der Zeitpunkt des Klicks ist der Anker der Widerrufsfrist. | ⚖ Q2, Q23 |
| 5. Zahlung | Stripe Checkout (Karte oder SEPA-Lastschrift); Stripe erstellt die Rechnung. | ⚖ Q19 |
| 6. Ticket und Bestätigung | Nach dem Zahlungseingang legt das Portal ein Einrichtungs-Ticket mit der Einbauanleitung an. Der Kunde erhält die Bestellbestätigung K15 mit den gezeigten Zustimmungen und, als PDF-Anhang auf einem dauerhaften Datenträger, Leistungsbeschreibung, AVV und Widerrufsbelehrung mit Muster-Widerrufsformular in der Fassung, der er zugestimmt hat. Das Team erhält die Meldung T16. | ⚖ Q3, Q20 |
| 7. Einbau und Freischaltung | WEBklar baut das Widget ein, der Kunde nimmt es ab, WEBklar schaltet es frei; der Kunde erhält die Mail K16 („bereit“). | |
| 8. Betrieb | Gäste buchen im Widget (Abschnitt 2.2), der Kunde verwaltet seine Termine im Kundenportal. | |
**Freischaltung ohne Bestellung (Pilot, Kulanz).** Das Team kann einen Terminplaner ohne Zahlung aktivieren. Sobald die Betriebsart `on` gilt (alle Kunden mit Wartungs-Abo), verlangt das Portal dafür einen Nachweis der Zustimmung des Kunden in Textform (Datum, Weg Chat oder E-Mail, Name des Mitarbeiters), außer für Pilotadressen; danach erhält der Kunde die Mail K16 mit Leistungsbeschreibung und AVV als PDF. Solche Dokumente gelten nicht als durch Kauf freigeschaltet; der tägliche Abo-Abgleich nimmt sie aus (⚖ Q7, Q21).
### 2.2 Gast: Buchung, Widerruf, Daten
1. Der Gast wählt Leistung, Termin und gegebenenfalls Person oder Platz und gibt Name und E-Mail-Adresse an (je nach Einstellung des Kunden auch Telefon, Nachricht, Zusatzfelder). Er sieht den Datenschutz-Hinweis (Abschnitt 3.8), die Haken (Abschnitt 3.7) und, bei Verbrauchern, die Widerrufsbelehrung zum Aufklappen (Abschnitt 3.6). ⚖ Q12, Q13, Q17
2. Die Buchung wird 30 Minuten gehalten. In dieser Zeit bestätigt der Gast per Link in einer E-Mail; erst die Bestätigung macht die Buchung verbindlich, bei einer Terminanfrage nimmt der Kunde später an. Nicht bestätigte Buchungen löscht WEBklar 48 Stunden nach Ablauf der Bestätigungsfrist. ⚖ Q14
3. Die Bestätigungs-E-Mail (G02) enthält bei Verbrauchern Widerrufshinweis, Widerrufsbelehrung und Muster-Widerrufsformular; bei einer Freizeit-Dienstleistung mit festem Termin, die der Kunde so einstuft, steht stattdessen der Hinweis, dass kein Widerrufsrecht besteht (Abschnitt 3.9). ⚖ Q15, Q16
4. Widerrufen kann der Gast über den Knopf „Vertrag widerrufen“ im Terminbereich der Website oder über den Link „Termin verwalten“ in der Bestätigungs-E-Mail; er erhält eine Eingangsbestätigung mit Datum und Uhrzeit (G08). Die Widerrufsfrist beträgt 14 Tage.
5. Der Terminplaner setzt keine Cookies und speichert nichts im Browser des Gastes; die IP-Adresse dient dem Missbrauchsschutz nur im Arbeitsspeicher des Servers. Die Buchung speichert als Nachweis die Fassung der Gästetexte, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes der gezeigten Texte; WEBklar archiviert jede Fassung der Gästetexte. ⚖ Q26
6. Löschfristen: bestätigte und erledigte Termine 90 Tage nach dem Terminende (einstellbar 14 bis 365 Tage), abgesagte oder abgelehnte Termine 30 Tage nach der Absage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist; in der Versandwarteschlange für E-Mails werden Empfängeradresse und Inhalt nach dem Versand geleert und der Eintrag nach 7 Tagen gelöscht; Sicherungen halten gelöschte Daten bis zu sieben Tage. ⚖ Q9
### 2.3 Ende des Abos
1. Ein täglicher Abgleich prüft das Wartungs-Abo. Endet es (auch durch Wechsel ins Hosting-Abo oder durch Widerruf des Abschlusses oder eines Wechsels), schaltet WEBklar den Terminplaner spätestens am folgenden Tag ab und informiert den Kunden mit der Mail K22. Ist etwas nicht lesbar, schaltet der Abgleich nichts ab und versucht es am nächsten Tag erneut. ⚖ Q7
2. Nach dem Abschalten nimmt der Terminplaner keine neuen Buchungen mehr an. Die Termine bleiben im Kundenportal sichtbar und als CSV-Datei exportierbar, Gäste können über ihren Link absagen, bereits angeforderte Erinnerungen laufen weiter, der Kunde informiert seine Gäste.
3. 30 Tage nach dem Abschalten löscht WEBklar alle Termindaten und die Einstellungen; das Einrichtungs-Ticket des Teams bleibt als Arbeitsnachweis mit einer Kurzfassung der Einstellungen bestehen (keine Daten von Gästen). Beginnt innerhalb dieser 30 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein (Rückkehr, Mail K23). Nach der Löschung sind ein neuer Fragebogen und die Einrichtung für 149 € noch einmal nötig. ⚖ Q9
4. Endet das Wartungs-Abo, bevor der Terminplaner freigeschaltet ist, erstattet WEBklar die 149 € vollständig. Der Abgleich schaltet den Terminplaner mit dem Grund „Abo endete vor Einrichtung“ ab und meldet dem Team die Erstattung (T19); die Erstattung löst der Inhaber in Stripe aus, es gibt keine automatische Rückzahlung. In diesem Fall gibt es keine automatische Rückkehr. ⚖ Q6
### 2.4 Widerruf der Bestellung durch den Kunden
Der Kunde widerruft auf der Seite „Vertrag widerrufen“ mit der Auswahl „Terminplaner (Einrichtung)“. Jede Bestellung hat ihre eigene Frist von 14 Tagen ab dem Zeitpunkt des Klicks auf „Zahlungspflichtig bestellen“ (bei mehreren Website-Projekten also mehrere Fristen). Die Widerrufsseite ordnet den Widerruf keinem Projekt zu: Sie bewertet den Eingang gegen das späteste Fristende aller bezahlten Terminplaner-Bestellungen des Kunden; das Team erhält in der Meldung T09 jede Bestellung mit Projekt, Zustand und eigenem Fristende und entscheidet. Der Widerruf erzeugt die Mail K04 an den Kunden und die Meldung T09 an das Team; die Abwicklung (Erstattung in Stripe abzüglich Wertersatz für einen schon begonnenen Einbau, Abschalten des Terminplaners) erledigt das Team von Hand. ⚖ Q2, Q5, Q7
### 2.5 Zahlen
| Wert | Bedeutung | Quelle im Code |
|---|---|---|
| 149 € | Einrichtung je Website-Projekt, einmalig, ohne Umsatzsteuer gemäß § 19 UStG | `priceLabel` des Terminplaners, `amountCents` 14900 |
| 14 Tage | Widerrufsfrist | `WITHDRAWAL_DAYS` |
| 30 Minuten | Haltezeit einer Buchung, Gültigkeit des Bestätigungslinks | `holdMinutes` |
| 48 Stunden | Löschung nicht bestätigter Buchungen nach Ablauf der Bestätigungsfrist | `VERFALLEN_BEHALTEN_MS` |
| 90 Tage | Aufbewahrung bestätigter Termine nach dem Terminende (Vorgabe) und Aufbewahrung von Entwürfen ohne Bestellung | `AUFBEWAHRUNG_VORGABE`, `ENTWURF_TAGE` |
| 7 Tage | Eintrag in der Versandwarteschlange nach dem Versand; Sicherungen des Servers | `GESENDET_BEHALTEN_MS` |
| 30 Tage | Löschung nach dem Abschalten; Löschung abgesagter oder abgelehnter Termine | `LOESCHFRIST_TAGE`, `ABGESAGT_BEHALTEN_MS` |
## 3. Texte in Endfassung
Alle Zitate stehen wörtlich so im Programm oder in den Dokumenten (Abschnitt 5 nennt die Fundstellen). Platzhalter in doppelten geschweiften Klammern stehen für Angaben des jeweiligen Betriebs. In den Zitaten steht vor dem Sternchen in Klammern (\*) und vor spitzen Klammern ein Rückstrich, damit die Darstellung sie nicht als Hervorhebung oder Markup deutet; gemeint sind die Zeichen selbst.
### 3.1 Bestellung des Terminplaners: vier Haken und Knopf
Auf der Bestellseite stehen vor dem Knopf die volle Widerrufsbelehrung mit Muster-Widerrufsformular (Abschnitt 3.2) und diese vier Haken, keiner ist vorangekreuzt. Die Links auf Leistungsbeschreibung, AVV und Widerrufsbelehrung in der Fassung stehen daneben, nicht im Text der Haken. Gespeichert wird genau der gezeigte Text mit Fassung und Zeitpunkt.
**Haken 1 (`unterlagen`): Zustimmung zu Leistungsbeschreibung und AVV**
> Ich stimme der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner in der Fassung 2026-10-05 zu.
⚖ Q1, Q10
**Haken 2 (`gesundheit`): kein Gesundheitsbereich**
> Mein Angebot liegt nicht im Gesundheitsbereich (zum Beispiel keine Arzt-, Zahnarzt-, Psychotherapie-, Heilpraktiker- oder Physiotherapiepraxis).
⚖ Q11
**Haken 3 (`beginn`): vorzeitiger Beginn, Erlöschen, Wertersatz**
> Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Terminplaners beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).
⚖ Q3, Q5
**Haken 4 (`information`): Pflichtinformationen**
> Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Terminplaners für diese Website: ein Widget an einer Stelle, gestaltet nach dem Fragebogen, mit Einbau und Datenschutz-Absatz), Gesamtpreis 149 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), voraussichtliche Dauer meist innerhalb von 1–2 Wochen, der Terminplaner endet mit dem Wartungs-Abo. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.
⚖ Q7, Q19, Q20
**Knopf**
> Zahlungspflichtig bestellen · 149 €
⚖ Q2, Q19
### 3.2 Widerrufsbelehrung und Muster-Widerrufsformular der Funktionen
WEBklar ist hier der Unternehmer. Die Belehrung steht auf der Bestellseite, im PDF-Anhang der Bestellbestätigung K15 und in der Datei `docs/rechtliches/widerrufsbelehrung-funktionen.md` (Fassung 2026-10-05).
**Widerrufsbelehrung und Muster-Widerrufsformular (Bestellung der Einrichtung)**
> Widerrufsbelehrung
>
> Widerrufsrecht
>
> Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.
>
> Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.
>
> Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.
>
> Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.
>
> Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.
>
> Folgen des Widerrufs
>
> Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.
>
> Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.
>
> Muster-Widerrufsformular
>
> (Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)
>
> – An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:
> – Hiermit widerrufe(n) ich/wir (\*) den von mir/uns (\*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (\*)/die Erbringung der folgenden Dienstleistung (\*)
> – Bestellt am (\*)/erhalten am (\*)
> – Name des/der Verbraucher(s)
> – Anschrift des/der Verbraucher(s)
> – Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)
> – Datum
>
> (\*) Unzutreffendes streichen.
⚖ Q1, Q2, Q12
Das Dokument enthält vor der Belehrung einen Einleitungssatz und nach dem Muster-Widerrufsformular „Hinweise zum Widerruf“, die nicht zur gesetzlichen Belehrung gehören:
**Einleitung des Dokuments**
> Das Widerrufsrecht gilt nur für Verbraucher. Wir zeigen die Belehrung und das Muster-Widerrufsformular trotzdem allen, die eine Funktion bestellen. Die Belehrung gilt für die Bestellung der Einrichtung einer Funktion im Kundenportal, zum Beispiel des Terminplaners. Hinweise dazu stehen nach dem Muster-Widerrufsformular.
⚖ Q1
**Hinweise zum Widerruf**
> Diese Hinweise gehören nicht zur gesetzlichen Widerrufsbelehrung. Sie erklären, wie sich das Widerrufsrecht auf die Bestellung einer Funktion auswirkt.
>
> - Das Widerrufsrecht gilt für jede Bestellung einer Funktion gesondert, also für jedes Website-Projekt, für das der Kunde eine Funktion bestellt. Die Frist von vierzehn Tagen beginnt jeweils mit dem Vertragsschluss.
> - Widerruft der Kunde die Einrichtung einer Funktion, endet diese Funktion. Das Wartungs-Abo bleibt davon unberührt.
> - Hat WEBklar auf ausdrücklichen Wunsch des Kunden vor Ablauf der Widerrufsfrist mit der Einrichtung begonnen, schuldet der Kunde bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachte Leistung. Der Betrag bemisst sich als Anteil am Gesamtpreis; Maß ist der im Arbeitsblatt erfasste Aufwand für die Einrichtung.
> - Die Rückzahlung erfolgt über das ursprüngliche Zahlungsmittel; die Zahlung lief über Stripe.
⚖ Q5, Q7
### 3.3 Zustimmung bei der Bestellung der News-Pinnwand (Altbestand)
Die News-Pinnwand (30 € einmalig) wird seit 2026-09-30 über denselben Bestellweg verkauft, bisher mit einem Haken und ohne Widerrufsbelehrung. Zum Vergleich der Text, den neue Bestellungen jetzt sehen, und der Haken, der bis zur Korrektur galt (Programmstand 3a0b008 vor der Korrektur). Die Leistungsbeschreibung der News-Pinnwand aus dieser Zeit steht in Frage Q4.
**Neuer Haken der News-Pinnwand**
> Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).
⚖ Q4, Q3
**Alte Fassung (Stand 3a0b008): früherer Haken der News-Bestellung**
> Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).
⚖ Q4
### 3.4 Leistungsbeschreibung Terminplaner (Anlage B), Auszüge
Die Datei ist `docs/rechtliches/leistungsbeschreibung-terminplaner.md`; sie ist als Anlage B vollständig beigefügt. Zitiert sind die Nummern, auf die sich unsere Fragen beziehen; weitere Nummern behandeln Geltung, Leistung, Voraussetzungen und Verfügbarkeit.
**Nr. 4 Abs. 3: Gesundheitsbereich**
> (3) Der Terminplaner ist nicht für Angebote im Gesundheitsbereich bestimmt, weil dort besondere Kategorien personenbezogener Daten anfallen können (Art. 9 DSGVO). Der Kunde setzt ihn dort nicht ein und fordert von seinen Gästen keine Gesundheitsangaben. WEBklar darf die Einrichtung für eine solche Website ablehnen oder den Terminplaner dort abschalten.
⚖ Q11
**Nr. 4 Abs. 9: Datenschutz-Absatz auf der Website des Kunden**
> (9) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einbau dort den Datenschutz-Absatz für die Online-Terminbuchung ein; das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet. Liegt sie woanders, etwa bei einem externen Generator, erhält der Kunde den Absatz als Textbaustein und fügt ihn selbst ein. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt der Kunde verantwortlich (§ 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf, weist WEBklar den Kunden darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht.
⚖ Q18
**Nr. 5: Einrichtung und Preis**
> (1) Die Einrichtung umfasst die Einrichtung nach dem Fragebogen, den Einbau des Widgets in die Website, den Datenschutz-Absatz nach Nr. 4 Abs. 9, die Abnahme durch den Kunden und die Freischaltung durch WEBklar.
>
> (2) Die Einrichtung kostet 149 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG.
>
> (3) Die voraussichtliche Dauer der Einrichtung nennt die Bestellseite vor der Bestellung. Sie ist eine Einschätzung und keine verbindliche Zusage eines Termins.
>
> (4) Der Kunde bezahlt bei der Bestellung im Kundenportal über Stripe und erhält eine Rechnung.
>
> (5) Der Preis steht neben dem Stundenkontingent des Wartungs-Abos: Die Einrichtung wird weder auf das Kontingent angerechnet noch damit bezahlt. Für den Betrieb des Terminplaners berechnet WEBklar neben dem Wartungs-Abo nichts weiter; er läuft, solange das Wartungs-Abo läuft (Nr. 8).
⚖ Q19, Q20
**Nr. 7: Haftung für verpasste Termine**
> (1) Der Terminplaner ist ein Hilfsmittel. Die Einhaltung der Termine bleibt Sache des Kunden.
>
> (2) WEBklar übernimmt keine Gewähr für die Zustellung der E-Mails (Nr. 6 Abs. 4).
>
> (3) Für entgangenen Gewinn und Folgeschäden aus verpassten Terminen haftet WEBklar nur im Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen.
>
> (4) Der Haftungsrahmen des Kunden gegenüber seinen Gästen bleibt unberührt.
⚖ Q8
**Nr. 8: Ende der Leistung**
> (1) Mit dem Ende des Wartungs-Abos endet der Terminplaner. WEBklar schaltet ihn spätestens am folgenden Tag ab und informiert den Kunden per E-Mail. Das gilt auch bei einem Wechsel ins Hosting-Abo und beim Widerruf des Abschlusses oder eines Wechsels ins Wartungs-Abo.
>
> (2) Mit dem Abschalten nimmt der Terminplaner keine neuen Buchungen mehr an, und das Widget zeigt den Ausweichtext. Die Termine bleiben im Kundenportal sichtbar, und der Kunde kann sie als CSV-Datei exportieren. Gäste können über ihren Link absagen. Bereits angeforderte Erinnerungen laufen weiter. Der Kunde informiert seine Gäste.
>
> (3) WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6 des AVV Terminplaner).
>
> (4) Beginnt innerhalb dieser 30 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Einrichtung nötig (neuer Fragebogen, neuer Preis nach Nr. 5).
>
> (5) Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.
>
> (6) Auf Wunsch des Kunden in Textform schaltet WEBklar den Terminplaner jederzeit ab. Eine Erstattung des Einrichtungspreises folgt daraus nicht; für den Widerruf gilt Nr. 10.
⚖ Q6, Q7, Q9
**Nr. 9 Abs. 4: Einstellung ohne Grund**
> (4) Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.
⚖ Q8
**Nr. 10: Widerruf**
> (1) Verbraucher können die Bestellung der Einrichtung nach der Widerrufsbelehrung widerrufen. Belehrung und Muster-Widerrufsformular stehen in der Anlage „Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)“.
>
> (2) Ob das Widerrufsrecht erlischt und ob der Kunde einen angemessenen Betrag für die bis dahin erbrachte Leistung schuldet, richtet sich nach der Zustimmung, die der Kunde bei der Bestellung gibt, und nach den Hinweisen zum Widerruf in der Anlage.
>
> (3) Mit dem Widerruf endet der Terminplaner.
⚖ Q3, Q5, Q7
**Nr. 11: Hinweise**
> (1) **Keine Rechtsberatung.** Der Hinweis am Buchungsformular, die Texte der Haken, der Datenschutz-Absatz und die Widerrufsbelehrung für Gäste sind Mustertexte für die Standardkonfiguration. WEBklar sorgt dafür, dass sie die Technik richtig beschreiben, und passt sie an, wenn sich die Technik ändert. Die Einstufung seiner Leistungen als Leistung für Verbraucher oder Unternehmer und als Freizeitleistung trägt der Kunde. Eine rechtliche Beratung im Einzelfall schuldet WEBklar nicht.
>
> (2) **Barrierefreiheit.** Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen, Fokusführung, Fehlermeldungen als Live-Region). Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu. Ob der Kunde als Dienstleistungserbringer unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Kunde.
⚖ Q15, Q18, Q22
### 3.5 Auftragsverarbeitungsvertrag Terminplaner (Anlage A), Auszüge
Die Datei ist `docs/rechtliches/avv-terminplaner.md`; sie ist als Anlage A vollständig beigefügt (mit der Anlage der technischen und organisatorischen Maßnahmen).
**Vorbemerkung Abs. 1 und 2: Abschluss**
> (1) Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Einrichtung des Terminplaners bestellt und dabei per Haken diesem Vertrag und der Leistungsbeschreibung „Terminplaner“ („Leistungsbeschreibung“) in der angezeigten Fassung zustimmt. Das Portal speichert die Fassung, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte und die Widerrufsbelehrung in dieser Fassung als PDF enthält und auf sie verlinkt.
>
> (2) Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform (etwa im Chat des Kundenportals oder per E-Mail) erklärt hat, dass er diesem Vertrag und der Leistungsbeschreibung in der aktuellen Fassung zustimmt. WEBklar speichert die Fassung, das Datum und den Weg dieser Erklärung, den Zeitpunkt des Einschaltens und den Namen des Mitarbeiters. Der Kunde erhält anschließend eine E-Mail mit diesen beiden Texten als PDF.
⚖ Q10, Q21
**Nr. 3 Abs. 1: Daten der Gäste**
> (1) Daten der Gäste: Name und E-Mail-Adresse; auf Einstellung des Kunden außerdem Telefonnummer, eine Nachricht und Zusatzfelder, die der Kunde im Formular festlegt; die gebuchte Leistung, die Person oder der Platz und der Termin; der Status der Buchung mit den Zeitpunkten (Anlegen, Bestätigung, Annahme, Absage, Widerruf); Absagegrund und Vermerke zu einer Absage oder einem Widerruf (Freitext, auch mit dem Namen aus einer Widerrufserklärung); Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde; der Nachweis der Zustimmungen (Fassung der Gästetexte, Zeitpunkt, Text des Buchungsknopfs, Anrede und Hashes (Prüfsummen) der gezeigten Texte); der Wunsch nach einer Erinnerung; der Zustellstatus der E-Mails an den Gast.
⚖ Q10
**Nr. 4 Abs. 2: Weisungen**
> (2) Mit der Bestellung weist der Kunde WEBklar an, den Terminplaner nach seinen Einstellungen und der Leistungsbeschreibung zu betreiben. Weitere Weisungen ergeben sich aus den Einstellungen im Kundenportal (Leistungen, Personen oder Plätze, Zeiten, Felder des Formulars, Aufbewahrungsdauer, Erinnerungen, Einschalten und Ausschalten). Sonstige Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Weisungen, die über diese Einstellungen hinausgehen, setzt WEBklar nur um, wenn das technisch vorgesehen ist; sonst kann der Kunde den Terminplaner abschalten lassen (Nr. 8 Abs. 6 der Leistungsbeschreibung). Die Löschung auf Weisung nach Nummer 9 Abs. 6 setzt WEBklar immer um.
⚖ Q10
**Nr. 7: Unterauftragsverarbeiter**
> (1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Terminplaner läuft, und für dessen Sicherung (Rechenzentrum Falkenstein, Deutschland) sowie für den Versand der Buchungs-E-Mails über den SMTP-Dienst des Webhostings (Rechenzentrum Nürnberg, Deutschland). Eine Übermittlung in Drittländer findet nicht statt.
>
> (2) Will WEBklar einen weiteren Unterauftragsverarbeiter hinzuziehen oder einen ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen. WEBklar setzt den neuen Unterauftragsverarbeiter nicht vor Ablauf dieser Frist ein. Kündigt ein Unterauftragsverarbeiter von WEBklar eine eigene Änderung mit kürzerer Frist an, informiert WEBklar den Kunden unverzüglich, nachdem die Ankündigung eingegangen ist; der Kunde kann dann unverzüglich widersprechen. Einigen sich die Parteien nicht, kann der Kunde den Terminplaner abschalten lassen und WEBklar ihn für diese Website zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt wird. Die Daten werden dann nach Nummer 9 gelöscht, auf Weisung des Kunden früher (Nummer 9 Abs. 6).
>
> (3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf die Datenschutzpflichten nach Art. 28 Abs. 4 DSGVO. Bei der Hetzner Online GmbH ist das deren Vertrag zur Auftragsverarbeitung, den WEBklar abgeschlossen hat. Kommt ein Unterauftragsverarbeiter seinen Datenschutzpflichten nicht nach, haftet WEBklar dem Kunden für die Einhaltung dieser Pflichten (Art. 28 Abs. 4 Satz 2 DSGVO).
>
> (4) Die Zahlung der Einrichtung über Stripe betrifft nur den Vertrag zwischen dem Kunden und WEBklar. Daten von Gästen oder Beschäftigten des Kunden gehen nicht an Stripe. Stripe ist deshalb kein Unterauftragsverarbeiter dieses Vertrags.
⚖ Q10
**Nr. 8 Abs. 5: Nachweis und Archiv der Gästetexte**
> (5) Die Buchung speichert als Nachweis der Zustimmungen die Fassung der Gästetexte, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes der übrigen Texte, die der Gast gesehen hat, nicht diese Texte selbst. Damit sich der Wortlaut nachweisen lässt, bewahrt WEBklar jede Fassung der Gästetexte (Hinweis am Formular, Texte der Haken, Datenschutz-Absatz und Widerrufsbelehrung für Gäste) mit dem Zeitraum ihrer Verwendung auf, bis drei Jahre nach dem Ende des Terminplaners. WEBklar stellt sie dem Kunden auf Verlangen zur Verfügung.
⚖ Q26
**Nr. 9: Löschung und Rückgabe**
> (1) Nicht bestätigte Buchungen löscht WEBklar 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten.
>
> (2) Abgesagte und abgelehnte Termine löscht WEBklar 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die der Kunde bis zum angefragten Terminbeginn nicht beantwortet, behandelt der Terminplaner als abgelehnt; die 30 Tage laufen dann ab diesem Zeitpunkt.
>
> (3) Bestätigte und erledigte Termine löscht WEBklar nach der Aufbewahrungsdauer, gerechnet ab dem Ende des Termins. Der Kunde stellt sie im Kundenportal ein; die Vorgabe sind 90 Tage, einstellbar sind 14 bis 365 Tage.
>
> (4) In der Versandwarteschlange für E-Mails leert WEBklar die Empfängeradresse und den Inhalt einer E-Mail nach dem Versand und löscht den Eintrag 7 Tage nach dem Versand. E-Mails, die nicht zugestellt werden können, löscht WEBklar mit Empfängeradresse und Inhalt 48 Stunden nach dem endgültigen Fehlschlag.
>
> (5) Der Löschlauf läuft täglich; gelöscht wird frühestens nach Ablauf der jeweiligen Frist. Ist an einem Tag mehr zu löschen, als ein Lauf schafft, folgt der Rest im nächsten Lauf.
>
> (6) Ist der Terminplaner abgeschaltet oder das Wartungs-Abo beendet, löscht WEBklar alle Termindaten und die Einstellungen 30 Tage danach. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen (zum Beispiel Betriebsangaben, Namen der Leistungen, die ersten acht Namen der Personen oder Plätze und die Wünsche des Kunden zum Einbau) und enthält keine Daten von Gästen; dafür gilt die Datenschutzerklärung von WEBklar (Abschnitt 6). Bis zur Löschung kann der Kunde die Termine im Kundenportal als CSV-Datei exportieren. Beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen wieder ein (Nr. 8 Abs. 4 der Leistungsbeschreibung). Früher löscht der Kunde einzelne Gastdaten selbst mit der Aktion „Gastdaten löschen“ (Nummer 8 Abs. 2); alle Daten löscht WEBklar auf Weisung des Kunden in Textform unverzüglich.
>
> (7) Entwürfe aus dem Fragebogen, aus denen keine Bestellung geworden ist, löscht WEBklar 90 Tage nach der letzten Änderung.
>
> (8) Die Daten sind in der täglichen Vollsicherung des Servers enthalten (§ 3 Abs. 5 der Abo-Bedingungen). Gelöschte Daten bleiben dort bis zu sieben Tage erhalten und werden dann überschrieben. Wird eine Sicherung zurückgespielt, holt der tägliche Löschlauf die fälligen Löschungen nach; Löschungen auf Weisung des Kunden wiederholt WEBklar.
>
> (9) Gesetzliche Aufbewahrungspflichten bleiben unberührt.
⚖ Q7, Q9, Q10
### 3.6 Gäste: Widerrufsbelehrung und Muster-Widerrufsformular
Der Terminplaner erzeugt diese Belehrung mit den Angaben des Betriebs (alle vier Angaben sind Pflicht) und zeigt sie Verbrauchern beim Buchen und in der Bestätigungs-E-Mail. Grundlage ist das Muster der Anlage 1 und das Muster-Widerrufsformular der Anlage 2 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB (Dateien `docs/rechtliches/quellen/anlage-1.txt` und `anlage-2.txt`, abgerufen 2026-10-02). Gestaltungshinweise für einen Dienstleistungsvertrag sind angewendet: Fristbeginn „des Vertragsabschlusses“, Wertersatz für Dienstleistungen; die Widerrufsfunktion nennt keine Internetadresse, sondern einen anderen geeigneten Hinweis. Ausgelassen sind die Textbausteine zu Waren und der Absatz zum elektronischen Webformular. Die Anrede ist immer „Sie“.
**Widerrufsbelehrung und Muster-Widerrufsformular für Gäste (Platzhalter für die Betriebsangaben)**
> Widerrufsbelehrung
>
> Widerrufsrecht
>
> Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.
>
> Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.
>
> Um Ihr Widerrufsrecht auszuüben, müssen Sie uns ({{Name des Betriebs}}, {{Anschrift}}, Telefon: {{Telefon}}, E-Mail: {{E-Mail}}) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.
>
> Sie können Ihr Widerrufsrecht auch online unter „Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail) ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.
>
> Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.
>
> Folgen des Widerrufs
>
> Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.
>
> Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.
>
> Muster-Widerrufsformular
>
> (Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)
>
> – An {{Name des Betriebs}}, {{Anschrift}}, {{E-Mail}}:
> – Hiermit widerrufe(n) ich/wir (\*) den von mir/uns (\*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (\*)/die Erbringung der folgenden Dienstleistung (\*)
> – Bestellt am (\*)/erhalten am (\*)
> – Name des/der Verbraucher(s)
> – Anschrift des/der Verbraucher(s)
> – Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)
> – Datum
>
> (\*) Unzutreffendes streichen.
⚖ Q12, Q13
### 3.7 Gäste: Haken und Hinweis zu Gesundheitsangaben
Die Haken stehen immer in der Sie-Form. Keiner ist vorangekreuzt.
**Haken „Vorzeitiger Beginn“** (Pflicht, nur für Verbraucher bei Leistungen mit Preis, wenn der Termin vor Ablauf der Widerrufsfrist liegt; bei Anfragen mit Preis immer)
> Ich verlange ausdrücklich, dass {{Firma}} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).
⚖ Q13, Q3
**Haken „Stornobedingungen“** (Pflicht, wenn der Betrieb Stornobedingungen hinterlegt hat und die Leistung einen Preis hat)
> Ich habe die Stornobedingungen gelesen.
⚖ Q16
**Haken „Erinnerung“** (freiwillig; nur wenn der Betrieb Erinnerungen anbietet; `{{n}}` ist 2, 12, 24 oder 48)
> Ich möchte {{n}} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.
⚖ Q17
**Hinweis über jedem Freitextfeld**
> Bitte keine Gesundheitsangaben.
⚖ Q11
### 3.8 Gäste: Datenschutz-Hinweis am Buchungsformular
Der Hinweis steht in der Zusammenfassung vor dem Absenden, zusammen mit einem Link auf die Datenschutzerklärung des Betriebs; es gibt keinen Haken dafür. Sie- oder Du-Fassung nach der Anrede des Betriebs; die Löschfristen sind die des Löschlaufs.
**Fassung Sie**
> Datenschutz. Wir verwenden Ihre Angaben, um Ihren Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). Betrieben wird der Terminkalender in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland). Wir speichern Ihre Daten bis 90 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist. Es werden keine Cookies gesetzt. Bitte machen Sie keine Angaben zu Ihrer Gesundheit. Mehr erfahren Sie in unserer Datenschutzerklärung. Verantwortlich: {{Firma}}, {{Anschrift}}.
⚖ Q17, Q9
**Fassung Du**
> Datenschutz. Wir verwenden deine Angaben, um deinen Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). Betrieben wird der Terminkalender in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland). Wir speichern deine Daten bis 90 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist. Es werden keine Cookies gesetzt. Bitte mach keine Angaben zu deiner Gesundheit. Mehr erfährst du in unserer Datenschutzerklärung. Verantwortlich: {{Firma}}, {{Anschrift}}.
⚖ Q17, Q9
### 3.9 Gäste: Hinweise in der Bestätigungs-E-Mail (G02)
Die Bestätigungs-E-Mail (Vorlage `server/mail-templates/G02 Termin bestaetigt.html`) enthält je nach Buchung einen der beiden folgenden Hinweise (hier die Sie-Fassung); danach folgt bei Widerrufsrecht die Belehrung aus Abschnitt 3.6. Die Du-Fassung ist inhaltlich gleich.
**Widerrufsrecht besteht (Verbraucher)**
> Widerrufsrecht. Wenn Sie Verbraucher sind, können Sie diesen Vertrag innerhalb von 14 Tagen ab Vertragsschluss ohne Angabe von Gründen widerrufen. Bis zum Beginn des Termins geht das über den Knopf „Vertrag widerrufen“; danach über das Formular „Vertrag widerrufen“ auf der Website von {{betrieb_name}} oder per E-Mail an {{betrieb_email}}. Die ausführliche Widerrufsbelehrung und das Muster-Widerrufsformular finden Sie weiter unten in dieser E-Mail.
⚖ Q2, Q12
**Kein Widerrufsrecht (Freizeit-Dienstleistung mit festem Termin)**
> Für diese Buchung besteht kein Widerrufsrecht (§ 312g Abs. 2 Nr. 9 BGB). Absagen richten sich nach den Stornobedingungen.
⚖ Q15, Q16
### 3.10 Gäste: Knopftexte
Die Knopftexte hängen davon ab, ob die Buchung verbindlich oder eine Anfrage ist und ob die Leistung einen Preis hat. Im Widget und auf der Bestätigungsseite heißen die Knöpfe:
**Verbindliche Buchung mit Preis: Widget, dann Bestätigungsseite**
> Zahlungspflichtig buchen
>
> Zahlungspflichtig bestätigen
⚖ Q14
**Verbindliche Buchung ohne Preis: Widget, dann Bestätigungsseite**
> Termin buchen
>
> Termin bestätigen
⚖ Q14
**Anfrage: Widget, dann Bestätigungsseite**
> Termin anfragen
>
> Anfrage absenden
⚖ Q14
**Widerrufsfunktion: Knopf im Terminbereich, dann Bestätigung des Formulars**
> Vertrag widerrufen
>
> Widerruf bestätigen
⚖ Q12, Q14
### 3.11 Anlagen A und B
- **Anlage A:** Auftragsverarbeitungsvertrag Terminplaner, Fassung 2026-10-05, als PDF `public/rechtliches/avv-terminplaner-2026-10-05.pdf` (Quelle `docs/rechtliches/avv-terminplaner.md`).
- **Anlage B:** Leistungsbeschreibung Terminplaner, Fassung 2026-10-05, als PDF `public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf` (Quelle `docs/rechtliches/leistungsbeschreibung-terminplaner.md`).
- Die Widerrufsbelehrung mit Muster-Widerrufsformular der Funktionen steht in Abschnitt 3.2 vollständig; ihr PDF ist `public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.pdf`.
- **Anlage C** (gerenderte Mails K15 und K16) wird nach der Mail-Überarbeitung des Inhabers nachgereicht (Abschnitt 7.4).
## 4. Fragen an die Kanzlei
Jede Frage hat vier Teile. **Frage:** was wir wissen möchten. **Fundstelle im Text:** wo der Text steht (Abschnitt 3, Anlage A oder B, Programm) und, als Kennung H1 bis C8, welche Punkte der früheren Listen offener Rechtsfragen die Frage mit abdeckt (Abschnitt 6.3 ordnet alle Kennungen zu). **Unsere Annahme:** der Stand, mit dem wir arbeiten; bitte bestätigen oder korrigieren. **Alternative:** was wir stattdessen täten. Die Zählung von § 356 BGB haben wir am 2026-10-05 auf gesetze-im-internet.de abgeglichen; für die übrigen Normen bitten wir um Gegenprüfung (Q3). Einordnungen wie „Annahme“ sind keine Rechtsurteile.
### Q1: Alle Besteller wie Verbraucher
**Frage.** Der Inhaber hat entschieden, alle Besteller der Einrichtung (149 €) wie Verbraucher zu behandeln: Alle sehen die volle Widerrufsbelehrung mit Muster-Widerrufsformular und alle setzen die Haken zum vorzeitigen Beginn. Die Besteller sind meist Unternehmer (Betriebe mit eigener Website); ob jemand als Verbraucher (§ 13 BGB) oder Unternehmer (§ 14 BGB) bestellt, kann WEBklar nicht verlässlich prüfen (Existenzgründer, Nebenerwerb). Genügt der Satz am Anfang des Dokuments „Das Widerrufsrecht gilt nur für Verbraucher. Wir zeigen die Belehrung und das Muster-Widerrufsformular trotzdem allen, die eine Funktion bestellen.“, damit Unternehmern kein vertragliches Widerrufsrecht entsteht und die Haken für sie nichts Falsches sagen?
**Fundstelle im Text.** Abschnitt 3.1 (Haken 1 bis 4), Abschnitt 3.2 (Einleitung des Dokuments und Belehrung), Leistungsbeschreibung Nr. 10 Abs. 1 („Verbraucher können die Bestellung der Einrichtung nach der Widerrufsbelehrung widerrufen“).
**Unsere Annahme.** Ja. Verbraucher haben das gesetzliche Widerrufsrecht, die Haken wirken bei ihnen; Unternehmern räumt die Einleitung kein Widerrufsrecht ein, die Haken schaden ihnen nicht. Die Abwicklung eines Widerrufs ist Handarbeit des Teams (Meldung T09); ob ein Unternehmer erstattet bekommt, entscheidet das Team im Einzelfall.
**Alternative.** Vor den Haken fragen, ob als Verbraucher oder als Unternehmer bestellt wird (Haken 3 und Belehrung nur für Verbraucher), oder den Terminplaner nur an Unternehmer verkaufen (Bestätigung nach § 14 BGB) und die Belehrung als freiwillige Zusage geben.
### Q2: Beginn der Widerrufsfrist
**Frage.** Wann beginnt die Widerrufsfrist der Bestellung? Wir rechnen ab dem Zeitpunkt des Klicks auf „Zahlungspflichtig bestellen · 149 €“ (gespeichert im Nachweis); das Fristende wird nach § 193 BGB auf einen Werktag verschoben. Die Belehrung steht vor dem Klick auf der Bestellseite, auf einem dauerhaften Datenträger (PDF-Anhang der Bestellbestätigung K15) erhält der Kunde sie erst nach dem Zahlungseingang. Beginnt die Frist mit dem Klick (Annahme durch WEBklar), erst mit dem Zahlungseingang (bei SEPA-Lastschrift Tage später) oder erst mit dem Zugang von K15 (§ 356 Abs. 3 BGB)? Gilt dasselbe für die Gäste (Vertragsschluss bei verbindlicher Buchung mit der Bestätigung per Link, bei einer Anfrage mit der Annahme durch den Betrieb)?
**Fundstelle im Text.** Abschnitt 3.1 (Knopf), Abschnitt 3.2 („ab dem Tag des Vertragsabschlusses“), Abschnitt 3.9 (G02), Abschnitt 2.4; Programm: `withdrawFeature` rechnet mit dem Zeitpunkt des Klicks aus dem Nachweis (ersatzweise mit dem Bestellzeitpunkt), `withdrawalDeadline` mit 14 Tagen.
**Unsere Annahme.** Der Klick ist der Vertragsschluss: Mit ihm löst der Kunde die Zahlung aus; danach gibt es keine gesonderte Annahmeerklärung von WEBklar. WEBklar kann die Einrichtung nur aus den Gründen von Leistungsbeschreibung Nr. 4 Abs. 3 (Gesundheitsbereich, Q11) ablehnen oder abschalten. Die Belehrung ist vor dem Klick lesbar; K15 bestätigt sie mit den PDFs auf einem dauerhaften Datenträger.
**Alternative.** Die Frist ab Zahlungseingang oder ab Zugang von K15 rechnen und die Belehrung entsprechend formulieren („ab dem Tag, an dem Sie diese Bestellbestätigung erhalten“).
### Q3: Zustimmungssatz: Erlöschen, Wertersatz, Kenntnis, dauerhafter Datenträger
**Frage.** Genügt Haken 3 („Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Terminplaners beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich … schulde ich einen angemessenen Betrag … (§ 357a Abs. 2 BGB).“)? Bitte prüfen: (a) die Zählung der Norm für das Erlöschen bei Dienstleistungen (§ 356 Abs. 5 Nr. 2 BGB) und für den Wertersatz (§ 357a Abs. 2 BGB); (b) ob das ausdrückliche Verlangen und die Bestätigung der Kenntnis im selben Haken stehen dürfen; (c) ob der Wertersatz-Hinweis im Haken ausreicht oder auch in die Belehrung gehört; (d) ob K15 mit den Texten der Zustimmung, dem Zeitpunkt und den PDFs die nötige Bestätigung auf einem dauerhaften Datenträger ist (die gerenderte Mail folgt als Anlage C nach der Mail-Überarbeitung des Inhabers).
**Fundstelle im Text.** Abschnitt 3.1 (Haken 3), Abschnitt 3.2 („Hinweise zum Widerruf“), Abschnitt 3.3 (Haken der News), Leistungsbeschreibung Nr. 10 Abs. 2; Programm: `termineConsent` (Register), `onFeaturePaid` (K15, Block „Zustimmungen“ und Anhänge).
**Unsere Annahme.** Der Haken erfüllt beides: Verlangen, Kenntnis vom Erlöschen und Hinweis auf den Wertersatz in einem Satz mit Zeitpunkt im Nachweis; K15 gibt den Satz zusammen mit der Belehrung auf einem dauerhaften Datenträger wieder. Die Zählung entspricht der Fassung auf gesetze-im-internet.de vom 2026-10-05.
**Alternative.** Zwei Haken (Verlangen und Kenntnis getrennt); die Bestätigung als eigener, ausdrücklich so benannter Absatz in K15.
### Q4: News-Altkunden
**Frage.** Seit 2026-09-30 verkauft WEBklar die News-Pinnwand (30 € einmalig) über denselben Bestellweg wie jetzt den Terminplaner. Bis zur Korrektur sah die Bestellung so aus („3a0b008“ ist die Kennung des Programmstands vor der Korrektur). Alte Fassung (Stand 3a0b008), der Haken:
> Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).
Alte Fassung (Stand 3a0b008), Leistungsbeschreibung der News-Pinnwand, Abschnitt 6 „Widerrufsrecht“ (nur diese zwei Absätze, keine gesetzliche Musterbelehrung, kein Muster-Widerrufsformular):
> Sind Sie Verbraucher, können Sie diesen Vertrag innerhalb von 14 Tagen ohne Angabe von Gründen widerrufen. Die Frist beginnt mit dem Vertragsschluss. Zum Widerruf genügt eine eindeutige Erklärung an uns, zum Beispiel über project.webklar.com/widerrufen.html.
>
> Verlangen Sie ausdrücklich, dass wir vor Ablauf der Widerrufsfrist mit dem Einbau beginnen, müssen Sie uns bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachte Leistung zahlen. Das Widerrufsrecht erlischt nach § 356 Abs. 4 BGB, wenn wir die Leistung vollständig erbracht haben und Sie vorher ausdrücklich zugestimmt haben, dass wir vor Ablauf der Widerrufsfrist beginnen, und Ihre Kenntnis bestätigt haben, dass Sie Ihr Widerrufsrecht bei vollständiger Vertragserfüllung verlieren.
Alte Fassung (Stand 3a0b008), Abschnitt 2 derselben Leistungsbeschreibung zum Preis:
> Der Einbau kostet 30,00 € einmalig, inklusive Umsatzsteuer. Die Nutzung der Pinnwand danach kostet nichts; es entstehen keine laufenden Kosten und kein Abo. Sie bezahlen bei der Bestellung im Kundenportal über Stripe und erhalten eine Rechnung.
Die Bestellbestätigung K15 enthielt nach unserem Stand weder eine Belehrung noch Anhänge. Der Haken zitierte also § 356 Abs. 4 BGB und enthielt keinen Hinweis auf den Wertersatz (§ 357a Abs. 2 BGB), den die Leistungsbeschreibung nur allgemein nannte; der Preis war „inklusive Umsatzsteuer“ angegeben (richtig ist § 19 UStG, Q19). Lief bei diesen News-Altkunden, soweit sie Verbraucher sind, die Widerrufsfrist nach § 356 Abs. 3 BGB nicht an? Wenn ja: Sollen wir die Belehrung per Mail nachholen (Form, Inhalt, Zeitpunkt, Hinweis zum Wertersatz), oder genügt es, nur künftig die neuen Texte zu verwenden? Was gilt für bereits eingebaute und freigeschaltete News-Pinnwände (Erlöschen des Widerrufsrechts, Wertersatz)? Die Zahl der betroffenen Fälle liefert der Inhaber vor der Übergabe: [Anzahl der Bestellungen seit 2026-09-30 mit dem alten Text].
**Fundstelle im Text.** Abschnitt 3.3 (neuer und früherer Haken), Abschnitt 3.2 (Belehrung der Funktionen, die die News künftig ebenfalls nutzt); Programm: `config.features.news` (neuer Haken; bestehende Bestellungen behalten den damals gespeicherten Text und die Fassung). Die alten Texte stammen aus dem Programmstand 3a0b008 (`server/config.js`, `public/vertragsunterlagen/funktionen-leistungsbeschreibung.html`). Die Leistungsbeschreibung der News-Pinnwand vom 30.09.2026 liegt unverändert unter `public/vertragsunterlagen/funktionen-leistungsbeschreibung-2026-09-30.html` (unveränderte Kopie des Standes 3a0b008, durch eine Prüfsumme gesichert), weil Bestellungen seit dem 30.09.2026 auf ihr beruhen; die geltende Datei (Stand 05.10.2026) verweist am Anfang auf sie.
**Unsere Annahme.** Nach unserer Lesart (kein Rechtsurteil) kann die Frist bei fehlender ordnungsgemäßer Belehrung nach § 356 Abs. 3 BGB nicht angelaufen sein. Der Inhaber entscheidet nach Ihrem Befund: Belehrung nur nachholen, wenn die Frist danach nicht lief; sonst gelten die neuen Texte nur für künftige Bestellungen.
**Alternative.** Allen News-Altkunden vorsorglich die Belehrung samt Muster-Formular per Mail schicken (mit dem Hinweis, dass die Frist damit beginnt), unabhängig vom Befund; oder, wenn die Kanzlei keinen Handlungsbedarf sieht, nichts tun.
### Q5: Wertersatz: Anteil am Gesamtpreis oder Stundenaufwand
**Frage.** Die Muster-Belehrung verlangt beim Widerruf einen angemessenen Betrag, der dem Anteil der bis zur Unterrichtung erbrachten Dienstleistungen im Vergleich zum Gesamtumfang entspricht (§ 357a Abs. 2 BGB). Unsere „Hinweise zum Widerruf“ sagen: „Der Betrag bemisst sich als Anteil am Gesamtpreis; Maß ist der im Arbeitsblatt erfasste Aufwand für die Einrichtung.“ Intern war ursprünglich eine Berechnung „nach Stundenaufwand“ vorgesehen. Ist diese Berechnung (Anteil an 149 € nach dem erfassten Aufwand im Verhältnis zum Gesamtaufwand der Einrichtung dieses Projekts) zulässig, oder muss der Gesamtumfang vorab festgelegt werden, damit der Anteil bestimmbar ist?
**Fundstelle im Text.** Abschnitt 3.1 (Haken 3), Abschnitt 3.2 (Muster-Belehrung, Absatz „Haben Sie verlangt …“, und „Hinweise zum Widerruf“, dritter Punkt), Leistungsbeschreibung Nr. 10 Abs. 2; Mails K04 und T09; Programm `withdrawFeature`.
**Unsere Annahme.** Anteilig am Preis (nicht Stundensatz mal Zeit). Das Maß der erbrachten Leistung ist der im Arbeitsblatt des Einrichtungs-Tickets erfasste Aufwand, der Gesamtumfang der Aufwand der Einrichtung dieses Projekts nach dem Ticket. Eine feste Gesamtstundenzahl steht nicht im Vertrag.
**Alternative.** Den Gesamtumfang in der Leistungsbeschreibung festlegen (zum Beispiel feste Schritte oder eine Stundenzahl) und den Wertersatz nach erledigten Schritten bemessen; oder bei Widerruf nach Einbaubeginn pauschal einen festen Anteil ansetzen.
### Q6: Volle Erstattung, wenn das Abo vor der Freischaltung endet
**Frage.** Leistungsbeschreibung Nr. 8 Abs. 5 lautet: „Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.“ Ist die Klausel wirksam und klar (§ 307 BGB), auch wenn WEBklar den Einbau schon begonnen oder fertiggestellt hat und auch wenn der Kunde das Abo selbst beendet? Außerdem: Nr. 8 Abs. 4 sagt, dass WEBklar den Terminplaner bei einem neuen Wartungs-Abo innerhalb der 30 Tage wieder einschaltet. Das Programm tut das nur für den Abschaltgrund „Abo beendet“ (`abo_ende`, nach Freischaltung), nie für `abo_ende_vor_einrichtung`: Nach der Erstattung bleibt der Terminplaner aus; ob und wie er wieder eingerichtet wird, entscheidet das Team im Einzelfall. Muss der Text das sagen?
**Fundstelle im Text.** Abschnitt 3.4 (Nr. 8 Abs. 4 bis 6), Abschnitt 2.3 Punkt 4; Programm: `gleicheAboAb` (Grund `abo_ende_vor_einrichtung`, Meldung T19 an das Team, keine automatische Rückkehr), Mail K22 Block „vor_einrichtung“.
**Unsere Annahme.** Die Klausel begünstigt den Kunden und ist unproblematisch. Die Rückkehr gilt nur für den Fall nach der Freischaltung; für den Erstattungsfall ist sie nicht gewollt, weil die Erstattung voll geschuldet bleibt.
**Alternative.** Die Erstattung auf den Fall beschränken, dass WEBklar noch nicht mit dem Einbau begonnen hat (sonst Wertersatz); Nr. 8 Abs. 4 um den Satz ergänzen, dass die Rückkehr nicht gilt, wenn der Einrichtungspreis erstattet wurde.
### Q7: Ende mit dem Abo, auch bei Wechsel und Widerruf; Ausnahme für Pilot und Kulanz
**Frage.** (a) Leistungsbeschreibung Nr. 8 Abs. 1 lässt den Terminplaner mit dem Wartungs-Abo enden, auch beim Wechsel ins Hosting-Abo und beim Widerruf des Abschlusses oder eines Wechsels ins Wartungs-Abo. Ist diese Kopplung zulässig, obwohl der Kunde die Einrichtung einmalig bezahlt hat und sie ein eigener Vertrag ist? Wie verhält sich der Widerruf des Abos zum Einrichtungsvertrag, insbesondere wenn der Terminplaner schon freigeschaltet ist und deshalb nichts erstattet wird? (b) Ausnahme: Terminplaner, die das Team ohne Bestellung freigeschaltet hat (Pilot, Kulanz; im Programm `unlockedByPurchase` nicht wahr), nimmt der Abgleich aus und zählt sie im Tagesbericht als „ausgenommen“; sie laufen weiter, bis WEBklar sie abschaltet. Genügt der Satz „WEBklar schaltet ihn spätestens am folgenden Tag ab“ trotzdem, oder braucht es eine Klausel, die diese Kulanzfälle nennt? (c) Der Abgleich schaltet bei Lesefehlern nichts ab und versucht es am nächsten Tag; ein Abo im Status „Zahlungsverzug“ gilt als laufend. Ist „spätestens am folgenden Tag“ dann zu streng gesagt?
**Fundstelle im Text.** Abschnitt 3.4 (Nr. 8 Abs. 1 und 6, Nr. 10), Abschnitt 3.1 (Haken 4: „der Terminplaner endet mit dem Wartungs-Abo“), Abschnitt 2.3, Abschnitt 2.1 (Freischaltung ohne Bestellung); Programm: `gleicheAboAb`, `endeTermineFuerKunde`.
**Unsere Annahme.** Die Kopplung ist zulässig, weil der Terminplaner nur mit einem Wartungs-Abo betrieben wird und der Kunde das in Haken 4 liest. Kulanzfälle sind keine Verträge über die Einrichtung; ihre Ausnahme braucht keine Klausel. „Spätestens am folgenden Tag“ beschreibt den Normalfall.
**Alternative.** Nr. 8 Abs. 1 auf „in der Regel am folgenden Tag“ abschwächen; eine Kulanzklausel („Terminplaner, die WEBklar ohne Bestellung freigeschaltet hat, schaltet WEBklar nach eigenem Ermessen ab“) aufnehmen; beim Widerruf des Abos nach Freischaltung eine anteilige Erstattung prüfen.
### Q8: Haftung für verpasste Termine; Erstattung bei Einstellung
**Frage.** (a) Leistungsbeschreibung Nr. 7 Abs. 3 (Abschnitt 3.4) beschränkt die Haftung für entgangenen Gewinn und Folgeschäden aus verpassten Terminen auf den Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen (unbeschränkt bei Vorsatz, grober Fahrlässigkeit, Verletzung von Leben, Körper oder Gesundheit; bei leicht fahrlässiger Verletzung wesentlicher Pflichten begrenzt auf den vertragstypischen, vorhersehbaren Schaden; im Übrigen ausgeschlossen). Ist die Klausel gegenüber Verbrauchern mit § 309 Nr. 7 BGB vereinbar, und ist die Verweisung hinreichend transparent? (b) Braucht es gegenüber Unternehmern einen Höchstbetrag (etwa den Jahresbetrag des Wartungs-Abos)? (c) Nr. 9 Abs. 4 schließt eine Einstellung ohne Grund aus. Wann WEBklar den Terminplaner beenden oder aussetzen darf, regeln Nr. 9 Abs. 2 und 3:
> (2) WEBklar darf den Terminplaner für eine Website mit einer Frist von vier Wochen beenden oder für alle Kunden mit einer Frist von drei Monaten einstellen, wenn
> - ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form unzulässig macht oder nur mit wesentlich anderer Technik erlaubt und der Kunde einer dafür nötigen neuen Fassung nicht zustimmt (AVV Terminplaner Nummer 12), oder
> - eine Sicherheitslücke sich nicht anders beheben lässt.
>
> Die Mitteilung erfolgt in Textform und nennt den Grund, die Merkmale der Änderung, den Zeitpunkt und bei Verbrauchern das Recht, den Vertrag nach § 327r Abs. 3 BGB zu beenden, mit seiner Frist.
>
> (3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar den Terminplaner ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Kunden darüber unverzüglich in Textform mit dem Grund und nimmt den Betrieb wieder auf, sobald das zulässig und technisch möglich ist.
Zur Erstattung des Einrichtungspreises, wenn WEBklar den Terminplaner danach beendet, schweigt der Text. Braucht es eine (anteilige) Erstattung?
**Fundstelle im Text.** Abschnitt 3.4 (Nr. 7 und Nr. 9 Abs. 4), Leistungsbeschreibung Nr. 9 Abs. 2 und 3 (oben zitiert), Abo-Bedingungen § 13 (`public/abo-bedingungen.html`).
**Unsere Annahme.** Die Verweisung auf § 13 genügt; ein Höchstbetrag ist nicht nötig, weil § 13 Abs. 2 die Haftung schon auf den vorhersehbaren Schaden begrenzt. Bei Einstellung aus den Gründen von Nr. 9 Abs. 2 erstattet WEBklar nichts, weil der Terminplaner bis dahin lief; die Rechte aus § 327r BGB bleiben.
**Alternative.** Einen Höchstbetrag gegenüber Unternehmern festlegen; bei Einstellung durch WEBklar den Einrichtungspreis anteilig für die Restlaufzeit erstatten.
### Q9: 30 Tage nach dem Abschalten: Anker, Rückkehr, Sicherungen
**Frage.** Der Terminplaner endet mit dem Wartungs-Abo; WEBklar schaltet ihn spätestens am folgenden Tag ab und löscht alle Termindaten und Einstellungen 30 Tage nach dem Abschalten. Bis dahin kann der Kunde die Termine im Kundenportal ansehen und als CSV-Datei exportieren; beginnt ein neues Wartungs-Abo, schaltet WEBklar ohne Kosten mit den vorhandenen Daten wieder ein (Rückkehr, nur beim Abschaltgrund „Abo beendet“, `abo_ende`). Fragen: (a) Ist der Anker (Zeitpunkt des Abschaltens, `disabledAt`, nicht der Tag des Abo-Endes) und die Frist von 30 Tagen mit Art. 28 Abs. 3 lit. g DSGVO vereinbar? (b) Darf die Rückkehr ohne neue Zustimmung zur Fassung automatisch erfolgen? (c) Gelöschte Daten bleiben in der täglichen Vollsicherung bis zu sieben Tage (Rückspielen holt der Löschlauf nach): genügt das? (d) Der tägliche Löschlauf löscht frühestens nach Ablauf der jeweiligen Frist, nicht sekundengenau; „48 Stunden“ und „30 Tage“ sind frühestmögliche Zeitpunkte (H4, A3). (e) Terminanfragen, die der Kunde bis zum angefragten Terminbeginn nicht beantwortet, gelten ab dann als abgelehnt, und die 30 Tage laufen ab diesem Zeitpunkt (AVV Nr. 9 Abs. 2). (f) Der Satz im Datenschutz-Absatz der Kunden-Website („Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.“) und der Satz in Abschnitt 6c stimmen mit dem Programm überein, seit der tägliche Abgleich das Abschalten übernimmt. (g) Das Einrichtungs-Ticket, in dem das Team die Einrichtung als Arbeitsnachweis festhält, bleibt nach der Löschung mit einer Kurzfassung der Einstellungen bestehen (Betriebsangaben, Namen der Leistungen, die ersten acht Namen der Personen oder Plätze, Wünsche des Kunden zum Einbau; keine Daten von Gästen). Leistungsbeschreibung Nr. 8 Abs. 3 und AVV Nr. 9 Abs. 6 sagen das; die Datenschutzerklärung (Abschnitt 6) nennt die Arbeitsnachweise und die Aufbewahrung bis zum Ablauf der Verjährungsfrist. Ist das neben der Löschpflicht des Art. 28 Abs. 3 lit. g DSGVO zulässig, auch wenn der Kunde die Löschung aller Daten anweist (AVV Nr. 9 Abs. 6)?
**Fundstelle im Text.** Abschnitt 3.4 (Nr. 8 Abs. 3 und 4), Abschnitt 3.5 (AVV Nr. 9, besonders Abs. 2, 6 und 8), Abschnitt 2.3; frühere Punkte H4, A3, C4 (Sicherungen), C7 (Satz in 6c); Mail K22 und Datenschutzerklärung 6c (Hausrunde); Programm: `LOESCHFRIST_TAGE`, `loescheAbgeschaltete`, `gleicheAboAb`; Einrichtungs-Ticket: `termineTicketDetails`.
**Unsere Annahme.** Ja. 30 Tage ab dem Abschalten sind angemessen, weil der Kunde in dieser Zeit Termine ansehen und exportieren kann und eine Rückkehr kostenlos ist. Die Rückkehr stellt den früheren Stand wieder her; die zugestimmte Fassung gilt weiter, eine neue Fassung braucht ihre Zustimmung nach AVV Nr. 12. Der Sicherungsüberhang von sieben Tagen folgt aus der täglichen Vollsicherung bei Hetzner (letzte sieben Tagessicherungen) und steht offen im Vertrag.
**Alternative.** Das Modell der Website-Analyse: Löschung am 61. Tag nach dem Ende, Kopie der Daten auf Verlangen bis zum 46. Tag (AVV Website-Analyse Nr. 9 Abs. 2); oder 30 Tage ab dem Tag des Abo-Endes statt ab dem Abschalten.
### Q10: AVV Terminplaner: Aufbau, Hetzner, Drittland, Daten, Weisungen
**Frage.** Bitte den AVV (Anlage A) prüfen; besonders: (a) der Abschluss durch Haken bei der Bestellung (Textform; Art. 28 Abs. 9 DSGVO) mit gespeicherter Fassung, Zeitpunkt, Konto und Person, und durch Zustimmung in Textform bei Freischaltung durch das Team (Q21); (b) die Wahl eines eigenen Vertrags (Geschwister des AVV der Website-Analyse, Vorrang vor Anlage 1 der Abo-Bedingungen) statt eines Rahmen-AVV oder eines Zusatzes, damit Kunden der Website-Analyse nicht neu zustimmen müssen; (c) Nr. 7: Hetzner Online GmbH, Rechenzentrum Falkenstein (Server und Sicherung) und Nürnberg (SMTP-Dienst des Webhostings) und der Satz „Eine Übermittlung in Drittländer findet nicht statt“, auch wenn ein Kunde den geheimen Kalender-Link in einem Kalenderdienst eines Anbieters wie Google oder Apple abonniert (das Abonnement richtet der Kunde ein, WEBklar übermittelt dorthin nichts) (A2); (d) Nr. 7 Abs. 4: Stripe ist kein Unterauftragsverarbeiter, weil keine Daten von Gästen oder Beschäftigten an Stripe gehen; (e) Aufruf des Widgets: IP-Adresse und Browser-Kennzeichen erhält der Server technisch bei jedem Abruf, Server-Protokolle 14 Tage (Art. 6 Abs. 1 lit. f DSGVO) (A1); im Knopfmodus lädt das Widget die Konfiguration schon beim Laden der Seite (A6, C8), vom selben Server wie das Skript; (f) Beschäftigtendaten: Namen, Zeiten und der geheime Kalender-Link; der Kunde informiert seine Beschäftigten (C3); (g) Nr. 3 Abs. 1: Absagegrund und Vermerke (Freitext, auch mit dem Namen aus einer Widerrufserklärung) sowie Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde; Nr. 2 Abs. 3: Zählungen ohne Personenbezug, die WEBklar für den sicheren Betrieb braucht, sind keine eigenen Zwecke; (h) Nr. 4 Abs. 2 und Nr. 9 Abs. 6: Löschung auf Weisung in Textform „unverzüglich“, ohne feste Frist.
**Fundstelle im Text.** Abschnitt 3.5 (Vorbemerkung Abs. 1 und 2, Nr. 3 Abs. 1, Nr. 4 Abs. 2, Nr. 7, Nr. 9), Anlage A vollständig mit der Anlage der technischen und organisatorischen Maßnahmen; frühere Punkte A1, A2, A6, C3, C8; Programm: `baueNachweis`, `datenschutzAbsatz`.
**Unsere Annahme.** Der AVV ist vollständig und passt zum Programm (Datenarten, Löschfristen, Unterauftragsverarbeiter sind durch Tests an den Code gebunden). Eine Drittlandübermittlung durch WEBklar findet nicht statt; das Kalender-Abonnement liegt in der Verantwortung des Kunden. „Unverzüglich“ genügt für die Löschung auf Weisung.
**Alternative.** Ein Rahmen-AVV mit einer Anlage je Funktion (Abschnitt 6.2); im AVV einen Hinweis auf das Kalender-Abonnement bei Drittanbietern aufnehmen; für die Löschung auf Weisung eine feste Frist nennen (zum Beispiel 14 Tage).
### Q11: Gesundheits-Ausschluss
**Frage.** Der Terminplaner ist nicht für den Gesundheitsbereich bestimmt (Art. 9 DSGVO). Schutzmaßnahmen: die Branchenfrage im Fragebogen (der Gesundheitsbereich ist wählbar, die Bestellung dann aber gesperrt), Haken 2 mit Aufzählung (Arzt-, Zahnarzt-, Psychotherapie-, Heilpraktiker- oder Physiotherapiepraxis), eine weiche Stichwortprüfung über Betriebsname, Leistungen und Felder, die das Team warnt, das Recht in Leistungsbeschreibung Nr. 4 Abs. 3, die Einrichtung abzulehnen oder den Terminplaner dort abzuschalten, der Hinweis „Bitte keine Gesundheitsangaben“ über jedem Freitextfeld und im Datenschutz-Hinweis (A5), die Pflicht des Kunden in AVV Nr. 11 Abs. 4. Reicht das, damit WEBklar als Auftragsverarbeiter keine besonderen Kategorien verarbeitet? Und: Leistungsbeschreibung Nr. 4 Abs. 3 sagt nichts zur Erstattung, wenn WEBklar die Einrichtung wegen des Gesundheitsbereichs ablehnt oder abschaltet; muss der Text das regeln?
**Fundstelle im Text.** Abschnitt 3.1 (Haken 2), Abschnitt 3.4 (Nr. 4 Abs. 3), Abschnitt 3.7 (Hinweis), Anlage A Nr. 3 Abs. 3 und Nr. 11 Abs. 4; früherer Punkt A5; Programm: `BRANCHEN`, `gesundheitsWarnungen`, `bestellpruefung`.
**Unsere Annahme.** Die Kombination aus Frage, Haken, Warnung und Ablehnungsrecht genügt; die Aufzählung im Haken ist beispielhaft. Lehnt WEBklar die Einrichtung ab, erstattet WEBklar den Preis aus Kulanz (nach Widerruf oder Kulanz, kein Satz im Text).
**Alternative.** Eine ausdrückliche Erstattungsklausel für die Ablehnung; die Aufzählung im Haken durch „Heilkunde und Gesundheitsversorgung“ ersetzen; eine zweite Bestätigung im Fragebogen.
### Q12: Gäste-Belehrung: Abweichungen vom Muster und Gesetzlichkeitsfiktion
**Frage.** Die Belehrung für Gäste (Abschnitt 3.6) beruht auf dem Muster der Anlage 1 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB, angepasst für einen Dienstleistungsvertrag. Genügt sie für die Gesetzlichkeitsfiktion, obwohl sie abweicht? (a) Die Klammer zu den Lieferkosten im Absatz „Folgen des Widerrufs“ bleibt stehen, weil der Gestaltungshinweis keine Streichung bei Dienstleistungen vorsieht (B1); dasselbe gilt für die Belehrung der Funktionen (Abschnitt 3.2). (b) Die Widerrufsfunktion nennt statt einer Internetadresse einen „anderen geeigneten Hinweis“: die Schaltfläche „Vertrag widerrufen“ im Terminbereich und den Link „Termin verwalten“ in der Bestätigungs-E-Mail; die Adresse der Seite fehlt (B5). (c) „… dass die Dienstleistungen … beginnen soll“ steht im Singular des Gesetzestexts (B2). (d) Das Widget zeigt die Belehrung aufklappbar in der Zusammenfassung vor dem Knopf, die Bestätigungs-E-Mail enthält sie als Text (H1). (e) Die Fiktion gilt nur, wenn das Muster inhaltlich und gestalterisch dem Gesetz entspricht; Zeilenumbrüche, Überschriften und die Aufzählung im Formular sind unsere Darstellung (B6).
**Fundstelle im Text.** Abschnitt 3.6, Abschnitt 3.2 (Lieferkosten-Klammer und Widerrufsfunktion mit Adresse der Widerrufsseite bei den Funktionen), Abschnitt 3.9, Abschnitt 3.10 („Vertrag widerrufen“); frühere Punkte H1, B1, B2, B5, B6; Programm: `widerrufsbelehrung`, Muster in `docs/rechtliches/quellen/`.
**Unsere Annahme.** Ja: Die Abweichungen sind die Ausfüllung der Lücken, die das Muster vorsieht, oder der Gesetzestext selbst; die aufklappbare Darstellung ist zulässig, weil sie vor dem Absenden vollständig erreichbar ist.
**Alternative.** Die Klammer zu den Lieferkosten streichen; die Widerrufsfunktion zusätzlich mit der Adresse der Seite des Betriebs nennen; die Belehrung ohne Aufklappen offen anzeigen; „beginnen sollen“ im Plural.
### Q13: Gäste: Haken zum vorzeitigen Beginn und Wertersatz
**Frage.** Der Haken „Vorzeitiger Beginn“ (Abschnitt 3.7) wird nur Verbrauchern bei Leistungen mit Preis gezeigt, wenn der Termin vor Ablauf der Widerrufsfrist beginnt; bei Anfragen mit Preis immer, weil der Tag der Annahme nicht feststeht. Die Frist rechnen wir mit 14 Tagen ab dem Tag nach dem Vertragsschluss im Berliner Kalender (§§ 187, 188, 193 BGB), gerechnet ab Buchungszeitpunkt plus Haltezeit. (a) Genügt der Haken (Verlangen, Kenntnis, Wertersatz) in dieser Form? (b) Der Absatz zum Wertersatz steht immer in der Belehrung, auch wenn der Gast keinen vorzeitigen Beginn verlangt hat, weil das Muster ihn für Dienstleistungen vorsieht (B4). Ist das unschädlich? (c) Liegt der Termin nach Ablauf der Frist, zeigen wir keinen Haken: stimmt das?
**Fundstelle im Text.** Abschnitt 3.7 (Haken „Vorzeitiger Beginn“), Abschnitt 3.6 (Absatz „Haben Sie verlangt …“); früherer Punkt B4; Programm: `ZUSTIMMUNG_VORZEITIG`, `brauchtZustimmungVorzeitig`.
**Unsere Annahme.** Ja zu (a) bis (c): Der Haken wirkt nur, wo ein Widerrufsrecht noch besteht und die Leistung vor Fristende beginnt; der Muster-Absatz zum Wertersatz bleibt unverändert, um die Fiktion nicht zu gefährden.
**Alternative.** Den Haken bei jeder Buchung mit Preis zeigen; den Wertersatz-Absatz nur bei gesetztem Haken in die Belehrung aufnehmen (Abweichung vom Muster).
### Q14: Anfrage mit Festpreis, Bestätigungslink und Seitenknopf als Bestellung
**Frage.** Wann ist die Buchung eines Gastes eine Bestellung mit Zahlungspflicht im Sinne von § 312j Abs. 3 BGB? Unser Ablauf: Bei einer verbindlichen Buchung mit Preis trägt der Knopf im Widget die Aufschrift „Zahlungspflichtig buchen“; der Gast bestätigt dann per Link in einer E-Mail, und die Bestätigungsseite hat den Knopf „Zahlungspflichtig bestätigen“; erst damit kommt der Vertrag zustande. Bezahlt wird vor Ort. Bei einer Terminanfrage, auch mit Festpreis, heißen die Knöpfe „Termin anfragen“ und „Anfrage absenden“, der Vertrag kommt erst mit der Annahme durch den Betrieb zustande. Ohne Preis lauten die Knöpfe „Termin buchen“ und „Termin bestätigen“. Genügt das? Muss der Knopf einer Anfrage mit Festpreis ebenfalls „zahlungspflichtig“ heißen? Ist der Seitenknopf auf der Bestätigungsseite die maßgebliche Bestellung?
**Fundstelle im Text.** Abschnitt 3.10 (Knopftexte), Abschnitt 2.2 Punkt 2; Programm: `knopfText`.
**Unsere Annahme.** Ja: Die zahlungspflichtige Handlung ist die Bestätigung auf der Bestätigungsseite; bei einer Anfrage entsteht die Zahlungspflicht erst mit der Annahme, die der Betrieb erklärt und nicht der Gast.
**Alternative.** Auch den Knopf einer Anfrage mit Festpreis „Zahlungspflichtig anfragen“ nennen; bei einem Preis den Preis am Knopf zeigen.
### Q15: Einstufung als Freizeitleistung und Hinweis im Portal
**Frage.** Im Kundenportal stuft der Betrieb selbst ein, ob seine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB); kreuzt er das an, zeigt der Terminplaner keine Widerrufsbelehrung, kein Formular und keine Widerrufsfunktion, und die Bestätigung nennt den Satz aus Abschnitt 3.9. Der Hinweis dazu im Portal lautet:
> 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.
Ist diese Einschätzung vertretbar, vor allem für Fotoshootings, Coaching, Beratung, Tattoo und Massage? Genügt „Das ist keine Rechtsberatung“, oder haftet WEBklar, wenn ein Betrieb zu Unrecht ankreuzt und Gäste noch bis zu 12 Monate und 14 Tage widerrufen können? Ein zweiter Hinweis im Portal (`WIDERRUF_HINWEIS`) erklärt dem Betrieb, dass Privatpersonen eine Buchung 14 Tage nach Vertragsschluss widerrufen können (bei Anfragen zählt die Annahme), dass das Widerrufsrecht seiner Stornoregel vorgeht, dass bei vorzeitigem Beginn Wertersatz geschuldet sein kann (§ 357a Abs. 2 BGB) und dass der Terminplaner Belehrung und Knopf „Vertrag widerrufen“ automatisch zeigt.
**Fundstelle im Text.** Abschnitt 3.9 (Satz „kein Widerrufsrecht“), Leistungsbeschreibung Nr. 4 Abs. 8 und Nr. 11 Abs. 1 (die Einstufung trägt der Kunde); Programm: `FREIZEIT_HINWEIS`, `WIDERRUF_HINWEIS`, `zeigtWiderruf`.
**Unsere Annahme.** Friseur, Kosmetik, Nagel- und Fußpflege, Tattoo, Fotoshootings, Coaching und Beratung fallen in der Regel nicht unter die Ausnahme; bei Massage und Wellness ist die Einordnung unklar, im Zweifel besteht ein Widerrufsrecht. Der Hinweis stellt die Einstufung klar dem Betrieb zu.
**Alternative.** Die Einstufung nicht dem Betrieb überlassen: Der Terminplaner zeigt die Belehrung immer, die Freizeit-Ausnahme gibt es nicht; oder die Beispiele im Hinweis streichen.
### Q16: Storno-Muster (50 Prozent, 24 Stunden) und Wegfall bei kostenlosen Leistungen
**Frage.** Die Vorlagen schlagen dem Betrieb Stornobedingungen vor, zum Beispiel für Salon, Kosmetik und Beratung:
> Sie können Ihren Termin bis 24 Stunden vorher kostenlos absagen oder verschieben (Link in der Bestätigungsmail). Danach oder bei Nichterscheinen berechnen wir für den freigehaltenen Termin 50 % des Preises. Dem Kunden bleibt der Nachweis vorbehalten, dass uns kein oder ein geringerer Aufwand entstanden ist. Ihr gesetzliches Widerrufsrecht bleibt unberührt.
(Bei Tattoo und Fotografie 48 Stunden.) Im Portal steht neben dem Textfeld der Hinweis:
> Das ist ein Muster, kein Rechtstext. Bitte prüfe es und passe es an deine Bedingungen an.
(a) Hält das Muster einer Kontrolle nach § 307 BGB (und § 309 Nr. 5 BGB gegenüber Verbrauchern) stand, soweit es als Vorschlag für den Betrieb dient? (b) Hat keine Leistung einer Vorlage einen Preis (Vorlagen „handwerk“ und „sonstiges“), gibt es keinen Vorschlag. Bietet ein Betrieb einzelne kostenlose Leistungen neben Leistungen mit Preis an, bleibt sein Stornotext stehen, aber der Pflicht-Haken „Ich habe die Stornobedingungen gelesen.“ entfällt bei den kostenlosen Leistungen. Genügt das? (c) Die Bestätigungs-E-Mail G02 zeigt den Stornotext immer, wenn der Betrieb einen hinterlegt hat; ein Betrieb kann auch dann einen Text eingeben, wenn er kostenlose Leistungen anbietet, und dann steht er auch in der Bestätigung kostenloser Buchungen. Der Satz „Absagen richten sich nach den Stornobedingungen“ im Block „kein_widerruf“ (Abschnitt 3.9) steht dagegen bei jeder Buchung, die der Betrieb als Freizeit-Dienstleistung einstuft, also auch bei kostenlosen Buchungen und auch ohne hinterlegten Stornotext. Die Einbauanleitung für Mitarbeiter nennt den Stornotext unabhängig vom Preis. Ist das bei kostenlosen Leistungen irreführend? Wir wollen für G02 einen neutralen Satz festlegen (Checkliste, Abschnitt 7.4).
**Fundstelle im Text.** Abschnitt 3.7 (Haken „Stornobedingungen“), Abschnitt 3.9 (G02); Programm: `stornoText`, `STORNO_PROZENT`, `pruefeEingabe`, `STORNO_MUSTER_HINWEIS`.
**Unsere Annahme.** Der Vorschlag ist ein Muster mit Nachweisvorbehalt und dem Hinweis auf das Widerrufsrecht; der Betrieb prüft und passt ihn an. Bei kostenlosen Leistungen gibt es nichts zu stornieren, der Haken ist dort nicht nötig. Der Satz in G02 wird für Buchungen ohne Preis neutral gefasst.
**Alternative.** Keinen Stornovorschlag mitliefern; oder 50 Prozent durch eine feste Gebühr oder einen Staffelsatz ersetzen.
### Q17: Datenschutz- und Impressum-Link; Widerrufssatz am Erinnerungshaken
**Frage.** (a) Der Datenschutz-Hinweis am Buchungsformular verweist auf die Datenschutzerklärung des Betriebs; das Widget ließe den Link sonst still weg. Deshalb verlangt das Bestell-Gate vor der Bestellung die Adressen von Impressum und Datenschutzerklärung (H3). Ist die Pflicht ausreichend, oder müssen Links zu Datenschutzerklärung und Impressum direkt am Haken stehen? (b) Der Erinnerungshaken endet mit „Diese Einwilligung kann ich jederzeit widerrufen.“ (Art. 7 Abs. 3 DSGVO), ohne Widerrufsweg und ohne Link am Haken; die Einzelheiten stehen im Datenschutz-Absatz (H2). Genügt das?
**Fundstelle im Text.** Abschnitt 3.7 (Haken „Erinnerung“), Abschnitt 3.8 (Datenschutz-Hinweis); frühere Punkte H2, H3; Programm: `ZUSTIMMUNG_ERINNERUNG`, `formularHinweis`, `bestellpruefung`.
**Unsere Annahme.** Ja: Der Satz am Haken informiert vor der Einwilligung über das Recht zum Widerruf, die Einzelheiten und der Weg stehen im Hinweis und im Datenschutz-Absatz.
**Alternative.** Den Widerrufsweg („über den Link in der E-Mail oder per Nachricht an {{E-Mail}}“) in den Haken schreiben; Links zu Datenschutzerklärung und Impressum neben den Haken setzen.
### Q18: Datenschutz-Absatz auf der Kundenseite: Einfügen durch WEBklar im Preis
**Frage.** Leistungsbeschreibung Nr. 4 Abs. 9: Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einbau den Datenschutz-Absatz für die Online-Terminbuchung ein, das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet; sonst erhält der Kunde den Absatz als Textbaustein. Der Kunde bleibt für seine Datenschutzerklärung verantwortlich; fällt WEBklar ein offensichtlicher Widerspruch auf, weist WEBklar darauf hin, eine Prüfpflicht entsteht nicht. (a) Ist das Einfügen eines Mustertextes durch WEBklar eine Rechtsdienstleistung (§ 2 RDG), oder bleibt es Technik und Mustertext (Nr. 11 Abs. 1)? (b) Reicht die Abgrenzung der Verantwortung? (c) Dieselbe Regel steht in der Leistungsbeschreibung der Website-Analyse (Nr. 4 Abs. 4); wir wollen sie für beide gleich halten.
**Fundstelle im Text.** Abschnitt 3.4 (Nr. 4 Abs. 9, Nr. 11 Abs. 1); `docs/rechtliches/leistungsbeschreibung-website-analyse.md` Nr. 4 Abs. 4; Programm: `datenschutzAbsatz`.
**Unsere Annahme.** Das Einfügen eines unveränderten Mustertextes mit Hinweis ist keine Rechtsdienstleistung im Einzelfall; die Verantwortung bleibt beim Kunden.
**Alternative.** WEBklar fügt nichts ein und liefert nur den Textbaustein; oder WEBklar fügt ein und der Kunde bestätigt den Text ausdrücklich.
### Q19: Preisangabe nach § 19 UStG; Preis am Knopf
**Frage.** (a) Die Einrichtung steht überall sinngemäß als „149 € einmalig, ohne Umsatzsteuer gemäß § 19 UStG“, im Wortlaut so: im Haken 4 als „Gesamtpreis 149 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG)“ (Abschnitt 3.1), in Leistungsbeschreibung Nr. 5 Abs. 2:
> (2) Die Einrichtung kostet 149 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG.
und in der Bestellbestätigung K15 als Zusatz zur Preiszeile:
> einmalig, ohne Umsatzsteuer gemäß § 19 UStG; der Terminplaner läuft, solange Dein Wartungs-Abo läuft
Früher stand bei den News „inklusive Umsatzsteuer“ (Q4); das haben wir korrigiert. Ist die Angabe als Endpreis richtig, solange WEBklar Kleinunternehmer bleibt, und was ist bei Überschreiten der Grenze zu tun? (b) Der Knopf trägt den Preis: „Zahlungspflichtig bestellen · 149 €“ (Abschnitt 3.1). Ist der Zusatz mit § 312j Abs. 3 BGB vereinbar und nötig?
**Fundstelle im Text.** Abschnitt 3.1 (Haken 4 und Knopf), Abschnitt 3.4 (Nr. 5); Programm: `ustHinweis`, `TERMINE_PREIS`, `termineConsent`.
**Unsere Annahme.** WEBklar bleibt Kleinunternehmer (der Inhaber bestätigt das, Abschnitt 7.1); der Preis am Knopf schadet nicht, weil die gesetzlichen Worte „zahlungspflichtig bestellen“ unverändert davor stehen.
**Alternative.** Knopf ohne Preis und den Preis direkt darüber zeigen; bei Überschreiten der Kleinunternehmergrenze Preise und Texte neu fassen (neues Fassungsdatum).
### Q20: Pflichtinformationen (Art. 246a § 1 EGBGB) als Haken und in K15
**Frage.** Haken 4 bestätigt, dass der Besteller die Pflichtinformationen gelesen hat: Leistung (Einrichtung des Terminplaners für diese Website: ein Widget an einer Stelle, gestaltet nach dem Fragebogen, mit Einbau und Datenschutz-Absatz), Gesamtpreis, voraussichtliche Dauer, Ende mit dem Wartungs-Abo, und dass er die Widerrufsbelehrung gelesen hat. Genügen Haken 4 zusammen mit der Belehrung, den versionierten Links auf Leistungsbeschreibung und AVV und der Bestellbestätigung K15 (Zustimmungstexte und PDF-Anhänge) den Informationspflichten nach Art. 246a § 1 Abs. 1 EGBGB? Wird zwischen Klick und Zahlung eine neue Fassung veröffentlicht, schickt K15 die Unterlagen der Fassung, der der Kunde zugestimmt hat (der vereinbarten Fassung), nicht die aktuelle; fehlen sie, geht K15 ohne Anhänge und das Team sendet sie von Hand. Ist das ausreichend? Die gerenderte Mail K15 folgt als Anlage C nach der Mail-Überarbeitung des Inhabers.
**Fundstelle im Text.** Abschnitt 3.1 (Haken 4), Abschnitt 3.4 (Nr. 5), Abschnitt 2.1 Schritt 6; Programm: `k15Rechtsunterlagen`, `onFeaturePaid`, `legalPdfAttachments`.
**Unsere Annahme.** Ja: Die Informationen stehen vor der Bestellung im Text der Seite und in den Unterlagen; der Haken macht die Kenntnisnahme nachweisbar; K15 gibt sie auf einem dauerhaften Datenträger wieder.
**Alternative.** Die Pflichtinformationen als eigenen Textblock auf der Bestellseite und in K15 im Wortlaut wiederholen, statt sie im Haken zu bündeln.
### Q21: Freischaltung durch das Team mit Zustimmung in Textform; K16 mit PDFs
**Frage.** Ohne Bestellung (Kulanz, Pilot) schaltet das Team einen Terminplaner in der Betriebsart `on` (alle Kunden mit Wartungs-Abo, Abschnitt 1) nur frei, wenn der Kunde vorher in Textform (Chat im Kundenportal oder E-Mail) zugestimmt hat; Pilotadressen sind davon ausgenommen. Das Portal speichert als Nachweis (ohne Stripe-Sitzung, IP und Browser; Konto ist der Mitarbeiter) diesen Satz, mit Fassung, Datum und Weg des Falls:
> Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung \<Fassung>) am \<TT>.\<MM>.\<JJJJ> per Chat im Kundenportal in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.
Bei der Zustimmung per E-Mail steht „per E-Mail“ statt „per Chat im Kundenportal“. Für den Chat liefert das Ticketsystem dem Mitarbeiter diese Textvorlage zum Kopieren (die Adressen sind die versionierten Seiten der beiden Unterlagen):
> Damit wir den Terminplaner für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten die Leistungsbeschreibung Terminplaner und der Auftragsverarbeitungsvertrag Terminplaner in der Fassung \<Fassung>: \<Adresse der Leistungsbeschreibung> · \<Adresse des AVV>. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.
Danach geht K16 mit Leistungsbeschreibung und AVV als PDF an den Kunden. Genügt das? (a) Zustimmung durch eine Chat-Antwort für den AVV (Art. 28 Abs. 9 DSGVO) und die Leistungsbeschreibung; (b) der Nachweis ist der Eintrag des Mitarbeiters, die Chat-Nachricht bleibt im Portal; (c) K16 nennt die Anhänge nicht im Text und ist die Bestätigung auf einem dauerhaften Datenträger (die gerenderte Mail folgt als Anlage C nach der Mail-Überarbeitung des Inhabers); (d) für diesen Weg gibt es keine Widerrufsbelehrung (kein Kauf, kein Preis); (e) auch das Wiedereinschalten eines abgeschalteten Terminplaners, den das Team ohne Bestellung freigeschaltet hatte, verlangt dieselbe Textform (bei einem gekauften oder schon aktiven Terminplaner entfällt sie).
**Fundstelle im Text.** Abschnitt 3.5 (Vorbemerkung Abs. 2), Abschnitt 2.1 (Freischaltung ohne Bestellung); Programm: `termineTextformNoetig`, `textformFuerAktivierung`, `adminActivate`, `textformNachweisText`, `meldeFreigeschaltet`; Zusammenhang mit Q7.
**Unsere Annahme.** Ja: Textform genügt für die Zustimmung zu Leistungsbeschreibung und AVV, der Nachweis des Mitarbeiters mit dem Chatverlauf im Portal ist hinreichend, und K16 liefert den dauerhaften Datenträger.
**Alternative.** Die Zustimmung durch den Kunden selbst im Portal (Haken) einholen, auch für Kulanzfälle; K16 im Text auf die Anhänge hinweisen lassen.
### Q22: BFSG-Satz ohne Konformitätszusage
**Frage.** Leistungsbeschreibung Nr. 11 Abs. 2 sagt: Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen, Fokusführung, Fehlermeldungen als Live-Region); eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu; ob der Kunde als Dienstleistungserbringer unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Kunde. Ist die Formulierung ohne Konformitätszusage zulässig und für den Kunden klar genug? Nach unserem Stand gab es für das Widget bisher keinen Lauf mit einem automatischen Prüfwerkzeug oder einem Screenreader.
**Fundstelle im Text.** Abschnitt 3.4 (Nr. 11 Abs. 2).
**Unsere Annahme.** Ja. Der Satz sagt nur, was gestaltet wurde, und weist die Prüfung des BFSG dem Kunden zu.
**Alternative.** Den Satz streichen; oder eine Prüfung des Widgets beauftragen und die Aussage danach anpassen.
### Q23: Nachweis der Bestellung mit IP-Adresse und Browser; Aufbewahrung drei Jahre
**Frage.** Beim Klick auf „Zahlungspflichtig bestellen“ speichert das Portal als Nachweis die gezeigten Texte und Fassungen, den Zeitpunkt, die Stripe-Sitzung, IP-Adresse und Browserkennung, das Konto und die angemeldete Person. Die Datenschutzerklärung sagt dazu:
> Bestellen Sie eine Funktion (News-Pinnwand, Terminplaner), speichern wir als Nachweis die Texte und Fassungen, denen Sie zugestimmt haben, den Zeitpunkt Ihres Klicks auf „Zahlungspflichtig bestellen“, die Stripe-Sitzung, IP-Adresse und Browserkennung sowie Ihr Konto und die angemeldete Person. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO; unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und der Zustimmungen, insbesondere für den Widerruf. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet. Bestellen Sie ohne Abo (zum Beispiel nur die News-Pinnwand), löschen wir diese Angaben spätestens drei Jahre nach Ablauf des Jahres der Bestellung. Den Entwurf Ihres Fragebogens speichern wir bis zu 90 Tage nach Ihrer letzten Änderung und löschen ihn danach, wenn Sie nichts bestellt haben.
Ist die Speicherung der IP-Adresse und der Browserkennung für den Nachweis des Vertragsschlusses und der Zustimmungen erforderlich (Art. 6 Abs. 1 lit. f DSGVO) und die Aufbewahrung bis drei Jahre nach Ablauf des Jahres, in dem das letzte Abo endet, angemessen? Genügt die Information in der Datenschutzerklärung? Für Bestellungen ohne Abo (nur News) nennt die Erklärung eine eigene Frist: drei Jahre nach Ablauf des Jahres der Bestellung. Das Programm löscht diese Nachweise heute noch nicht; die Löschung wird vor Ablauf der ersten Frist (31.12.2029) nachgerüstet. Ist diese Frist angemessen?
**Fundstelle im Text.** Abschnitt 2.1 Schritt 4, Datenschutzerklärung § 7 (Hausrunde); Programm: `speichereZustimmung`, `zustimmungFuerSitzung`, `findDueDeletions` (Löschung der Nachweise gemeinsam mit den Abo-Daten des Kunden; für Kunden ohne Abo noch offen, Abschnitt 7.7).
**Unsere Annahme.** Ja: Der Nachweis dient dem Widerrufsrecht und der Beweisführung; die Frist entspricht der regelmäßigen Verjährung samt Ablauf des Jahres.
**Alternative.** IP-Adresse und Browserkennung weglassen (nur Zeit, Texte, Konto); oder eine kürzere Frist, etwa bis zum Ablauf der Widerrufsfrist und der Gewährleistung.
### Q24: Rollen in Abschnitt 6c; Entwurfsaufbewahrung 90 Tage
**Frage.** Die Datenschutzerklärung von WEBklar (Abschnitt 6c) beschreibt WEBklar als Auftragsverarbeiter für die Daten der Gäste und als Verantwortlichen für die Betriebsdaten im Kundenportal, den Entwurf des Fragebogens und die Bestellung (C1). (a) Stimmt die Rollenverteilung? (b) Der Satz „mit der Buchung speichern wir die IP-Adresse nicht“ ist durch einen Test belegt (Buchung ohne IP und Browser-Kennzeichen); für den Missbrauchsschutz merkt sich der Server Zeitpunkte je IP-Adresse nur im Arbeitsspeicher (C2). Genügt die Darstellung? (c) Anfragen von Gästen leitet WEBklar an den Kunden weiter (A4). (d) Entwürfe aus dem Fragebogen ohne Bestellung löscht WEBklar 90 Tage nach der letzten Änderung: Ist diese Frist zweckgebunden und angemessen?
**Fundstelle im Text.** Abschnitt 2.1 Schritt 2, Anlage A Nr. 8 und Nr. 9 Abs. 7; Datenschutzerklärung 6c (`docs/rechtliches/termine-datenschutz-6c.md`, Hausrunde); frühere Punkte C1, C2, A4; Programm: `ENTWURF_TAGE`.
**Unsere Annahme.** Die Rollen sind richtig verteilt (wie beim Abschnitt 6b der Website-Analyse); 90 Tage Entwurf sind angemessen, weil Kunden Fragebögen über Wochen ausfüllen.
**Alternative.** Entwürfe kürzer aufbewahren (30 Tage) oder nur auf ausdrückliches Speichern des Kunden.
### Q25: Mindestlaufzeit des Wartungs-Abos und Terminplaner
**Frage.** Das Wartungs-Abo hat eine Mindestlaufzeit von zwölf Monaten (§ 8 der Abo-Bedingungen) und ist danach zum Monatsende kündbar. Der Terminplaner hat keine eigene Laufzeit und kein Sonderkündigungsrecht; er endet mit dem Abo. Bestellbar ist er nur bei aktivem Abo ohne vorgemerkte Kündigung (Abschnitt 2.1 Schritt 1). (a) Ist es unbedenklich, dass der Kunde 149 € einmalig für etwas zahlt, das mit dem Abo enden kann, zum Beispiel wenn die Mindestlaufzeit bald endet? (b) Reicht Haken 4 („der Terminplaner endet mit dem Wartungs-Abo“) für die Transparenz, oder braucht die Bestellseite einen Hinweis auf das Ende der laufenden Mindestlaufzeit?
**Fundstelle im Text.** Abschnitt 3.1 (Haken 4), Abschnitt 3.4 (Nr. 8 Abs. 1 und 5), Abo-Bedingungen § 8 (`public/abo-bedingungen.html`); Programm: `bestellbar`, `laeuft`.
**Unsere Annahme.** Ja: Ohne vorgemerkte Kündigung läuft das Abo weiter; wird es später gekündigt, greift die Erstattung nur vor der Freischaltung, danach hat der Kunde den Terminplaner genutzt.
**Alternative.** Auf der Bestellseite das Ende der Mindestlaufzeit anzeigen; bei Beendigung in den ersten Monaten nach der Freischaltung anteilig erstatten.
### Q26: Nachweis der Gäste: Hash und Archiv der Textfassung
**Frage.** Die Buchung eines Gastes speichert als Nachweis die Fassung der Gästetexte, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes der gezeigten Texte, nicht diese Texte selbst. WEBklar archiviert jede Fassung der Gästetexte (Hinweis am Formular, Texte der Haken, Datenschutz-Absatz, Belehrung für Gäste) mit Platzhaltern für die Betriebsangaben bis drei Jahre nach dem Ende des Terminplaners. Die Betriebsangaben (Name, Anschrift, Stornotext, Stunden der Erinnerung, Aufbewahrung) ändern sich später; der Stornotext hat eine eigene Version. Genügt der Hash zusammen mit dem Archiv der Textfassung, um die Zustimmung eines Gastes nachzuweisen? Der Nachweis beruht auf dem Textstand des Servers zum Zeitpunkt der Buchung, nicht auf dem, was das Widget angezeigt hat; wird die Fassung während der Haltezeit von 30 Minuten gewechselt, können beide abweichen (Änderungen sollen außerhalb der Stoßzeiten eingespielt werden).
**Fundstelle im Text.** Abschnitt 2.2 Punkt 5, Abschnitt 3.5 (AVV Nr. 8 Abs. 5); Archiv `docs/rechtliches/archiv/termine-gaestetexte-2026-10-05.md`; Programm: `baueNachweis`, `baueArchiv`.
**Unsere Annahme.** Ja: Mit der Fassung, dem Archiv und den Betriebsangaben des Zeitpunkts lässt sich der gezeigte Wortlaut rekonstruieren; das genügt für den Nachweis, ohne Volltexte je Buchung zu speichern.
**Alternative.** Den vollständigen Text je Buchung speichern (mehr Daten, mehr Speicher); oder den Text je Betrieb und Fassung einmal als Schnappschuss ablegen.
## 5. Fundstellen im Code
Pfade gelten im Repository des Kundenportals (`Webklar-Kundenbereich`); Bezeichner sind Funktionen oder Konstanten und stehen in der genannten Datei. Zeilennummern nennen wir nicht, weil sie sich ändern.
| Thema | Datei | Funktion/Konstante |
|---|---|---|
| Fassung der Vertragsunterlagen und Bestelltexte | `server/config.js` | `avvVersion` |
| Bestell-Haken und Knopf des Terminplaners (Register) | `server/config.js` | `termineConsent`, `TERMINE_PREIS` |
| Preisangabe und Umsatzsteuerhinweis | `server/config.js` | `ustHinweis` |
| Preis im Produktkatalog (14 900 Cent) | `server/config.js` | `termineSetup`, `amountCents` |
| Widerrufsbelehrung der Funktionen (WEBklar als Unternehmer) | `server/services/termine/rechtstexte.js` | `widerrufsbelehrungFunktion`, `WEBKLAR_KONTAKT` |
| Gästetexte: Belehrung, Haken, Hinweis, Datenschutz-Absatz | `server/services/termine/rechtstexte.js` | `widerrufsbelehrung`, `ZUSTIMMUNG_VORZEITIG`, `ZUSTIMMUNG_STORNO`, `ZUSTIMMUNG_ERINNERUNG`, `GESUNDHEIT_HINWEIS`, `formularHinweis`, `datenschutzAbsatz` |
| Fassung der Gästetexte | `server/services/termine/rechtstexte.js` | `RECHTSTEXTE_VERSION` |
| Knopftexte der Gäste, Widerrufsfunktion | `server/services/termine/einstellungen.js` | `knopfText`, `zeigtWiderruf` |
| Wann der Haken zum vorzeitigen Beginn gilt | `server/services/termine/einstellungen.js` | `brauchtZustimmungVorzeitig` |
| Eingabeprüfung der Buchung, Storno-Haken nur bei Preis, Nachweis mit Hashes | `server/services/termine/ablauf.js` | `pruefeEingabe`, `baueNachweis` |
| Storno-Vorschlag der Vorlagen (50 %, 24 Stunden) | `server/services/termine/vorlagen.js` | `stornoText`, `STORNO_PROZENT` |
| Zustimmungsnachweis der Bestellung | `server/services/featureConsents.js` | `speichereZustimmung`, `zustimmungFuerSitzung` |
| Bestellung: Reihenfolge Zahlungssitzung, Nachweis, Weiterleitung | `server/routes/funktionen.js` | `/:key/checkout`, `speichereNachweis`, `createFeatureCheckout` |
| Bestellbestätigung K15 mit PDF-Anhängen der vereinbarten Fassung | `server/services/siteFeatures.js` | `onFeaturePaid`, `k15Rechtsunterlagen` |
| Adressen und Dateien der Rechtsunterlagen | `server/services/legalDocs.js` | `legalPaths`, `legalPdfAttachments`, `featureLegalAttachments` |
| Abo-Prüfung (laufend, bestellbar) | `server/services/aboStatus.js` | `laeuft`, `bestellbar` |
| Täglicher Abo-Abgleich, Mail K22/K23, Erstattung T19 | `server/services/termine/aboAbgleich.js` | `gleicheAboAb`, `meldeAboEnde` |
| Abschalten, Wiedereinschalten, Abschaltgründe | `server/services/siteFeatures.js` | `disable`, `activate`, `DISABLED_REASONS` |
| Löschfrist nach dem Abschalten (30 Tage) | `server/services/termine/frist.js` | `LOESCHFRIST_TAGE` |
| Löschlauf, Fristen der Buchungen | `server/services/termine/jobs.js` | `loescheAbgeschaltete`, `VERFALLEN_BEHALTEN_MS`, `ABGESAGT_BEHALTEN_MS`, `AUFBEWAHRUNG_VORGABE` |
| Aufbewahrung der Entwürfe (90 Tage) | `server/services/termine/entwuerfe.js` | `ENTWURF_TAGE` |
| Versandwarteschlange für E-Mails | `server/services/termine/outbox.js` | `GESENDET_BEHALTEN_MS`, `FEHLER_BEHALTEN_MS` |
| Aufbewahrung der Zustimmungsnachweise der Bestellung | `server/services/retention.js` | `findDueDeletions`, `applyDeletions` |
| Gesundheits-Ausschluss: Branchen, Stichwortwarnung, Bestell-Gate | `server/services/termine/fragebogen.js` | `BRANCHEN`, `gesundheitsWarnungen`, `bestellpruefung` |
| Freischaltung ohne Bestellung: Textform nötig? | `server/services/termine/schalter.js` | `termineTextformNoetig` |
| Freischaltung ohne Bestellung: Prüfung von Datum und Weg, PDFs | `server/routes/admin/funktionenAdmin.js` | `textformFuerAktivierung`, `unterlagenAlsAnhang` |
| Freischaltung ohne Bestellung: Nachweis und Aktivierung | `server/services/siteFeatures.js` | `adminActivate`, `textformNachweisText` |
| Widerruf der Bestellung: Fristende je Bestellung, spätestes Fristende entscheidet über „rechtzeitig“ | `server/routes/abo.js` | `withdrawFeature` |
| Widerrufsfrist (14 Tage), Fristende | `server/services/deadlines.js` | `WITHDRAWAL_DAYS`, `withdrawalDeadline` |
| Widerruf durch Gäste (Widerrufsfunktion) | `server/routes/publicTermine.js` | `/:siteKey/widerruf`, `/termin/widerruf`, `widerrufeGast`, `widerrufeOhneLink` |
| Bestätigungs-E-Mail an Gäste, Hinweis kein Widerrufsrecht | `server/mail-templates/G02 Termin bestaetigt.html` | `kein_widerruf`, `widerrufsbelehrung` |
| Versand der Bestätigungs-E-Mail | `server/services/termine/mails.js` | `gastG02` |
| Eingangsbestätigung des Widerrufs | `server/mail-templates/G08 Eingang Widerruf.html` | `{{eingang}}`, `{{nummer}}`, `nach_frist` |
| Mail „Terminplaner beendet“ (30 Tage, Rückkehr) | `server/mail-templates/K22 Terminplaner beendet.html` | `loeschdatum`, `Rückkehr` |
| Mail „Terminplaner läuft wieder“ | `server/mail-templates/K23 Terminplaner laeuft wieder.html` | `{{kunde_name}}`, `innerhalb der Löschfrist` |
| Hinweise im Kundenportal (Widerruf, Freizeit, Storno-Muster) | `public/termine-tab-einstellungen.js` | `WIDERRUF_HINWEIS`, `FREIZEIT_HINWEIS`, `STORNO_MUSTER_HINWEIS` |
| Archiv der Gästetexte je Fassung | `scripts/termine-texte-archiv.mjs` | `baueArchiv` |
| HTML-Seiten der Vertragsunterlagen | `scripts/build-legal-html.mjs` | `termineDokumente` |
| PDFs der Vertragsunterlagen | `scripts/build-legal-pdfs.sh` | `termine_version`, `TERMINE_VERSION` |
| Muster der Widerrufsbelehrung (Anlage 1) | `docs/rechtliches/quellen/anlage-1.txt` | `Folgen des Widerrufs` |
| Muster-Widerrufsformular (Anlage 2) | `docs/rechtliches/quellen/anlage-2.txt` | `Muster-Widerrufsformular` |
| Datenschutzerklärung von WEBklar: Abschnitt 6c, § 6, § 7 | `public/datenschutz.html` | `id="termine"` |
## 6. Nicht Teil der Kanzleiprüfung
Dieser Abschnitt nennt, was wir der Kanzlei nicht zur Prüfung vorlegen, und warum.
### 6.1 Hausrunde
Diese Texte prüft WEBklar selbst (wie bei der Website-Analyse), weil sie keine der Kernfragen aus Abschnitt 1 betreffen:
- die Datenschutzerklärung von WEBklar mit Abschnitt 6c „Online-Terminbuchung auf Websites unserer Kunden“ und den Ergänzungen in § 6 (Kundenansicht) und § 7 (Nachweise der Funktionsbestellung); Quelle `docs/rechtliches/termine-datenschutz-6c.md` und `public/datenschutz.html`. Nur die Rollenverteilung fragen wir (Q24),
- die Hinweise im Kundenportal (Widerrufs-, Freizeit- und Storno-Hinweise); zu zwei Punkten fragen wir (Q15, Q16),
- die Mailtexte (K15, K16, K22, K23, K04, T09, G02, G08); sie gehen durch die Mail-Überarbeitung des Inhabers. Die offene Formulierung in G02 steht in Q16 und in Abschnitt 7,
- die Einbauanleitung und das Einrichtungs-Ticket für Mitarbeiter.
### 6.2 Später
Diese Themen gehören nicht zu diesem Verkaufsweg und stehen für spätere Pakete bereit:
- Abo-Bedingungen in der Fassung 6 bzw. ein Rahmen-AVV mit einer Anlage je Funktion,
- Themen der Online-Zahlung der Gäste (ein Satz zu Stripe in Abschnitt 6c, Zahlungsdienste, Vorauszahlung in den AGB, Preisangabenverordnung),
- Pflichtangaben auf Geschäftsbriefen (§ 37a HGB),
- Rechtsmissbrauch bei spätem Widerruf,
- Minderjährige,
- Mitgliedschaften,
- wiederholtes Versenden der Mails G08 und K21 nach Fristende,
- Widerruf nur vor Terminbeginn im Verwaltungslink.
### 6.3 Frühere Listen „Für die Rechtsprüfung offen“
Beim Fertigstellen der Gästetexte und der Datenschutzerklärung haben wir Listen offener Rechtsfragen aus den Dokumenten entfernt. Jeder Punkt dieser Listen hat eine Kennung: H1 bis H4 (Hinweise-Dokument), A1 bis A6 (Datenschutz-Absatz für die Website der Betriebe), B1 bis B6 (Muster der Widerrufsbelehrung für Gäste), C1 bis C8 (Entwurf von Abschnitt 6c der Datenschutzerklärung). Jeder Punkt wird entweder bei einer Frage in Abschnitt 4 behandelt (die Kennung steht dort in der Fundstelle) oder ist hier mit Grund aufgeführt:
| Kennung | Punkt | Wo behandelt |
|---|---|---|
| H1 | Belehrung vor dem Vertragsschluss: aufklappbare Darstellung im Widget | Q12 |
| H2 | Widerruf der Einwilligung am Erinnerungshaken | Q17 |
| H3 | Link zur Datenschutzerklärung neben dem Hinweis | Q17 |
| H4 | Bestätigungsfrist: 48 Stunden sind der früheste Zeitpunkt der Löschung | Q9 |
| A1 | Server-Protokolle: 14 Tage und Rechtsgrundlage | Q10 |
| A2 | Drittländer: gilt nur, solange Server und Mailversand bei Hetzner in Deutschland bleiben | Q10 |
| A3 | Löschlauf: Fristen sind die frühesten Zeitpunkte | Q9 |
| A4 | Kontakt der Gäste: Anfragen gehen an den Betrieb, WEBklar leitet weiter | Q24 |
| A5 | Gesundheitsbereich: Hinweis „Bitte keine Gesundheitsangaben“ | Q11 |
| A6 | Knopfmodus: Konfiguration schon beim Laden der Seite | Q10 |
| B1 | Lieferkosten-Klammer in der Belehrung | Q12 |
| B2 | „beginnen soll“ im Singular | Q12 |
| B3 | Anschrift und Telefon des Betriebs sind Pflicht | hier: technisch erledigt. `widerrufsbelehrung()` wirft einen Fehler, wenn eine der vier Angaben fehlt, und die Einstellungsprüfung verlangt beide Angaben. Keine Rechtsfrage. |
| B4 | Wertersatz-Absatz steht immer in der Belehrung | Q13 |
| B5 | Hinweis statt Internetadresse bei der Widerrufsfunktion | Q12 |
| B6 | Gesetzlichkeitsfiktion des Musters | Q12 |
| C1 | Rolle: Auftragsverarbeiter für die Gäste, Verantwortlicher für die Betriebsdaten | Q24 |
| C2 | IP-Adresse im Arbeitsspeicher: Satz „mit der Buchung speichern wir die IP-Adresse nicht“ | Q24 |
| C3 | Kalender-Abo-Link: Information der Beschäftigten | Q10 |
| C4 | Sicherungen: gelöschte Daten bis zu sieben Tage | Q9 |
| C5 | Zahlung der Gäste (spätere Online-Zahlung): Satz zu Stripe in Abschnitt 6c | hier: gehört zur späteren Online-Zahlung der Gäste (Abschnitt 6.2); der Terminplaner kennt heute nur die Zahlung vor Ort. |
| C6 | Geschäftsbriefe: Bestätigungen mit Preis sind keine Rechnungen | hier: Abschnitt 6.2 (Pflichtangaben auf Geschäftsbriefen); die Aufbewahrung nach Abschnitt 9 der Datenschutzerklärung betrifft den Terminplaner nicht. |
| C7 | Ende des Wartungs-Abos: Satz in Abschnitt 6c | Q9 |
| C8 | Knopfmodus: Formulierung zum Laden der Konfiguration in Abschnitt 6c | Q10 |
## 7. Checkliste für den Inhaber
Diese Punkte erledigt der Inhaber; kein Punkt ist Aufgabe der Kanzlei. Die Reihenfolge entspricht dem Ablauf bis zum ersten fremden Betrieb.
### 7.1 Vor der Beauftragung der Kanzlei
- [ ] Die PDFs der Anlagen A und B und der Widerrufsbelehrung einmal am Bildschirm ansehen (Kopfzeile „WEBklar · …“, „Seite x von y“, Tabelle der Anlage des AVV); wir konnten sie nur über ihre HTML-Seiten prüfen.
- [ ] Die Zahl der News-Bestellungen seit 2026-09-30 ermitteln, die mit dem alten Zustimmungstext und ohne Widerrufsbelehrung abgeschlossen wurden, und der Kanzlei nennen (Q4).
- [ ] Bestätigen, dass WEBklar Kleinunternehmer nach § 19 UStG bleibt (Q19).
### 7.2 Hetzner (nur im Konto prüfbar)
- [ ] Auftragsverarbeitungsvertrag mit Hetzner für die Cloud **und** für das Webhosting abgeschlossen (die Buchungs-E-Mails laufen über den SMTP-Dienst des Webhostings).
- [ ] Anlage 1 des Vertrags nennt „Terminbucher“, „Beschäftigte“ und „Buchungsdaten“ als Betroffene und Datenarten.
- [ ] Die Kontoadresse bei Hetzner stimmt mit dem Impressum überein.
- [ ] Die Datensicherung (Backups) ist eingeschaltet; AVV Nr. 9 Abs. 8 und die Abo-Bedingungen versprechen sieben Tagessicherungen.
### 7.3 Uploads in WOMS „Rechtliches“
- [ ] Das Setup-Skript von WOMS mit API-Key ausführen: Es legt die drei Karten „Leistungsbeschreibung Terminplaner“, „AVV Terminplaner (Auftragsverarbeitung)“ und „Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)“ an.
- [ ] Für diese drei Karten die Version der Fassung hochladen (PDF oder HTML aus `public/rechtliches/`; Markdown ist im Bucket nicht erlaubt).
- [ ] Die neue Datenschutzerklärung (Stand 05.10.2026, `docs/WEBklar-Datenschutzerklaerung.pdf`) als neue Version der Karte „Datenschutzerklärung“ hochladen.
- [ ] „Gültig ab“ in `docs/rechtliches/archiv/README.md` eintragen (Fassung der Gästetexte vom 2026-10-05 steht auf „offen bis Livegang“; bei der Fassung vom 2026-10-02 prüfen, ob „gültig ab 2026-10-03 (Pilot)“ stimmt).
### 7.4 Mailtexte
- [ ] Die Platzhalter „ENTWURF (Mailrunde): …“ in `mails.danach` und `mails.bereit` des Terminplaners (Register in `server/config.js`) durch den endgültigen Wortlaut ersetzen. Sie stehen heute in K15 und K16 an Kunden.
- [ ] Die Kopfkommentare von K22 und K23 („Texte sind Entwurf für die Mail-Überarbeitung“) in der Mail-Überarbeitung abarbeiten.
- [ ] K23 (Terminplaner läuft wieder) sagt „Deine Termine und Einstellungen sind unverändert da“. Für die Termine stimmt das nicht immer, weil der Löschlauf auch bei abgeschaltetem Terminplaner abgesagte und erledigte Termine nach ihren Fristen löscht. Den Satz in der Mail-Überarbeitung abschwächen.
- [ ] Nach der Mail-Überarbeitung die gerenderten Mails K15 (mit den Zustimmungen) und K16 als **Anlage C** beilegen und der Kanzlei nachreichen; ihre Angemessenheit als Bestätigung auf einem dauerhaften Datenträger fragen Q3, Q20 und Q21.
- [ ] G02 zeigt die Stornobedingungen und den Satz „Absagen richten sich nach den Stornobedingungen“ (Block `kein_widerruf`) auch bei kostenlosen Buchungen. Einen neutralen Satz festlegen, der für Buchungen ohne Preis passt (Q16).
### 7.5 Beauftragung und Freigabe
- [ ] Die Kanzlei mit der Prüfung des Kernpakets und den Fragen Q1 bis Q26 beauftragen (Anlagen A und B beilegen; Umfang und Frist vereinbaren).
- [ ] Den Befund einarbeiten: Texte ändern, neues Fassungsdatum setzen, HTML und PDFs neu erzeugen, Archiv der Gästetexte und Prüfsummen nachziehen. Dieses Paket (`docs/rechtliches/termine-kanzlei-paket.md`) wird dabei nachgezogen; sein Test meldet jedes Zitat, das nicht mehr wörtlich im Programm oder in den Dokumenten steht.
- [ ] Über die News-Altfälle entscheiden (Q4): Belehrung per Mail nachholen oder nur künftig die neuen Texte.
- [ ] Die Fassung vor dem ersten fremden Betrieb ausdrücklich freigeben; erst danach die Adresse des ersten fremden Betriebs in die Pilotliste aufnehmen.
- [ ] Beim Livegang die Reihenfolge in Abschnitt 7.6 einhalten; den Datenschutz-Abschnitt der Test-Website des Terminplaners auf den Endtext bringen.
### 7.6 Livegang: Reihenfolge
Diese Schritte gelten für den Tag, an dem die Betriebsart `on` gesetzt wird. Die Reihenfolge ist wichtig, vor allem: beide Schemata vor dem Code-Deploy und `on` als letzter Schritt.
- [ ] Vor dem Code-Deploy das Schema des Terminplaners anlegen, erst mit `--check`: `node scripts/termine-setup.mjs` ergänzt in `termineConfig` die Spalte `fragebogen`, in `termineBookings` die Indizes `idx_status_ends` und `idx_status_updated` und in `termineOutbox` den Index `idx_booking`. Ohne sie scheitern Schreibzugriffe und Abfragen des neuen Programms (der Fragebogen, der tägliche Löschlauf und das Löschen von Gastdaten).
- [ ] Vor dem Code-Deploy das Schema der Funktionen anlegen, erst mit `--check`: `node scripts/feature-setup.mjs` (neue Felder von `portalFeatures` und die Collection `featureConsents` mit ihren Indizes). Der Zeitpunkt ist der Code-Deploy, nicht erst der erste Löschlauf der Aufbewahrungsfristen: Schon die erste Bestellung des neuen Programms schreibt die neuen Felder, und ohne Schema antwortet Appwrite mit „Unknown attribute“.
- [ ] Hat sich `public/datenschutz.html` geändert, das PDF der Datenschutzerklärung mit `scripts/build-legal-pdfs.sh datenschutz` neu drucken, nicht mit `all` (das druckt auch die PDFs der Abo-Bedingungen und des Leitfadens neu); die Prüfsumme `docs/WEBklar-Datenschutzerklaerung.pdf.sha256` gehört dazu.
- [ ] Den Code zu einer ruhigen Zeit deployen. Der Nachweis einer Buchung speichert die Fassung der Gästetexte, die der Server beim Speichern hat; bucht ein Gast genau während des Deploys, kann sie von der abweichen, die er auf dem Bildschirm sah.
- [ ] Nach dem Deploy die Uploads in WOMS „Rechtliches“ erledigen (Abschnitt 7.3).
- [ ] Nach dem Deploy und nur mit Freigabe des Inhabers: `scripts/feature-consent-felder-leeren.mjs` zuerst als Probelauf ausführen (ohne Argument oder mit `--dry-run`; schreibt nichts), die Liste prüfen und erst dann mit `--apply` ausführen. Das Skript leert IP-Adresse und Browserkennung der Bestellung an den Funktionsdokumenten (`portalFeatures`), soweit der Nachweis (`featureConsents`) sie trägt; neue Bestellungen speichern sie dort nicht mehr.
- [ ] Als allerletzten Schritt `TERMINE_ENABLED=on` setzen, nachdem alle Punkte oben und die Freigabe der Fassung (Abschnitt 7.5) erledigt sind.
### 7.7 Nach dem Livegang
- [ ] Die Löschfrist für die Nachweise von Kunden ohne Abo (nur News) umsetzen, bevor die erste Frist abläuft (31.12.2029). Die Datenschutzerklärung (Abschnitt 7) sagt für Bestellungen ohne Abo „spätestens drei Jahre nach Ablauf des Jahres der Bestellung“; der Löschlauf (`findDueDeletions`) erfasst heute nur Kunden mit einem Abo, ein Kunde ohne Abo ist nie fällig. Umsetzung: Kunden ohne Abo-Dokument, aber mit Nachweisen in `featureConsents`, mit dem Ankerjahr der letzten Bestellung in den Plan aufnehmen und nur die Nachweise und die Bestellangaben am Funktionsdokument löschen, nicht die Beiträge.
@@ -0,0 +1,62 @@
# Widerrufsbelehrung und Muster-Widerrufsformular (Terminplaner, Dienstleistung)
Fassung 2026-10-05. Quelle des Wortlauts: Anlage 1 und Anlage 2 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB (Fundstelle BGBl. I 2013, S. 3663 bis 3665), abgerufen am 2026-10-02 von gesetze-im-internet.de; die Textdateien liegen unverändert in `docs/rechtliches/quellen/anlage-1.txt` und `anlage-2.txt`.
Der Text unten ist die Fassung, die `widerrufsbelehrung(betrieb)` in `server/services/termine/rechtstexte.js` erzeugt, mit Platzhaltern statt der Betriebsdaten. `test/termineRechtstexte.test.js` prüft, dass (a) jeder Absatz des Musters wörtlich aus den Quelldateien im Ergebnis steht und (b) der Textblock unten genau der Ausgabe der Funktion entspricht. Ändere den Text deshalb nur in `rechtstexte.js` und übernimm ihn hierher.
## Wie das Muster für einen Termin (Dienstleistung) ausgefüllt ist
| Gestaltungshinweis in Anlage 1 | Umsetzung |
|---|---|
| Frist: Textbaustein a) für Dienstleistungsverträge | „des Vertragsabschlusses.“ (Fristbeginn ist der Vertragsschluss: bei einer sofort verbindlichen Buchung die Bestätigung per Link, bei einer Anfrage die Annahme durch den Betrieb) |
| „Fügen Sie Ihren Namen, Ihre Anschrift, Ihre Telefonnummer und Ihre E-Mail-Adresse ein.“ | Name, Anschrift, Telefon, E-Mail aus den Betriebsangaben der Einstellung (`betrieb.name`, `adresse`, `telefon`, `email`); alle vier sind Pflicht, fehlt oder ist eine Angabe leer, wirft `widerrufsbelehrung()` einen TypeError |
| Widerrufsfunktion bei online geschlossenen Verträgen (§ 356a BGB) | Satzrahmen des Gesetzes wörtlich („Sie können Ihr Widerrufsrecht auch online unter … ausüben. Wenn Sie diese Online-Funktion nutzen, …“); in die Lücke „…“ kommt statt einer Internetadresse ein „anderer geeigneter Hinweis“: „Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail); der Gesetzeswortlaut zur Eingangsbestätigung bleibt unverändert |
| Absatz zum elektronisch ausfüllbaren Webformular | ausgelassen: Es gibt keine eigene Webformular-Funktion außer der Widerrufsfunktion |
| Textbausteine zu Waren (Besitz, Rückzahlungsverweigerung, Abholung oder Rücksendung, Rücksendekosten, Wertverlust) | ausgelassen: Ein Termin hat keine Waren |
| Dienstleistungen: Wertersatz („Haben Sie verlangt, dass die Dienstleistungen …“) | enthalten; „oder Lieferung von Wasser/Gas/Strom/Fernwärme [Unzutreffendes streichen]“ ist gestrichen, wie der Gestaltungshinweis es vorsieht |
| Muster-Widerrufsformular (Anlage 2) | enthalten; in der Zeile „An …“ stehen Name, Anschrift und E-Mail des Betriebs; die (*)-Alternativen bleiben unverändert |
Anrede immer „Sie“, auch bei Betrieben, die ihre Gäste duzen: Es ist der gesetzliche Mustertext.
## Text (Platzhalter in doppelten geschweiften Klammern)
```text
Widerrufsbelehrung
Widerrufsrecht
Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.
Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.
Um Ihr Widerrufsrecht auszuüben, müssen Sie uns ({{Name des Betriebs}}, {{Anschrift}}, Telefon: {{Telefon}}, E-Mail: {{E-Mail}}) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.
Sie können Ihr Widerrufsrecht auch online unter „Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail) ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.
Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.
Folgen des Widerrufs
Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.
Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.
Muster-Widerrufsformular
(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)
– An {{Name des Betriebs}}, {{Anschrift}}, {{E-Mail}}:
– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)
– Bestellt am (*)/erhalten am (*)
– Name des/der Verbraucher(s)
– Anschrift des/der Verbraucher(s)
– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)
– Datum
(*) Unzutreffendes streichen.
```
## Wo der Text erscheint
- G02 (Bestätigung an den Gast), Block `widerruf`, als `{{widerrufsbelehrung}}` (die Mailvorlage setzt den Text HTML-maskiert ein); nur wenn die Buchung ein Widerrufsrecht hat (verbindlich, Verbraucher).
- Bei einer Freizeit-Dienstleistung mit festem Termin (§ 312g Abs. 2 Nr. 9 BGB) und bei Gästen, die nur Unternehmen sind, entfallen Belehrung, Formular und Widerrufsfunktion; der Hinweis steht dann in `termine-hinweise.md`.
@@ -0,0 +1,22 @@
# Entwurf: Abschnitt 6f der Datenschutzerklärung (Buchungskalender)
Stand 2026-10-07, Fassung der Rechtstexte 2026-10-07. **Entwurf – Prüfung durch Kanzlei offen.** Der Text kommt als Abschnitt `6f` in `public/datenschutz.html`, hinter 6d (Webshop; 6e ist für die Öffnungszeiten reserviert und noch nicht live) und vor Abschnitt 7. Er wird erst mit dem Livegang übernommen (`docs/funktionen/unterkunft-livegang.md`, Schritt 9), danach die PDF-Fassung `scripts/build-legal-pdfs.sh datenschutz` neu drucken. Mit der Übernahme zugleich im Abschnitt 12 „Empfänger im Überblick“ die Zeile zu Stripe und in Abschnitt 3 die Verweise prüfen. Der Text ist an Abschnitt 6d angelehnt und beschreibt, was die Software tut; ändert sich der AVV Buchungskalender, hier mitändern.
## Text (als HTML-Absätze übernehmen)
**6f. Buchungskalender auf Websites unserer Kunden**
Kunden können auf ihrer eigenen Website einen Buchungskalender für ihre Unterkünfte betreiben („Buchungskalender“), sobald wir die Funktion für sie freigeschaltet haben. Dabei verarbeiten wir die Daten der Personen, die dort eine Unterkunft anfragen oder buchen („Gäste“), ausschließlich als Auftragsverarbeiter nach den Weisungen des jeweiligen Kunden, der dafür Verantwortlicher ist (Auftragsverarbeitungsvertrag Buchungskalender). Wir verwenden diese Daten nicht für eigene Zwecke. Verantwortliche sind wir für die Betriebsdaten des Kunden im Kundenportal und für seine Bestellung der Funktion. Diese Datenschutzerklärung gilt für unsere eigenen Seiten und das Kundenportal. Wer auf der Website eines Kunden anfragt oder bucht, findet die Informationen dazu (Zwecke, Rechtsgrundlagen, Speicherdauer, Rechte) in der Datenschutzerklärung dieser Website. Anfragen von Gästen zu ihren Daten leiten wir an den Kunden weiter.
Für den Betrieb des Buchungskalenders speichern wir im Auftrag des Kunden: bei einer Anfrage Name, E-Mail-Adresse, auf Wunsch Firma, Telefonnummer und Nachricht, die Unterkunft, den Zeitraum und die Zahl der Personen sowie bis zur Entscheidung des Kunden einen Hashwert der IP-Adresse (siehe unten); nach der Freigabe die Posten und den Preis des Aufenthalts, die Stornobedingungen und die Fassung der Rechtstexte, die galten; bei der Buchung die Rechnungsadresse, die Zahlart und die Rechnung (und bei einer Stornierung die Stornorechnung) mit lückenloser Rechnungsnummer, in der der Kunde als Aussteller genannt ist; den Stand der Buchung (zum Beispiel gebucht, bezahlt, storniert) mit den Zeitpunkten, bei einer Online-Zahlung die Kennungen der Zahlung bei Stripe und erstattete Beträge, auch automatische Erstattungen (siehe unten). Zahlungsdaten wie die Kartennummer gibt der Gast nur auf der Zahlungsseite von Stripe ein; wir erhalten sie nicht (Online-Zahlung derzeit nur per Karte, auch mit Apple Pay und Google Pay). Der Kunde sieht und bearbeitet Anfragen, Buchungen und Rechnungen im Kundenportal. Im Namen des Kunden stellen wir die Rechnungen aus (Rechnung im Namen und im Auftrag des Kunden, § 14 Abs. 2 Satz 4 UStG) und senden E-Mails an Gäste (Eingang der Anfrage, Freigabe mit Link zur Buchung, Ablehnung, Buchungsbestätigung mit Rechnung, Zahlungseingang, Ablauf einer Reservierung, Stornierung) und an den Kunden (neue Anfrage, neue Buchung, Hinweis bei fehlgeschlagenem Kalenderabgleich) über den E-Mail-Dienst der Hetzner Online GmbH (Rechenzentrum Nürnberg, Abschnitt 8). Als Absender erscheint der Kunde, Antworten gehen an ihn. Bis zum Versand liegt jede E-Mail in einer Warteschlange; nach dem Versand löschen wir dort Empfängeradresse und Inhalt, den Eintrag selbst nach sieben Tagen; E-Mails, die nicht zugestellt werden konnten, löschen wir nach 48 Stunden. Der Link zur Buchungsseite enthält ein geheimes Kennzeichen, das wir nur als Prüfsumme speichern; bei einer Online-Zahlung übergeben wir den Link mit dem Kennzeichen als Rücksprungadresse an Stripe, sodass er für Stripe und im Stripe-Dashboard des Kunden sichtbar ist (er gilt bis 90 Tage nach der Abreise, bei nicht zustande gekommenen Buchungen 30 Tage, und öffnet nur diese eine Buchungsseite). Das Widget setzt keine Cookies. Der Kunde kann Kalender anderer Plattformen einbinden und die geheime Kalenderadresse des Buchungskalenders bei ihnen eintragen; wir lesen aus fremden Kalendern nur belegte Zeiträume, nicht Angaben zu Gästen, und die Adresse des Buchungskalenders enthält nur belegte Nächte. Zum Schutz vor Missbrauch begrenzen wir die Zahl der Anfragen je IP-Adresse; die Zeitpunkte der Aufrufe merken wir uns nur im Arbeitsspeicher des Servers, nicht dauerhaft. Mit der Anfrage speichern wir nicht die IP-Adresse, sondern einen Hashwert davon, der mit einem geheimen Schlüssel unseres Servers berechnet wird (Pseudonym); ohne den Schlüssel lässt sich daraus keine IP-Adresse ableiten. Wir verwenden ihn nur für den Missbrauchsschutz und entfernen ihn, sobald der Kunde die Anfrage freigibt oder ablehnt, spätestens nach 30 Tagen. Im Übrigen gelten die Protokolle in Abschnitt 3.
Online-Zahlungen im Buchungskalender laufen über Stripe Connect unmittelbar auf das eigene Stripe-Konto des Kunden; wir erhalten und verwahren kein Geld der Gäste und beteiligen uns nicht am Umsatz. Beim Start einer Zahlung übergeben wir Stripe die Bezeichnung des Aufenthalts mit Rechnungsnummer, den Betrag, die Kennungen der Buchung, die E-Mail-Adresse des Gastes zur Vorbelegung und den Rücksprung-Link zur Buchungsseite. Wird ein Aufenthalt storniert, schließen wir eine offene Zahlungsseite. Geht eine Zahlung nach einer Stornierung oder dem Ablauf der Reservierung ein oder zahlt der Gast eine bezahlte Buchung ein zweites Mal, erstatten wir den Betrag im Namen des Kunden und nach seiner dauerhaften Weisung automatisch in voller Höhe über sein Stripe-Konto; der Stand der Buchung ändert sich dadurch nicht. Um dieses Konto zu eröffnen, übermitteln wir, sobald der Kunde im Kundenportal die Verbindung mit Stripe auslöst, seine E-Mail-Adresse, die Adresse seiner Website und den Namen seines Projekts an die Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, D02 H210, Irland („Stripe“), zusammen mit internen Kennzeichen, mit denen wir das Konto seinem Projekt zuordnen. Die weiteren Angaben für das Konto gibt der Kunde direkt bei Stripe ein. Wir speichern die Kennung des Stripe-Kontos und seinen Stand (ob Zahlungen und Auszahlungen möglich sind), den uns Stripe meldet, und zeigen ihn dem Kunden im Kundenportal an. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag mit dem Kunden). Stripe verarbeitet die Daten für sein Konto als eigener Verantwortlicher; dabei können Daten an die Stripe, LLC in den USA übermittelt werden, die nach dem EU-US Data Privacy Framework zertifiziert ist (Abschnitt 7). Es gelten die Datenschutzhinweise von Stripe: stripe.com/de/privacy.
Löschfristen: Den IP-Hash entfernen wir, sobald der Kunde eine Anfrage freigibt oder ablehnt, spätestens nach 30 Tagen. Anfragen, aus denen keine Buchung wurde, anonymisieren wir nach sechs Monaten. Bei gebuchten Aufenthalten anonymisieren wir Name, Firma, E-Mail-Adresse, Telefonnummer und Nachricht drei Jahre nach dem Ende des Jahres der Abreise; die Rechnungen und die Rechnungsadresse bewahren wir für den Kunden acht Jahre auf (Buchungsbelege, § 147 AO), solange der Buchungskalender läuft. Schaltet der Kunde den Buchungskalender ab oder endet der Vertrag über den Betrieb seiner Website, schalten wir den Buchungskalender ab und weisen den Kunden danach per E-Mail mit dem Löschdatum darauf hin, seine Rechnungen herunterzuladen. Der Kunde kann Anfragen, Buchungen und Rechnungen danach 90 Tage lang im Kundenportal lesen; danach löschen wir alle Daten des Buchungskalenders (Unterkünfte, Preise, Anfragen, Buchungen, Rechnungen, Kalenderverbindungen, Sperren und Einstellungen). Wird das Kundenkonto gelöscht, schalten wir den Buchungskalender ab und löschen seine Daten 90 Tage danach; einen Hinweis per E-Mail gibt es dann nicht, weil mit dem Konto auch die E-Mail-Adresse gelöscht wird. Beginnt innerhalb dieser Frist wieder ein Vertrag über den Betrieb der Website, schalten wir den Buchungskalender auf Wunsch des Kunden mit den vorhandenen Daten wieder ein. Eine Ausnahme ist das Einrichtungs-Ticket, in dem wir die Einrichtung als Arbeitsnachweis festhalten (Abschnitt 6): Es bleibt mit einer Kurzfassung der Angaben zur Einrichtung bestehen und enthält keine Daten von Gästen. Löschen wir Daten oder anonymisieren wir sie, bleiben sie in unseren Datensicherungen noch bis zu sieben Tage erhalten und werden dann überschrieben. Die Daten liegen auf unserem Server bei Hetzner im Rechenzentrum Falkenstein (Deutschland), wie die übrigen Portal-Daten (Abschnitt 3). Die Bestellung der Funktion und ihren Nachweis beschreibt Abschnitt 7.
## Hinweise für die Übernahme
- Online-Zahlung gibt es derzeit nur per Karte (kein SEPA); bei einer Erweiterung die Texte (6f, AVV Nr. 3 und 7, LB Nr. 3, Absatz) mitändern.
- Die Aufbewahrung der Rechnungen für 8 Jahre durch WEBklar ist ein Dienst für den Kunden und endet mit der Löschung 90 Tage nach dem Abschalten (Frage U6 im Kanzlei-Paket).
- Abschnitt 12 „Empfänger im Überblick“ der Datenschutzerklärung: Stripe-Zeile um „Buchungskalender“ ergänzen.
- Abschnitt 7 (Bestellung der Funktion): Der Satz zu Zustimmungsnachweisen gilt für den Buchungskalender entsprechend; prüfen, ob die Aufzählung der Funktionen dort ergänzt werden muss.
@@ -0,0 +1,34 @@
# Fragen an die Kanzlei: Buchungskalender
Stand 2026-10-07. Gegenstand der Prüfung: Leistungsbeschreibung Buchungskalender und AVV Buchungskalender (Fassung 2026-10-07, beide als „Entwurf – Prüfung durch Kanzlei offen“ gekennzeichnet), Abschnitt 6f der Datenschutzerklärung (`unterkunft-datenschutz-6f-entwurf.md`), Muster-Datenschutz-Absatz für die Website des Vermieters (`docs/funktionen/unterkunft-datenschutz-absatz.md`), Muster-Stornobedingungen (`docs/funktionen/unterkunft-storno-muster.md`), die Zustimmungen der Bestellung (Wortlaut in `server/config.js`, Register `unterkunft`) und die Buchungsseite. Der Buchungskalender ist für Ferienwohnungen, Monteurzimmer und kleine Pensionen gedacht; erste Kundin ist eine Vermieterin von Monteurunterkünften (Unternehmer als Gäste, Verbraucher möglich). Die Arbeitsweise der Software steht im Entwurf der Spezifikation `docs/superpowers/specs/2026-10-07-buchungskalender-design.md`; Muster ist das Kanzlei-Paket zum Webshop (`webshop-kanzlei-paket.md`).
## Fragen
**U1. Button-Lösung über Mail-Link (§ 312j Abs. 3 BGB).** Die Anfrage des Gastes ist unverbindlich (Schaltfläche „Anfrage senden“). Der Vertrag entsteht erst, wenn der Gast nach der Freigabe durch den Vermieter über den Link in der E-Mail die Buchungsseite öffnet und „Zahlungspflichtig buchen“ klickt; unmittelbar darüber stehen alle Pflichtangaben. Genügt das für die Button-Lösung, auch wenn der Vermieter den Preis vor der Freigabe ändern kann und der Gast ihn erst auf der Buchungsseite verbindlich sieht? Ist die Beschriftung „Zahlungspflichtig buchen“ auch bei der Zahlart „vor Ort“ (keine Zahlung im Moment der Buchung) zulässig, oder braucht es eine andere Beschriftung („Verbindlich buchen“)? Muss die Anfrage-Schaltfläche anders heißen, damit niemand sie für verbindlich hält?
**U2. Pflichtinformationen (Art. 246a § 1 EGBGB, § 312d BGB) und Rolle des Vermieters.** Welche Angaben muss die Buchungsseite zeigen, welche stehen in der Buchungsbestätigung (dauerhafter Datenträger, § 312f BGB), und welche muss der Vermieter auf seiner Website selbst vorhalten (Impressum, Informationen zum Anbieter, Verbraucherstreitbeilegung nach § 36 VSBG)? Reicht die Buchungsseite (Anbieter, Unterkunft, Zeitraum, Leistung, Gesamtpreis, Zahlart, Stornobedingungen, Hinweis zum Widerrufsrecht), und was fehlt für Beherbergung typischerweise (zum Beispiel Preisangabenverordnung, Pflichtangaben zur Unterkunft, Hausordnung)? Welche Pflichten treffen WEBklar als Betreiber der Seite im Namen des Vermieters? Insbesondere: Die Bestätigungsmail (G19) nennt den Anbieter mit Name und Anschrift aus den Einstellungen, aber ohne Impressum-Link und ohne Steuernummer; muss der Anbieter dort vollständig mit Impressum und Steuernummer genannt werden?
**U3. Kein Widerrufsrecht (§ 312g Abs. 2 Satz 1 Nr. 9 BGB).** Wir weisen auf der Buchungsseite darauf hin, dass für die Buchung zu einem bestimmten Zeitraum kein Widerrufsrecht besteht. Gilt die Ausnahme auch für Monteurunterkünfte, die langfristig oder für Unternehmer und Verbraucher gemischt vermietet werden, und für Aufenthalte, deren Zeitraum über Wochen reicht? Insbesondere: Ist ein langer Monteursaufenthalt (Wochen bis Monate, eher Wohnzweck als Beherbergung) noch „Beherbergung zu Freizeitzwecken“, oder entfällt die Ausnahme, und was bedeutet das für die Belehrung? Ist die Formulierung („Ein Widerrufsrecht besteht nicht (§ 312g Abs. 2 Satz 1 Nr. 9 BGB).“) ausreichend, und wie ist die Belehrungspflicht zu verstehen, wenn eine Ausnahme vorliegt?
**U4. Umsatzsteuer der Endreinigung.** Die Software kennt je Vermieter einen Steuersatz für die Übernachtung (Standard 7 %) und einen für die Endreinigung (Standard 7 %) und rechnet die Steuer aus den Bruttobeträgen heraus. Ist es zulässig, die Endreinigung als unselbständige Nebenleistung der Beherbergung mit demselben Satz zu behandeln (§ 12 Abs. 2 Nr. 11 UStG), oder wäre 19 % der sichere Standard? Wie ist es bei Kleinunternehmern (§ 19 UStG, Hinweistext auf der Rechnung) und bei langfristigen Vermietungen, bei denen die Steuerbefreiung nach § 4 Nr. 12 UStG greifen kann? Wie weit darf WEBklar Voreinstellungen vorgeben, ohne dass daraus eine Steuerberatung wird?
**U5. Rechnung im Namen des Vermieters (§ 14 Abs. 2 Satz 4 UStG).** WEBklar stellt die Rechnungen in Namen und Auftrag des Vermieters aus; er stimmt bei der Bestellung und mit einem Haken in den Einstellungen zu. Genügt das als Zustimmung (Textform?) und ist die Abrechnung durch einen Dritten damit sauber geregelt, auch für die Stornorechnung (Rechnungskorrektur mit eigener Nummer) und die lückenlose Nummerierung je Vermieter und Jahr? Gilt das Konstrukt auch für Rechnungen an Verbraucher (dann ist die Rechnung keine Pflichtrechnung, sondern Beleg) und reichen die Pflichtangaben nach § 14 Abs. 4 UStG, wenn der Vermieter unvollständige Stammdaten hinterlegt? Wer haftet für falsche Steuerangaben auf der Rechnung (§ 14c UStG)?
**U6. Aufbewahrung durch WEBklar (8 Jahre).** WEBklar speichert die Rechnungen und die Rechnungsadresse der Gäste 8 Jahre lang, solange der Buchungskalender läuft, und löscht sie 90 Tage nach dem Abschalten; der Vermieter wird vorher aufgefordert, sie herunterzuladen. Reicht dieser Dienst, ohne dass WEBklar selbst aufbewahrungspflichtig oder zum Buchführungssystem wird (§ 147 AO, GoBD), und wie ist die Spannung zwischen der 8-Jahres-Frist und der Löschung nach 90 Tagen zu regeln? Die Gästedaten ohne Rechnungsbezug werden 3 Jahre nach dem Ende des Abreisejahres anonymisiert und Anfragen ohne Buchung nach 6 Monaten: Sind die Fristen angemessen (Art. 5 Abs. 1 lit. e DSGVO)?
**U7. Rolle im Datenschutz, AVV Buchungskalender.** Der Vermieter ist Verantwortlicher, WEBklar Auftragsverarbeiter (Muster AVV Webshop). Ist die Rollenverteilung für die Rechnungserstellung und die Aufbewahrung richtig (kein eigener Zweck von WEBklar)? Ist die Behandlung von Stripe (Vertragspartner des Vermieters, kein Unterauftragsverarbeiter) und der importierten Kalender anderer Plattformen (nur Zeiträume, keine Gästedaten) vertretbar? Genügt die Bezeichnung des IP-Hash als pseudonymes Datum mit Löschung bei der Entscheidung? Der geheime Buchungslink (Token, nur als Prüfsumme gespeichert) gilt bis 90 Tage nach der Abreise, bei abgelehnten oder abgelaufenen Anfragen 30 Tage nach der Entscheidung; er ist für Stripe und im Stripe-Dashboard des Vermieters sichtbar (Rücksprungadresse). Ist die Frist angemessen? Wird das Kundenkonto gelöscht, schaltet WEBklar den Buchungskalender ab und löscht die Daten 90 Tage später ohne E-Mail-Hinweis (die Adresse ist dann gelöscht): ausreichend?
**U8. Haftung bei Doppelbuchung durch iCal-Verzögerung.** Der Abgleich mit Booking.com, Airbnb und ähnlichen Plattformen geschieht über iCal mit Verzögerung von Minuten bis Stunden; jede Anfrage braucht die Freigabe, und beim Freigeben und Buchen prüft die Software erneut. Eine Doppelbuchung ist trotzdem möglich. Ist der Haftungsausschluss in Nr. 4 und Nr. 11 der Leistungsbeschreibung (keine Echtzeit-Verfügbarkeit, kein Ersatz von Folgeschäden außer im Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen) wirksam, auch gegenüber Verbrauchern? Die Leistungsbeschreibung richtet sich nur an Vermieter, die Unternehmer sind; für deren Gäste gilt das nicht. Ist eine hervorgehobene Warnung im Portal sinnvoll oder nötig?
**U9. Zustimmungen der Bestellung.** Genügen die sechs Haken (Unterlagen, Verantwortung, Rechnung im Auftrag, Stripe, Beginn der Leistung und Erlöschen des Widerrufsrechts, Pflichtinformationen) und die Widerrufsbelehrung der Funktionen, wenn der Vermieter in der Praxis Unternehmer ist? Muss der Haken zu Stripe entfallen, wenn der Vermieter keine Online-Zahlung nutzt?
**U10. Kein Meldeschein, keine Kurtaxe, keine Anzahlung.** Die Leistungsbeschreibung nimmt Meldeschein, Kurtaxe und Anzahlungen aus dem Umfang und weist die Pflichten dem Vermieter zu (Nr. 2 Abs. 7, Nr. 5 Abs. 6). Ist das ausreichend deutlich, und wie ist eine Haftung von WEBklar wegen fehlender Hinweise ausgeschlossen?
**U11. Muster-Stornobedingungen.** Der Text `unterkunft-storno-muster.md` ist eine Vorlage für Vermieter (Staffel nach Zeit bis zur Anreise, Erstattungen, Verhältnis zu § 537 BGB und § 308 Nr. 7 BGB bei Verbrauchern, Pauschalen nach § 309 Nr. 5 BGB). Ist er als Vorlage mit Hinweis „keine Rechtsberatung“ vertretbar? Welche Pauschalen sind üblich und zulässig?
**U12. Datenschutz-Absatz für die Vermieter-Website.** Ist der Muster-Absatz `unterkunft-datenschutz-absatz.md` (Verantwortlicher, Rechtsgrundlagen Art. 6 Abs. 1 lit. b und f, Stripe, E-Mails, Speicherdauer) und der Abschnitt 6f der WEBklar-Erklärung in sich schlüssig und vollständig?
## Prüfpunkte vor dem Livegang
- Fassung der Rechtstexte: Ändert die Kanzlei etwas, gibt es eine neue Fassung (Datum, `config.unterkunft.fassung`, Zustimmungs-Pin in `test/featureConsentTexte.test.js`); ausgelieferte Fassungen werden nie überschrieben.
- Danach Upload in WOMS „Rechtliches“ und Schalter `UNTERKUNFT_ENABLED` von `pilot` auf `on` (Livegang-Schritt 10).
@@ -0,0 +1,3 @@
# Fragen an die Kanzlei: Webshop
Die Arbeitsliste der Fragen F1 bis F18 ist in das Kanzlei-Paket übernommen und dort um F19 bis F29 ergänzt worden. Maßgeblich ist jetzt: **`docs/rechtliches/webshop-kanzlei-paket.md`**, Abschnitt 4. Diese Datei bleibt nur als Verweis bestehen.
+394
View File
@@ -0,0 +1,394 @@
# Kanzlei-Paket Webshop (WEBklar)
Dieses Paket gehört zum Verkaufsweg des Webshops der WEBklar GbR (Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein). Es zeigt einer Kanzlei, was verkauft wird, wie der Ablauf ist, welche Texte Händler und Käufer in Endfassung sehen, wo diese Texte im Programm stehen und was wir wissen möchten. Es ist wie das Paket des Terminplaners (`docs/rechtliches/termine-kanzlei-paket.md`) aufgebaut; Fragen, die dort schon gestellt sind, nennen wir mit Verweis.
Fassungen: Leistungsbeschreibung Webshop, Auftragsverarbeitungsvertrag Webshop (AVV) und die Bestelltexte tragen die Fassung **2026-10-05** (Konstante `config.shop.fassung`). Die Widerrufsbelehrung mit Muster-Widerrufsformular ist die gemeinsame der Funktionen (`docs/rechtliches/widerrufsbelehrung-funktionen.md`, Fassung des Terminplaners). Alle Texte sind noch nie ausgeliefert worden; der Webshop bleibt bis zu Ihrem Befund hinter dem Schalter `SHOP_ENABLED` (nur Pilot).
## 1. Zweck und Umfang
**Zweck.** Der Inhaber lässt die Texte des Webshops durch eine Kanzlei prüfen, bevor der erste fremde Betrieb ihn nutzt (Schalter `SHOP_ENABLED=on`). Das Paket soll ohne Rückfragen zeigen, was die Kanzlei prüfen soll und von welchen Annahmen wir ausgehen.
**Was verkauft wird.** WEBklar verkauft nicht Waren, sondern ein Hilfsmittel: ein Shop-Widget für die Website eines Händlers samt Verwaltung im Kundenportal, Einrichtung einmalig 299 €, ohne Umsatzsteuer gemäß § 19 UStG, keine laufende Gebühr, keine Umsatzbeteiligung. Die Käufer bezahlen über Stripe Connect direkt an den Händler (eigenes Stripe-Konto, Direct Charges); WEBklar erhält und verwahrt kein Geld. Vertragspartner der Käufer ist der Händler. Der Webshop läuft, solange WEBklar die Website des Händlers betreibt; er ist nicht an Abo-Daten im Programm gekoppelt (Entscheidung W5, Abschnitt 2.5).
**Kernpaket.** Wir bitten die Kanzlei, zu prüfen:
- Leistungsbeschreibung Webshop (Anlage A) und AVV Webshop (Anlage B), vor allem Haftung, Ende mit dem Vertrag über den Betrieb der Website, ausgeschlossene Waren und Rolle bei Zahlungen (F1 bis F18),
- die Bestelltexte der Einrichtung: fünf Haken und Knopf (Abschnitt 3.1, F26),
- die Muster-Rechtstexte für Händler (Anlage C: AGB, Widerrufsbelehrung inklusive § 356a BGB, Muster-Widerrufsformular, Versand und Zahlung; F25),
- die Kasse im Widget: Knopf, Pflichtinformationen, Widerrufsfunktion und Gewährleistungshinweis (Abschnitte 3.3 und 3.4, F19, F20, F24),
- die Pflichtangaben am Artikel: Grundpreis, Lebensmittel, Produktsicherheit (F21 bis F23).
**Hausrunde.** Die Datenschutzerklärung von WEBklar (Abschnitt 6d), den Muster-Absatz für die Datenschutzerklärung des Händlers (Anlage D), die Mailtexte und die Hinweise im Kundenportal prüft WEBklar selbst. Zu einzelnen Punkten daraus stellen wir trotzdem Fragen (F27, F28).
**Begriffe.** *Händler* ist der Kunde von WEBklar, der den Webshop betreibt; *Käufer* sind die Besucher seiner Website, die bestellen. *Betriebsart* des Webshops ist `off` (abgeschaltet), `pilot` (nur Adressen einer Pilotliste) oder `on` (alle Kunden). Derzeit ist der Webshop nur für Konten des Inhabers freigeschaltet. `on` setzt der Inhaber erst nach Ihrem Befund und seiner Freigabe.
**Keine Rechtsberatung durch WEBklar.** Die Texte für Händler sind Muster ohne Gewähr (Leistungsbeschreibung Nr. 6). Die rechtlichen Aussagen in diesem Paket (vor allem „unsere Annahme“ bei den Fragen) sind ein Arbeitsstand von WEBklar und kein Rechtsurteil.
**Fassungen und Änderungen.** Ändern wir nach dem Befund einen Text, bekommt er ein neues Fassungsdatum und neue Dateien (HTML, PDF und Prüfsumme); die Zitate in diesem Paket werden dann angepasst.
**Aufbau.** Abschnitt 2 beschreibt den Produktablauf, Abschnitt 3 enthält die Texte, Abschnitt 4 unsere Fragen F1 bis F29, Abschnitt 5 die Fundstellen im Programm, Abschnitt 6 was nicht Teil der Prüfung ist, Abschnitt 7 die Checkliste für den Inhaber vor `SHOP_ENABLED=on`. Anlagen: **Anlage A** Leistungsbeschreibung Webshop, **Anlage B** AVV Webshop, **Anlage C** Muster-Rechtstexte der Händler, **Anlage D** Muster-Datenschutzabsatz für die Händler-Website (Pfade in Abschnitt 3.5).
## 2. Produktablauf in Kurzform
### 2.1 Anfrage, Freigabe und Bestellung der Einrichtung
| Schritt | Was geschieht |
|---|---|
| 1. Anfrage | Der Händler fragt den Webshop im Kundenportal für eine seiner Websites an: was er verkauft (Freitext und Warenart), ungefähre Artikelzahl, Versand und/oder Abholung, zwei Pflicht-Haken (er ist Unternehmer; er verkauft keine der ausgeschlossenen Waren), Nachricht an das Team. |
| 2. Prüfung | Das Team prüft die Anfrage (erlaubte Waren, Eignung) und nimmt sie an oder lehnt sie mit Begründung ab. Der Händler erhält eine Mail mit der Entscheidung. Ein Anspruch auf Freigabe besteht nicht (LB Nr. 2 Abs. 1). |
| 3. Bestellung | Nach der Freigabe bestellt der Händler die Einrichtung für 299 €: Zusammenfassung, volle Widerrufsbelehrung mit Muster-Widerrufsformular, fünf Haken, Knopf „Zahlungspflichtig bestellen · 299 €“ (Abschnitte 3.1 und 3.2). Eine Bindung an ein Abo besteht nicht. |
| 4. Nachweis | Der Server legt zuerst die Zahlungssitzung bei Stripe an und speichert den Nachweis: gezeigte Texte, Fassungen, Hash, Zeitpunkt des Klicks, IP-Adresse und Browser-Kennzeichen, Kundenkonto und angemeldete Person. Der Zeitpunkt des Klicks ist der Anker der Widerrufsfrist. |
| 5. Zahlung und Ticket | Stripe Checkout; danach legt das Portal ein Einrichtungs-Ticket mit der Einbauanleitung an, der Händler erhält die Bestellbestätigung mit den Unterlagen (Leistungsbeschreibung, AVV, Widerrufsbelehrung als PDF) in der Fassung, der er zugestimmt hat. |
| 6. Einrichtung | Der Händler verbindet sein Stripe-Konto (Onboarding bei Stripe), legt Artikel, Versand, Zahlungsarten und Rechtstexte an und bestätigt die Rechtstexte (Abschnitt 3.2). WEBklar baut das Widget und den Link „Vertrag widerrufen“ in die Website ein (Anleitung `docs/funktionen/shop-einbau.md`), fügt den Datenschutz-Absatz ein (Anlage D), macht mit dem Händler einen Testkauf mit Erstattung und schließt das Ticket; das schaltet die Funktion frei. Schaltet ein Mitarbeiter die Funktion stattdessen ohne Zahlung ein (Betriebsart `on`, Kunde nicht in der Pilotliste), verlangt das Portal vorher die Zustimmung des Kunden in Textform (Datum, Weg) und speichert sie als Zustimmungsnachweis (`featureConsents`, mit Mitarbeiter, ohne IP-Adresse und Browser-Kennzeichen); der Kunde erhält danach Leistungsbeschreibung und AVV als PDF (AVV Vorbemerkung Abs. 2). |
| 7. Öffnen | Der Händler öffnet den Shop (Schalter „Shop geöffnet“), sobald Einbau fertig, Stripe-Konto zahlungsfähig, Rechtstexte bestätigt, ein Artikel aktiv und Versand oder Abholung eingerichtet sind. Der Server prüft das bei jeder Bestellung erneut. |
### 2.2 Käufer: Bestellung
1. Der Käufer wählt Artikel (mit Grundpreis, Lebensmittel- und Produktsicherheitsangaben, Widerrufsausschluss), legt sie in den Warenkorb (im Sitzungsspeicher des Tabs, kein Cookie) und wählt Versand (nur Deutschland und Österreich) oder Abholung. Ganz oben im Widget stehen Lieferländer und Zahlungsarten.
2. In der Kasse stehen unmittelbar über dem Knopf die Pflichtinformationen, der Gewährleistungshinweis (Abschnitt 3.4) und der Knopf „Zahlungspflichtig bestellen“ (Abschnitt 3.3). Der Server rechnet alle Preise neu, prüft und reserviert den Bestand (30 Minuten).
3. Der Käufer wird zu Stripe Checkout geleitet und gibt dort Name, Adresse und Zahlung ein (Karte, Apple Pay, Google Pay, Klarna, SEPA-Lastschrift je nach Einstellung des Händlers; kein PayPal). Die Stripe-Kasse verlangt keine Telefonnummer (WEBklar erhebt und speichert auch keine) und fragt die Rechnungsanschrift nur ab, soweit die Zahlungsart sie braucht. SEPA gilt erst bei Eingang als bezahlt; bis dahin erhält der Käufer einmal eine Eingangsmail (G15) ohne Vertragsschluss.
4. Nach der Zahlung erhält der Käufer die Bestellbestätigung (G09, Abschnitt 3.3), der Händler die Mail „Neue Bestellung“ (K25).
5. Der Händler pflegt Status (versendet, abholbereit, abgeschlossen), erstattet über das Portal (Stripe-Rückzahlung auf dem Konto des Händlers) und exportiert Bestellungen als CSV.
### 2.3 Käufer: Widerruf (§ 356a BGB)
Der Käufer widerruft über „Vertrag widerrufen“ im Widget, auf der Standalone-Seite oder über den Link in der Fußzeile jeder Seite der Website: Schaltfläche, Formular (Name, E-Mail-Adresse, Bestellnummer „falls zur Hand“; ohne Bestellnummer ist die Erklärung Pflicht und soll Bestelldatum und Artikel nennen), Schaltfläche „Widerruf bestätigen“. Der Eingang wird gespeichert, der Käufer erhält sofort die Eingangsbestätigung mit Datum und Uhrzeit (G12), der Händler die Mail K26. Der Händler bearbeitet den Widerruf und die Erstattung selbst (LB Nr. 5 Abs. 6). Ohne Anmeldung, auch ohne passende Bestellung, mit Mengenbegrenzung gegen Missbrauch. ⚖ F20
### 2.4 Daten und Fristen
Käuferdaten liegen auf dem Server bei Hetzner (Falkenstein), E-Mails laufen über den E-Mail-Dienst von Hetzner (Nürnberg). Nicht bezahlte oder stornierte Bestellungen werden nach 30 Tagen gelöscht; bei bezahlten Bestellungen und Widerrufen werden Name, E-Mail-Adresse und Anschriften drei Jahre nach dem Ende des Bestelljahres anonymisiert (Positionen, Beträge, Bestellnummer und Status bleiben); der IP-Hash (Hashwert der IP-Adresse für den Missbrauchsschutz, nur in der offenen Bestellung) wird gelöscht, sobald die Bestellung bezahlt oder storniert wird, spätestens mit der Löschung nicht bezahlter Bestellungen nach 30 Tagen; eine Telefonnummer des Käufers wird nicht gespeichert. Nach dem Abschalten sind Bestellungen 90 Tage lesbar und als CSV exportierbar, danach löscht der tägliche Job alle Daten des Webshops (Artikel, Bilder, Bestellungen, Widerrufe, Einstellungen, Archiv der bestätigten Rechtstexte); das Einrichtungs-Ticket bleibt ohne Käuferdaten. Gelöschte Daten bleiben bis zu sieben Tage in den Sicherungen. ⚖ F14
### 2.5 Ende mit dem Vertrag über den Betrieb der Website
Der Webshop läuft, solange WEBklar die Website des Händlers betreibt (Hosting-Abo, Wartungs-Abo oder ein anderer Vertrag über den Betrieb der Website; LB Nr. 10 Abs. 1). Das Programm prüft das **nicht**: Es liest keine Abo-Daten für den Webshop und schaltet nicht von selbst ab. Das Team schaltet zum Vertragsende von Hand ab (Ticketsystem, Kunde, Funktionen, Webshop, „Abschalten“) und schreibt dem Händler vorher eine E-Mail. Beginnt innerhalb der 90 Tage wieder ein solcher Vertrag, schaltet das Team auf Wunsch wieder ein; endet der Vertrag, bevor der Webshop freigeschaltet ist, erstattet WEBklar die 299 € vollständig (von Hand in Stripe). ⚖ F9, F29
### 2.6 Widerruf der Bestellung durch den Händler
Der Händler (auch ein Existenzgründer oder Verbraucher) widerruft auf der Seite „Vertrag widerrufen“ von WEBklar mit der Auswahl „Webshop (Einrichtung)“. Die Frist von 14 Tagen beginnt mit dem Klick auf „Zahlungspflichtig bestellen“. Das Team erhält die Mitteilung T09 mit Fristende, Preis und Projekt; es wird nichts automatisch geändert. Das Team prüft, erstattet in Stripe und schaltet den Webshop ab; mit dem Widerruf endet der Webshop (LB Nr. 9 Abs. 6). ⚖ F8
### 2.7 Zahlen
| Wert | Bedeutung | Quelle im Code |
|---|---|---|
| 299 € | Einrichtung je Website-Projekt, einmalig, ohne Umsatzsteuer gemäß § 19 UStG | `SHOP_PREIS` in `server/config.js` |
| 14 Tage | Widerrufsfrist (Händler wie Käufer) | `WITHDRAWAL_DAYS` |
| 30 Minuten | Haltezeit einer offenen Bestellung (Checkout-Sitzung) | `bestellAblaufMinuten` in `server/config.js`, `legeBestellungAn` |
| 30 Tage | Löschung nicht bezahlter oder stornierter Bestellungen | `taeglicherJob` |
| 3 Jahre nach Ende des Bestelljahres | Anonymisierung der Käuferdaten | `anonymisierenJahre` in `server/config.js` |
| 90 Tage | Lesen und Export nach dem Abschalten, danach Löschung | `taeglicherJob` |
| 7 Tage | Eintrag in der Versandwarteschlange nach dem Versand; Sicherungen | AVV Nr. 9 |
## 3. Texte in Endfassung
Alle Zitate stehen wörtlich so im Programm oder in den Dokumenten (Abschnitt 5 nennt die Fundstellen).
### 3.1 Bestellung der Einrichtung: fünf Haken und Knopf
Auf der Bestellseite stehen vor dem Knopf die volle Widerrufsbelehrung mit Muster-Widerrufsformular und diese fünf Haken, keiner ist vorangekreuzt. Die Links auf Leistungsbeschreibung, AVV und Widerrufsbelehrung in der Fassung stehen daneben, nicht im Text der Haken. Gespeichert wird genau der gezeigte Text mit Fassung und Zeitpunkt. ⚖ F26
**Haken 1 (`unterlagen`)**
> Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung 2026-10-05 zu.
**Haken 2 (`verantwortung`)**
> Ich verkaufe als Unternehmer in eigenem Namen. Für meine Produkte, Preise, Produktangaben und Rechtstexte (AGB, Widerrufsbelehrung, Impressum, Datenschutzerklärung) bin ich selbst verantwortlich; WEBklar stellt Muster bereit und berät nicht rechtlich. Ich verkaufe keine der in der Leistungsbeschreibung ausgeschlossenen Waren.
**Haken 3 (`stripe`)**
> Die Zahlungen meiner Käufer laufen über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält und verwahrt kein Geld meiner Käufer und erhebt keine Umsatzbeteiligung.
**Haken 4 (`beginn`)**
> Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Webshops beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).
**Haken 5 (`information`)**
> Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Webshops für diese Website: Einbau des Shop-Widgets, Verwaltung im Portal, Muster-Rechtstexte), Gesamtpreis 299 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), voraussichtliche Dauer meist innerhalb von 2–3 Wochen, keine laufende Gebühr von WEBklar; der Webshop läuft, solange WEBklar die Website betreibt. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.
**Knopf**
> Zahlungspflichtig bestellen · 299 €
### 3.2 Bestätigung der Rechtstexte durch den Händler
Bevor der Händler den Shop öffnet, bestätigt er im Portal (Reiter Rechtliches) die Muster oder seine geänderten Texte mit diesem Haken. Zeit, Person und Hash der Texte werden gespeichert und die bestätigte Fassung wird zusätzlich im Archiv der Rechtstexte abgelegt (⚖ F16); jede Änderung der Texte hebt die Bestätigung auf. Neue Bestellungen werden mit der zuletzt bestätigten Fassung geschlossen. ⚖ F25
> Ich habe die Texte geprüft. Ich bin als Verkäufer für Rechtstexte, Produktangaben und Preise verantwortlich. WEBklar stellt Muster bereit und berät nicht rechtlich.
### 3.3 Kasse im Widget und Bestellbestätigung
Über dem Knopf zeigt das Widget (Quelle `public/embed/shop.js`, Zusammenfassung vom Server) unter den Überschriften „Ihre Bestellung“ (Positionen mit Einzel- und Summenpreis, Zwischensumme, Versandkosten oder Lieferart, Gesamtsumme, Steuerhinweis), „Lieferung und Zahlung“ (Lieferart, Lieferzeit, Zahlungsarten, Anbieter) und „Widerrufsrecht“, die Links „AGB lesen“, „Widerrufsbelehrung lesen“, „Versand & Zahlung lesen“, den Gewährleistungshinweis und den Satz „Name, Lieferadresse und Zahlung geben Sie im nächsten Schritt bei unserem Zahlungsdienstleister Stripe ein.“ Der Knopf heißt „Zahlungspflichtig bestellen“. Das Widerrufsrecht steht dort so:
> Sie haben ein gesetzliches Widerrufsrecht, soweit unten für einzelne Artikel nichts anderes steht. Einzelheiten finden Sie in der Widerrufsbelehrung.
Die Widerrufsfunktion führt mit diesen Texten:
> Hier können Sie Ihren Vertrag widerrufen. Bitte geben Sie Ihren Namen, die E-Mail-Adresse Ihrer Bestellung und möglichst die Bestellnummer an. Sie steht in Ihrer Bestätigungs-E-Mail.
> Bitte prüfen Sie Ihre Angaben. Mit „Widerruf bestätigen“ erklären Sie den Widerruf Ihres Vertrags.
Die Bestellbestätigung G09 (`server/mail-templates/G09 Bestellbestaetigung.html`) und die Eingangsbestätigung G12 (`G12 Eingang Widerruf.html`) sind (zusammen mit der Eingangsmail G15 bei ausstehender Zahlung) Anlage E in der gerenderten Fassung nach der Mail-Überarbeitung (Abschnitt 7.4). ⚖ F19, F20, F27
### 3.4 Hinweis auf die gesetzliche Gewährleistung
Der Hinweis steht hervorgehoben über dem Knopf (als Grafik `public/img/gewaehrleistung-eu.webp`, Wortlaut als Alternativtext) und in der Bestellbestätigung. Wortlaut (`gewaehrleistungsHinweis()`):
> GESETZLICHE GEWÄHRLEISTUNG
>
> Mindestens zwei Jahre gesetzliche Gewährleistung der Vertragsmäßigkeit für Waren, die in der Europäischen Union verkauft werden.
>
> Verbraucherinnen und Verbraucher können ihre Rechte im Rahmen des gesetzlichen Gewährleistungsrechts geltend machen, z. B. wenn die Waren nicht der Beschreibung entsprechen, nicht bestimmungsgemäß funktionieren.
>
> Verkäufer haften für jede Vertragswidrigkeit, die zum Zeitpunkt der Lieferung der Waren bestand und innerhalb des Zeitraums der gesetzlichen Gewährleistung erkennbar wird. Verkäufer müssen in solchen Fällen Folgendes anbieten: kostenlose Nachbesserung oder kostenlose Ersatzlieferung, in bestimmten Fällen eine Preisminderung oder eine vollständige Erstattung des Kaufpreises.
>
> In einigen Ländern gilt ein längerer Zeitraum für die gesetzliche Gewährleistung. Für gebrauchte Waren kann ein kürzerer Zeitraum gelten, jedoch nicht weniger als ein Jahr.
>
> Für weitere Informationen zu Ihren Rechten in einem bestimmten Land scannen Sie den nachstehenden QR-Code oder fragen Sie den Verkäufer. europa.eu/youreurope/garantien
>
> Was ist zu tun, wenn Sie vertragswidrige Waren erhalten? 1. Melden Sie dem Verkäufer das Problem so bald wie möglich. 2. Legen Sie einen Kaufnachweis vor, z. B. die Quittung, Rechnung oder einen Kontoauszug.
>
> Verkäufer und Hersteller können auch gewerbliche Garantien gewähren, die unabhängig von der gesetzlichen Gewährleistung gelten. Diese GARAN-Kennzeichnung zeigt beispielsweise, dass der Hersteller eine gewerbliche Haltbarkeitsgarantie ohne zusätzliche Kosten gewährt, die die gesamte Ware abdeckt.
⚖ F24
### 3.5 Dokumente und Fundstellen der Texte
| Anlage | Dokument | Pfad |
|---|---|---|
| A | Leistungsbeschreibung Webshop (Markdown, HTML, PDF) | `docs/rechtliches/leistungsbeschreibung-webshop.md`, `public/rechtliches/leistungsbeschreibung-webshop-2026-10-05.html` und `.pdf` |
| B | Auftragsverarbeitungsvertrag Webshop (Markdown, HTML, PDF) | `docs/rechtliches/avv-webshop.md`, `public/rechtliches/avv-webshop-2026-10-05.html` und `.pdf` |
| C | Muster-Rechtstexte der Händler (Belehrung, Formular, AGB, Versand und Zahlung, Gewährleistungshinweis) | `docs/funktionen/shop-muster-rechtstexte.md` (Quelltext der Muster: `server/services/shop/rechtstexte.js`) |
| D | Muster-Datenschutzabsatz für die Website des Händlers | `docs/funktionen/shop-datenschutz-absatz.md` |
| E | Gerenderte Mails G09 bis G15 und K24 bis K26 (nach der Mail-Überarbeitung nachgereicht) | `server/mail-templates/` |
| – | Gemeinsame Widerrufsbelehrung der Funktionen mit Muster-Widerrufsformular | `docs/rechtliches/widerrufsbelehrung-funktionen.md` |
| – | Bestelltexte (fünf Haken, Knopf) | `server/config.js`, `shopConsent` |
| – | Datenschutzerklärung von WEBklar, Abschnitt 6d (Hausrunde) | `public/datenschutz.html`, `id="webshop"` |
| – | Einbauanleitung für das Team, inklusive Abschalten von Hand | `docs/funktionen/shop-einbau.md` |
| – | Hinweis auf die gesetzliche Gewährleistung (Grafik und Text) | `public/img/gewaehrleistung-eu.webp`, `server/services/shop/rechtstexte.js`, `gewaehrleistungsHinweis` |
| – | Widerruf der Bestellung durch den Händler | `public/widerrufen.html`, `server/routes/abo.js` |
## 4. Fragen an die Kanzlei
Die Markierung ⚖ steht nur hier und in den Abschnittsverweisen, nie in den veröffentlichten Texten. „Unsere Annahme“ ist ein Arbeitsstand von WEBklar und kein Rechtsurteil. Kürzel: **LB** = Leistungsbeschreibung Webshop, **AVV** = Auftragsverarbeitungsvertrag Webshop, **AB** = Abo-Bedingungen. Die Fragen F1 bis F18 betreffen Anlagen A und B, F19 bis F29 den Ablauf im Widget und die übrigen Texte.
## Leistungsbeschreibung (Anlage A)
### F1: LB Nr. 1 Abs. 4: Webshop im Hosting-Abo
**Text.** Leistungsbeschreibung A Nr. 2 Abs. 3 (keine Formulare mit Server) und Nr. 4 (Rechtstexte nicht enthalten) sowie Leistungsbeschreibung B Nr. 3 stehen dem Webshop „nicht entgegen“, auch im Hosting-Abo.
**Frage.** Reicht diese Klarstellung, um die statische Hosting-Website mit einem eingebundenen Widget zu vereinbaren, oder muss die Leistungsbeschreibung A selbst geändert werden (Änderungsverfahren § 15 AB)? Ist der Vorrang der LB vor den AB (Abs. 3) wirksam?
**Unsere Annahme.** Ja, weil das Widget auf dem Server von WEBklar läuft und die Website statisch bleibt.
### F2: LB Nr. 3: Ausgeschlossene Waren und Abschaltung
**Text.** Katalog ausgeschlossener Waren (Alkohol, Tabak, Arzneimittel, Waffen und Feuerwerk, Erotik, lebende Tiere, digitale Inhalte, Gutscheine, Abonnements, Stripe-Liste); Abschalten nach Hinweis, bei Gefährdung auch ohne Hinweis; dann keine Erstattung des Einrichtungspreises, wenn der Händler den Verstoß zu vertreten hat.
**Frage.** Ist der Katalog gegenüber Unternehmern als AGB-Klausel haltbar (§ 307 BGB, auch bei Verweis auf eine fremde Liste wie die von Stripe, die sich ändert)? Ist die Abschaltung ohne vorherigen Hinweis und der Ausschluss der Erstattung angemessen? Sollte der Verweis auf die Stripe-Liste dynamisch bleiben?
### F3: LB Nr. 4: Zahlungen, Rolle von WEBklar, ZAG
**Text.** Stripe Connect mit eigenem Konto des Händlers (Direct Charges), WEBklar erhält und verwahrt kein Geld, keine Umsatzbeteiligung, „WEBklar ist kein Zahlungsdienstleister“; PayPal nicht verfügbar.
**Frage.** Trägt diese Konstruktion ohne Erlaubnis nach dem ZAG (Bereichsausnahme für technische Dienstleister, § 2 Abs. 1 Nr. 9 ZAG)? Ist der Satz „WEBklar ist kein Zahlungsdienstleister“ als Aussage in der LB unbedenklich oder sollte er entfallen? Bleibt das so, wenn der Webshop später eine Plattformgebühr oder PayPal bekommt?
**Unsere Annahme.** Keine Erlaubnis nötig, solange kein Geld über WEBklar läuft und keine Gebühr einbehalten wird.
### F4: LB Nr. 5: Pflichten des Händlers und Rolle als Hosting-Anbieter
**Text.** Der Händler verantwortet Artikel, Preise, Produktangaben (Lebensmittel, Produktsicherheit, Grundpreis), Rechtstexte, Lieferung, Gewährleistung, Rechnungen und Aufbewahrung.
**Frage.** Ist die Verteilung vollständig und vertretbar? Trifft WEBklar als Anbieter des Widgets eigene Pflichten (Verordnung über digitale Dienste für Hosting-Dienste; Produktsicherheitsverordnung für Online-Marktplätze), obwohl WEBklar nicht Verkäufer ist und keinen Marktplatz betreibt? Brauchen wir eine Meldestelle für rechtswidrige Angebote?
### F5: LB Nr. 6: Muster-Rechtstexte ohne Gewähr
**Text.** WEBklar stellt Muster-AGB, Muster-Widerrufsbelehrung und Muster-Widerrufsformular, Versand und Zahlung bereit („Muster ohne Gewähr“, keine Rechtsberatung); das Widget zeigt den Hinweis auf die gesetzliche Gewährleistung (Verordnung (EU) 2025/1960).
**Frage.** Ist die Bereitstellung von Mustern als Nebenleistung eines technischen Produkts nach dem Rechtsdienstleistungsgesetz zulässig (§ 2 Abs. 3, § 5 RDG)? Genügt der Hinweis „Muster ohne Gewähr“ mit Prüfempfehlung? Kann WEBklar für Fehler der Muster haften (Abs. 2 verspricht richtige Beschreibung der Technik)?
### F6: LB Nr. 8: Haftung
**Text.** Wie beim Terminplaner: Haftung für entgangenen Gewinn und Folgeschäden nur im Rahmen von § 13 Abs. 1 und 2 AB; keine Haftung für Inhalte, Preise und Rechtstexte des Händlers, Mustertexte, Ausfälle von Stripe, Rückbuchungen und Zahlungsausfälle.
**Frage.** Hält die Klausel der Inhaltskontrolle stand (Abs. 3 und 4 sind Ausschlüsse außerhalb des Einflussbereichs, keine Haftungsbegrenzung für eigene Pflichtverletzung)? Reicht der Verweis auf § 13 AB oder braucht der Webshop mit Zahlungsbezug eine eigene Haftungsgrenze (etwa Höchstbetrag)?
### F7: LB Nr. 9 Abs. 5: Mehraufwand und Stundenkontingent
**Text.** Mehraufwand wird nach Freigabe mit 100 € je Stunde berechnet und nicht auf das Stundenkontingent angerechnet.
**Frage.** Das ist eine Entscheidung des Inhabers, keine Rechtsfrage; sie ist hier nur zur Kontrolle gelistet: Soll der Händler im Wartungs-Abo den Mehraufwand wahlweise über sein Kontingent abwickeln können (wie beim Terminplaner nach § 4 AB)?
### F8: LB Nr. 9 Abs. 6: Widerruf der Einrichtung
**Text.** Verbraucher können die Bestellung der Einrichtung widerrufen; Verweis auf die gemeinsame Widerrufsbelehrung der Funktionen (Beispiel Terminplaner), Erlöschen nach der Zustimmung bei der Bestellung.
**Zitat der gemeinsamen Belehrung (`docs/rechtliches/widerrufsbelehrung-funktionen.md`).**
> Das Widerrufsrecht gilt nur für Verbraucher. Wir zeigen die Belehrung und das Muster-Widerrufsformular trotzdem allen, die eine Funktion bestellen. Die Belehrung gilt für die Bestellung der Einrichtung einer Funktion im Kundenportal, zum Beispiel des Terminplaners. Hinweise dazu stehen nach dem Muster-Widerrufsformular.
Die Belehrung nennt als Beispiel nur den Terminplaner, die Bestellung des Webshops verweist auf dieselbe Datei.
**Frage.** Der Webshop richtet sich nur an Unternehmer (Nr. 1 Abs. 1), die Bestellung kann aber ein Existenzgründer oder ein Verbraucher vor Gewerbebeginn aufgeben. Ist es richtig, allen Bestellern die Belehrung zu zeigen? Muss die gemeinsame Belehrung den Webshop namentlich nennen, oder genügt das „zum Beispiel“ mit dem Terminplaner, weil der Webshop eine Funktion im Sinne des ersten Satzes ist? Eine Änderung der Datei würde auch die Fassung des Terminplaners betreffen (die Kanzlei-Prüfung der Belehrung selbst ist Teil des Pakets Terminplaner).
### F9: LB Nr. 10: Ende mit dem Vertrag über den Betrieb der Website, Löschfrist, Wiedereinschaltung
**Text.** Der Webshop läuft, solange WEBklar die Website des Händlers betreibt (Hosting-Abo, Wartungs-Abo oder ein anderer Vertrag über den Betrieb der Website). Endet dieser Vertrag, endet auch der Webshop; WEBklar schaltet ihn zum Vertragsende ab und informiert den Händler vorher per E-Mail (Abs. 1, gilt auch beim Widerruf des Vertrags). 90 Tage Lesen und Export, danach Löschung; Wiedereinschaltung innerhalb der 90 Tage auf Wunsch ohne Kosten, wenn wieder ein Vertrag über den Betrieb der Website beginnt (Abs. 5); volle Erstattung des Einrichtungspreises, wenn der Vertrag vor der Freischaltung endet (Abs. 6). Technisch ist der Webshop nicht mit den Abo-Daten gekoppelt: Die Bestellung ist nicht an ein Abo gebunden, und das Abschalten zum Vertragsende erledigt das Team von Hand (WOMS, Funktionen, Webshop, „Abschalten“).
**Frage.** Ist die Kopplung des Webshops an den Vertrag über den Betrieb der Website wirksam, auch wenn der Händler einen laufenden Betrieb mit offenen Bestellungen und Widerrufen hat? Reicht der Hinweis an den Händler, seine Käufer zu informieren (Abs. 2), und die Vorab-Mail vor dem Abschalten? Sind die Erstattungs- und Wiedereinschaltungsregeln so vereinbar? Ist die Formulierung „anderer Vertrag über den Betrieb der Website“ bestimmt genug (§ 305c, § 307 BGB)?
### F10: LB Nr. 11 Abs. 2: Beendigung bei Änderungen von Stripe
**Text.** WEBklar darf den Webshop (je Website mit vier Wochen, für alle mit drei Monaten Frist) beenden oder einstellen, auch wenn Stripe die Anbindung beendet oder wesentlich ändert.
**Frage.** Ist dieser zusätzliche Beendigungsgrund (gegenüber dem Terminplaner) wirksam, oder muss er enger gefasst werden? Was gilt für den bereits gezahlten Einrichtungspreis?
### F11: LB Nr. 12 Abs. 2: Barrierefreiheit (BFSG)
**Text.** Wie beim Terminplaner: keine Zusage einer Konformität (WCAG 2.1 AA, EN 301 549); ob der Händler unter das BFSG fällt, entscheidet er selbst.
**Frage.** Ein Online-Shop ist ein Dienstleistungsangebot im elektronischen Geschäftsverkehr. Ist WEBklar mit dem Widget selbst Normadressat oder nur der Händler? Ist die Ausnahme für Kleinstunternehmen (§ 3 Abs. 3 BFSG) richtig dargestellt? Reicht „keine Zusage“, oder sollte WEBklar einen Mindeststandard für das Widget zusagen?
## Auftragsverarbeitungsvertrag (Anlage B)
### F12: AVV Vorbemerkung Abs. 4 und Nr. 2: Rollen
**Text.** Händler = Verantwortlicher, WEBklar = Auftragsverarbeiter, Stripe = Vertragspartner des Händlers (kein Unterauftragsverarbeiter). WEBklar verarbeitet Bestell- und Widerrufsdaten, versendet Bestell-E-Mails im Namen des Händlers und betreibt den Missbrauchsschutz. Dazu gehört ein gesalzener Hashwert der IP-Adresse in der Bestellung (Obergrenze von drei offenen Bestellungen je IP-Adresse in 30 Minuten; AVV Nr. 3 Abs. 4), gelöscht bei der Löschung oder Anonymisierung der Bestellung.
**Frage.** Ist WEBklar für alle diese Vorgänge reiner Auftragsverarbeiter, oder wird es beim Missbrauchsschutz (Arbeitsspeicher, Mengenbegrenzung, IP-Hash in der Bestellung) oder bei der Widerrufsfunktion (Eingangsbestätigung) zum eigenen Verantwortlichen (Art. 4 Nr. 7 oder Art. 26 DSGVO)?
### F13: AVV Nr. 7 Abs. 4: Stripe und Datenweitergabe
**Text.** Stripe ist kein Unterauftragsverarbeiter; Zahlung, Kartendaten und Auszahlung verantworten Händler und Stripe. WEBklar ruft auf Weisung des Händlers die Zahlungs- und Adressangaben der Checkout-Sitzung ab und veranlasst Erstattungen. Daten von Käufern gehen nicht von WEBklar an Stripe.
**Frage.** Ist es richtig, den Datenfluss von Stripe zu WEBklar als Verarbeitung im Auftrag des Händlers zu beschreiben? Ist die Abgrenzung (Stripe als Auftragsverarbeiter und teils eigener Verantwortlicher laut DPA von Stripe) für die Datenschutzerklärung des Händlers richtig? Ist der Satz zu Drittlandübermittlungen von Stripe („regeln Kunde und Stripe“) ausreichend?
### F14: AVV Nr. 9: Fristen für Anonymisierung und Löschung
**Text.** Nicht bezahlte Bestellungen (offen oder storniert): Löschung 30 Tage nach dem Anlegen. IP-Hash: gelöscht, sobald die Bestellung bezahlt oder storniert wird, spätestens mit dieser Löschung. Keine Telefonnummer des Käufers. Bezahlte Bestellungen und Widerrufe: Käuferdaten werden 3 Jahre nach dem Ende des Bestelljahres anonymisiert; Positionen, Beträge, Bestellnummer und Status bleiben. Nach dem Abschalten: 90 Tage Lesen und Export, dann Löschung aller Daten (auch des Archivs der Rechtstexte). Versandwarteschlange: Inhalt sofort nach dem Versand leeren, Eintrag nach 7 Tagen löschen, bei Fehlschlag nach 48 Stunden. Sicherung bis zu sieben Tage.
**Frage.** Sind 3 Jahre nach Ende des Bestelljahres für die Käuferdaten angemessen (Gewährleistung zwei Jahre, regelmäßige Verjährung drei Jahre ab Jahresende)? Entsteht ein Konflikt mit den Aufbewahrungspflichten des Händlers (zum Beispiel sechs oder acht Jahre nach AO und HGB), wenn WEBklar die Namen schon früher anonymisiert und der Händler den Export nicht gezogen hat? Reicht der Hinweis in Nr. 9 Abs. 8 und LB Nr. 5 Abs. 8, oder soll die Frist einstellbar sein?
### F15: AVV Nr. 8 Abs. 2: Betroffenenrechte nur auf Weisung im Chat
**Text.** Auskunft und Löschung/Anonymisierung einer einzelnen Bestellung geschehen auf Weisung des Händlers in Textform (Chat), nicht über eine Aktion im Portal; der Händler prüft vorher seine Aufbewahrungspflichten.
**Frage.** Genügt das als Unterstützung nach Art. 28 Abs. 3 lit. e DSGVO? Welche Reaktionszeit sollte der AVV nennen („unverzüglich“)?
### F16: AVV Nr. 8 Abs. 5: Archiv der bestätigten Rechtstexte und Prüfsumme
**Text.** Bestätigt der Händler seine Rechtstexte, legt WEBklar jede bestätigte Fassung einmal im Archiv ab (Wortlaut, Prüfsumme, Zeitpunkt, bestätigende Person des Händlers; keine Käuferdaten). Die Bestellung speichert die Prüfsumme der Fassung, die der Käufer sah; die Bestellbestätigung G09 zeigt die archivierte Fassung zu dieser Prüfsumme und nicht die beim Versand aktuelle (fehlt sie ausnahmsweise, gilt die aktuell bestätigte). WEBklar löscht das Archiv mit den übrigen Daten des Webshops 90 Tage nach dem Abschalten (AVV Nr. 9 Abs. 6), nicht früher; es ersetzt nicht die Aufbewahrung des Händlers (AVV Nr. 9 Abs. 8). Der Wortlaut bleibt also auch über die Anonymisierung der Bestellung (drei Jahre nach dem Bestelljahr) hinaus erhalten, solange der Webshop betrieben wird.
**Frage.** Genügt das als Nachweis des Händlers für den Vertragsinhalt (Bestellbestätigung mit den vollständigen Texten, § 312f Abs. 2 BGB)? Ist es richtig, die Fassungen bis 90 Tage nach dem Abschalten ohne weitere Frist aufzubewahren (Speicherbegrenzung; die Fassungen enthalten keine Käuferdaten, nur die bestätigende Person des Händlers), oder soll der AVV eine kürzere oder längere Frist nennen?
### F17: AVV Nr. 3 Abs. 5: Warenkorb im Browser
**Text.** Kein Cookie; der Warenkorb (Artikelkennungen und Mengen, kein Personenbezug) liegt im Sitzungsspeicher (sessionStorage) des Tabs, „technisch erforderlich“.
**Frage.** Ist das nach § 25 Abs. 2 Nr. 2 TDDDG ohne Einwilligung zulässig? Welche Information muss der Händler dazu in seiner Datenschutzerklärung geben (der Musterabsatz dazu entsteht in Aufgabe J2)?
### F18: AVV insgesamt: Übernahme der Textbausteine des Terminplaners
**Text.** Nummern 5, 6, 7 Abs. 1 bis 3, 10, 11 Abs. 1 und 12 sowie die Anlage sind inhaltlich aus dem AVV Terminplaner übernommen.
**Frage.** Gilt die Prüfung des AVV Terminplaner für diese Teile unverändert, oder ist wegen der Zahlungsdaten und der Adressdaten der Käufer (Liefer- und Rechnungsadresse, IP-Hash) eine Anpassung der Maßnahmen in der Anlage nötig?
## Ablauf, Pflichtangaben, Datenschutz und Ende
### F19: Vertragsschluss im Widget, § 312j BGB: Platz des Knopfes und Stripe nur als Zahlung
**Text.** Die verbindliche Bestellung geschieht im Widget des Händlers mit dem Knopf „Zahlungspflichtig bestellen“ (Abschnitt 3.3). Unmittelbar darüber stehen Positionen, Einzel- und Gesamtpreise, Versandkosten, Gesamtsumme, Lieferart und Lieferzeit, Zahlungsarten, Hinweis auf Widerrufsrecht oder -ausschluss je Artikel, Links auf AGB, Widerrufsbelehrung und Versand & Zahlung, der Gewährleistungshinweis (Abschnitt 3.4) und die Anbieterangaben. Name und Lieferadresse gibt der Käufer erst danach auf der Zahlungsseite von Stripe ein (ohne Telefonnummer, Rechnungsanschrift nur soweit nötig); Stripe Checkout ist nur die Zahlung. Bis zur Zahlung (30 Minuten) ist die Bestellung „offen“ und der Bestand reserviert; zahlt der Käufer nicht, verfällt sie.
**Frage.** (a) Erfüllt der Knopf mit der Beschriftung „Zahlungspflichtig bestellen“ im Widget § 312j Abs. 3 BGB, obwohl noch Daten (Name, Adresse) und die Zahlung folgen? (b) Erfüllen die Angaben über dem Knopf § 312j Abs. 2 BGB und Art. 246a § 1 Abs. 1 EGBGB, auch wenn die Lieferadresse noch fehlt? (c) Wann kommt der Vertrag zustande: mit dem Klick, mit der Zahlung oder mit der Bestätigung? Passt das zur Bestellbestätigung und zu den Mustertexten? (d) Ist die Informationspflicht zu Beginn des Bestellvorgangs (§ 312j Abs. 1 BGB: Lieferbeschränkungen und Zahlungsmittel) durch die Zeile ganz oben im Widget erfüllt?
**Unsere Annahme.** Ja: Der Händler gibt mit dem Klick im Widget das verbindliche Angebot ab; die Zahlung bei Stripe schließt den Vorgang ab; die Hinweise stehen unmittelbar über dem Knopf.
**Alternative.** Den Knopf erst auf der letzten Seite zeigen, nachdem Stripe die Daten erhoben hat (nicht möglich, weil Stripe Checkout die Seite betreibt), oder Name und Adresse im Widget abfragen und Stripe nur für die Zahlung nutzen.
### F20: Widerrufsfunktion nach § 356a BGB: Ausgestaltung
**Text.** Die Schaltfläche „Vertrag widerrufen“ steht in der Fußzeile des Widgets, auf der Standalone-Seite unter `/shop/<siteKey>/widerruf` und, vom Team eingebaut, als Link in der Fußzeile jeder Seite der Website des Händlers (Anleitung `docs/funktionen/shop-einbau.md`, Abschnitt 3). Sie führt ohne Anmeldung zu einem Formular (Name, E-Mail-Adresse, Bestellnummer „falls zur Hand“; ohne Bestellnummer muss die Erklärung Bestelldatum und Artikel nennen), danach zur Schaltfläche „Widerruf bestätigen“. WEBklar speichert den Eingang und sendet dem Erklärenden sofort eine Eingangsbestätigung mit Inhalt, Datum und Uhrzeit (G12) auf einem dauerhaften Datenträger; der Händler erhält die Mail K26. Die Funktion ist auch erreichbar, wenn keine Bestellung zur Angabe gefunden wird; der Händler klärt das. Die Bestellnummer ist ein freiwilliges Feld; fehlt sie, verlangt das Formular Bestelldatum und Artikel in der Erklärung (Freitext), damit der Händler zuordnen kann.
**Frage.** (a) Genügt diese Ausgestaltung § 356a BGB (Erreichbarkeit „während der gesamten Widerrufsfrist“, Schaltfläche, Bestätigungsschaltfläche, Eingangsbestätigung)? (b) Reichen Name, E-Mail-Adresse und entweder die Bestellnummer oder Bestelldatum und Artikel in der Erklärung zur Zuordnung („Identifikation“ des Vertrags), oder muss die Bestellung zwingend gefunden werden? (c) Ist der Link auf jeder Seite der Website des Händlers die richtige Verortung („Online-Benutzeroberfläche“), obwohl das Widget nur auf einer Seite läuft, und wer trägt die Pflicht, wenn der Händler den Link entfernt (LB Nr. 5 Abs. 6)? (d) Ist der Zeitstempel in Europe/Berlin mit Eingangsbestätigung ausreichend?
**Unsere Annahme.** Ja; die Funktion nimmt jede Erklärung an, die Zuordnung ist Sache des Händlers.
**Alternative.** Bestellnummer und E-Mail-Adresse als Pflichtfelder; Zuordnung mit Fehlermeldung bei Nichtfinden (würde Widerrufe abweisen und ist nach unserer Einschätzung riskanter).
### F21: PAngV, Grundpreis immer je 1 kg, 1 l, 1 m, 1 m²
**Text.** Das Portal rechnet den Grundpreis immer auf 1 kg, 1 l, 1 m oder 1 m² um und zeigt ihn als „x € / 1 kg“; die Umrechnung auf 100 g oder 100 ml (die für Waren mit einem Nenngewicht bis 250 g erlaubt wäre) ist nicht vorgesehen. Der Händler gibt Menge und Einheit des Artikels an. Der Grundpreis steht neben dem Gesamtpreis im Artikel und in der Kasse.
**Frage.** (a) Ist es zulässig, auch bei kleinen Mengen immer den Grundpreis je 1 kg oder 1 l anzugeben? (b) Reicht die Pflege durch den Händler (Menge und Einheit je Artikel) oder braucht es Ausnahmen (Waren nach Stück, lose Ware, Mengen, bei denen ein Grundpreis entfällt)? (c) Wie ist der Grundpreis bei Varianten mit unterschiedlicher Menge zu zeigen?
**Unsere Annahme.** Zulässig und eindeutiger; die Ausnahmen kennt der Händler.
### F22: Lebensmittel im Fernabsatz: lose Backwaren und Allergene
**Text.** Der Händler kann einen Artikel als Lebensmittel markieren; dann ist ein Pflichtfeld „Pflichtangaben“ auszufüllen (Bezeichnung, Zutaten, Allergene hervorgehoben, Menge, gegebenenfalls Hersteller), das vor dem Kauf am Artikel angezeigt wird. Ein typischer Kunde ist eine Bäckerei mit losen (nicht vorverpackten) Waren. Das Widerrufsrecht kann der Händler bei schnell verderblichen Waren ausschließen (§ 312g Abs. 2 Nr. 2 BGB). Die Lieferung und Abholung von Speisen ist in der Recherche als offen markiert.
**Frage.** (a) Genügt das Freitextfeld, um Art. 14 LMIV (Pflichtangaben im Fernabsatz vor Vertragsschluss) und die Allergenkennzeichnung nicht vorverpackter Ware zu erfüllen? (b) Muss der Shop die Angaben strukturieren (Allergenliste statt Freitext)? (c) Gilt für Lebensmittel, die der Händler selbst ausliefert oder zur Abholung bereitstellt, § 312 Abs. 2 Nr. 8 BGB oder der Ausschluss nach § 312g Abs. 2 Nr. 2 BGB, und was soll die Muster-Widerrufsbelehrung dazu sagen?
**Unsere Annahme.** Freitext genügt, wenn der Händler vollständig angibt; wir weisen ihn in der Leistungsbeschreibung (Nr. 5 Abs. 3) darauf hin.
### F23: Produktsicherheit (GPSR): Pflichtfelder am Artikel
**Text.** Je Artikel erfasst der Händler den Hersteller (Name, Anschrift, E-Mail oder Website; Vorgabe „ich selbst“), bei Herstellern außerhalb der EU die verantwortliche Person in der EU sowie Warn- und Sicherheitshinweise. Das Widget zeigt diese Angaben am Artikel. Eine Produktkennung (Typ, Charge, Seriennummer) und ein Bild jedes Produkts als eigene Pflichtfelder gibt es nicht; Bilder sind optional (bis vier).
**Frage.** Erfüllen diese Felder Art. 19 der Verordnung (EU) 2023/988 (GPSR) für Angebote im Fernabsatz? Fehlen Pflichtangaben (Produktkennung, Bild)? Trifft WEBklar als Anbieter des Widgets selbst Pflichten (siehe auch F4)?
**Unsere Annahme.** Die Felder decken die Pflichtangaben für die üblichen Waren ab; die Verantwortung bleibt beim Händler.
### F24: Hinweis auf die gesetzliche Gewährleistung (Verordnung (EU) 2025/1960 Anhang I)
**Text.** Seit dem 27.09.2026 ist der harmonisierte Hinweis hervorgehoben zu zeigen. Der Webshop zeigt die Grafik (`public/img/gewaehrleistung-eu.webp`) unmittelbar über dem Knopf „Zahlungspflichtig bestellen“, mit dem Wortlaut als Alternativtext, und führt denselben Text in der Bestellbestätigung. Der Wortlaut steht in Abschnitt 3.4. Der Abgleich der Grafikdatei mit der Originaldatei der Kommission steht noch aus.
**Frage.** (a) Entspricht Wortlaut und Gestaltung Anhang I (Größe, Hervorhebung, Position in der Kasse und im Angebot)? (b) Genügt die Einblendung in der Kasse, oder muss der Hinweis schon am Artikel stehen? (c) Genügt der Text in der Bestellbestätigung? (d) Wie ist der Hinweis bei Waren zu halten, die nicht dem Gewährleistungsrecht für Waren unterliegen?
**Unsere Annahme.** Kasse und Bestätigung genügen; die Grafik prüfen wir vor dem Livegang gegen die Datei der Kommission.
### F25: Muster-Rechtstexte des Händlers (AGB, Widerrufsbelehrung, Widerrufsformular, Versand und Zahlung)
**Text.** Das Portal befüllt für den Händler Muster nach `docs/funktionen/shop-muster-rechtstexte.md` (Anlage C). Die Widerrufsbelehrung folgt Anlage 1 zu Art. 246a § 1 Abs. 2 S. 2 EGBGB (Fassung 2026 mit dem Baustein zur Widerrufsfunktion), enthält beide Fristbeginn-Alternativen (eine Ware, mehrere getrennt gelieferte Waren), Rücksendekosten zulasten des Käufers (umschaltbar), Abholung und Ausschlussgründe in allgemeiner Form (§ 312g Abs. 2 Nr. 1, 2, 3 BGB). Dazu kommen Muster-AGB für kleine Shops, das Muster-Widerrufsformular (Anlage 2) und Versand- und Zahlungsinformationen, die aus den Einstellungen erzeugt werden. Der Händler bestätigt die Texte mit dem Satz aus Abschnitt 3.2; die Prüfsumme der Texte steht an jeder Bestellung.
**Frage.** (a) Erfüllen die Muster, richtig ausgefüllt, die Gesetzlichkeitsfiktion (Art. 246a § 1 Abs. 2 S. 3 EGBGB)? (b) Stimmen die Muster-AGB mit der Technik überein (Vertragsschluss, Eigentumsvorbehalt, Gewährleistung, Streitbeilegung nach § 36 VSBG ohne Link auf die eingestellte OS-Plattform)? (c) Ist es zulässig, Muster als Nebenleistung zu liefern (siehe F5)? (d) Teilsendungen und regelmäßige Lieferung sind nicht vorgesehen: Muss die Belehrung das ausdrücklich sagen?
**Unsere Annahme.** Ja, wenn der Händler die Platzhalter richtig ausfüllt; Abweichungen im Einzelfall prüft der Händler (LB Nr. 6).
**Alternative.** Nur die Belehrung als Muster, AGB nur als Gliederung.
### F26: Bestelltexte der Einrichtung: fünf Haken
**Text.** Die Bestellung der Einrichtung für 299 € trägt fünf Haken und den Knopf „Zahlungspflichtig bestellen · 299 €“ (Abschnitt 3.1). Alle Besteller sehen die volle Widerrufsbelehrung mit Muster-Widerrufsformular; Haken 4 verlangt den vorzeitigen Beginn mit Erlöschen und Wertersatz (§ 356 Abs. 5 Nr. 2, § 357a Abs. 2 BGB); Haken 5 bündelt die Pflichtinformationen.
**Frage.** Wie bei der Bestellung des Terminplaners (dort Q1 bis Q3, Q5, Q19, Q20): Genügen die fünf Haken, einschließlich Haken 3 („Die Zahlungen meiner Käufer laufen über mein eigenes Stripe-Konto …“) und Haken 5? Ist „der Webshop läuft, solange WEBklar die Website betreibt“ im Haken 5 als Information zur Laufzeit ausreichend, auch wenn das Ende des Webshops nicht an ein Abo, sondern an irgendeinen Vertrag über den Betrieb der Website gekoppelt ist (siehe F9)?
**Unsere Annahme.** Ja; wir gleichen die Antworten zum Terminplaner an.
### F27: Bestellbestätigung an den Käufer (G09) und Absender
**Text.** G09 enthält Anbieter, Bestellnummer, Positionen, Preise, Versand, Gesamtsumme, Lieferart und Lieferzeit, Adresse, Zahlungsart, Hinweis auf Widerrufsausschlüsse, die vollständigen AGB, die vollständige Widerrufsbelehrung und das Muster-Widerrufsformular im Mailtext, den Link „Vertrag widerrufen“ und den Gewährleistungshinweis. Absender ist die Versandadresse von WEBklar mit dem Namen des Shops, Antworten gehen an den Händler.
**Frage.** (a) Erfüllt G09 § 312f Abs. 2 BGB (Bestätigung auf einem dauerhaften Datenträger mit den Vertragsinhalten und den Informationen nach Art. 246a EGBGB)? (b) Ist der Absender (Domain von WEBklar, Name des Shops, Reply-To Händler) transparent genug, oder muss der Händler als Absender erscheinen? (c) Müssen die Mails der Anlage zur Prüfung beigelegt werden? (Sie liegen als gerenderte Fassung nach der Mail-Überarbeitung vor.)
**Unsere Annahme.** Ja.
### F28: Datenschutz: Abschnitt 6d, Stripe-Konto und Muster-Absatz
**Text.** (a) Datenschutzerklärung von WEBklar Abschnitt 6d („Webshop auf Websites unserer Kunden“): Rolle Auftragsverarbeiter für Käuferdaten, Verantwortliche für Betriebsdaten und Bestellung der Funktion. (b) Eröffnung des Stripe-Kontos des Händlers: WEBklar übermittelt auf Klick des Händlers dessen E-Mail-Adresse, die Adresse seiner Website und den Namen seines Projekts an Stripe Payments Europe Ltd. (Dublin), speichert Konto-ID und Stand und stützt das auf Art. 6 Abs. 1 lit. b DSGVO. (c) Muster-Absatz für die Datenschutzerklärung der Website des Händlers (`docs/funktionen/shop-datenschutz-absatz.md`, Anlage D): Stripe als eigener Verantwortlicher (Betrugsvorbeugung), Drittlandübermittlung (EU-US Data Privacy Framework), Klarna und SEPA über Stripe, Warenkorb im Sitzungsspeicher, E-Mails, Speicherdauer.
**Frage.** (a) Sind Rollen und Rechtsgrundlagen richtig (siehe auch F12, F13)? (b) Ist für die Übermittlung an Stripe bei der Kontoeröffnung eine Einwilligung oder Information des Händlers nötig, die über den Klick und Abschnitt 6d hinausgeht? (c) Reicht der Muster-Absatz für die Informationspflichten nach Art. 13 DSGVO (Verantwortlicher, Zwecke, Rechtsgrundlagen, Empfänger, Drittland, Dauer, Rechte), insbesondere für die Aussage zu Stripe und Klarna? (d) Passt die Speicherdauer (30 Tage unbezahlt, 3 Jahre bis zur Anonymisierung) zur Aufbewahrung des Händlers (siehe F14)?
**Unsere Annahme.** Ja; der Muster-Absatz folgt dem AVV Webshop.
### F29: Ende des Webshops mit dem Vertrag über den Betrieb der Website, Abschalten von Hand
**Text.** Der Webshop ist nicht mit Abo-Daten im Programm gekoppelt, die Bestellung hängt nicht an einem Abo, und das Programm schaltet nicht von selbst ab (Entscheidung W5). Das Team schaltet zum Vertragsende von Hand ab und schreibt dem Händler vorher (Anleitung `docs/funktionen/shop-einbau.md`, Abschnitt 8). LB Nr. 10 Abs. 1 verspricht dieses Vorgehen; ein verspätetes Abschalten bleibt ein Organisationsrisiko von WEBklar.
**Frage.** Entstehen aus einem verspäteten Abschalten Ansprüche des Händlers oder der Käufer gegen WEBklar (etwa wenn der Shop nach dem Vertragsende weiter bestellbar bleibt)? Wie ist „zum Vertragsende“ in Abs. 1 zu fassen (Tag der Beendigung oder Tag danach)? Genügt die Haftungsregelung der LB Nr. 8 dafür?
**Unsere Annahme.** Das Risiko ist gering und beschränkt sich auf die Zeit zwischen Vertragsende und Abschalten; die Haftung ist nach LB Nr. 8 begrenzt.
## 5. Fundstellen im Code
Pfade gelten im Repository des Kundenportals (`Webklar-Kundenbereich`); Bezeichner sind Funktionen oder Konstanten und stehen in der genannten Datei. Zeilennummern nennen wir nicht, weil sie sich ändern.
| Thema | Datei | Funktion/Konstante |
|---|---|---|
| Fassung, Preis, Betriebsart | `server/config.js` | `shop.fassung`, `SHOP_PREIS`, `SHOP_ENABLED` |
| Bestell-Haken und Knopf des Webshops (Register) | `server/config.js` | `shopConsent` |
| Betriebsart und Pilotliste | `server/services/shop/schalter.js` | `shopModus`, `shopFuerKunde` |
| Anfrage und Freigabe | `server/services/shop/anfrage.js` | – |
| Einstellungen, Zahlungsarten, Versand | `server/services/shop/einstellungen.js` | `STANDARD_EINSTELLUNGEN` |
| Artikel, Produktsicherheit, Lebensmittel, Grundpreis | `server/services/shop/produkte.js`, `server/services/shop/geld.js` | `normalisiereProdukt`, `grundpreisText` |
| Berechnung der Kasse (Preise nur aus der Datenbank) | `server/services/shop/preise.js` | `berechne` |
| Muster-Rechtstexte, Bestätigung, Gewährleistungshinweis | `server/services/shop/rechtstexte.js` | `musterWiderrufsbelehrung`, `musterWiderrufsformular`, `musterAgb`, `versandUndZahlung`, `BESTAETIGUNG_TEXT`, `gewaehrleistungsHinweis` |
| Stripe Connect: Konto, Onboarding, Checkout, Erstattung | `server/services/shop/connect.js` | `kontoAnlegen`, `onboardingLink`, `checkoutAnlegen`, `erstatten`, `connectEreignis` |
| Bestellung anlegen (IP-Hash, Stripe-Kasse ohne Telefonnummer), abschließen, Eingangsmail G15, verwerfen, Status, Erstattung, CSV | `server/services/shop/bestellungen.js` | `legeBestellungAn`, `ipHashVon`, `schliesseAb`, `meldeEingang`, `verwirf`, `setzeStatus`, `erstatte`, `csv` |
| Archiv der bestätigten Rechtstexte | `server/services/shop/archiv.js` | `archiviereRechtstexte`, `holeArchivierteRechtstexte` |
| Zustimmung in Textform bei Freischaltung durch das Team | `server/routes/admin/funktionenAdmin.js`, `server/services/shop/schalter.js`, `server/services/featureConsents.js` | `textformFuerAktivierung`, `shopTextformNoetig`, `speichereZustimmung` |
| Widerrufsfunktion der Käufer | `server/services/shop/widerrufe.js` | `normalisiereWiderruf` |
| Mails an Käufer und Händler | `server/services/shop/mails.js`, `server/mail-templates/` | `kaeuferWerte`, `haendlerK25`, `haendlerK26`; G09 bis G15, K24 bis K26, T21 |
| Öffentliche Schnittstelle des Widgets | `server/routes/publicShop.js` | Routen unter `/api/public/shop/v1/` |
| Widget | `public/embed/shop.js` | – |
| Löschfristen, Anonymisierung, Abschalten der Daten | `server/services/shop/jobs.js`, `server/services/shop/loeschen.js` | `taeglicherJob`, `anonymisierGrenze`, `loescheShopDaten` |
| Kundenlöschung | `server/services/shop/kundenLoeschung.js` | `schliesseShopsVonKunde` |
| Einrichtungs-Ticket mit Snippet und Schritten | `server/services/shop/ticketDetails.js` | `shopTicketDetails` |
| Rechtsunterlagen je Fassung | `server/services/legalDocs.js` | Satz `shop` |
| Widerruf der Bestellung | `server/routes/abo.js`, `public/widerrufen.html` | `ORDERS`, `FEATURE_OF_ORDER`, `orderLabel` |
| HTML-Seiten und PDFs der Vertragsunterlagen | `scripts/build-legal-html.mjs`, `scripts/build-legal-pdfs.sh` | Satz und Modus `shop` |
| Datenschutzerklärung von WEBklar | `public/datenschutz.html` | `id="webshop"` (Abschnitt 6d) |
## 6. Nicht Teil der Kanzleiprüfung
- **Hausrunde:** Datenschutzerklärung von WEBklar (Abschnitt 6d und die Ergänzungen in den Abschnitten 6, 7 und 12), die Hinweise im Kundenportal, die Mailtexte, die Einbauanleitung. Wir stellen nur die Fragen F27 und F28.
- **Gemeinsame Widerrufsbelehrung der Funktionen:** wird im Paket des Terminplaners geprüft; hier nur F8.
- **Die Rechtstexte der Händler im Einzelfall:** Jeder Händler bleibt für seine Texte verantwortlich (LB Nr. 5 Abs. 2 und Nr. 6). Wir bitten um Prüfung der Muster, nicht jedes Händlers.
- **Später:** Plattformgebühr, PayPal, Verkauf an Verbraucher außerhalb von Deutschland und Österreich, digitale Inhalte, Gutscheine, Abonnements, Rechnungserstellung durch WEBklar. Dafür gibt es keine Texte.
- **Steuerrecht der Händler** (Umsatzsteuer, Aufbewahrung): nur soweit F14 und F21 betroffen sind.
## 7. Checkliste für den Inhaber vor `SHOP_ENABLED=on`
Diese Punkte erledigt der Inhaber; kein Punkt ist Aufgabe der Kanzlei. Die Reihenfolge entspricht dem Ablauf bis zum ersten fremden Betrieb.
### 7.1 Vor der Beauftragung der Kanzlei
- [ ] Die PDFs der Anlagen A und B am Bildschirm ansehen (Kopfzeile, „Seite x von y“, Tabelle der Anlage des AVV); wir konnten sie nur über ihre HTML-Seiten prüfen.
- [ ] Bestätigen, dass WEBklar Kleinunternehmer nach § 19 UStG bleibt (Preis 299 € ohne Umsatzsteuer).
- [ ] Entscheiden, wie das Team den Vertragsende-Termin eines Händlers nicht verpasst (Erinnerung im Ticket, Kalender oder Abo-Liste), da der Webshop von Hand abgeschaltet wird (F29, `docs/funktionen/shop-einbau.md` Abschnitt 8).
- [ ] Entscheiden, ob Mehraufwand wahlweise über das Stundenkontingent abgerechnet werden darf (F7).
- [ ] Die Grafik `public/img/gewaehrleistung-eu.webp` mit der Originaldatei der Kommission abgleichen (F24).
### 7.2 Hetzner und Stripe (nur im Konto prüfbar)
- [ ] Auftragsverarbeitungsvertrag mit Hetzner für die Cloud **und** für das Webhosting (E-Mail-Versand) abgeschlossen; Anlage 1 nennt „Käufer“ und „Bestelldaten“ als Betroffene und Datenarten.
- [ ] Datensicherung eingeschaltet (AVV Nr. 9 Abs. 7 verspricht sieben Tagessicherungen).
- [ ] Stripe-Dashboard von WEBklar: Connect aktivieren (Plattformprofil: Verkäufer verkaufen eigene Waren, Direct Charges, keine Gebühr), Branding für das Onboarding setzen.
- [ ] Prüfen, dass der Vertrag mit Stripe die Nutzung von Connect für diesen Zweck erlaubt (F3).
### 7.3 Technik
- [ ] `node scripts/shop-setup.mjs --check`, dann `node scripts/shop-setup.mjs` (fünf Collections, darunter das Archiv der bestätigten Rechtstexte) und `node scripts/stripe-products.mjs --anlegen` (Produkt `shopSetup`), vor dem Code-Deploy.
- [ ] Connect-Webhook in Stripe anlegen (Ereignisse: `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`, `account.updated`, `charge.refunded`), Geheimnis als `STRIPE_CONNECT_WEBHOOK_SECRET` in `/home/webklar/apps/.env`, Zeilen `SHOP_ENABLED` und `SHOP_PILOT_EMAILS` in der Compose-Datei.
- [ ] Code nach `main` (nach dem Terminplaner), WOMS-Zweig nach `test`, Deploy zu einer ruhigen Zeit.
- [ ] PDF der Datenschutzerklärung mit `scripts/build-legal-pdfs.sh datenschutz` (nicht `all`), PDFs des Webshops mit `scripts/build-legal-pdfs.sh shop`; Prüfsummen mit den Tests abgleichen.
### 7.4 Uploads in WOMS „Rechtliches“ und Mailtexte
- [ ] In WOMS „Rechtliches“ Karten „Leistungsbeschreibung Webshop“ und „AVV Webshop“ bereitstellen (Setup-Skript von WOMS) und die Fassung 2026-10-05 hochladen; die neue Datenschutzerklärung (Stand 05.10.2026, `docs/WEBklar-Datenschutzerklaerung.pdf`) als neue Version hochladen.
- [ ] Mailtexte G09 bis G15, K24 bis K26, T21 in der Mail-Überarbeitung auf den endgültigen Wortlaut bringen, danach gerendert als Anlage E nachreichen (F27).
### 7.5 Pilot, Beauftragung und Freigabe
- [ ] Pilot mit dem Testkonto (`SHOP_ENABLED=pilot`): Anfrage, Freigabe, Bestellung, Einbau auf der Test-Website, Stripe-Konto, Testkauf für 1 € mit Erstattung, Widerruf über „Vertrag widerrufen“, Abschalten und Wiedereinschalten.
- [ ] Die Kanzlei mit der Prüfung des Kernpakets und den Fragen F1 bis F29 beauftragen (Anlagen A bis E beilegen; Umfang und Frist vereinbaren).
- [ ] Den Befund einarbeiten: Texte ändern, neues Fassungsdatum setzen (`config.shop.fassung`), HTML und PDFs neu erzeugen (`node scripts/build-legal-html.mjs shop`, `scripts/build-legal-pdfs.sh shop`), Prüfsummen und Tests nachziehen; dieses Paket nachziehen.
- [ ] Die Fassung ausdrücklich freigeben; erst danach `SHOP_ENABLED=on` setzen.
- [ ] Vor dem ersten fremden Betrieb: der Händler bestätigt seine Rechtstexte (Abschnitt 3.2), wir fügen den Datenschutz-Absatz (Anlage D) ein, Testkauf mit dem Händler.
@@ -0,0 +1,54 @@
# Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)
**Fassung 2026-10-05**
Das Widerrufsrecht gilt nur für Verbraucher. Wir zeigen die Belehrung und das Muster-Widerrufsformular trotzdem allen, die eine Funktion bestellen. Die Belehrung gilt für die Bestellung der Einrichtung einer Funktion im Kundenportal, zum Beispiel des Terminplaners. Hinweise dazu stehen nach dem Muster-Widerrufsformular.
## Widerrufsbelehrung
### Widerrufsrecht
Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.
Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.
Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.
Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.
Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.
### Folgen des Widerrufs
Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.
Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.
## Muster-Widerrufsformular
(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)
– An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:
– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)
– Bestellt am (*)/erhalten am (*)
– Name des/der Verbraucher(s)
– Anschrift des/der Verbraucher(s)
– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)
– Datum
(*) Unzutreffendes streichen.
## Hinweise zum Widerruf
Diese Hinweise gehören nicht zur gesetzlichen Widerrufsbelehrung. Sie erklären, wie sich das Widerrufsrecht auf die Bestellung einer Funktion auswirkt.
- Das Widerrufsrecht gilt für jede Bestellung einer Funktion gesondert, also für jedes Website-Projekt, für das der Kunde eine Funktion bestellt. Die Frist von vierzehn Tagen beginnt jeweils mit dem Vertragsschluss.
- Widerruft der Kunde die Einrichtung einer Funktion, endet diese Funktion. Das Wartungs-Abo bleibt davon unberührt.
- Hat WEBklar auf ausdrücklichen Wunsch des Kunden vor Ablauf der Widerrufsfrist mit der Einrichtung begonnen, schuldet der Kunde bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachte Leistung. Der Betrag bemisst sich als Anteil am Gesamtpreis; Maß ist der im Arbeitsblatt erfasste Aufwand für die Einrichtung.
- Die Rückzahlung erfolgt über das ursprüngliche Zahlungsmittel; die Zahlung lief über Stripe.
@@ -0,0 +1,52 @@
# Datenschutzerklärung von WEBklar: Abschnitt 6e „Öffnungszeiten live auf Websites unserer Kunden“ (Entwurf)
Entwurf für die Rechtsrunde (Spec „Öffnungszeiten live“, Abschnitt 9 Nr. 3), Stand 05.10.2026. Keine Rechtsberatung. **`public/datenschutz.html` ist nicht geändert**: Der Abschnitt wird erst nach der Freigabe durch den Inhaber (und nach der Kanzlei, soweit sie die Fragen aus `zeiten-kanzlei-fragen.md` bearbeitet) in die Erklärung übernommen. Reihenfolge der Rechtsrunde: Leistungsbeschreibung, dann dieser Abschnitt samt Website-Absatz (`docs/funktionen/website-datenschutz-absatz.md`), dann Mailtexte, dann Schalter `ZEITEN_ENABLED=on`.
Aufbau wie Abschnitt 6c (`termine-datenschutz-6c.md`): Rolle, gespeicherte Angaben, E-Mails, Aufruf der Kundenwebsite, Löschfristen. Die Leistungsbeschreibung verweist auf diesen Abschnitt (`/datenschutz.html#zeiten`, Nr. 12 Abs. 2): **Sie darf erst live gehen, wenn der Abschnitt mit dem Anker `id="zeiten"` in der Erklärung steht.**
Einbau in `public/datenschutz.html`: zwischen 6c und „7. Abos und Zahlung über Stripe“. Beim Übernehmen ändert sich der Stand der Erklärung (Abschnitt 15); die Tests `test/legalTexts.test.js` (Abschnitt 6c und Kundenansicht) und `test/zeitenRecht.test.js` (dieser Entwurf) werden dann angepasst.
**Nachtrag 06.10.2026 (nach dem Zusammenführen mit main):** Die Nummer 6d ist seit dem Webshop-Livegang vergeben („6d. Webshop auf Websites unserer Kunden“, Anker `id="webshop"`). Dieser Abschnitt heißt deshalb **6e** und steht hinter dem Webshop-Abschnitt und vor „7. Abos und Zahlung über Stripe“; der Anker `id="zeiten"` bleibt.
## Abschnitt 6e (HTML)
```html
<h2 id="zeiten">6e. Öffnungszeiten live auf Websites unserer Kunden</h2>
<p>Kunden können auf ihrer eigenen Website ihre Öffnungszeiten live anzeigen lassen („Öffnungszeiten live“): als Tabelle und als Status („Jetzt geöffnet“), als Hinweis-Leiste am Seitenanfang, auf dem Handy als Kontakt-Leiste mit Knöpfen zum Anrufen, für die Route und für WhatsApp sowie als Angaben für Suchmaschinen im Seitencode. Für die Besucher dieser Websites verarbeiten wir Daten ausschließlich als Auftragsverarbeiter nach den Weisungen des jeweiligen Kunden, der dafür Verantwortlicher ist. Wir verwenden diese Daten nicht für eigene Zwecke. Verantwortliche sind wir für die Angaben, die der Kunde im Kundenportal für die Funktion einträgt, für die E-Mails an den Kunden und für seine Bestellung. Diese Datenschutzerklärung gilt für unsere eigenen Seiten und das Kundenportal. Wer eine Kundenwebsite besucht, findet die Informationen zum Aufruf (Zwecke, Rechtsgrundlagen, Speicherdauer, Rechte) in der Datenschutzerklärung dieser Website.</p>
<p>Im Kundenportal speichern wir, was der Kunde für die Funktion einträgt: seine Öffnungszeiten (benannte Zeitpläne, Zeiten je Wochentag, Abweichungen an Feiertagen, Betriebsurlaub und Sondertage sowie das gewählte Bundesland), Hinweise für die Hinweis-Leiste (Text, Art, Zeitraum und gegebenenfalls ein Knopf mit Link oder Telefonnummer), Kontaktangaben für die Kontakt-Leiste (Telefonnummer, Anschrift für die Route und auf Wunsch WhatsApp-Nummer und Starttext der Nachricht, dazu der Zeitpunkt, zu dem der Kunde bestätigt hat, den WhatsApp-Hinweis in seine Datenschutzerklärung aufgenommen zu haben), die Anzeige-Einstellungen, die unser Team für die Website des Kunden setzt, sowie Zeitpunkt und Urheber (Kunde oder Mitarbeiter) der letzten Änderung. Das sind überwiegend Betriebsdaten; personenbezogene Daten enthalten sie nur, soweit der Kunde sie einträgt, zum Beispiel die Telefonnummer eines Inhabers. Was auf der Website erscheinen soll (Zeiten, Hinweise, Telefonnummer, Anschrift, WhatsApp-Nummer, Name des Betriebs), ist dort öffentlich sichtbar. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, wie bei den übrigen Portal-Daten (Abschnitt 6). Unsere Mitarbeiter können die Zeiten im Auftrag des Kunden ansehen und ändern (Abschnitt 6); den Haken für den WhatsApp-Hinweis setzen sie nicht. Die Bestellung und ihren Nachweis beschreibt Abschnitt 7.</p>
<p>Hat der Kunde die Funktion eingeschaltet, erinnern wir ihn per E-Mail an die E-Mail-Adresse seines Kundenkontos vor Feiertagen: Die E-Mail nennt die Tage der nächsten sieben Tage, an denen ein Feiertag seines Bundeslands liegt oder an denen wir um Prüfung bitten, und fragt, ob seine Zeiten für diese Tage stimmen. Für jeden Tag senden wir höchstens eine Erinnerung; dafür speichern wir den Tag als Merker (ohne weitere Angaben) und löschen den Merker nach 30 Tagen. Außerdem senden wir dem Kunden E-Mails zum Stand der Funktion (eingeschaltet, Ende des Wartungs-Abos mit der Möglichkeit, die Funktion zu behalten, abgeschaltet). Der Versand läuft über den E-Mail-Dienst der Hetzner Online GmbH (Abschnitt 8). Die E-Mails enthalten keine Werbung. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, weil die Erinnerung zur Funktion gehört.</p>
<p>Beim Aufruf einer Kundenwebsite mit Öffnungszeiten live lädt der Browser ein kleines Programm von unserem Server (project.webklar.com) und die Öffnungszeiten des Kunden. Das Programm ruft die Daten einmal je Seitenaufruf ab und danach höchstens alle zehn Minuten erneut. Dabei gelten die Protokolle in Abschnitt 3. Das Programm setzt keine Cookies, speichert nichts im Browser des Besuchers und lädt nichts von anderen Anbietern. Die Knöpfe „Route“ und „WhatsApp“ sind Links: Erst der Klick des Besuchers öffnet Google Maps oder WhatsApp; vorher übertragen wir nichts an diese Dienste, und danach gelten deren Datenschutzhinweise. Zum Schutz vor Missbrauch begrenzen wir die Abrufe je IP-Adresse (höchstens 120 je Minute); dafür merken wir uns die Zeitpunkte der Abrufe je IP-Adresse nur im Arbeitsspeicher des Servers, nicht dauerhaft. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere und störungsfreie Betrieb.</p>
<p>Löschfristen: Haben Sie die Funktion im Wartungs-Abo ohne Aufpreis eingeschaltet und endet das Wartungs-Abo, läuft sie noch 30 Tage weiter; in dieser Zeit können Sie sie für einmalig 30 € behalten. Andernfalls schalten wir sie danach ab, bewahren Ihre Eingaben noch weitere 30 Tage auf und löschen sie dann; beginnt in dieser Zeit wieder ein Wartungs-Abo, schalten wir die Funktion mit den vorhandenen Eingaben wieder ein. Haben Sie die Funktion für 30 € bestellt, bleiben Ihre Eingaben, solange die Funktion läuft. Schalten wir sie auf Ihren Wunsch ab, löschen wir Ihre Eingaben auf Ihr Verlangen, spätestens mit der Löschung Ihres Kundenkontos. In unseren Datensicherungen bleiben gelöschte Daten noch bis zu sieben Tage erhalten und werden dann überschrieben. Die Daten liegen auf unserem Server bei Hetzner im Rechenzentrum Falkenstein (Deutschland), wie die übrigen Portal-Daten (Abschnitt 3).</p>
```
## Ergänzung Abschnitt 6 (Mitarbeiter im Kundenauftrag)
Heute steht in Abschnitt 6 (wörtlich, `public/datenschutz.html`; der Satz ist durch `legal_datenschutz_kundenansicht` in `test/legalTexts.test.js` gepinnt):
> Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so öffnen, wie Sie es sehen, und dabei für Sie News-Beiträge anlegen, ändern oder löschen und, wenn Sie den Terminplaner nutzen, dessen Einstellungen und Termine ansehen und ändern.
Vorgeschlagen:
> Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so öffnen, wie Sie es sehen, und dabei für Sie News-Beiträge anlegen, ändern oder löschen, wenn Sie den Terminplaner nutzen, dessen Einstellungen und Termine ansehen und ändern sowie, wenn Sie Öffnungszeiten live nutzen, Ihre Öffnungszeiten, Hinweise und Kontaktangaben ansehen und ändern.
Der folgende Satz („Bestellungen, Zahlungen, Änderungen an Ihrem Passwort sowie Nachrichten oder Anträge in Ihrem Namen sind dabei ausgeschlossen.“) und das Protokoll (Mitarbeiter und Zeitpunkt, 12 Monate) bleiben unverändert und decken auch das Speichern der Zeiten durch Mitarbeiter ab (Protokollaktion `zeiten_gespeichert`). Beim Übernehmen die beiden Pins in `test/legalTexts.test.js` (`legal_datenschutz_kundenansicht`) auf den neuen Satz umstellen.
## Folgeänderung Abschnitt 7 (Nachweis beim Einschalten)
Abschnitt 7 nennt heute die Funktionen und den Klick auf „Zahlungspflichtig bestellen“. Beim Einschalten im Wartungs-Abo gibt es keine Zahlung und keine Stripe-Sitzung, gespeichert wird der Nachweis der Zustimmung trotzdem. Heute (wörtlich):
> Bestellen Sie eine Funktion (News-Pinnwand, Terminplaner), speichern wir als Nachweis die Texte und Fassungen, denen Sie zugestimmt haben, den Zeitpunkt Ihres Klicks auf „Zahlungspflichtig bestellen“, die Stripe-Sitzung, IP-Adresse und Browserkennung sowie Ihr Konto und die angemeldete Person. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO; unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und der Zustimmungen, insbesondere für den Widerruf. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet. Bestellen Sie ohne Abo (zum Beispiel nur die News-Pinnwand), löschen wir diese Angaben spätestens drei Jahre nach Ablauf des Jahres der Bestellung. Den Entwurf Ihres Fragebogens speichern wir bis zu 90 Tage nach Ihrer letzten Änderung und löschen ihn danach, wenn Sie nichts bestellt haben.
Vorgeschlagen (Ergänzung der Klammer und des Klicks; der Rest bleibt):
> Bestellen Sie eine Funktion (News-Pinnwand, Terminplaner, Öffnungszeiten live) oder schalten Sie Öffnungszeiten live im Wartungs-Abo ein, speichern wir als Nachweis die Texte und Fassungen, denen Sie zugestimmt haben, den Zeitpunkt Ihres Klicks auf „Zahlungspflichtig bestellen“ oder „Einschalten“, bei einer Bestellung die Stripe-Sitzung, jeweils die IP-Adresse und die Browserkennung sowie Ihr Konto und die angemeldete Person.
Diese Folgeänderung steht nicht in Spec 9 Nr. 3; sie ergibt sich daraus, dass der Gratisweg einen Nachweis speichert. Gegen den Code geprüft (`speichereNachweisAusAnfrage` in `server/services/featureConsents.js`, Zeilen 196 und 197): Der Gratisweg speichert IP-Adresse und Browserkennung genauso wie die Bestellung; nur die Stripe-Sitzung entfällt (ohne `sessionId` trägt der Nachweis keine). Der Satz nennt deshalb die Stripe-Sitzung nur bei der Bestellung, IP-Adresse und Browserkennung bei jeder Zustimmung.
## Offene Punkte für die Rechtsrunde
1. **Rolle bei Websites ohne Abo oder bei Fremdhosting.** Der Entwurf sagt wie 6c: Wir sind Auftragsverarbeiter der Kunden für die Besucherdaten (Server-Protokolle). Mit Abo deckt das Anlage 1 der Abo-Bedingungen, ohne Abo oder bei Fremdhosting besteht kein Auftragsverarbeitungsvertrag (gleiche Lücke wie bei der News-Pinnwand). Frage `ZQ1` in `zeiten-kanzlei-fragen.md`.
2. **Löschung bei Abschaltung durch das Team** (Spec Abschnitt 5: keine automatische Löschung bei Grund `team`). Der Entwurf sagt „auf Verlangen, spätestens mit der Löschung des Kundenkontos“. Soll stattdessen eine feste Frist gelten (zum Beispiel 30 Tage wie beim Ende des Abos)? Entscheidung des Inhabers; dann auch Leistungsbeschreibung Nr. 9 Abs. 5 und das Programm (`abschalten.js`) anpassen.
3. **Erinnerungs-E-Mail.** Rechtsgrundlage Art. 6 Abs. 1 lit. b DSGVO (Teil der Funktion). Es ist keine Werbung; ob sie wegen des Hinweises „bei Google auch eintragen“ als Werbung gilt, ist nicht anzunehmen, steht aber in der Kanzlei-Frage `ZQ6`.
4. **Nachweis beim Einschalten** (Folgeänderung Abschnitt 7, siehe oben): beantwortet. Beim Einschalten im Wartungs-Abo werden IP-Adresse und Browserkennung ebenfalls gespeichert, nur die Stripe-Sitzung entfällt (der Satz ist entsprechend gefasst). Offen bleibt allein die rechtliche Prüfung des Satzes.
5. **Kontaktdaten öffentlich.** Die Telefonnummer und Anschrift stehen im Seitencode der Kundenwebsite (Abschnitt 6e, zweiter Absatz). Das ist die Entscheidung des Kunden; die Leistungsbeschreibung (Nr. 6 Abs. 3) verlangt, nur veröffentlichungsfähige Angaben einzutragen.
@@ -0,0 +1,43 @@
# K02 und K03: Block `funktionen` mit Öffnungszeiten live (Entwurf)
Entwurf für die Rechtsrunde (Spec „Öffnungszeiten live“, Abschnitte 7 und 9; Task 33), Stand 05.10.2026. **Keine Codeänderung:** Der Block `funktionen` in `server/mail-templates/K02 Kuendigungsbestaetigung.html` und `K03 Eingang Kuendigung.html` ist Rechtstext mit Pins; dieser Entwurf schlägt den neuen Satz nur vor. Der Inhaber entscheidet, die Kanzlei sieht ihn mit der Mailrunde (Spec 9: Oberflächen- und Mailtexte). Erst danach werden die Vorlagen und die Pins geändert.
Beide Vorlagen sind Kündigungsbestätigungen an den Kunden und stehen in der Sie-Form. Der Block erscheint nur beim Wartungs-Abo (`server/routes/abo.js`: K02 mit `funktionen: isWartung(sub)`, K03 mit `funktionen: !storage && isWartung(sub)`), nicht bei Hosting-Abo und nicht bei der Kündigung von Zusatzspeicher.
## Heute (gepinnt)
Der Block besteht aus diesem Satzpaar, in beiden Vorlagen wörtlich gleich:
> Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm. Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.
Pins, die der Satz heute hat:
- `test/mailTemplates.test.js`, Test „K02, K03: Block funktionen nennt Terminplaner und Löschfrist, nur mit Block (in K03 nur innerhalb von abo)“: prüft das ganze Satzpaar mit einem regulären Ausdruck (beginnend mit „Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm.“ bis „… ansehen und exportieren.“), dass der Block ohne `funktionen` fehlt und dass „Terminplaner“ bei Hosting-Abo und bei Speicher-Kündigung nirgends steht.
- `test/publicForms.test.js`, Konstante `FUNKTIONEN_SATZ` (nur der erste Satz, „… und enden mit ihm.“).
- Die Vorlagen-Kommentare (Zeile 12 beider Vorlagen) beschreiben den Block als „Hinweis, dass Website-Analyse und Terminplaner mit dem Wartungs-Abo enden“.
## Vorschlag
Der heutige Satz bleibt unverändert der Anfang (damit beide Pins ohne Änderung grün bleiben); angehängt wird ein Satz für Öffnungszeiten live, der die 30 Tage zum Behalten und die 30 Tage Datenfrist nach Z11 und Leistungsbeschreibung Nr. 9 nennt:
> Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm. Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren. Haben Sie Öffnungszeiten live im Wartungs-Abo eingeschaltet, läuft die Funktion nach dem Ende des Abos noch 30 Tage weiter; in dieser Zeit können Sie sie im Kundenportal für einmalig 30 € behalten. Behalten Sie sie nicht, schalten wir sie danach ab, auf Ihrer Website stehen dann wieder feste Öffnungszeiten, und Ihre Eingaben löschen wir 30 Tage später.
Warum so:
- Der Satz ist bedingt („Haben Sie … eingeschaltet“), weil der Block bei jeder Kündigung eines Wartungs-Abos erscheint, auch bei Kunden ohne die Funktion.
- Wer die Funktion für 30 € bestellt hat, behält sie ohne Abo (Leistungsbeschreibung Nr. 9 Abs. 2 letzter Satz); für ihn gilt der Satz nicht, was die Bedingung „im Wartungs-Abo eingeschaltet“ ausdrückt. Ein eigener Satz dazu wäre in einer Kündigungsbestätigung verwirrend.
- Die Einzelheiten (Frist mit Datum, Knopf „Behalten“) sagt K29 („Wartungs-Abo beendet“), sobald das Ende des Abos erkannt ist; K02 und K03 sagen nur, dass es das gibt.
- „Abschalten“ bedeutet: Hinweis-Leiste, Kontakt-Leiste, Status und Google-Angaben verschwinden, die feste Ersatz-Tabelle der Website bleibt stehen (Leistungsbeschreibung Nr. 9 Abs. 4).
Im HTML ändert sich nur der Inhalt der Zelle zwischen `<!-- WENN funktionen -->` und `<!-- ENDE funktionen -->`; Aufbau und Blockkommentare bleiben. Die Kopfkommentare beider Vorlagen sagen dann „Website-Analyse, Terminplaner und Öffnungszeiten live“.
## Alternative
Ein eigener Block `zeiten` (nur für Kunden, bei denen die Funktion im Abo läuft): kein Text für Kunden ohne Funktion und die Zeile steht ohne „Haben Sie …“. Dafür braucht das Programm in `server/routes/abo.js` an beiden Stellen eine zusätzliche Abfrage der Funktion, beide Vorlagen einen neuen Block, und die Pins im Mail-Pin-Test (K01 bis K30) ändern sich. Nur sinnvoll, wenn der Inhaber den bedingten Satz nicht möchte.
## Beim Übernehmen
1. Vorlagen K02 und K03 ändern (Zelle des Blocks, Kopfkommentar).
2. `test/mailTemplates.test.js` (K02/K03-Test): zusätzlicher Wortlaut-Pin für den neuen Satz; die bestehenden Prüfungen bleiben.
3. Den Mail-Pin-Test (K01 bis K30, ebenfalls `test/mailTemplates.test.js`) und, falls sich der Block ändert, die Prüfung in `test/publicForms.test.js` nachziehen.
4. Kanzlei-Frage `ZQ5` (Ende mit dem Abo, 30 Tage zum Behalten) in `zeiten-kanzlei-fragen.md` abwarten, weil sie den Inhalt des Satzes betrifft.
+134
View File
@@ -0,0 +1,134 @@
# Fragen an die Kanzlei: Öffnungszeiten live (⚖)
Stand 05.10.2026, Entwurf (Spec „Öffnungszeiten live“, Abschnitt 9, und Task 33). Dies sind die Fragen zu den Texten dieser Funktion; sie ergänzen das Kanzlei-Paket des Terminplaners (`docs/rechtliches/termine-kanzlei-paket.md`, Fragen Q1 bis Q26).
**Warum eine eigene Datei.** Das Kanzlei-Paket des Terminplaners liegt auf dem Zweig `feature/terminplaner-tp2` und ist in diesen Zweig (`feature/oeffnungszeiten`) noch nicht eingeflossen; ändern wir es hier, entsteht beim Zusammenführen ein Konflikt. Die Fragen tragen deshalb die Kennung `ZQ1` bis `ZQ12`. Beim Zusammenführen: als `Q27` bis `Q38` in Abschnitt 4 des Kanzlei-Pakets übernehmen, die Fundstellen in Abschnitt 5 ergänzen, die Fassung der Leistungsbeschreibung in Abschnitt 3.4 daneben stellen. In veröffentlichten Dateien stehen keine ⚖-Marker.
Jede Frage hat vier Teile wie im Paket des Terminplaners: **Frage** (was wir wissen möchten), **Fundstelle im Text** (wo der Wortlaut steht), **Unsere Annahme** (der Stand, mit dem wir arbeiten; bitte bestätigen oder korrigieren), **Alternative** (was wir stattdessen täten). Einordnungen wie „Annahme“ sind keine Rechtsurteile. Die Texte, um die es geht: `public/vertragsunterlagen/zeiten-leistungsbeschreibung.html` (Leistungsbeschreibung, im Folgenden „LB“), `docs/rechtliches/zeiten-datenschutz-6e-entwurf.md` (Abschnitt 6e), `docs/rechtliches/zeiten-k02-k03-entwurf.md` (K02/K03), `docs/funktionen/website-datenschutz-absatz.md` (Website-Absatz) und die Haken im Register (`server/config.js`, `features.zeiten`).
Reihenfolge der Rechtsrunde (gemeinsam mit TP2): gemeinsame Bausteine (TP2 Block F) → Leistungsbeschreibung → Datenschutz § 6e/§ 6 und Website-Absatz → Oberflächen- und Mailtexte → diese Fragen ans Kanzlei-Paket → Inhaber lädt die Fassungen in WOMS „Rechtliches“ hoch → Schalter `ZEITEN_ENABLED=on`.
### ZQ1: ⚖ AVV Funktionen per Haken?
**Frage.** Für Öffnungszeiten live verarbeitet WEBklar beim Aufruf einer Kundenwebsite Besucherdaten (IP-Adresse, Zeitpunkt, Browserangaben in Server-Protokollen, 14 Tage). Mit einem Wartungs-Abo deckt Anlage 1 der Abo-Bedingungen (Auftragsverarbeitung, Server-Protokolle der Website) das ab. Beim 30-€-Weg ohne Abo oder bei Websites, die woanders liegen, gibt es keinen Auftragsverarbeitungsvertrag; das ist dieselbe Lücke wie bei der News-Pinnwand. Soll sie mit einem zusätzlichen Haken „AVV Funktionen“ beim Bestellen und Einschalten geschlossen werden (wie der Haken für den AVV Terminplaner und den AVV Website-Analyse), und genügt ein gemeinsamer AVV „Funktionen“ für News-Pinnwand und Öffnungszeiten live? Oder ist WEBklar für die Protokolle des eigenen Servers ohnehin selbst Verantwortlicher, sodass kein AVV nötig ist?
**Fundstelle im Text.** Spec Abschnitt 9 Nr. 5; Register `features.zeiten.avv: null`; Abschnitt 6e Absatz 1 (Rolle als Auftragsverarbeiter) und Offener Punkt 1; Website-Absatz Variante B (`[Prüfen: AVV mit WEBklar erforderlich]`); LB Nr. 12 Abs. 2.
**Unsere Annahme.** Mit Abo genügt Anlage 1. Ohne Abo und bei Fremdhosting braucht es einen AVV; wir würden einen kurzen AVV „Funktionen“ per Haken einführen (Aufbau und Nummern wie AVV Terminplaner Nr. 4 bis 9, ohne Termindaten, Unterauftragsverarbeiter nur Hetzner).
**Alternative.** Nichts ändern, weil die IP-Adresse in Server-Protokollen beim Abruf eines Skripts von `project.webklar.com` in der Verantwortung des Betreibers dieses Servers liegt; oder Öffnungszeiten live nur mit Wartungs-Abo oder bei WEBklar-Hosting verkaufen; oder der Rahmen-AVV mit Anlage je Funktion (TP2 Spec 7.4 „Später“).
### ZQ2: Behalten-Zustimmung nutzt den Haken „Einbau beginnt“
**Frage.** Endet das Wartungs-Abo, kann der Kunde Öffnungszeiten live im Kundenportal für einmalig 30 € behalten (LB Nr. 9 Abs. 2). Der Weg „Behalten“ verlangt dieselbe Zustimmung wie die Neubestellung, also auch den zweiten Haken (`beginn`): „Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit vollständiger Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).“ Beim Behalten gibt es keinen Einbau; die Funktion läuft bereits, es entsteht kein neues Ticket. Passt der Haken dann? Ist das Behalten ein eigener Vertrag mit eigenem Widerrufsrecht für Verbraucher (Frist ab dem Klick), und was ist die „Leistung“, deren vollständige Erbringung das Widerrufsrecht erlöschen lässt (der Weiterbetrieb ohne Abo hat kein Ende)?
**Fundstelle im Text.** Register `features.zeiten.consent.items` (Haken `leistung` und `beginn`); Programm: `POST /api/funktionen/zeiten/behalten` (`pruefeZustimmung(feature, body)`: „die der Bestellung, beide Haken“), `server/services/zeiten/behalten.js`; Spec Abschnitt 3.3; LB Nr. 9 Abs. 2 und Nr. 11.
**Unsere Annahme.** Der Haken ist für das Behalten unschädlich, weil er nur das Verlangen des Beginns und die Kenntnis vom Erlöschen bestätigt. Der Wortlaut „mit dem Einbau“ ist aber ungenau; wir würden für das Behalten „mit der Leistung“ oder „mit dem Weiterbetrieb“ sagen. Der Widerruf eines Behalten-Vertrags führt zur Abschaltung (LB Nr. 11 Abs. 3).
**Alternative.** Ein eigener Haken für das Behalten („Ich verlange, dass die Funktion nach dem Ende meines Wartungs-Abos ohne Unterbrechung weiterläuft …“); oder kein Beginn-Haken, wenn für das Behalten kein Widerrufsrecht besteht (Kunde ist Unternehmer, Leistung sofort).
### ZQ3: Beginn-Haken der bezahlten Bestellung: Wertersatz-Satz und Fassung
**Frage.** Der Haken `beginn` der Bestellung für 30 € trug zunächst nur das Zitat zum Erlöschen des Widerrufsrechts. Nach der Abschlussprüfung ist der Satz zum Wertersatz ergänzt, wörtlich wie in den Haken der News-Pinnwand und des Terminplaners (TP2 Q3 und Q5). Er lautet jetzt: „Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit vollständiger Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).“ Die Fassung der Zustimmung ist dafür auf `2026-10-05` gesetzt (die übrigen Funktionen-Unterlagen tragen ebenfalls `2026-10-05`; die Gratis-Zustimmung `consentFrei` und die LB bleiben bei `2026-10-04`, weil ihr Text unverändert ist). Bitte bestätigen: (a) Genügt der Satz in dieser Form, auch mit „der Leistung“ statt „des Einbaus“ im ersten Satzteil (bei der News-Pinnwand steht „des Einbaus“)? (b) Dürfen die Fassungen je Funktion unterschiedlich nummeriert sein, oder soll der Nachweis eine gemeinsame Fassung der Funktionen-Unterlagen tragen? Die LB nennt den Wertersatz weiterhin in Nr. 11 Abs. 2.
**Fundstelle im Text.** Register `features.zeiten.consent` (Fassung `2026-10-05`, Haken `leistung` und `beginn`) und `consentFrei` (Fassung `2026-10-04`); LB Nr. 11 Abs. 2; Widerrufsbelehrung der Funktionen (Hinweise zum Widerruf, Wertersatz als Anteil am Gesamtpreis); Test `test/zeitenRegister.test.js` pinnt den Wortlaut und prüft, dass der Satz mit dem der News-Pinnwand und des Terminplaners übereinstimmt.
**Unsere Annahme.** Der Wertersatz-Satz gehört wie beim Terminplaner in den Haken (Frage Q3 des Terminplaners gilt entsprechend) und ist jetzt enthalten; der Wortlaut bleibt Entwurf bis zur Freigabe, danach wird die Fassung auf den Tag der Freigabe gesetzt (Pins in `test/zeitenRegister.test.js` mitziehen).
**Alternative.** Den Satz wieder aus dem Haken nehmen und nur in der Belehrung und der LB lassen; eine gemeinsame Fassung für alle Funktionen.
### ZQ4: Haftung, ausgeschrieben für Kunden ohne Abo (LB Nr. 8)
**Frage.** Die LB schreibt die Haftung aus, statt auf § 13 der Abo-Bedingungen zu verweisen, weil Kunden ohne Wartungs-Abo die Abo-Bedingungen nicht haben. Nr. 8 Abs. 4 und 5 geben § 13 Abs. 1 und 2 wörtlich wieder, Abs. 6 den Satz zugunsten der Mitarbeiter und Beauftragten (§ 13 Abs. 5) und den Hinweis, dass der Abschnitt auch für Kunden ohne Abo gilt. Abs. 1 bis 3 sagen: Die Live-Anzeige ist ein Hilfsmittel, die Richtigkeit der Angaben liegt beim Kunden, Fehler in Feiertagsdaten und Softwarefehler beseitigt WEBklar nach Meldung, für entgangenen Gewinn und Folgeschäden (etwa weil ein Besucher wegen einer Fehlanzeige vor verschlossener Tür steht) gelten Abs. 4 und 5. Fragen: (a) Ist die Klausel gegenüber Verbrauchern mit § 309 Nr. 7 BGB vereinbar und hinreichend transparent? (b) Braucht es gegenüber Unternehmern einen Höchstbetrag? (c) Fehlen § 13 Abs. 3 (Datenverlust, Wiederherstellungsaufwand) und Abs. 4 (kein Schadensersatz nach § 536a Abs. 1 BGB für anfängliche Mängel) für Kunden ohne Abo? (d) Ist die Zusage „Fehler beseitigt WEBklar in angemessener Frist“ (Nr. 7 Abs. 1, Nr. 8 Abs. 2) für Kunden ohne Abo zu weit?
**Fundstelle im Text.** LB Nr. 7 und Nr. 8; Abo-Bedingungen § 13 (`public/abo-bedingungen.html`); der Test `test/zeitenRecht.test.js` hält den Wortlaut beider Klauseln gleich; TP2 Q8 (Haftung für verpasste Termine).
**Unsere Annahme.** Die Spiegelung von § 13 Abs. 1 und 2 genügt; ein Höchstbetrag ist nicht nötig, weil Abs. 2 den Schaden auf den vertragstypischen, vorhersehbaren begrenzt. Die Abs. 3 und 4 sind für diese Funktion nicht nötig (die Funktion speichert keine Daten Dritter; ein Schadensersatz für anfängliche Mängel ist bei einem Einmalpreis von 30 € kein praktisches Thema).
**Alternative.** Abs. 3 und 4 des § 13 ebenfalls ausschreiben; einen Höchstbetrag (zum Beispiel den Preis der Funktion oder den Jahresbetrag des Wartungs-Abos) gegenüber Unternehmern festlegen.
### ZQ5: Ende mit dem Abo: 30 Tage zum Behalten, Abschalten, Rückkehr (LB Nr. 9)
**Frage.** Wer Öffnungszeiten live im Wartungs-Abo ohne Aufpreis eingeschaltet hat, behält die Funktion nach dem Ende des Abos noch 30 Tage; in dieser Zeit kann er sie für einmalig 30 € behalten, sonst schaltet WEBklar sie ab (auf der Website bleibt die feste Ersatz-Tabelle), die Eingaben bleiben noch weitere 30 Tage und werden dann gelöscht; beginnt in dieser Zeit wieder ein Wartungs-Abo, läuft die Funktion mit den Eingaben weiter. Wer sie für 30 € bestellt hat, behält sie auch ohne Abo. Fragen: (a) Ist die Kopplung der Gratis-Funktion an das Abo mit dieser Frist zulässig, obwohl sie ein „freiwilliges Extra“ ist und die Abo-Bedingungen unverändert bleiben (Z19)? (b) Darf die Rückkehr bei neuem Abo ohne neue Zustimmung automatisch erfolgen? (c) Frist und Anker der Löschung (30 Tage nach dem Abschalten) und der Sicherungsüberhang von sieben Tagen. (d) Bei Abschaltung durch WEBklar auf Wunsch des Kunden oder aus anderem Grund als dem Ende des Abos gibt es im Programm keine automatische Löschung; die LB sagt „auf Verlangen, spätestens mit dem Löschen des Kundenkontos“ (Nr. 9 Abs. 5). Genügt das, oder braucht es eine feste Frist? (e) Gehört das einmalige Aktualisieren der Ersatz-Tabelle beim Abschalten (Nr. 9 Abs. 4) zu einer Leistung, die WEBklar auch nach dem Ende des Abos schuldet?
**Fundstelle im Text.** LB Nr. 9 Abs. 1 bis 5, Nr. 4 Abs. 3; Abschnitt 6e (Löschfristen) und Offener Punkt 2; K02/K03 (`zeiten-k02-k03-entwurf.md`); Programm: `server/services/zeiten/aboAbgleich.js` (30 Tage Frist zum Behalten, 30 Tage Datenfrist), `abschalten.js`; Spec Z11 und Abschnitt 5; Parallele: TP2 Q7 und Q9 (Terminplaner endet mit dem Abo).
**Unsere Annahme.** Ja zu (a) bis (c): Der Kunde hat die Funktion nur durch das Abo ohne Aufpreis; die Frist zum Behalten und die Datenfrist sind angemessen, die Rückkehr stellt den früheren Stand wieder her. Zu (d) genügt „auf Verlangen“, weil die Eingaben Betriebsdaten sind und der Kunde die Löschung jederzeit verlangen kann. Zu (e) ja, weil es zum Einbau gehört, den der Preis enthält (Z20).
**Alternative.** Löschung auch bei Abschaltung durch das Team nach 30 Tagen (das Programm müsste dafür angepasst werden); keine Frist zum Behalten (Abschaltung mit dem Ende des Abos); Rückkehr nur auf Antrag.
### ZQ6: Mails zur Funktion: Erinnerung vor Feiertagen und Zustandsmails
**Frage.** WEBklar sendet dem Kunden E-Mails: K27 (eingeschaltet), K28 (Erinnerung 1 bis 7 Tage vor einem Feiertag oder einem Prüftag: „Stimmen Ihre Zeiten an diesen Tagen?“, mit einem Knopf zum Editor und dem Hinweis, die Zeiten auch bei Google einzutragen), K29 (Wartungs-Abo beendet, Frist, „für einmalig 30 € behalten“), K30 (abgeschaltet). Sind das Vertragsmails (Art. 6 Abs. 1 lit. b DSGVO) ohne Einwilligung, oder wirkt K28 wegen des Hinweises „bei Google auch eintragen“ oder des Verweises auf die Funktion als Werbung (§ 7 UWG, Art. 6 Abs. 1 lit. f DSGVO mit Widerspruchsmöglichkeit)? Muss K28 einen Hinweis zum Abbestellen tragen? Die Erinnerung lässt sich im Programm nicht abschalten; der Kunde kann nur die Funktion abschalten lassen.
**Fundstelle im Text.** Abschnitt 6e Absatz 3; LB Nr. 2 Abs. 2; Mailvorlagen K27 bis K30 (`server/mail-templates/`), Spec Abschnitt 7 (K28: eine Mail für alle neuen Tage, Merker, 08:00 bis 20:00 Uhr Berliner Zeit, kein BCC ans Team).
**Unsere Annahme.** Die Erinnerung ist Teil der vertraglichen Leistung (LB Nr. 2 Abs. 2) und deshalb eine Vertragsmail ohne Einwilligung; der Hinweis auf Google dient der Durchführung (die Zeiten dort aktuell zu halten), nicht der Werbung.
**Alternative.** Hinweis auf Google aus K28 streichen; einen Abbestell-Link („Erinnerungen ausschalten“) mit Einstellung im Editor vorsehen.
### ZQ7: Einstellung ohne Grund bei Unternehmern im Gratisweg (LB Nr. 10 Abs. 4)
**Frage.** Die LB folgt in Nr. 10 der Einstellungsregel der Website-Analyse (Nr. 8 dort): Beenden oder Einstellen mit vier Wochen oder drei Monaten nur bei Rechtslage oder nicht behebbarer Sicherheitslücke (Abs. 2), zusätzlich nach Abs. 4 **ohne** besonderen Grund mit drei Monaten gegenüber Unternehmern, die die Funktion im Wartungs-Abo ohne Aufpreis haben; der Kunde kann das Abo dann innerhalb von 30 Tagen zum Einstellungszeitpunkt kündigen, auch vor Ablauf der Mindestlaufzeit. Wer die Funktion für 30 € bestellt hat, kann sie nur aus den Gründen von Abs. 2 verlieren (Abs. 5). Fragen: (a) Ist die Einstellung ohne Grund für ein kostenloses Zusatzangebot zulässig, auch wenn die Funktion im Abo „enthalten“ beworben wird (Z4)? (b) Ändert das Sonderkündigungsrecht in Abs. 4 die Abo-Bedingungen so, dass das Verfahren nach § 15 nötig wäre (Z19: freiwilliges Extra, keine Änderung der Abo-Bedingungen)? (c) Braucht es bei Einstellung einer gekauften Funktion durch WEBklar eine (anteilige) Erstattung des Preises (vgl. TP2 Q8)?
**Fundstelle im Text.** LB Nr. 10 Abs. 1 bis 5; Abo-Bedingungen §§ 3 Abs. 7, 8, 15; LB der Website-Analyse Nr. 8 (`docs/rechtliches/leistungsbeschreibung-website-analyse.md`); Spec Abschnitt 9 Nr. 1 („Einstellung/Änderung nach Vorbild Analyse Nr. 8“).
**Unsere Annahme.** Ja zu (a) und (b) wie bei der Analyse: Das Sonderkündigungsrecht stellt den Kunden besser; die Abo-Bedingungen bleiben unverändert. Zu (c): Bei Einstellung aus den Gründen von Abs. 2 erstattet WEBklar nichts, weil die Funktion bis dahin lief; die Rechte aus § 327r BGB bleiben.
**Alternative.** Abs. 4 streichen (Einstellung nur aus Gründen); bei gekauften Funktionen anteilig erstatten.
### ZQ8: Website-Absatz: WhatsApp und Google Maps erst nach Klick, Haken als Nachweis
**Frage.** (a) Der Absatz „Öffnungszeiten auf dieser Website“ (Variante A und B) sagt, dass die Knöpfe „Route“ und „WhatsApp“ einfache Links sind, dass Google Maps und WhatsApp erst nach dem Klick geöffnet werden und dass vorher nichts an Google oder Meta übertragen wird; das Skript setzt keine Cookies und speichert nichts im Browser. Genügt das, oder braucht es für diese Links eine Einwilligung (§ 25 TDDDG, Art. 6 Abs. 1 lit. a DSGVO)? (b) Der Kunde bestätigt im Portal mit einem Haken, dass er den „WhatsApp-Hinweis“ (Baustein WhatsApp) in seine Datenschutzerklärung aufgenommen hat; der Zeitpunkt wird gespeichert, Mitarbeiter setzen den Haken in der Kundenansicht nicht. Reicht der Haken als Nachweis? (c) Wenn WEBklar den Absatz beim Einbau in die Datenschutzerklärung einfügt (LB Nr. 6 Abs. 4), ist das Einfügen eines Mustertextes eine Rechtsdienstleistung (§ 2 RDG)? Das ist dieselbe Regel wie TP2 Q18 und soll für alle Funktionen gleich bleiben. (d) Die Angaben im Seitencode (strukturierte Daten zu den Öffnungszeiten) enthalten keine Daten von Besuchern; ist ein Satz dazu nötig?
**Fundstelle im Text.** `docs/funktionen/website-datenschutz-absatz.md` (Variante A und B, Bausteine Route und WhatsApp); LB Nr. 2 Abs. 4, Nr. 6 Abs. 3 und 4; Abschnitt 6e Absatz 4; Programm: `public/embed/zeiten.js` (keine Cookies, kein Storage, keine Anfragen an Dritte; Links erst beim Klick), Spec Z23 (Route öffnet Google Maps erst beim Klick).
**Unsere Annahme.** (a) Ein Link, den der Besucher anklickt, ist keine Übertragung durch WEBklar oder den Kunden vor dem Klick; eine Einwilligung ist nicht nötig. (b) Der Haken genügt als Nachweis. (c) Wie TP2 Q18: Technik und Mustertext, keine Rechtsdienstleistung im Einzelfall. (d) Ein Satz genügt, wie im Entwurf.
**Alternative.** Vor dem ersten Klick einen Hinweis einblenden („Sie verlassen diese Website“); die Schaltfläche „WhatsApp“ nur auf ausdrücklichen Wunsch einschalten.
### ZQ9: Vertragsrahmen ohne Abo (LB Nr. 1 Abs. 3)
**Frage.** Für Kunden ohne Wartungs-Abo gilt die LB „allein“. Sie regelt Leistung, Preis, Haftung, Ende, Einstellung und Widerruf, verweist für den Widerruf auf die Belehrung der Funktionen und für Datenschutz auf die Datenschutzerklärung. Fehlen Regeln, die sonst die Abo-Bedingungen liefern (Zahlung und Zahlungsverzug, Mängelrechte für digitale Dienstleistungen §§ 327 ff. BGB, Verjährung, Gerichtsstand, Textform, Änderungen der Bedingungen)? Sollen die Abo-Bedingungen auch für Kunden ohne Abo teilweise vereinbart werden (Verweis auf bestimmte Paragrafen), oder reichen die gesetzlichen Regeln?
**Fundstelle im Text.** LB Nr. 1 Abs. 3, Nr. 4 (Zahlung über Stripe), Nr. 7 Abs. 1 (Mängel in angemessener Frist), Nr. 8 (Haftung ausgeschrieben); Spec Abschnitt 9 Nr. 1 und Z19.
**Unsere Annahme.** Die gesetzlichen Regeln genügen für eine Einmalleistung von 30 €; die Haftung ist wegen der AGB-Kontrolle ausgeschrieben.
**Alternative.** Ausgewählte Paragrafen der Abo-Bedingungen (Zahlung, Mängel, Textform, Änderungen) in die LB aufnehmen oder auf sie verweisen.
### ZQ10: Hinweis zu Preisangaben in der Hinweis-Leiste
**Frage.** Der Editor zeigt beim Anlegen eines Hinweises, dessen Text „%“ oder „statt“ enthält, einen Hinweis zu Preisangaben (Endpreise, bei Waren der niedrigste Preis der letzten 30 Tage). LB Nr. 6 Abs. 2 verpflichtet den Kunden, Preise und Rabatte in Hinweisen rechtmäßig anzugeben, zum Beispiel als Endpreise und „bei Preisermäßigungen für Waren, mit dem niedrigsten Preis der letzten 30 Tage“; Nr. 12 Abs. 1 schließt eine Rechtsberatung aus. Sind diese Aussagen zutreffend und ausreichend, insbesondere die Beschränkung der 30-Tage-Regel auf Waren (Preisangabenverordnung) und die Abgrenzung, dass die Angaben des Kunden nicht von WEBklar geprüft werden (Nr. 8 Abs. 1)? Braucht es einen Hinweis zur Preisangabe auch für Leistungen, die Dienstleistungen sind?
**Fundstelle im Text.** LB Nr. 6 Abs. 2, Nr. 8 Abs. 1, Nr. 12 Abs. 1; Spec Abschnitt 3.4 Nr. 4 (Hinweis-Leiste); die genaue Zeile im Portal-Editor steht in `public/zeiten-leisten.js`.
**Unsere Annahme.** Der Hinweis im Editor und die Pflicht in der LB reichen als Warnung; die Prüfung bleibt beim Kunden. Die Zitate zur Preisangabenverordnung werden mit der Freigabe geprüft.
**Alternative.** Keinen Hinweis im Editor, nur die Pflicht in der LB; oder den Hinweis auf Preisermäßigungen insgesamt ausweiten.
### ZQ11: Aktivierung durch das Team ohne Zustimmung des Kunden
**Frage.** Das Team kann Öffnungszeiten live im WOMS für einen Kunden einschalten (Aktion „Aktivieren“, auch aus dem Zustand „abgeschaltet“). Das Programm speichert dabei keinen Zustimmungsnachweis (`einschaltenZeiten` mit `by: 'team'` ohne `nachweis`; Spec Abschnitt 5 und Ruling R32 lassen die Zustimmung für diesen Weg entfallen). Die LB setzt aber voraus, dass der Kunde den Haken setzt und auf „Einschalten“ oder „Zahlungspflichtig bestellen“ klickt (Nr. 1 Abs. 1). Beim Terminplaner verlangt TP2 für die Aktivierung durch das Team die Zustimmung des Kunden in Textform (F13a, `textformFuerAktivierung` in `server/routes/admin/funktionenAdmin.js` auf dem Zweig `feature/terminplaner-tp2`, Zeile 99: `if (key !== 'termine') return null`; die Prüfung gilt also nur für `termine`, und dort nur im Modus „on“, nicht für Pilotadressen). Soll diese Regel auf `zeiten` ausgeweitet werden (Kunde bestätigt in Textform, das Team vermerkt es), oder genügt es, dass die Funktion im Abo ohne Aufpreis ein freiwilliges Zusatzangebot ist und der Kunde die Aktivierung durch das Team ausdrücklich wünscht (Chat, Telefon)? Gilt das auch, wenn das Team ein Dokument wieder einschaltet, das der Kunde einmal gekauft hat?
**Fundstelle im Text.** LB Nr. 1 Abs. 1 und 2; Spec Abschnitt 5 (Team-Aktivierung), Ruling R32; Programm: `server/services/zeiten/einschalten.js` (`by: 'team'`), `server/routes/admin/funktionenAdmin.js` (`zeitenAktivieren`); TP2 F13a (Textform bei Aktivierung durch das Team, nur Terminplaner).
**Unsere Annahme.** Für die Gratis-Funktion genügt der ausdrückliche Wunsch des Kunden, den das Team im Feld „Notiz“ vermerkt; ein Zustimmungsnachweis fehlt dann bewusst. Bei einem Kunden ohne Wartungs-Abo wäre die Textform sauberer.
**Alternative.** Die Textform-Prüfung von TP2 auch für `zeiten` verlangen (Aktivieren nur mit Hinweis „Kunde hat in Textform zugestimmt“ und Notiz); die Aktivierung durch das Team nur für Kunden mit laufendem Wartungs-Abo zulassen.
### ZQ12: Pflichtinformationen als Haken und in K15
**Frage.** Beim Terminplaner gibt es neben `beginn` den Haken `information` („Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung …, Gesamtpreis …, voraussichtliche Dauer …“; `server/config.js`, `features.termine.consent`, TP2 Q20), und K15 (Bestätigung der Bestellung) trägt die Pflichtinformationen als Anhang. Für Öffnungszeiten live gibt es beides nicht: Das Register hat nur die Haken `leistung` und `beginn`, und K15 trägt für diese Funktion keine Vertragsunterlagen (der Block `anlagen` mit Fassung, Links und PDF-Anhang entfällt, weil das Register weder `avv` noch `widerruf` hat und `unterlagenAdressen` in `server/services/siteFeatures.js` dann `null` liefert; die Mail nennt nur Funktion, Preis und Dauer im Text). Genügt das für einen Verbrauchervertrag im elektronischen Geschäftsverkehr (§ 312d BGB, Art. 246a EGBGB, § 312j BGB: Information vor Vertragsschluss, Bestätigung auf einem dauerhaften Datenträger), weil die LB und die Bestellseite die Pflichtinformationen enthalten, oder braucht es wie beim Terminplaner einen eigenen Haken `information` und die Pflichtinformationen als Anhang oder Abschnitt der K15?
**Fundstelle im Text.** Register `features.zeiten.consent` (Haken `leistung`, `beginn`) gegen `features.termine.consent` (Haken `information`); Bestellseite `public/zeiten-bestellen.js` (Preis, Belehrung, Leistungsbeschreibung vor dem Knopf); K15 (`server/mail-templates/K15 Bestellung Funktion.html`, `bestellMail` und `unterlagenAdressen` in `server/services/siteFeatures.js`); LB Nr. 2 bis 4 und 11; TP2 Q20.
**Unsere Annahme.** Die Bestellseite zeigt Leistung, Preis, Umsatzsteuerhinweis, Widerrufsbelehrung und LB-Link vor dem Knopf; das genügt für die vorvertragliche Information. Ob die Bestätigungsmail (K15) die Pflichtinformationen und die Belehrung zusätzlich tragen muss (Textform auf einem dauerhaften Datenträger), ist offen; für den Terminplaner hat TP2 es mit dem Block `anlagen` gelöst.
**Alternative.** Wie beim Terminplaner: Haken `information` im Register (Fassung hochzählen) und die Pflichtinformationen als Abschnitt in K15.
## Nicht Teil der Kanzleiprüfung
- Der Hinweis in der Hinweis-Leiste („Aktion“ verlangt ein Enddatum, Texte höchstens 140 Zeichen) ist Oberfläche, keine Rechtsaussage.
- Technische Beobachtung beim Schreiben der Texte (kein Kanzlei-Thema, inzwischen behoben): Die öffentliche Widerrufsseite `widerrufen.html` kannte die Auswahl „Terminplaner (Einrichtung)“, aber keine „Öffnungszeiten live“; ein Verbraucher, der sie für 30 € bestellt hat, hätte „Funktion (z. B. News-Pinnwand)“ wählen müssen, und die Abwicklung hätte das der News-Pinnwand zugeordnet. Die Auswahl „Öffnungszeiten live“ gibt es jetzt (`ORDERS`, `FEATURE_OF_ORDER` und `orderLabel` in `server/routes/abo.js`, Radioknopf mit `data-text` in `public/widerrufen.html`).
@@ -0,0 +1,649 @@
# Admin-Panel ins Ticketsystem – Etappe 1 (Fundament + Kundenansicht) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Mitarbeiter öffnen aus dem WOMS mit einem Klick die Kundenansicht im Portal (nur News schreibbar, 2 h, Verwechslungsschutz, protokolliert); das Portal bekommt den JWT-Eingang `/api/team` für alle Mitarbeiter; Previews laufen über Einmal-Links.
**Architecture:** Portal-Server (Express, ESM, Node 20) bekommt `requireStaffJwt` + Router `/api/team`, einen zentralen Kundenansicht-Wächter vor allen Kunden-Routen und eine Protokoll-Collection `customerViewLog`. Das Dashboard markiert jede API-Anfrage mit der Ansichts-Kennung. WOMS (React 18, react-router 6) bekommt einen `teamApi`-Client, den Knopf „Kundenansicht öffnen“, Tab in der URL, Protokoll-Tab und Preview-Knöpfe per Einmal-Link. Das Admin-Panel bleibt unverändert in Betrieb.
**Tech Stack:** Portal: Express 4, cookie-parser (signierte Cookies), jose, node:test mit `test/support/*` (Appwrite-Nachbau, Stripe-Attrappe). WOMS: Vite 5, React 18, appwrite 13, node:test + esbuild-Loader `tests/unit/load.mjs`.
**Spec:** `docs/superpowers/specs/2026-10-01-admin-panel-ins-ticketsystem-design.md` (Abschnitte 1, 3, 4 und Etappe 1 in Abschnitt 5)
**Abweichung von der Spec (bewusst):** Die Route `GET /api/team/customers/:id/abo` wandert nach Etappe 3, wo ihr einziger Nutzer (Tab „Abo & Angebote“) entsteht. In Etappe 1 hätte sie keinen Aufrufer.
## Global Constraints
- Arbeitsorte: Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-admin-umzug` (Branch `feature/admin-umzug`, `node_modules` ist ein Symlink auf `../build/node_modules`). WOMS `/home/webklar/apps/ticket.webklar.com/dev-admin-umzug` (Branch `feature/admin-umzug` von `origin/test`, wird in Task 9 angelegt).
- **Nie `git add -A`** (Portal: `node_modules`-Symlink ist untracked; WOMS: `node_modules` ist getrackt). Immer einzelne Dateien adden.
- **Nichts pushen.** WOMS deployt jeden gepushten Branch. Livegang macht der Nutzer.
- `grep` in dieser Shell ist ein Wrapper, der Dateien still überspringt: `command grep -a` benutzen.
- UI-Texte deutsch mit Umlauten; Code-Kommentare im Portal wie im Bestand (ASCII, „ae/oe/ue“).
- Kundenansicht-Laufzeit: **2 Stunden** (`7200000` ms). Einmal-Link: **60 s**, einmal einlösbar. Protokoll-Aufbewahrung: **12 Monate** (365 Tage).
- Header-Name: `X-Kundenansicht`. Fehlercodes: `view_expired` (401), `view_replaced` (409), `view_blocked` (403), `no_portal_access` (409).
- Protokoll-Aktionen (genau diese Strings): `geoeffnet`, `beendet`, `abgelaufen`, `news_angelegt`, `news_bild`, `news_geaendert`, `news_geloescht`.
- Banner-Text: „Kundenansicht: *Kunde* · geöffnet von *Mitarbeiter*“ + Knopf „Beenden“.
- Rückweg nach „Beenden“: `${config.womsBaseUrl}/customers/<customerId>` (`WOMS_PUBLIC_URL`, Standard `https://ticket.webklar.com`).
- Portal-Tests: `npm test` im Portal-Worktree (Node 24 auf dem Host reicht für die Tests). WOMS-Tests: `npm test` im WOMS-Worktree.
## Review Focus
1. **Zwei Tabs, zwei Kunden:** Tab A (Kunde A) fragt weiter ab, nachdem im selben Browser Kunde B geöffnet wurde. Erwartung: Tab A bekommt 409 und zeigt nur den Ersetzt-Hinweis, nie Daten von B (Task 4 Server, Task 7 Client).
2. **Kunden-JWT am Team-Eingang:** Kunden haben Appwrite-Konten im selben Projekt und können sich ein JWT holen. Erwartung: 403, auch wenn die (unbestätigte) Konto-Mail einer Mitarbeiter-Mail gleicht (Task 1).
3. **Grenze der 2 Stunden:** Anfrage genau bei `viewExpiresAt`. Erwartung: abgelaufen (Task 4).
4. **Alte Kundenansicht aus admin.html** (Sitzung ohne `viewId`) während Etappe 1–3. Erwartung: Lesen geht, jede Schreibaktion 403, „Zurück zur Admin-Übersicht“ funktioniert weiter (Task 4, Task 7).
5. **Pop-up-Sperre:** Der neue Tab wird erst nach einer Netzwerkanfrage befüllt. Erwartung: `window.open` läuft synchron im Klick, Fehler schließen den leeren Tab wieder (Task 9).
---
## Portal
### Task 1: JWT-Eingang `/api/team` für alle Mitarbeiter + CORS-Header
**Files:**
- Create: `server/middleware/staffJwt.js`
- Create: `server/middleware/cors.js`
- Create: `server/routes/team/index.js`
- Modify: `server/index.js:55-69` (CORS-Block durch `corsMiddleware` ersetzen; `app.use('/api/team', teamRoutes)` nach `/api/portal-admin` einhängen)
- Modify: `server/routes/admin/switchLink.js` (lokales `appwriteAccountFromJwt` durch Import aus `staffJwt.js` ersetzen)
- Modify: `test/support/appwriteMock.js:167-178` (zwei neue Test-JWTs)
- Test: `test/teamAuth.test.js`
**Interfaces:**
- Produces: `appwriteAccountFromJwt(req) -> Promise<object|null>` (Appwrite `/account` mit `X-Appwrite-JWT`; null bei fehlendem Header oder nicht-ok).
- Produces: `resolveTeamStaff(account) -> Promise<{ isAdmin, name, email } | null>`.
- Produces: `requireStaffJwt(req, res, next)`; setzt `req.session = { role: 'admin', isAdmin, appwriteUserId, name, email }` (gleiche Form wie die Admin-Sitzung, damit bestehende Handler unverändert laufen) und `req.staff` = dasselbe Objekt.
- Produces: `corsMiddleware(allowedOrigins: string[])`.
- Produces: `server/routes/team/index.js` default export `Router` mit `router.use(requireStaffJwt)`; spätere Tasks hängen Unter-Router ein.
- [ ] **Step 1: Test-JWTs im Appwrite-Nachbau ergänzen**
In `/v1/account` zwei Fälle vor dem Session-Fall:
`'jwt-mail-ungeprueft'` → `[200, { $id: 'u_fremd', email: 'andrej@webklar.com', emailVerification: false, labels: [] }]`,
`'jwt-mail-geprueft'` → `[200, { $id: 'u_neu', email: 'nico@webklar.com', emailVerification: true, name: 'Nico', labels: [] }]`.
- [ ] **Step 2: Failing tests schreiben** (`test/teamAuth.test.js`, Aufbau wie `test/staffSwitch.test.js`; Probe-Router `GET /probe` hinter `requireStaffJwt`, der `req.session` als JSON zurückgibt)
```js
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
aw.seed('employees', { $id: 'e_nico', userId: '', displayName: 'Nico', email: 'nico@webklar.com' })
test('ohne, ungueltiges und halbes JWT -> 401', async () => {
for (const jwt of [null, 'jwt-falsch', 'jwt-halb']) assert.equal((await probe(jwt)).status, 401, String(jwt))
})
test('Kunde (kein Mitarbeiter, kein Label) -> 403', async () => {
assert.equal((await probe('jwt-kunde')).status, 403)
})
test('Mail gleicht Mitarbeiter, ist aber unbestaetigt -> 403', async () => {
assert.equal((await probe('jwt-mail-ungeprueft')).status, 403)
})
test('bestaetigte Mail eines Mitarbeiters ohne userId -> erlaubt', async () => {
const res = await probe('jwt-mail-geprueft')
assert.equal(res.status, 200)
assert.equal(res.body.name, 'Nico')
})
test('Mitarbeiter ohne Admin-Label -> erlaubt, Sitzung in Admin-Form', async () => {
const res = await probe('jwt-staff')
assert.deepEqual(res.body, { role: 'admin', isAdmin: false, appwriteUserId: 'u_staff2', name: 'Andrej', email: 'andrej@webklar.com' })
})
test('Label admin ohne employees-Eintrag -> erlaubt, isAdmin true', async () => {
const res = await probe('jwt-admin')
assert.equal(res.status, 200)
assert.equal(res.body.isAdmin, true)
})
test('CORS: erlaubte Herkunft bekommt die Upload-Header, fremde nichts', async () => {
// corsMiddleware(['https://ticket.webklar.com']) vor dem Probe-Router
const ok = await rawGet('/probe', { headers: { origin: 'https://ticket.webklar.com' } })
assert.equal(ok.headers.get('access-control-allow-headers'),
'Authorization, Content-Type, X-File-Name, X-Attachment-Kind, X-Duration-Ms, X-Image-Width, X-Image-Height')
const bad = await rawGet('/probe', { headers: { origin: 'https://evil.example' } })
assert.equal(bad.headers.get('access-control-allow-origin'), null)
})
```
- [ ] **Step 3: Tests laufen lassen, Fehlschlag prüfen**
Run: `node --test test/teamAuth.test.js`
Expected: FAIL (Modul `server/middleware/staffJwt.js` fehlt)
- [ ] **Step 4: Implementieren**
`resolveTeamStaff`: Mitarbeiter, wenn (a) `getEmployeeByUserId(account.$id)` trifft, oder (b) `account.labels` enthält `'admin'`, oder (c) `account.emailVerification === true` und `getEmployeeByEmail(account.email)` trifft. Name = `employee.displayName || account.name || 'Mitarbeiter'`, E-Mail = `account.email || employee.email`. Fehlender Header oder nicht-ok `/account` → 401 `{ error: 'Bitte im Ticketsystem neu anmelden.' }`; kein Mitarbeiter → 403 `{ error: 'Kein Mitarbeiterkonto.' }`; Ausnahme → 500. `corsMiddleware` übernimmt das bisherige Verhalten aus `index.js` (Methoden `GET, POST, PATCH, DELETE, OPTIONS`, OPTIONS → 204) mit der neuen Header-Liste aus dem Test. `switchLink.js` nutzt `appwriteAccountFromJwt` aus `staffJwt.js`; sein Verhalten bleibt (bestehende `test/staffSwitch.test.js` müssen grün bleiben).
- [ ] **Step 5: Tests laufen lassen**
Run: `node --test test/teamAuth.test.js test/staffSwitch.test.js`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add server/middleware/staffJwt.js server/middleware/cors.js server/routes/team/index.js server/index.js server/routes/admin/switchLink.js test/support/appwriteMock.js test/teamAuth.test.js
git commit -m "Team-Eingang: /api/team mit Appwrite-JWT fuer alle Mitarbeiter, CORS-Header fuer Uploads"
```
### Task 2: Protokoll-Collection `customerViewLog` und Dienst
**Files:**
- Modify: `scripts/setup-abo-collections.mjs` (neue `ensureCollection` vor `console.log('Fertig.')`; Kopfkommentar ergänzen)
- Modify: `server/config.js:141-161` (`customerViewLog: process.env.APPWRITE_COLLECTION_CUSTOMER_VIEW_LOG || 'customerViewLog'`)
- Create: `server/services/customerViewLog.js`
- Modify: `server/index.js` (täglicher Aufräumlauf neben den anderen Timern)
- Test: `test/customerViewLog.test.js`
**Interfaces:**
- Produces: `VIEW_ACTIONS` (Array der 7 Aktions-Strings aus den Global Constraints).
- Produces: `logViewAction({ customerId, staffUserId, staffName, action, detail = '' }) -> Promise<void>`; wirft nie (Fehler nur `console.warn('[kundenansicht] Protokoll ...')`); kürzt `detail` auf 300 Zeichen; setzt `createdAt` = jetzt (ISO).
- Produces: `listViewLog(customerId, { limit = 200 } = {}) -> Promise<Array<{ id, at, staffName, action, detail }>>`, neueste zuerst.
- Produces: `purgeViewLog({ now = new Date() } = {}) -> Promise<{ deleted: number }>`; löscht Einträge mit `createdAt` älter als 365 Tage, seitenweise (je 100) bis keine mehr übrig sind.
- [ ] **Step 1: Collection im Setup-Skript anlegen**
```js
await ensureCollection(
'customerViewLog',
'Kundenansicht-Protokoll',
[
{ type: 'string', key: 'customerId', size: 64 },
{ type: 'string', key: 'staffUserId', size: 64 },
{ type: 'string', key: 'staffName', size: 128 },
{ type: 'string', key: 'action', size: 32 },
{ type: 'string', key: 'detail', size: 300 },
{ type: 'datetime', key: 'createdAt' },
],
[
{ key: 'idx_customer_created', type: 'key', attributes: ['customerId', 'createdAt'] },
{ key: 'idx_created', type: 'key', attributes: ['createdAt'] },
]
)
```
- [ ] **Step 2: Failing tests schreiben** (Nachbau mit `schema: { customerViewLog: schemaKeys('customerViewLog') }`)
```js
test('logViewAction schreibt alle Felder und kuerzt detail auf 300', async () => {
await logViewAction({ customerId: 'c1', staffUserId: 'u_staff2', staffName: 'Andrej', action: 'news_angelegt', detail: 'x'.repeat(400) })
const [doc] = [...aw.docs('customerViewLog').values()]
assert.equal(doc.detail.length, 300)
assert.equal(doc.action, 'news_angelegt')
assert.ok(Date.parse(doc.createdAt))
})
test('logViewAction wirft nicht, wenn Appwrite scheitert', async () => { /* Nachbau-Schema ohne Feld detail -> 400; Aufruf resolved */ })
test('listViewLog: nur dieser Kunde, neueste zuerst, Form {id, at, staffName, action, detail}', async () => { /* 3 Eintraege c1, 1 Eintrag c2 */ })
test('purgeViewLog loescht nur Eintraege aelter als 365 Tage', async () => {
// seed: createdAt now-366d, now-364d, now-1d -> deleted 1, zwei bleiben
})
test('purgeViewLog arbeitet seitenweise (250 alte Eintraege -> deleted 250)', async () => {})
```
- [ ] **Step 3: Fehlschlag prüfen** — Run: `node --test test/customerViewLog.test.js` → FAIL (Modul fehlt)
- [ ] **Step 4: Implementieren** (`createDocument`, `listDocuments`, `deleteDocument`, `Query` aus `services/appwriteAdmin.js`; Liste mit `Query.equal('customerId')`, `Query.orderDesc('createdAt')`, `Query.limit(limit)`; Aufräumen mit `Query.lessThan('createdAt', grenze)`, `Query.limit(100)` in einer Schleife). Timer in `index.js` wie `purge` der Gutschriften: erster Lauf nach 150 s, dann alle 24 h, Log `[kundenansicht] n Protokolleinträge gelöscht`.
- [ ] **Step 5: Tests** — Run: `node --test test/customerViewLog.test.js` → PASS
- [ ] **Step 6: Commit**
```bash
git add scripts/setup-abo-collections.mjs server/config.js server/services/customerViewLog.js server/index.js test/customerViewLog.test.js
git commit -m "Kundenansicht: Protokoll-Collection customerViewLog mit Schreiben, Lesen und Loeschen nach 12 Monaten"
```
### Task 3: Kundenansicht öffnen (Einmal-Link → Sitzung 2 h)
**Files:**
- Create: `server/services/portalAccessStatus.js`
- Create: `server/services/kundenansicht.js`
- Create: `server/routes/team/customers.js` (in `routes/team/index.js` unter `/customers` einhängen)
- Modify: `server/middleware/session.js:14-22` (`setPortalSession(res, data, { maxAgeMs = SESSION_MAX_AGE_MS } = {})`)
- Modify: `server/services/previewSession.js` (`createPreviewSessionToken(payload, { maxAgeSec } = {})`, `previewSessionCookieOptions({ maxAgeSec } = {})`; ohne Option unverändert `config.preview.sessionMaxAge`)
- Modify: `server/services/staffPreviewTokens.js:24` (Kommentar: Zweck `kundenansicht`)
- Modify: `server/routes/auth.js` (neue Route `GET /kundenansicht`)
- Modify: `test/support/fixtures.js` (neuer Helfer `kundenansichtCookie`)
- Test: `test/kundenansicht.test.js`
**Interfaces:**
- Consumes: `requireStaffJwt` / `req.session` (Task 1), `logViewAction` (Task 2).
- Produces: `getPortalAccessStatus(customerId) -> Promise<{ customer: object|null, hasAccess: boolean, lastLoginAt: string|null }>`; `hasAccess` = Kunde existiert && `customer.portalAccessEnabled` && `customerPortalAccess`-Dokument mit `enabled`.
- Produces in `kundenansicht.js`: `KUNDENANSICHT_TTL_MS = 7200000`; `startKundenansicht(res, { staff, customer, now = Date.now() }) -> Promise<session>` (setzt Portal-Cookie mit `maxAgeMs` = TTL und Preview-Cookie für genau diesen Kunden: Payload `{ userId: staff.appwriteUserId, customerId, email: customer.email, name: customer.name, role: 'customer', labels: [], authSource: 'kundenansicht' }`, `maxAgeSec` = 7200); `isKundenansicht(session) -> boolean` (`viewAs && viewId`); `isExpired(session, now = Date.now()) -> boolean` (`now >= viewExpiresAt`); `endKundenansicht(res)` (beide Cookies löschen); `logOnce(session, action) -> Promise<void>` (protokolliert `abgelaufen`/`beendet` je `viewId` höchstens einmal, merkt sich IDs 3 h im Speicher).
- Produces: Sitzungsform `{ role: 'admin', viewAs: true, viewId: <32 hex>, viewExpiresAt: <ms>, isAdmin, appwriteUserId, adminName, adminEmail, customerId, name, email }`.
- Produces: Test-Helfer `kundenansichtCookie({ customerId = 'c1', viewId = 'a'.repeat(32), viewExpiresAt = Date.now() + 3600000 } = {})` (Mitarbeiter `u_staff2`/Andrej).
- [ ] **Step 1: Failing tests schreiben** (Router `/api/team` + `/api/auth`; `aw.seed('customerPortalAccess', { $id: 'pa1', customerId: 'c1', enabled: true, lastLoginAt: '2026-09-30T10:00:00.000Z' })`; c2 ohne Zugangs-Dokument)
```js
test('Link fuer Mitarbeiter: Einmal-Link auf /api/auth/kundenansicht', async () => {
const res = await post('/api/team/customers/c1/kundenansicht', {}, bearer('jwt-staff'))
assert.equal(res.status, 200)
assert.match(res.body.openUrl, /^https:\/\/project\.webklar\.com\/api\/auth\/kundenansicht\?token=[0-9a-f]{64}$/)
})
test('unbekannter Kunde 404, Kunde ohne Portal-Zugang 409 no_portal_access', async () => {
assert.equal((await post('/api/team/customers/gibtsnicht/kundenansicht', {}, bearer('jwt-staff'))).status, 404)
const res = await post('/api/team/customers/c2/kundenansicht', {}, bearer('jwt-staff'))
assert.equal(res.status, 409)
assert.equal(res.body.code, 'no_portal_access')
})
test('Einloesen: Sitzung 2 h, Preview-Cookie nur fuer den Kunden, Protokoll geoeffnet, Weiter zum Dashboard', async () => {
const res = await rawGet(`/api/auth/kundenansicht?token=${await tokenFor('c1')}`)
assert.equal(res.status, 302)
assert.equal(res.headers.get('location'), '/dashboard.html')
const s = portalSession(res)
assert.equal(s.viewAs, true)
assert.match(s.viewId, /^[0-9a-f]{32}$/)
assert.ok(Math.abs(s.viewExpiresAt - (Date.now() + 7200000)) < 5000)
assert.deepEqual([s.customerId, s.adminName, s.appwriteUserId], ['c1', 'Andrej', 'u_staff2'])
assert.match(cookieHeader(res, config.cookieName), /Max-Age=7200/)
const preview = await verifyPreviewSessionToken(cookieValue(res, 'preview_session'))
assert.deepEqual([preview.role, preview.customerId], ['customer', 'c1'])
assert.ok(preview.exp - preview.iat <= 7200)
const log = [...aw.docs('customerViewLog').values()].at(-1)
assert.deepEqual([log.customerId, log.action, log.staffName], ['c1', 'geoeffnet', 'Andrej'])
})
test('Token zweimal oder nach 61 s -> 410 mit Hinweis, Preview-Token gilt hier nicht', async () => {
// zweites Einloesen 410, Text enthaelt 'im Ticketsystem erneut öffnen'
// createStaffPreviewToken({...}) ohne purpose -> 410
})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/kundenansicht.test.js` → FAIL
- [ ] **Step 3: Implementieren**
`POST /api/team/customers/:id/kundenansicht`: `getPortalAccessStatus` → 404 `{ error: 'Kunde nicht gefunden' }` / 409 `{ code: 'no_portal_access', error: 'Kein Portal-Zugang' }`; sonst `createStaffPreviewToken({ purpose: 'kundenansicht', customerId, staff: req.staff })` und `openUrl` mit `config.publicBaseUrl`. `GET /api/auth/kundenansicht`: `consumeStaffPreviewToken(token, 'kundenansicht')`; null → 410 `text/plain; charset=utf-8` „Der Link ist abgelaufen. Bitte die Kundenansicht im Ticketsystem erneut öffnen.“; Kunde inzwischen ohne Zugang → ebenfalls 410 mit „Kein Portal-Zugang“. Sonst `startKundenansicht`, `logViewAction({ action: 'geoeffnet' })`, Redirect `/dashboard.html`. `viewId` aus `randomBytes(16).toString('hex')`.
- [ ] **Step 4: Tests** — Run: `node --test test/kundenansicht.test.js test/staffSwitch.test.js test/previewAdmin.test.js` → PASS
- [ ] **Step 5: Commit**
```bash
git add server/services/portalAccessStatus.js server/services/kundenansicht.js server/routes/team/customers.js server/routes/team/index.js server/middleware/session.js server/services/previewSession.js server/services/staffPreviewTokens.js server/routes/auth.js test/support/fixtures.js test/kundenansicht.test.js
git commit -m "Kundenansicht: Einmal-Link aus dem Ticketsystem, Sitzung und Preview-Zugang fuer 2 Stunden, Protokoll"
```
### Task 4: Kundenansicht-Wächter (Ablauf, Verwechslung, Schreibsperre)
**Files:**
- Create: `server/middleware/kundenansichtGuard.js`
- Modify: `server/index.js` (`app.use(kundenansichtGuard)` direkt nach `sessionMiddleware()`, also vor dem 15-MB-Parser der Gutschriften)
- Modify: `server/routes/news.js` (`rejectViewAs` und seine vier Verwendungen entfernen; der Wächter übernimmt)
- Test: `test/kundenansichtGuard.test.js`
**Interfaces:**
- Consumes: `isKundenansicht`, `isExpired`, `endKundenansicht`, `logOnce` (Task 3); Fixtures `kundenansichtCookie`, `viewAsCookie`.
- Produces: `kundenansichtGuard(req, res, next)`; exportiert zusätzlich `KUNDENANSICHT_ALLOWED` (Array `[method, RegExp]`) für Tests.
Regeln in dieser Reihenfolge (nur wenn `parsePortalSession(req)?.viewAs`; Pfad = `req.originalUrl` ohne Query; durchgelassen werden Pfade unter `/api/team`, `/api/admin`, `/api/portal-admin`, alles außerhalb `/api/` sowie `GET /api/auth/kundenansicht` und `GET /api/auth/me` – beide behandeln Ablauf und Sitzungswechsel selbst; sonst könnte ein abgelaufenes altes Cookie das Öffnen einer neuen Ansicht blockieren):
1. Neue Ansicht (`viewId`) abgelaufen → Cookies löschen, `logOnce(session, 'abgelaufen')`, 401 `{ code: 'view_expired', error: 'Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.' }`.
2. Neue Ansicht, Header `X-Kundenansicht` vorhanden und ≠ `viewId` → 409 `{ code: 'view_replaced', kunde: session.name, error: 'Diese Ansicht wurde durch eine andere Kundenansicht ersetzt.' }`.
3. Methode `GET`/`HEAD`/`OPTIONS` → weiter.
4. Neue Ansicht und Header fehlt → 409 wie in 2.
5. Pfad steht in `KUNDENANSICHT_ALLOWED` **und** es ist eine neue Ansicht → weiter. Erlaubt: `POST /api/news`, `POST /api/news/image`, `PATCH /api/news/:id`, `DELETE /api/news/:id`, `POST /api/funktionen/analyse/heatmap-link`, `POST /api/auth/kundenansicht/ende`, `POST /api/auth/logout`, `POST /api/auth/login`. Für alte Ansichten (ohne `viewId`) nur `logout` und `login`.
6. Sonst 403 `{ code: 'view_blocked', error: 'In der Kundenansicht nicht möglich.' }`.
- [ ] **Step 1: Failing tests schreiben** (App wie `index.js`: `kundenansichtGuard` + alle Kunden-Router mit denselben Mount-Pfaden: `/api/auth`, `/api/projects`, `/api/features`, `/api/news`, `/api` (customerData), `/api/chat`, `/api/abo/takeover-credits`, `/api/abo`, `/api/funktionen`)
```js
const VIEW = 'a'.repeat(32)
const kv = (extra = {}) => ({ cookie: kundenansichtCookie(extra), headers: { 'x-kundenansicht': extra.viewId || VIEW } })
test('jede Schreib-Route ausserhalb der Erlaubnisliste -> 403 view_blocked', async () => {
const routes = writeRoutes([['/api/auth', authRoutes], ['/api/news', newsRoutes], /* ... alle oben */])
assert.ok(routes.length >= 25, `nur ${routes.length} Schreib-Routen gefunden`)
for (const { method, path } of routes) {
if (KUNDENANSICHT_ALLOWED.some(([m, re]) => m === method && re.test(path))) continue
const res = await call(method, path, kv())
assert.equal(res.status, 403, `${method} ${path}`)
assert.equal(res.body.code, 'view_blocked', `${method} ${path}`)
}
})
// writeRoutes liest router.stack (layer.route.path, layer.route.methods), ersetzt :param durch 'x'
test('POST /api/chat/read ist gesperrt (Lesestatus bleibt unberuehrt)', async () => {
assert.equal((await call('POST', '/api/chat/read', kv())).status, 403)
})
test('News anlegen in der Kundenansicht erlaubt', async () => {
const res = await call('POST', '/api/news', { ...kv(), body: note })
assert.equal(res.status, 201)
})
test('fremde Kennung -> 409 view_replaced mit Kundennamen, auch beim Lesen', async () => {
const res = await call('GET', '/api/projects', { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'b'.repeat(32) } })
assert.equal(res.status, 409)
assert.deepEqual([res.body.code, res.body.kunde], ['view_replaced', 'Kunde Eins'])
})
test('Schreiben ohne Kennung -> 409, Lesen ohne Kennung -> erlaubt', async () => {})
test('genau bei viewExpiresAt abgelaufen: 401, Cookies geloescht, Protokoll abgelaufen genau einmal', async () => {
const at = Date.now()
// t.mock.method(Date, 'now', () => at); Cookie mit viewExpiresAt: at; zwei Anfragen
// -> beide 401 view_expired, Set-Cookie leert webklar_portal_session und preview_session, 1 Protokolleintrag
})
test('alte Ansicht aus admin.html (viewAsCookie, ohne viewId): Lesen ok, News anlegen 403, Logout ok', async () => {})
test('normale Kunden-Sitzung wird nicht beruehrt (News anlegen 201 ohne Header)', async () => {})
test('abgelaufenes Cookie blockiert weder /api/auth/me noch das Einloesen eines neuen Links', async () => {
// Cookie mit viewExpiresAt in der Vergangenheit: GET /api/auth/me -> 200 (der Waechter greift nicht ein;
// den Inhalt "reason view_expired" prueft Task 6); GET /api/auth/kundenansicht?token=<neuer Token> -> 302 /dashboard.html
})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/kundenansichtGuard.test.js` → FAIL
- [ ] **Step 3: Implementieren** wie in den Regeln oben.
- [ ] **Step 4: Gesamte Suite** — Run: `npm test` → PASS (insbesondere `newsRoutes.test.js`: der dortige View-as-Test erwartet weiterhin 403, jetzt vom Wächter; ist der Wächter im Test-App-Aufbau von `newsRoutes.test.js` nicht eingehängt, den Wächter dort ergänzen, nicht die Erwartung ändern).
- [ ] **Step 5: Commit**
```bash
git add server/middleware/kundenansichtGuard.js server/index.js server/routes/news.js test/kundenansichtGuard.test.js test/newsRoutes.test.js
git commit -m "Kundenansicht: zentraler Waechter (2 h, Verwechslungsschutz, Schreibsperre ausser News)"
```
### Task 5: News-Änderungen in der Kundenansicht protokollieren
**Files:**
- Modify: `server/routes/news.js` (nach erfolgreichem `createPost`, `storeNewsImage`, `updatePost`, `deletePost`)
- Modify: `server/services/kundenansicht.js` (`logIfKundenansicht(req, action, detail = '') -> Promise<void>`)
- Test: `test/kundenansichtNews.test.js`
**Interfaces:**
- Consumes: `logViewAction` (Task 2), `isKundenansicht` (Task 3).
- Produces: `logIfKundenansicht(req, action, detail)`; tut nichts außerhalb einer neuen Kundenansicht; Mitarbeiter aus `req.session.appwriteUserId`/`adminName`, Kunde aus `req.session.customerId`.
Detail: anlegen/ändern → `post.title || erste 60 Zeichen von post.text`; löschen → Titel/Text des Beitrags vor dem Löschen (`listPosts` liefert ihn nicht einzeln: vorher per `getDocument(config.collections.newsPosts, id)` lesen); Foto → leer.
- [ ] **Step 1: Failing tests** (Aufbau aus `newsRoutes.test.js` übernehmen, Wächter einhängen)
```js
test('anlegen, aendern, Foto, loeschen in der Kundenansicht -> vier Protokolleintraege', async () => {
// POST note (title 'Hallo') -> news_angelegt 'Hallo'; PATCH title 'Neu' -> news_geaendert 'Neu';
// POST /image -> news_bild ''; DELETE -> news_geloescht 'Neu'; staffName jeweils 'Andrej', customerId 'c1'
})
test('Kunde selbst schreibt -> kein Protokolleintrag', async () => {})
test('Protokoll scheitert -> News-Aktion trotzdem 201', async () => {})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/kundenansichtNews.test.js` → FAIL
- [ ] **Step 3: Implementieren**
- [ ] **Step 4: Tests** — Run: `node --test test/kundenansichtNews.test.js test/newsRoutes.test.js` → PASS
- [ ] **Step 5: Commit**
```bash
git add server/routes/news.js server/services/kundenansicht.js test/kundenansichtNews.test.js
git commit -m "Kundenansicht: News-Aenderungen mit Mitarbeiter und Titel protokollieren"
```
### Task 6: Ende, `/me` und Team-Leserouten (Portal-Status, Protokoll, Preview-Link)
**Files:**
- Modify: `server/routes/auth.js` (`POST /kundenansicht/ende`; `/me`-Zweig für neue Ansichten)
- Modify: `server/routes/team/customers.js` (`GET /:id/portal`, `GET /:id/protokoll`)
- Create: `server/routes/team/preview.js` (in `routes/team/index.js` unter `/preview-link`)
- Modify: `server/routes/admin/previewOpen.js` (Kern als `buildStaffPreviewLink` exportieren; alte Route behält ihre Admin-Prüfung)
- Test: `test/kundenansichtEnde.test.js`, `test/teamRoutes.test.js`
**Interfaces:**
- Consumes: Tasks 1–3.
- Produces: `buildStaffPreviewLink({ userId, email, name, subdomain, previewUrl }) -> Promise<{ status: number, body: object }>` (Logik unverändert aus `previewOpen.js`: Projekt per Subdomain, `isPreviewProjectReady`, Token, `openUrl` auf `/api/auth/staff-preview`).
- Produces (HTTP, von WOMS Task 9–11 genutzt):
- `GET /api/team/customers/:id/portal` → `{ hasAccess: boolean, lastLoginAt: string|null }` (404 unbekannter Kunde)
- `GET /api/team/customers/:id/protokoll` → `{ entries: [{ id, at, staffName, action, detail }] }`
- `POST /api/team/customers/:id/kundenansicht` → `{ openUrl }` (Task 3)
- `POST /api/team/preview-link` mit `{ subdomain, previewUrl }` → `{ openUrl }` / 404 / 409
- `POST /api/auth/kundenansicht/ende` → `{ redirect }`
- `GET /api/auth/me` in neuer Ansicht → zusätzlich `viewId`, `viewExpiresAt`; abgelaufen → `{ authenticated: false, reason: 'view_expired', message: 'Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.' }`
- [ ] **Step 1: Failing tests**
```js
// kundenansichtEnde.test.js
test('Beenden: Protokoll beendet, beide Cookies geloescht, Rueckweg zur Kundenseite im WOMS', async () => {
const res = await post('/api/auth/kundenansicht/ende', {}, kv())
assert.equal(res.body.redirect, 'https://ticket.webklar.com/customers/c1')
// Set-Cookie leert webklar_portal_session und preview_session; letzter Protokolleintrag action 'beendet'
})
test('Beenden ohne Kundenansicht -> Rueckweg /customers', async () => {})
test('/me in neuer Ansicht: viewId, viewExpiresAt, admin.name, Preview-Cookie nur fuer den Kunden', async () => {
// preview_session neu ausgestellt (ohne vorhandenes Cookie): role 'customer', customerId 'c1', exp <= viewExpiresAt/1000
})
test('/me abgelaufen -> authenticated false, reason view_expired, Protokoll abgelaufen einmal', async () => {})
test('/me alte Ansicht (viewAsCookie) unveraendert: viewAs true, keine viewId', async () => {})
// teamRoutes.test.js
test('portal: hasAccess und lastLoginAt; c2 ohne Zugang hasAccess false; unbekannt 404', async () => {})
test('protokoll: Eintraege des Kunden neueste zuerst', async () => {})
test('preview-link fuer Mitarbeiter ohne Admin-Label -> openUrl auf staff-preview; nicht bereit -> 409', async () => {})
test('alle Team-Routen ohne JWT -> 401', async () => {})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/kundenansichtEnde.test.js test/teamRoutes.test.js` → FAIL
- [ ] **Step 3: Implementieren** (`/me`: Zweig für `isKundenansicht(session)` vor dem bisherigen Admin-Zweig; Preview-Refresh mit Kunden-Payload und `maxAgeSec = ceil((viewExpiresAt - now)/1000)`)
- [ ] **Step 4: Tests** — Run: `npm test` → PASS
- [ ] **Step 5: Commit**
```bash
git add server/routes/auth.js server/routes/team/customers.js server/routes/team/preview.js server/routes/team/index.js server/routes/admin/previewOpen.js test/kundenansichtEnde.test.js test/teamRoutes.test.js
git commit -m "Kundenansicht: Beenden mit Rueckweg ins Ticketsystem, /me mit Ansichts-Kennung, Team-Routen Portal-Status, Protokoll, Preview-Link"
```
### Task 7: Dashboard: Kennung mitsenden, Banner, Ersetzt-/Abgelaufen-Hinweis
**Files:**
- Create: `public/kundenansicht.js` (klassisches Skript, kein Modul)
- Modify: `public/dashboard.html:288` (`<script src="/kundenansicht.js"></script>` vor `chat-composer.js`)
- Modify: `public/app.js:632-645` (`renderViewAsBanner`), `public/app.js:2069-2092` (Init), Aufrufe von `/api/chat/read` (Zeilen ~775, ~812, ~2007: in der Kundenansicht nicht aufrufen)
- Modify: `public/kundenbereich.css` (Overlay `.kb-view-overlay`)
- Test: `test/kundenansichtClient.test.js` (lädt `public/kundenansicht.js` per `node:vm` mit Fake-`window`)
**Interfaces:**
- Consumes: `/api/auth/me` (`viewId`), `POST /api/auth/kundenansicht/ende` (Task 6), Fehlercodes aus Task 4.
- Produces: `window.KbView = { setId(id), id(), showReplaced(kunde), showExpired() }`. Das Skript ersetzt `window.fetch` und `XMLHttpRequest.prototype.open/send` so, dass bei gesetzter ID jede Anfrage an dieselbe Herkunft mit Pfad `/api/…` den Header `X-Kundenansicht` bekommt. Antworten 409 `view_replaced` / 401 `view_expired` (Body per `response.clone().json()`) blenden ein Vollbild-Overlay ein und lassen die Antwort sonst unverändert durch.
Texte (fest):
- Banner: `Kundenansicht: <strong>{Kunde}</strong> · geöffnet von {Mitarbeiter}` + Knopf `Beenden`.
- Ersetzt: „Diese Ansicht wurde durch die Kundenansicht von {kunde} ersetzt.“ + Knopf „Tab schließen“ (`window.close()`).
- Abgelaufen: „Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.“
Verhalten in `app.js`: Nach `/me` bei `me.viewId` → `KbView.setId(me.viewId)` und neues Banner; „Beenden“ → `POST /api/auth/kundenansicht/ende`, dann `location.href = redirect`. Ohne `viewId` bei `me.viewAs` → altes Banner unverändert (Rückweg `/admin.html`). `me.reason === 'view_expired'` → Weiterleitung auf Login mit `error=me.message` wie bei `password_changed`.
- [ ] **Step 1: Failing tests** (`vm.runInNewContext(src, ctx)` mit `ctx.window = ctx`, Fake-`fetch`, das Aufrufe mitschreibt, `location.origin = 'https://project.webklar.com'`, minimalem `document` für das Overlay)
```js
test('ohne ID kein Header', async () => {})
test('mit ID: Header bei /api/... gleicher Herkunft, nicht bei fremder Herkunft oder /img', async () => {
ctx.KbView.setId('a'.repeat(32))
await ctx.fetch('/api/projects')
await ctx.fetch('https://example.org/api/x')
await ctx.fetch('/img/logo.png')
assert.equal(header(calls[0]), 'a'.repeat(32))
assert.equal(header(calls[1]), undefined)
assert.equal(header(calls[2]), undefined)
})
test('409 view_replaced zeigt Overlay mit Kundennamen, Antwort bleibt lesbar', async () => {})
test('401 view_expired zeigt Abgelaufen-Overlay', async () => {})
test('XHR bekommt den Header ebenfalls', async () => {})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/kundenansichtClient.test.js` → FAIL
- [ ] **Step 3: Implementieren** (`kundenansicht.js`, Banner/Init in `app.js`, CSS: Overlay fixiert über allem, Farben aus den vorhandenen Variablen von `kundenbereich.css`)
- [ ] **Step 4: Tests** — Run: `node --test test/kundenansichtClient.test.js` → PASS
- [ ] **Step 5: Sichtprüfung mit Playwright** nach dem Muster `test/ui/funktionen.ui.mjs` (statischer Server für `public/`, `page.route` für `/api/*`): neues Skript `test/ui/kundenansicht.ui.mjs` mit drei Szenarien (Banner + Beenden-Aufruf; 409 beim Nachladen → Overlay; `me.viewAs` ohne `viewId` → altes Banner). Screenshots nach `/root/reports/admin-umzug-ui/`. Run: `node test/ui/kundenansicht.ui.mjs` → alle Checks „ok“.
- [ ] **Step 6: Commit**
```bash
git add public/kundenansicht.js public/dashboard.html public/app.js public/kundenbereich.css test/kundenansichtClient.test.js test/ui/kundenansicht.ui.mjs
git commit -m "Kundenansicht im Dashboard: Kennung bei jeder Anfrage, Banner mit Beenden, Hinweis bei ersetzter oder abgelaufener Ansicht"
```
### Task 8: Datenschutzerklärung Abschnitt 6
**Files:**
- Modify: `public/datenschutz.html:82` (Satz ersetzen), `:47` (`Stand 01.10.2026`)
- Modify: `docs/WEBklar-Datenschutzerklaerung.pdf` (neu erzeugen)
- Test: `test/legalTexts.test.js` (neuer Test)
- [ ] **Step 1: Failing test**
```js
test('legal_datenschutz_kundenansicht: Abschnitt 6 nennt Kundenansicht, Sperren, Protokoll und 12 Monate', () => {
const html = read('public/datenschutz.html')
assert.ok(!html.includes('Eine Nachricht in Ihrem Namen senden können sie dabei nicht.'))
assert.ok(html.includes('Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so öffnen, wie Sie es sehen, und dabei für Sie News-Beiträge anlegen, ändern oder löschen.'))
assert.ok(html.includes('Bestellungen, Zahlungen, Änderungen an Ihrem Passwort sowie Nachrichten oder Anträge in Ihrem Namen sind dabei ausgeschlossen.'))
assert.ok(html.includes('Jedes Öffnen und jede Änderung halten wir mit Mitarbeiter und Zeitpunkt fest und löschen diese Einträge nach 12 Monaten.'))
assert.ok(html.includes('Rechtsgrundlage dafür ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist die Nachvollziehbarkeit von Zugriffen auf Ihr Konto.'))
assert.ok(html.includes('Stand 01.10.2026'))
})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/legalTexts.test.js` → FAIL
- [ ] **Step 3: Text einsetzen** (Wortlaut exakt aus der Spec, Abschnitt 3 „Datenschutzerklärung“), PDF erzeugen: `scripts/build-legal-pdfs.sh all` und danach `git status --short docs/` prüfen: nur `docs/WEBklar-Datenschutzerklaerung.pdf` darf sich geändert haben; andere geänderte PDFs mit `git checkout -- <datei>` zurücksetzen.
- [ ] **Step 4: Tests** — Run: `node --test test/legalTexts.test.js` → PASS
- [ ] **Step 5: Commit**
```bash
git add public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf test/legalTexts.test.js
git commit -m "Datenschutz Abschnitt 6: Kundenansicht mit News, Sperren und Protokoll (12 Monate)"
```
---
## WOMS
### Task 9: WOMS-Worktree, `teamApi` und `openInNewTab`
**Files:**
- Create (Worktree): `git -C /home/webklar/apps/ticket.webklar.com/build fetch origin && git -C /home/webklar/apps/ticket.webklar.com/build worktree add ../dev-admin-umzug -b feature/admin-umzug origin/test`, danach `rm -rf node_modules && ln -s ../build/node_modules node_modules` im neuen Worktree
- Create: `src/lib/teamApi.js`
- Create: `src/lib/openInNewTab.js`
- Test: `tests/unit/openInNewTab.test.mjs`
**Interfaces:**
- Consumes: `createFinanceRequest` aus `src/lib/financeRequest.js` (generischer Anfrage-Kern: JWT-Cache, Retry bei 401, deutsche Fehlertexte).
- Produces in `teamApi.js` (Basis `${VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'}/api/team`):
- `getCustomerPortalStatus(customerId) -> Promise<{ hasAccess, lastLoginAt }>`
- `getCustomerViewLog(customerId) -> Promise<{ entries }>`
- `createKundenansichtLink(customerId) -> Promise<string>` (gibt `openUrl` zurück)
- `createPreviewLink({ subdomain, previewUrl }) -> Promise<string>`
- Produces: `openInNewTab(getUrl: () => Promise<string>, { win = globalThis.window } = {}) -> Promise<void>`: öffnet **synchron** `win.open('', '_blank')`, setzt `tab.opener = null`, wartet auf `getUrl()`, setzt `tab.location.href`; bei Fehler `tab.close()` und Fehler weiterwerfen; liefert `win.open` null (Pop-up blockiert), wirft es `new Error('Neuer Tab wurde vom Browser blockiert.')`, ohne `getUrl` aufzurufen.
- [ ] **Step 1: Failing tests**
```js
test('open laeuft vor getUrl, danach wird die Adresse gesetzt', async () => {
const order = []
const tab = { location: {}, close: () => order.push('close') }
const win = { open: () => { order.push('open'); return tab } }
await openInNewTab(async () => { order.push('getUrl'); return 'https://x/y' }, { win })
assert.deepEqual(order, ['open', 'getUrl'])
assert.equal(tab.location.href, 'https://x/y')
assert.equal(tab.opener, null)
})
test('Fehler beim Holen -> Tab wird geschlossen, Fehler weitergereicht', async () => {})
test('Pop-up blockiert -> Fehlermeldung, getUrl nicht aufgerufen', async () => {})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test tests/unit/openInNewTab.test.mjs` → FAIL
- [ ] **Step 3: Implementieren**
- [ ] **Step 4: Tests** — Run: `npm test` → PASS
- [ ] **Step 5: Commit**
```bash
git add src/lib/teamApi.js src/lib/openInNewTab.js tests/unit/openInNewTab.test.mjs
git commit -m "Team-API des Kundenportals und Oeffnen im neuen Tab ohne Pop-up-Sperre"
```
### Task 10: Kundenseite: Tab in der URL, „Übersicht“, Protokoll-Tab
**Files:**
- Modify: `src/lib/customerTabs.js` (Label „Übersicht“, Tab `{ id: 'protokoll', label: 'Protokoll' }` nach `projects`)
- Create: `src/lib/viewLog.js`
- Create: `src/components/customer/CustomerViewLog.jsx`
- Modify: `src/pages/CustomerDetailPage.jsx:15-96` (Tab aus `useSearchParams` statt `useState`; Wechsel per `setSearchParams({ tab }, { replace: true })`; Tab `protokoll` rendert `CustomerViewLog`)
- Test: `tests/unit/customerTabs.test.mjs` (anpassen), `tests/unit/viewLog.test.mjs`
**Interfaces:**
- Consumes: `getCustomerViewLog` (Task 9).
- Produces: `viewLogLabel(action: string) -> string`; Zuordnung: `geoeffnet` → „Kundenansicht geöffnet“, `beendet` → „Kundenansicht beendet“, `abgelaufen` → „Kundenansicht abgelaufen“, `news_angelegt` → „News angelegt“, `news_bild` → „News-Foto hochgeladen“, `news_geaendert` → „News geändert“, `news_geloescht` → „News gelöscht“; unbekannt → der Rohwert.
- Produces: `CustomerViewLog({ customerId })`: Tabelle Zeit (`dd.mm.yyyy hh:mm`, Europe/Berlin) · Mitarbeiter · Aktion · Detail; leer → `EmptyState` „Noch keine Einträge“; Fehler → Fehlerzeile mit Knopf „Erneut versuchen“.
- [ ] **Step 1: Tests anpassen/schreiben**
```js
test('customerTabs: Reihenfolge mit Protokoll, Uebersicht mit Umlaut', () => {
assert.deepEqual(customerTabs(true).map((t) => t.label), ['Übersicht', 'Tickets', 'Rechnungen', 'Projekte', 'Protokoll'])
assert.deepEqual(customerTabs(false).map((t) => t.id), ['overview', 'tickets', 'projects', 'protokoll'])
})
test('resolveCustomerTab: protokoll fuer alle', () => {
assert.equal(resolveCustomerTab('protokoll', false), 'protokoll')
})
test('viewLogLabel: alle sieben Aktionen und Rohwert', () => {
assert.equal(viewLogLabel('news_bild'), 'News-Foto hochgeladen')
assert.equal(viewLogLabel('neu_irgendwas'), 'neu_irgendwas')
})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test tests/unit/customerTabs.test.mjs tests/unit/viewLog.test.mjs` → FAIL
- [ ] **Step 3: Implementieren**
- [ ] **Step 4: Tests** — Run: `npm test` → PASS
- [ ] **Step 5: Commit**
```bash
git add src/lib/customerTabs.js src/lib/viewLog.js src/components/customer/CustomerViewLog.jsx src/pages/CustomerDetailPage.jsx tests/unit/customerTabs.test.mjs tests/unit/viewLog.test.mjs
git commit -m "Kundenseite: Reiter in der Adresse, Uebersicht mit Umlaut, Protokoll der Kundenansicht"
```
### Task 11: Knopf „Kundenansicht öffnen“, Letzter Login, Preview per Einmal-Link
**Files:**
- Create: `src/components/customer/KundenansichtButton.jsx`
- Create: `src/lib/lastLogin.js`
- Modify: `src/pages/CustomerDetailPage.jsx` (Knopf in `PageHeader.actions` vor „Archivieren“; `PortalAccessCard`: Link „Portal öffnen“ durch `KundenansichtButton` ersetzen, Zeile „Letzter Login“ mit `getCustomerPortalStatus`)
- Modify: `src/components/PreviewLinkButton.jsx` (Props `{ href, subdomain, title, size }`; Klick → `openInNewTab(() => createPreviewLink({ subdomain, previewUrl: href }))`; ohne `subdomain` bleibt der einfache Link)
- Modify: `src/pages/ProjectsPage.jsx:577`, `src/components/AssignedProjectCard.jsx:94` (`subdomain={project.subdomain}` übergeben)
- Test: `tests/unit/lastLogin.test.mjs`
**Interfaces:**
- Consumes: `createKundenansichtLink`, `getCustomerPortalStatus`, `createPreviewLink` (Task 9), `openInNewTab` (Task 9).
- Produces: `KundenansichtButton({ customer })`: aktiv nur bei `customer.portalAccessEnabled && customer.appwriteUserId`, sonst ausgegraut mit `title="Kein Portal-Zugang"`; Beschriftung „Kundenansicht öffnen“ mit Icon `FaArrowUpRightFromSquare`; Fehler → `window.alert(err.message)`.
- Produces: `formatLastLogin(iso: string|null) -> string`: `null`/leer → „noch nie“, sonst `dd.mm.yyyy, hh:mm Uhr` in Europe/Berlin.
- [ ] **Step 1: Failing test**
```js
test('formatLastLogin', () => {
assert.equal(formatLastLogin(null), 'noch nie')
assert.equal(formatLastLogin('2026-09-30T10:05:00.000Z'), '30.09.2026, 12:05 Uhr')
})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test tests/unit/lastLogin.test.mjs` → FAIL
- [ ] **Step 3: Implementieren**
- [ ] **Step 4: Tests + Build**
Run: `npm test` → PASS
Run: `systemd-run --scope -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1200 npx vite build --outDir /tmp/claude-0/-root/58d3e37c-9aaf-40c4-9aa6-1a699f81c435/scratchpad/woms-out` → „built in“, keine Fehler
- [ ] **Step 5: Sichtprüfung mit Playwright** (Harness nach dem Rezept aus der Memory `woms-ticketsystem`: esbuild-Bündel der Kundenseite mit gestubbten Fremd-Panels, Appwrite-REST und `/api/team/*` per `page.route` nachgebaut, Speicherbremse `MemoryMax=700M`): Checks — Knopf aktiv/ausgegraut, Klick öffnet einen Tab und setzt die `openUrl`, Fehler schließt den Tab; `?tab=protokoll` öffnet direkt den Protokoll-Reiter; Reiterwechsel ändert die Adresse; „Letzter Login“ zeigt den formatierten Wert; Preview-Knopf ruft `/api/team/preview-link`. Harness im Scratchpad, nicht committen.
- [ ] **Step 6: Commit**
```bash
git add src/components/customer/KundenansichtButton.jsx src/lib/lastLogin.js src/pages/CustomerDetailPage.jsx src/components/PreviewLinkButton.jsx src/pages/ProjectsPage.jsx src/components/AssignedProjectCard.jsx tests/unit/lastLogin.test.mjs
git commit -m "Kundenseite: Kundenansicht oeffnen, letzter Login, Preview per Einmal-Link"
```
---
## Abschluss
### Task 12: Gesamtprüfung und Übergabe
- [ ] **Step 1:** Portal: `npm test` → alle grün (vorher bekannte Flakes: `repoMove`; bei Fehlschlag einzeln wiederholen und im Bericht nennen).
- [ ] **Step 2:** Portal unter Node 20 (Produktion): `docker run --rm -v $PWD:$PWD:ro -v /home/webklar/apps/kundenbereich.webklar.com/build/node_modules:/home/webklar/apps/kundenbereich.webklar.com/build/node_modules:ro -w $PWD node:20-alpine node --test test/teamAuth.test.js test/kundenansicht*.test.js test/teamRoutes.test.js test/customerViewLog.test.js` → PASS.
- [ ] **Step 3:** WOMS: `npm test` → grün; Build aus Task 11 Step 4 erfolgreich.
- [ ] **Step 4:** Abschlussreview des ganzen Branches (beide Repos) durch einen frischen Reviewer (Modell sonnet).
- [ ] **Step 5:** Übergabe an den Nutzer mit Livegang-Reihenfolge:
1. Portal pushen: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-admin-umzug push origin feature/admin-umzug:main`
2. Collection anlegen: `docker exec -w /app webklar-kundenbereich-server node scripts/setup-abo-collections.mjs` (scheitert es an Rechten: Felder aus Task 2 in der Appwrite-Konsole anlegen)
3. Live-Check (ich): `/api/team/customers/x/portal` ohne JWT → 401; CORS-Preflight von `https://ticket.webklar.com` → 204 mit neuer Header-Liste; `datenschutz.html` enthält den neuen Satz.
4. WOMS pushen: `git -C /home/webklar/apps/ticket.webklar.com/dev-admin-umzug push origin feature/admin-umzug:test`
5. Live-Check (ich): ausgeliefertes Bundle enthält „Kundenansicht öffnen“ und `/api/team/`.
6. Nutzer-Test: Kundenansicht bei einem Kunden öffnen, Banner prüfen, Beenden, Protokoll-Tab ansehen.
@@ -0,0 +1,271 @@
# Admin-Panel ins Ticketsystem – Etappe 2 (Chat) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Mitarbeiter chatten mit Kunden im WOMS – Messenger unter Kunden → Chat, Reiter „Chat“ auf der Kundenseite, Zahl der ungelesenen Nachrichten in der Sidebar – mit Anhängen wie heute; das Admin-Panel bleibt nutzbar.
**Architecture:** Die Chat-Routen des Admin-Panels werden ein eigener Router, den das Portal unter `/api/portal-admin` (Cookie, alt) und `/api/team` (JWT, neu) einhängt. Anhänge bekommen signierte Links (15 min), damit `<img>`/`<audio>`/Download im WOMS ohne Header funktionieren. WOMS lädt `chat-composer.js/.css` zur Laufzeit vom Portal (eine Quelle) und bettet Eingabezeile und Sprechblasen in React ein; der Composer bekommt dafür die Option `requestHeaders`.
**Tech Stack:** Portal: Express 4 (ESM, Node 20), node:test + `test/support/*`, Playwright-core für UI-Checks. WOMS: React 18, react-router 6, appwrite 13, node:test + `tests/unit/load.mjs`.
**Spec:** `docs/superpowers/specs/2026-10-01-admin-panel-ins-ticketsystem-design.md` (Abschnitt 1 Sidebar, Abschnitt 2 „Chat“, Abschnitt 4 Tabelle `/api/team` Zeile Chat + „Dateien ohne Header“ + CORS, Etappe 2 in Abschnitt 5)
## Global Constraints
- Arbeitsorte: Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-admin-umzug` (Branch `feature/admin-umzug`, Stand `6718018` = origin/main). WOMS `/home/webklar/apps/ticket.webklar.com/dev-admin-umzug` (Branch `feature/admin-umzug`, Stand `fce2f41` = origin/test). Vor Beginn je `git fetch` und `git merge --ff-only origin/main` bzw. `origin/test`.
- Nie `git add -A`; nichts pushen (WOMS deployt jeden gepushten Branch). Livegang macht der Nutzer, Portal zuerst.
- `command grep -a` statt `grep`. UI-Texte deutsch mit Umlauten.
- Funktionsgleich zum Admin-Panel, keine neuen Chat-Funktionen. Kunden-Chat im Dashboard bleibt unverändert.
- Signierte Links: HMAC-SHA256 (hex) mit `config.sessionSecret` über `chat:<customerId>:<attachmentId>:<exp>`, Gültigkeit **15 min**, Query `exp` (ms seit Epoch) und `sig`.
- Polling: offenes Gespräch **15 s**, Gesprächsliste **30 s**, Sidebar-Zahl **60 s** – jeweils nur bei sichtbarem Tab. Gesprächsverlauf komplett neu laden (frische Links) nach **10 min**.
- K17-Hinweistexte wortgleich zum Admin-Panel (`public/admin.js` `showAdminChatNotice`).
- Speicherbremse für Builds/Playwright: `systemd-run --scope -p MemoryMax=… -p MemorySwapMax=0`.
## Review Focus
1. **Gespräch länger als 15 min offen:** alte Anhang-Links laufen ab. Erwartung: Verlauf wird nach 10 min bzw. beim Zurückkehren in den Tab neu geladen, Download/Lightbox funktionieren weiter (Task 5: `needsFullRefresh`).
2. **Schneller Kundenwechsel im Messenger:** Antwort für Kunde A kommt an, nachdem B gewählt wurde. Erwartung: nichts von A erscheint im Gespräch von B (Task 6, Playwright).
3. **JWT nicht holbar beim Hochladen:** Erwartung: Anhang zeigt Fehler, kein hängender Fortschritt (Task 3, Playwright).
4. **Kunden-Chat im Dashboard:** ohne `requestHeaders` exakt wie bisher, kein Authorization-Header (Task 3, Playwright).
5. **Handy (390 px):** Messenger zeigt Liste oder Gespräch mit Zurück-Knopf, kein waagerechtes Scrollen (Task 6, Playwright).
---
## Portal
### Task 1: Signierte Datei-Links
**Files:**
- Create: `server/services/signedLinks.js`
- Test: `test/signedLinks.test.js`
**Interfaces:**
- Produces: `FILE_LINK_TTL_MS = 900000`; `signFileLink({ kind, customerId, id, now = Date.now() }) -> { exp: number, sig: string }`; `verifyFileLink({ kind, customerId, id, exp, sig, now = Date.now() }) -> boolean` (falsch bei `now >= exp`, fehlender/kaputter Signatur, anderer Länge – `timingSafeEqual` nur bei gleicher Länge).
- [ ] **Step 1: Failing tests**
```js
test('Signatur passt nur fuer dieselbe Datei, denselben Kunden und dieselbe Art', () => {
const { exp, sig } = signFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', now: 1000 })
assert.equal(exp, 1000 + 900000)
assert.match(sig, /^[0-9a-f]{64}$/)
assert.equal(verifyFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', exp, sig, now: 2000 }), true)
for (const other of [{ customerId: 'c2' }, { id: 'a2' }, { kind: 'takeover' }]) {
assert.equal(verifyFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', exp, sig, now: 2000, ...other }), false)
}
})
test('abgelaufen genau bei exp, manipuliertes exp, kaputte Signatur', () => {
const { exp, sig } = signFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', now: 0 })
assert.equal(verifyFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', exp, sig, now: exp }), false)
assert.equal(verifyFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', exp: exp + 1, sig, now: 0 }), false)
for (const bad of ['', 'abc', 'x'.repeat(64), undefined]) {
assert.equal(verifyFileLink({ kind: 'chat', customerId: 'c1', id: 'a1', exp, sig: bad, now: 0 }), false)
}
})
```
- [ ] **Step 2:** Run `node --test test/signedLinks.test.js` → FAIL (Modul fehlt)
- [ ] **Step 3:** Implementieren (`createHmac`, `timingSafeEqual` aus `node:crypto`; `exp` als Zahl prüfen)
- [ ] **Step 4:** Run `node --test test/signedLinks.test.js` → PASS
- [ ] **Step 5: Commit**
```bash
git add server/services/signedLinks.js test/signedLinks.test.js
git commit -m "Signierte Datei-Links (15 min) fuer Anhaenge im Ticketsystem"
```
### Task 2: Chat-Router für Admin-Panel und Team-Eingang, signierte Anhang-Links
**Files:**
- Create: `server/routes/chatAdmin.js` (Chat-Teil aus `server/routes/portalAdmin.js:309-474` verschoben, Hilfsfunktion `customerNameMap` mitnehmen bzw. dort exportieren)
- Modify: `server/routes/portalAdmin.js` (Chat-Routen entfernen, `router.use(chatAdminRouter)`)
- Create: `server/routes/team/files.js`
- Modify: `server/routes/team/index.js` (`router.use(teamFilesRouter)` **vor** `requireStaffJwt`, `router.use(chatAdminRouter)` danach)
- Test: `test/teamChat.test.js`
**Interfaces:**
- Consumes: `signFileLink`, `verifyFileLink` (Task 1); `requireStaffJwt` (Etappe 1, setzt `req.session` mit `appwriteUserId`, `name`).
- Produces (HTTP, unter `/api/portal-admin` und `/api/team` gleich): `GET /chats` → `{ chats: [{ customerId, customerName, lastText, lastSender, lastAt, unreadFromCustomer }] }`; `GET /chats/unread-count` → `{ unread }`; `GET /chats/:id/messages?since=` → `{ messages }`; `POST /chats/:id/messages` `{ text, attachmentIds }` → 201 `{ message, notice }`; `POST /chats/:id/attachments` (roh, Header `X-File-Name` …) → 201 `{ attachment }`; `DELETE /chats/:id/attachments/:att` → 204; `POST /chats/:id/read` → `{ updated }`.
- Produces: in jeder Nachricht der Antworten von `GET …/messages` und `POST …/messages` trägt jeder Anhang zusätzlich `url` = `${config.publicBaseUrl}/api/team/chats/<customerId>/attachments/<id>?exp=<exp>&sig=<sig>`.
- Produces: `GET /api/team/chats/:customerId/attachments/:id` liefert die Datei mit gültiger Signatur **ohne** JWT; ungültige/abgelaufene Signatur → 403 `{ error: 'Link abgelaufen. Bitte die Unterhaltung neu laden.' }`; ohne `sig` weiter zur JWT-Prüfung (ohne JWT 401, mit JWT 200).
- [ ] **Step 1: Failing tests** (Aufbau wie `test/teamRoutes.test.js`; `CHAT_UPLOAD_DIR` auf `mkdtempSync`; `setMailTransportForTests(mailSink())`; Router `/api/team` und `/api/portal-admin`; Fixtures `seedFixtures`, `adminCookie`)
```js
test('Liste und ungelesene Zahl ueber den Team-Eingang', async () => { /* 2 Kundennachrichten c1 unread -> chats[0].unreadFromCustomer 2, unread-count 2 */ })
test('Senden als Mitarbeiter: Absendername aus dem JWT, K17-Mail an den Kunden', async () => {
const res = await post('/api/team/chats/c1/messages', { text: 'Hallo' }, staff)
assert.equal(res.status, 201)
assert.equal(res.body.message.senderName, 'Andrej')
assert.equal(res.body.notice.sent, true)
assert.equal(sink.sent.at(-1).to[0].address, 'k@example.invalid')
})
test('Anhang hochladen, mit Nachricht senden -> signierte url', async () => {
// POST /api/team/chats/c1/attachments, Body Buffer 'hallo', Header content-type application/octet-stream, x-file-name notiz.txt
// POST messages { text: '', attachmentIds: [id] } -> message.attachments[0].url passt auf
// /^https:\/\/project\.webklar\.com\/api\/team\/chats\/c1\/attachments\/[^?]+\?exp=\d+&sig=[0-9a-f]{64}$/
})
test('signierter Link: ohne JWT 200 mit Inhalt, manipuliert/fremder Kunde/abgelaufen 403, ohne sig 401, JWT ohne sig 200', async (t) => {})
test('Lesen markiert Kundennachrichten', async () => { /* POST /api/team/chats/c1/read -> updated 2, danach unread-count 0 */ })
test('Admin-Panel unveraendert: /api/portal-admin/chats mit Admin-Cookie 200, ohne 401', async () => {})
```
- [ ] **Step 2:** Run `node --test test/teamChat.test.js` → FAIL (404 unter `/api/team/chats`)
- [ ] **Step 3:** Implementieren. In `chatAdmin.js` eine Hilfe `withSignedUrls(message, now)` für die Antworten; `files.js` nutzt `openAttachment({ viewer: 'admin' })` und `sendAttachment` aus `services/chatAttachments.js`.
- [ ] **Step 4:** Run `npm test` → PASS (alle bisherigen Tests bleiben grün, insbesondere `test/support.test.js`, `test/chatNotice.test.js`)
- [ ] **Step 5: Commit**
```bash
git add server/routes/chatAdmin.js server/routes/portalAdmin.js server/routes/team/files.js server/routes/team/index.js test/teamChat.test.js
git commit -m "Chat ueber den Team-Eingang: gemeinsamer Router fuer Admin-Panel und Ticketsystem, signierte Anhang-Links"
```
### Task 3: Composer-Option `requestHeaders`
**Files:**
- Modify: `public/chat-composer.js` (Optionsdoku Zeile ~536; `deleteOnServer` ~711; `upload` ~734)
- Create: `test/ui/composer-headers.ui.mjs` (Playwright, nicht Teil von `npm test`)
**Interfaces:**
- Produces: `ChatComposer.create({ …, requestHeaders?: () => object | Promise<object> })`. Die Header gehen an den Upload-XHR (zusätzlich zu den bestehenden) und an das DELETE beim Entfernen. Wirft/verwirft `requestHeaders`, wird der Anhang `status: 'error'` mit `error = err.message || 'Anmeldung abgelaufen. Bitte Seite neu laden.'`. Ohne Option: Verhalten unverändert.
- [ ] **Step 1: UI-Check schreiben** (Muster `test/ui/kundenansicht.ui.mjs`; statischer Server für `public/` plus Testseite `/composer-test.html`, die `chat-composer.css/.js` lädt und `ChatComposer.create` mit `uploadUrl: '/upload'` und einem per Query wählbaren `requestHeaders` aufruft; `/upload` per `page.route`)
Checks:
- `mit_header_upload`: async `requestHeaders` → POST `/upload` trägt `authorization: Bearer test`.
- `mit_header_delete`: Anhang entfernen → DELETE `/upload/a1` trägt `authorization: Bearer test`.
- `ohne_option_kein_header`: ohne `requestHeaders` → POST ohne `authorization`.
- `header_fehler`: `requestHeaders` verwirft mit `new Error('JWT weg')` → Anhang zeigt „JWT weg“, kein POST.
- [ ] **Step 2:** Run `node test/ui/composer-headers.ui.mjs` → FAIL (`mit_header_upload`, `mit_header_delete`, `header_fehler`)
- [ ] **Step 3:** Implementieren (Header vor `xhr.open` asynchron holen; zwischendurch entfernte oder zerstörte Anhänge nicht mehr hochladen)
- [ ] **Step 4:** Run `node test/ui/composer-headers.ui.mjs` → „alle Checks ok“; Run `node test/ui/kundenansicht.ui.mjs` → „alle Checks ok“
- [ ] **Step 5: Commit**
```bash
git add public/chat-composer.js test/ui/composer-headers.ui.mjs
git commit -m "Chat-Baustein: Option requestHeaders fuer Hochladen und Entfernen (Ticketsystem mit JWT)"
```
---
## WOMS
### Task 4: Team-API für den Chat und Laden des Chat-Bausteins
**Files:**
- Modify: `src/lib/teamApi.js`
- Create: `src/lib/loadChatComposer.js`
- Test: `tests/unit/loadChatComposer.test.mjs`
**Interfaces:**
- Consumes: `createJwtCache` aus `src/lib/serverpulsAuth.js`; HTTP aus Task 2.
- Produces in `teamApi.js`: `PORTAL_URL` (export); `listChats() -> Promise<{ chats }>`; `getChatUnreadCount() -> Promise<{ unread }>`; `getChatMessages(customerId, since = '') -> Promise<{ messages }>`; `sendChatMessage(customerId, { text, attachmentIds }) -> Promise<{ message, notice }>`; `markChatRead(customerId) -> Promise<{ updated }>`; `chatUploadUrl(customerId) -> string` (`${PORTAL_URL}/api/team/chats/<id>/attachments`); `teamAuthHeaders() -> Promise<{ Authorization: string }>` (eigener `createJwtCache` über `account.createJWT`).
- Produces: `loadChatComposer({ baseUrl, doc = globalThis.document, win = globalThis.window }) -> Promise<ChatComposer>`: hängt einmal `<link rel="stylesheet" href="<baseUrl>/chat-composer.css">` und `<script src="<baseUrl>/chat-composer.js">` an `doc.head`; löst mit `win.ChatComposer` auf; Fehler → `Error('Der Chat-Baustein konnte nicht geladen werden.')`; je `doc` nur ein Ladevorgang (Promise wird wiederverwendet, nach Fehler neu versucht).
- [ ] **Step 1: Failing tests** (Fake-`doc` mit `head.appendChild`, `createElement` → Objekt mit `setAttribute`, `onload`/`onerror`)
```js
test('haengt CSS und Skript einmal an und liefert window.ChatComposer', async () => { /* zwei Aufrufe -> 2 Elemente, gleicher Rueckgabewert */ })
test('Skript laedt nicht -> deutsche Meldung, naechster Aufruf versucht es neu', async () => {})
test('schon geladen (win.ChatComposer vorhanden) -> nichts anhaengen', async () => {})
```
- [ ] **Step 2:** Run `node --test tests/unit/loadChatComposer.test.mjs` → FAIL
- [ ] **Step 3:** Implementieren
- [ ] **Step 4:** Run `npm test` → PASS
- [ ] **Step 5: Commit**
```bash
git add src/lib/teamApi.js src/lib/loadChatComposer.js tests/unit/loadChatComposer.test.mjs
git commit -m "Chat: Team-API und Laden des Chat-Bausteins vom Kundenportal"
```
### Task 5: Sidebar mit Unterpunkt „Chat“ und Zahl, Chat-Hilfen
**Files:**
- Create: `src/lib/navLinks.js`, `src/lib/chatFormat.js`, `src/hooks/useChatUnread.js`
- Modify: `src/components/Navbar.jsx:57-113` (Liste aus `buildNavLinks`, Unterpunkte rendern), `src/styles/global.css` (Unterpunkt-Stil `.side-sublink`, Zahl `.side-count`)
- Test: `tests/unit/navLinks.test.mjs`, `tests/unit/chatFormat.test.mjs`
**Interfaces:**
- Consumes: `getChatUnreadCount` (Task 4); `formatViewLogTime` aus `src/lib/viewLog.js`.
- Produces: `buildNavLinks({ isAdmin, pulseLevel, chatUnread }) -> Array<{ key, label, href, also?, paused?, dot?, count?, children? }>`; Kunden-Eintrag: `{ key: 'customers', label: 'Kunden', href: '/customers', children: [{ key: 'chat', label: 'Chat', href: '/customers/chat', count: chatUnread }] }`; übrige Einträge und Reihenfolge wie heute (Navbar ordnet Icons über `key` zu).
- Produces: `useChatUnread() -> number` (alle 60 s bei sichtbarem Tab, sofort beim Sichtbarwerden; Fehler behalten den letzten Wert).
- Produces in `chatFormat.js`: `sortChats(chats)` (ungelesene zuerst, sonst `lastAt` absteigend); `noticeText(notice, time)` mit den drei Texten aus dem Admin-Panel (`E-Mail-Hinweis an ${to} gesendet (${time})` / `Kein neuer E-Mail-Hinweis: Der Kunde wurde in den letzten 15 Minuten schon benachrichtigt.` / `Kein E-Mail-Hinweis: ${reason}`; ohne notice `''`); `lastLine(chat)` (`Du: ` vor eigenen); `messageMeta(msg, customerName)` (`<Absender> · dd.mm.yyyy hh:mm`, eigener Absender `senderName || 'Team'`); `needsFullRefresh(lastFullAt, now) -> boolean` (ab 600000 ms).
- [ ] **Step 1: Failing tests**
```js
test('buildNavLinks: Chat unter Kunden mit Zahl, Admin-Eintraege nur fuer Admins', () => {
const links = buildNavLinks({ isAdmin: false, pulseLevel: 'ok', chatUnread: 3 })
const kunden = links.find((l) => l.key === 'customers')
assert.deepEqual(kunden.children, [{ key: 'chat', label: 'Chat', href: '/customers/chat', count: 3 }])
assert.deepEqual(links.map((l) => l.key), ['tickets', 'planboard', 'projects', 'services', 'customers', 'leads', 'legal'])
assert.ok(buildNavLinks({ isAdmin: true, pulseLevel: 'warn', chatUnread: 0 }).some((l) => l.key === 'serverpuls' && l.dot === 'warn'))
})
test('sortChats: ungelesene zuerst, dann neueste', () => {})
test('noticeText: gesendet, gedrosselt, Grund, leer', () => {
assert.equal(noticeText({ sent: true, to: 'k@x.de' }, '14:05'), 'E-Mail-Hinweis an k@x.de gesendet (14:05)')
assert.equal(noticeText({ sent: false, reason: 'throttled' }, '14:05'), 'Kein neuer E-Mail-Hinweis: Der Kunde wurde in den letzten 15 Minuten schon benachrichtigt.')
assert.equal(noticeText({ sent: false, reason: 'SMTP weg' }, '14:05'), 'Kein E-Mail-Hinweis: SMTP weg')
assert.equal(noticeText(null, '14:05'), '')
})
test('messageMeta und lastLine', () => {})
test('needsFullRefresh ab 10 Minuten', () => {
assert.equal(needsFullRefresh(0, 599999), false)
assert.equal(needsFullRefresh(0, 600000), true)
})
```
- [ ] **Step 2:** Run `node --test tests/unit/navLinks.test.mjs tests/unit/chatFormat.test.mjs` → FAIL
- [ ] **Step 3:** Implementieren; Navbar: Unterpunkte eingerückt (`.side-sublink`), eingeklappt nur Icon (`IconMessages`), Zahl als `.side-count` nur bei `count > 0`; „Kunden“ bleibt markiert, solange ein Unterpunkt aktiv ist. Knopf „Projekt-Admin“ bleibt (fällt erst in Etappe 4).
- [ ] **Step 4:** Run `npm test` → PASS
- [ ] **Step 5: Commit**
```bash
git add src/lib/navLinks.js src/lib/chatFormat.js src/hooks/useChatUnread.js src/components/Navbar.jsx src/styles/global.css tests/unit/navLinks.test.mjs tests/unit/chatFormat.test.mjs
git commit -m "Sidebar: Chat als Unterpunkt von Kunden mit Zahl der ungelesenen Nachrichten"
```
### Task 6: Gespräch, Messenger und Reiter „Chat“
**Files:**
- Create: `src/components/chat/ChatThread.jsx`, `src/pages/CustomerChatPage.jsx`, `src/styles/chat.css`
- Modify: `src/App.jsx:51` (Route `/customers/chat` vor `/customers/:id`), `src/lib/customerTabs.js` (Reiter `{ id: 'chat', label: 'Chat' }` vor `protokoll`), `src/pages/CustomerDetailPage.jsx` (Reiter rendert `ChatThread`)
- Test: `tests/unit/customerTabs.test.mjs` (anpassen); Playwright-Harness im Scratchpad (nicht committen)
**Interfaces:**
- Consumes: Tasks 4 und 5.
- Produces: `ChatThread({ customerId, customerName })`: lädt `loadChatComposer`, rendert Nachrichten mit `ChatComposer.renderMessage(msg, { mine: msg.sender === 'admin', meta: messageMeta(msg, customerName), fileUrl: (att) => att.url })` in einen scrollenden Verlauf, Eingabezeile mit `ChatComposer.create({ root, dropZone, uploadUrl: chatUploadUrl(id), requestHeaders: teamAuthHeaders, placeholder: 'Antwort an <Name>…', placeholderShort: 'Antwort…', send, onSent })`; Hinweiszeile mit `noticeText`; Abfrage alle 15 s mit `since`; neue Kundennachrichten → `markChatRead`; beim Öffnen `markChatRead`; vollständiges Neuladen nach `needsFullRefresh` (Intervall und beim Sichtbarwerden); Antworten für einen inzwischen gewechselten Kunden werden verworfen; `composer.destroy()` beim Wechsel/Verlassen; Ladefehler → Zeile mit „Erneut versuchen“.
- Produces: `CustomerChatPage` unter `/customers/chat`: links Feld „Neue Nachricht an Kunde…“ (`AutocompleteInput` über `useCustomers`, Treffer per Name → `?kunde=<id>`) und Liste (`sortChats`, Zahl ungelesen, `lastLine`, Zeit), Abfrage alle 30 s; rechts `ChatThread` zu `?kunde` oder „Unterhaltung links auswählen“. Unter 720 px: ohne `kunde` nur Liste, mit `kunde` nur Gespräch mit Knopf „← Alle Unterhaltungen“.
- Produces: Reihenfolge der Kundenreiter `Übersicht · Tickets · Rechnungen · Projekte · Chat · Protokoll` (Rechnungen nur Admins).
- [ ] **Step 1: Test anpassen**
```js
assert.deepEqual(customerTabs(false).map((t) => t.id), ['overview', 'tickets', 'projects', 'chat', 'protokoll'])
assert.equal(resolveCustomerTab('chat', false), 'chat')
```
- [ ] **Step 2:** Run `node --test tests/unit/customerTabs.test.mjs` → FAIL
- [ ] **Step 3:** Implementieren; `chat.css` setzt `--chat-accent: var(--accent)`, `--chat-accent-hover: var(--accent-2)`, `--chat-accent-soft: var(--accent-soft)` und die Sprechblasen/Verlauf-Stile (`.chat-messages`, `.chat-bubble.mine/.theirs`, `.chat-meta`) innerhalb `.woms-chat`.
- [ ] **Step 4:** Run `npm test` → PASS; Vite-Build mit `systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1200 node node_modules/vite/bin/vite.js build --outDir <scratchpad>/woms-out --emptyOutDir` → „built in“
- [ ] **Step 5: Playwright-Harness** (wie Etappe 1: esbuild-Bündel mit AuthContext-Stub, Appwrite-REST und `https://project.webklar.com/api/team/*` per `ctx.route`, `chat-composer.js/.css` aus dem Portal-Worktree ausgeliefert). Checks: Messenger-Liste sortiert, Zahl sichtbar; Klick öffnet Gespräch, Adresse `?kunde=c1`; Nachricht senden → POST mit Bearer, Hinweiszeile „E-Mail-Hinweis an … gesendet (hh:mm)“; Bild-Anhang in der Nachricht → `<img>` mit signierter `url`; Datei hochladen → POST an `/api/team/chats/c1/attachments` mit Bearer; Kunde wechseln, während die Antwort für A verzögert ist → keine Blase von A im Gespräch von B; Reiter „Chat“ auf der Kundenseite zeigt dasselbe Gespräch; Sidebar-Zahl; 390 px: Liste ↔ Gespräch mit Zurück-Knopf, kein waagerechtes Scrollen; keine JS-Fehler. Screenshots nach `/root/reports/admin-umzug-ui/woms-chat-*.png`.
- [ ] **Step 6: Commit**
```bash
git add src/components/chat/ChatThread.jsx src/pages/CustomerChatPage.jsx src/styles/chat.css src/App.jsx src/lib/customerTabs.js src/pages/CustomerDetailPage.jsx tests/unit/customerTabs.test.mjs
git commit -m "Chat im Ticketsystem: Messenger unter Kunden, Reiter Chat auf der Kundenseite"
```
---
## Abschluss
### Task 7: Gesamtprüfung und Übergabe
- [ ] Portal `npm test` → grün; neue Tests unter Node 20 (`docker run --rm … node:20-alpine node --test test/signedLinks.test.js test/teamChat.test.js`) → grün.
- [ ] WOMS `npm test` → grün; Build aus Task 6 erfolgreich.
- [ ] Abschlussreview beider Branches (frischer Reviewer, Modell sonnet).
- [ ] Übergabe mit Livegang: 1. Portal `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-admin-umzug push origin feature/admin-umzug:main`; 2. Live-Check (ich): `/api/team/chats` ohne JWT 401, `chat-composer.js` enthält `requestHeaders`, Admin-Panel-Chat lädt; 3. WOMS `git -C /home/webklar/apps/ticket.webklar.com/dev-admin-umzug push origin feature/admin-umzug:test`; 4. Live-Check (ich): Bundle enthält „Neue Nachricht an Kunde“; 5. Nutzer-Test: Nachricht aus dem WOMS senden, Antwort des Kunden mit Bild ansehen.
@@ -0,0 +1,219 @@
# Finanzen Plan 1: Bankanbindung (Enable Banking → Finom) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Der Kundenportal-Server ruft Kontostand und Umsätze des Finom-Kontos über Enable Banking ab, speichert sie dauerhaft in Appwrite, erneuert den Bankzugang auf Knopfdruck und warnt vor dessen Ablauf.
**Architecture:** Ein dünner Enable-Banking-Client (`node:crypto` signiert das RS256-JWT, `fetch` spricht die API) unter `server/services/finance/`, ein Sync-Dienst, der Buchungen idempotent in `bankTransactions` schreibt, und ein Admin-Router `/api/admin/finanzen` (requireAppwriteStaff). Jobs laufen wie die übrigen Portal-Jobs per `setInterval` in `server/index.js`.
**Tech Stack:** Node 20 (ESM), Express 4, Appwrite REST (`services/appwriteAdmin.js`), `node:test`, Nachbauten in `test/support/`.
**Spec:** `docs/superpowers/specs/2026-10-01-finanzen-design.md`
## Global Constraints
- Arbeitsverzeichnis: Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-finanzen`, Branch `feature/finanzen` (von `main` c37dd49). **Nie pushen** ohne ausdrückliches OK des Nutzers (der Deploy-Webhook kann jede gepushte Branch live stellen).
- Tests: `npm test` (= `node --test test/*.test.js`) muss vor jedem Commit grün sein; einzelne Datei: `node --test test/<datei>.test.js`.
- Keine neuen npm-Abhängigkeiten.
- Enable Banking: API `https://api.enablebanking.com`, JWT-Header `{ typ: 'JWT', alg: 'RS256', kid: <App-ID> }`, Payload `{ iss: 'enablebanking.com', aud: 'api.enablebanking.com', iat, exp: iat + 3600 }`.
- ASPSP `{ name: 'Finom', country: 'DE' }`, `psu_type: 'business'`, Redirect `https://ticket.webklar.com/finanzen/bank-callback`, Zugang `valid_until` = jetzt + 179 Tage.
- `ENABLE_BANKING_API_BASE` (nur Tests) wird nur für `127.0.0.1`/`localhost` akzeptiert, sonst ignoriert (wie `STRIPE_API_BASE`).
- Beträge intern immer als ganze Cent (`amountCents`, Integer); Eingang positiv, Ausgang negativ.
- Daten im Format `YYYY-MM-DD`; Berechnungen „heute“ in Europe/Berlin.
- Der private Schlüssel und Sitzungs-IDs erscheinen nie in Logs, Fehlermeldungen oder API-Antworten.
- Kommentare und Fehlermeldungen auf Deutsch, Stil wie die umgebenden Dateien (ASCII-Umlaute in Code-Kommentaren wie im Bestand, echte Umlaute in Texten für Menschen).
## Review Focus
- **Doppelte Abrufe:** Dieselbe Buchung kommt bei jedem Abruf erneut → darf nie doppelt gespeichert werden und darf Nutzerfelder (`category`, `matched*`, `fixedCostId`, `note`) nie überschreiben. Test in Task 3.
- **Vorgemerkte Buchungen:** Eine `PDNG`-Buchung verschwindet beim nächsten Abruf (wird gebucht oder storniert) → die alte Vormerkung wird gelöscht, die gebuchte erscheint einmal. Test in Task 3.
- **Abgelaufener Zugang:** Enable Banking antwortet 401/403 → Status `abgelaufen`, alte Daten bleiben, kein Absturz des Jobs, keine Wiederholungsschleife. Test in Task 3.
- **Fremde Rückkehr:** `state` passt nicht oder ist älter als 30 Minuten, oder Finom meldet `error` → 400 mit verständlicher Meldung, bestehende Sitzung bleibt unverändert. Test in Task 4.
- **Paging:** Enable Banking liefert `continuation_key` → alle Seiten werden geholt, aber höchstens 20 Seiten (Schutz vor Endlosschleife). Test in Task 1.
---
### Task 1: Konfiguration und Enable-Banking-Client
**Files:**
- Modify: `server/config.js` (neuer Block `enableBanking` nach `stripe`)
- Create: `server/services/finance/enableBanking.js`
- Create: `test/support/fakeEnableBanking.js`
- Test: `test/financeEnableBanking.test.js`
**Interfaces:**
- Produces:
- `config.enableBanking = { appId, keyPath, apiBase, redirectUrl, aspsp: { name: 'Finom', country: 'DE' }, enabled }` – `appId` aus `ENABLE_BANKING_APP_ID`, `keyPath` aus `ENABLE_BANKING_KEY_PATH`, `apiBase` = `ENABLE_BANKING_API_BASE` nur bei 127.0.0.1/localhost sonst `'https://api.enablebanking.com'`, `redirectUrl` = `${config.womsBaseUrl}/finanzen/bank-callback`, `enabled` = beide gesetzt.
- `createJwt({ now = Date.now() } = {}) → string`
- `startAuth({ state, validUntil }) → Promise<{ url, authorizationId }>`
- `createSession(code) → Promise<{ sessionId, validUntil, accounts: Array<{ uid, identificationHash, iban, name, currency }> }>`
- `getSession(sessionId) → Promise<{ status, validUntil, accountUids: string[] }>`
- `getBalances(accountUid, { psu } = {}) → Promise<Array<{ type, amountCents, currency, date }>>`
- `getTransactions(accountUid, { dateFrom, psu } = {}) → Promise<object[]>` (Rohobjekte von Enable Banking, alle Seiten)
- `class EnableBankingError extends Error { status, code }` – `status` = HTTP-Status, `code` = `body.code ?? body.error ?? null`
- `psu` = `{ ip, userAgent }` → Kopfzeilen `Psu-Ip-Address`, `Psu-User-Agent`
- `startFakeEnableBanking({ publicKeyPem })` in `test/support/fakeEnableBanking.js` → `{ url, calls, sessions: Map, accounts: Map, transactions: Map<uid, object[]>, pageSize, failNext({ path, status, body }), close() }`; prüft jedes JWT mit `publicKeyPem` (`crypto.verify('sha256', …)`) und Claims, sonst 401.
- [ ] **Step 1: Write the failing tests** in `test/financeEnableBanking.test.js`. Schlüsselpaar im Test erzeugen (`crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })`), PEM in eine Datei im `os.tmpdir()` schreiben, `ENABLE_BANKING_APP_ID='app-test'`, `ENABLE_BANKING_KEY_PATH=<datei>`, `ENABLE_BANKING_API_BASE=<fake.url>` vor dem Import von `config.js` setzen.
```js
test('createJwt: RS256, kid = App-ID, Claims', () => {
const [h, p, s] = createJwt({ now: 1_790_000_000_000 }).split('.')
const header = JSON.parse(Buffer.from(h, 'base64url'))
const payload = JSON.parse(Buffer.from(p, 'base64url'))
assert.deepEqual(header, { typ: 'JWT', alg: 'RS256', kid: 'app-test' })
assert.deepEqual(payload, { iss: 'enablebanking.com', aud: 'api.enablebanking.com', iat: 1_790_000_000, exp: 1_790_003_600 })
assert.ok(crypto.verify('sha256', Buffer.from(`${h}.${p}`), publicKey, Buffer.from(s, 'base64url')))
})
test('startAuth schickt Finom, business, Redirect und valid_until', async () => {
const r = await startAuth({ state: 'abc', validUntil: '2027-03-29T00:00:00.000Z' })
assert.match(r.url, /^https?:\/\//)
const call = fake.calls.at(-1)
assert.equal(call.path, '/auth')
assert.deepEqual(call.body, { access: { valid_until: '2027-03-29T00:00:00.000Z' }, aspsp: { name: 'Finom', country: 'DE' }, state: 'abc', redirect_url: 'https://ticket.webklar.com/finanzen/bank-callback', psu_type: 'business' })
})
test('createSession liefert Sitzung und Konten', async () => { /* fake: code 'ok' → session_id 's1', accounts [{uid:'u1', identification_hash:'h1', account_id:{iban:'DE62…9313'}, name:'Webklar', currency:'EUR'}], access.valid_until */
const s = await createSession('ok')
assert.equal(s.sessionId, 's1'); assert.equal(s.accounts[0].uid, 'u1'); assert.equal(s.accounts[0].identificationHash, 'h1'); assert.equal(s.accounts[0].iban, 'DE62…9313')
})
test('getTransactions holt alle Seiten (continuation_key), höchstens 20', async () => { /* 5 Buchungen, pageSize 2 → 3 Aufrufe, 5 Ergebnisse; endlose continuation_key → nach 20 Aufrufen Schluss */ })
test('getBalances rechnet in Cent', async () => { /* "108.93" → 10893, "0.5" → 50 */ })
test('PSU-Kopfzeilen nur wenn psu übergeben', async () => { /* calls[].headers['psu-ip-address'] */ })
test('Fehler → EnableBankingError mit status und code, ohne Sitzungs-ID in der Meldung', async () => { /* failNext({ path: /\/accounts\//, status: 401, body: { code: 'EXPIRED_SESSION' } }) */ })
test('ENABLE_BANKING_API_BASE außerhalb von 127.0.0.1 wird ignoriert', () => { /* reine Funktion resolveApiBase(value) exportieren und prüfen */ })
```
- [ ] **Step 2: Run** `node --test test/financeEnableBanking.test.js` – Expected: FAIL (Modul fehlt).
- [ ] **Step 3: Implement** `test/support/fakeEnableBanking.js` (Routen `POST /auth`, `POST /sessions`, `GET /sessions/:id`, `GET /accounts/:uid/balances`, `GET /accounts/:uid/transactions` mit `continuation_key` = Index als String), dann `server/config.js` und `server/services/finance/enableBanking.js`. Den Schlüssel bei jedem `createJwt` lesen (`readFileSync(config.enableBanking.keyPath)`), damit ein Schlüsseltausch ohne Neustart wirkt. `toCents(amountString)` als exportierte Hilfsfunktion: Ganzzahl- und Nachkommateil getrennt parsen, keine Gleitkommarechnung.
- [ ] **Step 4: Run** `node --test test/financeEnableBanking.test.js` – Expected: PASS. Dann `npm test` – Expected: alles grün.
- [ ] **Step 5: Commit**
```bash
git add server/config.js server/services/finance/enableBanking.js test/support/fakeEnableBanking.js test/financeEnableBanking.test.js
git commit -m "Finanzen: Enable-Banking-Client (JWT, Freigabe, Sitzung, Salden, Umsaetze)"
```
### Task 2: Collections und Übernahme der bestehenden Sitzung
**Files:**
- Create: `scripts/setup-finanzen.mjs`
- Modify: `test/support/schema.js` (zweite Quelldatei)
- Modify: `server/config.js` (`collections.bankConnections`, `bankTransactions`, `financeSettings`, `financeFixedCosts`)
- Test: `test/financeSetup.test.js`
**Interfaces:**
- Produces: Collections nach Spec „Datenmodell“ mit genau diesen Attributen (Größen):
- `bankConnections`: `status` 16, `sessionId` 64, `accountUid` 64, `accountIdHash` 128, `ibanMasked` 40, `iban` 40, `validUntil` 32, `lastSyncAt` 32, `lastSyncOk` boolean, `lastError` 500, `lastManualSyncAt` 32, `balanceBookedCents` integer, `balanceAvailableCents` integer, `balanceDate` 10, `latestBookingDate` 10, `authState` 64, `authStartedAt` 32, `warnedThresholds` 32
- `bankTransactions`: `accountUid` 64, `status` 8, `bookingDate` 10, `valueDate` 10, `amountCents` integer, `currency` 3, `counterpartyName` 255, `counterpartyIban` 40, `remittance` 1000, `mcc` 8, `entryReference` 128, `category` 32, `matchedInvoiceId` 64, `matchedInvoiceNumber` 64, `matchedPayoutId` 64, `fixedCostId` 64, `note` 500, `syncedAt` 32; Index `idx_booking` (key, `bookingDate`)
- `financeSettings`: `minBufferCents` integer, `taxReservePercent` integer, `previousYearRevenueCents` integer, `estPrepaymentCents` integer, `bankDetailsText` 500
- `financeFixedCosts`: `name` 120, `amountCents` integer, `interval` 16, `nextDate` 10, `counterpartyMatch` 120, `active` boolean
- Aufruf mit Übernahme: `node scripts/setup-finanzen.mjs --session <id> --account <uid> --valid-until <iso>` legt/aktualisiert `bankConnections/finom` mit `status: 'aktiv'` und legt `financeSettings/main` mit `taxReservePercent: 30`, `minBufferCents: 0`, `estPrepaymentCents: 0` an, falls nicht vorhanden. Ohne Argumente nur Collections.
- `schemaKeys(collection)` sucht in `setup-abo-collections.mjs` und `setup-finanzen.mjs`.
- [ ] **Step 1: Write the failing test** `test/financeSetup.test.js`: `schemaKeys('bankTransactions')` enthält `amountCents`, `category`, `matchedInvoiceId`; `schemaKeys('bankConnections')` enthält `status`, `warnedThresholds`; `schemaKeys('financeFixedCosts')` enthält `counterpartyMatch`; `schemaKeys('subscriptions')` funktioniert weiter.
- [ ] **Step 2: Run** `node --test test/financeSetup.test.js` – Expected: FAIL („Collection bankTransactions fehlt im Setup-Skript“).
- [ ] **Step 3: Implement** `scripts/setup-finanzen.mjs` nach dem Muster von `setup-abo-collections.mjs` (gleiche Helfer `aw`, `ensure`, `waitForAttributes`, `ensureCollection`, Blockformat `'<id>',` … `\n ],` damit `schemaKeys` greift; `permissions: []`). Argumente mit `node:util` `parseArgs`. Sitzungs-ID nie ausgeben (nur „Sitzung übernommen, gültig bis …“).
- [ ] **Step 4: Run** `node --test test/financeSetup.test.js` und `npm test` – Expected: PASS.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Collections und Setup-Skript mit Sitzungsuebernahme"` (Dateien einzeln adden).
### Task 3: Buchungen abrufen und speichern
**Files:**
- Create: `server/services/finance/dates.js`
- Create: `server/services/finance/bankStore.js`
- Create: `server/services/finance/bankSync.js`
- Test: `test/financeDates.test.js`
- Test: `test/financeBankSync.test.js`
**Interfaces:**
- Consumes: Task 1 (`getBalances`, `getTransactions`, `EnableBankingError`), Task 2 (Collections).
- Produces:
- `dates.js`: `berlinDate(date = new Date()) → 'YYYY-MM-DD'` (Europe/Berlin), `addDays(ymd, n) → 'YYYY-MM-DD'`, `daysBetween(fromYmd, toYmd) → number` (ganze Tage, `toYmd − fromYmd`), `formatDe(ymd) → 'TT.MM.JJJJ'`. Tests: `berlinDate(new Date('2026-09-30T22:30:00Z')) === '2026-10-01'`; `addDays('2026-03-29', 1) === '2026-03-30'` (Zeitumstellung); `daysBetween('2026-10-01', '2027-03-29') === 179`; `formatDe('2027-03-29') === '29.03.2027'`.
- `transactionDocId(raw) → string` (36 Zeichen Hex, Regel aus Spec)
- `toTransactionDoc(raw, { accountUid, syncedAt }) → object` – Felder aus Spec; `CRDT` → `debtor`/`debtor_account`, `DBIT` → `creditor`/`creditor_account`; `remittance` = `remittance_information.join(' ')` (gekürzt auf 1000); `iban` aus `account.iban ?? account.other?.identification ?? ''`.
- `bankStore.getConnection() → Promise<object|null>`, `saveConnection(patch) → Promise<object>` (legt `finom` an oder aktualisiert), `upsertTransaction(doc) → Promise<'neu'|'aktualisiert'>` (aktualisiert nur Bankfelder: `status`, `bookingDate`, `valueDate`, `amountCents`, `currency`, `counterpartyName`, `counterpartyIban`, `remittance`, `mcc`, `entryReference`, `accountUid`, `syncedAt`), `listTransactions({ limit = 1000 } = {}) → Promise<object[]>` (neueste zuerst nach `bookingDate`), `getTransaction(id)`, `updateTransaction(id, patch)`, `deleteTransaction(id)`.
- `syncBank({ now = new Date(), psu = null } = {}) → Promise<{ ok: boolean, neu: number, aktualisiert: number, geloescht: number, error?: string }>`
Ablauf `syncBank`: Verbindung laden (keine oder `status: 'abgelaufen'` → `{ ok: false, error: 'Kein aktiver Bankzugang' }` ohne API-Aufruf); `dateFrom` = `latestBookingDate − 7 Tage`, mindestens `heute − 89 Tage`, ohne `latestBookingDate` `heute − 89 Tage`; Salden holen (`ITBD` oder `CLBD` → `balanceBookedCents`, `ITAV` → `balanceAvailableCents`, Datum des gewählten Saldos → `balanceDate`); Umsätze holen und upserten; `PDNG`-Dokumente mit `bookingDate >= dateFrom`, die in diesem Abruf nicht vorkamen, löschen; Verbindung aktualisieren (`lastSyncAt`, `lastSyncOk: true`, `lastError: ''`, `latestBookingDate` = jüngstes `bookingDate` der `BOOK`-Buchungen). Fehler: `EnableBankingError` mit Status 401/403 → `status: 'abgelaufen'`, `lastError: 'Bankzugang abgelaufen oder widerrufen'`; andere Fehler → `status` bleibt, `lastSyncOk: false`, `lastError` = Meldung. Nie werfen.
- [ ] **Step 1: Write the failing tests** (`startAppwriteMock({ schema: { bankConnections: schemaKeys('bankConnections'), bankTransactions: schemaKeys('bankTransactions') } })` + `startFakeEnableBanking`):
- `toTransactionDoc`: Eingang „EWV SOFTWARE GMBH“, `168.79`, `CRDT` → `amountCents: 16879`, `counterpartyName: 'EWV SOFTWARE GMBH'`, `remittance: 'Rechnung 0005 vom 28.07.2026'`; Kartenzahlung `DBIT` `4.49` mit `merchant_category_code: '4816'` → `amountCents: -449`, `mcc: '4816'`, `counterpartyIban: ''`.
- `transactionDocId`: gleiche `entry_reference` → gleiche ID; ohne `entry_reference` und `transaction_id` → stabil aus Datum|Betrag|Gegenpartei|Zweck; Ergebnis passt auf `/^[a-f0-9]{36}$/`.
- Erster Abruf: 9 Buchungen → `neu: 9`, Saldo `balanceBookedCents: 10893`, `latestBookingDate: '2026-09-29'`, `dateFrom` im Aufruf = heute − 89 Tage.
- Zweiter Abruf mit denselben Buchungen nach `updateTransaction(id, { category: 'rechnung-extern', note: 'x' })` → `neu: 0`, `category`/`note` unverändert.
- Vormerkung `PDNG` (entry_reference `p1`) im ersten Abruf, im zweiten stattdessen `BOOK` (entry_reference `b1`) → Dokument `p1` gelöscht (`geloescht: 1`), `b1` vorhanden.
- Fake antwortet 401 auf `/accounts/…/balances` → Ergebnis `ok: false`, Verbindung `status: 'abgelaufen'`, vorhandene Buchungen bleiben; dritter Aufruf macht keinen API-Aufruf.
- Fake antwortet 500 → `status: 'aktiv'`, `lastSyncOk: false`, `lastError` gesetzt.
- [ ] **Step 2: Run** `node --test test/financeDates.test.js test/financeBankSync.test.js` – Expected: FAIL.
- [ ] **Step 3: Implement** `bankStore.js` (über `listDocuments`/`getDocument`/`createDocument`/`updateDocument`/`deleteDocument` aus `appwriteAdmin.js`, `Query.orderDesc('bookingDate')`, `Query.limit`) und `bankSync.js`. „Heute“ immer über `berlinDate()` aus `dates.js` (`Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' })`; Tagesrechnung in UTC-Mitternacht, damit die Zeitumstellung nicht stört).
- [ ] **Step 4: Run** `node --test test/financeDates.test.js test/financeBankSync.test.js` und `npm test` – Expected: PASS.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Bankabruf speichert Buchungen idempotent, Vormerkungen, Ablauf"`
### Task 4: Bankzugang neu verbinden
**Files:**
- Modify: `server/services/finance/bankSync.js`
- Test: `test/financeBankAuth.test.js`
**Interfaces:**
- Consumes: Task 1 (`startAuth`, `createSession`), Task 3 (`bankStore`, `syncBank`).
- Produces:
- `startBankAuth({ now = new Date() } = {}) → Promise<{ url }>` – `state` = `randomBytes(12).toString('hex')`, speichert `authState`, `authStartedAt`.
- `completeBankAuth({ code, state, error }, { now = new Date() } = {}) → Promise<{ ok: true, validUntil, sync }>`; Fehler als `Error` mit `status = 400` und Meldung:
- `state` leer/abweichend → „Diese Rückkehr passt zu keiner gestarteten Freigabe.“
- `authStartedAt` älter als 30 Minuten → „Die Freigabe ist abgelaufen. Bitte neu starten.“
- `error` gesetzt → „Finom hat die Freigabe nicht erteilt (<error>).“
- Kontowahl: gleiches `identificationHash` wie gespeichert, sonst gleiche IBAN, sonst erstes EUR-Konto; keins → 400 „Kein Euro-Konto in der Freigabe.“
- Erfolg setzt `sessionId`, `accountUid`, `accountIdHash`, `iban`, `ibanMasked` (erste 4 + „…“ + letzte 4), `validUntil`, `status: 'aktiv'`, `authState: ''`, `warnedThresholds: ''`, `lastError: ''` und startet `syncBank`.
- [ ] **Step 1: Write the failing tests:** Start speichert `authState` und liefert URL; Rückkehr mit richtigem `state` → neue `sessionId`, `status: 'aktiv'`, `warnedThresholds: ''`, Sync gelaufen; falscher `state` → 400 und alte `sessionId` unverändert; 31 Minuten später → 400; `error: 'access_denied'` → 400 mit „access_denied“ in der Meldung; zwei Konten, gespeicherter Hash passt aufs zweite → zweites gewählt.
- [ ] **Step 2: Run** `node --test test/financeBankAuth.test.js` – Expected: FAIL.
- [ ] **Step 3: Implement** in `bankSync.js`.
- [ ] **Step 4: Run** die Testdatei und `npm test` – Expected: PASS.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Bankzugang aus WOMS neu verbinden (Freigabe, Rueckkehr, Kontowahl)"`
### Task 5: Ablaufwarnung (Vorlage T18) und Jobs
**Files:**
- Create: `server/mail-templates/T18 Bankzugang erneuern.html`
- Modify: `server/services/finance/bankSync.js`
- Modify: `server/index.js` (Jobs im `app.listen`-Callback)
- Test: `test/financeBankConsent.test.js`
**Interfaces:**
- Produces: `checkBankConsent({ now = new Date() } = {}) → Promise<{ sent: number|null }>` – `tage` = volle Tage bis `validUntil` (aufgerundet); Schwellen `[30, 14, 3, 0]`; die kleinste erreichte, noch nicht in `warnedThresholds` stehende Schwelle löst `sendAdminNotice({ template: 'T18', values, blocks })` aus und wird angehängt; `sent` = Schwelle oder `null`.
- T18: Betreff `Bankzugang Finom läuft {{ablauf_text}}` mit `ablauf_text` = „in {{tage}} Tagen ab“ bzw. „heute ab“/„ist abgelaufen“; Platzhalter `{{ablauf_text}}`, `{{gueltig_bis}}` (`29.03.2027`), `{{link}}` (`${config.womsBaseUrl}/finance/einstellungen`); optionaler Block `abgelaufen`. Kopfkommentar und Aufbau wie `T17 Repo zuordnen.html`.
- Jobs (nur wenn `config.enableBanking.enabled`): `syncBank()` 2 Minuten nach Start und alle 6 Stunden; `checkBankConsent()` 3 Minuten nach Start und alle 24 Stunden; beide `.unref()`, Fehler nur `console.warn` mit Präfix `[finanzen]`.
- [ ] **Step 1: Write the failing tests** (mailSink wie in `aboRoutes.test.js`): `validUntil` in 29 Tagen → T18 einmal mit `gueltig_bis`, zweiter Aufruf am selben Tag → keine Mail; 13 Tage → Schwelle 14; abgelaufen → Block `abgelaufen` enthalten; `test/mailTemplates.test.js` bleibt grün (T18 hat Kopfkommentar mit Betreff und Platzhaltern).
- [ ] **Step 2: Run** `node --test test/financeBankConsent.test.js` – Expected: FAIL.
- [ ] **Step 3: Implement** Vorlage, `checkBankConsent`, Jobs in `server/index.js`.
- [ ] **Step 4: Run** `node --test test/financeBankConsent.test.js test/mailTemplates.test.js` und `npm test` – Expected: PASS.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Warnung vor Ablauf des Bankzugangs (T18) und Abruf-Jobs"`
### Task 6: Admin-Router für Bank und Buchungen
**Files:**
- Create: `server/routes/admin/finanzen.js`
- Modify: `server/index.js` (`app.use('/api/admin/finanzen', adminFinanzenRoutes)` bei den übrigen `/api/admin/*`)
- Modify: `server/index.js` CORS-Methoden bleiben `GET, POST, PATCH, DELETE, OPTIONS` (kein PUT verwenden)
- Test: `test/financeRoutesBank.test.js`
**Interfaces:**
- Consumes: Tasks 3–4.
- Produces (alle hinter `requireAppwriteStaff`):
- `GET /api/admin/finanzen/bank` → `{ verbindung: { status, ibanMasked, validUntil, lastSyncAt, lastSyncOk, lastError, balanceBookedCents, balanceAvailableCents, balanceDate, latestBookingDate } | null }` (ohne `sessionId`, `iban`, `authState`)
- `POST /api/admin/finanzen/bank/aktualisieren` → Ergebnis von `syncBank({ psu: { ip: req.ip, userAgent: req.get('User-Agent') } })`; war `lastManualSyncAt` vor < 10 Minuten → 429 `{ error: 'Bitte in N Minuten erneut versuchen.' }`; setzt `lastManualSyncAt`.
- `POST /api/admin/finanzen/bank/verbinden` → `{ url }`
- `POST /api/admin/finanzen/bank/rueckkehr` Body `{ code, state, error }` → `{ ok: true, validUntil }` oder 400 `{ error }`
- `GET /api/admin/finanzen/buchungen` → `{ buchungen: object[] }` (Felder aus `bankTransactions`, ohne `$`-Metafelder außer `$id`)
- Router exportiert zusätzlich nichts; Plan 2 ergänzt Routen in derselben Datei.
- [ ] **Step 1: Write the failing tests** mit `startTestApp({ '/api/admin/finanzen': routes })` und Header `Authorization: Bearer jwt-admin`: ohne JWT → 401; `jwt-staff` → 403; `GET /bank` enthält kein `sessionId`; zweimal `aktualisieren` hintereinander → 200 dann 429; `rueckkehr` mit falschem `state` → 400 mit Meldung; `GET /buchungen` sortiert neueste zuerst.
- [ ] **Step 2: Run** `node --test test/financeRoutesBank.test.js` – Expected: FAIL.
- [ ] **Step 3: Implement** Router und Mount.
- [ ] **Step 4: Run** die Testdatei und `npm test` – Expected: PASS.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Admin-Routen fuer Bankstatus, Abruf, Neuverbindung und Buchungen"`
@@ -0,0 +1,247 @@
# Finanzen Plan 2: Stripe-Rechnungen, Zahlungsabgleich und Auswertung Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Der Portal-Server liest und schreibt Stripe-Rechnungen, schlägt Zuordnungen von Bankeingängen vor, verwaltet Einstellungen und Fixkosten und liefert unter `GET /api/admin/finanzen/uebersicht` alles, was die WOMS-Seite braucht (Kopf, Aufgaben, Vorschau, Steuer, Abos, Altersgruppen).
**Architecture:** Reine Rechenmodule ohne I/O (`matching.js`, `recurring.js`, `forecast.js`, `tax.js`, `todos.js`) plus dünne I/O-Dienste (`stripeFinance.js`, `assign.js`, `settings.js`, `overview.js`) unter `server/services/finance/`; Routen in `server/routes/admin/finanzen.js` (aus Plan 1). Stripe wird nicht gespiegelt, sondern live gelesen (Zwischenspeicher 60 s).
**Tech Stack:** Node 20, Express 4, stripe-node ^19.3 (API 2025-10-29.clover), Appwrite REST, `node:test`, `test/support/fakeStripe.js`.
**Spec:** `docs/superpowers/specs/2026-10-01-finanzen-design.md`
## Global Constraints
- Setzt Plan 1 voraus (gleicher Worktree `dev-finanzen`, Branch `feature/finanzen`). **Nie pushen** ohne OK des Nutzers.
- `npm test` grün vor jedem Commit. Keine neuen Abhängigkeiten.
- Beträge in ganzen Cent; Stripe-Beträge sind schon Cent (EUR).
- Statuswerte nach außen (deutsch): Rechnung `entwurf` | `offen` | `ueberfaellig` | `bezahlt` | `storniert` | `uneinbringlich`; Sicherheit `sicher` | `erwartet`; Dringlichkeit `hoch` | `mittel` | `info`; Zuordnungskategorien `rechnung` | `rechnung-extern` | `stripe-auszahlung` | `fixkosten` | `privat` | `sonstiges`.
- Kleinunternehmer-Grenzen: 2.500.000 Cent (Folgejahr), 10.000.000 Cent (hart); Warnung ab 70 %.
- Gewerbesteuer-Hinweis ab Gewinn > 2.450.000 Cent. ESt-Vorauszahlungstermine: 10.03., 10.06., 10.09., 10.12.
- Standard-Einstellungen: `minBufferCents 0`, `taxReservePercent 30`, `previousYearRevenueCents null`, `estPrepaymentCents 0`, `bankDetailsText ''`.
- Rechnungsfuß = `config.stripe.invoiceFooter` + Zeilenumbruch + `bankDetailsText` (falls gesetzt) + Zeilenumbruch + `Bitte geben Sie bei Überweisung die Rechnungsnummer als Verwendungszweck an.`
- Projektrechnungen: `collection_method: 'send_invoice'`, `days_until_due` Standard 14 (0–60), `auto_advance: false`, `pending_invoice_items_behavior: 'exclude'`, `metadata: { webklarCustomerId, quelle: 'woms', erstelltVon }`.
- Abo-Rechnungen (aus Abos entstanden) lassen sich in WOMS weder stornieren noch löschen (409 „Abo-Rechnungen verwaltet Stripe.“).
- Eine Zuordnung zu einer Rechnung markiert sie mit `paid_out_of_band: true` und ist nicht umkehrbar.
- Hilfsfunktionen aus Plan 1: `berlinDate`, `addDays`, `daysBetween`, `formatDe` (`server/services/finance/dates.js`).
## Review Focus
- **Stripe nicht erreichbar:** `GET /uebersicht` liefert trotzdem Bankteil, Kopf (ohne Stripe-Posten) und Aufgaben aus Bankdaten; `stand.stripe.ok = false` mit Meldung. Test in Task 8.
- **Doppelte Bestätigung:** Zwei schnelle Klicks auf „Zuordnen“ für dieselbe Buchung → die zweite bekommt 409, Stripe wird nur einmal als bezahlt markiert. Test in Task 4.
- **Betrag weicht ab:** Eingang 100 € für Rechnung mit offenen 120 € → Vorschlag „mittel, Betrag weicht ab“, Bestätigen → 409 mit Hinweis auf Teilzahlung. Test in Tasks 3 und 4.
- **Monatsende in Vorschau:** Fixkosten am 31. → nächste Termine 30.11., 31.12., 28.02. (kein Überlauf in den Folgemonat). Test in Task 6.
- **Leere Daten:** Keine Rechnungen, keine Abos, keine Fixkosten, nur Kontostand → Übersicht ohne Fehler, Vorschau = flache Linie, keine Aufgaben außer Bank. Test in Task 8.
---
### Task 1: Stripe-Rechnungen und Auszahlungen lesen
**Files:**
- Modify: `test/support/fakeStripe.js` (Routen `GET /v1/invoices` mit `expand[]=data.customer`, `GET /v1/payouts`; Rückgabeobjekt `payouts: Map`)
- Create: `server/services/finance/format.js`
- Create: `server/services/finance/stripeFinance.js`
- Test: `test/financeStripeRead.test.js`
**Interfaces:**
- Produces:
- `format.js`: `euroDe(cents) → string` (`euroDe(123456) === '1.234,56 €'`, `euroDe(-449) === '−4,49 €'` mit echtem Minuszeichen U+2212, geschütztes Leerzeichen wie `Intl` es liefert).
- `mapInvoice(raw, { today }) → { id, number, status, customerName, customerEmail, webklarCustomerId, createdDate, dueDate, totalCents, remainingCents, paidCents, paidDate, overdueDays, source, hostedUrl, pdfUrl, outOfBand, bankTransactionId }`
- `status`: `draft`→`entwurf`, `open`→`offen` bzw. `ueberfaellig` wenn `dueDate < today`, `paid`→`bezahlt`, `void`→`storniert`, `uncollectible`→`uneinbringlich`
- `webklarCustomerId` = `raw.metadata.webklarCustomerId` oder `raw.customer.metadata.webklarCustomerId` (bei expandiertem Kunden)
- `paidDate` = `raw.metadata.bankBookingDate` oder Berlin-Datum von `status_transitions.paid_at`
- `overdueDays` = `daysBetween(dueDate, today)` bei `ueberfaellig`, sonst 0
- `source` = `'abo'` wenn `subscriptionIdOfInvoice(raw)` (aus `stripeBilling.js`) etwas liefert, sonst `'projekt'`
- `outOfBand` = `Boolean(raw.metadata?.bankTransactionId)`
- `listInvoices({ force = false } = {}) → Promise<Invoice[]>` – `stripe.invoices.list({ limit: 100, created: { gte: <jetzt − 400 Tage> }, expand: ['data.customer'] })` mit Auto-Paging, höchstens 1000; Zwischenspeicher 60 s.
- `listPayouts({ force = false } = {}) → Promise<Array<{ id, amountCents, arrivalDate, status }>>` – `stripe.payouts.list({ limit: 50 })`, 60 s.
- `invalidateFinanceCache() → void`
- [ ] **Step 1: Write the failing tests:** `mapInvoice` für je einen Rohdatensatz pro Stripe-Status; offene Rechnung fällig `2026-09-20`, `today = '2026-10-01'` → `ueberfaellig`, `overdueDays 11`; Rechnung mit `parent.subscription_details.subscription` → `source 'abo'`; `metadata.bankBookingDate '2026-08-05'` → `paidDate '2026-08-05'`, `outOfBand true`; `listInvoices` zweimal in 60 s → ein Stripe-Aufruf (`st.calls`), nach `invalidateFinanceCache()` zwei; `euroDe`-Fälle oben.
- [ ] **Step 2: Run** `node --test test/financeStripeRead.test.js` – Expected: FAIL.
- [ ] **Step 3: Implement** Fake-Routen, `format.js`, `stripeFinance.js` (`getStripe()` aus `stripeBilling.js`).
- [ ] **Step 4: Run** Testdatei und `npm test` – Expected: PASS.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Stripe-Rechnungen und Auszahlungen lesen (deutsch gemappt, 60-s-Cache)"`
### Task 2: Rechnungen schreiben, senden, stornieren, löschen
**Files:**
- Modify: `server/services/stripeBilling.js` (`getOrCreateStripeCustomer` exportieren, sonst unverändert)
- Modify: `test/support/fakeStripe.js` (`POST /v1/invoices`, `POST /v1/invoiceitems`, `POST /v1/invoices/:id` (Metadaten), `POST /v1/invoices/:id/finalize`, `/send`, `/void`, `/pay`, `DELETE /v1/invoices/:id`, `GET /v1/customers/search` falls fehlend; Statusübergänge wie Stripe: finalize nur `draft`→`open`, void nur `open`, pay nur `open`, del nur `draft`, sonst 400)
- Modify: `server/services/finance/stripeFinance.js`
- Test: `test/financeStripeWrite.test.js`
**Interfaces:**
- Consumes: Task 1; `getSettings()` aus Task 5 – bis dahin `bankDetailsText` als Parameter: `invoiceFooter(bankDetailsText) → string`.
- Produces:
- `invoiceFooter(bankDetailsText = '') → string` (Regel in Global Constraints)
- `createProjectInvoice({ customerId, items, daysUntilDue = 14, memo = '', senden = false, createdBy, bankDetailsText = '' }) → Promise<Invoice>`; Validierung (Fehler mit `status 400`): Kunde existiert und `customerStatus` ist nicht `lead`/`lost` („Rechnungen nur für feste Kunden.“); 1–20 Positionen; `description` 1–200 Zeichen; `quantity` ganze Zahl 1–999; `unitAmountCents` ganze Zahl 1–10.000.000; `daysUntilDue` 0–60.
- `sendInvoiceNow(invoiceId) → Promise<Invoice>` (finalize mit `auto_advance: false`, dann send); nur `entwurf`, sonst 409 „Nur Entwürfe können gesendet werden.“
- `voidInvoice(invoiceId) → Promise<Invoice>`; nur `offen`/`ueberfaellig` und `source 'projekt'`, sonst 409.
- `deleteDraft(invoiceId) → Promise<void>`; nur `entwurf` und `source 'projekt'`, sonst 409.
- Alle schreibenden Funktionen rufen `invalidateFinanceCache()`.
- Positionen: `stripe.invoiceItems.create({ customer, invoice, description, quantity, unit_amount_decimal: String(unitAmountCents), currency: 'eur' })`. **Vor dem Implementieren** in der Stripe-API-Referenz für die gepinnte Version prüfen, ob `unit_amount_decimal` beim Invoice Item noch erlaubt ist; falls nicht, `pricing`/`price_data` nach Referenz verwenden und den Test-Assert entsprechend anpassen. Der Test prüft, dass auf der Rechnung Menge und Einzelpreis ankommen.
- [ ] **Step 1: Write the failing tests:** Kunde `c1` (Fixtures) mit zwei Positionen (`'Website-Anpassung', 2, 5000` und `'Domain', 1, 1200`) → `st.calls` enthält `POST /v1/invoices` mit `collection_method=send_invoice`, `days_until_due=14`, `auto_advance=false`, `metadata[quelle]=woms`, `footer` beginnt mit dem §-19-Hinweis und endet mit dem Verwendungszweck-Satz; zwei `POST /v1/invoiceitems`; Ergebnis `status 'entwurf'`; mit `senden: true` → finalize + send, Ergebnis `offen`; Lead-Kunde → 400; 21 Positionen → 400; `voidInvoice` auf Entwurf → 409; `deleteDraft` auf Abo-Rechnung → 409; `sendInvoiceNow` auf offene → 409.
- [ ] **Step 2: Run** `node --test test/financeStripeWrite.test.js` – Expected: FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** Testdatei und `npm test` (insb. `stripeBilling.test.js`, `aboRoutes.test.js` unverändert grün) – Expected: PASS.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Projektrechnungen in Stripe anlegen, senden, stornieren, Entwurf loeschen"`
### Task 3: Zuordnungsvorschläge und wiederkehrende Ausgaben (rein)
**Files:**
- Create: `server/services/finance/matching.js`
- Create: `server/services/finance/recurring.js`
- Test: `test/financeMatching.test.js`, `test/financeRecurring.test.js`
**Interfaces:**
- Produces:
- `normalizeRef(text) → string` (Großbuchstaben, ohne Leerraum, `-`, `_`, `.`, `/`)
- `nameTokens(name) → string[]` (klein, an Nicht-Buchstaben trennen, Umlaute bleiben, Länge ≥ 4, ohne `gmbh gbr ohg mbh und der die das kg ag ug haftungsbeschränkt`)
- `suggestMatches(tx, { invoices, payouts }) → Array<{ kind: 'invoice'|'payout', id, number, confidence: 'hoch'|'mittel'|'niedrig', reason, amountDiffers }>` – nur für `tx.amountCents > 0` ohne `category`; Regeln 1–4 aus Spec „Zuordnungsregeln“ in dieser Reihenfolge; je Rechnung/Auszahlung höchstens ein Vorschlag (der stärkste); sortiert hoch → niedrig; höchstens 3. Nur Rechnungen mit Status `offen`/`ueberfaellig`; Nummern mit normalisierter Länge < 4 ignorieren. `reason` deutsch, z. B. „Rechnungsnummer im Verwendungszweck“, „Betrag und Name passen“, „Nur der Betrag passt“, „Stripe-Auszahlung gleicher Betrag“.
- `detectRecurring(transactions, { fixedCosts = [] } = {}) → Array<{ key, name, amountCents, interval: 'monat'|'quartal'|'jahr', lastDate, count }>` – nur `DBIT`-Buchungen (`amountCents < 0`, `status 'BOOK'`); Gruppierung nach normalisierter Gegenpartei (klein, ohne Ziffern und Satzzeichen, Leerraum zusammengefasst); ≥ 3 Buchungen; Median der Abstände 25–35 Tage → `monat`, 80–100 → `quartal`, 350–380 → `jahr`, sonst keine; Variationskoeffizient der Beträge ≤ 0,15; Gruppen, deren Name einen `counterpartyMatch` einer aktiven Fixkostenposition enthält, entfallen; `amountCents` = Median als positiver Wert.
- [ ] **Step 1: Write the failing tests:**
- Eingang 16879 „EWV SOFTWARE GMBH“ mit Zweck „Rechnung WK-0007 vom …“, offene Rechnung `WK-0007` über 16879 → ein Vorschlag `hoch`; gleiche Rechnung über 20000 → `mittel`, `amountDiffers true`.
- Zweck ohne Nummer, Betrag passt, Kunde „EWV Software GmbH“ → `mittel` („Betrag und Name passen“); Kunde „Döner Nation“ → `niedrig`; zwei offene Rechnungen gleichen Betrags ohne Namensbezug → keine `niedrig`-Vorschläge (Regel 4 nur bei genau einer).
- Gegenpartei „Stripe Technologies Europe Ltd“, Betrag = Auszahlung, Datum 2 Tage nach `arrivalDate` → `payout`, `hoch`; 5 Tage → kein Vorschlag.
- Ausgang (`amountCents < 0`) oder schon zugeordnet → `[]`.
- `detectRecurring`: drei PORKBUN-Buchungen 449 am 27.09., 29.09., 29.09. → `[]`; OPENROUTER 4358 am 16.06., 16.07., 16.08. → `monat`, `amountCents 4358`; Beträge 1000/2000/3000 monatlich → `[]`; aktive Fixkosten mit `counterpartyMatch 'openrouter'` → `[]`.
- [ ] **Step 2: Run** `node --test test/financeMatching.test.js test/financeRecurring.test.js` – Expected: FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** beide Testdateien und `npm test` – Expected: PASS.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Zuordnungsvorschlaege und Erkennung wiederkehrender Ausgaben"`
### Task 4: Buchungen zuordnen
**Files:**
- Create: `server/services/finance/assign.js`
- Modify: `server/routes/admin/finanzen.js` (`GET /buchungen` mit Vorschlägen; `POST /buchungen/:id/zuordnen`; `DELETE /buchungen/:id/zuordnung`)
- Test: `test/financeAssign.test.js`
**Interfaces:**
- Consumes: Plan 1 `bankStore` (`getTransaction`, `updateTransaction`, `listTransactions`); Task 1 (`listInvoices`, `listPayouts`, `invalidateFinanceCache`); Task 3 (`suggestMatches`); Task 5 `getFixedCost(id)` (bis dahin über `getDocument('financeFixedCosts', id)`).
- Produces:
- `assignTransaction(txId, { invoiceId, payoutId, fixedCostId, category, note }) → Promise<Transaction>` – genau eines der vier Ziele, sonst 400. Regeln:
- bereits `category` gesetzt → 409 „Bereits zugeordnet.“ (vor jedem Stripe-Aufruf geprüft; gleichzeitige Aufrufe für dieselbe Buchung mit `withLocks([`finanzen-buchung:${txId}`], fn)` aus `server/services/keyedLock.js` serialisieren)
- `invoiceId`: Eingang; Rechnung per `stripe.invoices.retrieve`; Status `open`, sonst 409; `amount_remaining === tx.amountCents`, sonst 409 „Betrag weicht vom offenen Betrag ab (<euroDe>). Teilzahlungen bitte direkt in Stripe erfassen.“; dann `stripe.invoices.pay(id, { paid_out_of_band: true })`, `stripe.invoices.update(id, { metadata: { bankTransactionId, bankBookingDate } })`, Buchung `{ category: 'rechnung', matchedInvoiceId, matchedInvoiceNumber }`
- `payoutId`: Auszahlung aus `listPayouts({ force: true })`, gleicher Betrag, sonst 409 → `{ category: 'stripe-auszahlung', matchedPayoutId }`
- `fixedCostId`: Ausgang, Position existiert → `{ category: 'fixkosten', fixedCostId }`
- `category` ∈ `rechnung-extern` (nur Eingang), `privat`, `sonstiges` → `{ category }`
- `note` optional, ≤ 500 Zeichen
- `unassignTransaction(txId) → Promise<Transaction>` – `category 'rechnung'` → 409 „Die Rechnung ist in Stripe schon als bezahlt markiert und lässt sich nicht zurücksetzen.“; sonst `category`, `matchedInvoiceId`, `matchedInvoiceNumber`, `matchedPayoutId`, `fixedCostId` leeren.
- `GET /buchungen` → `{ buchungen: Array<Transaction & { vorschlaege: Suggestion[] }>, stripeFehler?: string }`
- Routen: `POST /buchungen/:id/zuordnen` Body = Ziel → `{ buchung }`; `DELETE /buchungen/:id/zuordnung` → `{ buchung }`.
- [ ] **Step 1: Write the failing tests:** Zuordnen zu offener Rechnung → Stripe-Aufrufe `pay` (mit `paid_out_of_band=true`) und Update mit `metadata[bankTransactionId]`, Buchung `category 'rechnung'`; zwei parallele Zuordnungen derselben Buchung (`Promise.allSettled`) → eine 200, eine 409, genau ein `pay`-Aufruf; Betrag weicht ab → 409, kein `pay`; Zuordnung lösen bei `rechnung` → 409, bei `privat` → Felder leer; `rechnung-extern` auf Ausgang → 400; `GET /buchungen` liefert für den EWV-Eingang einen Vorschlag; Stripe-Fehler (`st.failNext({ path: '/v1/invoices', status: 500 })` und `/v1/payouts`) → `GET /buchungen` 200 mit `stripeFehler` und leeren `vorschlaege`.
- [ ] **Step 2: Run** `node --test test/financeAssign.test.js` – Expected: FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** Testdatei und `npm test` – Expected: PASS.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Bankbuchungen Rechnungen, Auszahlungen, Fixkosten und Kategorien zuordnen"`
### Task 5: Einstellungen und Fixkosten
**Files:**
- Create: `server/services/finance/settings.js`
- Modify: `server/routes/admin/finanzen.js` (`GET`/`PATCH /einstellungen`, `GET`/`POST /fixkosten`, `PATCH`/`DELETE /fixkosten/:id`)
- Modify: `server/services/finance/stripeFinance.js` (`createProjectInvoice` holt `bankDetailsText` selbst über `getSettings()`, Parameter entfällt)
- Test: `test/financeSettings.test.js`
**Interfaces:**
- Produces:
- `getSettings() → Promise<Settings>` (Dokument `financeSettings/main` mit Standardwerten zusammengeführt; fehlt es, nur Standardwerte, nichts anlegen)
- `patchSettings(patch) → Promise<Settings>`; erlaubte Schlüssel und Grenzen: `minBufferCents` 0–100.000.000, `taxReservePercent` 0–60, `previousYearRevenueCents` ≥ 0 oder `null`, `estPrepaymentCents` ≥ 0, `bankDetailsText` ≤ 500 Zeichen; unbekannter Schlüssel oder Verstoß → 400 mit Feldname in der Meldung. Legt `main` an, falls nötig.
- `listFixedCosts() → Promise<FixedCost[]>`, `getFixedCost(id)`, `createFixedCost(input)`, `updateFixedCost(id, patch)`, `deleteFixedCost(id)`; Validierung: `name` 1–120, `amountCents` ganze Zahl > 0, `interval` ∈ `monat|quartal|jahr`, `nextDate` `YYYY-MM-DD`, `counterpartyMatch` ≤ 120 (wird klein gespeichert), `active` Standard `true`.
- [ ] **Step 1: Write the failing tests:** ohne Dokument → Standardwerte; `PATCH { taxReservePercent: 25 }` → 25 und Dokument angelegt; `{ taxReservePercent: 80 }` → 400 mit „taxReservePercent“; `{ foo: 1 }` → 400; Fixkosten anlegen mit `counterpartyMatch 'PORKBUN'` → gespeichert `porkbun`; `interval 'woche'` → 400; Rechnung über Task-2-Funktion nach `PATCH { bankDetailsText: 'IBAN DE00 …' }` → Fuß enthält die IBAN-Zeile. Routen: `jwt-staff` → 403.
- [ ] **Step 2: Run** `node --test test/financeSettings.test.js` – Expected: FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** Testdatei und `npm test` – Expected: PASS.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Einstellungen und Fixkostenliste"`
### Task 6: Liquiditätsvorschau (rein)
**Files:**
- Create: `server/services/finance/forecast.js`
- Test: `test/financeForecast.test.js`
**Interfaces:**
- Produces: `buildForecast({ today, horizonDays = 30, balanceCents, payouts = [], invoices = [], subscriptions = [], fixedCosts = [], settings }) → { posten: Array<{ date, label, amountCents, sicherheit }>, minSicher: { amountCents, date }, minErwartet: { amountCents, date }, endeSicherCents, endeErwartetCents, fixkosten30Cents, annahme }`
- `sicher`: Auszahlungen `pending`/`in_transit` mit `arrivalDate` im Zeitraum (+); aktive Fixkosten (−) an allen Terminen ab `nextDate` (vor heute liegende Termine vorspulen) im Rhythmus +1/+3/+12 Monate, Tag auf Monatsende begrenzt, aber vom ursprünglichen Tag aus gerechnet (31. → 30.11. → 31.12.); ESt-Vorauszahlung (−) am 10.03./10.06./10.09./10.12., wenn `estPrepaymentCents > 0`.
- `erwartet`: Rechnungen `offen` (+ `remainingCents`) am `dueDate`, `ueberfaellig` am `today + 14` mit Label-Zusatz „(überfällig, angenommen)“; Abos mit Status `active` (+ `priceAmount`) an `currentPeriodEnd` und monatlich danach, jeweils + 4 Tage (Auszahlung), ohne Termine ab `cancelAt` bzw. nach `currentPeriodEnd` bei `cancelAtPeriodEnd`.
- Zeitraum: `today` bis `today + horizonDays` einschließlich; Posten nach Datum, bei gleichem Datum Abflüsse zuerst.
- `minSicher`/`minErwartet`: tiefster laufender Saldo der jeweiligen Linie inklusive Startwert (`date = today`, falls nie tiefer); bei Gleichstand das früheste Datum.
- `fixkosten30Cents` = Summe der `sicher`-Abflüsse (positiv).
- `annahme` = `'Erwartet: offene Rechnungen zum Fälligkeitstag, überfällige in 14 Tagen, Abo-Verlängerungen 4 Tage nach Abbuchung, vor Stripe-Gebühren.'`
- [ ] **Step 1: Write the failing tests** (`today '2026-10-01'`, `balanceCents 10893`):
- nur Kontostand → `posten []`, beide Minima 10893 am `2026-10-01`, `fixkosten30Cents 0`.
- Fixkosten 449 monatlich ab `2026-09-27` → Termin `2026-10-27` (−449), `minSicher 10444` am `2026-10-27`.
- Fixkosten am 31. ab `2026-10-31`, Horizont 120 → Termine `2026-10-31`, `2026-11-30`, `2026-12-31`, `2027-01-31`.
- Rechnung offen 50000 fällig `2026-10-14` → `erwartet` am `2026-10-14`, `endeErwartetCents 60893`, `endeSicherCents 10893`.
- Rechnung überfällig → am `2026-10-15` mit „(überfällig, angenommen)“.
- Abo 2000, `currentPeriodEnd 2026-10-05` → Posten am `2026-10-09`; mit `cancelAtPeriodEnd: true` und `currentPeriodEnd 2026-10-05` → kein Posten am 09.11.
- `estPrepaymentCents 50000` → Posten am `2026-12-10` bei Horizont 90, keiner bei 30.
- Auszahlung `in_transit` 1500 am `2026-10-03` → `sicher` +1500.
- [ ] **Step 2: Run** `node --test test/financeForecast.test.js` – Expected: FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** Testdatei und `npm test` – Expected: PASS.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Liquiditaetsvorschau 30 Tage (sicher/erwartet)"`
### Task 7: Steuer und Aufgabenliste (rein)
**Files:**
- Create: `server/services/finance/tax.js`
- Create: `server/services/finance/todos.js`
- Test: `test/financeTax.test.js`, `test/financeTodos.test.js`
**Interfaces:**
- Produces:
- `computeTax({ today, transactions, invoices, settings }) → { jahr, einnahmenCents, ausgabenCents, gewinnCents, ruecklageCents, prozent, gewerbesteuerHinweis, kleinunternehmer: { jahrCents, vorjahrCents, grenzeFolgejahrCents: 2500000, grenzeHartCents: 10000000, anteilFolgejahr }, naechsteVorauszahlung: { date, amountCents } | null }`
- Einnahmen/Ausgaben: Buchungen `BOOK` im Kalenderjahr von `today`, ohne `category 'privat'`; Eingänge positiv summiert, Ausgänge als positiver Betrag.
- `ruecklageCents` = `Math.round(max(0, gewinnCents) * prozent / 100)`
- `kleinunternehmer.jahrCents` = Summe `paidCents` der Stripe-Rechnungen mit `paidDate` im Jahr + Buchungen `category 'rechnung-extern'` im Jahr (Stripe-Auszahlungen zählen nicht, sonst doppelt).
- `anteilFolgejahr` = `jahrCents / 2500000`
- `buildTodos({ today, now, connection, invoices, transactions, subscriptions, recurring, tax }) → Array<{ art, dringlichkeit, titel, text, datum, aktion }>` nach Spec-Tabelle „Zu erledigen – Arten“, sortiert hoch → mittel → info, darin nach `datum` aufsteigend. `aktion.typ` ∈ `bank-verbinden`, `bank-aktualisieren`, `rechnung-oeffnen` (`invoiceId`, `hostedUrl`), `zuordnen` (`transactionId`), `kunde-oeffnen` (`customerId`), `fixkosten-uebernehmen` (`vorschlag`), `einstellungen`. Texte mit `euroDe` und `formatDe`. `transactions` enthalten bereits `vorschlaege`; bei Vorschlag `hoch` endet `text` mit „Vorschlag: Rechnung <Nr.>“ bzw. „Vorschlag: Stripe-Auszahlung“.
- [ ] **Step 1: Write the failing tests:**
- Steuer: Eingänge 16879 (`rechnung-extern`) und Ausgänge 449+449+449+50+50+50+131+4358 im Jahr 2026, eine Buchung `privat` −10000 → `einnahmenCents 16879`, `ausgabenCents 5986`, `gewinnCents 10893`, `ruecklageCents 3268` (30 %), `kleinunternehmer.jahrCents 16879`; Gewinn negativ → Rücklage 0; Stripe-Rechnung bezahlt 2026-10-02 über 5000 → `jahrCents` + 5000; `estPrepaymentCents 50000` und `today 2026-10-01` → `naechsteVorauszahlung { date: '2026-12-10', amountCents: 50000 }`.
- Aufgaben: Zugang gültig bis `2026-10-20` (19 Tage) → `bank-zugang` `mittel`; bis `2026-10-10` → `hoch`; `status 'abgelaufen'` → „Bankzugang abgelaufen“ `hoch`; `lastSyncAt` vor 25 h → `bank-abruf`; Rechnung 31 Tage überfällig → `hoch`, 5 Tage → `mittel`; ungeordneter Eingang → `zuordnung` mit `aktion.transactionId`; Abo `past_due` → `abo-zahlung` `hoch`; Entwurf von vor 4 Tagen → `entwurf` `info`, von gestern → keine Aufgabe; `anteilFolgejahr 0.72` → `kleinunternehmer` `mittel`, `1.05` → `hoch`; Reihenfolge hoch vor mittel vor info.
- [ ] **Step 2: Run** `node --test test/financeTax.test.js test/financeTodos.test.js` – Expected: FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** beide Testdateien und `npm test` – Expected: PASS.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Steuerruecklage, Kleinunternehmer-Zaehler und Aufgabenliste"`
### Task 8: Übersicht und Rechnungsrouten
**Files:**
- Create: `server/services/finance/overview.js`
- Modify: `server/routes/admin/finanzen.js` (`GET /uebersicht`, `GET /rechnungen`, `POST /rechnungen`, `POST /rechnungen/:id/senden`, `POST /rechnungen/:id/stornieren`, `DELETE /rechnungen/:id`)
- Test: `test/financeOverview.test.js`
**Interfaces:**
- Consumes: alle vorigen Tasks; `listDocuments(config.collections.subscriptions, [Query.limit(500)])` und Kundennamen per `getDocument(config.collections.customers, id)` (nur für Abo-Kunden).
- Produces:
- `buildOverview({ now = new Date() } = {}) → Promise<Overview>` mit
- `stand: { bank: <öffentliche Verbindung wie GET /bank>, stripe: { ok, fehler?, abgerufenUm } }`
- `kopf: { kontostandCents, verfuegbarCents, aufschluesselung: { kontostandCents, steuerRuecklageCents, fixkosten30Cents, mindestpufferCents }, tiefsterSicher, tiefsterErwartet }` mit `verfuegbarCents = kontostand − steuerRuecklage − fixkosten30 − mindestpuffer`
- `aufgaben` (Task 7), `vorschau` (Task 6), `steuer` (Task 7)
- `abos: { monatlichCents, zahlendeKunden, gefaehrdetCents, liste: Array<{ customerId, kunde, produkt, status, betragCents, naechsteAbbuchung }> }` – `monatlichCents` = Summe `priceAmount` der Status `active` und `past_due`; `gefaehrdetCents` = Summe `past_due`; `zahlendeKunden` = verschiedene `customerId` mit `active`; `produkt` = `productKey ?? planKey`
- `rechnungen: { altersgruppen: Array<{ key: 'nicht-faellig'|'1-30'|'31-60'|'ueber-60', anzahl, summeCents }>, entwuerfe }` aus `offen`/`ueberfaellig`
- Stripe-Fehler: Rechnungen/Auszahlungen als leere Listen weiterrechnen, `stand.stripe.ok false`.
- `GET /rechnungen?status=offen|ueberfaellig|bezahlt|entwurf|alle&kunde=<customerId>` → `{ rechnungen }` (Standard `alle`, neueste zuerst; `kunde` filtert auf `webklarCustomerId`)
- `POST /rechnungen` Body `{ customerId, items: [{ description, quantity, unitAmountCents }], daysUntilDue, memo, senden }` → `{ rechnung }`; `createdBy` = `req.appwriteUser.email`
- `POST /rechnungen/:id/senden` → `{ rechnung }`; `POST /rechnungen/:id/stornieren` → `{ rechnung }`; `DELETE /rechnungen/:id` → `{ ok: true }`
- [ ] **Step 1: Write the failing tests** (Appwrite-Mock mit Verbindung, 9 Buchungen, Einstellungen; Fake-Stripe mit einer offenen Projekt-, einer überfälligen und einer bezahlten Rechnung; ein Abo `active` 2000 und ein `past_due` 5000):
- `GET /uebersicht` → `kopf.kontostandCents 10893`; `verfuegbarCents` = 10893 − Rücklage − Fixkosten30 − Puffer (konkrete Zahl aus den Testdaten ausrechnen und festschreiben); `abos.monatlichCents 7000`, `gefaehrdetCents 5000`, `zahlendeKunden 1`; Altersgruppen enthalten die überfällige Rechnung in der richtigen Gruppe; `aufgaben` enthält `ueberfaellig` und `abo-zahlung`.
- Leere Daten (keine Rechnungen, Abos, Fixkosten) → 200, `vorschau.posten []`, `aufgaben` ohne Rechnungs-/Abo-Arten.
- Stripe 500 → 200, `stand.stripe.ok false`, `kopf.kontostandCents 10893`.
- `GET /rechnungen?status=ueberfaellig` → nur die überfällige; `?kunde=c1` → nur Rechnungen mit `webklarCustomerId c1`.
- `POST /rechnungen` → 200 Entwurf; `POST /rechnungen/:id/senden` → `offen`; `DELETE` auf offene → 409.
- `jwt-staff` auf `/uebersicht` → 403.
- [ ] **Step 2: Run** `node --test test/financeOverview.test.js` – Expected: FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** Testdatei und `npm test` – Expected: PASS (gesamte Suite).
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Uebersicht und Rechnungsrouten fuer WOMS"`
@@ -0,0 +1,208 @@
# Finanzen Plan 3: WOMS-Oberfläche und Livegang Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Die WOMS-Seite „Finanzen“ zeigt Stripe-Rechnungen und das Finom-Konto nach dem Seitenaufbau der Spec, Projektrechnungen werden in WOMS geschrieben, InvoiceNinja verschwindet aus der Oberfläche, und das Ganze geht kontrolliert live.
**Architecture:** WOMS spricht ausschließlich die Portal-Routen `/api/admin/finanzen/*` aus Plan 1 und 2 an (Appwrite-JWT, wiederverwendeter JWT-Cache wie Serverpuls). Reine Formatierungs- und Parse-Logik in `src/lib/finance*.js` (mit `node --test` geprüft), Darstellung in `src/components/finance/*` und drei Seiten.
**Tech Stack:** React 18 + Vite (WOMS), vorhandene UI-Bausteine `src/components/ui` (PageHeader, Card, Button, Badge, Tabs, EmptyState, Field, Kpi, AutocompleteInput), `styles/global.css`, `node --test` mit `tests/unit/load.mjs`.
**Spec:** `docs/superpowers/specs/2026-10-01-finanzen-design.md` (im Portal-Repo, Worktree `kundenbereich.webklar.com/dev-finanzen`)
## Global Constraints
- WOMS-Worktree: `/home/webklar/apps/ticket.webklar.com/dev-finanzen`, Branch `feature/finanzen` von `origin/test`, `node_modules` als Symlink auf `../build/node_modules`. `node_modules` ist in Git getrackt: **nur einzelne Dateien adden, nie `git add -A`**. **Nie pushen** ohne OK des Nutzers (der Webhook stellt jede gepushte Branch live).
- Tests: `npm test` (`node --test tests/*.test.mjs tests/unit/*.test.mjs`) grün vor jedem Commit. Reine Logik gehört nach `src/lib/` (ohne Appwrite-Import), damit `loadModule` sie bündeln kann.
- Build-Prüfung unter Speichergrenze: `systemd-run --scope -p MemoryMax=1500M -p MemorySwapMax=0 npm run build` im Worktree.
- Portal-Basis-URL: `import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'`; Auth `Authorization: Bearer <JWT>` über `createJwtCache` + `fetchWithRetry` aus `src/lib/serverpulsAuth.js`. Kein `PUT` (Portal-CORS erlaubt GET, POST, PATCH, DELETE).
- Nur Admins (`isAdmin` aus `useAuth()`) sehen Menüpunkt und Seiten; Nicht-Admins werden auf `/tickets` umgeleitet.
- Darstellung: Beträge de-DE mit `€`, rechtsbündig, Klasse mit `font-variant-numeric: tabular-nums`; Daten `TT.MM.JJJJ`; nie Farbe allein (immer Text oder Symbol dazu); keine Prozent-Veränderungen, keine Diagramme außer optionaler Treppenlinie (nicht in diesem Plan).
- Texte für Nutzer auf Deutsch mit echten Umlauten.
- InvoiceNinja-Server und `ticket-integrations` bleiben unangetastet.
- Seitenblöcke, Aufgabenarten und Zuordnungsregeln exakt wie in der Spec.
## Review Focus
- **Rückkehrseite doppelt geladen** (React StrictMode, Neuladen, Zurück-Taste): der Code darf nur einmal an `/bank/rueckkehr` gehen; zweiter Versuch zeigt die Serverantwort verständlich statt „Fehler“. Test in Task 2 (`parseCallback` + Einmal-Schutz in der Seite).
- **Betragseingabe** „1.234,50“, „50“, „50,5“, „ 12 “, „-5“, „abc“ → 123450, 5000, 5050, 1200, Fehler, Fehler. Test in Task 1.
- **Portal nicht erreichbar oder 403** (Admin ohne 2FA-Sitzung): Seite zeigt eine Fehlerkarte mit Text und „Erneut versuchen“, kein weißer Bildschirm. Test in Task 7 (Smoke).
- **Unumkehrbare Zuordnung:** Bestätigen einer Rechnungszuordnung verlangt einen zweiten Klick mit dem Hinweis „wird in Stripe als bezahlt markiert, nicht umkehrbar“. Test in Task 7 (Smoke).
- **Kunde ohne E-Mail:** „Rechnung schreiben“ für diesen Kunden ist gesperrt mit Hinweis „E-Mail fehlt“ (Stripe verschickt per Mail). Test in Task 6 (`canInvoice`).
---
### Task 1: Formatierung, Parser und API-Client
**Files:**
- Create: `src/lib/financeFormat.js`
- Create: `src/lib/financeApi.js`
- Test: `tests/unit/financeFormat.test.mjs`
**Interfaces:**
- Produces (`financeFormat.js`, rein):
- `euro(cents) → string` (`euro(123456) === '1.234,56 €'`, `euro(null) === '–'`)
- `euroWhole(cents) → string` (`euroWhole(10893) === '109 €'`)
- `signedEuro(cents) → string` (`'+168,79 €'`, `'−4,49 €'` mit U+2212)
- `dateDe('2027-03-29') === '29.03.2027'`; `sinceLabel(iso, now) → 'gerade eben' | 'vor 12 Min.' | 'vor 3 Std.' | 'vor 2 Tagen'`
- `parseEuro(text) → number` (Cent) oder wirft `Error('Ungültiger Betrag')`; erlaubt Tausenderpunkte und Dezimalkomma, keine negativen Werte
- `INVOICE_STATUS = { entwurf: { label: 'Entwurf', tone: 'muted' }, offen: { label: 'Offen', tone: 'info' }, ueberfaellig: { label: 'Überfällig', tone: 'warn' }, bezahlt: { label: 'Bezahlt', tone: 'ok' }, storniert: { label: 'Storniert', tone: 'muted' }, uneinbringlich: { label: 'Uneinbringlich', tone: 'danger' } }`
- `CATEGORY_LABEL = { rechnung: 'Rechnung', 'rechnung-extern': 'Einnahme ohne Stripe-Rechnung', 'stripe-auszahlung': 'Stripe-Auszahlung', fixkosten: 'Fixkosten', privat: 'Privat/Umbuchung', sonstiges: 'Sonstiges', '': 'Offen' }`
- `URGENCY = { hoch: { label: 'Dringend', tone: 'danger' }, mittel: { label: 'Bald', tone: 'warn' }, info: { label: 'Hinweis', tone: 'muted' } }`
- `AGE_GROUP_LABEL = { 'nicht-faellig': 'Nicht fällig', '1-30': '1–30 Tage', '31-60': '31–60 Tage', 'ueber-60': 'Über 60 Tage' }`
- `pageOf(list, page, size = 50) → { items, page, pages }`
- `canInvoice(customer) → { ok: boolean, reason?: string }` – `customerStatus` `lead`/`lost` → „Noch kein fester Kunde“; ohne `email` → „E-Mail fehlt“.
- Produces (`financeApi.js`): `getOverview()`, `getInvoices({ status, kunde })`, `createInvoice(body)`, `sendInvoice(id)`, `voidInvoice(id)`, `deleteInvoice(id)`, `getTransactions()`, `assignTransaction(id, target)`, `unassignTransaction(id)`, `getBank()`, `refreshBank()`, `startBankAuth()`, `completeBankAuth({ code, state, error })`, `getSettings()`, `patchSettings(patch)`, `getFixedCosts()`, `createFixedCost(body)`, `updateFixedCost(id, patch)`, `deleteFixedCost(id)` – Pfade und Methoden wie in Plan 1/2; Fehler werfen `Error(data.error || 'API-Fehler <status>')` mit `.status`.
- [ ] **Step 1: Worktree anlegen**
```bash
cd /home/webklar/apps/ticket.webklar.com && git -C build fetch -q origin && git -C build worktree add ../dev-finanzen -b feature/finanzen origin/test && ln -s ../build/node_modules dev-finanzen/node_modules
```
- [ ] **Step 2: Write the failing tests** in `tests/unit/financeFormat.test.mjs` mit allen Beispielwerten oben (inkl. Review-Focus-Fälle für `parseEuro` und `canInvoice`).
- [ ] **Step 3: Run** `npm test` – Expected: FAIL (Modul fehlt).
- [ ] **Step 4: Implement** beide Dateien (`financeApi.js` ohne Test, folgt dem Muster von `serverpulsApi.js`).
- [ ] **Step 5: Run** `npm test` – Expected: PASS.
- [ ] **Step 6: Commit** `git add src/lib/financeFormat.js src/lib/financeApi.js tests/unit/financeFormat.test.mjs && git commit -m "Finanzen: Formatierung, Betragsparser und Portal-API-Client"`
### Task 2: Zugriff nur für Admins, Routen, Rückkehrseite
**Files:**
- Create: `src/components/AdminRoute.jsx`
- Create: `src/lib/bankCallback.js`
- Create: `src/pages/BankCallbackPage.jsx`
- Modify: `src/App.jsx` (Routen `/finance`, `/finance/einstellungen`, `/finanzen/bank-callback`, alle in `ProtectedRoute` + `AdminRoute`)
- Modify: `src/components/Navbar.jsx:64` (Eintrag „Finanzen“ nur bei `isAdmin`)
- Test: `tests/unit/bankCallback.test.mjs`
**Interfaces:**
- Produces:
- `AdminRoute({ children })` – wartet auf geladenen Nutzer, sonst `<Navigate to="/tickets" replace />` für Nicht-Admins.
- `parseCallback(search) → { code, state, error } | null` (`null`, wenn weder `code` noch `error`); `error_description` wird an `error` angehängt („access_denied: …“).
- `BankCallbackPage`: liest `location.search`, ruft `completeBankAuth` genau einmal (`useRef`-Schutz), zeigt „Bankzugang wird eingerichtet …“, dann Erfolg „Bankzugang erneuert, gültig bis TT.MM.JJJJ“ oder die Fehlermeldung des Servers, jeweils mit Knopf „Zu den Finanzen“ (`/finance`). Entfernt danach `code`/`state` aus der Adresszeile (`navigate('/finanzen/bank-callback', { replace: true, state: result })`).
- [ ] **Step 1: Write the failing tests:** `parseCallback('?state=a&code=b') → { code: 'b', state: 'a', error: '' }`; `'?state=a&error=access_denied&error_description=abgebrochen' → error 'access_denied: abgebrochen'`; `'' → null`.
- [ ] **Step 2: Run** `npm test` – Expected: FAIL.
- [ ] **Step 3: Implement** Dateien und Routen.
- [ ] **Step 4: Run** `npm test` und den Build-Befehl aus Global Constraints – Expected: PASS, Build ohne Fehler.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: nur fuer Admins, Rueckkehrseite fuer die Bankfreigabe"`
### Task 3: Seitenkopf – Statuszeile, Liquidität, Zu erledigen
**Files:**
- Modify: `src/pages/FinancePage.jsx` (komplett neu, InvoiceNinja-Inhalt entfällt)
- Create: `src/components/finance/StatusLine.jsx`, `LiquidityHead.jsx`, `TodoList.jsx`
- Create: `src/hooks/useFinanceOverview.js` (`{ data, loading, error, reload }` über `getOverview`)
- Modify: `src/styles/global.css` (Klassen `.fin-amount` mit `tabular-nums` und rechtsbündig, `.fin-todo`, `.fin-status`; Farben nur über vorhandene Variablen)
**Interfaces:**
- Consumes: Task 1, Übersicht-Format aus Plan 2 Task 8.
- Produces:
- `StatusLine({ stand, onRefresh, refreshing })` – Text nach Spec Block 0; „Aktualisieren“ ruft `refreshBank`, zeigt 429-Meldung des Servers als Hinweis.
- `LiquidityHead({ kopf })` – drei `Kpi`: „Kontostand“ (`euroWhole`), „Verfügbar nach Rücklagen“ (`euroWhole`, Unterzeile „Aufschlüsselung“ klappt `kontostand − Steuer-Rücklage − Fixkosten 30 Tage − Mindestpuffer` als Liste auf), „Tiefster Stand in 30 Tagen“ (`sicher: … am TT.MM.` / `erwartet: … am TT.MM.`).
- `TodoList({ aufgaben, onAction })` – höchstens 7 Zeilen, danach „Alle N anzeigen“; je Zeile Badge (`URGENCY`), Titel, Text, ein Knopf. `onAction(aktion)` wird in `FinancePage` behandelt: `bank-verbinden` → `startBankAuth()` und `window.location.assign(url)`; `bank-aktualisieren` → Refresh; `rechnung-oeffnen` → `hostedUrl` in neuem Tab bzw. Sprung zum Rechnungsblock; `zuordnen` → öffnet Zuordnen-Dialog (Task 5); `kunde-oeffnen` → `/customers/:id`; `fixkosten-uebernehmen` → Bestätigung, dann `createFixedCost({ name, amountCents, interval, nextDate: <lastDate + Rhythmus>, counterpartyMatch: key })`; `einstellungen` → `/finance/einstellungen`. Leerzustand: `EmptyState` „Nichts offen“.
- `FinancePage`: Ladezustand, Fehlerkarte mit Meldung und „Erneut versuchen“, danach Blöcke 0–2.
- [ ] **Step 1: Implement** Komponenten, Hook, Seite (Blöcke 0–2).
- [ ] **Step 2: Run** `npm test` und Build – Expected: PASS.
- [ ] **Step 3: Commit** `git commit -m "Finanzen: Statuszeile, Liquiditaetskopf und Aufgabenliste"`
### Task 4: Vorschau, Steuer, Abos
**Files:**
- Create: `src/components/finance/ForecastTable.jsx`, `TaxCard.jsx`, `AboCard.jsx`
- Modify: `src/pages/FinancePage.jsx` (Blöcke 3–5; Desktop: Vorschau links, Steuer rechts; Abos halbe Breite)
**Interfaces:**
- Produces:
- `ForecastTable({ vorschau })` – Spalten Datum, Posten, „sicher“/„erwartet“ (Text, nicht nur Farbe), Betrag (`signedEuro`), Saldo sicher, Saldo erwartet (laufend ab Kontostand); darunter `vorschau.annahme`; leer → „Keine Zahlungen in den nächsten 30 Tagen bekannt.“
- `TaxCard({ steuer })` – Rücklage (`euro`, „30 % von <Gewinn> Gewinn 2026“), Kleinunternehmer-Zeile „<Betrag> von 25.000 € (2026)“ und „Grenze 100.000 €“, Vorjahr nur wenn gesetzt, Gewerbesteuer-Hinweis nur wenn `gewerbesteuerHinweis`, nächste ESt-Vorauszahlung nur wenn gesetzt.
- `AboCard({ abos })` – „Abo-Einnahmen pro Monat“, „zahlende Kunden“, „gefährdet“ (nur > 0), Liste Kunde · Produkt · Status · Betrag · nächste Abbuchung; leer → „Noch keine Abos.“
- [ ] **Step 1: Implement.**
- [ ] **Step 2: Run** `npm test` und Build – Expected: PASS.
- [ ] **Step 3: Commit** `git commit -m "Finanzen: Vorschau, Steuer und Abos"`
### Task 5: Rechnungen, Rechnung schreiben, Buchungen, Zuordnen
**Files:**
- Create: `src/components/finance/InvoiceTable.jsx`, `InvoiceCreateModal.jsx`, `TransactionTable.jsx`, `AssignDialog.jsx`
- Modify: `src/pages/FinancePage.jsx` (Blöcke 6–7, Dialoge)
**Interfaces:**
- Consumes: Task 1 (`financeApi`, `financeFormat`), `useCustomers` (vorhanden) für die Kundenauswahl.
- Produces:
- `InvoiceTable({ altersgruppen, customerId = null, onChanged })` – lädt `getInvoices({ status, kunde: customerId })`; Filter `.seg` Offen/Überfällig/Bezahlt/Entwürfe/Alle (Standard Offen); Altersgruppen-Zeile mit Anzahl und Summe; Spalten nach Spec Block 6; Aktionen je Status: Entwurf → „Senden“, „Löschen“ (mit Rückfrage); offen/überfällig → „Zahlungsseite“, „PDF“, „Stornieren“ (Rückfrage, nur `source 'projekt'`); bezahlt → „PDF“, bei `outOfBand` Hinweis „per Überweisung“. Knopf „Rechnung schreiben“.
- `InvoiceCreateModal({ open, onClose, onCreated, fixedCustomer = null })` – Kunde (AutocompleteInput über Kunden mit `canInvoice(c).ok`, gesperrte mit Grund), Positionen (Beschreibung, Menge, Einzelpreis in Euro → `parseEuro`), „+ Position“, Zahlungsziel (Standard 14 Tage), Notiz; Summe; Hinweis „Gemäß § 19 UStG ohne Umsatzsteuer.“; Knöpfe „Als Entwurf speichern“ und „Senden“ (`senden: true`); Serverfehler im Dialog anzeigen.
- `TransactionTable({ onAssign })` – `getTransactions()`, 50 je Seite (`pageOf`), neueste zuerst; Spalten Datum, Gegenpartei + Verwendungszweck (zweizeilig), Betrag (`signedEuro`), Zuordnung (`CATEGORY_LABEL`, bei `rechnung` mit Nummer); offene Eingänge mit stärkstem Vorschlag als Text und Knopf „Zuordnen“.
- `AssignDialog({ transaction, invoices, onClose, onDone })` – Auswahl: Vorschläge, andere offene Rechnung, Stripe-Auszahlung (aus Vorschlägen), Fixkosten (nur Ausgänge, aus `getFixedCosts`), Kategorie (Einnahme ohne Stripe-Rechnung nur bei Eingang, Privat/Umbuchung, Sonstiges), Notiz; bei Rechnung zweistufig: erst „Zuordnen“, dann Bestätigungstext „Die Rechnung <Nr.> wird in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.“ mit „Ja, als bezahlt markieren“; bei bestehender Zuordnung (außer `rechnung`) Knopf „Zuordnung lösen“. Serverfehler (409 Betrag weicht ab usw.) im Dialog.
- [ ] **Step 1: Implement.**
- [ ] **Step 2: Run** `npm test` und Build – Expected: PASS.
- [ ] **Step 3: Commit** `git commit -m "Finanzen: Rechnungen schreiben und verwalten, Buchungen zuordnen"`
### Task 6: Einstellungen, Kunden-Tab, InvoiceNinja aus der Oberfläche
**Files:**
- Create: `src/pages/FinanceSettingsPage.jsx`
- Create: `src/components/finance/CustomerInvoices.jsx`
- Modify: `src/pages/CustomerDetailPage.jsx` (Tab „Rechnungen“ → `CustomerInvoices`; Ninja-Sync beim Speichern `:133-156` entfernen; Karte „Rechnungsdaten (für InvoiceNinja)“ → „Rechnungsdaten“; Badge „verknüpft“ entfernen; `syntheticTicket` entfernen)
- Modify: `src/components/TicketCard.jsx` (Tab `invoices` und `InvoicePanel` entfernen)
- Modify: `src/pages/CustomersPage.jsx` (Untertitel „Sync mit InvoiceNinja“ und Badge „Ninja“ entfernen)
- Modify: `src/lib/leads.js` (`ninjaPayload`, `syncCustomerToNinja` und den Ninja-Teil von `upgradeToCustomer` entfernen; `isInvoiceReady` entfernen, falls danach unbenutzt)
- Delete: `src/components/InvoicePanel.jsx`, `src/hooks/useInvoices.js`, `src/hooks/useFinanceSummary.js`, `src/lib/integrationsApi.js` (nur wenn `grep -rn integrationsApi src` danach leer ist)
**Interfaces:**
- Produces:
- `FinanceSettingsPage` (`/finance/einstellungen`): Formular Einstellungen (Mindestpuffer und Vorjahresumsatz/ESt-Vorauszahlung in Euro über `parseEuro`, Rücklage in %, Bankverbindungstext) mit „Speichern“; Fixkostenliste (anlegen, bearbeiten, aktiv/inaktiv, löschen mit Rückfrage); Karte „Bankzugang“ (Status, IBAN maskiert, gültig bis, letzter Abruf, Fehler) mit „Jetzt abrufen“ und „Neu verbinden“; Link zurück zu `/finance`.
- `CustomerInvoices({ customer })` – `canInvoice(customer)`: nicht ok → `EmptyState` mit Grund und „Daten erfassen“ (wechselt zum Übersicht-Tab); ok → `InvoiceTable({ customerId })` + `InvoiceCreateModal({ fixedCustomer: customer })`.
- [ ] **Step 1: Implement** Seite und Komponente.
- [ ] **Step 2: Remove** InvoiceNinja-Stellen wie oben; danach `grep -rni "ninja\|invoicePanel\|useInvoices\|useFinanceSummary" src` – Expected: keine Treffer.
- [ ] **Step 3: Run** `npm test` und Build – Expected: PASS.
- [ ] **Step 4: Commit** (geänderte und gelöschte Dateien einzeln: `git add <dateien>` und `git rm <dateien>`) `git commit -m "Finanzen: Einstellungen, Rechnungen am Kunden, InvoiceNinja aus der Oberflaeche entfernt"`
### Task 7: Oberflächentest (Smoke) gegen Nachbau
**Files:**
- Create (nicht committen): Scratchpad `finanzen-ui/` mit `fixtures.json` (Übersicht, Rechnungen, Buchungen aus Plan-2-Testdaten) und `smoke.mjs`
**Interfaces:**
- Consumes: gebautes `dist/` aus dem Worktree.
- [ ] **Step 1: Write** `smoke.mjs`: statischer Server für `dist/` (SPA-Fallback auf `index.html`) unter einer festen Herkunft; Playwright (`headless shell`, unter `systemd-run --scope -p MemoryMax=700M`) mit `page.route` für `**/v1/account*` (Admin-Nutzer mit Label `admin`), `**/v1/account/jwt*` und `https://project.webklar.com/api/admin/finanzen/**` aus `fixtures.json`. Prüfungen:
1. `/finance` zeigt „Kontostand“ mit „109 €“, „Zu erledigen“ mit der Aufgabe „Zahlungseingang zuordnen“, Vorschau-Annahmezeile, Rechnungsfilter „Offen“.
2. Zuordnen eines Eingangs zu einer Rechnung: nach erstem Klick erscheint der Satz „lässt sich nicht rückgängig machen“, erst der zweite Klick sendet `POST …/zuordnen` (Anfrage mitzählen).
3. Übersicht antwortet 403 → Fehlerkarte mit „Erneut versuchen“, kein leerer Bildschirm.
4. Nutzer ohne Label `admin` → `/finance` landet auf `/tickets`, Menüpunkt „Finanzen“ fehlt.
5. `/finanzen/bank-callback?state=s&code=c` → genau ein `POST …/bank/rueckkehr`, Text „Bankzugang erneuert, gültig bis 29.03.2027“.
6. Breite 390 px: keine horizontale Scrollleiste auf `/finance`.
- [ ] **Step 2: Run** `node smoke.mjs` – Expected: alle 6 Prüfungen „ok“.
- [ ] **Step 3:** Gefundene Fehler in den Komponenten beheben, `npm test` + Build, Commit `git commit -m "Finanzen: Korrekturen aus dem Oberflaechentest"` (nur falls Änderungen).
### Task 8: Livegang (jeder Schritt mit OK des Nutzers)
**Files:**
- Modify: `/home/webklar/apps/docker-compose.yml` (Sicherung vorher: `docker-compose.yml.bak-2026-10-XX-finanzen`)
- Move: `/home/webklar/apps/ticket-integrations/enablebanking.pem` und `.pub` → `/home/webklar/apps/secrets/enablebanking/`
- [ ] **Step 1: Schlüssel verschieben** (Ordner 700, Schlüssel 600; Inhalt nie ausgeben):
```bash
install -d -m 700 /home/webklar/apps/secrets/enablebanking && mv /home/webklar/apps/ticket-integrations/enablebanking.pem /home/webklar/apps/ticket-integrations/enablebanking.pub /home/webklar/apps/secrets/enablebanking/ && chmod 600 /home/webklar/apps/secrets/enablebanking/enablebanking.pem
```
- [ ] **Step 2: Compose** beim Dienst `kundenbereich-server` ergänzen: Volume `- ./secrets/enablebanking:/run/secrets/enablebanking:ro`; Umgebung `- ENABLE_BANKING_APP_ID=b2582270-11bf-4489-affb-1a151f076c07` und `- ENABLE_BANKING_KEY_PATH=/run/secrets/enablebanking/enablebanking.pem`. Prüfen: `docker compose config -q` ohne Fehler.
- [ ] **Step 3: Portal live** (OK des Nutzers): `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-finanzen push origin feature/finanzen:main`; warten, bis `build/` auf dem neuen Stand ist (`git -C …/build log -1`); dann `cd /home/webklar/apps && docker compose up -d kundenbereich-server` (übernimmt Volume und Umgebung). Erwartet: `docker logs webklar-kundenbereich-server --since 2m` zeigt den Start ohne Fehler; `curl -s https://project.webklar.com/api/health` → `ok: true`.
- [ ] **Step 4: Collections und Sitzung übernehmen** (Werte aus `ticket-integrations/enablebanking-session.txt`, ohne sie auszugeben):
```bash
F=/home/webklar/apps/ticket-integrations/enablebanking-session.txt; docker exec -w /app webklar-kundenbereich-server node scripts/setup-finanzen.mjs --session "$(sed -n 's/^session_id=//p' $F)" --account "$(sed -n 's/^account_uid=//p' $F)" --valid-until "$(sed -n 's/^valid_until=//p' $F)"
```
Erwartet: „Sitzung übernommen, gültig bis 29.03.2027“. Danach `enablebanking-session.txt` und `enablebanking-app-id.txt` löschen. Nach höchstens 2 Minuten (Job) oder per `POST /bank/aktualisieren`: 9 Buchungen, Kontostand 108,93 €.
- [ ] **Step 5: Stripe-Dashboard** (Nutzer): Rechnungsnummern fortlaufend auf Kontoebene; Zahlarten für Rechnungen Karte und SEPA-Lastschrift; in WOMS-Einstellungen den Bankverbindungstext (IBAN, BIC) eintragen.
- [ ] **Step 6: WOMS live** (OK des Nutzers): `git -C /home/webklar/apps/ticket.webklar.com/dev-finanzen push origin feature/finanzen:test`. Erwartet: neues Bundle in `ticket.webklar.com/out` enthält „Verfügbar nach Rücklagen“; Seite lädt für Kenso.
- [ ] **Step 7: Daten nachziehen:** Buchung „Rechnung 0005 vom 28.07.2026“ als „Einnahme ohne Stripe-Rechnung“ zuordnen; prüfen, dass Justin das Label `admin` hat (sonst Nutzer fragen).
- [ ] **Step 8: Merken:** Memory-Datei `webklar-finanzen.md` mit Livestand, Commits und offenen Punkten aktualisieren (Enable-Banking-Vertrag, Kundenportal-Tab „Rechnungen“ noch InvoiceNinja).
@@ -0,0 +1,323 @@
# Website-Analyse – Pflicht-Änderungen aus der Rechtsprüfung: Umsetzungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Die technischen Voraussetzungen der geprüften Rechtstexte (Fassung 2026-10-02) herstellen, damit `ANALYSE_ENABLED=on` ohne falsche Angaben möglich ist: Einwilligung und Widerspruch wirken im Browser, der Messdienst speichert weniger und löscht verlässlich, jede Beendigung wird durchgesetzt, Einschalten ist nachweisbar (Historie, Textform für Mitarbeiter), die geprüften Texte sind veröffentlicht und gehen als PDF an den Kunden.
**Architecture:** Änderungen in vier Bereichen auf dem bestehenden Stand (`feature/website-analyse` = live `main` 30412cb): (1) Client `analytics/client/` (`w.js`, `core.js`, committetes `dist/`), (2) Messdienst `analytics/src/` (Annahme, Nachtjob, Skript-Auslieferung, Admin-Route), (3) Portal `server/` + `public/` (Aktivierung, Historie, Texte, Mails, Tickets, Beendigungswege), Runner `runner/` (Skript-URL, Zusatzseiten-Vorlage), (4) WOMS (Textform-Formular). Ein Datenmodell-Schritt (`scripts/analyse-setup.mjs`) kommt vor dem Portal-Code live.
**Tech Stack:** Node 24 (Messdienst, `node:sqlite`, `node --test`), Node 20 (Portal/Runner), vanilla JS (Portal-UI, Client), React + `node --test` (WOMS; `package.json` nutzt `node --test tests/*.test.mjs tests/unit/*.test.mjs`, nicht vitest), Chrome headless (PDF).
**Spec:** `/root/reports/website-analyse-2026-10-01/rechtstexte-geprueft/technik-aenderungen.md` (T1–T22), `/root/reports/website-analyse-2026-10-01/rechtstexte-geprueft/00-gutachten.md` (Abschnitt „Entscheidungen für dich“, E1–E6, und P-Tabelle). Texte: `/root/reports/website-analyse-2026-10-01/rechtstexte-geprueft/banner-portal-mailtexte.md` (im Plan nur als „§ n“ zitiert, nicht neu getippt), `avv-website-analyse.{md,html}`, `leistungsbeschreibung-website-analyse.{md,html}`, `datenschutz-analyse.html`. Bisheriger Plan/Design: `docs/superpowers/plans/2026-10-01-website-analyse.md`, `docs/superpowers/specs/2026-10-01-website-analyse-design.md`.
**Worktrees:** Portal/Runner/Analytics `/home/webklar/apps/kundenbereich.webklar.com/dev-analyse` (Branch `feature/website-analyse`). WOMS `/home/webklar/apps/ticket.webklar.com/dev-analyse`. Quelle der Textdateien: `RT = /root/reports/website-analyse-2026-10-01/rechtstexte-geprueft`.
## Global Constraints
- Umfang: T1–T11, T13–T17, T19 (T5 und T18 sind Compose-Änderungen und gehören in den Livegang L1). Nicht im Plan: T12, T20, T21, T22 (siehe „Außerhalb des Plans“).
- Fassung überall `'2026-10-02'`: `config.analyse.avvVersion`, `BANNER_VERSION` im Client, Dateinamen der veröffentlichten Texte, Textform-Mindestdatum.
- Entscheidungen: E1=B (Ping nur mit Seitenadresse ohne Einwilligung), E2 (WEBklar fügt den Hinweis in die Datenschutzerklärung ein, keine Anrechnung; externer Generator ⇒ Baustein per Chat), E3 (Mitarbeiter schaltet nur mit Textform ein: Pflichtfelder Datum und Weg), E4 (Du-Anrede auf Kunden-Websites), E5 (Tageswerte nach 25 Monaten löschen), E6 (nur Text, keine Technik).
- Subagents pushen nie (kein `git push`, auch keine Tags pushen). Lokale Commits und lokale Tags sind erlaubt. Ich (Controller) pushe auch nicht auf `main`; das macht der Nutzer (L2, L4).
- `ANALYSE_ENABLED` bleibt `off` bis L5. Nichts live schalten, keine Compose-Datei ändern (liegt außerhalb der Worktrees).
- Texte deutsch, Du-Anrede im Portal und auf den Kunden-Websites; kein `confirm()`. Texte aus `banner-portal-mailtexte.md` werden 1:1 aus dem jeweiligen Abschnitt kopiert, nicht umformuliert. Neue Texte, die dort fehlen (Fehlermeldungen, Ticket-Details für „veröffentlichen“), sind im Plan mit „NEU“ markiert und kurz zu halten; der Nutzer sieht sie im Review.
- Keine neuen Abhängigkeiten im Messdienst und im Client-Skript. Keine IP, kein User-Agent gespeichert oder geloggt.
- Datenbank-Schreibzugriffe im Portal nur auf Attribute, die `scripts/analyse-setup.mjs` anlegt (Test-Mock erzwingt das).
- Commit-Messages deutsch, Präfix „Analyse: …“, enden mit der Attribution-Zeile der Sitzung (`Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>`).
- Testkommandos: Messdienst `cd analytics && npm test`; Runner `cd runner && npm test`; Portal im Worktree-Wurzelordner `npm test`; WOMS `npm test`. Nach jeder Task müssen alle vier Suiten grün sein, in denen die Task Dateien berührt.
## Review Focus
1. Nach „Ablehnen“, bei GPC/DNT und bei gespeichertem „Ablehnen“ verlässt kein `pv`/`bn`/`en`/`ck`/`ct` mehr den Browser, auch nicht über `pushState`/`popstate`/`pageshow` oder andere Tabs; vor „Annehmen“ weder `ref`/`vp` noch ein Lesezugriff auf `document.referrer`/`innerWidth`. → Task 1
2. Keine millisekundengenauen Zeitstempel (`events_raw`, `clicks`) und keine kennungsartigen Pfadsegmente in der Datenbank; nichts davon ist umgehbar über den neuen Typ `cx`. → Task 2
3. Jede Beendigung (Ausschalten, Abo-Ende, Kunde/Projekt weg, Domain/Öffentlichkeit weg, Löschfrist, Widerruf Wechsel) setzt `active=false` + `offSince` im Messdienst; danach liefert `w.js?s=` ein leeres Skript; Tageswerte > 25 Monate und gelöschte Zeilen sind aus Datei und WAL verschwunden. → Task 3, 4, 9
4. Einschalten ohne Firma/Name oder gültige E-Mail ist unmöglich (statt stillem „aktiv“); Mitarbeiter-Weg ohne Datum und Weg unmöglich; Einwilligungs-Historie ist nur anfügbar. → Task 5, 7, 10
5. Veröffentlichte Texte, PDFs, K18-Anhänge, Banner-Text und Zusatzseiten-Vorlage sind byte-/textgleich zur geprüften Fassung 2026-10-02; keine Entwurfsseite bleibt öffentlich. → Task 6, 8
---
## Verifizierter Stand (Abweichungen zu `technik-aenderungen.md`)
Die Zeilenangaben stimmen mit dem Live-Code überein (`w.js:44,55,65,155,162-168,177-231,240-245`; `ingest.js:129,142,151,160,167`; `jobs.js:70-84`; `db.js:57`; `analyse.js:94-102,104-121,123-140,200-218,221-242`; `funktionen.js:9,595,608,619,763`). Befunde, die der Plan zusätzlich berücksichtigt:
- `activateAnalyse` setzt bei erneutem Einschalten `activatedMailAt` zurück, **nicht** `privacyTicketId` ⇒ nach Aus/An gäbe es kein neues Ticket. Task 5/8 setzt es zurück.
- Der tägliche `runAboCheck` läuft schon heute unabhängig vom Schalter (`server/index.js:175`). Der neue Abgleich (Task 9) hängt sich dort ein.
- `retention.js` ruft `siteFeatures.disable()`, das über `stopAnalyseCollecting` nur `putSite(..., active:false)` mit Domains aus dem Projekt versucht; fehlt das Projekt, scheitert es still (nur `console.warn`). Task 9 ersetzt das durch einen domainlosen Aufruf.
- Im Portal-Code gibt es keinen Pfad, der `websiteProjects` löscht oder `liveDomain` schreibt (nur WOMS/Appwrite-Konsole). Gelöschte Projekte und geänderte Live-Domains werden deshalb über den täglichen Abgleich erfasst; Subdomain-/Öffentlichkeitsänderungen laufen über `giteaAdmin.updateWebsiteProject` (dort Hook).
- Es gibt keine Historien-Sammlung; `portalFeatures` hat nur das aktuelle Dokument. Neue Collection `analyseConsents` (Task 5).
- Der Client sendet heute über `navigator.sendBeacon` (`w.js:39`); die Test-Harness (`analytics/test/client.test.js`, `harness()`) fängt nur `sendBeacon` ab und muss auf `fetch` umgestellt werden.
- Die veröffentlichte AVV-Seite hat den Titel „Entwurf – vor Verwendung anwaltlich prüfen lassen“, `test/avvPage.test.js` prüft genau das und kippt bewusst (Task 6).
- `runner/test/analytics-inject.test.js` erwartet im Zusatzseiten-Text „Cookie-Einstellungen“; die geprüfte Vorlage sagt „Datenschutz-Einstellungen“.
- PDFs: `scripts/build-legal-pdfs.sh` (Chrome headless, Chromium unter `~/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome` vorhanden, PDFs werden eingecheckt, Commit d4241d2 war so entstanden). Das Skript erzeugt alle Vertrags-PDFs auf einmal; Task 6 ergänzt einen Modus `analyse`, damit andere PDFs unverändert bleiben. K01 hängt PDFs bereits über `sendCustomerMail(..., { attachments:[{filename, content, contentType}] })` an; K18 nutzt denselben Weg.
---
## Teil A – Messdienst und Client
### Task 1: Client – Einwilligung vor Senden (T1, T2-Client, T4-Client, T15, T19, Banner-Texte aus T10)
**Files:** `analytics/client/core.js`, `analytics/client/w.js`, `analytics/client/dist/w.js` + `dist/h.js` (neu bauen: `node client/build.js`), `analytics/test/client.test.js`.
**Produces (core.js):** `export const BANNER_VERSION = '2026-10-02'`; Textkonstanten `BANNER_TEXT` (Fließtext bis einschließlich „Details: “, 1:1 aus `banner-portal-mailtexte.md` § 1), `BANNER_LINK_LABEL = 'Datenschutz'`, `BANNER_ARIA = 'Einwilligung zur Reichweitenmessung'`, `SETTINGS_LABEL = 'Datenschutz-Einstellungen'`. `readConsent(storage, now)` liefert nur noch Einträge mit `c.bv === BANNER_VERSION` und gibt `{ v:1, a, t, bv }` zurück; ältere Einträge ohne/mit anderer `bv` zählen als nicht vorhanden (Besucher wird neu gefragt). Schlüssel `wk-consent` bleibt.
**Ablauf in `w.js` (neu geordnet):**
1. Skript-/siteKey-Prüfung und Heatmap-Modus bleiben vorn, unverändert.
2. `optOut` (GPC/`doNotTrack`/`msDoNotTrack`) und `stored = readConsent(...)` werden **vor** jedem Senden und vor jedem Hook berechnet. `optOut` ⇒ `return`: kein `pv`, kein Banner, kein Einstellungsknopf, kein `pageshow`/`popstate`/`pushState`-Hook.
3. `choiceNow()` liest die Wahl bei jedem Aufruf frisch (Speicher, sonst `memChoice`); `counting() = choiceNow() !== 'reject'`.
4. `sendPv(first)` bricht ohne `counting()` ab. Nutzlast ohne Wahl exakt `{ t:'pv', site, path }`; erst bei `choiceNow()==='accept'` kommen `ref` (nur `first`) und `vp` hinzu. `document.referrer` und `innerWidth` werden ausschließlich in diesem Zweig gelesen.
5. Hooks (`pageshow`, `pushState`-Wrapper, `popstate`) nur installieren, wenn beim Start `choiceNow() !== 'reject'`; `scheduleNav()` prüft `counting()` im Timer-Callback erneut. `ensureHooks()` ist idempotent (spätes „Annehmen“ nach gespeichertem „Ablehnen“ installiert sie nachträglich).
6. `choose('reject')`: Wahl speichern, genau ein `bn` mit `a:'reject'`, `stopTracking()`, danach sendet nichts mehr (auch nicht `pv` per Seitenwechsel). `choose('accept')`: `bn accept`, `startTracking()`, dann `onAccept()`: wurde auf dieser Seite schon ein `pv` gesendet, genau einmal `cx` `{ t:'cx', site, path:lastPath, ref:refHost(DOC.referrer, ownHost()), vp:vp() }` (Flag `cxSent`), sonst `sendPv(true)` mit `ref`/`vp`.
7. `storage`-Handler: `reject` ⇒ `stopTracking()` (Zählen endet über `counting()`), `accept` ⇒ `onAccept()`, geleerter Eintrag ⇒ unverändert „keine Wahl“.
8. Gespeichertes „Ablehnen“: nichts senden, nur `addSettingsLink()` und `[data-webklar-consent]`-Delegation; gespeichertes „Annehmen“: `pv` mit `ref`+`vp` wie bisher, kein Banner, kein `cx`.
9. `send(obj)`: ausschließlich `fetch(endpoint, { method:'POST', body, keepalive:true, credentials:'omit', headers:{ 'Content-Type':'text/plain' } }).catch(()=>{})`; kein `navigator.sendBeacon` mehr.
10. Banner: `aria-label` = `BANNER_ARIA`, Fließtext = `BANNER_TEXT` + Link (`BANNER_LINK_LABEL` → `/datenschutz-analyse.html`), Knopf zum Wiederöffnen = `SETTINGS_LABEL`. `choose()` speichert `{ v:1, a, t, bv: BANNER_VERSION }`.
11. T19: Solange das Banner offen ist, setzt `openBanner()` `DOC.body.style.paddingBottom` auf die Banner-Höhe (`bannerHost.getBoundingClientRect().height || offsetHeight`, aufgerundet, in px; bei `resize` nachziehen) und stellt beim Schließen den vorherigen Inline-Wert wieder her. Einstellungsknopf: `min-width:24px;min-height:32px;padding:8px 12px;font-size:13px`.
**Tests (zuerst, müssen scheitern; `harness()` auf `fetch`-Capture umbauen, `document.referrer` und `win.innerWidth` als zählende Getter):**
- [ ] `core`: `readConsent` verwirft `{v:1,a:'accept',t}` ohne `bv`, verwirft `bv:'2026-01-01'`, akzeptiert `bv:'2026-10-02'`; abgelaufen (≥ 6 Monate) weiter `null`.
- [ ] GPC (`navigator.globalPrivacyControl=true`) und DNT `'1'`: `h.sent.length === 0`; `h.winL.pageshow`/`h.winL.popstate` undefiniert; `history.pushState` ist die Original-Funktion; `h.body.children.length === 0` (kein Banner, kein Einstellungsknopf).
- [ ] Gespeichertes `{a:'reject',bv}`: `sent` leer, nur Einstellungsknopf in `body`, keine `pageshow`/`popstate`-Listener; Klick auf den Knopf öffnet das Banner ohne Senden; dort „Annehmen“ ⇒ Typen `['bn:accept','pv']` und das `pv` trägt `ref` und `vp`.
- [ ] Keine Wahl: erstes Ereignis ist `deepEqual({t:'pv', site, path:'/start'})` (genau diese drei Schlüssel), danach `bn:shown`; Zähler für `document.referrer` und `innerWidth` = 0.
- [ ] „Ablehnen“: Typen enden auf genau ein `bn:reject`; danach `pushState` + Timer feuern, `pageshow{persisted:true}`, Scroll, Klick, `pagehide` ⇒ nichts Neues. Zweiter Tab: Speicher auf reject setzen + `storage`-Ereignis, danach Seitenwechsel ⇒ nichts Neues; Seitenwechsel-Timer, der vor dem Ablehnen geplant war, sendet nichts.
- [ ] „Annehmen“ auf laufender Seite (zuvor `pv` ohne Daten): Reihenfolge `bn:accept`, `cx` mit `ref === refHost('https://www.google.com/','mein.de')` und `vp:'d'`; zweites „Annehmen“ nach Widerruf sendet kein zweites `cx` für dieselbe Seite.
- [ ] Gespeichertes „Annehmen“: erstes `pv` hat `ref` und `vp`, kein `cx`.
- [ ] T15: `navigator.sendBeacon` ist ein Spy und wird nie aufgerufen; `fetch` bekommt `keepalive:true`, `credentials:'omit'`, `method:'POST'`, Header `Content-Type: text/plain`.
- [ ] T19: Fake-`offsetHeight=90` ⇒ `body.style.paddingBottom === '90px'` bei offenem Banner, nach Schließen wieder der Ausgangswert; Shadow-CSS des Einstellungsknopfs enthält `min-width:24px` und `min-height:32px`.
- [ ] Banner-Aufbau: `aria-label === BANNER_ARIA`, Text beginnt mit `BANNER_TEXT`, Knopfname `SETTINGS_LABEL`; Konstanten stimmen mit § 1 überein (Test liest `RT/banner-portal-mailtexte.md` nicht, sondern vergleicht in Task 6 gegen die Archivdatei).
- [ ] Bestehende Tests anpassen, die bewusst kippen: „GPC und DNT ⇒ nur pv“ (jetzt: nichts), „ohne Einwilligung nur pv + bn shown“ (jetzt `pv` ohne `ref`/`vp`), sendBeacon-Erwartungen; `dist ist aktuell` nach Neubau grün.
- [ ] FAIL → implementieren → `cd analytics && node client/build.js && npm test` PASS → Commit „Analyse: Client prüft Einwilligung vor dem Senden, Fassung der Banner-Wahl, fetch ohne Cookies“
### Task 2: Messdienst – Annahme (T2-Server, T3, T14)
**Files:** `analytics/src/ingest.js`, `analytics/src/jobs.js` (nur `aggregateRows`), `analytics/src/heat.js` (Sortierung), `analytics/test/ingest.test.js`, `analytics/test/stats.test.js`.
**Produces:**
- `FIELDS.pv = ['t','site','path','ref','vp']` bleibt, `vp` ist optional: fehlt `vp` (nicht vorhanden/`undefined`), speichert `store()` `vp = NULL`; ein vorhandener, ungültiger `vp` verwirft das Ereignis wie bisher.
- `FIELDS.cx = ['t','site','path','ref','vp']`; `store()`: `vp` gültig Pflicht, `ref` über `cleanRef`, Zeile `type='cx'`, `ref`, `vp`, `country`/`device` leer.
- T3: alle gespeicherten Zeitstempel `Math.floor(t / HOUR) * HOUR` (`pv`, `cx`, `en`, `ct` in `events_raw`; `ck` in `clicks`). Berliner Offsets sind ganze Stunden, daher bleibt jeder Zeitstempel im selben Berliner Tag; `heat.js` sortiert `ORDER BY ts DESC, id DESC`.
- T14: `cleanPath()` ersetzt Segmente, die kennungsartig aussehen, durch `:id`: Segment (auch nach `decodeURIComponent`, Fehler ⇒ Rohwert) mit Ziffernfolge `/\d{6,}/`, UUID `/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i` oder enthält `@` (E-Mail, auch `%40`). Danach weiter Länge ≤ 300 und Steuerzeichen-Prüfung. Gilt für alle Typen (`pv`, `cx`, `en`, `ck`, `ct`).
- `jobs.js aggregateRows`: `pv` zählt `pv_total` und `pv_path`/`pv_country`/`pv_device` wie bisher, `pv_ref` aber **nur** wenn `r.vp != null` (nur Besucher mit Einwilligung); `cx` zählt ausschließlich `pv_ref` (`r.ref || ''`), nie `pv_total`.
**Tests:**
- [ ] `pv` ohne `ref`/`vp` (`{t:'pv',site,path:'/'}`) ⇒ 1 Zeile, `vp IS NULL`, `ref IS NULL`, `device` aus User-Agent, `country` aus Geo.
- [ ] `pv` mit `vp:'x'` ⇒ verworfen; `cx` ohne gültiges `vp` ⇒ verworfen; `cx` mit `ref:'https://www.google.com/x'` ⇒ Zeile `type='cx'`, `ref='www.google.com'`; `cx` mit eigener Domain als `ref` ⇒ `ref IS NULL`.
- [ ] Zeitstempel: `now = Date.UTC(2026,9,1,10,30,45)` ⇒ für `pv`, `cx`, `en`, `ct` und `clicks.ts` jeweils `=== Date.UTC(2026,9,1,10,0,0)`; kein Wert mit `ts % 3600000 !== 0`.
- [ ] Pfade: `/kunde/123456789/rechnung` ⇒ `/kunde/:id/rechnung`; `/u/3f2b8c1e-5d4a-4e0b-9a7c-1b2c3d4e5f60` ⇒ `/u/:id`; `/mail/max%40example.com` und `/mail/max@example.com` ⇒ `/mail/:id`; unverändert bleiben `/preise/2026`, `/blog/12345`, `/`; Query/Hash weiter abgeschnitten; gilt auch für `en`, `ck`, `ct`.
- [ ] Verdichtung: Zeilen `pv(vp NULL)` + `cx(ref 'google.com')` ⇒ `pv_total` 1, `pv_ref` genau `{google.com:1}` (kein `''`-Schlüssel); `pv(vp 'd', ref NULL)` ⇒ `pv_ref {'':1}`; `cx` erhöht `pv_total` nie; Stats-Endpunkt liefert `refs` entsprechend (live und aus `daily`, nie doppelt).
- [ ] Heatmap: zwei Klicks gleicher Stunde, `LIMIT 1` liefert den mit höherer `id`.
- [ ] FAIL → implementieren → `cd analytics && npm test` PASS → Commit „Analyse: Messdienst: Ping ohne Einwilligung, Zeitstempel auf Stunde, Pfade ohne Kennungen“
### Task 3: Messdienst – Aufbewahrung, sicheres Löschen, Export (T6-Messdienst, T7, E5, T16)
**Files:** `analytics/src/db.js`, `analytics/src/jobs.js`, `analytics/scripts/export-site.mjs` (neu), `analytics/test/stats.test.js` (oder neu `analytics/test/retention.test.js`), `analytics/test/export.test.js` (neu).
**Produces:**
- `db.js openDb`: nach `journal_mode = WAL` zusätzlich `db.exec('PRAGMA secure_delete = ON')`.
- `jobs.js`: `export const DAILY_MONTHS = 25`; `dailyCutoffDay(now) ⇒ 'YYYY-MM-DD'` = Berliner Tag von `now` minus 25 Kalendermonate (Tag am Monatsende auf den letzten Tag des Zielmonats begrenzt). `runNightly`: nach der Verdichtung `DELETE FROM daily WHERE day < ?` und `DELETE FROM agg_state WHERE day < ?` mit diesem Tag (alle Websites). Verwaiste Einträge: (a) `UPDATE sites SET offSince = ? WHERE active = 0 AND offSince IS NULL` (Frist beginnt dann bei Entdeckung), (b) Zeilen in `events_raw`/`clicks`/`daily`/`agg_state`, deren `site` in `sites` fehlt, werden gelöscht. Am Ende (nach allen Transaktionen) `try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)') } catch (e) { log }`. Rückgabe erweitert: `{ aggregated, sitesDeleted, dailyDeleted, orphansMarked, orphanRowsDeleted }`; `scheduleNightly` loggt die neuen Zähler.
- `analytics/scripts/export-site.mjs`: `exportSite({ dbPath, siteKey, outDir })` öffnet die Datenbank **nur lesend** (`new DatabaseSync(dbPath, { readOnly: true })`) und schreibt drei CSV-Dateien (RFC 4180, UTF-8, `\r\n`): `<siteKey>-tageswerte.csv` (Kopf `day,dim,key,count`), `<siteKey>-ereignisse.csv` (Kopf `time,type,path,vp,ref,country,device,visitor,sec,scroll,kind,host`, `time` als ISO-UTC), `<siteKey>-klicks.csv` (Kopf `time,path,vp,x,y,sel`). Zellen, die mit `=`, `+`, `-` oder `@` beginnen, bekommen ein vorangestelltes `'`. CLI: `node scripts/export-site.mjs <siteKey> [--db <Pfad>] [--out <Ordner>]`; Standard `--db /srv/webklar-analytics/analytics.db`, Exit-Code 1 bei ungültigem Schlüssel oder unbekannter Website.
**Tests:**
- [ ] `dailyCutoffDay(Date.UTC(2026,9,1,10))` = `'2024-09-01'`; `dailyCutoffDay` für 2026-03-31 = `'2024-02-29'`.
- [ ] `daily`-Zeilen mit `day` `'2024-08-31'` werden gelöscht, `'2024-09-01'` bleibt; zugehörige `agg_state`-Zeilen ebenso; `dailyDeleted` zählt.
- [ ] Website `active=0, offSince NULL` ⇒ nach Lauf `offSince` gesetzt (= `new Date(now).toISOString()`), zweiter Lauf ändert den Wert nicht; Website mit gesetztem `offSince` > 60 Tage wird weiter komplett gelöscht.
- [ ] Zeilen ohne `sites`-Eintrag werden gelöscht, Zeilen von Websites mit Eintrag bleiben.
- [ ] `secure_delete`: Datei-DB im Temp-Ordner, Marker-String `'GEHEIMER-PFAD-4711'` als `events_raw.path` einfügen, löschen, `wal_checkpoint(TRUNCATE)`; Rohbytes der `.db`-Datei enthalten den Marker nicht; `PRAGMA secure_delete` liefert 1.
- [ ] `runNightly` auf Datei-DB: danach `statSync(path + '-wal').size === 0`.
- [ ] Export: 3 Dateien mit den exakten Kopfzeilen; Zeilenzahlen = Zeilen der Website (andere Website erscheint nicht); Zelle `a,"b"\nc` korrekt gequotet; `sel` `'=1+1'` ⇒ `'=1+1`; Aufruf mit unbekanntem Schlüssel ⇒ Exit 1 ohne Dateien; Datei-DB bleibt byte-gleich (Hash vor/nach); Schreibversuch über die geöffnete Verbindung wirft.
- [ ] FAIL → implementieren → `cd analytics && npm test` PASS → Commit „Analyse: Tageswerte nach 25 Monaten löschen, secure_delete, CSV-Export“
### Task 4: Leeres Skript für ausgeschaltete Websites, siteKey in der Skript-URL (T13a)
**Files:** `analytics/src/assets.js`, `analytics/src/server.js`, `runner/src/analytics-inject.js` (`scriptTag`), `analytics/test/assets.test.js`, `analytics/test/sites.test.js`, `runner/test/analytics-inject.test.js`, `runner/test/jobs.test.js` (falls Tag erwartet).
**Produces:**
- `createAssets({ distDir, log, isActive })`: `/a/w.js` liefert nur dann das echte Skript, wenn `?s=` dem Muster `^[a-z0-9]{16,40}$` entspricht **und** `isActive(s)` wahr ist; sonst (ohne `s`, ungültig, unbekannt, ausgeschaltet) HTTP 200 mit Körper `/* WEBklar Analyse: aus */\n`, Header `Content-Type: text/javascript; charset=utf-8`, `Cache-Control: public, max-age=60`, `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: cross-origin`, `Access-Control-Allow-Origin: *`, `Vary: Accept-Encoding`. Echtes Skript und `/a/h.js` behalten ihre bisherigen Header (`max-age=3600`, ETag, gzip). 503 bei fehlender Datei bleibt.
- `server.js`: `createAssets({ distDir, isActive: (k) => !!db.prepare('SELECT 1 FROM sites WHERE siteKey = ? AND active = 1').get(k) })`.
- Runner: `scriptTag()` ergibt `<script data-wk-analytics src="${baseUrl}w.js?s=${siteKey}" data-site="${siteKey}" data-color="${color}" defer></script>`. `w.js` entfernt die Query bereits (`src.replace(/[?#].*$/,'')`), `data-site` bleibt für Alt-Fassungen. Alte Tags ohne `?s=` werden beim nächsten Neubau ersetzt (bestehende Ersetzungslogik).
**Tests:**
- [ ] Aktive Website, `GET /a/w.js?s=<key>` ⇒ echtes Skript, `max-age=3600`, ETag, `304` bei `If-None-Match`.
- [ ] Unbekannter Schlüssel, ungültiger Schlüssel (`?s=ABC`), kein `s`, ausgeschaltete Website ⇒ Körper `/* WEBklar Analyse: aus */\n`, Status 200, `Cache-Control: public, max-age=60`, kein `Content-Encoding`, kein ETag.
- [ ] Ende-zu-Ende mit Admin- und Public-Server auf einer DB: `PUT active:true` ⇒ echtes Skript; `PUT active:false` ⇒ sofort leer; erneut `true` ⇒ echt.
- [ ] Runner: Tag enthält `w.js?s=abcdef0123456789abcd`; zweimal ausführen ⇒ ein Tag; alter Tag ohne `?s=` wird ersetzt; ungültige Eingaben werfen weiter.
- [ ] FAIL → implementieren → `cd analytics && npm test` und `cd runner && npm test` PASS → Commit „Analyse: Skript nur für aktive Websites, leeres Skript sonst, siteKey in der Skript-URL“
---
## Teil B – Portal und Runner
### Task 5: Datenmodell, Einwilligungs-Historie, Aktivierung mit Nachweis und Sperre (T8 Server, T9 Server)
**Files:** `scripts/analyse-setup.mjs`, `server/config.js`, `server/services/analysePrivacy.js` (neu, rein, ohne Imports), `server/services/analyseConsent.js` (neu), `server/services/analyse.js`, `server/services/previewDeploy.js` (`buildAnalytics`), `server/routes/funktionen.js`, `server/routes/admin/analyse.js`, `test/analyseSetup.test.js`, `test/analyse.test.js`, `test/analyseRoutes.test.js`, `test/previewDeploy.test.js`, ggf. `test/support/appwriteMock.js` (neue Collection im Mock-Schema).
**Produces:**
- `config.analyse.avvVersion = '2026-10-02'`; `config.collections.analyseConsents = process.env.APPWRITE_COLLECTION_ANALYSE_CONSENTS || 'analyseConsents'`.
- `analyse-setup.mjs`: portalFeatures-Attribute zusätzlich `avvConsentDate` (10), `avvConsentChannel` (10), `removalTicketId` (40), `publishTicketId` (40) (alle `string`, optional; gesamt 12). Neue Collection `analyseConsents` (Datenbank wie portalFeatures, nur Server-Key, keine Client-Rechte) mit Attributen `customerId` 36, `projectId` 36, `siteKey` 40, `event` 8 (`on`/`off`), `avvVersion` 20, `via` 8 (`portal`/`woms`), `acceptedAt` 40, `acceptedBy` 100, `textformDate` 10, `textformChannel` 10 und Index auf `projectId`. Das Skript bleibt idempotent; der Client erhält `ensureCollection(id, name)` und nimmt die Collection-ID als Parameter.
- `analysePrivacy.js`: `analysePrivacyData(customer) ⇒ { ok, company, address, email, missing }` mit derselben Bereinigung wie heute in `buildAnalytics` (Steuerzeichen raus, Firma = `companyName || name` ≤ 200, Adresse `street, PLZ Ort` ≤ 300, E-Mail ≤ 200 und `^[^\s@]+@[^\s@]+\.[^\s@]+$`); `missing ⊆ ['firma','email']`; `ok` ⇔ `missing` leer. `buildAnalytics` ruft sie auf (kein zweiter Regelsatz).
- `analyseConsent.js`: `recordConsent(entry) ⇒ Dokument` (Appwrite `createDocument` mit `ID.unique()`), `listConsents(projectId)`. Es gibt bewusst keine Änderungs- und Löschfunktion.
- `analyse.js`:
- `validateTextform({ date, channel }, { today, minDate })` mit `date` `YYYY-MM-DD` (echtes Datum, nicht nach `today` in Berlin, nicht vor `minDate = config.analyse.avvVersion`), `channel ∈ {'chat','email'}`; Fehler 400.
- `activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer', actor, textform })`: Kunde (`by==='customer'`) braucht `consent.accepted === true`, `consent.avvVersion === config.analyse.avvVersion` und `actor` mit `name || email`; Mitarbeiter braucht gültiges `textform` und trägt seinen Namen in `by`. Vor `putSite`: `analysePrivacyData(await customerOf(customerId))` muss `ok` sein, sonst 409 mit NEU-Text „Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.“ (WOMS-Variante: Fehlt-Hinweis mit Feldnamen). Beim Wechsel `off→active`: `putSite` → `recordConsent({ event:'on', via, avvVersion, acceptedAt:now, acceptedBy, textformDate, textformChannel, customerId, projectId, siteKey })` (schlägt es fehl: `putSite(..., active:false)` zurücknehmen und Fehler werfen) → `updateDocument` mit `avvVersion`, `avvAcceptedAt`, `avvAcceptedBy` (= Name/E-Mail der Person bzw. Mitarbeitername), `avvConsentDate`/`avvConsentChannel` (leer beim Kunden-Weg), `activatedAt`, `offSince:''`, `activatedMailAt:''`, `privacyTicketId:''`, `removalTicketId:''`, `publishTicketId:''`.
- `deactivateAnalyse` schreibt zusätzlich `recordConsent({ event:'off', via, acceptedBy })` (Fehler dabei nur loggen, Ausschalten darf nicht scheitern).
- Routen: `POST /api/funktionen/analyse/activate` übergibt `actor = { name: req.session.name, email: req.session.email }`. `POST /api/admin/analyse/:projectId` Body `{ on, textformDate, textformChannel, audienceConfirmed }`: `on:true` verlangt `audienceConfirmed === true`, gültiges Datum und Weg (sonst 400, NEU-Texte kurz); `avvConfirmedVerbally` wird nicht mehr akzeptiert (400 „Zustimmung in Textform nötig“). `GET /api/admin/analyse/:projectId` liefert zusätzlich `avvConsentDate`, `avvConsentChannel`, `privacy: { ok, missing }`. `GET /api/funktionen` (`analyseView`) liefert zusätzlich `controller: { company, address, email, ok, missing }`.
**Tests:**
- [ ] Setup: `ANALYSE_ATTRIBUTES` hat 12 Einträge mit den Größen aus der Liste; `main` mit Mock legt nur fehlende Attribute an; Collection `analyseConsents` wird angelegt, wenn sie fehlt, und übersprungen, wenn sie existiert; `--check` legt nichts an und meldet `missing`.
- [ ] `analysePrivacyData`: Firma leer + Name leer ⇒ `missing ['firma']`; nur `name` ⇒ `company === name`; E-Mail `'abc'` ⇒ `missing ['email']`; Steuerzeichen/Zeilenumbruch in der Firma werden zu Leerzeichen; Adresse nur mit `street` ohne Ort ⇒ nur Straße.
- [ ] `buildAnalytics`: Kunde mit nur `name` ⇒ `privacy.company === name`; ungültige E-Mail ⇒ `null`.
- [ ] Aktivieren ohne Firma/Name oder mit `email:'x'` ⇒ 409, **kein** `putSite`, **kein** Ticket, **keine** K18, Feld `state` bleibt `off`.
- [ ] Kunde aktiviert: ein Historien-Dokument `event:'on', via:'portal', avvVersion:'2026-10-02', acceptedBy:'<Name>'`; `acceptedAt` ISO; Portal-Dokument trägt dieselben Werte; zweites Aktivieren (schon aktiv) ⇒ kein weiteres Historien-Dokument; Farbwechsel allein schreibt keines.
- [ ] Aus → An: neues Historien-Dokument, `privacyTicketId`/`removalTicketId`/`publishTicketId` zurückgesetzt; `listConsents` zeigt `on`,`off`,`on` in zeitlicher Folge; das Modul exportiert nur `recordConsent`, `listConsents`.
- [ ] `recordConsent` wirft ⇒ `putSite` wird mit `active:false` zurückgenommen, Dokument bleibt `off`.
- [ ] `validateTextform`: `'2026-10-01'` (vor Fassung) ⇒ 400; Datum in der Zukunft ⇒ 400; `'2026-02-30'` ⇒ 400; Weg `'telefon'` ⇒ 400; `{date:'2026-10-02', channel:'email'}` ok.
- [ ] Admin-Route: `on:true` ohne `textformDate` ⇒ 400; ohne `textformChannel` ⇒ 400; ohne `audienceConfirmed` ⇒ 400; mit altem `avvConfirmedVerbally:true` ⇒ 400; gültig ⇒ 200 und Historie `via:'woms'`, `acceptedBy` = Mitarbeitername, `textformDate`/`textformChannel` gesetzt, Portal-Dokument `avvConsentDate`/`avvConsentChannel`.
- [ ] `GET /api/funktionen`: `controller.ok === false` und `missing` gefüllt, wenn Angaben fehlen; `GET /api/admin/analyse/:id` enthält `privacy`.
- [ ] FAIL → implementieren → `npm test` PASS → Commit „Analyse: Einwilligungs-Historie, Nachweis beim Einschalten, Sperre ohne Verantwortlichen-Angaben, Textform für Mitarbeiter“
### Task 6: Rechtstexte veröffentlichen, Zusatzseiten-Vorlage, Archiv (T10 Dateien, T11)
**Files:** `public/rechtliches/avv-website-analyse-2026-10-02.html`, `public/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html`, `public/rechtliches/avv-website-analyse-2026-10-02.pdf`, `public/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.pdf`, `public/rechtliches/avv-website-analyse.html` (entfernen), `server/services/analyseLegal.js` (neu), `server/index.js` (Route), `scripts/build-legal-pdfs.sh`, `runner/src/templates/datenschutz-analyse.html`, `docs/rechtliches/avv-website-analyse.md`, `docs/rechtliches/leistungsbeschreibung-website-analyse.md`, `docs/rechtliches/archiv/` (neu), `test/avvPage.test.js`, `test/legalTexts.test.js` (neu `test/analyseLegal.test.js`), `runner/test/analytics-inject.test.js`.
**Schritte und Produces:**
- Kopieren (Bytes unverändert) aus `RT`: `avv-website-analyse.html` → `public/rechtliches/avv-website-analyse-2026-10-02.html`, `leistungsbeschreibung-website-analyse.html` → `public/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html`, `avv-website-analyse.md`/`leistungsbeschreibung-website-analyse.md` → `docs/rechtliches/` (ersetzen die Entwürfe), `datenschutz-analyse.html` → `runner/src/templates/datenschutz-analyse.html`. Die bisherige Entwurfsseite `public/rechtliches/avv-website-analyse.html` wird gelöscht (T11).
- `analyseLegal.js`: `legalPaths(version) ⇒ { avv:'/rechtliches/avv-website-analyse-<version>.html', lb:'/rechtliches/leistungsbeschreibung-website-analyse-<version>.html' }`, `legalPdfFiles(version) ⇒ { avv:{filename:'AVV-Website-Analyse-<version>.pdf', path}, lb:{filename:'Leistungsbeschreibung-Website-Analyse-<version>.pdf', path} }`, `legalPdfAttachments(version) ⇒ Promise<[{filename,content,contentType:'application/pdf'},…]>` (wirft bei unlesbarer Datei).
- Route in `server/index.js` vor `express.static`: `GET /rechtliches/avv-website-analyse.html` und `GET /rechtliches/leistungsbeschreibung-website-analyse.html` liefern `res.sendFile` der Datei zur aktuellen `config.analyse.avvVersion` (Adressen ohne Fassung zeigen immer die aktuelle geprüfte Fassung; eine neue Fassung = neue Dateien + Konfiguration).
- `build-legal-pdfs.sh`: Aufruf `scripts/build-legal-pdfs.sh analyse` erzeugt nur die beiden PDFs aus den versionierten HTML-Dateien (Standard `all` wie bisher); PDFs werden eingecheckt. Ausführen: `CHROME=$(ls -d ~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome | tail -1) scripts/build-legal-pdfs.sh analyse`.
- Archiv (T4): `docs/rechtliches/archiv/README.md` mit Tabelle „Fassung | gültig ab | Banner-Text | Zusatzseite“; `docs/rechtliches/archiv/banner-2026-10-02.md` enthält Fließtext (§ 1), `aria-label`, Schaltflächennamen, `BANNER_VERSION`; `docs/rechtliches/archiv/datenschutz-analyse-2026-10-02.html` ist eine Kopie der Vorlage. „Gültig ab“ bleibt `offen bis Livegang` und wird in L2/L5 eingetragen. Lokales Git-Tag `analyse-texte-2026-10-02` auf den Commit dieser Task (nicht pushen; L2 pusht der Nutzer).
**Tests:**
- [ ] `test/avvPage.test.js` neu: beide versionierten HTML-Dateien existieren, enthalten `Fassung 2026-10-02`, enthalten weder `<!--` noch `Entwurf` noch `Prüfpunkt`; `public/rechtliches/avv-website-analyse.html` existiert **nicht** mehr als Datei; AVV-Seite enthält die Überschrift `12. Haftung` (wie bisher).
- [ ] `GET /rechtliches/avv-website-analyse.html` und `…/leistungsbeschreibung-website-analyse.html` über die Express-App liefern exakt die Bytes der Dateien für `config.analyse.avvVersion`; `…-2026-10-02.html` ebenfalls 200; `…-2026-01-01.html` 404.
- [ ] PDFs: beginnen mit `%PDF`, Größe > 20 KB, `legalPdfAttachments('2026-10-02')` liefert genau 2 Einträge mit den Dateinamen oben.
- [ ] Banner-Archiv: `archiv/banner-2026-10-02.md` enthält `BANNER_TEXT` und `BANNER_ARIA` aus `analytics/client/core.js` (Import der Konstanten) wortgleich; `README.md` nennt `BANNER_VERSION`.
- [ ] Runner-Test `Datenschutzseite…`: Erwartete Abschnittswörter auf „Datenschutz-Einstellungen“ statt „Cookie-Einstellungen“ ändern; zusätzlich: Seite enthält `Stand: 02.10.2026`, keine `{{`-Reste, `{{#address}}` ohne Adresse entfernt, `<!--` nirgends.
- [ ] `public/funktionen.js` `AVV_URL`-Test aus `avvPage.test.js` wird in Task 7 angepasst; hier nur entfernen/ersetzen durch Existenzprüfung der versionierten Dateien.
- [ ] `npm test` und `cd runner && npm test` PASS → Commit „Analyse: geprüfte Rechtstexte Fassung 2026-10-02 veröffentlichen (HTML, PDF, Zusatzseiten-Vorlage, Archiv)“
### Task 7: Portal-Oberfläche (T8 Frontend, T10 Portal-Vorschau)
**Files:** `public/funktionen.js`, `public/analyse-view.js`, `server/routes/funktionen.js` (`analyseView`: `legal`), `test/avvPage.test.js`, `test/analyseView.test.js`, `test/funktionenTiles.test.js`, optional `test/ui/funktionen.ui.mjs` (Playwright, manuell: `node test/ui/funktionen.ui.mjs`).
**Produces:**
- `analyseView` liefert zusätzlich `legal: { avv, lb }` (Pfade aus `legalPaths(config.analyse.avvVersion)`), `controller` kommt aus Task 5.
- `public/analyse-view.js`: `BANNER_PREVIEW_TEXT` (= `BANNER_TEXT` aus `core.js`), `controllerLines(controller) ⇒ string[]` (Firma, Anschrift falls vorhanden, E-Mail) für die Vorschau, `AVV_CHECK_*`-Konstanten nicht nötig.
- `public/funktionen.js`: `AVV_URL` und Leistungsbeschreibungs-Link kommen aus `a.legal` (Konstante `AVV_URL` entfällt). Texte, jeweils aus `banner-portal-mailtexte.md`: Panel-Aufzählung (§ 2, ersetzt die drei bisherigen Listenpunkte, Zeile ~608), Vorschau der Verantwortlichen-Angaben vor dem Haken (§ 2; Schreib-uns-Hinweis nur wenn `controller.ok`), Haken mit zwei Links und Kinder-Satz (§ 2, ersetzt Zeile ~619), `bannerPreview` zeigt `BANNER_PREVIEW_TEXT` plus Link und Knopf-Beschriftungen („Ablehnen“, „Annehmen“) (§ 1), Hinweis vor dem Ausschalten (§ 2, ersetzt Zeile ~763), Bestätigung nach „Ja, ausschalten“ (§ 2; Bestätigungstext statt sofortigem `reload()`, mit Knopf „OK“, der danach `reload()` ruft). Ist `controller.ok === false`, bleibt „Analyse einschalten“ gesperrt und ein Hinweis (NEU-Text wie in Task 5) erklärt, was fehlt. `consent: { accepted:true, avvVersion }` bleibt.
**Tests:**
- [ ] Parität: `BANNER_PREVIEW_TEXT === BANNER_TEXT` (beide aus den Modulen importiert); Preview-HTML enthält `Ablehnen` und `Annehmen`.
- [ ] Quelltext-Prüfung wie in `avvPage.test.js`: `funktionen.js` enthält keine der alten Formulierungen („anonym“, „Cookie-Einstellungen“, „Die bisherigen Daten bleiben noch 60 Tage erhalten“, „Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag“) und verweist nicht mehr auf `AVV_URL = '/rechtliches/avv-website-analyse.html'`.
- [ ] Render-Test (`funktionenTiles`-Muster, `render()` mit Fixture `analyse:{state:'off', eligible:true, avvVersion:'2026-10-02', legal:{…}, controller:{ok:true, company:'Muster GmbH', address:'Weg 1, 12345 Ort', email:'info@muster.de'}}`): HTML enthält die zwei Links `href` aus `legal`, den Text „Fassung 2026-10-02“, „Muster GmbH“ und „info@muster.de“; mit `controller.ok:false` ist der Knopf `disabled` und der Fehlt-Hinweis sichtbar.
- [ ] Ausschalt-Ablauf (Fake-`fetch`): nach „Ja, ausschalten“ erscheint der Bestätigungstext aus § 2, erst „OK“ lädt neu.
- [ ] `controllerLines`: ohne Adresse nur 2 Zeilen; HTML-Maskierung im Vorschau-Text (`esc`) für `<`/`&` in der Firma.
- [ ] `npm test` PASS; Playwright-Skript einmal laufen lassen (Screenshots 390 px und Desktop nach `/root/reports/website-analyse-2026-10-01/`) → Commit „Analyse: Portal-Texte, Verantwortlichen-Vorschau und Bestätigungen nach geprüftem Wortlaut“
### Task 8: Mails K18/K19, Ticket-Texte, Neubau-Ergebnis (T10 Mails, T13b/c, T17)
**Files:** `server/services/analyse.js`, `server/services/analyseTexts.js` (neu), `server/mail-templates/K18 Analyse aktiv.html`, `server/mail-templates/K19 Analyse beendet.html`, `test/analyse.test.js`, `test/mailTemplates.test.js`.
**Produces:**
- `analyseTexts.js`: Konstanten `PRIVACY_SNIPPET_DU` und `PRIVACY_SNIPPET_SIE` (aus `banner-portal-mailtexte.md` § 7, Fließtext ohne Markdown-Sternchen, Überschrift „Reichweitenmessung“ eigene Zeile), Funktionen `privacyTicketDetails({ name, domains, rebuildNote })` (Kopfzeilen `Funktion`, `Projekt`, `Domain(s)`, `Zusatzseite`, danach die vier Punkte aus § 6 „Beim Einschalten“, darunter beide Bausteine) und `removalTicketDetails({ name, domains })` (Kopfzeilen + die drei Punkte aus § 6 „Beim Ausschalten“); `publishTicketDetails({ name, domains, reason })` (NEU, kurz: Website neu veröffentlichen, damit Skript und Zusatzseite erscheinen; `reason` ∈ `kein_repo|preview_aus|fehler`).
- `rebuildSafe(project)` gibt `{ ok, reason }` zurück: `kein_repo` (kein `repoFullName`), `preview_aus` (`previewMode === 'off'`), `fehler` (Ausnahme oder `result.error`), sonst `ok:true`.
- `privacyTicket` (T17) nutzt `privacyTicketDetails`, Thema bleibt „Datenschutzerklärung von <Projekt> um Hinweis auf Analyse ergänzen“. Beim Einschalten: erst `rebuildSafe`, dann Ticket; ist der Neubau nicht möglich oder gescheitert, zusätzlich Ticket `Website-Analyse von <Projekt> veröffentlichen` (Marker `publishTicketId`, genau eines je Aktivierung).
- `deactivateAnalyse`: nach `rebuildSafe` Ticket „Website-Analyse von <Projekt> entfernen“ (Details aus § 6 „Beim Ausschalten“; Marker `removalTicketId`, genau eines je Beendigung; Ticket-Fehler loggen, nie das Ausschalten verhindern). Gilt für Kunde, Mitarbeiter und `runAboCheck` (geht über `deactivateAnalyse`).
- `mailSafe(customerId, template, project, { values, blocks, attachments })`: K18 bekommt `legalPdfAttachments(config.analyse.avvVersion)`; sind sie unlesbar, wird nicht gesendet (`false`, Marker `activatedMailAt` bleibt leer ⇒ Wiederholung beim nächsten Abgleich, Task 9). Zusätzliche Werte K18: `fassung`, `zeitpunkt` (Berlin, `TT.MM.JJJJ, HH:MM Uhr` über `berlinDateTime`), `person`, `datum` (`TT.MM.JJJJ`), `weg` (`Chat im Kundenportal` bzw. `E-Mail`), `avv_url`, `lb_url` (absolut mit `config.publicBaseUrl`); Blöcke `portal_weg` (Kunde) und `textform_weg` (Mitarbeiter).
- K18: Absatz nach „Hallo {{kunde_name}},“ durch § 4 ersetzen, Tabelle um die Zeilen „Vereinbart“ und „Zugestimmt“ (§ 4) erweitern, Kopfkommentar `Platzhalter` und `Optionale Blöcke` nachziehen (`mailTemplates.test.js` prüft Kopfkommentar gegen Vorlage). K19: Absatz durch § 5 ersetzen.
**Tests:**
- [ ] Einschalten (Kunde): K18 verschickt, `attachments.length === 2`, Dateinamen `AVV-Website-Analyse-2026-10-02.pdf` und `Leistungsbeschreibung-Website-Analyse-2026-10-02.pdf`, Inhalte beginnen mit `%PDF`; HTML enthält `2026-10-02`, die beiden absoluten Links und die Zeile mit `durch <Name>`; Text enthält den Einleitungssatz aus § 4 („Das Einwilligungsbanner erscheint mit der nächsten Veröffentlichung“).
- [ ] Einschalten (Mitarbeiter, `textform {date:'2026-10-03', channel:'email'}`): Mail enthält `in Textform am 03.10.2026 per E-Mail`.
- [ ] PDFs unlesbar (Pfad per Testschalter ungültig) ⇒ keine Mail, `activatedMailAt` leer, Aktivierung selbst bleibt gültig.
- [ ] Ticket beim Einschalten: Details enthalten „Textbaustein“, „offensichtlicher Widerspruch“, „externer Generator“, „Stundenkontingent“ und den Anfang beider Bausteine („Wir messen mit einer eigenen Reichweitenmessung ohne Cookies“, „Solange Sie im Banner nichts ausgewählt haben“); enthält **nicht** mehr „Satz + Link in die bestehende Datenschutzerklärung einfügen“.
- [ ] Ticket beim Ausschalten (Kunde, Mitarbeiter, Abo-Ende über `runAboCheck`): genau ein Ticket mit Thema `Website-Analyse von Bäckerei entfernen`; zweimal ausschalten ⇒ ein Ticket; Aus → An → Aus ⇒ zwei Tickets; Details enthalten „fünf Werktagen“ und „Stundenkontingent“.
- [ ] `rebuildSafe`: Projekt ohne `repoFullName` ⇒ `reason 'kein_repo'`, Rebuild-Stub nicht aufgerufen, beim Einschalten zusätzlich Ticket `… veröffentlichen`; `previewMode:'off'` ⇒ `preview_aus`; Rebuild wirft ⇒ `fehler`; erfolgreicher Rebuild ⇒ kein Veröffentlichen-Ticket.
- [ ] K19-Text enthält „keine Daten mehr gespeichert“ und „46. Tag“ (§ 5), nicht mehr „Der Banner und die Messung sind von Deiner Website entfernt“.
- [ ] `mailTemplates.test.js` grün (Kopfkommentar, Platzhalter, Blöcke, Textfassung ohne `{{`).
- [ ] `npm test` PASS → Commit „Analyse: K18 mit PDFs und Nachweis, K19 neu, Tickets für Einschalten, Ausschalten und Veröffentlichen“
### Task 9: Alle Beendigungswege setzen `active=false` + `offSince` (T6-Portal)
**Files:** `analytics/src/server.js`, `analytics/test/sites.test.js`, `server/services/analyticsClient.js`, `server/services/analyse.js`, `server/services/siteFeatures.js`, `server/services/customerAdmin.js`, `server/services/giteaAdmin.js` (`updateWebsiteProject`), `server/routes/abo.js` (`withdrawOrder`), `server/index.js`, `test/analyticsClient.test.js`, `test/analyse.test.js`, `test/retention.test.js`, `test/aboRoutes.test.js`, `test/giteaAdmin.test.js`, `test/analyticsContract.test.js`.
**Produces:**
- Messdienst: Admin-Route `POST /a/admin/sites/:siteKey/off` (Bearer wie alle Admin-Routen, 401 sonst; setzt `active=0` und `offSince` einmalig auf jetzt, ändert einen vorhandenen `offSince` nicht; unbekannter Schlüssel 404; ruft `onSitesChanged`). Portal-Client `analyticsClient.stopSite(siteKey)` (404 zählt als erledigt) und im Vertragstest `test/analyticsContract.test.js` ergänzt.
- `analyse.js`: `endAnalyse({ doc, project, reason, now, by, tickets = true, rebuild = true })` als gemeinsamer Kern (Messdienst `stopSite`, Dokument `state:'off'` + `offSince`, optional Neubau und Entfernen-Ticket, wenn Projekt und Kunde noch existieren); `deactivateAnalyse` ruft ihn. `endAnalyseForCustomer(customerId, { reason, mail })` beendet alle aktiven Analysen des Kunden (bei `mail:true` K19). `analyseOnProjectChanged(projectId)`: aktives Dokument; fällt die Berechtigung weg (`nicht_oeffentlich`, `keine_subdomain`) ⇒ `endAnalyse(reason:'projekt_nicht_mehr_oeffentlich')`, sonst `putSite` mit neuen `analyseDomains(project)`. `runAnalyseReconcile({ now })` (täglich nach `runAboCheck`, `server/index.js`): für Analyse-Dokumente `active` mit fehlendem Projekt oder fehlendem Kunden ⇒ `endAnalyse(reason:'projekt_geloescht'|'kunde_geloescht', tickets:false, rebuild:false)`; für `active` mit vorhandenem Projekt `putSite` mit aktuellen Domains (fängt geänderte `liveDomain`); für `off` und `disabled` `stopSite` (idempotent); für `active` ohne `activatedMailAt` K18 erneut versuchen.
- Hooks: `customerAdmin.deleteCustomerWithPortalAccess` ruft vor dem Löschen `endAnalyseForCustomer(customerId, { reason:'kunde_geloescht', mail:false })` (Fehler loggen); `giteaAdmin.updateWebsiteProject` ruft nach dem Speichern `analyseOnProjectChanged` (dynamischer Import wegen Zyklus, Fehler loggen); `siteFeatures.stopAnalyseCollecting` nutzt `stopSite` statt `putSite` mit Projekt-Domains (damit Löschfrist auch ohne Projekt wirkt); `withdrawOrder` ruft bei `type === 'wechsel'` und `inTime` `endAnalyseForCustomer(sub.customerId, { reason:'widerruf_wechsel', mail:true })` in `try/catch` (der Widerruf darf nie daran scheitern).
**Tests:**
- [ ] Messdienst: `POST …/off` ohne Token 401; unbekannt 404; bekannt ⇒ `active=0`, `offSince` gesetzt, zweiter Aufruf lässt den ersten Wert; direkt danach verwirft `/a/e` Ereignisse (Cache invalidiert) und `/a/w.js?s=` liefert das leere Skript.
- [ ] Kunde gelöscht (`deleteCustomerWithPortalAccess`): `stopSite` für jeden aktiven Analyse-Schlüssel des Kunden, Dokument `state:'off'`, **kein** Ticket, **keine** Mail, Löschen des Kunden gelingt auch wenn `stopSite` wirft.
- [ ] Projekt gelöscht (Dokument aus `websiteProjects` entfernt): `runAnalyseReconcile` ⇒ `stopSite`, `state:'off'`, `offSince` gesetzt, kein Ticket; Kunde fehlt ⇒ dasselbe; vorhandenes Projekt mit geänderter `liveDomain` ⇒ `putSite` mit neuer Domainliste; `off`-Dokument ⇒ `stopSite` aufgerufen.
- [ ] Domain/Öffentlichkeit: `updateWebsiteProject` mit `isPublic:false` ⇒ Analyse beendet (`stopSite`, `off`, Entfernen-Ticket); mit neuer `subdomain` ⇒ `putSite` mit `<neu>.project.webklar.com`; Fehler im Hook ändert das Ergebnis des Projekt-Updates nicht.
- [ ] Löschfrist: `applyDeletions` mit Analyse-Dokument, dessen Projekt nicht mehr existiert ⇒ Dokument `disabled`, Messdienst-Stub bekam `stopSite(siteKey)` (nicht `putSite`).
- [ ] Widerruf Wechsel (`POST /public/withdraw`, `order:'wechsel'`, in der Frist, Kunde hat aktive Analyse): Analyse `off`, `stopSite` aufgerufen, K19 gesendet; Widerruf außerhalb der Frist oder `order:'zusatzspeicher'` ändert nichts; wirft `endAnalyseForCustomer`, bleibt der Widerruf `200`.
- [ ] K18-Wiederholung: aktives Dokument ohne `activatedMailAt` und lesbaren PDFs ⇒ Abgleich sendet K18 genau einmal und setzt den Marker.
- [ ] `npm test` und `cd analytics && npm test` PASS → Commit „Analyse: alle Beendigungswege schalten den Messdienst ab (Kunde/Projekt weg, Domain, Löschfrist, Widerruf Wechsel)“
---
## Teil C – WOMS
### Task 10: WOMS – Zustimmung in Textform (T9)
**Files (WOMS-Worktree):** `src/lib/analyseView.js`, `src/components/AnalyseRow.jsx`, `src/lib/analyseApi.js`, `tests/unit/analyseView.test.mjs`.
**Produces:**
- `analyseApi.setAnalyse(projectId, { on, textformDate, textformChannel, audienceConfirmed })` sendet genau diese Felder (kein `avvConfirmedVerbally` mehr).
- `analyseView.js`: `avvLabel` entfällt; neu `textformLabel(version)` (Kästchentext aus `banner-portal-mailtexte.md` § 3, Links werden in der Komponente gesetzt), `canSubmitTextform({ confirmed, date, channel, today, minDate }) ⇒ boolean` (alle drei Pflichtangaben, `date ≥ minDate` = Fassungsdatum, `date ≤ today`, `channel ∈ {'chat','email'}`), `channelLabel(channel)` („Chat im Kundenportal“/„E-Mail“), `analyseStatus` zeigt bei `active` zusätzlich „Zustimmung in Textform am TT.MM.JJJJ per <Weg>“, wenn `avvConsentDate` vorhanden ist.
- `AnalyseRow.jsx`: Einschalte-Zeile zeigt Kästchen (§ 3) mit Links auf AVV und Leistungsbeschreibung (aus `data.legal`, absolut `https://project.webklar.com…`), Pflichtfeld „Datum der Zustimmung“ (`<input type="date" required max=heute min=Fassungsdatum>`), Pflichtauswahl „Weg“ (Radio „Chat im Kundenportal“ / „E-Mail“); Knopf „Einschalten“ bleibt gesperrt, bis `canSubmitTextform`. Aufklappbar „Textvorlage für den Chat“ (§ 3, `{Link AVV}`/`{Link Leistungsbeschreibung}` mit den echten Links gefüllt) mit Kopieren-Knopf. Ist `data.privacy.ok === false`, Einschalten gesperrt und Hinweis, welches Feld fehlt (`firma`/`email`).
**Tests:**
- [ ] `canSubmitTextform`: ohne Häkchen, ohne Datum, ohne Weg, Datum vor `2026-10-02`, Datum morgen ⇒ je `false`; alle gültig ⇒ `true`.
- [ ] `channelLabel('chat') === 'Chat im Kundenportal'`, `channelLabel('email') === 'E-Mail'`.
- [ ] `analyseStatus({enabled:true,state:'active',eligible:true,activatedAt:'2026-10-03T08:00:00Z',avvConsentDate:'2026-10-02',avvConsentChannel:'email'}).text` enthält `An seit 03.10.2026` und `Zustimmung in Textform am 02.10.2026 per E-Mail`.
- [ ] `setAnalyse` (Fetch-Stub): Body-JSON hat `on:true,textformDate,textformChannel,audienceConfirmed:true` und kein `avvConfirmedVerbally`.
- [ ] `npm test` und `npm run build` PASS → Commit „Analyse: Einschalten im WOMS nur mit Datum und Weg der Zustimmung in Textform“
---
## Livegang (jeder Schritt braucht das OK des Nutzers; Reihenfolge einhalten)
- **L1 Vorbereitung auf dem Server (ohne Portal-Code):**
1. Schema: im Portal-Umfeld (Appwrite-Key) `node scripts/analyse-setup.mjs --check`, dann `node scripts/analyse-setup.mjs` ausführen (Attribute an `portalFeatures` + Collection `analyseConsents`). Muss **vor** L2 passieren, weil der neue Portal-Code diese Felder schreibt.
2. `/home/webklar/apps/docker-compose.yml`, Dienst `analytics` (Zeilen ~1786-1814): unter `mem_limit: 384m` die Zeile `memswap_limit: 384m` ergänzen (T5). Kommentar zum Traefik-Zugriffsprotokoll ergänzen (T18): am Traefik-Block (Zeilen ~15-56) „Zugriffsprotokoll bleibt aus“ und an den Labels von `webklar-analytics` „falls je eingeschaltet: `traefik.http.routers.webklar-analytics.observability.accessLogs=false` setzen“. `docker compose config -q` prüfen, `docker compose up -d analytics`, danach `docker inspect webklar-analytics --format '{{.HostConfig.MemorySwap}}'` = `402653184` (384 MiB).
3. Rollback: Zeile `memswap_limit` entfernen, `up -d analytics`; Schema-Skript ist additiv.
- **L2 Portal-Push durch den Nutzer** (meine Pushes auf `main` sind blockiert): Branch `feature/website-analyse` in `main` übernehmen und pushen, zusätzlich Tag `analyse-texte-2026-10-02` (lokal angelegt in Task 6) pushen; `deploy.sh` startet bei `analytics/`-Änderung den Messdienst neu. Prüfen: `curl -s https://project.webklar.com/a/health` ⇒ `{"ok":true}`; `curl -s 'https://project.webklar.com/a/w.js?s=unbekannt00000000000'` ⇒ `/* WEBklar Analyse: aus */`; `https://project.webklar.com/rechtliches/avv-website-analyse.html` und `…/leistungsbeschreibung-website-analyse.html` zeigen die geprüfte Fassung 2026-10-02 ohne „Entwurf“/„Prüfpunkt“; `…-2026-10-02.pdf` laden; `ANALYSE_ENABLED` weiter `off` (Analyse-Routen antworten 409). `docs/rechtliches/archiv/README.md`: „gültig ab“ mit dem Tag des Einschaltens (L5) nachtragen.
- **L3 Browser-Probe auf einer öffentlichen Wegwerf-Website** (statische Testseite unter `*.project.webklar.com`, Skript-Tag von Hand mit `w.js?s=<Probe-Schlüssel>`; Website beim Messdienst per Admin-Token (`PUT /a/admin/sites/<key>`, interner Port) angelegt, Portal-Aktivierung nicht nötig). Frisches Chrome-Profil, Netzwerk-Tab offen; vorher im selben Profil im Kundenportal anmelden, damit Portal-Cookies existieren. Belege als Screenshots/Netzwerk-Export nach `/root/reports/website-analyse-2026-10-01/livegang/`:
1. Ohne Wahl: genau ein `POST /a/e` mit Körper `{"t":"pv","site":"…","path":"/…"}` (keine Felder `ref`, `vp`) und ein `bn shown`; **kein** `Cookie`-Header an `project.webklar.com/a/e`; Banner-Text, `aria-label`, Platz unten (Footer-Links erreichbar) und Knopf „Datenschutz-Einstellungen“ ≥ 24 × 24 px prüfen.
2. „Ablehnen“: genau ein `bn reject`; danach Seitenwechsel (Link, `pushState`-Seite falls vorhanden, Zurück-Taste, Scrollen, Klicken) und Neuladen ⇒ **keine** weiteren `/a/e`-Anfragen, nur Knopf „Datenschutz-Einstellungen“.
3. GPC: Browser mit `Sec-GPC`/`navigator.globalPrivacyControl === true` (Firefox `privacy.globalprivacycontrol.enabled`) ⇒ **null** `/a/e`-Anfragen, kein Banner, kein Knopf.
4. „Annehmen“ auf laufender Seite: `bn accept`, `cx` mit `ref` und `vp`, später `ck`/`ct`/`en`; neue Banner-Fassung testen, indem im Speicher `wk-consent` ohne `bv` gesetzt wird ⇒ Banner erscheint erneut.
5. Datenbank: `docker exec webklar-analytics node -e "…SELECT COUNT(*) FROM events_raw WHERE ts % 3600000 != 0…"` ⇒ 0 (alle Zeitstempel voll Stunde, auch `clicks`); unbefragtes `pv` hat `vp IS NULL` und `ref IS NULL`; Pfad mit 7-stelliger Zahl erscheint als `:id`.
6. Export: `docker exec webklar-analytics node scripts/export-site.mjs <key> --out /srv/webklar-analytics/exports` ⇒ drei CSVs mit Daten; danach den Ordner löschen.
7. Ausschalten: `PUT /a/admin/sites/<key>` mit `active:false` ⇒ `curl -sI 'https://project.webklar.com/a/w.js?s=<key>'` zeigt den leeren Körper und `Cache-Control: public, max-age=60`; nach > 60 Sekunden Seite neu laden (Cache leeren) ⇒ kein Banner, keine `/a/e`-Anfragen; `offSince` gesetzt.
8. Aufräumen: `DELETE /a/admin/sites/<key>`, Wegwerf-Website entfernen, Protokolle der Probe löschen.
- **L4 WOMS-Push durch den Nutzer:** WOMS-Branch nach dem üblichen Weg (Test, dann main) pushen und ausrollen. WOMS muss zeitnah zu L2 live gehen, weil die neue Portal-Route das alte `avvConfirmedVerbally` ablehnt (Schalter ist bis L5 aus, es gibt keinen Schaden).
- **L5 Einschalten (Nutzer):**
1. Nutzer lädt AVV und Leistungsbeschreibung Fassung 2026-10-02 in WOMS „Rechtliches“ hoch (anwaltliche Freigabe vorausgesetzt).
2. `ANALYSE_ENABLED=on` im Portal-Umfeld setzen, Portal neu starten.
3. Echter Kundentest mit einer Kunden-Website mit Wartungs-Abo: Einschalten im Portal ⇒ K18 mit beiden PDFs, Historie-Eintrag, Ticket mit Textbaustein, Neubau, `w.js?s=` und `datenschutz-analyse.html` auf der Seite; Mitarbeiter-Weg an einem zweiten Testprojekt (Datum + Weg Pflicht, K18 mit „in Textform am …“); Ausschalten ⇒ Bestätigungstext, Ticket „Website-Analyse von … entfernen“, nach Neubau kein Banner.
4. Rückfall: `ANALYSE_ENABLED=off` (Routen 409, bestehende Analysen laufen weiter und werden vom täglichen Abgleich weiter beendet, wenn das Abo endet).
---
## Außerhalb des Plans
- **T12:** Hetzner-Konto prüfen (Backups für `ubuntu-16gb-webklar-v1`, AV-Vertrag) – vom Server aus nicht prüfbar, Aufgabe des Nutzers.
- **T20:** Portal-Zustimmung zu einer neuen Fassung für laufende Analysen (Hinweis plus Haken); bis dahin Zustimmung in Textform nach AVV Nr. 12 Abs. 2. Technisch erst vor der ersten Fassungsänderung nötig.
- **T21:** `journald` begrenzen (`MaxRetentionSec=14day`, `MaxFileSec=1day`, `journalctl --vacuum-time=14d`); betrifft Abo-Bedingungen v5, nicht die Analyse.
- **T22:** Datenbank-Export `/opt/webklar-backup/db-dump.sh` für SQLite-Anwendungen prüfen; betrifft Abo-Bedingungen v5.
- Weiter nicht Teil des Plans: `abo-bedingungen-v6-vorschlag.md` (reiner Text), E6 (nur Text in der Leistungsbeschreibung: drei Monate Frist, Sonderkündigungsrecht, keine Technik), anwaltliche Abnahme der Texte, „gültig ab“-Eintrag im Archiv (L2/L5). T5 und T18 stehen im Livegang L1.
## Offene Annahmen (kurz prüfen, bevor die Task startet)
- WOMS-Tests laufen über `node --test` (nicht vitest) – Befehle im Plan entsprechend.
- Das Ticket-Feld `details` der Collection `workorders` muss die beiden Textbausteine (ca. 2 KB) aufnehmen; sonst in Task 8 nur den Du-Baustein ins Ticket und den Sie-Baustein in einen zweiten Absatz-Hinweis „liegt in `server/services/analyseTexts.js`“ legen (vor Task 8 mit `grep details APPWRITE_SCHEMA.md` bzw. Appwrite prüfen).
- `runDeploy` meldet nur synchron erkennbare Fehler (`result.error`); spätere Runner-Fehler bleiben über `previewState: 'failed'` sichtbar und lösen kein zusätzliches „veröffentlichen“-Ticket aus.
- Heatmap-Links für Seiten, deren Pfad durch T14 zu `:id` wurde, führen auf eine nicht existierende Seite; das ist Absicht (Pfade mit Kennungen sind nicht auswertbar) und braucht keine Sonderbehandlung.
- Der echte `w.js` bleibt 3600 s im Browser-Cache; wer vor dem Ausschalten geladen hat, sieht das Banner bis zu eine Stunde lang weiter, der Messdienst speichert dann aber nichts mehr (`active=0`). Das erfüllt LB Nr. 7 Abs. 2 („spätestens innerhalb von fünf Werktagen“). Soll es kürzer sein, `max-age` für das echte Skript in Task 4 auf 300 s setzen.
### Critical Files for Implementation
- /home/webklar/apps/kundenbereich.webklar.com/dev-analyse/analytics/client/w.js
- /home/webklar/apps/kundenbereich.webklar.com/dev-analyse/analytics/src/ingest.js
- /home/webklar/apps/kundenbereich.webklar.com/dev-analyse/server/services/analyse.js
- /home/webklar/apps/kundenbereich.webklar.com/dev-analyse/scripts/analyse-setup.mjs
- /home/webklar/apps/ticket.webklar.com/dev-analyse/src/components/AnalyseRow.jsx
@@ -0,0 +1,225 @@
# Admin-Panel ins Ticketsystem – Etappe 3 (Gutschriften, Abo & Angebote, E-Mail-Speicher, Funktionen) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Die restlichen Admin-Panel-Bereiche laufen im WOMS: Sammellisten Gutschriften, Abo-Angebote, E-Mail-Speicher unter Kunden; Kundenreiter „Abo & Angebote“, „Gutschriften“, „Funktionen“; Abo-Status für alle Mitarbeiter. Das Admin-Panel bleibt bis Etappe 4 nutzbar.
**Architecture:** Die vier bestehenden Admin-Router des Portals (`takeoverAdminRouter`, `offersAdminRouter`, `storageTasksAdminRouter`, `funktionenAdminRouter`) werden zusätzlich unter `/api/team` eingehängt (Logik unverändert, Sitzung aus dem JWT), ergänzt um Kundenfilter, Zähl-Routen ohne Stripe, Abo-Status und signierte Nachweis-Links. WOMS baut die Oberflächen in React nach; reine Logik (Texte, Rechnungen, Prüfungen) in testbaren `src/lib/*`-Modulen, portiert aus `public/admin.js`.
**Tech Stack:** Portal Express 4 (ESM, Node 20), node:test + `test/support/*` (Appwrite-Nachbau, Stripe-Attrappe, Mail-Senke). WOMS React 18, react-router 6, node:test + `tests/unit/load.mjs`, Playwright-Harness im Scratchpad.
**Spec:** `docs/superpowers/specs/2026-10-01-admin-panel-ins-ticketsystem-design.md` (Abschnitt 1 Sidebar/Kundenseite, Abschnitt 2 Gutschriften/Abo & Angebote/E-Mail-Speicher/Funktionen, Abschnitt 4 Tabelle `/api/team`, Etappe 3 in Abschnitt 5). Abo-Status-Route aus Etappe 1 (dort bewusst verschoben) gehört hierher.
## Global Constraints
- Arbeitsorte: Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-admin-umzug` (Branch `feature/admin-umzug`, Stand `a6ac451` = origin/main), WOMS `/home/webklar/apps/ticket.webklar.com/dev-admin-umzug` (Branch `feature/admin-umzug`, Stand `813c6a7` = origin/test). Vor Beginn je `git fetch` + `git merge --ff-only`.
- Nie `git add -A`, nichts pushen; Livegang Portal zuerst, dann WOMS (Nutzer pusht).
- `command grep -a`. UI-Texte deutsch mit Umlauten. **Funktionsgleich zum Admin-Panel:** Texte, Bestätigungsfragen, Prüfungen und Abläufe wortgleich aus `public/admin.js` (Zeilenangaben je Task); keine neuen Funktionen.
- Server-Logik der vier Router bleibt unverändert; nur Einhängen, Filter, Zähl-Routen, Abo-Status, Signatur-Links kommen dazu.
- Reiterfolge Kundenseite: `Übersicht · Tickets · Rechnungen · Projekte · Chat · Abo & Angebote · Gutschriften · Funktionen · Protokoll` (ids `overview, tickets, invoices, projects, chat, abo, gutschriften, funktionen, protokoll`; Rechnungen nur Admins).
- Sidebar-Unterpunkte unter Kunden: `Chat` (Zahl ungelesen), `Gutschriften` (Zahl offen), `Abo-Angebote`, `E-Mail-Speicher` (Zahl offen) – Abfrage alle 60 s bei sichtbarem Tab.
- Routen WOMS: `/customers/gutschriften` (`?antrag=<id>`), `/customers/angebote`, `/customers/email-speicher`; Kundenseite `?tab=gutschriften&antrag=<id>`.
- Signierte Nachweis-Links: `signFileLink({ kind: 'takeover', customerId: <requestId>, id: <fileId> })`, 15 min.
- Speicherbremse für Builds/Playwright (`systemd-run --scope … MemoryMax`).
## Review Focus
1. **Doppelklick oder zwei Mitarbeiter gleichzeitig auf „Genehmigen & gutschreiben“:** Erwartung: genau eine Gutschrift in Stripe, die zweite Aktion bekommt die Server-Meldung „Über diesen Antrag wurde bereits entschieden.“ (Task 6: Knopf während der Anfrage gesperrt, 409 sichtbar).
2. **Kundenwechsel im Angebotsformular, während der Kontext noch lädt:** Erwartung: Vorschau und „Angebot anlegen“ zeigen nie Daten des vorigen Kunden; Speichern erst, wenn der Kontext zum aktuellen Kunden da ist (Task 7, Playwright).
3. **Mitarbeiter ohne Admin-Label:** Erwartung: Gutschriften, Angebote, E-Mail-Speicher, Funktionen und Abo-Status funktionieren vollständig (Task 1/3 Server-Tests mit `jwt-staff`).
4. **Nachweis länger als 15 min geöffnet:** Erwartung: erneutes Öffnen der Antragsdetails liefert frische Links (Task 2: Links werden bei jedem `GET /:id` neu signiert).
5. **Handy (390 px):** Listen und Detail ohne waagerechtes Scrollen bedienbar (Task 6–8, Playwright).
---
## Portal
### Task 1: Admin-Router im Team-Eingang, Kundenfilter, Zähl-Route E-Mail-Speicher
**Files:**
- Modify: `server/routes/team/index.js` (nach `requireStaffJwt`: `router.use('/takeover-credits', takeoverAdminRouter)`, `router.use('/offers', offersAdminRouter)`, `router.use('/storage-tasks', storageTasksAdminRouter)`, `router.use('/funktionen', funktionenAdminRouter)`)
- Modify: `server/routes/takeoverCredits.js:119-153` (`?customerId=` filtert `requests`; `openCount` unverändert über alle)
- Modify: `server/routes/offers.js:23-39` (`?customerId=` filtert `offers`)
- Modify: `server/routes/storageTasks.js` (neu `GET /open-count` vor `/:id`-Routen: `{ open }` = offene Speicher-Aufgaben, **ohne** `reconcileOpenSwitches`)
- Test: `test/teamAdminRoutes.test.js`
**Interfaces:**
- Produces (HTTP, gleiche Form wie unter `/api/portal-admin`): `/api/team/takeover-credits[?status=all][&customerId=]`, `/calc`, `/open-count`, `/:id`, `/:id/decision`, `/:id/apply`; `/api/team/offers[?customerId=]`, `/context/:customerId`, `POST /`, `/:id/withdraw`, `/:id/notify`; `/api/team/storage-tasks`, `/open-count`, `/:id/done`; `/api/team/funktionen/:customerId`, `POST /:customerId/news`.
- [ ] **Step 1: Failing tests** (Aufbau wie `test/teamChat.test.js`; `seedFixtures`, Mitarbeiter `e_staff2`; Gutschrift-Antraege `takeoverCredits` mit Schema aus `schemaKeys('takeoverCredits')`; Speicher-Aufgaben als `contractEvents` `type: 'speicher', status: 'offen'` wie in `test/storageTasks.test.js`)
```js
test('alle vier Bereiche mit jwt-staff erreichbar (kein Admin-Label noetig)', async () => {
for (const p of ['/api/team/takeover-credits', '/api/team/offers', '/api/team/storage-tasks', '/api/team/funktionen/c1']) {
assert.equal((await rawGet(p, staff)).status, 200, p)
}
})
test('Kundenfilter: Gutschriften und Angebote nur des Kunden, openCount weiter ueber alle', async () => {
const credits = (await rawGet('/api/team/takeover-credits?status=all&customerId=c1', staff)).body
assert.ok(credits.requests.length > 0 && credits.requests.every((r) => r.customerId === 'c1'))
assert.equal(credits.openCount, /* offene Antraege aller Kunden */ 2)
const offers = (await rawGet('/api/team/offers?customerId=c2', staff)).body.offers
assert.ok(offers.every((o) => o.customerId === 'c2'))
})
test('E-Mail-Speicher open-count ohne Stripe-Abgleich', async () => {
const before = st.calls.length
assert.deepEqual((await rawGet('/api/team/storage-tasks/open-count', staff)).body, { open: 1 })
assert.equal(st.calls.length, before)
})
test('Mitarbeiter aus dem JWT: Rueckfrage traegt "Andrej <andrej@webklar.com>", Erledigt traegt die Mail, Funktionen-Notiz den Namen', async () => {
// POST /api/team/takeover-credits/<id>/decision { action: 'question', note: 'Bitte Rechnung schicken' } -> 200, Verlauf by 'Andrej <andrej@webklar.com>'
// POST /api/team/storage-tasks/<id>/done -> task.doneBy 'andrej@webklar.com'
// POST /api/team/funktionen/c1/news { projectId, action: 'activate', note: 'Pilot' } -> adminNote enthaelt 'Andrej: activate'
})
test('ohne JWT 401 fuer alle neuen Pfade', async () => {})
```
- [ ] **Step 2:** Run `node --test test/teamAdminRoutes.test.js` → FAIL (404 unter `/api/team/...`)
- [ ] **Step 3:** Implementieren
- [ ] **Step 4:** Run `npm test` → PASS (bestehende `storageTasks.test.js`, `funktionenRoutes.test.js` bleiben grün)
- [ ] **Step 5: Commit** — `git add server/routes/team/index.js server/routes/takeoverCredits.js server/routes/offers.js server/routes/storageTasks.js test/teamAdminRoutes.test.js` · Nachricht „Team-Eingang: Gutschriften, Angebote, E-Mail-Speicher und Funktionen fuer alle Mitarbeiter, Kundenfilter, Zaehlroute“
### Task 2: Signierte Links für Gutschrift-Nachweise
**Files:**
- Modify: `server/routes/takeoverCredits.js:176-182` (Antwort von `GET /:id`: jedes `request.files[i]` bekommt `url` = `${config.publicBaseUrl}/api/team/takeover-credits/<id>/files/<fileId>?exp=…&sig=…`)
- Modify: `server/routes/team/files.js` (`GET /takeover-credits/:id/files/:fileId` mit `sig` → wie `takeoverAdminRouter.get('/:id/files/:fileId')` über `getTakeoverRequest` + `readUpload`; ungültig → 403 `{ error: 'Link abgelaufen. Bitte den Antrag neu öffnen.' }`; ohne `sig` → `next()`)
- Test: `test/teamAdminRoutes.test.js` (ergänzen)
**Interfaces:**
- Consumes: `signFileLink`/`verifyFileLink` (`server/services/signedLinks.js`).
- Produces: `request.files[].url` in `GET /api/team/takeover-credits/:id` (und unter `/api/portal-admin`, dort unbenutzt).
- [ ] **Step 1: Failing tests**
```js
test('Nachweis: signierter Link ohne JWT 200 mit Inhalt, fremder Antrag/abgelaufen 403, ohne sig 401', async (t) => {
// Antrag mit gespeicherter Datei (wie test/takeoverCredits.test.js bzw. services/takeoverCredits.js storeUpload) seeden
// GET /api/team/takeover-credits/<id> (staff) -> request.files[0].url passt auf /\/api\/team\/takeover-credits\/<id>\/files\/<fid>\?exp=\d+&sig=[0-9a-f]{64}$/
// fetch(url ohne Origin) -> 200, Content-Type application/pdf
})
```
- [ ] **Step 2:** Run → FAIL · **Step 3:** Implementieren · **Step 4:** `npm test` → PASS
- [ ] **Step 5: Commit** — „Gutschriften: signierte Links fuer Nachweise im Ticketsystem“
### Task 3: Abo-Status für alle Mitarbeiter
**Files:**
- Modify: `server/routes/team/customers.js` (`GET /:id/abo`)
- Test: `test/teamRoutes.test.js` (ergänzen)
**Interfaces:**
- Consumes: `getCurrentSubscription(customerId)` (`server/services/stripeBilling.js:178`, nur Appwrite), `PRODUCT_KEYS`/`ACTIVE_STATUSES` wie in `server/routes/admin/customers.js:49-61`.
- Produces: `GET /api/team/customers/:id/abo` → `{ productKey: 'wartung'|'hosting'|null, active: boolean, status: string|null, storageUnits: number, startedAt: string|null, serviceStart: string|null, currentPeriodEnd: string|null, cancelAt: string|null }` (`productKey`/`active` exakt wie die Admin-Route, damit `useCustomerAbo` umsteigen kann).
- [ ] **Step 1: Failing test**
```js
test('abo: Mitarbeiter ohne Admin-Label sieht den Abo-Status', async () => {
aw.seed('subscriptions', { $id: 'sub_t', customerId: 'c3', status: 'active', productKey: 'wartung', storageUnits: 1, startedAt: '2026-09-01T10:00:00.000Z', serviceStart: '2026-09-15T00:00:00.000Z', currentPeriodEnd: '2026-11-01T00:00:00.000Z' })
const res = await rawGet('/api/team/customers/c3/abo', auth)
assert.deepEqual(res.body, { productKey: 'wartung', active: true, status: 'active', storageUnits: 1, startedAt: '2026-09-01T10:00:00.000Z', serviceStart: '2026-09-15T00:00:00.000Z', currentPeriodEnd: '2026-11-01T00:00:00.000Z', cancelAt: null })
assert.deepEqual((await rawGet('/api/team/customers/c4/abo', auth)).body, { productKey: null, active: false, status: null, storageUnits: 0, startedAt: null, serviceStart: null, currentPeriodEnd: null, cancelAt: null })
})
```
- [ ] **Step 2:** Run → FAIL · **Step 3:** Implementieren · **Step 4:** `npm test` → PASS
- [ ] **Step 5: Commit** — „Abo-Status eines Kunden fuer alle Mitarbeiter im Team-Eingang“
---
## WOMS
### Task 4: Team-API, Zähler und Format-Hilfen
**Files:**
- Modify: `src/lib/teamApi.js`, `src/lib/navLinks.js` (+ Test), `src/components/Navbar.jsx`, `src/hooks/useCustomerAbo.js` (auf `getCustomerAboStatus` umstellen, Rückgabe unverändert)
- Create: `src/hooks/useSidebarCounts.js` (ersetzt `useChatUnread.js`, Datei entfernen), `src/lib/money.js`, `src/lib/creditFormat.js`, `src/lib/offerFormat.js`, `src/lib/storageFormat.js`
- Test: `tests/unit/navLinks.test.mjs` (anpassen), `tests/unit/creditFormat.test.mjs`, `tests/unit/offerFormat.test.mjs`, `tests/unit/storageFormat.test.mjs`
**Interfaces:**
- Produces in `teamApi.js`: `listCredits({ status, customerId })`, `getCreditOpenCount()`, `getCredit(id)`, `calcCredit(body)`, `decideCredit(id, body)`, `applyCredit(id)`; `listOffers({ customerId })`, `getOfferContext(customerId)`, `createOffer(body)`, `withdrawOffer(id)`, `notifyOffer(id)`; `listStorageTasks()`, `getStorageOpenCount()`, `markStorageDone(id)`; `getFunktionen(customerId)`, `setNewsFeature(customerId, { projectId, action, note })`; `getCustomerAboStatus(customerId)`.
- Produces: `useSidebarCounts() -> { chat, credits, storage }` (60 s, sichtbarer Tab, Fehler behalten Wert); `buildNavLinks({ isAdmin, pulseLevel, counts })` mit Kunden-Kindern `chat` (count `counts.chat`), `gutschriften` (`/customers/gutschriften`, `counts.credits`), `angebote` (`/customers/angebote`, kein count), `speicher` (`/customers/email-speicher`, Label „E-Mail-Speicher“, `counts.storage`).
- Produces in `money.js`: `formatCents(cents) -> '12,34 €'`, `parseEuroToCents(text) -> number|null`.
- Produces in `creditFormat.js` (aus `public/admin.js:535-906`): `CREDIT_STATUS` (Pill-Text und Ton je Status, admin.js `creditStatusPill`), `historyLabel(type)`, `calcLine(calc)` (`Rechnungszeitraum … → Vorschlag …`), `decisionError(action, note) -> string|null` (Notiz ≥ 5 Zeichen bei question/reject, Texte wie admin.js:845-857), `decisionConfirm(action, amountCents, customerName) -> string` (admin.js:858-863), `decisionMessage(action, result) -> string` (admin.js:873-881), `applyMessage(result)`, `BILLING_MONTHS = [1, 3, 6, 12, 24, 36]`.
- Produces in `offerFormat.js` (aus `public/admin.js:984-1317`): `addMonthsYmd`, `offerDiscountEstimate`, `discountLine(offer)` (admin.js:1034-1057), `offerPreviewText`, `evaluateOfferForm(context, form) -> { errors: string[], running: boolean, saveLabel: string, previewLines: string[] }` (Regeln admin.js `renderOfferPreview` 1155-1205), `offerConfirm(context, form, preview)` (admin.js:1229), `createdMessage(result)`.
- Produces in `storageFormat.js` (aus `public/admin.js:908-982`): `storageLabel(units)`, `summaryText({ allocatedGb, packageGb, warnGb, packageName })`, `taskText(task)`, `orderedText(task)`, `failedLine(event)`.
- [ ] **Step 1: Failing tests** (Erwartungswerte wortgleich aus admin.js; je Modul mindestens: alle Status-/Verlaufs-Texte, eine vollständige Rechenzeile, beide Validierungsfehler, alle drei Bestätigungsfragen, Erfolgsmeldung mit gebucht/nicht gebucht und Mail gesendet/fehlgeschlagen; Angebots-Vorschau mit/ohne laufendem Abo, offenes Angebot blockiert, Preis ≥ regulär blockiert, Rabattende geschätzt und bekannt; Speicher-Texte einrichten/periodenende/teilweise/gescheitert; `formatCents(123456) === '1.234,56 €'`, `parseEuroToCents('12,5') === 1250`, `parseEuroToCents('') === null`)
```js
test('buildNavLinks: vier Unterpunkte unter Kunden mit Zahlen', () => {
const kunden = buildNavLinks({ isAdmin: false, pulseLevel: 'ok', counts: { chat: 3, credits: 1, storage: 2 } }).find((l) => l.key === 'customers')
assert.deepEqual(kunden.children.map((c) => [c.key, c.label, c.href, c.count]), [
['chat', 'Chat', '/customers/chat', 3],
['gutschriften', 'Gutschriften', '/customers/gutschriften', 1],
['angebote', 'Abo-Angebote', '/customers/angebote', undefined],
['speicher', 'E-Mail-Speicher', '/customers/email-speicher', 2],
])
})
```
- [ ] **Step 2:** Run `node --test tests/unit/*.test.mjs` → FAIL
- [ ] **Step 3:** Implementieren (Icons in `Navbar.jsx`: Gutschriften `IconReceiptRefund`, Angebote `IconDiscount`, E-Mail-Speicher `IconMailCog` – vorher in `node_modules/@tabler/icons-react/dist/esm/icons/` prüfen, sonst nächstliegendes vorhandenes Icon)
- [ ] **Step 4:** Run `npm test` → PASS
- [ ] **Step 5: Commit** — „Team-API, Sidebar-Zaehler und Format-Hilfen fuer Gutschriften, Angebote und E-Mail-Speicher“
### Task 5: Abo-Status, Funktionen-Reiter und E-Mail-Speicher
**Files:**
- Create: `src/components/customer/AboStatusCard.jsx`, `src/components/customer/CustomerFunktionen.jsx`, `src/pages/StorageTasksPage.jsx`
- Modify: `src/App.jsx` (Route `/customers/email-speicher`), `src/lib/customerTabs.js` (+ Test: `abo`, `gutschriften`, `funktionen` an ihren Plätzen; `abo` rendert vorerst nur `AboStatusCard`, Angebote folgen in Task 7), `src/pages/CustomerDetailPage.jsx`
**Interfaces:**
- Consumes: Task 4.
- Produces: `AboStatusCard({ customerId })`: Zeilen „Abo“ (Wartungs-Abo/Hosting-Abo/kein Abo, Status-Pille), „Leistungsbeginn“, „Nächste Abbuchung“ (nur aktiv), „Gekündigt zum“ (nur `cancelAt`), „Zusatzspeicher“ (`storageLabel`).
- Produces: `CustomerFunktionen({ customerId })` wie admin.js:188-296 (Tabelle Projekt | News | Ticket | Aktionen, Pillen Aus/Bestellt/Einrichtung/Aktiv/Abgeschaltet, Notiz gekürzt auf 60 Zeichen mit Titel, „Ticket fehlt“, Knöpfe „Aktivieren (ohne Zahlung)“/„Abschalten“ mit Inline-Bestätigung samt Notizfeld (max. 120) und „Ja“/„Abbrechen“, Status „Gespeichert.“).
- Produces: `StorageTasksPage` wie admin.js:908-982 (Zusammenfassung mit Warnfarbe ab `warnGb`, Tabelle Fällig | Kunde | Aufgabe in konsoleH | Bestellt | „Erledigt“, Rückfrage „Stripe-Menge von Hand korrigiert?“ nur bei `partial`, Liste „Gescheiterte Änderungen (letzte 30 Tage)“, Erklärtext aus `public/admin.html:299`); Kundenname verlinkt auf `/customers/<id>`.
- [ ] **Step 1:** `tests/unit/customerTabs.test.mjs` auf die volle Reiterfolge anpassen → Run → FAIL
- [ ] **Step 2: Playwright-Checks schreiben** (Harness wie Etappe 2): E-Mail-Speicher-Zusammenfassung mit Warnfarbe ab Schwelle, Aufgabentexte einrichten/periodenende, „Erledigt“ ruft `POST …/done` (bei `partial` erst nach Rückfrage), gescheiterte Liste; Funktionen-Reiter: „Aktivieren (ohne Zahlung)“ → Inline-Bestätigung mit Notiz → Body `{ projectId, action: 'activate', note }` → Zeile aktualisiert, „Gespeichert.“; Abo-Status-Karte für aktives Abo und „kein Abo“ → Run → FAIL
- [ ] **Step 3:** Implementieren; Run Harness → ok; `npm test` → PASS
- [ ] **Step 4: Commit** — „Kundenseite: Abo-Status und Funktionen; Seite E-Mail-Speicher“
### Task 6: Gutschriften (Sammelliste, Detail, Kunden-Reiter)
**Files:**
- Create: `src/components/credits/CreditList.jsx`, `src/components/credits/CreditDetail.jsx`, `src/pages/CreditsPage.jsx`
- Modify: `src/App.jsx` (Route `/customers/gutschriften`), `src/pages/CustomerDetailPage.jsx` (Reiter `gutschriften` mit `customerId`-Filter, `&antrag=` öffnet Detail)
**Interfaces:**
- Consumes: Task 4 (`listCredits`, `getCredit`, `calcCredit`, `decideCredit`, `applyCredit`, `creditFormat`), `KundenansichtButton` (statt „Als Kunde ansehen“).
- Produces: `CreditList({ customerId?, selectedId, onSelect })` (Filter „Offen“/„Alle“, Spalten und Leertexte wie admin.js; Knopf „Prüfen“/„Ansehen“); `CreditDetail({ id, onClose, onChanged })` mit allen Abschnitten aus admin.js:684-796 (Nachweise als Links auf `file.url`, neuer Tab), Formular mit 4 Prüfhaken, Rechner (`calcCredit` 300 ms entprellt, Gutschrift-Betrag folgt dem Vorschlag bis zur ersten eigenen Eingabe), Notiz (max. 4000), Knöpfe „Genehmigen & gutschreiben“ (erst bei 4 Haken und Betrag > 0) / „Rückfrage senden“ / „Ablehnen“ mit `decisionError` und `window.confirm(decisionConfirm(…))`; während der Anfrage alle drei Knöpfe gesperrt; Ergebnis über `decisionMessage`; entschiedene Anträge zeigen die Zusammenfassung und ggf. „Jetzt in Stripe gutschreiben“.
- Produces: `CreditsPage` unter `/customers/gutschriften`: Liste links/oben, Detail daneben/darunter; `?antrag=<id>` öffnet direkt, Schließen entfernt den Parameter.
- [ ] **Step 1: Playwright-Checks schreiben** (Harness wie Etappe 2 im Scratchpad, Stripe-/Mail-Ergebnisse per gemockter Team-API): Liste „Offen“ zeigt Pillen „Zu prüfen“/„Rückfrage beim Kunden“; Detail per `?antrag=`; Genehmigen gesperrt bis 4 Haken; Rechner-Zeile nach Eingabe; Rückfrage ohne Notiz → Fehlertext ohne Anfrage; Genehmigen → confirm-Text exakt, Body `{ action: 'approve', amountCents, checks, … }`, Meldung „Genehmigt. Das Guthaben ist in Stripe gebucht. Die E-Mail an den Kunden ist raus.“; Doppelklick → genau eine POST-Anfrage; 409 → Servertext sichtbar, Knöpfe wieder frei; Nachweis-Link zeigt auf `file.url`; Kunden-Reiter zeigt nur Anträge dieses Kunden; 390 px ohne waagerechtes Scrollen.
- [ ] **Step 2:** Run Harness → FAIL (Seiten fehlen)
- [ ] **Step 3:** Implementieren
- [ ] **Step 4:** Harness → alle ok; `npm test` → PASS
- [ ] **Step 5: Commit** — „Gutschriften im Ticketsystem: Sammelliste, Pruefung und Entscheidung, Kunden-Reiter“
### Task 7: Abo & Angebote (Sammelliste, Formular, Kunden-Reiter)
**Files:**
- Create: `src/components/offers/OfferList.jsx`, `src/components/offers/OfferForm.jsx`, `src/pages/OffersPage.jsx`
- Modify: `src/App.jsx` (Route `/customers/angebote`), `src/pages/CustomerDetailPage.jsx` (Reiter `abo`: `AboStatusCard`, darunter `OfferList customerId` und „Neues Angebot“ mit fest gewähltem Kunden)
**Interfaces:**
- Consumes: Task 4 (`listOffers`, `getOfferContext`, `createOffer`, `withdrawOffer`, `notifyOffer`, `offerFormat`, `customerPickLabel`/`findCustomerByLabel` aus `chatFormat.js`), `useCustomers`.
- Produces: `OfferList({ customerId?, reloadKey })` (Filter „Offen & aktiv“/„Alle“, Spalten, Statuszelle mit Pillen, Stripe-Links, Mailstatus, Knöpfe „E-Mail senden“/„Erneut senden“ und „Zurückziehen“ mit Bestätigungen/Meldungen wie admin.js); `OfferForm({ fixedCustomerId?, onCreated, onCancel })`: Felder und Vorgaben wie admin.js:1256-1311; Kontext genau einmal je aufgelöster Kunden-ID laden, veraltete Antworten verwerfen, beim Wechsel sofort leeren; Vorschau/Speichern über `evaluateOfferForm`, Speichern erst mit Kontext des aktuellen Kunden; `window.confirm(offerConfirm(…))`; nach dem Anlegen Meldung `createdMessage`.
- Produces: `OffersPage` unter `/customers/angebote` mit Knopf „Neues Angebot“.
- [ ] **Step 1: Playwright-Checks schreiben**: Liste „Offen & aktiv“ filtert, Rabattzeile; „Neues Angebot“: Kunde per eindeutiger Beschriftung, Kontext genau einmal geladen; Kundenwechsel mit verzögertem Kontext von A → Vorschau zeigt nie A, „Angebot anlegen“ gesperrt bis Kontext B da; offenes Angebot blockiert mit rotem Hinweis; laufendes Abo → Knopftext „Angebot anlegen und aufs laufende Abo anwenden“; confirm-Text exakt; Body `{ customerId, productKey, monthlyPriceCents, months, validUntil, note, internalNote, notifyCustomer }`; Zurückziehen/Erneut senden mit Bestätigung; Kunden-Reiter `abo` zeigt Abo-Status und nur eigene Angebote; 390 px ohne waagerechtes Scrollen.
- [ ] **Step 2:** Run → FAIL · **Step 3:** Implementieren · **Step 4:** Harness → ok; `npm test` → PASS
- [ ] **Step 5: Commit** — „Abo-Angebote im Ticketsystem: Sammelliste, neues Angebot, Kunden-Reiter Abo & Angebote“
---
## Abschluss
### Task 8: Gesamtprüfung und Übergabe
- [ ] Portal `npm test` grün; neue Tests unter Node 20 grün. WOMS `npm test` grün; Vite-Build erfolgreich; alle Harness-Checks (Etappe 2 und 3) grün.
- [ ] Abschlussreview beider Branches (frischer Reviewer, sonnet).
- [ ] Übergabe mit Livegang: 1. Portal `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-admin-umzug push origin feature/admin-umzug:main`; 2. Live-Check (ich): neue Team-Pfade ohne JWT 401, Admin-Panel unverändert; 3. WOMS `git -C /home/webklar/apps/ticket.webklar.com/dev-admin-umzug push origin feature/admin-umzug:test`; 4. Live-Check: Bundle enthält „Genehmigen & gutschreiben“, „Neues Angebot“, „Gescheiterte Änderungen“; 5. Nutzer-Test mit einem echten offenen Antrag/Angebot erst nach Rücksprache (echte Stripe-Buchungen!).
@@ -0,0 +1,167 @@
# Admin-Panel ins Ticketsystem – Etappe 4 (Abschalten) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** project.webklar.com ist nur noch Kundenoberfläche: Admin-Panel, Rechnungsanalyse, `/api/portal-admin`, Projekt-Admin-Wechsel und der Mitarbeiter-Login im Portal entfallen; Team-Mails verlinken ins Ticketsystem.
**Architecture:** Reiner Rückbau plus drei kleine Umstellungen: Login lehnt Mitarbeiter mit Hinweis ab, `/me` beendet alte Mitarbeiter- und Alt-Ansichts-Sitzungen mit Weiterleitung ins WOMS, Mail-Vorlagen zeigen auf WOMS-Seiten. Alles, was das WOMS über `/api/team` nutzt (Router, Chat-Baustein, Kundenansicht, Preview-Links), bleibt.
**Tech Stack:** Portal Express 4 (ESM, Node 20), node:test + `test/support/*`, Playwright-UI-Checks. WOMS React 18.
**Spec:** `docs/superpowers/specs/2026-10-01-admin-panel-ins-ticketsystem-design.md` (Abschnitt 4 „Was in Etappe 4 wegfällt“, Etappe 4 in Abschnitt 5; E1, E4, E10, E11)
## Global Constraints
- Arbeitsorte wie Etappe 3 (Portal Stand `40f0a04`, WOMS `feda436`); vorher `git fetch` + `git merge --ff-only`.
- **Livegang-Reihenfolge: WOMS zuerst** (Knopf „Projekt-Admin“ ruft `/api/admin/switch-link`), dann Portal. Nutzer pusht.
- Nie `git add -A` (Löschungen mit `git rm <datei>`), nichts pushen. `command grep -a`.
- **Bleibt:** `public/chat-composer.js/.css` (WOMS lädt sie), `GET /api/auth/staff-preview` und `/kundenansicht*`, `isAdminSession` in `/preview-access`, `services/staffMfaAdmin.js` + `scripts/mitarbeiter-2fa.mjs` (WOMS-2FA), `services/customerActivity.js`, `services/invoiceNinja.js`, `services/staffAccount.js`, `middleware/appwriteStaff.js`, alle Router unter `/api/team`.
- Fester Text Login-Sperre: `Mitarbeiter arbeiten im Ticketsystem.` + Link „Zum Ticketsystem“ auf `config.womsBaseUrl`.
- Mail-Texte und Design bleiben; nur Links, Linktext „Admin-Bereich“ → „Ticketsystem“ und Sätze, die den Admin-Bereich nennen.
## Review Focus
1. **Mitarbeiter mit alter Portal-Sitzung (Cookie bis 7 Tage):** öffnet `/dashboard.html` oder `/login.html`. Erwartung: Weiterleitung ins Ticketsystem bzw. normale Login-Seite, keine Fehlerseite, kein Zugriff auf Kundendaten (Task 1).
2. **Alte „Als Kunde ansehen“-Sitzung ohne Kennung:** Erwartung: wie abgelaufen behandelt (Cookie weg, Hinweis), keine Lese- oder Schreibzugriffe mehr (Task 2).
3. **Mitarbeiter mit 2FA meldet sich im Portal an:** Erwartung: Hinweis „Mitarbeiter arbeiten im Ticketsystem.“ ohne Code-Mail (Task 1).
4. **Team-Mail-Links:** jeder Link in T01–T18 öffnet eine bestehende WOMS-Seite; kein `admin.html` mehr (Task 3).
5. **Kunden-Login und Kundenansicht unverändert** (Task 1/2: bestehende Tests grün, UI-Check Kundenansicht).
---
## Portal
### Task 1: Mitarbeiter-Login sperren, alte Mitarbeiter-Sitzungen beenden
**Files:**
- Modify: `server/routes/auth.js` (`POST /login` :163-250: Mitarbeiter → Appwrite-Sitzung best effort beenden, `403 { error: 'Mitarbeiter arbeiten im Ticketsystem.', ticketUrl: config.womsBaseUrl }`; entfernen: `/login/mfa`, `/login/mfa/resend`, `startStaffPortalSession`, `mfaCookieOptions`, `MFA_GONE`, `GET /staff-switch`, Import `staffMfa`; `GET /me` :445-475: Rolle admin ohne Kundenansicht-Kennung → Portal- und Preview-Cookie löschen, `{ authenticated: false, reason: 'staff', redirect: config.womsBaseUrl }`)
- Delete: `server/services/staffMfa.js`, `test/staffMfa.test.js`
- Modify: `server/services/appwriteClient.js` (`sessionNeedsSecondFactor` entfernen; `appwriteSessionFetch` bleibt für `DELETE /account/sessions/current`)
- Modify: `public/login.html` (MFA-Formular :75-99 raus; unter `#login-error` ein verstecktes `<a id="login-staff-link">Zum Ticketsystem</a>`), `public/login.css` (MFA-Stile :658-671 raus), `public/app.js` (`showMfaStep`, `adminLoginTarget`, `mfaRequired`-Zweig raus; Login: bei 403 mit `ticketUrl` Text + Link zeigen; schon angemeldet: `me.reason === 'staff'` bleibt auf der Login-Seite; Dashboard: `me.reason === 'staff'` → `location.href = me.redirect`; `api()` reicht `ticketUrl` am Fehler weiter)
- Modify: `test/support/appwriteMock.js` (`DELETE /v1/account/sessions/current` mit Sitzungs-Cookie → 204, gezählt in `sessionDeletes()`)
- Test: `test/staffLogin.test.js` (umschreiben), `test/kundenansichtEnde.test.js` (Alt-Test `/me` mit `viewAsCookie` → neues Verhalten)
**Interfaces:**
- Produces: `POST /api/auth/login` für Mitarbeiter (employees-Eintrag oder Label admin, mit oder ohne 2FA) → 403 `{ error, ticketUrl }`, keine Code-Mail, keine Portal-Sitzung.
- Produces: `GET /api/auth/me` für Sitzungen `role: 'admin'` ohne `viewId` (reine Mitarbeiter-Sitzung und Alt-Ansicht) → `{ authenticated: false, reason: 'staff', redirect }` + Cookies gelöscht.
- [ ] **Step 1: Failing tests**
```js
test('Kunde meldet sich weiter normal an', async () => { /* bestehender Test :31-36 bleibt */ })
test('Mitarbeiter ohne 2FA: 403 mit Link ins Ticketsystem, keine Sitzung, Appwrite-Sitzung beendet', async () => {
const res = await post('/api/auth/login', { email: 'andrej@webklar.com', password: 'richtig' })
assert.equal(res.status, 403)
assert.deepEqual(res.body, { error: 'Mitarbeiter arbeiten im Ticketsystem.', ticketUrl: 'https://ticket.webklar.com' })
assert.equal(setCookies(res).filter((c) => c.startsWith('webklar_portal_session=') && !/Expires=Thu, 01 Jan 1970/.test(c)).length, 0)
assert.equal(aw.sessionDeletes(), 1)
})
test('Mitarbeiter mit 2FA: dieselbe Antwort, keine Code-Mail', async () => { /* aw.addUser mfa: true; mailsSent(userId) === 0 */ })
test('/me mit alter Mitarbeiter-Sitzung: reason staff, Weiterleitung, Cookies geloescht', async () => {
const res = await rawGet('/api/auth/me', { cookie: sessionCookie({ role: 'admin', appwriteUserId: 'u_admin', name: 'Kenso', email: 'kenso@webklar.com' }) })
assert.deepEqual(res.body, { authenticated: false, reason: 'staff', redirect: 'https://ticket.webklar.com' })
})
test('/me mit Alt-Ansicht (viewAs ohne viewId): ebenso reason staff', async () => {})
test('/login/mfa und /staff-switch gibt es nicht mehr (404)', async () => {})
```
- [ ] **Step 2:** Run `node --test test/staffLogin.test.js test/kundenansichtEnde.test.js` → FAIL
- [ ] **Step 3:** Implementieren
- [ ] **Step 4:** UI-Check `test/ui/kundenansicht.ui.mjs`: Szenario „alt“ ersetzen durch „staff“ (`/me` → `reason: 'staff'`) mit Erwartung Weiterleitung auf `redirect`; neuer Check Login-Seite: 403 mit `ticketUrl` zeigt Text und sichtbaren Link „Zum Ticketsystem“ mit `href` = `ticketUrl`. Run → alle ok
- [ ] **Step 5:** Run `npm test` → PASS
- [ ] **Step 6: Commit** — „Portal: Mitarbeiter-Login gesperrt (Hinweis aufs Ticketsystem), alte Mitarbeiter-Sitzungen enden“
### Task 2: Admin-Panel, Rechnungsanalyse und alte Admin-Schnittstellen entfernen
**Files:**
- Delete (`git rm`): `public/admin.html`, `public/admin.js`, `public/kalkulation.html`, `public/kalkulation.js`, `public/kalkulation.css`, `server/routes/portalAdmin.js`, `server/routes/admin/switchLink.js`, `server/services/appointments.js`, `test/staffSwitch.test.js`, `test/ui/admin-funktionen.ui.mjs`
- Create: `server/services/staffPreviewLink.js` (`buildStaffPreviewLink` samt `safePreviewUrl`/`previewUrlForSubdomain` aus `server/routes/admin/previewOpen.js` verschoben); Delete `server/routes/admin/previewOpen.js`; Modify `server/routes/team/preview.js` (Import)
- Modify: `server/index.js` (Imports/Mounts `/api/portal-admin`, `/api/admin/preview-open`, `/api/admin/switch-link` raus), `server/middleware/session.js` (`requireAdminSession` raus), `server/services/staffPreviewTokens.js` (Doku ohne `switch`), `server/middleware/kundenansichtGuard.js` (`LEGACY_ALLOWED` und Präfix `/api/portal-admin/` raus; Sitzung mit `viewAs` ohne `viewId` → Cookies löschen, 401 `view_expired` wie abgelaufene Kundenansicht), `public/app.js` (Alt-Zweig in `renderViewAsBanner` raus)
- Modify Kommentare/Texte, die das Admin-Panel nennen (Liste: Bestandsaufnahme Abschnitt 6): Server-Meldungen „Im "Als Kunde ansehen"-Modus …“ → „In der Kundenansicht …“; Kommentare auf „Ticketsystem“ bzw. „früheres Admin-Panel“.
- Modify Tests: `test/storageTasks.test.js` (auf `/api/team/storage-tasks` mit `Bearer jwt-staff`), `test/funktionenRoutes.test.js` (Admin-Teil auf `/api/team/funktionen`), `test/teamChat.test.js` (Admin-Panel-Test raus), `test/kundenansichtGuard.test.js` (Alt-Ansicht: jetzt 401 `view_expired`; Untergrenze geprüfter Schreib-Routen `>= 18`), `test/newsRoutes.test.js` (Alt-Ansicht → 401), `test/support/fixtures.js` (`adminCookie`/`viewAsCookie` nur noch, wo Tests sie als „alte Sitzung“ brauchen; sonst `kundenansichtCookie`), `test/ui/kundenansicht.ui.mjs` (Alt-Banner-Szenario bereits in Task 1 ersetzt)
**Interfaces:**
- Consumes: Task 1.
- Produces: `buildStaffPreviewLink({ userId, email, name, subdomain, previewUrl })` unverändert, neuer Ort `server/services/staffPreviewLink.js`.
- Produces: 404 für `/admin.html`, `/kalkulation.html`, `/api/portal-admin/*`, `/api/admin/switch-link`, `/api/admin/preview-open`.
- [ ] **Step 1: Failing test** `test/adminPanelGone.test.js`
```js
test('Admin-Panel und alte Admin-Schnittstellen sind weg', async () => {
// App wie server/index.js aufbauen ist zu gross: stattdessen Dateien und Router-Mounts pruefen
for (const f of ['public/admin.html', 'public/admin.js', 'public/kalkulation.html', 'public/kalkulation.js', 'public/kalkulation.css', 'server/routes/portalAdmin.js', 'server/routes/admin/switchLink.js', 'server/routes/admin/previewOpen.js']) {
assert.equal(existsSync(new URL(`../${f}`, import.meta.url)), false, f)
}
const index = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8')
for (const mount of ['/api/portal-admin', '/api/admin/switch-link', '/api/admin/preview-open']) assert.ok(!index.includes(`'${mount}'`), mount)
for (const f of ['public/chat-composer.js', 'public/chat-composer.css', 'public/kundenansicht.js']) assert.ok(existsSync(new URL(`../${f}`, import.meta.url)), `${f} muss bleiben`)
})
test('keine Verweise mehr auf admin.html, kalkulation.html oder /api/portal-admin in server/ und public/', () => {
// rekursiv lesen (node:fs), Fundstellen auflisten -> []
})
test('Alt-Ansicht ohne Kennung: Lesen und Schreiben 401 view_expired, Cookies geloescht', async () => {})
```
- [ ] **Step 2:** Run → FAIL
- [ ] **Step 3:** Löschen, verschieben, umstellen; Tests anpassen
- [ ] **Step 4:** Run `npm test` → PASS; `node test/ui/kundenansicht.ui.mjs` und `node test/ui/composer-headers.ui.mjs` → alle ok
- [ ] **Step 5: Commit** — „Portal: Admin-Panel, Rechnungsanalyse und alte Admin-Schnittstellen entfernt“
### Task 3: Team-Mails verlinken ins Ticketsystem
**Files:**
- Modify: 15 Vorlagen mit Fußzeilen-Link (`server/mail-templates/T01,T02,T03,T04,T05,T06,T07,T08,T09,T10,T11,T12,T13,T14,T16 *.html`): `href="https://project.webklar.com/admin.html"` → `https://ticket.webklar.com/customers`, Linktext „Admin-Bereich“ → „Ticketsystem“
- Modify: `T01 Neue Chatnachricht.html` (Knopf „Im Ticketsystem antworten →“ auf `{{team_link}}`, Platzhalter im Kopfkommentar ergänzen, Satz :39 anpassen), `T10`/`T11` (Knopftexte bleiben, `{{admin_link}}` bleibt als Name), Sätze mit „Admin-Bereich“ in `T09:64`, `T13:41,45,53` → „Ticketsystem (Kunden → …)“
- Modify: `server/services/mailer.js` (`sendChatNotification`: `team_link: ${config.womsBaseUrl}/customers/chat?kunde=<customer.$id>`), `server/services/takeoverCredits.js:460,:489` (`admin_link: ${config.womsBaseUrl}/customers/<customerId>?tab=gutschriften&antrag=<id>`)
- Test: `test/mailTemplates.test.js` (ergänzen)
**Interfaces:**
- Consumes: WOMS-Routen `/customers`, `/customers/chat?kunde=`, `/customers/<id>?tab=gutschriften&antrag=` (live seit Etappe 2/3).
- [ ] **Step 1: Failing tests**
```js
test('keine Vorlage verlinkt admin.html oder nennt den Admin-Bereich', () => {
for (const t of allTemplates()) {
assert.ok(!t.html.includes('admin.html'), t.id)
assert.ok(!t.html.includes('Admin-Bereich'), t.id)
}
})
test('Fusszeile der Team-Mails: Ticketsystem', () => { /* die 15 Vorlagen enthalten href="https://ticket.webklar.com/customers" und den Text "Ticketsystem" */ })
test('T01 antwortet im Ticketsystem beim Kunden', async () => {
// sendChatNotification({ $id: 'c1', name: 'Berta' }, 'Hallo') ueber mailSink: html enthaelt href="https://ticket.webklar.com/customers/chat?kunde=c1" und "Im Ticketsystem antworten"
})
test('T10/T11 oeffnen den Antrag im Kunden-Reiter Gutschriften', async () => {
// Antrag anlegen bzw. beantworten wie in test/takeoverCredits*: Link https://ticket.webklar.com/customers/c1?tab=gutschriften&antrag=<id>
})
```
- [ ] **Step 2:** Run `node --test test/mailTemplates.test.js` → FAIL
- [ ] **Step 3:** Implementieren (Platzhalter-Prüfung `declared == used` aus `mailTemplates.test.js` muss grün bleiben)
- [ ] **Step 4:** Run `npm test` → PASS
- [ ] **Step 5: Commit** — „Team-Mails: Links ins Ticketsystem statt Admin-Bereich“
---
## WOMS
### Task 4: Knopf „Projekt-Admin“ entfernen
**Files:**
- Modify: `src/components/Navbar.jsx` (Knopf :142-151, `handleProjectAdmin`, `switching`, Import `openProjectAdmin`, `IconExternalLink`), `src/lib/employeeAdminApi.js` (`openProjectAdmin` raus), `src/styles/global.css` (`.side-link-button` raus), Kommentare mit `public/admin.js`-Zeilen → „wie im früheren Admin-Panel“ (`storageFormat.js`, `creditFormat.js`, `offerFormat.js`, `OfferForm.jsx`, `CreditDetail.jsx`)
- Test: `tests/unit/navLinks.test.mjs` (unverändert grün), Harness `test-e3.mjs` Sidebar-Check um „kein Projekt-Admin“ ergänzen
- [ ] **Step 1:** Harness-Check `sidebar_ohne_projekt_admin` ergänzen → Run → FAIL
- [ ] **Step 2:** Implementieren; `npm test` → PASS; Vite-Build → „built in“; Harness → alle ok
- [ ] **Step 3: Commit** — „Sidebar: Projekt-Admin entfernt (Admin-Panel abgeschaltet)“
---
## Abschluss
### Task 5: Gesamtprüfung und Übergabe
- [ ] Portal `npm test` grün, neue/geänderte Tests unter Node 20 grün; UI-Checks grün. WOMS `npm test`, Build, Harnesses (Etappe 2 und 3) grün.
- [ ] Abschlussreview beider Branches (frischer Reviewer, sonnet).
- [ ] Übergabe, **WOMS zuerst**: 1. `git -C /home/webklar/apps/ticket.webklar.com/dev-admin-umzug push origin feature/admin-umzug:test`; 2. Live-Check (ich): Bundle ohne „Projekt-Admin“; 3. `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-admin-umzug push origin feature/admin-umzug:main`; 4. Live-Check: `/admin.html` 404, `/api/portal-admin/chats` 404, Mitarbeiter-Login 403 mit Hinweis (ohne echtes Passwort: nur Routen), `/dashboard.html` und Kundenansicht weiter ok, `chat-composer.js` 200; 5. Nutzer-Test: eine Team-Mail (z. B. Chat) öffnen und Link prüfen.
@@ -0,0 +1,255 @@
# Terminplaner TP1 Kern – Umsetzungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Gäste buchen auf der Website eines Betriebs Termine (Person oder Platz, sofort oder als Anfrage, Bestätigung per E-Mail-Link, Absagen/Verschieben/Widerrufen per Link); der Betrieb verwaltet alles im Portal; Pilot mit dem Kunden „test“.
**Architecture:** Neue Funktion `termine` im Kundenportal (Express, Appwrite, vanilla JS). Reine Bausteine in `server/services/termine/` (Zeit, Feiertage, Einstellungen, Slots, Zellen gegen Doppelbuchung, ICS, Mails, Warteschlange, Ablauf, Jobs), dazu eine öffentliche, versionierte Schnittstelle `/api/public/termine/v1`, eine Kunden-Schnittstelle `/api/termine`, ein Einbau-Skript `embed/termine.js`, Gästeseiten `termin.html` und die Portal-Ansicht `#termine`. WOMS bekommt Aktivieren/Abschalten je Funktion.
**Tech Stack:** Node 20 (Produktion) / 24 (Host-Tests), Express 4, Appwrite 1.8.1 REST, nodemailer 9, `feiertagejs` 1.5.1 (neu), Playwright (`/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs`, Chromium `~/.cache/ms-playwright/chromium-1234`), React 18 (WOMS).
**Spec:** `docs/superpowers/specs/2026-10-02-terminplaner-design.md` (Abschnitte „TP1 Kern“ 1–14). Recherche: `/root/reports/terminplaner-2026-10-02/`.
**Worktrees:** Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-termine` (Branch `feature/terminplaner` von `origin/main` ff7fe35, eigenes `node_modules` per `npm ci`, Basis 1463 Tests grün). WOMS siehe V3 und Task 22.
## Global Constraints
- Funktionsschlüssel `termine`; Schalter `TERMINE_ENABLED` = `off` (Vorgabe) | `pilot` | `on`; `TERMINE_PILOT_EMAILS` Komma-Liste (klein, getrimmt, `*` = alle). TP1: `on` = `pilot` ohne E-Mail-Filter; kein Bestellen. Bei `off` laufen keine Termine-Jobs und keine Termine-Abfragen.
- `config.termine` wird bei **jedem** Aufruf gelesen (Tests ändern `config.termine.*` zur Laufzeit). Werte: `mode`, `pilotEmails`, `holdMinutes: 30`, `zelleMin: 5`, `mailsProStunde: 200`, `formMinSekunden: 3` (Env `TERMINE_FORM_MIN_SECONDS`).
- Neue Dateien unter `server/services/termine/`; nie `server/services/appointments.js` (Test `adminPanelGone`).
- Einstellungs-Attribut heißt **`einstellung`** (nie `data`: `appwriteAdmin.formatRequestBody` hält Bodies mit `data` für fertig verpackt).
- Zeit: Wanduhr `Europe/Berlin`, gespeichert UTC (`toISOString()`); Appwrite liefert `+00:00` ⇒ Datumswerte immer mit `new Date()` lesen. Nicht existierende Wanduhrzeiten (März) auslassen, doppelte (Oktober) = erstes Vorkommen. Alle `HH:MM` in Plänen, Sonderöffnungen, Abwesenheiten und Sperren sind Vielfache von 5 Minuten. `deadlines.berlinTime` nicht verwenden.
- Zellen à **5 Minuten**, Zell-ID `c_` + 34 Hex von `sha1(featureId|ressourceId|zelleIso)`, Feld `bis` = `blockEndsAt` der Buchung (nie Haltezeit). Alle neuen Zellen einer Buchung in **einem** Bulk-Create (≤ 100 ⇒ jede Belegung ≤ 500 Minuten, auch Sperren und manuelle Termine). Ob eine Zelle noch gilt, entscheidet allein ihre Buchung (fehlt ⇒ frei; Status nicht belegend ⇒ frei; `gehalten` mit `heldUntil ≤ now` ⇒ frei).
- Status: `gehalten`, `angefragt`, `bestaetigt`, `erledigt`, `nichterschienen`, `abgesagt`, `abgelehnt`, `verfallen`. Belegend: `gehalten` (solange `heldUntil > now`), `angefragt`, `bestaetigt`.
- Tokens: **Bestätigungstoken** = 32 Zufallsbytes base64url, gespeichert nur `verifyHash` (SHA-256-Hex), wirksam einmal bis `heldUntil`. **Verwaltungstoken** = base64url(HMAC-SHA256(Schlüssel aus `config.sessionSecret` + `'termine-manage'`, bookingId)), gespeichert nur `manageHash`, jederzeit neu berechenbar, gültig bis Terminende + 1 Tag. Zustandsänderungen nur per POST. Vertragsschluss `vertragAt` = `bestaetigtAt` (sofort) bzw. `angenommenAt` (Anfrage).
- Knopftexte exakt: verbindlich + Preis ⇒ „Zahlungspflichtig buchen“ (Bestätigungsseite „Zahlungspflichtig bestätigen“); verbindlich ohne Preis ⇒ „Termin buchen“ („Termin bestätigen“); Anfrage ⇒ „Termin anfragen“ („Anfrage absenden“).
- Zustimmungstexte exakt (Sie-Form, auch bei Du-Betrieben, weil Rechtstext): vorzeitiger Beginn (Pflicht bei verbindlich + Preis + Verbraucher + Terminbeginn ≤ Ende der Widerrufsfrist = deadlines.withdrawalDeadline(jetzt + Haltezeit).endsAt; bei bepreisten Verbraucher-Anfragen immer — Ruling R31): „Ich verlange ausdrücklich, dass {Betrieb} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).“ · Storno: „Ich habe die Stornobedingungen gelesen.“ · Erinnerung (nie vorangekreuzt): „Ich möchte {n} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.“ · über Freitext: „Bitte keine Gesundheitsangaben.“
- Verbraucher-Buchung ⇔ `recht.gaesteSind !== 'unternehmer' && !recht.freizeit` (beim Halten in `verbraucher` gespeichert). Widerruf möglich ⇔ verbindlich, `verbraucher`, Status `bestaetigt`, `now <= deadlines.withdrawalDeadline(vertragAt).endsAt` (§§ 187/188/193 BGB, Ruling R29), Termin noch nicht begonnen.
- Öffentliche Antworten: nie `customerId`, `projectId`, `featureId`, `$id`, Ticket-/Stripe-IDs; nur Leistungs-/Ressourcen-IDs der Einstellung und die Buchungsnummer (8 Zeichen). Fehlerform immer `{ fehler: { <feld|_>: text }, code? }`. Unbekannter, inaktiver, abgeschalteter siteKey oder Modus `off` ⇒ `200 { aktiv: false }` (GET und POST unter `/:siteKey`); `/termin/*` (Token) bleibt für `disabled` nutzbar (Absagen, Widerruf).
- Schutz: Router-eigener Parser 16 KB + Router-Fehlermiddleware (413/400/503, nie `err.message`); `content-length` > 16 384 ⇒ 413 vorab; Honeypot-Feld `kontakt_url`; Formular-Token HMAC, `zu_schnell` (< `formMinSekunden`) oder Honeypot ⇒ gleiche Erfolgsantwort ohne Speichern/Mail, `abgelaufen` (> 2 h) ⇒ 400 „Bitte laden Sie die Seite neu.“; Grenzen (Modul `limits.js`, Speicher-Maps mit Obergrenze 10 000 Schlüssel, `resetTermineLimitsForTests()`): je IP (IPv6 je /64) 120 GET/min und 10 Buchungen/10 min; je siteKey 600 GET/min und 60 Haltungen/h; je E-Mail+Funktion `regeln.maxOffeneJeEmail` (Vorgabe 3) offene Termine und 5 G01/Tag; Widerruf 10/h je IP, 3 G08/Tag je Empfängeradresse, 20 G08/h je Funktion, 10 K21 „nicht zugeordnet“/h je Funktion. Grenze „offene Termine“ ⇒ dieselbe 201-Antwort, statt der Bestätigung geht G01-Block `zu_viele` an die Adresse (kein Orakel für Dritte). E-Mail-Adressen: genau ein Postfach (kein `,;<>"`, ≤ 254), getrimmt und klein.
- Mails: Gast `from: { name: betrieb.name, address: SMTP_FROM }`, `replyTo` = Betriebs-E-Mail; K20/K21 `from: { name: 'WEBklar', address }` an die Betriebs-E-Mail; **nie BCC**, kein Tracking. Betreff = Platzhalter `{{betreff}}` (je Anrede/Fall vom Server). G01 und G08 ohne Text, den der Gast eingegeben hat. Alles über `termineOutbox`: einziger Sendepfad `sendeEintrag` prüft Pause und Stundenbudget (200/h); `EAUTH`/535 ⇒ 15 min Pause für alle, Eintrag bleibt `wartet`; Verbindungsfehler/4xx ⇒ erneut nach 1, 5, 30 min, dann `fehler`; 5xx (außer 535) ⇒ sofort `fehler`; nach `gesendet` werden `an`, `html`, `text`, `ics` geleert; Einträge 7 Tage nach `gesendet` bzw. 48 h nach `fehler` gelöscht.
- Löschfristen: `verfallen` 48 h nach `heldUntil`; `abgesagt`/`abgelehnt` 30 Tage; `erledigt`/`nichterschienen` `aufbewahrungTage` (Vorgabe 90, 14–365) nach Terminende; `angefragt` mit Beginn in der Vergangenheit ⇒ `abgelehnt` (System); Funktion `disabled` ⇒ alles 30 Tage nach `disabledAt`.
- Tests: Termine-Tests starten den Nachbau mit `startAppwriteMock({ schema, datetimes, defaultLimit: 25 })` (Task 2); feste `now` per Parameter; Mails mit `mailSink()` + `warteAufVersand()` prüfen; Kalenderdaten stehen im Sink als `icalEvent` (nicht als Anhang). Schwere Läufe (Playwright, `vite build`, Docker-Node-20) nacheinander, vorher `free -m`, mit `nice`.
- Portaltexte Du-Form, kein `confirm()`; Gästetexte Sie/Du nach `anrede`. Kundentext nur über `textContent`/`esc()`; `href` nur für geprüfte `https:`-URLs, mit `rel="noopener noreferrer"`.
- Nichts pushen, nichts in Produktion schreiben (Ausnahme Vorab-Spike mit Freigabe). Nie `git add -A`.
## Review Focus
1. Gast-Browser in anderer Zeitzone (New York) sieht dieselben Daten und Uhrzeiten wie in Berlin; das Widget rechnet nie mit Browser-Datum. → Task 19 (Playwright `timezoneId`, statischer Test)
2. E-Mail mit Großbuchstaben/Leerzeichen (` Max@GMail.com `) zählt als dieselbe Adresse für Grenzen und Widerruf. → Task 12 (`email_normalisiert`)
3. Betrieb löscht Leistung/Ressource mit künftigen Terminen ⇒ abgelehnt mit Anzahl; Deaktivieren erlaubt, Termine bleiben. → Task 4 + 18
4. Bestätigungsklick genau während der Minutenjob die Haltung verfallen lässt ⇒ genau ein Endzustand. → Task 15 (`verfall_und_bestaetigung_gleichzeitig`)
5. Zwei Widgets auf einer Seite (`data-leistung`) stören sich nicht (IDs, Stile einmal). → Task 19
---
## Vorab (Controller, kein Subagent)
- [ ] **V1 Spike Bulk-Create** (Nutzer-Freigabe einholen): im Container (`docker exec -w /app webklar-kundenbereich-server node …`, Skript im Scratchpad) Wegwerf-Collection `termineSpike` (`bookingId` string 64), 50 parallele `POST …/collections/termineSpike/documents` mit `{ documents: [{ $id, bookingId }, …] }` (je 20 Zellen, Überlappungen) ⇒ je überlappender Gruppe genau ein 201, Rest 409 `document_already_exists`, keine Teil-Schreibungen; Collection löschen. Negativ ⇒ Task 2 baut `createDocuments` über die Transactions-API (`POST /databases/transactions`, Operationen, `PATCH {commit:true}`, 409 `transaction_conflict`).
- [ ] **V2 Gesetzestexte:** Anlage 1 und 2 zu Art. 246a § 1 EGBGB von `https://www.gesetze-im-internet.de/bgbeg/anlage_1.html` und `…/anlage_2.html` wörtlich nach `docs/rechtliches/quellen/anlage-1.txt` und `anlage-2.txt` (Kopfzeile mit Abrufdatum).
- [ ] **V3 WOMS-Worktree:** `git -C /home/webklar/apps/ticket.webklar.com/build worktree add -b feature/terminplaner ../dev-termine origin/test`, dort `git ls-files -z node_modules | xargs -0 git update-index --skip-worktree` und `npm install` (nie `git add -A`).
---
## Portal: Grundlagen
### Task 1: Schalter, Konfiguration, Schema
**Files:** Modify `server/config.js`, `test/support/schema.js`; Create `server/services/termine/schalter.js`, `server/services/termine/schema.js`, `scripts/termine-setup.mjs`, `test/termineSetup.test.js`, `test/termineSchalter.test.js`.
**Produces:**
- `config.collections.termineConfig|termineBookings|termineClaims|termineOutbox` (Env `APPWRITE_COLLECTION_TERMINE_CONFIG|…_BOOKINGS|…_CLAIMS|…_OUTBOX`); `config.termine` laut Global Constraints.
- `schalter.js`: `termineModus()`, `termineFuerKunde(email) ⇒ boolean`, `termineAus() ⇒ boolean`.
- `schema.js` (reine Daten, **kein** Import von `config.js`): `TERMINE_COLLECTIONS = { termineConfig: { attributes, indexes }, … }` exakt nach Spec 2 (mit `einstellung` statt `data`; `termineOutbox.icsMethod`); Attributtypen `string` (Größe), `integer`, `boolean`, `datetime`; Indizes `{ key, type: 'key', attributes }`; `TERMINE_DATETIMES = { collection: [keys] }`.
- `scripts/termine-setup.mjs` (Muster `analyse-setup.mjs`, `createAppwriteClient` von dort wiederverwenden; `createAttribute` nutzt `/attributes/<type>` für alle Typen): `main({ check, client, log })`, legt Fehlendes an, Indizes nur bei neuen Collections, `--check` ⇒ Exit 1 bei Fehlendem.
- `test/support/schema.js`: `schemaKeys(collection)` kennt auch `TERMINE_COLLECTIONS` (statischer Import aus `server/services/termine/schema.js`).
- [ ] Tests: Modus (leer/unbekannt ⇒ off; pilot + `KENSO.gri@gmail.com ` ⇒ true; `*`; Laufzeitänderung von `config.termine.mode` wirkt); Setup mit Fake-Client: 4 Collections + Attribute + Indizes, zweiter Lauf legt nichts an, `--check` legt nichts an; `schemaKeys('termineConfig')` enthält `einstellung`, nicht `data`; **Rundreise**: Nachbau mit `schemaKeys('termineConfig')`, `createDocument` + `updateDocument` mit `einstellung`/`version`/`updatedBy` und Zurücklesen.
- [ ] FAIL → implementieren → PASS (`node --test test/termineSetup.test.js test/termineSchalter.test.js`, dann `npm test`) → Commit „Terminplaner: Schalter, Konfiguration, Schema“
### Task 2: Appwrite-Wrapper und Nachbau
**Files:** Modify `server/services/appwriteAdmin.js`, `test/support/appwriteMock.js`, `test/support/testApp.js` (exportiert zusätzlich `send(method, path, opts)` = bisheriges privates `request`); Test `test/appwriteBulk.test.js`.
**Produces:**
- `Query.between`, `Query.greaterThanEqual`, `Query.lessThanEqual`, `Query.cursorAfter(id)`.
- `createDocuments(collectionId, documents: Array<{ $id, ...felder }>) ⇒ docs` (roher Body `{ documents }` über neue `adminFetch`-Option `raw: true`; 409 bei Duplikat; Spike-Ergebnis V1).
- `listAllDocuments(collectionId, queries, { pageSize = 500, max = 20000 }) ⇒ docs` (`Query.limit(pageSize)`, `orderAsc('$id')`, `cursorAfter`, bis Seite < pageSize; über `max` ⇒ Fehler).
- Nachbau: `startAppwriteMock({ schema, datetimes = {}, defaultLimit = null })` – Bulk alles-oder-nichts (Duplikat ⇒ 409, unbekanntes Attribut je Dokument ⇒ 400, nichts geschrieben); neue Abfragen inkl. `cursorAfter`; Vergleich als Zeitpunkt nur, wenn beide Werte `^\d{4}-\d\d-\d\dT` sind; `datetimes`-Felder werden beim Schreiben auf `…+00:00` normalisiert, `''` ⇒ 400; `defaultLimit` (Termine-Tests: 25) gilt ohne `limit`-Abfrage wie Appwrite.
- [ ] Tests: Bulk 3 Dokumente; Bulk mit vorhandener ID ⇒ 409 und unverändert; `between` inklusiv; `listAllDocuments` über 3 Seiten; `defaultLimit` 25 liefert 25 von 30; Datum `…Z` kommt als `+00:00` zurück; alle bisherigen Tests grün.
- [ ] FAIL → implementieren → PASS → Commit „Appwrite: Bulk-Create, Bereichsabfragen, seitenweises Laden, genauerer Nachbau“
### Task 3: Zeit und Feiertage
**Files:** Create `server/services/termine/zeit.js`, `server/services/termine/feiertage.js`, `test/termineZeit.test.js`; Modify `package.json`/`package-lock.json` (`npm install feiertagejs@1.5.1 --save-exact`).
**Produces:** `zeit.js` (nur `Intl`; `/root/reports/terminplaner-2026-10-02/spike-code/tz.mjs` nur als Vorlage für `offsetMs`): `TZ`; `zuUtc(tag, 'HH:MM') ⇒ Date | null` (null, wenn `ausUtc(Ergebnis)` nicht dieselbe Wanduhrzeit ergibt; `'24:00'` = Folgetag 00:00); `ausUtc(date) ⇒ { tag, uhrzeit }`; `wochentag(tag) ⇒ 1..7`; `plusTage(tag, n)`; `heute(now) ⇒ tag`; `monatInfo('YYYY-MM') ⇒ { label: 'Oktober 2026', ersterWochentag, anzahlTage, vorher, nachher }`; `datumLang(date) ⇒ 'Montag, 12.10.2026'`, `datumKurz(date) ⇒ '12.10.2026'`, `uhrzeit(date) ⇒ '10:30'`. `feiertage.js`: `BUNDESLAENDER` (16 Kürzel), `istFeiertag(tag, land)`.
- [ ] Tests: `zuUtc('2026-03-29','02:30') === null`; `zuUtc('2026-03-29','03:00')` = `01:00Z`; `zuUtc('2026-10-25','02:30')` = `00:30Z`; `ausUtc` rückwärts; `wochentag('2026-10-05') === 1`; `plusTage('2026-10-25', 1)`; `monatInfo('2026-02')` (28 Tage, So=7 als erster); Feiertage RP 2026-06-04 ja, BE 2026-06-04 nein, BE 2026-03-08 ja, SN 2026-11-18 ja, alle 2026-12-25 ja. Zusätzlich `docker run --rm -v "$PWD":/w:ro -w /w node:20-alpine node --test test/termineZeit.test.js`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Zeit (Berlin, Umstellung) und Feiertage“
### Task 4: Einstellungen und Branchen-Vorlagen
**Files:** Create `server/services/termine/einstellungen.js`, `server/services/termine/vorlagen.js`, `test/termineEinstellungen.test.js`.
**Interfaces** (Datenform = Spec 2 `termineConfig.einstellung` + `betrieb.kleinunternehmer: boolean`, `betrieb.websiteUrl`):
- `neueId('r'|'s'|'a') ⇒ 'r_' + 8 [a-z0-9]`.
- `normalisiereEinstellung(eingabe, { bisher = null, kuenftigeTermine = { leistungen: {}, ressourcen: {} }, now }) ⇒ { einstellung } | { fehler: { [pfad]: text } }`: Vorgaben auffüllen; Steuerzeichen entfernen; Längen kürzen; Pflicht `betrieb.name` (1–80), `betrieb.email` (ein Postfach); URLs (`impressumUrl`, `datenschutzUrl`, `agbUrl`, `websiteUrl`) nur `https:`, ≤ 300, ohne Zugangsdaten; `farbe` `^#[0-9a-f]{6}$`; `anrede` sie|du; `bundesland` ∈ BUNDESLAENDER; IDs `^[rsa]_[a-z0-9]{8}$`, je Liste eindeutig, neue Einträge ohne ID ⇒ `neueId`; Obergrenzen 20 Ressourcen, 50 Leistungen, 100 Abwesenheiten, 200 Sonderöffnungen, `JSON.stringify ≤ 200 000`; Fenster `HH:MM` auf 5 min, aufsteigend, ohne Überlappung, Ende > Beginn (24:00 erlaubt); `dauer` 5–480, `pufferNach` 0–120 (je Vielfache von 5), `dauer + pufferNach ≤ 500`; `preisArt` fest|ab|keiner, `preisCent` 0–1 000 000; `buchungsart` sofort|anfrage; aktive Leistung braucht ≥ 1 aktive Ressource; `regeln.raster` ∈ {5,10,15,20,30,60}, `vorlaufMin` 0–20160, `horizontTage` 1–365, `absageBisStd` 0–336, `maxOffeneJeEmail` 1–10; `gastFelder` aus|optional|pflicht, Zusatzfelder max. 5 (`{ key: ^[a-z0-9_]{1,20}$, label, typ: text|auswahl, optionen ≤ 10, pflicht }`); `erinnerung.stundenVorher` ∈ {2,12,24,48}; `zahlung.modus` nur `vorort` (sonst Fehler „Online-Zahlung kommt bald“), `vorOrtArten` ⊆ bar|ec|kreditkarte|ueberweisung|paypal; `aufbewahrungTage` 14–365; Abwesenheiten `von ≤ bis`. Entfernen einer ID mit künftigen Terminen ⇒ Fehler „Für diese Leistung gibt es noch {n} künftige Termine. Deaktiviere sie stattdessen.“ (Ressource entsprechend); `storno.version` bei geändertem Text `YYYY-MM-DD-n` (aus `now`); `feedToken` nur aus `bisher` übernehmen, neue Ressourcen bekommen eines (32 Byte base64url), Eingabewerte werden ignoriert.
- `oeffentlicheEinstellung(einstellung, now) ⇒` Felder von `GET /config` außer `aktiv`, `hinweis`, `texte`, `formToken` (Form Task 16).
- `knopfText(leistung, { bestaetigen })`; `istVerbraucher(einstellung)`; `brauchtZustimmungVorzeitig(einstellung, leistung, start, now)`; `preisText(leistung) ⇒ '45,00 €' | 'ab 45,00 €' | ''`; `preisHinweis(einstellung) ⇒ 'inkl. USt' | 'kein Ausweis der USt nach § 19 UStG'`; `zahlungText(einstellung) ⇒ 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte'`.
- `vorlagen.js`: `VORLAGEN` (`salon, kosmetik, beratung, werkstatt, handwerk, tattoo, fotograf, hundesalon, sonstiges`; je 2–5 Leistungen, Plan, `buchungsart` anfrage bei tattoo/handwerk/fotograf, werkstatt mit 2 Plätzen, Stornotext Variante 1 aus `recherche-recht-zahlung.md` 7.3 F), `vorlagenListe()`, `ausVorlage(key, betrieb, now) ⇒ einstellung` (valide).
- [ ] Tests: alle Vorlagen valide; Pflichtfelder; `javascript:alert(1)` als `impressumUrl` ⇒ Fehler am Pfad `betrieb.impressumUrl`; Fenster 09:07 ⇒ Fehler; Überlappung ⇒ Fehler am Pfad `ressourcen.0.plan.1`; Leistung ohne aktive Ressource; Löschen mit künftigen Terminen ⇒ Fehler mit Anzahl; Client-`feedToken` wird ignoriert; `online` ⇒ Fehler; Knopftexte; Zustimmung vorzeitig (3 Tage ⇒ true, 20 Tage ⇒ false, Unternehmer/kein Preis ⇒ false); `oeffentlicheEinstellung` ohne `feedToken`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Einstellungen und Branchen-Vorlagen“
### Task 5: Verfügbarkeit
**Files:** Create `server/services/termine/slots.js`, `test/termineSlots.test.js`.
**Produces:** `belegungAus(buchungen, now, { ohneBuchung = null }) ⇒ [{ ressourceId, start, ende }]` (belegende Status, `ende` = `blockEndsAt`, ohne die genannte Buchung); `freieZeiten({ einstellung, leistungId, ressourceId /* id|'egal' */, tag, belegung, now, ohneRegeln = false }) ⇒ [{ uhrzeit, start, ressourceIds }]` (Sonderöffnung > Abwesenheit > Feiertag > Plan; Raster ab Fensterbeginn; `start + dauer + pufferNach` im Fenster; frei; `start ≥ now + vorlaufMin` und `tag ≤ heute + horizontTage` außer `ohneRegeln`; nie Vergangenheit); `freieTage({ …, monat }) ⇒ ['YYYY-MM-DD']`; `waehleRessource(ids, buchungenAmTag, einstellung)`; `blockEnde(start, leistung)`.
- [ ] Tests: Pause; Puffer am Pausenrand; Vorlauf; Horizont; Abwesenheit Ressource und Betrieb; Feiertag vs. Sonderöffnung; abgelaufene Haltung blockiert nicht; `egal` mit zwei Personen; `ohneBuchung` gibt die eigene Zeit frei; 2026-03-29 ohne `02:xx`; 2026-10-25 60-min-Termin um 02:00 dauert real 60 min; letzter Termin endet genau am Fensterende; Belegung, die am Vortag 22:00 beginnt und bis 06:00 läuft, blockiert 05:30.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Verfuegbarkeit“
### Task 6: Zellen, Tokens, Buchungsspeicher
**Files:** Create `server/services/termine/zellen.js`, `server/services/termine/token.js`, `server/services/termine/buchungen.js`, `test/termineZellen.test.js`.
**Consumes:** Task 2. **Produces:**
- `token.js`: `neuesToken() ⇒ { token, hash }`; `hashToken(t)`; `verwaltungsToken(bookingId) ⇒ token`; `buchungsNummer(bookingId) ⇒ 8 [A-Z0-9]`; `formToken(siteKey, now)`, `pruefeFormToken(siteKey, token, now) ⇒ 'ok'|'zu_schnell'|'abgelaufen'|'ungueltig'` (Mindestalter `config.termine.formMinSekunden`).
- `zellen.js` (`spike-code/claims.mjs` nur als Vorlage für den Hash-Ansatz; Zellgröße/ID/Puffer wie hier): `zellenFuer(start, blockEnde) ⇒ Date[]`; `zellenId(featureId, ressourceId, zelle)`; `belegeZellen({ featureId, ressourceId, bookingId, start, blockEnde, now, ausser = null }) ⇒ void` (Sperre `termine-res:<featureId>:<ressourceId>`, `waitMs 5000`; mit `ausser` zählen Zellen dieser Buchung als eigene: angelegt wird nur Neu − Eigen; bei 409 blockierende Zellen per ID lesen, Gültigkeit nach Global Constraints prüfen, ungültige löschen, genau ein zweiter Versuch, sonst Fehler `{ status: 409, code: 'SLOT_VERGEBEN' }`); `gibZellenFrei(bookingId, { behalte = [] })` (über `listAllDocuments`, behält die genannten Zellzeiten); `verwaisteZellen(now) ⇒ ids` (`bis < now − 1 Tag` oder Buchung nicht belegend).
- `buchungen.js` (nur Datenzugriff): `legeAn`, `hole`, `aendere` (setzt `updatedAt`), `imZeitraum(featureId, von, bis)` (`lessThan(startsAt, bis)` + `greaterThan(blockEndsAt, von)` + `featureId`, `listAllDocuments`), `perHash(feld, hash)`, `offeneFuerEmail(featureId, email, now)`, `perEmailUndNummer(featureId, email, nummer)`, `zaehleKuenftige(featureId, now) ⇒ { leistungen, ressourcen }`, `abgelaufeneHaltungen(now)`, `erinnerungsKandidaten(now)`, `zuErledigen(now)`, `vergangeneAnfragen(now)`, `loeschKandidaten(status, vor)`, `zaehleAnfragen(featureId)`.
- [ ] Tests (Nachbau, `defaultLimit 25`): zweite Buchung derselben Zeit ⇒ `SLOT_VERGEBEN`; 10 parallel ⇒ genau eine; abgelaufene Haltung und fehlende Buchung blockieren nicht; Verschieben 10:00→10:30 mit `ausser` gelingt, Fremdbuchung im neuen Bereich ⇒ `SLOT_VERGEBEN` und alte Zellen unverändert; `gibZellenFrei` löscht 40 Zellen (über 25er-Seiten); `imZeitraum` findet die Belegung vom Vortag; Verwaltungstoken stabil; Formular-Token zu jung/alt/gefälscht.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Zellen gegen Doppelbuchung, Tokens, Buchungsspeicher“
### Task 7: Rechtstext-Bausteine für Gäste
**Files:** Create `docs/rechtliches/termine-widerrufsbelehrung-muster.md`, `docs/rechtliches/termine-datenschutz-absatz.md`, `docs/rechtliches/termine-hinweise.md`, `docs/rechtliches/termine-datenschutz-6c-entwurf.md`, `server/services/termine/rechtstexte.js`, `test/termineRechtstexte.test.js`.
**Consumes:** V2. **Produces:** Belehrung + Muster-Widerrufsformular für Dienstleistungen aus `docs/rechtliches/quellen/anlage-1.txt`/`anlage-2.txt` (Gestaltungshinweise angewendet: Dienstleistung, Betriebsdaten, Hinweis auf die Widerrufsfunktion), immer Sie-Form; Datenschutz-Absatz (Sie/Du) und Formular-Hinweis nach `recherche-recht.md` 11.1/11.2 mit den Löschfristen der Global Constraints; 6c-Entwurf (nicht einbauen). Alle Dateien mit Kopf „ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen“. `rechtstexte.js`: `RECHTSTEXTE_VERSION = '2026-10-02'`, `widerrufsbelehrung(betrieb) ⇒ string` (Text mit Zeilenumbrüchen), `formularHinweis(einstellung) ⇒ string`, `ZUSTIMMUNG_VORZEITIG(name)`, `ZUSTIMMUNG_STORNO`, `ZUSTIMMUNG_ERINNERUNG(stunden)`, `GESUNDHEIT_HINWEIS`.
- [ ] Tests: Belehrung enthält „Vertrag widerrufen“, die Betriebsanschrift, keinen offenen Platzhalter und die unveränderten Kernsätze der Quelle (Absätze aus `anlage-1.txt` wörtlich enthalten); Zustimmungstexte wortgleich zu den Global Constraints.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Rechtstext-Bausteine fuer Gaeste (Entwurf)“
### Task 8: Kalenderdatei, Gästeversand, Vorlagen-Engine
**Files:** Create `server/services/termine/ics.js`, `test/termineIcs.test.js`; Modify `server/services/mailTemplates.js` (`[GKT]\d{2}` in `templateIds` und im Kopfkommentar-Regex), `server/services/mailer.js`, `test/mailTemplates.test.js` (Ordner-Test-Regex `[GKT]`; Logo-Prüfung nur K/T; G-Vorlagen: kein `webklar-logo.png`).
**Produces:** `ics.js`: `icsTermin({ uid, sequenz, methode: 'REQUEST'|'CANCEL'|'PUBLISH', start, ende, titel, ort, beschreibung, organisator: { name, email }, teilnehmer: { name, email } | null, jetzt }) ⇒ string` (CRLF, Faltung 75 Oktette, Escaping `\ ; , \n`, CR/LF in Texten entfernt, `CN="…"` ohne `"` und Steuerzeichen, UTC, `RSVP=FALSE`, CANCEL mit `STATUS:CANCELLED`); `icsKalender(termine, { name, jetzt })` (`METHOD:PUBLISH`, `X-WR-CALNAME`, `X-PUBLISHED-TTL:PT15M`). `mailer.sendGuestMail({ to, fromName, replyTo = null, subject, html, text, ics = null, icsMethod = 'REQUEST' }) ⇒ info` (wirft; `from: { name, address }`; `icalEvent: { method, filename: 'termin.ics', content }` nur mit `ics`; nie `bcc`).
- [ ] Tests: ICS-Zeilenlänge, `UID`/`SEQUENCE`, Escaping, CANCEL; `sendGuestMail` über `mailSink`: kein `bcc`, `replyTo`, From-Name = Betrieb, `sent[0].icalEvent.method === 'REQUEST'` und `content` enthält `BEGIN:VCALENDAR`, ohne `ics` kein `icalEvent`; bestehende Vorlagen-Tests grün (noch ohne neue Vorlagen).
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Kalenderdatei, Gaesteversand, Vorlagen-Engine fuer G-Vorlagen“
### Task 9: Mailvorlagen G01–G08, K20, K21
**Files:** Create `server/mail-templates/G01 Termin bestaetigen.html`, `G02 Termin bestaetigt.html`, `G03 Anfrage eingegangen.html`, `G04 Anfrage abgelehnt.html`, `G05 Erinnerung.html`, `G06 Termin abgesagt.html`, `G07 Termin verschoben.html`, `G08 Eingang Widerruf.html`, `K20 Neue Buchung.html`, `K21 Termin geaendert.html`; Modify `test/mailTemplates.test.js` (`EXPECTED` sortiert: G01–G08, K01–K21, T01–T18; Kommentar).
- Gerüst wie die vorhandenen Vorlagen: Kopfkommentar (`G01 · …`, `Betreff: {{betreff}}`, Platzhalter-, Block-Liste), `<title>{{betreff}}</title>`, `<!--[if mso]>`-Block, sonst keine Kommentare, jeder Platzhalter/Block deklariert und benutzt.
- G-Layout neutral: Kopfbalken in `{{farbe}}` mit `{{betrieb_name}}`, kein Logo; Fuß `{{betrieb_name}}`, `{{betrieb_adresse}}`, `{{betrieb_telefon}}`, `{{betrieb_email}}`, `{{impressum_url}}`, `{{datenschutz_url}}`, Satz „Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Terminplaner von WEBklar versendet.“; Blöcke `sie`/`du` für jeden Anredetext.
- Platzhalter: G01 `leistung, datum, zeit, link` + Block `zu_viele` (Text: höchstmögliche Zahl offener Termine, absagen oder anrufen `{{betrieb_telefon}}`) – **kein** `gast_name`; G02 `gast_name, leistung, person, datum, zeit, dauer, preis, zahlung, nummer, link, ort` + Blöcke `preis`, `person`, `storno` (`{{storno_text}}`), `widerruf` (`{{widerrufsbelehrung}}`, `{{widerruf_link}}`), `anfrage_angenommen`; G03 `gast_name, leistung, datum, zeit, link`; G04 `gast_name, leistung, datum, zeit` + Block `nachricht` (`{{nachricht}}`); G05 `gast_name, leistung, person, datum, zeit, link` + Block `person`; G06 `gast_name, leistung, datum, zeit, absage_von, grund` + Block `nachricht`; G07 `gast_name, leistung, alt_datum, alt_zeit, datum, zeit, link`; G08 `eingang, nummer` + Blöcke `zugeordnet`, `nach_frist` – **kein** `gast_name`. K20 (Du-Form, WEBklar-Layout mit Logo): `kunde_name, leistung, person, datum, zeit, gast_name, gast_email, gast_telefon, gast_nachricht, portal_link` + Block `anfrage`; K21: wie K20 + `alt_datum, alt_zeit, eingang` + Blöcke `abgesagt`, `verschoben`, `widerrufen`, `nicht_zugeordnet`, `nach_frist`.
- [ ] Tests: Vorlagen-Suite grün inkl. neuer IDs (Titel = Betreff, ein Kommentar, keine Reste, Text ohne Tags); `renderMail('G02', …, { du: true })` enthält im Text kein „ Sie “/„Ihr “; G01/G08 haben keinen Platzhalter `gast_name`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Mailvorlagen G01–G08, K20, K21“
### Task 10: Mail-Warteschlange
**Files:** Create `server/services/termine/outbox.js`, `test/termineOutbox.test.js`.
**Consumes:** Task 8. **Produces:** `einreihen({ featureId, bookingId, art, an, fromName, replyTo, template, values, blocks, ics, icsMethod }) ⇒ doc` (rendert sofort, speichert `wartet`, ruft `sendeEintrag(id)` ohne zu warten); `sendeEintrag(id)` (einziger Sendepfad: Pause/Budget prüfen, Sperre `termine-mail:<id>`, Fehlerklassen laut Global Constraints, nach Erfolg Inhalt leeren); `verarbeite(now)`; `warteAufVersand()` (für Tests: wartet auf laufende Sendeversuche); `zustellFehler(bookingIds) ⇒ Set`; `raeumeAuf(now)`; `pausiertBis()`.
- [ ] Tests: Budget 2/h ⇒ dritter wartet; 4xx ⇒ 1/5/30 min, dann `fehler`; 550 ⇒ sofort `fehler`; EAUTH ⇒ Pause, während der Pause **keine** Sendeversuche (Fake-Transport zählt), danach weiter; zwei `verarbeite` parallel ⇒ eine Mail; nach Erfolg sind `an/html/text/ics` leer; Aufräumen 7 Tage / 48 h.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Mail-Warteschlange mit Stundenbudget“
## Portal: Ablauf
### Task 11: Kontext, Einstellungs-Speicher, Mail-Werte
**Files:** Create `server/services/termine/kontext.js`, `server/services/termine/mails.js`, `test/termineKontext.test.js`.
**Consumes:** Tasks 4, 6, 7, 9, 10. **Produces:**
- `kontext.js`: `kontextPerSiteKey(siteKey) ⇒ ctx|null` (Funktion `termine`, `active`, Modus ≠ off; eigene Abfrage bis Task 17, dann `findActiveBySiteKey(siteKey, 'termine')`; 30 s Speicher-Cache je siteKey); `kontextPerFeature(featureId) ⇒ ctx|null` (auch `disabled`); `ctx = { feature, project, einstellung, version }`; `ladeEinstellung(featureId)`; `speichereEinstellung(feature, einstellung, { version, by }) ⇒ { einstellung, version }` (Sperre `termine-cfg:<featureId>`, Dokument-ID = featureId, 409 bei Versionskonflikt, Cache leeren); `websiteUrl(ctx)` (`einstellung.betrieb.websiteUrl` oder `https://` + erster Host aus `analyseHosts(project)`).
- `mails.js`: `BETREFFE` (Tabelle je Vorlage × Anrede × Fall, Sie/Du, G05 „Erinnerung: Ihr Termin am {datum} um {zeit}“); `werteFuer(art, ctx, buchung, extra) ⇒ { values, blocks }` (Links `${config.publicBaseUrl}/termin.html#v=<token>` bzw. `#m=<verwaltungsToken>`; `widerruf_link` = Verwaltungslink); `anGast(art, ctx, buchung, extra)`, `anBetrieb(art, ctx, buchung, { block, extra })` über `einreihen` (ICS: G02/G07 REQUEST, G06 CANCEL, K20 PUBLISH).
- [ ] Tests: Speichern/Laden mit Version, Konflikt ⇒ 409; Cache wird beim Speichern geleert; Betreff je Anrede (Du ohne „Sie“); G02-Werte mit Widerrufsblock nur bei Verbraucher; Links zeigen auf `config.publicBaseUrl`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Kontext, Einstellungs-Speicher, Mail-Werte“
### Task 12: Gast: halten, Vorschau, bestätigen
**Files:** Create `server/services/termine/ablauf.js`, `test/termineAblaufGast.test.js`.
**Consumes:** Tasks 5, 6, 11. **Produces** (jede Zustandsänderung unter `withLocks(['termine-buchung:'+id])` mit frischem Lesen):
- `halte(ctx, eingabe, now) ⇒ { status: 'gehalten'|'zu_viele', email }` – Eingabe `{ leistung, ressource, tag, zeit, name, email, telefon, nachricht, zusatz, erinnerung, zustimmungen: { storno, vorzeitig } }`; Feldfehler (400, `err.fehler`); Slot neu berechnen; Ressource bei `egal` wählen; **erst** Buchung `gehalten` anlegen (`heldUntil = now + holdMinutes`, `nachweis` = Fassungen/Hashes der Texte, Zeitpunkte, Knopftext, Anrede; `verbraucher`, `preisCent`, `preisArt`, `buchungsart`, `zahlart 'vorort'`, `zahlStatus 'keine'`, `icsSequenz 0`, `verifyHash`), **dann** Zellen; `SLOT_VERGEBEN` ⇒ Buchung `verfallen`, Fehler 409 Feld `zeit`; G01; Grenze offene Termine ⇒ nichts anlegen, G01-Block `zu_viele`, Ergebnis `zu_viele`.
- `vorschau(token, now) ⇒ { art: 'bestaetigen'|'verwalten', status, termin: { nummer, leistung, person, datum, zeit, dauer, preis, zahlung, ort }, betrieb: { name, telefon, adresse, impressumUrl, datenschutzUrl, websiteUrl }, gast: { name, email }, anrede, farbe, knopf, aktionen: { bestaetigen, absagen, verschieben, widerrufen }, hinweis }` (Token zuerst als Bestätigungs-, dann als Verwaltungstoken suchen; unbekannt/abgelaufen ⇒ 404).
- `bestaetige(token, now) ⇒ { status, schon? }` – `gehalten` + `heldUntil > now` ⇒ `sofort`: `bestaetigt` + `bestaetigtAt` + `manageHash` + G02 + K20; `anfrage`: `angefragt` + `manageHash` + G03 + K20-Block `anfrage`; schon bestätigt ⇒ `{ status, schon: true }`; abgelaufen ⇒ 410 `{ code: 'abgelaufen', betrieb: { name, telefon, websiteUrl } }`.
- [ ] Tests (Nachbau `defaultLimit 25`, `mailSink`, `warteAufVersand`): Halten ⇒ G01 ohne Gastname, Zellen; Bestätigen ⇒ G02 mit `icalEvent` und `#m=`-Link + K20 an den Betrieb, kein BCC; Anfrage ⇒ G03/K20-Block; zweiter Klick ⇒ `schon`; nach 31 min ⇒ 410; `email_normalisiert` (`' Max@GMail.com '` zählt gegen dieselbe Grenze); vierte offene Buchung ⇒ `zu_viele`, keine Buchung, G01-Block; Slot weg ⇒ 409, Buchung `verfallen`, keine Zellen; Zustimmung vorzeitig fehlt ⇒ 400 Feld `vorzeitig`; `vorschau` mit Verwaltungstoken liefert `gast`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Buchung halten und bestaetigen“
### Task 13: Gast: absagen, verschieben, widerrufen
**Files:** Modify `server/services/termine/ablauf.js`; Test `test/termineAblaufVerwalten.test.js`.
**Produces:** `sageAbGast(token, now)` (bis `absageBisStd` vor Beginn, sonst 409 Feld `_` „Absage nur telefonisch: {telefon}“; Zellen frei, G06 CANCEL, K21 `abgesagt`); `verschiebeGast(token, { tag, zeit }, now)` (gleiche Frist; Belegung `ohneBuchung`; `belegeZellen({ …, ausser: id })`, dann `gibZellenFrei(id, { behalte: neueZellen })`; `icsSequenz+1`; G07 + K21 `verschoben`); `zeitenFuerToken(token, { tag | monat }, now)` (für die Gästeseite, `ohneBuchung`); `widerrufeGast(token, { name }, now) ⇒ { eingang: { datum, uhrzeit } }` und `widerrufeOhneLink(ctx, { name, email, nummer }, now) ⇒ { eingang }` (Treffer mit Widerrufsrecht ⇒ `widerrufenAt`, `abgesagt` von `gast`, Zellen frei, G08 `zugeordnet`, K21 `widerrufen`; Treffer nach Frist ⇒ nur speichern + G08 `nach_frist` + K21 `nach_frist`; kein Treffer ⇒ G08 + K21 `nicht_zugeordnet`; Grenzen aus Global Constraints; Antwort immer gleich, Eingang in Berliner Zeit als Strings).
- [ ] Tests: Absage vor/nach Frist; Verschieben 10:00→10:30 (überlappend) gelingt, ICS Sequenz 1; Verschieben auf belegte Zeit ⇒ 409, alte Zellen unverändert; Widerruf 2 h vor Termin trotz abgelaufener Stornofrist; Widerruf ohne Treffer = gleiche Antwort + K21 `nicht_zugeordnet`; vierter G08 an dieselbe Adresse am Tag wird nicht gesendet; nicht angenommene Anfrage ⇒ kein Widerruf, nur Absage.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Absagen, Verschieben, Widerruf durch Gaeste“
### Task 14: Betrieb: Aktionen, manuelle Termine, Sperren
**Files:** Modify `server/services/termine/ablauf.js`; Test `test/termineAblaufBetrieb.test.js`.
**Produces:** `aktion(ctx, bookingId, { aktion: 'annehmen'|'ablehnen'|'absagen'|'verschieben'|'nichterschienen'|'anonymisieren', grund, nachricht, tag, zeit }, now)` (annehmen ⇒ `bestaetigt`, `angenommenAt`, G02 Block `anfrage_angenommen`; ablehnen ⇒ `abgelehnt`, G04, Zellen frei; absagen ⇒ G06 mit `absage_von` = Betriebsname; verschieben ⇒ wie Gast, ohne Frist, `ohneRegeln`; nichterschienen nur nach Beginn; anonymisieren ⇒ Gastfelder leeren, Status bleibt); `manuell(ctx, eingabe, now)` (`quelle 'manuell'`, `bestaetigt`, `verbraucher false`, Länge ≤ 500 min, G02 nur mit `bestaetigungSenden`); `sperre(ctx, { ressource, tag, von, bis, titel }, now)` (`quelle 'sperre'`, `bestaetigt`, `leistungName` = Titel, Länge ≤ 500 min sonst 400 Feld `bis` „Eine Sperre kann höchstens 8 Stunden 20 Minuten dauern. Für ganze Tage nutze Abwesenheiten.“); `entsperre(ctx, bookingId)`.
- [ ] Tests: annehmen ⇒ G02 mit Verwaltungslink (gleicher Link wie in G03); ablehnen ⇒ Zellen frei; manuell ohne Haken ⇒ keine Gastmail; Sperre 09:00–17:30 ⇒ 400; Sperre 09:00–13:00 blockiert Slots; anonymisieren; `nichterschienen` vor Beginn ⇒ 409.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Aktionen des Betriebs, manuelle Termine, Sperren“
### Task 15: Hintergrund-Jobs
**Files:** Create `server/services/termine/jobs.js`, `test/termineJobs.test.js`; Modify `server/index.js`.
**Produces:** jeder Job kehrt bei `termineAus()` sofort zurück (keine Abfrage); `collection_not_found` (404) ⇒ höchstens eine Warnung je Stunde. `minutenJob(now)` (abgelaufene Haltungen ⇒ `verfallen`, Zellen frei; `outbox.verarbeite`); `fuenfMinutenJob(now)` (Erinnerungen nach Spec 4: Marker zuerst, dann G05; `erledigt`; vergangene Anfragen ⇒ `abgelehnt`); `taeglicherJob(now)` (Löschfristen, Outbox aufräumen, verwaiste Zellen, `disabled`-Funktionen > 30 Tage: Buchungen, Zellen, Einstellung). In `server/index.js` wie die vorhandenen Jobs (`setTimeout(…).unref()` + `setInterval(…).unref()`, eigene Fehlerbehandlung): 60 s, 5 min, täglich (+180 s).
- [ ] Tests: Modus off ⇒ keine Appwrite-Anfrage; Verfall; Erinnerung genau einmal (zwei Läufe), nicht bei Buchung kürzer als `stundenVorher` vor Beginn, nachgeholt nach 20 min Jobausfall; `erledigt`; vergangene Anfrage ⇒ `abgelehnt`, Zellen frei; Löschfristen je Status; `verfall_und_bestaetigung_gleichzeitig` (Job und `bestaetige` parallel ⇒ ein Endzustand, keine G02 nach `verfallen`); fehlende Collection ⇒ eine Warnung.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Hintergrund-Jobs“
## Portal: Schnittstellen
### Task 16: Öffentliche Schnittstelle
**Files:** Create `server/services/termine/limits.js`, `server/routes/publicTermine.js`, `test/publicTermine.test.js`, `test/publicTermineRohling.test.js`; Modify `server/index.js` (nach `kundenansichtGuard`, vor dem globalen `express.json`), `server/middleware/kundenansichtGuard.js` (`PASS_PREFIXES` + `'/api/public/'`), `test/kundenansichtGuard.test.js`.
**Consumes:** Tasks 11–14, `analyseDomains(project)` aus `server/services/analyse.js`.
**Routen** (`/api/public/termine/v1`; feste Pfade vor `/:siteKey`):
- `GET /:siteKey/config` ⇒ `{ aktiv: true, heute, betrieb: { name, telefon, adresse, impressumUrl, datenschutzUrl }, anrede, farbe, leistungen: [{ id, name, beschreibung, dauer, preis, preisArt, buchungsart, knopf, verbraucher, ressourcen }], ressourcen: [{ id, name, typ }], personWaehlbar, regeln: { horizontTage, absageBisStd }, zahlung: { modus: 'vorort', text }, preisHinweis, storno: { text, version } | null, widerruf, erinnerung: { anbieten, stundenVorher }, gastFelder, hinweis, texte: { storno, erinnerung, vorzeitig, gesundheit }, formToken }` (`public, max-age=60`, ohne ETag).
- `GET /:siteKey/tage?leistung&ressource&monat` ⇒ `{ tage, monat: monatInfo }` (`max-age=15`); `GET /:siteKey/zeiten?leistung&ressource&tag` ⇒ `{ zeiten: ['09:00'], datumLang, vorzeitigNoetig }` (`no-store`; `vorzeitigNoetig` serverseitig für diesen Tag).
- `POST /:siteKey/buchungen` (Eingabe von `halte` + `formToken`, `kontakt_url`) ⇒ 201 `{ ok: true, email: 'm***@gmail.com', minuten: 30 }`.
- `POST /:siteKey/widerruf` `{ name, email, nummer }` ⇒ `{ ok: true, eingang: { datum, uhrzeit } }`.
- `POST /termin/vorschau|bestaetigen|absagen` `{ token }`, `POST /termin/verschieben` `{ token, tag, zeit }`, `POST /termin/widerruf` `{ token, name }`, `POST /termin/tage` `{ token, monat }`, `POST /termin/zeiten` `{ token, tag }`.
- `GET /kalender/:siteKey/:feedToken.ics` (konstantzeitiger Vergleich mit den `feedToken` der Ressourcen; falsch ⇒ 404 ohne Hinweis; `text/calendar; charset=utf-8`, `max-age=300`; bestätigte Termine + Sperren der Ressource, −7 bis +90 Tage).
- CORS: GET `*`; POST/OPTIONS erlaubt, wenn Origin-Host ∈ `analyseDomains(project)` ∪ {Host von `config.publicBaseUrl`} oder `new URL(origin).host === req.get('host')` (gleiche Herkunft); für `/termin/*` nur die beiden letzten; dann `Access-Control-Allow-Origin: <origin>`, `Vary: Origin`; sonst 403 `{ fehler: { _: 'Nicht erlaubt' } }`; Preflight 204 (`GET, POST, OPTIONS`, `Content-Type`, `Max-Age 86400`). Router-Parser + Fehlermiddleware laut Global Constraints; `/tage` und `/zeiten` 10 s Speicher-Cache je (siteKey, Parameter).
- [ ] `publicTermine.test.js` (startTestApp + Nachbau + `mailSink`; Origin/`X-Forwarded-For` je Test, `resetTermineLimitsForTests()`, `config.termine.formMinSekunden = 0`): rekursiv keine verbotenen IDs; unbekannt/off/disabled ⇒ `{aktiv:false}` (GET und POST); fremde Origin ⇒ 403, gleiche Herkunft und `liveDomain`-Origin erlaubt; Preflight (`send('OPTIONS', …)`); Honeypot und `zu_schnell` (mit `formMinSekunden = 3`) ⇒ 201 ohne Buchung; `abgelaufen` ⇒ 400; 11. Buchung je IP ⇒ 429, IPv6 /64 geteilt; Knopf je Fall in `config`; Ablauf halten → vorschau → bestätigen → verschieben → absagen über HTTP; Widerruf mit/ohne Treffer gleiche Antwort; Kalender mit falschem Token 404, richtigem enthält den bestätigten, nicht den gehaltenen Termin; `failNext` ⇒ 503 ohne Rohtext; Kundenansicht-Cookie stört öffentliche POSTs nicht.
- [ ] `publicTermineRohling.test.js` (nacktes `express()` nur mit dem Router): 20 KB ohne `content-length` (chunked) ⇒ 413; kaputtes JSON ⇒ 400; Antwort ohne Parser-Rohtext. Reihenfolge-Test: in `server/index.js` steht `app.use(kundenansichtGuard)` vor `'/api/public/termine/v1'` vor `app.use(express.json(`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: oeffentliche Schnittstelle v1 mit Missbrauchsschutz“
### Task 17: Funktionen verallgemeinern, Team-Route, Kundenansicht
**Files:** Modify `server/services/siteFeatures.js`, `server/routes/admin/funktionenAdmin.js`, `server/routes/funktionen.js`, `server/middleware/kundenansichtGuard.js`, `server/services/customerViewLog.js`, `server/services/customerAdmin.js`, `server/services/termine/kontext.js`; Tests `test/funktionenRoutes.test.js`, `test/siteFeatures.test.js`, `test/kundenansichtGuard.test.js` (Erlaubnisliste exakt; `MOUNTS` + `/api/termine` erst in Task 18), `test/customerViewLog.test.js` (zehn Aktionen), neu `test/funktionenTermine.test.js`.
**Produces:** `FEATURE_KEYS = ['news','termine']`; `FEATURE_LABELS = { news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' }, termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' } }`; `findActiveBySiteKey(siteKey, featureKey = 'news')`; `activateClosedTickets` unverändert (TP2). Team: `POST /api/team/funktionen/:customerId/:key` (`key` ∉ FEATURE_KEYS ⇒ 404; Meldung „Für dieses Projekt gibt es noch {keine}“); `adminView` lädt die Funktionsdokumente des Kunden einmal. `GET /api/funktionen` + `termine: { verfuegbar: termineFuerKunde(req.session.email) }`. `KUNDENANSICHT_ALLOWED` + `['POST', /^\/api\/termine\/.+$/]`, `['PATCH', /^\/api\/termine\/einstellungen\/?$/]`, `['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/]`; `VIEW_ACTIONS` + `termin_einstellungen`, `termin_aktion`, `termin_manuell`. `deleteCustomerWithPortalAccess` schaltet Terminplaner-Funktionen des Kunden ab (Notiz „Kunde gelöscht“). `kontext.js` nutzt nun `findActiveBySiteKey(…, 'termine')`.
- [ ] Tests: Team aktiviert `termine` für Projekt von c1, News unverändert; `/:customerId/kalender` ⇒ 404; Meldungen grammatisch; `GET /api/funktionen` mit/ohne Pilot-E-Mail; Erlaubnisliste exakt (inkl. Negativfall `POST /api/termineX`); Kunde löschen ⇒ `disabled`; bisherige Tests grün.
- [ ] FAIL → implementieren → PASS → Commit „Funktionen: Terminplaner als zweite Funktion (Team-Route, Kundenansicht)“
### Task 18: Kunden-Schnittstelle `/api/termine`
**Files:** Create `server/routes/termine.js`, `test/termineRoutes.test.js`; Modify `server/index.js` (`app.use('/api/termine', termineRoutes)`), `test/kundenansichtGuard.test.js` (`MOUNTS` + `['/api/termine', termineRoutes]`).
**Consumes:** Tasks 4, 6, 11–14, 17. Alle Routen: `requireSession`; `projectId` (Query/Body); `assertOwnProject`; `termineFuerKunde(session.email)` sonst 409 „Der Terminplaner ist noch nicht verfügbar.“; Funktion `off`/fehlt ⇒ 409 „Der Terminplaner ist für diese Website nicht aktiv.“; `disabled` ⇒ nur Lesen + Export; Schreiblimit 120/h je Kunde; Protokoll: PATCH einstellungen, POST vorlage, POST feed ⇒ `termin_einstellungen`; POST aktion, DELETE ⇒ `termin_aktion`; POST buchungen ⇒ `termin_manuell`.
**Routen:** `GET /einstellungen?projectId` ⇒ `{ state, aktivSeit, einstellung | null, version, vorlagen, feeds: { [ressourceId]: url }, siteKey, einbau: { snippet }, anfragenOffen }`; `POST /einstellungen/vorlage` `{ projectId, vorlage }` (nur ohne Einstellung, sonst 409; Betrieb vorbefüllt aus `analysePrivacyData(customer)` (Name/Firma, Anschrift, E-Mail) + `phone`); `PATCH /einstellungen` `{ projectId, version, einstellung }` ⇒ `{ einstellung, version }` | 400 `{ fehler }` | 409; `GET /buchungen?projectId&von&bis&status=a,b` (Zeitraum ≤ 62 Tage) ⇒ `{ buchungen: [{ id, nummer, status, quelle, startsAt, endsAt, datum, zeit, leistungId, leistung, dauer, titel, person, ressourceId, gastName, gastEmail, gastTelefon, gastNachricht, erinnerung, zustellFehler, angenommenAt, widerrufenAt, abgesagtVon, absageGrund }] }`; `GET /zeiten?projectId&leistung&ressource&tag[&ohne=bookingId]` (`ohneRegeln`); `POST /buchungen` `{ projectId, art: 'manuell'|'sperre', … }` ⇒ 201; `POST /buchungen/:id/aktion`; `DELETE /buchungen/:id?projectId` (nur Sperren); `POST /ressourcen/:id/feed` ⇒ `{ url }`; `GET /export.csv?projectId` (`text/csv; charset=utf-8`, BOM, `;`, Kopf `Nummer;Status;Datum;Uhrzeit;Leistung;Person/Platz;Name;E-Mail;Telefon;Nachricht`, Zellen mit `= + - @` am Anfang ⇒ `'` voran).
- [ ] Tests (`send` für PATCH/DELETE): fremdes Projekt 403; ohne Pilot 409; Vorlage zweimal ⇒ 409; PATCH alte Version ⇒ 409; Löschen mit künftigem Termin ⇒ 400 mit Anzahl; Aktion annehmen ⇒ G02; Kundenansicht darf PATCH/DELETE (mit Header) und es wird protokolliert; Guard-Schleife „jede Schreib-Route außerhalb der Liste ⇒ 403“ grün mit dem neuen Router; `disabled` ⇒ PATCH 409, Export 200; CSV entschärft.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Kunden-Schnittstelle“
## Portal: Oberflächen
**UI-Testserver (Tasks 19–21):** `startAppwriteMock({ schema, datetimes, defaultLimit: 25 })` + `startFakeStripe()` + `useTestEnv({ appwriteUrl, stripeUrl })`, dann dynamische Imports; `startTestApp({ '/api/public/termine/v1': publicTermineRoutes, '/api/termine': termineRoutes, '/api/funktionen': funktionenRoutes, '/': express.static('public') })`; danach `config.publicBaseUrl = app.url`, `config.termine.mode = 'pilot'`, `config.termine.pilotEmails = ['*']`, `config.termine.formMinSekunden = 0`; Seed: Projekt + aktive Funktion + Einstellung aus Vorlage `salon` mit Plan Mo–So 00:00–24:00, `feiertageGeschlossen false`, `vorlaufMin 0`, `maxOffeneJeEmail 10`; `mailSink`. Screenshots nach `/root/reports/terminplaner-ui/`.
### Task 19: Einbau-Skript, Demo, Einbauanleitung
**Files:** Create `public/embed/termine.js`, `public/embed/termine-demo.html`, `docs/funktionen/termine-einbau.md`, `test/ui/termine-widget.ui.mjs`, `test/termineEmbedStatic.test.js`.
**Consumes:** Task 16 (Antwortformen). **Produces:** Einbau nach Spec 7 (`data-webklar-termine`, `data-api`, `data-modus inline|knopf`, `data-knopf`, `data-leistung`); Klassen `wkt-*`; `<style id="wkt-style">` einmal, `@layer wkt`; CSS-Variablen `--wkt-akzent, --wkt-akzent-text, --wkt-hintergrund, --wkt-text, --wkt-rand, --wkt-radius, --wkt-schrift, --wkt-schrift-titel`; Schritte Spec 4; Monatsraster aus `monat` (APG: Pfeile, Pos1/Ende, Bild↑/↓, `aria-selected`, `aria-disabled`), Zeiten als Knöpfe; `aria-live="polite"`; Texte Sie/Du nach `anrede`, Haken-Texte aus `texte`, Knopf aus `leistungen[].knopf`; Honeypot `kontakt_url` (unsichtbar, `autocomplete="off"`, `tabindex="-1"`, `aria-hidden`); `config` neu laden, wenn älter als 100 min; Fuß „Vertrag widerrufen“ (Formular → `POST /widerruf`), „Datenschutz“, „Terminbuchung über WEBklar“; Knopfmodus lädt beim Klick; Fehler ⇒ Ausweichtext bzw. „Gerade nicht erreichbar. Bitte rufen Sie an: {telefon}“. Einbauanleitung: Snippet, Variablen, drei Stilbeispiele, Datenschutz-Absatz (Task 7), Abnahme-Checkliste, API-Kurzreferenz.
- [ ] `termineEmbedStatic.test.js`: Quelltext ohne `localStorage`, `sessionStorage`, `document.cookie`, `Date`, `getDay`, `toLocale`, `Intl.`; `innerHTML` nur mit Konstanten.
- [ ] Playwright (UI-Testserver): inline bis „Fast geschafft“ + G01 im Sink; Knopfmodus mit `<dialog>`; nur Tastatur; 390 px; `timezoneId: 'America/New_York'` zeigt dieselben Uhrzeiten; Anrede Du; zwei Widgets; Server gestoppt ⇒ Ausweichtext.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Einbau-Skript, Demo und Einbauanleitung“
### Task 20: Gästeseiten
**Files:** Create `public/termin.html`, `public/termin.js`, `public/termin.css`, `test/ui/termin-seiten.ui.mjs`.
**Produces:** liest `#v=`/`#m=`, entfernt den Hash per `history.replaceState`, ruft `/termin/vorschau`; Ansichten Bestätigen (Zusammenfassung + `knopf`), Bestätigt/Angefragt, Abgelaufen (Link `websiteUrl`, Telefon), Verwalten (Absagen mit Rückfrage in der Seite, Verschieben über `/termin/tage|zeiten`, „Vertrag widerrufen“ → Formular nach § 356a mit vorbefülltem Namen/Buchungsnummer/E-Mail, Knopf „Widerruf bestätigen“, Eingang mit `eingang.datum`/`uhrzeit`); Kopf in `farbe`; Fuß: Impressum/Datenschutz des Betriebs + „Impressum WEBklar“ (`https://webklar.com/impressum`); `noindex`; keine Cookies; Sie/Du.
- [ ] Playwright: G01-Link aus dem Sink öffnen ⇒ bestätigen ⇒ G02 im Sink; zweiter Aufruf „bereits bestätigt“; abgelaufen; absagen; verschieben; widerrufen (`timezoneId: 'America/New_York'` zeigt Berliner Eingangszeit); 390 px.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Gaesteseiten Bestaetigen, Verwalten, Widerrufen“
### Task 21a: Portal-Ansicht – Gerüst, Kachel, Termine
**Files:** Create `public/termine.js`, `public/termine.css`, `public/termine-view.js`, `test/termineView.test.js`, `test/ui/termine-portal.ui.mjs`; Modify `public/funktionen.js`, `public/app.js` (`VIEW_HASHES.termine = 'termine'`, `viewRefreshers.termine`, `APP_PARENT.termine = 'funktionen'`, `FEATURE_LABELS.termine = 'Terminplaner'`), `public/dashboard.html` (`<section class="kb-view" id="view-termine"><header class="kb-head"><h1>Terminplaner</h1><p>…</p></header><div id="termine-body">…`, Stylesheet), `test/funktionenTiles.test.js`.
**Produces:** Kachel (`termine?.verfuegbar` fehlt/false ⇒ „Kommt bald“; `project.features?.termine || { state: 'off' }`; *Aus* „Wir schalten den Terminplaner für dich frei.“ + Chat / *Aktiv* „Termine öffnen“ + „{n} Anfragen warten“ aus `anfragenOffen` / *Abgeschaltet*); `termine-view.js` (rein): `fensterParsen('09:00–12:30, 13:30–18:00')`, `fensterText`, `statusLabel`, `wocheVon(tag)`, `gruppiereNachTag(buchungen)`; `termine.js` `initTermine(root, { projectId })`: Projektauswahl, Vorlagenauswahl ohne Einstellung, Reiter-Gerüst, Reiter **Termine** (Tag/Woche/Monat mit `von/bis`, Filter → Statuslisten, Aktionen mit Rückfragen in der Seite, „+ Termin eintragen“, „Zeit sperren“ mit 500-Minuten-Hinweis, Hinweis „Bestätigung nicht zugestellt“), `disabled` ⇒ nur Lesen.
- [ ] Tests reine Helfer; Kachelzustände; Playwright (Fixture `page.route` und ein Lauf gegen den UI-Testserver): Vorlage wählen, Liste mit allen Status, Anfrage annehmen, App-Ansicht 390 px als Unterseite von Funktionen (Titel „Terminplaner“).
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Portal-Ansicht, Kachel, Termine“
### Task 21b: Portal-Ansicht – Leistungen, Team & Plätze, Einstellungen
**Files:** Modify `public/termine.js`, `public/termine.css`, `test/ui/termine-portal.ui.mjs`.
**Produces:** Reiter **Leistungen** (Liste, Reihenfolge, Felder Task 4, Gesundheits-Hinweis), **Team & Plätze** (Wochenplan je Tag mit `fensterParsen`, Leistungszuordnung, Abwesenheiten, Bundesland, Sonderöffnungen, Kalender-Abo-Link kopieren/neu), **Einstellungen** (Spec 9 Punkt 4; Zahlung „online“ ausgegraut „kommt bald“; Hinweis Widerruf schlägt Stornofrist; Stornotext-Muster gekennzeichnet; Export-Link); Speichern mit `version`, Konflikt ⇒ „Die Einstellungen wurden inzwischen geändert. Bitte neu laden.“; Feldfehler am Feld.
- [ ] Playwright: Leistung anlegen, Wochenplan ändern, Speichern, Konflikt (zweiter Tab), Feldfehler (`javascript:`-URL), Feed-Link neu erzeugen.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Portal-Ansicht Leistungen, Team, Einstellungen“
## WOMS
### Task 22: Funktionen-Reiter für mehrere Funktionen
**Repo:** `/home/webklar/apps/ticket.webklar.com/dev-termine` (aus V3; existiert er nicht, Schritte aus V3 zuerst ausführen).
**Files:** Create `src/lib/funktionenView.js`, `tests/unit/funktionenView.test.mjs`; Modify `src/lib/teamApi.js` (`setFeature(customerId, key, { projectId, action, note })`; `setNewsFeature` als Wrapper), `src/components/customer/CustomerFunktionen.jsx`, `src/lib/viewLog.js` (+ `termin_einstellungen: 'Terminplaner-Einstellungen geändert'`, `termin_aktion: 'Termin bearbeitet'`, `termin_manuell: 'Termin eingetragen'`), `tests/unit/viewLog.test.mjs`.
**Produces:** `FUNKTIONEN = [{ key: 'news', label: 'News', bestaetigen: { activate: 'News ohne Zahlung aktivieren?', disable: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.' } }, { key: 'termine', label: 'Terminplaner', bestaetigen: { activate: 'Terminplaner ohne Zahlung aktivieren?', disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich.' } }]`, `ZUSTAND` (bisheriges `STATE`), `funktionOf(project, key)`, `aktionen(state)`, `mergeFunktion(projects, projectId, key, aktualisiert)`; Reiter zeigt je Projekt eine Zeile je Funktion, Ticketnummer als Link `/tickets?woid=`, Antwort übernimmt nur `features[key]`.
- [ ] Tests (Register, Aktionen, Merge überschreibt die andere Funktion nicht); `npm test`; `nice npx vite build` (vorher `free -m`).
- [ ] FAIL → implementieren → PASS → Commit „Funktionen-Reiter: News und Terminplaner je Projekt“
---
## Abschluss
- [ ] Gesamt-Review des Branches (Portal + WOMS) gegen Spec TP1 und Global Constraints; Critical/Important beheben.
- [ ] `npm test` (Node 24); reine Termine-Tests unter Node 20 (`docker run --rm -v "$PWD":/w:ro -w /w node:20-alpine node --test test/termineZeit.test.js test/termineSlots.test.js test/termineEinstellungen.test.js test/termineIcs.test.js`).
- [ ] Ledger nach `/root/reports/terminplaner-tp1/`.
## Livegang (je Schritt Freigabe des Nutzers; der Nutzer pusht `main`)
1. **L1 Portal:** auf aktuelles `origin/main` rebasen, `npm test`, Push `feature/terminplaner:main` (Deploy installiert `feiertagejs`). Schalter `off` ⇒ keine Jobs, nichts sichtbar. Prüfen: `/api/public/termine/v1/aaaaaaaaaaaaaaaa/config` ⇒ `{"aktiv":false}`, `/api/termine/einstellungen` ohne Login 401, Log ohne Fehler.
2. **L2 Schema:** `docker exec -w /app webklar-kundenbereich-server node scripts/termine-setup.mjs --check`, dann ohne `--check`; erneut `--check` ⇒ nichts fehlt.
3. **L3 Schalter:** Compose (`kundenbereich-server`): `TERMINE_ENABLED=${TERMINE_ENABLED:-off}`, `TERMINE_PILOT_EMAILS=${TERMINE_PILOT_EMAILS:-}`; `/home/webklar/apps/.env` (Sicherung `.bak-termine`): `TERMINE_ENABLED=pilot`, `TERMINE_PILOT_EMAILS=kenso.gri@gmail.com`; `docker compose -f /home/webklar/apps/docker-compose.yml up -d --no-deps kundenbereich-server` (nie `preview-portal`).
4. **L4 WOMS:** Push `feature/terminplaner:test`; Bundle enthält „Terminplaner“.
5. **L5 Pilot:** WOMS Kunde „test“ → Funktionen → Terminplaner für „Test-Website“ aktivieren; Portal (Login des Testkunden): Vorlage, Leistungen, Zeiten, Betriebs-E-Mail kenso.gri@gmail.com; `WEBklar/test-website`: Seite „Termin buchen“ mit Widget + Datenschutz-Absatz-Entwurf, Push ⇒ Deploy; Testbuchungen: bestätigen, Anfrage annehmen/ablehnen, Erinnerung (Termin knapp über der Erinnerungszeit), absagen, verschieben, widerrufen, Kalender-Abo in Google.
6. **L6:** Nutzertest; Testbuchungen nach Wunsch löschen.
@@ -0,0 +1,909 @@
# Terminplaner TP2 Verkauf – Umsetzungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Kunden mit laufendem Wartungs-Abo bestellen den Terminplaner selbst (Fragebogen → Zustimmungen → 149 € über Stripe → Ticket mit Einbauanleitung → Einbau → Freischaltung); endet das Abo, endet der Terminplaner (Löschung nach 30 Tagen); dazu Rechtstexte + Kanzlei-Paket; am Ende `TERMINE_ENABLED=on`.
**Architecture:** Der News-Bestellweg wird zu einem Register `config.features` verallgemeinert (Checkout, Mails, Ticket, Freischaltung je Funktion). Der Fragebogen schreibt in dasselbe `termineConfig`-Dokument wie TP1 (Entwurf = spätere Live-Einstellung) plus ein neues Attribut `fragebogen`. Ein gemeinsamer Abo-Baustein `aboStatus.js` dient Analyse und Terminplaner; ein täglicher Abgleich koppelt den Terminplaner an das Abo. Einbauanleitung als reine Funktion, im Ticket (kurz) und im WOMS (voll).
**Tech Stack:** wie TP1 (Node 20 Produktion / 24 Host, Express 4, Appwrite 1.8.1 REST, nodemailer 9, Stripe über `stripeBilling.js`, Playwright aus `/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs`), WOMS React/Vite.
**Spec:** `docs/superpowers/specs/2026-10-03-terminplaner-tp2-verkauf-design.md` (Abschnitte 1–11, Entscheidungen D1–D8). TP1-Spec `docs/superpowers/specs/2026-10-02-terminplaner-design.md`, TP1-Ledger `/root/reports/terminplaner-tp1/progress.md` (Rulings R1–R55), Erkundung `/root/reports/terminplaner-tp2/erkundung-2026-10-03.json`.
**Worktrees:** Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-termine`, Branch `feature/terminplaner-tp2` (von `origin/main` 3a0b008; Spec-Commit 6ed0e3b; eigenes `node_modules`). WOMS `/home/webklar/apps/ticket.webklar.com/dev-termine`, Branch `feature/terminplaner-tp2` von `origin/test` 78bc99a (V1).
## Global Constraints
- Nichts pushen, nichts in Produktion schreiben, keine Live-Stripe-Aufrufe; das macht der Inhaber bzw. der Livegang mit Freigabe. Nie `git add -A`; Dateien explizit stagen. WOMS: jeder gepushte Branch wird ausgeliefert.
- Node-20-tauglich (keine APIs ab Node 21). `config.*` bei jedem Aufruf lesen (Tests ändern Werte zur Laufzeit).
- Tests: Portal `npm test` (`node --test test/*.test.js`), Mocks aus `test/support/` (`startAppwriteMock`, `startFakeStripe`, `mailSink`, `useTestEnv`, `startTestApp`, `sessionCookie`); nie echte Dienste. `test/previewDevRouter.test.js` ist ein bekannter, unabhängiger Wackel-Test (einzeln wiederholen). WOMS `npm test` + einmal `nice npx vite build`. Playwright nicht in `npm test`, schwere Läufe nacheinander, vorher `free -m`, mit `nice`.
- Schema nur über idempotente Skripte mit `--check`: `scripts/termine-setup.mjs` (Termine-Collections) und das neue `scripts/feature-setup.mjs` (`portalFeatures.disabledReason`, `aboEndeMailAt`, `zahlungMeldung`, neue Collection `featureConsents`); `scripts/setup-abo-collections.mjs` bleibt unverändert; `test/support/schema.js` zieht mit.
- News bleibt im Verhalten unverändert (bestehende Tests grün), **außer** den Rechtstext-Korrekturen in Block F. Analyse ändert ihr Verhalten nicht (nur gemeinsamer Abo-Baustein).
- Funktionsschlüssel `news`, `termine` im Register; `analyse` bleibt eigener Weg. Preise in Cent; Terminplaner-Einrichtung `14900`, Stripe-Katalogschlüssel `termineSetup`, Lookup-Key `webklar_termine_einrichtung_14900`. Preisanzeige „149 €“ mit Hinweis „ohne Umsatzsteuer gemäß § 19 UStG“ (gilt nach Block F auch für News).
- Zustände `portalFeatures.state`: `off` | `ordered` | `setup` | `active` | `disabled`. `disabledReason` ∈ `team` | `abo_ende` | `abo_ende_vor_einrichtung` | `kunde_geloescht`. `unlockedByPurchase === true` nur bei bezahlter Bestellung; Staff-/Pilot-Dokumente sind vom Abo-Abgleich ausgenommen.
- Mails neu: **K22** „Terminplaner beendet“ (Blöcke `abo_ende`, `hosting`, `vor_einrichtung`, `daten`), **K23** „Terminplaner läuft wieder“, **T19** „Erstattung fällig: Terminplaner-Einrichtung“ (Vorlage aus B9b; einziger Sender `meldeErstattung`, B10b), **T20** „Terminplaner Betriebsmeldung“ (A10a1). K15/K16/T16 mit `{{funktion}}`; T16 neue Blöcke `kein_abo`, `bereits_aktiv`, `anleitung`, `gesundheit` (`{{gesundheit_text}}`) und `anhang_fehlt` (F8); K02/K03 Block `funktionen`; K04/T09 mit `{{produkt}}` und `{{preis}}`. K/T in Du-Form; Layout nach den bestehenden Vorlagen; `test/mailTemplates.test.js`: Liste endet `…ids('K',23), …ids('T',19), 'T20'`, Kopfkommentar ergänzen.
- Kundentexte Du-Form, Gästetexte Sie/Du nach `anrede`, Rechtstexte für Verbraucher Sie-Form. Text nur über `textContent`/`esc()`; `href` nur geprüfte `https:`-URLs mit `rel="noopener noreferrer"`. Keine Gesundheitsangaben.
- Kundenansicht: `POST /api/funktionen/:key/checkout` und `PUT /api/termine/fragebogen` sind für `req.session.viewAs` gesperrt (403); die Wildcard `POST /^\/api\/termine\/.+$/` wird durch eine explizite Liste ersetzt (Block A).
- Protokolle ohne Gast-PII; Tagesbericht nur mit Zählungen.
- Ein Portal-Prozess (Sperren, Grenzen, Mailbudget im Speicher) – festhalten, nicht ändern.
## Review Focus
1. Zahlung trifft ein, nachdem sich der Zustand geändert hat (Abo beendet, Staff hat aktiviert, Sitzung abgelaufen, Dokument `disabled`): nie Geld ohne Spur – immer Ticket **oder** T16/T19 an das Team, nie doppelt. → Block B (onFeaturePaid-Fälle), Block E
2. Abo-Abgleich irrt: Lesefehler, `past_due`, Kündigung vorgemerkt, Pilot-/Staff-Dokument, Abo kommt innerhalb von 30 Tagen zurück ⇒ nie Abschalten bei Fehler, Pilot nie, Rückkehr schaltet wieder ein, Löschung prüft vorher frisch. → Block E
3. Entwurf ohne aktive Funktion: nie öffentlich sichtbar, nur der eigene Kunde schreibt, ab `ordered` nur lesbar (außer Staff in Kundenansicht), Kundenansicht bestätigt keine Fragebogen-Schritte. → Block C
4. Löschjobs mit vielen Betrieben: seitenweise, nie früher als die Frist, nie still für immer übersprungen (Überlauf läuft an Folgetagen weiter und wird gezählt). → Block A
5. Zustimmungsnachweis: genau die gezeigten Texte + Fassungen + Zeitpunkt sind gespeichert, **bevor** der Kunde zu Stripe geht (die Stripe-Sitzung wird zuerst angelegt, weil der Nachweis ihre ID trägt; die URL geht erst nach gespeichertem Nachweis an den Browser); Widerrufs-Anker = Zeit des Klicks. → Block B, F
## Schnittstellen über Blockgrenzen (verbindlich für alle Tasks)
- **Register** `config.features` (Block B, `server/config.js`): `{ news: {...}, termine: {...} }`, je Eintrag: `key`, `product` (Katalogschlüssel `newsSetup` | `termineSetup`), `label: { name, kurz, keine }`, `priceLabel` (z. B. „149 €“), `setupEstimate`, `consent: { version, knopf, items: [{ id, text, haken }] }`, `leistungsbeschreibung`, `avv`, `widerruf` (Fassungen + URLs), `returnHash` (`'news'` | `'termine'`), `ticket: { topic(project) ⇒ string, details(ctx) ⇒ Promise<string> }` mit `ctx = { feature, doc, project, customer }`, `mails: { funktion, knopf }`, `requiresWartungsAbo: boolean`, `ticketFreischaltung: boolean`, `disabledMessage`, `orderedConflictMessage`; dazu `config.ustHinweis`. Helfer in `server/services/featureRegistry.js`: `featureConfig(key) ⇒ Eintrag | null`, `featureHaken(key)` mit den Haken `verfuegbar`, `bestellpruefung`, `unterlagenBereit`, `belehrung`, `freischaltbar`, und `anleitungsLink`.
- **Checkout-Body** (Block B): `POST /api/funktionen/:key/checkout` mit `{ projectId, zustimmung: { version, haken } }` oder `{ projectId, fortsetzen: true }` (offene Sitzung fortsetzen).
- **Abo-Baustein** (`server/services/aboStatus.js`; Kernversion in B5 mit endgültiger Signatur, ausgebaut in E1): `laeuft(customerId, { altbestand = true } = {}) ⇒ Promise<boolean>` (Wartungs-Abo, `productKey || 'wartung'`, Status ∈ `ACTIVE_STATUSES`; wirft bei Lesefehler; B5 kennt nur `laeuft(customerId)`, E1 ergänzt `altbestand` und die Wechsel-Regel), `bestellbar(customerId) ⇒ Promise<{ ok: boolean, grund?: 'kein_abo' | 'gekuendigt' | 'nicht_aktiv' }>` (Gründe in dieser Prüfreihenfolge), `aboMemo() ⇒ { laeuft, bestellbar }` (je Lauf zwischengespeichert; E1 ergänzt `lage`) und **erst ab E1** `aboLage(customerId) ⇒ Promise<{ laeuft, art, endedAt }>` (gelesener Spiegel inkl. Wechsel-Regel; in B5 gibt es sie nicht). **`aboMeldung` gehört nicht in diese Datei:** `aboMeldung(feature, grund) ⇒ string` (Argumentreihenfolge: Register-Eintrag, dann Grund ∈ `kein_abo` | `gekuendigt` | `nicht_aktiv` | `unbekannt`) liegt in `server/services/featureOrder.js` (B6) und ist die einzige Quelle der Abo-Hinweistexte; C2, C4a, C4b, C10a und E importieren sie von dort. Andere Blöcke nutzen nur diese Funktionen.
- **siteFeatures**: `disable(docId, { by, adminNote, reason })` idempotent (ein bereits `disabled` Dokument behält `disabledAt`), setzt `disabledReason` (A11); `activate()` leert `disabledAt`, `disabledReason`, `aboEndeMailAt`, nicht `zahlungMeldung` (A11); `markPaid(...) ⇒ { doc, created, fall }` und Marker `zahlungMeldung` (B10a); `meldeErstattung(doc, { customer, sitzung, nachAboEnde })` ist der **einzige** T19-Sender (B10b); `activateClosedTickets({ now, customerId })` über alle Register-Schlüssel mit `ticketFreischaltung: true` und Haken `freischaltbar(doc) ⇒ Promise<boolean>` aus dem Register (Termine: Einstellung vorhanden **und** `laeuft`) (B11).
- **Zustimmungsnachweis** (Block B, `server/services/featureConsents.js`, Collection `featureConsents`): `speichereZustimmung({ customerId, projectId, featureKey, stripeSessionId, fassungen, texte, ip, userAgent, konto, at }) ⇒ Promise<doc>` (Hash über die Texte), `letzteZustimmung({ customerId, projectId, featureKey }) ⇒ Promise<doc | null>`, `zustimmungFuerSitzung(sessionId)`, `nachweisTexte(feature, { belehrung })`; Widerrufs-Anker `vertragAt` über `paidFeatureOrders`.
- **Fragebogen** (Block C, `server/services/termine/fragebogen.js`; Stub in B5): `bestellungAnsicht(...)` (async), `BRANCHEN` (Liste `{ key, name, vorlage | null, gesundheit: boolean }`), `SCHRITTE` (Schlüssel der 8 Schritte), `normalisiereFragebogen(eingabe) ⇒ { fragebogen } | { fehler }`, `bestellpruefung({ einstellung, fragebogen, feature, abo, mitAbo = true }) ⇒ Array<{ schritt, text }>` (leer = bestellbar; der Abo-Punkt (h) nur bei `mitAbo !== false`: der Checkout (B6, Standard-Haken) ruft mit `mitAbo: false` und ohne `abo`, weil dort die Abo-Stufe (7) die Codes `kein_abo` | `gekuendigt` | `nicht_aktiv` liefert; nur `GET /api/termine/einstellungen` (C4a) übergibt `abo` und zeigt (h) als offenen Punkt), `gesundheitsWarnungen(einstellung) ⇒ string[]`. Attribut `termineConfig.fragebogen` (JSON-String, ≤ 16 000 Zeichen). Route `PUT /api/termine/fragebogen { projectId, fragebogen }`.
- **Einbauanleitung** (Block D, `server/services/termine/einbauanleitung.js`): `einbauanleitung({ feature /* portalFeatures-Dokument */, project, einstellung, fragebogen, version, now }) ⇒ { stand: { version, updatedAt }, warnungen: string[], abschnitte: Array<{ id, titel, text, kopierbar }>, text, kurz }` (rein, nie `feedToken`); `einbauSnippet({ siteKey, apiBase, telefon, modus, knopfText })` (aus `routes/termine.js` hierher verschoben). Team-Route `GET /api/team/funktionen/:customerId/termine/anleitung?projectId=`.
- **Rechtstexte** (Blöcke D/F, `server/services/termine/rechtstexte.js`): `datenschutzAbsatz(einstellung, anrede) ⇒ string` (Quelle für Anleitung und Doku).
- **Status-Route** (Block A): `GET /api/team/termine/status ⇒ { budget, queue: { wartet, aeltesteMin }, pause, letzterTagesjob, aktiveBetriebe }`.
## Grundlagen der Blöcke (aus den Block-Einleitungen, verbindlich für alle Tasks)
Jeder Task bekommt Kopf und eigenen Text, nicht die Einleitung seines Blocks. Was ein Task aus einer Block-Einleitung braucht, steht deshalb hier.
- **Zeilenangaben:** Alle `Z. n` und `:n` im Plan gelten für Portal `3a0b008` (WOMS `78bc99a`) und verschieben sich durch frühere Tasks an denselben Dateien (besonders `test/termineJobs.test.js`, die A3a, A3b, A4, A6, A10a2, A10a3, A12, E7, E8 und C6 ändern; ebenso `server/services/termine/jobs.js`, `outbox.js`, `server/config.js`, `server/services/siteFeatures.js`, `server/routes/termine.js`): **per Test- oder Funktionsname suchen**, nicht nach Zeilennummer.
- **Neue Werte in `config.termine`** (Standardwerte, bei jedem Aufruf gelesen, Tests setzen sie zur Laufzeit; angelegt in A3a, A3b, A6, A7 und A9a): `mailsProFunktion: 40`, `mailsReserve: 0.25`, `mailListe: 100`, `loeschDeckel: 2000`, `loeschDeckelGesamt: 20000`, `zellenDeckel: 5000` (vorhanden: `mailsProStunde: 200`, `holdMinutes: 30`).
- **Testkonventionen für Register-, Checkout-, Abo- und Zustimmungstests** (Block B; gelten ebenso für die Tests in C, D, E und F, die diese Teile berühren): `startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents'), termineConfig: schemaKeys('termineConfig') } })` (`featureConsents` ab B3), `useTestEnv` vor den dynamischen Importen, `config.termine.mode = 'pilot'` und `config.termine.pilotEmails = ['*']` für Termine-Fälle, Mails über `setMailTransportForTests(mailSink())`. Der Nachbau prüft Schlüssel nur beim Anlegen/Ändern, nicht in `seed`: Felder, die erst Block A/E ins Schema bringen (`disabledReason`), dürfen Tests schon seeden (`schemaKeys('portalFeatures')` enthält seit A11 `disabledReason`, `aboEndeMailAt`, `zahlungMeldung`). **Pins auf Werte, die Block F ändert** (News-`consent.version`/-Text, `mails.preisHinweis`, Termine-Items und -Fassung, `leistungsbeschreibung`/`avv`) lesen `config.features.*` statt Literale; nur der Einfrier-Test `featureMails` (B9a, B9b) und die dort genannten Wortlaute sind Literale, die Block F2 gezielt anpasst.
- **Falle `seedFixtures`:** `seedFixtures(aw)` legt für `c1` nur ein **Hosting**-Abo `sub_h` an, `c2` hat ebenfalls nur Hosting, `c3` gar kein Abo. Jeder Test, der für `c1` ein **Wartungs**-Abo braucht (Termine-Bestellung, Abo-Baustein, Freischaltung, Abgleich), überschreibt es mit `aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))` (Hilfen in `test/support/fixtures.js`); sonst scheitert er an `kein_abo`.
- **Im Code geprüft (Block B):** Das Schema läuft über `scripts/feature-setup.mjs` (A11 legt es nach dem Muster `scripts/analyse-setup.mjs` an, B3 ergänzt `featureConsents`); `scripts/setup-abo-collections.mjs` kennt **kein** `--check`, ist ein Top-Level-Skript ohne Exporte und bleibt unangetastet. `scripts/stripe-products.mjs` exportiert nur `parseArgs` (Task B2 exportiert `KEYS` und `catalogEntry`). `aboStatus.js`, `featureRegistry.js`, `featureConsents.js` und `termine/fragebogen.js` gibt es noch nicht. `markPaid` übergibt weder `topic` noch `details` (`siteFeatures.js:261-266`), `createFeatureTicket` kürzt nichts (`ticketAdmin.js:85`). `teamNotice` und `unmatchedNotice` kennen kein `funktion` (T16-Betreff „…: News-Pinnwand“ ist in `test/funktionenRoutes.test.js` Z. 150, 339, 371 gepinnt und bleibt für News so). `seedFixtures(aw)` legt für `c1` ein **Hosting**-Abo `sub_h` an; Termine-Tests brauchen `aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))` (überschreibt es), `c2` hat nur Hosting, `c3` gar kein Abo.
- **Festlegungen des Blocks E** (gelten für E4–E10):
- Schreiben nur unter der Sperre `feature-admin:<projectId>` (wie `routes/admin/funktionenAdmin.js:87`) und nach **frischem** Lesen von Dokument **und** Abo (`aboLage` direkt, nie der Memo). Der Memo (`aboMemo()`) dient nur der Vorauswahl je Kunde im Listenlauf.
- Lesefehler (Appwrite, Abo, Ereignisse) ⇒ Kunde/Dokument überspringen, `fehler += 1`, Log ohne Namen, Adressen und IDs von Gästen; **nie** abschalten, wenn etwas nicht lesbar war.
- Marker erst nach Erfolg: `aboEndeMailAt` (K22) und `zahlungMeldung === 'gemeldet'` (T19 über `meldeErstattung`, B10b; `purchaseStatus` bleibt `paid`). Schlägt der Versand fehl, wiederholt ihn der nächste Lauf; `disabledAt` ändert sich dabei nie (sonst wüchse die Löschfrist).
- Abgleich nur für `disabledReason`-Werte aus dem Kopf (`team | abo_ende | abo_ende_vor_einrichtung | kunde_geloescht`); ein `disabled`-Dokument ohne Grund (Altbestand) wird wie `team` behandelt und nie angefasst. Die Spec nennt in Abschnitt 6 zusätzlich `frist`; maßgeblich ist die Liste aus dem Plankopf.
- **Fassungsdatum `<F>`** (gilt für F1 bis F15b und überall, wo `<F>` oder `<V>` steht): `<F>` ist der **Berliner Kalendertag des F1-Commits** (`JJJJ-MM-TT`, muss nach `2026-10-02` liegen, der eingefrorenen Altfassung der Gästetexte in F10a). F1 schreibt ihn als Wert von `config.termine.avvVersion`; alle späteren Tasks lesen die Konstante und tragen den Tag nie als Literal in Code oder Tests (Ausnahme: das Literal `RECHTSTEXTE_VERSION` in `termine/rechtstexte.js`, das F10b1 auf denselben Tag setzt; `<V>` = `<F>`, ein Test hält beide gleich). Drei Stände: `config.termine.avvVersion` (Leistungsbeschreibung, AVV, Belehrung, Bestelltexte, News-`consent.version`), `RECHTSTEXTE_VERSION` (Gästetexte) und „Stand“ der Datenschutzerklärung (Tag der Änderung). Eine **ausgelieferte** Fassung wird nie überschrieben: sobald die Texte an die Kanzlei gegangen oder live sind (Block G1/G7), heißt jede Textänderung neues Datum und neue Dateien; davor darf der Controller `<F>` mit einem eigenen Commit „Fassungsdatum anheben“ auf den Tag der Übergabe setzen. Beim Anheben entstehen neu: die F6-Dateien `*-<F>.html/.pdf` (alte, nie ausgelieferte Dateien werden gelöscht), das Archiv `termine-gaestetexte-<V>.md` (F10b1) samt Hash-Pin, die Fassungs- und Hash-Pins in den Tests von F2, F7, F10b1, F13a und F15a, `RECHTSTEXTE_VERSION` (F10b1) und News-`consent.version` (F2); die Datenschutzerklärung nur bei Textänderung.
---
## Vorab (Controller, kein Subagent)
- [ ] **V1 WOMS-Branch:** im Worktree `/home/webklar/apps/ticket.webklar.com/dev-termine` `git fetch origin && git checkout -b feature/terminplaner-tp2 origin/test`; `npm test` grün als Basis.
- [ ] **V2 Basis Portal:** im Portal-Worktree `npm ci` falls `node_modules` fehlt, `npm test` als Basis (Erkundung: 2235 Portal grün, WOMS 294); dazu der Node-20-Lauf der reinen Termine-Tests (`docker run --rm -v "$PWD":/w:ro -w /w node:20-alpine node --test test/termineZeit.test.js test/termineSlots.test.js test/termineEinstellungen.test.js test/termineIcs.test.js`).
- [ ] **V3 Ledger:** SDD-Workspace für diesen Plan anlegen; die Rulings R1–R55 aus TP1 gelten weiter, sofern dieser Plan nichts anderes sagt. Abweichungen aus der Planprüfung festhalten: der Entwurfszugriff (C4b) und der `kein_abo`-Hinweis (B10b) nutzen `laeuft`; der Tagesbericht zählt `ausgenommen`e Dokumente; der Abo-Abgleich hat keinen eigenen Beobachtungsschalter (er beobachtet über den Datenstand); der Checkout wendet den Abo-Punkt (h) des Bestell-Gates nicht an (`mitAbo: false`, B6/C2), die Abo-Stufe (7) in B6 liefert die Abo-Codes.
Reihenfolge der Blöcke: **A → B → E → C → D → F**, danach **Block G** (Livegang, am Ende von Block F). Was TP2 bewusst nicht enthält, steht in Spec Abschnitt 1 und 8 („SOLLTE“/„Kann warten“).
---
## Block A: Technik-Voraussetzungen aus TP1
Schuld aus TP1 (Spec Abschnitt 8, MUSS #1–#6; die Verallgemeinerung von `activateClosedTickets` baut Block B, Task B11) plus die billigen SOLLTE-Punkte am selben Code (Frühausstieg in `verarbeite`, Index `termineOutbox(bookingId)`, Minutenjob teilen). Löschjobs, Zellen-Sweep und CSV-Export lesen seitenweise (`limit` ≤ 100, Export 500), haben einen Deckel je Lauf und laufen an Folgetagen weiter; die Mail-Warteschlange bekommt Kontingent, Vorrang, Alarm und eine Status-Route. Neue Werte in `config.termine` (`server/config.js` Z. 378–394, bei jedem Aufruf gelesen, Tests setzen sie zur Laufzeit): `mailsProFunktion: 40`, `mailsReserve: 0.25`, `mailListe: 100`, `loeschDeckel: 2000`, `loeschDeckelGesamt: 20000`, `zellenDeckel: 5000`.
Reihenfolge: A2 vor A3a–A6 und A9a; A3a vor A3b, A4 und A6; A7 vor A9a; A9a vor A9b; A3a, A3b, A4, A6, A7 und A9b vor A10a1; A10a1 vor A10a2 vor A10a3; A10a3 vor A10b und A12; A11 steht für sich (vor Block B). Schema-Änderungen (Indizes, `portalFeatures.disabledReason`, `aboEndeMailAt`, `zahlungMeldung`) laufen **vor** dem Code-Deploy über die idempotenten Skripte `scripts/termine-setup.mjs` und das neue `scripts/feature-setup.mjs` (beide mit `--check`; Livegang Schritt 3): `activate()` schreibt die neuen Felder, ohne Schema käme `Unknown attribute`. `scripts/setup-abo-collections.mjs` bleibt unverändert (es hat kein `--check` und ist ein Top-Level-Skript ohne Exporte); `feature-setup.mjs` folgt dem Muster von `scripts/analyse-setup.mjs` (exportierte Attribut-Listen, `main({ check, client, log })`, Test mit Mock-Client).
**Bewusst nicht in TP2** (Spec Abschnitt 8 „SOLLTE“/„Kann warten“, kein Task; im Ledger als offen führen): Transport `pool: true` und Begrenzung der Parallelität der `einreihen`-Sendungen (`mailer.js:25`); Mailbudget nach einem Neustart aus der Outbox rekonstruieren; `/tage` und `/zeiten` außerhalb des Horizonts vor `imZeitraum` kurzschließen (`publicTermine.js`); Hetzner-Überlimit-Antworten als Pause statt als `fehler` einstufen (`outbox.js`); `mailSicher`-Fehlermarker, damit das Portal „nicht zugestellt“ zeigt; Tagesjob zu fester Nachtzeit (heute 180 s nach jedem Start); `TERMINE_TOKEN_SECRET`; `engines >=20`; axe-/Screenreader-Lauf von Widget und Gästeseite (deshalb in der Leistungsbeschreibung keine Konformitätszusage, F4). Lock-Wartezeiten, `halte` ohne `termine-cfg`-Lock, Fetch-Timeouts und der Widget-Monolith bleiben liegen.
### Task A1: Kundenansicht-Wildcard durch eine explizite Liste ersetzen
**Files:** Modify `server/middleware/kundenansichtGuard.js` (Z. 18–21), `test/kundenansichtGuard.test.js` (Z. 106–127 und neue Tests am Ende).
**Consumes:** –. **Produces:** `KUNDENANSICHT_ALLOWED` mit genau sechs Termine-Einträgen, jeweils mit `\/?$`: POST `/api/termine/einstellungen/vorlage`, POST `/api/termine/ressourcen/[^/]+/feed`, POST `/api/termine/buchungen`, POST `/api/termine/buchungen/[^/]+/aktion`, PATCH `/api/termine/einstellungen`, DELETE `/api/termine/buchungen/[^/]+`. Alles andere unter `/api/termine` (auch Block C `PUT /fragebogen`) bleibt in der Kundenansicht gesperrt, ohne dass es einen Eintrag braucht; Block B legt den Checkout unter `/api/funktionen` (dort nie erlaubt).
- [ ] Tests: (1) „Erlaubnisliste“ (Z. 106): `KUNDENANSICHT_ALLOWED.length === 8 + 6`; die bisherigen `ok`-Fälle bleiben erlaubt; neu **nicht** erlaubt: `POST /api/termine/zukunft`, `POST|PUT /api/termine/fragebogen`, `POST /api/termine/export.csv`, `POST /api/termine/buchungen/abc` (bisher durch die Wildcard erlaubt), `POST …/buchungen/abc/aktion/x`, `POST …/einstellungen/vorlage/x`, `POST …/ressourcen/abc/feed/x`, `POST /api/funktionen/termine/checkout`. (2) Neu „unbekanntes POST unter /api/termine ⇒ 403“: über HTTP mit `kv()` ⇒ 403 `view_blocked` für `POST /api/termine/zukunft`, `PUT /api/termine/fragebogen`, `POST /api/termine/buchungen/abc/aktion/x` und `POST /API/Termine/Zukunft` (der Wächter antwortet vor dem Router, auch wenn es die Route nicht gibt); dieselben Pfade mit `customerCookie('c1')` ⇒ nicht 403 (404, der Wächter fasst normale Kunden nicht an). (3) Neu „Router-Abgleich“: `writeRoutes([['/api/termine', termineRouter]])` gefiltert mit `allowed()` ergibt exakt die sechs Routen (`:id` ⇒ `x`); eine künftige Schreib-Route unter `/api/termine` fällt dadurch im Test „jede Schreib-Route ausserhalb der Erlaubnisliste ⇒ 403“ auf, nicht erst in Produktion. (4) Der Test „Kundenansicht darf PATCH, POST und DELETE (mit Header) und es wird protokolliert“ (`termineRoutes.test.js` Z. 1028: Vorlage, Feed, Buchung, Aktion, PATCH, DELETE) bleibt grün.
- [ ] FAIL → implementieren (die vier POST-Muster einzeln ausschreiben, kein `.+`) → PASS → `npm test` → Commit „Kundenansicht: Terminplaner-POSTs nur noch ueber eine explizite Liste“
### Task A2: Abfrage-Bausteine und Nachbau-Erweiterung
**Files:** Modify `server/services/appwriteAdmin.js` (`Query` Z. 77–94, neu `countDocuments`), `test/support/appwriteMock.js` (`matches` Z. 124, `supportedQueries` Z. 145, `list` Z. 147–169, Rückgabeobjekt Z. 388); Test `test/appwriteBulk.test.js` (dort stehen die Query- und Nachbau-Tests, ab Z. 37).
**Consumes:** –. **Produces:** `Query.select(attrs[])` ⇒ `{ method: 'select', values: attrs }`; `Query.notEqual(attr, wert | wert[])` ⇒ `{ method: 'notEqual', attribute, values: […] }` (Liste = „keiner von“); `countDocuments(collectionId, queries) ⇒ Promise<number | null>` (ein Aufruf mit `limit(1)`, liefert `total` der Antwort, `null`, wenn Appwrite keines liefert). Nachbau: `select` liefert nur die genannten Felder plus alle `$…`-Felder; `notEqual` (kein Wert gleich), `total` = Treffer vor `limit`/`cursorAfter` (heute: Länge nach `limit`), `aw.listCalls(collection?)` ⇒ `[{ collection, queries: [{ method, attribute, values }] }]` (nur Listen-GETs, geparst) und `aw.resetListCalls()`. `Query.offset` wird bewusst nicht gebaut (Löschseiten lesen ohne Cursor neu), ebenso `Query.isNull` (Block E filtert `aboEndeMailAt` im Code; sein Verhalten bei Datetime-Attributen auf Appwrite 1.8.1 ist ungemessen). Dass `notEqual` mit Liste und `total` auf der echten Appwrite 1.8.1 dasselbe bedeuten wie im Nachbau, ist ebenfalls ungemessen: Messprüfung in Block G (Livegang 5).
- [ ] Tests (`appwriteBulk.test.js`): `Query`-Helfer liefern die Formen oben; Nachbau: `select(['a'])` ⇒ Dokumente nur mit `a` und `$id`; `notEqual('f', ['x','y'])` schließt beide aus, `notEqual` mit einem Wert; `total` bei `limit(2)` über 5 Treffer ⇒ 5 und `countDocuments` ⇒ 5; `listCalls('termineClaims')` zeichnet Listenabfragen auf (keine POST/GET-by-ID), `resetListCalls` leert; „Nachbau ohne Optionen verhält sich wie bisher“ (Z. 196) bleibt grün.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Appwrite-Abfragen: select, notEqual, countDocuments und Nachbau dazu“
### Task A3a: Aufbewahrungs-Löschung seitenweise – Grundlagen und Schritte `verfallen`/`abgesagt`/`abgelehnt` (Review Focus 4)
**Files:** Modify `server/services/termine/buchungen.js` (`loeschKandidaten` Z. 183–192), `server/services/termine/jobs.js` (`loeschfristen` Z. 288–315, nur die Schritte `verfallen`, `abgesagt`, `abgelehnt`; neu `arbeiteSeiten`; `wendeLoeschfristenAn` Z. 400, `taeglicherJob` Z. 420), `server/config.js` (`loeschDeckel`, Standard 2000), `server/services/termine/schema.js` (termineBookings: `index('idx_status_ends', 'status', 'endsAt')` und `index('idx_status_updated', 'status', 'updatedAt')`; die Spec nennt nur das erste, `abgesagt`/`abgelehnt` laufen über `updatedAt` und brauchen das zweite); Tests `test/termineJobs.test.js` (Z. 672–751 anpassen, neue), `test/termineZellen.test.js` (Z. 933–958), `test/termineSetup.test.js` (Z. 143–155).
**Consumes:** A2 (`Query.select`, `aw.listCalls`). **Produces:** `loeschKandidaten(status, vor, { featureId = null, limit = 100 } = {}) ⇒ Promise<doc[]>` (höchstens `limit`, älteste Bezugszeit zuerst, `Query.select` ohne Gastfelder: `$id, featureId, status, endsAt, updatedAt, heldUntil`; TypeError für nicht löschbare Status wie bisher); `arbeiteSeiten(lauf, { holeSeite, bearbeite, deckel }) ⇒ { geloescht, gedeckelt, vollstaendig }` (für A3b, A4); `wendeLoeschfristenAn(now) ⇒ { verfallen, abgesagt, abgelehnt, erledigt, nichterschienen, gedeckelt }` (`gedeckelt` = Zahl der Schritte, die am Deckel endeten; die Schritte `erledigt`/`nichterschienen` laufen bis A3b unverändert über den alten Weg); `taeglicherJob` liefert zusätzlich `gedeckelt: { loeschfristen, abgeschaltet: 0, zellen: 0 }` (A4/A6 füllen die anderen) und trennt `gedeckelt` vom Löschzähler ab, damit die Summe im `tagesbericht` stimmt.
**Ansatz:** Die Frist steht in der Datenbankabfrage (`lessThan(Bezug, jetzt − Frist)`) und wird nie im Code nachgeholt. Seiten ohne Cursor: Seite lesen, jede Buchung wie bisher unter ihrer Sperre frisch prüfen und samt Zellen löschen (`loescheMitZellen`), Liste neu lesen (Gelöschtes fehlt). `arbeiteSeiten` endet bei leerer Seite, beim Deckel (zählt `lauf.gedeckelt`, Log-Warnung ohne IDs) oder bei einer Seite ohne einen Erfolg (Fehler stehen im Log; bereits versuchte IDs merkt eine Menge, kein Endlosdurchlauf). Durchgang (1): global `verfallen` (48 h), `abgesagt`, `abgelehnt` (30 Tage), Deckel `loeschDeckel` je Status. Die Prüfung am Kandidaten (`endsAt` < Frist) bleibt als zweiter Boden.
- [ ] Tests: (1) `loeschKandidaten` (termineZellen): 150 alte `erledigt` ⇒ Standard 100, `{ limit: 10 }` ⇒ 10, aufsteigend nach `endsAt`, `{ featureId }` filtert, Treffer ohne `gastName`/`gastEmail`, Bezugszeit je Status und TypeError für `bestaetigt`/`gehalten` wie bisher. (2) Seitenlauf: 250 `verfallen`/`abgesagt`/`abgelehnt` gemischt, davon 40 eine Millisekunde zu jung ⇒ ein Lauf löscht genau die 210 fälligen samt Zellen und keine junge; alle Listenabfragen an `termineBookings` haben `limit ≤ 100` und ein `select` ohne Gastfelder (`aw.listCalls`), kein `$id`-Seitenlauf mit 500; der Standard `loeschDeckel` (2000, je Status) liegt über den 210 fälligen, ohne dass der Test ihn setzt. (3) Deckel: `loeschDeckel = 7`, 20 fällige `verfallen` ⇒ Lauf 1 löscht 7 und meldet `gedeckelt: 1` (Warnung im Log ohne ID/Name), Lauf 2 weitere 7, Lauf 3 die letzten 6 mit `gedeckelt: 0`; nach jedem Lauf stimmen Rest und Zellen, an keinem wird eine junge Buchung gelöscht. (4) Fehler stoppt nicht für immer: alle DELETE auf `termineBookings` scheitern (fetch-Wrapper) ⇒ der Lauf endet nach höchstens einer Seite je Schritt (Anfragenzahl begrenzt), Fehler gezählt, die anderen Schritte laufen; nächster Lauf ohne Fehler löscht alles. (5) Angepasste TP1-Tests: Z. 672 erwartet `gedeckelt: 0`; der Race-Test (Z. 742) bleibt. (6) `termineSetup`: Index-Liste von `termineBookings` um `idx_status_ends` (status, endsAt) und `idx_status_updated` (status, updatedAt) ergänzen, `main` legt beide an.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Aufbewahrungs-Loeschung seitenweise (Grundlagen, verfallen/abgesagt/abgelehnt)“
### Task A3b: Aufbewahrungs-Löschung seitenweise – je Funktion, Sicherheitsnetz, Gesamtdeckel, Bericht (Review Focus 4)
**Files:** Modify `server/services/termine/jobs.js` (`loeschfristen` Z. 288–315, die Schritte `erledigt`/`nichterschienen`; `wendeLoeschfristenAn` Z. 400, `taeglicherJob` Z. 420, `tagesbericht` Z. 437), `server/config.js` (`loeschDeckelGesamt`, Standard 20000; `loeschDeckel` Standard 2000 kommt aus A3a); Tests `test/termineJobs.test.js` (Z. 672–751 anpassen, neue).
**Consumes:** A3a (`loeschKandidaten`, `arbeiteSeiten`, `loeschDeckel`, Indizes). **Produces:** `wendeLoeschfristenAn` und `taeglicherJob` behalten die Form aus A3a; `tagesbericht` bekommt das Segment „Löschläufe am Deckel: n, Rest folgt an den nächsten Tagen“ (nur bei `gedeckelt` > 0, direkt nach `Mails nicht zugestellt`).
**Ansatz:** Durchgang (2): je Funktion (`portalFeatures` mit `featureKey termine`, `state` active|disabled, `listAllDocuments` mit `Query.select(['$id'])`) `erledigt`/`nichterschienen` nach `aufbewahrungTage` der Funktion (`aufbewahrungTage()` wie bisher, `null` ⇒ Funktion auslassen), gemeinsamer Deckel `loeschDeckel` je Funktion und `loeschDeckelGesamt` je Lauf. Durchgang (3), Sicherheitsnetz: `erledigt`/`nichterschienen` mit `endsAt < jetzt − 365 Tage` ohne Funktionsfilter (die größte erlaubte Aufbewahrung; fängt Buchungen ohne Funktionsdokument und Funktionen mit unlesbarer Einstellung spätestens nach 365 Tagen). Die Prüfung am Kandidaten (`endsAt` < Frist) bleibt als zweiter Boden.
- [ ] Tests: (1) „viele Betriebe“: 60 Funktionen (`active`, `aufbewahrungTage` 14/90/365 gemischt), je 6 Buchungen (3 fällig, 3 eine Millisekunde zu jung, Status gemischt) ⇒ ein Lauf löscht genau die 180 fälligen samt Zellen und keine junge (die Standards `loeschDeckel` 2000 je Funktion und `loeschDeckelGesamt` 20000 je Lauf liegen darüber, der Test setzt sie nicht); alle Listenabfragen an `termineBookings` haben `limit ≤ 100` (`aw.listCalls`), kein `$id`-Seitenlauf mit 500. (2) Deckel je Funktion: `loeschDeckel = 7`, eine Funktion mit 20 fälligen ⇒ Lauf 1 löscht 7 und meldet `gedeckelt: 1` (Warnung im Log ohne ID/Name), Lauf 2 weitere 7, Lauf 3 die letzten 6 mit `gedeckelt: 0`; A 50 fällige, B 2 fällige, `loeschDeckel = 5` ⇒ A 5, B 2 im ersten Lauf; `loeschDeckelGesamt = 6` ⇒ insgesamt 6, Rest im nächsten Lauf; an keinem Lauf wird eine junge Buchung gelöscht. (3) Sicherheitsnetz: Buchung ohne Funktionsdokument, 366 Tage alt ⇒ gelöscht, 200 Tage alt ⇒ bleibt (dokumentierte Grenze, Waisen gibt es in Produktion nicht); Funktion mit unlesbarer Einstellung (`failNext` termineConfig GET): 100 Tage bleibt (nie früher), 366 Tage weg. (4) Angepasste TP1-Tests: Z. 692 legt für F2 und F4 `portalFeatures`-Dokumente an (F2 ohne Einstellung ⇒ 90, F4 kaputte Einstellung ⇒ 90). (5) `tagesbericht` unverändert ohne Deckel, mit `gedeckelt` > 0 zusätzlich das Segment oben (vollständige Zeile siehe E7).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Aufbewahrungs-Loeschung je Funktion mit Deckel, Sicherheitsnetz und Bericht“
### Task A4: Löschung abgeschalteter Funktionen seitenweise (Review Focus 4)
**Files:** Modify `server/services/termine/jobs.js` (`loescheAbgeschaltete` Z. 347–391, `nochAbgeschaltet` Z. 325, `loescheAbgeschalteteFunktionen` Z. 410), `server/services/termine/buchungen.js` (neu `ersteBuchungen`); Tests `test/termineJobs.test.js` (Z. 753–870).
**Consumes:** A3a (`arbeiteSeiten`, `loeschDeckel`, `gedeckelt` im Tagesjob). **Produces:** `ersteBuchungen(featureId, limit = 100) ⇒ doc[]` (`Query.equal('featureId')`, `limit`, `select(['$id'])`); `loescheAbgeschalteteFunktionen(now) ⇒ { funktionen, buchungen, gedeckelt }`. Block E hängt die frische Abo-Prüfung an `nochAbgeschaltet` und setzt das Funktionsdokument nach dem Löschen der Einstellung auf `off`.
**Ansatz:** Je abgeschaltete Funktion (Frist `disabledAt < jetzt − 30 Tage` bleibt in der Funktionsliste) Seiten zu 100 lesen und löschen, bis die Seite leer ist; vor jeder Seite nach dem Lesen und alle 50 Buchungen `nochAbgeschaltet` (frisch, wie bisher); Deckel `loeschDeckel` je Funktion. Die Einstellung (`termineConfig`) wird nur nach leerer Seite ohne Fehler und mit letzter Prüfung unter `termine-cfg:<id>` gelöscht; sonst bleibt die Funktion `disabled` mit altem `disabledAt` und der nächste Tag macht weiter (`gedeckelt` + Log-Warnung).
- [ ] Tests: (1) Funktion seit 31 Tagen abgeschaltet mit 230 Buchungen (mit Zellen), `loeschDeckel = 100`: Lauf 1 ⇒ `{ funktionen: 1, buchungen: 100, gedeckelt: 1 }`, `termineConfig` und `disabledAt` unverändert; Lauf 2 ⇒ weitere 100; Lauf 3 ⇒ 30 und Einstellung weg, `gedeckelt: 0`; alle Listenabfragen `limit ≤ 100`. (2) Deckel gilt je Funktion: A 150 Buchungen, B 10 ⇒ B im selben Lauf komplett samt Einstellung, A noch nicht. (3) Schaltet das Personal die Funktion nach der ersten Seite wieder ein (`mitEinwurf` auf die zweite Listenabfrage) ⇒ keine weitere Löschung, Einstellung bleibt, Log „inzwischen wieder aktiv“, kein Fehler. (4) Scheitert jedes Löschen (Wrapper) ⇒ Ende nach einer Seite, Einstellung bleibt, Anfragenzahl begrenzt, kein Wurf. (5) Die bestehenden Tests Z. 753–870 bleiben bis auf `gedeckelt: 0` in den `deepEqual`-Pins (`{ funktionen, buchungen, gedeckelt }`; Block E pinnt dieselbe Form) grün (Wiedereinschalten nach 50 Buchungen ⇒ genau 50 gelöscht; Neulesen scheitert ⇒ nichts gelöscht, ein Fehler im Log).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Loeschung abgeschalteter Funktionen seitenweise, Rest an Folgetagen“
### Task A5: CSV-Export seitenweise streamen (Review Focus 4)
**Files:** Modify `server/routes/termine.js` (`GET /export.csv` Z. 450–474, Import Z. 11), `server/services/termine/buchungen.js` (neu `exportSeite`); Tests `test/termineRoutes.test.js` (Z. 927–990).
**Consumes:** A2 (`aw.listCalls`). **Produces:** `exportSeite(featureId, { nach = null, limit = 500 } = {}) ⇒ doc[]` (`Query.equal('featureId')`, `orderAsc('startsAt')`, `limit`, `cursorAfter(nach)` ⇒ `idx_feature_start`).
**Ansatz:** Die erste Seite wird gelesen, **bevor** Header gesendet werden (Fehler ⇒ normales JSON-500). Dann BOM, Kopfzeile und je Seite die Zeilen per `res.write` (Sperren, `quelle sperre`, je Seite herausfiltern; Cursor = letzte Zeile der Rohseite). Ein Fehler nach dem ersten Byte ⇒ `res.destroy(err)`: ein abgebrochener Download statt einer still gekürzten Datei. Wird das Cursor-Dokument zwischen zwei Seiten vom Tagesjob gelöscht, bricht der Export ab und wird neu geladen (selten, dokumentieren). `imZeitraum` bleibt für die übrigen Aufrufer.
- [ ] Tests: (1) 1 200 Buchungen + 40 Sperren + fremde Funktion ⇒ 1 200 Datenzeilen nach Beginn sortiert, Zeilen byte-gleich zum bisherigen Format; genau drei Listenabfragen an `termineBookings` (500/500/200) mit `limit 500` und ohne `$id`-Sortierung. (2) 20 100 Buchungen (über dem alten Deckel von 20 000 in `listAllDocuments`) ⇒ 20 100 Zeilen, kein `list_limit_exceeded`. (3) `failNext` GET 500 bei der ersten Seite ⇒ 500 JSON ohne CSV-Anfang; bei der zweiten (`after: 1`) ⇒ der Antwortkörper bricht ab (`arrayBuffer()` wirft), kein vollständig aussehendes Ende; im Log kein Gastname/keine Adresse. (4) Die Tests Z. 934 (BOM, Kopf, Sortierung, Sperren, fremde Funktionen) und Z. 957 (Hochkomma-Schutz) bleiben unverändert grün; `Content-Disposition`, `no-store`, `nosniff` wie bisher.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: CSV-Export seitenweise gestreamt“
### Task A6: Zellen-Sweep seitenweise über `idx_bis` (Review Focus 4)
**Files:** Modify `server/services/termine/zellen.js` (`ALLE_ZELLEN_MAX` Z. 46, `ungueltigeZellen` Z. 92–99, `verwaisteAus`/`alleZellen`/`verwaisteZellen`/`raeumeVerwaisteZellen` Z. 215–270), `server/config.js` (`zellenDeckel`, Standard 5000), `server/services/termine/jobs.js` (`taeglicherJob`); Tests `test/termineZellen.test.js` (Z. 600–745), `test/termineJobs.test.js` (Tagesjob-Test Z. 867).
**Consumes:** A2 (`Query.select`, `listCalls`), A3a (`gedeckelt` im Tagesjob). **Produces:** `raeumeVerwaisteZellen(now, { deckel = config.termine.zellenDeckel } = {}) ⇒ Promise<{ geloescht, gedeckelt }>`; `verwaisteZellen`, `alleZellen`, `ALLE_ZELLEN_MAX` entfallen (nur in Tests benutzt); `ungueltigeZellen(zellen, nowMs)` prüft zuerst das Zeitfenster: eine Zelle mit `bis < jetzt − 1 Tag` ist ohne Buchungsabfrage ungültig, nur die übrigen werden gegen ihre Buchung geprüft (`raeumeBlockierer` in `belegeZellen` nutzt dieselbe Funktion).
**Ansatz:** Seiten über `idx_bis`: `lessThan('bis', jetzt − 1 Tag)`, `orderAsc('bis')`, `limit(100)`, `select(['$id','featureId','ressourceId','bookingId','bis'])`, ohne Cursor neu lesen. Gruppierung je Ressourcensperre mit `Map.get`/`push` (statt `[...alt, zelle]`, Z. 247, quadratisch); je Gruppe unter der Sperre erneut lesen (`leseNachIds`) und nur löschen, was dort noch dieselbe `bookingId` hat und alt ist; erster Fehler wird am Ende geworfen, eine Seite ohne Erfolg beendet den Lauf. **Bewusste Änderung:** Zellen mit künftigem `bis`, deren Buchung fehlt oder nichts mehr belegt (nur möglich, wenn `gibFreiSicher` scheiterte), findet der Sweep nicht mehr täglich; sie stören nicht (`belegeZellen` räumt Blockierer bei Bedarf, `raeumeBlockierer`) und fallen spätestens einen Tag nach `bis` in den Sweep.
- [ ] Tests: (1) Zellen mit `bis` genau `jetzt − 1 Tag` bleiben, eine Millisekunde älter ⇒ weg; der Sweep fragt `termineBookings` nicht ab (kein GET). (2) 260 alte Zellen auf zwei Ressourcen (Test Z. 687 angepasst; der Standard `zellenDeckel` 5000 liegt darüber) ⇒ `{ geloescht: 260, gedeckelt: false }`, alle Listenabfragen an `termineClaims` mit `limit ≤ 100`. (3) Deckel: 600 alte Zellen, `zellenDeckel = 250` ⇒ Lauf 1 `{ 250, true }`, Lauf 2 `{ 250, true }`, Lauf 3 `{ 100, false }`; `taeglicherJob` meldet `gedeckelt.zellen` und `zellen` als Zahl. (4) 3 000 alte Zellen einer einzigen Ressource (ein Sperrschlüssel, Deckel 5 000) ⇒ alle gelöscht (Regression des quadratischen Aufbaus). (5) Race (Tests Z. 697/711 angepasst auf alte Zellen): wird eine gelistete Zelle vor der Sperre durch eine andere `bookingId` und künftiges `bis` ersetzt (`mitEinwurf`), bleibt sie. (6) Waise mit künftigem `bis` (Buchung `abgesagt`) bleibt im Sweep stehen, blockiert aber keine Neubuchung (`belegeZellen` räumt sie, bestehender Test Z. 361/406 grün). (7) Ein DELETE-Fehler stoppt die übrigen Blöcke nicht und wird am Ende geworfen (Z. 736); danach kein Endlosdurchlauf. (8) **Angepasste/entfallende TP1-Tests (einzeln):** die beiden `verwaisteZellen`-Tests Z. 600 (Buchung fehlt, nicht mehr belegend, Zelle länger als einen Tag vorbei) und Z. 636 (liest alle Zellen, prüft in Blöcken) werden **gelöscht**, weil die Funktion entfällt; ihre Fälle gehen in (1), (2) und (6) auf. Der Test Z. 670 („löscht genau die verwaisten Zellen und zählt sie“) liest `.geloescht` statt der Zahl (Z. 681–683: `equal(…, 4)` und `equal(…, 0)`, dazu müssen die Zellen `bis` älter als einen Tag haben; die künftigen `c_ok`/`c_abgesagt`/`c_fehlt` bleiben bewusst liegen, vgl. (6)); Z. 687 (260 Zellen) laut (2); Z. 697 und Z. 711 laut (5); Z. 736 laut (7), dort `equal(…, 1)` (Z. 742) ebenfalls auf `.geloescht`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Zellen-Sweep seitenweise ueber idx_bis mit Deckel“
### Task A7: Mail-Fairness 1 – Kontingent je Funktion, Vorrang-Reserve, Haltungsgrenze
**Files:** Modify `server/services/termine/outbox.js` (Budgetzustand Z. 50–79, `sende` Z. 161–204), `server/config.js` (`mailsProFunktion`, Standard 40; `mailsReserve`, Standard 0.25), `server/services/termine/limits.js` (`HALTUNGEN_JE_SITE` Z. 44: 60 → 25); Tests `test/termineOutbox.test.js` (Z. 247–297, 466–493), `test/termineLimits.test.js` (Z. 139–155, 176–193), `test/publicTermine.test.js` (Z. 614–628).
**Consumes:** –. **Produces:** `MAIL_VORRANG = ['G01','G02','G08']`, `MAIL_NACHRANGIG = ['G05','K20','K21']`, `mailKlasse(art) ⇒ 'vorrang' | 'normal' | 'nachrangig'`; `pruefePlatz(nowMs, featureId, art) ⇒ null | 'pause' | 'budget' | 'reserve' | 'kontingent'`; `mailBudget(now) ⇒ { stunde: { benutzt, max }, nachrangig: { benutzt, max }, jeFunktionMax, spitzen: [{ featureId, benutzt }] (höchstens 3) }` (für A10a2 und A10b; nur Zahlen und Funktions-IDs, keine Adressen).
**Ansatz:** Das Stundenfenster hält Einträge `{ t, f, n }` (Zeit, Funktion, nachrangig) statt Zahlen; `gibPlatzFrei` entfernt per Objekt. Regeln in dieser Reihenfolge: Pause; Gesamtzahl im Fenster ≥ `mailsProStunde` ⇒ `budget`; nachrangige im Fenster ≥ `max(1, floor(mailsProStunde × (1 − mailsReserve)))` ⇒ `reserve` (so bleibt ein Viertel der Stunde Vorrang- und Normal-Mails vorbehalten); Mails der Funktion im Fenster ≥ `mailsProFunktion` ⇒ `kontingent`. `sende` liest den Eintrag (kennt dann `featureId` und `art`), belegt den Platz erst danach und lässt einen gebremsten Eintrag unverändert `wartet` (kein Versuch, `versuche` bleibt); der Sofortversand aus `einreihen` unterliegt denselben Regeln. Mit 25 Haltungen je Stunde und siteKey löst eine Funktion höchstens 25 G01 aus, unter dem Kontingent 40.
- [ ] Tests (`config.termine.mailsProFunktion`, `mailsReserve` je Test setzen, `beforeEach` stellt 200/40/0.25 zurück): (1) Kontingent: `mailsProFunktion = 3`, Funktion A reiht 5 G02 ein ⇒ 3 gesendet, 2 `wartet` (`versuche 0`, Inhalt erhalten); Funktion B reiht 2 ein ⇒ beide gehen raus; `verarbeite(+61 min)` ⇒ A's 2 gehen raus. (2) Reserve: `mailsProStunde = 8` (nachrangig höchstens 6): 10 G05 ⇒ 6 gesendet, danach ein G01 ⇒ geht raus (Plätze 7 und 8 gehören dem Vorrang), das siebte G05 bleibt `wartet`, obwohl noch ein Platz frei ist; `mailKlasse` für alle zehn Arten (G03/G04/G06/G07 ⇒ `normal`). (3) Gleitendes Fenster je Funktion: Plätze laufen 60 Minuten nach dem Versand ab (59 Min nein, 60 Min ja); 4xx-Fehlversuche und Anmeldepause belegen weder Gesamt- noch Funktionsplatz (Tests Z. 281/386 grün). (4) Gleichzeitig: `Promise.all` über 6 Einträge einer Funktion mit Kontingent 3 ⇒ genau 3 Transportaufrufe. (5) `mailBudget`: nach 5 Sendungen aus zwei Funktionen stimmen `benutzt`, `max`, `spitzen` (sortiert, höchstens 3), kein `@` im Ergebnis. (6) TP1-Pins: Z. 466 (`Stundenbudget gilt auch bei gleichzeitigen Versuchen`) nimmt `art: 'G02'`; Z. 247–263 bleibt außer der `faellig`-Zeile (A9a). (7) Limits: `darfHaltung` 25 je siteKey und Stunde (Test Z. 139: Titel, 65 Aufrufe ⇒ 25 gezählte; Test Z. 176: Schleife `i < 60` ⇒ 25), `publicTermine.test.js` Z. 614: Titel und Schleifen 60/59 ⇒ 25/24, die 25. Haltung geht noch, die 26. ⇒ 429.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Mailkontingent je Funktion, Vorrang-Reserve und kleinere Haltungsgrenze“
### Task A8: Empfänger-Kappe über Betriebe (G01, Freitext in G04/G06)
**Files:** Modify `server/services/termine/limits.js` (`darfG01` Z. 196, neu `darfEmpfaenger`), `server/services/termine/mails.js` (`anGast` Z. 472–494); `server/routes/publicTermine.js` Z. 535 bleibt unverändert (ruft `darfG01`); Tests `test/termineLimits.test.js` (Z. 162–174 neu geschrieben, Z. 176–193), `test/publicTermine.test.js` (Z. 630–647), `test/termineAblaufBetrieb.test.js` (G04 Z. 281, G06 Z. 323).
**Consumes:** –. **Produces:** `darfG01(featureId, adresse, now)` zählt zusätzlich über alle Funktionen (`zaehleAlle`, alles oder nichts): `empfaenger-h:<adresse>` 3 je Stunde und `empfaenger-d:<adresse>` 10 je Tag, neben dem bisherigen `g01:<funktion>:<adresse>` 5 je Tag; `darfEmpfaenger(adresse, now) ⇒ boolean` (dieselben zwei Zähler, ohne Funktionsregel). Die Adresse ist die normalisierte (klein, getrimmt).
**Ansatz:** Das Gast-Formular bleibt ein stiller Erfolg wie heute (keine Buchung, keine Mail, gleiche Antwort), weil `darfG01` vor `halte` prüft (kein verwaister Halte-Eintrag). Für G04 (`extra.nachricht`) und G06 vom Betrieb (`extra.grund`, `extra.nachricht`) mit Freitext prüft `anGast` `darfEmpfaenger(an)`: ist die Kappe erreicht, geht die Mail **ohne den Freitext** raus (der Hinweis auf Ablehnung/Absage bleibt, der Missbrauchswert fällt weg), Log-Warnung ohne Adresse; ohne Freitext zählt nichts, G05/K20/K21 sind nicht betroffen.
- [ ] Tests: (1) `darfG01` (Test Z. 162 wird neu geschrieben): dieselbe Adresse bei `f1` ⇒ `[true, true, true, false, false, false, false]`; `f2` mit derselben Adresse ⇒ `false` (Kappe über Betriebe, bisher `true`), andere Adresse ⇒ `true`; nach 61 Minuten wieder drei; Tageskappe: 3 + 3 + 3 + 1 über vier Stunden verteilt ja, die 11. nein; alles oder nichts (ein abgelehnter Aufruf erhöht keinen Zähler, auch nicht den der Funktion); `darfG08` zählt weiter getrennt. Test Z. 176 (Speicher): drei statt fünf Durchläufe, der vierte `false`. (2) `darfEmpfaenger` teilt die Zähler mit `darfG01` (zwei `darfEmpfaenger` + zwei `darfG01` ⇒ der vierte Aufruf `false`). (3) `publicTermine` Test Z. 630 wird „je Adresse 3 je Stunde“: sechs Anfragen derselben Adresse ⇒ alle 201 mit gleicher Antwort, drei Mails (statt 5), drei Buchungen, die 4. bis 6. ohne Haltung und ohne Mail; andere Adresse und „Fehler der Zeit kommen vor der Mailgrenze“ (409) wie bisher. (4) `anGast`: G04 mit Nachricht an eine Adresse über der Kappe ⇒ Mail geht raus und enthält den Text nicht, darunter mit Text; G06 `von: 'betrieb'` mit `grund` ebenso, G06 `von: 'gast'` zählt nicht; das Log enthält keine Adresse.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Empfaenger-Kappe ueber Betriebe (G01, Freitext in G04/G06)“
### Task A9a: Mail-Fairness 2a – Frühausstieg, begrenzte und nach Klasse geordnete Warteschlange
**Files:** Modify `server/services/termine/outbox.js` (`verarbeite` Z. 260–275, `sende` Z. 161–204: nur die Platzprüfung), `server/config.js` (`mailListe`, Standard 100); Tests `test/termineOutbox.test.js` (Z. 218–263, neue).
**Consumes:** A2 (`select`, `notEqual`, `listCalls`), A7 (`pruefePlatz`, `mailKlasse`, `MAIL_*`). **Produces:** `verarbeite(now) ⇒ { faellig, gesendet }` (Form unverändert; `faellig` = gelesene Einträge).
**Ansatz:** (1) Frühausstieg ohne Abfrage bei Pause oder leerem Gesamtbudget. (2) Je Klasse `vorrang` ⇒ `normal` ⇒ `nachrangig` (entfällt bei `reserve`) eine Abfrage: `status wartet`, `naechsterVersuch ≤ jetzt`, `art` in/nicht in der Klasse (`equal`/`notEqual`), `featureId notEqual blockierte` (nur wenn nicht leer, höchstens 90 Werte), `orderAsc('naechsterVersuch')`, `limit(mailListe)`, `select(['$id','featureId','art','naechsterVersuch'])` (keine Bodies). (3) Vor jedem Versand `pruefePlatz`: `budget`/`pause` ⇒ Ende; `kontingent` ⇒ Funktion in `blockiert`, Eintrag überspringen; ist die Seite voll und wurde etwas gesendet oder blockiert, höchstens 4 weitere Runden mit der neuen Ausschlussliste (keine Funktion verhungert hinter einer vollen).
- [ ] Tests: (1) Frühausstieg: Budget voll (`mailsProStunde = 2`, zwei gesendet) ⇒ `verarbeite` liefert `{ faellig: 0, gesendet: 0 }` ohne eine Listenabfrage an `termineOutbox` (`listCalls`); ebenso während der Anmeldepause; der TP1-Test Z. 247–263 erwartet in der Zeile `verarbeite(+59 min)` jetzt `{ faellig: 0, gesendet: 0 }`, `+61 min` weiter `{ faellig: 1, gesendet: 1 }`. (2) Begrenzt: 250 fällige G02 über 3 Funktionen ⇒ jede Abfrage `limit ≤ 100`, ohne `$id`-Seitenlauf, mit `select` (kein `html` im Abfragetext); gesendet wird höchstens bis zum Kontingent. (3) Reihenfolge: ein älteres G05, ein G03 und ein jüngeres G01 fällig, Budget 1 ⇒ G01; Budget 2 ⇒ G01 und G03; G05 zuletzt; innerhalb einer Klasse nach `naechsterVersuch` (TP1-Test Z. 218 erwartet „fälligste zuerst“ statt `createdAt`). (4) Kein Verhungern: Funktion A mit 150 fälligen G02 (Kontingent 40) vor einem einzelnen G02 von Funktion B ⇒ ein Lauf sendet 40 von A und die Mail von B, höchstens 5 Abfragen je Klasse. (5) `reserve` erreicht ⇒ keine nachrangige Abfrage mehr.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Mail-Warteschlange begrenzt und nach Vorrang geordnet“
### Task A9b: Mail-Fairness 2b – veraltete G01 verwerfen, Index `bookingId`
**Files:** Modify `server/services/termine/outbox.js` (`sende` Z. 161–204: G01-Prüfung, `raeumeAuf` Z. 304), `server/services/termine/schema.js` (termineOutbox: `index('idx_booking', 'bookingId')`; `zustellFehler` und `verwerfeWartende` fragen nach `bookingId`); Tests `test/termineOutbox.test.js` (neue), `test/termineSetup.test.js` (Z. 157).
**Consumes:** A9a. **Produces:** neuer Status `verworfen` (9 Zeichen, passt in `status`, kein Schema-Eingriff); `sendeEintrag` kann zusätzlich `'verworfen'` liefern; `verworfeneG01() ⇒ number` (Zähler seit Prozessstart, für A10b).
**Ansatz:** Veraltetes G01: in `sende` nach dem Lesen und nach dem Check auf `unverbucht`, vor dem Budget: `art 'G01'`, `bookingId !== '-'` (G01 `zu_viele` hat keinen Link), `nowMs − createdAt > (config.termine.holdMinutes + 1) · 60 s` ⇒ Status `verworfen`, `an/html/text/ics` geleert, `fehler 'Verworfen: Bestätigungslink abgelaufen'`, `naechsterVersuch = jetzt + 48 h`; `raeumeAuf` löscht `gesendet`, `fehler` und `verworfen`; `zustellFehler` zählt `verworfen` nicht (der Betrieb sieht keinen Zustellfehler für einen verfallenen Halte-Eintrag).
- [ ] Tests: (1) Veraltetes G01: `createdAt` = jetzt − 31 min − 1 s ⇒ `verworfen` ohne Transportaufruf, Felder geleert, `zustellFehler([id])` leer, `raeumeAuf(+48 h)` löscht ihn; genau 31 min ⇒ wird gesendet; G01 mit `bookingId '-'` (`zu_viele`) und ein gleich altes G02 werden gesendet; kein Budgetplatz belegt; `holdMinutes` wird bei jedem Aufruf gelesen. (2) Ein bereits gesendeter, nicht verbuchter Eintrag (`unverbucht`) wird nie verworfen und nie doppelt gesendet. (3) Schema: `indexes('termineOutbox')` = `[['idx_status_next','status','naechsterVersuch'], ['idx_booking','bookingId']]`, `main` legt ihn an.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: veraltete G01 verworfen, Index bookingId“
### Task A10a1: Sichtbarkeit 1a – Team-Mail-Vorlage T20
**Files:** Create `server/mail-templates/T20 Terminplaner Betriebsmeldung.html` (Layout von `T14 Loeschfristen.html`, Betreff `[Terminplaner] {{vorfall}}`, Platzhalter `vorfall`, `details`, `zeit`, ohne Blöcke); Modify `test/mailTemplates.test.js` (Z. 8 Kopfkommentar, Z. 11 Liste `[...ids('G', 8), ...ids('K', 21), ...ids('T', 18), 'T20']`, Z. 28 Präfix-Regel um `Terminplaner`). T19 „Erstattung fällig“ gehört **Block B (B9b)**; dort wird die Liste zu `[...ids('G', 8), ...ids('K', 21), ...ids('T', 19), 'T20']`, in E3 zu `ids('K', 23)`; `'T20'` bleibt stehen.
**Consumes:** –. **Produces:** die Vorlage T20 (Versand ab A10a2 über `sendAdminNotice`).
- [ ] Tests (`mailTemplates.test.js`): T20 rendert ohne Reste mit und ohne Blöcke, WEBklar-Logo vorhanden, Kopfkommentar passt zum `<title>`, Betreff beginnt mit `[Terminplaner]`, die Platzhalter `vorfall`, `details`, `zeit` sind deklariert und verwendet; Liste und Präfix-Regel wie oben.
- [ ] FAIL → Vorlage schreiben → PASS → `npm test` → Commit „Terminplaner: Team-Mail-Vorlage T20 Betriebsmeldung“
### Task A10a2: Sichtbarkeit 1b – Betriebsprüfung mit Abkühlzeit (`pruefeBetrieb`)
**Files:** Create `server/services/termine/betrieb.js`, `test/termineBetrieb.test.js`; Modify `server/services/termine/outbox.js` (neu `warteschlangeAlter`), `server/services/termine/jobs.js` (Betriebsprüfung am Ende von `minutenJob`).
**Consumes:** A2 (`countDocuments`), A7 (`mailBudget`), A10a1 (T20), `pausiertBis()` (bisher ungenutzt). **Produces:** `pruefeBetrieb(now) ⇒ Promise<string[]>` (aktive Vorfälle, wirft nie); der gemeinsame Melder `meldeVorfall(schluessel, text, now)` (Warnlog stündlich, Team-Mail mit Abkühlzeit je Schlüssel; A10a3 meldet über ihn die Vorfälle `tagesjob`, `deckel`, `betriebe`); `resetBetriebForTests()`.
**Ansatz:** Vorfälle dieses Tasks: `budget` (≥ 70 % von `mailsProStunde` im Fenster), `queue` (ältester fälliger `wartet`-Eintrag > 10 Min überfällig; `warteschlangeAlter` = `aeltesteMin` aus `status wartet`, `naechsterVersuch ≤ jetzt`, `orderAsc`, `limit(1)`), `pause` (`pausiertBis() > now`). Je Vorfall: Warnlog höchstens stündlich (`[termine-betrieb] …`, nur Zahlen), Team-Mail T20 über `sendAdminNotice` beim Beginn und danach frühestens nach 6 Stunden (Abkühlzeit je Schlüssel, auch wenn der Vorfall zwischendurch endete). Lesefehler ⇒ Werte `null` und ein Log, nie ein 500; Modus `off` ⇒ `minutenJob` ruft nichts auf. Fällt der SMTP aus, scheitert auch die Team-Mail; das Log bleibt der Rückfall.
- [ ] Tests (`termineBetrieb.test.js`, feste Uhr, `mailSink`, `config.chatNotify.emails` gesetzt): (1) Budget: 139 von 200 ⇒ kein Vorfall, 140 ⇒ Vorfall; Warnlog mit „Mailbudget 140/200“ genau einmal, 30 Min später kein zweites, 61 Min später wieder; T20 genau einmal, Betreff beginnt mit `[Terminplaner]`, weder Adresse noch Name im Text. (2) Queue: Eintrag 10 Min überfällig ⇒ kein Vorfall, 11 Min ⇒ Vorfall; ein Eintrag mit `naechsterVersuch` in der Zukunft (Wiederholung) zählt nicht. (3) Pause: EAUTH ⇒ Vorfall `pause` mit Endzeit im Log; endet nach Ablauf; zweite Pause innerhalb 6 Stunden ⇒ Log ja, Mail nein, nach 6 Stunden Mail ja. (4) `pruefeBetrieb` wirft nie: Appwrite 500 ⇒ ein Log, keine Mail-Schleife; Team-Mail scheitert ⇒ Log, Vorfall bleibt gemerkt. (5) `minutenJob` im Modus `off` ruft `pruefeBetrieb` nicht auf (0 Anfragen).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Betriebspruefung mit Abkuehlzeit fuer die Team-Mail“
### Task A10a3: Sichtbarkeit 1c – Tagesjob-Meldung, Tagesjob-Felder und Bericht
**Files:** Modify `server/services/termine/betrieb.js` (neu `meldeTagesjob`), `server/services/termine/jobs.js` (`meldeTagesjob` am Ende von `taeglicherJob` mit `aktiveBetriebe` und `geloescht` im Ergebnis, `tagesbericht`), `test/termineBetrieb.test.js`, `test/termineJobs.test.js` (Z. 867, gepinnte `tagesbericht`-Zeile).
**Consumes:** A10a2 (`betrieb.js`, `meldeVorfall`), A2 (`countDocuments`), A3a/A3b/A4/A6 (`gedeckelt`). **Produces:** `meldeTagesjob(ergebnis, now)` (liest `ergebnis.fehler`, `ergebnis.gedeckelt` = `{ loeschfristen, abgeschaltet, zellen }`, `ergebnis.geloescht` = Summe aller Löschzähler und `ergebnis.aktiveBetriebe` = Zahl oder `null`; merkt `letzterTagesjob = { at, fehler, gedeckelt, geloescht }` (nur Zählungen); prüft Fehler, Deckel, Betriebe; wirft nie); `taeglicherJob` liefert dafür zusätzlich `geloescht` und `aktiveBetriebe` (Zahl); der eingeschränkte Lauf aus E7 liefert dieselben Felder.
**Ansatz:** Vorfälle über den Melder aus A10a2: `tagesjob` (`fehler > 0` oder Abbruch), `deckel` (Löschdeckel an 3 Tagesläufen in Folge), `betriebe` (≥ 20 aktive Betriebe, D8); `aktiveBetriebe` = `countDocuments` der aktiven Termine-Dokumente.
- [ ] Tests (`termineBetrieb.test.js` und `termineJobs.test.js`, feste Uhr, `mailSink`, `config.chatNotify.emails` gesetzt): (1) `meldeTagesjob({ fehler: 2, … })` ⇒ Vorfall `tagesjob`; Deckel an 2 Tagen ⇒ nein, am 3. ⇒ `deckel`; `aktiveBetriebe` 19 ⇒ nein, 20 ⇒ `betriebe`; `letzterTagesjob` trägt `{ at, fehler, gedeckelt, geloescht }` als Zahlen; `meldeTagesjob` wirft nie (Appwrite 500, Team-Mail scheitert). (2) Die exakt gepinnte `tagesbericht`-Zeile (`termineJobs.test.js` Z. 867) bekommt am Ende „, n aktive Betriebe“; das Ergebnis von `taeglicherJob` trägt `aktiveBetriebe` (Zahl) und `geloescht`, die Zeile bleibt frei von IDs, Adressen und Namen.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Tagesjob-Meldung, Betriebe-Zaehlung und Bericht“
### Task A10b: Sichtbarkeit 2 – Status-Route
**Files:** Create `server/routes/team/termine.js`, `test/termineStatus.test.js`; Modify `server/services/termine/betrieb.js` (neu `statusDaten`), `server/routes/team/index.js` (`router.use('/termine', termineTeamRoutes)` hinter `requireStaffJwt`).
**Consumes:** A10a2 (`betrieb.js`, `warteschlangeAlter`), A10a3 (`letzterTagesjob`, `aktiveBetriebe`), A7 (`mailBudget`), A9b (`verworfeneG01`), `pausiertBis()`. **Produces:** `statusDaten(now) ⇒ Promise<{ budget, queue: { wartet, aeltesteMin }, pause: { aktiv, bis }, letzterTagesjob, aktiveBetriebe }>` (`budget` = `mailBudget(now)` plus `verworfeneG01`; `letzterTagesjob` = `null` oder `{ at, fehler, gedeckelt, geloescht }`, nur Zählungen, nach einem Neustart `null` bis zum Tagesjob; `aktiveBetriebe` = `countDocuments` der aktiven Termine-Dokumente); `GET /api/team/termine/status` liefert genau das, `Cache-Control: no-store`. Lesefehler ⇒ Werte `null` und ein Log, nie ein 500.
- [ ] Tests (`termineStatus.test.js`, Team-Eingang wie `teamAdminRoutes.test.js`): ohne JWT 401, `jwt-kunde` 403, `jwt-staff` 200; Form wie oben; `letzterTagesjob` vorher `null`, nach `taeglicherJob` gefüllt; `aktiveBetriebe` 2 bei zwei aktiven und einem abgeschalteten Dokument; `failNext` auf `termineOutbox` ⇒ 200 mit `queue` aus `null`-Werten; rekursiv keine Adresse und kein Gastname im Body; ein Kunden-Cookie erreicht die Route nicht.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Status-Route fuer das Team“
### Task A11: `disable()` idempotent, `disabledReason`, Schema-Skript `feature-setup.mjs`
**Files:** Modify `server/services/siteFeatures.js` (`activate` Z. 293–302, `disable` Z. 304–313, `disableCustomerFeatures` Z. 321–337), `server/services/customerAdmin.js` (Z. 289: `reason: 'kunde_geloescht'`), `test/support/schema.js` (`schemaKeys('portalFeatures')` hängt die Schlüssel aus `PORTAL_FEATURE_ATTRIBUTES` an, Import aus dem neuen Skript); Create `scripts/feature-setup.mjs`, `test/featureSetup.test.js`; Tests `test/siteFeatures.test.js` (Z. 216–265), `test/funktionenTermine.test.js` (Z. 74, 172–195). `scripts/setup-abo-collections.mjs`, `retention.js` und die WOMS-Route bleiben unverändert (Vorgabe-Grund `team`).
**Consumes:** –. **Produces (verbindlich laut Kopf):** `DISABLED_REASONS = ['team', 'abo_ende', 'abo_ende_vor_einrichtung', 'kunde_geloescht']`; `disable(docId, { by, adminNote, reason = 'team' })` unter `withLocks(['feature-disable:' + docId])`: Dokument lesen (404 wie bisher), steht es schon auf `disabled`, bleibt **alles** unverändert (`disabledAt`, `disabledReason`, `adminNote`, kein Schreibzugriff) und das Dokument wird zurückgegeben; **einzige Ausnahme Altbestand:** fehlt einem `disabled`-Dokument `disabledAt`, wird nur dieses Feld auf jetzt gesetzt (sonst fiele es nie in die 30-Tage-Löschung), Grund und Notiz bleiben; sonst `state disabled`, `disabledAt` jetzt, `disabledReason`, Notiz wie bisher; ein unbekannter Grund ⇒ 400 ohne Schreibzugriff. Die Analyse-Nacharbeit (`stopAnalyseCollecting`) läuft für `analyse`-Dokumente unverändert bei jedem Aufruf. `activate()` setzt zusätzlich `disabledReason: null` und `aboEndeMailAt: null` (`zahlungMeldung` fasst `activate()` bewusst **nicht** an: ein noch offener Zahlungsfall darf durch eine Aktivierung nicht verloren gehen; nur E8 setzt es beim Zurücksetzen auf `''`). `disableCustomerFeatures(customerId, key, { by, adminNote, reason })` reicht den Grund durch. `portalFeatures.consentText` bleibt unberührt. **Schema:** `scripts/feature-setup.mjs` (Muster `scripts/analyse-setup.mjs`, importiert dessen `createAppwriteClient`; Hauptlauf nur bei direktem Aufruf) exportiert `PORTAL_FEATURE_ATTRIBUTES = [{ type: 'string', key: 'disabledReason', size: 32 }, { type: 'datetime', key: 'aboEndeMailAt' }, { type: 'string', key: 'zahlungMeldung', size: 24 }]` (alle optional; `zahlungMeldung` ist der Marker aus B10a/B10b: `''` | `'bereits_aktiv'` | `'abgeschaltet'` | `'abo_ende'` | `'gemeldet'`) und `main({ check = false, client, log = console.log } = {})`: legt Fehlendes idempotent an, `--check` liest nur und meldet je Lücke `- portalFeatures.<key> FEHLT` (Exit 1 bei Lücken). Block B (B3) ergänzt `featureConsents` im selben Skript.
- [ ] Tests: (1) `disable` ohne Grund ⇒ `disabledReason 'team'`; mit `reason: 'abo_ende'` gespeichert; mit `'frist'` ⇒ 400 und nichts geschrieben. (2) Idempotenz: zweites `disable` (anderer Grund, andere Notiz) lässt `disabledAt`, `disabledReason`, `adminNote` und `$updatedAt` unverändert und liefert das Dokument; `Promise.all([disable, disable])` ⇒ ein Schreibzugriff, gleiches `disabledAt`; Altbestand `disabled` ohne `disabledAt` ⇒ `disabledAt` wird jetzt gesetzt, Grund und Notiz bleiben (zweiter Aufruf schreibt nichts mehr). (3) Die 30-Tage-Löschung wird nicht verschoben: Dokument mit `disabledAt` vor 31 Tagen, erneutes `disable` (Doppelklick im WOMS, Kundenlöschung) ⇒ `loescheAbgeschalteteFunktionen` löscht weiterhin. (4) `activate` leert `disabledAt`, `disabledReason` und `aboEndeMailAt` (Dokument vorher mit allen dreien gefüllt) und lässt `zahlungMeldung` unverändert. (5) `disableCustomerFeatures` mit `reason` ⇒ alle Dokumente tragen ihn, ein schon abgeschaltetes behält Grund und `disabledAt`; Kundenlöschung (Test Z. 172) ⇒ `kunde_geloescht`, `schonAus` behält `team`. (6) Analyse: `disable` einer `analyse`-Funktion ruft `analyseDisabled` wie vorher (auch beim zweiten Aufruf), kein Verhaltensunterschied. (7) `featureSetup.test.js` (Mock-Client wie `analyseSetup.test.js`): `PORTAL_FEATURE_ATTRIBUTES` mit Typ und Größe (`disabledReason` ≥ 24, längster Wert `abo_ende_vor_einrichtung`; `aboEndeMailAt` datetime; alle optional); `main` legt nur Fehlendes an und überspringt Vorhandenes; `main({ check: true })` legt nichts an und meldet; `schemaKeys('portalFeatures')` enthält die drei Schlüssel (der Analyse-Aufbau `[...schemaKeys('portalFeatures'), ...ANALYSE_KEYS]` bleibt fehlerfrei).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: disable() idempotent, disabledReason, Schema-Skript feature-setup“
### Task A12: `minutenJob` in Verfall und Mailversand getrennt schützen
**Files:** Modify `server/services/termine/jobs.js` (`fuehreAus` Z. 91–108, `minutenJob` Z. 151–157); Tests `test/termineJobs.test.js` (Z. 300–361, 963–1005). `server/index.js` bleibt unverändert (der Pin-Test Z. 1007 prüft `minutenJob` in `index.js`).
**Consumes:** A10a2 (die Betriebsprüfung wandert in den Mail-Teil). **Produces:** `fuehreAus(job, now, arbeit, { teil = null } = {})` führt die Überlappungssperre je `job:teil` (Block E7 erweitert dasselbe Optionsobjekt um `trotzAus`; die Signatur ist `{ teil = null, trotzAus = null } = {}`); `minutenJob(now)` startet beide Teile mit `Promise.all` und liefert wie bisher `{ verfallen, faellig, gesendet, fehler }` (Summe der Fehler), `{ aus: true }` im Modus `off`, `{ uebersprungen: true }`, wenn **beide** Teile übersprungen werden; ist nur der Verfall übersprungen, kommt zusätzlich `verfallUebersprungen: true` (analog `mailUebersprungen`). Die Warnung bei Überlappung nennt den Teil (`minutenJob (Verfall)`), das Fehlerlog behält `minutenJob` als Jobnamen.
- [ ] Tests: (1) Hängt der Verfall (erste Appwrite-Anfrage blockiert), versendet der nächste Takt trotzdem die Warteschlange (`gesendet: 1`, `verfallUebersprungen: true`), die Überlappung steht einmal im Log; (2) hängt der Mailversand, verfällt im nächsten Takt eine abgelaufene Haltung trotzdem; (3) hängen beide ⇒ `{ uebersprungen: true }` wie im TP1-Test Z. 963 (Pin wird um die Teilnamen im Log ergänzt); (4) scheitern beide Teile (Z. 991) ⇒ `fehler: 2`, die Sperren sind danach frei; (5) Fehler im Verfall stoppt den Mailversand nicht (Z. 347 grün); (6) Modus `off` ⇒ `{ aus: true }` ohne Abfrage (Z. 277 grün); (7) der Pin `server/index.js: die drei Jobs …` (Z. 1007) bleibt unverändert grün.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Minutenjob in Verfall und Mailversand getrennt geschuetzt“
**Abschlussprüfung Block A (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. Schema-Check vor jedem Deploy: `node scripts/termine-setup.mjs --check` und `node scripts/feature-setup.mjs --check` (Livegang Schritt 3).
---
## Block B: Register, Checkout, Zustimmungsnachweis
Block B macht aus dem festverdrahteten News-Bestellweg ein Register `config.features` (Checkout, Mails, Ticket, Freischaltung je Funktion), legt den Stripe-Preis `termineSetup` an, baut `POST /api/funktionen/:key/checkout` mit der Prüfreihenfolge aus Spec 4.3 samt unveränderlichem Zustimmungsnachweis (`featureConsents`) und fängt jede Zahlung ab, die in einem anderen Zustand eintrifft (Review Focus #1 und #5). Reihenfolge im Gesamtplan A → B → E → C → D → F: Was Block C (`bestellpruefung`), E (`aboStatus`) und D (Ticket-Kurzfassung) liefern, steht hier als **Stub mit endgültiger Signatur** (Task B5, B8), den die genannten Tasks ersetzen. Alle Zeilenangaben beziehen sich auf Portal `3a0b008` plus Block A.
**Im Code geprüft, Testkonventionen für B und die Falle `seedFixtures`:** stehen im Kopf, Abschnitt „Grundlagen der Blöcke“ (jeder Task bekommt nur Kopf und eigenen Text).
Der Register-Eintrag (Tabelle der verbindlichen Form) steht in Task B1.
---
### Task B1: Register `config.features` und `featureRegistry.js`
**Files:** Modify `server/config.js` (neuer Block `features` vor `export const config`, `funktionen` Z. 193–197 wird Alias mit Gettern auf `features.news`; neu `config.ustHinweis = 'ohne Umsatzsteuer gemäß § 19 UStG'`, von Register und `mails.preisHinweis` genutzt), `server/services/siteFeatures.js` (`FEATURE_LABELS` Z. 11–14 und `DISABLED_MESSAGE` Z. 17–18 werden aus dem Register abgeleitet, Exporte und Werte bleiben). Create `server/services/featureRegistry.js`, `test/featureRegistry.test.js`.
**Consumes:** – (Block A fasst diese Stellen nicht an).
**Produces:** `config.features` (Tabelle unten in diesem Task); `featureConfig(key) ⇒ Eintrag | null` (nur eigene Schlüssel des Registers, `Object.hasOwn`; `'analyse'`, `'__proto__'`, `'constructor'`, `undefined` ⇒ `null`); `featureKeys() ⇒ ['news','termine']`; `featureHaken(key) ⇒ { verfuegbar?, bestellpruefung?, unterlagenBereit?, belehrung?, freischaltbar? }` (B1 liefert die leere Hülle samt Überschreib-Regel, B6, B7 und B11 füllen die Standards; Standard-Haken laden Dienste per dynamischem `import()`, weil `featureRegistry.js` von `siteFeatures.js` importiert wird); `oeffentlicheAnsicht(key) ⇒ null | { key, name, kurz, priceLabel, setupEstimate, consent: { version, knopf, items: [{ id, text, haken }] }, leistungsbeschreibung, avv, widerruf, returnHash, requiresWartungsAbo }` (JSON-sicher, ohne Funktionen und ohne `ticket`/`mails`).
**Register-Eintrag (verbindliche Form; der Plan-Kopf-Kern bleibt, alles Weitere ist additiv):**
| Feld | `news` (heutige Werte, wörtlich) | `termine` |
|---|---|---|
| `key`, `product` | `'news'`, `'newsSetup'` | `'termine'`, `'termineSetup'` |
| `label` | `{ name: 'News-Pinnwand', kurz: 'News', keine: 'keine News-Pinnwand' }` | `{ name: 'Terminplaner', kurz: 'Termine', keine: 'keinen Terminplaner' }` |
| `priceLabel`, `setupEstimate` | `'30 €'`, `'meist innerhalb von 1–2 Wochen'` | `'149 €'`, gleicher Text (Frage Q1) |
| `consent` | `{ version: '2026-09-30', knopf: 'Zahlungspflichtig bestellen · 30 €', items: [{ id: 'beginn', haken: true, text: <heutiger Satz aus config.js:195-196, wörtlich inkl. „§ 356 Abs. 4 BGB“> }] }` | `{ version: 'ENTWURF-2026-10-03', knopf: 'Zahlungspflichtig bestellen · 149 €', items }` mit vier Haken `unterlagen`, `gesundheit`, `beginn`, `information` (alle `haken: true`, ohne `url`; die IDs sind die endgültigen aus F7, Block F ersetzt nur Texte und Fassung); Texte nur Platzhalter „ENTWURF (Block F): …“ nach Spec 4.3 (1)–(4). Die Widerrufsbelehrung ist kein Haken-Item, sondern wird als eigener Nachweiseintrag `belehrung` gespeichert (B4/B7/F7) |
| `leistungsbeschreibung`, `avv`, `widerruf` | `{ version: '2026-09-30', url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' }`, `null`, `null` | `leistungsbeschreibung` und `avv` je `{ version: 'ENTWURF', url: '' }`, `widerruf` `null` (Block F1/F7 setzt `config.termine.avvVersion`-Fassung und versionierte Adressen) |
| `returnHash`, `returnProjekt` | `'news'`, `false` | `'termine'`, `true` (Rücksprung `#termine?bestellt=1&projekt=<id>`) |
| `ticket` | `topic(project)` = `` `News-Pinnwand einbauen – ${projectName}`.trim() ``; `details(ctx)` = die 6 Zeilen aus `ticketAdmin.js:95-102` | `topic` = `` `Terminplaner einbauen – ${projectName}` ``; `details(ctx)` ruft lazy `termine/ticketDetails.js` (Stub aus B8, Block D4 ersetzt den Inhalt) |
| `mails` | `funktion: 'News-Pinnwand'`; `formen: { dein: 'Deine News-Pinnwand', nom: 'die News-Pinnwand', akk: 'die News-Pinnwand', gen: 'der News-Pinnwand', pron: 'sie' }`; `knopf: 'Beitrag schreiben'`; `danach: 'Danach kannst Du Beiträge im Portal schreiben.'`; `bereit: 'Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.'`; `preisHinweis: 'brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo'`; `anleitung: false` | `funktion: 'Terminplaner'`; `formen: { dein: 'Dein Terminplaner', nom: 'der Terminplaner', akk: 'den Terminplaner', gen: 'des Terminplaners', pron: 'ihn' }`; `knopf: 'Termine öffnen'`; `danach`, `bereit` als Platzhaltertexte für die Mail-Überarbeitung des Inhabers (Mailrunde des Inhabers, nicht Block F; vom `todo`-Test unten ausgenommen); `preisHinweis` = `` `einmalig, ${config.ustHinweis}; der Terminplaner läuft, solange Dein Wartungs-Abo läuft` ``; `anleitung: true` (die K15-Anhänge hängt F8 direkt in `onFeaturePaid` an, es gibt keinen Registerhaken dafür) |
| Schalter | `requiresWartungsAbo: false`, `ticketFreischaltung: true` | `requiresWartungsAbo: true`, `ticketFreischaltung: true` |
| Meldungen | `disabledMessage` = heutiger `DISABLED_MESSAGE`; `orderedConflictMessage: 'Die News-Pinnwand ist für dieses Projekt schon bestellt.'` | `disabledMessage: 'Der Terminplaner ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.'`; `orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.'`; `unavailableMessage: 'Der Terminplaner ist noch nicht verfügbar.'` (= `MELDUNG.nichtVerfuegbar`, `routes/termine.js:57`) |
| Haken (optional) | – | **nicht in `config.js`**, sondern als Standard-Haken in `featureRegistry.js` (`featureHaken(key)`): `verfuegbar(email)` = `termineFuerKunde` (B6), `bestellpruefung(ctx)` = `ladeEntwurf` + Block-C-Funktion (B6), `unterlagenBereit()` (B6, Standard `true`; F8 hängt `featureLegalReady` ein), `belehrung()` (B7, Standard: der Platzhaltertext `BELEHRUNG_PLATZHALTER` = „ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.“, exportiert aus `featureRegistry.js`; F7 liefert `widerrufsbelehrungFunktion()`; `news` hat keinen `belehrung`-Haken), `freischaltbar(doc)` (B11); wer `config.features.<key>.<haken>` setzt (Tests), überschreibt den Standard |
- [ ] Tests (`test/featureRegistry.test.js`, Testaufbau laut Kopf): „News-Eintrag entspricht den bisherigen Festwerten“ (`product 'newsSetup'`, `priceLabel '30 €'`, `setupEstimate`, Satz wörtlich, `returnHash 'news'`, `requiresWartungsAbo false`, `ticketFreischaltung true`, beide Meldungen wortgleich zu den heutigen, `topic({ projectName: 'Bäckerei' }) === 'News-Pinnwand einbauen – Bäckerei'`, `details` == die 6 Zeilen des heutigen Defaults mit `siteKey` und Domain `liveDomain || previewUrl || subdomain`); „Alias bleibt“ (`config.funktionen.setupEstimate`/`consentText` == News-Werte, Getter folgen einer Änderung an `config.features.news` zur Laufzeit); „`FEATURE_LABELS` unverändert“ (`{ news: { name, keine }, termine: { name, keine } }` exakt) und `FEATURE_KEYS` == `featureKeys()`; „Schlüsselschutz“ (`featureConfig` für `analyse`, `__proto__`, `toString`, `undefined`, `''` ⇒ `null`); „Haken überschreibbar“ (`config.features.termine.bestellpruefung = fn` gewinnt gegen den Standard, danach zurücksetzen); „Ansicht ohne Funktionen“ (`JSON.parse(JSON.stringify(ansicht))` gleich, kein `ticket`, kein `mails`); „Termine: Preis und Struktur“ (`product 'termineSetup'`, `priceLabel '149 €'`, `requiresWartungsAbo true`, `returnProjekt true`, Item-IDs `['unterlagen','gesundheit','beginn','information']` mit `haken` `[true,true,true,true]`, `config.ustHinweis` wörtlich, `mails.preisHinweis` enthält `config.ustHinweis`); **`{ todo: 'Block F ersetzt die Platzhaltertexte' }`-Test** „Bestelltexte ohne ENTWURF“ (Version, Items, `leistungsbeschreibung`, `avv`, `widerruf` frei von `ENTWURF`, ab B7 auch der Text von `await featureHaken('termine').belehrung()`; **ausgenommen** `mails.danach`/`mails.bereit`, die der Inhaber in seiner Mailrunde schreibt; schlägt bis Block F1/F7 fehl, ohne die Suite zu brechen).
- [ ] FAIL → implementieren (Einträge als reine Daten in `config.js`; `ticket.*` des News-Eintrags ohne Importe, `ticket.details` des Termine-Eintrags per dynamischem `import()` innerhalb der Funktion, weil `config.js` keine Dienste statisch importieren darf; das Ziel `termine/ticketDetails.js` entsteht erst in B8, aufgerufen wird es vorher nirgends) → PASS → `npm test` → Commit „Funktionen: Register config.features mit News- und Termine-Eintrag“
### Task B2: Stripe – Katalog `termineSetup` und generisches `createFeatureCheckout`
**Files:** Modify `server/config.js` (`termineSetup` neben `newsSetup` Z. 100–107, im `config` neben `newsSetup` Z. 190), `server/services/stripeBilling.js` (`PRODUCT_METADATA` Z. 54 + `termineSetup: 'termine-einrichtung'`, `catalogFor` Z. 57–62, `createFeatureCheckout` Z. 647–682), `scripts/stripe-products.mjs` (`KEYS` Z. 20 + `'termineSetup'`, `KEYS` und `catalogEntry` Z. 30–34 mit `export`), `test/stripeBilling.test.js` (neu neben `news_einrichtung_einmalpreis`, Z. 246–260). Create `test/featureCheckout.test.js`, `test/stripeProductsScript.test.js`.
**Consumes:** B1 `featureConfig`.
**Produces:** `config.termineSetup = { key: 'termineSetup', name: 'Terminplaner – Einrichtung', amountCents: 14900, lookupKey: 'webklar_termine_einrichtung_14900', oneTime: true, priceNickname: 'Terminplaner-Einrichtung 149 €' }`; `createFeatureCheckout(customer, { projectId, key = 'news', consentAt }) ⇒ { id, url }` (unbekannter `key` ⇒ Fehler `status 400`, **vor** jedem Stripe-Aufruf): Preis `ensurePrice(feature.product)`, `invoice_data: { description: <Katalogname>, footer, metadata: { featureKey: key, projectId } }`, `metadata` unverändert (`customerId, projectId, featureKey, consentAt`), `success_url` = `${publicBaseUrl}/dashboard.html#${returnHash}?bestellt=1` (+ `&projekt=<encodeURIComponent(projectId)>` bei `returnProjekt`), `cancel_url` = `…#${returnHash}` (+ `?projekt=<id>`); SEPA-Rückfall und 24-h-Ablauf unverändert. Skript: `KEYS` und `catalogEntry(key)` exportiert (Import startet `main()` nicht, `import.meta.url`-Wächter besteht).
- [ ] Tests: `stripeBilling.test.js` „termine_einrichtung_einmalpreis“ nach Vorbild News: ein `POST /v1/prices` mit `unit_amount '14900'`, `lookup_key 'webklar_termine_einrichtung_14900'`, ohne `recurring`, Produkt-Metadaten `webklar = 'termine-einrichtung'`, zweiter Aufruf legt nichts an. `featureCheckout.test.js` (Stripe-Nachbau, direkter Aufruf): News: Preis 3000, `success_url`/`cancel_url` **identisch zu heute** (`…#news?bestellt=1`, `…#news`), `invoice_creation[invoice_data][description] = 'News-Pinnwand – Einrichtung'`, Footer, `metadata[featureKey]='news'`, `expires_at` < jetzt + 24 h, `payment_method_types` Karte + SEPA, SEPA-Rückfall nur Karte; Termine: Preis 14900, `invoice_data[description] = 'Terminplaner – Einrichtung'`, `invoice_data[metadata][featureKey]='termine'` und `[projectId]`, `success_url …#termine?bestellt=1&projekt=p%2F1` (Sonderzeichen kodiert), `cancel_url …#termine?projekt=p%2F1`; unbekannter Schlüssel (`'analyse'`) ⇒ `rejects` mit `status 400` und `st.calls` unverändert. `stripeProductsScript.test.js`: `KEYS` deepEqual `['wartung','hosting','mailStorage','newsSetup','termineSetup']`, `catalogEntry('termineSetup') === config.termineSetup`, Import ohne Stripe-Aufruf und ohne `process.exit` (kein Live-Aufruf, `--anlegen` macht der Inhaber im Livegang-Schritt 4).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Stripe: Katalog Terminplaner-Einrichtung (149 €) und Checkout je Funktion aus dem Register“
### Task B3: Schema `featureConsents` in `scripts/feature-setup.mjs`
**Files:** Modify `scripts/feature-setup.mjs` (aus A11: neu `FEATURE_CONSENT_ATTRIBUTES`, `FEATURE_CONSENT_INDEXES`, `CONSENT_COLLECTION_NAME`; `main` legt die Collection samt Attributen und Indizes an bzw. meldet sie mit `--check`), `server/config.js` (`collections.featureConsents`, Env `APPWRITE_COLLECTION_FEATURE_CONSENTS`), `test/support/schema.js` (`schemaKeys('featureConsents')` liefert die Schlüssel aus `FEATURE_CONSENT_ATTRIBUTES`), `test/featureSetup.test.js` (aus A11, erweitert). `scripts/setup-abo-collections.mjs` bleibt unverändert (Top-Level-Skript ohne Exporte und ohne `--check`; ein nachträglich eingebauter Prüfmodus gefährdete das Live-Schema-Skript).
**Consumes:** A11 (`feature-setup.mjs`; `createAppwriteClient` aus `analyse-setup.mjs`). **Produces:** Collection `featureConsents` (nur Server-Key, wie `analyseConsents`; Anlegen über `ensureCollection` ohne Rechte): `customerId` 36, `projectId` 36, `featureKey` 16, `stripeSessionId` 128, `acceptedAt` datetime, `fassungen` 1000 (JSON), `texte` 20000 (JSON, Appwrite macht daraus eine TEXT-Spalte), `textHash` 64, `ip` 64, `userAgent` 300, `konto` 255; Indizes `idx_session` [`stripeSessionId`], `idx_project_feature` [`projectId`, `featureKey`, `acceptedAt`]. `node scripts/feature-setup.mjs --check` meldet je fehlende Collection/Attribut/Index eine Zeile `- <id>[.<key>] FEHLT`, schreibt nichts, Exit-Code 1 bei Lücken.
- [ ] Tests (`featureSetup.test.js`, Mock-Client wie `analyseSetup.test.js`): „Schema featureConsents“ (die 11 Schlüssel, Größen: `texte` ≥ 20000, `stripeSessionId` ≥ 128, `acceptedAt` datetime, alle optional; `schemaKeys('featureConsents')` == dieselben Schlüssel; beide Indizes mit den Attributen oben); `main` legt bei fehlender Collection genau `featureConsents` mit 11 Attributen und 2 Indizes an (und für `portalFeatures` nur Fehlendes, A11); `main` überspringt Vorhandenes (keine Anlege-Aufrufe); `main({ check: true })` schreibt nie (keine `createAttribute`/`ensureCollection`/`createIndex`-Aufrufe), meldet `featureConsents FEHLT` und liefert die Lücken im Ergebnis. Der Lauf gegen die echte Appwrite gehört in den Livegang-Schritt 3 (Block G), weil der Nachbau die Attribut-Wartezeiten nicht nachstellt.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Schema: Zustimmungsnachweis featureConsents im Skript feature-setup“
### Task B4: Zustimmungsnachweis `featureConsents.js` und Widerrufs-Anker
**Files:** Create `server/services/featureConsents.js`, `test/featureConsents.test.js`. Modify `server/services/siteFeatures.js` (`paidFeatureOrders` Z. 190–206), `server/routes/abo.js` (`withdrawFeature` Z. 1091: `o.vertragAt || o.orderedAt`), `test/publicForms.test.js` (neuer Test neben „Widerruf Funktion…“ Z. 515).
**Consumes:** B3 Schema, B1 `featureConfig`.
**Produces:** `speichereZustimmung({ customerId, projectId, featureKey, stripeSessionId, fassungen, texte, ip, userAgent, konto, at }) ⇒ Promise<doc>` (Plan-Kopf-Signatur; `fassungen` Objekt Name ⇒ Fassung, `texte` Array `{ id, text, haken }` genau in Anzeigereihenfolge; speichert beides als JSON-Text, `textHash` = SHA-256 (hex) über `JSON.stringify(texte)`, `acceptedAt = at`); `letzteZustimmung({ customerId, projectId, featureKey }) ⇒ Promise<doc | null>` (neueste nach `acceptedAt`); `zustimmungFuerSitzung(stripeSessionId) ⇒ Promise<doc | null>`; `leseZustimmung(doc) ⇒ { …doc, fassungen: object, texte: array }`; `texteHash(texte) ⇒ string`; `zustimmungsfassungen(feature) ⇒ { zustimmung, leistungsbeschreibung, avv?, widerruf? }` (jeweils die `version` des Registerfelds, falls vorhanden) und `nachweisTexte(feature, { belehrung = null } = {}) ⇒ [{ id, text, haken }]` (alle `consent.items` in Anzeigereihenfolge, dann – falls übergeben – `{ id: 'belehrung', text: belehrung, haken: false }` (der **gezeigte** Belehrungstext samt Muster-Formular, Review Focus 5; die Route holt ihn über `featureHaken(key).belehrung`), zuletzt `{ id: 'knopf', text: consent.knopf, haken: false }`). `speichereZustimmung` erlaubt leeres `stripeSessionId`, `ip` und `userAgent` sowie fehlendes `haken` (Nachweis der Staff-Aktivierung aus F13a); `zustimmungFuerSitzung('')` (leer/`undefined`) liefert `null` **ohne Abfrage** (sonst träfe es die F13-Zeilen über `idx_session`). `paidFeatureOrders` liefert zusätzlich `featureKey` und `vertragAt` (= `acceptedAt` der Zustimmung **zur bezahlten Sitzung**, sonst `doc.consentAt`, sonst `orderedAt`; Lesefehler ⇒ Rückfall auf `orderedAt` mit Log, die Widerrufsprüfung darf an der Nachweis-Collection nie scheitern). Das Modul exportiert bewusst nur diese Lese-/Anfügefunktionen (keine Änderung, kein Löschen).
- [ ] Tests (`featureConsents.test.js`): „speichert und liest“ (Rundlauf, `fassungen`/`texte` als Objekt/Array zurück, `textHash` == unabhängig berechneter SHA-256); „append-only“ (`Object.keys(import)` genau die sieben Exporte `speichereZustimmung`, `letzteZustimmung`, `zustimmungFuerSitzung`, `leseZustimmung`, `texteHash`, `zustimmungsfassungen`, `nachweisTexte`; kein `update`/`delete`/`remove`); „neueste Zustimmung“ (zwei Einträge, `at` verschieden, `letzteZustimmung` liefert den späteren, anderes Projekt/andere Funktion stört nicht); „`zustimmungFuerSitzung`“; „vollständig oder gar nicht“ (`texte` > 20 000 Zeichen ⇒ wirft 400, nichts gekürzt; fehlendes/ungültiges `at` ⇒ wirft; leere `texte` ⇒ wirft); „IP/UA/Konto gekürzt auf 64/300/255“; „Nachweistexte aus dem Register“ (`nachweisTexte(featureConfig('termine'))` hat fünf Einträge (vier Haken + `knopf`), letzter `knopf`; mit `{ belehrung: 'Text' }` sechs, `belehrung` direkt vor `knopf`, `haken false`); „Staff-Nachweis“ (`stripeSessionId: ''`, `ip: ''`, `userAgent: ''`, Text ohne `haken` ⇒ gespeichert; `zustimmungFuerSitzung('')` ⇒ `null` und **keine** Listenabfrage an `featureConsents`, `zustimmungFuerSitzung(undefined)` ebenso). `publicForms.test.js`: „Widerruf Funktion: Fristanker ist die Zeit des Klicks der bezahlten Sitzung“: Dokument `orderedAt` = vor 1 Tag (durch Neubestellung überschrieben), Zustimmung zur bezahlten Sitzung vor 13 Tagen ⇒ T09 nennt das Fristende von `withdrawalDeadline(vor 13 Tagen)` und `RECHTZEITIG`, mit Zustimmung vor 20 Tagen stattdessen `NACH Fristende` (je ein Testfall); eine **spätere, unbezahlte** Zustimmung desselben Kunden/Projekts (andere Sitzung) verschiebt den Anker nicht (Block F9 stützt sich auf `vertragAt`/`featureKey` aus diesem Task, nicht auf `letzteZustimmung`); Nachweis-Collection kaputt (`failNext` auf `featureConsents`) ⇒ Rückfall auf `orderedAt`, Antwort 200.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Zustimmungsnachweis je Funktion (featureConsents) und Fristanker aus dem Klick“
### Task B5: Schnittstellen-Stubs für Block E und C (`aboStatus`, `fragebogen`)
**Files:** Create `server/services/aboStatus.js`, `server/services/termine/fragebogen.js` (Stub), `test/aboStatus.test.js`, `test/termineFragebogenStub.test.js`.
**Consumes:** `getCurrentSubscription`, `ACTIVE_STATUSES` aus `stripeBilling.js` (Z. 24, 178).
**Produces (Plan-Kopf-Signaturen; Block E1 erweitert die Datei `aboStatus.js` (Modify, die Marke `KERNVERSION` entfällt) und zieht `hasWartungsAbo` aus `analyse.js:43-46` hinein; Block C ersetzt den Stub):**
- `aboStatus.js` (Kopfkommentar `// KERNVERSION (Block B) – Block E baut aus`; **fail-closed**, kein Stub im Sinn von „durchlassen“): `laeuft(customerId) ⇒ Promise<boolean>` (`productKey || 'wartung'` == `'wartung'` und Status ∈ `ACTIVE_STATUSES`; wirft bei Lesefehler), `bestellbar(customerId) ⇒ Promise<{ ok: boolean, grund?: 'kein_abo' | 'gekuendigt' | 'nicht_aktiv' }>` (**Reihenfolge der Gründe, einmal für alle Blöcke:** nicht `laeuft` ⇒ `kein_abo`; `cancelAtPeriodEnd || cancelAt` ⇒ `gekuendigt`; Status ≠ `active` ⇒ `nicht_aktiv`), `aboMemo() ⇒ { laeuft, bestellbar }` (Zwischenspeicher je Aufruf von `aboMemo()` und `customerId`).
- `termine/fragebogen.js` (Stub, Kopfkommentar `// STUB (Block B) – ersetzt Task C…`): `bestellpruefung({ einstellung, fragebogen, feature, abo, mitAbo = true }) ⇒ Array<{ schritt, text }>` gibt `[]` zurück (Signatur wie im Kopf, endgültig).
- [ ] Tests (`aboStatus.test.js`; Testaufbau laut Kopf: `seedFixtures` gibt `c1` nur ein Hosting-Abo, für Wartungsfälle überschreibt der Test es mit `aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))`): Wartungs-Abo `active` ⇒ `laeuft` true, `bestellbar { ok: true }`; Hosting-Abo ⇒ false / `kein_abo`; Dokument **ohne** `productKey` zählt als Wartung (wie `abo.js:68`); `canceled` ⇒ false / `kein_abo`; `past_due` und `trialing` ⇒ `laeuft` true, `bestellbar { ok: false, grund: 'nicht_aktiv' }`; `cancelAtPeriodEnd` und `cancelAt` ⇒ `gekuendigt`, auch bei `trialing` und `past_due` (Kündigung vor Status); kein Abo (`c3`) ⇒ `kein_abo`; **Lesefehler** (`aw.failNext({ collection: 'subscriptions', status: 500 })`) ⇒ beide Funktionen **werfen** (nie `false`/`ok:false`); Memo: zweiter Aufruf desselben Kunden aus demselben `aboMemo()` kommt aus dem Speicher (ein danach eingeplantes `failNext` trifft ihn nicht, ein frischer `laeuft('c1')` schon), anderer `aboMemo()` liest neu. `termineFragebogenStub.test.js`: Stub liefert `[]` und trägt die Marke (Block C löscht diese Datei mit dem Stub).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abo-Baustein (Kernversion) und Platzhalter fuer die Bestellpruefung“
### Task B6: Checkout-Route, Teil 1 – Vorprüfungen in Spec-Reihenfolge
**Files:** Modify `server/routes/funktionen.js` (`POST /news/checkout` Z. 137–181 wird `POST /:key/checkout`; `fail` Z. 35–40 gibt zusätzlich `err.body` weiter), `server/services/featureRegistry.js` (`featureSperre`). Create `server/services/featureOrder.js`, `test/funktionenCheckout.test.js`; Modify `test/kundenansichtGuard.test.js` (Z. 118: `['POST','/api/funktionen/termine/checkout']` in die Negativliste).
**Consumes:** B1 `featureConfig`/`oeffentlicheAnsicht`/`featureHaken`, B5 `aboMemo`, `bestellpruefung`, `ladeEinstellung` (`termine/kontext.js:75`), `termineFuerKunde` (`schalter.js:22`), `assertOwnProject`.
**Produces:** Vertrag der Route `POST /api/funktionen/:key/checkout`, Body `{ projectId, zustimmung: { version, haken: [id, …] } }` (News zusätzlich wie heute `{ projectId, consent: true }`, gilt nur für Funktionen mit genau einem Haken-Item) **oder** `{ projectId, fortsetzen: true }` („Zahlung fortsetzen“ aus der Statuskarte: die Zustimmungsprüfung (3) entfällt **nur**, wenn das Dokument nach (4) `ordered` ist, sonst 400 `zustimmung_fehlt`; Antwort und Nachweisregel in B7; enthält der Body beides, gilt `zustimmung`); `featureOrder.js`: `pruefeZustimmung(feature, eingabe) ⇒ { akzeptiert: [id] }` (wirft 400 `zustimmung_fehlt`, 409 `fassung_veraltet` mit `err.body.consent = oeffentlicheAnsicht(key).consent`), `aboMeldung(feature, grund) ⇒ string` (exportiert aus `featureOrder.js`; **die** Quelle der Abo-Hinweistexte, Du-Form, Akkusativ aus `feature.mails.formen.akk`; in den Wortlauten steht „den Terminplaner“ für `formen.akk`: `kein_abo` „Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.“, `gekuendigt` „Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.“, `nicht_aktiv` „Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.“, `unbekannt` „Das Abo konnte gerade nicht geprüft werden.“; Block C und E definieren keine eigenen Wortlaute), `ladeEntwurf(featureId) ⇒ { einstellung, fragebogen }` (`ladeEinstellung` + tolerantes Parsen von `termineConfig.fragebogen`, fehlt/kaputt ⇒ `null`), `orderDeps = { aboMemo }` (Test-Einhängepunkt), `featureSperre(key, email) ⇒ string | null` (in `featureRegistry.js`; `featureHaken(key).verfuegbar`, bei `false` die `unavailableMessage`; Standard-Haken `verfuegbar`, `bestellpruefung` und `unterlagenBereit` (Standard `true`) werden hier gefüllt). **Prüfreihenfolge, jede Stufe ohne Seiteneffekt:** (1) `req.session.viewAs` ⇒ 403 „In der Kundenansicht kann nicht bestellt werden.“; (2) `featureConfig(key)` fehlt ⇒ 404 „Unbekannte Funktion“; (3) Zustimmungen vollständig und Fassung == Serverfassung (400/409 wie oben); bei `fortsetzen: true` ohne `zustimmung` übersprungen; (4) `projectId` Text ⇒ sonst 400 „Bitte wähle ein Projekt.“; `assertOwnProject` ⇒ sonst 403; bei `fortsetzen: true` danach: `getFeatureDoc` nicht `ordered` ⇒ 400 `zustimmung_fehlt`; (5) `featureSperre` ⇒ 409 `nicht_verfuegbar`; (5b) `featureHaken(key).unterlagenBereit()` falsch ⇒ 503 `unterlagen_fehlen` „Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.“ (Standard immer bereit; F8 hängt `featureLegalReady` ein, vor jeder Stripe-Sitzung); (6) `featureHaken(key).bestellpruefung({ customerId, projectId, feature })` (der Kontext enthält **kein** `abo`) ⇒ nicht leer ⇒ 409 `bestellung_offen` mit `body.offen` (Standard für `termine`: `getFeatureDoc` → `ladeEntwurf(doc.$id)`, ohne Dokument `einstellung`/`fragebogen` = `null`, dann `bestellpruefung({ einstellung, fragebogen, feature, mitAbo: false })` aus `termine/fragebogen.js`, **ohne** `abo`); (7) `requiresWartungsAbo`: `aboMemo().bestellbar(customerId)` ⇒ nicht ok ⇒ 409 mit `code` und `body.grund` = `kein_abo` | `gekuendigt` | `nicht_aktiv` (ohne Präfix, derselbe Code wie in Block C) und Text aus `aboMeldung`; Lesefehler ⇒ 500 mit generischem Text (fail-closed). **Regel für den Abo-Punkt (h) des Gates (verbindlich für B6 und C2):** der Checkout wendet (h) nicht an; der Standard-Haken ruft mit `mitAbo: false` und ohne `abo`, und auch der überschreibbare Haken `config.features.termine.bestellpruefung` bekommt kein `abo`. Die Abo-Stufe (7) ist damit die **einzige** Quelle der Checkout-Codes `kein_abo` | `gekuendigt` | `nicht_aktiv` und bleibt erreichbar: ein Kunde ohne Abo mit vollständigem Fragebogen bekommt 409 `kein_abo`, nie `bestellung_offen` mit Schritt `bestellen`; mit unvollständigem Fragebogen kommt zuerst `bestellung_offen` (ohne Eintrag `bestellen`, die Abo-Stufe wird nicht gelesen). Nur die Vorschau (`GET /api/termine/einstellungen`, C4a) übergibt `abo` und zeigt (h) als offenen Punkt.
- [ ] Tests (`funktionenCheckout.test.js`, jede Ablehnung prüft zusätzlich: `st.checkoutSessions.size === 0`, kein `featureConsents`-Dokument, kein `portalFeatures`-Dokument): „Kundenansicht ⇒ 403“ (Cookie `kundenansichtCookie()`, Testanwendung ohne Wächter, gültiger Body); „Wächter sperrt zusätzlich“ (`kundenansichtGuard.test.js`: die vorhandene Schleife `writeRoutes(MOUNTS)` trifft `/api/funktionen/x/checkout` automatisch ⇒ 403 `view_blocked`; Negativliste um den Termine-Pfad ergänzt); „unbekannter Schlüssel“ (`analyse`, `__proto__`, `nope` ⇒ 404); „Zustimmung“: fehlt/leer ⇒ 400 `zustimmung_fehlt`; ein Pflicht-Haken fehlt ⇒ 400; unbekannte Haken-ID ⇒ 400; falsche Fassung ⇒ 409 `fassung_veraltet` mit `consent.version` == Serverfassung und allen Item-Texten; `consent: true` bei Termine (vier Haken) ⇒ 400, bei News ⇒ weiter; „Projekt“: ohne `projectId` 400, fremd (`p5` von `c2`) 403; „Schalter“: `mode 'off'` ⇒ 409 „Der Terminplaner ist noch nicht verfügbar.“, `pilot` mit fremder Adresse ⇒ 409, News unabhängig vom Modus; **„Reihenfolge“**: `config.features.termine.bestellpruefung` auf `async () => [{ schritt: 'zeiten', text: 'Zeiten bestätigen' }]` und `orderDeps.aboMemo` auf einen Zähler ⇒ 409 `bestellung_offen` mit `offen[0].schritt === 'zeiten'` und Zähler **0**; danach Pruefung `[]` ⇒ Zähler 1; der Haken erhält ein Objekt ohne `abo`-Eigenschaft (`'abo' in arg === false`); „Abo-Gründe“ (Testaufbau laut Kopf, `seedFixtures`-Falle): `c3` (kein Abo) und `c2` (Hosting) ⇒ `kein_abo`, Kündigung vorgemerkt ⇒ `gekuendigt`, `past_due` ohne Kündigung ⇒ `nicht_aktiv`, `past_due` mit Kündigung ⇒ `gekuendigt`, Lesefehler ⇒ 500 ohne Rohtext; `nicht_aktiv`-Text == der volle Wortlaut oben (endet auf „bestellen.“); mit leerer Prüfung (`[]`, Stub oder echtes Gate) und Kunde ohne Abo ⇒ 409 `kein_abo`, nicht `bestellung_offen`; „Unterlagen“: `config.features.termine.unterlagenBereit = async () => false` ⇒ 503 `unterlagen_fehlen`, Zähler 0, Reihenfolge (5) → (5b) → (6) im Reihenfolge-Test; „fortsetzen“: `{ projectId, fortsetzen: true }` bei `off` oder ohne Dokument ⇒ 400 `zustimmung_fehlt`, kein Dokument entstanden; bei fremdem Projekt ⇒ 403; „News braucht kein Abo“ (`c3` bestellt News weiter nach Teil 2).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Checkout je Funktion: Vorpruefungen in der Reihenfolge der Spezifikation“
### Task B7: Checkout-Route, Teil 2 – Sperre, Zustandsprüfung, Nachweis vor der Weiterleitung
**Files:** Modify `server/routes/funktionen.js` (Rest der Route, `openOrderSession` Z. 121–135 mit Meldung aus dem Register), `server/services/siteFeatures.js` (`markOrdered` Z. 208–226: Meldungen aus dem Register, `consentText` aus den Haken-Texten), `server/services/featureRegistry.js` (Standard-Haken `belehrung`, `BELEHRUNG_PLATZHALTER`), `test/funktionenRoutes.test.js` (Schema `featureConsents`, neue News-Fälle), `test/funktionenCheckout.test.js`.
**Consumes:** B2 `createFeatureCheckout`, B4 `speichereZustimmung`/`zustimmungFuerSitzung`/`zustimmungsfassungen`/`nachweisTexte`, B6.
**Produces:** Ablauf unter `withLocks(['feature-order:<customer>:<project>'])` (Eingangszeit `klickAt = new Date().toISOString()` **vor** der Sperre): (a) `getFeatureDoc`: `setup`/`active` ⇒ 409 `feature.orderedConflictMessage`, `disabled` ⇒ 409 `feature.disabledMessage`; (b) `ordered` ⇒ `openOrderSession` (offene Sitzung wiederverwenden, `expired` ⇒ neu, `complete` oder Stripe nicht erreichbar < 24 h ⇒ 409); bei Body `fortsetzen: true` (ohne `zustimmung`, „Zahlung fortsetzen“) wird eine offene Sitzung **nur** zurückgegeben, wenn `zustimmungFuerSitzung(session.id)` einen Nachweis kennt (dann weder zweite Stripe-Sitzung noch zweiter Nachweis); fehlt der Nachweis oder bräuchte es eine neue Sitzung (`expired`), antwortet die Route 409 `zustimmung_fehlt` ohne URL, und die Bestellseite zeigt das Formular, in dem der Kunde erneut zustimmt; (c) neue Sitzung: `createFeatureCheckout(customer, { projectId, key, consentAt: klickAt })`; (d) `zustimmungFuerSitzung(session.id)` leer ⇒ `speichereZustimmung({ …, stripeSessionId: session.id, fassungen: zustimmungsfassungen(feature), texte: nachweisTexte(feature, { belehrung: (await featureHaken(key).belehrung?.()) ?? null }), ip: req.ip, userAgent: req.get('user-agent'), konto: 'Name <E-Mail>' (max. 255), at: klickAt })` — **auch** bei Wiederverwendung einer offenen Sitzung, falls für sie noch kein Nachweis liegt; (e) erst danach `markOrdered({ customerId, projectId, key, sessionId, consent: { text, at: klickAt, ip, userAgent } })`; (f) Antwort `{ url }`. Gespeichert werden **die Serverfassungen und -texte**, nie Texte aus dem Client. Scheitert (d), gibt es **keine** URL und kein `ordered` (die offene, nicht erreichbare Stripe-Sitzung läuft nach 24 h ab; kein Stripe-Storno nötig). **Belehrungs-Haken:** B7 füllt den Standard-Haken `featureHaken('termine').belehrung()` in `featureRegistry.js` mit dem Platzhalter `BELEHRUNG_PLATZHALTER = 'ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.'` (exportiert; F7 ersetzt ihn durch `widerrufsbelehrungFunktion()`). Damit hat der Nachweis ab B7 immer einen Eintrag `belehrung` (`haken false`, direkt vor `knopf`) und die Bestellseite (C2, C8, C11) zeigt vor dem Knopf einen Belehrungstext; `news` hat keinen `belehrung`-Haken (kein solcher Eintrag, Verhalten unverändert).
- [ ] Tests, **Review Focus #5** (`funktionenCheckout.test.js`, Termine): „Nachweis steht vor der Weiterleitung“ – gültiger Aufruf ⇒ 200 `{ url }`; genau **ein** `featureConsents`-Dokument mit `stripeSessionId` == der Stripe-Sitzung, `texte` (geparst) deepEqual `nachweisTexte(featureConfig('termine'), { belehrung: (await featureHaken('termine').belehrung?.()) ?? null })` in Reihenfolge, `fassungen` == `zustimmungsfassungen(…)`, `textHash` == SHA-256 über die gespeicherten Texte, `ip`/`userAgent`/`konto` gesetzt; **ein Zeitpunkt**: `acceptedAt` == `metadata.consentAt` der Stripe-Sitzung == `portalFeatures.consentAt`, zwischen den vor/nach dem Aufruf genommenen Zeiten; „ohne Nachweis keine URL“ – `aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 })` ⇒ 500, Antwort ohne `url`, Dokument nicht `ordered` (fehlt oder `off`), `orderedAt` leer; „Belehrung im Nachweis“ – `texte` endet auf `[…, { id: 'belehrung', text: BELEHRUNG_PLATZHALTER, haken: false }, { id: 'knopf', … }]`, der News-Nachweis hat keinen `belehrung`-Eintrag; „Client-Texte zählen nicht“ – Body mit zusätzlichem `texte: ['manipuliert']` ⇒ gespeichert werden die Registertexte; „Fassungswechsel“ – `config.features.termine.consent.version` zur Laufzeit ändern ⇒ alter Body 409, neuer Body 200 mit neuer Fassung im Nachweis; „Wiederverwendung“ – zweiter Aufruf bei offener Sitzung ⇒ gleiche URL, keine zweite Stripe-Sitzung, **kein** zweites Nachweis-Dokument; Nachweis der Sitzung per Test gelöscht ⇒ zweiter Aufruf legt einen neuen an, bevor die URL zurückkommt; „Zahlung fortsetzen“ (`{ projectId, fortsetzen: true }`, Review Focus 5) – Dokument `ordered` mit Nachweis zur offenen Sitzung ⇒ 200 mit **derselben** URL, keine zweite Stripe-Sitzung, kein zweites `featureConsents`-Dokument; Nachweis per Test gelöscht ⇒ 409 `zustimmung_fehlt`, keine URL, danach derselbe Aufruf mit `zustimmung` ⇒ 200, Nachweis wieder da (ein Dokument); abgelaufene Sitzung mit `fortsetzen` ⇒ 409 `zustimmung_fehlt`, mit `zustimmung` ⇒ neue Sitzung; „abgelaufene Sitzung“ ⇒ neue Sitzung und neuer Nachweis (zwei Dokumente, zwei Sitzungs-IDs); „parallele Klicks“ (`Promise.all` ×2) ⇒ eine Sitzung, ein Nachweis, beide 200 mit gleicher URL; Zustände: `setup`/`active` ⇒ 409 `orderedConflictMessage`, `disabled` ⇒ 409 `disabledMessage`, `complete` ⇒ 409, nirgends neue Sitzung; Datenbankfehler beim `markOrdered` (`failNext` PATCH `portalFeatures`) ⇒ 500 mit „Bestellung konnte gerade nicht gestartet werden“, ohne Rohtext. News (`funktionenRoutes.test.js`, Schema um `featureConsents` ergänzt, alle bisherigen Tests unverändert grün): zusätzlich „News schreibt auch einen Nachweis“ (`texte` == die `consent.items` von `config.features.news` + Knopf, `fassungen { zustimmung: config.features.news.consent.version, leistungsbeschreibung: config.features.news.leistungsbeschreibung.version }` – gegen `config` statt Literale, damit Block F2 die Fassung ohne Testbruch anhebt –, `portalFeatures.consentText` weiter == `config.funktionen.consentText`, Widerrufs-Anker über `paidFeatureOrders().vertragAt`).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Checkout je Funktion: Zustimmungsnachweis vor der Weiterleitung, Zustaende aus dem Register“
### Task B8: Ticket aus dem Register (Deckel, Notfall-Details) – alleinige Besitzerin von `createFeatureTicket`
**Files:** Modify `server/services/siteFeatures.js` (`markPaid` Z. 243–270), `server/services/ticketAdmin.js` (`createFeatureTicket` Z. 75–109), `server/services/featureRegistry.js` (`ticketTexte`, `minimaleTicketDetails`, `anleitungsLink`, `kuerze`, Konstanten `TICKET_TOPIC_MAX = 128`, `TICKET_DETAILS_MAX = 7000`, `LANGE_DETAILS = 1000`). Create `server/services/termine/ticketDetails.js` (Stub), `test/featureTicket.test.js`. Der bestehende Test `webhook_retry_setzt_ticketlose_einrichtung_fort` (`test/funktionenRoutes.test.js:321-345`) bleibt unverändert grün. Block D ändert `createFeatureTicket` **nicht** mehr (D5 entfällt).
**Consumes:** B1.
**Produces:** `ticketTexte(ctx) ⇒ Promise<{ topic, details, fallbackDetails }>` mit `ctx = { feature, doc, project, customer }` (`feature` = Register-Eintrag, `doc` = `portalFeatures`-Dokument, `project` = `websiteProjects`-Dokument, `customer` best effort oder `null`; `now` setzt Block D4 selbst im Rumpf): `topic` auf `TICKET_TOPIC_MAX` gekürzt, `details` auf `TICKET_DETAILS_MAX` (Schnitt an der letzten Zeilengrenze, eine einheitliche Schlusszeile „… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen“); wirft `feature.ticket.details` oder liefert es Leeres/Nicht-Text, gilt `fallbackDetails`. `kuerze(text, max, { anZeile = false, endzeile = '' })` kürzt **surrogatsicher** (nie ein halbes Zeichenpaar). `minimaleTicketDetails(ctx)` = Kopfzeilen (Funktion, Projekt, Domain = `liveDomain || previewUrl || subdomain`, Kunde, `siteKey`) + `Verweis: ${anleitungsLink(doc.customerId, doc.projectId)}`. `anleitungsLink(customerId, projectId)` = `${config.womsBaseUrl}/customers/<id>?tab=funktionen&anleitung=<encodeURIComponent(projectId)>` (der Reiter `funktionen` existiert in `W/src/lib/customerTabs.js`; das WOMS-Panel öffnet sich mit `anleitung=` automatisch, D10; B9b (T16 `anleitung_url`) und D3 nutzen dieselbe Funktion). `createFeatureTicket({ …, topic, details, fallbackDetails })`: `topic` und `details` bleiben **optional** mit den heutigen News-Festwerten als Rückfall (direkte Aufrufer und die Analyse bleiben wirkungsgleich; `markPaid` übergibt immer die Registertexte), kürzt beides selbst; **Wiederholung nur**, wenn `createDocument` mit `status 400` scheitert **und** `fallbackDetails` übergeben wurde **und** `details.length > LANGE_DETAILS` (dann **einmal** mit `fallbackDetails`, gleiche `woid`); kurze Details (News, Standardtext) und jeder andere Fehler (5xx, Netz: unklar, ob das Ticket entstand, sonst Doppel-Tickets) werden ohne Wiederholung weitergereicht; scheitert auch der Fallback, wird der **erste** Fehler geworfen (`markPaid` bleibt dann `setup` ohne `ticketId`, Stripe wiederholt – unverändert). `markPaid` lädt den Kunden (best effort) und übergibt Register-`topic`/`details`/`fallbackDetails`. `termine/ticketDetails.js`: `termineTicketDetails(ctx) ⇒ Promise<string>` = `minimaleTicketDetails` + Zeile „Einrichtung bezahlt (149 €), Zeit nicht auf das Stundenkontingent anrechnen.“ (Kopfkommentar `// STUB (Block B) – ersetzt Task D4`; **Block D4 ersetzt den Inhalt** durch `einbauanleitung(…).kurz`, Signatur und `ctx` bleiben).
- [ ] Tests (`featureTicket.test.js`; die bisherigen News-Tests in `siteFeatures.test.js`/`funktionenRoutes.test.js` bleiben unverändert grün): „News-Ticket byte-gleich“ (`topic === 'News-Pinnwand einbauen – Bäckerei'`, `details` == die 6 Zeilen des heutigen Defaults, einschließlich `Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)`); „Termine-Ticket aus dem Register“ (`topic` beginnt `Terminplaner einbauen – `, `details` enthält `siteKey` und `…?tab=funktionen&anleitung=<projectId>`); „Deckel“ (Projektname 255 Zeichen ⇒ `topic.length <= 128`; 100 × „😀“ als Projektname ⇒ gültige Zeichenkette ohne halbes Surrogat; `details`-Funktion liefert 20 000 Zeichen ⇒ gespeichert ≤ 7000, Schnitt an einer Zeilengrenze, endet auf der Schlusszeile; genau 7000 Zeichen unverändert); „Notfall“: `details`-Funktion wirft ⇒ Ticket entsteht mit `minimaleTicketDetails` (Geld ohne Spur ausgeschlossen); „Wiederholung nur bei langen Details“: Details mit 5000 Zeichen und `aw.failNext({ collection: 'workorders', method: 'POST', status: 400 })` einmal ⇒ zweiter Versuch mit `fallbackDetails` (Kopf + Link, ohne Snippet), genau ein Dokument in `workorders`, gleiche `woid`; zweimal 400 ⇒ `markPaid` wirft den **ersten** Fehler, Dokument `setup` ohne `ticketId`, eine spätere Wiederholung legt genau ein Ticket an; dieselbe Lage mit `status: 503` ⇒ wirft ohne Wiederholung, `workorders` unverändert (kein Doppel-Ticket); **kurze** Details (News/Standard) + einmal 400 ⇒ wirft, keine Wiederholung (das ist der bestehende Test `webhook_retry_setzt_ticketlose_einrichtung_fort`: Webhook 500, danach 200); „Termine über `markPaid`“ (lange Kurzfassung + 400 ⇒ Ticket mit Minimal-Details, `created: true`, K15/T16 gehen raus); „Analyse unberührt“ (`createFeatureTicket` mit eigenem `topic`/`details` ohne `fallbackDetails` ⇒ ein `createDocument`-Aufruf, Text unverändert, Deckel nur bei Überlänge); „Defaults“ (ohne `topic`/`details` ⇒ der heutige News-Text).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktions-Ticket: Text aus dem Register, Laengendeckel, Notfall-Details und gezielte Wiederholung“
### Task B9a: Mails K15 und K16 je Funktion (News-Wortlaut zuerst eingefroren)
**Files:** Modify `server/mail-templates/K15 Bestellung Funktion.html`, `K16 Funktion bereit.html`; Modify `server/services/siteFeatures.js` (K15-Versand Z. 530–546 in `onFeaturePaid`, `runActivation` Z. 578–593, `portalUrl` Z. 406); Modify `test/mailTemplates.test.js` (Platzhalter-/Blocklisten-Tests für K15 und K16). Create `test/featureMails.test.js` (K15/K16-Teil; B9b ergänzt T16 und T19).
**Consumes:** B1 (`mails.*`, `formen`, `featureConfig`).
**Produces:** Platzhalter/Blöcke (Kopfkommentar der Vorlagen, Reihenfolge wie hier): **K15** `kunde_name, projekt, funktion, funktion_gen, funktion_akk, funktion_nom, funktion_pron, preis, preis_hinweis, zeit, danach, portal_url, rechnung_url`, Block `rechnung`; Betreff „Deine Bestellung: {{funktion}}“; die News-Texte werden 1:1 aus den Platzhaltern erzeugt (z. B. „Wir richten {{funktion_akk}} jetzt für Dein Projekt ein“, „Sobald {{funktion_nom}} auf Deiner Website eingebaut ist, schalten wir {{funktion_pron}} im Kundenportal frei … {{danach}}“, Preiszeile „<strong>{{preis}}</strong> ({{preis_hinweis}})“, Knopf „Zum Kundenportal“ ⇒ `{{portal_url}}`); der Spec-Block „kein Abo“ entfällt zugunsten des Registerwerts `preisHinweis`. **K16** `kunde_name, projekt, funktion, funktion_dein, funktion_nom, bereit, knopf, portal_url`; Betreff „{{funktion_dein}} ist bereit“. Aufrufstellen: K15/K16 füllen alle Werte aus `featureConfig(doc.featureKey)`, `eur(config.newsSetup.amountCents)` wird `eur(catalog.amountCents)` je `feature.product`, `portal_url` = `${publicBaseUrl}/dashboard.html#${returnHash}`. **Anhänge:** K15 geht in B9a **ohne** Anhänge raus; die PDF-Anhänge der Termine-Bestellung hängt F8 direkt in `onFeaturePaid` an (`featureLegalAttachments(doc.featureKey)`, lazy importiert; es gibt keinen Registerhaken `mails.anhaenge`).
- [ ] **Zuerst Charakterisierung** (vor dem Umbau der Vorlagen): `renderMail` mit den heutigen News-Werten für K15 (mit/ohne `rechnung`) und K16 ausführen, `subject` und `text` als Literale in `test/featureMails.test.js` einfrieren („News-Mails bleiben wörtlich“, u. a. „Wir richten die News-Pinnwand jetzt für Dein Projekt ein.“, „(brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)“, „Danach kannst Du Beiträge im Portal schreiben.“, „Deine News-Pinnwand ist bereit“, Knopf „Beitrag schreiben“, Link `…/dashboard.html#news`). Danach Vorlagen umbauen; derselbe Test bleibt grün. Weitere Tests: „Termine-Mails“ (K15/K16 mit Termine-Werten enthalten weder „News“ noch „Beitr“; Betreff „Deine Bestellung: Terminplaner“, „Dein Terminplaner ist bereit“; Preis „149,00 €“; K15-Link `…#termine`; Knopf „Termine öffnen“); „Platzhalter-/Blocklisten exakt“ (`loadTemplate('K15')` und `loadTemplate('K16')`: `.placeholders`/`.blocks` deepEqual den Listen oben, die Schleifen in `mailTemplates.test.js` decken Rendern mit/ohne Blöcke ab); „K15 ohne Anhänge“ (Termine-Bestellung in B9a ⇒ K15 trägt keinen Anhang, der Versand ruft keinen Registerhaken).
- [ ] FAIL → implementieren → PASS → `npm test` (alle vorhandenen Mail-Tests in `funktionenRoutes.test.js` Z. 150, 166, 199, 238, 339, 371 grün) → Commit „Mails K15/K16 je Funktion (News-Wortlaut eingefroren)“
### Task B9b: Mail T16 je Funktion und neue Vorlage T19 Erstattung fällig
**Files:** Modify `server/mail-templates/T16 Funktion bestellt.html`; Create `server/mail-templates/T19 Erstattung faellig.html` (Layout von T16, Footer wie T16); Modify `server/services/siteFeatures.js` (`teamNotice` Z. 456, `unmatchedNotice` Z. 472), `test/mailTemplates.test.js` (Kopfkommentar Z. 8–9: „K15, K16, T16 Funktionen“ um „T19 Erstattung fällig“ ergänzen; `EXPECTED` Z. 11: `[...ids('G', 8), ...ids('K', 21), ...ids('T', 19), 'T20']` (A10a1 hat `'T20'` angehängt, nicht überschreiben; E3 hebt nur die K-Zahl); `TEAM_FOOTER` Z. 489 + `'T19'`; Blockliste/Platzhalter-Tests für T16 und T19), `test/featureMails.test.js` (aus B9a: T16/T19-Teil dazu).
**Consumes:** B1 (`mails.*`, `featureConfig`), B8 (`anleitungsLink`), B9a (`featureMails.test.js`).
**Produces:** Platzhalter/Blöcke (Kopfkommentar der Vorlagen, Reihenfolge wie hier): **T16** `kunde_name, email, projekt, funktion, woid, sitekey, sitzung, zustand, anleitung_url, gesundheit_text`; Blöcke `zahlung_fehlgeschlagen, ungeklaert, kein_abo, bereits_aktiv, anleitung, gesundheit`; Betreff „Neue Funktion bestellt: {{funktion}}“; Text `kein_abo`: Zahlung da und Ticket angelegt, aber kein laufendes Wartungs-Abo, Freischaltung bleibt gesperrt, endet das Abo vor Fertigstellung ⇒ volle Erstattung (T19); Text `bereits_aktiv`: „Zahlung eingegangen, aber die Funktion war schon {{zustand}}. Es wurde kein Ticket angelegt und nichts eingerichtet. Bitte prüfen: Einbau nachholen oder erstatten. Sitzung: {{sitzung}}“; `anleitung` = Knopf „Im Ticketsystem öffnen“ ⇒ `{{anleitung_url}}` (= `anleitungsLink(doc.customerId, doc.projectId)` aus B8, also `…?tab=funktionen&anleitung=<projektId>`, nur bei `mails.anleitung`); `gesundheit` = Warnzeile „Gesundheitsbereich prüfen: {{gesundheit_text}}“ (Spec 3.5; B deklariert Platzhalter und Block, sie bleiben bis D4 aus; **Block D4 füllt sie** in `teamNotice` aus `gesundheitsWarnungen`). Der T16-Betreff „…: News-Pinnwand“ ist in `test/funktionenRoutes.test.js` Z. 150, 339, 371 gepinnt und bleibt für News so. **T19** (Betreff „[Abo] Erstattung fällig: {{funktion}}-Einrichtung“, passt zur Betreff-Regel für T-Vorlagen in `mailTemplates.test.js` Z. 28): `kunde_name, email, projekt, funktion, betrag, sitzung, woid, rechnung_url`, Blöcke `zahlung_nach_ende, ticket_offen, rechnung`; **B9b ist die einzige Besitzerin von T19** (Block E legt sie nicht noch einmal an; E5 und E10 senden sie über `meldeErstattung`, B10b). Inhalt: der Kunde hat gezahlt (Betrag, Sitzung, Rechnung), das Wartungs-Abo lief nicht mehr bzw. endete vor der Freischaltung ⇒ volle Erstattung von Hand in Stripe (keine automatische Rückzahlung); Ticket mit Vermerk schließen (die Freischaltung beim Schließen schaltet dann nicht frei, weil das Dokument `disabled` ist); `zahlung_nach_ende`: „Die Zahlung ist erst nach dem Abo-Ende eingegangen, es wurde kein Ticket angelegt“; `ticket_offen`: das Ticket `{{woid}}` ist noch offen. Aufrufstellen: T16 füllt alle Werte aus `featureConfig(doc.featureKey)` (`unmatchedNotice` ohne bekannten Schlüssel: `funktion = 'Funktion'`, Rest leer); `eur(...)` und `portal_url` wie in B9a. Exportiert wird `meldeErstattung` erst in B10b.
- [ ] **Zuerst Charakterisierung** (vor dem Umbau der Vorlage): `renderMail` mit den heutigen News-Werten für T16 (Normal, `zahlung_fehlgeschlagen`, `ungeklaert`) ausführen, `subject` und `text` als Literale in `test/featureMails.test.js` einfrieren („News-Mails bleiben wörtlich“: Betreff „Neue Funktion bestellt: News-Pinnwand“ und die Texte der drei Fälle). Danach die Vorlage umbauen; derselbe Test bleibt grün. Weitere Tests: „Termine-T16“ (enthält weder „News“ noch „Beitr“; Betreff „Neue Funktion bestellt: Terminplaner“); „Platzhalter-/Blocklisten exakt“ (`loadTemplate('T16')` und `loadTemplate('T19')`: `.placeholders`/`.blocks` deepEqual den Listen oben, `mailTemplates.test.js`-Schleifen decken Rendern mit/ohne Blöcke ab); „T19 rendert beide Fälle“ (nur `zahlung_nach_ende`, nur `ticket_offen`, mit Rechnungsknopf; `problems` leer; Footer „Ticketsystem“); „T16 gesundheit“ (Block aus ⇒ kein Warntext, Block an mit `gesundheit_text` ⇒ der Text steht in der Mail; `anleitung_url` trägt `&anleitung=<projektId>`).
- [ ] FAIL → implementieren → PASS → `npm test` (alle vorhandenen Mail-Tests in `funktionenRoutes.test.js` Z. 150, 166, 199, 238, 339, 371 grün) → Commit „Mail T16 je Funktion, neue Vorlage T19 Erstattung faellig“
### Task B10a: Zahlungseingang in jedem Zustand 1 – `markPaid` meldet den Fall, Meldemarker (Review Focus #1)
**Files:** Modify `server/services/siteFeatures.js` (`markPaid` Z. 243–270); Tests `test/siteFeatures.test.js` (neue `fall`-Tests).
**Consumes:** B1, B8 (Ticket), A11 (`portalFeatures.zahlungMeldung` im Schema; `activate()` lässt es unberührt, nur E8 setzt es beim Zurücksetzen zurück).
**Produces:** `markPaid(…) ⇒ { doc, created, fall }` mit `fall ∈ null | 'bereits_aktiv' | 'abgeschaltet' | 'abo_ende'`. Unter der vorhandenen Sperre `feature-paid:<sessionId>` gilt für ein per Sitzung gefundenes Dokument: `ordered` oder fortsetzbares `setup` ⇒ wie bisher (`created: true`, `fall: null`); `off` ⇒ unverändert `created: false`, `fall: null` (die Übernahme `adoptPaidSession` bleibt zuständig); **jeder andere Zustand** (`active`, `disabled`, `setup` mit Ticket): hat das Dokument `purchaseStatus !== 'paid'`, wird die Zahlung **einmalig verbucht** (`purchaseStatus: 'paid'`, `unlockedByPurchase: true`, `stripeInvoiceId`, `invoiceUrl`, `adminNote` neuester Eintrag `<ISO> System: Zahlung eingegangen (Sitzung …), nichts eingerichtet`, 300 Zeichen) und im **eigenen Feld `zahlungMeldung`** der noch zu meldende Fall vermerkt (`active` und `setup` mit Ticket ⇒ `'bereits_aktiv'`; `disabled` mit `disabledReason === 'abo_ende_vor_einrichtung'` ⇒ `'abo_ende'`; sonstiges `disabled` ⇒ `'abgeschaltet'`). `fall` ist der Wert von `zahlungMeldung`, solange er einer dieser drei ist; nach der erfolgreichen Meldung steht dort `'gemeldet'` (B10b) und `fall` ist `null`. Eine **Wiederholung** des Ereignisses (Stripe-Retry, `completed` + `async_payment_succeeded` bei SEPA) bucht deshalb nichts doppelt (`purchaseStatus` bleibt `'paid'`), liefert den Fall aber erneut, solange die Meldung noch nicht raus ist. Eine normal bezahlte Bestellung (Übergang aus `ordered`) hat `zahlungMeldung ''`: ihr Folgeereignis liefert `fall: null`, auch wenn das Dokument inzwischen `active` ist. `purchaseStatus` wird nie mit Meldezuständen überladen (andere Stellen, z. B. die Widerrufsprüfung, lesen es).
- [ ] Tests (`siteFeatures.test.js`, `markPaid` direkt): `fall` je Zustand (`active` ⇒ `bereits_aktiv`, `disabled`/`team` ⇒ `abgeschaltet`, `disabled`/`abo_ende_vor_einrichtung` ⇒ `abo_ende`, `setup` mit Ticket ⇒ `bereits_aktiv`), jeweils `created: false`, `purchaseStatus 'paid'`, `unlockedByPurchase true`, Rechnung gespeichert, `zahlungMeldung` == Fall; zweiter Aufruf ⇒ nichts doppelt verbucht (`adminNote` unverändert), `fall` bleibt gesetzt, bis `zahlungMeldung` auf `'gemeldet'` steht, danach `null`; normal bezahltes Dokument (`ordered` ⇒ `setup` ⇒ `active`), Folgeereignis ⇒ `fall: null` und `zahlungMeldung ''`; `off` ⇒ `created: false`, `fall: null`, Dokument unverändert; zwei parallele Aufrufe ⇒ einmal verbucht.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Zahlungseingang: markPaid meldet den Fall und merkt ihn im Dokument“
### Task B10b: Zahlungseingang in jedem Zustand 2 – Meldungen T16/T19, `meldeErstattung` (Review Focus #1)
**Files:** Modify `server/services/siteFeatures.js` (`onFeaturePaid` Z. 515–552, `teamNotice`, neu `meldeErstattung`, `meldeZahlungFall`, `zahlungsNotiz`; `customerOf` und `projectName` werden exportiert). Create `test/featurePaid.test.js`.
**Consumes:** B1, B5 `laeuft`, B8 (Ticket), B9a (K15), B9b (T16-Vorlage, T19), B10a.
**Produces:** `onFeaturePaid`: `created` ⇒ K15 + T16 wie bisher; zusätzlich `feature.requiresWartungsAbo` und `await laeuft(customerId)` **nach** der Verbuchung (Lesefehler ⇒ Log, kein Block, nie 500) falsch ⇒ T16-Block `kein_abo`. `fall` `'bereits_aktiv'`/`'abgeschaltet'` ⇒ `meldeZahlungFall(doc, fall, { customer })` = **nur** T16-Block `bereits_aktiv` (`zustand` „freigeschaltet“ / „in Einrichtung“ bzw. „abgeschaltet“, `woid` leer), `console.error` mit Sitzungs-ID, keine K15. `fall` `'abo_ende'` ⇒ **nur** `meldeErstattung(doc, { customer, nachAboEnde: true })`, kein Ticket, keine K15. `meldeErstattung(doc, { customer = null, sitzung = doc.stripeSessionId, nachAboEnde = false } = {}) ⇒ Promise<{ sent: boolean, schonGemeldet?: boolean }>` sendet T19 (`betrag` = `priceLabel`, `woid`, `rechnung_url`, Blöcke `zahlung_nach_ende`, wenn `nachAboEnde` gesetzt ist oder `doc.zahlungMeldung === 'abo_ende'` (Zahlung nach dem Abo-Ende, so auch wenn der Abgleich vor dem Webhook-Versand zuerst meldet), `ticket_offen`, wenn `doc.ticketId` gesetzt ist (kein Ticket und keine späte Zahlung ⇒ keiner der beiden Blöcke), `rechnung`) und ist **die einzige** Stelle für T19: **Block E5 ruft sie aus dem Abgleich** (`nachAboEnde: false`), `erstattungMelden` o. Ä. gibt es nicht. **Idempotenz (`meldeErstattung` und `meldeZahlungFall`):** unter `withLocks(['feature-meldung:' + doc.$id])` (nie innerhalb einer `feature-paid:*`-Sperre aufrufen, `withLocks` ist nicht wiederbetretbar) das Dokument **frisch lesen**; `zahlungMeldung === 'gemeldet'` ⇒ `{ sent: false, schonGemeldet: true }` ohne Versand; sonst senden und **erst bei `sent === true`** `zahlungMeldung: 'gemeldet'` schreiben (auch wenn dort vorher ein offener Fall stand: die Wiederholung sendet kein zweites T19). `sendAdminNotice` wirft nie; die Funktionen werfen deshalb nicht selbst. **`onFeaturePaid` wirft** bei den Fällen `bereits_aktiv`/`abgeschaltet`/`abo_ende`, wenn weder `sent` noch `schonGemeldet` zutrifft: der Webhook antwortet 500, Stripe wiederholt, `markPaid` liefert den noch offenen Fall erneut (B10a) und der nächste Versuch sendet genau einmal. Die Mailfehler der Normalpfade (K15, T16 beim `created`) bleiben wie bisher gefangen. Annahme für E: der Abgleich Schritt 1b meldet T19 nur für **bezahlte** (`setup`) Dokumente; ein unbezahltes `ordered` wird nur abgeschaltet, die spätere Zahlung meldet dieser Task.
- [ ] Tests, **Review Focus #1** (Testaufbau laut Kopf; für „Abo läuft“ überschreibt der Test `sub_h` von `c1` mit `wartungDoc`; `featurePaid.test.js`, Termine, echter Bestellweg über die Route oder `markOrdered`, Webhook über `postWebhook`; Hilfsprüfung `spur()`: `Tickets + Team-Mails ≥ 1` und `Tickets ≤ 1` nach jedem Fall): (1) `ordered` + Abo läuft ⇒ `setup`, ein Ticket „Terminplaner einbauen – …“, K15 „Deine Bestellung: Terminplaner“ an den Kunden, T16 genau einmal **ohne** `kein_abo`-Text; (2) `ordered` + Abo beendet (`canceled`) ⇒ `setup`, Ticket, K15, T16 mit `kein_abo`-Text; (3) `laeuft` scheitert (`failNext` auf `subscriptions`) ⇒ Webhook 200, Ticket, K15, T16 ohne `kein_abo`; (4) Staff aktiviert die `ordered`-Bestellung (`adminActivate`), dann Zahlung ⇒ Dokument bleibt `active`, **kein** Ticket, **keine** K15, T16 mit `bereits_aktiv`/„freigeschaltet“ genau einmal, `purchaseStatus 'paid'`, `unlockedByPurchase true`, Rechnung gespeichert, `adminNote` beginnt mit der System-Zeile, `console.error` mit Sitzungs-ID, `zahlungMeldung 'gemeldet'`; zweite Zustellung desselben Ereignisses **und** zwei parallele Zustellungen (`Promise.all`) ⇒ weiterhin genau ein T16; (5) Team schaltet die `ordered`-Bestellung ab, dann Zahlung ⇒ `disabled` bleibt (`disabledAt` unverändert), T16 „abgeschaltet“ einmal, Wiederholung stumm; (6) `disabled` mit `disabledReason: 'abo_ende_vor_einrichtung'` (geseedet) + Zahlung ⇒ T19 „[Abo] Erstattung fällig: Terminplaner-Einrichtung“ einmal mit Block `zahlung_nach_ende`, **kein** T16, kein Ticket, keine K15, Wiederholung stumm (auch `async_payment_succeeded` nach `completed`); (7) Stripe-Sitzung läuft ab (`off`), Zahlung kommt doch ⇒ Übernahme, `setup`, ein Ticket, K15 + T16 je einmal; (8) Neubestellung nach Ablauf, die **alte** Sitzung zahlt ⇒ Übernahme in die laufende Bestellung, ein Ticket; die **neue** Sitzung zahlt danach ⇒ T16 „ungeklärt“ (bestehendes Verhalten), kein zweites Ticket; (9) SEPA: `completed` ohne `paid` ⇒ nichts, `async_payment_succeeded` ⇒ wie (1), `async_payment_failed` ⇒ T16 `zahlung_fehlgeschlagen` mit „Terminplaner“; (10) **Versandfehler in den Fallpfaden:** T16/T19 scheitert beim Versand (SMTP wirft) ⇒ Webhook **500** (Stripe wiederholt), die Zahlung ist gebucht (`purchaseStatus 'paid'`, `adminNote`-Spur), `zahlungMeldung` noch offen; die Wiederholung (Versand geht) ⇒ genau eine Mail, `zahlungMeldung 'gemeldet'`, weitere Wiederholungen stumm; K15/T16 beim normalen `created` scheitern weiterhin gefangen (Webhook 200); (11) News unverändert (alle bisherigen Fälle in `funktionenRoutes.test.js` grün, zusätzlich News + Staff-Aktivierung + Zahlung ⇒ T16 `bereits_aktiv`, die heute stille Lücke); (12) `meldeErstattung` direkt (der Aufruf aus Block E5): bezahltes `setup`-Dokument ⇒ ein T19 mit Block `ticket_offen`, `zahlungMeldung 'gemeldet'`, zweiter und paralleler Aufruf ⇒ `schonGemeldet`, nie zwei Mails; offener Fall `abo_ende` + Aufruf des Abgleichs + Webhook-Wiederholung ⇒ insgesamt genau ein T19; Mail scheitert ⇒ `{ sent: false }`, kein Marker, kein Wurf.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Zahlungseingang in jedem Zustand: nie Geld ohne Spur, nie doppelt (T16/T19)“
### Task B11: Freischaltung über das Register (`activateClosedTickets`, `freischaltbar`) – alleinige Besitzerin
**Files:** Modify `server/services/siteFeatures.js` (`activateClosedTickets` Z. 359–387; `findActiveBySiteKey` unverändert), `server/services/featureRegistry.js` (`ticketFreischaltungKeys`, Standard-Haken `freischaltbar` für `termine`, mit dynamischen Importen von `kontext.js` und `aboStatus.js`), `test/siteFeatures.test.js` (**Z. 235**, Test „activateClosedTickets bleibt bei News (TP2)…“: er pinnt heute, dass nur `news` geöffnet wird; er wird auf „Funktionen mit `ticketFreischaltung`; News verhält sich unverändert“ umgestellt). Create `test/featureFreischaltung.test.js`. Block D6 ändert `activateClosedTickets` **nicht** mehr.
**Consumes:** B1, B5 `laeuft`, B9a (K16 je Funktion), `ladeEinstellung`.
**Produces:** `ticketFreischaltungKeys() ⇒ string[]` (Register-Schlüssel mit `ticketFreischaltung: true`); `activateClosedTickets({ now, customerId })` filtert `Query.equal('featureKey', ticketFreischaltungKeys())` statt fest `news` und ruft **unter der Sperre `feature-activate:<id>` nach dem frischen Lesen** den optionalen Haken `featureHaken(key).freischaltbar(doc) ⇒ Promise<boolean>` (der Standard für `termine` darf einen zweiten Parameter `{ abo }` annehmen, D6); `false` oder Fehler ⇒ Dokument bleibt `setup` (Log; **einmal** ein `adminNote`-Eintrag „Freischaltung wartet: <Grund>“, **kein** Mailversand alle 5 Minuten – der T16-Hinweis `kein_abo` kam bei der Zahlung (B10b), T19 vom Abgleich (E5)). Termine-Standardhaken: Einstellung vorhanden (`ladeEinstellung(doc.$id)`) **und** `laeuft(doc.customerId)` (Spec 4.5: Aktivierung bleibt gesperrt, bis ein Abo läuft). Block D6 zieht diese Prüfung nach `termine/freischaltung.js` (`freischaltHindernis(doc, { abo })`) und der Standardhaken delegiert dorthin; die Tests dieses Tasks bleiben dabei unverändert grün. `adminActivate` (Staff-Knopf) bleibt Sache von Block D/E9/WOMS.
- [ ] Tests (Testaufbau laut Kopf, `seedFixtures`-Falle: Wartungs-Abo für `c1` durch Überschreiben von `sub_h`): „News unverändert“ (bisherige Tests grün, **Z. 235 wie oben angepasst**); „Termine: Ticket geschlossen + Einstellung + Abo ⇒ `active`, K16 „Dein Terminplaner ist bereit“ mit Knopf „Termine öffnen“ und Link `…#termine`“; „kein Abo ⇒ bleibt `setup`, `adminNote` enthält „Freischaltung wartet“, zweiter Lauf schreibt keinen zweiten Eintrag und sendet keine Mail; Abo kommt zurück ⇒ nächster Lauf aktiviert und sendet K16“; „keine Einstellung ⇒ bleibt `setup`“; „Abo-Lesefehler ⇒ bleibt `setup`, `runActivation` wirft nicht, andere Dokumente werden trotzdem bearbeitet“; „Abo endet zwischen Listenlesen und Sperre (der Haken meldet beim frischen Lesen `false`) ⇒ bleibt `setup`“; „Ticket `Cancelled` ⇒ bleibt `setup`“; „`customerId`-Filter“; „Hook gilt nur für seine Funktion“ (News-Dokument im selben Lauf wird aktiviert); „parallele Läufe“ (`runActivation()` und `runActivation({ customerId })` ⇒ ein K16); „Dokument anderer Funktion mit `setup` (z. B. `analyse`) bleibt unberührt“.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Freischaltung ueber das Register mit Pruefung von Einstellung und Abo“
### Task B12: Register-Ansicht im Kundenportal (`GET /api/funktionen`, News-Seite)
**Files:** Modify `server/routes/funktionen.js` (`GET /` Z. 88–114), `public/funktionen.js` (`PRICE` Z. 9, `newsTile` Z. 81–85, `initNews` Z. 220, `renderBooking` Z. 261–297), `test/funktionenRoutes.test.js` (`liste_zustaende` Z. 60ff.), `test/funktionenTiles.test.js`.
**Consumes:** B1 `oeffentlicheAnsicht`, B7 (Checkout-Vertrag).
**Produces:** `GET /api/funktionen` ⇒ wie bisher plus `features: { news: <Ansicht>, termine: <Ansicht> }` (`oeffentlicheAnsicht`, auch wenn `termine.verfuegbar` falsch ist: nur öffentliche Bestelltexte); `setupEstimate`/`consentText` bleiben als News-Alias. Zusätzlich `ustHinweis: config.ustHinweis` (die einzige Stelle für den Preishinweis auf Bestellseiten; F2/F8 ergänzen ihn nicht noch einmal). Die Bestellseite (Block C8) bekommt dieselben Registerwerte über `bestellungAnsicht()` (aus `oeffentlicheAnsicht`, C2) in `GET /api/termine/einstellungen → bestellung`, nicht aus dieser Liste, und schickt `zustimmung: { version, haken }` an den Checkout; „Zahlung fortsetzen“ schickt `{ projectId, fortsetzen: true }` (B6/B7: die Route liefert die offene Sitzung nur, wenn sie den Nachweis dazu kennt, sonst 409 `zustimmung_fehlt` und die Seite zeigt wieder das Formular). `public/funktionen.js`: Preis und Zustimmungstext der News-Seite aus `features.news` (`priceLabel`, `consent.items[0].text`, `consent.knopf`), Rückfall auf die bisherigen Konstanten, wenn `features` fehlt; News sendet weiter `{ projectId, consent: true }` (Alias B6), das Verhalten bleibt gleich; der Wortlaut „inklusive Mehrwertsteuer“ (Z. 272) bleibt hier stehen (Korrektur in Block F).
- [ ] Tests: `liste_zustaende` + „`features.news.priceLabel === '30 €'`, `features.termine.priceLabel === '149 €'`, `ustHinweis === config.ustHinweis`, Items vollständig, kein Feld mit Funktion/`ticket`/`mails`, `features.termine` auch bei `verfuegbar: false`“; `funktionenTiles.test.js`: „Rückfall ohne `features`“ (die bisherigen `render`-Hilfen liefern nur `{ projects }`, Tests unverändert grün) und „Preis aus dem Register“ (`features.news.priceLabel: '31 €'` ⇒ Kachel-Knopf „Einrichten lassen · 31 €“); statische Prüfung „News postet weiter `consent: true` an `/api/funktionen/news/checkout`“.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen-Seite: Preis und Bestelltexte aus dem Register“
---
**Abschlussprüfung Block B (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); `command grep -rn "STUB (Block" server` zeigt genau zwei Treffer (`server/services/termine/fragebogen.js`, `server/services/termine/ticketDetails.js`), `aboStatus.js` trägt die Marke `// KERNVERSION (Block B) – Block E baut aus`; Blöcke C, D und E entfernen ihre Marken. Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. Festverdrahtetes News ist nur noch im Register: `command grep -rn "dashboard.html#news\|config.newsSetup\|DISABLED_MESSAGE" server` trifft ausschließlich die Definitionen in `server/config.js`, den Alias-Export in `server/services/siteFeatures.js` und den Katalog in `stripeBilling.js` (`catalogFor`); `config.funktionen` wird nur noch in `server/routes/funktionen.js` (GET-Alias) gelesen. Die Beschriftungen „News-Pinnwand“ in `routes/abo.js` (Widerruf, Block F/4.7), `retention.js` und `routes/admin/funktionenAdmin.js` sind **nicht** Teil von B.
**Übergaben an andere Blöcke (verbindlich für deren Tasks):**
- **A:** `disable()` idempotent mit `reason`, das Schema (`portalFeatures.disabledReason`, `aboEndeMailAt`, `zahlungMeldung`) im neuen Skript `feature-setup.mjs` (A11; B3 ergänzt dort `featureConsents`) und die explizite Liste statt der Wildcard liefert A; B berührt `activateClosedTickets` (B11), nicht `disable`/`activate`. B-Tests seeden `disabledReason` nur.
- **E:** erweitert `aboStatus.js` (Modify, Marke `KERNVERSION` entfällt; gleiche drei Funktionen, `aboMemo` je Lauf, dazu `aboLage` und `aboMemo().lage` aus E1; die Reihenfolge der Gründe `kein_abo` → `gekuendigt` → `nicht_aktiv` steht hier fest); `aboMeldung` bleibt in `featureOrder.js` (E definiert keine eigene); ruft `meldeErstattung(doc, { customer, nachAboEnde: false })` (B10b, einzige T19-Stelle) aus dem Abgleich 1b (nur für bezahlte `setup`-Dokumente) und schickt K22/K23; `unlockedByPurchase === true` ist nach B10a auch bei „Zahlung nach Staff-Aktivierung“ gesetzt (diese Dokumente unterliegen dann dem Abgleich); E8 setzt beim Zurücksetzen `zahlungMeldung` auf `''`.
- **C:** ersetzt den Stub `termine/fragebogen.js` (`bestellpruefung`, `BRANCHEN`, …) und darf `ladeEntwurf` in `featureOrder.js` durch seinen Lader ersetzen (Signatur `(featureId) ⇒ { einstellung, fragebogen }` bleibt); die Bestellseite `funktion-bestellen.js` liest `bestellung` aus `GET /api/termine/einstellungen` (Registerwerte über `oeffentlicheAnsicht`), schickt `{ projectId, zustimmung: { version, haken } }`, zeigt bei 409 `fassung_veraltet` die neue Fassung aus `body.consent` und bei `bestellung_offen` die Punkte aus `body.offen`; „Zahlung fortsetzen“ sendet `{ projectId, fortsetzen: true }` (409 `zustimmung_fehlt` ⇒ Formular); der Rücksprung liefert `#termine?bestellt=1&projekt=<id>`, die Abbruch-Adresse `#termine?projekt=<id>`; die Abo-Codes sind `kein_abo` | `gekuendigt` | `nicht_aktiv`. Der T16-Warnblock `gesundheit` (Spec 3.5) ist in B9b deklariert und wird von Block D4 gefüllt (nicht von C).
- **D:** ersetzt den Inhalt von `termine/ticketDetails.js` (`termineTicketDetails(ctx)`, `ctx = { feature, doc, project, customer }`, `now` im Rumpf), füllt den T16-Block `gesundheit` (D4), zieht die Freischalt-Prüfung nach `termine/freischaltung.js` (D6; B11 bleibt Besitzerin von `activateClosedTickets`), blendet im WOMS „Aktivieren (ohne Zahlung)“ für Termine in `ordered`/`setup` aus (der Fall `bereits_aktiv` aus B10b bleibt als Netz); `anleitungsLink` kommt aus `featureRegistry.js` (B8), T16 und Ticket verlinken also schon mit `&anleitung=<projektId>`; `createFeatureTicket` ändert D nicht (D5 entfällt).
- **F:** ersetzt alle `ENTWURF`-Platzhalter im Termine-Eintrag (`consent`-Texte und -Fassung bei bereits festen IDs, `leistungsbeschreibung`, `avv`, `widerruf`, `mails.danach/bereit` ausgenommen: das ist die Mailrunde des Inhabers; `mails.preisHinweis` bleibt aus `config.ustHinweis`; die K15-Anhänge hängt F8 direkt in `onFeaturePaid` an (`featureLegalAttachments(doc.featureKey)`), nicht über einen Registerhaken), korrigiert die News-Texte (§ 356 Abs. 5 Nr. 2, Wertersatz, § 19 UStG in `consent.items[0].text`, `mails.preisHinweis`, `public/funktionen.js:272`) und **hebt dabei `consent.version` an** (der Nachweis speichert die Fassung; die Pins in B1/B7/B9a/B9b lesen `config`, F2 passt nur den Einfrier-Test in `featureMails.test.js` an); Widerruf je Funktion (4.7: `withdrawFeature`-Frist je Funktion, K04/T09 `{{produkt}}`/`{{preis}}`, `widerrufen.html`) ist nicht in B, B4 liefert dafür `featureKey` und `vertragAt` aus `paidFeatureOrders` (F9 nutzt genau diese); der `todo`-Test aus B1 muss mit F1/F7 grün werden.
---
## Block E: Abo-Kopplung
Ein gemeinsamer Abo-Baustein (`aboStatus.js`) trägt Analyse und Terminplaner. Der tägliche Abgleich `gleicheAboAb` (Spec 6 und 4.6) schaltet den Terminplaner ab, wenn das Wartungs-Abo endet, meldet fällige Erstattungen (T19), holt K22 nach und schaltet innerhalb der Frist wieder ein (K23); die Löschung nach 30 Tagen prüft vorher frisch und setzt das Dokument auf `off` zurück. **Reihenfolge:** A → B → E → C → D → F. B bringt `aboStatus.js` als Kernversion (B5), A11 `disabledReason`/`disable()`; E1 erweitert die Kernversion, E3–E12 folgen in dieser Reihenfolge. E11 und E12 liegen vor C und D: C4a/C4b/C10b/D2 bzw. D9/D10 setzen auf deren Stand auf und erhalten ihn. Der Abgleich liest nur den Appwrite-Spiegel `subscriptions`, nie Stripe; nichts läuft gegen Produktion.
**Festlegungen des Blocks** (gelten für E4–E10): stehen im Kopf, Abschnitt „Grundlagen der Blöcke“.
---
### Task E1: Abo-Baustein `aboStatus.js` ausbauen, Analyse darauf umgestellt
**Files:** Modify `server/services/aboStatus.js` (B5-Kernversion; die Marke `// KERNVERSION (Block B) – Block E baut aus` entfällt), `test/aboStatus.test.js` (B5, **erweitern statt neu anlegen**); Modify `server/services/analyse.js` (Z. 11: Import `ACTIVE_STATUSES, getCurrentSubscription` entfällt, neu `laeuft` aus `./aboStatus.js`; Z. 43–46 `hasWartungsAbo` wird `(id) => laeuft(id, { altbestand: false })`), `test/analyse.test.js` (+1 Test; bestehende Zeilen bleiben unberührt).
**Consumes:** `getCurrentSubscription` (`stripeBilling.js:178`), `ACTIVE_STATUSES` (`:24`), `listContractEvents` (`contractEvents.js:120`).
**Produces** (Namen und Signaturen aus dem Plankopf unverändert, Zusätze mit „+“; ein privater Lader `lage(customerId, { altbestand })` liefert `{ laeuft, art, endedAt, abo }`, **alle** Prädikate bauen darauf, damit Bestell-Gate, Freischaltung, T16 `kein_abo` und Abgleich nie verschieden urteilen):
- `laeuft(customerId, { altbestand = true } = {}) ⇒ Promise<boolean>`: aktuelles Abo ist ein Wartungs-Abo mit Status ∈ `ACTIVE_STATUSES` (active, trialing, past_due; vorgemerkte Kündigung zählt noch als laufend). Wirft bei jedem Lesefehler, gibt bei einem Fehler nie `false`. **Zwei Regeln:** `altbestand: true` (Vorgabe, Terminplaner) ist die großzügige Regel: Produktschlüssel `productKey || 'wartung'` wie `routes/abo.js:68` **und** die Wechsel-Regel unten; `altbestand: false` (Analyse) ist die heutige Regel unverändert: `=== 'wartung'` (ein Spiegel ohne `productKey` zählt nicht), keine Ereignisabfrage (Spec: „`analyseEligibility` ändert sein Verhalten nicht“). Der Unterschied betrifft nur Spiegel ohne `productKey` und den Wechsel-Widerruf; `stripeBilling.js:246` schreibt `productKey` immer, nur Altbestand könnte es fehlen (Produktion ungeprüft ⇒ offene Frage).
- `bestellbar(customerId) ⇒ Promise<{ ok: boolean, grund?: 'kein_abo' | 'gekuendigt' | 'nicht_aktiv' }>`: **Reihenfolge der Gründe (einmal für alle Blöcke, wie B5):** nicht `laeuft` (großzügige Regel inkl. Wechsel-Regel) ⇒ `kein_abo`; `cancelAtPeriodEnd || cancelAt` (Muster `routes/abo.js:96`) ⇒ `gekuendigt`; Status ≠ `active` (trialing, past_due) ⇒ `nicht_aktiv`; sonst `{ ok: true }`. Wirft bei Lesefehler.
- `aboMemo() ⇒ { laeuft(id), bestellbar(id), lage(id) }`: je Aufruf von `aboMemo()` ein eigener Speicher (Promise je `customerId`, eine Ablehnung bleibt für diesen Lauf stehen). Nie für Prüfungen unter einer Sperre.
- + `aboLage(customerId) ⇒ Promise<{ laeuft: boolean, art: 'laeuft' | 'wechsel_widerrufen' | 'hosting' | 'beendet', endedAt: string | null }>` (= der private Lader mit `altbestand: true`, kein eigener Zweig): **Wechsel-Regel:** sagt der Spiegel „Wartung läuft“ und hat das jüngste `wechsel`-Ereignis dieses Abos Status `widerrufen` ⇒ `laeuft: false`, `art: 'wechsel_widerrufen'`. Grund: Der Widerruf eines Wechsels (Hosting→Wartung) setzt den Spiegel nicht zurück, das tut das Team von Hand (Kommentar `routes/abo.js:992`); ohne diese Regel schaltete der tägliche Abgleich einen per Sofortpfad (E9) beendeten Terminplaner am nächsten Tag wieder ein, **und** ein Kunde mit widerrufenem Wechsel dürfte bestellen (149 €) und freischalten, der Abgleich schaltete am Folgetag ab und löste T19 aus. Ereignisse werden nur gelesen, wenn der Spiegel „Wartung läuft“ sagt (und nur bei `altbestand: true`). Ohne laufendes Wartungs-Abo: `art: 'hosting'`, wenn das aktuelle Abo ein laufendes Hosting-Abo ist, sonst `'beendet'`; `endedAt` = `endedAt || canceledAt` des Abos oder `null`.
- `aboMeldung` gibt es hier **nicht** (die einzige Quelle der Abo-Hinweistexte ist `featureOrder.js`, B6).
- [ ] Tests `aboStatus.test.js` (Testaufbau und `seedFixtures`-Falle laut Kopf; die B5-Fälle bleiben; Mock: `subscriptions` und `contractEvents` mit `schemaKeys`, Dokumente aus `subscriptionDoc`/`wartungDoc`/`hosting2Doc` in `test/support/fixtures.js`):
- `laeuft`: Wartung `active`/`trialing`/`past_due` ⇒ true; `canceled`/`unpaid`/`incomplete_expired` ⇒ false; Hosting aktiv ⇒ false; kein Dokument ⇒ false; aktives Abo mit `cancelAtPeriodEnd: true` ⇒ **true**; zwei Dokumente (altes `canceled`, neues `active`) ⇒ true.
- **Altbestand-Unterschied:** Dokument ohne `productKey` ⇒ `laeuft(id)` true, `laeuft(id, { altbestand: false })` false.
- **Lesefehler:** `aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })` ⇒ `laeuft`, `bestellbar`, `aboLage` lehnen ab (nie `false`/`{ ok: false }`).
- `bestellbar`: Tabelle (active ohne Kündigung ⇒ ok; `cancelAtPeriodEnd`, nur `cancelAt` in der Zukunft ⇒ `gekuendigt`; trialing, past_due ⇒ `nicht_aktiv`; canceled, Hosting, kein Abo ⇒ `kein_abo`; trialing **und** past_due **mit** Kündigung ⇒ `gekuendigt`, Kündigung vor Status).
- `aboLage`: `art` je Fall (`laeuft`, `hosting` mit aktivem Hosting-Abo, `beendet` mit `endedAt`, `wechsel_widerrufen` mit `wechsel`-Ereignis `widerrufen`; ein **neueres** `wechsel`-Ereignis `erledigt` hebt es auf ⇒ `laeuft`); bei `beendet` und `hosting` keine Ereignis-Abfrage (Anfragen zählen per Wrapper um `globalThis.fetch` wie `mitEinwurf` in `termineJobs.test.js:234`).
- **Wechsel-Regel in allen Prädikaten:** Spiegel Wartung `active` + jüngstes `wechsel`-Ereignis `widerrufen` ⇒ `laeuft(id)` false, `bestellbar(id)` `{ ok: false, grund: 'kein_abo' }`, `aboLage.art 'wechsel_widerrufen'`; `laeuft(id, { altbestand: false })` bleibt true und fragt **keine** Ereignisse ab. Integration: Checkout (B6) ⇒ 409 `kein_abo`, kein Dokument entstanden; `activateClosedTickets` (B11) mit Ticket `Closed` ⇒ Dokument bleibt `setup`; `adminActivate` (E9) ⇒ 409.
- `aboMemo`: drei Aufrufe für denselben Kunden ⇒ eine Abfrage auf `subscriptions`; zweiter Kunde ⇒ eine weitere; ein neuer `aboMemo()` liest wieder; eine Ablehnung wird im selben Memo nicht neu gelesen (keine zusätzliche Anfrage).
- `analyse.test.js` neuer Test „Altbestand ohne productKey: Analyse bleibt streng (`kein_abo`), `aboStatus.laeuft` großzügig“ (pinnt den Unterschied).
- [ ] Beweis „Analyse unverändert“: `node --test test/analyse.test.js test/analyseRoutes.test.js test/analyseView.test.js test/funktionenRoutes.test.js test/publicForms.test.js` vor der Umstellung grün, danach mit **keiner geänderten bestehenden Testzeile** grün (`git diff test/analyse*.test.js` zeigt nur den neuen Test); `grep -n "ACTIVE_STATUSES\|getCurrentSubscription" server/services/analyse.js` leer.
- [ ] FAIL → `aboStatus.js` ausbauen (ein privater Lader `lage(customerId, { altbestand })` über `getCurrentSubscription` und `listContractEvents`; die Prädikate darauf; Memo je Aufruf in einer `Map` von Promises) → PASS → `npm test` → Commit „Abo-Baustein aboStatus.js: Wechsel-Regel in laeuft/bestellbar, aboLage; Analyse nutzt ihn“
### Task E2: Anzeige von Abschaltgrund und Löschfrist (`featureView`, `frist.js`)
**Files:** Create `server/services/termine/frist.js`; Modify `server/services/siteFeatures.js` (`featureView` Z. 116); Tests `test/siteFeatures.test.js`, `test/funktionenTermine.test.js` (Z. 158). **Nicht Teil von E2** (liegt in A11): idempotentes `disable()`, `DISABLED_REASONS`, `activate()`-Reset, `disableCustomerFeatures(reason)`, `customerAdmin.js` `kunde_geloescht`, die Schema-Attribute (`feature-setup.mjs`) und deren Tests.
**Consumes:** A11 (`disabledReason`, `disabledAt`, `aboEndeMailAt`, `reason`).
**Produces:** `featureView` liefert zusätzlich `disabledReason`, `disabledAt` und `loeschungAm` (Berliner Tag `'JJJJ-MM-TT'`, nur bei `state === 'disabled'` und vorhandenem `disabledAt`, sonst `null`; bei anderen Zuständen alle drei `null`). `frist.js`: `LOESCHFRIST_TAGE = 30`, `loeschzeit(disabledAt) ⇒ Date | null` (`disabledAt` + 30 × 24 h, dieselbe Rechnung wie `jobs.js:50`), `loeschTag(disabledAt) ⇒ 'JJJJ-MM-TT' | null` (Berliner Kalendertag über `ausUtc` aus `zeit.js`); nur Importe aus `zeit.js`/`util.js` (kein Kreis mit `siteFeatures.js`). F4/F5 prüfen ihre Fristsätze gegen `LOESCHFRIST_TAGE`. Sperre und Mails kommen später (E4ff.).
- [ ] Tests: `featureView`/`GET /api/funktionen` (`funktionenTermine.test.js:158`): disabled+`abo_ende` mit `disabledAt: '2026-10-04T23:30:00.000Z'` ⇒ `loeschungAm === '2026-11-04'` (Berliner Tag, nicht der UTC-Tag `2026-11-03`); `team` ⇒ Grund `team` und ebenfalls ein Datum; active ⇒ alle drei Felder `null`; Altbestand ohne Grund ⇒ `disabledReason: null`. `frist.js`: `loeschTag('2026-10-04T23:30:00.000Z') === '2026-11-04'`; `loeschzeit` = exakt 30 × 24 h später; ungültig/leer ⇒ `null`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abschaltgrund und Loeschfrist in der Funktionsansicht (featureView, frist.js)“
### Task E3: Mailvorlagen K22, K23 und Block in K02/K03
**Files:** Create `server/mail-templates/K22 Terminplaner beendet.html`, `K23 Terminplaner laeuft wieder.html` (Layout wörtlich aus `K19 Analyse beendet.html`, Kopfkommentar im Format der vorhandenen); **T19 legt B9b an**, nicht E; Modify `server/mail-templates/K02 Kuendigungsbestaetigung.html`, `K03 Eingang Kuendigung.html`, `server/routes/abo.js` (K02-Aufruf Z. 469: Block `funktionen: isWartung(sub)`; K03-Aufruf Z. 937: `funktionen: !storage && isWartung(sub)`), `test/mailTemplates.test.js` (Z. 8–11: nur `ids('K', 23)`; die T-Liste bleibt `ids('T', 19)` plus `'T20'` aus B9b/A10a1, nicht überschreiben; Kopfkommentar um „K22, K23 Terminplaner beendet/läuft wieder“ ergänzen).
**Consumes:** `renderMail`/`loadTemplate` (`mailTemplates.js`). Texte sind Entwurf für die Mail-Überarbeitung des Inhabers (Spec 2); nur Platzhalter und Blöcke sind verbindlich.
**Produces:**
- **K22** Betreff `Dein Terminplaner wurde beendet – {{projekt}}`; Platzhalter `kunde_name, projekt, enddatum, loeschdatum, preis, portal_url`; Blöcke `abo_ende` (Wartungs-Abo beendet), `hosting` (Hosting-Abo reicht nicht), `vor_einrichtung` (Abo endete vor der Einrichtung, Einrichtung wird voll erstattet, nennt `{{preis}}`), `daten` (Kasten „Was jetzt gilt“, nur für die ersten beiden: bis `{{loeschdatum}}` ansehen und CSV-Export im Portal, Gäste können ihre Termine weiter absagen, bestehende Termine bleiben eingetragen und Erinnerungen an Gäste laufen bis zur Löschung weiter, **bitte Gäste selbst informieren**, ab `{{loeschdatum}}` Löschung, **Rückkehr:** läuft bis dahin wieder ein Wartungs-Abo, schalten wir ihn automatisch und ohne Gebühr wieder ein; nach der Löschung braucht es neuen Fragebogen und neue `{{preis}}`). Du-Form.
- **K23** Betreff `Dein Terminplaner läuft wieder – {{projekt}}`; Platzhalter `kunde_name, projekt, portal_url`; keine Blöcke.
- **T19** gehört B9b (eine Vorlage: Platzhalter `kunde_name, email, projekt, funktion, betrag, sitzung, woid, rechnung_url`, Blöcke `zahlung_nach_ende, ticket_offen, rechnung`); E4/E5/E10 senden sie ausschließlich über `meldeErstattung` (B10b).
- **K02/K03:** neuer Block `funktionen` (im Kopfkommentar „Optionale Blöcke“ ergänzen; in K03 **innerhalb** von `abo`): „Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm. Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.“ Sie-Form, weil K02/K03 selbst Sie-Form sind (Abweichung vom Du-Grundsatz, offene Frage).
- [ ] Tests (`mailTemplates.test.js`): `templateIds()` = G01–G08, K01–K23, T01–T20 (T19/T20 aus B9b/A10a1); die schleifenweisen Tests (Kopfkommentar = `<title>`, deklarierte = verwendete Platzhalter/Blöcke, Rendern ohne Reste mit allen Blöcken an/aus, WEBklar-Logo) laufen automatisch für die zwei neuen IDs K22 und K23. Neu: `K22` je Variante mit nur diesem Block an (`abo_ende`: „Wartungs-Abo“ und kein „erstatten“; `hosting`: „Hosting-Abo“; `vor_einrichtung`: enthält den Wert von `preis` und „erstatten“, **kein** Löschdatum-Kasten; `daten` zeigt `loeschdatum` und den Satz zu den Gästen); K22/K23 ohne `Sie`/`Ihr` (Du-Form); K23 nennt `projekt` und führt zu `portal_url`; `K02`/`K03`: `funktionen` an ⇒ Text enthält „Terminplaner“, aus ⇒ nicht; K03 mit `abo: false, funktionen: true` ⇒ Text fehlt (Speicher-Kündigung); bestehende K02-Hosting-Prüfung (`/Wartungs-Abo/` darf nicht vorkommen, Z. 388) bleibt grün.
- [ ] FAIL → Vorlagen schreiben, Aufrufstellen in `routes/abo.js` ergänzen → PASS → `npm test` → Commit „Mails K22, K23 und Hinweis auf das Ende der Funktionen in K02/K03“
### Task E4: `gleicheAboAb` Schritt 1 (aktive abschalten) und Schritt 2 (K22 nachholen)
**Files:** Create `server/services/termine/aboAbgleich.js`, `test/termineAboAbgleich.test.js`; Modify `server/services/siteFeatures.js` (`customerOf` und `projectName` aus Z. 420–435 exportieren statt ein drittes Mal zu kopieren; B10b hat sie schon exportiert, dann entfällt der Eintrag).
**Consumes:** E1 `aboMemo`, `aboLage`; A11 `disable({ reason })`; E2 `loeschzeit`, `loeschTag`; E3 K22; `listAllDocuments`, `withLocks`, `getDocument`/`updateDocument` (`appwriteAdmin.js`), `datumKurz` (`termine/zeit.js:120`), `config.features.termine.priceLabel` (Block B, „149 €“; Wert für `{{preis}}` in K22).
**Produces:** `gleicheAboAb(now) ⇒ Promise<{ geprueft, abgeschaltet, wiederEin, mails, ausgenommen, fehler }>` (wirft nie; nur Zählungen). `geprueft` = bewertete Dokumente (aktiv mit `unlockedByPurchase === true`, `ordered`/`setup`, `disabled` mit Grund `abo_ende*`); Pilot-/Kulanz-Dokumente zählen nicht dort, sondern in `ausgenommen` (aktive Dokumente mit `unlockedByPurchase !== true`: der Abgleich fasst sie nicht an, der Tagesbericht macht sie für den Inhaber sichtbar; Kanzleifrage Q7). Intern/exportiert für E5/E9: `meldeAboEnde(doc, { lage, now }) ⇒ Promise<boolean>` – **der** gemeinsame Melder für das Ende (Schritt 2 und der Sofortpfad E9 rufen nur ihn, nie `meldeErstattung` oder K22 einzeln). E4 baut ihn für den Grund `abo_ende` (K22 und Marker); E5 erweitert dieselbe Funktion um `abo_ende_vor_einrichtung` (T19, K22, Marker). Rückgabe `true` = nichts mehr offen (Marker gesetzt oder nichts zu melden), `false` = Versand gescheitert (der nächste Tag wiederholt).
Ablauf: **eine** `listAllDocuments(portalFeatures, [Query.equal('featureKey', 'termine'), Query.notEqual('state', 'off')])` (die vielen `off`-Entwürfe bleiben ungelesen; `notEqual` mit Einzelwert aus A2, der Rest der Filter im Code), gruppiert je `customerId`, ein `memo = aboMemo()`; je Kunde `lage = await memo.lage(id)` in `try/catch`. **Schritt 1:** Dokumente mit `state === 'active' && unlockedByPurchase === true` bei `!lage.laeuft`: `withLocks(['feature-admin:' + projectId])` ⇒ Dokument frisch lesen, noch `active` und `unlockedByPurchase === true`, **frische** `aboLage` noch `!laeuft` ⇒ `disable(id, { reason: 'abo_ende', adminNote: 'Abgeschaltet: Wartungs-Abo beendet' })`. **Schritt 2 (Nachlauf):** für jedes `disabled`-Dokument mit Grund `abo_ende` ohne `aboEndeMailAt` (auch die eben abgeschalteten) und weiter `!lage.laeuft`: `meldeAboEnde`: K22 an `customers.email` (Block `hosting`, wenn `lage.art` `hosting` oder `wechsel_widerrufen`, sonst `abo_ende`; `daten: true`), Werte `enddatum` = Berliner Tag von `disabledAt`, `loeschdatum` = `datumKurz(loeschzeit(disabledAt))`, `preis`, `portal_url` = `${publicBaseUrl}/dashboard.html#termine`; **erst nach Erfolg** `aboEndeMailAt = now`. Kunde fehlt oder ohne E-Mail ⇒ Marker setzen (kein Wiederholen), Log ohne Adresse. Läuft das Abo wieder, überspringt Schritt 2 (E6 übernimmt).
- [ ] Tests `termineAboAbgleich.test.js` (Aufbau wie `termineJobs.test.js`: Mock mit `portalFeatures`/`subscriptions`/`termineConfig`/`termineBookings`, `mailSink`, feste `JETZT`, Logs auf Gast-/Kundendaten prüfen, Umgebung per `useTestEnv({ appwriteUrl, stripeUrl: 'http://127.0.0.1:1' })`; Abo-Dokumente aus `test/support/fixtures.js` per dynamischem Import):
- **Abo beendet ⇒ abgeschaltet:** `status: 'canceled'` ⇒ Dokument `disabled`, `enabled false`, `disabledReason 'abo_ende'`, `disabledAt === JETZT`, Notiz „Abgeschaltet: Wartungs-Abo beendet“; Ergebnis `{ geprueft: 1, abgeschaltet: 1, wiederEin: 0, mails: 1, ausgenommen: 0, fehler: 0 }`; K22 an den Kunden (Betreff `Dein Terminplaner wurde beendet – <Projekt>`, Löschdatum = `disabledAt` + 30 Tage), `aboEndeMailAt === JETZT`. **Zweiter Lauf am Folgetag:** nichts mehr, `disabledAt` unverändert, keine zweite Mail (Frist wird nie verschoben).
- Kein Abo-Dokument ⇒ abgeschaltet; nur Hosting-Abo aktiv ⇒ abgeschaltet, K22 mit Block `hosting` („Hosting-Abo“).
- **Läuft weiter (nie abschalten):** `past_due`, `trialing`, `active` mit `cancelAtPeriodEnd: true` und `cancelAt` in 10 Tagen ⇒ Dokument bleibt `active`, 0 Mails; `unpaid`, `incomplete_expired`, `canceled` ⇒ abgeschaltet (Tabelle).
- **Lesefehler:** `failNext({ collection: 'subscriptions', method: 'GET', status: 500 })` ⇒ bleibt `active`, `fehler: 1`, keine Mail; derselbe Fehler erst bei der **frischen** Prüfung unter der Sperre (`after: 1`) ⇒ bleibt `active`; der folgende Lauf ohne Fehler schaltet ab. Fehler beim Lesen der Dokumentliste ⇒ `gleicheAboAb` wirft nicht (Ergebnis mit `fehler`), nichts geändert.
- **Pilot/Kulanz:** `unlockedByPurchase: false` und fehlend, Kunde ohne Abo ⇒ unverändert `active`, nicht in `geprueft`, `ausgenommen` zählt sie (2 bei beiden Fällen), keine Mail; `off`-Entwürfe werden nicht gelesen (`listCalls` zeigt `notEqual state off`).
- **Rennen:** zwischen Liste und Sperre kommt ein aktives Abo (Einwurf wie `mitEinwurf`, `termineJobs.test.js:234`) ⇒ frische Prüfung ⇒ bleibt `active`; zwischen Liste und Sperre schaltet das Team ab (`reason: 'team'`) ⇒ Grund bleibt `team`, `disabledAt` unverändert, kein K22; Sperre: hält der Test `feature-admin:<projectId>`, bleibt der Abgleich offen, bis sie frei ist.
- **Memo:** ein Kunde mit drei Projekten ⇒ genau eine Abfrage auf `subscriptions`, drei abgeschaltete Dokumente, drei K22 (je Projekt eine).
- **K22-Nachlauf:** Transport wirft ⇒ `disabled`, `aboEndeMailAt` leer, `fehler 1`, `mails 0`; nächster Tag ohne Fehler ⇒ K22 geht raus, Marker gesetzt, `disabledAt` und Löschdatum im Text unverändert; Kunde gelöscht/ohne E-Mail ⇒ kein Wurf, Marker gesetzt, am Folgetag nichts.
- Alt-`disabled` ohne Grund, `team`, `kunde_geloescht` ⇒ nie angefasst, nie K22.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abo-Abgleich: aktive Terminplaner ohne Wartungs-Abo abschalten, K22 nachholen“
### Task E5: Schritt 1b (Abo endet vor der Einrichtung) und T19 über `meldeErstattung`
**Files:** Modify `server/services/termine/aboAbgleich.js`, `test/termineAboAbgleich.test.js`. (`siteFeatures.js` bleibt unberührt: es gibt **keine** eigene `erstattungMelden`.)
**Consumes:** E1–E4; B10b `meldeErstattung(doc, { customer, sitzung, nachAboEnde })` (**die** einzige T19-Stelle: Sperre `feature-meldung:<docId>`, Marker `zahlungMeldung === 'gemeldet'` erst nach `sent === true`, wirft nie); Plankopf/B11 `activateClosedTickets` nur als Prüfpunkt im Test.
**Produces:** Schritt 1b in `gleicheAboAb`: Dokumente mit `state ∈ {'ordered','setup'}` bei `!lage.laeuft` ⇒ unter `feature-admin:<projectId>` frisch lesen, noch `ordered`/`setup`, frische `aboLage` noch `!laeuft` ⇒ `disable(id, { reason: 'abo_ende_vor_einrichtung', adminNote: 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung' })`. Schritt 2 erweitert sich auf Grund `abo_ende_vor_einrichtung`; die Meldelogik liegt **in `meldeAboEnde`** (E4), nicht in der Schleife, damit der Sofortpfad E9 dasselbe tut. `meldeAboEnde` für `abo_ende_vor_einrichtung`: bei `unlockedByPurchase === true` **erst** `meldeErstattung(doc, { customer, nachAboEnde: false })` (T19 „[Abo] Erstattung fällig: Terminplaner-Einrichtung“; Aufruf **außerhalb** jeder `feature-admin`/`feature-paid`-Sperre), dann K22 mit Block `vor_einrichtung` (`daten: false`), dann Marker `aboEndeMailAt`; `sent === false` ohne `schonGemeldet` ⇒ Rückgabe `false`, `fehler += 1`, **weder K22 noch Marker** (der nächste Tag wiederholt T19 und K22 in dieser Reihenfolge). Ein **unbezahltes** `ordered`-Dokument (`unlockedByPurchase !== true`) wird nur abgeschaltet: **kein** T19, **kein** K22, **kein** Marker, kein Schreibzugriff (es ist nichts zu erstatten; kommt die Zahlung später, löst B10b/E10 T19 aus, und Schritt 2 sendet dann K22). Die offene Stripe-Sitzung wird nicht storniert (Spec 6 Schritt 1b). Das Ticket wird nicht angefasst.
- [ ] Tests:
- **Bezahlt, im Aufbau (`setup`, `unlockedByPurchase`, `stripeSessionId 'cs_test_1'`, Ticket vorhanden) und kein Abo ⇒** `disabled`, Grund `abo_ende_vor_einrichtung`; T19 **genau einmal** an `config.chatNotify.emails` (Betreff `[Abo] Erstattung fällig: Terminplaner-Einrichtung`, Text nennt Kunde, Projekt, `cs_test_1`, Rechnungslink, Ticketnummer), `zahlungMeldung === 'gemeldet'` und `purchaseStatus` weiter `paid`; K22 mit Block `vor_einrichtung` (Preis genannt, kein Löschkasten); das Ticket bleibt `Open` (Dokument in `workorders` unverändert); zweiter Lauf ⇒ weder T19 noch K22.
- **T19 schlägt fehl** (Transport wirft; `sendAdminNotice` fängt ab ⇒ `sent: false`) ⇒ `zahlungMeldung` bleibt leer, Dokument trotzdem `disabled`, `fehler ≥ 1`, weder K22 noch `aboEndeMailAt` bis dahin; nächster Lauf sendet T19 und danach K22 nach, danach nie wieder. Ein anschließendes Stripe-Ereignis zur selben Sitzung (`async_payment_succeeded`) ⇒ kein zweites T19.
- **Unbezahlt `ordered`** ohne Abo ⇒ `disabled`, **kein** T19, kein K22, `aboEndeMailAt` leer, `stripeSessionId` unverändert.
- `setup` ohne `ticketId` („Ticket fehlt“) ⇒ T19 ohne Block `ticket_offen`.
- Läuft das Abo (`past_due`) ⇒ `ordered`/`setup` bleiben unberührt; Lesefehler ⇒ unberührt.
- **Review Focus #1, „nie ohne Spur“:** ein `setup`-Dokument, dessen Abo endet, und das Ticket wird danach geschlossen (`workorders.status = 'Closed'`) ⇒ `activateClosedTickets` aktiviert das `disabled`-Dokument **nicht** (Zustand bleibt `disabled`).
- Rennen: Team aktiviert zwischen Liste und Sperre (Zustand `active`) ⇒ Schritt 1b greift nicht (frisch gelesen).
- **`meldeAboEnde` direkt** (der Aufruf, den E9 nutzt): bezahltes `setup`-Dokument mit Grund `abo_ende_vor_einrichtung` ⇒ ein T19, ein K22 `vor_einrichtung`, Marker gesetzt, Rückgabe `true`; unbezahltes `ordered`-Dokument ⇒ weder T19 noch K22 noch Marker, Rückgabe `true`, Dokument unverändert; zweiter Aufruf ⇒ nichts mehr.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abo-Abgleich: Abo endet vor der Einrichtung ⇒ abgeschaltet, T19 und K22“
### Task E6: Schritt 3 Wiederkehr und K23
**Files:** Modify `server/services/termine/aboAbgleich.js`, `test/termineAboAbgleich.test.js`.
**Consumes:** E1–E5; `ladeEinstellung(featureId)` (`kontext.js:75`, prüft den Modus nicht und liefert `null`, wenn keine lesbare Einstellung da ist), `activate` (A11).
**Produces:** Schritt 3 in `gleicheAboAb`: Dokumente mit `state === 'disabled'`, Grund **`abo_ende`** und `lage.laeuft === true`: unter `feature-admin:<projectId>` Dokument frisch lesen (noch `disabled`/`abo_ende`), frische `aboLage` noch `laeuft`, `await ladeEinstellung(doc.$id)` ≠ `null` (Daten da), dann `activate(id, { adminNote: 'Wieder eingeschaltet: Wartungs-Abo läuft wieder' })` (Daten und Einstellungen bleiben, `activate` leert Grund/Marker/`disabledAt`) und K23 **nur, wenn K22 ging** (`aboEndeMailAt` vor dem `activate` gelesen); scheitert K23, bleibt die Reaktivierung bestehen (`fehler += 1`). Festlegung: Die Rückkehr gilt, solange die Einstellung noch da ist, auch wenn der Lauf erst nach Tag 30 kommt (Abgleich läuft vor der Löschung im selben Tagesjob; sonst blieben Löschung und Rückkehr gegenseitig blockiert). Nach der Löschung ist das Dokument `off` (E8) und wird nie automatisch aktiv. Nicht automatisch: Grund `abo_ende_vor_einrichtung` (Erstattung läuft; Wiedereinschalten entscheidet das Team per WOMS), `team`, `kunde_geloescht`, Altbestand ohne Grund.
- [ ] Tests:
- **Rückkehr innerhalb von 30 Tagen:** `disabled`/`abo_ende`, `disabledAt` = JETZT − 10 Tage, `aboEndeMailAt` gesetzt, Einstellung und eine künftige Buchung vorhanden, neues Wartungs-Abo `active` ⇒ Dokument `active`, `enabled true`, `activatedAt === JETZT`, `disabledAt`/`disabledReason`/`aboEndeMailAt` `null`; Einstellung (Version) und Buchung **unverändert**; K23 genau einmal (Betreff `Dein Terminplaner läuft wieder – <Projekt>`); zweiter Lauf ⇒ nichts; Ergebnis `wiederEin: 1`.
- K22 ging nie (Marker leer, Abo kam vorher zurück) ⇒ reaktiviert, **kein** K22, **kein** K23.
- **Nach der Löschung nie automatisch:** Dokument `off` mit laufendem Abo ⇒ unberührt; `disabled`/`abo_ende` ohne Einstellung (`termineConfig` fehlt) ⇒ bleibt `disabled`, kein K23.
- `team`, `kunde_geloescht`, ohne Grund, `abo_ende_vor_einrichtung` + laufendes Abo ⇒ unberührt (auch kein Wegschreiben von Feldern).
- Rennen: Abo endet wieder zwischen Liste und Sperre ⇒ frische Prüfung ⇒ bleibt `disabled`; Lesefehler ⇒ bleibt `disabled`, `fehler 1`.
- `disabledAt` = JETZT − 35 Tage, Einstellung noch da, Abo läuft ⇒ reaktiviert; derselbe Tagesjob löscht danach nichts (siehe E8).
- **Wechsel widerrufen (Spiegel noch Wartung):** `wechsel`-Ereignis `widerrufen` ⇒ ein aktives Dokument wird abgeschaltet (Schritt 1, K22 Block `hosting`) und ein `disabled`/`abo_ende`-Dokument bleibt `disabled` (kein Flattern); ein neueres, wirksames `wechsel`-Ereignis hebt das auf ⇒ Rückkehr.
- K23 schlägt fehl ⇒ Dokument bleibt `active`, `fehler 1`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abo-Abgleich: Rückkehr innerhalb der Frist schaltet wieder ein (K23)“
### Task E7: Einbindung in den Tagesjob, geteilter Schalter-Guard, Bericht
**Files:** Modify `server/services/termine/jobs.js` (`fuehreAus` Z. 91, `taeglicherJob` Z. 420, `tagesbericht` Z. 437, Kopfkommentar Z. 20–37), `test/termineJobs.test.js` (Test „Modus off“ Z. 277, Tagesjob Z. 867, Berichtszeile Z. 905).
**Consumes:** E4–E6 `gleicheAboAb`; A12 (`fuehreAus(job, now, arbeit, { teil })` mit Sperre je `job:teil`), A3a/A3b/A10a3 (`gedeckelt`, `geloescht`, `aktiveBetriebe`, Berichtssegmente); `termineAus` (`schalter.js`). Block A hat Tagesjob und `tagesbericht` schon erweitert: E7 hängt nur sein Segment an und ordnet den Schritt ein.
**Produces:** `fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {})` (erweitert das Optionsobjekt aus A12; `teil` und die Überlappungssperre je `job:teil` bleiben unverändert): bei Modus `off` läuft der Job nur, wenn `await trotzAus?.()` wahr ist; wirft `trotzAus`, wird geloggt und `{ aus: true }` geliefert. `taeglicherJob` übergibt `trotzAus` = eine Abfrage `listDocuments(portalFeatures, [equal('featureKey','termine'), equal('unlockedByPurchase', true), limit(1)])`. **Modus off mit bezahlten Dokumenten** (eingeschränkter Lauf): nur `gleicheAboAb` und `loescheAbgeschaltete`, Ergebnis `{ eingeschraenkt: true, aboAbgleich, abgeschaltet, gedeckelt: { loeschfristen: 0, abgeschaltet: <n>, zellen: 0 }, geloescht: <abgeschaltet.buchungen>, aktiveBetriebe: null, fehler }` (dieselben Felder, die `meldeTagesjob` (A10a3) und `letzterTagesjob` der Status-Route lesen, damit dort nichts `undefined` ist); alle übrigen Schritte (Löschfristen der Buchungen, Outbox, Zellen) bleiben aus (Annahme, offene Frage). **Sonst** (pilot/on) neuer Schritt `gleicheAboAb(now)` nach `loeschfristen` und **vor** `loescheAbgeschaltete`, in `schritt(lauf, 'Abo-Abgleich', …, ersatz)` (ein Fehler dort stoppt weder die Löschung noch den Rest; `lauf.fehler += aboAbgleich.fehler`); Ergebnis erhält `aboAbgleich`. `tagesbericht` hängt `Abo-Abgleich N geprüft / N abgeschaltet / N wieder ein / N Mails / N ausgenommen` vor `N Fehler` an (nur Zahlen, keine IDs). **Reihenfolge der Segmente (verbindlich für A3b, A10a3, C6, E7):** `…, N Mails nicht zugestellt, [Löschläufe am Deckel: n, Rest folgt an den nächsten Tagen, ][Entwürfe n gelöscht (m offen), ]Abo-Abgleich …, N Fehler, n aktive Betriebe`; die vollständige Zeile in E7 (Entwürfe fehlen hier noch, C6 fügt sie zwischen Deckel und Abo-Abgleich ein): `[termine] Tagesjob: 0 Buchungen gelöscht (verfallen 0, abgesagt 0, abgelehnt 0, erledigt 0, nichterschienen 0, abgeschaltete Funktionen 0), 0 Funktionen bereinigt, 0 Zellen, 0 Outbox, 0 Mails nicht zugestellt, Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler, 0 aktive Betriebe`; für den eingeschränkten Lauf eine eigene Zeile `[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): …`.
- [ ] Tests:
- Test „Modus off“ angepasst: `minutenJob`/`fuenfMinutenJob` ⇒ `{ aus: true }` und **0** Anfragen; `taeglicherJob` ohne bezahlte Dokumente ⇒ `{ aus: true }` und **genau 1** Anfrage (die Guard-Abfrage), sonst nichts; schlägt die Guard-Abfrage fehl ⇒ `{ aus: true }`, Warnung, keine Löschung.
- **Modus off, bezahltes Dokument:** abgeschaltet seit 31 Tagen mit Buchungen ⇒ Buchungen/Zellen/Einstellung gelöscht und Dokument `off` (E8); aktives bezahltes Dokument ohne Abo ⇒ abgeschaltet, K22 geht raus; verfallene Buchung eines anderen Betriebs bleibt (kein `loeschfristen`-Schritt); Ergebnis hat `eingeschraenkt: true`, `tagesbericht` liefert die eingeschränkte Zeile ohne IDs.
- Pilot-Modus: `taeglicherJob` ⇒ `erg.aboAbgleich` mit den vier Zählern; Reihenfolge (Abgleich vor Löschung) per Test: ein Dokument, das heute zurückkehrt (Abo läuft, `disabledAt` vor 40 Tagen, Einstellung da), wird **nicht** gelöscht; `erg.fehler` enthält die Fehler des Abgleichs; wirft der Abgleich (Liste nicht lesbar), läuft die Löschung weiter (Fehler gezählt, `ersatz` mit Nullen).
- Berichtszeile (Z. 905) gleicht gegen das Literal oben (zusätzlich eine Variante mit `gedeckelt` > 0 und dem Deckel-Segment), weiter ohne `b_`, `f_`, `@`; Z. 867 prüft zusätzlich `erg.aboAbgleich`; der eingeschränkte Lauf liefert `gedeckelt`, `geloescht` und `aktiveBetriebe: null`, und `meldeTagesjob`/`statusDaten` lesen daraus ohne `undefined`.
- Überlappung: der eingeschränkte Lauf nutzt dieselbe Sperre wie der volle Tagesjob (zweiter paralleler Aufruf ⇒ `{ uebersprungen: true }`); die Sperren der beiden `minutenJob`-Teile (`job:teil`, A12) bleiben davon **unabhängig** (hängender Tagesjob ⇒ Verfall und Mailversand laufen weiter, und umgekehrt).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Tagesjob: Abo-Abgleich vor der Löschung, auch bei TERMINE_ENABLED=off mit bezahlten Dokumenten“
### Task E8: Löschung nach 30 Tagen: frische Abo-Prüfung und Zurücksetzen auf `off`
**Files:** Modify `server/services/termine/jobs.js` (`ABGESCHALTET_BEHALTEN_MS` Z. 50 aus `LOESCHFRIST_TAGE`, `nochAbgeschaltet` Z. 325, `loescheAbgeschaltete` Z. 347), `test/termineJobs.test.js` (Z. 753–780, 822, 846).
**Consumes:** E1 `aboLage`, E2 `LOESCHFRIST_TAGE`, A11 `disabledReason`; Block A (seitenweise Löschung und Deckel: die beiden Änderungen hängen an `nochAbgeschaltet` und an der Stelle, wo die Einstellung gelöscht wird, nicht an der Seitenlogik).
**Produces:** `nochAbgeschaltet` prüft bei Grund `abo_ende` zusätzlich **frisch** `(await aboLage(frisch.customerId)).laeuft`; läuft das Abo wieder ⇒ `false` mit Log („Abo läuft wieder, Löschung abgebrochen“); ein Lesefehler dabei ⇒ `false` und `melde` (im Zweifel wird nichts gelöscht). Die Prüfung gilt vor jedem unumkehrbaren Schritt (Buchungsliste, alle 50 Buchungen, Einstellung); für `team`, `kunde_geloescht`, `abo_ende_vor_einrichtung`, Altbestand gibt es **keine** Abo-Abfrage. **Zurücksetzen:** sind alle Buchungen weg (`vollstaendig`) und die Einstellung gelöscht **oder schon nicht mehr da (404)**, setzt der Job unter `withLocks(['feature-admin:<projectId>', 'termine-cfg:<id>'])` nach nochmaligem Prüfen das Feature-Dokument zurück: `stateFields('off')`, `unlockedByPurchase false`, `purchaseStatus 'zurueckgesetzt'` (dauerhafte Marke „hier wurde nach dem Abo-Ende gelöscht“: Block C6 löscht ein so markiertes Funktionsdokument **nie**, nur seine Einstellung, damit der `siteKey` bestehen bleibt; weder `paid` noch Meldezustand), `zahlungMeldung ''`, `ticketId ''`, `activatedAt null`, `disabledAt null`, `disabledReason null`, `aboEndeMailAt null`, `adminNote 'Daten gelöscht (Löschfrist abgelaufen)'`; **`siteKey` bleibt** (das eingebettete Snippet bleibt gültig), Bestell-/Rechnungsfelder bleiben bis zur nächsten Bestellung stehen. Gilt für jeden Grund. Der Zähler hat die Form `{ funktionen, buchungen, gedeckelt }` (A4; ein reines Zurücksetzen ohne Löschung zählt nicht). Ist nur noch das Zurücksetzen offen (Einstellung weg, keine Buchungen), entfällt die Abo-Prüfung (nichts Unumkehrbares mehr).
- [ ] Tests (`termineJobs.test.js`, Abo-Dokumente über `subscriptions` im Mock-Schema ergänzen):
- Bestehender Test Z. 753: die Zeile „das Funktionsdokument selbst bleibt“ (Z. 776) wird zu: Dokument vorhanden mit `state 'off'`, `enabled false`, `unlockedByPurchase false`, `purchaseStatus 'zurueckgesetzt'`, `zahlungMeldung ''`, `ticketId ''`, `disabledAt null`, `siteKey 'abcdefghijklmnop'` unverändert; die Dokumente der anderen Funktionen (noch nicht abgelaufen, wieder aktiv, andere `featureKey`, ohne `disabledAt`) bleiben unverändert; zweiter Lauf `{ funktionen: 0, buchungen: 0, gedeckelt: 0 }` (die `deepEqual`-Pins der TP1-Tests tragen `gedeckelt: 0` seit A4) und das Dokument wird **nicht mehr gelistet** (keine tägliche Neuprüfung); Entwurfsjob (C6) nach 91 Tagen ⇒ das zurückgesetzte Dokument behält den `siteKey` (Test in C6).
- **Abo-Vorprüfung:** Grund `abo_ende`, Abo läuft **wieder**, `disabledAt` vor 31 Tagen ⇒ nichts gelöscht (Buchungen, Zellen, Einstellung da), Dokument bleibt `disabled`, Warnung im Log; dasselbe Dokument mit Grund `team` ⇒ gelöscht und **0** Abfragen auf `subscriptions`; Lesefehler auf `subscriptions` ⇒ nichts gelöscht, `fehler` gezählt; das Abo kommt erst nach 60 Buchungen zurück (Einwurf nach der 50. Löschung, Muster Z. 822) ⇒ Löschung endet dort, Einstellung bleibt.
- **Zurücksetzen:** scheitert eine Buchungslöschung (`failNext` DELETE `termineBookings`) ⇒ Dokument bleibt `disabled`, Einstellung bleibt; nächster Lauf löscht und setzt zurück. Scheitert das Zurücksetzen (`failNext` PATCH `portalFeatures`) ⇒ Einstellung ist weg, Dokument noch `disabled`; nächster Lauf setzt zurück, **auch wenn das Abo inzwischen läuft** (kein Unumkehrbares mehr offen). Wird das Dokument zwischen Löschen der Einstellung und Zurücksetzen vom Team aktiviert (Einwurf), bleibt es `active` (frisch gelesen, nicht überschrieben).
- Nach dem Zurücksetzen ist eine Neubestellung möglich: `markOrdered` (Block B) auf dem `off`-Dokument ⇒ `ordered` (Verweis auf B-Test, hier nur der Zustand `off`).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Löschung nach 30 Tagen: Abo-Vorprüfung, Funktionsdokument danach auf off“
### Task E9: Sofortpfad Widerruf Wechsel und Schutz der Team-Aktivierung
**Files:** Modify `server/services/termine/aboAbgleich.js` (+ `endeTermineFuerKunde`), `server/routes/abo.js` (Z. 1051–1058, nach dem Analyse-Block), `server/services/siteFeatures.js` (`adminActivate` Z. 351), Tests `test/publicForms.test.js` (neben Z. 422), `test/funktionenTermine.test.js`, `test/termineAboAbgleich.test.js`.
**Consumes:** E4–E6, E1 `aboLage`.
**Produces:** `endeTermineFuerKunde(customerId, { now = new Date() } = {}) ⇒ Promise<{ beendet: string[] }>` (wirft nie, wie `endAnalyseForCustomer`): alle `termine`-Dokumente des Kunden (`listCustomerFeatureDocs`), `active` mit `unlockedByPurchase === true` ⇒ `disable({ reason: 'abo_ende', adminNote: 'Abgeschaltet: Wechsel widerrufen' })`, `ordered`/`setup` ⇒ `abo_ende_vor_einrichtung`; je Dokument unter `feature-admin:<projectId>` mit frischem Lesen, **ohne** Abo-Prüfung (der Spiegel ist veraltet); danach sofort `meldeAboEnde` (E4/E5: `abo_ende` ⇒ K22; `abo_ende_vor_einrichtung` mit `unlockedByPurchase === true` ⇒ erst T19 über `meldeErstattung` (B10b), dann K22 `vor_einrichtung` und Marker; ein unbezahltes `ordered`-Dokument wird nur abgeschaltet und bekommt weder T19 noch K22 noch Marker), ein Fehler bei einem Dokument stoppt die anderen nicht. `routes/abo.js` ruft sie per dynamischem Import in einem eigenen `try/catch` nach dem Analyse-Block (`inTime && type === 'wechsel'`); der Widerruf antwortet auch bei einem Fehler dort 200. `adminActivate`: für `termine` und ein Dokument mit `unlockedByPurchase === true` ist das Aktivieren nur erlaubt, wenn `(await aboLage(customerId)).laeuft`; sonst 409 „Das Wartungs-Abo des Kunden läuft nicht. Der Terminplaner kann erst wieder freigeschaltet werden, wenn es läuft.“ (verhindert, dass der nächste Abgleichslauf die Kulanz zurücknimmt und K22 erneut sendet); Lesefehler ⇒ Fehler, nicht aktiviert. Nicht betroffen: `news`, Dokumente mit `unlockedByPurchase !== true` (Pilot/Kulanz ohne Kauf).
- [ ] Tests:
- `publicForms.test.js` (Muster Z. 401–440, Dokument `termine` statt `analyse`): Widerruf des Wechsels in der Frist ⇒ aktives bezahltes Termine-Dokument `disabled`/`abo_ende`, K22 in der Senke, Analyse-Verhalten unverändert; wirft die Termine-Abschaltung (Dokument-Update scheitert), bleibt der Widerruf 200; Widerruf nach Fristende oder Zusatzspeicher ⇒ Termine unverändert (`wf_feat`-Test erweitert).
- **Bezahlter Aufbau über den Sofortpfad (Review Focus #1):** Termine-Dokument `setup` (`unlockedByPurchase`, `stripeSessionId 'cs_test_1'`, Ticket vorhanden), Widerruf des Wechsels in der Frist ⇒ `disabled`/`abo_ende_vor_einrichtung`, **genau ein** T19 (Block `ticket_offen`), K22 mit Block `vor_einrichtung`, `aboEndeMailAt` gesetzt, `zahlungMeldung 'gemeldet'`; der folgende `gleicheAboAb`-Lauf sendet nichts mehr (weder T19 noch K22). Unbezahltes `ordered` ⇒ `disabled`/`abo_ende_vor_einrichtung`, **kein** T19, kein K22, kein Marker; kommt die Zahlung danach, meldet B10b T19 genau einmal und der Abgleich K22. T19-Versand scheitert ⇒ der Widerruf antwortet trotzdem 200, K22 und Marker fehlen, der Abgleich am Folgetag sendet T19 und K22 nach.
- **Kein Flattern:** nach dem Sofortpfad (Spiegel `sub_h` auf `productKey 'wartung'`, `active`) läuft `gleicheAboAb` ⇒ Dokument bleibt `disabled`, kein K23, kein zweites K22.
- `funktionenTermine.test.js`: Team-„Aktivieren“ bei bezahltem `disabled`-Dokument ohne laufendes Abo ⇒ 409 mit dem Text, Zustand unverändert; mit laufendem Abo ⇒ 200, `active`, Grund `null`; Dokument ohne Kauf (`unlockedByPurchase: false`) ohne Abo ⇒ 200; `news` ⇒ unberührt; Abo-Lesefehler ⇒ 5xx, nicht aktiviert.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abo-Ende: Sofortpfad beim Widerruf des Wechsels, Team-Aktivierung nur mit laufendem Abo“
### Task E10: Zahlung nach dem Abo-Ende (T19 statt Ticket) – Zusatztests gegen B10
**Files:** Modify `test/termineAboAbgleich.test.js` (nur Tests). **Kein eigener Code** und kein eigener Zweig in `onFeaturePaid`: das Verhalten (kein Ticket, kein K15/T16, T19 genau einmal, Webhook 500 bei Versandfehler, Wiederholung sendet genau einmal) baut B10a/B10b.
**Consumes:** B10a (`markPaid`-Fall `abo_ende`, Marker `zahlungMeldung`), B10b (`meldeErstattung`, `onFeaturePaid`), E4–E6 (`gleicheAboAb`).
**Produces:** Tests für das Zusammenspiel von Zahlungs-Webhook und täglichem Abgleich (Review Focus #1: nie Geld ohne Spur, nie doppelt).
- [ ] Tests (`handleFeatureCheckoutEvent` direkt, Hilfen `event`/`paidSession` wie in `funktionenRoutes.test.js:51–58`, Dokument `termine` im Zustand `ordered`, danach `gleicheAboAb` ohne Abo):
- Zahlung nach Abschalten ⇒ genau ein T19 (Block `zahlung_nach_ende`, Sitzungs-ID), **kein** Eintrag in `workorders`, kein K15/T16, `unlockedByPurchase true`, `purchaseStatus 'paid'`, `zahlungMeldung 'gemeldet'`, Zustand bleibt `disabled`; dasselbe Event noch einmal und danach `async_payment_succeeded` ⇒ kein zweites T19 (nie doppelt).
- T19-Versand schlägt fehl ⇒ `handleFeatureCheckoutEvent` wirft (Webhook 500), `unlockedByPurchase` ist trotzdem `true`, `zahlungMeldung` offen; zweiter Aufruf mit funktionierendem Versand ⇒ genau ein T19.
- Folgetag-Abgleich ⇒ K22 `vor_einrichtung` an den Kunden, `aboEndeMailAt` gesetzt, **kein** zweites T19 (Marker `gemeldet`).
- Abgleich **vor** der verspäteten Zahlung-Wiederholung: Schritt 2 meldet T19 (das Dokument ist durch die erste Verbuchung bezahlt), die Wiederholung des Webhooks danach bleibt stumm; parallel (`Promise.all`) ⇒ insgesamt ein T19.
- Zahlung für ein Dokument mit Grund `team` bleibt beim Verhalten aus B10b (T16 „abgeschaltet“, kein T19); Zahlung bei `setup`/`ordered` (Abo läuft) unverändert (Ticket, K15, T16).
- Rennen Reihenfolge: Zahlung **vor** dem Abgleich (Dokument `setup`, Ticket da) und Abgleich danach ⇒ T16/Ticket **und** ein T19 (mindestens eine Spur, T19 höchstens einmal); anschließend Stripe-Retry desselben Ereignisses ⇒ stumm.
- [ ] FAIL → Tests schreiben (bei Rot: den Fehler in B10 beheben, nicht hier umgehen) → PASS → `npm test` → Commit „Zahlung nach Abo-Ende: Zusatztests Webhook und Abgleich (T19 nie doppelt)“
### Task E11: Meldungen und Banner im Portal nach Abschaltgrund
**Files:** Modify `server/routes/termine.js` (`MELDUNG.abgeschaltet` Z. 59 und Z. 152; `GET /einstellungen` Z. 201), `public/termine-view.js` (+ `abschaltText`), `public/termine.js` (`hinweisAbgeschaltet` Z. 66, Aufrufe Z. 96–98 und Z. 200); Tests `test/termineRoutes.test.js` (Z. 994), `test/termineView.test.js`, `test/ui/termine-portal.ui.mjs` (Z. 1024, Playwright, nicht in `npm test`).
**Consumes:** E2 `loeschTag`, `featureView`-Felder. **Eigentum:** E11 **besitzt** Banner, Meldungstexte und das Feld `abgeschaltet` (Reihenfolge A → B → E → C → D): Block C (C4 schreibt `MELDUNG`, `withTermine` und GET `/einstellungen` neu, C10b ändert `zeigeProjekt`) und Block D (D2 verschiebt den Snippet-Aufruf in GET `/einstellungen`) setzen auf diesen Stand auf und **erhalten** `MELDUNG.abgeschaltet`, das Feld `abgeschaltet`, `abschaltText` und `hinweisAbgeschaltet`; C baut keinen eigenen disabled-Banner und kein eigenes Löschdatum.
**Produces:** `GET /api/termine/einstellungen` liefert zusätzlich `abgeschaltet: { grund, seit, loeschungAm } | null` (nur bei `state === 'disabled'`; `grund` = `disabledReason` oder `null`, `seit` = `disabledAt`, `loeschungAm` = `'JJJJ-MM-TT'`). Die 409-Meldung bei Schreibversuchen (Code bleibt `abgeschaltet`) nennt je Grund das Löschdatum (`dd.mm.yyyy`): `abo_ende` „Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und exportieren. Ab dem 04.11.2026 löschen wir die Daten.“, `abo_ende_vor_einrichtung` „…Abo endete, bevor der Terminplaner eingerichtet war…“, sonst der bisherige Satz plus (mit Datum) „Ab dem … löschen wir die Daten.“. `abschaltText({ grund, loeschungAm }) ⇒ { davor, danach, mitExport }` in `termine-view.js` (rein, Du-Form, `datumKurz` für das Datum): `mitExport` falsch nur bei `abo_ende_vor_einrichtung`; `hinweisAbgeschaltet(projectId, abgeschaltet)` setzt `[davor, exportLink(projectId, 'exportieren'), danach]` (Linktext und genau ein Link bleiben, die UI-Prüfung und der Link-Wächter in `termineView.test.js` ~Z. 290 gelten weiter), Text nur über `h()`. Wortlaut ist Vorschlag für die Rechtsrunde (Block F).
- [ ] Tests:
- `termineRoutes.test.js` (Test Z. 994 erweitert): disabled ohne Grund/`disabledAt` ⇒ `abgeschaltet: { grund: null, seit: null, loeschungAm: null }` und der **bisherige** Satz unverändert; `abo_ende` mit `disabledAt: '2026-10-04T23:30:00.000Z'` ⇒ `loeschungAm '2026-11-04'`, jede Schreibroute 409 mit `code 'abgeschaltet'` und Text mit „Wartungs-Abo“ und „04.11.2026“; `abo_ende_vor_einrichtung` ⇒ Text mit „eingerichtet“; `active` ⇒ `abgeschaltet: null`; Lesen und Export bleiben 200.
- `termineView.test.js`: `abschaltText` je Grund (abo_ende, abo_ende_vor_einrichtung, team, kunde_geloescht, `null`) mit und ohne Datum; `mitExport` nur bei vor_einrichtung falsch; kein „Sie“/„Ihr“ (die Dateischleife Z. 277 bleibt grün, keine neue Datei).
- UI (`nice`, vorher `free -m`): Fixture `disabled` + `abo_ende` ⇒ Banner nennt „Wartungs-Abo“ und das Datum, der Export-Link bleibt der einzige Link (`.tm-box-warn a`, Text „exportieren“); Fixture ohne Grund ⇒ wie bisher (Z. 1039).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner-Banner und Meldung nach Abschaltgrund mit Löschdatum“
### Task E12: WOMS „Abgeschaltet (Abo beendet), Löschung am …“
**Files:** Modify `src/lib/funktionenView.js` (+ `abgeschaltetText`), `src/components/customer/CustomerFunktionen.jsx` (Statuszelle, Z. 96–98), `tests/unit/funktionenView.test.mjs`.
**Consumes:** E2 (`disabledReason`, `loeschungAm` in `features[key]` über `GET /api/team/funktionen/:customerId`; `adminView` reicht die Felder von `listCustomerFeatures` durch, kein Portal-Eingriff), `fmtYmd` (`src/lib/format.js:41`); Block D (D9/D10) ändert dieselbe Komponente **nach** E12 (Anleitung, `aktionen(state, key)`) und **erhält** die Zeile mit `abgeschaltetText` in der Statuszelle; E12 ergänzt nur diese Zeile und die reine Funktion.
**Produces:** `abgeschaltetText(feature) ⇒ string` (rein): `''`, wenn `state !== 'disabled'`; sonst `Abgeschaltet` + ` (Abo beendet)` bei `abo_ende`, ` (Abo endete vor Einrichtung, Erstattung prüfen)` bei `abo_ende_vor_einrichtung`, ` (Team)` bei `team`, ` (Kunde gelöscht)` bei `kunde_geloescht`, nichts ohne Grund; mit `loeschungAm` anschließend `, Löschung am ${fmtYmd(loeschungAm)}`. Die Komponente zeigt den Text als `area-muted`-Zeile unter dem Abzeichen (das Abzeichen bleibt `ZUSTAND.disabled`, der Test dort bleibt unverändert). Das Team-„Aktivieren“ meldet bei fehlendem Abo (E9) den Servertext im bestehenden Fehlerbereich.
- [ ] Tests (`funktionenView.test.mjs`): `abgeschaltetText({ state: 'disabled', disabledReason: 'abo_ende', loeschungAm: '2026-11-04' }) === 'Abgeschaltet (Abo beendet), Löschung am 04.11.2026'`; die drei anderen Gründe; ohne Grund und Datum `'Abgeschaltet'`; ohne Grund mit Datum `'Abgeschaltet, Löschung am …'`; `active`/`setup`/`off`/`undefined` ⇒ `''`; unbekannter Grund wird wie „ohne Grund“ behandelt; bestehende Tests (`ZUSTAND`, `aktionen`, `mergeFunktion`) unverändert grün. `npm test` (WOMS) und einmal `nice npx vite build`.
- [ ] FAIL → implementieren → PASS → WOMS `npm test` + `nice npx vite build` → Commit „Funktionen-Reiter: Abschaltgrund und Löschdatum des Terminplaners“
**Abschlussprüfung Block E (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. WOMS-Anteil (E12): `npm test` im WOMS-Worktree und einmal `nice npx vite build`. Check vor dem Livegang: der Abgleich läuft zunächst nur beobachtend (Block G: solange kein Dokument `unlockedByPurchase === true` hat, schaltet er nichts ab).
---
## Block C: Fragebogen
Block C macht aus dem TP1-Einstellungsdokument den Fragebogen: In `off` ist `termineConfig.einstellung` der Entwurf und zugleich die spätere Live-Einstellung, dazu das neue Attribut `termineConfig.fragebogen` (Schrittbestätigungen, Einbauwünsche), acht Schritte aus den TP1-Editoren und die Bestellseite „Prüfen und bestellen“. Der strikte Gate `bestellpruefung` ersetzt den Stub aus Block B. Reihenfolge: Server C1–C6 (C4a, C4b), Client C7a–C10b (C7a, C7b, C8, C9a, C9b, C10a, C10b), Playwright C11 (Voraussetzung: A, B, E sind fertig; C vor D und F).
Gelesen und geprüft (Portal-Worktree): `server/routes/termine.js` (`withTermine` :137-157, GET :201-216, Vorlage :230-252, PATCH :254-272), `server/services/termine/{kontext,einstellungen,vorlagen,schema,jobs}.js`, `public/termine*.js`, `public/funktionen.js` (Termine-Kachel :139-197), `server/routes/funktionen.js:109`, `server/services/customerAdmin.js:285-292`, UI-Testserver `test/ui/termineTestServer.mjs`. Abweichungen von der Spec: (1) `BRANCHEN` liegt nach der Schnittstellenliste in `fragebogen.js`, nicht in `vorlagen.js`; (2) das Attribut kommt in `server/services/termine/schema.js` (das Setup-Skript `scripts/termine-setup.mjs` und `test/support/schema.js` lesen von dort, beide bleiben unverändert); (3) `feiertage(t)` aus `termine-team-ausnahmen.js` wird nicht übernommen, Schritt 4 braucht ein Bundesland ohne Vorauswahl und ein Speichern je „Weiter“.
**Annahmen an andere Blöcke (abgestimmt, verbindlich):**
- **B Register:** `featureConfig('termine')` hat die Felder aus B1 (`consent: { version, knopf, items: [{ id, text, haken }] }`, `leistungsbeschreibung`, `avv`, `widerruf`, `priceLabel`, `setupEstimate`). Die Widerrufsbelehrung liefert der Haken `featureHaken('termine').belehrung` (F7); C erfindet keine Registerfelder `pflichtinfo` oder `widerruf{belehrung,muster}`. Nur `bestellungAnsicht()` (C2) bildet das Register für die Bestellseite ab.
- **B Checkout** `POST /api/funktionen/termine/checkout`: Body `{ projectId, zustimmung: { version, haken: [id, …] } }` (`haken` = angehakte `consent.items[].id` mit `haken: true`); „Zahlung fortsetzen“ in `ordered` sendet `{ projectId, fortsetzen: true }` und bekommt die offene Sitzung nur, wenn für sie ein Nachweis vorliegt, sonst 409 `zustimmung_fehlt` (die Seite zeigt wieder das Formular); Antwort `{ url }`. Bei nicht leerer `bestellpruefung` antwortet B mit 409 `bestellung_offen` und `body.offen` (Liste `{ schritt, text }`), bei veralteter Fassung mit 409 `fassung_veraltet` und `body.consent`, bei Abo-Mangel mit 409 und `code` `kein_abo` | `gekuendigt` | `nicht_aktiv` (Abo-Stufe (7) in B6, nicht über das Gate).
- **B (Checkout) und C4a (Vorschau)** rufen `bestellpruefung` mit `ladeEinstellung(featureId)` (C3 liefert dort jetzt auch `fragebogen`): B6 im Checkout als `bestellpruefung({ einstellung, fragebogen, feature: featureConfig('termine'), mitAbo: false })` (ohne `abo`, die Abo-Stufe (7) liegt in B6), C4a im GET als `bestellpruefung({ einstellung, fragebogen, feature: featureConfig('termine'), abo: await bestellbar(customerId) })` (bei Lesefehler ohne `abo` ⇒ Punkt (h) „unbekannt“). Block D liest die Einbauwünsche über denselben Weg. Abo-Hinweistexte kommen aus `aboMeldung(feature, grund)` (`featureOrder.js`, B6); C definiert keine eigenen Wortlaute.
- **E** liefert `aboStatus.laeuft`/`bestellbar` (E1) und **besitzt** Abschalt-Banner, Meldungstexte und das GET-Feld `abgeschaltet` (E11). C4a/C4b, C10b und D2 setzen darauf auf und erhalten `MELDUNG.abgeschaltet`, das Feld `abgeschaltet`, `abschaltText` und `hinweisAbgeschaltet`.
- **A** `kundenansichtGuard`: `PUT /api/termine/fragebogen` bleibt außerhalb der Erlaubnisliste; Einfügepunkt für C6 ist `taeglicherJob` nach `loescheAbgeschaltete` (auch A und E ändern diese Funktion und `tagesbericht`; Segmentreihenfolge siehe E7).
### Task C1: Fragebogen-Attribut, Branchen, Schritte, Normalisierung
**Files:** Create `server/services/termine/fragebogen.js` (Teil 1; hat Block B die Datei schon mit dem Stub angelegt, wird sie ergänzt), `test/termineFragebogen.test.js`. Modify `server/services/termine/schema.js` (`termineConfig`: `str('fragebogen', 20000)` hinter `updatedBy`), `test/termineSetup.test.js` (Attributliste in „Schema: Attribute je Collection“ um `fragebogen` am Ende; Größen-Test; neuer `--check`-Test).
**Consumes:** `text` aus `einstellungen.js`; `VORLAGEN` aus `vorlagen.js`.
**Produces:** `SCHRITTE = ['branche','wer','leistungen','zeiten','regeln','gastfelder','benachrichtigung','website']`; `BRANCHEN = [{ key, name, vorlage: string|null, gesundheit: boolean }]` (Reihenfolge von `vorlagenListe()`, danach der Eintrag `{ key: 'gesundheit', name: 'Gesundheitsbereich', vorlage: null, gesundheit: true }`; Schlüssel `gesundheit` und Name „Gesundheitsbereich“ gelten wörtlich für C2, C5, C9a und C11); `leerFragebogen()`; `normalisiereFragebogen(eingabe) ⇒ { fragebogen } | { fehler: { [pfad]: text } }`; `FRAGEBOGEN_MAX = 16000`. Form: `{ schema: 1, branche: <BRANCHEN-key>|null, bestaetigt: [Schrittschlüssel], bundeslandGewaehlt: boolean, preiseGeprueft: boolean, einbau: { modus: 'inline'|'knopf'|null, knopfText ≤ 40, stelle ≤ 300, gestaltung ≤ 1000, bemerkung ≤ 1000 } }`. `branche` ist gegenüber der Spec ergänzt, sonst kennt der Gate (f) die Branche nicht.
- [ ] Tests: `SCHRITTE` genau diese acht; `BRANCHEN`: jede `vorlage` steht in `VORLAGEN`, alle neun Vorlagen kommen genau einmal vor, genau ein Eintrag `gesundheit: true` ohne Vorlage, `sonstiges` bleibt wählbar (D6). `normalisiereFragebogen`: `{}` ⇒ `leerFragebogen()`; unbekannte Schlüssel und `__proto__` fliegen raus; `bestaetigt: ['website','branche','branche','quatsch']` ⇒ `['branche','website']` (Reihenfolge der Schritte, ohne Doppelte und Fremde); `knopfText` mit 41 Zeichen wird auf 40 gekürzt; Steuerzeichen (`\u0000`, `‮`) in `gestaltung` verschwinden, Absätze bleiben; `modus: 'popup'` ⇒ `fehler['einbau.modus']`; `bundeslandGewaehlt: 'ja'` ⇒ Fehler; Nicht-Objekt ⇒ `fehler._`; idempotent (`normalisiere(normalisiere(x).fragebogen)` gleich); größte gültige Eingabe bleibt unter `FRAGEBOGEN_MAX`. Setup: `keysOf('termineConfig')` endet auf `fragebogen`; Größe 20000 (Inhalt ≤ 16 000 erzwingt der Server, die größere Spalte wird in Appwrite eine Textspalte und zählt nicht zur Zeilenbreite; Annahme, vor Produktion `termine-setup.mjs --check` und Anlegen gegen eine Kopie); `main({ check: true })` meldet in einer vorhandenen Collection ohne `fragebogen` „termineConfig.fragebogen FEHLT“, der Lauf ohne `--check` legt genau dieses Attribut an, der zweite nichts.
- [ ] FAIL → implementieren (`normalisiereFragebogen` baut ein neues Objekt Feld für Feld, Texte über `text()`; `stelle`/`gestaltung`/`bemerkung` mehrzeilig) → PASS → `npm test` → Commit „Terminplaner TP2: Fragebogen-Attribut, Branchen, Schritte, Normalisierung“
### Task C2: Bestell-Gate und Gesundheits-Stichwortprüfung
**Files:** Modify `server/services/termine/fragebogen.js` (Teil 2, ersetzt den Stub aus B; die Marke `STUB` entfällt), `server/services/termine/einstellungen.js` (`sichtbareLeistungen` :568 und `MELDUNG_PFLICHT` :562 exportieren, sonst unverändert), `test/termineFragebogen.test.js`. **Delete** `test/termineFragebogenStub.test.js` (B5; sie prüft den Stub und bliebe rot). Create `test/support/fragebogenStand.js`. B-Tests, die nur mit dem Stub (`[]`) bestellbar waren (`grep -l checkout test/*.test.js`), bekommen `bestellbarerStand()`.
**Produces:** `bestellpruefung({ einstellung, fragebogen, feature, abo, mitAbo = true }) ⇒ Array<{ schritt, text }>` (`schritt` ∈ `SCHRITTE` ∪ `'bestellen'`, sortiert nach Schrittfolge; `feature` = Register-Eintrag, nur für `label.name` und `aboMeldung` (aus `featureOrder.js`, B6, Argumente `(feature, grund)`); `abo` = Ergebnis von `bestellbar`, gelesen nur bei `mitAbo !== false`); `SCHRITT_TITEL`; `gesundheitsWarnungen(einstellung) ⇒ string[]`; `bestellungAnsicht(feature) ⇒ Promise<{ preis, preisHinweis: config.ustHinweis, einrichtung, consent: { version, knopf, items: [{ id, text, haken }] }, dokumente: [{ id, titel, version, url }], belehrung } | null>` (reine Abbildung von `oeffentlicheAnsicht(feature.key)` (B1) für die Bestellseite: `consent` unverändert **inklusive `knopf` und `haken`-Flag** (der Knopf zeigt genau diesen Text, B speichert genau diesen Text), `dokumente` aus den Registerfeldern `leistungsbeschreibung`/`avv`/`widerruf` mit gesetzter `url`, `belehrung` = Ergebnis von `featureHaken(feature.key).belehrung()` (derselbe Text, den B7 im Nachweis speichert; bis F7 der Platzhalter `BELEHRUNG_PLATZHALTER` aus `featureRegistry.js` (B7), „ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.“); keine Felder `pflichtinfo`/`widerruf`). Testhelfer: `bestellbarerStand({ now }) ⇒ { einstellung, fragebogen }` (Vorlage `salon`, Telefon, Anschrift, Impressum, Datenschutz, alle Schritte bestätigt, `bundeslandGewaehlt`, `preiseGeprueft`, `einbau.modus: 'inline'`, `branche: 'salon'`) und `entwurfDoc(featureId, { customerId, projectId, stand })` (Seed für `termineConfig`).
Gate (a)–(h), jeder Punkt ein eigener Eintrag: (a) `fehlendePflichtangaben` (Telefon ⇒ Schritt `website`, Anschrift ⇒ `branche`, Text aus `MELDUNG_PFLICHT`); (b) keine `sichtbareLeistungen` ⇒ `leistungen`; (c) je unbestätigtem Schritt ein Punkt „Bitte bestätige den Schritt „…“.“, dazu `leistungen`, wenn eine sichtbare Leistung einen Preis hat und `preiseGeprueft` fehlt; (d) `bundeslandGewaehlt !== true` ⇒ `zeiten` (der Serverstandard `RP` zählt nicht); (e) `einbau.modus` fehlt, oder `knopf` ohne `knopfText` ⇒ `website`; (f) `branche` fehlt oder Gesundheit ⇒ `branche`; (g) `impressumUrl` oder `datenschutzUrl` fehlt ⇒ `website`; (h) nur bei `mitAbo !== false` (der Checkout in B6 ruft mit `mitAbo: false` und ohne `abo`; dort liefert die Abo-Stufe (7) die Codes): `abo.ok !== true` ⇒ `bestellen` mit `aboMeldung(feature, abo.grund)`, `abo` fehlt (Lesefehler) ⇒ derselbe Schritt mit `aboMeldung(feature, 'unbekannt')` (im Zweifel nicht bestellbar). `einstellung: null` ⇒ ein Punkt `branche`.
Stichwortprüfung (Regel: Treffer nur am **Wortanfang**, vor dem Treffer steht kein Buchstabe `\p{L}`; „Gesichtsbehandlung“ ist keine Behandlung). Gruppe 1, Stamm mit beliebiger Fortsetzung bis zum Wortende (Zusammensetzungen zählen): `physio`, `therap` (Therapie, Therapeut/in), `arzt`, `ärzt`, `zahnarzt`, `zahnärzt`, `heilpraktik`, `krankengymnast`, `osteopath`, `diagnos`. Gruppe 2, Stamm plus höchstens eine Beugungsendung aus `e`, `en`, `n`, `s`, `es` und dann Wortende: `rezept`, `beschwerde`, `behandlung` (so trifft „Rezepte“, „Beschwerden“, „Behandlungen“, aber **nicht** „Rezeption“ oder „Rezeptur“: `rezept` + `ion`/`ur` ist keine erlaubte Endung); geprüft werden Leistungsnamen, Beschreibungen, Zusatzfeld-Beschriftungen und -Auswahlen, ergänzt um den Betriebsnamen; höchstens 10 Meldungen, ohne Doppelte, ohne Gästedaten. Nur Warnblock für das Team (T16-Block `gesundheit` und Ticket-Kurzfassung, beide füllt Block D4), dem Kunden zeigt Block C nichts.
- [ ] Tests: `bestellbarerStand()` mit `abo: { ok: true }` ⇒ `[]`; je Kriterium (a)–(h) ein Fall mit genau einem Punkt und dem erwarteten `schritt` (Tabelle im Test), u. a. Telefon leer ⇒ `{ schritt: 'website' }`, Anschrift leer ⇒ `'branche'`, `bestaetigt: []` ⇒ acht Punkte in Schrittfolge, `bundeslandGewaehlt: false` bei `einstellung.bundesland === 'RP'` ⇒ trotzdem Punkt, alle Preise `keiner` ⇒ kein Preis-Punkt, `branche: 'gesundheit'` ⇒ Punkt in `branche`, `abo: { ok: false, grund }` für die drei Gründe ⇒ Text == `aboMeldung(feature, grund)`, `abo: undefined` ⇒ `aboMeldung(feature, 'unbekannt')`, `mitAbo: false` ⇒ kein (h)-Punkt, auch bei `abo: undefined` und bei `abo: { ok: false, grund: 'kein_abo' }`; Reihenfolge stabil; alle Texte Du-Form (kein `\bSie\b`). `gesundheitsWarnungen`: je Stichwort ein Treffer (Name, Beschreibung, Zusatzfeld), Groß-/Kleinschreibung und Umlaute; **Regeltest** (Treffer: „Rezept“, „Rezepte“, „Behandlungen“, „Beschwerden“, „Physiotherapie“, „Therapiestunde“, „Zahnärztin“, „Krankengymnastik“, „Osteopathie“; kein Treffer: „Rezeption“, „Rezeptur“, „Gesichtsbehandlung“, „Beratung“, „Besichtigung“); **alle neun `ausVorlage`-Vorlagen ergeben `[]`** (schützt „Gesichtsbehandlung“, „Beratung“, „Besichtigung“); `null` ⇒ `[]`. `bestellungAnsicht`: gleicht `oeffentlicheAnsicht('termine')` (Items mit `haken`-Flag, `consent.knopf` wörtlich, `dokumente` nur mit `url`), `belehrung` == Ergebnis des Hakens, `null` für unbekannte Funktion, enthält nie interne Felder (`ticket`, `mails`). **Integration Checkout (B6, mit dem echten Gate):** Kunde ohne Abo mit `bestellbarerStand()` ⇒ 409 `kein_abo` (nicht `bestellung_offen`); Kunde ohne Abo mit offenem Fragebogen ⇒ 409 `bestellung_offen` ohne Eintrag `bestellen`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Bestell-Gate und Gesundheits-Stichwortpruefung“
### Task C3: Fragebogen in Kontext und Speicher
**Files:** Modify `server/services/termine/kontext.js` (`liesDokument` :58, `ladeEinstellung` :75, `baueKontext` :82, `speichereEinstellung` :156), `test/termineKontext.test.js`.
**Consumes:** C1 (`leerFragebogen`, `normalisiereFragebogen`).
**Produces:** `ctx.fragebogen` (normalisiert; fehlt oder kaputtes JSON ⇒ `leerFragebogen()`, kaputtes JSON mit einer Logzeile, ohne Wurf); `ladeEinstellung(featureId) ⇒ { einstellung, version, fragebogen } | null` (additiv, B und D nutzen es); `kontextFuerPortal(feature) ⇒ Promise<ctx>` (portalFeatures-Dokument in jedem Zustand, kein Modus-/Zustandscheck, den macht der Aufrufer; `kontextPerSiteKey` und `kontextPerFeature` bleiben unverändert); `speichereFragebogen(feature, aendere, { by, now }) ⇒ { fragebogen }` (unter `termine-cfg:<id>`; `aendere(bisher) ⇒ Eingabe`, wird normalisiert, Fehler tragen `err.fehler`; schreibt nur `fragebogen`, `updatedAt`, `updatedBy`, **ohne** `version`; fehlt das termineConfig-Dokument ⇒ 409 `code: 'keine_einstellung'`); neue Option `fragebogen` bei `speichereEinstellung(feature, …, { version, by, now, fragebogen })` schreibt Einstellung und Fragebogen in derselben Sperre (Neuanlage mit Branche, „Vorlage ersetzen“ mit zurückgesetzten Bestätigungen). Beide leeren den Zwischenspeicher.
- [ ] Tests: `speichereFragebogen` ändert `fragebogen`/`updatedAt`/`updatedBy`, `version` und `einstellung` bleiben bytegleich, `ladeEinstellung` liefert den neuen Stand; `speichereEinstellung` ohne Option lässt `fragebogen` stehen; mit Option auf leerer Collection entsteht ein Dokument mit beidem; gleichzeitig `speichereEinstellung` (Version 1) und `speichereFragebogen` ⇒ beide gelten, Version steigt genau einmal; ohne Dokument ⇒ `keine_einstellung`, nichts angelegt; kaputtes Fragebogen-JSON ⇒ leerer Fragebogen und gültige Einstellung; `kontextFuerPortal` liefert für `off`, `ordered`, `setup`, `active`, `disabled` einen Kontext, **`kontextPerSiteKey` für `off`, `ordered`, `setup`, `disabled` mit vollständiger Einstellung `null`** (Review-Focus 3: Entwurf nie öffentlich), nach `activate` den Kontext.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Fragebogen im Kontext und Speicher“
### Task C4a: Entwurfszugriff – lesen in jedem Zustand, GET ohne Dokument, GET-Felder
**Files:** Modify `server/routes/termine.js` (`MELDUNG`, `withTermine`, GET `/einstellungen`), `test/termineRoutes.test.js` (Test „Funktion aus, noch nicht bestellt oder ohne Dokument“ :247-264 wird ersetzt), Create `test/termineFragebogenRoutes.test.js` (Teil 1).
**Consumes:** C1–C3, `aboMemo` (`bestellbar`, E1), `aboMeldung(feature, grund)` aus `featureOrder.js` (B6), `featureConfig` (B), `betriebAusKonto` (bestehend), `analyseHosts`; **E11 bleibt erhalten:** `MELDUNG.abgeschaltet` (Funktion nach Grund), das GET-Feld `abgeschaltet` und `hinweisAbgeschaltet` werden von C4a **nicht** neu geschrieben, sondern übernommen (C4a ersetzt nur die Zweige für `off`/`ordered`/`setup`).
**Produces:** `withTermine({ schreiben, entwurf })`: ohne `entwurf` unverändert (`active|disabled`). Mit `entwurf: true` (nur GET/PATCH `/einstellungen`, POST `/einstellungen/vorlage`, PUT `/fragebogen`): **lesen** in jedem Zustand; Dokument fehlt ⇒ virtueller Kontext `off`, nichts wird angelegt (die Schreibregeln folgen in C4b). Alle anderen Routen behalten die Schranke `active|disabled`. GET liefert zusätzlich `branchen: [{ key, name, gesundheit }]`, `fragebogen`, `kundenansicht: boolean`, in `off`: `abo: { ok, grund? } | null`, `offen: bestellpruefung({ einstellung, fragebogen, feature, abo }) | null` (mit `abo`, also inklusive Punkt (h); bei Lesefehler ohne `abo` ⇒ (h) „unbekannt“), `bestellung: await bestellungAnsicht(…)`; ohne Einstellung `betriebVorschlag: { name, adresse, email, telefon, websiteUrl }` (Konto, Website nur aus `liveDomain`); `einbau.snippet` und `feeds` nur in `active`/`disabled`.
- [ ] Tests (Zustandsmatrix je Zeile Kunde/Kundenansicht, Cookie via `kv()` wie im vorhandenen Kundenansicht-Test): GET ohne Dokument ⇒ 200 `{ state: 'off', einstellung: null, version: 0 }` und `aw.docs('portalFeatures').size` unverändert; GET in `ordered`/`setup` 200, aber `/buchungen`, `/zeiten`, `/export.csv`, `POST /ressourcen/:id/feed`, `POST /buchungen` ⇒ 409 „Der Terminplaner ist für diese Website nicht aktiv.“; **Mandanten (Review-Focus 3): Kunde c2 mit Projekt von c1 ⇒ 403 auf GET**; Abo: ohne Abo GET 200 mit `abo.ok === false`/`grund 'kein_abo'`, `cancelAtPeriodEnd` ⇒ `gekuendigt`; `aw.failNext` auf `subscriptions` ⇒ GET `abo: null` mit offenem Punkt „nicht geprüft“ (Text aus `aboMeldung(…, 'unbekannt')`); `bestellung.consent.knopf` == Registertext; **E11 unverändert:** `disabled` ⇒ `abgeschaltet`-Feld und Meldung nach Grund wie in den E11-Tests (sie bleiben grün).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Entwurfszugriff lesen, GET ohne Dokument, GET-Felder“
### Task C4b: Entwurfszugriff – Schreibregeln, PATCH mit `fehlt`, nie öffentlich
**Files:** Modify `server/routes/termine.js` (`withTermine` Schreibregeln, PATCH `/einstellungen`), `test/termineRoutes.test.js` (PATCH-Antwort mit `fehlt` in :399, die übrigen `fehlt`-Prüfungen :282, :316, :357, :478 bleiben), `test/termineFragebogenRoutes.test.js` (Teil 1b).
**Consumes:** C4a, `laeuft` (E1), `aboMeldung(feature, grund)` aus `featureOrder.js` (B6).
**Produces:** Schreibregeln für `entwurf: true`: `off` und `active` ja, `ordered`/`setup` nur mit `req.session.viewAs` (sonst 409 `code: 'gesperrt'`: „Dein Terminplaner ist bestellt. Die Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.“), `disabled` 409 wie bisher (Text von E11); in `off` zusätzlich `laeuft(customerId)` – **nicht** `bestellbar`: eine Kündigung oder offene Zahlung sperrt nur die Bestellung (Gate (h)), nicht das Speichern des Entwurfs (bewusste Abweichung von Spec 6, im Plan-Kopf vermerkt) –, sonst 409 `code: 'kein_abo'` mit Text aus `aboMeldung`, bei Lesefehler 503 `abo_pruefung` (Entwurf entsteht nur für Kunden mit laufendem Wartungs-Abo). Der erste Schreibzugriff legt das Dokument über `ensureFeatureDoc(customerId, projectId, 'termine')` erst im Handler an, nach der Eingabeprüfung. PATCH ruft `normalisiereEinstellung(…, { entwurf: state !== 'active' })` und antwortet mit `fehlt: fehlendePflichtangaben(…)`.
- [ ] Tests (Zustandsmatrix wie C4a): PATCH `off` ⇒ 200, `active` ⇒ 200, `ordered`/`setup` Kunde ⇒ 409 `gesperrt` (Dokument unverändert), Kundenansicht ⇒ 200 und `termin_einstellungen` protokolliert, `disabled` ⇒ 409 auch in der Kundenansicht; **Mandanten (Review-Focus 3): Kunde c2 mit Projekt von c1 ⇒ 403 auf PATCH, `termineConfig` danach unverändert**; PATCH `off` ohne Telefon/Anschrift ⇒ 200 mit `fehlt: ['betrieb.telefon','betrieb.adresse']`, dieselbe Eingabe in `active` ⇒ 400, ungültige URL in `off` ⇒ weiter 400 (der Entwurf überspringt nur die Pflichtangaben); Abo: ohne Abo PATCH ⇒ 409 `kein_abo`, kein Dokument entstanden; `cancelAtPeriodEnd` oder `past_due` ⇒ PATCH weiter 200 (Entwurf bleibt speicherbar; die Bestellung sperrt Gate (h)); `aw.failNext` auf `subscriptions` ⇒ PATCH 503; öffentlich: für `off`, `ordered`, `setup` mit vollständiger Einstellung liefern `GET /api/public/termine/v1/:siteKey/config` und `POST …/buchungen` `{ aktiv: false }` und der Kalender-Link mit dem richtigen Token 404, nach Aktivierung geht es; **Einbauwünsche nie öffentlich:** `fragebogen.einbau.stelle = 'GEHEIMWUNSCH'` taucht in `JSON.stringify` der öffentlichen Config nicht auf, ebenso nicht `fragebogen`, `einbau`, `knopfText`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Entwurf schreiben, PATCH mit fehlt, nie oeffentlich“
### Task C5: Branche und Vorlage im Entwurf, `PUT /fragebogen`
**Files:** Modify `server/routes/termine.js` (POST `/einstellungen/vorlage` :230-252, neue Route `PUT /fragebogen`), `test/termineFragebogenRoutes.test.js` (Teil 2); die bestehenden Vorlage-Tests in `termineRoutes.test.js` müssen unverändert grün bleiben.
**Consumes:** C1–C4b, `ausVorlage` mit `betrieb`-Feldern (bestehend).
**Produces:** `POST /api/termine/einstellungen/vorlage { projectId, branche | vorlage, betrieb?: { name, adresse, telefon, email, websiteUrl }, ersetzen?: true, version?: int }`: `branche` ⇒ Vorlage laut `BRANCHEN`; unbekannt ⇒ 400; **`gesundheit` ⇒ 400 `code: 'gesundheitsbereich'`, es wird nichts angelegt** (D6, harter Ausschluss); `vorlage` ohne `branche` wie bisher (TP1). Die Betriebsfelder überschreiben nur nichtleere Kontowerte. Eine Einstellung ist nur mit `ersetzen: true`, `version` (muss stimmen, sonst 409 `EINSTELLUNG_VERALTET`) und Zustand `off` ersetzbar (neue IDs und Feed-Token, `fragebogen` neu mit gewählter `branche`, `bestaetigt: []`, `preiseGeprueft: false`, `bundeslandGewaehlt: false`, `einbau` bleibt), in einem Schreibvorgang über die Option `fragebogen` aus C3. Antwort 201 mit `einstellung` (ohne Feed-Token), `version`, `fehlt`, `fragebogen`. `PUT /api/termine/fragebogen { projectId, fragebogen }`: erst `req.session.viewAs` ⇒ 403 „In der Kundenansicht kann nichts bestätigt werden.“, dann `withTermine({ entwurf: true, schreiben: true })` (Abo-Gate in `off`, in `ordered`/`setup`/`disabled` 409), `limitWrites`, `normalisiereFragebogen` (400 mit `fehler`), `speichereFragebogen`; Antwort `{ fragebogen, offen }`; ohne Einstellung 409 `keine_einstellung`; kein Eintrag im Kundenansicht-Protokoll.
- [ ] Tests: Vorlage mit `branche: 'salon'` legt Feature-Dokument `off` (mit dauerhaftem siteKey) und `termineConfig` mit `fragebogen.branche === 'salon'` an; `betrieb.name` überschreibt das Kontowert, leeres Feld nicht; `branche: 'gesundheit'` ⇒ 400, `portalFeatures` und `termineConfig` unverändert (auch ohne vorher vorhandenes Dokument); Vorlage zweimal ⇒ 409; `ersetzen` ohne/mit falscher `version` ⇒ 400/409, in `active` ⇒ 409, in `off` ⇒ neue Leistungen, neue Feed-Token, `bestaetigt` leer, `einbau` erhalten; PUT: speichert, `version` der Einstellung bleibt, `updatedBy` = `kunde:<id>`; zwei Tabs mit offenem Editor (alte `version`) können danach weiter per PATCH speichern (kein 409); ungültiger Modus ⇒ 400 mit `fehler['einbau.modus']`; PUT in `ordered`, `setup`, `disabled` ⇒ 409; **PUT in der Kundenansicht ⇒ 403 `view_blocked` über den Wächter und 403 mit Text direkt am Router (zweite App ohne Wächter)**; Mandant c2 ⇒ 403; Schreibbudget: ein kompletter Durchlauf (1 Vorlage, 8 PATCH, 8 PUT, 5 Editor-Speicherungen) bleibt unter 120 je Stunde, der 121. Aufruf ist 429.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Branche und Vorlage im Entwurf, PUT /fragebogen“
### Task C6: Entwürfe aufbewahren und löschen
**Files:** Create `server/services/termine/entwuerfe.js`, `test/termineEntwuerfe.test.js`. Modify `server/services/termine/jobs.js` (`taeglicherJob` nach `loescheAbgeschaltete`; Rückgabe `entwuerfe`; `tagesbericht` bekommt das Segment `Entwürfe <n> gelöscht (<m> offen)` nach dem Deckel-Segment (A3b) und vor `Abo-Abgleich` (E7); vollständige Zeile: `[termine] Tagesjob: 0 Buchungen gelöscht (verfallen 0, abgesagt 0, abgelehnt 0, erledigt 0, nichterschienen 0, abgeschaltete Funktionen 0), 0 Funktionen bereinigt, 0 Zellen, 0 Outbox, 0 Mails nicht zugestellt, Entwürfe 0 gelöscht (0 offen), Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler, 0 aktive Betriebe`), `server/services/customerAdmin.js` (nach `disableCustomerFeatures`, Fehler nur loggen), `test/termineJobs.test.js` (Test „taeglicherJob: Löschfristen, …“ :867-906 und die festgenagelte Berichtszeile), `test/funktionenTermine.test.js` (Test „Kunde loeschen …“ :173).
**Produces:** `loescheEntwuerfe(now) ⇒ { geprueft, geloescht, uebrig, fehler }` und `loescheEntwuerfeVonKunde(customerId) ⇒ { geloescht }`; Konstanten `ENTWURF_TAGE = 90`, `MAX_GEPRUEFT = 2000`, `MAX_GELOESCHT = 100`. Kandidaten: `portalFeatures` mit `featureKey 'termine'` und `state 'off'`, seitenweise (`limit 100`, `orderAsc('$id')`, `cursorAfter`). Frist: der jüngere Zeitpunkt aus `$updatedAt` des Funktionsdokuments und `termineConfig.updatedAt` ist älter als 90 Tage. Vor jedem Löschen unter `withLocks([feature-order:<kunde>:<projekt>, feature-admin:<projekt>, termine-cfg:<id>])` das Dokument frisch lesen (noch `off`, Frist noch überschritten, keine Buchung zur Funktion), dann `termineConfig` löschen (404 ist ok), das Funktionsdokument nur, wenn es nie in Betrieb oder bezahlt war (kein `activatedAt`, `ticketId`, `disabledAt`, kein `unlockedByPurchase`, `purchaseStatus` weder `paid` noch `zurueckgesetzt`; sonst bleibt es wegen des siteKeys). `zurueckgesetzt` ist die dauerhafte Marke, die E8 nach der Löschung bei Abo-Ende setzt (E8 leert `activatedAt`, `ticketId`, `disabledAt` und `unlockedByPurchase` wieder, damit das Dokument neu bestellbar ist, und lässt den `siteKey` stehen, damit das eingebettete Snippet gültig bleibt): ein so markiertes Dokument verliert höchstens seine Einstellung, **nie** das Funktionsdokument. Nie `ordered`/`setup`. Deckel: nach 100 Löschungen zählt der Rest der Seite als `uebrig`, nach 2000 geprüften Dokumenten endet der Lauf mit einer Logzeile (ohne IDs); gelöschte Kandidaten fehlen am nächsten Tag in der Liste, der Überlauf läuft also an Folgetagen weiter. Einzelfehler zählen in `fehler`, brechen den Lauf nicht ab. Kundenlöschung entfernt alle `off`-Entwürfe des Kunden vollständig (Funktionsdokument und Einstellung).
- [ ] Tests: 89 Tage alt bleibt, 91 Tage ⇒ `termineConfig` und Funktionsdokument weg; frisch bearbeiteter Entwurf (alte `$updatedAt`, neue `termineConfig.updatedAt`, nur `PUT /fragebogen`) bleibt; `ordered`, `setup`, `active`, `disabled` bleiben unabhängig vom Alter; Funktionsdokument mit `activatedAt` verliert nur die Einstellung; **von E8 zurückgesetztes Dokument** (`off`, `purchaseStatus 'zurueckgesetzt'`, sonst alle Spuren leer) behält nach 91 Tagen ohne Änderung sein Dokument und den `siteKey` unverändert (eine vorhandene Einstellung wird gelöscht); nach fehlgeschlagener Zahlung (`purchaseStatus: 'failed'`) ist der Entwurf nach 90 Tagen weg; **Rennen:** der Test hält `withLocks(['feature-order:<kunde>:<projekt>'])`, startet `loescheEntwuerfe`, schreibt das Dokument auf `ordered` und gibt die Sperre frei ⇒ der Lauf liest frisch und löscht nichts; Buchungen zur Funktion ⇒ nichts gelöscht; 130 Kandidaten ⇒ erster Lauf `geloescht 100, uebrig 30`, zweiter `30/0`; Einzelfehler (`aw.failNext` DELETE) ⇒ `fehler 1`, die übrigen werden gelöscht; Tagesjob enthält `entwuerfe`, die Berichtszeile hat keine IDs, kein `@`, keine Namen; bei `TERMINE_ENABLED=off` läuft der Schritt nicht (Entwürfe sind nie bezahlt; bei der Aufteilung von `fuehreAus` durch Block E gehört er zum stillstehenden Teil); Kunde löschen entfernt seine `off`-Entwürfe samt Einstellung, die eines anderen Kunden nicht, ein Fehler dabei hält das Löschen nicht auf (Logzeile).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Entwuerfe nach 90 Tagen und bei Kundenloeschung entfernen“
### Task C7a: Client-Bausteine 1 – reine Helfer in `termine-view.js`
**Files:** Modify `public/termine-view.js` (reine Helfer), `test/termineView.test.js` (Dateiliste, „Spiegel“-Tests), Create `test/termineFragebogenView.test.js`.
**Produces:** in `termine-view.js`: `FRAGEBOGEN_SCHRITTE = [[key, titel]]` (Spiegel von `SCHRITTE`), `schrittStatus(fragebogen) ⇒ [{ key, titel, erledigt }]`, `ersterOffenerSchritt(fragebogen) ⇒ key | 'bestellen'`, `ressourcenAusNamen({ bisher, typ, namen, neueId }) ⇒ { ressourcen, entfernt, neu }` (trimmt, entfernt Doppelte ohne Rücksicht auf Groß-/Kleinschreibung, höchstens `LIMITS.ressourcen`; gleiche Namen behalten ID und Plan, übrige alte Einträge werden in Reihenfolge umbenannt, weitere Namen kopieren den Plan des ersten, `typ` gilt für alle), `buchungsartFuerAlle(leistungen, art)`, `buchungsartLage(leistungen) ⇒ 'sofort'|'anfrage'|'gemischt'`, `zusammenfassung(einstellung, fragebogen, branchen) ⇒ [{ schritt, titel, zeilen: [string] }]` (acht Gruppen, nur Text). **Kein** `loeschDatum`: Löschdatum und Abschalt-Texte gehören E11 (`abschaltText`).
- [ ] Tests: `FRAGEBOGEN_SCHRITTE` gleich `SCHRITTE` des Servers (Spiegel-Test), `schrittStatus`/`ersterOffenerSchritt` für leer, teilweise, vollständig; `ressourcenAusNamen`: „Stylist/in“ → „Anna“ behält ID und Plan, zweiter Name `Ben` bekommt neue ID und den Plan von Anna, Entfernen liefert die IDs, Doppelte/Leere fallen weg, über 20 Namen werden gekürzt, `typ: 'platz'` gilt für alle, Ergebnis besteht `normalisiereEinstellung` (mit Leistungen, die allen zugeordnet sind); `buchungsartFuerAlle` ändert nur `buchungsart`; `zusammenfassung` für die Vorlage `salon` enthält Name, Leistungen mit Preis, Wochenplan, Bundesland nur bei `bundeslandGewaehlt`, nie `feedToken`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Fragebogen-Helfer im Client“
### Task C7b: Client-Bausteine 2 – Exporte der Reiter, `wochenplanFeld`, `fehlt`
**Files:** Modify `public/termine-tab-einstellungen.js` (Abschnitte exportieren), `public/termine-tab-team.js` (Wochenplan-Feld herauslösen), `public/termine-speichern.js:116`, `public/termine.js` (nur `ctx`-Bau herauslösen, Verhalten gleich), `test/termineView.test.js`, `test/termineFragebogenView.test.js` (statische Teile und Verhalten); Create `test/support/fakeDom.js`.
**Consumes:** C7a.
**Produces:** Exporte aus `termine-tab-einstellungen.js`: `betrieb(ctx, e, { nur, titel, hinweis })` (`nur` = Teilmenge von `name, email, telefon, adresse, kleinunternehmer, impressumUrl, datenschutzUrl, agbUrl, websiteUrl`, `lese` schreibt nur diese Felder; ohne `nur` wie bisher), `darstellung`, `regeln`, `gastFelder`, `erinnerung`, `storno`, `rechtliches`, `zahlung`. Aus `termine-tab-team.js`: `wochenplanFeld(r, pfad) ⇒ { el, lese() ⇒ { plan } | { fehler } }` (die Tagesfelder :81-86 und der Wochenplan-Block :108-111 aus `formular`, das ihn danach selbst nutzt). `termine-speichern.js` übernimmt `fehlt: antwort.fehlt ?? []` statt `[]`. Testhilfe `test/support/fakeDom.js`: `installFakeDom({ antworten = [] } = {}) ⇒ { document, fetchLog, finde(baum, vergleich), alle(baum, vergleich), text(baum), klick(el), setze(el, wert), restore() }` – ein Minimal-`document` auf `globalThis` (`createElement`, Elemente mit `setAttribute`, `append`, `addEventListener`, `dataset`, `className`, `value`, `checked`, `children`) und ein Fake-`fetch`, das jeden Aufruf als `{ method, url, body }` in `fetchLog` schreibt und die nächste Antwort aus `antworten` (`{ status, body }`, Standard 200 `{}`) liefert; `h` aus `termine-dom.js` liest `document` bei jedem Aufruf, deshalb braucht der Client keine Einhängepunkte. C8, C9a, C9b und C10b nutzen dieselbe Datei.
- [ ] Tests: statisch: `termine-speichern.js` setzt `fehlt` aus der Antwort; `termine-tab-einstellungen.js` exportiert die acht Abschnitte, `betrieb` ohne `nur` baut weiter alle neun Felder, `wochenplanFeld` wird von `termine-tab-team.js` selbst benutzt (gemeinsame bestehende UI-Skripte `termine-portal.ui.mjs` laufen unverändert grün). **Verhalten mit Fake-DOM** (`termineFragebogenView.test.js`): (a) `betrieb(ctx, e, { nur: ['email'] })` baut nur das Feld `email`, `lese()` liefert nur `{ email }` (Schlüsselmenge geprüft); ohne `nur` entstehen weiter alle neun Felder; (b) `wochenplanFeld(r, pfad)`: gültige Fenster in zwei Tagesfeldern ⇒ `lese()` liefert `{ plan }` mit genau diesen Fenstern, ein ungültiges Fenster ⇒ `{ fehler }` mit Text, ein leerer Tag ⇒ geschlossen; (c) `termine-speichern.js`: antwortet der Fake-`fetch` auf den PATCH mit `fehlt: ['betrieb.telefon']`, enthält das Ergebnis des Speicherns `fehlt: ['betrieb.telefon']` (ohne `fehlt` in der Antwort `[]`).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Reiter-Exporte und wochenplanFeld fuer den Fragebogen“
### Task C8: Bestellseite und Statuskarten (`funktion-bestellen.js`)
**Files:** Create `public/funktion-bestellen.js` (heißt bewusst nicht `termine*.js`: einzige Datei mit `href`-Links, wird relativ importiert, Regel R4 bleibt; **diese** Datei entsteht hier, F8 ändert sie nicht), `test/termineFragebogenView.test.js` (Teil), Modify `public/termine.css` (`tm-bestellen*`, `tm-rechtstext`, `tm-statuskarte`).
**Consumes:** C2 (`bestellung` aus `GET /api/termine/einstellungen`), C7a (`zusammenfassung`), B (`POST /api/funktionen/termine/checkout`), `h`, `request` aus `termine-dom.js`.
**Produces:** `sicherLink(url) ⇒ string` (nur `https:` oder Pfade unter `/rechtliches/`, sonst `''`); `bestellKoerper({ projectId, bestellung, gewaehlt }) ⇒ { projectId, zustimmung: { version, haken } }` (`version` = `bestellung.consent.version`, `haken` = die angehakten IDs der Items mit `haken: true`, nie Texte; das ist der Vertrag aus B6); `fortsetzenKoerper(projectId) ⇒ { projectId, fortsetzen: true }`; `renderBestellen(root, { ctx, daten, zumSchritt })` und `statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht }, { weiter })`. Es gibt **keine** Knopftext-Konstante im Client: der Knopf zeigt `bestellung.consent.knopf` (derselbe Text, den B7 als Eintrag `knopf` im Nachweis speichert; Review Focus 5: gezeigter Text = gespeicherter Text). Reihenfolge auf der Seite: Zusammenfassung (je Gruppe Knopf „Ändern“ ⇒ `zumSchritt(key)`), offene Punkte aus `daten.offen` (je Punkt „Zum Schritt“), Preis mit `bestellung.preisHinweis` und Einrichtungsdauer, die **vollständige Widerrufsbelehrung samt Muster-Formular** aus `bestellung.belehrung` als Text (scrollbarer Bereich, **nie eingeklappt, kein `<details>`**; der Text kommt vom Server: bis F7 der Platzhalter `BELEHRUNG_PLATZHALTER` (B7), ab F7 die Belehrung; F8 ändert diese Datei nicht), Links aus `bestellung.dokumente` (versionierte Adressen, nur über `sicherLink`), die Zustimmungs-Haken in Registerreihenfolge (Wortlaut aus `bestellung.consent.items`; Items mit `haken: true` sind Haken, andere reiner Text), zuletzt der Knopf. Knopf gesperrt, solange ein Haken fehlt, `offen` nicht leer ist oder `kundenansicht` (dann Hinweis „In der Kundenansicht kann nicht bestellt werden.“); ein Klick sperrt ihn, sendet `bestellKoerper` und setzt `window.location.href` nur auf eine `https:`-Adresse; bei Fehler Meldung am Knopf, bei 409 lädt die Seite `daten` neu und setzt die Haken zurück: `bestellung_offen` zeigt die Punkte aus `body.offen`, `fassung_veraltet` die neue Fassung aus `body.consent`, ein Abo-Code den Text aus der Antwort. `statusKarte`: `ordered` ⇒ Pille „Zahlung offen“, Bestelldatum, Knopf „Zahlung fortsetzen“ (POST `fortsetzenKoerper`, Navigation zur offenen Sitzung; antwortet die Route 409 `zustimmung_fehlt`, lädt die Seite neu und zeigt wieder das Bestellformular; Hinweis, dass SEPA einige Tage dauern kann), Chat-Knopf; `setup` ⇒ „In Einrichtung“, Ticketnummer (`ticketWoid`, ohne Ticket „Wir legen dein Ticket gerade an.“), Bestelldatum, Rechnungslink über `sicherLink`, Zeiteinschätzung, Hinweis „Deine Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.“; `danke` (Rückkehr von Stripe) zeigt „Danke! Sobald die Zahlung da ist, legen wir los.“
- [ ] Tests (Node, ohne DOM): `sicherLink` lässt `https://x.de/a` und `/rechtliches/avv-terminplaner-v1.html` durch, weist `javascript:`, `data:`, `http:`, `//evil.example` und Leerraum-Tricks ab; `bestellKoerper` liefert **genau** `{ projectId, zustimmung: { version, haken } }` mit nur den angehakten IDs der `haken: true`-Items (kein `consent`, keine Texte; die gesendeten IDs sind Teilmenge der Register-IDs, Querprüfung gegen `featureConfig('termine').consent.items` in `termineFragebogenView.test.js`); `fortsetzenKoerper` liefert `{ projectId, fortsetzen: true }` ohne `zustimmung`; statisch (wie die `termine*.js`-Regeln): kein `innerHTML`/`confirm`/`alert`, **kein Literal „Zahlungspflichtig“ im Client** (der Knopftext kommt aus `bestellung.consent.knopf`), Du-Form im Code (die Rechtstexte kommen vom Server), jedes `href` nur über `sicherLink(`, `location.href =` nur nach Prüfung auf `https:`, kein `localStorage`, kein `<details>` für die Belehrung; Datei ist in `termine*.js` nicht enthalten und wird von keinem absoluten Import geladen (`funktionen.js` behält genau `/analyse-view.js` und `/geraete.js`); Reihenfolge im erzeugten Baum (Fake-DOM aus C7b): Belehrungstext steht vor dem Knopf; Knopftext == `bestellung.consent.knopf` (Integration „Knopftext == Registertext == gespeicherter `knopf`-Eintrag“ in C11).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: Bestellseite und Statuskarten“
### Task C9a: Fragebogen-Oberfläche 1 – Gerüst und Schritte 1 bis 3
**Files:** Create `public/termine-fragebogen.js`, Modify `public/termine.css` (`tm-schritte`, `tm-schritt`, Handy-Ansicht), `test/termineView.test.js` (Dateiliste: `termine-fragebogen.js` zwischen `termine-formulare.js` und `termine-gaestetexte.js`; neue Regeln unten), `test/termineFragebogenView.test.js` (Verhalten mit Fake-DOM aus C7b).
**Consumes:** C4a/C4b, C5 (Routen), C7a/C7b, C8, `neuerSpeicher` (einziger PATCH-Weg), `render` aus `termine-tab-leistungen.js`.
**Produces:** `renderFragebogen(root, ctx)` (ctx wie bei den Reitern, `hinweise` leer): Fortschrittsleiste `<ol>` mit `aria-current="step"` (Handy: „Schritt 3 von 8“ + Liste), Start beim ersten offenen Schritt (alles erledigt ⇒ Bestellseite), freie Navigation, ein `<form>` je Schritt, „Weiter“ = **ein** PATCH über den `neuerSpeicher` des Schritts (nur wenn der Schritt Felder hat) und danach `PUT /fragebogen` mit dem Schritt in `bestaetigt`; Fehler am Feld (`setzeFehler`), Fokus auf die Schrittüberschrift beim Wechsel, Hinweis „Wir speichern deinen Entwurf bis zu 90 Tage nach deiner letzten Änderung.“ Schritte 1 bis 3:
1 **Branche und Betrieb:** ohne Einstellung Radiogruppe aus `daten.branchen` plus `betrieb(…, { nur: ['name','adresse','kleinunternehmer'] })` mit `betriebVorschlag`, „Mit dieser Branche starten“ ⇒ `POST vorlage { branche, betrieb }` ⇒ PUT; **„Gesundheitsbereich“ ⇒ Erklärtext statt Weiter, es wird keine Anfrage gesendet**; mit Einstellung: „Branche wechseln“ mit Rückfrage in der Seite („ersetzt Leistungen, Zeiten und Regeln, alle Bestätigungen gehen zurück“) ⇒ `ersetzen: true` + `version`.
2 **Wer wird gebucht:** Einzelperson / Team / Plätze, Namen je Zeile (`ressourcenAusNamen`); neue Einträge werden im selben PATCH allen Leistungen zugeordnet, entfernte aus Leistungen und Abwesenheiten genommen; mindestens ein Name.
3 **Leistungen:** `render` aus `termine-tab-leistungen.js` unverändert (speichert selbst) plus Haken „Ich habe die Preise geprüft (die Preise der Vorlage sind Platzhalter)“ ⇒ `preiseGeprueft`; Weiter braucht eine sichtbare Leistung und bei Preisen den Haken; nur PUT.
`daten.kundenansicht === true`: PATCH bleibt möglich, „Weiter“ geht ohne PUT weiter, die Bestellseite ist gesperrt (Review-Focus 3, Kundenansicht bestätigt nichts). Nach Schritt 8 (C9b) folgt `renderBestellen`.
- [ ] Tests: statisch in `termineView.test.js`: Datei in der Liste, kein `innerHTML`, Du-Form, kein `href:` (der einzige bleibt `exportUrl`), `'PATCH'` weiter nur in `termine-speichern.js`, `'PUT'` nur in `termine-fragebogen.js`, kein Zugriff auf `localStorage`; Eintrag per ID statt Stelle (bestehende Regel gilt auch hier); `renderFragebogen` wird nur über `termine.js` geladen. **Verhalten in Node** (`test/termineFragebogenView.test.js`, Fake-DOM und Fake-`fetch` aus C7b): (a) Schritt 1, Auswahl „Gesundheitsbereich“: der Erklärtext steht im Baum, es gibt keinen Knopf „Mit dieser Branche starten“ und kein „Weiter“, `fetchLog` bleibt leer (kein einziger Aufruf); (b) „Weiter“ in einem Schritt mit Feldern (Schritt 7): `fetchLog` ist genau `[PATCH /api/termine/einstellungen, PUT /api/termine/fragebogen]` in dieser Reihenfolge, der PUT-Body enthält `projectId` und `fragebogen.bestaetigt` mit dem Schritt; antwortet der PATCH mit 400 und `fehler`, folgt **kein** PUT und der Fehler steht am Feld (`data-fehler`); (c) Schritt 3 mit Preisen ohne den Haken „Preise geprüft“ ⇒ „Weiter“ meldet den Fehler und ruft nichts auf, mit Haken ⇒ nur ein PUT (kein PATCH) mit `preiseGeprueft: true`; (d) `daten.kundenansicht === true`: „Weiter“ sendet den PATCH, aber **keinen** PUT und wechselt trotzdem zum nächsten Schritt; (e) Start beim ersten offenen Schritt (`aria-current="step"` am richtigen Listenpunkt); (f) Schritt 2 mit den Namen „Anna“ und „Ben“ ⇒ der PATCH-Body enthält beide Ressourcen und jede Leistung ist beiden zugeordnet. Browser-Layout und echter Server in C11 (Playwright), reine Teile in C7a.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Fragebogen-Geruest und Schritte 1 bis 3“
### Task C9b: Fragebogen-Oberfläche 2 – Schritte 4 bis 8 und Bestellseite
**Files:** Modify `public/termine-fragebogen.js`, `public/termine.css`, `test/termineView.test.js` (zusätzliche Importregeln), `test/termineFragebogenView.test.js` (Verhalten mit Fake-DOM aus C7b).
**Consumes:** C9a, C7b (`wochenplanFeld`, `betrieb`, `darstellung`, `regeln`, `gastFelder`, `erinnerung`, `storno`, `rechtliches`, `zahlung`), C8 (`renderBestellen`), `abwesenheiten`/`sonderoeffnungen` aus `termine-team-ausnahmen.js`.
**Produces:** Schritte 4 bis 8:
4 **Zeiten:** je Person/Platz `wochenplanFeld`, Bundesland **ohne Vorauswahl** (leere erste Option) und „An Feiertagen geschlossen“, im PATCH; danach PUT mit `bundeslandGewaehlt: true`; darunter `abwesenheiten(t)` und `sonderoeffnungen(t)` (speichern selbst, mit eigenem Container und eigenem `zeichne`, damit ungespeicherte Zeiten oben beim Neuzeichnen nicht verloren gehen).
5 **Regeln:** Sammelknopf „Alle sofort buchbar / Alle nur als Anfrage“ (`buchungsartFuerAlle`, Anzeige `buchungsartLage`), `regeln`, `storno(ctx, e, regeln.absage)` (das Muster folgt der Absagefrist), `rechtliches`, `zahlung`.
6 **Gastfelder:** `gastFelder`. 7 **Benachrichtigung:** `betrieb(…, { nur: ['email'] })` (einzige Adresse, auch Reply-To), `darstellung`, `erinnerung`. 8 **Website:** `betrieb(…, { nur: ['telefon','impressumUrl','datenschutzUrl','agbUrl','websiteUrl'] })` plus Einbauwünsche (Modus „direkt auf der Seite / hinter einem Knopf“, Knopftext bei Knopf, Stelle, Gestaltung, Bemerkung; Hinweis, dass ein zweites Widget, Knöpfe je Leistung und Sonderdesign nach Stunden gehen) im PUT. Danach `renderBestellen`.
- [ ] Tests: statisch in `termineView.test.js`: `termine-fragebogen.js` importiert `abwesenheiten`/`sonderoeffnungen` aus `termine-team-ausnahmen.js` und `wochenplanFeld` aus `termine-tab-team.js`, kein zweiter Zugriff auf `'PATCH'`/`'PUT'` außerhalb der erlaubten Dateien, Bundesland-Auswahl hat keine Vorauswahl (leere erste Option im Quelltext). **Verhalten in Node** (`test/termineFragebogenView.test.js`, Fake-DOM und Fake-`fetch`): (a) Schritt 4: die erste Option des Bundesland-`select` hat `value ''` und keine Option ist `selected`; „Weiter“ ohne Wahl ⇒ Fehler am Feld, `fetchLog` leer; mit Wahl `RP` ⇒ PATCH (`bundesland: 'RP'`, Wochenplan aus `wochenplanFeld`), danach PUT mit `bundeslandGewaehlt: true` und `bestaetigt` mit `zeiten`; (b) Schritt 7: der PATCH enthält aus `betrieb` nur `email` (Schlüsselmenge geprüft), Schritt 8 nur `telefon`, `impressumUrl`, `datenschutzUrl`, `agbUrl`, `websiteUrl`; der PUT enthält `einbau` mit `modus`, `knopfText`, `stelle`, `gestaltung`, `bemerkung`, bei Modus „hinter einem Knopf“ ohne Knopftext ⇒ Fehler am Feld und kein Aufruf; (c) nach Schritt 8 zeigt `renderBestellen` die Bestellseite (Knopfbeschriftung == `bestellung.consent.knopf`). Browser-Layout und echter Server in C11 (Playwright).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Fragebogen-Schritte 4 bis 8 und Bestellseite“
### Task C10a: Kachel mit Bestellweg und Abo-Felder in `GET /api/funktionen`
**Files:** Modify `public/funktionen.js` (`termineTile` :151-180, `pickTermineProject`), `server/routes/funktionen.js:109`, `test/funktionenTiles.test.js` (Tests ab :82 „Aus ⇒ …“ und „Abgeschaltet“), `test/funktionenTermine.test.js` (Test „GET /api/funktionen: termine.verfuegbar …“ :144), `test/ui/termine-portal.ui.mjs` (Prüfungen `fx_kachel_aus_aktiv_abgeschaltet` :306 und `fx_projektwahl` :377 pinnen die alten Texte).
**Consumes:** E1 (`bestellbar`, `aboMeldung` über `featureOrder.js`, B6), E11 (Texte der Abschaltung bleiben unberührt), B (`featureConfig('termine')`).
**Produces:** `GET /api/funktionen` ⇒ `termine: { verfuegbar: false }` unverändert; bei `true` zusätzlich `bestellbar: boolean|null`, `grund: 'kein_abo'|'gekuendigt'|'nicht_aktiv'|null`, `preis` (`priceLabel`), `einrichtung` (`setupEstimate`); ein Lesefehler im Abo ergibt `bestellbar: null` und kostet die Liste nichts. **Kachel** (Pille / Knopf): `off` mit Abo ⇒ „Aus“ / „Terminplaner einrichten · 149 €“, mit vorhandenem Entwurf (Funktionsdokument da, `siteKey` gesetzt) „Fragebogen fortsetzen“; `off` ohne Abo ⇒ kein Knopf, „Im Wartungs-Abo enthalten. Zu Verträge“ (`kein_abo`), „Dein Wartungs-Abo ist gekündigt …“, „… gerade nicht aktiv …“, bei `bestellbar === null` „Das Abo konnte gerade nicht geprüft werden.“; `ordered` ⇒ „Zahlung offen“ / „Status ansehen“; `setup` ⇒ „In Einrichtung“ / „Status ansehen“; `active` ⇒ „Aktiv“ / „Termine öffnen“; `disabled` ⇒ „Abgeschaltet“ / „Termine ansehen“. Zielprojekt: aktiv, dann `ordered`/`setup`, dann abgeschaltet, dann Entwurf, dann erstes.
- [ ] Tests: `funktionenTermine`: `{ verfuegbar: false }` exakt wie bisher, bei `true` die vier neuen Felder für Kunde mit Wartungs-Abo, ohne Abo (`kein_abo`), mit `cancelAtPeriodEnd` (`gekuendigt`), Abo-Lesefehler (`bestellbar: null`, Projekte kommen trotzdem); `funktionenTiles` (Node, `fetch`-Attrappe wie bisher): jede Zeile der Kacheltabelle mit Pillenklasse und Knopftext, `ordered`/`setup` ohne Zahl-Abfrage an `/api/termine`, Mehrprojekt-Auswahl, Entwurf ⇒ „Fragebogen fortsetzen“, kein `data-view="termine"` ohne Abo, Projektname maskiert; die E11-Tests zu `disabled` bleiben unverändert grün; die zwei Playwright-Prüfungen oben sind auf die neuen Kachel-Texte angepasst und grün.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Kachel mit Bestellweg, Abo-Felder in der Funktionen-Liste“
### Task C10b: Ansicht `#termine` nach Zustand und Rückkehr von Stripe
**Files:** Modify `public/termine.js` (`zeigeProjekt` :75-101, `initTermine`, Reiterliste), `test/funktionenTiles.test.js` (Rückkehr-Tests), `test/termineFragebogenView.test.js` (Ansicht je Zustand mit Fake-DOM aus C7b), `test/ui/termine-portal.ui.mjs` (Fixture um `abo`, `branchen` erweitern).
**Consumes:** C4a (GET-Felder), C8, C9a/C9b, C10a (Kachel führt in die Ansicht), E1 (`bestellbar`), E11 (Banner und Texte der Abschaltung bleiben unberührt), B (`featureConfig('termine')`).
**Produces:** **Ansicht** (`zeigeProjekt`): `off` ⇒ ohne `abo.ok` Hinweis wie in der Kachel (kein Fragebogen), sonst `renderFragebogen`; `active` ohne Einstellung weiter die TP1-Vorlagenauswahl; `ordered`/`setup` ⇒ `statusKarte` und darunter die Reiter Leistungen, Team & Plätze, Einstellungen schreibgeschützt (`nurLesen`, der Reiter „Termine“ entfällt, er bekäme 409); `disabled` ⇒ unverändert der Banner aus E11 (`hinweisAbgeschaltet`/`abschaltText`, Löschdatum aus `abgeschaltet.loeschungAm`); C10b baut **keine** eigenen Abschalttexte und kein eigenes Löschdatum. Rückkehr `#termine?bestellt=1&projekt=<id>` (B2 `success_url`) **und** Abbruch `#termine?projekt=<id>` (B2 `cancel_url`): der Parameter `projekt` wird in beiden Fällen ausgewertet (Projekt wählen), `danke` nur mit `bestellt=1`; Flag und Projekt einmal lesen, Hash per `history.replaceState` bereinigen (wie `initNews`).
- [ ] Tests: **Ansicht je Zustand** (Fake-DOM und Fake-`fetch` aus C7b, der Fake liefert die GET-Antwort von `/api/termine/einstellungen`): `off` mit Abo ⇒ `renderFragebogen` (Fortschrittsleiste im Baum), `off` ohne `abo.ok` ⇒ Hinweis und **kein** Fragebogen, `active` ohne Einstellung ⇒ TP1-Vorlagenauswahl, `ordered`/`setup` ⇒ `statusKarte` und die schreibgeschützten Reiter ohne Reiter „Termine“, `disabled` ⇒ Banner aus E11 (`hinweisAbgeschaltet`) mit dem Löschdatum aus `abgeschaltet.loeschungAm`; Rückkehr: `#termine?bestellt=1&projekt=p2` wählt p2 und zeigt `danke`, `#termine?projekt=p2` (Abbruch) wählt p2 **ohne** `danke`, in beiden Fällen ist der Hash bereinigt; die E11-Tests zu `disabled` bleiben unverändert grün; Playwright-Fixture-Skripte angepasst und grün.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Ansicht nach Zustand und Rueckkehr von Stripe“
### Task C11: UI-Testserver für Entwürfe und Playwright „Fragebogen bis Bestellklick“
**Files:** Modify `test/ui/termineTestServer.mjs` (Schema um `subscriptions`, `featureConsents`; `zuruecksetzen({ entwurf: true })`: kein Funktionsdokument, keine Einstellung, Wartungs-Abo `wartungDoc` für `c1`; Aufräumliste um beide Collections; `srv.abo(zustand)` und `srv.kundenansichtCookie()`), Create `test/ui/termine-fragebogen.ui.mjs`. Nicht Teil von `npm test`; vorher `free -m`, Aufruf `nice node test/ui/termine-fragebogen.ui.mjs`, Bilder nach `/root/reports/terminplaner-ui/fragebogen-*.png`.
**Consumes:** C4a–C10b, B (Collection `featureConsents` über `feature-setup.mjs`/`schemaKeys`, Checkout-Route), E (`aboStatus`), fake Stripe (`checkout.stripe.test`, wird per `page.route` abgefangen, kein Netz).
Prüfungen (je eine, nacheinander, `check()`-Rahmen wie in `termine-portal.ui.mjs`: fester Browser-Takt Montag 2026-10-05 10:00, Portal-Cookie `srv.portalCookie()`, Seitenfehler und Browser-Dialoge sind Fehler, Hosts außerhalb `127.0.0.1`/`localhost`/`checkout.stripe.test` sind Fehler):
- [ ] `ohne_abo`: Kachel „Im Wartungs-Abo enthalten“, Ansicht zeigt den Hinweis, es gibt keinen Fragebogen, `portalFeatures` bleibt leer.
- [ ] `gesundheit_blockt`: Kachel „Terminplaner einrichten · 149 €“ ⇒ Branche „Gesundheitsbereich“ ⇒ Erklärtext, kein „Weiter“, **kein einziger Schreibaufruf**, kein Dokument in Appwrite.
- [ ] `fragebogen_komplett_bis_bestellklick`: Branche „Friseur und Salon“ (Name/Anschrift aus dem Konto) ⇒ Start legt Funktionsdokument `off` und Einstellung an, Schritte 2 bis 8 ausfüllen (Team mit zwei Namen, Preise geprüft, Bundesland „Rheinland-Pfalz“ gewählt, „Alle sofort buchbar“, Telefon, Impressum, Datenschutz, Modus „hinter einem Knopf“ mit Text) und nach jedem „Weiter“ steht der Schritt in `fragebogen.bestaetigt` (aus `termineConfig` gelesen), `version` der Einstellung steigt nur durch PATCH; Bestellseite: offene Punkte leer, Knopf gesperrt, **Widerrufsbelehrung und Muster-Formular stehen im DOM vor dem Knopf** (`compareDocumentPosition`; bis F7 der Platzhaltertext `BELEHRUNG_PLATZHALTER`, B7), nach allen Haken aktiv; Klick ⇒ Navigation zu `https://checkout.stripe.test/c/pay/cs_test_…`; danach Funktionsdokument `ordered` mit `stripeSessionId`, genau ein `POST /v1/checkout/sessions` mit `metadata.featureKey 'termine'` und `projectId`, ein `featureConsents`-Dokument mit den gezeigten Texten (Haken, Belehrung, **Knopf**: gespeicherter `knopf`-Eintrag == `bestellung.consent.knopf` == Beschriftung des geklickten Knopfes), Fassungen und Zeit, gespeichert **bevor** die Navigation lief (Zeitstempel kleiner als der Aufruf der Stripe-Attrappe; die Sitzung selbst entsteht davor, B7).
- [ ] `offene_punkte_sperren`: Fragebogen mit fehlendem Impressum ⇒ Bestellseite listet den Punkt mit „Zum Schritt“, der Knopf bleibt gesperrt, Klick auf den Punkt öffnet Schritt 8; ohne Bundesland ⇒ Punkt im Schritt „Zeiten“, obwohl der Serverstandard `RP` gespeichert ist.
- [ ] `rueckkehr_und_zahlung_fortsetzen`: Seed `ordered` mit Stripe-Sitzung **und** dem `featureConsents`-Dokument dieser Sitzung; Seite `#termine?bestellt=1&projekt=p1` ⇒ Statuskarte „Zahlung offen“ mit „Danke!“, Hash bereinigt, Einstellungen schreibgeschützt (Felder gesperrt, Reiter ohne „Termine“); „Zahlung fortsetzen“ (Body `{ projectId, fortsetzen: true }`) ⇒ Navigation zur selben Sitzung, es gibt weiterhin genau eine Checkout-Sitzung und weiterhin genau ein `featureConsents`-Dokument; ohne das `featureConsents`-Dokument (Seed) ⇒ keine Navigation, die Seite zeigt wieder das Bestellformular (409 `zustimmung_fehlt`); Abbruch-Rückkehr `#termine?projekt=p1` ohne „Danke!“; ein PATCH direkt am Router ⇒ 409 `gesperrt`; Zustand `setup` per Seed ⇒ Karte mit Ticketnummer und Rechnungslink.
- [ ] `entwurf_bleibt_beim_neuladen`: nach Schritt 4 Seite neu laden ⇒ Fragebogen startet bei Schritt 5, Eingaben der Schritte 1 bis 4 stehen in den Feldern; Branche wechseln mit Rückfrage ⇒ Bestätigungen zurückgesetzt, `einbau` bleibt.
- [ ] `kundenansicht`: Portal mit Kundenansicht-Cookie (`srv.kundenansichtCookie()`, neu, baut auf `kundenansichtCookie` aus `test/support/fixtures.js`; der Wächter hängt im UI-Testserver nicht, geprüft wird die Absicherung am Router selbst) ⇒ Schritte lassen sich lesen und per „Weiter“ durchklicken, `fragebogen.bestaetigt` bleibt unverändert, Bestellknopf gesperrt mit Hinweis, `PUT /fragebogen` direkt ⇒ 403.
- [ ] `handy_390`: Fortschritt „Schritt n von 8“, keine seitliche Rollleiste (`scrollWidth <= innerWidth`) auf Schritt 1, 4, 8 und Bestellseite; Bilder.
- [ ] FAIL → implementieren (nur Testserver und Skript, keine Produktivcode-Änderung) → alle Prüfungen grün → vorhandene UI-Skripte `termine-portal.ui.mjs` und `funktionen.ui.mjs` einmal nacheinander mit durchlaufen lassen → Commit „Terminplaner TP2: UI-Testserver Entwurf und Playwright Fragebogen bis Bestellklick“
**Abschlussprüfung Block C (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. Playwright-Läufe (`termine-fragebogen.ui.mjs`, `termine-portal.ui.mjs`, `funktionen.ui.mjs`) nacheinander, vorher `free -m`, mit `nice`, nicht Teil von `npm test`.
---
## Block D: Ticket, Einbauanleitung, WOMS
Aus Einstellungen und Fragebogen entsteht eine reine, deterministische Einbauanleitung (`einbauanleitung.js`): vollständig im WOMS-Panel mit Kopierknöpfen, als Kurzfassung (≤ 7000 Zeichen) in `ticket.details`. Die Freischaltung über das Register verallgemeinert Block B (B11, Termine: Einstellung vorhanden **und** Abo läuft); D liefert die Hindernis-Codes (`termine/freischaltung.js`) und die Team-Aktionen „Freischalten (Abnahme)“ und „Ticket nachlegen“. Das Ticket selbst (`createFeatureTicket`: Deckel, gezielte Wiederholung, Notfall-Details) gehört Block B (B8); ein eigener D5 entfällt. Nie `feedToken` in Ausgaben, Kundentexte nur als Text, kein Termine-Modus-Gate und kein Zugriff aus der Kundenseite.
Vorbedingungen aus früheren Blöcken: B (`featureConfig`, `config.features.termine.{ priceLabel, ticket, ticketFreischaltung }`, B8 `ticketTexte`/`anleitungsLink`/`kuerze`/`TICKET_DETAILS_MAX` in `featureRegistry.js` und das Gerüst `termine/ticketDetails.js`, B9b T16 mit Block `gesundheit`, B9a K15/K16, B11 `activateClosedTickets` samt Standardhaken, `markPaid` ruft `ticket.topic/details`, K16/T16 mit `{{funktion}}`), C (`termineConfig.fragebogen`, `gesundheitsWarnungen`), E (`aboStatus.laeuft`, `aboMemo`). Reihenfolge D1 → D10 (D1–D8 Portal, D9–D10 WOMS; D3a vor D3b; D5 entfällt).
### Task D1: Datenschutz-Absatz als Rechtstext-Baustein
**Files:** Modify `server/services/termine/rechtstexte.js` (neu `datenschutzAbsatz`, `RECHTSTEXTE_ENTWURF`; Kopfkommentar „ohne config“ ersetzen durch „nur Hilfsfunktionen aus einstellungen.js“), `test/termineRechtstexte.test.js`. `docs/rechtliches/termine-datenschutz-absatz.md` (Fassung 2026-10-02) bleibt inhaltlich unverändert; den endgültigen Wortlaut setzt Block F in Markdown **und** Funktion gemeinsam.
**Consumes:** `zeigtWiderruf(einstellung)` aus `einstellungen.js` (Bedingung „Gäste Verbraucher“; deckt sich mit dem Widerrufsknopf im Widget), `RECHTSTEXTE_VERSION`. **Produces:** `datenschutzAbsatz(einstellung, anrede) ⇒ string` (reiner Text, Absätze durch Leerzeile, erste Zeile „Online-Terminbuchung“, nicht HTML-maskiert wie `widerrufsbelehrung`; `anrede === 'du'` ⇒ Du-Fassung, sonst Sie wie `formularHinweis`), `RECHTSTEXTE_ENTWURF = true` (Block F setzt `false`, sobald die ENTWURF-Köpfe fallen). Bedingte Sätze nach der Markdown-Quelle: Telefon-Feld (`gastFelder.telefon !== 'aus'`), Nachricht/Zusatzfelder (`gastFelder.nachricht !== 'aus' || gastFelder.zusatz.length > 0`), Erinnerung (`erinnerung.anbieten`), Widerruf (`zeigtWiderruf`), Telefon hinterlegt (`betrieb.telefon`). Platzhalter: `betrieb.name/adresse/email/telefon`, `aufbewahrungTage` (ungültig ⇒ 90 wie `formularHinweis`). Fehlen Name oder E-Mail ⇒ `TypeError` wie `betriebsdaten`.
- [ ] Tests: (a) Pin gegen die Quelle: aus „Fassung Sie“/„Fassung Du“ den Blockquote lesen (`> `, `**`, `###` entfernt, Leerraum normalisiert), `[[wenn …: X]]` bei „alle Bedingungen an“ durch X ersetzen, bei „alle aus“ streichen, `{{Firma}}`/`{{Anschrift}}`/`{{E-Mail}}`/`{{Telefon}}`/`{{Tage}}` durch Testwerte ersetzen; das Ergebnis ist gleich `datenschutzAbsatz(…)` (ebenfalls normalisiert) für beide Zustände und beide Anreden. (b) Jede Bedingung schaltet genau ihren Satz (z. B. `anbieten: false` ⇒ kein „Erinnerung“, `gaesteSind: 'unternehmer'` ⇒ kein „Vertrag widerrufen“, `telefon: 'aus'` ⇒ kein „Telefonnummer“). (c) `aufbewahrungTage: 365` ⇒ „365 Tage nach dem Termin“, `'viel'` ⇒ 90. (d) Sie-Fassung ohne Du-Wörter, Du-Fassung ohne Sie-Wörter (Regexe aus dem bestehenden Datenschutz-Absatz-Test wiederverwenden). (e) Ohne Name oder E-Mail ⇒ `TypeError`; leere Anschrift ⇒ „Verantwortlich ist X, E-Mail Y.“ ohne leeres Komma. (f) `RECHTSTEXTE_ENTWURF === true` (Block F10b2 setzt die Konstante auf `false` und ändert diese Assertion zusammen mit den Köpfen `KOPF` der Dokumente).
- [ ] FAIL → implementieren (Absätze als kleine Funktionen mit Bedingung, je Anrede eine Textliste; kein Lesen der Markdown-Datei zur Laufzeit) → PASS → `npm test` → Commit „Terminplaner: Datenschutz-Absatz als Rechtstext-Baustein“
### Task D2: Einbau-Snippet (verschoben, mit Knopf) und Farbvorschlag mit WCAG-Kontrast
**Files:** Create `server/services/termine/einbauanleitung.js` (zunächst nur diese Bausteine), `test/termineEinbauanleitung.test.js`; Modify `server/routes/termine.js` (`:182-194` lokales `esc` und `einbauSnippet(ctx)` entfernen, Aufrufstelle `:212` ⇒ `einbauSnippet({ siteKey: ctx.feature.siteKey, apiBase: config.publicBaseUrl, telefon: ctx.einstellung?.betrieb?.telefon })`; Modus bleibt `inline`, die Kundenansicht ändert sich nicht; Merge-Hinweis: Block C ändert dieselbe Datei an `withTermine` und GET `/einstellungen`), `server/services/termine/einstellungen.js` (`VORGABE_FARBE` exportieren), `test/termineEmbedStatic.test.js` (Farbparität).
**Produces:** `esc(text)`, `einbauSnippet({ siteKey, apiBase, telefon, modus = 'inline', knopfText })`, `luminanz(hex)`, `kontrast(hexA, hexB)`, `textFarbeAuf(hex) ⇒ '#ffffff' | '#111827' | '#000000'`, `cssVorschlag(farbe) ⇒ { css, akzent, akzentText, kontrastText, kontrastSeite, hinweise: string[] }`. Snippet: wie bisher (`<div data-webklar-termine=… data-api=… data-modus=…>`, Ausweichtext mit Telefon, `<script src="…/embed/termine.js" defer>`); Modus `knopf` ergänzt `data-knopf="…"` nur bei gesetztem Text (Widget-Vorgabe „Termin buchen“); Text ≤ 40 Zeichen, Zeilenumbrüche zu Leerzeichen, durch `esc` maskiert; unbekannter Modus ⇒ `inline`. `cssVorschlag`: nur gültige `#rrggbb` (sonst `VORGABE_FARBE`, nie Rohtext im CSS), Kommentar + `[data-webklar-termine]{ --wkt-akzent; --wkt-akzent-text }`; `hinweise` warnt, wenn der Akzent gegen Weiß unter 3 : 1 liegt (Rahmen freier Tage, `termine-einbau.md` Abschnitt 3).
- [ ] Tests: Snippet inline ist für `{ siteKey: 'abcdefghijklmnop', apiBase: 'https://project.webklar.com', telefon: '06301 1234' }` zeichengleich zu Abschnitt 1 von `docs/funktionen/termine-einbau.md`; `telefon: '06301 <12>'` ⇒ `&lt;12&gt;`; ohne Telefon „Termine telefonisch vereinbaren.“; Modus `knopf` mit `knopfText: 'Jetzt "buchen" & mehr'` ⇒ `data-modus="knopf" data-knopf="Jetzt &quot;buchen&quot; &amp; mehr"`; Knopf ohne Text ⇒ kein `data-knopf`; Text mit 60 Zeichen und `\n` ⇒ 40 Zeichen, keine Umbrüche; Modus `'dialog'` ⇒ `inline`. `luminanz('#ffffff') === 1`, `kontrast('#000000', '#ffffff') === 21`, `textFarbeAuf('#0f766e') === '#ffffff'`, `textFarbeAuf('#f5c542') === '#111827'`; für alle 4096 Farben mit Kanalwerten in 17er-Schritten ist `kontrast(farbe, textFarbeAuf(farbe)) >= 4.5`; **Parität**: dieselben 4096 Farben liefern in `public/embed/termine.js` (Marken `@farben-anfang`/`@farben-ende`, per `vm` wie `farbFunktionen()` im Bestandstest) und hier dasselbe `textFarbeAuf`. `cssVorschlag('#9d4b6f').css` enthält `--wkt-akzent:#9d4b6f;` und `--wkt-akzent-text:#ffffff;`; `'red'`, `'#fff'`, `undefined`, `'#9d4b6f;}</style>'` ⇒ `#7c3aed` und der Rohtext steht nirgends in der Ausgabe; `cssVorschlag('#f5c542').hinweise` nennt den Kontrast gegen Weiß. Bestehende `termineRoutes.test.js`-Assertions zum Snippet (`:283-305`) bleiben unverändert grün.
- [ ] FAIL → implementieren (Farbfunktionen 1:1 aus `public/embed/termine.js:192-216`, nicht aus dem Widget importieren) → PASS → `npm test` → Commit „Terminplaner: Einbau-Snippet mit Knopf und Farbvorschlag nach WCAG verschoben“
### Task D3a: Generator `einbauanleitung()` 1 – Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen
**Files:** Modify `server/services/termine/einbauanleitung.js`, `test/termineEinbauanleitung.test.js`.
**Consumes:** `einstellungen.js` (`preisText`, `zahlungText`, `zeigtWiderruf`, `hatPreis`), `zeit.js` (`heute`, `datumKurz`), B `config.features.termine.priceLabel` (hier „149 €“), `config.publicBaseUrl`. **Produces:** `einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt = null, kunde = null, entwurf = RECHTSTEXTE_ENTWURF }) ⇒ { stand: { version, updatedAt }, warnungen, abschnitte, text, kurz }` (`feature` = **`portalFeatures`-Dokument** (der Registereintrag heißt in B `feature`, der Aufrufer D4 übergibt `ctx.doc`), `project` = `websiteProjects`-Dokument, `fragebogen` = geparstes Objekt oder `null`, `kunde` = `{ name }`, die Anrede kommt aus `einstellung.anrede` (`'sie'` | `'du'`, kein eigener Parameter); **rein = kein I/O**, `config` wird beim Aufruf gelesen; `kurz` kommt in D4), hier die Abschnitte `modell`, `leistungen`, `ressourcen`, `regeln`, `gastfelder`, `benachrichtigungen` (feste Reihenfolge, `kopierbar: false`) und `stand`. Der Link ins WOMS ist `anleitungsLink(customerId, projectId)` aus `featureRegistry.js` (B8). **Feldweißliste:** gelesen werden ausdrücklich benannte Felder, nie Objekte kopiert (kein `feedToken`, keine Betriebs-E-Mail außer im Datenschutz-Absatz). Kundentexte (Leistungsnamen, Ressourcenbezeichnungen) nur als Text. Ablaufende Dinge (Abwesenheiten, Sonderöffnungen) nur ab `heute(now)`.
- [ ] Tests (Fixture `ausVorlage('salon', BETRIEB, NOW)` mit Farbe `#9d4b6f`, Telefon, Anschrift, Impressum-/Datenschutz-URL): die sechs Abschnitt-IDs in fester Reihenfolge, alle `kopierbar: false`; Leistungen mit Name, Dauer, `preisText`, Buchungsart, Personen, inaktive markiert; Ressourcen mit Wochenplan („Mo 09:00–12:30, 14:00–18:00“), nur künftige Abwesenheiten; Regeln mit Raster, Vorlauf, Horizont, Absagefrist, `maxOffeneJeEmail`, Storno-Text samt Fassung, `zahlungText`; **feedToken-Test:** jede Ressource trägt einen echten Token, `JSON.stringify(ergebnis)` enthält weder einen Token noch `feedToken`; **Weißlistentest:** Zusatzfelder `geheim: 'XYZ-GEHEIM'` an Betrieb, Ressource und Leistung (und `notiz` an der Leistung) tauchen nirgends auf. Determinismus: gleiche Eingabe und `now` ⇒ `deepEqual`; anderes `now` ändert nur die „erzeugt am“-Zeile und die Filterung vergangener Abwesenheiten. `stand` gleich `{ version, updatedAt }`.
- [ ] FAIL → implementieren (je Abschnitt eine kleine Funktion `(daten) ⇒ { id, titel, text, kopierbar }`; Hilfsfunktionen für Wochenplan-Text und Kürzen mit „und N weitere“) → PASS → `npm test` → Commit „Terminplaner: Einbauanleitung als reiner Generator (Modell bis Benachrichtigungen)“
### Task D3b: Generator `einbauanleitung()` 2 – Einbau, Snippet, CSS, Datenschutz, Checkliste, Abrechnung, Warnungen, Volltext
**Files:** Modify `server/services/termine/einbauanleitung.js`, `test/termineEinbauanleitung.test.js`.
**Consumes:** D3a, D1 `datenschutzAbsatz`, `RECHTSTEXTE_ENTWURF`; D2 Bausteine; `einstellungen.js` (`fehlendePflichtangaben`, `zeigtWiderruf`, `hatPreis`), C `gesundheitsWarnungen(einstellung) ⇒ string[]` aus `fragebogen.js`, `config.womsBaseUrl`. **Produces:** `ENTWURF_BANNER` = „ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen“; die restlichen Abschnitt-IDs in fester Reihenfolge hinter D3a: `einbau`, `snippet`, `css`, `datenschutz_sie`, `datenschutz_du`, `checkliste`, `abrechnung` (zusammen die 13 IDs `modell`, `leistungen`, `ressourcen`, `regeln`, `gastfelder`, `benachrichtigungen`, `einbau`, `snippet`, `css`, `datenschutz_sie`, `datenschutz_du`, `checkliste`, `abrechnung`); `kopierbar: true` nur für `snippet`, `css`, `datenschutz_sie`, `datenschutz_du`. `text` = alle Abschnitte mit Überschrift, aber nur die zur Anrede (`einstellung.anrede`, D3a) passende Datenschutz-Fassung (die Abschnitte `datenschutz_sie` und `datenschutz_du` rufen `datenschutzAbsatz(einstellung, 'sie')` bzw. `(einstellung, 'du')`). Kundentexte (Wünsche aus `fragebogen.einbau`: `stelle`, `gestaltung`, `bemerkung`, `knopfText`) nur als Text, gesammelt in einem Block „Kundenwunsch (Text des Kunden)“ im Abschnitt `einbau`, nie in `snippet`/`css`; Grenzen 40/300/1000/1000 erneut angewandt. Ohne Fragebogen: Modus `inline`. Abnahme-Checkliste bedingt: `zeigtWiderruf` ⇒ Widerrufsbelehrung aufklappbar, Haken für vorzeitigen Beginn bei Preis, Knopf „Vertrag widerrufen“; Modus `knopf` und `zeigtWiderruf` ⇒ Widerrufsknopf dauerhaft neben dem Buchungsknopf, Modus `knopf` ⇒ `--wkt-akzent` immer setzen; immer: Widget unsichtbar bis zur Freischaltung, Ausweichtext, Datenschutz-Absatz auf der Website, URLs von Impressum/Datenschutz, „Freischalten (Abnahme)“, danach Probebuchung bis „Fast geschafft“ und Stornierung (die öffentliche Schnittstelle antwortet erst bei `active`), Kontrast, 390 px, Tastatur (Punkte aus Abschnitt 7 von `termine-einbau.md`). Abrechnung: „Einrichtung bezahlt (149 €, Rechnung <URL> | Rechnung folgt im Portal), Zeit im Arbeitsblatt, nicht auf Abo-Stunden“; Leistungsumfang „ein Widget an einer Stelle, Gestaltung nach Fragebogen, Einbau; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden, vorher im Chat abstimmen“; bei `feature.unlockedByPurchase !== true` „ohne Zahlung freigeschaltet (Pilot/Kulanz), keine Abrechnung“. Datenschutz-Absatz in der Checkliste: bis Block F steht der Zusatz „liegt die Datenschutzerklärung nicht in unserem Repo: Absatz per Chat an den Kunden senden“; **F10b2 ersetzt ihn durch die Endregel aus F4** (WEBklar fügt den Absatz ein, wenn die Website von WEBklar betreut wird, sonst Textbaustein per Chat; Teil der Einrichtung, nicht auf das Stundenkontingent angerechnet).
**Warnungen** (Reihenfolge): `ENTWURF_BANNER` genau dann, wenn `entwurf`; fehlende Pflichtangaben (Telefon/Anschrift, Gäste können nicht buchen); fehlende Impressum-/Datenschutz-URL; `gesundheitsWarnungen`; Kontrasthinweise aus `cssVorschlag`; Datenschutz-Absatz nicht erzeugbar (kein Name/keine E-Mail ⇒ statt Wurf Hinweistext im Abschnitt + Warnung). Ohne Einstellung (`einstellung === null`): Warnung „Keine Einstellungen gespeichert“ und nur der Abschnitt `snippet` (mit Modus aus dem Fragebogen, falls vorhanden).
- [ ] Tests (Fixture wie D3a): Abschnitt-IDs (alle 13) und `kopierbar`-Flags exakt wie oben; `text` enthält genau eine Datenschutz-Fassung (Sie bei `einstellung.anrede === 'sie'`, Du bei `'du'`), `abschnitte` beide; Fragebogen `{ einbau: { modus: 'knopf', knopfText: 'Termin sichern', stelle: 'Startseite unter dem Hero', gestaltung: '…', bemerkung: '<script>alert(1)</script>' } }` ⇒ Snippet mit `data-modus="knopf" data-knopf="Termin sichern"`, die Wünsche stehen nur im Kundenwunsch-Block und das Skript-Tag erscheint nirgends in `snippet`/`css`; Texte über Grenze werden gekürzt. Warnungen: Banner genau bei `entwurf` (Standard und `entwurf: false` getrennt prüfen; der Standard hängt an `RECHTSTEXTE_ENTWURF`, F10b2 stellt die Standard-Assertion um), `betrieb.telefon: ''` ⇒ Pflichtangaben-Warnung, fehlende URL ⇒ Warnung, Leistung „Physiotherapie“ ⇒ Warnung mit „Physio“ (Liste gehört C). Checkliste: `gaesteSind: 'unternehmer'` ⇒ keine Widerrufspunkte; Verbraucher + Modus `knopf` ⇒ dauerhafter Widerrufsknopf, Verbraucher + `inline` ⇒ kein solcher Punkt; „Freischalten (Abnahme)“ und „stornieren“ immer. Abrechnung mit `invoiceUrl` ⇒ URL, „149 €“, „nicht auf Abo-Stunden“; ohne ⇒ „Rechnung folgt“; `unlockedByPurchase: false` ⇒ „keine Abrechnung“. `einstellung: null` ⇒ `abschnitte.map(a => a.id)` gleich `['snippet']` und Warnung „Keine Einstellungen gespeichert“. Fehlende E-Mail im Betrieb ⇒ kein Wurf, Abschnitt mit Hinweis.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Einbauanleitung als reiner Generator (Einbau bis Abrechnung, Warnungen)“
### Task D4: Kurzfassung für `ticket.details`, T16-Gesundheitsblock und Registry-Anbindung
**Files:** Modify `server/services/termine/einbauanleitung.js` (`kurz`, `ticketStand`, `driftWarnung`), `server/services/termine/kontext.js` (`ladeStand`), `server/services/termine/ticketDetails.js` (B8 hat den Stub angelegt: der Inhalt wird ersetzt; `server/config.js` bleibt unberührt, denn `features.termine.ticket.details` ruft die Datei schon lazy auf), `server/services/siteFeatures.js` (`teamNotice`: den von B9b deklarierten T16-Block `gesundheit` füllen), Create `test/termineTicket.test.js`; Tests `test/termineEinbauanleitung.test.js`, `test/termineKontext.test.js`, `test/featureMails.test.js` (T16-Fall).
**Consumes:** D3a/D3b; B8: `featureConfig('termine').ticket.details(ctx)` mit `ctx = { feature, doc, project, customer }` (`feature` = Register-Eintrag, `doc` = `portalFeatures`-Dokument, `customer` darf `null` sein; `now` setzt D4 im Rumpf), `TICKET_DETAILS_MAX`, `anleitungsLink`, `kuerze` aus `featureRegistry.js`; B9b (T16-Platzhalter `gesundheit_text`, Block `gesundheit`); C `gesundheitsWarnungen` und Attribut `termineConfig.fragebogen` (JSON-String, in `test/support/schema.js` über `schemaKeys('termineConfig')` enthalten). **Produces:** `kurz` (Klartext, ≤ `TICKET_DETAILS_MAX` aus B8; die Konstante wird hier nicht neu definiert), `ladeStand(featureId) ⇒ Promise<{ einstellung|null, version, updatedAt, fragebogen|null } | null>` (`null` nur ohne `termineConfig`-Dokument; unlesbares JSON ⇒ jeweiliges Feld `null`), `ticketStand(details) ⇒ number | null` (liest `^Konfigurationsstand: Version (\d{1,9})`), `driftWarnung(ticketVersion, jetzt) ⇒ string | null` („Einstellungen seit der Bestellung geändert (Ticket Version N, jetzt M).“ nur bei `jetzt > N`), `termineTicketDetails(ctx) ⇒ Promise<string>` (wirft **nie**: bei Lesefehlern Kopfzeilen + „Anleitung konnte nicht erzeugt werden, im WOMS öffnen“ + Link; setzt `now = new Date()` im Rumpf und ruft `einbauanleitung({ feature: ctx.doc, project: ctx.project, … })` auf – in D3 ist `feature` das `portalFeatures`-Dokument, den Registereintrag `ctx.feature` braucht der Generator nicht; `siteKey` und `$id` kommen aus `ctx.doc`). **T16-Block `gesundheit`:** `teamNotice` füllt `gesundheit_text` aus `gesundheitsWarnungen((await ladeEinstellung(doc.$id))?.einstellung)` (zusammengefügt, höchstens 500 Zeichen) und schaltet den Block ein, wenn der Text nicht leer ist; ein Lesefehler schaltet den Block aus, T16 geht trotzdem raus (nie Geld ohne Spur). Aufbau von `kurz` (Absätze durch Leerzeile, **erster Absatz = Kopf**): Kopf (`Funktion: Terminplaner`, Projekt, Domain = `liveDomain || previewUrl || subdomain` wie `createFeatureTicket`, Kunde, `siteKey`, `Konfigurationsstand: Version N, TT.MM.JJJJ` bzw. `Version 0 (noch keine Einstellungen gespeichert)`); Kundenwunsch-Block (Modus/Stelle/Gestaltung/Bemerkung); Zählungen mit ersten 8 Namen („12 Leistungen (11 aktiv): A, B, … und 4 weitere“, Ressourcen, Zusatzfelder); Warnungen (höchstens 5, ohne Banner-Text-Doppel); die Gesundheitswarnungen stehen in einem eigenen Absatz „Gesundheitsbereich prüfen“ und zählen nicht zu den 5; Snippet; 6 Schritte (WOMS-Panel öffnen, Code einfügen, Gestaltung/Kontrast, Datenschutz-Absatz, „Freischalten (Abnahme)“ + Probebuchung + Stornierung, Ticket erledigen und Zeit buchen, „Nicht auf das Stundenkontingent anrechnen“); Verweis `anleitungsLink`. Ohne Datenschutz-Absatz, ohne Tokens. Passt die Summe nicht, fallen erst Zählungen, dann die übrigen Warnungen, dann der Kundenwunsch-Block weg (Kopf, Snippet, Schritte, Verweis **und der Gesundheitsabsatz** bleiben immer; eine Gesundheitswarnung verschwindet nie unter Platzdruck).
- [ ] Tests: `kurz.length <= 7000` bei Maximalbefüllung (50 Leistungen mit 100 Zeichen, 20 Ressourcen, 5 Zusatzfelder, Wünsche 300/1000/1000, 5 Warnungen und 10 Gesundheitswarnungen); enthält Kopf, Snippet mit `siteKey`, Verweis, „Nicht auf das Stundenkontingent anrechnen“ und **nicht** „Online-Terminbuchung“ (Datenschutz-Absatz) oder einen `feedToken`; erster Absatz endet vor der ersten Leerzeile mit der `Konfigurationsstand`-Zeile; `ticketStand('…Konfigurationsstand: Version 7, 03.10.2026…') === 7`, ohne die Zeile oder mit `Version 99999999999` ⇒ `null`; `driftWarnung(7, 9)` Text wie oben, `driftWarnung(9, 9)`, `(9, 7)`, `(null, 9)` ⇒ `null`. `ladeStand`: ohne Dokument `null`, mit Dokument alle Felder, kaputtes `fragebogen`-JSON ⇒ `fragebogen: null` bei gültiger Einstellung. `termineTicketDetails`: mit Dokument ⇒ enthält `Konfigurationsstand: Version 1`; ohne `termineConfig` ⇒ String mit „noch keine Einstellungen“ und Snippet; `aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500 })` ⇒ löst auf (kein Wurf) mit Kopf und Link. Registry: `featureConfig('termine').ticket.details(ctx)` liefert denselben Text wie `termineTicketDetails`, mit `ctx` genau in der Form, die `markPaid` übergibt (`{ feature, doc, project, customer }`; `siteKey` aus `ctx.doc`, nicht aus `ctx.feature`). **Gesundheit:** Leistung „Physiotherapie“ + Maximalbefüllung ⇒ `kurz` enthält „Physio“ und bleibt ≤ 7000; T16 (`teamNotice`) für ein Termine-Dokument mit dieser Leistung enthält den Warnblock, ohne Treffer kein Block, `ladeEinstellung` scheitert (`failNext`) ⇒ T16 geht ohne Block raus. **Integration (Review Focus 1):** `markOrdered` + `markPaid` für `termine` ⇒ `workorders`-Dokument mit `details` ≤ 7000, enthält `data-webklar-termine="<siteKey>"`; derselbe Ablauf mit kaputtem `termineConfig`-Lesen (failNext) legt das Ticket trotzdem an und liefert `created: true` (Zahlung nie ohne Ticket oder T16).
- [ ] FAIL → implementieren (Budgetliste mit Prioritäten statt Abschneiden mitten im Satz) → PASS → `npm test` → Commit „Terminplaner: Kurzfassung der Einbauanleitung fuer das Einrichtungs-Ticket, T16-Gesundheitsblock“
### Task D5: entfällt (vollständig in B8 aufgegangen)
Die Härtung von `createFeatureTicket` (Titel auf 128 Zeichen, Details auf 7000 mit einheitlicher Schlusszeile, surrogatsicheres Kürzen, **gezielte** Wiederholung nur bei `status 400` und `details.length > 1000` mit den Minimal-Details, kein Doppel-Ticket bei 5xx) und ihre Tests liegen in **Task B8**; `webhook_retry_setzt_ticketlose_einrichtung_fort` (`funktionenRoutes.test.js:321-345`) bleibt dort grün. Die Konstanten `TICKET_TOPIC_MAX`, `TICKET_DETAILS_MAX`, `LANGE_DETAILS` und `kuerze` stehen in `featureRegistry.js` (B8); D4 und D3 nutzen sie. Block D ändert `ticketAdmin.js` nicht.
### Task D6: Freischalt-Hindernisse und Freigabe-Mail (aus B11 herausgelöst)
**Files:** Create `server/services/termine/freischaltung.js`; Modify `server/services/siteFeatures.js` (`runActivation` Z. 578–593: den K16-Versand in die neue Funktion `meldeFreigeschaltet` herauslösen), `server/services/featureRegistry.js` (der Standardhaken `freischaltbar` für `termine` delegiert an `freischaltHindernis`); Test neu `test/termineFreischaltung.test.js`. **`activateClosedTickets`, der Hook `freischaltbar`, die Behandlung einer blockierten Freischaltung (ein `adminNote`-Eintrag, kein Mailversand) und die Anpassung des Tests `siteFeatures.test.js:235` gehören Block B (B11)**; D6 ändert weder `config.js` noch `activateClosedTickets`.
**Consumes:** B11 (`activateClosedTickets`, `featureHaken`, Standardhaken mit der Prüfung „Einstellung vorhanden **und** `laeuft`“), B9a (K16 mit `{{funktion}}`/`{{portal_url}}`/Knopftext aus dem Register), E1 `aboMemo()`/`laeuft`, `ladeEinstellung(featureId)` (C3). **Produces:** `freischaltHindernis(doc, { abo = aboMemo() } = {}) ⇒ Promise<null | 'kein_abo' | 'keine_einstellung' | 'abo_unbekannt'>` (Reihenfolge: Abo zuerst, dann Einstellung vorhanden; `laeuft` wirft ⇒ `'abo_unbekannt'`, also nie freischalten bei Lesefehler); der B11-Standardhaken wird `freischaltbar(doc) = (await freischaltHindernis(doc)) === null` (Verhalten und Tests von B11 bleiben unverändert); `meldeFreigeschaltet(doc, { attachments = [] } = {})` (K16 an den Kunden, Fehler geloggt und nie geworfen; `attachments` nimmt die PDFs der Staff-Aktivierung aus F13a auf); `runActivation` ruft sie für jedes aktivierte Dokument.
- [ ] Tests (Review Focus 2): `freischaltHindernis`-Tabelle: Wartungs-Abo da + Einstellung da ⇒ `null`; kein Abo ⇒ `'kein_abo'` (auch ohne Einstellung: Abo zuerst); Abo da, keine Einstellung ⇒ `'keine_einstellung'`; Abo-Lesefehler (`failNext` auf `subscriptions`) ⇒ `'abo_unbekannt'`; Abo `past_due` zählt als laufend (`null`); Wechsel-Widerruf (E1) ⇒ `'kein_abo'`. `meldeFreigeschaltet`: K16 mit „Terminplaner“ im Betreff und Link `#termine`; mit `attachments` steht das PDF in der gesendeten Mail (`mailSink`); Versandfehler ⇒ kein Wurf, `console.error`. Delegation: **die Tests von B11 laufen unverändert grün**; `runActivation` sendet für ein aktiviertes Termine-Dokument genau ein K16.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: Freischalt-Hindernisse und Freigabe-Mail ausgelagert“
### Task D7: Team-Aktionen „Freischalten (Abnahme)“ und „Ticket nachlegen“
**Files:** Modify `server/services/siteFeatures.js` (neu `freischaltenNachAbnahme`, `ticketNachlegen`), `server/routes/admin/funktionenAdmin.js` (POST `/:customerId/:key`, `:73-103`: erlaubte `action` ⇒ `activate | disable | abnahme | ticket`; bei `abnahme`/`ticket` zusätzlich `featureConfig(key)?.ticketFreischaltung` sonst 409; `adminNote` wie bisher über `composeAdminNote`), Test `test/funktionenTermine.test.js`.
**Consumes:** D6 (`freischaltHindernis`, `meldeFreigeschaltet`), B11 (`activateClosedTickets` bleibt unberührt), B (Ticketanlage mit Register-Text in `markPaid`: dort `createFeatureTicket({ …, topic, details })` in eine interne Funktion `legeTicketAn(doc, project, customer)` herauslösen, falls B das nicht schon getan hat), `handleFeatureCheckoutEvent(event)` und `retrieveCheckoutSession(id)`. **Produces:** `freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote, now }) ⇒ { doc, activated: boolean }`: nur aus `setup` (bereits `active` ⇒ no-op mit `activated: false`, Antwort 200; `off`/`ordered`/`disabled` ⇒ 409 „Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.“; ohne Dokument 409 „Für dieses Projekt gibt es noch {keine}“), Hindernis ⇒ 409 mit Klartext (`kein_abo`: „Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.“, `keine_einstellung`: „Es sind noch keine Einstellungen gespeichert.“, `abo_unbekannt` ⇒ 503 „Das Abo konnte gerade nicht geprüft werden.“); Schreiben unter `feature-activate:<id>` mit erneutem Lesen und Hook; setzt nur `active`, `activatedAt`, `adminNote` (**Ticket und `unlockedByPurchase` bleiben**, das Ticket wird nicht geschlossen), danach `meldeFreigeschaltet` (K16). `ticketNachlegen({ customerId, projectId, key, by, adminNote }) ⇒ { doc, created: boolean }`: nur in `setup`; (a) `ticketId` leer ⇒ Zahlung bei Stripe prüfen (`retrieveCheckoutSession(doc.stripeSessionId)`, `payment_status === 'paid'` sonst 409 „Die Zahlung ist noch nicht eingegangen.“, Stripe nicht erreichbar ⇒ 503) und **denselben Weg wie der Webhook** laufen lassen (`handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: session } })`), damit Ticket, K15 und T16 genau einmal entstehen und ein späterer Stripe-Retry nichts doppelt sendet; (b) `ticketId` gesetzt, Ticket aber gelöscht ⇒ neues Ticket über `legeTicketAn`, `ticketId` ersetzen, **keine** Mails (K15/T16 gingen beim ersten Mal raus); (c) Ticket vorhanden ⇒ no-op `created: false`. Keine Sperre `feature-paid:<session>` um (a) halten (`withLocks` ist nicht wiederbetretbar, `markPaid` nimmt sie selbst); (b) läuft unter dieser Sperre.
- [ ] Tests: `abnahme` auf `setup` mit Einstellung und Abo ⇒ 200, `termine.state === 'active'`, `activatedAt` gesetzt, `ticketId` und Ticket-Status `Open` unverändert, `unlockedByPurchase === true`, `adminNote` passt auf `/Andrej: abnahme/`, K16 genau einmal; zweiter Klick ⇒ 200 ohne zweite Mail; zwei parallele Klicks ⇒ eine Mail; gleichzeitig `runActivation` mit geschlossenem Ticket ⇒ eine Mail insgesamt; `off`/`ordered`/`disabled` ⇒ 409; kein Abo ⇒ 409 und `setup` bleibt; keine Einstellung ⇒ 409; Abo-Lesefehler ⇒ 503; Projekt eines anderen Kunden ⇒ 403; Kundensitzung statt JWT ⇒ 401/403; `news` + `abnahme` funktioniert ohne Abo-Prüfung (Hook fehlt), `analyse` ⇒ 404 wie bisher. `ticket`: (a) `setup` ohne `ticketId`, Sitzung in `st.checkoutSessions` bezahlt ⇒ Ticket mit Kurzfassung (D4), `ticketId` gesetzt, K15 und T16 je einmal, zweiter Klick ⇒ `created: false`, keine weiteren Mails, ein folgender Webhook mit derselben Sitzung ⇒ ebenfalls nichts Neues (Review Focus 1: nie doppelt); unbezahlte Sitzung ⇒ 409; Stripe-Ausfall ⇒ 503; (b) gelöschtes Ticket ⇒ neues Ticket, neue `ticketId`, keine Mail; (c) vorhandenes ⇒ no-op; `ordered`/`active` ⇒ 409; zwei parallele Klicks ⇒ ein Ticket. Bisherige Tests dieser Datei (`Pruefungen wie bei News`: `action: 'loeschen'` ⇒ 400) bleiben grün.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: Freischalten nach Abnahme und Ticket nachlegen (Team)“
### Task D8: Team-Route Anleitung
**Files:** Modify `server/routes/admin/funktionenAdmin.js` (neu `GET /:customerId/termine/anleitung`; `adminView` `:50-62` ergänzt `hindernis` über `freischaltHindernis` für `termine`-Dokumente in `setup`, ohne `portalFeatures` erneut zu lesen), `docs/funktionen/termine-einbau.md` (Einleitung um drei Sätze: Anleitung je Kunde im WOMS-Panel, Kurzfassung im Ticket; alle von `termineEmbedStatic.test.js:212-228` gepinnten Strings bleiben), Test `test/funktionenTermine.test.js`.
**Consumes:** D3a/D3b, D4 (`ladeStand`, `ticketStand`, `driftWarnung`), D6 (`freischaltHindernis`, `aboMemo` je Anfrage), `assertOwnProject`, `getFeatureDoc`, B-Meldungstext „Für dieses Projekt gibt es noch keinen Terminplaner“ (`FEATURE_LABELS.termine.keine`). **Produces:** `GET /api/team/funktionen/:customerId/termine/anleitung?projectId=` ⇒ `{ stand: { version, updatedAt }, warnungen, abschnitte, text, state, ticketWoid, ticketVersion, hindernis }` (ohne `kurz`; `hindernis` nur in `setup`, sonst `null`). Hinter `requireStaffJwt` (bestehender Mount), **kein** `TERMINE_ENABLED`-Gate, **kein** `customerViewLog`, kein Pfad unter `/api/funktionen` (Kundenrouter). 400 ohne `projectId`, 403 fremdes Projekt (`assertOwnProject`), 409 ohne Funktionsdokument (Text wie die bestehende Meldung), sonst 200 auch in `off`/`ordered`/`setup`/`active`/`disabled`; fehlt die Einstellung ⇒ 200 mit Warnung und nur Snippet. Drift: Ticket über `feature.ticketId` lesen (404 ⇒ keine Warnung, kein Fehler), `ticketStand(details)` gegen `stand.version`, Warnung am Ende von `warnungen`. Rechnung und Preis stammen live aus dem Dokument (`backfillInvoiceLinks` ergänzt den Link später, die Anleitung zeigt ihn dann auch bei altem Ticket).
- [ ] Tests: ohne JWT 401, mit Kundencookie 401/403, `GET /api/funktionen/termine/anleitung` (Kundenrouter) 404; fehlendes `projectId` 400; Projekt von c2 für c1 ⇒ 403; Projekt ohne Dokument ⇒ 409; `config.termine.mode = 'off'` ⇒ trotzdem 200; `customerViewLog` unverändert; Antwort-JSON enthält keinen der seeded `feedToken` und kein `kurz`; mit Einstellung ⇒ `abschnitte.map(a => a.id)` wie D3b, `stand.version === 1`; ohne Einstellung ⇒ nur `snippet` und die Warnung; Ticket-Details „Konfigurationsstand: Version 1“, Einstellung inzwischen Version 3 ⇒ Warnung „Einstellungen seit der Bestellung geändert (Ticket Version 1, jetzt 3).“; Ticket gelöscht oder ohne `ticketId` ⇒ keine Warnung; `setup` ohne Abo ⇒ `hindernis: 'kein_abo'`, mit Abo `null`, `active` ⇒ `null`; `GET /api/team/funktionen/:customerId` liefert `hindernis` je `setup`-Dokument und liest `portalFeatures` weiterhin einmal (Bestandstest „liest die Funktionsdokumente des Kunden einmal“ grün); Rechnungs-Link erscheint im Abschnitt `abrechnung`, sobald `invoiceUrl` im Dokument steht.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: Team-Route fuer die Terminplaner-Einbauanleitung“
### Task D9: WOMS – Ansicht-Logik und API-Client
**Files:** Modify `src/lib/funktionenView.js`, `src/lib/teamApi.js`, `tests/unit/funktionenView.test.mjs` (Worktree `/home/webklar/apps/ticket.webklar.com/dev-termine`, Branch `feature/terminplaner-tp2`).
**Consumes:** D7/D8 (Routen und `action`-Werte `abnahme`, `ticket`; Feld `hindernis` in der Funktionsliste). **Produces:** `aktionen(state, key, { ticketMissing = false } = {})`: News unverändert (`off` `['activate']`, `ordered`/`setup` `['activate','disable']`, `active` `['disable']`, `disabled` `['activate']`; ohne `key` wie News), Termine `off` `['activate']`, `ordered` `['disable']` (kein „Aktivieren (ohne Zahlung)“, sonst verschluckt das Portal eine spätere Zahlung), `setup` `['abnahme','disable']` bzw. mit `ticketMissing` `['ticket','abnahme','disable']`, `active` `['disable']`, `disabled` `['activate']`; `FUNKTIONEN[termine].bestaetigen` um `abnahme` („Terminplaner freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können buchen.“) und `ticket` („Fehlendes Einrichtungs-Ticket neu anlegen?“) erweitert; `AKTION_LABEL = { activate: 'Aktivieren (ohne Zahlung)', disable: 'Abschalten', abnahme: 'Freischalten (Abnahme)', ticket: 'Ticket nachlegen' }`; `hindernisText(code)` (`kein_abo` „Kein laufendes Wartungs-Abo: Freischalten ist gesperrt.“, `keine_einstellung` „Noch keine Einstellungen gespeichert.“, `abo_unbekannt` „Abo gerade nicht prüfbar.“, sonst `''`); `anleitungKopien(antwort) ⇒ [{ id, label, text }]` (zuerst `alles` mit `antwort.text`, dann alle `kopierbar`-Abschnitte; feste Beschriftungen für `snippet` „Einbau-Code kopieren“, `css` „CSS-Variablen kopieren“, `datenschutz_sie` „Datenschutz-Absatz (Sie) kopieren“, `datenschutz_du` „… (Du) kopieren“, unbekannte kopierbare ⇒ „<Titel> kopieren“; `null` oder ohne Text ⇒ `[]`); `anleitungPfad(customerId, projectId)` ⇒ `/funktionen/<id>/termine/anleitung?projectId=<id>` (kodiert); `teamApi.getTermineAnleitung(customerId, projectId) = request(anleitungPfad(…))`; **`panelModell(antwort, feature) ⇒ { kopf, ticketPfad, warnungen, hinweis, kopien, abschnitte }`** (rein, die gesamte Entscheidungslogik des Panels aus D10): `kopf` = `Stand Version N, TT.MM.JJJJ` (aus `antwort.stand`, ohne Antwort `''`), `ticketPfad` = `/tickets?woid=<ticketWoid>` (ohne `antwort.ticketWoid` `null`), `warnungen` = `antwort.warnungen` (sonst `[]`), `hinweis` = `hindernisText(feature.hindernis)`, `kopien` = `anleitungKopien(antwort)`, `abschnitte` = `[{ id, titel, text }]` in Antwortreihenfolge; `null`-Antwort ⇒ alles leer; **`anleitungOffen(search, projectId) ⇒ boolean`** (`true`, wenn die Adresse `?anleitung=<projectId>` trägt, kodiert oder nicht, sonst `false`).
- [ ] Tests (WOMS `npm test`): `FUNKTIONEN`-Test an die neuen Texte angepasst; `aktionen` für alle Zustände × {`news`, `termine`} inkl. `ticketMissing`, ohne `key` und mit `undefined`-Zustand wie bisher (`funktionenView.test.mjs:48-55` anpassen, News-Zeilen unverändert); `AKTION_LABEL` exakt; `hindernisText` für alle Codes und `'x'`; `anleitungKopien` mit einer Beispielantwort (Reihenfolge, Beschriftungen, nicht kopierbare Abschnitte fehlen, `null` ⇒ `[]`); `anleitungPfad` kodiert `projectId` mit Sonderzeichen; `panelModell`: Beispielantwort ⇒ alle sechs Felder (Kopf `Stand Version 3, 03.10.2026`, Ticketpfad, Warnliste, `hinweis` bei `hindernis: 'kein_abo'`, Abschnitte in Reihenfolge, Kopien), `null` ⇒ leeres Modell, Antwort ohne `ticketWoid` ⇒ `ticketPfad: null`; `anleitungOffen('?tab=funktionen&anleitung=p%2F1', 'p/1') === true`, anderes Projekt, leere und fehlende Suche ⇒ `false`; `mergeFunktion`/`siteKeyOf`-Tests unverändert grün.
- [ ] FAIL → implementieren (`teamApi.js` bleibt dünn, weil es `appwrite` lädt und nicht unter Node testbar ist) → PASS → `npm test` → Commit „WOMS: Aktionen und Anleitungs-Logik fuer den Terminplaner“
### Task D10: WOMS – Anleitungs-Panel und Aktionsknöpfe
**Files:** Modify `src/components/customer/CustomerFunktionen.jsx` (Zeilen `:80-124`), `src/styles/admin-areas.css` (`.funktionen-panel*`).
**Consumes:** D9 (`aktionen`, `AKTION_LABEL`, `hindernisText`, `anleitungKopien`, `panelModell`, `anleitungOffen`, `getTermineAnleitung`), bestehend `copyText` (`CopyableCredential`), `Card/Badge/Button`. **Produces:** je Termine-Zeile mit Funktionsdokument (Zustand ≠ `off`) ein Knopf „Anleitung“, der ein Panel unter der Zeile aufklappt (Zeilen werden `Fragment` mit `key`, das Panel ist eine eigene Zeile außerhalb der Gitterspalten); Panel (alle Inhalte und Entscheidungen kommen aus `panelModell` und `anleitungOffen` in D9, die Komponente verdrahtet nur): Kopfzeile `modell.kopf` + Ticketlink `modell.ticketPfad`, `modell.warnungen` als Liste (`area-error`), `modell.hinweis` als Hinweis, Knopfreihe aus `modell.kopien` (Kopieren mit „Kopiert“-Rückmeldung wie `SiteKey`), darunter die Abschnitte als aufklappbare Blöcke (`<details>`, Text in `<pre>` mit `pre-wrap`, ausschließlich React-Textknoten, kein `dangerouslySetInnerHTML`, nie als Link oder HTML); Lade-/Fehlerzustand mit „Erneut laden“; nach jeder Aktion (`save`) für dieselbe Zeile neu laden, weil sich Version und Hindernis ändern; Panel öffnet automatisch, wenn `anleitungOffen(window.location.search, projectId)` wahr ist (T16 und Ticket verlinken so); `aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing })` und Beschriftung aus `AKTION_LABEL`; in `setup` neben dem Status `hindernisText(feature.hindernis)`.
- [ ] Prüfung: `npm test` grün (die Panel-Logik ist in D9 getestet: `panelModell`, `anleitungOffen`, `anleitungKopien`, `aktionen`, `hindernisText`; `CustomerFunktionen.jsx` enthält keine eigene Entscheidungslogik); einmal `nice npx vite build` (vorher `free -m`) ohne Fehler; keine neuen Abhängigkeiten. Sichtprüfung des Panels (390 px und Desktop, Kopieren, Warnungen, Aktionsknöpfe je Zustand) gehört zum Livegang-Test (Plan-Kopf Schritt 8), nicht in `npm test`.
- [ ] Implementieren → Build → Commit „WOMS: Einbauanleitung als Panel mit Kopierknoepfen, Abnahme-Knopf und Ticket nachlegen“
**Bewusst nicht in Block D (Spec 7.3, letzter Punkt):** Die Textform-Pflicht für `adminActivate` bei Termine ab Modus `on` (außer Pilot-Adressen, wie `activateAnalyse` mit `validateTextform` und Zielgruppen-Haken) ist keine kleine Ergänzung: sie braucht Eingabefelder (Datum, Kanal, Haken) in der Bestätigungszeile des WOMS, Modusweichen und eigene Tests. Der Einhängepunkt ist vorbereitet: Zweig `action === 'activate'` in `funktionenAdmin.js` (POST `/:customerId/:key`) und die Bestätigungszeile in `CustomerFunktionen.jsx` (`isConfirming`). Block F (oder ein eigener Task nach D10) setzt dort an; „Freischalten (Abnahme)“ ist davon nicht betroffen, weil die Zahlung samt Zustimmungsnachweis (`featureConsents`) vorliegt.
**Abschlussprüfung Block D (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. WOMS-Anteil (D9, D10): `npm test` im WOMS-Worktree und einmal `nice npx vite build` (vorher `free -m`); Sichtprüfung des Panels gehört zum Livegang-Test (Block G).
---
## Block F: Rechtstexte, Kanzlei-Paket, News-Korrektur
Block F liefert den Textsatz, ohne den `TERMINE_ENABLED=on` nicht zu verantworten ist: Leistungsbeschreibung (LB), AVV und Widerrufsbelehrung des Terminplaners (Markdown → HTML → PDF), Datenschutz 6c samt §6/§7, endgültige Gäste- und Bestelltexte, die Korrektur der News-Texte, den Widerruf je Funktion, die Textform-Sperre für die Staff-Aktivierung, die WOMS-Karten und das Kanzlei-Paket. Alle Texte sind „Endfassung zur Kanzlei-/Hausprüfung“, keine Rechtsberatung; jede Aussage, die die Kanzlei bestätigen muss, steht im Kanzlei-Paket (F15a, F15b) als `⚖ Q<n>` (in den veröffentlichten Dateien stehen keine Marker).
Fassungsdatum `<F>`: siehe Kopf, Abschnitt „Grundlagen der Blöcke“ (Berliner Kalendertag des F1-Commits, die drei Stände, was beim Anheben neu entsteht). Dateien einer ausgelieferten Fassung werden nie überschrieben: Änderung nach der Kanzlei = neues Datum + neue Dateien. Review-Fokus 5 (Zustimmungsnachweis: genau die gezeigten Texte + Fassungen + Zeitpunkt, gespeichert vor der Umleitung auf Stripe; Widerrufs-Anker = Klick) gilt für F7–F9; „nie Geld ohne Spur“ (Fokus 1) für den Ausfall der K15-Anhänge in F8. Reihenfolge: F1, F2 · F3–F6 (Dokumente) · F7–F9 (Verdrahtung, nach B) · F10a, F10b1, F10b2, F11, F12a, F12b · F13a (nach B/D), F13b · F14 · F15a, F15b zuletzt · Block G (Livegang-Checkliste, Controller) am Ende dieser Datei.
**Befunde beim Lesen des Codes (ergänzen oder korrigieren die Spec):**
- „inklusive Mehrwertsteuer“ steht nur in `public/funktionen.js:272`; die News-LB sagt „inklusive Umsatzsteuer“ (`funktionen-leistungsbeschreibung.html:49,60`), K15 „(brutto, einmalig; … kein Abo)“, `stripeBilling.js:642` und `funktionen-news-design.md:78` „brutto“. Alle fallen unter die Korrektur (F2). „§ 356 Abs. 4“ steht in `config.js:192,196`, LB `:51,73`, `funktionen-news-design.md:73`, Test `legalTexts.test.js:485`.
- Die Analyse-Rechtstexte liegen in `public/rechtliches/` (nicht unter `vertragsunterlagen/`); ihr HTML ist von Hand gebaut, eine Markdown-Bibliothek gibt es nicht (F6 baut einen kleinen Generator). `analyseLegal.js` kennt nur die Analyse; `build-legal-pdfs.sh` nur `all|analyse`.
- `ablauf.js:231` (`stornoNoetig`) hängt nur am Stornotext, nicht am Preis der Leistung; im Widget zeigen `embed/termine.js:1215-1219,1259,1279` Stornobox, Haken und Prüfung ebenso preisunabhängig.
- `paidFeatureOrders` liefert seit **B4** `featureKey` und `vertragAt` (Zeit der Zustimmung zur bezahlten Sitzung); `withdrawFeature` (`abo.js:1088`) nimmt aber noch das Maximum über alle Funktionen (F9 filtert je Funktion); `ORDERS`/`orderLabel` (`abo.js:707,782`) kennen nur `funktion`.
- Das Wort ENTWURF ist gepinnt in `termineRechtstexte.test.js:226` (Kopf der vier Dokumente), `termineEmbedStatic.test.js:219` (+ `docs/funktionen/termine-einbau.md:127`) und im Datenschutz-Wächter `termineRechtstexte.test.js:267`.
- WOMS `scripts/setup-legal.mjs` `DOCUMENTS` (Z. 89-96) hat sechs Karten; die Analyse-Karten (README nennt „datenschutz-website-analyse“) stehen nicht im Skript. Nicht Teil dieses Auftrags, dem Inhaber melden.
- Der gesetzliche Wertersatz (§ 357a Abs. 2 BGB, Anlage 1: Anteil am Gesamtpreis) ist nicht D3 „nach Stundenaufwand“; die Belehrung trägt den Gesetzeswortlaut, der Aufwand ist nur das Maß für den Anteil (⚖ Q5).
- `adminActivate` (`siteFeatures.js:350`) prüft weder Einwilligung noch AVV; `validateTextform` (`analyse.js:117`) ist exportiert und nimmt `minDate`.
---
### Task F1: Fassungskonstante und Dokument-Registry
**Files:** Create `server/services/legalDocs.js`, `test/legalDocs.test.js`; Modify `server/config.js` (`termine.avvVersion`; `features.termine.leistungsbeschreibung` und `.avv` als Getter `{ version: config.termine.avvVersion, url: '/rechtliches/leistungsbeschreibung-terminplaner-<version>.html' | '/rechtliches/avv-terminplaner-<version>.html' }`, sie ersetzen die ENTWURF-Platzhalter aus B1; `config.js` darf `legalDocs.js` nicht importieren, die Adresse wird dort aus Slug und Version gebaut, ein Test gleicht sie mit `legalPaths` ab; `ustHinweis` gibt es seit B1), `server/services/analyseLegal.js` (dünner Wrapper), `server/index.js` (`:41,114`: `app.use('/rechtliches', legalRouter())`).
**Consumes:** B1 (`features.termine`, `config.ustHinweis`). Der letzte Test nutzt B `featureConfig` aus `server/services/featureRegistry.js`.
**Produces:** `config.termine.avvVersion` (= `<F>`, der echte Berliner Kalendertag des F1-Commits laut Kopf, kein Platzhalter und nicht vor `2026-10-03`) und die Getter `features.termine.leistungsbeschreibung`/`.avv` (Version und Adresse folgen der Konstante auch zur Laufzeit). In `legalDocs.js`: Sätze `analyse` (avv, lb) und `termine` (lb, avv, widerruf; Slugs `leistungsbeschreibung-terminplaner`, `avv-terminplaner`, `widerrufsbelehrung-funktionen`; PDF-Namen `Leistungsbeschreibung-Terminplaner-<v>.pdf`, `AVV-Terminplaner-<v>.pdf`, `Widerrufsbelehrung-Muster-Widerrufsformular-<v>.pdf`); `legalPaths(satz, version) ⇒ { [doc]: '/rechtliches/<slug>-<v>.html' }`; `legalPdfFiles(satz, version) ⇒ { [doc]: { filename, path } }`; `legalPdfAttachments(satz, version, { ohne = [] } = {}) ⇒ Promise<Array<{ filename, content, contentType }>>` (Reihenfolge termine: lb, avv, widerruf; analyse: avv, lb; wirft bei fehlender Datei); `featureLegalAttachments(featureKey, opts) ⇒ Promise<Array>` (`termine` ⇒ Satz `termine` in aktueller Fassung, `news` ⇒ `[]`); `featureLegalReady(featureKey) ⇒ Promise<boolean>`; `legalRouter()` (Adressen ohne Fassung für beide Sätze, liefert die Fassung aus `config`; **nur** für Menschen und WOMS-Karten, keine Mail und kein Zustimmungstext darf sie verlinken).
`analyseLegal.js` behält `legalPaths(version) ⇒ { avv, lb }`, `legalPdfFiles`, `legalPdfAttachments`, `analyseLegalRouter` (rufen die Registry mit `'analyse'`), damit `analyseLegal.test.js` und `analyseRoutes.test.js:115` ohne Änderung grün bleiben. Versionsprüfung bleibt `^\d{4}-\d{2}-\d{2}$`.
- [ ] Tests (`legalDocs.test.js`): `legalPaths('termine', V)` nennt drei versionierte Adressen; `'../x'` und unbekannter Satz werfen; `legalPdfFiles` Dateinamen; `legalPdfAttachments('termine','1999-01-01')` rejected; `featureLegalAttachments('news')` = `[]`, `featureLegalReady('news')` = true; `config.termine.avvVersion` passt auf das Datumsmuster und liegt nach `'2026-10-02'` (der Altfassung der Gästetexte), `config.ustHinweis` wörtlich (aus B1, nur gelesen); Analyse-Tests unverändert grün; (nach B) `featureConfig('termine').leistungsbeschreibung` und `.avv` = `{ version: config.termine.avvVersion, url: legalPaths('termine', version).lb | .avv }` (Drift-Wächter gegen die in `config.js` gebaute Adresse; ändert ein Test `config.termine.avvVersion`, folgt der Getter); `featureLegalAttachments('news')` ist `[]` (die News-Rechtsdokumente `leistungsbeschreibung`/Fassung 2026-09-30 aus B1 bleiben unberührt und gehören F2).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Rechtstexte: Dokument-Registry und Fassungskonstante des Terminplaners“
### Task F2: News-Korrektur (Zitat, Wertersatz, Umsatzsteuer-Wortlaut)
**Files:** Modify `server/config.js` (`features.news.consent`, Alias `funktionen.consentText`, Kommentar `:192`), `public/vertragsunterlagen/funktionen-leistungsbeschreibung.html` (`:49,51,60,73`, „Stand“), `public/funktionen.js:272` (bzw. die Stelle, an die B den Preis verschoben hat), `server/services/stripeBilling.js:642` (Kommentar), `docs/superpowers/specs/2026-09-30-funktionen-news-design.md` (`:73,:78`, Korrekturvermerk); `features.news.mails.preisHinweis` steht in `config.js` (die K15-Vorlage ändert F2 **nicht**, B9a hat sie schon auf `{{preis_hinweis}}` umgestellt; `ustHinweis` im GET liefert B12). Tests `test/legalTexts.test.js:485`, `test/funktionenRoutes.test.js` (~153), neu `test/funktionenRecht.test.js`; **angepasste B-Pins:** `test/featureMails.test.js` (B9a: der eingefrorene News-Wortlaut „(brutto, einmalig; …)“ und die K15-Preiszeile bekommen den neuen Satz ohne „brutto“), `test/featureRegistry.test.js` (B1: der Wortlaut-Pin des News-Satzes und der Fassung; die übrigen Pins lesen `config.features.news`), `test/funktionenCheckout.test.js`/`test/funktionenRoutes.test.js` (B7: News-Nachweis liest Text und Fassung aus `config`).
**Consumes:** B-Register (`featureConfig('news').consent`), F1 `config.ustHinweis`.
**Produces:** News-Zustimmung (ein Haken, id `beginn`, `consent.version` = `<F>`): „Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).“ (⚖ Q3; Aufbau wie der geprüfte Gästetext `ZUSTIMMUNG_VORZEITIG`). LB: Kopf-Tabelle und Abschnitt 2 „30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG)“, Abschnitt 6 „… erlischt nach § 356 Abs. 5 Nr. 2 BGB …“ plus Satz zum Wertersatz mit „(§ 357a Abs. 2 BGB)“; die LB bleibt „ENTWURF, Freigabe Nutzer ausstehend“ (Freigabe ist Sache des Inhabers). Bestellseite: „30 € · ohne Umsatzsteuer gemäß § 19 UStG“ aus `ustHinweis` (B12); K15: nur der Registerwert `features.news.mails.preisHinweis` verliert „brutto“ („einmalig, ohne Umsatzsteuer gemäß § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo“). Bestehende News-Bestellungen behalten Text und Fassung (keine Migration).
- [ ] Tests (`funktionenRecht.test.js`): `news_zustimmung_wortlaut` (Register = Alias = exakter Text, enthält `Abs. 5 Nr. 2` und `§ 357a Abs. 2`, nicht `Abs. 4`); `waechter_zitat_und_preistext` (über config.js, News-LB, funktionen.js bzw. Bestellseite, K15, stripeBilling.js kein Treffer auf `/§ 356 Abs\. 4/`, `/inklusive (Mehr|Umsatz)steuer/i`, `/\bbrutto\b/i`; LB nennt `ohne Umsatzsteuer gemäß § 19 UStG` zweimal); `bestellseite_hinweis` (`GET /api/funktionen` ⇒ `ustHinweis === config.ustHinweis` (B12), Client setzt ihn per `textContent`); `k15_news` (gerenderter Text: `30,00 €`, Hinweis, kein „brutto“); `alte_bestellung_unveraendert` (Dokument mit altem `consentText` bleibt beim Lesen unverändert, neue Bestellung speichert Register-Text und Fassung). `legalTexts.test.js:485` prüft `['30,00 €','§ 356 Abs. 5 Nr. 2 BGB','§ 357a Abs. 2 BGB','1–2 Wochen','keine laufenden Kosten']`; der ENTWURF-Pin der News-LB (`:481`) bleibt.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „News: Zitat § 356 Abs. 5 Nr. 2, Wertersatz-Satz und § 19 UStG statt brutto“
### Task F3: Widerrufsbelehrung der Funktionen (Text aus Code, Dokument gepinnt)
**Files:** Modify `server/services/termine/rechtstexte.js` (die Absatz-Bausteine nehmen die „Widerrufsfunktion“ als Parameter), Create `docs/rechtliches/widerrufsbelehrung-funktionen.md`; Test `test/termineRechtstexte.test.js` (erweitern, Hilfsfunktionen `musterZeilen`/`fristBaustein` unverändert nutzen).
**Produces:** `widerrufsbelehrungFunktion() ⇒ string` (Sie-Form, Text mit Zeilenumbrüchen wie `widerrufsbelehrung`): WEBklar als Unternehmer (`WEBKLAR_KONTAKT = { name: 'WEBklar GbR', adresse: 'Am Schwimmbad 10, 67722 Winnweiler', telefon: '0176 23726355', email: 'support@webklar.com' }`), Fristbeginn „des Vertragsabschlusses.“, Widerrufsfunktion im Satzrahmen der Anlage 1 mit der Adresse `https://project.webklar.com/widerrufen.html` (Wortlaut wie im Abo, `abo-bedingungen.html` Abschnitt „Widerrufsbelehrung“), Wertersatz für Dienstleistungen, Muster-Widerrufsformular mit „An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:“. Die Ausgabe von `widerrufsbelehrung(betrieb)` (Gäste) bleibt byte-gleich.
Dokument `widerrufsbelehrung-funktionen.md`: `# Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)`, `**Fassung <F>**`, Hinweis „Das Widerrufsrecht gilt nur für Verbraucher“ (D1: Belehrung und Haken erhalten trotzdem alle Besteller, ⚖ Q1), Belehrung + Formular wörtlich aus `widerrufsbelehrungFunktion()`, dann „Hinweise zum Widerruf“ (nicht Teil der gesetzlichen Belehrung): 1. gilt je Bestellung einer Funktion und je Website-Projekt; 2. der Widerruf der Einrichtung beendet die Funktion, das Wartungs-Abo bleibt unberührt (⚖ Q7); 3. hat WEBklar auf ausdrücklichen Wunsch vor Fristende mit der Einrichtung begonnen, schuldet der Kunde einen angemessenen Betrag für die bis dahin erbrachte Leistung, bemessen als Anteil am Gesamtpreis; Maß ist der im Arbeitsblatt erfasste Aufwand (⚖ Q5); 4. Rückzahlung über das ursprüngliche Zahlungsmittel (Stripe).
- [ ] Tests: `belehrung_funktion_muster_woertlich` (jeder Absatz der Anlage 1 für Dienstleistungen steht wörtlich darin, nur die Lücken sind gefüllt); `belehrung_funktion_widerrufsfunktion` (Satzrahmen aus Anlage 1 wörtlich, Lücke = die Adresse); `belehrung_funktion_formular` (Anlage 2 wörtlich, „An“-Zeile mit WEBklar); `belehrung_gleich_abo` (die Absätze zwischen „Widerrufsrecht“ und „Ende der Widerrufsbelehrung“ in `public/abo-bedingungen.html` sind nach Normalisierung der Absatzgrenzen wortgleich zur Funktionsausgabe, wobei Kontaktklammer und „An“-Zeile vorher in beiden durch einen Platzhalter ersetzt werden, weil die Gäste-Bausteine „Telefon: …, E-Mail: …“ mit Doppelpunkt setzen; Drift-Wächter zwischen den beiden WEBklar-Belehrungen); `belehrung_dokument_gepinnt` (Textblock in der Datei = Funktionsausgabe, Kopf trägt `Fassung ${config.termine.avvVersion}`, kein `[`, `…`, `{{`); Gäste-Belehrung unverändert (bestehende Tests grün).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Rechtstexte: Widerrufsbelehrung der Funktionen aus dem Gesetzesmuster“
### Task F4: Leistungsbeschreibung Terminplaner (Markdown)
**Files:** Create `docs/rechtliches/leistungsbeschreibung-terminplaner.md`, `test/termineVertragsunterlagen.test.js`.
**Aufbau** (Nummern verbindlich: AVV, Paket und Mails verweisen darauf): `# Leistungsbeschreibung Terminplaner`, `**Fassung <F>** · Zusatzbedingungen zum Wartungs-Abo` und Abschnitte 1 Geltung · 2 Leistung · 3 Nicht enthalten · 4 Voraussetzungen und Pflichten des Kunden · 5 Einrichtung und Preis · 6 Verfügbarkeit und Störungen · 7 Haftung für verpasste Termine · 8 Ende der Leistung · 9 Änderungen und Einstellung · 10 Widerruf · 11 Hinweise. Dritte Person („der Kunde“) wie die Analyse-LB; Gerüst und Tonfall aus `leistungsbeschreibung-website-analyse.md`.
**Inhalt (Festlegungen):**
- 1: Zusatz zum Wartungs-Abo, **Hosting-Abo genügt nicht**; vereinbart mit der Bestellung und der Zustimmung zu LB, AVV Terminplaner und Belehrung (AVV Vorbemerkung regelt den Ablauf); Rangfolge: LB vor Abo-Bedingungen, in Datenschutzfragen der AVV; LB B Nr. 3 der Abo-Bedingungen („Rechtstexte nicht enthalten“) gilt nicht für Datenschutz-Absatz und Gästetexte.
- 2: je Website-Projekt ein Widget an einer Stelle (inline oder Knopf), Gestaltung nach Fragebogen; Leistungen, Personen/Plätze, Zeiten, Regeln; Portal-Ansicht, CSV-Export; E-Mails (Bestätigungslink, Bestätigung, Änderung, Absage, Erinnerung nur mit Haken, Eingang eines Widerrufs); geheimer Kalender-Link (ICS); Missbrauchsschutz; Löschfristen nach AVV Nr. 9; **nur Zahlung vor Ort**; Gästetexte, Datenschutz-Absatz und Belehrung für Gäste sind Mustertexte der Standardkonfiguration.
- 3: nicht enthalten: SMS, Online-Zahlung, Kalender-Sync (nur ICS-Abo), Konten der Gäste, Marketing-Mails, Gruppen, Mehrsprachigkeit, **Gesundheitsbereich**; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden (§ 4 Abo-Bedingungen).
- 4: laufendes Wartungs-Abo; Betriebsangaben samt Impressum- und Datenschutz-Adresse vor der Bestellung; kein Angebot im Gesundheitsbereich (WEBklar darf Einrichtung ablehnen oder abschalten, ⚖ Q11); kein Angebot gezielt an Kinder unter 16; Telefon-Ausweichlösung; Haken- und Hinweistexte unverändert lassen; Beschäftigte über Namen/Zeiten und Kalender-Link informieren; Pflichten gegenüber Gästen (Preisangaben, AGB/Stornobedingungen, Einstufung Freizeit/Verbraucher) trägt der Kunde. **Datenschutz-Absatz:** liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar ihn beim Einbau ein, Teil der Einrichtung und nicht auf das Stundenkontingent angerechnet; sonst Textbaustein, den der Kunde selbst einfügt (gleiche Regel wie Analyse-LB Nr. 4 Abs. 4, ⚖ Q18).
- 5: Einrichtung nach Fragebogen, Einbau, Datenschutz-Absatz, Abnahme, Freischaltung; **149 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG**; die voraussichtliche Dauer nennt die Bestellseite (Einschätzung, keine Zusage); Zahlung über Stripe, Rechnung; Preis nicht auf Stunden anrechenbar.
- 6: Verfügbarkeitsziel (§ 5 Abo-Bedingungen) gilt für die Website, nicht für die Funktion; bei Ausfall zeigt das Widget den Ausweichtext mit Telefonnummer; Zeiten in Europe/Berlin; keine Gewähr für Zustellung und Lesen von E-Mails (Postfach des Empfängers, Mailgrenzen des Anbieters); gesetzliche Rechte, §§ 12, 13 Abo-Bedingungen bleiben.
- 7: (1) Hilfsmittel; Einhaltung der Termine bleibt Sache des Kunden. (2) keine Gewähr für Zustellung der E-Mails. (3) Entgangener Gewinn und Folgeschäden aus verpassten Terminen nur im Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen. (4) Der Haftungsrahmen des Kunden gegenüber seinen Gästen bleibt unberührt (⚖ Q8).
- 8: (1) „Mit dem Ende des Wartungs-Abos endet der Terminplaner. WEBklar schaltet ihn spätestens am folgenden Tag ab und informiert den Kunden per E-Mail. Das gilt auch bei einem Wechsel ins Hosting-Abo und beim Widerruf des Abschlusses oder eines Wechsels ins Wartungs-Abo.“ (⚖ Q7). (2) Wirkung: keine neuen Buchungen, Ausweichtext; Termine bleiben im Portal sichtbar, CSV-Export; Gäste können über ihren Link absagen; bereits angeforderte Erinnerungen laufen weiter; der Kunde informiert seine Gäste. (3) „WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten.“ (⚖ Q9). (4) Rückkehr (D2): „Beginnt innerhalb dieser 30 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Einrichtung nötig (neuer Fragebogen, neuer Preis nach Nr. 5).“ (5) Erstattung (D3): „Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.“ (⚖ Q6). (6) Auf Wunsch des Kunden in Textform schaltet WEBklar den Terminplaner jederzeit ab; eine Erstattung des Einrichtungspreises folgt daraus nicht (außer Widerruf, ⚖ Q6). Hinweis zur Kanzleifrage Q7: Dokumente, die WEBklar **ohne Bestellung** freigeschaltet hat (Pilot/Kulanz, auch F13a), nimmt der tägliche Abgleich aus und zählt sie im Tagesbericht als `ausgenommen`; sie bleiben bis zur Abschaltung durch WEBklar aktiv.
- 9: Technik verbessern, solange der Leistungsumfang nicht schlechter wird; Einstellung nur bei Rechtslage oder nicht behebbarer Sicherheitslücke mit Frist (Muster Analyse-LB Nr. 8 Abs. 2 und 3); **keine** Einstellung ohne Grund; Rechte nach § 327r BGB bleiben (Erstattung bei Einstellung durch WEBklar offen, ⚖ Q8).
- 10: Verbraucher widerrufen die Bestellung der Einrichtung nach der Belehrung (Anlage); Erlöschen und Wertersatz nach der Zustimmung bei der Bestellung (⚖ Q3, Q5); der Widerruf beendet die Funktion.
- 11: Keine Rechtsberatung: Mustertexte für die Standardkonfiguration, Einstufung (Verbraucher/Unternehmer, Freizeit) trägt der Kunde; **Barrierefreiheit:** „Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen, Fokusführung, Fehlermeldungen als Live-Region). Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu. Ob der Kunde als Dienstleistungserbringer unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Kunde.“ (⚖ Q22; kein axe-/Screenreader-Lauf in TP1, daher keine Konformitätszusage).
- [ ] Tests (`termineVertragsunterlagen.test.js`, LB-Teil): `lb_aufbau` (h2-Liste exakt, Kopf `Fassung ${config.termine.avvVersion}`); `lb_sauber` (kein `ENTWURF`, `Prüfpunkt`, `TODO`, `{{`, `<!--`); `lb_festwerte` (`149 €`, `§ 19 UStG`, „Hosting-Abo genügt nicht“-Satz, die Sätze von Nr. 8 Abs. 1, 3, 4, 5 wörtlich); `lb_preis_gleich_register` (`149 €` = `featureConfig('termine').priceLabel`); `lb_fristen_gleich_code` (30 Tage Abschalten = `LOESCHFRIST_TAGE` aus `termine/frist.js` (E2), kein Regex-Lesen von `jobs.js`); `lb_verweise` (jedes „Nr. N“ auf LB/AVV und „§ N Abo-Bedingungen“ existiert); `lb_ohne_du` (keine Du-Form); `lb_bfsg_ohne_konformitaetszusage` (kein „WCAG … entspricht“).
- [ ] FAIL → Text schreiben → PASS → `npm test` → Commit „Rechtstexte: Leistungsbeschreibung Terminplaner“
### Task F5: AVV Terminplaner (Markdown)
**Files:** Create `docs/rechtliches/avv-terminplaner.md`; Test `test/termineVertragsunterlagen.test.js` (AVV-Teil).
**Aufbau** wie `avv-website-analyse.md` (eigenes Geschwister, **kein Zusatz** zum AVV der Analyse, damit Analyse-Kunden nicht neu zustimmen): `# Auftragsverarbeitungsvertrag Terminplaner (Art. 28 DSGVO)`, `**Fassung <F>**`, Parteien, Vorbemerkung, Nr. 1–12, Anlage TOM.
**Inhalt (Festlegungen):**
- Vorbemerkung: Abschluss per Haken bei der Bestellung zusammen mit der LB; das Portal speichert Fassung, Zeitpunkt, Konto und Person (`featureConsents`); danach schickt WEBklar K15 mit den PDFs. Mitarbeiter dürfen die Funktion nur einschalten, wenn der Kunde vorher in Textform zugestimmt hat (F13a); der Kunde erhält die Unterlagen anschließend per E-Mail; Verhältnis zu Anlage 1 der Abo-Bedingungen: für den Terminplaner geht dieser Vertrag vor.
- Nr. 1 Dauer: ab Einschalten bis Abschalten, spätestens Ende des Wartungs-Abos (Abschalten spätestens am Folgetag); Pflichten bis zur Löschung. Nr. 2: Termine verwalten, E-Mails im Auftrag, Missbrauchsschutz; keine eigenen Zwecke, keine Profile.
- Nr. 3 Daten: Gäste (Name, E-Mail; optional Telefon, Nachricht, Zusatzfelder des Betriebs; Leistung, Person/Platz, Termin; Status mit Zeitpunkten; Nachweis der Zustimmungen als Zeitpunkt und Hashes der gezeigten Texte; Erinnerungswunsch; Zustellstatus der E-Mails) und Beschäftigte des Kunden (Namen/Bezeichnungen, Arbeitszeiten, Abwesenheiten, geheimer Kalender-Link). **Art. 9 DSGVO ausgeschlossen** (Gesundheitsbereich; Pflicht Nr. 11). IP-Adresse und Browser sieht der Server technisch, Mengenbegrenzung nur im Arbeitsspeicher, **mit der Buchung wird keine IP gespeichert**; Server-Protokolle 14 Tage (Anlage 2 der Abo-Bedingungen).
- Nr. 4 Weisungen (Einstellungen im Portal, sonst Textform), Nr. 5 Vertraulichkeit, Nr. 6 TOM (Anlage), Nr. 7 Unterauftragsverarbeiter: **Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen: Falkenstein (Server, Sicherung) und Nürnberg (Webhosting-SMTP für die Buchungsmails)**, keine Drittländer, Änderung mit vier Wochen Vorlauf und zwei Wochen Widerspruch (Muster Analyse-AVV Nr. 7), Stripe betrifft nur den Vertrag Kunde–WEBklar (⚖ Q10).
- Nr. 8 Betroffenenrechte: Anfragen von Gästen leitet WEBklar an den Kunden weiter; Unterstützung durch die Aktion „Gastdaten löschen“ (`anonymisieren`) und den CSV-Export; Datenpannen unverzüglich, möglichst binnen 24 Stunden; **Archiv der Fassungen der Gästetexte** (`docs/rechtliches/archiv/termine-gaestetexte-<V>.md`) bis drei Jahre nach Ende (Nachweis speichert nur Hashes, ⚖ Q26).
- **Nr. 9 Löschung genau wie gebaut:** nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten; abgesagte/abgelehnte Termine 30 Tage; bestätigte und erledigte Termine `aufbewahrungTage` (Vorgabe 90, einstellbar 14–365) nach Terminende; Versandwarteschlange: Empfängeradresse und Inhalt nach dem Versand geleert, Eintrag nach 7 Tagen gelöscht, nicht zustellbare nach 48 Stunden; **Abgeschaltet oder Ende des Abos: alle Termindaten und Einstellungen 30 Tage danach** (CSV-Export bis dahin, Rückkehr nach LB Nr. 8 Abs. 4); Entwürfe aus dem Fragebogen ohne Bestellung 90 Tage nach der letzten Änderung; Sicherungen: gelöschte Daten bleiben bis zu 7 Tage (§ 3 Abs. 5 Abo-Bedingungen, ⚖ Q9).
- Nr. 10 Nachweise/Kontrollen (wie Analyse), Nr. 11 Pflichten des Kunden (Verantwortlicher; Information der Gäste nach Art. 13 mit Datenschutz-Absatz und Hinweis am Formular; Beschäftigte informieren; kein Gesundheitsbereich; Erinnerungs-Haken nicht verändern), Nr. 12 Haftung (§ 13 Abo-Bedingungen, Art. 82), Änderungen mit sechs Wochen Vorlauf, im Übrigen § 16.
- Anlage TOM (Art. 32): TLS; **keine Cookies, nichts im Browser des Gastes** (gesichert durch `termineEmbedStatic.test.js:24`); Zugriff: Portal-Sitzung, Kunde sieht nur eigene Daten, öffentliche Schnittstelle nur über Site-Key, Kalender-Link mit geheimem Token (konstantzeitiger Vergleich); Missbrauchsschutz (Honeypot, Mindestzeit, Grenzen und Mailbudget nur im Arbeitsspeicher, `limits.js`); Datenminimierung; Trennung je Funktions-Dokument; Löschlauf täglich; Protokolle ohne Gast-Daten; Sicherung Hetzner (7 Tagessicherungen); Rechenzentrum ISO/IEC 27001; Organisation (Verpflichtung, Überprüfung jährlich). Jede Zeile der Anlage nennt in der Test-Tabelle ihre Fundstelle.
- [ ] Tests (AVV-Teil): `avv_aufbau` (Überschriften Vorbemerkung, 1–12, Anlage; Kopf mit Fassung); `avv_sauber` (wie LB); `avv_unterauftragsverarbeiter` (Nr. 7 nennt Hetzner mit Anschrift, `Falkenstein` **und** `Nürnberg`, kein Drittland); `avv_loeschfristen_gleich_code` (48 h, 30 Tage, Vorgabe 90 / 14 / 365, Outbox 7 Tage / 48 h; die 30 Tage Abschalten aus `LOESCHFRIST_TAGE` (`termine/frist.js`, E2), die übrigen Werte aus exportierten Konstanten bzw. per Regex aus `jobs.js`/`outbox.js`/`AUFBEWAHRUNG_*`, soweit nicht exportiert); `avv_keine_ip_in_buchung` (`TERMINE_COLLECTIONS.termineBookings.attributes` in `schema.js` enthält kein Attribut, dessen Name auf `/^(ip|ipAdresse|clientIp|userAgent)$/i` passt – ein loses `/ip/` träfe `stripeSessionId` und `stripePaymentIntent`; der `nachweis` einer über `halte` gehaltenen Buchung enthält keinen Schlüssel `ip`/`userAgent`; `baueNachweis` selbst ist nicht exportiert); `avv_verweise_auf_lb` (jedes „Nummer N der Leistungsbeschreibung“ existiert); `avv_ohne_analyse_bezug` (Analyse-Kunden brauchen keine neue Zustimmung: kein Verweis auf eine Änderung des Analyse-AVV).
- [ ] FAIL → Text schreiben → PASS → `npm test` → Commit „Rechtstexte: AVV Terminplaner“
### Task F6: HTML-Generator, PDF-Modus und ausgelieferte Dateien
**Files:** Create `scripts/build-legal-html.mjs`, `test/buildLegalHtml.test.js`, `public/rechtliches/{leistungsbeschreibung-terminplaner,avv-terminplaner,widerrufsbelehrung-funktionen}-<F>.{html,pdf}`; Modify `scripts/build-legal-pdfs.sh` (Modi `termine` und `datenschutz`, Variable `TERMINE_VERSION` mit Vorgabe `<F>`; die Zeile `pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf` bleibt in `all`), `docs/rechtliches/archiv/README.md` (Tabelle „Terminplaner“: Fassung, Dateien, „gültig ab“ offen bis Livegang).
**Consumes:** F1 `legalPaths`, F3–F5 Markdown.
**Produces:** `node scripts/build-legal-html.mjs termine [--check]` rendert die drei Markdown-Quellen deterministisch (keine Datumsangabe) in dieselbe Seitenhülle wie die Analyse-Seiten (Navigation zu den anderen Dokumenten, `@page`-Kopf „WEBklar · <Titel>“, Seite x von y, `meta` mit Fassung); Teilmenge von Markdown (h1–h3, Absätze, `- `-Listen, `**fett**`, `` `code` ``, Tabellen); jede andere Syntax (``` , `>`, Bilder, rohes HTML) bricht laut ab. `--check` vergleicht mit den eingecheckten Dateien (Exit 1). `render(md, meta) ⇒ html` wird exportiert. `build-legal-pdfs.sh termine` erzeugt die drei PDFs auf dem Host (vorher `free -m`, mit `nice`, Chrome aus `~/.cache/ms-playwright`); PDFs und HTML werden explizit gestaged.
- [ ] Tests (`buildLegalHtml.test.js`): `html_gleich_generator` (eingecheckte HTML = `render(md)` für alle drei); `html_text_gleich_markdown` (jeder Absatz der Quelle steht nach Tag-Entfernung und Normalisierung im HTML); `html_sauber` (kein `<!--`, `Entwurf`, `Prüfpunkt`, `<script`, `on…=`; `Fassung <F>` im Kopf; Links nur relativ oder `https:`); `generator_lehnt_unbekannte_syntax_ab`; `generator_escaped` (`<`, `&`, `"` in Text und Attributen); `pdf_dateien` (`%PDF`, > 20 KB je Datei, Dateinamen aus `legalPdfFiles`); `anhaenge_drei` (`legalPdfAttachments('termine', F)` liefert lb, avv, widerruf mit `application/pdf`); `router_alias` (`/rechtliches/avv-terminplaner.html` = Bytes der Fassungsdatei, unbekannte Fassung 404, `-1999-01-01.html` 404); `script_modus` (`build-legal-pdfs.sh` enthält die Modi `termine`, `datenschutz` und die unveränderte `all`-Zeile).
- [ ] FAIL → implementieren, Generator laufen lassen, PDFs bauen → PASS → `npm test` → Commit „Rechtstexte: HTML-Generator und PDFs des Terminplaners“
### Task F7: Bestelltexte (Zustimmungen), Dokumente und Belehrung im Register
**Files:** Modify `server/config.js` (`features.termine.consent`; `features.termine.widerruf` als Getter `{ version: config.termine.avvVersion, url: '/rechtliches/widerrufsbelehrung-funktionen-<version>.html' }` wie in F1), `server/services/featureRegistry.js` (Standardhaken `featureHaken('termine').belehrung` = `widerrufsbelehrungFunktion()`, F3); Test neu `test/featureConsentTexte.test.js`; **angepasste B-Tests** (gezielt, ihre Pins hängen an Texten und Fassung): `test/featureRegistry.test.js` (B1: Item-Texte und Fassung; der `todo`-Test „Bestelltexte ohne ENTWURF“ wird grün; die Item-IDs `unterlagen`/`gesundheit`/`beginn`/`information` stehen seit B1), `test/featureConsents.test.js` (B4: `nachweisTexte` mit `belehrung`, `zustimmungsfassungen` mit `widerruf`), `test/funktionenCheckout.test.js` (B6/B7: vier Haken, Nachweis mit `belehrung` und `knopf`).
**Consumes:** B-Register (`featureConfig`, `server/services/featureRegistry.js`: `consent: { version, knopf, items }`, `priceLabel`, `setupEstimate`, `leistungsbeschreibung`), B `featureConsents.js` und Checkout (Reihenfolge laut B7: Stripe-Sitzung erzeugen, Nachweis speichern, **dann** die URL zurückgeben), C2 (`bestellungAnsicht` liest `belehrung` aus demselben Haken), F1, F3.
**Produces:** `features.termine.consent = { version: config.termine.avvVersion, knopf: 'Zahlungspflichtig bestellen · 149 €', items }` mit vier Haken (alle Besteller, D1; alle `haken: true`, ohne `url`; die B-/C-Oberfläche rendert jedes Item mit `haken: true` als Haken, die ids sind für Tests und Nachweis verbindlich):
1. `unterlagen`: „Ich stimme der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${version} zu.“ (die versionierten Links stehen daneben: C8 rendert sie aus `bestellung.dokumente`, F1/F7 liefern die Adressen)
2. `gesundheit`: „Mein Angebot liegt nicht im Gesundheitsbereich (zum Beispiel keine Arzt-, Zahnarzt-, Psychotherapie-, Heilpraktiker- oder Physiotherapiepraxis).“
3. `beginn`: „Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Terminplaners beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).“ (⚖ Q3, Q5)
4. `information`: „Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Terminplaners für diese Website: ein Widget an einer Stelle, gestaltet nach dem Fragebogen, mit Einbau und Datenschutz-Absatz), Gesamtpreis ${priceLabel} einmalig (${ustHinweis}), voraussichtliche Dauer ${setupEstimate}, der Terminplaner endet mit dem Wartungs-Abo. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.“ (⚖ Q20)
Die dynamischen Teile setzt `config.js` einmal beim Laden ein, gespeichert wird immer der fertige Text. Knopf: „Zahlungspflichtig bestellen · 149 €“ (⚖ Q19). **Nachweis (Review Focus 5):** B7 speichert zusätzlich zu den Haken den Knopftext als Eintrag `{ id: 'knopf', text }` und die **gezeigte** Widerrufsbelehrung samt Muster-Formular (Hook `belehrung`) als Eintrag `{ id: 'belehrung', text }`; `zustimmungsfassungen` trägt `widerruf: version`. Die Bestellseite (C8) zeigt `bestellung.belehrung` aus demselben Hook, gezeigter Text und gespeicherter Text sind also dieselbe Zeichenkette.
- [ ] Tests (`featureConsentTexte.test.js`): `termine_haken_woertlich` (ids genau `['unterlagen','gesundheit','beginn','information']`, alle `haken true`, Texte exakt); `termine_version_gleich_dokumente` (= `config.termine.avvVersion`, `Fassung` in Haken 1); `information_nennt_register_werte` (`149 €`, `ohne Umsatzsteuer gemäß § 19 UStG`, `setupEstimate`); `keine_urls_im_text`; `textstand_pin` (SHA-256 über die verbundenen Texte beider Funktionen steht im Test, Kommentar „bei Textänderung neues Fassungsdatum **und** neuen Hash“: Textänderung ohne Fassungswechsel fällt auf); `nachweis_vor_umleitung` (Integration über B7: `POST /api/funktionen/termine/checkout` legt `featureConsents` mit `texte` = Register-Haken + `belehrung` + `knopf`, `fassungen` (mit `widerruf`) und Zeitpunkt an, `acceptedAt` == Klickzeit; das Dokument existiert, **bevor die Antwort mit `url` zurückkommt**, die Stripe-Sitzung entsteht davor (B7); schlägt das Speichern fehl ⇒ 500 ohne `url` und ohne `ordered`, die verwaiste Sitzung läuft nach 24 h ab); `belehrung_gezeigt_gleich_gespeichert` (gespeicherter `belehrung`-Text == `bestellung.belehrung` aus `GET /api/termine/einstellungen` == `widerrufsbelehrungFunktion()`); `knopf_gleich_register_und_nachweis` (`bestellung.consent.knopf` == Registertext == gespeicherter `knopf`-Eintrag); `alte_fassung_409` (Body mit alter Fassung ⇒ 409 mit neuer Fassung).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Bestelltexte des Terminplaners (vier Zustimmungen, Belehrung im Nachweis, Fassung)“
### Task F8: K15-Anhänge, Preisangabe und Unterlagen in der Bestellseite
**Files:** Modify `server/services/siteFeatures.js` (K15-Versand in `onFeaturePaid`: lazy `import('./legalDocs.js')` und `featureLegalAttachments(doc.featureKey)`; **kein** Registerhaken `mails.anhaenge`, B9a legt keinen an), `server/services/featureRegistry.js` (der Haken `unterlagenBereit` für `termine` = `featureLegalReady('termine')`), `server/routes/funktionen.js` (GET-Feld `legal`), `server/mail-templates/K15 Bestellung Funktion.html`, `server/mail-templates/T16 Funktion bestellt.html` (Block `anhang_fehlt`), `test/mailTemplates.test.js` (Platzhalter/Blöcke), Tests `test/funktionenRecht.test.js` (erweitern), `test/funktionenRoutes.test.js`, `test/featureMails.test.js` (**B9a/B9b-Pin „Platzhalter-/Blocklisten exakt“**: K15 um `fassung`, `lb_url`, `avv_url`, `widerruf_url`, `zustimmungen_text` und die Blöcke `anlagen`, `zustimmungen`, T16 um den Block `anhang_fehlt` ergänzen). Die Bestellseite `public/funktion-bestellen.js` (Darstellung der Belehrung und der Links) gehört C8; F8 ändert sie nicht.
**Consumes:** F1 `featureLegalAttachments`, `featureLegalReady`, `legalPaths`; F3 `widerrufsbelehrungFunktion`; B `onFeaturePaid`, `zustimmungFuerSitzung` (B4).
**Produces:** K15 trägt für `termine` drei PDF-Anhänge (LB, AVV, Belehrung mit Muster-Formular; dauerhafter Datenträger, § 312f BGB; Muster `legalPdfAttachments`/K18). **Mechanismus (einziger Weg):** `onFeaturePaid` ruft vor dem K15-Versand `await featureLegalAttachments(doc.featureKey)` (lazy `import()` aus `legalDocs.js`; `news` ⇒ `[]`) und übergibt das Ergebnis als `attachments`; ein Registerhaken `mails.anhaenge` besteht nicht. Neue K15-Werte (Texte in Du-Form wie die übrigen K-Vorlagen; `preis_hinweis` hat B9a schon deklariert und mit `mails.preisHinweis` gefüllt): `fassung`, `lb_url`, `avv_url`, `widerruf_url` (absolute, **versionierte** Adressen), `zustimmungen_text` (Wortlaut und Zeitpunkt der erteilten Zustimmungen aus `zustimmungFuerSitzung(session.id)`, B4: die Zustimmung zur **bezahlten** Sitzung); Blöcke `anlagen` und `zustimmungen` nur für Funktionen mit Rechtsdokumenten (⚖ Q20). Sind die PDFs beim Versand nicht lesbar, geht K15 **ohne** Anhänge raus (Bestätigung vor Anhang; die Zahlung hat Spur) und T16 meldet im Block `anhang_fehlt` „Unterlagen von Hand nachsenden“; Fehler loggen. Checkout: der B6-Haken `unterlagenBereit` ist für `termine` an `featureLegalReady('termine')` gehängt (Stufe 5b in B6: `false` ⇒ 503 `unterlagen_fehlen` „Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.“, vor jeder Stripe-Sitzung; die Stufe steht im Reihenfolge-Test von B6). `GET /api/funktionen` ergänzt `legal: { termine: { version, links: { lb, avv, widerruf } (absolut, https), belehrung: widerrufsbelehrungFunktion() }, news: null }` (`ustHinweis` liefert schon B12). Dieselben Werte (die versionierten Adressen über `dokumente`, die Belehrung über den Haken aus F7) fließen in `bestellungAnsicht` (C2), aus der die Bestellseite (C8) rendert: Links mit `rel="noopener noreferrer"` und `target="_blank"`, die Belehrung samt Formular als Text im **nicht einklappbaren** Bereich über dem Knopf (Absätze per `textContent`, **kein `<details>`**).
- [ ] Tests: `k15_termine_anhaenge` (drei Anhänge in der Reihenfolge lb, avv, widerruf, Namen aus `legalPdfFiles`, alle `%PDF`; `Object.hasOwn(featureConfig('termine').mails, 'anhaenge') === false`); `k15_news_ohne_anhaenge` (News unverändert, kein Block `anlagen`); `k15_links_versioniert` (Text enthält `-${F}.html`, **keine** Adresse ohne Fassung); `k15_anhang_nicht_lesbar` (`legalPdfAttachments` wirft ⇒ K15 ohne Anhänge, T16 mit Block `anhang_fehlt`, Dokument trotzdem `setup`, Ticket angelegt, Webhook 200); `k15_zustimmungen_text` (Text der vier Haken und Zeit im Mail-Text); `checkout_503_ohne_unterlagen` (keine Stripe-Sitzung, kein `ordered`-Dokument); `liste_legal` (`legal.termine.links` alle `https://` und versioniert, `belehrung` = Funktionsausgabe); `bestellseite_statisch` (kein `innerHTML` für die Belehrung, kein `<details>`, `rel="noopener noreferrer"`); `mailTemplates.test.js`: deklarierte = verwendete Platzhalter/Blöcke für K15 und T16; `featureMails.test.js`: die exakten Listen aus B9a/B9b sind um die oben genannten Werte ergänzt (kein zweiter Test dafür).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: Vertragsunterlagen als K15-Anhang, Belehrung vor dem Bestellknopf“
### Task F9: Widerruf je Funktion (Formular, Frist, K04/T09)
**Files:** Modify `public/widerrufen.html` (`:23`), `server/routes/abo.js` (`ORDERS :707`, `orderLabel :782`, `withdrawFeature :1088`, `:1118`, `:1143`, `:1166`), `server/mail-templates/K04 Eingang Widerruf.html`, `server/mail-templates/T09 Widerruf eingegangen.html`, `test/mailTemplates.test.js` (`:254-258`, Kopfkommentar), Tests `test/publicForms.test.js` (neben `:518`).
**Consumes:** B4 `paidFeatureOrders(customerId)` mit `featureKey` und `vertragAt` (Zeit der Zustimmung zur **bezahlten Sitzung** = Klick, Review-Fokus 5; B4 stellt `withdrawFeature` bereits auf `vertragAt || orderedAt` um), `featureConfig(key)` (`label.name`, `priceLabel`). F9 baut keinen zweiten Anker (kein `contractAt`, kein `letzteZustimmung`: eine spätere, unbezahlte Zustimmung zu einer neuen Sitzung würde den Anker verfälschen).
**Produces:** `widerrufen.html`: neuer Radioknopf `value="termine"` mit `data-text="Terminplaner (Einrichtung)"`, der bestehende `funktion` („Funktion (z. B. News-Pinnwand)“) bleibt unverändert. `ORDERS` + `termine`; `FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine' }`; `orderLabel('termine') = 'Terminplaner (Einrichtung)'`. `withdrawFeature(d, receivedAt, featureKey)` filtert die Bestellungen aus `paidFeatureOrders` auf die gewählte Funktion (`featureKey`) und rechnet die Frist **aus deren `vertragAt`** (spätestes Fristende je Funktion, Zeilen mit eigenem Fristende je Projekt). `produkt` = `featureConfig(key).label.name` (statt fest „News-Pinnwand“), neu `preis` = `featureConfig(key).priceLabel` in T09. K04 Block `funktion` (Sie-Form bleibt): Wertersatz-Satz für „begonnene“ statt „abgeschlossene“ Einrichtung, Verweis § 357a Abs. 2 BGB. T09 Block `funktion`: „Widerruf einer Funktion ({{produkt}}, {{preis}} einmalig) …“; Handgriffe: Erstattung in Stripe binnen 14 Tagen, ggf. abzüglich Wertersatz für den begonnenen Einbau (Aufwand im Arbeitsblatt, ⚖ Q5), Funktion im Reiter „Funktionen“ abschalten; bei Terminplaner: Wartungs-Abo bleibt unberührt.
- [ ] Tests: `widerruf_termine_frist_je_funktion` (News vor 20 Tagen, Terminplaner vor 3 Tagen bestellt, Widerruf `termine` ⇒ T09 `RECHTZEITIG`, Fristende des Terminplaners; umgekehrt Widerruf `funktion` ⇒ `NACH Fristende`; Regression des bisherigen Maximums); `widerruf_termine_mails` (K04 nennt „Terminplaner (Einrichtung)“, T09 „149 €“ und das Projekt; `Funktion (z. B. News-Pinnwand)`-Test bei `funktion` unverändert grün); `widerruf_anker_vertragAt` (`orderedAt` später überschrieben, Zustimmung zur bezahlten Sitzung früher ⇒ Frist aus `vertragAt`; ohne `featureConsents`-Eintrag Rückfall auf `orderedAt`; die Grundlage prüft der B4-Test, hier nur die Funktionsfilterung darauf); `widerruf_unbekannte_funktion` (kein bezahltes Dokument ⇒ „ungeprüft (keine bezahlte Funktion gefunden)“); `mailTemplates.test.js`: T09 deklariert `preis`, K04/T09 Blöcke unverändert; `widerrufen_html_radio` (Wert `termine`, Beschriftung wörtlich).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Widerruf: Terminplaner als eigene Auswahl, Frist und Mails je Funktion“
### Task F10a: Gästetexte – Archiv-Skript und eingefrorene Altfassung 2026-10-02
**Files:** Create `scripts/termine-texte-archiv.mjs`, `docs/rechtliches/archiv/termine-gaestetexte-2026-10-02.md` (Altfassung, aus den **unveränderten** Texten erzeugt, **eigener Commit vor jeder Textänderung**), `test/termineRechtstexteArchiv.test.js`; Modify `docs/rechtliches/archiv/README.md` (Tabelle „Terminplaner-Gästetexte“).
**Consumes:** `server/services/termine/rechtstexte.js` im Stand 2026-10-02 (noch ohne Änderung durch F10b1).
**Produces:** `termine-texte-archiv.mjs [--check]` schreibt `docs/rechtliches/archiv/termine-gaestetexte-<V>.md` aus `rechtstexte.js` mit Platzhalter-Betrieb: Fassung, Formular-Hinweis (Sie/Du), alle Haken, Gesundheitshinweis, Knopftexte (Tabelle), Belehrung (Platzhalter-Fassung), Hinweis „Der Nachweis speichert Hashes der Texte mit den Betriebsangaben zum Buchungszeitpunkt“ (⚖ Q26); `--check` vergleicht mit der eingecheckten Datei (Exit 1). Die README-Tabelle „Terminplaner-Gästetexte“ nennt die Fassung 2026-10-02.
- [ ] Tests: `archiv_aktuelle_fassung` (Skriptausgabe = eingecheckte Datei für die aktuelle `RECHTSTEXTE_VERSION`; Datei und README nennen die Fassung); `archiv_altfassung_eingefroren` (die Datei 2026-10-02 enthält den alten Erinnerungstext **ohne** den Widerrufssatz, ihr SHA-256 ist im Test festgenagelt).
- [ ] FAIL → implementieren, Altfassung erzeugen → PASS → `npm test` → Commit „Gaestetexte: Archiv-Skript und eingefrorene Fassung 2026-10-02“ (**vor** F10b1)
### Task F10b1: Gästetexte final 1 – Fassung <V>, Erinnerungs- und Abo-Ende-Satz, Archiv
**Files:** Modify `server/services/termine/rechtstexte.js` (`RECHTSTEXTE_VERSION`, `ZUSTIMMUNG_ERINNERUNG`, `datenschutzAbsatz` aus D1), `docs/rechtliches/termine-datenschutz-absatz.md` (Satz (c); Quelle der D1-Pins, Funktion und Markdown ändern gemeinsam), `docs/rechtliches/archiv/README.md`; Create `docs/rechtliches/archiv/termine-gaestetexte-<V>.md` (mit dem Skript aus F10a); Tests `test/termineRechtstexte.test.js` (Version, Sätze (b) und (c), D1-Pins), `test/publicTermine.test.js` (Nachweis-`fassung`), `test/termineRechtstexteArchiv.test.js`.
**Consumes:** F10a, D1 `datenschutzAbsatz(einstellung, anrede)`, F1 (`config.termine.avvVersion` = `<F>`).
**Produces:** `RECHTSTEXTE_VERSION = '<V>'` (= `<F>`, Literal in `rechtstexte.js`, nicht `2026-10-02`). Änderungen gegenüber `2026-10-02`: (b) `ZUSTIMMUNG_ERINNERUNG(n)` = „Ich möchte ${n} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.“ (Art. 7 Abs. 3 DSGVO, ⚖ Q17; der Hash im Nachweis deckt den Satz); (c) Datenschutz-Absatz Sie/Du: „Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.“ (Quelle `datenschutzAbsatz()` und Markdown gemeinsam); `termine-texte-archiv.mjs` (F10a) erzeugt `docs/rechtliches/archiv/termine-gaestetexte-<V>.md`. Die README-Tabelle „Terminplaner-Gästetexte“ nennt beide Fassungen. Die ENTWURF-Köpfe und `RECHTSTEXTE_ENTWURF` bleiben bis F10b2 unverändert.
- [ ] Tests: `archiv_aktuelle_fassung` (aus F10a, jetzt für `<V>`: Skriptausgabe = eingecheckte Datei, `RECHTSTEXTE_VERSION` in Datei und README); `archiv_altfassung_eingefroren` (aus F10a, unverändert grün); `textstand_pin` (SHA-256 der aktuellen Archivdatei im Test: Textänderung ohne neues Datum fällt auf); `erinnerung_widerrufssatz` (Haken-Text, Dokument und `texte.erinnerung` der öffentlichen Konfiguration wörtlich gleich); `absatz_abo_ende` (Funktion und Markdown, Sie und Du, nennen „30 Tage nach dem Abschalten“; bestehende Regex-Pins bleiben grün); `version_gleich_register` (`RECHTSTEXTE_VERSION === config.termine.avvVersion` und ungleich `'2026-10-02'`); Nachweis-`fassung` einer neuen Buchung = `RECHTSTEXTE_VERSION`, Altbuchungen behalten `2026-10-02`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Gästetexte des Terminplaners: Fassung <V>, Erinnerungs- und Abo-Ende-Satz, Archiv je Fassung“
### Task F10b2: Gästetexte final 2 – Entwurf-Kennzeichnung entfernt
**Files:** Modify `server/services/termine/rechtstexte.js` (Kopf, `RECHTSTEXTE_ENTWURF = false` (D1)), `server/services/termine/einbauanleitung.js` (Checklisten-Text zum Datenschutz-Absatz, D3b), `docs/rechtliches/termine-hinweise.md`, `-datenschutz-absatz.md`, `-widerrufsbelehrung-muster.md`, Rename `termine-datenschutz-6c-entwurf.md` → `termine-datenschutz-6c.md` (hier nur Kopf entfernt, Inhalt in F12a), `docs/funktionen/termine-einbau.md` (`:127`); Tests `test/termineRechtstexte.test.js` (`:49`, Liste `DOKUMENTE :219` mit neuem 6c-Namen, `:226`, Pfad im Wächter `:267`, **D1-Test (f)**: `RECHTSTEXTE_ENTWURF === false`), `test/termineEinbauanleitung.test.js` (D3b: die Banner-Tests mit Standardwert drehen um, Checklisten-Text), `test/termineEmbedStatic.test.js:219`.
**Consumes:** F10a, F10b1 (Version, Sätze und Archiv stehen), D `RECHTSTEXTE_ENTWURF` und `einbauanleitung` (der Banner hängt nach D1/D3b an `RECHTSTEXTE_ENTWURF`; er fällt hier weg).
**Produces:** `RECHTSTEXTE_ENTWURF = false`. Änderungen: (a) ENTWURF-Köpfe aus allen vier Dokumenten und die „Für die Rechtsprüfung offen“-Listen aus den drei Gäste-Dokumenten entfernt (die offenen Punkte stehen als Fragen im Kanzlei-Paket); (d) `termine-einbau.md` Abschnitt 6 ohne die Pilot-Einschränkung; (e) der Checklisten-Text der Einbauanleitung nennt die Endregel aus F4 Nr. 4 (WEBklar fügt den Datenschutz-Absatz ein, wenn die Website von WEBklar betreut wird, sonst Textbaustein per Chat; Teil der Einrichtung, nicht auf das Stundenkontingent angerechnet) statt der Zwischenregel aus D3b.
- [ ] Tests: `kein_entwurf_mehr` (kein `ENTWURF` im Sinne von `/ENTWURF(?![_A-Z])/` in den vier Dokumenten (Köpfe), in den Texten von `rechtstexte.js` und in `docs/funktionen/termine-einbau.md`; der Bezeichner `RECHTSTEXTE_ENTWURF` ist erlaubt und steht auf `false`; das alte Kopf-Gebot `termineRechtstexte.test.js:226` wird zum Verbot); `einbau_ohne_banner` (nach D: die Anleitung im Standardaufruf enthält kein ENTWURF-Banner; D3b-Banner-Tests mit explizitem `entwurf: true` bleiben); `checkliste_datenschutz_endregel` (Text der Checkliste = Endregel, die Zwischenregel „liegt die Datenschutzerklärung nicht in unserem Repo“ ist weg); `termineEmbedStatic.test.js:219` prüft statt `'ENTWURF'` das Wort `'Fassung'`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Gästetexte des Terminplaners: Entwurf-Kennzeichnung entfernt“
### Task F11: Portal-Hinweistexte und Storno nur bei Preis
**Files:** Modify `public/termine-tab-einstellungen.js` (`WIDERRUF_HINWEIS`, `FREIZEIT_HINWEIS` `:16-23` exportieren; Storno-Abschnitt ~`:222`), `server/services/termine/ablauf.js` (`:231,:282`), `server/services/termine/vorlagen.js` (`ausVorlage` ~`:209`), `public/embed/termine.js` (`:1215-1219,:1259,:1279`); Tests `test/termineAblaufGast.test.js`, `test/termineEinstellungen.test.js`, `test/termineView.test.js`, `test/termineEmbedStatic.test.js`, (`test/ui/termine-widget.ui.mjs` von Hand).
**Produces:** `FREIZEIT_HINWEIS` (Du-Form): „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.“ (nimmt Massage und Weiterbildung aus der Liste „keine Freizeitleistung“, die die Recherche nicht trägt, ⚖ Q15). `WIDERRUF_HINWEIS` bleibt, erhält nach dem dritten Satz: „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).“ Storno: `stornoNoetig` nur bei `hatPreis(leistung)`; bei kostenlosen Leistungen kein Pflichthaken, **kein** `nachweis.storno`, im Widget weder Stornobox noch Haken noch Prüfung (nach `l.preis` der gewählten Leistung); `ausVorlage` setzt `storno.text = ''`, wenn keine Leistung der Vorlage einen Preis hat; Abschnitt „Stornobedingungen“ ergänzt: „Die Stornobedingungen gelten nur für Leistungen mit Preis; bei kostenlosen Leistungen fragt der Terminplaner Gäste nicht danach.“ (⚖ Q16). **Muster-Kennzeichnung (Spec 7.3):** der vorgeschlagene Stornotext (Vorgabe 50 % / 24 h) wird im Editor als Muster gekennzeichnet, mit einem Hinweis neben dem Feld: „Das ist ein Muster, kein Rechtstext. Bitte prüfe es und passe es an deine Bedingungen an.“ (Du-Form, nur im Portal, nicht im Gästetext).
- [ ] Tests: `storno_haken_nur_bei_preis` (`termineAblaufGast`: Leistung mit Preis ohne Haken ⇒ 400 wie bisher; `S_GRATIS` ohne Haken ⇒ Buchung gehalten, `nachweis.storno` fehlt; mit `storno: true` bei Gratis ⇒ ebenfalls ohne `nachweis.storno`; Handeintrag unverändert); `vorlage_ohne_preis_ohne_storno` (Vorlage mit nur kostenlosen Leistungen ⇒ `storno.text === ''`, Vorlagen mit Preisen unverändert); `widget_storno_je_leistung` (`termineEmbedStatic`: Box, Haken und Prüfung hängen an `l.preis`); `freizeit_hinweis_text` (nennt weder „Massage“ noch „Weiterbildung“ als Beispiel für „keine Freizeitleistung“, enthält „keine Rechtsberatung“, Du-Form); `widerruf_hinweis_wertersatz`; `storno_abschnitt_text`; `storno_muster_gekennzeichnet` (Hinweis steht neben dem Stornofeld, wörtlich, Du-Form; er erscheint nicht in den Gästetexten und nicht im Hash des Nachweises); Playwright-Lauf des Widgets bleibt außerhalb von `npm test` (einmal von Hand, `nice`, nach `free -m`).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Hinweistexte nach Recherche, Storno nur bei Preis“
### Task F12a: Datenschutzerklärung (6c, §6, §7) und PDF
**Files:** Modify `public/datenschutz.html` (`Stand`; §6 `:82`; nach 6b `:93-95` neu `<h2 id="termine">`; §7 `:99`), `docs/WEBklar-Datenschutzerklaerung.pdf` (neu per `build-legal-pdfs.sh datenschutz`); `docs/rechtliches/termine-datenschutz-6c.md` (aus F10b2 umbenannt; Markdown-Quelle des Abschnitts, ohne „Einbau in TP2“, ohne „nicht eingebaut“, ohne „Für die Rechtsprüfung offen“); Tests `test/legalTexts.test.js` (`:494-` Kundenansicht-Pin, Stand-Pin), `test/termineRechtstexte.test.js` (`:267` Wächter entfernen).
**Consumes:** B (`config.collections.featureConsents`), A/C (Entwurfs-Löschung nach 90 Tagen), E (Abo-Ende, Rückkehr, K22), F10b2 (Umbenennung).
**Produces:** **6c final** (`<h2 id="termine">6c. Online-Terminbuchung auf Websites unserer Kunden</h2>` zwischen 6b und §7, kein Kommentar im HTML): Rolle (Auftragsverarbeiter für Gäste; Verantwortliche für Betriebsdaten, Fragebogen-Entwurf und Bestellung); Daten wie Entwurf; Einstellungen und Entwurf: nicht bestellte Entwürfe werden 90 Tage nach der letzten Änderung gelöscht (auch bei Kundenlöschung); E-Mail-Versand über Hetzner (Rechenzentrum Nürnberg, Abschnitt 8), Server in Falkenstein; „mit der Buchung speichern wir die IP-Adresse nicht“ (belegt durch `avv_keine_ip_in_buchung`); Aufruf: Skript und Texte beim Laden der Seite, Termine im Modus `inline` beim Durchklicken, im Modus `knopf` erst nach Klick (`termine-einbau.md` §6); Fristen wie AVV Nr. 9; **Ende des Abos:** „Endet das Wartungs-Abo oder schalten wir die Funktion ab, schalten wir den Terminplaner spätestens am folgenden Tag ab und löschen alle Termindaten und Einstellungen 30 Tage danach; beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schalten wir ihn mit den vorhandenen Daten wieder ein“; Sicherungen halten gelöschte Daten bis zu 7 Tage. **§6:** „… und dabei für Sie News-Beiträge anlegen, ändern oder löschen und, wenn Sie den Terminplaner nutzen, dessen Einstellungen und Termine ansehen und ändern.“ (Sperre für Bestellungen, Zahlungen, Fragebogen-Bestätigung bleibt). **§7:** neuer Absatz: „Bestellen Sie eine Funktion (News-Pinnwand, Terminplaner), speichern wir als Nachweis die Texte und Fassungen, denen Sie zugestimmt haben, den Zeitpunkt Ihres Klicks auf „Zahlungspflichtig bestellen“, die Stripe-Sitzung, IP-Adresse und Browserkennung sowie Ihr Konto und die angemeldete Person. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO; unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und der Zustimmungen, insbesondere für den Widerruf. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet. Den Entwurf Ihres Fragebogens speichern wir bis zu 90 Tage nach Ihrer letzten Änderung und löschen ihn danach, wenn Sie nichts bestellt haben.“ (⚖ Q23; Spec 3.7: 6c und §7 nennen die Entwurfsdaten und die Frist). `Stand` = Änderungstag.
- [ ] Tests: `legal_datenschutz_termine` (Reihenfolge `id="analyse"` < `id="termine"` < `<h2>7. `; Pflichtsätze: Auftragsverarbeiter, `Falkenstein`, `Nürnberg`, `48 Stunden`, `30 Tage`, `90 Tage`, `sieben Tage`, „nicht dauerhaft“; kein `<!--` im Block; Nummerierung 7–15 unverändert); die bestehenden Wächter bleiben grün, weil 6c ihre Verbote meidet: `legal_datenschutz_news` schneidet von 6a bis §7 (also auch 6c; verboten sind dort „Abschnitt 6 und 7“, „spätestens nach 24 Stunden“, „Endet die Zusammenarbeit, schalten wir“), `Datenschutz: Abos, produktneutral` verbietet im ganzen Dokument u. a. „Unterauftragsverarbeiter“ und „im Rahmen des Wartungs-Abos“; `datenschutz_6c_gleich_markdown` (HTML-Block = Markdown-Quelle); `legal_datenschutz_kundenansicht` angepasst (neuer Satz wörtlich, `Stand` per Regex `/Stand \d{2}\.\d{2}\.2026/` und ≥ 01.10.2026 und ≥ Datum aus `termsVersion`); `legal_datenschutz_funktionsbestellung` (§7-Absatz wörtlich, **einschließlich des Satzes zu den Entwurfsdaten und den 90 Tagen**); `wiederkehr_satz_gleich_lb` (Satz zur Rückkehr in 6c/LB/AVV/K22 enthält jeweils „30 Tage“ und „wieder ein“, K22 per Glob `server/mail-templates/K22 *.html`); der 6c-Wächter `termineRechtstexte.test.js:267` entfällt; `build-legal-pdfs.sh datenschutz` erzeugt das PDF (> 20 KB, `%PDF`).
- [ ] FAIL → implementieren, PDF bauen → PASS → `npm test` → Commit „Datenschutz: Abschnitt 6c Terminplaner, Kundenansicht und Nachweise bei Funktionsbestellung“
### Task F12b: Aufbewahrung der Zustimmungsnachweise (Retention)
**Files:** Modify `server/services/retention.js` (`findDueDeletions :74` samt Bedingung `if (!posts.length && !features.length) continue`, `applyDeletions :172`, `formatReport :244`); Test `test/retention.test.js`.
**Consumes:** B3 (`config.collections.featureConsents`), F12a (der §7-Satz „drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet“).
**Produces:** `findDueDeletions` plant die `featureConsents` fälliger Kunden mit (`plan.news[].consentIds`), `applyDeletions` löscht sie (`deleted.featureConsents`), `formatReport` nennt sie (damit der §7-Satz stimmt; Kunden ohne Abo, z. B. nur News, bleiben wie bisher außerhalb der Retention, siehe offene Fragen).
- [ ] Tests: `retention_loescht_featureConsents` (fälliger Kunde ⇒ Einträge geplant und gelöscht, nicht fälliger Kunde unberührt, Bericht nennt Anzahl; ein Kunde ohne Abo, z. B. nur News, bleibt außerhalb der Retention wie bisher).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Retention: Zustimmungsnachweise der Funktionsbestellung drei Jahre nach Abo-Ende loeschen“
### Task F13a: Staff-Aktivierung nur mit Textform-Nachweis (Modus `on`) – Portal
**Files:** Modify `server/services/termine/schalter.js`, `server/routes/admin/funktionenAdmin.js` (POST + GET), `server/services/siteFeatures.js` (`adminActivate`), Tests `test/termineSchalter.test.js`, `test/funktionenTermine.test.js`.
**Consumes:** B4 `speichereZustimmung` (erlaubt leeres `stripeSessionId`/`ip`/`userAgent` und Texte ohne `haken`), D6 `meldeFreigeschaltet(doc, { attachments })`; `validateTextform(textform, { minDate })` aus `analyse.js`; F1 `legalPaths`, `featureLegalAttachments`.
**Produces:** `termineTextformNoetig(email) ⇒ boolean` (`termineModus() === 'on'` **und** die Adresse steht nicht in `config.termine.pilotEmails`; `'*'` zählt nicht als Pilotadresse). `POST /api/team/funktionen/:customerId/termine` mit `action: 'activate'` verlangt dann `textformDate` (JJJJ-MM-TT, nicht vor `config.termine.avvVersion`, nicht in der Zukunft) und `textformChannel` (`chat` | `email`), sonst 400. Bei Erfolg: `speichereZustimmung({ customerId, projectId, featureKey: 'termine', stripeSessionId: '', fassungen: { leistungsbeschreibung: v, avv: v }, texte: [{ id: 'textform', text: 'Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung <v>) am <TT.MM.JJJJ> per <Chat im Kundenportal | E-Mail> in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.' }], ip: '', userAgent: '', konto: 'Mitarbeiter: <Name>', at })`, danach K16 über `meldeFreigeschaltet(doc, { attachments })` mit den beiden PDFs (LB, AVV; `featureLegalAttachments('termine', { ohne: ['widerruf'] })`), damit der Kunde die Unterlagen auf einem dauerhaften Datenträger erhält (⚖ Q21). `GET /api/team/funktionen/:customerId` ⇒ zusätzlich `termine: { textformNoetig, avvVersion, links }`. Hinweis: Dokumente, die so ohne Bestellung freigeschaltet werden (`unlockedByPurchase` bleibt falsch), nimmt der Abo-Abgleich aus und zählt sie im Tagesbericht als `ausgenommen` (Kanzleifrage Q7).
- [ ] Tests (Portal): `textform_pflicht_modus_on` (Kunde fremd ⇒ ohne Angaben 400, mit gültigen 200, `active`, `featureConsents` mit Datum, Weg, Fassung, Mitarbeitername, leerem `stripeSessionId`, K16 mit zwei `%PDF`-Anhängen in `mailSink`); `textform_entfaellt` (Modus `off`/`pilot`, Modus `on` mit Pilotadresse, News, Abschalten); `textform_datum` (vor Fassung, Zukunft, ungültiger Weg ⇒ 400, nichts angelegt); `get_admin_termine_feld`; `termineTextformNoetig` je Modus; `chat_vorlage_versioniert` (nur Adressen mit `-${v}.html`); `zustimmungFuerSitzung('')` findet den Staff-Nachweis nicht (B4).
- [ ] FAIL → implementieren → PASS → `npm test` (Portal) → Commit „Terminplaner: Staff-Aktivierung nur mit Zustimmung in Textform (Modus on), Portal“
### Task F13b: Staff-Aktivierung nur mit Textform-Nachweis (Modus `on`) – WOMS
**Files (WOMS, Worktree `dev-termine`):** Modify `src/lib/funktionenView.js`, `src/components/customer/CustomerFunktionen.jsx`, `src/lib/teamApi.js`; Tests `tests/unit/funktionenView.test.mjs`.
**Consumes:** F13a (Feld `termine.textformNoetig`, Body `textformDate`/`textformChannel`), D9/D10 (Bedienung „Aktivieren (ohne Zahlung)“ nur in `off`/`disabled`, Panel in `CustomerFunktionen.jsx`).
**Produces:** im Bestätigungsdialog „Terminplaner ohne Zahlung aktivieren?“ erscheinen bei `textformNoetig` Haken (Text wie in F13a), Datum und Weg (Wiederverwendung von `CHANNELS`/`canSubmitTextform` aus `analyseView.js`), `termineChatTemplate(version, links)` (Kopierknopf): „Damit wir den Terminplaner für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten die Leistungsbeschreibung Terminplaner und der Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${version}: ${lb} · ${avv}. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.“ (versionierte Adressen); News und „Abschalten“ unverändert.
- [ ] Tests (WOMS): `termineChatTemplate` wörtlich, `canSubmitTextform` mit `minDate` = Fassung, Dialogtext, Body `{ …, textformDate, textformChannel }`; zusätzlich einmal `nice npx vite build`.
- [ ] FAIL → implementieren → PASS → `npm test` (WOMS) und `nice npx vite build` → Commit „Terminplaner: Staff-Aktivierung nur mit Zustimmung in Textform (Modus on), WOMS“
### Task F14: WOMS „Rechtliches“: Karten der Terminplaner-Unterlagen
**Files:** Modify `scripts/setup-legal.mjs` (`DOCUMENTS :89-96`) im WOMS-Worktree; Test `tests/setup-legal.test.mjs`.
**Produces:** drei neue Karten (sort 7–9): `leistungsbeschreibung-terminplaner` („Leistungsbeschreibung Terminplaner“, `liveUrl` `https://project.webklar.com/rechtliches/leistungsbeschreibung-terminplaner.html`), `avv-terminplaner` („AVV Terminplaner (Auftragsverarbeitung)“, `…/avv-terminplaner.html`), `widerrufsbelehrung-funktionen` („Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)“, `…/widerrufsbelehrung-funktionen.html`); die Adressen ohne Fassung sind die Alias-Seiten aus F1. Das Skript legt nur die Karten an (idempotent); **Ausführung mit API-Key und Upload der Versionen (PDF/HTML der Fassung) macht der Inhaber** (Livegang-Schritt 7; `md` ist im Bucket nicht erlaubt, das Datenschutz-PDF geht als neue Version der vorhandenen Karte `datenschutz`). `--dry-run` listet sie. Die Analyse-Karten werden hier nicht ergänzt.
- [ ] Tests: `documents_keys_eindeutig_und_sort_lueckenlos` (Schlüssel `^[a-z0-9-]{1,64}$`, `sort` 1..n), `terminplaner_karten_vorhanden` (drei Schlüssel, Titel, `liveUrl` beginnt mit `https://project.webklar.com/rechtliches/` und endet ohne Fassungsdatum), `bestehende_karten_unveraendert` (die sechs alten Einträge deepEqual), `dry_run_listet_karten` (Skript mit `--dry-run`, keine Netzaufrufe).
- [ ] FAIL → implementieren → PASS → `npm test` (WOMS) → Commit „Rechtliches: Karten für Leistungsbeschreibung, AVV und Widerrufsbelehrung des Terminplaners“
### Task F15a: Kanzlei-Paket 1 – Aufbau, Texte, Ablauf, Fundstellen (Abschnitte 1–3 und 5–7)
**Files:** Create `docs/rechtliches/termine-kanzlei-paket.md`, `test/termineKanzleiPaket.test.js`.
**Consumes:** alle Texte aus F2–F14 (Zitate), Register, Code-Fundstellen.
**Aufbau** (Überschriften verbindlich): `## 1. Zweck und Umfang` (Kernpaket nach D5: Widerrufsbelehrung + Muster-Formular der 149-€-Bestellung, Zustimmungssatz, AVV, Haftungs- und Ende-mit-Abo-Klauseln der LB, Gästetexte mit Gesetzlichkeitsfiktion; Hausrunde: 6c, Portal-Hinweise, Mails; keine Rechtsberatung durch WEBklar; Fassungen `<F>`/`<V>`) · `## 2. Produktablauf in Kurzform` (Abo-Prüfung → Fragebogen-Entwurf → Bestellung mit vier Haken → Nachweis → Stripe → Ticket/Einbau → Freischaltung → Betrieb; Gast: Haltezeit 30 Min., Bestätigungslink, Widerrufsfunktion; Ende des Abos: Abschalten spätestens am Folgetag, 30 Tage, Rückkehr, Erstattung vor Freischaltung; Zahlen 149 €, 14 Tage, 48 Stunden, 90 Tage, 7 Tage) · `## 3. Texte in Endfassung` (wörtlich als Zitatblöcke `> `: vier Bestell-Haken + Knopf, Widerrufsbelehrung + Formular der Funktionen, LB Nr. 5, 7, 8, 11, AVV Nr. 7 und 9, Gäste-Belehrung, Gäste-Haken, Datenschutz-Hinweis am Formular, Knopftexte; AVV und LB in voller Länge als PDF-Anlagen A/B; hinter jedem Zitat `⚖ Q<n>`) · `## 4. Fragen an die Kanzlei` (`### Q1` … `### Q26`, je: Frage, Fundstelle im Text, unsere Annahme, Alternative) · `## 5. Fundstellen im Code` (Tabelle Thema | Datei | Funktion/Konstante, ohne Zeilennummern) · `## 6. Nicht Teil der Kanzleiprüfung` (Hausrunde; Liste 7.4 „Später“) · `## 7. Checkliste für den Inhaber` (Hetzner-AV für Cloud **und** Webhosting, Anlage 1 mit „Terminbucher/Beschäftigte/Buchungsdaten“, Kontoadresse = Impressum, Backups an; Uploads in WOMS; Beauftragung der Kanzlei; Freigabe der Fassung vor dem ersten fremden Betrieb).
**Umfang dieses Tasks:** alle sieben Überschriften und die Abschnitte 1, 2, 3, 5, 6, 7 mit Inhalt; die Zitate in Abschnitt 3 tragen schon ihre `⚖ Q<n>`-Marker (die Nummern sind in F15b festgelegt). Abschnitt 4 besteht hier nur aus der Überschrift und der Zeile „Fragen Q1 bis Q26: Task F15b.“; F15b ersetzt sie.
- [ ] Tests (`termineKanzleiPaket.test.js`): `paket_aufbau` (sieben Überschriften exakt, Fassungen `config.termine.avvVersion` und `RECHTSTEXTE_VERSION` genannt); `paket_zitate_woertlich` (jeder `> `-Absatz steht nach Whitespace-Normalisierung in einer Quelle: Register-Haken, `rechtstexte.js`-Ausgaben, die drei Markdown-Dokumente, `config.ustHinweis`); `paket_fundstellen` (jede Datei der Tabelle existiert, der genannte Bezeichner steht darin); `paket_zahlen_gleich_code` (149 €, 14 Tage, 30 Minuten, 48 Stunden, 90 Tage, 7 Tage, 30 Tage aus Register bzw. den Konstanten); `paket_konsistenz_abo_ende` (LB Nr. 8, AVV Nr. 9, 6c, K22 nennen alle „30 Tage“ und die Rückkehr).
- [ ] FAIL → Paket schreiben → PASS → `npm test` → Commit „Kanzlei-Paket Terminplaner: Aufbau, Texte, Ablauf, Fundstellen“
### Task F15b: Kanzlei-Paket 2 – Fragen Q1 bis Q26 (Abschnitt 4)
**Files:** Modify `docs/rechtliches/termine-kanzlei-paket.md` (Abschnitt `## 4. Fragen an die Kanzlei`: `### Q1` … `### Q26`, je Frage, Fundstelle im Text, unsere Annahme, Alternative), `test/termineKanzleiPaket.test.js`.
**Consumes:** F15a (Aufbau und `⚖ Q<n>`-Marker in den Zitaten), alle Texte aus F2–F14.
**Fragen (feste Nummern):** Q1 Alle Besteller wie Verbraucher (D1): Haken und Belehrung für alle, Hinweis „gilt nur für Verbraucher“ ausreichend? · Q2 Fristbeginn: Klick auf „Zahlungspflichtig bestellen“ (unser Anker), Zahlungseingang oder K15? · Q3 Zustimmungssatz (Zählung § 356 Abs. 5 Nr. 2, Kenntnis-Bestätigung, Dauerhafter Datenträger durch K15) · Q4 **D7 News-Altkunden:** Bestellungen seit 2026-09-30 mit altem Text (Abs. 4, ohne Wertersatz-Hälfte, ohne Musterbelehrung): lief die Frist nach § 356 Abs. 3 BGB nicht an; Belehrung nachholen per Mail oder nur künftig; Anzahl der Fälle liefert der Inhaber · Q5 Wertersatz: gesetzlicher Anteil am Gesamtpreis (Anlage 1) vs. D3 „nach Stundenaufwand“ · Q6 volle Erstattung bei Ende des Abos vor Freischaltung (Klausel LB Nr. 8 Abs. 5, § 307 BGB) · Q7 Ende mit dem Abo, auch Wechsel und Widerruf; Verhältnis Widerruf des Abos zum Einrichtungsvertrag; Ausnahme: ohne Bestellung freigeschaltete Dokumente (Pilot/Kulanz, F13a) nimmt der Abgleich aus (`ausgenommen` im Tagesbericht): genügt „schaltet spätestens am folgenden Tag ab“ trotzdem, oder braucht es eine Kulanz-Klausel? · Q8 Haftung für verpasste Termine (LB Nr. 7, § 309 Nr. 7, Höchstbetrag gegenüber Unternehmern?), Erstattung bei Einstellung durch WEBklar · Q9 30 Tage: Anker `disabledAt`, Rückkehr gratis, Backup-Überhang 7 Tage; Alternative Modell der Analyse (Tag 61, Export bis Tag 46) · Q10 AVV: eigenes Geschwister statt Rahmen-AVV, Hetzner Falkenstein und Nürnberg, „keine Drittlandübermittlung“ trotz Kalender-Abo bei Google/Apple (Abonnement durch den Kunden) · Q11 Gesundheits-Ausschluss: Branchenfrage + Haken + Stichwort-Warnung + Ablehnungsrecht ausreichend (Art. 9)? · Q12 Gästebelehrung: Abweichungen vom Muster (Lieferkosten-Klammer, Widerrufsfunktion als „anderer geeigneter Hinweis“ ohne Adresse, „beginnen soll“ im Singular) und Gesetzlichkeitsfiktion · Q13 Gästetext vorzeitiger Beginn und Wertersatz-Satz · Q14 Anfrage mit Festpreis, Bestätigungslink und Seitenknopf „Zahlungspflichtig bestätigen“ als Bestellung (§ 312j Abs. 3) · Q15 Einstufung Freizeit/Massage/Fotograf/Coaching und der Portal-Hinweis · Q16 Storno-Muster 50 %/24 h (§ 307) und Wegfall bei kostenlosen Leistungen · Q17 Pflicht von Datenschutz-/Impressum-Link und „jederzeit widerrufen“-Satz am Erinnerungshaken · Q18 Datenschutz-Absatz auf der Kundenseite: Einfügen durch WEBklar im Preis, eine Regel für beide LB · Q19 Preisangabe „ohne Umsatzsteuer gemäß § 19 UStG“ (bei Fortbestehen des Kleinunternehmerstatus), „· 149 €“ am Knopf · Q20 Pflichtinformationen (Art. 246a § 1 EGBGB) als Haken und Wortlaut in K15 · Q21 Staff-Aktivierung mit Zustimmung in Textform und K16 mit PDFs · Q22 BFSG-Satz (keine Konformitätszusage) · Q23 Nachweis der Bestellung mit IP/Browser, Frist drei Jahre (Art. 6 Abs. 1 lit. f) · Q24 Rollenverteilung in 6c (Auftragsverarbeiter/Verantwortliche), Entwurfsaufbewahrung 90 Tage · Q25 Mindestlaufzeit des Wartungs-Abos und Terminplaner (kein Sonderkündigungsrecht) · Q26 Nachweis der Gäste: Hash + Archiv der Textfassung genügt (Betriebsdaten ändern sich später)?
- [ ] Tests (`termineKanzleiPaket.test.js`): `paket_marker_und_fragen` (jedes `⚖ Q<n>` hat `### Q<n>`, jede Frage ist mindestens einmal verwiesen, Nummern lückenlos 1–26, Q4 nennt „News“, „§ 356 Abs. 3“ und „Altkunden“).
- [ ] FAIL → Fragen schreiben → PASS → `npm test` → Commit „Kanzlei-Paket Terminplaner: Fragen Q1 bis Q26“
**Abschlussprüfung Block F (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. WOMS-Anteil (F13b, F14): `npm test` im WOMS-Worktree und einmal `nice npx vite build`. PDFs: `build-legal-pdfs.sh termine` und `datenschutz` laufen auf dem Host (vorher `free -m`, mit `nice`).
## Block G: Livegang-Checkliste (Controller, je Schritt Freigabe des Inhabers)
Kein Subagent-Block. Der Controller arbeitet die Schritte in dieser Reihenfolge ab (Spec Abschnitt 9) und holt vor jedem Schritt mit Schreibzugriff auf Produktion oder Stripe die Freigabe des Inhabers ein. Claude pusht nicht auf `main`; das macht der Inhaber. **Betriebsannahme:** genau ein Portal-Prozess (Sperren, Grenzen und das Mailbudget liegen im Speicher; jeder zweite Prozess bricht das; festhalten, nicht ändern). Die Schritte G1–G10 entsprechen 9.1–9.10 der Spec.
- [ ] **G1 Rechtsrunde (9.1):** Texte aus F2–F12 und das Kanzlei-Paket (F15a, F15b) fertigstellen; der Inhaber beauftragt die Kanzlei; Hausrunde für den Rest (Portal-Hinweise, Mails, 6c).
- [ ] **G2 Code in den Worktrees (9.2):** Blöcke A–F fertig, jede Abschlussprüfung grün (einschließlich Node-20-Lauf und Grep-Wächter), WOMS-Build ohne Fehler; die Test-Pins aus den Berichten sind mitgeändert (`funktionenRoutes`, `termineRoutes`, `termineSetup`, `termineView`, `kundenansichtGuard`, `legalTexts`, `mailTemplates`, `termineJobs`, dazu `featureRegistry`, `featureConsents`, `featureMails`, `funktionenCheckout`).
- [ ] **G3 Schema in der Produktions-Appwrite vor dem Code-Deploy (9.3, Freigabe):** `node scripts/termine-setup.mjs --check`, dann anwenden (`termineConfig.fragebogen`, Indizes `termineBookings(status,endsAt)` und `(status,updatedAt)`, `termineOutbox(bookingId)`); `node scripts/feature-setup.mjs --check`, dann anwenden (`portalFeatures.disabledReason`, `aboEndeMailAt`, `zahlungMeldung`, neue Collection `featureConsents` samt Indizes). `scripts/setup-abo-collections.mjs` wird **nicht** ausgeführt. Der Schritt liegt vor dem Deploy, weil `activate()` die neuen Felder schreibt (ohne Schema `Unknown attribute`); der Indexaufbau ist asynchron, die Abfragen funktionieren auch ohne.
- [ ] **G4 Stripe (9.4, Freigabe):** `node scripts/stripe-products.mjs --pruefen`, dann `--anlegen` für `termineSetup` (Lookup-Key `webklar_termine_einrichtung_14900`); der Inhaber prüft im Dashboard, dass der Live-Webhook `async_payment_succeeded`, `async_payment_failed` und `expired` abonniert hat (Annahme: ja, News läuft seit 2026-09-30).
- [ ] **G5 Messprüfungen (9.5, Spec 8 #7, Freigabe):** (1) tatsächliche Größe von `workorders.details` (trägt `TICKET_DETAILS_MAX = 7000`?); (2) Seiten-Listing über 500 Dokumente auf der Live-Appwrite 1.8.1 (die Seitenläufe aus A3a–A6 lesen mit `limit ≤ 100` ohne Cursor; im Nachbau bisher nur Spike); (3) `Query.notEqual` mit Liste und `total`/`countDocuments` (A2, A9a, A10b, E4) auf 1.8.1 mit derselben Bedeutung wie im Nachbau, `limit(1)`-Antwort mit `total` bis 5000; (4) tatsächliche Hetzner-Antwort bei mehr als 500 Mails je Stunde; (5) Hetzner-AV-Prüfung durch den Inhaber (Cloud **und** Webhosting, Anlage 1 mit „Terminbucher/Beschäftigte/Buchungsdaten“, Kontoadresse = Impressum, Backups an). Ergebnisse ins Ledger; weicht ein Wert ab, geht der betroffene Task an seinen Block zurück.
- [ ] **G6 Deploy Portal + WOMS (9.6, der Inhaber pusht):** Modus bleibt `pilot` mit leerem `TERMINE_PILOT_EMAILS` plus Pilotkunde „test“. Der Abo-Abgleich läuft zunächst nur beobachtend: das ist durch den Datenstand gegeben, denn er schaltet nur Dokumente mit `unlockedByPurchase === true` ab, und davon gibt es vor der ersten bezahlten Bestellung keines (Pilotkunde und Kulanzfreigaben stehen im Tagesbericht als `ausgenommen`); ein eigener Beobachtungsschalter ist nicht gebaut. Tagesbericht und `GET /api/team/termine/status` beobachten.
- [ ] **G7 Rechtstexte live (9.7):** PDFs bauen (`build-legal-pdfs.sh termine` und `datenschutz`), HTML/PDF unter `/rechtliches`, 6c + PDF der Datenschutzerklärung, WOMS-Karten (`scripts/setup-legal.mjs` mit API-Key, Freigabe) und Uploads der Fassung durch den Inhaber, Gästetexte final (`RECHTSTEXTE_VERSION`).
- [ ] **G8 Ende-zu-Ende-Test (9.8)** mit einem Testkunden und echtem Wartungs-Abo: Fragebogen → Bestellung (echte 149 €, Rückerstattung durch den Inhaber danach; Annahme: es gibt kein Test-Konto für diesen Preis) → Ticket + Anleitung → „Freischalten (Abnahme)“ → Probebuchung → Abo-Ende-Simulation (Testkunde) → K22 → Rückkehr K23 → Löschlauf auf Wegwerfdaten. Dazu die Sichtprüfung des WOMS-Panels (390 px und Desktop, Kopieren, Warnungen, Aktionsknöpfe je Zustand) und die Playwright-Läufe (`termine-fragebogen.ui.mjs`, `termine-portal.ui.mjs`, `funktionen.ui.mjs`, `termine-widget.ui.mjs`), nacheinander, vorher `free -m`, mit `nice`.
- [ ] **G9 Erster fremder Kunde im Pilot (9.9)**, erst nach dem Kanzleibefund (Entscheidung D5 der Spec): nur seine Adresse in `TERMINE_PILOT_EMAILS`; Beobachtung von Tagesbericht, Mailbudget und Status-Route (Freigabe der Adresse).
- [ ] **G10 `TERMINE_ENABLED=on` (9.10)** als **letzter** Schritt (Compose-Änderung, nur `kundenbereich-server` neu erstellen); danach eine Woche Beobachtung, News-Altfälle (D7) und der Entwurfs-Löschjob werden geprüft.
---
@@ -0,0 +1,456 @@
# Analyse-Übersicht neu („Cockpit“) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Die Analyse-Seite im Kundenportal zeigt eine Übersicht im „Cockpit“-Stil: Klartext-Zusammenfassung, Kennzahlen mit Vorzeitraum-Vergleich und Mini-Verlauf, Kontakt-Aktionen, Datenbasis; Heatmap und Einstellungen in eigenen Reitern.
**Architecture:** Der Collector (`analytics/src/stats.js`) liefert zusätzlich Vorzeitraum, `firstDay`, Tageswerte und Durchschnitte; der Portal-Server reicht das durch. Im Browser: reine Funktionen (`analyse-view.js`), reine HTML-Bausteine (`analyse-cards.js`), DOM-Verdrahtung (`analyse.js`), Stil (`analyse.css`). Die Analyse-Oberfläche zieht aus `funktionen.js` aus.
**Tech Stack:** Node 24 + `node:sqlite` (Collector), Node 20 + Express (Portal), Vanilla-JS-ES-Module im Browser, `node --test`, Playwright (`playwright-core` aus `/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core`).
**Spec:** `docs/superpowers/specs/2026-10-04-analyse-uebersicht-design.md`
## Global Constraints
- Arbeitsort: Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-analyse-neu`, Branch `feature/analyse-uebersicht`. `node_modules` ist ein Symlink auf `../build/node_modules` und bleibt untracked: **nie `git add -A`**, immer Dateien einzeln adden.
- Keine neuen Abhängigkeiten. Keine Änderung an `analytics/client/*`, `public/rechtliches/*`, Banner, AVV, Leistungsbeschreibung, Datenschutztexten, WOMS, Schema, `.env`.
- Nur vorhandene Daten (Spec U4). Klartext nur durch feste Regeln im Browser (U5).
- Texte der Zustände Einschalten und Ausschalten bleiben **wörtlich** wie heute (bestehende Tests in `test/funktionenTiles.test.js` prüfen sie).
- Neue Texte: Deutsch, Du-Form klein („du“, „deine“), Zahlen `de-DE` („6.930“, „7,8“).
- Element-IDs bleiben: `#analyse-body`, `#an-stage`, `#an-preview`, `#an-avv`, `#an-on`, `#an-error`, `#an-color`, `#an-hint`, `#an-off`, `#an-off-*`, `#an-heat-page`.
- Alle Werte aus Daten (Seitenpfade, Hosts, Projektnamen) werden in HTML maskiert (`esc`).
- Akzent nur unter `.an`: `--an-acc: #2dd4bf`, `--an-acc-strong: #5eead4`, `--an-acc-soft: rgba(45,212,191,.1)`, weniger `#fda4af`, Karte `rgba(14,16,20,.92)`, Rand `rgba(255,255,255,.09)`, Radius 16 px, Abstand 14 px.
- Reiter-Schlüssel: `'uebersicht' | 'heatmap' | 'einstellungen'`. Zeiträume: 7, 30, 90.
- Portal-Tests laufen auf Node 20 (Produktion) und 24; Collector-Tests auf Node 24.
- Commit-Nachrichten enden mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Review Focus
1. Antwort eines **alten Collectors** ohne `previous`, `firstDay`, `from/to` und ohne `series[].visitors` (Deploy-Reihenfolge): Seite rendert ohne Chips und ohne Fehler, Mini-Verläufe nur, wo Werte da sind → Test in Task 4.
2. **Website ohne einen einzigen Aufruf** (alles 0, `consentRate` 0, `avgSec` null): keine `NaN`/`Infinity`/`undefined` im HTML, Leerzustand im Block „Das Wichtigste“, Ring 0 % → Test in Task 4.
3. **Seitenpfad oder Host mit HTML** (`/<img src=x onerror=alert(1)>`, Host `a"b.de`): überall maskiert (Hinweise, Ranglisten, Heatmap-Auswahl) → Tests in Task 3 und Task 4.
4. **Schnelles Umschalten** von Zeitraum/Website: eine langsame ältere Antwort überschreibt die neuere nicht → Playwright-Check mit verzögerter Antwort in Task 5.
5. **Sehr langer Pfad** auf dem Handy (402 px): keine waagrechte Scrollleiste, Text gekürzt → Playwright-Check in Task 5.
---
### Task 1: Collector liefert Vorzeitraum, firstDay, Tageswerte, Durchschnitte; Portal liefert activatedAt
**Files:**
- Modify: `analytics/src/stats.js` (gesamte Datei, 69 Zeilen)
- Modify: `analytics/test/stats.test.js` (Test „Stats-Endpunkt: Form …“ ab Zeile 118 anpassen, neue Tests anhängen)
- Modify: `test/analyticsContract.test.js:42-50`
- Modify: `server/routes/funktionen.js:51-75` (`analyseView`)
- Modify: `test/analyseRoutes.test.js:115` (+ neuer Test)
**Interfaces:**
- Produces: Antwort von `computeStats(db, site, range, now)` (und damit `GET /a/admin/stats/:key` und `GET /api/funktionen/analyse/stats`) mit den Feldern aus Spec Abschnitt 1:
`range`, `from`, `to`, `firstDay`, `totals { pv, visitors, contacts, consentRate, engaged, avgSec, avgScroll }`, `series [{ day, pv, visitors, contacts, avgSec }]`, `pages`, `refs`, `countries`, `devices`, `contacts`, `banner`, `previous { from, to, complete, totals {…gleich…}, series […gleich…], contacts [{key,count}] }`.
- Produces: `p.analyse.activatedAt` (ISO-String oder `null`) in `GET /api/funktionen`.
- [ ] **Step 1: Failing Tests im Collector schreiben** (`analytics/test/stats.test.js`, Helfer `mk`, `admin`, `KEY`, `NOW` = 10.10.2026 12:00 Berlin, `D`, `H` existieren)
Bestehenden Test „Stats-Endpunkt: Form …“ anpassen:
```js
assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'firstDay', 'from', 'pages', 'previous', 'range', 'refs', 'series', 'to', 'totals']);
assert.deepEqual(j.totals, { pv: 4, visitors: 2, contacts: 1, consentRate: 2 / 4, engaged: 1, avgSec: 5, avgScroll: 5 });
assert.deepEqual(j.series[5], { day: '2026-10-09', pv: 3, visitors: 1, contacts: 0, avgSec: 5 });
assert.deepEqual(j.series[6], { day: '2026-10-10', pv: 1, visitors: 1, contacts: 1, avgSec: null });
assert.equal(j.from, '2026-10-04'); assert.equal(j.to, '2026-10-10'); assert.equal(j.range, 7);
```
Neue Tests:
```js
test('Stats: Vorzeitraum = die range Kalendertage davor, complete nach firstDay', async () => {
// pv am 30.09. (im Vorzeitraum 27.09.–03.10.), en am 01.10., ct am 02.10., pv am 26.09. (vor dem Vorzeitraum)
// erst ohne 26.09.: firstDay '2026-09-30', complete false; dann mit 26.09.: firstDay '2026-09-26', complete true
assert.equal(j.previous.from, '2026-09-27'); assert.equal(j.previous.to, '2026-10-03');
assert.equal(j.previous.series.length, 7); assert.equal(j.previous.series[3].day, '2026-09-30');
assert.equal(j.previous.totals.pv, 1); // 26.09. zählt nicht mit
assert.deepEqual(j.previous.contacts, [{ key: 'tel', count: 1 }]);
assert.equal(j.totals.pv, 0); // aktueller Zeitraum leer
});
test('Stats: firstDay aus daily und Rohdaten, leer ohne Daten', async () => { /* '' ohne Ereignisse; nach runNightly weiterhin gleich */ });
test('Stats: avgSec/avgScroll gerundet, null ohne en; Tageswert avgSec', async () => {
// zwei en am selben Tag: sec 30/scroll 50 und sec 91/scroll 100 → avgSec 61 (60,5 gerundet), avgScroll 75, engaged 2
});
test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappung', async () => {
const NOV = Date.UTC(2026, 10, 10, 10); // range 30
// series[0].day '2026-10-12', series[29].day '2026-11-10', previous.series[29].day '2026-10-11', previous.series[0].day '2026-09-12'
// alle 60 Tage verschieden
});
test('Stats: Vorzeitraum aus daily und live, nie doppelt', async () => { /* Ereignis im Vorzeitraum vor und nach runNightly: previous.totals.pv bleibt 1 */ });
```
Die Ereignisse der Tests mit `ev({ ts, type, … })` einfügen; `runNightly(db, { now: NOW })` verdichtet Tage < heute.
- [ ] **Step 2: Tests laufen lassen, sie schlagen fehl**
Run: `cd analytics && node --test test/stats.test.js`
Expected: FAIL (fehlende Felder `previous`, `firstDay` …)
- [ ] **Step 3: `computeStats(db, site, range, now = Date.now())` in `analytics/src/stats.js` umbauen**
Aufteilen in eine Sammel-Funktion für eine beliebige Tagesliste und eine Formungs-Funktion:
- `dayList(endDay, range)` bildet Kalendertage per `Date.UTC(y, m-1, d-i)` (wie heute). Aktueller Zeitraum: `dayList(dayOf(now), range)`. Vorzeitraum: `dayList(<Tag vor days[0]>, range)` – kalendarisch, nie `now - range*DAY_MS`.
- `collect(db, site, days, now)`: `daily` für `day IN days` und noch nicht in `agg_state` stehende Tage live aus `events_raw` (Abfrage begrenzt auf Mitternacht UTC des ersten Tags − 2 Tage bis letzter Tag + 2 Tage und `ts <= now`, dann nach `dayOf(ts)` gefiltert; `aggregateRows` aus `jobs.js` wie heute). Ergebnis je Tag: `pv_total`, `visitors`, `ct_kind`-Summe, `en_count`, `en_sec_sum`, `scroll_sum` sowie die Top-Dimensionen über alle Tage.
- Durchschnitte: `avgSec = Math.round(en_sec_sum / en_count)`, `avgScroll = Math.round(scroll_sum / en_count)`, beide `null` bei `en_count = 0`; `engaged = en_count`.
- `firstDay`: Minimum aus `SELECT MIN(day) FROM daily WHERE site = ?` und `dayOf(SELECT MIN(ts) FROM events_raw WHERE site = ?)`, `''` wenn beides fehlt. `previous.complete = firstDay !== '' && firstDay <= previous.from`.
- `pages`, `refs`, `countries`, `devices`, `contacts`, `banner`, `consentRate` bleiben exakt wie bisher (nur für den aktuellen Zeitraum); `previous.contacts` = `top(ct_kind)` des Vorzeitraums.
- [ ] **Step 4: Collector-Tests grün**
Run: `cd analytics && npm test`
Expected: PASS (bisher 137 Tests + neue), 0 fail
- [ ] **Step 5: Vertragstest ergänzen** (`test/analyticsContract.test.js` nach Zeile 50)
```js
assert.equal(stats.range, 7)
assert.equal(stats.previous.series.length, 7)
assert.ok('firstDay' in stats && 'avgSec' in stats.totals)
assert.ok(stats.series.every((d) => 'visitors' in d && 'contacts' in d && 'avgSec' in d))
```
Run: `node --test test/analyticsContract.test.js` → PASS
- [ ] **Step 6: Failing Test für activatedAt** (`test/analyseRoutes.test.js`)
Zeile 115: erwartetes Objekt um `activatedAt: null` ergänzen. Im Test nach erfolgreichem `activate` (Zeile ~128–135): `assert.match(view.activatedAt, /^\d{4}-\d{2}-\d{2}T/)`.
Run: `node --test test/analyseRoutes.test.js` → FAIL
- [ ] **Step 7: `analyseView` in `server/routes/funktionen.js` gibt `activatedAt: doc?.state === 'active' ? doc.activatedAt || null : null` zurück**
Run: `node --test test/analyseRoutes.test.js` → PASS
- [ ] **Step 8: Gesamte Portal-Suite**
Run: `npm test`
Expected: PASS, 0 fail (Ausgangslage 2259 pass)
- [ ] **Step 9: Commit**
```bash
git add analytics/src/stats.js analytics/test/stats.test.js test/analyticsContract.test.js server/routes/funktionen.js test/analyseRoutes.test.js
git commit -m "Analyse: Vorzeitraum, firstDay, Tageswerte und Durchschnitte in der Statistik; activatedAt in der Ansicht"
```
---
### Task 2: Analyse-Oberfläche zieht aus funktionen.js aus (ohne sichtbare Änderung)
**Files:**
- Create: `public/fn-util.js`
- Create: `public/analyse.js`
- Modify: `public/funktionen.js` (Zeilen 6–7 Importe, 28–61 Helfer, 101–110 Analyse-Zustand, 611–980 Analyse-Abschnitt)
- Modify: `public/analyse-view.js` (Zustand und Auswahl-Helfer dazu)
- Modify: `public/app.js:1920`
- Modify: `test/funktionenTiles.test.js:8-16`, `test/analyseView.test.js:50-59`
**Interfaces:**
- Produces: `public/fn-util.js` exportiert `esc(value): string`, `safeUrl(url): string`, `request(path, { method, body, raw }): Promise<object>` (unverändert aus `funktionen.js` verschoben).
- Produces: `public/analyse-view.js` exportiert zusätzlich `analyseOf(project)`, `analyseState(a)`, `pickAnalyseProject(projects)` (verschoben) und `export const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' }` (gemeinsamer Zustand für Kachel und Analyse; ersetzt das bisherige `analyseSel` in `funktionen.js`).
- Produces: `public/analyse.js` exportiert `initAnalyse(root, { projectId })`, `renderAnalyseActivation(stage, project, reload)`, `renderAnalyseOff(box, projectId, reload)` (Verhalten unverändert).
- Importstil: `funktionen.js` importiert absolut (`'/fn-util.js'`, `'/analyse-view.js'`), `analyse.js` und `analyse-view.js` relativ (`'./fn-util.js'`, `'./analyse-view.js'`, `'./geraete.js'`) – so lassen sie sich in Node direkt importieren und sind im Browser dieselben Module.
- [ ] **Step 1: Tests auf die neue Aufteilung umstellen (schlagen fehl)**
`test/funktionenTiles.test.js`: zusätzlich `.replace("'/fn-util.js'", `'${fileUrl('fn-util.js')}'`)`; `initAnalyse`, `renderAnalyseOff` aus `await import(fileUrl('analyse.js'))` statt aus `funktionen.mjs`.
`test/analyseView.test.js` („verdrahtet“):
```js
assert.match(app, /analyse: \(\) => import\('\/analyse\.js'\)/)
const an = readFileSync(new URL('../public/analyse.js', import.meta.url), 'utf8')
assert.doesNotMatch(an, /\bconfirm\(/)
const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
assert.doesNotMatch(fn, /function renderAnalyseStats|function renderAnalyseHeat/)
```
Run: `node --test test/funktionenTiles.test.js test/analyseView.test.js` → FAIL
- [ ] **Step 2: Verschieben**
Abschnitt `/* --- Analyse --- */` (Zeile 611 bis Dateiende) und die Analyse-Importe aus `funktionen.js` nach `public/analyse.js`; `esc`/`safeUrl`/`request` nach `fn-util.js`; `analyseOf`/`analyseState`/`pickAnalyseProject` und `analyseSel` nach `analyse-view.js`. `funktionen.js` behält Kacheln und News und setzt beim Klick auf die Analyse-Kachel `analyseSel.projectId` (importiert). Vorher prüfen, wer die Funktionen sonst nutzt: `grep -a -rn "initAnalyse\|renderAnalyse\|analyseSel" public server test`.
- [ ] **Step 3: `app.js:1920` lädt `/analyse.js`**
`analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),`
- [ ] **Step 4: Tests grün, Suite grün**
Run: `npm test` → PASS, 0 fail
- [ ] **Step 5: Sichtprüfung, dass nichts anders aussieht**
Run: `node test/ui/analyse.ui.mjs /tmp/claude-0/-root/072dd97e-d807-40b8-b757-afe9463e87f8/scratchpad/ui-task2`
Expected: alle Zeilen `OK`, keine `FAIL`.
- [ ] **Step 6: Commit**
```bash
git add public/fn-util.js public/analyse.js public/funktionen.js public/analyse-view.js public/app.js test/funktionenTiles.test.js test/analyseView.test.js
git commit -m "Analyse: Oberfläche in eigenes Modul analyse.js, gemeinsame Helfer in fn-util.js"
```
---
### Task 3: Reine Funktionen für Vergleich und Klartext
**Files:**
- Modify: `public/analyse-view.js`
- Create: `test/analyseInsights.test.js`
**Interfaces:**
- Consumes: Statistik-Form aus Task 1; `esc` aus `./fn-util.js`.
- Produces (alle in `public/analyse-view.js`, ohne DOM):
- `fmtNum(n): string` – `de-DE`, ganzzahlig.
- `fmtDuration(sec): string` – `'1:48'`, `'0:07'`; `null`/ungültig → `'–'`.
- `dayLabel(day): string` – `'2026-09-19'` → `'Sa., 19.09.'`.
- `periodLabel(from, to): string` – `'5. Sep. – 4. Okt.'` (Monatskürzel Jan., Feb., März, Apr., Mai, Juni, Juli, Aug., Sep., Okt., Nov., Dez.); leere Eingabe → `''`.
- `compareFrom(stats): { ok: boolean, from: string }` – `ok` wenn `stats.previous?.complete && stats.previous.totals.pv >= 20`; `from` = `'TT.MM.'` von `firstDay + (2 × range − 1)` Tagen nur wenn `previous` vorhanden, `!complete` und `firstDay`; sonst `''`.
- `delta(cur, prev, { kind = 'count' } = {}): { dir: 'up'|'down'|'flat', text: string, aria: string } | null` – Regeln Spec 2.4; `null`, wenn `cur` oder `prev` keine Zahl ist.
- `contactRate(contacts, visitors): number | null` – Kontakt-Aktionen pro 100 Besucher, eine Nachkommastelle; `null` wenn `visitors < 30`.
- `refName(host): string` – Spec 2.1 (Google, Bing, Instagram, Facebook, DuckDuckGo, Ecosia, sonst Host; `''` → `'Direkt oder unbekannt'`).
- `weekdayStats(series, firstDay): { best: { weekday, name, avg } | null, avg: number, peak: { day, pv } | null } | null` – über Tage ≥ `firstDay` (alle, wenn `''`); `null` bei < 7 solchen Tagen; `best` nur wenn Schnitt ≥ 1,2 × Gesamtschnitt; `peak` nur wenn ≥ 1,8 × Gesamtschnitt.
- `summarize(stats): { lead: string, items: [{ icon: 'contact'|'day'|'source'|'device'|'page', html: string }] } | null` – Spec 2.3; `null` bei `totals.pv < 30`; `lead` und `html` sind fertiges, maskiertes HTML mit `<b>`.
- `chartModel(series, opts)` bekommt die Option `compare` (Array `{ pv }`): gemeinsame Skala über beide Reihen, zusätzliches Feld `prevLine` (Pfad, `''` ohne `compare`), `points[i].prev` (Zahl oder `null`).
- [ ] **Step 1: Failing Tests schreiben** (`test/analyseInsights.test.js`, Testdaten-Helfer `stats(over)` baut eine vollständige Antwort mit 30 Tagen)
```js
test('delta: Prozent ab 100, sonst absolut, flat, Sekunden, null', () => {
assert.deepEqual(delta(6930, 6080), { dir: 'up', text: '▲ 14 %', aria: 'plus 14 Prozent gegenüber dem Zeitraum davor' })
assert.equal(delta(188, 143).text, '▲ 31 %') // beide ≥ 100 → Prozent
assert.equal(delta(45, 33).text, '▲ 12')
assert.equal(delta(30, 33).text, '▼ 3')
assert.deepEqual(delta(7, 7), { dir: 'flat', text: '± 0', aria: 'unverändert gegenüber dem Zeitraum davor' })
assert.equal(delta(1004, 1000).text, '± 0 %') // |0,4 %| < 1
assert.equal(delta(108, 114, { kind: 'sec' }).text, '▼ 6 s')
assert.equal(delta(200, 135, { kind: 'sec' }).text, '▲ 1:05')
assert.equal(delta(null, 3), null)
})
test('fmtDuration, dayLabel, periodLabel, fmtNum', () => {
assert.equal(fmtDuration(108), '1:48'); assert.equal(fmtDuration(7), '0:07'); assert.equal(fmtDuration(null), '–')
assert.equal(dayLabel('2026-09-19'), 'Sa., 19.09.')
assert.equal(periodLabel('2026-09-05', '2026-10-04'), '5. Sep. – 4. Okt.')
assert.equal(fmtNum(6930), '6.930')
})
test('compareFrom: ok, Datum bei neuer Website, nichts ohne previous', () => {
assert.deepEqual(compareFrom(stats()), { ok: true, from: '' })
assert.deepEqual(compareFrom(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } })), { ok: false, from: '18.11.' }) // range 30: +59 Tage
assert.deepEqual(compareFrom({ totals: { pv: 50 }, series: [] }), { ok: false, from: '' })
assert.equal(compareFrom(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 19 } } })).ok, false)
})
test('contactRate und refName', () => {
assert.equal(contactRate(188, 2410), 7.8); assert.equal(contactRate(5, 29), null)
assert.equal(refName('www.google.de'), 'Google'); assert.equal(refName('l.facebook.com'), 'Facebook'); assert.equal(refName(''), 'Direkt oder unbekannt'); assert.equal(refName('blog.example'), 'blog.example')
})
test('weekdayStats: stärkster Wochentag, Spitzentag, < 7 Tage null', () => { /* Samstage 2× Schnitt → best.name 'Samstag'; ein Tag 3× Schnitt → peak */ })
test('summarize: Wachstum mit Prozent', () => {
const s = summarize(stats())
assert.equal(s.lead, 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> aufgerufen – <b>14 % mehr</b> als in den 30 Tagen davor.')
assert.equal(s.items[0].icon, 'contact')
assert.equal(s.items[0].html, '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 45 mehr als zuvor.')
assert.ok(s.items.length <= 4)
})
test('summarize: Rückgang, etwa gleich, kleine Zahlen absolut', () => { /* '– <b>9 % weniger</b> als', '– etwa so oft wie in den 30 Tagen davor.', '– <b>12 Aufrufe mehr</b> als' */ })
test('summarize: ohne Vergleich und neu gestartet', () => {
// previous fehlt: '…in den letzten 30 Tagen <b>X-mal</b> aufgerufen.'
// firstDay im Zeitraum (complete false): 'Seit dem Start der Analyse am 20.09. wurde deine Website <b>X-mal</b> aufgerufen.'
})
test('summarize: zu wenig Daten → null; keine Kontakte → Hinweis nur ab 30 Besuchern', () => {
assert.equal(summarize(stats({ totals: { ...t, pv: 29 } })), null)
// contacts 0, visitors 40: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.'
})
test('summarize: Herkunft, Gerät, Seite und Reihenfolge, höchstens 4', () => {
// refs google.com 880 von 2270 → 'Die meisten, die über einen Link kamen, kommen von Google (880).'
// devices m 4590/6930 → '66 % der Aufrufe kommen vom Handy.'
// pages / dann /preise → 'Am meisten angesehen: die Startseite, danach /preise (1.310).'
// alle 5 Bedingungen wahr → Seite fällt weg
})
test('summarize: Pfade und Hosts werden maskiert', () => {
const s = summarize(stats({ pages: [{ key: '/<img src=x>', count: 900 }], refs: [{ key: 'a"b.de', count: 900 }] }))
assert.ok(!JSON.stringify(s).includes('<img'))
assert.ok(JSON.stringify(s).includes('&lt;img'))
})
test('chartModel mit compare: gemeinsame Skala, prevLine', () => { /* top über beide Reihen; ohne compare prevLine '' und bestehender Test unverändert */ })
```
- [ ] **Step 2: Tests laufen, schlagen fehl**
Run: `node --test test/analyseInsights.test.js` → FAIL (Exporte fehlen)
- [ ] **Step 3: Funktionen in `public/analyse-view.js` implementieren** (Signaturen oben; `esc` aus `'./fn-util.js'`; Wochentagsnamen Sonntag…Samstag; Spitzentag-Satz „Spitzentag war Sa., 19.09. mit 371 Aufrufen.“ hängt am Wochentag-Hinweis, wenn beides zutrifft)
- [ ] **Step 4: Tests grün, Suite grün**
Run: `node --test test/analyseInsights.test.js test/analyseView.test.js && npm test` → PASS
- [ ] **Step 5: Commit**
```bash
git add public/analyse-view.js test/analyseInsights.test.js
git commit -m "Analyse: Vergleich zum Vorzeitraum und Klartext-Regeln als reine Funktionen"
```
---
### Task 4: HTML-Bausteine der Cockpit-Ansicht
**Files:**
- Create: `public/analyse-cards.js`
- Create: `test/analyseCards.test.js`
**Interfaces:**
- Consumes: Task 3 (`summarize`, `delta`, `compareFrom`, `contactRate`, `refName`, `fmtDuration`, `fmtNum`, `periodLabel`, `dayLabel`, `chartModel`, `barRows`, `countryName`, `flagEmoji`, `DEVICE_LABELS`), `esc`.
- Produces (alle geben HTML-Strings zurück, ohne DOM; relative Importe):
- `headerHtml({ project, usable, range, showRange, devMode, stamp })` – Kopfleiste `.an-top`; Auswahl `<select id="an-project">` nur bei `usable.length > 1`; Zeitraum-Knöpfe `.an-seg button[data-range]` nur bei `showRange`; Status „Analyse aktiv · Stand: heute, 19:42 Uhr“ (`stamp` = `'19:42'`) bzw. gelb „Im Dev-Modus wird nicht gemessen.“
- `tabsHtml(tab)` – `role="tablist"`, Knöpfe `[data-tab="uebersicht|heatmap|einstellungen"]`, `aria-selected`.
- `overviewHtml(stats)` – setzt zusammen: `summaryHtml`, `kpisHtml`, Zeile `.an-row2` (`chartHtml` + `contactsHtml`), `rankCardHtml('seen', …)` + `rankCardHtml('who', …)`, `basisHtml` + `heatTeaserHtml`, Fußzeile „Länderdaten: IP-Geolocation by DB-IP“ (Link wie heute).
- `summaryHtml(stats)`, `kpisHtml(stats)`, `sparkHtml(values)`, `chartHtml(stats)`, `contactsHtml(stats)`, `rankCardHtml(card, stats, { tab, expanded } = {})` mit `card` `'seen'` (Reiter `pages` | `refs`) oder `'who'` (`devices` | `countries`), `basisHtml(stats)`, `heatTeaserHtml()` (Knopf `[data-tab="heatmap"]`), `skeletonHtml()`, `errorHtml(message)` (Knopf `#an-retry`), `settingsHtml(project)` (Status, Unterlagen, leere `<section class="an-card" id="an-off">`).
- Texte exakt aus Spec 2.1–2.4 (Leerzustand „Sobald ein paar mehr Besuche da sind, steht hier das Wichtigste über deine Website in zwei, drei Sätzen.“, Quote „Auf 100 Besucher kommen 7,8 Kontakt-Aktionen (davor 6,5).“, „Noch keine Kontakt-Aktionen. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt.“, Datenbasis-Sätze, „Noch hat niemand über das Banner entschieden.“, „Vergleich ab TT.MM.“).
- [ ] **Step 1: Failing Tests schreiben** (`test/analyseCards.test.js`; Datenhelfer wie in Task 3)
```js
test('overviewHtml: volle Daten – Kacheln mit Chips, Vorzeitraum-Linie, Kontakte, Quote', () => {
const h = overviewHtml(full)
assert.match(h, /▲ 14 %/); assert.match(h, /vs\. 6\.080/); assert.match(h, /class="an-prev"/)
assert.match(h, /Ø Zeit pro Seite/); assert.match(h, /1:48/); assert.match(h, /Ø 64 % gescrollt/)
assert.match(h, /Auf 100 Besucher kommen 7,8 Kontakt-Aktionen \(davor 6,5\)\./)
assert.match(h, /61 % haben dem Banner zugestimmt \(2\.410 von 3\.950 Entscheidungen\)/)
})
test('overviewHtml: alter Collector ohne previous/firstDay – keine Chips, kein Fehler', () => {
const h = overviewHtml({ totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: [{ day: '2026-09-17', pv: 20 }], pages: [], refs: [], countries: [], devices: [], contacts: [], banner: { shown: 1, accept: 1, reject: 0 } })
assert.doesNotMatch(h, /▲|▼|undefined|NaN/); assert.doesNotMatch(h, /class="an-prev"/)
})
test('overviewHtml: Website ohne Aufrufe – Leerzustand, keine NaN/Infinity', () => {
const h = overviewHtml(zero)
assert.match(h, /Sobald ein paar mehr Besuche da sind/); assert.match(h, /Noch hat niemand über das Banner entschieden\./)
assert.doesNotMatch(h, /NaN|Infinity|undefined|null/)
})
test('kpisHtml: neue Website zeigt "Vergleich ab 18.11."', () => {})
test('contactsHtml: Arten mit 0 in beiden Zeiträumen entfallen, Leertext ohne Kontakte', () => {})
test('rankCardHtml: Startseite statt /, Anteil in %, 6 Zeilen, expanded bis 20, Herkunft mit refName, Pfad maskiert', () => {
const h = rankCardHtml('seen', stats({ pages: [{ key: '/<img src=x>', count: 9 }] }))
assert.match(h, /&lt;img src=x&gt;/); assert.doesNotMatch(h, /<img src=x>/)
})
test('headerHtml: Auswahl nur bei mehreren Websites, Zeitraum nur bei showRange, Dev-Hinweis, Name maskiert', () => {})
test('settingsHtml: aktiv seit TT.MM.JJJJ, Farbfeld, Fassung, Links AVV und Leistungsbeschreibung, #an-off', () => {})
test('errorHtml und skeletonHtml', () => { assert.match(errorHtml('Auswertung gerade nicht erreichbar'), /id="an-retry"/) })
```
- [ ] **Step 2: Tests laufen, schlagen fehl**
Run: `node --test test/analyseCards.test.js` → FAIL
- [ ] **Step 3: `public/analyse-cards.js` implementieren**
Klassen-Präfix `an-` (Karten `.an-card`, Kacheln `.an-kpi`, Chips `.an-delta.up|down|flat` mit `aria-label`, Mini-Verlauf `.an-spark`, Diagramm `.an-chart` mit `.an-area`, `.an-line`, gestrichelt `.an-prev` (nur wenn `compareFrom(stats).ok`), Legende „<range> Tage“ / „Zeitraum davor“ (zweiter Eintrag nur mit Vergleich), Punkte mit `data-i`, Daten für die Blase als `data-day`/`data-pv`/`data-prev` am Punkt). Mini-Verläufe: Seitenaufrufe `series[].pv`, Besucher `series[].visitors`, Kontakte `series[].contacts`, Zeit `series[].avgSec` (fehlende Werte als 0, ganz fehlende Reihe → kein Verlauf). Symbole als Inline-SVG (gleicher Stil wie `ICONS` in `funktionen.js`, `stroke`-basiert), keine Emoji.
- [ ] **Step 4: Tests grün, Suite grün**
Run: `node --test test/analyseCards.test.js && npm test` → PASS
- [ ] **Step 5: Commit**
```bash
git add public/analyse-cards.js test/analyseCards.test.js
git commit -m "Analyse: HTML-Bausteine der Cockpit-Übersicht"
```
---
### Task 5: Cockpit verdrahten – Rahmen, Reiter, Übersicht, Heatmap, Einstellungen, Stil
**Files:**
- Modify: `public/analyse.js`
- Create: `public/analyse.css`
- Modify: `public/dashboard.html` (Zeile 24: `<link rel="stylesheet" href="/analyse.css">` nach `funktionen.css`)
- Modify: `public/funktionen.css:140-218` (Analyse-Abschnitt entfernen; was die Einschalten-Ansicht und Heatmap brauchen, zieht nach `analyse.css`)
- Modify: `test/ui/analyse.ui.mjs`
**Interfaces:**
- Consumes: Task 2 (`analyseSel`, `request`, `esc`, `safeUrl`, bestehende Heatmap-Funktionen in `analyse.js`), Task 3, Task 4 (alle Bausteine).
- Produces: `initAnalyse(root, { projectId })` rendert `<div class="an">` mit `headerHtml` + (nur Zustand `active`) `tabsHtml` + `<div id="an-stage">`. Bestehende Exporte bleiben.
Verhalten:
- Zustand `active`: Reiter aus `analyseSel.tab`. Übersicht: erst `skeletonHtml()`, dann `overviewHtml(stats)`; Fehler → `errorHtml(…)`, `#an-retry` lädt neu; Zähler `statsRequestId` verwirft veraltete Antworten (wie heute). Zeitraum-Knöpfe setzen `analyseSel.range` und laden nur die Übersicht neu. `#an-project` wechselt die Website (Reiter bleibt). Ranglisten-Umschalter und „Alle anzeigen“ rendern nur ihre Karte neu (`rankCardHtml`). `[data-tab="heatmap"]` im Teaser wechselt den Reiter.
- Diagramm-Blase: Zeigen/Tippen auf `.an-chart` wählt den nächsten Punkt (`data-i`) und zeigt `.an-tip` mit „Sa., 19.09.: 371 Aufrufe (davor 210)“ (ohne Vorwert ohne Klammer); verlassen → ausblenden.
- Heatmap-Reiter: `renderAnalyseHeat` wie heute; die Seitenliste kommt aus der zuletzt geladenen Statistik (sonst einmal `range=30` laden). iframes entstehen erst beim Öffnen des Reiters.
- Einstellungen-Reiter: `settingsHtml(project)`, danach `renderAnalyseOff(stage.querySelector('#an-off'), projectId, reload)`.
- Andere Zustände (Einschalten, gesperrt, nicht verfügbar, unbekannt, keine nutzbare Website): Kopfleiste ohne Zeitraum und ohne Reiter, Inhalt im Cockpit-Rahmen, Texte unverändert. `root.querySelector` kann in Node-Tests für andere Selektoren als `#an-stage` `null` liefern – überall `?.` verwenden.
- `analyse.css`: Werte aus Global Constraints und Spec 2.5; Raster Desktop wie Entwurf A (`/root/reports/analyse-ueberarbeitung-2026-10-04/entwurf-a-cockpit.png`); `.an-row2` mit `align-items: stretch`, Diagramm füllt die Höhe; ≤ 880 px: Kacheln 2 × 2, alles einspaltig, Reiter `overflow-x: auto`, Zeitraum volle Breite, lange Namen mit `text-overflow: ellipsis`; `@media (prefers-reduced-motion: reduce)` ohne Übergänge.
- [ ] **Step 1: UI-Test erweitern (schlägt fehl)** – `test/ui/analyse.ui.mjs`
Neue Szenarien in `SCENARIOS`/Statistik: `full` (30 Tage, Werte wie Fixture „Boulderhalle Überhang“: pv 6.930/Vorzeitraum 6.080, visitors 2.410/2.210, contacts 188/143, avgSec 108/114, avgScroll 64, consentRate 0,61, banner 3.950/2.410/1.540, `firstDay` vor dem Vorzeitraum), `neu` (`firstDay` im Zeitraum, `previous.complete` false), `wenig` (pv 12), `dev` (`devMode: true`), `alt` (ohne `previous`), `langerPfad` (Seite `/` + 120 Zeichen), `fehler` (Statistik 503). Prüfungen je Viewport 1440 × 1000 und 402 × 874:
```
OK aktiv: Block "Das Wichtigste" mit "14 % mehr"
OK aktiv: 4 Kacheln, 3 Chips ▲, 1 Chip ▼
OK Reiter Heatmap: vorher 0 iframes, nach Klick 2 iframes
OK Reiter Einstellungen: "Analyse ausschalten" sichtbar, "aktiv seit"
OK Zeitraum 7 → Anfrage range=7, Reiter bleibt Übersicht
OK verzögerte erste Antwort (range=30, 1,5 s) überschreibt die spätere (range=7) nicht
OK neu: "Vergleich ab" statt Chips, Satz "Seit dem Start der Analyse"
OK wenig: Leerzustand
OK dev: "Im Dev-Modus wird nicht gemessen."
OK alt: keine Chips, keine pageerror
OK fehler: "Auswertung gerade nicht erreichbar" + Neu laden
OK langer Pfad, 402 px: document.scrollingElement.scrollWidth <= innerWidth
OK Einschalten: Banner-Vorschau, Häkchen schaltet Knopf frei (wie bisher)
OK keine pageerror in allen Zuständen
```
Screenshots je Zustand und Viewport nach dem Ordner aus `argv[2]`.
Run: `node test/ui/analyse.ui.mjs /tmp/claude-0/-root/072dd97e-d807-40b8-b757-afe9463e87f8/scratchpad/ui-task5` → FAIL
- [ ] **Step 2: `analyse.js` verdrahten, `analyse.css` schreiben, `dashboard.html` und `funktionen.css` anpassen**
- [ ] **Step 3: UI-Test grün**
Run: `node test/ui/analyse.ui.mjs /tmp/claude-0/-root/072dd97e-d807-40b8-b757-afe9463e87f8/scratchpad/ui-task5`
Expected: nur `OK`-Zeilen.
- [ ] **Step 4: Screenshots ansehen und mit Entwurf A vergleichen**
Desktop- und Handy-Bilder von `full`, `neu`, Einschalten und Einstellungen öffnen (Read-Tool). Erwartet: Aufbau wie Entwurf A, Diagramm und Kontaktkarte gleich hoch, keine abgeschnittenen Texte, keine Überlappungen, Kacheln auf dem Handy 2 × 2. Abweichungen beheben und Step 3 wiederholen.
- [ ] **Step 5: Suite grün**
Run: `npm test` → PASS, 0 fail
- [ ] **Step 6: Commit**
```bash
git add public/analyse.js public/analyse.css public/dashboard.html public/funktionen.css test/ui/analyse.ui.mjs
git commit -m "Analyse: Cockpit-Übersicht mit Reitern Übersicht, Heatmap, Einstellungen"
```
---
### Task 6: Abschlussprüfung auf Node 20 und Bilder für den Nutzer
**Files:**
- keine Code-Dateien (nur Prüfung); Bilder nach `/root/reports/analyse-ueberarbeitung-2026-10-04/screens/`
- [ ] **Step 1: Portal-Suite auf Node 20**
Run: `docker run --rm -v /home/webklar/apps/kundenbereich.webklar.com:/home/webklar/apps/kundenbereich.webklar.com:ro -w /home/webklar/apps/kundenbereich.webklar.com/dev-analyse-neu node:20-alpine node --test test/*.test.js 2>&1 | tail -8`
Expected: `fail 0` (bekannte Ausnahme laut Notizen: nur `sharp`-abhängige Tests, falls `sharp` für musl fehlt – dann diese benennen, nicht ignorieren).
- [ ] **Step 2: Collector-Suite auf Node 24**
Run: `cd analytics && npm test` → `fail 0`
- [ ] **Step 3: Bilder für den Nutzer**
Run: `node test/ui/analyse.ui.mjs /root/reports/analyse-ueberarbeitung-2026-10-04/screens`
Expected: nur `OK`; Bilder `full`, `neu`, Heatmap, Einstellungen, Einschalten je Desktop/Handy liegen im Ordner.
- [ ] **Step 4: Branch-Zustand festhalten**
Run: `git status --short` (nur `?? node_modules`) und `git log --oneline origin/main..HEAD` (Spec, Plan, Tasks 1–5).
---
## Livegang (nur mit OK des Nutzers, nicht Teil der Tasks)
- **L1** Nutzer pusht: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-analyse-neu push origin feature/analyse-uebersicht:main` (vorher ggf. auf `origin/main` rebasen und `npm test` erneut).
- **L2** Prüfen: `build/` auf dem neuen Stand; `docker ps` zeigt `webklar-kundenbereich-server` und `webklar-analytics` frisch gestartet; `curl -s https://project.webklar.com/a/health`; im Container Stats einer echten aktiven Website abrufen (über `analyticsClient.getStats(siteKey, 30)` in `docker exec -w /app webklar-kundenbereich-server node -e …`) → Felder `previous`, `firstDay`, `totals.avgSec` vorhanden; `https://project.webklar.com/analyse.js` und `/analyse.css` 200.
- **L3** Nutzer öffnet Funktionen → Analyse einer Website mit aktiver Analyse und testet Reiter, Zeitraum, Handy.
- **L4** Notizen aktualisieren (Memory `analyse-uebersicht-neu.md`).
@@ -0,0 +1,135 @@
# Öffnungszeiten live – Livegang-Notiz
Stand 05.10.2026, für Kenso. Grundlage: Spec Abschnitt 13.6, das Protokoll der Umsetzung (`.superpowers/sdd/2026-10-04-oeffnungszeiten-live/progress.md`, alle LIVEGANG-Hinweise und Rechtsrunde-Punkte) und die Gesamtprüfung (Task 37, Bericht `task-37-report.md` im selben Ordner). Nichts davon ist gepusht. Keine Rechtsberatung.
## Stand der drei Branches
| Repo | Worktree / Branch | Stand | Basis und Abstand |
|---|---|---|---|
| Portal (+ Runner) | `dev-zeiten`, `feature/oeffnungszeiten` | Inhalt von `90e8607` plus diese Notiz und die Fixrunde nach der Abschlussprüfung (`ecf67d6` Server, `aa9ee35` Skript und Editor, danach die Doku) | enthält TP2 bis `a629b25`; TP2 steht inzwischen auf `af63b69` (34 Commits weiter) |
| WOMS | `ticket.webklar.com/dev-zeiten`, `feature/oeffnungszeiten` | `919d96f` (Fixrunde: Statustext der Einrichtung) auf `3d67aa8` | auf WOMS-TP2 `72eedeb`; TP2 steht inzwischen auf `4c3f67e` (5 Commits weiter) |
| Relaunch | `webklar.com/dev-zeiten-station`, `feature/zeiten-station` | `8f5d522` | auf `main` `4139f31` (kein Abstand, Stand 05.10.) |
Letzte Prüfung vor der Fixrunde 05.10.2026, alles grün: Portal 4544/4545 (der eine Fehler war der bekannte wacklige `previewDevRouter`-Test, einzeln 12/12), Portal unter Node 20 (wie in Produktion) 4543/4543 ohne `analyticsContract`, Runner 177/177, UI-Prüfungen 31/31 + 8/8 + alle OK, WOMS 377/377 + `vite build`, Relaunch 349/349 + Build (Haupt-JS 128 084 Byte gz, Grenze 133 120).
Nach der Fixrunde (Abschlussprüfung, 05.10.2026), alles grün: Portal 4564/4564 (`npm test`), Runner 177/177, UI-Prüfungen Skript 34/34 und Portal 9/9, WOMS 378/378 + `vite build`. Der Node-20-Lauf (Schritt A.2) und die Relaunch-Prüfung (A.4) stehen für nach der TP2-Zusammenführung an, sie laufen ohnehin neu.
## A. Reihenfolge am Livegang-Tag
1. **TP2 geht zuerst live** (Portal und WOMS) nach seiner eigenen Reihenfolge (Kanzlei-Paket Abschnitt 7.6, mit `termine-setup.mjs` und `feature-setup.mjs`). Öffnungszeiten live baut auf TP2 auf und braucht dessen Schema (`aboEndeMailAt`, `disabledReason`, `featureConsents`).
2. **Portal neu aufsetzen.** In `dev-zeiten`: das jetzt live gegangene TP2 einmal hineinführen, mit `git fetch origin && git merge origin/main`. **Nicht** `git merge main`: die lokale Ref `main` steht noch auf `f1b43ca` (vor TP2), das gäbe „Already up to date“, und der Push würde abgelehnt. Ist TP2 noch nicht in `origin/main`: `git merge feature/terminplaner-tp2`. Es gibt einen Merge statt Rebase, damit die Commit-Nummern des Protokolls gültig bleiben. Eine Probe-Zusammenführung mit TP2 `af63b69` (`git merge-tree --write-tree`) ergab im Portal drei Textkonflikte, die so zu lösen sind (bei der ersten Zusammenführung `1d5a565` überlappten 14 Dateien):
- `server/routes/admin/funktionenAdmin.js`: unser Zeiten-Zweig (`zeitenAktivieren`, `zeitenAbschalten`, `ersatzTicket` in der Antwort) **und** TP2s Textform-/K16-Warnung (`warnung`) bleiben beide; die Antwort trägt beides.
- `server/services/retention.js`: unser `zeitenIds` (Zeiten vor dem Abschalten des Kunden löschen) **und** TP2s `consentIds`/`featureFieldIds` bleiben beide. TP2 `9a2a70b` („Fehlschlag hält den Kunden für den nächsten Lauf“) löst vermutlich die Retention-Hälfte von N9 (eine fehlgeschlagene `loescheZeitenVonKunde` wird nicht wiederholt): bei der Konfliktlösung prüfen, ob `loescheZeitenVonKunde` bei jedem Lauf aufgerufen wird, und nicht vorher separat patchen.
- `server/services/siteFeatures.js`: Textkonflikt, beide Seiten behalten. TP2 `b974b3a` legt `disable()` unter die Sperre `feature-activate:<id>` (Sperren sind nicht wiederbetretbar): nach der Zusammenführung prüfen, dass **kein Halter von `feature-activate` `disable()` aufruft** (`aboAbgleich` und `zeitenAbschalten` rufen es unter `feature-admin` auf: erlaubte Reihenfolge).
Danach alles erneut laufen lassen:
- `nice npm test` (nur `previewDevRouter` darf wackeln, dann einzeln wiederholen), `cd runner && nice npm test`
- Node 20: `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-zeiten:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'`
- UI, je einzeln mit `nice` und vorher `free -m`: `test/ui/zeiten-embed.ui.mjs`, `test/ui/zeiten-portal.ui.mjs`, `test/ui/funktionen.ui.mjs`
3. **WOMS neu aufsetzen.** In `ticket.webklar.com/dev-zeiten`: die WOMS-Hauptlinie (`test`, mit TP2) hineinführen (`git fetch origin && git merge origin/test`). Erwarteter Konflikt (1 Datei): `src/components/customer/CustomerFunktionen.jsx`; dort bleiben unser `ersatzTicket` **und** TP2s `speicherStatus`/`K16_WARNUNG` (TP2 `4c3f67e` ändert dieselben `save()`-Zeilen sowie das Ende von `src/lib/funktionenView.js` und seinen Test, das Portal fügt dieselbe POST-Route um `warnung` ein). `K16_WARNUNG` nennt einen „AVV“, den Öffnungszeiten live nicht hat: nach der Zusammenführung prüfen und für die Zeiten anpassen. Danach `nice npm test` und `nice npx vite build`. `node_modules` ist dort im Git verfolgt und wurde von `npm ci` verändert: **nie stagen**.
4. **Relaunch prüfen.** `git log --oneline 4139f31..main` im Relaunch-Repo; gibt es neue Commits, `feature/zeiten-station` darauf aufsetzen, dann `nice npm test`, `nice npm run build` und `gzip -9 -c dist/assets/index-*.js | wc -c` (höchstens 133 120).
5. **Schema anlegen, vor dem Portal-Deploy.** `scripts/zeiten-setup.mjs` legt `oeffnungszeiten` und `zeitenErinnerungen` an und ergänzt `portalFeatures` um `unlockedBy`, `aboEndeAt`, `einbau`, `behaltenSessionId`, `einschaltMailAt`. Seit der Fixrunde schreibt `activate()` `aboEndeAt` und `behaltenSessionId` nur noch für Öffnungszeiten live (Parameter `extra`); News und Terminplaner brauchen die Felder nicht mehr und laufen auch vor dem Skript. Trotzdem erst das Schema, dann der Deploy: die Zeiten-Pfade schreiben die Felder. Das Skript braucht den neuen Code, also aus dem Worktree in einem Wegwerf-Container (kein `docker compose run`: der Container bekäme die Traefik-Labels des Portals). Die Env-Datei enthält Zugangsdaten: mit `umask 077` anlegen, nicht erst danach `chmod`. Erst `--check` (beim ersten Lauf Exit 1, erwartet: es fehlt ja noch alles), dann ohne, dann **noch einmal `--check`** (muss grün sein; die Index-Reihenfolge der Schema-Skripte hat am 30.09. schon einmal überrascht):
```
(umask 077; docker inspect webklar-kundenbereich-server -f '{{range .Config.Env}}{{println .}}{{end}}' > /root/zeiten-env.tmp)
docker run --rm --env-file /root/zeiten-env.tmp --network webklar-network -v /home/webklar/apps/kundenbereich.webklar.com/dev-zeiten:/app:ro -w /app node:20-alpine node scripts/zeiten-setup.mjs --check
docker run --rm --env-file /root/zeiten-env.tmp --network webklar-network -v /home/webklar/apps/kundenbereich.webklar.com/dev-zeiten:/app:ro -w /app node:20-alpine node scripts/zeiten-setup.mjs
docker run --rm --env-file /root/zeiten-env.tmp --network webklar-network -v /home/webklar/apps/kundenbereich.webklar.com/dev-zeiten:/app:ro -w /app node:20-alpine node scripts/zeiten-setup.mjs --check
rm /root/zeiten-env.tmp
```
(Der Aufruf ist so noch nie gegen die Produktion gelaufen; er folgt dem Muster der früheren Schema-Skripte. Das Skript ist idempotent, `--check` schreibt nichts.)
6. **Stripe prüfen.** `node scripts/stripe-products.mjs --pruefen` (gleicher Wegwerf-Container). Erwartet wird ein Preis `webklar_zeiten_einrichtung_3000` (30 €, einmalig, `tax_behavior` inclusive, Standard-Produktbild). Fehlt er: `--anlegen`; danach `--pruefen` nochmal.
7. **Einstellungen, vor dem Push.** Zuerst Sicherungen: `cp /home/webklar/apps/docker-compose.yml /home/webklar/apps/docker-compose.yml.bak-2026-10-05-zeiten` und `(umask 077; cp /home/webklar/apps/.env /home/webklar/apps/.env.bak-2026-10-05-zeiten)` (`.env` enthält Zugangsdaten). Dann in `/home/webklar/apps/.env`: `ZEITEN_ENABLED=pilot`, `ZEITEN_PILOT_EMAILS=kenso.gri@gmail.com`, `ZEITEN_KEIN_NEUBAU=emmas-schatzkiste`. In `/home/webklar/apps/docker-compose.yml`, Dienst `kundenbereich-server`, drei Zeilen neben `TERMINE_ENABLED`: `- ZEITEN_ENABLED=${ZEITEN_ENABLED:-off}`, `- ZEITEN_PILOT_EMAILS=${ZEITEN_PILOT_EMAILS:-}`, `- ZEITEN_KEIN_NEUBAU=${ZEITEN_KEIN_NEUBAU:-emmas-schatzkiste}`. Prüfen mit `docker compose -f /home/webklar/apps/docker-compose.yml config -q` (keine Ausgabe = gültig), dann übernehmen mit `docker compose -f /home/webklar/apps/docker-compose.yml up -d --no-deps kundenbereich-server`. Ein bloßes `docker restart` liest neue Umgebungswerte nicht. Das alles **vor dem Push** (Schritt 9): dann startet der Container mit dem alten Code und den neuen Werten (die kennt er nicht, harmlos), und der Neustart durch `deploy.sh` nach dem Push ist der einzige mit dem neuen Code (jeder Containerstart führt `apk add` und `npm install` aus).
8. **Runner: kein Image-Bau nötig.** Der Runner ist `node:20-alpine` mit eingehängtem `build/runner` (das Protokoll sagt „Image neu bauen“, das stimmt für die echte Konfiguration nicht). `deploy.sh` startet ihn neu, **nur wenn sich `runner/` in der Deploy-Spanne ändert** und die Warteschlange leer ist. `ZEITEN_PUBLIC_BASE` nicht setzen: die Vorgabe `https://project.webklar.com/embed/` stimmt. **Nach dem Push prüfen:** `docker inspect -f '{{.State.StartedAt}}' webklar-preview-runner` muss nach dem Push liegen (vorher 2026-10-02T16:17Z); sonst von Hand `docker restart webklar-preview-runner` (das Deploy-Log im Container `webklar-kundenbereich-build` wird täglich um 00:00 rotiert und ist als Beleg unzuverlässig; „naechster Deploy versucht es erneut“ stimmt nicht, ohne Änderung an `runner/` kommt kein Neustart). Ein alter Runner verwirft das neue Feld `zeiten` stillschweigend, der Einbau meldete dann „angestoßen“, setzt aber nichts ein.
9. **Pushen, in dieser Reihenfolge** (du pushst, ich nicht). Die Ziel-Ref steht immer dabei (`<lokaler Branch>:<Ziel>`), damit nichts unter dem falschen Namen ankommt:
1. Portal samt Runner: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-zeiten && git push origin feature/oeffnungszeiten:main`. **Der Portal-Webhook deployt jeden gepushten Branch** (`webhook-server.js` setzt `DEPLOY_BRANCH` aus `payload.ref`, `deploy.sh` macht `reset --hard origin/<branch>`): den Feature-Branch nie unter seinem eigenen Namen pushen (`git push origin feature/oeffnungszeiten`), das würde ihn auf die Produktion bringen.
2. WOMS erst **nach** dem Portal, sonst antwortet das Portal auf die neuen Zeiten-Aktionen mit 404: `cd /home/webklar/apps/ticket.webklar.com/dev-zeiten && git push origin feature/oeffnungszeiten:test`. Der WOMS-Webhook deployt nur `test` (`TICKET_DEPLOY_BRANCH`, Vorgabe `test`; Pushes auf andere Branches ignoriert er); `test` also nie vor dem Portal. Danach `setup-legal.mjs` (legt die Rechtliches-Karte `leistungsbeschreibung-zeiten` an, Schlüssel ≤ 36 Zeichen): **ungetestet gegen die Produktion**, zuerst `node scripts/setup-legal.mjs --dry-run` im WOMS-Worktree, dann `APPWRITE_API_KEY=… APPWRITE_ENDPOINT=… node scripts/setup-legal.mjs`. Der Schlüssel (`APPWRITE_API_KEY`) ist nötig; der Standard-Endpoint des Skripts ist `https://appwrite.webklar.com/v1`, das WOMS-Frontend nutzt in der Vorgabe `https://ticket.webklar.com/v1`: den Endpoint vorher gegen den ausliefernden Container prüfen und ausdrücklich setzen.
3. Relaunch: `cd /home/webklar/apps/webklar.com/dev-zeiten-station && git push origin feature/zeiten-station:main`. Die Station „Öffnungszeiten live“ steht dort auf `bald: true` („Bald verfügbar“) und bleibt so bis Schritt C.
10. **Live-Checks nach dem Deploy** (Portal und WOMS sind da):
- Öffentliche Schnittstelle kennt einen unbekannten Schlüssel nicht: `curl -s https://project.webklar.com/api/public/zeiten/v1/aaaaaaaaaaaaaaaa` antwortet `{"aktiv":false}`.
- Skript: `curl -sI https://project.webklar.com/embed/zeiten.js` ist 200, mit `Access-Control-Allow-Origin` und `Cache-Control`.
- Leistungsbeschreibung: `curl -sI https://project.webklar.com/vertragsunterlagen/zeiten-leistungsbeschreibung.html` ist 200.
- WOMS: Kunde → Reiter „Funktionen“ lädt ohne Fehler (auch für ein Projekt ohne Zeiten).
- Portal-Log: `docker logs --since 15m webklar-kundenbereich-server 2>&1 | grep -i "unknown attribute"` bleibt leer (sonst fehlt ein Schema-Feld, Schritt 5).
- Stripe: Webhook-Adresse und Ereignisliste im Dashboard sind unverändert (die Funktion nutzt dieselben Ereignisse wie News und Terminplaner; es kommt nichts Neues dazu).
- **Rückweg**, falls etwas hakt: `ZEITEN_ENABLED=off` in `.env`, dann `docker compose -f /home/webklar/apps/docker-compose.yml up -d --no-deps kundenbereich-server`. Die Kachel verschwindet, die Schnittstelle antwortet `aktiv: false`, die Platzhalter zeigen ihr Ersatz-HTML, der Abo-Abgleich läuft weiter. Das Schema ist rein additiv (neue Collections und Felder), es gibt nichts zurückzubauen; ein Code-Rollback ist bei `off` nicht nötig.
11. **Pilot-Probelauf** (Abschnitt B), danach Rechtsrunde abschließen (Abschnitt D), dann Abschnitt C.
## B. Probelauf mit `ZEITEN_ENABLED=pilot` auf `test-website`
Nur dein Konto sieht die Kachel. Beachte: Das Portal prüft nur „Leistungsbeschreibung vorhanden“, nicht „freigegeben“ (`unterlagenBereit` ist für Zeiten immer wahr). Der Pilot ist deshalb der einzige Schutz vor dem Entwurfstext, bis `on` kommt.
1. Kachel im Funktionen-Tab sichtbar. Je nach Konto: „Einschalten“ (Wartungs-Abo aktiv) oder „30 € einmalig“. Den Bezahlweg nur mit echter 30-€-Zahlung testen (danach erstatten) oder weglassen; die Tests decken ihn ab.
2. Einschalten: Ticket „Öffnungszeiten live einbauen“ in WOMS, Mail K27, `einbau` = `angestossen` (Runner-Log) oder `manuell`.
3. Editor: Wochenplan mit Fenstern und Mitternacht, zweiter Plan, Bundesland und Feiertage, Urlaub (Hinweis-Vorschlag), Hinweis-Leiste, Kontakt-Leiste mit Telefon und Adresse, „Für Google kopieren“, Uhrzeit-Regler.
4. Preview der `test-website` nach dem Neubau: im Quelltext `<script data-wk-zeiten … data-site="…">`; Hinweis-Leiste oben (schiebt den Header?), Kontakt-Leiste auf dem Handy, JSON-LD im Quelltext (kein Duplikat), Platzhalter mit Status, Tabelle und Badge; Ersatz-HTML aus WOMS („Ersatz-HTML kopieren“) in die Platzhalter. Dazu der Datenschutz-Absatz aus `docs/funktionen/website-datenschutz-absatz.md` (Entwurf) auf der Test-Seite.
5. WOMS: Panel (Zeiten-Einstellungen `navSelektor`, Leisten aus), „Aktivieren ohne Zahlung“ und „Abschalten“ am Testprojekt; Ticket schließen ⇒ nach spätestens 5 Minuten `active` und Mail K16; Abschalten ⇒ Ticket „Ersatz-Tabelle aktualisieren“ (nur wenn der Wochenplan vom Ersatz-HTML abweicht).
6. Mails K27 bis K30 ansehen (K28 über einen Feiertag in 1–7 Tagen, K29/K30 nur mit einem Abo-Ende; sonst über die Tests bewertet).
7. Aufräumen: Funktion am Testprojekt abschalten, Ticket schließen. Das Abschalten durch das Team löscht **nie** Daten; die Testdaten löschst du von Hand in der Appwrite-Konsole (Datenbank des Portals): im Dokument der Funktion in `portalFeatures` (`featureKey` = `zeiten`, `projectId` = das Testprojekt) die ID notieren; in der Collection `oeffnungszeiten` das Dokument mit genau dieser ID löschen (es trägt die Eingaben, das Team-Feld und den Ersatz-Hash); in `zeitenErinnerungen` alle Dokumente mit `featureId` = dieser ID löschen (die Erinnerungs-Merker). Danach ist die Schnittstelle des Testprojekts nach spätestens 60 Sekunden (Zwischenspeicher) wieder leer. (Automatisch gelöscht wird nur beim Ende des Abos nach 30 Tagen und beim Löschen des Kundenkontos.)
## C. Schalter `on` (letzter Schritt)
Erst wenn Abschnitt B ohne offene Fehler und Abschnitt D vollständig erledigt ist:
1. `ZEITEN_ENABLED=on` in `.env`, `docker compose up -d kundenbereich-server`.
2. Relaunch: in `src/components/home/Kundenportal.tsx` den Eintrag `oeffnungszeiten` auf `bald: false` (die Marke „Bald verfügbar“ verschwindet). **Dabei den Test anpassen**: `relaunch-kundenportal.test.tsx` (um Zeile 142 bis 156) pinnt genau zwei „Bald verfügbar“; nach dem Umschalten ist es eines weniger. Dann Tests und Build, du pushst. Das Handy-Bild `phone-12-zeiten` zeigt den Einleitungstext des Editors (und einen laufenden Urlaubs-Hinweis): nach der Textrunde **neu aufnehmen**, bevor `bald: false` live geht.
3. Logs beobachten (`[zeiten]`, `[zeiten-jobs]`): Abo-Abgleich täglich, Feiertags-Erinnerung stündlich zwischen 08:00 und 20:00.
4. Zurück auf `off`: Kachel verschwindet, die öffentliche Schnittstelle antwortet `aktiv: false`, die Platzhalter zeigen ihr Ersatz-HTML; der Abo-Abgleich läuft weiter.
## D. Rechtsrunde und Texte (Voraussetzung für `on`)
Reihenfolge laut Spec Abschnitt 9: gemeinsame TP2-Bausteine, Leistungsbeschreibung, Datenschutz, Oberflächen- und Mailtexte, Fragen an die Kanzlei, Uploads, `on`.
1. **Gemeinsame TP2-Bausteine** (Block F: Widerrufsbelehrung, AVV) zuerst.
2. **Leistungsbeschreibung** `public/vertragsunterlagen/zeiten-leistungsbeschreibung.html`: Entwurf („ENTWURF – vor Livegang Rechtsrunde“ im Kopf). Wortlaut-Punkte der Gesamtprüfung: Nr. 4 Abs. 2 „sobald das Wartungs-Abo aktiv ist“; Nr. 1 Abs. 1 (wann die Vereinbarung bei Team-Aktivierung entsteht, siehe ZQ11); Nr. 3 Abs. 2 „oder stehen sollen“ streichen; Nr. 3 Abs. 3 „bestätigt den Einbau als abgeschlossen“ und „Tag, der nicht geschlossen ist“; Nr. 8 Abs. 7 zweiter Satz unklar; „Behalten nach Abschaltung“. Danach Fassung festlegen (Register `leistungsbeschreibung.version` und `consent.version` hochzählen, Tests nachziehen).
3. **Datenschutz Portal § 6e (6d ist seit dem Webshop vergeben)** (`docs/rechtliches/zeiten-datenschutz-6e-entwurf.md`) in `public/datenschutz.html` einbauen, mit dem Anker `id="zeiten"`, plus der Satz in § 6 (Öffnungszeiten im Kundenauftrag). **Er muss live sein, bevor der Link der Leistungsbeschreibung funktioniert.** Danach `scripts/build-legal-pdfs.sh datenschutz` (nicht `all`), Prüfsumme nachziehen, `test/legalTexts.test.js` und `test/zeitenRecht.test.js` anpassen. Wortlaut-Punkte: „Ihre Eingaben“ gegen „der Kunde“, „keine Werbung“ gegen ZQ6.
4. **Widerrufsbelehrung für das 30-€-Produkt freigeben.** Die Bestellseite muss genau `featureHaken('zeiten').belehrung()` vor dem Knopf zeigen (Ruling R32), sonst belegt der Nachweis einen Text, den der Kunde nie sah.
5. **Wertersatz-Satz** (§ 357a Abs. 2 BGB) im Beginn-Haken der bezahlten Bestellung, wie bei News und Terminplaner (ZQ3): im Code ergänzt (Fixrunde, Fassung `2026-10-05`); der Wortlaut bleibt Entwurf bis zur Rechtsrunde, danach die Fassung auf den Tag der Freigabe setzen (Pins in `test/zeitenRegister.test.js`).
6. **Widerruf-Seite:** in der Fixrunde ergänzt (`public/widerrufen.html`, `server/routes/abo.js` mit `ORDERS`, `FEATURE_OF_ORDER`, `orderLabel`); ein zahlender Verbraucher, der widerruft, wird jetzt den Öffnungszeiten zugeordnet. Nichts mehr zu tun, außer beim Livegang die Seite einmal zu öffnen.
7. **Fragen an die Kanzlei:** `docs/rechtliches/zeiten-kanzlei-fragen.md` (ZQ1 bis ZQ12) ins Kanzlei-Paket des Terminplaners als Q27 ff. übernehmen (AVV Funktionen per Haken, Behalten-Haken, Wertersatz, Haftung ohne Abo, Ende mit dem Abo, Mails, Einstellung bei Unternehmern, Website-Absatz, Vertragsrahmen ohne Abo, Preisangaben in der Hinweis-Leiste, Aktivierung durch das Team ohne Zustimmung, Pflichtinformationen als Haken und in K15).
8. **K02/K03:** Entwurf für den Block `funktionen` in `docs/rechtliches/zeiten-k02-k03-entwurf.md`. Nach der Freigabe die Vorlagen ändern und die Pins nachziehen (`test/mailTemplates.test.js`, `test/publicForms.test.js` `FUNKTIONEN_SATZ`).
9. **Website-Absatz** `docs/funktionen/website-datenschutz-absatz.md` (Varianten A/B, Baustein Route und WhatsApp nur wenn die Leiste die Knöpfe zeigt): freigeben; der Einbau auf einer Kundenseite erst, wenn er in deren Datenschutzerklärung steht.
10. **K15 für bezahlte Öffnungszeiten** trägt keine Vertragsunterlagen und keine Belehrung als Anhang (dauerhafter Datenträger): entscheiden, ob nötig (Frage ZQ12 zusammen mit dem Haken `information`).
11. **Mailtexte K27 bis K30** und die Texte im Editor in deiner Textrunde: K28 sagt „deine Zeiten gelten normal“, obwohl die Seite an gesetzlichen Feiertagen „geschlossen“ zeigt, wenn du nichts änderst; K27-Variante „wieder eingeschaltet“ (ohne „per Ticket“ / „von Hand“); T19-Text spricht von „Einrichtung“; `disabledMessage` schreibt „du“ klein; eigene Texte des Editors (Preishinweis, Vorschau); Betreffzeile K29 nennt „Wartungs-Abo“ (so gewollt).
12. **Uploads:** die Fassungen (Leistungsbeschreibung, Datenschutzerklärung) in WOMS „Rechtliches“ hochladen (Karte `leistungsbeschreibung-zeiten`).
## E. Offene Code-Punkte (Checkliste für die letzte Fixrunde)
Mit ☐ markiert, was noch im Code offen ist; die Fixrunde des Controllers kann einzelne schließen (dann hier abhaken).
**Vor `on` nötig**
- [x] N1 Wertersatz-Satz im Beginn-Haken `features.zeiten.consent` (`server/config.js`), Fassung `2026-10-05`, Pins in `test/zeitenRegister.test.js` (Fixrunde `ecf67d6`). Der Wortlaut bleibt Entwurf (ZQ3).
- [x] N2 „Öffnungszeiten live“ in `ORDERS`, `FEATURE_OF_ORDER` und im Bestell-Label von `server/routes/abo.js`, im Radioknopf von `public/widerrufen.html` und in den Pins (`widerrufen_html_radio`, neuer Test `widerruf_zeiten`); `public/legal-form.js` braucht keine Änderung, es liest `data-text` (Fixrunde `ecf67d6`).
- [ ] N3 Rechtstexte aus den Entwürfen übernehmen (Abschnitt D, Nr. 2 und 3) und den Entwurfskommentar der Leistungsbeschreibung entfernen.
- [ ] N4 Entscheidung zu Anhängen in K15 für Zeiten (D, Nr. 10); K02/K03-Block (D, Nr. 8).
**Härtung (vor `on` empfohlen, für den Pilot nicht nötig)**
- [x] N5 Website-Skript: im JSON-LD `<` als `\u003c` schreiben (`jsonLdText`, Test); Kopf-Elemente bekommen bei Scroll ≥ Leistenhöhe ihren eigenen Inline-`top` zurück (UI-Prüfung `fester_header_versteckt_beim_scrollen`); dazu späte Platzhalter (Einzelseiten-Apps) über einen entprellten MutationObserver (Fixrunde `aa9ee35`).
- [x] N6 Abo-Abgleich: K30 gilt als offen, solange `aboEndeMailAt` vor `disabledAt` liegt (das Zurücksetzen nach `disable()` entfällt); K30 geht erst nach dem Ersatz-Ticket raus, scheitert es, wiederholt der nächste Lauf beides (K30 so höchstens einmal); `activatedAt` wird bei der Löschung geleert (Fixrunde `ecf67d6`).
- [ ] N7 Team: Sperre `zeiten-ersatz:<docId>` gegen zwei Ersatz-Tickets (paralleles Abschalten und Abgleich); `PATCH …/zeiten/team` nur für `setup`/`active`/`disabled` (409 sonst); `createFeatureTicket` mit `fallbackDetails` für das lange Ersatz-Ticket.
- [x] N8 WOMS „Ticket nachlegen“ bei Abo- oder Team-Zeiten ohne `ticketId`: das Portal geht über `holeZeitenNach` (Fixrunde `ecf67d6`). Offen bleiben die Texte „keine Zahlung vermerkt“ (bei gekauften Zeiten ohne Zahlungsvermerk) und der 409 der Abnahme („noch keine Öffnungszeiten eingetragen“).
- [ ] N9 Löschen: in `loescheFunktion` erst die Merker, dann das Dokument; fehlgeschlagene Löschung in `retention.js` beim nächsten Lauf wiederholen.
- [ ] N10 `heileZeiten` in `try/finally`, wenn `activateClosedTickets` wirft.
- [ ] N13 Nach dem Löschen (Abo-Ende + 30 Tage, Kundenlöschung) setzt `loesche()` keinen Rückgabe-Marker wie TP2 (`purchaseStatus: 'zurueckgesetzt'`): eine von Stripe erneut zugestellte `checkout.session.completed` einer früher bezahlten Zeiten-Bestellung würde neu einrichten (Ticket, K15, T16). Im Pilot praktisch ausgeschlossen (Merge-Bericht 06.10.2026, Punkt 3).
**Qualität (kann nach `on`)**
- [ ] N11 Zeitlogik: `wechselListe` um Schwellen (Ende minus 24 h und minus 7 Tage) ergänzen (Texte wie „bis Sonntag Mitternacht“ bleiben sonst den ganzen Sonntag stehen); Mitternachts-Einträge nicht zusammenfassen („heute“ hinkt sonst bis zu 10 Minuten nach).
- [ ] N12 Oberfläche: Portal-Knopf auf dem Desktop 40 px hoch (Ziel 44 px); Relaunch-Stationsliste kann bei 721–780 px Fensterhöhe überlaufen (Schwelle prüfen).
**Weitere Punkte der Fixrunde nach der Abschlussprüfung (erledigt)**
- [x] `activate()` schreibt die Zeiten-Felder nur noch über `extra` (Abo-Rückkehr und Team-Aktivierung der Zeiten): News und Terminplaner brechen auch vor dem Schema-Skript nicht mehr mit „Unknown attribute“.
- [x] Die Einschalten-Route (`/api/funktionen/zeiten/einschalten`) nimmt nur `{ zustimmung }`, nicht die Altform `{ consent: true }` ohne Fassungsprüfung.
- [x] Die Team-Aktivierung behält `kauf` und `unlockedByPurchase`, wenn das Dokument vorher gekauft war (die Widerrufs-Zuordnung bleibt richtig; `einbau` bleibt dabei stehen, weil für gekaufte Dokumente das Nachholen nicht neu baut).
- [x] WOMS: „Leisten live – Einbau läuft“ nur bei `einbau` = `angestossen`, sonst „Einbau läuft (Team setzt die Skript-Zeile von Hand)“ (`919d96f`).
- [x] Editor: die Bestätigung klebt unter der Kopfleiste und bleibt nach dem Speichern eines unteren Abschnitts sichtbar; Website-Skript: Links mit Zugangsdaten werden verworfen, die Adresse der Schnittstelle bestimmt nur das eigene Script-Tag.
## F. Bewusste Abweichungen und Hinweise
- Schema der neuen `portalFeatures`-Felder liegt in `scripts/zeiten-setup.mjs` (nicht in `feature-setup.mjs`), weil dessen Tests genau drei Attribute festschreiben.
- Das Angebot liefert `GET /api/funktionen` als `zeiten` auf oberster Ebene plus `zeitenStatus` je Projekt (die Spec nennt `zeitenAngebot` je Projekt); die Kachel entscheidet nach `inklusive` und `grund` (Ruling R20).
- Der Editor einer abgeschalteten Funktion (`disabled`) ist nur lesbar (Spec Z13 sagt „nie gesperrt“); die Website zeigt dann ohnehin nichts mehr.
- Der Block `funktionen` in K02/K03 ist bewusst nur ein Entwurf (Rechtstext mit Pins).
- Schalter aus (`off`): `GET /api/public/zeiten/v1/<siteKey>` antwortet `aktiv: false`; die Jobs für Abo-Abgleich laufen unabhängig vom Schalter (ein beendetes Abo muss abschalten und löschen können).
- Wie im Terminplaner-Livegang: den Code zu einer ruhigen Zeit deployen.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,346 @@
# Webshop (Funktion `shop`) – Umsetzungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Kunden fragen im Portal einen Webshop an, das Team gibt frei, der Kunde bestellt die Einrichtung (299 € einmalig),
verbindet sein eigenes Stripe-Konto (Connect), pflegt Artikel/Versand/Rechtstexte; Käufer bestellen im Widget auf der
Website und zahlen über Stripe Checkout direkt an den Kunden; Bestellungen, Versand, Erstattung, Widerrufe im Portal.
**Spec:** `docs/superpowers/specs/2026-10-05-webshop-design.md` (Entscheidungen W1–W12). **Fakten:**
`/root/reports/webshop-2026-10-05/fakten-server.md`, `fakten-frontend.md`, `fakten-woms.md`, `recherche-recht-stripe.md`
(jeder Implementer liest die Abschnitte, die sein Task nennt).
**Worktrees:** Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-webshop` (Branch `feature/webshop` von
`feature/terminplaner-tp2` af63b69; `node_modules` ist ein Symlink, nie `git add -A`). WOMS: neuer Worktree
`/home/webklar/apps/ticket.webklar.com/dev-webshop`, Branch `feature/webshop` von `feature/terminplaner-tp2` (Block I).
## Global Constraints
- Nichts pushen, nichts in Produktion schreiben, keine Live-Stripe-Aufrufe, keine echten Mails. Dateien explizit stagen.
- **Speicher:** jeder Node-Lauf mit `node --max-old-space-size=1024` und `timeout` (Einzeltests `timeout 300`); nie die ganze
Suite parallel mehrfach; vor schweren Läufen `free -m`. Ganze Suite nur der Controller.
- Node-20-tauglich. `config.*` bei jedem Aufruf lesen (Tests ändern Werte).
- Tests: `node --test test/<datei>.test.js`; Mocks aus `test/support/` (`startAppwriteMock`, `startFakeStripe`, `mailSink`,
`useTestEnv` vor dem ersten Import von config, `startTestApp`, `customerCookie`); nie echte Dienste.
- Pitfall Shell: `grep` ist ein ugrep-Wrapper, der Dateien still überspringt → `command grep -a`.
- Kundentexte (Portal) Du-Form; Texte für Käufer (Widget, Käufer-Mails, Muster-Rechtstexte) **Sie-Form**; Rechtstexte für
WEBklar-Kunden (LB/AVV) Sie-Form wie die vorhandenen. Fremdtext nur per `textContent`; `href` nur geprüfte https-URLs.
- Neue Server-Dienste unter `server/services/shop/`, Front-End-Dateien `public/shop*.js`, `public/shop.css` (nie `termine*`).
- Geld immer in ganzen Cent (`Number.isInteger`), nie Gleitkomma-Summen; Preise aus der Datenbank, nie aus dem Browser.
- Appwrite-Felder: kein Feld namens `data`; Dokument-IDs ≤ 36 Zeichen `[A-Za-z0-9][A-Za-z0-9._-]*`.
- Käufer-PII nie in Logs; Fehlertexte an Browser nur eigene deutsche Texte.
- **Recht aus `recherche-recht-stripe.md` (verbindlich):** Stripe Accounts **v1** mit `controller`-Eigenschaften (v2 nicht nötig); Webhook prüft `livemode` (Live-Schlüssel ⇒ nur `livemode:true`);
kein PayPal; SEPA asynchron (Abschluss erst bei `async_payment_succeeded`). Harmonisierter **Hinweis auf die gesetzliche Gewährleistung** (VO (EU) 2025/1960 Anhang I, Art. 246a § 1 Abs. 1 Nr. 11 EGBGB, seit 27.09.2026)
hervorgehoben in Kasse (über dem Knopf) und in G09 – Text in `shop/rechtstexte.js` `gewaehrleistungsHinweis()` (Wortlaut nach Anhang I, Kanzlei-Frage, Grafik später).
Ausgeschlossene Waren (LB + Anfrage): Tabak/E-Zigaretten, Alkohol, Arzneimittel, Waffen/Feuerwerk, Erotik, lebende Tiere, digitale Inhalte, Gutscheine.
## Schnittstellen über Blockgrenzen (verbindlich)
- `config.shop` (A1): `{ mode: SHOP_ENABLED ('off'|'pilot'|'on', Standard 'off'), pilotEmails: SHOP_PILOT_EMAILS (Liste, '*' = alle),
fassung: '2026-10-05', uploadDir: SHOP_UPLOAD_DIR || <cwd>/upload-data/shop, connectWebhookSecret: STRIPE_CONNECT_WEBHOOK_SECRET || '',
bestellAblaufMinuten: 30, maxArtikel: 500, maxBilder: 4, maxVarianten: 30, anonymisierenJahre: 3, loeschTageNachAbschalten: 90,
unbezahltLoeschTage: 30 }`. `config.shopSetup = { key:'shopSetup', name:'Webshop – Einrichtung', amountCents:29900,
lookupKey:'webklar_shop_einrichtung_29900', oneTime:true, priceNickname:'Webshop-Einrichtung 299 €' }`.
`config.collections.shopConfig|shopProducts|shopOrders|shopWiderrufe` (Env-Override `APPWRITE_COLLECTION_SHOP_*`).
- `server/services/shop/schalter.js` (A1): `shopModus()`, `shopFuerKunde(email) ⇒ boolean` (Muster `termine/schalter.js`).
- `server/services/shop/schema.js` (A2): `SHOP_COLLECTIONS` (reine Daten, kein config-Import; Muster `termine/schema.js`), `SHOP_DATETIMES`.
- Register `config.features.shop` (A3) und `STANDARD_HAKEN.shop` (A3).
- `server/services/shop/geld.js` (C1, rein): `euro(cent) ⇒ '12,50 €'` (Tausenderpunkt, geschütztes Leerzeichen vor €),
`parseEuro(text|number) ⇒ cent|null`, `grundpreisText({ preisCent, menge, einheit }) ⇒ string|''`.
- `server/services/shop/einstellungen.js` (C2): `STANDARD_EINSTELLUNGEN`, `normalisiereEinstellungen(eingabe) ⇒ { einstellungen } | { fehler: {feld: text} }`.
- `server/services/shop/produkte.js` (C3): `normalisiereProdukt(eingabe, { einstellungen }) ⇒ { produkt } | { fehler }`,
`produktOeffentlich(doc, { einstellungen, reserviert }) ⇒ object`, CRUD `listeProdukte(shopId)`, `holeProdukt(shopId, id)`,
`speichereProdukt(shop, id|null, produkt)`, `loescheProdukt(shop, id)`, `sortiere(shop, ids)`.
- `server/services/shop/preise.js` (C4, rein): `berechne({ produkte, positionen, lieferart, land, einstellungen, reserviert }) ⇒ { ok:true, ... } | { ok:false, fehler }` (Felder unten in C4).
- `server/services/shop/rechtstexte.js` (C5): `musterWiderrufsbelehrung(einstellungen)`, `musterWiderrufsformular(einstellungen)`,
`musterAgb(einstellungen)`, `versandUndZahlung(einstellungen)`, `normalisiereRechtstexte(eingabe) ⇒ { rechtstexte } | { fehler }`,
`rechtstexteHash(rechtstexte, einstellungen)`.
- `server/services/shop/kontext.js` (C6): `ladeShopFuerProjekt(customerId, projectId) ⇒ { doc /*portalFeatures*/, shop /*shopConfig, geparst*/ } | null`,
`shopPerSiteKey(siteKey) ⇒ ctx | null` (30-s-Cache, `leereCache(siteKey)`), `bereitschaft(ctx) ⇒ { bereit, punkte: [{ id, ok, text }] }`,
`istGeoeffnet(ctx) ⇒ boolean`. ctx = `{ doc, shop: { id, customerId, projectId, siteKey, einstellungen, rechtstexte, rechtstexteBestaetigt, stripeAccountId, stripeStatus, geoeffnet, anfrage... }, project }`.
- `server/services/shop/connect.js` (D1): `kontoAnlegen({ customer, project }) ⇒ accountId`, `onboardingLink(accountId, { returnUrl, refreshUrl }) ⇒ url`,
`kontoStatus(accountId) ⇒ stripeStatus`, `checkoutAnlegen(accountId, params) ⇒ { id, url }`, `sessionHolen(accountId, id)`,
`erstatten(accountId, { paymentIntent, betragCent, grund }) ⇒ refund`, `connectEreignis(rawBody, signatur) ⇒ event` (wirft 400/503).
Alle Aufrufe mit `{ stripeAccount: accountId }` als Request-Option über `getStripe()`.
- `server/services/shop/bestellungen.js` (E1–E3): `legeBestellungAn(ctx, eingabe, { origin, now })`, `schliesseAb({ accountId, session, now }) ⇒ { order, neu }`
(idempotent, einzige Stelle für Status `bezahlt`), `verwirf(orderId, grund)`, `setzeStatus(shop, orderId, aktion, extra)`,
`erstatte(shop, orderId, { betragCent, grund })`, `listeBestellungen(shopId, filter)`, `bestellungKunde(order)`, `bestellungOeffentlich(order)`, `csv(shopId, von, bis)`,
`reserviert(shopId, now) ⇒ Map<schluessel, menge>` (Schlüssel `produktId` oder `produktId:variantId`).
- Bestellstatus: `offen` (wartet auf Zahlung) → `bezahlt` → `versendet` | `abholbereit` → `abgeschlossen`; `storniert` (nicht bezahlt / abgelaufen / Zahlung fehlgeschlagen);
Zusatzfelder in `daten.erstattungen[]` (Teil-/Vollerstattung, Status `erstattet` wenn voll erstattet).
- Mails (E4): Käufer **G09** Bestellbestätigung, **G10** Versand, **G11** Abholbereit, **G12** Eingang Widerruf, **G13** Erstattung,
**G14** Zahlung fehlgeschlagen; Händler **K25** Neue Bestellung, **K26** Widerruf eingegangen (beide `bccTeam:false`),
**K24** Anfrage-Entscheidung (Blöcke `angenommen`, `abgelehnt`); Team **T21** Neue Webshop-Anfrage (Betreff `[Webshop] …`).
Käufer-Mails über die Terminplaner-Warteschlange (`termine/outbox.js einreihen`, `featureId` = portalFeatures-ID des Shops, `bookingId` = Bestell-ID).
- Öffentliche API (F1–F3) unter `/api/public/shop/v1/:siteKey`: `GET /` (Konfiguration + Artikel), `GET /bild/:datei`, `POST /bestellung`,
`GET /bestellung/:id?t=`, `POST /widerruf`. Kundenroute `/api/shop/*` (B, C7, D2, E5). Team `/api/team/shop/*` (B2). Connect-Webhook
`POST /api/shop/stripe-webhook` (D3). Seiten `GET /shop/:siteKey`, `GET /shop/:siteKey/widerruf` (H2).
---
## Vorab (Controller)
- [ ] V1 Ledger `.superpowers/sdd/2026-10-05-webshop/progress.md` anlegen; Basislauf der betroffenen Testdateien (nicht die ganze Suite):
`featureRegistry, funktionenCheckout, siteFeatures, mailTemplates, legalDocs, buildLegalHtml, stripeBilling, stripeProductsScript, funktionenTiles, termineView`.
---
## Block A: Grundlagen
### Task A1: Konfiguration und Schalter
**Dateien:** `server/config.js`, `server/services/shop/schalter.js` (neu), `test/shopSchalter.test.js` (neu).
- `shopSetup` (Katalog) und `config.shop` wie oben; `config.collections.shop*`; Export auf `config`.
- `schalter.js`: `shopModus()` liest `config.shop.mode` (unbekannt ⇒ 'off'); `shopFuerKunde(email)`: 'on' ⇒ true, 'pilot' ⇒ E-Mail (klein) in `pilotEmails` oder `'*'` enthalten, sonst false.
- `stripeBilling.js`: `PRODUCT_METADATA.shopSetup = 'shop-einrichtung'`, `catalogFor('shopSetup')`; `scripts/stripe-products.mjs` KEYS/catalogEntry um `shopSetup`;
Tests `stripeProductsScript.test.js`, `stripeBilling.test.js` gezielt erweitern (Muster termineSetup).
- Tests: Modi, Pilotliste, Katalogwerte.
### Task A2: Schema und Setup-Skript
**Dateien:** `server/services/shop/schema.js` (neu), `scripts/shop-setup.mjs` (neu, Muster `scripts/termine-setup.mjs`, `--check`), `test/support/schema.js` (`schemaKeys` für die vier Collections), `test/shopSetup.test.js` (neu, Muster `test/termineSetup.test.js`).
Collections (alle ohne Client-Rechte, Felder `required:false`):
- `shopConfig`: customerId 36, projectId 36, siteKey 16, anfrage str 4000, anfrageStatus 16, anfrageAt dt, anfrageEntschiedenAt dt, anfrageEntschiedenVon 255,
anfrageBegruendung 1000, stripeAccountId 64, stripeStatus 4000, einstellungen 20000, rechtstexte 100000, rechtstexteBestaetigtAt dt,
rechtstexteBestaetigtVon 255, rechtstexteHash 64, geoeffnet bool, geoeffnetAt dt, version int. Indexe: idx_customer(customerId), idx_sitekey(siteKey), idx_anfrage(anfrageStatus), idx_account(stripeAccountId).
- `shopProducts`: shopId 36, customerId 36, aktiv bool, sort int, name 120, produkt 30000. Indexe idx_shop(shopId, sort).
- `shopOrders`: shopId 36, customerId 36, bestellnummer 16, status 16, tokenHash 64, stripeSessionId 128, paymentIntentId 64, summeCent int,
erstelltAt dt, ablaufAt dt, bezahltAt dt, kaeuferEmail 320, bestellung 60000, anonymisiertAt dt. Indexe idx_shop_status(shopId,status), idx_nummer(shopId,bestellnummer), idx_session(stripeSessionId), idx_erstellt(shopId, erstelltAt).
- `shopWiderrufe`: shopId 36, customerId 36, bestellnummer 32, name 200, email 320, nachricht 2000, eingangAt dt, bestaetigungAt dt, bearbeitetAt dt, orderId 36. Index idx_shop(shopId, eingangAt).
- Test: Skript legt im Mock an, `--check` meldet Lücken, zweiter Lauf idempotent.
### Task A3: Register-Eintrag `shop` und Haken
**Dateien:** `server/config.js` (`features.shop`), `server/services/featureRegistry.js` (`STANDARD_HAKEN.shop`), `server/services/legalDocs.js`
(`SAETZE.shop = { lb:{slug:'leistungsbeschreibung-webshop',pdf:'Leistungsbeschreibung-Webshop'}, avv:{slug:'avv-webshop',pdf:'AVV-Webshop'}, widerruf:{ wie termine } }`,
`FUNKTION_SATZ.shop='shop'`, `aktuelleFassung('shop') = config.shop.fassung`), Tests `featureRegistry.test.js`, `legalDocs.test.js`, `siteFeatures.test.js`,
`funktionenRoutes.test.js`, `featureFreischaltung.test.js`, `funktionenTermine.test.js` (Stellen mit festem `['news','termine']` laut fakten-woms §3 → `['news','termine','shop']`).
Registereintrag (Muster `termine`; Rechtsunterlagen-Getter wie `termineRechtsunterlage`, aber mit `config.shop.fassung`; config importiert keine Dienste):
`key:'shop'`, `product:'shopSetup'`, `label:{ name:'Webshop', kurz:'Shop', keine:'keinen Webshop' }`, `priceLabel:'299 €'`, `setupEstimate:'meist innerhalb von 2–3 Wochen'`,
`consent` (Fassung = `config.shop.fassung`; `knopf:'Zahlungspflichtig bestellen · 299 €'`; Items in dieser Reihenfolge, alle `haken:true`):
1. `unterlagen`: „Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung <F> zu.“
2. `verantwortung`: „Ich verkaufe als Unternehmer in eigenem Namen. Für meine Produkte, Preise, Produktangaben und Rechtstexte (AGB, Widerrufsbelehrung, Impressum, Datenschutzerklärung) bin ich selbst verantwortlich; WEBklar stellt Muster bereit und berät nicht rechtlich. Ich verkaufe keine der in der Leistungsbeschreibung ausgeschlossenen Waren.“
3. `stripe`: „Die Zahlungen meiner Käufer laufen über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält und verwahrt kein Geld meiner Käufer und erhebt keine Umsatzbeteiligung.“
4. `beginn`: Wortlaut wie Terminplaner `beginn`, mit „Einrichtung des Webshops“.
5. `information`: „Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Webshops für diese Website: Einbau des Shop-Widgets, Verwaltung im Portal, Muster-Rechtstexte), Gesamtpreis 299 € einmalig (<ustHinweis>), voraussichtliche Dauer meist innerhalb von 2–3 Wochen, keine laufende Gebühr von WEBklar; der Webshop läuft, solange WEBklar die Website betreibt. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.“
`returnHash:'webshop'`, `returnProjekt:true`, `ticket: { topic: (p) => 'Webshop einbauen – <Projekt>', details: async (ctx) => (await import('./services/shop/ticketDetails.js')).shopTicketDetails(ctx) }`,
`mails:{ funktion:'Webshop', formen:{ dein:'Dein Webshop', nom:'der Webshop', akk:'den Webshop', gen:'des Webshops', pron:'ihn' }, knopf:'Webshop öffnen',
danach:'Danach kannst Du im Portal Artikel anlegen, Dein Stripe-Konto verbinden und den Shop öffnen.', bereit:'Dein Webshop ist eingebaut. Sobald Stripe-Konto, Artikel und Rechtstexte fertig sind, kannst Du ihn im Portal öffnen.',
preisHinweis:'einmalig, <ustHinweis>; keine laufende Gebühr von WEBklar, Stripe-Gebühren zahlst Du direkt an Stripe', anleitung:false }`,
`requiresWartungsAbo:false`, `ticketFreischaltung:true`, `disabledMessage:'Der Webshop ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.'`,
`orderedConflictMessage:'Der Webshop ist für dieses Projekt schon bestellt.'`, `unavailableMessage:'Der Webshop ist noch nicht verfügbar.'`.
`server/services/shop/ticketDetails.js` (neu): Kopf (Funktion, Projekt, Domain, Kunde, siteKey), Anfrage-Kurzfassung (Warenart, Versand/Abholung, Artikelzahl; aus shopConfig, Lesefehler ⇒ ohne), Snippet
`<div data-webklar-shop="<siteKey>"></div>` + `<script src="https://project.webklar.com/embed/shop.js" defer></script>`, Schritte (Shop-Seite + Menüpunkt anlegen, Snippet einsetzen, auf **allen** Seiten im Footer Link „Vertrag widerrufen“ → `https://project.webklar.com/shop/<siteKey>/widerruf`, Datenschutz-Absatz Webshop (`docs/funktionen/shop-datenschutz-absatz.md`) einfügen, Impressum prüfen, Vorschau `https://project.webklar.com/shop/<siteKey>` prüfen, Ticket schließen ⇒ Freischaltung), „Zeit im Arbeitsblatt, nicht auf Abo-Stunden“, Verweis WOMS (`anleitungsLink`).
Haken `STANDARD_HAKEN.shop`: `verfuegbar: (email) => shopFuerKunde(email)`, `bestellpruefung` (dynamisch import `./shop/anfrage.js` `anfrageOffenePunkte({customerId, projectId})` ⇒ `[]` wenn Anfrage `angenommen`, sonst `['Deine Webshop-Anfrage ist noch nicht freigegeben.']`), `unterlagenBereit: () => featureLegalReady('shop')`, `belehrung: termine-Belehrung (gleiche Quelle)`, kein `freischaltbar` (geschlossenes Ticket genügt).
Für A3 genügt in `shop/anfrage.js` ein Stub mit `anfrageOffenePunkte`, den Block B ausbaut. `featureConsentTexte.test.js`: Hash-Pin für shop ergänzen (Muster termine).
Tests: Registerwerte, `oeffentlicheAnsicht('shop')`, Haken, legalPaths('shop'), `featureLegalReady('shop')` false solange Dateien fehlen (Block J legt sie an), Ticket-Texte.
---
## Block B: Anfrage („auf Anfrage“)
### Task B1: Anfrage-Dienst und Kundenroute
**Dateien:** `server/services/shop/anfrage.js`, `server/routes/shop.js` (neu, Mount `/api/shop` in `server/index.js` neben `/api/termine`), `server/middleware/kundenansichtGuard.js`, `test/shopAnfrage.test.js`.
- `shopConfig`-Dokument-ID = portalFeatures-ID (`ensureFeatureDoc(customerId, projectId, 'shop')` liefert sie); `ensureShopConfig(featureDoc)` legt es an (siteKey kopiert).
- `normalisiereAnfrage(eingabe) ⇒ { anfrage } | { fehler }`: `warenart` ∈ `waren|lebensmittel|kleidung|kunst|sonstiges`, `beschreibung` 10–1500 Zeichen, `artikelzahl` ∈ `bis20|bis100|mehr`,
`versand` bool, `abholung` bool (mind. eins), `unternehmer === true`, `keineAusgeschlossenen === true`, `nachricht` ≤ 1000.
- `stelleAnfrage({ customerId, projectId, eingabe, person, now })`: Projekt gehört Kunde (`assertOwnProject(..., {forNewOrder:true})`), Schalter `shopFuerKunde`, nur wenn Status `''|abgelehnt|zurueckgezogen` (sonst 409),
speichert JSON + Status `offen` + `anfrageAt`, schickt **T21** an das Team (`sendAdminNotice`, Fehler nur loggen).
- `zieheZurueck` (nur `offen`), `anfrageAnsicht(shop)`, `anfrageOffenePunkte({customerId, projectId})`.
- Routen (requireSession, viewAs ⇒ 403 für POST): `GET /api/shop/status?projectId=` ⇒ `{ verfuegbar, anfrage: {status, anfrage, at, begruendung} | null, state, ... }` (Grundgerüst, C7 erweitert),
`POST /api/shop/anfrage {projectId, anfrage}`, `POST /api/shop/anfrage/zurueckziehen {projectId}`. Rate-Limit 20/h je Kunde (`createWriteLimiter`).
- Vorlage `server/mail-templates/T21 Neue Webshop-Anfrage.html` (Muster T16-Layout; Betreff `[Webshop] Neue Anfrage: {{kunde_name}}`; Platzhalter kunde_name, email, projekt, warenart, beschreibung, artikelzahl, lieferung, nachricht, woms_url);
`test/mailTemplates.test.js`: EXPECTED, `[Webshop]` in die T-Betreff-Regex, TEAM_FOOTER.
### Task B2: Team-Entscheidung und K24
**Dateien:** `server/routes/team/shop.js` (neu, Mount in `server/routes/team/index.js` als `/shop`), `server/services/shop/anfrage.js`, `server/mail-templates/K24 Webshop-Anfrage.html`, Tests.
- `GET /api/team/shop/:customerId` ⇒ je Projekt `{ projectId, projectName, anfrage, stripe: {accountId?, status}, geoeffnet, zahlen: { artikel, offeneBestellungen } }`.
- `POST /api/team/shop/:customerId/anfrage { projectId, entscheidung: 'annehmen'|'ablehnen', begruendung }` (Begründung Pflicht bei ablehnen, ≤ 1000) unter `withLocks`;
nur aus `offen`; speichert Entscheidung, Person (`req.session.name/email`), Zeit; **K24** an den Kunden (Blöcke `angenommen` mit Link `dashboard.html#webshop?projekt=<id>` und Preis 299 €, `abgelehnt` mit Begründung).
- Tests: Annehmen/Ablehnen, falscher Zustand 409, Mail-Inhalte, Bestell-Haken liefert danach `[]`.
---
## Block C: Shop-Daten und Kundenverwaltung (Server)
### Task C1: Geld und Grundpreis (rein)
`server/services/shop/geld.js` + `test/shopGeld.test.js`: `euro`, `parseEuro` ('12,5' / '12,50 €' / '1.234,56' / 12.5 ⇒ Cent; negative, > 100000 €, mehr als 2 Nachkommastellen ⇒ null),
`grundpreisText({preisCent, menge, einheit})`: Einheiten `g|kg|ml|l|m|m2`; Bezug 1 kg / 1 l / 1 m / 1 m² (g/ml ⇒ kg/l); Ergebnis „(12,50 € / 1 kg)“; ohne Menge ''; Rundung kaufmännisch auf Cent.
### Task C2: Einstellungen (rein + Speichern)
`server/services/shop/einstellungen.js` + Tests. Felder: `shopname` (1–80), `anbieter { name, strasse, plz, ort, land:'DE'|'AT', email, telefon, ustId? }` (Pflicht außer ustId/telefon),
`kleinunternehmer` bool, `versand { aktiv, laender: ['DE'] | ['DE','AT'], kostenCent { DE, AT? }, freiAbCent|null }`, `abholung { aktiv, adresse, hinweis ≤ 300 }`
(mind. eins aktiv), `lieferzeit` (Pflicht, 3–80, z. B. „2–4 Werktage“), `zahlungsarten`: Teilmenge von `card|klarna|sepa_debit` (card immer enthalten; PayPal geht bei Connect-Direct-Charges laut Stripe NICHT; SEPA nur mit Warnung „Zahlung erst nach Tagen sicher, Ware erst nach Zahlungseingang versenden“),
`akzent` (#rrggbb, Standard #111111), `mindestbestellCent|null`, `ruecksendekosten: 'kaeufer'|'haendler'`.
`speichereEinstellungen(shop, einstellungen)` erhöht `version`, leert Kontext-Cache. Fehlerobjekt je Feld (deutsche Texte, Du-Form).
### Task C3: Produkte (rein + CRUD)
`server/services/shop/produkte.js` + Tests. Produktfelder: `name` 1–120, `kategorie` ≤ 40, `kurz` ≤ 300, `beschreibung` ≤ 5000, `preisCent` (> 0, ≤ 10 000 000),
`steuersatz` ∈ 0|7|19 (bei Kleinunternehmer ignoriert ⇒ 0), `grundpreis {menge, einheit} | null`, `bestand` int ≥ 0 | null (unbegrenzt), `aktiv`,
`varianten { name ≤ 30, werte: [{ id (8 Zeichen, server), name ≤ 40, preisCent, bestand|null }] ≤ 30 } | null` (mit Varianten gilt Preis/Bestand der Variante),
`bilder: [imageId] ≤ 4`, `lieferart: 'beides'|'abholung'`, `widerruf: 'gesetzlich'|'verderblich'|'individuell'|'hygiene'`,
`sicherheit { hersteller: 'selbst' | { name, anschrift, kontakt }, euVerantwortlicher? { name, anschrift, kontakt }, hinweise ≤ 2000 }`,
`lebensmittel { aktiv, pflichtangaben (Pflicht wenn aktiv, ≤ 4000) }`.
CRUD auf `shopProducts` (Feld `produkt` = JSON), Grenze `config.shop.maxArtikel`; `produktOeffentlich` liefert nur Anzeigefelder + `preisText`, `grundpreisText`,
`verfuegbar` (Bestand minus Reservierung > 0 bzw. je Variante), `ausverkauft`, `widerrufText` (z. B. „Vom Widerruf ausgeschlossen: schnell verderbliche Ware“), Bild-URLs
`/api/public/shop/v1/<siteKey>/bild/<imageId>-s.webp` und `-l.webp`.
### Task C4: Warenkorb-Berechnung (rein)
`server/services/shop/preise.js` + Tests (viele Fälle). `berechne({ produkte /*Map id⇒produkt mit id*/, positionen: [{ produktId, varianteId?, menge }], lieferart: 'versand'|'abholung', land, einstellungen, reserviert })`:
Prüft: 1–50 Positionen, Menge 1–99 ganzzahlig, Produkt aktiv, Variante passt, Bestand (minus `reserviert`) reicht, `abholung` nur wenn aktiv; `versand` nur wenn aktiv, Land erlaubt und **kein** Artikel `lieferart:'abholung'`;
Mindestbestellwert. Versandkosten = `kostenCent[land]`, 0 ab `freiAbCent` (Zwischensumme ≥ Schwelle) oder bei Abholung. Steuer: bei Kleinunternehmer keine; sonst je Satz enthaltene USt = round(brutto * satz / (100 + satz)) je Satz über die Summe der Positionen,
Versandkosten anteilig nach Bruttowert der Sätze (Rest-Cent auf den höchsten Satz). Ergebnis `{ ok:true, positionen:[{ produktId, varianteId, name, varianteText, menge, einzelCent, summeCent, steuersatz, widerruf, einzelText, summeText }], zwischensummeCent, versandCent, gesamtCent, steuer:[{ satz, betragCent, text }], texte:{ zwischensumme, versand, gesamt, steuerHinweis } }`
(`steuerHinweis`: „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.“ oder „inkl. 19 % USt: 3,19 €; inkl. 7 % USt: …“), Fehler `{ ok:false, fehler: { code, text, produktId? } }` mit Sie-Texten für Käufer.
### Task C5: Muster-Rechtstexte des Händlers
`server/services/shop/rechtstexte.js` + `docs/funktionen/shop-muster-rechtstexte.md` (Quelle, gleicher Wortlaut, Kopf „Muster – keine Rechtsberatung“) + Tests (Pins).
- `musterWiderrufsbelehrung(e)`: gesetzliches Muster für Waren (Anlage 1 zu Art. 246a § 1 Abs. 2 S. 2 EGBGB) in aktueller Fassung **inkl. Hinweis auf die Widerrufsfunktion** (nach `recherche-recht-stripe.md` B2/B3),
Anbieter-Name/Anschrift/E-Mail eingesetzt, Variante Rücksendekosten (Käufer trägt unmittelbare Kosten / Händler trägt), Fristbeginn für eine Ware bzw. mehrere getrennt gelieferte Waren (ein Text mit beiden Varianten laut Gestaltungshinweis),
Ausschlussgründe als Absatz „Ausschluss des Widerrufsrechts“ (§ 312g Abs. 2 Nr. 1, 2, 3 BGB).
- `musterWiderrufsformular(e)` (Anlage 2). `musterAgb(e)`: kurze Muster-AGB (Geltung, Vertragspartner, Vertragsschluss: Bestellung durch Klick „Zahlungspflichtig bestellen“, Annahme durch Bestellbestätigung per E-Mail nach Zahlungseingang; Preise und Versandkosten; Zahlung über Stripe, Zahlungsarten; Lieferung, Lieferzeit, Liefergebiet, Abholung; Eigentumsvorbehalt; Widerrufsrecht Verweis; Gewährleistung gesetzlich; Vertragstext wird nicht vom Anbieter gespeichert, Bestellbestätigung enthält ihn; Sprache Deutsch; Streitbeilegung: keine Bereitschaft/Verpflichtung zur Teilnahme an Verbraucherschlichtung (§ 36 VSBG)).
- `versandUndZahlung(e)` (erzeugt, nicht frei editierbar). `normalisiereRechtstexte({ agb ≤ 30000, widerrufsbelehrung ≤ 20000, widerrufsformular ≤ 5000, impressumUrl, datenschutzUrl (https) })`, `rechtstexteHash`.
### Task C6: Kontext und Bereitschaft
`server/services/shop/kontext.js` + Tests: Laden per Projekt und per siteKey (`findActiveBySiteKey(siteKey,'shop')` + shopConfig + Projekt), JSON-Felder sicher parsen (kaputt ⇒ Standard), Cache 30 s.
`bereitschaft(ctx)` Punkte: `einbau` (state active), `stripe` (`stripeStatus.charges_enabled`), `rechtstexte` (bestätigt und Hash = aktueller Hash), `einstellungen` (normalisiert ok), `artikel` (≥ 1 aktiv und lieferbar), jeweils mit Du-Text.
`istGeoeffnet(ctx)` = `shop.geoeffnet && alle Punkte außer rechtstexte-Hash-Gleichheit ok && rechtstexteBestaetigt vorhanden` (nach Spec 4.6: geändert, aber früher bestätigt ⇒ bleibt offen mit der zuletzt bestätigten Fassung; dafür speichert C7 beim Bestätigen eine Kopie `rechtstexteBestaetigt` in `rechtstexte` JSON: `{ entwurf, bestaetigt }`).
### Task C7: Kundenrouten Verwaltung
`server/routes/shop.js` + Tests (`test/shopRoutes.test.js`). Alle mit requireSession + eigenes Projekt + Funktion `setup|active` (lesend auch `disabled`), Rate-Limit 300/h je Kunde, Kundenansicht: Lesen ja; Schreiben von Artikeln/Einstellungen/Rechtstexten erlaubt (Guard-Liste ergänzen), **nicht** Stripe, Öffnen, Erstatten.
- `GET /api/shop/status?projectId=` (vollständig): Funktion (state, ticket), Anfrage, Bereitschaft, geoeffnet, stripe (Status ohne Geheimnisse), Zahlen.
- `GET|PUT /api/shop/einstellungen`, `GET /api/shop/produkte`, `POST /api/shop/produkte`, `PUT /api/shop/produkte/:id`, `DELETE /api/shop/produkte/:id`, `POST /api/shop/produkte/sortierung {ids}`.
- `GET /api/shop/rechtstexte` (inkl. `muster` zum Zurücksetzen und `versandUndZahlung`), `PUT /api/shop/rechtstexte` (speichert Entwurf), `POST /api/shop/rechtstexte/bestaetigen { hash }` (Haken-Text aus Spec 4.6; speichert Kopie, Zeit, Person, Hash).
- `POST /api/shop/oeffnen { projectId, offen: bool }` (öffnen nur wenn `bereitschaft.bereit`; schließen immer).
- Bilder: `POST /api/shop/bild?projectId=` (octet-stream ≤ 8 MB, Muster news.js) über `server/services/shop/bilder.js` (aus `newsImages.js` verallgemeinert: Wurzel `config.shop.uploadDir/<siteKey>/`, 1600/480 px WebP, Sidecar, `claim` beim Produkt-Speichern, `purgeUnclaimed` 24 h, `deleteShopImage`, `deleteShopImages(siteKey)`); Vorschau `GET /api/shop/bild/:projectId/:datei`.
---
## Block D: Stripe Connect
### Task D1: Connect-Dienst + Fake-Stripe
`server/services/shop/connect.js`, `test/support/fakeStripe.js` (Header `Stripe-Account` aufzeichnen; `POST /v1/accounts`, `GET /v1/accounts/:id`, `POST /v1/account_links`, Checkout-Sessions je Konto, `POST /v1/refunds`; vorhandenes Verhalten unverändert), `test/shopConnect.test.js`.
- `kontoAnlegen`: `accounts.create({ country:'DE', email, business_profile:{ url: liveUrl|undefined, name: projectName }, controller:{ fees:{payer:'account'}, losses:{payments:'stripe'}, stripe_dashboard:{type:'full'}, requirement_collection:'stripe' }, capabilities:{ card_payments:{requested:true}, transfers:{requested:true} }, metadata:{ webklar:'shop', customerId, projectId } })` – genaue Parameter nach `recherche-recht-stripe.md` A1 prüfen.
- `onboardingLink`: `accountLinks.create({ account, type:'account_onboarding', refresh_url, return_url, collection_options:{fields:'eventually_due'} })`.
- `kontoStatus`: `accounts.retrieve` ⇒ `{ charges_enabled, payouts_enabled, details_submitted, currently_due: [...], disabled_reason, geprueftAt }`.
- `checkoutAnlegen(accountId, params)` = `checkout.sessions.create(params, { stripeAccount: accountId, idempotencyKey })`; `sessionHolen`; `erstatten` = `refunds.create({ payment_intent, amount, reason:'requested_by_customer', metadata }, { stripeAccount })`.
- `connectEreignis(raw, sig)`: 503 wenn `config.shop.connectWebhookSecret` leer, sonst `getStripe().webhooks.constructEvent(raw, sig, secret)`.
### Task D2: Kundenrouten Stripe
`server/routes/shop.js`: `POST /api/shop/stripe/einrichten { projectId }` (kein viewAs; legt Konto an falls keins, speichert `stripeAccountId` unter Sperre, liefert `{ url }` Onboarding),
`POST /api/shop/stripe/link { projectId }` (neuer Onboarding-Link), `POST /api/shop/stripe/aktualisieren { projectId }` (Status neu lesen + speichern). Rücksprung `dashboard.html#webshop?stripe=zurueck&projekt=<id>`, refresh `...?stripe=neu&projekt=<id>`. Tests mit Fake-Stripe.
### Task D3: Connect-Webhook
`server/routes/shopWebhook.js` (Mount `POST /api/shop/stripe-webhook` in index.js **vor** `/api/shop`-Router, nutzt `req.rawBody` des globalen Parsers), Tests mit signierten Events (eigenes Test-Secret).
Ereignisse: `account.updated` ⇒ `stripeStatus` des Shops mit diesem Konto (Index `idx_account`); `checkout.session.completed` (payment_status `paid`) und `checkout.session.async_payment_succeeded` ⇒ `schliesseAb({ accountId: event.account, session })`;
`checkout.session.async_payment_failed` ⇒ `verwirf(..., 'zahlung_fehlgeschlagen')` + G14; `checkout.session.expired` ⇒ `verwirf(..., 'abgelaufen')`; `charge.refunded` ⇒ Erstattung nachtragen (nur wenn noch nicht über das Portal gebucht, über `refund.id`).
Nur Sessions mit `metadata.wkShop === '1'` werden behandelt; unbekannte ⇒ 200 ohne Wirkung. Fehler ⇒ 500 (Stripe wiederholt), alles idempotent.
---
## Block E: Bestellungen
### Task E1: Bestellung anlegen (Server-Kern)
`server/services/shop/bestellungen.js` + Tests. `legeBestellungAn(ctx, { positionen, lieferart, land, rueckUrl }, { now })`:
unter `withLocks(['shop-bestand:'+shopId])`: `istGeoeffnet` (sonst 409 `geschlossen`), Produkte laden, `reserviert(shopId, now)` (Summe der `offen`-Bestellungen mit `ablaufAt > now`), `berechne`; Bestellnummer `JJ-XXXXXX` (base32 ohne 0/1/O/I, eindeutig im Shop), Token 32 Byte (nur Hash speichern),
Dokument `offen` mit Schnappschuss `{ positionen, summen, lieferart, land, einstellungenAuszug (Anbieter, Lieferzeit, Zahlungsarten), rechtstexteHash der bestätigten Fassung, verlauf:[{at, was:'angelegt'}] }`, `ablaufAt = now + 30 min`;
dann Checkout auf dem Konto: `mode:'payment'`, `line_items` (je Position `price_data{ currency:'eur', unit_amount, product_data:{ name: 'Name – Variante', images? (öffentliche absolute Bild-URL) } }, quantity`), Versand als `shipping_options:[{ shipping_rate_data:{ type:'fixed_amount', fixed_amount:{amount, currency:'eur'}, display_name:'Versand' | 'Abholung', delivery_estimate? } }]`,
`shipping_address_collection:{ allowed_countries }` nur bei Versand, `billing_address_collection:'required'`, `phone_number_collection:{enabled:true}`, `payment_method_types` aus Einstellungen, `locale:'de'`, `expires_at` (30 min), `client_reference_id`, `metadata:{ wkShop:'1', shopId, orderId, bestellnummer }`, `payment_intent_data:{ metadata: gleich, description:'Bestellung <nr>' }`,
`success_url` = geprüfte Rück-URL + `wk_bestellung=<id>&wk_t=<token>`, `cancel_url` = Rück-URL + `wk_abbruch=1`. Rück-URL nur, wenn Host ∈ erlaubte Hosts (`analyseDomains(project)` + Portal-Host), sonst `https://project.webklar.com/shop/<siteKey>`.
Scheitert Stripe ⇒ Bestellung `storniert` (Reservierung frei) und 503 mit Käufertext; Klarna/SEPA nicht freigeschaltet ⇒ einmal mit nur `card` wiederholen und dem Händler einen Hinweis in `stripeStatus.hinweis` speichern.
### Task E2: Abschluss, Verwerfen, Ablauf
`schliesseAb({ accountId, session, now })`: Sperre `shop-order:<orderId>`; Konto muss zum Shop passen; nur aus `offen` (oder `storniert` mit `grund:'abgelaufen'` und bezahlt ⇒ trotzdem bezahlt, Bestand ggf. negativ ⇒ Händler-Hinweis in K25 Block `ueberverkauft`); `amount_total` muss `summeCent` entsprechen (sonst trotzdem bezahlt, Verlauf-Vermerk + Team-Log);
setzt `bezahlt`, `bezahltAt`, `paymentIntentId`, Käuferdaten aus `customer_details` (name, email, phone, address) und `shipping_details`/`collected_information.shipping_details`, zieht Bestand ab (Produkte unter `shop-bestand`-Sperre), schickt G09 (Käufer) und K25 (Händler). Rückgabe `{ order, neu }`.
`verwirf(orderId, grund)` nur aus `offen`. Minutenjob (`server/services/shop/jobs.js` `minutenJob(now)`): `offen` mit `ablaufAt < now - 5 min` ⇒ Session bei Stripe lesen: `complete`+`paid` ⇒ `schliesseAb`, `expired`/`open` ⇒ (open: `checkout.sessions.expire`) `verwirf('abgelaufen')`; Lesefehler ⇒ nächster Lauf.
### Task E3: Händler-Aktionen, Erstattung, CSV
`setzeStatus(shop, orderId, aktion)`: `versendet { versanddienst?, sendungsnummer? }` (aus bezahlt) ⇒ G10; `abholbereit` (aus bezahlt, Lieferart abholung) ⇒ G11; `abgeschlossen` (aus bezahlt|versendet|abholbereit); `storniert` (nur offen).
`erstatte(shop, orderId, { betragCent, grund })`: nur bezahlt/versendet/abholbereit/abgeschlossen, Betrag ≤ Rest; `erstatten()` mit Idempotenz-Schlüssel; Eintrag in `erstattungen[]`, voll ⇒ Status `erstattet`; G13 an Käufer. Fehler ⇒ 502 mit Du-Text.
`csv(shopId, von, bis)`: `;`-getrennt, UTF-8 mit BOM, Spalten Bestellnummer, Datum, Status, Name, E-Mail, Lieferart, Adresse, Positionen, Versand, Gesamt, Erstattet; Formel-Injektion verhindern (Zellen mit `= + - @` voranstellen `'`).
Kundenrouten: `GET /api/shop/bestellungen?projectId=&status=&seite=`, `GET /api/shop/bestellungen/:id`, `POST /api/shop/bestellungen/:id/aktion { aktion, ... }`, `POST /api/shop/bestellungen/:id/erstatten`, `GET /api/shop/bestellungen.csv?projectId=&von=&bis=`, `GET /api/shop/widerrufe`, `POST /api/shop/widerrufe/:id/erledigt`.
### Task E4: Mails
Vorlagen G09–G14 (Muster `G03 …`: Gästelayout, `{{farbe}}`, `{{betrieb_name}}`, Sie-Form, kein WEBklar-Logo; Fußzeile mit Anbieter-Impressumsdaten, Link „Vertrag widerrufen“ `{{widerruf_url}}`), K24 (B2), K25, K26 (K-Layout, Du-Form), T21 (B1).
**G09** Bestellbestätigung: Betreff „Ihre Bestellung {{bestellnummer}} bei {{betrieb_name}}“; Anbieter (Name, Anschrift, E-Mail), Bestellnummer, Datum, Positionen (vorgerenderter Text/HTML-Tabelle aus dem Server, Platzhalter `positionen_html` wird **nicht** escaped? – Nein: die Vorlage-Engine escaped immer; deshalb Positionen als Textblock `{{positionen}}` mit Zeilenumbrüchen), Zwischensumme, Versand, Gesamt, Steuerhinweis, Lieferart + Adresse/Abholort, Lieferzeit, Zahlungsart, Widerrufsausschlüsse (Block), **vollständige AGB, Widerrufsbelehrung, Muster-Widerrufsformular** (Blöcke mit Text), Link Widerrufsfunktion.
`server/services/shop/mails.js`: `anKaeufer(art, ctx, order, extra, now)` ⇒ `einreihen({ featureId: ctx.doc.$id, bookingId: order.$id, art, an, fromName: shopname, replyTo: anbieter.email, template: art, values, blocks })` (Outbox `art` ≤ 16; die Outbox prüft Budget; Grenze 3 Mails/h je Empfänger über `limits.darfEmpfaenger` beachten – für G09 nicht anwenden, G09 ist Vertragspflicht);
`anHaendler(template, ctx, values, blocks)` ⇒ `sendCustomerMail(kundenEmail, {..., bccTeam:false})`. Outbox-Erweiterung: `GAST_ARTEN`/Budgetklassen so ändern, dass G09–G14 als Gästemails zählen (G09, G12 Vorrang); der Mail-Job läuft auch, wenn `TERMINE_ENABLED=off` aber `SHOP_ENABLED≠off` (in `termine/jobs.js` die Bedingung für den Mailteil erweitern, sonst unverändert). `test/mailTemplates.test.js` (EXPECTED, GUEST-Liste + Platzhaltertabellen, K-Liste).
### Task E5: Widerrufe (§ 356a BGB)
`server/services/shop/widerrufe.js` + Tests: `erfasseWiderruf(ctx, { name, email, bestellnummer, nachricht }, { now, ip })`: Pflicht Name (2–200), E-Mail, Bestellnummer (≤ 32; muss nicht existieren); passende Bestellung (Nummer + E-Mail gleich) ⇒ `orderId` + Verlauf; speichert, schickt sofort **G12** Eingangsbestätigung (Inhalt der Erklärung, Datum und Uhrzeit Berlin, Bestellnummer) und **K26** an den Händler; Antwort `{ ok, eingang: 'TT.MM.JJJJ, HH:MM Uhr' }`. Grenzen `darfWiderrufsversuch` (10/IP/h) + 20/Shop/h.
---
## Block F: Öffentliche API
### Task F1: Lesen
`server/routes/publicShop.js` (Mount `/api/public/shop/v1` **vor** dem globalen Parser, eigenes `express.json({limit:16kb})`, Antwortmuster wie `publicTermine.js schreibe()`), Tests `test/publicShop.test.js`.
`GET /:siteKey` ⇒ `{ aktiv:false }` (unbekannt/inaktiv/aus/nicht verfügbar, kein Orakel) oder `{ aktiv:true, offen: istGeoeffnet, shop:{ name, akzent, anbieter (Name, Anschrift, E-Mail, Telefon), lieferzeit, lieferlaender:[{code,name}], abholung:{aktiv, adresse, hinweis}, zahlungsarten:[Texte], versandText, mindestbestellText, kleinunternehmer, steuerHinweisKurz }, kategorien, produkte:[produktOeffentlich], rechtstexte:{ agb, widerrufsbelehrung, widerrufsformular, versandUndZahlung, impressumUrl, datenschutzUrl } (bestätigte Fassung), widerrufUrl, formToken }`.
Rechtstexte nur, wenn eine bestätigte Fassung existiert. `GET /:siteKey/bild/:datei` (`^[a-z0-9-]{1,64}-(s|l)\.webp$`, Bild muss zu einem Produkt des Shops gehören, Cache 1 Tag, nosniff). Grenzen `darfAnfrage` 120/IP/min, 600/Shop/min. CORS GET `*`.
### Task F2: Bestellung und Status
`POST /:siteKey/bestellung { positionen, lieferart, land, rueckUrl, formToken, kontakt_url (Honeypot) }`: Herkunftsprüfung wie `erlaubteHerkunft` (Origin-Host ∈ Projektdomains + Portal-Host), Formtoken (HMAC, ≥ 3 s), Honeypot ⇒ scheinbarer Erfolg ohne Wirkung (`{ url: rueckUrl }`), Grenzen 10/IP/10 min + 60/Shop/h, Mindestantwortzeit 300 ms; ⇒ `{ url }` (Stripe) oder Fehler `{ fehler:{ _: text }, code }`.
Vorab-Berechnung ohne Anlegen: `POST /:siteKey/pruefen { positionen, lieferart, land }` ⇒ Ergebnis von `berechne` (Texte), damit die Kasse die Zusammenfassung **vom Server** zeigt.
`GET /:siteKey/bestellung/:id?t=` ⇒ Token prüfen (timingSafeEqual auf Hash) ⇒ bei `offen` einmal `sessionHolen` + ggf. `schliesseAb`; Antwort `{ status, bestellnummer, gesamtText, lieferart, email (maskiert) }`.
### Task F3: Widerruf und Seiten
`POST /:siteKey/widerruf { name, email, bestellnummer, nachricht, formToken, kontakt_url }` (Origin: Projektdomains + Portal-Host; Formtoken; Grenzen; Mindestantwortzeit) ⇒ `{ ok:true, eingang }`.
Funktioniert auch bei geschlossenem, aber `active`/`disabled`-Shop (Widerrufsrecht läuft weiter); nur `off`/unbekannt ⇒ `{aktiv:false}`.
---
## Block G: Portal-Oberfläche
### Task G1: Verdrahtung, Kachel, Bestellseite verallgemeinern
`public/dashboard.html` (`/shop.css`, `<section class="kb-view" id="view-webshop">` mit Kopf „Webshop“ + `#webshop-body`), `public/app.js` (`FEATURE_LABELS.shop`, `VIEW_HASHES.webshop`, `viewRefreshers.webshop` ⇒ `import('/shop.js').then(m => m.initShop(...))`, `APP_PARENT.webshop = 'funktionen'` am Ende), `public/funktionen.js` (Kachel „Webshop“: Zustände laut Spec-Tabelle 2; Text „Dein eigener Online-Shop auf Deiner Website. Zahlungen direkt auf Dein Stripe-Konto. 299 € einmalig.“; Klick ⇒ `#webshop` mit Wunschprojekt), `public/funktion-bestellen.js` (Checkout-Pfad und Zusammenfassung über Parameter `key`/`zusammenfassung`, Termine-Verhalten unverändert; Tests bleiben grün), Tests (`funktionenTiles.test.js`, neue statische Verdrahtungstests).
### Task G2: Hülle `#webshop`, Anfrage, Status
`public/shop.js` (Hülle nach `termine.js`: Rückkehr lesen `?bestellt=1`, `?stripe=…`, Projektwahl, Zustände: nicht verfügbar ⇒ Infokarte; keine Anfrage ⇒ Anfrageformular (`public/shop-anfrage.js`); Anfrage offen/abgelehnt ⇒ Karte; freigegeben + `off` ⇒ Bestellseite (`funktion-bestellen.js` mit key `shop`, Belehrung/Unterlagen aus `GET /api/funktionen` `features.shop` + `legal.shop`); `ordered` ⇒ Statuskarte „Zahlung fortsetzen“; `setup|active|disabled` ⇒ Reiter), `public/shop-dom.js` (Helfer: `h`, `request`, Felder – Kopie der termine-dom-Helfer, nicht importieren), `public/shop.css` (Tokens `--kb-*`, Reiterleiste 5 Spalten, mobil scrollbar), Test `test/shopView.test.js` mit `installFakeDom`.
### Task G3: Reiter Übersicht + Stripe
`public/shop-tab-uebersicht.js`: Checkliste (Bereitschaft vom Server), Schalter „Shop geöffnet“, Stripe-Karte (Einrichten / Angaben ergänzen / Status aktualisieren / Dashboard-Link `https://dashboard.stripe.com/`), Vorschau-Link `https://project.webklar.com/shop/<siteKey>`, Einbau-Snippet nur lesen, Kennzahlen. Test.
### Task G4: Reiter Artikel
`public/shop-tab-artikel.js`: Liste mit Hoch/Runter, Aktiv-Schalter, Editor (alle Felder aus C3, Varianten-Editor, Bild-Upload mit Vorschau/Entfernen/Reihenfolge, Lebensmittel-/Sicherheits-Abschnitte einklappbar, Grundpreis), Speichern mit Feldfehlern. Test.
### Task G5: Reiter Bestellungen
`public/shop-tab-bestellungen.js`: Filter, Liste, Detail, Aktionen (versendet mit Sendungsnummer, abholbereit, abgeschlossen, erstatten mit Betrag + Bestätigung), Widerrufe-Liste mit „erledigt“, CSV-Download (Zeitraum). Test.
### Task G6: Reiter Einstellungen + Rechtliches
`public/shop-tab-einstellungen.js` (alle Felder C2, Hinweis bei Klarna/SEPA „nur einschalten, wenn in Deinem Stripe-Konto aktiviert“; SEPA-Warnung; Satz „PayPal ist über Stripe für Shops wie Deinen leider nicht verfügbar“), `public/shop-tab-rechtliches.js` (AGB/Widerrufsbelehrung/Formular bearbeiten, „Muster einsetzen“, Versand & Zahlung nur lesen, Impressum-/Datenschutz-URL, Bestätigungshaken + Knopf, Banner wenn Änderungen unbestätigt, Hinweis „Muster ohne Gewähr; lass Deine Texte prüfen, z. B. von einer Kanzlei oder einem Händlerbund“). Test.
---
## Block H: Widget und öffentliche Seiten
### Task H1: `public/embed/shop.js`
ES5-IIFE nach `embed/termine.js` (Light DOM, `@layer wks`, Präfix `wks-`, `data-webklar-shop`, `data-api`, `--wks-akzent`, `credentials:'omit'`, 20 s Timeout, Ausweichtext bleibt bei Fehlern). Ansichten: Raster (Kategorien-Filter), Detail (Galerie, Beschreibung, Preis + Grundpreis + Steuerhinweis, Varianten, Menge, Lieferhinweis, Widerrufsausschluss, Produktsicherheit, Lebensmittelangaben), Warenkorb (Zustand im Speicher **und** `sessionStorage` mit try/catch, nur IDs/Mengen), Kasse (Lieferart/Land, Zusammenfassung **vom Server** `/pruefen`, Pflichtinformationen, Links AGB/Widerrufsbelehrung/Versand, Hinweis „Name, Adresse und Zahlung geben Sie im nächsten Schritt bei Stripe ein“, Knopf **„Zahlungspflichtig bestellen“**), Rückkehr (`wk_bestellung`/`wk_t` ⇒ Status anzeigen, `wk_abbruch` ⇒ Warenkorb; danach `history.replaceState` bereinigen), Widerruf (Schaltfläche „Vertrag widerrufen“ ⇒ Formular ⇒ Knopf **„Widerruf bestätigen“** ⇒ Eingangsanzeige mit Zeit). Kopfzeile: Lieferländer + Zahlungsarten (§ 312j Abs. 1). Fußzeile: AGB · Widerrufsbelehrung · Versand & Zahlung · Vertrag widerrufen · Impressum · Datenschutz. Geschlossen ⇒ „Der Shop ist gerade geschlossen.“ + Widerruf weiter erreichbar. Rechtstexte als Overlay/`<details>` mit `textContent` (Absätze per `\n\n`). Keine Zahlen-/Datumsformatierung im Widget (alles vom Server). Statische Tests `test/shopEmbedStatic.test.js` (kein `innerHTML` mit Fremddaten, kein `eval`, Knopftexte vorhanden).
### Task H2: Seiten und Demo
`public/shop-seite.html` (neutrale Seite mit Widget; Server-Route `GET /shop/:siteKey` und `GET /shop/:siteKey/widerruf` (siteKey-Regex, sonst 404) liefert die Datei; `?ansicht=widerruf` öffnet direkt den Widerruf), `public/embed/shop-demo.html`, Test der Route.
---
## Block I: WOMS (eigener Worktree)
### Task I1: Funktionen-Karte + Anfrage
WOMS `src/lib/funktionenView.js` (`FUNKTIONEN` + `shop` mit Bestätigungstexten; `aktionen` für shop wie termine: setup ⇒ abnahme/ticket/disable), `src/lib/teamApi.js` (`getShop`, `entscheideShopAnfrage`), `src/components/customer/CustomerFunktionen.jsx` (Webshop-Panel je Projekt: Anfrage lesen, „Anfrage annehmen“/„Ablehnen“ mit Begründung, Stripe-Status, Kennzahlen), `scripts/setup-legal.mjs` (`DOCUMENTS` + `leistungsbeschreibung-webshop`, `avv-webshop`; Test-Anzahl anpassen), Tests `npm test` + `nice npx vite build`.
---
## Block J: Rechtstexte, Jobs, Aufräumen
### Task J1: Leistungsbeschreibung + AVV Webshop
`docs/rechtliches/leistungsbeschreibung-webshop.md`, `docs/rechtliches/avv-webshop.md` (Inhalt Spec 10; Muster LB/AVV Terminplaner; Fassung `config.shop.fassung`), `scripts/build-legal-html.mjs` und `scripts/build-legal-pdfs.sh` um Satz/Modus `shop` erweitern, HTML + PDF + `.sha256` erzeugen (Chrome headless wie vorhanden, `timeout 120`), Tests `legalDocs`, `buildLegalHtml` erweitern; danach `featureLegalReady('shop') === true`.
### Task J2: Datenschutz, Widerruf der Funktion, Doku
`public/datenschutz.html` Abschnitt „Webshop“ (Stand-Datum, PDF neu über `build-legal-pdfs.sh datenschutz`), `server/routes/abo.js` (`ORDERS`, `FEATURE_OF_ORDER`, `orderLabel` um `shop`) + `public/widerrufen.html` (Option „Webshop (Einrichtung)“), `docs/funktionen/shop-einbau.md`, `docs/funktionen/shop-datenschutz-absatz.md` (Muster für die Händler-Website), `docs/rechtliches/webshop-kanzlei-paket.md` (Fragen + Fundstellen), Tests.
### Task J3: Jobs, Aufbewahrung, Kundenlöschung
`server/services/shop/jobs.js` (`minutenJob` aus E2 + Bilder-Aufräumen stündlich; `taeglicherJob`: Käuferdaten anonymisieren 3 Jahre nach Ende des Bestelljahres, unbezahlte/stornierte nach 30 Tagen; Shops `disabled` seit > 90 Tagen: Produkte, Bestellungen, Widerrufe, Bilder, shopConfig löschen, Dokument bleibt `disabled`; seitenweise, Deckel je Lauf), `server/index.js` (Jobs registrieren, nur wenn `shopModus() !== 'off'` oder es Shop-Dokumente gibt), `server/services/customerAdmin.js` (Kundenlöschung: `disableCustomerFeatures(customerId,'shop',…)`, offenen Shop schließen), `retention.js` nur falls nötig. Tests.
---
## Block K: Abschluss (Controller)
- [ ] K1 ganze Suite (Host, `--max-old-space-size`), Node-20-Lauf der Shop-Tests im Container.
- [ ] K2 Gesamt-Review (2 sonnet-Reviewer: Geld/Stripe/Bestellungen; Recht/UI/Widget), Fixrunde.
- [ ] K3 End-to-End mit Mocks: Server mit Appwrite-Mock + Fake-Stripe + SMTP-Senke, Playwright: Anfrage → Freigabe → Bestellung → Artikel → Öffnen → Kauf im Widget → Webhook → Mails → Versand → Erstattung → Widerruf; Screenshots nach `/root/reports/webshop-2026-10-05/ui/`.
- [ ] K3b Gewährleistungshinweis: siehe Global Constraints
- [ ] K4 Livegang-Anleitung `docs/funktionen/shop-livegang.md` (Spec 11) und Memory/Ledger aktualisieren.
@@ -0,0 +1,405 @@
# Aufträge und Rechnungen – Umsetzungsplan, Übersicht und Verträge
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> Dieser Plan ist auf **parallele Stränge** ausgelegt (Nutzerwunsch 06.10.2026: „so viele Sub-Agents wie möglich, viel nebenbei, zum Schluss die Kontrolle“). Jeder Strang hat eine eigene Plan-Datei, einen eigenen Worktree und **eigene Dateien**. Diese Übersicht ist der Vertrag zwischen den Strängen: Namen, Signaturen, Datenformen und Endpunkte stehen nur hier und gelten für alle.
**Goal:** Admins legen in WOMS beim Kunden Auftragsbestätigungen (AB) und Rechnungen zu einem Projekt an, sehen je Projekt ein Projektkonto (vereinbart, berechnet, bezahlt, offen, Zeit) und rechnen per Anzahlung, Schlussrechnung, nach Aufwand oder als Festbetrag ab.
**Architecture:** Der Portal-Server (`kundenbereich.webklar.com`) bekommt einen Baustein `server/services/auftraege/` (Speicher, Projektkonto, Positionsbau, AB-Logik, PDF über pdfkit, Bestätigungs-Token, Mails) und erweitert die bestehende Stripe-Rechnung (`stripeFinance.js`). WOMS (`ticket.webklar.com`) zeigt Projektkonten, AB- und Rechnungsdialoge im neuen weißen Design; das Kundenportal bekommt eine Bestätigungsseite, einen Abschnitt „Aufträge“ und die Vertragsart „Projektauftrag“ auf der Widerrufsseite. Der AB-Teil hängt hinter dem Schalter `AUFTRAEGE_ENABLED` (Vorgabe `off`), damit der Rechnungsteil vor der Rechtsrunde live gehen kann.
**Tech Stack:** Node 20.19 (Container) und 24 (Host), Express 4, Appwrite-REST über `server/services/appwriteAdmin.js`, Stripe SDK 19 (API 2025-10-29.clover), pdfkit (neu), nodemailer mit HTML-Mailvorlagen (`server/mail-templates`), Tests mit `node --test` und den Nachbauten in `test/support/` (Appwrite, Stripe, Mail-Senke, Test-App); WOMS: React 18, Vite, Tests mit `npm test` (`tests/unit/*.test.mjs`).
**Spec:** `docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md` (Commit e00b4ae). Jeder Implementierer liest Spec **und** diese Übersicht **und** seine Strang-Datei.
---
## Ausführungsmodell
```
Phase A L0 Fundament (1 Agent) ───────────────────────────────┐
Phase B L1 Projektkonto · L2 Rechnungen · L3 AB-Kern · L4 AB-PDF · L5 Bestätigung+Mails · L6 Kundenportal · L7 Rechtstexte (7 Agents parallel)
Phase W [WOMS-Redesign P4+P6+P7 zusammengeführt] → W0 WOMS-Basis (1 Agent) → W1 Rechnungs-UI · W2 AB-UI (2 Agents parallel)
Phase Z Z1 Portal-Integration ‖ Z2 WOMS-Integration → Z3 Kontrolle (5 Prüfer parallel → Bestätigung → Korrektur) → Übergabe
```
- **Ein Implementierer je Strang** (Modell `sonnet`), Tasks im Strang der Reihe nach, TDD. **Keine Reviewer je Task** (Nutzerwunsch); jeder Strang endet mit seiner **Selbstprüfung** (letzter Task der Strang-Datei: eigene Tests grün, Vertragsliste abgehakt, `git status` sauber).
- **Phase W** startet, sobald die Redesign-Stränge Kunden (P4, P6) und Finanzen (P7) in `feature/design-weiss` zusammengeführt und getaggt sind (Tags `design-p4` … `design-p7`, siehe Memory „woms-theme-weiss“). Fehlen sie beim Start von Phase W, fragt der Orchestrator den Nutzer (warten oder auf `test` bauen und eine Mitnahme-Notiz in `/root/reports/ticketsystem-design-2026-10-04/strang/mitnehmen.md` hinterlegen).
- **Phase Z**: Z1 und Z2 dürfen parallel laufen; Z3 erst, wenn beide fertig sind. Kleine Prüfungen in Z3 (Bestätigung einzelner Befunde, Ein-Datei-Korrekturen) mit `haiku`. Kein `opus`/`fable`.
- Ausführung wahlweise mit dem Workflow-Werkzeug (Phasen wie oben) oder mit parallelen Agent-Aufrufen; die Plan-Dateien sind für beides gleich.
### Stränge, Plan-Dateien, Worktrees
| Strang | Plan-Datei (`docs/superpowers/plans/`) | Worktree | Branch | Start |
|---|---|---|---|---|
| L0 Fundament | `2026-10-06-auftraege-01-fundament.md` | `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege` | `feature/auftraege` | sofort |
| L1 Projektkonto | `…-02-projektkonto.md` | `…/dev-auftraege-l1` | `auftraege/l1-projektkonto` | nach L0 |
| L2 Rechnungen | `…-03-rechnungen.md` | `…/dev-auftraege-l2` | `auftraege/l2-rechnungen` | nach L0 |
| L3 AB-Kern | `…-04-ab-kern.md` | `…/dev-auftraege-l3` | `auftraege/l3-ab-kern` | nach L0 |
| L4 AB-PDF | `…-05-ab-pdf.md` | `…/dev-auftraege-l4` | `auftraege/l4-ab-pdf` | nach L0 |
| L5 Bestätigung + Mails | `…-06-bestaetigung.md` | `…/dev-auftraege-l5` | `auftraege/l5-bestaetigung` | nach L0 |
| L6 Kundenportal | `…-07-kundenportal.md` | `…/dev-auftraege-l6` | `auftraege/l6-kundenportal` | nach L0 |
| L7 Rechtstexte | `…-08-rechtstexte.md` | `…/dev-auftraege-l7` | `auftraege/l7-rechtstexte` | nach L0 |
| W0 WOMS-Basis | `…-09-woms-basis.md` | `/home/webklar/apps/ticket.webklar.com/dev-auftraege` | `feature/auftraege` (WOMS) | Redesign P4+P6+P7 da |
| W1 Rechnungs-UI | `…-10-woms-rechnungen.md` | `…/ticket.webklar.com/dev-auftraege-w1` | `auftraege/w1-rechnungen` | nach W0 |
| W2 AB-UI | `…-11-woms-auftraege.md` | `…/ticket.webklar.com/dev-auftraege-w2` | `auftraege/w2-auftraege` | nach W0 |
| Z Integration + Kontrolle | `…-12-integration-kontrolle.md` | Basis-Worktrees | `feature/auftraege` (beide Repos) | nach Phase B bzw. W |
Lane-Worktrees entstehen aus dem Stand **nach L0**:
`git -C /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege worktree add ../dev-auftraege-l1 -b auftraege/l1-projektkonto feature/auftraege`
und bekommen `ln -s ../dev-auftraege/node_modules node_modules` (L0 legt dort ein echtes `node_modules` an). Der Symlink ist unversioniert: **nie `git add -A`**, immer Dateien einzeln.
### Dateizuständigkeit (Konfliktschutz)
Jede Datei hat genau einen Besitzer-Strang. Ein Strang ändert **nur** seine Dateien; braucht er etwas aus einer fremden Datei, nutzt er den Vertrag unten. Gemeinsame Dateien (`server/config.js`, `server/index.js`, `server/routes/team/index.js`, `package.json`, `package-lock.json`, `test/support/*`, `server/services/auftraege/konstanten.js`) ändert **nur L0**. Ausnahmen stehen in der Tabelle.
| Datei | Besitzer |
|---|---|
| `server/services/auftraege/schema.js`, `konstanten.js`, `schalter.js`, `auftragStore.js`, `projektRechnungenStore.js`, `auftragSicht.js`, `arbeitsblaetter.js`, `ablage.js`, `fehler.js` | L0 |
| `server/services/auftraege/texte.js` | L0 legt an (vorläufige Wortlaute), **danach nur L7** (nur Wortlaute, Exportnamen bleiben) |
| `scripts/auftraege-setup.mjs`, `server/services/finance/settings.js` (Feld `steuernummer`) | L0 |
| Router-Gerüste `server/routes/admin/projektkonten.js`, `server/routes/team/arbeitsblaetter.js` | L0 legt an, L1 füllt |
| Router-Gerüst `server/routes/admin/auftraege.js` | L0 legt an, L3 füllt |
| Router-Gerüste `server/routes/auftrag.js`, `server/routes/auftraegeKunde.js` | L0 legt an, L5 füllt |
| `server/services/auftraege/projektkonto.js` | L1 |
| `server/services/auftraege/positionen.js`, `rechnungsbau.js`; `server/services/finance/stripeFinance.js`; `server/routes/admin/finanzen.js`; `test/support/fakeStripe.js` (Ausnahme von „nur L0“: nur Ergänzungen für Rechnungsposten mit `amount`, `custom_fields`, `rendering`) | L2 |
| `server/services/auftraege/nummern.js`, `auftraege.js` | L3 |
| `server/services/auftraege/pdf.js`, `server/assets/fonts/*` | L4 |
| `server/services/auftraege/token.js`, `bestaetigung.js`, `mails.js`; `server/mail-templates/K31 …`, `K32 …`, `T22 …`; `server/routes/abo.js` (Widerruf `projekt`); `test/mailTemplates.test.js` (falls er die Vorlagenliste pinnt) | L5 |
| `public/auftrag.html`, `public/auftrag.js`, `public/auftrag.css`, `public/dashboard.html`, `public/app.js`, `public/widerrufen.html`, `server/services/customerInvoices.js` | L6 |
| `public/rechtliches/*` (neue Dateien), `scripts/build-legal-html.mjs`, `scripts/build-legal-pdfs.sh`, `public/datenschutz.html`, `docs/WEBklar-Datenschutzerklaerung.pdf` (+ `.sha256`), `konstanten.js` **nur** `AGB_FASSUNG` | L7 |
| Tests: jede Lane legt eigene Dateien `test/auftraege<Thema>.test.js` an; bestehende Testdateien nur, wenn die Tabelle sie nennt | je Lane |
### Nachträge nach der Planrunde (verbindlich, 06.10.2026)
Die Strang-Pläne wurden parallel geschrieben; ihre „Vertragsfragen“ sind hier entschieden. Wo ein Strang-Plan davon abweicht, gilt dieser Abschnitt.
**Zusätzliche Dateien je Strang (Zuständigkeit):**
- L0: zusätzlich `.gitignore` (Eintrag `node_modules` **ohne** Schrägstrich, damit auch der Symlink ignoriert wird) und `fontkit` als direkte Abhängigkeit in `package.json` (Version wie die pdfkit-Abhängigkeit).
- L2: zusätzlich `test/financeStripeRead.test.js` (erwarteter Schlüsselsatz von `mapInvoice`).
- L5: zusätzlich `server/mail-templates/K04 Eingang Widerruf.html` und `T09 Widerruf eingegangen.html` (nur Block `projekt`).
- L6: zusätzlich `public/auftrag-ansicht.js`, `public/auftrag-dom.js`, `public/auftrag-portal.js`, `public/auftrag-widerruf.js`, `test/auftraegeKundenportalDaten.js`, `test/ui/auftraege-kundenportal.ui.mjs`, je eine Zeile in `test/customerInvoices.test.js` und `test/publicForms.test.js`.
- L7: zusätzlich `docs/rechtliches/agb.md`, `docs/rechtliches/quellen/normen-auftraege-2026-10-06.txt`, Stand-Zeile in `test/legalTexts.test.js`.
- Alle: Testhilfen ohne `.test.js` (z. B. `test/helpers/auftraegeAb.js`, `test/auftraege…Hilfe.js`) gehören dem anlegenden Strang.
- WOMS: W0 `src/lib/financeApi.js`, `src/lib/financeRequest.js` (Blob), `src/lib/teamApi.js`, `src/lib/auftraegeEndpoints.js`, `src/lib/auftraegeAnsicht.js`, `src/lib/customerTabs.js` (+ Test), Container und vier Startdateien unter `src/components/auftraege/`, `CustomerDetailPage.jsx` (nur Reiterzeile), `CustomerInvoices.jsx` (gelöscht); W1 `ProjektkontoListe.jsx`, `RechnungDialog.jsx`, `projektkonto.css`, `rechnung-dialog.css`, `src/lib/projektkontoView.js`, `src/lib/rechnungDialogView.js` (+ weitere eigene Libs), `InvoiceTable.jsx`, `WorksheetList.jsx`, `WorksheetTimesModal.jsx`, `InvoiceCreateModal.jsx` (gelöscht); W2 `AuftragListe.jsx`, `AuftragDialog.jsx`, `auftrag.css`, `src/lib/auftragView.js`, `src/lib/auftragPdf.js`, `src/lib/kundenFelder.js`, `CustomerDetailPage.jsx` (Feld Kundenart), `SettingsCard.jsx`.
**Sperren:** siehe V2 (zusammengesetzte Einzel-Schlüssel). Reihenfolge: wer beide braucht, nimmt erst `sperreRechnung`, darin `sperreAuftrag`; innerhalb von `sperreAuftrag` nie `sperreRechnung`. Mails (K32/T22) erst nach Freigabe der Sperre.
**L0:** `ablage.entfernePdf(pdfDatei)` (räumt eine abgelegte PDF weg, wenn danach etwas scheitert; L3 nutzt genau diesen Namen); Export `AUFTRAEGE_DATETIMES`; `schemaKeys` auch für `financeSettings`; `oeffentlicheAnsicht` liefert `texte.kleinunternehmer`; Steuernummer mit Buchstaben (V4).
**L1:** Zeitwert = Summe der je Tag gerundeten Werte (wie `aufwandPositionen`); `projektTyp` = Anzeigename der Projektart; `offenCents` bei Stripe-Ausfall `null`; „Ohne Projekt“ = finalisierte Rechnungen mit `source: 'projekt'` ohne aktive Projektrechnung plus verwaiste Projektrechnungen; archivierte Projekte nur mit Geldbezug; bei ausgeschaltetem Schalter werden ABs nicht gelesen.
**L2:** `baueRechnung` bekommt zusätzlich `createdBy`; `zeilentexte[schluessel]` ersetzt den ganzen Text vor dem letzten „ – “ (einschließlich Datum bzw. „KW …“); **Selbstheilung**: stößt eine Prüfung („bereits abgerechnet“, offener Entwurf zur AB) auf eine Projektrechnung, deren Stripe-Rechnung storniert oder gelöscht ist, wird sie `aktiv: false` und die Prüfung wiederholt; nur finalisierte Rechnungen zählen als „bereits berechnet“, ein Schluss-Entwurf blockiert weitere Anzahlungen; Schlussrechnung bis 40 Zeilen; Aufwand ohne `auftragId` nimmt die jüngste gültige Aufwands-AB nur bei eingeschaltetem Schalter (sonst 100 €); Buchungsfehler nach einem Stripe-Erfolg werden protokolliert, nicht geworfen; Schreibweisen „KW 05/2026“ (Schlüssel `KW-2026-05`), `stundenText` ohne Endnullen, Texte eines Tages mit „; “ verbunden.
**L3:** Senden: Sperren `SPERRE_NUMMER` + `sperreAuftrag(id)` → **innerhalb** der Sperre Eingabe und Kunde prüfen → Nummer → PDF (`AuftragError` aus `erzeugeAuftragPdf`, z. B. 409 „Entwurf unvollständig …“, wird durchgereicht; andere Fehler 500) → `speicherePdf` → Token → speichern (scheitert danach etwas: `entfernePdf`) → Sperren frei → Mail mit dem **gespeicherten** Dokument. „Erneut senden“ läuft über `/erinnern`: nach einem Mail-Fehler als normale K31, sonst als Erinnerung; `gesendetAn` = aktuelle Kunden-E-Mail. Neu: `ordneProjektZu(id, projektId)` + Route `POST …/auftraege/:id/projekt` (V5); hängt unter derselben `sperreAuftrag(id)` zuerst die Projektrechnungen der AB mit `projektId === ''` um (Stripe unverändert), zuletzt die AB. Stripe-Metadaten wandern beim Ersetzen **nicht** mit (nur `projektRechnungen.auftragId`).
**L4:** statische Figtree-TTFs aus `@expo-google-fonts/figtree` (OFL) unter `server/assets/fonts/`; reicht `privat` an `AB_TEXTE.termine` durch; leere Steuernummer → Zeile entfällt.
**L5:** K31-Link mit `#t=`; K31 ohne Team-BCC, Teamkopie ohne Link (Block `teamkopie`); T22-Betreff mit Präfix „[Aufträge]“; K32 mit `fruehbeginn_text`; K04/T09 Block `projekt`; `fruehbeginnFassung` bei Privatpersonen immer gespeichert, bei Unternehmen leer; Widerruf unabhängig vom Schalter, AB-Nummer optional.
**L6:** öffentliche Seite liest `#t=` (Rückfall `?t=`), sendet `credentials: 'omit'`; Abschnitt „Aufträge“ im Portal in Du-Form; Kleinunternehmer-Satz aus `texte.kleinunternehmer`; AB-Nummer auf der Widerrufsseite optional (Hinweis „falls zur Hand“); rechtlich gefärbte Sätze der Seiten stehen im Freigabe-Dokument von L7.
**L7:** Norm der Frühbeginn-Erklärung § 356 Abs. 5 Nr. 2 und § 357a Abs. 2 BGB; § 649 BGB (Kostenanschlag) ist die richtige Norm für den Überschreitungssatz; Datenschutz-Abschnitt als **letzter** Commit des Strangs (wird erst beim AB-Livegang gemergt); alte AGB-PDF-Fassungen bleiben liegen; Karten in WOMS → Rechtliches laut Freigabe-Dokument (Karte ohne Datei entfällt).
**WOMS:** Bildschirmfotos nur mit eigenen Skripten im Scratchpad (das Prüfwerkzeug des Redesigns unter `/root/reports/ticketsystem-design-2026-10-04/werkzeug*` wird nicht geändert); der WOMS-Push erfolgt erst, wenn das Redesign auf `test` liegt, und vorher wird `feature/auftraege` auf den dann aktuellen `origin/test` gebracht.
**Z:** Node-20-Läufe mit `node:20-alpine`; Merge mit `--no-ff` (kein Rebase der Strang-Branches); Gegenprüfung (haiku) nur für Critical/Important, Minors gehen ungeprüft als Liste an den Nutzer; die Prüfbilder des Endstands werden nach `/root/reports/auftraege-rechnungen-2026-10-06/bilder/` kopiert (der Scratchpad überlebt keinen Neustart); ein Löschlauf für alte ABs nach Ablauf der Aufbewahrung ist nicht im Umfang (Minor-Liste).
**Livegang:** Das Setup-Skript läuft **vor** dem Push (Wegwerf-Container mit dem Worktree, siehe Plan 12) oder als Ausweichweg sofort danach per `docker exec`; es legt `projektRechnungen` in jedem Fall an. Der WOMS-Push setzt das Attribut `customers.kundenart` voraus (Setup vorher).
---
## Global Constraints
- Sprache: alle Texte Deutsch; Mails, PDF und die öffentliche Bestätigungsseite in der **Sie**-Form; im angemeldeten Kundenportal (Dashboard, Abschnitt „Aufträge“) die **Du**-Form wie im übrigen Portal; WOMS wie bestehend.
- Schreibweise im Code: nie ein Unicode-Escape mit genau vier Hexziffern (Backslash, `u`, vier Ziffern) schreiben – das Schreibwerkzeug macht daraus das Zeichen selbst (z. B. ein unsichtbares geschütztes Leerzeichen); stattdessen `\u{…}` oder `String.fromCodePoint(…)`.
- Kleinunternehmer: keine Umsatzsteuer; jede AB trägt „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.“
- Beträge immer ganze **Cent** (`Number.isSafeInteger`); Anzeige über `euroDe(cents)` aus `server/services/finance/format.js` (bzw. WOMS `euro`).
- Datum intern als Berliner Kalendertag `YYYY-MM-DD` (`berlinDate` aus `server/services/finance/dates.js`), Anzeige `TT.MM.JJJJ` (`formatDe`); Zeitpunkte als ISO-UTC (`toISOString()`), Appwrite liefert `+00:00` → immer mit `new Date()` lesen.
- Stundensatz Standard **100 € = 10000 Cent**; Minuten je Arbeitsblatt = `worksheetMinutes(ws)` (inkl. Unterstützung), **je Arbeitsblatt auf 15 Minuten aufgerundet**.
- **Abo-Zeit** (`worksheets.countsToPlan === true`) und Kommentare (`isComment === true`) werden nie projektbezogen berechnet.
- Grenzen (Rechnung und AB): 1–20 Positionen bzw. Zeilen (Minuszeilen zählen mit; Ausnahme: eine Schlussrechnung darf bis 40 Zeilen haben – AB-Positionen ≤ 20, Zusatzpositionen ≤ 10, Abzüge ≤ 10), Beschreibung 1–200 Zeichen, Menge ganzzahlig 1–999, Einzelpreis 1–10.000.000 Cent, Summe ≤ 99.999.999 Cent, Zahlungsziel ganzzahlig 0–60 Tage (Standard 14), Leistungszeitraum ≤ 140 Zeichen.
- AB-Nummer `AB-JJJJ-NNNN` (4 Ziffern, je Kalenderjahr ab 0001, Berliner Jahr des Sendens).
- Bestätigungs-Token: 32 Zufallsbytes base64url (43 Zeichen), gespeichert **nur** als SHA-256 (hex), gültig **60 Tage** ab dem letzten Versand.
- **Keine IP-Adressen** speichern (Ratenbegrenzung nur im Arbeitsspeicher).
- Schalter `AUFTRAEGE_ENABLED` = `off` (Vorgabe) | `pilot` (nur Kunden, deren E-Mail in `AUFTRAEGE_PILOT_EMAILS` steht; `*` = alle) | `on`. Er betrifft nur AB-Funktionen und die Rechnungsarten `anzahlung`/`schluss`; Projektkonto und die Arten `frei`, `festbetrag`, `aufwand` laufen immer.
- Kein Chrome zur Laufzeit; PDFs der AB über **pdfkit**.
- Mail-Kennungen: **K31** Auftragsbestätigung, **K32** Auftrag bestätigt (Kunde), **T22** Auftrag bestätigt (Team).
- Tests: `node --test`; nur Nachbauten aus `test/support/` (`startAppwriteMock`, `startFakeStripe`, `useTestEnv`, `mailSink`, `startTestApp`), nie echtes Appwrite, Stripe oder SMTP. Neue Appwrite-Felder nur über `server/services/auftraege/schema.js` (der Appwrite-Nachbau prüft gegen das Schema).
- **Ressourcen** (Server mit wenig Arbeitsspeicher, Memory „server-speicher“): Testläufe nur gezielt und begrenzt: `cd <worktree> && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/<eigene Dateien>`. Die volle Suite (`npm test`) läuft **nur in Z1**, und schwere Läufe (volle Suite, Chrome-PDF-Druck, `npm ci`, `vite build`) laufen hinter `flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock`. Vorher `free -m`: unter 1500 MB „available“ warten. Nie `pkill -f` (trifft die eigene Shell), Prozesse nur per PID beenden.
- Git: jeder Strang nur in seinem Worktree und Branch; Commits klein, je Task mindestens einer, Nachricht deutsch, Schlusszeile `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`; **kein Push** (der Nutzer pusht), nie `--force`, nie `git add -A`.
- Sicherheit der Umgebung (Memory „feedback-destruktive-skripte“): kein `rm -rf`/`rsync --delete` außerhalb des eigenen Worktrees bzw. Scratchpads; keine Live-Daten lesen oder schreiben (kein Prod-Appwrite, kein Live-Stripe, keine echten Mails); `build/` nie anfassen.
- Bestehendes Verhalten bleibt: alle vorhandenen Tests bleiben grün; die bisherigen Rechnungsfelder (`mapInvoice`) bleiben, neue kommen dazu.
## Review Focus
1. **Gleichzeitige Aufwandsrechnungen** mit denselben Arbeitsblättern (Doppelklick, zwei Admins): die zweite muss mit 409 „Arbeitsblatt bereits abgerechnet“ scheitern. Test in L2 (zwei parallele `createProjectInvoice` mit gleichen `arbeitsblattIds` unter Sperre `sperreRechnung(kundenId)` = `['auftraege-rechnung:<kundenId>']`).
2. **Alte Token nach Erinnerung, neuer Fassung, Storno oder Widerruf**: der alte Link darf nie eine AB bestätigen; die Seite erklärt den Grund. Tests in L5 (Erinnerung → alter Token 404; ersetzt/storniert/widerrufen → 410 mit Meldung).
3. **Schlussrechnung neben stornierten oder noch nicht gesendeten Rechnungen**: Stornos zählen nicht, ein offener Entwurf zur AB blockiert mit 409. Tests in L2.
4. **Unvollständige Kundendaten beim AB-Senden** (keine Kundenart, keine Anschrift, keine E-Mail, Interessent statt Kunde): klare Meldung **vor** Nummernvergabe und PDF, keine Nummer verbraucht. Tests in L3.
5. **Umlaute, €, Minuszeichen und lange Texte im PDF** (ä ö ü ß €, „−“, 200 Zeichen ohne Leerzeichen, 20 Positionen über eine Seite): lesbar, kein Glyphenausfall, Seitenumbruch mit Kopf der Tabelle. Tests in L4 (Text aus dem PDF lesen).
---
## Verträge
### V1 Collections und Attribute (`server/services/auftraege/schema.js`, L0)
Bauart wie `server/services/zeiten/schema.js` (reine Daten, kein Import von `config.js`; Helfer `str(key,size)`, `int(key)`, `bool(key)`, `dt(key)`, `index(key, ...attrs)`; alle Attribute `required: false`).
```js
export const AUFTRAEGE_COLLECTIONS = {
auftraege: {
attributes: [
str('nummer', 20), int('jahr'), int('laufnummer'), str('status', 20),
str('kundenId', 64), str('kundeJson', 2000), str('projektId', 64), str('titel', 200),
str('zeitquelleJson', 1000), str('art', 20), str('positionenJson', 20000),
int('stundensatzCents'), int('schaetzungMinuten'), int('summeCents'),
str('zahlungsplanJson', 1000), int('zahlungszielTage'),
str('auftragVom', 10), str('auftragWeg', 20), str('startAm', 10), str('fertigBis', 10),
str('texteFassung', 20), str('agbFassung', 20), str('pdfDatei', 300), str('pdfSha256', 64),
dt('gesendetAm'), str('gesendetAn', 255), str('mailStatus', 20), str('mailFehler', 500),
str('tokenHash', 64), dt('tokenGueltigBis'),
dt('bestaetigtAm'), str('bestaetigtUeber', 10), str('bestaetigtPdfSha256', 64),
bool('fruehbeginn'), str('fruehbeginnFassung', 20),
str('ersetztId', 64), str('ersetztDurchId', 64), dt('storniertAm'), dt('widerrufenAm'),
str('erstelltVon', 255), dt('erstelltAm'), dt('geaendertAm'),
],
indexes: [index('idx_kunde', 'kundenId'), index('idx_projekt', 'projektId'), index('idx_jahr', 'jahr'),
index('idx_token', 'tokenHash'), index('idx_nummer', 'nummer')], // idx_nummer NICHT unique: Entwürfe haben nummer ''
},
// Dokument-ID = Stripe-Rechnungs-ID (muss /^in_\w{1,33}$/ erfüllen; der Stripe-Nachbau vergibt in_test_<n>)
projektRechnungen: {
attributes: [
str('invoiceId', 64), str('kundenId', 64), str('projektId', 64), str('auftragId', 64), str('art', 20),
int('betragCents'), str('arbeitsblattIdsJson', 20000), str('rechnungsnummer', 64), bool('aktiv'),
str('erstelltVon', 255), dt('erstelltAm'),
],
indexes: [index('idx_kunde', 'kundenId'), index('idx_projekt', 'projektId'), index('idx_auftrag', 'auftragId')],
},
}
export const AUFTRAEGE_ZUSATZ_ATTRIBUTE = {
customers: [str('kundenart', 20)], // '' | 'unternehmen' | 'privat'
financeSettings: [str('steuernummer', 40)],
}
```
`config.collections.auftraege` (`APPWRITE_COLLECTION_AUFTRAEGE`, Vorgabe `'auftraege'`), `config.collections.projektRechnungen` (`APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN`, Vorgabe `'projektRechnungen'`).
`config.auftraege = { mode, pilotEmails, uploadDir }` mit `mode` aus `AUFTRAEGE_ENABLED` (wie `config.zeiten.mode`), `pilotEmails = commaList(process.env.AUFTRAEGE_PILOT_EMAILS, [])`, `uploadDir = process.env.AUFTRAEGE_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'auftraege')`.
### V2 Konstanten (`server/services/auftraege/konstanten.js`, L0)
```js
export const RECHNUNGS_ARTEN = ['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss']
export const AB_RECHNUNGS_ARTEN = ['anzahlung', 'schluss'] // brauchen AB und Schalter
export const AUFTRAG_ARTEN = ['festpreis', 'aufwand']
export const AUFTRAG_STATUS = ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']
export const VEREINBARTE_STATUS = ['versendet', 'bestaetigt', 'abgerechnet'] // zählen als „vereinbart“
export const AUFTRAG_WEGE = { muendlich: 'mündlich', telefon: 'telefonisch', email: 'per E-Mail', vor_ort: 'vor Ort', chat: 'im Chat' }
export const ZAHLUNGSPLAN_ARTEN = ['nach_abnahme', 'anzahlung', 'monatlich', 'frei']
export const KUNDENARTEN = ['unternehmen', 'privat']
export const STUNDENSATZ_STANDARD_CENTS = 10000
export const VIERTELSTUNDE = 15
export const TOKEN_GUELTIG_TAGE = 60
export const AGB_FASSUNG = '2026-09-30' // L7 darf diese eine Zeile anpassen
export const LIMITS = {
maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000,
maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60,
standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10,
maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140,
}
// withLocks sperrt jedes Listenelement einzeln: deshalb je Sperre GENAU EIN zusammengesetzter Schlüssel
export const SPERRE_NUMMER = ['auftraege-nummer']
export const sperreRechnung = (kundenId) => [`auftraege-rechnung:${kundenId}`] // Aufwand, Anzahlung, Schluss je Kunde
export const sperreAuftrag = (id) => [`auftraege-ab:${id}`] // jede Statusänderung einer AB (L2, L3, L5)
export const SCHALTER_AUS_MELDUNG = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.'
```
Fehlerklasse (`server/services/auftraege/fehler.js`, L0): `export class AuftragError extends Error { constructor(status, message) }` (Eigenschaft `status`) und `export function sendeFehler(res, was, err)`: `AuftragError` und `InvoiceError` (aus `stripeFinance.js`) → `res.status(err.status).json({ error: err.message })`; sonst Protokoll ohne Meldung (wie `logFailure` in `finanzen.js`) und `500 { error: 'Interner Fehler.' }`.
Statusübergänge der AB (nur diese):
`entwurf → versendet` (L3 senden) · `versendet → bestaetigt` (L5) · `versendet|bestaetigt → abgerechnet` (L2, Schlussrechnung finalisiert) · `abgerechnet → bestaetigt|versendet` (L2, Storno der Schlussrechnung; Ziel `bestaetigt`, wenn `bestaetigtAm` gesetzt) · `versendet|bestaetigt → storniert` (L3) · `versendet|bestaetigt|abgerechnet → ersetzt` (L3, beim Senden der neuen Fassung) · `versendet|bestaetigt → widerrufen` (L5, nur Kundenart `privat`). Bestätigen ist auch bei `abgerechnet` ohne `bestaetigtAm` möglich (Status bleibt `abgerechnet`).
### V3 Datenformen
```js
// Auftrag (intern; auftragStore.docToAuftrag)
{
id, nummer /* '' im Entwurf */, jahr /* number|null */, laufnummer /* number|null */, status,
kundenId, kunde /* KundeSnapshot|null, beim Senden eingefroren */, projektId /* '' ohne Projekt */, titel,
zeitquelle /* string[] Ticket-IDs (workorders.$id) */, art /* 'festpreis'|'aufwand' */,
positionen /* AbPosition[]; aufwand: [] */, stundensatzCents /* aufwand, sonst null */,
schaetzungMinuten /* aufwand optional, sonst null */, summeCents /* festpreis, sonst null */,
zahlungsplan /* { art, prozent: number|null, text: string } */, zahlungszielTage,
auftragVom /* 'YYYY-MM-DD' */, auftragWeg /* Schlüssel aus AUFTRAG_WEGE */, startAm, fertigBis /* 'YYYY-MM-DD'|'' */,
ersetztId, ersetztDurchId, texteFassung, agbFassung, pdfDatei, pdfSha256,
gesendetAm /* ISO|null */, gesendetAn, mailStatus /* ''|'gesendet'|'fehler' */, mailFehler,
tokenHash, tokenGueltigBis /* ISO|null */,
bestaetigtAm /* ISO|null */, bestaetigtUeber /* ''|'link'|'portal' */, bestaetigtPdfSha256,
fruehbeginn /* boolean */, fruehbeginnFassung, storniertAm, widerrufenAm /* ISO|null */,
erstelltVon, erstelltAm, geaendertAm,
}
// KundeSnapshot (auftragSicht.kundeSnapshot(customerDoc))
{ name, firma, strasse, plz, ort, land, email, kundenart /* ''|'unternehmen'|'privat' */, kundennummer /* customers.code || '' */ }
// AbPosition
{ beschreibung, menge, einzelpreisCents }
// ProjektRechnung (projektRechnungenStore)
{ invoiceId, kundenId, projektId, auftragId, art /* 'festbetrag'|'aufwand'|'anzahlung'|'schluss' */, betragCents,
arbeitsblattIds /* string[] */, rechnungsnummer, aktiv /* boolean */, erstelltVon, erstelltAm }
// Arbeitsblatt (arbeitsblaetter.js; normiert)
{ id, ticketId /* worksheets.workorderId */, woid, datum /* 'YYYY-MM-DD' */, minuten /* auf 15 aufgerundet */,
text /* kundentauglich, ≤ 200 */, aboZeit /* countsToPlan === true */, kommentar /* isComment === true */ }
// Position (Rechnungszeile; positionen.js)
{ schluessel /* z. B. '2026-10-06', 'KW-2026-41', 'ab-1', 'zusatz-1', 'abzug-in_123' */, beschreibung,
menge, einzelpreisCents, betragCents /* menge × einzelpreis; Minuszeile: menge 1, beide negativ */ }
// Projektkonto (projektkonto.js)
{ projektId, name, projektTyp, tickets /* [{ id, woid }] */, zeitquelle /* string[] */,
vereinbart: { festpreisCents /* number|null */, aufwand /* { stundensatzCents, schaetzungMinuten }|null */ },
berechnetCents, bezahltCents, offenCents /* number|null */,
zeit: { minuten, aboMinuten, offeneMinuten, satzCents, wertCents, offenerWertCents } }
// AuftragTeam (auftragSicht.auftragFuerTeam): Auftrag ohne tokenHash, pdfDatei, bestaetigtPdfSha256, plus
{ pdfVorhanden /* boolean */, fruehesterBeginn /* 'YYYY-MM-DD'|null */, zahlungsplanText, aufwandText /* string|null */ }
// AuftragOeffentlich (auftragSicht.oeffentlicheAnsicht) – nur für Token- und Kundenrouten
{ nummer, datum /* Berliner Tag von gesendetAm */, titel, art,
positionen /* [{ beschreibung, menge, einzelpreisCents, summeCents }] */, summeCents, stundensatzCents,
schaetzungMinuten, aufwandText, zahlungsplanText, startAm, fertigBis, kundenart, status, bestaetigtAm,
fruehbeginn, fruehesterBeginn, agbUrl /* '/rechtliches/agb-<auftrag.agbFassung || AGB_FASSUNG>.pdf' */,
texte: { bestaetigung /* BESTAETIGUNG_TEXT */, fruehbeginn /* FRUEHBEGINN_TEXT oder null bei unternehmen */,
kleinunternehmer /* AB_TEXTE.kleinunternehmer */ } }
// Kundenrouten ergänzen id und kannBestaetigen = (status === 'versendet' || (status === 'abgerechnet' && !bestaetigtAm)) && nicht Kundenansicht
```
Regeln, die mehrere Stränge brauchen (alle in L0, `auftragSicht.js`):
- `fruehesterBeginn(auftrag)`: nur bei `kunde.kundenart === 'privat'`, `!fruehbeginn` und gesetztem `gesendetAm`: Tag nach `withdrawalDeadline(new Date(gesendetAm)).lastDay` (aus `server/services/deadlines.js`); sonst `null`.
- `zahlungsplanText(auftrag)` und `aufwandText(auftrag)`: aus den Vorlagen in `texte.js` (V7), Beträge mit `euroDe`, bei `anzahlung` Anzahlung = `Math.round(summeCents * prozent / 100)`, Rest = `summeCents − Anzahlung`.
### V4 Module und Funktionen
**L0 – Fundament**
- `schalter.js`: `auftraegeAktiv(kunde) → boolean` (`kunde`: Kunden-Dokument oder `{ email }`; `on` → true, `pilot` → E-Mail klein geschrieben in `config.auftraege.pilotEmails` oder Liste enthält `*`, sonst false).
- `auftragStore.js`: `docToAuftrag(doc)`, `auftragToDoc(auftrag)` (nur Felder aus V1, JSON-Felder serialisiert), `getAuftrag(id) → Auftrag|null`, `listAuftraege({ kundenId, projektId, jahr } = {}) → Auftrag[]` (neueste zuerst), `createAuftrag(daten) → Auftrag` (setzt `erstelltAm`, `geaendertAm`, Status `entwurf`), `updateAuftrag(id, patch) → Auftrag` (setzt `geaendertAm`), `deleteAuftrag(id)`, `findAuftragByTokenHash(hash) → Auftrag|null`, `findAuftragByNummer(nummer) → Auftrag|null`.
- `projektRechnungenStore.js`: `getProjektRechnung(invoiceId) → ProjektRechnung|null`, `listProjektRechnungen({ kundenId, projektId, auftragId, nurAktiv = true } = {})`, `createProjektRechnung(invoiceId, daten) → ProjektRechnung`, `updateProjektRechnung(invoiceId, patch) → ProjektRechnung`, `umhaengen(auftragIdAlt, auftragIdNeu) → number` (Anzahl umgehängter Dokumente).
- `auftragSicht.js`: `kundeSnapshot(customerDoc)`, `fruehesterBeginn(auftrag)`, `zahlungsplanText(auftrag)`, `aufwandText(auftrag)`, `auftragFuerTeam(auftrag)`, `oeffentlicheAnsicht(auftrag)`.
- `arbeitsblaetter.js`: `ladeArbeitsblaetter(ticketIds) → Arbeitsblatt[]` (sortiert nach `datum`, dann `id`; Datum wie `parseSheetDate` in `hourLedger.js`: `TT.MM.JJJJ`, sonst ISO, sonst `$createdAt`, als Berliner Tag; Text gefiltert wie `customerWorkEntries` in `customerProjectView.js`), `ladeArbeitsblaetterNachIds(ids) → Arbeitsblatt[]`, `abrechenbar(blatt) → boolean` (`!aboZeit && !kommentar && minuten > 0`), `abgerechneteArbeitsblaetter(kundenId) → Map<worksheetId, { invoiceId, rechnungsnummer }>` (aus aktiven `projektRechnungen`), `offeneArbeitsblaetter(ticketIds, kundenId) → Arbeitsblatt[]` (abrechenbar und nicht abgerechnet), `zeitquelleFuerProjekt(projekt, auftraege) → string[]` (Vereinigung der `zeitquelle` aller ABs mit Status aus `VEREINBARTE_STATUS`, sonst `[projekt.ticketId]` ohne Leereinträge).
- `ablage.js`: `speicherePdf(kundenId, auftragId, buffer) → { pdfDatei: '<kundenId>/<auftragId>.pdf', pdfSha256 }` (unter `config.auftraege.uploadDir`, Verzeichnis 0700, Datei 0600, überschreibt nie: vorhandene Datei → Fehler), `lesePdf(pdfDatei) → Buffer` (nur `^[A-Za-z0-9_-]{1,64}/[A-Za-z0-9_-]{1,64}\.pdf$`, sonst `AuftragError(404, 'PDF nicht gefunden.')`).
- `settings.js` (Finanzen): Schlüssel `steuernummer` (Vorgabe `''`, getrimmt, höchstens 40 Zeichen, erlaubt Buchstaben, Ziffern, Leerzeichen, `/`, `-` und `.` (auch eine USt-IdNr. „DE…“); `''` löscht; sonst `SettingsError(400, 'Steuernummer ungültig.')`).
**L1 – Projektkonto**: `projektkonto.js`: `projektkontenFuerKunde(kundenId) → { auftraegeAktiv, kunde: { id, name, kundenart }, konten: Projektkonto[], ohneProjekt: { berechnetCents, bezahltCents, anzahl }, stripeOk }`; `projektkontoDetail(projektId) → { konto: Projektkonto, auftraege: AuftragTeam[], rechnungen: RechnungKurz[], offeneArbeitsblaetter: Arbeitsblatt[], stripeOk }` mit `RechnungKurz = { id, number, status, createdDate, totalCents, paidCents, art, auftragId }`. Rechenregeln: Spec Abschnitt 4; `berechnetCents`/`bezahltCents` nur aus finalisierten Rechnungen (Status `offen`, `ueberfaellig`, `bezahlt`, `uneinbringlich` laut `listInvoices()`) aktiver `projektRechnungen`; `offenCents = festpreisCents === null ? null : max(0, festpreisCents − Σ total der finalisierten Rechnungen mit auftragId einer Festpreis-AB)`. Scheitert Stripe: Beträge 0, `stripeOk: false`.
**L2 – Rechnungen**: `positionen.js` (rein): `festePositionen(items) → Position[]` (Eingabe `{ description, quantity, unitAmountCents }` wie heute), `aufwandPositionen(blaetter, { satzCents, zeilentexte = {}, maxZeilen = 20 }) → { positionen, minuten, von, bis }` (eine Zeile je Tag, Text „06.10.2026: <Texte des Tages, mit '; ' verbunden, gekürzt> – 2,25 Std. à 100,00 €“, ohne Text „Arbeitszeit“; zu viele Zeilen → älteste Tage zu ISO-Kalenderwochen „KW 41/2026: …“, Schlüssel `KW-2026-41`; `zeilentexte[schluessel]` ersetzt den Text vor dem Gedankenstrich), `anzahlungPosition(auftrag, { prozent, betragCents, bezeichnung = 'Anzahlung' }, bereitsBerechnetCents) → Position`, `schlussPositionen(auftrag, fruehere, zusatz) → Position[]` (`fruehere: [{ invoiceId, rechnungsnummer, datum, betragCents, art }]`; Minuszeile „abzüglich Anzahlung, Rechnung <Nr> vom <TT.MM.JJJJ>“ bzw. „abzüglich Teilrechnung …“), `stundenText(minuten) → '2,25'`, `leistungszeitraumText(von, bis) → '06.10.2026' | '01.10.–20.10.2026' | '28.12.2026–05.01.2027'`.
`rechnungsbau.js`: `baueRechnung(eingabe) → RechnungsEntwurf` mit `eingabe = { customerId, art = 'frei', projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum }` und `RechnungsEntwurf = { kunde, art, projekt /* { id, name, ticketId }|null */, auftrag /* Auftrag|null */, positionen, summeCents, leistungszeitraum, customFields /* [{ name, value }] ≤ 4 */, metadata, projektRechnung /* Daten für createProjektRechnung ohne invoiceId|null */ }`. Zusatzfelder: `Projekt` (Name, auf 140 gekürzt; nicht bei `frei`), `Auftrag` („AB-2026-0002 vom 06.10.2026“, nur mit AB), `Leistungszeitraum` (wenn nicht leer). Metadaten zusätzlich zu den heutigen: `projektId`, `projektName`, `auftragId`, `auftragNummer`, `art`.
`stripeFinance.js`: `createProjectInvoice({ customerId, art = 'frei', projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, daysUntilDue, memo, senden, idempotencyKey, createdBy })` (Positionen über `baueRechnung`; positive Zeile → `quantity` + `unit_amount_decimal`, Minuszeile → `amount`; `custom_fields`; `rendering: { pdf: { page_size: 'a4' } }` für **alle** Rechnungen; `createProjektRechnung` nach dem Entwurf bei `art !== 'frei'`; Aufwand, Anzahlung und Schluss unter `withLocks(sperreRechnung(customerId))`), neue Funktion `rechnungsVorschau(eingabe) → { positionen, summeCents, leistungszeitraum, customFields }` (ohne Stripe-Schreibzugriff), `mapInvoice` liefert zusätzlich `projektId`, `projektName`, `art` (`metadata.art`, sonst `'abo'` bei Abo-Rechnungen bzw. `'frei'`), `auftragId`, `auftragNummer`; Finalisieren (`senden` oder `sendInvoiceNow`) setzt `projektRechnungen.rechnungsnummer` und bei `schluss` die AB auf `abgerechnet`; `voidInvoice`/`deleteDraft` setzen `aktiv: false` und nehmen `abgerechnet` zurück.
**L3 – AB-Kern**: `nummern.js`: `formatNummer(jahr, laufnummer) → 'AB-2026-0001'`, `vergibNummer(jetzt = new Date()) → { nummer, jahr, laufnummer }` (größte `laufnummer` des Berliner Jahres + 1; Aufrufer hält `SPERRE_NUMMER`). `auftraege.js`: `pruefeEingabe(eingabe, { teilweise = false }) → bereinigte Felder` (AuftragError 400 mit Feldmeldung), `legeEntwurfAn(eingabe, { von })`, `aendereEntwurf(id, eingabe)`, `loescheEntwurf(id)`, `vorschauPdf(id, { deps }) → Buffer`, `sendeAuftrag(id, { von, deps }) → { auftrag, mailFehler: boolean }`, `erinnereAuftrag(id, { deps }) → { auftrag, mailFehler }`, `neueFassung(id, { von }) → Auftrag` (neuer Entwurf mit `ersetztId`), `storniereAuftrag(id) → Auftrag`. `deps = { erzeugeAuftragPdf, neuerToken, sendeAuftragMail }`; Vorgabe per **dynamischem Import** von `./pdf.js` (L4), `./token.js` (L5), `./mails.js` (L5) beim Aufruf, damit L3 ohne L4/L5 testbar ist (Tests übergeben Attrappen). Reihenfolge beim Senden: Eingabe, Kunde (fester Kunde, E-Mail, Anschrift, Kundenart) prüfen → Sperre `SPERRE_NUMMER` → Nummer → PDF erzeugen → `speicherePdf` → Token → Dokument speichern (Status `versendet`) → Sperre frei → Mail → `mailStatus`. Hat die AB `ersetztId`: alte AB `ersetzt` (+ `ersetztDurchId`), `umhaengen(alt, neu)`.
**L4 – AB-PDF**: `pdf.js`: `auftragPdfDaten(auftrag, { settings, vorschau = false, jetzt, ersetzt }) → AuftragPdfDaten`, `erzeugeAuftragPdf(auftrag, { settings, vorschau = false, jetzt, ersetzt }) → Promise<Buffer>` (`ersetzt = { nummer, datum }` optional, sonst über `getAuftrag(auftrag.ersetztId)`; `AB_TEXTE.termine` bekommt `privat`). `auftrag.kunde` ist immer gesetzt (L3 übergibt bei der Vorschau `kundeSnapshot(liveKunde)` und `nummer: 'Entwurf'`). `settings = { steuernummer, bankDetailsText }`. Inhalt laut Spec Abschnitt 2 „Inhalt des PDFs“, Texte aus `texte.js`. Schrift: statische Figtree-Schnitte (Regular, SemiBold) als Dateien unter `server/assets/fonts/` (OFL, Lizenzdatei dazu); Logo aus `public/` (vorhandene Datei, die die Mailvorlagen nutzen).
**L5 – Bestätigung und Mails**: `token.js`: `neuerToken(jetzt = new Date()) → { token, hash, gueltigBis /* Date */ }`, `hashToken(token) → string|null` (null bei falschem Format). `bestaetigung.js`: `bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() }) → Auftrag` (Regeln V2; idempotent; `fruehbeginn` nur bei `privat`; schickt danach `sendeBestaetigtMails`, Fehler nur ins Protokoll). `mails.js`: `sendeAuftragMail(auftrag, { pdf, token, erinnerung = false }) → Promise<void>` (wirft bei Fehler), `sendeBestaetigtMails(auftrag) → Promise<void>`. Widerruf in `server/routes/abo.js`: `order = 'projekt'` mit Feld `abNummer`.
**L6 – Kundenportal**: nur Oberfläche und `customerInvoices.js` (Feld `projekt: raw.metadata?.projektName || ''`).
**L7 – Rechtstexte**: Wortlaute in `texte.js`, neue Rechtstext-Dateien, Datenschutz, Druckskripte (Spec Abschnitt 8) und das Freigabe-Dokument `/root/reports/auftraege-rechnungen-2026-10-06/rechtsrunde.md`.
### V5 HTTP-Endpunkte
Antwortformat bei Fehlern immer `{ error: '<Meldung>' }`. Admin-Routen: Router mit `requireAppwriteStaff` (wie `finanzen.js`); Team-Routen: unter `/api/team` (dort gilt `requireStaffJwt`); Kundenrouten: `requireSession` und eigener Kunde; Token-Routen: ohne Anmeldung, Ratenbegrenzung im Arbeitsspeicher je IP (GET 120/h, POST 10/h → 429 `{ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }`).
| Strang | Methode, Pfad | Eingabe | Antwort (200) | Fehler |
|---|---|---|---|---|
| L1 | GET `/api/admin/finanzen/projektkonten?kunde=<id>` | – | `{ auftraegeAktiv, kunde, konten, ohneProjekt, stripeOk }` | 400 ohne Kunde, 404 Kunde unbekannt |
| L1 | GET `/api/admin/finanzen/projektkonten/:projektId` | – | `{ konto, auftraege, rechnungen, offeneArbeitsblaetter, stripeOk }` | 404 |
| L1 | GET `/api/team/arbeitsblaetter/abgerechnet?ticketId=<id>` | – | `{ abgerechnet: { <worksheetId>: '<Rechnungsnummer>' \| 'Entwurf' } }` (unbekanntes Ticket: `{ abgerechnet: {} }`) | 400 ohne ticketId |
| L2 | POST `/api/admin/finanzen/rechnungen` | `{ customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung: { prozent, betragCents, bezeichnung }, zeilentexte, leistungszeitraum, daysUntilDue, memo, senden, idempotencyKey }` | `{ rechnung }` (wie heute, `mapInvoice` erweitert) | 400 Eingabe, 404, 409 (Arbeitsblatt bereits abgerechnet, offener Entwurf zur AB, Nichts mehr offen, Adresse), 502 |
| L2 | POST `/api/admin/finanzen/rechnungen/vorschau` | wie oben ohne `senden`, `idempotencyKey`, `daysUntilDue`, `memo` | `{ positionen, summeCents, leistungszeitraum, customFields }` | wie oben |
| L2 | GET `/api/admin/finanzen/rechnungen` | wie heute | Einträge zusätzlich mit `projektId`, `projektName`, `art`, `auftragId`, `auftragNummer` | – |
| L3 | GET `/api/admin/finanzen/auftraege?kunde=<id>` | – | `{ auftraege: AuftragTeam[] }` | 400, 404 Schalter aus |
| L3 | POST `/api/admin/finanzen/auftraege` | `{ kundenId, projektId, titel, kundenart, art, positionen, stundensatzCents, schaetzungMinuten, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle }` (`kundenart` setzt `customers.kundenart`, wenn dort leer) | `{ auftrag: AuftragTeam }` | 400, 404 |
| L3 | GET / PATCH / DELETE `/api/admin/finanzen/auftraege/:id` | PATCH wie POST (teilweise) | `{ auftrag }` bzw. `{ ok: true }` | 404, 409 kein Entwurf |
| L3 | GET `/api/admin/finanzen/auftraege/:id/pdf` | – | `application/pdf` (gesendet: gespeicherte Datei; Entwurf: Vorschau mit „Entwurf“) | 404, 409 Entwurf unvollständig |
| L3 | POST `/api/admin/finanzen/auftraege/:id/projekt` | `{ projektId }` | `{ auftrag }` (setzt projektId und, wenn leer, zeitquelle = [Ticket des Projekts]; PDF unverändert) | 400, 404, 409 (hat schon ein Projekt; Status storniert/ersetzt/widerrufen) |
| L3 | POST `/api/admin/finanzen/auftraege/:id/senden` | – | `{ auftrag, mailFehler }` | 400 Kundenart/E-Mail/Interessent, 409 Adresse, kein Entwurf, Entwurf unvollständig; 500 PDF |
| L3 | POST `…/auftraege/:id/erinnern` | – | `{ auftrag, mailFehler }` | 409 nicht `versendet` |
| L3 | POST `…/auftraege/:id/neue-fassung` | – | `{ auftrag }` (neuer Entwurf) | 409 |
| L3 | POST `…/auftraege/:id/stornieren` | – | `{ auftrag }` | 409 |
| L5 | GET `/api/auftrag/:token` | – | `{ auftrag: AuftragOeffentlich }` | 404 `'Dieser Link ist ungültig oder abgelaufen.'`; 410 `'Diese Auftragsbestätigung wurde ersetzt.'` / `'… wurde storniert.'` / `'… wurde widerrufen.'`; 429 |
| L5 | GET `/api/auftrag/:token/pdf` | – | `application/pdf` | wie oben |
| L5 | POST `/api/auftrag/:token/bestaetigen` | `{ bestaetigt: true, fruehbeginn: boolean }` | `{ auftrag: AuftragOeffentlich }` | 400 „Bitte bestätigen Sie den Auftrag mit dem Häkchen.“, 404/410, 429 |
| L5 | GET `/api/auftraege` (Kunde) | – | `{ aktiv, auftraege: (AuftragOeffentlich & { id, kannBestaetigen })[] }` (keine Entwürfe) | 401 |
| L5 | GET `/api/auftraege/:id/pdf` (Kunde) | – | `application/pdf` | 404 fremd/unbekannt |
| L5 | POST `/api/auftraege/:id/bestaetigen` (Kunde) | `{ bestaetigt: true, fruehbeginn }` | `{ auftrag }` | 400 (Text wie Token-Route), 404, 410 (Meldungen wie Token-Route); in der Kundenansicht sperrt der Wächter (kein Eintrag in `KUNDENANSICHT_ALLOWED`) |
| L5 | POST `/api/abo/public/withdraw` | zusätzlich `order: 'projekt'`, `abNummer` (optional; ohne/ungültig nur Mails mit Vermerk „ungeprüft“) | wie heute | wie heute; gilt unabhängig vom Schalter |
| L0 | GET/PATCH `/api/admin/finanzen/einstellungen` | zusätzlich `steuernummer` | wie heute | 400 |
Ist der Schalter für den Kunden aus: Admin-AB-Routen 404 mit `SCHALTER_AUS_MELDUNG`; Rechnungsarten `anzahlung`/`schluss` 400 mit derselben Meldung; Token- und Kundenrouten antworten wie bei unbekannter AB (404) bzw. `GET /api/auftraege` mit `{ aktiv: false, auftraege: [] }`.
Einbindung (L0): in `server/index.js` **vor** `app.use('/api/admin/finanzen', adminFinanzenRoutes)`: `app.use('/api/admin/finanzen/projektkonten', adminProjektkontenRoutes)`, `app.use('/api/admin/finanzen/auftraege', adminAuftraegeRoutes)`; außerdem `app.use('/api/auftrag', publicAuftragRoutes)` und `app.use('/api/auftraege', kundeAuftraegeRoutes)`; in `server/routes/team/index.js` nach `requireStaffJwt`: `router.use('/arbeitsblaetter', arbeitsblaetterTeamRoutes)`.
### V6 Mails (L5)
Vorlagen in `server/mail-templates/` mit Kopfkommentar wie die vorhandenen (`renderMail`):
- `K31 Auftragsbestaetigung.html` – Betreff „Ihre Auftragsbestätigung {{nummer}} – {{titel}}“; Platzhalter `name, nummer, titel, datum, betrag, link, gueltig_bis`; Blöcke `erinnerung, privat, festpreis, aufwand, kundenmail, teamkopie` (`kundenmail` = Gegenblock zu `teamkopie`, weil die Vorlagen nur positive Blöcke kennen; die Teamkopie geht als eine Mail ohne Anhänge über `sendAdminNotice` nach der Kundenmail). Anhänge: AB-PDF (`<nummer>.pdf`) und AGB-PDF (`public/rechtliches/agb-<AGB_FASSUNG>.pdf`, Dateiname `WEBklar-AGB-<AGB_FASSUNG>.pdf`). **Kein** BCC an das Team (`bccTeam: false`), weil die Mail den Bestätigungslink trägt; das Team bekommt eine Kopie mit Block `teamkopie` an `CHAT_NOTIFY_EMAILS` (statt Knopf: „Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.“), auch bei Erinnerungen. Anhang-Fassung: `auftrag.agbFassung` (Rückfall `AGB_FASSUNG`); fehlt die Datei, wirft der Versand (→ `mailStatus: 'fehler'`).
- `K32 Auftrag bestaetigt.html` – Betreff „Ihre Bestätigung zu {{nummer}} ist eingegangen“; Platzhalter `name, nummer, titel, zeitpunkt, fruehester_beginn, fruehbeginn_text`; Blöcke `fruehbeginn` (gibt `FRUEHBEGINN_TEXT` wörtlich wieder; kein Satz „Ihr Widerrufsrecht bleibt unberührt“), `ohne_fruehbeginn`.
- `T22 Auftrag bestaetigt.html` – Betreff „[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}“; Platzhalter `kunde, nummer, titel, zeitpunkt, ueber, woms_link, fruehester_beginn`; Blöcke `privat_fruehbeginn, privat_ohne_fruehbeginn`. `woms_link = <womsBaseUrl>/customers/<kundenId>?tab=invoices`.
- Link der K31: `<config.publicBaseUrl>/auftrag.html#t=<token>` (Fragment: landet nicht in Server- und Proxy-Protokollen; die Seite liest `#t=` und als Rückfall `?t=`).
- `K04 Eingang Widerruf.html` und `T09 Widerruf eingegangen.html` bekommen je einen Block `projekt` (Vertragsart „Projektauftrag (Auftragsbestätigung <Nr>)“; bei Unternehmen der Hinweis „kein gesetzliches Widerrufsrecht“ im K04-Block).
### V7 Texte (`server/services/auftraege/texte.js`; L0 legt an, L7 formuliert)
Exportnamen und Formen (verbindlich):
```js
export const TEXTE_FASSUNG = '2026-10-06'
export const ABSENDER = { name: 'WEBklar GbR', strasse: 'Am Schwimmbad 10', plzOrt: '67722 Winnweiler',
telefon: '0176 23726355', email: 'support@webklar.com', web: 'webklar.com', vertretenDurch: 'Kenso Grimm und Justin Klein' }
export const AB_TEXTE = {
titel: 'Auftragsbestätigung',
einleitung: ({ datum, weg }) => string, // „Vielen Dank für Ihren Auftrag vom 05.10.2026 (telefonisch). Wir bestätigen ihn wie folgt:“
aufwand: ({ satz, schaetzung /* '10' Stunden oder null */ }) => string,
kleinunternehmer: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.',
zahlungsplan: {
nach_abnahme: ({ ziel }) => string, anzahlung: ({ prozent, anzahlung, rest, ziel }) => string,
monatlich: ({ ziel }) => string, frei: ({ text, ziel }) => string,
},
termine: ({ start /* 'TT.MM.JJJJ'|null */, fertig /* 'TT.MM.JJJJ'|null */, privat /* boolean */ }) => string|null, // privat: „Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.“
mitwirkung: string, // Verweis auf AGB Nr. 7 und 8
agb: ({ fassung /* 'TT.MM.JJJJ' */ }) => string, // „Es gelten unsere Allgemeinen Geschäftsbedingungen in der Fassung vom …, die dieser Bestätigung beiliegen.“
ersetzt: ({ nummer, datum }) => string, // „Diese Auftragsbestätigung ersetzt AB-… vom ….“
gruss: string,
}
export const BESTAETIGUNG_TEXT = 'Ich bestätige den Auftrag zu diesen Bedingungen.'
export const FRUEHBEGINN_FASSUNG = '2026-10-06'
export const FRUEHBEGINN_TEXT = string
export const WIDERRUFSBELEHRUNG_PROJEKT = { titel: 'Widerrufsbelehrung', abschnitte: [{ ueberschrift: string, absaetze: string[] }] }
export const WIDERRUFSFORMULAR = { titel: 'Muster-Widerrufsformular', einleitung: string, zeilen: string[] }
```
### V8 WOMS-Schnittstellen (W0/W1/W2)
- `src/lib/financeApi.js` (W0 ergänzt): `getProjektkonten(kundeId)`, `getProjektkonto(projektId)`, `previewInvoice(body)`, `createInvoice(body)` (besteht, Körper erweitert), `listAuftraege(kundeId)`, `getAuftrag(id)`, `createAuftrag(body)`, `patchAuftrag(id, body)`, `deleteAuftrag(id)`, `sendAuftrag(id)`, `erinnereAuftrag(id)`, `neueFassungAuftrag(id)`, `storniereAuftrag(id)`, `getAuftragPdf(id) → Blob`.
- `src/lib/teamApi.js` (W0 ergänzt): `getAbgerechnet(ticketId) → { abgerechnet }`.
- `src/components/auftraege/AuftraegeUndRechnungen.jsx` (W0): Inhalt des Kundenreiters `invoices` (Beschriftung „Aufträge und Rechnungen“), Props `{ customer, onShowOverview }`; lädt `getProjektkonten` und (wenn `auftraegeAktiv`) `listAuftraege`; zeigt `ProjektkontoListe`, `AuftragListe` (nur mit Schalter), Rechnungsliste (bestehende `InvoiceTable`) und öffnet `RechnungDialog` bzw. `AuftragDialog`.
- `ProjektkontoListe.jsx` (W1): Props `{ konten, ohneProjekt, auftraegeAktiv, stripeOk, onRechnung(projektId|null), onAuftrag(projektId|null) }`; die Kindkomponenten rendern ihre `Card` selbst.
- `RechnungDialog.jsx` (W1): Props `{ open, onClose, onCreated(rechnung, info /* unverändert weiterreichen: senden, notice, tone, finalizedFilter */), kunde /* Kundendokument oder null = Kundenwahl */, projektId /* undefined = keine Vorauswahl, null = „Ohne Projekt“ */, auftraegeAktiv /* nur Startwert; danach gilt getProjektkonten */, onShowOverview /* optional */ }`; lädt `getProjektkonten` selbst; sitzt in `InvoiceTable.jsx` (dort ersetzt W1 das Modal) – `FinancePage.jsx` bleibt unverändert; ersetzt `InvoiceCreateModal` überall (auch `FinancePage`).
- `AuftragListe.jsx` (W2): Props `{ auftraege, konten, onChanged(), onBearbeiten(auftrag), onShowOverview }` (W0 reicht `konten` und `onShowOverview` durch); Aktion „Projekt zuordnen“ für ABs ohne Projekt (`ordneAuftragProjektZu(id, projektId)` in financeApi.js).
- `AuftragDialog.jsx` (W2): Props `{ open, onClose, onSaved(auftrag), kunde, projektId /* oder null */, auftrag /* Entwurf zum Bearbeiten oder null */, konten /* Projektkonten für die Projektwahl */, onShowOverview }`; Hinweise (nicht blockierend): fehlende Steuernummer im Schritt 4; bei Weg „vor Ort“ und Kundenart privat „zusätzlich ausdrucken und übergeben“ (§ 312f Abs. 1 BGB). PDF öffnen: `openInNewTab(async () => URL.createObjectURL(await getAuftragPdf(id)))`.
- Reine Ansichtslogik mit Tests: `src/lib/projektkontoView.js` und `src/lib/rechnungDialogView.js` (W1), `src/lib/auftragView.js` (W2).
- Je Strang eigene CSS-Datei: `src/components/auftraege/projektkonto.css`, `rechnung-dialog.css` (W1), `auftrag.css` (W2), Gestaltung nach den Regeln des Redesigns (Komponenten aus `src/components/ui`, `Dialog`, `financeUi.js`).
- Weitere Änderungen: W1 `InvoiceTable.jsx` (Spalten Projekt, Art; Modal → `RechnungDialog`; „weitere Tickets“ liest W1 direkt aus Appwrite und schickt `arbeitsblattIds`), `WorksheetList.jsx` (Abzeichen), `WorksheetTimesModal.jsx` (Warnung); W2 `CustomerDetailPage.jsx` (Feld Kundenart in der Übersicht), `SettingsCard.jsx` (Steuernummer); W0 `src/lib/customerTabs.js` (Beschriftung).
---
## Integration, Kontrolle, Übergabe
Plan-Datei `2026-10-06-auftraege-12-integration-kontrolle.md`:
- **Z1 Portal**: Merge in der Reihenfolge L1, L2, L3, L4, L7 (ohne seinen letzten Commit, den Datenschutz-Abschnitt: `git merge --no-ff auftraege/l7-rechtstexte~1`; der Datenschutz-Commit kommt erst beim AB-Livegang nach der Freigabe), L5, L6 nach `feature/auftraege` (`git merge --no-ff`), nach jedem Merge die Tests des Strangs; dann Durchlauf-Test `test/auftraegeAblauf.test.js` über `startTestApp` (Festbetrag → Projektkonto; Aufwand → abgerechnet → Storno → frei; AB → Senden → Token-Bestätigung → Anzahlung → Schlussrechnung mit Minuszeile → `abgerechnet` → Storno → zurück; Privatperson mit/ohne Frühbeginn; Kundenansicht gesperrt; Schalter aus → 404); volle Suite unter `flock`; Node-20-Lauf der `test/auftraege*.test.js` (`docker run --rm --network host -m 1200m -v <worktree>:<worktree>:ro node:20-alpine`).
- **Z2 WOMS**: Merge W1, W2; `npm test`; `vite build`; Bildschirmfotos (Desktop 1440 px, Handy 390 px) aller neuen Ansichten mit nachgebildeten Antworten.
- **Z3 Kontrolle**: fünf Prüfer parallel (sonnet) – Geld und Rechenlogik; Sicherheit und Datenschutz (Token, öffentliche Routen, Rechte, Pfade, IDOR, Kundenansicht); Recht und Texte (Spec, § 34a UStDV, § 312f BGB, Widerruf, Frühbeginn, Datenschutz); Oberfläche (Bildschirmfotos); Spec-Abdeckung (jede Zeile der Spec → Code/Test). Jeder Befund wird von einem zweiten Agenten (haiku) gegengeprüft; nur bestätigte Critical/Important werden korrigiert (sonnet), danach betroffene Tests und Z1-Durchlauf erneut.
- **Übergabe**: `/root/reports/auftraege-rechnungen-2026-10-06/uebergabe.md` mit Push-Befehlen (Portal `feature/auftraege:main`, WOMS erst nach dem Redesign), Setup-Befehl `docker exec -w /app webklar-kundenbereich-server node scripts/auftraege-setup.mjs`, Compose-Zeilen für `AUFTRAEGE_ENABLED`/`AUFTRAEGE_PILOT_EMAILS` (Vorgabe aus), Steuernummer (Stripe-Dashboard + WOMS-Einstellung), Rechtsrunde (Freigabe-Dokument), Live-Prüfungen.
## Livegang (Kurzfassung, Details in der Übergabe)
1. Nutzer: Steuernummer im Stripe-Dashboard hinterlegen.
2. Nutzer: Portal `feature/auftraege` → `main` pushen; sofort danach Setup-Skript; Container-Log prüfen. Rechnungsteil (Projektkonto, Festbetrag, Aufwand, A4, Zusatzfelder) ist live, AB-Teil aus.
3. Nutzer: WOMS pushen, sobald das Redesign auf `test` ist.
4. Rechtsrunde: Nutzer gibt die Texte frei; Claude lädt sie in WOMS → Rechtliches hoch (kurzlebiger Schlüssel).
5. `AUFTRAEGE_ENABLED=pilot` mit Testkunde, Probe-AB an den Testkunden, dann `on`.
File diff suppressed because it is too large. Load diff
Loaded 100 of 1104 files, more files were not shown because too many files have changed in this diff. Show more