From 4d833f273b3d5528de92c04a79291800ab30a6cd Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 28 Sep 2026 12:38:24 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20#75:=20Knopf=20=E2=80=9EAlle=20Ums=C3=A4t?= =?UTF-8?q?ze=20abrufen=E2=80=9C=20ruft=20jede=20Bank=20der=20Reihe=20nach?= =?UTF-8?q?=20ab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Über der Tabelle der Bankverbindungen ruft ein neuer Knopf jede aktive Bankverbindung einzeln nacheinander ab. Die Warteschlange (Services\Sammelabruf) übersteht TAN-Runden in der Sitzung; nach der Freigabe geht es mit der nächsten Bank weiter. Die TAN-Maske zeigt den Stand und erlaubt, eine Bank zu überspringen. Am Ende steht je Bankverbindung eine eigene Meldung. Co-Authored-By: Claude Opus 5.5 --- Changes.md | 13 ++ README.md | 3 +- docs/banking.md | 24 ++++ public/index.php | 1 + src/Controllers/BankingController.php | 172 ++++++++++++++++++++++--- src/Services/Sammelabruf.php | 176 ++++++++++++++++++++++++++ src/View.php | 10 +- src/Views/banking/index.php | 15 +++ src/Views/banking/tan.php | 15 ++- tests/46_SammelabrufTest.php | 138 ++++++++++++++++++++ tests/e2e/specs/banking.spec.js | 23 ++++ 11 files changed, 569 insertions(+), 21 deletions(-) create mode 100644 src/Services/Sammelabruf.php create mode 100644 tests/46_SammelabrufTest.php diff --git a/Changes.md b/Changes.md index 2826323..588fc03 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,18 @@ # Änderungen +## Online-Banking: Alle Umsätze abrufen (28.09.2026) + +Über der Tabelle der Bankverbindungen steht ein Knopf *Alle Umsätze abrufen*, der jede +aktive Bankverbindung der Reihe nach einzeln abruft (#75). + +- Neuer Dienst `Services\Sammelabruf` führt die Warteschlange; sie liegt während einer + TAN-Runde in der Sitzung, nach der Freigabe geht es mit der nächsten Bank weiter. +- Die TAN-Maske zeigt im Sammelabruf, welche Bankverbindung gerade fragt, und bietet + *Diese Bank überspringen* neben *Abruf abbrechen*. +- Ein Fehler bei einer Bank hält die übrigen nicht auf; am Ende steht je Bankverbindung + eine eigene Meldung. Dafür neu: `View::flash()` für mehrere Meldungen auf einmal. +- Neuer Test `tests/46_SammelabrufTest.php`, E2E-Prüfung in `banking.spec.js`. + ## Einnahme/Ausgabe entscheidet der Geldfluss, nicht die Kategorie-Art (28.09.2026) Cockpit und Berichte zählten eine Bewegung nach der Art ihrer Kategorie zu Einnahmen diff --git a/README.md b/README.md index 46e6d66..97edaaf 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ Haushalt wird beides parallel genutzt. Monat, Ausgaben nach Kategorie, Kontostände, offene Vorgänge. * **Online-Banking** – FinTS-Abruf mit TAN-Dialog über mehrere Requests, Einrichtung in drei 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 –, *Alle Umsätze + abrufen* für alle Bankverbindungen der Reihe nach, abgerufene Umsätze buchen sich auf Wunsch selbst, Abgleich gegen vorhandene Buchungen, Regeln für die automatische Kategorisierung. Alternativ **PayPal** über dessen NVP-Schnittstelle: ein Formular statt dreier Schritte, da PayPal genau ein Guthabenkonto führt. diff --git a/docs/banking.md b/docs/banking.md index 0ea8d43..e4958aa 100644 --- a/docs/banking.md +++ b/docs/banking.md @@ -174,6 +174,24 @@ Wie weit die Bank tatsächlich zurückreicht, entscheidet sie selbst – 90 Tage mehr liefert nicht jede. Ein früherer Stichtag holt deshalb nicht zwingend mehr; die Lücke davor schließt der Anfangssaldo des Kontos oder der QIF-Import. +### Alle Umsätze abrufen + +Über der Tabelle der Bankverbindungen ruft *Alle Umsätze abrufen* jede **aktive** +Bankverbindung der Reihe nach ab – in der Reihenfolge der Übersicht und jede mit ihrem +eingestellten Abrufzeitraum (ein einmaliger Stichtag gilt hier nicht). Die Warteschlange +führt `Services\Sammelabruf`; abgerufen wird weiterhin einzeln über `FinTsAbruf` +bzw. `PaypalAbruf`. + +Was ohne Rückfrage geht, läuft in einem Request durch. Verlangt eine Bank eine Freigabe, +liegt die Warteschlange in der Sitzung (`banking_sammelabruf`), und die TAN-Maske zeigt, +welche Bankverbindung gerade fragt („Bankverbindung 2 von 4“). Nach der Freigabe geht es in +`tanEinreichen()` mit der nächsten weiter. In der Maske lässt *Diese Bank überspringen* nur +die fragende Bank aus; *Abruf abbrechen* beendet den ganzen Lauf. + +Ein Fehler bei einer Bank hält die übrigen nicht auf. Am Ende steht je Bankverbindung eine +eigene Meldung – Fehler rot –, und es geht wie beim Einzelabruf in *Umsätze abgleichen*, +wenn dort etwas liegt. Ein einzelner Abruf löst einen liegengebliebenen Sammelabruf ab. + ### TAN-Verfahren * **Klassisch** (chipTAN, photoTAN): Die Maske zeigt den Hinweistext der Bank und, wenn @@ -511,6 +529,12 @@ ein PayPal-Zugang aber ohne sie auskommt. Der Weg im Browser (Upload, Prüfberic steht in `tests/e2e/specs/bankinstitute.spec.js`; übernommen wird dort nichts, weil ein Import den ganzen Bestand der Instanz ersetzte. +`tests/46_SammelabrufTest.php` prüft die Warteschlange von *Alle Umsätze abrufen*: nur +aktive Bankverbindungen in der Reihenfolge der Übersicht, ein Fehler hält die übrigen nicht +auf, der Zustand übersteht die TAN-Pause in der Sitzung, Überspringen und Abbrechen samt +ihren Meldungen. Dass der Knopf über der Tabelle steht, prüft +`tests/e2e/specs/banking.spec.js`. + `tests/28_UmsaetzeLoeschenTest.php` prüft das Sammellöschen: Es trifft genau die ausgewählten Zeilen, eine schon erzeugte Buchung überlebt es, jeder Status lässt sich löschen, leere und unbekannte Auswahl bleiben folgenlos, eine doppelt gesendete Nummer zählt diff --git a/public/index.php b/public/index.php index 4d47f00..b8c83e0 100644 --- a/public/index.php +++ b/public/index.php @@ -300,6 +300,7 @@ $router->post('/banking/neu/{typ}', fn(array $p) => (new BankingController())->a // „/banking/umsaetze“ und „/banking/regeln“. $router->get('/banking/umsaetze', fn() => (new BankingController())->umsaetze()); $router->post('/banking/umsaetze/alle', fn() => (new BankingController())->alleUebernehmen()); +$router->post('/banking/alle-abrufen', fn() => (new BankingController())->alleAbrufen()); $router->post('/banking/umsaetze/loeschen', fn() => (new BankingController())->umsaetzeLoeschen()); $router->post('/banking/umsaetze/{id}/uebernehmen', fn(array $p) => (new BankingController())->uebernehmen($p)); diff --git a/src/Controllers/BankingController.php b/src/Controllers/BankingController.php index edf3217..ee00bc1 100644 --- a/src/Controllers/BankingController.php +++ b/src/Controllers/BankingController.php @@ -20,6 +20,7 @@ use Finanzmanager\Services\Crypto; use Finanzmanager\Services\FinTsAbruf; use Finanzmanager\Services\FinTsVerfahren; use Finanzmanager\Services\PaypalAbruf; +use Finanzmanager\Services\Sammelabruf; use Finanzmanager\View; /** @@ -535,6 +536,10 @@ class BankingController extends BaseController // als schon vorhanden, und sie blieben für immer unsichtbar. Bankumsatz::verwaisteOeffnen(); + // Ein einzelner Abruf löst einen liegengebliebenen Sammelabruf ab – + // sonst liefe dessen Warteschlange nach der nächsten TAN weiter. + $this->sammelabrufMerken(null); + $id = (int)$params['id']; $zugang = Bankzugang::find($id); if ($zugang === null) { @@ -542,11 +547,100 @@ class BankingController extends BaseController return; } - $ergebnis = (string)$zugang['typ'] === 'paypal' - ? PaypalAbruf::starten($id, $this->post('stichtag')) - : FinTsAbruf::starten($id, 'voll', false, $this->post('stichtag')); + $this->zeigeAbruf($this->abrufStarten($zugang, $this->post('stichtag')), $id); + } - $this->zeigeAbruf($ergebnis, $id); + /** + * „Alle Umsätze abrufen“: jede aktive Bankverbindung der Reihe nach, mit + * dem jeweils eingestellten Abrufzeitraum. + * + * Was ohne Rückfrage geht, läuft in diesem einen Request durch. Verlangt + * eine Bank eine Freigabe, steht die TAN-Maske da; nach der Freigabe geht + * es in `tanEinreichen()` mit der nächsten Bank weiter. + */ + public function alleAbrufen(): void + { + $this->verlangeAnmeldung(); + + Bankumsatz::verwaisteOeffnen(); + + $zugaenge = Bankzugang::alle(true); + if ($zugaenge === []) { + View::redirect('/banking', 'Es gibt keine aktive Bankverbindung zum Abrufen.', 'fehler'); + return; + } + + $this->sammelabrufFortsetzen(Sammelabruf::fuer($zugaenge)); + } + + /** + * Die Warteschlange abarbeiten, bis sie leer ist oder eine Bank eine + * Freigabe verlangt. + */ + private function sammelabrufFortsetzen(Sammelabruf $lauf): void + { + while (($naechste = $lauf->naechste()) !== null) { + $zugang = Bankzugang::find($naechste['id']); + $ergebnis = $zugang === null + ? ['status' => 'fehler', 'meldung' => 'Die Bankverbindung gibt es nicht mehr.'] + : $this->abrufStarten($zugang, null); + + if ($ergebnis['status'] === 'tan') { + $this->sammelabrufMerken($lauf); + $this->zeigeTan($ergebnis['tan'] ?? [], $ergebnis['meldung'], $ergebnis['protokoll'], $lauf); + return; + } + + $lauf->vermerke($ergebnis); + } + + $this->sammelabrufBeenden($lauf); + } + + /** Je Bankverbindung eine Meldung, dann dorthin, wo es weitergeht. */ + private function sammelabrufBeenden(Sammelabruf $lauf): void + { + $this->sammelabrufMerken(null); + + foreach ($lauf->meldungen() as $meldung) { + View::flash($meldung['meldung'], $meldung['typ']); + } + + View::redirect(Bankumsatz::anzahlOffen() > 0 ? '/banking/umsaetze' : '/banking'); + } + + /** Der Sammelabruf aus der Sitzung – `null`, wenn keiner läuft. */ + private function sammelabruf(): ?Sammelabruf + { + Auth::startSession(); + + return Sammelabruf::aus($_SESSION[Sammelabruf::SITZUNG] ?? null); + } + + /** Den Sammelabruf für den nächsten Request ablegen; `null` vergisst ihn. */ + private function sammelabrufMerken(?Sammelabruf $lauf): void + { + Auth::startSession(); + if ($lauf === null) { + unset($_SESSION[Sammelabruf::SITZUNG]); + } else { + $_SESSION[Sammelabruf::SITZUNG] = $lauf->zustand(); + } + } + + /** + * Einen Abruf je nach Art der Bankverbindung starten. + * + * @param array $zugang + * @return array + */ + private function abrufStarten(array $zugang, ?string $stichtag): array + { + $id = (int)$zugang['id']; + + return (string)$zugang['typ'] === 'paypal' + ? PaypalAbruf::starten($id, $stichtag) + : FinTsAbruf::starten($id, 'voll', false, $stichtag); } /** Die TAN-Maske nach einem Neuladen der Seite. */ @@ -560,12 +654,7 @@ class BankingController extends BaseController return; } - $this->render('banking/tan.php', [ - 'titel' => 'Freigabe erforderlich', - 'tan' => $tan, - 'meldung' => 'Die Bank verlangt eine Freigabe.', - 'protokoll' => [], - ]); + $this->zeigeTan($tan, 'Die Bank verlangt eine Freigabe.', [], $this->sammelabruf()); } public function tanEinreichen(): void @@ -574,16 +663,64 @@ class BankingController extends BaseController $ergebnis = FinTsAbruf::tanEinreichen($this->post('tan')); + // Gehört die Freigabe zu einem Sammelabruf, geht es nach ihr mit der + // nächsten Bank weiter – nicht zurück in die Übersicht. + $lauf = $this->sammelabruf(); + if ($lauf !== null && $ergebnis['status'] !== 'tan' + && ($lauf->aktuell()['id'] ?? null) === (int)($ergebnis['zugang_id'] ?? 0)) { + $lauf->vermerke($ergebnis); + $this->sammelabrufFortsetzen($lauf); + return; + } + $this->zeigeAbruf($ergebnis, null); } + /** + * Den wartenden Abruf abbrechen. + * + * Im Sammelabruf gibt es zwei Wege: `weiter` lässt nur die Bank aus, die + * gerade fragt, und macht mit der nächsten weiter – für die Bank, deren + * Banking-Gerät gerade nicht zur Hand ist. Sonst endet der ganze Lauf. + */ public function tanAbbrechen(): void { $this->verlangeAnmeldung(); FinTsAbruf::abbrechen(); - View::redirect('/banking', 'Abruf abgebrochen.'); + $lauf = $this->sammelabruf(); + if ($lauf === null) { + View::redirect('/banking', 'Abruf abgebrochen.'); + return; + } + + if ($this->post('weiter') === '1') { + $lauf->ueberspringe(); + $this->sammelabrufFortsetzen($lauf); + return; + } + + $lauf->abbrechen(); + $this->sammelabrufBeenden($lauf); + } + + /** + * Die TAN-Maske – im Sammelabruf mit dem Hinweis, welche Bank fragt und wie + * viele noch folgen. + * + * @param array $tan + * @param list $protokoll + */ + private function zeigeTan(array $tan, string $meldung, array $protokoll, ?Sammelabruf $lauf): void + { + $this->render('banking/tan.php', [ + 'titel' => 'Freigabe erforderlich', + 'tan' => $tan, + 'meldung' => $meldung, + 'protokoll' => $protokoll, + 'sammelabruf' => $lauf?->aktuell() === null ? null : $lauf->stand(), + ]); } /** @@ -595,12 +732,13 @@ class BankingController extends BaseController private function zeigeAbruf(array $ergebnis, ?int $zugangId): void { if ($ergebnis['status'] === 'tan') { - $this->render('banking/tan.php', [ - 'titel' => 'Freigabe erforderlich', - 'tan' => $ergebnis['tan'] ?? [], - 'meldung' => $ergebnis['meldung'], - 'protokoll' => $ergebnis['protokoll'], - ]); + // Den Stand eines Sammelabrufs nur zeigen, wenn die Freigabe auch + // zu dessen laufender Bank gehört. + $lauf = $this->sammelabruf(); + if (($lauf?->aktuell()['id'] ?? null) !== (int)($ergebnis['zugang_id'] ?? 0)) { + $lauf = null; + } + $this->zeigeTan($ergebnis['tan'] ?? [], $ergebnis['meldung'], $ergebnis['protokoll'], $lauf); return; } diff --git a/src/Services/Sammelabruf.php b/src/Services/Sammelabruf.php new file mode 100644 index 0000000..679a13c --- /dev/null +++ b/src/Services/Sammelabruf.php @@ -0,0 +1,176 @@ +, + * aktuell: array{id: int, name: string}|null, + * gesamt: int, + * meldungen: list} $zustand + */ + private function __construct(private array $zustand) + { + } + + /** + * Eine neue Warteschlange über die übergebenen Bankverbindungen – in der + * Reihenfolge, in der sie auch in der Übersicht stehen. + * + * @param list> $zugaenge + */ + public static function fuer(array $zugaenge): self + { + $offen = []; + foreach ($zugaenge as $zugang) { + $offen[] = ['id' => (int)$zugang['id'], 'name' => (string)$zugang['name']]; + } + + return new self([ + 'offen' => $offen, + 'aktuell' => null, + 'gesamt' => count($offen), + 'meldungen' => [], + ]); + } + + /** + * Die Warteschlange aus einem gespeicherten Zustand – `null`, wenn keiner + * (mehr) da ist. + */ + public static function aus(mixed $zustand): ?self + { + if (!is_array($zustand) || !is_array($zustand['offen'] ?? null)) { + return null; + } + + return new self([ + 'offen' => array_values($zustand['offen']), + 'aktuell' => is_array($zustand['aktuell'] ?? null) ? $zustand['aktuell'] : null, + 'gesamt' => (int)($zustand['gesamt'] ?? 0), + 'meldungen' => is_array($zustand['meldungen'] ?? null) ? $zustand['meldungen'] : [], + ]); + } + + /** @return array Der Zustand zum Ablegen in der Sitzung. */ + public function zustand(): array + { + return $this->zustand; + } + + /** + * Die nächste Bankverbindung – `null`, wenn alle durch sind. Sie gilt ab + * jetzt als die laufende, bis ihr Ergebnis vermerkt ist. + * + * @return array{id: int, name: string}|null + */ + public function naechste(): ?array + { + $this->zustand['aktuell'] = array_shift($this->zustand['offen']); + + return $this->zustand['aktuell']; + } + + /** @return array{id: int, name: string}|null Die gerade laufende Bankverbindung. */ + public function aktuell(): ?array + { + return $this->zustand['aktuell']; + } + + /** + * Das Ergebnis der laufenden Bankverbindung festhalten. + * + * @param array $ergebnis wie von `FinTsAbruf`/`PaypalAbruf` + */ + public function vermerke(array $ergebnis): void + { + if ($this->zustand['aktuell'] === null) { + return; + } + + $fehler = ($ergebnis['status'] ?? 'fehler') === 'fehler'; + $this->notiere( + $fehler ? 'fehler' : 'erfolg', + ($fehler ? 'Abruf fehlgeschlagen: ' : '') . (string)($ergebnis['meldung'] ?? '') + ); + } + + /** Die laufende Bankverbindung ohne Ergebnis hinter sich lassen. */ + public function ueberspringe(): void + { + if ($this->zustand['aktuell'] !== null) { + $this->notiere('fehler', 'Übersprungen, die Freigabe wurde abgebrochen.'); + } + } + + /** + * Den ganzen Lauf abbrechen: Die laufende und alle noch offenen + * Bankverbindungen werden nicht mehr abgerufen. + */ + public function abbrechen(): void + { + $offen = count($this->zustand['offen']) + ($this->zustand['aktuell'] !== null ? 1 : 0); + $this->zustand['aktuell'] = null; + $this->zustand['offen'] = []; + + if ($offen > 0) { + $this->zustand['meldungen'][] = [ + 'typ' => 'fehler', + 'meldung' => sprintf( + 'Abruf abgebrochen – %d Bankverbindung%s nicht abgerufen.', + $offen, + $offen === 1 ? '' : 'en' + ), + ]; + } + } + + /** + * Wo der Lauf steht – für die TAN-Maske, damit dort klar ist, welche Bank + * gerade fragt und wie viele noch folgen. + * + * @return array{nummer: int, gesamt: int, name: string} + */ + public function stand(): array + { + return [ + 'nummer' => $this->zustand['gesamt'] - count($this->zustand['offen']), + 'gesamt' => $this->zustand['gesamt'], + 'name' => (string)($this->zustand['aktuell']['name'] ?? ''), + ]; + } + + /** @return list Je Bankverbindung eine Meldung. */ + public function meldungen(): array + { + return $this->zustand['meldungen']; + } + + private function notiere(string $typ, string $meldung): void + { + $this->zustand['meldungen'][] = [ + 'typ' => $typ, + 'meldung' => $this->zustand['aktuell']['name'] . ': ' . $meldung, + ]; + $this->zustand['aktuell'] = null; + } +} diff --git a/src/View.php b/src/View.php index 6d92631..cdb171d 100644 --- a/src/View.php +++ b/src/View.php @@ -30,13 +30,19 @@ class View } public static function redirect(string $pfad, string $meldung = '', string $typ = 'erfolg'): void + { + self::flash($meldung, $typ); + header('Location: ' . $pfad); + exit; + } + + /** Eine Meldung für die nächste Seite vormerken – es dürfen mehrere sein. */ + public static function flash(string $meldung, string $typ = 'erfolg'): void { Auth::startSession(); if ($meldung !== '') { $_SESSION['flash'][] = ['typ' => $typ, 'meldung' => $meldung]; } - header('Location: ' . $pfad); - exit; } public static function h(mixed $wert): string diff --git a/src/Views/banking/index.php b/src/Views/banking/index.php index 33dde49..8efc6b8 100644 --- a/src/Views/banking/index.php +++ b/src/Views/banking/index.php @@ -30,6 +30,15 @@ include __DIR__ . '/../layout/kopf.php';
Bankverbindung anlegen Umsätze abgleichen + (int)$z['aktiv'] === 1) !== []): ?> + +
+ + +
+
@@ -137,6 +146,12 @@ include __DIR__ . '/../layout/kopf.php'; übernommen oder verworfen werden.

