Finanzmanager/docs/banking.md
fds2610-bot e95fc7302d Fix #117: Nicht freigegebene Überweisungen nachvollziehbar machen
- Status „wartet auf Freigabe“, solange die Bank auf TAN/Namensabgleich wartet
- Abbruch der Freigabe (auch durch neuen Sendeversuch) im Übermittlungsprotokoll
  als „abgebrochen“ festhalten, Auftrag als fehlgeschlagen vermerken
- Aufträge mit Übermittlungsprotokoll nur stornieren, nicht löschen
- Migration 025, PHP-Test 57, E2E-Spec zahlungsfreigabe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:01:24 +02:00

600 lines
35 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 und PayPal)
Umsätze kommen per Knopfdruck von der Bank, werden gegen die vorhandenen Buchungen
abgeglichen und über Regeln weitgehend selbst kategorisiert.
Eine Bankverbindung ist entweder ein **HBCI/FinTS**-Zugang zu einer Bank oder ein
**PayPal**-Zugang (`bank_zugaenge.typ`). Beide teilen sich Tabelle, Konten, Umsätze,
Abrufprotokoll, Regeln und Abgleich – verschieden ist allein der Weg zur Gegenstelle
(`Services\FinTsAbruf` bzw. `Services\PaypalAbruf`). Beide Dienste liefern dasselbe
Ergebnis, `BankingController::zeigeAbruf()` wertet sie deshalb gleich aus.
Der Weg dorthin beginnt bei *Bankverbindung anlegen* mit der **Wahl der Art**
(`/banking/neu`): Die beiden Wege verlangen völlig verschiedene Angaben, ein gemeinsames
Formular mit zwei ausgegrauten Hälften hülfe niemandem. Die Wahl steht danach fest –
`Bankzugang::speichern()` schreibt `typ` nicht mehr um, weil ein umgestellter Zugang weder
passende Zugangsdaten noch passende Konten hätte.
## 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.6` 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.
### Eigene Änderungen am Protokoll
Dazu kommt ein Eingriff am **Namensabgleich** (Verification of Payee): Meldet eine Bank in
den Bankparametern die schrittweise Lieferung des Prüfberichts (`Art der Lieferung Payment
Status Report` = `S`, etwa comdirect), lehnte php-fints jede Überweisung ab. Der Parameter
beschreibt aber nur, wie die Bank den Bericht aufteilt, *wenn* sie ihn über mehrere Runden
liefert – bei einer Einzelüberweisung passiert das nie. Der Fork prüft ihn deshalb erst,
wenn tatsächlich eine Teillieferung eintrifft.
Dazu kommt ein zweiter: Stimmt der Empfängername nur **nahezu** überein (Close Match),
nennt die Bank den bei ihr hinterlegten Namen als Entscheidungshilfe. php-fints las ihn
zwar aus dem Segment, reichte ihn aber nicht an die Anwendung durch – ausgerechnet die
Angabe, die die Rückfrage überhaupt beantwortbar macht. Sie steht jetzt als
`VopConfirmationRequest::getDifferingPayeeName()` zur Verfügung.
Jeder Eingriff liegt als eigener Commit vor, zusätzlich auf einem eigenen Branch über
`master` (ohne den psr/log-Commit), damit daraus je ein PR an das Upstream-Projekt werden
kann: `vop-stepwise` und `vop-close-match-name`. Der Stand, den der Finanzmanager
verwendet, ist der Branch `finanzmanager` mit dem Tag `v4.1.3`.
## Das Bankleitzahlen-Verzeichnis
Beim Anlegen einer FinTS-Verbindung fehlt in aller Regel genau eine Angabe: der
**PIN/TAN-Zugangspunkt**. Die Bankleitzahl kennt jeder auswendig, die Adresse steht tief in
der Hilfe zum Online-Banking – und lautet oft ganz anders als der Name der Bank vermuten
lässt (1822direkt: `https://fints.1822direkt.com/fints/hbci`).
Dafür gibt es unter *Verwaltung → Bankleitzahlen* (`/verwaltung/bankinstitute`) das
Verzeichnis der FinTS-Institute. Es wird aus der CSV-Liste der Deutschen Kreditwirtschaft
(`hbci-zka.de`) eingelesen – derselben Datei, aus der auch der Lexware FinanzManager seine
Zugangsdaten zieht:
| | |
|---|---|
| Tabelle | `bankinstitute` (Migration 015) |
| Model | `Models\Bankinstitut` |
| Import | `Services\FintsInstituteImport` |
| Ansicht | `Views/verwaltung/bankinstitute.php` |
**Reine Nachschlagedaten**: keine `benutzer_id`, kein Fremdschlüssel, kein Verweis aus einer
anderen Tabelle. Ein Import **ersetzt den gesamten Bestand**, statt ihn zu ergänzen – sonst
blieben Institute stehen, die es nicht mehr gibt. Die hochgeladene Datei wird gelesen und
nicht abgelegt; ihr Inhalt steht ja vollständig in der Tabelle. *Nur prüfen* und
*Übernehmen* sind deshalb zwei Knöpfe an derselben Datei und nicht zwei Schritte an einer
abgelegten wie beim Buchungsimport.
Die Eigenheiten der Datei, an denen ein naiver Leser scheitert:
* Sie kommt in **ISO-8859-1**, nicht in UTF-8 (`CsvTabelle::lies()` erkennt beides).
* Ein Institutsname läuft schon mal über **zwei Zeilen**, in Anführungszeichen – gelesen
wird deshalb über `fgetcsv()`, nicht zeilenweise.
* Dieselbe **Bankleitzahl steht vielfach** darin, einmal je Filiale, mit identischem
Zugangspunkt. Die Tabelle hat darum keinen eindeutigen Schlüssel auf `blz`;
`findeNachBlz()` nimmt den ersten Eintrag mit Zugangspunkt.
* Am Ende hängen **Hunderte leere Zeilen** mit laufender Nummer. Sie werden gezählt und
übergangen.
* Wo es keinen PIN/TAN-Zugang gibt, steht Text statt einer Adresse; daraus wird ein leeres
Feld.
* Angesprochen werden die Spalten über ihre **Überschrift**, nicht über ihre Position – die
Datei hat im Lauf der Jahre Spalten dazubekommen. Fehlen `BLZ` und `Institut`, bricht der
Import ab, statt lauter leere Zeilen zu schreiben.
Die Spalten der Chipkartenverfahren (DDV, RDH-1…10, RAH-7/9/10) werden nicht übernommen:
Der Finanzmanager spricht ausschließlich PIN/TAN.
**Gesucht** wird über Bankleitzahl, BIC, Name und Ort in einem Zug. Eine Ziffernfolge nur
deshalb als Bankleitzahl zu lesen, weil sie aus Ziffern besteht, verschwiege dem Suchenden
die Bank, die er meint: „1822“ steht bei der 1822direkt im Namen und bei der Frankfurter
Sparkasse im BIC – in keiner der beiden Bankleitzahlen. Die Bankleitzahl wird von vorn
verglichen, alles Übrige als Wortbestandteil; Treffer in der Bankleitzahl stehen oben.
Aus der Trefferliste führt **Verbindung anlegen** auf `/banking/neu/fints?blz=…`, und das
Formular steht mit Bank, Bankleitzahl und FinTS-Adresse gefüllt da. Umgekehrt gilt: Bleibt
das Adressfeld leer, schlägt `BankingController::formulardaten()` sie zur Bankleitzahl nach.
Findet auch das nichts, weist `Bankzugang::anlegen()` den Zugang ab – ein FinTS-Zugang ohne
Adresse hätte niemanden, den er fragen könnte.
## 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.
### Wie weit zurück: Zeitraum und Stichtag
Ab wann abgerufen wird, bestimmt `Bankzugang::abrufBeginn()`. Zwei Angaben spielen hinein:
* Der **Abrufzeitraum** der Bankverbindung (`bank_zugaenge.abrufzeitraum`): seit dem letzten
Abruf, letzte 7/30/365 Tage – oder *Ab Stichtag* mit dem Datum aus
`bank_zugaenge.abruf_stichtag`.
* Ein **Stichtag für diesen einen Abruf**, angegeben im Datumsfeld neben *Umsätze abrufen*.
Er geht der Vorgabe vor und wird **nirgends gespeichert**.
Beides nebeneinander, weil beides gebraucht wird: Die Vorgabe beantwortet den Alltag („immer
seit dem letzten Abruf“), der einmalige Stichtag den Sonderfall – einen Bestand ab einem
festen Datum aufbauen, eine Lücke nachholen. Ohne ihn müsste man die Einstellung des Zugangs
ändern und hinterher zurückstellen, mit dem üblichen Ausgang, dass man das Zurückstellen
vergisst.
Der einmalige Stichtag liegt im **Abrufzustand**, nicht in der Datenbank: Nach einer
TAN-Runde läuft der Abruf im nächsten Request weiter und muss dort denselben Zeitraum holen
wie beim Start.
„Seit dem letzten Abruf“ nimmt den **Tag** des letzten Laufs, nicht die Uhrzeit: Banken
buchen nachträglich, und ein Umsatz, der schon da war, fällt ohnehin über seinen
Fingerabdruck heraus. Beim allerersten Abruf gilt ein Jahr zurück – sonst käme entweder
nichts oder alles.
Ein Stichtag **in der Zukunft** wird auf heute gesetzt statt abgewiesen: Aus der Zukunft
liefert keine Bank etwas, und ein verrutschtes Datum soll den Abruf nicht ergebnislos machen
– das Protokoll vermerkt die Korrektur. *Ab Stichtag* **ohne** Datum lässt sich dagegen gar
nicht erst speichern; eine Vorgabe, die nichts vorgibt, führte beim Abruf nur zu einem
anderen Zeitraum als gedacht.
Wie weit die Bank tatsächlich zurückreicht, entscheidet sie selbst – 90 Tage sind üblich,
mehr liefert nicht jede. Ein früherer Stichtag holt deshalb nicht zwingend mehr; die Lücke
davor schließt der Anfangssaldo des Kontos oder der QIF-Import.
### Alle Umsätze abrufen
Über der Tabelle der Bankverbindungen ruft *Alle Umsätze abrufen* jede **aktive**
Bankverbindung der Reihe nach ab – in der Reihenfolge der Übersicht und jede mit ihrem
eingestellten Abrufzeitraum (ein einmaliger Stichtag gilt hier nicht). Die Warteschlange
führt `Services\Sammelabruf`; abgerufen wird weiterhin einzeln über `FinTsAbruf`
bzw. `PaypalAbruf`.
Was ohne Rückfrage geht, läuft in einem Request durch. Verlangt eine Bank eine Freigabe,
liegt die Warteschlange in der Sitzung (`banking_sammelabruf`), und die TAN-Maske zeigt,
welche Bankverbindung gerade fragt („Bankverbindung 2 von 4“). Nach der Freigabe geht es in
`tanEinreichen()` mit der nächsten weiter. In der Maske lässt *Diese Bank überspringen* nur
die fragende Bank aus; *Abruf abbrechen* beendet den ganzen Lauf.
Ein Fehler bei einer Bank hält die übrigen nicht auf. Am Ende steht je Bankverbindung eine
eigene Meldung – Fehler rot –, und es geht wie beim Einzelabruf in *Umsätze abgleichen*,
wenn dort etwas liegt. Ein einzelner Abruf löst einen liegengebliebenen Sammelabruf ab.
### 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.
Der Hinweistext ist laut FinTS-Spezifikation HTML (br, p, b, i, u, ul, ol, li);
`Services\BankFreitext::klartext()` macht daraus Zeilenumbrüche statt Markup. Nennt die Bank
darin einen Startcode für den TAN-Generator, hebt `Services\StartcodeErkennung` ihn hervor:
Pro Bankzugang lässt sich ein Beispielsatz speichern (`startcode_muster`), dessen Ziffernfolge
als Platzhalter für den jeweiligen Code gilt – ohne eigenen Satz greift
`StartcodeErkennung::STANDARD_MUSTER`.
### 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.
## PayPal
PayPal wird über die **Classic-NVP-API** angesprochen (`Services\PaypalNvp`,
`TransactionSearch` und `GetBalance`, Fassung 204) – übernommen aus dem Parallelprojekt
`Steuerkram`, wo die Fallstricke schon einmal aufgelaufen sind. Gebraucht werden die drei
Angaben aus dem PayPal-Konto unter *Profil → Mein Verkäuferkonto → API-Zugriff*:
API-Benutzername, API-Kennwort und Signatur. Kennwort und Signatur stehen wie die FinTS-PIN
AES-verschlüsselt in der Datenbank.
### Was beim Anschluss aufläuft
* **Zwei Endpunkte – und eine Meldung für zwei Ursachen.** Live- und Sandbox-Zugangsdaten
passen jeweils nur zu ihrem eigenen Endpunkt. Mit dem falschen antwortet PayPal mit
*„Security header is not valid“* – **dieselbe** Antwort kommt aber auch, wenn eine der
drei Angaben schlicht falsch ist. `PaypalNvp::fehlermeldung()` nennt deshalb beide
Ursachen; nennte sie nur den Endpunkt, suchte man den Fehler garantiert an der falschen
Stelle.
Weil PayPal nie sagt, *welche* Angabe klemmt, prüft `PaypalNvp::auffaelligkeiten()` die
Form der drei Angaben: Der Benutzername hat bei PayPal die Gestalt `name_api1.domain.de`,
das Kennwort 16 Zeichen, die Signatur 56. Abweichungen sind **nur ein Hinweis** und
weisen nichts ab – PayPal kann seine Formate ändern, und ein Zugang, der funktioniert,
soll nicht an einer Regel von hier scheitern. Die Hinweise stehen im Abrufprotokoll und,
wenn PayPal die Anmeldung abweist, in der Fehlermeldung selbst; die Werte erscheinen dort
nur gekürzt.
Aus demselben Grund werden Kennwort und Signatur beim Speichern getrimmt: Eine aus der
Zwischenablage eingefügte Signatur bringt gern ein Leerzeichen mit, und PayPal antwortet
darauf mit genau jener Meldung.
* **Zeitscheiben.** `TransactionSearch` liefert je Aufruf höchstens 100 Buchungen und meldet
das nur als Warnung (`ACK = SuccessWithWarning`). Abgefragt wird deshalb in Abschnitten
von 30 Tagen; bleibt eine Antwort trotzdem abgeschnitten, vermerkt das Protokoll es. Eine
Warnung gilt als Erfolg – die Antwort enthält Buchungen, ein Abbruch wäre falsch.
* **`GetBalance` antwortet zweierlei.** Die Dokumentation nennt die flachen Felder
`AMT`/`CURRENCYCODE`; Live-Konten liefern stattdessen die Liste `L_AMT0`/`L_CURRENCYCODE0`
(Index 0 ist die Hauptwährung). Gelesen werden beide, sonst bliebe der Kontostand auf
echten Konten leer.
* **Nullbuchungen.** Einwilligungen und Abonnements erscheinen als Buchung über 0,00 € und
fallen heraus – sie bewegen nichts.
* **Netto, nicht brutto.** Gerechnet wird mit `L_NETAMT`, dem Betrag nach Abzug der
PayPal-Gebühr: Genau der bewegt das Guthaben, und nur damit stimmt der Kontostand mit der
Summe der Buchungen überein.
### Ein Konto, im Formular zugeordnet
PayPal führt je Konto genau ein Guthabenkonto. Es gibt deshalb weder eine Kontenabfrage noch
eine eigene Zuordnungsseite: `Bankzugang::paypalKonto()` legt die eine Zeile in
`bank_konten` an (feste Kontonummer `PAYPAL` statt einer IBAN), und die Auswahl des eigenen
Kontos steht mitsamt *neues Konto anlegen* im Zugangsformular. Ohne Zuordnung bleibt die
Verbindung nutzbar – die Umsätze kommen herein und warten in der Abgleichliste.
Der Fingerabdruck der Umsätze hängt an der **Transaktionsnummer**, die PayPal selbst vergibt
(`Bankumsatz::externIdKennung()`), nicht an Datum, Betrag und Text: Korrigiert PayPal
nachträglich einen Betrag oder den Namen des Gegenübers, bleibt es dieselbe Buchung – über
den zusammengesetzten Fingerabdruck käme sie ein zweites Mal herein.
### Freigaben (MFA)
Die NVP-API authentifiziert allein über die drei Zugangsdaten; eine Rückfrage wie die TAN
einer Bank stellt sie nicht. Verlangt PayPal eines Tages doch eine Bestätigung, ist dafür
nichts umzubauen: `PaypalAbruf` liefert dieselbe Ergebnisform wie `FinTsAbruf` –
einschließlich des Status `tan` samt Feld `tan` –, der Controller schickt beides durch
dieselbe Auswertung, und `bank_abrufe` kennt den Status `tan_offen` bereits. Einzuhängen
wäre nur die Stelle, an der die Antwort als Freigabeanforderung erkannt wird.
Ein PayPal-Zugang hat entsprechend kein TAN-Verfahren: `/banking/{id}/verfahren` weist ihn
mit einem Hinweis ab, `/banking/{id}/konten` führt zurück ins Formular.
## 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*.
### Umsätze löschen
Jeder Reiter der Liste – *offen*, *übernommen*, *verworfen* – hat dieselbe Mehrfachauswahl
wie ein Kontoblatt: ein Ankreuzfeld vor jedem Umsatz, eines in der Titelleiste für alle
angezeigten und darüber der Knopf **Auswahl löschen** (`POST /banking/umsaetze/loeschen`,
`Bankumsatz::mehrereLoeschen()`). Der Knopf nennt die Anzahl und bleibt ohne Auswahl
gesperrt; ohne JavaScript weist der Controller eine leere Auswahl ab.
Gelöscht wird allein die abgerufene Zeile. Eine daraus entstandene Buchung gehört ins
Kontoblatt und bleibt dort stehen – wer sie loswerden will, löscht sie dort, und dann kehrt
ihr Umsatz ohnehin in die Vorschlagsliste zurück. Mit dem Umsatz geht aber sein
Fingerabdruck: Was innerhalb des nächsten Abrufzeitraums liegt, kommt danach wieder herein –
bei einem verworfenen Umsatz also erneut als offen.
## 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 eine
**Splitbuchung**.
### Splitbuchung in einer Regel
Unter jeder Regel klappt *Splitbuchung* die Zeilen auf: je Zeile eine Kategorie, wahlweise
Klasse und Notiz, dazu ein Anteil in Prozent. Greift die Regel, entsteht keine Buchung mit
einer Kategorie, sondern eine Splitbuchung mit diesen Zeilen.
Die Anteile gelten **zueinander**, nicht als feste Beträge – darin liegt der ganze Zweck:
Dieselbe Regel trifft bei jeder Rechnungshöhe dasselbe Verhältnis. Eine Nebenkostenabrechnung
über 300 € wird bei 60 zu 30 in 200 € und 100 € geteilt, eine über 450 € in 300 € und 150 €.
Ob dort 60 und 30 steht oder 2 und 1, ändert nichts.
Dass die Anteile aufeinander bezogen werden und nicht absolut gelten, ist Absicht: Sonst
bekäme die letzte Zeile als Rest plötzlich 40 statt der eingetragenen 30 Prozent, und der
Fehler wäre in der fertigen Buchung nicht mehr zu sehen. Die letzte Zeile bekommt weiterhin
den Rundungsrest, damit die Summe auf den Cent zum Buchungsbetrag passt – bei einem Drittel
zu zwei Dritteln ginge sonst ein Cent verloren, und die Buchung ließe sich nicht speichern.
Eine Zeile ohne Kategorie oder ohne Anteil fällt weg; ohne jede Splitzeile bucht die Regel
wie zuvor auf ihre Kategorie. Im Abgleichdialog zeigt der Vorschlag die errechneten Zeilen
mit ihren Beträgen an und übernimmt sie unverändert – geändert wird eine Aufteilung im
Kontoblatt, wo die ganze Buchung samt Summenprüfung vorliegt.
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.
### Regel direkt aus einer Buchung anlegen
In der Buchungsmaske schaltet der Toggle *Regel anlegen* (standardmäßig aus) eine
Kurzstrecke frei: Beim Speichern entsteht aus Empfänger (ersatzweise Buchungstext),
Kategorie, Klasse, Gegenkonto und einer eventuellen Splitbuchung eine neue Regel
(`AbgleichRegel::ausBuchung()`) – Feld *Empfänger*, Vergleich *enthält*, das Muster ist der
Empfängertext. Fehlen sowohl Empfänger als auch Buchungstext, gibt es kein sinnvolles Muster
und keine Regel entsteht; die Buchung wird trotzdem ganz normal gespeichert.
Das Kontoblatt zeigt die neue Regel danach in einem modalen Dialog mit denselben Feldern wie
*Neue Regel* oben auf dieser Seite. *Abbruch* (auch über Escape) löscht die gerade erst
angelegte Regel wieder – die Buchung bleibt gespeichert, keine ungewollte Regel bleibt
zurück. *Speichern* übernimmt Änderungen und belässt die Regel in der Liste.
## 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.
### Übermittlungsprotokoll
Als Nachweis, dass eine Überweisung rechtzeitig und korrekt bei der Bank ankam, speichert
jeder Durchlauf – Überweisung, Umsatzabruf, Abfrage der TAN-Verfahren – seinen gesamten
Dialog in `bank_protokolle`: gesendete (`>`) und empfangene (`<`) Meldungen im Wortlaut, im
Erfolgs- wie im Fehlerfall. Den Mitschnitt macht `Services\BankProtokollLogger`, ein
PSR-3-Logger, den die Dienste der Bibliothek statt des `DebugLogAdapter` mitgeben; er läuft
also unabhängig vom Debug-Log. Die Bibliothek maskiert PIN und Benutzerkennung, bevor eine
Zeile ankommt. Eine Überweisung mit TAN-Freigabe hat mehrere Durchläufe.
Zu sehen ist das Protokoll an zwei Stellen: im Zahlungsverkehr über den Status der
Überweisung (modaler Dialog) und als Gesamtprotokoll unter *Verwaltung › Online-Banking ›
Protokoll* (`/banking/protokoll`), filterbar nach Bank, Zeitraum und Freitext. Es wird nie
automatisch gelöscht. Ein Zahlungsauftrag mit Protokoll lässt sich nicht mehr löschen, nur
stornieren – er bleibt als Beleg stehen.
`tests/42_UebermittlungsprotokollTest.php` prüft Mitschnitt, Zuordnung zur Überweisung, den
Fehlerfall und die Filter.
**Nicht erteilte Freigabe.** Verlangt die Bank eine TAN oder die Bestätigung des
Namensabgleichs, trägt der Auftrag bis dahin den Status `freigabe` („wartet auf Freigabe“) –
die Bank führt ihn ohne Freigabe nicht aus. Bricht der Nutzer ab oder löst ein neuer
Sendeversuch den wartenden Dialog ab, schreibt `FinTsUeberweisung::abbrechen()` einen
Protokolleintrag mit Status `abgebrochen` und vermerkt den Auftrag als fehlgeschlagen. Verfällt
nur die Sitzung, bleibt der Auftrag auf „wartet auf Freigabe“ stehen und die Liste weist darauf
hin, dass die Freigabe nicht abgeschlossen ist. So lässt sich eine Auskunft der Bank wie „TAN
angefordert, aber nicht verwendet“ am Protokoll prüfen. `tests/57_FreigabeNachvollziehenTest.php`
und `tests/e2e/specs/zahlungsfreigabe.spec.js`.
## 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 samt Stichtag (gespeicherte Vorgabe, einmalige Angabe geht ihr vor und lässt
die Vorgabe stehen, Zukunft wird auf heute gesetzt, unlesbare Datumsangaben werden
abgewiesen), 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/32_BankinstituteTest.php` prüft das Bankleitzahlen-Verzeichnis an einem Ausschnitt
der echten Datei – in ISO-8859-1, mit dem über zwei Zeilen laufenden Institutsnamen, zwei
Filialzeilen derselben Bankleitzahl, einer Zeile ohne Zugangspunkt und einer Fußnote ohne
Bankleitzahl: Zählwerk des Trockenlaufs, Schreiben erst beim echten Lauf, Umlaute,
Nachschlagen mit und ohne Leerzeichen, Ersetzen statt Ergänzen beim zweiten Import,
Abweisen einer fremden CSV-Datei, die Suche über Bankleitzahl, Name, Ort und BIC samt
maskierten LIKE-Platzhaltern – und dass ein FinTS-Zugang ohne Adresse abgewiesen wird,
ein PayPal-Zugang aber ohne sie auskommt. Der Weg im Browser (Upload, Prüfbericht, Suche)
steht in `tests/e2e/specs/bankinstitute.spec.js`; übernommen wird dort nichts, weil ein
Import den ganzen Bestand der Instanz ersetzte.
`tests/46_SammelabrufTest.php` prüft die Warteschlange von *Alle Umsätze abrufen*: nur
aktive Bankverbindungen in der Reihenfolge der Übersicht, ein Fehler hält die übrigen nicht
auf, der Zustand übersteht die TAN-Pause in der Sitzung, Überspringen und Abbrechen samt
ihren Meldungen. Dass der Knopf über der Tabelle steht, prüft
`tests/e2e/specs/banking.spec.js`.
`tests/28_UmsaetzeLoeschenTest.php` prüft das Sammellöschen: Es trifft genau die
ausgewählten Zeilen, eine schon erzeugte Buchung überlebt es, jeder Status lässt sich
löschen, leere und unbekannte Auswahl bleiben folgenlos, eine doppelt gesendete Nummer zählt
einmal – und die gerenderte Ansicht trägt in jedem Reiter Ankreuzfelder, Titelleiste und
Knopf, während die leere Liste keinen Knopf zeigt.
`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.
`tests/31_PaypalZugangTest.php` prüft die PayPal-Verbindung ohne PayPal: Typ und
verschlüsselte Zugangsdaten (leere Felder heißen „unverändert“, der Typ lässt sich nicht
umstellen), das eine Konto samt Zuordnung, der Fingerabdruck an der Transaktionsnummer und
die Dublettenerkennung – dazu die Auswertung nachgestellter Antworten: Endpunktwahl,
abgelehnte Antwort mit Grund, der Sicherheitsfehler samt Erklärung, beide Formen von
`GetBalance`, das Auslesen der Buchungen samt namenloser Buchungen, herausfallende
Nullbuchungen und die Warnung, die als Erfolg gilt. Zur Diagnose geprüft: Der
Sicherheitsfehler nennt beide Ursachen, auffällige Angaben werden einzeln benannt, ohne
ihren Wert mitzuliefern, und Leerraum um die Geheimnisse fällt beim Speichern weg.
`tests/e2e/specs/banking.spec.js` geht den Weg im Browser: *Bankverbindung anlegen* führt
zur Wahl, die erste Karte in die Bankenmaske, die zweite in die PayPal-Maske; eine
PayPal-Verbindung lässt sich samt neuem Konto anlegen und steht danach mit ihrer Art in der
Übersicht, die Geheimnisse werden nicht wieder angezeigt.
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.