verlangeAnmeldung(); $depots = Depot::depots(); $kontoId = (int)$this->query('depot'); $kontoId = $kontoId > 0 ? $kontoId : null; $this->render('wertpapiere/index.php', [ 'titel' => 'Wertpapiere', 'depots' => $depots, 'depot' => $kontoId, 'bestand' => Depot::bestand($kontoId), 'entwicklung' => Depot::entwicklung($kontoId), 'kursabruf' => Einstellung::get('wertpapiere_api_key', '') !== '', ]); } /** * 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(); $filter = [ 'konto_id' => (int)$this->query('depot') ?: null, 'wertpapier_id' => (int)$this->query('wertpapier') ?: null, 'vorgang' => $this->query('vorgang'), ]; $this->render('wertpapiere/buchungen.php', [ 'titel' => 'Depotbuchungen', 'buchungen' => Depotbuchung::liste(array_filter($filter)), 'depots' => Depot::depots(), 'wertpapiere' => Wertpapier::alle(), 'konten' => Konto::alle(true), 'filter' => $filter, ]); } public function neu(): void { $this->verlangeAnmeldung(); $this->render('wertpapiere/formular.php', [ 'titel' => 'Depotbuchung erfassen', 'depots' => Depot::depots(true), 'wertpapiere' => Wertpapier::alle(true), 'konten' => Konto::alle(true), ]); } public function anlegen(): void { $this->verlangeAnmeldung(); try { $wertpapierId = (int)$this->post('wertpapier_id'); // Ein neues Papier lässt sich im selben Formular anlegen – sonst // müsste man für jeden Erstkauf erst in die Stammdaten wechseln. if ($wertpapierId === 0) { $wertpapierId = Wertpapier::findenOderAnlegen([ 'name' => $this->post('wertpapier_name'), 'isin' => $this->post('isin'), 'wkn' => $this->post('wkn'), 'art' => $this->post('art', 'aktie'), 'waehrung' => $this->post('waehrung', 'EUR'), ]); } $stueck = $this->zahl($this->post('stueck')); $kurs = $this->zahl($this->post('kurs')); $betrag = $this->zahl($this->post('betrag')); // Zwei von drei Angaben genügen: Der dritte Wert ergibt sich. if ($betrag <= 0.0 && $stueck > 0.0 && $kurs > 0.0) { $betrag = round($stueck * $kurs, 2); } Depotbuchung::anlegen([ 'konto_id' => (int)$this->post('konto_id'), 'wertpapier_id' => $wertpapierId, 'datum' => $this->post('datum'), 'valuta' => $this->post('valuta'), 'vorgang' => $this->post('vorgang', 'kauf'), 'stueck' => $stueck, 'kurs' => $kurs > 0.0 ? $kurs : null, 'betrag' => $betrag, 'gebuehren' => $this->zahl($this->post('gebuehren')), 'steuern' => $this->zahl($this->post('steuern')), 'verrechnungskonto_id' => $this->post('verrechnungskonto_id'), 'notiz' => $this->post('notiz'), 'wertpapiername' => $this->post('wertpapier_name') ?: null, ]); } catch (\Throwable $e) { View::redirect('/wertpapiere/buchungen/neu', $e->getMessage(), 'fehler'); return; } View::redirect('/wertpapiere/buchungen', 'Depotbuchung erfasst.'); } /** @param array $params */ public function loeschen(array $params): void { $this->verlangeAnmeldung(); Depotbuchung::loeschen((int)$params['id']); 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)); } // ── Bestand laut Bank ──────────────────────────────────────────────────── /** * Der Depotbestand, wie ihn die Bank per FinTS meldet, neben dem eigenen * Bestand. Der Abruf selbst läuft über den Banking-Bereich (TAN-Maske). */ public function bankbestand(): void { $this->verlangeAnmeldung(); $depots = []; foreach (Depot::depots(true) as $depot) { $depots[] = $depot + [ 'vergleich' => Depotabgleich::vergleich((int)$depot['id']), 'abgerufen_am' => Depotabgleich::abgerufenAm((int)$depot['id']), ]; } $this->render('wertpapiere/bank.php', [ 'titel' => 'Depotbestand laut Bank', 'depots' => $depots, 'zugaenge' => array_values(array_filter( Bankzugang::alle(true), static fn(array $zugang): bool => (string)$zugang['typ'] === 'fints' )), ]); } /** Bucht die Unterschiede zwischen Bank- und eigenem Bestand ein. */ public function bankbestandEinbuchen(): void { $this->verlangeAnmeldung(); $depotId = (int)$this->post('konto_id'); if (Depot::depots(true) === [] || Konto::find($depotId) === null) { View::redirect('/wertpapiere/bank', 'Dieses Depot gibt es nicht.', 'fehler'); return; } $stichtag = trim((string)$this->post('stichtag')); if ($stichtag !== '') { $datum = \DateTimeImmutable::createFromFormat('!Y-m-d', $stichtag); if ($datum === false || $datum->format('Y-m-d') !== $stichtag) { View::redirect('/wertpapiere/bank', 'Der Stichtag ist kein gültiges Datum.', 'fehler'); return; } } try { $e = Depotabgleich::einbuchen($depotId, $stichtag !== '' ? $stichtag : null); } catch (\Throwable $ex) { View::redirect('/wertpapiere/bank', 'Einbuchen fehlgeschlagen: ' . $ex->getMessage(), 'fehler'); return; } View::redirect('/wertpapiere/bank', sprintf( '%d Einlieferung(en), %d Auslieferung(en) gebucht, %d Wertpapier(e) neu angelegt.', $e['eingeliefert'], $e['ausgeliefert'], $e['angelegt'] )); } // ── Bestandsimport aus einer CSV-Datei ─────────────────────────────────── public function importFormular(): void { $this->verlangeAnmeldung(); $this->render('wertpapiere/import.php', [ 'titel' => 'Depotbestand importieren', 'depots' => Depot::depots(true), 'geldkonten' => $this->geldkonten(), 'ergebnis' => null, 'datei' => '', 'depot' => (int)$this->query('depot'), 'verrechnungskonto' => 0, 'datum' => '', ]); } /** * Vorschau und Übernahme laufen durch dieselbe Tür: Der erste Aufruf bringt * die hochgeladene Datei mit, der zweite nur noch ihren Namen. */ public function importAusfuehren(): void { $this->verlangeAnmeldung(); try { $datei = $this->importDatei(); } catch (\Throwable $e) { View::redirect('/wertpapiere/import', $e->getMessage(), 'fehler'); return; } $trockenlauf = $this->post('modus') !== 'import'; $depotId = (int)$this->post('konto_id'); $verrechnung = (int)$this->post('verrechnungskonto_id'); try { $import = DepotbestandImport::ausDatei($this->importVerzeichnis() . '/' . $datei, [ 'konto_id' => $depotId, 'verrechnungskonto_id' => $verrechnung, 'datum' => $this->post('datum'), ]); // Ohne ausdrückliche Wahl entscheidet die Depotnummer aus der Datei. if ($depotId === 0) { $passend = DepotbestandImport::passendesDepot($import->depotnummer()); $depotId = (int)($passend['id'] ?? 0); $import->depot($depotId); } $ergebnis = $import->ausfuehren($trockenlauf); } catch (\Throwable $e) { View::redirect('/wertpapiere/import', 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler'); return; } if (!$trockenlauf) { View::redirect('/wertpapiere?depot=' . $depotId, sprintf( '%d Positionen übernommen, %d waren schon da%s.', $ergebnis['zaehler']['neu'], $ergebnis['zaehler']['vorhanden'], $ergebnis['zaehler']['fehler'] > 0 ? ', ' . $ergebnis['zaehler']['fehler'] . ' fehlgeschlagen' : '' ), $ergebnis['zaehler']['fehler'] > 0 ? 'fehler' : 'erfolg'); return; } $this->render('wertpapiere/import.php', [ 'titel' => 'Depotbestand importieren', 'depots' => Depot::depots(true), 'geldkonten' => $this->geldkonten(), 'ergebnis' => $ergebnis, 'datei' => $datei, 'depot' => $depotId, 'verrechnungskonto' => $verrechnung, 'datum' => $ergebnis['datum'], ]); } /** * Nimmt die hochgeladene Datei entgegen und legt sie ab – oder greift auf * die aus der Vorschau zurück. */ private function importDatei(): string { $hochgeladen = $_FILES['csv'] ?? null; if (is_array($hochgeladen) && ($hochgeladen['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK) { $name = self::sichererCsvName((string)$hochgeladen['name']); if ($name === null) { throw new \RuntimeException('Nur .CSV-Dateien können hochgeladen werden.'); } $ziel = $this->importVerzeichnis() . '/' . $name; if ( !is_uploaded_file((string)$hochgeladen['tmp_name']) || !move_uploaded_file((string)$hochgeladen['tmp_name'], $ziel) ) { throw new \RuntimeException('Die Datei konnte nicht abgelegt werden.'); } return $name; } $name = self::sichererCsvName($this->post('datei')); if ($name === null || !is_file($this->importVerzeichnis() . '/' . $name)) { throw new \RuntimeException('Es wurde keine CSV-Datei ausgewählt.'); } return $name; } /** Wie beim QIF-Import: kein Pfadanteil, keine fremde Endung. */ public static function sichererCsvName(string $eingabe): ?string { $name = basename(trim($eingabe)); if ($name === '' || $name === '.' || $name === '..' || str_contains($name, "\0")) { return null; } if (strtolower(pathinfo($name, PATHINFO_EXTENSION)) !== 'csv') { return null; } $name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?? ''; return $name === '' ? null : $name; } /** Dasselbe Verzeichnis wie beim QIF-Import – es ist das Importverzeichnis. */ private function importVerzeichnis(): string { $relativ = (string)Einstellung::get('import_qif_verzeichnis', 'data/import'); $pfad = dirname(__DIR__, 2) . '/' . trim($relativ, '/'); if (!is_dir($pfad)) { mkdir($pfad, 0o775, true); } return $pfad; } /** Konten, die als Verrechnungskonto in Frage kommen – alles außer Depots. */ private function geldkonten(): array { return array_values(array_filter( Konto::alle(true), static fn(array $konto): bool => (string)$konto['kontoart'] !== 'depot' )); } // ── Stammdaten und Kurse ───────────────────────────────────────────────── public function stammdaten(): void { $this->verlangeAnmeldung(); $kurse = Wertpapier::letzteKurse(); $liste = []; foreach (Wertpapier::alle() as $wertpapier) { $wertpapier['kurs'] = $kurse[(int)$wertpapier['id']]['kurs'] ?? null; $wertpapier['kurs_datum'] = $kurse[(int)$wertpapier['id']]['datum'] ?? null; $liste[] = $wertpapier; } $this->render('wertpapiere/stammdaten.php', [ 'titel' => 'Wertpapiere verwalten', 'wertpapiere' => $liste, 'anbieter' => Einstellung::get('wertpapiere_kursdienst', 'leeway'), 'schluessel' => Einstellung::get('wertpapiere_api_key', '') !== '', ]); } /** @param array $params */ public function speichern(array $params): void { $this->verlangeAnmeldung(); try { Wertpapier::speichern((int)$params['id'], [ 'name' => $this->post('name'), 'wkn' => $this->post('wkn'), 'isin' => $this->post('isin'), 'art' => $this->post('art'), 'waehrung' => $this->post('waehrung', 'EUR'), 'symbol' => $this->post('symbol'), 'aktiv' => isset($_POST['aktiv']) ? 1 : 0, ]); } catch (\Throwable $e) { View::redirect('/wertpapiere/stammdaten', $e->getMessage(), 'fehler'); return; } View::redirect('/wertpapiere/stammdaten', 'Wertpapier gespeichert.'); } /** @param array $params */ public function kursSetzen(array $params): void { $this->verlangeAnmeldung(); $kurs = $this->zahl($this->post('kurs')); $datum = $this->post('datum') ?: date('Y-m-d'); if ($kurs <= 0.0) { View::redirect('/wertpapiere/stammdaten', 'Der Kurs muss größer als null sein.', 'fehler'); return; } Wertpapier::kursSetzen((int)$params['id'], $datum, $kurs, 'manuell'); View::redirect('/wertpapiere/stammdaten', 'Kurs eingetragen.'); } public function kurseAbrufen(): void { $this->verlangeAnmeldung(); $ergebnis = Kursabruf::laufen(); $meldung = sprintf( '%d Kurse abgerufen, %d übersprungen, %d fehlgeschlagen.', $ergebnis['abgerufen'], $ergebnis['uebersprungen'], $ergebnis['fehler'] ); if ($ergebnis['fehler'] > 0) { $meldung .= ' ' . implode(' · ', array_slice($ergebnis['protokoll'], 0, 3)); } View::redirect( '/wertpapiere/stammdaten', $meldung, $ergebnis['fehler'] > 0 && $ergebnis['abgerufen'] === 0 ? 'fehler' : 'erfolg' ); } // ── Auswertungen ───────────────────────────────────────────────────────── public function auswertung(): void { $this->verlangeAnmeldung(); $art = $this->query('art', 'veraeusserungen'); $vorgabe = $this->query('zeitraum', 'aktuelles_jahr'); $zeitraum = Zeitraum::berechne($vorgabe, $this->query('von'), $this->query('bis')); $kontoId = (int)$this->query('depot') ?: null; $this->render('wertpapiere/auswertung.php', [ 'titel' => $art === 'ertraege' ? 'Erträge' : 'Veräußerungsgeschäfte', 'art' => $art === 'ertraege' ? 'ertraege' : 'veraeusserungen', 'zeitraum' => $zeitraum, 'depots' => Depot::depots(), 'depot' => $kontoId, 'daten' => $art === 'ertraege' ? Depot::ertraege($zeitraum['von'], $zeitraum['bis'], $kontoId) : Depot::veraeusserungen($zeitraum['von'], $zeitraum['bis'], $kontoId), ]); } /** Beträge kommen mit Komma aus dem Formular. */ private function zahl(string $wert): float { $wert = trim(str_replace(['.', ' '], '', $wert)); return $wert === '' ? 0.0 : (float)str_replace(',', '.', $wert); } }