Wertpapiere: Depotbestand aus einer CSV-Datei übernehmen

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 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-12 14:26:50 +02:00
parent 78f1b91bc0
commit 4484ddd90d
10 changed files with 1167 additions and 4 deletions

View file

@ -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:

View file

@ -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

View file

@ -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.

View file

@ -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());

View file

@ -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

View file

@ -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;

View file

@ -0,0 +1,494 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
/**
* Übernimmt eine Depotbestandsliste im CSV-Format der DKB.
*
* **Was die Datei enthält – und was nicht.** Der Export beschreibt nur den
* Bestand eines Stichtags: je Position Stückzahl, Einstiegskurs und
* Bewertungskurs. Die Geschäfte, die zu diesem Bestand geführt haben, fehlen
* vollständig. Der Import baut deshalb je Position *eine* Depotbuchung –
* einen Kauf zum Stichtag über die volle Stückzahl zum Einstiegskurs. Der
* Einstand stimmt damit auf den Cent, die Historie bleibt naturgemäß leer.
*
* **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 der Kurstabelle. Erst dadurch zeigt
* die Depotübersicht denselben Gewinn wie die Bank.
*
* **Kein Geldfluss.** Ein Verrechnungskonto ist optional und bleibt in aller
* Regel leer: Das Geld ist irgendwann in der Vergangenheit geflossen, eine
* Buchung zum heutigen Tag würde das Girokonto verfälschen.
*
* **Zweiter Lauf.** Jede Position bekommt einen `import_hash` aus Depot, Datum,
* Vorgang, Stück, Betrag und ISIN. Derselbe Export legt deshalb nichts doppelt
* an.
*/
class DepotbestandImport
{
/** Spaltenüberschrift (klein geschrieben) → eigenes Feld. */
private const SPALTEN = [
'datum der erstellung' => '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<array<string,mixed>> */
private array $zeilen = [];
private string $erstellt = '';
private string $depotnummer = '';
/** @var list<array{typ: string, text: string}> */
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<string,mixed> $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<array<string,mixed>>,
* zaehler: array<string,int>, einstand: float, wert: float,
* meldungen: list<array{typ: string, text: string}>
* }
*/
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<string,mixed> */
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<string,mixed> $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<string> $ueberschriften
* @return array<string,int> 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<string> $felder
* @param array<string,int> $spalten
* @return array<string,mixed>|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];
}
}

View file

