Merge pull request 'Fix #75: Knopf „Alle Umsätze abrufen“ ruft jede Bank der Reihe nach ab' (#87) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/87
This commit is contained in:
fds2610 2026-09-28 12:47:01 +02:00
commit e080e8f6dd
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}`