Online-Banking: Kontenzuordnung, Sammellöschen und drei Umsatz-Fehler
Kontenzuordnung als dritter Einrichtungsschritt: Welches Bankkonto gehört zu welchem eigenen Konto? Umsätze merken sich über `bank_konto_id`, woher sie stammen, und ziehen mit, wenn die Zuordnung erst nach einem Abruf fällt. Abgerufene Umsätze buchen sich auf Wunsch selbst (`Services\Bankabgleich`). Im Kontoblatt lassen sich Buchungen sammelweise löschen; Statusmeldungen liegen jetzt über der Ansicht, statt den Inhalt springen zu lassen. Dazu drei Fehler, die zusammen dafür sorgten, dass abgerufene Umsätze spurlos verschwanden – der Abruf meldete sie als vorhanden, das Kontoblatt zeigte nichts, und in der Vorschlagsliste standen sie ebenfalls nicht: - Eine gelöschte Buchung ließ ihren Umsatz auf „uebernommen“ stehen. Der Fremdschlüssel setzte nur `buchung_id` auf NULL. `Buchung::loeschen()` gibt den Umsatz jetzt vorher frei, `Bankumsatz::verwaisteOeffnen()` fängt die Wege ab, die daran vorbeiführen – Kaskaden lösen in MariaDB keine Trigger aus. - Ein Bankkonto entstand doppelt, wenn die Bank die IBAN nicht bei jedem Dialog mitliefert. Gesucht wird jetzt über die Kontonummer; eine nachgelieferte IBAN wird am vorhandenen Eintrag nachgetragen. - Der Fingerabdruck hing an der IBAN und kippte mit ihr. Er nimmt jetzt die `bank_konto_id`, die fest steht, solange es den Bankzugang gibt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
9111eeeb91
commit
78f1b91bc0
32 changed files with 2094 additions and 166 deletions
100
Changes.md
100
Changes.md
|
|
@ -1,5 +1,105 @@
|
||||||
# Änderungen
|
# Ä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)
|
## Phasen 4 bis 7 – Cockpit, Online-Banking, Wertpapiere, Nebenmodule (12.08.2026)
|
||||||
|
|
||||||
Damit ist der geplante Funktionsumfang beisammen: Von der Kontenübersicht bis zur
|
Damit ist der geplante Funktionsumfang beisammen: Von der Kontenübersicht bis zur
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,10 @@ Haushalt wird beides parallel genutzt.
|
||||||
Gruppen.
|
Gruppen.
|
||||||
* **Cockpit** – konfigurierbare Kacheln: Vermögensentwicklung, Einnahmen/Ausgaben je
|
* **Cockpit** – konfigurierbare Kacheln: Vermögensentwicklung, Einnahmen/Ausgaben je
|
||||||
Monat, Ausgaben nach Kategorie, Kontostände, offene Vorgänge.
|
Monat, Ausgaben nach Kategorie, Kontostände, offene Vorgänge.
|
||||||
* **Online-Banking** – FinTS-Abruf mit TAN-Dialog über mehrere Requests, TAN-Verfahren bei
|
* **Online-Banking** – FinTS-Abruf mit TAN-Dialog über mehrere Requests, Einrichtung in drei
|
||||||
der Bank abfragen und wählen, Abgleich der Umsätze gegen vorhandene Buchungen, Regeln für
|
Schritten (Zugangsdaten, TAN-Verfahren, Kontenzuordnung mit Anlegen fehlender Konten),
|
||||||
die automatische Kategorisierung.
|
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,
|
* **Wertpapiere** – Depotbestände mit Einstand und Bewertung, Depotbuchungen, Kursabruf,
|
||||||
Veräußerungsgeschäfte und Erträge.
|
Veräußerungsgeschäfte und Erträge.
|
||||||
* **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und
|
* **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und
|
||||||
|
|
|
||||||
100
docs/banking.md
100
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
|
fragt die Anwendung auf Knopfdruck nach (`checkDecoupledSubmission`) – steht die Freigabe
|
||||||
noch aus, bleibt die Maske stehen.
|
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
|
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 –
|
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.
|
1. **Zugangsdaten** (`/banking/neu`) – *Weiter* speichert sie.
|
||||||
2. **TAN-Verfahren** (`/banking/{id}/verfahren`) – die Seite fragt die Bank beim Aufruf
|
2. **TAN-Verfahren** (`/banking/{id}/verfahren`) – die Seite fragt die Bank beim Aufruf
|
||||||
selbst und zeigt die Liste zur Wahl.
|
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
|
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
|
ein Knopf, dessen Ergebnis feststeht, wäre ein Schritt zu viel. Ein erneuter Anlauf steht
|
||||||
trotzdem bereit, wenn sich etwas geändert hat.
|
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.
|
Später erreicht man dieselbe Seite über *TAN-Verfahren einstellen* am Zugang.
|
||||||
|
|
||||||
**Abgefragt, nicht eingetippt.** Die Kennungen sind bankspezifisch – 913 heißt nicht
|
**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
|
Abruf und Überweisung benutzen dieselbe Wahl (`Services\FinTsVerfahren::anwenden()`) – eine
|
||||||
Überweisung soll nicht plötzlich ein anderes Verfahren verlangen als der Abruf.
|
Ü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
|
Für jedes Konto, das die Bank meldet, legt die Anwendung eine Zeile in `bank_konten` an.
|
||||||
`bank_konten` an. Trägt ein eigenes Konto dieselbe IBAN, wird es gleich zugeordnet; sonst
|
Trägt ein eigenes Konto dieselbe IBAN, wird es gleich zugeordnet; sonst geschieht das auf
|
||||||
geschieht das im Formular des Zugangs. **Ohne Zuordnung lassen sich die Umsätze nicht
|
der Zuordnungsseite. **Ohne Zuordnung lassen sich die Umsätze nicht buchen** – das Konto
|
||||||
buchen** – das Konto fehlt dann schlicht.
|
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.
|
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
|
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.
|
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
|
Abgerufene Umsätze landen zuerst in `bank_umsaetze`. Ob sie von dort selbst weiterwandern,
|
||||||
Zahlungen selbst erfasst, hat sie beim Abruf meist schon im Kontoblatt; ohne Zwischenschritt
|
entscheidet *Abgerufene Umsätze selbst buchen* (`banking_automatisch_buchen`, Vorgabe **an**)
|
||||||
stünde alles doppelt da.
|
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:
|
Die Vorschlagsliste (*Umsätze abgleichen*) kennt drei Wege:
|
||||||
|
|
||||||
| Vorschlag | Wann | Wirkung |
|
| 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.
|
verbunden ist, wird nie ein zweites Mal vorgeschlagen.
|
||||||
|
|
||||||
Dass derselbe Umsatz bei überlappenden Abrufzeiträumen nur einmal hereinkommt, sichert
|
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,
|
**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
|
wo eine Buchung passt, und übernehmen, wo eine Regel greift. Umsätze ohne Regel bleiben hier
|
||||||
offen – sie sind Arbeit, die der Nutzer sehen soll.
|
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
|
## 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
|
Bank steckt hinter derselben Grenze wie der Dialog – geprüft wird, was mit ihrem Ergebnis
|
||||||
geschieht.
|
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.
|
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,
|
Geprüft wurde er gegen den Fehlerpfad: Eine unerreichbare Adresse bricht sauber ab,
|
||||||
protokolliert und meldet verständlich.
|
protokolliert und meldet verständlich.
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,25 @@ geändert wird.
|
||||||
Dieselbe Dreiteilung gilt für die Salden (`Buchung::saldenJeKonto()`), die alle Konten mit
|
Dieselbe Dreiteilung gilt für die Salden (`Buchung::saldenJeKonto()`), die alle Konten mit
|
||||||
sechs Abfragen auf einmal berechnet statt je Konto einzeln.
|
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
|
## Splitbuchungen
|
||||||
|
|
||||||
Eine Buchung ist entweder einfach zugeordnet **oder** gesplittet. Sobald Splitzeilen
|
Eine Buchung ist entweder einfach zugeordnet **oder** gesplittet. Sobald Splitzeilen
|
||||||
|
|
|
||||||
|
|
@ -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
|
Die Werte entstehen zentral in `Konto::mitSalden()`; dort kommen in Phase 2 die Buchungen
|
||||||
dazu, ohne dass die Aufrufer sich ändern.
|
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
|
**Anfangssalden** stehen nicht im QIF-Export. Sie werden nach dem Import je Konto im
|
||||||
Kontoformular nachgetragen.
|
Kontoformular nachgetragen.
|
||||||
|
|
||||||
|
|
|
||||||
19
migrations/010_kontenzuordnung.sql
Normal file
19
migrations/010_kontenzuordnung.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -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}', fn(array $p) => (new BuchungenController())->kontoblatt($p));
|
||||||
$router->get('/konten/{id}/buchung', fn(array $p) => (new BuchungenController())->neu($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}/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->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}', fn(array $p) => (new BuchungenController())->speichern($p));
|
||||||
$router->post('/buchungen/{id}/loeschen', fn(array $p) => (new BuchungenController())->loeschen($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', fn(array $p) => (new BankingController())->verfahrenWaehlen($p));
|
||||||
$router->post('/banking/{id}/verfahren/abfragen',
|
$router->post('/banking/{id}/verfahren/abfragen',
|
||||||
fn(array $p) => (new BankingController())->verfahrenAbfragen($p));
|
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->get('/verwaltung/import', fn() => (new ImportController())->index());
|
||||||
$router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen());
|
$router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen());
|
||||||
|
|
|
||||||
|
|
@ -699,6 +699,52 @@ table.liste tfoot tr.summenzeile td {
|
||||||
|
|
||||||
table.liste tr.inaktiv td { color: var(--text-gedaempft); }
|
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="text"],
|
||||||
table.liste input[type="number"] {
|
table.liste input[type="number"] {
|
||||||
width: 100%;
|
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)); }
|
.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; }
|
.feldwert { margin: .1rem 0 .3rem; }
|
||||||
|
|
||||||
/* ── Meldungen ───────────────────────────────────────────────────────────── */
|
/* ── 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 {
|
.meldung {
|
||||||
padding: .65rem .9rem;
|
padding: .55rem .9rem;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--rand);
|
border: 1px solid var(--rand);
|
||||||
margin-bottom: 1rem;
|
background: var(--flaeche);
|
||||||
|
box-shadow: var(--schatten);
|
||||||
font-size: .92rem;
|
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); }
|
.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 {
|
.meldung.verblasst {
|
||||||
opacity: 0;
|
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-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); }
|
.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; }
|
.hinweis { color: var(--text-gedaempft); font-size: .93rem; }
|
||||||
|
|
||||||
/* ── Schlichter Rahmen (Anmeldung, Fehlerseiten) ─────────────────────────── */
|
/* ── Schlichter Rahmen (Anmeldung, Fehlerseiten) ─────────────────────────── */
|
||||||
|
|
|
||||||
|
|
@ -174,14 +174,86 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Meldungen nach kurzer Zeit ausblenden – Fehler bleiben stehen.
|
// Kontenzuordnung einer Bankverbindung: Die Felder für ein neues Konto
|
||||||
// Über eine Klasse, nicht über element.style: Die Content-Security-Policy
|
// stehen in einer eigenen Tabellenzeile und werden erst sichtbar, wenn
|
||||||
// (style-src 'self') verwirft auch per JavaScript gesetzte Inline-Styles,
|
// „neues Konto anlegen“ gewählt ist. Ausgeblendet wird hier und nicht im
|
||||||
// die Meldung bliebe sonst einfach stehen.
|
// HTML – ohne JavaScript sollen die Felder da sein, sonst ließe sich kein
|
||||||
document.querySelectorAll('.meldung-erfolg').forEach(function (element) {
|
// Konto anlegen.
|
||||||
window.setTimeout(function () {
|
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');
|
element.classList.add('verblasst');
|
||||||
window.setTimeout(function () { element.remove(); }, 400);
|
window.setTimeout(function () { element.remove(); }, 400);
|
||||||
}, 5000);
|
};
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Finanzmanager\Controllers;
|
namespace Finanzmanager\Controllers;
|
||||||
|
|
||||||
use Finanzmanager\Auth;
|
use Finanzmanager\Auth;
|
||||||
|
use Finanzmanager\Database;
|
||||||
use Finanzmanager\Models\AbgleichRegel;
|
use Finanzmanager\Models\AbgleichRegel;
|
||||||
use Finanzmanager\Models\Bankabruf;
|
use Finanzmanager\Models\Bankabruf;
|
||||||
use Finanzmanager\Models\Bankumsatz;
|
use Finanzmanager\Models\Bankumsatz;
|
||||||
|
|
@ -12,6 +13,7 @@ use Finanzmanager\Models\Bankzugang;
|
||||||
use Finanzmanager\Models\Kategorie;
|
use Finanzmanager\Models\Kategorie;
|
||||||
use Finanzmanager\Models\Klasse;
|
use Finanzmanager\Models\Klasse;
|
||||||
use Finanzmanager\Models\Konto;
|
use Finanzmanager\Models\Konto;
|
||||||
|
use Finanzmanager\Services\Bankabgleich;
|
||||||
use Finanzmanager\Services\Crypto;
|
use Finanzmanager\Services\Crypto;
|
||||||
use Finanzmanager\Services\FinTsAbruf;
|
use Finanzmanager\Services\FinTsAbruf;
|
||||||
use Finanzmanager\Services\FinTsVerfahren;
|
use Finanzmanager\Services\FinTsVerfahren;
|
||||||
|
|
@ -30,6 +32,9 @@ class BankingController extends BaseController
|
||||||
/** Schlüssel der abgefragten TAN-Verfahren in der Sitzung. */
|
/** Schlüssel der abgefragten TAN-Verfahren in der Sitzung. */
|
||||||
private const SITZUNG_VERFAHREN = 'banking_verfahren';
|
private const SITZUNG_VERFAHREN = 'banking_verfahren';
|
||||||
|
|
||||||
|
/** Wert der Kontenauswahl, der für „neues Konto anlegen“ steht. */
|
||||||
|
public const KONTO_NEU = 'neu';
|
||||||
|
|
||||||
// ── Bankverbindungen ─────────────────────────────────────────────────────
|
// ── Bankverbindungen ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function index(): void
|
public function index(): void
|
||||||
|
|
@ -55,7 +60,6 @@ class BankingController extends BaseController
|
||||||
'titel' => 'Bankverbindung anlegen',
|
'titel' => 'Bankverbindung anlegen',
|
||||||
'zugang' => null,
|
'zugang' => null,
|
||||||
'konten' => [],
|
'konten' => [],
|
||||||
'eigene' => Konto::alle(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,7 +97,6 @@ class BankingController extends BaseController
|
||||||
'titel' => $zugang['name'],
|
'titel' => $zugang['name'],
|
||||||
'zugang' => $zugang,
|
'zugang' => $zugang,
|
||||||
'konten' => Bankzugang::konten((int)$zugang['id']),
|
'konten' => Bankzugang::konten((int)$zugang['id']),
|
||||||
'eigene' => Konto::alle(),
|
|
||||||
'abrufe' => Bankabruf::letzte(5, (int)$zugang['id']),
|
'abrufe' => Bankabruf::letzte(5, (int)$zugang['id']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -107,7 +110,6 @@ class BankingController extends BaseController
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Bankzugang::speichern($id, $this->formulardaten());
|
Bankzugang::speichern($id, $this->formulardaten());
|
||||||
$this->kontenzuordnungSpeichern();
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
View::redirect('/banking/' . $id, $e->getMessage(), 'fehler');
|
View::redirect('/banking/' . $id, $e->getMessage(), 'fehler');
|
||||||
return;
|
return;
|
||||||
|
|
@ -203,14 +205,120 @@ class BankingController extends BaseController
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
View::redirect(
|
// Dritter Schritt: Die Konten holen wir gleich hier – erst mit
|
||||||
'/banking/' . $id,
|
// gewähltem Verfahren lässt sich die Bank danach fragen, und ohne
|
||||||
$this->post('einrichtung') === '1'
|
// Zuordnung wäre jeder Umsatz eines fremden Kontos wertlos.
|
||||||
? 'Bankverbindung eingerichtet. Der erste Abruf ermittelt jetzt die Konten.'
|
if ($this->post('einrichtung') === '1') {
|
||||||
: 'TAN-Verfahren übernommen.'
|
$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<string,string> $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<string,string> $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<string,string> $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.
|
* Fragt die Bank und merkt sich das Ergebnis für diese Sitzung.
|
||||||
*
|
*
|
||||||
|
|
@ -260,6 +368,11 @@ class BankingController extends BaseController
|
||||||
{
|
{
|
||||||
$this->verlangeAnmeldung();
|
$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']);
|
$ergebnis = FinTsAbruf::starten((int)$params['id']);
|
||||||
|
|
||||||
$this->zeigeAbruf($ergebnis, (int)$params['id']);
|
$this->zeigeAbruf($ergebnis, (int)$params['id']);
|
||||||
|
|
@ -320,17 +433,33 @@ class BankingController extends BaseController
|
||||||
return;
|
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') {
|
if ($ergebnis['status'] === 'fehler') {
|
||||||
View::redirect(
|
View::redirect(
|
||||||
$zugangId === null ? '/banking' : '/banking/' . $zugangId,
|
$kontenlauf ? $kontenseite : ($zugangId === null ? '/banking' : '/banking/' . $zugangId),
|
||||||
'Abruf fehlgeschlagen: ' . $ergebnis['meldung'],
|
'Abruf fehlgeschlagen: ' . $ergebnis['meldung'],
|
||||||
'fehler'
|
'fehler'
|
||||||
);
|
);
|
||||||
return;
|
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(
|
View::redirect(
|
||||||
$ergebnis['neu'] > 0 ? '/banking/umsaetze' : '/banking',
|
Bankumsatz::anzahlOffen() > 0 ? '/banking/umsaetze' : '/banking',
|
||||||
$ergebnis['meldung']
|
$ergebnis['meldung']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -341,6 +470,8 @@ class BankingController extends BaseController
|
||||||
{
|
{
|
||||||
$this->verlangeAnmeldung();
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
|
Bankumsatz::verwaisteOeffnen();
|
||||||
|
|
||||||
$status = $this->query('status', 'offen');
|
$status = $this->query('status', 'offen');
|
||||||
if (!array_key_exists($status, Bankumsatz::STATUS)) {
|
if (!array_key_exists($status, Bankumsatz::STATUS)) {
|
||||||
$status = 'offen';
|
$status = 'offen';
|
||||||
|
|
@ -417,42 +548,21 @@ class BankingController extends BaseController
|
||||||
View::redirect('/banking/umsaetze?status=offen', 'Umsatz steht wieder zum Abgleich bereit.');
|
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
|
public function alleUebernehmen(): void
|
||||||
{
|
{
|
||||||
$this->verlangeAnmeldung();
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
$regeln = AbgleichRegel::alle(true);
|
View::redirect(
|
||||||
$verbunden = 0;
|
'/banking/umsaetze',
|
||||||
$uebernommen = 0;
|
Bankabgleich::bericht(Bankabgleich::laufenLassen(false))
|
||||||
$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
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Regeln ───────────────────────────────────────────────────────────────
|
// ── 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<string,mixed> $zugang
|
||||||
|
* @return int Zahl der dabei neu angelegten eigenen Konten
|
||||||
|
*/
|
||||||
|
private function kontenzuordnungSpeichern(array $zugang): int
|
||||||
{
|
{
|
||||||
$zuordnung = $_POST['bankkonto'] ?? [];
|
$zuordnung = $_POST['bankkonto'] ?? [];
|
||||||
if (!is_array($zuordnung)) {
|
if (!is_array($zuordnung)) {
|
||||||
return;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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) {
|
foreach ($zuordnung as $bankKontoId => $werte) {
|
||||||
if (!is_array($werte)) {
|
$bankKontoId = (int)$bankKontoId;
|
||||||
|
if (!is_array($werte) || !isset($bekannt[$bankKontoId])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Bankzugang::ordneKontoZu(
|
$wahl = (string)($werte['konto_id'] ?? '');
|
||||||
(int)$bankKontoId,
|
|
||||||
($werte['konto_id'] ?? '') === '' ? null : (int)$werte['konto_id'],
|
if ($wahl === self::KONTO_NEU) {
|
||||||
!empty($werte['abrufen'])
|
$kontoId = $this->kontoAnlegen($zugang, $bekannt[$bankKontoId], $werte);
|
||||||
|
$neue++;
|
||||||
|
} else {
|
||||||
|
$kontoId = $wahl === '' ? null : (int)$wahl;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bankzugang::ordneKontoZu($bankKontoId, $kontoId, !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<string,mixed> $zugang
|
||||||
|
* @param array<string,mixed> $bankkonto
|
||||||
|
* @param array<string,mixed> $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,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,74 @@ class BuchungenController extends BaseController
|
||||||
View::redirect('/konten/' . $buchung['konto_id'], 'Buchung gelöscht.');
|
View::redirect('/konten/' . $buchung['konto_id'], 'Buchung gelöscht.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mehrere im Kontoblatt angekreuzte Buchungen auf einmal löschen.
|
||||||
|
*
|
||||||
|
* @param array<string,string> $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<string,mixed>|null $konto */
|
/** @param array<string,mixed>|null $konto */
|
||||||
private function maske(?array $konto, ?array $buchung): void
|
private function maske(?array $konto, ?array $buchung): void
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ class EinstellungenController extends BaseController
|
||||||
'darstellung' => ['darstellung_thema' => 'text', 'zeilen_pro_seite' => 'text'],
|
'darstellung' => ['darstellung_thema' => 'text', 'zeilen_pro_seite' => 'text'],
|
||||||
'buchhaltung' => ['erster_monat' => 'text'],
|
'buchhaltung' => ['erster_monat' => 'text'],
|
||||||
'banking' => [
|
'banking' => [
|
||||||
|
'banking_automatisch_buchen' => 'bool',
|
||||||
'banking_regeln_beim_abruf' => 'bool',
|
'banking_regeln_beim_abruf' => 'bool',
|
||||||
'banking_abgleich_tage' => 'text',
|
'banking_abgleich_tage' => 'text',
|
||||||
'banking_produkt_id' => 'text',
|
'banking_produkt_id' => 'text',
|
||||||
|
|
|
||||||
|
|
@ -31,16 +31,23 @@ class Bankumsatz
|
||||||
/**
|
/**
|
||||||
* Fingerabdruck eines Umsatzes bei der Bank. Er entscheidet, ob ein Umsatz
|
* Fingerabdruck eines Umsatzes bei der Bank. Er entscheidet, ob ein Umsatz
|
||||||
* schon einmal abgerufen wurde – Abrufzeiträume überlappen sich fast immer.
|
* 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(
|
public static function externId(
|
||||||
string $iban,
|
int $bankKontoId,
|
||||||
string $datum,
|
string $datum,
|
||||||
float $betrag,
|
float $betrag,
|
||||||
string $zweck,
|
string $zweck,
|
||||||
string $empfaenger = ''
|
string $empfaenger = ''
|
||||||
): string {
|
): string {
|
||||||
return sha1(implode('|', [
|
return sha1(implode('|', [
|
||||||
strtoupper($iban),
|
$bankKontoId,
|
||||||
$datum,
|
$datum,
|
||||||
number_format($betrag, 2, '.', ''),
|
number_format($betrag, 2, '.', ''),
|
||||||
trim($zweck),
|
trim($zweck),
|
||||||
|
|
@ -69,13 +76,16 @@ class Bankumsatz
|
||||||
|
|
||||||
Database::execute(
|
Database::execute(
|
||||||
'INSERT INTO bank_umsaetze
|
'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)
|
betrag, waehrung, empfaenger, verwendungszweck, buchungsart, roh)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
[
|
[
|
||||||
Mandant::id(),
|
Mandant::id(),
|
||||||
(int)$daten['zugang_id'],
|
(int)$daten['zugang_id'],
|
||||||
($daten['konto_id'] ?? null) ?: null,
|
($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,
|
$externId,
|
||||||
(string)$daten['buchungstag'],
|
(string)$daten['buchungstag'],
|
||||||
($daten['wertstellung'] ?? null) ?: null,
|
($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
|
public static function loeschen(int $id): void
|
||||||
{
|
{
|
||||||
Database::execute('DELETE FROM bank_umsaetze WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
Database::execute('DELETE FROM bank_umsaetze WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,14 @@ class Bankzugang
|
||||||
* Legt ein bei der Bank gefundenes Konto an oder findet es wieder. Beim
|
* Legt ein bei der Bank gefundenes Konto an oder findet es wieder. Beim
|
||||||
* ersten Auftauchen bleibt es ohne Zuordnung – der Nutzer entscheidet, auf
|
* ersten Auftauchen bleibt es ohne Zuordnung – der Nutzer entscheidet, auf
|
||||||
* welches eigene Konto die Umsätze laufen.
|
* 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(
|
public static function findeOderLegeKontoAn(
|
||||||
int $zugangId,
|
int $zugangId,
|
||||||
|
|
@ -269,11 +277,22 @@ class Bankzugang
|
||||||
$kontonummer = trim($kontonummer);
|
$kontonummer = trim($kontonummer);
|
||||||
|
|
||||||
$zeile = Database::fetchOne(
|
$zeile = Database::fetchOne(
|
||||||
'SELECT * FROM bank_konten WHERE zugang_id = ? AND iban = ? AND kontonummer = ?',
|
'SELECT * FROM bank_konten
|
||||||
[$zugangId, $iban, $kontonummer]
|
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) {
|
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;
|
return $zeile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,6 +319,14 @@ class Bankzugang
|
||||||
return Database::fetchOne('SELECT * FROM bank_konten WHERE id = ?', [Database::lastInsertId()]) ?: [];
|
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
|
public static function ordneKontoZu(int $bankKontoId, ?int $kontoId, bool $abrufen = true): void
|
||||||
{
|
{
|
||||||
Database::execute(
|
Database::execute(
|
||||||
|
|
@ -309,6 +336,13 @@ class Bankzugang
|
||||||
WHERE bk.id = ? AND z.benutzer_id = ?',
|
WHERE bk.id = ? AND z.benutzer_id = ?',
|
||||||
[$kontoId, $abrufen ? 1 : 0, $bankKontoId, Mandant::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
|
public static function merkeSaldo(int $bankKontoId, float $saldo): void
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,14 @@ class Buchung
|
||||||
Monatsabschluss::verlangeOffen((string)$buchung['datum']);
|
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()]);
|
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<int,int> 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)? */
|
/** Wie viele Buchungen hat das Konto (beide Sichten)? */
|
||||||
public static function anzahlFuerKonto(int $kontoId): int
|
public static function anzahlFuerKonto(int $kontoId): int
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,9 @@ class Konto
|
||||||
* wie die Σ-Spalte im FinanzManager. Rentenversicherungen und durchlaufende
|
* wie die Σ-Spalte im FinanzManager. Rentenversicherungen und durchlaufende
|
||||||
* Auslagen bleiben so außen vor, erscheinen aber weiter in der Liste.
|
* 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<array<string,mixed>>, summe: float, summe_online: float}
|
* @return array{gruppen: list<array<string,mixed>>, summe: float, summe_online: float}
|
||||||
*/
|
*/
|
||||||
public static function uebersicht(bool $nurAktive = true): array
|
public static function uebersicht(bool $nurAktive = true): array
|
||||||
|
|
@ -183,6 +186,9 @@ class Konto
|
||||||
$aktuell = Buchung::saldenJeKonto(date('Y-m-d'));
|
$aktuell = Buchung::saldenJeKonto(date('Y-m-d'));
|
||||||
$erwartet = Buchung::saldenJeKonto();
|
$erwartet = Buchung::saldenJeKonto();
|
||||||
$depots = \Finanzmanager\Services\Depot::werteJeDepot();
|
$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 = [];
|
$gruppen = [];
|
||||||
$summe = 0.0;
|
$summe = 0.0;
|
||||||
|
|
@ -190,6 +196,7 @@ class Konto
|
||||||
|
|
||||||
foreach (self::alle($nurAktive) as $konto) {
|
foreach (self::alle($nurAktive) as $konto) {
|
||||||
$konto = self::mitSalden($konto, $aktuell, $erwartet, $depots);
|
$konto = self::mitSalden($konto, $aktuell, $erwartet, $depots);
|
||||||
|
$konto['unkategorisiert'] = $offene[(int)$konto['id']] ?? 0;
|
||||||
$name = $konto['gruppenname'] ?? null;
|
$name = $konto['gruppenname'] ?? null;
|
||||||
$name = ($name === null || $name === '') ? Kontengruppe::OHNE_GRUPPE : $name;
|
$name = ($name === null || $name === '') ? Kontengruppe::OHNE_GRUPPE : $name;
|
||||||
|
|
||||||
|
|
|
||||||
131
src/Services/Bankabgleich.php
Normal file
131
src/Services/Bankabgleich.php
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager\Services;
|
||||||
|
|
||||||
|
use Finanzmanager\Models\AbgleichRegel;
|
||||||
|
use Finanzmanager\Models\Bankumsatz;
|
||||||
|
use Finanzmanager\Models\Einstellung;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Abgleich am Stück: offene Bankumsätze zu Buchungen machen, ohne dass für
|
||||||
|
* jeden Umsatz ein Klick fällig wird.
|
||||||
|
*
|
||||||
|
* **Zwei Betriebsarten.** Der automatische Lauf nach dem Abruf bucht alles, was
|
||||||
|
* ein Konto hat – notfalls ohne Kategorie. Das ist der Sinn der Einstellung
|
||||||
|
* `banking_automatisch_buchen`: Die Umsätze sollen im Kontoblatt stehen, und die
|
||||||
|
* Kategorie wird dort nachgetragen. Welche Konten das betrifft, zeigt die
|
||||||
|
* Kontenübersicht (fett gedruckt, siehe `Buchung::unkategorisierteJeKonto()`).
|
||||||
|
*
|
||||||
|
* Der Knopf „Alles übernehmen“ in der Vorschlagsliste ist zurückhaltender: Er
|
||||||
|
* lässt liegen, was keine Regel trifft. Wer den Dialog benutzt, will die
|
||||||
|
* Zuordnung selbst treffen – dort wäre eine Buchung ohne Kategorie nur Arbeit,
|
||||||
|
* die man ihm ungefragt aufhalst.
|
||||||
|
*
|
||||||
|
* Gemeinsam ist beiden: Passt eine schon erfasste Buchung, wird **verbunden**
|
||||||
|
* statt neu gebucht. Sonst stünde jede selbst erfasste Zahlung doppelt im
|
||||||
|
* Kontoblatt.
|
||||||
|
*/
|
||||||
|
class Bankabgleich
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Arbeitet alle offenen Umsätze ab.
|
||||||
|
*
|
||||||
|
* @param bool $ohneKategorie Auch buchen, wenn keine Regel greift?
|
||||||
|
* @return array{verbunden: int, uebernommen: int, offen: int, fehler: list<string>}
|
||||||
|
*/
|
||||||
|
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<string>}|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<string>} $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,13 @@ use Finanzmanager\Models\Einstellung;
|
||||||
* Aufrufer bekommt `tan`; nach `tanEinreichen()` läuft es an derselben Stelle
|
* Aufrufer bekommt `tan`; nach `tanEinreichen()` läuft es an derselben Stelle
|
||||||
* weiter. Umsätze, die vorher schon geschrieben wurden, bleiben in der Datenbank
|
* weiter. Umsätze, die vorher schon geschrieben wurden, bleiben in der Datenbank
|
||||||
* – ein Abbruch mittendrin verliert also keine Arbeit.
|
* – 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
|
class FinTsAbruf
|
||||||
{
|
{
|
||||||
|
|
@ -71,10 +78,15 @@ class FinTsAbruf
|
||||||
/**
|
/**
|
||||||
* Startet einen Abruf.
|
* 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<string>,
|
* @return array{status: string, meldung: string, protokoll: list<string>,
|
||||||
* neu: int, uebersprungen: int, tan?: array<string,mixed>}
|
* neu: int, uebersprungen: int, tan?: array<string,mixed>}
|
||||||
*/
|
*/
|
||||||
public static function starten(int $zugangId): array
|
public static function starten(int $zugangId, string $modus = 'voll', bool $einrichtung = false): array
|
||||||
{
|
{
|
||||||
$zugang = Bankzugang::zugangsdaten($zugangId);
|
$zugang = Bankzugang::zugangsdaten($zugangId);
|
||||||
if ($zugang === null) {
|
if ($zugang === null) {
|
||||||
|
|
@ -92,6 +104,8 @@ class FinTsAbruf
|
||||||
|
|
||||||
$abruf = new self($zugang, Bankabruf::beginnen($zugangId), [
|
$abruf = new self($zugang, Bankabruf::beginnen($zugangId), [
|
||||||
'schritt' => 'anmelden',
|
'schritt' => 'anmelden',
|
||||||
|
'modus' => $modus === 'konten' ? 'konten' : 'voll',
|
||||||
|
'einrichtung' => $einrichtung,
|
||||||
'konto_nummer' => 0,
|
'konto_nummer' => 0,
|
||||||
'neu' => 0,
|
'neu' => 0,
|
||||||
'uebersprungen' => 0,
|
'uebersprungen' => 0,
|
||||||
|
|
@ -218,6 +232,15 @@ class FinTsAbruf
|
||||||
$this->zustand['schritt'] = 'umsaetze';
|
$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();
|
return $this->umsaetzeAbrufen();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,11 +295,22 @@ class FinTsAbruf
|
||||||
|
|
||||||
Bankzugang::merkeAbruf((int)$this->zugang['id']);
|
Bankzugang::merkeAbruf((int)$this->zugang['id']);
|
||||||
|
|
||||||
return $this->beenden('erfolg', sprintf(
|
$meldung = sprintf(
|
||||||
'%d neue Umsätze, %d bereits bekannt.',
|
'%d neue Umsätze, %d bereits bekannt.',
|
||||||
$this->zustand['neu'],
|
$this->zustand['neu'],
|
||||||
$this->zustand['uebersprungen']
|
$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());
|
$empfaenger = trim($umsatz->getName());
|
||||||
|
|
||||||
$externId = Bankumsatz::externId(
|
$externId = Bankumsatz::externId(
|
||||||
(string)($bankkonto['iban'] ?: $bankkonto['kontonummer']),
|
(int)$bankkonto['id'],
|
||||||
$buchungstag->format('Y-m-d'),
|
$buchungstag->format('Y-m-d'),
|
||||||
$betrag,
|
$betrag,
|
||||||
$zweck,
|
$zweck,
|
||||||
|
|
@ -318,6 +352,7 @@ class FinTsAbruf
|
||||||
$id = Bankumsatz::anlegen([
|
$id = Bankumsatz::anlegen([
|
||||||
'zugang_id' => (int)$this->zugang['id'],
|
'zugang_id' => (int)$this->zugang['id'],
|
||||||
'konto_id' => $bankkonto['konto_id'],
|
'konto_id' => $bankkonto['konto_id'],
|
||||||
|
'bank_konto_id' => (int)$bankkonto['id'],
|
||||||
'extern_id' => $externId,
|
'extern_id' => $externId,
|
||||||
'buchungstag' => $buchungstag->format('Y-m-d'),
|
'buchungstag' => $buchungstag->format('Y-m-d'),
|
||||||
'wertstellung' => $valuta->format('Y-m-d'),
|
'wertstellung' => $valuta->format('Y-m-d'),
|
||||||
|
|
@ -625,7 +660,7 @@ class FinTsAbruf
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::ergebnis(
|
$ergebnis = self::ergebnis(
|
||||||
$status,
|
$status,
|
||||||
$meldung,
|
$meldung,
|
||||||
$this->protokoll,
|
$this->protokoll,
|
||||||
|
|
@ -633,6 +668,14 @@ class FinTsAbruf
|
||||||
(int)$this->zustand['uebersprungen'],
|
(int)$this->zustand['uebersprungen'],
|
||||||
$tan
|
$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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ use Finanzmanager\View;
|
||||||
|
|
||||||
/** @var array<string,mixed>|null $zugang */
|
/** @var array<string,mixed>|null $zugang */
|
||||||
/** @var list<array<string,mixed>> $konten Bankkonten dieses Zugangs */
|
/** @var list<array<string,mixed>> $konten Bankkonten dieses Zugangs */
|
||||||
/** @var list<array<string,mixed>> $eigene Konten der Anwendung */
|
|
||||||
/** @var list<array<string,mixed>> $abrufe */
|
/** @var list<array<string,mixed>> $abrufe */
|
||||||
/** @var string $csrfToken */
|
/** @var string $csrfToken */
|
||||||
|
|
||||||
|
|
@ -17,7 +16,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php if ($neu): ?>
|
<?php if ($neu): ?>
|
||||||
<p class="schrittmarke">Schritt 1 von 2 · Zugangsdaten</p>
|
<p class="schrittmarke">Schritt 1 von 3 · Zugangsdaten</p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="seitenaktionen">
|
<div class="seitenaktionen">
|
||||||
|
|
@ -102,57 +101,31 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
|
|
||||||
<h3>Konten bei dieser Bank</h3>
|
<h3>Konten bei dieser Bank</h3>
|
||||||
<?php if ($konten === []): ?>
|
<?php if ($konten === []): ?>
|
||||||
<p class="platzhalter">
|
<p class="platzhalter">Noch keines bekannt.</p>
|
||||||
Noch nicht bekannt – sie werden beim ersten Abruf ermittelt und stehen
|
|
||||||
danach hier zur Zuordnung bereit.
|
|
||||||
</p>
|
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<p class="feldhinweis">
|
<ul class="kontenzuordnung">
|
||||||
Jedes Bankkonto braucht ein eigenes Konto, sonst lassen sich seine Umsätze
|
|
||||||
nicht buchen. Was nicht abgerufen werden soll, hier abwählen.
|
|
||||||
</p>
|
|
||||||
<table class="liste">
|
|
||||||
<thead>
|
|
||||||
<tr><th>Bei der Bank</th><th>Eigenes Konto</th><th class="rechts">Saldo</th><th>Abrufen</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($konten as $bankkonto): ?>
|
<?php foreach ($konten as $bankkonto): ?>
|
||||||
<?php $id = (int)$bankkonto['id']; ?>
|
<li>
|
||||||
<tr>
|
<?= View::h($bankkonto['iban'] ?: $bankkonto['kontonummer']) ?> →
|
||||||
<td>
|
<?php if ($bankkonto['kontoname'] === null): ?>
|
||||||
<?= View::h($bankkonto['iban'] ?: $bankkonto['kontonummer']) ?>
|
<span class="nebentext">nicht zugeordnet</span>
|
||||||
<?php if ($bankkonto['bezeichnung'] !== '' && $bankkonto['bezeichnung'] !== $bankkonto['iban']): ?>
|
<?php else: ?>
|
||||||
<span class="nebentext"><?= View::h($bankkonto['bezeichnung']) ?></span>
|
<strong><?= View::h($bankkonto['kontoname']) ?></strong>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
<?php if ((int)$bankkonto['abrufen'] !== 1): ?>
|
||||||
<td>
|
<span class="marke">wird nicht abgerufen</span>
|
||||||
<select name="bankkonto[<?= $id ?>][konto_id]">
|
|
||||||
<option value="">– nicht zugeordnet –</option>
|
|
||||||
<?php foreach ($eigene as $konto): ?>
|
|
||||||
<option value="<?= (int)$konto['id'] ?>"
|
|
||||||
<?= (int)$bankkonto['konto_id'] === (int)$konto['id'] ? 'selected' : '' ?>>
|
|
||||||
<?= View::h($konto['name']) ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td class="rechts">
|
|
||||||
<?= $bankkonto['saldo'] === null
|
|
||||||
? '<span class="nebentext">–</span>'
|
|
||||||
: View::betrag($bankkonto['saldo']) ?>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<label class="ankreuzfeld">
|
|
||||||
<input type="checkbox" name="bankkonto[<?= $id ?>][abrufen]" value="1"
|
|
||||||
<?= (int)$bankkonto['abrufen'] === 1 ? 'checked' : '' ?>>
|
|
||||||
ja
|
|
||||||
</label>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
<p class="feldhinweis">
|
||||||
|
<?php /* Die Zuordnung steht auf einer eigenen Seite: Dort lassen sich
|
||||||
|
fehlende Konten gleich anlegen, und die Liste der Bankkonten
|
||||||
|
kommt frisch von der Bank. */ ?>
|
||||||
|
Zuordnung und Abfrage bei der Bank stehen auf einer eigenen Seite:
|
||||||
|
<a href="/banking/<?= (int)$zugang['id'] ?>/konten">Konten zuordnen</a>.
|
||||||
|
</p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($neu): ?>
|
<?php if ($neu): ?>
|
||||||
|
|
|
||||||
170
src/Views/banking/konten.php
Normal file
170
src/Views/banking/konten.php
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Finanzmanager\Controllers\BankingController;
|
||||||
|
use Finanzmanager\Models\Konto;
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schritt 3 der Einrichtung: Welches Bankkonto gehört zu welchem eigenen Konto?
|
||||||
|
*
|
||||||
|
* Die Liste links kommt von der Bank, die Auswahl rechts aus der eigenen
|
||||||
|
* Kontenliste. Fehlt dort das passende Konto, legt „neues Konto anlegen“ es an –
|
||||||
|
* IBAN und Bank stehen schon fest, einzutragen bleibt der Name.
|
||||||
|
*
|
||||||
|
* @var array<string,mixed> $zugang
|
||||||
|
* @var list<array<string,mixed>> $konten Bankkonten dieses Zugangs
|
||||||
|
* @var list<array<string,mixed>> $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';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php if ($einrichtung): ?>
|
||||||
|
<p class="schrittmarke">Schritt 3 von 3 · Konten zuordnen</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="seitenaktionen">
|
||||||
|
<?php /* Wer die Einrichtung hier verlässt, landet in der Übersicht – dort
|
||||||
|
steht die Verbindung fertig zwischen den anderen. Beim einzelnen
|
||||||
|
Aufruf führt der Weg zurück, woher er kam. */ ?>
|
||||||
|
<a href="<?= $einrichtung ? '/banking' : '/banking/' . $zugangId ?>" class="knopf">
|
||||||
|
<?= $einrichtung ? 'Später zuordnen' : 'Zurück zur Bankverbindung' ?>
|
||||||
|
</a>
|
||||||
|
<form method="post" action="/banking/<?= $zugangId ?>/konten/abfragen" class="inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
|
<input type="hidden" name="einrichtung" value="<?= $einrichtung ? '1' : '0' ?>">
|
||||||
|
<button type="submit" class="knopf">
|
||||||
|
<?= $konten === [] ? 'Konten bei der Bank abfragen' : 'Erneut bei der Bank abfragen' ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($konten === []): ?>
|
||||||
|
<p class="platzhalter">
|
||||||
|
Noch kein Konto bekannt. Die Abfrage meldet sich bei der Bank an – dafür
|
||||||
|
verlangen die meisten Häuser eine TAN.
|
||||||
|
</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="feldhinweis">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" action="/banking/<?= $zugangId ?>/konten" class="formular breit">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
|
<input type="hidden" name="einrichtung" value="<?= $einrichtung ? '1' : '0' ?>">
|
||||||
|
|
||||||
|
<table class="liste">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Bei der Bank</th>
|
||||||
|
<th>Eigenes Konto</th>
|
||||||
|
<th class="rechts">Saldo</th>
|
||||||
|
<th>Abrufen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($konten as $bankkonto): ?>
|
||||||
|
<?php
|
||||||
|
$id = (int)$bankkonto['id'];
|
||||||
|
$kennung = (string)($bankkonto['iban'] ?: $bankkonto['kontonummer']);
|
||||||
|
// Namensvorschlag für ein neues Konto: Bank und die letzten vier
|
||||||
|
// Stellen – „Sparkasse 2051“ ist in der Kontenliste eindeutiger
|
||||||
|
// als eine nackte IBAN.
|
||||||
|
$endung = substr($kennung, -4);
|
||||||
|
$vorschlag = trim($bank . ($endung === '' ? '' : ' ' . $endung));
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<?= View::h($kennung) ?>
|
||||||
|
<?php if ($bankkonto['bezeichnung'] !== '' && $bankkonto['bezeichnung'] !== $kennung): ?>
|
||||||
|
<span class="nebentext"><?= View::h($bankkonto['bezeichnung']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select name="bankkonto[<?= $id ?>][konto_id]"
|
||||||
|
data-neues-konto="neues-konto-<?= $id ?>">
|
||||||
|
<option value="">– nicht zugeordnet –</option>
|
||||||
|
<?php foreach ($eigene as $konto): ?>
|
||||||
|
<option value="<?= (int)$konto['id'] ?>"
|
||||||
|
<?= (int)$bankkonto['konto_id'] === (int)$konto['id'] ? 'selected' : '' ?>>
|
||||||
|
<?= View::h($konto['name']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<option value="<?= View::h(BankingController::KONTO_NEU) ?>">
|
||||||
|
+ neues Konto anlegen
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="rechts">
|
||||||
|
<?= $bankkonto['saldo'] === null
|
||||||
|
? '<span class="nebentext">–</span>'
|
||||||
|
: View::betrag($bankkonto['saldo']) ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<label class="ankreuzfeld">
|
||||||
|
<input type="checkbox" name="bankkonto[<?= $id ?>][abrufen]" value="1"
|
||||||
|
<?= (int)$bankkonto['abrufen'] === 1 ? 'checked' : '' ?>>
|
||||||
|
ja
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php /* Die Felder des neuen Kontos stehen in einer eigenen Zeile:
|
||||||
|
In der Hauptzeile wären es vier Spalten mehr, die in den
|
||||||
|
meisten Fällen leer blieben. app.js blendet sie ein,
|
||||||
|
sobald „neues Konto anlegen“ gewählt ist. */ ?>
|
||||||
|
<tr class="unterzeile" id="neues-konto-<?= $id ?>">
|
||||||
|
<td></td>
|
||||||
|
<td colspan="3" class="neues-konto">
|
||||||
|
<label for="neuer-name-<?= $id ?>">Name</label>
|
||||||
|
<input type="text" id="neuer-name-<?= $id ?>"
|
||||||
|
name="bankkonto[<?= $id ?>][neuer_name]"
|
||||||
|
value="<?= View::h($vorschlag) ?>">
|
||||||
|
|
||||||
|
<label for="neue-kontoart-<?= $id ?>">Kontoart</label>
|
||||||
|
<select id="neue-kontoart-<?= $id ?>" name="bankkonto[<?= $id ?>][neue_kontoart]">
|
||||||
|
<?php foreach (Konto::ARTEN as $schluessel => $beschriftung): ?>
|
||||||
|
<option value="<?= View::h($schluessel) ?>"
|
||||||
|
<?= $schluessel === 'bank' ? 'selected' : '' ?>>
|
||||||
|
<?= View::h($beschriftung) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="anfangssaldo-<?= $id ?>">Anfangssaldo</label>
|
||||||
|
<input type="text" id="anfangssaldo-<?= $id ?>"
|
||||||
|
name="bankkonto[<?= $id ?>][anfangssaldo]" value="0,00" inputmode="decimal">
|
||||||
|
|
||||||
|
<span class="nebentext">
|
||||||
|
IBAN <?= View::h($kennung) ?> und Bank werden übernommen. Der Anfangssaldo
|
||||||
|
deckt die Zeit ab, die der Abruf nicht mehr hergibt.
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="knopfleiste">
|
||||||
|
<button type="submit" class="knopf primaer">
|
||||||
|
<?= $einrichtung ? 'Einrichtung abschließen' : 'Zuordnung speichern' ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="hinweis">
|
||||||
|
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
|
||||||
|
<a href="/einstellungen?bereich=banking">Einstellungen</a>.
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||||
|
|
@ -23,11 +23,11 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php if ($einrichtung): ?>
|
<?php if ($einrichtung): ?>
|
||||||
<p class="schrittmarke">Schritt 2 von 2 · TAN-Verfahren</p>
|
<p class="schrittmarke">Schritt 2 von 3 · TAN-Verfahren</p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="seitenaktionen">
|
<div class="seitenaktionen">
|
||||||
<a href="/banking/<?= $zugangId ?>" class="knopf">
|
<a href="<?= $einrichtung ? '/banking' : '/banking/' . $zugangId ?>" class="knopf">
|
||||||
<?= $einrichtung ? 'Später einstellen' : 'Zurück zur Bankverbindung' ?>
|
<?= $einrichtung ? 'Später einstellen' : 'Zurück zur Bankverbindung' ?>
|
||||||
</a>
|
</a>
|
||||||
<form method="post" action="/banking/<?= $zugangId ?>/verfahren/abfragen" class="inline">
|
<form method="post" action="/banking/<?= $zugangId ?>/verfahren/abfragen" class="inline">
|
||||||
|
|
@ -115,9 +115,15 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
|
|
||||||
<div class="knopfleiste">
|
<div class="knopfleiste">
|
||||||
<button type="submit" class="knopf primaer">
|
<button type="submit" class="knopf primaer">
|
||||||
<?= $einrichtung ? 'Einrichtung abschließen' : 'Verfahren übernehmen' ?>
|
<?= $einrichtung ? 'Weiter zur Kontenzuordnung' : 'Verfahren übernehmen' ?>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<?php if ($einrichtung): ?>
|
||||||
|
<p class="feldhinweis">
|
||||||
|
Danach fragt der Finanzmanager die Bank nach ihren Konten – dafür meldet er
|
||||||
|
sich an, und die meisten Banken verlangen dabei eine TAN.
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ $seitenPfad = static function (int $nr) use ($filter): string {
|
||||||
return '?' . http_build_query($p);
|
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';
|
include __DIR__ . '/../layout/kopf.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
|
@ -49,6 +53,18 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<div class="seitenaktionen">
|
<div class="seitenaktionen">
|
||||||
<a href="/konten/<?= (int)$konto['id'] ?>/buchung" class="knopf primaer">Neue Buchung</a>
|
<a href="/konten/<?= (int)$konto['id'] ?>/buchung" class="knopf primaer">Neue Buchung</a>
|
||||||
<a href="/konten" class="knopf">Kontenübersicht</a>
|
<a href="/konten" class="knopf">Kontenübersicht</a>
|
||||||
|
<?php if ($zeilen !== []): ?>
|
||||||
|
<!-- Das Formular steht hier, seine Ankreuzfelder stehen in der Tabelle;
|
||||||
|
verbunden sind sie über das form-Attribut. Ein Formular um die
|
||||||
|
ganze Tabelle würde das Filterformular verschachteln. -->
|
||||||
|
<form method="post" action="<?= View::h($loeschZiel) ?>" id="buchungsauswahl" class="inline"
|
||||||
|
data-bestaetigen="Die ausgewählten Buchungen wirklich löschen?"
|
||||||
|
data-frage-eine="Die ausgewählte Buchung wirklich löschen?"
|
||||||
|
data-frage-mehrere="{anzahl} ausgewählte Buchungen wirklich löschen?">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken ?? '') ?>">
|
||||||
|
<button type="submit" class="knopf gefahr">Auswahl löschen</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
<span class="fuellung"></span>
|
<span class="fuellung"></span>
|
||||||
<span class="saldoanzeige">
|
<span class="saldoanzeige">
|
||||||
Aktueller Saldo
|
Aktueller Saldo
|
||||||
|
|
@ -111,6 +127,10 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<table class="liste kontoblatt">
|
<table class="liste kontoblatt">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="wahlspalte">
|
||||||
|
<input type="checkbox" class="alle-waehlen" data-waehlt="buchungsauswahl"
|
||||||
|
aria-label="Alle angezeigten Buchungen auswählen">
|
||||||
|
</th>
|
||||||
<th>Datum</th>
|
<th>Datum</th>
|
||||||
<th>Nr.</th>
|
<th>Nr.</th>
|
||||||
<th>Empfänger</th>
|
<th>Empfänger</th>
|
||||||
|
|
@ -125,6 +145,13 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($zeilen as $zeile): ?>
|
<?php foreach ($zeilen as $zeile): ?>
|
||||||
<tr class="<?= $zeile['gegensicht'] ? 'gegensicht' : '' ?>">
|
<tr class="<?= $zeile['gegensicht'] ? 'gegensicht' : '' ?>">
|
||||||
|
<td class="wahlspalte">
|
||||||
|
<?php if (!$zeile['gegensicht']): ?>
|
||||||
|
<input type="checkbox" name="buchungen[]" value="<?= (int)$zeile['id'] ?>"
|
||||||
|
form="buchungsauswahl"
|
||||||
|
aria-label="Buchung vom <?= View::h(View::datum($zeile['datum'])) ?> auswählen">
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
<td class="nowrap"><?= View::datum($zeile['datum']) ?></td>
|
<td class="nowrap"><?= View::datum($zeile['datum']) ?></td>
|
||||||
<td class="nebentext"><?= View::h($zeile['nummer']) ?></td>
|
<td class="nebentext"><?= View::h($zeile['nummer']) ?></td>
|
||||||
<td>
|
<td>
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,19 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php elseif ($bereich === 'banking'): ?>
|
<?php elseif ($bereich === 'banking'): ?>
|
||||||
|
<label class="ankreuzfeld">
|
||||||
|
<input type="checkbox" name="banking_automatisch_buchen" value="1"
|
||||||
|
<?= $wert('banking_automatisch_buchen', '1') === '1' ? 'checked' : '' ?>>
|
||||||
|
Abgerufene Umsätze selbst buchen
|
||||||
|
</label>
|
||||||
|
<p class="feldhinweis">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
<label class="ankreuzfeld">
|
<label class="ankreuzfeld">
|
||||||
<input type="checkbox" name="banking_regeln_beim_abruf" value="1"
|
<input type="checkbox" name="banking_regeln_beim_abruf" value="1"
|
||||||
<?= $wert('banking_regeln_beim_abruf', '1') === '1' ? 'checked' : '' ?>>
|
<?= $wert('banking_regeln_beim_abruf', '1') === '1' ? 'checked' : '' ?>>
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,21 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<th colspan="6"><?= View::h($gruppe['name']) ?></th>
|
<th colspan="6"><?= View::h($gruppe['name']) ?></th>
|
||||||
</tr>
|
</tr>
|
||||||
<?php foreach ($gruppe['konten'] as $konto): ?>
|
<?php foreach ($gruppe['konten'] as $konto): ?>
|
||||||
<tr<?= (int)$konto['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
|
<?php
|
||||||
|
// Fett steht, wo noch Arbeit liegt: Buchungen aus diesem Jahr
|
||||||
|
// ohne Kategorie – typischerweise die, die der Bankabruf selbst
|
||||||
|
// gebucht hat.
|
||||||
|
$offene = (int)($konto['unkategorisiert'] ?? 0);
|
||||||
|
$klassen = array_filter([
|
||||||
|
(int)$konto['aktiv'] === 1 ? '' : 'inaktiv',
|
||||||
|
$offene > 0 ? 'unkategorisiert' : '',
|
||||||
|
]);
|
||||||
|
?>
|
||||||
|
<tr<?= $klassen === [] ? '' : ' class="' . implode(' ', $klassen) . '"' ?>>
|
||||||
<td>
|
<td>
|
||||||
<a href="/konten/<?= (int)$konto['id'] ?>"><?= View::h($konto['name']) ?></a>
|
<a href="/konten/<?= (int)$konto['id'] ?>"
|
||||||
|
<?= $offene > 0 ? 'title="' . $offene . ' Buchung(en) aus '
|
||||||
|
. date('Y') . ' ohne Kategorie"' : '' ?>><?= View::h($konto['name']) ?></a>
|
||||||
<?php if ((int)$konto['aktiv'] !== 1): ?>
|
<?php if ((int)$konto['aktiv'] !== 1): ?>
|
||||||
<span class="marke">inaktiv</span>
|
<span class="marke">inaktiv</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
@ -92,8 +104,9 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
|
|
||||||
<p class="hinweis">
|
<p class="hinweis">
|
||||||
In die Summen zählen nur Konten mit Σ-Kennzeichen – wie im FinanzManager. Der
|
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.
|
Online-Saldo stammt vom letzten Bankabruf, Depotwerte aus den Kursen.
|
||||||
Ein Klick auf den Kontonamen öffnet das Kontoblatt.
|
<strong>Fett</strong> stehen Konten mit Buchungen aus <?= date('Y') ?>, denen noch
|
||||||
|
eine Kategorie fehlt. Ein Klick auf den Kontonamen öffnet das Kontoblatt.
|
||||||
</p>
|
</p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ $istAktiv = static function (string $pfad) use ($aktuellerPfad): bool {
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>€</text></svg>">
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>€</text></svg>">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<?php include __DIR__ . '/meldungen.php'; ?>
|
||||||
<div class="rahmen">
|
<div class="rahmen">
|
||||||
<nav class="seitenleiste" aria-label="Hauptnavigation">
|
<nav class="seitenleiste" aria-label="Hauptnavigation">
|
||||||
<div class="marke">Finanzmanager</div>
|
<div class="marke">Finanzmanager</div>
|
||||||
|
|
@ -72,8 +73,3 @@ $istAktiv = static function (string $pfad) use ($aktuellerPfad): bool {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="inhalt">
|
<main class="inhalt">
|
||||||
<?php foreach ($flash ?? [] as $meldung): ?>
|
|
||||||
<div class="meldung meldung-<?= View::h($meldung['typ']) ?>" role="status">
|
|
||||||
<?= View::h($meldung['meldung']) ?>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
|
|
|
||||||
24
src/Views/layout/meldungen.php
Normal file
24
src/Views/layout/meldungen.php
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statusmeldungen als Einblendung über der Ansicht.
|
||||||
|
*
|
||||||
|
* Bewusst außerhalb des Inhalts: Die Meldungen liegen über der Seite und
|
||||||
|
* schieben sich von oben ins Bild, statt den Inhalt nach unten zu drücken und
|
||||||
|
* beim Verschwinden wieder hochschnappen zu lassen.
|
||||||
|
*/
|
||||||
|
/** @var array $flash */
|
||||||
|
|
||||||
|
if (($flash ?? []) === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="meldungen">
|
||||||
|
<?php foreach ($flash as $meldung): ?>
|
||||||
|
<div class="meldung meldung-<?= View::h($meldung['typ']) ?>" role="status">
|
||||||
|
<?= View::h($meldung['meldung']) ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
@ -20,13 +20,9 @@ use Finanzmanager\View;
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>€</text></svg>">
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>€</text></svg>">
|
||||||
</head>
|
</head>
|
||||||
<body class="schlicht">
|
<body class="schlicht">
|
||||||
|
<?php include __DIR__ . '/meldungen.php'; ?>
|
||||||
<div class="karte-mitte">
|
<div class="karte-mitte">
|
||||||
<div class="marke gross">Finanzmanager</div>
|
<div class="marke gross">Finanzmanager</div>
|
||||||
<?php foreach ($flash ?? [] as $meldung): ?>
|
|
||||||
<div class="meldung meldung-<?= View::h($meldung['typ']) ?>" role="status">
|
|
||||||
<?= View::h($meldung['meldung']) ?>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?= $inhalt ?>
|
<?= $inhalt ?>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,10 @@ class BankingTest extends TestCase
|
||||||
// array_key_exists, nicht ??: Ein ausdrückliches null steht hier für
|
// array_key_exists, nicht ??: Ein ausdrückliches null steht hier für
|
||||||
// „kein Konto zugeordnet“ und darf nicht auf das Giro zurückfallen.
|
// „kein Konto zugeordnet“ und darf nicht auf das Giro zurückfallen.
|
||||||
'konto_id' => array_key_exists('konto_id', $daten) ? $daten['konto_id'] : $this->giro,
|
'konto_id' => array_key_exists('konto_id', $daten) ? $daten['konto_id'] : $this->giro,
|
||||||
|
// Dieser Test kennt kein Bankkonto – die Umsätze müssen nur
|
||||||
|
// untereinander eindeutig sein, dafür reicht eine feste Kennung.
|
||||||
'extern_id' => $daten['extern_id'] ?? Bankumsatz::externId(
|
'extern_id' => $daten['extern_id'] ?? Bankumsatz::externId(
|
||||||
'DE02120300000000202051',
|
1,
|
||||||
(string)$daten['buchungstag'],
|
(string)$daten['buchungstag'],
|
||||||
(float)$daten['betrag'],
|
(float)$daten['betrag'],
|
||||||
(string)($daten['verwendungszweck'] ?? ''),
|
(string)($daten['verwendungszweck'] ?? ''),
|
||||||
|
|
|
||||||
486
tests/19_KontenzuordnungTest.php
Normal file
486
tests/19_KontenzuordnungTest.php
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Finanzmanager\Controllers\BankingController;
|
||||||
|
use Finanzmanager\Database;
|
||||||
|
use Finanzmanager\Models\AbgleichRegel;
|
||||||
|
use Finanzmanager\Models\Bankumsatz;
|
||||||
|
use Finanzmanager\Models\Bankzugang;
|
||||||
|
use Finanzmanager\Models\Buchung;
|
||||||
|
use Finanzmanager\Models\Einstellung;
|
||||||
|
use Finanzmanager\Models\Kategorie;
|
||||||
|
use Finanzmanager\Models\Konto;
|
||||||
|
use Finanzmanager\Models\Monatsabschluss;
|
||||||
|
use Finanzmanager\Services\Bankabgleich;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kontenzuordnung und automatisches Buchen.
|
||||||
|
*
|
||||||
|
* Der Weg, den ein Umsatz nimmt, wenn die Zuordnung Bankkonto → eigenes Konto
|
||||||
|
* erst nach dem Abruf getroffen wird – und was danach von allein im Kontoblatt
|
||||||
|
* landet. Der FinTS-Dialog selbst bleibt außen vor: Geprüft wird, was mit seinem
|
||||||
|
* Ergebnis geschieht.
|
||||||
|
*/
|
||||||
|
class KontenzuordnungTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 19: Kontenzuordnung und automatisches Buchen';
|
||||||
|
|
||||||
|
private int $giro = 0;
|
||||||
|
private int $zweitkonto = 0;
|
||||||
|
private int $strom = 0;
|
||||||
|
private int $zugang = 0;
|
||||||
|
private int $bankkonto = 0;
|
||||||
|
private int $zweitesBankkonto = 0;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->mandantAnlegen();
|
||||||
|
$this->stammdaten();
|
||||||
|
|
||||||
|
$this->zuordnungZiehtOffeneUmsaetzeNach();
|
||||||
|
$this->automatischesBuchenLegtBuchungenAn();
|
||||||
|
$this->automatischesBuchenVerbindetStattZuDoppeln();
|
||||||
|
$this->umsaetzeOhneKontoBleibenLiegen();
|
||||||
|
$this->einZeitverriegelterUmsatzBrichtDenLaufNichtAb();
|
||||||
|
$this->abgeschalteteAutomatikBuchtNicht();
|
||||||
|
$this->allesUebernehmenBleibtZurueckhaltend();
|
||||||
|
$this->unkategorisierteZaehlenJeKonto();
|
||||||
|
$this->kontenuebersichtKenntDieOffenenBuchungen();
|
||||||
|
$this->dieEinrichtungEndetInDerUebersicht();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stammdaten(): void
|
||||||
|
{
|
||||||
|
$this->giro = Konto::anlegen([
|
||||||
|
'name' => 'Zuordnungsgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
$this->zweitkonto = Konto::anlegen([
|
||||||
|
'name' => 'Zuordnungssparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->strom = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Strom Zuordnung', 'ausgabe');
|
||||||
|
|
||||||
|
$this->zugang = Bankzugang::anlegen([
|
||||||
|
'name' => 'Zuordnungsbank',
|
||||||
|
'bankname' => 'Zuordnungsbank AG',
|
||||||
|
'blz' => '12030000',
|
||||||
|
'fints_url' => 'https://banking.example.org/fints',
|
||||||
|
'benutzerkennung' => 'testkunde',
|
||||||
|
'pin' => 'geheim1234',
|
||||||
|
'abrufzeitraum' => 'letzter_abruf',
|
||||||
|
'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// So legt der Abruf ein bei der Bank gefundenes Konto an: erst einmal
|
||||||
|
// ohne Zuordnung, denn welches eigene Konto gemeint ist, weiß nur der
|
||||||
|
// Nutzer.
|
||||||
|
$this->bankkonto = (int)Bankzugang::findeOderLegeKontoAn(
|
||||||
|
$this->zugang,
|
||||||
|
'DE29120300000000202052',
|
||||||
|
'202052',
|
||||||
|
'Girokonto'
|
||||||
|
)['id'];
|
||||||
|
$this->zweitesBankkonto = (int)Bankzugang::findeOderLegeKontoAn(
|
||||||
|
$this->zugang,
|
||||||
|
'DE44120300000000202053',
|
||||||
|
'202053',
|
||||||
|
'Sparkonto'
|
||||||
|
)['id'];
|
||||||
|
|
||||||
|
Einstellung::set('banking_automatisch_buchen', '1', 'banking');
|
||||||
|
Einstellung::set('banking_abgleich_tage', '5', 'banking');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $daten */
|
||||||
|
private function umsatzAnlegen(array $daten): int
|
||||||
|
{
|
||||||
|
$id = Bankumsatz::anlegen([
|
||||||
|
'zugang_id' => $this->zugang,
|
||||||
|
'konto_id' => $daten['konto_id'] ?? null,
|
||||||
|
'bank_konto_id' => $daten['bank_konto_id'] ?? $this->bankkonto,
|
||||||
|
'extern_id' => Bankumsatz::externId(
|
||||||
|
(int)($daten['bank_konto_id'] ?? $this->bankkonto),
|
||||||
|
(string)$daten['buchungstag'],
|
||||||
|
(float)$daten['betrag'],
|
||||||
|
(string)($daten['verwendungszweck'] ?? ''),
|
||||||
|
(string)($daten['empfaenger'] ?? '')
|
||||||
|
),
|
||||||
|
'buchungstag' => $daten['buchungstag'],
|
||||||
|
'wertstellung' => $daten['buchungstag'],
|
||||||
|
'betrag' => $daten['betrag'],
|
||||||
|
'empfaenger' => $daten['empfaenger'] ?? '',
|
||||||
|
'verwendungszweck' => $daten['verwendungszweck'] ?? '',
|
||||||
|
'buchungsart' => 'LASTSCHRIFT',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNotEmpty($id, 'Umsatz angelegt: ' . ($daten['verwendungszweck'] ?? ''));
|
||||||
|
|
||||||
|
return (int)$id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function heute(string $tage = ''): string
|
||||||
|
{
|
||||||
|
return (new \DateTimeImmutable($tage === '' ? 'today' : 'today ' . $tage))->format('Y-m-d');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Kern des dritten Einrichtungsschritts: Umsätze, die vor der Zuordnung
|
||||||
|
* hereinkamen, dürfen nicht ohne Konto stehen bleiben – sonst ließen sie
|
||||||
|
* sich nie buchen.
|
||||||
|
*/
|
||||||
|
private function zuordnungZiehtOffeneUmsaetzeNach(): void
|
||||||
|
{
|
||||||
|
$offen = $this->umsatzAnlegen([
|
||||||
|
'buchungstag' => $this->heute('-3 days'), 'betrag' => -49.90,
|
||||||
|
'empfaenger' => 'Stadtwerke Zuordnung', 'verwendungszweck' => 'Abschlag Strom',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNull(
|
||||||
|
Bankumsatz::find($offen)['konto_id'],
|
||||||
|
'Vor der Zuordnung hat der Umsatz kein Konto'
|
||||||
|
);
|
||||||
|
|
||||||
|
Bankzugang::ordneKontoZu($this->bankkonto, $this->giro);
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
$this->giro,
|
||||||
|
(int)Bankumsatz::find($offen)['konto_id'],
|
||||||
|
'Mit der Zuordnung bekommt der offene Umsatz sein Konto'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ein Umsatz des anderen Bankkontos darf davon nichts abbekommen.
|
||||||
|
$fremd = $this->umsatzAnlegen([
|
||||||
|
'bank_konto_id' => $this->zweitesBankkonto,
|
||||||
|
'buchungstag' => $this->heute('-3 days'), 'betrag' => -7.10,
|
||||||
|
'empfaenger' => 'Sparkonto', 'verwendungszweck' => 'Anderes Bankkonto',
|
||||||
|
]);
|
||||||
|
$this->assertNull(
|
||||||
|
Bankumsatz::find($fremd)['konto_id'],
|
||||||
|
'Die Zuordnung greift nur auf das Bankkonto, um das es geht'
|
||||||
|
);
|
||||||
|
|
||||||
|
Bankzugang::ordneKontoZu($this->zweitesBankkonto, $this->zweitkonto);
|
||||||
|
$this->assertEquals(
|
||||||
|
$this->zweitkonto,
|
||||||
|
(int)Bankumsatz::find($fremd)['konto_id'],
|
||||||
|
'Und dann auch dort'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Übernommene bleiben, wo sie sind: Ihre Buchung steht bereits in einem
|
||||||
|
// Kontoblatt, ein Kontowechsel würde sie dort wegziehen.
|
||||||
|
$gebucht = $this->umsatzAnlegen([
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'buchungstag' => $this->heute('-2 days'), 'betrag' => -11.00,
|
||||||
|
'empfaenger' => 'Bäcker', 'verwendungszweck' => 'Schon gebucht',
|
||||||
|
]);
|
||||||
|
Bankumsatz::uebernehmen($gebucht, ['kategorie_id' => $this->strom]);
|
||||||
|
|
||||||
|
Bankzugang::ordneKontoZu($this->bankkonto, $this->zweitkonto);
|
||||||
|
$this->assertEquals(
|
||||||
|
$this->giro,
|
||||||
|
(int)Bankumsatz::find($gebucht)['konto_id'],
|
||||||
|
'Ein übernommener Umsatz wandert nicht mit'
|
||||||
|
);
|
||||||
|
|
||||||
|
Bankzugang::ordneKontoZu($this->bankkonto, $this->giro);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function automatischesBuchenLegtBuchungenAn(): void
|
||||||
|
{
|
||||||
|
AbgleichRegel::anlegen([
|
||||||
|
'name' => 'Strom Zuordnung', 'feld' => 'verwendungszweck', 'vergleich' => 'enthaelt',
|
||||||
|
'muster' => 'Abschlag Strom', 'kategorie_id' => $this->strom, 'prioritaet' => 10, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ohneRegel = $this->umsatzAnlegen([
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'buchungstag' => $this->heute('-1 day'), 'betrag' => -23.45,
|
||||||
|
'empfaenger' => 'Unbekannt GmbH', 'verwendungszweck' => 'Keine Regel trifft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$vorher = Buchung::anzahl();
|
||||||
|
$ergebnis = Bankabgleich::automatisch();
|
||||||
|
|
||||||
|
$this->assertNotEmpty($ergebnis, 'Eingeschaltet läuft der automatische Abgleich');
|
||||||
|
$this->assertEquals(0, $ergebnis['offen'], 'Nichts bleibt liegen');
|
||||||
|
$this->assertEquals(
|
||||||
|
$vorher + $ergebnis['uebernommen'],
|
||||||
|
Buchung::anzahl(),
|
||||||
|
'Für jeden übernommenen Umsatz steht eine Buchung mehr da'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Der Umsatz mit Regel bekommt seine Kategorie …
|
||||||
|
$mitRegel = Bankumsatz::liste(['status' => 'uebernommen']);
|
||||||
|
$strom = null;
|
||||||
|
foreach ($mitRegel as $eintrag) {
|
||||||
|
if (str_contains((string)$eintrag['verwendungszweck'], 'Abschlag Strom')) {
|
||||||
|
$strom = $eintrag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->assertNotEmpty($strom, 'Der Umsatz mit Regel ist übernommen');
|
||||||
|
$this->assertEquals(
|
||||||
|
$this->strom,
|
||||||
|
(int)Buchung::find((int)$strom['buchung_id'])['kategorie_id'],
|
||||||
|
'Die Kategorie kommt aus der Regel'
|
||||||
|
);
|
||||||
|
|
||||||
|
// … der ohne Regel wird trotzdem gebucht. Genau dafür ist die Automatik
|
||||||
|
// da: Der Umsatz soll im Kontoblatt stehen, die Kategorie kommt später.
|
||||||
|
$buchungOhneRegel = Buchung::find((int)Bankumsatz::find($ohneRegel)['buchung_id']);
|
||||||
|
$this->assertEquals(
|
||||||
|
'uebernommen',
|
||||||
|
Bankumsatz::find($ohneRegel)['status'],
|
||||||
|
'Auch ohne Regel wird gebucht'
|
||||||
|
);
|
||||||
|
$this->assertNull($buchungOhneRegel['kategorie_id'], 'Aber ohne Kategorie');
|
||||||
|
$this->assertEquals($this->giro, (int)$buchungOhneRegel['konto_id'], 'Im zugeordneten Konto');
|
||||||
|
$this->assertEquals('fints', $buchungOhneRegel['quelle'], 'Und mit dem Bankabruf als Quelle');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wer seine Buchungen selbst erfasst, will sie nicht doppelt sehen. */
|
||||||
|
private function automatischesBuchenVerbindetStattZuDoppeln(): void
|
||||||
|
{
|
||||||
|
$buchungId = Buchung::anlegen([
|
||||||
|
'konto_id' => $this->giro, 'datum' => $this->heute('-4 days'), 'betrag' => -128.00,
|
||||||
|
'empfaenger' => 'Versicherung', 'kategorie_id' => $this->strom,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$umsatz = $this->umsatzAnlegen([
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'buchungstag' => $this->heute('-2 days'), 'betrag' => -128.00,
|
||||||
|
'empfaenger' => 'Versicherung AG', 'verwendungszweck' => 'Beitrag',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$vorher = Buchung::anzahl();
|
||||||
|
$ergebnis = Bankabgleich::automatisch();
|
||||||
|
|
||||||
|
$this->assertEquals(1, $ergebnis['verbunden'], 'Der Umsatz wurde verbunden');
|
||||||
|
$this->assertEquals($vorher, Buchung::anzahl(), 'Und keine zweite Buchung angelegt');
|
||||||
|
$this->assertEquals(
|
||||||
|
$buchungId,
|
||||||
|
(int)Bankumsatz::find($umsatz)['buchung_id'],
|
||||||
|
'Er zeigt auf die selbst erfasste Buchung'
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
1,
|
||||||
|
(int)Buchung::find($buchungId)['abgeglichen'],
|
||||||
|
'Die gilt damit als abgeglichen'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function umsaetzeOhneKontoBleibenLiegen(): void
|
||||||
|
{
|
||||||
|
// Ein Bankkonto ohne Zuordnung – etwa ein Depot, das nicht mitgeführt wird.
|
||||||
|
$ohne = Bankzugang::findeOderLegeKontoAn($this->zugang, 'DE60120300000000202054', '202054', 'Ohne');
|
||||||
|
|
||||||
|
$umsatz = $this->umsatzAnlegen([
|
||||||
|
'bank_konto_id' => (int)$ohne['id'],
|
||||||
|
'buchungstag' => $this->heute('-1 day'), 'betrag' => -9.99,
|
||||||
|
'empfaenger' => 'Ohne Zuordnung', 'verwendungszweck' => 'Bleibt offen',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ergebnis = Bankabgleich::automatisch();
|
||||||
|
|
||||||
|
$this->assertEquals(1, $ergebnis['offen'], 'Ein Umsatz ohne Konto bleibt offen');
|
||||||
|
$this->assertEquals(
|
||||||
|
'offen',
|
||||||
|
Bankumsatz::find($umsatz)['status'],
|
||||||
|
'Und steht weiter in der Vorschlagsliste'
|
||||||
|
);
|
||||||
|
$this->assertEquals([], $ergebnis['fehler'], 'Ohne Fehlermeldung – das ist kein Fehler');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Umsatz, der sich nicht buchen lässt, darf die übrigen nicht mitreißen.
|
||||||
|
* Der abgeschlossene Monat ist der greifbarste Fall davon.
|
||||||
|
*/
|
||||||
|
private function einZeitverriegelterUmsatzBrichtDenLaufNichtAb(): void
|
||||||
|
{
|
||||||
|
$jahr = (int)date('Y') - 1;
|
||||||
|
$datum = sprintf('%d-03-15', $jahr);
|
||||||
|
|
||||||
|
Monatsabschluss::abschliessen($jahr, 3, 'Test');
|
||||||
|
|
||||||
|
$verriegelt = $this->umsatzAnlegen([
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'buchungstag' => $datum, 'betrag' => -31.00,
|
||||||
|
'empfaenger' => 'Aus dem Archiv', 'verwendungszweck' => 'Abgeschlossener Monat',
|
||||||
|
]);
|
||||||
|
$daneben = $this->umsatzAnlegen([
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'buchungstag' => $this->heute(), 'betrag' => -6.60,
|
||||||
|
'empfaenger' => 'Heute', 'verwendungszweck' => 'Geht durch',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ergebnis = Bankabgleich::automatisch();
|
||||||
|
|
||||||
|
$this->assertEquals(1, count($ergebnis['fehler']), 'Ein Umsatz ließ sich nicht buchen');
|
||||||
|
$this->assertEquals(
|
||||||
|
'offen',
|
||||||
|
Bankumsatz::find($verriegelt)['status'],
|
||||||
|
'Er bleibt offen und wartet auf die Wiedereröffnung'
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
'uebernommen',
|
||||||
|
Bankumsatz::find($daneben)['status'],
|
||||||
|
'Der Umsatz daneben wurde trotzdem gebucht'
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
str_contains(Bankabgleich::bericht($ergebnis), 'Nicht gebucht'),
|
||||||
|
'Der Bericht nennt den Fall'
|
||||||
|
);
|
||||||
|
|
||||||
|
$abschluss = Monatsabschluss::fuerMonat($jahr, 3);
|
||||||
|
Monatsabschluss::wiedereroeffnen((int)$abschluss['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function abgeschalteteAutomatikBuchtNicht(): void
|
||||||
|
{
|
||||||
|
Einstellung::set('banking_automatisch_buchen', '0', 'banking');
|
||||||
|
|
||||||
|
$umsatz = $this->umsatzAnlegen([
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'buchungstag' => $this->heute(), 'betrag' => -4.20,
|
||||||
|
'empfaenger' => 'Kiosk', 'verwendungszweck' => 'Von Hand abgleichen',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNull(Bankabgleich::automatisch(), 'Abgeschaltet läuft nichts');
|
||||||
|
$this->assertEquals(
|
||||||
|
'offen',
|
||||||
|
Bankumsatz::find($umsatz)['status'],
|
||||||
|
'Der Umsatz wartet auf den Abgleich'
|
||||||
|
);
|
||||||
|
|
||||||
|
Einstellung::set('banking_automatisch_buchen', '1', 'banking');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Knopf „Alles übernehmen“ ist zurückhaltender als die Automatik: Ohne
|
||||||
|
* Regel bleibt der Umsatz liegen, damit die Zuordnung beim Nutzer bleibt.
|
||||||
|
*/
|
||||||
|
private function allesUebernehmenBleibtZurueckhaltend(): void
|
||||||
|
{
|
||||||
|
$ergebnis = Bankabgleich::laufenLassen(false);
|
||||||
|
|
||||||
|
$this->assertEquals(0, $ergebnis['uebernommen'], 'Ohne Regel wird nichts gebucht');
|
||||||
|
$this->assertGreaterThan(0, $ergebnis['offen'], 'Die Umsätze bleiben offen');
|
||||||
|
|
||||||
|
// Mit Regel greift derselbe Lauf dann doch.
|
||||||
|
AbgleichRegel::anlegen([
|
||||||
|
'name' => 'Kiosk Zuordnung', 'feld' => 'empfaenger', 'vergleich' => 'enthaelt',
|
||||||
|
'muster' => 'Kiosk', 'kategorie_id' => $this->strom, 'prioritaet' => 10, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mitRegel = Bankabgleich::laufenLassen(false);
|
||||||
|
$this->assertEquals(1, $mitRegel['uebernommen'], 'Mit Regel wird gebucht');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unkategorisierteZaehlenJeKonto(): void
|
||||||
|
{
|
||||||
|
$jahr = (int)date('Y');
|
||||||
|
|
||||||
|
$vorher = Buchung::unkategorisierteJeKonto($jahr)[$this->giro] ?? 0;
|
||||||
|
|
||||||
|
Buchung::anlegen([
|
||||||
|
'konto_id' => $this->giro, 'datum' => $this->heute(), 'betrag' => -15.00,
|
||||||
|
'empfaenger' => 'Ohne Kategorie',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
$vorher + 1,
|
||||||
|
Buchung::unkategorisierteJeKonto($jahr)[$this->giro] ?? 0,
|
||||||
|
'Eine Buchung ohne Kategorie zählt mit'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Eine Umbuchung ist zugeordnet, auch ohne Kategorie.
|
||||||
|
Buchung::anlegen([
|
||||||
|
'konto_id' => $this->giro, 'datum' => $this->heute(), 'betrag' => -100.00,
|
||||||
|
'empfaenger' => 'Umbuchung', 'gegenkonto_id' => $this->zweitkonto,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Eine Splitbuchung trägt ihre Zuordnung in den Zeilen.
|
||||||
|
Buchung::anlegen(
|
||||||
|
[
|
||||||
|
'konto_id' => $this->giro, 'datum' => $this->heute(), 'betrag' => -50.00,
|
||||||
|
'empfaenger' => 'Gesplittet',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
['kategorie_id' => $this->strom, 'betrag' => -30.00],
|
||||||
|
['kategorie_id' => $this->strom, 'betrag' => -20.00],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
$vorher + 1,
|
||||||
|
Buchung::unkategorisierteJeKonto($jahr)[$this->giro] ?? 0,
|
||||||
|
'Umbuchung und Splitbuchung zählen nicht als unkategorisiert'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Das Vorjahr bleibt außen vor – die Übersicht zeigt das laufende Jahr.
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE buchungen SET datum = ? WHERE benutzer_id = ? AND konto_id = ?
|
||||||
|
AND kategorie_id IS NULL AND gegenkonto_id IS NULL AND gesplittet = 0',
|
||||||
|
[($jahr - 1) . '-06-15', \Finanzmanager\Mandant::id(), $this->giro]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
0,
|
||||||
|
Buchung::unkategorisierteJeKonto($jahr)[$this->giro] ?? 0,
|
||||||
|
'Buchungen aus dem Vorjahr zählen im laufenden Jahr nicht'
|
||||||
|
);
|
||||||
|
$this->assertGreaterThan(
|
||||||
|
0,
|
||||||
|
Buchung::unkategorisierteJeKonto($jahr - 1)[$this->giro] ?? 0,
|
||||||
|
'Dafür im Vorjahr'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function kontenuebersichtKenntDieOffenenBuchungen(): void
|
||||||
|
{
|
||||||
|
Buchung::anlegen([
|
||||||
|
'konto_id' => $this->zweitkonto, 'datum' => $this->heute(), 'betrag' => -8.00,
|
||||||
|
'empfaenger' => 'Frisch abgerufen', 'quelle' => 'fints',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$uebersicht = Konto::uebersicht();
|
||||||
|
|
||||||
|
$gefunden = [];
|
||||||
|
foreach ($uebersicht['gruppen'] as $gruppe) {
|
||||||
|
foreach ($gruppe['konten'] as $konto) {
|
||||||
|
$gefunden[(int)$konto['id']] = $konto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertNotEmpty($gefunden[$this->zweitkonto] ?? null, 'Das Konto steht in der Übersicht');
|
||||||
|
$this->assertGreaterThan(
|
||||||
|
0,
|
||||||
|
(int)$gefunden[$this->zweitkonto]['unkategorisiert'],
|
||||||
|
'Und meldet seine Buchung ohne Kategorie – die Übersicht setzt es fett'
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
0,
|
||||||
|
(int)($gefunden[$this->giro]['unkategorisiert'] ?? -1),
|
||||||
|
'Das aufgeräumte Konto bleibt normal'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der letzte Schritt der Einrichtung führt zurück in die Übersicht der
|
||||||
|
* Bankverbindungen, nicht in die Zugangsmaske: Dort ist alles erledigt, was
|
||||||
|
* sie anbietet.
|
||||||
|
*/
|
||||||
|
private function dieEinrichtungEndetInDerUebersicht(): void
|
||||||
|
{
|
||||||
|
$this->assertEquals(
|
||||||
|
'/banking',
|
||||||
|
BankingController::zielNachZuordnung($this->zugang, true),
|
||||||
|
'Nach der Einrichtung geht es in die Übersicht der Bankverbindungen'
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
'/banking/' . $this->zugang . '/konten',
|
||||||
|
BankingController::zielNachZuordnung($this->zugang, false),
|
||||||
|
'Die einzeln aufgerufene Zuordnung bleibt, wo sie ist'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
213
tests/20_VerwaisteUmsaetzeTest.php
Normal file
213
tests/20_VerwaisteUmsaetzeTest.php
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Finanzmanager\Database;
|
||||||
|
use Finanzmanager\Models\Bankumsatz;
|
||||||
|
use Finanzmanager\Models\Bankzugang;
|
||||||
|
use Finanzmanager\Models\Buchung;
|
||||||
|
use Finanzmanager\Models\Konto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Umsätze, die ihre Buchung verlieren – und Bankkonten, die doppelt entstehen.
|
||||||
|
*
|
||||||
|
* Beides führte zum selben Schaden: Umsätze, die nirgends mehr auftauchen. Der
|
||||||
|
* Abruf meldete sie als schon vorhanden, im Kontoblatt stand nichts, und die
|
||||||
|
* Vorschlagsliste zeigte sie nicht, weil sie noch auf „uebernommen“ standen.
|
||||||
|
*/
|
||||||
|
class VerwaisteUmsaetzeTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 20: Verwaiste Umsätze und doppelte Bankkonten';
|
||||||
|
|
||||||
|
private int $giro = 0;
|
||||||
|
private int $zugang = 0;
|
||||||
|
private int $bankkonto = 0;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->mandantAnlegen();
|
||||||
|
$this->stammdaten();
|
||||||
|
|
||||||
|
$this->geloeschteBuchungGibtDenUmsatzFrei();
|
||||||
|
$this->verwaisterUmsatzWirdWiederGeoeffnet();
|
||||||
|
$this->fingerabdruckHaengtNichtAnDerIban();
|
||||||
|
$this->bankkontoOhneIbanFindetSichUeberDieKontonummer();
|
||||||
|
$this->nachgelieferteIbanWirdNachgetragen();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stammdaten(): void
|
||||||
|
{
|
||||||
|
$this->giro = Konto::anlegen([
|
||||||
|
'name' => 'Verwaisungsgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->zugang = Bankzugang::anlegen([
|
||||||
|
'name' => 'Verwaisungsbank',
|
||||||
|
'bankname' => 'Verwaisungsbank AG',
|
||||||
|
'blz' => '20041111',
|
||||||
|
'fints_url' => 'https://banking.example.org/fints',
|
||||||
|
'benutzerkennung' => 'testkunde',
|
||||||
|
'pin' => 'geheim1234',
|
||||||
|
'abrufzeitraum' => 'letzter_abruf',
|
||||||
|
'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->bankkonto = (int)Bankzugang::findeOderLegeKontoAn(
|
||||||
|
$this->zugang,
|
||||||
|
'DE53200411110847626900',
|
||||||
|
'847626900',
|
||||||
|
'Girokonto'
|
||||||
|
)['id'];
|
||||||
|
|
||||||
|
Bankzugang::ordneKontoZu($this->bankkonto, $this->giro);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function umsatzAnlegen(string $zweck, float $betrag = -12.34): int
|
||||||
|
{
|
||||||
|
$id = Bankumsatz::anlegen([
|
||||||
|
'zugang_id' => $this->zugang,
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'bank_konto_id' => $this->bankkonto,
|
||||||
|
'extern_id' => Bankumsatz::externId($this->bankkonto, '2026-08-05', $betrag, $zweck),
|
||||||
|
'buchungstag' => '2026-08-05',
|
||||||
|
'wertstellung' => '2026-08-05',
|
||||||
|
'betrag' => $betrag,
|
||||||
|
'empfaenger' => 'Testempfänger',
|
||||||
|
'verwendungszweck' => $zweck,
|
||||||
|
'buchungsart' => 'LASTSCHRIFT',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNotEmpty($id, 'Umsatz angelegt: ' . $zweck);
|
||||||
|
|
||||||
|
return (int)$id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function status(int $umsatzId): string
|
||||||
|
{
|
||||||
|
return (string)Database::fetchValue(
|
||||||
|
'SELECT status FROM bank_umsaetze WHERE id = ?',
|
||||||
|
[$umsatzId],
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der eigentliche Fehler: Wer eine gebuchte Zahlung im Kontoblatt löscht,
|
||||||
|
* bekam den Umsatz nie wieder zu Gesicht.
|
||||||
|
*/
|
||||||
|
private function geloeschteBuchungGibtDenUmsatzFrei(): void
|
||||||
|
{
|
||||||
|
$umsatz = $this->umsatzAnlegen('Strom August');
|
||||||
|
$buchungId = Bankumsatz::uebernehmen($umsatz, []);
|
||||||
|
|
||||||
|
$this->assertEquals('uebernommen', $this->status($umsatz), 'Nach dem Übernehmen ist der Umsatz übernommen');
|
||||||
|
|
||||||
|
Buchung::loeschen((int)$buchungId);
|
||||||
|
|
||||||
|
$this->assertEquals('offen', $this->status($umsatz), 'Gelöschte Buchung gibt den Umsatz wieder frei');
|
||||||
|
$this->assertNull(
|
||||||
|
Database::fetchValue('SELECT buchung_id FROM bank_umsaetze WHERE id = ?', [$umsatz]),
|
||||||
|
'Der Umsatz zeigt auf keine Buchung mehr'
|
||||||
|
);
|
||||||
|
|
||||||
|
$offene = array_column(Bankumsatz::liste(['status' => 'offen']), 'id');
|
||||||
|
$this->assert(in_array((string)$umsatz, array_map('strval', $offene), true), 'Der Umsatz steht wieder in der Vorschlagsliste');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nicht jede Buchung verschwindet über `Buchung::loeschen()`: Wird ein Konto
|
||||||
|
* gelöscht, räumt der Fremdschlüssel seine Buchungen per CASCADE ab – und
|
||||||
|
* Kaskaden lösen in MariaDB keine Trigger aus. Dieser Zustand muss sich von
|
||||||
|
* selbst wieder auflösen.
|
||||||
|
*/
|
||||||
|
private function verwaisterUmsatzWirdWiederGeoeffnet(): void
|
||||||
|
{
|
||||||
|
$umsatz = $this->umsatzAnlegen('Kaskadenlastschrift', -8.39);
|
||||||
|
Bankumsatz::uebernehmen($umsatz, []);
|
||||||
|
|
||||||
|
// Genau der Zustand, den eine Kaskade hinterlässt.
|
||||||
|
Database::execute(
|
||||||
|
"UPDATE bank_umsaetze SET buchung_id = NULL WHERE id = ?",
|
||||||
|
[$umsatz]
|
||||||
|
);
|
||||||
|
$this->assertEquals('uebernommen', $this->status($umsatz), 'Die Kaskade lässt den Status stehen');
|
||||||
|
|
||||||
|
$anzahl = Bankumsatz::verwaisteOeffnen();
|
||||||
|
|
||||||
|
$this->assertGreaterThan(0, $anzahl, 'Mindestens ein verwaister Umsatz wurde geöffnet');
|
||||||
|
$this->assertEquals('offen', $this->status($umsatz), 'Der verwaiste Umsatz ist wieder offen');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Comdirect nennt die IBAN beim Kontenabruf, beim Umsatzabruf aber
|
||||||
|
* nicht. Hing der Fingerabdruck daran, kam derselbe Umsatz ein zweites Mal
|
||||||
|
* herein.
|
||||||
|
*/
|
||||||
|
private function fingerabdruckHaengtNichtAnDerIban(): void
|
||||||
|
{
|
||||||
|
$mitIban = Bankumsatz::externId($this->bankkonto, '2026-08-07', -22.00, 'Kiosk');
|
||||||
|
$erneut = Bankumsatz::externId($this->bankkonto, '2026-08-07', -22.00, 'Kiosk');
|
||||||
|
|
||||||
|
$this->assertEquals($mitIban, $erneut, 'Derselbe Umsatz ergibt denselben Fingerabdruck');
|
||||||
|
|
||||||
|
$anderesKonto = Bankumsatz::externId($this->bankkonto + 1, '2026-08-07', -22.00, 'Kiosk');
|
||||||
|
$this->assert($mitIban !== $anderesKonto, 'Ein anderes Bankkonto ergibt einen anderen Fingerabdruck');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die Bank beim zweiten Dialog keine IBAN mehr, darf daraus kein
|
||||||
|
* zweites Bankkonto entstehen – sonst hätte das Konto zwei Zuordnungen und
|
||||||
|
* zwei Fingerabdrücke, und jeder Umsatz stünde doppelt da.
|
||||||
|
*/
|
||||||
|
private function bankkontoOhneIbanFindetSichUeberDieKontonummer(): void
|
||||||
|
{
|
||||||
|
$ohneIban = Bankzugang::findeOderLegeKontoAn($this->zugang, '', '847626900', '847626900');
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
$this->bankkonto,
|
||||||
|
(int)$ohneIban['id'],
|
||||||
|
'Ohne IBAN findet sich dasselbe Bankkonto über die Kontonummer'
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
'DE53200411110847626900',
|
||||||
|
(string)$ohneIban['iban'],
|
||||||
|
'Die einmal bekannte IBAN bleibt erhalten'
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
$this->giro,
|
||||||
|
(int)$ohneIban['konto_id'],
|
||||||
|
'Und mit ihr die Zuordnung zum eigenen Konto'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
1,
|
||||||
|
(int)Database::fetchValue(
|
||||||
|
'SELECT COUNT(*) FROM bank_konten WHERE zugang_id = ? AND kontonummer = ?',
|
||||||
|
[$this->zugang, '847626900'],
|
||||||
|
0
|
||||||
|
),
|
||||||
|
'Es gibt weiterhin nur einen Eintrag für dieses Konto'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der umgekehrte Weg: erst ohne IBAN gesehen, dann mit. */
|
||||||
|
private function nachgelieferteIbanWirdNachgetragen(): void
|
||||||
|
{
|
||||||
|
$ohneIban = Bankzugang::findeOderLegeKontoAn($this->zugang, '', '847626905', 'Tagesgeld');
|
||||||
|
$this->assertEquals('', (string)$ohneIban['iban'], 'Beim ersten Sehen ohne IBAN angelegt');
|
||||||
|
|
||||||
|
$mitIban = Bankzugang::findeOderLegeKontoAn(
|
||||||
|
$this->zugang,
|
||||||
|
'DE15200411110847626905',
|
||||||
|
'847626905',
|
||||||
|
'Tagesgeld'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals((int)$ohneIban['id'], (int)$mitIban['id'], 'Es bleibt dasselbe Bankkonto');
|
||||||
|
$this->assertEquals(
|
||||||
|
'DE15200411110847626905',
|
||||||
|
(string)Database::fetchValue('SELECT iban FROM bank_konten WHERE id = ?', [(int)$mitIban['id']], ''),
|
||||||
|
'Die nachgelieferte IBAN wurde eingetragen'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -160,7 +160,29 @@ test.describe('Buchungen', () => {
|
||||||
await expect(page.locator('tbody tr')).toHaveCount(1)
|
await expect(page.locator('tbody tr')).toHaveCount(1)
|
||||||
await expect(page.locator('tbody')).toContainText('Supermarkt')
|
await expect(page.locator('tbody')).toContainText('Supermarkt')
|
||||||
// Mit Filter ist die Saldospalte leer – ein laufender Saldo wäre gelogen.
|
// Mit Filter ist die Saldospalte leer – ein laufender Saldo wäre gelogen.
|
||||||
await expect(page.locator('tbody tr td').nth(7)).toContainText('–')
|
// Spalte 0 ist das Ankreuzfeld der Mehrfachauswahl.
|
||||||
|
await expect(page.locator('tbody tr td').nth(8)).toContainText('–')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Eine Buchung ohne Kategorie setzt ihr Konto in der Übersicht fett', async ({ page }) => {
|
||||||
|
// Heutiges Datum: Fett stehen nur Konten mit offenen Buchungen aus dem
|
||||||
|
// laufenden Jahr – ein festes Datum wäre nächstes Silvester falsch.
|
||||||
|
const heute = new Date().toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
await page.goto(`/konten/${sparId}/buchung`)
|
||||||
|
await page.selectOption('#art', 'ausgabe')
|
||||||
|
await page.fill('#datum', heute)
|
||||||
|
await page.fill('#betrag', '17,50')
|
||||||
|
await page.fill('#empfaenger', 'Noch zuzuordnen')
|
||||||
|
// Kategorie bleibt „— keine —“: genau der Zustand, den ein automatisch
|
||||||
|
// gebuchter Bankumsatz ohne passende Regel hinterlässt.
|
||||||
|
await page.click('.buchungsmaske button[type="submit"]')
|
||||||
|
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||||
|
|
||||||
|
await page.goto('/konten')
|
||||||
|
await expect(page.locator('tr.unkategorisiert', { hasText: SPAR })).toHaveCount(1)
|
||||||
|
await expect(page.locator('tr.unkategorisiert a', { hasText: SPAR }))
|
||||||
|
.toHaveAttribute('title', /ohne Kategorie/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Löschen fragt nach – trotz strenger Content-Security-Policy', async ({ page }) => {
|
test('Löschen fragt nach – trotz strenger Content-Security-Policy', async ({ page }) => {
|
||||||
|
|
@ -186,4 +208,41 @@ test.describe('Buchungen', () => {
|
||||||
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
|
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
|
||||||
await expect(page.locator('tbody')).not.toContainText('Supermarkt')
|
await expect(page.locator('tbody')).not.toContainText('Supermarkt')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Mehrere Buchungen über die Auswahl im Kontoblatt löschen', async ({ page }) => {
|
||||||
|
for (const empfaenger of ['Sammellöschung A', 'Sammellöschung B']) {
|
||||||
|
await page.goto(`/konten/${sparId}/buchung`)
|
||||||
|
await page.selectOption('#art', 'ausgabe')
|
||||||
|
await page.fill('#datum', '2026-04-02')
|
||||||
|
await page.fill('#betrag', '9,90')
|
||||||
|
await page.fill('#empfaenger', empfaenger)
|
||||||
|
await page.click('.buchungsmaske button[type="submit"]')
|
||||||
|
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(`/konten/${sparId}`)
|
||||||
|
|
||||||
|
// Ohne Auswahl bleibt der Knopf gesperrt.
|
||||||
|
const knopf = page.locator('#buchungsauswahl button')
|
||||||
|
await expect(knopf).toBeDisabled()
|
||||||
|
|
||||||
|
// Das Ankreuzfeld in der Titelzeile wählt alle angezeigten Buchungen.
|
||||||
|
const anzahl = await page.locator('tbody input[name="buchungen[]"]').count()
|
||||||
|
await page.check('.alle-waehlen')
|
||||||
|
await expect(knopf).toContainText(`(${anzahl})`)
|
||||||
|
|
||||||
|
// Wie beim einzelnen Löschen hängt die Rückfrage an data-bestaetigen –
|
||||||
|
// der Handler muss vor dem Klick stehen.
|
||||||
|
const gefragt = []
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
gefragt.push(dialog.message())
|
||||||
|
await dialog.accept()
|
||||||
|
})
|
||||||
|
|
||||||
|
await knopf.click()
|
||||||
|
|
||||||
|
expect(gefragt.join(' ')).toContain('löschen')
|
||||||
|
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
|
||||||
|
await expect(page.getByText('Sammellöschung')).toHaveCount(0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -96,18 +96,24 @@ test.describe('Module der Phasen 4 bis 7', () => {
|
||||||
await page.fill('#pin', 'geheim')
|
await page.fill('#pin', 'geheim')
|
||||||
|
|
||||||
// Schritt 1 speichert nur die Zugangsdaten.
|
// Schritt 1 speichert nur die Zugangsdaten.
|
||||||
await expect(page.locator('.schrittmarke')).toContainText('Schritt 1 von 2')
|
await expect(page.locator('.schrittmarke')).toContainText('Schritt 1 von 3')
|
||||||
await page.click('button:has-text("Weiter")')
|
await page.click('button:has-text("Weiter")')
|
||||||
|
|
||||||
// Schritt 2 fragt die Bank selbst nach ihren TAN-Verfahren. Die Adresse
|
// Schritt 2 fragt die Bank selbst nach ihren TAN-Verfahren. Die Adresse
|
||||||
// im Test führt nirgendwohin – geprüft wird deshalb der Fehlerweg: Die
|
// im Test führt nirgendwohin – geprüft wird deshalb der Fehlerweg: Die
|
||||||
// Seite erklärt, was zu tun ist, statt stehen zu bleiben.
|
// Seite erklärt, was zu tun ist, statt stehen zu bleiben.
|
||||||
await expect(page.locator('.schrittmarke')).toContainText('Schritt 2 von 2')
|
await expect(page.locator('.schrittmarke')).toContainText('Schritt 2 von 3')
|
||||||
await expect(page.locator('h1')).toContainText('E2E-Testbank')
|
await expect(page.locator('h1')).toContainText('E2E-Testbank')
|
||||||
await expect(page.locator('.meldung-fehler')).toBeVisible()
|
await expect(page.locator('.meldung-fehler')).toBeVisible()
|
||||||
await expect(page.locator('a:has-text("Zugangsdaten prüfen")')).toHaveCount(1)
|
await expect(page.locator('a:has-text("Zugangsdaten prüfen")')).toHaveCount(1)
|
||||||
|
|
||||||
|
// Wer die Einrichtung vorzeitig verlässt, landet in der Übersicht: Die
|
||||||
|
// Zugangsmaske böte nur noch an, was gerade durchlaufen wurde.
|
||||||
await page.click('a:has-text("Später einstellen")')
|
await page.click('a:has-text("Später einstellen")')
|
||||||
|
await expect(page.locator('h1')).toContainText('Bankverbindungen')
|
||||||
|
await expect(page.locator('td a:has-text("E2E-Testbank")')).toHaveCount(1)
|
||||||
|
|
||||||
|
await page.click('td a:has-text("E2E-Testbank")')
|
||||||
await expect(page.locator('h1')).toContainText('E2E-Testbank')
|
await expect(page.locator('h1')).toContainText('E2E-Testbank')
|
||||||
|
|
||||||
// Die PIN wird nie wieder angezeigt.
|
// Die PIN wird nie wieder angezeigt.
|
||||||
|
|
@ -117,6 +123,16 @@ test.describe('Module der Phasen 4 bis 7', () => {
|
||||||
// Ohne Wahl bleibt der Hinweis stehen, und die Seite ist wieder erreichbar.
|
// Ohne Wahl bleibt der Hinweis stehen, und die Seite ist wieder erreichbar.
|
||||||
await expect(page.locator('a:has-text("TAN-Verfahren einstellen")')).toHaveCount(1)
|
await expect(page.locator('a:has-text("TAN-Verfahren einstellen")')).toHaveCount(1)
|
||||||
|
|
||||||
|
// Schritt 3: die Kontenzuordnung. Ohne erreichbare Bank kennt sie noch
|
||||||
|
// kein Konto und bietet die Abfrage an, statt eine leere Tabelle zu zeigen.
|
||||||
|
await page.click('a:has-text("Konten zuordnen")')
|
||||||
|
await expect(page.locator('h1')).toContainText('Konten zuordnen')
|
||||||
|
await expect(page.locator('.platzhalter')).toContainText('Noch kein Konto bekannt')
|
||||||
|
await expect(page.locator('button:has-text("Konten bei der Bank abfragen")')).toHaveCount(1)
|
||||||
|
|
||||||
|
await page.click('a:has-text("Zurück zur Bankverbindung")')
|
||||||
|
await expect(page.locator('h1')).toContainText('E2E-Testbank')
|
||||||
|
|
||||||
page.once('dialog', dialog => dialog.accept())
|
page.once('dialog', dialog => dialog.accept())
|
||||||
await page.click('button:has-text("Bankverbindung löschen")')
|
await page.click('button:has-text("Bankverbindung löschen")')
|
||||||
await expect(page.locator('.meldung')).toContainText('gelöscht')
|
await expect(page.locator('.meldung')).toContainText('gelöscht')
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue