From 4484ddd90d7d247d8bf0cd977aeacebaf6192b68 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 12 Aug 2026 14:26:50 +0200 Subject: [PATCH] =?UTF-8?q?Wertpapiere:=20Depotbestand=20aus=20einer=20CSV?= =?UTF-8?q?-Datei=20=C3=BCbernehmen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Depot, das nie im FinanzManager geführt wurde, ließ sich bisher nur Buchung für Buchung nachtragen. `Services\DepotbestandImport` liest stattdessen die Bestandsliste, die die Bank zum Herunterladen anbietet (erprobt mit der DKB): Kopfzeile mit Wertpapierbezeichnung, WKN, ISIN, Einstiegskurs, Bewertungskurs, Stückzahl und Assetklasse; Trennzeichen und Zeichensatz erkennt er selbst, Beträge wie "1.471,88 €" und Bruchstückzahlen wie 55,6863 liest er, wie sie kommen. Der Export kennt keine Historie – er beschreibt einen Stichtag. Je Position entsteht deshalb *eine* Depotbuchung: ein Kauf zum Stichtag über die volle Stückzahl zum Einstiegskurs. Der Einstand stimmt damit auf den Cent; frühere Käufe, Verkäufe und Dividenden bleiben leer und lassen sich nachtragen. Der Bewertungskurs wird als Kurs des Stichtags hinterlegt, sonst stünde der frisch übernommene Bestand sofort wieder ohne Bewertung da. Am echten Export nachgerechnet: 33.176,12 € Einstand, 47.120,25 € Wert – die Bank weist zehn Cent weniger Gewinn aus, was an ihren auf zwei Stellen gerundeten Kursen liegt. Kein Geldfluss: Ein Verrechnungskonto ist wählbar, bleibt aber vorbelegt leer. Das Geld ist vor Jahren geflossen, eine Belastung zum heutigen Tag würde das Girokonto verfälschen. Der Ablauf ist zweistufig – hochladen, Vorschau prüfen, übernehmen. Ohne ausdrückliche Wahl findet der Import das Depot über die Kontonummer, die zur Depotnummer der Datei passt; ein `import_hash` je Position verhindert, dass dieselbe Datei zweimal denselben Bestand anlegt. `Buchung::QUELLEN` kennt dafür jetzt die Herkunft `csv`. Co-Authored-By: Claude Opus 5 --- Changes.md | 38 ++ README.md | 5 +- docs/wertpapiere.md | 44 ++ public/index.php | 3 + src/Controllers/WertpapiereController.php | 153 +++++++ src/Models/Buchung.php | 7 +- src/Services/DepotbestandImport.php | 494 ++++++++++++++++++++++ src/Views/wertpapiere/import.php | 221 ++++++++++ src/Views/wertpapiere/index.php | 4 +- tests/21_DepotbestandImportTest.php | 202 +++++++++ 10 files changed, 1167 insertions(+), 4 deletions(-) create mode 100644 src/Services/DepotbestandImport.php create mode 100644 src/Views/wertpapiere/import.php create mode 100644 tests/21_DepotbestandImportTest.php diff --git a/Changes.md b/Changes.md index a558d2b..21f56e5 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,43 @@ # Änderungen +## 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 +einlesen – aus der Bestandsliste, die die Bank zum Herunterladen anbietet. + +**Neuer Knopf *Import*** (`Views/wertpapiere/index.php`, `Views/wertpapiere/import.php`) +- Steht auf der Bestandsseite neben *Depotbuchung erfassen*. Der Ablauf ist zweistufig: + hochladen, Vorschau prüfen, übernehmen. Die Vorschau zeigt je Position Stück, beide + Kurse, Einstand, Wert und ob sie neu ist oder schon einmal importiert wurde. +- Vor der Übernahme lassen sich Depot, Buchungsdatum und Verrechnungskonto noch ändern. + +**Der Import** (`Services\DepotbestandImport`, `WertpapiereController`) +- Erwartet die Spalten Wertpapierbezeichnung, WKN, ISIN, Einstiegskurs, Bewertungskurs, + Stückzahl und Assetklasse; Trennzeichen und Zeichensatz erkennt er selbst. Beträge wie + `"1.471,88 €"` und Bruchstückzahlen wie `55,6863` werden gelesen, wie sie kommen. +- **Die Vergangenheit fehlt im Export.** Er beschreibt nur einen Stichtag. Je Position + entsteht deshalb *eine* Depotbuchung: ein Kauf zum Stichtag über die volle Stückzahl zum + Einstiegskurs. Der Einstand stimmt damit auf den Cent; frühere Käufe, Verkäufe und + Dividenden bleiben leer. +- **Der Bewertungskurs wird als Kurs des Stichtags hinterlegt.** Ohne ihn stünde der + frisch übernommene Bestand sofort wieder ohne Bewertung da. Am echten Export + nachgerechnet: 33.176,12 € Einstand, 47.120,25 € Wert – die Bank weist zehn Cent + weniger Gewinn aus, was an ihren auf zwei Stellen gerundeten Kursen liegt. +- **Kein Geldfluss.** Ein Verrechnungskonto ist wählbar, bleibt aber vorbelegt leer: Das + Geld ist vor Jahren geflossen, eine Belastung zum heutigen Tag würde das Girokonto + verfälschen. +- Ohne ausdrückliche Wahl findet der Import das Depot über die **Kontonummer**, die zur + Depotnummer der Datei passt. Ein `import_hash` je Position verhindert, dass dieselbe + Datei zweimal denselben Bestand anlegt. +- Assetklassen werden übersetzt: *Aktien* → Aktie, *ETFs* und *Fonds* → Fonds, + *Anleihen* → Anleihe, *Zertifikate* → Zertifikat. + +**Neue Herkunft „CSV-Import“** (`Models\Buchung`) +- `Buchung::QUELLEN` kennt jetzt `csv`; Depot- und Geldbuchungen aus dem Bestandsimport + sind damit als solche erkennbar und fallen nicht mehr auf „manuell erfasst“ zurück. + +`tests/21_DepotbestandImportTest.php` prüft das an einem Ausschnitt des echten Exports. + ## Online-Banking: Umsätze, die niemand mehr zu sehen bekam (12.08.2026) Drei Fehler, die zusammen dazu führten, dass abgerufene Umsätze spurlos verschwanden: diff --git a/README.md b/README.md index 084c54d..7783c0b 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ Haushalt wird beides parallel genutzt. abgerufene Umsätze buchen sich auf Wunsch selbst, Abgleich gegen vorhandene Buchungen, Regeln für die automatische Kategorisierung. * **Wertpapiere** – Depotbestände mit Einstand und Bewertung, Depotbuchungen, Kursabruf, - Veräußerungsgeschäfte und Erträge. + Veräußerungsgeschäfte und Erträge; Bestandslisten der Bank lassen sich als CSV + übernehmen. * **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. @@ -110,6 +111,6 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf. - `docs/auswertungen.md` – Berichtsmodul, Filterleiste, Drilldown, CSV - `docs/cockpit.md` – Startseite und ihre Kacheln - `docs/banking.md` – FinTS-Abruf, TAN-Dialog, Abgleich und Regeln -- `docs/wertpapiere.md` – Depots, Einstand, Kurse und der Invst-Import +- `docs/wertpapiere.md` – Depots, Einstand, Kurse, Invst- und CSV-Bestandsimport - `docs/nebenmodule.md` – Vorlagen, Termine, Verträge, Sparziele, Kredite, Zahlungsverkehr - `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs diff --git a/docs/wertpapiere.md b/docs/wertpapiere.md index 9b212f2..15d1a41 100644 --- a/docs/wertpapiere.md +++ b/docs/wertpapiere.md @@ -111,6 +111,44 @@ Wertpapiergeschäfts als Abgang – auch bei Verkäufen und Dividenden. Das QIF `$` immer einen positiven Betrag; die Richtung steckt allein im Vorgang. Verrechnungskonten von Depots wiesen dadurch zu niedrige Salden aus. +## CSV-Import einer Bestandsliste + +*Wertpapiere → Bestände → Import* übernimmt die Depotbestandsliste, wie Banken sie zum +Herunterladen anbieten (erprobt mit der DKB). Erwartet wird eine Kopfzeile mit den Spalten +*Wertpapierbezeichnung*, *WKN*, *ISIN*, *Einstiegskurs*, *Bewertungskurs*, *Stückzahl* und +*Assetklasse*; *Datum der Erstellung* und *Depotnummer* werden mitgelesen, wenn vorhanden. +Trennzeichen (`;`, `,`, Tabulator) und Zeichensatz (UTF-8 mit oder ohne BOM, +Windows-1252) erkennt der Import selbst. + +**Der Export kennt keine Historie.** Er beschreibt einen Stichtag: was im Depot liegt und +was es im Schnitt gekostet hat. Die Geschäfte, die dorthin geführt haben, fehlen. Der +Import legt deshalb je Position **eine** Depotbuchung an – einen Kauf zum Stichtag über +die volle Stückzahl zum Einstiegskurs. Der Einstand stimmt damit auf den Cent, frühere +Käufe, Verkäufe und Dividenden bleiben naturgemäß leer und lassen sich später von Hand +nachtragen. + +**Zwei Kurse, zwei Rollen.** Der *Einstiegskurs* ist der durchschnittliche Kaufpreis und +wird zum Kurs der Buchung; der *Bewertungskurs* ist der aktuelle Kurs und landet als Kurs +des Stichtags in `kurse`. Erst dadurch ist der frisch übernommene Bestand sofort bewertbar +und zeigt denselben Gewinn wie die Bank – bis auf wenige Cent, die in den auf zwei Stellen +gerundeten Kursen des Exports stecken. + +**Kein Geldfluss.** Ein Verrechnungskonto ist wählbar, bleibt aber in aller Regel leer: +Das Geld ist vor Jahren geflossen, eine Belastung zum heutigen Tag würde das Girokonto +verfälschen. Ohne Verrechnungskonto entsteht keine Geldbuchung. + +**Assetklasse → Art**: *Aktien* → Aktie, *ETFs* und *Fonds* → Fonds, *Anleihen* → +Anleihe, *Zertifikate* → Zertifikat, alles Übrige → Sonstiges. + +Das Zieldepot lässt sich wählen; ohne Auswahl sucht der Import das Depot, dessen +**Kontonummer** der Depotnummer aus der Datei entspricht. Jede Position bekommt einen +`import_hash` aus Depot, Datum, Vorgang, Stück, Betrag und ISIN – dieselbe Datei zweimal +eingelesen legt nichts doppelt an. Ein *anderer* Stichtag gilt dagegen als neuer Vorgang. + +Der Ablauf ist zweistufig: Der Upload zeigt erst eine Vorschau mit allen Positionen, +Einstand, Wert und dem Status je Zeile; erst *Jetzt übernehmen* schreibt. Die Datei bleibt +im Importverzeichnis liegen. + ## Kontenübersicht Depots erscheinen dort mit ihrem **bewerteten Bestand**, nicht mit einem Buchungssaldo. Der @@ -127,3 +165,9 @@ Vorzeichen; Verkauf mit anteiligem Einstand und realisiertem Gewinn; Dividende o Bestandsänderung; fehlende Kurse; Veräußerungsrechnung über die Historie hinweg; der QIF-Import eines echten Ausschnitts (Kauf, Dividende, Verkauf) inklusive Idempotenz; Löschen samt Geldbuchung. + +`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 +Lauf, Zuordnung des Depots über die Depotnummer, Geldseite mit und ohne +Verrechnungskonto, sowie die Abwehr einer fremden oder leeren Datei. diff --git a/public/index.php b/public/index.php index 05a1fd5..5c8916b 100644 --- a/public/index.php +++ b/public/index.php @@ -250,6 +250,9 @@ $router->post('/wertpapiere/stammdaten/{id}/kurs', fn(array $p) => (new WertpapiereController())->kursSetzen($p)); $router->post('/wertpapiere/kurse', fn() => (new WertpapiereController())->kurseAbrufen()); +$router->get('/wertpapiere/import', fn() => (new WertpapiereController())->importFormular()); +$router->post('/wertpapiere/import', fn() => (new WertpapiereController())->importAusfuehren()); + // ── Online-Banking: Zugänge, Abruf, Abgleich, Regeln ──────────────────────── $router->get('/banking', fn() => (new BankingController())->index()); $router->get('/banking/neu', fn() => (new BankingController())->neu()); diff --git a/src/Controllers/WertpapiereController.php b/src/Controllers/WertpapiereController.php index a18952f..dba3940 100644 --- a/src/Controllers/WertpapiereController.php +++ b/src/Controllers/WertpapiereController.php @@ -9,6 +9,7 @@ use Finanzmanager\Models\Einstellung; use Finanzmanager\Models\Konto; use Finanzmanager\Models\Wertpapier; use Finanzmanager\Services\Depot; +use Finanzmanager\Services\DepotbestandImport; use Finanzmanager\Services\Kursabruf; use Finanzmanager\Services\Zeitraum; use Finanzmanager\View; @@ -130,6 +131,158 @@ class WertpapiereController extends BaseController View::redirect('/wertpapiere/buchungen', 'Depotbuchung gelöscht – samt ihrer Geldbuchung.'); } + // ── 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 diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index f810a51..7834bbd 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -18,7 +18,12 @@ use Finanzmanager\Mandant; */ class Buchung { - public const QUELLEN = ['manuell' => 'manuell erfasst', 'qif' => 'QIF-Import', 'fints' => 'Bankabruf']; + public const QUELLEN = [ + 'manuell' => 'manuell erfasst', + 'qif' => 'QIF-Import', + 'fints' => 'Bankabruf', + 'csv' => 'CSV-Import', + ]; /** Beträge gelten als gleich, wenn sie sich um weniger als einen halben Cent unterscheiden. */ private const CENT = 0.005; diff --git a/src/Services/DepotbestandImport.php b/src/Services/DepotbestandImport.php new file mode 100644 index 0000000..ec4fe95 --- /dev/null +++ b/src/Services/DepotbestandImport.php @@ -0,0 +1,494 @@ + 'erstellt', + 'depotnummer' => 'depotnummer', + 'wertpapierbezeichnung' => 'name', + 'wertpapier' => 'name', + 'bezeichnung' => 'name', + 'wkn' => 'wkn', + 'isin' => 'isin', + 'einstiegskurs' => 'einstiegskurs', + 'bewertungskurs' => 'bewertungskurs', + 'stückzahl' => 'stueck', + 'stueckzahl' => 'stueck', + 'nominal' => 'stueck', + 'assetklasse' => 'assetklasse', + ]; + + /** Assetklasse der Bank → eigene Wertpapierart. */ + private const ASSETKLASSEN = [ + 'aktien' => 'aktie', + 'aktie' => 'aktie', + 'etfs' => 'fonds', + 'etf' => 'fonds', + 'fonds' => 'fonds', + 'investmentfonds' => 'fonds', + 'anleihen' => 'anleihe', + 'anleihe' => 'anleihe', + 'renten' => 'anleihe', + 'zertifikate' => 'zertifikat', + 'zertifikat' => 'zertifikat', + ]; + + private const TRENNZEICHEN = [';', ',', "\t"]; + + /** @var list> */ + private array $zeilen = []; + + private string $erstellt = ''; + private string $depotnummer = ''; + + /** @var list */ + private array $meldungen = []; + + private int $kontoId; + private ?int $verrechnungskontoId; + private string $datum; + + /** + * @param string $inhalt Der rohe Dateiinhalt + * @param array{konto_id?: int, verrechnungskonto_id?: int|null, datum?: string} $optionen + */ + public function __construct(string $inhalt, array $optionen = []) + { + $this->lies($inhalt); + + $this->kontoId = (int)($optionen['konto_id'] ?? 0); + + $verrechnung = $optionen['verrechnungskonto_id'] ?? null; + $this->verrechnungskontoId = ($verrechnung === null || (int)$verrechnung === 0) + ? null : (int)$verrechnung; + + // Ohne Vorgabe gilt der Stichtag der Datei – er passt zum + // Bewertungskurs. Erst wenn auch der fehlt, wird es der heutige Tag. + $datum = trim((string)($optionen['datum'] ?? '')); + $this->datum = preg_match('/^\d{4}-\d{2}-\d{2}$/', $datum) === 1 + ? $datum + : ($this->erstellt !== '' ? $this->erstellt : date('Y-m-d')); + } + + /** @param array $optionen */ + public static function ausDatei(string $pfad, array $optionen = []): self + { + $inhalt = @file_get_contents($pfad); + if ($inhalt === false) { + throw new \RuntimeException('Die Datei „' . basename($pfad) . '“ lässt sich nicht lesen.'); + } + + return new self($inhalt, $optionen); + } + + /** + * Setzt das Zieldepot nachträglich – die Depotnummer steht erst fest, + * wenn die Datei gelesen ist. + */ + public function depot(int $kontoId): self + { + $this->kontoId = $kontoId; + + return $this; + } + + public function stichtag(): string + { + return $this->datum; + } + + public function depotnummer(): string + { + return $this->depotnummer; + } + + /** + * Das Depot, dessen Kontonummer zur Depotnummer der Datei passt – als + * Vorauswahl im Formular, damit niemand die Nummern vergleichen muss. + */ + public static function passendesDepot(string $depotnummer): ?array + { + $nummer = trim($depotnummer); + if ($nummer === '') { + return null; + } + + foreach (Depot::depots() as $depot) { + if (trim((string)$depot['kontonummer']) === $nummer) { + return $depot; + } + } + + return null; + } + + /** + * @return array{ + * trockenlauf: bool, datum: string, depotnummer: string, + * positionen: list>, + * zaehler: array, einstand: float, wert: float, + * meldungen: list + * } + */ + public function ausfuehren(bool $trockenlauf = false): array + { + $this->meldungen = []; + + if ($this->kontoId === 0 || Konto::find($this->kontoId) === null) { + throw new \InvalidArgumentException('Der Import braucht ein gültiges Depot.'); + } + + $lauf = fn(): array => $this->uebernimm($trockenlauf); + + // Beim echten Lauf gilt alles oder nichts: Ein Abbruch in der Mitte + // hinterließe einen halben Bestand. + $ergebnis = $trockenlauf ? $lauf() : Database::transaktion($lauf); + + $ergebnis['trockenlauf'] = $trockenlauf; + $ergebnis['datum'] = $this->datum; + $ergebnis['depotnummer'] = $this->depotnummer; + $ergebnis['meldungen'] = $this->meldungen; + + return $ergebnis; + } + + /** @return array */ + private function uebernimm(bool $trockenlauf): array + { + $zaehler = [ + 'positionen' => count($this->zeilen), + 'neu' => 0, + 'vorhanden' => 0, + 'fehler' => 0, + 'wertpapiereNeu' => 0, + 'kurse' => 0, + ]; + + $positionen = []; + $einstand = 0.0; + $wert = 0.0; + + foreach ($this->zeilen as $zeile) { + $position = $zeile; + + $betrag = round($zeile['stueck'] * $zeile['einstiegskurs'], 2); + $position['einstand'] = $betrag; + $position['wert'] = $zeile['bewertungskurs'] > 0.0 + ? round($zeile['stueck'] * $zeile['bewertungskurs'], 2) + : null; + $position['gewinn'] = $position['wert'] === null + ? null : round($position['wert'] - $betrag, 2); + + $hash = Depotbuchung::hash( + $this->kontoId, + $this->datum, + 'kauf', + $zeile['stueck'], + $betrag, + $zeile['isin'] + ); + + if (Depotbuchung::findeNachHash($hash) !== null) { + $position['status'] = 'vorhanden'; + $zaehler['vorhanden']++; + $positionen[] = $position; + $einstand += $betrag; + $wert += $position['wert'] ?? 0.0; + continue; + } + + $vorhandenesPapier = Wertpapier::finde($zeile['isin'], $zeile['wkn'], $zeile['name']); + $position['wertpapier_neu'] = $vorhandenesPapier === null; + if ($vorhandenesPapier === null) { + $zaehler['wertpapiereNeu']++; + } + + try { + if (!$trockenlauf) { + $this->buche($zeile, $betrag, $hash); + $zaehler['kurse'] += $zeile['bewertungskurs'] > 0.0 ? 1 : 0; + } elseif ($zeile['bewertungskurs'] > 0.0) { + $zaehler['kurse']++; + } + + $position['status'] = 'neu'; + $zaehler['neu']++; + } catch (\Throwable $e) { + $position['status'] = 'fehler'; + $position['meldung'] = $e->getMessage(); + $zaehler['fehler']++; + $this->melde('fehler', $zeile['name'] . ': ' . $e->getMessage()); + } + + $positionen[] = $position; + $einstand += $betrag; + $wert += $position['wert'] ?? 0.0; + } + + return [ + 'positionen' => $positionen, + 'zaehler' => $zaehler, + 'einstand' => round($einstand, 2), + 'wert' => round($wert, 2), + ]; + } + + /** @param array $zeile */ + private function buche(array $zeile, float $betrag, string $hash): void + { + $wertpapierId = Wertpapier::findenOderAnlegen([ + 'name' => $zeile['name'], + 'isin' => $zeile['isin'], + 'wkn' => $zeile['wkn'], + 'art' => $zeile['art'], + 'waehrung' => 'EUR', + ]); + + Depotbuchung::anlegen([ + 'konto_id' => $this->kontoId, + 'wertpapier_id' => $wertpapierId, + 'datum' => $this->datum, + 'vorgang' => 'kauf', + 'stueck' => $zeile['stueck'], + 'kurs' => $zeile['einstiegskurs'], + 'betrag' => $betrag, + 'verrechnungskonto_id' => $this->verrechnungskontoId, + 'notiz' => 'Bestandsübernahme ' . date('d.m.Y', strtotime($this->datum)), + 'wertpapiername' => $zeile['name'], + 'quelle' => 'csv', + 'import_hash' => $hash, + // Die Geldseite braucht einen eigenen Hash: Beide Tabellen führen + // ihre Kennung in CHAR(40), derselbe Wert zweimal wäre verwirrend. + 'buchung_hash' => sha1($hash . '|geld'), + ]); + + // Der Bewertungskurs ist der Kurs des Stichtags – ohne ihn stünde die + // frisch übernommene Position gleich wieder ohne Bewertung da. + if ($zeile['bewertungskurs'] > 0.0) { + Wertpapier::kursSetzen($wertpapierId, $this->datum, $zeile['bewertungskurs'], 'csv'); + } + } + + // ── Datei lesen ────────────────────────────────────────────────────────── + + private function lies(string $inhalt): void + { + $inhalt = trim($this->zuUtf8($inhalt)); + if ($inhalt === '') { + throw new \RuntimeException('Die Datei ist leer.'); + } + + $trenner = $this->trennzeichen((string)strtok($inhalt, "\r\n")); + + // Gelesen wird über einen Strom statt zeilenweise: Ein Wertpapiername + // in Anführungszeichen darf einen Zeilenumbruch enthalten. + $strom = fopen('php://memory', 'r+'); + if ($strom === false) { + throw new \RuntimeException('Die Datei lässt sich nicht verarbeiten.'); + } + + try { + fwrite($strom, $inhalt); + rewind($strom); + + $spalten = null; + $nummer = 0; + + while (($felder = fgetcsv($strom, 0, $trenner, '"', '\\')) !== false) { + $nummer++; + + if ($felder === [null]) { + continue; + } + + if ($spalten === null) { + $spalten = $this->kopfzeile(array_map(strval(...), $felder)); + continue; + } + + $zeile = $this->zeile(array_map(strval(...), $felder), $spalten); + if ($zeile !== null) { + $this->zeilen[] = $zeile + ['zeile' => $nummer]; + } + } + } finally { + fclose($strom); + } + + if ($this->zeilen === []) { + throw new \RuntimeException('Die Datei enthält keine Positionen.'); + } + } + + /** + * @param list $ueberschriften + * @return array eigenes Feld → Spaltennummer + */ + private function kopfzeile(array $ueberschriften): array + { + $spalten = []; + foreach ($ueberschriften as $nummer => $ueberschrift) { + $name = mb_strtolower(trim((string)$ueberschrift)); + if (isset(self::SPALTEN[$name]) && !isset($spalten[self::SPALTEN[$name]])) { + $spalten[self::SPALTEN[$name]] = $nummer; + } + } + + foreach (['name', 'einstiegskurs', 'stueck'] as $pflicht) { + if (!isset($spalten[$pflicht])) { + throw new \RuntimeException( + 'Die Datei sieht nicht wie eine Depotbestandsliste aus – ' + . 'es fehlen die Spalten Wertpapierbezeichnung, Einstiegskurs und Stückzahl.' + ); + } + } + + return $spalten; + } + + /** + * @param list $felder + * @param array $spalten + * @return array|null + */ + private function zeile(array $felder, array $spalten): ?array + { + $wert = static fn(string $feld): string => isset($spalten[$feld]) + ? trim((string)($felder[$spalten[$feld]] ?? '')) : ''; + + $name = $wert('name'); + $isin = strtoupper($wert('isin')); + $stueck = $this->zahl($wert('stueck')); + + // Summenzeilen und leere Zeilen am Dateiende fallen hier heraus. + if ($name === '' || $stueck <= 0.0) { + return null; + } + + if ($this->erstellt === '') { + $this->erstellt = $this->datumAusCsv($wert('erstellt')); + } + if ($this->depotnummer === '') { + $this->depotnummer = $wert('depotnummer'); + } + + return [ + 'name' => $name, + 'isin' => $isin, + 'wkn' => strtoupper($wert('wkn')), + 'assetklasse' => $wert('assetklasse'), + 'art' => self::ASSETKLASSEN[mb_strtolower($wert('assetklasse'))] ?? 'sonstiges', + 'stueck' => $stueck, + 'einstiegskurs' => $this->zahl($wert('einstiegskurs')), + 'bewertungskurs' => $this->zahl($wert('bewertungskurs')), + 'status' => 'neu', + 'wertpapier_neu' => false, + 'meldung' => '', + ]; + } + + /** + * Beträge kommen als „1.471,88 €“ – mit Tausenderpunkt, Komma und einem + * geschützten Leerzeichen vor dem Währungszeichen. + */ + private function zahl(string $wert): float + { + $wert = preg_replace('/[^0-9,.\-]/u', '', $wert) ?? ''; + if ($wert === '') { + return 0.0; + } + + $wert = str_replace('.', '', $wert); + + return (float)str_replace(',', '.', $wert); + } + + /** „12.08.2026“ → „2026-08-12“. */ + private function datumAusCsv(string $wert): string + { + if (preg_match('/(\d{1,2})\.(\d{1,2})\.(\d{4})/', $wert, $treffer) === 1) { + return sprintf('%04d-%02d-%02d', (int)$treffer[3], (int)$treffer[2], (int)$treffer[1]); + } + + if (preg_match('/^\d{4}-\d{2}-\d{2}/', $wert, $treffer) === 1) { + return $treffer[0]; + } + + return ''; + } + + /** + * Die DKB trennt mit Semikolon; andere Banken nehmen Komma oder Tabulator. + * Gewählt wird das Zeichen, das in der Kopfzeile am häufigsten vorkommt. + */ + private function trennzeichen(string $kopfzeile): string + { + $bestes = ';'; + $treffer = 0; + + foreach (self::TRENNZEICHEN as $zeichen) { + $anzahl = substr_count($kopfzeile, $zeichen); + if ($anzahl > $treffer) { + $bestes = $zeichen; + $treffer = $anzahl; + } + } + + return $bestes; + } + + /** + * Banken exportieren mal UTF-8 (gern mit BOM), mal Windows-1252. Ohne + * Umwandlung stünden Umlaute später als Fragezeichen in den Stammdaten. + */ + private function zuUtf8(string $inhalt): string + { + if (str_starts_with($inhalt, "\xEF\xBB\xBF")) { + return substr($inhalt, 3); + } + + if (!mb_check_encoding($inhalt, 'UTF-8')) { + return mb_convert_encoding($inhalt, 'UTF-8', 'Windows-1252'); + } + + return $inhalt; + } + + private function melde(string $typ, string $text): void + { + $this->meldungen[] = ['typ' => $typ, 'text' => $text]; + } +} diff --git a/src/Views/wertpapiere/import.php b/src/Views/wertpapiere/import.php new file mode 100644 index 0000000..bc4f491 --- /dev/null +++ b/src/Views/wertpapiere/import.php @@ -0,0 +1,221 @@ +> $depots + * @var list> $geldkonten + * @var array|null $ergebnis + * @var string $datei + * @var int $depot + * @var int $verrechnungskonto + * @var string $datum + * @var string $csrfToken + */ + +$stueckAnzeige = static fn(float $wert): string + => rtrim(rtrim(number_format($wert, 6, ',', '.'), '0'), ','); + +include __DIR__ . '/../layout/kopf.php'; +?> + + + + + + +
+
+

