diff --git a/Changes.md b/Changes.md index cf4cb6b..c08d702 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,97 @@ # Änderungen +## Bankleitzahlen-Verzeichnis: die FinTS-Adresse nachschlagen statt suchen (18.08.2026) + +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`). + +Neu ist deshalb unter *Verwaltung → Bankleitzahlen* (`/verwaltung/bankinstitute`) das +**Verzeichnis der FinTS-Institute**. Es nimmt die CSV-Liste der Deutschen Kreditwirtschaft +(`hbci-zka.de`) auf – dieselbe Datei, aus der auch der Lexware FinanzManager seine +Zugangsdaten zieht – und beantwortet damit die eine Frage, die das Formular stellt. + +**Einlesen** heißt hier ersetzen: Ein Import wirft den bisherigen Bestand weg, statt ihn zu +ergänzen; sonst blieben Institute stehen, die es nicht mehr gibt. *Nur prüfen* und +*Übernehmen* sind zwei Knöpfe an derselben Datei statt zweier Schritte an einer abgelegten: +Der Inhalt landet vollständig in der Tabelle, ein zweites Exemplar im Importverzeichnis wäre +nur Ballast zwischen den Kontoauszügen. Der Prüfbericht nennt Zeilen, Bankleitzahlen, +Zugangspunkte und den Stand der Daten und zeigt die ersten Zeilen als Sichtprobe – stimmen +dort Umlaute und Spalten, stimmt der Rest auch. + +Die Datei hat es in sich, und `Services\FintsInstituteImport` nimmt ihr das ab: **ISO-8859-1** +statt UTF-8, ein Institutsname über **zwei Zeilen** in Anführungszeichen, dieselbe +**Bankleitzahl vielfach** (einmal je Filiale, mit identischem Zugangspunkt), am Ende +**Hunderte leerer Zeilen** mit laufender Nummer, und wo es keinen PIN/TAN-Zugang gibt, steht +Text statt einer Adresse. Angesprochen werden die Spalten über ihre Überschrift, nicht über +ihre Position; fehlen `BLZ` und `Institut`, bricht der Import ab, statt lauter leere Zeilen +zu schreiben. Die Spalten der Chipkartenverfahren bleiben liegen – 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** direkt ins FinTS-Formular, das mit Bank, +Bankleitzahl und Adresse gefüllt dasteht. Umgekehrt gilt: Die **FinTS-Adresse ist kein +Pflichtfeld** mehr – bleibt sie leer, wird sie zur Bankleitzahl nachgeschlagen. Findet auch +das nichts, weist `Bankzugang` den Zugang jetzt ab, statt ihn ohne Gegenstelle zu speichern. + +Dazu Migration `015_bankinstitute.sql` (Tabelle `bankinstitute`, reine Nachschlagedaten ohne +`benutzer_id`), `Models\Bankinstitut`, `Controllers\BankinstituteController`, +`tests/32_BankinstituteTest.php` und `tests/e2e/specs/bankinstitute.spec.js`. + +## PayPal als zweite Art von Bankverbindung (18.08.2026) + +*Bankverbindung anlegen* führt nicht mehr direkt in die Bankenmaske, sondern zur **Wahl +der Art**: HBCI/FinTS oder PayPal. Die erste Karte führt in die bisherige Maske und den +bekannten Dreischritt, die zweite in ein neues Formular mit allen Angaben für die +PayPal-Abfrage. Die Wahl steht danach fest – ein umgestellter Zugang hätte weder passende +Zugangsdaten noch passende Konten. + +**Die Abfrage** läuft über die Classic-NVP-API (`Services\PaypalNvp`, `TransactionSearch` +und `GetBalance`), übernommen aus dem Parallelprojekt `Steuerkram` samt der Fallstricke, +die dort schon aufgelaufen sind: + +- Live- und Sandbox-Zugangsdaten passen nur zu ihrem eigenen Endpunkt; mit dem falschen + antwortet PayPal mit *„Security header is not valid“* – **dieselbe** Antwort kommt aber + auch bei schlicht falschen Angaben. Die Fehlermeldung nennt deshalb beide Ursachen, und + weil PayPal nie sagt, welche der drei Angaben klemmt, prüft + `PaypalNvp::auffaelligkeiten()` ihre Form (Benutzername `name_api1.domain.de`, Kennwort + 16 Zeichen, Signatur 56) und schreibt Abweichungen ins Protokoll und in die Meldung – + als Hinweis, nicht als Abweisung, und ohne die Werte mitzuliefern. Kennwort und Signatur + werden beim Speichern getrimmt: Eine eingefügte Signatur bringt gern ein Leerzeichen mit, + das dieselbe Meldung auslöst. +- `TransactionSearch` liefert je Aufruf höchstens 100 Buchungen und meldet das nur als + Warnung – abgefragt wird deshalb in Abschnitten von 30 Tagen, und eine Warnung gilt als + Erfolg statt als Abbruch. +- `GetBalance` antwortet je nach Konto mit `AMT` oder mit `L_AMT0`; gelesen werden beide, + sonst bliebe der Kontostand auf Live-Konten leer. +- Gerechnet wird mit `L_NETAMT` (nach Gebühr), Nullbuchungen aus Einwilligungen und + Abonnements fallen heraus. + +**Die Kontenzuordnung** steht im selben Formular: PayPal führt je Konto genau ein +Guthabenkonto, eine eigene Seite wäre eine Zeile Tabelle. Das Dropdown listet die eigenen +Konten und bietet *neues Konto anlegen* mit Name, Kontoart und Anfangssaldo. Ohne +Zuordnung bleibt die Verbindung nutzbar – die Umsätze warten dann in der Abgleichliste. + +Der Fingerabdruck der Umsätze hängt an der Transaktionsnummer von PayPal, nicht an Datum, +Betrag und Text: Eine nachträglich korrigierte Buchung käme sonst ein zweites Mal herein. + +**Freigaben (MFA)** sind vorgesehen, ohne erfundenen Code: Die NVP-API authentifiziert +allein über die drei Zugangsdaten und stellt keine Rückfrage. `PaypalAbruf` liefert aber +dieselbe Ergebnisform wie `FinTsAbruf` – Status `tan` eingeschlossen –, der Controller +wertet beide gleich aus, und `bank_abrufe` kennt `tan_offen` bereits. Einzuhängen wäre nur +die Stelle, an der eine Antwort als Freigabeanforderung erkannt wird. + +Neu: `migrations/014_paypal_zugang.sql` (Spalten `typ`, `api_benutzer`, `api_kennwort`, +`api_signatur`, `sandbox`), `Services\PaypalNvp`, `Services\PaypalAbruf`, +`Views/banking/typ.php`, `Views/banking/paypal.php`, `tests/31_PaypalZugangTest.php` und +`tests/e2e/specs/banking.spec.js`. Bestehende Zugänge bleiben FinTS-Zugänge. + ## Spiegelung der Umbuchungen unter Test (17.08.2026) Neuer Test `tests/30_UmbuchungSpiegelungTest.php`. Er nagelt die zwei Zusagen fest, die diff --git a/PLAN.md b/PLAN.md index 6794eb8..0857f6c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -288,7 +288,7 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag das Cockpit beantwortet die täglichen Fragen auf einen Blick. Einzelheiten in `docs/auswertungen.md` und `docs/cockpit.md`. -### Phase 5 — Online-Banking (FinTS) *(erledigt)* +### Phase 5 — Online-Banking (FinTS und PayPal) *(erledigt)* * Fork von `nemiah/php-fints` auf Codeberg (`fds2610-bot/phpFinTS`, Version `4.1.1`), Einbindung über VCS-Repository. `psr/log` ist im Fork direkt eingebaut — der Finanzmanager hat damit genau eine Abhängigkeit, und die steht unter eigener Kontrolle. @@ -307,6 +307,16 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag (`FinTs::persist()` plus serialisierte Aktion), nicht über einen wartenden Prozess — mit php-fpm wäre das der falsche Weg. Abgerufene Umsätze werden **nicht sofort gebucht**, sondern gehen durch die Vorschlagsliste; wer selbst erfasst, hätte sonst alles doppelt. +* **Nachgetragen (18.08.2026):** Eine Bankverbindung kann auch ein **PayPal**-Zugang sein + (`bank_zugaenge.typ`), abgerufen über die Classic-NVP-API — ebenfalls aus Steuerkram + übernommen. Konten, Umsätze, Protokoll, Regeln und Abgleich bleiben dieselben; verschieden + ist allein der Weg zur Gegenstelle. Kein Dreischritt, da PayPal genau ein Guthabenkonto + führt: Zugangsdaten und Kontenzuordnung stehen in einem Formular. +* **Nachgetragen (18.08.2026):** Ein **Bankleitzahlen-Verzeichnis** (`bankinstitute`, + *Verwaltung → Bankleitzahlen*) nimmt die CSV-Institutsliste der Deutschen + Kreditwirtschaft auf und beantwortet damit die einzige Angabe, die beim Anlegen einer + FinTS-Verbindung regelmäßig fehlt: den PIN/TAN-Zugangspunkt. Reine Nachschlagedaten ohne + `benutzer_id`; ein Import ersetzt den ganzen Bestand. * *Ergebnis:* Umsätze kommen per Knopfdruck rein und sind großteils automatisch kategorisiert. Einzelheiten in `docs/banking.md`. diff --git a/README.md b/README.md index cabd269..6a7090d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Finanzmanager Persönliche Finanzverwaltung als Web-Anwendung: Konten, Buchungen mit Kategorien und -Klassen, Jahresbudget, Monatsabschluss, Auswertungen, Online-Banking über FinTS und -Wertpapierdepots. +Klassen, Jahresbudget, Monatsabschluss, Auswertungen, Online-Banking über FinTS und PayPal +sowie Wertpapierdepots. Ersetzt den Lexware FinanzManager Deluxe, läuft auf einem Debian-Server statt auf einem Windows-Client. Die fachlichen Begriffe bleiben bewusst identisch zum Original – im @@ -29,7 +29,11 @@ Haushalt wird beides parallel genutzt. Schritten (Zugangsdaten, TAN-Verfahren, Kontenzuordnung mit Anlegen fehlender Konten), Abrufzeitraum als Spanne oder Stichtag – auch für einen einzelnen Abruf –, abgerufene Umsätze buchen sich auf Wunsch selbst, Abgleich gegen vorhandene Buchungen, Regeln für - die automatische Kategorisierung. + die automatische Kategorisierung. Alternativ **PayPal** über dessen NVP-Schnittstelle: + ein Formular statt dreier Schritte, da PayPal genau ein Guthabenkonto führt. + Die FinTS-Adresse muss niemand suchen: Das **Bankleitzahlen-Verzeichnis** unter + *Verwaltung → Bankleitzahlen* nimmt die CSV-Institutsliste der Deutschen + Kreditwirtschaft auf und füllt daraus das Formular einer neuen Verbindung. * **Wertpapiere** – Depotbestände mit Einstand und Bewertung, Depotbuchungen, Kursabruf, Veräußerungsgeschäfte und Erträge; Bestandslisten der Bank lassen sich als CSV übernehmen. Eine abgerufene Wertpapierabrechnung wird im Kontoblatt zum Geschäft: Das @@ -127,7 +131,7 @@ wieder ein. Einzelheiten in `docs/import-export.md`. - `docs/planung.md` – Budgetplaner und Monatsabschluss - `docs/auswertungen.md` – Berichtsmodul, Filterleiste, Drilldown, CSV - `docs/cockpit.md` – Startseite und ihre Kacheln -- `docs/banking.md` – FinTS-Abruf, TAN-Dialog, Abgleich und Regeln +- `docs/banking.md` – FinTS- und PayPal-Abruf, TAN-Dialog, Abgleich und Regeln - `docs/wertpapiere.md` – Depots, Einstand, Kurse, Invst- und CSV-Bestandsimport - `docs/nebenmodule.md` – Vorlagen, Termine, Verträge, Sparziele, Kredite, Zahlungsverkehr - `docs/import-export.md` – QIF und CSV in beide Richtungen, Formatwahl, Housekeeping diff --git a/docs/banking.md b/docs/banking.md index a5b42fc..f15e414 100644 --- a/docs/banking.md +++ b/docs/banking.md @@ -1,8 +1,20 @@ -# Online-Banking (FinTS) +# 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 @@ -18,6 +30,63 @@ Kontrolle. Beim Nachziehen eines neuen Upstream-Standes muss dieser Eingriff wiederholt werden; wie, steht in `psr-log/README.md` des Forks. +## 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`, @@ -176,6 +245,74 @@ 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. +## 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, @@ -326,6 +463,17 @@ 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/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/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 @@ -339,6 +487,21 @@ verbinden statt doppeln, Umsätze ohne Konto bleiben liegen, abgeschaltet passie 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. diff --git a/migrations/014_paypal_zugang.sql b/migrations/014_paypal_zugang.sql new file mode 100644 index 0000000..cbe870a --- /dev/null +++ b/migrations/014_paypal_zugang.sql @@ -0,0 +1,18 @@ +-- PayPal als zweite Art von Bankverbindung. +-- +-- Bisher war jeder Bankzugang ein HBCI/FinTS-Zugang. Die Spalte `typ` trennt +-- beide Wege; die FinTS-Felder (BLZ, Adresse, Benutzerkennung, PIN) bleiben bei +-- PayPal leer, die API-Felder umgekehrt. +-- +-- Die drei PayPal-Angaben sind die Zugangsdaten der Classic-NVP-API: +-- API-Benutzername, API-Kennwort und Signatur. Kennwort und Signatur stehen +-- AES-verschlüsselt in der Datenbank (Services\Crypto), genau wie die FinTS-PIN. + +ALTER TABLE bank_zugaenge + ADD COLUMN IF NOT EXISTS typ ENUM('fints','paypal') NOT NULL DEFAULT 'fints' AFTER benutzer_id, + ADD COLUMN IF NOT EXISTS api_benutzer VARCHAR(191) NOT NULL DEFAULT '' AFTER pin, + ADD COLUMN IF NOT EXISTS api_kennwort TEXT NULL AFTER api_benutzer, + ADD COLUMN IF NOT EXISTS api_signatur TEXT NULL AFTER api_kennwort, + -- Sandbox-Zugangsdaten sprechen einen eigenen Endpunkt an; mit dem falschen + -- antwortet PayPal mit „Security header is not valid“ und sonst nichts. + ADD COLUMN IF NOT EXISTS sandbox TINYINT(1) NOT NULL DEFAULT 0 AFTER api_signatur; diff --git a/migrations/015_bankinstitute.sql b/migrations/015_bankinstitute.sql new file mode 100644 index 0000000..fa9eddb --- /dev/null +++ b/migrations/015_bankinstitute.sql @@ -0,0 +1,48 @@ +-- Das Bankleitzahlen-Verzeichnis der FinTS-Institute. +-- +-- Die Liste kommt als CSV von der Deutschen Kreditwirtschaft +-- (https://www.hbci-zka.de/) und nennt zu jeder Bankleitzahl unter anderem den +-- PIN/TAN-Zugangspunkt. Genau der fehlt beim Anlegen einer Bankverbindung am +-- häufigsten – die Banken verstecken ihn tief in ihrer Hilfe. +-- +-- Reine Nachschlagedaten: keine benutzer_id, kein Fremdschlüssel, keine +-- Verweise aus anderen Tabellen. Ein Import ersetzt den gesamten Bestand, statt +-- ihn zu ergänzen – sonst blieben Institute stehen, die es nicht mehr gibt. +-- +-- Die Spalten für die Chipkartenverfahren (DDV, RDH-1…10, RAH-7/9/10) der Datei +-- werden nicht übernommen: Der Finanzmanager spricht ausschließlich PIN/TAN. + +CREATE TABLE IF NOT EXISTS bankinstitute ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + -- Die laufende Nummer der Datei. Sie ist nicht stabil zwischen zwei + -- Ausgaben, taugt aber als Reihenfolge innerhalb einer. + nr INT UNSIGNED NOT NULL DEFAULT 0, + blz VARCHAR(16) NOT NULL, + bic VARCHAR(16) NOT NULL DEFAULT '', + institut VARCHAR(191) NOT NULL DEFAULT '', + ort VARCHAR(128) NOT NULL DEFAULT '', + rechenzentrum VARCHAR(128) NOT NULL DEFAULT '', + organisation VARCHAR(64) NOT NULL DEFAULT '', + -- Der HBCI-Zugang über DNS-Namen; für PIN/TAN belanglos, aber er verrät bei + -- einer Fehlersuche, welches Rechenzentrum tatsächlich antwortet. + hbci_adresse VARCHAR(191) NOT NULL DEFAULT '', + hbci_version VARCHAR(16) NOT NULL DEFAULT '', + pintan_url VARCHAR(255) NOT NULL DEFAULT '', + pintan_version VARCHAR(32) NOT NULL DEFAULT '', + -- „Datum letzte Änderung“ der Zeile, nicht der Datei: Manche Einträge sind + -- über zehn Jahre alt und entsprechend mit Vorsicht zu genießen. + stand DATE NULL, + PRIMARY KEY (id), + -- Eine Bankleitzahl steht oft mehrfach in der Datei, einmal je Filiale, mit + -- identischem Zugangspunkt. Deshalb ein gewöhnlicher Index, kein UNIQUE. + KEY idx_bankinstitut_blz (blz), + KEY idx_bankinstitut_bic (bic), + KEY idx_bankinstitut_institut (institut) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Wann welche Datei eingelesen wurde. Steht bewusst nicht im Einstellungsdialog: +-- Beides schreibt der Import, von Hand gibt es daran nichts einzustellen. +INSERT INTO einstellungen (schluessel, wert, bereich) VALUES + ('bankinstitute_datei', '', 'banking'), + ('bankinstitute_import_am', '', 'banking') +ON DUPLICATE KEY UPDATE schluessel = schluessel; diff --git a/public/index.php b/public/index.php index f432666..7a17d90 100644 --- a/public/index.php +++ b/public/index.php @@ -12,6 +12,7 @@ use Finanzmanager\Auth; use Finanzmanager\Config; use Finanzmanager\Controllers\AuswertungenController; use Finanzmanager\Controllers\BankingController; +use Finanzmanager\Controllers\BankinstituteController; use Finanzmanager\Controllers\BuchungenController; use Finanzmanager\Controllers\BudgetController; use Finanzmanager\Controllers\CockpitController; @@ -100,7 +101,7 @@ $router->get('/buchungen/{id}/wertpapier', $router->post('/buchungen/{id}/wertpapier', fn(array $p) => (new BuchungenController())->wertpapier($p)); -// Verwaltung: Kategorien, Klassen, Kontengruppen, QIF-Import. +// Verwaltung: Kategorien, Klassen, Kontengruppen, Bankleitzahlen, QIF-Import. $router->get('/verwaltung', fn() => (new VerwaltungController())->index()); $router->get('/verwaltung/kategorien', fn() => (new KategorienController())->index()); @@ -131,6 +132,11 @@ $router->post('/verwaltung/kontengruppen/{id}', $router->post('/verwaltung/kontengruppen/{id}/loeschen', fn(array $p) => (new VerwaltungController())->kontengruppeLoeschen($p)); +// Das Bankleitzahlen-Verzeichnis: nachschlagen und die Institutsliste einlesen. +$router->get('/verwaltung/bankinstitute', fn() => (new BankinstituteController())->index()); +$router->post('/verwaltung/bankinstitute/import', fn() => (new BankinstituteController())->importieren()); +$router->post('/verwaltung/bankinstitute/leeren', fn() => (new BankinstituteController())->leeren()); + // ── Planung: Budgetplaner und Monatsabschluss ─────────────────────────────── $router->get('/planung', fn() => (new PlanungController())->index()); @@ -273,7 +279,11 @@ $router->post('/wertpapiere/import', fn() => (new WertpapiereController())->impo // ── Online-Banking: Zugänge, Abruf, Abgleich, Regeln ──────────────────────── $router->get('/banking', fn() => (new BankingController())->index()); $router->get('/banking/neu', fn() => (new BankingController())->neu()); -$router->post('/banking/neu', fn() => (new BankingController())->anlegen()); +// Je Art eine eigene Maske – die Angaben für FinTS und PayPal haben nichts +// miteinander gemein. Die Pfade stehen vor „/banking/{id}/…“, sonst wäre „neu“ +// eine Kennung. +$router->get('/banking/neu/{typ}', fn(array $p) => (new BankingController())->neuFormular($p)); +$router->post('/banking/neu/{typ}', fn(array $p) => (new BankingController())->anlegen($p)); // Die festen Pfade stehen vor „/banking/{id}“, sonst schluckt das Muster auch // „/banking/umsaetze“ und „/banking/regeln“. diff --git a/src/Controllers/BankingController.php b/src/Controllers/BankingController.php index df7c300..7330470 100644 --- a/src/Controllers/BankingController.php +++ b/src/Controllers/BankingController.php @@ -8,6 +8,7 @@ use Finanzmanager\Auth; use Finanzmanager\Database; use Finanzmanager\Models\AbgleichRegel; use Finanzmanager\Models\Bankabruf; +use Finanzmanager\Models\Bankinstitut; use Finanzmanager\Models\Bankumsatz; use Finanzmanager\Models\Bankzugang; use Finanzmanager\Models\Kategorie; @@ -17,15 +18,25 @@ use Finanzmanager\Services\Bankabgleich; use Finanzmanager\Services\Crypto; use Finanzmanager\Services\FinTsAbruf; use Finanzmanager\Services\FinTsVerfahren; +use Finanzmanager\Services\PaypalAbruf; use Finanzmanager\View; /** * Online-Banking: Bankverbindungen verwalten, Umsätze abrufen, abgleichen und * Regeln pflegen. * - * Der Abruf selbst steckt in `Services\FinTsAbruf`; dieser Controller reicht nur - * durch und übersetzt das Ergebnis in Seiten – vor allem in die TAN-Maske, die - * zwischen zwei Requests stehen bleibt. + * Der Abruf selbst steckt in `Services\FinTsAbruf` beziehungsweise + * `Services\PaypalAbruf`; dieser Controller reicht nur durch und übersetzt das + * Ergebnis in Seiten – vor allem in die TAN-Maske, die zwischen zwei Requests + * stehen bleibt. Beide Dienste liefern dasselbe Ergebnis, deshalb wertet + * `zeigeAbruf()` sie gleich aus. + * + * Eine Bankverbindung ist entweder ein **HBCI/FinTS**-Zugang oder ein + * **PayPal**-Zugang. Der FinTS-Weg wird in drei Schritten eingerichtet + * (Zugangsdaten → TAN-Verfahren → Konten zuordnen), weil jeder Schritt die Bank + * fragen muss. PayPal braucht das nicht: Die Zugangsdaten stehen von vornherein + * fest, und ein PayPal-Konto führt genau ein Guthabenkonto – die Zuordnung + * steht deshalb gleich mit im Formular. */ class BankingController extends BaseController { @@ -52,25 +63,101 @@ class BankingController extends BaseController ]); } + /** + * Die Wahl der Art: HBCI/FinTS oder PayPal. + * + * Beide brauchen völlig verschiedene Angaben – ein gemeinsames Formular mit + * zwei ausgegrauten Hälften wäre für niemanden eine Hilfe. Die Wahl fällt + * deshalb vorweg und ist danach nicht mehr umzustellen. + */ public function neu(): void { $this->verlangeAnmeldung(); - $this->render('banking/formular.php', [ - 'titel' => 'Bankverbindung anlegen', - 'zugang' => null, - 'konten' => [], - ]); + $this->render('banking/typ.php', ['titel' => 'Bankverbindung anlegen']); } - public function anlegen(): void + /** @param array $params */ + public function neuFormular(array $params): void { $this->verlangeAnmeldung(); + if (self::typ($params) === 'paypal') { + $this->render('banking/paypal.php', [ + 'titel' => 'PayPal-Verbindung anlegen', + 'zugang' => null, + 'bankkonto' => null, + 'eigene' => Konto::alle(), + ]); + return; + } + + // Aus dem Bankleitzahlen-Verzeichnis kommt man mit „?blz=…“ hierher; + // dann stehen Bank, Bankleitzahl und FinTS-Adresse schon im Formular. + $this->render('banking/formular.php', [ + 'titel' => 'Bankverbindung anlegen', + 'zugang' => null, + 'konten' => [], + 'vorgabe' => self::vorbelegung($this->query('blz')), + ]); + } + + /** + * Vorbelegung eines leeren FinTS-Formulars aus dem Bankleitzahlen- + * Verzeichnis: Von dort führt „Verbindung anlegen“ mit „?blz=…“ hierher. + * + * Ohne Treffer bleibt das Formular leer – eine Bankleitzahl, die das + * Verzeichnis nicht kennt, ist deshalb noch lange nicht falsch. + * + * @return array + */ + private static function vorbelegung(string $blz): array + { + $institut = Bankinstitut::findeNachBlz($blz); + if ($institut === null) { + return []; + } + + return [ + // Der Name ist frei wählbar; der Institutsname ist der naheliegende + // Vorschlag und lässt sich im Formular überschreiben. + 'name' => (string)$institut['institut'], + 'bankname' => (string)$institut['institut'], + 'blz' => (string)$institut['blz'], + 'fints_url' => (string)$institut['pintan_url'], + ]; + } + + /** @param array $params */ + public function anlegen(array $params): void + { + $this->verlangeAnmeldung(); + + $typ = self::typ($params); + try { - $id = Bankzugang::anlegen($this->formulardaten()); + $id = Bankzugang::anlegen( + $typ === 'paypal' ? $this->paypalFormulardaten() : $this->formulardaten() + ); } catch (\Throwable $e) { - View::redirect('/banking/neu', $e->getMessage(), 'fehler'); + View::redirect('/banking/neu/' . $typ, $e->getMessage(), 'fehler'); + return; + } + + if ($typ === 'paypal') { + // Kein zweiter und dritter Schritt: PayPal verlangt weder ein + // TAN-Verfahren noch eine Kontenabfrage, und das eine Konto ist im + // Formular schon zugeordnet worden. + try { + $neue = $this->paypalKontoZuordnen($id); + } catch (\Throwable $e) { + View::redirect('/banking/' . $id, $e->getMessage(), 'fehler'); + return; + } + + View::redirect('/banking', $neue + ? 'PayPal-Verbindung angelegt, das Konto ist neu angelegt und zugeordnet.' + : 'PayPal-Verbindung angelegt.'); return; } @@ -82,6 +169,18 @@ class BankingController extends BaseController ); } + /** + * Die Art aus dem Pfad – alles Unbekannte gilt als FinTS. + * + * @param array $params + */ + private static function typ(array $params): string + { + $typ = (string)($params['typ'] ?? 'fints'); + + return array_key_exists($typ, Bankzugang::TYPEN) ? $typ : 'fints'; + } + /** @param array $params */ public function bearbeiten(array $params): void { @@ -93,6 +192,19 @@ class BankingController extends BaseController return; } + if ((string)$zugang['typ'] === 'paypal') { + $konten = Bankzugang::konten((int)$zugang['id']); + + $this->render('banking/paypal.php', [ + 'titel' => $zugang['name'], + 'zugang' => $zugang, + 'bankkonto' => $konten[0] ?? null, + 'eigene' => Konto::alle(), + 'abrufe' => Bankabruf::letzte(5, (int)$zugang['id']), + ]); + return; + } + $this->render('banking/formular.php', [ 'titel' => $zugang['name'], 'zugang' => $zugang, @@ -106,16 +218,26 @@ class BankingController extends BaseController { $this->verlangeAnmeldung(); - $id = (int)$params['id']; + $id = (int)$params['id']; + $zugang = Bankzugang::find($id); + if ($zugang === null) { + View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler'); + return; + } + + $paypal = (string)$zugang['typ'] === 'paypal'; try { - Bankzugang::speichern($id, $this->formulardaten()); + Bankzugang::speichern($id, $paypal ? $this->paypalFormulardaten() : $this->formulardaten()); + $neue = $paypal && $this->paypalKontoZuordnen($id); } catch (\Throwable $e) { View::redirect('/banking/' . $id, $e->getMessage(), 'fehler'); return; } - View::redirect('/banking/' . $id, 'Bankverbindung gespeichert.'); + View::redirect('/banking/' . $id, ($neue ?? false) + ? 'Gespeichert, das Konto ist neu angelegt und zugeordnet.' + : 'Bankverbindung gespeichert.'); } /** @param array $params */ @@ -153,6 +275,10 @@ class BankingController extends BaseController View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler'); return; } + if ((string)$zugang['typ'] === 'paypal') { + View::redirect('/banking/' . $id, 'PayPal kennt kein TAN-Verfahren.', 'warnung'); + return; + } $abfrage = $this->gemerkteAbfrage($id) ?? $this->verfahrenHolen($id); @@ -238,6 +364,12 @@ class BankingController extends BaseController View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler'); return; } + // Bei PayPal steht die Zuordnung im Zugangsformular: Es gibt genau ein + // Konto, und abzufragen ist bei PayPal nichts. + if ((string)$zugang['typ'] === 'paypal') { + View::redirect('/banking/' . (int)$zugang['id']); + return; + } $this->render('banking/konten.php', [ 'titel' => 'Konten zuordnen – ' . $zugang['name'], @@ -382,9 +514,18 @@ class BankingController extends BaseController // als schon vorhanden, und sie blieben für immer unsichtbar. Bankumsatz::verwaisteOeffnen(); - $ergebnis = FinTsAbruf::starten((int)$params['id'], 'voll', false, $this->post('stichtag')); + $id = (int)$params['id']; + $zugang = Bankzugang::find($id); + if ($zugang === null) { + View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler'); + return; + } - $this->zeigeAbruf($ergebnis, (int)$params['id']); + $ergebnis = (string)$zugang['typ'] === 'paypal' + ? PaypalAbruf::starten($id, $this->post('stichtag')) + : FinTsAbruf::starten($id, 'voll', false, $this->post('stichtag')); + + $this->zeigeAbruf($ergebnis, $id); } /** Die TAN-Maske nach einem Neuladen der Seite. */ @@ -689,11 +830,21 @@ class BankingController extends BaseController /** @return array */ private function formulardaten(): array { + $blz = $this->post('blz'); + + // Bleibt die Adresse leer, wird sie im Bankleitzahlen-Verzeichnis + // nachgeschlagen. Das ist der übliche Fall: Die Bankleitzahl kennt + // jeder auswendig, den FinTS-Zugangspunkt niemand. + $url = $this->post('fints_url'); + if ($url === '') { + $url = (string)(Bankinstitut::findeNachBlz($blz)['pintan_url'] ?? ''); + } + return [ 'name' => $this->post('name'), 'bankname' => $this->post('bankname'), - 'blz' => $this->post('blz'), - 'fints_url' => $this->post('fints_url'), + 'blz' => $blz, + 'fints_url' => $url, 'benutzerkennung' => $this->post('benutzerkennung'), 'pin' => $this->post('pin'), 'abrufzeitraum' => $this->post('abrufzeitraum'), @@ -702,6 +853,83 @@ class BankingController extends BaseController ]; } + /** + * Die Zugangsdaten einer PayPal-Verbindung aus dem Formular. + * + * Die drei Angaben stammen aus dem PayPal-Konto unter *Profil → Mein + * Verkäuferkonto → API-Zugriff*: API-Benutzername, API-Kennwort und + * Signatur. Leere Geheimnisse heißen beim Bearbeiten „unverändert“ – wie die + * PIN eines FinTS-Zugangs. + * + * @return array + */ + private function paypalFormulardaten(): array + { + return [ + 'typ' => 'paypal', + 'name' => $this->post('name'), + 'bankname' => 'PayPal', + 'api_benutzer' => $this->post('api_benutzer'), + 'api_kennwort' => $this->post('api_kennwort'), + 'api_signatur' => $this->post('api_signatur'), + 'sandbox' => isset($_POST['sandbox']) ? 1 : 0, + 'abrufzeitraum' => $this->post('abrufzeitraum'), + 'abruf_stichtag' => $this->post('abruf_stichtag'), + 'aktiv' => isset($_POST['aktiv']) ? 1 : 0, + ]; + } + + /** + * Ordnet das eine Konto eines PayPal-Zugangs zu. + * + * PayPal führt je Konto genau ein Guthabenkonto – eine eigene Seite wie bei + * FinTS wäre eine Zeile Tabelle. Fehlt in der Kontenliste das passende + * Konto, entsteht es hier gleich mit; abzutippen bleibt der Name. + * + * @return bool Ob dabei ein eigenes Konto neu angelegt wurde + */ + private function paypalKontoZuordnen(int $zugangId): bool + { + $wahl = (string)$this->post('konto_id'); + + // Ohne Zuordnung bleibt die Verbindung nutzbar: Die Umsätze kommen + // herein und warten in der Abgleichliste, bis ein Konto feststeht. Beim + // Anlegen entsteht dafür noch keine Kontozeile – die kommt beim ersten + // Abruf. Steht sie schon da, heißt die leere Wahl dagegen ausdrücklich + // „Zuordnung lösen“ und muss auch so wirken. + if ($wahl === '' && Bankzugang::konten($zugangId) === []) { + return false; + } + + $neu = $wahl === self::KONTO_NEU; + if ($neu && trim((string)$this->post('neuer_name')) === '') { + throw new \InvalidArgumentException('Für das neue Konto fehlt der Name.'); + } + + // Alles in einer Transaktion: Scheitert das neue Konto, soll auch die + // halbe Zuordnung nicht stehen bleiben. + Database::transaktion(function () use ($zugangId, $wahl, $neu): void { + $bankkonto = Bankzugang::paypalKonto($zugangId); + + $kontoId = $wahl === '' ? null : ($neu + ? Konto::anlegen([ + 'name' => trim((string)$this->post('neuer_name')), + 'kontoart' => $this->post('neue_kontoart', 'bank'), + 'bankname' => 'PayPal', + // Was PayPal an Umsätzen hergibt, reicht nicht bis zur + // Kontoeröffnung zurück – der Anfangssaldo schließt die Lücke. + 'anfangssaldo' => KontenController::zahl((string)$this->post('anfangssaldo', '0')), + 'aktiv' => 1, + 'in_summe' => 1, + ]) + : (int)$wahl); + + Bankzugang::ordneKontoZu((int)$bankkonto['id'], $kontoId); + }); + + return $neu; + } + /** @return array */ private function regeldaten(): array { diff --git a/src/Controllers/BankinstituteController.php b/src/Controllers/BankinstituteController.php new file mode 100644 index 0000000..13618bf --- /dev/null +++ b/src/Controllers/BankinstituteController.php @@ -0,0 +1,139 @@ +verlangeAnmeldung(); + + $this->render('verwaltung/bankinstitute.php', $this->grunddaten()); + } + + public function importieren(): void + { + $this->verlangeAnmeldung(); + + $datei = $_FILES['datei'] ?? null; + if (!is_array($datei) || ($datei['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + View::redirect( + '/verwaltung/bankinstitute', + $this->uploadFehler((int)($datei['error'] ?? UPLOAD_ERR_NO_FILE)), + 'fehler' + ); + return; + } + + $pfad = (string)$datei['tmp_name']; + if (!is_uploaded_file($pfad)) { + View::redirect('/verwaltung/bankinstitute', 'Der Upload ist fehlgeschlagen.', 'fehler'); + return; + } + + if ((int)($datei['size'] ?? 0) > self::MAX_BYTES) { + View::redirect('/verwaltung/bankinstitute', 'Die Datei ist zu groß.', 'fehler'); + return; + } + + $name = basename((string)($datei['name'] ?? 'institute.csv')); + if (strtolower((string)pathinfo($name, PATHINFO_EXTENSION)) !== 'csv') { + View::redirect( + '/verwaltung/bankinstitute', + 'Erwartet wird die Institutsliste als CSV-Datei.', + 'fehler' + ); + return; + } + + $trockenlauf = $this->post('modus') !== 'import'; + + try { + $ergebnis = FintsInstituteImport::ausDatei($pfad)->ausfuehren($trockenlauf); + } catch (\Throwable $e) { + View::redirect('/verwaltung/bankinstitute', 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler'); + return; + } + + if (!$trockenlauf) { + // Woher der Bestand stammt, steht danach unter der Kennzahl – ohne + // das ließe sich nicht mehr sagen, welche Ausgabe eingespielt wurde. + Einstellung::set('bankinstitute_datei', mb_substr($name, 0, 191), 'banking'); + Einstellung::set('bankinstitute_import_am', date('Y-m-d H:i:s'), 'banking'); + } + + $this->render('verwaltung/bankinstitute.php', array_merge( + $this->grunddaten(), + [ + 'ergebnis' => $ergebnis, + 'trockenlauf' => $trockenlauf, + 'dateiname' => $name, + ] + )); + } + + public function leeren(): void + { + $this->verlangeAnmeldung(); + + Bankinstitut::leeren(); + Einstellung::set('bankinstitute_datei', '', 'banking'); + Einstellung::set('bankinstitute_import_am', '', 'banking'); + + View::redirect('/verwaltung/bankinstitute', 'Das Verzeichnis ist geleert.'); + } + + /** @return array */ + private function grunddaten(): array + { + $suche = $this->query('suche'); + + return [ + 'titel' => 'Bankleitzahlen', + 'suche' => $suche, + 'treffer' => $suche === '' ? [] : Bankinstitut::suche($suche), + 'grenze' => Bankinstitut::TREFFER_GRENZE, + 'anzahl' => Bankinstitut::anzahl(), + 'mitZugang' => Bankinstitut::anzahlMitZugang(), + 'stand' => Bankinstitut::juengsterStand(), + 'quelldatei' => (string)Einstellung::get('bankinstitute_datei', ''), + 'importAm' => (string)Einstellung::get('bankinstitute_import_am', ''), + 'ergebnis' => null, + 'trockenlauf' => true, + 'dateiname' => '', + ]; + } + + private function uploadFehler(int $code): string + { + return match ($code) { + UPLOAD_ERR_NO_FILE => 'Es wurde keine Datei ausgewählt.', + UPLOAD_ERR_INI_SIZE, + UPLOAD_ERR_FORM_SIZE => 'Die Datei ist zu groß.', + UPLOAD_ERR_PARTIAL => 'Die Datei wurde nur teilweise übertragen.', + default => 'Der Upload ist fehlgeschlagen (Code ' . $code . ').', + }; + } +} diff --git a/src/Controllers/VerwaltungController.php b/src/Controllers/VerwaltungController.php index 9e1d77a..18276e0 100644 --- a/src/Controllers/VerwaltungController.php +++ b/src/Controllers/VerwaltungController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Finanzmanager\Controllers; +use Finanzmanager\Models\Bankinstitut; use Finanzmanager\Models\Erinnerung; use Finanzmanager\Models\Kategorie; use Finanzmanager\Models\Klasse; @@ -33,6 +34,7 @@ class VerwaltungController extends BaseController 'klassen' => Klasse::anzahl(), 'konten' => Konto::anzahl(), 'kontengruppen' => count(Kontengruppe::alle()), + 'bankinstitute' => Bankinstitut::anzahl(), 'reihen' => count(RegelmaessigeBuchung::alle(true)), 'erinnerungen' => Erinnerung::anzahlOffen(), 'vertraege' => count(Vertrag::alle(true)), diff --git a/src/Models/Bankinstitut.php b/src/Models/Bankinstitut.php new file mode 100644 index 0000000..7292587 --- /dev/null +++ b/src/Models/Bankinstitut.php @@ -0,0 +1,180 @@ + ''", + [], + 0 + ); + } + + /** Das jüngste Änderungsdatum im Bestand – der Stand der Daten. */ + public static function juengsterStand(): ?string + { + $wert = Database::fetchValue('SELECT MAX(stand) FROM bankinstitute'); + + return is_string($wert) && $wert !== '' ? $wert : null; + } + + /** + * Der Eintrag zu einer Bankleitzahl. + * + * Steht die Bankleitzahl mehrfach in der Datei – bei den Großbanken einmal + * je Filiale –, gewinnt ein Eintrag mit Zugangspunkt vor einem ohne; unter + * gleichen entscheidet die Reihenfolge der Datei. Die Zugangspunkte + * derselben Bankleitzahl sind identisch, verschieden ist nur der Ort. + * + * @return array|null + */ + public static function findeNachBlz(string $blz): ?array + { + $blz = self::nurZiffern($blz); + if ($blz === '') { + return null; + } + + $zeile = Database::fetchOne( + "SELECT * FROM bankinstitute + WHERE blz = ? + ORDER BY (pintan_url <> '') DESC, nr + LIMIT 1", + [$blz] + ); + + return $zeile === false ? null : $zeile; + } + + /** + * Suche über Bankleitzahl, BIC, Name und Ort. + * + * Gesucht wird immer in allen vier Feldern, und zwar in einem Zug: Wer + * „1822“ eingibt, meint mal die Bankleitzahl und mal die 1822direkt – eine + * Eingabe nur deshalb als Bankleitzahl zu lesen, weil sie aus Ziffern + * besteht, verschwiege ihm die Bank, die er sucht. + * + * Verglichen wird die Bankleitzahl von vorn („5005“ findet 50050201 und + * 50050222), alles Übrige als Wortbestandteil. Treffer in der + * Bankleitzahl stehen oben, dann die mit Zugangspunkt. + * + * @return list> + */ + public static function suche(string $begriff, int $grenze = self::TREFFER_GRENZE): array + { + $begriff = trim($begriff); + if ($begriff === '') { + return []; + } + + $grenze = max(1, min($grenze, self::TREFFER_GRENZE)); + $muster = self::maskiere($begriff); + + // Eine Bankleitzahl steht in der Tabelle ohne Leerzeichen; die Eingabe + // darf welche enthalten. Bei einer Eingabe ohne Ziffern bleibt nichts + // übrig – dann trifft der Vergleich schlicht nie. + $blz = self::nurZiffern($begriff); + $blzMuster = $blz === '' ? null : self::maskiere($blz) . '%'; + $text = '%' . $muster . '%'; + + return Database::fetchAll( + "SELECT * FROM bankinstitute + WHERE (? IS NOT NULL AND blz LIKE ? ESCAPE '!') + OR institut LIKE ? ESCAPE '!' + OR ort LIKE ? ESCAPE '!' + OR bic LIKE ? ESCAPE '!' + ORDER BY (? IS NOT NULL AND blz LIKE ? ESCAPE '!') DESC, + (pintan_url <> '') DESC, blz, institut, ort + LIMIT " . $grenze, + [ + $blzMuster, (string)$blzMuster, $text, $text, $text, + $blzMuster, (string)$blzMuster, + ] + ); + } + + /** + * Ersetzt den gesamten Bestand. + * + * In einer Transaktion und satzweise über eine mehrzeilige INSERT-Anweisung: + * Die Datei bringt knapp viertausend Zeilen mit, und viertausend einzelne + * Anweisungen dauern spürbar länger als vierzig. + * + * @param list> $saetze + * @return int Zahl der geschriebenen Zeilen + */ + public static function ersetze(array $saetze): int + { + $spalten = [ + 'nr', 'blz', 'bic', 'institut', 'ort', 'rechenzentrum', 'organisation', + 'hbci_adresse', 'hbci_version', 'pintan_url', 'pintan_version', 'stand', + ]; + + return (int)Database::transaktion(static function () use ($saetze, $spalten): int { + Database::execute('DELETE FROM bankinstitute'); + + $geschrieben = 0; + foreach (array_chunk($saetze, 100) as $block) { + $werte = []; + $platz = []; + foreach ($block as $satz) { + foreach ($spalten as $spalte) { + $werte[] = $satz[$spalte] ?? null; + } + $platz[] = '(' . implode(',', array_fill(0, count($spalten), '?')) . ')'; + } + + Database::execute( + 'INSERT INTO bankinstitute (' . implode(', ', $spalten) . ') VALUES ' + . implode(', ', $platz), + $werte + ); + $geschrieben += count($block); + } + + return $geschrieben; + }); + } + + public static function leeren(): void + { + Database::execute('DELETE FROM bankinstitute'); + } + + /** Bankleitzahlen stehen in der Datei mit führenden Nullen – nur Ziffern zählen. */ + public static function nurZiffern(string $wert): string + { + return preg_replace('/\D/', '', $wert) ?? ''; + } + + /** Maskiert die LIKE-Platzhalter einer Sucheingabe. */ + private static function maskiere(string $begriff): string + { + return str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $begriff); + } +} diff --git a/src/Models/Bankumsatz.php b/src/Models/Bankumsatz.php index 7cdb835..698a90e 100644 --- a/src/Models/Bankumsatz.php +++ b/src/Models/Bankumsatz.php @@ -55,6 +55,20 @@ class Bankumsatz ])); } + /** + * Fingerabdruck aus der Kennung, die die Gegenstelle selbst vergibt. + * + * PayPal nennt zu jeder Buchung eine Transaktionsnummer, die sich nicht mehr + * ändert. Sie ist der bessere Fingerabdruck als der aus 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. + */ + public static function externIdKennung(int $bankKontoId, string $kennung): string + { + return sha1($bankKontoId . '|' . trim($kennung)); + } + public static function existiert(string $externId): bool { return Database::fetchValue( diff --git a/src/Models/Bankzugang.php b/src/Models/Bankzugang.php index f12ee0c..a273e16 100644 --- a/src/Models/Bankzugang.php +++ b/src/Models/Bankzugang.php @@ -12,12 +12,27 @@ use Finanzmanager\Services\Crypto; * Ein Bankzugang: Zugangsdaten einer Bank samt der Konten, die dort geführt * werden. * - * Die PIN steht verschlüsselt in der Datenbank (`Services\Crypto`) und wird - * ausschließlich über `zugangsdaten()` wieder herausgereicht – wer die Zeile - * über `find()` holt, bekommt sie im Geheimtext. + * Es gibt ihn in zwei Ausführungen (`typ`): den **HBCI/FinTS**-Zugang mit + * Bankleitzahl, Adresse, Benutzerkennung und PIN, und den **PayPal**-Zugang mit + * den drei Angaben der Classic-NVP-API. Beide teilen sich Tabelle, Konten, + * Umsätze und Abrufprotokoll – nur der Weg zur Bank ist ein anderer. + * + * PIN, TAN-Medium, API-Kennwort und API-Signatur stehen verschlüsselt in der + * Datenbank (`Services\Crypto`) und werden ausschließlich über + * `zugangsdaten()` wieder herausgereicht – wer die Zeile über `find()` holt, + * bekommt sie im Geheimtext. */ class Bankzugang { + /** Die Arten von Bankverbindung, Schlüssel → Beschriftung. */ + public const TYPEN = [ + 'fints' => 'HBCI/FinTS', + 'paypal' => 'PayPal', + ]; + + /** Kontonummer der einen Kontozeile eines PayPal-Zugangs. */ + public const PAYPAL_KENNUNG = 'PAYPAL'; + /** Vorgaben für den Abrufzeitraum, Schlüssel → Beschriftung. */ public const ZEITRAEUME = [ 'letzter_abruf' => 'Seit dem letzten Abruf', @@ -59,8 +74,10 @@ class Bankzugang return null; } - $zugang['pin'] = Crypto::entschluessle((string)($zugang['pin'] ?? '')); - $zugang['tan_medium'] = Crypto::entschluessle((string)($zugang['tan_medium'] ?? '')); + $zugang['pin'] = Crypto::entschluessle((string)($zugang['pin'] ?? '')); + $zugang['tan_medium'] = Crypto::entschluessle((string)($zugang['tan_medium'] ?? '')); + $zugang['api_kennwort'] = Crypto::entschluessle((string)($zugang['api_kennwort'] ?? '')); + $zugang['api_signatur'] = Crypto::entschluessle((string)($zugang['api_signatur'] ?? '')); return $zugang; } @@ -71,16 +88,20 @@ class Bankzugang public static function anlegen(array $daten): int { $felder = self::normalisiere($daten); + self::verlangeAdresse((string)$felder['typ'], (string)$felder['fints_url']); Database::execute( 'INSERT INTO bank_zugaenge - (benutzer_id, name, bankname, blz, fints_url, benutzerkennung, pin, + (benutzer_id, typ, name, bankname, blz, fints_url, benutzerkennung, pin, + api_benutzer, api_kennwort, api_signatur, sandbox, tan_verfahren_id, tan_verfahren_name, tan_medium, abrufzeitraum, abruf_stichtag, aktiv) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ - Mandant::id(), $felder['name'], $felder['bankname'], $felder['blz'], + Mandant::id(), $felder['typ'], $felder['name'], $felder['bankname'], $felder['blz'], $felder['fints_url'], $felder['benutzerkennung'], $felder['pin'], + $felder['api_benutzer'], $felder['api_kennwort'], $felder['api_signatur'], + $felder['sandbox'], $felder['tan_verfahren_id'], $felder['tan_verfahren_name'], $felder['tan_medium'], $felder['abrufzeitraum'], $felder['abruf_stichtag'], $felder['aktiv'], ] @@ -101,10 +122,19 @@ class Bankzugang $felder = self::normalisiere($daten); + // Der Typ steht beim Anlegen fest und wird nicht mehr umgestellt: Ein + // FinTS-Zugang, der plötzlich PayPal wäre, hätte weder passende + // Zugangsdaten noch passende Konten. + $felder['typ'] = (string)$bisher['typ']; + self::verlangeAdresse($felder['typ'], (string)$felder['fints_url']); + // Ein leeres PIN-Feld heißt „unverändert lassen“, nicht „PIN löschen“ – // sonst verlöre jedes Speichern des Formulars die Zugangsdaten. - if ($felder['pin'] === null) { - $felder['pin'] = $bisher['pin']; + // Für die beiden PayPal-Geheimnisse gilt dasselbe. + foreach (['pin', 'api_kennwort', 'api_signatur'] as $geheimnis) { + if ($felder[$geheimnis] === null) { + $felder[$geheimnis] = $bisher[$geheimnis]; + } } // Dasselbe für das TAN-Verfahren: Es steht nicht im Zugangsformular, @@ -118,12 +148,15 @@ class Bankzugang Database::execute( 'UPDATE bank_zugaenge SET name = ?, bankname = ?, blz = ?, fints_url = ?, - benutzerkennung = ?, pin = ?, tan_verfahren_id = ?, tan_verfahren_name = ?, + benutzerkennung = ?, pin = ?, api_benutzer = ?, api_kennwort = ?, + api_signatur = ?, sandbox = ?, tan_verfahren_id = ?, tan_verfahren_name = ?, tan_medium = ?, abrufzeitraum = ?, abruf_stichtag = ?, aktiv = ? WHERE id = ? AND benutzer_id = ?', [ $felder['name'], $felder['bankname'], $felder['blz'], $felder['fints_url'], - $felder['benutzerkennung'], $felder['pin'], $felder['tan_verfahren_id'], + $felder['benutzerkennung'], $felder['pin'], $felder['api_benutzer'], + $felder['api_kennwort'], $felder['api_signatur'], $felder['sandbox'], + $felder['tan_verfahren_id'], $felder['tan_verfahren_name'], $felder['tan_medium'], $felder['abrufzeitraum'], $felder['abruf_stichtag'], $felder['aktiv'], $id, Mandant::id(), @@ -131,6 +164,27 @@ class Bankzugang ); } + /** + * Ohne Adresse hat ein FinTS-Zugang niemanden, den er fragen könnte. + * + * Geprüft wird erst, wenn der Typ feststeht: Beim Bearbeiten stammt er aus + * der Datenbank und nicht aus dem Formular, und ein PayPal-Zugang führt + * überhaupt keine FinTS-Adresse. + * + * Das Formular darf sie leer lassen – dann schlägt der Controller sie im + * Bankleitzahlen-Verzeichnis nach. Findet auch das nichts, endet es hier, + * und zwar mit einer Meldung, die den Grund nennt. + */ + private static function verlangeAdresse(string $typ, string $url): void + { + if ($typ === 'fints' && trim($url) === '') { + throw new \InvalidArgumentException( + 'Die FinTS-Adresse fehlt und steht auch nicht im Bankleitzahlen-Verzeichnis ' + . '– sie muss von Hand eingetragen werden.' + ); + } + } + public static function loeschen(int $id): void { Database::execute('DELETE FROM bank_zugaenge WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); @@ -289,15 +343,28 @@ class Bankzugang throw new \InvalidArgumentException('Der Bankzugang braucht einen Namen.'); } + $typ = (string)($daten['typ'] ?? 'fints'); + $typ = array_key_exists($typ, self::TYPEN) ? $typ : 'fints'; + $url = trim((string)($daten['fints_url'] ?? '')); if ($url !== '' && !str_starts_with($url, 'https://')) { throw new \InvalidArgumentException('Die FinTS-Adresse muss mit https:// beginnen.'); } // Ein leeres PIN-Feld bedeutet beim Bearbeiten „unverändert“; beim - // Anlegen wird daraus schlicht eine leere PIN. - $pin = (string)($daten['pin'] ?? ''); - $pin = $pin === '' ? null : Crypto::verschluessle($pin); + // Anlegen wird daraus schlicht eine leere PIN. Für die beiden + // PayPal-Geheimnisse gilt dieselbe Regel. + // + // Getrimmt wird, bevor verschlüsselt wird: Eine aus der Zwischenablage + // eingefügte PayPal-Signatur bringt gern ein Leerzeichen mit, und PayPal + // antwortet darauf mit „Security header is not valid“ – einer Meldung, + // die alles Mögliche vermuten lässt, nur nicht das. + $geheim = static function (mixed $wert): ?string { + $wert = trim((string)($wert ?? '')); + return $wert === '' ? null : Crypto::verschluessle($wert); + }; + + $pin = $geheim($daten['pin'] ?? ''); $verfahren = $daten['tan_verfahren_id'] ?? null; $verfahren = ($verfahren === null || $verfahren === '') ? null : (int)$verfahren; @@ -316,12 +383,17 @@ class Bankzugang } return [ + 'typ' => $typ, 'name' => mb_substr($name, 0, 128), 'bankname' => mb_substr(trim((string)($daten['bankname'] ?? '')), 0, 128), 'blz' => preg_replace('/\D/', '', (string)($daten['blz'] ?? '')) ?? '', 'fints_url' => mb_substr($url, 0, 255), 'benutzerkennung' => mb_substr(trim((string)($daten['benutzerkennung'] ?? '')), 0, 128), 'pin' => $pin, + 'api_benutzer' => mb_substr(trim((string)($daten['api_benutzer'] ?? '')), 0, 191), + 'api_kennwort' => $geheim($daten['api_kennwort'] ?? ''), + 'api_signatur' => $geheim($daten['api_signatur'] ?? ''), + 'sandbox' => !empty($daten['sandbox']) ? 1 : 0, 'tan_verfahren_id' => $verfahren, 'tan_verfahren_name' => mb_substr(trim((string)($daten['tan_verfahren_name'] ?? '')), 0, 128), 'tan_medium' => Crypto::verschluessle(trim((string)($daten['tan_medium'] ?? ''))), @@ -346,6 +418,22 @@ class Bankzugang ); } + /** + * Das eine Konto eines PayPal-Zugangs – angelegt, falls es noch keines gibt. + * + * PayPal führt je Konto genau ein Guthabenkonto; eine Kontenabfrage wie bei + * FinTS gibt es dort nicht und wäre auch nichts zu wählen. Statt der IBAN + * steht die feste Kennung `PAYPAL` in der Zeile: Sie hält den + * Fingerabdruck der Umsätze stabil und macht die UNIQUE-Bedingung über + * (Zugang, IBAN, Kontonummer) eindeutig. + * + * @return array + */ + public static function paypalKonto(int $zugangId): array + { + return self::findeOderLegeKontoAn($zugangId, '', self::PAYPAL_KENNUNG, 'PayPal-Guthaben'); + } + /** * Legt ein bei der Bank gefundenes Konto an oder findet es wieder. Beim * ersten Auftauchen bleibt es ohne Zuordnung – der Nutzer entscheidet, auf diff --git a/src/Services/FintsInstituteImport.php b/src/Services/FintsInstituteImport.php new file mode 100644 index 0000000..16a749f --- /dev/null +++ b/src/Services/FintsInstituteImport.php @@ -0,0 +1,176 @@ + 'nr', + 'blz' => 'blz', + 'bic' => 'bic', + 'institut' => 'institut', + 'ort' => 'ort', + 'rz' => 'rechenzentrum', + 'organisation' => 'organisation', + 'hbcizugangdns' => 'hbci_adresse', + 'hbciversion' => 'hbci_version', + 'pintanzugangurl' => 'pintan_url', + 'version' => 'pintan_version', + 'datumletzteaenderung' => 'stand', + ]; + + /** @param list> $saetze Datensätze der Datei */ + private function __construct(private readonly array $saetze) + { + } + + public static function ausDatei(string $pfad): self + { + $inhalt = @file_get_contents($pfad); + if ($inhalt === false) { + throw new \RuntimeException('Die Datei „' . basename($pfad) . '“ ließ sich nicht lesen.'); + } + + return self::ausText($inhalt); + } + + public static function ausText(string $inhalt): self + { + $saetze = CsvTabelle::datensaetze($inhalt); + if ($saetze === []) { + throw new \RuntimeException('Die Datei enthält keine Datenzeilen.'); + } + + $vorhanden = array_keys($saetze[0]); + $fehlend = array_diff(self::PFLICHTSPALTEN, $vorhanden); + if ($fehlend !== []) { + throw new \RuntimeException( + 'Das ist keine FinTS-Institutsliste – es fehlen die Spalten: ' + . implode(', ', $fehlend) . '.' + ); + } + + return new self($saetze); + } + + /** + * Führt den Import aus. Im Trockenlauf wird nur gezählt, nichts geschrieben. + * + * @return array{gelesen: int, uebernommen: int, ohne_blz: int, mit_zugang: int, + * stand: string|null, banken: int, beispiele: list>} + */ + public function ausfuehren(bool $trockenlauf = true): array + { + $zeilen = []; + $ohneBlz = 0; + + foreach ($this->saetze as $satz) { + $zeile = self::zeile($satz); + if ($zeile === null) { + $ohneBlz++; + continue; + } + $zeilen[] = $zeile; + } + + if ($zeilen === []) { + throw new \RuntimeException('Keine einzige Zeile nennt eine Bankleitzahl.'); + } + + $mitZugang = 0; + $stand = null; + $banken = []; + foreach ($zeilen as $zeile) { + if ($zeile['pintan_url'] !== '') { + $mitZugang++; + } + if ($zeile['stand'] !== null && ($stand === null || $zeile['stand'] > $stand)) { + $stand = $zeile['stand']; + } + $banken[$zeile['blz']] = true; + } + + if (!$trockenlauf) { + Bankinstitut::ersetze($zeilen); + } + + return [ + 'gelesen' => count($this->saetze), + 'uebernommen' => count($zeilen), + 'ohne_blz' => $ohneBlz, + 'mit_zugang' => $mitZugang, + 'banken' => count($banken), + 'stand' => $stand, + // Die ersten Zeilen als Sichtprobe: Wer die Datei hochlädt, will + // vor dem Übernehmen sehen, dass Umlaute und Spalten stimmen. + 'beispiele' => array_slice($zeilen, 0, 5), + ]; + } + + /** + * Eine Dateizeile als Tabellenzeile – oder null, wenn keine Bankleitzahl + * darinsteht (die Datei endet gern mit Fußnoten). + * + * @param array $satz + * @return array|null + */ + private static function zeile(array $satz): ?array + { + $blz = Bankinstitut::nurZiffern($satz['blz'] ?? ''); + if ($blz === '') { + return null; + } + + $zeile = []; + foreach (self::SPALTEN as $schluessel => $spalte) { + $zeile[$spalte] = trim((string)($satz[$schluessel] ?? '')); + } + + $zeile['nr'] = (int)preg_replace('/\D/', '', $zeile['nr']); + $zeile['blz'] = $blz; + $zeile['stand'] = CsvTabelle::datum($zeile['stand']); + + // „nicht unterstützt“ steht in der Datei, wo es keinen Zugang gibt – + // als Adresse taugt das nicht. + if (!str_starts_with(strtolower($zeile['pintan_url']), 'http')) { + $zeile['pintan_url'] = ''; + } + + $laengen = [ + 'blz' => 16, 'bic' => 16, 'institut' => 191, 'ort' => 128, + 'rechenzentrum' => 128, 'organisation' => 64, 'hbci_adresse' => 191, + 'hbci_version' => 16, 'pintan_url' => 255, 'pintan_version' => 32, + ]; + foreach ($laengen as $spalte => $laenge) { + $zeile[$spalte] = mb_substr((string)$zeile[$spalte], 0, $laenge); + } + + return $zeile; + } +} diff --git a/src/Services/PaypalAbruf.php b/src/Services/PaypalAbruf.php new file mode 100644 index 0000000..1d04549 --- /dev/null +++ b/src/Services/PaypalAbruf.php @@ -0,0 +1,358 @@ + */ + private array $protokoll = []; + + private int $neu = 0; + private int $uebersprungen = 0; + + /** @param array $zugang mit entschlüsselten Zugangsdaten */ + private function __construct( + private array $zugang, + private int $abrufId + ) { + } + + /** + * Startet einen Abruf. + * + * `$stichtag` gilt nur für diesen Lauf und geht dem Abrufzeitraum des + * Zugangs vor – wie beim FinTS-Abruf. + * + * @return array{status: string, meldung: string, protokoll: list, + * neu: int, uebersprungen: int, zugang_id: int, modus: string, + * einrichtung: bool} + */ + public static function starten(int $zugangId, ?string $stichtag = null): array + { + $zugang = Bankzugang::zugangsdaten($zugangId); + if ($zugang === null || (string)$zugang['typ'] !== 'paypal') { + return self::ergebnis('fehler', 'Den PayPal-Zugang gibt es nicht.', [], 0, 0, $zugangId); + } + + foreach ([ + 'api_benutzer' => 'API-Benutzername', + 'api_kennwort' => 'API-Kennwort', + 'api_signatur' => 'API-Signatur', + ] as $feld => $bezeichnung) { + if (trim((string)$zugang[$feld]) === '') { + return self::ergebnis('fehler', 'Es fehlt: ' . $bezeichnung . '.', [], 0, 0, $zugangId); + } + } + + $stichtag = trim((string)$stichtag); + if ($stichtag !== '' && Bankzugang::stichtagDatum($stichtag) === null) { + return self::ergebnis('fehler', 'Der Stichtag ist kein gültiges Datum.', [], 0, 0, $zugangId); + } + + $abruf = new self($zugang, Bankabruf::beginnen($zugangId)); + + try { + return $abruf->lauf($stichtag); + } catch (\Throwable $e) { + DebugLog::fehler($e); + return $abruf->beenden('fehler', $e->getMessage()); + } + } + + // ── Ablauf ─────────────────────────────────────────────────────────────── + + /** @return array */ + private function lauf(string $stichtag): array + { + $nvp = new PaypalNvp( + (string)$this->zugang['api_benutzer'], + (string)$this->zugang['api_kennwort'], + (string)$this->zugang['api_signatur'], + (int)$this->zugang['sandbox'] === 1 + ); + + $this->notiere('Endpunkt: ' . $nvp->endpunkt()); + + // Die Längen der drei Angaben gehören ins Protokoll, nicht die Angaben + // selbst: Scheitert die Anmeldung, ist das die einzige Spur, an der sich + // ablesen lässt, welche Angabe klemmt – PayPals Antwort nennt sie nie. + $auffaellig = PaypalNvp::auffaelligkeiten( + (string)$this->zugang['api_benutzer'], + (string)$this->zugang['api_kennwort'], + (string)$this->zugang['api_signatur'] + ); + foreach ($auffaellig as $hinweis) { + $this->notiere('Hinweis: ' . $hinweis); + } + + // Ein PayPal-Zugang hat genau ein Konto; beim ersten Abruf entsteht es. + $bankkonto = Bankzugang::paypalKonto((int)$this->zugang['id']); + + $pruefung = $nvp->abfrage(['METHOD' => 'GetBalance']); + if ($pruefung === null) { + return $this->beenden('fehler', 'PayPal antwortet nicht.'); + } + + $fehler = PaypalNvp::fehlermeldung($pruefung); + if ($fehler !== null) { + // Weist PayPal die Anmeldung ab, gehören die Auffälligkeiten in die + // Meldung selbst: Sie ist das Einzige, was der Nutzer zu sehen + // bekommt, und sie sagt sonst nur, dass etwas nicht stimmt. + return $this->beenden('fehler', $auffaellig === [] + ? $fehler + : $fehler . ' Dazu fällt auf: ' . implode(' ', $auffaellig)); + } + + $saldo = PaypalNvp::saldoAusAntwort($pruefung); + if ($saldo !== null) { + Bankzugang::merkeSaldo((int)$bankkonto['id'], $saldo['betrag']); + $this->notiere(sprintf( + 'Kontostand: %s %s', + number_format($saldo['betrag'], 2, ',', '.'), + $saldo['waehrung'] + )); + } else { + $this->notiere('Verbindung steht; einen Kontostand nennt PayPal nicht.'); + } + + [$von, $beschreibung] = Bankzugang::abrufBeginn($this->zugang, $stichtag); + $bis = new \DateTimeImmutable('today'); + + $this->notiere('Zeitraum: ' . $beschreibung . ' (ab ' . $von->format('d.m.Y') . ').'); + + // PayPal liefert je Aufruf höchstens 100 Buchungen. Der Zeitraum wird + // deshalb in Abschnitte zerlegt – ohne das fehlten in einem vollen Monat + // still und leise die ältesten Buchungen. + $regeln = Einstellung::getBool('banking_regeln_beim_abruf', true) ? AbgleichRegel::alle(true) : []; + $cursor = $von; + + while ($cursor <= $bis) { + $ende = $cursor->modify('+' . PaypalNvp::ZEITSCHEIBE_TAGE . ' days'); + if ($ende > $bis) { + $ende = $bis; + } + + $antwort = $nvp->umsaetze($cursor, $ende); + if ($antwort === null) { + return $this->beenden('fehler', sprintf( + 'PayPal antwortet nicht (%s bis %s).', + $cursor->format('d.m.Y'), + $ende->format('d.m.Y') + )); + } + + $fehler = PaypalNvp::fehlermeldung($antwort); + if ($fehler !== null) { + return $this->beenden('fehler', $fehler); + } + + if (PaypalNvp::abgeschnitten($antwort)) { + $this->notiere(sprintf( + ' %s bis %s: PayPal meldet mehr Buchungen, als ein Aufruf hergibt –' + . ' mit einem späteren Stichtag noch einmal abrufen.', + $cursor->format('d.m.Y'), + $ende->format('d.m.Y') + )); + } + + $this->schreibeUmsaetze( + $bankkonto, + PaypalNvp::umsaetzeAusAntwort($antwort), + $regeln, + $cursor, + $ende + ); + + // Beide Grenzen zählen mit; der nächste Abschnitt beginnt am Tag + // danach, sonst käme jeder Grenztag zweimal. + $cursor = $ende->modify('+1 day'); + } + + Bankzugang::merkeAbruf((int)$this->zugang['id']); + + $meldung = sprintf( + '%d neue Umsätze, %d bereits bekannt.', + $this->neu, + $this->uebersprungen + ); + + $gebucht = Bankabgleich::automatisch(); + if ($gebucht !== null) { + $bericht = Bankabgleich::bericht($gebucht); + $this->notiere($bericht); + $meldung .= ' ' . $bericht; + } + + return $this->beenden('erfolg', $meldung); + } + + /** + * Schreibt die Buchungen einer Zeitscheibe weg. + * + * @param array $bankkonto + * @param list> $umsaetze + * @param list> $regeln + */ + private function schreibeUmsaetze( + array $bankkonto, + array $umsaetze, + array $regeln, + \DateTimeImmutable $von, + \DateTimeImmutable $bis + ): void { + $neu = 0; + $alt = 0; + + foreach ($umsaetze as $umsatz) { + $id = Bankumsatz::anlegen([ + 'zugang_id' => (int)$this->zugang['id'], + 'konto_id' => $bankkonto['konto_id'], + 'bank_konto_id' => (int)$bankkonto['id'], + 'extern_id' => Bankumsatz::externIdKennung( + (int)$bankkonto['id'], + (string)$umsatz['kennung'] + ), + 'buchungstag' => $umsatz['datum'], + 'wertstellung' => $umsatz['datum'], + 'betrag' => $umsatz['betrag'], + 'waehrung' => $umsatz['waehrung'], + 'empfaenger' => $umsatz['empfaenger'], + 'verwendungszweck' => $umsatz['verwendungszweck'], + 'buchungsart' => $umsatz['art'], + 'roh' => json_encode([ + 'kennung' => $umsatz['kennung'], + 'art' => $umsatz['art'], + 'status' => $umsatz['status'], + 'brutto' => $umsatz['brutto'], + 'netto' => $umsatz['betrag'], + ], JSON_UNESCAPED_UNICODE), + ]); + + if ($id === null) { + $alt++; + continue; + } + + $neu++; + + // Wie beim FinTS-Abruf zählt die Regel ihren Treffer schon hier – + // die Regelliste zeigt damit, welche im Alltag überhaupt greifen. + if ($regeln !== []) { + $regel = AbgleichRegel::passendeRegel( + [ + 'empfaenger' => $umsatz['empfaenger'], + 'verwendungszweck' => $umsatz['verwendungszweck'], + 'betrag' => $umsatz['betrag'], + ], + $regeln + ); + if ($regel !== null) { + AbgleichRegel::zaehleTreffer((int)$regel['id']); + } + } + } + + $this->neu += $neu; + $this->uebersprungen += $alt; + + $this->notiere(sprintf( + '%s bis %s: %d neu, %d bereits bekannt.', + $von->format('d.m.Y'), + $bis->format('d.m.Y'), + $neu, + $alt + )); + } + + // ── Protokoll und Ergebnis ─────────────────────────────────────────────── + + private function notiere(string $zeile): void + { + $this->protokoll[] = date('H:i:s') . ' ' . $zeile; + DebugLog::schreibe('paypal', $zeile); + } + + /** @return array */ + private function beenden(string $status, string $meldung): array + { + $this->protokoll[] = date('H:i:s') . ' ' . $meldung; + + Bankabruf::abschliessen( + $this->abrufId, + $status, + $this->neu, + $this->uebersprungen, + $meldung, + $this->protokoll + ); + + return self::ergebnis( + $status, + $meldung, + $this->protokoll, + $this->neu, + $this->uebersprungen, + (int)$this->zugang['id'] + ); + } + + /** + * Dieselbe Form wie bei `FinTsAbruf` – der Controller wertet beide gleich + * aus. + * + * @param list $protokoll + * @return array + */ + private static function ergebnis( + string $status, + string $meldung, + array $protokoll, + int $neu, + int $uebersprungen, + int $zugangId + ): array { + return [ + 'status' => $status, + 'meldung' => $meldung, + 'protokoll' => $protokoll, + 'neu' => $neu, + 'uebersprungen' => $uebersprungen, + 'zugang_id' => $zugangId, + 'modus' => 'voll', + 'einrichtung' => false, + ]; + } +} diff --git a/src/Services/PaypalNvp.php b/src/Services/PaypalNvp.php new file mode 100644 index 0000000..4f6afd2 --- /dev/null +++ b/src/Services/PaypalNvp.php @@ -0,0 +1,366 @@ +sandbox ? self::ENDPUNKT_SANDBOX : self::ENDPUNKT_LIVE; + } + + /** + * Ein Aufruf der Schnittstelle. + * + * @param array $felder + * @return array|null null, wenn die Gegenstelle nicht antwortet + */ + public function abfrage(array $felder): ?array + { + $rumpf = http_build_query(array_merge([ + 'USER' => $this->benutzer, + 'PWD' => $this->kennwort, + 'SIGNATURE' => $this->signatur, + 'VERSION' => self::VERSION, + ], $felder)); + + $verbindung = curl_init($this->endpunkt()); + if ($verbindung === false) { + return null; + } + + curl_setopt_array($verbindung, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $rumpf, + CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'], + CURLOPT_TIMEOUT => self::ZEITGRENZE, + ]); + + $antwort = curl_exec($verbindung); + $status = (int)curl_getinfo($verbindung, CURLINFO_HTTP_CODE); + curl_close($verbindung); + + if ($status !== 200 || !is_string($antwort) || $antwort === '') { + DebugLog::schreibe('paypal', 'HTTP-Status ' . $status . ' vom PayPal-Endpunkt.'); + return null; + } + + parse_str($antwort, $ergebnis); + + /** @var array $ergebnis */ + return $ergebnis; + } + + /** Der Kontostand. */ + public function saldo(): ?array + { + $antwort = $this->abfrage(['METHOD' => 'GetBalance']); + + return $antwort === null ? null : self::saldoAusAntwort($antwort); + } + + /** + * Die Buchungen eines Zeitraums. Beide Grenzen zählen mit. + * + * @return array|null + */ + public function umsaetze(\DateTimeImmutable $von, \DateTimeImmutable $bis): ?array + { + return $this->abfrage([ + 'METHOD' => 'TransactionSearch', + 'STARTDATE' => $von->format('Y-m-d') . 'T00:00:00Z', + 'ENDDATE' => $bis->format('Y-m-d') . 'T23:59:59Z', + ]); + } + + // ── Antworten auswerten ────────────────────────────────────────────────── + + /** + * Hat PayPal die Anfrage angenommen? `SuccessWithWarning` gilt als Erfolg: + * Die Warnung ist fast immer der Hinweis, dass mehr Buchungen vorliegen als + * geliefert werden – dagegen helfen die Zeitscheiben, nicht ein Abbruch. + * + * @param array $antwort + */ + public static function erfolgreich(array $antwort): bool + { + return in_array($antwort['ACK'] ?? '', ['Success', 'SuccessWithWarning'], true); + } + + /** + * Meldete PayPal eine Warnung? Sie steht dafür, dass die Antwort abgeschnitten + * ist – der Zeitraum enthält mehr Buchungen, als ein Aufruf hergibt. + * + * @param array $antwort + */ + public static function abgeschnitten(array $antwort): bool + { + return ($antwort['ACK'] ?? '') === 'SuccessWithWarning'; + } + + /** + * Die Fehlermeldung einer abgelehnten Antwort – oder null, wenn sie taugt. + * + * @param array $antwort + */ + public static function fehlermeldung(array $antwort): ?string + { + if (self::erfolgreich($antwort)) { + return null; + } + + $meldung = trim((string)( + $antwort['L_LONGMESSAGE0'] + ?? $antwort['L_SHORTMESSAGE0'] + ?? '' + )); + + if ($meldung === '') { + $meldung = 'PayPal hat die Anfrage abgelehnt (ACK ' . ($antwort['ACK'] ?? '?') . ').'; + } + + // Diese eine Meldung sagt nicht, was sie meint: PayPal schickt sie sowohl + // bei falschen Zugangsdaten als auch bei Zugangsdaten, die nicht zum + // Endpunkt gehören. Beides muss dastehen – nennte man nur den Endpunkt, + // suchte man den Fehler garantiert an der falschen Stelle. + if (stripos($meldung, 'Security header') !== false) { + $meldung .= ' – PayPal weist die Zugangsdaten ab. Zwei Ursachen kommen infrage:' + . ' (1) eine der drei Angaben stimmt nicht,' + . ' (2) sie gehören zum anderen Endpunkt – Live-Zugangsdaten brauchen den Haken' + . ' „Sandbox“ ausgeschaltet, Sandbox-Zugangsdaten eingeschaltet.'; + } + + return $meldung; + } + + /** + * Was an den drei Angaben nicht nach PayPal aussieht. + * + * Reine Formprüfung, bewusst **nur ein Hinweis** und kein Abweisen: PayPal + * kann seine Formate ändern, und ein Zugang, der funktioniert, soll nicht an + * einer Regel von hier scheitern. Der Sinn ist ein anderer – die Antwort + * „Security header is not valid“ nennt nie, welche der drei Angaben klemmt, + * und wer sie liest, sucht zuerst beim Endpunkt. + * + * @return list + */ + public static function auffaelligkeiten(string $benutzer, string $kennwort, string $signatur): array + { + $hinweise = []; + + // PayPal vergibt Namen der Form `name_api1.domain.de`. + if ($benutzer !== '' && !str_contains($benutzer, '_api')) { + $hinweise[] = sprintf( + 'Der API-Benutzername („%s“, %d Zeichen) sieht nicht nach PayPal aus –' + . ' er hat dort die Form name_api1.domain.de. Verwechselt wird er leicht mit der' + . ' E-Mail-Adresse des Kontos.', + self::gekuerzt($benutzer), + strlen($benutzer) + ); + } + + if ($kennwort !== '' && strlen($kennwort) !== self::LAENGE_KENNWORT) { + $hinweise[] = sprintf( + 'Das API-Kennwort ist %d Zeichen lang; PayPal vergibt %d.', + strlen($kennwort), + self::LAENGE_KENNWORT + ); + } + + if ($signatur !== '' && strlen($signatur) !== self::LAENGE_SIGNATUR) { + $hinweise[] = sprintf( + 'Die API-Signatur ist %d Zeichen lang; PayPal vergibt %d.', + strlen($signatur), + self::LAENGE_SIGNATUR + ); + } + + return $hinweise; + } + + /** Genug zum Wiedererkennen, zu wenig zum Mitlesen. */ + private static function gekuerzt(string $wert): string + { + return strlen($wert) <= 8 ? substr($wert, 0, 2) . '…' : substr($wert, 0, 6) . '…'; + } + + /** + * Kontostand und Währung aus einer `GetBalance`-Antwort. + * + * @param array $antwort + * @return array{betrag: float, waehrung: string}|null + */ + public static function saldoAusAntwort(array $antwort): ?array + { + if (!self::erfolgreich($antwort)) { + return null; + } + + // Der Dokumentationsfall … + if (isset($antwort['AMT']) && is_numeric($antwort['AMT'])) { + return [ + 'betrag' => round((float)$antwort['AMT'], 2), + 'waehrung' => (string)($antwort['CURRENCYCODE'] ?? 'EUR'), + ]; + } + + // … und der, den Live-Konten tatsächlich liefern. Index 0 ist die + // Hauptwährung des Kontos. + if (isset($antwort['L_AMT0']) && is_numeric($antwort['L_AMT0'])) { + return [ + 'betrag' => round((float)$antwort['L_AMT0'], 2), + 'waehrung' => (string)($antwort['L_CURRENCYCODE0'] ?? 'EUR'), + ]; + } + + return null; + } + + /** + * Die Buchungen aus einer `TransactionSearch`-Antwort. + * + * Gerechnet wird mit `L_NETAMT`, dem Betrag nach Abzug der PayPal-Gebühr: + * Genau der bewegt das Guthaben, und nur damit stimmt der Kontostand am Ende + * mit der Summe der Buchungen überein. + * + * @param array $antwort + * @return list> + */ + public static function umsaetzeAusAntwort(array $antwort): array + { + if (!self::erfolgreich($antwort)) { + return []; + } + + $umsaetze = []; + + for ($i = 0; isset($antwort['L_TRANSACTIONID' . $i]); $i++) { + $kennung = trim((string)$antwort['L_TRANSACTIONID' . $i]); + if ($kennung === '') { + continue; + } + + $brutto = (float)($antwort['L_AMT' . $i] ?? 0); + $netto = isset($antwort['L_NETAMT' . $i]) + ? (float)$antwort['L_NETAMT' . $i] + : $brutto; + + // Einwilligungen und Abonnements stehen als Buchung über 0,00 € in + // der Liste. Sie bewegen nichts und hätten im Kontoblatt nichts zu + // suchen. + if (round($netto, 2) === 0.0 && round($brutto, 2) === 0.0) { + continue; + } + + $art = trim((string)($antwort['L_TYPE' . $i] ?? '')); + $name = trim((string)($antwort['L_NAME' . $i] ?? '')); + $mail = trim((string)($antwort['L_EMAIL' . $i] ?? '')); + $datum = self::datum((string)($antwort['L_TIMESTAMP' . $i] ?? '')); + + $umsaetze[] = [ + 'kennung' => $kennung, + 'datum' => $datum, + 'betrag' => round($netto, 2), + 'brutto' => round($brutto, 2), + 'waehrung' => mb_substr((string)($antwort['L_CURRENCYCODE' . $i] ?? 'EUR'), 0, 3), + 'art' => $art, + 'status' => trim((string)($antwort['L_STATUS' . $i] ?? '')), + // Wer gezahlt hat oder bezahlt wurde. Nennt PayPal keinen Namen, + // ist die Adresse das Nächstbeste; fehlt auch die, bleibt die Art + // der Buchung – etwa „Transfer“ bei der Abbuchung vom Bankkonto. + 'empfaenger' => $name !== '' ? $name : ($mail !== '' ? $mail : ('PayPal ' . $art)), + 'verwendungszweck' => self::zweck($name, $mail, $art), + ]; + } + + return $umsaetze; + } + + /** + * Der Verwendungszweck: die Adresse des Gegenübers und die Art der Buchung. + * Doppelt genannt wird nichts – steht die Adresse schon als Empfänger da, + * bleibt sie hier weg. + */ + private static function zweck(string $name, string $mail, string $art): string + { + $teile = []; + if ($mail !== '' && $mail !== $name) { + $teile[] = $mail; + } + if ($art !== '') { + $teile[] = $art; + } + + return implode(' · ', $teile); + } + + /** Der Zeitstempel von PayPal als Datum – heute, wenn er unlesbar ist. */ + private static function datum(string $roh): string + { + $roh = trim($roh); + if ($roh === '') { + return date('Y-m-d'); + } + + try { + return (new \DateTimeImmutable($roh))->format('Y-m-d'); + } catch (\Throwable) { + return date('Y-m-d'); + } + } +} diff --git a/src/Views/banking/formular.php b/src/Views/banking/formular.php index ac5aaaf..f78c234 100644 --- a/src/Views/banking/formular.php +++ b/src/Views/banking/formular.php @@ -6,11 +6,17 @@ use Finanzmanager\View; /** @var array|null $zugang */ /** @var list> $konten Bankkonten dieses Zugangs */ /** @var list> $abrufe */ +/** @var array $vorgabe Vorbelegung aus dem Bankleitzahlen-Verzeichnis */ /** @var string $csrfToken */ -$neu = $zugang === null; -$ziel = $neu ? '/banking/neu' : '/banking/' . (int)$zugang['id']; -$wert = static fn(string $feld, string $standard = ''): string => (string)($zugang[$feld] ?? $standard); +$neu = $zugang === null; +$ziel = $neu ? '/banking/neu/fints' : '/banking/' . (int)$zugang['id']; +$vorgabe = $vorgabe ?? []; + +// Beim Bearbeiten zählt allein der gespeicherte Zugang; beim Anlegen springt +// die Vorbelegung aus dem Bankleitzahlen-Verzeichnis ein, sofern es eine gibt. +$wert = static fn(string $feld, string $standard = ''): string => + (string)($zugang[$feld] ?? $vorgabe[$feld] ?? $standard); include __DIR__ . '/../layout/kopf.php'; ?> @@ -20,7 +26,9 @@ include __DIR__ . '/../layout/kopf.php';
- Zurück zu den Bankverbindungen + + +
@@ -51,10 +59,12 @@ include __DIR__ . '/../layout/kopf.php'; + placeholder="https://…">

