Wertpapiere: Depotbuchungen sammelweise löschen

Nach einem Import, der schiefging, war das Löschen Zeile für Zeile mühsam. Die
Liste der Depotbuchungen räumt jetzt so auf wie das Kontoblatt: Ankreuzfeld je
Zeile, das Feld in der Titelzeile wählt alle angezeigten, „Auswahl löschen“ in
der Aktionsleiste schickt sie an POST /wertpapiere/buchungen/loeschen. Der Knopf
nennt die Anzahl und bleibt ohne Auswahl gesperrt; die Rückfrage nennt sie
ebenfalls. Ohne JavaScript funktioniert alles weiter – dann ist der Knopf offen
und der Controller weist eine leere Auswahl ab.

Mit jeder Depotbuchung geht ihre Geldbuchung, wie beim Löschen einer einzelnen.
Am Bestand ist nichts nachzuziehen: Er wird bei jedem Aufruf aus den
Depotbuchungen gerechnet und gibt die Löschung von selbst wieder her, ebenso
realisierter Gewinn, Veräußerungen und Erträge.

Der eingestellte Filter (Depot, Wertpapier, Vorgang) hängt am Ziel des
Formulars, deshalb steht danach wieder dieselbe Ansicht da. Zusammengesetzt wird
er im Controller neu, damit kein fremdes Ziel eingeschleust werden kann. Das
gilt jetzt auch für das „Löschen“ je Zeile, das bisher zur ungefilterten Liste
zurücksprang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-12 14:27:51 +02:00
parent 4484ddd90d
commit 4d62529e2c
7 changed files with 402 additions and 2 deletions

View file

@ -1,5 +1,28 @@
# Änderungen
## Wertpapiere: Depotbuchungen sammelweise löschen (12.08.2026)
Die Liste der Depotbuchungen räumt jetzt so auf wie das Kontoblatt – nach einem Import,
der schiefging, war das Löschen Zeile für Zeile mühsam.
**Auswahl löschen** (`Views/wertpapiere/buchungen.php`, `WertpapiereController`,
`public/index.php`)
- Ankreuzfeld je Zeile, das Feld in der Titelzeile wählt alle angezeigten Buchungen. Der
Knopf steht oben in der Aktionsleiste, nennt die Anzahl und bleibt ohne Auswahl
gesperrt; die Rückfrage nennt sie ebenfalls. Ohne JavaScript funktioniert alles weiter:
Dann ist der Knopf offen und `POST /wertpapiere/buchungen/loeschen` weist eine leere
Auswahl ab.
- Mit jeder Depotbuchung geht ihre Geldbuchung – wie beim Löschen einer einzelnen.
- **Am Bestand ist nichts nachzuziehen.** Er wird bei jedem Aufruf aus den Depotbuchungen
gerechnet und gibt die Löschung von selbst wieder her, ebenso realisierter Gewinn,
Veräußerungen und Erträge.
- Der eingestellte Filter (Depot, Wertpapier, Vorgang) hängt am Ziel des Formulars,
deshalb steht danach wieder dieselbe Ansicht da. Das gilt jetzt auch für das *Löschen*
je Zeile, das bisher zur ungefilterten Liste zurücksprang.
`tests/22_DepotbuchungenLoeschenTest.php` prüft Geldseite, Bestand und Auswertungen nach
dem Löschen; `tests/e2e/specs/module.spec.js` den Ablauf im Browser.
## Wertpapiere: Depotbestand aus einer CSV-Datei übernehmen (12.08.2026)
Ein Depot, das nie im FinanzManager geführt wurde, lässt sich jetzt in einem Zug

View file