Vorschau

+ +
+ +

+ Nichts wurde gespeichert. Übernommen würde je Position ein Kauf zum + über die volle Stückzahl + zum Einstiegskurs; der Bewertungskurs wird als Kurs dieses Tages hinterlegt. + + Die Datei nennt die Depotnummer + . + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WertpapierArtStückEinstiegskursBewertungskursEinstandWertStatus
+ + + + + + 0.0 + ? View::h(number_format((float)$position['bewertungskurs'], 2, ',', '.')) + : '–' ?> + + + schon importiert + + + + neu – Wertpapier wird angelegt + + neu + +
+ neu · + schon vorhanden · + neue Wertpapiere · + Kurse +
+ + +

Protokoll

+
    + +
  • + +
+ + +
+ + + + + + + + + + + +

+ Ohne Verrechnungskonto entsteht keine Geldbuchung – das Geld ist vor Jahren + geflossen, eine Belastung zum heutigen Tag würde das Konto verfälschen. +

+ +
+ + +
+
+
+ + +
+

Bestandsliste hochladen

+ + +

+ Kein Depot vorhanden. Ein Depot ist ein Konto der Kontoart Depot – + anzulegen in der Kontenverwaltung. +

+ + +
+ + + + + +

+ Erwartet wird der Depotexport der Bank mit den Spalten Wertpapierbezeichnung, + WKN, ISIN, Einstiegskurs, Bewertungskurs, Stückzahl und Assetklasse. +