@ -0,0 +1,221 @@
<?php
use Finanzmanager\View;
/**
* Import einer Depotbestandsliste (CSV der Bank): erst Vorschau, dann Übernahme.
*
* @var list<array<string,mixed>> $depots
* @var list<array<string,mixed>> $geldkonten
* @var array<string,mixed>|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';
?>
<div class="tableiste">
<a href="/wertpapiere" class="tab">Bestände</a>
<a href="/wertpapiere/buchungen" class="tab">Depotbuchungen</a>
<a href="/wertpapiere/stammdaten" class="tab">Wertpapiere</a>
<a href="/wertpapiere/auswertung?art=veraeusserungen" class="tab">Veräußerungen</a>
<a href="/wertpapiere/auswertung?art=ertraege" class="tab">Erträge</a>
</div>
<div class="seitenaktionen">
<a href="/wertpapiere" class="knopf">Zurück zu den Beständen</a>
</div>
<?php if ($ergebnis !== null): ?>
<div class="kachel">
<header>
<h2>Vorschau</h2>
<span class="phasenmarke"><?= View::h($datei) ?></span>
</header>
<p class="hinweis">
Nichts wurde gespeichert. Übernommen würde je Position ein <em>Kauf</em> zum
<strong><?= View::datum($ergebnis['datum']) ?></strong> über die volle Stückzahl
zum Einstiegskurs; der Bewertungskurs wird als Kurs dieses Tages hinterlegt.
<?php if ($ergebnis['depotnummer'] !== ''): ?>
Die Datei nennt die Depotnummer
<code><?= View::h($ergebnis['depotnummer']) ?></code>.
<?php endif; ?>
</p>
<table class="liste zahlen">
<thead>
<tr>
<th>Wertpapier</th>
<th>Art</th>
<th class="rechts">Stück</th>
<th class="rechts">Einstiegskurs</th>
<th class="rechts">Bewertungskurs</th>
<th class="rechts">Einstand</th>
<th class="rechts">Wert</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<?php foreach ($ergebnis['positionen'] as $position): ?>
<tr>
<td>
<?= View::h($position['name']) ?>
<span class="nebentext">
<?= View::h(trim($position['isin'] . ' · ' . $position['wkn'], ' ·')) ?>
</span>
</td>
<td><?= View::h($position['assetklasse']) ?></td>
<td class="rechts"><?= View::h($stueckAnzeige((float)$position['stueck'])) ?></td>
<td class="rechts"><?= View::h(number_format((float)$position['einstiegskurs'], 2, ',', '.')) ?></td>
<td class="rechts">
<?= (float)$position['bewertungskurs'] > 0.0
? View::h(number_format((float)$position['bewertungskurs'], 2, ',', '.'))
: '–' ?>
</td>
<td class="rechts"><?= View::betrag($position['einstand']) ?></td>
<td class="rechts"><?= $position['wert'] === null ? '–' : View::betrag($position['wert']) ?></td>
<td>
<?php if ($position['status'] === 'vorhanden'): ?>
<span class="nebentext">schon importiert</span>
<?php elseif ($position['status'] === 'fehler'): ?>
<span class="betrag negativ"><?= View::h($position['meldung']) ?></span>
<?php elseif ($position['wertpapier_neu']): ?>
neu – Wertpapier wird angelegt
<?php else: ?>
neu
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr class="summenzeile">
<td colspan="5">
<?= (int)$ergebnis['zaehler']['neu'] ?> neu ·
<?= (int)$ergebnis['zaehler']['vorhanden'] ?> schon vorhanden ·
<?= (int)$ergebnis['zaehler']['wertpapiereNeu'] ?> neue Wertpapiere ·
<?= (int)$ergebnis['zaehler']['kurse'] ?> Kurse
</td>
<td class="rechts"><?= View::betrag($ergebnis['einstand']) ?></td>
<td class="rechts"><?= View::betrag($ergebnis['wert']) ?></td>
<td></td>
</tr>
</tfoot>
</table>
<?php if (!empty($ergebnis['meldungen'])): ?>
<h3>Protokoll</h3>
<ul class="protokoll">
<?php foreach ($ergebnis['meldungen'] as $meldung): ?>
<li class="<?= View::h($meldung['typ']) ?>"><?= View::h($meldung['text']) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<form method="post" action="/wertpapiere/import" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="datei" value="<?= View::h($datei) ?>">
<label for="konto_id">Depot</label>
<select id="konto_id" name="konto_id" required>
<?php foreach ($depots as $eintrag): ?>
<option value="<?= (int)$eintrag['id'] ?>" <?= $depot === (int)$eintrag['id'] ? 'selected' : '' ?>>
<?= View::h($eintrag['name']) ?><?= trim((string)$eintrag['kontonummer']) !== ''
? ' (' . View::h($eintrag['kontonummer']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
<label for="datum">Buchungsdatum</label>
<input type="date" id="datum" name="datum" value="<?= View::h($datum) ?>" required>
<label for="verrechnungskonto_id">Verrechnungskonto <span class="optional">(optional)</span></label>
<select id="verrechnungskonto_id" name="verrechnungskonto_id">
<option value="">– keines, nur der Bestand –</option>
<?php foreach ($geldkonten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"
<?= $verrechnungskonto === (int)$konto['id'] ? 'selected' : '' ?>>
<?= View::h($konto['name']) ?>
</option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">
Ohne Verrechnungskonto entsteht keine Geldbuchung – das Geld ist vor Jahren
geflossen, eine Belastung zum heutigen Tag würde das Konto verfälschen.
</p>
<div class="knopfleiste">
<button type="submit" name="modus" value="import" class="knopf primaer">
Jetzt übernehmen
</button>
<button type="submit" name="modus" value="vorschau" class="knopf">
Vorschau neu berechnen
</button>
</div>
</form>
</div>
<?php endif; ?>
<div class="kachel">
<header><h2>Bestandsliste hochladen</h2></header>
<?php if ($depots === []): ?>
<p class="platzhalter">
Kein Depot vorhanden. Ein Depot ist ein Konto der Kontoart <em>Depot</em> –
anzulegen in der Kontenverwaltung.
</p>
<div class="knopfleiste">
<a href="/konten/neu" class="knopf primaer">Konto anlegen</a>
</div>
<?php else: ?>
<form method="post" action="/wertpapiere/import" enctype="multipart/form-data" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="modus" value="vorschau">
<label for="csv">CSV-Datei</label>
<input type="file" id="csv" name="csv" accept=".csv,.CSV" required>
<p class="feldhinweis">
Erwartet wird der Depotexport der Bank mit den Spalten Wertpapierbezeichnung,
WKN, ISIN, Einstiegskurs, Bewertungskurs, Stückzahl und Assetklasse.
</p>
<label for="konto_id_neu">Depot</label>
<select id="konto_id_neu" name="konto_id">
<option value="">– nach Depotnummer aus der Datei –</option>
<?php foreach ($depots as $eintrag): ?>
<option value="<?= (int)$eintrag['id'] ?>" <?= $depot === (int)$eintrag['id'] ? 'selected' : '' ?>>
<?= View::h($eintrag['name']) ?><?= trim((string)$eintrag['kontonummer']) !== ''
? ' (' . View::h($eintrag['kontonummer']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">
Ohne Auswahl sucht der Import das Depot, dessen Kontonummer der Depotnummer
in der Datei entspricht.
</p>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Vorschau</button>
<a href="/wertpapiere" class="knopf">Abbrechen</a>
</div>
</form>
<?php endif; ?>
</div>
<p class="hinweis">
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 <a href="/wertpapiere/buchungen/neu">Depotbuchungen</a>
nachtragen. Ein zweiter Import derselben Datei legt nichts doppelt an.
</p>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -47,6 +47,7 @@ include __DIR__ . '/../layout/kopf.php';
</select>
<button type="submit" class="knopf klein">Anzeigen</button>
</form>
<a href="/wertpapiere/import" class="knopf">Import</a>
<a href="/wertpapiere/buchungen/neu" class="knopf primaer">Depotbuchung erfassen</a>
</div>
@ -93,7 +94,8 @@ include __DIR__ . '/../layout/kopf.php';
<?php if ($bestand['positionen'] === []): ?>
<p class="platzhalter">
Keine Bestände. Depotbuchungen kommen aus dem QIF-Import oder werden hier erfasst.
Keine Bestände. Depotbuchungen kommen aus dem QIF-Import, aus einer
<a href="/wertpapiere/import">Bestandsliste der Bank</a> oder werden hier erfasst.
</p>
<?php else: ?>
<table class="liste zahlen">

View file

@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\Depot;
use Finanzmanager\Services\DepotbestandImport;
/**
* Import einer Depotbestandsliste im CSV-Format der Bank: Kurse, Einstand,
* Dublettenschutz und die Zuordnung des Depots über die Depotnummer.
*/
class DepotbestandImportTest extends TestCase
{
protected string $testName = 'Test 21: Depotbestand aus CSV';
private int $depot = 0;
private int $verrechnung = 0;
/** Ein Ausschnitt im Format des echten DKB-Exports – samt BOM. */
private const CSV = "\xEF\xBB\xBF"
. "Datum der Erstellung;Depotnummer;Wertpapierbezeichnung;WKN;ISIN;Einstiegskurs;"
. "Bewertungskurs;Stückzahl;Absoluter Gewinn;Relativer Gewinn;Assetklasse\r\n"
. "12.08.2026;502022353;ALLIANZ SE NA O.N.;840400;DE0008404005;\"224,60 €\";"
. "\"437,30 €\";60;\"12.762,10 €\";94.7%;Aktien\r\n"
. "12.08.2026;502022353;MUL-AMUN ST600 BANK ETF A;LYX01W;LU1834983477;\"58,36 €\";"
. "\"75,19 €\";\"55,6863\";\"937,05 €\";28.83%;ETFs\r\n"
. "12.08.2026;502022353;\"META PLATF. A DL-,000006\";A1JWVX;US30303M1027;\"648,33 €\";"
. "\"522,50 €\";3;\"-377,50 €\";-19.41%;Aktien\r\n";
public function run(): void
{
$this->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');
}
}