- Der HBCI/FinTS-Zugangspunkt der Bank – sie nennt ihn in ihrer Hilfe zum - Online-Banking. Muss mit https:// beginnen. + Der HBCI/FinTS-Zugangspunkt der Bank; muss mit https:// beginnen. + Leer lassen heißt: zur Bankleitzahl im + Bankleitzahlen-Verzeichnis nachschlagen. + Dort lässt sich auch nach der Bank suchen und das Formular fertig ausgefüllt öffnen.

diff --git a/src/Views/banking/index.php b/src/Views/banking/index.php index 6f80453..5b683ec 100644 --- a/src/Views/banking/index.php +++ b/src/Views/banking/index.php @@ -33,8 +33,9 @@ include __DIR__ . '/../layout/kopf.php';

- Noch keine Bankverbindung eingerichtet. Gebraucht werden Bankleitzahl, FinTS-Adresse, - Benutzerkennung und PIN – dieselben Angaben wie im Online-Banking der Bank. + Noch keine Bankverbindung eingerichtet. Für eine Bank über HBCI/FinTS gebraucht + werden Bankleitzahl, FinTS-Adresse, Benutzerkennung und PIN – dieselben Angaben wie + im Online-Banking; für PayPal die drei Angaben des API-Zugriffs.

@@ -56,6 +57,12 @@ include __DIR__ . '/../layout/kopf.php'; BLZ + + + +
diff --git a/src/Views/banking/paypal.php b/src/Views/banking/paypal.php new file mode 100644 index 0000000..ad0796e --- /dev/null +++ b/src/Views/banking/paypal.php @@ -0,0 +1,205 @@ +|null $zugang + * @var array|null $bankkonto Die eine Kontozeile dieses Zugangs + * @var list> $eigene Konten der Anwendung + * @var list> $abrufe + * @var string $csrfToken + */ + +$neu = $zugang === null; +$ziel = $neu ? '/banking/neu/paypal' : '/banking/' . (int)$zugang['id']; +$wert = static fn(string $feld, string $standard = ''): string => (string)($zugang[$feld] ?? $standard); + +$zugeordnet = $bankkonto === null ? null : ($bankkonto['konto_id'] === null ? null : (int)$bankkonto['konto_id']); + +include __DIR__ . '/../layout/kopf.php'; +?> + +
+ Zurück zu den Bankverbindungen + + + + + + + + +
+ +
+ + + + +

Frei wählbar – so steht die Verbindung in der Übersicht.

+ +

Zugangsdaten

+

+ Die drei Angaben stehen im PayPal-Konto unter Profil → Mein Verkäuferkonto → + API-Zugriff. Gebraucht wird dort der Punkt NVP/SOAP-API-Integration + mit einer API-Signatur, nicht ein API-Zertifikat. +

+ + + +

Endet üblicherweise auf _api1.….

+ + + > + + + > +

+ Kennwort und Signatur werden AES-verschlüsselt gespeichert (Schlüssel + APP_KEY) und nie wieder angezeigt. +

+ + +

+ + Nur für Testzugänge aus dem PayPal-Entwicklerkonto. Mit dem falschen Haken antwortet + PayPal ausschließlich mit „Security header is not valid“ – die Zugangsdaten + sind dann in Ordnung, nur der Endpunkt passt nicht. +

+ +

Konto

+

+ PayPal führt je Konto genau ein Guthabenkonto. Ohne Zuordnung kommen die Umsätze + zwar herein, lassen sich aber nicht buchen – sie warten dann in der Abgleichliste. +

+ + + + + +
+ + + + + + + + +

+ Der Anfangssaldo deckt die Zeit ab, die der Abruf nicht mehr hergibt. +

+
+ +

Abruf

+ + + + + + +

+ Gilt, wenn oben Ab Stichtag gewählt ist. PayPal liefert je Aufruf höchstens + 100 Buchungen; der Finanzmanager fragt deshalb in Abschnitten von 30 Tagen ab, ein + weit zurückreichender Zeitraum dauert entsprechend länger. +

+ + + + +

+ Kontostand bei PayPal: + + + Stand + + +

+ + +
+ +
+
+ + + +

Abrufe dieser Verbindung

+ + + + + + + + + + + + + + +
BegonnenStatusNeuMeldung
+ + +
+ +
+ +
+
+ + + diff --git a/src/Views/banking/typ.php b/src/Views/banking/typ.php new file mode 100644 index 0000000..db1e479 --- /dev/null +++ b/src/Views/banking/typ.php @@ -0,0 +1,50 @@ + + + + + + + diff --git a/src/Views/verwaltung/bankinstitute.php b/src/Views/verwaltung/bankinstitute.php new file mode 100644 index 0000000..a7a8b47 --- /dev/null +++ b/src/Views/verwaltung/bankinstitute.php @@ -0,0 +1,203 @@ +> $treffer + * @var int $grenze + * @var int $anzahl + * @var int $mitZugang + * @var string|null $stand + * @var string $quelldatei + * @var string $importAm + * @var array|null $ergebnis + * @var bool $trockenlauf + * @var string $dateiname + * @var string $csrfToken + */ + +$datum = static fn(?string $iso): string => + $iso === null || $iso === '' ? '–' : date('d.m.Y', strtotime($iso)); + +include __DIR__ . '/../layout/kopf.php'; +?> + + + +
+
+

Verzeichnis

+

+

+ Einträge, davon mit PIN/TAN-Zugangspunkt. + Jüngste Änderung in den Daten: . +

+
+ +
+

Stand

+ +

+ Noch nichts eingelesen. Ohne Verzeichnis muss die FinTS-Adresse beim Anlegen + einer Bankverbindung von Hand eingetragen werden. +

+ +

+ Eingelesen am + . +

+
+ + +
+ +
+
+ + +
+
+

+
+ + + + + + + + + + + + + +
Zeilen in der Datei
Ohne Bankleitzahl übergangen
Verschiedene Bankleitzahlen
Mit PIN/TAN-Zugangspunkt
Jüngste Änderung in den Daten
+ + +

+ Sichtprobe der ersten Zeilen – stimmen hier Umlaute und Spalten, stimmt der Rest auch. +

+ + + + + + + + + + + + + + +
BLZInstitutOrtFinTS-Adresse
+ + + +

+ Es wurde nichts geschrieben. Zum Übernehmen dieselbe Datei unten noch einmal + auswählen und „Übernehmen“ drücken – dabei wird der bisherige Bestand ersetzt. +

+ +
+ + +
+ +

Institutsliste einlesen

+ +

+ Erwartet wird die CSV-Liste der FinTS-Institute der Deutschen Kreditwirtschaft + (hbci-zka.de): Semikolon als Trenner, mit den Spalten BLZ, + BIC, Institut, Ort und PIN/TAN-Zugang URL. + ISO-8859-1 wie UTF-8 werden gelesen. Ein Import ersetzt den bisherigen Bestand + vollständig. +

