# Changelog --- ## 2026-08-26 – Fehlende Admin-Prüfung im BankinstituteController (#203) - **`src/Controllers/BankinstituteController.php`:** `index()`, `check()` und `import()` prüften bislang keine Rolle, obwohl der globale Admin-Gate in `public/index.php` nur `/useradmin*` abdeckt und `SettingsController` erst seit Fix #200 admin-pflichtig ist – dieser Controller wurde dabei übersehen. Insbesondere `import()` ersetzt über `Bankinstitut::replaceAll()` die komplette BLZ/BIC-Referenztabelle (u. a. für die FinTS-Direktverbindung genutzt) anhand einer hochgeladenen Datei; jeder eingeloggte Nicht-Admin-Nutzer konnte diese systemweite Tabelle beliebig überschreiben. Alle drei Methoden rufen jetzt `Auth::requireAdmin()` zu Beginn auf. `search()` bleibt bewusst offen, da sie unter der eigenen Route `/bankinstitute/search` für die BLZ-Suche beim Anlegen einer FinTS-Verbindung (`bank/fints.php`) von allen eingeloggten Nutzern benötigt wird. - **`tests/e2e/specs/settings-admin-authorization.spec.js`:** Fälle für `GET /settings/bankinstitute`, `POST /settings/bankinstitute/check` und `POST /settings/bankinstitute/import` ergänzt (403 für Nicht-Admins), sowie ein Check, dass `GET /bankinstitute/search` weiterhin für alle eingeloggten Nutzer erreichbar bleibt. ## 2026-08-26 – Fehlende Admin-Prüfung bei /settings/logo (Logo-Upload) (#202) - **`src/Controllers/SettingsController.php`:** `uploadLogo()` wurde bei Fix #200 übersehen und rief `Auth::requireAdmin()` nicht auf, obwohl `deleteLogo()` bereits abgesichert ist. Da `/settings` nicht im globalen Admin-Gate in `public/index.php` enthalten ist, konnte jeder eingeloggte Nicht-Admin-Benutzer per `POST /settings/logo` das Firmenlogo austauschen. `Auth::requireAdmin()` als erste Zeile ergänzt, analog zu `deleteLogo()`. - **`tests/e2e/specs/settings-admin-authorization.spec.js`:** Fall für `POST /settings/logo` (Multipart-Upload) ergänzt, der wie die übrigen Endpoints 403 für Nicht-Admins erwartet. ## 2026-08-24 – Fehlende Admin-Prüfung bei mehreren SettingsController-Endpoints (#200) - **`src/Controllers/SettingsController.php`:** `deleteLogo()`, `llmTest()`, `bankImport()`, `createBankImportFormat()`, `updateBankImportField()`, `deleteBankImportFormat()`, `storeSuggestion()` und `dismissSuggestion()` prüften bislang nur den generellen Login (`Auth::require()` im Router-Gate), nicht die Rolle `admin` – anders als `update()`/`mailTest()`, deren Code-Kommentar diesen Bereich bewusst als admin-pflichtig begründet. Ein eingeloggter Benutzer mit Rolle `user` konnte damit das Firmenlogo löschen, CSV-Bankimport-Formate anlegen/ändern/löschen (Integritätsrisiko für importierte Buchungen) und den LLM-Verbindungstest auslösen. Alle acht Methoden rufen jetzt `Auth::requireAdmin()` konsistent zu Beginn auf. - **`tests/e2e/specs/settings-admin-authorization.spec.js`** (neu): prüft auf HTTP-Ebene, dass ein Nicht-Admin-Account von den o.g. Endpoints mit 403 abgewiesen wird, während ein Admin weiterhin Zugriff hat. ## 2026-08-21 – scripts/ nach Ausführungsort aufgeräumt: Host / Container / intern (#196) - **`scripts/`:** Bislang lagen 16 Skripte mit vier verschiedenen Aufrufkonventionen flach nebeneinander – von außen war nicht erkennbar, was direkt vom Host aufrufbar ist, was `docker compose exec` braucht und was nie manuell laufen darf. Jetzt gibt es genau ein Ordnungskriterium: `scripts/` (Host, ausnahmslos direkt aufrufbar), `scripts/container/` (`docker compose exec web ...`) und `scripts/intern/` (Entrypoint/Container-Start, nie manuell). - **Verschoben und auf Bindestriche vereinheitlicht:** `analyse_nextcloud.sh` → `scripts/analyse-nextcloud.sh`; `e2e-cleanup.php`, `e2e-user.php`, `ki-batch.php`, `nextcloud-import.php`, `reset-password.php`, `seed-testdata.php`, `run-tests.sh` → `scripts/container/`; `encrypt_bank_secrets.php` → `scripts/container/encrypt-bank-secrets.php`; `migrate.php`, `uploads-uebernahme.sh` → `scripts/intern/`. `backup.sh`, `restore.sh`, `update.sh`, `migrate-datenverzeichnis.sh` bleiben unverändert in `scripts/`. - **`scripts/migrate-uploads-to-bindmount.sh`** entfernt: beschrieb einen Umzug in ein `./uploads/`-Verzeichnis, das es seit dem Datenverzeichnis-Umbau nicht mehr gibt, und verwies selbst bereits auf seinen Nachfolger `migrate-datenverzeichnis.sh`. - **`scripts/test.sh`, `scripts/e2e.sh`** (neu): dünne Host-Wrapper für die beiden meistgenutzten Abläufe – delegieren an `docker compose exec -u www-data web php tests/run.php` bzw. `tests/e2e/run.sh`, ohne eigene Logik. - **`docker-entrypoint.sh`, `scripts/update.sh`, `scripts/restore.sh`, `scripts/migrate-datenverzeichnis.sh`** sowie die PHP-Einstiegspunkte selbst (`dirname(__DIR__)`/`chdir(dirname(__DIR__))`-Aufrufe) auf die neue Verzeichnistiefe angepasst. - **`tests/71_DatenverzeichnisPfadeTest.php`, `tests/75_AnalyseNextcloudSkriptTest.php`, `tests/76_DatenverzeichnisUebernahmeTest.php`:** Pfade auf die neue Struktur nachgezogen. - Doku (`README.md`, `CLAUDE.md`, `docs/konfiguration.md`, `docs/update-backup.md`, `docs/datenbankstruktur.md`, `tests/e2e/README.md`) folgt den neuen Pfaden; Verweise in älteren Changelog-Einträgen oben bleiben unverändert, da sie den zum jeweiligen Zeitpunkt gültigen Stand dokumentieren. ## 2026-08-21 – Lieferantenliste (/suppliers) lief in HTTP 500: fehlender View-Import (#198) - **`src/Views/suppliers/list.php`:** Die View rief `View::formatDate()` ohne `use Steuerkram\View;` auf. Views werden per `include` eingebunden und laufen dadurch im globalen Namespace – das `use` des einbindenden Controllers gilt dort nicht, der Aufruf ging also auf `\View::formatDate()` und die Seite starb mit `Class "View" not found`. Sichtbar wurde das erst mit dem ersten Lieferanten, weil die Zeile in der `foreach`-Schleife steht. Import ergänzt; alle übrigen Views hatten ihn bereits. - **`tests/99_ViewKlassenImportTest.php`** (neu): prüft alle Views in `src/Views/` statisch – jeder unqualifizierte `Klasse::`-Aufruf muss entweder per `use` importiert sein oder eine global existierende Klasse (`DateTime`, `PDO`, …) meinen. Fängt fehlende Importe ab, bevor sie im Betrieb auffallen. - **`tests/e2e/specs/rechnung-export.spec.js`:** klickte noch `button[onclick^="showAddLineDialog"]`; der Inline-Handler-Umbau aus #160 hat daraus `data-action="show-add-line-dialog"` gemacht – fünf E2E-Tests liefen deshalb in den Timeout. Selektor nachgezogen. ## 2026-08-21 – run-tests.sh: Commit-Gate meldete fälschlich Erfolg bei fehlgeschlagenen Tests (#195) - **`scripts/run-tests.sh`:** Die Auswertung von `php tests/run.php --json` hing an `python3`, das weder im Container noch auf dem Host installiert ist – der Fallback `|| echo "0"` verwandelte jeden Parse-Fehler in „0 fehlgeschlagene Tests", das Commit-Gate griff also nie. Erfolg/Misserfolg hängt jetzt direkt am Exit-Code von `php tests/run.php --json`; Detailauswertung (`failed`, `failures`) läuft über `php -r` statt Python. Lässt sich die JSON-Ausgabe nicht auswerten, bricht das Skript mit einem Fehler ab statt fälschlich Erfolg zu melden. Der zuvor ungenutzte `EXIT_CODE` (verworfen durch ein voranstehendes `|| true`) wird jetzt tatsächlich ausgewertet. Die JSON-Payloads für die automatische Issue-Erstellung werden ebenfalls per `php -r` statt Python gebaut (Body-Text jetzt mit echten Zeilenumbrüchen statt literalem `\n`). - **`tests/run.php` / `tests/TestCase.php`:** `--json`-Modus gab bisher zusätzlich zur JSON-Ausgabe die komplette Fortschrittsanzeige (✓/✗ je Assertion) auf STDOUT aus – dadurch war die JSON-Ausgabe nie für sich allein parsbar, unabhängig vom Python-Problem. `TestCase::setQuiet()` unterdrückt diese Ausgabe im `--json`-Modus; PHP-Warnungen/-Notices einzelner Tests gehen in diesem Modus zusätzlich per `ini_set('display_errors', 'stderr')` nach STDERR statt STDOUT. - **`CLAUDE.md`:** Aufrufzeilen für `./scripts/run-tests.sh` auf `docker compose exec -u www-data web ./scripts/run-tests.sh` korrigiert – das Skript läuft nur im Container, auf dem Host ist kein PHP installiert. ## 2026-08-21 – SMTP-SSRF-Schutz: IP-Pinning gegen DNS-Rebinding, Admin-Pflicht für SMTP-Einstellungen (#193) - **`src/Services/SsrfGuard.php`:** neue `resolvePinnedHost(string $host): string[]` – wie `assertPublicHost()`, gibt aber zusätzlich die geprüfte(n) IP-Adresse(n) zurück, analog zu `resolvePinned()` für https-URLs. `assertPublicHost()` ruft sie jetzt intern auf. - **`src/Services/SmtpMailer.php`:** `connect()` löste den SMTP-Host bei `SsrfGuard::assertPublicHost()` und beim späteren `stream_socket_client('tcp://'.$host …')` zweimal unabhängig auf – ein Angreifer mit kurzer DNS-TTL konnte zwischen Prüfung und Verbindungsaufbau auf eine private/interne Adresse umschwenken (DNS-Rebinding/TOCTOU). Neue private Methode `buildConnectTarget()` nutzt die von `resolvePinnedHost()` gepinnte IP für `stream_socket_client()`; der ursprüngliche Hostname bleibt für SNI/Zertifikatsprüfung über `stream_context`-Option `ssl.peer_name` erhalten. - **`src/Controllers/SettingsController.php`:** `update()` und `mailTest()` verlangten bislang keine Admin-Rolle – jeder eingeloggte Benutzer konnte `mail_smtp_host`/`mail_smtp_port` setzen und per „SMTP testen" sofort einen Verbindungsversuch des Servers auslösen. Beide Methoden rufen jetzt `Auth::requireAdmin()`. `GET /settings` (Firmendaten anzeigen) bleibt für alle eingeloggten Benutzer erreichbar. - **`tests/98_SmtpSsrfPinningTest.php`** (neu): `SsrfGuard::resolvePinnedHost()` sowie `SmtpMailer::buildConnectTarget()` (per Reflection) – Verbindungsziel enthält die geprüfte IP statt des Hostnamens, `peer_name` bleibt gepinnt, private Hosts werden weiterhin abgelehnt. - **`tests/e2e/specs/settings-smtp-authorization.spec.js`** (neu): Nicht-Admin erhält 403 auf `POST /settings` und `POST /settings/mail-test`, Admin weiterhin nicht. ## 2026-08-18 – Inline-Styles und Inline-Handler reduziert (#160) Erster Umsetzungsschritt der in Issue #160 beschriebenen schrittweisen Reduktion (975 → 858 `style="`-Vorkommen, 51 → 0 Inline-Handler in `src/Views`). Kein Big-Bang über alle Views, sondern die im Ticket priorisierten Schritte 1–4: - **`public/static/css/app.css`:** neue wiederverwendbare Klassen für die häufigsten Muster – `.modal-overlay`/`.modal-card` (Dialog-Overlay + -Karte), `.icon-btn` (17 identische Icon-Button-Deklarationen quer über 11 Views), `.badge-lg`, `.alert`/`.alert-warning`/`.alert-info`, `.row-clickable`/`data-row-href` (klickbare Tabellenzeilen), `.btn-round-icon`, `.autosize-textarea`, diverse `invoice-*`- und `booking-table`-Klassen für die Rechnungsvorschau/Zahlungsübersicht sowie kleinere Utilities (`.nowrap`, `.min-w-0`, `.m-0`, `.text-danger`, `.text-faint` …). Laufzeitfarben (Kontofarbe, Icon-Warnfarbe, Logo-Maße) bleiben als CSS-Variable im `style`-Attribut, wie im Ticket als Zielbild vorgegeben. - **Modal-Overlay vereinheitlicht:** alle 12 Views mit Dialogen (`bank/_modal_*.php`, `belege/_crop_modal.php`, `belege/_anlage_modal.php`, `belege/_vorschau.php`, `assets/form.php`, `assets/list.php`, `buchungsvorlagen/modal.php`, `invoices/show.php`) nutzen jetzt `.modal-overlay`/`.modal-card` statt ausgeschriebener Inline-Styles je Vorkommen. - **`invoices/show.php`** (126 → 3 Inline-Styles, `bank/fints.php` und `belege/detail.php` lagen durch die Aufteilung aus #157/#161 bereits bei 0): Rechnungsvorschau-Tabellen, Zahlungs-Modal und Toolbar auf Klassen umgestellt; die beiden verbleibenden Modale (E-Mail senden, Zahlungen) laufen jetzt über das bestehende `data-modal-open`/`data-modal-close`-Muster aus `app.js` statt über `onclick`. - **53 Inline-Handler entfernt:** `onclick=`/`onchange=`/`oninput=` in `src/Views` durch `id`-/`data-*`-Attribute plus zentrale `addEventListener()`-Verdrahtung ersetzt – wo die Zielfunktion bereits Seiten-JS ist (z. B. `beleg-form.js`, `beleg-ui.js`, `beleg-crop.js`, `fints.js`), direkt dort; für generische, seitenübergreifende Muster (klickbare Tabellenzeile, Rechnungspositionen entfernen/hinzufügen, Buchungsvorlagen-Zeilen inkl. dynamisch per JS erzeugter Zeilen) zentrale `data-action`-Dispatcher in `app.js` bzw. im jeweiligen View-Script. Verhalten unverändert, keine Funktionsänderung. - **`tests/97_InlineStyleBudgetTest.php`** (neu): zählt `style="`-Vorkommen unter `src/Views` und schlägt bei Überschreitung des Budgets (aktuell 900) fehl; prüft zusätzlich, dass keine `onclick=`/`onchange=`/`oninput=`-Attribute mehr vorkommen. Verhindert, dass neue Views den Zähler wieder hochtreiben. - Restliche ~600 Inline-Styles in weiteren Views (`tax/euer_show.php`, `buchungsvorlagen/form.php`, `assets/form.php`, `settings/form.php`, `bank/upload.php` u. a.) sind laut Ticket bewusst nicht Teil dieses Schritts – die Reduktion ist als mehrstufiger Prozess angelegt, ein Rundumschlag über alle Views wäre nicht reviewbar gewesen. - Manuelle Abnahme der Optik war mangels Browser in dieser Umgebung nicht möglich; die Umstellung ist als mechanische 1:1-Übertragung (identische Werte, keine neuen Layoutentscheidungen) ausgeführt, um das Risiko sichtbarer Änderungen zu minimieren. ## 2026-08-18 – Restposten: O-CHAT.html, fints.php/bankimport.php, Request-Abstraktion (#161) - **(a) `public/O-CHAT.html`:** Testartefakt einer eigenständigen Ollama-Chat-Oberfläche wurde von Apache direkt ausgeliefert (`.htaccess` routet nur nicht-existierende Dateien auf `index.php`) und lief damit an der Authentifizierung vorbei. Nach `BEISPIELE/O-CHAT.html` verschoben (gitignored, wird nicht ausgeliefert). **`tests/93_WebrootSauberkeitTest.php`** (neu) hält `public/` dauerhaft sauber. - **(b) `src/Views/bank/fints.php`** (986 → unter 300 Zeilen): Markup in Teil-Views (`_connection_grid.php`, `_modal_new_connection.php`, `_modal_edit_connection.php`, `_modal_account_select.php`, `_modal_tan_input.php`, `_modal_run_log.php`), die 517 Zeilen Inline-JavaScript nach `public/static/js/fints.js`. Die Verbindungsliste wird nicht mehr per PHP-Interpolation ins Script geschrieben, sondern über `data-connections` auf einem eigenen Datencontainer-Element gelesen. Keine Verhaltensänderung am FinTS-Ablauf (TAN-Eingabe, Kontoauswahl, Drag&Drop-Sortierung, Abbruch). - **(c) `src/Views/settings/bankimport.php`** (797 → unter 300 Zeilen): Markup in Teil-Views (`_suggestion_card.php`, `_format_card.php`), die 293 Zeilen JavaScript nach `public/static/js/bankimport.js`. Datumsformat-Optionen für die Inline-Bearbeitung wandern über `data-date-format-options` statt per `json_encode()` direkt ins Script. - **(d) `src/Request.php`** (neu): schlanke statische Hilfsklasse für Zugriffe auf `$_POST`/`$_GET` – `post()`, `postInt()`, `postFloat()` (deutsches Dezimalkomma), `postDate()` (liefert `null` bei ungültigem Format, normalisiert auf `Y-m-d`), `get()`, `has()`. Kein Framework, keine neue Abhängigkeit. `BankController` ist als erster Controller umgestellt (180 → 126 direkte `$_POST`/`$_GET`-Zugriffe in `src/Controllers/`); verbliebene Array-Zugriffe (`$_GET` als Filter-Array, `dup_idx[]`, `computeFilterFromPost($_POST)`) bleiben unverändert, da Request nur einzelne Werte kapselt. Bisheriges Verhalten bei fehlenden/ungültigen Werten bleibt exakt erhalten. Die übrigen Controller folgen schrittweise in eigenen Tickets. **`tests/94_RequestHelperTest.php`** (neu). ## 2026-08-18 – Modale Dialoge: Label und Feld in einer Zeile, Hilfetexte als Tooltip (#189) - Einige Dialoge (v.a. „Neue Direktverbindung"/„Verbindung bearbeiten" unter Bank, „Rechnung per E-Mail senden"/„Zahlungen" unter Rechnungen) waren durch zweizeilige Formularfelder (Bezeichnung über dem Feld) höher als ein Standardbildschirm und mussten gescrollt werden. - **`public/static/css/app.css`:** neue Klasse `.modal-inline-form` – wird auf die Dialogbox gesetzt und bringt darin jedes `.form-group` per Flexbox in eine Zeile (Bezeichnung links, Feld rechts, mit Umbruch bei zu wenig Platz statt Überlauf). - **`src/Views/bank/fints.php`, `src/Views/invoices/show.php`, `src/Views/assets/form.php`, `src/Views/belege/_vorschau.php`:** betroffene Dialoge auf `.modal-inline-form` umgestellt; das Zahlungs-Erfassungsformular im Rechnungs-Dialog (bereits zweispaltig) bekommt je Spalte ebenfalls Bezeichnung und Feld nebeneinander statt gestapelt. - Erklärende Texte, die bisher als eigene Zeile unter dem Feld standen (z.B. „optional", Hinweise zu Abruf-Zeitraum/TAN-Verfahren, FinTS-vs.-API-Erklärung), sind entfernt und stehen jetzt als `title`-Attribut auf der Feldbezeichnung – erscheinen also nur noch als Tooltip beim Hovern. ## 2026-08-18 – Bankleitzahlen-Verzeichnis: FinTS-Institutsliste unter Einstellungen einlesen (#187) - Neuer Punkt unter Einstellungen (`/settings/bankinstitute`, analog zu „CSV Bankimport Formate"): nimmt die CSV-Institutsliste der Deutschen Kreditwirtschaft (hbci-zka.de) auf, damit `bank_providers.default_fints_url`/die FinTS-Server-URL nicht mehr per Migration gepflegt werden muss (siehe `069_bank_providers_fints_url.sql`). - **`migrations/094_bankinstitute.sql`:** neue Tabelle `bankinstitute` (blz, name, ort, bic, pin_tan_url) – reine Nachschlagedaten, kein Fremdschlüssel, bewusst kein UNIQUE auf `blz` (die Quelldatei führt dieselbe Bankleitzahl mehrfach, eine Zeile je Filiale). Dazu `bankinstitute_import` als Singleton-Tabelle für die Metadaten des letzten Imports (Stand der Daten, Zeilen, Anzahl Bankleitzahlen, Zugangspunkte, Zeitpunkt). - **`src/Services/FintsInstituteImport.php`** (neu): liest die Datei über `fgetcsv()` auf einem Stream statt zeilenweise, damit ein über zwei Zeilen laufender, in Anführungszeichen stehender Institutsname (z.B. Oldenburgische Landesbank) korrekt als eine Zeile gelesen wird. Konvertiert ISO-8859-1 → UTF-8, erkennt das Trennzeichen automatisch, spricht Spalten über ihre Überschrift an (nicht über ihre Position: `BLZ`/`Bankleitzahl`, `Institut`/`Kreditinstitut`, `Ort`, `BIC`, diverse PIN/TAN-Aliase) und bricht ab, wenn BLZ- oder Institut-Spalte fehlt. Zeilen ohne BLZ oder Institut werden übersprungen (deckt die hunderten leeren Zeilen mit laufender Nummer am Dateiende ab, ohne dass eine feste Zeilenzahl konfiguriert werden muss). Enthält eine Zelle in der PIN/TAN-Spalte keine `http(s)://`-Adresse, gilt sie als Text ohne Zugang. Die Chipkartenverfahren-Spalten (DDV, RDH-1…10, RAH-7/9/10) werden schlicht nicht gelesen. „Stand der Daten" wird heuristisch aus einer Titelzeile vor dem Header bzw. aus dem Dateinamen ermittelt (die Quelldatei hat kein festes Datumsfeld). - **`src/Models/Bankinstitut.php`** (neu): `search()` (Bankleitzahl, BIC, Name, Ort in einem Zug; Treffer in der Bankleitzahl von vorn stehen oben, eine Ziffernfolge wird nicht automatisch als Bankleitzahl gelesen), `findAccessPointByBlz()` (erster Treffer mit gesetztem Zugangspunkt), `replaceAll()` (Import ersetzt immer den gesamten Bestand statt ihn zu ergänzen, chunked Bulk-Insert in einer Transaktion). - **`src/Controllers/BankinstituteController.php`** (neu): `index()` (Bestandsübersicht + Suche), `check()`/`import()` als zwei Aktionen auf derselben hochgeladenen Datei (AJAX, die Datei wird nicht dauerhaft abgelegt – nur `$_FILES`-Tempdatei während des Requests), `search()` für die Live-Suche. - **`src/Views/settings/bankinstitute.php`** (neu): Upload mit „Nur prüfen"/„Übernehmen", Prüfbericht (Zeilen, Bankleitzahlen, Zugangspunkte, Stand, erkannte Spalten, Sichtprobe der ersten Zeilen), Bestands- und Sucheanzeige. - **`src/Views/bank/fints.php`:** im FinTS-Abschnitt des „Neue Direktverbindung"-Dialogs füllt eine Live-Suche gegen das neue Verzeichnis (`/bankinstitute/search`) BLZ und FinTS-Server-URL auf Klick vor, statt beides von Hand eintragen zu müssen. - **`tests/92_BankinstituteTest.php`** (neu): Deckt `FintsInstituteImport` (ISO-8859-1, mehrzeiliger Institutsname in Anführungszeichen, doppelte Bankleitzahlen, überzählige Leerzeilen am Ende, Text statt Adresse in der PIN/TAN-Spalte, fehlende Pflicht-Header, Stand-Erkennung) sowie `Models\Bankinstitut` (`replaceAll`, `search`, `findAccessPointByBlz`, `importMeta`) ab. ## 2026-08-18 – PayPal-Abruf: abgeschnittene NVP-Antworten im Protokoll (#185) - `PayPalNvpFetchService::externalIdExists()` dedupliziert bereits über `external_id` = die PayPal-Transaktionsnummer (`L_TRANSACTIONID`), nicht über Datum/Betrag/Text – eine nachträglich korrigierte Buchung wird also schon heute nicht doppelt importiert. Kein Änderungsbedarf an diesem Teil des Issues. - `nvpPost()` behandelte einen abgebrochenen/unvollständigen HTTP-Transfer bisher wie einen normalen Fehler und protokollierte nur den HTTP-Status – teils `0` ohne weitere Erklärung, ohne dass sichtbar wurde, dass die Antwort abgeschnitten war. - **`src/Services/PayPalNvpFetchService.php`:** `nvpPost()` wertet jetzt zusätzlich `curl_errno()`/`curl_error()` sowie `CURLINFO_CONTENT_LENGTH_DOWNLOAD` aus. Neue Methode `isTruncatedTransferError()` erkennt abgebrochene Transfers (u.a. `CURLE_PARTIAL_FILE`, `CURLE_GOT_NOTHING`, `CURLE_RECV_ERROR`, sowie die Fehlermeldungen „transfer closed“/„Empty reply“) und schreibt dafür einen erklärenden Hinweis ins Abrufprotokoll statt der bisherigen stillen HTTP-Status-Zeile. Neue Methode `isLengthMismatch()` erkennt zusätzlich den Fall, dass trotz HTTP 200 weniger Bytes ankamen als im `Content-Length`-Header angekündigt, und meldet das ebenfalls als Hinweis. - **`tests/91_PayPalNvpAbgeschnitteneAntwortTest.php`** (neu): Deckt `isTruncatedTransferError()` (bekannte cURL-Fehlercodes, Fehlermeldungstexte, normale Fehler ohne Falsch-Alarm) und `isLengthMismatch()` (kürzere Antwort, übereinstimmende Länge, fehlende Content-Length) über Reflection auf die privaten statischen Methoden ab – ohne echte PayPal-Verbindung. ## 2026-08-12 – SSRF-Schutz für SMTP-Einstellungen (#183) - `mail_smtp_host`/`mail_smtp_port` wurden ungeprüft aus `Setting::get()` an `SmtpMailer::connect()` durchgereicht, das per `stream_socket_client()` eine TCP-/TLS-Verbindung zu genau diesem Ziel aufbaut – anders als `fints_url` (#125) und `nextcloud_url` (#127) ohne jede Prüfung auf private/interne Adressen. Über `POST /settings/mail-test` konnte ein authentifizierter Nutzer damit interne Netzwerk-/Portscans auslösen (Blind-SSRF-Oracle, CWE-918), da unterschiedliche `RuntimeException`-Nachrichten offene/geschlossene/gefilterte Ports bzw. Fremddienste erkennbar machten. - **`src/Services/SsrfGuard.php`:** neue Methode `assertPublicHost(string $host)` – wie `assertPublicHttpsUrl()`, aber ohne vorausgesetztes `https://`-Schema (SMTP kennt kein URL-Schema). Prüft IP-Literale direkt, blockt `localhost` explizit, löst Hostnamen auf und prüft alle zurückgegebenen Adressen gegen `FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE`. - **`src/Services/SmtpMailer.php`:** `connect()` ruft `SsrfGuard::assertPublicHost($this->host)` vor dem `stream_socket_client()`-Aufruf auf. - **`src/Controllers/SettingsController.php`:** `update()` prüft `mail_smtp_host` defensiv bereits beim Speichern (analog zu `nextcloud_url`), damit ein ungültiger Host erst gar nicht in die Einstellungen gelangt. `mailTest()` gibt bei einem Fehler nicht mehr `$e->getMessage()` an den Client zurück, sondern eine generische Meldung; die tatsächliche Ursache landet per `error_log()` im Server-Log – entschärft die Oracle-Eigenschaft für den Fall, dass trotz Speicher-Prüfung (z.B. DNS-Rebinding zwischen Speichern und Testversand) noch unterscheidbare Fehler entstehen könnten. - **`tests/90_SsrfGuardAssertPublicHostTest.php`** (neu): Deckt `assertPublicHost()` gegen Loopback, `localhost`, RFC1918-Adressen, die Link-Local/Metadata-IP `169.254.169.254`, IPv6-Loopback, leeren Host sowie eine akzeptierte öffentliche IP ab. ## 2026-08-11 – Überlange Methoden zerlegt: FinTsFetchService::run(), Booking::create()/reverse(), Bank-/InvoiceController (#159) - Rein strukturelle Änderung, keine Verhaltensänderung: bestehende Codeblöcke wurden in benannte Methoden verschoben, Bedingungen, Fehlermeldungen und Ablaufreihenfolge blieben unverändert. - **`src/Services/FinTsFetchService.php`:** `run()` war mit 274 Zeilen der größte Ausreißer im Projekt und dabei von außen nicht testbar, weil sie eine echte Bankverbindung voraussetzt. Aufgeteilt in `validateConfig()`, `assertUrlSafe()`, `connect()`, `selectTan()` (mit `selectTanMode()`/`selectTanMedium()`), `login()`, `resolveAccounts()` (mit `fetchAccounts()`/`applyAccountFilter()`), `computeFetchRange()` sowie `fetchAllStatements()`/`fetchStatementsForAccount()`/`fetchAccountBalance()`/`importTransactions()`. `run()` liegt jetzt bei 50 Zeilen. Die doppelte SSRF-Prüfung aus Issue #130 (DNS-Rebinding-Fenster zwischen Validierung und Verbindungsaufbau) ist unverändert erhalten, der erklärende Kommentar sitzt weiterhin direkt am zweiten `assertUrlSafe()`-Aufruf in `run()`. - **`src/Models/Booking.php`:** `create()` (144 Zeilen) und `reverse()` (95 Zeilen) enthielten mehrere Schreibpfade im selben Transaktionsblock. Die tatsächlichen Pfade in `create()` sind Hauptbuchung, eine VSt-/USt-Korrekturzeile für normale Buchungen mit 7/19 % (nicht „Eigenanteil“, wie im Issue-Text vermutet – dieser Begriff bezeichnet im Projekt die 30-%-Bewirtungskürzung aus `upsertEigenanteil30()`, ein unabhängiger Pfad außerhalb von `create()`) sowie die ig.-Erwerb-19-%-ReverseCharge-Kindzeilen (kein „Split“ im Sinne von `bucheBewirtungssplit()`). Extrahiert nach `insertHauptbuchung()`, `insertVorsteuerUmsatzsteuerZeile()`, `insertIgErwerbReverseCharge()`; `reverse()` entsprechend nach `insertHauptstorno()`/`insertKindstornos()`. Die Transaktionsklammer bleibt jeweils in `create()`/`reverse()`. - **`src/Controllers/BankController.php`:** `direktStore()` (111 Zeilen) aufgeteilt in `storeCsvFormat()`, `resolveProviderId()`, `storeBankSource()`; `direktUpdate()` (70 Zeilen) in `buildUpdateData()`, `buildApiUpdateData()`, `buildFintsUpdateData()`. - **`src/Controllers/InvoiceController.php`:** `storeBooking()` (84 Zeilen, drei identische Render-Blöcke für Validierungs-/PDO-/allgemeine Fehler) aufgeteilt in `validateBookingInput()`, `renderBookForm()` (bündelt die vormals dreifach kopierte View-Rendering-Logik), `persistBooking()`. - **`tests/88_FinTsHilfsmethodenTest.php`** (neu): `validateConfig()` gegen fehlende BLZ/Benutzerkennung/PIN/URL, `applyAccountFilter()` mit leerer/passender/nicht passender Filterliste, `accountId()` für Konten mit/ohne IBAN und mit/ohne Unterkonto – alles ohne echte Bankverbindung über Reflection auf die privaten Methoden. - **`tests/89_BookingCreatePfadeTest.php`** (neu): Deckt die drei Schreibpfade von `Booking::create()` ab – reine Hauptbuchung, Hauptbuchung mit VSt-Korrekturzeile (19 %), ig.-Erwerb-19-%-Buchung mit zwei Kindzeilen. ## 2026-08-11 – Duplizierte View-Logik gebündelt: Kontengruppierung, Kontrastfarbe, Helper-Boilerplate (#158) - **Kontengruppierung** (`invoices/book.php`, `invoices/show.php`, `belege/_buchungsform.php`) war dreimal kopiert und bereits divergiert: Nur `belege/_buchungsform.php` (per `include` in `detail.php`) filterte Zahlungskonten auf den Nummernbereich 1200–1299, die beiden Rechnungs-Views nicht. Neue Methode `View::gruppiereKonten(array $accounts, bool $nurZahlungskonten1200 = false)` bündelt beide Varianten; alle drei Views wenden jetzt den Filter an (Parameter `true`). - Entscheidung für „Filter überall anwenden“ statt „nirgends“ oder „Unterschied beibehalten“: `BuchungsvorlageController::modal()` filtert unabhängig davon bereits identisch auf 1200–1299 – das ist die etablierte Konvention für „welches Bankkonto hat die Zahlung erhalten“-Auswahlen im Projekt, nicht nur ein Sonderfall der Belegerfassung. Produktiv gab es zum Zeitpunkt der Änderung noch keine einzige Rechnungs-Zahlungsbuchung (0 Zeilen in `bookings` mit verknüpfter `invoices`), das Risiko eines bestehenden Workflows, der auf Kasse/Verrechnungskonto/USt-Konten als Zahlungskonto einer Rechnung angewiesen ist, bestand also nicht. - Sichtbare Auswirkung: Im Zahlungs-Modal der Rechnungsdetailansicht und in `invoices/book.php` stehen jetzt nur noch die fünf Bankkonten (1200–1240) zur Auswahl, nicht mehr Kasse, Verrechnungskonto oder die Umsatzsteuerkonten. Siehe `docs/funktionen.md`. - **Kontrastfarbe**: Die Formel zur Wahl einer lesbaren Textfarbe (`0.299*hexdec(...)+0.587*hexdec(...)+0.114*hexdec(...)`) stand identisch in fünf Views (`belege/detail.php`, `bank/index.php`, `bank/show.php`, `bank/fints.php`, `settings/bankimport.php`). Neue Methode `View::kontrastfarbe(string $hex, string $dunkel = '#1e293b', string $hell = '#ffffff')` validiert die Eingabe (fehlt `#`, ist der Wert zu kurz oder kein gültiges Hex, liefert sie die Standardfarbe statt der bisherigen stillen Rechnung auf 0, die bei leerem/kurzem Wert immer weiße Schrift ergab); dreistellige Kurzform (`#abc`) wird expandiert. Alle fünf Views delegieren jetzt an `View::kontrastfarbe()`. - **Helper-Boilerplate**: `$h = fn($v) => View::h($v);` (36 Views) und `$fmt = fn($v) => View::formatMoney($v);` (8 exakte Duplikate; abweichende `$fmt`-Definitionen mit `abs()` bzw. ohne geschütztes Leerzeichen blieben unverändert) sind entfernt. `BaseController::renderView()` und `View::render()` stellen `$h`/`$fmt` jetzt per `$data['h'] ??= …` zentral bereit, bevor `extract()` läuft – Views, die die Variablen weiterhin selbst definieren, bleiben dadurch unverändert lauffähig. - **`tests/87_ViewHelperTest.php`** (neu): Deckt `View::kontrastfarbe()` (bekannte Farben, ungültige/leere Werte → Standardfarbe, Kurzform-Expansion, eigene Standardfarben) und `View::gruppiereKonten()` (mit/ohne Filter) ab. ## 2026-08-11 – belege/detail.php in Teil-Views und externe JS-Dateien aufgeteilt (#157) - `src/Views/belege/detail.php` war mit 1.548 Zeilen die mit Abstand größte Datei der Anwendung, davon 827 Zeilen JavaScript in neun `