+

+ Alle Umsätze abrufen holt jede aktive Bankverbindung der Reihe nach mit ihrem + eingestellten Abrufzeitraum ab. Verlangt eine Bank eine Freigabe, geht es nach der TAN + mit der nächsten weiter; am Ende steht je Bankverbindung eine Meldung. +

+

Das Datumsfeld neben Umsätze abrufen ist ein Stichtag für diesen einen Abruf: Es wird ab diesem Tag geholt, ohne den eingestellten Abrufzeitraum zu diff --git a/src/Views/banking/tan.php b/src/Views/banking/tan.php index 0bda396..410ccd6 100644 --- a/src/Views/banking/tan.php +++ b/src/Views/banking/tan.php @@ -10,10 +10,12 @@ use Finanzmanager\View; * @var array $tan * @var string $meldung * @var list $protokoll + * @var array{nummer: int, gesamt: int, name: string}|null $sammelabruf * @var string $csrfToken */ -$entkoppelt = ($tan['entkoppelt'] ?? false) === true; +$entkoppelt = ($tan['entkoppelt'] ?? false) === true; +$sammelabruf = $sammelabruf ?? null; include __DIR__ . '/../layout/kopf.php'; ?> @@ -24,6 +26,13 @@ include __DIR__ . '/../layout/kopf.php';