+ + + + +
+ + +
+
+ +
+

Nachschlagen

+ + + +

+ Eine reine Ziffernfolge gilt als Bankleitzahl und wird von vorn verglichen; + alles andere wird in Institut, Ort und BIC gesucht. +

+ +
+ +
+
+ + + +

+ Kein Treffer für „“. + +

+ + = $grenze): ?> +

+ Es werden höchstens Treffer gezeigt – die Suche weiter eingrenzen. +

+ + + + + + + + + + + > + + + + + + + + + + +
BLZBICInstitutOrtFinTS-AdresseStandAktion
+ + kein PIN/TAN-Zugang genannt + + + + + + + + Verbindung anlegen + + +
+ + + + diff --git a/src/Views/verwaltung/index.php b/src/Views/verwaltung/index.php index de4397b..3c0b7f4 100644 --- a/src/Views/verwaltung/index.php +++ b/src/Views/verwaltung/index.php @@ -70,6 +70,19 @@ include __DIR__ . '/../layout/kopf.php'; +
+

Bankleitzahlen

+

+

+ Das Verzeichnis der FinTS-Institute – dort steht die FinTS-Adresse zur + Bankleitzahl, die beim Anlegen einer Bankverbindung sonst zu suchen wäre. + Die Liste wird als CSV eingelesen. +

+ +
+

Termine