@ -149,6 +149,24 @@ Der Ablauf ist zweistufig: Der Upload zeigt erst eine Vorschau mit allen Positio
Einstand, Wert und dem Status je Zeile; erst *Jetzt übernehmen* schreibt. Die Datei bleibt
im Importverzeichnis liegen.
## Depotbuchungen löschen
Die Liste der Depotbuchungen löscht einzeln – *Löschen* je Zeile – oder mehrere auf
einmal, genau wie das Kontoblatt: Ankreuzfeld je Zeile, das Feld in der Titelzeile wählt
alle angezeigten, *Auswahl löschen* in der Aktionsleiste schickt sie an
`POST /wertpapiere/buchungen/loeschen`. Der Knopf nennt die Anzahl und bleibt ohne
Auswahl gesperrt; die Rückfrage kommt vor dem Absenden. Ohne JavaScript funktioniert
alles weiter – dann ist der Knopf offen und der Controller weist eine leere Auswahl ab.
Der eingestellte Filter hängt am Ziel beider Formulare, deshalb steht danach wieder
dieselbe Ansicht da. Zusammengesetzt wird er im Controller neu, damit kein fremdes Ziel
eingeschleust werden kann.
Mit jeder Depotbuchung geht ihre Geldbuchung (siehe *Zwei Seiten je Geschäft*). Am
Bestand ist nichts nachzuziehen: Er wird bei jedem Aufruf aus den Depotbuchungen
gerechnet und gibt die Löschung von selbst wieder her – ebenso realisierter Gewinn,
Veräußerungen und Erträge. Das Wertpapier selbst bleibt in den Stammdaten stehen.
## Kontenübersicht
Depots erscheinen dort mit ihrem **bewerteten Bestand**, nicht mit einem Buchungssaldo. Der
@ -166,6 +184,11 @@ Bestandsänderung; fehlende Kurse; Veräußerungsrechnung über die Historie hin
QIF-Import eines echten Ausschnitts (Kauf, Dividende, Verkauf) inklusive Idempotenz;
Löschen samt Geldbuchung.
`tests/22_DepotbuchungenLoeschenTest.php` – das Sammellöschen: die Geldbuchungen gehen
mit, das Verrechnungskonto stimmt danach wieder, Bestand, Einstand, realisierter Gewinn,
Erträge und die Veräußerungsauswertung geben die Löschung her, unbekannte Nummern in der
Auswahl bleiben folgenlos. Den Ablauf im Browser prüft `tests/e2e/specs/module.spec.js`.
`tests/21_DepotbestandImportTest.php` – der CSV-Bestandsimport an einem Ausschnitt des
echten DKB-Exports: Vorschau ohne Nebenwirkung, Einstand und Wert aus beiden Kursen,
Bruchstückzahlen, Assetklasse → Art, hinterlegte Bewertungskurse, Idempotenz beim zweiten

View file