+ +

+ Alle Umsätze abrufen: Bankverbindung + von – +

+ +

@@ -66,6 +75,10 @@ include __DIR__ . '/../layout/kopf.php';
+ + + +
diff --git a/tests/46_SammelabrufTest.php b/tests/46_SammelabrufTest.php new file mode 100644 index 0000000..12d39b8 --- /dev/null +++ b/tests/46_SammelabrufTest.php @@ -0,0 +1,138 @@ +mandantAnlegen(); + + $this->nurAktiveKommenDran(); + $this->jedeBankDerReiheNach(); + $this->derZustandUeberstehtDieTanPause(); + $this->ueberspringenUndAbbrechen(); + $this->kaputterZustandIstKeinLauf(); + } + + private function nurAktiveKommenDran(): void + { + foreach (['Sammel B' => 1, 'Sammel A' => 1, 'Sammel ruht' => 0] as $name => $aktiv) { + Bankzugang::anlegen([ + 'name' => $name, + 'blz' => '12030000', + 'fints_url' => 'https://banking.example.org/fints', + 'benutzerkennung' => 'kunde', + 'pin' => 'geheim', + 'abrufzeitraum' => 'letzter_abruf', + 'aktiv' => $aktiv, + ]); + } + + $lauf = Sammelabruf::fuer(Bankzugang::alle(true)); + $namen = []; + while (($naechste = $lauf->naechste()) !== null) { + $namen[] = $naechste['name']; + $lauf->vermerke(['status' => 'erfolg', 'meldung' => 'ok']); + } + + $this->assertEquals(['Sammel A', 'Sammel B'], $namen, + 'Nur aktive Bankverbindungen, in der Reihenfolge der Übersicht'); + } + + private function jedeBankDerReiheNach(): void + { + $lauf = Sammelabruf::fuer([ + ['id' => 1, 'name' => 'Girobank'], + ['id' => 2, 'name' => 'Kaputtbank'], + ['id' => 3, 'name' => 'PayPal'], + ]); + + $this->assertNull($lauf->aktuell(), 'Vor dem Start läuft keine Bank'); + + $this->assertEquals(1, $lauf->naechste()['id'] ?? null, 'Die erste Bank kommt zuerst'); + $this->assertEquals(['nummer' => 1, 'gesamt' => 3, 'name' => 'Girobank'], $lauf->stand(), + 'Der Stand nennt Bank 1 von 3'); + $lauf->vermerke(['status' => 'erfolg', 'meldung' => '4 neue Umsätze.']); + $this->assertNull($lauf->aktuell(), 'Nach dem Vermerk läuft keine Bank mehr'); + + $this->assertEquals(2, $lauf->naechste()['id'] ?? null, 'Dann die zweite'); + $lauf->vermerke(['status' => 'fehler', 'meldung' => 'PIN falsch.']); + + $this->assertEquals(3, $lauf->naechste()['id'] ?? null, + 'Ein Fehler hält die übrigen Banken nicht auf'); + $lauf->vermerke(['status' => 'erfolg', 'meldung' => 'Nichts Neues.']); + + $this->assertNull($lauf->naechste(), 'Danach ist die Warteschlange leer'); + + $this->assertEquals([ + ['typ' => 'erfolg', 'meldung' => 'Girobank: 4 neue Umsätze.'], + ['typ' => 'fehler', 'meldung' => 'Kaputtbank: Abruf fehlgeschlagen: PIN falsch.'], + ['typ' => 'erfolg', 'meldung' => 'PayPal: Nichts Neues.'], + ], $lauf->meldungen(), 'Je Bankverbindung eine Meldung, Fehler als Fehler'); + } + + private function derZustandUeberstehtDieTanPause(): void + { + $lauf = Sammelabruf::fuer([ + ['id' => 7, 'name' => 'TAN-Bank'], + ['id' => 8, 'name' => 'Folgebank'], + ]); + $lauf->naechste(); + + // Wie in der Sitzung: serialisiert und im nächsten Request zurückgeholt. + $weiter = Sammelabruf::aus(unserialize(serialize($lauf->zustand()))); + + $this->assert($weiter !== null, 'Der Zustand lässt sich wiederherstellen'); + $this->assertEquals(7, $weiter->aktuell()['id'] ?? null, 'Die fragende Bank ist noch die laufende'); + + $weiter->vermerke(['status' => 'erfolg', 'meldung' => 'Freigegeben.']); + $this->assertEquals(8, $weiter->naechste()['id'] ?? null, 'Nach der Freigabe geht es mit der nächsten weiter'); + $this->assertEquals(['nummer' => 2, 'gesamt' => 2, 'name' => 'Folgebank'], $weiter->stand(), + 'Der Stand zählt weiter'); + } + + private function ueberspringenUndAbbrechen(): void + { + $lauf = Sammelabruf::fuer([ + ['id' => 1, 'name' => 'Erste'], + ['id' => 2, 'name' => 'Zweite'], + ['id' => 3, 'name' => 'Dritte'], + ['id' => 4, 'name' => 'Vierte'], + ]); + + $lauf->naechste(); + $lauf->ueberspringe(); + $this->assertEquals(2, $lauf->naechste()['id'] ?? null, 'Überspringen macht mit der nächsten weiter'); + + $lauf->abbrechen(); + $this->assertNull($lauf->aktuell(), 'Nach dem Abbruch läuft nichts mehr'); + $this->assertNull($lauf->naechste(), 'Nach dem Abbruch folgt keine Bank mehr'); + + $meldungen = $lauf->meldungen(); + $this->assertEquals(2, count($meldungen), 'Übersprungene Bank und Abbruch je eine Meldung'); + $this->assertEquals('fehler', $meldungen[0]['typ'], 'Übersprungen gilt als nicht abgerufen'); + $this->assert(str_starts_with($meldungen[0]['meldung'], 'Erste: Übersprungen'), + 'Die Meldung nennt die übersprungene Bank'); + $this->assertEquals('Abruf abgebrochen – 3 Bankverbindungen nicht abgerufen.', + $meldungen[1]['meldung'], 'Der Abbruch zählt die laufende und die offenen Banken'); + } + + private function kaputterZustandIstKeinLauf(): void + { + $this->assertNull(Sammelabruf::aus(null), 'Ohne Zustand kein Lauf'); + $this->assertNull(Sammelabruf::aus(['gesamt' => 3]), 'Ein unvollständiger Zustand ist kein Lauf'); + } +} diff --git a/tests/e2e/specs/banking.spec.js b/tests/e2e/specs/banking.spec.js index 462dda8..5e80a74 100644 --- a/tests/e2e/specs/banking.spec.js +++ b/tests/e2e/specs/banking.spec.js @@ -130,6 +130,29 @@ test.describe('Bankverbindungen', () => { await expect(page.locator('#konto_id option:checked')).toHaveText('– nicht zugeordnet –') }) + test('„Alle Umsätze abrufen“ steht über der Tabelle der Bankverbindungen', async ({ page }) => { + const name = `E2E PayPal Sammelabruf ${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$/) + + // Abgeschickt wird hier nicht: Der Lauf ginge an echte Gegenstellen. + const form = page.locator('.seitenaktionen form#alle-abrufen') + await expect(form).toHaveAttribute('method', 'post') + await expect(form).toHaveAttribute('action', '/banking/alle-abrufen') + await expect(form.locator('button[type="submit"]')).toHaveText('Alle Umsätze abrufen') + + // Der Knopf steht über der Tabelle, nicht in einer ihrer Zeilen. + const knopf = await form.boundingBox() + const tabelle = await page.locator('table.liste').first().boundingBox() + expect(knopf.y).toBeLessThan(tabelle.y) + }) + test('Ein PayPal-Zugang kennt kein TAN-Verfahren', async ({ page }) => { const name = `E2E PayPal ohne TAN ${MARKE}`