From be3ee6400eda83b0f2dadc1456b65099b8810d06 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Thu, 13 Aug 2026 15:08:01 +0200 Subject: [PATCH] Detailseite eines Wertpapiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Name in Bestandsübersicht, Depotbuchungen und Stammdaten führt jetzt auf /wertpapiere/papier/{id}: Kennzahlen über die ganze Historie, Stammdaten und Kurs, Bestand je Depot, Wertentwicklung und die Buchungen eines Zeitraums. Depot::bestand() und Depot::entwicklung() nehmen dafür eine Wertpapiernummer entgegen – die Detailseite rechnet nicht selbst, sondern bekommt dieselben Zahlen wie die Übersicht. Neu sind Wertpapier::kurseckwerte() und Depotbuchung::datumsgrenzen(). Co-Authored-By: Claude Opus 5 --- Changes.md | 30 ++ README.md | 4 +- docs/wertpapiere.md | 31 ++ public/index.php | 1 + public/static/app.css | 18 ++ src/Controllers/WertpapiereController.php | 48 ++++ src/Models/Depotbuchung.php | 28 ++ src/Models/Wertpapier.php | 26 ++ src/Services/Depot.php | 30 +- src/View.php | 10 + src/Views/wertpapiere/auswertung.php | 4 +- src/Views/wertpapiere/buchungen.php | 7 +- src/Views/wertpapiere/index.php | 6 +- src/Views/wertpapiere/papier.php | 334 ++++++++++++++++++++++ src/Views/wertpapiere/stammdaten.php | 3 +- tests/26_WertpapierDetailTest.php | 270 +++++++++++++++++ tests/e2e/specs/wertpapierdetail.spec.js | 146 ++++++++++ 17 files changed, 979 insertions(+), 17 deletions(-) create mode 100644 src/Views/wertpapiere/papier.php create mode 100644 tests/26_WertpapierDetailTest.php create mode 100644 tests/e2e/specs/wertpapierdetail.spec.js diff --git a/Changes.md b/Changes.md index 2923071..74aa6cf 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,35 @@ # Änderungen +## Detailseite eines Wertpapiers (13.08.2026) + +Bisher endete der Weg zu einem Wertpapier in einer Tabellenzeile: Stückzahl, Kurs, Wert – +und wer mehr wissen wollte, musste sich die Angaben aus Stammdaten, Depotbuchungen und +Auswertungen zusammensuchen. Der Name in der Übersicht war kein Link. + +**Die neue Seite** (`/wertpapiere/papier/{id}`, `Views/wertpapiere/papier.php`, +`WertpapiereController::papier()`) +- Erreichbar über den Wertpapiernamen in der Bestandsübersicht und in der Liste der + Depotbuchungen sowie über *Details* in den Stammdaten. +- Oben die Kennzahlen über die ganze Historie: Bestand, Wert, Einstand, nicht realisierter + und realisierter Gewinn, Erträge. +- Darunter zwei Kacheln: **Stammdaten** (ISIN, WKN, Art, Währung, Kürzel, Status, erste + und letzte Buchung) und **Kurs** (letzter Kurs mit Datum und Quelle, Hoch und Tief der + letzten zwölf Monate, Einstand je Stück). +- **Bestand je Depot** – ein Papier kann in mehreren Depots liegen – und die + **Wertentwicklung** als dasselbe Diagramm wie in der Depotübersicht, auf dieses Papier + gerechnet. +- Unten die **Buchungen** eines Zeitraums, voreingestellt das laufende Jahr, mit denselben + Vorgaben wie die Auswertungen. Dazu eine Zeile mit Zu- und Abgängen und den Erträgen des + Zeitraums. Die Kennzahlen oben bleiben davon unberührt. + +**Unter der Haube** +- `Depot::bestand()` und `Depot::entwicklung()` nehmen eine Wertpapiernummer entgegen. Die + Detailseite rechnet damit nicht selbst, sondern bekommt dieselben Zahlen wie die + Übersicht – beide können nicht auseinanderlaufen. +- Neu: `Wertpapier::kurseckwerte()` (Hoch, Tief, Zahl der Kurse eines Zeitraums) und + `Depotbuchung::datumsgrenzen()` (erste und letzte Buchung, wahlweise je Papier). +- `View::stueck()` formatiert Stückzahlen an einer Stelle statt in vier Ansichten. + ## Wertpapiergeschäft aus der Kontobuchung (13.08.2026) Bisher konnte eine Depotbuchung ihre Geldbuchung nur selbst anlegen. Der Weg, den der diff --git a/README.md b/README.md index e0453e8..537ec84 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,9 @@ Haushalt wird beides parallel genutzt. übernehmen. Eine abgerufene Wertpapierabrechnung wird im Kontoblatt zum Geschäft: Das Depot in der Zuordnungsliste öffnet die Maske für Stück, Kurs und Nebenkosten, und daraus entstehen die Depotbuchung mit dem Einstandspreis und die Splitbuchung, die die - Gebühren und Steuern auf ihre Kategorien verteilt. + Gebühren und Steuern auf ihre Kategorien verteilt. Jeder Wertpapiername führt auf eine + Detailseite mit Stammdaten, Kurs, Bestand je Depot, Wertentwicklung und den Buchungen + eines wählbaren Zeitraums. * **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und Finanzkalender, Erinnerungen, Adressen, Verträge, Sparziele, Kredite mit Tilgungsplan. * **Zahlungsverkehr** – Überweisungen und Terminüberweisungen über FinTS. diff --git a/docs/wertpapiere.md b/docs/wertpapiere.md index cc4d121..0cfaf2a 100644 --- a/docs/wertpapiere.md +++ b/docs/wertpapiere.md @@ -116,6 +116,30 @@ Einstand. Vollständig verkaufte Positionen verschwinden aus der Bestandsliste; ihr realisierter Gewinn zählt weiter mit. +## Detailseite eines Wertpapiers + +Der Name in der Bestandsübersicht, in der Liste der Depotbuchungen und in den Stammdaten +führt auf `/wertpapiere/papier/{id}` – alles zu einem Papier auf einer Seite: + +* **Kennzahlen** über die ganze Historie: Bestand, Wert, Einstand, nicht realisierter und + realisierter Gewinn, Erträge. Wer wissen will, was ein Papier gebracht hat, meint alles + seit dem ersten Kauf – nicht den gewählten Berichtszeitraum. +* **Stammdaten und Kurs** in je einer Kachel: ISIN, WKN, Art, Währung, Kürzel, Status, + erste und letzte Buchung; daneben der letzte Kurs mit Datum und Quelle, Hoch und Tief + der letzten zwölf Monate (`Wertpapier::kurseckwerte()`) und der Einstand je Stück. +* **Bestand je Depot**: Ein Papier kann in mehreren Depots liegen; gerechnet wird je Depot, + weil auch der Einstand je Depot geführt wird. Ist alles verkauft, bleibt die Seite + bestehen – realisierte Gewinne und Erträge zählen weiter. +* **Wertentwicklung** – dasselbe Diagramm wie in der Depotübersicht, nur auf dieses Papier + gerechnet, und nur, wenn es Kurshistorie gibt. +* **Buchungen** eines Zeitraums, voreingestellt das laufende Jahr, mit denselben Vorgaben + wie die Auswertungen (`Services\Zeitraum`). Darunter eine Zeile mit Zu- und Abgängen und + den Erträgen des Zeitraums. + +Gerechnet wird nicht neu: `Depot::bestand()` nimmt eine Wertpapiernummer entgegen und +liefert dieselben Zahlen wie die Übersicht, nur auf ein Papier eingegrenzt. Damit können +Detailseite und Übersicht nicht auseinanderlaufen. + ## Kurse Ein Kurs je Wertpapier und Tag. Mehr Auflösung braucht eine Vermögensübersicht nicht, und @@ -265,6 +289,13 @@ Steuern, das Löschen, das die Bankbuchung stehen lässt, sowie die Abweisungen: Geschäft, Kosten in Höhe der Buchung, Konto statt Depot, Kauf ohne Stückzahl. Die beiden Dialoge prüft `tests/e2e/specs/wertpapiergeschaeft.spec.js`. +`tests/26_WertpapierDetailTest.php` – die Detailseite: der auf ein Papier eingegrenzte +Bestand (fremde Papiere bleiben draußen, mehrere Depots werden je Position geführt, Depot +und Papier lassen sich zugleich eingrenzen), Hoch und Tief der Kurse, die Datumsgrenzen +der Depotbuchungen, das Format der Stückzahlen sowie die gerenderte Seite: Stammdaten, +Kennzahlen und eine Buchungsliste, die dem Zeitraum folgt, während die Kennzahlen es nicht +tun. Den Klick vom Namen in der Übersicht prüft `tests/e2e/specs/wertpapierdetail.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 6342804..078ac66 100644 --- a/public/index.php +++ b/public/index.php @@ -247,6 +247,7 @@ $router->post('/zahlungsverkehr/{id}/loeschen', fn(array $p) => (new Zahlungsver // ── Wertpapiere: Bestände, Depotbuchungen, Kurse, Auswertungen ────────────── $router->get('/wertpapiere', fn() => (new WertpapiereController())->index()); $router->get('/wertpapiere/auswertung', fn() => (new WertpapiereController())->auswertung()); +$router->get('/wertpapiere/papier/{id}', fn(array $p) => (new WertpapiereController())->papier($p)); $router->get('/wertpapiere/buchungen', fn() => (new WertpapiereController())->buchungen()); $router->get('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->neu()); diff --git a/public/static/app.css b/public/static/app.css index f55a3bc..0acf57d 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -1721,6 +1721,24 @@ table.bericht .wertlink:hover { margin: 0; } +/* Stammdaten einer Detailseite: Merkmal links, Wert rechts. Kein Formular, + also auch keine Feldpaare – nur zwei Spalten, die untereinander bündig + bleiben, und ein Umbruch auf eine Spalte, wenn es eng wird. */ +.merkmale { + display: grid; + grid-template-columns: minmax(6rem, auto) 1fr; + gap: .3rem .9rem; + margin: 0; +} + +.merkmale dt { + color: var(--text-gedaempft); + font-size: .85rem; + align-self: center; +} + +.merkmale dd { margin: 0; } + /* Zahlenlastige Tabellen bekommen Ziffernschrift in den rechtsbündigen Spalten – so stehen Kommastellen untereinander. */ table.liste.zahlen td.rechts { font-family: var(--schrift-zahl); } diff --git a/src/Controllers/WertpapiereController.php b/src/Controllers/WertpapiereController.php index cc8db9f..d622603 100644 --- a/src/Controllers/WertpapiereController.php +++ b/src/Controllers/WertpapiereController.php @@ -38,6 +38,54 @@ class WertpapiereController extends BaseController ]); } + /** + * Die Detailseite eines Wertpapiers: Stammdaten, Kurs, Bestand je Depot, + * die Wertentwicklung – und unten die Buchungen eines Zeitraums. + * + * Die Kennzahlen oben gelten für die ganze Historie: Wer wissen will, was + * ein Papier gebracht hat, meint alles seit dem ersten Kauf. Nur die Liste + * unten folgt dem gewählten Zeitraum, voreingestellt das laufende Jahr. + * + * @param array $params + */ + public function papier(array $params): void + { + $this->verlangeAnmeldung(); + + $id = (int)$params['id']; + $wertpapier = Wertpapier::find($id); + + if ($wertpapier === null) { + View::redirect('/wertpapiere/stammdaten', 'Dieses Wertpapier gibt es nicht.', 'fehler'); + return; + } + + $grenzen = Depotbuchung::datumsgrenzen($id); + $zeitraum = Zeitraum::berechne( + $this->query('zeitraum', 'aktuelles_jahr'), + $this->query('von'), + $this->query('bis'), + null, + $grenzen + ); + + $this->render('wertpapiere/papier.php', [ + 'titel' => (string)$wertpapier['name'], + 'wertpapier' => $wertpapier, + 'bestand' => Depot::bestand(null, null, $id), + 'kurs' => Wertpapier::letzterKurs($id), + 'eckwerte' => Wertpapier::kurseckwerte($id, date('Y-m-d', strtotime('-1 year'))), + 'entwicklung' => Depot::entwicklung(null, 12, null, $id), + 'grenzen' => $grenzen, + 'zeitraum' => $zeitraum, + 'buchungen' => Depotbuchung::liste([ + 'wertpapier_id' => $id, + 'von' => $zeitraum['von'], + 'bis' => $zeitraum['bis'], + ]), + ]); + } + public function buchungen(): void { $this->verlangeAnmeldung(); diff --git a/src/Models/Depotbuchung.php b/src/Models/Depotbuchung.php index 9dc578b..e78fe99 100644 --- a/src/Models/Depotbuchung.php +++ b/src/Models/Depotbuchung.php @@ -116,6 +116,34 @@ class Depotbuchung return Database::fetchAll($sql . ' ORDER BY d.datum DESC, d.id DESC', $params); } + /** + * Erste und letzte Depotbuchung – für ein Wertpapier oder über alle. + * + * Die Detailseite zeigt daraus, seit wann das Papier geführt wird, und + * begrenzt damit den Zeitraum „Gesamter Zeitraum“ auf das, was es + * tatsächlich gibt. + * + * @return array{von: ?string, bis: ?string} + */ + public static function datumsgrenzen(?int $wertpapierId = null): array + { + $params = [Mandant::id()]; + $sql = 'SELECT MIN(datum) AS von, MAX(datum) AS bis + FROM depot_buchungen WHERE benutzer_id = ?'; + + if ($wertpapierId !== null) { + $sql .= ' AND wertpapier_id = ?'; + $params[] = $wertpapierId; + } + + $zeile = Database::fetchOne($sql, $params); + + return [ + 'von' => ($zeile['von'] ?? null) === null ? null : (string)$zeile['von'], + 'bis' => ($zeile['bis'] ?? null) === null ? null : (string)$zeile['bis'], + ]; + } + /** * Die Depotbuchung zu einer Geldbuchung – oder null, wenn die Buchung kein * Wertpapiergeschäft ist. diff --git a/src/Models/Wertpapier.php b/src/Models/Wertpapier.php index 1511001..755b83d 100644 --- a/src/Models/Wertpapier.php +++ b/src/Models/Wertpapier.php @@ -262,6 +262,32 @@ class Wertpapier return $kurse; } + /** + * Hoch und Tief eines Zeitraums, dazu die Zahl der bekannten Kurse – die + * Detailseite stellt sie neben den letzten Kurs. Ohne einen einzigen Kurs + * im Zeitraum gibt es nichts zu zeigen: null. + * + * @return array{tief: float, hoch: float, anzahl: int}|null + */ + public static function kurseckwerte(int $wertpapierId, string $von): ?array + { + $zeile = Database::fetchOne( + 'SELECT MIN(kurs) AS tief, MAX(kurs) AS hoch, COUNT(*) AS anzahl + FROM kurse WHERE wertpapier_id = ? AND datum >= ?', + [$wertpapierId, $von] + ); + + if ($zeile === false || (int)$zeile['anzahl'] === 0) { + return null; + } + + return [ + 'tief' => (float)$zeile['tief'], + 'hoch' => (float)$zeile['hoch'], + 'anzahl' => (int)$zeile['anzahl'], + ]; + } + /** @return list> */ public static function kursverlauf(int $wertpapierId, int $tage = 365): array { diff --git a/src/Services/Depot.php b/src/Services/Depot.php index 1e50ae1..e846f53 100644 --- a/src/Services/Depot.php +++ b/src/Services/Depot.php @@ -29,6 +29,10 @@ class Depot /** * Die Bestände eines Depots (oder aller Depots) zum Stichtag. * + * Mit `$wertpapierId` bleibt ein einziges Papier übrig – so rechnet die + * Detailseite dieselben Zahlen wie die Übersicht, nur je Depot statt für + * das ganze Depot. + * * @return array{ * positionen: list>, * einstand: float, @@ -39,8 +43,11 @@ class Depot * bewertbar: bool * } */ - public static function bestand(?int $kontoId = null, ?string $stichtag = null): array - { + public static function bestand( + ?int $kontoId = null, + ?string $stichtag = null, + ?int $wertpapierId = null + ): array { $stichtag ??= date('Y-m-d'); $kurse = Wertpapier::letzteKurse($stichtag); @@ -48,7 +55,7 @@ class Depot $realisiert = 0.0; $ertraege = 0.0; - foreach (self::buchungen($kontoId, $stichtag) as $buchung) { + foreach (self::buchungen($kontoId, $stichtag, $wertpapierId) as $buchung) { $schluessel = (int)$buchung['wertpapier_id'] . ':' . (int)$buchung['konto_id']; $position = $positionen[$schluessel] ?? [ @@ -283,12 +290,16 @@ class Depot * * @return list */ - public static function entwicklung(?int $kontoId = null, int $monate = 12, ?string $heute = null): array - { + public static function entwicklung( + ?int $kontoId = null, + int $monate = 12, + ?string $heute = null, + ?int $wertpapierId = null + ): array { $reihe = []; foreach (Vermoegensentwicklung::monate($monate, $heute) as $monat) { - $bestand = self::bestand($kontoId, $monat['bis']); + $bestand = self::bestand($kontoId, $monat['bis'], $wertpapierId); $reihe[] = [ 'monat' => sprintf('%04d-%02d', $monat['jahr'], $monat['monat']), @@ -307,7 +318,7 @@ class Depot * * @return list> */ - private static function buchungen(?int $kontoId, string $bis): array + private static function buchungen(?int $kontoId, string $bis, ?int $wertpapierId = null): array { $params = [Mandant::id(), $bis]; $sql = 'SELECT d.*, w.name AS wertpapier, w.isin, w.wkn, k.name AS depot @@ -321,6 +332,11 @@ class Depot $params[] = $kontoId; } + if ($wertpapierId !== null) { + $sql .= ' AND d.wertpapier_id = ?'; + $params[] = $wertpapierId; + } + return Database::fetchAll($sql . ' ORDER BY d.datum, d.id', $params); } } diff --git a/src/View.php b/src/View.php index fb70cb8..0fbd80d 100644 --- a/src/View.php +++ b/src/View.php @@ -55,6 +55,16 @@ class View return $mitWaehrung ? $text . "\u{00A0}€" : $text; } + /** + * Stückzahlen. Fonds werden in Bruchteilen geführt, Aktien in ganzen + * Stücken – dieselbe Spalte trägt beides, deshalb sechs Nachkommastellen + * ohne die Nullen, die niemand liest: „12“ statt „12,000000“. + */ + public static function stueck(mixed $wert): string + { + return rtrim(rtrim(number_format((float)$wert, 6, ',', '.'), '0'), ','); + } + /** * Monatsnamen auf Deutsch. Nicht über date('F') oder IntlDateFormatter – * date() ist immer englisch und intl ist keine Standarderweiterung. Der diff --git a/src/Views/wertpapiere/auswertung.php b/src/Views/wertpapiere/auswertung.php index 5ec35a8..f51f02f 100644 --- a/src/Views/wertpapiere/auswertung.php +++ b/src/Views/wertpapiere/auswertung.php @@ -112,9 +112,7 @@ include __DIR__ . '/../layout/kopf.php'; - - - + diff --git a/src/Views/wertpapiere/buchungen.php b/src/Views/wertpapiere/buchungen.php index 5ca0a3c..3a0edc2 100644 --- a/src/Views/wertpapiere/buchungen.php +++ b/src/Views/wertpapiere/buchungen.php @@ -114,7 +114,9 @@ include __DIR__ . '/../layout/kopf.php'; - + + + @@ -122,8 +124,7 @@ include __DIR__ . '/../layout/kopf.php'; + ? '–' : View::h(View::stueck($buchung['stueck'])) ?> diff --git a/src/Views/wertpapiere/index.php b/src/Views/wertpapiere/index.php index b2bd14b..512b8dc 100644 --- a/src/Views/wertpapiere/index.php +++ b/src/Views/wertpapiere/index.php @@ -115,13 +115,15 @@ include __DIR__ . '/../layout/kopf.php'; - + + + - + diff --git a/src/Views/wertpapiere/papier.php b/src/Views/wertpapiere/papier.php new file mode 100644 index 0000000..ea9b7b8 --- /dev/null +++ b/src/Views/wertpapiere/papier.php @@ -0,0 +1,334 @@ + $wertpapier + * @var array $bestand + * @var array|null $kurs + * @var array{tief:float, hoch:float, anzahl:int}|null $eckwerte + * @var list $entwicklung + * @var array{von: ?string, bis: ?string} $grenzen + * @var array{vorgabe:string, von:string, bis:string} $zeitraum + * @var list> $buchungen + * @var string $csrfToken + */ + +$id = (int)$wertpapier['id']; + +// Ein Papier kann in mehreren Depots liegen; die Zahlen der Seite sind ihre +// Summe. Der Einstand je Stück wird deshalb neu gemittelt und nicht aus einer +// einzelnen Position übernommen. +$stueck = 0.0; +foreach ($bestand['positionen'] as $position) { + $stueck += (float)$position['stueck']; +} +$einstandJeStueck = $stueck > 0.0 ? round($bestand['einstand'] / $stueck, 4) : null; + +// Was im gewählten Zeitraum geschehen ist – die Liste darunter zeigt es Zeile +// für Zeile, hier steht es in drei Zahlen. +$zugang = ['stueck' => 0.0, 'betrag' => 0.0]; +$abgang = ['stueck' => 0.0, 'betrag' => 0.0]; +$ertrag = 0.0; +foreach ($buchungen as $buchung) { + $betrag = (float)$buchung['betrag']; + $kosten = (float)$buchung['gebuehren'] + (float)$buchung['steuern']; + + if (in_array((string)$buchung['vorgang'], Depotbuchung::ZUGAENGE, true)) { + $zugang['stueck'] += abs((float)$buchung['stueck']); + $zugang['betrag'] += $betrag + $kosten; + } elseif (in_array((string)$buchung['vorgang'], Depotbuchung::ABGAENGE, true)) { + $abgang['stueck'] += abs((float)$buchung['stueck']); + $abgang['betrag'] += $betrag - $kosten; + } elseif ((string)$buchung['vorgang'] === 'dividende') { + $ertrag += $betrag - $kosten; + } +} + +include __DIR__ . '/../layout/kopf.php'; +?> + + + +
+ + + + · + + + · inaktiv + + + Alle Depotbuchungen + Stammdaten bearbeiten + Depotbuchung erfassen +
+ +
+
+ Bestand + +
+
+ Aktueller Wert + +
+
+ Einstandswert + +
+
+ Nicht realisiert + + + +
+
+ Realisiert + + + +
+
+ Erträge + +
+
+ +
+
+

