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';
@@ -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';
= View::h($tan['bezeichnung'] ?? 'Abruf') ?>
+
+
+ Alle Umsätze abrufen: Bankverbindung = (int)$sammelabruf['nummer'] ?>
+ von = (int)$sammelabruf['gesamt'] ?> – = View::h($sammelabruf['name']) ?>
+
+
+
= View::h($meldung) ?>
@@ -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}`