Damit ist der geplante Funktionsumfang beisammen. Phase 4 abgeschlossen - Konfigurierbare Cockpit-Kacheln; abgewählte werden nicht berechnet - Einnahmen/Ausgaben je Monat als Balkenpaare mit Saldolinie, aufgesetzt auf den Summenbericht statt auf eine eigene Abfrage - Meine Ausgaben als Ring auf der obersten Kategorieebene - Offene Vorgänge aus allen Modulen Phase 5 – Online-Banking - nemiah/php-fints als Fork auf Codeberg, psr/log direkt eingebaut: die Anwendung hängt an keinem fremden Update-Zyklus - PIN AES-256-GCM-verschlüsselt; Schlüssel aus APP_KEY oder aus data/schluessel/app_key, den der Entrypoint einmalig erzeugt - TAN-Dialog über mehrere Requests (FinTs::persist plus serialisierte Aktion in der Sitzung) – ein wartender Prozess wäre mit php-fpm der falsche Weg - Abgleich statt Direktbuchung: verbinden, übernehmen oder verwerfen - Regeln mit vier Vergleichsarten, Betragsgrenzen und Split-Schema Phase 6 – Wertpapiere - Depotbuchung und Geldbuchung als zwei Seiten eines Geschäfts; betrag ist der Bruttowert, Nebenkosten stehen daneben - Einstand nach gleitendem Durchschnitt, realisierte Gewinne, Erträge - Kursabruf über Leeway oder FMP, täglicher Lauf per Cron - QIF-Import für !Type:Invst Dabei behoben: Der Import buchte den Geldfluss jedes Wertpapiergeschäfts als Abgang – auch bei Verkäufen und Dividenden; und er erkannte das Verrechnungskonto nicht, wenn dem Konto im L-Feld eine Kategorie vorangeht (bei Verkäufen also immer). Phase 7 – Nebenmodule und Zahlungsverkehr - Buchungsvorlagen inklusive QIF-Import, Splits werden mitskaliert - Regelmäßige Buchungen mit Fälligkeitslauf, Finanzkalender, Erinnerungen - Adressen mit IBAN-Prüfung, Verträge mit Kündigungsfrist, Sparziele - Kredite mit gerechnetem Tilgungsplan - Überweisungen über FinTS; die Buchung entsteht erst nach Annahme durch die Bank. Die pain.001-Nachricht erzeugt SepaXml selbst Nebenbei: .dockerignore ergänzt (ohne sie brach der Build an data/mariadb ab), PlatzhalterController entfernt, zwei Inline-Handler beseitigt, die die Content-Security-Policy ohnehin blockierte. 888 PHP-Prüfungen und 45 E2E-Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
315 lines
15 KiB
PHP
315 lines
15 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Finanzmanager\Models\Buchung;
|
||
use Finanzmanager\Models\Depotbuchung;
|
||
use Finanzmanager\Models\Konto;
|
||
use Finanzmanager\Models\Wertpapier;
|
||
use Finanzmanager\Services\Depot;
|
||
use Finanzmanager\Services\QifBuchungsImport;
|
||
use Finanzmanager\Services\QifParser;
|
||
|
||
/**
|
||
* Depots: Bestandsrechnung, Einstand nach gleitendem Durchschnitt, realisierte
|
||
* Gewinne, Erträge und der QIF-Import von Wertpapiergeschäften.
|
||
*/
|
||
class WertpapiereTest extends TestCase
|
||
{
|
||
protected string $testName = 'Test 17: Wertpapiere und Depots';
|
||
|
||
private int $depot = 0;
|
||
private int $verrechnung = 0;
|
||
private int $papier = 0;
|
||
|
||
public function run(): void
|
||
{
|
||
$this->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');
|
||
}
|
||
}
|