diff --git a/Changes.md b/Changes.md index 027233c..a558d2b 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,105 @@ # Änderungen +## Online-Banking: Umsätze, die niemand mehr zu sehen bekam (12.08.2026) + +Drei Fehler, die zusammen dazu führten, dass abgerufene Umsätze spurlos verschwanden: +Der Abruf meldete sie als schon vorhanden, im Kontoblatt stand nichts, und in der +Vorschlagsliste tauchten sie ebenfalls nicht auf. + +**Eine gelöschte Buchung gibt ihren Umsatz wieder frei** (`Models\Buchung`, `Models\Bankumsatz`) +- Der Fremdschlüssel setzte beim Löschen zwar `bank_umsaetze.buchung_id` auf NULL, ließ den + Status aber auf *übernommen* stehen. Der Umsatz war damit unsichtbar: keine Buchung, kein + Eintrag in der Vorschlagsliste, und beim nächsten Abruf über den Fingerabdruck als schon + vorhanden übersprungen. `Buchung::loeschen()` setzt ihn jetzt vorher auf *offen* zurück. +- `Bankumsatz::verwaisteOeffnen()` fängt die Wege ab, die daran vorbeiführen: Wird ein Konto + gelöscht, räumt der Fremdschlüssel seine Buchungen per CASCADE ab – und Kaskaden lösen in + MariaDB keine Trigger aus. Der Aufruf steht vor dem Abruf und vor der Vorschlagsliste, + damit sich dieser Zustand von selbst wieder auflöst. + +**Ein Bankkonto entsteht nicht mehr doppelt** (`Models\Bankzugang`) +- Gesucht wird über die **Kontonummer**, nicht über IBAN und Kontonummer zusammen. Manche + Banken liefern die IBAN nicht bei jedem Dialog mit – die Comdirect nennt sie beim + Kontenabruf, beim Umsatzabruf aber nicht. So entstand beim zweiten Abruf ein zweiter + Eintrag fürs selbe Konto: einmal mit IBAN, einmal ohne, mit eigener Zuordnung und eigenem + Fingerabdruck. Jeder Umsatz stand daraufhin doppelt da. +- Liefert die Bank die IBAN erst später, wird sie am vorhandenen Eintrag nachgetragen. + +**Der Fingerabdruck hängt am Bankkonto, nicht an der IBAN** (`Models\Bankumsatz`) +- `Bankumsatz::externId()` nimmt die `bank_konto_id` statt IBAN oder Kontonummer. Welche + Kennung eine Bank liefert, wechselt von Dialog zu Dialog; mit ihr kippte der Fingerabdruck, + und derselbe Umsatz kam ein zweites Mal herein. Die Kennung der Zeile in `bank_konten` + steht dagegen fest, solange es den Bankzugang gibt. + +## Kontoblatt: Buchungen sammelweise löschen, Meldungen ohne Sprung (12.08.2026) + +**Mehrfachauswahl im Kontoblatt** (`Views/buchungen/kontoblatt.php`, `BuchungenController`) +- Links vor dem Datum steht je Buchung ein Ankreuzfeld, in der Titelzeile eines für alle + gerade angezeigten Buchungen. Es zeigt auch den Zwischenstand an, wenn nur ein Teil + ausgewählt ist. +- Neben *Kontenübersicht* löscht **Auswahl löschen** die angekreuzten Buchungen nach einer + Rückfrage, die die Anzahl nennt. Ohne Auswahl bleibt der Knopf gesperrt; ohne JavaScript + weist der Controller eine leere Auswahl ab. +- Die Gegenseite einer fremden Umbuchung bekommt kein Ankreuzfeld – sie gehört dem + Ursprungskonto und wird auch weiterhin nur dort gelöscht. +- Nach dem Löschen erscheint wieder dieselbe Ansicht: Filter und Seite hängen am Ziel des + Formulars und werden aus bekannten Feldern neu zusammengesetzt, nicht übernommen. + +**Statusmeldungen liegen über der Ansicht** (`Views/layout/meldungen.php`) +- Die Meldungen schieben sich von ganz oben ins Bild, statt im Inhalt Platz zu belegen. Der + Inhalt bleibt beim Ein- und Ausblenden an Ort und Stelle – vorher rückte die halbe Seite + auf und beim Verschwinden wieder zurück. +- Eine Meldung ist so breit wie ihr Text und bricht erst bei 80 % Fensterbreite um. +- Erfolgsmeldungen gehen nach fünf Sekunden von selbst, alle Meldungen lassen sich + anklicken – eine stehenbleibende Fehlermeldung verdeckte sonst die Werkzeugleiste. +- Beide Rahmen (`kopf.php`, `schlicht.php`) binden dieselbe Teilansicht ein. + +## Online-Banking: Kontenzuordnung als dritter Schritt, Umsätze buchen sich selbst (12.08.2026) + +**Einrichtung in drei Schritten** (`Views/banking/konten.php`, Migration `010_kontenzuordnung.sql`) +- Nach Zugangsdaten (1) und TAN-Verfahren (2) folgt die **Kontenzuordnung** (3): Welches + Bankkonto gehört zu welchem eigenen Konto? Die Liste kommt frisch von der Bank – das + Übernehmen des Verfahrens startet die Abfrage gleich mit, samt TAN-Maske, wenn die Bank + eine verlangt. +- Fehlt ein passendes eigenes Konto, legt *neues Konto anlegen* es an: IBAN, Kontonummer + und Bank stehen schon fest, einzutragen bleiben Name, Kontoart und Anfangssaldo. Letzterer + deckt die Zeit ab, die der Abruf nicht mehr hergibt. +- Die Zuordnung ist von der Zugangsmaske dorthin umgezogen – dort stand sie in einer + Tabelle, die nur zuordnen, aber nichts anlegen konnte. Geblieben ist eine Übersicht mit + Verweis. +- `bank_umsaetze.bank_konto_id` merkt sich, von welchem Bankkonto ein Umsatz stammt. Wird + die Zuordnung erst nach einem Abruf getroffen, ziehen die **offenen** Umsätze mit; + übernommene bleiben, wo sie sind – ihre Buchung steht bereits in einem Kontoblatt. +- Der Abruf kennt dafür einen kurzen Modus (`FinTsAbruf::starten($id, 'konten')`), der nach + der Kontenabfrage anhält. Dieselbe Zustandsmaschine, dieselbe TAN-Behandlung. +- Am Ende der Einrichtung geht es zurück in die **Übersicht der Bankverbindungen** – dort + steht die neue Verbindung fertig zwischen den anderen. Die Zugangsmaske böte nur noch an, + was gerade durchlaufen wurde. Dasselbe gilt beim vorzeitigen Verlassen über *Später + einstellen* und *Später zuordnen*; einzeln aufgerufen bleibt die Zuordnung auf ihrer Seite. + +**Abgerufene Umsätze buchen sich selbst** (`Services\Bankabgleich`) +- Neue Einstellung *Abgerufene Umsätze selbst buchen* (`banking_automatisch_buchen`, + Vorgabe an). Nach jedem Abruf – und nach dem Speichern der Kontenzuordnung – landen die + Umsätze im zugeordneten Konto: verbunden mit einer schon erfassten Buchung, wenn eine + passt, sonst als neue Buchung mit der Kategorie aus der Regel, sonst ohne. +- Der Lauf läuft **einmal am Ende**, nicht je Konto: So sieht er alle Umsätze und kann jeden + mit der Buchung verbinden, die zu ihm passt. +- Ein einzelner Umsatz bricht den Lauf nicht ab. Ein abgeschlossener Monat oder ein + doppelter Fingerabdruck betrifft nur ihn; er bleibt offen und steht weiter in der + Vorschlagsliste. Die Meldung nennt die ersten Fälle. +- Umsätze ohne zugeordnetes Konto bleiben liegen – ohne Konto gibt es kein Kontoblatt, in + das sie gehörten. +- *Alles übernehmen* in der Vorschlagsliste bleibt zurückhaltender und lässt liegen, was + keine Regel trifft: Wer den Dialog öffnet, will die Zuordnung selbst treffen. Beide Wege + teilen sich jetzt denselben Dienst. + +**Kontenübersicht: fett heißt „hier liegt Arbeit“** +- Konten mit Buchungen aus dem laufenden Jahr ohne Kategorie stehen **fett** + (`Buchung::unkategorisierteJeKonto()`), mit der Zahl im Titel. Dieselbe Zählweise wie im + Cockpit: keine Kategorie, kein Gegenkonto, nicht gesplittet – eine Umbuchung oder eine + Splitbuchung ist zugeordnet. +- Das ist die Gegenprobe zum automatischen Buchen: Die Umsätze stehen sofort im Kontoblatt, + und die Übersicht zeigt, wo die Kategorie noch fehlt. + ## Phasen 4 bis 7 – Cockpit, Online-Banking, Wertpapiere, Nebenmodule (12.08.2026) Damit ist der geplante Funktionsumfang beisammen: Von der Kontenübersicht bis zur diff --git a/README.md b/README.md index a8054af..084c54d 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,10 @@ Haushalt wird beides parallel genutzt. Gruppen. * **Cockpit** – konfigurierbare Kacheln: Vermögensentwicklung, Einnahmen/Ausgaben je Monat, Ausgaben nach Kategorie, Kontostände, offene Vorgänge. -* **Online-Banking** – FinTS-Abruf mit TAN-Dialog über mehrere Requests, TAN-Verfahren bei - der Bank abfragen und wählen, Abgleich der Umsätze gegen vorhandene Buchungen, Regeln für - die automatische Kategorisierung. +* **Online-Banking** – FinTS-Abruf mit TAN-Dialog über mehrere Requests, Einrichtung in drei + Schritten (Zugangsdaten, TAN-Verfahren, Kontenzuordnung mit Anlegen fehlender Konten), + abgerufene Umsätze buchen sich auf Wunsch selbst, Abgleich gegen vorhandene Buchungen, + Regeln für die automatische Kategorisierung. * **Wertpapiere** – Depotbestände mit Einstand und Bewertung, Depotbuchungen, Kursabruf, Veräußerungsgeschäfte und Erträge. * **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und diff --git a/docs/banking.md b/docs/banking.md index c4fb412..adc75b8 100644 --- a/docs/banking.md +++ b/docs/banking.md @@ -58,20 +58,29 @@ ausdrücklich aus. Sie wird bei jedem Request neu aus der Datenbank geholt und e fragt die Anwendung auf Knopfdruck nach (`checkDecoupledSubmission`) – steht die Freigabe noch aus, bleibt die Maske stehen. -### Einrichtung in zwei Schritten +### Einrichtung in drei Schritten Fast jede Bank bietet mehrere Verfahren an, und welche das sind, weiß nur sie. Fragen lässt sie sich aber erst, wenn Bankleitzahl, FinTS-Adresse, Benutzerkennung und PIN vorliegen – -deshalb zerfällt das Anlegen einer Bankverbindung in zwei Schritte: +deshalb zerfällt das Anlegen einer Bankverbindung in drei Schritte: 1. **Zugangsdaten** (`/banking/neu`) – *Weiter* speichert sie. 2. **TAN-Verfahren** (`/banking/{id}/verfahren`) – die Seite fragt die Bank beim Aufruf selbst und zeigt die Liste zur Wahl. +3. **Konten zuordnen** (`/banking/{id}/konten`) – welches Bankkonto gehört zu welchem + eigenen Konto? Der zweite Schritt holt die Liste **ohne Knopfdruck**: Wer hier landet, will genau das, und ein Knopf, dessen Ergebnis feststeht, wäre ein Schritt zu viel. Ein erneuter Anlauf steht trotzdem bereit, wenn sich etwas geändert hat. +Am Ende des dritten Schritts – und ebenso beim vorzeitigen Verlassen über *Später +einstellen* oder *Später zuordnen* – geht es zurück in die **Übersicht der +Bankverbindungen** (`BankingController::zielNachZuordnung()`), nicht in die Zugangsmaske: +Dort steht die neue Verbindung fertig zwischen den anderen, während die Zugangsmaske nur +noch anböte, was gerade durchlaufen wurde. Einzeln aufgerufen bleibt die Zuordnung dagegen +auf ihrer Seite stehen. + Später erreicht man dieselbe Seite über *TAN-Verfahren einstellen* am Zugang. **Abgefragt, nicht eingetippt.** Die Kennungen sind bankspezifisch – 913 heißt nicht @@ -104,12 +113,27 @@ Speichern die Wahl zurück. Abruf und Überweisung benutzen dieselbe Wahl (`Services\FinTsVerfahren::anwenden()`) – eine Überweisung soll nicht plötzlich ein anderes Verfahren verlangen als der Abruf. -### Konten +### Konten zuordnen -Beim ersten Abruf legt die Anwendung für jedes Konto, das die Bank meldet, eine Zeile in -`bank_konten` an. Trägt ein eigenes Konto dieselbe IBAN, wird es gleich zugeordnet; sonst -geschieht das im Formular des Zugangs. **Ohne Zuordnung lassen sich die Umsätze nicht -buchen** – das Konto fehlt dann schlicht. +Für jedes Konto, das die Bank meldet, legt die Anwendung eine Zeile in `bank_konten` an. +Trägt ein eigenes Konto dieselbe IBAN, wird es gleich zugeordnet; sonst geschieht das auf +der Zuordnungsseite. **Ohne Zuordnung lassen sich die Umsätze nicht buchen** – das Konto +fehlt dann schlicht. + +Die Liste kommt frisch von der Bank. Anders als die Verfahrensabfrage braucht das eine +**Anmeldung**, und die verlangt bei den meisten Banken eine TAN – deshalb steht dafür ein +Knopf und keine Automatik beim Seitenaufruf. Im Einrichtungsablauf startet das Übernehmen +des TAN-Verfahrens die Abfrage gleich mit; sie benutzt dieselbe Zustandsmaschine wie der +Umsatzabruf, hält aber nach der Kontenabfrage an (`FinTsAbruf::starten($id, 'konten')`). + +Fehlt ein passendes eigenes Konto, legt *neues Konto anlegen* es an. IBAN, Kontonummer und +Bankname kommen aus dem, was die Bank gemeldet hat; einzutragen bleiben Name, Kontoart und +Anfangssaldo – letzterer deckt die Zeit ab, die der Abruf nicht mehr hergibt. + +Wird die Zuordnung **nach** einem Abruf getroffen, ziehen die schon abgerufenen **offenen** +Umsätze mit: `bank_umsaetze.bank_konto_id` merkt sich, von welchem Bankkonto ein Umsatz +stammt. Übernommene bleiben, wo sie sind – ihre Buchung steht bereits in einem Kontoblatt, +und ein Kontowechsel würde sie dort wegziehen. Was nicht abgerufen werden soll, wird dort abgewählt; das ist der Kontenfilter. @@ -117,12 +141,36 @@ Der Saldo wird mitgeholt und landet in der Spalte *Online-Saldo* der Kontenüber Verlangt die Bank dafür eine eigene TAN, entfällt er – eine zweite TAN-Runde nur für eine Zahl wäre niemandem zu erklären. -## Abgleich statt Direktbuchung +## Vom Umsatz zur Buchung -Abgerufene Umsätze landen in `bank_umsaetze` und werden **nicht sofort gebucht**. Wer seine -Zahlungen selbst erfasst, hat sie beim Abruf meist schon im Kontoblatt; ohne Zwischenschritt -stünde alles doppelt da. +Abgerufene Umsätze landen zuerst in `bank_umsaetze`. Ob sie von dort selbst weiterwandern, +entscheidet *Abgerufene Umsätze selbst buchen* (`banking_automatisch_buchen`, Vorgabe **an**) +in den Einstellungen. +### Automatisch buchen + +Eingeschaltet läuft nach jedem Abruf – und nach dem Speichern der Kontenzuordnung – +`Services\Bankabgleich` über alle offenen Umsätze: + +* Passt eine schon erfasste Buchung, wird **verbunden** statt neu gebucht. Sonst stünde jede + selbst erfasste Zahlung doppelt im Kontoblatt. +* Sonst entsteht eine **neue Buchung** – mit der Kategorie aus der Regel, und wenn keine + greift, ohne. Genau dafür ist der Schalter da: Der Umsatz soll im Kontoblatt stehen, die + Kategorie wird dort nachgetragen. +* Umsätze **ohne zugeordnetes Konto** bleiben offen; ohne Konto gibt es kein Kontoblatt, in + das sie gehörten. + +Der Lauf läuft **einmal am Ende**, nicht je Konto: So sieht er alle Umsätze auf einmal und +kann jeden mit der Buchung verbinden, die zu ihm passt. Ein einzelner Umsatz bricht ihn +nicht ab – ein abgeschlossener Monat oder ein doppelter Fingerabdruck betrifft nur ihn, er +bleibt offen, und die Meldung nennt die ersten Fälle. + +Wo dabei noch eine Kategorie fehlt, zeigt die **Kontenübersicht**: Konten mit +unkategorisierten Buchungen aus dem laufenden Jahr stehen dort fett. + +### Der Abgleichdialog + +Abgeschaltet – oder für alles, was liegen geblieben ist – bleibt es beim Abgleich von Hand. Die Vorschlagsliste (*Umsätze abgleichen*) kennt drei Wege: | Vorschlag | Wann | Wirkung | @@ -136,11 +184,28 @@ in den Einstellungen (Vorgabe fünf Tage). Eine Buchung, die schon mit einem and verbunden ist, wird nie ein zweites Mal vorgeschlagen. Dass derselbe Umsatz bei überlappenden Abrufzeiträumen nur einmal hereinkommt, sichert -`extern_id` – ein Fingerabdruck aus IBAN, Datum, Betrag, Verwendungszweck und Empfänger. +`extern_id` – ein Fingerabdruck aus Bankkonto, Datum, Betrag, Verwendungszweck und +Empfänger. Das Bankkonto steckt als `bank_konto_id` darin, nicht als IBAN: Welche Kennung +eine Bank liefert, wechselt von Dialog zu Dialog – die Comdirect etwa nennt die IBAN beim +Kontenabruf, beim Umsatzabruf aber nicht. Hinge der Fingerabdruck daran, käme derselbe +Umsatz ein zweites Mal herein. Aus demselben Grund findet ein Bankkonto sich über seine +**Kontonummer** wieder; eine später nachgelieferte IBAN wird am vorhandenen Eintrag +nachgetragen, statt einen zweiten anzulegen. + +Verliert ein Umsatz seine Buchung – jemand löscht sie im Kontoblatt –, kehrt er in die +Vorschlagsliste zurück. Bliebe er auf *übernommen* stehen, wäre er nirgends mehr zu sehen: +nicht im Kontoblatt, nicht in der Liste, und beim nächsten Abruf als schon vorhanden +übersprungen. Verwaiste Umsätze aus gelöschten Konten fängt derselbe Mechanismus vor jedem +Abruf und vor jedem Öffnen der Liste ab. **Alle eindeutigen übernehmen** erledigt in einem Zug, was ohne Rückfrage geht: verbinden, -wo eine Buchung passt, und übernehmen, wo eine Regel greift. Umsätze ohne Regel bleiben -offen – sie sind Arbeit, die der Nutzer sehen soll. +wo eine Buchung passt, und übernehmen, wo eine Regel greift. Umsätze ohne Regel bleiben hier +offen – anders als beim automatischen Lauf. Wer den Dialog öffnet, will die Zuordnung selbst +treffen; eine Buchung ohne Kategorie wäre ihm nur ungefragt aufgehalst. + +Nichts verschwindet von allein: Ein Umsatz, der weder übernommen noch verworfen wird, bleibt +offen und steht beim nächsten Öffnen wieder da. Das Cockpit zählt die offenen unter *zum +Abgleich*. ## Regeln @@ -189,6 +254,13 @@ angehängt, und das Speichern des Zugangsformulars lässt die Wahl stehen. Die A Bank steckt hinter derselben Grenze wie der Dialog – geprüft wird, was mit ihrem Ergebnis geschieht. +`tests/19_KontenzuordnungTest.php` nimmt sich den Weg vor, den ein Umsatz nimmt, wenn die +Zuordnung erst nach dem Abruf getroffen wird: Offene Umsätze ziehen mit, übernommene nicht, +und nur das Bankkonto, um das es geht. Dazu das automatische Buchen – mit Regel, ohne Regel, +verbinden statt doppeln, Umsätze ohne Konto bleiben liegen, abgeschaltet passiert nichts – +sowie die Zählung der unkategorisierten Buchungen je Konto (Jahresgrenze, Umbuchung und +Splitbuchung zählen nicht mit) und ihr Weg in die Kontenübersicht. + Der FinTS-Dialog selbst braucht eine echte Bankverbindung und lässt sich nicht nachstellen. Geprüft wurde er gegen den Fehlerpfad: Eine unerreichbare Adresse bricht sauber ab, protokolliert und meldet verständlich. diff --git a/docs/buchungen.md b/docs/buchungen.md index 3dd4d21..416f817 100644 --- a/docs/buchungen.md +++ b/docs/buchungen.md @@ -24,6 +24,25 @@ geändert wird. Dieselbe Dreiteilung gilt für die Salden (`Buchung::saldenJeKonto()`), die alle Konten mit sechs Abfragen auf einmal berechnet statt je Konto einzeln. +## Mehrfachauswahl im Kontoblatt + +Vor dem Datum steht je Zeile ein Ankreuzfeld, in der Titelzeile eines für alle gerade +angezeigten Buchungen. *Auswahl löschen* in der Aktionsleiste schickt die Auswahl an +`POST /konten/{id}/buchungen/loeschen`. + +Zwei Punkte, die sich nicht ändern dürfen: + +* Der Knopf steht **über** der Tabelle, die Ankreuzfelder stehen **in** ihr. Verbunden sind + sie über `form="buchungsauswahl"`, nicht über Verschachtelung – ein Formular um die ganze + Tabelle würde das Filterformular umschließen. +* Zeilen mit `gegensicht = true` bekommen kein Ankreuzfeld, und der Controller prüft + zusätzlich `konto_id`. Die Gegenseite einer fremden Umbuchung wird nur auf ihrem + Ursprungskonto gelöscht – dieselbe Regel wie beim Bearbeiten. + +Filter und Seite hängen an der Abfragezeichenfolge des Formularziels. Der Controller baut +den Weg zurück aus den bekannten Feldern neu zusammen, statt ein mitgeschicktes Ziel zu +übernehmen; so lässt sich kein fremder Weiterleitungsort einschleusen. + ## Splitbuchungen Eine Buchung ist entweder einfach zugeordnet **oder** gesplittet. Sobald Splitzeilen diff --git a/docs/stammdaten.md b/docs/stammdaten.md index 0dac2dd..fbf3c66 100644 --- a/docs/stammdaten.md +++ b/docs/stammdaten.md @@ -44,6 +44,12 @@ Solange es keine Buchungen gibt (bis Phase 2), entsprechen alle drei dem Anfangs Die Werte entstehen zentral in `Konto::mitSalden()`; dort kommen in Phase 2 die Buchungen dazu, ohne dass die Aufrufer sich ändern. +**Fett gedruckt** steht ein Konto, in dem Buchungen aus dem laufenden Jahr ohne Kategorie +liegen (`Buchung::unkategorisierteJeKonto()`, Zahl im Titel des Kontonamens). Gezählt wird +wie im Cockpit: keine Kategorie, kein Gegenkonto, nicht gesplittet – eine Umbuchung und eine +Splitbuchung sind zugeordnet. Das ist vor allem die Gegenprobe zum Online-Banking, das +abgerufene Umsätze selbst bucht (siehe `docs/banking.md`). + **Anfangssalden** stehen nicht im QIF-Export. Sie werden nach dem Import je Konto im Kontoformular nachgetragen. diff --git a/migrations/010_kontenzuordnung.sql b/migrations/010_kontenzuordnung.sql new file mode 100644 index 0000000..a9c806d --- /dev/null +++ b/migrations/010_kontenzuordnung.sql @@ -0,0 +1,19 @@ +-- Kontenzuordnung als eigener Einrichtungsschritt, und abgerufene Umsätze, die +-- von selbst im Konto landen. +-- +-- Ein Umsatz kannte bisher nur das eigene Konto (`konto_id`), das beim Abruf +-- feststand. Wer die Zuordnung erst danach trifft – und genau das ist der neue +-- dritte Schritt –, hinterließe damit Umsätze, die für immer ohne Konto +-- dastehen. Mit `bank_konto_id` weiß jeder Umsatz, von welchem Bankkonto er +-- stammt; wird dessen Zuordnung gesetzt, ziehen die offenen Umsätze mit. + +ALTER TABLE bank_umsaetze + ADD COLUMN IF NOT EXISTS bank_konto_id INT UNSIGNED NULL AFTER konto_id, + ADD KEY IF NOT EXISTS idx_umsatz_bankkonto (bank_konto_id); + +-- Kein Fremdschlüssel auf bank_konten: Die Zeilen dort verschwinden nur mit dem +-- Bankzugang, und der räumt seine Umsätze ohnehin per CASCADE mit weg. + +INSERT INTO einstellungen (schluessel, wert, bereich) VALUES + ('banking_automatisch_buchen', '1', 'banking') +ON DUPLICATE KEY UPDATE schluessel = schluessel; diff --git a/public/index.php b/public/index.php index 1cad387..05a1fd5 100644 --- a/public/index.php +++ b/public/index.php @@ -80,6 +80,8 @@ $router->post('/konten/{id}/loeschen', fn(array $p) => (new KontenController( $router->get('/konten/{id}', fn(array $p) => (new BuchungenController())->kontoblatt($p)); $router->get('/konten/{id}/buchung', fn(array $p) => (new BuchungenController())->neu($p)); $router->post('/konten/{id}/buchung', fn(array $p) => (new BuchungenController())->anlegen($p)); +$router->post('/konten/{id}/buchungen/loeschen', + fn(array $p) => (new BuchungenController())->auswahlLoeschen($p)); $router->get('/buchungen/{id}', fn(array $p) => (new BuchungenController())->bearbeiten($p)); $router->post('/buchungen/{id}', fn(array $p) => (new BuchungenController())->speichern($p)); $router->post('/buchungen/{id}/loeschen', fn(array $p) => (new BuchungenController())->loeschen($p)); @@ -283,6 +285,10 @@ $router->get('/banking/{id}/verfahren', fn(array $p) => (new BankingController( $router->post('/banking/{id}/verfahren', fn(array $p) => (new BankingController())->verfahrenWaehlen($p)); $router->post('/banking/{id}/verfahren/abfragen', fn(array $p) => (new BankingController())->verfahrenAbfragen($p)); +$router->get('/banking/{id}/konten', fn(array $p) => (new BankingController())->konten($p)); +$router->post('/banking/{id}/konten', fn(array $p) => (new BankingController())->kontenSpeichern($p)); +$router->post('/banking/{id}/konten/abfragen', + fn(array $p) => (new BankingController())->kontenAbfragen($p)); $router->get('/verwaltung/import', fn() => (new ImportController())->index()); $router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen()); diff --git a/public/static/app.css b/public/static/app.css index 39ee051..8984961 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -699,6 +699,52 @@ table.liste tfoot tr.summenzeile td { table.liste tr.inaktiv td { color: var(--text-gedaempft); } +/* Spalte mit dem Ankreuzfeld der Mehrfachauswahl – so schmal wie möglich. */ +table.liste th.wahlspalte, +table.liste td.wahlspalte { + width: 1px; + padding-right: .3rem; + text-align: center; +} + +/* Die Zuordnung Bankkonto → eigenes Konto, kurz im Zugangsformular. */ +ul.kontenzuordnung { + margin: .25rem 0 .75rem; + padding-left: 1.1rem; +} + +ul.kontenzuordnung li { margin-bottom: .2rem; } + +/* Zusatzzeile einer Liste – die Felder für ein neu anzulegendes Konto in der + Kontenzuordnung. Sie hängt optisch an der Zeile darüber. */ +table.liste tr.unterzeile td { + border-top: 0; + padding-top: 0; +} + +table.liste tr[hidden] { display: none; } + +td.neues-konto { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .4rem .6rem; +} + +td.neues-konto label { + margin: 0; + font-size: .85rem; + color: var(--text-gedaempft); +} + +td.neues-konto input[type="text"] { width: auto; min-width: 12rem; } +td.neues-konto .nebentext { flex-basis: 100%; } + +/* Konten mit unkategorisierten Buchungen aus dem laufenden Jahr. Fett wie im + FinanzManager – kein Farbwechsel, sonst kollidierte er mit der Ampel der + Salden in derselben Zeile. */ +table.liste.konten tr.unkategorisiert td:first-child a { font-weight: 700; } + table.liste input[type="text"], table.liste input[type="number"] { width: 100%; @@ -1103,28 +1149,75 @@ table.splittabelle input.rechts { text-align: right; } .knopf.gefahr:hover { background: color-mix(in srgb, var(--negativ) 12%, var(--flaeche)); } +/* Gesperrt, solange nichts ausgewählt ist – sichtbar, aber ohne Aufforderung. */ +.knopf:disabled, +.knopf:disabled:hover { + opacity: .5; + cursor: default; + background: var(--flaeche); +} + .feldwert { margin: .1rem 0 .3rem; } /* ── Meldungen ───────────────────────────────────────────────────────────── */ +/* Die Meldungen liegen über der Ansicht, nicht in ihr: Sie schieben sich von + ganz oben ins Bild und wieder hinaus, ohne den Inhalt zu verschieben – sonst + rückt die halbe Seite auf und beim Ausblenden wieder zurück. */ +.meldungen { + position: fixed; + top: 0; + left: 50%; + transform: translateX(-50%); + z-index: 100; + display: flex; + flex-direction: column; + align-items: center; + gap: .4rem; + max-width: 80vw; + padding-top: .5rem; + pointer-events: none; +} + .meldung { - padding: .65rem .9rem; + padding: .55rem .9rem; border-radius: var(--radius); border: 1px solid var(--rand); - margin-bottom: 1rem; + background: var(--flaeche); + box-shadow: var(--schatten); font-size: .92rem; + /* So schmal wie die Meldung lang ist; erst an der 80-%-Grenze des + Elternelements wird umgebrochen. */ + max-width: 100%; + overflow-wrap: anywhere; + /* Anklicken blendet aus (app.js) – sonst verdeckte eine stehenbleibende + Fehlermeldung die Werkzeugleiste bis zum nächsten Seitenwechsel. */ + pointer-events: auto; + cursor: pointer; + animation: meldung-herein .3s ease-out; +} + +@keyframes meldung-herein { + from { transform: translateY(-150%); opacity: 0; } + to { transform: translateY(0); opacity: 1; } } .meldung-erfolg { background: var(--akzent-schwach); border-color: var(--akzent); } -/* Erfolgsmeldungen blenden sich nach einigen Sekunden aus (app.js). */ +/* Erfolgsmeldungen schieben sich nach einigen Sekunden wieder hinaus (app.js). */ .meldung.verblasst { opacity: 0; - transition: opacity .4s; + transform: translateY(-150%); + transition: opacity .4s, transform .4s; } .meldung-fehler { background: color-mix(in srgb, var(--negativ) 12%, var(--flaeche)); border-color: var(--negativ); } .meldung-warnung { background: color-mix(in srgb, var(--warnung) 15%, var(--flaeche)); border-color: var(--warnung); } +@media (prefers-reduced-motion: reduce) { + .meldung { animation: none; } + .meldung.verblasst { transform: none; transition: opacity .4s; } +} + .hinweis { color: var(--text-gedaempft); font-size: .93rem; } /* ── Schlichter Rahmen (Anmeldung, Fehlerseiten) ─────────────────────────── */ diff --git a/public/static/app.js b/public/static/app.js index f682b89..faf63f2 100644 --- a/public/static/app.js +++ b/public/static/app.js @@ -174,14 +174,86 @@ }); } - // Meldungen nach kurzer Zeit ausblenden – Fehler bleiben stehen. - // Über eine Klasse, nicht über element.style: Die Content-Security-Policy - // (style-src 'self') verwirft auch per JavaScript gesetzte Inline-Styles, - // die Meldung bliebe sonst einfach stehen. - document.querySelectorAll('.meldung-erfolg').forEach(function (element) { - window.setTimeout(function () { - element.classList.add('verblasst'); - window.setTimeout(function () { element.remove(); }, 400); - }, 5000); + // Kontenzuordnung einer Bankverbindung: Die Felder für ein neues Konto + // stehen in einer eigenen Tabellenzeile und werden erst sichtbar, wenn + // „neues Konto anlegen“ gewählt ist. Ausgeblendet wird hier und nicht im + // HTML – ohne JavaScript sollen die Felder da sein, sonst ließe sich kein + // Konto anlegen. + document.querySelectorAll('select[data-neues-konto]').forEach(function (auswahl) { + const zeile = document.getElementById(auswahl.dataset.neuesKonto); + if (!zeile) { + return; + } + + const anpassen = function () { + zeile.hidden = auswahl.value !== 'neu'; + }; + + auswahl.addEventListener('change', anpassen); + anpassen(); + }); + + // Mehrfachauswahl in Listen: Das Ankreuzfeld in der Titelzeile schaltet alle + // angezeigten Zeilen, der Knopf im zugehörigen Formular zählt die Auswahl + // mit und bleibt ohne Auswahl gesperrt. Ohne JavaScript funktioniert alles + // weiter – dann ist der Knopf offen und der Controller weist eine leere + // Auswahl ab. + document.querySelectorAll('.alle-waehlen').forEach(function (alle) { + const formular = document.getElementById(alle.dataset.waehlt || ''); + if (!formular) { + return; + } + + const kaestchen = Array.from( + document.querySelectorAll('input[type="checkbox"][form="' + formular.id + '"]') + ); + const knopf = formular.querySelector('button[type="submit"]'); + const beschriftung = knopf ? knopf.textContent : ''; + + const anpassen = function () { + const anzahl = kaestchen.filter(function (k) { return k.checked; }).length; + + alle.checked = anzahl > 0 && anzahl === kaestchen.length; + alle.indeterminate = anzahl > 0 && anzahl < kaestchen.length; + + if (knopf) { + knopf.disabled = anzahl === 0; + knopf.textContent = anzahl === 0 ? beschriftung : beschriftung + ' (' + anzahl + ')'; + } + // Die Rückfrage nennt die Anzahl; ihr Wortlaut steht in der Ansicht. + const vorlage = anzahl === 1 + ? formular.dataset.frageEine + : formular.dataset.frageMehrere; + if (vorlage) { + formular.dataset.bestaetigen = vorlage.replace('{anzahl}', String(anzahl)); + } + }; + + alle.addEventListener('change', function () { + kaestchen.forEach(function (k) { k.checked = alle.checked; }); + anpassen(); + }); + kaestchen.forEach(function (k) { k.addEventListener('change', anpassen); }); + anpassen(); + }); + + // Meldungen ausblenden. Über eine Klasse, nicht über element.style: Die + // Content-Security-Policy (style-src 'self') verwirft auch per JavaScript + // gesetzte Inline-Styles, die Meldung bliebe sonst einfach stehen. + const ausblenden = function (element) { + element.classList.add('verblasst'); + window.setTimeout(function () { element.remove(); }, 400); + }; + + // Erfolgsmeldungen gehen von selbst; Fehler und Warnungen bleiben stehen, + // bis sie weggeklickt werden. Sie liegen über der Ansicht – ohne diesen Weg + // verdeckten sie die Werkzeugleiste bis zum nächsten Seitenwechsel. + document.querySelectorAll('.meldung').forEach(function (element) { + element.title = 'Zum Ausblenden anklicken'; + element.addEventListener('click', function () { ausblenden(element); }); + + if (element.classList.contains('meldung-erfolg')) { + window.setTimeout(function () { ausblenden(element); }, 5000); + } }); })(); diff --git a/src/Controllers/BankingController.php b/src/Controllers/BankingController.php index 529f125..1a82349 100644 --- a/src/Controllers/BankingController.php +++ b/src/Controllers/BankingController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Finanzmanager\Controllers; use Finanzmanager\Auth; +use Finanzmanager\Database; use Finanzmanager\Models\AbgleichRegel; use Finanzmanager\Models\Bankabruf; use Finanzmanager\Models\Bankumsatz; @@ -12,6 +13,7 @@ use Finanzmanager\Models\Bankzugang; use Finanzmanager\Models\Kategorie; use Finanzmanager\Models\Klasse; use Finanzmanager\Models\Konto; +use Finanzmanager\Services\Bankabgleich; use Finanzmanager\Services\Crypto; use Finanzmanager\Services\FinTsAbruf; use Finanzmanager\Services\FinTsVerfahren; @@ -30,6 +32,9 @@ class BankingController extends BaseController /** Schlüssel der abgefragten TAN-Verfahren in der Sitzung. */ private const SITZUNG_VERFAHREN = 'banking_verfahren'; + /** Wert der Kontenauswahl, der für „neues Konto anlegen“ steht. */ + public const KONTO_NEU = 'neu'; + // ── Bankverbindungen ───────────────────────────────────────────────────── public function index(): void @@ -55,7 +60,6 @@ class BankingController extends BaseController 'titel' => 'Bankverbindung anlegen', 'zugang' => null, 'konten' => [], - 'eigene' => Konto::alle(), ]); } @@ -93,7 +97,6 @@ class BankingController extends BaseController 'titel' => $zugang['name'], 'zugang' => $zugang, 'konten' => Bankzugang::konten((int)$zugang['id']), - 'eigene' => Konto::alle(), 'abrufe' => Bankabruf::letzte(5, (int)$zugang['id']), ]); } @@ -107,7 +110,6 @@ class BankingController extends BaseController try { Bankzugang::speichern($id, $this->formulardaten()); - $this->kontenzuordnungSpeichern(); } catch (\Throwable $e) { View::redirect('/banking/' . $id, $e->getMessage(), 'fehler'); return; @@ -203,14 +205,120 @@ class BankingController extends BaseController return; } - View::redirect( - '/banking/' . $id, - $this->post('einrichtung') === '1' - ? 'Bankverbindung eingerichtet. Der erste Abruf ermittelt jetzt die Konten.' - : 'TAN-Verfahren übernommen.' + // Dritter Schritt: Die Konten holen wir gleich hier – erst mit + // gewähltem Verfahren lässt sich die Bank danach fragen, und ohne + // Zuordnung wäre jeder Umsatz eines fremden Kontos wertlos. + if ($this->post('einrichtung') === '1') { + $this->zeigeAbruf(FinTsAbruf::starten($id, 'konten', true), $id); + return; + } + + View::redirect('/banking/' . $id, 'TAN-Verfahren übernommen.'); + } + + // ── Kontenzuordnung ────────────────────────────────────────────────────── + + /** + * Der dritte Schritt der Einrichtung: Welches Bankkonto gehört zu welchem + * eigenen Konto? + * + * Ohne diese Zuordnung landet jeder abgerufene Umsatz in der + * Vorschlagsliste, ohne dass sich sagen ließe, wohin er gebucht gehört. + * Fehlt ein passendes eigenes Konto, lässt es sich hier gleich anlegen – + * mit IBAN und Bankname aus dem, was die Bank gemeldet hat. + * + * @param array $params + */ + public function konten(array $params): void + { + $this->verlangeAnmeldung(); + + $zugang = Bankzugang::find((int)$params['id']); + if ($zugang === null) { + View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler'); + return; + } + + $this->render('banking/konten.php', [ + 'titel' => 'Konten zuordnen – ' . $zugang['name'], + 'zugang' => $zugang, + 'konten' => Bankzugang::konten((int)$zugang['id']), + 'eigene' => Konto::alle(), + 'einrichtung' => $this->query('einrichtung') === '1', + ]); + } + + /** + * Die Konten (erneut) bei der Bank erfragen. Ein eigener Knopf, weil dafür + * eine Anmeldung nötig ist – und die verlangt bei den meisten Banken eine + * TAN, die niemand beim bloßen Öffnen einer Seite erwartet. + * + * @param array $params + */ + public function kontenAbfragen(array $params): void + { + $this->verlangeAnmeldung(); + + $id = (int)$params['id']; + + $this->zeigeAbruf( + FinTsAbruf::starten($id, 'konten', $this->post('einrichtung') === '1'), + $id ); } + /** @param array $params */ + public function kontenSpeichern(array $params): void + { + $this->verlangeAnmeldung(); + + $id = (int)$params['id']; + $zugang = Bankzugang::find($id); + if ($zugang === null) { + View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler'); + return; + } + + $einrichtung = $this->post('einrichtung') === '1'; + $ziel = '/banking/' . $id . '/konten' . ($einrichtung ? '?einrichtung=1' : ''); + + try { + $neue = $this->kontenzuordnungSpeichern($zugang); + } catch (\Throwable $e) { + View::redirect($ziel, $e->getMessage(), 'fehler'); + return; + } + + // Was schon abgerufen wurde, hat mit der Zuordnung eben sein Konto + // bekommen – jetzt kann es dort auch gebucht werden. + $gebucht = Bankabgleich::automatisch(); + + $meldung = $einrichtung ? 'Einrichtung abgeschlossen.' : 'Zuordnung gespeichert.'; + if ($neue > 0) { + $meldung .= sprintf(' %d Konto/Konten neu angelegt.', $neue); + } + if ($gebucht !== null && $gebucht['uebernommen'] + $gebucht['verbunden'] > 0) { + $meldung .= ' ' . Bankabgleich::bericht($gebucht); + } + + View::redirect(self::zielNachZuordnung($id, $einrichtung), $meldung); + } + + /** + * Wohin nach dem Speichern der Zuordnung? + * + * Am Ende der Einrichtung in die Übersicht der Bankverbindungen: Dort steht + * die neue Verbindung fertig zwischen den anderen. Die Zugangsmaske hätte + * nichts mehr zu zeigen, was gerade nicht schon durchlaufen wäre. + * + * Sonst zurück auf die Zuordnungsseite – wer sie einzeln aufruft, will das + * Ergebnis an derselben Stelle sehen. + */ + public static function zielNachZuordnung(int $zugangId, bool $einrichtung): string + { + return $einrichtung ? '/banking' : '/banking/' . $zugangId . '/konten'; + } + /** * Fragt die Bank und merkt sich das Ergebnis für diese Sitzung. * @@ -260,6 +368,11 @@ class BankingController extends BaseController { $this->verlangeAnmeldung(); + // Umsätze, deren Buchung hinter unserem Rücken verschwunden ist, gehören + // zurück in die Liste – sonst überspringt der Abruf sie gleich wieder + // als schon vorhanden, und sie blieben für immer unsichtbar. + Bankumsatz::verwaisteOeffnen(); + $ergebnis = FinTsAbruf::starten((int)$params['id']); $this->zeigeAbruf($ergebnis, (int)$params['id']); @@ -320,17 +433,33 @@ class BankingController extends BaseController return; } + // Nach einer TAN-Runde kennt nur das Ergebnis noch den Zugang: Der + // Aufrufer war dann `tanEinreichen()` ohne jeden Zusammenhang. + $zugangId ??= isset($ergebnis['zugang_id']) ? (int)$ergebnis['zugang_id'] : null; + + $kontenlauf = ($ergebnis['modus'] ?? 'voll') === 'konten'; + $kontenseite = $zugangId === null + ? '/banking' + : '/banking/' . $zugangId . '/konten' . (($ergebnis['einrichtung'] ?? false) ? '?einrichtung=1' : ''); + if ($ergebnis['status'] === 'fehler') { View::redirect( - $zugangId === null ? '/banking' : '/banking/' . $zugangId, + $kontenlauf ? $kontenseite : ($zugangId === null ? '/banking' : '/banking/' . $zugangId), 'Abruf fehlgeschlagen: ' . $ergebnis['meldung'], 'fehler' ); return; } + if ($kontenlauf) { + View::redirect($kontenseite, $ergebnis['meldung'] . ' Jetzt die Konten zuordnen.'); + return; + } + + // Automatisch gebuchte Umsätze stehen im Kontoblatt, nicht mehr in der + // Vorschlagsliste – dorthin lohnt der Sprung nur, wenn dort etwas liegt. View::redirect( - $ergebnis['neu'] > 0 ? '/banking/umsaetze' : '/banking', + Bankumsatz::anzahlOffen() > 0 ? '/banking/umsaetze' : '/banking', $ergebnis['meldung'] ); } @@ -341,6 +470,8 @@ class BankingController extends BaseController { $this->verlangeAnmeldung(); + Bankumsatz::verwaisteOeffnen(); + $status = $this->query('status', 'offen'); if (!array_key_exists($status, Bankumsatz::STATUS)) { $status = 'offen'; @@ -417,42 +548,21 @@ class BankingController extends BaseController View::redirect('/banking/umsaetze?status=offen', 'Umsatz steht wieder zum Abgleich bereit.'); } - /** Alles übernehmen, was einen eindeutigen Vorschlag hat. */ + /** + * Alles übernehmen, was einen eindeutigen Vorschlag hat. + * + * Ohne Regel bleibt ein Umsatz hier liegen – anders als beim automatischen + * Lauf nach dem Abruf. Wer den Dialog öffnet, will die Zuordnung selbst + * treffen; eine Buchung ohne Kategorie wäre ihm nur ungefragt aufgehalst. + */ public function alleUebernehmen(): void { $this->verlangeAnmeldung(); - $regeln = AbgleichRegel::alle(true); - $verbunden = 0; - $uebernommen = 0; - $offen = 0; - - foreach (Bankumsatz::liste(['status' => 'offen']) as $umsatz) { - $vorschlag = Bankumsatz::vorschlag($umsatz, $regeln); - - if ($vorschlag['art'] === 'verbinden' && $vorschlag['buchung'] !== null) { - Bankumsatz::verbinden((int)$umsatz['id'], (int)$vorschlag['buchung']['id']); - $verbunden++; - continue; - } - - // Ohne Regel bliebe die Buchung ohne Kategorie – das ist Arbeit, die - // der Nutzer sehen und selbst tun soll. - if ($vorschlag['zuordnung'] === null || (int)$umsatz['konto_id'] === 0) { - $offen++; - continue; - } - - Bankumsatz::uebernehmen((int)$umsatz['id'], $vorschlag['zuordnung']); - $uebernommen++; - } - - View::redirect('/banking/umsaetze', sprintf( - '%d mit vorhandenen Buchungen verbunden, %d nach Regel übernommen, %d bleiben offen.', - $verbunden, - $uebernommen, - $offen - )); + View::redirect( + '/banking/umsaetze', + Bankabgleich::bericht(Bankabgleich::laufenLassen(false)) + ); } // ── Regeln ─────────────────────────────────────────────────────────────── @@ -570,25 +680,88 @@ class BankingController extends BaseController ]; } - /** Die Zuordnung Bankkonto → eigenes Konto aus dem Formular des Zugangs. */ - private function kontenzuordnungSpeichern(): void + /** + * Die Zuordnung Bankkonto → eigenes Konto aus dem Formular. + * + * Nur Bankkonten dieses Zugangs werden angefasst: Die Kennungen kommen aus + * dem Formular, und eine fremde darf weder etwas zuordnen noch ein Konto + * anlegen. + * + * Alles in einer Transaktion: Scheitert eine Zeile – meist ein neues Konto + * ohne Namen –, soll die Seite wieder so dastehen wie vorher und nicht halb + * zugeordnet. + * + * @param array $zugang + * @return int Zahl der dabei neu angelegten eigenen Konten + */ + private function kontenzuordnungSpeichern(array $zugang): int { $zuordnung = $_POST['bankkonto'] ?? []; if (!is_array($zuordnung)) { - return; + return 0; } - foreach ($zuordnung as $bankKontoId => $werte) { - if (!is_array($werte)) { - continue; + $bekannt = []; + foreach (Bankzugang::konten((int)$zugang['id']) as $eintrag) { + $bekannt[(int)$eintrag['id']] = $eintrag; + } + + return (int)Database::transaktion(function () use ($zugang, $zuordnung, $bekannt): int { + $neue = 0; + + foreach ($zuordnung as $bankKontoId => $werte) { + $bankKontoId = (int)$bankKontoId; + if (!is_array($werte) || !isset($bekannt[$bankKontoId])) { + continue; + } + + $wahl = (string)($werte['konto_id'] ?? ''); + + if ($wahl === self::KONTO_NEU) { + $kontoId = $this->kontoAnlegen($zugang, $bekannt[$bankKontoId], $werte); + $neue++; + } else { + $kontoId = $wahl === '' ? null : (int)$wahl; + } + + Bankzugang::ordneKontoZu($bankKontoId, $kontoId, !empty($werte['abrufen'])); } - Bankzugang::ordneKontoZu( - (int)$bankKontoId, - ($werte['konto_id'] ?? '') === '' ? null : (int)$werte['konto_id'], - !empty($werte['abrufen']) + return $neue; + }); + } + + /** + * Legt das eigene Konto zu einem Bankkonto an. IBAN, Kontonummer und + * Bankname kommen aus dem, was die Bank gemeldet hat – abtippen muss der + * Nutzer nur den Namen, unter dem das Konto in seiner Liste stehen soll. + * + * @param array $zugang + * @param array $bankkonto + * @param array $werte + */ + private function kontoAnlegen(array $zugang, array $bankkonto, array $werte): int + { + $name = trim((string)($werte['neuer_name'] ?? '')); + if ($name === '') { + throw new \InvalidArgumentException( + 'Für „' . ($bankkonto['iban'] ?: $bankkonto['kontonummer']) + . '“ fehlt der Name des neuen Kontos.' ); } + + return Konto::anlegen([ + 'name' => $name, + 'kontoart' => (string)($werte['neue_kontoart'] ?? 'bank'), + 'bankname' => (string)($zugang['bankname'] !== '' ? $zugang['bankname'] : $zugang['name']), + 'iban' => (string)$bankkonto['iban'], + 'kontonummer' => (string)$bankkonto['kontonummer'], + // Was die Bank an Umsätzen liefert, reicht selten bis zur + // Kontoeröffnung zurück – der Anfangssaldo schließt die Lücke. + 'anfangssaldo' => KontenController::zahl((string)($werte['anfangssaldo'] ?? '0')), + 'aktiv' => 1, + 'in_summe' => 1, + ]); } /** diff --git a/src/Controllers/BuchungenController.php b/src/Controllers/BuchungenController.php index 23de9e4..cabf51f 100644 --- a/src/Controllers/BuchungenController.php +++ b/src/Controllers/BuchungenController.php @@ -153,6 +153,74 @@ class BuchungenController extends BaseController View::redirect('/konten/' . $buchung['konto_id'], 'Buchung gelöscht.'); } + /** + * Mehrere im Kontoblatt angekreuzte Buchungen auf einmal löschen. + * + * @param array $params + */ + public function auswahlLoeschen(array $params): void + { + $this->verlangeAnmeldung(); + + $kontoId = (int)$params['id']; + if (Konto::find($kontoId) === null) { + $this->nichtGefunden('/konten/' . $params['id']); + return; + } + + $ziel = $this->kontoblattPfad($kontoId); + $ids = $this->postIds('buchungen'); + if ($ids === []) { + View::redirect($ziel, 'Keine Buchung ausgewählt.', 'warnung'); + return; + } + + $anzahl = 0; + foreach ($ids as $id) { + $buchung = Buchung::find($id); + // Nur eigene Buchungen dieses Kontos: Die Gegenseite einer fremden + // Umbuchung gehört dem Ursprungskonto und wird auch nur dort + // gelöscht. + if ($buchung === null || (int)$buchung['konto_id'] !== $kontoId) { + continue; + } + + try { + Buchung::loeschen($id); + } catch (\Throwable $e) { + View::redirect($ziel, $e->getMessage(), 'fehler'); + return; + } + $anzahl++; + } + + View::redirect($ziel, $anzahl === 1 + ? 'Buchung gelöscht.' + : $anzahl . ' Buchungen gelöscht.'); + } + + /** + * Weg zurück ins Kontoblatt – mit demselben Filter und derselben Seite. + * + * Die Werte kommen aus der Abfragezeichenfolge des Formularziels und werden + * hier neu zusammengesetzt statt übernommen: So kann kein fremdes Ziel + * eingeschleust werden. + */ + private function kontoblattPfad(int $kontoId): string + { + $query = array_filter([ + 'suche' => $this->query('suche'), + 'von' => $this->query('von'), + 'bis' => $this->query('bis'), + 'kategorie_id' => $this->query('kategorie_id'), + 'klasse_id' => $this->query('klasse_id'), + 'nur_offene' => $this->query('nur_offene') === '1' ? '1' : '', + 'seite' => $this->query('seite'), + ], static fn(string $wert): bool => $wert !== ''); + + return '/konten/' . $kontoId . ($query === [] ? '' : '?' . http_build_query($query)); + } + /** @param array|null $konto */ private function maske(?array $konto, ?array $buchung): void { diff --git a/src/Controllers/EinstellungenController.php b/src/Controllers/EinstellungenController.php index 93c871b..a3188f0 100644 --- a/src/Controllers/EinstellungenController.php +++ b/src/Controllers/EinstellungenController.php @@ -98,9 +98,10 @@ class EinstellungenController extends BaseController 'darstellung' => ['darstellung_thema' => 'text', 'zeilen_pro_seite' => 'text'], 'buchhaltung' => ['erster_monat' => 'text'], 'banking' => [ - 'banking_regeln_beim_abruf' => 'bool', - 'banking_abgleich_tage' => 'text', - 'banking_produkt_id' => 'text', + 'banking_automatisch_buchen' => 'bool', + 'banking_regeln_beim_abruf' => 'bool', + 'banking_abgleich_tage' => 'text', + 'banking_produkt_id' => 'text', ], 'import' => ['import_qif_verzeichnis' => 'text', 'import_stichtag' => 'text'], 'wertpapiere' => [ diff --git a/src/Models/Bankumsatz.php b/src/Models/Bankumsatz.php index 09c75df..a6a925b 100644 --- a/src/Models/Bankumsatz.php +++ b/src/Models/Bankumsatz.php @@ -31,16 +31,23 @@ class Bankumsatz /** * Fingerabdruck eines Umsatzes bei der Bank. Er entscheidet, ob ein Umsatz * schon einmal abgerufen wurde – Abrufzeiträume überlappen sich fast immer. + * + * Das Konto steckt als `bank_konto_id` darin, nicht als IBAN oder + * Kontonummer: Welche Kennung eine Bank liefert, wechselt von Dialog zu + * Dialog, und mit ihr kippte der Fingerabdruck – derselbe Umsatz käme ein + * zweites Mal herein. Die Kennung der Zeile in `bank_konten` steht dagegen + * fest, solange es den Bankzugang gibt; verschwindet er, nimmt er seine + * Umsätze per CASCADE ohnehin mit. */ public static function externId( - string $iban, + int $bankKontoId, string $datum, float $betrag, string $zweck, string $empfaenger = '' ): string { return sha1(implode('|', [ - strtoupper($iban), + $bankKontoId, $datum, number_format($betrag, 2, '.', ''), trim($zweck), @@ -69,13 +76,16 @@ class Bankumsatz Database::execute( 'INSERT INTO bank_umsaetze - (benutzer_id, zugang_id, konto_id, extern_id, buchungstag, wertstellung, + (benutzer_id, zugang_id, konto_id, bank_konto_id, extern_id, buchungstag, wertstellung, betrag, waehrung, empfaenger, verwendungszweck, buchungsart, roh) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ Mandant::id(), (int)$daten['zugang_id'], ($daten['konto_id'] ?? null) ?: null, + // Woher der Umsatz stammt. Ohne das bliebe er ohne Konto stehen, + // wenn die Zuordnung erst nach dem Abruf getroffen wird. + ($daten['bank_konto_id'] ?? null) ?: null, $externId, (string)$daten['buchungstag'], ($daten['wertstellung'] ?? null) ?: null, @@ -310,6 +320,45 @@ class Bankumsatz ); } + /** + * Löst die Umsätze einer Buchung, die gleich gelöscht wird. + * + * Muss **vor** dem DELETE laufen: Danach hat der Fremdschlüssel `buchung_id` + * schon auf NULL gesetzt, und die Umsätze sind nicht mehr auffindbar. + */ + public static function loeseVonBuchung(int $buchungId): void + { + Database::execute( + "UPDATE bank_umsaetze SET status = 'offen', buchung_id = NULL + WHERE buchung_id = ? AND benutzer_id = ?", + [$buchungId, Mandant::id()] + ); + } + + /** + * Öffnet Umsätze, deren Buchung hinter unserem Rücken verschwunden ist. + * + * `Buchung::loeschen()` räumt selbst auf, aber nicht jede Buchung geht + * diesen Weg: Wird ein Konto gelöscht, räumt der Fremdschlüssel seine + * Buchungen per CASCADE ab – und Kaskaden lösen in MariaDB keine Trigger + * aus. Zurück blieben Umsätze mit Status „uebernommen“ ohne Buchung, die + * weder im Kontoblatt noch in der Vorschlagsliste auftauchen und beim + * nächsten Abruf als schon vorhanden übersprungen werden. + * + * Der Aufruf steht vor dem Abruf und vor der Vorschlagsliste, damit sich + * dieser Zustand von selbst wieder auflöst. + * + * @return int Wie viele Umsätze wieder offen sind. + */ + public static function verwaisteOeffnen(): int + { + return Database::execute( + "UPDATE bank_umsaetze SET status = 'offen' + WHERE benutzer_id = ? AND status = 'uebernommen' AND buchung_id IS NULL", + [Mandant::id()] + ); + } + public static function loeschen(int $id): void { Database::execute('DELETE FROM bank_umsaetze WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); diff --git a/src/Models/Bankzugang.php b/src/Models/Bankzugang.php index 806c2c0..4229c1a 100644 --- a/src/Models/Bankzugang.php +++ b/src/Models/Bankzugang.php @@ -258,6 +258,14 @@ class Bankzugang * Legt ein bei der Bank gefundenes Konto an oder findet es wieder. Beim * ersten Auftauchen bleibt es ohne Zuordnung – der Nutzer entscheidet, auf * welches eigene Konto die Umsätze laufen. + * + * **Die Kontonummer entscheidet, nicht die IBAN.** Manche Banken liefern die + * IBAN nicht bei jedem Dialog mit – die Comdirect etwa nennt sie beim + * Kontenabruf, beim Umsatzabruf aber nicht. Suchte man über beide Felder + * zusammen, entstünde beim zweiten Abruf ein zweiter Eintrag fürs selbe + * Konto: einmal mit IBAN, einmal ohne. Die Folge wären doppelte Umsätze, + * denn beide Einträge haben eine eigene Zuordnung und einen eigenen + * Fingerabdruck. */ public static function findeOderLegeKontoAn( int $zugangId, @@ -269,11 +277,22 @@ class Bankzugang $kontonummer = trim($kontonummer); $zeile = Database::fetchOne( - 'SELECT * FROM bank_konten WHERE zugang_id = ? AND iban = ? AND kontonummer = ?', - [$zugangId, $iban, $kontonummer] + 'SELECT * FROM bank_konten + WHERE zugang_id = ? + AND (kontonummer <> \'\' AND kontonummer = ? OR iban <> \'\' AND iban = ?) + ORDER BY iban = ? DESC, id + LIMIT 1', + [$zugangId, $kontonummer, $iban, $iban] ); if ($zeile !== false) { + // Liefert die Bank die IBAN erst jetzt, wird sie nachgetragen – + // sonst bliebe der Eintrag für immer ohne. + if ($iban !== '' && (string)$zeile['iban'] === '') { + Database::execute('UPDATE bank_konten SET iban = ? WHERE id = ?', [$iban, (int)$zeile['id']]); + $zeile['iban'] = $iban; + } + return $zeile; } @@ -300,6 +319,14 @@ class Bankzugang return Database::fetchOne('SELECT * FROM bank_konten WHERE id = ?', [Database::lastInsertId()]) ?: []; } + /** + * Ordnet ein Bankkonto einem eigenen Konto zu. + * + * Die schon abgerufenen **offenen** Umsätze ziehen mit: Wer die Zuordnung + * erst nach einem Abruf trifft, hätte sonst einen Stapel Umsätze ohne Konto, + * die sich nicht buchen lassen. Übernommene bleiben, wo sie sind – ihre + * Buchung steht bereits in einem Kontoblatt. + */ public static function ordneKontoZu(int $bankKontoId, ?int $kontoId, bool $abrufen = true): void { Database::execute( @@ -309,6 +336,13 @@ class Bankzugang WHERE bk.id = ? AND z.benutzer_id = ?', [$kontoId, $abrufen ? 1 : 0, $bankKontoId, Mandant::id()] ); + + Database::execute( + "UPDATE bank_umsaetze + SET konto_id = ? + WHERE bank_konto_id = ? AND benutzer_id = ? AND status = 'offen'", + [$kontoId, $bankKontoId, Mandant::id()] + ); } public static function merkeSaldo(int $bankKontoId, float $saldo): void diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index 1a96dc2..f810a51 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -132,6 +132,14 @@ class Buchung Monatsabschluss::verlangeOffen((string)$buchung['datum']); } + // Ein Umsatz, dessen Buchung verschwindet, gehört zurück in die + // Vorschlagsliste. Der Fremdschlüssel setzt zwar `buchung_id` auf NULL, + // lässt den Status aber auf „uebernommen“ stehen – und damit wäre der + // Umsatz unsichtbar: nicht im Kontoblatt, nicht in der Vorschlagsliste, + // und beim nächsten Abruf über den Fingerabdruck als schon vorhanden + // übersprungen. + Bankumsatz::loeseVonBuchung($id); + Database::execute('DELETE FROM buchungen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); } @@ -321,6 +329,38 @@ class Buchung ]; } + /** + * Buchungen ohne Zuordnung, je Konto gezählt – die Kontenübersicht hebt + * damit die Konten hervor, in denen noch Arbeit liegt. + * + * Unkategorisiert heißt hier dasselbe wie im Cockpit: keine Kategorie, kein + * Gegenkonto (eine Umbuchung ist zugeordnet) und nicht gesplittet (dann + * steckt die Zuordnung in den Zeilen). Gezählt wird nur die eigene Sicht: + * Die Gegenseite einer Umbuchung hat per Definition ein Gegenkonto. + * + * @return array konto_id => Anzahl + */ + public static function unkategorisierteJeKonto(?int $jahr = null): array + { + $jahr ??= (int)date('Y'); + + $zeilen = Database::fetchAll( + 'SELECT konto_id, COUNT(*) AS anzahl + FROM buchungen + WHERE benutzer_id = ? AND YEAR(datum) = ? + AND gesplittet = 0 AND kategorie_id IS NULL AND gegenkonto_id IS NULL + GROUP BY konto_id', + [Mandant::id(), $jahr] + ); + + $anzahlen = []; + foreach ($zeilen as $zeile) { + $anzahlen[(int)$zeile['konto_id']] = (int)$zeile['anzahl']; + } + + return $anzahlen; + } + /** Wie viele Buchungen hat das Konto (beide Sichten)? */ public static function anzahlFuerKonto(int $kontoId): int { diff --git a/src/Models/Konto.php b/src/Models/Konto.php index 5926d30..7d31f56 100644 --- a/src/Models/Konto.php +++ b/src/Models/Konto.php @@ -176,6 +176,9 @@ class Konto * wie die Σ-Spalte im FinanzManager. Rentenversicherungen und durchlaufende * Auslagen bleiben so außen vor, erscheinen aber weiter in der Liste. * + * Jedes Konto trägt zusätzlich `unkategorisiert`: die Zahl der Buchungen aus + * dem laufenden Jahr, denen noch eine Kategorie fehlt. + * * @return array{gruppen: list>, summe: float, summe_online: float} */ public static function uebersicht(bool $nurAktive = true): array @@ -183,6 +186,9 @@ class Konto $aktuell = Buchung::saldenJeKonto(date('Y-m-d')); $erwartet = Buchung::saldenJeKonto(); $depots = \Finanzmanager\Services\Depot::werteJeDepot(); + // Konten mit unkategorisierten Buchungen aus diesem Jahr stehen fett in + // der Liste – vor allem die, in die ein Bankabruf von selbst gebucht hat. + $offene = Buchung::unkategorisierteJeKonto(); $gruppen = []; $summe = 0.0; @@ -190,6 +196,7 @@ class Konto foreach (self::alle($nurAktive) as $konto) { $konto = self::mitSalden($konto, $aktuell, $erwartet, $depots); + $konto['unkategorisiert'] = $offene[(int)$konto['id']] ?? 0; $name = $konto['gruppenname'] ?? null; $name = ($name === null || $name === '') ? Kontengruppe::OHNE_GRUPPE : $name; diff --git a/src/Services/Bankabgleich.php b/src/Services/Bankabgleich.php new file mode 100644 index 0000000..804e983 --- /dev/null +++ b/src/Services/Bankabgleich.php @@ -0,0 +1,131 @@ +} + */ + public static function laufenLassen(bool $ohneKategorie): array + { + $regeln = AbgleichRegel::alle(true); + + $verbunden = 0; + $uebernommen = 0; + $offen = 0; + $fehler = []; + + foreach (Bankumsatz::liste(['status' => 'offen']) as $umsatz) { + // Ohne Konto gibt es kein Kontoblatt, in das die Buchung gehörte. + if ((int)$umsatz['konto_id'] === 0) { + $offen++; + continue; + } + + $vorschlag = Bankumsatz::vorschlag($umsatz, $regeln); + + if ($vorschlag['art'] === 'verbinden' && $vorschlag['buchung'] !== null) { + Bankumsatz::verbinden((int)$umsatz['id'], (int)$vorschlag['buchung']['id']); + $verbunden++; + continue; + } + + if ($vorschlag['zuordnung'] === null && !$ohneKategorie) { + $offen++; + continue; + } + + try { + Bankumsatz::uebernehmen((int)$umsatz['id'], $vorschlag['zuordnung'] ?? []); + $uebernommen++; + } catch (\Throwable $e) { + // Ein einzelner Umsatz darf den Lauf nicht abbrechen: Ein + // abgeschlossener Monat oder eine Buchung mit gleichem + // Fingerabdruck betrifft nur ihn. Er bleibt offen und steht + // weiter in der Vorschlagsliste. + $offen++; + $fehler[] = sprintf( + '%s, %s: %s', + date('d.m.Y', strtotime((string)$umsatz['buchungstag'])), + number_format((float)$umsatz['betrag'], 2, ',', '.') . ' €', + $e->getMessage() + ); + DebugLog::schreibe('abgleich', 'Umsatz ' . (int)$umsatz['id'] . ': ' . $e->getMessage()); + } + } + + return [ + 'verbunden' => $verbunden, + 'uebernommen' => $uebernommen, + 'offen' => $offen, + 'fehler' => $fehler, + ]; + } + + /** + * Der Lauf nach einem Abruf – nur, wenn er eingeschaltet ist. + * + * @return array{verbunden: int, uebernommen: int, offen: int, fehler: list}|null + */ + public static function automatisch(): ?array + { + if (!Einstellung::getBool('banking_automatisch_buchen', true)) { + return null; + } + + return self::laufenLassen(true); + } + + /** + * Eine Zeile für Meldung und Abrufprotokoll. + * + * @param array{verbunden: int, uebernommen: int, offen: int, fehler: list} $ergebnis + */ + public static function bericht(array $ergebnis): string + { + $text = sprintf( + '%d gebucht, %d mit vorhandenen Buchungen verbunden, %d bleiben offen.', + $ergebnis['uebernommen'], + $ergebnis['verbunden'], + $ergebnis['offen'] + ); + + if ($ergebnis['fehler'] !== []) { + $text .= ' Nicht gebucht: ' . implode('; ', array_slice($ergebnis['fehler'], 0, 3)); + if (count($ergebnis['fehler']) > 3) { + $text .= sprintf(' (und %d weitere)', count($ergebnis['fehler']) - 3); + } + } + + return $text; + } +} diff --git a/src/Services/FinTsAbruf.php b/src/Services/FinTsAbruf.php index 03f60a8..1b140d5 100644 --- a/src/Services/FinTsAbruf.php +++ b/src/Services/FinTsAbruf.php @@ -45,6 +45,13 @@ use Finanzmanager\Models\Einstellung; * Aufrufer bekommt `tan`; nach `tanEinreichen()` läuft es an derselben Stelle * weiter. Umsätze, die vorher schon geschrieben wurden, bleiben in der Datenbank * – ein Abbruch mittendrin verliert also keine Arbeit. + * + * Im Modus `konten` endet der Lauf nach der Kontenabfrage. Das ist der dritte + * Einrichtungsschritt: erst zuordnen, dann Umsätze holen – dieselbe + * Zustandsmaschine, dieselbe TAN-Behandlung, nur kürzer. + * + * Ist `banking_automatisch_buchen` eingeschaltet, bucht `Bankabgleich` am Ende + * eines vollen Laufs, was ein Konto hat (siehe dort). */ class FinTsAbruf { @@ -71,10 +78,15 @@ class FinTsAbruf /** * Startet einen Abruf. * + * `$modus` entscheidet, wie weit er läuft: `voll` ruft die Umsätze ab, + * `konten` hält nach der Kontenabfrage an. Der kurze Lauf ist der dritte + * Einrichtungsschritt – dort sollen erst die Konten zugeordnet werden, bevor + * Umsätze hereinkommen, die sonst ohne Konto dastünden. + * * @return array{status: string, meldung: string, protokoll: list, * neu: int, uebersprungen: int, tan?: array} */ - public static function starten(int $zugangId): array + public static function starten(int $zugangId, string $modus = 'voll', bool $einrichtung = false): array { $zugang = Bankzugang::zugangsdaten($zugangId); if ($zugang === null) { @@ -92,6 +104,8 @@ class FinTsAbruf $abruf = new self($zugang, Bankabruf::beginnen($zugangId), [ 'schritt' => 'anmelden', + 'modus' => $modus === 'konten' ? 'konten' : 'voll', + 'einrichtung' => $einrichtung, 'konto_nummer' => 0, 'neu' => 0, 'uebersprungen' => 0, @@ -218,6 +232,15 @@ class FinTsAbruf $this->zustand['schritt'] = 'umsaetze'; } + // Der kurze Lauf endet hier: Die Konten stehen fest, zugeordnet werden + // sie auf der Seite, von der aus er gestartet wurde. + if (($this->zustand['modus'] ?? 'voll') === 'konten') { + return $this->beenden('erfolg', sprintf( + '%d Konto/Konten bei der Bank gefunden.', + count(Bankzugang::konten((int)$this->zugang['id'])) + )); + } + return $this->umsaetzeAbrufen(); } @@ -272,11 +295,22 @@ class FinTsAbruf Bankzugang::merkeAbruf((int)$this->zugang['id']); - return $this->beenden('erfolg', sprintf( + $meldung = sprintf( '%d neue Umsätze, %d bereits bekannt.', $this->zustand['neu'], $this->zustand['uebersprungen'] - )); + ); + + // Erst am Ende, nicht je Konto: So sieht der Lauf alle Umsätze auf + // einmal und kann jeden mit der Buchung verbinden, die zu ihm passt. + $gebucht = Bankabgleich::automatisch(); + if ($gebucht !== null) { + $bericht = Bankabgleich::bericht($gebucht); + $this->notiere($bericht); + $meldung .= ' ' . $bericht; + } + + return $this->beenden('erfolg', $meldung); } /** @@ -308,7 +342,7 @@ class FinTsAbruf $empfaenger = trim($umsatz->getName()); $externId = Bankumsatz::externId( - (string)($bankkonto['iban'] ?: $bankkonto['kontonummer']), + (int)$bankkonto['id'], $buchungstag->format('Y-m-d'), $betrag, $zweck, @@ -318,6 +352,7 @@ class FinTsAbruf $id = Bankumsatz::anlegen([ 'zugang_id' => (int)$this->zugang['id'], 'konto_id' => $bankkonto['konto_id'], + 'bank_konto_id' => (int)$bankkonto['id'], 'extern_id' => $externId, 'buchungstag' => $buchungstag->format('Y-m-d'), 'wertstellung' => $valuta->format('Y-m-d'), @@ -625,7 +660,7 @@ class FinTsAbruf } } - return self::ergebnis( + $ergebnis = self::ergebnis( $status, $meldung, $this->protokoll, @@ -633,6 +668,14 @@ class FinTsAbruf (int)$this->zustand['uebersprungen'], $tan ); + + // Wohin es danach geht, weiß nur der Abruf selbst: Nach einer TAN-Runde + // kennt der Aufrufer weder den Zugang noch den Modus. + $ergebnis['zugang_id'] = (int)$this->zugang['id']; + $ergebnis['modus'] = (string)($this->zustand['modus'] ?? 'voll'); + $ergebnis['einrichtung'] = ($this->zustand['einrichtung'] ?? false) === true; + + return $ergebnis; } /** diff --git a/src/Views/banking/formular.php b/src/Views/banking/formular.php index 98fc411..f610fc2 100644 --- a/src/Views/banking/formular.php +++ b/src/Views/banking/formular.php @@ -5,7 +5,6 @@ use Finanzmanager\View; /** @var array|null $zugang */ /** @var list> $konten Bankkonten dieses Zugangs */ -/** @var list> $eigene Konten der Anwendung */ /** @var list> $abrufe */ /** @var string $csrfToken */ @@ -17,7 +16,7 @@ include __DIR__ . '/../layout/kopf.php'; ?> -

