Sammel-Commit: PayPal-Bankverbindung und Bankleitzahlen-Verzeichnis
Zwei Features an derselben Stelle, die im Arbeitsverzeichnis nebeneinander lagen und sich dieselben Dateien teilen (Routen, BankingController, Bankzugang, Zugangsformular, Dokumentation) – getrennte Commits ließen sich daraus nur mit einem kaputten Zwischenstand schneiden. PayPal als zweite Art von Bankverbindung ---------------------------------------- „Bankverbindung anlegen“ führt zur Wahl der Art: HBCI/FinTS oder PayPal (bank_zugaenge.typ, Migration 014). Die Abfrage läuft über die Classic-NVP-API (Services\PaypalNvp, TransactionSearch und GetBalance). Kein Dreischritt, da PayPal genau ein Guthabenkonto führt – Zugangsdaten und Kontenzuordnung stehen in einem Formular. Konten, Umsätze, Protokoll, Regeln und Abgleich bleiben dieselben. Bankleitzahlen-Verzeichnis -------------------------- Verwaltung → Bankleitzahlen (/verwaltung/bankinstitute, Migration 015) nimmt die CSV-Institutsliste der Deutschen Kreditwirtschaft auf und beantwortet damit die eine Angabe, die beim Anlegen einer FinTS-Verbindung regelmäßig fehlt: den PIN/TAN-Zugangspunkt. Reine Nachschlagedaten ohne benutzer_id; ein Import ersetzt den gesamten Bestand, statt ihn zu ergänzen. „Nur prüfen“ und „Übernehmen“ sind zwei Knöpfe an derselben Datei – abgelegt wird sie nicht, ihr Inhalt steht ja in der Tabelle. Services\FintsInstituteImport nimmt der Datei ihre Eigenheiten ab: ISO-8859-1 statt UTF-8, ein Institutsname über zwei Zeilen in Anführungszeichen, dieselbe Bankleitzahl vielfach (eine Zeile je Filiale), Hunderte leerer Zeilen am Ende, Text statt Adresse, wo es keinen PIN/TAN-Zugang gibt. Spalten werden über ihre Überschrift angesprochen; fehlen BLZ und Institut, bricht der Import ab. Gesucht wird über Bankleitzahl, BIC, Name und Ort in einem Zug: „1822“ steht bei der 1822direkt im Namen und bei der Frankfurter Sparkasse im BIC, in keiner der beiden Bankleitzahlen. Aus der Trefferliste führt „Verbindung anlegen“ ins gefüllte FinTS-Formular. Die FinTS-Adresse ist damit kein Pflichtfeld mehr – bleibt sie leer, wird sie zur Bankleitzahl nachgeschlagen. Findet auch das nichts, weist Bankzugang den Zugang ab, statt ihn ohne Gegenstelle zu speichern. Tests: 31_PaypalZugangTest, 32_BankinstituteTest, e2e/specs/banking.spec.js, e2e/specs/bankinstitute.spec.js. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
066879d61f
commit
0b4a216954
27 changed files with 3401 additions and 50 deletions
92
Changes.md
92
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
|
||||
|
|
|
|||
12
PLAN.md
12
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`.
|
||||
|
||||
|
|
|
|||
12
README.md
12
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
|
||||
|
|
|
|||
165
docs/banking.md
165
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.
|
||||
|
|
|
|||
18
migrations/014_paypal_zugang.sql
Normal file
18
migrations/014_paypal_zugang.sql
Normal file
|
|
@ -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;
|
||||
48
migrations/015_bankinstitute.sql
Normal file
48
migrations/015_bankinstitute.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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“.
|
||||
|
|
|
|||
|
|
@ -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/typ.php', ['titel' => 'Bankverbindung anlegen']);
|
||||
}
|
||||
|
||||
/** @param array<string,string> $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')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function anlegen(): void
|
||||
/**
|
||||
* 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<string,string>
|
||||
*/
|
||||
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<string,string> $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<string,string> $params
|
||||
*/
|
||||
private static function typ(array $params): string
|
||||
{
|
||||
$typ = (string)($params['typ'] ?? 'fints');
|
||||
|
||||
return array_key_exists($typ, Bankzugang::TYPEN) ? $typ : 'fints';
|
||||
}
|
||||
|
||||
/** @param array<string,string> $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,
|
||||
|
|
@ -107,15 +219,25 @@ class BankingController extends BaseController
|
|||
$this->verlangeAnmeldung();
|
||||
|
||||
$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<string,string> $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<string,mixed> */
|
||||
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<string,mixed>
|
||||
*/
|
||||
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<string,mixed> */
|
||||
private function regeldaten(): array
|
||||
{
|
||||
|
|
|
|||
139
src/Controllers/BankinstituteController.php
Normal file
139
src/Controllers/BankinstituteController.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Controllers;
|
||||
|
||||
use Finanzmanager\Models\Bankinstitut;
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
use Finanzmanager\Services\FintsInstituteImport;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* Das Bankleitzahlen-Verzeichnis: nachschlagen und aktualisieren.
|
||||
*
|
||||
* Zwei Dinge auf einer Seite, weil sie zusammengehören – wer nachschlägt und
|
||||
* seine Bank nicht findet, braucht als Nächstes eine frischere Liste.
|
||||
*
|
||||
* Die hochgeladene Datei wird gelesen und nicht abgelegt: Ihr Inhalt landet
|
||||
* vollständig in der Tabelle, ein zweites Exemplar im Importverzeichnis wäre
|
||||
* nur Ballast (und stünde dort zwischen den Kontoauszügen des
|
||||
* Buchungsimports). Deshalb sind Prüfen und Übernehmen zwei Knöpfe an
|
||||
* derselben Datei statt zwei Schritte an einer abgelegten.
|
||||
*/
|
||||
class BankinstituteController extends BaseController
|
||||
{
|
||||
/** Mehr nimmt die Datei nicht an – die echte Liste ist knapp 700 KB groß. */
|
||||
private const MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
$this->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<string,mixed> */
|
||||
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 . ').',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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)),
|
||||
|
|
|
|||
180
src/Models/Bankinstitut.php
Normal file
180
src/Models/Bankinstitut.php
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Models;
|
||||
|
||||
use Finanzmanager\Database;
|
||||
|
||||
/**
|
||||
* Ein Eintrag aus dem Bankleitzahlen-Verzeichnis der FinTS-Institute.
|
||||
*
|
||||
* Nachschlagedaten, keine Stammdaten: Die Tabelle gehört keinem Benutzer, wird
|
||||
* nur gelesen und in einem Zug durch einen Import ersetzt
|
||||
* (`Services\FintsInstituteImport`). Gebraucht wird sie an einer Stelle – beim
|
||||
* Anlegen einer Bankverbindung, wo die FinTS-Adresse zur Bankleitzahl gesucht
|
||||
* wird.
|
||||
*/
|
||||
class Bankinstitut
|
||||
{
|
||||
/** Wie viele Treffer eine Suche höchstens zeigt. */
|
||||
public const TREFFER_GRENZE = 100;
|
||||
|
||||
public static function anzahl(): int
|
||||
{
|
||||
return (int)Database::fetchValue('SELECT COUNT(*) FROM bankinstitute', [], 0);
|
||||
}
|
||||
|
||||
/** Wie viele Einträge einen PIN/TAN-Zugangspunkt nennen. */
|
||||
public static function anzahlMitZugang(): int
|
||||
{
|
||||
return (int)Database::fetchValue(
|
||||
"SELECT COUNT(*) FROM bankinstitute WHERE pintan_url <> ''",
|
||||
[],
|
||||
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<string,mixed>|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<array<string,mixed>>
|
||||
*/
|
||||
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<array<string,mixed>> $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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
@ -61,6 +76,8 @@ class Bankzugang
|
|||
|
||||
$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<string,mixed>
|
||||
*/
|
||||
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
|
||||
|
|
|
|||
176
src/Services/FintsInstituteImport.php
Normal file
176
src/Services/FintsInstituteImport.php
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Services;
|
||||
|
||||
use Finanzmanager\Models\Bankinstitut;
|
||||
|
||||
/**
|
||||
* Liest das Bankleitzahlen-Verzeichnis der FinTS-Institute ein.
|
||||
*
|
||||
* Die Datei kommt von der Deutschen Kreditwirtschaft (hbci-zka.de) als
|
||||
* Semikolon-CSV in ISO-8859-1 mit dieser Kopfzeile:
|
||||
*
|
||||
* Nr.;BLZ;BIC;Institut;Ort;RZ;Organisation;HBCI-Zugang DNS;
|
||||
* HBCI- Zugang IP-Adresse;HBCI-Version;DDV;RDH-1…RDH-10;RAH-7;RAH-9;RAH-10;
|
||||
* PIN/TAN-Zugang URL;Version;Datum letzte Änderung
|
||||
*
|
||||
* Angesprochen werden die Spalten über ihre Überschrift, nicht über ihre
|
||||
* Position (`CsvTabelle::datensaetze`): Die Datei hat im Lauf der Jahre schon
|
||||
* Spalten dazubekommen, und die Verfahrensspalten interessieren hier ohnehin
|
||||
* nicht. Fehlt eine erwartete Spalte, bricht der Import ab, statt lauter leere
|
||||
* Zeilen zu schreiben.
|
||||
*
|
||||
* Ein Lauf ersetzt den gesamten Bestand – siehe Migration 015.
|
||||
*/
|
||||
class FintsInstituteImport
|
||||
{
|
||||
/** Ohne diese Spalten ist es nicht die erwartete Datei. */
|
||||
private const PFLICHTSPALTEN = ['blz', 'institut'];
|
||||
|
||||
/** Überschrift (als Schlüssel) → Spalte in `bankinstitute`. */
|
||||
private const SPALTEN = [
|
||||
'nr' => '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<array<string,string>> $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<array<string,mixed>>}
|
||||
*/
|
||||
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<string,string> $satz
|
||||
* @return array<string,mixed>|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;
|
||||
}
|
||||
}
|
||||
358
src/Services/PaypalAbruf.php
Normal file
358
src/Services/PaypalAbruf.php
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Services;
|
||||
|
||||
use Finanzmanager\Models\AbgleichRegel;
|
||||
use Finanzmanager\Models\Bankabruf;
|
||||
use Finanzmanager\Models\Bankumsatz;
|
||||
use Finanzmanager\Models\Bankzugang;
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
|
||||
/**
|
||||
* Der Umsatzabruf bei PayPal.
|
||||
*
|
||||
* Das Gegenstück zu `FinTsAbruf`, nur erheblich kürzer: PayPal führt je Konto
|
||||
* genau ein Guthabenkonto, es gibt also weder eine Kontenabfrage noch eine
|
||||
* Zuordnung mehrerer Konten. Der Ablauf ist:
|
||||
*
|
||||
* Zugangsdaten prüfen (und dabei den Kontostand holen) → Umsätze je
|
||||
* Zeitscheibe → abgleichen
|
||||
*
|
||||
* Alles innerhalb eines Requests – anders als bei FinTS unterbricht hier nichts
|
||||
* für eine Freigabe.
|
||||
*
|
||||
* **Freigaben (MFA).** Die Classic-NVP-API authentifiziert allein über
|
||||
* API-Benutzernamen, Kennwort und Signatur; eine Rückfrage wie die TAN einer
|
||||
* Bank stellt sie nicht. Verlangt PayPal eines Tages doch eine Bestätigung, ist
|
||||
* dafür nichts umzubauen: Das Ergebnis dieser Klasse hat dieselbe Form wie das
|
||||
* von `FinTsAbruf` – einschließlich des Status `tan` samt Feld `tan` –, der
|
||||
* Controller schickt es durch dieselbe Auswertung, und `bank_abrufe` kennt den
|
||||
* Status `tan_offen` bereits. Einzuhängen wäre dann nur die Stelle, an der die
|
||||
* Antwort von PayPal als Freigabeanforderung erkannt wird.
|
||||
*
|
||||
* Ist `banking_regeln_beim_abruf` eingeschaltet, zählen die Abgleichregeln ihre
|
||||
* Treffer schon hier; `Bankabgleich` bucht am Ende, was ein Konto hat.
|
||||
*/
|
||||
class PaypalAbruf
|
||||
{
|
||||
/** @var list<string> */
|
||||
private array $protokoll = [];
|
||||
|
||||
private int $neu = 0;
|
||||
private int $uebersprungen = 0;
|
||||
|
||||
/** @param array<string,mixed> $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<string>,
|
||||
* 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<string,mixed> */
|
||||
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<string,mixed> $bankkonto
|
||||
* @param list<array<string,mixed>> $umsaetze
|
||||
* @param list<array<string,mixed>> $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<string,mixed> */
|
||||
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<string> $protokoll
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
366
src/Services/PaypalNvp.php
Normal file
366
src/Services/PaypalNvp.php
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Services;
|
||||
|
||||
/**
|
||||
* Die PayPal Classic NVP API – der Zugang, über den sich Umsätze eines
|
||||
* PayPal-Kontos abrufen lassen (`TransactionSearch`) samt Kontostand
|
||||
* (`GetBalance`).
|
||||
*
|
||||
* Übernommen aus dem Parallelprojekt `Steuerkram`, wo die Fallstricke schon
|
||||
* einmal aufgelaufen sind:
|
||||
*
|
||||
* - **Zwei Endpunkte.** Live- und Sandbox-Zugangsdaten passen jeweils nur zu
|
||||
* ihrem eigenen. Mit dem falschen antwortet PayPal ausschließlich mit
|
||||
* *„Security header is not valid“* – eine Meldung, die nach einem Tippfehler
|
||||
* in den Zugangsdaten aussieht und keiner ist.
|
||||
* - **Zeitscheiben.** `TransactionSearch` liefert je Aufruf höchstens 100
|
||||
* Buchungen und meldet das nur als Warnung. Abgefragt wird deshalb in
|
||||
* Abschnitten von 30 Tagen.
|
||||
* - **Listenfelder.** Die Antwort ist ein Formularstring; Listen stehen als
|
||||
* `L_TRANSACTIONID0`, `L_TRANSACTIONID1`, … darin und enden dort, wo der
|
||||
* nächste Index fehlt.
|
||||
* - **`GetBalance` antwortet zweierlei.** Die Dokumentation nennt die flachen
|
||||
* Felder `AMT`/`CURRENCYCODE`; Live-Konten liefern stattdessen die Liste
|
||||
* `L_AMT0`/`L_CURRENCYCODE0`. Beides muss gelesen werden, sonst bleibt der
|
||||
* Kontostand auf echten Konten leer.
|
||||
* - **Nullbuchungen.** Einwilligungen und Abonnements erscheinen als Buchung
|
||||
* über 0,00 € und gehören nicht ins Kontoblatt.
|
||||
*
|
||||
* Die Auswertung der Antworten steht bewusst in statischen Methoden ohne
|
||||
* Netzzugriff: Nur so lässt sie sich prüfen, ohne ein PayPal-Konto zu haben.
|
||||
*/
|
||||
class PaypalNvp
|
||||
{
|
||||
/** Fassung der Schnittstelle. 204 ist die letzte, die TransactionSearch führt. */
|
||||
public const VERSION = '204';
|
||||
|
||||
public const ENDPUNKT_LIVE = 'https://api-3t.paypal.com/nvp';
|
||||
public const ENDPUNKT_SANDBOX = 'https://api-3t.sandbox.paypal.com/nvp';
|
||||
|
||||
/** Länge einer Zeitscheibe der Umsatzabfrage in Tagen. */
|
||||
public const ZEITSCHEIBE_TAGE = 30;
|
||||
|
||||
/** Die Längen, die PayPal für Kennwort und Signatur vergibt. */
|
||||
private const LAENGE_KENNWORT = 16;
|
||||
private const LAENGE_SIGNATUR = 56;
|
||||
|
||||
/** Zeitüberschreitung einer Abfrage in Sekunden. */
|
||||
private const ZEITGRENZE = 30;
|
||||
|
||||
public function __construct(
|
||||
private string $benutzer,
|
||||
private string $kennwort,
|
||||
private string $signatur,
|
||||
private bool $sandbox = false
|
||||
) {
|
||||
}
|
||||
|
||||
public function endpunkt(): string
|
||||
{
|
||||
return $this->sandbox ? self::ENDPUNKT_SANDBOX : self::ENDPUNKT_LIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Aufruf der Schnittstelle.
|
||||
*
|
||||
* @param array<string,string> $felder
|
||||
* @return array<string,string>|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<string,string> $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<string,string>|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<string,string> $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<string,string> $antwort
|
||||
*/
|
||||
public static function abgeschnitten(array $antwort): bool
|
||||
{
|
||||
return ($antwort['ACK'] ?? '') === 'SuccessWithWarning';
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Fehlermeldung einer abgelehnten Antwort – oder null, wenn sie taugt.
|
||||
*
|
||||
* @param array<string,string> $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<string>
|
||||
*/
|
||||
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<string,string> $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<string,string> $antwort
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,11 +6,17 @@ use Finanzmanager\View;
|
|||
/** @var array<string,mixed>|null $zugang */
|
||||
/** @var list<array<string,mixed>> $konten Bankkonten dieses Zugangs */
|
||||
/** @var list<array<string,mixed>> $abrufe */
|
||||
/** @var array<string,string> $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);
|
||||
$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';
|
|||
<?php endif; ?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/banking" class="knopf">Zurück zu den Bankverbindungen</a>
|
||||
<a href="<?= $neu ? '/banking/neu' : '/banking' ?>" class="knopf">
|
||||
<?= $neu ? 'Zurück zur Auswahl' : 'Zurück zu den Bankverbindungen' ?>
|
||||
</a>
|
||||
<?php if (!$neu): ?>
|
||||
<form method="post" action="/banking/<?= (int)$zugang['id'] ?>/abrufen" class="inline abrufform">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
|
|
@ -51,10 +59,12 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
|
||||
<label for="fints_url">FinTS-Adresse</label>
|
||||
<input type="text" id="fints_url" name="fints_url" value="<?= View::h($wert('fints_url')) ?>"
|
||||
placeholder="https://…" required>
|
||||
placeholder="https://…">
|
||||
<p class="feldhinweis">
|
||||
Der HBCI/FinTS-Zugangspunkt der Bank – sie nennt ihn in ihrer Hilfe zum
|
||||
Online-Banking. Muss mit <code>https://</code> beginnen.
|
||||
Der HBCI/FinTS-Zugangspunkt der Bank; muss mit <code>https://</code> beginnen.
|
||||
Leer lassen heißt: zur Bankleitzahl im
|
||||
<a href="/verwaltung/bankinstitute">Bankleitzahlen-Verzeichnis</a> nachschlagen.
|
||||
Dort lässt sich auch nach der Bank suchen und das Formular fertig ausgefüllt öffnen.
|
||||
</p>
|
||||
|
||||
<label for="benutzerkennung">Benutzerkennung</label>
|
||||
|
|
|
|||
|
|
@ -33,8 +33,9 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
|
||||
<?php if ($zugaenge === []): ?>
|
||||
<p class="platzhalter">
|
||||
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.
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<table class="liste">
|
||||
|
|
@ -56,6 +57,12 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php if ($zugang['blz'] !== ''): ?>
|
||||
<span class="nebentext">BLZ <?= View::h($zugang['blz']) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php /* Woher die Umsätze kommen, entscheidet, welche Angaben
|
||||
hinter der Verbindung stehen – die Marke sagt es auf
|
||||
einen Blick. */ ?>
|
||||
<span class="marke">
|
||||
<?= View::h(Bankzugang::TYPEN[$zugang['typ']] ?? $zugang['typ']) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<?= View::h(Bankzugang::ZEITRAEUME[$zugang['abrufzeitraum']] ?? $zugang['abrufzeitraum']) ?>
|
||||
|
|
|
|||
205
src/Views/banking/paypal.php
Normal file
205
src/Views/banking/paypal.php
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Controllers\BankingController;
|
||||
use Finanzmanager\Models\Bankzugang;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* Zugangsdaten einer PayPal-Verbindung – und gleich die Zuordnung des Kontos.
|
||||
*
|
||||
* Anders als bei FinTS gibt es hier keine drei Schritte: PayPal kennt kein
|
||||
* TAN-Verfahren, und je Konto gibt es genau ein Guthabenkonto. Was dort auf drei
|
||||
* Seiten verteilt ist, passt hier in ein Formular.
|
||||
*
|
||||
* @var array<string,mixed>|null $zugang
|
||||
* @var array<string,mixed>|null $bankkonto Die eine Kontozeile dieses Zugangs
|
||||
* @var list<array<string,mixed>> $eigene Konten der Anwendung
|
||||
* @var list<array<string,mixed>> $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';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/banking" class="knopf">Zurück zu den Bankverbindungen</a>
|
||||
<?php if (!$neu): ?>
|
||||
<form method="post" action="/banking/<?= (int)$zugang['id'] ?>/abrufen" class="inline abrufform">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<?php /* Nur für diesen Lauf – die Vorgabe des Zugangs steht unten im
|
||||
Formular und bleibt davon unberührt. */ ?>
|
||||
<label class="stichtagwahl">
|
||||
Stichtag
|
||||
<input type="date" name="stichtag" class="stichtagfeld" max="<?= date('Y-m-d') ?>"
|
||||
title="Ab diesem Tag abrufen – leer: eingestellter Abrufzeitraum">
|
||||
</label>
|
||||
<button type="submit" class="knopf primaer">Umsätze abrufen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="<?= View::h($wert('name', 'PayPal')) ?>" required>
|
||||
<p class="feldhinweis">Frei wählbar – so steht die Verbindung in der Übersicht.</p>
|
||||
|
||||
<h3>Zugangsdaten</h3>
|
||||
<p class="feldhinweis">
|
||||
Die drei Angaben stehen im PayPal-Konto unter <em>Profil → Mein Verkäuferkonto →
|
||||
API-Zugriff</em>. Gebraucht wird dort der Punkt <em>NVP/SOAP-API-Integration</em>
|
||||
mit einer <strong>API-Signatur</strong>, nicht ein API-Zertifikat.
|
||||
</p>
|
||||
|
||||
<label for="api_benutzer">API-Benutzername</label>
|
||||
<input type="text" id="api_benutzer" name="api_benutzer" autocomplete="off"
|
||||
value="<?= View::h($wert('api_benutzer')) ?>" required>
|
||||
<p class="feldhinweis">Endet üblicherweise auf <code>_api1.…</code>.</p>
|
||||
|
||||
<label for="api_kennwort">API-Kennwort</label>
|
||||
<input type="password" id="api_kennwort" name="api_kennwort" autocomplete="off"
|
||||
<?= $neu ? 'required' : 'placeholder="unverändert lassen"' ?>>
|
||||
|
||||
<label for="api_signatur">API-Signatur</label>
|
||||
<input type="password" id="api_signatur" name="api_signatur" autocomplete="off"
|
||||
<?= $neu ? 'required' : 'placeholder="unverändert lassen"' ?>>
|
||||
<p class="feldhinweis">
|
||||
Kennwort und Signatur werden AES-verschlüsselt gespeichert (Schlüssel
|
||||
<code>APP_KEY</code>) und nie wieder angezeigt.<?= $neu ? '' : ' Leer lassen heißt: bisherige Angabe behalten.' ?>
|
||||
</p>
|
||||
|
||||
<label class="ankreuzfeld">
|
||||
<input type="checkbox" name="sandbox" value="1"
|
||||
<?= !$neu && (int)$zugang['sandbox'] === 1 ? 'checked' : '' ?>>
|
||||
Sandbox-Zugangsdaten
|
||||
</label>
|
||||
<p class="feldhinweis">
|
||||
<?php /* Der häufigste Fehler beim Einrichten: Die Zugangsdaten passen nur
|
||||
zu ihrem eigenen Endpunkt, und PayPal antwortet sonst mit einer
|
||||
Meldung, die nach einem Tippfehler aussieht. */ ?>
|
||||
Nur für Testzugänge aus dem PayPal-Entwicklerkonto. Mit dem falschen Haken antwortet
|
||||
PayPal ausschließlich mit <em>„Security header is not valid“</em> – die Zugangsdaten
|
||||
sind dann in Ordnung, nur der Endpunkt passt nicht.
|
||||
</p>
|
||||
|
||||
<h3>Konto</h3>
|
||||
<p class="feldhinweis">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<label for="konto_id">Eigenes Konto</label>
|
||||
<select id="konto_id" name="konto_id" data-neues-konto="neues-konto-paypal">
|
||||
<option value="">– nicht zugeordnet –</option>
|
||||
<?php foreach ($eigene as $konto): ?>
|
||||
<option value="<?= (int)$konto['id'] ?>"
|
||||
<?= $zugeordnet === (int)$konto['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($konto['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
<option value="<?= View::h(BankingController::KONTO_NEU) ?>">+ neues Konto anlegen</option>
|
||||
</select>
|
||||
|
||||
<?php /* Die Felder des neuen Kontos stehen immer im HTML: Ohne JavaScript
|
||||
sollen sie da sein, sonst ließe sich kein Konto anlegen. app.js
|
||||
blendet sie ein, sobald „neues Konto anlegen“ gewählt ist. */ ?>
|
||||
<div id="neues-konto-paypal">
|
||||
<label for="neuer_name">Name des neuen Kontos</label>
|
||||
<input type="text" id="neuer_name" name="neuer_name" value="PayPal">
|
||||
|
||||
<label for="neue_kontoart">Kontoart</label>
|
||||
<select id="neue_kontoart" name="neue_kontoart">
|
||||
<?php foreach (Konto::ARTEN as $schluessel => $beschriftung): ?>
|
||||
<option value="<?= View::h($schluessel) ?>" <?= $schluessel === 'bank' ? 'selected' : '' ?>>
|
||||
<?= View::h($beschriftung) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<label for="anfangssaldo">Anfangssaldo</label>
|
||||
<input type="text" id="anfangssaldo" name="anfangssaldo" value="0,00" inputmode="decimal">
|
||||
<p class="feldhinweis">
|
||||
Der Anfangssaldo deckt die Zeit ab, die der Abruf nicht mehr hergibt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>Abruf</h3>
|
||||
|
||||
<label for="abrufzeitraum">Abrufzeitraum</label>
|
||||
<select id="abrufzeitraum" name="abrufzeitraum">
|
||||
<?php foreach (Bankzugang::ZEITRAEUME as $schluessel => $beschriftung): ?>
|
||||
<option value="<?= View::h($schluessel) ?>"
|
||||
<?= $wert('abrufzeitraum', 'letzter_abruf') === $schluessel ? 'selected' : '' ?>>
|
||||
<?= View::h($beschriftung) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<label for="abruf_stichtag">Stichtag</label>
|
||||
<input type="date" id="abruf_stichtag" name="abruf_stichtag"
|
||||
value="<?= View::h($wert('abruf_stichtag')) ?>" max="<?= date('Y-m-d') ?>">
|
||||
<p class="feldhinweis">
|
||||
Gilt, wenn oben <em>Ab Stichtag</em> 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.
|
||||
</p>
|
||||
|
||||
<label class="ankreuzfeld">
|
||||
<input type="checkbox" name="aktiv" value="1" <?= $neu || (int)$zugang['aktiv'] === 1 ? 'checked' : '' ?>>
|
||||
aktiv
|
||||
</label>
|
||||
|
||||
<?php if (!$neu && $bankkonto !== null && $bankkonto['saldo'] !== null): ?>
|
||||
<p class="feldhinweis">
|
||||
Kontostand bei PayPal: <strong><?= View::betrag($bankkonto['saldo']) ?></strong>
|
||||
<?php if ($bankkonto['saldo_am'] !== null): ?>
|
||||
<span class="nebentext">
|
||||
Stand <?= View::h(date('d.m.Y H:i', strtotime((string)$bankkonto['saldo_am']))) ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer"><?= $neu ? 'Anlegen' : 'Speichern' ?></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (!$neu): ?>
|
||||
<?php if (($abrufe ?? []) !== []): ?>
|
||||
<h3>Abrufe dieser Verbindung</h3>
|
||||
<table class="liste">
|
||||
<thead>
|
||||
<tr><th>Begonnen</th><th>Status</th><th class="rechts">Neu</th><th>Meldung</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($abrufe as $abruf): ?>
|
||||
<tr>
|
||||
<td><?= View::h(date('d.m.Y H:i', strtotime((string)$abruf['begonnen_am']))) ?></td>
|
||||
<td><?= View::h($abruf['status']) ?></td>
|
||||
<td class="rechts"><?= (int)$abruf['neu'] ?></td>
|
||||
<td class="klein"><?= View::h($abruf['meldung'] ?? '') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/banking/<?= (int)$zugang['id'] ?>/loeschen" class="formular breit"
|
||||
data-bestaetigen="PayPal-Verbindung „<?= View::h($zugang['name']) ?>“ mit allen abgerufenen Umsätzen löschen?">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf gefahr">Bankverbindung löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
50
src/Views/banking/typ.php
Normal file
50
src/Views/banking/typ.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* Die Wahl der Art einer neuen Bankverbindung.
|
||||
*
|
||||
* Beide Wege verlangen völlig verschiedene Angaben – ein gemeinsames Formular
|
||||
* mit zwei ausgegrauten Hälften hülfe niemandem. Die Wahl fällt deshalb vorweg
|
||||
* und steht danach fest.
|
||||
*
|
||||
* @var string $csrfToken
|
||||
*/
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/banking" class="knopf">Zurück zu den Bankverbindungen</a>
|
||||
</div>
|
||||
|
||||
<div class="kachel">
|
||||
<header><h2>Wie wird abgerufen?</h2></header>
|
||||
|
||||
<p class="platzhalter">
|
||||
Die Art der Verbindung entscheidet, welche Zugangsdaten gebraucht werden. Sie lässt
|
||||
sich später nicht mehr umstellen – wohl aber eine zweite Verbindung anlegen.
|
||||
</p>
|
||||
|
||||
<div class="formatwahl">
|
||||
<a class="formatkarte" href="/banking/neu/fints">
|
||||
<span class="formatname">HBCI/FinTS</span>
|
||||
<span class="nebentext">
|
||||
Der Weg zu einer Bank: Bankleitzahl, FinTS-Adresse, Benutzerkennung und PIN –
|
||||
dieselben Angaben wie im Online-Banking. Die Konten meldet die Bank selbst.
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a class="formatkarte" href="/banking/neu/paypal">
|
||||
<span class="formatname">PayPal</span>
|
||||
<span class="nebentext">
|
||||
Der Weg zu einem PayPal-Konto: API-Benutzername, API-Kennwort und Signatur
|
||||
aus dem PayPal-Konto. PayPal führt genau ein Guthabenkonto, die Zuordnung
|
||||
steht deshalb gleich mit im Formular.
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
203
src/Views/verwaltung/bankinstitute.php
Normal file
203
src/Views/verwaltung/bankinstitute.php
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* Bankleitzahlen nachschlagen und die Institutsliste aktualisieren.
|
||||
*
|
||||
* @var string $suche
|
||||
* @var list<array<string,mixed>> $treffer
|
||||
* @var int $grenze
|
||||
* @var int $anzahl
|
||||
* @var int $mitZugang
|
||||
* @var string|null $stand
|
||||
* @var string $quelldatei
|
||||
* @var string $importAm
|
||||
* @var array<string,mixed>|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';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
|
||||
<a href="/banking" class="knopf">Bankverbindungen</a>
|
||||
</div>
|
||||
|
||||
<div class="kachelraster fussbuendig">
|
||||
<div class="kachel">
|
||||
<header><h2>Verzeichnis</h2></header>
|
||||
<p class="kennzahl"><?= number_format($anzahl, 0, ',', '.') ?></p>
|
||||
<p class="platzhalter">
|
||||
Einträge, davon <?= number_format($mitZugang, 0, ',', '.') ?> mit PIN/TAN-Zugangspunkt.
|
||||
Jüngste Änderung in den Daten: <?= View::h($datum($stand)) ?>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="kachel">
|
||||
<header><h2>Stand</h2></header>
|
||||
<?php if ($importAm === ''): ?>
|
||||
<p class="platzhalter">
|
||||
Noch nichts eingelesen. Ohne Verzeichnis muss die FinTS-Adresse beim Anlegen
|
||||
einer Bankverbindung von Hand eingetragen werden.
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<p class="platzhalter">
|
||||
Eingelesen am <?= View::h(date('d.m.Y', strtotime($importAm))) ?>
|
||||
<?= $quelldatei === '' ? '' : ' aus „' . View::h($quelldatei) . '“' ?>.
|
||||
</p>
|
||||
<form method="post" action="/verwaltung/bankinstitute/leeren" class="knopfleiste"
|
||||
data-bestaetigen="Das gesamte Bankleitzahlen-Verzeichnis löschen?">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<button type="submit" class="knopf klein gefahr">Verzeichnis leeren</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($ergebnis !== null): ?>
|
||||
<div class="kachel">
|
||||
<header>
|
||||
<h2><?= $trockenlauf ? 'Prüfung' : 'Übernommen' ?><?= $dateiname === '' ? '' : ': ' . View::h($dateiname) ?></h2>
|
||||
</header>
|
||||
|
||||
<table class="liste">
|
||||
<tbody>
|
||||
<tr><th>Zeilen in der Datei</th><td><?= (int)$ergebnis['gelesen'] ?></td></tr>
|
||||
<tr>
|
||||
<th><?= $trockenlauf ? 'Davon übernehmbar' : 'Übernommen' ?></th>
|
||||
<td><?= (int)$ergebnis['uebernommen'] ?></td>
|
||||
</tr>
|
||||
<tr><th>Ohne Bankleitzahl übergangen</th><td><?= (int)$ergebnis['ohne_blz'] ?></td></tr>
|
||||
<tr><th>Verschiedene Bankleitzahlen</th><td><?= (int)$ergebnis['banken'] ?></td></tr>
|
||||
<tr><th>Mit PIN/TAN-Zugangspunkt</th><td><?= (int)$ergebnis['mit_zugang'] ?></td></tr>
|
||||
<tr><th>Jüngste Änderung in den Daten</th><td><?= View::h($datum($ergebnis['stand'])) ?></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php if (!empty($ergebnis['beispiele'])): ?>
|
||||
<p class="feldhinweis">
|
||||
Sichtprobe der ersten Zeilen – stimmen hier Umlaute und Spalten, stimmt der Rest auch.
|
||||
</p>
|
||||
<table class="liste">
|
||||
<thead>
|
||||
<tr><th>BLZ</th><th>Institut</th><th>Ort</th><th>FinTS-Adresse</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($ergebnis['beispiele'] as $zeile): ?>
|
||||
<tr>
|
||||
<td class="nowrap"><?= View::h((string)$zeile['blz']) ?></td>
|
||||
<td><?= View::h((string)$zeile['institut']) ?></td>
|
||||
<td><?= View::h((string)$zeile['ort']) ?></td>
|
||||
<td class="nebentext"><?= View::h((string)$zeile['pintan_url']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($trockenlauf): ?>
|
||||
<p class="hinweis">
|
||||
Es wurde nichts geschrieben. Zum Übernehmen dieselbe Datei unten noch einmal
|
||||
auswählen und „Übernehmen“ drücken – dabei wird der bisherige Bestand ersetzt.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/verwaltung/bankinstitute/import" enctype="multipart/form-data"
|
||||
class="formular breit">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<h3>Institutsliste einlesen</h3>
|
||||
|
||||
<p class="feldhinweis">
|
||||
Erwartet wird die CSV-Liste der FinTS-Institute der Deutschen Kreditwirtschaft
|
||||
(<code>hbci-zka.de</code>): Semikolon als Trenner, mit den Spalten <em>BLZ</em>,
|
||||
<em>BIC</em>, <em>Institut</em>, <em>Ort</em> und <em>PIN/TAN-Zugang URL</em>.
|
||||
ISO-8859-1 wie UTF-8 werden gelesen. Ein Import ersetzt den bisherigen Bestand
|
||||
vollständig.
|
||||
</p>
|
||||
|
||||
<label for="datei">CSV-Datei</label>
|
||||
<input type="file" id="datei" name="datei" accept=".csv,text/csv" required>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" name="modus" value="pruefen" class="knopf">Nur prüfen</button>
|
||||
<button type="submit" name="modus" value="import" class="knopf primaer">Übernehmen</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="get" action="/verwaltung/bankinstitute" class="formular breit">
|
||||
<h3>Nachschlagen</h3>
|
||||
|
||||
<label for="suche">Bankleitzahl, BIC, Name oder Ort</label>
|
||||
<input type="search" id="suche" name="suche" value="<?= View::h($suche) ?>"
|
||||
placeholder="50050222, HELADEF1822, 1822direkt …">
|
||||
<p class="feldhinweis">
|
||||
Eine reine Ziffernfolge gilt als Bankleitzahl und wird von vorn verglichen;
|
||||
alles andere wird in Institut, Ort und BIC gesucht.
|
||||
</p>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Suchen</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if ($suche !== ''): ?>
|
||||
<?php if ($treffer === []): ?>
|
||||
<p class="platzhalter">
|
||||
Kein Treffer für „<?= View::h($suche) ?>“.
|
||||
<?= $anzahl === 0 ? 'Das Verzeichnis ist noch leer – erst die Liste einlesen.' : '' ?>
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<?php if (count($treffer) >= $grenze): ?>
|
||||
<p class="hinweis">
|
||||
Es werden höchstens <?= (int)$grenze ?> Treffer gezeigt – die Suche weiter eingrenzen.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<table class="liste">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>BLZ</th><th>BIC</th><th>Institut</th><th>Ort</th>
|
||||
<th>FinTS-Adresse</th><th>Stand</th><th class="rechts">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($treffer as $institut): ?>
|
||||
<tr<?= (string)$institut['pintan_url'] === '' ? ' class="inaktiv"' : '' ?>>
|
||||
<td class="nowrap"><?= View::h((string)$institut['blz']) ?></td>
|
||||
<td class="nowrap"><?= View::h((string)$institut['bic']) ?></td>
|
||||
<td><?= View::h((string)$institut['institut']) ?></td>
|
||||
<td><?= View::h((string)$institut['ort']) ?></td>
|
||||
<td class="nebentext">
|
||||
<?php if ((string)$institut['pintan_url'] === ''): ?>
|
||||
<span class="platzhalter">kein PIN/TAN-Zugang genannt</span>
|
||||
<?php else: ?>
|
||||
<?= View::h((string)$institut['pintan_url']) ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="nowrap"><?= View::h($datum($institut['stand'])) ?></td>
|
||||
<td class="rechts nowrap">
|
||||
<?php if ((string)$institut['pintan_url'] !== ''): ?>
|
||||
<?php /* Übernimmt Bank, Bankleitzahl und Adresse ins
|
||||
Formular für eine neue FinTS-Verbindung. */ ?>
|
||||
<a class="knopf klein primaer"
|
||||
href="/banking/neu/fints?blz=<?= urlencode((string)$institut['blz']) ?>">
|
||||
Verbindung anlegen
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
|
|
@ -70,6 +70,19 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kachel">
|
||||
<header><h2>Bankleitzahlen</h2></header>
|
||||
<p class="kennzahl"><?= number_format((int)$zahlen['bankinstitute'], 0, ',', '.') ?></p>
|
||||
<p class="platzhalter">
|
||||
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.
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<a href="/verwaltung/bankinstitute" class="knopf klein primaer">Nachschlagen und aktualisieren</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kachel">
|
||||
<header><h2>Termine</h2></header>
|
||||
<p class="kennzahl"><?= (int)$zahlen['reihen'] ?></p>
|
||||
|
|
|
|||
406
tests/31_PaypalZugangTest.php
Normal file
406
tests/31_PaypalZugangTest.php
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Models\Bankumsatz;
|
||||
use Finanzmanager\Models\Bankzugang;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Services\Crypto;
|
||||
use Finanzmanager\Services\PaypalNvp;
|
||||
|
||||
/**
|
||||
* PayPal als zweite Art von Bankverbindung – ohne PayPal.
|
||||
*
|
||||
* Geprüft wird alles, was um den API-Aufruf herum liegt: der Zugang mit seinen
|
||||
* verschlüsselten Zugangsdaten, das eine Konto, der Fingerabdruck der Umsätze
|
||||
* und die Auswertung der Antworten. Die Antworten selbst sind nachgestellt –
|
||||
* genau in der Form, in der PayPal sie schickt, samt der Eigenheiten, die beim
|
||||
* Anschluss aufgelaufen sind.
|
||||
*/
|
||||
class PaypalZugangTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 31: PayPal-Bankverbindung';
|
||||
|
||||
private int $zugang = 0;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
305
tests/32_BankinstituteTest.php
Normal file
305
tests/32_BankinstituteTest.php
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Models\Bankinstitut;
|
||||
use Finanzmanager\Models\Bankzugang;
|
||||
use Finanzmanager\Services\FintsInstituteImport;
|
||||
|
||||
/**
|
||||
* Das Bankleitzahlen-Verzeichnis: Import der FinTS-Institutsliste, Suche und
|
||||
* die Vorbelegung der FinTS-Adresse beim Anlegen einer Bankverbindung.
|
||||
*
|
||||
* Die Prüfsteine sind die Eigenheiten der echten Datei: ISO-8859-1 statt UTF-8,
|
||||
* ein Institutsname mit Zeilenumbruch in Anführungszeichen, dieselbe
|
||||
* Bankleitzahl in vielen Filialzeilen und Zeilen ohne Zugangspunkt.
|
||||
*/
|
||||
class BankinstituteTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 32: Bankleitzahlen-Verzeichnis';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
155
tests/e2e/specs/banking.spec.js
Normal file
155
tests/e2e/specs/banking.spec.js
Normal file
|
|
@ -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}$`))
|
||||
})
|
||||
})
|
||||
98
tests/e2e/specs/bankinstitute.spec.js
Normal file
98
tests/e2e/specs/bankinstitute.spec.js
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue