Finanzmanager/src/Services/Wertpapiergeschaeft.php
fds2610-bot d3f298f26c Fix #125: Klasse beim Buchen von Wertpapieren wählen
Die Wertpapiermaske (Dialog im Kontoblatt und Seite ohne JavaScript) sowie
„Depotbuchung erfassen“ bieten eine Klasse an. Im Kontoblatt ist sie mit der
bisherigen Klasse der Buchung vorbelegt und gilt für die Buchung samt aller
Splitzeilen der Nebenkosten; bei der Depotbuchung steht sie an der erzeugten
Geldbuchung. Unbekannte Klassen werden abgewiesen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:35:31 +02:00

227 lines
9.8 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);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
/**
* Ein Wertpapiergeschäft aus einer vorhandenen Kontobuchung.
*
* **Warum in dieser Richtung.** Der Bankabruf bringt die Wertpapierabrechnung
* als gewöhnlichen Umsatz auf dem Girokonto – ein Betrag, ein Verwendungszweck,
* sonst nichts. Dass es ein Kauf war, wieviele Stücke zu welchem Kurs und was
* davon Gebühren und Steuern waren, weiß erst der Mensch davor. Diese Klasse
* nimmt seine Angaben und macht aus der einen Buchung beides: die Depotbuchung
* mit dem Einstandspreis und die Aufteilung im Kontoblatt.
*
* **Der Buchungsbetrag ist die feste Größe.** Er kommt von der Bank und wird
* nicht nachgerechnet. Die Nebenkosten werden aus ihm herausgerechnet; was übrig
* bleibt, ist der Kurswert und damit der Einstandspreis:
*
* Kauf −255,00 € + 5,00 € Kosten = −250,00 € → Einstand 250,00 €
* Verkauf 3.103,17 € + 84,83 € Kosten = 3.188,00 € → Erlös 3.188,00 €
*
* Dieselbe Rechnung in beiden Richtungen: Nebenkosten sind Aufwand, ob gekauft
* oder verkauft wird. Der Kurs ist demgegenüber eine Angabe der Abrechnung –
* fehlt er, ergibt er sich aus Einstand und Stückzahl, nie umgekehrt.
*
* **Was im Kontoblatt entsteht.** Je belegter Kostenart eine Splitzeile in ihrer
* Kategorie, der Rest als Umbuchung ins Depot. Ohne Nebenkosten bleibt es bei
* der schlichten Umbuchung – eine Splitbuchung mit einer einzigen Zeile wäre nur
* umständlicher zu lesen.
*
* **Die Klasse gilt für das ganze Geschäft.** Sie steht an der Buchung und an
* jeder ihrer Splitzeilen – sonst zeigte das Kontoblatt eine Splitbuchung mit
* gemischten Klassen, und die Auswertung fände die Nebenkosten ohne Klasse.
*/
class Wertpapiergeschaeft
{
/**
* Die Aufteilung eines Buchungsbetrags – ohne Datenbank, damit sie sich
* nachrechnen lässt.
*
* @param float $geld Betrag der Buchung, mit Vorzeichen
* @param array<string,float> $betraege Kostenart => Betrag
* @return array{
* kurswert: float,
* gebuehren: float,
* steuern: float,
* kosten: list<array{art:string,name:string,betrag:float,kategorie_id:?int,steuer:bool}>
* }
*/
public static function aufteilung(float $geld, array $betraege, ?int $sonstigeKategorie = null): array
{
$geld = round($geld, 2);
$zeilen = Wertpapierkosten::zeilen($betraege, $sonstigeKategorie);
$summen = Wertpapierkosten::summen($zeilen);
return [
'kurswert' => round($geld + $summen['summe'], 2),
'gebuehren' => $summen['gebuehren'],
'steuern' => $summen['steuern'],
'kosten' => $zeilen,
];
}
/**
* Der Vorgang zum Geldfluss. Ein Abfluss kann nur ein Kauf sein; ein Zufluss
* ist ein Verkauf oder – wenn die Maske es so sagt – eine Dividende.
*/
public static function vorgang(float $geld, string $gewaehlt = ''): string
{
if ($geld < 0) {
return 'kauf';
}
return $gewaehlt === 'dividende' ? 'dividende' : 'verkauf';
}
/**
* Depotbuchung und Aufteilung in einem Zug.
*
* @param array<string,mixed> $buchung die Buchung des Verrechnungskontos
* @param array<string,mixed> $eingaben depot_id, wertpapier_id, wertpapier_name, isin,
* wkn, art, vorgang, datum, valuta, stueck, kurs,
* notiz, kosten, kosten_sonstige_kategorie, klasse_id
* @return array{depotbuchung_id:int,vorgang:string,betrag:float,gesplittet:bool}
*/
public static function buchen(array $buchung, array $eingaben): array
{
$depot = Konto::find((int)($eingaben['depot_id'] ?? 0));
if ($depot === null || (string)$depot['kontoart'] !== 'depot') {
throw new \InvalidArgumentException('Für ein Wertpapiergeschäft fehlt das Depot.');
}
if (Depotbuchung::findeZuBuchung((int)$buchung['id']) !== null) {
throw new \RuntimeException(
'Zu dieser Buchung gibt es schon ein Wertpapiergeschäft. Es ist unter den '
. 'Depotbuchungen zu löschen, bevor ein neues entstehen kann.'
);
}
$geld = round((float)$buchung['betrag'], 2);
if (abs($geld) < 0.005) {
throw new \InvalidArgumentException(
'Eine Buchung über null lässt sich nicht in ein Geschäft aufteilen.'
);
}
$aufteilung = self::aufteilung(
$geld,
is_array($eingaben['kosten'] ?? null) ? $eingaben['kosten'] : [],
($eingaben['kosten_sonstige_kategorie'] ?? null) ?: null
);
$kurswert = $aufteilung['kurswert'];
if (abs($kurswert) < 0.005 || ($kurswert < 0) !== ($geld < 0)) {
throw new \InvalidArgumentException(
'Die Nebenkosten sind so hoch wie die Buchung – für das Wertpapier bliebe nichts übrig.'
);
}
$vorgang = self::vorgang($geld, (string)($eingaben['vorgang'] ?? ''));
$stueck = abs((float)($eingaben['stueck'] ?? 0));
if ($vorgang !== 'dividende' && $stueck <= 0.0) {
throw new \InvalidArgumentException('Ohne Stückzahl lässt sich kein Kauf oder Verkauf buchen.');
}
$kurs = abs((float)($eingaben['kurs'] ?? 0));
if ($kurs <= 0.0 && $stueck > 0.0) {
$kurs = round(abs($kurswert) / $stueck, 6);
}
$klasseId = (int)($eingaben['klasse_id'] ?? 0) ?: null;
if (!array_key_exists('klasse_id', $eingaben)) {
// Splitzeilen erben dann die Klasse, die schon an der Buchung steht.
$klasseId = ($buchung['klasse_id'] ?? null) !== null ? (int)$buchung['klasse_id'] : null;
}
if ($klasseId !== null && Klasse::find($klasseId) === null) {
throw new \InvalidArgumentException('Die Klasse gibt es nicht.');
}
return Database::transaktion(static function () use (
$buchung, $depot, $eingaben, $aufteilung, $kurswert, $vorgang, $stueck, $kurs, $klasseId
): array {
$wertpapierId = (int)($eingaben['wertpapier_id'] ?? 0);
$name = trim((string)($eingaben['wertpapier_name'] ?? ''));
if ($wertpapierId === 0) {
if ($name === '') {
throw new \InvalidArgumentException('Ein neues Wertpapier braucht einen Namen.');
}
$wertpapierId = Wertpapier::findenOderAnlegen([
'name' => $name,
'isin' => (string)($eingaben['isin'] ?? ''),
'wkn' => (string)($eingaben['wkn'] ?? ''),
'art' => (string)($eingaben['art'] ?? 'aktie'),
]);
}
$papier = Wertpapier::find($wertpapierId);
$bezeichnung = $papier === null ? 'Wertpapier' : (string)$papier['name'];
$depotbuchungId = Depotbuchung::anlegen([
'konto_id' => (int)$depot['id'],
'wertpapier_id' => $wertpapierId,
'datum' => ($eingaben['datum'] ?? '') ?: substr((string)$buchung['datum'], 0, 10),
'valuta' => $eingaben['valuta'] ?? '',
'vorgang' => $vorgang,
'stueck' => $stueck,
'kurs' => $kurs > 0.0 ? $kurs : null,
'betrag' => abs($kurswert),
'gebuehren' => $aufteilung['gebuehren'],
'steuern' => $aufteilung['steuern'],
'verrechnungskonto_id' => (int)$buchung['konto_id'],
// Der Kern des Wegs übers Kontoblatt: Die Geldseite gibt es
// schon, sie kommt vom Bankabruf.
'buchung_id' => (int)$buchung['id'],
'notiz' => ($eingaben['notiz'] ?? '') ?: (string)$buchung['buchungstext'],
]);
$gesplittet = $aufteilung['kosten'] !== [];
if (!$gesplittet) {
Buchung::zuordnen((int)$buchung['id'], null, (int)$depot['id']);
} else {
$splits = [[
'gegenkonto_id' => (int)$depot['id'],
'klasse_id' => $klasseId,
'betrag' => $kurswert,
'notiz' => $bezeichnung,
]];
foreach ($aufteilung['kosten'] as $zeile) {
$splits[] = [
'kategorie_id' => $zeile['kategorie_id'],
'klasse_id' => $klasseId,
// Nebenkosten sind Aufwand – beim Kauf wie beim Verkauf.
'betrag' => -$zeile['betrag'],
'notiz' => $zeile['name'],
];
}
Buchung::splitsErsetzen((int)$buchung['id'], $splits);
}
// Die Auswahl der Maske gilt, auch wenn sie leer ist: Sie war mit
// der bisherigen Klasse der Buchung vorbelegt. Wer gar keine
// Klasse mitgibt, lässt die vorhandene stehen.
if (array_key_exists('klasse_id', $eingaben)) {
Buchung::klasseSetzen((int)$buchung['id'], $klasseId);
}
return [
'depotbuchung_id' => $depotbuchungId,
'vorgang' => $vorgang,
'betrag' => abs($kurswert),
'gesplittet' => $gesplittet,
];
});
}
}