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>
326 lines
12 KiB
PHP
326 lines
12 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Services;
|
||
|
||
use Finanzmanager\Database;
|
||
use Finanzmanager\Mandant;
|
||
use Finanzmanager\Models\Depotbuchung;
|
||
use Finanzmanager\Models\Konto;
|
||
use Finanzmanager\Models\Wertpapier;
|
||
|
||
/**
|
||
* Die Rechenmaschine hinter der Depotübersicht: aus Depotbuchungen und Kursen
|
||
* werden Bestände, Einstandswerte und die Wertentwicklung.
|
||
*
|
||
* **Einstandswert nach gleitendem Durchschnitt.** Bei jedem Kauf steigt der
|
||
* Einstand um Kaufpreis samt Gebühren, bei jedem Verkauf sinkt er anteilig um
|
||
* den bis dahin durchschnittlichen Stückpreis. Das ist die Rechnung, die auch
|
||
* der FinanzManager zeigt; sie kommt ohne die Frage aus, welches Stück
|
||
* eigentlich verkauft wurde. (Für die Steuer gilt in Deutschland FIFO – dafür
|
||
* ist die Bank zuständig, nicht diese Übersicht.)
|
||
*
|
||
* **Realisierte Gewinne** entstehen beim Verkauf: Erlös abzüglich des
|
||
* anteiligen Einstands.
|
||
*/
|
||
class Depot
|
||
{
|
||
/**
|
||
* Die Bestände eines Depots (oder aller Depots) zum Stichtag.
|
||
*
|
||
* @return array{
|
||
* positionen: list<array<string,mixed>>,
|
||
* einstand: float,
|
||
* wert: float,
|
||
* gewinn: float,
|
||
* realisiert: float,
|
||
* ertraege: float,
|
||
* bewertbar: bool
|
||
* }
|
||
*/
|
||
public static function bestand(?int $kontoId = null, ?string $stichtag = null): array
|
||
{
|
||
$stichtag ??= date('Y-m-d');
|
||
$kurse = Wertpapier::letzteKurse($stichtag);
|
||
|
||
$positionen = [];
|
||
$realisiert = 0.0;
|
||
$ertraege = 0.0;
|
||
|
||
foreach (self::buchungen($kontoId, $stichtag) as $buchung) {
|
||
$schluessel = (int)$buchung['wertpapier_id'] . ':' . (int)$buchung['konto_id'];
|
||
|
||
$position = $positionen[$schluessel] ?? [
|
||
'wertpapier_id' => (int)$buchung['wertpapier_id'],
|
||
'konto_id' => (int)$buchung['konto_id'],
|
||
'name' => (string)$buchung['wertpapier'],
|
||
'isin' => (string)$buchung['isin'],
|
||
'wkn' => (string)$buchung['wkn'],
|
||
'depot' => (string)$buchung['depot'],
|
||
'stueck' => 0.0,
|
||
'einstand' => 0.0,
|
||
'realisiert' => 0.0,
|
||
'ertraege' => 0.0,
|
||
];
|
||
|
||
$stueck = (float)$buchung['stueck'];
|
||
$betrag = (float)$buchung['betrag'];
|
||
$kosten = (float)$buchung['gebuehren'] + (float)$buchung['steuern'];
|
||
|
||
switch ((string)$buchung['vorgang']) {
|
||
case 'kauf':
|
||
case 'einlage':
|
||
// Nebenkosten gehören zum Einstand: Sie sind Teil dessen,
|
||
// was das Papier gekostet hat.
|
||
$position['stueck'] += $stueck;
|
||
$position['einstand'] += $betrag + $kosten;
|
||
break;
|
||
|
||
case 'verkauf':
|
||
case 'entnahme':
|
||
$abgang = abs($stueck);
|
||
$anteil = $position['stueck'] > 0.0
|
||
? min(1.0, $abgang / $position['stueck'])
|
||
: 1.0;
|
||
|
||
$anteiligerEinstand = round($position['einstand'] * $anteil, 2);
|
||
|
||
$position['stueck'] -= $abgang;
|
||
$position['einstand'] -= $anteiligerEinstand;
|
||
$position['realisiert'] += round($betrag - $kosten - $anteiligerEinstand, 2);
|
||
break;
|
||
|
||
case 'dividende':
|
||
$position['ertraege'] += $betrag - $kosten;
|
||
break;
|
||
}
|
||
|
||
$positionen[$schluessel] = $position;
|
||
}
|
||
|
||
$einstandGesamt = 0.0;
|
||
$wertGesamt = 0.0;
|
||
$bewertbar = true;
|
||
$fertig = [];
|
||
|
||
foreach ($positionen as $position) {
|
||
$realisiert += $position['realisiert'];
|
||
$ertraege += $position['ertraege'];
|
||
|
||
// Vollständig verkaufte Positionen verschwinden aus der Übersicht;
|
||
// ihr realisierter Gewinn zählt weiter mit.
|
||
if (round($position['stueck'], 6) <= 0.0) {
|
||
continue;
|
||
}
|
||
|
||
$kurs = $kurse[$position['wertpapier_id']] ?? null;
|
||
|
||
$position['stueck'] = round($position['stueck'], 6);
|
||
$position['einstand'] = round($position['einstand'], 2);
|
||
$position['kurs'] = $kurs['kurs'] ?? null;
|
||
$position['kurs_datum'] = $kurs['datum'] ?? null;
|
||
$position['einstand_je_stueck'] = $position['stueck'] > 0.0
|
||
? round($position['einstand'] / $position['stueck'], 4)
|
||
: null;
|
||
|
||
if ($kurs === null) {
|
||
// Ohne Kurs ist der Wert unbekannt. Der Einstand als Ersatz wäre
|
||
// bequem, verstellte aber den Blick: Die Übersicht sagt lieber,
|
||
// dass ein Kurs fehlt.
|
||
$position['wert'] = null;
|
||
$position['gewinn'] = null;
|
||
$bewertbar = false;
|
||
} else {
|
||
$position['wert'] = round($position['stueck'] * $kurs['kurs'], 2);
|
||
$position['gewinn'] = round($position['wert'] - $position['einstand'], 2);
|
||
$wertGesamt += $position['wert'];
|
||
}
|
||
|
||
$einstandGesamt += $position['einstand'];
|
||
$fertig[] = $position;
|
||
}
|
||
|
||
usort($fertig, static fn(array $a, array $b): int => strcasecmp($a['name'], $b['name']));
|
||
|
||
return [
|
||
'positionen' => $fertig,
|
||
'einstand' => round($einstandGesamt, 2),
|
||
'wert' => round($wertGesamt, 2),
|
||
'gewinn' => round($wertGesamt - $einstandGesamt, 2),
|
||
'realisiert' => round($realisiert, 2),
|
||
'ertraege' => round($ertraege, 2),
|
||
'bewertbar' => $bewertbar,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Der bewertete Depotwert je Konto – die Kontenübersicht braucht ihn für
|
||
* die Depotzeilen, wo ein Saldo aus Buchungen keinen Sinn ergäbe.
|
||
*
|
||
* @return array<int,float> konto_id => Wert
|
||
*/
|
||
public static function werteJeDepot(?string $stichtag = null): array
|
||
{
|
||
$werte = [];
|
||
foreach (self::depots() as $depot) {
|
||
$bestand = self::bestand((int)$depot['id'], $stichtag);
|
||
$werte[(int)$depot['id']] = $bestand['wert'];
|
||
}
|
||
|
||
return $werte;
|
||
}
|
||
|
||
/** Alle Konten der Kontoart „depot“. @return list<array<string,mixed>> */
|
||
public static function depots(bool $nurAktive = false): array
|
||
{
|
||
return array_values(array_filter(
|
||
Konto::alle($nurAktive),
|
||
static fn(array $konto): bool => (string)$konto['kontoart'] === 'depot'
|
||
));
|
||
}
|
||
|
||
/**
|
||
* Veräußerungsgeschäfte eines Zeitraums: je Verkauf der Erlös, der
|
||
* anteilige Einstand und der Gewinn.
|
||
*
|
||
* Gerechnet wird über die ganze Historie bis zum Verkaufstag – der Einstand
|
||
* eines im Mai verkauften Papiers hängt an allen Käufen davor, nicht nur an
|
||
* denen im Berichtszeitraum.
|
||
*
|
||
* @return array{geschaefte: list<array<string,mixed>>, gewinn: float, erloes: float}
|
||
*/
|
||
public static function veraeusserungen(string $von, string $bis, ?int $kontoId = null): array
|
||
{
|
||
$bestaende = [];
|
||
$geschaefte = [];
|
||
$gewinn = 0.0;
|
||
$erloes = 0.0;
|
||
|
||
foreach (self::buchungen($kontoId, $bis) as $buchung) {
|
||
$schluessel = (int)$buchung['wertpapier_id'] . ':' . (int)$buchung['konto_id'];
|
||
$stand = $bestaende[$schluessel] ?? ['stueck' => 0.0, 'einstand' => 0.0];
|
||
|
||
$betrag = (float)$buchung['betrag'];
|
||
$kosten = (float)$buchung['gebuehren'] + (float)$buchung['steuern'];
|
||
$stueck = (float)$buchung['stueck'];
|
||
|
||
if (in_array((string)$buchung['vorgang'], Depotbuchung::ZUGAENGE, true)) {
|
||
$stand['stueck'] += $stueck;
|
||
$stand['einstand'] += $betrag + $kosten;
|
||
} elseif (in_array((string)$buchung['vorgang'], Depotbuchung::ABGAENGE, true)) {
|
||
$abgang = abs($stueck);
|
||
$anteil = $stand['stueck'] > 0.0 ? min(1.0, $abgang / $stand['stueck']) : 1.0;
|
||
$anteiligerEinstand = round($stand['einstand'] * $anteil, 2);
|
||
|
||
$stand['stueck'] -= $abgang;
|
||
$stand['einstand'] -= $anteiligerEinstand;
|
||
|
||
$datum = (string)$buchung['datum'];
|
||
if ($datum >= $von && $datum <= $bis && (string)$buchung['vorgang'] === 'verkauf') {
|
||
$einzel = round($betrag - $kosten - $anteiligerEinstand, 2);
|
||
|
||
$geschaefte[] = [
|
||
'datum' => $datum,
|
||
'name' => (string)$buchung['wertpapier'],
|
||
'isin' => (string)$buchung['isin'],
|
||
'depot' => (string)$buchung['depot'],
|
||
'stueck' => $abgang,
|
||
'erloes' => round($betrag - $kosten, 2),
|
||
'einstand' => $anteiligerEinstand,
|
||
'gewinn' => $einzel,
|
||
];
|
||
|
||
$gewinn += $einzel;
|
||
$erloes += round($betrag - $kosten, 2);
|
||
}
|
||
}
|
||
|
||
$bestaende[$schluessel] = $stand;
|
||
}
|
||
|
||
return [
|
||
'geschaefte' => $geschaefte,
|
||
'gewinn' => round($gewinn, 2),
|
||
'erloes' => round($erloes, 2),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Erträge (Dividenden und Ausschüttungen) eines Zeitraums, je Wertpapier.
|
||
*
|
||
* @return array{zeilen: list<array<string,mixed>>, summe: float}
|
||
*/
|
||
public static function ertraege(string $von, string $bis, ?int $kontoId = null): array
|
||
{
|
||
$params = [Mandant::id(), $von, $bis];
|
||
$sql = "SELECT w.name, w.isin, SUM(d.betrag - d.gebuehren - d.steuern) AS summe,
|
||
COUNT(*) AS anzahl
|
||
FROM depot_buchungen d
|
||
JOIN wertpapiere w ON w.id = d.wertpapier_id
|
||
WHERE d.benutzer_id = ? AND d.vorgang = 'dividende'
|
||
AND d.datum BETWEEN ? AND ?";
|
||
|
||
if ($kontoId !== null) {
|
||
$sql .= ' AND d.konto_id = ?';
|
||
$params[] = $kontoId;
|
||
}
|
||
|
||
$zeilen = Database::fetchAll($sql . ' GROUP BY w.id ORDER BY summe DESC', $params);
|
||
|
||
$summe = 0.0;
|
||
foreach ($zeilen as $nummer => $zeile) {
|
||
$zeilen[$nummer]['summe'] = round((float)$zeile['summe'], 2);
|
||
$summe += $zeilen[$nummer]['summe'];
|
||
}
|
||
|
||
return ['zeilen' => $zeilen, 'summe' => round($summe, 2)];
|
||
}
|
||
|
||
/**
|
||
* Die Wertentwicklung eines Depots über die letzten Monate: je Monatsende
|
||
* der bewertete Bestand.
|
||
*
|
||
* @return list<array{monat: string, kuerzel: string, wert: float, einstand: float}>
|
||
*/
|
||
public static function entwicklung(?int $kontoId = null, int $monate = 12, ?string $heute = null): array
|
||
{
|
||
$reihe = [];
|
||
|
||
foreach (Vermoegensentwicklung::monate($monate, $heute) as $monat) {
|
||
$bestand = self::bestand($kontoId, $monat['bis']);
|
||
|
||
$reihe[] = [
|
||
'monat' => sprintf('%04d-%02d', $monat['jahr'], $monat['monat']),
|
||
'kuerzel' => $monat['kuerzel'],
|
||
'wert' => $bestand['wert'],
|
||
'einstand' => $bestand['einstand'],
|
||
];
|
||
}
|
||
|
||
return $reihe;
|
||
}
|
||
|
||
/**
|
||
* Alle Depotbuchungen bis zum Stichtag, aufsteigend – die Reihenfolge
|
||
* entscheidet über den gleitenden Durchschnitt.
|
||
*
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
private static function buchungen(?int $kontoId, string $bis): array
|
||
{
|
||
$params = [Mandant::id(), $bis];
|
||
$sql = 'SELECT d.*, w.name AS wertpapier, w.isin, w.wkn, k.name AS depot
|
||
FROM depot_buchungen d
|
||
JOIN wertpapiere w ON w.id = d.wertpapier_id
|
||
JOIN konten k ON k.id = d.konto_id
|
||
WHERE d.benutzer_id = ? AND d.datum <= ?';
|
||
|
||
if ($kontoId !== null) {
|
||
$sql .= ' AND d.konto_id = ?';
|
||
$params[] = $kontoId;
|
||
}
|
||
|
||
return Database::fetchAll($sql . ' ORDER BY d.datum, d.id', $params);
|
||
}
|
||
}
|