diff --git a/CHANGELOG.md b/CHANGELOG.md index db206ee..898b3d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ### Added +- Depotbuchung erfassen: Bei Kauf und Verkauf rechnet sich aus zwei der drei Felder + Betrag, Stück und Kurs automatisch das dritte (Betrag = Stück × Kurs) (#129) - 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) diff --git a/Changes.md b/Changes.md index c999513..169f03c 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,13 @@ # Änderungen +## Depotbuchung erfassen: Stück, Kurs und Betrag rechnen sich (12.10.2026) + +- Anlass (#129): Bei Kauf und Verkauf sollten sich Betrag, Stück und Kurs gegenseitig ergeben – Betrag = Stück × Kurs. +- Die Maske *Depotbuchung erfassen* rechnet bei Kauf und Verkauf das dritte Feld aus, sobald zwei eingetragen sind (`public/static/depotbuchung.js`): den Betrag auf zwei, Kurs und Stück auf sechs Nachkommastellen. Der ausgerechnete Wert ist farblich hinterlegt und zieht nach, wenn sich eines der anderen Felder ändert; selbst umgetippt gilt er als eingetragen, beim Leeren eines der anderen Felder verschwindet er. Bei Dividende, Ein- und Auslieferung wird nicht gerechnet; wechselt der Vorgang auf Kauf oder Verkauf, rechnet die Maske nach. +- Serverseitig dieselbe Regel für den Weg ohne JavaScript (`Depotbuchung::dritterWert()`): Neben dem Betrag ergeben sich jetzt auch Kurs bzw. Stück bei Kauf und Verkauf. +- Der Dialog des Wertpapiergeschäfts im Kontoblatt bleibt unverändert: Dort ist der Buchungsbetrag die feste Größe. +- Tests: `tests/63_DepotbuchungDritterWertTest.php`, `tests/e2e/specs/depotbuchung.spec.js`. + ## Wertpapiere: Klasse beim Buchen wählen (12.10.2026) - Anlass (#125): Beim Buchen von Wertpapieren fehlte die Auswahl einer Klasse. diff --git a/docs/wertpapiere.md b/docs/wertpapiere.md index 6e0790c..73dad2a 100644 --- a/docs/wertpapiere.md +++ b/docs/wertpapiere.md @@ -108,6 +108,13 @@ enthält. `stueck` trägt die Richtung: Zugänge positiv, Abgänge negativ, Dividenden null. +In der Maske *Depotbuchung erfassen* genügen bei Kauf und Verkauf zwei der drei Angaben +Stück, Kurs und Betrag: Sobald zwei eingetragen sind, rechnet `public/static/depotbuchung.js` +das dritte aus (Betrag auf zwei, Stück und Kurs auf sechs Nachkommastellen) und hebt es +farblich hervor. Ein ausgerechneter Wert wird nachgezogen, wenn sich eines der anderen +Felder ändert; tippt man ihn selbst um, gilt er als eingetragen. Ohne JavaScript rechnet +`Depotbuchung::dritterWert()` dieselbe Regel beim Speichern. + ## Einstand nach gleitendem Durchschnitt Bei jedem Kauf steigt der Einstand um Kaufpreis samt Gebühren, bei jedem Verkauf sinkt er diff --git a/public/static/app.css b/public/static/app.css index 26ffc4f..a60176a 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -1622,6 +1622,10 @@ table.liste th.bargeldspalte { margin: .15rem 0 0; } +/* Ein Feld, dessen Wert die Maske aus den anderen ausgerechnet hat + * (Stück, Kurs, Betrag in „Depotbuchung erfassen“). */ +input.berechnet { background: var(--akzent-schwach); } + /* Der Hinweis hinter einem Ankreuzfeld statt darunter – bei einer Liste gleichartiger Schalter hielte eine eigene Zeile je Hinweis sie zu weit auseinander. */ diff --git a/public/static/depotbuchung.js b/public/static/depotbuchung.js new file mode 100644 index 0000000..91dcb86 --- /dev/null +++ b/public/static/depotbuchung.js @@ -0,0 +1,111 @@ +/* Stück, Kurs und Betrag in der Maske „Depotbuchung erfassen“. + * + * Bei Kauf und Verkauf gilt Betrag = Stück × Kurs: Sobald zwei der drei Felder + * eingetragen sind, ergibt sich das dritte. Der ausgerechnete Wert bleibt + * ausgerechnet – ändert man danach eines der beiden anderen Felder, wird er + * nachgezogen. Tippt man ihn selbst um, gilt er als eingetragen; leert man eines + * der beiden anderen Felder, verschwindet er wieder, weil nichts mehr dahinter + * steht. + * + * Der Server rechnet dieselbe Regel noch einmal (Depotbuchung::dritterWert) – + * für den Weg ohne JavaScript. + */ + +(function () { + 'use strict'; + + const formular = document.getElementById('depotbuchungsformular'); + if (!formular || !window.FmBetrag) { + return; + } + + const vorgang = document.getElementById('vorgang'); + const felder = { + stueck: document.getElementById('stueck'), + kurs: document.getElementById('kurs'), + betrag: document.getElementById('betrag') + }; + if (!vorgang || !felder.stueck || !felder.kurs || !felder.betrag) { + return; + } + + // Welches Feld zuletzt ausgerechnet wurde – null, solange alle eingetragen sind. + let berechnet = null; + + function wert(name) { + return window.FmBetrag.zahl(felder[name].value); + } + + function leer(name) { + return felder[name].value.trim() === ''; + } + + function formatiere(name, zahl) { + if (name === 'betrag') { + return window.FmBetrag.formatiere(zahl); + } + // Stück und Kurs führt die Datenbank mit sechs Nachkommastellen. + return zahl.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 6 }); + } + + function setze(name, zahl) { + felder[name].value = zahl === null ? '' : formatiere(name, zahl); + felder[name].classList.toggle('berechnet', zahl !== null); + berechnet = zahl === null ? null : name; + } + + function ausrechnen(name) { + const stueck = wert('stueck'); + const kurs = wert('kurs'); + const betrag = wert('betrag'); + + switch (name) { + case 'betrag': + return stueck > 0 && kurs > 0 ? Math.round(stueck * kurs * 100) / 100 : null; + case 'kurs': + return stueck > 0 && betrag > 0 ? Math.round(betrag / stueck * 1e6) / 1e6 : null; + default: + return kurs > 0 && betrag > 0 ? Math.round(betrag / kurs * 1e6) / 1e6 : null; + } + } + + function rechne(geaendert) { + if (geaendert !== null && geaendert === berechnet) { + felder[geaendert].classList.remove('berechnet'); + berechnet = null; + } + if (vorgang.value !== 'kauf' && vorgang.value !== 'verkauf') { + return; + } + + if (geaendert !== null && leer(geaendert) && berechnet !== null) { + setze(berechnet, null); + return; + } + + let ziel = berechnet; + if (ziel === null) { + const offen = Object.keys(felder).filter(leer); + if (offen.length !== 1) { + return; + } + ziel = offen[0]; + } + + const ergebnis = ausrechnen(ziel); + if (ergebnis !== null) { + setze(ziel, ergebnis); + } else if (ziel === berechnet) { + setze(ziel, null); + } + } + + Object.keys(felder).forEach(function (name) { + felder[name].addEventListener('input', function () { + rechne(name); + }); + }); + vorgang.addEventListener('change', function () { + rechne(null); + }); +})(); diff --git a/src/Controllers/WertpapiereController.php b/src/Controllers/WertpapiereController.php index c4b8a36..071a179 100644 --- a/src/Controllers/WertpapiereController.php +++ b/src/Controllers/WertpapiereController.php @@ -141,21 +141,25 @@ class WertpapiereController extends BaseController ]); } - $stueck = $this->zahl($this->post('stueck')); - $kurs = $this->zahl($this->post('kurs')); - $betrag = $this->zahl($this->post('betrag')); - // Zwei von drei Angaben genügen: Der dritte Wert ergibt sich. - if ($betrag <= 0.0 && $stueck > 0.0 && $kurs > 0.0) { - $betrag = round($stueck * $kurs, 2); - } + $vorgang = $this->post('vorgang', 'kauf'); + [ + 'stueck' => $stueck, + 'kurs' => $kurs, + 'betrag' => $betrag, + ] = Depotbuchung::dritterWert( + $vorgang, + $this->zahl($this->post('stueck')), + $this->zahl($this->post('kurs')), + $this->zahl($this->post('betrag')) + ); Depotbuchung::anlegen([ 'konto_id' => (int)$this->post('konto_id'), 'wertpapier_id' => $wertpapierId, 'datum' => $this->post('datum'), 'valuta' => $this->post('valuta'), - 'vorgang' => $this->post('vorgang', 'kauf'), + 'vorgang' => $vorgang, 'stueck' => $stueck, 'kurs' => $kurs > 0.0 ? $kurs : null, 'betrag' => $betrag, diff --git a/src/Models/Depotbuchung.php b/src/Models/Depotbuchung.php index dc366fe..2f154ef 100644 --- a/src/Models/Depotbuchung.php +++ b/src/Models/Depotbuchung.php @@ -69,6 +69,32 @@ class Depotbuchung return self::QIF_VORGAENGE[trim($vorgang)] ?? 'kauf'; } + /** + * Zwei von drei Angaben genügen: Betrag = Stück × Kurs. + * + * Die Maske „Depotbuchung erfassen“ rechnet den dritten Wert schon im + * Browser aus (`depotbuchung.js`); hier steht dieselbe Regel für den Weg + * ohne JavaScript. Der Betrag ergibt sich bei jedem Vorgang, Kurs und Stück + * nur bei Kauf und Verkauf – nur dort hängen die drei sicher zusammen. + * Eine fehlende Angabe ist 0. + * + * @return array{stueck:float,kurs:float,betrag:float} + */ + public static function dritterWert(string $vorgang, float $stueck, float $kurs, float $betrag): array + { + if ($betrag <= 0.0 && $stueck > 0.0 && $kurs > 0.0) { + $betrag = round($stueck * $kurs, 2); + } elseif (in_array($vorgang, ['kauf', 'verkauf'], true) && $betrag > 0.0) { + if ($kurs <= 0.0 && $stueck > 0.0) { + $kurs = round($betrag / $stueck, 6); + } elseif ($stueck <= 0.0 && $kurs > 0.0) { + $stueck = round($betrag / $kurs, 6); + } + } + + return ['stueck' => $stueck, 'kurs' => $kurs, 'betrag' => $betrag]; + } + public static function find(int $id): ?array { $zeile = Database::fetchOne( diff --git a/src/Views/wertpapiere/formular.php b/src/Views/wertpapiere/formular.php index 094af0c..ccfc81a 100644 --- a/src/Views/wertpapiere/formular.php +++ b/src/Views/wertpapiere/formular.php @@ -19,7 +19,7 @@ include __DIR__ . '/../layout/kopf.php'; Zurück zu den Depotbuchungen -
+ @@ -83,7 +83,8 @@ include __DIR__ . '/../layout/kopf.php';

