Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-07-buchungskalender.md
T
2026-10-07 13:41:26 +00:00

492 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Buchungskalender (Funktion `unterkunft`) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 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.