# M5 App-Kern

Stand: 2026-07-14 (fortgeführt)

M5 stellt die operativen App-Seiten schrittweise auf das neue tenant-sichere
Modell um. Der Start erfolgt bewusst read-only, damit Summen, Links und
Darstellung gegen die bestehende Legacy-Oberfläche vergleichbar bleiben.

## Ziel

- Kernseiten aus `app/ledger.php` lesen lassen.
- Bestehendes Tabellenlayout und Bediengefühl erhalten.
- Schreibende Legacy-Flows erst nach stabiler Leseparität umstellen.
- Tenant-Kontext serverseitig bestimmen, nicht aus Formularfeldern.

## Umgesetzte Schritte

Umgesetzte Dateien:

```text
kaffeeliste.php
teilnehmerauswertung.php
index.php
```

Umgesetzter Umfang `kaffeeliste.php`:

- Die Gesamtübersicht liest aktive Teilnehmer aus
  `ledger_fetch_participant_summaries()`.
- Angezeigte Werte bleiben fachlich gleich: aktueller Stand, Gesamtausgabe,
  Gesamtstriche und Gesamteinzahlungen.
- Links zur bestehenden `teilnehmerauswertung.php` bleiben über
  `participants.legacy_mitarbeiter_id` erhalten.
- Owner, Admin und Treasurer dürfen die SaaS-Ansicht lesen.
- Der lokale Legacy-/Dev-Admin-Fallback nutzt nur ohne SaaS-Session den
  Default-Tenant.
- Die Sidebar zeigt Legacy-Schreibseiten weiterhin nur für Legacy-Admins;
  Ledger-Leseansichten sind für passende SaaS-Rollen sichtbar.
- Export, letzte Einträge, CSV-Upload und Info-Mail bleiben noch Legacy-Flows.

Umgesetzter Umfang `teilnehmerauswertung.php`:

- Die Detailauswertung liest Teilnehmerdaten über
  `ledger_fetch_participant_summary_by_legacy_id()`.
- Die Route bleibt kompatibel zu bestehenden Links:
  `teilnehmerauswertung.php?user_id=<legacy_mitarbeiter_id>`.
- Gesamtwerte, Jahresübersicht und letzte Buchungen werden aus
  `ledger_entries` geladen.
- PayPal-Anzeige nutzt die neuen Tenant-Settings statt `kl_config`.
- Owner, Admin und Treasurer dürfen die SaaS-Ansicht lesen; der lokale
  Legacy-/Dev-Admin-Fallback nutzt weiterhin den Default-Tenant.

Ergänzte Ledger-Helfer:

- `ledger_fetch_default_tenant()`
- `ledger_fetch_participant_summary_by_legacy_id()`
- `ledger_mirror_legacy_consumption()`
- Filter `legacy_mitarbeiter_ids` in `ledger_fetch_participant_summaries()`

Umgesetzter Umfang `index.php`:

- Das persönliche Dashboard liest den aktuellen Stand, Jahreswerte und letzte
  Buchungen aus `ledger_entries`.
- Die Teilnehmerzuordnung erfolgt über den aktuellen SaaS-User oder im lokalen
  Legacy-/Dev-Fallback über die E-Mail-Adresse des Legacy-Mitarbeiters.
- PayPal-Anzeige und Preis pro Strich nutzen die Tenant-Settings.
- Eigene Web-Striche bleiben mit dem bestehenden Legacy-Datensatz kompatibel
  und werden direkt ins Ledger gespiegelt.

## Fortsetzung: Schreibseiten und Storno

Umgesetzte Dateien:

```text
stricheintragen.php
einzahlung.php
letzteneintraege.php
app/ledger.php
scripts/check-m4-ledger-migration.php
```

Umgesetzter Umfang `stricheintragen.php` und `einzahlung.php`:

- Beide Sammelerfassungsseiten waren zuvor ohne jede Zugriffsprüfung
  erreichbar (nur CSRF-Schutz, kein `checkKaffeelisteAdmin`- oder Rollen-Check).
  Das ist jetzt behoben: Zugriff erfordert SaaS-Rolle `owner`, `admin` oder
  `treasurer`, oder im Legacy-/Dev-Fallback `checkKaffeelisteAdmin` mit dem
  Default-Tenant.
