app/totp.php implementiert RFC 6238 selbst statt per Bibliothek: der
Algorithmus ist ein HMAC plus eine Truncation, und ein zweiter Faktor ist
die letzte Stelle fuer ungepruefte Abhaengigkeiten. Der QR-Code entsteht
aus dem ohnehin vorhandenen TCPDF, damit kein externer Dienst das
Geheimnis sieht.
Beim Login wird die Anmeldung bei aktivem zweitem Faktor nicht
abgeschlossen; der Zwischenzustand gewaehrt keinerlei Zugriff und ist
byte-identisch zu einem unangemeldeten Aufruf. users.totp_last_step
verhindert die Wiederverwendung eines abgefangenen Codes innerhalb seines
Gueltigkeitsfensters. Abschalten verlangt Passwort und Code.
APP_REQUIRE_2FA_FOR_ADMINS macht den Faktor fuer Platform-Admins
verbindlich, per Weiterleitung auf die Einrichtung statt als harte Sperre
- sonst koennte der Schalter den einzigen Admin aussperren. Standard aus.
check-konto-und-mandantenwechsel erwartete beim Login noch das entfernte
Kundenkuerzel-Feld und damit einen direkten Sprung aufs Dashboard; der
Check bildet jetzt den tatsaechlichen Weg ueber die Mandantenauswahl ab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auf der Testumgebung liefen seit Juli 2026 taeglich 10-20 automatisierte
Registrierungen, die zufaellige Namen mit fremden echten E-Mail-Adressen
kombinierten - der Server verschickte Vertrags- und Verifikationsmail an
Unbeteiligte. Das IP-Rate-Limit griff nicht, weil jede Anfrage ueber eine
eigene Rechenzentrums-IP kam.
app/spam-guard.php ergaenzt daher Honigtopf, Zeitfalle, eine globale
Notbremse ueber alle IPs hinweg und eine MX/A-Pruefung der Mail-Domain.
Die ersten drei antworten mit derselben generischen Meldung wie das
Rate-Limit, damit die Antwort nicht verraet, welche Huerde angeschlagen
hat; Treffer landen im Error-Log. Bewusst ohne Captcha, um keinen
Drittanbieter in den Registrierungspfad zu holen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neuer Helfer app/pushover.php meldet Selbstregistrierungen an den
Betreiber. Der Aufruf sitzt in register.php statt in
saas_register_tenant_owner(), damit die check-*-Skripte keine Pushes
ausloesen, und wertet das Ergebnis nicht aus: ein fehlgeschlagener Push
darf die Registrierung nicht abbrechen. Ohne PUSHOVER_TOKEN/PUSHOVER_USER
ist die Funktion still deaktiviert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Pruefung suchte woertlich nach "@paypal.de" oder "@paypal.com". Mails
aus einer Versand-Subdomain (e.paypal.de) oder einer Landesvariante
(paypal.at, paypal.co.uk) fielen damit als not_from_paypal durch. Geprueft
wird jetzt die Domain der Absenderadresse an ihrem Ende - "paypal.de.
beispiel.com" ist damit weiterhin kein PayPal, waere bei einer Textsuche
aber durchgerutscht.
check-imap-support.php listet zusaetzlich die letzten 15 Mails mit
Absender, Empfaengerzeilen und Betreff auf und sagt je Mail, ob der
Absender als PayPal gilt. Damit laesst sich ein not_from_paypal in einem
Lauf klaeren statt zu raten. Rein lesend: OP_READONLY, kein \Seen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Die automatische Zuordnung verlangt exakte Uebereinstimmung des
Zahlernamens mit Anzeigename oder PayPal-Name des Mitglieds. Heisst jemand
bei PayPal anders, landet jede Zahlung in der Warteschlange - auch die
zehnte, obwohl der Fall laengst einmal von Hand geklaert wurde.
Nach einer manuellen Zuordnung wird der Zahlername deshalb beim Mitglied
hinterlegt. Zurueckhaltend: ein gepflegter PayPal-Name wird nie
ueberschrieben, ein Name gleich dem Anzeigenamen bringt nichts, und passt
der Name auch auf ein anderes Mitglied, wird nichts gelernt - das wuerde
die Zuordnung mehrdeutig machen und damit blockieren.
Was gelernt wurde, steht in der Erfolgsmeldung und im Audit-Log; eine
stillschweigende Aenderung am Mitglied waere sonst schwer nachvollziehbar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Mail-Verarbeitung ignorierte bisher die PayPal-Schalter: eingehende
Zahlungen wurden auch dann automatisch gutgeschrieben, wenn der Betreiber
die Funktion gesperrt oder der Mandant PayPal abgeschaltet hatte. Jetzt
wird die Zahlung in dem Fall geparkt - gespeichert, aber ohne Buchung.
Wegwerfen liesse eine echte Zahlung unbemerkt verschwinden, buchen
widersprache der Abschaltung; die Zuordnungsseite bleibt fuer offene
Zahlungen ja erreichbar.
--dry-run war bisher irrefuehrend: es liess die Verarbeitung samt Buchung
laufen und uebersprang nur das Setzen des Gelesen-Flags - ausgerechnet beim
ersten Testlauf haette es also echtes Geld verbucht. Der Probelauf nutzt
jetzt paypal_preview(), das nichts schreibt und meldet, was passieren
wuerde (would_book/would_queue/would_park/duplicate).
scripts/check-paypal-inbox-flow.php deckt die Kette ohne IMAP ab:
Absenderpruefung, Token, Parser, Zuordnung, Netto-Buchung, Dedup, beide
Park-Faelle und die Schreibfreiheit der Vorschau.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Marke stand bisher nur in der Web-Ansicht; auf dem Ausdruck, den die
Mitglieder taeglich vor sich haben, fehlte sie. Sie erscheint jetzt unten
rechts im Seitenfuss - nach derselben Regel wie im Web: im kostenlosen
Tarif fest, sonst nach Mandant-Einstellung.
Die Tasse wird mit TCPDFs Zeichenbefehlen gemalt statt als SVG oder Bild
eingebettet: ImageSVG braucht die XML-Extension, ein Rasterbild GD - beides
ist auf einem Webspace nicht garantiert.
Der Schriftzug sitzt in einer Cell() statt in Text(): dessen y-Bezug haengt
an Schriftmetriken und ergab in der Probe einen um Millimeter versetzten
Schriftzug. Steht die Marke, reserviert export_page_row_budget_mm() 6 mm am
Seitenfuss, damit die Tabelle nicht hineinlaeuft.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Hinweis unten rechts laesst sich ab einem bezahlten Tarif in den
Mandant-Einstellungen abschalten (neue Spalte tenant_settings.
watermark_enabled, Standard an). Im kostenlosen Tarif bleibt er fest
eingeschaltet: das Kaestchen ist angehakt und disabled, daneben steht der
Grund. Zusaetzlich erzwingt saas_update_tenant_settings() den Wert dort noch
einmal, damit ein nachgebauter POST ihn nicht abschalten kann.
Der Titel des Wasserzeichens spricht nicht mehr vom kostenlosen Tarif -
zahlende Mandanten koennen es jetzt freiwillig zeigen.
Der Smoke-Test prueft die Einstellungsseite jetzt auch angemeldet. Sein
Muster fuer PHP-Meldungen verlangt dafuer einen Doppelpunkt, sonst haette
das Feld "negative_warning" als Warnung gezaehlt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mandanten im Tarif "free" (bis 10 Teilnehmer) zeigen unten rechts eine
kleine Marke mit Logo und Adresse der Kaffeeliste; zahlende Mandanten
behalten ihre Oberflaeche ohne fremdes Logo.
Die Sichtbarkeit haengt allein an tenant_billing.plan_code und wird als
reine Leseabfrage geprueft - billing_fetch_or_init() waere ein Schreibzugriff
im Seitenaufbau. Laesst sich der Tarif nicht lesen, bleibt die Marke aus.
Das Logo ist ein Inline-SVG, das Linkziel kommt aus APP_MARKETING_URL bzw.
aus APP_HOST ohne App-Subdomain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wird PayPal in den Mandant-Einstellungen abgeschaltet, waehrend noch
Zahlungen unzugeordnet in der Warteschlange liegen, kaeme ohne diese
Ausnahme niemand mehr an sie heran - weder ueber das Menue noch ueber die
URL.
paypal_inbox_accessible() haelt Menuepunkt und Seite deshalb offen, solange
paypal_count_unmatched() etwas findet; die Seite weist per Hinweis darauf
hin, dass sie nach dem Abarbeiten verschwindet. Eine Sperre durch den
Betreiber sticht die Ausnahme.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bisher blendete das Menue nur aus, was der Betreiber gesperrt hatte. Hat
der Mandant selbst eine Funktion abgeschaltet - etwa "PayPal anbieten" in
den Mandant-Einstellungen - blieb der Menuepunkt stehen und fuehrte auf
eine Seite ohne Zweck.
app_feature_available() prueft nun beide Ebenen, app_feature_tenant_switch()
haelt die Zuordnung Funktion -> Mandanten-Schalter. Navigation und Anleitung
nutzen die neue Pruefung; paypal-zuordnung.php sperrt sich beim direkten
Aufruf ebenfalls und verweist dabei auf die Mandant-Einstellungen statt auf
den Betreiber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Funktions-Schalter je Mandant (app/features.php, tenant_features): der
Betreiber schaltet FAQ, Selbsteintrag, PDF, PayPal-Eingang, CSV-Import,
Mailversand, Jahresabschluss, Datenexport und eigenes Design pro Mandant
frei. Gesperrte Funktionen verschwinden aus Menue und Schaltflaechen, ihre
Seiten weisen Aufrufe und POSTs ab. Das Back-Office ist jetzt fuer
Platform-Admins im Menue verlinkt statt nur per URL erreichbar.
Eigenes Design je Mandant: Akzentfarbe und Logo in den Mandant-
Einstellungen, eingebettet ueber app/branding.php; das Logo liegt
geschuetzt in var/tenant_logos und wird nur ueber
tenant-logo-anzeigen.php an den eigenen Mandanten ausgeliefert.
Weniger Startinformationen: Startpaket neuer Mandanten auf zwei
Beispielfragen gekuerzt, Anleitung von ~1400 auf ~750 Woerter gestrafft
und um Abschnitte zu gesperrten Funktionen bereinigt.
Vorder-/Rueckseite erst bei mehr als 50 Personen (vorher schon ab 50).
Die Schwelle liegt jetzt gemeinsam in app/ledger.php und gilt auch fuer
die Vorder-/Rueckseiten-Auswahl beim Erfassen, wo sie bisher unabhaengig
von der Teamgroesse angeboten wurde.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Vier Luecken geschlossen, die im Alltag sofort aufgefallen waeren:
- Passwort aendern war im eingeloggten Zustand gar nicht moeglich; es gab
nur den Reset per Mail-Link. saas_change_password() prueft das aktuelle
Passwort, verlangt ein tatsaechlich anderes und erneuert danach die
Session-ID. Formular in konto.php.
- mandant-auswahl.php war nur direkt nach dem Login erreichbar. Wer bei
mehreren Mandanten Mitglied ist, musste sich zum Wechseln abmelden. Die
Seite bedient jetzt beide Wege, die Mandantenpruefung bleibt unveraendert
ueber saas_identity_for_user_tenant(). Menuepunkt ab zwei Mitgliedschaften.
- email_verified_at wurde nirgends geprueft, nur angezeigt - bei offener
Selbstregistrierung konnte sich jemand mit fremder Adresse anmelden und
alles nutzen. Erzwungen wird jetzt gezielt dort, wo eine Aktion nach
aussen wirkt: Einladung, Info-Mail, Jahresabschluss. Der Login selbst
bleibt bewusst frei, sonst waeren alle migrierten Bestandsnutzer mit
NULL-Verifikation ausgesperrt. Zusaetzlich setzt der Passwort-Reset die
Verifikation mit, weil der Mail-Link den Postfachzugriff nachweist -
sonst blieben eingeladene Mitglieder dauerhaft unbestaetigt.
- Login landete auf konto.php statt auf dem Dashboard.
Landingpage: die gruene Vertrauenszeile auf "DSGVO-konform" gekuerzt, die
Eintraege zu Paragraf 19 UStG und "Bestehende Ablaeufe bleiben" entfernt.
Geprueft: neues scripts/check-konto-und-mandantenwechsel.php mit 18
Assertions gruen, Passwortformular zusaetzlich manuell inkl. CSRF (419).
Bestehende Suiten unveraendert gruen: HTTP-Smoke 34 Seiten, Rollenmatrix
55, Mandanten-Isolation 12, M3-Auth 9, M3-Settings 15.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei vorbestehende, bislang rote Pruefungen behoben.
1) PHP lief in UTC, MySQL in der System-Zeitzone (hier CEST, +2h). Ueberall,
wo ein in PHP berechneter Zeitstempel gegen MySQL NOW() verglichen wird,
liefen die Uhren dadurch gegeneinander:
- Hinweise mit kurzer Restlaufzeit galten sofort als abgelaufen
(notices: valid_from = NOW() aus MySQL, valid_until aus PHP date()).
- Auth-Token (Passwort-Reset, E-Mail-Verifikation) liefen bis zu 2h zu
frueh ab (expires_at aus PHP date(), Pruefung gegen NOW()).
bootstrap.php pinnt die PHP-Zeitzone jetzt deterministisch aus
APP_TIMEZONE (Standard Europe/Berlin), app_db_pdo() setzt die DB-Session
per numerischem Offset auf dieselbe Zeit. Damit stimmen beide Uhren
ueberein. Behebt check-m8-tenant-isolation (11/1 -> 12/0).
2) check-m3-settings-flow stammte aus M3 und lieferte nicht die spaeter
hinzugekommenen Pflichtfelder (pdf_row_height_px,
payment_reminder_interval_days). Dadurch schlug bereits das Update fehl und
alle Folge-Assertions kippten. Der Test sendet jetzt den vollstaendigen
Feldsatz wie das Einstellungsformular und prueft die beiden Felder mit.
Die Update-Funktion selbst war korrekt (7/6 -> 15/0).
Voller Regressionslauf gruen: http-smoke 33/0, role-matrix 55/0,
tenant-isolation 12/0, m3-auth 9/0, password-email 14/0, settings 15/0,
tenant-resolution 12/0, billing 10/0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Abschluss des Legacy-Abbaus. Die Migration in participants/ledger_entries war
laut Dev-DB vollstaendig (keine legacy_mitarbeiter_id, kein legacy_table), die
kl_*-Tabellen enthielten nur noch Golden-Master-Testfixtures. Da kein echter
Altbestand mehr importiert werden muss (Prod entsteht aus der jetzigen Dev-DB),
kommt die Altwelt komplett raus.
Laufzeit-Code (Schritt 3a):
- ledger.php: Option legacy_mitarbeiter_ids, die Spalten aus allen SELECTs und
Rueckgaben, ledger_fetch_participant_summary_by_legacy_id sowie das Loeschen
gespiegelter Legacy-Zeilen in ledger_void_entry/ledger_void_own_self_entry
entfernt
- imports.php, paypal-inbox.php: legacy_mitarbeiter_id aus Ergebnis und
Typannotationen entfernt
- ledger-preview.php: Spalte "Legacy" entfernt
Skripte (Schritt 3b/3c):
- die acht reinen Legacy-/Golden-Master-Skripte entfernt (backfill-*,
seed-golden-master, golden-master-data, check-golden-master, check-m4-*,
check-m3-saas-basis)
- init-mysql-dev.php legt jetzt einen SaaS-Mandanten mit Owner-Login,
Teilnehmer, Journalbuchungen und Hinweis an statt kl_*-Zeilen
Schema (Schritt 4, Migration 0024):
- participants.legacy_mitarbeiter_id + uq_participants_tenant_legacy
- ledger_entries.legacy_table/legacy_id + uq_ledger_entries_tenant_legacy
- DROP der Tabellen kl_Einzahlungen, kl_Kaffeeverbrauch, kl_hinweise,
kl_config, kl_Mitarbeiter
Verifikation unveraendert gruen: http-smoke 33/0, role-matrix 55/0,
tenant-isolation 11/1 (Altfehler), m3-auth/tenant-resolution/billing gruen.
check-m3-settings-flow 7/6 ist vorbestehend (schon bei 3e24910 rot, mit
spaeteren Settings-Feldern nicht synchron) und unabhaengig von diesem Abbau.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Die Migration in participants/ledger_entries ist abgeschlossen: kein
Teilnehmer traegt noch eine legacy_mitarbeiter_id, kein Journaleintrag eine
legacy_table. Damit war der jeweils zweite Zweig ("Default-Mandant schreibt
zusaetzlich nach kl_Einzahlungen/kl_Kaffeeverbrauch/kl_Mitarbeiter") in
sechs Dateien nicht mehr erreichbar - er musste aber bei jeder Aenderung
mitgepflegt werden, zuletzt bei der Bemerkung fuer Einzahlungen.
Entfernt:
- Dual-Write beim Buchen: einzahlung.php, stricheintragen.php, index.php,
jahresauswertung.php, app/imports.php, app/paypal-inbox.php
- Dual-Write in der Mitgliederverwaltung: ledger_create_participant,
ledger_update_participant, ledger_set_participant_active,
ledger_anonymize_participant
- die verwaisten Spiegelfunktionen ledger_mirror_legacy_payment,
ledger_mirror_legacy_consumption und ledger_void_entry_by_legacy_id
Nebenbei behoben: kaffeeliste.php hat die Teilnehmer-Detailseite nur fuer
Mitglieder mit legacy_mitarbeiter_id verlinkt - die hat seit der Migration
niemand mehr, die Seite war also fuer alle unerreichbar. Verlinkt und
adressiert wird jetzt ueber participant_id; "user_id" bleibt als Alias
erhalten. Der Mandanten-Isolationstest prueft jetzt ledger_void_entry, also
den Pfad, den letzteneintraege.php tatsaechlich nutzt.
Pruefskripte unveraendert gegenueber der Baseline vor dem Umbau
(http-smoke 27/6, role-matrix 55/0); die Isolationspruefung steigt von
9 auf 11 PASS bei gleichem vorbestehendem Fehler.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Einzahlungen koennen jetzt eine Bemerkung tragen und negativ sein, damit
Abzuege (Auszahlung bei Austritt, Erstattung) nachvollziehbar gebucht werden
koennen.
- ledger_entries.note wurde bisher nur beim Import befuellt und wird nun auch
bei Einzahlungen geschrieben und angezeigt
- Negative Betraege verlangen zwingend eine Bemerkung, sonst ist spaeter nicht
mehr nachvollziehbar, warum jemandem Geld abgezogen wurde
- Plausibilitaetsgrenze von 1000 EUR gegen Groessenordnungs-Tippfehler; bei
einem Fehler in einer Zeile wird gar nichts gebucht und die Eingaben bleiben
stehen
- Legacy-Tabelle kl_Einzahlungen bekommt eine Bemerkung-Spalte, sonst haette
ledger_mirror_legacy_payment die Notiz beim Re-Sync wieder mit NULL
ueberschrieben
- PayPal-Buchungen tragen jetzt Zahler, Datum und Mitteilung als Bemerkung
Ausserdem behoben: letzteneintraege.php hat Einzahlungen und Striche direkt
aus den Legacy-Tabellen gelesen, ohne nach Mandant zu filtern. Jeder
Mandanten-Admin sah damit die Buchungen aus den Legacy-Tabellen, und das
Stornieren lief ueber die nicht mandantengetrennten Legacy-IDs. Die Seite
arbeitet jetzt auf dem mandantengebundenen Journal, und ledger_void_entry
prueft Mandant und Buchungsart, bevor es storniert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mandanten leiten ihre PayPal-Benachrichtigungsmails an ein zentrales
IMAP-Postfach weiter. Die Zuordnung zum Mandanten erfolgt ueber
Plus-Adressierung (zahlungen+<token>@...), der Token wird pro Mandant
erzeugt und im Backend angezeigt.
- Parser fuer PayPal-Eingangsmails, tolerant gegenueber falsch kodierten
Waehrungssymbolen und HTML-Struktur weitergeleiteter Mails
- Gutgeschrieben wird der Nettobetrag, also was tatsaechlich ankam
(bei Waren & Dienstleistungen nach Abzug der PayPal-Gebuehr)
- Deduplizierung ueber den Transaktionscode, damit doppelt weitergeleitete
Mails nicht doppelt buchen
- Eindeutiger Namens-Match bucht automatisch, alles andere landet in einer
Warteschlange zur manuellen Zuordnung
- Absenderpruefung gegen paypal.de/.com, da weitergeleitete Mails keine
gueltige SPF/DKIM-Signatur mehr haben
- IMAP-Zugang ausschliesslich ueber Server-/Env-Einstellungen, nicht pro
Mandant konfigurierbar
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bisher gab es nur PayPal. Jetzt sind pro Mandant drei unabhaengig
aktivierbare Zahlungswege konfigurierbar (mandant-einstellungen.php):
- Barzahlung mit frei waehlbarem Ansprechpartner.
- PayPal wie bisher, mit Zahlungslink.
- Ueberweisung mit Kontoinhaber und IBAN (Format wird geprueft,
Leerzeichen werden normalisiert).
Das Mitglieder-Dashboard (index.php) zeigt unter "Bezahlen" alle
aktivierten Methoden mit dem jeweils offenen Betrag; die IBAN wird zur
besseren Lesbarkeit in Viererbloecken angezeigt.
Neue tenant_settings-Spalten via Migration 0021 (cash_enabled,
cash_contact, bank_transfer_enabled, bank_account_holder, bank_iban).
billing_stripe_price_id() und der komplette abo-upgrade.php-Ablauf warfen
bisher eine ungefangene RuntimeException (Fatal Error), sobald
STRIPE_SECRET_KEY nicht gesetzt ist (z. B. lokale Dev-Umgebung ohne echte
Stripe-Zugangsdaten) - bestand bereits im alten Code, ist durch die neue
Tarif-Vergleichstabelle mit mehr Buchen-Buttons aber leichter auslösbar
geworden. Jetzt: sauberer 502 'Bezahldienst nicht verfuegbar' statt
Fatal-Error-Seite.
Erstmals lokal per HTTP end-to-end gegen echten Dev-Server + MariaDB
getestet (PHP 8.3 + MariaDB 11.4 als portable Binaries installiert, siehe
docs/dev-mysql.md): eigener Test-Mandant mit 12 aktiven Teilnehmern,
Tarif-Tabelle korrekt gerendert (5 Tarife, Pflicht-Upgrade-Hinweis, Buchen-
Buttons, Enterprise-Anfrage-Link), Buchung von 'free' bei Ueberschreitung
korrekt mit 400 abgelehnt, Buchung eines bezahlten Tarifs ohne Stripe-Key
jetzt sauber mit 502 statt Fatal-Error-Crash. Alle bestehenden
Regressionstests weiterhin gruen: Golden Master (104), Billing-Capacity
(10), M8-Isolation (10), M8-Rollenmatrix (55).
- mandant-einstellungen.php: vollstaendige Tarif-Vergleichstabelle statt nur
erzwungenem Upgrade-Hinweis. Jeder Tarif zeigt Limit, Preis und einen
Aktions-Button (Buchen/Kuendigen/Anfragen); aktueller Tarif markiert,
zu kleine Tarife fuer die aktuelle Teilnehmerzahl deaktiviert.
- abo-upgrade.php: unterscheidet jetzt Upgrade, Downgrade zwischen bezahlten
Stufen (Preis wird in-place gewechselt statt neuer Checkout-Session,
Stripe prorated automatisch) und Wechsel auf 'free' (echte Kuendigung der
bestehenden Subscription), statt nur eine Checkout-Session zu erzeugen.
- app/stripe.php: neue Helper stripe_get_subscription(),
stripe_update_subscription_price(), stripe_cancel_subscription().
- app/billing.php: billing_plan_selectable_for() prueft serverseitig, ob
ein Zieltarif die aktuelle Teilnehmerzahl noch abdeckt (Downgrade-Schutz).
- docs/billing.md: Phase 4 dokumentiert.
Getestet: php -l fuer alle geaenderten Dateien und das gesamte Repo (keine
Syntaxfehler), reine Funktionslogik-Tests ohne DB (billing_plans(),
billing_required_plan_code(), stripe_flatten_params()) gruen. Kein Live-Test
gegen echte Stripe-Testobjekte moeglich (keine PHP/DB-Laufzeitumgebung
verfuegbar) - vor Go-Live im Stripe-Testmodus nachholen.
Bugfixes aus dem Code-Review:
- XSS: unescaptes $_SERVER['PHP_SELF'] in csvupload.php und
letzteneintraege.php durch feste Seitennamen ersetzt.
- Stripe-Webhook: Event-Deduplizierung (neue Tabelle stripe_webhook_events)
gegen doppelte Verarbeitung/Dolibarr-Rechnungen bei Retry-Zustellung.
- Stripe-Webhook: Tarifwechsel aus dem Kundenportal wird lokal nachgezogen
(plan_code aus dem Preis-lookup_key bei subscription.updated).
- Post-Redirect-Get fuer jahresauswertung, mailversenden und die
Selbst-Stricheintragung - verhindert Doppelbuchung/Doppelversand per
Browser-Refresh.
- Jahresbonus: Mails erst nach erfolgreichem Commit; Restcent-Ausgleich
beim letzten Empfaenger, damit die Summe exakt stimmt.
- Verschachtelte HTML-Dokumente in csvupload/einzahlung/stricheintragen
entfernt (Layout kommt aus header.php).
- Rate-Limit fuer den Versand von E-Mail-Verifizierungslinks.
Neue Funktionen:
- Mitglieder koennen ihren zuletzt selbst eingetragenen Strich wieder
stornieren (nur eigene Web-Eintraege, Kassenwart-Eintraege bleiben).
- Monatsuebersicht des eigenen Verbrauchs im Mitglieder-Dashboard.
- Automatische Zahlungserinnerung: opt-in pro Mandant ab der
Warnschwelle, mit Intervall; Cron-Skript scripts/send-payment-reminders.php.
- CSV-Import fuer Mitgliederlisten inkl. herunterladbarer Vorlage
(mitglieder-vorlage.php), Semikolon-/Komma- und BOM-Erkennung.
- Logo als PDF-Wasserzeichen pro Mandant (Upload in den Mandant-
Einstellungen, geschuetzt unter var/tenant_logos/, ersetzt den
Text-Wasserzeichen im Ausdruck).
Robusterer Teilnehmer-Lookup im Dashboard ueber user_id (Fallback E-Mail).
Der PDF-Export war bisher immer zweiseitig (Vieltrinker-/Wenigtrinker)
mit fester Mindestzeilenzahl, unabhängig von der tatsächlichen
Mitgliederzahl. Umgestellt auf:
- Bis 49 aktive Mitglieder: eine Seite ohne Trennung.
- Ab 50 Mitgliedern: weiterhin zwei Seiten (Vorder-/Rückseite).
- Zeilenhöhe richtet sich nach der Mitgliederzahl (16-40px), statt fest
16px für alle.
- Neue Mandanten-Einstellungen: freie Zeilen für neue Mitglieder an/aus,
Trennmodus bei zwei Seiten (Trinkverhalten oder alphabetisch).
Migration 0015 ergänzt die dafür nötigen tenant_settings-Spalten.
Gegen die Dev-DB verifiziert: 8 Mitglieder -> einseitiges PDF,
53 Mitglieder (temporäre Testdaten) -> zweiseitiges PDF, Einstellungen
inkl. Validierung geprüft.
PHP-FPM/mod_php auf Shared Hosting erbt keine shell-exportierten
Umgebungsvariablen wie der lokale Dev-Server. app/bootstrap.php laedt
jetzt optional env.local.php (nicht eingecheckt, siehe
env.local.example.php als Vorlage) und setzt die Variablen per
putenv(), bevor irgendein app_env()/getenv()-Aufruf passiert. Greift
jetzt auch fuer CLI-Skripte (migrate.php, grant-platform-admin.php) ueber
scripts/dev-db.php.
- index.php zeigt bei abweichendem Host (APP_HOST-Env) die Landingpage
statt des Dashboards, damit kaffeeliste.de und app.kaffeeliste.de aus
demselben Webspace bedient werden koennen. Ohne gesetztes APP_HOST
(lokale Entwicklung) unveraendertes Verhalten.
- landing.php verlinkt Login/Registrierung ueber APP_HOST fest auf die
App-Domain, damit Besucher der Marketingdomain dort landen.
- functionsLDAP.php entfernt (nur ueber die tote IIS/AUTH_USER-Branch
erreichbar, vollstaendig durch app/saas-auth.php ersetzt); zugehoerige
tote AD/LDAP-Variablen aus config.php und der AUTH_USER-Zweig aus
functions.php entfernt.
- mailausgebe.php, umfrage.php, umfrageergebnisse.php entfernt: aus der
Navigation nicht erreichbare Debug-/Einzweck-Seiten ohne echte
Berechtigungspruefung (mailausgebe.php dumpte alle Mitglieder-E-Mails,
umfrageergebnisse.php hatte nur einen auskommentierten Basic-Auth-
Block). scripts/http-smoke.php entsprechend bereinigt.
Statt automatisch hochzustufen, blockiert das Anlegen/Reaktivieren
weiterer aktiver Mitglieder, sobald das Limit des gebuchten Tarifs
erreicht ist (Neuanlage, Bearbeiten mit Statuswechsel, Aktivieren-
Button). mitarbeiterverwalten.php zeigt die aktuelle Auslastung an.
Neuer Regressionstest scripts/check-billing-capacity.php deckt alle
drei Enforcement-Stellen ab.
Bei erfolgreicher Stripe-Zahlung (invoice.paid) wird automatisch ein
Dolibarr-Kunde ermittelt/angelegt und eine validierte Rechnung als
Buchhaltungsspiegel erzeugt. Dolibarr-Fehler blockieren die
Stripe-Webhook-Verarbeitung nicht, sondern landen im Audit-Log zur
manuellen Nachbearbeitung. Getestet gegen die produktive
Dolibarr-Instanz des Kunden (kein Sandbox verfuegbar) mit einem
rechtebeschraenkten API-Key und einem nicht validierten Test-Datensatz.
app/stripe.php: minimaler REST-Client fuer die Stripe-API auf Basis von
PHP-Streams statt eines vendorten SDKs - diese PHP-Installation hat keine
curl-Extension, Streams sind zudem portabler und brauchen kein
composer.json.
- Fuer jeden bezahlten Tarif per API ein Stripe-Produkt mit monatlichem
Preis angelegt, referenziert ueber einen stabilen lookup_key statt
hartcodierter Price-ID; Erstellung ist idempotent.
- abo-upgrade.php: erstellt eine Stripe-Checkout-Session fuer den
gewaehlten Tarif, tenant_id/plan_code als Metadaten auf Session UND
Subscription (damit spaetere Subscription-Events zuordenbar bleiben).
- abo-portal.php: oeffnet das Stripe Customer Portal fuer bestehende
Kunden (Zahlungsmittel/Kuendigung, ohne eigene UI dafuer).
- stripe-webhook.php: verifiziert die Stripe-Signature per HMAC-SHA256
mit Zeitstempel-Toleranz, verarbeitet checkout.session.completed,
customer.subscription.updated/.deleted, invoice.paid/.payment_failed
und haelt tenant_billing aktuell. Bewusst kein CSRF-/Login-Check
(Stripe ruft unauthentifiziert auf), stattdessen ausschliesslich
Signaturpruefung als Echtheitsnachweis.
- mandant-einstellungen.php: echter "Jetzt upgraden"-Button (Stripe
Checkout) sowie "Zahlungsmethode verwalten/Abo kuendigen" (Customer
Portal), sobald ein Stripe-Kunde existiert.
Live getestet (Stripe-Testmodus, kein echtes Geld): voller Checkout-Flow
bis zum echten Redirect auf checkout.stripe.com, Webhook-Verarbeitung
durch selbst erzeugte, korrekt signierte Test-Events (da diese
Dev-Umgebung keine oeffentlich erreichbare URL fuer echte Stripe-
Zustellung hat) inklusive Ablehnung falscher Signaturen, Customer Portal
mit echtem per API angelegtem Test-Kunden. Alle Regressionstests
weiterhin gruen (36 Seiten HTTP-Smoke, Golden Master, M8-Isolation/
Rollenmatrix).
Noch offen: echten Webhook-Endpunkt auf die Produktions-Domain eintragen,
sobald diese feststeht; Wechsel auf Live-Keys; Dolibarr-Sync (Phase 3).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Vorbereitung fuer Stripe Billing (Zahlungseinzug) + bestehendes Dolibarr
des Kunden (Rechnungsstellung/Buchhaltung) statt eines eigenen Rechnungs-
systems oder eines zusaetzlichen ERP nur fuer Kaffeeliste - Begruendung
in docs/billing.md.
- Neue Tabelle tenant_billing (plan_code, subscription_status, sowie
bereits vorbereitete, noch ungenutzte Felder fuer Stripe/Dolibarr-IDs).
- app/billing.php: billing_plans() als einzige Quelle der Tarifstufen
(synchron mit preise.php), billing_required_plan_code() anhand aktiver
Teilnehmerzahl, billing_check_tenant() vergleicht gebuchten mit
benoetigtem Tarif - rein informativ, kein Enforcement, solange Stripe
nicht angebunden ist.
- Neue Mandanten starten automatisch auf plan_code='free' bei der
Registrierung.
- mandant-einstellungen.php zeigt Owner/Admin ihren aktuellen Tarif und
Teilnehmerstand, mit Hinweis bei Bedarf einer hoeheren Stufe.
- Back-Office zeigt zusaetzlich zur Mandantenliste den gebuchten und
(falls abweichend) den tatsaechlich benoetigten Tarif.
Live getestet: Tarifgrenzen (10/25/50/150) mit einem Mandanten unter und
einem ueber dem Freikontingent geprueft, UI in Mandant-Einstellungen und
Back-Office verifiziert. Alle M8-Isolations-/Rollenmatrix-Tests weiterhin
gruen.
Phase 2 (Stripe) und Phase 3 (Dolibarr-Sync) folgen, sobald Test-Keys
beziehungsweise Sandbox-Zugang vorliegen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Neue, bewusst von tenant_memberships/saas_user_has_role() komplett
getrennte Platform-Admin-Ebene (neue Tabelle platform_admins), damit die
bestehende, automatisiert getestete Mandanten-Isolation
(check-m8-tenant-isolation.php, check-m8-role-matrix.php) unangetastet
bleibt - Platform-Admin-Rechte wirken ausschliesslich auf den neuen
backoffice-*.php-Seiten.
- backoffice.php: Uebersicht aller Mandanten (Status, Teilnehmerzahl,
Saldensumme).
- backoffice-mandant.php: reine Leseansicht eines Mandanten (Einstellungen,
Mitglieder/Rollen, letzte Buchungen, letzte Admin-Aktionen). Bewusst
kein Schreibzugriff von hier aus.
- backoffice-export.php: nutzt dieselbe app_export_tenant_data() wie der
Selbstbedienungs-Export, ausgeloest durch den Platform-Admin fuer
beliebige Mandanten.
- scripts/grant-platform-admin.php: CLI-only Bootstrap fuer den ersten
Platform-Admin, bewusst keine Web-UI dafuer.
- Jede Back-Office-Ansicht/-Export wird im Audit-Log DES BETROFFENEN
MANDANTEN protokolliert (Transparenzpflicht), nicht nur beim Betreiber.
Der bestehende Selbstbedienungs-Export (datenexport.php aus M8) bleibt
zusaetzlich bestehen statt ersetzt zu werden: der Mandant ist im AV-
Verhaeltnis Verantwortlicher, Art. 15/20/28 DSGVO verpflichten den
Auftragsverarbeiter zur Unterstuetzung bei Ausk''unfts-/Portabilitaets-
rechten - ein jederzeit verfuegbarer Mandanten-Export erfuellt das direkt.
Details und Begruendung in docs/backoffice.md.
Live getestet: Back-Office zeigt alle Mandanten korrekt (inkl. echter
Bestandsmandanten), Detail/Export fuer Test-Mandant funktioniert,
Audit-Log korrekt geschrieben. Kritischer Test bestanden: derselbe
Platform-Admin sieht auf normalen Mandanten-Seiten weiterhin nur seinen
eigenen Mandanten; ein eingeloggter Nicht-Platform-Admin bekommt 403.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
faq.php zeigte bislang fuer jeden Mandanten denselben, komplett
AOK-spezifischen Inhalt (Kaffeemaschinen-Bedienung, interner
Ansprechpartner) - fuer andere Kunden unbrauchbar und inhaltlich falsch.
- Neue Tabelle faq_entries (tenant-scoped, Soft-Delete, sort_order).
- Migration uebernimmt die bisherigen AOK-Inhalte einmalig als FAQ des
migrierten Default-Mandanten; andere Mandanten sehen sie nicht.
- Neue Mandanten bekommen bei der Registrierung automatisch eine kurze
generische Starter-FAQ (faq_seed_default_entries), frei editierbar.
- faq.php: lesbar fuer alle angemeldeten Mitglieder eines Mandanten,
Anlegen/Bearbeiten/Entfernen auf owner/admin beschraenkt.
- Landingpage verweist nicht mehr auf faq.php (jetzt interner,
mandantengebundener Inhalt statt oeffentlicher Marketing-Seite).
Live getestet: AOK-Carry-over fuer Default-Mandant, frisch registrierter
Test-Mandant bekam isolierte generische Starter-FAQ, Anlegen mit
HTML-Payload (korrekt escaped), Bearbeiten und Soft-Delete geprueft.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Zwei getrennte Flows:
- Teilnehmer anonymisieren statt loeschen (ledger_anonymize_participant):
Name/E-Mail/PayPal-Name werden durch einen Platzhalter ersetzt, das
Mitglied deaktiviert und vom Login-Konto getrennt. Buchungshistorie
bleibt fuer die Kassenfuehrung erhalten, konsistent mit dem
Storno-statt-Delete-Prinzip. Default-Mandant spiegelt die
Anonymisierung in kl_Mitarbeiter (Email dort NOT NULL UNIQUE, bekommt
Platzhalter statt NULL). Inhaber kann nicht anonymisiert werden.
- Mandant vollstaendig loeschen (mandant-loeschen.php, nur Inhaber):
erfordert exakte Eingabe des Kundenkuerzels, loescht die tenants-Zeile;
alle tenant-scoped Tabellen kaskadieren per Fremdschluessel. users
bleiben bestehen (koennen zu mehreren Mandanten gehoeren). Der
migrierte Default-Mandant ist ausgenommen, da seine kl_Mitarbeiter-
Historie sonst verwaisen wuerde.
Live getestet: Mitglied mit Buchungshistorie anonymisiert (Historie
blieb erhalten), Mandantenloeschung mit falscher/richtiger Bestaetigung
geprueft, vollstaendiger Cascade-Delete ueber alle tenant-scoped Tabellen
verifiziert, globale users-Zeile bleibt korrekt erhalten.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Neue Seite datenexport.php (Owner/Admin) laedt einen vollstaendigen
JSON-Export des eigenen Mandanten herunter: Stammdaten, Einstellungen,
Teilnehmer, Mitglieder mit Rolle, alle Ledger-Buchungen, Hinweise,
CSV-Importe, Mail-Versandlog und Admin-Protokoll. Passwort- und
Token-Hashes werden bewusst nicht exportiert; der Export selbst wird im
Audit-Log protokolliert. Link von konto.php aus.
Live getestet: eigens angelegter Test-Mandant, Export heruntergeladen,
Header und JSON-Struktur geprueft, keine Passwoerter im Export gefunden.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Erste drei Bausteine der Haertung:
- Security-Headers (X-Content-Type-Options, X-Frame-Options, Referrer-
Policy, Permissions-Policy, HSTS bei HTTPS) laufen automatisch ueber
app_send_security_headers() am Ende von app/bootstrap.php fuer jede
dynamische Seite; landing.php war als einzige Seite ganz ohne PHP und
bekam einen minimalen Bootstrap-Aufruf. Bewusst kein CSP, da die
bestehenden Templates durchgaengig auf Inline-style-Attribute setzen.
- DB-gestuetzte Rate-Limits (neue Tabelle rate_limit_attempts) fuer
Login (10/15min je E-Mail, 20/15min je IP), Registrierung (5/h je IP)
und Passwort-Reset-Anfrage (5/h je E-Mail, 10/h je IP); bei
ausgereiztem Reset-Limit erscheint dieselbe generische Meldung wie im
Erfolgsfall, um kein Konto-Enumeration-Signal zu geben.
- Zentrales Audit-Log (neue Tabelle audit_log) fuer Mitgliederverwaltung,
Zugangsvergabe/-entzug, Storno, Mandant-Einstellungen, Hinweise,
CSV-Import, Jahresbonus-Verteilung und Live-Mailversand; sichtbar fuer
Owner/Admin auf mandant-einstellungen.php.
Live getestet: Rate-Limit greift nach 10 Fehlversuchen, Audit-Log-Eintrag
mit korrekten Metadaten und Nutzernamen ueber einen isolierten Test-
Mandanten geprueft. Alle Regressionstests weiterhin gruen (26/26 Smoke,
104 Golden-Master-Assertions).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
jahresauswertung.php verband sich bisher mit fest codierten (kaputten)
Zugangsdaten selbst zur Datenbank statt ueber config.php, hatte keine
Zugriffskontrolle und kein CSRF, und verteilte bei jedem Aufruf sofort
einen hart codierten Bonus-Topf (490 Striche a 0,20 Euro) per PHPMailer
(dessen Quelldateien im Repo fehlen) mit AOK-spezifischem Mailtext.
Nach Abstimmung mit dem Kunden als generisches, mandantenfaehiges Feature
neu gebaut statt nur deaktiviert oder rein lesend umgesetzt:
- Admin gibt einen frei waehlbaren Gesamtbetrag ein, das System verteilt
ihn proportional zu den Jahresstrichen auf alle aktiven Mitglieder.
- Standardmaessig aktive Dry-Run-Checkbox zeigt die Verteilung, ohne zu
buchen oder Mails zu verschicken.
- Bestaetigter Lauf bucht ueber dasselbe Zweig-Muster wie ueberall
(Default-Mandant Dual-Write, andere Mandanten ledger_record_payment)
und verschickt personalisierte Mails ueber saas_send_mail(), protokolliert
im outbound_emails-Versandlog.
- Zugriffskontrolle ergaenzt (owner/admin/treasurer + Legacy-Fallback).
- http-smoke.php: jahresauswertung.php jetzt regulaerer Check statt
uebersprungenem unsicherem Aufruf; damit sind keine Seiten mehr
uebersprungen oder als bekannter offener Punkt markiert (26/26 gruen).
Live getestet: Dry-Run mit korrekter proportionaler Verteilung (Summe
ergibt exakt den Gesamtbetrag), Live-Lauf bucht und versendet korrekt,
Testdaten anschliessend vollstaendig entfernt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>