Fix #75: Knopf „Alle Umsätze abrufen“ ruft jede Bank der Reihe nach ab

Ü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 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-09-28 12:38:24 +02:00
parent 1a8b64e71d
commit 4d833f273b
11 changed files with 569 additions and 21 deletions

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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));

View file

@ -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<string,mixed> $zugang
* @return array<string,mixed>
*/
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<string,mixed> $tan
* @param list<string> $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;
}

View file

@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
/**
* „Alle Umsätze abrufen“: jede aktive Bankverbindung der Reihe nach.
*
* Die Klasse führt nur die Warteschlange – abgerufen wird weiterhin einzeln
* über `FinTsAbruf` beziehungsweise `PaypalAbruf`. Der Grund ist die TAN: Verlangt
* eine Bank eine Freigabe, endet der Request mit der TAN-Maske, und erst der
* nächste Request setzt den Abruf fort. Die Warteschlange muss diese Pause
* überstehen; der Controller legt ihren Zustand deshalb in die Sitzung und
* holt ihn dort wieder ab.
*
* Ein Fehler bei einer Bank hält die übrigen nicht auf: Er wird vermerkt, und
* es geht mit der nächsten weiter. Am Ende steht je Bankverbindung eine Meldung.
*/
final class Sammelabruf
{
/** Schlüssel des Zustands in der Sitzung. */
public const SITZUNG = 'banking_sammelabruf';
/**
* @param array{offen: list<array{id: int, name: string}>,
* aktuell: array{id: int, name: string}|null,
* gesamt: int,
* meldungen: list<array{typ: string, meldung: string}>} $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<array<string,mixed>> $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<string,mixed> 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<string,mixed> $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<array{typ: string, meldung: string}> 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;
}
}

View file

@ -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

View file

@ -30,6 +30,15 @@ include __DIR__ . '/../layout/kopf.php';
<div class="seitenaktionen">
<a href="/banking/neu" class="knopf primaer">Bankverbindung anlegen</a>
<a href="/banking/umsaetze" class="knopf">Umsätze abgleichen</a>
<?php if (array_filter($zugaenge, fn(array $z): bool => (int)$z['aktiv'] === 1) !== []): ?>
<?php /* Ruft jede aktive Bankverbindung der Reihe nach ab, jede mit
ihrem eingestellten Abrufzeitraum. Verlangt eine Bank eine
Freigabe, geht es nach der TAN mit der nächsten weiter. */ ?>
<form method="post" action="/banking/alle-abrufen" class="inline" id="alle-abrufen">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf">Alle Umsätze abrufen</button>
</form>
<?php endif; ?>
</div>
<?php if ($zugaenge === []): ?>
@ -137,6 +146,12 @@ include __DIR__ . '/../layout/kopf.php';
übernommen oder verworfen werden.
</p>
<p class="hinweis">
<em>Alle Umsätze abrufen</em> 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.
</p>
<p class="hinweis">
Das Datumsfeld neben <em>Umsätze abrufen</em> ist ein <strong>Stichtag für diesen einen
Abruf</strong>: Es wird ab diesem Tag geholt, ohne den eingestellten Abrufzeitraum zu

View file

@ -10,10 +10,12 @@ use Finanzmanager\View;
* @var array<string,mixed> $tan
* @var string $meldung
* @var list<string> $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';
<p class="kachelhinweis"><?= View::h($tan['bezeichnung'] ?? 'Abruf') ?></p>
</header>
<?php if ($sammelabruf !== null): ?>
<p class="feldhinweis sammelabrufstand">
Alle Umsätze abrufen: Bankverbindung <?= (int)$sammelabruf['nummer'] ?>
von <?= (int)$sammelabruf['gesamt'] ?> – <strong><?= View::h($sammelabruf['name']) ?></strong>
</p>
<?php endif; ?>
<p class="hinweis"><?= View::h($meldung) ?></p>
<?php if (($tan['hinweis'] ?? '') !== ''): ?>
@ -66,6 +75,10 @@ include __DIR__ . '/../layout/kopf.php';
<form method="post" action="/banking/tan/abbrechen" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<div class="knopfleiste">
<?php if ($sammelabruf !== null && $sammelabruf['nummer'] < $sammelabruf['gesamt']): ?>
<?php /* Nur diese Bank auslassen – die übrigen werden trotzdem abgerufen. */ ?>
<button type="submit" name="weiter" value="1" class="knopf">Diese Bank überspringen</button>
<?php endif; ?>
<button type="submit" class="knopf">Abruf abbrechen</button>
</div>
</form>

View file

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Services\Sammelabruf;
/**
* „Alle Umsätze abrufen“: die Warteschlange über alle aktiven Bankverbindungen.
*
* Der Abruf selbst bleibt außen vor – geprüft wird, dass jede Bank der Reihe
* nach drankommt, ein Fehler die übrigen nicht aufhält, die Warteschlange eine
* TAN-Pause in der Sitzung übersteht und am Ende je Bank eine Meldung steht.
*/
class SammelabrufTest extends TestCase
{
protected string $testName = 'Test 46: Alle Umsätze abrufen';
public function run(): void
{
$this->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');
}
}

View file

@ -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}`