- Jede eingetragene Legacy-Zeile (`kl_Kaffeeverbrauch` beziehungsweise
  `kl_Einzahlungen`) wird innerhalb derselben Transaktion sofort über
  `ledger_mirror_legacy_consumption()` beziehungsweise die neue
  `ledger_mirror_legacy_payment()` ins Ledger gespiegelt. Schlägt die
  Spiegelung fehl, wird die gesamte Sammelerfassung zurückgerollt statt
  teilweise gespeichert zu werden.
- Ein vorbestehender Bug wurde nebenbei behoben: Bei einer POST-Anfrage blieb
  `$sqlMitarbeiter` unbelegt, was einen PHP-Warning erzeugte und die
  Mitarbeiterliste nach dem Speichern leer ließ.

Umgesetzter Umfang `letzteneintraege.php`:

- Der Seitenzugriff nutzte bisher ausschließlich den Legacy-Check
  `checkKaffeelisteAdmin`, wodurch neu registrierte SaaS-Mandanten (ohne
  Legacy-`kl_Mitarbeiter`-Zeile) die Seite nie hätten nutzen können. Jetzt gilt
  dieselbe Rollen-/Fallback-Logik wie in `kaffeeliste.php`.
- Löschen erzeugt keinen harten Delete im Ledger mehr. Neue Funktion
  `ledger_void_entry_by_legacy_id()` setzt `voided_at` auf dem gespiegelten
  Ledger-Eintrag, bevor die Legacy-Zeile aus `kl_Einzahlungen` oder
  `kl_Kaffeeverbrauch` entfernt wird (in einer Transaktion). Der Ledger-Eintrag
  bleibt damit für die Revision erhalten; alle Lesepfade filtern bereits
  konsistent auf `voided_at IS NULL`.
- Zwei nie aufgerufene Funktionen (`berechneGesamtausgabe`,
  `berechneGesamtstriche`, `berechneGesamteinzahlungen`) wurden als toten Code
  entfernt.

Angepasstes Check-Skript:

- `scripts/check-m4-ledger-migration.php` ging bisher davon aus, dass jeder
  gespiegelte Ledger-Eintrag eine noch existierende Legacy-Zeile hat. Das ist
  durch das Storno-Modell nicht mehr korrekt: Ein stornierter Eintrag hat
  absichtlich keine Legacy-Zeile mehr, bleibt aber im Ledger stehen. Die
  Prüfungen filtern jetzt zusätzlich auf `voided_at IS NULL`, sodass echte
  Dateninkonsistenzen weiterhin erkannt werden, stornierte Einträge aber nicht
  mehr als Fehler zählen.

Alle drei Flows wurden gegen die Remote-Dev-Datenbank live per HTTP getestet
(Sammeleintrag Striche, Sammeleinzahlung, Storno beider Buchungsarten über
`letzteneintraege.php`) und anschließend wieder auf den Ausgangsstand
zurückgesetzt.

## Fortsetzung: Mitgliederverwaltung

Umgesetzte Dateien:

```text
mitarbeiterverwalten.php
app/ledger.php
```

Umfang:

- Zugriff nutzte bisher ausschließlich `checkKaffeelisteAdmin` und hätte neue
  SaaS-Mandanten ohne Legacy-Mitarbeiterzeile ausgeschlossen. Jetzt gilt die
  gleiche Rollen-/Fallback-Logik wie in `kaffeeliste.php`, mit den Rollen
  `owner` und `admin` (nicht `treasurer`, da Mitgliederpflege sensibler ist
  als reine Zahlungsvorgänge).
- Anlegen, Bearbeiten, Aktivieren und Deaktivieren schreiben weiterhin zuerst
  in `kl_Mitarbeiter` und spiegeln danach in derselben Transaktion über die
  neue Funktion `ledger_mirror_legacy_participant()` nach `participants`.
  Ledger-Ansichten (Dashboard, Kaffeeliste, Teilnehmerauswertung) sehen neue
  oder geänderte Mitglieder damit sofort, ohne auf einen manuellen Lauf von
  `scripts/backfill-default-tenant.php` zu warten.
- Eine gespeicherte-XSS-Lücke wurde behoben: Name und E-Mail wurden beim
  Bearbeiten-Formular und in der Mitgliederliste bisher ungeschützt
  ausgegeben (nur `paypalname` war escaped). Beide Stellen nutzen jetzt
  `saas_html()`.
- Live gegen die Dev-Datenbank getestet: Anlegen mit einem
  HTML/Skript-Payload im Namen (korrekt escaped in der Ausgabe, korrekt
  gespiegelt in `participants`), Deaktivieren (spiegelt `active = 0`).

