Files
kaffeekasse-saas/docs/backoffice.md
T
clemensandClaude Opus 5 d7de0d92cf PayPal-Zahlungen auch ueber die Mailadresse zuordnen
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>
2026-08-21 22:17:48 +02:00

253 lines
12 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.
# Back-Office (Platform-Admin)
Stand: 2026-07-16
Ergänzung außerhalb der ursprünglichen M0M9-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.