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>
12 KiB
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
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:
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 dieselbeapp_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 Featureself_entry(Betreiber). - Ohne Zeile = freigeschaltet.
tenant_featureshä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 inapp_feature_tenant_switch()– aktuellpaypal_inbox→paypal_enabledundself_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-Hinweisesapp_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, solangepaypal_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_updatedim 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 inheader.phpals 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 invar/tenant_logosund ist damit nicht direkt per URL abrufbar – sonst wäre jedes Kundenlogo unter einer ratbaren Adresse öffentlich. Ausgeliefert wird es übertenant-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:
- Mailadresse des Zahlers gegen
participants.email_norm— im Mandanten eindeutig (Unique Key auftenant_id + email_norm) und unabhängig davon, wie jemand bei PayPal heißt. - Zahlername gegen
paypal_name/display_name(genau ein Treffer). - 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 gegenPAYPAL_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.