Bewusst nicht umgesetzt:

- Der Haken "Administrator" bleibt ein reines Legacy-Feld auf
  `kl_Mitarbeiter.admin` und wird nicht automatisch in eine
  `tenant_memberships`-Rolle übersetzt. Das würde einen Login-Account ohne
  Einladung/Passwort-Setzung anlegen, was ein eigenes, sauber zu
  bauendes Einladungs-Flow braucht (E-Mail-Versand, Token, Passwortsetzung).
  Admin-Rollen für neue SaaS-Nutzer laufen bis dahin weiter über
  `scripts/backfill-default-tenant.php` oder die Registrierung.

## Fortsetzung: Hinweise und rollenbasierter Zugang

Umgesetzte Dateien:

```text
database/migrations/0007_saas_notices.sql
app/notices.php
hinweise.php
header.php
mitarbeiterverwalten.php (grundlegend neu)
app/ledger.php (Participant-CRUD)
app/saas-auth.php (Zugangsvergabe/-entzug)
app/saas-mail.php (Einladungsmail)
```

### Hinweise auf Notices umgezogen

- Neue Tabelle `notices` (tenant-scoped, mit `deleted_at` für Soft-Delete)
  ersetzt `kl_hinweise` als aktive Datenquelle. `kl_hinweise` bleibt
  unangetastet als Golden-Master-Referenz.
- Die Migration übernimmt einmalig alle zum Migrationszeitpunkt noch
  gültigen `kl_hinweise`-Einträge in `notices` des Default-Mandanten, damit
  keine sichtbaren Banner verloren gehen.
- `hinweise.php` liest/schreibt jetzt ausschließlich `notices`, tenant-scoped
  mit dem gleichen Rollen-/Legacy-Fallback-Muster wie andere Admin-Seiten.
  Löschen ist ein Soft-Delete (`deleted_at`), kein Hard-Delete.
- `header.php` (Banner-Anzeige auf allen App-Seiten) löst den Mandanten
  jetzt selbst auf (SaaS-Session oder Default-Tenant-Fallback) und zeigt den
  aktuell gültigen Hinweis dieses Mandanten statt eines global-legacy
  Hinweises.

### Mitgliederverwaltung: participant-nativ mit Rollen-Zugang

`mitarbeiterverwalten.php` wurde grundlegend umgebaut, nicht mehr additiv
gepatcht:

- Datenquelle ist jetzt `participants` (tenant-scoped), nicht mehr die
  global unscoped `kl_Mitarbeiter`-Tabelle. Das behebt nebenbei ein
  Datenleck: Da `kl_Mitarbeiter` keine `tenant_id` hat, hätte jeder
  SaaS-Mandant mit Owner/Admin-Rolle über die vorherige Version dieser
  Seite die komplette Mitgliederliste des Default-Mandanten sehen und
  bearbeiten können.
- Für den Default-Mandanten wird weiterhin dual-write nach
  `kl_Mitarbeiter` betrieben (`ledger_create_participant()`,
  `ledger_update_participant()`, `ledger_set_participant_active()`), damit
  `stricheintragen.php` und `einzahlung.php` (deren Mitarbeiter-Picker
  weiterhin direkt aus `kl_Mitarbeiter` liest) neue Mitglieder sofort
  anzeigen. Andere Mandanten haben keine Legacy-Schattentabelle und werden
  rein participant-nativ verwaltet.
- Die Legacy-„Administrator"-Checkbox ist aus dem Anlegen-/Bearbeiten-
  Formular entfernt. Stattdessen gibt es je Mitglied eine eigene
  „Zugang"-Spalte: Rolle wählen (`member`, `treasurer`, `admin`) und
  „Zugang gewähren", oder bei bestehendem Zugang Rolle ändern beziehungsweise
  „Zugang entziehen". `owner` ist über diese UI nicht vergebbar oder
  entziehbar (nur bei Registrierung gesetzt).
- Name und E-Mail eines Mitglieds (`participants.display_name`/`email`)
  bleiben unabhängig vom Login: Ein Mitglied kann ohne jeden Zugang
  existieren (nur für Kaffeeliste/Benachrichtigung), und ein Zugang kann
  jederzeit gewährt oder entzogen werden, ohne den Mitgliedsdatensatz zu
  berühren.