Schritt 1 von 2 · Zugangsdaten

+

Schritt 1 von 3 · Zugangsdaten

@@ -102,57 +101,31 @@ include __DIR__ . '/../layout/kopf.php';

Konten bei dieser Bank

-

- Noch nicht bekannt – sie werden beim ersten Abruf ermittelt und stehen - danach hier zur Zuordnung bereit. -

+

Noch keines bekannt.

-

- Jedes Bankkonto braucht ein eigenes Konto, sonst lassen sich seine Umsätze - nicht buchen. Was nicht abgerufen werden soll, hier abwählen. -

- - - - - +
    - -
- - - - - +
  • + → + + nicht zugeordnet + + + + + wird nicht abgerufen + +
  • - -
    Bei der BankEigenes KontoSaldoAbrufen
    - - - - - - - - –' - : View::betrag($bankkonto['saldo']) ?> - - -
    + +

    + + Zuordnung und Abfrage bei der Bank stehen auf einer eigenen Seite: + Konten zuordnen. +

    diff --git a/src/Views/banking/konten.php b/src/Views/banking/konten.php new file mode 100644 index 0000000..95c652b --- /dev/null +++ b/src/Views/banking/konten.php @@ -0,0 +1,170 @@ + $zugang + * @var list> $konten Bankkonten dieses Zugangs + * @var list> $eigene Konten der Anwendung + * @var bool $einrichtung Teil des Einrichtungsablaufs? + * @var string $csrfToken + */ + +$zugangId = (int)$zugang['id']; +$bank = (string)($zugang['bankname'] !== '' ? $zugang['bankname'] : $zugang['name']); + +include __DIR__ . '/../layout/kopf.php'; +?> + + +

    Schritt 3 von 3 · Konten zuordnen

    + + +
    + + + + +
    + + + +
    +
    + + +

    + Noch kein Konto bekannt. Die Abfrage meldet sich bei der Bank an – dafür + verlangen die meisten Häuser eine TAN. +

    + +

    + Jedes Bankkonto braucht ein eigenes Konto, sonst lassen sich seine Umsätze nicht + buchen. Was nicht abgerufen werden soll, hier abwählen. Schon abgerufene Umsätze + eines Kontos ziehen mit der Zuordnung nach. +

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Bei der BankEigenes KontoSaldoAbrufen
    + + + + + + + + –' + : View::betrag($bankkonto['saldo']) ?> + + +
    + + + + + + + + + + + IBAN und Bank werden übernommen. Der Anfangssaldo + deckt die Zeit ab, die der Abruf nicht mehr hergibt. + +
    + +
    + +
    +
    + +

    + Nach dem Abruf bucht der Finanzmanager die Umsätze selbst in das zugeordnete Konto – + verbunden mit einer schon erfassten Buchung, wenn eine passt, sonst als neue Buchung + mit der Kategorie aus der Regel. Konten mit Buchungen ohne Kategorie stehen in der + Kontenübersicht fett. Abschalten lässt sich das in den + Einstellungen. +

    + + + diff --git a/src/Views/banking/verfahren.php b/src/Views/banking/verfahren.php index 396b1bc..1e90aa8 100644 --- a/src/Views/banking/verfahren.php +++ b/src/Views/banking/verfahren.php @@ -23,11 +23,11 @@ include __DIR__ . '/../layout/kopf.php'; ?> -

    Schritt 2 von 2 · TAN-Verfahren

    +

    Schritt 2 von 3 · TAN-Verfahren

    - +
    @@ -115,9 +115,15 @@ include __DIR__ . '/../layout/kopf.php';
    + +

    + Danach fragt der Finanzmanager die Bank nach ihren Konten – dafür meldet er + sich an, und die meisten Banken verlangen dabei eine TAN. +

    +
    diff --git a/src/Views/buchungen/kontoblatt.php b/src/Views/buchungen/kontoblatt.php index f068ff1..4b0a1a2 100644 --- a/src/Views/buchungen/kontoblatt.php +++ b/src/Views/buchungen/kontoblatt.php @@ -38,6 +38,10 @@ $seitenPfad = static function (int $nr) use ($filter): string { return '?' . http_build_query($p); }; +// Nach dem Löschen soll dieselbe Ansicht wieder erscheinen – Filter und Seite +// hängen deshalb am Ziel des Löschformulars. +$loeschZiel = '/konten/' . (int)$konto['id'] . '/buchungen/loeschen' . $seitenPfad($seite); + include __DIR__ . '/../layout/kopf.php'; ?> @@ -49,6 +53,18 @@ include __DIR__ . '/../layout/kopf.php';
    Neue Buchung Kontenübersicht + + +
    + + +
    + Aktueller Saldo @@ -111,6 +127,10 @@ include __DIR__ . '/../layout/kopf.php'; + @@ -125,6 +145,13 @@ include __DIR__ . '/../layout/kopf.php'; + - > + 0 ? 'unkategorisiert' : '', + ]); + ?> + >
    + + Datum Nr. Empfänger
    + + + + diff --git a/src/Views/einstellungen/index.php b/src/Views/einstellungen/index.php index 8e76f87..16997f6 100644 --- a/src/Views/einstellungen/index.php +++ b/src/Views/einstellungen/index.php @@ -101,6 +101,19 @@ include __DIR__ . '/../layout/kopf.php'; + +

    + Nach jedem Abruf landen die Umsätze im zugeordneten Konto: verbunden mit einer + schon erfassten Buchung, wenn eine passt, sonst als neue Buchung – mit der + Kategorie aus der Regel, sonst ohne. Konten, in denen noch Buchungen ohne + Kategorie liegen, stehen in der Kontenübersicht fett. Abgeschaltet wandert + jeder Umsatz stattdessen in die Vorschlagsliste und wartet dort. +

    +
    - + 0 ? 'title="' . $offene . ' Buchung(en) aus ' + . date('Y') . ' ohne Kategorie"' : '' ?>> inaktiv @@ -92,8 +104,9 @@ include __DIR__ . '/../layout/kopf.php';

    In die Summen zählen nur Konten mit Σ-Kennzeichen – wie im FinanzManager. Der - Online-Saldo kommt mit dem Bankabruf in Phase 5, Depotwerte mit Phase 6. - Ein Klick auf den Kontonamen öffnet das Kontoblatt. + Online-Saldo stammt vom letzten Bankabruf, Depotwerte aus den Kursen. + Fett stehen Konten mit Buchungen aus , denen noch + eine Kategorie fehlt. Ein Klick auf den Kontonamen öffnet das Kontoblatt.

    diff --git a/src/Views/layout/kopf.php b/src/Views/layout/kopf.php index 4547acc..2b19549 100644 --- a/src/Views/layout/kopf.php +++ b/src/Views/layout/kopf.php @@ -40,6 +40,7 @@ $istAktiv = static function (string $pfad) use ($aktuellerPfad): bool { +