Finanzmanager/tests/25_WertpapiergeschaeftTest.php
fds2610-bot e56c21bc9b Wertpapiergeschäft aus einer Kontobuchung
Der Bankabruf bringt die Wertpapierabrechnung als Umsatz auf dem Girokonto, und
erst dort entscheidet sich, dass es ein Kauf war. Wer die Buchung einem Depot
zuordnete, buchte bisher eine Umbuchung – ohne Stückzahl, ohne Kurs, ohne
Einstand. Die Zuordnungsliste im Kontoblatt kennt jetzt neben Kategorie und
Gegenkonto eine dritte Gruppe "Wertpapiergeschäft im Depot", die statt zu
speichern die Wertpapiermaske öffnet; ohne JavaScript führt derselbe Weg auf
/buchungen/{id}/wertpapier.

Die Maske ist auf den "Wertpapier kaufen"-Dialog des FinanzManagers
zugeschnitten: vorne Vorgangsart, Datum, Wertpapier, Stück und Kurs, dahinter
hinter dem Knopf "Kosten …" die Nebenkosten nach Arten – sechs Gebühren und
sechs Steuern. ISIN und WKN werden aus dem Verwendungszweck gelesen, ein
bekanntes Papier ist damit vorausgewählt. Ein Abfluss vom Konto kann nur ein
Kauf sein, ein Zufluss ein Verkauf oder eine Dividende.

Der Buchungsbetrag ist die feste Größe: Die Nebenkosten werden aus ihm
herausgerechnet, was übrig bleibt ist der Kurswert und damit der
Einstandspreis. Im Kontoblatt entsteht eine Splitbuchung mit je einer Zeile pro
belegter Kostenart und dem Rest als Umbuchung ins Depot; ohne Nebenkosten
bleibt es bei der schlichten Umbuchung. Die Depotbuchung hängt sich an die
vorhandene Buchung, statt eine zweite anzulegen (neue Spalte
depot_buchungen.buchung_erzeugt) – wird sie gelöscht, bleibt die Buchung der
Bank stehen und ist nur wieder unzugeordnet.

Die Kategorien der Kostenarten liegen im Einstellungsdialog unter "Wertpapiere",
einmalig je Art; für "Sonstige Kosten" ist die Einstellung nur die Vorgabe und
im Dialog überschreibbar. Arten ohne Kategorie werden zu Splitzeilen ohne
Zuordnung, nicht zu einem Fehler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:27:52 +02:00

384 lines
17 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\Controllers\BuchungenController;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\Depot;
use Finanzmanager\Services\Wertpapiergeschaeft;
use Finanzmanager\Services\Wertpapierkosten;
/**
* Das Wertpapiergeschäft aus einer Kontobuchung heraus: Der Bankabruf bringt die
* Abrechnung aufs Girokonto, die Zuordnung auf ein Depot macht daraus die
* Depotbuchung samt Aufteilung der Nebenkosten.
*/
class WertpapiergeschaeftTest extends TestCase
{
protected string $testName = 'Test 25: Wertpapiergeschäft aus einer Kontobuchung';
private int $giro = 0;
private int $depot = 0;
private int $katGebuehren = 0;
private int $katSteuern = 0;
private int $katSonstige = 0;
public function run(): void
{
$this->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');
}
}