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:
fds2610 2026-08-12 13:05:51 +02:00
commit 888562d02d
32 changed files with 2094 additions and 166 deletions

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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.

View 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;

View file

@ -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());

View file

@ -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) ─────────────────────────── */

View file

@ -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);
}
});
})();

View file

@ -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,
]);
}
/**

View file

@ -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
{

View file

@ -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' => [

View file

@ -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()]);

View file

@ -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

View file

@ -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
{

View file

@ -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;

View 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;
}
}

View file

@ -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;
}
/**

View file

@ -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): ?>

View 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'; ?>

View file

@ -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; ?>

View file

@ -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>

View file

@ -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' : '' ?>>

View file

@ -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; ?>

View file

@ -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; ?>

View 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>

View file

@ -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>

View file

@ -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'] ?? ''),

View 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'
);
}
}

View 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'
);
}
}

View file

@ -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)
})
})

View file

@ -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')