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