mandantAnlegen(); $this->stammdaten(); $this->wertpapierWirdWiedergefunden(); $this->kaufErhoehtBestandUndEinstand(); $this->geldseiteLandetAufDemVerrechnungskonto(); $this->verkaufRealisiertDenGewinn(); $this->dividendeAendertDenBestandNicht(); $this->ohneKursKeineBewertung(); $this->veraeusserungenRechnenUeberDieHistorie(); $this->qifImportLegtDepotbuchungenAn(); $this->loeschenNimmtDieGeldbuchungMit(); } private function stammdaten(): void { $this->depot = Konto::anlegen([ 'name' => 'Testdepot', 'kontoart' => 'depot', 'anfangssaldo' => 0, 'aktiv' => 1, ]); $this->verrechnung = Konto::anlegen([ 'name' => 'Testverrechnungskonto', 'kontoart' => 'bank', 'anfangssaldo' => 10000, 'aktiv' => 1, ]); $this->papier = Wertpapier::findenOderAnlegen([ 'name' => 'Testaktie AG', 'isin' => 'DE0001234567', 'wkn' => '123456', 'art' => 'aktie', ]); } private function wertpapierWirdWiedergefunden(): void { // Über die ISIN, auch wenn der Name abweicht – Banken schreiben ihn // unterschiedlich lang. $nochmal = Wertpapier::findenOderAnlegen([ 'name' => 'Testaktie AG Namensaktien o.N.', 'isin' => 'DE0001234567', ]); $this->assertEquals($this->papier, $nochmal, 'Dieselbe ISIN ist dasselbe Papier'); // Über die WKN, wenn keine ISIN mitkommt. $ueberWkn = Wertpapier::findenOderAnlegen(['name' => 'Irgendwas', 'wkn' => '123456']); $this->assertEquals($this->papier, $ueberWkn, 'Auch die WKN findet es wieder'); // Ein wirklich anderes Papier bekommt einen eigenen Datensatz. $anderes = Wertpapier::findenOderAnlegen(['name' => 'Zweitaktie SE', 'isin' => 'US0009876543']); $this->assert($anderes !== $this->papier, 'Andere ISIN, anderes Papier'); // Fehlende Angaben werden nachgetragen, vorhandene nicht überschrieben. $ohneIsin = Wertpapier::findenOderAnlegen(['name' => 'Drittaktie NV']); Wertpapier::findenOderAnlegen(['name' => 'Drittaktie NV', 'isin' => 'NL0001111111']); $this->assertEquals( 'NL0001111111', Wertpapier::find($ohneIsin)['isin'], 'Eine später gelieferte ISIN wird nachgetragen' ); } private function kaufErhoehtBestandUndEinstand(): void { // 10 Stück zu 100 € plus 10 € Gebühren. Depotbuchung::anlegen([ 'konto_id' => $this->depot, 'wertpapier_id' => $this->papier, 'datum' => '2026-01-15', 'vorgang' => 'kauf', 'stueck' => 10, 'kurs' => 100, 'betrag' => 1000, 'gebuehren' => 10, 'verrechnungskonto_id' => $this->verrechnung, ]); $bestand = Depot::bestand($this->depot, '2026-12-31'); $this->assertEquals(1, count($bestand['positionen']), 'Eine Position im Depot'); $position = $bestand['positionen'][0]; $this->assertEquals(10.0, $position['stueck'], 'Zehn Stück im Bestand'); $this->assertEquals(1010.0, $position['einstand'], 'Die Gebühren gehören zum Einstand'); $this->assertEquals(101.0, $position['einstand_je_stueck'], 'Also 101 € je Stück'); // Zweiter Kauf, teurer: Der Durchschnitt wandert nach oben. Depotbuchung::anlegen([ 'konto_id' => $this->depot, 'wertpapier_id' => $this->papier, 'datum' => '2026-02-15', 'vorgang' => 'kauf', 'stueck' => 10, 'kurs' => 120, 'betrag' => 1200, 'gebuehren' => 10, 'verrechnungskonto_id' => $this->verrechnung, ]); $position = Depot::bestand($this->depot, '2026-12-31')['positionen'][0]; $this->assertEquals(20.0, $position['stueck'], 'Jetzt zwanzig Stück'); $this->assertEquals(2220.0, $position['einstand'], 'Einstand ist die Summe beider Käufe'); $this->assertEquals(111.0, $position['einstand_je_stueck'], 'Im Schnitt 111 € je Stück'); } private function geldseiteLandetAufDemVerrechnungskonto(): void { // Zwei Käufe: 1010 + 1210 = 2220 vom Verrechnungskonto. $saldo = Konto::saldo($this->verrechnung, '2026-12-31'); $this->assertEquals(10000.0 - 2220.0, $saldo, 'Beide Käufe haben das Konto belastet'); $buchungen = Depotbuchung::liste(['konto_id' => $this->depot]); $this->assertNotEmpty($buchungen[0]['buchung_id'], 'Jede Depotbuchung hat ihre Geldbuchung'); $geld = Buchung::find((int)$buchungen[0]['buchung_id']); $this->assertEquals( -1210.0, (float)$geld['betrag'], 'Beim Kauf wird Kurswert plus Gebühren belastet' ); } private function verkaufRealisiertDenGewinn(): void { // 10 von 20 Stück zu 150 €, 15 € Gebühren, 20 € Steuern. Depotbuchung::anlegen([ 'konto_id' => $this->depot, 'wertpapier_id' => $this->papier, 'datum' => '2026-03-15', 'vorgang' => 'verkauf', 'stueck' => 10, 'kurs' => 150, 'betrag' => 1500, 'gebuehren' => 15, 'steuern' => 20, 'verrechnungskonto_id' => $this->verrechnung, ]); $bestand = Depot::bestand($this->depot, '2026-12-31'); $position = $bestand['positionen'][0]; $this->assertEquals(10.0, $position['stueck'], 'Zehn Stück bleiben liegen'); // Die Hälfte des Einstands geht mit: 2220 / 2 = 1110. $this->assertEquals(1110.0, $position['einstand'], 'Der halbe Einstand ist abgegangen'); $this->assertEquals(111.0, $position['einstand_je_stueck'], 'Der Durchschnitt bleibt gleich'); // Erlös 1500 − 35 Kosten = 1465, abzüglich 1110 Einstand. $this->assertEquals(355.0, $bestand['realisiert'], 'Realisierter Gewinn nach Kosten'); $saldo = Konto::saldo($this->verrechnung, '2026-12-31'); $this->assertEquals(10000.0 - 2220.0 + 1465.0, $saldo, 'Die Gutschrift kam an'); } private function dividendeAendertDenBestandNicht(): void { Depotbuchung::anlegen([ 'konto_id' => $this->depot, 'wertpapier_id' => $this->papier, 'datum' => '2026-04-01', 'vorgang' => 'dividende', 'betrag' => 50, 'steuern' => 13.19, 'verrechnungskonto_id' => $this->verrechnung, ]); $bestand = Depot::bestand($this->depot, '2026-12-31'); $this->assertEquals(10.0, $bestand['positionen'][0]['stueck'], 'Der Bestand bleibt unberührt'); $this->assertEquals(36.81, $bestand['ertraege'], 'Der Ertrag ist die Gutschrift nach Steuer'); $ertraege = Depot::ertraege('2026-01-01', '2026-12-31', $this->depot); $this->assertEquals(36.81, $ertraege['summe'], 'Die Ertragsauswertung zeigt dasselbe'); $this->assertEquals(1, count($ertraege['zeilen']), 'Eine Zeile je Wertpapier'); } private function ohneKursKeineBewertung(): void { $bestand = Depot::bestand($this->depot, '2026-12-31'); $this->assert(!$bestand['bewertbar'], 'Ohne Kurs ist der Bestand nicht bewertbar'); $this->assertNull($bestand['positionen'][0]['wert'], 'Und die Position hat keinen Wert'); $this->assertEquals(0.0, $bestand['wert'], 'Die Summe bleibt bei null statt zu raten'); Wertpapier::kursSetzen($this->papier, '2026-06-30', 130.0, 'test'); $bestand = Depot::bestand($this->depot, '2026-12-31'); $this->assert($bestand['bewertbar'], 'Mit Kurs ist er bewertbar'); $this->assertEquals(1300.0, $bestand['wert'], 'Zehn Stück zu 130 €'); $this->assertEquals(190.0, $bestand['gewinn'], 'Wert minus Einstand von 1110'); // Ein älterer Kurs gilt weiter, bis ein neuerer kommt. $this->assertEquals( 130.0, (float)Wertpapier::letzterKurs($this->papier, '2026-08-01')['kurs'], 'Der letzte bekannte Kurs vor dem Stichtag zählt' ); $this->assertNull( Wertpapier::letzterKurs($this->papier, '2026-01-01'), 'Vor dem ersten Kurs gibt es keinen' ); Wertpapier::kursSetzen($this->papier, '2026-06-30', 140.0, 'test'); $this->assertEquals( 140.0, (float)Wertpapier::letzterKurs($this->papier)['kurs'], 'Ein zweiter Kurs am selben Tag ersetzt den ersten' ); } private function veraeusserungenRechnenUeberDieHistorie(): void { // Der Verkauf lag im März; der Einstand hängt an den Käufen im Januar // und Februar, die außerhalb des Berichtszeitraums liegen. $maerz = Depot::veraeusserungen('2026-03-01', '2026-03-31', $this->depot); $this->assertEquals(1, count($maerz['geschaefte']), 'Ein Verkauf im März'); $this->assertEquals(1465.0, $maerz['erloes'], 'Erlös nach Kosten'); $this->assertEquals(1110.0, $maerz['geschaefte'][0]['einstand'], 'Einstand aus den Käufen davor'); $this->assertEquals(355.0, $maerz['gewinn'], 'Und derselbe Gewinn wie in der Übersicht'); $februar = Depot::veraeusserungen('2026-02-01', '2026-02-28', $this->depot); $this->assertEquals([], $februar['geschaefte'], 'Im Februar wurde nichts verkauft'); } private function qifImportLegtDepotbuchungenAn(): void { // Ein Ausschnitt im Format des echten Exports: Kauf, Verkauf, Dividende. $qif = "!Option:MDY\r\n!Account\r\nNImportdepot\r\nTInvst\r\n^\r\n" . "!Type:Invst\r\n" . "D7.3.26\r\nV7.5.26\r\nNKauf\r\nFEUR\r\nMWERTPAPIERE TESTPAPIER\r\n" . "YTestpapier NV\r\n~A1J4U4\r\n@NL0010273215\r\n&1\r\n" . "I100.00000000\r\nQ4.000000\r\nU403.90\r\n" . "O0.00|0.00|0.00|3.90|0.00|0.00|0.00|0.00|0.00|0.00|0.00|0.00\r\n" . "L|[Importverrechnung]\r\n\$403.90\r\n^\r\n" . "D8.10.26\r\nV8.14.26\r\nNDivAus\r\nFEUR\r\nMERTRAEGNISGUTSCHRIFT\r\n" . "YTestpapier NV\r\n~A1J4U4\r\n@NL0010273215\r\n&1\r\n" . "Q0.000000\r\nU4.32\r\n" . "O0.00|0.58|0.00|0.00|0.00|0.87|0.00|0.00|0.03|0.00|0.00|0.00\r\n" . "L|[Importverrechnung]\r\n\$4.32\r\n^\r\n" . "D9.1.26\r\nV9.3.26\r\nNVerkauf\r\nFEUR\r\nMVERKAUF\r\n" . "YTestpapier NV\r\n~A1J4U4\r\n@NL0010273215\r\n&1\r\n" . "I150.00000000\r\nQ2.000000\r\nU287.13\r\n" . "O0.00|9.37|0.00|3.50|0.00|0.00|0.00|0.00|0.00|0.00|0.00|0.00\r\n" . "LKursgewinne:Realisierte Gewinne|[Importverrechnung]\r\n\$287.13\r\n^\r\n"; $depotId = Konto::anlegen(['name' => 'Importdepot', 'kontoart' => 'depot', 'aktiv' => 1]); Konto::anlegen(['name' => 'Importverrechnung', 'kontoart' => 'bank', 'aktiv' => 1]); $import = new QifBuchungsImport( new QifParser($qif), ['stichtag' => '2026-01-01', 'bis' => '2026-12-31'] ); $ergebnis = $import->ausfuehren(); $this->assertEquals(3, $ergebnis['buchungen']['wertpapier'], 'Drei Wertpapiergeschäfte übernommen'); $buchungen = Depotbuchung::liste(['konto_id' => $depotId]); $this->assertEquals(3, count($buchungen), 'Drei Depotbuchungen'); $nachVorgang = array_column($buchungen, null, 'vorgang'); // Kauf: 4 × 100 = 400 Kurswert, 3,90 Gebühren → 403,90 belastet. $kauf = $nachVorgang['kauf']; $this->assertEquals(4.0, (float)$kauf['stueck'], 'Vier Stück gekauft'); $this->assertEquals(400.0, (float)$kauf['betrag'], 'Kurswert ohne Nebenkosten'); $this->assertEquals(3.90, (float)$kauf['gebuehren'], 'Provision als Gebühr erkannt'); $this->assertEquals('Kauf', $kauf['vorgang_qif'], 'Die Bezeichnung des FinanzManagers bleibt erhalten'); // Dividende: Gutschrift 4,32 plus 1,48 Abzüge = 5,80 brutto. $dividende = $nachVorgang['dividende']; $this->assertEquals(0.0, (float)$dividende['stueck'], 'Eine Dividende bewegt keine Stücke'); $this->assertEquals(5.80, (float)$dividende['betrag'], 'Bruttoertrag zurückgerechnet'); $this->assertEquals(1.48, (float)$dividende['steuern'], 'Die Abzüge sind Steuern'); $this->assertEquals('DivAus', $dividende['vorgang_qif'], 'DivAus bleibt als Herkunft vermerkt'); // Verkauf: 2 × 150 = 300, minus 3,50 Gebühren und 9,37 Steuern = 287,13. $verkauf = $nachVorgang['verkauf']; $this->assertEquals(-2.0, (float)$verkauf['stueck'], 'Der Abgang zählt negativ'); $this->assertEquals(300.0, (float)$verkauf['betrag'], 'Kurswert des Verkaufs'); // Die Geldseite: −403,90 + 4,32 + 287,13 = −112,45. $saldo = Konto::saldo(Konto::findeNachName('Importverrechnung')['id'], '2026-12-31'); $this->assertEquals(-112.45, round($saldo, 2), 'Alle drei Geldflüsse mit richtigem Vorzeichen'); // Ein zweiter Lauf darf nichts verdoppeln. $zweiter = (new QifBuchungsImport( new QifParser($qif), ['stichtag' => '2026-01-01', 'bis' => '2026-12-31'] ))->ausfuehren(); $this->assertEquals(0, $zweiter['buchungen']['wertpapier'], 'Der zweite Import legt nichts an'); $this->assertEquals(3, count(Depotbuchung::liste(['konto_id' => $depotId])), 'Es bleiben drei'); // Und der Bestand stimmt: 4 gekauft, 2 verkauft. $bestand = Depot::bestand($depotId, '2026-12-31'); $this->assertEquals(2.0, $bestand['positionen'][0]['stueck'], 'Zwei Stück im Bestand'); $this->assertEquals(201.95, $bestand['positionen'][0]['einstand'], 'Halber Einstand von 403,90'); } private function loeschenNimmtDieGeldbuchungMit(): void { $id = Depotbuchung::anlegen([ 'konto_id' => $this->depot, 'wertpapier_id' => $this->papier, 'datum' => '2026-05-02', 'vorgang' => 'kauf', 'stueck' => 1, 'kurs' => 100, 'betrag' => 100, 'verrechnungskonto_id' => $this->verrechnung, ]); $buchungId = (int)Depotbuchung::find($id)['buchung_id']; $this->assertNotEmpty($buchungId, 'Die Geldbuchung wurde angelegt'); Depotbuchung::loeschen($id); $this->assertNull(Depotbuchung::find($id), 'Die Depotbuchung ist weg'); $this->assertNull(Buchung::find($buchungId), 'Und ihre Geldbuchung auch'); } }