diff --git a/tests/31_PaypalZugangTest.php b/tests/31_PaypalZugangTest.php new file mode 100644 index 0000000..79b8e7d --- /dev/null +++ b/tests/31_PaypalZugangTest.php @@ -0,0 +1,406 @@ +mandantAnlegen(); + $this->zugangAnlegen(); + + $this->zugangKenntSeinenTyp(); + $this->geheimnisseStehenVerschluesseltInDerDatenbank(); + $this->leereGeheimnisseBleibenUnveraendert(); + $this->typBleibtBeimSpeichernStehen(); + $this->kontoEntstehtGenauEinmal(); + $this->kontoLaesstSichZuordnen(); + $this->fingerabdruckHaengtAnDerTransaktionsnummer(); + + $this->endpunktRichtetSichNachDerSandbox(); + $this->abgelehnteAntwortNenntDenGrund(); + $this->sicherheitsfehlerNenntBeideUrsachen(); + $this->auffaelligeAngabenWerdenBenannt(); + $this->leerraumUmDieGeheimnisseFaelltWeg(); + $this->saldoKommtAusBeidenAntwortformen(); + $this->umsaetzeWerdenAusgelesen(); + $this->nullbuchungenFallenHeraus(); + $this->warnungGiltAlsErfolg(); + } + + private function zugangAnlegen(): void + { + $this->zugang = Bankzugang::anlegen([ + 'typ' => 'paypal', + 'name' => 'PayPal-Test', + 'bankname' => 'PayPal', + 'api_benutzer' => 'test_api1.example.org', + 'api_kennwort' => 'kennwort-geheim', + 'api_signatur' => 'signatur-geheim', + 'sandbox' => 1, + 'abrufzeitraum' => 'letzter_abruf', + 'aktiv' => 1, + ]); + } + + // ── Der Zugang ─────────────────────────────────────────────────────────── + + private function zugangKenntSeinenTyp(): void + { + $zugang = Bankzugang::find($this->zugang); + + $this->assertEquals('paypal', $zugang['typ'], 'Der Zugang ist ein PayPal-Zugang'); + $this->assertEquals('test_api1.example.org', $zugang['api_benutzer'], 'Der API-Benutzer steht im Klartext'); + $this->assertEquals(1, (int)$zugang['sandbox'], 'Der Sandbox-Haken ist gesetzt'); + } + + private function geheimnisseStehenVerschluesseltInDerDatenbank(): void + { + if (!Crypto::einsatzbereit()) { + $this->assert(false, 'APP_KEY fehlt – ohne ihn lässt sich die Verschlüsselung nicht prüfen'); + return; + } + + $roh = Bankzugang::find($this->zugang); + + $this->assert( + !str_contains((string)$roh['api_kennwort'], 'kennwort-geheim'), + 'Das API-Kennwort steht nicht im Klartext in der Datenbank' + ); + $this->assert( + !str_contains((string)$roh['api_signatur'], 'signatur-geheim'), + 'Die API-Signatur steht nicht im Klartext in der Datenbank' + ); + + $daten = Bankzugang::zugangsdaten($this->zugang); + + $this->assertEquals('kennwort-geheim', $daten['api_kennwort'], 'Das Kennwort kommt entschlüsselt zurück'); + $this->assertEquals('signatur-geheim', $daten['api_signatur'], 'Die Signatur kommt entschlüsselt zurück'); + } + + private function leereGeheimnisseBleibenUnveraendert(): void + { + // Dieselbe Regel wie bei der FinTS-PIN: Ein leeres Feld im Formular + // heißt „unverändert“, sonst verlöre jedes Speichern die Zugangsdaten. + Bankzugang::speichern($this->zugang, [ + 'typ' => 'paypal', + 'name' => 'PayPal-Test', + 'api_benutzer' => 'test_api1.example.org', + 'api_kennwort' => '', + 'api_signatur' => '', + 'sandbox' => 1, + 'abrufzeitraum' => 'letzter_abruf', + 'aktiv' => 1, + ]); + + $daten = Bankzugang::zugangsdaten($this->zugang); + + $this->assertEquals('kennwort-geheim', $daten['api_kennwort'], 'Das Kennwort überlebt das Speichern'); + $this->assertEquals('signatur-geheim', $daten['api_signatur'], 'Die Signatur überlebt das Speichern'); + } + + private function typBleibtBeimSpeichernStehen(): void + { + // Ein PayPal-Zugang, der sich in einen FinTS-Zugang verwandelte, hätte + // weder passende Zugangsdaten noch passende Konten. + Bankzugang::speichern($this->zugang, [ + 'typ' => 'fints', + 'name' => 'PayPal-Test', + 'api_benutzer' => 'test_api1.example.org', + 'abrufzeitraum' => 'letzter_abruf', + 'aktiv' => 1, + ]); + + $this->assertEquals( + 'paypal', + Bankzugang::find($this->zugang)['typ'], + 'Der Typ lässt sich nachträglich nicht umstellen' + ); + } + + // ── Das eine Konto ─────────────────────────────────────────────────────── + + private function kontoEntstehtGenauEinmal(): void + { + $erstes = Bankzugang::paypalKonto($this->zugang); + $zweites = Bankzugang::paypalKonto($this->zugang); + + $this->assertEquals((int)$erstes['id'], (int)$zweites['id'], 'Der zweite Abruf findet dasselbe Konto wieder'); + $this->assertEquals( + Bankzugang::PAYPAL_KENNUNG, + $erstes['kontonummer'], + 'Das Konto trägt die feste PayPal-Kennung' + ); + $this->assertEquals(1, count(Bankzugang::konten($this->zugang)), 'Ein PayPal-Zugang führt genau ein Konto'); + } + + private function kontoLaesstSichZuordnen(): void + { + $konto = Konto::anlegen([ + 'name' => 'PayPal-Guthaben', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + + $bankkonto = Bankzugang::paypalKonto($this->zugang); + Bankzugang::ordneKontoZu((int)$bankkonto['id'], $konto); + + $this->assertEquals( + $konto, + (int)Bankzugang::konten($this->zugang)[0]['konto_id'], + 'Das Konto ist zugeordnet' + ); + } + + private function fingerabdruckHaengtAnDerTransaktionsnummer(): void + { + $bankkonto = (int)Bankzugang::paypalKonto($this->zugang)['id']; + + $eine = Bankumsatz::externIdKennung($bankkonto, '3XY12345ABC678901'); + $selbe = Bankumsatz::externIdKennung($bankkonto, ' 3XY12345ABC678901 '); + $andere = Bankumsatz::externIdKennung($bankkonto, '3XY12345ABC678902'); + + $this->assertEquals($selbe, $eine, 'Leerraum um die Nummer ändert den Fingerabdruck nicht'); + $this->assert($eine !== $andere, 'Zwei Nummern ergeben zwei Fingerabdrücke'); + $this->assertEquals(40, strlen($eine), 'Der Fingerabdruck passt in die Spalte'); + + // Derselbe Umsatz darf beim zweiten Lauf nicht noch einmal hereinkommen – + // die Abrufzeiträume überlappen sich fast immer. + $konto = (int)Bankzugang::konten($this->zugang)[0]['konto_id']; + + $daten = [ + 'zugang_id' => $this->zugang, + 'konto_id' => $konto, + 'bank_konto_id' => $bankkonto, + 'extern_id' => $eine, + 'buchungstag' => '2026-08-01', + 'wertstellung' => '2026-08-01', + 'betrag' => -12.34, + 'empfaenger' => 'Beispielshop', + ]; + + $this->assertNotEmpty(Bankumsatz::anlegen($daten), 'Der Umsatz kommt herein'); + $this->assertNull(Bankumsatz::anlegen($daten), 'Beim zweiten Mal nicht noch einmal'); + } + + // ── Die Antworten von PayPal ───────────────────────────────────────────── + + private function endpunktRichtetSichNachDerSandbox(): void + { + $live = new PaypalNvp('b', 'k', 's', false); + $test = new PaypalNvp('b', 'k', 's', true); + + $this->assertEquals(PaypalNvp::ENDPUNKT_LIVE, $live->endpunkt(), 'Ohne Haken der Live-Endpunkt'); + $this->assertEquals(PaypalNvp::ENDPUNKT_SANDBOX, $test->endpunkt(), 'Mit Haken der Sandbox-Endpunkt'); + } + + private function abgelehnteAntwortNenntDenGrund(): void + { + $antwort = [ + 'ACK' => 'Failure', + 'L_SHORTMESSAGE0' => 'Invalid argument', + 'L_LONGMESSAGE0' => 'Die Suche war nicht erfolgreich.', + ]; + + $this->assert(!PaypalNvp::erfolgreich($antwort), 'Failure gilt nicht als Erfolg'); + $this->assert( + str_contains((string)PaypalNvp::fehlermeldung($antwort), 'Die Suche war nicht erfolgreich.'), + 'Die ausführliche Meldung steht im Fehler' + ); + $this->assertEquals([], PaypalNvp::umsaetzeAusAntwort($antwort), 'Aus einer Absage kommen keine Umsätze'); + $this->assertNull(PaypalNvp::saldoAusAntwort($antwort), 'Und auch kein Kontostand'); + } + + private function sicherheitsfehlerNenntBeideUrsachen(): void + { + // Diese Meldung schickt PayPal bei falschen Zugangsdaten genauso wie bei + // Zugangsdaten, die zum anderen Endpunkt gehören. Nennte die Erklärung + // nur den Endpunkt, suchte man garantiert an der falschen Stelle. + $meldung = (string)PaypalNvp::fehlermeldung([ + 'ACK' => 'Failure', + 'L_LONGMESSAGE0' => 'Security header is not valid', + ]); + + $this->assert(str_contains($meldung, 'Sandbox'), 'Der Sicherheitsfehler nennt den Endpunkt'); + $this->assert( + str_contains($meldung, 'eine der drei Angaben stimmt nicht'), + 'Und ebenso die falschen Zugangsdaten' + ); + } + + private function auffaelligeAngabenWerdenBenannt(): void + { + // Echte PayPal-Angaben: Name der Form name_api1.domain, Kennwort 16 + // Zeichen, Signatur 56. + $this->assertEquals( + [], + PaypalNvp::auffaelligkeiten('test_api1.example.org', str_repeat('k', 16), str_repeat('s', 56)), + 'An gültigen Angaben fällt nichts auf' + ); + + $hinweise = PaypalNvp::auffaelligkeiten('kurz', str_repeat('k', 15), str_repeat('s', 20)); + + $this->assertEquals(3, count($hinweise), 'Alle drei Angaben werden bemängelt'); + $this->assert( + str_contains($hinweise[0], 'name_api1'), + 'Der Benutzername bekommt die erwartete Form genannt' + ); + $this->assert(str_contains($hinweise[1], '15'), 'Die Länge des Kennworts steht dabei'); + $this->assert(str_contains($hinweise[2], '56'), 'Und die erwartete Länge der Signatur'); + + // Der Wert selbst darf nicht mitlesbar in der Meldung landen. + $this->assert( + !str_contains($hinweise[0], 'kurz'), + 'Der Benutzername steht nur gekürzt in der Meldung' + ); + + // Leere Angaben bemängelt niemand hier – dafür gibt es die Prüfung auf + // Vollständigkeit vor dem Abruf. + $this->assertEquals([], PaypalNvp::auffaelligkeiten('', '', ''), 'Leeres bleibt hier unbeanstandet'); + } + + private function leerraumUmDieGeheimnisseFaelltWeg(): void + { + // Eine aus der Zwischenablage eingefügte Signatur bringt gern ein + // Leerzeichen mit – und PayPal antwortet darauf mit „Security header is + // not valid“, was alles Mögliche vermuten lässt, nur nicht das. + $id = Bankzugang::anlegen([ + 'typ' => 'paypal', + 'name' => 'PayPal-Leerraum', + 'api_benutzer' => ' test_api1.example.org ', + 'api_kennwort' => " kennwort-mit-rand\n", + 'api_signatur' => "signatur-mit-rand\t", + 'abrufzeitraum' => 'letzter_abruf', + 'aktiv' => 1, + ]); + + $daten = Bankzugang::zugangsdaten($id); + + $this->assertEquals('test_api1.example.org', $daten['api_benutzer'], 'Der Benutzername steht ohne Rand'); + $this->assertEquals('kennwort-mit-rand', $daten['api_kennwort'], 'Das Kennwort ebenso'); + $this->assertEquals('signatur-mit-rand', $daten['api_signatur'], 'Und die Signatur'); + } + + private function saldoKommtAusBeidenAntwortformen(): void + { + // Die Form aus der Dokumentation … + $flach = PaypalNvp::saldoAusAntwort([ + 'ACK' => 'Success', 'AMT' => '42.50', 'CURRENCYCODE' => 'EUR', + ]); + $this->assertEquals(42.50, $flach['betrag'], 'Der Kontostand aus AMT'); + $this->assertEquals('EUR', $flach['waehrung'], 'Samt Währung'); + + // … und die, die Live-Konten tatsächlich liefern. + $liste = PaypalNvp::saldoAusAntwort([ + 'ACK' => 'Success', 'L_AMT0' => '-7.25', 'L_CURRENCYCODE0' => 'EUR', + 'L_AMT1' => '13.00', 'L_CURRENCYCODE1' => 'USD', + ]); + $this->assertEquals(-7.25, $liste['betrag'], 'Der Kontostand aus L_AMT0'); + $this->assertEquals('EUR', $liste['waehrung'], 'Index 0 ist die Hauptwährung'); + + $this->assertNull( + PaypalNvp::saldoAusAntwort(['ACK' => 'Success']), + 'Ohne Betrag gibt es keinen Kontostand' + ); + } + + private function umsaetzeWerdenAusgelesen(): void + { + $umsaetze = PaypalNvp::umsaetzeAusAntwort([ + 'ACK' => 'Success', + 'L_TRANSACTIONID0' => '3XY12345ABC678901', + 'L_TIMESTAMP0' => '2026-08-14T09:12:33Z', + 'L_TYPE0' => 'Payment', + 'L_STATUS0' => 'Completed', + 'L_NAME0' => 'Beispielshop GmbH', + 'L_EMAIL0' => 'shop@example.org', + 'L_AMT0' => '-25.00', + 'L_NETAMT0' => '-25.00', + 'L_CURRENCYCODE0' => 'EUR', + 'L_TRANSACTIONID1' => '9ZZ99999XYZ111111', + 'L_TIMESTAMP1' => '2026-08-15T22:04:00Z', + 'L_TYPE1' => 'Transfer', + 'L_STATUS1' => 'Completed', + 'L_NAME1' => '', + 'L_EMAIL1' => '', + 'L_AMT1' => '25.00', + 'L_NETAMT1' => '25.00', + 'L_CURRENCYCODE1' => 'EUR', + ]); + + $this->assertEquals(2, count($umsaetze), 'Beide Buchungen kommen heraus'); + + $this->assertEquals('3XY12345ABC678901', $umsaetze[0]['kennung'], 'Die Transaktionsnummer steht dabei'); + $this->assertEquals('2026-08-14', $umsaetze[0]['datum'], 'Der Zeitstempel wird zum Buchungstag'); + $this->assertEquals(-25.00, $umsaetze[0]['betrag'], 'Der Betrag trägt sein Vorzeichen'); + $this->assertEquals('Beispielshop GmbH', $umsaetze[0]['empfaenger'], 'Der Name ist der Empfänger'); + $this->assert( + str_contains($umsaetze[0]['verwendungszweck'], 'shop@example.org'), + 'Die Adresse steht im Verwendungszweck' + ); + $this->assertEquals('Payment', $umsaetze[0]['art'], 'Die Art der Buchung bleibt erhalten'); + + // Ohne Namen und Adresse bleibt nur die Art – etwa bei der Abbuchung vom + // hinterlegten Bankkonto. + $this->assertEquals('PayPal Transfer', $umsaetze[1]['empfaenger'], 'Namenlose Buchungen nennen ihre Art'); + } + + private function nullbuchungenFallenHeraus(): void + { + // Einwilligungen und Abonnements stehen als Buchung über 0,00 € in der + // Liste und bewegen nichts. + $umsaetze = PaypalNvp::umsaetzeAusAntwort([ + 'ACK' => 'Success', + 'L_TRANSACTIONID0' => 'B-0000000000000000', + 'L_TIMESTAMP0' => '2026-08-10T10:00:00Z', + 'L_TYPE0' => 'Recurring Payment', + 'L_AMT0' => '0.00', + 'L_NETAMT0' => '0.00', + 'L_TRANSACTIONID1' => '3XY00000ABC000001', + 'L_TIMESTAMP1' => '2026-08-11T10:00:00Z', + 'L_TYPE1' => 'Payment', + 'L_AMT1' => '-9.99', + 'L_NETAMT1' => '-9.99', + ]); + + $this->assertEquals(1, count($umsaetze), 'Die Nullbuchung fällt heraus'); + $this->assertEquals('3XY00000ABC000001', $umsaetze[0]['kennung'], 'Die echte Buchung bleibt'); + } + + private function warnungGiltAlsErfolg(): void + { + // PayPal liefert je Aufruf höchstens 100 Buchungen und meldet das als + // Warnung. Abbrechen wäre falsch – die Antwort enthält Buchungen. + $antwort = [ + 'ACK' => 'SuccessWithWarning', + 'L_LONGMESSAGE0' => 'The number of results were truncated.', + 'L_TRANSACTIONID0' => '3XY11111ABC111111', + 'L_TIMESTAMP0' => '2026-07-01T08:00:00Z', + 'L_TYPE0' => 'Payment', + 'L_AMT0' => '-1.00', + 'L_NETAMT0' => '-1.00', + ]; + + $this->assert(PaypalNvp::erfolgreich($antwort), 'Eine Warnung gilt als Erfolg'); + $this->assertNull(PaypalNvp::fehlermeldung($antwort), 'Und ist kein Fehler'); + $this->assert(PaypalNvp::abgeschnitten($antwort), 'Sie ist aber als abgeschnitten erkennbar'); + $this->assertEquals(1, count(PaypalNvp::umsaetzeAusAntwort($antwort)), 'Die Buchung kommt trotzdem herein'); + } +} diff --git a/tests/32_BankinstituteTest.php b/tests/32_BankinstituteTest.php new file mode 100644 index 0000000..d6c622d --- /dev/null +++ b/tests/32_BankinstituteTest.php @@ -0,0 +1,305 @@ +mandantAnlegen(); + + // Das Verzeichnis gehört keinem Benutzer: Was die Instanz schon + // eingelesen hat, stünde sonst in jeder Zählung mit drin. Der Rollback + // am Ende des Tests bringt es zurück. + Bankinstitut::leeren(); + + $this->importLiestDieEchteKopfzeile(); + $this->importErsetztDenBestand(); + $this->falscheDateiWirdAbgewiesen(); + $this->sucheFindetUeberBlzNameUndOrt(); + $this->zugangUebernimmtDieAdresseAusDemVerzeichnis(); + } + + // ── Testdaten ──────────────────────────────────────────────────────────── + + /** + * Ein Ausschnitt der echten Datei – mit allen Spalten, in ISO-8859-1 und + * mit den beiden Stolpersteinen: der über zwei Zeilen laufende, in + * Anführungszeichen gesetzte Institutsname und die doppelte Bankleitzahl. + */ + private function beispieldatei(): string + { + $kopf = 'Nr.;BLZ;BIC;Institut;Ort;RZ;Organisation;HBCI-Zugang DNS;' + . 'HBCI- Zugang IP-Adresse;HBCI-Version;DDV;RDH-1;RDH-2;RDH-3;RDH-4;RDH-5;' + . 'RDH-6;RDH-7;RDH-8;RDH-9;RDH-10;RAH-7;RAH-9;RAH-10;PIN/TAN-Zugang URL;Version;' + . 'Datum letzte Änderung'; + + $zeilen = [ + $kopf, + '1525;50050201;HELADEF1822;Frankfurter Sparkasse;Frankfurt am Main;' + . 'Finanz Informatik GmbH & Co. KG;DSGV;;;;;;;;;;;;;;;;;;' + . 'https://banking-hs6.s-fints-pt-hs.de/fints30;FinTS V3.0;09.02.2022', + '1526;50050222;HELADEF1822;1822direkt (PIN/TAN);Frankfurt am Main;' + . 'Finanz Informatik GmbH & Co. KG;DSGV;;;;;;;;;;;;;;;;;;' + . 'https://fints.1822direkt.com/fints/hbci;FinTS V3.0;09.02.2022', + // Zwei Filialen derselben Bankleitzahl, identischer Zugangspunkt. + '29;12070000;DEUTDEBB160;Deutsche Bank;Potsdam;eigenes Rechenzentrum;BdB;;;3.0;' + . ';;;;;;;;;;;;;;https://fints.deutsche-bank.de/;FinTS V3.0;12.03.2026', + '30;12070000;DEUTDEBB160;Deutsche Bank;Cottbus;eigenes Rechenzentrum;BdB;;;3.0;' + . ';;;;;;;;;;;;;;https://fints.deutsche-bank.de/;FinTS V3.0;12.03.2026', + // Ein Institutsname über zwei Zeilen, in Anführungszeichen. + '622;28030300;FORTDEH4XXX;"Oldenburgische Landesbank AG', + '(vormals W. Fortmann & Söhne)";Oldenburg (Oldb);eigenes Rechenzentrum;;' + . 'hbci.olb.de;;3.0;;ja;;;;;;;;ja;ja;;;;https://hbci.olb.de;FinTS V3.0;29.09.2017', + // Ohne PIN/TAN-Zugang – die Zeile zählt mit, taugt aber nicht als Vorlage. + '1822;55033300;SCFBDE33XXX;Santander Bank;Mainz;eigenes Rechenzentrum;BdB;' + . 'sec.santanderbank.de;193243184115;3.0;;ja;;;;;;;;;;;;;;;26.02.2014', + // Fußnote ohne Bankleitzahl – sie wird übergangen. (Eine völlig + // leere Zeile fiele schon in CsvTabelle heraus und käme hier nie an.) + 'Angaben ohne Gewähr;;;;;;;;;;;;;;;;;;;;;;;;;;', + ]; + + $utf8 = implode("\r\n", $zeilen) . "\r\n"; + + // Die echte Datei kommt in ISO-8859-1; genau das soll der Import + // erkennen, ohne dass jemand sie vorher umwandelt. + return (string)mb_convert_encoding($utf8, 'ISO-8859-1', 'UTF-8'); + } + + // ── Import ─────────────────────────────────────────────────────────────── + + private function importLiestDieEchteKopfzeile(): void + { + $pfad = $this->tempPfad('.csv'); + file_put_contents($pfad, $this->beispieldatei()); + + $probe = FintsInstituteImport::ausDatei($pfad)->ausfuehren(true); + + $this->assertEquals(7, $probe['gelesen'], 'Alle Datenzeilen wurden gelesen'); + $this->assertEquals(6, $probe['uebernommen'], 'Sechs Zeilen nennen eine Bankleitzahl'); + $this->assertEquals(1, $probe['ohne_blz'], 'Die Fußnote wurde übergangen'); + $this->assertEquals(5, $probe['banken'], 'Fünf verschiedene Bankleitzahlen'); + $this->assertEquals(5, $probe['mit_zugang'], 'Fünf nennen einen PIN/TAN-Zugangspunkt'); + $this->assertEquals('2026-03-12', $probe['stand'], 'Jüngste Änderung ist der 12.03.2026'); + $this->assertEquals(0, Bankinstitut::anzahl(), 'Der Trockenlauf hat nichts geschrieben'); + + FintsInstituteImport::ausDatei($pfad)->ausfuehren(false); + + $this->assertEquals(6, Bankinstitut::anzahl(), 'Der Import hat sechs Zeilen geschrieben'); + $this->assertEquals(5, Bankinstitut::anzahlMitZugang(), 'Davon fünf mit Zugangspunkt'); + $this->assertEquals('2026-03-12', Bankinstitut::juengsterStand(), 'Der Stand steht in der Tabelle'); + + $direkt = Bankinstitut::findeNachBlz('50050222'); + $this->assertNotEmpty($direkt, '1822direkt wurde gefunden'); + $this->assertEquals( + 'https://fints.1822direkt.com/fints/hbci', + (string)$direkt['pintan_url'], + 'Die FinTS-Adresse der 1822direkt stimmt' + ); + $this->assertEquals('HELADEF1822', (string)$direkt['bic'], 'Der BIC kam mit'); + $this->assertEquals('Frankfurt am Main', (string)$direkt['ort'], 'Der Ort kam mit'); + $this->assertEquals('FinTS V3.0', (string)$direkt['pintan_version'], 'Die Version kam mit'); + + // ISO-8859-1 und der Zeilenumbruch im Anführungszeichenfeld. + $olb = Bankinstitut::findeNachBlz('28030300'); + $this->assertNotEmpty($olb, 'Die Zeile mit dem Umbruch im Namen wurde gelesen'); + $this->assert( + str_contains((string)$olb['institut'], 'Söhne'), + 'Der Umlaut aus ISO-8859-1 kam als „Söhne“ an' + ); + + // Bei mehreren Filialen entscheidet die Reihenfolge der Datei. + $deutsche = Bankinstitut::findeNachBlz('12070000'); + $this->assertEquals('Potsdam', (string)$deutsche['ort'], 'Die erste Filialzeile gewinnt'); + + // Führende Nullen und Leerzeichen dürfen in der Anfrage stehen. + $this->assertNotEmpty( + Bankinstitut::findeNachBlz('500 502 22'), + 'Eine Bankleitzahl mit Leerzeichen wird trotzdem gefunden' + ); + $this->assertNull( + Bankinstitut::findeNachBlz(''), + 'Eine leere Bankleitzahl liefert keinen Treffer' + ); + + // Kein Zugangspunkt heißt: leeres Feld, nicht „nicht unterstützt“. + $santander = Bankinstitut::findeNachBlz('55033300'); + $this->assertEquals('', (string)$santander['pintan_url'], 'Ohne Zugang bleibt die Adresse leer'); + } + + /** Ein zweiter Import ersetzt den Bestand, statt ihn zu verdoppeln. */ + private function importErsetztDenBestand(): void + { + $pfad = $this->tempPfad('.csv'); + file_put_contents($pfad, $this->beispieldatei()); + + FintsInstituteImport::ausDatei($pfad)->ausfuehren(false); + $this->assertEquals(6, Bankinstitut::anzahl(), 'Nach dem zweiten Import stehen keine Dubletten da'); + + $kurz = "Nr.;BLZ;BIC;Institut;Ort;PIN/TAN-Zugang URL\r\n" + . "1;10010010;PBNKDEFFXXX;Postbank;Berlin;https://hbci.postbank.de/banking/hbci.do\r\n"; + FintsInstituteImport::ausText($kurz)->ausfuehren(false); + + $this->assertEquals(1, Bankinstitut::anzahl(), 'Der neue Bestand hat den alten abgelöst'); + $this->assertNull(Bankinstitut::findeNachBlz('50050222'), 'Die alten Zeilen sind fort'); + $this->assertNotEmpty(Bankinstitut::findeNachBlz('10010010'), 'Die neue Zeile steht da'); + $this->assertNull( + Bankinstitut::juengsterStand(), + 'Ohne Datumsspalte bleibt der Stand offen, statt zu raten' + ); + + // Für die folgenden Prüfungen wieder die volle Liste. + FintsInstituteImport::ausDatei($pfad)->ausfuehren(false); + } + + private function falscheDateiWirdAbgewiesen(): void + { + $this->assert( + $this->wirftFehler(fn() => FintsInstituteImport::ausText( + "Datum;Betrag;Empfänger\r\n01.02.2026;-12,50;Bäcker\r\n" + )), + 'Ein Kontoauszug ist keine Institutsliste' + ); + + $this->assert( + $this->wirftFehler(fn() => FintsInstituteImport::ausText('')), + 'Eine leere Datei wird abgewiesen' + ); + + $this->assert( + $this->wirftFehler(fn() => FintsInstituteImport::ausText( + "Nr.;BLZ;Institut;PIN/TAN-Zugang URL\r\n;;;\r\n" + )), + 'Eine Datei ohne eine einzige Bankleitzahl wird abgewiesen' + ); + + $this->assertEquals(6, Bankinstitut::anzahl(), 'Der Bestand blieb dabei unangetastet'); + } + + // ── Suche ──────────────────────────────────────────────────────────────── + + private function sucheFindetUeberBlzNameUndOrt(): void + { + $this->assertEquals(0, count(Bankinstitut::suche('')), 'Eine leere Suche liefert nichts'); + + // Ziffern gelten als Bankleitzahl und werden von vorn verglichen. + $praefix = Bankinstitut::suche('5005'); + $this->assertEquals(2, count($praefix), 'Zwei Frankfurter Leitzahlen beginnen mit 5005'); + + $nachName = Bankinstitut::suche('1822direkt'); + $this->assertEquals(1, count($nachName), 'Der Name führt zu genau einem Treffer'); + $this->assertEquals('50050222', (string)$nachName[0]['blz'], 'Und zwar zur richtigen Leitzahl'); + + $nachOrt = Bankinstitut::suche('Frankfurt'); + $this->assertEquals(2, count($nachOrt), 'Der Ort findet beide Frankfurter Institute'); + + $nachBic = Bankinstitut::suche('DEUTDEBB160'); + $this->assertEquals(2, count($nachBic), 'Der BIC findet beide Filialzeilen'); + + // Eine Ziffernfolge wird nicht nur als Bankleitzahl gelesen: „1822“ + // steht bei der 1822direkt im Namen und bei der Frankfurter Sparkasse + // im BIC – in keiner der beiden Bankleitzahlen. + $gemischt = array_column(Bankinstitut::suche('1822'), 'blz'); + $this->assertEquals(2, count($gemischt), '„1822“ findet beide über Name und BIC'); + $this->assert( + in_array('50050222', $gemischt, true), + 'Darunter die 1822direkt, deren Leitzahl ganz anders anfängt' + ); + + // Umgekehrt geht die Bankleitzahl vor: Ein Treffer in ihr steht oben. + $this->assertEquals( + '12070000', + (string)Bankinstitut::suche('1207')[0]['blz'], + 'Ein Treffer in der Bankleitzahl steht vor den Texttreffern' + ); + + // Die Platzhalter von LIKE dürfen in der Eingabe stehen, ohne zu wirken. + $this->assertEquals( + 0, + count(Bankinstitut::suche('%')), + 'Ein Prozentzeichen sucht nach einem Prozentzeichen, nicht nach allem' + ); + } + + // ── Zusammenspiel mit der Bankverbindung ───────────────────────────────── + + private function zugangUebernimmtDieAdresseAusDemVerzeichnis(): void + { + $institut = Bankinstitut::findeNachBlz('50050222'); + + $id = Bankzugang::anlegen([ + 'name' => '1822direkt', + 'bankname' => (string)$institut['institut'], + 'blz' => (string)$institut['blz'], + 'fints_url' => (string)$institut['pintan_url'], + 'benutzerkennung' => 'testkunde', + 'pin' => 'geheim1234', + 'abrufzeitraum' => 'letzter_abruf', + 'aktiv' => 1, + ]); + + $zugang = Bankzugang::find($id); + $this->assertEquals( + 'https://fints.1822direkt.com/fints/hbci', + (string)$zugang['fints_url'], + 'Die Adresse aus dem Verzeichnis steht im Zugang' + ); + $this->assertEquals('50050222', (string)$zugang['blz'], 'Die Bankleitzahl steht ohne Leerzeichen da'); + + // Ohne Adresse gibt es keinen FinTS-Zugang – der Controller schlägt sie + // vorher nach, und findet auch er nichts, endet es hier mit einer + // Meldung statt mit einem Zugang, der niemanden fragen kann. + $this->assert( + $this->wirftFehler(fn() => Bankzugang::anlegen([ + 'name' => 'Ohne Adresse', + 'blz' => '99999999', + 'fints_url' => '', + 'benutzerkennung' => 'testkunde', + 'pin' => 'geheim1234', + 'aktiv' => 1, + ])), + 'Ein FinTS-Zugang ohne Adresse wird abgewiesen' + ); + + // PayPal führt keine FinTS-Adresse – dort darf sie leer bleiben. + $paypal = Bankzugang::anlegen([ + 'typ' => 'paypal', + 'name' => 'PayPal-Verzeichnisprobe', + 'api_benutzer' => 'api_test', + 'api_kennwort' => 'geheim', + 'api_signatur' => 'signatur', + 'aktiv' => 1, + ]); + $this->assertEquals( + '', + (string)Bankzugang::find($paypal)['fints_url'], + 'Ein PayPal-Zugang kommt ohne FinTS-Adresse aus' + ); + } + + /** Ruft $fn auf und meldet, ob dabei eine Ausnahme flog. */ + private function wirftFehler(callable $fn): bool + { + try { + $fn(); + } catch (\Throwable) { + return true; + } + + return false; + } +} diff --git a/tests/e2e/specs/banking.spec.js b/tests/e2e/specs/banking.spec.js new file mode 100644 index 0000000..462dda8 --- /dev/null +++ b/tests/e2e/specs/banking.spec.js @@ -0,0 +1,155 @@ +// @ts-check +const { test, expect } = require('@playwright/test') + +const BENUTZER = process.env.E2E_USER || 'e2e' +const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' + +// Eindeutige Namen je Lauf: die Tests arbeiten auf einer bestehenden Instanz +// und dürfen sich nicht an Resten eines früheren Laufs stoßen. +const MARKE = Date.now().toString().slice(-6) + +async function anmelden(page) { + await page.goto('/login') + + if (page.url().includes('/einrichtung')) { + throw new Error( + 'Instanz ist noch nicht eingerichtet. Zuerst einen Benutzer anlegen: ' + + 'docker compose exec -u www-data php php scripts/e2e-benutzer.php' + ) + } + + await page.fill('#benutzername', BENUTZER) + await page.fill('#passwort', PASSWORT) + await page.click('button[type="submit"]') + await expect(page).toHaveURL(/\/$/) +} + +test.describe('Bankverbindungen', () => { + test.beforeEach(async ({ page }) => { + await anmelden(page) + }) + + test('„Bankverbindung anlegen“ führt zur Wahl zwischen HBCI/FinTS und PayPal', async ({ page }) => { + await page.goto('/banking') + await page.click('a[href="/banking/neu"]') + + await expect(page.locator('h2')).toContainText('Wie wird abgerufen?') + + const karten = page.locator('.formatwahl .formatkarte') + await expect(karten).toHaveCount(2) + await expect(karten.nth(0)).toContainText('HBCI/FinTS') + await expect(karten.nth(1)).toContainText('PayPal') + }) + + test('Die erste Wahl führt in die bisherige Bankenmaske', async ({ page }) => { + await page.goto('/banking/neu') + await page.click('.formatkarte:has-text("HBCI/FinTS")') + + await expect(page).toHaveURL(/\/banking\/neu\/fints$/) + await expect(page.locator('.schrittmarke')).toContainText('Schritt 1 von 3') + await expect(page.locator('#fints_url')).toBeVisible() + await expect(page.locator('#pin')).toBeVisible() + }) + + test('Die zweite Wahl führt in die PayPal-Maske mit allen Angaben für die Abfrage', async ({ page }) => { + await page.goto('/banking/neu') + await page.click('.formatkarte:has-text("PayPal")') + + await expect(page).toHaveURL(/\/banking\/neu\/paypal$/) + + // Die drei Angaben der NVP-Schnittstelle und der Endpunkt-Schalter. + await expect(page.locator('#api_benutzer')).toBeVisible() + await expect(page.locator('#api_kennwort')).toBeVisible() + await expect(page.locator('#api_signatur')).toBeVisible() + await expect(page.locator('input[name="sandbox"]')).toBeAttached() + + // Die Kontenzuordnung steht hier im Formular, nicht auf einer eigenen Seite. + await expect(page.locator('#konto_id')).toBeVisible() + await expect(page.locator('#konto_id option[value="neu"]')).toHaveCount(1) + }) + + test('PayPal-Verbindung anlegen, dabei ein neues Konto anlegen und wiederfinden', async ({ page }) => { + const name = `E2E PayPal ${MARKE}` + const konto = `E2E PayPal-Guthaben ${MARKE}` + + await page.goto('/banking/neu/paypal') + await page.fill('#name', name) + await page.fill('#api_benutzer', `e2e_api1.example.org`) + await page.fill('#api_kennwort', 'e2e-kennwort') + await page.fill('#api_signatur', 'e2e-signatur') + await page.check('input[name="sandbox"]') + + // Die Felder des neuen Kontos sind erst nach dieser Wahl sichtbar. + await expect(page.locator('#neuer_name')).toBeHidden() + await page.selectOption('#konto_id', 'neu') + await expect(page.locator('#neuer_name')).toBeVisible() + + await page.fill('#neuer_name', konto) + await page.fill('#anfangssaldo', '25,00') + await page.click('.formular button[type="submit"]') + + await expect(page.locator('.meldung-erfolg')).toBeVisible() + await expect(page).toHaveURL(/\/banking$/) + + // In der Übersicht steht die Verbindung mit ihrer Art. + const zeile = page.locator('table.liste tr', { hasText: name }) + await expect(zeile).toContainText('PayPal') + + // Und beim Öffnen steht das eben angelegte Konto zugeordnet da; die + // Geheimnisse werden nicht wieder angezeigt. + await zeile.locator('a').first().click() + await expect(page.locator('#api_benutzer')).toHaveValue('e2e_api1.example.org') + await expect(page.locator('#api_kennwort')).toHaveValue('') + await expect(page.locator('#konto_id option:checked')).toHaveText(konto) + await expect(page.locator('input[name="sandbox"]')).toBeChecked() + }) + + test('„nicht zugeordnet“ löst eine bestehende Zuordnung wieder', async ({ page }) => { + const name = `E2E PayPal Zuordnung ${MARKE}` + const konto = `E2E PayPal-Konto ${MARKE}` + + await page.goto('/banking/neu/paypal') + await page.fill('#name', name) + await page.fill('#api_benutzer', 'e2e_api1.example.org') + await page.fill('#api_kennwort', 'e2e-kennwort') + await page.fill('#api_signatur', 'e2e-signatur') + await page.selectOption('#konto_id', 'neu') + await page.fill('#neuer_name', konto) + await page.click('.formular button[type="submit"]') + await expect(page).toHaveURL(/\/banking$/) + + await page.locator('table.liste tr', { hasText: name }).locator('a').first().click() + await expect(page.locator('#konto_id option:checked')).toHaveText(konto) + + // Die leere Wahl heißt hier ausdrücklich „Zuordnung lösen“ – sie darf + // nicht als „nichts geändert“ durchrutschen. + await page.selectOption('#konto_id', '') + await page.click('.formular button[type="submit"]') + + await expect(page.locator('.meldung-erfolg')).toBeVisible() + await expect(page.locator('#konto_id option:checked')).toHaveText('– nicht zugeordnet –') + }) + + test('Ein PayPal-Zugang kennt kein TAN-Verfahren', async ({ page }) => { + const name = `E2E PayPal ohne TAN ${MARKE}` + + await page.goto('/banking/neu/paypal') + await page.fill('#name', name) + await page.fill('#api_benutzer', 'e2e_api1.example.org') + await page.fill('#api_kennwort', 'e2e-kennwort') + await page.fill('#api_signatur', 'e2e-signatur') + await page.click('.formular button[type="submit"]') + await expect(page).toHaveURL(/\/banking$/) + + const ziel = await page + .locator('table.liste tr', { hasText: name }) + .locator('a') + .first() + .getAttribute('href') + + await page.goto(`${ziel}/verfahren`) + + await expect(page.locator('.meldung-warnung')).toContainText('kein TAN-Verfahren') + await expect(page).toHaveURL(new RegExp(`${ziel}$`)) + }) +}) diff --git a/tests/e2e/specs/bankinstitute.spec.js b/tests/e2e/specs/bankinstitute.spec.js new file mode 100644 index 0000000..c455d5c --- /dev/null +++ b/tests/e2e/specs/bankinstitute.spec.js @@ -0,0 +1,98 @@ +// @ts-check +const { test, expect } = require('@playwright/test') + +const BENUTZER = process.env.E2E_USER || 'e2e' +const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' + +async function anmelden(page) { + await page.goto('/login') + + if (page.url().includes('/einrichtung')) { + throw new Error( + 'Instanz ist noch nicht eingerichtet. Zuerst einen Benutzer anlegen: ' + + 'docker compose exec -u www-data php php scripts/e2e-benutzer.php' + ) + } + + await page.fill('#benutzername', BENUTZER) + await page.fill('#passwort', PASSWORT) + await page.click('button[type="submit"]') + await expect(page).toHaveURL(/\/$/) +} + +// Die Zeile im Format der echten Institutsliste – mit allen Spalten in der +// Reihenfolge, in der die Deutsche Kreditwirtschaft sie ausgibt. +const KOPF = 'Nr.;BLZ;BIC;Institut;Ort;RZ;Organisation;HBCI-Zugang DNS;' + + 'HBCI- Zugang IP-Adresse;HBCI-Version;DDV;RDH-1;RDH-2;RDH-3;RDH-4;RDH-5;' + + 'RDH-6;RDH-7;RDH-8;RDH-9;RDH-10;RAH-7;RAH-9;RAH-10;PIN/TAN-Zugang URL;Version;' + + 'Datum letzte Änderung' + +const ZEILE = '1526;50050222;HELADEF1822;E2E-Institut (PIN/TAN);Frankfurt am Main;' + + 'Finanz Informatik GmbH & Co. KG;DSGV;;;;;;;;;;;;;;;;;;' + + 'https://fints.beispiel.invalid/fints/hbci;FinTS V3.0;09.02.2022' + +/** + * Das Bankleitzahlen-Verzeichnis im Browser. + * + * Geprüft wird nur der Weg – Upload, Prüfbericht, Suche –, nicht das Einlesen + * selbst: Das steckt in der PHP-Suite. Und übernommen wird hier nichts, denn + * ein Import ersetzt den gesamten Bestand der Instanz; der Prüflauf lässt ihn + * unangetastet. + */ +test.describe('Bankleitzahlen-Verzeichnis', () => { + test.beforeEach(async ({ page }) => { + await anmelden(page) + }) + + test('Die Verwaltung führt zum Verzeichnis', async ({ page }) => { + await page.goto('/verwaltung') + + const kachel = page.locator('.kachel', { hasText: 'Bankleitzahlen' }) + await kachel.getByRole('link', { name: /Nachschlagen/ }).click() + + await expect(page).toHaveURL(/\/verwaltung\/bankinstitute$/) + await expect(page.locator('h1')).toContainText('Bankleitzahlen') + }) + + test('Der Prüflauf berichtet, ohne den Bestand anzurühren', async ({ page }) => { + await page.goto('/verwaltung/bankinstitute') + + await page.setInputFiles('#datei', { + name: 'e2e-institute.csv', + mimeType: 'text/csv', + buffer: Buffer.from([KOPF, ZEILE, ''].join('\r\n'), 'latin1'), + }) + await page.getByRole('button', { name: 'Nur prüfen' }).click() + + const bericht = page.locator('.kachel', { hasText: 'Prüfung' }) + await expect(bericht).toContainText('e2e-institute.csv') + await expect(bericht).toContainText('Es wurde nichts geschrieben') + await expect(bericht).toContainText('50050222') + await expect(bericht).toContainText('fints.beispiel.invalid') + }) + + test('Eine fremde CSV-Datei wird abgewiesen', async ({ page }) => { + await page.goto('/verwaltung/bankinstitute') + + await page.setInputFiles('#datei', { + name: 'kontoauszug.csv', + mimeType: 'text/csv', + buffer: Buffer.from('Datum;Betrag;Empfänger\r\n01.02.2026;-12,50;Bäcker\r\n', 'utf8'), + }) + await page.getByRole('button', { name: 'Nur prüfen' }).click() + + await expect(page.locator('.meldung-fehler')).toContainText('keine FinTS-Institutsliste') + }) + + test('Die Suche nimmt Bankleitzahl wie Namen', async ({ page }) => { + await page.goto('/verwaltung/bankinstitute') + + await page.fill('#suche', '50050222') + await page.getByRole('button', { name: 'Suchen' }).click() + await expect(page).toHaveURL(/suche=50050222/) + + // Ob es einen Treffer gibt, hängt vom eingelesenen Bestand ab – die + // Seite muss in beiden Fällen antworten, statt leer zu bleiben. + await expect(page.locator('table.liste, .platzhalter').first()).toBeVisible() + }) +}) diff --git a/tests/e2e/specs/module.spec.js b/tests/e2e/specs/module.spec.js index 74b19f1..1356400 100644 --- a/tests/e2e/specs/module.spec.js +++ b/tests/e2e/specs/module.spec.js @@ -87,7 +87,10 @@ test.describe('Module der Phasen 4 bis 7', () => { }) test('Eine Bankverbindung lässt sich anlegen und wieder löschen', async ({ page }) => { + // Vor den drei Einrichtungsschritten steht die Wahl der Art; die + // Bankenmaske hängt an der ersten der beiden Karten. await page.goto('/banking/neu') + await page.click('.formatkarte:has-text("HBCI/FinTS")') await page.fill('#name', 'E2E-Testbank') await page.fill('#blz', '12030000')