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'); + } +}