Finanzmanager/tests/21_DepotbestandImportTest.php
fds2610-bot 4484ddd90d 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>
2026-08-12 14:26:50 +02:00

202 lines
9.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

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

<?php
declare(strict_types=1);
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');
}
}