Spec: Terminplaner (TP1 Kern, TP2 Verkauf, TP3 Online-Zahlung)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent d8328722b3
commit 275dbcd690
1 file changed
+544
@@ -0,0 +1,544 @@
# Terminplaner – Design
Stand: 2026-10-02 · Status: im Chat freigegeben (Entscheidungen E1–E8), Spec zur Durchsicht
Teilprojekte: **TP1 Kern** (diese Spec im Detail, erster Plan) · **TP2 Verkauf** · **TP3 Online-Zahlung**
(TP2 und TP3 sind hier verbindlich umrissen und bekommen je einen eigenen Plan; Regel des Nutzers:
ein Teilprojekt live, vom Nutzer getestet, erst dann das nächste).
Recherche (nur lesend, Quellen dort): `/root/reports/terminplaner-2026-10-02/`
`code-funktionen.md`, `code-kern.md`, `code-woms.md`, `recherche-branchen.md`, `recherche-recht.md`,
`recherche-technik.md` (+ `spike-code/`), `recherche-stripe-connect.md`, `recherche-recht-zahlung.md`.
## Ziel
Betriebe (unsere Kunden) bekommen eine Online-Terminbuchung auf ihrer eigenen Website. Der Teil auf der
Website sieht bei jedem Kunden anders aus (Design der Kundenseite), die Verwaltung im Kundenportal ist
für alle gleich. Ein Fragebogen (TP2) klassifiziert den Betrieb und erzeugt für WEBklar eine fertige
Einbauanleitung: wir bauen ein, schließen das Ticket, der Terminplaner läuft.
Die Funktion ersetzt die Kachel „Terminplaner · Kommt bald“ (`public/funktionen.js:151`).
## Entscheidungen (vom Nutzer)
| # | Entscheidung |
|---|---|
| E1 | Abrechnung: **einmalige Einrichtung + nur solange ein Wartungs-Abo läuft** (wie Website-Analyse). Endet das Abo, endet der Terminplaner. Hosting-Abo reicht nicht. |
| E2 | Einrichtungspreis **149 €** (brutto, einmalig, Stripe Checkout wie News). |
| E3 | Umfang V1: **Personen und Plätze** (Einzelperson; Team mit Wahl der Person oder „egal“; buchbare Plätze wie Kabine, Hebebühne). Pro Leistung: **sofort verbindlich** oder **Anfrage** (Betrieb bestätigt). Gruppen/Kurse/Tische später. |
| E4 | Der Gast **bestätigt seine E-Mail-Adresse per Link**, bevor der Termin gilt; der Slot ist so lange 30 Minuten reserviert. |
| E5 | **Ein gemeinsames Widget**, je Kunde gestaltet (Farben, Schrift, Rundungen, Texte, Position) + offene, versionierte Schnittstelle für Sonderfälle. |
| E6 | Der **Fragebogen kommt vor der Zahlung**; das Ticket enthält sofort die Einbauanleitung (TP2). |
| E7 | **Zahlungsmodus legt der Betrieb fest:** nur vor Ort · nur online · online oder vor Ort (dann wählt der Gast). Online-Geld geht **direkt an den Betrieb** (Stripe Connect, WEBklar berührt kein Geld). TP1 kann nur „vor Ort“, TP3 bringt „online“. |
| E8 | Reihenfolge: TP1 Kern (Pilot mit Kunde „test“) → TP2 Verkauf → TP3 Online-Zahlung. |
Vorgaben aus dem Entwurf, denen der Nutzer nicht widersprochen hat:
- Gebaut **im Kundenportal** (kein eigener Container, keine Fremdsoftware), Daten in Appwrite `woms-database`.
- **Ein** Buchungssystem für alle Branchen; Branchen-Vorlagen sind Daten, kein Code je Nische.
- **Gesundheitsbereich ausgeschlossen** (Arzt, Zahnarzt, Physio/Ergo/Logo/Podologie, Psychotherapie,
Heilpraktiker, medizinische Kosmetik/Massage). Freitext beim Gast standardmäßig aus.
- Erinnerung an den Gast **nur per nicht vorangekreuztem Haken**.
- Gästemails: Anzeigename = Betrieb, Antworten an den Betrieb, **keine Team-Kopie** (`bccTeam` aus), kein
Tracking, keine Werbung.
- Mailversand über eine **Warteschlange** (Hetzner-Grenze 500 Mails/h für alles).
- Löschfristen: unbestätigt 48 h, abgesagt 30 Tage, vergangen 90 Tage (einstellbar 14–365 Tage).
- Schalter `TERMINE_ENABLED` = `off` | `pilot` | `on`.
## Begriffe
- **Betrieb** = unser Kunde (Portal-Kunde, Inhaber der Website). **Gast** = wer auf der Website bucht.
- **Ressource** = was gebucht wird: eine **Person** oder ein **Platz** (je Kapazität 1; zwei Hebebühnen =
zwei Plätze). **Leistung** = was gebucht wird (Dauer, Puffer, Preis, Buchungsart).
- **Funktion** `termine` = Dokument in `portalFeatures` (`featureKey: 'termine'`), mit eigenem `siteKey`.
---
## TP1 Kern
### 1. Lebenszyklus in TP1
- `TERMINE_ENABLED=off` (Standard): Kachel „Kommt bald“, öffentliche Schnittstelle antwortet für jeden
Schlüssel „nicht aktiv“, Routen des Kunden 409.
- `pilot`: Kachel und Ansicht nur für Kunden, deren E-Mail in `TERMINE_PILOT_EMAILS` steht (Komma-Liste,
Muster `ABO_PILOT_EMAILS`). Bestellen gibt es in TP1 nicht; die Funktion wird im Ticketsystem mit
„Aktivieren (ohne Zahlung)“ eingeschaltet (vorhandenes `adminActivate`, auf Schlüssel verallgemeinert).
- `on` kommt mit TP2 (Bestellung für alle mit Wartungs-Abo).
- Zustände der Funktion wie bei News: `off → active → disabled` (TP1); `ordered/setup` folgen mit TP2.
`disabled` ⇒ Widget zeigt den Ausweichtext, keine neuen Buchungen; bestehende Termine bleiben im Portal
sichtbar, Gäste können weiter absagen und widerrufen.
### 2. Datenmodell (Appwrite, neues Setup-Skript `scripts/termine-setup.mjs`)
Muster von `scripts/analyse-setup.mjs` (exportierte Attributlisten, injizierbarer Client, `--check`,
legt nur Fehlendes an, Indizes nur bei neuen Collections). Alle Collections `permissions: []`,
`documentSecurity: false` (nur Server-Schlüssel). `test/support/schema.js` liest das neue Skript mit.
**`termineConfig`** – eine Einstellung je Funktion; Dokument-ID = ID des `portalFeatures`-Dokuments.
`customerId`, `projectId` (64), `data` (Text, JSON bis 200 000 Zeichen), `version` (Ganzzahl),
`updatedAt`, `updatedBy` (100). Inhalt von `data` (validiert in `services/termine/einstellungen.js`):
```jsonc
{
"schema": 1,
"vorlage": "salon", // Branchen-Vorlage, nur Herkunft
"betrieb": { "name": "Salon Müller", "email": "info@salon.de", "telefon": "06301 1234",
"adresse": "Hauptstr. 1, 67722 Winnweiler",
"impressumUrl": "https://…", "datenschutzUrl": "https://…", "agbUrl": null },
"anrede": "sie", // sie | du (Widget, Gästeseiten, Gästemails)
"farbe": "#7c3aed", // Akzent für Gästeseiten und -mails
"bundesland": "RP", "feiertageGeschlossen": true,
"sonderoeffnungen": { "2026-12-24": [["09:00","13:00"]] }, // Datum → Fenster, schlägt Plan/Feiertag
"abwesenheiten": [ { "id": "a1", "ressourceId": null, "von": "2026-12-27", "bis": "2026-12-31", "titel": "Betriebsurlaub" } ],
"ressourcen": [ { "id": "r1", "typ": "person", "name": "Anna", "aktiv": true,
"plan": { "1": [["09:00","12:30"],["13:30","18:00"]], "6": [["09:00","13:00"]] },
"feedToken": "…" } ], // ISO-Wochentag 1=Mo … 7=So; Pausen = Lücken
"leistungen": [ { "id": "s1", "name": "Damenschnitt", "beschreibung": "", "dauer": 45, "pufferNach": 5,
"preisArt": "fest", "preisCent": 4500, // fest | ab | keiner
"buchungsart": "sofort", // sofort | anfrage
"ressourcen": ["r1"], "aktiv": true, "reihenfolge": 1 } ],
"regeln": { "raster": 15, "vorlaufMin": 120, "horizontTage": 60, "absageBisStd": 24,
"maxOffeneJeEmail": 3, "personWaehlbar": true },
"gastFelder": { "telefon": "optional", "nachricht": "aus", "zusatz": [] }, // aus | optional | pflicht
"erinnerung": { "anbieten": true, "stundenVorher": 24 },
"storno": { "text": "Kostenlose Absage bis 24 Stunden vor dem Termin …", "version": "2026-10-02-1" },
"recht": { "gaesteSind": "verbraucher", // verbraucher | unternehmer | gemischt
"freizeit": false }, // true ⇒ kein Widerrufsrecht (§ 312g Abs. 2 Nr. 9), nur nach Prüfung
"zahlung": { "modus": "vorort", "vorOrtArten": ["bar","ec","kreditkarte"] }, // online/beides erst TP3
"aufbewahrungTage": 90
}
```
IDs von Ressourcen/Leistungen/Abwesenheiten sind kurze Zufalls-IDs (keine Appwrite-IDs). Änderungen
sind optimistisch gesperrt: `PATCH` schickt `version`, der Server schreibt unter
`withLocks(['termine-cfg:<featureId>'])` und antwortet bei abweichender Version 409 „Die Einstellungen
wurden inzwischen geändert. Bitte neu laden.“
**`termineBookings`** – eine Buchung je Dokument (ID `randomUUID`).
Felder: `featureId`, `customerId`, `projectId` (64); `leistungId`, `leistungName` (120, Schnappschuss),
`ressourceId`, `ressourceName` (80); `startsAt`, `endsAt`, `blockEndsAt` (datetime UTC; `blockEndsAt` =
Ende + Puffer); `status` (16, siehe 4); `quelle` (16: `online` | `manuell` | `sperre`);
`gastName` (100), `gastEmail` (254), `gastTelefon` (40), `gastNachricht` (500), `gastZusatz` (2000, JSON);
`erinnerung` (bool), `erinnerungGesendetAt`; `heldUntil`; `verifyHash`, `manageHash` (64, SHA-256 der
Tokens); `bestaetigtAt`, `angenommenAt`, `abgesagtAt`, `abgesagtVon` (16: `gast`|`betrieb`|`system`),
`absageGrund` (300), `widerrufenAt`; `nachweis` (4000, JSON: Fassung und Wortlaut-Hash von Storno-,
Datenschutz- und Widerrufstext, Zeitpunkt der Zustimmungen, Knopftext, Anrede); `preisCent`,
`preisArt` (8), `buchungsart` (8), `verbraucher` (bool); `zahlart` (8: `vorort` | `online`),
`zahlStatus` (16: `keine`|`offen`|`bezahlt`|`erstattet`|`teilerstattet`|`fehlgeschlagen`; TP1 immer
`keine`), `stripeSessionId` (128), `stripePaymentIntent` (64) (TP3); `icsSequenz` (Ganzzahl);
`createdAt`, `updatedAt`.
Indizes: `idx_feature_start(featureId, startsAt)`, `idx_status_held(status, heldUntil)`,
`idx_feature_email(featureId, gastEmail)`, `idx_verify(verifyHash)`, `idx_manage(manageHash)`,
`idx_status_start(status, startsAt)`.
**`termineClaims`** – Belegungszellen gegen Doppelbuchung (siehe 5). Dokument-ID deterministisch
`c_` + erste 34 Hex von `sha1(featureId|ressourceId|zellenStartUtcIso)`. Felder `featureId`,
`ressourceId`, `bookingId`, `zelle` (datetime), `bis` (datetime, Haltezeit oder Terminende).
Index `idx_booking(bookingId)`, `idx_bis(bis)`.
**`termineOutbox`** – Mail-Warteschlange. Felder `featureId`, `bookingId`, `art` (16), `an` (254),
`fromName` (120), `replyTo` (254), `subject` (300), `html` (Text 100 000), `text` (Text 50 000),
`ics` (Text 20 000), `icsMethod` (8), `status` (`wartet`|`gesendet`|`fehler`), `versuche`,
`naechsterVersuch`, `gesendetAt`, `fehler` (300), `createdAt`. Index `idx_status_next(status, naechsterVersuch)`.
Gesendete und endgültig gescheiterte Einträge werden nach 7 Tagen gelöscht (enthalten personenbezogene Daten).
`portalFeatures` bekommt **keine** neuen Attribute; `featureKey 'termine'` nutzt die vorhandenen.
### 3. Verfügbarkeit (reine Funktionen, `server/services/termine/`)
- **Zeit** (`zeit.js`, ohne Abhängigkeit, `Intl`): Wanduhr Europe/Berlin ↔ UTC; Wochentag; Tage addieren.
Nicht existierende Zeiten (Umstellung März, 02:00–02:59) werden **ausgelassen**, doppelte Zeiten
(Oktober) nehmen das **erste** Vorkommen. Tests für 2026-03-29 und 2026-10-25. `deadlines.berlinTime`
wird dafür **nicht** benutzt (kennt die Lücke nicht).
- **Feiertage** (`feiertage.js`): Paket `feiertagejs` (MIT, ohne Abhängigkeiten) je Bundesland;
`sonderoeffnungen` und `abwesenheiten` schlagen Feiertage/Plan.
- **Slots** (`slots.js`, `now` wird übergeben): je Ressource und Berliner Kalendertag die Öffnungsfenster
aus `plan` (oder `sonderoeffnungen`), minus Feiertag (wenn geschlossen), minus Abwesenheiten, minus
belegte Zeiten (Buchungen mit Status `gehalten|angefragt|bestaetigt` und Sperren, jeweils bis
`blockEndsAt`). Startzeiten im `raster` ab Fensterbeginn; Termin + `pufferNach` muss ganz ins Fenster
passen; nicht vor `now + vorlaufMin`; nicht nach `horizontTage`. „Egal“ = Vereinigung über alle
Ressourcen der Leistung; beim Buchen wird die Ressource mit den wenigsten Buchungen an dem Tag gewählt
(bei Gleichstand Reihenfolge der Liste).
- Ein Abruf liefert höchstens 31 Tage; der Server lädt dafür die Buchungen des Zeitraums per Indexabfrage
(`featureId` + `startsAt` zwischen, mit Seitenweise-Laden über `cursorAfter`), nie „alles laden“.
### 4. Buchungsablauf und Zustände
Zustände (`status`): `gehalten` (Formular abgeschickt, wartet auf E-Mail-Bestätigung) → `angefragt`
(Leistung „Anfrage“, wartet auf den Betrieb) oder `bestaetigt` → `erledigt` (automatisch 1 h nach Ende) ·
`nichterschienen` (Betrieb) · `abgesagt` (Gast, Betrieb, System) · `abgelehnt` (Betrieb lehnt Anfrage ab) ·
`verfallen` (Link nicht innerhalb von 30 Minuten bestätigt). Sperren (`quelle: sperre`) sind immer
`bestaetigt` und haben keinen Gast.
**Gast im Widget**
1. Leistung wählen (Name, Dauer, Preis „45 €“ / „ab 45 €“ / ohne, Kurzbeschreibung). Zahlungsart steht
schon hier („Bezahlung vor Ort: bar, EC-Karte, Kreditkarte“, § 312j Abs. 1 BGB).
2. Person wählen, wenn mehr als eine Ressource infrage kommt und `personWaehlbar` (mit „Egal“ als erster
Option); bei Plätzen kein Schritt.
3. Tag (Monatsraster, nur Tage mit freien Zeiten aktiv) und Uhrzeit.
4. Daten: Name, E-Mail, Telefon/Nachricht/Zusatzfelder nach `gastFelder`. Über der Nachricht steht
„Bitte keine Gesundheitsangaben.“
5. Zusammenfassung: Leistung, Person/Platz, Datum, Uhrzeit, Preis inkl. USt (bzw. Hinweis Kleinunternehmer
aus dem Einstellungsfeld des Betriebs), Ort, Zahlungsart, Stornoregel im Klartext + Link, Hinweis zum
Datenschutz (Text 11.2 aus `recherche-recht.md`, Link auf `datenschutzUrl`), Haken:
- Pflicht: „Ich habe die Stornobedingungen gelesen.“ (nur wenn `storno.text` gesetzt)
- Pflicht nur bei verbindlicher Leistung mit Preis, Verbrauchern und Termin innerhalb von 14 Tagen nach
der Buchung: Zustimmung zum vorzeitigen Beginn (§ 356 Abs. 5 Nr. 2 BGB, Wortlaut im Rechtsteil).
- Freiwillig, nicht vorangekreuzt (nur wenn `erinnerung.anbieten`): „Ich möchte {24} Stunden vorher eine
Erinnerung per E-Mail.“
Knopf: **„Zahlungspflichtig buchen“** (verbindlich + Preis), **„Termin buchen“** (verbindlich, ohne
Preis), **„Termin anfragen“** (Buchungsart Anfrage).
6. Ergebnis: „Fast geschafft: Bitte bestätigen Sie den Termin über den Link, den wir an m***@gmail.com
geschickt haben. Der Termin ist 30 Minuten für Sie reserviert.“
**Server beim Abschicken** (`POST …/buchungen`): Prüfungen (Abschnitt 8), Slot neu berechnen, unter
`withLocks(['termine-res:<featureId>:<ressourceId>'], {waitMs: 5000})` Zellen belegen (5), Buchung
`gehalten` mit `heldUntil = now + 30 min` anlegen, Mail G01 in die Warteschlange (sofort senden).
**Bestätigungslink** `https://project.webklar.com/termin.html#v=<token>`: Die Seite liest das Token aus
dem Fragment (geht nicht an Server-Logs), zeigt per `POST …/vorschau` die Zusammenfassung und einen Knopf
mit demselben Wortlaut wie im Widget (bei Preis „Zahlungspflichtig bestätigen“). Erst `POST …/bestaetigen`
ändert den Zustand (Mailscanner öffnen nur Links): `gehalten` → `bestaetigt` (G02 an Gast mit .ics,
Verwaltungslink und ggf. Widerrufsbelehrung; K20 an den Betrieb mit .ics) bzw. → `angefragt` (G03 an Gast,
K20-Block „Anfrage“ an den Betrieb). Abgelaufen ⇒ „Diese Reservierung ist abgelaufen“ + Link zur Website.
Ein Token gilt genau einmal.
**Verwaltungslink** `termin.html#m=<token>` (gültig bis Terminende + 1 Tag): zeigt den Termin und je nach
Lage
- „Termin absagen“ bis `absageBisStd` vor Beginn (danach Text „Absage nur telefonisch: …“) ⇒ `abgesagt`,
Zellen frei, G06 an Gast (ICS CANCEL), K21 an den Betrieb;
- „Termin verschieben“ bis zur selben Frist ⇒ neue Zeit wählen; neuer Slot wird belegt, alter freigegeben
(eine Sperre über beide Ressourcen), Status bleibt, `icsSequenz + 1`, G07 + K21;
- **„Vertrag widerrufen“**, solange ein Widerrufsrecht besteht (verbindlich, Verbraucher, nicht `freizeit`,
innerhalb von 14 Tagen nach `bestaetigtAt`) – unabhängig von der Stornofrist (§ 361 BGB) ⇒ Formular nach
§ 356a BGB (Name, Buchungsnummer, E-Mail für die Eingangsbestätigung vorbefüllt, Knopf „Widerruf
bestätigen“) ⇒ `widerrufenAt`, `abgesagt` (von `gast`), G08 Eingangsbestätigung mit Datum und Uhrzeit,
K21-Block „Widerruf“.
**Widerruf ohne Link** (§ 356a: auf der Oberfläche, über die der Vertrag geschlossen wurde): Das Widget
zeigt im Fuß dauerhaft „Vertrag widerrufen“, sobald der Betrieb verbindliche Leistungen für Verbraucher
anbietet. Formular: Name, E-Mail, Buchungsnummer (steht in G02), Knopf „Widerruf bestätigen“. Der Server
antwortet immer gleich („Ihre Erklärung ist eingegangen“), speichert die Erklärung an der passenden
Buchung (E-Mail + Nummer), schickt G08 an die angegebene Adresse und leitet die Erklärung per K21 an den
Betrieb weiter – auch wenn keine Buchung passt (dann Block „nicht zugeordnet“, der Betrieb prüft).
**Betrieb im Portal**: Anfrage annehmen (`angefragt` → `bestaetigt`, G02) oder ablehnen (`abgelehnt`, G04,
Zellen frei); bestätigten Termin absagen (Pflicht-Grund-Auswahl + optionale Nachricht, G06) oder verschieben
(G07); „nicht erschienen“; Termin von Hand eintragen (`quelle: manuell`, Gast-E-Mail optional, G02 nur wenn
der Haken „Bestätigung an den Gast senden“ gesetzt ist; ohne Widerrufsbelehrung durch das System, die ist
bei telefonischen Buchungen Sache des Betriebs); Zeit sperren (`quelle: sperre`).
**Hintergrund-Jobs** (`server/index.js`, Muster der vorhandenen `setInterval`-Jobs, je Job eigene Fehlerbehandlung):
- jede Minute: abgelaufene `gehalten` ⇒ `verfallen` + Zellen löschen; Warteschlange senden (6).
- alle 5 Minuten: Erinnerungen (`bestaetigt`, `erinnerung`, kein `erinnerungGesendetAt`,
`startsAt` in `[now + stundenVorher − 5 min, now + stundenVorher]`; erst Marker setzen, dann einreihen);
`bestaetigt` mit `endsAt < now − 1 h` ⇒ `erledigt`.
- täglich: Löschfristen (11), verwaiste Zellen (Buchung fehlt oder nicht mehr belegend).
### 5. Doppelbuchung verhindern
- Zellen à **5 Minuten** von Beginn bis `blockEndsAt`. Jede Zelle ist ein `termineClaims`-Dokument mit
deterministischer ID; eine zweite Belegung derselben Zelle scheitert in der Datenbank mit 409.
- Alle Zellen einer Buchung werden in **einem** Aufruf angelegt (Appwrite 1.8.1 Bulk-Create
`POST …/documents` mit `documents: [...]`, eine Transaktion, höchstens 100 Dokumente ⇒ Termine bis
500 Minuten). 409 ⇒ „Dieser Termin wurde gerade vergeben. Bitte wählen Sie eine andere Zeit.“
- Zusätzlich `withLocks` je Ressource im Prozess (Reihenfolge, keine Fehlalarme bei Doppelklick).
- **Spike vor dem Bau (Pflicht, Plan-Aufgabe 1):** auf einer Wegwerf-Collection in der Produktions-Appwrite
50 parallele Bulk-Creates mit überlappenden IDs ⇒ genau einer 201, Rest 409, keine Teil-Schreibungen;
danach Collection löschen. Braucht die Freigabe des Nutzers. Fällt der Spike negativ aus: Fallback
Transactions-API (`/v1/databases/transactions`, ebenfalls in 1.8.1), sonst Einzel-Creates mit Rückbau
unter der Prozess-Sperre.
- Der Test-Nachbau (`test/support/appwriteMock.js`) lernt Bulk-Create mit Alles-oder-nichts und 409,
`between`/`greaterThanEqual`/`lessThanEqual`/`cursorAfter`.
### 6. Mails
**Versand**: neue Funktion `sendGuestMail` in `mailer.js` (nutzt den vorhandenen Transport): From
`"<betrieb.name>" <SMTP_FROM>` (Anzeigename = Betrieb, Adresse bleibt `noreply@webklar.com`, also keine
DNS-/Postfach-Änderung), `replyTo = betrieb.email`, **nie BCC**, `icalEvent` für Kalenderdaten. Alle
Termine-Mails laufen über `termineOutbox` (Muster „erst speichern, dann senden“): sofortiger Sendeversuch
nach dem Einreihen, sonst Job; höchstens **200 Mails/Stunde** für den Terminplaner (Rest des
Hetzner-Budgets bleibt Portal/Abo); Wiederholung nach 1, 5, 30 Minuten, dann `fehler` und Hinweis im
Portal an der Buchung („Bestätigung konnte nicht zugestellt werden“). **Sicherung:** bei einem
SMTP-Anmeldefehler pausiert die Warteschlange 15 Minuten (Hetzner sperrt die IP nach mehreren
Fehlversuchen für ~10 Minuten).
**Vorlagen** (Engine `mailTemplates.js`; Kennungen um `G` erweitern, Test `mailTemplates.test.js`
`EXPECTED` mitpflegen). Gästemails haben ein **neutrales Layout** ohne WEBklar-Logo: Kopf mit Betriebsname
in `farbe`, Fuß mit Betrieb (Name, Adresse, Telefon, E-Mail, Links Impressum/Datenschutz) und dem Satz
„Diese E-Mail wurde im Auftrag von {Betrieb} über den Terminplaner von WEBklar versendet.“ Jede Vorlage hat
Blöcke `sie`/`du`.
| ID | an | Betreff (Sie-Form) | Inhalt |
|---|---|---|---|
| G01 | Gast | Bitte bestätigen Sie Ihren Termin bei {betrieb} | Datum, Uhrzeit, Leistung, Knopf „Termin bestätigen“, „Das waren Sie nicht? Dann ignorieren Sie diese E-Mail.“ Nichts Werbliches. |
| G02 | Gast | Ihr Termin bei {betrieb} am {datum} | Zusammenfassung, Buchungsnummer, Zahlungsart, Stornoregel (Wortlaut), Verwaltungslink, .ics (REQUEST); bei Widerrufsrecht: Widerrufsbelehrung + Muster-Widerrufsformular + Hinweis auf die Widerrufsfunktion (§ 312f Abs. 2 BGB) |
| G03 | Gast | Ihre Terminanfrage bei {betrieb} | „Der Betrieb meldet sich“, Verwaltungslink (nur Absage) |
| G04 | Gast | Ihre Terminanfrage bei {betrieb} | abgelehnt, optionale Nachricht des Betriebs |
| G05 | Gast | Erinnerung: Ihr Termin morgen um {zeit} | Termin, Verwaltungslink; sonst nichts |
| G06 | Gast | Ihr Termin am {datum} wurde abgesagt | wer abgesagt hat, Grund/Nachricht, .ics (CANCEL) |
| G07 | Gast | Ihr Termin wurde verschoben | alte/neue Zeit, .ics (REQUEST, Sequenz + 1) |
| G08 | Gast | Eingangsbestätigung Ihres Widerrufs | Inhalt der Erklärung, Datum und Uhrzeit des Eingangs |
| K20 | Betrieb | Neue Buchung: {leistung} am {datum} um {zeit} (Block „anfrage“: Neue Terminanfrage …) | Gastdaten, Link ins Portal, .ics (PUBLISH) |
| K21 | Betrieb | Termin abgesagt / verschoben / widerrufen: … | Block je Fall, Link ins Portal |
K20/K21 im WEBklar-Layout (Du-Form wie K15–K19), Empfänger `betrieb.email`, **ohne** Team-BCC (enthalten
Gästedaten).
**ICS** (`ics.js`, eigener Schreiber, ~50 Zeilen, Zeilenfaltung/Escaping nach RFC 5545): `UID =
<bookingId>@termine.webklar.com` (stabil), `SEQUENCE = icsSequenz`, `DTSTART/DTEND` in UTC, `SUMMARY` =
„{Leistung} – {Betrieb}“ (an den Betrieb: „{Leistung}: {Gastname}“), `LOCATION` = Adresse, `ORGANIZER` =
Betrieb, `ATTENDEE` = Gast (`RSVP=FALSE`), `METHOD` REQUEST/CANCEL. Keine „In Google eintragen“-Links.
**Kalender-Abo für den Betrieb**: je Ressource geheimer Link `…/v1/kalender/<feedToken>.ics` (nur
bestätigte Termine und Sperren der nächsten 90 Tage, `X-PUBLISHED-TTL: PT15M`; Google aktualisiert nur
alle paar Stunden – steht so im Portal). Link neu erzeugen macht den alten ungültig.
### 7. Website-Teil
**Einbau** (vom Mitarbeiter, je Kunde gestaltet):
```html
<div data-webklar-termine="SITEKEY" data-api="https://project.webklar.com" data-modus="inline">
<p>Termine telefonisch unter 06301 1234.</p> <!-- Ausweichtext, bleibt ohne JS/bei Fehlern stehen -->
</div>
<script src="https://project.webklar.com/embed/termine.js" defer></script>
```
`data-modus`: `inline` (Buchungsstrecke direkt in der Seite) oder `knopf` (Knopf mit Text aus
`data-knopf`, öffnet ein `<dialog>`; Daten werden erst beim Klick geladen). Optional
`data-leistung="s1"` (vorausgewählt, z. B. Knopf neben einer Leistung).
**`public/embed/termine.js`** (ohne Abhängigkeiten, ohne Build, IIFE wie `news.js`):
- Light DOM mit Klassenpräfix `wkt-`, Standard-Stile in `@layer wkt`, Gestaltung über CSS-Variablen der
Seite: `--wkt-akzent`, `--wkt-akzent-text`, `--wkt-hintergrund`, `--wkt-text`, `--wkt-rand`,
`--wkt-radius`, `--wkt-schrift`, `--wkt-schrift-titel`; Containerabfragen für schmale Spalten/Handy.
- Kein Cookie, kein Web-Storage, kein Auslesen von Referrer/Bildschirm/Zeitzone, keine Drittanfragen;
Text nur per `textContent`.
- Barrierefrei nach WCAG 2.1 AA: echte Formularelemente, sichtbarer Fokus, Monatsraster nach dem
APG-Muster „Date Picker Dialog“ (Pfeiltasten, `aria-selected`, Tage ohne Zeiten `aria-disabled`),
Fehler über `aria-live`, Zielgröße ≥ 24 px, kein Zeitlimit im Widget (die 30 Minuten gelten erst nach
dem Absenden).
- Deutsche Texte in Sie- oder Du-Form nach `anrede`. Fuß: „Vertrag widerrufen“ (wenn nötig, siehe 4) und
„Datenschutz“ (Link `datenschutzUrl`), klein „Terminbuchung über WEBklar“.
- Fehler/inaktiv/Abo beendet: Ausweichtext bleibt sichtbar; bei Netzfehler mitten in der Strecke „Gerade
nicht erreichbar. Bitte rufen Sie an: {telefon}“.
- Demo `public/embed/termine-demo.html`, Einbauanleitung für Mitarbeiter `docs/funktionen/termine-einbau.md`
(Snippet, Variablen-Tabelle, Beispiele für drei Stile, Datenschutz-Absatz, Abnahme-Checkliste).
**Gästeseiten** `public/termin.html` + `public/termin.js` + `public/termin.css`: Bestätigen, Verwalten,
Widerrufen (Token im Fragment). Kopf mit Betriebsname in `farbe`, Fuß mit Links auf Impressum und
Datenschutz des Betriebs **und** auf das Impressum von WEBklar. `noindex`, keine Cookies.
### 8. Öffentliche Schnittstelle `/api/public/termine/v1/:siteKey/…`
Eigener Router (`server/routes/publicTermine.js`), in `server/index.js` **vor** dem globalen
`express.json` eingehängt (eigener Parser `limit: '16kb'`). Unbekannter oder inaktiver Schlüssel ⇒
immer dieselbe Antwort `{ aktiv: false }` (200), nie ein Hinweis, ob der Schlüssel existiert. Antworten
enthalten **nie** `customerId`, `projectId`, `featureId`, Appwrite-, Stripe- oder Ticket-IDs; nur
Leistungs-/Ressourcen-IDs aus der Einstellung und die Buchungsnummer (8 Zeichen, aus der Buchungs-ID
abgeleitet, nur zur Anzeige).
| Methode, Pfad | Zweck | Cache |
|---|---|---|
| `GET /config` | Betriebsname, Anrede, Farbe, Leistungen (ohne interne Felder), wählbare Ressourcen, Regeln für die Anzeige, Zahlungsarten, Stornotext + Version, Rechtsflags (Widerruf ja/nein), Formular-Token (HMAC über siteKey + Zeit) | `public, max-age=60`, ETag |
| `GET /tage?leistung=&ressource=&monat=YYYY-MM` | Tage mit freien Zeiten | `max-age=15` |
| `GET /zeiten?leistung=&ressource=&tag=YYYY-MM-DD` | freie Startzeiten; bei „egal“ ohne Angabe der Person | `no-store` |
| `POST /buchungen` | Formular abschicken ⇒ `gehalten`, G01 | – |
| `POST /vorschau` `{token}` | Zusammenfassung für Bestätigen/Verwalten | – |
| `POST /bestaetigen` `{token}` | E-Mail bestätigt | – |
| `POST /absagen`, `POST /verschieben` `{token, …}` | Verwaltung durch den Gast | – |
| `POST /widerruf` `{name, email, nummer, token?}` | Widerrufsfunktion | – |
| `GET /kalender/:feedToken.ics` | Kalender-Abo des Betriebs (Schlüssel im Pfad statt siteKey) | `max-age=300` |
Schutz (ohne Captcha-Dienst):
- **CORS**: GETs `Access-Control-Allow-Origin: *`; POSTs nur von erlaubten Origins = Hosts des Projekts
(Subdomain unter `project.webklar.com`, `liveDomain` + www-Gegenstück, wie `analyseHosts`) und
`https://project.webklar.com` (Gästeseiten); Preflight erlaubt `Content-Type`.
- **Honeypot** (`website`, unsichtbar) und **Zeitfalle** (Formular-Token muss ≥ 3 s und ≤ 2 h alt sein) ⇒
bei Treffer dieselbe Erfolgsantwort, aber nichts gespeichert, keine Mail.
- **Mengen** (im Speicher, mit Obergrenze der Schlüssel wie `pushTime.js`): je IP (IPv6 je /64) 10
Buchungen/10 min und 120 GET/min; je Funktion 60 gehaltene Buchungen/h; je E-Mail-Adresse und
Funktion höchstens `maxOffeneJeEmail` offene Termine und 5 Bestätigungsmails/Tag; `widerruf` 10/h je IP.
- Eingaben streng prüfen (Längen, E-Mail-Syntax, Telefon nur Ziffern/Leerzeichen/+/-/(), Zusatzfelder
nur bekannte Schlüssel); Fehler als Feldfehler `{ fehler: { feld: text } }`.
- Appwrite nicht erreichbar ⇒ 503 `{ fehler: 'Gerade nicht erreichbar' }`, nie Rohfehler.
### 9. Verwaltung im Portal (für alle gleich)
Neue Ansicht `#view-termine` (Hash `#termine`), Kachel „Terminplaner“ im Funktionen-Tab mit Pille *Aus* /
*Aktiv* / *Abgeschaltet* (TP1) und Knopf „Termine öffnen“. In der App-Ansicht Unterseite von Funktionen
(`APP_PARENT.termine = 'funktionen'`). Mehrere Website-Projekte ⇒ Projektauswahl oben (wie News). Eigenes
Modul `public/termine.js` + `public/termine.css` (nicht in das schon 794 Zeilen lange `funktionen.js`).
Reiter:
1. **Termine** – Liste nach Tagen (heute, morgen, …; „Woche“ / „Monat“ / Datumssprung), Filter
offen/angefragt/bestätigt/abgesagt; Zeile: Uhrzeit, Leistung, Person/Platz, Gast, Status, Hinweise
(„Bestätigung nicht zugestellt“). Aktionen siehe 4; „+ Termin eintragen“, „Zeit sperren“. Zähler
„{n} Anfragen warten“ auf der Kachel.
2. **Leistungen** – Liste mit Reihenfolge; Felder wie im Datenmodell; Hinweis, dass Leistungsnamen keine
Gesundheitsangaben enthalten dürfen.
3. **Team & Plätze** – Ressourcen mit Wochenplan-Editor (je Tag mehrere Zeitfenster), Zuordnung der
Leistungen, Abwesenheiten/Urlaub, Bundesland + „an Feiertagen geschlossen“, Sonderöffnungen,
Kalender-Abo-Link je Ressource (Kopieren, Neu erzeugen).
4. **Einstellungen** – Betriebsangaben für Gäste (vorbefüllt aus dem Kundenkonto), Anrede, Farbe, Regeln,
Gastfelder, Erinnerung, Stornotext (Mustertext als Vorschlag, deutlich als Muster gekennzeichnet),
Rechtliches („Ihre Gäste sind: Privatpersonen / Unternehmen / beides“; Hinweis, dass der Widerruf die
Stornofrist schlägt), Zahlung (TP1: nur „vor Ort“ mit Zahlarten; „online“ ausgegraut „kommt bald“),
Aufbewahrung, **Export** (CSV der Buchungen), Status „aktiv seit …“.
- **Erstes Öffnen** ohne Einstellung: Auswahl einer Branchen-Vorlage (`salon`, `kosmetik`, `beratung`,
`werkstatt`, `handwerk`, `tattoo`, `fotograf`, `hundesalon`, `sonstiges`; Daten in
`server/services/termine/vorlagen.js`, die TP2 für den Fragebogen wiederverwendet), danach Bearbeiten.
Kunden-API `server/routes/termine.js` unter `/api/termine` (`requireSession`, `assertOwnProject`, Funktion
`active` außer für reines Lesen, Schreiblimit 120/h je Kunde): `GET/PATCH /einstellungen`,
`GET /buchungen?von&bis&status`, `POST /buchungen` (manuell/Sperre), `POST /buchungen/:id/aktion`
(`annehmen|ablehnen|absagen|verschieben|nichterschienen`), `POST /ressourcen/:id/feed` (neu erzeugen),
`GET /export.csv`.
**Kundenansicht** (Mitarbeiter als Kunde): Lesen und Schreiben auf `/api/termine/*` erlaubt
(Einstellungen und Termine kosten nichts, sind keine Erklärungen im Namen des Kunden); Einträge in
`KUNDENANSICHT_ALLOWED` + `test/kundenansichtGuard.test.js`, Protokoll `termin_einstellungen`,
`termin_aktion`, `termin_manuell` (Portal `VIEW_ACTIONS`, WOMS `src/lib/viewLog.js`).
### 10. Ticketsystem (WOMS) in TP1
- Portal: `FEATURE_KEYS = ['news','termine']`; `findActiveBySiteKey(siteKey, featureKey)`;
`activateClosedTickets` je Schlüssel (für TP2 vorbereitet); `funktionenAdmin.js` Route
`POST /api/team/funktionen/:customerId/:key` (`key` ∈ `FEATURE_KEYS`; `/news` bleibt gleichwertig) und
`adminView` mit allen Schlüsseln (Ticket-Lookups gebündelt statt N+1).
- WOMS: `src/lib/funktionenView.js` (Register der Funktionen, Zustandstexte, Bestätigungstexte),
`CustomerFunktionen.jsx` zeigt je Projekt **News** und **Terminplaner** (eigene Zeilen), Aktionen
„Aktivieren (ohne Zahlung)“ / „Abschalten“ je Funktion, Ticketnummer als Link `/tickets?woid=`;
`teamApi.setFeature(customerId, key, …)`. `viewLog.js` um die neuen Aktionen.
### 11. Recht und Datenschutz in TP1 (Pilot)
TP1 läuft nur im Pilot mit dem eigenen Testkunden; echte Gäste fremder Betriebe erst nach TP2
(AVV + Leistungsbeschreibung + Freigabe der Texte durch den Nutzer). TP1 liefert als **Entwürfe**
(Kennzeichnung „Entwurf“, Dateien in `docs/rechtliches/` bzw. `docs/funktionen/`):
- Datenschutz-Absatz „Terminbuchung“ für die Website des Betriebs (Sie/Du), Hinweis unter dem Formular,
Stornotext-Muster, Widerrufsbelehrung + Muster-Widerrufsformular für Dienstleistungen (Anlage 1/2
EGBGB, Platzhalter des Betriebs) – Grundlage `recherche-recht.md` 11.x und `recherche-recht-zahlung.md` 7.3.
- Abschnitt „6c Terminplaner“ für `public/datenschutz.html` (WEBklar als Auftragsverarbeiter; was das
Portal selbst verarbeitet) – nicht veröffentlicht vor TP2.
- Zitat-Korrektur im Bestand („§ 356 Abs. 4“ ⇒ „§ 356 Abs. 5 Nr. 2 BGB“ in `config.funktionen.consentText`,
`funktionen-leistungsbeschreibung.html`, Test) erfolgt in TP2 mit dem neuen Einwilligungstext je Funktion.
**Löschfristen** (täglicher Job im Terminplaner selbst, unabhängig von `retention.js`, weil die Fristen je
Buchung laufen und nicht am Vertragsende des Kunden hängen): `verfallen` und unbestätigte nach 48 h; `abgesagt`/`abgelehnt` nach 30 Tagen;
`erledigt`/`nichterschienen` nach `aufbewahrungTage` (Standard 90, erlaubt 14–365) ab Terminende;
Warteschlange nach 7 Tagen; Zellen mit der Buchung. Funktion `disabled` ⇒ neue Buchungen aus, nach 30
Tagen (Zeit für den Export) Löschung aller Buchungen, Zellen, Einstellung. Kunde gelöscht ⇒ Funktion
`disabled` (Haken in `customerAdmin.deleteCustomerWithPortalAccess`, wie Analyse).
### 12. Fehlerfälle
- Slot inzwischen weg (409 beim Belegen) ⇒ Meldung im Widget, Zeiten neu laden.
- Bestätigungslink nach Ablauf ⇒ „abgelaufen“; zweiter Klick auf denselben Link ⇒ „bereits bestätigt“ +
Verwaltungslink-Hinweis.
- Mail scheitert endgültig ⇒ Hinweis an der Buchung im Portal; Buchung bleibt gültig.
- Einstellung ungültig (z. B. Leistung ohne Ressource) ⇒ Speichern mit Feldfehler abgelehnt; Leistung ohne
aktive Ressource wird im Widget nicht angezeigt.
- Leistung oder Ressource gelöscht, während Termine bestehen ⇒ nur deaktivieren erlaubt, solange künftige
Termine daran hängen (Meldung mit Anzahl).
- Funktion abgeschaltet ⇒ öffentliche Schreibrouten `{ aktiv: false }`, Gästeseiten erlauben weiter
Absagen/Widerruf bestehender Termine.
- Uhr-Umstellung ⇒ keine Slots in der fehlenden Stunde; Termine über die Umstellung behalten ihre
echte Dauer.
### 13. Tests TP1
- `npm test` (node --test): Zeit (beide Umstellungstage), Feiertage je Land (Stichproben 2026/2027),
Slots (Pausen, Puffer, Vorlauf, Horizont, Abwesenheit, Feiertag, Sonderöffnung, „egal“), Zellen/Bulk
(Mock: Alles-oder-nichts), Tokens (einmalig, Ablauf), ICS (Faltung, Escaping, CANCEL), Vorlagen
(alle G/K-Vorlagen rendern ohne `problems`, `EXPECTED`), Einstellungs-Validierung, öffentliche Routen
(keine internen IDs, `{aktiv:false}` einheitlich, CORS/Origin, Honeypot, Zeitfalle, Mengen, Knopftexte
je Fall, Widerruf mit/ohne Treffer), Kunden-Routen (Rechte, fremdes Projekt 403, Versionskonflikt 409,
Kundenansicht-Liste), Jobs (Verfall, Erinnerung genau einmal, Erledigt, Löschfristen), Warteschlange
(Rate, Wiederholung, Pause nach Anmeldefehler).
- Playwright (Fixture-Server, wie `test/ui/*.ui.mjs`): Widget inline und Knopf auf einer Testseite
(Desktop, Handy, nur Tastatur, Ausweichtext bei Fehler), Gästeseiten (bestätigen, absagen, verschieben,
widerrufen), Portal-Ansicht (alle Reiter, Vorlage wählen, Wochenplan-Editor) – Screenshots nach
`/root/reports/terminplaner-ui/`.
- WOMS: `npm test` (Register, Zustände), `vite build`.
- Live-Spike Bulk-Create (5) vor dem Bau.
### 14. Livegang TP1 (je Schritt Freigabe des Nutzers)
1. L1 Schema: `termine-setup.mjs --check`, dann anlegen (im Container). Abhängigkeit `feiertagejs`
kommt mit dem Deploy (`deploy.sh` führt `npm install` aus).
2. L2 Portal: Push `feature/terminplaner` → `main` (der Nutzer pusht); Compose: `TERMINE_ENABLED=pilot`,
`TERMINE_PILOT_EMAILS=kenso.gri@gmail.com`, Container neu erstellen (`docker compose up -d --no-deps
kundenbereich-server`, nie `preview-portal`).
3. L3 WOMS: Push auf `test`.
4. L4 Pilot: im WOMS beim Kunden „test“ den Terminplaner für „Test-Website“ aktivieren; im Portal (Login
des Testkunden) Vorlage wählen, Leistungen/Zeiten pflegen; Widget in `WEBklar/test-website` einbauen
(Push → Deploy) inkl. Datenschutz-Absatz-Entwurf; Testbuchungen mit kenso.gri@gmail.com: bestätigen,
Erinnerung, absagen, verschieben, widerrufen, Anfrage annehmen/ablehnen, Kalender-Abo in Google.
5. L5 Nutzertest; Aufräumen der Testbuchungen nach Wunsch.
---
## TP2 Verkauf (eigener Plan nach TP1)
- **Fragebogen** im Portal (Kachel „Terminplaner einrichten · 149 €“, nur mit Wartungs-Abo): 8 Schritte
(Branche/Vorlage mit Gesundheits-Ausschluss · wer wird gebucht + Namen · Leistungen · Zeiten, Bundesland,
Urlaub · Regeln + sofort/Anfrage · Gastfelder · Benachrichtigungen, Anrede, Erinnerung · Website: wo
buchen, Knopf/inline, Gestaltungswunsch, Ausweichtelefon, Links Impressum/Datenschutz), Zwischenspeichern
als Entwurf in `termineConfig`. Ergebnis = dieselbe Einstellung wie TP1 + Einbauwünsche.
- **Bestellung**: Zusammenfassung, Haken Leistungsbeschreibung + AVV Terminplaner (Fassung), „kein
Gesundheitsbereich“, Widerrufs-Zustimmung für Verbraucher (§ 356 Abs. 5 Nr. 2 BGB), Knopf
„Zahlungspflichtig bestellen · 149 €“ ⇒ Stripe Checkout (einmalig, Katalogeintrag `termineSetup`
14900 Cent). Verallgemeinerung des Funktionen-Checkouts: Register `config.features` (Preis,
Einwilligungstext, Zeiteinschätzung, Ticket-Text, Mails, Rücksprung-Hash) statt fester News-Werte;
K15/K16/T16 mit Platzhalter `{{funktion}}`; Widerrufsformular-Beschriftung.
- **Ticket mit Einbauanleitung** (`services/termine/einbauanleitung.js`, deterministisch aus der
Einstellung): Kurzfassung in `ticket.details` (≤ 8000 Zeichen), vollständig im WOMS-Reiter Funktionen
(Kopieren-Knopf) über `GET /api/team/funktionen/:customerId/termine/anleitung`. Inhalt: Modell,
Leistungen, Ressourcen + Zeiten, Regeln, Gastfelder, Benachrichtigungen, Einbau (Snippet mit siteKey,
Modus, CSS-Variablen-Vorschlag, Stelle auf der Website), Datenschutz-Absatz, Abnahme-Checkliste,
Abrechnung („Einrichtung bezahlt, nicht auf Abo-Stunden“).
- **Abo-Kopplung**: Bestellen nur mit laufendem Wartungs-Abo (gemeinsame Prüfung aus `analyse.js`
herausgelöst); täglicher Abgleich: Abo beendet ⇒ `disabled`, Mail an den Kunden, Löschung nach 30 Tagen.
- **Rechtstexte**: Leistungsbeschreibung Terminplaner, AVV Terminplaner (Geschwister des AVV
Website-Analyse), Datenschutz 6c, Gästetexte final, Karten in WOMS „Rechtliches“; Prüfrunde wie bei der
Analyse; danach `TERMINE_ENABLED=on`.
## TP3 Online-Zahlung (eigener Plan nach TP2)
- **Stripe Connect**, verbundene Konten mit Standard-Verhalten (`controller`: volles Dashboard,
`losses.payments=stripe`, `fees.payer=account`, `requirement_collection=stripe`), Fähigkeit
`card_payments`, Rahmenvertrag `full`; Einrichtung per **Account Link** (von Stripe gehostet); Status über
`account.updated` + Rücksprung. Sandbox-Plattformeinrichtung ist erledigt (Testkonto zeigt
Negativsalden/Anforderungen/Gebühren = Stripe); Live-Plattformeinrichtung vor dem Livegang.
- **Direct Charges** über von Stripe gehostetes Checkout auf dem Konto des Betriebs (`Stripe-Account`),
`mode: payment`, nur Festpreise ≥ 0,50 €, keine Plattformgebühr, SEPA ausgeschlossen (verzögert),
PayPal nicht verfügbar (Stripe unterstützt es bei Direct Charges nicht), `expires_at` ≥ 30 Minuten ⇒
Haltezeit bei Online-Zahlung = Sitzungsdauer + 2 Minuten.
- Zahlungsmodus je Betrieb `vorort | online | beides` (beides ⇒ Frage „Wie möchten Sie bezahlen?“ ohne
Vorauswahl, gleicher Preis); online nur, wenn das verbundene Konto `charges_enabled` hat, sonst
automatisch zurück auf vor Ort + Hinweis.
- Ablauf online (Vorschlag, in TP3 zu bestätigen): Widget „Zahlungspflichtig buchen“ ⇒ Bestätigungslink
⇒ Weiterleitung zu Stripe ⇒ Zahlung ⇒ `bestaetigt` per Webhook; späte Zahlung bei vergebenem Slot ⇒
sofortige Erstattung.
- **Eigener Connect-Webhook** (eigenes Secret, `event.account` + `livemode` prüfen, idempotent je Sitzung),
nicht in `routes/abo.js`.
- **Erstattungsregeln** (`recherche-recht-zahlung.md` 3.5): Betrieb sagt ab ⇒ voll; Gast in der Frist ⇒
voll; Widerruf ⇒ voll binnen 14 Tagen; zu spät/nicht erschienen ⇒ nach Regel des Betriebs; fehlendes
Guthaben ⇒ „Erstattung ausstehend“ im Portal + Mail.
- Rechtliches: Stripe-Absatz für die Datenschutzerklärung des Betriebs, AVV/Leistungsbeschreibung
ergänzen, keine Kassenfunktion, kein Rechnungsdruck.
## Nicht Teil (V1)
Gruppen/Kurse/Tischreservierung · Person + Platz gleichzeitig · Mehrere Leistungen in einer Buchung ·
Varianten (30/60/90) · Warteliste · SMS · Zwei-Wege-Kalender-Sync (Google/Outlook) · Anzahlungen ·
Kundenkonten für Gäste · Bewertungs- oder Werbemails · Mehrsprachigkeit · Gesundheitsbereich ·
Wechsel des Portal-Containers auf Node 24 (eigene Aufgabe; Node 20 ist seit 30.04.2026 ohne Pflege).
## Offene Punkte
1. Spike Bulk-Create (TP1, Aufgabe 1) – Freigabe für Wegwerf-Collection in der Produktions-Appwrite.
2. Rechtstexte: alle als Entwurf; Prüfung vor TP2-Livegang (wie Website-Analyse), u. a. Einstufung
Massage/Fotograf/Coaching als Widerrufsfälle und ob der Bestätigungslink die Bestellung ist.
3. TP3: Stripe-Live-Plattformeinrichtung durch den Nutzer; Ablauf „Bestätigungslink vor Zahlung“ bestätigen.