Compare commits

...
85 Commits
Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
159 changed files with 18610 additions and 79 deletions

No files matched your search

Binary file not shown.
@@ -1 +1 @@
d73389a0cf78380029948d91db022e20d7d2f7182ea1c4fa78478d78c719c938
8facae22cea59e23f6a9350374e03f0c42d0cba2e3adfda6062034d47081617a
+43
View File
@@ -0,0 +1,43 @@
# 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}], reinigungCent, personenInklusive, aufpreisPersonNachtCent, direktrabattProzent, steuerart:'kleinunternehmer'|'regel', ustUebernachtung, ustReinigung, fristTage, stornobedingungen } | null }`; PUT sendet genau diese Felder.
- `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.
+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. |
@@ -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,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,493 @@
# Buchungskalender (Funktion `unterkunft`) Implementation Plan
> **Hinweis (2026-10-07, Livegang-Vorbereitung):** Die Mail-Nummern K31, K32 und T22 gehören seit dem Merge mit `origin/main` den Aufträgen (Auftragsbestätigung). Im Buchungskalender heißen die Mails jetzt **K36** (Neue Buchungsanfrage, vorher K31), **K37** (Neue Buchung, vorher K32) und **T23** (Neue Anfrage Buchungskalender, vorher T22). Der folgende Text nennt noch die alten Nummern.
> **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:** Neue Portal-Funktion „Buchungskalender“ für Ferienwohnungen: Kalender-Widget mit Anfrage, Freigabe durch den Vermieter, verbindliche Buchung per Link, Rechnung im Namen des Vermieters, Zahlung über Stripe Connect oder vor Ort, iCal-Abgleich; erste Kundin Donau Home.
**Architecture:** Eigenes Modul `server/services/unterkunft/` nach dem Muster von Webshop (`shop`) und Terminplaner (`termine`). Ein serieller Kern (Strang 0) legt Schema, Konfiguration, reine Logik, Statusmaschine und **alle** Verdrahtungen in gemeinsam genutzten Dateien an; danach laufen vier Stränge parallel in eigenen Worktrees und berühren nur noch ihre eigenen Dateien (Ausnahmen sind je Task genannt). Abschluss: Zusammenführen, UI-Durchlauf, Review.
**Tech Stack:** Node 20/24 ESM, Express 4, Appwrite (node-appwrite, Server-Key), Stripe Connect (stripe ^19), nodemailer, Vanilla-ES-Module im Frontend, `node:test`, Playwright-core für UI.
**Spec:** `docs/superpowers/specs/2026-10-07-buchungskalender-design.md` (Abschnitte 1–16 + Nachträge N1–N6 in Abschnitt 17; N1–N6 gehen vor).
**Code-Fakten:** `/tmp/claude-0/-root/b360f353-cb78-442e-ada0-d24927a23169/scratchpad/plan/fakten-portal.md` und `fakten-woms.md` (vor jedem Task die genannten Muster dort nachschlagen; bei Abweichung zählt der Code).
## Global Constraints
- **Sicherheit (in JEDEN Agenten-Prompt kopieren):** Kein `rm -rf`, `rsync --delete`, `git reset --hard`, `git clean`, `git checkout -- .`, `git push`, kein `--force`; nichts außerhalb des eigenen Worktrees und des Scratchpads schreiben; keine destruktiven Proben; nie `/dev/null` o. ä. löschen oder ersetzen; nie Produktions-Container, `.env`, compose-Dateien oder Live-Datenbanken anfassen.
- **Speicher:** Server hat wenig RAM. Jeder Node-Testlauf: `flock /tmp/claude-0/uk-test.lock nice timeout 600 node --max-old-space-size=1024 --test <datei>`; nie `npm test` (alles parallel); UI-Läufe vorher `free -m`, nur wenn „available“ > 1500 MB.
- **node_modules:** Worktrees haben keins → `ln -s ../dev-termine/node_modules node_modules` (Portal), untracked lassen, **nie** `git add -A`, immer Dateien einzeln stagen. Keine neuen Abhängigkeiten.
- **Commits:** Autor `Kenso Grimm <kenso@webklar.com>`, Nachricht deutsch, endet mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Kein Push (macht der Inhaber).
- Funktionsschlüssel `unterkunft`; UI-Name „Buchungskalender“; Portal-Hash `#buchungen`; Fassung Rechtstexte `2026-10-07`.
- Schalter `UNTERKUNFT_ENABLED` (`off`|`pilot`|`on`), `UNTERKUNFT_PILOT_EMAILS` (Komma, `*` = alle).
- Stripe-Produkt `unterkunftSetup`: 19900 Cent, Lookup-Key `webklar_unterkunft_einrichtung_19900`, `priceLabel '199 €'`, `setupEstimate 'meist innerhalb von 1–2 Wochen'`.
- Collections: `ukUnterkuenfte`, `ukPreise`, `ukSperren`, `ukAnfragen`, `ukRechnungen`, `ukEinstellungen` (Dokument-ID = `featureId`). Kein Feld heißt `data`. JSON-Felder als Text.
- Datumswerte im Modul sind Kalendertage `YYYY-MM-DD` (Berlin); `bis` = Abreisetag, exklusiv. Nie mit lokaler Zeitzone rechnen; „heute“ über `termine/zeit.js` `heute()`.
- Geld immer ganze Cent (Integer), Bruttobeträge.
- Mail-IDs: G16–G22, K31–K34, T22 (vor Anlage gegen `server/mail-templates/` prüfen).
- Gast-Mails: Sie-Form, Absender `config.smtp.from` mit Anzeigename = Firma des Vermieters, Reply-To = E-Mail des Vermieters. K-Mails: Du-Form.
- Öffentliche Antworten enthalten nie Kunden-, Projekt-, Feature-, Anfrage-IDs (außer der Anfrage-ID im Buchungslink), keine E-Mails außer Anbieterdaten.
- Sperren nur über `withLocks([key], fn)` aus `server/services/keyedLock.js`; Schlüssel `unterkunft:<unterkunftId>` (Belegung/Status) und `uk-rechnungsnr:<featureId>`.
- Statuswerte exakt: `neu`, `freigegeben`, `gebucht`, `bezahlt`, `abgelehnt`, `abgelaufen`, `storniert`; `zahlart` ∈ `online|vorort`; `bezahltVia` ∈ `stripe|vorort`.
## Review Focus
1. **Sommerzeit/Datumsgrenzen:** Aufenthalt 2026-10-24 → 2026-10-26 (Zeitumstellung 25.10.) = genau 2 Nächte, Preis für 2 Nächte; Anreise heute (Berlin) ist erlaubt, gestern nicht. → Tests in Task 2 und Task 3.
2. **Gleichzeitige Anfragen für dieselben Nächte:** Zwei Anfragen überlappen; Freigabe der ersten klappt, Freigabe der zweiten scheitert mit `409 zeitraum_belegt`, ohne Mail; zwei gleichzeitige `bucheVerbindlich` derselben Anfrage ergeben genau eine Rechnung. → Tests in Task 4.
3. **iCal-Formate aus der Praxis:** Booking.com (`DTSTART;VALUE=DATE`), Airbnb, `DATE-TIME` mit `Z` oder `TZID`, gefaltete Zeilen, `STATUS:CANCELLED`, fehlendes `DTEND`, HTML-Fehlerseite statt ICS (→ Fehler, alter Stand bleibt). → Tests in Task 9.
4. **Buchungslink erneut geöffnet / doppelt geklickt:** nach Buchung zeigt die Seite den Status statt erneut „buchen“; Doppelklick erzeugt keine zweite Rechnung; abgelaufener Link → klare Meldung, keine Buchung; falsches Token → 404 ohne Unterschied zu unbekannter ID. → Tests in Task 7.
5. **Unsinnige Posten des Vermieters:** leere Postenliste, Gesamtsumme ≤ 0, Text leer/über 200 Zeichen, mehr als 30 Posten, ust ≠ 0 bei Kleinunternehmer → 400 mit Feldfehler, nichts gespeichert. → Tests in Task 2 und Task 8.
---
## Ablauf der Stränge
```
Strang 0 (seriell): Task 1 → 2 → 3 → 4 → 5 Branch feature/unterkunft (dev-unterkunft)
danach parallel, je eigener Worktree von feature/unterkunft:
Strang 1 Server : Task 6 → 7 → 8 → 9 → 10 → 11 feature/uk-server (dev-uk-server)
Strang 2 Oberfl. : Task 12 → 13 → 14 → 15 feature/uk-ui (dev-uk-ui) + WOMS feature/unterkunft
Strang 3 Widget : Task 16 → 17 → 18 feature/uk-widget (dev-uk-widget) + Donau-Repo
Strang 4 Mail/PDF : Task 19 → 20 → 21 feature/uk-mail (dev-uk-mail)
Abschluss (seriell): Task 22 (Merge + UI-Durchlauf) → Task 23 (Gesamtreview)
```
Stränge 2 und 3 bauen gegen die API-Verträge aus Task 5 (Routen + Antwortformen sind dort festgelegt) und testen mit Attrappen; echte Daten erst in Task 22. Worktree je Strang: `git -C /home/webklar/apps/kundenbereich.webklar.com/build worktree add -b feature/uk-<x> ../dev-uk-<x> feature/unterkunft` (Upstream danach mit `git branch --unset-upstream` lösen).
---
## Strang 0: Kern
### Task 1: Konfiguration, Schalter, Schema, Setup-Skript
**Files:**
- Modify: `server/config.js` (Env-Lesen neben `SHOP_ENABLED` ~Z.32; Produkt neben `shopSetup` ~Z.131 und Export ~Z.653; `collections` ~Z.612-636; neuer Block `unterkunft` neben `shop` ~Z.884)
- Create: `server/services/unterkunft/schalter.js`, `server/services/unterkunft/schema.js`, `scripts/unterkunft-setup.mjs`
- Modify: `test/support/schema.js` (`UNTERKUNFT_COLLECTIONS` in `schemaKeys`)
- Test: `test/unterkunftSchalter.test.js`, `test/unterkunftSetup.test.js`
**Interfaces:**
- Produces:
- `config.unterkunft = { mode, pilotEmails, fassung:'2026-10-07', formMinSekunden:3, minAntwortMs:300, icalIntervallMin:15, icalMaxBytes:1_000_000, icalTimeoutMs:10_000, icalFehlerStunden:6, horizontMonate:18, maxPosten:30, postenTextMax:200, nachrichtMax:2000, anfragenJeSiteStunde:30, ipHashTage:30, unverbindlichMonate:6, neuVerfallTage:60, anonymisierenJahre:3, rechnungJahre:8, loeschTageNachAbschalten:90 }` (Werte bei jedem Zugriff aus Env, Muster `config.shop`).
- `config.unterkunftSetup = { key:'unterkunftSetup', name:'Buchungskalender (Einrichtung)', amountCents:19900, lookupKey:'webklar_unterkunft_einrichtung_19900', oneTime:true, priceNickname }`.
- `config.collections.{ukUnterkuenfte,ukPreise,ukSperren,ukAnfragen,ukRechnungen,ukEinstellungen}` mit Env-Override `APPWRITE_COLLECTION_UK_*`.
- `schalter.js`: `unterkunftModus(): 'off'|'pilot'|'on'`, `unterkunftFuerKunde(email): boolean`.
- `schema.js`: `export const UNTERKUNFT_COLLECTIONS` (reine Daten, Muster `shop/schema.js`, Helfer `str/int/bool/dt/index`).
- `scripts/unterkunft-setup.mjs`: `export async function main({ check=false, client, log })`.
- [ ] **Step 1: Schalter-Test schreiben** (Muster `test/shopSchalter.test.js`): `off` bei leer/unbekannt; `pilot` → nur `UNTERKUNFT_PILOT_EMAILS` (case-insensitiv), `*` = alle; `on` → alle.
- [ ] **Step 2: Setup-Test schreiben** (Muster `test/shopSetup.test.js`): `--check` gegen leeren Mock meldet alle 6 Collections als FEHLT und schreibt nichts; ohne `check` legt es alle an, zweiter Lauf legt nichts an; jede Collection hat die Attribute aus Spec §3 + N2, u. a.:
- `ukUnterkuenfte`: featureId, customerId, projectId, name(120), beschreibung(500), maxPersonen int, mindestNaechte int, checkIn(5), checkOut(5), sort int, aktiv bool, icalImporte(4000), icalExportToken(64); Index featureId.
- `ukPreise`: unterkunftId, featureId, staffeln(2000), reinigungCent, personenInklusive, aufpreisPersonNachtCent, direktrabattProzent, steuerart(20), ustUebernachtung, ustReinigung, fristTage, stornobedingungen(5000); Index unterkunftId.
- `ukSperren`: unterkunftId, featureId, von(10), bis(10), quelle(60), notiz(200); Indizes unterkunftId, (unterkunftId,quelle).
- `ukAnfragen`: featureId, customerId, projectId, unterkunftId, nummer(12), von, bis, personen int, status(12), gast(3000), rechnungsadresse(1500), posten(8000), summen(1000), nachrichtVermieter(2000), fristAt dt, tokenHash(64), zahlart(8), bezahltVia(8), bezahltAt dt, stripeSessionId(100), stripePaymentIntentId(100), erstattetCent int, stornoGrund(500), stornobedingungenText(5000), rechtstexteFassung(20), ipHash(16), freigegebenAt/gebuchtAt/abgelehntAt/abgelaufenAt/storniertAt dt, rechnungId(36), anonymisiertAt dt; Indizes featureId, unterkunftId, (unterkunftId,status), status.
- `ukRechnungen`: featureId, anfrageId, art(8), nummer(24), datum(10), bruttoCent int, inhalt(20000); Indizes featureId, anfrageId, (featureId,nummer).
- `ukEinstellungen`: featureId, firma(150), inhaber(150), strasse(150), plz(10), ort(100), email(200), telefon(50), steuernummer(30), ustId(20), iban(34), bic(11), bank(100), rechnungsPraefix(8), rechnungZaehler(100), akzentfarbe(7), stripeAccountId(40), stripeStatus(4000), onlineZahlung bool, rechnungAuftragAt dt, anfrage(2000), anfrageStatus(16), anfrageAt dt, anfrageEntschiedenAt dt, anfrageEntschiedenVon(120), anfrageBegruendung(1000), icalFehlerMailAt dt.
- [ ] **Step 3: Tests laufen lassen, FAIL erwartet** (`flock … --test test/unterkunftSchalter.test.js test/unterkunftSetup.test.js`).
- [ ] **Step 4: Implementieren** (config-Block, Produkt, Collections, schalter.js, schema.js, Setup-Skript als Kopie des Shop-Musters mit `createAppwriteClient()` aus `scripts/analyse-setup.mjs`), `test/support/schema.js` ergänzen.
- [ ] **Step 5: Tests PASS**; zusätzlich `test/shopSetup.test.js` und `test/shopSchalter.test.js` weiter grün.
- [ ] **Step 6: Commit** `Buchungskalender: Konfiguration, Schalter, Schema und Setup-Skript`.
### Task 2: Preisrechnung
**Files:**
- Create: `server/services/unterkunft/datum.js`, `server/services/unterkunft/preis.js`
- Test: `test/unterkunftPreis.test.js`
**Interfaces:**
- Produces:
- `datum.js`: `istTag(s): boolean` (`/^\d{4}-\d{2}-\d{2}$/` + gültig), `naechte(von, bis): number` (UTC-Datumsarithmetik auf Tagesstrings), `plusTage(tag, n): string`, `tageVon(von, bis): string[]` (belegte Nächte, `bis` exklusiv), `formatTag(tag): '12.11.2026'`.
- `preis.js`:
- `berechnePreis({ preise, von, bis, personen }) → { naechte, posten:[{text, menge, einzelCent, ustProzent}], summen }`
- `summiere(posten, steuerart) → { bruttoCent, nettoCent, ust:[{ satz, nettoCent, steuerCent }] }` (bei `kleinunternehmer` `ust: []`, netto = brutto)
- `pruefePosten(posten, steuerart) → { posten } | { fehler: { posten: string } }`
- `formatEuro(cent) → '1.234,50 €'`
- `preiseVollstaendig(preise) → boolean`
- [ ] **Step 1: Failing tests** mit `PREISE = { staffeln:[{abNaechten:1,preisProNachtCent:6500},{abNaechten:7,preisProNachtCent:5500},{abNaechten:28,preisProNachtCent:4500}], reinigungCent:6000, personenInklusive:2, aufpreisPersonNachtCent:1000, direktrabattProzent:10, steuerart:'regel', ustUebernachtung:7, ustReinigung:7 }`:
- `naechte('2026-10-24','2026-10-26') === 2`; `naechte('2026-12-30','2027-01-02') === 3`.
- 2026-11-02→2026-11-09, 2 Personen: Posten `[Übernachtung 7×5500, Endreinigung 1×6000, Direktbuchungsrabatt 10 % 1×-3850]`, `bruttoCent 40650`, `ust [{satz:7, nettoCent:37991, steuerCent:2659}]`.
- 30 Nächte, 3 Personen: Übernachtung 30×4500, „Zusatzperson“ menge 30 × 1000, Reinigung 6000, Rabatt -16500 → `bruttoCent 184500`.
- 3 Nächte, 2 Personen: `bruttoCent 23550`.
- Unterschiedliche Sätze (ustReinigung 19): zwei `ust`-Einträge, Summe netto+steuer = brutto.
- `kleinunternehmer`: `ust: []`, `nettoCent === bruttoCent`.
- Rabatt 0 / Reinigung 0 → kein Posten dafür.
- `pruefePosten`: `[]` → Fehler; Summe 0 → Fehler; Text '' oder 201 Zeichen → Fehler; 31 Posten → Fehler; `ustProzent:7` bei `kleinunternehmer` → Fehler; `ustProzent` außerhalb {0,7,19} → Fehler; `menge` kein positiver Integer → Fehler; gültige Liste → `{posten}` mit getrimmten Texten.
- `formatEuro(123450) === '1.234,50 €'`, `formatEuro(-3850) === '−38,50 €'` (U+2212).
- [ ] **Step 2: FAIL** prüfen.
- [ ] **Step 3: Implementieren.** Staffel = höchste `abNaechten` ≤ `naechte` für alle Nächte (U11). Rabatt-Basis = Übernachtung + Zusatzpersonen, `-Math.round(basis*p/100)`, ust = `ustUebernachtung` (U12). USt je Satz: `netto = Math.round(bruttoSatz*100/(100+satz))`, `steuer = bruttoSatz - netto`. Texte: `Übernachtung, ${n} Nächte`, `Zusatzperson${k>1?'en':''} (${k}), ${n} Nächte`, `Endreinigung`, `Direktbuchungsrabatt ${p} %`.
- [ ] **Step 4: PASS.**
- [ ] **Step 5: Commit** `Buchungskalender: Preisrechnung und Datumshilfen`.
### Task 3: Speicher und Belegung
**Files:**
- Create: `server/services/unterkunft/speicher.js`, `server/services/unterkunft/belegung.js`
- Test: `test/unterkunftBelegung.test.js`
**Interfaces:**
- Consumes: `datum.js` (Task 2), `appwriteAdmin.js` (`listAllDocuments`, `getDocument`, `createDocument`, `updateDocument`, `deleteDocument`, `Query`).
- Produces:
- `speicher.js` (alle JSON-Felder geparst/serialisiert; Rückgaben sind „flache“ Objekte mit `$id`):
`ladeEinstellungen(featureId) → obj|null`, `speichereEinstellungen(featureId, felder) → obj` (legt an, falls fehlt),
`listeUnterkuenfte(featureId, { nurAktive=false }) → obj[]` (nach `sort`), `ladeUnterkunft(id) → obj|null`, `speichereUnterkunft(featureId, felder, id?) → obj`,
`ladePreise(unterkunftId) → obj|null`, `speicherePreise(unterkunftId, featureId, felder) → obj`,
`listeSperren(unterkunftId) → obj[]`, `legeSperreAn(...)`, `loescheSperre(id)`,
`ladeAnfrage(id) → obj|null`, `legeAnfrageDokAn(felder) → obj`, `aktualisiereAnfrage(id, felder) → obj`, `listeAnfragen(featureId, { status?: string[], unterkunftId? }) → obj[]`.
- `belegung.js`:
- `AKTIVE_STATUS = ['gebucht','bezahlt']`
- `belegteBereiche(unterkunftId, von, bis, { ohneAnfrage, now }) → [{ von, bis, quelle:'manuell'|'ical'|'reserviert'|'gebucht' }]` (Sperren + Anfragen `gebucht|bezahlt` + `freigegeben` mit `fristAt > now`; Ausschnitt auf [von,bis))
- `istFrei(unterkunftId, von, bis, { ohneAnfrage, now }) → boolean`
- `ueberschneidet(a, b) → boolean` (rein, Halboffen-Intervalle)
- [ ] **Step 1: Failing tests** (Appwrite-Mock mit `ukSperren`, `ukAnfragen`):
- `ueberschneidet({von:'2026-11-01',bis:'2026-11-05'},{von:'2026-11-05',bis:'2026-11-07'}) === false` (Abreise = Anreise ok); `…'2026-11-04'…` → true.
- Sperre manuell 11-10..11-12 → `istFrei(u,'2026-11-11','2026-11-13')` false; `'2026-11-12','2026-11-14'` true.
- Anfrage `freigegeben` mit `fristAt` in Zukunft blockiert; mit `fristAt` in Vergangenheit nicht; `neu`, `abgelehnt`, `abgelaufen`, `storniert` blockieren nicht; `gebucht`/`bezahlt` blockieren.
- `ohneAnfrage: id` ignoriert genau diese Anfrage.
- `belegteBereiche` liefert die Quelle `ical` für `quelle:'ical:abc'`.
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Implementieren** (Abfragen per `Query.equal('unterkunftId', id)` + Status-Filter, `listAllDocuments`).
- [ ] **Step 4: PASS.**
- [ ] **Step 5: Commit** `Buchungskalender: Speicher und Belegung`.
### Task 4: Statusmaschine, Token, Rechnungsnummern
**Files:**
- Create: `server/services/unterkunft/fehler.js`, `token.js`, `rechnungen.js`, `ablauf.js`
- Create (Stubs, die die Stränge ersetzen): `server/services/unterkunft/mails.js`, `server/services/unterkunft/zahlung.js`
- Test: `test/unterkunftAblauf.test.js`, `test/unterkunftRechnungen.test.js`
**Interfaces:**
- Consumes: Tasks 1–3.
- Produces:
- `fehler.js`: `class UkFehler extends Error { constructor(status, code, felder = {}) }` — Codes u. a. `zeitraum_belegt` (409), `status_ungueltig` (409), `frist_abgelaufen` (410), `nicht_gefunden` (404), `ungueltig` (400), `mindestnaechte` (400), `zu_viele_personen` (400), `online_aus` (409).
- `token.js`: `neuesToken() → { token, hash }` (32 Byte base64url, sha256 hex), `tokenPasst(hash, token) → boolean` (timingSafeEqual), `formToken(siteKey, now)`, `pruefeFormToken(siteKey, token, now) → 'ok'|'zu_schnell'|'abgelaufen'|'ungueltig'` (Kopie `shop/token.js`, Label `'uk-form'`, Min `config.unterkunft.formMinSekunden`), `ipHashVon(ip)` (Präfix `uk-ip:`).
- `rechnungen.js`:
- `naechsteNummer(featureId, now) → 'RE-2026-0001'` (Sperre `uk-rechnungsnr:<featureId>`, Zähler `{jahr,n}` in `ukEinstellungen.rechnungZaehler`, Jahreswechsel → n=1; Präfix aus Einstellungen, Standard `RE`)
- `stelleAus(ctx, anfrage, { art:'rechnung'|'storno', bezugNummer?, now }) → rechnungDoc` (`inhalt` = `{ aussteller:{firma,inhaber,strasse,plz,ort,email,telefon,steuernummer,ustId,iban,bic,bank}, empfaenger: anfrage.rechnungsadresse, unterkunft:{name}, von, bis, naechte, personen, posten (bei storno: einzelCent negiert), summen, steuerart, zahlart, bezugNummer, anfrageNummer }`)
- `listeRechnungen(featureId) → doc[]`, `ladeRechnung(id) → doc|null`
- `mails.js` (Stub, Task 20 ersetzt den Inhalt, Signaturen bleiben): `anGast(art:'G16'|'G17'|'G18'|'G19'|'G20'|'G21'|'G22', ctx, anfrage, extra={}) → Promise<void>`, `anVermieter(template:'K31'|'K32'|'K33', ctx, values={}) → Promise<void>`; Stub protokolliert Aufrufe in `export const _aufrufe = []`.
- `zahlung.js` (Stub, Task 7 ersetzt): `erstatte(ctx, anfrage, betragCent, { now }) → Promise<{ erstattetCent }>`; Stub wirft `UkFehler(409,'online_aus')`, wenn `ctx.einstellungen.stripeAccountId` fehlt, sonst `{erstattetCent: betragCent}`.
- `ablauf.js` (jede Änderung unter `withLocks(['unterkunft:'+anfrage.unterkunftId])`, Status vor Schreiben frisch lesen):
- `ctx` = `{ featureId, customerId, projectId, siteKey, einstellungen }` (Task 5 liefert ihn)
- `legeAnfrageAn(ctx, { unterkunftId, von, bis, personen, gast:{name,firma,email,telefon,nachricht} }, { now, ipHash }) → anfrage` → prüft Tage, `von ≥ heute`, `bis ≤ heute+horizontMonate`, `mindestNaechte`, `maxPersonen`, `istFrei`; `nummer` = `A-` + 4 Zeichen `[A-Z2-9]`; Mails G16 + K31.
- `gibFrei(ctx, anfrageId, { posten?, fristTage?, nachricht?, now }) → { anfrage, token }` → nur aus `neu`; Posten = `posten` (über `pruefePosten`) oder `berechnePreis`; `istFrei(…, {ohneAnfrage})`; `fristAt = now + fristTage*24h`; `tokenHash`; `stornobedingungenText` + `rechtstexteFassung` als Schnappschuss; `ipHash` = ''; Mail G17 (`extra.token`).
- `lehneAb(ctx, anfrageId, { text, now }) → anfrage` → nur aus `neu`; `ipHash` = ''; Mail G18.
- `bucheVerbindlich(ctx, anfrageId, token, { rechnungsadresse, zahlart, now }) → { anfrage, rechnung }` → Token prüfen (sonst `nicht_gefunden`), Status `freigegeben` (bei `gebucht|bezahlt` mit gleichem Token: bestehendes Ergebnis zurückgeben, idempotent), `fristAt > now` (sonst `frist_abgelaufen`), `zahlart==='online'` nur mit `einstellungen.onlineZahlung && stripeAccountId`, Pflichtfelder Adresse (name|firma, strasse, plz, ort, land), `istFrei(…,{ohneAnfrage})`; → `gebucht`, `stelleAus(rechnung)`, `rechnungId`, Mails G19 + K32.
- `markiereBezahlt(ctx, anfrageId, { via, paymentIntentId?, now }) → anfrage` → aus `gebucht`; idempotent bei `bezahlt`; Mail G20 nur bei `via==='stripe'`.
- `storniere(ctx, anfrageId, { grund, erstattungCent=0, now }) → { anfrage, stornoRechnung|null }` → aus `freigegeben|gebucht|bezahlt`; Erstattung nur bei `bezahltVia==='stripe'`, 0 ≤ cent ≤ brutto − bereits erstattet, über `zahlung.erstatte`; Stornorechnung nur, wenn Rechnung existiert; Mail G22.
- `laufeFristenAb(now) → { abgelaufen: number }` → alle `freigegeben` mit `fristAt ≤ now` → `abgelaufen`, Mail G21.
- `UEBERGAENGE` (Objekt Status → erlaubte Ziele), `darfUebergang(von, nach) → boolean`.
- [ ] **Step 1: Failing tests Rechnungen:** erste Nummer `RE-2026-0001`, zweite `RE-2026-0002`; 10 parallele `naechsteNummer` → 10 verschiedene, lückenlos 1..10; Jahreswechsel (now 2027-01-01T00:30+01:00 Berlin) → `RE-2027-0001`; Präfix `DH` → `DH-2026-0001`; `stelleAus(storno)` negiert alle `einzelCent` und `summen.bruttoCent`, trägt `bezugNummer`.
- [ ] **Step 2: Failing tests Ablauf** (Appwrite-Mock, `mails._aufrufe` prüfen):
- Anfrage → `neu`, Nummer-Format `/^A-[A-Z2-9]{4}$/`, `_aufrufe` enthält G16 und K31.
- Anfrage in der Vergangenheit / unter Mindestnächten / mehr Personen als max / über belegte Nacht → `UkFehler` mit passendem Code, nichts gespeichert.
- Review Focus 2: Anfragen A und B überlappen; `gibFrei(A)` ok; `gibFrei(B)` → 409 `zeitraum_belegt`, keine G17 für B.
- `gibFrei` mit Posten `[{text:'Pauschale',menge:1,einzelCent:50000,ustProzent:7}]` → summen.bruttoCent 50000.
- `bucheVerbindlich` falsches Token → 404 `nicht_gefunden`; nach `fristAt` → 410; zweimal parallel mit richtigem Token → genau ein `ukRechnungen`-Dokument, beide Aufrufe liefern dieselbe `rechnung.nummer`.
- `zahlart:'online'` ohne Stripe-Konto → 409 `online_aus`.
- `laufeFristenAb` setzt `abgelaufen` und schickt G21; Zeitraum danach frei.
- `storniere` gebuchter Anfrage → Stornorechnung mit eigener Nummer, G22; `storniere` mit `erstattungCent` > brutto → 400.
- `darfUebergang('neu','bezahlt') === false`.
- [ ] **Step 3: FAIL.**
- [ ] **Step 4: Implementieren.**
- [ ] **Step 5: PASS.**
- [ ] **Step 6: Commit** `Buchungskalender: Statusmaschine, Token und Rechnungsnummern`.
### Task 5: Verdrahtung in gemeinsamen Dateien + API-Verträge
Dieser Task berührt alle geteilten Dateien, damit die Stränge danach konfliktfrei arbeiten. Router-Dateien entstehen hier als leere `express.Router()` mit Kommentar, welcher Task sie füllt.
**Files:**
- Modify: `server/config.js` (`features.unterkunft` neben `shop` ~Z.461-500), `server/services/featureRegistry.js` (`STANDARD_HAKEN.unterkunft`), `server/services/legalDocs.js` (`SAETZE.unterkunft = { lb:{slug:'leistungsbeschreibung-unterkunft', pdf:'Leistungsbeschreibung-Buchungskalender'}, avv:{slug:'avv-unterkunft', pdf:'AVV-Buchungskalender'}, widerruf: <gemeinsam> }`, `FUNKTION_SATZ`, `aktuelleFassung`), `server/services/stripeBilling.js` (PRODUCT_METADATA `unterkunftSetup:'unterkunft-einrichtung'`), `scripts/stripe-products.mjs` (KEYS), `server/routes/abo.js` (ORDERS, FEATURE_OF_ORDER `unterkunft:'unterkunft'`, Text 'Buchungskalender (Einrichtung)'), `server/routes/funktionen.js` (Antwort `unterkunft:{verfuegbar}`), `server/index.js` (Mounts + Job-Registrierung), `server/routes/shopWebhook.js` (Weiche `wkUk` vor Z.54, `charge.refunded` nach Metadaten trennen, `account.updated` auch an Uk), `server/services/termine/outbox.js` + `server/services/termine/schema.js` (optionales Feld `anhang` 500, wird nur gespeichert/durchgereicht), `public/dashboard.html` (Section `view-buchungen` mit `<div id="buchungen-body">`), `public/app.js` (`VIEW_HASHES.buchungen`, `viewRefreshers.buchungen = () => import('/buchungen.js').then(m => m.initBuchungen(document.getElementById('buchungen-body')))`, `APP_PARENT.buchungen='funktionen'`, `FEATURE_LABELS.unterkunft='Buchungskalender'`)
- Create: `server/services/unterkunft/kontext.js`, `server/services/unterkunft/jobs.js` (Gerüst), `server/routes/publicUnterkunft.js`, `server/routes/unterkunft.js`, `server/routes/team/unterkunft.js`, `server/routes/unterkunftSeite.js`, `server/routes/unterkunftWebhook.js` (exportiert `verarbeiteUkEreignis(event, now)`, leer), `public/buchungen.js` (Platzhalter `export function initBuchungen(el){ el.textContent = 'Buchungskalender wird geladen …' }`), `docs/funktionen/unterkunft-api.md` (API-Vertrag, s. u.)
- Test: `test/unterkunftWiring.test.js` (Muster `test/shopWiring.test.js`), Anpassung `test/featureRegistry.test.js`, `test/legalDocs.test.js`, `test/termineOutbox.test.js` falls Pins brechen
**Interfaces:**
- Produces:
- `kontext.js`: `unterkunftPerSiteKey(siteKey, { now }) → ctx|null` (30-s-Cache, Feature-Dokument `featureKey:'unterkunft'`, nur state `active`; im Modus `pilot` nur, wenn die Besitzer-E-Mail in der Pilotliste steht; Modus `off` → null), `ladeFuerProjekt(customerId, projectId) → ctx|null` (beliebiger state ≠ off), `leereCache(siteKey)`, `SITE_KEY_RE`. `ctx = { featureId, customerId, projectId, siteKey, state, besitzerEmail, einstellungen, project }`.
- `jobs.js`: `minutenJob(now)`, `viertelstundenJob(now)`, `taeglicherJob(now)` mit `einzeln()`-Wrapper; bei Modus `off` → `{aus:true}`. Minutenjob ruft schon `laufeFristenAb(now)`; die anderen sind leer (Task 9/10 füllen).
- Mounts in `server/index.js`: `app.use('/api/public/unterkunft/v1', publicUnterkunftRoutes)` vor dem globalen Parser (Z.77); `app.use('/api/unterkunft', unterkunftRoutes)` (Kunden-Session); Team-Router `router.use('/unterkunft', unterkunftTeamRoutes)` (wie Shop, index.js:28); `app.use(unterkunftSeiteRouter())` vor `express.static`. Jobs: 80 s Start/60 s, 140 s Start/15 min, 210 s Start/24 h.
- Register `config.features.unterkunft` = Kopie `shop` mit: `product:'unterkunftSetup'`, `label {name:'Buchungskalender', kurz:'Buchungskalender', keine:…}`, `priceLabel '199 €'`, `setupEstimate`, `consent` (items `unterlagen`, `verantwortung` [Text Spec §9], `rechnung` [„Ich beauftrage WEBklar, in meinem Namen und auf meine Rechnung Rechnungen an meine Gäste auszustellen (§ 14 Abs. 2 Satz 4 UStG). Für Richtigkeit von Preisen und Steuerangaben bin ich verantwortlich.“], `stripe`, `beginn`, `information`), `returnHash:'buchungen'`, `returnProjekt:true`, `ticket.topic = (p) => 'Buchungskalender einbauen – ' + name`, `ticket.details` → dyn. Import `services/unterkunft/ticketDetails.js` (Task 11), `requiresWartungsAbo:false`, `ticketFreischaltung:true`.
- `STANDARD_HAKEN.unterkunft = { verfuegbar: unterkunftFuerKunde, bestellpruefung: (args) => import('./unterkunft/anfrageFunktion.js').then(m => m.anfrageOffenePunkte(args)), unterlagenBereit: () => featureLegalReady('unterkunft'), belehrung: termineBelehrung, freischaltbar: (doc) => import('./unterkunft/freischaltung.js').then(m => m.freischaltbar(doc)) }` — die zwei Module entstehen in Task 11; bis dahin existieren sie als Stubs (`anfrageOffenePunkte → ['Anfrage noch nicht freigegeben']`, `freischaltbar → false`).
- **API-Vertrag** `docs/funktionen/unterkunft-api.md` (Stränge 2 und 3 bauen dagegen; Task 6–8 erfüllen ihn). Fehlerform überall `{ fehler: code, felder?: {feld: text} }`.
- Ö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).
- `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, unterkunft:{name,checkIn,checkOut}, von, bis, naechte, personen, posten, summen, steuerart, fristAt, stornobedingungen, anbieter:{firma,inhaber,strasse,plz,ort,email,telefon}, gast:{name,firma}, rechnungsadresse?, zahlarten:['vorort'|'online'], zahlart?, bezahlt:boolean, rechnungNummer? }`.
- `POST /:siteKey/buchung/:id?t=` 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=`): `GET /status`; `GET|PUT /einstellungen`; `GET /unterkuenfte`; `POST /unterkuenfte`; `PUT|DELETE /unterkuenfte/:id`; `GET|PUT /unterkuenfte/:id/preise`; `GET /unterkuenfte/:id/kalender?von&bis` → `{ bereiche:[{von,bis,quelle,anfrageNummer?,sperreId?,notiz?}] }`; `POST /unterkuenfte/:id/sperren`; `DELETE /sperren/:id`; `POST /unterkuenfte/:id/ical-importe`; `DELETE /unterkuenfte/:id/ical-importe/:importId`; `POST /unterkuenfte/:id/ical-export/erneuern`; `GET /anfragen?filter=offen|gebucht|alle`; `GET /anfragen/:id` (inkl. `vorschlag` = `berechnePreis`); `POST /anfragen/:id/freigeben` `{posten,fristTage,nachricht}`; `POST /anfragen/:id/ablehnen` `{text}`; `POST /anfragen/:id/bezahlt`; `POST /anfragen/:id/stornieren` `{grund, erstattungCent}`; `POST /preisvorschau` `{unterkunftId,von,bis,personen}`; `GET /rechnungen`; `GET /rechnungen/:id.pdf` (nur Einzel-PDF, kein ZIP — Spec N7); `POST /stripe/verbinden`, `POST /stripe/vorhandenes-konto`, `POST /stripe/aktualisieren`; `POST /anfrage-funktion` `{art,einheiten,portale,nachricht}`; `POST /anfrage-funktion/zurueckziehen`.
- Team (`/api/team/unterkunft`): `GET /:customerId`; `POST /:customerId/anfrage` `{projectId, entscheidung, begruendung}`.
- [ ] **Step 1: Wiring-Test schreiben:** Registereintrag vorhanden mit Produkt `unterkunftSetup` und `returnHash 'buchungen'`; `featureHaken('unterkunft')` hat alle 5 Haken; `FUNKTION_SATZ.unterkunft === 'unterkunft'`; `GET /api/public/unterkunft/v1/aaaaaaaaaaaaaaaa` → 200 `{aktiv:false}` (Stub-Router liefert das schon); `public/app.js` enthält `buchungen` in VIEW_HASHES (Quelltext-Prüfung); `dashboard.html` enthält `id="buchungen-body"`; Shop-Webhook mit `metadata.wkUk:'1'` ruft `verarbeiteUkEreignis` statt Shop-Logik.
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Implementieren** inkl. API-Vertrag-Datei (Text oben 1:1).
- [ ] **Step 4: PASS**; zusätzlich grün: `test/shopWiring.test.js`, `test/featureRegistry.test.js`, `test/legalDocs.test.js`, `test/termineOutbox.test.js`, `test/funktionenRoutes.test.js`, `test/shopWebhook*.test.js` (einzeln nacheinander).
- [ ] **Step 5: Commit** `Buchungskalender: Verdrahtung, Router-Gerüste und API-Vertrag`.
- [ ] **Step 6: Worktrees der vier Stränge anlegen** (Befehl oben), je `node_modules`-Symlink.
---
## Strang 1: Server (`dev-uk-server`)
### Task 6: Öffentliche API – Konfiguration, Belegung, Anfrage
**Files:**
- Modify: `server/routes/publicUnterkunft.js`
- Test: `test/unterkunftPublic.test.js`
**Interfaces:**
- Consumes: `kontext.unterkunftPerSiteKey`, `ablauf.legeAnfrageAn`, `belegung.belegteBereiche`, `token.formToken/pruefeFormToken/ipHashVon`, `termine/limits.js` (`zaehle`, `darfAnfrage`, `ipSchluessel`).
- Produces: die drei öffentlichen Endpunkte `GET /:siteKey`, `GET /:siteKey/belegung`, `POST /:siteKey/anfrage` laut Vertrag.
- [ ] **Step 1: Failing tests:** unbekannter/inaktiver siteKey → 200 `{aktiv:false}`; aktiver → Unterkünfte nur `aktiv`, sortiert, ohne `featureId/customerId`; Belegung über 18 Monate hinaus → 400; Belegung fasst angrenzende Bereiche zusammen; `POST anfrage` gültig → 201 `{nummer}` + Dokument `neu`; Honeypot `kontakt_url` gefüllt → 201 mit Schein-Nummer, nichts gespeichert; Form-Token `zu_schnell` → ebenso stiller Erfolg; fehlende E-Mail → 400 `felder.email`; mehr als `anfragenJeSiteStunde` je Site → 429; CORS: `OPTIONS` von erlaubter Origin (Domain des Projekts) → 204 mit Origin, fremde Origin bei POST → 403; GET mit `Access-Control-Allow-Origin: *`.
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Implementieren** (Helfer `route/schreibe/fehlerMit/erlaubteHerkunft/vorabantwort` aus `publicShop.js` in eine eigene lokale Kopie; eigener 16-KB-JSON-Parser; `UkFehler` → Status/Code).
- [ ] **Step 4: PASS.**
- [ ] **Step 5: Commit** `Buchungskalender: öffentliche API für Kalender und Anfrage`.
### Task 7: Buchungsseite, Stripe-Zahlung, Webhook
**Files:**
- Modify: `server/routes/publicUnterkunft.js` (Buchungs-Endpunkte), `server/routes/unterkunftWebhook.js`, `server/services/unterkunft/zahlung.js` (Stub ersetzen), `server/services/shop/connect.js` (`erstatten(..., { webklar = 'shop' })` Parameter, Standard unverändert)
- Test: `test/unterkunftBuchung.test.js`
**Interfaces:**
- Consumes: `ablauf.bucheVerbindlich/markiereBezahlt`, `connect.checkoutAnlegen/sessionHolen/erstatten`, `rechnungen.ladeRechnung`, `rechnungPdf` aus Task 19 (bis zum Merge: `GET …/rechnung.pdf` darf in diesem Strang mit `501` antworten, wenn `server/services/unterkunft/rechnungPdf.js` fehlt — dynamischer Import mit Fallback, Test prüft nur 200/501).
- Produces:
- `zahlung.js`: `checkoutFuer(ctx, anfrage, { now }) → { url }` (Session auf `ctx.einstellungen.stripeAccountId`, `mode:'payment'`, ein Posten `Aufenthalt <Unterkunft> <von>–<bis>, Rechnung <Nr>` Betrag `summen.bruttoCent`, `payment_method_types` `['card','sepa_debit']` wenn SEPA im Status aktiv sonst `['card']`, `locale:'de'`, `expires_at` +30 min, `metadata:{wkUk:'1', anfrageId}`, `payment_intent_data.metadata` gleich, `success_url`/`cancel_url` = Buchungsseite `…/unterkunft/<siteKey>/buchung/<id>?t=<token>&wk_zahlung=ok|abbruch`, `idempotencyKey:'uk-zahlung-'+anfrageId+'-'+minute`), speichert `stripeSessionId`; `schliesseAbWennBezahlt(ctx, anfrage, { now }) → anfrage` (Session holen, bei `complete`+`paid` → `markiereBezahlt(via:'stripe')`); `erstatte(...)` echt mit `webklar:'unterkunft'`.
- `unterkunftWebhook.verarbeiteUkEreignis(event, now)`: `checkout.session.completed` (paid) / `async_payment_succeeded` → `markiereBezahlt`; `charge.refunded` → `erstattetCent` nachtragen; idempotent.
- [ ] **Step 1: Failing tests** (Fake-Stripe): `GET buchung` falsches Token und unbekannte ID → identisch 404; gültig → Vertrag-Antwort, `zahlarten` ohne `online`, wenn Stripe fehlt; `POST buchung` vor Ort → `{status:'gebucht', rechnungNummer:'RE-2026-0001'}`; Review Focus 4: zweites `POST` → gleiche Antwort, keine zweite Rechnung; `GET` danach zeigt `status:'gebucht'` und `rechnungNummer`; abgelaufene Frist → 410 `frist_abgelaufen`; `online` → Antwort enthält `zahlungUrl` vom Fake-Stripe; Webhook `checkout.session.completed` mit `metadata.wkUk` → `bezahlt` + G20 im Stub-Protokoll; Buchungsseite mit `wk_zahlung=ok` schließt ohne Webhook selbst ab; Webhook zweimal → ein Übergang.
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Implementieren.**
- [ ] **Step 4: PASS**; `test/shopWebhook*.test.js` und Erstattungs-Tests des Shops weiter grün.
- [ ] **Step 5: Commit** `Buchungskalender: Buchungsseite, Stripe-Zahlung und Webhook`.
### Task 8: Kunden-API (Vermieter)
**Files:**
- Modify: `server/routes/unterkunft.js`
- Create: `server/services/unterkunft/verwaltung.js` (Validierung + Speichern von Einstellungen, Unterkünften, Preisen, Sperren; Stripe-Verbinden über `shop/connect.js` `kontoAnlegen`/`onboardingLink` mit `idempotencyKey 'uk-konto-'+projectId`)
- Test: `test/unterkunftVerwaltung.test.js`
**Interfaces:**
- Consumes: Tasks 1–4, `siteFeatures.assertOwnProject`, `writeLimiter.createWriteLimiter`, `shop/connect.js`; PDF-Download mit demselben 501-Fallback wie Task 7, bis Task 19 gemergt ist.
- Produces: alle Kunden-Endpunkte laut Vertrag außer `anfrage-funktion*` (Task 11). Validierung `verwaltung.js`: `pruefeEinstellungen(eingabe) → {werte}|{fehler}`, `pruefeUnterkunft`, `pruefePreise` (Staffeln aufsteigend, erste `abNaechten:1`, max 6 Staffeln, Preise 100–100000 Cent, Rabatt 0–50, Frist 1–14, Stornobedingungen 20–5000 Zeichen), `pruefeSperre`, `pruefeIcalUrl` (nur `https:`, max 500 Zeichen). `rechnungsPraefix` nur änderbar, solange `rechnungZaehler` leer.
- [ ] **Step 1: Failing tests:** fremdes Projekt → 403; Einstellungen ohne Steuernummer und ohne USt-ID → 400 `felder.steuernummer`; Präfix nach erster Rechnung ändern → 409; Unterkunft anlegen/sortieren/deaktivieren; Preise mit Staffel `abNaechten:2` als erste → 400; Kalender liefert Sperren mit `notiz` und Anfragen mit `anfrageNummer`; Sperre über gebuchte Nacht → 409 `zeitraum_belegt`; Review Focus 5 über `POST /anfragen/:id/freigeben` (leere Posten, Summe ≤ 0, Text 201 Zeichen, 31 Posten, ust 7 bei Kleinunternehmer → je 400, Anfrage bleibt `neu`); `GET /anfragen/:id` enthält `vorschlag`; `POST /preisvorschau` = `berechnePreis`; `POST /stripe/vorhandenes-konto` übernimmt `shopConfig.stripeAccountId` desselben Projekts, ohne eins → 409; `GET /rechnungen/:id.pdf` fremde Rechnung → 404.
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Implementieren.**
- [ ] **Step 4: PASS.**
- [ ] **Step 5: Commit** `Buchungskalender: Verwaltungs-API für Vermieter`.
### Task 9: iCal-Import und -Export
**Files:**
- Create: `server/services/unterkunft/ical.js`, `server/services/unterkunft/icalAbgleich.js`
- Modify: `server/services/unterkunft/jobs.js` (`viertelstundenJob` → `gleicheAlleAb(now)`), `server/routes/unterkunftSeite.js` (Export-Route)
- Test: `test/unterkunftIcal.test.js` (Fixtures `test/fixtures/ical/booking.ics`, `airbnb.ics`, `tzid.ics`, `gefaltet.ics`, `fehlerseite.html`)
**Interfaces:**
- Produces:
- `ical.js` (rein): `parseIcs(text, { heute, horizontTage }) → { ereignisse:[{von,bis,uid}] } | throws Error('kein_ics')`; `baueIcs({ name, ereignisse:[{uid,von,bis}] , jetzt }) → string` (CRLF, Zeilen ≤ 75 Oktette gefaltet, `PRODID:-//WEBklar//Buchungskalender//DE`, `SUMMARY:Belegt`, `DTSTART;VALUE=DATE`).
- `icalAbgleich.js`: `holeIcs(url, { fetch, now }) → string` (nur `https:`, DNS-Auflösung → Abbruch bei privaten/loopback/link-local Adressen v4+v6, Timeout `icalTimeoutMs`, Abbruch über `icalMaxBytes`, max 3 Weiterleitungen je erneut geprüft); `gleicheAb(unterkunft, importEintrag, { now, fetch }) → { ok, anzahl }|{ ok:false, fehler }`; `gleicheAlleAb(now)`; nach `icalFehlerStunden` ohne Erfolg `mails.anVermieter('K33', …)` höchstens alle 24 h (`ukEinstellungen.icalFehlerMailAt`).
- Export: `GET /unterkunft/ical/:token.ics` → `text/calendar; charset=utf-8`, `Cache-Control: no-store`; Ereignisse = Buchungen `gebucht|bezahlt`, Reservierungen `freigegeben` mit Frist, `manuell`-Sperren; nie `ical:*`; `UID` = `<anfrageId|sperreId>@project.webklar.com`; unbekanntes Token → 404.
- [ ] **Step 1: Failing tests** (Review Focus 3): Booking-Fixture → Ereignisse mit DATE-Werten; `DTSTART:20261110T140000Z` → `2026-11-10`; `TZID=Europe/Berlin:20261110T000000` → `2026-11-10`; fehlendes DTEND → 1 Nacht; `STATUS:CANCELLED` ignoriert; gefaltete `SUMMARY` stört nicht; Ereignisse vor gestern/hinter Horizont fallen weg; HTML → `kein_ics`; `holeIcs('http://…')` → abgelehnt; `https://127.0.0.1/…`, `https://10.0.0.5/`, `https://[::1]/` → abgelehnt; zu große Antwort → abgelehnt; `gleicheAb` Erfolg ersetzt alte `ical:<id>`-Sperren (neue zuerst), Fehler lässt alte stehen und setzt `fehler`; K33 erst nach 6 h, dann höchstens 1× je 24 h; Export enthält Buchung + manuelle Sperre, nicht die importierte, Zeilen mit CRLF.
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Implementieren** (DNS über `node:dns/promises` `lookup(host,{all:true})`; `fetch` injizierbar für Tests).
- [ ] **Step 4: PASS.**
- [ ] **Step 5: Commit** `Buchungskalender: iCal-Import und -Export`.
### Task 10: Fristen-, Aufräum- und Löschjobs
**Files:**
- Modify: `server/services/unterkunft/jobs.js`, `server/services/retention.js` (Uk-Zweig analog Shop), `server/services/customerAdmin.js` (~Z.299-305: `disableCustomerFeatures` auch `unterkunft`, `schliesseUkVonKunde`)
- Create: `server/services/unterkunft/aufbewahrung.js`
- Test: `test/unterkunftAufbewahrung.test.js`
**Interfaces:**
- Produces: `aufbewahrung.js`: `anonymisiere(now, { deckel }) → { anfragen:n, gaeste:n }` — `ipHash` leeren nach `ipHashTage`; `neu` älter als `neuVerfallTage` → `abgelaufen` (ohne Mail); Anfragen ohne Buchung (`abgelehnt|abgelaufen`) älter als `unverbindlichMonate` → `gast`, `nachrichtVermieter` leeren, `anonymisiertAt`; gebuchte Aufenthalte 3 Jahre nach Jahresende der Abreise → `gast` leeren (Rechnungsadresse und `ukRechnungen` bleiben); `ukRechnungen` 8 Jahre nach Jahresende löschen. `loescheUkDaten({ featureId, deckel })` (90 Tage nach Abschalten, Marker in `portalFeatures.adminNote` wie Shop). `schliesseUkVonKunde(customerId)`.
- [ ] **Step 1: Failing tests:** je Frist ein Dokument knapp davor und knapp danach (now fest); Deckel begrenzt Anzahl; Rechnung bleibt nach Gast-Anonymisierung lesbar und PDF-Schnappschuss unverändert; Kundenlöschung schaltet Funktion ab.
- [ ] **Step 2: FAIL.** **Step 3: Implementieren** (Tagesjob ruft `anonymisiere` + `loescheUkDaten` für abgeschaltete). **Step 4: PASS**; `test/retention*.test.js` weiter grün.
- [ ] **Step 5: Commit** `Buchungskalender: Aufbewahrung und Löschfristen`.
### Task 11: Funktion bestellen „auf Anfrage“, Freischaltung, Ticket
**Files:**
- Create (ersetzt Stubs aus Task 5): `server/services/unterkunft/anfrageFunktion.js`, `server/services/unterkunft/freischaltung.js`, `server/services/unterkunft/ticketDetails.js`
- Modify: `server/routes/unterkunft.js` (`anfrage-funktion*`), `server/routes/team/unterkunft.js`
- Test: `test/unterkunftAnfrageFunktion.test.js`
**Interfaces:**
- Produces:
- `anfrageFunktion.js` (Kopie-Muster `shop/anfrage.js`, Status in `ukEinstellungen.anfrage*`, Sperre `uk-anfrage:<projectId>`): `ARTEN = ['ferienwohnung','monteurzimmer','pension','sonstiges']`, `anfrageOffenePunkte({customerId,projectId}) → string[]`, `stelleAnfrage({customerId,projectId,eingabe:{art,einheiten(1–50),portale:boolean,nachricht},person,now})` → T22 ans Team, K34 an Kunden (über `sendAdminNotice`/`sendCustomerMail` direkt; Vorlagen kommen aus Task 20 — Test prüft nur den Aufruf mit Template-ID), `zieheZurueck`, `entscheideAnfrage({…, entscheidung, begruendung})`, `anfrageAnsicht(einstellungen)`.
- `freischaltung.js`: `freischaltbar(doc) → boolean` (≥ 1 aktive Unterkunft mit `preiseVollstaendig` und Stornobedingungen; Einstellungen firma, strasse, plz, ort, email und (steuernummer|ustId); fail-closed).
- `ticketDetails.js`: `ticketDetails(ctx) → string` (siteKey, Snippet `<div data-webklar-unterkunft="SITEKEY" data-api="https://project.webklar.com"></div><script src="https://project.webklar.com/embed/unterkunft.js" defer></script>`, Schritte aus Spec §9, Verweis `docs/funktionen/unterkunft-einbau.md`).
- Team: `GET /api/team/unterkunft/:customerId` → je Projekt `{ projectId, name, state, anfrage, stripe, unterkuenfte:n, offeneAnfragen:n }`; `POST …/anfrage`.
- [ ] **Step 1: Failing tests:** Anfrage nur bei state `off`; doppelte Anfrage → 409; Entscheidung `annehmen` → `anfrageOffenePunkte` leer; `freischaltbar` false ohne Preise, true mit vollständigen Daten, false bei Lesefehler; Ticket-Details enthalten den siteKey.
- [ ] **Step 2: FAIL.** **Step 3: Implementieren.** **Step 4: PASS.**
- [ ] **Step 5: Commit** `Buchungskalender: Bestellung auf Anfrage, Freischaltung und Ticket`.
---
## Strang 2: Oberfläche (`dev-uk-ui`, WOMS-Worktree `ticket.webklar.com/dev-unterkunft`)
Alle Portal-Module nach `public/shop.js`-Muster: `public/buchungen.js` (`initBuchungen(rootEl, { projectId })`, `REITER`), Reiter-Module `public/buchungen-tab-*.js` mit `render(panel, ctx)`, DOM-Helfer aus `public/shop-dom.js` wiederverwenden (`h`, `request`, `fehlerKarte`, `infoKarte`), Stile `public/buchungen.css`. Texte deutsch, Du-Form. Tests: Quelltext-/fakeDom-Tests nach Muster `test/shop*.test.js` mit `test/support/fakeDom.js`; API mit `request`-Attrappe gemäß Vertrag.
### Task 12: Grundgerüst + Reiter „Anfragen“
**Files:**
- Modify: `public/buchungen.js` (Platzhalter ersetzen)
- Create: `public/buchungen-tab-anfragen.js`, `public/buchungen-posten.js` (Posten-Editor, reine Funktionen `summeLive(posten, steuerart)` = Spiegel von `summiere`), `public/buchungen.css`
- Test: `test/buchungenUi.test.js`
- [ ] **Step 1: Failing tests:** `REITER` = `anfragen, kalender, unterkuenfte, rechnungen, einstellungen` (Labels „Anfragen“, „Kalender“, „Unterkünfte & Preise“, „Rechnungen“, „Einstellungen“); bei state `off` statt Reitern Anfrage-/Bestellkarte (Task 14); Anfragenliste rendert Nummer, Zeitraum „12.–30.11.2026 · 18 Nächte“, Gast, Status-Pille, Betrag; Detail zeigt Vorschlag-Posten editierbar, Summe aktualisiert sich (`summeLive` = Server-Werte aus Task 2 Beispielen: 40650); „Freigeben“ sendet `{posten,fristTage,nachricht}`; 409-Antwort zeigt „Der Zeitraum ist inzwischen belegt.“; gebuchte Anfrage zeigt Rechnung-Link, „Als bezahlt (vor Ort) markieren“, „Stornieren“ (Dialog mit Grund + Erstattungsbetrag nur bei Online-Zahlung).
- [ ] **Step 2: FAIL.** **Step 3: Implementieren.** **Step 4: PASS.**
- [ ] **Step 5: Commit** `Buchungskalender: Portal-Bereich und Anfragen`.
### Task 13: Reiter „Kalender“ und „Unterkünfte & Preise“
**Files:**
- Create: `public/buchungen-tab-kalender.js`, `public/buchungen-tab-unterkuenfte.js`
- Test: `test/buchungenUi.test.js` (erweitern)
- [ ] **Step 1: Failing tests:** Monatsraster beginnt Montag; Bereiche gefärbt nach `quelle` (Klassen `ist-gebucht`, `ist-reserviert`, `ist-ical`, `ist-manuell`) mit Legende; zwei Klicks auf freie Tage öffnen „Zeitraum sperren“ mit Notiz → `POST sperren`; Klick auf manuelle Sperre bietet „Sperre aufheben“; Unterkunft-Formular mit allen Feldern aus Spec §3; Preis-Editor mit Staffelzeilen (+/−), Beispielrechnung „7 Nächte, 2 Personen = 406,50 €“ über `POST /preisvorschau`; iCal-Bereich: Import hinzufügen (Name, URL), Status „zuletzt abgeglichen …“ bzw. Fehlertext, Export-Link mit „Kopieren“, Kurzanleitung Booking.com („Unterkunft → Tarife & Verfügbarkeit → Kalender synchronisieren → Kalender importieren → Link einfügen“) und Airbnb, „Link erneuern“ mit Bestätigung.
- [ ] **Step 2: FAIL.** **Step 3: Implementieren.** **Step 4: PASS.**
- [ ] **Step 5: Commit** `Buchungskalender: Kalender und Unterkünfte im Portal`.
### Task 14: Rechnungen, Einstellungen, Funktionen-Kachel, Anfrageformular
**Files:**
- Create: `public/buchungen-tab-rechnungen.js`, `public/buchungen-tab-einstellungen.js`, `public/buchungen-anfrage.js` (Formular der Funktion, Muster `shop-anfrage.js`)
- Modify: `public/funktionen.js` (Kachel „Buchungskalender“ nach `shopTile` ~Z.216-260, sichtbar nur bei `unterkunft.verfuegbar`; Wunschprojekt-Klick wie Z.405 auf `buchungen-body`)
- Test: `test/buchungenUi.test.js`, `test/funktionen*.test.js` falls Pins
- [ ] **Step 1: Failing tests:** Rechnungsliste mit PDF-Link `/api/unterkunft/rechnungen/<id>.pdf?projectId=`; Einstellungen: Pflichtfelder markiert, Präfix-Feld gesperrt mit Hinweis, sobald Rechnungen existieren; Stripe-Karte mit Zuständen nicht verbunden / Onboarding offen / aktiv und Knopf „Vorhandenes Webshop-Konto verwenden“ nur wenn API es meldet; Schalter „Online-Zahlung anbieten“ nur bei aktivem Konto; Knopf „Mustertext übernehmen“ füllt Stornobedingungen (Text aus `docs/funktionen/unterkunft-storno-muster.md`, Task 21 — bis dahin Konstante in `buchungen-tab-einstellungen.js`, Task 22 gleicht ab); Kachel zeigt Preis „199 €“ und Zustände wie Shop-Kachel; Anfrageformular sendet `{art,einheiten,portale,nachricht}`.
- [ ] **Step 2: FAIL.** **Step 3: Implementieren.** **Step 4: PASS.**
- [ ] **Step 5: Commit** `Buchungskalender: Rechnungen, Einstellungen und Funktionen-Kachel`.
### Task 15: WOMS
**Files (WOMS-Repo, Worktree von `origin/test` als `feature/unterkunft`, `git ls-files | grep -c node_modules` prüfen, nur Dateien einzeln stagen):**
- Modify: `src/lib/funktionenView.js` (`FUNKTIONEN` Eintrag `{ key:'unterkunft', label:'Buchungskalender', bestaetigen:{…wie shop} }`, `aktionen()`, `siteKey`, Hilfen für Anfrage-Zeilen generalisieren statt kopieren: `anfrageZeilen(eintrag, art)`), `src/lib/teamApi.js` (`getUnterkunft(customerId)`, `entscheideUnterkunftAnfrage(customerId, body)`), `src/components/customer/CustomerFunktionen.jsx` (Panel `UnterkunftPanel` nach `ShopPanel`-Muster: Anfrage-Zeilen, Annehmen/Ablehnen, Stripe-Status, Zahl Unterkünfte/offene Anfragen), `scripts/setup-legal.mjs` (DOCUMENTS anhängen: `leistungsbeschreibung-unterkunft` „Leistungsbeschreibung Buchungskalender“, `avv-unterkunft` „AVV Buchungskalender“, `liveUrl https://project.webklar.com/rechtliches/<key>.html` → sort 13 und 14)
- Test: `tests/unit/funktionenView.test.mjs`, `tests/setup-legal.test.mjs` (Länge 14, letzter Eintrag `avv-unterkunft`)
- [ ] **Step 1: Failing tests** (WOMS `node --test` mit `node_modules`-Symlink auf `../build/node_modules`): `FUNKTIONEN` enthält `unterkunft`; `aktionen()` für `unterkunft` wie Shop; `anfrageZeilen` liefert für beide Arten die richtigen Zeilen; setup-legal Länge 14.
- [ ] **Step 2: FAIL.** **Step 3: Implementieren.** **Step 4: PASS**; Vite-Build: `NODE_OPTIONS=--max-old-space-size=1200 node node_modules/vite/bin/vite.js build --outDir /tmp/claude-0/uk-woms-build` läuft durch.
- [ ] **Step 5: Commit** (WOMS) `Buchungskalender: Funktionsanfragen und Rechtliches-Karten`.
---
## Strang 3: Widget (`dev-uk-widget`, Donau-Repo `/root/reports/donau-home-website`)
### Task 16: Widget `public/embed/unterkunft.js`
**Files:**
- Create: `public/embed/unterkunft.js`, `public/embed/unterkunft-demo.html`
- Test: `test/unterkunftEmbedStatic.test.js` (Muster `shopEmbedStatic.test.js`), `test/unterkunftKalenderLogik.test.js`
**Interfaces:**
- Consumes: öffentlicher Vertrag `GET /:siteKey`, `GET /:siteKey/belegung`, `POST /:siteKey/anfrage`.
- Produces: IIFE; Einbau `<div data-webklar-unterkunft="SITEKEY" data-api="https://project.webklar.com"></div>`. Die reine Kalenderlogik steht im Widget in einem Objekt `WKU_LOGIK` und wird nur dann als `globalThis.__wkuLogik` bereitgestellt, wenn `document.currentScript?.dataset.test === '1'`; der Test lädt die Datei per `node:vm` mit `test/support/fakeDom.js` und diesem Attribut. Bei `{aktiv:false}` oder Ladefehler zeigt das Widget nur „Anfragen gerne per Telefon oder E-Mail.“ Funktionen: `monatsRaster(jahr, monat) → [{tag|null}] (Montag zuerst)`, `nachtBelegt(tag, belegt) → boolean`, `waehle(zustand, tag, belegt, mindestNaechte) → zustand` (erster Klick Anreise; zweiter Klick nach Anreise → Abreise, wenn keine belegte Nacht dazwischen, sonst neue Anreise; Klick vor Anreise → neue Anreise), `zusammenfassung(von, bis) → '12.–30. Nov. · 18 Nächte'`.
- Gestaltung: N1 (`@layer wku`, `.wku-`, Variablen). Zwei Spalten ab 760 px (Container-Query), Kalender links (1 Monat, 2 Monate ab 1100 px), Formular rechts: Unterkunft (nur bei > 1), Personen (1…max), Firma (opt.), Name, E-Mail, Telefon (opt.), Nachricht (opt.), Honeypot `kontakt_url` (versteckt, `tabindex=-1`, `autocomplete=off`), Datenschutzhinweis mit Link `anbieter.datenschutzUrl`, Knopf „Anfrage senden“. Ohne Zeitraum → Meldung „Bitte wählen Sie Anreise und Abreise im Kalender.“. Erfolg: „Danke! Ihre Anfrage A-XXXX ist eingegangen. Sie erhalten gleich eine Bestätigung per E-Mail.“ Tastatur: Pfeile bewegen Fokus, Enter/Leertaste wählt; `aria-label` „Dienstag, 10. November 2026, frei“ / „belegt“; `aria-live` für Zusammenfassung. Alle Texte per `textContent`.
- [ ] **Step 1: Failing tests:** Statik: kein `innerHTML` mit Variablen, kein `eval`, API-Pfad `/api/public/unterkunft/v1/`, Präfix `.wku-`, `@layer wku`; Logik: `monatsRaster(2026,11)` beginnt mit 6 Leerfeldern (1.11.2026 ist Sonntag); `waehle` über belegte Nacht → neue Anreise; Abreise auf belegter Anreisetag-Grenze (Nacht davor frei) erlaubt; unter Mindestnächten → Hinweis-Zustand `{fehler:'mindestnaechte'}`; `zusammenfassung('2026-11-12','2026-11-30') === '12.–30. Nov. · 18 Nächte'`, über Monatsgrenze `'28. Nov. – 3. Dez. · 5 Nächte'`.
- [ ] **Step 2: FAIL.** **Step 3: Implementieren.** **Step 4: PASS.**
- [ ] **Step 5: Commit** `Buchungskalender: Website-Widget mit Kalender und Anfrageformular`.
### Task 17: Standalone-Seiten und Buchungsseite
**Files:**
- Modify: `server/routes/unterkunftSeite.js` (GET `/unterkunft/:siteKey` → Widget auf neutraler Seite; GET `/unterkunft/:siteKey/buchung/:id` → Buchungsseite; Export-Route kommt aus Task 9 — Reihenfolge `ical` zuerst)
- Create: `public/unterkunft-seite.html` (Platzhalter `__SITEKEY__`, `__ANSICHT__` wie `shop-seite.html`, CSP `script-src 'self'`, `frame-ancestors 'none'`, `noindex`), `public/unterkunft-buchung.js`, `public/unterkunft-buchung.css`
- Test: `test/unterkunftSeite.test.js`
- [ ] **Step 1: Failing tests:** Routen liefern HTML mit escaptem siteKey, CSP-Header, `Cache-Control: no-store`; ungültiger siteKey → 404; `unterkunft-buchung.js` (fakeDom, Vertrag-Attrappe): Status `freigegeben` zeigt alle Pflichtangaben aus Spec §6 in dieser Reihenfolge direkt über dem Knopf — Anbieter, Unterkunft + Zeitraum mit Check-in/-out, Personen, Posten, Gesamtpreis (inkl. USt-Ausweis bzw. „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.“), Zahlarten, Stornobedingungen, „Ein Widerrufsrecht besteht nicht (§ 312g Abs. 2 Nr. 9 BGB).“, Datenschutzhinweis, „Reserviert bis …“; Knopftext exakt „Zahlungspflichtig buchen“; Option „online“ fehlt, wenn nicht in `zahlarten`; nach Buchung: „Ihre Buchung ist bestätigt“ + Rechnung-Link + bei unbezahlt online „Jetzt online bezahlen“; `abgelaufen` → „Die Reservierung ist abgelaufen. Bitte fragen Sie erneut an.“; 404 → „Dieser Link ist ungültig.“; `wk_zahlung=abbruch` → Hinweis „Zahlung abgebrochen – Sie können jetzt oder vor Ort bezahlen.“
- [ ] **Step 2: FAIL.** **Step 3: Implementieren** (Akzentfarbe aus Antwort als CSS-Variable). **Step 4: PASS.**
- [ ] **Step 5: Commit** `Buchungskalender: Buchungsseite für Gäste`.
### Task 18: Einbau auf der Donau-Home-Seite
**Files (Repo `/root/reports/donau-home-website`, Branch `main`, auf 5f8465a; vorher `git status` sauber und `git log -1` prüfen — hat der Inhaber gepusht, auf `origin/main` aufsetzen, nie überschreiben):**
- Modify: `index.html` Abschnitt `#anfrage` (Z.~555-590) und Skript (Z.~668-690): altes Formular und Datums-Skript entfernen; linke Spalte = Kopf + Einleitung „Wählen Sie Ihren Zeitraum im Kalender, nennen Sie die Zahl der Personen – wir melden uns mit Verfügbarkeit und Preis.“ + Kontaktliste; rechte/ganze Breite = `<div data-webklar-unterkunft="SITEKEY_FOLGT" data-api="https://project.webklar.com"></div>` + Script-Tag. Da das Widget selbst zweispaltig ist (Kalender links, Formular rechts), wird `.anfrage` zu: Kopf + Kontakt oben (zweispaltig: Text links, Kontakt rechts), darunter das Widget über volle Breite — so steht der Kalender links.
- Modify: CSS-Block `/* Anfrage */`: Variablen `--wku-akzent: var(--eiche)`, `--wku-hintergrund: var(--leinen)`, `--wku-text: var(--tinte)`, `--wku-rand: var(--linie)`, `--wku-radius: 10px`, `--wku-schrift: 'Figtree', system-ui, sans-serif` (genaue Token-Namen aus dem `:root` der Seite übernehmen).
- Fallback ohne aktives Widget liefert das Widget selbst (Task 16); ohne JS bleibt die Kontaktliste sichtbar.
- [ ] **Step 1:** Mit lokalem Widget-Demo prüfen: `public/embed/unterkunft-demo.html` um eine Variante „Donau“ mit den Variablen ergänzen; Screenshot 390 px und 1280 px per Playwright nach `/root/reports/buchungskalender-2026-10-07/donau/`.
- [ ] **Step 2:** `index.html` ändern; Seite lokal statisch öffnen (Widget mit API-Attrappe über `page.route`), Screenshots wie oben; keine horizontale Scrollleiste bei 390 px.
- [ ] **Step 3: Commit** im Donau-Repo `Anfrage: Buchungskalender statt Anreise/Abreise-Feldern` (Autor Kenso Grimm). Kein Push. siteKey wird beim Livegang eingesetzt (Livegang-Notiz).
---
## Strang 4: Mails, PDF, Recht (`dev-uk-mail`)
### Task 19: PDF-Writer und Rechnungs-PDF
**Files:**
- Create: `server/services/unterkunft/pdf.js`, `server/services/unterkunft/rechnungPdf.js`
- Test: `test/unterkunftPdf.test.js`
**Interfaces:**
- Produces:
- `pdf.js`: `class PdfDoc { constructor({ breite=595.28, hoehe=841.89 }); seite(); text(x, y, str, { groesse=10, fett=false, rechts=false }); linie(x1,y1,x2,y2,{dicke=0.5}); breite(str, groesse, fett) → number; umbrechen(str, maxBreite, groesse) → string[]; toBuffer() → Buffer }` — Standardschriften Helvetica/Helvetica-Bold, `WinAnsiEncoding`, Breitentabelle der Helvetica-AFM für 32–255 eingebettet, Zeichen außerhalb WinAnsi → `?`; `€` = 0x80, Umlaute/ß nach CP1252; Strings PDF-escaped (`\`, `(`, `)`); xref mit korrekten Offsets; kein Kompressionsfilter.
- `rechnungPdf.js`: `rechnungPdf(inhalt, { art, nummer, datum }) → Buffer` — Layout A4: Absenderzeile klein, Empfängeradresse, rechts Rechnungsnummer/Datum/Leistungszeitraum, Titel „Rechnung“ bzw. „Stornorechnung zu <bezugNummer>“, Leistung „Beherbergung <Unterkunft>, <von>–<bis> (<n> Nächte, <p> Personen)“, Posten-Tabelle (Text, Menge, Einzelpreis, Betrag), Summen (netto je Satz, USt je Satz, Gesamt) bzw. § 19-Satz, Zahlart („Zahlung vor Ort“ / „Online-Zahlung“), Fuß: Firma, Anschrift, Steuernummer/USt-ID, Bank (falls vorhanden). Seitenumbruch bei vielen Posten.
- [ ] **Step 1: Failing tests:** Buffer beginnt `%PDF-1.4`, endet `%%EOF\n`; `startxref`-Wert zeigt auf `xref`; jeder xref-Offset zeigt auf `n 0 obj`; Text „Übernachtung“ als CP1252-Bytes enthalten; `€` als Byte 0x80; `(` im Text escaped; 40 Posten → 2 Seiten (`/Count 2`); Rechnung enthält Nummer, „Gemäß § 19 UStG“ bei Kleinunternehmer, „USt 7 %“ bei Regel; gleicher `inhalt` → byte-gleiches PDF (deterministisch, kein Zeitstempel außer `datum`).
- [ ] **Step 2: FAIL.** **Step 3: Implementieren.** **Step 4: PASS**; zusätzlich ein Beispiel-PDF nach `/root/reports/buchungskalender-2026-10-07/beispiel-rechnung.pdf` schreiben und mit `pdftotext` (falls vorhanden) oder Chrome prüfen.
- [ ] **Step 5: Commit** `Buchungskalender: PDF-Writer und Rechnungs-PDF`.
### Task 20: Mail-Vorlagen und Versand
**Files:**
- Create: `server/mail-templates/G16 Buchungsanfrage eingegangen.html`, `G17 Buchungsanfrage freigegeben.html`, `G18 Buchungsanfrage abgelehnt.html`, `G19 Buchungsbestätigung.html`, `G20 Zahlung eingegangen (Unterkunft).html`, `G21 Reservierung abgelaufen.html`, `G22 Buchung storniert.html`, `K31 Neue Buchungsanfrage.html`, `K32 Neue Buchung.html`, `K33 Kalenderabgleich fehlgeschlagen.html`, `K34 Anfrage Buchungskalender.html`, `T22 Neue Anfrage Buchungskalender.html`
- Modify: `server/services/unterkunft/mails.js` (Stub ersetzen, Signaturen aus Task 4 bleiben; `_aufrufe` bleibt als Testhilfe erhalten), `server/mailer.js` bzw. `server/services/mailer.js` (`sendGuestMail({ …, attachments })`), `server/services/termine/outbox.js` (beim Senden: `anhang.art==='ukRechnung'` → `rechnungPdf` erzeugen und als `attachments:[{filename:'Rechnung-<nr>.pdf', content, contentType:'application/pdf'}]` übergeben)
- Test: `test/unterkunftMails.test.js`, `test/mailTemplates.test.js` (Pins), `test/termineOutbox.test.js`
**Interfaces:**
- Produces: `mails.anGast` reiht über `einreihen({ featureId, bookingId: anfrage.$id, art: 'uk-'+art, an: gast.email, fromName: einstellungen.firma, replyTo: einstellungen.email, template: art, values, blocks, anhang? }, now)` ein; G19/G22 mit `anhang:{art:'ukRechnung', rechnungId}`. Platzhalter je Vorlage im Kopfkommentar; gemeinsame Werte: `gast_name`, `firma`, `unterkunft`, `zeitraum` („12.11.2026 – 30.11.2026“), `naechte`, `personen`, `nummer`, `gesamt`, `posten_text` (mehrzeilig), `frist`, `buchungs_link`, `stornobedingungen`, `anbieter_kontakt`, `rechnung_nummer`, `erstattung`, `nachricht_vermieter`. K-Mails an `besitzerEmail` mit `portal_url` = `${publicBaseUrl}/dashboard.html#buchungen?projekt=<id>`.
- Inhalt: G17 enthält Preis, Frist, Stornobedingungen, Knopf-Link „Jetzt verbindlich buchen“ und Satz „Erst mit dem Klick auf „Zahlungspflichtig buchen“ auf der Buchungsseite ist Ihre Buchung verbindlich.“; G19 (dauerhafter Datenträger) alle Angaben der Buchungsseite + Stornobedingungen vollständig + Hinweis kein Widerrufsrecht + bei online unbezahlt Bezahl-Link.
- [ ] **Step 1: Failing tests:** `templateIds()` enthält alle 12 IDs; jede Vorlage rendert mit Beispielwerten ohne `problems`; G-Vorlagen in Sie-Form ohne WEBklar-Logo; Outbox-Eintrag mit `anhang` → gesendete Mail hat 1 Anhang `Rechnung-RE-2026-0001.pdf` beginnend mit `%PDF`; ohne `anhang` unverändert (Termine-Tests grün); `anGast('G17',…)` Mail hat `fromName` Firma und `replyTo` Vermieter-E-Mail.
- [ ] **Step 2: FAIL.** **Step 3: Implementieren.** **Step 4: PASS.**
- [ ] **Step 5: Commit** `Buchungskalender: Mails an Gäste, Vermieter und Team`.
### Task 21: Rechtstexte und Doku
**Files:**
- Create: `docs/rechtliches/leistungsbeschreibung-unterkunft.md`, `docs/rechtliches/avv-unterkunft.md` (Muster der Webshop-Texte; Fassung 2026-10-07; Inhalte: Leistung, 199 € einmalig ohne USt § 19, Laufzeit solange Website bei WEBklar, Pflichten Vermieter [Preise, Steuer, Stornobedingungen, Meldeschein, Kurtaxe], Rechnungsstellung im Auftrag § 14 Abs. 2 S. 4 UStG, Verfügbarkeit iCal ohne Gewähr, Stripe als eigener Vertrag), `docs/rechtliches/unterkunft-datenschutz-6f-entwurf.md`, `docs/rechtliches/unterkunft-kanzlei-fragen.md` (mind.: Button-Lösung per Mail-Link, Pflichtinfos Art. 246a, Widerrufsausschluss § 312g Abs. 2 Nr. 9 auch bei Monteursunterkünften für Unternehmer/Verbraucher, USt Endreinigung 7 %, Rechnung im Auftrag, Aufbewahrung 8 Jahre durch WEBklar, Rolle AVV, Haftung bei Doppelbuchung durch iCal-Verzögerung), `docs/funktionen/unterkunft-datenschutz-absatz.md`, `docs/funktionen/unterkunft-storno-muster.md`, `docs/funktionen/unterkunft-einbau.md`, `docs/funktionen/unterkunft-livegang.md`
- Modify: `scripts/build-legal-html.mjs` (Modus `unterkunft`), danach `node scripts/build-legal-html.mjs unterkunft` → `public/rechtliches/*-2026-10-07.html`; PDFs über `scripts/build-legal-pdfs.sh unterkunft` (Host-Chrome; falls nicht vorhanden: im Livegang-Dokument als Schritt vermerken)
- Test: `test/legalDocs.test.js` (featureLegalReady('unterkunft') true, wenn Dateien da)
- [ ] **Step 1:** Texte schreiben (Kennzeichnung „Entwurf – Prüfung durch Kanzlei offen“ in LB/AVV-Kopf).
- [ ] **Step 2:** HTML/PDF bauen, `--check` grün; `featureLegalReady('unterkunft')` Test PASS.
- [ ] **Step 3:** Livegang-Notiz mit Schritten: (1) Inhaber prüft Texte; (2) `node scripts/unterkunft-setup.mjs --check` dann ohne; Termine-Setup für Outbox-Feld `anhang`; (3) `stripe-products.mjs --anlegen` (unterkunftSetup); (4) compose/.env `UNTERKUNFT_ENABLED=pilot`, `UNTERKUNFT_PILOT_EMAILS=kenso.gri@gmail.com,<Mimi>`; (5) Push `feature/unterkunft:main` (Portal), `feature/unterkunft:test` (WOMS) — nie unter eigenem Namen; (6) Rechtstexte in WOMS Rechtliches hochladen (macht Claude, Schlüssel `/root/.appwrite-rechtliches.key`); (7) Mimi im WOMS freischalten (`activate`), siteKey in Donau-`index.html` einsetzen, Donau pushen; (8) Pilot: Testanfrage → Freigabe → vor Ort buchen → Rechnung prüfen → stornieren; (9) Datenschutz § 6f in `public/datenschutz.html` übernehmen; (10) nach Kanzlei `on`.
- [ ] **Step 4: Commit** `Buchungskalender: Rechtstexte, Einbau- und Livegang-Notiz`.
---
## Abschluss
### Task 22: Zusammenführen und Gesamtdurchlauf
**Files:**
- Merge in `feature/unterkunft`: `feature/uk-server`, `feature/uk-mail`, `feature/uk-widget`, `feature/uk-ui` (in dieser Reihenfolge; Konflikte nur in `server/routes/unterkunft.js`, `unterkunftSeite.js` erwartet)
- Create: `test/ui/unterkunftTestServer.mjs` (Muster `shopTestServer.mjs`), `test/ui/unterkunft.ui.mjs`
- [ ] **Step 1:** Merges, danach alle `test/unterkunft*.test.js`, `test/buchungen*.test.js` und die in Tasks 1–21 genannten Bestands-Tests einzeln nacheinander grün.
- [ ] **Step 2:** Abgleich: Stornomuster-Konstante in `buchungen-tab-einstellungen.js` = `docs/funktionen/unterkunft-storno-muster.md`; Stub-Reste (`_aufrufe` nur in Tests genutzt, 501-Fallback in Task 7 entfernen).
- [ ] **Step 3: UI-Durchlauf** (`free -m` vorher): Widget auf Demo-Seite → Zeitraum + Anfrage → Portal „Anfragen“ → Posten ändern → Freigeben → Link aus Mail-Sink → Buchungsseite „vor Ort“ → „Zahlungspflichtig buchen“ → Rechnung-PDF abrufbar → Portal „bezahlt (vor Ort)“ → zweite Anfrage online → Fake-Stripe-Zahlung → `bezahlt` → Stornieren mit Teilerstattung. Screenshots nach `/root/reports/buchungskalender-2026-10-07/ui/`.
- [ ] **Step 4: Commit** `Buchungskalender: Zusammenführung und UI-Durchlauf`.
### Task 23: Gesamtreview
- [ ] **Step 1:** Ein Review-Agent (sonnet) prüft den ganzen Branch gegen Spec + Review Focus; Funde beheben, je Fund ein Commit.
- [ ] **Step 2:** Ledger-Abschluss, Memory-Notiz aktualisieren, dem Inhaber Livegang-Notiz verlinken.
@@ -0,0 +1,296 @@
# Buchungskalender für Unterkünfte (Funktion `unterkunft`): Design
> **Hinweis (2026-10-07, Livegang-Vorbereitung):** Die Mail-Nummern K31, K32 und T22 gehören seit dem Merge mit `origin/main` den Aufträgen (Auftragsbestätigung). Im Buchungskalender heißen die Mails jetzt **K36** (Neue Buchungsanfrage, vorher K31), **K37** (Neue Buchung, vorher K32) und **T23** (Neue Anfrage Buchungskalender, vorher T22). Der folgende Text nennt noch die alten Nummern.
Stand 2026-10-07. Basis: Portal `origin/main` 8814c4a (TP2, Webshop, Öffnungszeiten im Pilot). Arbeitszweig
`feature/unterkunft`, Worktree `dev-unterkunft`. Pfade: **P** = Portal-Repo, **W** = WOMS-Repo, **D** = Website Donau Home
(`WEBklar/donau-home`, lokaler Klon `/root/reports/donau-home-website`).
Auftrag des Inhabers (2026-10-07): Auf der Donau-Home-Seite (MIMI FEWO, erste Kundin) ersetzt ein Kalender die Felder
Anreise/Abreise; der Gast wählt den Zeitraum, gibt Personen, Name, E-Mail, Telefon, Nachricht an. Die Anfrage geht an
die Vermieterin; gibt sie frei, bekommt der Gast eine Mail mit Rechnung und zahlt direkt oder vor Ort. Als Funktion im
Kundenportal, für alle Kunden. Entscheidungen des Inhabers U1–U7 (Abschnitt 13), alle übrigen von Claude (U8 ff.).
---
## 1. Ziel und Abgrenzung
Eine Funktion für Ferienwohnungen, Monteurzimmer und kleine Pensionen: Belegungskalender auf der Website, Anfrage,
Freigabe durch den Vermieter, verbindliche Buchung durch den Gast, Rechnung im Namen des Vermieters, Zahlung online
(Stripe, direkt an den Vermieter) oder vor Ort, Abgleich mit Booking.com/Airbnb per iCal.
Nicht im Umfang: Meldeschein (Bundesmeldegesetz), Kurtaxe/Gästebeitrag, Anzahlungen und Teilzahlungen, Sofortbuchung
ohne Freigabe, saisonale Preise (nur Staffel nach Aufenthaltsdauer), Gutscheine, Channel-Manager-APIs außer iCal,
Mehrsprachigkeit (nur Deutsch).
Begriffe: **Vermieter** = unser Kunde (Mimi). **Gast** = wer auf der Website anfragt. **Unterkunft** = buchbare Einheit
(Wohnung, Zimmer).
## 2. Ablauf
1. **Website (Widget):** Kalender zeigt belegte Nächte der gewählten Unterkunft (bei nur einer Unterkunft keine Auswahl).
Erster Klick Anreise, zweiter Abreise, Spanne markiert, Zusammenfassung „12.–30. Nov. · 18 Nächte“. Belegte Nächte
und Vergangenheit nicht wählbar; ein Zeitraum über eine belegte Nacht hinweg ebenso nicht; Mindestnächte werden
geprüft. Formular: Personen (1 … Höchstzahl), Firma (optional), Name, E-Mail, Telefon (optional), Nachricht
(optional), Datenschutzhinweis. Knopf **„Anfrage senden“** (unverbindlich, U6).
→ Anfrage `neu`, **G16** Eingangsbestätigung an den Gast, **K31** Neue Anfrage an den Vermieter.
2. **Portal, Reiter „Buchungen“ → Anfragen:** Detail mit vorgerechnetem Preis (Posten editierbar: Betrag, Text, Posten
hinzufügen/entfernen), Reservierungsfrist (Standard aus Einstellungen), optionale Nachricht an den Gast.
**Freigeben** → Prüfung unter Sperre, dass der Zeitraum frei ist; Status `freigegeben`, Nächte als Reservierung
gesperrt bis `fristAt`, **G17** an den Gast mit Preis, Stornobedingungen und Buchungslink.
**Ablehnen** (optional mit Text) → `abgelehnt`, **G18**.
3. **Buchungsseite** `https://project.webklar.com/unterkunft/<siteKey>/buchung/<id>?t=<token>` (Abschnitt 6):
Rechnungsadresse, Zahlart „online“/„vor Ort“, alle Pflichtangaben, Knopf **„Zahlungspflichtig buchen“** (U6).
→ Prüfung unter Sperre (Status `freigegeben`, Frist nicht abgelaufen, Zeitraum frei); Status `gebucht`,
Rechnungsnummer vergeben, PDF erzeugen, **G19** Buchungsbestätigung mit PDF an den Gast, **K32** an den Vermieter.
Bei „online“ direkt weiter zu Stripe Checkout; Zahlung → `bezahlt`, **G20**. Bricht der Gast die Zahlung ab, bleibt
die Buchung `gebucht` (unbezahlt); G19 und die Buchungsseite enthalten „Jetzt online bezahlen“.
4. **Frist abgelaufen** (Minuten-Job): `freigegeben` → `abgelaufen`, Nächte frei, **G21**.
5. **Vor Ort bezahlt:** Vermieter setzt „bezahlt (vor Ort)“ mit Datum; keine Mail.
6. **Stornierung** durch den Vermieter (nur `freigegeben`, `gebucht`, `bezahlt`): Grund, bei Online-Zahlung Erstattung
0 … Gesamtbetrag über Stripe; bei vorhandener Rechnung Stornorechnung (Rechnungskorrektur mit eigener Nummer, voller
Betrag negativ); **G22** an den Gast mit Stornorechnung. Stornierungen auf Wunsch des Gastes laufen ebenso über den
Vermieter (der Gast schreibt ihm; keine Selbststornierung im Widget).
Statusmaschine (nur vorwärts, Übergänge idempotent unter Sperre `unterkunft:<unterkunftId>`):
`neu → freigegeben → gebucht → bezahlt`; `neu → abgelehnt`; `freigegeben → abgelaufen`;
`freigegeben|gebucht|bezahlt → storniert`. `zahlart` ∈ {`online`, `vorort`} wird bei `gebucht` gesetzt;
`bezahltVia` ∈ {`stripe`, `vorort`}.
## 3. Datenmodell (Appwrite, `scripts/unterkunft-setup.mjs`, nur Server-Key, Muster `shop-setup.mjs`)
Alle Dokumente tragen `customerId`, `projectId`, `featureId` (portalFeatures-Dokument der Funktion).
- **`ukUnterkuenfte`**: `name`, `beschreibung` (kurz, optional), `maxPersonen` (1–20), `mindestNaechte` (1–90),
`checkIn`/`checkOut` (HH:MM), `sort`, `aktiv`, `icalImporte` (JSON-Array {id, name, url, letzterAbrufAt,
letzterErfolgAt, fehler}), `icalExportToken` (32 Byte, zufällig, erneuerbar).
- **`ukPreise`** (1:1 zur Unterkunft): `staffeln` (JSON [{abNaechten, preisProNachtCent}], aufsteigend, erste ab 1),
`reinigungCent`, `personenInklusive`, `aufpreisPersonNachtCent`, `direktrabattProzent` (0–50, ganze Zahl),
`steuerart` (`kleinunternehmer` | `regel`), `ustUebernachtung` (Standard 7), `ustReinigung` (Standard 7),
`fristTage` (1–14, Standard 3), `stornobedingungen` (Text, Pflicht vor dem Freischalten).
- **`ukSperren`**: `unterkunftId`, `von`, `bis` (Datum, `bis` = Abreisetag exklusiv, also belegte Nächte von..bis-1),
`quelle` (`manuell` | `ical:<importId>` | `anfrage:<anfrageId>`), `notiz` (nur manuell, nur Portal).
Reservierungen und Buchungen werden nicht als Sperre kopiert, sondern direkt aus `ukAnfragen` gelesen (Status
`freigegeben` mit `fristAt` > jetzt, `gebucht`, `bezahlt`); `ukSperren` enthält nur `manuell` und `ical:*`.
- **`ukAnfragen`**: `nummer` (kurz, z. B. `A-7K2Q`), `unterkunftId`, `von`, `bis`, `personen`, `status`, `gast`
(JSON {name, firma, email, telefon, nachricht}), `rechnungsadresse` (JSON {name, firma, strasse, plz, ort, land}),
`posten` (JSON [{text, menge, einzelCent, ustProzent}], Schnappschuss bei Freigabe), `summen` (JSON {netto, ust je
Satz, brutto}), `nachrichtVermieter`, `fristAt`, `tokenHash` (sha256 des Buchungslink-Tokens), `zahlart`,
`bezahltVia`, `bezahltAt`, `stripeSessionId`, `stripePaymentIntentId`, `erstattetCent`, `stornoGrund`,
`stornobedingungenText` (Schnappschuss bei Freigabe), `rechtstexteFassung`, `ipHash` (nur bis Freigabe/Ablehnung,
danach entfernt), Zeitstempel je Status.
- **`ukRechnungen`**: `anfrageId`, `art` (`rechnung` | `storno`), `nummer`, `datum`, `fileId` (Bucket
`uk-rechnungen`), `aussteller` (JSON-Schnappschuss der Firmendaten), `bruttoCent`.
- **`ukEinstellungen`** (1 je Funktion): `firma`, `inhaber`, `strasse`, `plz`, `ort`, `email`, `telefon`,
`steuernummer`/`ustId` (eins Pflicht), `iban`/`bic`/`bank` (optional, für „vor Ort/Überweisung“-Hinweis), `rechnungsPraefix`
(2–8 Zeichen A–Z0–9, Standard `RE`), `rechnungZaehler` (JSON {jahr, n}), `akzentfarbe`, `stripeAccountId`,
`stripeStatus` (Muster Webshop), `onlineZahlung` (an/aus; aus, solange Stripe nicht aktiv).
Rechnungsnummern: `<Präfix>-<Jahr>-<n vierstellig>`, lückenlos je Funktion und Jahr, vergeben unter Sperre
`uk-rechnungsnr:<featureId>` (keyedLock) mit Lesen–Erhöhen–Schreiben des Zählers vor dem PDF; schlägt das PDF fehl, wird
es neu erzeugt (Nummer bleibt). Storno-Rechnungen ziehen aus demselben Zähler.
## 4. Preisrechnung (`server/services/unterkunft/preis.js`, rein, ohne I/O)
`berechnePreis({ preise, von, bis, personen })` → `{ naechte, posten, summen }`:
- `naechte` = Tage zwischen `von` und `bis`; Staffel = höchste `abNaechten` ≤ `naechte` für **alle** Nächte.
- Posten: „Übernachtung, N Nächte à X €“ (ust Übernachtung); falls `personen` > `personenInklusive`: „Zusatzperson(en),
k × N Nächte à Y €“; „Endreinigung“ (falls > 0, ust Reinigung); „Direktbuchungsrabatt P %“ als negativer Posten auf
Übernachtung + Zusatzpersonen (nicht auf Reinigung), mit ust Übernachtung.
- Beträge sind Bruttobeträge in Cent. Bei `regel` wird die enthaltene USt je Satz aus der Summe der Posten dieses Satzes
herausgerechnet und kaufmännisch auf Cent gerundet; bei `kleinunternehmer` keine USt.
- Mimis Änderungen ersetzen die Posten vollständig; `summen` werden immer serverseitig aus den Posten neu berechnet
(`summiere(posten, steuerart)`), nie aus dem Browser übernommen.
## 5. Verfügbarkeit und iCal (`server/services/unterkunft/belegung.js`, `ical.js`)
- `belegteNaechte(unterkunftId, von, bis)`: Vereinigung aus `ukSperren` und aktiven Anfragen (Abschnitt 3).
`istFrei(...)` wird bei Freigabe und Buchung **unter der Sperre** erneut geprüft (U9). Eine eigene Anfrage zählt bei
ihrer eigenen Buchung nicht als belegt.
- **Import** (Job alle 15 Minuten, je Import höchstens 1 MB, Zeitlimit 10 s, nur `https:`, keine privaten/lokalen
Ziele — Prüfung der aufgelösten IP gegen private Netze): VEVENT mit `DTSTART`/`DTEND` (DATE oder DATE-TIME →
Datum), ohne `DTEND` = 1 Nacht, `STATUS:CANCELLED` ignorieren, nur Zeitraum heute−1 Tag bis +18 Monate. Erfolgreicher
Abruf ersetzt alle Sperren `ical:<importId>` (erst neue anlegen, dann alte löschen, damit nie eine Lücke entsteht). Fehler: alter Stand
bleibt, `fehler` gesetzt; ab 6 Stunden ohne Erfolg **K33** (höchstens einmal je 24 h und Import). Parser selbst
geschrieben (Zeilenfaltung, Escapes), keine Abhängigkeit.
- **Export** `GET /unterkunft/ical/<icalExportToken>.ics`: VCALENDAR mit einem VEVENT „Belegt“ je gebuchter/bezahlter
Buchung, Reservierung und manueller Sperre (nicht die importierten, sonst Echo), `UID` stabil, keine Gastdaten.
Bei der Booking.com-Anleitung im Portal: Link kopieren, bei Booking.com „Kalender synchronisieren“ eintragen.
## 6. Website-Widget und öffentliche Schnittstellen
Einbau (Muster Termine/Shop): `<div data-webklar-unterkunft="<siteKey>"></div>` +
`<script src="https://project.webklar.com/embed/unterkunft.js" defer></script>`. Shadow DOM, Stile über CSS-Variablen
(`--wk-uk-akzent`, `--wk-uk-text`, `--wk-uk-flaeche`, `--wk-uk-linie`, `--wk-uk-radius`, `--wk-uk-schrift`), damit jede
Seite das Widget anpassen kann. Layout: zwei Spalten (Kalender links, Formular rechts), unter 760 px untereinander.
Kalender: ein Monat sichtbar (zwei ab 1100 px Breite), Pfeile, Tastatur (Pfeiltasten, Enter), `aria-label` je Tag mit
Datum und Status, belegte Nächte durchgestrichen/grau, Anreise an einem Tag, an dem nur eine Abreise liegt, ist möglich
(Nächte-Logik).
Öffentliche Routen (`server/routes/publicUnterkunft.js`, Antworten nie mit IDs außer Unterkunft-IDs, keine Gastdaten):
- `GET /api/public/unterkunft/:siteKey` → Unterkünfte (id, name, maxPersonen, mindestNaechte, checkIn/Out),
Akzentfarbe, Datenschutz-Link-Ziel, Anbietername.
- `GET /api/public/unterkunft/:siteKey/belegung?unterkunft=<id>&von=&bis=` (höchstens 18 Monate) → belegte Nächte als
Bereiche. Cache 60 s.
- `POST /api/public/unterkunft/:siteKey/anfrage` → Prüfung (Pflichtfelder, Längen, E-Mail, Zeitraum frei und gültig,
Personen), Honeypot-Feld + Mindest-Ausfüllzeit, Rate-Limit je IP-Hash und Site (Muster Termine). Antwort: Anfragenummer.
- `GET /api/public/unterkunft/:siteKey/buchung/:id?t=` und `POST …/buchung/:id` (Token prüfen gegen `tokenHash`,
zeitkonstanter Vergleich; nach `abgelaufen`/`abgelehnt`/`storniert` nur Statusanzeige) → Buchungsseite.
- `POST …/buchung/:id/zahlung?t=` → Stripe Checkout Session auf dem verbundenen Konto (Direct Charge, Muster Webshop:
`mode: payment`, ein Posten „Aufenthalt <Unterkunft> <von>–<bis>, Rechnung <Nr>“, Betrag = brutto, `expires_at` 30 min,
`metadata` {ukAnfrageId}, `success_url` zurück zur Buchungsseite). Abschluss über den bestehenden Connect-Webhook
(Weiterleitung nach `metadata`) **und** beim Laden der Buchungsseite (Muster Webshop „Danke-Seite schließt selbst ab“).
**Buchungsseite** (Standalone-Seite im Portal-Design mit Akzentfarbe des Vermieters, § 312j BGB und Art. 246a EGBGB,
unmittelbar über dem Knopf): Anbieter (Firma, Anschrift, Kontakt), Unterkunft, An-/Abreise mit Check-in/-out-Zeiten,
Personen, alle Posten, Gesamtpreis inkl. USt bzw. Kleinunternehmer-Hinweis, Zahlarten, Stornobedingungen (Schnappschuss),
Hinweis „Ein Widerrufsrecht besteht nicht (§ 312g Abs. 2 Nr. 9 BGB).“, Datenschutzhinweis des Vermieters, Frist.
Pflichtfelder Rechnungsadresse (Name/Firma, Straße, PLZ, Ort, Land; Vorbelegung aus der Anfrage). Korrektur bis zum Knopf
möglich. Knopf **„Zahlungspflichtig buchen“**; „online“ nur wählbar, wenn `onlineZahlung` an.
## 7. Portal für den Vermieter (Funktionen → „Buchungen“, Hash `#buchungen`)
Nach dem Muster des Webshop-Bereichs; nur sichtbar, wenn die Funktion für das Projekt aktiv ist (Einrichtung: schon in
`setup` nutzbar zum Vorbereiten, Widget erst nach Freischaltung öffentlich).
- **Anfragen:** Filter offen (neu, freigegeben) / gebucht / alle; Liste mit Nummer, Zeitraum, Nächten, Gast, Status,
Betrag. Detail: Gastangaben, Kalenderausschnitt, Posten-Editor mit Live-Summe, Frist, Nachricht, Freigeben/Ablehnen;
bei Buchungen Rechnung herunterladen, „bezahlt (vor Ort)“, Stornieren (mit Erstattungsbetrag bei Online-Zahlung).
- **Kalender:** Monatsansicht je Unterkunft, Farben nach Quelle (Direkt gebucht, reserviert, Booking/iCal, manuell);
Klick-Klick auf freie Nächte → manuelle Sperre mit Notiz; Klick auf manuelle Sperre → löschen.
- **Unterkünfte & Preise:** Anlegen/Bearbeiten/Sortieren/Deaktivieren, Preis-Editor mit Beispielrechnung („7 Nächte, 2
Personen = …“), iCal-Importe (Name + URL, Status letzter Abruf), Export-Link mit Kopieren und Anleitung für Booking.com
und Airbnb, „Link erneuern“.
- **Rechnungen:** Liste (Nummer, Datum, Gast, Betrag, Art), PDF-Download, Jahres-ZIP-Download.
- **Einstellungen:** Firmendaten, Steuer, Rechnungspräfix (änderbar nur vor der ersten Rechnung), Bankverbindung,
Akzentfarbe, Stripe verbinden (Wiederverwendung `shop/connect.js`, ein Stripe-Konto je Kunde: hat der Kunde bereits ein
Shop-Konto, wird es angeboten), Online-Zahlung an/aus, Mustertext Stornobedingungen zum Übernehmen.
- Pflicht vor dem Freischalten (`freischaltbar`): mindestens eine aktive Unterkunft mit Preisen und Stornobedingungen,
Firmendaten vollständig, Steuernummer oder USt-ID.
## 8. Rechnung (PDF, `server/services/unterkunft/rechnungPdf.js`)
Mit der vorhandenen PDF-Bibliothek des Portals (Plan prüft, welche; keine neue Abhängigkeit, wenn vermeidbar).
Pflichtangaben § 14 Abs. 4 UStG: Name/Anschrift Aussteller und Empfänger, Steuernummer oder USt-ID, Rechnungsdatum,
Rechnungsnummer, Leistung („Beherbergung <Unterkunft>, <Anschrift der Unterkunft = Firmenanschrift, sofern nicht
abweichend>“), Leistungszeitraum (An–Abreise), Posten, Entgelt netto je Steuersatz, Steuersatz und Steuerbetrag bzw.
„Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.“, Gesamtbetrag. Zusätzlich Zahlart und Status („bezahlt am …“ wird
nicht nachträglich ins PDF geschrieben; die Rechnung bleibt unverändert). Storno-Rechnung verweist auf die
Ursprungsrechnung. Kein WEBklar-Hinweis auf der Rechnung (U10), sie ist die des Vermieters.
## 9. Bestellung der Funktion bei WEBklar (Register-Eintrag `unterkunft`)
Muster Webshop: `product: 'unterkunftSetup'` (19900 Cent, Lookup-Key `webklar_unterkunft_einrichtung_19900`),
`priceLabel '199 €'`, `setupEstimate 'meist innerhalb von 1–2 Wochen'`, `requiresWartungsAbo: false`,
`ticketFreischaltung: true`, `returnHash 'buchungen'`, `returnProjekt: true`. Haken: `verfuegbar` (Schalter
`UNTERKUNFT_ENABLED` aus/pilot/an + `UNTERKUNFT_PILOT_EMAILS`), `bestellpruefung` (Anfrage angenommen, Muster Webshop
`anfrage.js`), `unterlagenBereit` (Leistungsbeschreibung + AVV Buchungskalender), `belehrung` (gemeinsame),
`freischaltbar` (Abschnitt 7). Anfrageformular der Funktion: Art der Unterkunft, Zahl der Einheiten, Booking.com/Airbnb
genutzt ja/nein, Nachricht → **K34** an den Kunden, **T22** an das Team. Zustimmungen wie Webshop: unterlagen,
verantwortung („Ich vermiete in eigenem Namen; für Preise, Stornobedingungen, Rechnungen und Meldepflichten bin ich
verantwortlich.“), stripe (nur wenn Online-Zahlung genutzt werden soll; sonst entfällt), beginn, information.
Ticket „Buchungskalender einbauen – <Projekt>“ mit siteKey, Snippet, Schritten (Kalender-Bereich anlegen, Snippet,
CSS-Variablen an die Seite anpassen, Datenschutz-Absatz einfügen, Testanfrage), Einbauanleitung
`docs/funktionen/unterkunft-einbau.md`. Für Kunden, die die Funktion als Teil ihres Projekts bekommen (Mimi), schaltet
das Team sie wie bei den anderen Funktionen manuell frei (vorhandener Weg „ohne Zahlung freischalten“ im WOMS; der Plan
prüft, ob es ihn gibt, sonst Skript `scripts/unterkunft-freischalten.mjs`).
## 10. WOMS
Wie Webshop: Anfragen der Funktion in der Kunden-Funktionsansicht annehmen/ablehnen; Anzeige des Funktionsstatus;
Rechtliches-Karten `leistungsbeschreibung-unterkunft` und `avv-unterkunft` (Sortnummern nach den belegten, der Plan
prüft `setup-legal.mjs`). Keine Verwaltung der Buchungen im WOMS (Datenhoheit beim Kunden).
## 11. Mails (Vorlagen im Stil der bestehenden, `server/mail-templates`)
Gast (Absender WEBklar-Versandadresse, Anzeigename = Firma des Vermieters, Reply-To = E-Mail des Vermieters):
G16 Anfrage eingegangen · G17 Ihre Anfrage ist frei – jetzt buchen (Preis, Frist, Stornobedingungen, Link) ·
G18 Leider nicht möglich · G19 Buchungsbestätigung (alle Angaben, Stornobedingungen, Rechnungs-PDF, ggf. Bezahl-Link;
dauerhafter Datenträger § 312f BGB) · G20 Zahlung eingegangen · G21 Reservierung abgelaufen · G22 Stornierung
(Stornorechnung-PDF, Erstattungsbetrag).
Vermieter: K31 Neue Buchungsanfrage · K32 Neue Buchung · K33 Abgleich fehlgeschlagen · K34 Ihre Anfrage zum
Buchungskalender. Team: T22 Neue Anfrage Buchungskalender. Nummern vor dem Bau gegen `main` prüfen (Stand: K30, G15,
T21 belegt).
## 12. Datenschutz, Aufbewahrung, Rechtstexte
- Rollen: Vermieter ist Verantwortlicher für Gastdaten, WEBklar Auftragsverarbeiter → **AVV Buchungskalender** (Muster
AVV Webshop; Unterauftragsverarbeiter Hetzner, Stripe nur als eigener Vertragspartner des Vermieters).
- Fristen: `ipHash` weg bei Freigabe/Ablehnung bzw. nach 30 Tagen; Anfragen ohne Buchung (abgelehnt, abgelaufen, neu
älter als 60 Tage) nach 6 Monaten anonymisiert; Gastdaten gebuchter Aufenthalte 3 Jahre nach Jahresende der Abreise
anonymisiert, Rechnungs-PDFs und Rechnungsadresse bleiben 8 Jahre (§ 147 AO, Buchungsbelege) solange die Funktion
läuft; bei Abschaltung: Jahres-ZIP-Download angeboten, Hinweis-Mail, Löschung 90 Tage danach (Muster Webshop).
Täglicher Job im bestehenden Aufräum-Muster (`retention.js`).
- Datenschutz WEBklar: neuer Abschnitt **§ 6f** (6d Webshop, 6e Öffnungszeiten; Plan prüft). Muster-Absatz für die
Website des Vermieters `docs/funktionen/unterkunft-datenschutz-absatz.md`. Mustertext Stornobedingungen (mit Hinweis
„Vorlage, keine Rechtsberatung“).
- WEBklar-Texte (Fassung `2026-10-07`): Leistungsbeschreibung Buchungskalender, AVV Buchungskalender, Kanzlei-Fragen
`docs/rechtliches/unterkunft-kanzlei-fragen.md` (u. a. Button-Lösung über Mail-Link, USt-Satz Endreinigung, Rolle bei
Rechnungserstellung im Namen des Vermieters § 14 Abs. 2 S. 4 UStG — Rechnung durch Dritten im Auftrag, Vermieter muss
zustimmen → Zustimmung beim Bestellen und Einstellungen-Haken).
- Upload der Texte in WOMS Rechtliches macht Claude selbst (Feedback des Inhabers), sobald der Inhaber die Texte
freigibt.
## 13. Entscheidungen
Inhaber (2026-10-07): **U1** allgemeine Funktion für alle Kunden, Mimi zuerst. **U2** Stripe Connect wie Webshop, Geld
direkt an den Vermieter, PDF-Rechnung in dessen Namen, Zahlung online oder vor Ort. **U3** Preise im Portal hinterlegt,
vorgerechnet, vor Freigabe änderbar. **U4** iCal-Abgleich in beide Richtungen + manuelle Sperren. **U5** mehrere
Unterkünfte je Website. **U6** Anfrage unverbindlich, Buchung erst per „Zahlungspflichtig buchen“ nach Freigabe,
Reservierung bis Frist (Standard 3 Tage). **U7** 199 € einmalig auf Anfrage, keine laufenden Kosten. Bau in parallelen
Strängen.
Claude: **U8** eigenes Modul statt Terminplaner-Erweiterung oder Fremdsystem. **U9** Freigabe und Buchung prüfen
Verfügbarkeit unter Sperre erneut. **U10** keine WEBklar-Werbung auf der Rechnung. **U11** Staffelpreis gilt für alle
Nächte. **U12** Rabatt nicht auf Reinigung. **U13** Stornierung nur durch den Vermieter. **U14** Zahlungsabbruch lässt
die Buchung bestehen (unbezahlt). **U15** ein Stripe-Konto je Kunde für Shop und Buchungen. **U16** Widget ohne
Fremdbibliothek, Shadow DOM, CSS-Variablen.
## 14. Website Donau Home (D)
Abschnitt `#anfrage`: das bisherige Formular wird durch das Widget ersetzt (Kalender links unter Überschrift und
Einleitung, Formular rechts; Telefon/E-Mail unter dem Kalender). CSS-Variablen in Palette A (Eiche & Leinen) und
Schrift Figtree; Widget-Kalender optisch an den Zeichenstil angelehnt (Rahmen, Akzent Eiche). Bis zur Freischaltung
zeigt die Vorschau das Widget über `UNTERKUNFT_ENABLED=pilot` mit Mimis siteKey. Einleitungstext anpassen („Wählen Sie
Ihren Zeitraum im Kalender …“). Kein Push durch Claude.
## 15. Tests und Livegang
- Unit: `preis.js` (Staffeln, Zusatzpersonen, Rabatt, USt-Rundung, Kleinunternehmer), `ical.js` (Parser inkl. Faltung,
DATE/DATE-TIME, CANCELLED, Export), Statusmaschine, Rechnungsnummern unter Parallelität, Belegung/Überschneidung,
öffentliche Routen (Validierung, Rate-Limit, Token), Aufbewahrung. Muster der bestehenden Tests (Appwrite-Mock),
Node-Läufe mit `--max-old-space-size=1024` und Timeout.
- UI: Playwright-Durchlauf (Muster `test/ui/shopTestServer.mjs`): Anfrage im Widget → Freigabe im Portal → Buchungsseite
→ „vor Ort“ → Rechnung; Online-Zahlung gegen Stripe-Attrappe.
- Livegang (Notiz `docs/funktionen/unterkunft-livegang.md`): Setup-Skript, Stripe-Preis anlegen, Connect-Webhook-Events
prüfen (vorhandener Endpunkt), compose/.env `UNTERKUNFT_ENABLED=pilot`, `UNTERKUNFT_PILOT_EMAILS=kenso.gri@gmail.com,
<Mimi>`, Push durch den Inhaber (`feature/unterkunft:main`, nie unter eigenem Namen), Rechtstexte hochladen, Pilot mit
Mimi, danach `on`.
## 16. Parallele Stränge (für den Plan)
0. **Kern zuerst** (seriell, klein): Schema + Setup-Skript, Schalter, Register-Eintrag, `preis.js`, `belegung.js`,
Statusmaschine mit festen Funktionsschnittstellen und Attrappen.
Danach gleichzeitig:
1. **Ablauf + Server:** öffentliche Routen, Portal-API, Stripe, Jobs (Frist, iCal, Aufbewahrung), Rechnungsnummern.
2. **Portal-Oberfläche + WOMS.**
3. **Widget + Buchungsseite + Donau-Home-Einbau.**
4. **Mails, Rechnungs-PDF, Rechtstexte, Einbau- und Livegang-Notiz.**
Abschluss: Zusammenführen, Gesamttests, Review, UI-Durchlauf.
## 17. Nachträge aus der Code-Prüfung (2026-10-07, vor dem Plan)
- **N1 (ersetzt U16):** Widget nach dem Muster `shop.js`/`termine.js` statt Shadow DOM: eigenes `<style id="wku-style">` mit
`@layer wku`, Klassenpräfix `.wku-`, Variablen `--wku-akzent`, `--wku-akzent-text`, `--wku-hintergrund`, `--wku-text`,
`--wku-rand`, `--wku-radius`, `--wku-schrift`, `--wku-schrift-titel`. Öffentliche API unter `/api/public/unterkunft/v1/`.
- **N2 (ersetzt den Bucket `uk-rechnungen`):** Keine Datei-Ablage. `ukRechnungen.inhalt` speichert den vollständigen,
unveränderlichen Schnappschuss (Aussteller, Empfänger, Unterkunft, Zeitraum, Posten, Summen, Steuerart, Bezug); das PDF
wird bei jedem Abruf daraus deterministisch erzeugt. Eigener minimaler PDF-Writer (Helvetica, WinAnsi inkl. Umlaute und
€), keine neue Abhängigkeit.
- **N3:** Die Mail-Warteschlange (`termine/outbox.js`) bekommt ein optionales Feld `anhang` (JSON-Verweis
`{art:'ukRechnung', rechnungId}`), `sendGuestMail` einen Parameter `attachments`; das PDF entsteht beim Versand.
- **N4 (konkretisiert U15):** `ukEinstellungen.stripeAccountId` ist eigen; hat das Projekt ein Webshop-Konto
(`shopConfig.stripeAccountId`), bietet das Portal „vorhandenes Stripe-Konto verwenden“ an. Metadaten `wkUk:'1'`;
`account.updated` aktualisiert auch `ukEinstellungen`; `erstatten` bekommt einen Parameter für das Metadatum `webklar`.
- **N5:** Datenschutz-Abschnitt bleibt § 6f (6e ist für Öffnungszeiten reserviert, noch nicht live).
- **N6:** Kein Textform-Nachweis (nicht in `TEXTFORM_KEYS`); Mimi wird im WOMS über „Freischalten“ (`action:'activate'`)
ohne Zahlung freigeschaltet.
- **N7:** Kein Jahres-ZIP der Rechnungen (YAGNI); Einzel-PDF-Download je Rechnung. Bei Abschaltung Hinweis-Mail mit der Bitte, die PDFs herunterzuladen.
+4 -3
View File
@@ -139,7 +139,7 @@ function projectState(project) {
return ['kb-state-off', 'Noch keine Website']
}
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner', shop: 'Webshop', zeiten: 'Öffnungszeiten' }
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner', shop: 'Webshop', unterkunft: 'Buchungskalender', zeiten: 'Öffnungszeiten' }
function renderProjectCard(project, features) {
const li = document.createElement('li')
@@ -1930,7 +1930,7 @@ function initPasswordView() {
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
// "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt
// "Was wir zuletzt erledigt haben" (#pj-activity); hinter dem Hash darf ein Query stehen (#news?bestellt=1, Rueckkehr von Stripe).
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort', webshop: 'webshop', zeiten: 'zeiten' }
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort', webshop: 'webshop', buchungen: 'buchungen', zeiten: 'zeiten' }
const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' }
const viewLoaders = { chat: initChat }
// Funktionen und News laden bei jedem Oeffnen neu (Zustand aendert sich durch Bestellung und Freischaltung); Modul erst dann laden
@@ -1940,6 +1940,7 @@ const viewRefreshers = {
analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))),
webshop: () => import('/shop.js').then((m) => m.initShop(document.getElementById('webshop-body'))),
buchungen: () => import('/buchungen.js').then((m) => m.initBuchungen(document.getElementById('buchungen-body'))),
// baut sich bei gleichem Projekt und Stand nicht neu auf, solange der Editor steht (ungespeicherte Eingaben bleiben; zeiten.js, initZeiten)
zeiten: () => import('/zeiten.js').then((m) => m.initZeiten(document.getElementById('zeiten-body'))),
}
@@ -2135,7 +2136,7 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (
const coarsePointer = window.matchMedia('(pointer: coarse)')
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects', webshop: 'funktionen', zeiten: 'funktionen' }
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects', webshop: 'funktionen', buchungen: 'funktionen', zeiten: 'funktionen' }
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' }
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
+138
View File
@@ -0,0 +1,138 @@
/* Buchungskalender im Kundenbereich: die Anfrage der Funktion (Zustand off), Muster shop-anfrage.js.
POST /api/unterkunft/anfrage-funktion?projectId= { art, einheiten, portale, nachricht } => { anfrage } (portale: Liste aus 'booking' | 'airbnb'),
POST /api/unterkunft/anfrage-funktion/zurueckziehen?projectId=. Status im Stand (GET /status): anfrage:{ status:'offen'|'abgelehnt'|'angenommen',
at, begruendung?, anfrage:{ art, einheiten, portale, nachricht } }. Geprueft wird auf dem Server (Feldfehler am Feld). Text nur ueber textContent (h). */
import { allgemeinerFehler, datumText, feld, h, hakenFeld, request, setzeFehler, zeigeFehler } from './shop-dom.js'
import { fehlerFuerFormular } from './buchungen-fehler.js'
export const ARTEN = [['ferienwohnung', 'Ferienwohnung oder Ferienhaus'], ['monteurzimmer', 'Monteurwohnung'], ['pension', 'Pension oder Gästezimmer'], ['sonstiges', 'Sonstiges']]
export const PORTALE = [['booking', 'Booking.com'], ['airbnb', 'Airbnb']]
export const KALENDER_PREIS = '199 €'
const text = (wert) => (typeof wert === 'string' ? wert : '')
/** Der Server liest 'monteur' als Alias, gibt es aber in der Anfrage so zurueck; die Auswahl kennt nur 'monteurzimmer'. */
const artWert = (a) => (a === 'monteur' ? 'monteurzimmer' : a)
const labelVon = (liste, key) => liste.find(([k]) => k === key)?.[1] || ''
const enc = encodeURIComponent
/** Angaben der Anfrage als Definitionsliste (nur Text). */
export function anfrageListe(a) {
const s = a && typeof a === 'object' ? a : {}
const portale = (Array.isArray(s.portale) ? s.portale : []).map((p) => labelVon(PORTALE, p)).filter(Boolean).join(', ')
const zeilen = [
['Art der Unterkunft', labelVon(ARTEN, artWert(s.art))],
['Anzahl Einheiten', Number.isInteger(s.einheiten) ? String(s.einheiten) : ''],
['Portale', portale || 'keine'],
['Nachricht', text(s.nachricht)],
].filter(([, wert]) => wert !== '')
return h('dl', { class: 'sh-fakten' }, zeilen.map(([name, wert]) => h('div', null, h('dt', null, name), h('dd', null, wert))))
}
/** Formular „Buchungskalender anfragen“ in root; nachSenden(anfrage) wird nach dem Speichern aufgerufen (die Ansicht zeichnet sich neu). */
export function renderAnfrageFormular(root, { projectId, vorgabe = null, preis = KALENDER_PREIS, nachSenden } = {}) {
const v = vorgabe && typeof vorgabe === 'object' ? vorgabe : {}
const art = h('select', null, [h('option', { value: '' }, 'Bitte wählen …'), ...ARTEN.map(([w, n]) => h('option', { value: w }, n))])
art.value = ARTEN.some(([k]) => k === artWert(v.art)) ? artWert(v.art) : ''
const einheiten = h('input', { type: 'text', inputmode: 'numeric', maxlength: 3, autocomplete: 'off' })
einheiten.value = Number.isInteger(v.einheiten) ? String(v.einheiten) : ''
const portale = PORTALE.map(([wert]) => {
const box = h('input', { type: 'checkbox', value: wert })
box.checked = Array.isArray(v.portale) && v.portale.includes(wert)
return box
})
const nachricht = h('textarea', { rows: 3, maxlength: 1000 })
nachricht.value = text(v.nachricht)
const absenden = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'anfrage-senden' }, 'Anfrage senden')
const form = h('form', { class: 'sh-form', novalidate: true, 'aria-label': 'Buchungskalender anfragen' },
feld('art', 'Welche Art von Unterkunft vermietest du?', art),
feld('einheiten', 'Wie viele Einheiten (Wohnungen, Zimmer) vermietest du?', einheiten),
h('fieldset', { class: 'sh-gruppe' },
h('legend', null, 'Nutzt du Booking.com oder Airbnb?'),
...PORTALE.map(([wert, name], i) => hakenFeld(`portale.${wert}`, portale[i], name)),
h('p', { class: 'sh-fehler', 'data-fehler': 'portale', role: 'alert' })),
feld('nachricht', 'Noch etwas, das wir wissen sollten? (freiwillig)', nachricht),
allgemeinerFehler(),
h('div', { class: 'sh-aktionen' }, absenden))
let laeuft = false
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
if (laeuft) return
laeuft = true
absenden.disabled = true
setzeFehler(form, {})
const zahl = einheiten.value.trim()
try {
const antwort = await request(`/api/unterkunft/anfrage-funktion?projectId=${enc(projectId)}`, {
method: 'POST',
body: {
art: art.value,
einheiten: /^\d{1,3}$/.test(zahl) ? Number(zahl) : zahl,
portale: portale.filter((b) => b.checked).map((b) => b.value),
nachricht: nachricht.value.trim(),
},
})
if (typeof nachSenden === 'function') await nachSenden(antwort?.anfrage ?? null)
return
} catch (err) {
zeigeFehler(form, fehlerFuerFormular(err))
if (err?.status === 409 && typeof nachSenden === 'function') {
try { await nachSenden(null, { meldung: fehlerFuerFormular(err).message }) } catch { /* der Fehler steht schon am Formular */ }
return
}
}
laeuft = false
absenden.disabled = false
})
const erklaerung = h('div', { class: 'sh-erklaerung' },
h('h3', null, 'Buchungskalender anfragen'),
h('p', null, 'Mit dem Buchungskalender zeigst du auf deiner Website, wann deine Unterkunft frei ist. Gäste fragen direkt an, du gibst frei, und die Rechnung entsteht automatisch.'),
h('ul', null,
h('li', null, `${preis} einmalig für den Einbau in deine Website.`),
h('li', null, 'Belegungskalender mit Abgleich zu Booking.com und Airbnb (über iCal).'),
h('li', null, 'Rechnung in deinem Namen und optional Online-Zahlung auf dein eigenes Stripe-Konto.'),
h('li', null, 'Wir sehen uns deine Anfrage kurz an. Erst nach unserer Zusage kannst du bestellen.')))
const karte = h('section', { class: 'kb-panel sh-anfrage' }, erklaerung, form)
root.replaceChildren(karte)
return karte
}
/** Karte einer Anfrage im Stand offen (mit „Anfrage zurückziehen“) oder abgelehnt (Begründung, „Neue Anfrage stellen“). */
export function anfrageKarte(ansicht, { projectId, neu, neuLaden } = {}) {
const offen = ansicht?.status === 'offen'
const meldung = h('p', { class: 'sh-fehler', role: 'alert', 'data-meldung': 'anfrage' })
const datum = datumText(ansicht?.at)
let aktion
if (offen) {
aktion = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueckziehen' }, 'Anfrage zurückziehen')
aktion.addEventListener('click', async () => {
if (aktion.disabled) return
aktion.disabled = true
meldung.textContent = ''
try {
await request(`/api/unterkunft/anfrage-funktion/zurueckziehen?projectId=${enc(projectId)}`, { method: 'POST' })
if (typeof neuLaden === 'function') await neuLaden()
return
} catch (err) {
meldung.textContent = err.message
if (err?.status === 409 && typeof neuLaden === 'function') { try { await neuLaden({ meldung: err.message }) } catch { /* Meldung bleibt */ } return }
}
aktion.disabled = false
})
} else {
aktion = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'neue-anfrage', onclick: () => { if (typeof neu === 'function') neu() } }, 'Neue Anfrage stellen')
}
return h('section', { class: 'kb-panel sh-info sh-anfrage-karte', 'data-anfrage': offen ? 'offen' : 'abgelehnt' },
h('span', { class: `sh-pille ${offen ? 'sh-pille-wait' : 'sh-pille-bad'}` }, offen ? 'In Prüfung' : 'Abgelehnt'),
h('h3', null, offen ? 'Deine Anfrage wird geprüft' : 'Deine Anfrage wurde leider abgelehnt'),
offen
? h('p', null, `${datum ? `Du hast deine Anfrage am ${datum} Uhr gesendet. ` : ''}Wir melden uns, sobald wir sie angesehen haben. Bis dahin kannst du sie zurückziehen und eine neue stellen.`)
: h('p', null, 'Unsere Begründung:'),
!offen ? h('blockquote', { class: 'sh-begruendung' }, text(ansicht?.begruendung) || 'Dazu liegt keine Begründung vor.') : null,
ansicht?.anfrage ? h('div', { class: 'sh-zusammenfassung' }, h('h4', null, 'Deine Angaben'), anfrageListe(ansicht.anfrage)) : null,
meldung,
h('div', { class: 'sh-aktionen' }, aktion, h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Frage dazu? Chat öffnen')))
}
+34
View File
@@ -0,0 +1,34 @@
/* Buchungskalender: Fehler der Schnittstelle fuers Formular. Die Schnittstelle antwortet { fehler: '<code>', felder?: {feld: text} };
request() (shop-dom.js) legt den Code in err.fehler und die ganze Antwort in err.body. Fuer zeigeFehler() daher vorher umformen:
zeigeFehler(form, fehlerFuerFormular(err)). */
const CODES = {
zeitraum_belegt: 'Der Zeitraum ist bereits belegt.',
zeitraum_ungueltig: 'Bitte prüfe den Zeitraum.',
zustimmung_fehlt: 'Bitte erteile zuerst in den Einstellungen die Zustimmung zur Rechnungsstellung in deinem Namen.',
stripe_nicht_aktiv: 'Online-Zahlung geht erst, wenn dein Stripe-Konto freigeschaltet ist.',
erstattung_fehlgeschlagen: 'Die Stornierung wurde durchgeführt, aber die Erstattung ist fehlgeschlagen. Bitte erstatte den Betrag im Stripe-Dashboard oder versuch es später noch einmal.',
frist_abgelaufen: 'Die Frist für diese Anfrage ist abgelaufen.',
preise_unvollstaendig: 'Die Preise sind noch nicht vollständig. Trage im Reiter „Unterkünfte“ unter „Preise“ alle Pflichtpreise ein und speichere sie.',
einstellungen_unvollstaendig: 'Deine Einstellungen sind noch nicht vollständig. Ergänze im Reiter „Einstellungen“ Rechnungsangaben und Zustimmung und speichere sie.',
status_ungueltig: 'Das geht beim aktuellen Stand der Anfrage nicht mehr. Lade die Seite neu und prüfe den Status.',
limit_erreicht: 'Du hast die Höchstzahl erreicht. Entferne zuerst einen vorhandenen Eintrag, dann kannst du einen neuen anlegen.',
kein_konto: 'Es ist noch kein Stripe-Konto verbunden. Richte zuerst die Online-Zahlung im Reiter „Shop“ ein.',
nicht_aktiv: 'Der Buchungskalender ist bei dir gerade nicht aktiv. Schalte ihn unter „Funktionen“ ein oder wende dich an uns.',
praefix_gesperrt: 'Das Rechnungspräfix kann nach der ersten Rechnung nicht mehr geändert werden. Lass das Feld unverändert.',
sperre_aus_ical: 'Diese Sperre stammt aus einem Kalenderimport und lässt sich hier nicht ändern. Ändere sie im Ursprungskalender oder entferne den Import.',
online_aus: 'Online-Zahlung ist bei dir ausgeschaltet. Schalte sie in den Einstellungen ein oder wähle „Vor Ort bezahlen“.',
zahlung_fehlgeschlagen: 'Die Zahlung konnte nicht gestartet werden. Versuche es in ein paar Minuten noch einmal.',
hat_anfragen: 'Zu dieser Unterkunft gibt es schon Anfragen, deshalb kannst du sie nicht löschen. Schalte sie stattdessen auf inaktiv.',
konto_vorhanden: 'Es ist schon ein anderes Stripe-Konto hinterlegt. Wende dich an uns, wenn du es wechseln möchtest.',
zu_viele_personen: 'Das sind mehr Personen, als die Unterkunft aufnimmt. Prüfe die Personenzahl.',
}
/** => { message, fehler: { feld: text } } (Feldfehler aus body.felder, sonst ein Text unter „_“). */
export function fehlerFuerFormular(err) {
const felder = err?.body?.felder
const STANDARD = 'Das hat nicht geklappt. Bitte versuch es noch einmal.'
const code = typeof err?.fehler === 'string' ? err.fehler : ''
const message = (code && CODES[code]) || (code ? STANDARD : err?.message) || STANDARD
if (felder && typeof felder === 'object' && Object.keys(felder).length) return { message, fehler: felder }
return { message, fehler: { _: message } }
}
+108
View File
@@ -0,0 +1,108 @@
/* Buchungskalender: iCal-Bereich einer Unterkunft (Importe von Booking.com/Airbnb, Export-Link, Kurzanleitungen).
unterkunft = Eintrag aus GET /api/unterkunft/unterkuenfte: { id, icalImporte:[{ id, name, url, letzterAbrufAt, letzterErfolgAt, fehler }], icalExportUrl }.
POST /unterkuenfte/:id/ical-importe { name, url } · DELETE …/ical-importe/:importId · POST …/ical-export/erneuern => { icalExportUrl }.
Nach jeder Aenderung ruft der Aufrufer aenderung() (laedt die Unterkunft neu). */
import { abschnitt, allgemeinerFehler, datumText, feld, frage, h, zeigeFehler } from './shop-dom.js'
import { enc, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
export function zeichneIcal(ctx, unterkunft, aenderung) {
const basis = `/api/unterkunft/unterkuenfte/${enc(unterkunft.id)}`
const q = `projectId=${enc(ctx.projectId)}`
const importe = Array.isArray(unterkunft.icalImporte) ? unterkunft.icalImporte : []
const dialog = h('div', { class: 'bk-dialog' })
const status = (i) => {
if (i.fehler) return `Der letzte Abgleich hat nicht geklappt: ${zeichen(i.fehler)}${i.letzterErfolgAt ? ` (zuletzt erfolgreich ${datumText(i.letzterErfolgAt)})` : ''}`
if (i.letzterErfolgAt) return `Zuletzt abgeglichen ${datumText(i.letzterErfolgAt)}`
return 'Noch nicht abgeglichen. Das passiert automatisch alle 15 Minuten.'
}
const importListe = importe.length
? h('ul', { class: 'bk-liste' }, importe.map((i) => h('li', { class: 'bk-import', 'data-import': i.id },
h('strong', null, zeichen(i.name)),
h('span', { class: i.fehler ? 'sh-fehler' : 'sh-hinweis', 'data-status': true }, status(i)),
ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'import-weg', onclick: () => entfernenDialog(i) }, 'Entfernen'))))
: h('p', { class: 'sh-hinweis' }, 'Noch kein Kalender angebunden.')
function entfernenDialog(i) {
const box = frage('Kalender entfernen', h('p', null, `Die Belegung aus „${zeichen(i.name)}“ verschwindet dann aus deinem Kalender.`), allgemeinerFehler(),
h('div', { class: 'bk-aktionen' },
h('button', {
type: 'button', class: 'kb-btn', 'data-aktion': 'import-weg-ok',
onclick: async (ev) => {
ev.currentTarget.disabled = true
try {
await ctx.request(`${basis}/ical-importe/${enc(i.id)}?${q}`, { method: 'DELETE' })
if (ctx.aktuell()) await aenderung('Der Kalender ist entfernt.')
} catch (err) { ev.currentTarget.disabled = false; zeigeApiFehler(box, err) }
},
}, 'Entfernen'),
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => dialog.replaceChildren() }, 'Abbrechen')))
dialog.replaceChildren(box)
}
const name = h('input', { type: 'text', id: 'bk-i-name', maxlength: '60', autocomplete: 'off', placeholder: 'Booking.com' })
const url = h('input', { type: 'text', id: 'bk-i-url', inputmode: 'url', autocomplete: 'off', placeholder: 'https://…' })
const neu = ctx.nurLesen ? null : h('form', { novalidate: true, class: 'bk-importform', onsubmit: (ev) => ev.preventDefault() },
feld('name', 'Name', name), feld('url', 'Link (iCal-Adresse)', url, 'Der Link beginnt mit https:// und endet oft auf .ics.'), allgemeinerFehler(),
h('button', {
type: 'button', class: 'kb-btn', 'data-aktion': 'import-dazu',
onclick: async (ev) => {
const form = ev.currentTarget.form || ev.currentTarget.closest('form')
const body = { name: String(name.value).trim(), url: String(url.value).trim() }
if (!body.name || !/^https:\/\//i.test(body.url)) return zeigeFehler(form, { message: '', fehler: { _: 'Bitte gib einen Namen und einen Link an, der mit https:// beginnt.' } })
ev.currentTarget.disabled = true
try {
await ctx.request(`${basis}/ical-importe?${q}`, { method: 'POST', body })
if (ctx.aktuell()) await aenderung('Der Kalender ist angebunden.')
} catch (err) { ev.currentTarget.disabled = false; zeigeApiFehler(form, err) }
},
}, 'Kalender hinzufügen'))
const exportUrl = zeichen(unterkunft.icalExportUrl)
const kopiert = h('p', { class: 'sh-hinweis', role: 'status', 'data-kopiert': true })
const erneuern = () => {
const box = frage('Link erneuern', h('p', null, 'Der alte Link funktioniert danach nicht mehr. Du musst den neuen Link bei Booking.com und Airbnb eintragen.'), allgemeinerFehler(),
h('div', { class: 'bk-aktionen' },
h('button', {
type: 'button', class: 'kb-btn', 'data-aktion': 'erneuern-ok',
onclick: async (ev) => {
ev.currentTarget.disabled = true
try {
await ctx.request(`${basis}/ical-export/erneuern?${q}`, { method: 'POST' })
if (ctx.aktuell()) await aenderung('Der Link ist erneuert. Trage den neuen Link bei den Portalen ein.')
} catch (err) { ev.currentTarget.disabled = false; zeigeApiFehler(box, err) }
},
}, 'Link erneuern'),
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => dialog.replaceChildren() }, 'Abbrechen')))
dialog.replaceChildren(box)
}
const export_ = h('div', { class: 'bk-link' },
h('p', { class: 'sh-hinweis' }, 'Mit diesem Link sehen Booking.com und Airbnb, wann deine Unterkunft belegt ist. Es werden keine Gastdaten weitergegeben.'),
exportUrl ? h('input', { type: 'text', readonly: true, value: exportUrl, 'aria-label': 'Export-Link', 'data-export': true }) : h('p', { class: 'sh-hinweis' }, 'Der Link ist noch nicht verfügbar.'),
h('div', { class: 'bk-aktionen' },
exportUrl ? h('button', {
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'export-kopieren',
onclick: async () => {
try { await navigator.clipboard.writeText(exportUrl); kopiert.textContent = 'Link kopiert.' } catch { kopiert.textContent = 'Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.' }
},
}, 'Kopieren') : null,
ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'export-erneuern', onclick: erneuern }, 'Link erneuern')),
kopiert)
const anleitung = h('div', { class: 'bk-anleitung' },
h('details', { 'data-anleitung': 'booking' }, h('summary', null, 'Anleitung Booking.com'),
h('ol', null,
h('li', null, 'Unterkunft → Tarife & Verfügbarkeit → Kalender synchronisieren → Kalender importieren → Link einfügen.'),
h('li', null, 'Den Export-Link von oben dort einfügen und speichern.'),
h('li', null, 'Für die andere Richtung: Booking.com zeigt dir dort auch einen Link zum Exportieren. Den trägst du oben bei „Kalender anbinden“ ein.'))),
h('details', { 'data-anleitung': 'airbnb' }, h('summary', null, 'Anleitung Airbnb'),
h('ol', null,
h('li', null, 'Inserat → Verfügbarkeit → Kalender synchronisieren → Kalender verbinden.'),
h('li', null, 'Den Export-Link von oben dort einfügen und speichern.'),
h('li', null, 'Airbnb zeigt dir unter „Kalender exportieren“ einen Link. Den trägst du oben bei „Kalender anbinden“ ein.'))))
return abschnitt('Kalender-Abgleich (iCal)', 'So verhinderst du Doppelbuchungen: Belegungen von Booking.com oder Airbnb kommen in deinen Kalender, und deine Belegungen gehen dorthin.',
h('h4', null, 'Kalender anbinden'), importListe, neu,
h('h4', null, 'Dein Export-Link'), export_, anleitung, dialog)
}
+109
View File
@@ -0,0 +1,109 @@
/* Buchungskalender: Posten-Editor fuer die Freigabe einer Anfrage.
summeLive(posten, steuerart) ist ein Spiegel von server/services/unterkunft/preis.js summiere (gleiche Rundung): Anzeige waehrend des Tippens,
verbindlich rechnet der Server. Posten: { text, menge, einzelCent (negativ bei Rabatt), ustProzent }. */
import { euroText, euroZuCent, h } from './shop-dom.js'
export function summeLive(posten, steuerart) {
const bruttoCent = posten.reduce((a, p) => a + p.menge * p.einzelCent, 0)
if (steuerart === 'kleinunternehmer') return { bruttoCent, nettoCent: bruttoCent, ust: [] }
const proSatz = new Map()
for (const p of posten) proSatz.set(p.ustProzent, (proSatz.get(p.ustProzent) || 0) + p.menge * p.einzelCent)
const ust = [...proSatz.entries()]
.sort((a, b) => a[0] - b[0])
.map(([satz, brutto]) => {
const nettoCent = Math.round((brutto * 100) / (100 + satz))
return { satz, nettoCent, steuerCent: brutto - nettoCent }
})
const nettoCent = ust.reduce((a, u) => a + u.nettoCent, 0)
return { bruttoCent, nettoCent, ust }
}
/** Betrag mit Vorzeichen (Rabatt) in Euro-Schreibweise: -3850 ⇒ '-38,50 €'. */
export const euroMitVorzeichen = (cent) => (Number.isInteger(cent) ? (cent < 0 ? `-${euroText(-cent)}` : euroText(cent)) : '')
/** Feldtext ('-38,50', '−38,50 €', '12,5') in Cent mit Vorzeichen; null bei allem Unlesbaren. */
export function euroZuCentMitVorzeichen(text) {
if (typeof text !== 'string') return null
const s = text.trim()
const minus = /^[-−–]/.test(s)
const c = euroZuCent(minus ? s.slice(1) : s)
return c === null ? null : minus ? -c : c
}
const centFeld = (cent) => (Number.isInteger(cent) ? `${cent < 0 ? '-' : ''}${Math.floor(Math.abs(cent) / 100)},${String(Math.abs(cent) % 100).padStart(2, '0')}` : '')
/**
* Editor bauen. => { el, posten() => [{text,menge,einzelCent,ustProzent}] oder null bei Unlesbarem, summe() }.
* steuerart 'kleinunternehmer': keine USt-Spalte (ustProzent 0). aenderung() wird nach jeder Eingabe gerufen.
*/
export function baueEditor(start, steuerart, { aenderung } = {}) {
const klein = steuerart === 'kleinunternehmer'
const zeilen = []
const liste = h('div', { class: 'bk-posten' })
const summenEl = h('div', { class: 'bk-summen', 'data-summen': true, 'aria-live': 'polite' })
const lies = () => {
const ergebnis = []
for (const z of zeilen) {
const einzelCent = euroZuCentMitVorzeichen(String(z.einzel.value))
const menge = Number(String(z.menge.value).trim())
if (einzelCent === null || !Number.isInteger(menge) || menge < 1) return null
ergebnis.push({ text: String(z.text.value).trim(), menge, einzelCent, ustProzent: klein ? 0 : Number(z.ust.value) })
}
return ergebnis
}
const zeichneSumme = () => {
const p = lies()
if (!p) {
summenEl.replaceChildren(h('p', { class: 'sh-fehler' }, 'Bitte prüfe Menge und Preis der Posten.'))
return
}
const s = summeLive(p, steuerart)
summenEl.replaceChildren(
...(klein ? [] : [h('p', null, `Netto ${euroMitVorzeichen(s.nettoCent)}`), ...s.ust.map((u) => h('p', null, `${u.satz} % USt ${euroMitVorzeichen(u.steuerCent)}`))]),
h('p', { class: 'bk-gesamt' }, h('strong', null, `Gesamt ${euroMitVorzeichen(s.bruttoCent)}`)),
...(klein ? [h('p', { class: 'sh-hinweis' }, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')] : []))
}
const geaendert = () => {
zeichneSumme()
aenderung?.()
}
function fuegeZeile(p = { text: '', menge: 1, einzelCent: 0, ustProzent: 7 }) {
const z = {
text: h('input', { type: 'text', maxlength: '200', 'aria-label': 'Beschreibung', 'data-posten': 'text' }),
menge: h('input', { type: 'text', inputmode: 'numeric', 'aria-label': 'Menge', 'data-posten': 'menge' }),
einzel: h('input', { type: 'text', inputmode: 'decimal', autocomplete: 'off', 'aria-label': 'Einzelpreis in Euro (Rabatt mit Minus)', 'data-posten': 'einzel' }),
ust: klein ? null : h('select', { 'aria-label': 'Umsatzsteuer', 'data-posten': 'ust' }, [7, 19, 0].map((satz) => h('option', { value: String(satz) }, `${satz} %`))),
}
z.text.value = p.text
z.menge.value = String(p.menge)
z.einzel.value = centFeld(p.einzelCent)
if (z.ust) z.ust.value = String(p.ustProzent)
for (const el of [z.text, z.menge, z.einzel, z.ust]) el?.addEventListener('input', geaendert)
z.ust?.addEventListener('change', geaendert)
z.einzel.addEventListener('change', () => {
const c = euroZuCentMitVorzeichen(String(z.einzel.value))
if (c !== null) z.einzel.value = centFeld(c)
})
z.zeile = h('div', { class: 'bk-posten-zeile', 'data-zeile': true },
z.text, z.menge, z.einzel, z.ust,
h('button', {
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'posten-weg', 'aria-label': 'Posten entfernen',
onclick: () => {
zeilen.splice(zeilen.indexOf(z), 1)
z.zeile.remove()
geaendert()
},
}, 'Entfernen'))
zeilen.push(z)
liste.append(z.zeile)
}
for (const p of start) fuegeZeile(p)
const kopf = h('div', { class: 'bk-posten-kopf', 'aria-hidden': 'true' },
h('span', null, 'Beschreibung'), h('span', null, 'Menge'), h('span', null, 'Einzelpreis €'), klein ? null : h('span', null, 'USt'), h('span'))
const el = h('div', { class: 'bk-editor' }, kopf, liste,
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'posten-neu', onclick: () => { fuegeZeile(); geaendert() } }, 'Posten hinzufügen'),
summenEl)
zeichneSumme()
return { el, posten: lies, summe: () => { const p = lies(); return p ? summeLive(p, steuerart) : null } }
}
+185
View File
@@ -0,0 +1,185 @@
/* Buchungskalender: Preis-Editor einer Unterkunft (Staffeln, Reinigung, Zusatzpersonen, Rabatt, Steuer, Frist, Stornobedingungen) mit Beispielrechnung.
GET|PUT /api/unterkunft/unterkuenfte/:id/preise => { preise:{ staffeln:[{abNaechten,preisProNachtCent}], reinigungCent, personenInklusive, aufpreisPersonNachtCent,
direktrabattProzent, steuerart:'kleinunternehmer'|'regel', ustUebernachtung, ustReinigung, fristTage, stornobedingungen } | null }
POST /api/unterkunft/preisvorschau { unterkunftId, von, bis, personen } => { naechte, posten, summen:{ bruttoCent } } (rechnet mit den gespeicherten Preisen). */
import { abschnitt, allgemeinerFehler, euroEingabe, euroText, feld, h, setzeFehler, zeigeFehler } from './shop-dom.js'
import { enc, heuteBerlin, plusTage, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
// Wortgleich zu docs/funktionen/unterkunft-storno-muster.md, Abschnitt "Stornobedingungen (Muster)" (ohne Markdown-Fettdruck); test/buchungenUi.test.js vergleicht beides.
export const STORNO_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.",
].join('\n')
export const STORNO_HINWEIS = 'Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung.'
const STANDARD = { staffeln: [{ abNaechten: 1, preisProNachtCent: null }], reinigungCent: 0, personenInklusive: 2, aufpreisPersonNachtCent: 0, direktrabattProzent: 0, steuerart: 'kleinunternehmer', ustUebernachtung: 7, ustReinigung: 7, fristTage: 3, stornobedingungen: '' }
const ganzzahl = (steuer) => { const t = String(steuer.value).trim(); return /^\d{1,9}$/.test(t) ? Number(t) : null }
export async function zeichnePreise(ctx, unterkunft) {
const basis = `/api/unterkunft/unterkuenfte/${enc(unterkunft.id)}`
const q = `projectId=${enc(ctx.projectId)}`
const wurzel = h('div', { class: 'bk-preise' }, h('p', { class: 'kb-empty' }, 'Lade…'))
let preise = STANDARD
try {
const daten = await ctx.request(`${basis}/preise?${q}`)
if (daten?.preise) preise = { ...STANDARD, ...daten.preise }
} catch (err) {
wurzel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err.message))
return wurzel
}
const zeilenEl = h('div', { class: 'bk-staffeln' })
const zeile = (s) => {
const ab = h('input', { type: 'text', inputmode: 'numeric', 'aria-label': 'Ab Nächten', 'data-staffel': 'ab', autocomplete: 'off' })
ab.value = String(s.abNaechten ?? '')
const preis = euroEingabe({ cent: s.preisProNachtCent, label: 'Preis pro Nacht in Euro' })
preis.dataset.staffel = 'preis'
const el = h('div', { class: 'bk-staffel', 'data-staffel-zeile': true }, 'ab ', ab, ' Nächten: ', preis, ' € pro Nacht ',
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'staffel-weg', 'aria-label': 'Staffel entfernen', onclick: () => { if (zeilenEl.children.length > 1) { el.remove(); halteErste() } } }, '−'))
return el
}
const halteErste = () => { const ab = zeilenEl.querySelector('[data-staffel="ab"]'); if (ab) { ab.value = '1'; ab.readOnly = true; ab.setAttribute('readonly', '') } }
zeilenEl.append(...(preise.staffeln?.length ? preise.staffeln : STANDARD.staffeln).map(zeile))
halteErste()
const reinigung = euroEingabe({ cent: preise.reinigungCent, id: 'bk-p-reinigung' })
const inklusive = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-inklusive' }); inklusive.value = String(preise.personenInklusive ?? 2)
const aufpreis = euroEingabe({ cent: preise.aufpreisPersonNachtCent, id: 'bk-p-aufpreis' })
const rabatt = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-rabatt' }); rabatt.value = String(preise.direktrabattProzent ?? 0)
const steuerart = h('select', { id: 'bk-p-steuerart' }, [['kleinunternehmer', 'Kleinunternehmer (keine Umsatzsteuer)'], ['regel', 'Regelbesteuerung (mit Umsatzsteuer)']].map(([w, t]) => h('option', { value: w }, t)))
steuerart.value = preise.steuerart === 'regel' ? 'regel' : 'kleinunternehmer'
const ustUe = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-ust-ue' }); ustUe.value = String(preise.ustUebernachtung ?? 7)
const ustRe = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-ust-re' }); ustRe.value = String(preise.ustReinigung ?? 7)
const frist = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-frist' }); frist.value = String(preise.fristTage ?? 3)
const storno = h('textarea', { id: 'bk-p-storno', rows: '6', maxlength: '5000' }); storno.value = zeichen(preise.stornobedingungen)
const ustFelder = h('div', { class: 'bk-ust' }, feld('ustUebernachtung', 'USt Übernachtung in %', ustUe), feld('ustReinigung', 'USt Endreinigung in %', ustRe))
const zeigeUst = () => { ustFelder.hidden = steuerart.value !== 'regel' }
steuerart.addEventListener('change', zeigeUst)
zeigeUst()
const vorschauText = h('p', { class: 'bk-vorschau', 'data-vorschau': true, role: 'status' })
const naechteEl = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-vn' }); naechteEl.value = '7'
const personenEl = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-vp' }); personenEl.value = '2'
async function vorschau() {
const naechte = ganzzahl(naechteEl)
const personen = ganzzahl(personenEl)
if (!naechte || !personen) { vorschauText.textContent = 'Gib Nächte und Personen als ganze Zahlen an.'; return }
const von = plusTage(heuteBerlin(), 60)
try {
const antwort = await ctx.request(`/api/unterkunft/preisvorschau?${q}`, { method: 'POST', body: { unterkunftId: unterkunft.id, von, bis: plusTage(von, naechte), personen } })
if (!ctx.aktuell()) return
vorschauText.textContent = Number.isInteger(antwort?.summen?.bruttoCent)
? `Beispiel: ${antwort.naechte ?? naechte} ${(antwort.naechte ?? naechte) === 1 ? 'Nacht' : 'Nächte'}, ${personen} ${personen === 1 ? 'Person' : 'Personen'} = ${euroText(antwort.summen.bruttoCent)}`
: 'Für diese Angaben gibt es noch keine Beispielrechnung.'
} catch (err) {
vorschauText.textContent = Object.values(err.body?.felder || {})[0] || 'Die Beispielrechnung klappt erst, wenn die Preise gespeichert sind.'
}
}
for (const el of [naechteEl, personenEl]) el.addEventListener('change', vorschau)
const form = h('form', { novalidate: true, class: 'bk-preisform', onsubmit: (ev) => ev.preventDefault() },
h('h4', null, 'Preis pro Nacht (Staffeln)'),
h('p', { class: 'sh-hinweis' }, 'Je länger der Aufenthalt, desto günstiger die Nacht: Die passende Staffel gilt für alle Nächte der Buchung.'),
zeilenEl, h('div', { class: 'sh-fehler', 'data-fehler': 'staffeln', role: 'alert' }),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'staffel-dazu', onclick: () => { zeilenEl.append(zeile({ abNaechten: null, preisProNachtCent: null })) } }, '+ Staffel'),
feld('reinigungCent', 'Endreinigung in Euro', reinigung),
feld('personenInklusive', 'Personen im Preis inklusive', inklusive),
feld('aufpreisPersonNachtCent', 'Aufpreis je weiterer Person und Nacht in Euro', aufpreis),
feld('direktrabattProzent', 'Direktbuchungsrabatt in %', rabatt, 'Ganze Zahl von 0 bis 50. Gilt für Übernachtung und Zusatzpersonen, nicht für die Reinigung.'),
feld('steuerart', 'Steuer', steuerart), ustFelder,
feld('fristTage', 'Frist für die Buchung nach Freigabe in Tagen', frist, '1 bis 14 Tage.'),
feld('stornobedingungen', 'Stornobedingungen', storno, 'Pflicht, bevor du den Buchungskalender freischaltest. Sie stehen auf der Buchungsseite.'),
ctx.nurLesen ? null : h('div', { class: 'bk-aktionen' },
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'storno-muster', onclick: () => { storno.value = STORNO_MUSTER } }, 'Mustertext übernehmen'),
h('p', { class: 'sh-hinweis', 'data-hinweis': 'storno-muster' }, STORNO_HINWEIS)),
allgemeinerFehler(),
h('div', { class: 'bk-aktionen' }, ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'preise-speichern', onclick: speichern }, 'Preise speichern')))
if (ctx.nurLesen) for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true
// Ein Feldfehler (z. B. Platzhalter in den Stornobedingungen) verschwindet, sobald im Feld getippt wird
for (const el of form.querySelectorAll('[data-feld]')) {
el.addEventListener('input', () => {
const name = el.getAttribute('data-feld')
const ziel = form.querySelector(`[data-fehler="${CSS.escape(name)}"]`)
if (ziel) ziel.textContent = ''
el.removeAttribute('aria-invalid')
})
}
async function speichern(ev) {
const knopf = ev.currentTarget
const fehler = {}
const staffeln = []
let staffelFehler = ''
for (const z of zeilenEl.children) {
const ab = ganzzahl(z.querySelector('[data-staffel="ab"]'))
const p = z.querySelector('[data-staffel="preis"]').inCent()
if (ab === null || ab < 1 || p === null) staffelFehler ||= 'Bitte gib bei jeder Staffel Nächte (ganze Zahl) und einen Preis an.'
else if (p < 100 || p > 100000) staffelFehler ||= 'Der Preis pro Nacht muss zwischen 1,00 € und 1.000,00 € liegen.'
else staffeln.push({ abNaechten: ab, preisProNachtCent: p })
}
if (!staffelFehler) {
if (staffeln.length < 1 || staffeln.length > 6) staffelFehler = 'Du kannst 1 bis 6 Staffeln anlegen.'
else if (staffeln[0].abNaechten !== 1) staffelFehler = 'Die erste Staffel beginnt bei 1 Nacht.'
else if (staffeln.some((s, i) => i > 0 && s.abNaechten <= staffeln[i - 1].abNaechten)) staffelFehler = 'Die Nächte der Staffeln müssen von oben nach unten größer werden, keine Zahl doppelt.'
}
if (staffelFehler) fehler.staffeln = staffelFehler
const zahl = (name, steuer, text, min, max) => { const n = ganzzahl(steuer); if (n === null || n < min || n > max) { fehler[name] = text; return null } return n }
const reinigungCent = reinigung.istLeer() ? 0 : reinigung.inCent()
if (reinigungCent === null) fehler.reinigungCent = 'Bitte gib einen Betrag wie 60,00 an.'
const aufpreisCent = aufpreis.istLeer() ? 0 : aufpreis.inCent()
if (aufpreisCent === null) fehler.aufpreisPersonNachtCent = 'Bitte gib einen Betrag wie 15,00 an.'
const regel = steuerart.value === 'regel'
const ustWert = (name, steuer, alt) => {
if (!regel) return ganzzahl(steuer) ?? alt // andere Steuerart: gespeicherten Wert unveraendert zurueckschicken
const n = ganzzahl(steuer)
if (![0, 7, 19].includes(n)) { fehler[name] = 'Erlaubt sind 0, 7 oder 19 Prozent.'; return null }
return n
}
const storn = String(storno.value).trim()
if (storn.length < 20 || storn.length > 5000) fehler.stornobedingungen = 'Die Stornobedingungen brauchen 20 bis 5000 Zeichen.'
if (!fehler.stornobedingungen && /\[[^\]]*\]/.test(storn)) fehler.stornobedingungen = 'Bitte ersetze die Platzhalter in eckigen Klammern durch deine Werte.'
const body = {
staffeln, reinigungCent, aufpreisPersonNachtCent: aufpreisCent,
personenInklusive: zahl('personenInklusive', inklusive, 'Bitte gib eine ganze Zahl ab 1 an.', 1, 1000),
direktrabattProzent: zahl('direktrabattProzent', rabatt, 'Bitte gib eine ganze Zahl von 0 bis 50 an.', 0, 50),
steuerart: steuerart.value,
ustUebernachtung: ustWert('ustUebernachtung', ustUe, preise.ustUebernachtung ?? 7),
ustReinigung: ustWert('ustReinigung', ustRe, preise.ustReinigung ?? 7),
fristTage: zahl('fristTage', frist, 'Bitte gib eine ganze Zahl von 1 bis 14 an.', 1, 14),
stornobedingungen: storn,
}
if (Object.keys(fehler).length) return zeigeFehler(form, { message: '', fehler })
knopf.disabled = true
try {
await ctx.request(`${basis}/preise?${q}`, { method: 'PUT', body })
if (!ctx.aktuell()) return
setzeFehler(form, {})
ctx.zeigeMeldung('Die Preise sind gespeichert.', 'ok')
await vorschau()
} catch (err) {
zeigeApiFehler(form, err)
}
knopf.disabled = false
}
wurzel.replaceChildren(form,
abschnitt('Beispielrechnung', 'So rechnet dein Gast. Sie nutzt die gespeicherten Preise.',
h('div', { class: 'bk-vorschaufelder' }, feld('naechte', 'Nächte', naechteEl), feld('personen', 'Personen', personenEl)), vorschauText))
vorschau()
return wurzel
}
+301
View File
@@ -0,0 +1,301 @@
/* Buchungskalender, Reiter „Anfragen“: Filter (offen | gebucht | alle), Liste, Detail.
API (docs/funktionen/unterkunft-api.md, alle mit ?projectId=):
GET /api/unterkunft/anfragen?filter= => { anfragen:[{ id, nummer, von, bis, naechte?, gast:{name}, status, summen:{bruttoCent}, unterkunftName? }] }
GET /api/unterkunft/anfragen/:id => { anfrage:{ id, nummer, von, bis, personen, status, gast:{name,firma,email,telefon,nachricht?}, posten, summen,
steuerart, zahlart?, bezahltVia?, erstattetCent?, rechnungId?, rechnungNummer?, fristAt?, unterkunftName? }, vorschlag:{ naechte, posten, summen } }
POST …/freigeben {posten,fristTage,nachricht} (=> { buchungsLink? }), …/ablehnen {text}, …/bezahlt, …/stornieren {grund,erstattungCent}.
Fremdtext (Gast, Nachricht) nur ueber textContent. render(panel, ctx) wie shop-tab-*.js. */
import { abschnitt, allgemeinerFehler, centZuEuro, euroZuCent, fehlerKarte, feld, frage, h, zeigeFehler, datumText } from './shop-dom.js'
import { fehlerFuerFormular } from './buchungen-fehler.js'
import { baueEditor, euroMitVorzeichen } from './buchungen-posten.js'
export const STATUS = {
neu: ['Neu', 'wait'],
freigegeben: ['Freigegeben – wartet auf Buchung', 'wait'],
gebucht: ['Gebucht', 'ok'],
bezahlt: ['Bezahlt', 'ok'],
abgelehnt: ['Abgelehnt', 'bad'],
abgelaufen: ['Abgelaufen', 'off'],
storniert: ['Storniert', 'bad'],
}
const FILTER = [['offen', 'Offen'], ['gebucht', 'Gebucht'], ['alle', 'Alle']]
const FEHLER_LADEN = 'Das konnte nicht geladen werden. Bitte versuch es noch einmal.'
const BELEGT = 'Der Zeitraum ist inzwischen belegt.'
const zeichen = (x) => (typeof x === 'string' ? x : '')
const enc = encodeURIComponent
const pille = (status) => {
const [text, art] = STATUS[status] || [zeichen(status) || 'Unbekannt', 'off']
return h('span', { class: `sh-pille sh-pille-${art}`, 'data-status': status }, text)
}
/** '2026-11-12','2026-11-30' ⇒ '12.–30.11.2026'; über Monats-/Jahresgrenze '28.10.–03.11.2026' bzw. '28.12.2026–03.01.2027'. */
export function zeitraumText(von, bis) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/
const a = m.exec(von || '')
const b = m.exec(bis || '')
if (!a || !b) return ''
if (a[1] !== b[1]) return `${a[3]}.${a[2]}.${a[1]}–${b[3]}.${b[2]}.${b[1]}`
if (a[2] !== b[2]) return `${a[3]}.${a[2]}.–${b[3]}.${b[2]}.${b[1]}`
return `${a[3]}.–${b[3]}.${b[2]}.${b[1]}`
}
export function naechteText(von, bis) {
const ms = (t) => Date.UTC(...t.split('-').map((x, i) => (i === 1 ? Number(x) - 1 : Number(x))))
const n = Math.round((ms(bis) - ms(von)) / 86400000)
return Number.isFinite(n) ? `${n} ${n === 1 ? 'Nacht' : 'Nächte'}` : ''
}
const gastName = (gast) => (typeof gast === 'string' ? gast : zeichen(gast?.name))
export async function render(panel, ctx) {
const st = { filter: 'offen', lauf: 0 }
const kopf = h('div', { class: 'bk-filter' }, h('label', { for: 'bk-a-filter' }, 'Anzeigen'),
h('select', { id: 'bk-a-filter', 'data-aktion': 'filter' }, FILTER.map(([wert, text]) => h('option', { value: wert }, text))))
const inhalt = h('div', { class: 'bk-anfragen' })
panel.replaceChildren(kopf, inhalt)
const select = kopf.querySelector('select')
select.value = st.filter
select.addEventListener('change', () => { st.filter = select.value; zeigeListe() })
await zeigeListe()
async function zeigeListe() {
const lauf = ++st.lauf
const gueltig = () => st.lauf === lauf && ctx.aktuell()
inhalt.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let daten
try {
daten = await ctx.request(`/api/unterkunft/anfragen?projectId=${enc(ctx.projectId)}&filter=${enc(st.filter)}`)
} catch (err) {
if (gueltig()) inhalt.replaceChildren(fehlerKarte(err.message || FEHLER_LADEN, zeigeListe))
return
}
if (!gueltig()) return
const zeilen = Array.isArray(daten?.anfragen) ? daten.anfragen : []
if (!zeilen.length) {
inhalt.replaceChildren(h('div', { class: 'kb-panel kb-empty', 'data-leer': true }, st.filter === 'offen' ? 'Keine offenen Anfragen.' : 'Keine Anfragen.'))
return
}
inhalt.replaceChildren(h('ul', { class: 'bk-liste' }, zeilen.map((a) => h('li', null, h('button', {
type: 'button', class: 'bk-zeile', 'data-anfrage': a.id, onclick: () => zeigeDetail(a.id),
},
h('strong', { class: 'bk-nummer' }, zeichen(a.nummer)),
h('span', { class: 'bk-zeitraum' }, `${zeitraumText(a.von, a.bis)} · ${naechteText(a.von, a.bis)}`),
h('span', { class: 'bk-gast' }, gastName(a.gast)),
pille(a.status),
h('span', { class: 'bk-betrag' }, Number.isInteger(a.summen?.bruttoCent) ? euroMitVorzeichen(a.summen.bruttoCent) : ''))))))
}
async function zeigeDetail(id) {
const lauf = ++st.lauf
const gueltig = () => st.lauf === lauf && ctx.aktuell()
inhalt.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let daten
try {
daten = await ctx.request(`/api/unterkunft/anfragen/${enc(id)}?projectId=${enc(ctx.projectId)}`)
} catch (err) {
if (gueltig()) inhalt.replaceChildren(zurueck(), fehlerKarte(err.message || FEHLER_LADEN, () => zeigeDetail(id)))
return
}
if (!gueltig()) return
if (!daten?.anfrage) {
inhalt.replaceChildren(zurueck(), fehlerKarte(FEHLER_LADEN, () => zeigeDetail(id)))
return
}
inhalt.replaceChildren(zurueck(), baueDetail(daten.anfrage, daten.vorschlag))
}
const zurueck = () => h('button', { type: 'button', class: 'kb-btn-outline bk-zurueck', 'data-aktion': 'zurueck', onclick: () => zeigeListe() }, '‹ Zur Liste')
function baueDetail(a, vorschlag) {
const gast = a.gast && typeof a.gast === 'object' ? a.gast : { name: zeichen(a.gast) }
const kopfZeile = h('div', { class: 'bk-detail-kopf' },
h('h3', null, `Anfrage ${zeichen(a.nummer)}`), pille(a.status))
const daten = h('dl', { class: 'bk-daten' },
...[
['Unterkunft', zeichen(a.unterkunftName)],
['Zeitraum', `${zeitraumText(a.von, a.bis)} · ${naechteText(a.von, a.bis)}`],
['Personen', Number.isInteger(a.personen) ? String(a.personen) : ''],
['Gast', gastName(gast)],
['Firma', zeichen(gast.firma)],
['E-Mail', zeichen(gast.email)],
['Telefon', zeichen(gast.telefon)],
['Nachricht', zeichen(gast.nachricht)],
['Frist', a.fristAt && a.status === 'freigegeben' ? datumText(a.fristAt) : ''],
].filter(([, w]) => w).flatMap(([n, w]) => [h('dt', null, n), h('dd', null, w)]))
const karte = h('section', { class: 'kb-panel sh-abschnitt bk-detail', 'data-detail': a.id }, kopfZeile, daten)
if (a.status === 'neu') karte.append(neuBereich(a, vorschlag))
else karte.append(festerBereich(a))
return karte
}
/* ---------------------------------------------- Status neu: Freigeben / Ablehnen --- */
function neuBereich(a, vorschlag) {
const steuerart = a.steuerart || vorschlag?.steuerart || ctx.status?.steuerart || 'regelbesteuert'
const startPosten = Array.isArray(vorschlag?.posten) ? vorschlag.posten : []
const editor = baueEditor(startPosten, steuerart)
const frist = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-a-frist' })
frist.value = '7'
const nachricht = h('textarea', { id: 'bk-a-nachricht', rows: '3', maxlength: '2000' })
const form = h('form', { class: 'bk-freigabe', novalidate: true, onsubmit: (ev) => ev.preventDefault() },
h('h4', null, 'Angebot für den Gast'),
editor.el,
h('p', { class: 'sh-fehler', 'data-fehler': 'posten', role: 'alert' }),
feld('fristTage', 'Frist zum Buchen (Tage)', frist, 'So lange ist der Zeitraum für den Gast reserviert.'),
feld('nachricht', 'Nachricht an den Gast (optional)', nachricht),
allgemeinerFehler())
const freigeben = h('button', {
type: 'button', class: 'kb-btn', 'data-aktion': 'freigeben',
onclick: () => sendeFreigabe(a, editor, frist, nachricht, form, freigeben),
}, 'Freigeben')
const ablehnen = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'ablehnen', onclick: () => ablehnDialog(a, ablehnen) }, 'Ablehnen')
return h('div', null, form, h('div', { class: 'bk-aktionen' }, freigeben, ablehnen), h('div', { class: 'bk-dialog' }))
}
async function sendeFreigabe(a, editor, frist, nachricht, form, knopf) {
const posten = editor.posten()
const tage = Number(String(frist.value).trim())
if (!posten) return zeigeFehler(form, { message: '', fehler: { posten: 'Bitte prüfe Menge und Preis der Posten.' } })
if (!Number.isInteger(tage) || tage < 1) return zeigeFehler(form, { message: '', fehler: { fristTage: 'Bitte gib die Frist in ganzen Tagen an.' } })
knopf.disabled = true
try {
const antwort = await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/freigeben?projectId=${enc(ctx.projectId)}`, {
method: 'POST', body: { posten, fristTage: tage, nachricht: String(nachricht.value).trim() },
})
if (!ctx.aktuell()) return
ctx.zeigeMeldung('Freigegeben. Der Buchungslink wurde an deinen Gast geschickt – falls die Mail nicht ankommt, kannst du ihn hier kopieren.', 'ok')
zeigeLink(antwort?.buchungsLink, a)
} catch (err) {
knopf.disabled = false
if (err.status === 409) zeigeFehler(form, { message: BELEGT, fehler: { _: BELEGT } })
else zeigeFehler(form, fehlerFuerFormular(err))
}
}
/** Nach „Freigeben“: der Buchungslink zum Kopieren, falls die Mail nicht ankommt (nur wenn die Antwort ihn enthaelt). */
function zeigeLink(link, a) {
const box = h('section', { class: 'kb-panel sh-abschnitt bk-link', 'data-freigegeben': a.id },
h('h3', null, `Anfrage ${zeichen(a.nummer)} ist freigegeben`),
h('p', null, 'Der Link wurde per Mail verschickt. Falls er nicht ankommt, kopiere ihn hier und schick ihn selbst.'))
if (typeof link === 'string' && link) {
const meldung = h('p', { class: 'sh-hinweis', role: 'status' })
box.append(
h('input', { type: 'text', readonly: true, value: link, 'aria-label': 'Buchungslink', 'data-link': true }),
h('button', {
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'link-kopieren',
onclick: async () => {
try {
await navigator.clipboard.writeText(link)
meldung.textContent = 'Link kopiert.'
} catch {
meldung.textContent = 'Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.'
}
},
}, 'Link kopieren'),
meldung)
}
box.append(h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', onclick: () => zeigeListe() }, 'Zur Liste'))
inhalt.replaceChildren(box)
}
function ablehnDialog(a, ausloeser) {
const dialogEl = inhalt.querySelector('.bk-dialog')
const text = h('textarea', { id: 'bk-a-ablehn', rows: '3', maxlength: '1000' })
const box = frage('Anfrage ablehnen', h('p', { class: 'sh-hinweis' }, 'Der Gast bekommt deinen Text per Mail.'), feld('text', 'Nachricht an den Gast', text), allgemeinerFehler(),
h('div', { class: 'bk-aktionen' },
h('button', {
type: 'button', class: 'kb-btn', 'data-aktion': 'ablehnen-senden',
onclick: async (ev) => {
ev.currentTarget.disabled = true
try {
await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/ablehnen?projectId=${enc(ctx.projectId)}`, { method: 'POST', body: { text: String(text.value).trim() } })
if (!ctx.aktuell()) return
ctx.zeigeMeldung('Anfrage abgelehnt.', 'ok')
await zeigeListe()
} catch (err) {
ev.currentTarget.disabled = false
zeigeFehler(box, fehlerFuerFormular(err))
}
},
}, 'Ablehnen'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => { dialogEl.replaceChildren(); ausloeser.focus() } }, 'Abbrechen')))
dialogEl.replaceChildren(box)
text.focus()
}
/* ---------------------------------------------- weitere Status: Posten, Rechnung, Bezahlt, Storno --- */
function festerBereich(a) {
const posten = Array.isArray(a.posten) ? a.posten : []
const teile = []
if (posten.length) {
teile.push(h('table', { class: 'bk-tabelle' }, h('tbody', null, posten.map((p) => h('tr', null,
h('td', null, zeichen(p.text)), h('td', null, `${p.menge} ×`), h('td', null, euroMitVorzeichen(p.einzelCent)))))))
}
if (Number.isInteger(a.summen?.bruttoCent)) teile.push(h('p', { class: 'bk-gesamt' }, h('strong', null, `Gesamt ${euroMitVorzeichen(a.summen.bruttoCent)}`)))
const aktionen = h('div', { class: 'bk-aktionen' })
const dialogEl = h('div', { class: 'bk-dialog' })
const aktiv = a.status === 'gebucht' || a.status === 'bezahlt'
if (aktiv && a.rechnungId) {
teile.push(h('p', null, h('a', {
href: `/api/unterkunft/rechnungen/${enc(a.rechnungId)}.pdf?projectId=${enc(ctx.projectId)}`, target: '_blank', rel: 'noopener', 'data-aktion': 'rechnung',
}, `Rechnung ${zeichen(a.rechnungNummer)}`.trim() + ' (PDF)')))
}
if (a.status === 'gebucht') {
aktionen.append(h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'bezahlt', onclick: (ev) => markiereBezahlt(a, ev.currentTarget) }, 'Als bezahlt (vor Ort) markieren'))
}
if (aktiv) {
const stornoKnopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'stornieren', onclick: () => stornoDialog(a, dialogEl, stornoKnopf) }, 'Stornieren')
aktionen.append(stornoKnopf)
}
return h('div', null, ...teile, aktionen.childNodes.length ? aktionen : null, dialogEl)
}
async function markiereBezahlt(a, knopf) {
knopf.disabled = true
try {
await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/bezahlt?projectId=${enc(ctx.projectId)}`, { method: 'POST' })
if (!ctx.aktuell()) return
ctx.zeigeMeldung('Als bezahlt markiert.', 'ok')
await zeigeDetail(a.id)
} catch (err) {
knopf.disabled = false
ctx.zeigeMeldung(err.message, 'fehler')
}
}
function stornoDialog(a, dialogEl, ausloeser) {
const online = a.bezahltVia === 'stripe'
const grund = h('textarea', { id: 'bk-a-grund', rows: '3', maxlength: '500' })
const betrag = h('input', { type: 'text', inputmode: 'decimal', id: 'bk-a-erstattung', autocomplete: 'off' })
betrag.value = Number.isInteger(a.summen?.bruttoCent) ? centZuEuro(a.summen.bruttoCent - (a.erstattetCent || 0)) : ''
const box = frage('Buchung stornieren', h('p', { class: 'sh-hinweis' }, 'Der Gast bekommt eine Stornomail und eine Storno-Rechnung.'),
feld('grund', 'Grund', grund),
online ? feld('erstattungCent', 'Erstattung in Euro', betrag, 'Der Betrag geht an die Karte des Gastes zurück. 0 = keine Erstattung.') : null,
allgemeinerFehler(),
h('div', { class: 'bk-aktionen' },
h('button', {
type: 'button', class: 'kb-btn', 'data-aktion': 'storno-senden',
onclick: async (ev) => {
const knopf = ev.currentTarget
let erstattungCent = 0
if (online) {
erstattungCent = String(betrag.value).trim() === '' ? 0 : euroZuCent(String(betrag.value))
if (erstattungCent === null) return zeigeFehler(box, { message: '', fehler: { erstattungCent: 'Bitte gib einen Betrag wie 120,00 an.' } })
}
knopf.disabled = true
try {
await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/stornieren?projectId=${enc(ctx.projectId)}`, { method: 'POST', body: { grund: String(grund.value).trim(), erstattungCent } })
if (!ctx.aktuell()) return
ctx.zeigeMeldung('Die Buchung ist storniert.', 'ok')
await zeigeDetail(a.id)
} catch (err) {
knopf.disabled = false
if (err.fehler === 'erstattung_fehlgeschlagen' || err.body?.fehler === 'erstattung_fehlgeschlagen') {
zeigeFehler(box, { message: 'Die Buchung ist storniert, aber die Erstattung ist fehlgeschlagen. Bitte versuch die Erstattung gleich noch einmal.', fehler: {} })
} else zeigeFehler(box, fehlerFuerFormular(err))
}
},
}, 'Stornieren'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => { dialogEl.replaceChildren(); ausloeser.focus() } }, 'Abbrechen')))
dialogEl.replaceChildren(box)
grund.focus()
}
}
+189
View File
@@ -0,0 +1,189 @@
/* Buchungskalender, Reiter „Einstellungen“: Firmendaten, Steuernummer/USt-IdNr., Rechnungspraefix, Auftrag zur Rechnungsstellung,
Bankverbindung, Akzentfarbe, Stripe-Konto und Online-Zahlung.
API (docs/funktionen/unterkunft-api.md, ?projectId=): GET /einstellungen => { einstellungen:{ firma, inhaber, strasse, plz, ort, email, telefon,
steuernummer, ustId, iban, bic, bank, rechnungsPraefix, akzentfarbe, onlineZahlung, rechnungAuftrag },
stripe:{ status:'keins'|'onboarding'|'aktiv', vorhandenesShopKonto:boolean }, rechnungenVorhanden:boolean };
PUT /einstellungen mit denselben Feldern (ohne Wrapper; rechnungAuftrag:true setzt rechnungAuftragAt, false widerruft);
POST /stripe/verbinden => { url } (Onboarding bei Stripe), POST /stripe/vorhandenes-konto und /stripe/aktualisieren => { stripe }.
Geprueft wird auf dem Server; Feldfehler stehen am Feld. Text nur ueber textContent (h). render(panel, ctx) wie shop-tab-*.js. */
import { abschnitt, allgemeinerFehler, feld, frage, h, hakenFeld, setzeFehler } from './shop-dom.js'
import { enc, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
export const AUFTRAG_TEXT = 'Ich beauftrage WEBklar, in meinem Namen und auf meine Rechnung Rechnungen an meine Gäste auszustellen (§ 14 Abs. 2 Satz 4 UStG).'
export const AUFTRAG_WIDERRUF = 'Ohne diese Zustimmung können keine Buchungen bestätigt werden. Zustimmung wirklich zurückziehen?'
export const AUFTRAG_FEHLT = 'Ohne diesen Auftrag kannst du Buchungen nicht bestätigen, weil wir keine Rechnungen für dich ausstellen dürfen.'
const STRIPE_FEHLER = 'Stripe hat keine gültige Adresse geliefert. Bitte versuch es gleich noch einmal.'
const PFLICHT = { firma: 'Bitte gib den Namen deiner Firma oder Unterkunft an.', inhaber: 'Bitte gib den Namen des Inhabers an.', strasse: 'Bitte gib Straße und Hausnummer an.', plz: 'Bitte gib die Postleitzahl an.', ort: 'Bitte gib den Ort an.', email: 'Bitte gib eine E-Mail-Adresse an.' }
const textfeld = (optionen = {}) => h('input', { type: 'text', autocomplete: 'off', ...optionen })
const istHttps = (url) => { try { return new URL(url).protocol === 'https:' } catch { return false } }
export async function render(panel, ctx = {}) {
const projectId = ctx.projectId
const q = `projectId=${enc(projectId)}`
const nurLesen = ctx.nurLesen === true
const antwort = await ctx.request(`/api/unterkunft/einstellungen?${q}`)
const e = antwort?.einstellungen && typeof antwort.einstellungen === 'object' ? antwort.einstellungen : {}
const stripe = antwort?.stripe || ctx.status?.stripe || { status: 'keins' }
const rechnungenVorhanden = (antwort?.rechnungenVorhanden ?? ctx.status?.rechnungenVorhanden) === true
const melde = (text, art) => { if (typeof ctx.zeigeMeldung === 'function') ctx.zeigeMeldung(text, art) }
const neu = () => render(panel, ctx).catch((err) => panel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err.message)))
/* ----------------------------------------------------------------- Felder --- */
const firma = textfeld({ maxlength: 120, autocomplete: 'organization' }); firma.value = zeichen(e.firma)
const inhaber = textfeld({ maxlength: 120 }); inhaber.value = zeichen(e.inhaber)
const strasse = textfeld({ maxlength: 120, autocomplete: 'street-address' }); strasse.value = zeichen(e.strasse)
const plz = textfeld({ maxlength: 10, inputmode: 'numeric', autocomplete: 'postal-code' }); plz.value = zeichen(e.plz)
const ort = textfeld({ maxlength: 80, autocomplete: 'address-level2' }); ort.value = zeichen(e.ort)
const email = h('input', { type: 'email', maxlength: 254, autocomplete: 'email' }); email.value = zeichen(e.email)
const telefon = h('input', { type: 'tel', maxlength: 40, autocomplete: 'tel' }); telefon.value = zeichen(e.telefon)
const steuernummer = textfeld({ maxlength: 30 }); steuernummer.value = zeichen(e.steuernummer)
const ustId = textfeld({ maxlength: 20, placeholder: 'DE123456789' }); ustId.value = zeichen(e.ustId)
const praefix = textfeld({ maxlength: 8, placeholder: 'RE' }); praefix.value = zeichen(e.rechnungsPraefix) || 'RE'
if (rechnungenVorhanden) { praefix.disabled = true; praefix.setAttribute('aria-disabled', 'true') }
const auftrag = h('input', { type: 'checkbox' }); auftrag.checked = e.rechnungAuftrag === true
const iban = textfeld({ maxlength: 34 }); iban.value = zeichen(e.iban)
const bic = textfeld({ maxlength: 11 }); bic.value = zeichen(e.bic)
const bank = textfeld({ maxlength: 80 }); bank.value = zeichen(e.bank)
const akzent = h('input', { type: 'color' }); akzent.value = /^#[0-9a-fA-F]{6}$/.test(zeichen(e.akzentfarbe)) ? e.akzentfarbe : '#111111'
const online = h('input', { type: 'checkbox' }); online.checked = e.onlineZahlung === true && stripe.status === 'aktiv'
const auftragHinweis = h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-hinweis': 'auftrag' }, AUFTRAG_FEHLT)
const zeigeAuftrag = () => { auftragHinweis.hidden = auftrag.checked }
// Widerruf einer gespeicherten Zustimmung nur nach Rueckfrage in der Seite
const widerrufPlatz = h('div', { 'data-widerruf-platz': '' })
auftrag.addEventListener('change', () => {
if (!auftrag.checked && e.rechnungAuftrag === true && !widerrufPlatz.firstChild) {
auftrag.checked = true
const zu = () => { widerrufPlatz.replaceChildren(); auftrag.focus() }
const box = frage('Zustimmung zurückziehen', h('p', null, AUFTRAG_WIDERRUF),
h('div', { class: 'sh-aktionen' },
h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'widerruf-ja', onclick: () => { auftrag.checked = false; zeigeAuftrag(); zu() } }, 'Ja, zurückziehen'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'widerruf-nein', onclick: zu }, 'Abbrechen')))
widerrufPlatz.replaceChildren(box)
box.focus()
}
zeigeAuftrag()
})
zeigeAuftrag()
/* ------------------------------------------------------------------ Stripe --- */
let beschaeftigt = false
async function stripeAktion(pfad, { weiter = false } = {}) {
if (beschaeftigt || nurLesen) return
beschaeftigt = true
for (const k of stripeKarte.querySelectorAll('button')) k.setAttribute('disabled', '')
try {
const a = await ctx.request(`/api/unterkunft/stripe/${pfad}?${q}`, { method: 'POST' })
if (weiter) {
if (!istHttps(a?.url)) throw new Error(STRIPE_FEHLER)
melde('Du wirst zu Stripe weitergeleitet …', 'ok')
window.location.href = a.url
return
}
if (typeof ctx.neuLaden === 'function') { try { await ctx.neuLaden() } catch { /* der Stand erscheint beim naechsten Laden */ } }
await neu()
} catch (err) {
melde(err?.message || 'Das hat nicht geklappt. Bitte versuch es noch einmal.', 'fehler')
for (const k of stripeKarte.querySelectorAll('button')) k.removeAttribute('disabled')
} finally {
beschaeftigt = false
}
}
const knopf = (text, aktion, pfad, optionen, art = 'kb-btn-outline') => h('button', { type: 'button', class: art, 'data-aktion': aktion, onclick: () => stripeAktion(pfad, optionen) }, text)
const zustandText = { keins: 'Noch nicht verbunden', onboarding: 'Angaben bei Stripe noch offen', aktiv: 'Verbunden und aktiv' }
const status = ['keins', 'onboarding', 'aktiv'].includes(stripe.status) ? stripe.status : 'keins'
const stripeKnoepfe = nurLesen ? [] : status === 'keins'
? [knopf('Stripe-Konto verbinden', 'stripe-verbinden', 'verbinden', { weiter: true }, 'kb-btn-primary'),
stripe.vorhandenesShopKonto === true ? knopf('Vorhandenes Webshop-Konto verwenden', 'stripe-vorhanden', 'vorhandenes-konto') : null]
: status === 'onboarding'
? [knopf('Angaben bei Stripe ergänzen', 'stripe-verbinden', 'verbinden', { weiter: true }, 'kb-btn-primary'), knopf('Status aktualisieren', 'stripe-aktualisieren', 'aktualisieren')]
: [knopf('Status aktualisieren', 'stripe-aktualisieren', 'aktualisieren')]
const stripeKarte = abschnitt('Stripe-Konto für Online-Zahlung', 'Gäste können die Buchung online bezahlen. Das Geld geht direkt auf dein Stripe-Konto.',
h('p', { 'data-stripe-status': status, class: status === 'aktiv' ? 'sh-ok' : 'sh-offen' }, zustandText[status]),
h('div', { class: 'sh-aktionen' }, ...stripeKnoepfe))
/* ----------------------------------------------------------------- Formular --- */
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'einstellungen-speichern' }, 'Einstellungen speichern')
const form = h('form', { class: 'sh-form sh-einstellungen', novalidate: true, 'aria-label': 'Einstellungen des Buchungskalenders' },
abschnitt('Deine Firmendaten', 'Diese Angaben stehen auf der Buchungsseite und auf den Rechnungen an deine Gäste. Pflichtfelder sind mit * markiert.',
feld('firma', 'Firma oder Name der Unterkunft *', firma),
feld('inhaber', 'Inhaber *', inhaber),
feld('strasse', 'Straße und Hausnummer *', strasse),
feld('plz', 'Postleitzahl *', plz),
feld('ort', 'Ort *', ort),
feld('email', 'E-Mail-Adresse *', email, 'An diese Adresse gehen neue Anfragen.'),
feld('telefon', 'Telefon (freiwillig)', telefon)),
abschnitt('Steuer', 'Eine der beiden Angaben ist Pflicht (§ 14 Abs. 4 UStG).',
feld('steuernummer', 'Steuernummer', steuernummer),
feld('ustId', 'USt-IdNr.', ustId, 'Zum Beispiel DE123456789.')),
abschnitt('Rechnungen', null,
feld('rechnungsPraefix', 'Rechnungspräfix', praefix, rechnungenVorhanden
? 'Der Präfix ist gesperrt, weil es schon Rechnungen gibt. Die Nummern bleiben so lückenlos.'
: '2 bis 8 Zeichen (A–Z, 0–9). Die Nummern lauten dann zum Beispiel RE-2026-0001. Du kannst den Präfix nur vor der ersten Rechnung ändern.'),
hakenFeld('rechnungAuftrag', auftrag, AUFTRAG_TEXT),
auftragHinweis,
widerrufPlatz),
abschnitt('Bankverbindung (freiwillig)', 'Erscheint als Hinweis, wenn Gäste vor Ort oder per Überweisung zahlen.',
feld('iban', 'IBAN', iban),
feld('bic', 'BIC', bic),
feld('bank', 'Name der Bank', bank)),
abschnitt('Darstellung', null, feld('akzentfarbe', 'Akzentfarbe', akzent, 'Diese Farbe bekommen Knöpfe auf deiner Buchungsseite.')),
status === 'aktiv'
? abschnitt('Online-Zahlung', null, hakenFeld('onlineZahlung', online, 'Online-Zahlung anbieten', 'Gäste können bei der Buchung per Stripe bezahlen.'))
: null,
allgemeinerFehler(),
nurLesen ? null : h('div', { class: 'sh-aktionen' }, speichern))
/* --------------------------------------------------------------- Speichern --- */
function sammle() {
const fehler = {}
const felder = { firma, inhaber, strasse, plz, ort, email }
for (const [name, el] of Object.entries(felder)) if (!el.value.trim()) fehler[name] = PFLICHT[name]
if (!steuernummer.value.trim() && !ustId.value.trim()) {
fehler.steuernummer = 'Bitte gib Steuernummer oder USt-IdNr. an.'
fehler.ustId = 'Bitte gib Steuernummer oder USt-IdNr. an.'
}
const p = praefix.value.trim().toUpperCase()
if (!rechnungenVorhanden && !/^[A-Z0-9]{2,8}$/.test(p)) fehler.rechnungsPraefix = 'Der Präfix braucht 2 bis 8 Zeichen aus A–Z und 0–9.'
if (Object.keys(fehler).length) return { fehler }
return {
einstellungen: {
firma: firma.value.trim(), inhaber: inhaber.value.trim(), strasse: strasse.value.trim(), plz: plz.value.trim(), ort: ort.value.trim(),
email: email.value.trim(), telefon: telefon.value.trim(), steuernummer: steuernummer.value.trim(), ustId: ustId.value.trim(),
iban: iban.value.trim(), bic: bic.value.trim(), bank: bank.value.trim(),
rechnungsPraefix: rechnungenVorhanden ? zeichen(e.rechnungsPraefix) || 'RE' : p,
rechnungAuftrag: auftrag.checked, akzentfarbe: akzent.value,
onlineZahlung: status === 'aktiv' && online.checked,
},
}
}
let laeuft = false
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
if (nurLesen || laeuft) return
laeuft = true
speichern.disabled = true
setzeFehler(form, {})
try {
const eingabe = sammle()
if (eingabe.fehler) {
setzeFehler(form, eingabe.fehler)
form.querySelector('[aria-invalid]')?.focus()
return
}
await ctx.request(`/api/unterkunft/einstellungen?${q}`, { method: 'PUT', body: eingabe.einstellungen })
melde('Die Einstellungen sind gespeichert.', 'ok')
if (typeof ctx.neuLaden === 'function') { try { await ctx.neuLaden() } catch { /* gespeichert ist gespeichert */ } }
} catch (err) {
zeigeApiFehler(form, err)
} finally {
laeuft = false
speichern.disabled = false
}
})
if (nurLesen) for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true
panel.replaceChildren(stripeKarte, form)
}
+189
View File
@@ -0,0 +1,189 @@
/* Buchungskalender, Reiter „Kalender“: Monatsansicht je Unterkunft, Farben nach Quelle, zwei Klicks auf freie Nächte sperren den Zeitraum.
API (alle mit ?projectId=): GET /api/unterkunft/unterkuenfte => { unterkuenfte:[{ id, name, … }] }
GET /unterkuenfte/:id/kalender?von&bis => { bereiche:[{ von, bis (Abreisetag, exklusiv), quelle, anfrageNummer?, sperreId?, notiz? }] }
quelle: 'gebucht' | 'reserviert' | 'ical' (auch 'ical:<id>') | 'manuell'.
POST /unterkuenfte/:id/sperren { von, bis, notiz } · DELETE /sperren/:id. Wochen beginnen am Montag. Fremdtext nur ueber textContent. */
import { abschnitt, allgemeinerFehler, fehlerKarte, feld, frage, h } from './shop-dom.js'
import { enc, heuteBerlin, ladeUnterkuenfte, msTag, plusTage, tagText, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
export const QUELLEN = [
['gebucht', 'Direkt gebucht', 'ist-gebucht'],
['reserviert', 'Reserviert (wartet auf Buchung)', 'ist-reserviert'],
['ical', 'Booking.com / Airbnb (iCal)', 'ist-ical'],
['manuell', 'Von dir gesperrt', 'ist-manuell'],
]
const MONATE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
const WOCHENTAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const quelleArt = (q) => { const s = zeichen(q); return s.startsWith('ical') ? 'ical' : s.startsWith('anfrage') ? 'gebucht' : s }
/** Alle Tage des Monatsrasters (Montag bis Sonntag, volle Wochen) als 'YYYY-MM-DD'. */
export function rasterTage(jahr, monat) {
const erster = Date.UTC(jahr, monat, 1)
const letzter = Date.UTC(jahr, monat + 1, 0)
const start = erster - ((new Date(erster).getUTCDay() + 6) % 7) * 86400000
const ende = letzter + (6 - ((new Date(letzter).getUTCDay() + 6) % 7)) * 86400000
const tage = []
for (let ms = start; ms <= ende; ms += 86400000) tage.push(msTag(ms))
return tage
}
export async function render(panel, ctx) {
const heute = heuteBerlin()
const st = { unterkunft: null, jahr: Number(heute.slice(0, 4)), monat: Number(heute.slice(5, 7)) - 1, bereiche: [], start: null, lauf: 0 }
panel.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let liste
try {
liste = (await ladeUnterkuenfte(ctx)).filter((u) => u.aktiv !== false)
} catch (err) {
if (ctx.aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => render(panel, ctx)))
return
}
if (!ctx.aktuell()) return
if (!liste.length) {
panel.replaceChildren(h('div', { class: 'kb-panel kb-empty', 'data-leer': true }, 'Noch keine Unterkunft. Lege sie im Reiter „Unterkünfte & Preise“ an.'))
return
}
st.unterkunft = liste[0].id
const wahl = h('select', { id: 'bk-k-unterkunft', 'data-aktion': 'unterkunft' }, liste.map((u) => h('option', { value: u.id }, zeichen(u.name))))
wahl.addEventListener('change', () => { st.unterkunft = wahl.value; st.start = null; lade() })
const titel = h('strong', { class: 'bk-monat', 'aria-live': 'polite', 'data-monat': true })
const schritt = (n) => () => { const d = new Date(Date.UTC(st.jahr, st.monat + n, 1)); st.jahr = d.getUTCFullYear(); st.monat = d.getUTCMonth(); st.start = null; lade() }
const kopf = h('div', { class: 'bk-filter bk-kalenderkopf' },
liste.length > 1 ? h('label', { for: 'bk-k-unterkunft' }, 'Unterkunft') : null, liste.length > 1 ? wahl : h('strong', null, zeichen(liste[0].name)),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', 'aria-label': 'Vorheriger Monat', onclick: schritt(-1) }, '‹'),
titel,
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'weiter', 'aria-label': 'Nächster Monat', onclick: schritt(1) }, '›'))
const raster = h('div', { class: 'bk-raster', role: 'grid' })
const hinweis = h('p', { class: 'sh-hinweis', 'data-hinweis': true, role: 'status' })
const dialog = h('div', { class: 'bk-dialog' })
const legende = h('ul', { class: 'bk-legende', 'aria-label': 'Legende' }, QUELLEN.map(([, text, klasse]) => h('li', null, h('span', { class: `bk-farbe ${klasse}`, 'aria-hidden': 'true' }), text)))
panel.replaceChildren(abschnitt('Kalender', ctx.nurLesen ? 'Dein Kalender ist hier nur zum Ansehen.' : 'Klicke zwei freie Tage an, um den Zeitraum zu sperren, zum Beispiel für eigene Aufenthalte. Gesperrte Tage kannst du mit einem Klick wieder freigeben.',
kopf, raster, hinweis, legende, dialog))
await lade()
function bereichAm(tag) {
for (const art of ['gebucht', 'reserviert', 'ical', 'manuell']) {
const b = st.bereiche.find((x) => quelleArt(x.quelle) === art && x.von <= tag && tag < x.bis)
if (b) return b
}
return null
}
async function lade() {
const lauf = ++st.lauf
const tage = rasterTage(st.jahr, st.monat)
titel.textContent = `${MONATE[st.monat]} ${st.jahr}`
dialog.replaceChildren()
hinweis.textContent = ''
try {
const daten = await ctx.request(`/api/unterkunft/unterkuenfte/${enc(st.unterkunft)}/kalender?projectId=${enc(ctx.projectId)}&von=${tage[0]}&bis=${plusTage(tage.at(-1), 1)}`)
if (st.lauf !== lauf || !ctx.aktuell()) return
st.bereiche = Array.isArray(daten?.bereiche) ? daten.bereiche : []
} catch (err) {
if (st.lauf === lauf && ctx.aktuell()) { st.bereiche = []; zeichne(); hinweis.textContent = err.message }
return
}
zeichne()
}
function zeichne() {
const tage = rasterTage(st.jahr, st.monat)
const heute = heuteBerlin()
raster.replaceChildren(
...WOCHENTAGE.map((w) => h('div', { class: 'bk-wochentag', role: 'columnheader' }, w)),
...tage.map((tag) => {
const b = bereichAm(tag)
const art = b ? quelleArt(b.quelle) : null
const klassen = ['bk-tag', art ? `ist-${art}` : 'ist-frei', Number(tag.slice(5, 7)) - 1 === st.monat ? '' : 'bk-ausserhalb', tag === heute ? 'bk-heute' : '', st.start === tag ? 'bk-start' : '']
const label = `${tagText(tag)}, ${b ? (QUELLEN.find((q) => q[0] === art)?.[1] || 'belegt') : 'frei'}${b?.anfrageNummer ? `, Anfrage ${zeichen(b.anfrageNummer)}` : ''}`
return h('button', {
type: 'button', class: klassen.filter(Boolean).join(' '), 'data-tag': tag, 'aria-label': label, 'aria-pressed': st.start === tag ? 'true' : 'false',
onclick: () => klickTag(tag, b),
}, String(Number(tag.slice(8))))
}))
}
function klickTag(tag, b) {
dialog.replaceChildren()
hinweis.textContent = ''
if (b) {
st.start = null
zeichne()
if (quelleArt(b.quelle) === 'manuell' && b.sperreId) return aufhebenDialog(b)
hinweis.textContent = `${tagText(tag)}: ${QUELLEN.find((q) => q[0] === quelleArt(b.quelle))?.[1] || 'belegt'}${b.anfrageNummer ? ` (Anfrage ${zeichen(b.anfrageNummer)})` : ''}. Das kannst du hier nicht ändern.`
return
}
if (ctx.nurLesen) return
if (tag < heuteBerlin()) {
hinweis.textContent = 'Vergangene Tage kannst du nicht sperren.'
return
}
if (!st.start) {
st.start = tag
zeichne()
hinweis.textContent = `Erster Tag: ${tagText(tag)}. Klicke jetzt den letzten Tag an.`
return
}
const [von, letzter] = st.start <= tag ? [st.start, tag] : [tag, st.start]
const bis = plusTage(letzter, 1)
const belegt = st.bereiche.some((x) => x.von < bis && von < x.bis)
if (belegt) {
st.start = null
zeichne()
hinweis.textContent = 'In diesem Zeitraum liegen schon belegte Tage. Wähle einen freien Zeitraum.'
return
}
sperrDialog(von, bis)
}
function sperrDialog(von, bis) {
const notiz = h('input', { type: 'text', id: 'bk-k-notiz', maxlength: '200', autocomplete: 'off' })
const box = frage('Zeitraum sperren', h('p', null, `${tagText(von)} bis ${tagText(plusTage(bis, -1))} (Abreise ${tagText(bis)}) sperren?`),
feld('notiz', 'Notiz (nur für dich)', notiz), allgemeinerFehler(),
h('div', { class: 'bk-aktionen' },
h('button', {
type: 'button', class: 'kb-btn', 'data-aktion': 'sperren-senden',
onclick: async (ev) => {
const knopf = ev.currentTarget
knopf.disabled = true
try {
await ctx.request(`/api/unterkunft/unterkuenfte/${enc(st.unterkunft)}/sperren?projectId=${enc(ctx.projectId)}`, { method: 'POST', body: { von, bis, notiz: String(notiz.value).trim() } })
if (!ctx.aktuell()) return
st.start = null
ctx.zeigeMeldung('Der Zeitraum ist gesperrt.', 'ok')
await lade()
} catch (err) {
knopf.disabled = false
zeigeApiFehler(box, err)
}
},
}, 'Zeitraum sperren'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => { st.start = null; dialog.replaceChildren(); zeichne() } }, 'Abbrechen')))
dialog.replaceChildren(box)
notiz.focus()
}
function aufhebenDialog(b) {
const box = frage('Sperre aufheben', h('p', null, `${tagText(b.von)} bis ${tagText(plusTage(b.bis, -1))}${b.notiz ? `: ${zeichen(b.notiz)}` : ''}`), allgemeinerFehler(),
h('div', { class: 'bk-aktionen' },
ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn', 'data-aktion': 'aufheben',
onclick: async (ev) => {
const knopf = ev.currentTarget
knopf.disabled = true
try {
await ctx.request(`/api/unterkunft/sperren/${enc(b.sperreId)}?projectId=${enc(ctx.projectId)}`, { method: 'DELETE' })
if (!ctx.aktuell()) return
ctx.zeigeMeldung('Die Sperre ist aufgehoben.', 'ok')
await lade()
} catch (err) {
knopf.disabled = false
zeigeApiFehler(box, err)
}
},
}, 'Sperre aufheben'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => dialog.replaceChildren() }, 'Schließen')))
dialog.replaceChildren(box)
}
}
+33
View File
@@ -0,0 +1,33 @@
/* Buchungskalender, Reiter „Rechnungen“: Liste der Rechnungen und Storno-Rechnungen mit PDF-Link.
API: GET /api/unterkunft/rechnungen?projectId= => { rechnungen:[{ id, nummer, datum, art:'rechnung'|'storno', gastName, bruttoCent, anfrageNummer? }] };
PDF: GET /api/unterkunft/rechnungen/<id>.pdf?projectId= (Einzel-PDF, kein ZIP). Fremdtext nur ueber textContent. render(panel, ctx). */
import { abschnitt, datumText, euroText, h, infoKarte } from './shop-dom.js'
import { enc, zeichen } from './buchungen-uk-hilfe.js'
const ART = { rechnung: 'Rechnung', storno: 'Storno-Rechnung' }
const betragText = (cent) => {
if (!Number.isInteger(cent)) return ''
return cent < 0 ? `−${euroText(-cent)}` : euroText(cent)
}
export async function render(panel, ctx = {}) {
const q = `projectId=${enc(ctx.projectId)}`
const antwort = await ctx.request(`/api/unterkunft/rechnungen?${q}`)
const liste = Array.isArray(antwort?.rechnungen) ? antwort.rechnungen : []
if (!liste.length) {
panel.replaceChildren(infoKarte('Rechnungen', 'Noch keine Rechnungen. Sobald ein Gast bucht, erscheint hier die Rechnung.'))
return
}
const zeilen = liste.map((r) => h('li', { class: 'bk-rechnung', 'data-rechnung': zeichen(r.id), 'data-art': zeichen(r.art) },
h('span', { class: 'bk-nummer' }, zeichen(r.nummer)),
h('span', { class: 'bk-zeitraum' }, datumText(zeichen(r.datum), { zeit: false })),
h('span', { class: 'bk-gast' }, zeichen(r.gastName)),
h('span', { class: `sh-pille ${r.art === 'storno' ? 'sh-pille-bad' : 'sh-pille-ok'}` }, ART[r.art] || 'Rechnung'),
h('span', { class: 'bk-betrag' }, betragText(r.bruttoCent)),
h('a', {
class: 'kb-btn-outline', 'data-aktion': 'pdf', href: `/api/unterkunft/rechnungen/${enc(zeichen(r.id))}.pdf?${q}`, target: '_blank', rel: 'noopener',
'aria-label': `PDF zu ${zeichen(r.nummer)} herunterladen`,
}, 'PDF')))
panel.replaceChildren(abschnitt('Rechnungen', 'Rechnungen und Storno-Rechnungen deiner Gäste, ausgestellt in deinem Namen.', h('ul', { class: 'bk-liste' }, zeilen)))
}
+127
View File
@@ -0,0 +1,127 @@
/* Buchungskalender, Reiter „Unterkünfte & Preise“: Liste (anlegen, sortieren, aktivieren), Bearbeiten (Stammdaten, Preise, iCal).
API (alle mit ?projectId=): GET /api/unterkunft/unterkuenfte => { unterkuenfte:[{ id, name, beschreibung, maxPersonen, mindestNaechte, checkIn, checkOut, sort, aktiv,
icalImporte, icalExportUrl }] }; POST /unterkuenfte und PUT /unterkuenfte/:id mit { name, beschreibung, maxPersonen, mindestNaechte, checkIn, checkOut, sort, aktiv }
(Antwort { unterkunft:{…} }). Preise: buchungen-preise.js, iCal: buchungen-ical.js. Fremdtext nur ueber textContent. */
import { abschnitt, allgemeinerFehler, fehlerKarte, feld, h, hakenFeld, setzeFehler, zeigeFehler } from './shop-dom.js'
import { enc, ladeUnterkuenfte, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
import { zeichnePreise } from './buchungen-preise.js'
import { zeichneIcal } from './buchungen-ical.js'
const ganz = (steuer) => { const t = String(steuer.value).trim(); return /^\d{1,3}$/.test(t) ? Number(t) : null }
const zeit = (t) => /^([01]\d|2[0-3]):[0-5]\d$/.test(t)
export async function render(panel, ctx) {
const q = `projectId=${enc(ctx.projectId)}`
const inhalt = h('div', { class: 'bk-unterkuenfte' })
panel.replaceChildren(inhalt)
await zeigeListe()
async function zeigeListe() {
inhalt.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let liste
try {
liste = await ladeUnterkuenfte(ctx)
} catch (err) {
if (ctx.aktuell()) inhalt.replaceChildren(fehlerKarte(err.message, zeigeListe))
return
}
if (!ctx.aktuell()) return
const kopf = h('div', { class: 'bk-aktionen' }, ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'neu', onclick: () => zeigeForm(null, liste) }, 'Unterkunft hinzufügen'))
if (!liste.length) {
inhalt.replaceChildren(kopf, h('div', { class: 'kb-panel kb-empty', 'data-leer': true }, 'Noch keine Unterkunft. Lege deine erste Unterkunft an.'))
return
}
inhalt.replaceChildren(kopf, h('ul', { class: 'bk-liste' }, liste.map((u, i) => h('li', { class: 'kb-panel', 'data-unterkunft': u.id },
h('div', { class: 'bk-detail-kopf' },
h('strong', null, zeichen(u.name)),
h('span', { class: `sh-pille sh-pille-${u.aktiv === false ? 'off' : 'ok'}` }, u.aktiv === false ? 'Deaktiviert' : 'Aktiv')),
h('p', { class: 'sh-hinweis' }, `bis ${u.maxPersonen ?? '?'} Personen, ab ${u.mindestNaechte ?? 1} ${u.mindestNaechte === 1 ? 'Nacht' : 'Nächten'}, Check-in ${zeichen(u.checkIn)}, Check-out ${zeichen(u.checkOut)}`),
h('div', { class: 'bk-aktionen' },
h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'bearbeiten', onclick: () => zeigeForm(u, liste) }, 'Bearbeiten'),
ctx.nurLesen ? null : [
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'hoch', 'aria-label': `${zeichen(u.name)} nach oben`, disabled: i === 0, onclick: () => schiebe(liste, i, -1) }, '↑'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'runter', 'aria-label': `${zeichen(u.name)} nach unten`, disabled: i === liste.length - 1, onclick: () => schiebe(liste, i, 1) }, '↓'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'umschalten', onclick: () => speichere(u, { aktiv: u.aktiv === false }, 'Gespeichert.') }, u.aktiv === false ? 'Aktivieren' : 'Deaktivieren'),
])))))
}
const koerper = (u, extra = {}) => ({
name: u.name, beschreibung: zeichen(u.beschreibung), maxPersonen: u.maxPersonen, mindestNaechte: u.mindestNaechte, checkIn: u.checkIn, checkOut: u.checkOut, sort: u.sort ?? 0, aktiv: u.aktiv !== false, ...extra,
})
async function speichere(u, extra, meldung) {
try {
await ctx.request(`/api/unterkunft/unterkuenfte/${enc(u.id)}?${q}`, { method: 'PUT', body: koerper(u, extra) })
if (!ctx.aktuell()) return
ctx.zeigeMeldung(meldung, 'ok')
await zeigeListe()
} catch (err) { ctx.zeigeMeldung(err.message, 'fehler') }
}
async function schiebe(liste, i, d) {
const a = liste[i]
const b = liste[i + d]
try {
await ctx.request(`/api/unterkunft/unterkuenfte/${enc(a.id)}?${q}`, { method: 'PUT', body: koerper(a, { sort: i + d }) })
await ctx.request(`/api/unterkunft/unterkuenfte/${enc(b.id)}?${q}`, { method: 'PUT', body: koerper(b, { sort: i }) })
if (ctx.aktuell()) await zeigeListe()
} catch (err) { ctx.zeigeMeldung(err.message, 'fehler') }
}
/** Formular (u = null: neu). Bei einer bestehenden Unterkunft folgen Preise und iCal. */
async function zeigeForm(u, liste) {
const name = h('input', { type: 'text', id: 'bk-u-name', maxlength: '120', autocomplete: 'off' }); name.value = zeichen(u?.name)
const beschr = h('textarea', { id: 'bk-u-beschr', rows: '3', maxlength: '500' }); beschr.value = zeichen(u?.beschreibung)
const maxP = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-u-max' }); maxP.value = String(u?.maxPersonen ?? 2)
const minN = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-u-min' }); minN.value = String(u?.mindestNaechte ?? 1)
const ein = h('input', { type: 'text', id: 'bk-u-ein', placeholder: '15:00', maxlength: '5' }); ein.value = zeichen(u?.checkIn) || '15:00'
const aus = h('input', { type: 'text', id: 'bk-u-aus', placeholder: '10:00', maxlength: '5' }); aus.value = zeichen(u?.checkOut) || '10:00'
const aktiv = h('input', { type: 'checkbox', id: 'bk-u-aktiv' }); aktiv.checked = u ? u.aktiv !== false : true
const form = h('form', { novalidate: true, onsubmit: (ev) => ev.preventDefault() },
feld('name', 'Name der Unterkunft', name), feld('beschreibung', 'Kurzbeschreibung (optional)', beschr),
feld('maxPersonen', 'Höchstens Personen (1 bis 20)', maxP), feld('mindestNaechte', 'Mindestaufenthalt in Nächten (1 bis 90)', minN),
feld('checkIn', 'Check-in ab (HH:MM)', ein), feld('checkOut', 'Check-out bis (HH:MM)', aus),
hakenFeld('aktiv', aktiv, 'Aktiv (für Gäste buchbar)'), allgemeinerFehler(),
h('div', { class: 'bk-aktionen' },
ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'speichern', onclick: senden }, u ? 'Speichern' : 'Unterkunft anlegen'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zur-liste', onclick: zeigeListe }, 'Zur Liste')))
if (ctx.nurLesen) for (const el of form.querySelectorAll('input, textarea')) el.disabled = true
const bereiche = h('div', { class: 'bk-bearbeiten' })
inhalt.replaceChildren(abschnitt(u ? `Unterkunft „${zeichen(u.name)}“` : 'Neue Unterkunft', null, form), bereiche)
name.focus()
if (u) {
bereiche.replaceChildren(abschnitt('Preise', 'Preise sind Bruttopreise in Euro, so sieht sie dein Gast.', await zeichnePreise(ctx, u)), zeichneIcal(ctx, u, async (meldung) => {
ctx.zeigeMeldung(meldung, 'ok')
const neu = (await ladeUnterkuenfte(ctx)).find((x) => x.id === u.id)
if (ctx.aktuell()) await zeigeForm(neu || u, liste)
}))
}
async function senden(ev) {
const fehler = {}
const n = String(name.value).trim()
const mp = ganz(maxP)
const mn = ganz(minN)
if (!n) fehler.name = 'Bitte gib einen Namen an.'
if (mp === null || mp < 1 || mp > 20) fehler.maxPersonen = 'Bitte gib eine Zahl von 1 bis 20 an.'
if (mn === null || mn < 1 || mn > 90) fehler.mindestNaechte = 'Bitte gib eine Zahl von 1 bis 90 an.'
if (!zeit(String(ein.value).trim())) fehler.checkIn = 'Bitte gib eine Uhrzeit wie 15:00 an.'
if (!zeit(String(aus.value).trim())) fehler.checkOut = 'Bitte gib eine Uhrzeit wie 10:00 an.'
if (Object.keys(fehler).length) return zeigeFehler(form, { message: '', fehler })
setzeFehler(form, {})
const body = { name: n, beschreibung: String(beschr.value).trim(), maxPersonen: mp, mindestNaechte: mn, checkIn: String(ein.value).trim(), checkOut: String(aus.value).trim(), sort: u?.sort ?? liste.length, aktiv: aktiv.checked }
const knopf = ev.currentTarget
knopf.disabled = true
try {
const antwort = await ctx.request(u ? `/api/unterkunft/unterkuenfte/${enc(u.id)}?${q}` : `/api/unterkunft/unterkuenfte?${q}`, { method: u ? 'PUT' : 'POST', body })
if (!ctx.aktuell()) return
ctx.zeigeMeldung(u ? 'Gespeichert.' : 'Die Unterkunft ist angelegt. Jetzt kannst du die Preise eintragen.', 'ok')
const neu = antwort?.unterkunft?.id ? antwort.unterkunft : null
if (u || !neu) await zeigeListe()
else await zeigeForm({ ...neu }, [...liste, neu])
} catch (err) {
knopf.disabled = false
zeigeApiFehler(form, err)
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
/* Buchungskalender: gemeinsame Helfer der Reiter „Kalender“ und „Unterkünfte & Preise“ (Datumsrechnung, Fehlertexte, Unterkunftsliste).
Kalendertage sind Texte YYYY-MM-DD (Berlin); gerechnet wird nur mit UTC-Mitternacht, nie mit der Zeitzone des Browsers. */
import { zeigeFehler } from './shop-dom.js'
import { fehlerFuerFormular } from './buchungen-fehler.js'
export const enc = encodeURIComponent
export const zeichen = (x) => (typeof x === 'string' ? x : '')
export const tagMs = (tag) => { const [j, m, t] = String(tag).split('-').map(Number); return Date.UTC(j, m - 1, t) }
export const msTag = (ms) => new Date(ms).toISOString().slice(0, 10)
export const plusTage = (tag, n) => msTag(tagMs(tag) + n * 86400000)
/** Heute als Berlin-Datum. */
let jetzt = () => new Date()
/** Uhr der Portal-Reiter austauschen (nur fuer Tests); ohne Argument zurueck auf die echte Uhr. */
export const setzeUhrFuerTests = (fn) => { jetzt = fn || (() => new Date()) }
export const heuteBerlin = () => jetzt().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
/** '2026-11-12' ⇒ '12.11.2026'. */
export const tagText = (tag) => { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(tag || ''); return m ? `${m[3]}.${m[2]}.${m[1]}` : '' }
/** Fehler am Formular zeigen (Feldfehler am Feld, sonst allgemein); der Fokus geht auf das erste fehlerhafte Feld. */
export function zeigeApiFehler(form, err) {
zeigeFehler(form, fehlerFuerFormular(err))
}
/** Unterkuenfte des Projekts: GET /api/unterkunft/unterkuenfte => { unterkuenfte:[…] }. */
export async function ladeUnterkuenfte(ctx) {
const daten = await ctx.request(`/api/unterkunft/unterkuenfte?projectId=${enc(ctx.projectId)}`)
return (Array.isArray(daten?.unterkuenfte) ? daten.unterkuenfte : []).slice().sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
}
+77
View File
@@ -0,0 +1,77 @@
/* Buchungskalender im Kundenbereich (buchungen.js): Klassen mit bk-; Reiter, Pillen, Karten kommen aus shop.css (sh-). */
#view-buchungen .kb-panel { max-width: none; }
#view-buchungen .fn-project { margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; }
#view-buchungen button:focus-visible, #view-buchungen input:focus-visible, #view-buchungen select:focus-visible, #view-buchungen textarea:focus-visible,
#view-buchungen a:focus-visible { outline: 3px solid #d4b4ff; outline-offset: 2px; }
.bk-filter { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.bk-liste { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.bk-zeile {
display: grid; grid-template-columns: 90px 1.4fr 1fr auto auto; gap: 8px 14px; align-items: center; width: 100%; padding: 12px 16px; text-align: left;
border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.03); color: inherit; font: inherit; cursor: pointer;
}
.bk-zeile:hover { border-color: var(--kb-purple-line); }
.bk-zeile .sh-pille { margin: 0; justify-self: start; }
.bk-betrag { font-weight: 700; text-align: right; }
.bk-zurueck { margin-bottom: 12px; }
.bk-detail-kopf { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.bk-detail-kopf .sh-pille { margin: 0; }
.bk-daten { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 12px 0 18px; }
.bk-daten dt { color: var(--kb-muted); }
.bk-daten dd { margin: 0; overflow-wrap: anywhere; }
.bk-posten-kopf, .bk-posten-zeile { display: grid; grid-template-columns: minmax(0, 3fr) 70px 110px 80px auto; gap: 8px; align-items: center; }
.bk-posten-kopf { font-size: 12px; color: var(--kb-muted); margin-bottom: 4px; }
.bk-posten-zeile { margin-bottom: 8px; }
.bk-summen { margin: 12px 0; text-align: right; }
.bk-summen p { margin: 2px 0; }
.bk-gesamt { font-size: 18px; }
.bk-aktionen { display: flex; gap: 10px; flex-wrap: wrap; margin: 14px 0; }
.bk-tabelle { width: 100%; border-collapse: collapse; margin: 10px 0; }
.bk-tabelle td { padding: 6px 4px; border-bottom: 1px solid var(--kb-border); }
.bk-tabelle td:last-child { text-align: right; }
.bk-link input { width: 100%; margin: 6px 0 10px; }
@media (max-width: 640px) {
.bk-zeile { grid-template-columns: 1fr auto; }
.bk-posten-kopf { display: none; }
.bk-posten-zeile { grid-template-columns: 1fr 1fr; }
.bk-posten-zeile input:first-child { grid-column: 1 / -1; }
}
.bk-kalenderkopf { flex-wrap: wrap; }
.bk-monat { min-width: 9em; text-align: center; }
.bk-raster { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; width: 100%; margin: 10px 0; }
.bk-wochentag { text-align: center; font-size: 12px; color: var(--kb-muted); }
.bk-tag { aspect-ratio: 1.5; min-height: 40px; border-radius: 8px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.03); color: inherit; font: inherit; cursor: pointer; }
.bk-tag.bk-ausserhalb { opacity: 0.45; }
.bk-tag.bk-heute { border-color: var(--kb-purple-line); font-weight: 700; }
.bk-tag.bk-start { outline: 2px solid #d4b4ff; }
.ist-gebucht { background: rgba(74, 222, 128, 0.28); }
.ist-reserviert { background: rgba(251, 191, 36, 0.28); }
.ist-ical { background: rgba(96, 165, 250, 0.28); }
.ist-manuell { background: rgba(156, 163, 175, 0.35); }
.bk-legende { list-style: none; display: flex; flex-wrap: wrap; gap: 6px 16px; padding: 0; margin: 8px 0; }
.bk-legende li { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.bk-farbe { width: 16px; height: 16px; border-radius: 4px; border: 1px solid var(--kb-border); display: inline-block; }
.bk-staffel { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
.bk-staffel input { width: 90px; }
.bk-vorschau { font-weight: 700; }
.bk-import { display: flex; align-items: center; gap: 4px 12px; flex-wrap: wrap; }
.bk-anleitung details { margin: 8px 0; }
/* Rechnungen */
.bk-rechnung {
display: grid; grid-template-columns: 110px 100px 1fr auto auto auto; gap: 8px 14px; align-items: center; padding: 12px 16px;
border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.03);
}
.bk-rechnung .sh-pille { margin: 0; }
@media (max-width: 720px) { .bk-rechnung { grid-template-columns: 1fr auto; } }
/* Knoepfe „kb-btn“ (Hauptaktion) wie kb-btn-primary; Eingaben ausserhalb von .sh-feld (Posten-Editor, Staffeln, Filter) wie die Felder der Formulare */
#view-buchungen .kb-btn { display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 999px; padding: 10px 20px; font: inherit; font-weight: 600; cursor: pointer; background: var(--kb-purple); color: #fff; min-height: 40px; }
#view-buchungen .kb-btn:hover { background: var(--kb-purple-hover); }
#view-buchungen .kb-btn:disabled { opacity: 0.5; cursor: default; }
#view-buchungen .bk-posten input:not([type=checkbox]), #view-buchungen .bk-posten select, #view-buchungen .bk-staffel input, #view-buchungen .bk-filter select,
#view-buchungen .bk-vorschaufelder input, #view-buchungen .bk-importform input {
box-sizing: border-box; min-width: 0; padding: 9px 10px; border-radius: 10px; font: inherit; font-size: 15px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit;
}
#view-buchungen .bk-posten select option, #view-buchungen .bk-filter select option { color: #111; }
#view-buchungen .bk-posten input, #view-buchungen .bk-posten select { width: 100%; }
#view-buchungen input[readonly] { box-sizing: border-box; width: 100%; padding: 9px 10px; border-radius: 10px; font: inherit; font-size: 15px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; }
+212
View File
@@ -0,0 +1,212 @@
/* Buchungskalender im Kundenbereich (ES-Modul, von app.js beim Oeffnen der Ansicht #buchungen geladen). Aufbau wie shop.js:
initBuchungen(rootEl, { projectId }): Projekt waehlen (GET /api/funktionen, features.unterkunft), Stand holen (GET /api/unterkunft/status)
und je nach state die Reiter (REITER, lazy geladen, render(panel, ctx)) oder (state off) die Anfrage-/Bestellkarte (Task 14).
ctx = { projectId, status, nurLesen, aktuell(), neuLaden(), zeigeMeldung(text, art), request }.
Helfer: shop-dom.js (h, request, Karten), Styles: buchungen.css (bk-) und shop.css (sh-). Fremdtext nur ueber textContent. */
import { fehlerKarte, h, infoKarte, request } from './shop-dom.js'
import { statusKarte } from './funktion-bestellen.js'
import { anfrageKarte, renderAnfrageFormular } from './buchungen-anfrage.js'
/** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */
export const REITER = [
{ key: 'anfragen', label: 'Anfragen', laden: () => import('./buchungen-tab-anfragen.js') },
{ key: 'kalender', label: 'Kalender', laden: () => import('./buchungen-tab-kalender.js') },
{ key: 'unterkuenfte', label: 'Unterkünfte & Preise', laden: () => import('./buchungen-tab-unterkuenfte.js') },
{ key: 'rechnungen', label: 'Rechnungen', laden: () => import('./buchungen-tab-rechnungen.js') },
{ key: 'einstellungen', label: 'Einstellungen', laden: () => import('./buchungen-tab-einstellungen.js') },
]
const NICHT_VERFUEGBAR = 'Der Buchungskalender ist noch nicht verfügbar.'
const auswahl = { projectId: '', reiter: 'anfragen' }
/** Gemerkte Auswahl vergessen (fuer Tests). */
export function vergissAuswahl() {
auswahl.projectId = ''
auswahl.reiter = 'anfragen'
}
let S = null // aktuelle Sitzung; eine alte Antwort darf eine neuere nicht ueberschreiben
const textOr = (x) => (typeof x === 'string' && x.trim() ? x : '')
const zustandVon = (projekt) => projekt?.features?.unterkunft?.state || 'off'
export function initBuchungen(root, { projectId } = {}) {
if (!root) return Promise.resolve()
const wunsch = projectId || root.dataset?.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
if (root.dataset) delete root.dataset.wunschProjekt
return starte(root, wunsch)
}
async function starte(root, projectId) {
if (projectId) auswahl.projectId = projectId
const s = { root, projects: [], projekt: null, status: null, lauf: 0, buehne: null, meldungEl: null }
S = s
let funktionen
try {
funktionen = await request('/api/funktionen')
} catch (err) {
if (S === s) root.replaceChildren(fehlerKarte(err.message, () => starte(root, auswahl.projectId)))
return
}
if (S !== s) return
if (!funktionen?.unterkunft?.verfuegbar) {
root.replaceChildren(infoKarte(NICHT_VERFUEGBAR, 'Sobald er für dich bereit ist, findest du ihn hier.'))
return
}
s.projects = Array.isArray(funktionen.projects) ? funktionen.projects : []
if (!s.projects.length) {
root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.'))
return
}
const hat = (zustaende) => s.projects.find((p) => zustaende.includes(zustandVon(p)))
s.projekt = s.projects.find((p) => p.projectId === auswahl.projectId) || hat(['active']) || hat(['setup']) || hat(['disabled']) || s.projects[0]
auswahl.projectId = s.projekt.projectId
s.meldungEl = h('div', { class: 'sh-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
s.buehne = h('div', { id: 'buchungen-buehne' })
root.replaceChildren(...(s.projects.length > 1 ? [projektwahl(s)] : []), s.meldungEl, s.buehne)
await zeigeProjekt(s)
}
function projektwahl(s) {
const select = h('select', { id: 'buchungen-project' }, s.projects.map((p) => {
const option = h('option', { value: p.projectId }, p.projectName)
option.selected = p.projectId === s.projekt.projectId
return option
}))
select.addEventListener('change', () => {
auswahl.projectId = select.value
s.projekt = s.projects.find((p) => p.projectId === select.value)
s.neueAnfrage = false
zeigeMeldung(s, '')
zeigeProjekt(s)
})
return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'buchungen-project' }, 'Website'), select)
}
function zeigeMeldung(s, meldung, art = 'ok') {
const el = s.meldungEl
if (!el) return
el.textContent = meldung || ''
el.className = `sh-meldung sh-meldung-${art}`
el.setAttribute('role', art === 'fehler' ? 'alert' : 'status')
el.setAttribute('aria-live', art === 'fehler' ? 'assertive' : 'polite')
if (!meldung) return
if (art === 'fehler') el.focus({ preventScroll: true })
el.scrollIntoView?.({ block: 'nearest' })
}
const ladeStatus = (projectId) => request(`/api/unterkunft/status?projectId=${encodeURIComponent(projectId)}`)
async function zeigeProjekt(s) {
const lauf = ++s.lauf
const aktuell = () => S === s && s.lauf === lauf
s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let status
try {
status = await ladeStatus(s.projekt.projectId)
} catch (err) {
if (aktuell()) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s)))
return
}
if (!aktuell()) return
zeichneAnsicht(s, { status, aktuell })
}
function zeichneAnsicht(s, { status, aktuell }) {
s.status = status
const zustand = status?.state || zustandVon(s.projekt)
if (zustand === 'setup' || zustand === 'active' || zustand === 'disabled') zeigeReiter(s, { status, aktuell, zustand })
else if (zustand === 'ordered') zeigeBestellung(s)
else if (zustand === 'off') zeigeAnfrage(s, { status, aktuell })
else s.buehne.replaceChildren(infoKarte('Buchungskalender', 'Der Stand deines Buchungskalenders ist gerade nicht lesbar. Bitte lade die Seite neu.'))
}
/* Zustand off: Anfrageformular, Karte „wird geprueft“ / „abgelehnt“ oder (angenommen) der Hinweis auf die Bestellung. */
function zeigeAnfrage(s, { status, aktuell }) {
const projectId = s.projekt.projectId
const ansicht = status?.anfrage
// nach einem 409 bleibt die Meldung des Servers sichtbar, waehrend die Ansicht neu laedt
const neuLaden = async (o) => { await zeigeProjekt(s); if (o?.meldung && S === s) zeigeMeldung(s, o.meldung, 'fehler') }
if (ansicht?.status === 'angenommen') {
s.buehne.replaceChildren(infoKarte('Deine Anfrage wurde angenommen', 'Wir melden uns bei dir mit den nächsten Schritten zur Bestellung.', { chat: true }))
} else if (ansicht?.status === 'offen') {
s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neuLaden }))
} else if (ansicht?.status === 'abgelehnt' && !s.neueAnfrage) {
s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neu: () => { s.neueAnfrage = true; zeigeAnfrage(s, { status, aktuell }) } }))
} else {
const wurzel = h('div', { class: 'sh-wurzel' })
s.buehne.replaceChildren(wurzel)
renderAnfrageFormular(wurzel, {
projectId,
vorgabe: ansicht?.anfrage || null,
preis: textOr(s.projekt?.features?.unterkunft?.priceLabel) || '199 €',
nachSenden: async (_a, o) => { s.neueAnfrage = false; if (aktuell()) await neuLaden(o) },
})
}
}
/* Zustand ordered: Statuskarte „Zahlung offen“ mit „Zahlung fortsetzen“ (generische Bestellseite, Checkout /api/funktionen/unterkunft/checkout). */
function zeigeBestellung(s) {
const eintrag = { ...s.projekt.features?.unterkunft, state: 'ordered' }
s.buehne.replaceChildren(statusKarte({ projekt: s.projekt, bestellung: eintrag, kundenansicht: false, key: 'unterkunft', name: 'Buchungskalender' }, {
weiter: async ({ meldung }) => { await zeigeProjekt(s); if (S === s) zeigeMeldung(s, meldung, 'fehler') },
}))
}
function zeigeReiter(s, { status, aktuell, zustand }) {
const neuLaden = async () => {
const neu = await ladeStatus(s.projekt.projectId)
if (!aktuell()) return neu
s.status = neu
ctx.status = neu
if ((neu.state || 'off') !== zustand) zeichneAnsicht(s, { status: neu, aktuell })
return neu
}
const ctx = {
projectId: s.projekt.projectId,
status,
nurLesen: zustand === 'disabled',
abwickeln: true, // Anfragen und Stornos bleiben auch bei abgeschalteter Funktion bearbeitbar (Pflichten gegenueber Gaesten)
aktuell,
request,
zeigeMeldung: (meldung, art) => { if (aktuell()) zeigeMeldung(s, meldung, art) },
neuLaden,
}
const panel = h('div', { class: 'sh-panel', role: 'tabpanel', id: 'bk-panel', tabindex: '0' })
let aktiv = null
let zaehler = 0
const knoepfe = REITER.map((r) => h('button', {
type: 'button', role: 'tab', class: 'sh-reiter', id: `bk-tab-${r.key}`, 'aria-controls': 'bk-panel', 'data-reiter': r.key,
onclick: () => waehleReiter(r.key),
}, r.label))
const leiste = h('div', { class: 'sh-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Buchungskalenders' }, knoepfe)
leiste.addEventListener('keydown', (ev) => {
const i = REITER.findIndex((r) => r.key === aktiv)
const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key]
if (ziel === undefined) return
ev.preventDefault()
waehleReiter(REITER[ziel].key)
knoepfe[ziel].focus()
})
function waehleReiter(key) {
const reiter = REITER.find((r) => r.key === key) || REITER[0]
auswahl.reiter = reiter.key
aktiv = reiter.key
const nr = ++zaehler
for (const k of knoepfe) {
const an = k.dataset.reiter === reiter.key
k.setAttribute('aria-selected', String(an))
k.tabIndex = an ? 0 : -1
}
panel.setAttribute('aria-labelledby', `bk-tab-${reiter.key}`)
zeigeMeldung(s, '')
panel.replaceChildren()
reiter.laden()
.then((modul) => (nr === zaehler && aktuell() ? modul.render(panel, ctx) : undefined))
.catch((err) => { if (nr === zaehler && aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) })
}
const hinweis = zustand === 'setup' ? 'Wir bauen deinen Buchungskalender gerade ein. Du kannst schon Unterkünfte, Preise und Einstellungen vorbereiten.'
: zustand === 'disabled' ? 'Dein Buchungskalender ist abgeschaltet. Du kannst bestehende Buchungen noch ansehen und bearbeiten.' : ''
const hinweisBox = h('div', { class: 'sh-hinweise' }, hinweis ? h('div', { class: `sh-box ${zustand === 'disabled' ? 'sh-box-warn' : 'sh-box-info'}`, role: 'note', 'data-hinweis': zustand }, hinweis) : null)
s.buehne.replaceChildren(hinweisBox, leiste, panel)
waehleReiter(auswahl.reiter)
}
+10
View File
@@ -25,6 +25,7 @@
<link rel="stylesheet" href="/analyse.css">
<link rel="stylesheet" href="/termine.css">
<link rel="stylesheet" href="/shop.css">
<link rel="stylesheet" href="/buchungen.css">
<link rel="stylesheet" href="/zeiten.css">
<link rel="stylesheet" href="/auftrag.css">
</head>
@@ -208,6 +209,15 @@
<div id="webshop-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Buchungskalender: Unterkünfte, Preise, Anfragen, Rechnungen, Einstellungen (buchungen.js, initBuchungen); Unterseite von Funktionen -->
<section class="kb-view" id="view-buchungen">
<header class="kb-head">
<h1>Buchungskalender</h1>
<p>Anfragen und Buchungen für deine Unterkünfte – mit Belegungskalender und Rechnungen für deine Gäste.</p>
</header>
<div id="buchungen-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Öffnungszeiten live: Einschalten oder Bestellen, Status und Editor (zeiten.js, initZeiten); Unterseite von Funktionen. Kein aria-live am Bereich -->
<section class="kb-view" id="view-zeiten">
<header class="kb-head">
+8 -2
View File
@@ -44,7 +44,7 @@
<div class="brand">WEBKLAR</div>
<h1>Datenschutzerklärung</h1>
<p class="meta">Stand 05.10.2026</p>
<p class="meta">Stand 07.10.2026</p>
<p>Hier erfahren Sie, welche personenbezogenen Daten wir verarbeiten, wenn Sie unsere Websites besuchen, unser Kundenportal nutzen oder mit uns einen Vertrag schließen, und welche Rechte Sie haben.</p>
@@ -108,6 +108,12 @@
<p>Zahlungen im Webshop laufen über Stripe Connect auf das eigene Stripe-Konto des Kunden; wir erhalten und verwahren kein Geld der Käufer. 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 (zum Beispiel zur Person und zum Unternehmen) 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.</p>
<p>Löschfristen: Nicht bezahlte und stornierte Bestellungen löschen wir nach 30 Tagen. Bei bezahlten Bestellungen und Widerrufen anonymisieren wir Name, E-Mail-Adresse und Anschriften des Käufers drei Jahre nach dem Ende des Jahres der Bestellung; Positionen, Beträge, Bestellnummer und Status bleiben für die Unterlagen des Kunden. Schaltet der Kunde den Webshop ab oder endet der Vertrag über den Betrieb seiner Website, schalten wir den Webshop ab. Der Kunde kann Bestellungen und Widerrufe danach 90 Tage lang im Kundenportal lesen und exportieren; danach löschen wir alle Daten des Webshops (Artikel, Bilder, Bestellungen, Widerrufe, Einstellungen und das Archiv der bestätigten Rechtstexte). Beginnt innerhalb dieser Frist wieder ein Vertrag über den Betrieb der Website, schalten wir den Webshop 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 Käufern. 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.</p>
<h2 id="buchungskalender">6f. Buchungskalender auf Websites unserer Kunden</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h2>7. Abos und Zahlung über Stripe</h2>
<p>Schließen Sie eines unserer Abos ab, verarbeiten wir Name, E-Mail-Adresse, Rechnungsanschrift, das gewählte Abo und den gebuchten Zusatzspeicher, Abo- und Zahlungsstatus sowie Ihre Rechnungen. Zahlungsdaten wie Kartennummer oder IBAN geben Sie direkt bei unserem Zahlungsdienstleister ein. Wir erhalten davon nur eingeschränkte Angaben, etwa die Art des Zahlungsmittels und die letzten vier Ziffern.</p>
<p>Um Ihre Bestellungen nachweisen zu können, speichern wir bei jeder Bestellung Zeitpunkt, Version der Vertragsbedingungen, gegebenenfalls Ihre Angaben zum Leistungsbeginn sowie IP-Adresse und Browserkennung, im Kundenportal und als Angaben zum Abo bei Stripe. Das gilt für den Abschluss eines Abos, für den Wechsel vom Hosting-Abo ins Wartungs-Abo und für jede Bestellung von E-Mail-Zusatzspeicher. Außerdem halten wir Ihre Bestellungen und Änderungen als Vertragsereignisse mit Zeitpunkt und Status fest: Abschluss, Wechsel, Zusatzspeicher einschließlich Verringerungen, Kündigung und Widerruf. Bei Verringerungen, Kündigung und Widerruf speichern wir als Vertragsereignis nur Zeitpunkt und Status, keine IP-Adresse und Browserkennung. Den Stand Ihrer Bestellungen, etwa laufende Widerrufsfristen und die Einrichtung von Zusatzspeicher, sehen Sie im Kundenportal unter „Verträge“. Für uns sind die Vertragsereignisse zugleich die Aufgabenliste, um Änderungen am Zusatzspeicher umzusetzen. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO. Unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und Ihrer Bestellungen. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet.</p>
@@ -135,7 +141,7 @@
<ul>
<li>Hetzner Online GmbH: Hosting, E-Mail und Datensicherung (Auftragsverarbeiter)</li>
<li>UptimeRobot s. r. o.: Überwachung der Erreichbarkeit, Daten auch außerhalb der EU (insbesondere USA), Auftragsverarbeiter (Abschnitt 3)</li>
<li>Stripe: Zahlungsabwicklung und Eröffnung des Stripe-Kontos für den Webshop unserer Kunden (Abschnitte 6d und 7)</li>
<li>Stripe: Zahlungsabwicklung und Eröffnung des Stripe-Kontos für den Webshop und den Buchungskalender unserer Kunden (Abschnitte 6d, 6f und 7)</li>
<li>Porkbun LLC: bisheriger E-Mail-Dienst, nur noch bis zur Löschung älterer E-Mails (Abschnitt 8)</li>
<li>Domain-Registrar und Vergabestellen, sofern wir eine Domain für Sie registrieren (Abschnitt 10)</li>
<li>Steuerberatung und Behörden, soweit gesetzlich erforderlich</li>
+26
View File
@@ -0,0 +1,26 @@
<!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>Buchungskalender Testseite</title>
<style>
body{margin:0;font-family:system-ui,sans-serif;background:#f6f7f9;color:#1f2937;line-height:1.5}
header,footer{padding:16px 24px;background:#e8ebf0}
main{max-width:1180px;margin:0 auto;padding:32px 16px}
/* Gestaltung ueber CSS-Variablen der Seite (alle optional):
[data-webklar-unterkunft]{ --wku-akzent:#0f766e; --wku-radius:8px; --wku-schrift:Georgia,serif; } */
</style>
</head>
<body>
<header>Testseite der Kundenwebsite (intern, nicht verlinkt)</header>
<main>
<h1>Unterkunft anfragen</h1>
<!-- data-api weglassen = Herkunft des Skripts; die SiteKey ist ein Platzhalter -->
<div data-webklar-unterkunft="abcdefghijklmnop"></div>
</main>
<footer>Ende der Testseite</footer>
<script src="unterkunft.js" defer></script>
</body>
</html>
+504
View File
@@ -0,0 +1,504 @@
/*! WEBklar Buchungskalender: Einbau-Skript fuer Kundenwebsites.
* <div data-webklar-unterkunft="SITEKEY" data-api="https://project.webklar.com"></div>
* <script src="https://project.webklar.com/embed/unterkunft.js" defer></script>
* Keine Abhaengigkeiten, keine Cookies, kein Storage, keine Drittanfragen. Alle Texte nur ueber textContent.
* Gestaltung ueber CSS-Variablen der Seite (alle optional): --wku-akzent, --wku-akzent-text, --wku-hintergrund, --wku-text,
* --wku-rand, --wku-radius, --wku-schrift, --wku-schrift-titel.
* Belegte Naechte kommen als [von,bis) (bis = Abreisetag, nicht belegt). Datumsrechnung ist reine Kalenderrechnung auf Zeichenketten
* (UTC), die Zeitzone des Browsers spielt nie eine Rolle; "heute" und das Ende des Buchungsfensters liefert der Server. */
(function () {
'use strict'
var SELF = document.currentScript
var STYLE_ID = 'wku-style'
var API_PFAD = '/api/public/unterkunft/v1/'
var AUSWEICH = 'Anfragen gerne per Telefon oder E-Mail.'
var TIMEOUT_MS = 20000
var MONAT_KURZ = ['Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', 'Juli', 'Aug.', 'Sept.', 'Okt.', 'Nov.', 'Dez.']
var MONAT_LANG = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
var WOCHE_KURZ = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
var WOCHE_LANG = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
var EMAIL_FORM = /^[^\s@,;<>"]+@[^\s@,;<>"]+\.[^\s@,;<>"]+$/
var FARBE = /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/
var zaehler = 0
/* ------------------------------------------------------------ reine Kalenderlogik --- */
function zwei(n) { return (n < 10 ? '0' : '') + n }
function iso(j, m, t) { return j + '-' + zwei(m) + '-' + zwei(t) }
function zerlege(s) { var p = s.split('-'); return [parseInt(p[0], 10), parseInt(p[1], 10), parseInt(p[2], 10)] }
function utc(s) { var p = zerlege(s); return Date.UTC(p[0], p[1] - 1, p[2]) }
function plusTage(s, n) {
var d = new Date(utc(s) + n * 86400000)
return iso(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate())
}
function tageZwischen(a, b) { return Math.round((utc(b) - utc(a)) / 86400000) }
function wochentag(s) { return (new Date(utc(s)).getUTCDay() + 6) % 7 } // 0 = Montag
function tageImMonat(j, m) { return new Date(Date.UTC(j, m, 0)).getUTCDate() }
var WKU_LOGIK = {
monatsRaster: function (jahr, monat) {
var r = []
var lead = wochentag(iso(jahr, monat, 1))
var n = tageImMonat(jahr, monat)
var i
for (i = 0; i < lead; i += 1) r.push({ tag: null })
for (i = 1; i <= n; i += 1) r.push({ tag: iso(jahr, monat, i) })
while (r.length % 7) r.push({ tag: null })
return r
},
nachtBelegt: function (tag, belegt) {
for (var i = 0; i < (belegt || []).length; i += 1) if (belegt[i].von <= tag && tag < belegt[i].bis) return true
return false
},
waehle: function (zustand, tag, belegt, mindestNaechte) {
var z = zustand || {}
var L = WKU_LOGIK
var neu = function () { return L.nachtBelegt(tag, belegt) ? { von: z.von || null, bis: z.bis || null } : { von: tag, bis: null } }
if (!z.von || z.bis || tag <= z.von) return neu()
for (var d = z.von; d < tag; d = plusTage(d, 1)) if (L.nachtBelegt(d, belegt)) return neu()
if (tageZwischen(z.von, tag) < (mindestNaechte || 1)) return { von: z.von, bis: null, fehler: 'mindestnaechte' }
return { von: z.von, bis: tag }
},
zusammenfassung: function (von, bis) {
var a = zerlege(von)
var b = zerlege(bis)
var n = tageZwischen(von, bis)
var tail = ' · ' + n + (n === 1 ? ' Nacht' : ' Nächte')
if (a[0] !== b[0]) return a[2] + '. ' + MONAT_KURZ[a[1] - 1] + ' ' + a[0] + ' – ' + b[2] + '. ' + MONAT_KURZ[b[1] - 1] + ' ' + b[0] + tail
if (a[1] === b[1]) return a[2] + '.–' + b[2] + '. ' + MONAT_KURZ[a[1] - 1] + tail
return a[2] + '. ' + MONAT_KURZ[a[1] - 1] + ' – ' + b[2] + '. ' + MONAT_KURZ[b[1] - 1] + tail
}
}
var testModus = false
try { testModus = !!(SELF && SELF.dataset && SELF.dataset.test === '1') } catch (e) { testModus = false }
if (testModus) globalThis.__wkuLogik = WKU_LOGIK
function langesDatum(s) {
var p = zerlege(s)
return WOCHE_LANG[wochentag(s)] + ', ' + p[2] + '. ' + MONAT_LANG[p[1] - 1] + ' ' + p[0]
}
/* ------------------------------------------------------------------- Gestaltung --- */
var CSS = [
'.wku-root{--wku-i-akzent:var(--wku-akzent,var(--wku-vorgabe-akzent,#0f766e));--wku-i-akzent-text:var(--wku-akzent-text,var(--wku-vorgabe-akzent-text,#fff));',
'--wku-i-bg:var(--wku-hintergrund,#fff);--wku-i-text:var(--wku-text,#1f2937);--wku-i-rand:var(--wku-rand,#b6bcc6);--wku-i-radius:var(--wku-radius,12px);',
'--wku-i-weich:#f3f4f6;--wku-i-bereich:#e5eceb;--wku-i-fehler:#b91c1c;--wku-i-schrift:var(--wku-schrift,inherit);--wku-i-titel:var(--wku-schrift-titel,var(--wku-schrift,inherit))}',
'@supports (color:color-mix(in srgb,red,blue)){.wku-root{--wku-i-weich:color-mix(in srgb,var(--wku-i-text) 6%,var(--wku-i-bg));--wku-i-bereich:color-mix(in srgb,var(--wku-i-akzent) 18%,var(--wku-i-bg))}}',
'.wku-root{container:wku/inline-size;display:block;position:relative;box-sizing:border-box;color:var(--wku-i-text);font-family:var(--wku-i-schrift);font-size:16px;line-height:1.5;text-align:left;width:100%;max-width:100%}',
'.wku-root :is(button,input,select,textarea,label,h1,h2,h3,h4,h5,h6,p,a,fieldset,legend){all:revert;box-sizing:border-box}',
'.wku-root [hidden]{display:none}',
'.wku-root *,.wku-root *::before,.wku-root *::after{box-sizing:border-box}',
'.wku-root .wku-vh{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}',
'.wku-root .wku-haupt{display:grid;grid-template-columns:minmax(0,1fr);gap:24px}',
'.wku-root .wku-karte{background:var(--wku-i-bg);border:1px solid var(--wku-i-rand);border-radius:var(--wku-i-radius);padding:20px}',
'.wku-root .wku-titel{font-family:var(--wku-i-titel);font-size:1.2rem;line-height:1.25;font-weight:700;margin:0 0 .75rem}',
'.wku-root.wku-ausweich{margin:0}',
'.wku-root .wku-monate{display:grid;grid-template-columns:minmax(0,1fr);gap:20px}',
'.wku-root .wku-zwei{display:none}',
'.wku-root .wku-monat-kopf{display:flex;align-items:center;justify-content:space-between;gap:.5rem;min-height:44px;margin-bottom:.35rem}',
'.wku-root .wku-monat-name{font-family:var(--wku-i-titel);font-size:1.05rem;font-weight:700;margin:0;flex:1;text-align:center}',
'.wku-root .wku-pfeil{font:inherit;font-size:1.4rem;line-height:1;min-width:44px;min-height:44px;border-radius:50%;border:1px solid var(--wku-i-rand);background:var(--wku-i-bg);color:inherit;cursor:pointer;padding:0}',
'.wku-root .wku-pfeil:hover{background:var(--wku-i-weich)}.wku-root .wku-pfeil[disabled]{opacity:.3;cursor:default}.wku-root .wku-pfeil-leer{display:inline-block;width:44px;height:44px}',
'.wku-root .wku-raster{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));row-gap:2px}',
'.wku-root .wku-wt{font-size:.78rem;font-weight:600;text-align:center;padding:.25rem 0;opacity:.7}',
'.wku-root .wku-leer{min-height:44px}',
'.wku-root .wku-tag{font:inherit;position:relative;min-height:44px;padding:0;margin:0;border:0;border-radius:0;background:transparent;color:inherit;text-align:center;cursor:pointer;overflow:hidden}',
'.wku-root .wku-tag>span{position:relative;display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:1px solid transparent;z-index:1}',
'.wku-root .wku-tag:hover>span{background:var(--wku-i-weich)}',
'.wku-root .wku-heute>span{border-color:var(--wku-i-text)}',
'.wku-root .wku-vergangen,.wku-root .wku-zu{opacity:.35;cursor:default}.wku-root .wku-vergangen:hover>span,.wku-root .wku-zu:hover>span{background:transparent}',
'.wku-root .wku-belegt{color:#6b7280;cursor:default;background:linear-gradient(to top right,transparent calc(50% - 1px),#9ca3af calc(50% - 1px),#9ca3af calc(50% + 1px),transparent calc(50% + 1px)) center/60% 60% no-repeat}',
'.wku-root .wku-belegt:hover>span{background:transparent}',
'.wku-root .wku-bereich{background:var(--wku-i-bereich)}',
'.wku-root .wku-an{background:linear-gradient(to right,transparent 50%,var(--wku-i-bereich) 50%)}',
'.wku-root .wku-ab{background:linear-gradient(to left,transparent 50%,var(--wku-i-bereich) 50%)}',
'.wku-root .wku-an.wku-ab{background:transparent}',
'.wku-root .wku-an>span,.wku-root .wku-ab>span,.wku-root .wku-an:hover>span,.wku-root .wku-ab:hover>span{background:var(--wku-i-akzent);color:var(--wku-i-akzent-text);border-color:var(--wku-i-akzent)}',
'.wku-root :focus-visible{outline:3px solid var(--wku-i-text);outline-offset:2px}',
'.wku-root .wku-legende{display:flex;flex-wrap:wrap;gap:.25rem 1rem;margin:.75rem 0 0;font-size:.8rem;opacity:.8}',
'.wku-root .wku-zus{margin:1rem 0 0;font-weight:700;min-height:1.5rem}',
'.wku-root .wku-hinweis{margin:.25rem 0 0;font-size:.9rem;color:var(--wku-i-fehler);font-weight:600;min-height:1.4rem}',
'.wku-root .wku-form{margin:0}',
'.wku-root .wku-feld{margin:0 0 .9rem}',
'.wku-root .wku-label{display:block;font-weight:600;font-size:.92rem;margin-bottom:.25rem}',
'.wku-root .wku-stern{color:var(--wku-i-fehler)}',
'.wku-root .wku-eingabe{display:block;width:100%;min-height:44px;font:inherit;color:inherit;background:var(--wku-i-bg);border:1px solid var(--wku-i-rand);border-radius:calc(var(--wku-i-radius) * .7);padding:.5rem .7rem}',
'.wku-root textarea.wku-eingabe{min-height:5.5rem;resize:vertical}',
'.wku-root .wku-eingabe[aria-invalid="true"]{border-color:var(--wku-i-fehler);border-width:2px}',
'.wku-root .wku-feldfehler{color:var(--wku-i-fehler);font-weight:600;font-size:.88rem;margin:.25rem 0 0}',
'.wku-root .wku-reihe{display:grid;grid-template-columns:minmax(0,1fr);gap:0 12px}',
'.wku-root .wku-fehler{margin:0 0 .75rem;padding:.6rem .8rem;border-left:4px solid var(--wku-i-fehler);background:var(--wku-i-weich);font-weight:600}',
'.wku-root .wku-datenschutz{font-size:.85rem;margin:0 0 1rem;opacity:.9}.wku-root .wku-datenschutz a{color:inherit}',
'.wku-root .wku-btn{font:inherit;font-weight:700;min-height:48px;padding:.6rem 1.5rem;border-radius:var(--wku-i-radius);border:2px solid var(--wku-i-akzent);background:var(--wku-i-akzent);color:var(--wku-i-akzent-text);cursor:pointer;line-height:1.25;width:100%}',
'.wku-root .wku-btn:hover{filter:brightness(1.08)}.wku-root .wku-btn[disabled]{opacity:.6;cursor:default}',
'.wku-root .wku-danke{margin:0;font-size:1.05rem;font-weight:600}',
'.wku-root .wku-hp{position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden}',
'@container wku (min-width:520px){.wku-root .wku-reihe{grid-template-columns:1fr 1fr}}',
'@container wku (min-width:760px){.wku-root .wku-haupt{grid-template-columns:minmax(0,1.15fr) minmax(0,1fr);align-items:start}}',
'@container wku (min-width:1100px){.wku-root .wku-zwei{display:block}.wku-root .wku-monate{grid-template-columns:1fr 1fr}.wku-root .wku-weiter-ein{display:none}}',
'@media (prefers-reduced-motion:no-preference){.wku-root .wku-btn,.wku-root .wku-tag>span{transition:background-color .15s}}'
].join('')
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return
var st = document.createElement('style')
st.id = STYLE_ID
st.textContent = CSS
;(document.head || document.documentElement).appendChild(st)
}
/* ------------------------------------------------------------------------ Helfer --- */
function h(tag, attrs, kinder) {
var el = document.createElement(tag)
var k
attrs = attrs || {}
for (k in attrs) {
if (!Object.prototype.hasOwnProperty.call(attrs, k) || attrs[k] === null || attrs[k] === undefined || attrs[k] === false) continue
if (k === 'text') el.textContent = attrs[k]
else if (k === 'class') el.className = attrs[k]
else el.setAttribute(k, attrs[k] === true ? '' : String(attrs[k]))
}
;(kinder || []).forEach(function (c) { if (c) el.appendChild(c) })
return el
}
function apiBasis(el) {
var a = el.getAttribute('data-api')
if (!a && SELF && SELF.src) { var m = /^(https?:\/\/[^\/]+)/.exec(SELF.src); a = m ? m[1] : '' }
return (a || '').replace(/\/+$/, '')
}
function holen(url, opt) {
var ctl = typeof AbortController === 'function' ? new AbortController() : null
var t = ctl ? setTimeout(function () { ctl.abort() }, TIMEOUT_MS) : null
var o = opt || {}
if (ctl) o.signal = ctl.signal
return fetch(url, o).then(function (r) {
if (t) clearTimeout(t)
return r.json().then(function (body) { return { status: r.status, ok: r.ok, body: body || {} } }, function () { return { status: r.status, ok: r.ok, body: {} } })
}, function (e) { if (t) clearTimeout(t); throw e })
}
function textFarbe(hex) {
var s = hex.slice(1)
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2]
var r = parseInt(s.slice(0, 2), 16); var g = parseInt(s.slice(2, 4), 16); var b = parseInt(s.slice(4, 6), 16)
return (0.299 * r + 0.587 * g + 0.114 * b) > 160 ? '#111827' : '#ffffff'
}
function ausweich(el) {
el.textContent = ''
el.appendChild(h('p', { class: 'wku-root wku-ausweich', text: AUSWEICH }))
}
/* ------------------------------------------------------------------------ Widget --- */
function starten(el) {
if (el.getAttribute('data-wku-fertig')) return
el.setAttribute('data-wku-fertig', '1')
var siteKey = el.getAttribute('data-webklar-unterkunft')
if (!/^[A-Za-z0-9_-]{8,64}$/.test(siteKey || '')) return
ensureStyle()
var basis = apiBasis(el) + API_PFAD + encodeURIComponent(siteKey)
holen(basis, { headers: { Accept: 'application/json' } }).then(function (r) {
if (!r.ok || !r.body || r.body.aktiv !== true || !r.body.unterkuenfte || !r.body.unterkuenfte.length) return ausweich(el)
bauen(el, basis, r.body)
}, function () { ausweich(el) })
}
function bauen(el, basis, cfg) {
var id = 'wku' + (zaehler += 1) + '-'
var heute = null
var bisMax = null
var belegt = []
var sel = { von: null, bis: null }
var hinweis = ''
var unterk = cfg.unterkuenfte[0]
var token = cfg.formToken
var ansicht = null // { j, m } erster angezeigter Monat
var fokusTag = null
var sendet = false
var root = h('div', { class: 'wku-root' })
if (cfg.akzentfarbe && FARBE.test(cfg.akzentfarbe) && root.style) {
root.style.setProperty('--wku-vorgabe-akzent', cfg.akzentfarbe)
root.style.setProperty('--wku-vorgabe-akzent-text', textFarbe(cfg.akzentfarbe))
}
var haupt = h('div', { class: 'wku-haupt' })
var kal = h('div', { class: 'wku-karte' })
var formBox = h('div', { class: 'wku-karte' })
var zus = h('p', { class: 'wku-zus', 'aria-live': 'polite' })
var hint = h('p', { class: 'wku-hinweis', 'aria-live': 'polite' })
var monate = h('div', { class: 'wku-monate' })
kal.appendChild(h('h2', { class: 'wku-titel', text: 'Zeitraum wählen' }))
kal.appendChild(monate)
kal.appendChild(h('div', { class: 'wku-legende' }, [h('span', { text: 'Grau durchgestrichen: belegt' }), h('span', { text: 'Umrandet: heute' })]))
kal.appendChild(zus)
kal.appendChild(hint)
haupt.appendChild(kal)
haupt.appendChild(formBox)
root.appendChild(haupt)
el.textContent = ''
el.appendChild(root)
/* ---- Formular ---- */
var feld = {}
var fehlerBox = h('p', { class: 'wku-fehler', role: 'alert', hidden: true })
var form = h('form', { class: 'wku-form', novalidate: true })
var selU = null
var selP = h('select', { class: 'wku-eingabe', id: id + 'personen', name: 'personen' })
function eingabe(name, label, opt) {
opt = opt || {}
var inp = h(opt.mehrzeilig ? 'textarea' : 'input', { class: 'wku-eingabe', id: id + name, name: name, type: opt.mehrzeilig ? null : (opt.type || 'text'), autocomplete: opt.auto || 'off', maxlength: opt.max || 200, required: opt.pflicht ? true : null, rows: opt.mehrzeilig ? 4 : null })
var fe = h('p', { class: 'wku-feldfehler', id: id + name + '-f', hidden: true })
var box = h('div', { class: 'wku-feld' }, [h('label', { class: 'wku-label', for: id + name }, [h('span', { text: label }), opt.pflicht ? h('span', { class: 'wku-stern', 'aria-hidden': 'true', text: ' *' }) : null]), inp, fe])
feld[name] = { inp: inp, fe: fe }
return box
}
function fuellePersonen() {
selP.textContent = ''
var max = Math.max(1, parseInt(unterk.maxPersonen, 10) || 1)
for (var i = 1; i <= max; i += 1) selP.appendChild(h('option', { value: i, text: i === 1 ? '1 Person' : i + ' Personen' }))
}
if (cfg.unterkuenfte.length > 1) {
selU = h('select', { class: 'wku-eingabe', id: id + 'unterkunft', name: 'unterkunft' })
cfg.unterkuenfte.forEach(function (u, i) { selU.appendChild(h('option', { value: i, text: u.name })) })
form.appendChild(h('div', { class: 'wku-feld' }, [h('label', { class: 'wku-label', for: id + 'unterkunft', text: 'Unterkunft' }), selU]))
}
form.appendChild(h('div', { class: 'wku-feld' }, [h('label', { class: 'wku-label', for: id + 'personen', text: 'Personen' }), selP]))
form.appendChild(eingabe('firma', 'Firma (optional)', { auto: 'organization' }))
form.appendChild(eingabe('name', 'Name', { pflicht: true, auto: 'name' }))
form.appendChild(h('div', { class: 'wku-reihe' }, [eingabe('email', 'E-Mail', { pflicht: true, type: 'email', auto: 'email' }), eingabe('telefon', 'Telefon (optional)', { type: 'tel', auto: 'tel', max: 50 })]))
form.appendChild(eingabe('nachricht', 'Nachricht (optional)', { mehrzeilig: true, max: 2000 }))
var hp = h('input', { type: 'text', name: 'kontakt_url', id: id + 'hp', tabindex: '-1', autocomplete: 'off' })
form.appendChild(h('div', { class: 'wku-hp', 'aria-hidden': 'true' }, [hp]))
var ds = h('p', { class: 'wku-datenschutz' })
var anb = cfg.anbieter || {}
ds.appendChild(document.createTextNode('Ihre Angaben werden zur Bearbeitung Ihrer Anfrage verwendet' + (anb.name ? ' (' + anb.name + ')' : '') + '. Mehr dazu in der '))
if (anb.datenschutzUrl && /^https?:\/\//i.test(anb.datenschutzUrl)) ds.appendChild(h('a', { href: anb.datenschutzUrl, target: '_blank', rel: 'noopener noreferrer', text: 'Datenschutzerklärung' }))
else ds.appendChild(document.createTextNode('Datenschutzerklärung'))
ds.appendChild(document.createTextNode('.'))
form.appendChild(ds)
var btn = h('button', { class: 'wku-btn', type: 'submit', text: 'Anfrage senden' })
form.appendChild(fehlerBox)
form.appendChild(btn)
formBox.appendChild(h('h2', { class: 'wku-titel', text: 'Unverbindlich anfragen' }))
formBox.appendChild(form)
fuellePersonen()
function zeigeFehler(text) { fehlerBox.textContent = text || ''; fehlerBox.hidden = !text }
function feldFehler(name, text) {
var f = feld[name]
if (!f) return false
f.fe.textContent = text || ''
f.fe.hidden = !text
if (text) { f.inp.setAttribute('aria-invalid', 'true'); f.inp.setAttribute('aria-describedby', f.fe.id) } else { f.inp.removeAttribute('aria-invalid'); f.inp.removeAttribute('aria-describedby') }
return true
}
/* ---- Kalender ---- */
function monatKey(j, m) { return j * 12 + (m - 1) }
function zweiSichtbar() { return (root.offsetWidth || root.clientWidth || 0) >= 1100 }
function aktualisiereZus() {
if (sel.von && sel.bis) { zus.textContent = WKU_LOGIK.zusammenfassung(sel.von, sel.bis); hint.textContent = '' }
else if (sel.von) { zus.textContent = 'Anreise ' + langesDatum(sel.von) + '. Bitte Abreise wählen.'; hint.textContent = hinweis }
else { zus.textContent = ''; hint.textContent = '' }
if (hinweis && !sel.bis) hint.textContent = hinweis
}
function tageBisFrei(von, bis) {
for (var d = von; d < bis; d = plusTage(d, 1)) if (WKU_LOGIK.nachtBelegt(d, belegt)) return false
return true
}
function tagKnopf(tag) {
var p = zerlege(tag)
var vergangen = tag < heute
var zu = tag > bisMax
var nb = WKU_LOGIK.nachtBelegt(tag, belegt)
var kl = ['wku-tag']
if (tag === heute) kl.push('wku-heute')
if (vergangen) kl.push('wku-vergangen')
if (zu) kl.push('wku-zu')
if (nb && !vergangen && !zu) kl.push('wku-belegt')
var istAn = sel.von === tag
var istAb = sel.bis === tag
if (istAn && sel.bis) kl.push('wku-an')
else if (istAn) kl.push('wku-an', 'wku-ab')
if (istAb) kl.push('wku-ab')
if (sel.von && sel.bis && tag > sel.von && tag < sel.bis) kl.push('wku-bereich')
var status = (nb ? (sel.von && !sel.bis && tag > sel.von && !vergangen && !zu && !WKU_LOGIK.nachtBelegt(sel.von, belegt) && tageBisFrei(sel.von, tag) ? 'belegt – als Abreisetag möglich' : 'belegt') : 'frei') + (istAn ? ', Anreise' : '') + (istAb ? ', Abreise' : '')
var b = h('button', { class: kl.join(' '), type: 'button', 'data-tag': tag, 'aria-label': langesDatum(tag) + ', ' + status, 'aria-pressed': (istAn || istAb) ? 'true' : 'false', tabindex: tag === fokusTag ? '0' : '-1' }, [h('span', { text: String(p[2]) })])
if (vergangen || zu) { b.setAttribute('aria-disabled', 'true') }
b.addEventListener('click', function () { tagKlick(tag) })
return b
}
function monatBauen(j, m, erster) {
var kopf = h('div', { class: 'wku-monat-kopf' })
var min = monatKey(heute.slice(0, 4) * 1, heute.slice(5, 7) * 1)
var max = monatKey(bisMax.slice(0, 4) * 1, bisMax.slice(5, 7) * 1)
var zur
if (erster) {
zur = h('button', { class: 'wku-pfeil', type: 'button', 'data-nav': 'zur', 'aria-label': 'Vorheriger Monat', text: '‹' })
if (monatKey(j, m) <= min) zur.setAttribute('disabled', '')
zur.addEventListener('click', function () { blaettern(-1) })
kopf.appendChild(zur)
} else kopf.appendChild(h('span', { class: 'wku-pfeil-leer' }))
kopf.appendChild(h('h3', { class: 'wku-monat-name', text: MONAT_LANG[m - 1] + ' ' + j }))
// Erster Monat: immer ein echter Weiter-Knopf (bei zwei sichtbaren Monaten per CSS ausgeblendet, dann sitzt er im zweiten Monat).
var vor = h('button', { class: 'wku-pfeil' + (erster ? ' wku-weiter-ein' : ''), type: 'button', 'data-nav': erster ? 'vor1' : 'vor2', 'aria-label': 'Nächster Monat', text: '›' })
if (monatKey(j, m) >= max) vor.setAttribute('disabled', '')
vor.addEventListener('click', function () { blaettern(1) })
kopf.appendChild(vor)
var grid = h('div', { class: 'wku-raster', role: 'group', 'aria-label': MONAT_LANG[m - 1] + ' ' + j })
WOCHE_KURZ.forEach(function (w, i) { grid.appendChild(h('div', { class: 'wku-wt', 'aria-hidden': 'true', text: w, title: WOCHE_LANG[i] })) })
WKU_LOGIK.monatsRaster(j, m).forEach(function (z) { grid.appendChild(z.tag ? tagKnopf(z.tag) : h('div', { class: 'wku-leer' })) })
return h('div', { class: 'wku-monat' + (erster ? '' : ' wku-zwei') }, [kopf, grid])
}
function zeichne(fokus) {
if (!heute) return
var a = ansicht
var b = a.m === 12 ? { j: a.j + 1, m: 1 } : { j: a.j, m: a.m + 1 }
monate.textContent = ''
monate.appendChild(monatBauen(a.j, a.m, true))
monate.appendChild(monatBauen(b.j, b.m, false))
aktualisiereZus()
if (fokus && fokusTag) {
var k = monate.querySelector('[data-tag="' + fokusTag + '"]')
if (k && k.focus) k.focus()
}
}
function blaettern(d) {
var m = ansicht.m + d
var j = ansicht.j
if (m < 1) { m = 12; j -= 1 } else if (m > 12) { m = 1; j += 1 }
var min = monatKey(heute.slice(0, 4) * 1, heute.slice(5, 7) * 1)
var max = monatKey(bisMax.slice(0, 4) * 1, bisMax.slice(5, 7) * 1)
if (monatKey(j, m) < min || monatKey(j, m) > max) return
ansicht = { j: j, m: m }
var erster = iso(j, m, 1)
fokusTag = erster < heute ? heute : erster
zeichne(false)
var zwei = zweiSichtbar()
var wunsch = d > 0 ? (zwei ? 'vor2' : 'vor1') : 'zur'
var knopf = monate.querySelector('[data-nav="' + wunsch + '"]')
if (!knopf || knopf.hasAttribute('disabled')) knopf = monate.querySelector(d > 0 ? '[data-nav="zur"]' : (zwei ? '[data-nav="vor2"]' : '[data-nav="vor1"]'))
if (knopf && knopf.focus) knopf.focus()
}
function tagKlick(tag) {
if (tag < heute || tag > bisMax) return
var neu = WKU_LOGIK.waehle(sel, tag, belegt, parseInt(unterk.mindestNaechte, 10) || 1)
hinweis = neu.fehler === 'mindestnaechte' ? 'Mindestaufenthalt: ' + (unterk.mindestNaechte === 1 ? '1 Nacht' : unterk.mindestNaechte + ' Nächte') + '.' : ''
sel = { von: neu.von || null, bis: neu.bis || null }
fokusTag = tag
zeichne(true)
}
monate.addEventListener('keydown', function (ev) {
var t = ev.target && ev.target.getAttribute ? ev.target.getAttribute('data-tag') : null
if (!t) return
var d = ev.key === 'ArrowRight' ? 1 : ev.key === 'ArrowLeft' ? -1 : ev.key === 'ArrowDown' ? 7 : ev.key === 'ArrowUp' ? -7 : 0
if (!d) return
ev.preventDefault()
var ziel = plusTage(t, d)
if (ziel < heute || ziel > bisMax) return
fokusTag = ziel
var p = zerlege(ziel)
var sichtbar = monatKey(p[0], p[1]) >= monatKey(ansicht.j, ansicht.m) && monatKey(p[0], p[1]) <= monatKey(ansicht.j, ansicht.m) + (zweiSichtbar() ? 1 : 0)
if (!sichtbar) ansicht = { j: p[0], m: p[1] }
zeichne(true)
})
function pad(n) { return zwei(n) }
var ladeNr = 0
function laden() {
var nr = (ladeNr += 1)
var jetzt = new Date()
var von = jetzt.getUTCFullYear() + '-' + pad(jetzt.getUTCMonth() + 1) + '-' + pad(jetzt.getUTCDate())
von = plusTage(von, -1)
var bis = plusTage(von, 600)
return holen(basis + '/belegung?unterkunft=' + encodeURIComponent(unterk.id) + '&von=' + von + '&bis=' + bis, { headers: { Accept: 'application/json' } }).then(function (r) {
if (nr !== ladeNr) return
if (!r.ok || !r.body.heute || !r.body.bisMax) throw new Error('belegung')
belegt = r.body.belegt || []
heute = r.body.heute
bisMax = r.body.bisMax
if (!ansicht) { var p = zerlege(heute); ansicht = { j: p[0], m: p[1] } }
if (!fokusTag) fokusTag = heute
sel = { von: null, bis: null }
hinweis = ''
zeichne(false)
})
}
function ladenOderAusweichen() { laden().then(null, function () { ausweich(el) }) }
if (selU) selU.addEventListener('change', function () {
unterk = cfg.unterkuenfte[parseInt(selU.value, 10) || 0]
fuellePersonen()
ladenOderAusweichen()
})
form.addEventListener('submit', function (ev) {
ev.preventDefault()
if (sendet) return
zeigeFehler('')
Object.keys(feld).forEach(function (k) { feldFehler(k, '') })
if (!sel.von || !sel.bis) { zeigeFehler('Bitte wählen Sie Anreise und Abreise im Kalender.'); return }
var wert = function (n) { return String(feld[n].inp.value || '').trim() }
var fehler = 0
if (!wert('name')) { feldFehler('name', 'Bitte geben Sie Ihren Namen an.'); fehler += 1 }
if (!EMAIL_FORM.test(wert('email'))) { feldFehler('email', 'Bitte geben Sie eine gültige E-Mail-Adresse an.'); fehler += 1 }
if (fehler) { var ersteFehl = form.querySelector('[aria-invalid="true"]'); if (ersteFehl && ersteFehl.focus) ersteFehl.focus(); return }
sendet = true
btn.setAttribute('disabled', '')
var body = { unterkunft: unterk.id, von: sel.von, bis: sel.bis, personen: parseInt(selP.value, 10) || 1, name: wert('name'), firma: wert('firma'), email: wert('email'), telefon: wert('telefon'), nachricht: wert('nachricht'), formToken: token, kontakt_url: hp.value }
holen(basis + '/anfrage', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body) }).then(function (r) {
sendet = false
btn.removeAttribute('disabled')
if (r.ok && r.body.nummer) {
formBox.textContent = ''
formBox.appendChild(h('p', { class: 'wku-danke', role: 'status', tabindex: '-1', text: 'Danke! Ihre Anfrage ' + r.body.nummer + ' ist eingegangen. Sie erhalten gleich eine Bestätigung per E-Mail.' }))
var d = formBox.querySelector('.wku-danke'); if (d && d.focus) d.focus()
laden().then(null, function () {})
return
}
var b = r.body || {}
var gezeigt = false
var uebrig = []
if (b.felder) Object.keys(b.felder).forEach(function (k) {
if (!Object.prototype.hasOwnProperty.call(b.felder, k)) return
if (feldFehler(k, String(b.felder[k]))) gezeigt = true
else if (k === 'zeitraum' || k === 'personen') uebrig.push(String(b.felder[k]))
})
if (uebrig.length) { zeigeFehler(uebrig.join(' ')); gezeigt = true }
var fehlerFokus = form.querySelector('[aria-invalid="true"]')
if (fehlerFokus && fehlerFokus.focus) fehlerFokus.focus()
if (gezeigt) return
if (b.fehler === 'neu_laden') {
zeigeFehler('Das Formular war zu lange geöffnet. Bitte senden Sie Ihre Anfrage noch einmal ab.')
holen(basis, { headers: { Accept: 'application/json' } }).then(function (c) { if (c.ok && c.body.formToken) token = c.body.formToken }, function () {})
return
}
if (b.fehler === 'mindestnaechte') {
var mn = parseInt((b.felder && b.felder.mindestNaechte) || unterk.mindestNaechte, 10) || 1
zeigeFehler('Mindestaufenthalt: ' + (mn === 1 ? '1 Nacht' : mn + ' Nächte') + '. Bitte wählen Sie einen längeren Zeitraum.')
return
}
if (b.fehler === 'belegt' || r.status === 409) { zeigeFehler('Dieser Zeitraum ist leider nicht mehr frei. Bitte wählen Sie einen anderen.'); laden().then(null, function () {}) }
else if (r.status === 429) zeigeFehler('Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.')
else {
zeigeFehler('Die Anfrage konnte nicht gesendet werden. Bitte versuchen Sie es erneut oder melden Sie sich per Telefon oder E-Mail.')
holen(basis, { headers: { Accept: 'application/json' } }).then(function (c) { if (c.ok && c.body.formToken) token = c.body.formToken }, function () {})
}
}, function () {
sendet = false
btn.removeAttribute('disabled')
zeigeFehler('Die Anfrage konnte nicht gesendet werden. Bitte versuchen Sie es erneut oder melden Sie sich per Telefon oder E-Mail.')
})
})
ladenOderAusweichen()
}
function alleStarten() {
var liste = document.querySelectorAll('[data-webklar-unterkunft]')
for (var i = 0; i < liste.length; i += 1) starten(liste[i])
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', alleStarten)
else alleStarten()
})()
+52 -1
View File
@@ -260,6 +260,51 @@ function shopTile(projects, features) {
</article>`
}
/* Buchungskalender (Schluessel unterkunft): Kachel nur, wenn der Server die Funktion freigibt (unterkunft.verfuegbar der Antwort, Schalter
UNTERKUNFT_ENABLED); sonst gar keine Kachel. Die Ansicht #buchungen (buchungen.js) zeigt Anfrage und Stand; die Kachel kennt nur den Zustand
des Funktionsdokuments (projects[].features.unterkunft: off | ordered | setup | active | disabled), Preis aus features.unterkunft.priceLabel,
Rueckfall 199 €. */
const UNTERKUNFT_PREIS = '199 €'
const UNTERKUNFT_TEXT = 'Belegungskalender auf deiner Website, Anfragen freigeben, Rechnung und Online-Zahlung'
const unterkunftOf = (project) => project.features?.unterkunft || { state: 'off' }
function pickUnterkunftProject(projects) {
return projects.find((p) => unterkunftOf(p).state === 'active')
|| projects.find((p) => isSetup(unterkunftOf(p).state))
|| projects.find((p) => unterkunftOf(p).state === 'disabled')
|| projects[0]
}
function unterkunftTile(projects, features) {
const preis = textOr(features?.unterkunft?.priceLabel, UNTERKUNFT_PREIS)
const active = projects.filter((p) => unterkunftOf(p).state === 'active').length
const target = pickUnterkunftProject(projects)
const state = target ? unterkunftOf(target).state : 'off'
const knopf = (text) => `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="buchungen" data-project="${esc(target.projectId)}">${text}</button>`
const ZUSTAENDE = {
off: [['Aus', 'off'], 'Buchungskalender anfragen'],
ordered: [['Zahlung offen', 'setup'], 'Zahlung offen'],
setup: [['In Einrichtung', 'setup'], 'In Einrichtung'],
active: [['Aktiv', 'on'], 'Buchungskalender öffnen'],
disabled: [['Abgeschaltet', 'off'], 'Buchungskalender ansehen'],
}
const [pill, knopfText] = ZUSTAENDE[state] || ZUSTAENDE.off
const action = target ? knopf(knopfText) : ''
let extra = ''
if (!target) extra = '<p class="fn-count">Sobald deine Website angelegt ist, kannst du den Buchungskalender anfragen.</p>'
else if (state === 'disabled') extra = '<p>Der Buchungskalender ist für diese Website abgeschaltet. Deine Buchungen kannst du noch ansehen.</p>'
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
return `
<article class="fn-tile" data-fn-tile="unterkunft">
<div class="fn-tile-head">${svg('calendar')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
<h3>Buchungskalender</h3>
<p>${UNTERKUNFT_TEXT}. ${esc(preis)} einmalig.</p>
${extra}
${count}
${action}
</article>`
}
/* Oeffnungszeiten live: Kachel nur, wenn der Schalter es erlaubt (zeiten.verfuegbar); sonst gibt es sie gar nicht (auch kein "Kommt bald").
Wie bei den anderen Funktionen steht sie inline, die Ansicht #zeiten (zeiten.js) laedt app.js. Alles, was sie zeigt, kommt mit der Liste
(GET /api/funktionen): zeiten (Angebot) mit inklusive (true: im laufenden, aktiven Wartungs-Abo enthalten, einschaltbar | false: sonst, dann
@@ -386,12 +431,13 @@ const soonTile = (icon, title, text) => `
export async function initFunktionen(root) {
if (!root) return
const { projects = [], termine, features, shop, zeiten } = await request('/api/funktionen')
const { projects = [], termine, features, shop, zeiten, unterkunft } = await request('/api/funktionen')
root.innerHTML = `
<div class="fn-tiles">
${newsTile(projects, newsOffer(features).price)}
${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
${shop?.verfuegbar ? shopTile(projects, features) : ''}
${unterkunft?.verfuegbar === true ? unterkunftTile(projects, features) : ''}
${zeiten?.verfuegbar === true ? zeitenTile(projects, zeiten) : ''}
${analyseTile(projects)}
</div>`
@@ -407,6 +453,11 @@ export async function initFunktionen(root) {
const body = document.getElementById('webshop-body')
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
})
// Die Buchungskalender-Ansicht liest das gewaehlte Projekt aus ihrem Behaelter (buchungen.js, initBuchungen)
root.querySelector('[data-fn-tile="unterkunft"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => {
const body = document.getElementById('buchungen-body')
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
})
// Die Zeiten-Ansicht liest Projekt und Weg (Behalten) aus ihrem Behaelter (zeiten.js, initZeiten); der Klick steigt danach zum Navigationshandler auf
root.querySelector('[data-fn-tile="zeiten"]')?.addEventListener('click', (ev) => {
const knopf = ev.target?.closest?.('button[data-view="zeiten"]')
@@ -0,0 +1,212 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="webklar-fassung" content="2026-10-07">
<title>Auftragsverarbeitungsvertrag Buchungskalender – WEBklar</title>
<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 80px; }
.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; }
h3 { font-size: 16.5px; margin: 22px 0 6px; }
p, li { margin: 6px 0; }
ul { padding-left: 24px; }
.meta { color: #6b6b70; font-size: 14px; }
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
.box.widerspruch { border: 2px solid #6d1fd1; }
table { border-collapse: collapse; width: 100%; font-size: 15px; }
td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b6b70; }
a { color: #6d1fd1; }
nav.top { font-size: 14px; margin-bottom: 22px; }
.offen { background: #fff3cd; padding: 0 4px; border-radius: 4px; }
@media print {
@page { size: A4; margin: 18mm 17mm 18mm;
@top-left { content: "WEBklar · AVV Buchungskalender"; font: 8pt sans-serif; color: #888; }
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } }
body { background: #fff; font-size: 10pt; line-height: 1.45; }
main { max-width: none; padding: 0; }
nav.top { display: none; }
h1 { font-size: 20pt; }
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
a { color: inherit; text-decoration: none; }
.box, tr, ol.abs > li { break-inside: avoid; }
}
</style>
</head>
<body>
<main>
<nav class="top"><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/rechtliches/leistungsbeschreibung-unterkunft-2026-10-07.html">Leistungsbeschreibung Buchungskalender</a> · <a href="/rechtliches/avv-unterkunft-2026-10-07.html">AVV Buchungskalender</a> · <a href="/rechtliches/widerrufsbelehrung-funktionen-2026-10-07.html">Widerrufsbelehrung Funktionen</a> · <a href="/datenschutz.html">Datenschutzerklärung</a> · <a href="https://webklar.com/impressum">Impressum</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Auftragsverarbeitungsvertrag Buchungskalender (Art. 28 DSGVO)</h1>
<p class="meta"><strong>Fassung 2026-10-07</strong> · Entwurf – Prüfung durch Kanzlei offen</p>
<p>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“).</p>
<h2>Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen</h2>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<h2>1. Gegenstand und Dauer</h2>
<p>(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.</p>
<p>(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.</p>
<h2>2. Art und Zweck der Verarbeitung</h2>
<p>(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.</p>
<p>(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).</p>
<p>(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.</p>
<h2>3. Datenarten und Betroffene</h2>
<p>(1) Daten der Gäste, die WEBklar aus dem Widget, von der Buchungsseite und aus Stripe erhält und speichert:</p>
<ul>
<li>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;</li>
<li>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;</li>
<li>bei der Buchung: die Rechnungsadresse (Name, Firma, Straße, Postleitzahl, Ort, Land), die Zahlart, der Zeitpunkt der Buchung und die Rechnungsnummer;</li>
<li>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;</li>
<li>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;</li>
<li>ein Hashwert des geheimen Zugangs zur Buchungsseite (der Link in der E-Mail wird nur als Prüfsumme gespeichert);</li>
<li>der Zustellstatus der E-Mails an den Gast.</li>
</ul>
<p><strong>Kartendaten des Gastes erhält und speichert WEBklar nicht;</strong> 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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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 <strong>Hashwert der IP-Adresse („IP-Hash“)</strong>: 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).</p>
<p>(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.</p>
<p>(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.</p>
<h2>4. Weisungen</h2>
<p>(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.</p>
<p>(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).</p>
<p>(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.</p>
<h2>5. Vertraulichkeit</h2>
<p>WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.</p>
<h2>6. Technische und organisatorische Maßnahmen</h2>
<p>(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.</p>
<p>(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.</p>
<h2>7. Unterauftragsverarbeiter</h2>
<p>(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.</p>
<p>(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).</p>
<p>(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).</p>
<p>(4) <strong>Stripe ist kein Unterauftragsverarbeiter von WEBklar.</strong> 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 <strong>geheime Link zur Buchungsseite mit dem Token als Rücksprungadresse</strong> (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.</p>
<p>(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.</p>
<h2>8. Unterstützung des Kunden, Betroffenenrechte</h2>
<p>(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.</p>
<p>(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).</p>
<p>(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.</p>
<p>(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.</p>
<p>(5) Die Gäste informiert der Kunde (Art. 13 DSGVO; Nummer 11 Abs. 2).</p>
<h2>9. Löschung und Rückgabe</h2>
<p>(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.</p>
<p>(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.</p>
<p>(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). <strong>Rechnungen, Stornorechnungen und die Rechnungsadresse bleiben 8 Jahre erhalten</strong>, 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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<h2>10. Nachweise und Kontrollen</h2>
<p>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.</p>
<h2>11. Pflichten des Kunden</h2>
<p>(1) Der Kunde ist Verantwortlicher für die Verarbeitung der Daten seiner Gäste im Buchungskalender.</p>
<p>(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.</p>
<p>(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).</p>
<p>(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.</p>
<p>(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).</p>
<p>(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).</p>
<h2>12. Haftung, Änderungen, Schlussbestimmungen</h2>
<p>(1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen.</p>
<p>(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.</p>
<p>(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.</p>
<p>(4) Im Übrigen gilt § 16 der Abo-Bedingungen.</p>
<h2>Anlage: Technische und organisatorische Maßnahmen des Buchungskalenders (Art. 32 DSGVO)</h2>
<table>
<thead><tr><th>Bereich</th><th>Maßnahmen</th></tr></thead>
<tbody>
<tr><td>Übertragung</td><td>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.</td></tr>
<tr><td>Speicher im Browser</td><td>Es werden keine Cookies gesetzt. Das Widget sendet seine Anfragen ohne Cookies und ruft nur die Schnittstelle des Buchungskalenders auf, keine Dienste Dritter.</td></tr>
<tr><td>Zugriffskontrolle</td><td>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.</td></tr>
<tr><td>Missbrauchsschutz</td><td>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.</td></tr>
<tr><td>Datenminimierung</td><td>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.</td></tr>
<tr><td>Integrität der Rechnungen</td><td>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.</td></tr>
<tr><td>Trennung</td><td>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.</td></tr>
<tr><td>Löschung</td><td>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).</td></tr>
<tr><td>Protokolle</td><td>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).</td></tr>
<tr><td>Datensicherung</td><td>Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen).</td></tr>
<tr><td>Rechenzentrum</td><td>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.</td></tr>
<tr><td>Organisation</td><td>Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nummer 8 Abs. 3), Überprüfung der Maßnahmen mindestens einmal im Jahr.</td></tr>
</tbody>
</table>
</main>
</body>
</html>
Binary file not shown.
@@ -0,0 +1 @@
58ff80e8b4c2a359b817c11a5bb577485cdf6f0881289a1ac694a87938eece02
@@ -0,0 +1,255 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="webklar-fassung" content="2026-10-07">
<title>Leistungsbeschreibung Buchungskalender – WEBklar</title>
<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 80px; }
.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; }
h3 { font-size: 16.5px; margin: 22px 0 6px; }
p, li { margin: 6px 0; }
ul { padding-left: 24px; }
.meta { color: #6b6b70; font-size: 14px; }
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
.box.widerspruch { border: 2px solid #6d1fd1; }
table { border-collapse: collapse; width: 100%; font-size: 15px; }
td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b6b70; }
a { color: #6d1fd1; }
nav.top { font-size: 14px; margin-bottom: 22px; }
.offen { background: #fff3cd; padding: 0 4px; border-radius: 4px; }
@media print {
@page { size: A4; margin: 18mm 17mm 18mm;
@top-left { content: "WEBklar · Leistungsbeschreibung Buchungskalender"; font: 8pt sans-serif; color: #888; }
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } }
body { background: #fff; font-size: 10pt; line-height: 1.45; }
main { max-width: none; padding: 0; }
nav.top { display: none; }
h1 { font-size: 20pt; }
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
a { color: inherit; text-decoration: none; }
.box, tr, ol.abs > li { break-inside: avoid; }
}
</style>
</head>
<body>
<main>
<nav class="top"><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/rechtliches/leistungsbeschreibung-unterkunft-2026-10-07.html">Leistungsbeschreibung Buchungskalender</a> · <a href="/rechtliches/avv-unterkunft-2026-10-07.html">AVV Buchungskalender</a> · <a href="/rechtliches/widerrufsbelehrung-funktionen-2026-10-07.html">Widerrufsbelehrung Funktionen</a> · <a href="/datenschutz.html">Datenschutzerklärung</a> · <a href="https://webklar.com/impressum">Impressum</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Leistungsbeschreibung Buchungskalender</h1>
<p class="meta"><strong>Fassung 2026-10-07</strong> · Entwurf – Prüfung durch Kanzlei offen · Zusatzbedingungen zum Vertrag über den Betrieb der Website</p>
<h2>1. Geltung</h2>
<p>(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.</p>
<p>(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.</p>
<p>(3) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für den Buchungskalender diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Buchungskalender.</p>
<p>(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.</p>
<h2>2. Leistung</h2>
<p>(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.</p>
<p>(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.</p>
<p>(3) So läuft eine Buchung ab:</p>
<ul>
<li>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 <strong>unverbindliche Anfrage</strong>. Er erhält eine Eingangsbestätigung, der Vermieter eine Benachrichtigung.</li>
<li>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.</li>
<li>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.</li>
<li>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.</li>
<li>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.</li>
<li>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.</li>
</ul>
<p>(4) Im Kundenportal verwaltet der Vermieter:</p>
<ul>
<li>Unterkünfte mit Beschreibung, Höchstzahl der Personen, Mindestnächten und Check-in- und Check-out-Zeiten,</li>
<li>Preise (Staffel nach Aufenthaltsdauer, Endreinigung, Zusatzpersonen, Direktbuchungsrabatt, Steuerart und Steuersätze) und seine Stornobedingungen,</li>
<li>Anfragen und Buchungen mit Status, die Eintragung „bezahlt (vor Ort)“ und Stornierungen,</li>
<li>Kalender mit manuellen Sperren,</li>
<li>die Verbindungen zu anderen Kalendern (Nr. 4),</li>
<li>seine Rechnungen als PDF,</li>
<li>seine Angaben als Aussteller der Rechnungen (Name, Anschrift, Steuernummer oder Umsatzsteuer-Identifikationsnummer, Bankverbindung, Rechnungspräfix) und die Verbindung mit seinem Stripe-Konto (Nr. 3).</li>
</ul>
<p>(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.</p>
<p>(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).</p>
<p>(7) Nicht enthalten sind:</p>
<ul>
<li>der Meldeschein nach dem Bundesmeldegesetz und die Verwaltung der Meldedaten,</li>
<li>Kurtaxe, Gästebeitrag und Beherbergungsabgaben,</li>
<li>Anzahlungen und Teilzahlungen (der Gast zahlt den Gesamtbetrag in einer Zahlung, online oder vor Ort),</li>
<li>Sofortbuchung ohne Freigabe,</li>
<li>saisonale Preise (die Preise richten sich nur nach der Aufenthaltsdauer),</li>
<li>Gutscheine, Channel-Manager-Anbindungen außer dem Kalenderabgleich nach Nr. 4 und Mehrsprachigkeit (der Buchungskalender ist deutschsprachig),</li>
<li>die Buchführung des Vermieters.</li>
</ul>
<p>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).</p>
<h2>3. Zahlungen über Stripe und Rechnungen im Namen des Vermieters</h2>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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. <strong>Automatische Erstattung:</strong> 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.</p>
<p>(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.</p>
<p>(6) <strong>Rechnungen im Namen des Vermieters.</strong> 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.</p>
<p>(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.</p>
<h2>4. Verfügbarkeit und Kalenderabgleich (iCal)</h2>
<p>(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.</p>
<p>(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. <strong>Zwischen einer Buchung hier und ihrem Eingang dort können deshalb Minuten bis Stunden vergehen, und umgekehrt.</strong> WEBklar sagt keine Echtzeit-Verfügbarkeit zu und übernimmt keine Gewähr dafür, dass eine Unterkunft nicht doppelt gebucht wird.</p>
<p>(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.</p>
<p>(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.</p>
<h2>5. Pflichten des Vermieters</h2>
<p>(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.</p>
<p>(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.</p>
<p>(3) Er legt vor der Freischaltung seine Stornobedingungen fest und verantwortet sie. Der Muster-Text nach Nr. 6 ist nur ein Vorschlag.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<h2>6. Muster-Text zur Stornierung, keine Rechtsberatung</h2>
<p>(1) WEBklar stellt dem Vermieter einen Muster-Text für Stornobedingungen bereit. Er ist eine <strong>Vorlage ohne Gewähr</strong> 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.</p>
<p>(2) WEBklar empfiehlt, die Stornobedingungen und die Angaben zum Angebot vor der Freischaltung von einer Rechtsanwältin oder einem Rechtsanwalt prüfen zu lassen.</p>
<p>(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.</p>
<h2>7. Kein Widerrufsrecht des Gastes, Hinweise auf der Buchungsseite</h2>
<p>(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.</p>
<p>(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).</p>
<p>(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.</p>
<h2>8. Verfügbarkeit und Störungen</h2>
<p>(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).</p>
<p>(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.</p>
<p>(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.</p>
<p>(4) Alle Zeiten gelten in der Zeitzone Europe/Berlin.</p>
<p>(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.</p>
<p>(6) Die gesetzlichen Rechte des Vermieters sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt.</p>
<h2>9. Preis, Abrechnung und Widerruf</h2>
<p>(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).</p>
<p>(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.</p>
<p>(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.</p>
<p>(4) Der Preis steht neben den Leistungen eines Abos: Die Einrichtung wird weder auf ein Stundenkontingent angerechnet noch damit bezahlt.</p>
<p>(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.</p>
<p>(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.</p>
<h2>10. Laufzeit und Ende</h2>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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).</p>
<p>(6) Endet der Vertrag über den Betrieb der Website, bevor WEBklar den Buchungskalender freigeschaltet hat, erstattet WEBklar den gezahlten Einrichtungspreis vollständig.</p>
<p>(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.</p>
<h2>11. Haftung</h2>
<p>(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.</p>
<p>(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.</p>
<p>(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).</p>
<p>(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.</p>
<p>(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.</p>
<h2>12. Änderungen und Einstellung</h2>
<p>(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.</p>
<p>(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</p>
<ul>
<li>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),</li>
<li>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</li>
<li>eine Sicherheitslücke sich nicht anders beheben lässt.</li>
</ul>
<p>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.</p>
<p>(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.</p>
<p>(4) Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.</p>
<h2>13. Hinweise</h2>
<p>(1) <strong>Keine Rechtsberatung.</strong> 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.</p>
<p>(2) <strong>Barrierefreiheit.</strong> 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.</p>
</main>
</body>
</html>
@@ -0,0 +1 @@
6cb29a60b2747ee9fd5011276b03603c0d2378afcf498459cb986058565df52a
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="webklar-fassung" content="2026-10-07">
<title>Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen) – WEBklar</title>
<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 80px; }
.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; }
h3 { font-size: 16.5px; margin: 22px 0 6px; }
p, li { margin: 6px 0; }
ul { padding-left: 24px; }
.meta { color: #6b6b70; font-size: 14px; }
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
.box.widerspruch { border: 2px solid #6d1fd1; }
table { border-collapse: collapse; width: 100%; font-size: 15px; }
td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b6b70; }
a { color: #6d1fd1; }
nav.top { font-size: 14px; margin-bottom: 22px; }
.offen { background: #fff3cd; padding: 0 4px; border-radius: 4px; }
@media print {
@page { size: A4; margin: 18mm 17mm 18mm;
@top-left { content: "WEBklar · Widerrufsbelehrung Funktionen"; font: 8pt sans-serif; color: #888; }
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } }
body { background: #fff; font-size: 10pt; line-height: 1.45; }
main { max-width: none; padding: 0; }
nav.top { display: none; }
h1 { font-size: 20pt; }
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
a { color: inherit; text-decoration: none; }
.box, tr, ol.abs > li { break-inside: avoid; }
}
</style>
</head>
<body>
<main>
<nav class="top"><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/rechtliches/leistungsbeschreibung-unterkunft-2026-10-07.html">Leistungsbeschreibung Buchungskalender</a> · <a href="/rechtliches/avv-unterkunft-2026-10-07.html">AVV Buchungskalender</a> · <a href="/rechtliches/widerrufsbelehrung-funktionen-2026-10-07.html">Widerrufsbelehrung Funktionen</a> · <a href="/datenschutz.html">Datenschutzerklärung</a> · <a href="https://webklar.com/impressum">Impressum</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)</h1>
<p class="meta"><strong>Fassung 2026-10-07</strong></p>
<p>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.</p>
<h2>Widerrufsbelehrung</h2>
<h3>Widerrufsrecht</h3>
<p>Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.</p>
<p>Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.</p>
<p>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.</p>
<p>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.</p>
<p>Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.</p>
<h3>Folgen des Widerrufs</h3>
<p>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.</p>
<p>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.</p>
<h2>Muster-Widerrufsformular</h2>
<p>(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)</p>
<p>– An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:</p>
<p>– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)</p>
<p>– Bestellt am (*)/erhalten am (*)</p>
<p>– Name des/der Verbraucher(s)</p>
<p>– Anschrift des/der Verbraucher(s)</p>
<p>– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)</p>
<p>– Datum</p>
<p>(*) Unzutreffendes streichen.</p>
<h2>Hinweise zum Widerruf</h2>
<p>Diese Hinweise gehören nicht zur gesetzlichen Widerrufsbelehrung. Sie erklären, wie sich das Widerrufsrecht auf die Bestellung einer Funktion auswirkt.</p>
<ul>
<li>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.</li>
<li>Widerruft der Kunde die Einrichtung einer Funktion, endet diese Funktion. Das Wartungs-Abo bleibt davon unberührt.</li>
<li>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.</li>
<li>Die Rückzahlung erfolgt über das ursprüngliche Zahlungsmittel; die Zahlung lief über Stripe.</li>
</ul>
</main>
</body>
</html>
@@ -0,0 +1 @@
32e53411e95d65d8707b9ef80090c69db0a435a4a3e38f09a7b9d20eb02529d4
+1
View File
@@ -10,6 +10,7 @@
/* -------------------------------------------------- Hinweise und Meldungen --- */
.sh-box { display: flex; align-items: center; justify-content: space-between; gap: 10px 16px; flex-wrap: wrap; margin-bottom: 16px; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; }
.sh-box[hidden] { display: none; }
.sh-box-warn { background: rgba(251, 191, 36, 0.12); border: 1px solid rgba(251, 191, 36, 0.45); }
.sh-box-info { background: var(--kb-purple-soft); border: 1px solid var(--kb-purple-line); }
.sh-hinweise:empty { display: none; }
+30
View File
@@ -0,0 +1,30 @@
:root{--uk-akzent:#0f766e;--uk-akzent-text:#fff}
*{box-sizing:border-box}
body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:#f6f7f9;color:#1f2937;line-height:1.5}
.uk-seite{max-width:760px;margin:0 auto;padding:24px 16px 56px}
.uk-ausweich{margin:0;padding:16px;color:#4b5563}
.uk-karte{background:#fff;border:1px solid #e5e7eb;border-top:4px solid var(--uk-akzent);border-radius:10px;padding:20px 20px 24px;margin:0 0 16px}
.uk-karte h1{font-size:1.4rem;margin:0 0 8px}
.uk-karte h2{font-size:1.05rem;margin:20px 0 8px}
.uk-hinweis{margin:0 0 16px;padding:10px 14px;border-radius:8px;background:#fef3c7;color:#78350f}
.uk-fehler{margin:12px 0 0;padding:10px 14px;border-radius:8px;background:#fee2e2;color:#7f1d1d}
.uk-feldfehler{display:block;color:#b91c1c;font-size:.85rem}
.uk-feld{display:block;margin:0 0 12px}
.uk-feld span.uk-bez{display:block;font-size:.9rem;font-weight:600;margin-bottom:4px}
.uk-feld input{width:100%;padding:10px 12px;border:1px solid #cbd5e1;border-radius:8px;font:inherit;background:#fff}
.uk-feld input:focus-visible,.uk-knopf:focus-visible,.uk-zahlart input:focus-visible,.uk-link:focus-visible{outline:3px solid var(--uk-akzent);outline-offset:2px}
.uk-zeile2{display:grid;grid-template-columns:1fr 2fr;gap:12px}
.uk-zahlart-gruppe{border:0;margin:0 0 12px;padding:0;min-width:0}.uk-zahlart-gruppe legend{font-size:1.1rem;font-weight:700;padding:0;margin:0 0 8px}
.uk-feld input[aria-invalid="true"]{border-color:#b91c1c;border-width:2px}
.uk-zahlart{display:flex;gap:10px;align-items:flex-start;margin:0 0 8px}
.uk-zahlart input{margin-top:5px}
.uk-pflicht{margin:20px 0 16px;padding:14px 16px;border:1px solid #e5e7eb;border-radius:8px;background:#f9fafb;font-size:.92rem}
.uk-pflicht h2{margin:0 0 10px}
.uk-pflicht p{margin:0 0 10px}
.uk-pflicht ul{list-style:none;margin:0 0 10px;padding:0}
.uk-pflicht li{display:flex;justify-content:space-between;gap:12px;padding:2px 0}
.uk-gesamt{font-weight:700;font-size:1.05rem}
.uk-knopf{display:block;width:100%;padding:14px 18px;border:0;border-radius:8px;background:var(--uk-akzent);color:var(--uk-akzent-text);font:inherit;font-weight:700;cursor:pointer;text-align:center;text-decoration:none}
.uk-knopf[disabled]{opacity:.6;cursor:default}
.uk-link{display:inline-block;margin:8px 0;color:var(--uk-akzent);font-weight:600}
@media (max-width:520px){.uk-zeile2{grid-template-columns:1fr}.uk-karte{padding:16px}}
+312
View File
@@ -0,0 +1,312 @@
/* Buchungskalender, Standalone-Seiten fuer Gaeste (Task 17).
* Ansicht "widget": setzt data-webklar-unterkunft auf den Container, public/embed/unterkunft.js (danach geladen) baut das Widget.
* Ansicht "buchung": /unterkunft/<siteKey>/buchung/<id>?t=<token> - Buchung pruefen und "Zahlungspflichtig buchen".
* Der Token (?t=) wird nur an die Schnittstelle gesendet, nie geloggt oder anderswohin geschickt. Alle Texte per textContent. */
(function () {
'use strict'
var API = '/api/public/unterkunft/v1/'
var app = document.getElementById('uk-app')
if (!app) return
var siteKey = app.getAttribute('data-sitekey') || ''
if (app.getAttribute('data-ansicht') !== 'buchung') {
app.textContent = ''
app.setAttribute('data-webklar-unterkunft', siteKey)
return
}
var teile = String(window.location.pathname || '').split('/')
var id = decodeURIComponent(teile[teile.length - 1] || '')
var params = new URLSearchParams(window.location.search || '')
var token = params.get('t') || ''
var zahlungParam = params.get('wk_zahlung') || ''
var basis = API + encodeURIComponent(siteKey) + '/buchung/' + encodeURIComponent(id)
var tokenQuery = '?t=' + encodeURIComponent(token)
var TXT = {
ungueltig: 'Dieser Link ist ungültig.',
abgelaufen: 'Die Reservierung ist abgelaufen. Bitte fragen Sie erneut an.',
abbruch: 'Zahlung abgebrochen – Sie können jetzt oder vor Ort bezahlen.',
widerruf: 'Ein Widerrufsrecht besteht nicht (§ 312g Abs. 2 Satz 1 Nr. 9 BGB).',
kleinunternehmer: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.',
laden: 'Die Buchung konnte nicht geladen werden. Bitte versuchen Sie es später erneut.',
senden: 'Die Buchung konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.',
belegt: 'Der Zeitraum ist leider inzwischen belegt. Bitte fragen Sie einen anderen Zeitraum an.',
knopf: 'Zahlungspflichtig buchen',
onlineAus: 'Online-Zahlung ist gerade nicht möglich – bitte wählen Sie „Vor Ort bezahlen“.',
nichtMoeglich: 'Die Buchung ist gerade nicht möglich. Bitte wenden Sie sich direkt an den Anbieter.',
standGeaendert: 'Der Stand Ihrer Buchung hat sich geändert.',
zahlungStart: 'Die Zahlung konnte nicht gestartet werden. Bitte versuchen Sie es erneut.',
}
function h(tag, attrs, kinder) {
var el = document.createElement(tag)
var a = attrs || {}
Object.keys(a).forEach(function (k) {
if (a[k] === null || a[k] === undefined || a[k] === false) return
if (k === 'text') el.textContent = a[k]
else if (k === 'class') el.className = a[k]
else el.setAttribute(k, a[k] === true ? '' : String(a[k]))
})
;(kinder || []).forEach(function (c) { if (c) el.appendChild(c) })
return el
}
var p = function (text, klasse) { return h('p', { text: text, class: klasse }) }
function euro(cent) {
return (cent / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
}
function datum(iso) {
var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || ''))
return m ? m[3] + '.' + m[2] + '.' + m[1] : String(iso || '')
}
function zeitpunkt(iso) {
var d = new Date(iso)
if (isNaN(d.getTime())) return String(iso || '')
return d.toLocaleString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }).replace(',', '') + ' Uhr'
}
function api(pfad, methode, body) {
var opt = { method: methode || 'GET' }
if (body) { opt.headers = { 'Content-Type': 'application/json' }; opt.body = JSON.stringify(body) }
return fetch(basis + pfad + tokenQuery, opt).then(function (r) {
return r.json().then(function (b) { return { status: r.status, ok: r.ok, body: b || {} } }, function () { return { status: r.status, ok: r.ok, body: {} } })
})
}
function httpsUrl(u) { return typeof u === 'string' && /^https:\/\//i.test(u) ? u : '' }
var meldungNachLaden = ''
function zeige(karte, hinweis) {
app.textContent = ''
var text = [hinweis, meldungNachLaden].filter(Boolean).join(' ')
meldungNachLaden = ''
if (text) app.appendChild(h('p', { text: text, class: 'uk-hinweis', role: 'status' }))
app.appendChild(karte)
}
function nurText(titel, text) {
zeige(h('section', { class: 'uk-karte' }, [h('h1', { text: titel }), p(text)]))
}
function akzent(daten) {
var f = daten && daten.akzentfarbe
if (typeof f !== 'string' || !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(f)) return
var s = f.slice(1)
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2]
var hell = (0.299 * parseInt(s.slice(0, 2), 16) + 0.587 * parseInt(s.slice(2, 4), 16) + 0.114 * parseInt(s.slice(4, 6), 16)) > 160
app.setAttribute('style', '--uk-akzent:' + f + ';--uk-akzent-text:' + (hell ? '#111827' : '#ffffff'))
}
var hinweisAbbruch = function () { return zahlungParam === 'abbruch' ? TXT.abbruch : '' }
function anschrift(a) {
var zeile = [a.strasse, [a.plz, a.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ')
return [a.firma, a.inhaber].filter(Boolean).join(', ') + (zeile ? ', ' + zeile : '')
}
function zahlartenText(d, wahl) {
var z = (d.zahlarten || []).map(function (x) { return x === 'online' ? 'online (Karte, über Stripe)' : 'vor Ort' })
return 'Zahlarten: ' + z.join(' oder ') + (wahl ? ' (gewählt: ' + (wahl === 'online' ? 'online' : 'vor Ort') + ')' : '')
}
/* ------------------------------------------------------------- Pflichtangaben --- */
function pflichtangaben(d, wahl) {
var s = d.summen || {}
var kopf = h('h2', { text: 'Ihre Buchung im Überblick' })
var a = d.anbieter || {}
var kontakt = [a.email, a.telefon].filter(Boolean).join(', ')
var u = d.unterkunft || {}
var posten = h('ul', { 'data-tag': 'posten' }, (d.posten || []).map(function (x) {
var bez = x.menge !== 1 ? x.text + ' (' + x.menge + ' × ' + euro(x.einzelCent) + ')' : x.text
return h('li', {}, [h('span', { text: bez }), h('span', { text: euro(x.menge * x.einzelCent) })])
}))
var gesamt = 'Gesamtpreis: ' + euro(s.bruttoCent || 0)
var ust = []
if (d.steuerart === 'kleinunternehmer') ust.push(TXT.kleinunternehmer)
else (s.ust || []).filter(function (z) { return z.satz > 0 }).forEach(function (z) { ust.push(z.satz + ' % USt ' + euro(z.steuerCent)) })
var preisText = d.steuerart === 'kleinunternehmer' ? gesamt + '. ' + TXT.kleinunternehmer : gesamt + (ust.length ? ' inkl. ' + ust.join(' und ') : ' inkl. USt')
var ds = a.datenschutzUrl && /^https:\/\//.test(a.datenschutzUrl) ? a.datenschutzUrl : ''
var datenschutz = [p('Datenschutz: ' + (a.firma || 'Der Anbieter') + ' verarbeitet Ihre Angaben zur Durchführung der Buchung und für die Rechnung (Art. 6 Abs. 1 lit. b und c DSGVO). Näheres steht in der Datenschutzerklärung des Anbieters.')]
if (ds) datenschutz.push(h('a', { class: 'uk-link', href: ds, target: '_blank', rel: 'noopener noreferrer', text: 'Datenschutzerklärung des Anbieters' }))
return h('section', { class: 'uk-pflicht', 'data-tag': 'pflichtangaben' }, [
kopf,
p('Anbieter: ' + anschrift(a) + (kontakt ? ' · ' + kontakt : ''), 'uk-anbieter'),
p('Unterkunft: ' + (u.name || '') + ', ' + datum(d.von) + ' bis ' + datum(d.bis) + ' (' + d.naechte + ' ' + (d.naechte === 1 ? 'Nacht' : 'Nächte') + '), Check-in ab ' + (u.checkIn || '') + ' Uhr, Check-out bis ' + (u.checkOut || '') + ' Uhr', 'uk-unterkunft'),
p('Personen: ' + d.personen, 'uk-personen'),
posten,
p(preisText, 'uk-gesamt'),
p(zahlartenText(d, wahl), 'uk-zahlarten'),
p('Stornobedingungen: ' + (d.stornobedingungen || ''), 'uk-storno'),
p(TXT.widerruf, 'uk-widerruf'),
h('div', { class: 'uk-datenschutz' }, datenschutz),
p('Reserviert bis ' + zeitpunkt(d.fristAt), 'uk-frist'),
])
}
/* ------------------------------------------------------------------- Formular --- */
function formular(d) {
var ra = d.rechnungsadresse || {}
var g = d.gast || {}
var felder = {}
function feld(name, bez, wert, extra) {
var input = h('input', { type: 'text', name: name, autocomplete: (extra && extra.ac) || 'off', maxlength: 120 })
input.value = wert || ''
felder[name] = input
var fehler = h('span', { class: 'uk-feldfehler', 'data-fehler': name, id: 'uk-fehler-' + name, hidden: true })
return { wurzel: h('label', { class: 'uk-feld' }, [h('span', { class: 'uk-bez', text: bez }), input, fehler]), fehler: fehler }
}
var fName = feld('name', 'Name', ra.name || g.name, { ac: 'name' })
var fFirma = feld('firma', 'Firma (optional)', ra.firma || g.firma, { ac: 'organization' })
var fStrasse = feld('strasse', 'Straße und Hausnummer', ra.strasse, { ac: 'street-address' })
var fPlz = feld('plz', 'PLZ', ra.plz, { ac: 'postal-code' })
var fOrt = feld('ort', 'Ort', ra.ort, { ac: 'address-level2' })
var fLand = feld('land', 'Land', ra.land || 'Deutschland', { ac: 'country-name' })
var alle = { name: fName, firma: fFirma, strasse: fStrasse, plz: fPlz, ort: fOrt, land: fLand }
var arten = (d.zahlarten || []).filter(function (z) { return z === 'vorort' || z === 'online' })
var gewaehlt = arten.indexOf(d.zahlart) >= 0 ? d.zahlart : arten[0]
var radios = arten.map(function (z) {
var r = h('input', { type: 'radio', name: 'zahlart', value: z, id: 'uk-zahlart-' + z })
if (z === gewaehlt) r.checked = true
return h('label', { class: 'uk-zahlart' }, [r, h('span', { text: z === 'online' ? 'Online bezahlen (Karte)' : 'Vor Ort bezahlen' })])
})
var meldung = h('div', { class: 'uk-fehler', role: 'alert', hidden: true })
var knopf = h('button', { type: 'submit', class: 'uk-knopf', text: TXT.knopf })
var form = h('form', { novalidate: true }, [
h('h2', { text: 'Rechnungsadresse' }),
fName.wurzel, fFirma.wurzel, fStrasse.wurzel,
h('div', { class: 'uk-zeile2' }, [fPlz.wurzel, fOrt.wurzel]),
fLand.wurzel,
h('fieldset', { class: 'uk-zahlart-gruppe', 'data-tag': 'zahlart' }, [h('legend', { text: 'Zahlart' })].concat(radios)),
pflichtangaben(d, gewaehlt),
knopf,
meldung,
])
radios.forEach(function (l) {
l.querySelector('input').addEventListener('change', function () {
var z = form.querySelector('.uk-zahlarten')
if (z) z.textContent = zahlartenText(d, waehl())
})
})
function waehl() { var r = radios.map(function (l) { return l.querySelector('input') }).find(function (i) { return i.checked }); return r ? r.value : gewaehlt }
var sendet = false
function fehlerAnzeigen(felderFehler, text) {
Object.keys(alle).forEach(function (k) { markiere(k, '') })
var erstes = null
Object.keys(alle).forEach(function (k) {
if (felderFehler && Object.prototype.hasOwnProperty.call(felderFehler, k)) { markiere(k, String(felderFehler[k])); if (!erstes) erstes = felder[k] }
})
meldung.textContent = text || ''
meldung.hidden = !text
if (erstes && erstes.focus) erstes.focus()
}
function markiere(k, text) {
alle[k].fehler.textContent = text
alle[k].fehler.hidden = !text
if (text) { felder[k].setAttribute('aria-invalid', 'true'); felder[k].setAttribute('aria-describedby', 'uk-fehler-' + k) }
else { felder[k].removeAttribute('aria-invalid'); felder[k].removeAttribute('aria-describedby') }
}
form.addEventListener('submit', function (ev) {
ev.preventDefault()
if (sendet) return
var val = {}
Object.keys(alle).forEach(function (k) { val[k] = String(felder[k].value || '').trim() })
var f = {}
if (!val.name && !val.firma) f.name = 'Bitte geben Sie einen Namen oder eine Firma an.'
if (!val.strasse) f.strasse = 'Bitte geben Sie die Straße an.'
if (!val.plz) f.plz = 'Bitte geben Sie die PLZ an.'
if (!val.ort) f.ort = 'Bitte geben Sie den Ort an.'
if (!val.land) f.land = 'Bitte geben Sie das Land an.'
if (!gewaehlt) return fehlerAnzeigen({}, 'Es ist keine Zahlart verfügbar. Bitte wenden Sie sich an den Anbieter.')
if (Object.keys(f).length) return fehlerAnzeigen(f, 'Bitte prüfen Sie Ihre Angaben.')
fehlerAnzeigen({}, '')
sendet = true
knopf.setAttribute('disabled', '')
var zahlart = waehl()
api('', 'POST', { rechnungsadresse: val, zahlart: zahlart }).then(function (r) {
if (r.ok && r.body.status === 'gebucht') {
if (zahlart === 'online' && httpsUrl(r.body.zahlungUrl)) { window.location.assign(r.body.zahlungUrl); return }
bestaetigt(Object.assign({}, d, { status: 'gebucht', zahlart: zahlart, bezahlt: false, rechnungNummer: r.body.rechnungNummer }))
return
}
if (r.status === 404) return nurText('Buchung', TXT.ungueltig)
if (r.status === 410 || r.body.fehler === 'frist_abgelaufen' || r.body.fehler === 'abgelaufen') return nurText('Buchung', TXT.abgelaufen)
if (r.body.fehler === 'zeitraum_belegt') return nurText('Buchung', TXT.belegt)
var code = r.body.fehler
if (r.status === 409 && (code === 'online_aus' || code === 'einstellungen_unvollstaendig' || code === 'zustimmung_fehlt')) {
sendet = false
knopf.removeAttribute('disabled')
if (code === 'online_aus') {
var vor = radios.map(function (l) { return l.querySelector('input') }).find(function (i) { return i.value === 'vorort' })
if (vor) vor.checked = true
}
return fehlerAnzeigen({}, code === 'online_aus' ? TXT.onlineAus : TXT.nichtMoeglich)
}
if (r.status === 409 || r.ok) { meldungNachLaden = TXT.standGeaendert; laden(); return }
sendet = false
knopf.removeAttribute('disabled')
fehlerAnzeigen(r.body.felder, r.status === 400 && r.body.felder ? 'Bitte prüfen Sie Ihre Angaben.' : TXT.senden)
}, function () {
sendet = false
knopf.removeAttribute('disabled')
fehlerAnzeigen({}, TXT.senden)
})
})
// Fehler verschwinden, sobald der Gast das Feld aendert; die allgemeine Meldung, wenn kein Feldfehler mehr steht
Object.keys(alle).forEach(function (k) {
felder[k].addEventListener('input', function () {
markiere(k, '')
if (!Object.keys(alle).some(function (n) { return !alle[n].fehler.hidden })) { meldung.textContent = ''; meldung.hidden = true }
})
})
return h('section', { class: 'uk-karte' }, [h('h1', { text: 'Ihre Buchung bestätigen' }), p('Hallo ' + (g.name || '') + ', Ihre Anfrage wurde freigegeben. Bitte prüfen Sie die Angaben und buchen Sie verbindlich.'), form])
}
/* --------------------------------------------------------------- Bestaetigung --- */
function bestaetigt(d) {
var kinder = [h('h1', { text: 'Ihre Buchung ist bestätigt' })]
kinder.push(p((d.unterkunft && d.unterkunft.name ? d.unterkunft.name + ', ' : '') + datum(d.von) + ' bis ' + datum(d.bis) + '. Buchungsnummer: ' + (d.nummer || '') + '.'))
if (d.rechnungNummer) kinder.push(p('Rechnung ' + d.rechnungNummer))
kinder.push(h('a', { class: 'uk-link', 'data-tag': 'rechnung', href: basis + '/rechnung.pdf' + tokenQuery + '&art=rechnung', text: 'Rechnung herunterladen (PDF)' }))
var online = (d.zahlarten || []).indexOf('online') >= 0
if (d.bezahlt || d.status === 'bezahlt') kinder.push(p('Bezahlt – vielen Dank.'))
else if (d.zahlart === 'online' && online) {
var meldung = h('div', { class: 'uk-fehler', role: 'alert', hidden: true })
var knopf = h('button', { type: 'button', class: 'uk-knopf', 'data-tag': 'online-zahlen', text: 'Jetzt online bezahlen' })
knopf.addEventListener('click', function () {
knopf.setAttribute('disabled', '')
api('/zahlung', 'POST', {}).then(function (r) {
if (r.ok && httpsUrl(r.body.url)) { window.location.assign(r.body.url); return }
if (r.status === 409 && r.body.fehler === 'status_ungueltig') { meldungNachLaden = TXT.standGeaendert; laden(); return }
knopf.removeAttribute('disabled')
meldung.textContent = r.status === 409 && r.body.fehler === 'online_aus' ? TXT.onlineAus : r.status === 409 && (r.body.fehler === 'einstellungen_unvollstaendig' || r.body.fehler === 'zustimmung_fehlt') ? TXT.nichtMoeglich : TXT.zahlungStart
meldung.hidden = false
}, function () {
knopf.removeAttribute('disabled')
meldung.textContent = 'Die Zahlung konnte nicht gestartet werden. Bitte versuchen Sie es erneut.'; meldung.hidden = false
})
})
kinder.push(knopf, meldung)
} else kinder.push(p('Die Zahlung erfolgt vor Ort.'))
kinder.push(p('Sie erhalten die Bestätigung auch per E-Mail.'))
zeige(h('section', { class: 'uk-karte' }, kinder), hinweisAbbruch())
}
/* ----------------------------------------------------------------------- Start --- */
function darstellen(d) {
akzent(d)
if (d.unterkunft && d.unterkunft.name) document.title = 'Buchung – ' + d.unterkunft.name
if (d.status === 'freigegeben') zeige(formular(d), hinweisAbbruch())
else if (d.status === 'gebucht' || d.status === 'bezahlt') bestaetigt(d)
else if (d.status === 'abgelaufen') nurText('Buchung', TXT.abgelaufen)
else if (d.status === 'abgelehnt') nurText('Buchung', 'Diese Anfrage wurde leider abgelehnt.')
else if (d.status === 'storniert') nurText('Buchung', 'Diese Buchung wurde storniert.')
else nurText('Buchung', 'Ihre Anfrage wird noch geprüft. Sie erhalten eine E-Mail, sobald sie freigegeben ist.')
}
if (!token || !id) { nurText('Buchung', TXT.ungueltig); return }
function laden() {
return api('', 'GET').then(function (r) {
if (r.status === 404 || r.status === 401 || r.status === 403) return nurText('Buchung', TXT.ungueltig)
if (!r.ok) return nurText('Buchung', TXT.laden)
darstellen(r.body)
}, function () { nurText('Buchung', TXT.laden) })
}
laden()
})()
+19
View File
@@ -0,0 +1,19 @@
<!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">
<meta name="referrer" content="no-referrer">
<title>Buchung</title>
<link rel="stylesheet" href="/unterkunft-buchung.css">
</head>
<body>
<main class="uk-seite">
<div id="uk-app" data-sitekey="__SITEKEY__" data-ansicht="__ANSICHT__" data-api=""><p class="uk-ausweich">Die Seite wird geladen …</p></div>
<noscript><p class="uk-ausweich">Für diese Seite wird JavaScript benötigt.</p></noscript>
</main>
<script src="/unterkunft-buchung.js" defer></script>
<script src="/embed/unterkunft.js" defer></script>
</body>
</html>
+39 -5
View File
@@ -7,11 +7,18 @@
// node scripts/build-legal-html.mjs termine --check vergleicht mit den eingecheckten Seiten (Exit 1 bei Abweichung, schreibt nichts)
// node scripts/build-legal-html.mjs shop [--check] dasselbe für Leistungsbeschreibung und AVV des Webshops (die Widerrufsbelehrung der
// Funktionen gehört zum Satz termine und wird von shop nicht angefasst)
// node scripts/build-legal-html.mjs unterkunft [--check]
// Leistungsbeschreibung, AVV und Widerrufsbelehrung des Buchungskalenders in der Fassung
// config.unterkunft.fassung; die Widerrufsbelehrung ist derselbe Text wie beim Satz termine
// (docs/rechtliches/widerrufsbelehrung-funktionen.md), nur mit der Fassung des Buchungskalenders gestempelt
// Achtung: Ändert sich die Widerrufsbelehrung der Funktionen (neue Fassung bei termine/shop), braucht auch unterkunft
// eine neue Fassung (config.unterkunft.fassung); eine ausgelieferte Datei wird nie an Ort und Stelle neu gebaut.
//
// node scripts/build-legal-html.mjs auftraege [--check]
// dasselbe für die Widerrufsbelehrung der Projektaufträge (Wortlaut aus server/services/auftraege/texte.js)
// und die AGB-Kopie (Quelle docs/rechtliches/agb.md)
//
// Fassung = config.termine.avvVersion (Satz termine), config.shop.fassung (Satz shop) bzw. TEXTE_FASSUNG und AGB_FASSUNG (Satz auftraege). Die Ausgabe enthält keine Datumsangabe außer der Fassung und hängt nur vom Markdown und
// Fassung = config.termine.avvVersion (Satz termine), config.shop.fassung (Satz shop) bzw. config.unterkunft.fassung (Satz unterkunft) bzw. TEXTE_FASSUNG und AGB_FASSUNG (Satz auftraege). Die Ausgabe enthält keine Datumsangabe außer der Fassung und hängt nur vom Markdown und
// von der Fassung ab (zweimal erzeugen ergibt dieselben Bytes). Unterstützte Teilmenge von Markdown: Überschriften # bis ###
// (# nur als erste Zeile), Absätze, Listen mit "- ", **fett**, `code` und Tabellen (Kopfzeile, Trennzeile |---|---|, Zeilen).
// Jede andere Syntax (Codeblöcke, Zitate, Bilder, Links, rohes HTML, nummerierte Listen, Einrückungen, Trennlinien) bricht laut ab,
@@ -274,9 +281,15 @@ const SHOP_NAMEN = {
lb: { titel: 'Leistungsbeschreibung Webshop', seitenkopf: 'Leistungsbeschreibung Webshop' },
avv: { titel: 'Auftragsverarbeitungsvertrag Webshop', seitenkopf: 'AVV Webshop' },
}
const UNTERKUNFT_NAMEN = {
lb: { titel: 'Leistungsbeschreibung Buchungskalender', seitenkopf: 'Leistungsbeschreibung Buchungskalender' },
avv: { titel: 'Auftragsverarbeitungsvertrag Buchungskalender', seitenkopf: 'AVV Buchungskalender' },
widerruf: { titel: 'Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)', seitenkopf: 'Widerrufsbelehrung Funktionen' },
}
const NAV_NAMEN = {
termine: { lb: 'Leistungsbeschreibung Terminplaner', avv: 'AVV Terminplaner', widerruf: 'Widerrufsbelehrung Funktionen' },
shop: { lb: 'Leistungsbeschreibung Webshop', avv: 'AVV Webshop', widerruf: 'Widerrufsbelehrung Funktionen' },
unterkunft: { lb: 'Leistungsbeschreibung Buchungskalender', avv: 'AVV Buchungskalender', widerruf: 'Widerrufsbelehrung Funktionen' },
}
/**
@@ -314,6 +327,27 @@ export function shopDokumente(fassung = config.shop.fassung) {
return dokumenteDesSatzes('shop', fassung, SHOP_NAMEN)
}
/**
* Die drei Dokumente des Buchungskalenders in der Fassung config.unterkunft.fassung. Die Widerrufsbelehrung hat dieselbe Quelle wie die des
* Terminplaners (ein Text für alle Funktionen); sie liegt hier unter der Fassung des Buchungskalenders, weil die Bestellung auf diese
* Adresse verweist (legalPaths('unterkunft', …).widerruf). stempel = Fassung, die in der Quelle steht und beim Rendern ersetzt wird.
*/
export function unterkunftDokumente(fassung = config.unterkunft.fassung) {
return dokumenteDesSatzes('unterkunft', fassung, UNTERKUNFT_NAMEN).map((d) =>
d.doc === 'widerruf' ? { ...d, stempel: config.termine.avvVersion } : d,
)
}
/** Sätze der Kommandozeile: Name => Dokumente. */
/** Markdown eines Dokuments; trägt die Quelle eine andere Fassung (d.stempel), wird die Fassungszeile auf die des Dokuments gesetzt. */
export function quelleVon(d, root = ROOT) {
const md = readFileSync(join(root, d.md), 'utf8')
if (!d.stempel || d.stempel === d.meta.fassung) return md
const alt = `**Fassung ${d.stempel}**`
if (!md.includes(alt)) throw new MarkdownFehler(3, `${d.md}: die Zeile ${alt} fehlt`)
return md.replace(alt, `**Fassung ${d.meta.fassung}**`)
}
// ---------------------------------------------------------------------------------------------------------- Projektaufträge
/** Einleitung der Seite zur Widerrufsbelehrung der Projektaufträge (Seitentext, nicht Teil der Belehrung nach dem Muster). */
@@ -373,24 +407,24 @@ export function auftraegeDokumente(texteFassung = TEXTE_FASSUNG, agbFassung = AG
}
/** Sätze der Kommandozeile: Name => Dokumente. */
const SAETZE = { termine: termineDokumente, shop: shopDokumente, auftraege: auftraegeDokumente }
const SAETZE = { termine: termineDokumente, shop: shopDokumente, unterkunft: unterkunftDokumente, auftraege: auftraegeDokumente }
// ---------------------------------------------------------------------------------------------------------- Aufruf
/**
* Kommandozeile: `termine|shop|auftraege [--check]`. root = Wurzel des Projekts (Tests übergeben ein Verzeichnis mit Kopien).
* Kommandozeile: `termine|shop|unterkunft|auftraege [--check]`. root = Wurzel des Projekts (Tests übergeben ein Verzeichnis mit Kopien).
* => Exit-Code: 0 ok, 1 mindestens eine Seite weicht ab (nur --check), 2 falscher Aufruf
*/
export function lauf(argv, { root = ROOT, log = console.log, fehler = console.error } = {}) {
const [satz, ...optionen] = argv
const pruefen = optionen.includes('--check')
if (!Object.hasOwn(SAETZE, satz ?? '') || optionen.some((o) => o !== '--check')) {
fehler('Aufruf: node scripts/build-legal-html.mjs termine|shop [--check]\n node scripts/build-legal-html.mjs auftraege [--check]')
fehler('Aufruf: node scripts/build-legal-html.mjs termine|shop|unterkunft [--check]\n node scripts/build-legal-html.mjs auftraege [--check]')
return 2
}
let abweichungen = 0
for (const d of SAETZE[satz]()) {
const html = render(d.markdown ? d.markdown() : readFileSync(join(root, d.md), 'utf8'), d.meta)
const html = render(d.markdown ? d.markdown() : quelleVon(d, root), d.meta)
const ziel = join(root, d.html)
if (pruefen) {
const gleich = existsSync(ziel) && readFileSync(ziel, 'utf8') === html
+29 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Erzeugt die Druck-/PDF-Fassungen der Vertragsunterlagen aus den HTML-Quellen.
# Laeuft auf dem Host (braucht Chrome/Chromium), die PDFs werden eingecheckt.
# Usage: CHROME=/pfad/zu/chrome scripts/build-legal-pdfs.sh [all|analyse|termine|shop|auftraege|datenschutz]
# Usage: CHROME=/pfad/zu/chrome scripts/build-legal-pdfs.sh [all|analyse|termine|shop|unterkunft|auftraege|datenschutz]
# analyse = nur AVV und Leistungsbeschreibung der Website-Analyse (Fassung ANALYSE_VERSION), andere PDFs bleiben unveraendert
# termine = Leistungsbeschreibung, AVV und Widerrufsbelehrung des Terminplaners (Fassung TERMINE_VERSION, Standard: config.termine.avvVersion);
# die HTML-Seiten kommen vorher aus `node scripts/build-legal-html.mjs termine`. Neben jedem PDF liegt <PDF>.sha256 mit der
@@ -10,6 +10,8 @@
# shop = Leistungsbeschreibung und AVV des Webshops (Fassung SHOP_VERSION, Standard: config.shop.fassung); die HTML-Seiten kommen vorher
# aus `node scripts/build-legal-html.mjs shop`, Pruefsumme und Neudruck wie bei termine. Die Widerrufsbelehrung der Funktionen
# gehoert zu termine (dieselbe Datei) und wird hier nicht gedruckt. `all` druckt auch diese beiden PDFs.
# unterkunft = Leistungsbeschreibung, AVV und Widerrufsbelehrung des Buchungskalenders (Fassung UNTERKUNFT_VERSION, Standard: config.unterkunft.fassung); die HTML-Seiten
# kommen vorher aus `node scripts/build-legal-html.mjs unterkunft`, Pruefsumme und Neudruck wie bei termine. `all` druckt diese PDFs nicht mit.
# auftraege = Widerrufsbelehrung der Projektauftraege (Fassung TEXTE_VERSION, Standard: TEXTE_FASSUNG aus server/services/auftraege/texte.js) und AGB
# (Fassung AGB_VERSION, Standard: AGB_FASSUNG aus server/services/auftraege/konstanten.js); die HTML-Seiten kommen vorher aus
# `node scripts/build-legal-html.mjs auftraege`, Pruefsumme und Neudruck wie bei termine und shop. `all` druckt auch diese beiden PDFs.
@@ -50,6 +52,16 @@ shop_version() {
echo "$v"
}
# Fassung des Buchungskalenders: UNTERKUNFT_VERSION, sonst die Konstante des Servers
unterkunft_version() {
local v="${UNTERKUNFT_VERSION:-}"
if [ -z "$v" ]; then
v="$(timeout 60 node --max-old-space-size=256 --input-type=module -e "const { config } = await import('./server/config.js'); console.log(config.unterkunft.fassung)")"
fi
[[ "$v" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { echo "Ungueltige Fassung: $v" >&2; exit 1; }
echo "$v"
}
# Fassung der Projektauftrags-Texte (Widerrufsbelehrung): TEXTE_VERSION, sonst TEXTE_FASSUNG aus server/services/auftraege/texte.js
texte_version() {
local v="${TEXTE_VERSION:-}"
@@ -132,6 +144,21 @@ termine)
shop)
shop_pdfs
;;
unterkunft)
fassung="$(unterkunft_version)"
for slug in leistungsbeschreibung-unterkunft avv-unterkunft widerrufsbelehrung-funktionen; do
[ -f "public/rechtliches/$slug-$fassung.html" ] || { echo "HTML fehlt: public/rechtliches/$slug-$fassung.html (zuerst node scripts/build-legal-html.mjs unterkunft)"; exit 1; }
quelle="public/rechtliches/$slug-$fassung.html"
ziel="public/rechtliches/$slug-$fassung.pdf"
summe="$(sha256sum "$quelle" | cut -d' ' -f1)"
if [ -z "${FORCE:-}" ] && [ -f "$ziel" ] && [ "$(cat "$ziel.sha256" 2>/dev/null)" = "$summe" ]; then
echo "= $ziel (unveraendert)"
else
pdf "$quelle" "$ziel"
echo "$summe" > "$ziel.sha256"
fi
done
;;
auftraege)
auftraege_pdfs
;;
@@ -155,6 +182,6 @@ all)
auftraege_pdfs
;;
*)
echo "Usage: $0 [all|analyse|termine|shop|datenschutz]"; echo " $0 auftraege (Widerrufsbelehrung der Projektauftraege und AGB)"; exit 1
echo "Usage: $0 [all|analyse|termine|shop|unterkunft|datenschutz]"; echo " $0 auftraege (Widerrufsbelehrung der Projektauftraege und AGB)"; exit 1
;;
esac
+2 -1
View File
@@ -17,7 +17,7 @@ import { config } from '../server/config.js'
import { ensurePortalConfiguration, ensurePrice, getStripe, PORTAL_HEADLINE } from '../server/services/stripeBilling.js'
import { PRODUCT_KEYS } from '../server/services/products.js'
export const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'zeitenSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'zeitenSetup']
export const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'unterkunftSetup', 'zeitenSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'zeitenSetup']
const USAGE = 'Usage: node scripts/stripe-products.mjs --pruefen | --anlegen'
export function parseArgs(argv) {
@@ -32,6 +32,7 @@ export function catalogEntry(key) {
if (key === 'newsSetup') return config.newsSetup
if (key === 'termineSetup') return config.termineSetup
if (key === 'shopSetup') return config.shopSetup
if (key === 'unterkunftSetup') return config.unterkunftSetup
if (key === 'zeitenSetup') return config.zeitenSetup
return config.products[key]
}
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env node
/**
* Buchungskalender: legt die sechs Collections ukUnterkuenfte, ukPreise, ukSperren, ukAnfragen, ukRechnungen und ukEinstellungen mit
* Attributen und Indizes in Appwrite an (nur Server-Key, keine Client-Rechte). Das Schema steht in
* server/services/unterkunft/schema.js. Idempotent: Vorhandenes wird uebersprungen, fehlende Attribute und Indizes
* auch in schon vorhandenen Collections nachgezogen. Vor dem Einschalten (UNTERKUNFT_ENABLED) ausfuehren.
* Usage:
* node scripts/unterkunft-setup.mjs --check (nur melden, nichts anlegen; Exit 1 bei Fehlendem)
* node scripts/unterkunft-setup.mjs (Fehlendes anlegen)
*/
import { pathToFileURL } from 'node:url'
import { config } from '../server/config.js'
import { UNTERKUNFT_COLLECTIONS } from '../server/services/unterkunft/schema.js'
import { createAppwriteClient } from './analyse-setup.mjs'
export async function main({ check = false, client, log = console.log } = {}) {
const api = client || createAppwriteClient()
const missing = []
const missingCollections = []
const missingIndexes = []
const plan = []
// Erst alles pruefen, dann (ohne --check) anlegen, damit --check garantiert nichts veraendert
for (const [name, { attributes, indexes }] of Object.entries(UNTERKUNFT_COLLECTIONS)) {
const id = config.collections[name]
const exists = await api.collectionExists(id)
const haveAttributes = exists ? new Set((await api.listAttributes(id)).map((a) => a.key)) : new Set()
const haveIndexes = exists ? new Set((await api.listIndexes(id)).map((i) => i.key)) : new Set()
const absentAttributes = attributes.filter((a) => !haveAttributes.has(a.key))
const absentIndexes = indexes.filter((i) => !haveIndexes.has(i.key))
if (!exists) {
log(`${check ? '-' : '+'} Collection ${id}${check ? ' FEHLT' : ''}`)
missingCollections.push(id)
} else {
log(`= ${id}: ${haveAttributes.size} Attribute, ${haveIndexes.size} Indizes vorhanden`)
}
if (check) {
absentAttributes.forEach((a) => log(`- ${id}.${a.key} FEHLT`))
absentIndexes.forEach((i) => log(`- Index ${id}.${i.key} FEHLT`))
}
absentAttributes.forEach((a) => missing.push({ ...a, collection: id }))
absentIndexes.forEach((i) => missingIndexes.push({ ...i, collection: id }))
plan.push({ id, exists, absentAttributes, absentIndexes })
}
// Rechnungs-PDF an Gastmails: termineOutbox braucht das Attribut `anhang` (Schema des Terminplaners). Fehlt es, meldet --check das (Exit 1) und
// der normale Lauf legt es an. Fehlt die ganze Collection, ist das ein Fall fuer scripts/termine-setup.mjs (nur Hinweis).
const outboxId = config.collections.termineOutbox
const anhang = { key: 'anhang', type: 'string', size: 500, required: false }
if (await api.collectionExists(outboxId)) {
if (!(await api.listAttributes(outboxId)).some((a) => a.key === 'anhang')) {
if (check) log(`- ${outboxId}.anhang FEHLT`)
missing.push({ ...anhang, collection: outboxId })
plan.push({ id: outboxId, exists: true, absentAttributes: [anhang], absentIndexes: [] })
}
} else {
log(`! ${outboxId} fehlt: zuerst scripts/termine-setup.mjs ausfuehren (Gastmails mit Rechnungs-PDF brauchen termineOutbox.anhang)`)
}
const result = { missing, missingCollections, missingIndexes }
if (check) {
const open = missing.length + missingCollections.length + missingIndexes.length
log(open ? `${open} Eintrag/Eintraege fehlen.` : 'Alles vorhanden.')
return result
}
for (const { id, exists, absentAttributes } of plan) {
if (!exists) await api.ensureCollection(id, id)
for (const attr of absentAttributes) {
await api.createAttribute(id, attr)
log(`+ Attribut ${id}.${attr.key}`)
}
}
// Indizes brauchen verfuegbare Attribute: erst warten, dann anlegen
for (const { id, absentAttributes } of plan) {
for (const attr of absentAttributes) await api.waitForAttribute(id, attr.key)
}
for (const { id, absentIndexes } of plan) {
for (const index of absentIndexes) {
await api.createIndex(id, index)
log(`+ Index ${id}.${index.key}`)
}
}
log('Fertig.')
return result
}
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (invokedDirectly) {
const check = process.argv.includes('--check')
main({ check })
.then(({ missing, missingCollections, missingIndexes }) =>
process.exit(check && (missing.length || missingCollections.length || missingIndexes.length) ? 1 : 0))
.catch((e) => {
console.error(e.message)
process.exit(1)
})
}
+192
View File
@@ -31,6 +31,7 @@ function nonNegativeNumber(raw, fallback) {
const termineEnabled = (process.env.TERMINE_ENABLED || '').trim().toLowerCase()
const shopEnabled = (process.env.SHOP_ENABLED || '').trim().toLowerCase()
const unterkunftEnabled = (process.env.UNTERKUNFT_ENABLED || '').trim().toLowerCase()
const zeitenEnabled = (process.env.ZEITEN_ENABLED || '').trim().toLowerCase()
const auftraegeEnabled = (process.env.AUFTRAEGE_ENABLED || '').trim().toLowerCase()
@@ -138,6 +139,16 @@ const shopSetup = {
priceNickname: 'Webshop-Einrichtung 299 €',
}
// Buchungskalender (Funktionsschluessel 'unterkunft'): einmalige Einrichtung (Einmalpreis, kein Abo)
const unterkunftSetup = {
key: 'unterkunftSetup',
name: 'Buchungskalender (Einrichtung)',
amountCents: 19900,
lookupKey: 'webklar_unterkunft_einrichtung_19900',
oneTime: true,
priceNickname: 'Buchungskalender-Einrichtung 199 €',
}
// Oeffnungszeiten live: einmalige Einrichtung (Einmalpreis, kein Abo); im Wartungs-Abo ist die Funktion enthalten
const zeitenSetup = {
key: 'zeitenSetup',
@@ -331,6 +342,86 @@ function shopConsent() {
}
}
/**
* Rechtsunterlage des Buchungskalenders: wie shopRechtsunterlage, aber mit Fassung und Adresse aus config.unterkunft.fassung
* (bei jedem Zugriff gelesen). Slugs wie in services/legalDocs.js (SAETZE.unterkunft); test/legalDocs.test.js gleicht beide ab.
*/
function unterkunftRechtsunterlage(slug, { nullIstWert = false } = {}) {
let ueberschrieben // undefined = Standard
const standard = () => ({ version: config.unterkunft.fassung, url: `/rechtliches/${slug}-${config.unterkunft.fassung}.html` })
return {
get: () => (ueberschrieben === undefined ? standard() : ueberschrieben),
set(wert) {
const std = standard()
const zurueck = wert === undefined || (wert === null && !nullIstWert) || (wert !== null && wert.version === std.version && wert.url === std.url)
ueberschrieben = zurueck ? undefined : wert
},
}
}
const unterkunftLb = unterkunftRechtsunterlage('leistungsbeschreibung-unterkunft')
const unterkunftAvv = unterkunftRechtsunterlage('avv-unterkunft')
const unterkunftWiderruf = unterkunftRechtsunterlage('widerrufsbelehrung-funktionen', { nullIstWert: true })
const UNTERKUNFT_PREIS = '199 €'
const UNTERKUNFT_DAUER = 'meist innerhalb von 1–2 Wochen'
/**
* Bestelltexte des Buchungskalenders: sechs Haken (alle Besteller), Aufbau wie shopConsent (Haken je Fassung einmal gebaut).
* Die Fassung (consent.version) ist config.unterkunft.fassung. Bei Textaenderung neues Fassungsdatum und neuen Hash im Pin von
* test/featureConsentTexte.test.js. Zitat, Wertersatz-Satz und Pflichtinformationen sind Entwurf bis zur Kanzlei.
*/
function unterkunftConsent() {
let versionGesetzt // undefined = config.unterkunft.fassung
let gebaut = null // { version, items }
const version = () => versionGesetzt ?? config.unterkunft.fassung
const items = (v) => [
{
id: 'unterlagen',
haken: true,
text: `Ich stimme der Leistungsbeschreibung Buchungskalender und dem Auftragsverarbeitungsvertrag Buchungskalender in der Fassung ${v} zu.`,
},
{
id: 'verantwortung',
haken: true,
text: 'Ich vermiete in eigenem Namen; für Preise, Stornobedingungen, Rechnungen und Meldepflichten bin ich verantwortlich.',
},
{
id: 'rechnung',
haken: true,
text: 'Ich beauftrage WEBklar, in meinem Namen und auf meine Rechnung Rechnungen an meine Gäste auszustellen (§ 14 Abs. 2 Satz 4 UStG). Für Richtigkeit von Preisen und Steuerangaben bin ich verantwortlich.',
},
{
id: 'stripe',
haken: true,
text: 'Online-Zahlungen meiner Gäste laufen, sofern ich sie nutze, über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält und verwahrt kein Geld meiner Gäste und erhebt keine Umsatzbeteiligung.',
},
{
id: 'beginn',
haken: true,
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Buchungskalenders 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).',
},
{
id: 'information',
haken: true,
text: `Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Buchungskalenders für diese Website: Einbau des Kalender-Widgets, Verwaltung im Portal, Rechnungserstellung für Gäste), Gesamtpreis ${UNTERKUNFT_PREIS} einmalig (${ustHinweis}), voraussichtliche Dauer ${UNTERKUNFT_DAUER}, keine laufende Gebühr von WEBklar; der Buchungskalender läuft, solange WEBklar die Website betreibt. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.`,
},
]
return {
get version() {
return version()
},
set version(wert) {
versionGesetzt = wert === undefined || wert === null || wert === config.unterkunft.fassung ? undefined : wert
},
knopf: `Zahlungspflichtig bestellen · ${UNTERKUNFT_PREIS}`,
get items() {
const v = version()
if (gebaut?.version !== v) gebaut = { version: v, items: items(v) }
return gebaut.items
},
}
}
/**
* Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner, Webshop): Preis, Zustimmungen, Rechtsunterlagen,
* Ticket- und Mail-Texte je Funktion; reine Daten, config.js importiert keine Dienste. Die Helfer (featureConfig,
@@ -511,6 +602,56 @@ const features = {
orderedConflictMessage: 'Der Webshop ist für dieses Projekt schon bestellt.',
unavailableMessage: 'Der Webshop ist noch nicht verfügbar.',
},
unterkunft: {
key: 'unterkunft',
product: 'unterkunftSetup',
label: { name: 'Buchungskalender', kurz: 'Buchungskalender', keine: 'keinen Buchungskalender' },
priceLabel: UNTERKUNFT_PREIS,
setupEstimate: UNTERKUNFT_DAUER,
consent: unterkunftConsent(),
get leistungsbeschreibung() {
return unterkunftLb.get()
},
set leistungsbeschreibung(wert) {
unterkunftLb.set(wert)
},
get avv() {
return unterkunftAvv.get()
},
set avv(wert) {
unterkunftAvv.set(wert)
},
get widerruf() {
return unterkunftWiderruf.get()
},
set widerruf(wert) {
unterkunftWiderruf.set(wert)
},
returnHash: 'buchungen',
returnProjekt: true,
ticket: {
topic: (project) => `Buchungskalender einbauen – ${ticketProjektName(project)}`,
// Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch
details: async (ctx) => {
const { unterkunftTicketDetails } = await import('./services/unterkunft/ticketDetails.js')
return unterkunftTicketDetails(ctx)
},
},
mails: {
funktion: 'Buchungskalender',
formen: { dein: 'Dein Buchungskalender', nom: 'der Buchungskalender', akk: 'den Buchungskalender', gen: 'des Buchungskalenders', pron: 'ihn' },
knopf: 'Buchungen öffnen',
danach: 'Danach kannst Du im Portal Unterkünfte, Preise und Anfragen verwalten.',
bereit: 'Dein Buchungskalender ist eingebaut. Du kannst ab sofort Anfragen und Buchungen im Portal verwalten.',
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 Buchungskalender ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.',
orderedConflictMessage: 'Der Buchungskalender ist für dieses Projekt schon bestellt.',
unavailableMessage: 'Der Buchungskalender ist noch nicht verfügbar.',
},
// Oeffnungszeiten live (Spec 2026-10-04): 30 EUR einmalig, im Wartungs-Abo ohne Aufpreis (aboInklusive, consentFrei)
zeiten: {
key: 'zeiten',
@@ -632,6 +773,13 @@ export const config = {
shopOrders: process.env.APPWRITE_COLLECTION_SHOP_ORDERS || 'shopOrders',
shopWiderrufe: process.env.APPWRITE_COLLECTION_SHOP_WIDERRUFE || 'shopWiderrufe',
shopRechtstexteArchiv: process.env.APPWRITE_COLLECTION_SHOP_RECHTSTEXTE_ARCHIV || 'shopRechtstexteArchiv',
// Buchungskalender (Unterkuenfte, Preise, Sperren, Anfragen, Rechnungen, Einstellungen; scripts/unterkunft-setup.mjs)
ukUnterkuenfte: process.env.APPWRITE_COLLECTION_UK_UNTERKUENFTE || 'ukUnterkuenfte',
ukPreise: process.env.APPWRITE_COLLECTION_UK_PREISE || 'ukPreise',
ukSperren: process.env.APPWRITE_COLLECTION_UK_SPERREN || 'ukSperren',
ukAnfragen: process.env.APPWRITE_COLLECTION_UK_ANFRAGEN || 'ukAnfragen',
ukRechnungen: process.env.APPWRITE_COLLECTION_UK_RECHNUNGEN || 'ukRechnungen',
ukEinstellungen: process.env.APPWRITE_COLLECTION_UK_EINSTELLUNGEN || 'ukEinstellungen',
// Oeffnungszeiten live (Einstellungen, Erinnerungsmerker; scripts/zeiten-setup.mjs)
oeffnungszeiten: process.env.APPWRITE_COLLECTION_OEFFNUNGSZEITEN || 'oeffnungszeiten',
zeitenErinnerungen: process.env.APPWRITE_COLLECTION_ZEITEN_ERINNERUNGEN || 'zeitenErinnerungen',
@@ -655,6 +803,7 @@ export const config = {
newsSetup,
termineSetup,
shopSetup,
unterkunftSetup,
zeitenSetup,
ustHinweis,
// Register der kaufbaren Funktionen (siehe oben)
@@ -919,6 +1068,49 @@ export const config = {
unbezahltLoeschTage: 30,
},
// Buchungskalender (Funktionsschluessel 'unterkunft'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur
// Laufzeit); Schalter siehe server/services/unterkunft/schalter.js
unterkunft: {
// off (Vorgabe) | pilot (nur UNTERKUNFT_PILOT_EMAILS) | on (alle Kunden)
mode: ['pilot', 'on'].includes(unterkunftEnabled) ? unterkunftEnabled : 'off',
// Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle
pilotEmails: (process.env.UNTERKUNFT_PILOT_EMAILS || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
// Fassung der Rechtstexte: Berliner Kalendertag (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben
fassung: '2026-10-07',
// Mindestzeit (Sekunden) zwischen Laden und Absenden des Anfrageformulars (schneller = Bot)
formMinSekunden: nonNegativeNumber(process.env.UNTERKUNFT_FORM_MIN_SEKUNDEN, 3),
// Kuerzeste Antwortzeit (ms) der POST-Routen der oeffentlichen Schnittstelle. Tests setzen 0.
minAntwortMs: nonNegativeNumber(process.env.UNTERKUNFT_MIN_ANTWORT_MS, 300),
// iCal-Import: Abrufintervall, Groessen-/Zeitgrenzen, Fehlermail nach so vielen Stunden anhaltenden Fehlern
icalIntervallMin: 15,
icalMaxBytes: 1_000_000,
icalTimeoutMs: 10_000,
icalFehlerStunden: 6,
// Buchbar sind so viele Monate in die Zukunft
horizontMonate: 18,
maxPosten: 30,
postenTextMax: 200,
nachrichtMax: 2000,
// Anfragen je Unterkunfts-Seite und Stunde (Missbrauchsbremse)
anfragenJeSiteStunde: 30,
// IP-Hashes werden nach so vielen Tagen geloescht
ipHashTage: 30,
// Unverbindliche Anfragen werden nach so vielen Monaten anonymisiert/geloescht
unverbindlichMonate: 6,
// Offene (neue) Anfragen verfallen nach so vielen Tagen
neuVerfallTage: 60,
// Hoechstzahl Schreib-/Loeschvorgaenge der Aufbewahrung je Tageslauf
aufbewahrungDeckel: 500,
// Anfragen werden so viele Jahre nach Abschluss anonymisiert; Rechnungen bleiben rechnungJahre Jahre
anonymisierenJahre: 3,
rechnungJahre: 8,
// Tage nach Abschalten der Funktion bis zur Loeschung der Daten
loeschTageNachAbschalten: 90,
},
// Oeffnungszeiten live (Funktionsschluessel 'zeiten'); Geschwister von termine (termineSchalter.test.js pinnt
// config.termine). Wird bei jedem Aufruf gelesen; Schalter siehe server/services/zeiten/schalter.js
zeiten: {
+32
View File
@@ -20,6 +20,8 @@ import termineRoutes from './routes/termine.js'
import shopRoutes from './routes/shop.js'
import shopWebhookRoutes from './routes/shopWebhook.js'
import { shopSeiteRouter } from './routes/shopSeite.js'
import unterkunftRoutes from './routes/unterkunft.js'
import { unterkunftSeiteRouter } from './routes/unterkunftSeite.js'
import zeitenRoutes from './routes/zeiten.js'
import publicAuftragRoutes from './routes/auftrag.js'
import kundeAuftraegeRoutes from './routes/auftraegeKunde.js'
@@ -31,6 +33,7 @@ import { purgeUnclaimedNewsImages } from './services/newsImages.js'
import { runActivation } from './services/siteFeatures.js'
import { runAboCheck, runAnalyseReconcile } from './services/analyse.js'
import { fuenfMinutenJob, minutenJob, tagesbericht, taeglicherJob } from './services/termine/jobs.js'
import { minutenJob as unterkunftMinutenJob, viertelstundenJob as unterkunftViertelstundenJob, taeglicherJob as unterkunftTaeglicherJob } from './services/unterkunft/jobs.js'
import { minutenJob as shopMinutenJob, stuendlicherJob as shopStuendlicherJob, taeglicherJob as shopTaeglicherJob } from './services/shop/jobs.js'
import { zeitenAboJob, zeitenErinnerungsJob } from './services/zeiten/jobs.js'
import giteaWebhookRoutes from './routes/webhook/gitea.js'
@@ -46,6 +49,7 @@ import newsRoutes from './routes/news.js'
import publicNewsRoutes from './routes/publicNews.js'
import publicTermineRoutes from './routes/publicTermine.js'
import publicShopRoutes from './routes/publicShop.js'
import publicUnterkunftRoutes from './routes/publicUnterkunft.js'
import publicZeitenRoutes from './routes/publicZeiten.js'
import adminAnalyseRoutes from './routes/admin/analyse.js'
import { legalRouter } from './services/legalDocs.js'
@@ -73,6 +77,8 @@ app.use(kundenansichtGuard)
app.use('/api/public/termine/v1', publicTermineRoutes)
// Oeffentliche Schnittstelle des Webshops (Kaeufer ohne Session): wie die des Terminplaners mit eigenem Parser (16 KB), daher ebenfalls VOR dem globalen Parser
app.use('/api/public/shop/v1', publicShopRoutes)
// Oeffentliche Schnittstelle des Buchungskalenders (Gaeste ohne Session): ebenfalls mit eigenem Parser VOR dem globalen
app.use('/api/public/unterkunft/v1', publicUnterkunftRoutes)
// Uebernahmegutschrift: Nachweise kommen als Base64 im JSON (max. 10 MB Dateien) -> eigenes Limit,
// muss vor dem globalen Parser stehen (der ueberspringt bereits geparste Bodies). Login-Pruefung
// zuerst, damit niemand ohne Session grosse Bodies parsen laesst.
@@ -97,6 +103,7 @@ app.use('/api/termine', termineRoutes)
// Stripe-Connect-Webhook des Webshops (ohne Sitzung): vor dem Router, braucht rawBody des globalen Parsers
app.use('/api/shop/stripe-webhook', shopWebhookRoutes)
app.use('/api/shop', shopRoutes)
app.use('/api/unterkunft', unterkunftRoutes)
app.use('/api/zeiten', zeitenRoutes)
// Auftragsbestaetigung: oeffentliche Bestaetigung per Token (ohne Sitzung) und die Auftraege des angemeldeten Kunden
app.use('/api/auftrag', publicAuftragRoutes)
@@ -139,6 +146,8 @@ app.use('/embed/', (_req, res, next) => {
app.use('/rechtliches', legalRouter())
// Webshop-Seiten fuer Kaeufer: /shop/:siteKey und /shop/:siteKey/widerruf (vor express.static)
app.use(shopSeiteRouter())
// Buchungskalender-Seiten fuer Gaeste: /unterkunft/:siteKey und /unterkunft/:siteKey/buchung/:id (vor express.static)
app.use(unterkunftSeiteRouter())
// Abgeschaltetes Admin-Panel und Rechnungsanalyse: Links aus alten Team-Mails ins Ticketsystem
app.use('/admin.html', legacyAdminRedirect.admin)
app.use('/kalkulation.html', legacyAdminRedirect.kalkulation)
@@ -270,6 +279,29 @@ const server = app.listen(config.port, '0.0.0.0', () => {
setInterval(shopTages, 24 * 60 * 60 * 1000).unref()
}, 200 * 1000).unref()
// Buchungskalender: jede Minute Fristen ablaufen lassen (80 s nach dem Start beginnend), alle 15 Minuten iCal-Importe (140 s),
// taeglich Aufbewahrung (210 s). Bei UNTERKUNFT_ENABLED=off kehrt jeder Lauf sofort zurueck. Die Jobs melden Einzelfehler selbst
// (ohne Gastdaten); hier faengt der Rahmen alles Unerwartete ab.
const unterkunftMinuten = () => unterkunftMinutenJob(new Date()).catch((err) => console.warn('[unterkunft] Minutenjob fehlgeschlagen:', err.message))
setTimeout(() => {
unterkunftMinuten()
setInterval(unterkunftMinuten, 60 * 1000).unref()
}, 80 * 1000).unref()
const unterkunftViertelstunden = () =>
unterkunftViertelstundenJob(new Date()).catch((err) => console.warn('[unterkunft] Viertelstundenjob fehlgeschlagen:', err.message))
setTimeout(() => {
unterkunftViertelstunden()
setInterval(unterkunftViertelstunden, 15 * 60 * 1000).unref()
}, 140 * 1000).unref()
const unterkunftTages = () =>
unterkunftTaeglicherJob(new Date())
.then((r) => r && !r.aus && !r.uebersprungen && console.log(`[unterkunft] Tagesjob: ${JSON.stringify(r)}`))
.catch((err) => console.warn('[unterkunft] Tagesjob fehlgeschlagen:', err.message))
setTimeout(() => {
unterkunftTages()
setInterval(unterkunftTages, 24 * 60 * 60 * 1000).unref()
}, 210 * 1000).unref()
// Oeffnungszeiten live: taeglich der Abo-Abgleich (4 Minuten nach dem Start beginnend; laeuft unabhaengig vom Schalter ZEITEN_ENABLED,
// ohne Zeiten-Dokumente kostet er eine Abfrage) und stuendlich die Feiertags-Erinnerung (5 Minuten nach dem Start beginnend; nur bei
// Schalter pilot/on und nur 08:00 bis 20:00 Berliner Zeit, sonst kehrt jeder Lauf sofort zurueck). Beide Jobs ueberlappen nicht und
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G16 · Buchungsanfrage eingegangen (Buchungskalender)
Betreff: Ihre Buchungsanfrage bei {{firma}} ist eingegangen
Absender: „<Firma>“ <Portal-Postfach> · Antworten an die E-Mail-Adresse des Vermieters
Empfänger: Gast (E-Mail aus der Buchungsanfrage) · ohne BCC
Platzhalter: {{gast_name}}, {{unterkunft}}, {{zeitraum}}, {{naechte}}, {{personen}}, {{nummer}}, {{firma}}, {{farbe}}, {{anbieter_kontakt}}
Sie-Form, keine Anrede-Blöcke, neutrales Layout in der Akzentfarbe des Vermieters, ohne Logo. Footer: Anbieter-Kontakt des Vermieters.
naechte und personen sind fertige Texte (z. B. „2 Nächte“, „2 Personen“); zeitraum z. B. „12.11.2026 – 30.11.2026“; posten_text = eine Zeile je Posten; gesamt z. B. „1.234,00 €“; anbieter_kontakt = Name, Anschrift, E-Mail und Telefon des Vermieters.
Keine Buchung und kein Vertrag: der Vermieter prüft die Anfrage und meldet sich mit Preis und Buchungslink.
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihre Buchungsanfrage bei {{firma}} ist eingegangen</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{firma}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihre Anfrage ist eingegangen</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{gast_name}},<br>vielen Dank für Ihre Buchungsanfrage. {{firma}} prüft sie und meldet sich bei Ihnen mit Preis und einem Link zur verbindlichen Buchung.</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Unterkunft</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{unterkunft}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zeitraum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeitraum}} ({{naechte}})</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Personen</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{personen}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Buchungsnummer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nummer}}</td></tr>
</table></td></tr>
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><strong>Noch keine Buchung:</strong> Mit dieser Anfrage ist noch nichts verbindlich gebucht und es entstehen Ihnen keine Kosten. Der Zeitraum ist erst nach der Freigabe reserviert.</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Freundliche Grüße<br><strong>{{firma}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{firma}}</strong></div><div>{{anbieter_kontakt}}</div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{firma}} über den Buchungskalender von WEBklar versendet. Antworten gehen direkt an {{firma}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G17 · Buchungsanfrage freigegeben (Buchungskalender)
Betreff: Ihre Buchungsanfrage {{nummer}} bei {{firma}}: jetzt buchen
Absender: „<Firma>“ <Portal-Postfach> · Antworten an die E-Mail-Adresse des Vermieters
Empfänger: Gast (E-Mail aus der Buchungsanfrage) · ohne BCC
Platzhalter: {{gast_name}}, {{unterkunft}}, {{zeitraum}}, {{naechte}}, {{personen}}, {{nummer}}, {{posten_text}}, {{gesamt}}, {{frist}}, {{buchungs_link}}, {{stornobedingungen}}, {{nachricht_vermieter}}, {{firma}}, {{farbe}}, {{anbieter_kontakt}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): nachricht
Sie-Form, keine Anrede-Blöcke, neutrales Layout in der Akzentfarbe des Vermieters, ohne Logo. Footer: Anbieter-Kontakt des Vermieters.
naechte und personen sind fertige Texte (z. B. „2 Nächte“, „2 Personen“); zeitraum z. B. „12.11.2026 – 30.11.2026“; posten_text = eine Zeile je Posten; gesamt z. B. „1.234,00 €“; anbieter_kontakt = Name, Anschrift, E-Mail und Telefon des Vermieters.
frist = Ende der Reservierung, z. B. „15.11.2026, 18:00 Uhr“; buchungs_link führt zur Buchungsseite mit dem persönlichen Token. Block nachricht: Mitteilung des Vermieters (nachricht_vermieter).
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihre Buchungsanfrage {{nummer}} bei {{firma}}: jetzt buchen</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{firma}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihre Anfrage wurde freigegeben</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{gast_name}},<br>{{firma}} hat Ihre Anfrage geprüft und den Zeitraum für Sie reserviert. Bitte buchen Sie bis {{frist}} verbindlich, danach läuft die Reservierung ab.</td></tr>
<!-- WENN nachricht -->
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;">{{nachricht_vermieter}}</td></tr></table></td></tr>
<!-- ENDE nachricht -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Unterkunft</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{unterkunft}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zeitraum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeitraum}} ({{naechte}})</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Personen</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{personen}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Buchungsnummer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nummer}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Leistungen</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{posten_text}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Gesamtpreis</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{gesamt}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Reserviert bis</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{frist}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Stornobedingungen</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{stornobedingungen}}</td></tr>
</table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{buchungs_link}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Jetzt verbindlich buchen &rarr;</a></td></tr></table></td></tr>
<tr><td class="px mut" style="padding:20px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Erst mit dem Klick auf „Zahlungspflichtig buchen“ auf der Buchungsseite ist Ihre Buchung verbindlich.</td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Freundliche Grüße<br><strong>{{firma}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{firma}}</strong></div><div>{{anbieter_kontakt}}</div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{firma}} über den Buchungskalender von WEBklar versendet. Antworten gehen direkt an {{firma}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G18 · Buchungsanfrage abgelehnt (Buchungskalender)
Betreff: Zu Ihrer Buchungsanfrage {{nummer}} bei {{firma}}
Absender: „<Firma>“ <Portal-Postfach> · Antworten an die E-Mail-Adresse des Vermieters
Empfänger: Gast (E-Mail aus der Buchungsanfrage) · ohne BCC
Platzhalter: {{gast_name}}, {{unterkunft}}, {{zeitraum}}, {{naechte}}, {{personen}}, {{nummer}}, {{nachricht_vermieter}}, {{firma}}, {{farbe}}, {{anbieter_kontakt}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): nachricht
Sie-Form, keine Anrede-Blöcke, neutrales Layout in der Akzentfarbe des Vermieters, ohne Logo. Footer: Anbieter-Kontakt des Vermieters.
naechte und personen sind fertige Texte (z. B. „2 Nächte“, „2 Personen“); zeitraum z. B. „12.11.2026 – 30.11.2026“; posten_text = eine Zeile je Posten; gesamt z. B. „1.234,00 €“; anbieter_kontakt = Name, Anschrift, E-Mail und Telefon des Vermieters.
Block nachricht: Begründung oder Mitteilung des Vermieters (nachricht_vermieter).
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Zu Ihrer Buchungsanfrage {{nummer}} bei {{firma}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{firma}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Zu Ihrer Anfrage</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{gast_name}},<br>leider kann {{firma}} Ihre Buchungsanfrage für den gewünschten Zeitraum nicht annehmen.</td></tr>
<!-- WENN nachricht -->
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;">{{nachricht_vermieter}}</td></tr></table></td></tr>
<!-- ENDE nachricht -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Unterkunft</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{unterkunft}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zeitraum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeitraum}} ({{naechte}})</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Personen</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{personen}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Buchungsnummer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nummer}}</td></tr>
</table></td></tr>
<tr><td class="px mut" style="padding:20px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Sie können gern einen anderen Zeitraum anfragen oder {{firma}} direkt kontaktieren, indem Sie auf diese E-Mail antworten.</td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Freundliche Grüße<br><strong>{{firma}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{firma}}</strong></div><div>{{anbieter_kontakt}}</div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{firma}} über den Buchungskalender von WEBklar versendet. Antworten gehen direkt an {{firma}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G19 · Buchungsbestätigung (Buchungskalender, dauerhafter Datenträger)
Betreff: Ihre Buchung {{nummer}} bei {{firma}} ist bestätigt
Absender: „<Firma>“ <Portal-Postfach> · Antworten an die E-Mail-Adresse des Vermieters
Empfänger: Gast (E-Mail aus der Buchungsanfrage) · ohne BCC
Platzhalter: {{gast_name}}, {{unterkunft}}, {{zeitraum}}, {{naechte}}, {{personen}}, {{nummer}}, {{posten_text}}, {{gesamt}}, {{zahlart_text}}, {{rechnung_nummer}}, {{stornobedingungen}}, {{firma}}, {{farbe}}, {{anbieter_kontakt}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): online_unbezahlt
Sie-Form, keine Anrede-Blöcke, neutrales Layout in der Akzentfarbe des Vermieters, ohne Logo. Footer: Anbieter-Kontakt des Vermieters.
naechte und personen sind fertige Texte (z. B. „2 Nächte“, „2 Personen“); zeitraum z. B. „12.11.2026 – 30.11.2026“; posten_text = eine Zeile je Posten; gesamt z. B. „1.234,00 €“; anbieter_kontakt = Name, Anschrift, E-Mail und Telefon des Vermieters.
Dauerhafter Datenträger: alle Angaben der Buchungsseite stehen in der Mail, die Rechnung hängt als PDF an. Block online_unbezahlt: Zahlart online, noch nicht bezahlt. Kein Widerrufsrecht (§ 312g Abs. 2 Satz 1 Nr. 9 BGB), der Hinweis steht fest im Text.
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihre Buchung {{nummer}} bei {{firma}} ist bestätigt</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{firma}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihre Buchung ist bestätigt</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{gast_name}},<br>vielen Dank, Ihre Buchung bei {{firma}} ist verbindlich. Alle Angaben finden Sie unten; die Rechnung {{rechnung_nummer}} hängt als PDF an.</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Unterkunft</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{unterkunft}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zeitraum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeitraum}} ({{naechte}})</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Personen</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{personen}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Buchungsnummer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nummer}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Leistungen</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{posten_text}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Gesamtpreis</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{gesamt}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zahlung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zahlart_text}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Stornobedingungen</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{stornobedingungen}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Anbieter</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{anbieter_kontakt}}</td></tr>
</table></td></tr>
<!-- WENN online_unbezahlt -->
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;">Den Bezahl-Link finden Sie auf der Buchungsseite aus unserer vorigen E-Mail.</td></tr></table></td></tr>
<!-- ENDE online_unbezahlt -->
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><strong>Kein Widerrufsrecht:</strong> Für Verträge über die Beherbergung zu einem bestimmten Zeitraum besteht nach § 312g Abs. 2 Satz 1 Nr. 9 BGB kein gesetzliches Widerrufsrecht. Es gelten die oben genannten Stornobedingungen.</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Freundliche Grüße<br><strong>{{firma}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{firma}}</strong></div><div>{{anbieter_kontakt}}</div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{firma}} über den Buchungskalender von WEBklar versendet. Antworten gehen direkt an {{firma}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G20 · Zahlung eingegangen (Buchungskalender)
Betreff: Zahlung zu Ihrer Buchung {{nummer}} eingegangen
Absender: „<Firma>“ <Portal-Postfach> · Antworten an die E-Mail-Adresse des Vermieters
Empfänger: Gast (E-Mail aus der Buchungsanfrage) · ohne BCC
Platzhalter: {{gast_name}}, {{unterkunft}}, {{zeitraum}}, {{naechte}}, {{personen}}, {{nummer}}, {{gesamt}}, {{rechnung_nummer}}, {{firma}}, {{farbe}}, {{anbieter_kontakt}}
Sie-Form, keine Anrede-Blöcke, neutrales Layout in der Akzentfarbe des Vermieters, ohne Logo. Footer: Anbieter-Kontakt des Vermieters.
naechte und personen sind fertige Texte (z. B. „2 Nächte“, „2 Personen“); zeitraum z. B. „12.11.2026 – 30.11.2026“; posten_text = eine Zeile je Posten; gesamt z. B. „1.234,00 €“; anbieter_kontakt = Name, Anschrift, E-Mail und Telefon des Vermieters.
Nach erfolgreicher Online-Zahlung; rechnung_nummer = Nummer der schon versendeten Rechnung.
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Zahlung zu Ihrer Buchung {{nummer}} eingegangen</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{firma}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Zahlung eingegangen</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{gast_name}},<br>wir haben Ihre Zahlung erhalten. Vielen Dank!</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Unterkunft</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{unterkunft}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zeitraum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeitraum}} ({{naechte}})</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Personen</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{personen}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Buchungsnummer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nummer}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bezahlter Betrag</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{gesamt}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Rechnung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{rechnung_nummer}}</td></tr>
</table></td></tr>
<tr><td class="px mut" style="padding:20px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Ihre Buchung bleibt wie bestätigt bestehen. Die Rechnung haben Sie mit der Buchungsbestätigung erhalten.</td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Freundliche Grüße<br><strong>{{firma}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{firma}}</strong></div><div>{{anbieter_kontakt}}</div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{firma}} über den Buchungskalender von WEBklar versendet. Antworten gehen direkt an {{firma}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G21 · Reservierung abgelaufen (Buchungskalender)
Betreff: Ihre Reservierung {{nummer}} bei {{firma}} ist abgelaufen
Absender: „<Firma>“ <Portal-Postfach> · Antworten an die E-Mail-Adresse des Vermieters
Empfänger: Gast (E-Mail aus der Buchungsanfrage) · ohne BCC
Platzhalter: {{gast_name}}, {{unterkunft}}, {{zeitraum}}, {{naechte}}, {{personen}}, {{nummer}}, {{frist}}, {{firma}}, {{farbe}}, {{anbieter_kontakt}}
Sie-Form, keine Anrede-Blöcke, neutrales Layout in der Akzentfarbe des Vermieters, ohne Logo. Footer: Anbieter-Kontakt des Vermieters.
naechte und personen sind fertige Texte (z. B. „2 Nächte“, „2 Personen“); zeitraum z. B. „12.11.2026 – 30.11.2026“; posten_text = eine Zeile je Posten; gesamt z. B. „1.234,00 €“; anbieter_kontakt = Name, Anschrift, E-Mail und Telefon des Vermieters.
Die Frist zur verbindlichen Buchung ist verstrichen; der Zeitraum ist wieder frei. Kein Link (der Server kennt das Token nicht mehr).
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihre Reservierung {{nummer}} bei {{firma}} ist abgelaufen</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{firma}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Reservierung abgelaufen</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{gast_name}},<br>die Frist für Ihre verbindliche Buchung ist am {{frist}} abgelaufen. Der Zeitraum ist deshalb nicht mehr für Sie reserviert.</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Unterkunft</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{unterkunft}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zeitraum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeitraum}} ({{naechte}})</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Personen</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{personen}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Buchungsnummer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nummer}}</td></tr>
</table></td></tr>
<tr><td class="px mut" style="padding:20px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Wenn Sie weiterhin Interesse haben, stellen Sie gern eine neue Anfrage oder antworten Sie auf diese E-Mail.</td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Freundliche Grüße<br><strong>{{firma}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{firma}}</strong></div><div>{{anbieter_kontakt}}</div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{firma}} über den Buchungskalender von WEBklar versendet. Antworten gehen direkt an {{firma}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G22 · Buchung storniert (Buchungskalender)
Betreff: Ihre Buchung {{nummer}} bei {{firma}} wurde storniert
Absender: „<Firma>“ <Portal-Postfach> · Antworten an die E-Mail-Adresse des Vermieters
Empfänger: Gast (E-Mail aus der Buchungsanfrage) · ohne BCC
Platzhalter: {{gast_name}}, {{unterkunft}}, {{zeitraum}}, {{naechte}}, {{personen}}, {{nummer}}, {{rechnung_nummer}}, {{erstattung}}, {{nachricht_vermieter}}, {{firma}}, {{farbe}}, {{anbieter_kontakt}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): erstattung, nachricht
Sie-Form, keine Anrede-Blöcke, neutrales Layout in der Akzentfarbe des Vermieters, ohne Logo. Footer: Anbieter-Kontakt des Vermieters.
naechte und personen sind fertige Texte (z. B. „2 Nächte“, „2 Personen“); zeitraum z. B. „12.11.2026 – 30.11.2026“; posten_text = eine Zeile je Posten; gesamt z. B. „1.234,00 €“; anbieter_kontakt = Name, Anschrift, E-Mail und Telefon des Vermieters.
rechnung_nummer = Nummer der Stornorechnung (hängt als PDF an). Block erstattung: Erstattung wird nach dem Versand der Mail veranlasst („wird veranlasst“, nicht „wurde erstattet“). Block nachricht: Grund des Vermieters.
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihre Buchung {{nummer}} bei {{firma}} wurde storniert</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{firma}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihre Buchung wurde storniert</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{gast_name}},<br>{{firma}} hat Ihre Buchung storniert. Die Stornorechnung {{rechnung_nummer}} hängt als PDF an.</td></tr>
<!-- WENN nachricht -->
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;">{{nachricht_vermieter}}</td></tr></table></td></tr>
<!-- ENDE nachricht -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Unterkunft</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{unterkunft}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zeitraum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeitraum}} ({{naechte}})</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Personen</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{personen}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Buchungsnummer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nummer}}</td></tr>
</table></td></tr>
<!-- WENN erstattung -->
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;">Eine Erstattung von {{erstattung}} wird veranlasst. Je nach Zahlart kann es einige Tage dauern, bis der Betrag bei Ihnen sichtbar ist.</td></tr></table></td></tr>
<!-- ENDE erstattung -->
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Freundliche Grüße<br><strong>{{firma}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{firma}}</strong></div><div>{{anbieter_kontakt}}</div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{firma}} über den Buchungskalender von WEBklar versendet. Antworten gehen direkt an {{firma}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K33 · Kalenderabgleich fehlgeschlagen (an den Vermieter)
Betreff: Der Kalenderabgleich für {{unterkunft}} funktioniert nicht
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{unterkunft}}, {{quelle}}, {{seit}}, {{portal_url}}
quelle = Name des externen Kalenders (z. B. Booking.com); seit = Datum und Uhrzeit des letzten erfolgreichen Abgleichs bzw. des ersten Fehlers.
Der Abgleich mit einem externen Kalender (iCal-Link, z. B. Airbnb oder Booking.com) schlägt wiederholt fehl; belegte Tage von dort fehlen sonst im Buchungskalender. Höchstens einmal je Tag.
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Der Kalenderabgleich für {{unterkunft}} funktioniert nicht</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Buchungskalender</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">Hinweis</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Kalenderabgleich fehlgeschlagen</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>der Abgleich Deines Kalenders „{{quelle}}“ für {{unterkunft}} schlägt seit {{seit}} fehl. Solange das nicht klappt, fehlen die dort belegten Tage im Buchungskalender, und Gäste könnten Zeiträume anfragen, die schon vergeben sind. Bitte prüfe den Kalender-Link in den Einstellungen.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Kalender prüfen &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K34 · Anfrage Buchungskalender entschieden (Annahme oder Ablehnung)
Betreff: Deine Anfrage zum Buchungskalender für {{projekt}}
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{preis}}, {{ust_hinweis}}, {{begruendung}}, {{portal_url}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angenommen, abgelehnt
Genau einer der Blöcke ist an. angenommen = Anfrage angenommen, der Kunde kann den Buchungskalender jetzt bestellen; abgelehnt = mit Begründung des Teams.
preis = Preis der Einrichtung, z. B. „299 €“; ust_hinweis = Umsatzsteuerhinweis; portal_url = Seite des Projekts im Kundenportal.
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Deine Anfrage zum Buchungskalender für {{projekt}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Buchungskalender</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">Buchungskalender</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;"><!-- WENN angenommen -->Deine Anfrage wurde angenommen<!-- ENDE angenommen --><!-- WENN abgelehnt -->Zu Deiner Anfrage<!-- ENDE abgelehnt --></td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br><!-- WENN angenommen -->wir haben Deine Anfrage zum Buchungskalender für {{projekt}} angenommen. Du kannst ihn jetzt im Kundenportal bestellen; danach richten wir ihn ein.<!-- ENDE angenommen --><!-- WENN abgelehnt -->leider können wir Deine Anfrage zum Buchungskalender für {{projekt}} nicht annehmen. Den Grund findest Du unten. Wenn Du Fragen hast, antworte einfach auf diese E-Mail.<!-- ENDE abgelehnt --></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{projekt}}</strong></td></tr>
<!-- WENN angenommen --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Preis</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">einmalig {{preis}} ({{ust_hinweis}})</td></tr>
<!-- ENDE angenommen --><!-- WENN abgelehnt --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Begründung</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{begruendung}}</td></tr>
<!-- ENDE abgelehnt --></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K35 · Buchungskalender abgeschaltet – Rechnungen sichern
Betreff: Buchungskalender abgeschaltet – bitte sichere Deine Rechnungen
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{loeschdatum}}, {{portal_url}}
Geht einmal an Kunden, deren Buchungskalender abgeschaltet ist und noch Daten (Buchungen, Rechnungen) enthält: Bis zum Löschdatum (90 Tage nach dem Abschalten) sind die Rechnungen im Kundenportal abrufbar, danach löschen wir alle Daten des Buchungskalenders.
loeschdatum = Tag, ab dem wir die Daten löschen (90 Tage nach dem Abschalten), als TT.MM.JJJJ.
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Buchungskalender abgeschaltet – bitte sichere Deine Rechnungen</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Bitte sichere Deine Rechnungen vor der Löschung.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Abgeschaltet</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Buchungskalender</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Buchungskalender abgeschaltet – Rechnungen sichern</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>der Buchungskalender für Dein Projekt {{projekt}} ist abgeschaltet. Gäste können auf Deiner Website nichts mehr anfragen oder buchen. Die Daten des Buchungskalenders (Buchungen, Gastdaten und Rechnungen) löschen wir am {{loeschdatum}}. Bitte sichere Deine Rechnungen vorher.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Buchungskalender</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Daten gelöscht am</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{loeschdatum}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Was jetzt gilt</div><div class="mut" style="color:#4a4843;">Bis zum {{loeschdatum}} kannst Du Deine Rechnungen und Stornorechnungen im Kundenportal ansehen und als PDF herunterladen. Danach löschen wir alle Daten des Buchungskalenders; sie lassen sich dann nicht wiederherstellen.</div><div class="mut" style="color:#4a4843;padding-top:8px;">Bitte lade die Rechnungen herunter und bewahre sie auf. Für Rechnungen gelten gesetzliche Aufbewahrungsfristen; diese Pflicht liegt bei Dir als Vermieter.</div></td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Rechnungen im Portal öffnen &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K36 · Neue Buchungsanfrage (an den Vermieter)
Betreff: Neue Buchungsanfrage {{nummer}} für {{unterkunft}}
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · OHNE BCC (enthält Angaben des Gastes)
Platzhalter: {{kunde_name}}, {{gast_name}}, {{gast_email}}, {{unterkunft}}, {{zeitraum}}, {{naechte}}, {{personen}}, {{nummer}}, {{nachricht_gast}}, {{portal_url}}
Ein Gast hat über die Website eine Anfrage gestellt; der Vermieter prüft und gibt sie im Portal frei oder lehnt sie ab. nachricht_gast steht „–“, wenn der Gast keine geschrieben hat. portal_url = Buchungen des Projekts im Kundenportal.
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Neue Buchungsanfrage {{nummer}} für {{unterkunft}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Buchungskalender</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">Neue Anfrage</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Neue Buchungsanfrage {{nummer}}</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>über Deine Website ist eine neue Buchungsanfrage für {{unterkunft}} eingegangen. Bitte prüfe sie und gib sie frei oder lehne sie ab.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Gast</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{gast_name}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_email}}</td></tr>
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zeitraum</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeitraum}} ({{naechte}})</td></tr>
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Personen</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{personen}}</td></tr>
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Nachricht</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nachricht_gast}}</td></tr>
</table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Anfrage ansehen &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K37 · Neue Buchung (an den Vermieter)
Betreff: Neue Buchung {{nummer}} für {{unterkunft}}
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · OHNE BCC (enthält Angaben des Gastes)
Platzhalter: {{kunde_name}}, {{gast_name}}, {{unterkunft}}, {{zeitraum}}, {{naechte}}, {{personen}}, {{nummer}}, {{gesamt}}, {{zahlart_text}}, {{portal_url}}
Der Gast hat verbindlich gebucht; Rechnung und Bestätigung sind schon an ihn raus. zahlart_text = „Online-Zahlung“ oder „Zahlung vor Ort“.
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Neue Buchung {{nummer}} für {{unterkunft}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Buchungskalender</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">Neue Buchung</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Neue Buchung {{nummer}}</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>{{gast_name}} hat {{unterkunft}} verbindlich gebucht. Die Bestätigung mit der Rechnung ist schon beim Gast.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zeitraum</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeitraum}} ({{naechte}})</td></tr>
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Personen</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{personen}}</td></tr>
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Gesamt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{gesamt}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zahlung</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zahlart_text}}</td></tr>
</table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Buchung ansehen &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T23 · Neue Anfrage Buchungskalender
Betreff: [Buchungskalender] Neue Anfrage: {{kunde_name}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{kunde_name}}, {{email}}, {{projekt}}, {{nachricht}}, {{woms_url}}
Die Vorlage hat keine optionalen Blöcke. Ein Kunde hat im Portal eine Anfrage für den Buchungskalender gestellt („auf Anfrage“); das Team entscheidet im Ticketsystem (Kunde, Reiter Funktionen): annehmen oder ablehnen.
nachricht steht „–“, wenn der Kunde keine geschrieben hat. woms_url führt zum Kunden im Ticketsystem (Reiter Funktionen).
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>[Buchungskalender] Neue Anfrage: {{kunde_name}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Buchungskalender</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">Neue Anfrage</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Neue Anfrage: Buchungskalender</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">{{kunde_name}} möchte den Buchungskalender für {{projekt}} und hat eine Anfrage gestellt.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Kunde</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{kunde_name}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{email}}</td></tr>
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr>
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Nachricht</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nachricht}}</td></tr>
</table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{woms_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Im Ticketsystem öffnen &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+4 -3
View File
@@ -729,16 +729,16 @@ export function publicRateLimitState() {
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
const CONTRACTS = ['abo', 'zusatzspeicher']
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'zeiten', 'projekt']
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'unterkunft', 'zeiten', 'projekt']
const AB_NUMMER = /^AB-\d{4}-\d{4}$/
// Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist)
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', shop: 'shop', zeiten: 'zeiten' }
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', shop: 'shop', unterkunft: 'unterkunft', zeiten: 'zeiten' }
// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals)
const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' }
/**
* Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl.
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'zeiten' | 'projekt'; packages (nur beim
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'unterkunft' | 'zeiten' | 'projekt'; packages (nur beim
* Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026).
*/
function validDeclaration(body) {
@@ -815,6 +815,7 @@ function orderLabel(order, sub, { customer = false, abNummer = '' } = {}) {
if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)'
if (order === 'termine') return 'Terminplaner (Einrichtung)'
if (order === 'shop') return 'Webshop (Einrichtung)'
if (order === 'unterkunft') return 'Buchungskalender (Einrichtung)'
if (order === 'zeiten') return 'Öffnungszeiten live'
const abo = productNameOf(sub)
return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}`
+15 -4
View File
@@ -31,6 +31,7 @@ import { driftWarnung, einbauanleitung, ticketStand } from '../../services/termi
import { freischaltHindernis, meldeFreigeschaltet } from '../../services/termine/freischaltung.js'
import { ladeStand } from '../../services/termine/kontext.js'
import { shopTextformNoetig } from '../../services/shop/schalter.js'
import { unterkunftTextformNoetig } from '../../services/unterkunft/schalter.js'
import { termineTextformNoetig } from '../../services/termine/schalter.js'
/**
@@ -121,8 +122,8 @@ async function notiereK16Fehlt(projectId, doc) {
const ticketExists = async (ticketId) => (await readTicket(ticketId)) !== null
/** Verlangt die Aktivierung dieser Funktion fuer diese Adresse die Zustimmung in Textform? Nur Terminplaner und Webshop, je nach Modus und Pilotliste der Funktion. */
const textformVerlangt = { termine: termineTextformNoetig, shop: shopTextformNoetig }
/** Verlangt die Aktivierung dieser Funktion fuer diese Adresse die Zustimmung in Textform? Nur Terminplaner, Webshop und Buchungskalender, je nach Modus und Pilotliste der Funktion. */
const textformVerlangt = { termine: termineTextformNoetig, shop: shopTextformNoetig, unterkunft: unterkunftTextformNoetig }
/**
* Zustimmung des Kunden in Textform fuer die Aktivierung des Terminplaners oder des Webshops durch das Team (F13a; AVV Webshop,
@@ -147,7 +148,7 @@ async function unterlagenAlsAnhang(key) {
return await featureLegalAttachments(key, { ohne: ['widerruf'] })
} catch (err) {
console.error('[funktionen-admin] Rechtsunterlagen (PDF) fuer die Freischaltung nicht lesbar:', err.message)
const funktion = key === 'shop' ? 'Der Webshop' : 'Der Terminplaner'
const funktion = key === 'shop' ? 'Der Webshop' : key === 'unterkunft' ? 'Der Buchungskalender' : 'Der Terminplaner'
throw Object.assign(new Error(`Die Rechtsunterlagen (PDF) sind nicht lesbar. ${funktion} wurde nicht freigeschaltet. Bitte später erneut versuchen.`), { status: 503 })
}
}
@@ -199,10 +200,20 @@ async function shopFeld(customerId) {
}
}
/** Dasselbe fuer den Buchungskalender (AVV Buchungskalender, Vorbemerkung Abs. 2): Fassung = config.unterkunft.fassung. */
async function unterkunftFeld() {
const { lb, avv } = featureLegalPaths('unterkunft')
return {
textformNoetig: unterkunftTextformNoetig(),
avvVersion: config.unterkunft.fassung,
links: { lb: `${config.publicBaseUrl}${lb}`, avv: `${config.publicBaseUrl}${avv}` },
}
}
funktionenAdminRouter.get('/:customerId', async (req, res) => {
try {
const projects = await adminView(req.params.customerId)
res.json({ projects, termine: await termineFeld(req.params.customerId), shop: await shopFeld(req.params.customerId) })
res.json({ projects, termine: await termineFeld(req.params.customerId), shop: await shopFeld(req.params.customerId), unterkunft: await unterkunftFeld() })
} catch (err) {
sendError(res, err)
}
+3
View File
@@ -20,6 +20,7 @@ import {
import { actingPerson, speichereNachweisAusAnfrage, zustimmungFuerSitzung } from '../services/featureConsents.js'
import { termineFuerKunde } from '../services/termine/schalter.js'
import { shopFuerKunde } from '../services/shop/schalter.js'
import { unterkunftFuerKunde } from '../services/unterkunft/schalter.js'
import { zeitenFuerKunde } from '../services/zeiten/schalter.js'
import { einschaltenDeps, einschaltenZeiten, zeitenProjektAnsicht } from '../services/zeiten/einschalten.js'
import { starteBehalten } from '../services/zeiten/behalten.js'
@@ -238,6 +239,8 @@ router.get('/', requireSession, async (req, res) => {
// Webshop: Schalter SHOP_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel; Anfrage und Zustand je Projekt
// kommen aus projects[].features.shop und GET /api/shop/status
shop: { verfuegbar: shopFuerKunde(req.session.email) },
// Buchungskalender: Schalter UNTERKUNFT_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel
unterkunft: { verfuegbar: unterkunftFuerKunde(req.session.email) },
// Oeffnungszeiten live: Schalter ZEITEN_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann mit
// dem Abo-Stand (inklusive, grund), dem Preis und den Texten des Gratisweges "Einschalten"
zeiten: await zeitenAngebot(req.session.email, customerId, abos),
+517
View File
@@ -0,0 +1,517 @@
import { randomInt } from 'node:crypto'
import express, { Router } from 'express'
import { config } from '../config.js'
import { analyseDomains, analyseHosts } from '../services/analyse.js'
import { bucheVerbindlich, legeAnfrageAn } from '../services/unterkunft/ablauf.js'
import { belegteBereiche } from '../services/unterkunft/belegung.js'
import { istTag, naechte } from '../services/unterkunft/datum.js'
import { UkFehler } from '../services/unterkunft/fehler.js'
import { unterkunftPerSiteKey } from '../services/unterkunft/kontext.js'
import { ID_RE, belegungsStand, findeRechnung, ladeAnfrage, ladeRechnung, ladeUnterkunft, listeUnterkuenfte } from '../services/unterkunft/speicher.js'
import { checkoutFuer, schliesseAbWennBezahlt } from '../services/unterkunft/zahlung.js'
import { formToken, ipHashVon, linkGueltig, pruefeFormToken, tokenPasst } from '../services/unterkunft/token.js'
import { darfAnfrage, darfAnfrageSite, darfBuchungsversuch, zaehle } from '../services/termine/limits.js'
import { heute } from '../services/termine/zeit.js'
/**
* Oeffentliche Schnittstelle des Buchungskalenders: /api/public/unterkunft/v1 (Vertrag: docs/funktionen/unterkunft-api.md).
* Gaeste ohne Session; gemountet VOR dem globalen JSON-Parser, deshalb mit eigenem 16-KB-Parser (wie publicShop.js).
* Konfiguration, Belegung, Anfrage (Task 6); Buchungsseite GET|POST /:siteKey/buchung/:id, POST …/zahlung, GET …/rechnung.pdf (Task 7).
*
* - CORS: GET fuer alle (*). POST und Preflight nur von Seiten des Betriebs (analyseDomains des Projekts), des Portals
* (config.publicBaseUrl) oder gleicher Herkunft; sonst 403.
* - Grenzen: je IP 120 Abrufe je Minute, je Seite 600 je Minute; Anfragen je IP 10 je 10 Minuten und je Seite
* config.unterkunft.anfragenJeSiteStunde je Stunde.
* - Fehlerform: { fehler: code, felder? }.
*/
const router = Router()
const BODY_MAX = 16 * 1024
const STUNDE_MS = 60 * 60 * 1000
const NUMMER_ZEICHEN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ23456789'
const STANDARD_AKZENT = '#8a6a3f'
const AKZENT_RE = /^#[0-9a-fA-F]{6}$/
let uhr = () => new Date()
export function setzeUhrFuerTests(neueUhr) {
uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date()
}
// ---------------------------------------------------------------- Antworten und Fehler
const fehlerMit = (status, fehler, extra = {}) => Object.assign(new Error(fehler), { status, fehlerCode: fehler, ...extra })
const zuViele = (retryAfter) => fehlerMit(429, 'zu_viele_anfragen', { retryAfter })
const nichtErlaubt = () => fehlerMit(403, 'nicht_erlaubt')
const nichtGefunden = () => fehlerMit(404, 'nicht_gefunden')
const ungueltig = (felder) => fehlerMit(400, 'ungueltig', felder ? { felder } : {})
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
function fehlerErgebnis(err) {
if (err instanceof UkFehler && err.status >= 400 && err.status < 500) {
const felder = Object.fromEntries(Object.entries(err.felder ?? {}).filter(([, v]) => typeof v === 'string' || typeof v === 'number'))
return { status: err.status, body: { fehler: err.code, ...(Object.keys(felder).length ? { felder } : {}) } }
}
if (typeof err?.fehlerCode === 'string' && err.status >= 400 && err.status < 500) {
const body = { fehler: err.fehlerCode }
if (err.felder) body.felder = err.felder
return { status: err.status, body, headers: Number.isInteger(err.retryAfter) ? { 'Retry-After': String(err.retryAfter) } : {} }
}
console.error(`[publicUnterkunft] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
return { status: 503, body: { fehler: 'nicht_erreichbar' } }
}
/** Schreibt { status, body, cache, headers }; GET/HEAD tragen immer `*`, POST nur mit geprueftem cors.erlaubt (Meldungen ohne Daten auch `*`). */
function schreibe(req, res, ergebnis, cors = { erlaubt: null }) {
const { status = 200, body, typ = 'application/json; charset=utf-8', cache: cacheControl = 'no-store', headers = {} } = ergebnis
const lesen = req.method === 'GET' || req.method === 'HEAD'
if (lesen) res.set('Access-Control-Allow-Origin', '*')
else if (cors.erlaubt) res.set({ 'Access-Control-Allow-Origin': cors.erlaubt, Vary: 'Origin' })
else if (status === 429 || status === 503 || body?.aktiv === false) res.set('Access-Control-Allow-Origin', '*')
res.status(status)
res.set({ 'Cache-Control': cacheControl, 'X-Content-Type-Options': 'nosniff', ...headers })
if (status === 204) return res.end()
const inhalt = Buffer.isBuffer(body) || typeof body === 'string' ? body : JSON.stringify(body)
res.set({ 'Content-Type': typ, 'Content-Length': String(Buffer.byteLength(inhalt)) })
return res.end(inhalt)
}
function notantwort(res, err) {
try {
console.error(`[publicUnterkunft] Antwort nicht geschrieben: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
if (res.headersSent) return void res.end()
const inhalt = JSON.stringify({ fehler: 'nicht_erreichbar' })
res.statusCode = 503
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Content-Length', String(inhalt.length))
return void res.end(inhalt)
} catch {
try {
res.destroy()
} catch {
// nichts mehr zu tun
}
}
}
const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const minAntwortMs = () => (Number.isFinite(config.unterkunft?.minAntwortMs) && config.unterkunft.minAntwortMs >= 0 ? config.unterkunft.minAntwortMs : 300)
/**
* Handler mit Fehlerabbildung und optionaler Mindestantwortzeit. fn(req, now, cors) liefert ein Ergebnis fuer schreibe oder wirft;
* cors.erlaubt setzt fn, sobald die Herkunft geprueft ist. Mit mindestdauer antwortet auch ein Fehler nicht schneller als minAntwortMs.
*/
const route = (fn, { mindestdauer = false } = {}) => async (req, res) => {
try {
const cors = { erlaubt: null }
let ergebnis
try {
ergebnis = await fn(req, uhr(), cors)
} catch (err) {
ergebnis = fehlerErgebnis(err)
}
if (mindestdauer) {
const rest = minAntwortMs() - (performance.now() - req.ukStart)
if (rest > 0) await warte(rest)
}
schreibe(req, res, ergebnis, cors)
} catch (err) {
notantwort(res, err)
}
}
// ---------------------------------------------------------------- Beginn: Zeit, Rumpf
router.use((req, res, next) => {
req.ukStart = performance.now()
if (Number(req.get('content-length')) > BODY_MAX) {
res.set('Connection', 'close')
return schreibe(req, res, { status: 413, body: { fehler: 'zu_gross' } })
}
return next()
})
router.use(express.json({ limit: BODY_MAX }))
// ---------------------------------------------------------------- Herkunft
/**
* Die Origin der Anfrage, wenn sie POST und Preflight darf: Host in analyseDomains(project), Host von config.publicBaseUrl
* oder gleiche Herkunft wie die Anfrage. Sonst null.
*/
function erlaubteHerkunft(req, project) {
const origin = req.get('origin')
if (typeof origin !== 'string' || origin === '') return null
let url
try {
url = new URL(origin)
} catch {
return null
}
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.origin !== origin) return null
const erlaubt = new Set([String(req.get('host') || '').toLowerCase()])
try {
erlaubt.add(new URL(config.publicBaseUrl).host.toLowerCase())
} catch {
// Portal-Adresse unlesbar: dann gilt nur die gleiche Herkunft
}
if (project) for (const domain of analyseDomains(project)) erlaubt.add(domain)
return erlaubt.has(url.host) ? origin : null
}
function pruefeHerkunft(req, cors, project) {
const origin = erlaubteHerkunft(req, project)
if (!origin) throw nichtErlaubt()
cors.erlaubt = origin
}
const vorabantwort = (origin) => ({
status: 204,
body: '',
headers: {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
Vary: 'Origin',
},
})
// Preflight: ohne erreichbare Seite gibt es nichts zu pruefen und nichts zu verraten.
router.options(
'/:siteKey/:was',
route(async (req, now, cors) => {
if (req.params.was !== 'anfrage') throw nichtGefunden()
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now })
if (!ctx) return vorabantwort('*')
pruefeHerkunft(req, cors, ctx.project)
return vorabantwort(cors.erlaubt)
}),
)
router.options(
['/:siteKey/buchung/:id', '/:siteKey/buchung/:id/zahlung'],
route(async (req, now, cors) => {
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now })
if (!ctx) return vorabantwort('*')
pruefeHerkunft(req, cors, ctx.project)
return vorabantwort(cors.erlaubt)
}),
)
// ---------------------------------------------------------------- Konfiguration
const akzentfarbe = (e) => (AKZENT_RE.test(e?.akzentfarbe ?? '') ? e.akzentfarbe : STANDARD_AKZENT)
const aktivFalse = () => ({ body: { aktiv: false }, cache: 'public, max-age=15' })
function datenschutzUrl(project) {
if (!project) return null
const hosts = analyseHosts(project) // [Vorschau-Subdomain, Live-Domain]: die Live-Domain, wenn es eine gibt
const host = hosts[hosts.length - 1]
return host ? `https://${host}/datenschutz` : null
}
router.get(
'/:siteKey',
route(async (req, now) => {
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now })
if (!ctx) return aktivFalse()
if (!darfAnfrageSite(req.params.siteKey, now)) throw zuViele(60)
const e = ctx.einstellungen ?? {}
const unterkuenfte = await listeUnterkuenfte(ctx.featureId, { nurAktive: true })
return {
body: {
aktiv: true,
anbieter: { name: String(e.firma ?? ''), ort: String(e.ort ?? ''), datenschutzUrl: datenschutzUrl(ctx.project) },
akzentfarbe: akzentfarbe(e),
formToken: formToken(req.params.siteKey, now),
unterkuenfte: unterkuenfte.map((u) => ({
id: u.$id,
name: u.name,
maxPersonen: u.maxPersonen,
mindestNaechte: u.mindestNaechte,
checkIn: u.checkIn,
checkOut: u.checkOut,
})),
},
}
}),
)
// ---------------------------------------------------------------- Belegung
function monateDazu(tag, n) {
const [j, m, t] = tag.split('-').map(Number)
const d = new Date(Date.UTC(j, m - 1 + n, 1))
const letzter = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate()
d.setUTCDate(Math.min(t, letzter))
return d.toISOString().slice(0, 10)
}
/** Fasst sich beruehrende oder ueberlappende Bereiche (nach von sortiert) zusammen; die Quelle wird nicht ausgegeben. */
function verschmelze(bereiche) {
const aus = []
for (const b of bereiche) {
const letzter = aus[aus.length - 1]
if (letzter && b.von <= letzter.bis) {
if (b.bis > letzter.bis) letzter.bis = b.bis
} else aus.push({ von: b.von, bis: b.bis })
}
return aus
}
// Antwort von /belegung 10 s im Speicher je siteKey+unterkunft+von+bis; jedes Schreiben an Sperren/Anfragen (speicher.belegungsStand) macht sie ungueltig
const BELEGUNG_TTL_MS = 10 * 1000
const BELEGUNG_MAX = 2000
const belegungSpeicher = new Map()
export const leereBelegungSpeicherFuerTests = () => belegungSpeicher.clear()
router.get(
'/:siteKey/belegung',
route(async (req, now) => {
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now })
if (!ctx) return aktivFalse()
if (!darfAnfrageSite(req.params.siteKey, now)) throw zuViele(60)
if (typeof req.query.unterkunft === 'string' && !ID_RE.test(req.query.unterkunft)) throw ungueltig({ unterkunft: 'Ungültige Unterkunft.' })
const { unterkunft, von, bis } = req.query
if (typeof unterkunft !== 'string' || unterkunft === '' || !istTag(von) || !istTag(bis) || bis < von) {
throw ungueltig({ zeitraum: 'Ungültiger Zeitraum.' })
}
const tag = heute(now)
const nowMs = now instanceof Date ? now.getTime() : Number(now)
const speicherKey = `${req.params.siteKey}|${unterkunft}|${von}|${bis}`
const gemerkt = belegungSpeicher.get(speicherKey)
if (gemerkt && gemerkt.bis > nowMs && gemerkt.stand === belegungsStand() && gemerkt.tag === tag) return { body: gemerkt.body }
const stand = belegungsStand()
const bisMax = monateDazu(tag, config.unterkunft.horizontMonate)
const bisBis = bis > bisMax ? bisMax : bis // Fenster des Widgets (UTC-Datum des Browsers) darf nie an einem Tag Abweichung scheitern
const u = await ladeUnterkunft(unterkunft)
if (!u || u.featureId !== ctx.featureId || u.aktiv !== true) throw nichtGefunden()
const ab = von < tag ? tag : von
const belegt = ab < bisBis ? verschmelze(await belegteBereiche(u.$id, ab, bisBis, { now })) : []
const body = { belegt, heute: tag, bisMax }
if (belegungSpeicher.size >= BELEGUNG_MAX) {
for (const [k, e] of belegungSpeicher) if (e.bis <= nowMs) belegungSpeicher.delete(k)
if (belegungSpeicher.size >= BELEGUNG_MAX) belegungSpeicher.delete(belegungSpeicher.keys().next().value)
}
belegungSpeicher.set(speicherKey, { bis: nowMs + BELEGUNG_TTL_MS, stand, tag, body })
return { body }
}),
)
// ---------------------------------------------------------------- Anfrage
function scheinNummer() {
let n = 'A-'
for (let k = 0; k < 4; k++) n += NUMMER_ZEICHEN[randomInt(NUMMER_ZEICHEN.length)]
return n
}
const text = (wert) => (typeof wert === 'string' ? wert : '')
router.post(
'/:siteKey/anfrage',
route(
async (req, now, cors) => {
const { siteKey } = req.params
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
const ctx = await unterkunftPerSiteKey(siteKey, { now })
if (!ctx) return { body: { aktiv: false } }
pruefeHerkunft(req, cors, ctx.project)
if (!darfBuchungsversuch(req.ip, now)) throw zuViele(600)
const b = req.body
if (!istObjekt(b)) throw ungueltig()
// Honigtopf gefuellt oder zu schnell abgeschickt: dieselbe Erfolgsantwort, gespeichert und gesendet wird nichts.
if (b.kontakt_url !== undefined && b.kontakt_url !== null && b.kontakt_url !== '') return { status: 201, body: { nummer: scheinNummer() } }
const pruefung = pruefeFormToken(siteKey, b.formToken, now)
if (pruefung === 'zu_schnell') return { status: 201, body: { nummer: scheinNummer() } }
if (pruefung !== 'ok') throw fehlerMit(400, 'neu_laden')
if (!ID_RE.test(text(b.unterkunft))) throw ungueltig({ unterkunft: 'Ungültige Unterkunft.' })
if (!istTag(b.von) || !istTag(b.bis)) throw ungueltig({ zeitraum: 'Ungültige Daten.' })
// je Website erst nach der Feldpruefung zaehlen: fehlerhafte Formulare verbrauchen das Stundenkontingent nicht
if (!zaehle(`uk-anfrage:${siteKey}`, config.unterkunft.anfragenJeSiteStunde, STUNDE_MS, now)) throw zuViele(3600)
const personen = typeof b.personen === 'string' && /^\d{1,3}$/.test(b.personen) ? Number(b.personen) : b.personen
const anfrage = await legeAnfrageAn(
ctx,
{
unterkunftId: text(b.unterkunft),
von: b.von,
bis: b.bis,
personen,
gast: { name: text(b.name), firma: text(b.firma), email: text(b.email), telefon: text(b.telefon), nachricht: text(b.nachricht) },
},
{ now, ipHash: ipHashVon(req.ip) },
)
return { status: 201, body: { nummer: anfrage.nummer } }
},
{ mindestdauer: true },
),
)
// Rumpf nicht lesbar (kaputtes JSON): 400 in der Fehlerform der Schnittstelle
router.use((err, req, res, next) => {
if (err?.type === 'entity.parse.failed' || err?.type === 'entity.too.large') {
return schreibe(req, res, { status: err.type === 'entity.too.large' ? 413 : 400, body: { fehler: err.type === 'entity.too.large' ? 'zu_gross' : 'ungueltig' } })
}
return next(err)
})
// ---------------------------------------------------------------- Buchungsseite (Task 7)
const BUCHUNG = { mindestdauer: true }
const ART_RE = /^(rechnung|storno)$/
/** Die Anfrage zu Pfad-ID und Token: fuer unbekannte ID, fremde Funktion, falsches Token und abgelaufenen Link (token.linkGueltig) dieselbe 404-Antwort. */
async function ladeMitToken(ctx, id, token, now) {
if (!ID_RE.test(id) || typeof token !== 'string' || token === '' || token.length > 200) throw nichtGefunden()
const a = await ladeAnfrage(id)
if (!a || a.featureId !== ctx.featureId || !tokenPasst(a.tokenHash, token) || !linkGueltig(a, now)) throw nichtGefunden()
return a
}
/** Kontext der Buchungsseite: Lesen je IP begrenzt; Schreiben nur von erlaubter Herkunft und je IP als Buchungsversuch gezaehlt. */
async function buchungsKontext(req, now, cors, { schreiben }) {
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now })
if (!ctx) throw nichtGefunden()
if (schreiben) {
pruefeHerkunft(req, cors, ctx.project)
if (!darfBuchungsversuch(req.ip, now)) throw zuViele(600)
} else if (!darfAnfrageSite(req.params.siteKey, now)) throw zuViele(60)
return ctx
}
const onlineMoeglich = (ctx) => Boolean(ctx.einstellungen?.onlineZahlung && ctx.einstellungen?.stripeAccountId)
const wort = (v, max = 200) => (typeof v === 'string' ? v.slice(0, max) : '')
async function buchungAntwort(ctx, a) {
const [u, rechnung] = await Promise.all([ladeUnterkunft(a.unterkunftId), a.rechnungId ? ladeRechnung(a.rechnungId) : null])
const e = ctx.einstellungen ?? {}
const adr = a.rechnungsadresse
return {
status: a.status,
nummer: a.nummer,
unterkunft: { name: u?.name ?? '', checkIn: u?.checkIn ?? '', checkOut: u?.checkOut ?? '' },
von: a.von,
bis: a.bis,
naechte: naechte(a.von, a.bis),
personen: a.personen,
posten: Array.isArray(a.posten) ? a.posten : [],
summen: a.summen ?? null,
steuerart: a.summen?.steuerart ?? '',
fristAt: a.fristAt ?? '',
stornobedingungen: wort(a.stornobedingungenText, 5000),
anbieter: {
firma: wort(e.firma),
inhaber: wort(e.inhaber),
strasse: wort(e.strasse),
plz: wort(e.plz),
ort: wort(e.ort),
email: wort(e.email),
telefon: wort(e.telefon),
datenschutzUrl: datenschutzUrl(ctx.project),
},
akzentfarbe: akzentfarbe(e),
gast: { name: wort(a.gast?.name), firma: wort(a.gast?.firma) },
...(adr && typeof adr === 'object'
? { rechnungsadresse: { name: wort(adr.name), firma: wort(adr.firma), strasse: wort(adr.strasse), plz: wort(adr.plz), ort: wort(adr.ort), land: wort(adr.land), ustId: wort(adr.ustId, 20) } }
: {}),
zahlarten: onlineMoeglich(ctx) ? ['vorort', 'online'] : ['vorort'],
...(a.zahlart ? { zahlart: a.zahlart } : {}),
bezahlt: a.status === 'bezahlt',
...(rechnung?.nummer ? { rechnungNummer: rechnung.nummer } : {}),
}
}
router.get(
'/:siteKey/buchung/:id',
route(
async (req, now, cors) => {
const ctx = await buchungsKontext(req, now, cors, { schreiben: false })
let a = await ladeMitToken(ctx, req.params.id, req.query.t, now)
// Rueckkehr von Stripe vor dem Webhook (oder Webhook verloren): einmal bei Stripe nachsehen
a = await schliesseAbWennBezahlt(ctx, a, { now, erzwingen: req.query.wk_zahlung === 'ok' })
return { body: await buchungAntwort(ctx, a) }
},
BUCHUNG,
),
)
router.post(
'/:siteKey/buchung/:id',
route(
async (req, now, cors) => {
const ctx = await buchungsKontext(req, now, cors, { schreiben: true })
const a = await ladeMitToken(ctx, req.params.id, req.query.t, now)
const b = req.body
if (!istObjekt(b)) throw ungueltig()
const { anfrage, rechnung } = await bucheVerbindlich(ctx, a.$id, req.query.t, {
rechnungsadresse: istObjekt(b.rechnungsadresse) ? b.rechnungsadresse : {},
zahlart: text(b.zahlart),
now,
})
const body = { status: anfrage.status, rechnungNummer: rechnung?.nummer ?? '' }
if (anfrage.status === 'gebucht' && anfrage.zahlart === 'online') {
try {
body.zahlungUrl = (await checkoutFuer(ctx, anfrage, { now, token: req.query.t })).url
} catch (err) {
// Gebucht ist gebucht: die Seite holt die Zahlung ueber POST .../zahlung nach
console.warn(`[publicUnterkunft] Zahlungssitzung nicht angelegt: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 120)}`)
}
}
return { status: 200, body }
},
BUCHUNG,
),
)
router.post(
'/:siteKey/buchung/:id/zahlung',
route(
async (req, now, cors) => {
const ctx = await buchungsKontext(req, now, cors, { schreiben: true })
let a = await ladeMitToken(ctx, req.params.id, req.query.t, now)
a = await schliesseAbWennBezahlt(ctx, a, { now, erzwingen: true })
if (a.status !== 'gebucht' || a.zahlart !== 'online') throw fehlerMit(409, 'status_ungueltig')
try {
return { body: await checkoutFuer(ctx, a, { now, token: req.query.t }) }
} catch (err) {
if (err instanceof UkFehler) throw err
throw new UkFehler(502, 'zahlung_fehlgeschlagen')
}
},
BUCHUNG,
),
)
router.get(
'/:siteKey/buchung/:id/rechnung.pdf',
route(
async (req, now, cors) => {
const ctx = await buchungsKontext(req, now, cors, { schreiben: false })
const a = await ladeMitToken(ctx, req.params.id, req.query.t, now)
const art = typeof req.query.art === 'string' ? req.query.art : 'rechnung'
if (!ART_RE.test(art)) throw ungueltig({ art: 'Ungültig.' })
const dok = await findeRechnung(a.$id, art)
if (!dok) throw nichtGefunden()
let modul
try {
modul = await import('../services/unterkunft/rechnungPdf.js')
} catch (err) {
if (err?.code !== 'ERR_MODULE_NOT_FOUND') throw err
return { status: 501, body: { fehler: 'nicht_verfuegbar' } }
}
const pdf = modul.rechnungPdf(dok.inhalt, { art, nummer: dok.nummer, datum: dok.datum })
const dateiname = `${art === 'storno' ? 'Stornorechnung' : 'Rechnung'}-${String(dok.nummer).replace(/[^A-Za-z0-9_-]/g, '')}.pdf`
return { body: pdf, typ: 'application/pdf', headers: { 'Content-Disposition': `inline; filename="${dateiname}"` } }
},
BUCHUNG,
),
)
export default router
+14
View File
@@ -6,6 +6,7 @@ import { connectEreignis, statusAusKonto, statusMitHinweis } from '../services/s
import { meldeEingang, schliesseAb, verwirf } from '../services/shop/bestellungen.js'
import { bucheStripeErstattung } from '../services/shop/erstattungen.js'
import { ladeShop, leereCache } from '../services/shop/kontext.js'
import { kontoAktualisiertUk, verarbeiteUkEreignis } from './unterkunftWebhook.js'
/**
* Stripe-Connect-Webhook des Webshops: POST /api/shop/stripe-webhook (Plan 2026-10-05-webshop.md, Task D3).
@@ -41,10 +42,23 @@ async function verarbeite(event, now) {
if (event.type === 'account.updated') {
await kontoAktualisiert(accountId || objekt.id, objekt, now)
// dasselbe Konto kann auch im Buchungskalender haengen
try {
await kontoAktualisiertUk(accountId || objekt.id, objekt, now)
} catch (err) {
// ein Fehler im Buchungskalender darf die Antwort fuer den Shop nie beeinflussen (ohne Kundendaten im Log)
console.warn(`[shop] Webhook account.updated: Buchungskalender nicht aktualisiert: ${err?.name}`)
}
return
}
if (!accountId) return
// Buchungskalender: Checkout-Sitzungen mit wkUk, Erstattungen nach den Metadaten der Zahlung
if (objekt.metadata?.wkUk === '1' || (event.type === 'charge.refunded' && objekt.metadata?.webklar === 'unterkunft')) {
await verarbeiteUkEreignis(event, now)
return
}
if (event.type === 'charge.refunded') {
await bucheStripeErstattung({ accountId, charge: objekt, now })
return
+3
View File
@@ -10,6 +10,7 @@ import customersRoutes from './customers.js'
import filesRoutes from './files.js'
import previewRoutes from './preview.js'
import shopTeamRoutes from './shop.js'
import unterkunftTeamRoutes from './unterkunft.js'
import termineTeamRoutes from './termine.js'
// Team-Eingang fuer das Ticketsystem: alle Mitarbeiter, Appwrite-JWT (Spec 2026-10-01 Admin-Panel ins Ticketsystem)
@@ -29,6 +30,8 @@ router.use('/funktionen', funktionenAdminRouter)
router.use('/termine', termineTeamRoutes)
// Webshop: Stand je Projekt und Entscheidung ueber die Anfrage
router.use('/shop', shopTeamRoutes)
// Buchungskalender: Stand je Projekt und Entscheidung ueber die Anfrage
router.use('/unterkunft', unterkunftTeamRoutes)
// Abgerechnete Arbeitsblaetter (Abzeichen am Ticket, alle Mitarbeiter)
router.use('/arbeitsblaetter', arbeitsblaetterTeamRoutes)
+88
View File
@@ -0,0 +1,88 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { Query, countDocuments, listDocuments } from '../../services/appwriteAdmin.js'
import { anfrageAnsichtTeam, entscheideAnfrage } from '../../services/unterkunft/anfrageFunktion.js'
import { ladeEinstellungen, listeUnterkuenfte } from '../../services/unterkunft/speicher.js'
import { listCustomerFeatureDocs } from '../../services/siteFeatures.js'
/**
* Buchungskalender im Ticketsystem (Kunden-Reiter "Funktionen"): GET /api/team/unterkunft/:customerId (Stand je Projekt) und
* POST /api/team/unterkunft/:customerId/anfrage (Entscheidung ueber die Anfrage der Funktion). Huelle wie routes/team/shop.js.
* Eingehaengt in routes/team/index.js unter /unterkunft, dort hinter requireStaffJwt.
*/
const router = Router()
const sendError = (res, err) => {
const status = err.status >= 400 && err.status < 600 ? err.status : 500
if (status >= 500) console.error('[team-unterkunft]', err.message)
return res.status(status).json({ error: status >= 500 ? 'Interner Serverfehler' : err.message, ...(err.body?.code ? { code: err.body.code } : {}) })
}
const route = (fn) => (req, res) => fn(req, res).catch((err) => sendError(res, err))
const person = (req) => ({ name: req.session.adminName || req.session.name || '', email: req.session.email || '' })
/** Zaehler oder null, wenn die Abfrage scheitert (die Uebersicht bleibt lesbar). */
async function zaehle(collection, queries, was) {
try {
return await countDocuments(config.collections[collection], queries)
} catch (err) {
console.warn(`[team-unterkunft] ${was} nicht lesbar: ${err.message}`)
return null
}
}
/** stripeStatus (JSON-Objekt in ukEinstellungen, schon geparst) oder null. */
const stripeStatus = (roh) => (roh !== null && typeof roh === 'object' && !Array.isArray(roh) ? roh : null)
async function projektStand(project, feature) {
let einst = null
let unterkuenfte = 0
if (feature) {
try {
einst = await ladeEinstellungen(feature.$id)
} catch (err) {
console.warn(`[team-unterkunft] Einstellungen ${feature.$id} nicht lesbar: ${err.message}`)
}
try {
unterkuenfte = (await listeUnterkuenfte(feature.$id)).length
} catch (err) {
console.warn(`[team-unterkunft] Unterkuenfte ${feature.$id} nicht lesbar: ${err.message}`)
unterkuenfte = null
}
}
const offeneAnfragen = feature ? await zaehle('ukAnfragen', [Query.equal('featureId', feature.$id), Query.equal('status', 'neu')], 'Anfragenzahl') : 0
return {
projectId: project.$id,
name: project.projectName || '',
state: feature?.state || 'off',
anfrage: einst?.anfrageStatus ? anfrageAnsichtTeam(einst) : null,
stripe: { accountId: Boolean(einst?.stripeAccountId), status: stripeStatus(einst?.stripeStatus) },
unterkuenfte,
offeneAnfragen,
}
}
// Uebersicht je Projekt des Kunden; archivierte Projekte nur, wenn dort ein Dokument der Funktion existiert
router.get('/:customerId', route(async (req, res) => {
const customerId = req.params.customerId
const [docs, projects] = await Promise.all([
listCustomerFeatureDocs(customerId),
listDocuments(config.collections.websiteProjects, [Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), Query.limit(1000)]),
])
const ukDocs = new Map(docs.filter((d) => d.featureKey === 'unterkunft').map((d) => [d.projectId, d]))
const sichtbar = projects.filter((p) => !p.archived || ukDocs.has(p.$id))
res.json({ projects: await Promise.all(sichtbar.map((p) => projektStand(p, ukDocs.get(p.$id)))) })
}))
router.post('/:customerId/anfrage', route(async (req, res) => {
const { projectId, entscheidung, begruendung } = req.body || {}
const ergebnis = await entscheideAnfrage({
customerId: req.params.customerId,
projectId,
entscheidung,
begruendung,
person: person(req),
})
res.json({ ok: true, anfrage: ergebnis.anfrage, ...(ergebnis.mailFehler ? { mailFehler: true } : {}) })
}))
export default router
+696
View File
@@ -0,0 +1,696 @@
import { Router } from 'express'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
import { config } from '../config.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { withLocks } from '../services/keyedLock.js'
import { isKundenansicht } from '../services/kundenansicht.js'
import { createWriteLimiter } from '../services/writeLimiter.js'
import { kontoAnlegen, kontoStatus, onboardingLink, statusAusKonto } from '../services/shop/connect.js'
import * as ablauf from '../services/unterkunft/ablauf.js'
import { istFrei } from '../services/unterkunft/belegung.js'
import { istTag, naechte } from '../services/unterkunft/datum.js'
import { anfrageAnsicht as funktionAnfrageAnsicht, stelleAnfrage, zieheZurueck } from '../services/unterkunft/anfrageFunktion.js'
import { UkFehler } from '../services/unterkunft/fehler.js'
import { ladeFuerProjekt, leereCache } from '../services/unterkunft/kontext.js'
import { berechnePreis, preiseVollstaendig, summiere } from '../services/unterkunft/preis.js'
import { unterkunftFuerKunde } from '../services/unterkunft/schalter.js'
import * as sp from '../services/unterkunft/speicher.js'
import * as vw from '../services/unterkunft/verwaltung.js'
/**
* Kunden-API des Buchungskalenders: /api/unterkunft (Session, Projekt per ?projectId= oder Body; Vertrag: docs/funktionen/unterkunft-api.md,
* Antwortformen: ui-erwartungen.md). Jede Route: Sitzung, eigenes Projekt (403), Funktion vorhanden und nicht off (409 nicht_aktiv).
* Schreibende Routen zusaetzlich: nur der Kunde selbst (nicht Kundenansicht/"Ansehen als"), Zustand setup|active, Schreibgrenze
* (300 je Kunde und Stunde). Alle IDs aus dem Pfad werden vor jedem Zugriff geprueft und gehoeren zum Projekt (sonst 404).
* Fehler: { fehler: code, felder? }; Texte von Appwrite oder Stripe verlassen den Server nie. Die Anfrage der Funktion (/anfrage-funktion, nur solange sie off ist) hat eine eigene Grenze (20 je Kunde und Stunde).
*/
const router = Router()
// Jede Route hier braucht eine Sitzung; Kunden-Pruefung je Route (nurKunde)
router.use(requireSession)
router.use((_req, res, next) => {
res.set('Cache-Control', 'no-store')
next()
})
const MAX_WRITES_PER_HOUR = 300
const MAX_ANFRAGE_WRITES_PER_HOUR = 20
const MELDUNG = {
kundenansicht: 'In der Kundenansicht kann das nicht geändert werden.',
zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.',
stripeFern: 'Stripe ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.',
plattform: 'Die Stripe-Anbindung ist bei WEBklar noch nicht fertig eingerichtet. Wir kümmern uns darum – schreib uns gern im Chat.',
erstattung: 'Die Stornierung wurde durchgeführt, aber die Erstattung ist fehlgeschlagen. Bitte erstatte den Betrag im Stripe-Dashboard oder versuch es später noch einmal.',
}
const schreibLimiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR })
const anfrageLimiter = createWriteLimiter({ max: MAX_ANFRAGE_WRITES_PER_HOUR })
const limitAnfrage = anfrageLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
const limitWrites = schreibLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
export function resetUnterkunftRoutesForTests() {
schreibLimiter.reset()
anfrageLimiter.reset()
}
const nurKunde = (req, res, next) => {
if (isKundenansicht(req.session) || req.session?.viewAs) return res.status(403).json({ fehler: 'kundenansicht', error: MELDUNG.kundenansicht })
return next()
}
const schreib = [nurKunde, limitWrites]
/** Fachfehler und 403 wie sie sind, alles andere 500 ohne Rohtext. */
function fail(res, err) {
if (err instanceof UkFehler) {
const body = { fehler: err.code }
if (err.felder && Object.keys(err.felder).length) body.felder = err.felder
if (err.status === 502 && err.code === 'erstattung_fehlgeschlagen') body.meldung = MELDUNG.erstattung
return res.status(err.status).json(body)
}
if (err?.status === 403 && !err.type) return res.status(403).json({ fehler: 'kein_zugriff', error: err.message })
if (err?.fachlich) return res.status(err.status).json({ fehler: err.fachlich, ...(err.meldung ? { meldung: err.meldung } : {}) })
console.error(`[unterkunft] ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${String(err?.message ?? '').slice(0, 200)}`.trim())
return res.status(500).json({ fehler: 'intern', error: 'Interner Serverfehler' })
}
const route = (fn) => async (req, res) => {
try {
await fn(req, res)
} catch (err) {
fail(res, err)
}
}
const stop = (status, fachlich, extra = {}) => Object.assign(new Error(fachlich), { status, fachlich, ...extra })
const ungueltig = (felder) => new UkFehler(400, 'ungueltig', felder)
const nichtGefunden = () => new UkFehler(404, 'nicht_gefunden')
const projectIdOf = (req) => {
const raw = req.query.projectId ?? req.body?.projectId
return typeof raw === 'string' ? raw : ''
}
const body = (req) => (req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {})
/** Eigenes Projekt (403) und Funktion des Projekts; schreiben verlangt Zustand setup|active. */
async function mitProjekt(req, { schreiben = false } = {}) {
const projectId = projectIdOf(req)
if (!projectId) throw ungueltig({ projectId: 'Bitte wähle ein Projekt.' })
const customerId = getSessionCustomerId(req)
await assertOwnProject(customerId, projectId)
const ctx = await ladeFuerProjekt(customerId, projectId)
if (!ctx || !unterkunftFuerKunde(ctx.besitzerEmail)) throw new UkFehler(409, 'nicht_aktiv')
if (schreiben && !['setup', 'active'].includes(ctx.state)) throw new UkFehler(409, 'nicht_aktiv')
return ctx
}
/** Unterkunft aus dem Pfad: ID geprueft, gehoert zum Projekt (sonst 404). */
async function eigeneUnterkunft(ctx, id) {
if (typeof id !== 'string' || !sp.ID_RE.test(id)) throw nichtGefunden()
const u = await sp.ladeUnterkunft(id)
if (!u || u.featureId !== ctx.featureId) throw nichtGefunden()
return u
}
async function eigeneAnfrage(ctx, id) {
if (typeof id !== 'string' || !sp.ID_RE.test(id)) throw nichtGefunden()
const a = await sp.ladeAnfrage(id)
if (!a || a.featureId !== ctx.featureId) throw nichtGefunden()
return a
}
const sperreKey = (id) => ['unterkunft:' + id]
// ---- Ansichten (nie tokenHash, ipHash, Konto-ID, Stripe-IDs)
const gastAnsicht = (g) => ({ name: g?.name ?? '', firma: g?.firma ?? '', email: g?.email ?? '', telefon: g?.telefon ?? '', nachricht: g?.nachricht ?? '' })
function unterkunftAnsicht(u) {
return {
id: u.$id,
name: u.name ?? '',
beschreibung: u.beschreibung ?? '',
maxPersonen: u.maxPersonen ?? 1,
mindestNaechte: u.mindestNaechte ?? 1,
checkIn: u.checkIn ?? '',
checkOut: u.checkOut ?? '',
sort: u.sort ?? 0,
aktiv: u.aktiv === true,
icalImporte: (Array.isArray(u.icalImporte) ? u.icalImporte : []).map((i) => ({
id: i.id,
name: i.name ?? '',
url: i.url ?? '',
letzterAbrufAt: i.letzterAbrufAt ?? null,
letzterErfolgAt: i.letzterErfolgAt ?? null,
fehler: i.fehler ?? '',
})),
icalExportUrl: vw.exportUrl(u.icalExportToken),
}
}
function anfrageAnsicht(a, { unterkunftName = '', rechnungNummer = null, stornoRechnungId = null, steuerart = null } = {}) {
return {
id: a.$id,
nummer: a.nummer ?? '',
status: a.status ?? '',
von: a.von,
bis: a.bis,
naechte: istTag(a.von) && istTag(a.bis) ? naechte(a.von, a.bis) : 0,
personen: a.personen ?? 0,
unterkunftId: a.unterkunftId ?? '',
unterkunftName,
gast: gastAnsicht(a.gast),
rechnungsadresse: a.rechnungsadresse ?? null,
posten: a.posten ?? null,
summen: a.summen ?? null,
steuerart: a.summen?.steuerart ?? steuerart,
zahlart: a.zahlart || null,
bezahltVia: a.bezahltVia || null,
bezahltAt: a.bezahltAt || null,
erstattetCent: a.erstattetCent ?? 0,
rechnungId: a.rechnungId || null,
rechnungNummer,
stornoRechnungId,
fristAt: a.fristAt || null,
nachrichtVermieter: a.nachrichtVermieter ?? '',
}
}
/** Anfrage mit Namen der Unterkunft und Rechnungsnummern (nur Rechnungen dieses Projekts). */
async function volleAnfrageAnsicht(ctx, a, preise) {
const [u, re, storno] = await Promise.all([
sp.ladeUnterkunft(a.unterkunftId),
a.rechnungId ? sp.ladeRechnung(a.rechnungId) : null,
a.status === 'storniert' ? sp.findeRechnung(a.$id, 'storno') : null,
])
return anfrageAnsicht(a, {
unterkunftName: u?.name ?? '',
rechnungNummer: re && re.featureId === ctx.featureId ? re.nummer : null,
stornoRechnungId: storno && storno.featureId === ctx.featureId ? storno.$id : null,
steuerart: preise?.steuerart ?? null,
})
}
const preiseAnsicht = (p) =>
p
? {
staffeln: Array.isArray(p.staffeln) ? p.staffeln : [],
reinigungCent: p.reinigungCent ?? 0,
personenInklusive: p.personenInklusive ?? 1,
aufpreisPersonNachtCent: p.aufpreisPersonNachtCent ?? 0,
direktrabattProzent: p.direktrabattProzent ?? 0,
steuerart: p.steuerart ?? 'regel',
ustUebernachtung: p.ustUebernachtung ?? 0,
ustReinigung: p.ustReinigung ?? 0,
fristTage: p.fristTage ?? 3,
stornobedingungen: p.stornobedingungen ?? '',
}
: null
const STRIPE_FELDER = ['charges_enabled', 'payouts_enabled', 'details_submitted', 'currently_due', 'past_due', 'disabled_reason', 'geprueftAt']
const stripeStatusKnapp = (s) => (s && typeof s === 'object' ? Object.fromEntries(STRIPE_FELDER.filter((k) => Object.hasOwn(s, k)).map((k) => [k, s[k]])) : null)
const stripeZustand = (einst) => (!einst?.stripeAccountId ? 'keins' : einst.stripeStatus?.charges_enabled === true ? 'aktiv' : 'onboarding')
/** shopConfig mit Stripe-Konto desselben Projekts (shopConfig hat die ID des Shop-Funktionsdokuments), sonst null. */
async function shopMitKonto(customerId, projectId) {
const doc = await getFeatureDoc(customerId, projectId, 'shop')
if (!doc) return null
try {
const shop = await getDocument(config.collections.shopConfig, doc.$id)
return typeof shop?.stripeAccountId === 'string' && shop.stripeAccountId ? shop : null
} catch (err) {
if (err?.status === 404) return null
throw err
}
}
// ---- Status
router.get('/status', route(async (req, res) => {
const projectId = projectIdOf(req)
if (!projectId) throw ungueltig({ projectId: 'Bitte wähle ein Projekt.' })
const customerId = getSessionCustomerId(req)
await assertOwnProject(customerId, projectId)
const doc = await getFeatureDoc(customerId, projectId, 'unterkunft')
const state = doc?.state ?? 'off'
const antwort = { state, stripe: { status: 'keins', vorhandenesShopKonto: false }, onlineZahlung: false, unterkuenfte: 0, rechnungenVorhanden: false }
if (state === 'off') {
// Anfrage der Funktion: offen | abgelehnt | angenommen (zurueckgezogen und keine zaehlen als keine)
let ansicht = null
try {
ansicht = doc ? funktionAnfrageAnsicht(await sp.ladeEinstellungen(doc.$id)) : null
} catch (err) {
console.warn(`[unterkunft] Anfrage im Status nicht lesbar: ${err?.message}`)
}
antwort.anfrage = ansicht && ansicht.status !== 'zurueckgezogen' ? ansicht : null
return res.json(antwort)
}
const [einst, unterkuenfte, rechnungen, shop] = await Promise.all([
sp.ladeEinstellungen(doc.$id),
sp.listeUnterkuenfte(doc.$id),
sp.listeRechnungen(doc.$id),
shopMitKonto(customerId, projectId),
])
antwort.stripe = { status: stripeZustand(einst), vorhandenesShopKonto: Boolean(shop) }
antwort.onlineZahlung = einst?.onlineZahlung === true
antwort.unterkuenfte = unterkuenfte.length
antwort.rechnungenVorhanden = rechnungen.length > 0
const preise = await Promise.all(unterkuenfte.map((u) => sp.ladePreise(u.$id)))
const mitArt = preise.find((p) => p?.steuerart)
if (mitArt) antwort.steuerart = mitArt.steuerart
res.json(antwort)
}))
// ---- Anfrage der Funktion (nur solange sie off ist; kein mitProjekt)
/** Fehler des Anfrage-Dienstes (status, Text, body.code, fehler je Feld) in { fehler: code, felder? }. */
function anfrageFehler(err) {
if (err instanceof UkFehler || err?.status === 403 || err?.fachlich || !(err?.status >= 400 && err.status < 500)) return err
if (err.fehler) return new UkFehler(400, 'ungueltig', err.fehler)
return new UkFehler(err.status, err.body?.code || (err.status === 400 ? 'ungueltig' : 'anfrage'), err.status === 400 ? { projectId: err.message } : {})
}
const anfrageKunde = (req) => ({ name: req.session.name || '', email: req.session.email || '' })
router.post('/anfrage-funktion', nurKunde, limitAnfrage, route(async (req, res) => {
const projectId = projectIdOf(req)
if (!projectId) throw ungueltig({ projectId: 'Bitte wähle ein Projekt.' })
try {
const anfrage = await stelleAnfrage({ customerId: getSessionCustomerId(req), projectId, eingabe: body(req), person: anfrageKunde(req) })
res.json({ anfrage })
} catch (err) {
throw anfrageFehler(err)
}
}))
router.post('/anfrage-funktion/zurueckziehen', nurKunde, limitAnfrage, route(async (req, res) => {
const projectId = projectIdOf(req)
if (!projectId) throw ungueltig({ projectId: 'Bitte wähle ein Projekt.' })
try {
res.json({ anfrage: await zieheZurueck({ customerId: getSessionCustomerId(req), projectId, person: anfrageKunde(req) }) })
} catch (err) {
throw anfrageFehler(err)
}
}))
// ---- Einstellungen
async function einstellungenAntwort(ctx, einst) {
const e = einst ?? {}
const ansicht = Object.fromEntries(vw.EINSTELLUNG_FELDER.map((k) => [k, e[k] ?? '']))
ansicht.onlineZahlung = e.onlineZahlung === true
ansicht.rechnungAuftrag = Boolean(e.rechnungAuftragAt)
const [shop, rechnungen] = await Promise.all([shopMitKonto(ctx.customerId, ctx.projectId), sp.listeRechnungen(ctx.featureId)])
return {
einstellungen: ansicht,
stripe: { status: stripeZustand(e), vorhandenesShopKonto: Boolean(shop) },
rechnungenVorhanden: rechnungen.length > 0,
}
}
router.get('/einstellungen', route(async (req, res) => {
const ctx = await mitProjekt(req)
res.json(await einstellungenAntwort(ctx, await sp.ladeEinstellungen(ctx.featureId)))
}))
router.put('/einstellungen', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const eingabe = body(req).einstellungen && typeof body(req).einstellungen === 'object' ? body(req).einstellungen : body(req)
const neu = await withLocks(['uk-einst:' + ctx.featureId, 'uk-rechnungsnr:' + ctx.featureId], async () => {
const vorher = (await sp.ladeEinstellungen(ctx.featureId)) ?? {}
const { rechnungAuftrag, ...rest } = eingabe
if (rechnungAuftrag !== undefined && typeof rechnungAuftrag !== 'boolean') throw ungueltig({ rechnungAuftrag: 'Ungültige Eingabe.' })
const r = vw.pruefeEinstellungen(rest, vorher)
if (r.fehler) throw ungueltig(r.fehler)
if (r.werte.onlineZahlung === true && !vorher.onlineZahlung && stripeZustand(vorher) !== 'aktiv') {
throw new UkFehler(409, 'stripe_nicht_aktiv', { onlineZahlung: 'Online-Zahlung ist erst möglich, wenn dein Stripe-Konto aktiv ist.' })
}
if (r.werte.rechnungsPraefix !== undefined && r.werte.rechnungsPraefix !== (vorher.rechnungsPraefix ?? '') && !vw.zaehlerLeer(vorher)) {
throw new UkFehler(409, 'praefix_gesperrt', { rechnungsPraefix: 'Das Präfix kann nach der ersten Rechnung nicht mehr geändert werden.' })
}
const werte = { ...r.werte }
if (rechnungAuftrag === true && !vorher.rechnungAuftragAt) werte.rechnungAuftragAt = new Date().toISOString()
if (rechnungAuftrag === false) werte.rechnungAuftragAt = null
return sp.speichereEinstellungen(ctx.featureId, werte)
})
// die oeffentliche Schnittstelle (Farbe, Firma, Online-Zahlung) merkt sich den Kontext 30 s: sofort frisch
if (ctx.siteKey) leereCache(ctx.siteKey)
res.json(await einstellungenAntwort(ctx, neu))
}))
// ---- Unterkuenfte
router.get('/unterkuenfte', route(async (req, res) => {
const ctx = await mitProjekt(req)
res.json({ unterkuenfte: (await sp.listeUnterkuenfte(ctx.featureId)).map(unterkunftAnsicht) })
}))
router.post('/unterkuenfte', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const r = vw.pruefeUnterkunft(body(req))
if (r.fehler) throw ungueltig(r.fehler)
const u = await withLocks(['uk-neu:' + ctx.featureId], async () => {
const alle = await sp.listeUnterkuenfte(ctx.featureId)
if (alle.length >= vw.MAX_UNTERKUENFTE) throw new UkFehler(409, 'limit_erreicht')
const werte = { ...r.werte }
if (body(req).sort === undefined) werte.sort = alle.reduce((m, x) => Math.max(m, x.sort ?? 0), 0) + 1
return sp.speichereUnterkunft(ctx.featureId, {
...werte,
customerId: ctx.customerId,
projectId: ctx.projectId,
icalImporte: [],
icalExportToken: vw.neuesExportToken(),
})
})
res.status(201).json({ unterkunft: unterkunftAnsicht(u) })
}))
router.put('/unterkuenfte/:id', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u = await eigeneUnterkunft(ctx, req.params.id)
const r = vw.pruefeUnterkunft(body(req), { partiell: true })
if (r.fehler) throw ungueltig(r.fehler)
const neu = await sp.speichereUnterkunft(ctx.featureId, r.werte, u.$id)
res.json({ unterkunft: unterkunftAnsicht(neu) })
}))
// Loeschen nur ohne Anfragen (sonst deaktivieren: aktiv:false); die Oberflaeche deaktiviert nur
router.delete('/unterkuenfte/:id', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u = await eigeneUnterkunft(ctx, req.params.id)
await withLocks(sperreKey(u.$id), async () => {
if ((await sp.listeAnfragen(ctx.featureId, { unterkunftId: u.$id })).length > 0) throw new UkFehler(409, 'hat_anfragen')
for (const s of await sp.listeSperren(u.$id)) await sp.loescheSperre(s.$id)
await sp.loeschePreise(u.$id)
await sp.loescheUnterkunft(u.$id)
})
res.json({ ok: true })
}))
// ---- Preise
router.get('/unterkuenfte/:id/preise', route(async (req, res) => {
const ctx = await mitProjekt(req)
const u = await eigeneUnterkunft(ctx, req.params.id)
res.json({ preise: preiseAnsicht(await sp.ladePreise(u.$id)) })
}))
router.put('/unterkuenfte/:id/preise', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u = await eigeneUnterkunft(ctx, req.params.id)
const eingabe = body(req).preise && typeof body(req).preise === 'object' ? body(req).preise : body(req)
const r = vw.pruefePreise(eingabe)
if (r.fehler) throw ungueltig(r.fehler)
const neu = await sp.speicherePreise(u.$id, ctx.featureId, r.werte)
res.json({ preise: preiseAnsicht(neu) })
}))
// ---- Kalender, Sperren
const FENSTER_MAX_TAGE = 100
router.get('/unterkuenfte/:id/kalender', route(async (req, res) => {
const ctx = await mitProjekt(req)
const u = await eigeneUnterkunft(ctx, req.params.id)
const { von, bis } = req.query
if (!istTag(von) || !istTag(bis) || naechte(von, bis) < 1 || naechte(von, bis) > FENSTER_MAX_TAGE) {
throw ungueltig({ zeitraum: 'Bitte einen gültigen Zeitraum von höchstens 100 Tagen angeben.' })
}
res.json({ bereiche: await vw.kalenderBereiche(u.$id, von, bis) })
}))
router.post('/unterkuenfte/:id/sperren', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u = await eigeneUnterkunft(ctx, req.params.id)
const r = vw.pruefeSperre(body(req))
if (r.fehler) throw ungueltig(r.fehler)
const sperre = await withLocks(sperreKey(u.$id), async () => {
if (!(await istFrei(u.$id, r.werte.von, r.werte.bis))) throw new UkFehler(409, 'zeitraum_belegt')
return sp.legeSperreAn({ unterkunftId: u.$id, featureId: ctx.featureId, von: r.werte.von, bis: r.werte.bis, quelle: 'manuell', notiz: r.werte.notiz })
})
res.status(201).json({ sperre: { id: sperre.$id, von: sperre.von, bis: sperre.bis, notiz: sperre.notiz ?? '' } })
}))
router.delete('/sperren/:id', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
if (typeof req.params.id !== 'string' || !sp.ID_RE.test(req.params.id)) throw nichtGefunden()
const s = await sp.ladeSperre(req.params.id)
if (!s || s.featureId !== ctx.featureId) throw nichtGefunden()
if (String(s.quelle ?? 'manuell').startsWith('ical')) throw new UkFehler(409, 'sperre_aus_ical')
await sp.loescheSperre(s.$id)
res.json({ ok: true })
}))
// ---- iCal
router.post('/unterkuenfte/:id/ical-importe', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u0 = await eigeneUnterkunft(ctx, req.params.id)
const r = vw.pruefeIcalImport(body(req))
if (r.fehler) throw ungueltig(r.fehler)
const eintrag = await withLocks(sperreKey(u0.$id), async () => {
const u = await sp.ladeUnterkunft(u0.$id)
const bisher = Array.isArray(u.icalImporte) ? u.icalImporte : []
if (bisher.length >= vw.MAX_ICAL_IMPORTE) throw new UkFehler(409, 'limit_erreicht')
const neu = { id: vw.neueImportId(), name: r.werte.name, url: r.werte.url, letzterAbrufAt: null, letzterErfolgAt: null, fehler: '' }
await sp.speichereUnterkunft(ctx.featureId, { icalImporte: [...bisher, neu] }, u.$id)
return neu
})
res.status(201).json({ import: eintrag })
}))
router.delete('/unterkuenfte/:id/ical-importe/:importId', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u0 = await eigeneUnterkunft(ctx, req.params.id)
const importId = req.params.importId
if (typeof importId !== 'string' || !sp.ID_RE.test(importId)) throw nichtGefunden()
await withLocks(sperreKey(u0.$id), async () => {
const u = await sp.ladeUnterkunft(u0.$id)
const bisher = Array.isArray(u.icalImporte) ? u.icalImporte : []
if (!bisher.some((i) => i.id === importId)) throw nichtGefunden()
await sp.speichereUnterkunft(ctx.featureId, { icalImporte: bisher.filter((i) => i.id !== importId) }, u.$id)
for (const s of await sp.listeSperren(u.$id)) if (s.quelle === `ical:${importId}`) await sp.loescheSperre(s.$id)
})
res.json({ ok: true })
}))
router.post('/unterkuenfte/:id/ical-export/erneuern', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u = await eigeneUnterkunft(ctx, req.params.id)
const neu = await sp.speichereUnterkunft(ctx.featureId, { icalExportToken: vw.neuesExportToken() }, u.$id)
res.json({ icalExportUrl: vw.exportUrl(neu.icalExportToken) })
}))
// ---- Preisvorschau (rechnet nur, schreibt nichts)
router.post('/preisvorschau', route(async (req, res) => {
const ctx = await mitProjekt(req)
const b = body(req)
const u = await eigeneUnterkunft(ctx, b.unterkunftId)
if (!istTag(b.von) || !istTag(b.bis) || naechte(b.von, b.bis) < 1 || naechte(b.von, b.bis) > 365) throw ungueltig({ zeitraum: 'Ungültiger Zeitraum.' })
if (!Number.isInteger(b.personen) || b.personen < 1 || b.personen > 50) throw ungueltig({ personen: 'Ungültige Personenzahl.' })
const preise = await sp.ladePreise(u.$id)
if (!preiseVollstaendig(preise)) throw new UkFehler(409, 'preise_unvollstaendig')
res.json(berechnePreis({ preise, von: b.von, bis: b.bis, personen: b.personen }))
}))
// ---- Anfragen
const FILTER = { offen: ['neu', 'freigegeben'], gebucht: ['gebucht', 'bezahlt'] }
router.get('/anfragen', route(async (req, res) => {
const ctx = await mitProjekt(req)
const filter = req.query.filter === undefined ? 'alle' : String(req.query.filter)
if (filter !== 'alle' && !FILTER[filter]) throw ungueltig({ filter: 'Ungültiger Filter.' })
const [liste, unterkuenfte] = await Promise.all([sp.listeAnfragen(ctx.featureId, { status: FILTER[filter] }), sp.listeUnterkuenfte(ctx.featureId)])
const namen = new Map(unterkuenfte.map((u) => [u.$id, u.name ?? '']))
liste.sort((a, b) => String(b.$createdAt ?? '').localeCompare(String(a.$createdAt ?? '')) || String(b.nummer).localeCompare(String(a.nummer)))
res.json({
anfragen: liste.map((a) => ({
id: a.$id,
nummer: a.nummer ?? '',
von: a.von,
bis: a.bis,
naechte: istTag(a.von) && istTag(a.bis) ? naechte(a.von, a.bis) : 0,
personen: a.personen ?? 0,
gast: { name: a.gast?.name ?? '' },
status: a.status ?? '',
summen: a.summen && typeof a.summen.bruttoCent === 'number' ? { bruttoCent: a.summen.bruttoCent } : null,
unterkunftName: namen.get(a.unterkunftId) ?? '',
})),
})
}))
function vorschlagFuer(preise, a) {
const steuerart = preise?.steuerart ?? 'regel'
if (preiseVollstaendig(preise) && istTag(a.von) && istTag(a.bis) && naechte(a.von, a.bis) >= 1) {
return { ...berechnePreis({ preise, von: a.von, bis: a.bis, personen: a.personen }), steuerart }
}
return { posten: [], summen: summiere([], steuerart), steuerart }
}
router.get('/anfragen/:id', route(async (req, res) => {
const ctx = await mitProjekt(req)
const a = await eigeneAnfrage(ctx, req.params.id)
const preise = await sp.ladePreise(a.unterkunftId)
res.json({ anfrage: await volleAnfrageAnsicht(ctx, a, preise), vorschlag: vorschlagFuer(preise, a) })
}))
router.post('/anfragen/:id/freigeben', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const a = await eigeneAnfrage(ctx, req.params.id)
const b = body(req)
if (b.fristTage !== undefined && b.fristTage !== null && (!Number.isInteger(b.fristTage) || b.fristTage < 1 || b.fristTage > 14)) {
throw ungueltig({ fristTage: 'Die Frist muss 1 bis 14 Tage betragen.' })
}
if (b.nachricht !== undefined && b.nachricht !== null && (typeof b.nachricht !== 'string' || b.nachricht.length > 2000)) {
throw ungueltig({ nachricht: 'Die Nachricht darf höchstens 2000 Zeichen lang sein.' })
}
const { anfrage, token } = await ablauf.gibFrei(ctx, a.$id, { posten: b.posten, fristTage: b.fristTage ?? undefined, nachricht: b.nachricht ?? '' })
const preise = await sp.ladePreise(anfrage.unterkunftId)
res.json({
anfrage: await volleAnfrageAnsicht(ctx, anfrage, preise),
buchungsLink: `${config.publicBaseUrl}/unterkunft/${ctx.siteKey}/buchung/${anfrage.$id}?t=${token}`,
})
}))
router.post('/anfragen/:id/ablehnen', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const a = await eigeneAnfrage(ctx, req.params.id)
const t = body(req).text
if (t !== undefined && t !== null && (typeof t !== 'string' || t.length > 2000)) throw ungueltig({ text: 'Der Text darf höchstens 2000 Zeichen lang sein.' })
const neu = await ablauf.lehneAb(ctx, a.$id, { text: t ?? '' })
res.json({ anfrage: await volleAnfrageAnsicht(ctx, neu, await sp.ladePreise(neu.unterkunftId)) })
}))
router.post('/anfragen/:id/bezahlt', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const a = await eigeneAnfrage(ctx, req.params.id)
const neu = await ablauf.markiereBezahlt(ctx, a.$id, { via: 'vorort' })
res.json({ anfrage: await volleAnfrageAnsicht(ctx, neu, await sp.ladePreise(neu.unterkunftId)) })
}))
router.post('/anfragen/:id/stornieren', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const a = await eigeneAnfrage(ctx, req.params.id)
const b = body(req)
const grund = b.grund === undefined || b.grund === null ? '' : b.grund
if (typeof grund !== 'string' || grund.length > 500) throw ungueltig({ grund: 'Der Grund darf höchstens 500 Zeichen lang sein.' })
const erstattungCent = b.erstattungCent === undefined || b.erstattungCent === null ? 0 : b.erstattungCent
if (!Number.isInteger(erstattungCent) || erstattungCent < 0) throw ungueltig({ erstattungCent: 'Ungültiger Betrag.' })
const { anfrage, stornoRechnung } = await ablauf.storniere(ctx, a.$id, { grund, erstattungCent })
res.json({ anfrage: await volleAnfrageAnsicht(ctx, anfrage, await sp.ladePreise(anfrage.unterkunftId)), stornoRechnungId: stornoRechnung?.$id ?? null })
}))
// ---- Rechnungen
router.get('/rechnungen', route(async (req, res) => {
const ctx = await mitProjekt(req)
const liste = await sp.listeRechnungen(ctx.featureId)
res.json({
rechnungen: liste.reverse().map((r) => ({
id: r.$id,
nummer: r.nummer ?? '',
art: r.art ?? 'rechnung',
datum: r.datum ?? '',
bruttoCent: r.bruttoCent ?? 0,
anfrageNummer: r.inhalt?.anfrageNummer ?? '',
gastName: r.inhalt?.empfaenger?.firma || r.inhalt?.empfaenger?.name || '',
})),
})
}))
const PDF_RE = /^([A-Za-z0-9_-]{1,36})\.pdf$/
router.get('/rechnungen/:datei', route(async (req, res) => {
const ctx = await mitProjekt(req)
const m = PDF_RE.exec(String(req.params.datei))
if (!m) throw nichtGefunden()
const dok = await sp.ladeRechnung(m[1])
if (!dok || dok.featureId !== ctx.featureId) throw nichtGefunden()
let modul
try {
modul = await import('../services/unterkunft/rechnungPdf.js')
} catch (err) {
if (err?.code !== 'ERR_MODULE_NOT_FOUND') throw err
return res.status(501).json({ fehler: 'nicht_verfuegbar' })
}
const art = dok.art === 'storno' ? 'storno' : 'rechnung'
const pdf = modul.rechnungPdf(dok.inhalt, { art, nummer: dok.nummer, datum: dok.datum })
const dateiname = `${art === 'storno' ? 'Stornorechnung' : 'Rechnung'}-${String(dok.nummer).replace(/[^A-Za-z0-9_-]/g, '')}.pdf`
res.set('Content-Type', 'application/pdf')
res.set('Content-Disposition', `inline; filename="${dateiname}"`)
res.send(pdf)
}))
// ---- Stripe
const PLATTFORM_FEHLT = /platform profile|signed up for Connect/i
/** Stripe-Aufruf: jeder Fehler von Stripe wird zu 502 mit festem Text (geloggt wird nur Typ und Code). */
async function ueberStripe(fn) {
try {
return await fn()
} catch (err) {
if (err instanceof UkFehler) throw err
if (err?.status >= 400 && err.status < 500 && !err.type) throw err
if (err?.type === 'StripeInvalidRequestError' && PLATTFORM_FEHLT.test(String(err.message || ''))) {
console.error('[unterkunft] Stripe Connect: Plattformprofil fehlt')
throw stop(503, 'plattform_nicht_bereit', { meldung: MELDUNG.plattform })
}
console.error(`[unterkunft] Stripe ${String(err?.type ?? err?.name ?? 'Fehler').slice(0, 60)} ${String(err?.code ?? '').slice(0, 60)}`.trim())
throw stop(502, 'stripe_nicht_erreichbar', { meldung: MELDUNG.stripeFern })
}
}
const stripeRueckUrl = (projectId, was) => `${config.publicBaseUrl}/dashboard.html#buchungen?stripe=${was}&projekt=${encodeURIComponent(projectId)}`
router.post('/stripe/verbinden', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const accountId = await withLocks(['uk-stripe:' + ctx.featureId], async () => {
const einst = await sp.ladeEinstellungen(ctx.featureId)
if (einst?.stripeAccountId) return einst.stripeAccountId
const customer = await getDocument(config.collections.customers, ctx.customerId).catch(() => null)
const neu = await ueberStripe(() =>
kontoAnlegen({
customer: customer || { $id: ctx.customerId, email: req.session?.email },
project: ctx.project,
webklar: 'unterkunft',
idempotencyKey: 'uk-konto-' + ctx.projectId,
})
)
let status
try {
status = await ueberStripe(() => kontoStatus(neu))
} catch {
status = statusAusKonto({})
}
await sp.speichereEinstellungen(ctx.featureId, { stripeAccountId: neu, stripeStatus: JSON.stringify(status) })
return neu
})
const url = await ueberStripe(() => onboardingLink(accountId, { returnUrl: stripeRueckUrl(ctx.projectId, 'zurueck'), refreshUrl: stripeRueckUrl(ctx.projectId, 'neu') }))
res.json({ url })
}))
router.post('/stripe/vorhandenes-konto', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const shop = await shopMitKonto(ctx.customerId, ctx.projectId)
if (!shop) throw new UkFehler(409, 'kein_konto')
const einst = await withLocks(['uk-stripe:' + ctx.featureId], async () => {
const vorher = await sp.ladeEinstellungen(ctx.featureId)
if (vorher?.stripeAccountId && vorher.stripeAccountId !== shop.stripeAccountId) throw new UkFehler(409, 'konto_vorhanden')
return sp.speichereEinstellungen(ctx.featureId, {
stripeAccountId: shop.stripeAccountId,
stripeStatus: typeof shop.stripeStatus === 'string' && shop.stripeStatus ? shop.stripeStatus : JSON.stringify(statusAusKonto({})),
})
})
res.json({ stripe: { status: stripeZustand(einst), details: stripeStatusKnapp(einst.stripeStatus) } })
}))
router.post('/stripe/aktualisieren', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const einst = await sp.ladeEinstellungen(ctx.featureId)
if (!einst?.stripeAccountId) throw new UkFehler(409, 'kein_konto')
const status = await ueberStripe(() => kontoStatus(einst.stripeAccountId))
const neu = await sp.speichereEinstellungen(ctx.featureId, { stripeStatus: JSON.stringify(status) })
res.json({ stripe: { status: stripeZustand(neu), details: stripeStatusKnapp(neu.stripeStatus) } })
}))
export default router
+120
View File
@@ -0,0 +1,120 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { Router } from 'express'
import { config } from '../config.js'
import { listDocuments, Query } from '../services/appwriteAdmin.js'
import { baueIcs } from '../services/unterkunft/ical.js'
import { plusTage } from '../services/unterkunft/datum.js'
import { heute as berlinHeute } from '../services/termine/zeit.js'
import { ipSchluessel, zaehle } from '../services/termine/limits.js'
import { ladeFuerProjekt, SITE_KEY_RE } from '../services/unterkunft/kontext.js'
import { unterkunftFuerKunde, unterkunftModus } from '../services/unterkunft/schalter.js'
import { listeAnfragenNachStatus, listeSperren } from '../services/unterkunft/speicher.js'
/**
* Oeffentliche Standalone-Seiten des Buchungskalenders: /unterkunft/:siteKey und /unterkunft/:siteKey/buchung/:id
* (Vorbild: shopSeite.js). Geruest (Task 5): Task 7 und Strang 3 fuellen die Seiten.
* Davor steht der iCal-Export /unterkunft/ical/<token>.ics (Task 9): die Adresse ist das Geheimnis (Token, 43 Zeichen).
*/
const DATEI_RE = /^[A-Za-z0-9_-]{20,64}\.ics$/
const EXPORT_JE_MINUTE = 60
const nicht = (res) => res.status(404).type('text/plain').set('Cache-Control', 'no-store').send('Not found')
async function exportEreignisse(unterkunft, jetzt) {
const gestern = plusTage(berlinHeute(new Date(jetzt)), -1)
const out = []
for (const s of await listeSperren(unterkunft.$id)) {
const quelle = String(s.quelle ?? 'manuell')
if (quelle.startsWith('ical') || !s.von || !s.bis) continue // nie zurueckspielen, was importiert wurde
if (s.bis <= gestern) continue // nur Ereignisse ab gestern
out.push({ uid: `${s.$id}@project.webklar.com`, von: s.von, bis: s.bis })
}
for (const a of await listeAnfragenNachStatus(unterkunft.$id, ['gebucht', 'bezahlt', 'freigegeben'])) {
if (a.status === 'freigegeben' && !(Date.parse(a.fristAt) > jetzt)) continue
if (!a.von || !a.bis || a.bis <= gestern) continue
out.push({ uid: `${a.$id}@project.webklar.com`, von: a.von, bis: a.bis })
}
return out.sort((x, y) => (x.von < y.von ? -1 : x.von > y.von ? 1 : x.uid < y.uid ? -1 : 1))
}
/**
* Oeffentliche Standalone-Seiten des Buchungskalenders (Vorbild: shopSeite.js):
* GET /unterkunft/:siteKey Widget public/embed/unterkunft.js auf neutraler Seite
* GET /unterkunft/:siteKey/buchung/:id Buchungsseite fuer Gaeste (public/unterkunft-buchung.js, Token ?t= liest nur das Skript)
* Die Seite verraet nicht, ob Schluessel oder Buchung existieren; das Skript fragt die oeffentliche Schnittstelle.
* Ungueltige Schluessel (z. B. "ical" der Export-Route /unterkunft/ical/:token.ics) und Ids: next(), am Ende 404.
*/
const ID_RE = /^[A-Za-z0-9_-]{1,36}$/
const DATEI = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public', 'unterkunft-seite.html')
const CSP = [
"default-src 'self'",
"img-src 'self' data:",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'", // das Widget legt ein <style>-Element an
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'none'",
"form-action 'self'",
].join('; ')
let vorlage = null
export function resetUnterkunftSeiteForTests() {
vorlage = null
}
function escapeAttr(s) {
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function seite(res, next, siteKey, ansicht) {
try {
if (vorlage === null) vorlage = readFileSync(DATEI, 'utf8')
} catch (err) {
return next(err)
}
const html = vorlage.replaceAll('__SITEKEY__', escapeAttr(siteKey)).replaceAll('__ANSICHT__', escapeAttr(ansicht))
res.set({
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': CSP,
'X-Robots-Tag': 'noindex',
})
return res.status(200).send(html)
}
export function unterkunftSeiteRouter() {
const router = Router()
router.get('/unterkunft/ical/:datei', async (req, res, next) => {
try {
if (unterkunftModus() === 'off' || !DATEI_RE.test(req.params.datei)) return nicht(res)
if (!zaehle(`uk-ical:${ipSchluessel(req.ip)}`, EXPORT_JE_MINUTE, 60_000, new Date())) return res.status(429).type('text/plain').set({ 'Cache-Control': 'no-store', 'Retry-After': '60' }).send('Too many requests')
const token = req.params.datei.slice(0, -4)
const [u] = await listDocuments(config.collections.ukUnterkuenfte, [Query.equal('icalExportToken', token), Query.limit(1)])
if (!u || u.icalExportToken !== token || u.aktiv === false) return nicht(res)
const ctx = await ladeFuerProjekt(u.customerId, u.projectId)
if (!ctx || ctx.featureId !== u.featureId || ctx.state !== 'active' || !unterkunftFuerKunde(ctx.besitzerEmail)) return nicht(res)
const jetzt = new Date()
const text = baueIcs({ name: u.name, ereignisse: await exportEreignisse(u, jetzt.getTime()), jetzt })
res.set({ 'Content-Type': 'text/calendar; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }).send(text)
} catch (err) {
next(err)
}
})
router.get('/unterkunft/:siteKey', (req, res, next) => {
if (!SITE_KEY_RE.test(req.params.siteKey)) return next()
return seite(res, next, req.params.siteKey, 'widget')
})
router.get('/unterkunft/:siteKey/buchung/:id', (req, res, next) => {
if (!SITE_KEY_RE.test(req.params.siteKey) || !ID_RE.test(req.params.id)) return next()
return seite(res, next, req.params.siteKey, 'buchung')
})
return router
}
export default unterkunftSeiteRouter
+135
View File
@@ -0,0 +1,135 @@
import { Router } from 'express'
import { config } from '../config.js'
import { getDocument, listDocuments, Query, updateDocument } from '../services/appwriteAdmin.js'
import { withLocks } from '../services/keyedLock.js'
import { statusAusKonto } from '../services/shop/connect.js'
import { ladeFuerProjekt } from '../services/unterkunft/kontext.js'
import { unterkunftModus } from '../services/unterkunft/schalter.js'
import { aktualisiereAnfrage, ladeAnfrage, ladeEinstellungen } from '../services/unterkunft/speicher.js'
import { erstatteUeberzaehligeZahlung, verbucheZahlung, zahlungPasst } from '../services/unterkunft/zahlung.js'
/**
* Stripe-Ereignisse des Buchungskalenders. Kein eigener Endpunkt: routes/shopWebhook.js (Connect-Webhook, ein Endpunkt je Konto)
* leitet Ereignisse mit metadata.wkUk === '1' (Checkout-Sitzungen) oder metadata.webklar === 'unterkunft' (Erstattungen) hierher
* und ruft bei account.updated zusaetzlich kontoAktualisiertUk. Alles idempotent; ein Fehler bei Checkout- und Erstattungsereignissen => 500 (Stripe wiederholt), bei account.updated nur ein Log-Eintrag (shopWebhook.js).
*/
const ACCOUNT_RE = /^acct_[A-Za-z0-9]+$/
const text = (v) => (typeof v === 'string' ? v : '')
const ID_OK = /^[A-Za-z0-9_-]{1,36}$/
/** Kontext fuer ablauf.js: voll, solange die Funktion existiert; sonst der Mindestkontext (eine eingegangene Zahlung geht nie verloren). */
async function kontextFuer(anfrage) {
const voll = await ladeFuerProjekt(anfrage.customerId, anfrage.projectId).catch(() => null)
if (voll && voll.featureId === anfrage.featureId) return voll
return {
featureId: anfrage.featureId,
customerId: anfrage.customerId ?? '',
projectId: anfrage.projectId ?? '',
siteKey: '',
besitzerEmail: '',
einstellungen: await ladeEinstellungen(anfrage.featureId),
project: null,
}
}
/** Anfrage zum Ereignis: nur wenn das Konto des Ereignisses das Konto der Funktion ist (sonst Fremdereignis, ohne Wirkung). */
async function anfrageZu(event, anfrageId) {
if (!ID_OK.test(text(anfrageId))) return null
const anfrage = await ladeAnfrage(anfrageId)
if (!anfrage) return null
const ctx = await kontextFuer(anfrage)
if (!ctx.einstellungen?.stripeAccountId || ctx.einstellungen.stripeAccountId !== event.account) return null
return { anfrage, ctx }
}
async function zahlungEingegangen(event, session, now) {
const treffer = await anfrageZu(event, session.metadata?.anfrageId)
if (!treffer) return
const { anfrage, ctx } = treffer
const pi = typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id
if (!zahlungPasst(anfrage, session)) {
console.warn(`[unterkunft] Webhook: Betrag oder Währung weicht ab (Buchung ${anfrage.nummer}), Zahlung wird erstattet`)
await erstatteUeberzaehligeZahlung(ctx, pi)
return
}
await verbucheZahlung(ctx, anfrage.$id, pi, { now })
}
async function erstattungGebucht(event, charge) {
const treffer = await anfrageZu(event, charge.metadata?.anfrageId)
if (!treffer) return
const { anfrage } = treffer
// nur die Zahlung, die diese Buchung bezahlt hat (fremde oder ueberzaehlige Zahlungen mit gleicher anfrageId zaehlen nicht)
if (!anfrage.stripePaymentIntentId || typeof charge.payment_intent !== 'string' || charge.payment_intent !== anfrage.stripePaymentIntentId) return
const stand = charge.amount_refunded
if (!Number.isInteger(stand) || stand <= 0) return
await withLocks(['unterkunft:' + anfrage.unterkunftId], async () => {
const frisch = await ladeAnfrage(anfrage.$id)
if (frisch && stand > (frisch.erstattetCent || 0)) await aktualisiereAnfrage(frisch.$id, { erstattetCent: stand })
})
}
/**
* Checkout- und Erstattungsereignisse des Buchungskalenders verarbeiten (idempotent; Fehler => 500, Stripe wiederholt).
* checkout.session.completed (bezahlt) und async_payment_succeeded: Buchung als bezahlt markieren. charge.refunded: erstattetCent auf
* mindestens den Stand der Zahlung heben (heilt einen Absturz zwischen Stripe-Erfolg und Schreiben).
* @param {object} event Stripe-Ereignis (signiert und geprueft)
* @param {Date} [now]
*/
export async function verarbeiteUkEreignis(event, now = new Date()) {
return ereignisHandler(event, now)
}
async function verarbeite(event, now) {
const objekt = event?.data?.object
if (!objekt || typeof objekt !== 'object') return
switch (event.type) {
case 'checkout.session.completed':
if (objekt.metadata?.wkUk === '1' && objekt.payment_status === 'paid') await zahlungEingegangen(event, objekt, now)
break
case 'checkout.session.async_payment_succeeded':
if (objekt.metadata?.wkUk === '1') await zahlungEingegangen(event, objekt, now)
break
case 'charge.refunded':
await erstattungGebucht(event, objekt)
break
default:
}
}
let ereignisHandler = verarbeite
// Austauschbar nur fuer Tests (Weiche in shopWebhook.js pruefen)
export function setzeUkEreignisHandlerFuerTests(fn) {
ereignisHandler = fn ?? verarbeite
}
/**
* account.updated: Bereitschaftsstatus (stripeStatus) aller Einstellungen mit diesem Konto neu speichern. Bei UNTERKUNFT_ENABLED=off
* und solange die Collection fehlt (Setup-Skript noch nicht gelaufen) ohne Wirkung.
* @returns {Promise<number>} Zahl aktualisierter Einstellungen
*/
export async function kontoAktualisiertUk(accountId, konto, now = new Date()) {
if (unterkunftModus() === 'off' || !ACCOUNT_RE.test(accountId || '')) return 0
let dokumente
try {
dokumente = await listDocuments(config.collections.ukEinstellungen, [Query.equal('stripeAccountId', accountId), Query.limit(25)])
} catch (err) {
if (err?.status === 404) return 0
throw err
}
let n = 0
for (const doc of dokumente) {
await withLocks([`uk-stripe:${doc.$id}`], async () => {
const frisch = await getDocument(config.collections.ukEinstellungen, doc.$id)
if (frisch.stripeAccountId !== accountId) return
await updateDocument(config.collections.ukEinstellungen, doc.$id, { stripeStatus: JSON.stringify(statusAusKonto(konto, now)) })
n++
})
}
return n
}
// Kein Endpunkt: die Datei haengt nur Funktionen an. Der leere Router erlaubt ein spaeteres Einhaengen ohne Aenderung am Import.
export default Router()
+15
View File
@@ -307,6 +307,21 @@ export async function deleteCustomerWithPortalAccess(customerId) {
console.error(`[customerAdmin] Webshop von Kunde ${customerId} nicht geschlossen:`, err.message)
}
// Buchungskalender abschalten: Gaeste koennen keine Anfragen mehr stellen, die Daten loescht der Tagesjob 90 Tage nach disabledAt. Neue
// Anfragen schliessen wir sofort (Unterkuenfte inaktiv, Speicher der oeffentlichen Schnittstelle leeren). Jeder Teilschritt fuer sich abgesichert.
try {
const { disableCustomerFeatures } = await import('./siteFeatures.js')
await disableCustomerFeatures(customerId, 'unterkunft', { adminNote: 'Kunde gelöscht', reason: 'kunde_geloescht' })
} catch (err) {
console.error(`[customerAdmin] Buchungskalender von Kunde ${customerId} nicht abgeschaltet:`, err.message)
}
try {
const { schliesseUkVonKunde } = await import('./unterkunft/aufbewahrung.js')
await schliesseUkVonKunde(customerId)
} catch (err) {
console.error(`[customerAdmin] Buchungskalender von Kunde ${customerId} nicht geschlossen:`, err.message)
}
// Oeffnungszeiten live: wie der Terminplaner abschalten (die Antwort der Website ist sofort aktiv false: Kunde und Funktion fehlen/sind
// disabled), danach Zeiten und Erinnerungsmerker sofort loeschen (Spec 5) und den Speicher der Antworten leeren. Beides fuer sich in
// try/catch: ein Fehler haelt weder das Loeschen des Kunden noch den jeweils anderen Schritt auf. Geloggt wird nur die Kunden-ID.
+15
View File
@@ -1,6 +1,7 @@
import { config } from '../config.js'
import { termineFuerKunde } from './termine/schalter.js'
import { shopFuerKunde } from './shop/schalter.js'
import { unterkunftFuerKunde } from './unterkunft/schalter.js'
import { zeitenFuerKunde } from './zeiten/schalter.js'
// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner, Webshop, Oeffnungszeiten live; Daten in server/config.js).
@@ -99,6 +100,12 @@ async function shopBestellpruefung({ customerId, projectId }) {
return anfrageOffenePunkte({ customerId, projectId })
}
/** Vertragsunterlagen des Buchungskalenders abrufbar? (legalDocs.featureLegalReady, fail-closed, wirft nie.) */
async function unterkunftUnterlagenBereit() {
const { featureLegalReady } = await import('./legalDocs.js')
return featureLegalReady('unterkunft')
}
/** Vertragsunterlagen des Webshops abrufbar? (Stufe 5b des Checkouts; legalDocs.featureLegalReady, fail-closed, wirft nie.) */
async function shopUnterlagenBereit() {
const { featureLegalReady } = await import('./legalDocs.js')
@@ -127,6 +134,14 @@ const STANDARD_HAKEN = {
unterlagenBereit: shopUnterlagenBereit,
belehrung: termineBelehrung,
},
// Buchungskalender: Anfrage-Funktion und Freischaltung sind bis Task 11 Stubs (unterkunft/anfrageFunktion.js, freischaltung.js)
unterkunft: {
verfuegbar: (email) => unterkunftFuerKunde(email),
bestellpruefung: (args) => import('./unterkunft/anfrageFunktion.js').then((m) => m.anfrageOffenePunkte(args)),
unterlagenBereit: unterkunftUnterlagenBereit,
belehrung: termineBelehrung,
freischaltbar: (doc) => import('./unterkunft/freischaltung.js').then((m) => m.freischaltbar(doc)),
},
// Oeffnungszeiten live: keine Bestellpruefung (die Einstellung entsteht erst im Editor); belehrung wie beim Terminplaner,
// unterlagenBereit immer (die Oeffnungszeiten haben nur die Leistungsbeschreibung als Seite, keine PDFs); freischaltbar: erst mit
// eingetragenen Zeiten; nachFreischaltung: Ersatz-Hash merken
+7 -1
View File
@@ -37,15 +37,21 @@ const SAETZE = {
avv: { slug: 'avv-webshop', pdf: 'AVV-Webshop' },
widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' },
},
unterkunft: {
lb: { slug: 'leistungsbeschreibung-unterkunft', pdf: 'Leistungsbeschreibung-Buchungskalender' },
avv: { slug: 'avv-unterkunft', pdf: 'AVV-Buchungskalender' },
widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' },
},
}
/** Satz, dessen Dokumente eine Funktion (Registerschluessel) mit der Bestellung bekommt; News hat keine. */
const FUNKTION_SATZ = { news: null, termine: 'termine', shop: 'shop' }
const FUNKTION_SATZ = { news: null, termine: 'termine', shop: 'shop', unterkunft: 'unterkunft' }
/** Aktuelle Fassung je Satz, bei jedem Aufruf aus config gelesen (Tests aendern sie zur Laufzeit). */
const aktuelleFassung = (satz) => {
if (satz === 'analyse') return config.analyse.avvVersion
if (satz === 'shop') return config.shop.fassung
if (satz === 'unterkunft') return config.unterkunft.fassung
return config.termine.avvVersion
}
+1
View File
@@ -111,6 +111,7 @@ export function renderMail(id, values = {}, blocks = {}) {
.replace(/\{\{(\w+)\}\}/g, (_, key) => escapeHtml(valueOf(key)).replace(/\r?\n/g, '<br>'))
// Anrede ohne bekannten Namen
.replace(/Guten Tag ,/g, 'Guten Tag,')
.replace(/Hallo ,/g, 'Hallo,')
const subject = template.subject
.replace(/\{\{(\w+)\}\}/g, (_, key) => valueOf(key))
+3 -2
View File
@@ -192,9 +192,9 @@ const oneLine = (value) => String(value ?? '').replace(/\s*[\r\n]+\s*/g, ' ').tr
* (Betriebs-E-Mail), nie BCC. Mit `ics` haengt die Kalenderdatei als icalEvent (nodemailer-Kalenderteil, kein Anhang) an.
* Wirft bei fehlendem SMTP, ungueltiger Adresse oder Methode - der Aufrufer (Outbox) entscheidet ueber Wiederholung.
* @param {{ to: string, fromName: string, replyTo?: string|null, subject: string, html: string, text: string,
* ics?: string|null, icsMethod?: 'REQUEST'|'CANCEL'|'PUBLISH' }} message
* ics?: string|null, icsMethod?: 'REQUEST'|'CANCEL'|'PUBLISH', attachments?: object[]|null (nodemailer, unveraendert, z. B. Rechnungs-PDF) }} message
*/
export async function sendGuestMail({ to, fromName, replyTo = null, subject, html, text, ics = null, icsMethod = 'REQUEST' }) {
export async function sendGuestMail({ to, fromName, replyTo = null, subject, html, text, ics = null, icsMethod = 'REQUEST', attachments = null }) {
const mailer = getTransporter()
if (!mailer) throw new Error('SMTP nicht konfiguriert')
checkMailbox(to, 'to')
@@ -212,6 +212,7 @@ export async function sendGuestMail({ to, fromName, replyTo = null, subject, htm
text,
html,
icalEvent: ics ? { method: icsMethod, filename: 'termin.ics', content: ics } : undefined,
attachments: Array.isArray(attachments) && attachments.length ? attachments : undefined,
})
}
+27 -4
View File
@@ -4,6 +4,7 @@ import { config } from '../config.js'
import { deleteDocument, getDocument, listDocuments, Query, updateDocument } from './appwriteAdmin.js'
import { deleteNewsImage } from './newsImages.js'
import { loescheShopDaten } from './shop/loeschen.js'
import { hatUkDaten, loescheUkDaten } from './unterkunft/aufbewahrung.js'
import { disable } from './siteFeatures.js'
import { getStripe } from './stripeBilling.js'
import { filesOf, STATUS } from './takeoverCredits.js'
@@ -37,6 +38,9 @@ import { filesOf, STATUS } from './takeoverCredits.js'
* Abschalten die Zeiten und Erinnerungsmerker des Kunden (loescheZeitenVonKunde, per dynamischem Import wie bei siteFeatures).
* Scheitert das, werden diese Funktionen nicht abgeschaltet (solange Daten da sind, soll nichts "aus" aussehen); der Fehler steht unter
* "Fehlgeschlagen" im Bericht.
* - Buchungskalender: Fuer faellige Kunden loescht derselbe Lauf auch alle uk*-Daten ihrer Funktionen 'unterkunft' (plan.news[].ukIds, nur wenn es welche gibt;
* Deckel UK_LOESCH_DECKEL je Kunde, Rest = Kunde gehalten). Die eigenen Fristen (ipHash, Anonymisierung, Rechnungen 8 Jahre, 90 Tage nach dem
* Abschalten) laufen im Tagesjob des Buchungskalenders (unterkunft/aufbewahrung.js).
* Zeitrechnung in deutscher Zeit: Silvester 23:30 in Berlin ist noch das alte Jahr.
* Nicht abgedeckt: Vertragsereignisse ohne customerId bzw. ohne zugehoeriges Abo-Dokument (Waisen) werden
* weder gefunden noch geloescht; sie muessen von Hand geprueft werden. Listen werden nicht paginiert: wird eine
@@ -51,6 +55,7 @@ const FEATURES = () => config.collections.portalFeatures
const CONSENTS = () => config.collections.featureConsents
const SHOP_CONFIG = () => config.collections.shopConfig
const SHOP_LOESCH_DECKEL = 2000
const UK_LOESCH_DECKEL = 2000
/** Felder am Funktionsdokument, die die Zustimmung zur Bestellung festhalten (leer => nichts zu loeschen). */
const FEATURE_CONSENT_FIELDS = ['consentIp', 'consentUserAgent', 'consentText', 'consentAt']
@@ -154,7 +159,10 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT }
const withFields = allFeatures.filter(hasConsentFields)
const consents = await list(CONSENTS(), [Query.equal('customerId', customerId)])
const shops = await shopsOf(customerId, list)
if (!posts.length && !features.length && !consents.length && !withFields.length && !shops.length) continue
// Buchungskalender: Funktionen 'unterkunft' mit uk*-Daten (auch abgeschaltete); ohne Collections (Setup nicht gelaufen) gibt es keine
const ukIds = []
for (const f of allFeatures.filter((x) => x.featureKey === 'unterkunft')) if (await hatUkDaten(f.$id)) ukIds.push(f.$id)
if (!posts.length && !features.length && !consents.length && !withFields.length && !shops.length && !ukIds.length) continue
news.push({
customerId,
posts: posts.map((p) => ({ id: p.$id, projectId: p.projectId, imageId: p.imageId || null })),
@@ -162,6 +170,7 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT }
consentIds: consents.map((c) => c.$id),
featureFieldIds: withFields.map((f) => f.$id),
...(shops.length ? { shops } : {}),
...(ukIds.length ? { ukIds } : {}),
zeitenIds: features.filter((f) => f.featureKey === 'zeiten').map((f) => f.$id),
})
}
@@ -216,12 +225,12 @@ async function deleteQuietly(collection, id) {
* die Appwrite-Dokumente werden trotzdem geloescht, denn die Frist ist abgelaufen.
* @returns {Promise<{ deleted: { subscriptions: number, contractEvents: number, takeover: number, stripeCleared: number,
* newsPosts: number, newsImages: number, features: number, featureConsents: number, featureFields: number, shops: number, shopDocs: number,
* zeiten: number },
* zeiten: number, unterkuenfte: number, ukDocs: number },
* manual: { customerId: string, subscriptionId: string, reason: string }[],
* failed: { what: string, id: string, reason: string }[], held: string[] }>}
*/
export async function applyDeletions(plan) {
const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, featureConsents: 0, featureFields: 0, shops: 0, shopDocs: 0, zeiten: 0 }
const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, featureConsents: 0, featureFields: 0, shops: 0, shopDocs: 0, zeiten: 0, unterkuenfte: 0, ukDocs: 0 }
const manual = []
const failed = []
const held = []
@@ -286,6 +295,18 @@ export async function applyDeletions(plan) {
for (const id of entry.consentIds || []) {
if (await attempt('featureConsents', id, () => deleteQuietly(CONSENTS(), id))) deleted.featureConsents += 1
}
// Buchungskalender: alle uk*-Daten der Funktion (Anfragen, Rechnungen, Sperren, Preise, Unterkuenfte, Einstellungen); gemeinsamer Deckel je Kunde
let ukRest = UK_LOESCH_DECKEL
for (const featureId of entry.ukIds || []) {
await attempt('unterkunft', featureId, async () => {
const weg = await loescheUkDaten({ featureId, deckel: ukRest })
ukRest -= weg.geloescht
deleted.ukDocs += weg.geloescht
if (!weg.vollstaendig) throw new Error('Buchungsdaten nicht vollständig gelöscht (Deckel je Lauf), der nächste Lauf macht weiter')
deleted.unterkuenfte += 1
return true
})
}
// Webshop-Daten: Bestellungen, Artikel, Widerrufe, Rechtstexte-Archiv, Bilder, zuletzt shopConfig (Deckel je Kunde; Rest = der Kunde wird gehalten)
let shopRest = SHOP_LOESCH_DECKEL
for (const shop of entry.shops || []) {
@@ -366,9 +387,10 @@ export function formatReport(plan, result = null) {
const consents = n.consentIds?.length || 0
const fields = n.featureFieldIds?.length || 0
const shops = n.shops?.length || 0
const uk = n.ukIds?.length ? `; ${n.ukIds.length} Buchungskalender samt Anfragen, Rechnungen und Unterkünften zu löschen` : ''
const zeiten = n.zeitenIds?.length ? `; Öffnungszeiten-Daten von ${n.zeitenIds.length} Funktion(en) zu löschen` : ''
lines.push(
`Kunde ${n.customerId}: ${n.posts.length} News-Beitrag/-Beiträge mit ${images} Foto(s) zu löschen, ${n.featureIds.length} Funktion(en) abzuschalten, ${consents} Zustimmungsnachweis(e) zu löschen, ${fields} Funktionsdokument(e) mit Zustimmungsangaben zu leeren${shops ? `, ${shops} Webshop(s) samt Bestellungen, Artikeln, Widerrufen und Bildern zu löschen` : ''}${zeiten}`
`Kunde ${n.customerId}: ${n.posts.length} News-Beitrag/-Beiträge mit ${images} Foto(s) zu löschen, ${n.featureIds.length} Funktion(en) abzuschalten, ${consents} Zustimmungsnachweis(e) zu löschen, ${fields} Funktionsdokument(e) mit Zustimmungsangaben zu leeren${shops ? `, ${shops} Webshop(s) samt Bestellungen, Artikeln, Widerrufen und Bildern zu löschen` : ''}${uk}${zeiten}`
)
}
const del = plan.takeover.filter((t) => t.action === 'loeschen')
@@ -391,6 +413,7 @@ export function formatReport(plan, result = null) {
`Zustimmungsangaben an Funktionsdokumenten (IP, Browser, Text, Zeit): ${d.featureFields} geleert.`,
...(d.shops || d.shopDocs ? [`Webshop: ${d.shops} Shop(s) gelöscht (${d.shopDocs} Dokument(e) in Bestellungen, Artikeln und Widerrufen).`] : [])
)
if (d.unterkuenfte || d.ukDocs) lines.push(`Buchungskalender: ${d.unterkuenfte} Funktion(en) gelöscht (${d.ukDocs} Dokument(e)).`)
if (d.zeiten) lines.push(`Öffnungszeiten: ${d.zeiten} Datensatz/Datensätze gelöscht.`)
if (result.failed?.length) {
lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`))
+5 -5
View File
@@ -41,7 +41,7 @@ function liveUrl(project) {
}
/** Legt das Haendlerkonto an und liefert die Konto-ID. Idempotent je Projekt (Stripe liefert bei gleichem Schluessel dasselbe Konto). */
export async function kontoAnlegen({ customer, project }) {
export async function kontoAnlegen({ customer, project, webklar = 'shop', idempotencyKey } = {}) {
const customerId = String(customer?.$id || customer?.id || '')
const projectId = String(project?.$id || project?.id || '')
if (!projectId) throw fehler(400, 'Projekt fehlt.')
@@ -60,9 +60,9 @@ export async function kontoAnlegen({ customer, project }) {
requirement_collection: 'stripe',
},
capabilities: { card_payments: { requested: true }, transfers: { requested: true } },
metadata: { webklar: 'shop', customerId, projectId },
metadata: { webklar, customerId, projectId },
},
{ idempotencyKey: `shop-konto-${projectId}` }
{ idempotencyKey: idempotencyKey || `shop-konto-${projectId}` }
)
return account.id
}
@@ -139,7 +139,7 @@ export async function sessionAblaufen(accountId, id) {
}
/** Erstattung auf dem Haendlerkonto; ohne betragCent wird voll erstattet. */
export async function erstatten(accountId, { paymentIntent, betragCent, grund, idempotencyKey } = {}) {
export async function erstatten(accountId, { paymentIntent, betragCent, grund, idempotencyKey, webklar = 'shop' } = {}) {
pruefeKonto(accountId)
if (typeof paymentIntent !== 'string' || !PAYMENT_INTENT_RE.test(paymentIntent)) throw fehler(400, 'Ungültige Zahlung.')
if (betragCent !== undefined && (!Number.isInteger(betragCent) || betragCent <= 0)) throw fehler(400, 'Ungültiger Erstattungsbetrag.')
@@ -149,7 +149,7 @@ export async function erstatten(accountId, { paymentIntent, betragCent, grund, i
payment_intent: paymentIntent,
...(betragCent !== undefined ? { amount: betragCent } : {}),
reason: 'requested_by_customer',
metadata: { webklar: 'shop', ...(text ? { grund: text } : {}) },
metadata: { webklar, ...(text ? { grund: text } : {}) },
},
{ stripeAccount: accountId, ...(idempotencyKey ? { idempotencyKey } : {}) }
)
+7 -4
View File
@@ -617,8 +617,8 @@ async function assertAboLaeuftFuerAktivierung(customerId) {
const TEXTFORM_WEG = { chat: 'Chat im Kundenportal', email: 'E-Mail' }
/** Funktionen, die das Team mit Zustimmung des Kunden in Textform aktivieren kann (Modus on), und die Fassung der Unterlagen, auf die sie sich bezieht. */
export const TEXTFORM_KEYS = ['termine', 'shop']
export const textformFassung = (key) => (key === 'shop' ? config.shop.fassung : config.termine.avvVersion)
export const TEXTFORM_KEYS = ['termine', 'shop', 'unterkunft']
export const textformFassung = (key) => (key === 'shop' ? config.shop.fassung : key === 'unterkunft' ? config.unterkunft.fassung : config.termine.avvVersion)
/**
* Text des Nachweises, wenn der Kunde dem Terminplaner oder dem Webshop in Textform zugestimmt hat (Aktivierung durch das Team im
@@ -629,6 +629,9 @@ export function textformNachweisText({ date, channel }, fassung, key = 'termine'
if (key === 'shop') {
return `Der Kunde hat am ${tag}.${monat}.${jahr} per ${TEXTFORM_WEG[channel]} in Textform erklärt, dass er der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop (Fassung ${fassung}) zustimmt und die folgenden Erklärungen der Bestelltexte der Fassung ${fassung} abgibt.`
}
if (key === 'unterkunft') {
return `Der Kunde hat am ${tag}.${monat}.${jahr} per ${TEXTFORM_WEG[channel]} in Textform erklärt, dass er der Leistungsbeschreibung Buchungskalender und dem Auftragsverarbeitungsvertrag Buchungskalender (Fassung ${fassung}) zustimmt.`
}
return `Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung ${fassung}) am ${tag}.${monat}.${jahr} per ${TEXTFORM_WEG[channel]} in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.`
}
@@ -648,13 +651,13 @@ function textformNachweisTexte(textform, fassung, key) {
* Freischaltung ohne Zahlung und ohne Ticket (unlockedByPurchase bleibt false). Ausnahme: ein Terminplaner, den ein Kauf
* freigeschaltet hat (unlockedByPurchase true), wird nur bei laufendem Wartungs-Abo aktiviert (409 sonst, Lesefehler => Fehler).
* News und Dokumente ohne Kauf (Pilot, Kulanz) sind nicht betroffen.
* textform (nur Terminplaner und Webshop, schon geprueft: { date, channel }): Zustimmung des Kunden in Textform. Der Nachweis
* textform (nur Terminplaner, Webshop und Buchungskalender, schon geprueft: { date, channel }): Zustimmung des Kunden in Textform. Der Nachweis
* (featureConsents, ohne Stripe-Sitzung, IP und Browser; Konto = Mitarbeiter; Fassung = textformFassung(key)) wird VOR der Freischaltung
* gespeichert: scheitert er, bleibt die Funktion aus. Der Aufrufer sendet danach K16 mit den Unterlagen (meldeFreigeschaltet).
*/
export async function adminActivate(customerId, projectId, key, { by, adminNote, textform = null, now = new Date() } = {}) {
assertKey(key)
if (textform && !TEXTFORM_KEYS.includes(key)) throw httpError(400, 'Zustimmung in Textform gibt es nur für den Terminplaner und den Webshop')
if (textform && !TEXTFORM_KEYS.includes(key)) throw httpError(400, 'Zustimmung in Textform gibt es nur für den Terminplaner, den Webshop und den Buchungskalender')
await assertOwnProject(customerId, projectId)
const doc = await ensureFeatureDoc(customerId, projectId, key)
if (key === 'termine' && doc.unlockedByPurchase === true) await assertAboLaeuftFuerAktivierung(customerId)
+3 -1
View File
@@ -59,6 +59,7 @@ const PRODUCT_METADATA = {
newsSetup: 'news-einrichtung',
termineSetup: 'termine-einrichtung',
shopSetup: 'shop-einrichtung',
unterkunftSetup: 'unterkunft-einrichtung',
zeitenSetup: 'zeiten-einrichtung',
}
@@ -68,6 +69,7 @@ function catalogFor(key) {
if (key === 'newsSetup') return config.newsSetup
if (key === 'termineSetup') return config.termineSetup
if (key === 'shopSetup') return config.shopSetup
if (key === 'unterkunftSetup') return config.unterkunftSetup
if (key === 'zeitenSetup') return config.zeitenSetup
return getProduct(key)
}
@@ -75,7 +77,7 @@ function catalogFor(key) {
const priceIdCache = new Map()
/**
* Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' |
* 'mailStorage' | 'newsSetup' | 'termineSetup' | 'shopSetup' | 'zeitenSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe
* 'mailStorage' | 'newsSetup' | 'termineSetup' | 'shopSetup' | 'unterkunftSetup' | 'zeitenSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe
* nie uebertragen, ein neuer Preis bekaeme also ohnehin einen neuen lookup_key im Katalog.
*/
export async function ensurePrice(key) {
+3 -2
View File
@@ -24,6 +24,7 @@ import { kontextPerFeature, ladeEinstellung } from './kontext.js'
import { anGast } from './mails.js'
import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js'
import { shopModus } from '../shop/schalter.js'
import { unterkunftModus } from '../unterkunft/schalter.js'
import { termineAus } from './schalter.js'
import { buchungsNummer } from './token.js'
import { pruefeNow, zeitMs } from './util.js'
@@ -812,11 +813,11 @@ async function gibtBezahlteTerminplaner() {
/**
* Freigabe des Tagesjobs im Modus off (fuehreAus: trotzAus): false (aus) oder { termine, mails }. termine = es gibt ein bezahltes
* Termine-Dokument (Abo-Abgleich und Loeschung abgeschalteter Funktionen), mails = der Webshop ist aktiv (Warteschlange aufraeumen).
* Termine-Dokument (Abo-Abgleich und Loeschung abgeschalteter Funktionen), mails = der Webshop oder der Buchungskalender ist aktiv (Warteschlange aufraeumen).
* Eine einzige Abfrage; scheitert sie bei aktivem Shop, laeuft nur der Mail-Teil (Warnung), sonst wirft sie wie bisher.
*/
async function tagesjobFreigabe() {
const mails = shopModus() !== 'off'
const mails = shopModus() !== 'off' || unterkunftModus() !== 'off'
let termine = false
try {
termine = await gibtBezahlteTerminplaner()
+44 -2
View File
@@ -3,6 +3,8 @@ import { Query, countDocuments, createDocument, deleteDocument, getDocument, kei
import { withLocks } from '../keyedLock.js'
import { sendGuestMail } from '../mailer.js'
import { renderMail } from '../mailTemplates.js'
import { ladeRechnung } from '../unterkunft/speicher.js'
import { rechnungPdf } from '../unterkunft/rechnungPdf.js'
import { pruefeNow, pruefeText, zeitMs } from './util.js'
/**
@@ -190,12 +192,13 @@ const fehlerText = (err) =>
* Eingaben (TypeError) oder wenn das Speichern scheitert; dann ist nichts gespeichert und nichts gesendet.
* `ics` und `icsMethod` gehoeren zusammen: die METHOD-Zeile der Kalenderdatei muss icsMethod entsprechen.
* @param {{ featureId: string, bookingId: string, art: string, an: string, fromName: string, replyTo?: string|null,
* template: string, values?: object, blocks?: object, ics?: string|null, icsMethod?: 'REQUEST'|'CANCEL'|'PUBLISH' }} daten
* template: string, values?: object, blocks?: object, ics?: string|null, icsMethod?: 'REQUEST'|'CANCEL'|'PUBLISH',
* anhang?: string|null (Verweis auf einen Anhang, max. 500 Zeichen; wird nur gespeichert) }} daten
* @param {Date} [now]
* @returns {Promise<object>} das gespeicherte Dokument (Status wartet)
*/
export async function einreihen(
{ featureId, bookingId, art, an, fromName, replyTo = null, template, values = {}, blocks = {}, ics = null, icsMethod = 'REQUEST' },
{ featureId, bookingId, art, an, fromName, replyTo = null, template, values = {}, blocks = {}, ics = null, icsMethod = 'REQUEST', anhang = null },
now = new Date(),
) {
for (const [wert, name] of [[featureId, 'featureId'], [bookingId, 'bookingId'], [art, 'art'], [an, 'an'], [fromName, 'fromName'], [template, 'template']]) {
@@ -207,6 +210,8 @@ export async function einreihen(
if (!new RegExp(`^METHOD:${icsMethod}\\r?$`, 'm').test(ics)) throw new TypeError(`ics: METHOD passt nicht zu icsMethod ${icsMethod}`)
}
if (anhang !== null && anhang !== undefined && (typeof anhang !== 'string' || anhang.length > 500)) throw new TypeError('anhang muss ein Text bis 500 Zeichen sein')
const mail = renderMail(template, values, blocks)
for (const problem of mail.problems) console.warn(`[termine-outbox] Vorlage ${template}: ${problem}`)
@@ -222,6 +227,8 @@ export async function einreihen(
text: mail.text,
ics: ics || '',
icsMethod: ics ? icsMethod : '',
// nur wenn gesetzt: das Attribut gibt es in Appwrite erst nach erneutem termine-setup, unbekannte Felder wuerden abgelehnt
...(anhang ? { anhang } : {}),
status: 'wartet',
versuche: 0,
naechsterVersuch: iso(nowMs),
@@ -281,6 +288,7 @@ async function sende(id, now) {
if (!platz) return 'wartet'
try {
const attachments = await baueAnhaenge(doc)
await sendGuestMail({
to: doc.an,
fromName: doc.fromName,
@@ -290,6 +298,7 @@ async function sende(id, now) {
text: doc.text,
ics: doc.ics || null,
icsMethod: doc.icsMethod || 'REQUEST',
...(attachments.length ? { attachments } : {}),
})
} catch (err) {
gibPlatzFrei(platz)
@@ -300,6 +309,39 @@ async function sende(id, now) {
})
}
/** Nach so vielen Fehlschlaegen beim Erzeugen des Anhangs geht die Mail ohne ihn raus (Buchungskalender). */
const ANHANG_VERSUCHE = 3
/**
* Anhaenge des Eintrags. Das Feld `anhang` ist JSON {art:'ukRechnung', rechnungId}: die Rechnung (ukRechnungen) wird als PDF
* gerendert (Rechnung-<nr>.pdf bzw. Stornorechnung-<nr>.pdf). Ohne `anhang` oder mit anderem Inhalt: keine Anhaenge. Scheitert das
* Laden oder Rendern, wirft die Funktion einen gewoehnlichen Fehler (der Eintrag bleibt `wartet` und wird wie jeder vorlaeufige
* Fehler wiederholt); ab dem vierten Versuch geht die Mail ohne Anhang raus. Der Fehlertext enthaelt keine Personendaten.
*/
async function baueAnhaenge(doc) {
if (!doc.anhang) return []
let verweis
try {
verweis = JSON.parse(doc.anhang)
} catch {
return []
}
if (verweis?.art !== 'ukRechnung' || typeof verweis.rechnungId !== 'string' || !verweis.rechnungId) return []
try {
const rechnung = await ladeRechnung(verweis.rechnungId)
if (!rechnung?.inhalt) throw new Error('Rechnung nicht lesbar')
const storno = rechnung.art === 'storno'
const pdf = rechnungPdf(rechnung.inhalt, { art: rechnung.art, nummer: rechnung.nummer, datum: rechnung.datum })
return [{ filename: `${storno ? 'Stornorechnung' : 'Rechnung'}-${rechnung.nummer}.pdf`, content: Buffer.from(pdf), contentType: 'application/pdf' }]
} catch (err) {
if ((Number(doc.versuche) || 0) >= ANHANG_VERSUCHE) {
console.error(`[termine-outbox] Eintrag ${doc.$id}: Rechnungs-PDF nicht erzeugt, Mail geht ohne Anhang raus (${String(err?.message).slice(0, 80)})`)
return []
}
throw new Error(`Anhang nicht erzeugt: ${String(err?.message).slice(0, 80)}`)
}
}
/**
* Ist der Bestaetigungslink dieser G01 schon verfallen? Das Halte-Fenster (config.termine.holdMinutes, bei jedem Aufruf
* gelesen) plus eine Minute Toleranz ab Einreihen; genau so alt ist noch nicht veraltet. G01 ohne Buchung (zu_viele) hat
+2
View File
@@ -110,6 +110,8 @@ export const TERMINE_COLLECTIONS = {
str('text', 50000),
str('ics', 20000),
str('icsMethod', 8),
// optional: Verweis auf einen Anhang (Buchungskalender: Rechnungs-PDF, Task 20); wird nur gespeichert, das Senden bleibt unveraendert
str('anhang', 500),
str('status', 16),
int('versuche'),
dt('naechsterVersuch'),
+330
View File
@@ -0,0 +1,330 @@
import { randomInt } from 'node:crypto'
import { config } from '../../config.js'
import { withLocks } from '../keyedLock.js'
import { heute } from '../termine/zeit.js'
import { istTag, naechte } from './datum.js'
import { istFrei } from './belegung.js'
import { UkFehler } from './fehler.js'
import * as mails from './mails.js'
import { BRUTTO_MAX_CENT, berechnePreis, preiseVollstaendig, pruefePosten, summiere } from './preis.js'
import { stelleAus } from './rechnungen.js'
import { aktualisiereAnfrage, ladeAnfrage, findeRechnung, ladeEinstellungen, ladePreise, ladeRechnung, ladeUnterkunft, legeAnfrageDokAn, listeAnfragen, listeAnfragenAlle } from './speicher.js'
import { linkGueltig, neuesToken, tokenPasst } from './token.js'
import * as zahlung from './zahlung.js'
export const UEBERGAENGE = {
neu: ['freigegeben', 'abgelehnt'],
freigegeben: ['gebucht', 'abgelaufen', 'storniert'],
gebucht: ['bezahlt', 'storniert'],
bezahlt: ['storniert'],
abgelehnt: [],
abgelaufen: [],
storniert: [],
}
export const darfUebergang = (von, nach) => (UEBERGAENGE[von] ?? []).includes(nach)
const TAG_MS = 24 * 3600 * 1000
const iso = (ms) => new Date(ms).toISOString()
const ms = (now) => (now instanceof Date ? now.getTime() : Number(now))
const withLocksKey = (id) => ['unterkunft:' + id]
const NUMMER_ZEICHEN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ23456789'
/** Mail nach erfolgreichem Schreiben: ein Mailfehler macht den schon gespeicherten Schritt nicht rueckgaengig. */
async function sende(fn) {
try {
await fn()
} catch (err) {
console.warn('Buchungskalender: Mail fehlgeschlagen:', err?.message || err)
}
}
/** Rechnung zu Anfrage und Art ausstellen, aber nur einmal: eine vorhandene wird wiederverwendet (Wiederholung nach Teilfehler). */
async function rechnungEinmal(ctx, a, art, opts) {
return (await findeRechnung(a.$id, art)) ?? stelleAus(ctx, a, { art, ...opts })
}
function fuerFeature(ctx, a) {
if (!a || a.featureId !== ctx.featureId) throw new UkFehler(404, 'nicht_gefunden')
return a
}
/** Ohne Rechnungsauftrag (Zustimmung des Vermieters) darf nichts freigegeben oder verbindlich gebucht werden. */
function brauchtZustimmung(ctx) {
if (!ctx?.einstellungen?.rechnungAuftragAt) throw new UkFehler(409, 'zustimmung_fehlt')
}
const text = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '')
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
function monateDazu(tag, n) {
const [j, m, t] = tag.split('-').map(Number)
const d = new Date(Date.UTC(j, m - 1 + n, 1))
const letzter = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate()
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), Math.min(t, letzter))).toISOString().slice(0, 10)
}
async function neueNummer(featureId) {
const vorhanden = new Set((await listeAnfragen(featureId)).map((a) => a.nummer))
for (let i = 0; i < 20; i++) {
let n = 'A-'
for (let k = 0; k < 4; k++) n += NUMMER_ZEICHEN[randomInt(NUMMER_ZEICHEN.length)]
if (!vorhanden.has(n)) return n
}
throw new UkFehler(500, 'nummer_vergeben')
}
export async function legeAnfrageAn(ctx, eingabe, { now = new Date(), ipHash = '' } = {}) {
const e = eingabe || {}
const anfrage = await withLocks(withLocksKey(e.unterkunftId), async () => {
const u = await ladeUnterkunft(e.unterkunftId)
if (!u || u.featureId !== ctx.featureId || u.aktiv !== true) throw new UkFehler(404, 'nicht_gefunden')
if (!istTag(e.von) || !istTag(e.bis)) throw new UkFehler(400, 'ungueltig', { zeitraum: 'Ungültige Daten.' })
const n = naechte(e.von, e.bis)
const tag = heute(now)
if (n < 1 || e.von < tag) throw new UkFehler(400, 'ungueltig', { zeitraum: 'Der Zeitraum ist ungültig.' })
if (e.bis > monateDazu(tag, config.unterkunft.horizontMonate)) throw new UkFehler(400, 'ungueltig', { zeitraum: 'Der Zeitraum liegt zu weit in der Zukunft.' })
if (n < (u.mindestNaechte || 1)) throw new UkFehler(400, 'mindestnaechte', { mindestNaechte: u.mindestNaechte })
if (!Number.isInteger(e.personen) || e.personen < 1) throw new UkFehler(400, 'ungueltig', { personen: 'Ungültige Personenzahl.' })
if (e.personen > u.maxPersonen) throw new UkFehler(400, 'zu_viele_personen', { maxPersonen: u.maxPersonen })
const g = e.gast || {}
const gast = {
name: text(g.name, 150),
firma: text(g.firma, 150),
email: text(g.email, 200),
telefon: text(g.telefon, 50),
nachricht: text(g.nachricht, config.unterkunft.nachrichtMax ?? 2000),
}
if (!gast.name) throw new UkFehler(400, 'ungueltig', { name: 'Bitte einen Namen angeben.' })
if (!EMAIL_RE.test(gast.email)) throw new UkFehler(400, 'ungueltig', { email: 'Bitte eine gültige E-Mail-Adresse angeben.' })
if (!(await istFrei(u.$id, e.von, e.bis, { now }))) throw new UkFehler(409, 'zeitraum_belegt')
return legeAnfrageDokAn({
featureId: ctx.featureId,
customerId: ctx.customerId,
projectId: ctx.projectId,
unterkunftId: u.$id,
nummer: await neueNummer(ctx.featureId),
von: e.von,
bis: e.bis,
personen: e.personen,
status: 'neu',
gast,
ipHash: String(ipHash || ''),
erstattetCent: 0,
})
})
await sende(() => mails.anGast('G16', ctx, anfrage))
await sende(() => mails.anVermieter('K36', ctx, { anfrage }))
return anfrage
}
export async function gibFrei(ctx, anfrageId, { posten, fristTage, nachricht, now = new Date() } = {}) {
const pre = fuerFeature(ctx, await ladeAnfrage(anfrageId))
brauchtZustimmung(ctx)
const ergebnis = await withLocks(withLocksKey(pre.unterkunftId), async () => {
const a = fuerFeature(ctx, await ladeAnfrage(anfrageId))
if (a.status !== 'neu') throw new UkFehler(409, 'status_ungueltig')
const preise = await ladePreise(a.unterkunftId)
let sauber
if (posten !== undefined && posten !== null) {
const r = pruefePosten(posten, preise?.steuerart ?? 'regel')
if (r.fehler) throw new UkFehler(400, 'ungueltig', r.fehler)
sauber = r.posten
} else {
if (!preiseVollstaendig(preise)) throw new UkFehler(400, 'ungueltig', { posten: 'Preise sind nicht vollständig.' })
sauber = berechnePreis({ preise, von: a.von, bis: a.bis, personen: a.personen }).posten
}
const steuerart = preise?.steuerart ?? 'regel'
const summen = { ...summiere(sauber, steuerart), steuerart }
if (!(summen.bruttoCent > 0)) throw new UkFehler(400, 'ungueltig', { posten: 'Die Summe muss größer als 0 sein.' })
if (summen.bruttoCent > BRUTTO_MAX_CENT) throw new UkFehler(400, 'ungueltig', { posten: 'Die Summe ist zu hoch.' })
if (!(await istFrei(a.unterkunftId, a.von, a.bis, { ohneAnfrage: a.$id, now }))) throw new UkFehler(409, 'zeitraum_belegt')
const tage = Number.isInteger(fristTage) && fristTage >= 1 ? fristTage : preise?.fristTage || 3
const { token, hash } = neuesToken()
const neu = await aktualisiereAnfrage(a.$id, {
status: 'freigegeben',
posten: sauber,
summen,
fristAt: iso(ms(now) + tage * TAG_MS),
tokenHash: hash,
stornobedingungenText: preise?.stornobedingungen ?? '',
rechtstexteFassung: config.unterkunft.fassung,
nachrichtVermieter: text(nachricht, 2000),
ipHash: '',
freigegebenAt: iso(ms(now)),
})
return { anfrage: neu, token }
})
await sende(() => mails.anGast('G17', ctx, ergebnis.anfrage, { token: ergebnis.token }))
return ergebnis
}
export async function lehneAb(ctx, anfrageId, { text: grund, now = new Date() } = {}) {
const pre = fuerFeature(ctx, await ladeAnfrage(anfrageId))
const neu = await withLocks(withLocksKey(pre.unterkunftId), async () => {
const a = fuerFeature(ctx, await ladeAnfrage(anfrageId))
if (a.status !== 'neu') throw new UkFehler(409, 'status_ungueltig')
return aktualisiereAnfrage(a.$id, { status: 'abgelehnt', nachrichtVermieter: text(grund, 2000), ipHash: '', abgelehntAt: iso(ms(now)) })
})
await sende(() => mails.anGast('G18', ctx, neu, { text: text(grund, 2000) }))
return neu
}
const ADRESSE_PFLICHT = ['strasse', 'plz', 'ort', 'land']
function pruefeAdresse(r) {
const x = r && typeof r === 'object' ? r : {}
const adresse = {
name: text(x.name, 150),
firma: text(x.firma, 150),
strasse: text(x.strasse, 150),
plz: text(x.plz, 10),
ort: text(x.ort, 100),
land: text(x.land, 60),
ustId: text(x.ustId, 20),
}
const felder = {}
if (!adresse.name && !adresse.firma) felder.name = 'Bitte Name oder Firma angeben.'
for (const k of ADRESSE_PFLICHT) if (!adresse[k]) felder[k] = 'Pflichtfeld.'
if (Object.keys(felder).length) throw new UkFehler(400, 'ungueltig', felder)
return adresse
}
export async function bucheVerbindlich(ctx, anfrageId, token, { rechnungsadresse, zahlart, now = new Date() } = {}) {
const pre = await ladeAnfrage(anfrageId)
if (!pre || pre.featureId !== ctx.featureId) throw new UkFehler(404, 'nicht_gefunden')
const r = await withLocks(withLocksKey(pre.unterkunftId), async () => {
const a = await ladeAnfrage(anfrageId)
if (!a || a.featureId !== ctx.featureId || !tokenPasst(a.tokenHash, token) || !linkGueltig(a, now)) throw new UkFehler(404, 'nicht_gefunden')
if (a.status === 'gebucht' || a.status === 'bezahlt') {
return { anfrage: a, rechnung: a.rechnungId ? await ladeRechnung(a.rechnungId) : null, neu: false }
}
brauchtZustimmung(ctx)
if (a.status === 'abgelaufen') throw new UkFehler(410, 'frist_abgelaufen')
if (a.status !== 'freigegeben') throw new UkFehler(409, 'status_ungueltig')
if (!(Date.parse(a.fristAt) > ms(now))) throw new UkFehler(410, 'frist_abgelaufen')
if (zahlart !== 'online' && zahlart !== 'vorort') throw new UkFehler(400, 'ungueltig', { zahlart: 'Bitte eine Zahlart wählen.' })
const einst = ctx.einstellungen || {}
if (zahlart === 'online' && !(einst.onlineZahlung && einst.stripeAccountId)) throw new UkFehler(409, 'online_aus')
const adresse = pruefeAdresse(rechnungsadresse)
if (!einst.firma || !einst.strasse || !einst.plz || !einst.ort || !(einst.steuernummer || einst.ustId)) {
throw new UkFehler(409, 'einstellungen_unvollstaendig')
}
if (!Array.isArray(a.posten) || !a.summen) throw new UkFehler(409, 'status_ungueltig')
if (!(await istFrei(a.unterkunftId, a.von, a.bis, { ohneAnfrage: a.$id, now }))) throw new UkFehler(409, 'zeitraum_belegt')
// Existiert die Rechnung schon (Wiederholung nach Teilfehler), gelten ihre Adresse und Zahlart weiter.
const vorhanden = await findeRechnung(a.$id, 'rechnung')
const endgAdresse = vorhanden?.inhalt?.empfaenger ?? adresse
const endgZahlart = vorhanden?.inhalt?.zahlart || zahlart
const vorlaeufig = { ...a, rechnungsadresse: endgAdresse, zahlart: endgZahlart }
const rechnung = vorhanden ?? (await rechnungEinmal(ctx, vorlaeufig, 'rechnung', { now }))
const anfrage = await aktualisiereAnfrage(a.$id, {
status: 'gebucht',
rechnungsadresse: endgAdresse,
zahlart: endgZahlart,
rechnungId: rechnung.$id,
gebuchtAt: iso(ms(now)),
})
return { anfrage, rechnung, neu: true }
})
if (r.neu) {
await sende(() => mails.anGast('G19', ctx, r.anfrage, { rechnung: r.rechnung }))
await sende(() => mails.anVermieter('K37', ctx, { anfrage: r.anfrage }))
}
return { anfrage: r.anfrage, rechnung: r.rechnung }
}
export async function markiereBezahlt(ctx, anfrageId, { via, paymentIntentId, now = new Date() } = {}) {
if (via !== 'stripe' && via !== 'vorort') throw new UkFehler(400, 'ungueltig', { via: 'Ungültig.' })
const pre = fuerFeature(ctx, await ladeAnfrage(anfrageId))
const r = await withLocks(withLocksKey(pre.unterkunftId), async () => {
const a = fuerFeature(ctx, await ladeAnfrage(anfrageId))
if (a.status === 'bezahlt') return { anfrage: a, neu: false }
if (a.status !== 'gebucht') throw new UkFehler(409, 'status_ungueltig')
const felder = { status: 'bezahlt', bezahltVia: via, bezahltAt: iso(ms(now)) }
if (paymentIntentId) felder.stripePaymentIntentId = paymentIntentId
return { anfrage: await aktualisiereAnfrage(a.$id, felder), neu: true }
})
if (r.neu && via === 'stripe') await sende(() => mails.anGast('G20', ctx, r.anfrage))
return r.anfrage
}
function pruefeErstattung(a, betragCent, bisher = a.erstattetCent || 0) {
const brutto = a.summen?.bruttoCent
if (a.bezahltVia !== 'stripe') throw new UkFehler(409, 'status_ungueltig', { erstattungCent: 'Erstattung ist nur bei Online-Zahlung möglich.' })
if (!Number.isInteger(betragCent) || betragCent <= 0 || !Number.isInteger(brutto) || betragCent > brutto - bisher) {
throw new UkFehler(400, 'ungueltig', { erstattungCent: 'Der Betrag ist ungültig oder zu hoch.' })
}
return bisher
}
/**
* Erstattung einer stornierten, online bezahlten Buchung. Vor dem Stripe-Aufruf wird der bei Stripe LIVE erstattete Betrag der Zahlung
* gelesen; bisher = max(erstattetCent, live), die Obergrenze wird gegen diesen Wert geprueft (kein doppeltes Erstatten, wenn ein frueherer
* Versuch bei Stripe durchging, aber nicht gespeichert wurde). Liegt die Datenbank hinter Stripe zurueck, wird sie zuerst nachgezogen.
* erstattetCent wird ERST NACH dem Stripe-Erfolg geschrieben, ohne Rollback; der charge.refunded-Webhook heilt ebenfalls.
* wiederholung: Wiederholung eines Storno-Aufrufs - ist der Betrag bei Stripe schon erstattet, wird nur nachgezogen (nichts erstattet).
*/
export async function erstatteBetrag(ctx, anfrageId, { betragCent, now = new Date(), wiederholung = false } = {}) {
const pre = fuerFeature(ctx, await ladeAnfrage(anfrageId))
return withLocks(withLocksKey(pre.unterkunftId), async () => {
let a = fuerFeature(ctx, await ladeAnfrage(anfrageId))
if (a.status !== 'storniert') throw new UkFehler(409, 'status_ungueltig')
if (a.bezahltVia !== 'stripe') pruefeErstattung(a, betragCent)
const live = await zahlung.liveErstattet(ctx, a)
const bisher = Math.max(a.erstattetCent || 0, live)
if (live > (a.erstattetCent || 0)) a = await aktualisiereAnfrage(a.$id, { erstattetCent: live })
if (wiederholung && Number.isInteger(betragCent) && betragCent > 0 && live >= betragCent) return a
pruefeErstattung(a, betragCent, bisher)
const res = await zahlung.erstatte(ctx, a, betragCent, { now, idempotencyKey: `uk-erstattung-${anfrageId}-${bisher + betragCent}` })
return aktualisiereAnfrage(a.$id, { erstattetCent: bisher + res.erstattetCent })
})
}
export async function storniere(ctx, anfrageId, { grund = '', erstattungCent = 0, now = new Date() } = {}) {
if (!Number.isInteger(erstattungCent) || erstattungCent < 0) throw new UkFehler(400, 'ungueltig', { erstattungCent: 'Ungültiger Betrag.' })
const pre = fuerFeature(ctx, await ladeAnfrage(anfrageId))
const r = await withLocks(withLocksKey(pre.unterkunftId), async () => {
const a = fuerFeature(ctx, await ladeAnfrage(anfrageId))
if (a.status === 'storniert') return { anfrage: a, stornoRechnung: await findeRechnung(a.$id, 'storno'), neu: false }
if (!darfUebergang(a.status, 'storniert')) throw new UkFehler(409, 'status_ungueltig')
if (erstattungCent > 0) pruefeErstattung(a, erstattungCent)
let stornoRechnung = null
if (a.rechnungId) {
const orig = await ladeRechnung(a.rechnungId)
if (orig) stornoRechnung = await rechnungEinmal(ctx, a, 'storno', { bezugNummer: orig.nummer, bezug: orig, now })
}
const anfrage = await aktualisiereAnfrage(a.$id, { status: 'storniert', stornoGrund: text(grund, 500), storniertAt: iso(ms(now)) })
return { anfrage, stornoRechnung, neu: true }
})
if (r.neu) await sende(() => zahlung.beendeOffeneZahlung(ctx, r.anfrage))
if (r.neu) await sende(() => mails.anGast('G22', ctx, r.anfrage, { stornoRechnung: r.stornoRechnung, erstattetCent: erstattungCent, grund: text(grund, 500) }))
let anfrage = r.anfrage
if (erstattungCent > 0) {
try {
anfrage = await erstatteBetrag(ctx, anfrageId, { betragCent: erstattungCent, now, wiederholung: !r.neu })
} catch (err) {
if (err instanceof UkFehler && err.status < 500 && err.code !== 'online_aus') throw err
throw new UkFehler(502, 'erstattung_fehlgeschlagen')
}
}
return { anfrage, stornoRechnung: r.stornoRechnung }
}
/** Alle freigegebenen Anfragen mit abgelaufener Frist auf 'abgelaufen' setzen (Minutenjob, alle Unterkuenfte). */
export async function laufeFristenAb(now = new Date()) {
let abgelaufen = 0
for (const k of await listeAnfragenAlle('freigegeben')) {
if (!(Date.parse(k.fristAt) <= ms(now))) continue
const neu = await withLocks(withLocksKey(k.unterkunftId), async () => {
const a = await ladeAnfrage(k.$id)
if (!a || a.status !== 'freigegeben' || !(Date.parse(a.fristAt) <= ms(now))) return null
return aktualisiereAnfrage(a.$id, { status: 'abgelaufen', abgelaufenAt: iso(ms(now)) })
})
if (!neu) continue
abgelaufen++
const ctx = { featureId: neu.featureId, customerId: neu.customerId, projectId: neu.projectId, einstellungen: (await ladeEinstellungen(neu.featureId)) ?? {} }
await sende(() => mails.anGast('G21', ctx, neu))
}
return { abgelaufen }
}
@@ -0,0 +1,80 @@
import { config } from '../../config.js'
import { getDocument, listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { sendCustomerMail } from '../mailer.js'
import { customerOf } from '../siteFeatures.js'
import { datumKurz, heute } from '../termine/zeit.js'
import { hatUkDaten } from './aufbewahrung.js'
/**
* Hinweis-Mail K35 vor der Loeschung: Sobald der Tagesjob einen abgeschalteten Buchungskalender mit Daten erstmals sieht, geht EINMAL
* K35 an die E-Mail des Kundenkontos (Loeschdatum = disabledAt + loeschTageNachAbschalten Tage, Rechnungen bis dahin im Portal abrufbar).
* Marker in portalFeatures.adminNote: "K35 am YYYY-MM-DD" (gilt nur, wenn nicht vor disabledAt; ein spaeteres Abschalten setzt disabledAt neu
* und loest den Hinweis erneut aus). Scheitert der Versand, bleibt der Marker aus und der naechste Lauf wiederholt ihn. Ein Kunde ohne Adresse
* (z. B. Kontoloeschung) bekommt den Marker ohne Mail. Ist das Loeschdatum schon erreicht, geht keine Mail mehr raus (die Loeschung laeuft im
* selben Lauf). Logs ohne Personendaten.
*/
const TAG_MS = 24 * 60 * 60 * 1000
const MARKER_RE = /K35 am (\d{4}-\d{2}-\d{2})/
const zeitMs = (v) => {
const t = Date.parse(v)
return Number.isNaN(t) ? NaN : t
}
function markiere(notiz, tag) {
const marker = `K35 am ${tag}`
const alt = String(notiz ?? '').replace(/\s*(?:[-–|]\s*)?K35 am \d{4}-\d{2}-\d{2}/g, '').trim()
if (!alt) return marker
return `${alt.slice(0, Math.max(0, 300 - marker.length - 3))} – ${marker}`
}
/** @returns {Promise<{ geprueft: number, gesendet: number, ohneAdresse: number, fehler: number }>} */
export async function sendeHinweiseVorLoeschung(now = new Date()) {
const nowMs = now instanceof Date ? now.getTime() : Number(now)
const tage = config.unterkunft.loeschTageNachAbschalten ?? 90
const FEATURES = config.collections.portalFeatures
const erg = { geprueft: 0, gesendet: 0, ohneAdresse: 0, fehler: 0 }
const funktionen = await listAllDocuments(FEATURES, [Query.equal('featureKey', 'unterkunft'), Query.equal('state', 'disabled')])
for (const f of funktionen) {
const abgeschaltet = zeitMs(f.disabledAt)
if (!Number.isFinite(abgeschaltet)) continue
const m = MARKER_RE.exec(String(f.adminNote ?? ''))
if (m && m[1] >= String(f.disabledAt).slice(0, 10)) continue
const loeschAm = abgeschaltet + tage * TAG_MS
if (loeschAm <= nowMs) continue
erg.geprueft += 1
try {
if (!(await hatUkDaten(f.$id))) continue
const kunde = f.customerId ? await customerOf(f.customerId).catch(() => null) : null
const an = typeof kunde?.email === 'string' ? kunde.email.trim() : ''
if (an) {
let projekt = ''
try {
const p = await getDocument(config.collections.websiteProjects, f.projectId)
projekt = p?.projectName || p?.subdomain || ''
} catch {
// ohne Projektname: die Vorlage nennt dann keinen
}
await sendCustomerMail(an, {
template: 'K35',
values: {
kunde_name: typeof kunde.name === 'string' ? kunde.name.trim() : '',
projekt,
loeschdatum: datumKurz(new Date(loeschAm)),
portal_url: `${config.publicBaseUrl}/dashboard.html#buchungen?projekt=${encodeURIComponent(f.projectId ?? '')}`,
},
blocks: {},
bccTeam: true,
})
erg.gesendet += 1
} else {
erg.ohneAdresse += 1
}
await updateDocument(FEATURES, f.$id, { adminNote: markiere(f.adminNote, heute(new Date(nowMs))) })
} catch (err) {
erg.fehler += 1
console.warn(`[unterkunft-aufbewahrung] K35 für Funktion ${f.$id} nicht gesendet: ${[err?.name, err?.status].filter(Boolean).join(' ')}`)
}
}
return erg
}
@@ -0,0 +1,296 @@
import { config } from '../../config.js'
import { withLocks } from '../keyedLock.js'
import { sendAdminNotice, sendCustomerMail } from '../mailer.js'
import { assertOwnProject, customerOf, ensureFeatureDoc, getFeatureDoc } from '../siteFeatures.js'
import { unterkunftFuerKunde } from './schalter.js'
import { ladeEinstellungen, speichereEinstellungen } from './speicher.js'
/**
* Anfrage der Funktion "Buchungskalender" (Muster services/shop/anfrage.js, Spezifikation 2026-10-07-buchungskalender-design.md,
* Abschnitt 9). Der Kunde stellt eine Anfrage, das Team entscheidet (annehmen oder ablehnen); erst eine angenommene Anfrage laesst
* die Bestellung zu (anfrageOffenePunkte, Haken bestellpruefung). Die Anfrage steht in ukEinstellungen (Dokument-ID = ID des
* portalFeatures-Dokuments): anfrage (JSON), anfrageStatus ('' | offen | angenommen | abgelehnt | zurueckgezogen), anfrageAt,
* anfrageEntschiedenAt, anfrageEntschiedenVon, anfrageBegruendung. Sperre je Projekt: uk-anfrage:<projectId>.
* Fehler an den Kunden tragen status (4xx) und einen deutschen Text (Du-Form), optional err.body.code und err.fehler (je Feld).
* Mails: T23 ans Team (Anfrage eingegangen), K34 an den Kunden (Entscheidung).
*/
export const ANFRAGE_OFFEN_TEXT = 'Deine Anfrage für den Buchungskalender ist noch nicht freigegeben.'
export const ARTEN = ['ferienwohnung', 'monteurzimmer', 'pension', 'sonstiges']
export const ART_NAMEN = { ferienwohnung: 'Ferienwohnung oder Ferienhaus', monteurzimmer: 'Monteurwohnung', pension: 'Pension oder Gästezimmer', sonstiges: 'Sonstiges' }
/** Das Portal sendet 'monteurzimmer'; 'monteur' (aeltere Fassung, ui-erwartungen.md) wird weiter als Alias angenommen und gelesen. */
const ART_ALIAS = { monteur: 'monteurzimmer' }
export const PORTALE = ['booking', 'airbnb']
export const PORTAL_NAMEN = { booking: 'Booking.com', airbnb: 'Airbnb', andere: 'Portale (ohne Angabe)' }
const EINHEITEN_MAX = 999
const NACHRICHT_MAX = 1000
const BEGRUENDUNG_MAX = 1000
const MELDUNG = {
projekt: 'Bitte wähle ein Projekt.',
nichtVerfuegbar: 'Der Buchungskalender ist noch nicht verfügbar.',
vorhanden: 'Für dieses Projekt gibt es schon einen Buchungskalender.',
laeuft: 'Für dieses Projekt gibt es schon eine Anfrage für den Buchungskalender.',
nichtOffen: 'Die Anfrage kann nur zurückgezogen werden, solange sie offen ist.',
nichtEntscheidbar: 'Diese Anfrage ist nicht (mehr) offen und kann nicht entschieden werden.',
entscheidung: 'Bitte wähle annehmen oder ablehnen.',
begruendung: 'Bitte nenne eine Begründung für die Ablehnung (höchstens 1000 Zeichen).',
ungueltig: 'Ungültige Eingabe.',
}
const stop = (status, text, code) => Object.assign(new Error(text), { status, ...(code ? { body: { code } } : {}) })
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
/** Versand (nur fuer Tests austauschbar). */
export const _versand = {
admin: (nachricht) => sendAdminNotice(nachricht),
kunde: (an, nachricht) => sendCustomerMail(an, nachricht),
}
/** [] wenn die Anfrage des Projekts vom Team angenommen ist, sonst ein Punkt. Lesefehler werden weitergereicht. */
export async function anfrageOffenePunkte({ customerId, projectId }) {
const doc = await getFeatureDoc(customerId, projectId, 'unterkunft')
if (!doc) return [ANFRAGE_OFFEN_TEXT]
const einst = await ladeEinstellungen(doc.$id)
return einst?.anfrageStatus === 'angenommen' ? [] : [ANFRAGE_OFFEN_TEXT]
}
/**
* Eingabe pruefen => { anfrage } (nur bekannte Felder, bereinigt) oder { fehler: { feld: text } }.
* art (ARTEN, auch 'monteur'), einheiten (ganze Zahl 1-999), portale (Liste aus booking|airbnb, leer erlaubt; ein boolean wird
* angenommen: true = 'andere'), nachricht (optional, bis 1000 Zeichen).
*/
export function normalisiereAnfrage(eingabe) {
const fehler = {}
const e = istObjekt(eingabe) ? eingabe : {}
const artRoh = typeof e.art === 'string' ? (Object.hasOwn(ART_ALIAS, e.art) ? ART_ALIAS[e.art] : e.art) : ''
const art = ARTEN.includes(artRoh) ? artRoh : ''
if (!art) fehler.art = 'Bitte wähle die Art deiner Unterkunft.'
const einheiten = e.einheiten
if (!Number.isInteger(einheiten) || einheiten < 1 || einheiten > EINHEITEN_MAX) fehler.einheiten = `Bitte gib eine ganze Zahl von 1 bis ${EINHEITEN_MAX} an.`
let portale = []
if (e.portale === undefined || e.portale === null) portale = []
else if (typeof e.portale === 'boolean') portale = e.portale ? ['andere'] : []
else if (Array.isArray(e.portale) && e.portale.every((p) => PORTALE.includes(p))) portale = PORTALE.filter((p) => e.portale.includes(p))
else fehler.portale = MELDUNG.ungueltig
let nachricht = ''
if (e.nachricht !== undefined && e.nachricht !== null && e.nachricht !== '') {
if (typeof e.nachricht !== 'string') fehler.nachricht = MELDUNG.ungueltig
else {
nachricht = e.nachricht.toWellFormed().trim()
if (nachricht.length > NACHRICHT_MAX) fehler.nachricht = `Die Nachricht darf höchstens ${NACHRICHT_MAX} Zeichen lang sein.`
}
}
if (Object.keys(fehler).length) return { fehler }
return { anfrage: { art, einheiten, portale, nachricht } }
}
/** Gespeicherte Anfrage (aus ukEinstellungen.anfrage, schon geparst) bereinigt oder null. */
function leseAnfrage(einst) {
const roh = einst?.anfrage
if (!istObjekt(roh)) return null
return {
art: ARTEN.includes(roh.art) ? roh.art : '',
einheiten: Number.isInteger(roh.einheiten) ? roh.einheiten : null,
portale: Array.isArray(roh.portale) ? roh.portale.filter((p) => typeof p === 'string') : [],
nachricht: typeof roh.nachricht === 'string' ? roh.nachricht : '',
}
}
/**
* Anfrage fuer das Portal des Kunden: { status, anfrage: { art ('monteur' statt 'monteurzimmer'), einheiten, portale (Liste),
* nachricht }, at, entschiedenAt, begruendung } oder null, solange es keine gab.
*/
export function anfrageAnsicht(einst) {
const status = einst?.anfrageStatus || ''
if (!status) return null
const a = leseAnfrage(einst)
return {
status,
anfrage: a ? { ...a, art: a.art === 'monteurzimmer' ? 'monteur' : a.art } : null,
at: einst.anfrageAt || null,
entschiedenAt: einst.anfrageEntschiedenAt || null,
begruendung: einst.anfrageBegruendung || '',
}
}
/** Anfrage fuer das Team (WOMS): flach, art wie gespeichert, portale als boolean; ohne Anfrage status ''. */
export function anfrageAnsichtTeam(einst) {
const a = leseAnfrage(einst)
return {
status: einst?.anfrageStatus || '',
at: einst?.anfrageAt || null,
art: a?.art || '',
einheiten: a?.einheiten ?? null,
portale: (a?.portale.length ?? 0) > 0,
nachricht: a?.nachricht || '',
entschiedenAt: einst?.anfrageEntschiedenAt || null,
entschiedenVon: einst?.anfrageEntschiedenVon || '',
begruendung: einst?.anfrageBegruendung || '',
}
}
/** Projekt des Kunden (Appwrite-Fehler 400 bei unbrauchbarer ID zaehlt als fremdes Projekt) und Schalter des Kunden. */
async function pruefeZugang({ customerId, projectId, person, neu }) {
if (typeof projectId !== 'string' || !projectId) throw stop(400, MELDUNG.projekt)
let project
try {
project = await assertOwnProject(customerId, projectId, { forNewOrder: neu })
} catch (err) {
throw err?.status === 400 && err.type ? stop(403, 'Kein Zugriff auf dieses Projekt') : err
}
if (!unterkunftFuerKunde(person?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar')
return project
}
/**
* Anfrage stellen. Reihenfolge: Projekt (403/409 archiviert), Schalter (409), Eingabe (400 mit `fehler`), dann unter Sperre je
* Projekt: Funktion muss `off` sein (409), Status '' | abgelehnt | zurueckgezogen (409 sonst). Speichert, loescht eine fruehere
* Entscheidung und schickt T23 ans Team (ein Fehler dabei wird nur geloggt).
* @returns {Promise<object>} anfrageAnsicht des neuen Standes
*/
export async function stelleAnfrage({ customerId, projectId, eingabe, person = {}, now = new Date() }) {
const project = await pruefeZugang({ customerId, projectId, person, neu: true })
const { anfrage, fehler } = normalisiereAnfrage(eingabe)
if (fehler) throw Object.assign(stop(400, Object.values(fehler)[0]), { fehler })
const gespeichert = await withLocks([`uk-anfrage:${projectId}`], async () => {
const feature = await ensureFeatureDoc(customerId, projectId, 'unterkunft')
if (feature.state !== 'off') throw stop(409, MELDUNG.vorhanden, 'vorhanden')
return withLocks([`uk-einst:${feature.$id}`], async () => {
const einst = await ladeEinstellungen(feature.$id)
if (!['', 'abgelehnt', 'zurueckgezogen'].includes(einst?.anfrageStatus || '')) throw stop(409, MELDUNG.laeuft, 'anfrage_laeuft')
return speichereEinstellungen(feature.$id, {
anfrage,
anfrageStatus: 'offen',
anfrageAt: now.toISOString(),
anfrageEntschiedenAt: null,
anfrageEntschiedenVon: '',
anfrageBegruendung: '',
})
})
})
await meldeTeam({ customerId, projectId, project, anfrage, person })
return anfrageAnsicht(gespeichert)
}
/** T23 "Neue Anfrage Buchungskalender" ans Team; sendAdminNotice wirft nie, ein Fehlschlag steht nur im Log. */
async function meldeTeam({ customerId, projectId, project, anfrage, person }) {
try {
let kundeName = String(person?.name || '').trim()
if (!kundeName) kundeName = String((await customerOf(customerId))?.name || '').trim()
const result = await _versand.admin({
template: 'T23',
values: {
kunde_name: kundeName || 'Kunde',
email: person?.email || '',
projekt: project?.projectName || projectId,
// die Vorlage T23 kennt nur kunde_name, email, projekt, nachricht, woms_url: die Angaben stehen zusammen in der Nachricht
nachricht: [
`Art: ${ART_NAMEN[anfrage.art]}`,
`Einheiten: ${anfrage.einheiten}`,
`Portale: ${anfrage.portale.length ? anfrage.portale.map((p) => PORTAL_NAMEN[p]).join(', ') : 'keine'}`,
...(anfrage.nachricht ? ['', anfrage.nachricht] : []),
].join('\n'),
woms_url: `${config.womsBaseUrl}/customers/${encodeURIComponent(customerId)}?tab=funktionen`,
},
})
if (!result?.sent) console.warn(`[unterkunft] Team-Mail T23 nicht gesendet: ${result?.reason || 'unbekannt'}`)
} catch (err) {
console.warn(`[unterkunft] Team-Mail T23 nicht gesendet: ${err?.message}`)
}
}
/** Offene Anfrage zurueckziehen (Status `zurueckgezogen`); aus jedem anderen Zustand 409. @returns {Promise<object>} anfrageAnsicht */
export async function zieheZurueck({ customerId, projectId, person = {} }) {
await pruefeZugang({ customerId, projectId, person, neu: false })
const gespeichert = await withLocks([`uk-anfrage:${projectId}`], async () => {
const feature = await getFeatureDoc(customerId, projectId, 'unterkunft')
if (!feature) throw stop(409, MELDUNG.nichtOffen, 'nicht_offen')
return withLocks([`uk-einst:${feature.$id}`], async () => {
const einst = await ladeEinstellungen(feature.$id)
if (!einst || einst.anfrageStatus !== 'offen') throw stop(409, MELDUNG.nichtOffen, 'nicht_offen')
return speichereEinstellungen(feature.$id, { anfrageStatus: 'zurueckgezogen' })
})
})
return anfrageAnsicht(gespeichert)
}
/** 'Name <email>' (wie createdBy der Angebote); ohne Name die Adresse, ohne beides 'Admin'. */
const personLabel = (person) => (person?.name ? `${person.name}${person.email ? ` <${person.email}>` : ''}` : person?.email || 'Admin')
/**
* Das Team entscheidet eine offene Anfrage: annehmen oder ablehnen (Begruendung Pflicht, 1-1000 Zeichen). Unter der Sperre je
* Projekt; das Projekt muss dem Kunden gehoeren (403), der Status muss `offen` sein (409). Danach geht K34 an den Kunden; scheitert
* die Mail, bleibt die Entscheidung bestehen, wird geloggt und die Antwort traegt `mailFehler: true`.
* @returns {Promise<{ anfrage: object, mailFehler: boolean }>} anfrageAnsicht des neuen Standes
*/
export async function entscheideAnfrage({ customerId, projectId, entscheidung, begruendung, person = {}, now = new Date() }) {
if (entscheidung !== 'annehmen' && entscheidung !== 'ablehnen') throw stop(400, MELDUNG.entscheidung)
let grund = ''
if (entscheidung === 'ablehnen') {
grund = typeof begruendung === 'string' ? begruendung.toWellFormed().trim() : ''
if (!grund || grund.length > BEGRUENDUNG_MAX) throw stop(400, MELDUNG.begruendung)
}
if (typeof projectId !== 'string' || !projectId) throw stop(400, MELDUNG.projekt)
const { gespeichert, project } = await withLocks([`uk-anfrage:${projectId}`], async () => {
let besitz
try {
besitz = await assertOwnProject(customerId, projectId)
} catch (err) {
throw err?.status === 400 && err.type ? stop(403, 'Kein Zugriff auf dieses Projekt') : err
}
const feature = await getFeatureDoc(customerId, projectId, 'unterkunft')
if (!feature || feature.state !== 'off') throw stop(409, MELDUNG.nichtEntscheidbar, 'nicht_offen')
const neu = await withLocks([`uk-einst:${feature.$id}`], async () => {
const einst = await ladeEinstellungen(feature.$id)
if (!einst || einst.anfrageStatus !== 'offen') throw stop(409, MELDUNG.nichtEntscheidbar, 'nicht_offen')
return speichereEinstellungen(feature.$id, {
anfrageStatus: entscheidung === 'annehmen' ? 'angenommen' : 'abgelehnt',
anfrageEntschiedenAt: now.toISOString(),
anfrageEntschiedenVon: personLabel(person).slice(0, 120),
anfrageBegruendung: grund,
})
})
return { gespeichert: neu, project: besitz }
})
const mailFehler = !(await sendeEntscheidung({ customerId, projectId, project, angenommen: entscheidung === 'annehmen', begruendung: grund }))
return { anfrage: anfrageAnsicht(gespeichert), mailFehler }
}
/** K34 an den Kunden; true = gesendet. Jeder Fehler wird nur geloggt (die Entscheidung ist gespeichert). */
async function sendeEntscheidung({ customerId, projectId, project, angenommen, begruendung }) {
try {
const kunde = await customerOf(customerId)
if (!kunde?.email) {
console.warn(`[unterkunft] K34 nicht gesendet: Kunde ${customerId} ohne E-Mail-Adresse`)
return false
}
await _versand.kunde(kunde.email, {
template: 'K34',
values: {
kunde_name: String(kunde.name || '').trim() || 'Kunde',
projekt: project?.projectName || projectId,
preis: config.features.unterkunft.priceLabel,
ust_hinweis: config.ustHinweis,
begruendung,
portal_url: `${config.publicBaseUrl}/dashboard.html#buchungen?projekt=${encodeURIComponent(projectId)}`,
},
blocks: { angenommen, abgelehnt: !angenommen },
})
return true
} catch (err) {
console.warn(`[unterkunft] K34 nicht gesendet: ${err?.message}`)
return false
}
}
+372
View File
@@ -0,0 +1,372 @@
import { config } from '../../config.js'
import { deleteDocument, listAllDocuments, listDocuments, Query, updateDocument, getDocument } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { heute } from '../termine/zeit.js'
import { aktualisiereAnfrage, ladeAnfrage } from './speicher.js'
import { leereCache } from './kontext.js'
/**
* Buchungskalender: Aufbewahrung und Loeschfristen (AVV Nr. 9, bindend):
* - ipHash: spaetestens config.unterkunft.ipHashTage (30) Tage nach Anlage der Anfrage weg (bei Freigabe/Ablehnung schon in ablauf.js).
* - Status `neu` ohne Entscheidung nach neuVerfallTage (60) Tagen: `abgelaufen`, ohne Mail.
* - Anfragen, die nie eine Buchung wurden (abgelehnt, abgelaufen, storniert ohne Rechnung): unverbindlichMonate (6) nach Anlage
* anonymisiert (gast {}, nachrichtVermieter '', rechnungsadresse null, ipHash '', anonymisiertAt).
* - Gebuchte Aufenthalte (gebucht, bezahlt, storniert mit Rechnung): Gastdaten 3 Jahre nach Ende des Kalenderjahres der Abreise (Berlin)
* anonymisiert; rechnungsadresse und ukRechnungen bleiben (der Rechnungs-Schnappschuss ist der Beleg).
* - ukRechnungen: 8 Jahre nach Ende des Kalenderjahres des Rechnungsdatums geloescht; zugleich wird die rechnungsadresse der Anfrage
* anonymisiert (sobald keine Rechnung der Anfrage mehr uebrig ist).
* - Abgeschaltete Funktion: 90 Tage nach disabledAt werden alle uk*-Daten der Funktion geloescht (Marker in portalFeatures.adminNote).
* Je Lauf hoechstens `deckel` (500) Schreib-/Loeschvorgaenge; idempotent; Logs ohne Personendaten (nur Zaehler, IDs, Fehlername).
* Leichtgewichtig gehalten (retention.js importiert dieses Modul): keine Mail-, Stripe- oder Ablauf-Importe.
*/
const TAG_MS = 24 * 60 * 60 * 1000
const STANDARD_DECKEL = 500
const SEITE = 100
const ANONYM_JAHRE = () => config.unterkunft.anonymisierenJahre ?? 3
const RECHNUNG_JAHRE = () => config.unterkunft.rechnungJahre ?? 8
const MARKER = 'Buchungsdaten gelöscht am '
const MARKER_RE = /Buchungsdaten gelöscht am (\d{4}-\d{2}-\d{2})/
const ANFRAGE_ANONYM = { gast: {}, nachrichtVermieter: '', ipHash: '', tokenHash: '' } // tokenHash leer: der Buchungslink ist mit der Anonymisierung tot
const zeitMs = (v) => {
const t = Date.parse(v)
return Number.isNaN(t) ? NaN : t
}
const jahrVon = (tag) => Number(String(tag).slice(0, 4))
const gueltigerTag = (tag) => typeof tag === 'string' && /^\d{4}-\d{2}-\d{2}/.test(tag)
/** Nur Name, Status und Code: die Meldung kann Personendaten enthalten und kommt nie ins Log. */
const fehlerText = (err) => [err?.name, err?.status, err?.code ?? err?.type].filter((x) => x !== undefined && x !== null && x !== '').join(' ')
const sammlungFehlt = (err) => err?.type === 'collection_not_found' || err?.code === 'collection_not_found'
/** now minus n Kalendermonate (UTC, Tag wird auf den Monatsletzten gekuerzt). */
function minusMonate(ms, n) {
const d = new Date(ms)
const ziel = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - n, 1, d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()))
const letzter = new Date(Date.UTC(ziel.getUTCFullYear(), ziel.getUTCMonth() + 1, 0)).getUTCDate()
ziel.setUTCDate(Math.min(d.getUTCDate(), letzter))
return ziel.getTime()
}
const nowMsVon = (now) => {
const ms = now instanceof Date ? now.getTime() : Number(now)
if (!Number.isFinite(ms)) throw new TypeError('aufbewahrung: now fehlt')
return ms
}
/** Liest seitenweise (nach $id, 100 je Seite, nur die genannten Felder) und sammelt Treffer von `passt`, hoechstens max. */
async function sammle(sammlung, filter, felder, passt, max) {
const treffer = []
if (max <= 0) return { treffer, gedeckelt: true }
let cursor = null
for (;;) {
const seite = await listDocuments(sammlung, [
...filter,
Query.select(['$id', ...felder]),
Query.orderAsc('$id'),
Query.limit(SEITE),
...(cursor ? [Query.cursorAfter(cursor)] : []),
])
for (const doc of seite) {
if (!passt(doc)) continue
if (treffer.length >= max) return { treffer, gedeckelt: true }
treffer.push(doc)
}
if (seite.length < SEITE) return { treffer, gedeckelt: false }
cursor = seite[seite.length - 1].$id
}
}
/** Anfragen mit Rechnung: Menge der anfrageIds aller ukRechnungen (nur ein Feld gelesen). */
async function anfragenMitRechnung() {
const alle = await listAllDocuments(config.collections.ukRechnungen, [Query.select(['$id', 'anfrageId'])])
return new Set(alle.map((r) => r.anfrageId).filter(Boolean))
}
/**
* Tagesarbeit der Aufbewahrung.
* @param {Date} now
* @param {{ deckel?: number }} [opt] hoechstens so viele Schreib-/Loeschvorgaenge (Standard 500)
* @returns {Promise<{ verbraucht: number (genutzter Deckel), anfragen: number, gaeste: number, ipHashes: number, abgelaufen: number, rechnungen: number, adressen: number, fehler: number, gedeckelt: boolean }>}
* anfragen = unverbindliche Anfragen anonymisiert, gaeste = Gastdaten gebuchter Aufenthalte anonymisiert
*/
export async function anonymisiere(now, { deckel = config.unterkunft.aufbewahrungDeckel ?? STANDARD_DECKEL } = {}) {
const nowMs = nowMsVon(now)
const erg = { anfragen: 0, gaeste: 0, ipHashes: 0, abgelaufen: 0, rechnungen: 0, adressen: 0, fehler: 0, gedeckelt: false, verbraucht: 0 }
let rest = Number.isInteger(deckel) && deckel > 0 ? deckel : STANDARD_DECKEL
const start = rest
const jetztIso = new Date(nowMs).toISOString()
const AN = config.collections.ukAnfragen
const c = config.unterkunft
const bearbeite = async (name, { treffer, gedeckelt }, arbeit) => {
if (gedeckelt) erg.gedeckelt = true
for (const doc of treffer) {
if (rest <= 0) {
erg.gedeckelt = true
return
}
try {
if (await arbeit(doc)) rest -= 1
} catch (err) {
erg.fehler += 1
console.warn(`[unterkunft-aufbewahrung] ${name} ${doc.$id} fehlgeschlagen: ${fehlerText(err)}`)
}
}
}
const schritt = async (name, arbeit) => {
try {
await arbeit()
} catch (err) {
erg.fehler += 1
console.warn(`[unterkunft-aufbewahrung] ${name} fehlgeschlagen: ${fehlerText(err)}`)
}
}
// 1. ipHash leeren (30 Tage nach Anlage)
await schritt('ipHash', async () => {
const grenze = nowMs - c.ipHashTage * TAG_MS
const gefunden = await sammle(AN, [Query.lessThan('$createdAt', new Date(grenze).toISOString())], ['ipHash', '$createdAt'], (d) => d.ipHash && zeitMs(d.$createdAt) < grenze, rest)
await bearbeite('ipHash', gefunden, async (d) => {
await aktualisiereAnfrage(d.$id, { ipHash: '' })
erg.ipHashes += 1
return true
})
})
// 2. neu, nach neuVerfallTage nicht entschieden: abgelaufen (ohne Mail)
await schritt('neu verfallen', async () => {
const grenze = nowMs - c.neuVerfallTage * TAG_MS
const gefunden = await sammle(AN, [Query.equal('status', 'neu'), Query.lessThan('$createdAt', new Date(grenze).toISOString())], ['status', 'unterkunftId', '$createdAt'], (d) => zeitMs(d.$createdAt) < grenze, rest)
await bearbeite('neu verfallen', gefunden, async (d) => {
const neu = await withLocks(['unterkunft:' + d.unterkunftId], async () => { // derselbe Schluessel wie ablauf.js (gibFrei/lehneAb/Fristen)
const a = await ladeAnfrage(d.$id)
if (!a || a.status !== 'neu') return null
return aktualisiereAnfrage(a.$id, { status: 'abgelaufen', abgelaufenAt: jetztIso, ipHash: '' })
})
if (!neu) return false
erg.abgelaufen += 1
return true
})
})
// 3. + 4. Anonymisieren
await schritt('anonymisieren', async () => {
const mitRechnung = await anfragenMitRechnung()
const hatRechnung = (d) => Boolean(d.rechnungId) || mitRechnung.has(d.$id)
const unverbindlichGrenze = minusMonate(nowMs, c.unverbindlichMonate)
const gebuchtJahr = jahrVon(heute(new Date(nowMs)))
const unv = await sammle(
AN,
[Query.equal('status', ['abgelehnt', 'abgelaufen', 'storniert']), Query.lessThan('$createdAt', new Date(unverbindlichGrenze).toISOString())],
['status', 'rechnungId', 'anonymisiertAt', '$createdAt'],
(d) => !d.anonymisiertAt && zeitMs(d.$createdAt) < unverbindlichGrenze && !(d.status === 'storniert' && hatRechnung(d)),
rest,
)
await bearbeite('anonymisieren', unv, async (d) => {
await aktualisiereAnfrage(d.$id, { ...ANFRAGE_ANONYM, rechnungsadresse: null, anonymisiertAt: jetztIso })
erg.anfragen += 1
return true
})
const gebucht = await sammle(
AN,
[Query.equal('status', ['gebucht', 'bezahlt', 'storniert'])],
['status', 'rechnungId', 'anonymisiertAt', 'bis'],
// Abreisejahr Y: faellig, sobald das Berliner Jahr >= Y + 3 + 1 ist (3 Jahre nach Ende des Jahres Y)
(d) => !d.anonymisiertAt && gueltigerTag(d.bis) && gebuchtJahr >= jahrVon(d.bis) + ANONYM_JAHRE() + 1 && (d.status !== 'storniert' || hatRechnung(d)),
rest,
)
await bearbeite('anonymisieren', gebucht, async (d) => {
// ohne Rechnung gibt es keinen Beleg, der die Rechnungsadresse schuetzt: sie geht mit den Gastdaten
await aktualisiereAnfrage(d.$id, { ...ANFRAGE_ANONYM, ...(hatRechnung(d) ? {} : { rechnungsadresse: null }), anonymisiertAt: jetztIso })
erg.gaeste += 1
return true
})
})
// 5. Rechnungen nach 8 Jahren loeschen, vorher die Rechnungsadresse der Anfrage anonymisieren
await schritt('rechnungen', async () => {
const jahr = jahrVon(heute(new Date(nowMs)))
const RE = config.collections.ukRechnungen
const faellig = (r) => gueltigerTag(r.datum) && jahr >= jahrVon(r.datum) + RECHNUNG_JAHRE() + 1
const gefunden = await sammle(RE, [], ['anfrageId', 'datum'], faellig, rest)
if (gefunden.gedeckelt) erg.gedeckelt = true
const erledigt = new Set()
for (const r of gefunden.treffer) {
if (rest <= 0) {
erg.gedeckelt = true
return
}
try {
// Adresse erst, wenn keine nicht faellige Rechnung der Anfrage uebrig ist (sonst braucht die Anfrage sie noch)
if (r.anfrageId && !erledigt.has(r.anfrageId)) {
const alle = await listAllDocuments(RE, [Query.equal('anfrageId', r.anfrageId), Query.select(['$id', 'datum'])])
if (alle.every(faellig)) {
const a = await ladeAnfrage(r.anfrageId)
if (a && (a.rechnungsadresse !== null || !a.anonymisiertAt)) {
await aktualisiereAnfrage(a.$id, { rechnungsadresse: null, ...(a.anonymisiertAt ? {} : { ...ANFRAGE_ANONYM, anonymisiertAt: jetztIso }) })
erg.adressen += 1
rest -= 1
}
erledigt.add(r.anfrageId)
}
}
try {
await deleteDocument(RE, r.$id)
} catch (err) {
if (err?.status !== 404) throw err
}
erg.rechnungen += 1
rest -= 1
} catch (err) {
erg.fehler += 1
console.warn(`[unterkunft-aufbewahrung] rechnungen ${r.$id} fehlgeschlagen: ${fehlerText(err)}`)
}
}
})
erg.verbraucht = start - rest
return erg
}
const GRUPPEN = ['ukAnfragen', 'ukRechnungen', 'ukSperren', 'ukPreise', 'ukUnterkuenfte']
/**
* Alle uk*-Daten EINER Funktion (featureId) loeschen: Anfragen, Rechnungen, Sperren, Preise, Unterkuenfte und zuletzt die Einstellungen
* (ukEinstellungen, Dokument-ID = featureId). Seitenweise (immer die erste Seite neu), am Deckel endet der Lauf (vollstaendig: false).
* @returns {Promise<{ geloescht: number, vollstaendig: boolean, abgebrochen: boolean }>}
*/
export async function loescheUkDaten({ featureId, deckel = Number.POSITIVE_INFINITY, nochGueltig = async () => true } = {}) {
if (typeof featureId !== 'string' || featureId === '') throw new TypeError('loescheUkDaten: featureId fehlt')
const erg = { geloescht: 0, vollstaendig: false, abgebrochen: false }
for (const name of GRUPPEN) {
const sammlung = config.collections[name]
for (;;) {
if (!(await nochGueltig())) return { ...erg, abgebrochen: true }
const rest = deckel - erg.geloescht
if (rest <= 0) return erg
let seite
try {
seite = await listDocuments(sammlung, [Query.equal('featureId', featureId), Query.select(['$id']), Query.limit(Math.min(SEITE, rest))])
} catch (err) {
if (sammlungFehlt(err)) break
throw err
}
if (seite.length === 0) break
for (const doc of seite) {
try {
await deleteDocument(sammlung, doc.$id)
} catch (err) {
if (err?.status !== 404) throw err
}
erg.geloescht += 1
}
}
}
if (!(await nochGueltig())) return { ...erg, abgebrochen: true }
try {
await deleteDocument(config.collections.ukEinstellungen, featureId)
erg.geloescht += 1
} catch (err) {
if (err?.status !== 404 && !sammlungFehlt(err)) throw err
}
erg.vollstaendig = true
return erg
}
/** Hat die Funktion (featureId) noch uk*-Daten? (fuer die Kundenloeschung in retention.js; fehlende Collections zaehlen als leer) */
export async function hatUkDaten(featureId) {
for (const name of ['ukUnterkuenfte', 'ukAnfragen', 'ukRechnungen', 'ukSperren', 'ukPreise', 'ukEinstellungen']) {
try {
const treffer = await listDocuments(config.collections[name], [Query.equal('featureId', featureId), Query.select(['$id']), Query.limit(1)])
if (treffer.length) return true
} catch (err) {
if (!sammlungFehlt(err)) throw err
}
}
return false
}
/** Marker an die bisherige Notiz haengen (eine alte Marke wird ersetzt, der Rest bleibt; hoechstens 300 Zeichen, der Marker bleibt ganz). */
function markiere(notiz, tag) {
const marker = `${MARKER}${tag}`
const alt = String(notiz ?? '').replace(/\s*(?:[-–|]\s*)?Buchungsdaten gelöscht am \d{4}-\d{2}-\d{2}/g, '').trim()
if (!alt) return marker
return `${alt.slice(0, Math.max(0, 300 - marker.length - 3))} – ${marker}`
}
/**
* Abgeschaltete Buchungskalender (portalFeatures.state disabled, disabledAt aelter als loeschTageNachAbschalten = 90 Tage): alle uk*-Daten
* loeschen, danach Marker in adminNote (wie beim Shop; ein spaeteres Abschalten setzt disabledAt neu und gilt wieder).
* @returns {Promise<{ funktionen: number, dokumente: number, fehler: number, gedeckelt: boolean }>}
*/
export async function loescheAbgeschaltete(now, { deckel = STANDARD_DECKEL } = {}) {
const nowMs = nowMsVon(now)
const tage = config.unterkunft.loeschTageNachAbschalten ?? 90
const grenze = nowMs - tage * TAG_MS
const FEATURES = config.collections.portalFeatures
const erg = { funktionen: 0, dokumente: 0, fehler: 0, gedeckelt: false }
let rest = deckel
const funktionen = await listAllDocuments(FEATURES, [Query.equal('featureKey', 'unterkunft'), Query.equal('state', 'disabled')])
for (const f of funktionen) {
if (!(zeitMs(f.disabledAt) < grenze)) continue
const marker = MARKER_RE.exec(String(f.adminNote ?? ''))
if (marker && marker[1] >= String(f.disabledAt).slice(0, 10)) continue
if (rest <= 0) {
erg.gedeckelt = true
return erg
}
try {
// vor jeder Seite frisch lesen: das Team kann die Funktion zwischenzeitlich wieder einschalten
const nochAus = async () => {
const frisch = await getDocument(FEATURES, f.$id)
return frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze
}
const weg = await loescheUkDaten({ featureId: f.$id, deckel: rest, nochGueltig: nochAus })
rest -= weg.geloescht
erg.dokumente += weg.geloescht
if (f.siteKey) leereCache(f.siteKey)
if (weg.vollstaendig) {
erg.funktionen += 1
const frisch = await getDocument(FEATURES, f.$id)
if (frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze) {
await updateDocument(FEATURES, f.$id, { adminNote: markiere(frisch.adminNote, heute(new Date(nowMs))) })
}
} else if (!weg.abgebrochen) {
erg.gedeckelt = true
}
} catch (err) {
erg.fehler += 1
console.warn(`[unterkunft-aufbewahrung] Funktion ${f.$id} nicht vollständig gelöscht: ${fehlerText(err)}`)
}
}
return erg
}
/**
* Kundenloeschung (customerAdmin.js): schliesst die Buchungskalender des Kunden fuer neue Anfragen (alle Unterkuenfte aktiv=false) und
* leert den Speicher der oeffentlichen Schnittstelle. Die Funktion selbst schaltet disableCustomerFeatures ab; die Daten loescht
* loescheAbgeschaltete 90 Tage nach dem Abschalten. Scheitert eines, werden die uebrigen trotzdem geschlossen, der erste Fehler wird weitergereicht.
* @returns {Promise<number>} Zahl der geschlossenen Unterkuenfte
*/
export async function schliesseUkVonKunde(customerId) {
const funktionen = await listAllDocuments(config.collections.portalFeatures, [Query.equal('customerId', customerId), Query.equal('featureKey', 'unterkunft')])
let geschlossen = 0
let erster = null
for (const f of funktionen) {
try {
const unterkuenfte = await listAllDocuments(config.collections.ukUnterkuenfte, [Query.equal('featureId', f.$id)])
for (const u of unterkuenfte) {
if (u.aktiv !== false) await updateDocument(config.collections.ukUnterkuenfte, u.$id, { aktiv: false })
geschlossen += 1
}
} catch (err) {
if (!sammlungFehlt(err)) erster ||= err
}
if (f.siteKey) leereCache(f.siteKey)
}
if (erster) throw erster
return geschlossen
}
+40
View File
@@ -0,0 +1,40 @@
import { listeAnfragenNachStatus, listeSperren } from './speicher.js'
/** Status, die die Unterkunft endgueltig belegen. 'freigegeben' blockiert nur bis fristAt. */
export const AKTIVE_STATUS = ['gebucht', 'bezahlt']
/** Halboffene Intervalle [von,bis) auf Tagesstrings: Abreisetag gleich Anreisetag ueberschneidet nicht. */
export const ueberschneidet = (a, b) => a.von < b.bis && b.von < a.bis
/**
* Belegte Bereiche einer Unterkunft im Fenster [von,bis), auf das Fenster zugeschnitten, nach von sortiert.
* Quellen: manuell, ical (Sperre mit quelle 'ical:<id>'), gebucht (gebucht|bezahlt), reserviert (freigegeben mit fristAt in der Zukunft).
* ohneAnfrage: ID einer Anfrage, die nicht zaehlt (z. B. beim Freigeben der Anfrage selbst). now: Date oder ms.
*/
export async function belegteBereiche(unterkunftId, von, bis, { ohneAnfrage, now = Date.now() } = {}) {
const jetzt = now instanceof Date ? now.getTime() : now
const fenster = { von, bis }
const roh = []
// nur was nach dem Fensteranfang endet (Index auf bis): alles andere kann das Fenster nicht beruehren
for (const s of await listeSperren(unterkunftId, { bisNach: von })) {
roh.push({ von: s.von, bis: s.bis, quelle: String(s.quelle ?? '').startsWith('ical') ? 'ical' : 'manuell' })
}
for (const a of await listeAnfragenNachStatus(unterkunftId, [...AKTIVE_STATUS, 'freigegeben'], { bisNach: von })) {
if (ohneAnfrage && a.$id === ohneAnfrage) continue
if (a.status === 'freigegeben') {
const frist = Date.parse(a.fristAt)
if (!(frist > jetzt)) continue
roh.push({ von: a.von, bis: a.bis, quelle: 'reserviert' })
} else {
roh.push({ von: a.von, bis: a.bis, quelle: 'gebucht' })
}
}
return roh
.filter((r) => r.von && r.bis && ueberschneidet(r, fenster))
.map((r) => ({ von: r.von > von ? r.von : von, bis: r.bis < bis ? r.bis : bis, quelle: r.quelle }))
.sort((a, b) => (a.von < b.von ? -1 : a.von > b.von ? 1 : 0))
}
export async function istFrei(unterkunftId, von, bis, opts = {}) {
return (await belegteBereiche(unterkunftId, von, bis, opts)).length === 0
}
+39
View File
@@ -0,0 +1,39 @@
// Datumshilfen fuer Buchungskalender: reine Funktionen auf Tagesstrings (YYYY-MM-DD), UTC-Arithmetik, keine I/O.
const TAG_RE = /^\d{4}-\d{2}-\d{2}$/
const TAG_MS = 86400000
function zuMs(tag) {
const [j, m, t] = tag.split('-').map(Number)
return Date.UTC(j, m - 1, t)
}
/** Gueltiger Kalendertag im Format YYYY-MM-DD. */
export function istTag(s) {
if (typeof s !== 'string' || !TAG_RE.test(s)) return false
const d = new Date(zuMs(s))
return d.toISOString().slice(0, 10) === s
}
/** Anzahl Naechte zwischen Anreise und Abreise. */
export function naechte(von, bis) {
return Math.round((zuMs(bis) - zuMs(von)) / TAG_MS)
}
/** Tag um n Tage verschoben. */
export function plusTage(tag, n) {
return new Date(zuMs(tag) + n * TAG_MS).toISOString().slice(0, 10)
}
/** Belegte Naechte: von inklusive, bis exklusive. */
export function tageVon(von, bis) {
const n = naechte(von, bis)
const out = []
for (let i = 0; i < n; i++) out.push(plusTage(von, i))
return out
}
/** '2026-11-12' wird '12.11.2026'. */
export function formatTag(tag) {
const [j, m, t] = tag.split('-')
return `${t}.${m}.${j}`
}
+10
View File
@@ -0,0 +1,10 @@
/** Fachfehler des Buchungskalenders: status = HTTP-Status, code = maschinenlesbar, felder = Feldfehler fuer die Oberflaeche. */
export class UkFehler extends Error {
constructor(status, code, felder = {}) {
super(code)
this.name = 'UkFehler'
this.status = status
this.code = code
this.felder = felder
}
}
@@ -0,0 +1,33 @@
import { preiseVollstaendig } from './preis.js'
import { ladeEinstellungen, ladePreise, listeUnterkuenfte } from './speicher.js'
/**
* freischaltbar-Haken des Buchungskalenders (featureRegistry, Zustand 'setup', Ticket geschlossen): darf die Funktion jetzt aktiv
* werden? Ja, wenn der Kunde alles hinterlegt hat, was Rechnungen und Buchungen brauchen:
* - Einstellungen: firma, strasse, plz, ort, email und (steuernummer oder ustId), dazu rechnungAuftragAt (Zustimmung zur
* Rechnungsstellung im Namen des Vermieters);
* - mindestens eine aktive Unterkunft mit vollstaendigen Preisen (preiseVollstaendig) und Stornobedingungen.
* Fail-closed: bei jedem Lesefehler false.
* @param {{ $id: string }} doc portalFeatures-Dokument
* @returns {Promise<boolean>}
*/
export async function freischaltbar(doc) {
try {
const featureId = doc?.$id
if (typeof featureId !== 'string' || !featureId) return false
const einst = await ladeEinstellungen(featureId)
if (!einst) return false
const text = (wert) => typeof wert === 'string' && wert.trim() !== ''
if (!['firma', 'strasse', 'plz', 'ort', 'email'].every((k) => text(einst[k]))) return false
if (!text(einst.steuernummer) && !text(einst.ustId)) return false
if (!einst.rechnungAuftragAt) return false
const unterkuenfte = await listeUnterkuenfte(featureId, { nurAktive: true })
for (const u of unterkuenfte) {
const preise = await ladePreise(u.$id)
if (preiseVollstaendig(preise) && text(preise.stornobedingungen)) return true
}
return false
} catch {
return false
}
}
+161
View File
@@ -0,0 +1,161 @@
import { heute as berlinHeute } from '../termine/zeit.js'
import { istTag, naechte, plusTage } from './datum.js'
import { config } from '../../config.js'
/**
* Buchungskalender: iCalendar lesen und schreiben (reine Funktionen, keine I/O). Gelesen werden nur VEVENT mit Anfangs- und
* Enddatum (DATE, lokale Zeit, TZID oder UTC); mehr braucht die Belegung nicht. Ergebnis sind Berliner Kalendertage [von, bis)
* wie bei allen Sperren. Wiederholungsregeln (RRULE) werden nicht aufgeloest: Booking.com und Airbnb liefern Einzeltermine.
*/
const MAX_EREIGNISSE = 2000
const MAX_NAECHTE = 731
/** Zeilenumbrueche entfalten: eine Zeile, die mit Leerzeichen oder Tab beginnt, setzt die vorige fort. */
function entfalte(text) {
const out = []
for (const z of text.split(/\r\n|\n|\r/)) {
if ((z.startsWith(' ') || z.startsWith('\t')) && out.length) out[out.length - 1] += z.slice(1)
else out.push(z)
}
return out
}
/** 'NAME;PARAM=x:Wert' => { name (gross), params, wert }. Doppelpunkte in Anfuehrungszeichen der Parameter zaehlen nicht. */
function zeile(z) {
let inAnf = false
let doppelpunkt = -1
for (let i = 0; i < z.length; i++) {
const c = z[i]
if (c === '"') inAnf = !inAnf
else if (c === ':' && !inAnf) {
doppelpunkt = i
break
}
}
if (doppelpunkt < 1) return null
const kopf = z.slice(0, doppelpunkt).split(';')
const params = {}
for (const p of kopf.slice(1)) {
const i = p.indexOf('=')
if (i > 0) params[p.slice(0, i).toUpperCase()] = p.slice(i + 1).replace(/^"|"$/g, '')
}
return { name: kopf[0].trim().toUpperCase(), params, wert: z.slice(doppelpunkt + 1).trim() }
}
const DATUM_RE = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?(Z)?)?$/
/** iCal-Datum oder -Zeitpunkt => Berliner Kalendertag oder null. UTC (Z) wird nach Berlin umgerechnet, alles andere gilt als lokal. */
function tagVon(wert) {
const m = DATUM_RE.exec(wert)
if (!m) return null
const [, j, mo, t, h, mi, s, z] = m
if (z && h !== undefined) {
const d = new Date(Date.UTC(+j, +mo - 1, +t, +h, +mi, +(s ?? 0)))
return Number.isNaN(d.getTime()) ? null : berlinHeute(d)
}
const tag = `${j}-${mo}-${t}`
return istTag(tag) ? tag : null
}
function dauerTage(wert) {
const m = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T.*)?$/.exec(wert)
if (!m) return null
const tage = (m[1] ? +m[1] * 7 : 0) + (m[2] ? +m[2] : 0)
return tage > 0 ? tage : null
}
/**
* ICS-Text lesen. Ereignisse mit STATUS:CANCELLED, ohne lesbaren Anfang, die vor gestern enden oder nach dem Horizont beginnen
* fallen weg. Fehlt das Ende, ist es eine Nacht. Kein VCALENDAR (z. B. HTML-Fehlerseite) => Error('kein_ics').
* @param {string} text
* @param {{ heute?: string, horizontTage?: number }} [opt] heute: Berliner Tag (Vorgabe: jetzt); horizontTage: Vorgabe 31 x horizontMonate
*/
export function parseIcs(text, { heute = berlinHeute(), horizontTage = config.unterkunft.horizontMonate * 31 } = {}) {
if (typeof text !== 'string' || !/^\s*BEGIN:VCALENDAR/i.test(text.replace(/^\uFEFF/, ''))) throw new Error('kein_ics')
const gestern = plusTage(heute, -1)
const horizont = plusTage(heute, horizontTage)
const ereignisse = []
const gesehen = new Set()
let ev = null
let tiefe = 0
let nr = 0
for (const roh of entfalte(text.replace(/^\uFEFF/, ''))) {
const p = zeile(roh)
if (!p) continue
if (p.name === 'BEGIN') {
if (p.wert.toUpperCase() === 'VEVENT') {
ev = { uid: '', start: null, ende: null, dauer: null, status: '' }
tiefe = 0
} else if (ev) tiefe++
continue
}
if (p.name === 'END') {
if (p.wert.toUpperCase() === 'VEVENT' && ev) {
const e = ev
ev = null
nr++
if (e.status === 'CANCELLED' || !e.start) continue
let bis = e.ende
if (!bis && e.dauer) bis = plusTage(e.start, e.dauer)
if (!bis || bis <= e.start) bis = plusTage(e.start, 1)
if (naechte(e.start, bis) > MAX_NAECHTE) bis = plusTage(e.start, MAX_NAECHTE)
if (bis < gestern || e.start > horizont) continue
let uid = e.uid || `ohne-uid-${nr}`
if (gesehen.has(uid)) uid = `${uid}#${nr}`
gesehen.add(uid)
if (ereignisse.length < MAX_EREIGNISSE) ereignisse.push({ von: e.start, bis, uid })
} else if (ev && tiefe > 0) tiefe--
continue
}
if (!ev || tiefe > 0) continue
if (p.name === 'UID') ev.uid = p.wert.slice(0, 200)
else if (p.name === 'STATUS') ev.status = p.wert.toUpperCase()
else if (p.name === 'DTSTART') ev.start = tagVon(p.wert)
else if (p.name === 'DTEND') ev.ende = tagVon(p.wert)
else if (p.name === 'DURATION') ev.dauer = dauerTage(p.wert)
}
return { ereignisse }
}
// ---- Schreiben
/** Zeile auf hoechstens 75 Oktette falten (Fortsetzungszeilen beginnen mit einem Leerzeichen); schneidet nie mitten in ein Zeichen. */
function falte(z) {
const teile = []
let aktuell = ''
let bytes = 0
let limit = 75
for (const zeichen of z) {
const b = Buffer.byteLength(zeichen)
if (bytes + b > limit) {
teile.push(aktuell)
aktuell = ' '
bytes = 1
limit = 75
}
aktuell += zeichen
bytes += b
}
teile.push(aktuell)
return teile
}
const maskiere = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n')
const ohneTrenner = (tag) => tag.replace(/-/g, '')
const zeitstempel = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
/**
* Kalender mit ganztaegigen Ereignissen "Belegt" bauen (CRLF, gefaltet). Absichtlich keine Gastdaten.
* @param {{ name: string, ereignisse: {uid: string, von: string, bis: string}[], jetzt?: Date }} eingabe
*/
export function baueIcs({ name, ereignisse, jetzt = new Date() }) {
const stempel = zeitstempel(jetzt)
const z = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//WEBklar//Buchungskalender//DE', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH']
if (name) z.push(`X-WR-CALNAME:${maskiere(name)}`)
for (const e of ereignisse) {
z.push('BEGIN:VEVENT', `UID:${e.uid}`, `DTSTAMP:${stempel}`, `DTSTART;VALUE=DATE:${ohneTrenner(e.von)}`, `DTEND;VALUE=DATE:${ohneTrenner(e.bis)}`, 'SUMMARY:Belegt', 'TRANSP:OPAQUE', 'END:VEVENT')
}
z.push('END:VCALENDAR')
return z.flatMap(falte).join('\r\n') + '\r\n'
}
+367
View File
@@ -0,0 +1,367 @@
import dns from 'node:dns/promises'
import https from 'node:https'
import net from 'node:net'
import { Readable } from 'node:stream'
import { config } from '../../config.js'
import { listAllDocuments, Query } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { heute as berlinHeute } from '../termine/zeit.js'
import { parseIcs } from './ical.js'
import { ladeFuerProjekt } from './kontext.js'
import * as mails from './mails.js'
import { unterkunftFuerKunde, unterkunftModus } from './schalter.js'
import { ladeEinstellungen, ladeUnterkunft, legeSperreAn, listeSperren, listeUnterkuenfte, loescheSperre, speichereEinstellungen, speichereUnterkunft } from './speicher.js'
/**
* Buchungskalender: iCal-Import. holeIcs ruft eine fremde Adresse ab (Booking.com, Airbnb, ...) und ist deshalb gegen SSRF
* gehaertet: nur https auf Port 443, keine Zugangsdaten, der Host wird selbst aufgeloest, jede Adresse muss oeffentlich sein,
* und der Abruf verbindet sich genau mit der geprueften Adresse (kein zweites DNS, also kein Rebinding). Weiterleitungen
* (hoechstens 3) werden jeweils neu geprueft. Fehler tragen einen Code (err.code) und nie die Adresse: Booking- und
* Airbnb-Adressen enthalten ein Geheimnis, deshalb wird nirgends eine volle Adresse protokolliert (nur der Host).
*/
const MAX_WEITERLEITUNGEN = 3
const TAG_MS = 24 * 3600 * 1000
const MAX_IMPORTE_JE_LAUF = 200
class IcalFehler extends Error {
constructor(code) {
super(code)
this.code = code
}
}
// ---- Adresspruefung
function v4Privat(a) {
const [o1, o2, o3] = a.split('.').map(Number)
return (
o1 === 0 || // "dieses Netz" inkl. 0.0.0.0
o1 === 10 ||
(o1 === 100 && o2 >= 64 && o2 <= 127) || // CGNAT
o1 === 127 ||
(o1 === 169 && o2 === 254) || // Link-Local, Cloud-Metadaten
(o1 === 172 && o2 >= 16 && o2 <= 31) ||
(o1 === 192 && o2 === 0 && o3 === 0) ||
(o1 === 192 && o2 === 0 && o3 === 2) ||
(o1 === 192 && o2 === 168) ||
(o1 === 198 && (o2 === 18 || o2 === 19)) ||
(o1 === 198 && o2 === 51 && o3 === 100) ||
(o1 === 203 && o2 === 0 && o3 === 113) ||
o1 >= 224 // Multicast, reserviert, Broadcast
)
}
function v6Gruppen(adresse) {
let a = adresse.toLowerCase().split('%')[0]
const m = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(a)
if (m) {
const b = m[2].split('.').map(Number)
a = `${m[1]}${((b[0] << 8) | b[1]).toString(16)}:${((b[2] << 8) | b[3]).toString(16)}`
}
const teile = a.split('::')
if (teile.length > 2) return null
const kopf = teile[0] ? teile[0].split(':') : []
const rest = teile.length === 2 && teile[1] ? teile[1].split(':') : []
let g
if (teile.length === 2) {
const luecke = 8 - kopf.length - rest.length
if (luecke < 1) return null
g = [...kopf, ...Array(luecke).fill('0'), ...rest]
} else g = kopf
if (g.length !== 8 || g.some((x) => !/^[0-9a-f]{1,4}$/.test(x))) return null
return g.map((x) => parseInt(x, 16))
}
const v4Aus = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`
function v6Privat(adresse) {
const g = v6Gruppen(adresse)
if (!g) return true
const nullBis = (n) => g.slice(0, n).every((x) => x === 0)
if (nullBis(5) && g[5] === 0xffff) return v4Privat(v4Aus(g[6], g[7])) // IPv4-mapped
if (nullBis(6)) return true // ::, ::1, IPv4-kompatibel
if (g[0] === 0x64 && g[1] === 0xff9b) return true // NAT64
if (g[0] === 0x2002) return v4Privat(v4Aus(g[1], g[2])) // 6to4
if (g[0] === 0x2001 && g[1] === 0) return true // Teredo
if (g[0] === 0x2001 && g[1] === 0xdb8) return true // Dokumentation
if (g[0] === 0x100 && g[1] === 0 && g[2] === 0 && g[3] === 0) return true // Discard
return (g[0] & 0xfe00) === 0xfc00 || (g[0] & 0xffc0) === 0xfe80 || (g[0] & 0xffc0) === 0xfec0 || (g[0] & 0xff00) === 0xff00
}
/** true fuer alles, was nicht eindeutig eine oeffentliche IPv4-/IPv6-Adresse ist. */
export function istPrivateAdresse(adresse) {
if (net.isIPv4(adresse)) return v4Privat(adresse)
if (net.isIPv6(adresse)) return v6Privat(adresse)
return true
}
// ---- Abruf
/** Standard-Abruf: https.request mit fester Adresse (lookup-Override), SNI und Zertifikatspruefung gegen den Hostnamen. */
function festerAbruf(url, { adresse, signal }) {
return new Promise((resolve, reject) => {
const u = new URL(url)
const host = u.hostname.replace(/^\[|\]$/g, '')
const req = https.request(
{
hostname: host,
port: 443,
path: `${u.pathname}${u.search}`,
method: 'GET',
headers: { 'user-agent': 'WEBklar-Buchungskalender/1.0', accept: 'text/calendar, text/plain;q=0.9, */*;q=0.1', 'accept-encoding': 'identity' },
lookup: (_h, opts, cb) => (opts?.all ? cb(null, [{ address: adresse.address, family: adresse.family }]) : cb(null, adresse.address, adresse.family)),
signal,
},
(res) => {
const headers = new Headers()
for (const [k, v] of Object.entries(res.headers)) if (v !== undefined) headers.set(k, Array.isArray(v) ? v.join(', ') : v)
const ohneKoerper = [204, 205, 304].includes(res.statusCode)
if (ohneKoerper) res.resume()
resolve(new Response(ohneKoerper ? null : Readable.toWeb(res), { status: res.statusCode, headers }))
},
)
req.on('error', reject)
req.end()
})
}
async function pruefeZiel(url, lookup) {
let u
try {
u = new URL(url)
} catch {
throw new IcalFehler('ungueltige_adresse')
}
if (u.protocol !== 'https:' || !u.hostname || u.username || u.password || (u.port && u.port !== '443')) throw new IcalFehler('ungueltige_adresse')
const host = u.hostname.replace(/^\[|\]$/g, '')
let adressen
if (net.isIP(host)) adressen = [{ address: host, family: net.isIPv6(host) ? 6 : 4 }]
else {
try {
adressen = await lookup(host, { all: true })
} catch {
throw new IcalFehler('netzwerk')
}
}
if (!adressen?.length) throw new IcalFehler('netzwerk')
if (adressen.some((a) => istPrivateAdresse(a.address))) throw new IcalFehler('private_adresse')
return { adresse: adressen[0] }
}
async function liesBegrenzt(res, max) {
const laenge = Number(res.headers.get('content-length'))
if (Number.isFinite(laenge) && laenge > max) {
await res.body?.cancel().catch(() => {})
throw new IcalFehler('zu_gross')
}
if (!res.body) return ''
const reader = res.body.getReader()
const teile = []
let summe = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
summe += value.byteLength
if (summe > max) {
await reader.cancel().catch(() => {})
throw new IcalFehler('zu_gross')
}
teile.push(value)
}
return Buffer.concat(teile).toString('utf8')
}
/**
* ICS-Text von einer Adresse holen. Fehler: Error mit code ungueltige_adresse | private_adresse | netzwerk | timeout | zu_gross |
* zu_viele_weiterleitungen | http_<status>.
* @param {string} url
* @param {{ fetch?: Function, lookup?: Function }} [opt] fetch(url, { adresse, signal, redirect: 'manual' }) => Response; lookup(host, { all: true }) (Tests)
*/
export async function holeIcs(url, { fetch: abruf = festerAbruf, lookup = dns.lookup } = {}) {
const ac = new AbortController()
const timer = setTimeout(() => ac.abort(), config.unterkunft.icalTimeoutMs)
try {
let aktuell = url
for (let sprung = 0; ; sprung++) {
const { adresse } = await pruefeZiel(aktuell, lookup)
const res = await abruf(aktuell, { adresse, signal: ac.signal, redirect: 'manual' })
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
await res.body?.cancel().catch(() => {})
if (sprung >= MAX_WEITERLEITUNGEN) throw new IcalFehler('zu_viele_weiterleitungen')
try {
aktuell = new URL(res.headers.get('location'), aktuell).href
} catch {
throw new IcalFehler('ungueltige_adresse')
}
continue
}
if (res.status !== 200) {
await res.body?.cancel().catch(() => {})
throw new IcalFehler(`http_${res.status}`)
}
return await liesBegrenzt(res, config.unterkunft.icalMaxBytes)
}
} catch (err) {
if (err instanceof IcalFehler) throw err
throw new IcalFehler(ac.signal.aborted ? 'timeout' : 'netzwerk')
} finally {
clearTimeout(timer)
}
}
// ---- Abgleich
const ms = (now) => (now instanceof Date ? now.getTime() : Number(now))
const iso = (t) => new Date(t).toISOString()
const lockKey = (id) => ['unterkunft:' + id]
const hostVon = (url) => {
try {
return new URL(url).hostname
} catch {
return '?'
}
}
/** '07.11.2026, 06:00' in Berliner Zeit. */
function berlinZeit(t) {
const p = Object.fromEntries(
new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
.formatToParts(new Date(t))
.map((x) => [x.type, x.value]),
)
return `${p.day}.${p.month}.${p.year}, ${p.hour}:${p.minute}`
}
async function ersetzeEintrag(unterkunft, importId, aenderung) {
const u = await ladeUnterkunft(unterkunft.$id)
const liste = Array.isArray(u?.icalImporte) ? u.icalImporte : []
const i = liste.findIndex((x) => x.id === importId)
if (i < 0) return null
const neu = aenderung(liste[i])
const kopie = [...liste]
kopie[i] = neu
await speichereUnterkunft(u.featureId, { icalImporte: kopie }, u.$id)
return neu
}
/** Fehlermail K33, wenn der Import seit icalFehlerStunden nicht mehr gelang, hoechstens einmal je 24 h und Funktion. Wirft nie. */
async function pruefeFehlerMail(unterkunft, eintrag, jetzt, ctxVorgabe) {
try {
const seit = Date.parse(eintrag.letzterErfolgAt) || Date.parse(eintrag.fehlerSeit)
if (!Number.isFinite(seit) || jetzt - seit < config.unterkunft.icalFehlerStunden * 3600e3) return
const einst = await ladeEinstellungen(unterkunft.featureId)
const letzte = Date.parse(einst?.icalFehlerMailAt)
if (Number.isFinite(letzte) && jetzt - letzte < TAG_MS) return
const ctx = ctxVorgabe ?? (await ladeFuerProjekt(unterkunft.customerId, unterkunft.projectId)) ?? { featureId: unterkunft.featureId, customerId: unterkunft.customerId, projectId: unterkunft.projectId, einstellungen: einst ?? {} }
await mails.anVermieter('K33', ctx, { unterkunft: unterkunft.name, quelle: eintrag.name, seit: berlinZeit(seit) })
await speichereEinstellungen(unterkunft.featureId, { icalFehlerMailAt: iso(jetzt) })
} catch (err) {
console.warn(`[unterkunft-ical] Fehlermail fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
}
}
/**
* Einen Import abgleichen: Feed holen, lesen, die Sperren 'ical:<importId>' angleichen (fehlende zuerst anlegen, dann
* ueberzaehlige loeschen) und den Eintrag in icalImporte nachfuehren. Bei einem Fehler bleiben die alten Sperren stehen.
* @returns {Promise<{ok: true, anzahl: number} | {ok: false, fehler: string}>}
*/
export async function gleicheAb(unterkunft, importEintrag, { now = new Date(), fetch, lookup, ctx } = {}) {
const jetzt = ms(now)
const importId = importEintrag.id
const quelle = `ical:${importId}`
let ereignisse = null
let code = null
try {
const text = await holeIcs(importEintrag.url, { fetch, lookup })
ereignisse = parseIcs(text, { heute: berlinHeute(new Date(jetzt)) }).ereignisse
} catch (err) {
code = err?.code || (err?.message === 'kein_ics' ? 'kein_ics' : 'netzwerk')
}
const ergebnis = await withLocks(lockKey(unterkunft.$id), async () => {
const frisch = await ladeUnterkunft(unterkunft.$id)
const aktuellerEintrag = (Array.isArray(frisch?.icalImporte) ? frisch.icalImporte : []).find((x) => x.id === importId)
if (!aktuellerEintrag) return { ok: false, fehler: 'import_entfernt' }
if (code) {
const neu = await ersetzeEintrag(frisch, importId, (e) => ({ ...e, letzterAbrufAt: iso(jetzt), fehler: code, fehlerSeit: e.fehlerSeit || iso(jetzt) }))
return { ok: false, fehler: code, eintrag: neu }
}
const alt = (await listeSperren(frisch.$id)).filter((s) => s.quelle === quelle)
const behalten = new Set()
const vorhanden = new Map()
const ueberzaehlig = []
for (const s of alt) {
const k = `${s.von}|${s.bis}`
if (vorhanden.has(k)) ueberzaehlig.push(s)
else vorhanden.set(k, s)
}
const wuensche = new Map()
for (const e of ereignisse) wuensche.set(`${e.von}|${e.bis}`, e)
for (const [k, e] of wuensche) {
if (vorhanden.has(k)) behalten.add(k)
else await legeSperreAn({ unterkunftId: frisch.$id, featureId: frisch.featureId, von: e.von, bis: e.bis, quelle, notiz: String(importEintrag.name ?? '').slice(0, 200) })
}
for (const [k, s] of vorhanden) if (!behalten.has(k)) ueberzaehlig.push(s)
for (const s of ueberzaehlig) await loescheSperre(s.$id)
await ersetzeEintrag(frisch, importId, (e) => {
const { fehlerSeit, ...rest } = e
void fehlerSeit
return { ...rest, letzterAbrufAt: iso(jetzt), letzterErfolgAt: iso(jetzt), fehler: '' }
})
return { ok: true, anzahl: wuensche.size }
})
if (!ergebnis.ok && ergebnis.eintrag) {
await pruefeFehlerMail(unterkunft, ergebnis.eintrag, jetzt, ctx)
return { ok: false, fehler: ergebnis.fehler }
}
return ergebnis.ok ? { ok: true, anzahl: ergebnis.anzahl } : { ok: false, fehler: ergebnis.fehler }
}
/**
* Alle faelligen Importe aller aktiven Buchungskalender nacheinander abgleichen (Viertelstundenjob). Hoechstens maxImporte je Lauf;
* ein Fehler stoppt die anderen nicht. Protokoll nur mit Host, nie mit der vollen Adresse.
* @returns {Promise<{importe: number, erfolg: number, fehler: number}>}
*/
export async function gleicheAlleAb(now = new Date(), { fetch, lookup, maxImporte = MAX_IMPORTE_JE_LAUF } = {}) {
const jetzt = ms(now)
const zaehler = { importe: 0, erfolg: 0, fehler: 0 }
const faellig = (e) => {
const letzter = Date.parse(e.letzterAbrufAt)
return !Number.isFinite(letzter) || jetzt - letzter >= (config.unterkunft.icalIntervallMin - 1) * 60e3
}
const features = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'unterkunft'), Query.equal('state', 'active')])
for (const feature of features) {
if (zaehler.importe >= maxImporte) break
try {
if (unterkunftModus() === 'off') break
let ctx
if (unterkunftModus() === 'pilot') {
ctx = await ladeFuerProjekt(feature.customerId, feature.projectId)
if (!ctx || !unterkunftFuerKunde(ctx.besitzerEmail)) continue
}
for (const u of await listeUnterkuenfte(feature.$id, { nurAktive: true })) {
for (const eintrag of Array.isArray(u.icalImporte) ? u.icalImporte : []) {
if (zaehler.importe >= maxImporte) break
if (!eintrag?.url || !faellig(eintrag)) continue
zaehler.importe++
try {
const r = await gleicheAb(u, eintrag, { now, fetch, lookup, ctx })
if (r.ok) zaehler.erfolg++
else {
zaehler.fehler++
console.warn(`[unterkunft-ical] Import von ${hostVon(eintrag.url)} fehlgeschlagen: ${r.fehler}`)
}
} catch (err) {
zaehler.fehler++
console.warn(`[unterkunft-ical] Import von ${hostVon(eintrag.url)} fehlgeschlagen: ${err?.name}`)
}
}
}
} catch (err) {
console.warn(`[unterkunft-ical] Funktion ${feature.$id} uebersprungen: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
}
}
return zaehler
}
+88
View File
@@ -0,0 +1,88 @@
import { laufeFristenAb } from './ablauf.js'
import { config } from '../../config.js'
import { listDocuments, Query } from '../appwriteAdmin.js'
import { sendeHinweiseVorLoeschung } from './abschaltHinweis.js'
import { anonymisiere, loescheAbgeschaltete } from './aufbewahrung.js'
import { gleicheAlleAb } from './icalAbgleich.js'
import { unterkunftModus } from './schalter.js'
/**
* Jobs des Buchungskalenders (Registrierung in server/index.js: Minuten 80 s Start / 60 s, Viertelstunden 140 s / 15 min,
* taeglich 210 s / 24 h). Jeder Job laeuft hoechstens einmal gleichzeitig (ein Lauf, der noch haengt, ueberspringt den naechsten
* Takt), wirft nie und kehrt bei UNTERKUNFT_ENABLED=off sofort zurueck (der Tagesjob nur, solange es keine Buchungsdaten mehr gibt).
*/
const laufend = new Set()
async function einzeln(name, arbeit) {
if (laufend.has(name)) {
console.warn(`[unterkunft-jobs] ${name}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`)
return { uebersprungen: true }
}
laufend.add(name)
try {
return await arbeit()
} catch (err) {
console.error(`[unterkunft-jobs] ${name} fehlgeschlagen: ${err?.name} ${err?.status ?? ''}`)
return { fehler: true }
} finally {
laufend.delete(name)
}
}
/** Minutenjob: Fristen ablaufen lassen (Anfragen, Reservierungen). Bei UNTERKUNFT_ENABLED=off aus. */
export async function minutenJob(now = new Date()) {
if (unterkunftModus() === 'off') return { aus: true }
return einzeln('minutenJob', () => laufeFristenAb(now))
}
/** Viertelstundenjob: iCal-Importe abgleichen. Bei UNTERKUNFT_ENABLED=off aus. */
export async function viertelstundenJob(now = new Date()) {
if (unterkunftModus() === 'off') return { aus: true }
return einzeln('viertelstundenJob', () => gleicheAlleAb(now))
}
/** Gibt es irgendwelche Buchungsdaten? Die Fristen gelten auch bei UNTERKUNFT_ENABLED=off (fehlende Collections = nein). */
async function gibtDaten() {
for (const name of ['ukEinstellungen', 'ukAnfragen']) {
try {
if ((await listDocuments(config.collections[name], [Query.select(['$id']), Query.limit(1)])).length > 0) return true
} catch (err) {
if (err?.type !== 'collection_not_found' && err?.code !== 'collection_not_found') throw err
}
}
return false
}
/**
* Tagesjob: Aufbewahrung (ipHash, Verfall von `neu`, Anonymisierung, Rechnungen nach 8 Jahren) und Loeschung abgeschalteter Funktionen
* (90 Tage; davor einmal die Hinweis-Mail K35). Gemeinsamer Deckel config.unterkunft.aufbewahrungDeckel (500) je Lauf (die Loeschung bekommt, was die Aufbewahrung uebrig laesst). Bei UNTERKUNFT_ENABLED=off nur, wenn es noch
* Buchungsdaten gibt (die Fristen gelten auch dann). Ein Teil, der scheitert, stoppt den anderen nicht.
*/
export async function taeglicherJob(now = new Date()) {
return einzeln('taeglicherJob', async () => {
if (unterkunftModus() === 'off' && !(await gibtDaten())) return { aus: true }
const deckel = config.unterkunft.aufbewahrungDeckel ?? 500
let anon = { anfragen: 0, gaeste: 0, fehler: 1 }
let weg = { funktionen: 0, dokumente: 0, fehler: 1, gedeckelt: false }
let rest = deckel
try {
anon = await anonymisiere(now, { deckel })
rest = Math.max(0, deckel - (anon.verbraucht ?? 0))
} catch (err) {
console.error(`[unterkunft-jobs] taeglicherJob: Aufbewahrung fehlgeschlagen: ${err?.name} ${err?.status ?? ''}`)
}
// Vor der Loeschung: einmaliger Hinweis K35 (Rechnungen sichern); ein Fehler stoppt die Loeschung nicht (Marker fehlt, naechster Lauf wiederholt)
try {
await sendeHinweiseVorLoeschung(now)
} catch (err) {
console.error(`[unterkunft-jobs] taeglicherJob: Hinweis vor der Löschung fehlgeschlagen: ${err?.name} ${err?.status ?? ''}`)
}
try {
weg = await loescheAbgeschaltete(now, { deckel: rest })
} catch (err) {
console.error(`[unterkunft-jobs] taeglicherJob: Löschung abgeschalteter Funktionen fehlgeschlagen: ${err?.name} ${err?.status ?? ''}`)
}
return { aufbewahrung: anon, abgeschaltet: weg }
})
}
+123
View File
@@ -0,0 +1,123 @@
import { config } from '../../config.js'
import { getDocument, listDocuments, Query } from '../appwriteAdmin.js'
import { getFeatureDoc } from '../siteFeatures.js'
import { unterkunftFuerKunde, unterkunftModus } from './schalter.js'
import { ladeEinstellungen } from './speicher.js'
/**
* Kontext eines Buchungskalenders (Funktionsschluessel 'unterkunft').
*
* ctx = { featureId, customerId, projectId, siteKey, state, besitzerEmail, einstellungen, project }
* featureId ID des portalFeatures-Dokuments (zugleich ID der ukEinstellungen)
* state Zustand der Funktion ('active' fuer die oeffentliche Schnittstelle)
* besitzerEmail E-Mail des Kunden (leer, wenn der Kunde fehlt)
* einstellungen ukEinstellungen (geparst) oder null, solange der Kunde noch nichts gespeichert hat
* project websiteProjects-Dokument oder null
* Ein ctx aus unterkunftPerSiteKey ist nur zum Lesen: innerhalb von 30 Sekunden kommt dasselbe Objekt zurueck. Der Schalter
* (off | pilot | on) gilt bei jedem Aufruf, nicht erst nach Ablauf des Speichers.
*/
const FUNKTION = 'unterkunft'
export const SITE_KEY_RE = /^[a-z2-7]{16}$/
const CACHE_MS = 30 * 1000
const CACHE_MAX = 10000
const cache = new Map()
/** Wird mit jedem Leeren erhoeht: eine Abfrage, die davor begann, darf ihr Ergebnis nicht mehr merken. */
let generation = 0
/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt). */
const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found'
async function holeOderNull(collectionId, id) {
if (typeof id !== 'string' || id === '') return null
try {
return await getDocument(collectionId, id)
} catch (err) {
if (dokumentFehlt(err)) return null
throw err
}
}
async function bauKontext(doc) {
const [kunde, project, einstellungen] = await Promise.all([
holeOderNull(config.collections.customers, doc.customerId),
holeOderNull(config.collections.websiteProjects, doc.projectId),
ladeEinstellungen(doc.$id),
])
return {
featureId: doc.$id,
customerId: doc.customerId ?? '',
projectId: doc.projectId ?? '',
siteKey: doc.siteKey ?? '',
state: doc.state ?? '',
besitzerEmail: String(kunde?.email ?? ''),
einstellungen,
project,
}
}
/**
* Kontext fuer die oeffentliche Schnittstelle: Funktion 'unterkunft' mit diesem siteKey im Zustand active. Modus off => null;
* pilot => nur, wenn die Besitzer-E-Mail in der Pilotliste steht. Treffer und Fehlschlaege werden 30 Sekunden je siteKey gemerkt;
* leereCache leert den Speicher.
* @param {string} siteKey
* @param {{ now?: Date }} [opt] now: Zeitpunkt fuer den Speicher (Tests)
* @returns {Promise<object|null>} null bei ungueltigem oder unbekanntem siteKey, anderem Zustand als active, Modus off und
* Besitzer ausserhalb der Pilotliste
*/
export async function unterkunftPerSiteKey(siteKey, { now = new Date() } = {}) {
if (typeof siteKey !== 'string' || !SITE_KEY_RE.test(siteKey)) return null
const modus = unterkunftModus()
if (modus === 'off') return null
const nowMs = now instanceof Date ? now.getTime() : Number(now)
if (!Number.isFinite(nowMs)) throw new TypeError('unterkunftPerSiteKey: now ist kein gueltiger Zeitpunkt')
let ctx
const gemerkt = cache.get(siteKey)
if (gemerkt && gemerkt.bis > nowMs) {
ctx = gemerkt.ctx
} else {
const stand = generation
const [doc] = await listDocuments(config.collections.portalFeatures, [
Query.equal('siteKey', siteKey),
Query.equal('featureKey', FUNKTION),
Query.equal('state', 'active'),
Query.limit(1),
])
ctx = doc && doc.state === 'active' ? await bauKontext(doc) : null
if (stand === generation) merke(siteKey, ctx, nowMs)
}
if (!ctx) return null
if (modus === 'pilot' && !unterkunftFuerKunde(ctx.besitzerEmail)) return null
return ctx
}
/**
* Kontext fuer das Portal: Funktion des Projekts in jedem Zustand ausser off (bestellt, Einrichtung, aktiv, abgeschaltet).
* Immer frisch gelesen (kein Speicher). null, wenn das Projekt nicht dem Kunden gehoert, es keine Funktion gibt oder sie off ist.
* @returns {Promise<object|null>}
*/
export async function ladeFuerProjekt(customerId, projectId) {
const project = await holeOderNull(config.collections.websiteProjects, projectId)
if (!project || !customerId || project.customerId !== customerId) return null
const doc = await getFeatureDoc(customerId, projectId, FUNKTION)
if (!doc || doc.state === 'off') return null
const ctx = await bauKontext(doc)
return { ...ctx, project }
}
function merke(siteKey, ctx, nowMs) {
if (cache.size >= CACHE_MAX) {
for (const [key, eintrag] of cache) if (eintrag.bis <= nowMs) cache.delete(key)
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value)
}
cache.set(siteKey, { bis: nowMs + CACHE_MS, ctx })
}
/** Leert den Speicher von unterkunftPerSiteKey: fuer einen siteKey oder (ohne Angabe) ganz. Laufende Abfragen merken ihr Ergebnis nicht mehr. */
export function leereCache(siteKey) {
generation++
if (siteKey === undefined) cache.clear()
else cache.delete(siteKey)
}
+192
View File
@@ -0,0 +1,192 @@
import { config } from '../../config.js'
import { loadTemplate } from '../mailTemplates.js'
import { customerOf } from '../siteFeatures.js'
import { sendAdminNotice, sendCustomerMail } from '../mailer.js'
import { einreihen } from '../termine/outbox.js'
import { formatTag } from './datum.js'
import { formatEuro } from './preis.js'
import { ladeEinstellungen, ladePreise, ladeUnterkunft } from './speicher.js'
/**
* Mails des Buchungskalenders: G16-G22 an Gaeste (ueber die Mail-Warteschlange termineOutbox, Absendername = Firma des Vermieters,
* Antworten an dessen E-Mail), K33, K34, K36 und K37 an den Vermieter (Kunde des Portals, ohne BCC bei Gastdaten) und T23 ans Team.
* `_aufrufe` protokolliert jeden Aufruf fuer Tests (die Mail geht trotzdem raus). Fehler werden geworfen; der Aufrufer
* (ablauf.js) faengt sie ab, weil der gespeicherte Schritt nicht rueckgaengig gemacht wird.
*
* extra je Gastmail: G17 token (Roh-Token, nur hier und nur einmal bekannt; ergibt den Buchungslink), text (G18 Begruendung),
* rechnung (G19: ukRechnungen-Dokument), stornoRechnung + erstattetCent + grund (G22), G21 braucht nichts.
*/
export const _aufrufe = []
/** Protokoll nur im Test (UK_TESTPROTOKOLL, setzt useTestEnv); nie Token, Werte oder Gastdaten. */
const testProtokoll = () => process.env.NODE_ENV === 'test' || process.env.UK_TESTPROTOKOLL === '1'
const LEER = '–'
const text = (v) => (typeof v === 'string' ? v.trim() : '')
const GAST_ARTEN = ['G16', 'G17', 'G18', 'G19', 'G20', 'G21', 'G22']
const mehrzahl = (n, eins, viele) => `${n} ${n === 1 ? eins : viele}`
const FRIST_FORMAT = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
function fristText(wert) {
const t = Date.parse(wert)
if (!Number.isFinite(t)) return LEER
const teile = Object.fromEntries(FRIST_FORMAT.formatToParts(new Date(t)).map((p) => [p.type, p.value]))
return `${teile.day}.${teile.month}.${teile.year}, ${teile.hour}:${teile.minute} Uhr`
}
function anbieterKontakt(e) {
return [text(e.firma), text(e.strasse), [text(e.plz), text(e.ort)].filter(Boolean).join(' '), text(e.email), text(e.telefon)].filter(Boolean).join('\n')
}
function postenText(posten) {
if (!Array.isArray(posten) || posten.length === 0) return LEER
return posten.map((p) => `${p.text}: ${formatEuro(p.menge * p.einzelCent)}`).join('\n')
}
const ZAHLART = { online: 'Online-Zahlung', vorort: 'Zahlung vor Ort' }
/** Einstellungen des Vermieters: aus dem Kontext, sonst aus der Datenbank. */
async function einstellungenVon(ctx, featureId) {
return ctx?.einstellungen ?? (featureId ? await ladeEinstellungen(featureId) : null) ?? {}
}
/** Gemeinsame Werte aller Mails zu einer Anfrage. */
async function werteZuAnfrage(ctx, anfrage) {
const e = await einstellungenVon(ctx, anfrage.featureId ?? ctx?.featureId)
const u = anfrage.unterkunftId ? await ladeUnterkunft(anfrage.unterkunftId) : null
const n = Number(anfrage.naechte) || naechteVon(anfrage)
return {
einstellungen: e,
unterkunft: u,
werte: {
gast_name: text(anfrage.gast?.name) || 'Gast',
firma: text(e.firma) || text(u?.name) || 'Ihr Gastgeber',
farbe: /^#[0-9a-fA-F]{6}$/.test(text(e.akzentfarbe)) ? e.akzentfarbe : '#111111',
anbieter_kontakt: anbieterKontakt(e) || LEER,
unterkunft: text(u?.name) || LEER,
zeitraum: `${formatTag(anfrage.von)} – ${formatTag(anfrage.bis)}`,
naechte: mehrzahl(n, 'Nacht', 'Nächte'),
personen: mehrzahl(Number(anfrage.personen) || 0, 'Person', 'Personen'),
nummer: text(anfrage.nummer) || LEER,
},
}
}
function naechteVon(a) {
return Math.max(0, Math.round((Date.parse(a.bis) - Date.parse(a.von)) / 86400000))
}
async function stornoText(anfrage) {
// Schnappschuss bei der Freigabe gilt; nur ohne ihn die aktuellen Bedingungen
if (text(anfrage.stornobedingungenText)) return text(anfrage.stornobedingungenText)
const preise = anfrage.unterkunftId ? await ladePreise(anfrage.unterkunftId) : null
return text(preise?.stornobedingungen) || 'Es wurden keine besonderen Stornobedingungen vereinbart.'
}
export async function anGast(art, ctx, anfrage, extra = {}) {
if (testProtokoll()) _aufrufe.push({ an: 'gast', art, anfrageId: anfrage?.$id, hatToken: Boolean(extra?.token), grund: extra?.grund })
if (!GAST_ARTEN.includes(art)) throw new TypeError(`${String(art).slice(0, 20)} ist keine Gastmail`)
extra = extra ?? {}
const gast = anfrage?.gast ?? {}
if (art === 'G17' && (!text(extra.token) || !text(ctx?.siteKey))) throw new TypeError('Token oder siteKey für den Buchungslink fehlt')
if (!text(gast.email)) throw new TypeError('Empfänger fehlt')
const { einstellungen: e, werte } = await werteZuAnfrage(ctx, anfrage)
const values = { ...werte }
const blocks = {}
let anhang = null
if (art !== 'G16' && art !== 'G18' && art !== 'G21' && art !== 'G20' && art !== 'G22') values.stornobedingungen = await stornoText(anfrage)
if (art === 'G17' || art === 'G19' || art === 'G20') values.gesamt = formatEuro(anfrage.summen?.bruttoCent ?? 0)
if (art === 'G17' || art === 'G19') values.posten_text = postenText(anfrage.posten)
if (art === 'G17') {
values.frist = fristText(anfrage.fristAt)
const token = text(extra.token)
values.buchungs_link = `${config.publicBaseUrl}/unterkunft/${ctx?.siteKey ?? ''}/buchung/${anfrage.$id}?t=${token}`
values.nachricht_vermieter = text(anfrage.nachrichtVermieter)
blocks.nachricht = values.nachricht_vermieter !== ''
}
if (art === 'G18') {
values.nachricht_vermieter = text(extra.text)
blocks.nachricht = values.nachricht_vermieter !== ''
}
if (art === 'G19') {
const zahlart = anfrage.zahlart
values.zahlart_text = ZAHLART[zahlart] ?? LEER
values.rechnung_nummer = text(extra.rechnung?.nummer) || LEER
blocks.online_unbezahlt = zahlart === 'online' && anfrage.status !== 'bezahlt'
if (extra.rechnung?.$id) anhang = { art: 'ukRechnung', rechnungId: extra.rechnung.$id }
}
if (art === 'G20') {
values.rechnung_nummer = text(extra.rechnung?.nummer) || text(anfrage.rechnungNummer) || LEER
}
if (art === 'G21') values.frist = fristText(anfrage.fristAt)
if (art === 'G22') {
values.rechnung_nummer = text(extra.stornoRechnung?.nummer) || LEER
values.nachricht_vermieter = text(extra.grund)
blocks.nachricht = values.nachricht_vermieter !== ''
const erstattung = Number(extra.erstattetCent) || 0
values.erstattung = erstattung > 0 ? formatEuro(erstattung) : ''
blocks.erstattung = erstattung > 0
if (extra.stornoRechnung?.$id) anhang = { art: 'ukRechnung', rechnungId: extra.stornoRechnung.$id }
}
return einreihen({
featureId: anfrage.featureId ?? ctx?.featureId,
bookingId: anfrage.$id,
art: `uk-${art}`,
an: text(gast.email),
fromName: values.firma.slice(0, 120),
replyTo: text(e.email) || null,
template: art,
values,
blocks,
...(anhang ? { anhang: JSON.stringify(anhang) } : {}),
})
}
const portalUrl = (ctx) => `${config.publicBaseUrl}/dashboard.html#buchungen?projekt=${encodeURIComponent(ctx?.projectId ?? '')}`
/**
* Mail an den Vermieter (K33, K34, K36 und K37, Kunde des Portals) bzw. ans Team (T23). K36/K37: values.anfrage; die Werte lassen sich ueberschreiben,
* values.an ersetzt die Adresse (Standard ctx.besitzerEmail). T23 geht an die Team-Adressen und wirft nie.
*/
export async function anVermieter(template, ctx, values = {}) {
if (testProtokoll()) _aufrufe.push({ an: 'vermieter', template, anfrageId: values?.anfrage?.$id })
const { anfrage, an, ...eigene } = values ?? {}
const vorgabe = {}
if (anfrage) {
const { werte } = await werteZuAnfrage(ctx, anfrage)
Object.assign(vorgabe, werte, {
kunde_name: text(ctx?.kundeName) || (await kundenName(ctx)) || werte.firma,
gast_email: text(anfrage.gast?.email) || LEER,
nachricht_gast: text(anfrage.gast?.nachricht) || LEER,
gesamt: formatEuro(anfrage.summen?.bruttoCent ?? 0),
zahlart_text: ZAHLART[anfrage.zahlart] ?? LEER,
})
}
if (!anfrage) vorgabe.kunde_name = text(ctx?.kundeName) || (await kundenName(ctx))
const alle = { portal_url: portalUrl(ctx), ...vorgabe, ...eigene }
if (template === 'T23') {
const r = await sendAdminNotice({ template, values: nurPlatzhalter(template, alle) })
if (!r?.sent) console.warn(`[unterkunft] Team-Mail ${template} nicht gesendet: ${r?.reason || 'unbekannt'}`)
return r
}
const empfaenger = text(an) || text(ctx?.besitzerEmail)
if (!empfaenger) throw new TypeError('Empfänger fehlt')
return sendCustomerMail(empfaenger, { template, values: nurPlatzhalter(template, alle), blocks: eigene.blocks ?? {}, bccTeam: ['K33', 'K34'].includes(template) })
}
/** Nur die Werte, die die Vorlage kennt (keine Warnung wegen Zusatzwerten wie farbe oder anbieter_kontakt). */
function nurPlatzhalter(template, werte) {
const kennt = loadTemplate(template).placeholders
return Object.fromEntries(Object.entries(werte).filter(([k]) => kennt.includes(k) && k !== 'blocks'))
}
/** Name des Kunden (Vermieters) aus dem Kundendokument; Fehler beim Laden machen die Mail nicht kaputt. */
async function kundenName(ctx) {
try {
return text((await customerOf(ctx?.customerId))?.name)
} catch {
return ''
}
}
+169
View File
@@ -0,0 +1,169 @@
// Minimaler PDF-Writer (Buchungskalender N2): Helvetica und Helvetica-Bold, WinAnsiEncoding, keine Abhaengigkeit.
// Koordinaten: Punkt, Ursprung oben links (y waechst nach unten). Ausgabe ist deterministisch.
// Helvetica-AFM-Breiten (1/1000 em) fuer die WinAnsi-Codes 32..255 (0 = nicht definiert, wird als '?' geschrieben).
const W_NORMAL = [
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0,
556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667,
278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333,
400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611,
667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278,
556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500,
]
const W_FETT = [
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611,
975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556,
333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611,
611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0,
556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667,
278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333,
400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611,
722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278,
611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556,
]
// CP1252-Zeichen oberhalb von U+007F im Bereich 0x80..0x9F.
const CP1252 = new Map(Object.entries({
'€': 0x80, '‚': 0x82, 'ƒ': 0x83, '„': 0x84, '…': 0x85, '†': 0x86, '‡': 0x87, 'ˆ': 0x88, '‰': 0x89, 'Š': 0x8a,
'‹': 0x8b, 'Œ': 0x8c, 'Ž': 0x8e, '‘': 0x91, '’': 0x92, '“': 0x93, '”': 0x94, '•': 0x95, '–': 0x96, '—': 0x97,
'˜': 0x98, '™': 0x99, 'š': 0x9a, '›': 0x9b, 'œ': 0x9c, 'ž': 0x9e, 'Ÿ': 0x9f,
}))
/** Ein Zeichen als WinAnsi-Code; Unbekanntes wird '?'. */
function code(ch) {
if (ch === '−') return 0x2d
const c = ch.codePointAt(0)
if (c === 0x0a || c === 0x0d || c === 0x09) return 0x20
if (c >= 32 && c < 127) return c
if (c >= 160 && c <= 255) return c
return CP1252.get(ch) ?? 0x3f
}
const bytes = (str) => Array.from(String(str ?? ''), code)
const zahl = (n) => String(Math.round(n * 100) / 100)
export class PdfDoc {
constructor({ breite = 595.28, hoehe = 841.89 } = {}) {
this.b = breite
this.h = hoehe
this.seiten = []
this.aktuell = null
this.seite()
}
/** Neue Seite beginnen (und zur aktuellen machen). */
seite() {
this.seiten.push([])
this.aktuell = this.seiten.length - 1
return this.aktuell
}
/** Auf eine bestehende Seite zurueckschalten (z. B. fuer Fusszeilen). */
aufSeite(n) {
if (!(n >= 0 && n < this.seiten.length)) throw new RangeError('Seite existiert nicht')
this.aktuell = n
}
get seitenzahl() {
return this.seiten.length
}
breite(str, groesse = 10, fett = false) {
const w = fett ? W_FETT : W_NORMAL
let sum = 0
for (const c of bytes(str)) sum += w[c - 32] || w[0x3f - 32]
return (sum * groesse) / 1000
}
/** Text; y = Grundlinie von oben, x = linker Rand (bei rechts: rechter Rand). */
text(x, y, str, { groesse = 10, fett = false, rechts = false } = {}) {
const bs = bytes(str)
if (bs.length === 0) return
const px = rechts ? x - this.breite(str, groesse, fett) : x
let s = ''
for (const c of bs) s += c === 0x5c || c === 0x28 || c === 0x29 ? `\\${String.fromCharCode(c)}` : String.fromCharCode(c)
this.seiten[this.aktuell].push(`BT /${fett ? 'F2' : 'F1'} ${zahl(groesse)} Tf ${zahl(px)} ${zahl(this.h - y)} Td (${s}) Tj ET`)
}
linie(x1, y1, x2, y2, { dicke = 0.5 } = {}) {
this.seiten[this.aktuell].push(`${zahl(dicke)} w ${zahl(x1)} ${zahl(this.h - y1)} m ${zahl(x2)} ${zahl(this.h - y2)} l S`)
}
/** Bricht an Leerzeichen um; ueberlange Woerter werden hart getrennt. */
umbrechen(str, maxBreite, groesse = 10, fett = false) {
const zeilen = []
for (const absatz of String(str ?? '').split(/\r?\n/)) {
let zeile = ''
for (let wort of absatz.split(/ +/).filter(Boolean)) {
const probe = zeile ? `${zeile} ${wort}` : wort
if (this.breite(probe, groesse, fett) <= maxBreite) {
zeile = probe
continue
}
if (zeile) zeilen.push(zeile)
zeile = ''
while (this.breite(wort, groesse, fett) > maxBreite && wort.length > 1) {
let n = wort.length - 1
while (n > 1 && this.breite(wort.slice(0, n), groesse, fett) > maxBreite) n--
zeilen.push(wort.slice(0, n))
wort = wort.slice(n)
}
zeile = wort
}
zeilen.push(zeile)
}
return zeilen
}
toBuffer() {
const teile = []
const offs = []
let len = 0
const add = (buf) => {
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf, 'latin1')
teile.push(b)
len += b.length
}
const obj = (nr, inhalt) => {
offs[nr] = len
add(`${nr} 0 obj\n`)
add(inhalt)
add('\nendobj\n')
}
const n = this.seiten.length
add('%PDF-1.4\n%\xe2\xe3\xcf\xd3\n')
obj(1, '<< /Type /Catalog /Pages 2 0 R >>')
const kids = this.seiten.map((_, i) => `${5 + i * 2} 0 R`).join(' ')
obj(2, `<< /Type /Pages /Kids [${kids}] /Count ${n} >>`)
obj(3, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>')
obj(4, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>')
this.seiten.forEach((ops, i) => {
const pn = 5 + i * 2
obj(pn, `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${zahl(this.b)} ${zahl(this.h)}] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${pn + 1} 0 R >>`)
const strom = Buffer.from(ops.join('\n') + '\n', 'latin1')
offs[pn + 1] = len
add(`${pn + 1} 0 obj\n<< /Length ${strom.length} >>\nstream\n`)
add(strom)
add('endstream\nendobj\n')
})
const total = 5 + n * 2
const xref = len
add(`xref\n0 ${total}\n0000000000 65535 f \n`)
for (let i = 1; i < total; i++) add(`${String(offs[i]).padStart(10, '0')} 00000 n \n`)
add(`trailer\n<< /Size ${total} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`)
return Buffer.concat(teile)
}
}
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more