diff --git a/app/ledger.php b/app/ledger.php
index cd942da..cc447a1 100644
--- a/app/ledger.php
+++ b/app/ledger.php
@@ -496,6 +496,52 @@ function ledger_set_participant_active(PDO $pdo, int $tenantId, int $participant
}
}
+/**
+ * Anonymizes a participant (GDPR-style erasure) instead of deleting them:
+ * name and email are replaced with a non-identifying placeholder and the
+ * participant is deactivated, but their ledger history stays intact for
+ * the tenant's bookkeeping. For the default tenant, the linked
+ * kl_Mitarbeiter row is anonymized the same way (its email column is
+ * NOT NULL UNIQUE, so it gets a placeholder instead of NULL).
+ */
+function ledger_anonymize_participant(PDO $pdo, int $tenantId, int $participantId): bool
+{
+ $stmt = $pdo->prepare('SELECT legacy_mitarbeiter_id FROM participants WHERE id = ? AND tenant_id = ?');
+ $stmt->execute([$participantId, $tenantId]);
+ $row = $stmt->fetch();
+ if ($row === false) {
+ return false;
+ }
+ $legacyMitarbeiterId = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null;
+ $placeholderName = 'Gelöschter Teilnehmer #' . $participantId;
+ $placeholderEmail = "deleted-participant-{$participantId}@invalid.local";
+
+ try {
+ $pdo->beginTransaction();
+
+ if ($legacyMitarbeiterId !== null) {
+ $pdo->prepare('UPDATE kl_Mitarbeiter SET Name = ?, Email = ?, paypalname = NULL, aktiv = 0 WHERE MitarbeiterID = ?')
+ ->execute([$placeholderName, $placeholderEmail, $legacyMitarbeiterId]);
+ }
+
+ $pdo->prepare(
+ 'UPDATE participants
+ SET display_name = ?, email = NULL, email_norm = NULL, paypal_name = NULL, active = 0, user_id = NULL
+ WHERE id = ? AND tenant_id = ?'
+ )->execute([$placeholderName, $participantId, $tenantId]);
+
+ $pdo->commit();
+
+ return true;
+ } catch (Throwable $e) {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+
+ return false;
+ }
+}
+
function ledger_mirror_legacy_payment(PDO $pdo, int $tenantId, int $legacyPaymentId): void
{
$stmt = $pdo->prepare(
diff --git a/docs/m8-haertung.md b/docs/m8-haertung.md
index 42a7eb5..dcee5cb 100644
--- a/docs/m8-haertung.md
+++ b/docs/m8-haertung.md
@@ -149,17 +149,62 @@ scripts/http-smoke.php
geprüft, verifiziert dass keine Passwörter enthalten sind. Testdaten
anschließend entfernt.
+## Lösch-/Anonymisierungsprozess
+
+Umgesetzte Dateien:
+
+```text
+app/ledger.php (ledger_anonymize_participant)
+mitarbeiterverwalten.php
+mandant-loeschen.php
+konto.php
+scripts/http-smoke.php
+```
+
+Zwei getrennte Flows, je nachdem was gelöscht werden soll:
+
+- **Teilnehmer anonymisieren** statt löschen: Name, E-Mail und PayPal-Name
+ werden durch einen nicht-identifizierenden Platzhalter ersetzt, das
+ Mitglied wird deaktiviert und vom Login-Konto getrennt
+ (`ledger_anonymize_participant()`). Die Ledger-Historie (Buchungen)
+ bleibt für die Kassenführung erhalten – konsistent mit dem
+ „keine harten Deletes"-Prinzip für Buchungen. Für den Default-Mandanten
+ wird die verknüpfte `kl_Mitarbeiter`-Zeile mitanonymisiert (`Email` ist
+ dort NOT NULL UNIQUE, bekommt also einen Platzhalter statt NULL). Der
+ Inhaber kann nicht anonymisiert werden. Button „Anonymisieren" mit
+ JS-Bestätigungsdialog in `mitarbeiterverwalten.php`.
+- **Mandant vollständig löschen**: neue Seite `mandant-loeschen.php`
+ (nur Inhaber). Erfordert die exakte Eingabe des Kundenkürzels zur
+ Bestätigung. Löscht die `tenants`-Zeile; alle tenant-scoped Tabellen
+ (`participants`, `ledger_entries`, `notices`, `tenant_memberships`,
+ `audit_log`, `outbound_emails`, `payment_import_batches`/`_rows`,
+ `tenant_settings`) sind per Fremdschlüssel `ON DELETE CASCADE`
+ verknüpft und verschwinden automatisch mit. `users` bleiben bestehen,
+ da ein Login-Konto zu mehreren Mandanten gehören kann. Der migrierte
+ Default-Mandant ist von dieser Selbstbedienungs-Löschung ausgenommen
+ (seine `kl_Mitarbeiter`-Historie hat keinen Fremdschlüssel zu `tenants`
+ und würde verwaisen); dafür verweist die Seite an den Betreiber.
+- Beide Aktionen werden auditiert (`participant.anonymized`; die
+ Mandantenlöschung selbst nicht, da mit ihr auch das Audit-Log dieses
+ Mandanten verschwindet – das ist beabsichtigt, echte Löschung soll
+ keine Spuren hinterlassen).
+
+Live getestet: Mitglied mit Buchungshistorie angelegt, anonymisiert –
+Name/E-Mail weg, Jahresstriche unverändert erhalten. Mandantenlöschung mit
+falscher Bestätigung abgewiesen, mit korrekter Bestätigung vollständig
+durchgeführt; anschließend geprüft, dass wirklich alle tenant-scoped
+Tabellen leer sind und die globale `users`-Zeile erhalten bleibt.
+
## Prüfstatus
- Golden Master: grün mit 104 Assertions.
- M4 Ledger-Migration: grün mit 73 Assertions.
- M3 Settings-Flow: grün mit 13 Assertions.
-- HTTP-Smoke: grün mit 27 geprüften Seiten.
+- HTTP-Smoke: grün mit 28 geprüften Seiten.
- M8 Mandanten-Isolation: grün mit 10 Assertions.
- M8 Rollenmatrix: grün mit 55 Assertions.
## Noch offen in M8
- Content-Security-Policy (braucht Template-Bereinigung der Inline-Styles).
-- Lösch-/Anonymisierungsprozess für Teilnehmer und Kunden.
- Backup-/Restore-Prozess und Monitoring/Fehlerlogging dokumentieren.
diff --git a/docs/saas-umstrukturierungsplan.md b/docs/saas-umstrukturierungsplan.md
index ade3c27..8398168 100644
--- a/docs/saas-umstrukturierungsplan.md
+++ b/docs/saas-umstrukturierungsplan.md
@@ -652,7 +652,12 @@ Schritte:
des eigenen Mandanten (Stammdaten, Teilnehmer, Mitglieder/Rollen,
Buchungen, Hinweise, Importe, Mail-Log, Admin-Protokoll) ohne
Passwörter, protokolliert im Audit-Log.
-- Lösch-/Anonymisierungsprozess für Teilnehmer und Kunden.
+- Lösch-/Anonymisierungsprozess für Teilnehmer und Kunden: erledigt.
+ Teilnehmer werden anonymisiert statt gelöscht (Buchungshistorie bleibt
+ für die Kassenführung erhalten), Mandanten können sich über
+ `mandant-loeschen.php` mit Bestätigungseingabe vollständig selbst
+ löschen (Cascade über Fremdschlüssel); der migrierte Default-Mandant
+ ist davon ausgenommen.
- Rate-Limits und Security Headers: erledigt. Globale Security-Headers
über `app/bootstrap.php` (ohne CSP, siehe `docs/m8-haertung.md`),
DB-gestützte Rate-Limits für Login, Registrierung und Passwort-Reset.
diff --git a/konto.php b/konto.php
index abe424a..9944c40 100644
--- a/konto.php
+++ b/konto.php
@@ -67,6 +67,9 @@ include 'nav.php';
diff --git a/mandant-loeschen.php b/mandant-loeschen.php
new file mode 100644
index 0000000..d38d49f
--- /dev/null
+++ b/mandant-loeschen.php
@@ -0,0 +1,83 @@
+prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
+ saas_logout();
+ header('Location: landing.php?tenant_deleted=1');
+ exit;
+ }
+}
+
+include 'header.php';
+include 'headerline.php';
+include 'nav.php';
+?>
+
+
+
+
Mandant löschen
+
+
+
+
Dieser Mandant ist der migrierte Bestandsmandant und kann hier nicht
+ selbstständig gelöscht werden. Bitte wende dich für diesen Fall an den
+ Betreiber.
+
+
+
+
Diese Aktion kann nicht rückgängig gemacht werden. Alle Daten von
+ „" werden unwiderruflich gelöscht:
+ Mitglieder, alle Buchungen, Hinweise, Importe, Mail-Log und das
+ Admin-Protokoll. Ziehe vorher gegebenenfalls einen
+ Datenexport.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mitarbeiterverwalten.php b/mitarbeiterverwalten.php
index e7d0ecd..aa6740e 100644
--- a/mitarbeiterverwalten.php
+++ b/mitarbeiterverwalten.php
@@ -122,6 +122,24 @@ if($hasAccess){
} else {
$fehler = implode(' ', $ergebnis['errors']);
}
+ } elseif ($aktion === 'anonymisieren') {
+ $participantId = (int)$_POST["mitgliedID"];
+ $mitgliedRolle = null;
+ foreach (ledger_fetch_participants_for_admin($pdo, $tenantId) as $m) {
+ if ($m['participant_id'] === $participantId) {
+ $mitgliedRolle = $m['role'];
+ break;
+ }
+ }
+
+ if ($mitgliedRolle === 'owner') {
+ $fehler = 'Der Inhaber kann nicht anonymisiert werden.';
+ } elseif (ledger_anonymize_participant($pdo, $tenantId, $participantId)) {
+ app_audit_log($pdo, $tenantId, $actorUserId, 'participant.anonymized', 'participant', $participantId);
+ $meldung = 'Mitglied wurde anonymisiert. Name und E-Mail sind entfernt, Buchungen bleiben für die Kassenführung erhalten.';
+ } else {
+ $fehler = 'Das Mitglied konnte nicht anonymisiert werden.';
+ }
}
}
@@ -292,6 +310,16 @@ if($hasAccess){
+
+
+
+
+
diff --git a/scripts/http-smoke.php b/scripts/http-smoke.php
index 67c026a..8511e46 100644
--- a/scripts/http-smoke.php
+++ b/scripts/http-smoke.php
@@ -117,6 +117,11 @@ $checks = [
'path' => 'datenexport.php',
'contains' => ['Login'],
],
+ [
+ 'label' => 'Mandant löschen Login-Schutz',
+ 'path' => 'mandant-loeschen.php',
+ 'contains' => ['Login'],
+ ],
[
'label' => 'Teilnehmerauswertung',
'path' => 'teilnehmerauswertung.php?user_id=1',