Finanzmanager/src/Controllers/WertpapiereController.php
fds2610-bot 9a3472f4b7 Fix n.n.: Depot-Zuordnung, Bestand einbuchen mit Stichtag
- Depot der Bank wird einem Depot im Finanzmanager zugeordnet (Migration 022)
- Bestand einbuchen: Ein-/Auslieferungen aus dem Bankbestand, Stichtag per Dialog
- Zuordnungsliste: Depots nur noch als Wertpapiergeschäft

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 15:10:43 +02:00

574 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\Depot;
use Finanzmanager\Services\DepotbestandImport;
use Finanzmanager\Services\Depotabgleich;
use Finanzmanager\Services\Kursabruf;
use Finanzmanager\Services\Zeitraum;
use Finanzmanager\View;
/**
* Depots: Bestände, Buchungen, Kurse und die beiden Auswertungen
* *Veräußerungsgeschäfte* und *Erträge*.
*/
class WertpapiereController extends BaseController
{
public function index(): void
{
$this->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<string,string> $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<string,string> $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<string,string> $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<string,string> $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);
}
}