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
|
# Ä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)
|
## Spiegelung der Umbuchungen unter Test (17.08.2026)
|
||||||
|
|
||||||
Neuer Test `tests/30_UmbuchungSpiegelungTest.php`. Er nagelt die zwei Zusagen fest, die
|
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
|
das Cockpit beantwortet die täglichen Fragen auf einen Blick. Einzelheiten in
|
||||||
`docs/auswertungen.md` und `docs/cockpit.md`.
|
`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`),
|
* 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
|
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.
|
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
|
(`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**,
|
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.
|
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
|
* *Ergebnis:* Umsätze kommen per Knopfdruck rein und sind großteils automatisch
|
||||||
kategorisiert. Einzelheiten in `docs/banking.md`.
|
kategorisiert. Einzelheiten in `docs/banking.md`.
|
||||||
|
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -1,8 +1,8 @@
|
||||||
# Finanzmanager
|
# Finanzmanager
|
||||||
|
|
||||||
Persönliche Finanzverwaltung als Web-Anwendung: Konten, Buchungen mit Kategorien und
|
Persönliche Finanzverwaltung als Web-Anwendung: Konten, Buchungen mit Kategorien und
|
||||||
Klassen, Jahresbudget, Monatsabschluss, Auswertungen, Online-Banking über FinTS und
|
Klassen, Jahresbudget, Monatsabschluss, Auswertungen, Online-Banking über FinTS und PayPal
|
||||||
Wertpapierdepots.
|
sowie Wertpapierdepots.
|
||||||
|
|
||||||
Ersetzt den Lexware FinanzManager Deluxe, läuft auf einem Debian-Server statt auf einem
|
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
|
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),
|
Schritten (Zugangsdaten, TAN-Verfahren, Kontenzuordnung mit Anlegen fehlender Konten),
|
||||||
Abrufzeitraum als Spanne oder Stichtag – auch für einen einzelnen Abruf –, abgerufene
|
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
|
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,
|
* **Wertpapiere** – Depotbestände mit Einstand und Bewertung, Depotbuchungen, Kursabruf,
|
||||||
Veräußerungsgeschäfte und Erträge; Bestandslisten der Bank lassen sich als CSV
|
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
|
ü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/planung.md` – Budgetplaner und Monatsabschluss
|
||||||
- `docs/auswertungen.md` – Berichtsmodul, Filterleiste, Drilldown, CSV
|
- `docs/auswertungen.md` – Berichtsmodul, Filterleiste, Drilldown, CSV
|
||||||
- `docs/cockpit.md` – Startseite und ihre Kacheln
|
- `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/wertpapiere.md` – Depots, Einstand, Kurse, Invst- und CSV-Bestandsimport
|
||||||
- `docs/nebenmodule.md` – Vorlagen, Termine, Verträge, Sparziele, Kredite, Zahlungsverkehr
|
- `docs/nebenmodule.md` – Vorlagen, Termine, Verträge, Sparziele, Kredite, Zahlungsverkehr
|
||||||
- `docs/import-export.md` – QIF und CSV in beide Richtungen, Formatwahl, Housekeeping
|
- `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
|
Umsätze kommen per Knopfdruck von der Bank, werden gegen die vorhandenen Buchungen
|
||||||
abgeglichen und über Regeln weitgehend selbst kategorisiert.
|
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
|
## Die Bibliothek: ein eigener Fork
|
||||||
|
|
||||||
Verwendet wird `nemiah/php-fints`, aber nicht von Packagist, sondern als **Fork auf
|
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,
|
Beim Nachziehen eines neuen Upstream-Standes muss dieser Eingriff wiederholt werden; wie,
|
||||||
steht in `psr-log/README.md` des Forks.
|
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
|
## Der Schlüssel für die Zugangsdaten
|
||||||
|
|
||||||
PIN und TAN-Medium stehen AES-256-GCM-verschlüsselt in der Datenbank (`Services\Crypto`,
|
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
|
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.
|
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
|
## Vom Umsatz zur Buchung
|
||||||
|
|
||||||
Abgerufene Umsätze landen zuerst in `bank_umsaetze`. Ob sie von dort selbst weiterwandern,
|
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
|
Bank steckt hinter derselben Grenze wie der Dialog – geprüft wird, was mit ihrem Ergebnis
|
||||||
geschieht.
|
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
|
`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
|
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
|
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
|
sowie die Zählung der unkategorisierten Buchungen je Konto (Jahresgrenze, Umbuchung und
|
||||||
Splitbuchung zählen nicht mit) und ihr Weg in die Kontenübersicht.
|
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.
|
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,
|
Geprüft wurde er gegen den Fehlerpfad: Eine unerreichbare Adresse bricht sauber ab,
|
||||||
protokolliert und meldet verständlich.
|
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\Config;
|
||||||
use Finanzmanager\Controllers\AuswertungenController;
|
use Finanzmanager\Controllers\AuswertungenController;
|
||||||
use Finanzmanager\Controllers\BankingController;
|
use Finanzmanager\Controllers\BankingController;
|
||||||
|
use Finanzmanager\Controllers\BankinstituteController;
|
||||||
use Finanzmanager\Controllers\BuchungenController;
|
use Finanzmanager\Controllers\BuchungenController;
|
||||||
use Finanzmanager\Controllers\BudgetController;
|
use Finanzmanager\Controllers\BudgetController;
|
||||||
use Finanzmanager\Controllers\CockpitController;
|
use Finanzmanager\Controllers\CockpitController;
|
||||||
|
|
@ -100,7 +101,7 @@ $router->get('/buchungen/{id}/wertpapier',
|
||||||
$router->post('/buchungen/{id}/wertpapier',
|
$router->post('/buchungen/{id}/wertpapier',
|
||||||
fn(array $p) => (new BuchungenController())->wertpapier($p));
|
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', fn() => (new VerwaltungController())->index());
|
||||||
|
|
||||||
$router->get('/verwaltung/kategorien', fn() => (new KategorienController())->index());
|
$router->get('/verwaltung/kategorien', fn() => (new KategorienController())->index());
|
||||||
|
|
@ -131,6 +132,11 @@ $router->post('/verwaltung/kontengruppen/{id}',
|
||||||
$router->post('/verwaltung/kontengruppen/{id}/loeschen',
|
$router->post('/verwaltung/kontengruppen/{id}/loeschen',
|
||||||
fn(array $p) => (new VerwaltungController())->kontengruppeLoeschen($p));
|
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 ───────────────────────────────
|
// ── Planung: Budgetplaner und Monatsabschluss ───────────────────────────────
|
||||||
$router->get('/planung', fn() => (new PlanungController())->index());
|
$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 ────────────────────────
|
// ── Online-Banking: Zugänge, Abruf, Abgleich, Regeln ────────────────────────
|
||||||
$router->get('/banking', fn() => (new BankingController())->index());
|
$router->get('/banking', fn() => (new BankingController())->index());
|
||||||
$router->get('/banking/neu', fn() => (new BankingController())->neu());
|
$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
|
// Die festen Pfade stehen vor „/banking/{id}“, sonst schluckt das Muster auch
|
||||||
// „/banking/umsaetze“ und „/banking/regeln“.
|
// „/banking/umsaetze“ und „/banking/regeln“.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use Finanzmanager\Auth;
|
||||||
use Finanzmanager\Database;
|
use Finanzmanager\Database;
|
||||||
use Finanzmanager\Models\AbgleichRegel;
|
use Finanzmanager\Models\AbgleichRegel;
|
||||||
use Finanzmanager\Models\Bankabruf;
|
use Finanzmanager\Models\Bankabruf;
|
||||||
|
use Finanzmanager\Models\Bankinstitut;
|
||||||
use Finanzmanager\Models\Bankumsatz;
|
use Finanzmanager\Models\Bankumsatz;
|
||||||
use Finanzmanager\Models\Bankzugang;
|
use Finanzmanager\Models\Bankzugang;
|
||||||
use Finanzmanager\Models\Kategorie;
|
use Finanzmanager\Models\Kategorie;
|
||||||
|
|
@ -17,15 +18,25 @@ use Finanzmanager\Services\Bankabgleich;
|
||||||
use Finanzmanager\Services\Crypto;
|
use Finanzmanager\Services\Crypto;
|
||||||
use Finanzmanager\Services\FinTsAbruf;
|
use Finanzmanager\Services\FinTsAbruf;
|
||||||
use Finanzmanager\Services\FinTsVerfahren;
|
use Finanzmanager\Services\FinTsVerfahren;
|
||||||
|
use Finanzmanager\Services\PaypalAbruf;
|
||||||
use Finanzmanager\View;
|
use Finanzmanager\View;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Online-Banking: Bankverbindungen verwalten, Umsätze abrufen, abgleichen und
|
* Online-Banking: Bankverbindungen verwalten, Umsätze abrufen, abgleichen und
|
||||||
* Regeln pflegen.
|
* Regeln pflegen.
|
||||||
*
|
*
|
||||||
* Der Abruf selbst steckt in `Services\FinTsAbruf`; dieser Controller reicht nur
|
* Der Abruf selbst steckt in `Services\FinTsAbruf` beziehungsweise
|
||||||
* durch und übersetzt das Ergebnis in Seiten – vor allem in die TAN-Maske, die
|
* `Services\PaypalAbruf`; dieser Controller reicht nur durch und übersetzt das
|
||||||
* zwischen zwei Requests stehen bleibt.
|
* 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
|
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
|
public function neu(): void
|
||||||
{
|
{
|
||||||
$this->verlangeAnmeldung();
|
$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', [
|
$this->render('banking/formular.php', [
|
||||||
'titel' => 'Bankverbindung anlegen',
|
'titel' => 'Bankverbindung anlegen',
|
||||||
'zugang' => null,
|
'zugang' => null,
|
||||||
'konten' => [],
|
'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();
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
|
$typ = self::typ($params);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$id = Bankzugang::anlegen($this->formulardaten());
|
$id = Bankzugang::anlegen(
|
||||||
|
$typ === 'paypal' ? $this->paypalFormulardaten() : $this->formulardaten()
|
||||||
|
);
|
||||||
} catch (\Throwable $e) {
|
} 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;
|
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 */
|
/** @param array<string,string> $params */
|
||||||
public function bearbeiten(array $params): void
|
public function bearbeiten(array $params): void
|
||||||
{
|
{
|
||||||
|
|
@ -93,6 +192,19 @@ class BankingController extends BaseController
|
||||||
return;
|
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', [
|
$this->render('banking/formular.php', [
|
||||||
'titel' => $zugang['name'],
|
'titel' => $zugang['name'],
|
||||||
'zugang' => $zugang,
|
'zugang' => $zugang,
|
||||||
|
|
@ -107,15 +219,25 @@ class BankingController extends BaseController
|
||||||
$this->verlangeAnmeldung();
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
$id = (int)$params['id'];
|
$id = (int)$params['id'];
|
||||||
|
$zugang = Bankzugang::find($id);
|
||||||
|
if ($zugang === null) {
|
||||||
|
View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$paypal = (string)$zugang['typ'] === 'paypal';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Bankzugang::speichern($id, $this->formulardaten());
|
Bankzugang::speichern($id, $paypal ? $this->paypalFormulardaten() : $this->formulardaten());
|
||||||
|
$neue = $paypal && $this->paypalKontoZuordnen($id);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
View::redirect('/banking/' . $id, $e->getMessage(), 'fehler');
|
View::redirect('/banking/' . $id, $e->getMessage(), 'fehler');
|
||||||
return;
|
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 */
|
/** @param array<string,string> $params */
|
||||||
|
|
@ -153,6 +275,10 @@ class BankingController extends BaseController
|
||||||
View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler');
|
View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ((string)$zugang['typ'] === 'paypal') {
|
||||||
|
View::redirect('/banking/' . $id, 'PayPal kennt kein TAN-Verfahren.', 'warnung');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$abfrage = $this->gemerkteAbfrage($id) ?? $this->verfahrenHolen($id);
|
$abfrage = $this->gemerkteAbfrage($id) ?? $this->verfahrenHolen($id);
|
||||||
|
|
||||||
|
|
@ -238,6 +364,12 @@ class BankingController extends BaseController
|
||||||
View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler');
|
View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler');
|
||||||
return;
|
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', [
|
$this->render('banking/konten.php', [
|
||||||
'titel' => 'Konten zuordnen – ' . $zugang['name'],
|
'titel' => 'Konten zuordnen – ' . $zugang['name'],
|
||||||
|
|
@ -382,9 +514,18 @@ class BankingController extends BaseController
|
||||||
// als schon vorhanden, und sie blieben für immer unsichtbar.
|
// als schon vorhanden, und sie blieben für immer unsichtbar.
|
||||||
Bankumsatz::verwaisteOeffnen();
|
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. */
|
/** Die TAN-Maske nach einem Neuladen der Seite. */
|
||||||
|
|
@ -689,11 +830,21 @@ class BankingController extends BaseController
|
||||||
/** @return array<string,mixed> */
|
/** @return array<string,mixed> */
|
||||||
private function formulardaten(): array
|
private function formulardaten(): array
|
||||||
{
|
{
|
||||||
|
$blz = $this->post('blz');
|
||||||
|
|
||||||
|
// Bleibt die Adresse leer, wird sie im Bankleitzahlen-Verzeichnis
|
||||||
|
// nachgeschlagen. Das ist der übliche Fall: Die Bankleitzahl kennt
|
||||||
|
// jeder auswendig, den FinTS-Zugangspunkt niemand.
|
||||||
|
$url = $this->post('fints_url');
|
||||||
|
if ($url === '') {
|
||||||
|
$url = (string)(Bankinstitut::findeNachBlz($blz)['pintan_url'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => $this->post('name'),
|
'name' => $this->post('name'),
|
||||||
'bankname' => $this->post('bankname'),
|
'bankname' => $this->post('bankname'),
|
||||||
'blz' => $this->post('blz'),
|
'blz' => $blz,
|
||||||
'fints_url' => $this->post('fints_url'),
|
'fints_url' => $url,
|
||||||
'benutzerkennung' => $this->post('benutzerkennung'),
|
'benutzerkennung' => $this->post('benutzerkennung'),
|
||||||
'pin' => $this->post('pin'),
|
'pin' => $this->post('pin'),
|
||||||
'abrufzeitraum' => $this->post('abrufzeitraum'),
|
'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> */
|
/** @return array<string,mixed> */
|
||||||
private function regeldaten(): array
|
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;
|
namespace Finanzmanager\Controllers;
|
||||||
|
|
||||||
|
use Finanzmanager\Models\Bankinstitut;
|
||||||
use Finanzmanager\Models\Erinnerung;
|
use Finanzmanager\Models\Erinnerung;
|
||||||
use Finanzmanager\Models\Kategorie;
|
use Finanzmanager\Models\Kategorie;
|
||||||
use Finanzmanager\Models\Klasse;
|
use Finanzmanager\Models\Klasse;
|
||||||
|
|
@ -33,6 +34,7 @@ class VerwaltungController extends BaseController
|
||||||
'klassen' => Klasse::anzahl(),
|
'klassen' => Klasse::anzahl(),
|
||||||
'konten' => Konto::anzahl(),
|
'konten' => Konto::anzahl(),
|
||||||
'kontengruppen' => count(Kontengruppe::alle()),
|
'kontengruppen' => count(Kontengruppe::alle()),
|
||||||
|
'bankinstitute' => Bankinstitut::anzahl(),
|
||||||
'reihen' => count(RegelmaessigeBuchung::alle(true)),
|
'reihen' => count(RegelmaessigeBuchung::alle(true)),
|
||||||
'erinnerungen' => Erinnerung::anzahlOffen(),
|
'erinnerungen' => Erinnerung::anzahlOffen(),
|
||||||
'vertraege' => count(Vertrag::alle(true)),
|
'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
|
public static function existiert(string $externId): bool
|
||||||
{
|
{
|
||||||
return Database::fetchValue(
|
return Database::fetchValue(
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,27 @@ use Finanzmanager\Services\Crypto;
|
||||||
* Ein Bankzugang: Zugangsdaten einer Bank samt der Konten, die dort geführt
|
* Ein Bankzugang: Zugangsdaten einer Bank samt der Konten, die dort geführt
|
||||||
* werden.
|
* werden.
|
||||||
*
|
*
|
||||||
* Die PIN steht verschlüsselt in der Datenbank (`Services\Crypto`) und wird
|
* Es gibt ihn in zwei Ausführungen (`typ`): den **HBCI/FinTS**-Zugang mit
|
||||||
* ausschließlich über `zugangsdaten()` wieder herausgereicht – wer die Zeile
|
* Bankleitzahl, Adresse, Benutzerkennung und PIN, und den **PayPal**-Zugang mit
|
||||||
* über `find()` holt, bekommt sie im Geheimtext.
|
* 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
|
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. */
|
/** Vorgaben für den Abrufzeitraum, Schlüssel → Beschriftung. */
|
||||||
public const ZEITRAEUME = [
|
public const ZEITRAEUME = [
|
||||||
'letzter_abruf' => 'Seit dem letzten Abruf',
|
'letzter_abruf' => 'Seit dem letzten Abruf',
|
||||||
|
|
@ -61,6 +76,8 @@ class Bankzugang
|
||||||
|
|
||||||
$zugang['pin'] = Crypto::entschluessle((string)($zugang['pin'] ?? ''));
|
$zugang['pin'] = Crypto::entschluessle((string)($zugang['pin'] ?? ''));
|
||||||
$zugang['tan_medium'] = Crypto::entschluessle((string)($zugang['tan_medium'] ?? ''));
|
$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;
|
return $zugang;
|
||||||
}
|
}
|
||||||
|
|
@ -71,16 +88,20 @@ class Bankzugang
|
||||||
public static function anlegen(array $daten): int
|
public static function anlegen(array $daten): int
|
||||||
{
|
{
|
||||||
$felder = self::normalisiere($daten);
|
$felder = self::normalisiere($daten);
|
||||||
|
self::verlangeAdresse((string)$felder['typ'], (string)$felder['fints_url']);
|
||||||
|
|
||||||
Database::execute(
|
Database::execute(
|
||||||
'INSERT INTO bank_zugaenge
|
'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,
|
tan_verfahren_id, tan_verfahren_name, tan_medium, abrufzeitraum,
|
||||||
abruf_stichtag, aktiv)
|
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['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['tan_verfahren_id'], $felder['tan_verfahren_name'], $felder['tan_medium'],
|
||||||
$felder['abrufzeitraum'], $felder['abruf_stichtag'], $felder['aktiv'],
|
$felder['abrufzeitraum'], $felder['abruf_stichtag'], $felder['aktiv'],
|
||||||
]
|
]
|
||||||
|
|
@ -101,10 +122,19 @@ class Bankzugang
|
||||||
|
|
||||||
$felder = self::normalisiere($daten);
|
$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“ –
|
// Ein leeres PIN-Feld heißt „unverändert lassen“, nicht „PIN löschen“ –
|
||||||
// sonst verlöre jedes Speichern des Formulars die Zugangsdaten.
|
// sonst verlöre jedes Speichern des Formulars die Zugangsdaten.
|
||||||
if ($felder['pin'] === null) {
|
// Für die beiden PayPal-Geheimnisse gilt dasselbe.
|
||||||
$felder['pin'] = $bisher['pin'];
|
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,
|
// Dasselbe für das TAN-Verfahren: Es steht nicht im Zugangsformular,
|
||||||
|
|
@ -118,12 +148,15 @@ class Bankzugang
|
||||||
|
|
||||||
Database::execute(
|
Database::execute(
|
||||||
'UPDATE bank_zugaenge SET name = ?, bankname = ?, blz = ?, fints_url = ?,
|
'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 = ?
|
tan_medium = ?, abrufzeitraum = ?, abruf_stichtag = ?, aktiv = ?
|
||||||
WHERE id = ? AND benutzer_id = ?',
|
WHERE id = ? AND benutzer_id = ?',
|
||||||
[
|
[
|
||||||
$felder['name'], $felder['bankname'], $felder['blz'], $felder['fints_url'],
|
$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['tan_verfahren_name'], $felder['tan_medium'],
|
||||||
$felder['abrufzeitraum'], $felder['abruf_stichtag'], $felder['aktiv'],
|
$felder['abrufzeitraum'], $felder['abruf_stichtag'], $felder['aktiv'],
|
||||||
$id, Mandant::id(),
|
$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
|
public static function loeschen(int $id): void
|
||||||
{
|
{
|
||||||
Database::execute('DELETE FROM bank_zugaenge WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
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.');
|
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'] ?? ''));
|
$url = trim((string)($daten['fints_url'] ?? ''));
|
||||||
if ($url !== '' && !str_starts_with($url, 'https://')) {
|
if ($url !== '' && !str_starts_with($url, 'https://')) {
|
||||||
throw new \InvalidArgumentException('Die FinTS-Adresse muss mit https:// beginnen.');
|
throw new \InvalidArgumentException('Die FinTS-Adresse muss mit https:// beginnen.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein leeres PIN-Feld bedeutet beim Bearbeiten „unverändert“; beim
|
// Ein leeres PIN-Feld bedeutet beim Bearbeiten „unverändert“; beim
|
||||||
// Anlegen wird daraus schlicht eine leere PIN.
|
// Anlegen wird daraus schlicht eine leere PIN. Für die beiden
|
||||||
$pin = (string)($daten['pin'] ?? '');
|
// PayPal-Geheimnisse gilt dieselbe Regel.
|
||||||
$pin = $pin === '' ? null : Crypto::verschluessle($pin);
|
//
|
||||||
|
// 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 = $daten['tan_verfahren_id'] ?? null;
|
||||||
$verfahren = ($verfahren === null || $verfahren === '') ? null : (int)$verfahren;
|
$verfahren = ($verfahren === null || $verfahren === '') ? null : (int)$verfahren;
|
||||||
|
|
@ -316,12 +383,17 @@ class Bankzugang
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'typ' => $typ,
|
||||||
'name' => mb_substr($name, 0, 128),
|
'name' => mb_substr($name, 0, 128),
|
||||||
'bankname' => mb_substr(trim((string)($daten['bankname'] ?? '')), 0, 128),
|
'bankname' => mb_substr(trim((string)($daten['bankname'] ?? '')), 0, 128),
|
||||||
'blz' => preg_replace('/\D/', '', (string)($daten['blz'] ?? '')) ?? '',
|
'blz' => preg_replace('/\D/', '', (string)($daten['blz'] ?? '')) ?? '',
|
||||||
'fints_url' => mb_substr($url, 0, 255),
|
'fints_url' => mb_substr($url, 0, 255),
|
||||||
'benutzerkennung' => mb_substr(trim((string)($daten['benutzerkennung'] ?? '')), 0, 128),
|
'benutzerkennung' => mb_substr(trim((string)($daten['benutzerkennung'] ?? '')), 0, 128),
|
||||||
'pin' => $pin,
|
'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_id' => $verfahren,
|
||||||
'tan_verfahren_name' => mb_substr(trim((string)($daten['tan_verfahren_name'] ?? '')), 0, 128),
|
'tan_verfahren_name' => mb_substr(trim((string)($daten['tan_verfahren_name'] ?? '')), 0, 128),
|
||||||
'tan_medium' => Crypto::verschluessle(trim((string)($daten['tan_medium'] ?? ''))),
|
'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
|
* Legt ein bei der Bank gefundenes Konto an oder findet es wieder. Beim
|
||||||
* ersten Auftauchen bleibt es ohne Zuordnung – der Nutzer entscheidet, auf
|
* 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 array<string,mixed>|null $zugang */
|
||||||
/** @var list<array<string,mixed>> $konten Bankkonten dieses Zugangs */
|
/** @var list<array<string,mixed>> $konten Bankkonten dieses Zugangs */
|
||||||
/** @var list<array<string,mixed>> $abrufe */
|
/** @var list<array<string,mixed>> $abrufe */
|
||||||
|
/** @var array<string,string> $vorgabe Vorbelegung aus dem Bankleitzahlen-Verzeichnis */
|
||||||
/** @var string $csrfToken */
|
/** @var string $csrfToken */
|
||||||
|
|
||||||
$neu = $zugang === null;
|
$neu = $zugang === null;
|
||||||
$ziel = $neu ? '/banking/neu' : '/banking/' . (int)$zugang['id'];
|
$ziel = $neu ? '/banking/neu/fints' : '/banking/' . (int)$zugang['id'];
|
||||||
$wert = static fn(string $feld, string $standard = ''): string => (string)($zugang[$feld] ?? $standard);
|
$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';
|
include __DIR__ . '/../layout/kopf.php';
|
||||||
?>
|
?>
|
||||||
|
|
@ -20,7 +26,9 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="seitenaktionen">
|
<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): ?>
|
<?php if (!$neu): ?>
|
||||||
<form method="post" action="/banking/<?= (int)$zugang['id'] ?>/abrufen" class="inline abrufform">
|
<form method="post" action="/banking/<?= (int)$zugang['id'] ?>/abrufen" class="inline abrufform">
|
||||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
<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>
|
<label for="fints_url">FinTS-Adresse</label>
|
||||||
<input type="text" id="fints_url" name="fints_url" value="<?= View::h($wert('fints_url')) ?>"
|
<input type="text" id="fints_url" name="fints_url" value="<?= View::h($wert('fints_url')) ?>"
|
||||||
placeholder="https://…" required>
|
placeholder="https://…">
|
||||||
<p class="feldhinweis">
|
<p class="feldhinweis">
|
||||||
Der HBCI/FinTS-Zugangspunkt der Bank – sie nennt ihn in ihrer Hilfe zum
|
Der HBCI/FinTS-Zugangspunkt der Bank; muss mit <code>https://</code> beginnen.
|
||||||
Online-Banking. 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>
|
</p>
|
||||||
|
|
||||||
<label for="benutzerkennung">Benutzerkennung</label>
|
<label for="benutzerkennung">Benutzerkennung</label>
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,9 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
|
|
||||||
<?php if ($zugaenge === []): ?>
|
<?php if ($zugaenge === []): ?>
|
||||||
<p class="platzhalter">
|
<p class="platzhalter">
|
||||||
Noch keine Bankverbindung eingerichtet. Gebraucht werden Bankleitzahl, FinTS-Adresse,
|
Noch keine Bankverbindung eingerichtet. Für eine Bank über HBCI/FinTS gebraucht
|
||||||
Benutzerkennung und PIN – dieselben Angaben wie im Online-Banking der Bank.
|
werden Bankleitzahl, FinTS-Adresse, Benutzerkennung und PIN – dieselben Angaben wie
|
||||||
|
im Online-Banking; für PayPal die drei Angaben des API-Zugriffs.
|
||||||
</p>
|
</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<table class="liste">
|
<table class="liste">
|
||||||
|
|
@ -56,6 +57,12 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<?php if ($zugang['blz'] !== ''): ?>
|
<?php if ($zugang['blz'] !== ''): ?>
|
||||||
<span class="nebentext">BLZ <?= View::h($zugang['blz']) ?></span>
|
<span class="nebentext">BLZ <?= View::h($zugang['blz']) ?></span>
|
||||||
<?php endif; ?>
|
<?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>
|
||||||
<td>
|
<td>
|
||||||
<?= View::h(Bankzugang::ZEITRAEUME[$zugang['abrufzeitraum']] ?? $zugang['abrufzeitraum']) ?>
|
<?= 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>
|
</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">
|
<div class="kachel">
|
||||||
<header><h2>Termine</h2></header>
|
<header><h2>Termine</h2></header>
|
||||||
<p class="kennzahl"><?= (int)$zahlen['reihen'] ?></p>
|
<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 }) => {
|
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.goto('/banking/neu')
|
||||||
|
await page.click('.formatkarte:has-text("HBCI/FinTS")')
|
||||||
|
|
||||||
await page.fill('#name', 'E2E-Testbank')
|
await page.fill('#name', 'E2E-Testbank')
|
||||||
await page.fill('#blz', '12030000')
|
await page.fill('#blz', '12030000')
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue