From ea7d9a471494cc974d7e05bbfb60ef150a550bdd Mon Sep 17 00:00:00 2001 From: Clemens Creutzburg Date: Tue, 21 Jul 2026 18:33:49 +0200 Subject: [PATCH] Automatische Verbuchung von PayPal-Zahlungen per Mail-Weiterleitung Mandanten leiten ihre PayPal-Benachrichtigungsmails an ein zentrales IMAP-Postfach weiter. Die Zuordnung zum Mandanten erfolgt ueber Plus-Adressierung (zahlungen+@...), 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 --- app/paypal-inbox.php | 326 ++++++++++++++++++++++ app/paypal-mail-parser.php | 173 ++++++++++++ database/migrations/0022_paypal_inbox.sql | 35 +++ env.local.example.php | 16 ++ footer.php | 1 + paypal-zuordnung.php | 142 ++++++++++ scripts/fetch-paypal-payments.php | 175 ++++++++++++ 7 files changed, 868 insertions(+) create mode 100644 app/paypal-inbox.php create mode 100644 app/paypal-mail-parser.php create mode 100644 database/migrations/0022_paypal_inbox.sql create mode 100644 paypal-zuordnung.php create mode 100644 scripts/fetch-paypal-payments.php diff --git a/app/paypal-inbox.php b/app/paypal-inbox.php new file mode 100644 index 0000000..4e043f0 --- /dev/null +++ b/app/paypal-inbox.php @@ -0,0 +1,326 @@ +prepare('SELECT paypal_inbox_token FROM tenants WHERE id = ?'); + $stmt->execute([$tenantId]); + $token = (string) ($stmt->fetchColumn() ?: ''); + if ($token !== '') { + return $token; + } + + for ($try = 0; $try < 5; $try++) { + $candidate = bin2hex(random_bytes(6)); // 12 hex-Zeichen + try { + $pdo->prepare('UPDATE tenants SET paypal_inbox_token = ? WHERE id = ?') + ->execute([$candidate, $tenantId]); + + return $candidate; + } catch (Throwable $e) { + // Kollision (unwahrscheinlich) - erneut versuchen. + } + } + + throw new RuntimeException('Es konnte kein eindeutiger Inbox-Token erzeugt werden.'); +} + +/** + * Ermittelt den Mandanten anhand des Plus-Adress-Tokens (aus der Empfaenger- + * adresse der weitergeleiteten Mail). Null, wenn kein Mandant passt. + */ +function paypal_inbox_resolve_tenant(PDO $pdo, string $token): ?int +{ + $token = trim($token); + if ($token === '') { + return null; + } + $stmt = $pdo->prepare("SELECT id FROM tenants WHERE paypal_inbox_token = ? AND status = 'active' LIMIT 1"); + $stmt->execute([$token]); + $id = $stmt->fetchColumn(); + + return $id === false ? null : (int) $id; +} + +/** + * Zieht den Plus-Token aus einer Empfaengeradresse ("zahlungen+ab12cd@host"). + * Null, wenn kein Token enthalten ist. + */ +function paypal_inbox_extract_token(string $address): ?string +{ + if (preg_match('/\+([A-Za-z0-9]{6,32})@/', $address, $m)) { + return $m[1]; + } + + return null; +} + +/** + * Bucht eine PayPal-Netto-Zahlung als Einzahlung ins Ledger. Beim migrierten + * Default-Mandanten wird zusaetzlich in die Legacy-Tabelle geschrieben und + * gespiegelt (gleiches Muster wie Sammelerfassung/CSV-Import). + * + * @return int Ledger-Entry-ID + */ +function paypal_book_payment(PDO $pdo, int $tenantId, array $participant, int $netCents, ?int $actorUserId): int +{ + $legacyMitarbeiterId = isset($participant['legacy_mitarbeiter_id']) && $participant['legacy_mitarbeiter_id'] !== null + ? (int) $participant['legacy_mitarbeiter_id'] + : 0; + + if ($legacyMitarbeiterId > 0) { + $stmt = $pdo->prepare('INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)'); + $stmt->execute([$legacyMitarbeiterId, $netCents / 100, date('Y-m-d H:i:s')]); + $legacyPaymentId = (int) $pdo->lastInsertId(); + ledger_mirror_legacy_payment($pdo, $tenantId, $legacyPaymentId); + + $idStmt = $pdo->prepare( + "SELECT id FROM ledger_entries WHERE tenant_id = ? AND legacy_table = 'kl_Einzahlungen' AND legacy_id = ? LIMIT 1" + ); + $idStmt->execute([$tenantId, $legacyPaymentId]); + + return (int) $idStmt->fetchColumn(); + } + + return ledger_record_payment($pdo, $tenantId, (int) $participant['participant_id'], $netCents, 'paypal_import', $actorUserId); +} + +/** + * Sucht den passenden Teilnehmer zu einer PayPal-Zahlung: zuerst ueber den + * Zahlernamen (paypal_name/display_name), sonst ueber die Mitteilung (Mitglied + * schreibt dort teils seinen Namen). Liefert null, wenn nicht eindeutig. + * + * @return array{participant_id:int, legacy_mitarbeiter_id:?int, display_name:string}|null + */ +function paypal_match_participant(PDO $pdo, int $tenantId, string $payerName, ?string $note): ?array +{ + $byName = imports_find_participant($pdo, $tenantId, $payerName); + if ($byName !== null) { + return $byName; + } + if ($note !== null && trim($note) !== '') { + return imports_find_participant($pdo, $tenantId, trim($note)); + } + + return null; +} + +/** + * Verarbeitet eine geparste PayPal-Zahlung fuer einen Mandanten: + * - Dedup ueber den Transaktionscode (bereits verarbeitet -> uebersprungen). + * - Eindeutiger Match -> automatisch als Netto-Einzahlung gebucht. + * - Kein eindeutiger Match -> in der Warteschlange ('unmatched') abgelegt. + * + * @param array $parsed Ergebnis von paypal_parse_notification() + * @return array{status:string, payment_id?:int, participant?:string} + */ +function paypal_reconcile(PDO $pdo, int $tenantId, array $parsed, ?int $actorUserId = null): array +{ + $code = (string) ($parsed['transaction_code'] ?? ''); + if ($code === '') { + return ['status' => 'no_code']; + } + $netCents = (int) ($parsed['net_cents'] ?? 0); + if ($netCents <= 0) { + return ['status' => 'no_amount']; + } + + // Dedup: Transaktionscode ist global eindeutig. INSERT IGNORE als Schranke. + $insert = $pdo->prepare( + 'INSERT IGNORE INTO paypal_payments + (tenant_id, transaction_code, payer_name, note, gross_cents, fee_cents, net_cents, paid_at, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $insert->execute([ + $tenantId, + $code, + (string) ($parsed['payer_name'] ?? ''), + $parsed['note'] ?? null, + (int) ($parsed['gross_cents'] ?? 0), + $parsed['fee_cents'] ?? null, + $netCents, + $parsed['date'] ?? null, + 'unmatched', + ]); + if ($insert->rowCount() === 0) { + return ['status' => 'duplicate']; + } + $paymentId = (int) $pdo->lastInsertId(); + + // Eindeutiger Match -> automatisch buchen. + $participant = paypal_match_participant($pdo, $tenantId, (string) ($parsed['payer_name'] ?? ''), $parsed['note'] ?? null); + if ($participant === null) { + return ['status' => 'unmatched', 'payment_id' => $paymentId]; + } + + try { + $pdo->beginTransaction(); + $ledgerId = paypal_book_payment($pdo, $tenantId, $participant, $netCents, $actorUserId); + $pdo->prepare("UPDATE paypal_payments SET participant_id = ?, ledger_entry_id = ?, status = 'booked' WHERE id = ?") + ->execute([(int) $participant['participant_id'], $ledgerId, $paymentId]); + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + // Buchung fehlgeschlagen -> in der Warteschlange belassen. + return ['status' => 'unmatched', 'payment_id' => $paymentId]; + } + + app_audit_log($pdo, $tenantId, $actorUserId, 'paypal_import.auto_booked', 'paypal_payment', $paymentId, [ + 'transaction_code' => $code, + 'net_cents' => $netCents, + 'participant_id' => (int) $participant['participant_id'], + ]); + + return ['status' => 'booked', 'payment_id' => $paymentId, 'participant' => (string) $participant['display_name']]; +} + +/** + * Verarbeitet eine rohe PayPal-Mail vollstaendig: Tenant per Plus-Token, + * Absenderpruefung, Parsing, Abgleich. Fuer den IMAP-Cron und Tests. + * + * @param string $recipient Empfaengeradresse mit Plus-Token + * @param string $fromHeader From-Header der Mail + * @param string $rawBody (dekodierter) Mail-Body + * @return array{status:string, tenant_id?:int} + */ +function paypal_process_raw(PDO $pdo, string $recipient, string $fromHeader, string $rawBody): array +{ + if (!preg_match('/@paypal\.(de|com)/i', $fromHeader)) { + return ['status' => 'not_from_paypal']; + } + $token = paypal_inbox_extract_token($recipient); + if ($token === null) { + return ['status' => 'no_token']; + } + $tenantId = paypal_inbox_resolve_tenant($pdo, $token); + if ($tenantId === null) { + return ['status' => 'unknown_tenant']; + } + $parsed = paypal_parse_notification($rawBody); + if ($parsed === null) { + return ['status' => 'not_a_payment']; + } + + $result = paypal_reconcile($pdo, $tenantId, $parsed); + $result['tenant_id'] = $tenantId; + + return $result; +} + +/** + * Offene (noch nicht zugeordnete) PayPal-Zahlungen eines Mandanten. + * + * @return list + */ +function paypal_fetch_unmatched(PDO $pdo, int $tenantId): array +{ + $stmt = $pdo->prepare( + "SELECT id, transaction_code, payer_name, note, net_cents, paid_at, created_at + FROM paypal_payments + WHERE tenant_id = ? AND status = 'unmatched' + ORDER BY created_at DESC, id DESC" + ); + $stmt->execute([$tenantId]); + + return $stmt->fetchAll(); +} + +/** + * Ordnet eine Zahlung aus der Warteschlange manuell einem Teilnehmer zu und + * bucht den Netto-Betrag als Einzahlung. + * + * @return array{ok:bool, error?:string} + */ +function paypal_assign_payment(PDO $pdo, int $tenantId, int $paymentId, int $participantId, ?int $actorUserId): array +{ + $stmt = $pdo->prepare("SELECT id, net_cents, status FROM paypal_payments WHERE id = ? AND tenant_id = ?"); + $stmt->execute([$paymentId, $tenantId]); + $payment = $stmt->fetch(); + if ($payment === false || (string) $payment['status'] !== 'unmatched') { + return ['ok' => false, 'error' => 'Diese Zahlung ist nicht (mehr) offen.']; + } + + $summaries = ledger_fetch_participant_summaries($pdo, $tenantId, ['participant_ids' => [$participantId]]); + $participant = $summaries[0] ?? null; + if ($participant === null) { + return ['ok' => false, 'error' => 'Das gewählte Mitglied wurde nicht gefunden.']; + } + + try { + $pdo->beginTransaction(); + $ledgerId = paypal_book_payment($pdo, $tenantId, $participant, (int) $payment['net_cents'], $actorUserId); + $pdo->prepare("UPDATE paypal_payments SET participant_id = ?, ledger_entry_id = ?, status = 'booked' WHERE id = ?") + ->execute([$participantId, $ledgerId, $paymentId]); + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + return ['ok' => false, 'error' => 'Die Zahlung konnte nicht gebucht werden.']; + } + + app_audit_log($pdo, $tenantId, $actorUserId, 'paypal_import.manual_assigned', 'paypal_payment', $paymentId, [ + 'participant_id' => $participantId, + 'net_cents' => (int) $payment['net_cents'], + ]); + + return ['ok' => true]; +} + +/** + * Verwirft eine offene Zahlung aus der Warteschlange (z. B. Fehleingang, keine + * Kaffeelisten-Zahlung). + */ +function paypal_ignore_payment(PDO $pdo, int $tenantId, int $paymentId, ?int $actorUserId): array +{ + $stmt = $pdo->prepare("UPDATE paypal_payments SET status = 'ignored' WHERE id = ? AND tenant_id = ? AND status = 'unmatched'"); + $stmt->execute([$paymentId, $tenantId]); + if ($stmt->rowCount() === 1) { + app_audit_log($pdo, $tenantId, $actorUserId, 'paypal_import.ignored', 'paypal_payment', $paymentId); + + return ['ok' => true]; + } + + return ['ok' => false, 'error' => 'Diese Zahlung ist nicht (mehr) offen.']; +} diff --git a/app/paypal-mail-parser.php b/app/paypal-mail-parser.php new file mode 100644 index 0000000..9c607ea --- /dev/null +++ b/app/paypal-mail-parser.php @@ -0,0 +1,173 @@ + gezogen, weil der + * dort am zuverlaessigsten steht. + */ + +/** + * Wandelt den (ggf. UTF-16-kodierten) Mail-Body in normalisierten UTF-8-Text um: + * Skript/Style raus, Tags durch Leerzeichen ersetzt, Entities dekodiert, NBSP + * und Mehrfach-Whitespace zusammengefasst. + */ +function paypal_mail_to_text(string $body): string +{ + // UTF-16-BOM erkennen und nach UTF-8 wandeln (weitergeleitete .htm-Exporte). + if (str_starts_with($body, "\xFF\xFE") || str_starts_with($body, "\xFE\xFF")) { + $converted = @iconv('UTF-16', 'UTF-8//IGNORE', $body); + if ($converted !== false) { + $body = $converted; + } + } + + $body = preg_replace('#<(script|style)[^>]*>.*?#is', ' ', $body) ?? $body; + $text = preg_replace('#<[^>]+>#', ' ', $body) ?? $body; + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $text = str_replace(["\xC2\xA0", "\xEF\xBB\xBF"], ' ', $text); // NBSP, BOM + $text = preg_replace('/\s+/u', ' ', $text) ?? $text; + + return trim($text); +} + +/** + * Deutscher Betrag ("1.234,56") -> Cent. Null bei ungueltigem Format. + */ +function paypal_amount_to_cents(string $raw): ?int +{ + $raw = trim($raw); + $raw = str_replace('.', '', $raw); // Tausenderpunkte + $raw = str_replace(',', '.', $raw); + if (!preg_match('/^\d+(\.\d{1,2})?$/', $raw)) { + return null; + } + + return (int) round(((float) $raw) * 100); +} + +/** + * Deutsches Datum ("7. Juli 2026") -> "Y-m-d". Null, wenn nicht erkennbar. + */ +function paypal_german_date_to_iso(string $raw): ?string +{ + $months = [ + 'januar' => 1, 'februar' => 2, 'märz' => 3, 'maerz' => 3, 'april' => 4, + 'mai' => 5, 'juni' => 6, 'juli' => 7, 'august' => 8, 'september' => 9, + 'oktober' => 10, 'november' => 11, 'dezember' => 12, + ]; + if (!preg_match('/(\d{1,2})\.\s*([\p{L}]+)\s+(\d{4})/u', $raw, $m)) { + return null; + } + $day = (int) $m[1]; + $monthName = mb_strtolower_compat($m[2]); + $year = (int) $m[3]; + if (!isset($months[$monthName])) { + return null; + } + + return sprintf('%04d-%02d-%02d', $year, $months[$monthName], $day); +} + +/** + * Kleinschreibung ohne mbstring-Abhaengigkeit (nur fuer deutsche Monatsnamen + * inkl. Umlaut noetig). + */ +function mb_strtolower_compat(string $s): string +{ + if (function_exists('mb_strtolower')) { + return mb_strtolower($s, 'UTF-8'); + } + $s = strtr($s, ['Ä' => 'ä', 'Ö' => 'ö', 'Ü' => 'ü']); + + return strtolower($s); +} + +/** + * Betrag in Cent aus dem Text nach einem Label ("Erhaltener Betrag" etc.). + * Waehrungssymbol zwischen Zahl und "EUR" wird ignoriert. + */ +function paypal_extract_labeled_amount(string $text, string $labelRegex): ?int +{ + if (preg_match('/' . $labelRegex . '\s*([\d.]+,\d{2})\s*\S*\s*EUR/u', $text, $m)) { + return paypal_amount_to_cents($m[1]); + } + + return null; +} + +/** + * Parst eine PayPal-Zahlungseingangs-Mail. + * + * @return array{ + * payer_name: string, + * transaction_code: ?string, + * gross_cents: int, + * fee_cents: ?int, + * net_cents: int, + * date: ?string, + * note: ?string + * }|null Null, wenn es keine erkennbare Zahlungseingangs-Mail ist. + */ +function paypal_parse_notification(string $body): ?array +{ + $text = paypal_mail_to_text($body); + + // Kernmarker: " hat dir … EUR gesendet". Fehlt er, ist es + // keine Zahlungseingangs-Mail (z. B. Werbung, Sicherheitsmail). + if (!preg_match('/([\p{L}\p{M}.\'\- ]{2,60}?)\s+hat dir\s+([\d.]+,\d{2})\s*\S*\s*EUR\s+gesendet/u', $text, $m)) { + return null; + } + $payer = trim($m[1]); + $grossCents = paypal_amount_to_cents($m[2]) ?? 0; + + // Transaktionscode primaer aus der Detail-URL (steht dort am zuverlaessigsten), + // sonst nach dem Label. + $code = null; + if (preg_match('#/activities/details/([A-Z0-9]{12,25})#', $body, $mc)) { + $code = $mc[1]; + } elseif (preg_match('/Transaktionscode\s+([A-Z0-9]{12,25})/u', $text, $mc)) { + $code = $mc[1]; + } + + $receivedCents = paypal_extract_labeled_amount($text, 'Erhaltener Betrag'); + $feeCents = paypal_extract_labeled_amount($text, 'Geb(?:ü|ue)hr'); + $summeCents = paypal_extract_labeled_amount($text, 'Summe'); + + // Netto = tatsaechlich angekommener Betrag: bei Waren & Dienstleistungen die + // "Summe" (nach Abzug der Gebuehr), bei Freunde & Familie der erhaltene + // Betrag (keine Gebuehr, keine Summe-Zeile), im Zweifel der Sende-Betrag. + $netCents = $summeCents ?? $receivedCents ?? $grossCents; + + $date = null; + if (preg_match('/Transaktionsdatum\s+(\d{1,2}\.\s*[\p{L}]+\s+\d{4})/u', $text, $md)) { + $date = paypal_german_date_to_iso($md[1]); + } + + // Mitteilung des Zahlers ("Mitteilung von ") bis zum naechsten + // bekannten Abschnitt. Der Zahlername dient als Anker, da er Leerzeichen + // enthalten kann. + $note = null; + $noteRegex = '/Mitteilung von\s+' . preg_quote($payer, '/') + . '\s+(.+?)\s*(?:Geb(?:ü|ue)hr|Summe|Erhaltener Betrag|Transaktions|Lieferadresse|Du siehst|Bist du|Copyright|$)/u'; + if (preg_match($noteRegex, $text, $mn)) { + $note = trim($mn[1]); + } + + return [ + 'payer_name' => $payer, + 'transaction_code' => $code, + 'gross_cents' => $grossCents, + 'fee_cents' => $feeCents, + 'net_cents' => $netCents, + 'date' => $date, + 'note' => $note, + ]; +} diff --git a/database/migrations/0022_paypal_inbox.sql b/database/migrations/0022_paypal_inbox.sql new file mode 100644 index 0000000..904d372 --- /dev/null +++ b/database/migrations/0022_paypal_inbox.sql @@ -0,0 +1,35 @@ +-- Automatischer PayPal-Abgleich per weitergeleiteter Benachrichtigungsmail. +-- +-- Jeder Mandant bekommt einen eindeutigen Token fuer die Plus-Adresse +-- (z. B. zahlungen+@domain). Der Admin richtet eine Weiterleitung von +-- PayPal an diese Adresse ein; der Token ordnet die Mail eindeutig dem +-- Mandanten zu. Der Token ist nicht sensibel und wird bei Bedarf erzeugt. +ALTER TABLE tenants + ADD COLUMN paypal_inbox_token VARCHAR(32) NULL AFTER slug, + ADD UNIQUE KEY uq_tenants_paypal_inbox_token (paypal_inbox_token); + +-- Erkannte PayPal-Zahlungen: Dedup ueber den global eindeutigen +-- Transaktionscode und gleichzeitig Warteschlange fuer nicht eindeutig +-- zuordenbare Zahlungen (participant_id IS NULL, status 'unmatched'). +CREATE TABLE IF NOT EXISTS paypal_payments ( + id INT AUTO_INCREMENT PRIMARY KEY, + tenant_id INT NOT NULL, + transaction_code VARCHAR(40) NOT NULL, + payer_name VARCHAR(255) NOT NULL DEFAULT '', + note VARCHAR(255) NULL, + gross_cents INT NOT NULL DEFAULT 0, + fee_cents INT NULL, + net_cents INT NOT NULL DEFAULT 0, + paid_at DATE NULL, + participant_id INT NULL, + ledger_entry_id INT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'unmatched', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_paypal_payments_txn (transaction_code), + KEY idx_paypal_payments_tenant_status (tenant_id, status), + CONSTRAINT fk_paypal_payments_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + CONSTRAINT fk_paypal_payments_participant + FOREIGN KEY (participant_id) REFERENCES participants(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/env.local.example.php b/env.local.example.php index c169840..09d4dc1 100644 --- a/env.local.example.php +++ b/env.local.example.php @@ -41,6 +41,22 @@ putenv('STRIPE_WEBHOOK_SECRET='); putenv('DOLIBARR_URL=https://verwaltung.ctb-it.de'); putenv('DOLIBARR_API_KEY='); +// Automatische Verbuchung weitergeleiteter PayPal-Zahlungsmails. +// PAYPAL_INBOX_BASE ist die zentrale Sammeladresse; jeder Mandant bekommt +// daraus per Plus-Adressierung eine eigene Variante (zahlungen+@...), +// die im Backend unter "PayPal-Zahlungen" angezeigt wird. Das Postfach muss +// Plus-Adressierung an dieselbe Mailbox zustellen (Catch-All). +// Die Zugangsdaten sind bewusst Server-/Betreiber-Einstellungen und NICHT +// pro Mandant konfigurierbar. Abruf per Cron: +// php scripts/fetch-paypal-payments.php +putenv('PAYPAL_INBOX_BASE=zahlungen@kaffeeliste.de'); +putenv('PAYPAL_IMAP_HOST='); +putenv('PAYPAL_IMAP_PORT=993'); +putenv('PAYPAL_IMAP_FLAGS=/imap/ssl'); +putenv('PAYPAL_IMAP_USER='); +putenv('PAYPAL_IMAP_PASS='); +putenv('PAYPAL_IMAP_MAILBOX=INBOX'); + // WICHTIG: DEV_AUTH_EMAIL hier NICHT setzen (auch nicht auskommentiert // stehen lassen und aus Versehen aktivieren). Ist diese Variable gesetzt, // wird JEDER Besucher ohne echten Login automatisch als dieser Nutzer diff --git a/footer.php b/footer.php index d914eee..841edfb 100644 --- a/footer.php +++ b/footer.php @@ -32,6 +32,7 @@ $canUseVerwaltungGroup = $saasCanUseLedgerNav || $legacyCanUseAdminNav; Verwaltung