@ -240,6 +240,8 @@ $router->get('/wertpapiere/auswertung', fn() => (new WertpapiereController())->a
$router->get('/wertpapiere/buchungen', fn() => (new WertpapiereController())->buchungen());
$router->get('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->neu());
$router->post('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->anlegen());
$router->post('/wertpapiere/buchungen/loeschen',
fn() => (new WertpapiereController())->auswahlLoeschen());
$router->post('/wertpapiere/buchungen/{id}/loeschen',
fn(array $p) => (new WertpapiereController())->loeschen($p));

View file

@ -128,7 +128,65 @@ class WertpapiereController extends BaseController
Depotbuchung::loeschen((int)$params['id']);
View::redirect('/wertpapiere/buchungen', 'Depotbuchung gelöscht – samt ihrer Geldbuchung.');
View::redirect($this->buchungenPfad(), 'Depotbuchung gelöscht – samt ihrer Geldbuchung.');
}
/**
* Sammellöschen aus der Liste der Depotbuchungen – wie im Kontoblatt.
*
* Der Bestand wird bei jedem Aufruf aus den Depotbuchungen gerechnet; er
* gibt die Löschung deshalb von selbst wieder her, ebenso Veräußerungen und
* Erträge. Fortgeschrieben wird nichts.
*/
public function auswahlLoeschen(): void
{
$this->verlangeAnmeldung();
$ziel = $this->buchungenPfad();
$ids = $this->postIds('buchungen');
if ($ids === []) {
View::redirect($ziel, 'Keine Depotbuchung ausgewählt.', 'warnung');
return;
}
$anzahl = 0;
foreach ($ids as $id) {
// Fremde oder längst gelöschte Nummern still übergehen – der Rest
// der Auswahl soll trotzdem verschwinden.
if (Depotbuchung::find($id) === null) {
continue;
}
try {
Depotbuchung::loeschen($id);
} catch (\Throwable $e) {
View::redirect($ziel, $e->getMessage(), 'fehler');
return;
}
$anzahl++;
}
View::redirect($ziel, $anzahl === 1
? 'Depotbuchung gelöscht – samt ihrer Geldbuchung.'
: $anzahl . ' Depotbuchungen gelöscht – samt ihrer Geldbuchungen.');
}
/**
* Zurück in die Liste, mit demselben Filter.
*
* Die Werte kommen aus der Abfragezeichenfolge des Formularziels und werden
* hier neu zusammengesetzt statt übernommen: So kann kein fremdes Ziel
* eingeschleust werden.
*/
private function buchungenPfad(): string
{
$query = array_filter([
'depot' => $this->query('depot'),
'wertpapier' => $this->query('wertpapier'),
'vorgang' => $this->query('vorgang'),
], static fn(string $wert): bool => $wert !== '');
return '/wertpapiere/buchungen' . ($query === [] ? '' : '?' . http_build_query($query));
}
// ── Bestandsimport aus einer CSV-Datei ───────────────────────────────────

View file

@ -11,6 +11,18 @@ use Finanzmanager\View;
* @var string $csrfToken
*/
// Nach dem Löschen soll dieselbe Ansicht wieder erscheinen – der Filter hängt
// deshalb am Ziel der Löschformulare.
$filterPfad = static function () use ($filter): string {
$query = array_filter([
'depot' => (string)($filter['konto_id'] ?? ''),
'wertpapier' => (string)($filter['wertpapier_id'] ?? ''),
'vorgang' => (string)($filter['vorgang'] ?? ''),
], static fn(string $wert): bool => $wert !== '');
return $query === [] ? '' : '?' . http_build_query($query);
};
include __DIR__ . '/../layout/kopf.php';
?>
@ -53,6 +65,20 @@ include __DIR__ . '/../layout/kopf.php';
</select>
<button type="submit" class="knopf klein">Filtern</button>
</form>
<?php if ($buchungen !== []): ?>
<!-- Wie im Kontoblatt: Das Formular steht hier, seine Ankreuzfelder
stehen in der Tabelle; verbunden sind sie über das form-Attribut.
Ein Formular um die ganze Tabelle würde das Filterformular
verschachteln. -->
<form method="post" action="/wertpapiere/buchungen/loeschen<?= View::h($filterPfad()) ?>"
id="depotbuchungsauswahl" class="inline"
data-bestaetigen="Die ausgewählten Depotbuchungen wirklich löschen? Die zugehörigen Geldbuchungen werden mitgelöscht."
data-frage-eine="Die ausgewählte Depotbuchung wirklich löschen? Die zugehörige Geldbuchung wird mitgelöscht."
data-frage-mehrere="{anzahl} ausgewählte Depotbuchungen wirklich löschen? Die zugehörigen Geldbuchungen werden mitgelöscht.">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf gefahr">Auswahl löschen</button>
</form>
<?php endif; ?>
<a href="/wertpapiere/buchungen/neu" class="knopf primaer">Depotbuchung erfassen</a>
</div>
@ -62,6 +88,10 @@ include __DIR__ . '/../layout/kopf.php';
<table class="liste zahlen">
<thead>
<tr>
<th class="wahlspalte">
<input type="checkbox" class="alle-waehlen" data-waehlt="depotbuchungsauswahl"
aria-label="Alle angezeigten Depotbuchungen auswählen">
</th>
<th>Datum</th><th>Vorgang</th><th>Wertpapier</th><th>Depot</th>
<th class="rechts">Stück</th><th class="rechts">Kurs</th>
<th class="rechts">Betrag</th><th class="rechts">Kosten</th>
@ -71,6 +101,11 @@ include __DIR__ . '/../layout/kopf.php';
<tbody>
<?php foreach ($buchungen as $buchung): ?>
<tr>
<td class="wahlspalte">
<input type="checkbox" name="buchungen[]" value="<?= (int)$buchung['id'] ?>"
form="depotbuchungsauswahl"
aria-label="Depotbuchung vom <?= View::h(View::datum($buchung['datum'])) ?> auswählen">
</td>
<td><?= View::h(View::datum($buchung['datum'])) ?></td>
<td>
<?= View::h(Depotbuchung::VORGAENGE[$buchung['vorgang']] ?? $buchung['vorgang']) ?>
@ -108,7 +143,8 @@ include __DIR__ . '/../layout/kopf.php';
<?php endif; ?>
</td>
<td class="rechts">
<form method="post" action="/wertpapiere/buchungen/<?= (int)$buchung['id'] ?>/loeschen"
<form method="post"
action="/wertpapiere/buchungen/<?= (int)$buchung['id'] ?>/loeschen<?= View::h($filterPfad()) ?>"
class="inline"
data-bestaetigen="Depotbuchung löschen? Die zugehörige Geldbuchung wird mitgelöscht.">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">

View file

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\Depot;
/**
* Sammellöschen in der Liste der Depotbuchungen.
*
* Geprüft wird, was hinter dem Knopf *Auswahl löschen* passiert: Jede gewählte
* Depotbuchung geht samt ihrer Geldbuchung, und Bestand, realisierter Gewinn
* und Erträge stehen danach wieder auf dem Stand, den sie ohne die Buchungen
* hätten – der Bestand wird gerechnet, nicht fortgeschrieben.
*/
class DepotbuchungenLoeschenTest extends TestCase
{
protected string $testName = 'Test 22: Depotbuchungen sammelweise löschen';
private int $depot = 0;
private int $verrechnung = 0;
private int $papier = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->auswahlNimmtDieGeldbuchungenMit();
$this->bestandGibtDieLoeschungWiederHer();
$this->unbekannteNummernBleibenFolgenlos();
}
private function stammdaten(): void
{
$this->depot = Konto::anlegen([
'name' => 'Sammeldepot', 'kontoart' => 'depot', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->verrechnung = Konto::anlegen([
'name' => 'Sammelverrechnungskonto', 'kontoart' => 'bank',
'anfangssaldo' => 10000, 'aktiv' => 1,
]);
$this->papier = Wertpapier::findenOderAnlegen([
'name' => 'Sammelaktie AG', 'isin' => 'DE0007654321', 'art' => 'aktie',
]);
}
/**
* Zwei Käufe, ein Verkauf, eine Dividende – wie eine Auswahl in der Liste.
*
* @return list<int>
*/
private function vierBuchungen(): array
{
return [
Depotbuchung::anlegen([
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
'datum' => '2026-01-15', 'vorgang' => 'kauf',
'stueck' => 10, 'kurs' => 100, 'betrag' => 1000, 'gebuehren' => 10,
'verrechnungskonto_id' => $this->verrechnung,
]),
Depotbuchung::anlegen([
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
'datum' => '2026-02-15', 'vorgang' => 'kauf',
'stueck' => 10, 'kurs' => 120, 'betrag' => 1200, 'gebuehren' => 10,
'verrechnungskonto_id' => $this->verrechnung,
]),
Depotbuchung::anlegen([
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
'datum' => '2026-03-15', 'vorgang' => 'verkauf',
'stueck' => 5, 'kurs' => 150, 'betrag' => 750,
'gebuehren' => 15, 'steuern' => 20,
'verrechnungskonto_id' => $this->verrechnung,
]),
Depotbuchung::anlegen([
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
'datum' => '2026-04-01', 'vorgang' => 'dividende',
'betrag' => 50, 'steuern' => 13.19,
'verrechnungskonto_id' => $this->verrechnung,
]),
];
}
/** So arbeitet der Controller die Auswahl ab: Nummer für Nummer. */
private function auswahlLoeschen(array $ids): int
{
$anzahl = 0;
foreach ($ids as $id) {
if (Depotbuchung::find((int)$id) === null) {
continue;
}
Depotbuchung::loeschen((int)$id);
$anzahl++;
}
return $anzahl;
}
private function auswahlNimmtDieGeldbuchungenMit(): void
{
$ids = $this->vierBuchungen();
$geldIds = [];
foreach ($ids as $id) {
$geldIds[] = (int)Depotbuchung::find($id)['buchung_id'];
}
$this->assertEquals(4, count(array_filter($geldIds)), 'Jede Depotbuchung hat ihre Geldbuchung');
// Die beiden Käufe auswählen – der Rest bleibt stehen.
$this->assertEquals(2, $this->auswahlLoeschen([$ids[0], $ids[1]]), 'Zwei Buchungen gelöscht');
$this->assertNull(Depotbuchung::find($ids[0]), 'Der erste Kauf ist weg');
$this->assertNull(Depotbuchung::find($ids[1]), 'Der zweite Kauf auch');
$this->assertNotEmpty(Depotbuchung::find($ids[2]), 'Der Verkauf steht noch');
$this->assertNotEmpty(Depotbuchung::find($ids[3]), 'Die Dividende ebenso');
$this->assertNull(Buchung::find($geldIds[0]), 'Die Geldbuchung des ersten Kaufs ging mit');
$this->assertNull(Buchung::find($geldIds[1]), 'Die des zweiten Kaufs ebenso');
$this->assertNotEmpty(Buchung::find($geldIds[2]), 'Die des Verkaufs blieb unberührt');
// 10000 − 1010 − 1210 + 715 + 36,81; nach dem Löschen der Käufe fehlen
// deren Belastungen wieder.
$this->assertEquals(
10000.0 + 715.0 + 36.81,
Konto::saldo($this->verrechnung, '2026-12-31'),
'Das Verrechnungskonto trägt nur noch Verkauf und Dividende'
);
// Aufräumen für den nächsten Abschnitt.
$this->auswahlLoeschen([$ids[2], $ids[3]]);
}
private function bestandGibtDieLoeschungWiederHer(): void
{
Wertpapier::kursSetzen($this->papier, '2026-06-30', 130.0, 'test');
$leer = Depot::bestand($this->depot, '2026-12-31');
$this->assertEquals(0, count($leer['positionen']), 'Vorher ist das Depot leer');
$ids = $this->vierBuchungen();
$voll = Depot::bestand($this->depot, '2026-12-31');
$this->assertEquals(15.0, $voll['positionen'][0]['stueck'], 'Fünfzehn Stück nach zwei Käufen und einem Verkauf');
$this->assertGreaterThan(0.0, $voll['realisiert'], 'Der Verkauf hat etwas realisiert');
$this->assertEquals(36.81, $voll['ertraege'], 'Die Dividende zählt als Ertrag');
// Verkauf und Dividende zurücknehmen: Der Bestand steht wieder auf den
// beiden Käufen, realisierter Gewinn und Erträge fallen auf null.
$this->assertEquals(2, $this->auswahlLoeschen([$ids[2], $ids[3]]), 'Zwei Buchungen gelöscht');
$nachher = Depot::bestand($this->depot, '2026-12-31');
$this->assertEquals(20.0, $nachher['positionen'][0]['stueck'], 'Die verkauften Stücke sind zurück');
$this->assertEquals(2220.0, $nachher['positionen'][0]['einstand'], 'Und der volle Einstand beider Käufe');
$this->assertEquals(2600.0, $nachher['wert'], 'Zwanzig Stück zu 130 €');
$this->assertEquals(0.0, $nachher['realisiert'], 'Ohne Verkauf ist nichts realisiert');
$this->assertEquals(0.0, $nachher['ertraege'], 'Ohne Dividende gibt es keinen Ertrag');
$veraeusserungen = Depot::veraeusserungen('2026-01-01', '2026-12-31', $this->depot);
$this->assertEquals(0, count($veraeusserungen['geschaefte']), 'Kein Veräußerungsgeschäft mehr');
$this->assertEquals(0.0, $veraeusserungen['gewinn'], 'Und kein Gewinn in der Auswertung');
$this->auswahlLoeschen([$ids[0], $ids[1]]);
}
private function unbekannteNummernBleibenFolgenlos(): void
{
$id = $this->vierBuchungen()[0];
// Eine Nummer, die es nicht (mehr) gibt, wird übergangen – der Rest der
// Auswahl verschwindet trotzdem.
$this->assertEquals(1, $this->auswahlLoeschen([999999, $id]), 'Nur die vorhandene Buchung zählt');
$this->assertNull(Depotbuchung::find($id), 'Und sie ist gelöscht');
}
}

View file

@ -170,6 +170,87 @@ test.describe('Module der Phasen 4 bis 7', () => {
await expect(page.locator('.meldung')).toContainText('gelöscht')
})
test('Depotbuchungen lassen sich über die Auswahl sammelweise löschen', async ({ page }) => {
const marke = Date.now().toString().slice(-6)
const depot = `E2E Depot ${marke}`
const papier = `E2E Papier ${marke}`
await page.goto('/konten/neu')
await page.fill('#name', depot)
await page.selectOption('#kontoart', 'depot')
await page.click('.formular button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
// Zwei Käufe desselben Papiers – der zweite über die Auswahlliste, damit
// kein zweites Wertpapier entsteht.
for (const [nr, stueck] of [[1, '10'], [2, '5']]) {
await page.goto('/wertpapiere/buchungen/neu')
await page.selectOption('#konto_id', { label: depot })
await page.fill('#datum', '2026-05-0' + nr)
if (nr === 1) {
await page.fill('#wertpapier_name', papier)
} else {
await page.selectOption('#wertpapier_id', { label: papier })
}
await page.fill('#stueck', stueck)
await page.fill('#kurs', '100')
await page.click('.formular button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
}
// Der Filter bleibt über das Löschen hinweg stehen – das Formularziel
// trägt ihn mit.
await page.goto('/wertpapiere/buchungen')
await page.selectOption('select[name="depot"]', { label: depot })
await page.click('button:has-text("Filtern")')
await expect(page.locator('tbody tr')).toHaveCount(2)
// Der Bestand kennt beide Käufe.
await page.goto('/wertpapiere')
await page.selectOption('select[name="depot"]', { label: depot })
await page.click('button:has-text("Anzeigen")')
await expect(page.locator('tbody tr', { hasText: papier })).toContainText('15')
await page.goBack()
await page.goBack()
// Ohne Auswahl bleibt der Knopf gesperrt.
const knopf = page.locator('#depotbuchungsauswahl button')
await expect(knopf).toBeDisabled()
await page.check('.alle-waehlen')
await expect(knopf).toContainText('(2)')
const gefragt = []
page.on('dialog', async (dialog) => {
gefragt.push(dialog.message())
await dialog.accept()
})
await knopf.click()
expect(gefragt.join(' ')).toContain('löschen')
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
await expect(page).toHaveURL(/depot=/)
// Der Name steht weiter im Filterfeld – gemeint ist die Liste.
await expect(page.locator('table.liste')).toHaveCount(0)
await expect(page.locator('.platzhalter')).toContainText('Keine Depotbuchungen')
// Und der Bestand ist die Buchungen los – er wird gerechnet.
await page.goto('/wertpapiere')
await page.selectOption('select[name="depot"]', { label: depot })
await page.click('button:has-text("Anzeigen")')
await expect(page.locator('.platzhalter')).toContainText('Keine Bestände')
// Aufräumen: Das Testdepot wieder entfernen. Die Rückfrage übernimmt der
// Dialoghandler von oben.
await page.goto('/konten')
const pfad = await page.locator('a', { hasText: depot }).first().getAttribute('href')
await page.goto(pfad + '/bearbeiten')
await page.locator('button:has-text("Konto löschen")').click()
await expect(page.locator('.meldung')).toContainText('gelöscht')
})
test('Der Finanzkalender ist erreichbar und lässt sich einstellen', async ({ page }) => {
await page.goto('/termine/kalender')
await expect(page.locator('h1')).toContainText('Finanzkalender')