Bisher verglich die Zuordnung ausschliesslich Namen: der Parser las die Adresse des Zahlers gar nicht aus, und die Suche kannte nur paypal_name und display_name. Eine Zahlung von genau der Adresse, die beim Mitglied hinterlegt ist, blieb deshalb liegen. Die Adresse wird jetzt ausgelesen (neue Spalte paypal_payments.payer_email), in der Warteschlange angezeigt und zuerst geprueft - sie ist im Mandanten eindeutig und aendert sich nicht, wenn jemand bei PayPal anders heisst. Der heikle Teil ist das Aussortieren: In einer weitergeleiteten Mail stehen mehrere Adressen. Die falsche zu nehmen wuerde fremdes Geld dem Mitglied hinter dieser Adresse gutschreiben - typischerweise dem Kassenwart. Deshalb fallen paypal.*-Adressen und die eigene Eingangsadresse raus (geprueft gegen die tatsaechliche Empfaengeradresse, Plus-Adressierung ignoriert), und es zaehlt nur eine Adresse in unmittelbarer Naehe des Zahlernamens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
253 lines
12 KiB
Markdown
253 lines
12 KiB
Markdown
# Back-Office (Platform-Admin)
|
||
|
||
Stand: 2026-07-16
|
||
|
||
Ergänzung außerhalb der ursprünglichen M0–M9-Meilensteine: ein
|
||
Betreiber-Zugang, der mandantenübergreifend alle Kunden einsehen kann –
|
||
getrennt von der normalen, streng mandantengebundenen Rollenlogik.
|
||
|
||
## Design-Entscheidung: getrennt von `tenant_memberships`
|
||
|
||
Platform-Admin-Rechte sind bewusst **kein** Teil von
|
||
`saas_user_has_role()`/`tenant_memberships`, sondern eine eigene, komplett
|
||
separate Tabelle `platform_admins` (`user_id` → `users.id`). Das ist
|
||
kein Zufall: Alle bestehenden Mandanten-Seiten und die automatisierten
|
||
Isolationstests (`scripts/check-m8-tenant-isolation.php`,
|
||
`scripts/check-m8-role-matrix.php`) verlassen sich darauf, dass ein
|
||
Login niemals automatisch mandantenübergreifenden Zugriff bekommt. Ein
|
||
Platform-Admin-Flag direkt in `tenant_memberships` oder `users` einzubauen
|
||
hätte dieses Fundament unterlaufen können. Stattdessen prüft
|
||
ausschließlich der neue Code-Pfad in `app/platform-admin.php`
|
||
(`app_require_platform_admin()`) auf Back-Office-Seiten die
|
||
Platform-Admin-Eigenschaft; alle bestehenden Seiten sind unverändert und
|
||
bleiben strikt mandantengebunden.
|
||
|
||
## Umgesetzte Dateien
|
||
|
||
```text
|
||
database/migrations/0013_saas_platform_admins.sql
|
||
app/platform-admin.php
|
||
scripts/grant-platform-admin.php
|
||
backoffice.php
|
||
backoffice-mandant.php
|
||
backoffice-export.php
|
||
```
|
||
|
||
## Erster Platform-Admin
|
||
|
||
Es gibt bewusst **keine Weboberfläche**, um den ersten Platform-Admin zu
|
||
setzen – das wäre ein öffentlich erreichbarer "werde Admin"-Endpunkt und
|
||
ein erhebliches Risiko. Stattdessen: Person registriert sich normal als
|
||
Mandant (oder nutzt einen bestehenden Login), danach per Shell-Zugriff auf
|
||
dem Server:
|
||
|
||
```bash
|
||
php scripts/grant-platform-admin.php person@example.com
|
||
```
|
||
|
||
Das Skript prüft, dass der Account existiert, und legt nur dann den
|
||
`platform_admins`-Eintrag an. Zugang entziehen aktuell nur per direktem
|
||
`DELETE FROM platform_admins WHERE user_id = ?` (keine UI dafür – aus
|
||
demselben Grund wie beim Setzen).
|
||
|
||
## Funktionsumfang
|
||
|
||
- `backoffice.php`: Übersicht aller Mandanten mit Kürzel, Status,
|
||
Erstelldatum, Teilnehmerzahl (aktiv/gesamt) und Saldensumme.
|
||
- `backoffice-mandant.php?tenant_id=X`: Ansicht eines einzelnen Mandanten
|
||
– Einstellungen, Mitglieder mit Rolle, letzte 20 Buchungen, letzte 20
|
||
Admin-Aktionen, dazu die Funktions-Schalter (siehe unten). Absichtlich
|
||
**kein** Schreibzugriff auf die eigentlichen Mandantendaten (Buchungen,
|
||
Mitglieder, Einstellungen des Kunden) von hier aus, um das Risiko einer
|
||
versehentlichen Fremdänderung auszuschließen.
|
||
- `backoffice-export.php`: nutzt dieselbe `app_export_tenant_data()`-
|
||
Funktion wie der Selbstbedienungs-Export, aber ausgelöst durch den
|
||
Platform-Admin für einen beliebigen Mandanten.
|
||
|
||
## Funktions-Schalter je Mandant
|
||
|
||
Stand: 2026-08-17. Zentralstelle des Betreibers, um einzelne Funktionen
|
||
pro Mandant freizuschalten oder zu sperren – gepflegt in
|
||
`backoffice-mandant.php`, umgesetzt in `app/features.php` und der Tabelle
|
||
`tenant_features` (Migration `0025_tenant_features.sql`).
|
||
|
||
Schaltbar sind: FAQ-Seite, Striche selbst eintragen, Kaffeeliste als PDF,
|
||
PayPal-Zahlungseingang, CSV-Import, Info-Mails und Erinnerungen,
|
||
Jahresabschluss, Datenexport, eigenes Design.
|
||
|
||
Design-Entscheidungen:
|
||
|
||
- **Getrennt von `tenant_settings`.** Dort stellt der Kunde ein, *wie*
|
||
eine Funktion arbeitet; hier entscheidet der Betreiber, ob sie ihm
|
||
überhaupt zur Verfügung steht. Beide Prüfungen stehen nebeneinander,
|
||
z. B. beim Selbsteintrag: `self_entry_enabled` (Kunde) **und**
|
||
Feature `self_entry` (Betreiber).
|
||
- **Ohne Zeile = freigeschaltet.** `tenant_features` hält nur die
|
||
Abweichungen vom Standard; ein neuer Mandant bekommt automatisch den
|
||
vollen Funktionsumfang, ohne dass der Betreiber etwas einschalten muss.
|
||
Fehlt die Tabelle (Migration noch nicht ausgerollt), bleibt ebenfalls
|
||
alles freigeschaltet – eine ausstehende Migration darf keine Funktion
|
||
sperren.
|
||
- **Gesperrt heißt unsichtbar.** Die betroffenen Menüpunkte und
|
||
Schaltflächen verschwinden (`footer.php`, `kaffeeliste.php`,
|
||
`einzahlung.php`, `konto.php`); ein direkter Aufruf der Seite endet mit
|
||
einem Hinweis statt mit einem Fehler. Die POST-Verarbeitung der
|
||
betroffenen Seiten hängt an derselben Prüfung, eine gesperrte Funktion
|
||
lässt sich also auch nicht per Formular-POST auslösen.
|
||
- **Abgeschaltet vom Kunden heißt ebenfalls unsichtbar.** Für Funktionen
|
||
mit eigenem Schalter in den Mandant-Einstellungen zieht `footer.php`
|
||
(und die Anleitung) `app_feature_available()` heran: erst die
|
||
Freischaltung durch den Betreiber, dann der Schalter des Kunden. Die
|
||
Zuordnung steht in `app_feature_tenant_switch()` – aktuell
|
||
`paypal_inbox` → `paypal_enabled` und `self_entry` →
|
||
`self_entry_enabled`. Wer PayPal nicht als Zahlungsweg anbietet, sieht
|
||
den Menüpunkt „PayPal-Zahlungen" also gar nicht erst; beim direkten
|
||
Aufruf erscheint statt des Betreiber-Hinweises
|
||
`app_feature_tenant_notice_html()` mit dem Weg zurück in die
|
||
Mandant-Einstellungen.
|
||
- **Ausnahme: offene Warteschlange.** Schaltet ein Mandant PayPal ab,
|
||
während noch nicht zugeordnete Zahlungen liegen, wären diese weder über
|
||
Menü noch über URL erreichbar. `paypal_inbox_accessible()`
|
||
(`app/paypal-inbox.php`) hält Menüpunkt und Seite deshalb offen, solange
|
||
`paypal_count_unmatched()` etwas findet – mit einem Hinweis auf der
|
||
Seite. Ist die Warteschlange abgearbeitet, schließt sie sich von selbst.
|
||
Eine Sperre durch den Betreiber sticht auch diese Ausnahme.
|
||
- Jede Änderung landet als `backoffice.features_updated` im Audit-Log des
|
||
betroffenen Mandanten – wie jeder andere Back-Office-Zugriff auch.
|
||
|
||
Das Back-Office ist außerdem jetzt für Platform-Admins im Menü verlinkt
|
||
(Gruppe „Betreiber" in `footer.php`); vorher war es nur über die direkte
|
||
URL erreichbar.
|
||
|
||
## Eigenes Design je Mandant
|
||
|
||
Stand: 2026-08-17, hängt am Feature `branding`. Der Kunde hinterlegt in
|
||
`mandant-einstellungen.php` eine Akzentfarbe und ein Logo:
|
||
|
||
- Die Farbe (`tenant_settings.brand_color`, validiert als `#rrggbb`) wird
|
||
in `header.php` als kleines Stylesheet eingebettet
|
||
(`app/branding.php`), das die Akzentfarbe des Templates überschreibt.
|
||
Eine eigene CSS-Datei je Mandant wäre ein zusätzlicher Request für
|
||
wenige Zeilen.
|
||
- Das Logo (`tenant_settings.brand_logo`) liegt wie das PDF-Wasserzeichen
|
||
in `var/tenant_logos` und ist damit **nicht** direkt per URL abrufbar –
|
||
sonst wäre jedes Kundenlogo unter einer ratbaren Adresse öffentlich.
|
||
Ausgeliefert wird es über `tenant-logo-anzeigen.php`, das ausschließlich
|
||
das Logo des Mandanten des angemeldeten Nutzers ausgibt.
|
||
- Wasserzeichen (Ausdruck) und Marken-Logo (Oberfläche) sind getrennte
|
||
Dateien mit eigenem Namenspräfix (`logo_` bzw. `brand_`).
|
||
- Sperrt der Betreiber `branding`, verschwindet der Abschnitt aus den
|
||
Einstellungen und die Oberfläche fällt aufs Standarddesign zurück. Die
|
||
gespeicherte Farbe bleibt erhalten und gilt nach einer erneuten
|
||
Freischaltung wieder.
|
||
|
||
## Verhältnis zum Selbstbedienungs-Export (`datenexport.php`)
|
||
|
||
Bewusste Entscheidung: Der bestehende Selbstbedienungs-Export für
|
||
Mandanten-Owner/Admin (`datenexport.php`, aus M8) bleibt zusätzlich
|
||
bestehen, statt ihn durch das Back-Office zu ersetzen. Begründung: Der
|
||
Mandant ist im Auftragsverarbeitungs-Verhältnis der Verantwortliche
|
||
(Controller), der Betreiber dieser App der Auftragsverarbeiter
|
||
(Processor). Art. 15/20 DSGVO geben den betroffenen Personen ein
|
||
Auskunfts-/Portabilitätsrecht, und Art. 28 DSGVO verpflichtet den
|
||
Auftragsverarbeiter, den Verantwortlichen bei der Erfüllung dieser
|
||
Rechte zu unterstützen sowie Daten am Vertragsende zurückzugeben – ein
|
||
jederzeit verfügbarer Selbstbedienungs-Export erfüllt genau das. Das
|
||
Back-Office ergänzt das um einen Betreiber-seitigen Zugriff für Support,
|
||
Migrationen oder eigene Nachweispflichten, ersetzt den
|
||
Mandanten-Selbstexport aber nicht.
|
||
|
||
## Audit-Trail
|
||
|
||
Jede Back-Office-Ansicht und jeder Back-Office-Export wird im Audit-Log
|
||
**des betroffenen Mandanten** protokolliert
|
||
(`backoffice.tenant_viewed`, `backoffice.tenant_exported`, mit dem
|
||
Platform-Admin als `actor_user_id`). Ein Mandant sieht damit über sein
|
||
eigenes Protokoll (`mandant-einstellungen.php`), wann der Betreiber auf
|
||
seine Daten zugegriffen hat – Transparenzpflicht statt stiller
|
||
Einsicht.
|
||
|
||
## Prüfstatus
|
||
|
||
- `scripts/check-m8-tenant-isolation.php`: weiterhin grün mit 10
|
||
Assertions – das Back-Office berührt die geprüften Pfade nicht.
|
||
- `scripts/check-m8-role-matrix.php`: weiterhin grün mit 55 Assertions.
|
||
- HTTP-Smoke: grün mit 30 geprüften Seiten (zwei neue Login-Schutz-Checks
|
||
für `backoffice.php`/`backoffice-mandant.php`).
|
||
- Golden Master: grün mit 104 Assertions.
|
||
|
||
Live getestet: eigens angelegter Test-Mandant, per CLI-Skript zum
|
||
Platform-Admin gemacht, Back-Office-Übersicht zeigt korrekt alle
|
||
Mandanten (inklusive der echten Bestandsmandanten), Detailansicht und
|
||
Export für den eigenen Test-Mandanten funktionieren, Audit-Log-Einträge
|
||
korrekt geschrieben. Kritischer Isolationstest bestätigt: derselbe
|
||
Platform-Admin sieht auf regulären Mandanten-Seiten (`kaffeeliste.php`)
|
||
weiterhin ausschließlich seinen eigenen Mandanten. Ein zweiter,
|
||
eingeloggter, aber nicht privilegierter Testnutzer bekommt auf
|
||
`backoffice.php` korrekt `403 Forbidden`. Alle Testdaten anschließend
|
||
vollständig entfernt.
|
||
|
||
## Eingehende PayPal-Mails bei abgeschalteter Funktion
|
||
|
||
Stand: 2026-08-21. Die Mail-Verarbeitung
|
||
(`scripts/fetch-paypal-payments.php` → `paypal_reconcile()`) prüft
|
||
dieselben zwei Ebenen wie das Menü. Ist PayPal für den Mandanten
|
||
abgeschaltet — vom Betreiber (`paypal_inbox`) oder vom Mandanten selbst
|
||
(`paypal_enabled`) —, wird eine eingehende Zahlung **geparkt**: sie wird
|
||
gespeichert (Status `unmatched`, Ergebnis `parked`), aber nicht
|
||
automatisch gutgeschrieben.
|
||
|
||
- **Warum nicht wegwerfen:** eine echte Zahlung würde unbemerkt
|
||
verschwinden; das Geld ist ja trotzdem angekommen.
|
||
- **Warum nicht buchen:** es soll gerade nichts über PayPal laufen — die
|
||
Entscheidung gehört dem Mandanten.
|
||
- Die Zuordnungsseite bleibt für offene Zahlungen erreichbar (siehe
|
||
`paypal_inbox_accessible()`), der Mandant sieht die geparkte Zahlung
|
||
also und kann sie zuordnen oder abhaken.
|
||
|
||
### PayPal-Name lernen
|
||
|
||
Die automatische Zuordnung verlangt **exakte** Übereinstimmung des
|
||
Zahlernamens mit `display_name` oder `paypal_name` des Mitglieds
|
||
(`imports_find_participant()`, genau ein Treffer). Heißt jemand bei PayPal
|
||
anders — Zweitname, Firmenkonto, anderer Nachname —, landet jede Zahlung in
|
||
der Warteschlange, auch die zehnte.
|
||
|
||
`paypal_assign_payment()` hinterlegt den Zahlernamen deshalb beim Mitglied,
|
||
sobald eine Zahlung von Hand zugeordnet wurde; die nächste Zahlung desselben
|
||
Zahlers wird automatisch gebucht. Bewusst zurückhaltend
|
||
(`paypal_remember_payer_name()`): ein bereits gepflegter PayPal-Name wird nie
|
||
überschrieben, ein Name gleich dem Anzeigenamen bringt nichts, und passt der
|
||
Name auch auf ein anderes Mitglied, wird nichts gelernt — sonst wäre die
|
||
Zuordnung mehrdeutig und würde gar nicht mehr greifen. Was gelernt wurde,
|
||
steht in der Erfolgsmeldung und im Audit-Log
|
||
(`paypal_import.manual_assigned`, Feld `learned_paypal_name`).
|
||
|
||
### Zuordnung über die Mailadresse
|
||
|
||
Stand: 2026-08-21. `paypal_match_participant()` prüft in dieser Reihenfolge:
|
||
|
||
1. **Mailadresse des Zahlers** gegen `participants.email_norm` — im
|
||
Mandanten eindeutig (Unique Key auf `tenant_id + email_norm`) und
|
||
unabhängig davon, wie jemand bei PayPal heißt.
|
||
2. Zahlername gegen `paypal_name`/`display_name` (genau ein Treffer).
|
||
3. Mitteilung des Zahlers gegen dieselben Felder.
|
||
|
||
Die Adresse zieht `paypal_extract_payer_email()` aus der Mail und
|
||
`paypal_payments.payer_email` (Migration `0028`) hält sie fest; in der
|
||
Warteschlange steht sie unter dem Zahlernamen.
|
||
|
||
Das Heikle daran ist nicht das Finden, sondern das Aussortieren: In einer
|
||
weitergeleiteten Mail stehen mehrere Adressen — die von PayPal, die
|
||
Eingangsadresse der Kaffeeliste, je nach Weiterleitung die des
|
||
ursprünglichen Empfängers. Würde davon die falsche genommen, bekäme das
|
||
Mitglied hinter dieser Adresse fremdes Geld gutgeschrieben. Deshalb:
|
||
|
||
- alles auf `paypal.*` fällt raus,
|
||
- die eigene Eingangsadresse fällt raus — geprüft gegen die tatsächliche
|
||
Empfängeradresse der Mail (`paypal_same_mailbox()`, Plus-Adressierung
|
||
wird ignoriert), nicht nur gegen `PAYPAL_INBOX_BASE`,
|
||
- übrig bleibt nur eine Adresse, die **nahe am Namen des Zahlers** steht;
|
||
ist keine in Reichweite, wird keine genommen und es bleibt beim
|
||
Namensvergleich.
|