+ + + +

+ Ohne Auswahl sucht der Import das Depot, dessen Kontonummer der Depotnummer + in der Datei entspricht. +

+ +
+ + Abbrechen +
+
+ +
+ +

+ Der Export enthält nur den Bestand eines Stichtags, keine Historie. Der Import legt + deshalb je Position einen Kauf an: Stückzahl und Einstiegskurs ergeben den Einstand, + der Bewertungskurs den aktuellen Wert. Frühere Käufe, Verkäufe und Dividenden fehlen + naturgemäß – sie lassen sich später über Depotbuchungen + nachtragen. Ein zweiter Import derselben Datei legt nichts doppelt an. +

+ + diff --git a/src/Views/wertpapiere/index.php b/src/Views/wertpapiere/index.php index 7c34709..b2bd14b 100644 --- a/src/Views/wertpapiere/index.php +++ b/src/Views/wertpapiere/index.php @@ -47,6 +47,7 @@ include __DIR__ . '/../layout/kopf.php'; + Import Depotbuchung erfassen @@ -93,7 +94,8 @@ include __DIR__ . '/../layout/kopf.php';

- Keine Bestände. Depotbuchungen kommen aus dem QIF-Import oder werden hier erfasst. + Keine Bestände. Depotbuchungen kommen aus dem QIF-Import, aus einer + Bestandsliste der Bank oder werden hier erfasst.

