Files
kaffeekasse-saas/docs/backoffice.md
T

13 KiB
Raw Blame History

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_idusers.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 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_inboxpaypal_enabled und self_entryself_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.phppaypal_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.

Keine Absenderprüfung

Stand: 2026-08-21. Die Verarbeitung prüft nicht, ob die Mail von PayPal kommt. Der Grund ist praktisch: Zahlungsmails erreichen die Kaffeeliste auch über manuelle Weiterleitungen, und dann steht im Absender die Adresse des Weiterleitenden. Eine Absenderprüfung hätte genau diese Mails abgelehnt (not_from_paypal).

Maßgeblich ist allein die Empfangsadresse: ihr Plus-Token bestimmt den Mandanten (paypal_inbox_extract_token()paypal_inbox_resolve_tenant()) und ist damit zugleich das Geheimnis, das den Zugang schützt. Wer die Adresse kennt, kann eine Mail im PayPal-Wortlaut schicken und eine Gutschrift auslösen — die Adresse gehört deshalb nicht in Verteiler, Signaturen oder Tickets. Als Bremsen bleiben: der Text muss als PayPal-Zahlungsmail erkennbar sein, jeder Transaktionscode wird nur einmal verbucht, und ohne eindeutige Zuordnung landet alles in der Warteschlange.

paypal_from_is_paypal() gibt es weiterhin, aber nur noch als Anzeige in scripts/check-imap-support.php („Absender sieht nach PayPal aus").