Steuerkram/Changes.md

157 KiB
Raw Blame History

Changelog


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 <script>-Blöcken – teils mit einem DOMContentLoaded-Workaround, weil updateAutoUst() erst in einem späteren Block definiert wurde. Reine Strukturänderung, kein Verhaltensunterschied.
  • Teil-Views (src/Views/belege/, per include __DIR__ . '/_name.php' eingebunden, Variablen stehen durch extract() in BaseController::renderView() bereits im Scope): _infobar.php (Beleg-Info-Bar), _vorschau.php (Split-Pane, Dokumentvorschau, Buchungsliste), _buchungsform.php (Buchungsformular inkl. Kontengruppierung), _anlage_modal.php (Anlagevermögen-Modal), _crop_modal.php (Zuschnitt-Modal, nur bei image/jpeg/image/png eingebunden). _infobar.php und _vorschau.php benötigen ein eigenes use Steuerkram\View;, da use-Deklarationen anders als Variablen nicht über include vererbt werden.
  • JavaScript (public/static/js/, mit ?v=<?= ASSET_VERSION ?> eingebunden wie app.css): beleg-form.js (Netto/Brutto-Umschalter, USt-Auto-Vorbelegung, Bewirtung/Eigenanteil, Netto-Neuberechnung bei verknüpfter Bankbuchung), beleg-anlage.js (Anlagevermögen-Auswahl), beleg-ki.js (KI-Kontierungsvorschlag, nur wenn $kiSektion und nicht gesperrt), beleg-crop.js (Bildzuschnitt, nur bei Bild-MIME-Typ), beleg-ui.js (Lieferanten-Autospeicherung, verschiebbarer Trenner, Bildzoom). beleg-form.js wird als erstes eingebunden, da es updateAutoUst() definiert – der bisherige DOMContentLoaded-Workaround (Zeile 645–673 der alten Datei) entfällt, weil Funktionsdeklarationen innerhalb derselben Datei bereits beim Parsen gehoben werden.
  • Alle PHP-Interpolationen in JavaScript sind durch data-*-Attribute ersetzt: Beleg-ID (data-beleg-id auf #crop-modal-overlay, bereits vorhandenes data-beleg-id auf #beleg-info-bar für die Lieferanten-Autospeicherung), Anlagevermögen-Liste (data-assets auf #modal-anlagevermoegen, JSON via $h() HTML-escaped statt JSON_HEX_TAG), KI-Vorschlag (data-ki-aktiv/data-ki-vorschlag auf #ki-badge), Bankbuchungs-Brutto und Meta-Formular-Autoaufklappung (data-bank-tx-brutto/data-open-meta-form auf #buchung_form).
  • showKontoArt(), setupAccountSearch() und transferKontoNr() existierten bereits in app.js und wurden nicht dupliziert.
  • tests/86_BelegDetailStrukturTest.php (neu): Prüft, dass detail.php unter 200 Zeilen bleibt, keine inline <script>-Blöcke mehr enthält, alle per include/<script src> referenzierten Dateien existieren, beleg-form.js vor beleg-ki.js eingebunden wird und keine der ausgelagerten JS-Dateien PHP-Interpolationsreste enthält.
  • tests/14_UstBruttoAnzeigeTest.php und tests/61_BetragModusToggleTest.php: lasen bisher den Inhalt von detail.php direkt; geprüfte Markup- und JS-Fundstellen liegen jetzt in _buchungsform.php bzw. beleg-form.js, die Tests wurden entsprechend umgestellt.
  • Manuelle Abnahme: Alle vier Beleg-Varianten (PDF, Bild, gesperrt, gesperrt+Bild) über HTTP gerendert und geprüft, dass keine PHP-Reste im HTML auftauchen, die <script src>-Tags je nach Sperrstatus/MIME-Typ korrekt bedingt eingebunden werden, beleg-form.js vor beleg-ki.js referenziert wird und data-assets bei Sonderzeichen (Anführungszeichen, &, Umlaute) korrekt escaped/parsebar ist. Ein echter Browser-Durchlauf der in #157 geforderten Checkliste (Formular-Interaktionen, Zuschnitt, Zoom, Trenner) war mangels verfügbarem e2e-runner-Projekt in dieser Umgebung nicht möglich (tests/e2e/run.sh übersprang mit der dokumentierten Warnung); die JS-Dateien wurden bis auf die beschriebenen Interpolations-Ersetzungen unverändert aus den ursprünglichen <script>-Blöcken übernommen.

2026-08-10 – Direkte SQL-Aufrufe aus Controllern in die Modellschicht verschoben (#156)

  • 34 direkte Database::-Aufrufe in AccountController, InvoiceController, BookingController, UstvaController, BankController, BelegController und CustomerController verletzten die MVC-Schichtgrenze (CLAUDE.md: SQL ausschließlich über Database:: innerhalb der Modelle) und waren dadurch nur über einen vollständigen HTTP-Request testbar. AssetController.php bleibt unverändert – die dort verbleibenden vier Aufrufe liegen außerhalb des Umfangs von #155 und sind laut Issue #156 nicht Teil dieses Tickets.
  • src/Models/Booking.php: Neue Methoden dashboardKennzahlen() (offene Rechnungen, ungebuchte Belege – bislang ungetestete Dashboard-Kennzahlen), recent(int $limit), journalForAccount(int $accountId, int $year), deleteHard(int $id) sowie bucheBewirtungssplit() (aus BookingController verschoben, vgl. Q3 im Issue: Split-Fachlogik gehört zu Booking, dort existiert bereits der verwandte Helfer upsertKorrekturbuchung()).
  • src/Models/FiscalYear.php: Neue Methode registeredYears() (Geschäftsjahre aus fiscal_years, für das Konten-Journal – die bestehende availableYears() liest stattdessen DISTINCT YEAR() aus bookings und passte inhaltlich nicht).
  • src/Models/Invoice.php: Neue Methoden setBelegId(), findStorno(), findReference(), markSent(), deleteLine(), forCustomer() sowie finalizeWithBeleg() (bündelt die bisher im Controller offene Transaktion aus Finalisierung + Beleg-Erzeugung; die eigentliche PDF-Erstellung bleibt als Callback beim Aufrufer, da sie Datei-I/O und keine reine Datenbank-Operation ist).
  • src/Models/Beleg.php: Neue Methoden updateBewirtung(), updateEigenanteil30(), updateBetragBrutto(), updateStorage() sowie setSupplierId() (identisch zu updateSupplier(), aber bewusst ohne locked-Prüfung – wird beim automatischen Vorbelegen des Lieferanten aus einer Bankbuchung verwendet).
  • src/Models/BankTransaction.php: Neue Methode importBatch() bündelt Batch-Anlage, Zeilen-Import und Transaktionsklammer (vorher offen in BankController::doImport()).
  • src/Models/UstvaSubmission.php (neu): all(), find(), upsert() für ustva_submissions, bisher nur als Inline-SQL in UstvaController.
  • Alle verschobenen SQL-Statements wurden unverändert übernommen; keine Verhaltensänderung außer der bewusst parametrisierten LIMIT in Booking::recent() (vorher fest LIMIT 10, jetzt Parameter mit Default 10) und der Zusammenfassung der Transaktionsklammern in die jeweiligen Modellmethoden.
  • tests/85_ModellAbfragenTest.php (neu): Deckt alle oben genannten Modellmethoden anhand bekannter Testdaten ab, Schwerpunkt auf Booking::dashboardKennzahlen(). Regressionsschutz am Ende: grep-Äquivalent, das außerhalb der dokumentierten AssetController-Ausnahme keinen Database::-Aufruf mehr in src/Controllers/ toleriert.

2026-08-09 – Regressionstest: Reflektiertes XSS über Kontonummer-Freitextfeld (#149)

  • BookingController::store() schreibt eine nicht auflösbare Freitext-Kontonummer (kategorie_kontonummer/zahlungs_kontonummer) ungefiltert in $errors[...]. Die Ausgabestelle in src/Views/belege/detail.php:419 escaped Schlüssel und Wert bereits seit Fix #132 (Commit 6489819) konsequent mit $h() – der in #149 gemeldete Bug war zum Zeitpunkt der Meldung durch die dortige Änderung bereits geschlossen, nur ohne eigene Testabdeckung für diesen speziellen Eingabepfad.
  • tests/84_BookingStoreReflectedXssTest.php (neu): Rendert belege/detail.php mit einem <script>-Payload in formErrors['kategorie_konto_id'] (wie ihn store() bei nicht gefundener Kontonummer erzeugt) und stellt sicher, dass der Payload nur escaped in der Ausgabe erscheint.

2026-08-09 – Fix: Fehlende Autorisierung in der Benutzerverwaltung (#147)

  • users hatte kein Rollen-Feld – Auth::require() prüfte für alle /useradmin-Routen (GET/POST /useradmin, .../{id}, .../{id}/delete, .../{id}/toggle) nur eine gültige Session, keine besondere Berechtigung. Jeder eingeloggte Benutzer (auch einer, der nur für die Belegerfassung angelegt wurde) konnte damit beliebige Konten anlegen, das Passwort jedes anderen Benutzers – inklusive des ursprünglichen Admin-Accounts – setzen sowie andere Benutzer löschen oder deaktivieren (Privilege Escalation, CWE-862/CWE-269).
  • migrations/093_users_role.sql (neu): users.role ENUM('admin','user') NOT NULL DEFAULT 'user'. Auf Bestandsinstallationen wird der älteste Benutzer (MIN(id)) automatisch zu admin, damit die Benutzerverwaltung nach dem Update nicht für alle gesperrt ist.
  • src/Models/User.php: create() akzeptiert jetzt eine optionale Rolle (Default user), neue Methode setRole(); all()/find()/findByUsername() liefern das Rollen-Feld mit.
  • src/Auth.php: login() legt die Rolle in der Session ab ($_SESSION['role']). Neue Methoden isAdmin() und requireAdmin() (analog zu require(), antwortet bei fehlender Admin-Rolle mit HTTP 403).
  • src/Controllers/LoginController.php: Der bei der Ersteinrichtung angelegte erste Benutzer erhält automatisch die Rolle admin.
  • public/index.php: Zentraler Guard vor dem Routing – jede Anfrage unter /useradmin durchläuft zusätzlich Auth::requireAdmin().
  • src/Views/layout/nav.php: Der Menüpunkt „Benutzer“ wird nur noch für Admins gerendert (rein kosmetisch, ersetzt nicht die serverseitige Prüfung).
  • scripts/e2e-user.php: Der E2E-Testnutzer wird als Admin angelegt bzw. beim erneuten Ausführen auf die Rolle admin zurückgesetzt, damit die E2E-Suite weiterhin auf /useradmin zugreifen kann.
  • tests/83_UserRoleTest.php (neu): Rollen-Default user, explizite Vergabe von admin, setRole(), Auth::isAdmin() anhand der Session.
  • tests/e2e/specs/useradmin-authorization.spec.js (neu): Ein frisch angelegter Benutzer ohne Admin-Rolle sieht den Nav-Link nicht und bekommt beim direkten Aufruf von /useradmin HTTP 403.

2026-08-03 – Fix: AfA-Buchungen umgehen das Booking-Modell (#155)

  • AssetController::bookAfa() und reverseAfaBooking() setzten ihre INSERT INTO bookings-Statements bislang direkt ab statt über Booking::create()/reverse(). Vier Folgen: Die Sperre eines abgeschlossenen Geschäftsjahres griff nicht, ein zweiter, unabhängiger Nummernkreis (AFA-NNNNN) lief neben BU-YYYY-NNNNNN auf derselben Spalte, die Nummernvergabe stand außerhalb der Transaktion, und das Storno einer AfA-Buchung wurde immer dem aktuellen Kalenderjahr statt dem Jahr der Originalbuchung zugeordnet.
  • src/Models/Booking.php: Neue Methoden createAfa() und reverseAfa() bündeln die komplette Schreiblogik für AfA-Buchungen – inklusive Sperrprüfung des Geschäftsjahres, Nummernvergabe (generateAfaBuchungsNr(), eigener AFA-NNNNN-Kreis, bewusst beibehalten statt vereinheitlicht) innerhalb der Transaktion, und – beim Storno – Übernahme der fiscal_year_id der Originalbuchung. Das Buchungsdatum des Storno bleibt das tatsächliche Stornodatum (Ist-Versteuerung).
  • src/Controllers/AssetController.php: bookAfa() und reverseAfaBooking() rufen nur noch die Modellmethoden auf, die bisherigen Vorprüfungen bleiben erhalten. RuntimeException aus dem Modell wird als HTTP 409 mit {"error": "..."} beantwortet. Kein direkter Database-Zugriff auf bookings mehr im Controller.
  • migrations/092_afa_storno_fiscal_year_korrektur.sql (neu): Korrigiert die fiscal_year_id bereits vorhandener AfA-Stornobuchungen rückwirkend auf das Jahr ihrer Originalbuchung.
  • tests/82_AfaBuchungModellTest.php (neu): Sperre verhindert AfA-Buchung und -Storno, Buchung im offenen Jahr landet korrekt (Jahr, 31.12.-Datum, betrag_ust = 0), Storno übernimmt die fiscal_year_id der Originalbuchung, reversed_by/is_reversal werden gesetzt, Buchungsnummer folgt dem Schema AFA-NNNNN.

2026-08-02 – Testsuite als echtes Gate in update.sh (#169)

  • Schritt 9/9 von scripts/update.sh gab bei rotem Testergebnis nur eine Warnung aus – Exit-Code 0, kein Abbruch. Ein Update mit fehlgeschlagenen Tests galt damit als erfolgreich, obwohl die neue Version zu diesem Zeitpunkt bereits live war.
  • scripts/update.sh: Schritt 9 führt jetzt zusätzlich zur PHP-Suite tests/e2e/run.sh aus und wertet beide Ergebnisse als Gate: Schlägt eine der beiden Suiten fehl, setzt UPDATE_FAILED=true und der Lauf endet mit Exit-Code 1. tests/e2e/run.sh überspringt sich selbst mit Exit-Code 0, wenn kein e2e-runner-Projekt bzw. kein Playwright-Image vorhanden ist (Normalfall auf Produktivmaschinen) – das zählt als bestanden, nicht als Fehler; nachgeladen wird nichts. Kein automatischer Rollback (Migrationen sind ggf. nicht rückwärtskompatibel): bei rotem Ergebnis nennt das Script stattdessen laut die Pfade des in Schritt 3 erstellten Backups und den Befehl ./scripts/restore.sh. --no-tests bleibt als dokumentierter Notausstieg bestehen.

2026-08-02 – Aufräumen des verwaisten data/uploads-Verzeichnisses (#173)

  • Rest aus dem Umzug der Nutzdaten nach data/ (#150): Ein root-eigenes, leeres data/uploads blieb auf Produktivsystemen zurück und lenkte bei der Suche nach Rechteproblemen unter data/ ab. Weder docker-compose.yml noch docker-entrypoint.sh legen es an – aktuell wird ausschließlich data/legacy-uploads verwendet.
  • scripts/migrate-datenverzeichnis.sh: Die rmdir "$ROOT/data/uploads"-Zeile entfernt, da kein aktiver Pfad das Verzeichnis mehr anlegt und die stillschweigend fehlschlagende Aufräumzeile damit ohne Funktion war. Betroffene Bestandsinstallationen müssen das leere, root-eigene Verzeichnis einmalig manuell entfernen (z.B. sudo rmdir data/uploads) – es liegt außerhalb der Versionskontrolle (data/* ist per .gitignore ausgenommen).
  • data/README.md enthielt keinen Verweis auf data/uploads, keine Änderung nötig.

2026-08-02 – Dashboard rechnet wie die EÜR, Ereignisse zeigen alle Fehler, kompaktere Buchungszeilen

Dashboard-Zahlen stimmen mit der EÜR überein und lassen sich aufschlüsseln (#167)

  • Das Dashboard summierte nach bookings.buchungstyp, die EÜR nach accounts.kontenart – für 2026 wich das Ergebnis um 1.241,17 € ab (16.903,50 € statt 18.144,67 €). Im Einzelnen zählte das Dashboard 818,79 € Vorsteuer (Konten 1571/1576/1574, Kontenart „Zahlungskonto") und 350,00 € Anlagenzugang als Betriebsausgabe, ließ AfA-Buchungen komplett aus (buchungstyp='AfA' kam in keiner der beiden Abfragen vor) und minderte das Ergebnis um 389,42 € nicht abzugsfähige Bewirtungskosten, die die EÜR getrennt ausweist.
  • src/Services/EuerCalculator.php: Neue Methoden summary($jahr, $monat = null) und fetchBookingsForBucket($jahr, $monat, $gruppe). Beide leiten ihre Bedingung aus denselben Konstanten ab wie calculate() – Zuordnungsregeln stehen damit an genau einer Stelle.
  • src/Controllers/AccountController.php: dashboard() bezieht Umsatz, Ausgaben und Ergebnis für Jahr, Vormonat und laufenden Monat aus summary(); die sechs handgeschriebenen SQL-Abfragen entfallen. Neue Action bucketBookings() für die Detailansicht.
  • src/Views/dashboard.php, src/Views/dashboard_bucket.php (neu): Alle neun Zahlen der ersten drei Kacheln verlinken auf /dashboard/buchungen/{gruppe}/{jahr}[/{monat}]. Die Liste zeigt Datum, Buchungsnummer, Beleg, Text und Konten mit Summenzeile; in der Ergebnisansicht stehen Betriebsausgaben mit negativem Vorzeichen, sodass die Spaltensumme dem Kachelwert entspricht. Nicht abzugsfähige Ausgaben werden unterhalb der Liste beziffert statt stillschweigend weggelassen.
  • tests/80_DashboardEuerAbgleichTest.php (neu), tests/e2e/specs/dashboard-detail.spec.js (neu): Der PHP-Test bucht Aufwand, AfA, Vorsteuer und nicht abzugsfähige Bewirtung und prüft, was davon wo landet; der E2E-Test vergleicht den angezeigten Kachelwert mit der Summe der verlinkten Liste.

Ereignisse umfassen alle Fehler und Warnungen (#165)

  • Die Ereignisliste kannte nur den Nextcloud-Import. Fehler an anderer Stelle – etwa ein gescheiterter Beleg-Upload – waren nur als kurz sichtbare Meldung im Browser oder im Server-Log nachvollziehbar.
  • migrations/091_system_events_level.sql (neu): Spalte level (info/warning/error) samt Indizes. Bestehende Einträge werden anhand ihres Textpräfixes eingestuft, nach dem die Liste sie bisher eingefärbt hat.
  • src/Models/EventLog.php: add() nimmt einen Schweregrad entgegen, dazu warning(), error(), find() mit Filter nach Bereich und Schweregrad sowie channels(). tryAdd() schreibt das Ereignis, ohne selbst scheitern zu können – nötig für Aufrufer, die bereits einen Fehler behandeln.
  • Neue Fehlerquellen: View::redirect() protokolliert jede Meldung vom Typ error oder warning im Bereich meldungen – damit ist jede dem Nutzer angezeigte Fehlermeldung erfasst, ohne sie einzeln anbinden zu müssen. BelegController::renderUploadErrors() schreibt zusätzlich im Bereich beleg-upload mit Dateiname, public/index.php protokolliert unbehandelte Ausnahmen und schwere PHP-Fehler im Bereich system.
  • src/Controllers/EventController.php, src/Views/ereignisse/index.php, src/Views/ereignisse/_body.php: Aus der Nextcloud-Seite wird eine Übersicht über alle Bereiche mit zwei Auswahlfeldern (Bereich, Schweregrad), die nebeneinander stehen – beide setzen width:auto gegen das globale select { width: 100% }, das sie sonst im Flex-Formular untereinander schiebt. Der automatische Neuaufbau alle 60 Sekunden behält den eingestellten Filter. /ereignisse/nextcloud-import leitet auf die gefilterte Übersicht weiter, der bisherige Daten-Endpunkt bleibt erreichbar.
  • tests/81_EreignisseAlleFehlerTest.php (neu), tests/e2e/specs/ereignisse.spec.js (neu), tests/58_EreignisAutoReloadTest.php (angepasst).

Buchungszeilen brauchen weniger Breite (#166)

  • src/Views/belege/detail.php: Buchungsnummer, Buchungsdatum und Buchungstyp teilen sich in der Belegansicht eine Spalte (Nummer oben, Datum darunter, Typ-Badge zuunterst) – wie bereits bei Kategorie- und Zahlungskonto. Die Spalte Anlage entfällt ersatzlos; das Wirtschaftsgut ist weiterhin über Journal, UStVA- und EÜR-Drill-down sowie die Anlagenliste erreichbar. Von acht Spalten bleiben sechs, die Tabelle ist ohne waagerechtes Scrollen lesbar.
  • src/View.php: formatMoney() setzt zwischen Betrag und € ein geschütztes Leerzeichen. In schmalen Spalten rutschte das Währungszeichen sonst allein in die nächste Zeile. Wirkt in allen HTML-Ansichten; die PDF-Dienste haben eigene Formatierer und bleiben unberührt.
  • docs/anlagevermögen.md: Der Abschnitt „Anzeige in Buchungstabellen" nennt die Beleg-Detailansicht nicht mehr unter den Übersichten mit Anlage-Spalte.
  • tests/e2e/specs/beleg-buchung.spec.js: Tests auf die zusammengelegte Spalte (inklusive Typ und entfallener Anlage-Spalte) und darauf, dass Betrag und Währungszeichen in einer Zeile bleiben; die Betragsprüfungen der bestehenden Tests greifen jetzt auf die verschobene Zelle zu.

Nebenbei

  • docs/berechnungslogik.md: Die Tabelle der EÜR-Zuordnung nannte für nicht abzugsfähige Ausgaben noch euer_zeile='51'; seit Migration 075 ist es '74', und 51 ist eine gewöhnliche Ausgabenzeile (u.a. Bürobedarf 4930). Korrigiert und um Anlagevermögen, Vorsteuerkonten und die Dashboard-Kacheln ergänzt.

2026-08-02 – Testläufe hinterlassen keine Verzeichnisse mehr, die echte Uploads blockieren

  • Die Testsuite lief über docker compose exec als root, Apache/PHP dagegen als www-data. Legte ein Test unterhalb von data/ ein Verzeichnis an, gehörte es root und war für die Anwendung nicht beschreibbar. Weil der Rollback nur die Datenbank zurückdreht, wurde die im Test vergebene Belegnummer danach erneut vergeben – der nächste echte Upload traf auf das bestehende Verzeichnis, übersprang das mkdir und scheiterte in move_uploaded_file() mit HTTP 500. In #168 war nur der konkrete Verursacher entschärft worden, nicht die Ursache.
  • Aufrufkonvention: Die PHP-Suite läuft jetzt mit docker compose exec -u www-data web php tests/run.php, also unter derselben Kennung wie die Anwendung. Zurückbleibende Dateien hätten damit die richtigen Rechte und wären unschädlich. Angepasst in CLAUDE.md, README.md, docs/automatisierung.md, tests/e2e/README.md, scripts/update.sh sowie scripts/run-tests.sh – letzteres wechselt selbst per setpriv auf www-data, wenn es als root im Container startet.
  • tests/TestCase.php: Neue Methoden registerTempPath(), makeTempDir() und tempPath(). Angemeldete Pfade entfernt tearDown() rekursiv – auch nach einer Ausnahme, auch bei schreibgeschützten Verzeichnissen und inklusive der von mkdir nebenbei angelegten Elternverzeichnisse. Als Sicherheitsnetz wird ausschließlich unterhalb des Temp-, Beleg- und Logoverzeichnisses gelöscht und nie eines dieser Verzeichnisse selbst; alles andere bleibt liegen und wird gemeldet.
  • tests/run.php: Vergleicht den Bestand der Datenverzeichnisse vor und nach dem Lauf und meldet am Ende jeden Rückstand, den ein Test nicht angemeldet hat. Damit fällt ein künftiger Test auf, statt still Leichen zu hinterlassen.
  • tests/19, 33, 34, 35, 49, 66, 67, 70, 76: Alle Tests mit Dateisystem-Zugriff auf die zentrale Registrierung umgestellt; die verstreuten unlink()/rmdir()-Aufrufe und der eigene tearDown() in Test 19 entfallen. Test 66 hinterließ zusätzlich die von tempnam() angelegte Datei, weil das Suffix erst danach angehängt wurde.
  • src/Controllers/BelegController.php: upload() prüft vor dem Speichern, ob das Zielverzeichnis existiert und beschreibbar ist, und wertet den Rückgabewert von move_uploaded_file() aus. Statt einer Ausnahme mit HTTP 500 erscheint jetzt eine verständliche Meldung im Formular (bzw. als JSON bei AJAX-Uploads), und die bereits vergebene Belegnummer wird über Beleg::releaseBelegNr() wieder freigegeben.
  • tests/79_TestAufraeumenTest.php (neu): Sichert das Aufräumen samt Sicherheitsnetz und die Härtung des Uploads ab.

2026-08-02 – Nummernkreise geben die zuletzt vergebene Nummer beim Löschen wieder frei

  • Der E2E-Finalisierungstest verbrauchte bei jedem Lauf eine Rechnungs- und eine Belegnummer: Die Testrechnung wurde zwar wieder gelöscht, die Nummern blieben aber vergeben und rissen dauerhaft Lücken in den laufenden Nummernkreis.
  • src/Models/Invoice.php, src/Models/Beleg.php: delete() gibt die Nummer des gelöschten Datensatzes jetzt wieder frei. Die zuletzt vergebene Nummer wird dafür aus dem Zählerstand rekonstruiert und nur bei exakter Übereinstimmung zurückgezählt – existiert bereits eine jüngere Rechnung bzw. ein jüngerer Beleg (oder stammt die Nummer aus einem früheren Monat/Jahr), bleibt der Zähler stehen. Damit kann keine Nummer doppelt vergeben werden. Invoice::delete() gibt zusätzlich die Belegnummer des mitgelöschten Rechnungsbelegs frei.
  • scripts/e2e-cleanup.php: Räumt liegengebliebene Testdaten absteigend ab (jüngste zuerst), damit die Freigabe für die ganze Kette greift und nicht nur für den letzten Datensatz.
  • tests/78_NummernkreisFreigabeTest.php (neu): Sichert Freigabe und Wiedervergabe für beide Nummernkreise ab – inklusive der Gegenprobe, dass das Löschen eines älteren Datensatzes den Zähler unverändert lässt.
  • tests/e2e/specs/rechnung-export.spec.js: Neuer Test Löschen einer finalisierten Rechnung gibt die Nummer wieder frei weist über zwei aufeinanderfolgende Finalisierungen nach, dass der Testlauf den Nummernkreis nicht mehr weiterschiebt. Der Hinweis auf die „nicht umkehrbare Nebenwirkung" in tests/e2e/README.md entfällt damit.

2026-08-02 – E2E-Tests: Browser-Ebene abgedeckt, zwei kaputte Seiten repariert

  • Die PHP-Suite prüft Models und Services, ruft aber nie einen Controller über HTTP auf. Routing, Views und das JavaScript in den Formularen waren damit vollständig ungetestet – genau die Ebene, auf der die Fehler der letzten Wochen auftraten. Steuerkram ist jetzt an den generischen e2e-runner angebunden; auf dem Host wird dafür nichts installiert.
  • src/Controllers/AccountController.php: /accounts/new lieferte HTTP 500. create() las $_GET['from'] ohne Existenzprüfung – beim Aufruf aus dem Menü (ohne Parameter) also immer. Jahrelang war das eine stille PHP-Warning; seit der Fehler-Härtung (#142) wandelt der set_error_handler Warnings in Exceptions um, seitdem war die Seite tot. Jetzt mit ?? '' abgesichert.
  • src/Controllers/AssetController.php, src/Views/assets/form.php: /assets/new lieferte aus demselben Grund HTTP 500. Die View nutzt $testMode und $defaultKontoId unterhalb des Bearbeiten-Zweigs, gesetzt wurden beide aber nur innerhalb davon. create() übergibt jetzt testMode, die View initialisiert beide Werte am Kopf.
  • tests/e2e/ (neu): 44 Playwright-Tests. smoke.spec.js prüft, dass alle Hauptseiten ohne Serverfehler rendern (dieser Lauf fand die beiden 500er), dazu 404-Verhalten, Login-Zwang und CSV-Export. beleg-buchung.spec.js deckt Upload → Buchungsmaske → gespeicherte Buchung ab, insbesondere den Netto/Brutto-Umschalter (#137), dessen Umrechnung erst im submit-Handler passiert und serverseitig nicht prüfbar ist. rechnung-export.spec.js prüft Entwurf → Finalisieren → PDF- und XML-Export. bank-import.spec.js sichert die Vorschau samt XSS-Regression (#133) ab, die vollständig im Browser aufgebaut wird.
  • scripts/e2e-user.php, scripts/e2e-cleanup.php (neu): Legen den Testnutzer idempotent an bzw. entfernen liegengebliebene, mit E2E-TEST markierte Testdaten. Die Tests laufen gegen eine Instanz mit echten Arbeitsdaten und rühren ausschließlich ihre eigenen Datensätze an.
  • tests/77_BelegLoeschsperreTest.php (neu): Sichert ab, dass Beleg::delete() einen Beleg mit Buchungen und einen festgeschriebenen Beleg verweigert und dabei die Buchungen unangetastet lässt. Bewusst als PHP-Test – reine Model-Logik gehört nicht in den Browser.
  • tests/67_BelegXmlXsltXssTest.php: Der Test legte ein Belegverzeichnis an und löschte am Ende nur die Datei, nicht das Verzeichnis. Die Transaktion rollt die Belegnummer zurück, das Dateisystem aber nicht – und weil die Suite über docker compose exec als root läuft, blieb ein root-eigenes Verzeichnis zurück. Bekam später ein echter Upload dieselbe Belegnummer, scheiterte move_uploaded_file() mit „Permission denied" und die Seite antwortete mit HTTP 500. Das traf nicht nur Tests, sondern den normalen Beleg-Upload nach jedem lokalen Testlauf. Der Test räumt das Verzeichnis jetzt mit ab.
  • Fehlt Docker, das Runner-Projekt oder das Playwright-Image, überspringt tests/e2e/run.sh mit Exit 0 und einer Warnung. Die E2E-Tests bleiben damit optional und blockieren keine Umgebung ohne Runner.

2026-08-01 – Belege blieben nach dem Umzug nach data/ unauffindbar

  • Beim Umzug der Nutzdaten nach data/ (#150) wurden die Belege mit mv uploads/2026 data/belege/ verschoben. Existierte im Ziel bereits ein Jahresverzeichnis – etwa weil vorher schon ein Container gelaufen war –, scheiterte mv (bzw. legte den Baum als data/belege/2026/2026 ab). Weil alle Fehler mit 2>/dev/null || true unterdrückt wurden, meldete die Migration trotzdem Erfolg: In der Oberfläche waren die Belege danach nicht mehr auffindbar, obwohl die Datenbankeinträge unverändert waren.
  • scripts/uploads-uebernahme.sh (neu): Übernimmt den Altbestand jetzt Datei für Datei und führt bestehende Verzeichnisse zusammen, statt ganze Verzeichnisse zu verschieben. Eine bereits vorhandene Zieldatei wird nie überschrieben – die Quelldatei bleibt in uploads/ zur Prüfung liegen und die Kollision wird gemeldet. Verbleiben Reste, endet das Skript mit Exit-Code 1 statt mit einer Erfolgsmeldung.
  • docker-entrypoint.sh, scripts/migrate-datenverzeichnis.sh: Beide rufen dieselbe Übernahme-Logik auf, damit Container-Start und manueller Umzug nicht auseinanderdriften. Da scripts/ als Volume gemountet ist, der Entrypoint dagegen aus dem Image stammt, wirkt eine Korrektur am Umzug ohne Rebuild.
  • tests/76_DatenverzeichnisUebernahmeTest.php (neu): Führt die Übernahme real gegen ein Zielverzeichnis aus, in dem das Jahresverzeichnis bereits existiert, und sichert Zusammenführung statt Verschachtelung, unangetastete Zieldateien, die Trennung von Logo und Belegen sowie die Exit-Codes ab.

2026-08-01 – Analyse-Skript für die Nextcloud-Verbindung

  • scripts/analyse_nextcloud.sh (neu): Grenzt Störungen des Belegimports ein, ohne dass dafür Einzeiler auf dem Server nötig sind. Das Skript liest die hinterlegte Konfiguration (Passwort nur als Länge und Verschlüsselungsstatus, nie im Klartext), zeigt Namensauflösung und IP-Pinning des SSRF-Schutzes, unternimmt einen PROPFIND auf den Import-Ordner, listet dessen Inhalt inklusive der Dateien, die wegen ihres Typs übersprungen würden, und prüft die Erreichbarkeit des Erledigte-Ordners.
  • Schlägt der Verbindungsversuch fehl, folgen automatisch Gegenproben – ohne IP-Pinning sowie je Adressfamilie einzeln – und ein Befund, der zwischen abgewiesener Anmeldung (401/403), fehlendem Ordner (404), einer nicht nutzbaren Adressfamilie und einem generell nicht erreichbaren Server unterscheidet. Die cURL-Optionen entsprechen bewusst denen von NextcloudImportService::newCurl(), damit die Analyse den echten Importlauf abbildet.
  • Rein lesend: keine schreibenden WebDAV-Methoden, kein Importlauf – bei der Fehlersuche werden also keine Belege verschoben oder gelöscht. Exit-Code 0 bedeutet, dass die Verbindung steht. Läuft das Skript im Container, ruft es php direkt auf, sonst über docker compose exec.
  • tests/75_AnalyseNextcloudSkriptTest.php (neu): Sichert bash-Syntax, Ausführbarkeit und vor allem zu, dass das Skript lesend bleibt und das Passwort nicht ausgibt.
  • docs/konfiguration.md: Abschnitt „Verbindung analysieren" unter „Nextcloud-Import einrichten".

2026-08-01 – Nextcloud-Belegimport wieder funktionsfähig (IP-Pinning bei Dual-Stack-Hosts)

  • Seit dem Deployment des SSRF-Schutzes (#130) schlug der Nextcloud-Import bei jedem Lauf mit „PROPFIND fehlgeschlagen. URL/Zugangsdaten prüfen." fehl. Ursache war nicht die Konfiguration, sondern das IP-Pinning: SsrfGuard::resolvePinned() gab pro aufgelöster IP-Adresse einen eigenen CURLOPT_RESOLVE-Eintrag zurück. libcurl behandelt mehrere Einträge für dieselbe HOST:PORT-Kombination aber nicht als Alternativen – der zuletzt gesetzte verdrängt die vorherigen. Bei einem Host mit A- und AAAA-Record blieb dadurch nur die IPv6-Adresse übrig, und da der Container keine IPv6-Konnektivität hat, scheiterte jede Verbindung sofort mit „Could not connect to server".
  • src/Services/SsrfGuard.php: resolvePinned() liefert jetzt genau einen Eintrag mit allen geprüften Adressen als Liste (HOST:PORT:IP1,IP2,…), IPv6 in eckigen Klammern. cURL probiert die Adressen damit wie bei eigener Auflösung nacheinander durch und fällt auf IPv4 zurück. Der DNS-Rebinding-Schutz bleibt unverändert – gepinnt wird weiterhin ausschließlich auf die zuvor validierten Adressen.
  • src/Services/NextcloudImportService.php: Fehlgeschlagene Requests landeten bisher ohne jedes Detail im Ereignis-Log, was die Eingrenzung ohne Server-Zugriff unmöglich machte. PROPFIND- und Download-Fehler nennen jetzt den cURL-Fehlercode bzw. den HTTP-Status samt tatsächlich kontaktierter Gegenstelle (neue Methode describeTransfer()).
  • tests/74_SsrfPinningDualStackTest.php (neu): Stellt sicher, dass resolvePinned() genau einen Eintrag liefert, beide Adressfamilien eines Dual-Stack-Hosts darin enthalten sind und IPv6-Adressen geklammert werden.

2026-07-31 – Rechnungsstatus folgt automatisch den erfassten Zahlungen

  • Bisher blieb eine Ausgangsrechnung auch dann auf „Gesendet", wenn dem zugehörigen Beleg bereits Zahlungsbuchungen zugeordnet waren – der Status wurde ausschließlich manuell über „Rechnung als bezahlt markieren" gesetzt. Neu leitet sich der Status aus der Summe der zugeordneten Buchungen ab: Teilzahlung (weniger als der Rechnungsbetrag), Bezahlt (exakt der Rechnungsbetrag) und Überzahlt (mehr als der Rechnungsbetrag). Werden alle Buchungen storniert, fällt die Rechnung auf „Gesendet" bzw. „Finalisiert" zurück.
  • migrations/090_invoice_zahlstatus.sql (neu): Erweitert das Status-ENUM um Teilzahlung und Überzahlt, ergänzt die Spalte sent_at (damit beim Zurücksetzen zwischen „Finalisiert" und „Gesendet" unterschieden werden kann) und rechnet den Status vorhandener Rechnungen einmalig aus deren Buchungen nach. Manuell auf „Bezahlt" gesetzte sowie stornierte Rechnungen bleiben unangetastet.
  • src/Models/Invoice.php: Neue Methoden recalcPaymentStatus() (Statusableitung inkl. Vorzeichennormalisierung für Stornorechnungen mit negativen Beträgen) und recalcPaymentStatusForBeleg(). Entwürfe und stornierte Rechnungen werden nie überschrieben.
  • src/Models/Booking.php: create() und reverse() stoßen die Neuberechnung nach dem Commit an, ebenso das Löschen einer Buchung im Testmodus (BookingController::destroy()). Damit greift die Statusberechnung unabhängig davon, ob über die Rechnung, den Beleg oder die Bankzuordnung gebucht wird. sumBruttoForBeleg() kann jetzt auf einen Buchungstyp eingeschränkt werden; sumZahlungen() summiert die aktiven Einnahme-Hauptbuchungen einer bereits geladenen Liste.
  • src/Views/invoices/show.php, book.php: Das Zahlungs-Modal und die Buchungsansicht zeigen unter den Buchungen jetzt Rechnungsbetrag, gezahlten Betrag und den offenen Rest (bzw. den überzahlten Betrag). Die Betragsvorbelegung im Modal entspricht dem offenen Restbetrag statt immer dem vollen Rechnungsnetto, der USt-Satz wird aus der Rechnung vorbelegt.
  • Statusanzeige in Rechnungsliste, Detailansicht, Buchungsansicht und Kundenakte um die neuen Status ergänzt (badge-orange neu für Teilzahlung, badge-purple für Überzahlt), Statusfilter der Rechnungsliste umfasst jetzt alle sieben Status.
  • src/Controllers/AccountController.php: Die Kennzahl „offene Rechnungen" auf dem Dashboard berücksichtigt Teilzahlungen und weist nur noch den tatsächlich offenen Restbetrag aus.
  • tests/73_InvoiceZahlungsstatusTest.php (neu): Deckt Teilzahlung, Vollzahlung, Überzahlung, Stornos und den Rückfall auf „Gesendet"/„Finalisiert" ab.

2026-07-31 – XML-Rechnung (EN 16931) als Download in der Rechnungs-Detailansicht

  • Neben dem PDF-Symbol steht in der Detailansicht einer Rechnung jetzt ein XML-Symbol, das die Rechnungsdaten als reine EN16931-XML-Datei (Rechnung-<Nr>.xml bzw. Stornorechnung-<Nr>.xml) zum Download anbietet. Bisher war das EN16931-XML nur in die PDF eingebettet und nicht einzeln zu bekommen.
  • src/Services/ZugferdService.php: Der Dokumentaufbau steckt jetzt in buildDocument(); attachToPdf() bettet das Ergebnis wie bisher in die PDF ein, neu liefert createXml() dasselbe Dokument als XML-String. Alle Feldzugriffe sind gegen fehlende Schlüssel und null abgesichert, sodass auch unvollständige Rechnungen (z.B. Entwürfe ohne Rechnungsnummer) ein wohlgeformtes XML ergeben statt eines Fehlers.
  • Neue Methode collectMissingData(): Prüft die für EN 16931 relevanten Angaben und liefert die fehlenden getrennt nach Stammdaten (BT-27, BT-35/37/38, BT-31/32, BT-84) und Rechnungsdaten (BT-1, BT-2, BT-9, BT-10, BT-44, BT-50/52/53/55, BT-72, BG-25) zurück.
  • src/Controllers/InvoiceController.php, public/index.php: Neue Action xml() und Route GET /invoices/{id}/xml; die Detailansicht bekommt zusätzlich die Liste der fehlenden Angaben übergeben.
  • src/Views/invoices/show.php: XML-Download-Symbol neben dem PDF-Symbol (auch bei Entwürfen sichtbar). Fehlen Angaben, erscheint über der Aktionsleiste ein Hinweisbanner mit den betroffenen Feldern samt BT-Nummer, und das Symbol wird in Warnfarbe dargestellt – der Download bleibt in jedem Fall möglich.
  • Fix nebenbei: mapEinheit() verglich Einheiten ohne den im UI üblichen Punkt („Std.", „Stk."), sodass Stunden im ZUGFeRD-XML bislang als Stück (C62) statt als HUR ausgewiesen wurden. Das betraf auch das in die PDF eingebettete XML.
  • tests/72_InvoiceXmlExportTest.php (neu): Prüft Profilkennung, Rechnungsnummer, Käufer- und Verkäuferdaten, Positionen, Bruttosumme und Einheiten-Mapping im erzeugten XML sowie die Meldung fehlender Stamm-/Rechnungsdaten und die Anbindung in Route und Detailansicht.

Nachtrag: Konformität zur XRechnung-CIUS hergestellt

Der E-Rechnungsvalidator (KoSIT-Prüfkonfiguration) meldete für die heruntergeladene Datei einen Fehler und eine Warnung. Die EN16931-Schematron-Prüfung war bereits fehlerfrei – beanstandet wurden ausschließlich die zusätzlichen deutschen Regeln der CIUS XRechnung:

  • BR-DE-2 (Fehler): Die Gruppe „SELLER CONTACT" (BG-6) fehlte. ZugferdService setzt jetzt Ansprechpartner (BT-41, aus inhaber mit Rückfall auf firma), Telefon (BT-42) und E-Mail (BT-43) aus den Firmeneinstellungen.
  • BR-DE-21 (Warnung): Die Spezifikationskennung (BT-24) soll der XRechnung entsprechen. Der XML-Download verwendet dafür jetzt das Profil XRechnung 3.0 (ZugferdProfiles::PROFILE_XRECHNUNG_3) statt des reinen EN16931-Profils. XRechnung ist eine CIUS von EN 16931, die Datei bleibt damit normkonform und erfüllt zusätzlich die BR-DE-Regeln.
  • PEPPOL-EN16931-R001/R010/R020 (Hinweise): Geschäftsprozess (BT-23) sowie die elektronischen Adressen von Verkäufer (BT-34) und Käufer (BT-49, aus der Kunden-E-Mail) werden jetzt mit ausgegeben.
  • Die PDF-Einbettung bleibt unverändert beim Profil EN16931 (Factur-X, factur-x.xml) – dem üblichen Format für hybride B2B-Rechnungen. Die neuen Kontakt- und Adressangaben stehen aber auch dort, das PDF-XML wird dadurch nur vollständiger.
  • collectMissingData() prüft zusätzlich Ansprechpartner (BT-41), Telefon (BT-42), E-Mail (BT-43/BT-34) und die Kunden-E-Mail (BT-49); Test 72 deckt die neuen Elemente sowie eine XSD-Prüfung des erzeugten XML ab.
  • Fix: USt-IdNr. des Kunden (BT-48) fehlte immer im XML. Invoice::find() selektierte vom Kunden zwar Adresse, E-Mail und Zahlungsziel, aber nicht c.ust_id – und invoices hat keine eigene Spalte dafür. Der Zweig addDocumentBuyerVATRegistrationNumber() in ZugferdService lief deshalb nie, auch wenn beim Kunden eine USt-IdNr. gepflegt war (Bestandsfehler, betraf auch das in die PDF eingebettete XML). Bei inländischen Leistungen mit Steuerkategorie „S" ist BT-48 optional, weshalb es nicht auffiel; bei Reverse Charge (§ 13b UStG) und innergemeinschaftlichen Lieferungen ist die Angabe dagegen Pflicht. Invoice::find() liefert c.ust_id jetzt mit, Test 72 prüft BT-31 und BT-48 samt Schemakennung „VA".

2026-07-31 – Firmenlogo persistent in data/logo, Nutzdaten gebündelt unter data/

  • Fix #116 hatte die Beleg-Uploads aus dem Web-Root nach data/uploads verschoben – inklusive des Unterordners logo/. Angepasst wurden damals aber nur Beleg-, Bank- und Invoice-Code; Setting, SettingsController und PdfInvoiceService zeigten weiterhin auf public/static/uploads/logo/. Dadurch war das hochgeladene Firmenlogo ab diesem Zeitpunkt nicht mehr auffindbar (Datei vorhanden, Pfad falsch) und ein Neu-Hochladen legte es erneut im Web-Root ab, wo es bei jedem Update verloren ging.
  • Neue Ablagestruktur: Alle persistenten Nutzdaten liegen jetzt gebündelt und nach Art getrennt unter data/ – data/mariadb (Datenbank), data/belege (Belegdateien), data/logo (Firmenlogo). Jedes Verzeichnis ist ein eigenes Volume; data/mariadb wird nicht mehr in den web-Container gemountet.
  • data/README.md (neu): Beschreibt für den Admin, was wo liegt, warum die Dateien außerhalb des Web-Roots stehen und wie eine vollständige Datensicherung aussieht.
  • src/Config.php: upload_dir zeigt auf data/belege, neuer Schlüssel logo_dir auf data/logo.
  • src/Models/Setting.php: Neue Methode logoAbsolutePath() löst den DB-Wert gegen logo_dir auf (toleriert Altpfade mit static/uploads/-Präfix); deleteLogo() nutzt sie statt des Web-Root-Pfads. setLogo() speichert nur noch den Dateinamen.
  • src/Controllers/SettingsController.php: uploadLogo() schreibt nach data/logo und räumt beim Formatwechsel (png ↔ jpg) die Altdatei ab; neue Methode serveLogo() liefert das Logo authentifiziert über /settings/logo/file aus (Pfad- und MIME-Prüfung).
  • src/Services/PdfInvoiceService.php, src/Views/invoices/show.php, src/Views/settings/form.php: Beziehen das Logo über den neuen Endpunkt bzw. den absoluten Dateipfad statt über einen Web-Root-Pfad.
  • migrations/089_pfade_ohne_webroot_praefix.sql (neu): Entfernt die historischen static/uploads/-Präfixe aus belege.storage_path und company_settings.logo_path; idempotent.
  • scripts/migrate-datenverzeichnis.sh (neu): Verschiebt vorhandene Daten aus ./uploads nach ./data/belege bzw. ./data/logo. Der Umzug läuft in einem kurzlebigen Container als root, da die Dateien www-data gehören – kein sudo nötig, idempotent.
  • docker-compose.yml, Dockerfile, docker-entrypoint.sh, .gitignore: Volumes, Verzeichnisanlage und Rechte auf die neue Struktur umgestellt; .gitignore schließt data/* aus (Ausnahme: data/README.md), sodass git clean die Nutzdaten nicht mehr treffen kann.
  • Kein Breaking Change bei bestehenden Installationen: Der Entrypoint übernimmt vorhandene Dateien beim Container-Start automatisch nach data/belege bzw. data/logo – sowohl aus der gemeinsamen uploads-Ablage (dafür bleibt ./uploads übergangsweise als data/legacy-uploads gemountet) als auch ein Logo aus dem alten Web-Root. Ein reines scripts/update.sh (git pull + Rebuild + Start) genügt damit; ohne diese Automatik hätte Migration 089 die DB-Pfade umgeschrieben, während die Dateien am alten Ort liegen geblieben wären.
  • Fix: Datei-Backups liefen seit Fix #116 leer. scripts/backup.sh sicherte weiterhin public/static/uploads/ – seit dem Umzug aus dem Web-Root ein leeres Verzeichnis. Die Archive enthielten dadurch keine einzige Belegdatei mehr (117 Bytes statt zweistelliger MB-Beträge), ohne dass es auffiel; gesichert wurde nur noch die Datenbank. Das Skript sichert jetzt data/belege und data/logo und bricht nicht mehr stillschweigend ab, sondern warnt, wenn das Archiv keine Datei enthält.
  • scripts/restore.sh: Stellt Belege und Logo an den neuen Orten wieder her und erkennt zusätzlich das alte uploads/-Archivformat, sodass ältere Datei-Backups einspielbar bleiben.
  • scripts/update.sh: Neuer Schritt 2 aktualisiert scripts/ per git pull und startet sich in der neuen Fassung neu, bevor das Backup läuft. Bislang sicherte ein Update sich noch mit den Skripten des Vorgängerstands ab – dadurch entstand das leere Datei-Backup. Der Ausgangs-Commit wird über STEUERKRAM_BEFORE_COMMIT an den Neustart weitergereicht, damit die Rebuild-Erkennung die Änderungen weiterhin sieht. Der frühere, engere Selbst-Neustart (nur bei Änderung an update.sh selbst, und erst nach dem Backup) entfällt.
  • scripts/backup.sh: Sichert defensiv jeden Ort, an dem Nutzdateien liegen können (data/belege, data/logo, data/legacy-uploads, data/uploads sowie das alte Web-Root-Verzeichnis), sofern er nicht leer ist. Damit ist das Backup auch im Übergangszustand eines laufenden Updates vollständig, in dem die Skripte schon neu sind, die Dateien aber erst beim nächsten Container-Start umziehen.
  • scripts/restore.sh: Entpackt in ein Staging-Verzeichnis und normalisiert anschließend alle je verwendeten Ablagen (belege/, logo/, legacy-uploads/, uploads/) auf die aktuelle Struktur.
  • tests/71_DatenverzeichnisPfadeTest.php (neu): Hält Backup-, Restore-, Compose-, Entrypoint- und Update-Pfade mit Config::upload_dir/logo_dir zusammen und prüft, dass die Skript-Vorabaktualisierung vor dem Backup steht – genau dieser Drift hatte das leere Backup verursacht.

2026-07-31 – Fix: Logo-Upload defekt (falsches Ziel-Formular + Warnings in Setting::update())

  • Der „Hochladen"-Button in den Einstellungen postete an /settings statt an /settings/logo: Das Logo-Formular lag als verschachteltes <form> innerhalb von #settings-form. HTML erlaubt keine verschachtelten Formulare – der Browser verwirft das innere Start-Tag, wodurch das erste </form> bereits #settings-form schloss. Folge: Der Upload lief nie, stattdessen wurde Setting::update() mit unvollständigen POST-Daten aufgerufen. Aus demselben Grund war auch der Logo-Löschen-Button (form="delete-logo-form") wirkungslos.
  • Setting::update() las mail_subject_template/mail_body_template weiterhin per ?: aus $data, obwohl beide Felder seit „E-Mail-Betreff und -Text pro Kunde" (2026-05-30) nicht mehr im Einstellungsformular stehen. Das erzeugte „Undefined array key"-Warnings; deren Ausgabe verhinderte anschließend den Location-Header in View::redirect() („headers already sent").
  • src/Views/settings/form.php: #logo-upload-form und #delete-logo-form stehen jetzt als eigenständige, leere Formulare außerhalb von #settings-form; Datei-Input und Buttons sind per form="…"-Attribut daran gebunden.
  • src/Models/Setting.php: Alle optionalen Felder werden konsistent über ($data['x'] ?? '') ?: default gelesen, sodass fehlende POST-Keys keine Warnings mehr auslösen.
  • tests/70_SettingsLogoUploadTest.php: Neuer Test prüft, dass Setting::update() ohne optionale Keys warnungsfrei durchläuft, dass setLogo()/deleteLogo() den Pfad korrekt schreiben und dass in #settings-form kein verschachteltes <form> mehr steht.

2026-07-30 – Fix: SMTP-Header-/Kommando-Injection über mail_from_email (Issue #148)

  • SmtpMailer::sendMessage()/buildMessage() verwendeten fromEmail roh sowohl im MAIL FROM:<...>-SMTP-Kommando als auch im From:-Header, während alle anderen dynamischen Werte (Empfänger, fromName, subject, Anhangsname) bereits validiert bzw. CRLF-bereinigt wurden. mail_from_email (Firmeneinstellung, per POST setzbar) durchlief keine serverseitige Prüfung – ein eingeloggter Nutzer konnte per CRLF-Payload zusätzliche SMTP-Kommandos (z.B. eine verdeckte RCPT TO-Zeile) einschleusen oder den From:-Header manipulieren (CWE-93).
  • src/Models/Setting.php: Setting::update() validiert mail_from_email jetzt über filter_var(..., FILTER_VALIDATE_EMAIL) (neue private Methode sanitizeFromEmail()); ungültige Werte (inkl. CRLF-Payloads) werden verworfen statt gespeichert.
  • src/Services/SmtpMailer.php: Defense-in-Depth – neue Methode sanitizeAddress() entfernt CR/LF aus fromEmail, bevor dieser im MAIL FROM:-Kommando und im From:-Header verwendet wird.
  • tests/69_MailFromEmailInjectionTest.php: Neuer Test prüft, dass ungültige/CRLF-behaftete Absenderadressen beim Speichern verworfen werden und dass SmtpMailer::sanitizeAddress() CR/LF zuverlässig entfernt.

2026-07-27 – Fix: Produktions-Härtung der PHP-Fehlerausgabe (Issue #142)

  • Das Docker-Image (php:8.3-apache) übernahm die kompilierten PHP-Defaults, bei denen display_errors aktiviert ist – im offiziellen Image wird php.ini-production nicht automatisch aktiviert. In Kombination mit PDO::ATTR_ERRMODE_EXCEPTION (src/Database.php) und fehlendem globalem Error-Handler hätte jeder unerwartete Fehler (SQL-Fehler, DB-Verbindungsproblem, Typfehler) eine vollständige PHP-Fehlermeldung inkl. Stacktrace, absoluten Serverpfaden und ggf. SQL-Details direkt an den Client ausgegeben (CWE-209, Information Exposure Through an Error Message) – auch für nicht angemeldete Nutzer (/login ist vor Auth::require() erreichbar).
  • php/error-handling.ini (neu): display_errors = Off, display_startup_errors = Off, log_errors = On, error_reporting = E_ALL & ~E_DEPRECATED.
  • Dockerfile: Kopiert php/error-handling.ini zusätzlich zu php/uploads.ini nach /usr/local/etc/php/conf.d/.
  • public/index.php: Registriert ganz zu Beginn (vor Autoload/Routing) set_error_handler (wandelt Warnings/Notices in ErrorException um), set_exception_handler und register_shutdown_function (fängt Fatal Errors, die nicht als Exception auftreten). Unerwartete Fehler werden per error_log serverseitig geloggt; der Client erhält nur eine generische HTTP-500-Seite ohne Details.
  • tests/68_ProductionErrorHardeningTest.php: Neuer Test prüft die Ini-Direktiven, die Dockerfile-Einbindung, die Registrierung der Handler vor dem Autoload sowie dass die an den Client gesendete Fehlerseite keine Exception-Details (Message/Trace/File) enthält.

2026-07-27 – Fix: Stored XSS über XSLT in hochgeladenen XML-Belegen (Issue #135)

  • BelegController::serveFile() liefert Belege mit dem in der DB hinterlegten mime_type und Content-Disposition: inline unverändert an den Browser aus. Da für Belege application/xml/text/xml als MIME-Typ erlaubt ist, konnte eine hochgeladene XML-Datei mit eingebettetem <?xml-stylesheet?>/xsl:stylesheet beim Öffnen über den „Datei öffnen“-Link (target="_blank", kein download-Attribut) direkt im Browser (v.a. Firefox) per XSLT-Transformation zu ausführbarem <script> im Sicherheitskontext der Anwendung führen (Stored-XSS, CWE-79) – inklusive Zugriff auf das im <meta>-Tag liegende CSRF-Token (vgl. #132/#133).
  • src/Controllers/BelegController.php: serveFile() liefert XML-Belege (application/xml/text/xml) jetzt mit Content-Disposition: attachment statt inline aus, sodass der Browser die Datei herunterlädt statt sie zu navigieren/rendern; andere MIME-Typen bleiben unverändert inline.
  • public/index.php: Globaler X-Content-Type-Options: nosniff-Header für alle Responses als zusätzliche Absicherung gegen MIME-Sniffing.
  • tests/67_BelegXmlXsltXssTest.php: Neuer Test lädt einen XML-Beleg mit XSLT-Payload an und prüft, dass serveFile() den Content-Disposition-Header auf attachment statt inline setzt.

2026-07-27 – Fix: DOM-XSS in der CSV/XLSX-Vorschau beim Bankimport (Issue #133)

  • BankController::detectFormat() liefert die aus einer hochgeladenen Bank-Umsatzdatei geparste header_row/first_data unverändert als JSON zurück; src/Views/bank/upload.php rendert diese Werte per innerHTML in ein title="..."-Attribut. Die dortige esc()-Funktion escapte bisher nur &, < und >, nicht aber ". Ein Header-/Zellwert wie Spalte" onmouseover="alert(document.cookie) konnte damit aus dem Attribut ausbrechen und beliebigen JavaScript-Code im Kontext des eingeloggten Nutzers ausführen (CWE-79), z.B. um das im <meta>-Tag liegende CSRF-Token auszulesen. Bank-Exportdateien stammen typischerweise von Dritten (Bank, per E-Mail zugeschickter Kontoauszug) und sind damit ein realistischer, angreiferkontrollierter Eingabekanal.
  • src/Views/bank/upload.php: esc() escaped jetzt zusätzlich " (zu &quot;) und ' (zu &#39;).
  • tests/66_BankUploadPreviewXssTest.php: Neuer Test prüft per Quelltext-Check, dass esc() Anführungszeichen escaped, und führt die extrahierte JS-Funktion (falls node verfügbar ist) mit einem bösartigen Zellwert aus, um zu verifizieren, dass kein Ausbruch aus dem title-Attribut mehr möglich ist.

2026-07-27 – Fix: Reflektiertes XSS über _error-Parameter bei Rechnungs-Zahlungserfassung (Issue #132)

  • InvoiceController::book() übernahm den Fehler-Array ungeprüft direkt aus $_GET['_error'] und reichte ihn an die View weiter. Da _error nirgendwo serverseitig befüllt wird und der CSRF-Schutz nur für POST/PUT/DELETE/PATCH greift, konnte ein Angreifer per einfachem Link (GET /invoices/{id}/book?_error[x]=<script>...) beliebiges JavaScript im Kontext eines eingeloggten Nutzers ausführen und darüber auch das im <meta>-Tag liegende CSRF-Token auslesen.
  • src/Controllers/InvoiceController.php: book() übernimmt _error nicht mehr aus $_GET, $formErrors startet immer leer.
  • src/Views/invoices/book.php, src/Views/belege/detail.php: Die Fehleranzeige escaped Schlüssel und Werte jetzt konsequent mit $h() (zuvor ungeschütztes implode/array_map-Pattern, in belege/detail.php betrifft dies POST-basierte Fehler aus BookingController).
  • tests/65_InvoiceBookReflectedXssTest.php: Neuer Test prüft, dass ein <script>-Payload in $_GET['_error'] nicht mehr unescaped in der Ausgabe von InvoiceController::book() erscheint.

2026-07-27 – Fix: CSV-Formel-Injection im Anlagelisten-Export (Issue #131)

  • EuerController::anlageCsv() schrieb assets.bezeichnung (frei editierbarer Text) ungefiltert per fputcsv() in die exportierte Anlageliste-{year}.csv. Eine Bezeichnung wie =cmd|'/c calc'!A1 konnte beim Öffnen der CSV in Excel/LibreOffice zu Formelausführung führen (CSV-/Formel-Injection, CWE-1236). BelegController/BookingController waren dank eigener, privater csvSanitize()-Methoden bereits abgesichert.
  • src/Controllers/BaseController.php: Die bisher in BelegController und BookingController duplizierte csvSanitize()-Methode wurde als gemeinsame protected Methode hierher verschoben (Präfix ' bei führendem =, +, - oder @).
  • src/Controllers/EuerController.php: anlageCsv() sanitisiert bezeichnung jetzt über $this->csvSanitize().
  • src/Controllers/BelegController.php, src/Controllers/BookingController.php: Duplizierte private csvSanitize()-Methoden entfernt, nutzen jetzt die geerbte Methode aus BaseController.
  • tests/64_AnlageCsvInjectionTest.php: Neuer Test prüft, dass eine Anlagenbezeichnung mit führendem = im CSV-Export mit vorangestelltem ' maskiert wird.

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 <datalist id="bl-supplier-datalist"> 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.
  • 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.
  • 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 <script>-Tag) gesetzt wird und nicht sofort von ihr überschrieben wird.

2026-05-14 – Feature: Bank buchen

  • Navigation: neuer Eintrag „Bank buchen" unter Belege & Buchungen
  • Migration 049: Tabellen bank_import_formats, bank_import_batches, bank_transactions; Comdirect-Format als erster Eintrag
  • BankImportParser: Parst CSV/TXT (quoted, Semikolon-getrennt) und XLSX (via ZipArchive/SimpleXML, kein Composer-Paket)
  • Liste /bank: Suche + Filter (Jahr, Monat, Einnahme/Ausgabe, Status); Standard-Filter: offen; Einnahmen grün, Ausgaben rot
  • Upload /bank/upload: Drag-&-Drop-Zone für CSV/TXT/XLSX + Format-Auswahl
  • Detail /bank/{id}: Bank-Zeile oben; Beleg-Upload; Liste offener Belege zum Verknüpfen
  • Status-Aktionen: „Ignorieren" / „Wieder öffnen"; Verknüpfen eines Belegs setzt Status auf „gebucht"

2026-05-14 – Feature: UStVA Kz 83 = ust81 + ust89 − Kz66 − Kz61

  • UstvaCalculator: Kz 83 wird nun als ust81 + ust89 − Kz66 − Kz61 berechnet (nicht mehr kontobasiert via ustva_kz='83')
  • Test 29: angepasst (7%-Buchung auf korrektes Konto 8300; delta83-Erwartung aktualisiert)

2026-05-14 – Feature: UStVA Kz 61 aus ust89 abgeleitet (ReverseCharge-Symmetrie)

  • UstvaCalculator: Kz 61 = ust89 (nicht mehr kontobasiert via Konto 1574) – fachlich korrekt, da VSt ig. Erwerb = USt ig. Erwerb
  • Booking::reverse(): Hauptstornobuchung überträgt jetzt steuerart der Originalbuchung (fehlte bisher, wurde durch Kz-61-Kontoberechnung kaschiert)

2026-05-14 – Feature: UStVA Kz 81/89 auf volle Euro abgerundet

  • UstvaCalculator: Kz 81 und 89 werden mit floor() auf volle Euro abgerundet; USt wird auf dem gerundeten Wert berechnet
  • Darstellung: Kz 81/89 zeigen "1.234,- €" statt "1.234,00 €" (in Tabelle und Zusammenfassung)
  • USt-Felder (ust81, ust89) bleiben als Dezimalzahl unverändert
  • Test 31: prüft floor-Verhalten für Kz 81 und 89

2026-05-14 – Refactor: UStVA auf 5-Kennzahlen-Schema vereinfacht

  • Migration 048: Kontenrahmen bereinigt – nur noch ustva_kz ∈ {81, 83, 61, 66} aktiv; 1571/1576 → 66, 1574 → 61, 1771/1774 → 83, alle anderen auf NULL
  • UstvaCalculator: Nur noch Kz 81, 89, 66, 61, 83 + zwei Anzeigefelder (ust81, ust89 = Kz × 0,19, kein XML)
  • Kz 83 ist jetzt „Vorauszahlung / Überschuss" = (USt-Konten) − Kz 66 − Kz 61
  • ElsterXmlService: Export nur Kz 81/89 (ganzzahlig) + 66/61/83 (2 Dez); Kz 83 nur bei positivem Wert
  • View ustva_show.php: Neue Sektionsgliederung, Zusammenfassungsbox aktualisiert
  • Tests 5, 7, 13, 18, 28, 29, 30 auf neues Schema angepasst

2026-05-10 – Feature: ig. Erwerb 19 % ReverseCharge über Kindzeilen

  • Migration 047: ustva_kz='91' für Konto 1774; Kindzeilen (1574/1774) für bestehende ig_erwerb_19-Buchungen
  • Booking::create(): steuerart=ig_erwerb_19 erzeugt zwei Kindzeilen – VSt auf 1574 (Kz 67), USt auf 1774 (Kz 91)
  • UstvaCalculator: Kz 67/91 jetzt kontobasiert via 1574/1774; Kz 93/94 (ig_erwerb_7) entfernt
  • Buchungsformular: Option „ig. Erwerb 7 %" entfernt
  • Test 7 auf neue Logik angepasst; Test 30 neu

2026-05-10 – Feature: USt-Buchungszeilen für Ausgangsrechnungen

  • Migration 046: Konten 1771 (USt 7 %) und 1776 (USt 19 %) erhalten ustva_kz='84'/'83'`; bestehende Einnahme-Buchungen mit USt erhalten nachträglich USt-Kindzeilen
  • Booking::create(): Bei Einnahme-Buchungen mit 7/19 % und steuerart=normal wird analog zur VSt-Kindzeile eine USt-Zeile auf 1771/1776 angelegt
  • UstvaCalculator: Kz 83/84 jetzt kontobasiert via 1771/1776 (betrag_netto), nicht mehr via betrag_ust der Erlöskonten; KZ_ACCOUNT_MAP aktualisiert
  • Invoice::cancel(): Nur Hauptbuchungen (parent_booking_id IS NULL) werden direkt storniert; Kindzeilen werden von Booking::reverse() mitaufgehoben (verhindert Doppelstorno)
  • Tests 10 und 27 an geänderte Buchungsstruktur angepasst; Test 29 neu

2026-05-05 – Feature: Stornorechnung (§ 14 UStG)

Hinzugefügt

  • migrations/042_invoice_storno_of_id.sql: Neue Spalte storno_of_id in invoices (FK auf Original)
  • Invoice::createCancellation(): Legt Stornorechnung mit negierten Beträgen an, setzt Original auf „Storniert", erzeugt GoBD-konforme Stornobuchungen
  • Storno-Button in Rechnungsübersicht (list.php) und Detailansicht (show.php)
  • Querverweise Original ↔ Stornorechnung in der Detailansicht
  • PDF-Anpassung: Titel „STORNORECHNUNG", Referenzzeile, kein Zahlungshinweis, angepasster Dateiname
  • Test 27_StornorechnungTest.php

2026-04-28 – Feature: E-Rechnung / ZUGFeRD EN16931

Hinzugefügt

  • src/Services/ZugferdService.php: Erzeugt ZUGFeRD-konformes EN16931-XML und bettet es in den PDF-String ein (via horstoeko/zugferd)
  • src/Services/PdfInvoiceService.php: PDF wird nach der mPDF-Generierung durch ZugferdService::attachToPdf() geleitet; Ausgabe erfolgt jetzt manuell per Header/echo statt mpdf->Output('D')
  • Alle Ausgangsrechnungs-PDFs sind ab sofort gültige E-Rechnungen (ZUGFeRD EN16931 / Factur-X)

2026-04-27 – Feature: Neues Rechnungsnummernformat (PPPYYYYMM-nn)

Geändert

  • src/Models/Invoice.php: generateRechnungsNr() erzeugt jetzt das Format PPPYYYYMM-nn (Prefix + Jahr + Monat + zweistellige Sequenz, die bei Bedarf wächst); Zähler wird monatlich zurückgesetzt
  • migrations/038_invoice_nr_month.sql: Neue Spalte invoice_nr_month in company_settings

2026-04-26 – Fix: Eigenanteil-Buchung als Einnahme statt Ausgabe

Geändert

  • src/Models/Booking.php: upsertKorrekturbuchung() setzt buchungstyp jetzt korrekt auf Einnahme (war fälschlich Ausgabe)

2026-04-26 – Feature #57: Lineare Abschreibungstabelle für Anlagegüter

Hinzugefügt

  • migrations/035_assets_abschreibung_flags.sql: Neue Spalten automatisch_berechnet und cent_im_ersten_jahr in assets
  • migrations/036_asset_depreciation_schedule.sql: Neue Tabelle asset_depreciation_schedule (Jahr, Abschreibungsbetrag, Restwert)
  • src/Models/AssetDepreciation.php: Berechnung (linear, Sofortabschreibung, Cent-Option), Persistenz
  • tests/26_AbschreibungstabelleTest.php: 22 Tests für alle AfA-Szenarien

Geändert

  • src/Models/Asset.php: create() und update() speichern automatisch_berechnet und cent_im_ersten_jahr
  • src/Controllers/AssetController.php: Abschreibungstabelle wird nach Speichern automatisch generiert; neue Endpunkte /assets/depreciation-preview (AJAX-Vorschau) und /assets/{id}/depreciation (manuelle Speicherung)
  • src/Views/assets/form.php: Nutzungsdauer-Dropdown (sofort, 1–10, 12, 13, 15, 20, 25, 50 Jahre + individuell), Cent-Option, „AfA-Tabelle"-Button, Modal mit Auto/Manuell-Toggle und editierbarer Tabelle
  • public/index.php: Neue Routen für Depreciation-Preview und -Speicherung

2026-04-26 – KI-Diagnose: Erreichbarkeitsprüfung und Fehlerlog für Ollama

Geändert

  • src/Services/LlmService.php: Neue Methode isReachable() für Diagnosezwecke; Fehlerlog in callOllama() bei cURL-Fehler oder leerer Antwort
  • src/Views/settings/form.php: Einstellungsseite prüft jetzt aktiv, ob der Ollama-Server erreichbar ist (nicht nur ob er konfiguriert ist); zeigt bei Verbindungsfehler einen konkreten Hinweis auf OLLAMA_HOST=0.0.0.0:11434

2026-04-26 – Leistungszeitraum als freies Textfeld

Geändert

  • migrations/034_leistungszeitraum_text.sql: Spalten leistungszeitraum_von (DATE) und leistungszeitraum_bis (DATE) wurden durch eine einzige Textspalte leistungszeitraum (VARCHAR 255) ersetzt; bestehende Datumswerte werden automatisch migriert
  • src/Views/invoices/form.php: Zwei Datumsfelder durch ein Freitextfeld „Leistungszeitraum" ersetzt, Grid von 4 auf 3 Spalten angepasst
  • src/Models/Invoice.php: INSERT/UPDATE verwenden jetzt leistungszeitraum statt leistungszeitraum_von/leistungszeitraum_bis
  • src/Controllers/InvoiceController.php: Datums-Defaults für leistungszeitraum_von/leistungszeitraum_bis entfernt
  • src/Views/invoices/show.php: Leistungszeitraum-Anzeige vereinfacht (kein Datumsformat, direkter Textausgabe)
  • src/Services/PdfInvoiceService.php: Leistungszeitraum-Ausgabe vereinfacht

2026-04-15 – KI-Erkennung: automatischer Aufruf aus Belegansicht entfernt

Geändert

  • src/Views/belege/detail.php: KI-Analyse wird nicht mehr automatisch beim Öffnen eines Belegs ausgelöst; stattdessen wird ein Hinweis "KI-Analyse wird im Hintergrund durchgeführt …" mit einem "Jetzt generieren"-Button angezeigt, wenn noch kein Vorschlag vorhanden ist
  • Hintergrund: Die Analyse läuft bereits beim Nextcloud-Import (ki-batch.php); ein doppelter Aufruf aus der UI war überflüssig

2026-04-12 – Anlagevermögen: Anzeige-Fix und Daten-Backfill

Fix

  • Buchungstabellen (Beleg-Detail, Journal, UStVA-Drill-down, EÜR-Drill-down): Anlage-Spalte zeigt jetzt auch die Bezeichnung als Fallback, wenn anlage_nr leer ist
  • migrations/032_anlage_nr_backfill.sql: Anlage-Nummern für bestehende Anlagegüter ohne anlage_nr werden nachgeneriert

2026-04-12 – Anlagevermögen (Wirtschaftsgüter)

Neu

  • Navigation: neuer Abschnitt „Betriebsvermögen" mit Menüpunkt „Anlagevermögen"
  • migrations/028_assets_anlagevermoegen.sql: neue Tabelle assets für Wirtschaftsgüter
  • src/Models/Asset.php: CRUD-Model mit automatischer laufender Nummer und Geschäftsjahrzuordnung
  • src/Controllers/AssetController.php: Liste, Anlegen, Bearbeiten, Löschen
  • src/Views/assets/list.php: Anlagenspiegel-Tabelle mit Bleistift- und Papierkorb-Aktionen
  • src/Views/assets/form.php: Formular für Wirtschaftsgut (Bezeichnung, Anschaffungsdatum, Kosten, Zugänge, Abgänge, AfA, Nutzungsdauer, Notizen)
  • tests/20_AnlagevermoegensTest.php: 14 Tests für CRUD-Operationen

2026-04-12 – EÜR-Korrekturen: Gewerbesteuer + Bewirtungskonten ohne VSt

Geändert

  • migrations/028_euer_4320_bewirtung_ohne_vst.sql: Gewerbesteuer (4320) von Z. 52 auf Z. 51 (§ 4 Abs. 5b EStG, KZ 165 nicht abzugsfähig)
  • migrations/028_euer_4320_bewirtung_ohne_vst.sql: Konten 4652 (Bewirtung 70 % ohne VSt, Z. 44) und 4657 (Bewirtung 30 % ohne VSt, Z. 51) ergänzt
  • docs/kontenrahmen.md: Dokumentation entsprechend aktualisiert

2026-04-12 – Klasse-0-Konten als Anlagevermögen klassifiziert

Geändert

  • docs/kontenrahmen.md: Alle 10 Klasse-0-Konten (0027–0480) von Zahlungskonto auf Anlagevermögen korrigiert
  • migrations/027_anlagevermögen_kontenart.sql: Migration war bereits vorhanden und setzt die DB-Änderung beim nächsten Container-Start um

2026-04-12 – Backup: smarte Rotation und Lock-Schutz

Geändert

  • scripts/backup.sh: Rotation auf 7 Tages- / 4 Wochen- / 12 Monats-Buckets umgestellt (statt pauschaler 90-Tage-Löschung)
  • scripts/backup.sh: flock-Lock verhindert parallele Läufe bei Verzögerungen
  • scripts/backup.sh: Pfade sind absolut (Script funktioniert auch aus Cron ohne Verzeichniswechsel)

2026-04-12 – Uploads: named Volume → Bind Mount

Geändert

  • docker-compose.yml: Upload-Volume von named Volume (steuerkram_uploads) auf Bind Mount (./uploads/) umgestellt – konsistent mit ./data/mariadb/
  • scripts/migrate-uploads-to-bindmount.sh: Einmalige Migration hinzugefügt

Update auf Produktiv-System

Vor docker compose up einmalig ausführen:

./scripts/migrate-uploads-to-bindmount.sh

Das Script kopiert alle Dateien aus dem named Volume in ./uploads/, ist idempotent und gibt am Ende den Befehl zum Löschen des alten Volumes aus.


2026-04-11 – Buchungsbasierte Vorsteuer Kz 61/62 in UStVA

Geändert

  • UstvaCalculator: Kz 61 und 62 werden nicht mehr über accounts.ustva_kz geroutet, sondern direkt aus bookings.ust_satz ermittelt (buchungstyp='Ausgabe', betrag_ust != 0). Damit reicht ein einziges Aufwandskonto für beide USt-Sätze – separate VSt19-/VSt7-Konten sind nicht mehr erforderlich
  • fetchBookingsForKz: Drill-down für Kz 61/62 nutzt jetzt ebenfalls buchungsbasierte Filterung
  • BERICHTE.md: Routing-Logik für Kz 61/62 dokumentiert

2026-04-10 – Nextcloud-Belegimport via WebDAV

Neu

  • NextcloudImportService: importiert Belege aus einem Nextcloud-Ordner via WebDAV (PROPFIND → GET → MOVE/DELETE); Duplikat-Erkennung via SHA-256; JPEG/PNG werden nach dem Download automatisch zugeschnitten
  • scripts/nextcloud-import.php: CLI-Einstiegspunkt für Cron-Betrieb
  • Migration 023: Nextcloud-Zugangsdaten (nextcloud_url, nextcloud_user, nextcloud_pass, nextcloud_folder, nextcloud_done) in company_settings; Konfiguration über Einstellungen → Nextcloud-Belegimport in der UI

2026-04-10 – UStVA Vorsteuer-Kz-Aufteilung (Migration 022)

Fix

  • Migration 022: Vorsteuer-Konten von ustva_kz='66' auf '61' (VSt19) bzw. '62' (VSt7) aufgeteilt – Kz 61, 62 und 66 wurden zuvor stets als 0 ausgewiesen
  • UstvaCalculator: zweite Abfrage für steuerart-basierte EU-Kennzahlen (ig. Lieferung → Kz 41, Ausfuhr → Kz 44, ig. Erwerb → Kz 89/91/93/94); verhindert Doppelzählung gegenüber kontobasierter Berechnung
  • fetchBookingsForKz: UNION-Abfrage aus Konto-Kz und steuerart für korrekten Drill-down
  • Tests 4, 5, 6, 9: Kontonummern auf tatsächliche SKR03-Nummern korrigiert (8000→8400, 1400→1200)

2026-04-10 – Upload-Limit auf 20 MB erhöht

Geändert

  • php/uploads.ini: upload_max_filesize und post_max_size auf 20 MB gesetzt (zuvor PHP-Standard 2 MB)
  • Dockerfile: bindet php/uploads.ini als PHP-Konfiguration ein

2026-04-10 – Belegtext-Speicherung für spätere Volltextsuche

Neu

  • Migration 021: Spalte beleg_text MEDIUMTEXT in belege – speichert den per pdftotext oder Tesseract-OCR extrahierten Volltext
  • KontierungsvorschlagService::extractText(): neue public Methode zur Textextraktion (PDF + Bild), unabhängig vom KI-Vorschlag nutzbar
  • Beim Generieren des KI-Vorschlags wird beleg_text automatisch befüllt (einmalig, nur wenn noch leer)
  • Beleg::saveBelegText(): neue Methode im Model

2026-04-10 – OCR-Schalter unabhängig vom KI-Schalter

Neu

  • Einstellungen: separater Schalter „OCR-Texterkennung" (ocr_aktiv) zum unabhängigen Ein-/Ausschalten der Tesseract-OCR bei Bildbelegen (Migration 020)
  • KI-Schalter und OCR-Schalter arbeiten nun vollständig unabhängig voneinander
  • KontierungsvorschlagService::isOcrEnabled(): neue Methode, prüft OCR-Einstellung
  • Bildbeleg-OCR wird nur noch ausgeführt, wenn ocr_aktiv = 1; PDF-Textextraktion via pdftotext läuft unabhängig davon

2026-04-02 – Kontenrahmen: Dokumentation und finale Korrekturen

Neu

  • Kontenrahmen.md: Vollständige Dokumentation aller SKR03-Systemkonten mit UStVA-Kennzahlen und EÜR-Zeilennummern
  • Verweis auf Kontenrahmen.md in README.md ergänzt

Geändert (Navigation)

  • Kontenrahmen (/accounts) als eingerückter Untermenüpunkt unter „Einstellungen" in der Seitenleiste
  • Konten-Tabelle aus /settings entfernt (war redundant zu /accounts)

Geändert (UStVA-Kennzahlen, Migration 017)

  • KZ 61 → 66 für alle VSt19-Konten (normale Inlands-Vorsteuer, § 15 Abs. 1 Nr. 1 UStG)
  • KZ 62 → 66 für alle VSt7-Konten
  • KZ 35 → 48 für 8100/8105/8110 (steuerfreie Umsätze ohne VoSt-Abzug)
  • KZ 35 → 43 für 8120 (Ausfuhrlieferungen § 4 Nr. 1a UStG)

Geändert (EÜR-Zeilennummern, Migration 019)

  • 8400/8570/8720: Z. 14 → Z. 16 (19 %-Erlöse netto; Z. 14 = Kleinunternehmer brutto)
  • 8120/8125: Z. 16 → Z. 18 (Ausfuhr/ig. Lieferungen)
  • 8950: Z. 18 → Z. 21 (nicht steuerbare Innenumsätze)
  • 4672: Z. 43 → Z. 51 (nicht abziehbarer Anteil Unternehmerreisekosten, § 4 Abs. 5 EStG)
  • 4288: Z. 52 → Z. 65 (häusl. Arbeitszimmer abziehbar, eigene Zeile seit EÜR 2023)
  • 4287: Z. 52 → Z. 66 (Tagespauschale häusl. Wohnung, eigene Zeile seit EÜR 2023)
  • 4856: Z. 49 → Z. 37 (Sonderabschreibungen § 7b EStG)

Kanonischer Seed (Migration 018)

  • 018_seed_kontenrahmen_skr03_final.sql: Alle 146 Systemkonten mit bereits korrekten UStVA-KZ und EÜR-Zeilen; Neuinstallationen erhalten den vollständigen aktuellen Stand ohne weitere Korrekturen

2026-04-01 – Feature: EÜR-Zeile in Konten-Übersicht anzeigen

Neu

  • Spalte „EÜR-Zeile" in der Konten-Liste (/accounts) ergänzt
  • Zeigt den Wert euer_zeile je Konto an (z.B. 14, 26, 51, 52 oder „–" wenn leer)

2026-04-01 – Feature: Bewirtungsbelege (Konten 4650/4651) mit automatischem Split

Neu

  • Buchungen auf Konto 4650 (Bewirtungskosten VSt. 19%) oder 4651 (Bewirtungskosten VSt. 7%) werden automatisch aufgeteilt: 70 % auf das gewählte Konto, 30 % auf das Abzugskonto (4654 bzw. 4656)
  • Neue Felder bewirtungsgrund und teilnehmerliste in der bookings-Tabelle (Migration 016)
  • Im Buchungsformular erscheinen die beiden Felder automatisch, wenn 4650/4651 als Kategorie gewählt wird
  • Vorhandene Buchungen zeigen Bewirtungsgrund und Teilnehmerliste in der Buchungsübersicht an

2026-04-01 – Bugfix: Nicht abzugsfähige Kosten (4654/4656) separat in EÜR unter KZ 165

Behoben

  • Konten mit euer_zeile = '51' (4654, 4656, 4635, 4636, 4289) fließen nicht mehr in total_ausgaben und gewinn ein
  • Sie erscheinen in einem eigenen Abschnitt „Nicht abzugsfähige Betriebsausgaben (KZ 165)" in Web und PDF
  • Migration 015 setzt euer_zeile = '51' für Konto 4656 (fehlte bisher)

2026-03-30 – Bugfix: 30 % Eigenanteil fließt jetzt korrekt in UStVA KZ 81/83

Behoben

  • Booking::upsertEigenanteil30: statt zwei Buchungen (8922 Netto + 1776 USt-Korrektur) wird nur noch eine einzige Buchung auf 8922 mit echtem ust_satz und betrag_ust erzeugt
  • upsertKorrekturbuchung: neue Parameter ustSatz und betragUst; UPDATE und INSERT setzen jetzt betrag_ust und betrag_brutto korrekt
  • Vorher: betrag_ust = 0 auf 8922 → KZ 83 blieb leer; 1776-Buchung hatte keine UStVA-Wirkung

2026-03-30 – Kontenrahmen-Reset: DATEV SKR03 2026 (IST-Versteuerung)

Geändert

  • Alle Buchungen und Belege gelöscht (Testdaten)
  • Gesamter Kontenrahmen durch DATEV SKR03 2026 (gültig ab 2026-01-01) ersetzt
  • Neue Spalte euer_zeile in Tabelle accounts: Zeilennummer der Anlage EÜR
  • 145 Konten neu angelegt: Klassen 0, 1, 3, 4, 8 (ohne Klasse 9 / ohne Verbindlichkeiten)
  • Verfahren: IST-Versteuerung; Konto 1390 „Verrechnungskonto Ist-Versteuerung" enthalten
  • UStVA-Kennzeichen korrekt nach DATEV: 81/86 (Umsätze), 61/62 (Vorsteuer), 35/41 (steuerfrei)
  • Migration: 013_reset_kontenrahmen_skr03_2026.sql

2026-03-30 – KI-gestützte Belegerkennung (experimentell) + 30 % Eigenanteil Telefon/Internet

Neu

  • Eigenanteil von 30 % bei Buchungen auf Telefon- und Internetkosten-Konten (4920/4925) wählbar
  • Experimentelle KI-Beleganalyse: erkennt Buchungstyp, Betrag, Konto und Lieferant aus PDF/Bild-Belegen
    • Setzt lokales LLM (z. B. Ollama/Mistral) oder Remote-API-Schlüssel voraus
    • Hoher Ressourcenbedarf bei lokaler Ausführung; Ergebnisqualität mit Mistral bisher mäßig
    • Getestet mit ollama run mistral

Betrifft

  • src/Services/KontierungsvorschlagService.php
  • src/Services/LlmService.php
  • src/Views/belege/detail.php, list.php
  • Dockerfile, docker-compose.yml

2026-03-30 – Fix: Lieferant-Eingabe in Metadaten-Leiste speichert korrekt

Geändert

  • supplier-persistent-input: Nach erfolgreichem AJAX-Speichern wird das Texteingabefeld durch den Inline-Trigger-Span ersetzt – visuelle Bestätigung, dass der Lieferant gespeichert wurde
  • Fehlerbehandlung (try/catch) hinzugefügt: bei fehlgeschlagenem Speichern bleibt das Feld editierbar
  • Das versteckte Metaformular (supplier_name_input) wird nach AJAX-Speichern synchronisiert, damit ein späteres Absenden des Metaformulars den Lieferanten nicht überschreibt

Betrifft

  • src/Views/belege/detail.php

2026-03-30 – Lieferant inline editierbar + KI-Lieferantenerkennung

Geändert

  • Lieferant in der Beleg-Info-Bar ist jetzt direkt inline editierbar (Klick auf ✎), wie Belegdatum und Rechnungsnr.
  • KI-Kontierungsvorschlag extrahiert nun auch den Lieferantennamen aus dem Beleg
  • Nach KI-Analyse: bekannter Lieferant wird automatisch eingetragen; unbekannter Lieferant öffnet das Lieferantenfeld sofort mit Fokus
  • KI-Badge zeigt Lieferantenname an, unbekannte Lieferanten werden als „(neu – bitte prüfen)" markiert
  • Validierung in KontierungsvorschlagService prüft zahlungskonto_id und kategorie_konto_id jetzt gegen die korrekte Kontenart statt gegen alle Konten

Betrifft

  • src/Services/KontierungsvorschlagService.php
  • src/Views/belege/detail.php
  • public/static/js/app.js

2026-03-29 – EÜR: Spaltenauswahl nach Kontenart statt Buchungstyp

Geändert

  • EuerCalculator::calculate() wertet accounts.kontenart aus statt buchungstyp
  • Betriebseinnahme → Einnahmen-Spalte, Betriebsausgabe → Ausgaben-Spalte
  • Privat, Zahlungskonto, Verbindlichkeit als Kategorie-Konto werden nicht in der EÜR ausgewiesen
  • Vorzeichen von betrag_netto werden beibehalten (kein abs() mehr) – negative Beträge mindern die jeweilige Spalte
  • Buchungen desselben Kontos werden unabhängig vom buchungstyp zu einem Eintrag zusammengefasst

Betrifft

  • src/Services/EuerCalculator.php
  • BERICHTE.md (EÜR-Kapitel aktualisiert)

2026-03-29 – UStVA: kontonummernbasierte Kennzahlenberechnung

Geändert

  • UstvaCalculator::calculate() wertet ausschließlich accounts.ustva_kz des Kategorie-Kontos aus – buchungstyp und steuerart werden nicht mehr benötigt
  • UstvaCalculator::fetchBookingsForKz() filtert über k.ustva_kz IN (...) statt über Buchungsfelder
  • Kz 83 und 84 kommen jetzt direkt aus betrag_ust der Erlöskonten (statt Kz81 × 19 %)
  • Kz 91 und 94 kommen direkt aus betrag_ust der ig.-Erwerb-Konten (statt abgeleitet)

Betrifft

  • src/Services/UstvaCalculator.php
  • Migration 010_ustva_kz_konto_mapping.sql
  • BERICHTE.md (UStVA-Kapitel vollständig neu)

Migration 010 – Kontenanpassungen

  • Alle Betriebsausgabe-Konten mit steuercode='VSt19': ustva_kz von '66' auf '61'
  • Alle Betriebsausgabe-Konten mit steuercode='VSt7': ustva_kz von '66' auf '62'
  • Neue Konten: 3425 „Innergemeinschaftliche Erwerbe 19 %" (ustva_kz='89'), 3420 „Innergemeinschaftliche Erwerbe 7 %" (ustva_kz='93')

2026-03-28 – 30% Eigenanteil-Kennzeichnung und Korrekturbuchungen

Hinzugefügt

  • Checkbox „30% Eigenanteil buchen" in der Buchungszeilen-Erfassung (zwischen Nettobetrag und Submit-Button)
  • Neues Feld eigenanteil_30 TINYINT(1) in der belege-Tabelle (Migration 009_beleg_eigenanteil_30.sql)
  • Der Checkbox-Wert wird beim Speichern einer Buchung in die Beleg-Metadaten übernommen
  • Bei gesetzter Checkbox werden automatisch zwei Korrekturbuchungen angelegt oder aktualisiert:
    • 30% des Nettobetrags an Konto 8922 (nicht abzugsfähiger Eigenanteil), Zahlungskonto 1800
    • 30% der errechneten USt an Konto 1776 (Vorsteuerkorrektur), Zahlungskonto 1800 – nur wenn USt-Satz > 0
  • Im Testmodus: bestehende Korrekturbuchungen werden direkt überschrieben
  • Im Produktivmodus: bestehende Korrekturbuchungen werden GoBD-konform storniert und neu angelegt

Betrifft

  • src/Views/belege/detail.php
  • src/Controllers/BookingController.php
  • src/Models/Booking.php (neue Methoden upsertEigenanteil30, upsertKorrekturbuchung)
  • Migration: 009_beleg_eigenanteil_30.sql

2026-03-27 – DATEV-konformer SKR03 Kontenrahmen (#11)

Geändert

  • Kontenrahmen: Bisherige ~80 Konten durch DATEV-konformen SKR03 ersetzt (Migration 008_skr03_vollstaendig.sql)
  • Umfang: 157 Systemkonten aus Klassen 0 (Anlagevermögen), 1 (Zahlungsmittel, Vorsteuer, USt, Privat), 2 (Eigenkapital), 4 (Betriebsausgaben), 8 (Betriebseinnahmen)
  • EÜR-Fokus: Keine Forderungs- oder Verbindlichkeitskonten (nicht bilanzierend)
  • Kompatibilität: Alte Konten bleiben mit is_system = 0 erhalten – bestehende Buchungen sind nicht betroffen

Betrifft

  • Migration: 008_skr03_vollstaendig.sql

2026-03-27 – Ausgangsrechnungen in Dashboard/EÜR/UStVA (#8)

Behoben

  • Seed-Skript: scripts/seed-testdata.php rief Invoice::finalize() auf, ohne anschließend Beleg und Buchungen anzulegen – Ausgangsrechnungen fehlten daher in allen Auswertungen
  • Ursache: Invoice::finalize() (Model) setzt nur Status und Rechnungsnummer; die Buchungserstellung liegt in InvoiceController::createBelegAndBookings() und wurde im Seed nicht aufgerufen
  • Fix: Seed-Skript legt nach Invoice::finalize() nun Beleg und zugehörige Buchungen an (analog zur Controller-Logik), sodass Ausgangsrechnungen korrekt in EÜR, UStVA und Dashboard erscheinen

Betrifft

  • Skript: scripts/seed-testdata.php
  • Test: tests/09_InvoiceToReportsTest.php (neu – End-to-End-Test Ausgangsrechnung → EÜR/UStVA/Dashboard)

2026-03-26 – Lieferantenmanagement (#7)

Neu

  • Lieferantenverwaltung: Neue Seite /suppliers zum Anlegen, Bearbeiten und Löschen von Lieferanten
  • Lieferant am Beleg: Im Metadaten-Formular der Belegdetailseite kann ein Lieferant per Dropdown/Freitexteingabe ausgewählt werden; unbekannte Namen werden beim Speichern automatisch angelegt
  • Datenbank: Tabelle suppliers (id, name) und Fremdschlüssel supplier_id in belege
  • Navigation: Neuer Menüpunkt "Lieferanten" im Bereich Belege & Buchungen

Betrifft

  • Migration: 007_suppliers.sql
  • Model: Supplier.php (neu), Beleg.php (JOIN + updateMeta)
  • Controller: SupplierController.php (neu), BelegController.php
  • Views: suppliers/list.php, suppliers/form.php (neu), belege/detail.php, layout/nav.php
  • Routes: public/index.php
  • Test: 08_LieferantTest.php

2026-03-26 – EÜR-Buchungsmodell (Soll/Haben → Einnahme/Ausgabe)

Geändert

  • Buchungsmodell: Doppelte Buchführung (Soll-Konto/Haben-Konto) durch reines EÜR-Modell ersetzt – jede Buchung hat nun buchungstyp (Einnahme/Ausgabe), kategorie_konto_id (Was?) und zahlungskonto_id (Wie bezahlt?)
  • Kontenarten: Umbenannt von Aktiv/Passiv/Aufwand/Ertrag/Neutral zu Betriebseinnahme/Betriebsausgabe/Zahlungskonto/Verbindlichkeit/Privat
  • EÜR-Berechnung: Vereinfacht – gruppiert direkt nach buchungstyp statt komplexer Soll/Haben-Ableitung
  • UStVA-Berechnung: Verwendet buchungstyp statt Kontenart-Ableitung für USt/VSt-Zuordnung
  • UI-Terminologie: Alle Bezeichnungen in Views, Journal, Dashboard und Formularen von Soll/Haben auf Typ/Kategorie/Zahlungskonto umgestellt
  • CSV-Export: Spalten an neues Modell angepasst (Typ, Kategorie-Kto., Zahlungskonto-Kto.)

Betrifft

  • Schema (001_initial_schema.sql), Seed (002_seed_skr03.sql)
  • Models: Booking.php, Account.php
  • Services: EuerCalculator.php, UstvaCalculator.php
  • Controllers: BookingController.php, AccountController.php, InvoiceController.php
  • Views: dashboard.php, journal.php, detail.php, accounts/form.php, accounts/list.php
  • JavaScript: app.js
  • Tests: 02, 04, 05 aktualisiert + neuer Test 06 (EÜR-Buchungsmodell)

2026-03-25 – Session-Authentifizierung & Benutzerverwaltung

Features

  • Session-Authentifizierung: HTTP Basic Auth durch cookie-basierte Session-Authentifizierung ersetzt; Sessions laufen nach 24 Stunden absolut ab
  • Benutzerverwaltung (/useradmin): Benutzer anlegen, bearbeiten (Benutzername und Passwort), deaktivieren und löschen; Passwörter mit bcrypt gehasht
  • Ersteinrichtungsflow: Beim ersten Start ohne Benutzer ermöglicht /login das Anlegen des ersten Administrator-Accounts
  • Abmelden-Button in der Seitenleiste

Geändert

  • AUTH_USER und AUTH_PASS entfernt – Zugangsdaten werden ausschließlich in der Datenbank verwaltet
  • Neue Datenbanktabelle users (Migration 005_users.sql)

Behoben

  • Fehler in BelegController behoben

2026-03-25 – Sicherheit, UX-Verbesserungen & Bugfixes

Sicherheit

  • Timing-sicherer Login: Passwort- und Benutzernamevergleich auf hash_equals() umgestellt (verhindert Timing-Angriffe)
  • SVG-Upload gesperrt: Logo-Upload akzeptiert nur noch PNG und JPEG – SVG wurde entfernt (XSS-Risiko)
  • CSV-Injection-Schutz: Felder im Buchungsjournal-CSV-Export werden gegen Formula-Injection (=, +, -, @) abgesichert

Features

  • Inline-Editing: Belegdatum und Rechnungsnr. können direkt in der Beleg-Detailansicht per Klick bearbeitet werden (kein Seitenreload)
  • Belegart-Filter: Neue Filteroption in der Belegliste zum Filtern nach Belegart (Eingangsrechnung, Ausgangsrechnung, Quittung etc.)
  • Favicon & App-Icon: Favicon für Browser-Tabs, Apple Touch Icon und Web-App-Manifest eingebunden

Robustheit

  • Datenbankfehler-Handling: PDO-Exceptions in Invoice-, Booking- und BelegController werden separat abgefangen und geloggt statt als generische Fehler zu erscheinen

2026-03-24 – Initiales Release

Features

  • Basis-System: MVC-Framework, Routing, Authentifizierung, Datenbank-Migrations-System
  • Ausgangsrechnungen: Erstellen, Verwalten und PDF-Export von Ausgangsrechnungen
  • EÜR-Vorbereitung: Einnahmen-Überschuss-Rechnung als PDF-Export
  • UStVA-Vorbereitung: Umsatzsteuer-Voranmeldung inkl. XML-Export für elster.de