Stammdaten

+
+
ISIN
+
+
WKN
+
+
Art
+
+
Währung
+
+
Kürzel
+
+
Status
+
+ +
Notiz
+
+ +
Erste Buchung
+
+
Letzte Buchung
+
+
+
+ +
+
+

Kurs

+

Hoch und Tief der letzten zwölf Monate, soweit Kurse vorliegen.

+
+
+
Letzter Kurs
+
+ + kein Kurs + + + + · + + +
+
Höchstkurs
+
+
Tiefstkurs
+
+
Bekannte Kurse
+
+
Einstand je Stück
+
+ +
+
+
+
+ +
+
+

Bestand je Depot

+
+ +

+ Kein Bestand – das Papier ist verkauft oder war nie im Depot. Realisierte + Gewinne und Erträge zählen oben weiter mit. +

+ + + + + + + + + + + + + + + + + + + + + + + + + 1): ?> + + + + + + + + + + + +
DepotStückEinstand je StückEinstandWertGewinn
+ + + + + +
Summe + +
+ +

+ Für dieses Papier fehlt ein Kurs – Wert und nicht realisierter Gewinn bleiben + offen. Einzutragen unter Wertpapiere. +

+ + +
+ + 0.0; +} +?> + +
+
+

Wertentwicklung

+

