Finanzmanager/docs/banking.md
fds2610-bot 78f1b91bc0 Online-Banking: Kontenzuordnung, Sammellöschen und drei Umsatz-Fehler
Kontenzuordnung als dritter Einrichtungsschritt: Welches Bankkonto gehört zu
welchem eigenen Konto? Umsätze merken sich über `bank_konto_id`, woher sie
stammen, und ziehen mit, wenn die Zuordnung erst nach einem Abruf fällt.
Abgerufene Umsätze buchen sich auf Wunsch selbst (`Services\Bankabgleich`).

Im Kontoblatt lassen sich Buchungen sammelweise löschen; Statusmeldungen liegen
jetzt über der Ansicht, statt den Inhalt springen zu lassen.

Dazu drei Fehler, die zusammen dafür sorgten, dass abgerufene Umsätze spurlos
verschwanden – der Abruf meldete sie als vorhanden, das Kontoblatt zeigte
nichts, und in der Vorschlagsliste standen sie ebenfalls nicht:

- Eine gelöschte Buchung ließ ihren Umsatz auf „uebernommen“ stehen. Der
  Fremdschlüssel setzte nur `buchung_id` auf NULL. `Buchung::loeschen()` gibt
  den Umsatz jetzt vorher frei, `Bankumsatz::verwaisteOeffnen()` fängt die Wege
  ab, die daran vorbeiführen – Kaskaden lösen in MariaDB keine Trigger aus.
- Ein Bankkonto entstand doppelt, wenn die Bank die IBAN nicht bei jedem Dialog
  mitliefert. Gesucht wird jetzt über die Kontonummer; eine nachgelieferte IBAN
  wird am vorhandenen Eintrag nachgetragen.
- Der Fingerabdruck hing an der IBAN und kippte mit ihr. Er nimmt jetzt die
  `bank_konto_id`, die fest steht, solange es den Bankzugang gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:54:23 +02:00