Beträge

- Zwei von drei Angaben genügen: Fehlt der Betrag, ergibt er sich aus Stück mal Kurs. + Zwei von drei Angaben genügen: Betrag = Stück × Kurs. Bei Kauf und Verkauf rechnet + die Maske den dritten Wert aus, sobald zwei eingetragen sind. Gebühren und Steuern stehen neben dem Betrag – auf dem Verrechnungskonto wird bei einem Kauf die Summe belastet, bei einem Verkauf die Differenz gutgeschrieben.

@@ -143,4 +144,7 @@ include __DIR__ . '/../layout/kopf.php';
+ + + diff --git a/tests/63_DepotbuchungDritterWertTest.php b/tests/63_DepotbuchungDritterWertTest.php new file mode 100644 index 0000000..88b4459 --- /dev/null +++ b/tests/63_DepotbuchungDritterWertTest.php @@ -0,0 +1,52 @@ +assertEquals(548.04, $werte['betrag'], 'Kauf: Betrag = Stück × Kurs'); + $this->assertEquals(12.0, $werte['stueck'], 'Kauf: Stück bleibt'); + $this->assertEquals(45.67, $werte['kurs'], 'Kauf: Kurs bleibt'); + + $werte = Depotbuchung::dritterWert('verkauf', 8.0, 0.0, 1000.0); + $this->assertEquals(125.0, $werte['kurs'], 'Verkauf: Kurs = Betrag / Stück'); + + $werte = Depotbuchung::dritterWert('kauf', 0.0, 3.0, 100.0); + $this->assertEquals(33.333333, $werte['stueck'], 'Kauf: Stück = Betrag / Kurs, sechs Nachkommastellen'); + + $werte = Depotbuchung::dritterWert('kauf', 10.0, 20.0, 150.0); + $this->assertEquals( + ['stueck' => 10.0, 'kurs' => 20.0, 'betrag' => 150.0], + $werte, + 'Alle drei angegeben: nichts wird überschrieben' + ); + + $werte = Depotbuchung::dritterWert('kauf', 0.0, 0.0, 150.0); + $this->assertEquals( + ['stueck' => 0.0, 'kurs' => 0.0, 'betrag' => 150.0], + $werte, + 'Nur eine Angabe: nichts zu rechnen' + ); + + $werte = Depotbuchung::dritterWert('dividende', 0.0, 2.0, 50.0); + $this->assertEquals(0.0, $werte['stueck'], 'Dividende: keine Stückzahl aus Betrag und Kurs'); + + $werte = Depotbuchung::dritterWert('einlage', 5.0, 0.0, 500.0); + $this->assertEquals(0.0, $werte['kurs'], 'Einlieferung: kein Kurs aus Betrag und Stück'); + + $werte = Depotbuchung::dritterWert('einlage', 5.0, 10.0, 0.0); + $this->assertEquals(50.0, $werte['betrag'], 'Einlieferung: Betrag aus Stück × Kurs wie bisher'); + } +} diff --git a/tests/e2e/specs/depotbuchung.spec.js b/tests/e2e/specs/depotbuchung.spec.js new file mode 100644 index 0000000..a2c4036 --- /dev/null +++ b/tests/e2e/specs/depotbuchung.spec.js @@ -0,0 +1,79 @@ +// @ts-check +const { test, expect } = require('@playwright/test') + +/* + * Fix #129: In „Depotbuchung erfassen“ hängen bei Kauf und Verkauf Stück, Kurs + * und Betrag zusammen – Betrag = Stück × Kurs. Sobald zwei Felder eingetragen + * sind, rechnet die Maske das dritte aus. + */ + +const BENUTZER = process.env.E2E_USER || 'e2e' +const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' + +async function anmelden(page) { + await page.goto('/login') + if (page.url().includes('/einrichtung')) { + throw new Error( + 'Instanz ist noch nicht eingerichtet: ' + + 'docker compose exec -u www-data php php scripts/e2e-benutzer.php' + ) + } + await page.fill('#benutzername', BENUTZER) + await page.fill('#passwort', PASSWORT) + await page.click('button[type="submit"]') + await expect(page).toHaveURL(/\/$/) +} + +test.describe('Depotbuchung erfassen: Stück, Kurs und Betrag', () => { + test.beforeEach(async ({ page }) => { + await anmelden(page) + await page.goto('/wertpapiere/buchungen/neu') + }) + + test('Aus Stück und Kurs ergibt sich der Betrag', async ({ page }) => { + await page.selectOption('#vorgang', 'kauf') + await page.fill('#stueck', '12') + await page.fill('#kurs', '45,67') + await expect(page.locator('#betrag')).toHaveValue('548,04') + await expect(page.locator('#betrag')).toHaveClass(/berechnet/) + + // Der ausgerechnete Betrag zieht nach, wenn sich ein Feld ändert. + await page.fill('#stueck', '10') + await expect(page.locator('#betrag')).toHaveValue('456,70') + + // Wird ein Feld geleert, steht hinter dem Betrag nichts mehr. + await page.fill('#kurs', '') + await expect(page.locator('#betrag')).toHaveValue('') + }) + + test('Aus Betrag und Stück ergibt sich der Kurs', async ({ page }) => { + await page.selectOption('#vorgang', 'verkauf') + await page.fill('#betrag', '1.000,00') + await page.fill('#stueck', '8') + await expect(page.locator('#kurs')).toHaveValue('125') + }) + + test('Aus Betrag und Kurs ergibt sich die Stückzahl', async ({ page }) => { + await page.selectOption('#vorgang', 'kauf') + await page.fill('#kurs', '3') + await page.fill('#betrag', '100') + await expect(page.locator('#stueck')).toHaveValue('33,333333') + + // Selbst eingetragen gilt der Wert als fest und wird nicht mehr überschrieben. + await page.fill('#stueck', '30') + await page.fill('#kurs', '4') + await expect(page.locator('#stueck')).toHaveValue('30') + await expect(page.locator('#betrag')).toHaveValue('100') + }) + + test('Bei einer Dividende wird nichts gerechnet', async ({ page }) => { + await page.selectOption('#vorgang', 'dividende') + await page.fill('#stueck', '12') + await page.fill('#kurs', '2') + await expect(page.locator('#betrag')).toHaveValue('') + + // Wechselt der Vorgang auf Kauf, rechnet die Maske nach. + await page.selectOption('#vorgang', 'kauf') + await expect(page.locator('#betrag')).toHaveValue('24,00') + }) +})