mandantAnlegen(); $this->stammdaten(); $this->depotAusDerAuswahlliste(); $this->kennungenAusDemVerwendungszweck(); $this->kaufMitKosten(); $this->kaufOhneKosten(); $this->verkaufMitSteuern(); $this->loeschenLaesstDieBankbuchungStehen(); $this->pruefungen(); } private function stammdaten(): void { $this->giro = Konto::anlegen([ 'name' => 'WP Giro', 'kontoart' => 'bank', 'anfangssaldo' => 10000, 'aktiv' => 1, ]); $this->depot = Konto::anlegen([ 'name' => 'WP Depot', 'kontoart' => 'depot', 'anfangssaldo' => 0, 'aktiv' => 1, ]); $this->katGebuehren = Kategorie::anlegen(['name' => 'WP Bankprovision', 'art' => 'ausgabe']); $this->katSteuern = Kategorie::anlegen(['name' => 'WP Kapitalertragsteuer', 'art' => 'ausgabe']); $this->katSonstige = Kategorie::anlegen(['name' => 'WP Sonstiges', 'art' => 'ausgabe']); Einstellung::set( Wertpapierkosten::schluessel('bankprovision'), (string)$this->katGebuehren, 'wertpapiere' ); Einstellung::set( Wertpapierkosten::schluessel('kapitalertragsteuer'), (string)$this->katSteuern, 'wertpapiere' ); } private function buchung(float $betrag, string $datum, string $zweck = ''): int { return Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => $datum, 'betrag' => $betrag, 'empfaenger' => 'Wertpapierabrechnung', 'buchungstext' => $zweck, 'quelle' => 'banking', ]); } /** Das Depot steht als eigener Wert in der Zuordnungsliste – `depot:7`. */ private function depotAusDerAuswahlliste(): void { $this->assertEquals(7, BuchungenController::depotAus('depot:7'), 'Ein Depot wird erkannt'); $this->assertNull(BuchungenController::depotAus('konto:7'), 'Eine Umbuchung ist kein Depotgeschäft'); $this->assertNull(BuchungenController::depotAus('kategorie:7'), 'Eine Kategorie erst recht nicht'); $this->assertNull(BuchungenController::depotAus('depot:0'), 'Kennung 0 zählt nicht'); $this->assertNull(BuchungenController::depotAus(''), 'Leer heißt keine Auswahl'); // Und der bisherige Weg bleibt unberührt: Ein Depot ist weiterhin ein // gewöhnliches Gegenkonto, wenn es als `konto:` daherkommt. $this->assertEquals( ['kategorie_id' => null, 'gegenkonto_id' => 7], BuchungenController::zuordnungAus('konto:7'), 'Die Umbuchung auf ein Depot gibt es weiterhin' ); $this->assertEquals( ['kategorie_id' => null, 'gegenkonto_id' => null], BuchungenController::zuordnungAus('depot:7'), 'Ein Depotgeschäft ist selbst noch keine Zuordnung' ); } /** ISIN und WKN stehen im Verwendungszweck der Abrechnung. */ private function kennungenAusDemVerwendungszweck(): void { $zweck = 'Depot 0502022353 Wertp.Abrechn. 03.08.2026 000007239846100 WKN LYX01W ' . 'Gesch.Art KV MUL-AMUN ST600 BANK ETF A ISIN LU1834983477 Ihr Wertpapie'; $gelesen = Wertpapier::ausText($zweck); $this->assertEquals('LU1834983477', $gelesen['isin'], 'Die ISIN wird gefunden'); $this->assertEquals('LYX01W', $gelesen['wkn'], 'Die WKN auch'); $leer = Wertpapier::ausText('Miete August 2026'); $this->assertEquals('', $leer['isin'], 'Ohne Wertpapier keine ISIN'); $this->assertEquals('', $leer['wkn'], 'Und keine WKN'); // Eine sechsstellige Belegnummer ist keine WKN – ohne das Wort davor // bleibt das Feld leer. $this->assertEquals( '', Wertpapier::ausText('Beleg 4711AB vom 01.08.2026')['wkn'], 'Sechs Zeichen allein machen noch keine WKN' ); } /** * Der Fall aus dem Dialog: 255,00 € gehen vom Girokonto ab, davon 4,00 € * Bankprovision und 1,00 € sonstige Kosten. Als Einstand bleiben 250,00 €. */ private function kaufMitKosten(): void { $id = $this->buchung(-255.0, '2026-08-03', 'WKN LYX01W ISIN LU1834983477 Kauf'); $ergebnis = Wertpapiergeschaeft::buchen(Buchung::find($id), [ 'depot_id' => $this->depot, 'wertpapier_name' => 'LYXOR IF-LY.ST.EUR.600 BANKS', 'isin' => 'LU1834983477', 'wkn' => 'LYX01W', 'art' => 'fonds', 'stueck' => 3.3593, 'kurs' => 74.42026613, 'kosten' => ['bankprovision' => 4.0, 'sonstige' => 1.0], 'kosten_sonstige_kategorie' => $this->katSonstige, ]); $this->assertEquals('kauf', $ergebnis['vorgang'], 'Ein Abfluss ist ein Kauf'); $this->assertEquals(250.0, $ergebnis['betrag'], 'Der Einstandspreis ist der Rest nach den Kosten'); $depotbuchung = Depotbuchung::find($ergebnis['depotbuchung_id']); $this->assertEquals(250.0, (float)$depotbuchung['betrag'], 'So steht er auch in der Depotbuchung'); $this->assertEquals(5.0, (float)$depotbuchung['gebuehren'], 'Gebühren daneben, nicht darin'); $this->assertEquals(0.0, (float)$depotbuchung['steuern'], 'Ohne Steuern'); $this->assertEquals(3.3593, (float)$depotbuchung['stueck'], 'Die Stückzahl'); $this->assertEquals( $id, (int)$depotbuchung['buchung_id'], 'Die Depotbuchung hängt an der vorhandenen Buchung' ); $this->assertEquals( 0, (int)$depotbuchung['buchung_erzeugt'], 'Sie hat die Geldbuchung nicht selbst angelegt' ); $this->assertEquals( $this->giro, (int)$depotbuchung['verrechnungskonto_id'], 'Das Verrechnungskonto ist das Girokonto' ); // Keine zweite Buchung neben der echten. $this->assertEquals( 1, count(array_filter( Buchung::kontoblatt($this->giro), static fn(array $zeile): bool => abs((float)$zeile['betrag'] + 255.0) < 0.005 )), 'Es bleibt bei der einen Buchung des Bankabrufs' ); // Die Aufteilung: der Rest ins Depot, jede Kostenart in ihre Kategorie. $splits = Buchung::splits($id); $this->assertEquals(3, count($splits), 'Drei Splitzeilen: Depot, Bankprovision, Sonstiges'); $this->assertEquals($this->depot, (int)$splits[0]['gegenkonto_id'], 'Die erste Zeile geht ins Depot'); $this->assertEquals(-250.0, (float)$splits[0]['betrag'], 'Mit dem Kurswert'); $this->assertEquals($this->katGebuehren, (int)$splits[1]['kategorie_id'], 'Die Bankprovision in ihrer Kategorie'); $this->assertEquals(-4.0, (float)$splits[1]['betrag'], 'Nebenkosten sind Aufwand'); $this->assertEquals($this->katSonstige, (int)$splits[2]['kategorie_id'], 'Sonstige Kosten in der gewählten Kategorie'); $summe = array_sum(array_map(static fn(array $z): float => (float)$z['betrag'], $splits)); $this->assertEquals(-255.0, round($summe, 2), 'Die Zeilen ergeben genau den Buchungsbetrag'); // Das Papier wurde nebenbei angelegt und ist im Depot bewertbar. $bestand = Depot::bestand($this->depot); $this->assertEquals(255.0, round($bestand['einstand'], 2), 'Der Einstand enthält die Nebenkosten genau einmal'); // Ein zweites Geschäft zur selben Buchung wird abgewiesen. $doppelt = false; try { Wertpapiergeschaeft::buchen(Buchung::find($id), [ 'depot_id' => $this->depot, 'wertpapier_name' => 'Zweites', 'stueck' => 1, ]); } catch (\Throwable $e) { $doppelt = true; } $this->assert($doppelt, 'Zu einer Buchung gibt es nur ein Wertpapiergeschäft'); } /** Ohne Nebenkosten bleibt es bei der schlichten Umbuchung ins Depot. */ private function kaufOhneKosten(): void { $id = $this->buchung(-1000.0, '2026-08-04'); $ergebnis = Wertpapiergeschaeft::buchen(Buchung::find($id), [ 'depot_id' => $this->depot, 'wertpapier_name' => 'Ohne Nebenkosten AG', 'isin' => 'DE0001234567', 'stueck' => 10, ]); $this->assert(!$ergebnis['gesplittet'], 'Ohne Kosten wird nicht gesplittet'); $this->assertEquals(1000.0, $ergebnis['betrag'], 'Der ganze Betrag ist Einstand'); $buchung = Buchung::find($id); $this->assertEquals(0, (int)$buchung['gesplittet'], 'Die Buchung ist keine Splitbuchung'); $this->assertEquals($this->depot, (int)$buchung['gegenkonto_id'], 'Sondern eine Umbuchung ins Depot'); $depotbuchung = Depotbuchung::find($ergebnis['depotbuchung_id']); $this->assertEquals(100.0, (float)$depotbuchung['kurs'], 'Der fehlende Kurs ergibt sich aus Betrag und Stück'); } /** * Der Verkauf aus der Modelldokumentation: 4 × 797,00 € brutto, 15,37 € * Gebühren, 69,46 € Steuern – gutgeschrieben werden 3.103,17 €. */ private function verkaufMitSteuern(): void { $id = $this->buchung(3103.17, '2026-08-05'); $ergebnis = Wertpapiergeschaeft::buchen(Buchung::find($id), [ 'depot_id' => $this->depot, 'wertpapier_name' => 'Verkaufte AG', 'isin' => 'DE0007654321', 'stueck' => 4, 'kosten' => ['bankprovision' => 15.37, 'kapitalertragsteuer' => 69.46], ]); $this->assertEquals('verkauf', $ergebnis['vorgang'], 'Ein Zufluss ist ein Verkauf'); $this->assertEquals(3188.0, $ergebnis['betrag'], 'Der Bruttoerlös ist die Gutschrift plus Nebenkosten'); $depotbuchung = Depotbuchung::find($ergebnis['depotbuchung_id']); $this->assertEquals(-4.0, (float)$depotbuchung['stueck'], 'Ein Abgang zählt negativ'); $this->assertEquals(797.0, (float)$depotbuchung['kurs'], 'Der Kurs ergibt sich aus Erlös und Stück'); $this->assertEquals(15.37, (float)$depotbuchung['gebuehren'], 'Gebühren getrennt von den Steuern'); $this->assertEquals(69.46, (float)$depotbuchung['steuern'], 'Und die Steuern für sich'); $splits = Buchung::splits($id); $this->assertEquals(3188.0, (float)$splits[0]['betrag'], 'Die Depotzeile trägt den Bruttoerlös'); $summe = array_sum(array_map(static fn(array $z): float => (float)$z['betrag'], $splits)); $this->assertEquals(3103.17, round($summe, 2), 'Und die Zeilen ergeben wieder die Gutschrift'); // Kosten so hoch wie die Buchung ergeben kein Geschäft. $abgewiesen = false; try { Wertpapiergeschaeft::buchen($this->buchungAls(-50.0, '2026-08-06'), [ 'depot_id' => $this->depot, 'wertpapier_name' => 'Zu teuer AG', 'stueck' => 1, 'kosten' => ['spesen' => 50.0], ]); } catch (\Throwable $e) { $abgewiesen = true; } $this->assert($abgewiesen, 'Nebenkosten in Höhe der Buchung werden abgewiesen'); } /** * Wird das Geschäft rückgängig gemacht, bleibt die Buchung der Bank stehen – * sie ist kein Erzeugnis dieses Moduls. Nur ihre Aufteilung geht mit. */ private function loeschenLaesstDieBankbuchungStehen(): void { $id = $this->buchung(-330.0, '2026-08-07'); $ergebnis = Wertpapiergeschaeft::buchen(Buchung::find($id), [ 'depot_id' => $this->depot, 'wertpapier_name' => 'Wieder weg AG', 'isin' => 'DE0009999999', 'stueck' => 3, 'kosten' => ['spesen' => 30.0], ]); $this->assertEquals(1, (int)Buchung::find($id)['gesplittet'], 'Die Buchung ist zunächst aufgeteilt'); Depotbuchung::loeschen($ergebnis['depotbuchung_id']); $buchung = Buchung::find($id); $this->assertNotEmpty($buchung, 'Die Buchung des Bankabrufs bleibt stehen'); $this->assertEquals(-330.0, (float)$buchung['betrag'], 'Mit unverändertem Betrag'); $this->assertEquals(0, (int)$buchung['gesplittet'], 'Aber ohne die Aufteilung des Geschäfts'); $this->assertEquals([], Buchung::splits($id), 'Die Splitzeilen sind weg'); $this->assertNull($buchung['gegenkonto_id'], 'Und ohne Zuordnung – sie ist wieder offen'); } /** Eine Buchung anlegen und gleich als Datensatz zurückgeben. */ private function buchungAls(float $betrag, string $datum): array { return Buchung::find($this->buchung($betrag, $datum)); } private function pruefungen(): void { // Die Kostenarten des Dialogs und ihre Einstellungsschlüssel. $arten = Wertpapierkosten::arten(); $this->assertEquals(12, count($arten), 'Sechs Gebühren- und sechs Steuerarten'); $this->assert(isset($arten['bankprovision']), 'Die Bankprovision ist dabei'); $this->assert(Wertpapierkosten::istSteuer('kirchensteuer'), 'Die Kirchensteuer zählt als Steuer'); $this->assert(!Wertpapierkosten::istSteuer('spesen'), 'Spesen nicht'); $this->assertEquals( 'wertpapiere_kategorie_spesen', Wertpapierkosten::schluessel('spesen'), 'Der Einstellungsschlüssel folgt einem festen Muster' ); $this->assertEquals( $this->katGebuehren, Wertpapierkosten::kategorien()['bankprovision'], 'Die hinterlegte Kategorie wird gefunden' ); $this->assertNull( Wertpapierkosten::kategorien()['kirchensteuer'], 'Ohne Hinterlegung bleibt die Kategorie leer' ); // Zeilen unter einem Cent zählen nicht. $this->assertEquals( [], Wertpapierkosten::zeilen(['spesen' => 0.0, 'maklercourtage' => 0.001]), 'Leere Beträge ergeben keine Splitzeile' ); // Die reine Rechnung, ohne Datenbank. $kauf = Wertpapiergeschaeft::aufteilung(-255.0, ['bankprovision' => 5.0]); $this->assertEquals(-250.0, $kauf['kurswert'], 'Beim Kauf gehen die Kosten vom Abfluss ab'); $verkauf = Wertpapiergeschaeft::aufteilung(3103.17, ['kapitalertragsteuer' => 84.83]); $this->assertEquals(3188.0, $verkauf['kurswert'], 'Beim Verkauf kommen sie zum Zufluss hinzu'); $this->assertEquals(84.83, $verkauf['steuern'], 'Und zählen als Steuern'); $this->assertEquals(0.0, $verkauf['gebuehren'], 'Nicht als Gebühren'); $this->assertEquals('kauf', Wertpapiergeschaeft::vorgang(-10.0), 'Ein Abfluss ist immer ein Kauf'); $this->assertEquals( 'kauf', Wertpapiergeschaeft::vorgang(-10.0, 'dividende'), 'Auch wenn die Maske etwas anderes sagt' ); $this->assertEquals('verkauf', Wertpapiergeschaeft::vorgang(10.0), 'Ein Zufluss ist ein Verkauf'); $this->assertEquals( 'dividende', Wertpapiergeschaeft::vorgang(10.0, 'dividende'), 'Oder eine Dividende, wenn die Maske es sagt' ); // Ohne Depot kein Geschäft. $ohneDepot = false; try { Wertpapiergeschaeft::buchen($this->buchungAls(-100.0, '2026-08-08'), [ 'depot_id' => $this->giro, 'wertpapier_name' => 'Kein Depot AG', 'stueck' => 1, ]); } catch (\Throwable $e) { $ohneDepot = true; } $this->assert($ohneDepot, 'Ein Girokonto ist kein Depot'); $ohneStueck = false; try { Wertpapiergeschaeft::buchen($this->buchungAls(-100.0, '2026-08-09'), [ 'depot_id' => $this->depot, 'wertpapier_name' => 'Ohne Stück AG', ]); } catch (\Throwable $e) { $ohneStueck = true; } $this->assert($ohneStueck, 'Ein Kauf ohne Stückzahl wird abgewiesen'); } }