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>
This commit is contained in:
parent
c58837dee8
commit
d3f298f26c
14 changed files with 309 additions and 6 deletions
|
|
@ -8,6 +8,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Wertpapiere: Beim Buchen eines Wertpapiergeschäfts (Dialog im Kontoblatt und
|
||||||
|
„Depotbuchung erfassen“) lässt sich eine Klasse wählen; sie gilt für die Buchung samt
|
||||||
|
aller Splitzeilen der Nebenkosten (#125)
|
||||||
- Kategorieauswahl: Unter den Kategorien stehen überall die aktiven Umbuchungskonten,
|
- Kategorieauswahl: Unter den Kategorien stehen überall die aktiven Umbuchungskonten,
|
||||||
sortiert und gruppiert nach Kontengruppe – in Regeln samt Splitzeilen, Umsatzabgleich,
|
sortiert und gruppiert nach Kontengruppe – in Regeln samt Splitzeilen, Umsatzabgleich,
|
||||||
Buchungsvorlagen, Regelmäßigen Buchungen, Zahlungsverkehr, Verträgen sowie in den
|
Buchungsvorlagen, Regelmäßigen Buchungen, Zahlungsverkehr, Verträgen sowie in den
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
# Änderungen
|
# Änderungen
|
||||||
|
|
||||||
|
## Wertpapiere: Klasse beim Buchen wählen (12.10.2026)
|
||||||
|
|
||||||
|
- Anlass (#125): Beim Buchen von Wertpapieren fehlte die Auswahl einer Klasse.
|
||||||
|
- Die Wertpapiermaske (Dialog im Kontoblatt und Seite `/buchungen/{id}/wertpapier`) hat ein Feld „Klasse“, vorbelegt mit der bisherigen Klasse der Buchung. Die Klasse steht danach an der Buchung und an jeder Splitzeile der Nebenkosten – das Kontoblatt zeigt so die eine Klasse statt „Splitbuchung“. Eine leere Auswahl entfernt die Klasse.
|
||||||
|
- Auch „Depotbuchung erfassen“ unter *Wertpapiere* bietet die Klasse an; sie gilt der dort erzeugten Geldbuchung auf dem Verrechnungskonto.
|
||||||
|
- Eine unbekannte Klasse wird abgewiesen, ohne dass ein Geschäft entsteht.
|
||||||
|
- Tests: `tests/62_WertpapierKlasseTest.php`, `tests/e2e/specs/wertpapiergeschaeft.spec.js`.
|
||||||
|
|
||||||
## Umbuchungskonten in jeder Kategorieauswahl (12.10.2026)
|
## Umbuchungskonten in jeder Kategorieauswahl (12.10.2026)
|
||||||
|
|
||||||
- Anlass (#124): In den Splitzeilen der Regeln, beim Anlegen einer Regel, im Filter des Buchungsberichts und weiteren Listen fehlten die Umbuchungskonten unter den Kategorien.
|
- Anlass (#124): In den Splitzeilen der Regeln, beim Anlegen einer Regel, im Filter des Buchungsberichts und weiteren Listen fehlten die Umbuchungskonten unter den Kategorien.
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,13 @@ ihrer Kategorie, der Rest als Umbuchung ins Depot. Die Zeilen ergeben genau den
|
||||||
Buchungsbetrag. Ohne Nebenkosten bleibt es bei der schlichten Umbuchung – eine
|
Buchungsbetrag. Ohne Nebenkosten bleibt es bei der schlichten Umbuchung – eine
|
||||||
Splitbuchung mit einer einzigen Zeile wäre nur umständlicher zu lesen.
|
Splitbuchung mit einer einzigen Zeile wäre nur umständlicher zu lesen.
|
||||||
|
|
||||||
|
**Klasse.** Die Maske bietet eine Klasse an, vorbelegt mit der bisherigen Klasse der
|
||||||
|
Buchung. Sie gilt für das ganze Geschäft: an der Buchung und an jeder Splitzeile, damit
|
||||||
|
das Kontoblatt keine gemischten Klassen zeigt und die Nebenkosten in der Auswertung nach
|
||||||
|
Klassen nicht fehlen. Eine leere Auswahl entfernt die Klasse. Auch die Maske *Depotbuchung
|
||||||
|
erfassen* unter *Wertpapiere* hat das Feld; dort steht die Klasse an der Geldbuchung auf
|
||||||
|
dem Verrechnungskonto.
|
||||||
|
|
||||||
### Kostenarten und ihre Kategorien
|
### Kostenarten und ihre Kategorien
|
||||||
|
|
||||||
`Services\Wertpapierkosten` kennt sechs Gebühren- und sechs Steuerarten – dieselben, die
|
`Services\Wertpapierkosten` kennt sechs Gebühren- und sechs Steuerarten – dieselben, die
|
||||||
|
|
@ -309,7 +316,8 @@ eigener Wert der Zuordnungsliste, ISIN und WKN aus dem Verwendungszweck, Kauf mi
|
||||||
Kauf ohne Kosten als schlichte Umbuchung mit errechnetem Kurs, Verkauf mit Gebühren und
|
Kauf ohne Kosten als schlichte Umbuchung mit errechnetem Kurs, Verkauf mit Gebühren und
|
||||||
Steuern, das Löschen, das die Bankbuchung stehen lässt, sowie die Abweisungen: doppeltes
|
Steuern, das Löschen, das die Bankbuchung stehen lässt, sowie die Abweisungen: doppeltes
|
||||||
Geschäft, Kosten in Höhe der Buchung, Konto statt Depot, Kauf ohne Stückzahl. Die beiden
|
Geschäft, Kosten in Höhe der Buchung, Konto statt Depot, Kauf ohne Stückzahl. Die beiden
|
||||||
Dialoge prüft `tests/e2e/specs/wertpapiergeschaeft.spec.js`.
|
Dialoge prüft `tests/e2e/specs/wertpapiergeschaeft.spec.js`, die Klasse des Geschäfts
|
||||||
|
`tests/62_WertpapierKlasseTest.php`.
|
||||||
|
|
||||||
`tests/26_WertpapierDetailTest.php` – die Detailseite: der auf ein Papier eingegrenzte
|
`tests/26_WertpapierDetailTest.php` – die Detailseite: der auf ein Papier eingegrenzte
|
||||||
Bestand (fremde Papiere bleiben draußen, mehrere Depots werden je Position geführt, Depot
|
Bestand (fremde Papiere bleiben draußen, mehrere Depots werden je Position geführt, Depot
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,13 @@
|
||||||
document.getElementById('wp_isin').value = isin;
|
document.getElementById('wp_isin').value = isin;
|
||||||
document.getElementById('wp_wkn').value = wkn;
|
document.getElementById('wp_wkn').value = wkn;
|
||||||
|
|
||||||
|
// Die Klasse der Buchung steht schon in ihrer Zeile – sie ist die
|
||||||
|
// Vorgabe für das Geschäft.
|
||||||
|
const zeile = feld.closest('tr');
|
||||||
|
const klassenfeld = zeile && zeile.querySelector('a.klassenfeld');
|
||||||
|
const klasse = klassenfeld ? (klassenfeld.dataset.klasse || '') : '';
|
||||||
|
document.getElementById('wp_klasse').value = klasse === '0' ? '' : klasse;
|
||||||
|
|
||||||
papierWaehlen(isin, wkn);
|
papierWaehlen(isin, wkn);
|
||||||
vorgaengeStellen();
|
vorgaengeStellen();
|
||||||
rechne();
|
rechne();
|
||||||
|
|
|
||||||
|
|
@ -392,6 +392,7 @@ class BuchungenController extends BaseController
|
||||||
'depot' => $depot,
|
'depot' => $depot,
|
||||||
'wertpapiere' => Wertpapier::alle(true),
|
'wertpapiere' => Wertpapier::alle(true),
|
||||||
'kategorien' => Kategorie::baumFlach(),
|
'kategorien' => Kategorie::baumFlach(),
|
||||||
|
'klassen' => Klasse::alle(),
|
||||||
'vorschlag' => Wertpapier::ausText((string)$buchung['buchungstext'] . ' ' . (string)$buchung['empfaenger']),
|
'vorschlag' => Wertpapier::ausText((string)$buchung['buchungstext'] . ' ' . (string)$buchung['empfaenger']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -464,6 +465,7 @@ class BuchungenController extends BaseController
|
||||||
'notiz' => $this->post('notiz'),
|
'notiz' => $this->post('notiz'),
|
||||||
'kosten' => $kosten,
|
'kosten' => $kosten,
|
||||||
'kosten_sonstige_kategorie' => (int)$this->post('kosten_sonstige_kategorie') ?: null,
|
'kosten_sonstige_kategorie' => (int)$this->post('kosten_sonstige_kategorie') ?: null,
|
||||||
|
'klasse_id' => (int)$this->post('klasse_id') ?: null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ namespace Finanzmanager\Controllers;
|
||||||
use Finanzmanager\Models\Bankzugang;
|
use Finanzmanager\Models\Bankzugang;
|
||||||
use Finanzmanager\Models\Depotbuchung;
|
use Finanzmanager\Models\Depotbuchung;
|
||||||
use Finanzmanager\Models\Einstellung;
|
use Finanzmanager\Models\Einstellung;
|
||||||
|
use Finanzmanager\Models\Klasse;
|
||||||
use Finanzmanager\Models\Konto;
|
use Finanzmanager\Models\Konto;
|
||||||
use Finanzmanager\Models\Wertpapier;
|
use Finanzmanager\Models\Wertpapier;
|
||||||
use Finanzmanager\Services\Depot;
|
use Finanzmanager\Services\Depot;
|
||||||
|
|
@ -117,6 +118,7 @@ class WertpapiereController extends BaseController
|
||||||
'depots' => Depot::depots(true),
|
'depots' => Depot::depots(true),
|
||||||
'wertpapiere' => Wertpapier::alle(true),
|
'wertpapiere' => Wertpapier::alle(true),
|
||||||
'konten' => Konto::alle(true),
|
'konten' => Konto::alle(true),
|
||||||
|
'klassen' => Klasse::alle(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,6 +162,7 @@ class WertpapiereController extends BaseController
|
||||||
'gebuehren' => $this->zahl($this->post('gebuehren')),
|
'gebuehren' => $this->zahl($this->post('gebuehren')),
|
||||||
'steuern' => $this->zahl($this->post('steuern')),
|
'steuern' => $this->zahl($this->post('steuern')),
|
||||||
'verrechnungskonto_id' => $this->post('verrechnungskonto_id'),
|
'verrechnungskonto_id' => $this->post('verrechnungskonto_id'),
|
||||||
|
'klasse_id' => (int)$this->post('klasse_id') ?: null,
|
||||||
'notiz' => $this->post('notiz'),
|
'notiz' => $this->post('notiz'),
|
||||||
'wertpapiername' => $this->post('wertpapier_name') ?: null,
|
'wertpapiername' => $this->post('wertpapier_name') ?: null,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,14 @@ class Depotbuchung
|
||||||
{
|
{
|
||||||
$f = self::normalisiere($daten);
|
$f = self::normalisiere($daten);
|
||||||
|
|
||||||
return (int)Database::transaktion(static function () use ($f, $daten): int {
|
// Die Klasse gilt der Geldbuchung – sie ist die Seite, die in den
|
||||||
|
// Auswertungen nach Klassen auftaucht.
|
||||||
|
$klasseId = (int)($daten['klasse_id'] ?? 0) ?: null;
|
||||||
|
if ($klasseId !== null && Klasse::find($klasseId) === null) {
|
||||||
|
throw new \InvalidArgumentException('Die Klasse gibt es nicht.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int)Database::transaktion(static function () use ($f, $daten, $klasseId): int {
|
||||||
$buchungId = $f['buchung_id'];
|
$buchungId = $f['buchung_id'];
|
||||||
$erzeugt = 0;
|
$erzeugt = 0;
|
||||||
|
|
||||||
|
|
@ -186,6 +193,7 @@ class Depotbuchung
|
||||||
'empfaenger' => mb_substr((string)($daten['wertpapiername'] ?? 'Wertpapiergeschäft'), 0, 255),
|
'empfaenger' => mb_substr((string)($daten['wertpapiername'] ?? 'Wertpapiergeschäft'), 0, 255),
|
||||||
'buchungstext' => (string)$f['notiz'],
|
'buchungstext' => (string)$f['notiz'],
|
||||||
'kategorie_id' => ($daten['kategorie_id'] ?? null) ?: null,
|
'kategorie_id' => ($daten['kategorie_id'] ?? null) ?: null,
|
||||||
|
'klasse_id' => $klasseId,
|
||||||
'quelle' => (string)$f['quelle'],
|
'quelle' => (string)$f['quelle'],
|
||||||
'abgeglichen' => !empty($daten['abgeglichen']) ? 1 : 0,
|
'abgeglichen' => !empty($daten['abgeglichen']) ? 1 : 0,
|
||||||
// Eigener Hash für die Geldseite: Er erkennt Buchungen
|
// Eigener Hash für die Geldseite: Er erkennt Buchungen
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ namespace Finanzmanager\Services;
|
||||||
use Finanzmanager\Database;
|
use Finanzmanager\Database;
|
||||||
use Finanzmanager\Models\Buchung;
|
use Finanzmanager\Models\Buchung;
|
||||||
use Finanzmanager\Models\Depotbuchung;
|
use Finanzmanager\Models\Depotbuchung;
|
||||||
|
use Finanzmanager\Models\Klasse;
|
||||||
use Finanzmanager\Models\Konto;
|
use Finanzmanager\Models\Konto;
|
||||||
use Finanzmanager\Models\Wertpapier;
|
use Finanzmanager\Models\Wertpapier;
|
||||||
|
|
||||||
|
|
@ -35,6 +36,10 @@ use Finanzmanager\Models\Wertpapier;
|
||||||
* Kategorie, der Rest als Umbuchung ins Depot. Ohne Nebenkosten bleibt es bei
|
* Kategorie, der Rest als Umbuchung ins Depot. Ohne Nebenkosten bleibt es bei
|
||||||
* der schlichten Umbuchung – eine Splitbuchung mit einer einzigen Zeile wäre nur
|
* der schlichten Umbuchung – eine Splitbuchung mit einer einzigen Zeile wäre nur
|
||||||
* umständlicher zu lesen.
|
* 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
|
class Wertpapiergeschaeft
|
||||||
{
|
{
|
||||||
|
|
@ -84,7 +89,7 @@ class Wertpapiergeschaeft
|
||||||
* @param array<string,mixed> $buchung die Buchung des Verrechnungskontos
|
* @param array<string,mixed> $buchung die Buchung des Verrechnungskontos
|
||||||
* @param array<string,mixed> $eingaben depot_id, wertpapier_id, wertpapier_name, isin,
|
* @param array<string,mixed> $eingaben depot_id, wertpapier_id, wertpapier_name, isin,
|
||||||
* wkn, art, vorgang, datum, valuta, stueck, kurs,
|
* wkn, art, vorgang, datum, valuta, stueck, kurs,
|
||||||
* notiz, kosten, kosten_sonstige_kategorie
|
* notiz, kosten, kosten_sonstige_kategorie, klasse_id
|
||||||
* @return array{depotbuchung_id:int,vorgang:string,betrag:float,gesplittet:bool}
|
* @return array{depotbuchung_id:int,vorgang:string,betrag:float,gesplittet:bool}
|
||||||
*/
|
*/
|
||||||
public static function buchen(array $buchung, array $eingaben): array
|
public static function buchen(array $buchung, array $eingaben): array
|
||||||
|
|
@ -132,8 +137,17 @@ class Wertpapiergeschaeft
|
||||||
$kurs = round(abs($kurswert) / $stueck, 6);
|
$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 (
|
return Database::transaktion(static function () use (
|
||||||
$buchung, $depot, $eingaben, $aufteilung, $kurswert, $vorgang, $stueck, $kurs
|
$buchung, $depot, $eingaben, $aufteilung, $kurswert, $vorgang, $stueck, $kurs, $klasseId
|
||||||
): array {
|
): array {
|
||||||
$wertpapierId = (int)($eingaben['wertpapier_id'] ?? 0);
|
$wertpapierId = (int)($eingaben['wertpapier_id'] ?? 0);
|
||||||
$name = trim((string)($eingaben['wertpapier_name'] ?? ''));
|
$name = trim((string)($eingaben['wertpapier_name'] ?? ''));
|
||||||
|
|
@ -178,12 +192,14 @@ class Wertpapiergeschaeft
|
||||||
} else {
|
} else {
|
||||||
$splits = [[
|
$splits = [[
|
||||||
'gegenkonto_id' => (int)$depot['id'],
|
'gegenkonto_id' => (int)$depot['id'],
|
||||||
|
'klasse_id' => $klasseId,
|
||||||
'betrag' => $kurswert,
|
'betrag' => $kurswert,
|
||||||
'notiz' => $bezeichnung,
|
'notiz' => $bezeichnung,
|
||||||
]];
|
]];
|
||||||
foreach ($aufteilung['kosten'] as $zeile) {
|
foreach ($aufteilung['kosten'] as $zeile) {
|
||||||
$splits[] = [
|
$splits[] = [
|
||||||
'kategorie_id' => $zeile['kategorie_id'],
|
'kategorie_id' => $zeile['kategorie_id'],
|
||||||
|
'klasse_id' => $klasseId,
|
||||||
// Nebenkosten sind Aufwand – beim Kauf wie beim Verkauf.
|
// Nebenkosten sind Aufwand – beim Kauf wie beim Verkauf.
|
||||||
'betrag' => -$zeile['betrag'],
|
'betrag' => -$zeile['betrag'],
|
||||||
'notiz' => $zeile['name'],
|
'notiz' => $zeile['name'],
|
||||||
|
|
@ -193,6 +209,13 @@ class Wertpapiergeschaeft
|
||||||
Buchung::splitsErsetzen((int)$buchung['id'], $splits);
|
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 [
|
return [
|
||||||
'depotbuchung_id' => $depotbuchungId,
|
'depotbuchung_id' => $depotbuchungId,
|
||||||
'vorgang' => $vorgang,
|
'vorgang' => $vorgang,
|
||||||
|
|
|
||||||
|
|
@ -555,6 +555,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
|
|
||||||
<?php $datumsvorgabe = date('Y-m-d'); ?>
|
<?php $datumsvorgabe = date('Y-m-d'); ?>
|
||||||
<?php $vorschlag = ['isin' => '', 'wkn' => '']; ?>
|
<?php $vorschlag = ['isin' => '', 'wkn' => '']; ?>
|
||||||
|
<?php $klassenvorgabe = 0; ?>
|
||||||
<?php include __DIR__ . '/wertpapierfelder.php'; ?>
|
<?php include __DIR__ . '/wertpapierfelder.php'; ?>
|
||||||
|
|
||||||
<div class="knopfleiste">
|
<div class="knopfleiste">
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,14 @@ use Finanzmanager\View;
|
||||||
* @var array<string,mixed> $depot
|
* @var array<string,mixed> $depot
|
||||||
* @var list<array<string,mixed>> $wertpapiere
|
* @var list<array<string,mixed>> $wertpapiere
|
||||||
* @var list<array<string,mixed>> $kategorien
|
* @var list<array<string,mixed>> $kategorien
|
||||||
|
* @var list<array<string,mixed>> $klassen
|
||||||
* @var array{isin:string,wkn:string} $vorschlag
|
* @var array{isin:string,wkn:string} $vorschlag
|
||||||
* @var string $csrfToken
|
* @var string $csrfToken
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$betrag = (float)$buchung['betrag'];
|
$betrag = (float)$buchung['betrag'];
|
||||||
$datumsvorgabe = substr((string)$buchung['datum'], 0, 10);
|
$datumsvorgabe = substr((string)$buchung['datum'], 0, 10);
|
||||||
|
$klassenvorgabe = (int)($buchung['klasse_id'] ?? 0);
|
||||||
|
|
||||||
include __DIR__ . '/../layout/kopf.php';
|
include __DIR__ . '/../layout/kopf.php';
|
||||||
?>
|
?>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ use Finanzmanager\View;
|
||||||
* rechnet ihn vor, gespeichert wird er aus denselben Zahlen noch einmal.
|
* rechnet ihn vor, gespeichert wird er aus denselben Zahlen noch einmal.
|
||||||
*
|
*
|
||||||
* @var list<array<string,mixed>> $wertpapiere
|
* @var list<array<string,mixed>> $wertpapiere
|
||||||
|
* @var list<array<string,mixed>> $klassen
|
||||||
|
* @var int $klassenvorgabe die bisherige Klasse der Buchung, 0 für keine
|
||||||
* @var array{isin:string,wkn:string} $vorschlag aus dem Verwendungszweck gelesen
|
* @var array{isin:string,wkn:string} $vorschlag aus dem Verwendungszweck gelesen
|
||||||
* @var string $datumsvorgabe
|
* @var string $datumsvorgabe
|
||||||
*/
|
*/
|
||||||
|
|
@ -102,5 +104,18 @@ use Finanzmanager\View;
|
||||||
das nicht: Der Betrag der Buchung steht fest, wie die Bank ihn geliefert hat.
|
das nicht: Der Betrag der Buchung steht fest, wie die Bank ihn geliefert hat.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<label for="wp_klasse">Klasse <span class="optional">(optional)</span></label>
|
||||||
|
<select id="wp_klasse" name="klasse_id">
|
||||||
|
<option value="">— keine —</option>
|
||||||
|
<?php foreach ($klassen as $k): ?>
|
||||||
|
<option value="<?= (int)$k['id'] ?>" <?= (int)($klassenvorgabe ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
|
||||||
|
<?= View::h($k['name']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<p class="feldhinweis">
|
||||||
|
Die Klasse gilt für das ganze Geschäft – auch für die Splitzeilen der Kosten.
|
||||||
|
</p>
|
||||||
|
|
||||||
<label for="wp_notiz">Notiz</label>
|
<label for="wp_notiz">Notiz</label>
|
||||||
<input type="text" id="wp_notiz" name="notiz">
|
<input type="text" id="wp_notiz" name="notiz">
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use Finanzmanager\View;
|
||||||
* @var list<array<string,mixed>> $depots
|
* @var list<array<string,mixed>> $depots
|
||||||
* @var list<array<string,mixed>> $wertpapiere
|
* @var list<array<string,mixed>> $wertpapiere
|
||||||
* @var list<array<string,mixed>> $konten
|
* @var list<array<string,mixed>> $konten
|
||||||
|
* @var list<array<string,mixed>> $klassen
|
||||||
* @var string $csrfToken
|
* @var string $csrfToken
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -125,6 +126,15 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
Depotbuchung – richtig etwa bei einer Ein- oder Auslieferung.
|
Depotbuchung – richtig etwa bei einer Ein- oder Auslieferung.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<label for="klasse_id">Klasse <span class="optional">(optional)</span></label>
|
||||||
|
<select id="klasse_id" name="klasse_id">
|
||||||
|
<option value="">— keine —</option>
|
||||||
|
<?php foreach ($klassen as $k): ?>
|
||||||
|
<option value="<?= (int)$k['id'] ?>"><?= View::h($k['name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<p class="feldhinweis">Die Klasse steht an der Geldbuchung auf dem Verrechnungskonto.</p>
|
||||||
|
|
||||||
<label for="notiz">Notiz</label>
|
<label for="notiz">Notiz</label>
|
||||||
<input type="text" id="notiz" name="notiz">
|
<input type="text" id="notiz" name="notiz">
|
||||||
|
|
||||||
|
|
|
||||||
202
tests/62_WertpapierKlasseTest.php
Normal file
202
tests/62_WertpapierKlasseTest.php
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Finanzmanager\Models\Buchung;
|
||||||
|
use Finanzmanager\Models\Depotbuchung;
|
||||||
|
use Finanzmanager\Models\Einstellung;
|
||||||
|
use Finanzmanager\Models\Kategorie;
|
||||||
|
use Finanzmanager\Models\Klasse;
|
||||||
|
use Finanzmanager\Models\Konto;
|
||||||
|
use Finanzmanager\Models\Wertpapier;
|
||||||
|
use Finanzmanager\Services\Wertpapiergeschaeft;
|
||||||
|
use Finanzmanager\Services\Wertpapierkosten;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix #125: Beim Buchen von Wertpapieren lässt sich eine Klasse wählen – im
|
||||||
|
* Wertpapiergeschäft aus dem Kontoblatt wie in der Depotbuchung mit eigener
|
||||||
|
* Geldbuchung.
|
||||||
|
*/
|
||||||
|
class WertpapierKlasseTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 62: Klasse beim Buchen von Wertpapieren';
|
||||||
|
|
||||||
|
private int $giro = 0;
|
||||||
|
private int $depot = 0;
|
||||||
|
private int $klasse = 0;
|
||||||
|
private int $andereKlasse = 0;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->mandantAnlegen();
|
||||||
|
|
||||||
|
$this->giro = Konto::anlegen([
|
||||||
|
'name' => 'KL Giro', 'kontoart' => 'bank', 'anfangssaldo' => 10000, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
$this->depot = Konto::anlegen([
|
||||||
|
'name' => 'KL Depot', 'kontoart' => 'depot', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
$this->klasse = Klasse::anlegen('KL Altersvorsorge');
|
||||||
|
$this->andereKlasse = Klasse::anlegen('KL Haushalt');
|
||||||
|
|
||||||
|
Einstellung::set(
|
||||||
|
Wertpapierkosten::schluessel('bankprovision'),
|
||||||
|
(string)Kategorie::anlegen(['name' => 'KL Bankprovision', 'art' => 'ausgabe']),
|
||||||
|
'wertpapiere'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->geschaeftOhneKosten();
|
||||||
|
$this->geschaeftMitKosten();
|
||||||
|
$this->leereAuswahlLoeschtDieKlasse();
|
||||||
|
$this->ohneAngabeBleibtDieKlasse();
|
||||||
|
$this->unbekannteKlasse();
|
||||||
|
$this->depotbuchungMitGeldbuchung();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buchung(float $betrag, string $datum, ?int $klasse = null): int
|
||||||
|
{
|
||||||
|
return Buchung::anlegen([
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'datum' => $datum,
|
||||||
|
'betrag' => $betrag,
|
||||||
|
'klasse_id' => $klasse,
|
||||||
|
'quelle' => 'banking',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function geschaeftOhneKosten(): void
|
||||||
|
{
|
||||||
|
$id = $this->buchung(-500.0, '2026-09-01');
|
||||||
|
|
||||||
|
Wertpapiergeschaeft::buchen(Buchung::find($id), [
|
||||||
|
'depot_id' => $this->depot,
|
||||||
|
'wertpapier_name' => 'KL Ohne Kosten AG',
|
||||||
|
'stueck' => 5,
|
||||||
|
'klasse_id' => $this->klasse,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$buchung = Buchung::find($id);
|
||||||
|
$this->assertEquals($this->depot, (int)$buchung['gegenkonto_id'], 'Die Umbuchung ins Depot entsteht');
|
||||||
|
$this->assertEquals($this->klasse, (int)$buchung['klasse_id'], 'Die gewählte Klasse steht an der Buchung');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function geschaeftMitKosten(): void
|
||||||
|
{
|
||||||
|
$id = $this->buchung(-255.0, '2026-09-02');
|
||||||
|
|
||||||
|
Wertpapiergeschaeft::buchen(Buchung::find($id), [
|
||||||
|
'depot_id' => $this->depot,
|
||||||
|
'wertpapier_name' => 'KL Mit Kosten AG',
|
||||||
|
'stueck' => 2,
|
||||||
|
'kosten' => ['bankprovision' => 5.0],
|
||||||
|
'klasse_id' => $this->klasse,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$buchung = Buchung::find($id);
|
||||||
|
$this->assertEquals($this->klasse, (int)$buchung['klasse_id'], 'Auch die Splitbuchung trägt die Klasse');
|
||||||
|
|
||||||
|
$splits = Buchung::splits($id);
|
||||||
|
$this->assertEquals(2, count($splits), 'Depot und Bankprovision');
|
||||||
|
foreach ($splits as $nr => $zeile) {
|
||||||
|
$this->assertEquals($this->klasse, (int)$zeile['klasse_id'], 'Splitzeile ' . $nr . ' trägt die Klasse');
|
||||||
|
}
|
||||||
|
|
||||||
|
$anzeige = Buchung::splitKlasse($splits, (int)$buchung['klasse_id']);
|
||||||
|
$this->assert(!$anzeige['gemischt'], 'Das Kontoblatt zeigt keine gemischten Klassen');
|
||||||
|
$this->assertEquals($this->klasse, $anzeige['klasse_id'], 'Sondern die eine gewählte Klasse');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Maske ist mit der Klasse der Buchung vorbelegt – leer heißt: keine. */
|
||||||
|
private function leereAuswahlLoeschtDieKlasse(): void
|
||||||
|
{
|
||||||
|
$id = $this->buchung(-100.0, '2026-09-03', $this->andereKlasse);
|
||||||
|
|
||||||
|
Wertpapiergeschaeft::buchen(Buchung::find($id), [
|
||||||
|
'depot_id' => $this->depot,
|
||||||
|
'wertpapier_name' => 'KL Leer AG',
|
||||||
|
'stueck' => 1,
|
||||||
|
'klasse_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNull(Buchung::find($id)['klasse_id'], 'Die leere Auswahl entfernt die Klasse');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ohneAngabeBleibtDieKlasse(): void
|
||||||
|
{
|
||||||
|
$id = $this->buchung(-110.0, '2026-09-04', $this->andereKlasse);
|
||||||
|
|
||||||
|
Wertpapiergeschaeft::buchen(Buchung::find($id), [
|
||||||
|
'depot_id' => $this->depot,
|
||||||
|
'wertpapier_name' => 'KL Ohne Angabe AG',
|
||||||
|
'stueck' => 1,
|
||||||
|
'kosten' => ['bankprovision' => 10.0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
$this->andereKlasse,
|
||||||
|
(int)Buchung::find($id)['klasse_id'],
|
||||||
|
'Ohne Angabe bleibt die Klasse der Buchung'
|
||||||
|
);
|
||||||
|
foreach (Buchung::splits($id) as $zeile) {
|
||||||
|
$this->assertEquals($this->andereKlasse, (int)$zeile['klasse_id'], 'Und die Splitzeilen übernehmen sie');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unbekannteKlasse(): void
|
||||||
|
{
|
||||||
|
$id = $this->buchung(-120.0, '2026-09-05');
|
||||||
|
|
||||||
|
$abgewiesen = false;
|
||||||
|
try {
|
||||||
|
Wertpapiergeschaeft::buchen(Buchung::find($id), [
|
||||||
|
'depot_id' => $this->depot,
|
||||||
|
'wertpapier_name' => 'KL Unbekannt AG',
|
||||||
|
'stueck' => 1,
|
||||||
|
'klasse_id' => 999999999,
|
||||||
|
]);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
$abgewiesen = true;
|
||||||
|
}
|
||||||
|
$this->assert($abgewiesen, 'Eine unbekannte Klasse wird abgewiesen');
|
||||||
|
$this->assertNull(Depotbuchung::findeZuBuchung($id), 'Und es entsteht kein Geschäft');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Depotbuchung unter Wertpapiere legt die Geldbuchung selbst an. */
|
||||||
|
private function depotbuchungMitGeldbuchung(): void
|
||||||
|
{
|
||||||
|
$papier = Wertpapier::findenOderAnlegen(['name' => 'KL Depot AG', 'isin' => 'DE000KL00001']);
|
||||||
|
|
||||||
|
$depotbuchungId = Depotbuchung::anlegen([
|
||||||
|
'konto_id' => $this->depot,
|
||||||
|
'wertpapier_id' => $papier,
|
||||||
|
'datum' => '2026-09-06',
|
||||||
|
'vorgang' => 'kauf',
|
||||||
|
'stueck' => 10,
|
||||||
|
'kurs' => 20,
|
||||||
|
'betrag' => 200,
|
||||||
|
'verrechnungskonto_id' => $this->giro,
|
||||||
|
'klasse_id' => $this->klasse,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$geldbuchung = Buchung::find((int)Depotbuchung::find($depotbuchungId)['buchung_id']);
|
||||||
|
$this->assertNotEmpty($geldbuchung, 'Die Geldbuchung entsteht');
|
||||||
|
$this->assertEquals($this->klasse, (int)$geldbuchung['klasse_id'], 'Mit der gewählten Klasse');
|
||||||
|
|
||||||
|
$abgewiesen = false;
|
||||||
|
try {
|
||||||
|
Depotbuchung::anlegen([
|
||||||
|
'konto_id' => $this->depot,
|
||||||
|
'wertpapier_id' => $papier,
|
||||||
|
'datum' => '2026-09-07',
|
||||||
|
'vorgang' => 'kauf',
|
||||||
|
'stueck' => 1,
|
||||||
|
'betrag' => 20,
|
||||||
|
'verrechnungskonto_id' => $this->giro,
|
||||||
|
'klasse_id' => 999999999,
|
||||||
|
]);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
$abgewiesen = true;
|
||||||
|
}
|
||||||
|
$this->assert($abgewiesen, 'Auch hier wird eine unbekannte Klasse abgewiesen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ const GIRO = `E2E WP Giro ${MARKE}`
|
||||||
const DEPOT = `E2E WP Depot ${MARKE}`
|
const DEPOT = `E2E WP Depot ${MARKE}`
|
||||||
const KAT_GEBUEHR = `E2E Bankprovision ${MARKE}`
|
const KAT_GEBUEHR = `E2E Bankprovision ${MARKE}`
|
||||||
const KAT_STEUER = `E2E Kapitalertragsteuer ${MARKE}`
|
const KAT_STEUER = `E2E Kapitalertragsteuer ${MARKE}`
|
||||||
|
const KLASSE = `E2E WP Klasse ${MARKE}`
|
||||||
|
|
||||||
// ISIN und WKN tragen die Marke des Laufs: Ein Papier wird über seine Kennung
|
// ISIN und WKN tragen die Marke des Laufs: Ein Papier wird über seine Kennung
|
||||||
// wiedergefunden, nicht über den Namen. Feste Kennungen würden den Test beim
|
// wiedergefunden, nicht über den Namen. Feste Kennungen würden den Test beim
|
||||||
|
|
@ -87,6 +88,11 @@ test.describe('Wertpapiergeschäft aus dem Kontoblatt', () => {
|
||||||
await kategorieAnlegen(page, KAT_GEBUEHR)
|
await kategorieAnlegen(page, KAT_GEBUEHR)
|
||||||
await kategorieAnlegen(page, KAT_STEUER)
|
await kategorieAnlegen(page, KAT_STEUER)
|
||||||
|
|
||||||
|
await page.goto('/verwaltung/klassen')
|
||||||
|
await page.click('#neu-knopf')
|
||||||
|
await page.fill('form[action="/verwaltung/klassen/neu"] #name', KLASSE)
|
||||||
|
await page.click('form[action="/verwaltung/klassen/neu"] button.primaer')
|
||||||
|
|
||||||
// Die Kostenarten bekommen ihre feste Kategorie – danach fragt der
|
// Die Kostenarten bekommen ihre feste Kategorie – danach fragt der
|
||||||
// Kostendialog nur noch Beträge ab.
|
// Kostendialog nur noch Beträge ab.
|
||||||
await page.goto('/einstellungen?bereich=wertpapiere')
|
await page.goto('/einstellungen?bereich=wertpapiere')
|
||||||
|
|
@ -173,12 +179,17 @@ test.describe('Wertpapiergeschäft aus dem Kontoblatt', () => {
|
||||||
await page.fill('#wp_name', `Sparplan-Fonds ${MARKE}`)
|
await page.fill('#wp_name', `Sparplan-Fonds ${MARKE}`)
|
||||||
await page.fill('#wp_stueck', '10')
|
await page.fill('#wp_stueck', '10')
|
||||||
|
|
||||||
|
// Die Klasse wird im Dialog gleich mitgewählt (#125).
|
||||||
|
await expect(page.locator('#wp_klasse')).toHaveValue('')
|
||||||
|
await page.selectOption('#wp_klasse', { label: KLASSE })
|
||||||
|
|
||||||
await page.locator('#wertpapierdialog button[type="submit"]').click()
|
await page.locator('#wertpapierdialog button[type="submit"]').click()
|
||||||
await expect(page.locator('.meldung-erfolg')).toContainText('Einstandspreis 1.000,00')
|
await expect(page.locator('.meldung-erfolg')).toContainText('Einstandspreis 1.000,00')
|
||||||
|
|
||||||
// Keine Splitbuchung mit einer einzigen Zeile, sondern eine Umbuchung.
|
// Keine Splitbuchung mit einer einzigen Zeile, sondern eine Umbuchung.
|
||||||
await expect(page.locator('tr', { hasText: empfaenger }).first())
|
const danach = page.locator('tr', { hasText: empfaenger }).first()
|
||||||
.toContainText(`[${DEPOT}]`)
|
await expect(danach).toContainText(`[${DEPOT}]`)
|
||||||
|
await expect(danach.locator('a.klassenfeld')).toHaveText(KLASSE)
|
||||||
|
|
||||||
// Ohne Kennung im Verwendungszweck entsteht das Papier unter dem
|
// Ohne Kennung im Verwendungszweck entsteht das Papier unter dem
|
||||||
// Namen, der in der Maske steht.
|
// Namen, der in der Maske steht.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue