157 KiB
157 KiB
Changelog
2026-08-24 – Fehlende Admin-Prüfung bei mehreren SettingsController-Endpoints (#200)
src/Controllers/SettingsController.php:deleteLogo(),llmTest(),bankImport(),createBankImportFormat(),updateBankImportField(),deleteBankImportFormat(),storeSuggestion()unddismissSuggestion()prüften bislang nur den generellen Login (Auth::require()im Router-Gate), nicht die Rolleadmin– anders alsupdate()/mailTest(), deren Code-Kommentar diesen Bereich bewusst als admin-pflichtig begründet. Ein eingeloggter Benutzer mit Rolleuserkonnte 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 jetztAuth::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, wasdocker compose execbraucht und was nie manuell laufen darf. Jetzt gibt es genau ein Ordnungskriterium:scripts/(Host, ausnahmslos direkt aufrufbar),scripts/container/(docker compose exec web ...) undscripts/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.shbleiben unverändert inscripts/. scripts/migrate-uploads-to-bindmount.shentfernt: beschrieb einen Umzug in ein./uploads/-Verzeichnis, das es seit dem Datenverzeichnis-Umbau nicht mehr gibt, und verwies selbst bereits auf seinen Nachfolgermigrate-datenverzeichnis.sh.scripts/test.sh,scripts/e2e.sh(neu): dünne Host-Wrapper für die beiden meistgenutzten Abläufe – delegieren andocker compose exec -u www-data web php tests/run.phpbzw.tests/e2e/run.sh, ohne eigene Logik.docker-entrypoint.sh,scripts/update.sh,scripts/restore.sh,scripts/migrate-datenverzeichnis.shsowie 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 riefView::formatDate()ohneuse Steuerkram\View;auf. Views werden perincludeeingebunden und laufen dadurch im globalen Namespace – dasusedes einbindenden Controllers gilt dort nicht, der Aufruf ging also auf\View::formatDate()und die Seite starb mitClass "View" not found. Sichtbar wurde das erst mit dem ersten Lieferanten, weil die Zeile in derforeach-Schleife steht. Import ergänzt; alle übrigen Views hatten ihn bereits.tests/99_ViewKlassenImportTest.php(neu): prüft alle Views insrc/Views/statisch – jeder unqualifizierteKlasse::-Aufruf muss entweder peruseimportiert 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 nochbutton[onclick^="showAddLineDialog"]; der Inline-Handler-Umbau aus #160 hat darausdata-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 vonphp tests/run.php --jsonhing anpython3, 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 vonphp tests/run.php --json; Detailauswertung (failed,failures) läuft überphp -rstatt Python. Lässt sich die JSON-Ausgabe nicht auswerten, bricht das Skript mit einem Fehler ab statt fälschlich Erfolg zu melden. Der zuvor ungenutzteEXIT_CODE(verworfen durch ein voranstehendes|| true) wird jetzt tatsächlich ausgewertet. Die JSON-Payloads für die automatische Issue-Erstellung werden ebenfalls perphp -rstatt 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 perini_set('display_errors', 'stderr')nach STDERR statt STDOUT.CLAUDE.md: Aufrufzeilen für./scripts/run-tests.shaufdocker compose exec -u www-data web ./scripts/run-tests.shkorrigiert – 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: neueresolvePinnedHost(string $host): string[]– wieassertPublicHost(), gibt aber zusätzlich die geprüfte(n) IP-Adresse(n) zurück, analog zuresolvePinned()für https-URLs.assertPublicHost()ruft sie jetzt intern auf.src/Services/SmtpMailer.php:connect()löste den SMTP-Host beiSsrfGuard::assertPublicHost()und beim späterenstream_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 MethodebuildConnectTarget()nutzt die vonresolvePinnedHost()gepinnte IP fürstream_socket_client(); der ursprüngliche Hostname bleibt für SNI/Zertifikatsprüfung überstream_context-Optionssl.peer_nameerhalten.src/Controllers/SettingsController.php:update()undmailTest()verlangten bislang keine Admin-Rolle – jeder eingeloggte Benutzer konntemail_smtp_host/mail_smtp_portsetzen und per „SMTP testen" sofort einen Verbindungsversuch des Servers auslösen. Beide Methoden rufen jetztAuth::requireAdmin().GET /settings(Firmendaten anzeigen) bleibt für alle eingeloggten Benutzer erreichbar.tests/98_SmtpSsrfPinningTest.php(neu):SsrfGuard::resolvePinnedHost()sowieSmtpMailer::buildConnectTarget()(per Reflection) – Verbindungsziel enthält die geprüfte IP statt des Hostnamens,peer_namebleibt gepinnt, private Hosts werden weiterhin abgelehnt.tests/e2e/specs/settings-smtp-authorization.spec.js(neu): Nicht-Admin erhält 403 aufPOST /settingsundPOST /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, diverseinvoice-*- undbooking-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 imstyle-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-cardstatt ausgeschriebener Inline-Styles je Vorkommen. invoices/show.php(126 → 3 Inline-Styles,bank/fints.phpundbelege/detail.phplagen 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 bestehendedata-modal-open/data-modal-close-Muster ausapp.jsstatt überonclick.- 53 Inline-Handler entfernt:
onclick=/onchange=/oninput=insrc/Viewsdurchid-/data-*-Attribute plus zentraleaddEventListener()-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) zentraledata-action-Dispatcher inapp.jsbzw. im jeweiligen View-Script. Verhalten unverändert, keine Funktionsänderung. tests/97_InlineStyleBudgetTest.php(neu): zähltstyle="-Vorkommen untersrc/Viewsund schlägt bei Überschreitung des Budgets (aktuell 900) fehl; prüft zusätzlich, dass keineonclick=/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.phpu. 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 (.htaccessroutet nur nicht-existierende Dateien aufindex.php) und lief damit an der Authentifizierung vorbei. NachBEISPIELE/O-CHAT.htmlverschoben (gitignored, wird nicht ausgeliefert).tests/93_WebrootSauberkeitTest.php(neu) hältpublic/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 nachpublic/static/js/fints.js. Die Verbindungsliste wird nicht mehr per PHP-Interpolation ins Script geschrieben, sondern überdata-connectionsauf 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 nachpublic/static/js/bankimport.js. Datumsformat-Optionen für die Inline-Bearbeitung wandern überdata-date-format-optionsstatt perjson_encode()direkt ins Script. - (d)
src/Request.php(neu): schlanke statische Hilfsklasse für Zugriffe auf$_POST/$_GET–post(),postInt(),postFloat()(deutsches Dezimalkomma),postDate()(liefertnullbei ungültigem Format, normalisiert aufY-m-d),get(),has(). Kein Framework, keine neue Abhängigkeit.BankControllerist als erster Controller umgestellt (180 → 126 direkte$_POST/$_GET-Zugriffe insrc/Controllers/); verbliebene Array-Zugriffe ($_GETals 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-groupper 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-formumgestellt; 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, damitbank_providers.default_fints_url/die FinTS-Server-URL nicht mehr per Migration gepflegt werden muss (siehe069_bank_providers_fints_url.sql). migrations/094_bankinstitute.sql: neue Tabellebankinstitute(blz, name, ort, bic, pin_tan_url) – reine Nachschlagedaten, kein Fremdschlüssel, bewusst kein UNIQUE aufblz(die Quelldatei führt dieselbe Bankleitzahl mehrfach, eine Zeile je Filiale). Dazubankinstitute_importals 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 überfgetcsv()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 keinehttp(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): DecktFintsInstituteImport(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) sowieModels\Bankinstitut(replaceAll,search,findAccessPointByBlz,importMeta) ab.
2026-08-18 – PayPal-Abruf: abgeschnittene NVP-Antworten im Protokoll (#185)
PayPalNvpFetchService::externalIdExists()dedupliziert bereits überexternal_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 – teils0ohne weitere Erklärung, ohne dass sichtbar wurde, dass die Antwort abgeschnitten war.src/Services/PayPalNvpFetchService.php:nvpPost()wertet jetzt zusätzlichcurl_errno()/curl_error()sowieCURLINFO_CONTENT_LENGTH_DOWNLOADaus. Neue MethodeisTruncatedTransferError()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 MethodeisLengthMismatch()erkennt zusätzlich den Fall, dass trotz HTTP 200 weniger Bytes ankamen als imContent-Length-Header angekündigt, und meldet das ebenfalls als Hinweis.tests/91_PayPalNvpAbgeschnitteneAntwortTest.php(neu): DecktisTruncatedTransferError()(bekannte cURL-Fehlercodes, Fehlermeldungstexte, normale Fehler ohne Falsch-Alarm) undisLengthMismatch()(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_portwurden ungeprüft ausSetting::get()anSmtpMailer::connect()durchgereicht, das perstream_socket_client()eine TCP-/TLS-Verbindung zu genau diesem Ziel aufbaut – anders alsfints_url(#125) undnextcloud_url(#127) ohne jede Prüfung auf private/interne Adressen. ÜberPOST /settings/mail-testkonnte ein authentifizierter Nutzer damit interne Netzwerk-/Portscans auslösen (Blind-SSRF-Oracle, CWE-918), da unterschiedlicheRuntimeException-Nachrichten offene/geschlossene/gefilterte Ports bzw. Fremddienste erkennbar machten.src/Services/SsrfGuard.php: neue MethodeassertPublicHost(string $host)– wieassertPublicHttpsUrl(), aber ohne vorausgesetzteshttps://-Schema (SMTP kennt kein URL-Schema). Prüft IP-Literale direkt, blocktlocalhostexplizit, löst Hostnamen auf und prüft alle zurückgegebenen Adressen gegenFILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE.src/Services/SmtpMailer.php:connect()ruftSsrfGuard::assertPublicHost($this->host)vor demstream_socket_client()-Aufruf auf.src/Controllers/SettingsController.php:update()prüftmail_smtp_hostdefensiv bereits beim Speichern (analog zunextcloud_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 pererror_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): DecktassertPublicHost()gegen Loopback,localhost, RFC1918-Adressen, die Link-Local/Metadata-IP169.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 invalidateConfig(),assertUrlSafe(),connect(),selectTan()(mitselectTanMode()/selectTanMedium()),login(),resolveAccounts()(mitfetchAccounts()/applyAccountFilter()),computeFetchRange()sowiefetchAllStatements()/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 zweitenassertUrlSafe()-Aufruf inrun().src/Models/Booking.php:create()(144 Zeilen) undreverse()(95 Zeilen) enthielten mehrere Schreibpfade im selben Transaktionsblock. Die tatsächlichen Pfade increate()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 ausupsertEigenanteil30(), ein unabhängiger Pfad außerhalb voncreate()) sowie die ig.-Erwerb-19-%-ReverseCharge-Kindzeilen (kein „Split“ im Sinne vonbucheBewirtungssplit()). Extrahiert nachinsertHauptbuchung(),insertVorsteuerUmsatzsteuerZeile(),insertIgErwerbReverseCharge();reverse()entsprechend nachinsertHauptstorno()/insertKindstornos(). Die Transaktionsklammer bleibt jeweils increate()/reverse().src/Controllers/BankController.php:direktStore()(111 Zeilen) aufgeteilt instoreCsvFormat(),resolveProviderId(),storeBankSource();direktUpdate()(70 Zeilen) inbuildUpdateData(),buildApiUpdateData(),buildFintsUpdateData().src/Controllers/InvoiceController.php:storeBooking()(84 Zeilen, drei identische Render-Blöcke für Validierungs-/PDO-/allgemeine Fehler) aufgeteilt invalidateBookingInput(),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 vonBooking::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: Nurbelege/_buchungsform.php(perincludeindetail.php) filterte Zahlungskonten auf den Nummernbereich 1200–1299, die beiden Rechnungs-Views nicht. Neue MethodeView::gruppiereKonten(array $accounts, bool $nurZahlungskonten1200 = false)bündelt beide Varianten; alle drei Views wenden jetzt den Filter an (Parametertrue).- 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 inbookingsmit verknüpfterinvoices), 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.phpstehen jetzt nur noch die fünf Bankkonten (1200–1240) zur Auswahl, nicht mehr Kasse, Verrechnungskonto oder die Umsatzsteuerkonten. Siehedocs/funktionen.md.
- Entscheidung für „Filter überall anwenden“ statt „nirgends“ oder „Unterschied beibehalten“:
- 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 MethodeView::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 anView::kontrastfarbe(). - Helper-Boilerplate:
$h = fn($v) => View::h($v);(36 Views) und$fmt = fn($v) => View::formatMoney($v);(8 exakte Duplikate; abweichende$fmt-Definitionen mitabs()bzw. ohne geschütztes Leerzeichen blieben unverändert) sind entfernt.BaseController::renderView()undView::render()stellen$h/$fmtjetzt per$data['h'] ??= …zentral bereit, bevorextract()läuft – Views, die die Variablen weiterhin selbst definieren, bleiben dadurch unverändert lauffähig. tests/87_ViewHelperTest.php(neu): DecktView::kontrastfarbe()(bekannte Farben, ungültige/leere Werte → Standardfarbe, Kurzform-Expansion, eigene Standardfarben) undView::gruppiereKonten()(mit/ohne Filter) ab.
2026-08-11 – belege/detail.php in Teil-Views und externe JS-Dateien aufgeteilt (#157)
src/Views/belege/detail.phpwar mit 1.548 Zeilen die mit Abstand größte Datei der Anwendung, davon 827 Zeilen JavaScript in neun<script>-Blöcken – teils mit einemDOMContentLoaded-Workaround, weilupdateAutoUst()erst in einem späteren Block definiert wurde. Reine Strukturänderung, kein Verhaltensunterschied.- Teil-Views (
src/Views/belege/, perinclude __DIR__ . '/_name.php'eingebunden, Variablen stehen durchextract()inBaseController::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 beiimage/jpeg/image/pngeingebunden)._infobar.phpund_vorschau.phpbenötigen ein eigenesuse Steuerkram\View;, dause-Deklarationen anders als Variablen nicht überincludevererbt werden. - JavaScript (
public/static/js/, mit?v=<?= ASSET_VERSION ?>eingebunden wieapp.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$kiSektionund nicht gesperrt),beleg-crop.js(Bildzuschnitt, nur bei Bild-MIME-Typ),beleg-ui.js(Lieferanten-Autospeicherung, verschiebbarer Trenner, Bildzoom).beleg-form.jswird als erstes eingebunden, da esupdateAutoUst()definiert – der bisherigeDOMContentLoaded-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-idauf#crop-modal-overlay, bereits vorhandenesdata-beleg-idauf#beleg-info-barfür die Lieferanten-Autospeicherung), Anlagevermögen-Liste (data-assetsauf#modal-anlagevermoegen, JSON via$h()HTML-escaped stattJSON_HEX_TAG), KI-Vorschlag (data-ki-aktiv/data-ki-vorschlagauf#ki-badge), Bankbuchungs-Brutto und Meta-Formular-Autoaufklappung (data-bank-tx-brutto/data-open-meta-formauf#buchung_form). showKontoArt(),setupAccountSearch()undtransferKontoNr()existierten bereits inapp.jsund wurden nicht dupliziert.tests/86_BelegDetailStrukturTest.php(neu): Prüft, dassdetail.phpunter 200 Zeilen bleibt, keine inline<script>-Blöcke mehr enthält, alle perinclude/<script src>referenzierten Dateien existieren,beleg-form.jsvorbeleg-ki.jseingebunden wird und keine der ausgelagerten JS-Dateien PHP-Interpolationsreste enthält.tests/14_UstBruttoAnzeigeTest.phpundtests/61_BetragModusToggleTest.php: lasen bisher den Inhalt vondetail.phpdirekt; geprüfte Markup- und JS-Fundstellen liegen jetzt in_buchungsform.phpbzw.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.jsvorbeleg-ki.jsreferenziert wird unddata-assetsbei 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ügbareme2e-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 inAccountController,InvoiceController,BookingController,UstvaController,BankController,BelegControllerundCustomerControllerverletzten die MVC-Schichtgrenze (CLAUDE.md: SQL ausschließlich überDatabase::innerhalb der Modelle) und waren dadurch nur über einen vollständigen HTTP-Request testbar.AssetController.phpbleibt 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 MethodendashboardKennzahlen()(offene Rechnungen, ungebuchte Belege – bislang ungetestete Dashboard-Kennzahlen),recent(int $limit),journalForAccount(int $accountId, int $year),deleteHard(int $id)sowiebucheBewirtungssplit()(ausBookingControllerverschoben, vgl. Q3 im Issue: Split-Fachlogik gehört zuBooking, dort existiert bereits der verwandte HelferupsertKorrekturbuchung()).src/Models/FiscalYear.php: Neue MethoderegisteredYears()(Geschäftsjahre ausfiscal_years, für das Konten-Journal – die bestehendeavailableYears()liest stattdessenDISTINCT YEAR()ausbookingsund passte inhaltlich nicht).src/Models/Invoice.php: Neue MethodensetBelegId(),findStorno(),findReference(),markSent(),deleteLine(),forCustomer()sowiefinalizeWithBeleg()(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 MethodenupdateBewirtung(),updateEigenanteil30(),updateBetragBrutto(),updateStorage()sowiesetSupplierId()(identisch zuupdateSupplier(), aber bewusst ohnelocked-Prüfung – wird beim automatischen Vorbelegen des Lieferanten aus einer Bankbuchung verwendet).src/Models/BankTransaction.php: Neue MethodeimportBatch()bündelt Batch-Anlage, Zeilen-Import und Transaktionsklammer (vorher offen inBankController::doImport()).src/Models/UstvaSubmission.php(neu):all(),find(),upsert()fürustva_submissions, bisher nur als Inline-SQL inUstvaController.- Alle verschobenen SQL-Statements wurden unverändert übernommen; keine Verhaltensänderung außer der bewusst parametrisierten
LIMITinBooking::recent()(vorher festLIMIT 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 aufBooking::dashboardKennzahlen(). Regressionsschutz am Ende:grep-Äquivalent, das außerhalb der dokumentiertenAssetController-Ausnahme keinenDatabase::-Aufruf mehr insrc/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 insrc/Views/belege/detail.php:419escaped Schlüssel und Wert bereits seit Fix #132 (Commit6489819) 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): Rendertbelege/detail.phpmit einem<script>-Payload informErrors['kategorie_konto_id'](wie ihnstore()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)
usershatte 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 zuadmin, damit die Benutzerverwaltung nach dem Update nicht für alle gesperrt ist.src/Models/User.php:create()akzeptiert jetzt eine optionale Rolle (Defaultuser), neue MethodesetRole();all()/find()/findByUsername()liefern das Rollen-Feld mit.src/Auth.php:login()legt die Rolle in der Session ab ($_SESSION['role']). Neue MethodenisAdmin()undrequireAdmin()(analog zurequire(), antwortet bei fehlender Admin-Rolle mit HTTP 403).src/Controllers/LoginController.php: Der bei der Ersteinrichtung angelegte erste Benutzer erhält automatisch die Rolleadmin.public/index.php: Zentraler Guard vor dem Routing – jede Anfrage unter/useradmindurchläuft zusätzlichAuth::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 Rolleadminzurückgesetzt, damit die E2E-Suite weiterhin auf/useradminzugreifen kann.tests/83_UserRoleTest.php(neu): Rollen-Defaultuser, explizite Vergabe vonadmin,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/useradminHTTP 403.
2026-08-03 – Fix: AfA-Buchungen umgehen das Booking-Modell (#155)
AssetController::bookAfa()undreverseAfaBooking()setzten ihreINSERT INTO bookings-Statements bislang direkt ab statt überBooking::create()/reverse(). Vier Folgen: Die Sperre eines abgeschlossenen Geschäftsjahres griff nicht, ein zweiter, unabhängiger Nummernkreis (AFA-NNNNN) lief nebenBU-YYYY-NNNNNNauf 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 MethodencreateAfa()undreverseAfa()bündeln die komplette Schreiblogik für AfA-Buchungen – inklusive Sperrprüfung des Geschäftsjahres, Nummernvergabe (generateAfaBuchungsNr(), eigenerAFA-NNNNN-Kreis, bewusst beibehalten statt vereinheitlicht) innerhalb der Transaktion, und – beim Storno – Übernahme derfiscal_year_idder Originalbuchung. Das Buchungsdatum des Storno bleibt das tatsächliche Stornodatum (Ist-Versteuerung).src/Controllers/AssetController.php:bookAfa()undreverseAfaBooking()rufen nur noch die Modellmethoden auf, die bisherigen Vorprüfungen bleiben erhalten.RuntimeExceptionaus dem Modell wird als HTTP 409 mit{"error": "..."}beantwortet. Kein direkterDatabase-Zugriff aufbookingsmehr im Controller.migrations/092_afa_storno_fiscal_year_korrektur.sql(neu): Korrigiert diefiscal_year_idbereits 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 diefiscal_year_idder Originalbuchung,reversed_by/is_reversalwerden gesetzt, Buchungsnummer folgt dem SchemaAFA-NNNNN.
2026-08-02 – Testsuite als echtes Gate in update.sh (#169)
- Schritt 9/9 von
scripts/update.shgab 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-Suitetests/e2e/run.shaus und wertet beide Ergebnisse als Gate: Schlägt eine der beiden Suiten fehl, setztUPDATE_FAILED=trueund 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-testsbleibt 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, leeresdata/uploadsblieb auf Produktivsystemen zurück und lenkte bei der Suche nach Rechteproblemen unterdata/ab. Wederdocker-compose.ymlnochdocker-entrypoint.shlegen es an – aktuell wird ausschließlichdata/legacy-uploadsverwendet. scripts/migrate-datenverzeichnis.sh: Diermdir "$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.gitignoreausgenommen).data/README.mdenthielt keinen Verweis aufdata/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 nachaccounts.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 Methodensummary($jahr, $monat = null)undfetchBookingsForBucket($jahr, $monat, $gruppe). Beide leiten ihre Bedingung aus denselben Konstanten ab wiecalculate()– Zuordnungsregeln stehen damit an genau einer Stelle.src/Controllers/AccountController.php:dashboard()bezieht Umsatz, Ausgaben und Ergebnis für Jahr, Vormonat und laufenden Monat aussummary(); die sechs handgeschriebenen SQL-Abfragen entfallen. Neue ActionbucketBookings()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): Spaltelevel(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, dazuwarning(),error(),find()mit Filter nach Bereich und Schweregrad sowiechannels().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 Typerroroderwarningim Bereichmeldungen– damit ist jede dem Nutzer angezeigte Fehlermeldung erfasst, ohne sie einzeln anbinden zu müssen.BelegController::renderUploadErrors()schreibt zusätzlich im Bereichbeleg-uploadmit Dateiname,public/index.phpprotokolliert unbehandelte Ausnahmen und schwere PHP-Fehler im Bereichsystem. 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 setzenwidth:autogegen das globaleselect { width: 100% }, das sie sonst im Flex-Formular untereinander schiebt. Der automatische Neuaufbau alle 60 Sekunden behält den eingestellten Filter./ereignisse/nextcloud-importleitet 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 nocheuer_zeile='51'; seit Migration 075 ist es'74', und51ist 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 execals root, Apache/PHP dagegen alswww-data. Legte ein Test unterhalb vondata/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 dasmkdirund scheiterte inmove_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 inCLAUDE.md,README.md,docs/automatisierung.md,tests/e2e/README.md,scripts/update.shsowiescripts/run-tests.sh– letzteres wechselt selbst persetprivaufwww-data, wenn es als root im Container startet. tests/TestCase.php: Neue MethodenregisterTempPath(),makeTempDir()undtempPath(). Angemeldete Pfade entfernttearDown()rekursiv – auch nach einer Ausnahme, auch bei schreibgeschützten Verzeichnissen und inklusive der vonmkdirnebenbei 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 verstreutenunlink()/rmdir()-Aufrufe und der eigenetearDown()in Test 19 entfallen. Test 66 hinterließ zusätzlich die vontempnam()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 vonmove_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 überBeleg::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 TestLöschen einer finalisierten Rechnung gibt die Nummer wieder freiweist über zwei aufeinanderfolgende Finalisierungen nach, dass der Testlauf den Nummernkreis nicht mehr weiterschiebt. Der Hinweis auf die „nicht umkehrbare Nebenwirkung" intests/e2e/README.mdentfä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/newlieferte 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 derset_error_handlerWarnings in Exceptions um, seitdem war die Seite tot. Jetzt mit?? ''abgesichert.src/Controllers/AssetController.php,src/Views/assets/form.php:/assets/newlieferte aus demselben Grund HTTP 500. Die View nutzt$testModeund$defaultKontoIdunterhalb des Bearbeiten-Zweigs, gesetzt wurden beide aber nur innerhalb davon.create()übergibt jetzttestMode, die View initialisiert beide Werte am Kopf.tests/e2e/(neu): 44 Playwright-Tests.smoke.spec.jsprüft, dass alle Hauptseiten ohne Serverfehler rendern (dieser Lauf fand die beiden 500er), dazu 404-Verhalten, Login-Zwang und CSV-Export.beleg-buchung.spec.jsdeckt 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.jsprüft Entwurf → Finalisieren → PDF- und XML-Export.bank-import.spec.jssichert 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, mitE2E-TESTmarkierte 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, dassBeleg::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 überdocker compose execals root läuft, blieb ein root-eigenes Verzeichnis zurück. Bekam später ein echter Upload dieselbe Belegnummer, scheitertemove_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.shmit 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 mitmv uploads/2026 data/belege/verschoben. Existierte im Ziel bereits ein Jahresverzeichnis – etwa weil vorher schon ein Container gelaufen war –, scheitertemv(bzw. legte den Baum alsdata/belege/2026/2026ab). Weil alle Fehler mit2>/dev/null || trueunterdrü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 inuploads/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. Dascripts/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
phpdirekt auf, sonst überdocker 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 eigenenCURLOPT_RESOLVE-Eintrag zurück. libcurl behandelt mehrere Einträge für dieselbeHOST: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 MethodedescribeTransfer()).tests/74_SsrfPinningDualStackTest.php(neu): Stellt sicher, dassresolvePinned()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 umTeilzahlungundÜberzahlt, ergänzt die Spaltesent_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 MethodenrecalcPaymentStatus()(Statusableitung inkl. Vorzeichennormalisierung für Stornorechnungen mit negativen Beträgen) undrecalcPaymentStatusForBeleg(). Entwürfe und stornierte Rechnungen werden nie überschrieben.src/Models/Booking.php:create()undreverse()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-orangeneu für Teilzahlung,badge-purplefü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>.xmlbzw.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 inbuildDocument();attachToPdf()bettet das Ergebnis wie bisher in die PDF ein, neu liefertcreateXml()dasselbe Dokument als XML-String. Alle Feldzugriffe sind gegen fehlende Schlüssel undnullabgesichert, 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 Actionxml()und RouteGET /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 alsHURausgewiesen 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.
ZugferdServicesetzt jetzt Ansprechpartner (BT-41, ausinhabermit Rückfall auffirma), 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 nichtc.ust_id– undinvoiceshat keine eigene Spalte dafür. Der ZweigaddDocumentBuyerVATRegistrationNumber()inZugferdServicelief 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()liefertc.ust_idjetzt 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/uploadsverschoben – inklusive des Unterordnerslogo/. Angepasst wurden damals aber nur Beleg-, Bank- und Invoice-Code;Setting,SettingsControllerundPdfInvoiceServicezeigten weiterhin aufpublic/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/mariadbwird nicht mehr in denweb-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_dirzeigt aufdata/belege, neuer Schlüssellogo_diraufdata/logo.src/Models/Setting.php: Neue MethodelogoAbsolutePath()löst den DB-Wert gegenlogo_dirauf (toleriert Altpfade mitstatic/uploads/-Präfix);deleteLogo()nutzt sie statt des Web-Root-Pfads.setLogo()speichert nur noch den Dateinamen.src/Controllers/SettingsController.php:uploadLogo()schreibt nachdata/logound räumt beim Formatwechsel (png ↔ jpg) die Altdatei ab; neue MethodeserveLogo()liefert das Logo authentifiziert über/settings/logo/fileaus (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 historischenstatic/uploads/-Präfixe ausbelege.storage_pathundcompany_settings.logo_path; idempotent.scripts/migrate-datenverzeichnis.sh(neu): Verschiebt vorhandene Daten aus./uploadsnach./data/belegebzw../data/logo. Der Umzug läuft in einem kurzlebigen Container als root, da die Dateienwww-datagehören – kein sudo nötig, idempotent.docker-compose.yml,Dockerfile,docker-entrypoint.sh,.gitignore: Volumes, Verzeichnisanlage und Rechte auf die neue Struktur umgestellt;.gitignoreschließtdata/*aus (Ausnahme:data/README.md), sodassgit cleandie Nutzdaten nicht mehr treffen kann.- Kein Breaking Change bei bestehenden Installationen: Der Entrypoint übernimmt vorhandene Dateien beim Container-Start automatisch nach
data/belegebzw.data/logo– sowohl aus der gemeinsamenuploads-Ablage (dafür bleibt./uploadsübergangsweise alsdata/legacy-uploadsgemountet) als auch ein Logo aus dem alten Web-Root. Ein reinesscripts/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.shsicherte weiterhinpublic/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 jetztdata/belegeunddata/logound 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 alteuploads/-Archivformat, sodass ältere Datei-Backups einspielbar bleiben.scripts/update.sh: Neuer Schritt 2 aktualisiertscripts/pergit pullund 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 überSTEUERKRAM_BEFORE_COMMITan den Neustart weitergereicht, damit die Rebuild-Erkennung die Änderungen weiterhin sieht. Der frühere, engere Selbst-Neustart (nur bei Änderung anupdate.shselbst, 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/uploadssowie 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 mitConfig::upload_dir/logo_dirzusammen 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
/settingsstatt 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-formschloss. Folge: Der Upload lief nie, stattdessen wurdeSetting::update()mit unvollständigen POST-Daten aufgerufen. Aus demselben Grund war auch der Logo-Löschen-Button (form="delete-logo-form") wirkungslos. Setting::update()lasmail_subject_template/mail_body_templateweiterhin 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 denLocation-Header inView::redirect()(„headers already sent").src/Views/settings/form.php:#logo-upload-formund#delete-logo-formstehen jetzt als eigenständige, leere Formulare außerhalb von#settings-form; Datei-Input und Buttons sind perform="…"-Attribut daran gebunden.src/Models/Setting.php: Alle optionalen Felder werden konsistent über($data['x'] ?? '') ?: defaultgelesen, sodass fehlende POST-Keys keine Warnings mehr auslösen.tests/70_SettingsLogoUploadTest.php: Neuer Test prüft, dassSetting::update()ohne optionale Keys warnungsfrei durchläuft, dasssetLogo()/deleteLogo()den Pfad korrekt schreiben und dass in#settings-formkein verschachteltes<form>mehr steht.
2026-07-30 – Fix: SMTP-Header-/Kommando-Injection über mail_from_email (Issue #148)
SmtpMailer::sendMessage()/buildMessage()verwendetenfromEmailroh sowohl imMAIL FROM:<...>-SMTP-Kommando als auch imFrom:-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 verdeckteRCPT TO-Zeile) einschleusen oder denFrom:-Header manipulieren (CWE-93).src/Models/Setting.php:Setting::update()validiertmail_from_emailjetzt überfilter_var(..., FILTER_VALIDATE_EMAIL)(neue private MethodesanitizeFromEmail()); ungültige Werte (inkl. CRLF-Payloads) werden verworfen statt gespeichert.src/Services/SmtpMailer.php: Defense-in-Depth – neue MethodesanitizeAddress()entfernt CR/LF ausfromEmail, bevor dieser imMAIL FROM:-Kommando und imFrom:-Header verwendet wird.tests/69_MailFromEmailInjectionTest.php: Neuer Test prüft, dass ungültige/CRLF-behaftete Absenderadressen beim Speichern verworfen werden und dassSmtpMailer::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 denendisplay_errorsaktiviert ist – im offiziellen Image wirdphp.ini-productionnicht automatisch aktiviert. In Kombination mitPDO::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 (/loginist vorAuth::require()erreichbar). php/error-handling.ini(neu):display_errors = Off,display_startup_errors = Off,log_errors = On,error_reporting = E_ALL & ~E_DEPRECATED.Dockerfile: Kopiertphp/error-handling.inizusätzlich zuphp/uploads.ininach/usr/local/etc/php/conf.d/.public/index.php: Registriert ganz zu Beginn (vor Autoload/Routing)set_error_handler(wandelt Warnings/Notices inErrorExceptionum),set_exception_handlerundregister_shutdown_function(fängt Fatal Errors, die nicht als Exception auftreten). Unerwartete Fehler werden pererror_logserverseitig 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 hinterlegtenmime_typeundContent-Disposition: inlineunverändert an den Browser aus. Da für Belegeapplication/xml/text/xmlals MIME-Typ erlaubt ist, konnte eine hochgeladene XML-Datei mit eingebettetem<?xml-stylesheet?>/xsl:stylesheetbeim Öffnen über den „Datei öffnen“-Link (target="_blank", keindownload-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 mitContent-Disposition: attachmentstattinlineaus, sodass der Browser die Datei herunterlädt statt sie zu navigieren/rendern; andere MIME-Typen bleiben unverändertinline.public/index.php: GlobalerX-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, dassserveFile()denContent-Disposition-Header aufattachmentstattinlinesetzt.
2026-07-27 – Fix: DOM-XSS in der CSV/XLSX-Vorschau beim Bankimport (Issue #133)
BankController::detectFormat()liefert die aus einer hochgeladenen Bank-Umsatzdatei geparsteheader_row/first_dataunverändert als JSON zurück;src/Views/bank/upload.phprendert diese Werte perinnerHTMLin eintitle="..."-Attribut. Die dortigeesc()-Funktion escapte bisher nur&,<und>, nicht aber". Ein Header-/Zellwert wieSpalte" 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") und'(zu').tests/66_BankUploadPreviewXssTest.php: Neuer Test prüft per Quelltext-Check, dassesc()Anführungszeichen escaped, und führt die extrahierte JS-Funktion (fallsnodeverfügbar ist) mit einem bösartigen Zellwert aus, um zu verifizieren, dass kein Ausbruch aus demtitle-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_errornirgendwo 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_errornicht mehr aus$_GET,$formErrorsstartet 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ütztesimplode/array_map-Pattern, inbelege/detail.phpbetrifft dies POST-basierte Fehler ausBookingController).tests/65_InvoiceBookReflectedXssTest.php: Neuer Test prüft, dass ein<script>-Payload in$_GET['_error']nicht mehr unescaped in der Ausgabe vonInvoiceController::book()erscheint.
2026-07-27 – Fix: CSV-Formel-Injection im Anlagelisten-Export (Issue #131)
EuerController::anlageCsv()schriebassets.bezeichnung(frei editierbarer Text) ungefiltert perfputcsv()in die exportierteAnlageliste-{year}.csv. Eine Bezeichnung wie=cmd|'/c calc'!A1konnte beim Öffnen der CSV in Excel/LibreOffice zu Formelausführung führen (CSV-/Formel-Injection, CWE-1236).BelegController/BookingControllerwaren dank eigener, privatercsvSanitize()-Methoden bereits abgesichert.src/Controllers/BaseController.php: Die bisher inBelegControllerundBookingControllerdupliziertecsvSanitize()-Methode wurde als gemeinsameprotectedMethode hierher verschoben (Präfix'bei führendem=,+,-oder@).src/Controllers/EuerController.php:anlageCsv()sanitisiertbezeichnungjetzt über$this->csvSanitize().src/Controllers/BelegController.php,src/Controllers/BookingController.php: Duplizierte privatecsvSanitize()-Methoden entfernt, nutzen jetzt die geerbte Methode ausBaseController.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 MethoderesolvePinned()gibt zusätzlich zur Validierung die geprüften IP-Adressen alsCURLOPT_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) dernextcloud_urleinmalig;newCurl()setztCURLOPT_RESOLVE, sodass alle cURL-Requests (PROPFIND/GET/MOVE/DELETE) den Hostnamen nicht erneut über den System-Resolver auflösen.src/Services/FinTsFetchService.php:php-fintslö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 (nachFinTs::new()) wiederholt, um das Zeitfenster für DNS-Rebinding zu minimieren.tests/63_SsrfPinningTest.php: Neuer Test prüftSsrfGuard::resolvePinned()(Einträge für Hostnamen, leeres Ergebnis + weiterhin Ablehnung für IP-Literale) sowie das Pinning inNextcloudImportService.
2026-07-27 – Fix: CSRF-Schutz für GET /bank/direkt/{id}/run (Issue #136)
- Der SSE-Endpunkt
GET /bank/direkt/{id}/runlöste einen echten Bankabruf (PayPal/FinTS) allein anhand der Session aus, ohne CSRF-Prüfung – daEventSourcenurGETunterstützt, griff die bestehende CSRF-Middleware (nur fürPOST/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, seiteneffektfreieisValidCsrfToken()(analog zucookieParams()), damit sich die Vergleichslogik isoliert testen lässt.src/Controllers/BankController.php:direktRun()validiert vor dem eigentlichen Abruf das percsrf_token-Query-Parameter übergebene Token gegen die Session.src/Views/bank/fints.php: DieEventSource-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üftAuth::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ätzlichSupplier::all()an die Viewbelege/list.src/Views/belege/list.php: Neue<datalist id="bl-supplier-datalist">mit allen vorhandenen Lieferanten; das Inline-Eingabefeld (blInlineSupplier()) verweist perlist-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- stattclick-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 drittesust_zeile-Feldnetto_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 MethodenextcloudImportData()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 ausnextcloud_import.phpin 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 persetInterval(alle 60s, ohne externe Bibliothek) ein/aus; Zustand wird perlocalStoragegemerkt. 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 RouteGET /ereignisse/nextcloud-import/data.tests/58_EreignisAutoReloadTest.php: Neuer Test prüft Button, Polling-Code, Partial-Wiederverwendung und Routen-Registrierung.
2026-07-21 – Fix: Zwei dauerhaft fehlschlagende Tests (Session-Cookie-Härtung, Nextcloud-SSRF)
- Test 54 (Session-Cookie-Härtung): Der Test rief echtes
Auth::startSession()auf. Datests/run.phpErgebnisse laufend perechoausgibt und Test 54 der einzige Test ist, der Sessions anfasst, war zum Testzeitpunkt bereits Output gesendet – PHP lehntsession_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 MethodeAuth::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 jetztAuth::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 nutztehttps://cloud.example.comals „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-Domainhttps://example.com.
2026-07-21 – Feature: Kontostand nach Direktabruf auf der Bank-Verbindungskachel
migrations/087_bank_sources_last_balance.sql: Neue Spaltelast_balanceinbank_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 dedizierterGetBalance-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 vorhandeneGetBalance-Aufruf intestCredentials()liefert den aktuellen Kontostand, der nun mitgespeichert und im Abruf-Log ausgegeben wird. Live-Konten liefern dabei offenbar durchgängig das ListenformatL_AMT0/L_CURRENCYCODE0statt der laut NVP-Doku primären FelderAMT/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, dasslast_balancebeim 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_nameaus dem per LLM erzeugten Kontierungsvorschlag (KontierungsvorschlagService) wird nurtrim()t und ungeprüft inbelege.ki_vorschlag(JSON) gespeichert._showKiBadgeDone()insrc/Views/belege/detail.phpbaute daraus String-Konkatenation und wies sie ohne Escaping überinnerHTMLzu. 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ürsupplier_nameHTML/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, umv.supplier_namevor der Einbettung ininnerHTMLHTML-zu-escapen. Andere Ausgabepfade vonsupplier_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 gespeichertenextcloud_urlweder beim Speichern noch bei der Verwendung geprüft.NextcloudImportServicebaute 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-Endpunkt169.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()validiertnextcloud_urlvor dem Speichern mit dem bereits fürfints_urlverwendetenSsrfGuard::assertPublicHttpsUrl(); ungültige Ziele werden mit Fehlermeldung abgelehnt statt in der Datenbank zu landen.src/Services/NextcloudImportService.php: Der Konstruktor prüft die geladenenextcloud_urldefensiv erneut mitSsrfGuard(Schutz gegen DNS-Rebinding und gegen Altbestände, die vor diesem Fix gespeichert wurden); bei Ablehnung bleibtbaseUrlleer undimport()bricht sofort mit Fehlermeldung ab, ohne einen Request abzusetzen.newCurl()setztCURLOPT_FOLLOWLOCATIONnun auffalse, 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, dasshttp://, private/loopback/link-local IP-Literale sowielocalhostalsnextcloud_urlvonNextcloudImportServiceabgelehnt werden (kein Request,import()meldet Fehler), während gültigehttps://-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_templatewurde beim Speichern nurtrim()t, eingebettete\r/\nblieben 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/\nausmail_subject_templatejetzt zentral über die neue MethodesanitizeSubjectTemplate(), bevor der Wert in der Datenbank landet.src/Services/SmtpMailer.php:encodeHeader()entfernt zusätzlich (Defense-in-Depth)\r/\naus 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 dassSmtpMailer::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.shprüfte den Erfolg der Test-Suite nicht über den Exit-Code vontests/run.php, sondern pergrepnach 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 warTEST_EXIT=${PIPESTATUS[0]:-0}wirkungslos, daPIPESTATUSsich nicht auf die vorangegangene Command-Substitution bezieht. scripts/update.sh: Exit-Code vondocker compose exec ... tests/run.phpwird nun direkt überif TEST_OUTPUT=$(...); then ... else TEST_EXIT=$?; fierfasst (kompatibel mitset -e) und für die Erfolgsmeldung ausgewertet, statt den Output nach Schlüsselwörtern zu durchsuchen.
2026-07-10 – Fix: Session-Cookie ohne Secure-, HttpOnly- und SameSite-Attribute (Issue #121)
- Mittel-Hoch:
session_start()wurde an acht Stellen (src/Auth.php,src/View.php,src/Controllers/LoginController.php) unkonfiguriert aufgerufen;session.cookie_httponly,session.cookie_secureundsession.cookie_samesiteliefen 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 keinerSameSite-Beschränkung (CWE-614/CWE-1004). src/Auth.php: Neue zentrale MethodeAuth::startSession()setzt vor jedemsession_start()session_set_cookie_params()mithttponly => trueundsamesite => 'Lax'.securewird anhand des Requests erkannt (HTTPS-Server-Variable bzw.X-Forwarded-Proto: httpshinter einem TLS-terminierenden Reverse-Proxy), damit der Standard-Quickstart per HTTP ohne vorgeschalteten Proxy weiter funktioniert. Über die UmgebungsvariableSESSION_COOKIE_SECURElässt sich das Verhalten explizit erzwingen (1) oder abschalten (0).src/View.php,src/Controllers/LoginController.php: Alle direktensession_start()-Aufrufe durchAuth::startSession()ersetzt, damit die Härtung an jeder Stelle greift..env.example:SESSION_COOKIE_SECUREdokumentiert.tests/54_SessionCookieHardeningTest.php: Neuer Test prüft HttpOnly/SameSite, automatische Secure-Erkennung über HTTPS bzw.X-Forwarded-Proto, sowie den expliziten Override perSESSION_COOKIE_SECURE.
2026-07-10 – Fix: Kein Schutz gegen Brute-Force am Login (Issue #122)
- Mittel:
LoginController::authenticate()prüfte Benutzername/Passwort ausschließlich perpassword_verify()– ohne Rate-Begrenzung, Lockout, Captcha oder Delay. Da/loginals einzige Route bewusst ohneAuth::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 Spaltenusers.failed_attemptsundusers.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) undnextcloud_pass(Nextcloud-App-Passwort) wurden unverschlüsselt incompany_settingsabgelegt, obwohl für vergleichbar sensible Felder (FinTS-PIN, Client-ID/Secret) bereitsCrypto::encrypt()/Crypto::decrypt()(AES-256-GCM) etabliert ist. Zusätzlich schriebsrc/Views/settings/form.phpbeide Klartext-Passwörter in dasvalue-Attribut zurück, wodurch sie für jeden Betrachter des Seitenquelltexts sichtbar waren. src/Models/Setting.php:get()entschlüsseltnextcloud_pass/mail_smtp_passnun überCrypto::decrypt()(Migrationsmodus: bestehende Klartextwerte in der DB werden unverändert durchgereicht),update()verschlüsselt beide Felder vor dem Speichern überCrypto::encrypt()– analog zum bestehenden Muster inBankSource.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 inBankController.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_passvonVARCHAR(255)aufTEXTerweitert, 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, überSetting::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_urlungefiltert übernommen und später vonFinTsFetchServicedirekt 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 Schemahttps://, 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 viaFILTER_FLAG_NO_PRIV_RANGE/FILTER_FLAG_NO_RES_RANGE); bei Verstoß wirft die Methode eineRuntimeException. src/Controllers/BankController.php:direktStore()unddirektUpdate()validierenfints_urlvor dem Speichern mitSsrfGuard; 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, dasshttp://, private/loopback/link-local IP-Literale,localhostund URLs mit Userinfo abgelehnt werden, während öffentlichehttps://-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/\ngeprüft. DaSmtpMailerdas SMTP-Protokoll über einen rohen Socket implementiert, konnte eine Adresse mit eingebettetem CRLF zusätzlicheRCPT TO:-Kommandos oder Mail-Header einschleusen (CWE-93, SMTP-/Header-Injection). src/Services/SmtpMailer.php:send()validiert jetzt jede Empfängeradresse mitfilter_var(..., FILTER_VALIDATE_EMAIL), bevor überhaupt eine SMTP-Verbindung aufgebaut wird; ungültige Adressen (inkl. CRLF-Payloads) werfen eineRuntimeException. Da alle Versandwege (EmailService::sendInvoice(),sendTestMail()) durch diese einzige Methode laufen, ist die Prüfung zentral wirksam.src/Controllers/CustomerController.php: Formularvalidierung prüftemail,email_cc1,email_cc2zusätzlich mitfilter_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 vonSmtpMailer::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 Requestindex.php/Auth::require()erreichte – ein Angreifer konnte Belege ohne Anmeldung per URL abrufen. src/Config.php:upload_dirzeigt jetzt aufdata/uploads(außerhalb des Web-Roots, analog zum bereits per Apache gesperrten/data-Verzeichnis)src/Models/Beleg.php: Neue MethodeabsolutePath()als einzige Quelle für die Pfad-Auflösung; unterstützt weiterhin Bestandsdaten mit dem alten Präfixstatic/uploads/instorage_pathsrc/Controllers/BelegController.php,InvoiceController.php,BankController.php,src/Services/NextcloudImportService.php,KontierungsvorschlagService.php: Alle Stellen, die Beleg-Dateien schreiben oder lesen, nutzen jetztConfig::get('upload_dir')bzw.Beleg::absolutePath()statt eigenerpublic/static/uploads-Pfadedocker-compose.yml,Dockerfile,docker-entrypoint.sh: Upload-Verzeichnis wird nach/var/www/html/data/uploadsgemountet/angelegt (bestehender Host-Ordner./uploadsbleibt 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ütztesunlink(); 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 Spaltenemail_cc1,email_cc2incustomerssrc/Models/Customer.php:create()undupdate()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-Mailsrc/Controllers/InvoiceController.php:show()lädt den Kunden und übergibt ihn an die Detailansichtsrc/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 Tabellesystem_events(id, channel, message, created_at)src/Models/EventLog.php: Neues Model mitadd()undfindByChannel()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 geloggtsrc/Controllers/EventController.php: Neuer Controller für/ereignisseund/ereignisse/nextcloud-importsrc/Views/ereignisse/nextcloud_import.php: Log-Ansicht mit Zeitstempel, Kanal und farbiger Meldungsrc/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 Vorschausrc/Models/Beleg.php: Neue MethodeupdateSupplier()aktualisiert nur diesupplier_idohne andere Metadaten zu verändernsrc/Controllers/BelegController.php: Neue MethodeupdateSupplier()als AJAX-Endpunktpublic/index.php: RoutePOST /belege/{id}/supplierregistriert
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 MethodegetAvailableModels()fragt/api/tagsab und liefert die Namen aller lokal verfügbaren Ollama-Modelle; neue GettergetMode(),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 ZeilenBankController::direktStore(): Behandeltprovider_id === '__csv__'gesondert – legt einenbank_sources-Eintrag mittype='csv'undprovider_id=NULLan (Standard-Spaltenzuordnung: Buchungstag=0, Wertstellung=1, Vorgang=2, Betrag=4) und leitet zur Feinabstimmung nach/settings/bankimportweiter
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 MethodetestConnection()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[]stattnullgespeichert (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:
ZugferdServicesetzt jetzt BT-72 (Liefer-/Leistungsdatum) viasetDocumentSupplyChainEvent(); das Datum wird aus dem Feldleistungszeitraumgeparst (FormatDD.MM.YYYY→ direkt; FormatMM.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:
VorlageBuchungsServiceinterpretiert JSON-Buchungsregeln und ruftBooking::create()direkt auf (keine automatischen Kindzeilen, da Konten explizit); Metafelder werden überBeleg::updateMeta()gespeichert - Pflege-UI:
/einstellungen/buchungsvorlagen– CRUD-Oberfläche für Vorlagen, Felder und Buchungsregeln; Navigationslink unter Einstellungen - Test 42:
42_BuchungsvorlageTest.phpprüft CRUD, Buchungsausführung, korrekte Konten undneq_zero-Bedingung
2026-05-24 – Fix: EÜR-Zeilennummern auf amtliche Anlage EÜR 2025 korrigiert
migrations/075_euer_zeilen_korrektur.sql: alleeuer_zeile-Werte inaccountsgemäß amtlicher Anlage EÜR 2025 neu zugeordnet (zuvor internes Schema ohne Bezug zum amtlichen Formular)EuerCalculator.php: Sonderbehandlung für nicht abziehbare BA voneuer_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; neuerPdfAnlagelisteService - 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 setztafa_booking_idauf NULL → der Pfeil-aus-Box-Button erscheint wieder - Löschen (Testmodus) löscht die AFA-Buchung direkt und setzt
afa_booking_idauf NULL - Neuer Controller:
POST /assets/{id}/depreciation/{rowId}/reverseund…/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
AfAin 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_AfaAnteiligTestprü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_rangewieder auf'last_fetched'zurück - Migration
072_bank_sources_fetch_range.sqlergänzt die neue ENUM-Spalte (Default'last_fetched', bestehende Verbindungen verhalten sich unverändert) - Logik in
BankSource::computeStartDate()zentralisiert;FinTsFetchServiceundPayPalNvpFetchServicenutzen sie. Neuer Test39_BankFetchRangeTestdeckt 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}/runhielt die Session-Datei während desawaitAccountSelection()-Polling-Loops gesperrt, der parallele POST/bank/direkt/{id}/accountsblockierte beimsession_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 Test38_BankSourceFilterFromPostTestdeckt 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-keyliefertbin2hex(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.enveintragen →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 intax/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/fintsundsettings/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 incustomers/show, Aktionsspalte incustomers/listund „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-Prefixenc:v1:) verschlüsselt PayPal-Signature und FinTS-PIN. Schlüssel ausAPP_KEYin.env– im Backup mitsichern, Verlust = Verlust aller verschlüsselten Werte - Datenmodell-Fusion: Neue Tabelle
bank_sourcesersetztbank_import_formats+bank_connectionsmittype-Spalte (csv/api/fints).bank_import_batches.source_idlöstformat_id/connection_idab (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 seitlast_fetched_at(00:00) → Buchungen mit Hash-basierterexternal_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/bankimporterscheint 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-formatinBankController; neue MethodenpeekRows()undisFormatMatch()inBankImportParser
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) durchfinfo_file(finfo_open(FILEINFO_MIME_TYPE), ...)inBelegController,SettingsController,BankControllerundImageCropServiceersetzt- XSS in
topbarActions:View::buildQuery()(neuer Helper) statt rohemhttp_build_query($_GET) @-Unterdrückung inSettingsControllerentfernt (ersetzt durchfile_exists()-Prüfung)- BaseController: gemeinsame
renderView()-Methode zentralisiert; alle 13 Controller erben davon; lokale Duplikate entfernt - DB-Transaktionen:
Booking::create()undBooking::reverse()laufen jetzt in atomaren Transaktionen;Database::beginTransaction/commit/rollBackunterstützt verschachtelte Aufrufe via Tiefenzähler (TestCase-Isolation bleibt intakt) src/Constants.php: Buchungstypen und Steuerkontonummern (SKR03) als benannte Konstanten; Magic Strings inBooking.phpersetzt- 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) und33_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_identfernt; Status-ENUM umteilweiseerweitert; 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_transactionsentfernt (UI-Prüfung ist die primäre Schutzschicht) BankTransaction::findDuplicates(): neue Methode mit Batch-Abfrage gegen die DBBankController::doImport(): gemeinsame Import-Logik extrahiert (wird vonupload()unduploadConfirm()genutzt)
2026-05-15 – Fix: Bank-Import-Duplikate verhindern
- Migration 051: UNIQUE-Constraint
(buchungstag, wertstellung, wert, buchungstext(200))aufbank_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 hinterlassenBankController::upload(): Dateiname-Prüfung vor dem Import – erneuter Upload derselben Datei wird mit Fehlermeldung abgelehntBankTransaction::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
updateAutoUstin einenDOMContentLoaded-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 alsust81 + ust89 − Kz66 − Kz61berechnet (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. ErwerbBooking::reverse(): Hauptstornobuchung überträgt jetztsteuerartder 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 mitfloor()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 % undsteuerart=normalwird analog zur VSt-Kindzeile eine USt-Zeile auf 1771/1776 angelegtUstvaCalculator: Kz 83/84 jetzt kontobasiert via 1771/1776 (betrag_netto), nicht mehr viabetrag_ustder Erlöskonten;KZ_ACCOUNT_MAPaktualisiertInvoice::cancel(): Nur Hauptbuchungen (parent_booking_id IS NULL) werden direkt storniert; Kindzeilen werden vonBooking::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 Spaltestorno_of_idininvoices(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 (viahorstoeko/zugferd)src/Services/PdfInvoiceService.php: PDF wird nach der mPDF-Generierung durchZugferdService::attachToPdf()geleitet; Ausgabe erfolgt jetzt manuell per Header/echo stattmpdf->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 FormatPPPYYYYMM-nn(Prefix + Jahr + Monat + zweistellige Sequenz, die bei Bedarf wächst); Zähler wird monatlich zurückgesetztmigrations/038_invoice_nr_month.sql: Neue Spalteinvoice_nr_monthincompany_settings
2026-04-26 – Fix: Eigenanteil-Buchung als Einnahme statt Ausgabe
Geändert
src/Models/Booking.php:upsertKorrekturbuchung()setztbuchungstypjetzt korrekt aufEinnahme(war fälschlichAusgabe)
2026-04-26 – Feature #57: Lineare Abschreibungstabelle für Anlagegüter
Hinzugefügt
migrations/035_assets_abschreibung_flags.sql: Neue Spaltenautomatisch_berechnetundcent_im_ersten_jahrinassetsmigrations/036_asset_depreciation_schedule.sql: Neue Tabelleasset_depreciation_schedule(Jahr, Abschreibungsbetrag, Restwert)src/Models/AssetDepreciation.php: Berechnung (linear, Sofortabschreibung, Cent-Option), Persistenztests/26_AbschreibungstabelleTest.php: 22 Tests für alle AfA-Szenarien
Geändert
src/Models/Asset.php:create()undupdate()speichernautomatisch_berechnetundcent_im_ersten_jahrsrc/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 Tabellepublic/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 MethodeisReachable()für Diagnosezwecke; Fehlerlog incallOllama()bei cURL-Fehler oder leerer Antwortsrc/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 aufOLLAMA_HOST=0.0.0.0:11434
2026-04-26 – Leistungszeitraum als freies Textfeld
Geändert
migrations/034_leistungszeitraum_text.sql: Spaltenleistungszeitraum_von(DATE) undleistungszeitraum_bis(DATE) wurden durch eine einzige Textspalteleistungszeitraum(VARCHAR 255) ersetzt; bestehende Datumswerte werden automatisch migriertsrc/Views/invoices/form.php: Zwei Datumsfelder durch ein Freitextfeld „Leistungszeitraum" ersetzt, Grid von 4 auf 3 Spalten angepasstsrc/Models/Invoice.php: INSERT/UPDATE verwenden jetztleistungszeitraumstattleistungszeitraum_von/leistungszeitraum_bissrc/Controllers/InvoiceController.php: Datums-Defaults fürleistungszeitraum_von/leistungszeitraum_bisentferntsrc/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_nrleer ist migrations/032_anlage_nr_backfill.sql: Anlage-Nummern für bestehende Anlagegüter ohneanlage_nrwerden 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 Tabelleassetsfür Wirtschaftsgütersrc/Models/Asset.php: CRUD-Model mit automatischer laufender Nummer und Geschäftsjahrzuordnungsrc/Controllers/AssetController.php: Liste, Anlegen, Bearbeiten, Löschensrc/Views/assets/list.php: Anlagenspiegel-Tabelle mit Bleistift- und Papierkorb-Aktionensrc/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änztdocs/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) vonZahlungskontoaufAnlagevermögenkorrigiertmigrations/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ögerungenscripts/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 überaccounts.ustva_kzgeroutet, sondern direkt ausbookings.ust_satzermittelt (buchungstyp='Ausgabe',betrag_ust != 0). Damit reicht ein einziges Aufwandskonto für beide USt-Sätze – separate VSt19-/VSt7-Konten sind nicht mehr erforderlichfetchBookingsForKz: Drill-down für Kz 61/62 nutzt jetzt ebenfalls buchungsbasierte FilterungBERICHTE.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 zugeschnittenscripts/nextcloud-import.php: CLI-Einstiegspunkt für Cron-Betrieb- Migration 023: Nextcloud-Zugangsdaten (
nextcloud_url,nextcloud_user,nextcloud_pass,nextcloud_folder,nextcloud_done) incompany_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 BerechnungfetchBookingsForKz: 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_filesizeundpost_max_sizeauf 20 MB gesetzt (zuvor PHP-Standard 2 MB)Dockerfile: bindetphp/uploads.inials PHP-Konfiguration ein
2026-04-10 – Belegtext-Speicherung für spätere Volltextsuche
Neu
- Migration 021: Spalte
beleg_text MEDIUMTEXTinbelege– speichert den perpdftotextoder 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_textautomatisch 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 viapdftotextlä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.mdinREADME.mdergänzt
Geändert (Navigation)
- Kontenrahmen (
/accounts) als eingerückter Untermenüpunkt unter „Einstellungen" in der Seitenleiste - Konten-Tabelle aus
/settingsentfernt (war redundant zu/accounts)
Geändert (UStVA-Kennzahlen, Migration 017)
- KZ
61→66für alle VSt19-Konten (normale Inlands-Vorsteuer, § 15 Abs. 1 Nr. 1 UStG) - KZ
62→66für alle VSt7-Konten - KZ
35→48für 8100/8105/8110 (steuerfreie Umsätze ohne VoSt-Abzug) - KZ
35→43fü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_zeileje 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
bewirtungsgrundundteilnehmerlistein derbookings-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 intotal_ausgabenundgewinnein - 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 echtemust_satzundbetrag_usterzeugtupsertKorrekturbuchung: neue ParameterustSatzundbetragUst; UPDATE und INSERT setzen jetztbetrag_ustundbetrag_bruttokorrekt- Vorher:
betrag_ust = 0auf 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_zeilein Tabelleaccounts: 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.phpsrc/Services/LlmService.phpsrc/Views/belege/detail.php,list.phpDockerfile,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
KontierungsvorschlagServiceprüftzahlungskonto_idundkategorie_konto_idjetzt gegen die korrekte Kontenart statt gegen alle Konten
Betrifft
src/Services/KontierungsvorschlagService.phpsrc/Views/belege/detail.phppublic/static/js/app.js
2026-03-29 – EÜR: Spaltenauswahl nach Kontenart statt Buchungstyp
Geändert
EuerCalculator::calculate()wertetaccounts.kontenartaus stattbuchungstypBetriebseinnahme→ Einnahmen-Spalte,Betriebsausgabe→ Ausgaben-SpaltePrivat,Zahlungskonto,Verbindlichkeitals Kategorie-Konto werden nicht in der EÜR ausgewiesen- Vorzeichen von
betrag_nettowerden beibehalten (keinabs()mehr) – negative Beträge mindern die jeweilige Spalte - Buchungen desselben Kontos werden unabhängig vom
buchungstypzu einem Eintrag zusammengefasst
Betrifft
src/Services/EuerCalculator.phpBERICHTE.md(EÜR-Kapitel aktualisiert)
2026-03-29 – UStVA: kontonummernbasierte Kennzahlenberechnung
Geändert
UstvaCalculator::calculate()wertet ausschließlichaccounts.ustva_kzdes Kategorie-Kontos aus –buchungstypundsteuerartwerden nicht mehr benötigtUstvaCalculator::fetchBookingsForKz()filtert überk.ustva_kz IN (...)statt über Buchungsfelder- Kz 83 und 84 kommen jetzt direkt aus
betrag_ustder Erlöskonten (stattKz81 × 19 %) - Kz 91 und 94 kommen direkt aus
betrag_ustder 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_kzvon'66'auf'61' - Alle Betriebsausgabe-Konten mit
steuercode='VSt7':ustva_kzvon'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 derbelege-Tabelle (Migration009_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.phpsrc/Controllers/BookingController.phpsrc/Models/Booking.php(neue MethodenupsertEigenanteil30,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 = 0erhalten – 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.phpriefInvoice::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 inInvoiceController::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
/supplierszum 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üsselsupplier_idinbelege - 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?) undzahlungskonto_id(Wie bezahlt?) - Kontenarten: Umbenannt von Aktiv/Passiv/Aufwand/Ertrag/Neutral zu Betriebseinnahme/Betriebsausgabe/Zahlungskonto/Verbindlichkeit/Privat
- EÜR-Berechnung: Vereinfacht – gruppiert direkt nach
buchungstypstatt komplexer Soll/Haben-Ableitung - UStVA-Berechnung: Verwendet
buchungstypstatt 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
/logindas Anlegen des ersten Administrator-Accounts - Abmelden-Button in der Seitenleiste
Geändert
AUTH_USERundAUTH_PASSentfernt – Zugangsdaten werden ausschließlich in der Datenbank verwaltet- Neue Datenbanktabelle
users(Migration005_users.sql)
Behoben
- Fehler in
BelegControllerbehoben
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