- Zugangsvergabe (`saas_grant_participant_access()`) legt bei Bedarf einen
  `users`-Datensatz ohne Passwort an, setzt/aktualisiert die
  `tenant_memberships`-Rolle und verknüpft `participants.user_id`.
  Anschließend wird ein Einladungslink über den bestehenden
  Passwort-Reset-Mechanismus verschickt (`saas_send_invite_mail()`, gleicher
  Token-Typ `password_reset`, gleiche Zielseite
  `passwort-zuruecksetzen.php` wie beim regulären Passwort-Reset).
- Zugangsentzug (`saas_revoke_participant_access()`) setzt die
  `tenant_memberships`-Zeile auf `status = 'revoked'`, statt sie zu löschen
  oder den `user_id`-Verweis zu entfernen. Der Zugang kann später erneut
  gewährt werden, ohne den Account neu anzulegen. Der Login prüft bereits
  überall auf `tm.status = 'active'`, wodurch ein entzogener Zugang sofort
  wirkt.
- Live gegen die Dev-Datenbank getestet: Mitglied anlegen (inklusive
  Dual-Write-Check), Zugang mit Rolle `treasurer` gewähren, Einladungsmail
  geprüft, Passwort über den Einladungslink gesetzt, erfolgreicher Login,
  Rollenschutz geprüft (kein Zugriff auf `mitarbeiterverwalten.php`, Zugriff
  auf `kaffeeliste.php`), Zugang entzogen, anschließender Login-Versuch
  korrekt mit „kein aktiver Mandant" abgelehnt.

## Fortsetzung: Sammelerfassung tenant-nativ

Umgesetzte Dateien:

```text
stricheintragen.php (Picker + Schreibpfad neu)
einzahlung.php (Picker + Schreibpfad neu)
app/ledger.php (ledger_record_consumption, ledger_record_payment,
  ledger_fetch_participants_by_window_marks)
```

`stricheintragen.php` und `einzahlung.php` lasen ihren Mitarbeiter-Picker
bisher direkt aus der global unscoped `kl_Mitarbeiter`-Tabelle. Für jeden
Mandanten außer dem Default-Mandanten war das faktisch nutzlos: Die Liste
zeigte fremde (Default-Mandanten-)Namen an, und Schreiben schlug wegen des
Tenant-Checks in `ledger_mirror_legacy_*` sicher, aber ohne verständliche
Fehlermeldung fehl.

- Der Picker kommt jetzt aus `ledger_fetch_participant_summaries()`
  (tenant-scoped), Formularfelder verwenden `participant_id` statt
  `MitarbeiterID`.
- Beim Speichern wird pro Teilnehmer entschieden: Hat der Teilnehmer eine
  `legacy_mitarbeiter_id` (Default-Mandant), läuft der Schreibpfad wie
  bisher über `kl_Kaffeeverbrauch`/`kl_Einzahlungen` plus Ledger-Spiegelung.
  Ohne Legacy-Verknüpfung (jeder andere Mandant) wird direkt über die neuen
  Funktionen `ledger_record_consumption()`/`ledger_record_payment()` ins
  Ledger gebucht, ohne Legacy-Tabellen zu berühren.
- Die "Vorderseite"/"Rückseite"-Filter (100-Tage-Listen-Regel, `>= 10`
  beziehungsweise `< 10` Striche im Fenster) bleiben für den Default-
  Mandanten exakt auf der bisherigen Legacy-Logik (Anker: jüngstes Datum in
  `kl_Kaffeeverbrauch`). Andere Mandanten nutzen die neue, Ledger-native
  Fensterprüfung `ledger_fetch_participants_by_window_marks()` mit dem in
  `tenant_settings.sheet_window_days` konfigurierten Fenster.
- Nebenbei zwei Legacy-Bugs behoben: In `einzahlung.php` verlinkten die
  Vorderseite/Rückseite/Alle-Buttons auf `?aktion=...`, ausgewertet wurde
  aber `$_GET['action']` – die Filter griffen also nie. Und in beiden
  Dateien blieb nach einem POST-Speichern `$sqlMitarbeiter` unbelegt
  (behoben bereits in der vorherigen Session, jetzt strukturell nicht mehr
  möglich, da die Anzeige nach dem Speichern regulär über denselben
  Picker-Code läuft).
- Preis-pro-Strich-Vorbelegung kommt jetzt aus `tenant_settings.mark_price_cents`
  statt aus der mandantenunabhängigen `kl_config`-Tabelle. Das ist auch für
  den Default-Mandanten eine Korrektur: `kl_config` wird seit M3 nicht mehr
  über `mandant-einstellungen.php` gepflegt und kann veraltet sein.
