# Changelog --- ## 2026-07-27 – Fix: SSRF-Schutz per DNS-Rebinding umgehbar (TOCTOU) (Issue #130) - `SsrfGuard::assertPublicHttpsUrl()` löste den Hostnamen einmalig auf und prüfte die IPs, der eigentliche Request (cURL bzw. `php-fints`) löste den Hostnamen aber später erneut selbst auf. Ein Angreifer mit Kontrolle über die DNS-Antworten der konfigurierten Domain konnte zwischen Prüfung und Request von einer öffentlichen auf eine interne IP (127.0.0.1, internes Netz, Cloud-Metadata) umschalten und so die SSRF-Prüfung aus #127/#118 umgehen. - **`src/Services/SsrfGuard.php`:** Neue Methode `resolvePinned()` gibt zusätzlich zur Validierung die geprüften IP-Adressen als `CURLOPT_RESOLVE`-Einträge (`HOST:PORT:IP`) zurück; `assertPublicHttpsUrl()` bleibt als Wrapper bestehen. - **`src/Services/NextcloudImportService.php`:** Der Konstruktor pinnt die geprüfte(n) IP(s) der `nextcloud_url` einmalig; `newCurl()` setzt `CURLOPT_RESOLVE`, sodass alle cURL-Requests (PROPFIND/GET/MOVE/DELETE) den Hostnamen nicht erneut über den System-Resolver auflösen. - **`src/Services/FinTsFetchService.php`:** `php-fints` löst den Hostnamen selbst auf (kein IP-Pinning möglich) – die SSRF-Prüfung wird daher zusätzlich unmittelbar vor dem ersten tatsächlichen Request (nach `FinTs::new()`) wiederholt, um das Zeitfenster für DNS-Rebinding zu minimieren. - **`tests/63_SsrfPinningTest.php`:** Neuer Test prüft `SsrfGuard::resolvePinned()` (Einträge für Hostnamen, leeres Ergebnis + weiterhin Ablehnung für IP-Literale) sowie das Pinning in `NextcloudImportService`. ## 2026-07-27 – Fix: CSRF-Schutz für GET /bank/direkt/{id}/run (Issue #136) - Der SSE-Endpunkt `GET /bank/direkt/{id}/run` löste einen echten Bankabruf (PayPal/FinTS) allein anhand der Session aus, ohne CSRF-Prüfung – da `EventSource` nur `GET` unterstützt, griff die bestehende CSRF-Middleware (nur für `POST`/`PUT`/`DELETE`/`PATCH`) nicht. Ein Angreifer konnte per Top-Level-Navigation (SameSite=Lax) einen eingeloggten Nutzer dazu bringen, unbeabsichtigt einen Abruf für eine beliebige Verbindungs-ID auszulösen. - **`src/Auth.php`:** `validateCsrfToken()` prüft jetzt über die neue, seiteneffektfreie `isValidCsrfToken()` (analog zu `cookieParams()`), damit sich die Vergleichslogik isoliert testen lässt. - **`src/Controllers/BankController.php`:** `direktRun()` validiert vor dem eigentlichen Abruf das per `csrf_token`-Query-Parameter übergebene Token gegen die Session. - **`src/Views/bank/fints.php`:** Die `EventSource`-URL hängt das bereits vorhandene, in der Seite eingebettete CSRF-Token (`meta[name="csrf-token"]`) als Query-Parameter an. - **`tests/62_BankDirektRunCsrfTest.php`:** Neuer Test prüft `Auth::isValidCsrfToken()` gegen korrektes, falsches, leeres und fehlendes Session-Token. ## 2026-07-27 – Feature: Netto/Brutto-Umschalter in der Buchungsmaske (Issue #137) - Beim Erfassen einer Buchung musste bisher immer der Nettobetrag eingegeben werden – lag nur der Bruttobetrag vor (z. B. von einem Kassenbon), führte die manuelle Rückrechnung regelmäßig zu Rundungsdifferenzen. - **`src/Views/belege/detail.php`:** Das Label „Nettobetrag (€)" wurde durch zwei Umschalt-Buttons „NETTOBETRAG (€)" / „BRUTTOBETRAG (€)" ersetzt (`setBetragModus()`); der jeweils aktive Button ist optisch eingedrückt hervorgehoben und bestimmt, wie der eingegebene Betrag interpretiert wird. Die Live-Anzeige (`updateNettoAnzeige()`) zeigt je nach Modus die jeweils fehlenden Werte (Netto/USt. bzw. USt./Brutto) an. Beim Absenden des Formulars wird ein im Brutto-Modus eingegebener Betrag serverseitig weiterhin als Nettobetrag benötigt – die Umrechnung erfolgt daher unmittelbar vor dem Submit per JavaScript, sodass Backend (`BookingController`, `Models/Booking.php`) unverändert bleibt. - **`public/static/css/app.css`:** Neue Klasse `.betrag-modus-btn` (inkl. `.active`-Zustand) für den Umschalter. - **`tests/61_BetragModusToggleTest.php`:** Neuer Test prüft Vorhandensein der Umschalt-Buttons, der zugehörigen JS-Funktionen sowie der Umrechnung vor dem Absenden. ## 2026-07-27 – Feature: Lieferanten-Typeahead in der Belegliste (Issue #138) - Beim Klick in die Spalte „Lieferant" der Belegliste (Inline-Bearbeitung) gab es bisher nur ein freies Textfeld – Lieferanten konnten dadurch versehentlich mit unterschiedlicher Schreibweise mehrfach angelegt werden. - **`src/Controllers/BelegController.php`:** `index()` übergibt jetzt zusätzlich `Supplier::all()` an die View `belege/list`. - **`src/Views/belege/list.php`:** Neue `` mit allen vorhandenen Lieferanten; das Inline-Eingabefeld (`blInlineSupplier()`) verweist per `list`-Attribut darauf – analog zur bereits bestehenden Typeahead-Auswahl in der Buchungsmaske (`belege/detail.php`). - **`tests/60_BelegListeLieferantTypeaheadTest.php`:** Neuer Test prüft, dass Datalist, Befüllung und Verknüpfung mit dem Inline-Eingabefeld vorhanden sind. ## 2026-07-27 – Fix: Auto-Reload-Button der Ereignisanzeige durch iOS-artigen Toggle-Switch ersetzt (Issue #139) - Rückmeldung zu #139: Der Text-Button „Auto-Reload: An/Aus" war nicht intuitiv genug. - **`src/Views/ereignisse/nextcloud_import.php`:** Button durch einen runden Schiebeschalter (Checkbox + CSS, keine externe Bibliothek) ersetzt – links/grau = aus, rechts/grün = ein. Verhalten (Polling, `localStorage`-Persistenz) unverändert. - **`tests/58_EreignisAutoReloadTest.php`:** Test an die neue Toggle-Switch-Struktur (Checkbox statt Button, `change`- statt `click`-Event) angepasst. ## 2026-07-26 – Feature: Dritte Eingabezeile ohne USt. in der Essensbeleg-Buchungsvorlage (Issue #140) - Die Buchungsvorlage „Essensbeleg n. abzugsfähig (nur USt.)" hatte nur Eingabezeilen für 7 % und 19 % USt. – Beträge ohne USt. (z. B. Trinkgeld) ließen sich damit nicht erfassen. - **`migrations/088_buchungsvorlage_essensbeleg_ohne_ust.sql`:** Ergänzt die Vorlage um ein drittes `ust_zeile`-Feld `netto_0` (0 % USt.) sowie eine Buchungsregel, die den eingegebenen Betrag auf Konto 4657 („Nicht abzugsfähige Bewirtungskosten ohne Vorsteuer") bucht – Formular-Rendering (`buchungsvorlagen/modal.php`), Live-Berechnung (`app.js`) und Buchungsausführung (`VorlageBuchungsService`) sind bereits vollständig datengetrieben und benötigten keine Code-Änderung. - **`tests/59_EssensbelegOhneUstTest.php`:** Neuer Test prüft das neue Feld, die zugehörige Regel und die korrekte Buchung auf Konto 4657 bei gleichzeitiger Nutzung aller drei Zeilen. ## 2026-07-26 – Feature: Ereignisanzeige aktualisiert sich automatisch (Issue #139) - Die Ereignisanzeige (`/ereignisse/nextcloud-import`) war statisch – neu geloggte Ereignisse erschienen erst nach manuellem Neuladen der Seite. - **`src/Controllers/EventController.php`:** Neue Methode `nextcloudImportData()` liefert die aktuellen Ereignisse als JSON (Anzahl + serverseitig gerendertes HTML-Fragment), damit initiale Seite und Auto-Reload denselben Rendering-Code (`ereignisse/_nextcloud_import_body.php`) verwenden. - **`src/Views/ereignisse/_nextcloud_import_body.php`:** Tabellen-/Leer-Markup aus `nextcloud_import.php` in ein Partial ausgelagert, das von initialer Seite und Datenendpunkt gemeinsam genutzt wird. - **`src/Views/ereignisse/nextcloud_import.php`:** Neuer Ein-/Aus-Button oben rechts in der Kartenkopfzeile schaltet automatisches Neuladen per `setInterval` (alle 60s, ohne externe Bibliothek) ein/aus; Zustand wird per `localStorage` gemerkt. Ein echter Push-Mechanismus (z. B. Server-Sent Events) hätte dauerhafte PHP-FPM-Worker gebunden – bei diesem Anwendungsfall (Log-Anzeige) ist periodisches Polling die robustere Lösung. - **`public/index.php`:** Neue Route `GET /ereignisse/nextcloud-import/data`. - **`tests/58_EreignisAutoReloadTest.php`:** Neuer Test prüft Button, Polling-Code, Partial-Wiederverwendung und Routen-Registrierung. ## 2026-07-21 – Fix: Zwei dauerhaft fehlschlagende Tests (Session-Cookie-Härtung, Nextcloud-SSRF) - **Test 54 (Session-Cookie-Härtung):** Der Test rief echtes `Auth::startSession()` auf. Da `tests/run.php` Ergebnisse laufend per `echo` ausgibt und Test 54 der einzige Test ist, der Sessions anfasst, war zum Testzeitpunkt bereits Output gesendet – PHP lehnt `session_set_cookie_params()`/`session_start()` danach mit „headers already sent" ab (Warnung + wirkungslos). Ein nachträglicher Output-Buffer im Test half nicht, da bereits vor dem Test unbuffered Output geflossen war. - **`src/Auth.php`:** Neue reine Methode `Auth::cookieParams()` berechnet die gehärteten Cookie-Attribute (HttpOnly, SameSite, Secure inkl. Override/Proxy-Erkennung) ohne Seiteneffekt; `startSession()` nutzt sie jetzt intern. Verhalten zur Laufzeit unverändert. - **`tests/54_SessionCookieHardeningTest.php`:** Prüft jetzt `Auth::cookieParams()` direkt statt eine echte Session zu starten – unabhängig vom bereits erfolgten Output der Test-Suite. - **Test 56 (Nextcloud-SSRF):** `SsrfGuard::assertPublicHttpsUrl()` löst den Hostnamen bewusst per echtem DNS auf (Schutz vor DNS-Rebinding). Der Test nutzte `https://cloud.example.com` als „gültiges" Beispiel – diese Subdomain ist nicht registriert (NXDOMAIN), wurde also fälschlich als ungültig abgelehnt. - **`tests/56_NextcloudSsrfTest.php`:** Nutzt stattdessen die laut IANA dauerhaft real auflösbare Apex-Domain `https://example.com`. ## 2026-07-21 – Feature: Kontostand nach Direktabruf auf der Bank-Verbindungskachel - **`migrations/087_bank_sources_last_balance.sql`:** Neue Spalte `last_balance` in `bank_sources`. - **`src/Models/BankSource.php`:** `updateLastFetched()` nimmt optional den beim Abruf ermittelten Kontostand entgegen und persistiert ihn; ohne neuen Wert bleibt der zuletzt bekannte Stand erhalten (kein Verlust bei Fehlern während des Abrufs). - **`src/Services/FinTsFetchService.php`:** Fragt pro Konto zusätzlich per dedizierter `GetBalance`-Aktion (HKSAL) den aktuellen gebuchten Saldo ab und summiert diesen. Fällt nur auf den Schlusssaldo des letzten Statements zurück, wenn die Saldenabfrage fehlschlägt – der ursprüngliche Ansatz (Saldo nur aus dem Statement ableiten) lieferte keinen Wert, wenn die Bank mangels neuer Buchungen kein MT940/CAMT-Statement zurückgab (häufigster Fall bei täglichen Abrufen ohne neue Umsätze). - **`src/Services/PayPalNvpFetchService.php`:** Der bereits vorhandene `GetBalance`-Aufruf in `testCredentials()` liefert den aktuellen Kontostand, der nun mitgespeichert und im Abruf-Log ausgegeben wird. Live-Konten liefern dabei offenbar durchgängig das Listenformat `L_AMT0`/`L_CURRENCYCODE0` statt der laut NVP-Doku primären Felder `AMT`/`CURRENCYCODE` – beide Formate werden jetzt ausgewertet. - **`src/Views/bank/fints.php`:** Kachel zeigt bei vorhandenem Kontostand rechts neben dem Abrufdatum den Betrag an (gleiche Höhe wie das Datum), mit dem Label „Kontostand" rechtsbündig darüber. Kachelgröße unverändert. - **`tests/57_BankSourceLastBalanceTest.php`:** Neuer Test prüft, dass `last_balance` beim Abruf gesetzt wird und bei einem Abruf ohne ermittelten Kontostand erhalten bleibt. ## 2026-07-16 – Fix: Stored XSS über KI-Kontierungsvorschlag (supplier_name) in Beleg-Detailansicht (Issue #129) - **Mittel-Hoch:** `supplier_name` aus dem per LLM erzeugten Kontierungsvorschlag (`KontierungsvorschlagService`) wird nur `trim()`t und ungeprüft in `belege.ki_vorschlag` (JSON) gespeichert. `_showKiBadgeDone()` in `src/Views/belege/detail.php` baute daraus String-Konkatenation und wies sie ohne Escaping über `innerHTML` zu. Da der Belegtext (PDF/Bild/ZUGFeRD-XML) von einem externen Rechnungssteller stammt, konnte ein präparierter Beleg das LLM per Prompt-Injection dazu bringen, für `supplier_name` HTML/JS-Payload statt eines Firmennamens auszugeben – dieser wurde beim Öffnen des Belegs im Browser-Kontext des eingeloggten Nutzers ausgeführt (CWE-79). - **`src/Views/belege/detail.php`:** `_showKiBadgeDone()` verwendet nun die bereits vorhandene `_esc()`-Hilfsfunktion, um `v.supplier_name` vor der Einbettung in `innerHTML` HTML-zu-escapen. Andere Ausgabepfade von `supplier_name`/`buchungstext` (`.value`, `.textContent`, `.dataset`) waren bereits sicher. ## 2026-07-12 – Fix: SSRF über ungeprüftes nextcloud_url-Feld beim Belegimport (Issue #127) - **Mittel:** Anders als bei `fints_url` (Issue #118) wurde die in den Einstellungen gespeicherte `nextcloud_url` weder beim Speichern noch bei der Verwendung geprüft. `NextcloudImportService` baute daraus ungefiltert die WebDAV-Basis-URL für PROPFIND-, GET- und MOVE/DELETE-Requests samt Basic-Auth-Credentials; ein authentifizierter Benutzer mit Zugriff auf die Einstellungen konnte so interne Ziele (`127.0.0.1`, internes Netz, Cloud-Metadata-Endpunkt `169.254.169.254`) über den Belegimport-Cron ansprechen (CWE-918). Zusätzlich folgte der verwendete cURL-Handle HTTP-Redirects, wodurch eine nachträgliche Host-Prüfung per Weiterleitung hätte umgangen werden können. - **`src/Controllers/SettingsController.php`:** `update()` validiert `nextcloud_url` vor dem Speichern mit dem bereits für `fints_url` verwendeten `SsrfGuard::assertPublicHttpsUrl()`; ungültige Ziele werden mit Fehlermeldung abgelehnt statt in der Datenbank zu landen. - **`src/Services/NextcloudImportService.php`:** Der Konstruktor prüft die geladene `nextcloud_url` defensiv erneut mit `SsrfGuard` (Schutz gegen DNS-Rebinding und gegen Altbestände, die vor diesem Fix gespeichert wurden); bei Ablehnung bleibt `baseUrl` leer und `import()` bricht sofort mit Fehlermeldung ab, ohne einen Request abzusetzen. `newCurl()` setzt `CURLOPT_FOLLOWLOCATION` nun auf `false`, damit eine Weiterleitung die Prüfung nicht umgehen kann. - **`src/Views/settings/form.php`:** Fehleranzeige für das Nextcloud-URL-Feld ergänzt. - **`tests/56_NextcloudSsrfTest.php`:** Neuer Test prüft, dass `http://`, private/loopback/link-local IP-Literale sowie `localhost` als `nextcloud_url` von `NextcloudImportService` abgelehnt werden (kein Request, `import()` meldet Fehler), während gültige `https://`-Ziele weiterhin funktionieren. ## 2026-07-11 – Fix: E-Mail-Header-Injection über Kunden-Betreffvorlage `mail_subject_template` (Issue #126) - **Mittel:** Das pro Kunde konfigurierbare Feld `mail_subject_template` wurde beim Speichern nur `trim()`t, eingebettete `\r`/`\n` blieben erhalten. Beim Rechnungsversand landete der Wert ungeprüft im SMTP-`Subject:`-Header (`SmtpMailer::buildMessage()`), sodass ein authentifizierter Benutzer über das Kunden-Bearbeitungsformular per CRLF-Payload (z.B. `Rechnung %RNR%\r\nBcc: angreifer@example.com`) zusätzliche Header injizieren und den Firmen-SMTP-Account zum verdeckten Versand an beliebige Empfänger missbrauchen konnte (CWE-93, analog zu #117, dort aber nur für Empfänger-/CC-Felder behoben). - **`src/Models/Customer.php`:** `create()`/`update()` entfernen `\r`/`\n` aus `mail_subject_template` jetzt zentral über die neue Methode `sanitizeSubjectTemplate()`, bevor der Wert in der Datenbank landet. - **`src/Services/SmtpMailer.php`:** `encodeHeader()` entfernt zusätzlich (Defense-in-Depth) `\r`/`\n` aus jedem Header-Wert (Subject, From-Name, Anhang-Dateiname), bevor dieser in die rohe SMTP-DATA eingebettet wird – wirksam unabhängig davon, woher der Wert stammt. - **`tests/55_MailSubjectHeaderInjectionTest.php`:** Neuer Test prüft, dass CRLF-Payloads in der Betreff-Vorlage beim Speichern entfernt werden und dass `SmtpMailer::encodeHeader()` CR/LF auch bei direkter Übergabe entfernt. ## 2026-07-10 – Fix: `update.sh` meldet Testfehler per Textsuche statt Exit-Code (Issue #124) - **Niedrig:** `scripts/update.sh` prüfte den Erfolg der Test-Suite nicht über den Exit-Code von `tests/run.php`, sondern per `grep` nach den Wörtern „FEHLER“, „FAILED“, „fehlgeschlagen“ bzw. „failed“ im Test-Output. Diese Wörter kommen jedoch auch in Testdaten und erwarteten Fehlermeldungen bestandener Tests vor (z.B. Test 47 EventLog, Test 50 SMTP), sodass trotz „✓ Alle Tests bestanden“ eine falsche Warnung ausgegeben wurde. Zusätzlich war `TEST_EXIT=${PIPESTATUS[0]:-0}` wirkungslos, da `PIPESTATUS` sich nicht auf die vorangegangene Command-Substitution bezieht. - **`scripts/update.sh`:** Exit-Code von `docker compose exec ... tests/run.php` wird nun direkt über `if TEST_OUTPUT=$(...); then ... else TEST_EXIT=$?; fi` erfasst (kompatibel mit `set -e`) und für die Erfolgsmeldung ausgewertet, statt den Output nach Schlüsselwörtern zu durchsuchen. ## 2026-07-10 – Fix: Session-Cookie ohne Secure-, HttpOnly- und SameSite-Attribute (Issue #121) - **Mittel-Hoch:** `session_start()` wurde an acht Stellen (`src/Auth.php`, `src/View.php`, `src/Controllers/LoginController.php`) unkonfiguriert aufgerufen; `session.cookie_httponly`, `session.cookie_secure` und `session.cookie_samesite` liefen auf den PHP-Compiled-in-Defaults (aus/leer). Das Session-Cookie – Zugriff auf sämtliche Mandanten-/Steuerdaten – war damit per JavaScript auslesbar (volle Session-Übernahme bei jeder Art von XSS) und unterlag keiner `SameSite`-Beschränkung (CWE-614/CWE-1004). - **`src/Auth.php`:** Neue zentrale Methode `Auth::startSession()` setzt vor jedem `session_start()` `session_set_cookie_params()` mit `httponly => true` und `samesite => 'Lax'`. `secure` wird anhand des Requests erkannt (`HTTPS`-Server-Variable bzw. `X-Forwarded-Proto: https` hinter einem TLS-terminierenden Reverse-Proxy), damit der Standard-Quickstart per HTTP ohne vorgeschalteten Proxy weiter funktioniert. Über die Umgebungsvariable `SESSION_COOKIE_SECURE` lässt sich das Verhalten explizit erzwingen (`1`) oder abschalten (`0`). - **`src/View.php`, `src/Controllers/LoginController.php`:** Alle direkten `session_start()`-Aufrufe durch `Auth::startSession()` ersetzt, damit die Härtung an jeder Stelle greift. - **`.env.example`:** `SESSION_COOKIE_SECURE` dokumentiert. - **`tests/54_SessionCookieHardeningTest.php`:** Neuer Test prüft HttpOnly/SameSite, automatische Secure-Erkennung über HTTPS bzw. `X-Forwarded-Proto`, sowie den expliziten Override per `SESSION_COOKIE_SECURE`. ## 2026-07-10 – Fix: Kein Schutz gegen Brute-Force am Login (Issue #122) - **Mittel:** `LoginController::authenticate()` prüfte Benutzername/Passwort ausschließlich per `password_verify()` – ohne Rate-Begrenzung, Lockout, Captcha oder Delay. Da `/login` als einzige Route bewusst ohne `Auth::require()` erreichbar ist und Passwörter nur eine Mindestlänge von 8 Zeichen benötigen, war ein Online-Brute-Force-/Credential-Stuffing-Angriff gegen einzelne Konten praktisch unlimitiert (CWE-307). - **`migrations/086_login_throttle.sql`:** Neue Spalten `users.failed_attempts` und `users.locked_until`. - **`src/Models/User.php`:** `registerFailedLogin()` zählt Fehlversuche pro Benutzer und sperrt das Konto ab 5 Fehlversuchen für ein mit jedem weiteren Fehlversuch steigendes Zeitfenster (`2^(Fehlversuche-5)` Minuten, gedeckelt auf 60 Minuten). `resetFailedLogins()` setzt den Zähler bei erfolgreichem Login zurück, `isLocked()` prüft die Sperre. - **`src/Controllers/LoginController.php`:** `authenticate()` weist Login-Versuche für gesperrte Konten ab, bevor das Passwort geprüft wird, und registriert Fehlversuche bei falschem Passwort. - **`tests/53_LoginThrottleTest.php`:** Neuer Test prüft Zählung der Fehlversuche, Sperre ab dem 5. Fehlversuch, steigendes Zeitfenster bei weiteren Fehlversuchen und Reset bei erfolgreichem Login. ## 2026-07-08 – Fix: SMTP-/Nextcloud-Zugangsdaten im Klartext gespeichert und im HTML sichtbar (Issue #119) - **Hoch:** `mail_smtp_pass` (SMTP-Passwort) und `nextcloud_pass` (Nextcloud-App-Passwort) wurden unverschlüsselt in `company_settings` abgelegt, obwohl für vergleichbar sensible Felder (FinTS-PIN, Client-ID/Secret) bereits `Crypto::encrypt()`/`Crypto::decrypt()` (AES-256-GCM) etabliert ist. Zusätzlich schrieb `src/Views/settings/form.php` beide Klartext-Passwörter in das `value`-Attribut zurück, wodurch sie für jeden Betrachter des Seitenquelltexts sichtbar waren. - **`src/Models/Setting.php`:** `get()` entschlüsselt `nextcloud_pass`/`mail_smtp_pass` nun über `Crypto::decrypt()` (Migrationsmodus: bestehende Klartextwerte in der DB werden unverändert durchgereicht), `update()` verschlüsselt beide Felder vor dem Speichern über `Crypto::encrypt()` – analog zum bestehenden Muster in `BankSource.php`. - **`src/Controllers/SettingsController.php`:** `update()` behält bei leer gelassenem Passwortfeld das bisherige (entschlüsselte) Passwort bei, statt es zu überschreiben – analog zum bestehenden Muster in `BankController`. - **`src/Views/settings/form.php`:** `value`-Attribute der beiden Passwortfelder entfernt; stattdessen Platzhalter „(gespeichert – leer lassen für unverändert)", wenn bereits ein Wert existiert. - **`migrations/085_encrypt_settings_passwords.sql`:** `mail_smtp_pass` von `VARCHAR(255)` auf `TEXT` erweitert, damit der Verschlüsselungs-Overhead (Prefix + Base64) nicht abgeschnitten wird. - **`tests/52_SettingsPasswordEncryptionTest.php`:** Neuer Test prüft, dass beide Felder verschlüsselt in der DB landen, über `Setting::get()` korrekt entschlüsselt werden und bei leerem Wert nicht verschlüsselt gespeichert werden. ## 2026-07-08 – Fix: SSRF über ungeprüftes fints_url-Feld bei Bankverbindungen (Issue #118) - **Kritisch:** Beim Anlegen/Bearbeiten einer FinTS-Bankverbindung wurde die vom Benutzer eingegebene `fints_url` ungefiltert übernommen und später von `FinTsFetchService` direkt als Ziel-URL für eine echte HTTP-Verbindung verwendet – ein authentifizierter Benutzer konnte den Server so Requests gegen beliebige Ziele absetzen lassen (interne Dienste, `localhost`, Cloud-Metadata-Endpunkte, CWE-918). - **Neu `src/Services/SsrfGuard.php`:** `assertPublicHttpsUrl()` erzwingt Schema `https://`, verbietet Userinfo in der URL und löst den Host auf, um sämtliche zugehörigen IPs gegen private/reservierte Bereiche zu prüfen (RFC1918, Loopback, Link-Local/Metadata via `FILTER_FLAG_NO_PRIV_RANGE`/`FILTER_FLAG_NO_RES_RANGE`); bei Verstoß wirft die Methode eine `RuntimeException`. - **`src/Controllers/BankController.php`:** `direktStore()` und `direktUpdate()` validieren `fints_url` vor dem Speichern mit `SsrfGuard`; ungültige Ziele werden mit Fehlermeldung abgelehnt statt in der Datenbank zu landen. - **`src/Services/FinTsFetchService.php`:** `run()` prüft die gespeicherte URL zusätzlich unmittelbar vor dem Verbindungsaufbau erneut (Schutz gegen DNS-Rebinding zwischen Speichern und Abruf). - **`tests/51_SsrfGuardTest.php`:** Neuer Test prüft, dass `http://`, private/loopback/link-local IP-Literale, `localhost` und URLs mit Userinfo abgelehnt werden, während öffentliche `https://`-Ziele weiterhin akzeptiert werden. --- ## 2026-07-08 – Fix: SMTP-/E-Mail-Header-Injection über ungeprüfte Empfänger-/CC-Adressen (Issue #117) - **Kritisch:** Empfänger- und CC-Adressen (Rechnungsversand, Test-Mail, Kundenstammdaten) wurden nur `trim()`t, aber nie auf gültiges Format oder enthaltene `\r`/`\n` geprüft. Da `SmtpMailer` das SMTP-Protokoll über einen rohen Socket implementiert, konnte eine Adresse mit eingebettetem CRLF zusätzliche `RCPT TO:`-Kommandos oder Mail-Header einschleusen (CWE-93, SMTP-/Header-Injection). - **`src/Services/SmtpMailer.php`:** `send()` validiert jetzt jede Empfängeradresse mit `filter_var(..., FILTER_VALIDATE_EMAIL)`, bevor überhaupt eine SMTP-Verbindung aufgebaut wird; ungültige Adressen (inkl. CRLF-Payloads) werfen eine `RuntimeException`. Da alle Versandwege (`EmailService::sendInvoice()`, `sendTestMail()`) durch diese einzige Methode laufen, ist die Prüfung zentral wirksam. - **`src/Controllers/CustomerController.php`:** Formularvalidierung prüft `email`, `email_cc1`, `email_cc2` zusätzlich mit `filter_var(FILTER_VALIDATE_EMAIL)`, damit gar nicht erst ungültige Adressen in der Kundendatenbank landen. - **`src/Views/customers/form.php`:** Fehleranzeige für die drei E-Mail-Felder ergänzt. - **`tests/50_EmailHeaderInjectionTest.php`:** Neuer Test prüft, dass CRLF-Injection-Payloads und ungültige Adressformate von `SmtpMailer::send()` abgelehnt werden. --- ## 2026-07-08 – Fix: Unauthentifizierter Direktzugriff auf Belege/Rechnungs-PDFs (Issue #116) - **Kritisch:** Hochgeladene Belege und Rechnungs-PDFs lagen unter `public/static/uploads/...` im Web-Root und wurden von Apache direkt ausgeliefert, bevor der Request `index.php`/`Auth::require()` erreichte – ein Angreifer konnte Belege ohne Anmeldung per URL abrufen. - **`src/Config.php`:** `upload_dir` zeigt jetzt auf `data/uploads` (außerhalb des Web-Roots, analog zum bereits per Apache gesperrten `/data`-Verzeichnis) - **`src/Models/Beleg.php`:** Neue Methode `absolutePath()` als einzige Quelle für die Pfad-Auflösung; unterstützt weiterhin Bestandsdaten mit dem alten Präfix `static/uploads/` in `storage_path` - **`src/Controllers/BelegController.php`, `InvoiceController.php`, `BankController.php`, `src/Services/NextcloudImportService.php`, `KontierungsvorschlagService.php`:** Alle Stellen, die Beleg-Dateien schreiben oder lesen, nutzen jetzt `Config::get('upload_dir')` bzw. `Beleg::absolutePath()` statt eigener `public/static/uploads`-Pfade - **`docker-compose.yml`, `Dockerfile`, `docker-entrypoint.sh`:** Upload-Verzeichnis wird nach `/var/www/html/data/uploads` gemountet/angelegt (bestehender Host-Ordner `./uploads` bleibt unverändert) --- ## 2026-07-06 – Fix: Fehlermeldung beim Beleg-Löschen, falls Datei nicht löschbar ist (Issue #114) - **`src/Models/Beleg.php`:** `delete()` nutzte ein ungeschütztes `unlink()`; schlug dieses auf dem Server mit „Permission denied" fehl, wurde eine PHP-Warning ausgegeben, die den nachfolgenden Redirect-Header zerstörte („headers already sent"). Der Datensatz wurde dabei trotzdem gelöscht, was zu Verwirrung führte. Fehler beim Datei-Löschen wird jetzt abgefangen und nur noch geloggt (`error_log`), die Löschung des Datensatzes läuft unverändert durch. --- ## 2026-07-02 – Feature: Zusätzliche CC-E-Mail-Adressen pro Kunde (Issue #111) - **`migrations/084_customer_cc_emails.sql`:** Neue Spalten `email_cc1`, `email_cc2` in `customers` - **`src/Models/Customer.php`:** `create()` und `update()` speichern die beiden CC-Adressen (Whitespace wird zu NULL normalisiert) - **`src/Views/customers/form.php`:** Zwei neue Eingabefelder „E-Mail CC 1/2" unter der Haupt-E-Mail - **`src/Controllers/InvoiceController.php`:** `show()` lädt den Kunden und übergibt ihn an die Detailansicht - **`src/Views/invoices/show.php`:** CC-Felder im E-Mail-Sende-Modal werden mit den hinterlegten Kunden-CC-Adressen vorbelegt --- ## 2026-06-29 – Feature: Ereignisse-Log für Nextcloud-Import (Issue #109) - **`migrations/083_system_events.sql`:** Neue Tabelle `system_events` (id, channel, message, created_at) - **`src/Models/EventLog.php`:** Neues Model mit `add()` und `findByChannel()` - **`src/Services/NextcloudImportService.php`:** Persistiert relevante Ereignisse (Import, Duplikat, Fehler) automatisch; stille Läufe ohne Dateiaktivität erzeugen keine Einträge; Zusammenfassung wird nur bei tatsächlicher Aktivität geloggt - **`src/Controllers/EventController.php`:** Neuer Controller für `/ereignisse` und `/ereignisse/nextcloud-import` - **`src/Views/ereignisse/nextcloud_import.php`:** Log-Ansicht mit Zeitstempel, Kanal und farbiger Meldung - **`src/Views/layout/nav.php`:** Neuer Menüpunkt „Ereignisse" unter Verwaltung mit aufklappbarem Unterpunkt „Nextcloud-Import" --- ## 2026-06-29 – Feature: Lieferant in Belegliste inline bearbeiten (Issue #108) - **`src/Views/belege/list.php`:** Lieferant-Spalte ist direkt in der Liste klickbar; zeigt ein Inline-Textfeld zur Eingabe, speichert mit Enter, bricht mit Escape ab; andere Spalten öffnen weiterhin die Vorschau - **`src/Models/Beleg.php`:** Neue Methode `updateSupplier()` aktualisiert nur die `supplier_id` ohne andere Metadaten zu verändern - **`src/Controllers/BelegController.php`:** Neue Methode `updateSupplier()` als AJAX-Endpunkt - **`public/index.php`:** Route `POST /belege/{id}/supplier` registriert --- ## 2026-06-11 – Fix: Undefined-Array-Key-Warning bei Buchungsvorlagen ohne Standard-Lieferant - **`src/Models/Buchungsvorlage.php`:** `create()`/`update()` prüften `$data['standard_lieferant']` direkt ohne Null-Coalescing, was bei fehlendem Schlüssel eine PHP-Warning auslöste --- ## 2026-06-11 – Feature: Liste verfügbarer Ollama-Modelle in den Einstellungen (Issue #103) - **`LlmService`:** Neue Methode `getAvailableModels()` fragt `/api/tags` ab und liefert die Namen aller lokal verfügbaren Ollama-Modelle; neue Getter `getMode()`, `getModel()`, `getVisionModel()` - **Einstellungsseite:** Im KI-Abschnitt wird bei erreichbarem Ollama-Provider die Liste der verfügbaren Modelle angezeigt, das konfigurierte Standard- und Vision-Modell sind markiert ## 2026-06-11 – Feature: CSV-Importformat aus „Neue Direktverbindung"-Dialog anlegen (Issue #102) - **`bank/fints.php`:** Neue Auswahl „+ CSV-Importformat…" im Anbieter-Dropdown des „Neue Direktverbindung"-Dialogs; zeigt Felder für Trennzeichen, Anführungszeichen, Datumsformat, Dezimal-/Tausendertrennzeichen, Headerzeile und übersprungene Zeilen - **`BankController::direktStore()`:** Behandelt `provider_id === '__csv__'` gesondert – legt einen `bank_sources`-Eintrag mit `type='csv'` und `provider_id=NULL` an (Standard-Spaltenzuordnung: Buchungstag=0, Wertstellung=1, Vorgang=2, Betrag=4) und leitet zur Feinabstimmung nach `/settings/bankimport` weiter ## 2026-06-11 – Fix: Direktabruf springt nach Abschluss zurück zu „Bank buchen" (Issue #101) - **`src/Views/bank/fints.php`:** Nach erfolgreichem Direktabruf (FinTS) führt der „Schließen"-Button im Abruf-Log-Modal jetzt zu einem Redirect auf `/bank` ("Bank buchen"); bei Abbruch oder Fehler bleibt die Anzeige wie bisher auf `/bank/direkt`, damit der Abruf wiederholt werden kann --- ## 2026-06-08 – Fix: KI-Verbindungstest mit Diagnoseinformationen (Issue #55) - **`LlmService`:** Neue Methode `testConnection()` gibt detaillierte Diagnose zurück (cURL-Fehlernummer/-meldung, HTTP-Code, Antwortzeit, verfügbare Modelle) - **`LlmService`:** `probeOllama()` schreibt bei cURL-Fehlern jetzt einen Eintrag ins PHP-Fehlerlog (`docker compose logs web`) - **Einstellungsseite:** Neuer Button „Verbindung testen" im KI-Abschnitt; zeigt bei Erfolg verfügbare Modelle, bei Fehler cURL-Fehler, URL und Firewall-Hinweis an - **Route:** `POST /settings/llm-test` → `SettingsController::llmTest()` - **`BelegController`:** Bei Verbindungsfehler wird jetzt `[]` statt `null` gespeichert (verhindert endlose Auto-Retry-Schleife); Fehlermeldung differenziert zwischen „Ollama nicht erreichbar" und „kein Vorschlag generiert" - **Beleg-Detailseite:** Zeigt bei Verbindungsfehler spezifische Meldung „Ollama nicht erreichbar" mit Link zu den Einstellungen statt der generischen Warnung ## 2026-06-01 – Fix: EN16931-Validierungsfehler in ZUGFeRD-Rechnungen - **[BR-FX-EN-04]** behoben: `ZugferdService` setzt jetzt BT-72 (Liefer-/Leistungsdatum) via `setDocumentSupplyChainEvent()`; das Datum wird aus dem Feld `leistungszeitraum` geparst (Format `DD.MM.YYYY` → direkt; Format `MM.YYYY` → letzter Tag des Monats; sonst Fallback auf Rechnungsdatum) - **[PEPPOL-EN16931-R008]** behoben: Leere Strings in optionalen Adressfeldern (Straße, PLZ, Ort) werden als `null` übergeben, sodass keine leeren XML-Elemente entstehen ## 2026-05-28 – Feature: Buchungsvorlagen im Buchungsdialog - **Datenbank:** 3 neue Tabellen `buchungsvorlagen`, `buchungsvorlage_felder`, `buchungsvorlage_regeln` (Migrationen 078–080); initiale Vorlage "Essensbeleg n. abzugsfähig (nur USt.)" per Migration 080 eingespielt - **Buchungsdialog** (`/belege/{id}`): Neues Vorlagen-Badge (gelb) zwischen Bank-Badge und Buchungsformular; Auswahlbox mit aktiven Vorlagen; Vorlage auswählen öffnet modalen Dialog mit Felder-Gruppen und Live-USt-Berechnung - **Modal:** Feldgruppe 1 (Metadaten: Lieferant, Rechnungsnr., Belegdatum, Brutto-Anzeige), Feldgruppe 2 (USt-Zeilen mit Netto↔Brutto-Live-Berechnung), Feldgruppe 3 (Zahlungskonto; 1200 vorausgewählt wenn Bankbuchung, sonst 1210) - **Buchungslogik:** `VorlageBuchungsService` interpretiert JSON-Buchungsregeln und ruft `Booking::create()` direkt auf (keine automatischen Kindzeilen, da Konten explizit); Metafelder werden über `Beleg::updateMeta()` gespeichert - **Pflege-UI:** `/einstellungen/buchungsvorlagen` – CRUD-Oberfläche für Vorlagen, Felder und Buchungsregeln; Navigationslink unter Einstellungen - **Test 42:** `42_BuchungsvorlageTest.php` prüft CRUD, Buchungsausführung, korrekte Konten und `neq_zero`-Bedingung ## 2026-05-24 – Fix: EÜR-Zeilennummern auf amtliche Anlage EÜR 2025 korrigiert - `migrations/075_euer_zeilen_korrektur.sql`: alle `euer_zeile`-Werte in `accounts` gemäß amtlicher Anlage EÜR 2025 neu zugeordnet (zuvor internes Schema ohne Bezug zum amtlichen Formular) - `EuerCalculator.php`: Sonderbehandlung für nicht abziehbare BA von `euer_zeile='51'` auf `'74'` umgestellt (Zeile 74 = „Nicht abziehbare Beträge" der amtlichen Anlage EÜR) - `accounts/form.php`: `$euerZeilen`-Array vollständig durch amtliche Zeilen 12–72 und 74 ersetzt; alle Beschriftungen entsprechen exakt dem Formulartext der Anlage EÜR 2025 ## 2026-05-24 – Feature: EÜR Anlageliste CSV/PDF-Export - EÜR-Ansicht: Kopfzeile der Anlageliste-Karte enthält jetzt rechtsbündig „Herunterladen: [CSV] [PDF]" - CSV-Export: `GET /tax/euer/{year}/anlageliste/csv` – alle 9 Spalten mit UTF-8 BOM (Excel-kompatibel), Semikolon-getrennt, inkl. Summenzeile - PDF-Export: `GET /tax/euer/{year}/anlageliste/pdf` – A4 Querformat, enge Spaltenbreiten sodass alle 9 Spalten auf eine Seite passen; neuer `PdfAnlagelisteService` - EÜR-Karte: heller „PDF herunterladen"-Button entfernt; Topbar-Button umbenannt zu „PDF" --- ## 2026-05-24 – Feature: AFA-Buchung stornieren/löschen in AfA-Tabelle - AfA-Tabelle im Asset-Edit-Modal: Gebuchte Zeilen zeigen neben dem grünen Haken einen Storno-Button (Undo-Pfeil, alle Modi) und im Testmodus zusätzlich einen Papierkorb-Button - Storno legt eine Gegenbuchung (`buchungstyp=AfA`, `is_reversal=1`, negativer Betrag) an und setzt `afa_booking_id` auf NULL → der Pfeil-aus-Box-Button erscheint wieder - Löschen (Testmodus) löscht die AFA-Buchung direkt und setzt `afa_booking_id` auf NULL - Neuer Controller: `POST /assets/{id}/depreciation/{rowId}/reverse` und `…/delete` - `AssetDepreciation::setBookingId()` akzeptiert jetzt `?int` (null erlaubt) --- ## 2026-05-24 – Feature: Buchungsjournal-Filter Buchungstyp + EÜR-Anlageliste - Buchungsjournal: neues Filterfeld „Buchungstyp" (Einnahme / Ausgabe / AfA); vorbelegt mit „Alle Typen" - Buchungsjournal: AfA-Buchungen (ohne Beleg) werden jetzt korrekt angezeigt (`LEFT JOIN belege`); Beleg-Zelle zeigt „–" wenn kein Beleg - Buchungsjournal: Badge für Buchungstyp `AfA` in grau (badge-gray) - EÜR-Ansicht: neue Tabelle „Anlagegüter {Jahr}" unterhalb der EÜR mit Spalten Anlage-ID, Bezeichnung, Anschaffungsdatum, Anschaffungskosten, Nutzungsdauer, AfA Vorjahre (kumulierte gebuchte AfA-Buchungen), Restbuchwert Vorjahr, AfA {Jahr} (gebuchte AfA des Jahres), Restbuchwert 31.12.{Jahr}; Summenzeile am Ende - `Asset::anlageliste(int $year)` als neue Modell-Methode (single query mit Subqueries) --- ## 2026-05-24 – Feature: AfA-Tabelle anteilig nach Anschaffungsmonat - Das erste Abschreibungsjahr wird anteilig nach Restmonaten berechnet: der Anschaffungsmonat zählt voll (Januar = 12 Monate, April = 9 Monate usw.) - Das letzte Jahr erhält automatisch die verbleibenden Monate (12 − Monate des ersten Jahres); bei Januar-Kauf entfällt das extra letzte Jahr - Die Cent-Option (`cent_im_ersten_jahr`) bleibt davon unberührt: der Cent-Rest geht weiterhin vollständig ins erste Jahr, ohne Aufteilung auf das letzte Jahr - Sofortabschreibung (`nutzungsdauer = 0`) ist unverändert - Neuer Test `41_AfaAnteiligTest` prüft April-, Oktober-, Juli-, Dezember- und Januar-Käufe sowie die Cent-Option mit anteiligem Jahr --- ## 2026-05-22 – Feature: „Abrufen ab"-Auswahl für Direktverbindungen - Im Bearbeiten-Dialog jeder FinTS- bzw. API-Direktverbindung gibt es ein neues Dropdown **Abrufen ab** mit den Optionen *Ab letztem Abruf (00:00 Uhr)* (Default), *Letzte 7 Tage*, *Aktueller Monat (ab 01.)*, *Letzte 30 Tage*, *Ohne Datum (letzte 365 Tage)* - Die Auswahl gilt nur für den nächsten Abruf: nach erfolgreichem Lauf setzt `BankSource::updateLastFetched()` `fetch_range` wieder auf `'last_fetched'` zurück - Migration `072_bank_sources_fetch_range.sql` ergänzt die neue ENUM-Spalte (Default `'last_fetched'`, bestehende Verbindungen verhalten sich unverändert) - Logik in `BankSource::computeStartDate()` zentralisiert; `FinTsFetchService` und `PayPalNvpFetchService` nutzen sie. Neuer Test `39_BankFetchRangeTest` deckt alle fünf Range-Werte plus Fallbacks ab --- ## 2026-05-22 – Fix: FinTS-Konto-Auswahl wird jetzt gespeichert + Bearbeiten-Dialog mit Checkboxen - **Bug:** Beim ersten Abruf erschien das Konto-Auswahl-Modal, das Speichern blieb aber hängen. Ursache war PHP-Session-Locking: der SSE-Stream `/bank/direkt/{id}/run` hielt die Session-Datei während des `awaitAccountSelection()`-Polling-Loops gesperrt, der parallele POST `/bank/direkt/{id}/accounts` blockierte beim `session_start()` → Deadlock bis Timeout - **Fix:** Im SSE-Endpoint `session_write_close()` direkt nach dem Auth-Check; entstört zugleich die analog gelagerte TAN-Eingabe - **UI:** Im Bearbeiten-Dialog einer FinTS-Verbindung erscheinen die gespeicherten Konten jetzt als Checkbox-Liste (statt nur als Text). Abhaken entfernt den Identifier beim Speichern aus `account_filter`; alle abgehakt verhält sich wie der Reset-Toggle - Filter-Berechnung als `BankSource::computeFilterFromPost()` extrahiert, neuer Test `38_BankSourceFilterFromPostTest` deckt Reset, Teilauswahl, leere Auswahl und Schutz gegen Unterschieben fremder Identifier ab --- ## 2026-05-22 – Feature: APP_KEY Status & Generator in den Einstellungen - Neuer Settings-Bereich unter dem Betriebsmodus zeigt Status des Verschlüsselungsschlüssels (`APP_KEY`): **Gesetzt** (grün, 64 Hex-Zeichen valide), **Ungültig** (falsches Format) oder **Fehlt** (leer) - Bei fehlendem oder ungültigem Schlüssel: Button „Neuen Schlüssel generieren" → POST `/settings/generate-app-key` liefert `bin2hex(random_bytes(32))`, Anzeige im Code-Block mit Copy-Button - Schlüssel wird nicht persistiert (Container hat keinen Schreibzugriff auf `.env`); Anleitung im UI: kopieren → in `.env` eintragen → `docker compose restart web` --- ## 2026-05-22 – UI: Einheitliche Icon-Buttons für Bearbeiten/Ansehen/Löschen - **Bleistift-Icon** (transparent, ohne Hintergrund – Stil der Direktverbindungs-Kacheln) ersetzt alle „Bearbeiten"- und „Öffnen"-Buttons in `accounts/list`, `customers/show`, `customers/list`, `suppliers/list`, `useradmin/list`, `assets/list`, `belege/list`, `invoices/list`, `invoices/show` - **Auge-Icon** (Kästchen mit Auge) ersetzt die PDF-Buttons in `invoices/list` (PDF), `invoices/show` (PDF herunterladen) sowie den „Ansehen"-Button in `tax/ustva_index`, um „nur ansehen" visuell vom „bearbeiten" abzugrenzen - **Papierkorb-Icon** (transparent, Danger-Farbe) ersetzt alle „Löschen"-Buttons in `accounts/list`, `invoices/list`, `useradmin/list`, `suppliers/list`, `assets/list`, `belege/detail`, `settings/bankimport`, `bank/fints` und `settings/form` - **Plus-Icon** (rund, btn-primary, 1,8rem) ersetzt alle „Neu anlegen/erstellen/hochladen"-Buttons – Topbars (`bank/fints`, Kunden, Lieferanten, Rechnungen, Belege, Wirtschaftsgüter, Konten), Dashboard-Schnellzugriff, Card-Header in `customers/show`, Aktionsspalte in `customers/list` und „Position hinzufügen" im Rechnungsformular - `bank/index.php`: Button „Öffnen" → „Aktivieren" (Status-Toggle, semantisch eindeutiger) --- ## 2026-05-21 – Feature: FinTS-Direktverbindung + Vereinheitlichung der Bank-Importquellen - **FinTS-Abruf** für Comdirect, DKB und weitere Banken über `nemiah/php-fints` (PHP-Lib für HBCI/FinTS 3.0). Leseabruf mit PIN; TAN-pflichtige Aktionen werden mit klarer Fehlermeldung abgebrochen - **Verschlüsselung sensibler Felder**: Neuer `Crypto`-Service (AES-256-GCM, Magic-Prefix `enc:v1:`) verschlüsselt PayPal-Signature und FinTS-PIN. Schlüssel aus `APP_KEY` in `.env` – im Backup mitsichern, Verlust = Verlust aller verschlüsselten Werte - **Datenmodell-Fusion**: Neue Tabelle `bank_sources` ersetzt `bank_import_formats` + `bank_connections` mit `type`-Spalte (`csv`/`api`/`fints`). `bank_import_batches.source_id` löst `format_id`/`connection_id` ab (alte Spalten bleiben vorerst bestehen für rollback-Sicherheit) - **Route-Umbenennung**: `/bank/fints*` → `/bank/direkt*` (semantisch korrekt – PayPal ist keine FinTS-Verbindung). Topbar in /bank vereinheitlicht zu einer „Importieren"-Schaltfläche - **NEU/Edit-Modal** zeigt typabhängig API-Felder (PayPal: App-Name, Kennwort, Signature, Sandbox) oder FinTS-Felder (BLZ, Benutzerkennung, PIN, optional FinTS-URL und IBAN-Filter) - **`FinTsFetchService`**: Login → SEPA-Konten abrufen → Statements seit `last_fetched_at` (00:00) → Buchungen mit Hash-basierter `external_id` (IBAN+Datum+Betrag+Verwendungszweck) deduplizieren - Migrationen 068 (bank_sources + Backfill), 069 (default_fints_url auf bank_providers für Comdirect/DKB) - `scripts/encrypt_bank_secrets.php` – einmaliges idempotentes Verschlüsseln der Klartext-Werte nach Backfill - Neue Tests 36 (Crypto-Roundtrip) und 37 (BankSource CRUD über alle drei Typen) --- ## 2026-05-17 – Feature: Automatischer Format-Vorschlag beim Bank-Import - **Format-Vorschlag**: Wenn eine hochgeladene CSV keinem bekannten Format eindeutig entspricht, analysiert `BankImportParser::suggestFormat()` die Datei automatisch (Headerzeilen-Erkennung bei skip_rows 0–10, Delimiter-Erkennung, Keyword-basiertes Spalten-Mapping) - **Session-gestützter Vorschlags-Flow**: Klick auf „Formate bearbeiten" speichert den Vorschlag via AJAX in der Session und öffnet die Einstellungsseite - **Vorschlags-Card**: Auf `/settings/bankimport` erscheint der Vorschlag oberhalb bestehender Formate als ausfüllbares Formular mit vorausgefüllten Feldern (skip_rows, Delimiter, Spalten-Mapping mit erkannten Headernamen) - **Explizites Speichern**: Neues Format wird erst nach Klick auf „Speichern" in der DB angelegt (POST `/settings/bankimport/create`); Verwerfen löscht die Session-Suggestion - Neue Routen: `POST /settings/bankimport/suggest`, `POST /settings/bankimport/create`, `POST /settings/bankimport/dismiss-suggestion` - Neues Model: `BankImportFormat::create()`; neuer Test 35 --- ## 2026-05-17 – Feature: Automatische Format-Erkennung beim Bank-Import - **Format-Erkennung**: Hochgeladene Datei wird automatisch gegen alle gespeicherten Formate geprüft; bei eindeutigem Match wird sofort importiert - **Vorschau-Panel**: Wenn kein Format passt, zeigt die Upload-Seite Überschriften-Zeile, erste Werte-Zeile und die Spalten-Zuordnung (Wertstellung, Buchungstext, Wert) für das gewählte Format an; wechselt live beim Umschalten der Formatauswahl - **„Formate bearbeiten"-Button** oben rechts verlinkt direkt auf die Einstellungsseite für Bankimport-Formate - Neuer AJAX-Endpunkt `POST /bank/detect-format` in `BankController`; neue Methoden `peekRows()` und `isFormatMatch()` in `BankImportParser` --- ## 2026-05-17 – Feature: DKB-Importformat für Bankbuchungen - **Neues Format „DKB Bank"**: 4 Skip-Zeilen, Semikolon-getrennt, Anführungszeichen-eingeschlossen - Buchungstext wird konditionell zusammengesetzt: bei negativem Betrag aus Zahlungsempfänger + Verwendungszweck, sonst aus Zahlungspflichtiger + Verwendungszweck - Migration 054 ergänzt drei neue Nullable-Spalten in `bank_import_formats` (`col_zahlungspflichtiger`, `col_zahlungsempfaenger`, `col_verwendungszweck`) --- ## 2026-05-16 – Refactor: Code-Qualität, Sicherheit und Konsistenz - **CSRF-Schutz**: `Auth::generateCsrfToken()` / `validateCsrfToken()` neu; Router validiert Token bei allen POST/PUT/DELETE-Requests; base.php injiziert Token in Formulare und fetch-Aufrufe via JS; login.php erhält hidden Input - **Path Traversal** in `BelegController::serveFile()` abgesichert (`realpath()` + Prefix-Check gegen Upload-Basisverzeichnis) - **`mime_content_type()`** (deprecated) durch `finfo_file(finfo_open(FILEINFO_MIME_TYPE), ...)` in `BelegController`, `SettingsController`, `BankController` und `ImageCropService` ersetzt - **XSS** in `topbarActions`: `View::buildQuery()` (neuer Helper) statt rohem `http_build_query($_GET)` - **`@`-Unterdrückung** in `SettingsController` entfernt (ersetzt durch `file_exists()`-Prüfung) - **BaseController**: gemeinsame `renderView()`-Methode zentralisiert; alle 13 Controller erben davon; lokale Duplikate entfernt - **DB-Transaktionen**: `Booking::create()` und `Booking::reverse()` laufen jetzt in atomaren Transaktionen; `Database::beginTransaction/commit/rollBack` unterstützt verschachtelte Aufrufe via Tiefenzähler (TestCase-Isolation bleibt intakt) - **`src/Constants.php`**: Buchungstypen und Steuerkontonummern (SKR03) als benannte Konstanten; Magic Strings in `Booking.php` ersetzt - **Dokumentation**: `docs/funktionen.md` – Abschnitt „Bank buchen" ergänzt; UStVA-Kennzahlen (81, 89, 66, 61, 83) korrigiert; Eigenanteil-Konten (4920/4925) korrigiert - **Tests**: `32_BankTransactionTest` (linkBeleg, unlinkBeleg, recalcStatus, findDuplicates) und `33_BankImportParserTest` (CSV-Parsing, BOM, Tausendertrenner, Fehlerfall) neu --- ## 2026-05-15 – Feature: Bankbuchung auf mehrere Belege aufteilen - Migration 053: Junction-Tabelle `bank_transaction_belege` (bank_tx_id, beleg_id, betrag); `bank_transactions.beleg_id` entfernt; Status-ENUM um `teilweise` erweitert; bestehende Verknüpfungen automatisch migriert - Neuer Status `teilweise`: wird gesetzt wenn mind. ein Beleg verknüpft ist, aber noch Restbetrag offen bleibt - Standardfilter in der Bankübersicht zeigt jetzt „Offen & Teilweise" - `bank/show.php`: Sektion „Verknüpfte Belege" mit Betrag je Beleg und Entfernen-Button; Restsaldo-Anzeige; Betrag-Eingabefeld pro Beleg in der Offene-Belege-Liste - Beim Verknüpfen wird der zugeordnete Betrag an die Buchungsmaske weitergegeben (`alloc_betrag`) - `BankTransaction::linkBeleg()` schreibt jetzt in Junction-Tabelle mit Betrag; `recalcStatus()` setzt Status automatisch; `unlinkBeleg()` entfernt Verknüpfung; `getLinkedBelege()` / `getRemainingBetrag()` neu - Neue Route `POST /bank/{id}/unlink` --- ## 2026-05-15 – Feature: Dublettenprüfung beim Bank-Import - Nach dem Upload wird geprüft, welche Buchungen bereits in der DB vorhanden sind (Abgleich über Buchungstag, Wertstellung, Betrag, Buchungstext) - Werden Dubletten gefunden, erscheint eine Prüfseite mit zwei Tabellen: erkannte Dubletten (per Checkbox auswählbar) und neue Buchungen (werden immer importiert) - „Alle auswählen / abwählen"-Buttons + Gesamtzähler zeigen an, wie viele Buchungen importiert werden - Erst nach Bestätigung wird importiert; ohne Auswahl wird der Import übersprungen - Migration 052: UNIQUE-Constraint auf `bank_transactions` entfernt (UI-Prüfung ist die primäre Schutzschicht) - `BankTransaction::findDuplicates()`: neue Methode mit Batch-Abfrage gegen die DB - `BankController::doImport()`: gemeinsame Import-Logik extrahiert (wird von `upload()` und `uploadConfirm()` genutzt) --- ## 2026-05-15 – Fix: Bank-Import-Duplikate verhindern - Migration 051: UNIQUE-Constraint `(buchungstag, wertstellung, wert, buchungstext(200))` auf `bank_transactions`; bestehende Duplikate wurden dabei bereinigt - `BankController::upload()`: Import läuft jetzt in einer DB-Transaktion – bei Fehler wird alles zurückgerollt statt einen unvollständigen Batch zu hinterlassen - `BankController::upload()`: Dateiname-Prüfung vor dem Import – erneuter Upload derselben Datei wird mit Fehlermeldung abgelehnt - `BankTransaction::filenameExists()`: neue Hilfsmethode zur Duplikat-Prüfung anhand Dateiname --- ## 2026-05-15 – Fix: Netto-Neuberechnung bei Kategorie-Wechsel in Bank-Buchungsmaske - IIFE-Wrapper für `updateAutoUst` in einen `DOMContentLoaded`-Listener verschoben, damit er erst nach der Funktionsdefinition (späteres `