266 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Online-Banking (FinTS)
Umsätze kommen per Knopfdruck von der Bank, werden gegen die vorhandenen Buchungen
abgeglichen und über Regeln weitgehend selbst kategorisiert.
## Die Bibliothek: ein eigener Fork
Verwendet wird `nemiah/php-fints`, aber nicht von Packagist, sondern als **Fork auf
Codeberg** (`fds2610-bot/phpFinTS`, eingebunden über ein VCS-Repository in der
`composer.json`, Version fest auf `4.1.1` gepinnt).
Im Fork ist `psr/log` **direkt eingebaut**: Die acht Interface-Dateien liegen unter
`psr-log/Psr/Log/` und werden über einen eigenen Autoload-Eintrag geladen, der
`require`-Eintrag ist entfallen. Damit hängt der Finanzmanager an keinem fremden
Update-Zyklus – die Anwendung hat genau eine Abhängigkeit, und die steht unter eigener
Kontrolle.
Beim Nachziehen eines neuen Upstream-Standes muss dieser Eingriff wiederholt werden; wie,
steht in `psr-log/README.md` des Forks.
## Der Schlüssel für die Zugangsdaten
PIN und TAN-Medium stehen AES-256-GCM-verschlüsselt in der Datenbank (`Services\Crypto`,
übernommen aus `Steuerkram`). Der Schlüssel ist eine 64-stellige Hex-Zeichenkette:
* aus der Umgebung (`APP_KEY` in der `.env`), oder
* aus `data/schluessel/app_key` – die Datei erzeugt der Container-Entrypoint beim ersten
Start, wenn `APP_KEY` leer ist.
Das Verzeichnis ist ein eigenes Bind-Mount, damit der Schlüssel einen Neubau des Images
überlebt. **Er gehört ins Backup**: Ohne ihn sind gespeicherte Zugangsdaten nicht mehr
lesbar (neu eingeben hilft, verloren ist sonst nichts).
## Ablauf eines Abrufs
Der Abruf ist eine Zustandsmaschine über mehrere Requests:
```
anmelden → konten → umsaetze (je Konto) → fertig
```
Jeder Schritt kann eine TAN verlangen – bei den meisten Banken schon der Login, auch für
reines Lesen. Dann sichert `Services\FinTsAbruf` den Dialog (`FinTs::persist()`) und die
serialisierte Aktion in der **Sitzung**, zeigt die TAN-Maske und macht nach der Eingabe an
derselben Stelle weiter.
Ein wartender Prozess wäre der naheliegende, aber falsche Weg: php-fpm hat dafür keinen
Platz, und ein Request, der fünf Minuten auf eine TAN wartet, blockiert einen Worker.
Was in der Sitzung liegt, enthält keine PIN – die Bibliothek schließt sie beim Persistieren
ausdrücklich aus. Sie wird bei jedem Request neu aus der Datenbank geholt und entschlüsselt.
### TAN-Verfahren
* **Klassisch** (chipTAN, photoTAN): Die Maske zeigt den Hinweistext der Bank und, wenn
vorhanden, die photoTAN-Grafik als Datenadresse. Der Nutzer tippt die TAN ein.
* **Entkoppelt** (pushTAN-App): Die Freigabe passiert im Gerät des Nutzers. Statt zu warten,
fragt die Anwendung auf Knopfdruck nach (`checkDecoupledSubmission`) – steht die Freigabe
noch aus, bleibt die Maske stehen.
### 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 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
überall chipTAN QR –, und niemand soll sie in einer PDF der Bank suchen müssen.
`getTanModes()` kommt ohne Anmeldung aus (die Bibliothek braucht dafür nur die
Bankparameterdaten) und verlangt deshalb selbst keine TAN.
`getTanMedia()` wird nur für die Verfahren aufgerufen, die ein Medium brauchen: Jeder
Aufruf ist ein eigener Dialog mit der Bank.
Die Zeitüberschreitungen sind kürzer gesetzt als in der Bibliothek (8 statt 15 Sekunden zum
Verbinden, 20 statt 30 zur Antwort): Die Abfrage hängt an einem Seitenaufruf.
Das Ergebnis liegt in der Sitzung, **auch ein gescheitertes** – sonst liefe jeder
Seitenaufruf erneut in dieselbe Zeitüberschreitung. Scheitert die Abfrage, erklärt die
Seite, wo der Fehler meist steckt, und der Weg zurück zu den Zugangsdaten steht daneben.
Die Wahl lässt sich überspringen; die Verbindung ist auch ohne sie benutzbar.
Beim Übernehmen wird gegen die abgefragte Liste geprüft: Eine Kennung, die die Bank nicht
angeboten hat, wäre beim nächsten Abruf wirkungslos.
**Ohne Wahl** nimmt der erste Abruf das erste Verfahren, das die Bank meldet, und hält es
fest; sonst stünde im Formular nichts. Eine **ausdrückliche** Wahl wird dagegen nie
überschrieben: Bietet die Bank sie einmal nicht an, sagt das Protokoll das und die
Einstellung bleibt stehen.
Das Zugangsformular fasst Verfahren und Medium nicht an: Ohne diesen Vorbehalt setzte jedes
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 zuordnen
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.
Der Saldo wird mitgeholt und landet in der Spalte *Online-Saldo* der Kontenübersicht.
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.
## Vom Umsatz zur Buchung
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 |
|---|---|---|
| **Verbinden** | Es gibt eine erfasste Buchung mit gleichem Konto, gleichem Betrag und nahem Datum | Der Umsatz wird mit ihr verknüpft, die Buchung gilt als abgeglichen |
| **Als neue Buchung** | Keine passende Buchung vorhanden | Neue Buchung mit Quelle *fints*, Kennzeichen abgeglichen |
| **Verwerfen** | Der Umsatz soll nicht ins Kontoblatt | Bleibt als „verworfen“ liegen und kommt nicht wieder |
Wie weit Buchungs- und Banktag auseinanderliegen dürfen, steht als *Toleranz beim Abgleich*
in den Einstellungen (Vorgabe fünf Tage). Eine Buchung, die schon mit einem anderen Umsatz
verbunden ist, wird nie ein zweites Mal vorgeschlagen.
Dass derselbe Umsatz bei überlappenden Abrufzeiträumen nur einmal hereinkommt, sichert
`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 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
Eine Regel prüft ein Muster gegen Empfänger und/oder Verwendungszweck:
* **Vergleich**: enthält, beginnt mit, endet auf, regulärer Ausdruck. Groß- und
Kleinschreibung spielt nie eine Rolle – Banken schreiben denselben Empfänger mal so, mal
so.
* **Betragsgrenzen** rechnen mit Vorzeichen: „von −60 bis −40“ trifft Lastschriften
zwischen 40 und 60 Euro.
* **Wirkung**: Kategorie, Klasse, Gegenkonto (macht daraus eine Umbuchung) oder ein festes
**Split-Schema** in Prozent. Die letzte Splitzeile bekommt den Rest, damit die Summe auf
den Cent zum Buchungsbetrag passt.
Die **erste passende Regel gewinnt**, sortiert nach Priorität (kleinere Zahl zuerst). Kein
Sammeln über mehrere Regeln: Wenn zwei verschiedene Kategorien setzen wollten, wäre am Ende
nicht mehr nachvollziehbar, warum die eine dasteht.
Reguläre Ausdrücke werden beim Speichern geprüft – ein kaputtes Muster brächte sonst jeden
Abruf zum Stehen. Eingegeben wird ohne Trennzeichen: `Miete|Nebenkosten`, nicht `/Miete/`.
Der Zähler *Treffer* zeigt, welche Regeln im Alltag überhaupt greifen.
## Protokoll und Diagnose
Jeder Abruf schreibt eine Zeile nach `bank_abrufe` mit Status, Zahlen und Verlauf – ohne das
ist ein fehlgeschlagener Abruf nicht mehr zu rekonstruieren, denn FinTS-Meldungen der Banken
sind selten selbsterklärend. Läufe, die länger als zwei Stunden auf eine TAN warten, gelten
als abgebrochen.
Bei eingeschaltetem Debug-Log (Einstellungen → Diagnose) landen zusätzlich die
FinTS-Dialogschritte in `data/logs/` – über `Services\DebugLogAdapter`, der den PSR-3-Logger
der Bibliothek an das eigene Log hängt. PIN und TAN maskieren beide Seiten.
## Tests
`tests/16_BankingTest.php` prüft alles um den Dialog herum: Verschlüsselung (Kennung, kein
Klartext, wechselnder Geheimtext, kein Verschachteln), PIN bleibt beim Speichern erhalten,
Abrufzeiträume, Dublettenerkennung, Regeln nach Priorität und Feld, Splitschema mit
Restausgleich, Abweisung ungültiger Ausdrücke, Abgleich gegen vorhandene Buchungen samt
Toleranz sowie Übernehmen, Verwerfen und Zurücksetzen.
Zur Verfahrenswahl geprüft: Kennung, Name und verschlüsseltes Medium werden gespeichert,
unbekannte Kennungen und Medien abgewiesen, ein Verfahren ohne Medienzwang bekommt keines
angehängt, und das Speichern des Zugangsformulars lässt die Wahl stehen. Die Abfrage bei der
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.