- Live gegen die Dev-Datenbank getestet: eigener isolierter Test-Mandant
  angelegt, Picker zeigt nur dessen Teilnehmer, Striche und Einzahlung rein
  Ledger-nativ gebucht (korrekter Saldo), Vorder-/Rückseiten-Schwelle bei
  10 Strichen korrekt ein-/aussortiert, Default-Mandant weiterhin mit
  Dual-Write geprüft. Alle Testdaten anschließend entfernt.

## Noch offen

- Eigene PayPal-/Zahlungsbereich als eigenständiger App-Screen (aktuell nur im
  Dashboard integriert).
- Export, Mail und Jahresprozesse bleiben M6-Themen.

## Nachtrag: FAQ pro Mandant

Umgesetzte Dateien:

```text
database/migrations/0012_saas_faq_entries.sql
app/faq.php
faq.php
app/saas-auth.php (Starter-FAQ bei Registrierung)
landing.php (Link auf die jetzt mandantengebundene faq.php entfernt)
```

Hintergrund: `faq.php` war komplett AOK-spezifischer Inhalt
(Kaffeemaschinen-Bedienung, Milch-/Zucker-Standort, interner
Ansprechpartner) und für jeden Mandanten identisch sichtbar – nicht
brauchbar für andere Kunden.

- Neue Tabelle `faq_entries` (tenant-scoped, Soft-Delete via `deleted_at`,
  `sort_order` für die Reihenfolge).
- Die Migration übernimmt die bisherigen AOK-Inhalte einmalig als erste
  FAQ-Einträge des migrierten Default-Mandanten (`kl_hinweise`-Migration
  als Vorbild). Andere Mandanten sehen diese Inhalte nicht.
- Neue Mandanten bekommen bei der Registrierung automatisch eine kurze,
  generische Starter-FAQ (`faq_seed_default_entries()`, vier Fragen zu
  Einrichtung, Rollen, PayPal, eigenem Stand) – editierbar, nicht bindend.
- `faq.php` ist jetzt für alle angemeldeten Mitglieder eines Mandanten
  lesbar (auch Legacy-Fallback über `checkKaffeelisteAccess`); Anlegen,
  Bearbeiten und Entfernen einzelner Fragen ist auf `owner`/`admin`
  beschränkt (beziehungsweise `checkKaffeelisteAdmin` im Legacy-Fallback).
- Die Landingpage verweist nicht mehr auf `faq.php` (das ist jetzt
  interner, mandantengebundener App-Inhalt), sondern zeigt den
  vollständigen generischen FAQ-Auszug direkt auf der Seite selbst.

Live getestet: Carry-over der AOK-Inhalte für den Default-Mandanten
geprüft, ein frisch registrierter Test-Mandant bekam korrekt die
generische Starter-FAQ (isoliert von den AOK-Inhalten), Anlegen mit
HTML/Skript-Payload im Text (korrekt escaped), Bearbeiten und
Soft-Delete-Löschen erfolgreich geprüft. Testdaten anschließend entfernt.

## Aktueller Prüfstatus

- M4 Ledger-Migration: grün mit 73 Assertions (Storno-fähig).
- M4 Ledger-Service: grün mit 115 Assertions.
- Golden Master: grün mit 104 Assertions.
- HTTP-Smoke: grün mit 23 geprüften Seiten.
- Live-Test der Schreibflows gegen die Dev-DB: Sammelstriche, Sammeleinzahlung
  und Storno beider Buchungsarten erfolgreich geprüft.
- Live-Test der Mitgliederverwaltung gegen die Dev-DB: Anlegen (inklusive
  XSS-Payload-Check), Deaktivieren, participants-Spiegelung erfolgreich
  geprüft.
- Live-Test Hinweise/Notices: Carry-over-Migration, Anlegen mit HTML-Payload
  (korrekt escaped), Soft-Delete, Banner-Anzeige auf Mandant geprüft.
- Live-Test Zugangsvergabe/-entzug: kompletter Flow von Einladung bis
  Login-Sperre nach Entzug erfolgreich geprüft (siehe oben).
- Live-Test Sammelerfassung für Nicht-Default-Mandanten: eigener Test-Tenant,
  Striche/Einzahlung Ledger-nativ gebucht, Vorder-/Rückseiten-Fenster
  geprüft (siehe oben).