Bewerteter Bestand je Monatsende, dazu der Einstand.

+
+ +
+ + +

Buchungen

+ +
+ + + + + + + + + +
+ +

+ + +

Keine Buchungen in diesem Zeitraum.

+ + + + + + + + + + + + + + + + + + + + + + + + +
DatumVorgangDepotStückKursBetragKostenVerrechnung
+ + + + + + + + + + + 0.0 ? View::betrag($kosten) : '–' ?> + + + + + + + – + +
+ +

+ Im Zeitraum: + Zugänge Stück + für · + Abgänge Stück + für · + Erträge . + Zugänge samt Nebenkosten, Abgänge und Erträge abzüglich – so, wie das Geld + geflossen ist. +

+ + + diff --git a/src/Views/wertpapiere/stammdaten.php b/src/Views/wertpapiere/stammdaten.php index 8327be3..51d56af 100644 --- a/src/Views/wertpapiere/stammdaten.php +++ b/src/Views/wertpapiere/stammdaten.php @@ -105,7 +105,8 @@ include __DIR__ . '/../layout/kopf.php'; > - + + Details diff --git a/tests/26_WertpapierDetailTest.php b/tests/26_WertpapierDetailTest.php new file mode 100644 index 0000000..032f366 --- /dev/null +++ b/tests/26_WertpapierDetailTest.php @@ -0,0 +1,270 @@ +mandantAnlegen(); + $this->stammdaten(); + + $this->bestandFiltertAufEinPapier(); + $this->bestandLiegtInBeidenDepots(); + $this->kurseckwerteZeigenHochUndTief(); + $this->datumsgrenzenUmfassenNurDiesesPapier(); + $this->stueckOhneUeberfluessigeNullen(); + $this->seiteZeigtStammdatenUndBuchungen(); + $this->seiteFolgtDemZeitraum(); + $this->uebersichtVerlinktDieDetailseite(); + } + + private function stammdaten(): void + { + $this->depot = Konto::anlegen([ + 'name' => 'Detaildepot', 'kontoart' => 'depot', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->zweitdepot = Konto::anlegen([ + 'name' => 'Zweitdepot', 'kontoart' => 'depot', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->verrechnung = Konto::anlegen([ + 'name' => 'Detailverrechnung', 'kontoart' => 'bank', 'anfangssaldo' => 50000, 'aktiv' => 1, + ]); + + $this->papier = Wertpapier::findenOderAnlegen([ + 'name' => 'Detailaktie AG', 'isin' => 'DE0007654321', 'wkn' => '765432', 'art' => 'aktie', + ]); + $this->anderes = Wertpapier::findenOderAnlegen([ + 'name' => 'Nebenaktie SE', 'isin' => 'FR0001234567', 'art' => 'aktie', + ]); + + // Zehn Stück zu 100 € plus 10 € Gebühren, dann fünf davon zu 130 € + // verkauft, dazu eine Dividende – die Zahlen der Kennzahlenleiste. + Depotbuchung::anlegen([ + 'konto_id' => $this->depot, 'wertpapier_id' => $this->papier, + 'datum' => '2026-01-10', '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-03-20', 'vorgang' => 'verkauf', + 'stueck' => 5, 'kurs' => 130, 'betrag' => 650, 'gebuehren' => 5, + 'verrechnungskonto_id' => $this->verrechnung, + ]); + Depotbuchung::anlegen([ + 'konto_id' => $this->depot, 'wertpapier_id' => $this->papier, + 'datum' => '2026-05-02', 'vorgang' => 'dividende', + 'betrag' => 40, 'steuern' => 10, + 'verrechnungskonto_id' => $this->verrechnung, + ]); + + // Dasselbe Papier ein zweites Mal, in einem anderen Depot. + Depotbuchung::anlegen([ + 'konto_id' => $this->zweitdepot, 'wertpapier_id' => $this->papier, + 'datum' => '2026-06-15', 'vorgang' => 'kauf', + 'stueck' => 4, 'kurs' => 125, 'betrag' => 500, 'gebuehren' => 0, + 'verrechnungskonto_id' => $this->verrechnung, + ]); + + // Ein fremdes Papier, das auf der Detailseite nichts zu suchen hat. + Depotbuchung::anlegen([ + 'konto_id' => $this->depot, 'wertpapier_id' => $this->anderes, + 'datum' => '2025-11-11', 'vorgang' => 'kauf', + 'stueck' => 3, 'kurs' => 50, 'betrag' => 150, + 'verrechnungskonto_id' => $this->verrechnung, + ]); + + Wertpapier::kursSetzen($this->papier, '2026-07-01', 120.0, 'manuell'); + Wertpapier::kursSetzen($this->papier, '2026-08-01', 140.0, 'leeway'); + Wertpapier::kursSetzen($this->anderes, '2026-08-01', 60.0, 'manuell'); + } + + private function bestandFiltertAufEinPapier(): void + { + $bestand = Depot::bestand(null, '2026-12-31', $this->papier); + + foreach ($bestand['positionen'] as $position) { + $this->assertEquals( + $this->papier, + $position['wertpapier_id'], + 'Nur das gewählte Papier steht in den Positionen' + ); + } + + // 10 Stück für 1010 €, davon 5 verkauft: die Hälfte des Einstands geht + // ab (505 €), der Erlös von 645 € ergibt 140 € realisiert. + $this->assertEquals(140.0, $bestand['realisiert'], 'Der realisierte Gewinn gehört zu diesem Papier'); + $this->assertEquals(30.0, $bestand['ertraege'], 'Die Dividende zählt netto'); + $this->assertEquals(1005.0, $bestand['einstand'], '505 € aus dem ersten Depot, 500 € aus dem zweiten'); + $this->assertEquals(1260.0, $bestand['wert'], 'Neun Stück zum letzten Kurs von 140 €'); + } + + private function bestandLiegtInBeidenDepots(): void + { + $bestand = Depot::bestand(null, '2026-12-31', $this->papier); + $this->assertEquals(2, count($bestand['positionen']), 'Je Depot eine Position'); + + $stueck = 0.0; + foreach ($bestand['positionen'] as $position) { + $stueck += (float)$position['stueck']; + } + $this->assertEquals(9.0, $stueck, 'Fünf im ersten, vier im zweiten Depot'); + + // Mit Depot eingegrenzt bleibt nur eine Position übrig. + $eines = Depot::bestand($this->zweitdepot, '2026-12-31', $this->papier); + $this->assertEquals(1, count($eines['positionen']), 'Depot und Papier lassen sich zugleich eingrenzen'); + $this->assertEquals(4.0, $eines['positionen'][0]['stueck'], 'Vier Stück im Zweitdepot'); + } + + private function kurseckwerteZeigenHochUndTief(): void + { + $eckwerte = Wertpapier::kurseckwerte($this->papier, '2026-01-01'); + + $this->assertNotEmpty($eckwerte, 'Es gibt Eckwerte'); + $this->assertEquals(140.0, $eckwerte['hoch'], 'Der höchste Kurs'); + $this->assertEquals(120.0, $eckwerte['tief'], 'Der niedrigste Kurs'); + $this->assertEquals(2, $eckwerte['anzahl'], 'Zwei bekannte Kurse'); + + // Kurse anderer Papiere bleiben außen vor, ein leerer Zeitraum ergibt null. + $this->assertEquals(60.0, Wertpapier::kurseckwerte($this->anderes, '2026-01-01')['hoch'], 'Je Papier gerechnet'); + $this->assertNull(Wertpapier::kurseckwerte($this->papier, '2030-01-01'), 'Ohne Kurse im Zeitraum: null'); + } + + private function datumsgrenzenUmfassenNurDiesesPapier(): void + { + $grenzen = Depotbuchung::datumsgrenzen($this->papier); + $this->assertEquals('2026-01-10', $grenzen['von'], 'Erste Buchung des Papiers'); + $this->assertEquals('2026-06-15', $grenzen['bis'], 'Letzte Buchung des Papiers'); + + // Ohne Papier zählen alle – das fremde von 2025 verschiebt den Anfang. + $alle = Depotbuchung::datumsgrenzen(); + $this->assertEquals('2025-11-11', $alle['von'], 'Über alle Papiere reicht es weiter zurück'); + } + + private function stueckOhneUeberfluessigeNullen(): void + { + $this->assertEquals('12', View::stueck(12.0), 'Ganze Stücke ohne Nachkommastellen'); + $this->assertEquals('0', View::stueck(0.0), 'Null bleibt eine Null'); + $this->assertEquals('1,5', View::stueck(1.5), 'Bruchteile bleiben erhalten'); + $this->assertEquals('1.234,25', View::stueck(1234.25), 'Tausenderpunkt wie bei Beträgen'); + } + + private function seiteZeigtStammdatenUndBuchungen(): void + { + $seite = $this->detailseite('aktuelles_jahr'); + + $this->assert(str_contains($seite, 'Detailaktie AG'), 'Der Name steht auf der Seite'); + $this->assert(str_contains($seite, 'DE0007654321'), 'Die ISIN steht auf der Seite'); + $this->assert(str_contains($seite, '765432'), 'Die WKN steht auf der Seite'); + $this->assert(str_contains($seite, 'Detaildepot'), 'Das Depot mit Bestand wird genannt'); + $this->assert(str_contains($seite, 'Zweitdepot'), 'Auch das zweite Depot'); + $this->assert(!str_contains($seite, 'Nebenaktie SE'), 'Fremde Papiere bleiben draußen'); + + // Kennzahlen und Kursangaben. + $this->assert(str_contains($seite, '1.260,00'), 'Der bewertete Bestand steht in den Kennzahlen'); + $this->assert(str_contains($seite, '140,00'), 'Der letzte Kurs steht in der Kurskachel'); + $this->assert(str_contains($seite, 'leeway'), 'Die Quelle des Kurses wird genannt'); + $this->assert(str_contains($seite, '10.01.2026'), 'Die erste Buchung wird genannt'); + + // Die Buchungen des laufenden Jahres, mit Vorgängen im Klartext. + $this->assert(str_contains($seite, '20.03.2026'), 'Der Verkauf steht in der Liste'); + $this->assert(str_contains($seite, 'Dividende'), 'Die Dividende steht in der Liste'); + $this->assert( + substr_count($seite, '/wertpapiere/papier/' . $this->papier) > 0, + 'Der Zeitraumfilter zeigt auf dieselbe Seite' + ); + } + + private function seiteFolgtDemZeitraum(): void + { + // Das laufende Jahr ist 2026 – die Testdaten liegen darin. Ein Zeitraum + // ohne Buchungen muss die Liste leeren, ohne die Kennzahlen anzutasten: + // Die gelten für die ganze Historie. + $leer = $this->detailseite('benutzerdefiniert', '2026-09-01', '2026-09-30'); + + $this->assert(str_contains($leer, 'Keine Buchungen in diesem Zeitraum'), 'Leerer Zeitraum, leere Liste'); + $this->assert(!str_contains($leer, '20.03.2026'), 'Der Verkauf liegt außerhalb'); + $this->assert(str_contains($leer, '1.260,00'), 'Die Kennzahlen bleiben die der ganzen Historie'); + + // Der Kauf vom Januar steht dann nur noch bei den Stammdaten als erste + // Buchung, nicht mehr in der Liste darunter. + $eng = $this->detailseite('benutzerdefiniert', '2026-03-01', '2026-03-31'); + $this->assert(str_contains($eng, '20.03.2026'), 'Der Verkauf liegt im engen Zeitraum'); + $this->assertEquals(1, substr_count($eng, '10.01.2026'), 'Der Kauf vom Januar nicht'); + } + + private function uebersichtVerlinktDieDetailseite(): void + { + $bestand = Depot::bestand($this->depot, '2026-12-31'); + + $seite = $this->rendere('wertpapiere/index.php', [ + 'titel' => 'Wertpapiere', + 'depots' => Depot::depots(), + 'depot' => $this->depot, + 'bestand' => $bestand, + 'entwicklung' => [], + 'kursabruf' => false, + ]); + + $this->assert( + str_contains($seite, 'href="/wertpapiere/papier/' . $this->papier . '"'), + 'Der Wertpapiername in der Übersicht führt zur Detailseite' + ); + } + + /** Rendert die Detailseite so, wie der Controller sie zusammenstellt. */ + private function detailseite(string $vorgabe, string $von = '', string $bis = ''): string + { + $grenzen = Depotbuchung::datumsgrenzen($this->papier); + $zeitraum = Zeitraum::berechne($vorgabe, $von, $bis, '2026-08-13', $grenzen); + + return $this->rendere('wertpapiere/papier.php', [ + 'titel' => 'Detailaktie AG', + 'wertpapier' => Wertpapier::find($this->papier), + 'bestand' => Depot::bestand(null, '2026-12-31', $this->papier), + 'kurs' => Wertpapier::letzterKurs($this->papier), + 'eckwerte' => Wertpapier::kurseckwerte($this->papier, '2025-08-13'), + 'entwicklung' => [], + 'grenzen' => $grenzen, + 'zeitraum' => $zeitraum, + 'buchungen' => Depotbuchung::liste([ + 'wertpapier_id' => $this->papier, + 'von' => $zeitraum['von'], + 'bis' => $zeitraum['bis'], + ]), + ]); + } + + /** @param array $daten */ + private function rendere(string $view, array $daten): string + { + $daten += ['aktuellerPfad' => '/wertpapiere', 'flash' => [], 'benutzer' => null, 'csrfToken' => 'x']; + + extract($daten, EXTR_SKIP); + + ob_start(); + include dirname(__DIR__) . '/src/Views/' . $view; + return (string)ob_get_clean(); + } +} diff --git a/tests/e2e/specs/wertpapierdetail.spec.js b/tests/e2e/specs/wertpapierdetail.spec.js new file mode 100644 index 0000000..6f671f5 --- /dev/null +++ b/tests/e2e/specs/wertpapierdetail.spec.js @@ -0,0 +1,146 @@ +// @ts-check +const { test, expect } = require('@playwright/test') + +/* + * Vom Namen in der Depotübersicht zur Detailseite des Wertpapiers: Stammdaten, + * Kennzahlen und darunter die Buchungen eines Zeitraums – voreingestellt das + * laufende Jahr. + */ + +const BENUTZER = process.env.E2E_USER || 'e2e' +const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' + +const MARKE = Date.now().toString().slice(-6) +const DEPOT = `E2E Detaildepot ${MARKE}` +const GIRO = `E2E Detailgiro ${MARKE}` +const PAPIER = `E2E Detailaktie ${MARKE}` + +// Wie im Test des Wertpapiergeschäfts: Ein Papier wird über seine Kennung +// wiedergefunden, feste Kennungen hingen den zweiten Lauf an den ersten. +const ISIN = `DE0007${MARKE}` + +// Die Zeiträume rechnen mit dem Tagesdatum, nicht mit festen Jahreszahlen – +// sonst liefe der Test am 1. Januar ins Leere. +const HEUTE = new Date() +const JAHR = HEUTE.getFullYear() +const DIESES_JAHR = `${JAHR}-03-04` +const VORJAHR = `${JAHR - 1}-03-04` + +async function anmelden(page) { + await page.goto('/login') + if (page.url().includes('/einrichtung')) { + throw new Error( + 'Instanz ist noch nicht eingerichtet: ' + + 'docker compose exec -u www-data php php scripts/e2e-benutzer.php' + ) + } + await page.fill('#benutzername', BENUTZER) + await page.fill('#passwort', PASSWORT) + await page.click('button[type="submit"]') + await expect(page).toHaveURL(/\/$/) +} + +async function kontoAnlegen(page, name, kontoart, anfangssaldo) { + await page.goto('/konten/neu') + await page.fill('#name', name) + await page.selectOption('#kontoart', kontoart) + await page.fill('#anfangssaldo', anfangssaldo) + await page.click('.formular button[type="submit"]') + await expect(page).toHaveURL(/\/konten\/\d+\/bearbeiten/) +} + +/** Ein Kauf über die Maske unter *Wertpapiere*. */ +async function kaufErfassen(page, datum, stueck, kurs, neuesPapier) { + await page.goto('/wertpapiere/buchungen/neu') + await page.selectOption('#konto_id', { label: DEPOT }) + await page.selectOption('#vorgang', 'kauf') + await page.fill('#datum', datum) + + if (neuesPapier) { + await page.fill('#wertpapier_name', PAPIER) + await page.fill('#isin', ISIN) + } else { + await page.selectOption('#wertpapier_id', { label: `${PAPIER} (${ISIN})` }) + } + + await page.fill('#stueck', stueck) + await page.fill('#kurs', kurs) + await page.selectOption('#verrechnungskonto_id', { label: GIRO }) + await page.click('.formular button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toBeVisible() +} + +test.describe('Detailseite eines Wertpapiers', () => { + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + await anmelden(page) + + await kontoAnlegen(page, GIRO, 'bank', '10.000,00') + await kontoAnlegen(page, DEPOT, 'depot', '0,00') + + // Zwei Käufe in zwei Jahren – der Zeitraumfilter muss sie trennen. + await kaufErfassen(page, VORJAHR, '4', '50', true) + await kaufErfassen(page, DIESES_JAHR, '6', '100', false) + + // Ohne Kurs bliebe die Bewertung offen; der Kurs kommt von Hand. Der + // Name steht in der Stammdatenliste in einem Eingabefeld und nicht als + // Text – die Zeile wird deshalb über den Feldwert gesucht. + await page.goto('/wertpapiere/stammdaten') + const zeile = page.locator('tr').filter({ has: page.locator(`input[value="${PAPIER}"]`) }) + await zeile.locator('input[name="kurs"]').fill('120') + await zeile.getByRole('button', { name: 'Setzen' }).click() + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + await page.close() + }) + + test.beforeEach(async ({ page }) => { + await anmelden(page) + }) + + test('Der Name in der Übersicht führt zu den Details', async ({ page }) => { + await page.goto('/wertpapiere') + await page.locator('tr', { hasText: PAPIER }).getByRole('link', { name: PAPIER }).click() + + await expect(page).toHaveURL(/\/wertpapiere\/papier\/\d+/) + await expect(page.locator('h1')).toHaveText(PAPIER) + + // Stammdaten und Kurs stehen in je einer Kachel. + await expect(page.locator('.kachel', { hasText: 'Stammdaten' }).locator('.merkmale')) + .toContainText(ISIN) + await expect(page.locator('.kachel', { hasText: 'Letzter Kurs' }).locator('.merkmale')) + .toContainText('120,00') + + // Zehn Stück für 800 €, bewertet mit 1.200,00 €. + const kennzahlen = page.locator('.kennzahlenleiste') + await expect(kennzahlen).toContainText('10') + await expect(kennzahlen).toContainText('1.200,00') + await expect(kennzahlen).toContainText('800,00') + + // Der Bestand steht je Depot – hier liegt alles in einem. + const bestand = page.locator('.kachel', { hasText: 'Bestand je Depot' }) + await expect(bestand).toContainText(DEPOT) + await expect(bestand).toContainText('800,00') + }) + + test('Die Buchungen folgen dem gewählten Zeitraum', async ({ page }) => { + await page.goto('/wertpapiere') + await page.locator('tr', { hasText: PAPIER }).getByRole('link', { name: PAPIER }).click() + + const liste = page.locator('table.liste').last() + + // Voreingestellt ist das laufende Jahr: nur der Kauf von diesem Jahr. + await expect(page.locator('#zeitraum')).toHaveValue('aktuelles_jahr') + await expect(liste).toContainText(`04.03.${JAHR}`) + await expect(liste).not.toContainText(`04.03.${JAHR - 1}`) + + await page.selectOption('#zeitraum', 'letztes_jahr') + await page.click('.filterleiste button[type="submit"]') + + await expect(page.locator('table.liste').last()).toContainText(`04.03.${JAHR - 1}`) + await expect(page.locator('table.liste').last()).not.toContainText(`04.03.${JAHR}`) + + // Die Kennzahlen oben gelten weiter für die ganze Historie. + await expect(page.locator('.kennzahlenleiste')).toContainText('800,00') + }) +})