diff --git a/tests/21_DepotbestandImportTest.php b/tests/21_DepotbestandImportTest.php new file mode 100644 index 0000000..32b5b8a --- /dev/null +++ b/tests/21_DepotbestandImportTest.php @@ -0,0 +1,202 @@ +mandantAnlegen(); + $this->stammdaten(); + + $this->vorschauLegtNichtsAn(); + $this->importLegtBestandUndKurseAn(); + $this->zweiterLaufLegtNichtsDoppeltAn(); + $this->depotWirdUeberDieDepotnummerGefunden(); + $this->verrechnungskontoIstFreiwillig(); + $this->fremdeDateiWirdAbgewiesen(); + $this->ausDateiLiestVomDatentraeger(); + } + + private function stammdaten(): void + { + $this->depot = Konto::anlegen([ + 'name' => 'CSV-Depot', 'kontoart' => 'depot', 'kontonummer' => '502022353', + 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->verrechnung = Konto::anlegen([ + 'name' => 'CSV-Verrechnung', 'kontoart' => 'bank', 'anfangssaldo' => 5000, 'aktiv' => 1, + ]); + } + + private function import(array $optionen = []): DepotbestandImport + { + return new DepotbestandImport(self::CSV, $optionen + ['konto_id' => $this->depot]); + } + + private function vorschauLegtNichtsAn(): void + { + $ergebnis = $this->import()->ausfuehren(true); + + $this->assertEquals(3, $ergebnis['zaehler']['neu'], 'Drei Positionen in der Vorschau'); + $this->assertEquals(3, $ergebnis['zaehler']['wertpapiereNeu'], 'Drei Wertpapiere wären neu'); + $this->assertEquals('2026-08-12', $ergebnis['datum'], 'Der Stichtag kommt aus der Datei'); + $this->assertEquals('502022353', $ergebnis['depotnummer'], 'Die Depotnummer wird gelesen'); + $this->assertEquals(0, Depotbuchung::anzahl(), 'Der Trockenlauf hat nichts gespeichert'); + $this->assertEquals(0, Wertpapier::anzahl(), 'Und auch kein Wertpapier angelegt'); + + // 60 × 224,60 + 55,6863 × 58,36 + 3 × 648,33 = 13.476,00 + 3.249,85 + 1.944,99. + $this->assertEquals(18670.84, $ergebnis['einstand'], 'Einstand aus Stück × Einstiegskurs'); + // 60 × 437,30 + 55,6863 × 75,19 + 3 × 522,50 = 26.238,00 + 4.187,05 + 1.567,50. + $this->assertEquals(31992.55, $ergebnis['wert'], 'Wert aus Stück × Bewertungskurs'); + } + + private function importLegtBestandUndKurseAn(): void + { + $ergebnis = $this->import()->ausfuehren(); + + $this->assertEquals(3, $ergebnis['zaehler']['neu'], 'Drei Positionen übernommen'); + $this->assertEquals(3, $ergebnis['zaehler']['kurse'], 'Drei Bewertungskurse hinterlegt'); + $this->assertEquals(0, $ergebnis['zaehler']['fehler'], 'Keine Fehler'); + + $buchungen = Depotbuchung::liste(['konto_id' => $this->depot]); + $this->assertEquals(3, count($buchungen), 'Drei Depotbuchungen'); + + foreach ($buchungen as $buchung) { + $this->assertEquals('kauf', (string)$buchung['vorgang'], 'Alles wird als Kauf gebucht'); + $this->assertEquals('2026-08-12', (string)$buchung['datum'], 'Und zwar zum Stichtag'); + $this->assertNull($buchung['buchung_id'], 'Ohne Verrechnungskonto keine Geldbuchung'); + } + + // Bruchstückzahlen kommen mit Komma und dürfen nicht auf ganze Stücke fallen. + $etf = Wertpapier::finde('LU1834983477'); + $this->assertNotEmpty($etf, 'Der ETF wurde angelegt'); + $this->assertEquals('fonds', (string)$etf['art'], 'Assetklasse ETFs wird zur Art Fonds'); + + $bestand = Depot::bestand($this->depot, '2026-08-12'); + $this->assertEquals(3, count($bestand['positionen']), 'Drei Positionen im Bestand'); + $this->assert($bestand['bewertbar'], 'Der Bestand ist sofort bewertbar'); + $this->assertEquals(18670.84, $bestand['einstand'], 'Der Einstand entspricht dem Export'); + $this->assertEquals(31992.55, $bestand['wert'], 'Der Wert ebenso'); + // Die Bank weist in Summe 13.321,65 € aus – die Differenz von sechs Cent + // steckt in den auf zwei Stellen gerundeten Kursen des Exports. + $this->assertEquals(13321.71, $bestand['gewinn'], 'Und damit auch der Gewinn'); + + $allianz = Wertpapier::finde('DE0008404005'); + $kurs = Wertpapier::letzterKurs((int)$allianz['id'], '2026-08-12'); + $this->assertEquals(437.30, (float)$kurs['kurs'], 'Der Bewertungskurs steht als Kurs des Stichtags'); + + $position = null; + foreach ($bestand['positionen'] as $eintrag) { + $position = $eintrag['isin'] === 'LU1834983477' ? $eintrag : $position; + } + $this->assertEquals(55.6863, (float)$position['stueck'], 'Bruchstücke bleiben erhalten'); + } + + private function zweiterLaufLegtNichtsDoppeltAn(): void + { + $ergebnis = $this->import()->ausfuehren(); + + $this->assertEquals(0, $ergebnis['zaehler']['neu'], 'Der zweite Lauf legt nichts an'); + $this->assertEquals(3, $ergebnis['zaehler']['vorhanden'], 'Alle drei sind schon da'); + $this->assertEquals(3, count(Depotbuchung::liste(['konto_id' => $this->depot])), 'Es bleiben drei'); + + // Ein anderer Stichtag ist ein anderer Vorgang – dann darf gebucht werden. + $spaeter = $this->import(['datum' => '2026-08-13'])->ausfuehren(true); + $this->assertEquals(3, $spaeter['zaehler']['neu'], 'Zu einem anderen Datum wäre alles neu'); + $this->assertEquals(0, $spaeter['zaehler']['wertpapiereNeu'], 'Die Wertpapiere gibt es aber schon'); + } + + private function depotWirdUeberDieDepotnummerGefunden(): void + { + $passend = DepotbestandImport::passendesDepot('502022353'); + $this->assertEquals($this->depot, (int)$passend['id'], 'Die Kontonummer führt zum Depot'); + $this->assertNull(DepotbestandImport::passendesDepot('999'), 'Eine fremde Nummer findet nichts'); + $this->assertNull(DepotbestandImport::passendesDepot(''), 'Ohne Nummer wird nicht geraten'); + } + + private function verrechnungskontoIstFreiwillig(): void + { + $zweitesDepot = Konto::anlegen([ + 'name' => 'CSV-Depot mit Geldseite', 'kontoart' => 'depot', 'aktiv' => 1, + ]); + + (new DepotbestandImport(self::CSV, [ + 'konto_id' => $zweitesDepot, + 'verrechnungskonto_id' => $this->verrechnung, + ]))->ausfuehren(); + + $buchungen = Depotbuchung::liste(['konto_id' => $zweitesDepot]); + $this->assertEquals(3, count($buchungen), 'Auch hier drei Depotbuchungen'); + $this->assertNotEmpty($buchungen[0]['buchung_id'], 'Diesmal mit Geldbuchung'); + + // Der volle Einstand belastet das Verrechnungskonto. + $saldo = Konto::saldo($this->verrechnung, '2026-08-12'); + $this->assertEquals(5000.0 - 18670.84, round($saldo, 2), 'Der Einstand wurde abgebucht'); + } + + private function fremdeDateiWirdAbgewiesen(): void + { + $fehler = ''; + try { + new DepotbestandImport("Datum;Empfänger;Betrag\n01.01.2026;Miete;-800,00\n", [ + 'konto_id' => $this->depot, + ]); + } catch (\Throwable $e) { + $fehler = $e->getMessage(); + } + $this->assert(str_contains($fehler, 'Depotbestandsliste'), 'Eine Umsatzliste wird erkannt'); + + $fehler = ''; + try { + new DepotbestandImport('', ['konto_id' => $this->depot]); + } catch (\Throwable $e) { + $fehler = $e->getMessage(); + } + $this->assert($fehler !== '', 'Eine leere Datei ebenso'); + + // Ohne gültiges Depot wird gar nicht erst gelesen. + $fehler = ''; + try { + (new DepotbestandImport(self::CSV, ['konto_id' => 0]))->ausfuehren(true); + } catch (\Throwable $e) { + $fehler = $e->getMessage(); + } + $this->assert(str_contains($fehler, 'Depot'), 'Ohne Depot bricht der Import ab'); + } + + private function ausDateiLiestVomDatentraeger(): void + { + $pfad = $this->tempPfad('.csv'); + file_put_contents($pfad, self::CSV); + + $ergebnis = DepotbestandImport::ausDatei($pfad, ['konto_id' => $this->depot])->ausfuehren(true); + $this->assertEquals(3, count($ergebnis['positionen']), 'Auch aus der Datei kommen drei Positionen'); + } +}