diff --git a/Changes.md b/Changes.md index 21f56e5..ad6f6e4 100644 --- a/Changes.md +++ b/Changes.md @@ -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 diff --git a/docs/wertpapiere.md b/docs/wertpapiere.md index 15d1a41..1abb791 100644 --- a/docs/wertpapiere.md +++ b/docs/wertpapiere.md @@ -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 diff --git a/public/index.php b/public/index.php index 5c8916b..9f20bf2 100644 --- a/public/index.php +++ b/public/index.php @@ -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)); diff --git a/src/Controllers/WertpapiereController.php b/src/Controllers/WertpapiereController.php index dba3940..cc8db9f 100644 --- a/src/Controllers/WertpapiereController.php +++ b/src/Controllers/WertpapiereController.php @@ -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 ─────────────────────────────────── diff --git a/src/Views/wertpapiere/buchungen.php b/src/Views/wertpapiere/buchungen.php index f2aa4d0..5ca0a3c 100644 --- a/src/Views/wertpapiere/buchungen.php +++ b/src/Views/wertpapiere/buchungen.php @@ -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'; + + +
+ + +
+ Depotbuchung erfassen @@ -62,6 +88,10 @@ include __DIR__ . '/../layout/kopf.php'; + @@ -71,6 +101,11 @@ include __DIR__ . '/../layout/kopf.php'; +
+ + DatumVorgangWertpapierDepot StückKurs BetragKosten
+ + @@ -108,7 +143,8 @@ include __DIR__ . '/../layout/kopf.php'; -
diff --git a/tests/22_DepotbuchungenLoeschenTest.php b/tests/22_DepotbuchungenLoeschenTest.php new file mode 100644 index 0000000..0fe5590 --- /dev/null +++ b/tests/22_DepotbuchungenLoeschenTest.php @@ -0,0 +1,177 @@ +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 + */ + 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'); + } +} diff --git a/tests/e2e/specs/module.spec.js b/tests/e2e/specs/module.spec.js index bd722da..397e475 100644 --- a/tests/e2e/specs/module.spec.js +++ b/tests/e2e/specs/module.spec.js @@ -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')