Nennt die Bank bei der TAN-Abfrage einen Startcode für den TAN-Generator, geht er im Fließtext leicht unter. StartcodeErkennung erkennt ihn über einen pro Bankverbindung konfigurierbaren Beispielsatz (Ziffernfolge = Platzhalter für den Code) und zeigt den Fund fett, zentriert und in eigener Zeile. BankFreitext löst dafür die bisherige Klartext-Aufbereitung aus FinTsUeberweisung::vopHinweis() heraus, damit TAN-Anfrage und VoP-Rückmeldung dieselbe HTML-zu-Klartext-Umwandlung nutzen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
589 lines
34 KiB
Markdown
589 lines
34 KiB
Markdown
# 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; wird ein Zahlungsauftrag entfernt, bleibt sein Protokoll stehen.
|
||
`tests/42_UebermittlungsprotokollTest.php` prüft Mitschnitt, Zuordnung zur Überweisung, den
|
||
Fehlerfall und die Filter.
|
||
|
||
## 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.
|