From e56c21bc9b05d5d9f9b9e616959d9ffd4699e6d8 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Thu, 13 Aug 2026 13:27:52 +0200 Subject: [PATCH] =?UTF-8?q?Wertpapiergesch=C3=A4ft=20aus=20einer=20Kontobu?= =?UTF-8?q?chung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Bankabruf bringt die Wertpapierabrechnung als Umsatz auf dem Girokonto, und erst dort entscheidet sich, dass es ein Kauf war. Wer die Buchung einem Depot zuordnete, buchte bisher eine Umbuchung – ohne Stückzahl, ohne Kurs, ohne Einstand. Die Zuordnungsliste im Kontoblatt kennt jetzt neben Kategorie und Gegenkonto eine dritte Gruppe "Wertpapiergeschäft im Depot", die statt zu speichern die Wertpapiermaske öffnet; ohne JavaScript führt derselbe Weg auf /buchungen/{id}/wertpapier. Die Maske ist auf den "Wertpapier kaufen"-Dialog des FinanzManagers zugeschnitten: vorne Vorgangsart, Datum, Wertpapier, Stück und Kurs, dahinter hinter dem Knopf "Kosten …" die Nebenkosten nach Arten – sechs Gebühren und sechs Steuern. ISIN und WKN werden aus dem Verwendungszweck gelesen, ein bekanntes Papier ist damit vorausgewählt. Ein Abfluss vom Konto kann nur ein Kauf sein, ein Zufluss ein Verkauf oder eine Dividende. Der Buchungsbetrag ist die feste Größe: Die Nebenkosten werden aus ihm herausgerechnet, was übrig bleibt ist der Kurswert und damit der Einstandspreis. Im Kontoblatt entsteht eine Splitbuchung mit je einer Zeile pro belegter Kostenart und dem Rest als Umbuchung ins Depot; ohne Nebenkosten bleibt es bei der schlichten Umbuchung. Die Depotbuchung hängt sich an die vorhandene Buchung, statt eine zweite anzulegen (neue Spalte depot_buchungen.buchung_erzeugt) – wird sie gelöscht, bleibt die Buchung der Bank stehen und ist nur wieder unzugeordnet. Die Kategorien der Kostenarten liegen im Einstellungsdialog unter "Wertpapiere", einmalig je Art; für "Sonstige Kosten" ist die Einstellung nur die Vorgabe und im Dialog überschreibbar. Arten ohne Kategorie werden zu Splitzeilen ohne Zuordnung, nicht zu einem Fehler. Co-Authored-By: Claude Opus 5 --- Changes.md | 47 +++ README.md | 5 +- docs/buchungen.md | 14 + docs/wertpapiere.md | 82 ++++- migrations/012_wertpapierkosten.sql | 34 ++ public/index.php | 6 + public/static/app.css | 14 + public/static/kontoblatt.js | 27 ++ public/static/wertpapierbuchung.js | 178 +++++++++ src/Controllers/BuchungenController.php | 153 +++++++- src/Controllers/EinstellungenController.php | 22 +- src/Models/Depotbuchung.php | 55 ++- src/Models/Wertpapier.php | 27 ++ src/Services/Wertpapiergeschaeft.php | 204 +++++++++++ src/Services/Wertpapierkosten.php | 147 ++++++++ src/Views/buchungen/formular.php | 6 +- src/Views/buchungen/kontoblatt.php | 65 +++- src/Views/buchungen/wertpapier.php | 71 ++++ src/Views/buchungen/wertpapierfelder.php | 106 ++++++ src/Views/buchungen/wertpapierkosten.php | 83 +++++ src/Views/buchungen/zuordnungsliste.php | 25 ++ src/Views/einstellungen/index.php | 28 ++ tests/25_WertpapiergeschaeftTest.php | 384 ++++++++++++++++++++ tests/e2e/specs/wertpapiergeschaeft.spec.js | 173 +++++++++ 24 files changed, 1939 insertions(+), 17 deletions(-) create mode 100644 migrations/012_wertpapierkosten.sql create mode 100644 public/static/wertpapierbuchung.js create mode 100644 src/Services/Wertpapiergeschaeft.php create mode 100644 src/Services/Wertpapierkosten.php create mode 100644 src/Views/buchungen/wertpapier.php create mode 100644 src/Views/buchungen/wertpapierfelder.php create mode 100644 src/Views/buchungen/wertpapierkosten.php create mode 100644 tests/25_WertpapiergeschaeftTest.php create mode 100644 tests/e2e/specs/wertpapiergeschaeft.spec.js diff --git a/Changes.md b/Changes.md index d881c0b..2923071 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,52 @@ # Änderungen +## Wertpapiergeschäft aus der Kontobuchung (13.08.2026) + +Bisher konnte eine Depotbuchung ihre Geldbuchung nur selbst anlegen. Der Weg, den der +Alltag nimmt, fehlte: Der Bankabruf bringt die Wertpapierabrechnung als Umsatz auf dem +Girokonto, und erst dort entscheidet sich, dass es ein Kauf war. Wer die Buchung dann +einem Depot zuordnet, buchte eine Umbuchung – ohne Stückzahl, ohne Kurs, ohne Einstand. + +**Das Depot in der Zuordnungsliste** (`Views/buchungen/zuordnungsliste.php`, +`kontoblatt.js`) +- Unter *Kategorie / Gegenkonto* steht eine dritte Gruppe: *Wertpapiergeschäft im Depot*. + Die Auswahl speichert nicht sofort, sondern öffnet die Wertpapiermaske. +- Die schlichte Umbuchung auf ein Depot bleibt daneben stehen – auf ein Depot lässt sich + auch Geld überweisen, ohne dass etwas gekauft wird. +- Ohne JavaScript führt derselbe Weg auf `/buchungen/{id}/wertpapier` mit denselben + Feldern; auch die Buchungsmaske leitet dorthin. + +**Die beiden Dialoge** (`Views/buchungen/wertpapierfelder.php`, `wertpapierkosten.php`, +`wertpapierbuchung.js`) – zugeschnitten auf den *Wertpapier kaufen*-Dialog des +FinanzManagers: +- Vorne Vorgangsart, Datum, Wertpapier, Stück und Kurs; dahinter, hinter dem Knopf + *Kosten …*, die Nebenkosten nach Arten: sechs Gebühren (Bankprovision, Maklercourtage, + Börsenplatzentgelt, Spesen, Spesen Ausland, Sonstige Kosten) und sechs Steuern + (Zinsabschlagsteuer, Kapitalertragsteuer, Solidaritätszuschlag, Ausländische + Quellensteuer, Abgeltungsteuer, Kirchensteuer). +- ISIN und WKN werden aus dem Verwendungszweck gelesen (`Wertpapier::ausText()`); ein + bekanntes Papier ist damit vorausgewählt, ein unbekanntes vorausgefüllt. +- Ein Abfluss vom Konto kann nur ein Kauf sein, ein Zufluss ein Verkauf oder eine + Dividende – was nicht passt, steht gar nicht zur Wahl. + +**Was gebucht wird** (`Services\Wertpapiergeschaeft`, `Services\Wertpapierkosten`) +- Der Buchungsbetrag ist die feste Größe. Die Nebenkosten werden aus ihm + herausgerechnet; was übrig bleibt, ist der Kurswert und damit der **Einstandspreis**. + Ein Kauf über 255,00 € mit 5,00 € Kosten ergibt 250,00 € Einstand, ein Verkauf mit + 3.103,17 € Gutschrift und 84,83 € Kosten einen Bruttoerlös von 3.188,00 €. +- Im Kontoblatt entsteht eine **Splitbuchung**: je belegter Kostenart eine Zeile in ihrer + Kategorie, der Rest als Umbuchung ins Depot. Die Zeilen ergeben genau den + Buchungsbetrag. Ohne Nebenkosten bleibt es bei der schlichten Umbuchung. +- Die Depotbuchung hängt sich an die vorhandene Buchung, statt eine zweite anzulegen + (neue Spalte `depot_buchungen.buchung_erzeugt`, `migrations/012_wertpapierkosten.sql`). + Wird sie gelöscht, bleibt die Buchung der Bank stehen und ist nur wieder unzugeordnet. + +**Kategorien der Kostenarten** (`Views/einstellungen/index.php`, Bereich *Wertpapiere*) +- Je Kostenart einmalig eine Kategorie hinterlegen; der Kostendialog fragt dann nur noch + Beträge ab. Für *Sonstige Kosten* ist die Einstellung nur die Vorgabe – sie lässt sich + im Dialog überschreiben, wie im FinanzManager. +- Arten ohne Kategorie werden zu Splitzeilen ohne Zuordnung, nicht zu einem Fehler. + ## Stichtag für den Umsatzabruf (13.08.2026) Der Abrufzeitraum kannte bisher nur Spannen: seit dem letzten Abruf, 7, 30 oder 365 Tage. diff --git a/README.md b/README.md index 9f006eb..e0453e8 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,10 @@ Haushalt wird beides parallel genutzt. die automatische Kategorisierung. * **Wertpapiere** – Depotbestände mit Einstand und Bewertung, Depotbuchungen, Kursabruf, Veräußerungsgeschäfte und Erträge; Bestandslisten der Bank lassen sich als CSV - übernehmen. + übernehmen. Eine abgerufene Wertpapierabrechnung wird im Kontoblatt zum Geschäft: Das + Depot in der Zuordnungsliste öffnet die Maske für Stück, Kurs und Nebenkosten, und + daraus entstehen die Depotbuchung mit dem Einstandspreis und die Splitbuchung, die die + Gebühren und Steuern auf ihre Kategorien verteilt. * **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und Finanzkalender, Erinnerungen, Adressen, Verträge, Sparziele, Kredite mit Tilgungsplan. * **Zahlungsverkehr** – Überweisungen und Terminüberweisungen über FinTS. diff --git a/docs/buchungen.md b/docs/buchungen.md index e281942..0fe834e 100644 --- a/docs/buchungen.md +++ b/docs/buchungen.md @@ -68,9 +68,18 @@ auseinandernimmt: | `kategorie:12` | Kategorie 12 | | `konto:7` | Umbuchung mit Gegenkonto 7 | | `split` | Splitdialog öffnen (nur im Kontoblatt) | +| `depot:7` | Wertpapiergeschäft im Depot 7 (Kontoblatt und Buchungsmaske) | Die Auswahl *Ausgabe / Einnahme* bestimmt seither nur noch die Richtung des Betrags. +Ein Depot steht **zweimal** in der Liste: als gewöhnliche Umbuchung (`konto:7`) und in der +Gruppe *Wertpapiergeschäft im Depot* (`depot:7`). Beides gibt es wirklich – auf ein Depot +lässt sich Geld überweisen, ohne dass etwas gekauft wird. `depot:7` speichert keine +Zuordnung, sondern öffnet die Wertpapiermaske; daraus wird am Ende meist eine Splitbuchung +(siehe `docs/wertpapiere.md`, *Geschäft aus einer Kontobuchung*). In den **Splitzeilen** +fehlt die Gruppe: Dort steht der Betrag der Zeile schon fest, und die Umbuchung ins Depot +ist genau das, was gebraucht wird. + ## Schnellzuordnung im Kontoblatt Ein Klick auf die Kategoriespalte öffnet die Liste in der Zelle; die Auswahl geht an @@ -94,6 +103,11 @@ Drei Punkte, die sich nicht ändern dürfen: rückt beim Anklicken die halbe Zeile: Die Spalte wüchse auf die Breite der längsten Kategorie, und der Empfänger daneben bräche um. +Die Auswahl eines Depots (`depot:7`) öffnet statt beider den **Wertpapierdialog**; ohne +JavaScript führt sie auf `/buchungen/{id}/wertpapier?depot=7`, wo dieselben Felder als +gewöhnliches Formular stehen. Auch die Buchungsmaske leitet dorthin, nachdem sie die +Buchung gespeichert hat. + Eine schon gesplittete Buchung öffnet auf Klick **direkt den Splitdialog**, nicht die Auswahlliste: Deren einziger sinnvoller Eintrag wäre für sie ohnehin *Splitbuchung* gewesen. Wer eine Aufteilung wieder in eine einzelne Zuordnung wandeln will, löscht sie diff --git a/docs/wertpapiere.md b/docs/wertpapiere.md index 1abb791..cc4d121 100644 --- a/docs/wertpapiere.md +++ b/docs/wertpapiere.md @@ -13,11 +13,76 @@ Jedes Wertpapiergeschäft erzeugt bis zu zwei Datensätze: Kontoblatt steht wie jede andere. Verbunden sind sie über `buchung_id`. So bleibt das Girokonto stimmig, ohne dass -Kontoblätter Stückzahlen führen müssten. Wird die Depotbuchung gelöscht, geht die -Geldbuchung mit; sie hat ohne das Geschäft keinen Sinn. +Kontoblätter Stückzahlen führen müssten. + +Beide Richtungen kommen vor, und `buchung_erzeugt` hält fest, welche es war: + +* **Depotbuchung zuerst** (Maske unter *Wertpapiere*, QIF-Import): Sie legt die + Geldbuchung an. Wird sie gelöscht, geht die Geldbuchung mit – sie hat ohne das Geschäft + keinen Sinn. +* **Geldbuchung zuerst** (Bankabruf, siehe *Geschäft aus einer Kontobuchung*): Die + Depotbuchung hängt sich an die vorhandene Buchung. Wird sie gelöscht, bleibt die Buchung + der Bank stehen und ist nur wieder unzugeordnet – sie ist deren Beleg, nicht unsere. Ein- und Auslieferungen rühren kein Konto an und haben deshalb keine Geldbuchung. +## Geschäft aus einer Kontobuchung + +Der Weg, den der Alltag nimmt: Der Bankabruf liefert die Wertpapierabrechnung als Umsatz +auf dem Girokonto – ein Betrag, ein Verwendungszweck, sonst nichts. Im Kontoblatt öffnet +die Zuordnungsliste unter *Wertpapiergeschäft im Depot* die Wertpapiermaske; die schlichte +Umbuchung auf dasselbe Depot steht daneben weiter zur Wahl. + +Zwei Dialoge, wie im FinanzManager: vorne Vorgangsart, Datum, Wertpapier, Stück und Kurs, +dahinter hinter *Kosten …* die Nebenkosten nach Arten. ISIN und WKN liest +`Wertpapier::ausText()` aus dem Verwendungszweck („WKN LYX01W … ISIN LU1834983477“) und +wählt das Papier vor. Ohne JavaScript führt derselbe Weg auf +`/buchungen/{id}/wertpapier` – dieselben Felder, untereinander statt hinter einem Knopf. + +**Der Buchungsbetrag ist die feste Größe.** Er kommt von der Bank und wird nicht +nachgerechnet. Die Nebenkosten gehen aus ihm heraus, der Rest ist der Kurswert und damit +der Einstandspreis: + +``` +Kurswert = Buchungsbetrag + Summe der Nebenkosten + +Kauf −255,00 € + 5,00 € = −250,00 € → Einstand 250,00 € +Verkauf 3.103,17 € + 84,83 € = 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. + +Im Kontoblatt entsteht daraus eine **Splitbuchung**: je belegter Kostenart eine Zeile in +ihrer Kategorie, der Rest als Umbuchung ins Depot. Die Zeilen ergeben genau den +Buchungsbetrag. Ohne Nebenkosten bleibt es bei der schlichten Umbuchung – eine +Splitbuchung mit einer einzigen Zeile wäre nur umständlicher zu lesen. + +### Kostenarten und ihre Kategorien + +`Services\Wertpapierkosten` kennt sechs Gebühren- und sechs Steuerarten – dieselben, die +der FinanzManager im Kostendialog führt: + +| Gebühren | Steuern | +|---|---| +| Bankprovision | Zinsabschlagsteuer | +| Maklercourtage | Kapitalertragsteuer | +| Börsenplatzentgelt | Solidaritätszuschlag | +| Spesen | Ausländische Quellensteuer | +| Spesen Ausland | Abgeltungsteuer | +| Sonstige Kosten | Kirchensteuer | + +Welche Kategorie zu welcher Art gehört, steht **einmal** unter *Einstellungen → +Wertpapiere* (`wertpapiere_kategorie_`); der Dialog fragt dann nur noch Beträge ab. +Einzige Ausnahme ist *Sonstige Kosten*: Deren Kategorie lässt sich an Ort und Stelle +wählen, weil sie nicht immer dieselbe ist. Arten ohne hinterlegte Kategorie werden zu +Splitzeilen ohne Zuordnung, nicht zu einem Fehler. + +In der Depotbuchung stehen die Kosten nur als zwei Summen (`gebuehren`, `steuern`) – für +den Einstand genügt das. Die Aufschlüsselung lebt in den Splitzeilen, wo sie gebraucht +wird. + ## Was in welchem Feld steht `betrag` ist der **Bruttowert**: Stück × Kurs, bei Dividenden der Bruttoertrag. Gebühren @@ -162,7 +227,10 @@ Der eingestellte Filter hängt am Ziel beider Formulare, deshalb steht danach wi dieselbe Ansicht da. Zusammengesetzt wird er im Controller neu, damit kein fremdes Ziel eingeschleust werden kann. -Mit jeder Depotbuchung geht ihre Geldbuchung (siehe *Zwei Seiten je Geschäft*). Am +Mit jeder Depotbuchung geht ihre **selbst erzeugte** Geldbuchung (siehe *Zwei Seiten je +Geschäft*). Hing sie dagegen an einer Buchung des Bankabrufs, bleibt diese stehen und +verliert nur die Aufteilung des Geschäfts – sie steht danach wieder unzugeordnet im +Kontoblatt. Am Bestand ist nichts nachzuziehen: Er wird bei jedem Aufruf aus den Depotbuchungen gerechnet und gibt die Löschung von selbst wieder her – ebenso realisierter Gewinn, Veräußerungen und Erträge. Das Wertpapier selbst bleibt in den Stammdaten stehen. @@ -189,6 +257,14 @@ mit, das Verrechnungskonto stimmt danach wieder, Bestand, Einstand, realisierter Erträge und die Veräußerungsauswertung geben die Löschung her, unbekannte Nummern in der Auswahl bleiben folgenlos. Den Ablauf im Browser prüft `tests/e2e/specs/module.spec.js`. +`tests/25_WertpapiergeschaeftTest.php` – das Geschäft aus einer Kontobuchung: das Depot als +eigener Wert der Zuordnungsliste, ISIN und WKN aus dem Verwendungszweck, Kauf mit Kosten +(Einstand, Splitzeilen, Summe gleich dem Buchungsbetrag, keine zweite Buchung daneben), +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 +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`. + `tests/21_DepotbestandImportTest.php` – der CSV-Bestandsimport an einem Ausschnitt des echten DKB-Exports: Vorschau ohne Nebenwirkung, Einstand und Wert aus beiden Kursen, Bruchstückzahlen, Assetklasse → Art, hinterlegte Bewertungskurse, Idempotenz beim zweiten diff --git a/migrations/012_wertpapierkosten.sql b/migrations/012_wertpapierkosten.sql new file mode 100644 index 0000000..adcd77a --- /dev/null +++ b/migrations/012_wertpapierkosten.sql @@ -0,0 +1,34 @@ +-- Wertpapiergeschäfte aus einer Kontobuchung heraus erfassen. +-- +-- Bisher legte eine Depotbuchung ihre Geldbuchung selbst an. Der umgekehrte Weg +-- fehlte: Der Bankabruf bringt die Abrechnung als Umsatz auf dem Girokonto, und +-- erst dort entscheidet sich, dass es ein Wertpapiergeschäft war. Die +-- Depotbuchung hängt sich dann an die vorhandene Buchung, statt eine zweite +-- anzulegen. +-- +-- `buchung_erzeugt` hält fest, welcher der beiden Wege gegangen wurde. Nur eine +-- selbst erzeugte Geldbuchung geht beim Löschen der Depotbuchung mit; eine vom +-- Bankabruf stammende bleibt stehen – sie ist der Beleg der Bank und nicht +-- unsere, sie wieder wegzunehmen. + +ALTER TABLE depot_buchungen + ADD COLUMN IF NOT EXISTS buchung_erzeugt TINYINT(1) NOT NULL DEFAULT 1 AFTER buchung_id; + +-- Je Kostenart eine feste Kategorie. Sie steht einmal in den Einstellungen, +-- damit der Kostendialog nur noch Beträge abfragt – so hält es auch der +-- FinanzManager. Leer heißt: Die Splitzeile bleibt ohne Kategorie. + +INSERT INTO einstellungen (schluessel, wert, bereich) VALUES + ('wertpapiere_kategorie_bankprovision', '', 'wertpapiere'), + ('wertpapiere_kategorie_maklercourtage', '', 'wertpapiere'), + ('wertpapiere_kategorie_boersenplatzentgelt', '', 'wertpapiere'), + ('wertpapiere_kategorie_spesen', '', 'wertpapiere'), + ('wertpapiere_kategorie_spesen_ausland', '', 'wertpapiere'), + ('wertpapiere_kategorie_sonstige', '', 'wertpapiere'), + ('wertpapiere_kategorie_zinsabschlagsteuer', '', 'wertpapiere'), + ('wertpapiere_kategorie_kapitalertragsteuer', '', 'wertpapiere'), + ('wertpapiere_kategorie_solidaritaetszuschlag','', 'wertpapiere'), + ('wertpapiere_kategorie_quellensteuer', '', 'wertpapiere'), + ('wertpapiere_kategorie_abgeltungsteuer', '', 'wertpapiere'), + ('wertpapiere_kategorie_kirchensteuer', '', 'wertpapiere') +ON DUPLICATE KEY UPDATE schluessel = schluessel; diff --git a/public/index.php b/public/index.php index be4241a..6342804 100644 --- a/public/index.php +++ b/public/index.php @@ -90,6 +90,12 @@ $router->post('/buchungen/{id}/zuordnung', fn(array $p) => (new BuchungenController())->zuordnung($p)); $router->post('/buchungen/{id}/splitbuchung', fn(array $p) => (new BuchungenController())->splitbuchung($p)); +// Das Wertpapiergeschäft zu einer Buchung – Dialog im Kontoblatt, Seite ohne +// JavaScript, ein Ziel für beide. +$router->get('/buchungen/{id}/wertpapier', + fn(array $p) => (new BuchungenController())->wertpapierFormular($p)); +$router->post('/buchungen/{id}/wertpapier', + fn(array $p) => (new BuchungenController())->wertpapier($p)); // Verwaltung: Kategorien, Klassen, Kontengruppen, QIF-Import. $router->get('/verwaltung', fn() => (new VerwaltungController())->index()); diff --git a/public/static/app.css b/public/static/app.css index 6782924..f55a3bc 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -1112,6 +1112,20 @@ table.splittabelle input.rechts { text-align: right; } „Speichern“ und „Abbrechen“, damit es nicht im Vorbeigehen getroffen wird. */ .splitdialog .knopfleiste .knopf.gefahr { margin-left: auto; } +/* Der Wertpapierdialog trägt ein Formular und wird dadurch länger als der + Splitdialog; bei kleinen Fenstern scrollt er. */ +.wertpapierdialog { max-height: 88vh; overflow-y: auto; } +.wertpapierdialog .formular { max-width: none; } +.wertpapierdialog h2 { margin: 0 0 .3rem; font-size: 1.05rem; } + +/* Die Kostentabelle: Betrag rechts, Kategorie als stiller Hinweis daneben. */ +table.kostentabelle { margin-top: .6rem; } +table.kostentabelle td { padding: .25rem .4rem; } +table.kostentabelle input.rechts { text-align: right; width: 7.5rem; } +table.kostentabelle select { max-width: 16rem; width: 100%; } +table.kostentabelle th[scope="row"] { font-weight: 400; text-align: left; } +table.kostentabelle .gruppenzeile th { padding-top: .7rem; font-weight: 600; } + /* ── Formulare ───────────────────────────────────────────────────────────── */ .formular { diff --git a/public/static/kontoblatt.js b/public/static/kontoblatt.js index 0f5adbc..38be5e8 100644 --- a/public/static/kontoblatt.js +++ b/public/static/kontoblatt.js @@ -92,6 +92,21 @@ oeffnen(feld); }); + /* Ein Depot ist keine fertige Zuordnung: Stückzahl, Kurs und Nebenkosten + * fehlen noch. Ohne den Dialog – etwa wenn das Skript nicht geladen ist – + * nimmt der Server dieselbe Auswahl entgegen und schickt auf die Seite mit + * denselben Feldern. */ + function wertpapierOeffnen(feld, wert, name) { + const depotId = wert.slice('depot:'.length); + + if (window.FmWertpapier) { + window.FmWertpapier.oeffnen(feld, depotId, name, ziel(feld, 'wertpapier')); + return; + } + + window.location.href = '/buchungen/' + feld.dataset.buchung + '/wertpapier?depot=' + depotId; + } + liste.addEventListener('change', function () { if (!offenesFeld) { return; @@ -105,6 +120,18 @@ return; } + if (liste.value.indexOf('depot:') === 0) { + const wert = liste.value; + // Die Beschriftung lautet „[Depotname] …“ – der Dialog nennt nur + // den Namen. + const name = liste.options[liste.selectedIndex].textContent + .trim().replace(/^\[/, '').replace(/\]\s*…?$/, '').trim(); + + schliessen(); + wertpapierOeffnen(feld, wert, name); + return; + } + // Eine Rückfrage wegen verlorener Splitzeilen braucht es hier nicht mehr: // Für eine aufgeteilte Buchung öffnet sich diese Liste gar nicht erst. auswahl.requestSubmit(); diff --git a/public/static/wertpapierbuchung.js b/public/static/wertpapierbuchung.js new file mode 100644 index 0000000..8d48c24 --- /dev/null +++ b/public/static/wertpapierbuchung.js @@ -0,0 +1,178 @@ +/* Das Wertpapiergeschäft zu einer Kontobuchung. + * + * Zwei Dialoge wie im FinanzManager: vorne Vorgang, Papier, Stück und Kurs, + * dahinter die Nebenkosten. Die Kostenfelder gehören zum vorderen Formular und + * stehen nur räumlich im zweiten Dialog – verbunden über das form-Attribut. + * + * Gerechnet wird hier nur zur Anzeige: Der Buchungsbetrag steht fest, und was + * nach den Kosten übrig bleibt, ist der Einstandspreis. Der Server rechnet + * dieselbe Zeile noch einmal, denn was der Browser anzeigt, ist kein Beleg. + * + * Dieselbe Datei bedient die Seite /buchungen/{id}/wertpapier: Dort gibt es + * keine Dialoge, aber dieselben Felder und dieselbe Rechnung. + */ + +(function () { + 'use strict'; + + const formular = document.getElementById('wertpapierformular'); + if (!formular || !window.FmBetrag) { + return; + } + + const dialog = document.getElementById('wertpapierdialog'); + const kostendialog = document.getElementById('kostendialog'); + const vorgang = document.getElementById('wp_vorgang'); + const einstand = document.getElementById('wp_einstand'); + const kostensumme = document.getElementById('wp_kostensumme'); + const knopfsumme = document.getElementById('wp_kostenknopfsumme'); + + // Auf der Seite steht der Betrag am Formular, im Kontoblatt bringt ihn erst + // die angeklickte Zeile mit. + let betrag = Number(formular.dataset.betrag || '0'); + + function kostenfelder() { + return Array.from(document.querySelectorAll('.kostenbetrag')); + } + + function summeKosten() { + let summe = 0; + kostenfelder().forEach(function (feld) { + summe += window.FmBetrag.zahl(feld.value); + }); + return Math.round(summe * 100) / 100; + } + + function rechne() { + const kosten = summeKosten(); + const rest = Math.round((Math.abs(betrag) - kosten) * 100) / 100; + + if (kostensumme) { + kostensumme.textContent = window.FmBetrag.formatiere(kosten); + } + if (knopfsumme) { + knopfsumme.textContent = window.FmBetrag.formatiere(kosten); + } + if (einstand) { + einstand.textContent = window.FmBetrag.formatiere(rest); + // Bleibt nichts übrig, ist etwas falsch – der Server weist es ab. + einstand.classList.toggle('negativ', rest <= 0); + } + } + + /* Ein Abfluss vom Konto kann nur ein Kauf sein, ein Zufluss ein Verkauf oder + * eine Dividende. Was nicht passt, steht gar nicht erst zur Wahl. */ + function vorgaengeStellen() { + if (!vorgang) { + return; + } + const erlaubt = betrag < 0 ? ['kauf'] : ['verkauf', 'dividende']; + + Array.from(vorgang.options).forEach(function (option) { + const passt = erlaubt.indexOf(option.value) !== -1; + option.hidden = !passt; + option.disabled = !passt; + }); + + if (erlaubt.indexOf(vorgang.value) === -1) { + vorgang.value = erlaubt[0]; + } + } + + document.addEventListener('input', function (ereignis) { + if (ereignis.target instanceof Element + && ereignis.target.classList.contains('kostenbetrag')) { + rechne(); + } + }); + + if (kostendialog) { + document.getElementById('wp_kostenknopf').addEventListener('click', function () { + kostendialog.showModal(); + }); + document.getElementById('wp_kostenfertig').addEventListener('click', function () { + kostendialog.close(); + rechne(); + }); + } + + if (dialog) { + document.getElementById('wp_abbrechen').addEventListener('click', function () { + dialog.close(); + }); + } + + vorgaengeStellen(); + rechne(); + + if (!dialog) { + return; + } + + /* ── Der Dialog des Kontoblatts ──────────────────────────────────────── */ + + const papierliste = document.getElementById('wp_wertpapier'); + + /* Das Papier zur ISIN oder WKN aus dem Verwendungszweck. Ohne Treffer bleibt + * die Auswahl auf „neues Wertpapier anlegen“, und die gelesenen Kennungen + * stehen als Vorgabe in den Feldern darunter. */ + function papierWaehlen(isin, wkn) { + papierliste.value = ''; + if (isin === '' && wkn === '') { + return; + } + + Array.from(papierliste.options).forEach(function (option) { + if (papierliste.value !== '') { + return; + } + if ((isin !== '' && option.dataset.isin === isin) + || (wkn !== '' && option.dataset.wkn === wkn)) { + papierliste.value = option.value; + } + }); + } + + function leeren() { + kostenfelder().forEach(function (feld) { + feld.value = ''; + }); + ['wp_name', 'wp_stueck', 'wp_kurs', 'wp_notiz', 'wp_valuta'].forEach(function (id) { + document.getElementById(id).value = ''; + }); + } + + window.FmWertpapier = { + /* @param feld die angeklickte Zuordnungszelle mit ihren data-Werten + * @param depotId das gewählte Depot + * @param depotName sein Name, für die Kopfzeile des Dialogs + * @param ziel wohin das Formular geht */ + oeffnen: function (feld, depotId, depotName, ziel) { + formular.action = ziel; + betrag = Number(feld.dataset.betrag || '0'); + + const isin = (feld.dataset.isin || '').toUpperCase(); + const wkn = (feld.dataset.wkn || '').toUpperCase(); + + // Der Dialog gilt für alle Zeilen der Seite – die Angaben der + // vorigen dürfen nicht stehen bleiben. + leeren(); + + document.getElementById('wp_depot').value = depotId; + document.getElementById('wp_depotname').textContent = depotName; + document.getElementById('wp_bezeichnung').textContent = feld.dataset.bezeichnung || ''; + document.getElementById('wp_verwendungszweck').textContent = feld.dataset.zweck || ''; + document.getElementById('wp_buchungsbetrag').textContent = + window.FmBetrag.formatiere(betrag); + document.getElementById('wp_datum').value = feld.dataset.datum || ''; + document.getElementById('wp_isin').value = isin; + document.getElementById('wp_wkn').value = wkn; + + papierWaehlen(isin, wkn); + vorgaengeStellen(); + rechne(); + + dialog.showModal(); + } + }; +})(); diff --git a/src/Controllers/BuchungenController.php b/src/Controllers/BuchungenController.php index a960994..9b196c4 100644 --- a/src/Controllers/BuchungenController.php +++ b/src/Controllers/BuchungenController.php @@ -5,10 +5,14 @@ declare(strict_types=1); namespace Finanzmanager\Controllers; 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; use Finanzmanager\View; /** @@ -72,6 +76,8 @@ class BuchungenController extends BaseController 'klassen' => Klasse::alle(), 'konten' => Konto::alle(), 'splits' => Buchung::splitsJeBuchung($gesplittete), + // Für den Wertpapierdialog, den die Zuordnung auf ein Depot öffnet. + 'wertpapiere' => Wertpapier::alle(true), ]); } @@ -102,12 +108,18 @@ class BuchungenController extends BaseController try { [$daten, $splits] = $this->formulardaten($kontoId); - Buchung::anlegen($daten, $splits); + $id = Buchung::anlegen($daten, $splits); } catch (\Throwable $e) { View::redirect('/konten/' . $kontoId . '/buchung', $e->getMessage(), 'fehler'); return; } + $depotId = self::depotAus($this->post('zuordnung')); + if ($depotId !== null) { + View::redirect('/buchungen/' . $id . '/wertpapier?depot=' . $depotId); + return; + } + View::redirect('/konten/' . $kontoId, 'Buchung angelegt.'); } @@ -145,6 +157,15 @@ class BuchungenController extends BaseController return; } + // Ein Depot in der Zuordnungsliste ist der Anfang eines Geschäfts: Die + // Buchung steht jetzt mit ihrem Betrag fest, die Wertpapiermaske baut + // darauf auf. + $depotId = self::depotAus($this->post('zuordnung')); + if ($depotId !== null) { + View::redirect('/buchungen/' . $id . '/wertpapier?depot=' . $depotId); + return; + } + View::redirect('/konten/' . $buchung['konto_id'], 'Buchung gespeichert.'); } @@ -181,6 +202,16 @@ class BuchungenController extends BaseController $ziel = $this->kontoblattPfad((int)$buchung['konto_id']); + // Ein Depot ist keine Zuordnung, sondern der Anfang eines Geschäfts: + // Stückzahl, Kurs und Nebenkosten fehlen noch. Mit JavaScript öffnet + // das Kontoblatt dafür einen Dialog und kommt hier gar nicht an; ohne + // führt derselbe Weg auf die Seite mit denselben Feldern. + $depotId = self::depotAus($this->post('zuordnung')); + if ($depotId !== null) { + View::redirect('/buchungen/' . (int)$buchung['id'] . '/wertpapier?depot=' . $depotId); + return; + } + try { $zuordnung = self::zuordnungAus($this->post('zuordnung')); Buchung::zuordnen( @@ -229,6 +260,115 @@ class BuchungenController extends BaseController View::redirect($ziel, $loeschen ? 'Splitbuchung gelöscht.' : 'Splitbuchung gespeichert.'); } + /** + * Die Wertpapiermaske zu einer Buchung als eigene Seite – der Weg ohne + * JavaScript. Sie zeigt dieselben Felder wie der Dialog des Kontoblatts und + * schickt sie an dasselbe Ziel. + * + * @param array $params + */ + public function wertpapierFormular(array $params): void + { + $this->verlangeAnmeldung(); + + $buchung = Buchung::find((int)$params['id']); + if ($buchung === null) { + $this->nichtGefunden('/buchungen/' . $params['id']); + return; + } + + $depot = Konto::find((int)$this->query('depot')); + if ($depot === null || (string)$depot['kontoart'] !== 'depot') { + View::redirect( + $this->kontoblattPfad((int)$buchung['konto_id']), + 'Für ein Wertpapiergeschäft fehlt das Depot.', + 'fehler' + ); + return; + } + + $this->render('buchungen/wertpapier.php', [ + 'titel' => 'Wertpapiergeschäft', + 'buchung' => $buchung, + 'konto' => Konto::find((int)$buchung['konto_id']), + 'depot' => $depot, + 'wertpapiere' => Wertpapier::alle(true), + 'kategorien' => Kategorie::baumFlach(), + 'vorschlag' => Wertpapier::ausText((string)$buchung['buchungstext'] . ' ' . (string)$buchung['empfaenger']), + ]); + } + + /** + * Ein Wertpapiergeschäft zu einer vorhandenen Buchung. + * + * Die Buchung bleibt, wie die Bank sie geliefert hat – ihr Betrag ist die + * feste Größe. Aus ihm werden die Nebenkosten herausgerechnet; was übrig + * bleibt, ist der Kurswert und damit der Einstandspreis. Beides erscheint + * als Splitbuchung: je Kostenart eine Zeile in ihrer Kategorie, der Rest als + * Umbuchung ins Depot. + * + * @param array $params + */ + public function wertpapier(array $params): void + { + $this->verlangeAnmeldung(); + + $buchung = Buchung::find((int)$params['id']); + if ($buchung === null) { + $this->nichtGefunden('/buchungen/' . $params['id']); + return; + } + + $ziel = $this->kontoblattPfad((int)$buchung['konto_id']); + + try { + $geschaeft = Wertpapiergeschaeft::buchen($buchung, $this->wertpapiereingaben()); + } catch (\Throwable $e) { + View::redirect( + '/buchungen/' . (int)$buchung['id'] . '/wertpapier?depot=' . (int)$this->post('depot_id'), + $e->getMessage(), + 'fehler' + ); + return; + } + + View::redirect($ziel, sprintf( + '%s gebucht – Einstandspreis %s €.', + Depotbuchung::VORGAENGE[$geschaeft['vorgang']], + number_format($geschaeft['betrag'], 2, ',', '.') + )); + } + + /** + * Die Formulareingaben des Wertpapierdialogs, wie der Dienst sie erwartet. + * + * @return array + */ + private function wertpapiereingaben(): array + { + $kosten = []; + foreach (array_keys(Wertpapierkosten::arten()) as $art) { + $kosten[$art] = KontenController::zahl($this->post('kosten_' . $art, '')); + } + + return [ + 'depot_id' => (int)$this->post('depot_id'), + 'wertpapier_id' => (int)$this->post('wertpapier_id'), + 'wertpapier_name' => $this->post('wertpapier_name'), + 'isin' => $this->post('isin'), + 'wkn' => $this->post('wkn'), + 'art' => $this->post('art', 'aktie'), + 'vorgang' => $this->post('vorgang'), + 'datum' => $this->post('datum'), + 'valuta' => $this->post('valuta'), + 'stueck' => KontenController::zahl($this->post('stueck', '0')), + 'kurs' => KontenController::zahl($this->post('kurs', '0')), + 'notiz' => $this->post('notiz'), + 'kosten' => $kosten, + 'kosten_sonstige_kategorie' => (int)$this->post('kosten_sonstige_kategorie') ?: null, + ]; + } + /** * Mehrere im Kontoblatt angekreuzte Buchungen auf einmal löschen. * @@ -409,6 +549,17 @@ class BuchungenController extends BaseController }; } + /** + * Das Depot aus einem Auswahlwert `depot:7` – oder null, wenn der Wert für + * eine gewöhnliche Zuordnung steht. + */ + public static function depotAus(string $wert): ?int + { + [$art, $id] = array_pad(explode(':', $wert, 2), 2, ''); + + return $art === 'depot' && (int)$id > 0 ? (int)$id : null; + } + private function nichtGefunden(string $pfad): void { http_response_code(404); diff --git a/src/Controllers/EinstellungenController.php b/src/Controllers/EinstellungenController.php index a3188f0..e852c37 100644 --- a/src/Controllers/EinstellungenController.php +++ b/src/Controllers/EinstellungenController.php @@ -5,8 +5,10 @@ declare(strict_types=1); namespace Finanzmanager\Controllers; use Finanzmanager\Models\Einstellung; +use Finanzmanager\Models\Kategorie; use Finanzmanager\Services\Cockpit; use Finanzmanager\Services\DebugLog; +use Finanzmanager\Services\Wertpapierkosten; use Finanzmanager\View; /** @@ -31,6 +33,7 @@ class EinstellungenController extends BaseController 'werte' => Einstellung::alle(), 'logDateien' => $this->logDateien(), 'kacheln' => Cockpit::sichtbar(), + 'kategorien' => Kategorie::baumFlach(), ]); } @@ -108,12 +111,29 @@ class EinstellungenController extends BaseController 'wertpapiere_kursdienst' => 'text', 'wertpapiere_api_key' => 'text', 'wertpapiere_kursabruf_aktiv' => 'bool', - ], + ] + $this->kostenfelder(), 'diagnose' => ['debug_log_aktiv' => 'bool'], default => [], }; } + /** + * Je Kostenart eines Wertpapiergeschäfts ein Feld. Sie stehen nicht in + * felderFuer(), weil sie aus Wertpapierkosten::arten() kommen – eine neue + * Art soll nicht an zwei Stellen nachgetragen werden müssen. + * + * @return array + */ + private function kostenfelder(): array + { + $felder = []; + foreach (array_keys(Wertpapierkosten::arten()) as $art) { + $felder[Wertpapierkosten::schluessel($art)] = 'text'; + } + + return $felder; + } + /** @return list */ private function logDateien(): array { diff --git a/src/Models/Depotbuchung.php b/src/Models/Depotbuchung.php index bc8781c..9dc578b 100644 --- a/src/Models/Depotbuchung.php +++ b/src/Models/Depotbuchung.php @@ -116,10 +116,28 @@ class Depotbuchung return Database::fetchAll($sql . ' ORDER BY d.datum DESC, d.id DESC', $params); } + /** + * Die Depotbuchung zu einer Geldbuchung – oder null, wenn die Buchung kein + * Wertpapiergeschäft ist. + */ + public static function findeZuBuchung(int $buchungId): ?array + { + $zeile = Database::fetchOne( + 'SELECT * FROM depot_buchungen WHERE buchung_id = ? AND benutzer_id = ?', + [$buchungId, Mandant::id()] + ); + return $zeile === false ? null : $zeile; + } + /** * Legt eine Depotbuchung an und – wenn ein Verrechnungskonto angegeben ist – * die zugehörige Geldbuchung gleich mit. * + * Steht in `buchung_id` schon eine Buchung, hängt sich die Depotbuchung an + * diese: der Weg über das Kontoblatt, bei dem der Bankabruf die Abrechnung + * längst geliefert hat. Eine zweite Buchung entstünde sonst neben der + * echten. + * * @param array $daten */ public static function anlegen(array $daten): int @@ -127,9 +145,11 @@ class Depotbuchung $f = self::normalisiere($daten); return (int)Database::transaktion(static function () use ($f, $daten): int { - $buchungId = null; + $buchungId = $f['buchung_id']; + $erzeugt = 0; - if ($f['verrechnungskonto_id'] !== null && abs($f['geldbetrag']) > 0.005) { + if ($buchungId === null && $f['verrechnungskonto_id'] !== null && abs($f['geldbetrag']) > 0.005) { + $erzeugt = 1; $buchungId = Buchung::anlegen([ 'konto_id' => $f['verrechnungskonto_id'], 'datum' => $f['datum'], @@ -151,13 +171,13 @@ class Depotbuchung 'INSERT INTO depot_buchungen (benutzer_id, konto_id, wertpapier_id, datum, valuta, vorgang, vorgang_qif, stueck, kurs, betrag, gebuehren, steuern, waehrung, - verrechnungskonto_id, buchung_id, notiz, quelle, import_hash) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + verrechnungskonto_id, buchung_id, buchung_erzeugt, notiz, quelle, import_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ Mandant::id(), $f['konto_id'], $f['wertpapier_id'], $f['datum'], $f['valuta'], $f['vorgang'], $f['vorgang_qif'], $f['stueck'], $f['kurs'], $f['betrag'], $f['gebuehren'], $f['steuern'], $f['waehrung'], $f['verrechnungskonto_id'], - $buchungId, $f['notiz'], $f['quelle'], $f['import_hash'], + $buchungId, $erzeugt, $f['notiz'], $f['quelle'], $f['import_hash'], ] ); @@ -175,11 +195,25 @@ class Depotbuchung [$id, Mandant::id()] ); - // Die Geldbuchung gehört zum Geschäft und geht mit – sie stünde - // sonst ohne Zusammenhang im Kontoblatt. - if ($buchung !== null && $buchung['buchung_id'] !== null) { + // Die selbst erzeugte Geldbuchung gehört zum Geschäft und geht mit – + // sie stünde sonst ohne Zusammenhang im Kontoblatt. Eine vorhandene + // Buchung, an die sich die Depotbuchung nur angehängt hat, bleibt: + // Sie kommt vom Bankabruf und ist ohne das Geschäft nicht falsch, + // nur wieder unzugeordnet. + if ($buchung !== null + && $buchung['buchung_id'] !== null + && (int)($buchung['buchung_erzeugt'] ?? 1) === 1) { Buchung::loeschen((int)$buchung['buchung_id']); } + + // Die angehängte Buchung behält ihren Betrag, verliert aber die + // Aufteilung: Deren Zeilen beschreiben ein Geschäft, das es nicht + // mehr gibt. Sie steht danach wieder unzugeordnet im Kontoblatt. + if ($buchung !== null + && $buchung['buchung_id'] !== null + && (int)($buchung['buchung_erzeugt'] ?? 1) === 0) { + Buchung::splitsErsetzen((int)$buchung['buchung_id'], []); + } }); } @@ -264,7 +298,12 @@ class Depotbuchung $verrechnung = ($verrechnung === null || $verrechnung === '' || (int)$verrechnung === 0) ? null : (int)$verrechnung; + $buchungId = $daten['buchung_id'] ?? null; + $buchungId = ($buchungId === null || $buchungId === '' || (int)$buchungId === 0) + ? null : (int)$buchungId; + return [ + 'buchung_id' => $buchungId, 'konto_id' => $kontoId, 'wertpapier_id' => $wertpapierId, 'datum' => $datum, diff --git a/src/Models/Wertpapier.php b/src/Models/Wertpapier.php index 88ed377..1511001 100644 --- a/src/Models/Wertpapier.php +++ b/src/Models/Wertpapier.php @@ -85,6 +85,33 @@ class Wertpapier return null; } + /** + * ISIN und WKN aus einem Verwendungszweck. + * + * Wertpapierabrechnungen führen beides im Text mit („WKN LYX01W … ISIN + * LU1834983477“). Wer ein Geschäft aus dem Kontoblatt heraus bucht, muss das + * Papier dann nicht heraussuchen – die Maske schlägt es vor. + * + * Die ISIN ist an ihrer Form erkennbar (zwei Buchstaben Länderkennung, neun + * Zeichen, eine Prüfziffer) und wird auch ohne Beschriftung gefunden. Die + * WKN ist bloß sechsstellig alphanumerisch – zu wenig, um sie ohne das Wort + * „WKN“ davor von einer Belegnummer zu unterscheiden. + * + * @return array{isin:string,wkn:string} + */ + public static function ausText(string $text): array + { + $gross = strtoupper($text); + + $isin = preg_match('/\b([A-Z]{2}[A-Z0-9]{9}[0-9])\b/', $gross, $treffer) === 1 + ? $treffer[1] : ''; + + $wkn = preg_match('/\bWKN[\s:.]*([A-Z0-9]{6})\b/', $gross, $treffer) === 1 + ? $treffer[1] : ''; + + return ['isin' => $isin, 'wkn' => $wkn]; + } + /** @param array $daten */ public static function findenOderAnlegen(array $daten): int { diff --git a/src/Services/Wertpapiergeschaeft.php b/src/Services/Wertpapiergeschaeft.php new file mode 100644 index 0000000..48a2fe6 --- /dev/null +++ b/src/Services/Wertpapiergeschaeft.php @@ -0,0 +1,204 @@ + $betraege Kostenart => Betrag + * @return array{ + * kurswert: float, + * gebuehren: float, + * steuern: float, + * kosten: list + * } + */ + 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 $buchung die Buchung des Verrechnungskontos + * @param array $eingaben depot_id, wertpapier_id, wertpapier_name, isin, + * wkn, art, vorgang, datum, valuta, stueck, kurs, + * notiz, kosten, kosten_sonstige_kategorie + * @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); + } + + return Database::transaktion(static function () use ( + $buchung, $depot, $eingaben, $aufteilung, $kurswert, $vorgang, $stueck, $kurs + ): 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'], + 'betrag' => $kurswert, + 'notiz' => $bezeichnung, + ]]; + foreach ($aufteilung['kosten'] as $zeile) { + $splits[] = [ + 'kategorie_id' => $zeile['kategorie_id'], + // Nebenkosten sind Aufwand – beim Kauf wie beim Verkauf. + 'betrag' => -$zeile['betrag'], + 'notiz' => $zeile['name'], + ]; + } + + Buchung::splitsErsetzen((int)$buchung['id'], $splits); + } + + return [ + 'depotbuchung_id' => $depotbuchungId, + 'vorgang' => $vorgang, + 'betrag' => abs($kurswert), + 'gesplittet' => $gesplittet, + ]; + }); + } +} diff --git a/src/Services/Wertpapierkosten.php b/src/Services/Wertpapierkosten.php new file mode 100644 index 0000000..6a27bef --- /dev/null +++ b/src/Services/Wertpapierkosten.php @@ -0,0 +1,147 @@ +`. Einmal hinterlegt, fragt der + * Kostendialog nur noch Beträge ab. Einzige Ausnahme ist „Sonstige Kosten“: + * Deren Kategorie lässt sich im Dialog wählen, weil sie eben nicht immer + * dieselbe ist – so hält es auch der FinanzManager. + */ +class Wertpapierkosten +{ + /** Gebührenarten in der Reihenfolge des Dialogs. */ + public const GEBUEHREN = [ + 'bankprovision' => 'Bankprovision', + 'maklercourtage' => 'Maklercourtage', + 'boersenplatzentgelt' => 'Börsenplatzentgelt', + 'spesen' => 'Spesen', + 'spesen_ausland' => 'Spesen Ausland', + 'sonstige' => 'Sonstige Kosten', + ]; + + /** Steuerarten in der Reihenfolge des Dialogs. */ + public const STEUERN = [ + 'zinsabschlagsteuer' => 'Zinsabschlagsteuer', + 'kapitalertragsteuer' => 'Kapitalertragsteuer', + 'solidaritaetszuschlag' => 'Solidaritätszuschlag', + 'quellensteuer' => 'Ausländische Quellensteuer', + 'abgeltungsteuer' => 'Abgeltungsteuer', + 'kirchensteuer' => 'Kirchensteuer', + ]; + + /** Die eine Art, deren Kategorie im Dialog steht statt in den Einstellungen. */ + public const SONSTIGE = 'sonstige'; + + /** @return array art => Beschriftung, Gebühren vor Steuern */ + public static function arten(): array + { + return self::GEBUEHREN + self::STEUERN; + } + + public static function istSteuer(string $art): bool + { + return array_key_exists($art, self::STEUERN); + } + + /** Der Einstellungsschlüssel zu einer Kostenart. */ + public static function schluessel(string $art): string + { + return 'wertpapiere_kategorie_' . $art; + } + + /** + * Die hinterlegten Kategorien, auch die leeren. + * + * @return array + */ + public static function kategorien(): array + { + $kategorien = []; + foreach (array_keys(self::arten()) as $art) { + $wert = (int)Einstellung::get(self::schluessel($art), ''); + $kategorien[$art] = $wert > 0 ? $wert : null; + } + + return $kategorien; + } + + /** + * Die belegten Kostenzeilen eines Geschäfts, fertig für die Splitbuchung. + * + * Beträge werden auf ihren Betrag ohne Vorzeichen gebracht: Nebenkosten + * sind immer Aufwand – beim Kauf wie beim Verkauf –, und das Vorzeichen der + * Splitzeile setzt erst der Aufrufer. + * + * @param array $betraege art => Betrag + * @param ?int $sonstigeKategorie Kategorie für „Sonstige Kosten“ + * @return list + */ + public static function zeilen(array $betraege, ?int $sonstigeKategorie = null): array + { + $kategorien = self::kategorien(); + $zeilen = []; + + foreach (self::arten() as $art => $name) { + $betrag = round(abs((float)($betraege[$art] ?? 0.0)), 2); + if ($betrag < 0.005) { + continue; + } + + $kategorie = $art === self::SONSTIGE && $sonstigeKategorie !== null + ? $sonstigeKategorie + : $kategorien[$art]; + + $zeilen[] = [ + 'art' => $art, + 'name' => $name, + 'betrag' => $betrag, + 'kategorie_id' => $kategorie, + 'steuer' => self::istSteuer($art), + ]; + } + + return $zeilen; + } + + /** + * Gebühren- und Steuersumme der Zeilen – so, wie die Depotbuchung sie führt. + * + * @param list $zeilen + * @return array{gebuehren:float,steuern:float,summe:float} + */ + public static function summen(array $zeilen): array + { + $gebuehren = 0.0; + $steuern = 0.0; + + foreach ($zeilen as $zeile) { + if ($zeile['steuer']) { + $steuern += $zeile['betrag']; + } else { + $gebuehren += $zeile['betrag']; + } + } + + return [ + 'gebuehren' => round($gebuehren, 2), + 'steuern' => round($steuern, 2), + 'summe' => round($gebuehren + $steuern, 2), + ]; + } +} diff --git a/src/Views/buchungen/formular.php b/src/Views/buchungen/formular.php index 344f977..4488c9e 100644 --- a/src/Views/buchungen/formular.php +++ b/src/Views/buchungen/formular.php @@ -31,6 +31,7 @@ $zuordnungswert = static function (?array $zeile): string { $eigenesKonto = (int)$konto['id']; $mitSplit = false; +$mitDepot = true; // Immer ein paar leere Splitzeilen anbieten; weitere fügt der Knopf hinzu. $splitZeilen = $splits; @@ -128,7 +129,10 @@ include __DIR__ . '/../layout/kopf.php'; diff --git a/src/Views/buchungen/kontoblatt.php b/src/Views/buchungen/kontoblatt.php index 867c759..400bfc5 100644 --- a/src/Views/buchungen/kontoblatt.php +++ b/src/Views/buchungen/kontoblatt.php @@ -1,5 +1,6 @@ $konto */ @@ -230,10 +231,23 @@ include __DIR__ . '/../layout/kopf.php'; + data-splits="" @@ -277,7 +291,7 @@ include __DIR__ . '/../layout/kopf.php'; + + +
+ + + +

Wertpapiergeschäft

+

+ über + 0,00 · + Depot +

+

+ + + '', 'wkn' => '']; ?> + + +
+ + + + + + Einstandspreis 0,00 + +
+
+
+ + + +

Kosten

+ + +
+ +
+
+ + diff --git a/src/Views/buchungen/wertpapier.php b/src/Views/buchungen/wertpapier.php new file mode 100644 index 0000000..d9ca93f --- /dev/null +++ b/src/Views/buchungen/wertpapier.php @@ -0,0 +1,71 @@ + $buchung + * @var array $konto + * @var array $depot + * @var list> $wertpapiere + * @var list> $kategorien + * @var array{isin:string,wkn:string} $vorschlag + * @var string $csrfToken + */ + +$betrag = (float)$buchung['betrag']; +$datumsvorgabe = substr((string)$buchung['datum'], 0, 10); + +include __DIR__ . '/../layout/kopf.php'; +?> + +
+ +
+ + + +

Vorgang

+

+ + · + · + auf +

+ +

+ + + + +

Kosten

+ + + +

+ Was nach Abzug der Kosten vom Buchungsbetrag übrig bleibt, ist der + Einstandspreis: €. +

+ +
+ + Abbrechen +
+
+ + + + + diff --git a/src/Views/buchungen/wertpapierfelder.php b/src/Views/buchungen/wertpapierfelder.php new file mode 100644 index 0000000..6d10a03 --- /dev/null +++ b/src/Views/buchungen/wertpapierfelder.php @@ -0,0 +1,106 @@ +> $wertpapiere + * @var array{isin:string,wkn:string} $vorschlag aus dem Verwendungszweck gelesen + * @var string $datumsvorgabe + */ +?> + + +

+ Ein Abfluss vom Konto kann nur ein Kauf sein, ein Zufluss ein Verkauf oder eine + Dividende. +

+ +
+
+ + +
+
+ + +
+
+ +

Wertpapier

+ + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Auftrag

+ +
+
+ + +
+
+ + +
+
+

+ Fehlt der Kurs, ergibt er sich aus Einstandspreis und Stückzahl. Umgekehrt gilt + das nicht: Der Betrag der Buchung steht fest, wie die Bank ihn geliefert hat. +

+ + + diff --git a/src/Views/buchungen/wertpapierkosten.php b/src/Views/buchungen/wertpapierkosten.php new file mode 100644 index 0000000..33d9432 --- /dev/null +++ b/src/Views/buchungen/wertpapierkosten.php @@ -0,0 +1,83 @@ +> $kategorien + * @var string $kostenFormular id des Formulars, wenn die Felder außerhalb stehen + */ + +$kostenFormular ??= ''; +$formAttribut = $kostenFormular === '' ? '' : ' form="' . View::h($kostenFormular) . '"'; +$hinterlegt = Wertpapierkosten::kategorien(); + +/** Eine Betragszeile mit ihrer Kategorie daneben. */ +$zeile = static function (string $art, string $name) use ($formAttribut, $hinterlegt, $kategorien): void { + $kategorie = $hinterlegt[$art] ?? null; + $pfad = ''; + foreach ($kategorien as $eintrag) { + if ((int)$eintrag['id'] === (int)$kategorie) { + $pfad = (string)$eintrag['pfad']; + break; + } + } + ?> + + + + > + + + + + + ohne Kategorie' : View::h($pfad) ?> + + + + + + + + + + + + $name) { $zeile($art, $name); } ?> + + $name) { $zeile($art, $name); } ?> + + + + + + + + +
ArtBetragKategorie
Gebühren
Steuern
Kosten0,00
+ +

+ Arten ohne Kategorie landen als Splitzeile ohne Zuordnung im Kontoblatt. Die + feste Zuordnung steht unter + Einstellungen → Wertpapiere. +

diff --git a/src/Views/buchungen/zuordnungsliste.php b/src/Views/buchungen/zuordnungsliste.php index 6a1b6e6..44821ee 100644 --- a/src/Views/buchungen/zuordnungsliste.php +++ b/src/Views/buchungen/zuordnungsliste.php @@ -17,7 +17,21 @@ use Finanzmanager\View; * @var int $eigenesKonto Konto der Buchung – kein Gegenkonto seiner selbst * @var string $gewaehlt vorausgewählter Wert, '' für keine Zuordnung * @var bool $mitSplit Eintrag „Splitbuchung“ anbieten (nur im Kontoblatt) + * @var bool $mitDepot Depots als Wertpapiergeschäft anbieten (`depot:7`) + * + * Mit `$mitDepot` kommt eine dritte Gruppe hinzu: dieselben Depots noch einmal, + * diesmal als Wertpapiergeschäft. Ein Kauf ist am Ende zwar eine Umbuchung ins + * Depot, aber erst, wenn Stückzahl, Kurs und Nebenkosten feststehen – die + * Auswahl führt deshalb in die Wertpapiermaske, statt die Zuordnung sofort zu + * speichern. Die schlichte Umbuchung bleibt daneben stehen: Auf ein Depot lässt + * sich auch Geld überweisen, ohne dass etwas gekauft wird. + * + * In den Splitzeilen fehlt die Gruppe. Dort steht der Betrag der Zeile schon + * fest, und die Umbuchung ins Depot ist genau das, was gebraucht wird. */ + +$istDepot = static fn(array $konto): bool => (string)($konto['kontoart'] ?? '') === 'depot'; +$depots = !empty($mitDepot) ? array_filter($konten, $istDepot) : []; ?> @@ -40,3 +54,14 @@ use Finanzmanager\View; + + + + + + + + + diff --git a/src/Views/einstellungen/index.php b/src/Views/einstellungen/index.php index 16997f6..bb0f08d 100644 --- a/src/Views/einstellungen/index.php +++ b/src/Views/einstellungen/index.php @@ -3,12 +3,14 @@ use Finanzmanager\Models\Einstellung; use Finanzmanager\Services\Cockpit; use Finanzmanager\Services\Kursabruf; +use Finanzmanager\Services\Wertpapierkosten; use Finanzmanager\View; /** @var string $bereich */ /** @var array $werte */ /** @var list $logDateien */ /** @var list $kacheln */ +/** @var list> $kategorien */ /** @var string $csrfToken */ $wert = static fn(string $k, string $standard = ''): string => (string)($werte[$k] ?? $standard); @@ -179,6 +181,32 @@ include __DIR__ . '/../layout/kopf.php'; funktioniert weiterhin.

+

Kategorien für die Kosten eines Wertpapiergeschäfts

+

+ Wer eine Wertpapierabrechnung aus dem Kontoblatt heraus bucht, gibt im + Kostendialog nur noch Beträge ein – in welche Kategorie eine Gebühr oder + Steuer gehört, steht hier. Jede belegte Art wird dann zu einer Splitzeile + auf dem Verrechnungskonto. Ohne Kategorie bleibt die Zeile unzugeordnet. +

+ + $name): ?> + + + + +

+ Für Sonstige Kosten ist das nur die Vorgabe – sie lässt sich im + Kostendialog überschreiben. +

+ diff --git a/tests/25_WertpapiergeschaeftTest.php b/tests/25_WertpapiergeschaeftTest.php new file mode 100644 index 0000000..f2ceef2 --- /dev/null +++ b/tests/25_WertpapiergeschaeftTest.php @@ -0,0 +1,384 @@ +mandantAnlegen(); + $this->stammdaten(); + + $this->depotAusDerAuswahlliste(); + $this->kennungenAusDemVerwendungszweck(); + $this->kaufMitKosten(); + $this->kaufOhneKosten(); + $this->verkaufMitSteuern(); + $this->loeschenLaesstDieBankbuchungStehen(); + $this->pruefungen(); + } + + private function stammdaten(): void + { + $this->giro = Konto::anlegen([ + 'name' => 'WP Giro', 'kontoart' => 'bank', 'anfangssaldo' => 10000, 'aktiv' => 1, + ]); + $this->depot = Konto::anlegen([ + 'name' => 'WP Depot', 'kontoart' => 'depot', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + + $this->katGebuehren = Kategorie::anlegen(['name' => 'WP Bankprovision', 'art' => 'ausgabe']); + $this->katSteuern = Kategorie::anlegen(['name' => 'WP Kapitalertragsteuer', 'art' => 'ausgabe']); + $this->katSonstige = Kategorie::anlegen(['name' => 'WP Sonstiges', 'art' => 'ausgabe']); + + Einstellung::set( + Wertpapierkosten::schluessel('bankprovision'), + (string)$this->katGebuehren, + 'wertpapiere' + ); + Einstellung::set( + Wertpapierkosten::schluessel('kapitalertragsteuer'), + (string)$this->katSteuern, + 'wertpapiere' + ); + } + + private function buchung(float $betrag, string $datum, string $zweck = ''): int + { + return Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => $datum, + 'betrag' => $betrag, + 'empfaenger' => 'Wertpapierabrechnung', + 'buchungstext' => $zweck, + 'quelle' => 'banking', + ]); + } + + /** Das Depot steht als eigener Wert in der Zuordnungsliste – `depot:7`. */ + private function depotAusDerAuswahlliste(): void + { + $this->assertEquals(7, BuchungenController::depotAus('depot:7'), 'Ein Depot wird erkannt'); + $this->assertNull(BuchungenController::depotAus('konto:7'), 'Eine Umbuchung ist kein Depotgeschäft'); + $this->assertNull(BuchungenController::depotAus('kategorie:7'), 'Eine Kategorie erst recht nicht'); + $this->assertNull(BuchungenController::depotAus('depot:0'), 'Kennung 0 zählt nicht'); + $this->assertNull(BuchungenController::depotAus(''), 'Leer heißt keine Auswahl'); + + // Und der bisherige Weg bleibt unberührt: Ein Depot ist weiterhin ein + // gewöhnliches Gegenkonto, wenn es als `konto:` daherkommt. + $this->assertEquals( + ['kategorie_id' => null, 'gegenkonto_id' => 7], + BuchungenController::zuordnungAus('konto:7'), + 'Die Umbuchung auf ein Depot gibt es weiterhin' + ); + $this->assertEquals( + ['kategorie_id' => null, 'gegenkonto_id' => null], + BuchungenController::zuordnungAus('depot:7'), + 'Ein Depotgeschäft ist selbst noch keine Zuordnung' + ); + } + + /** ISIN und WKN stehen im Verwendungszweck der Abrechnung. */ + private function kennungenAusDemVerwendungszweck(): void + { + $zweck = 'Depot 0502022353 Wertp.Abrechn. 03.08.2026 000007239846100 WKN LYX01W ' + . 'Gesch.Art KV MUL-AMUN ST600 BANK ETF A ISIN LU1834983477 Ihr Wertpapie'; + + $gelesen = Wertpapier::ausText($zweck); + $this->assertEquals('LU1834983477', $gelesen['isin'], 'Die ISIN wird gefunden'); + $this->assertEquals('LYX01W', $gelesen['wkn'], 'Die WKN auch'); + + $leer = Wertpapier::ausText('Miete August 2026'); + $this->assertEquals('', $leer['isin'], 'Ohne Wertpapier keine ISIN'); + $this->assertEquals('', $leer['wkn'], 'Und keine WKN'); + + // Eine sechsstellige Belegnummer ist keine WKN – ohne das Wort davor + // bleibt das Feld leer. + $this->assertEquals( + '', + Wertpapier::ausText('Beleg 4711AB vom 01.08.2026')['wkn'], + 'Sechs Zeichen allein machen noch keine WKN' + ); + } + + /** + * Der Fall aus dem Dialog: 255,00 € gehen vom Girokonto ab, davon 4,00 € + * Bankprovision und 1,00 € sonstige Kosten. Als Einstand bleiben 250,00 €. + */ + private function kaufMitKosten(): void + { + $id = $this->buchung(-255.0, '2026-08-03', 'WKN LYX01W ISIN LU1834983477 Kauf'); + + $ergebnis = Wertpapiergeschaeft::buchen(Buchung::find($id), [ + 'depot_id' => $this->depot, + 'wertpapier_name' => 'LYXOR IF-LY.ST.EUR.600 BANKS', + 'isin' => 'LU1834983477', + 'wkn' => 'LYX01W', + 'art' => 'fonds', + 'stueck' => 3.3593, + 'kurs' => 74.42026613, + 'kosten' => ['bankprovision' => 4.0, 'sonstige' => 1.0], + 'kosten_sonstige_kategorie' => $this->katSonstige, + ]); + + $this->assertEquals('kauf', $ergebnis['vorgang'], 'Ein Abfluss ist ein Kauf'); + $this->assertEquals(250.0, $ergebnis['betrag'], 'Der Einstandspreis ist der Rest nach den Kosten'); + + $depotbuchung = Depotbuchung::find($ergebnis['depotbuchung_id']); + $this->assertEquals(250.0, (float)$depotbuchung['betrag'], 'So steht er auch in der Depotbuchung'); + $this->assertEquals(5.0, (float)$depotbuchung['gebuehren'], 'Gebühren daneben, nicht darin'); + $this->assertEquals(0.0, (float)$depotbuchung['steuern'], 'Ohne Steuern'); + $this->assertEquals(3.3593, (float)$depotbuchung['stueck'], 'Die Stückzahl'); + $this->assertEquals( + $id, + (int)$depotbuchung['buchung_id'], + 'Die Depotbuchung hängt an der vorhandenen Buchung' + ); + $this->assertEquals( + 0, + (int)$depotbuchung['buchung_erzeugt'], + 'Sie hat die Geldbuchung nicht selbst angelegt' + ); + $this->assertEquals( + $this->giro, + (int)$depotbuchung['verrechnungskonto_id'], + 'Das Verrechnungskonto ist das Girokonto' + ); + + // Keine zweite Buchung neben der echten. + $this->assertEquals( + 1, + count(array_filter( + Buchung::kontoblatt($this->giro), + static fn(array $zeile): bool => abs((float)$zeile['betrag'] + 255.0) < 0.005 + )), + 'Es bleibt bei der einen Buchung des Bankabrufs' + ); + + // Die Aufteilung: der Rest ins Depot, jede Kostenart in ihre Kategorie. + $splits = Buchung::splits($id); + $this->assertEquals(3, count($splits), 'Drei Splitzeilen: Depot, Bankprovision, Sonstiges'); + $this->assertEquals($this->depot, (int)$splits[0]['gegenkonto_id'], 'Die erste Zeile geht ins Depot'); + $this->assertEquals(-250.0, (float)$splits[0]['betrag'], 'Mit dem Kurswert'); + $this->assertEquals($this->katGebuehren, (int)$splits[1]['kategorie_id'], 'Die Bankprovision in ihrer Kategorie'); + $this->assertEquals(-4.0, (float)$splits[1]['betrag'], 'Nebenkosten sind Aufwand'); + $this->assertEquals($this->katSonstige, (int)$splits[2]['kategorie_id'], 'Sonstige Kosten in der gewählten Kategorie'); + + $summe = array_sum(array_map(static fn(array $z): float => (float)$z['betrag'], $splits)); + $this->assertEquals(-255.0, round($summe, 2), 'Die Zeilen ergeben genau den Buchungsbetrag'); + + // Das Papier wurde nebenbei angelegt und ist im Depot bewertbar. + $bestand = Depot::bestand($this->depot); + $this->assertEquals(255.0, round($bestand['einstand'], 2), 'Der Einstand enthält die Nebenkosten genau einmal'); + + // Ein zweites Geschäft zur selben Buchung wird abgewiesen. + $doppelt = false; + try { + Wertpapiergeschaeft::buchen(Buchung::find($id), [ + 'depot_id' => $this->depot, 'wertpapier_name' => 'Zweites', 'stueck' => 1, + ]); + } catch (\Throwable $e) { + $doppelt = true; + } + $this->assert($doppelt, 'Zu einer Buchung gibt es nur ein Wertpapiergeschäft'); + } + + /** Ohne Nebenkosten bleibt es bei der schlichten Umbuchung ins Depot. */ + private function kaufOhneKosten(): void + { + $id = $this->buchung(-1000.0, '2026-08-04'); + + $ergebnis = Wertpapiergeschaeft::buchen(Buchung::find($id), [ + 'depot_id' => $this->depot, + 'wertpapier_name' => 'Ohne Nebenkosten AG', + 'isin' => 'DE0001234567', + 'stueck' => 10, + ]); + + $this->assert(!$ergebnis['gesplittet'], 'Ohne Kosten wird nicht gesplittet'); + $this->assertEquals(1000.0, $ergebnis['betrag'], 'Der ganze Betrag ist Einstand'); + + $buchung = Buchung::find($id); + $this->assertEquals(0, (int)$buchung['gesplittet'], 'Die Buchung ist keine Splitbuchung'); + $this->assertEquals($this->depot, (int)$buchung['gegenkonto_id'], 'Sondern eine Umbuchung ins Depot'); + + $depotbuchung = Depotbuchung::find($ergebnis['depotbuchung_id']); + $this->assertEquals(100.0, (float)$depotbuchung['kurs'], 'Der fehlende Kurs ergibt sich aus Betrag und Stück'); + } + + /** + * Der Verkauf aus der Modelldokumentation: 4 × 797,00 € brutto, 15,37 € + * Gebühren, 69,46 € Steuern – gutgeschrieben werden 3.103,17 €. + */ + private function verkaufMitSteuern(): void + { + $id = $this->buchung(3103.17, '2026-08-05'); + + $ergebnis = Wertpapiergeschaeft::buchen(Buchung::find($id), [ + 'depot_id' => $this->depot, + 'wertpapier_name' => 'Verkaufte AG', + 'isin' => 'DE0007654321', + 'stueck' => 4, + 'kosten' => ['bankprovision' => 15.37, 'kapitalertragsteuer' => 69.46], + ]); + + $this->assertEquals('verkauf', $ergebnis['vorgang'], 'Ein Zufluss ist ein Verkauf'); + $this->assertEquals(3188.0, $ergebnis['betrag'], 'Der Bruttoerlös ist die Gutschrift plus Nebenkosten'); + + $depotbuchung = Depotbuchung::find($ergebnis['depotbuchung_id']); + $this->assertEquals(-4.0, (float)$depotbuchung['stueck'], 'Ein Abgang zählt negativ'); + $this->assertEquals(797.0, (float)$depotbuchung['kurs'], 'Der Kurs ergibt sich aus Erlös und Stück'); + $this->assertEquals(15.37, (float)$depotbuchung['gebuehren'], 'Gebühren getrennt von den Steuern'); + $this->assertEquals(69.46, (float)$depotbuchung['steuern'], 'Und die Steuern für sich'); + + $splits = Buchung::splits($id); + $this->assertEquals(3188.0, (float)$splits[0]['betrag'], 'Die Depotzeile trägt den Bruttoerlös'); + $summe = array_sum(array_map(static fn(array $z): float => (float)$z['betrag'], $splits)); + $this->assertEquals(3103.17, round($summe, 2), 'Und die Zeilen ergeben wieder die Gutschrift'); + + // Kosten so hoch wie die Buchung ergeben kein Geschäft. + $abgewiesen = false; + try { + Wertpapiergeschaeft::buchen($this->buchungAls(-50.0, '2026-08-06'), [ + 'depot_id' => $this->depot, + 'wertpapier_name' => 'Zu teuer AG', + 'stueck' => 1, + 'kosten' => ['spesen' => 50.0], + ]); + } catch (\Throwable $e) { + $abgewiesen = true; + } + $this->assert($abgewiesen, 'Nebenkosten in Höhe der Buchung werden abgewiesen'); + } + + /** + * Wird das Geschäft rückgängig gemacht, bleibt die Buchung der Bank stehen – + * sie ist kein Erzeugnis dieses Moduls. Nur ihre Aufteilung geht mit. + */ + private function loeschenLaesstDieBankbuchungStehen(): void + { + $id = $this->buchung(-330.0, '2026-08-07'); + + $ergebnis = Wertpapiergeschaeft::buchen(Buchung::find($id), [ + 'depot_id' => $this->depot, + 'wertpapier_name' => 'Wieder weg AG', + 'isin' => 'DE0009999999', + 'stueck' => 3, + 'kosten' => ['spesen' => 30.0], + ]); + + $this->assertEquals(1, (int)Buchung::find($id)['gesplittet'], 'Die Buchung ist zunächst aufgeteilt'); + + Depotbuchung::loeschen($ergebnis['depotbuchung_id']); + + $buchung = Buchung::find($id); + $this->assertNotEmpty($buchung, 'Die Buchung des Bankabrufs bleibt stehen'); + $this->assertEquals(-330.0, (float)$buchung['betrag'], 'Mit unverändertem Betrag'); + $this->assertEquals(0, (int)$buchung['gesplittet'], 'Aber ohne die Aufteilung des Geschäfts'); + $this->assertEquals([], Buchung::splits($id), 'Die Splitzeilen sind weg'); + $this->assertNull($buchung['gegenkonto_id'], 'Und ohne Zuordnung – sie ist wieder offen'); + } + + /** Eine Buchung anlegen und gleich als Datensatz zurückgeben. */ + private function buchungAls(float $betrag, string $datum): array + { + return Buchung::find($this->buchung($betrag, $datum)); + } + + private function pruefungen(): void + { + // Die Kostenarten des Dialogs und ihre Einstellungsschlüssel. + $arten = Wertpapierkosten::arten(); + $this->assertEquals(12, count($arten), 'Sechs Gebühren- und sechs Steuerarten'); + $this->assert(isset($arten['bankprovision']), 'Die Bankprovision ist dabei'); + $this->assert(Wertpapierkosten::istSteuer('kirchensteuer'), 'Die Kirchensteuer zählt als Steuer'); + $this->assert(!Wertpapierkosten::istSteuer('spesen'), 'Spesen nicht'); + $this->assertEquals( + 'wertpapiere_kategorie_spesen', + Wertpapierkosten::schluessel('spesen'), + 'Der Einstellungsschlüssel folgt einem festen Muster' + ); + $this->assertEquals( + $this->katGebuehren, + Wertpapierkosten::kategorien()['bankprovision'], + 'Die hinterlegte Kategorie wird gefunden' + ); + $this->assertNull( + Wertpapierkosten::kategorien()['kirchensteuer'], + 'Ohne Hinterlegung bleibt die Kategorie leer' + ); + + // Zeilen unter einem Cent zählen nicht. + $this->assertEquals( + [], + Wertpapierkosten::zeilen(['spesen' => 0.0, 'maklercourtage' => 0.001]), + 'Leere Beträge ergeben keine Splitzeile' + ); + + // Die reine Rechnung, ohne Datenbank. + $kauf = Wertpapiergeschaeft::aufteilung(-255.0, ['bankprovision' => 5.0]); + $this->assertEquals(-250.0, $kauf['kurswert'], 'Beim Kauf gehen die Kosten vom Abfluss ab'); + $verkauf = Wertpapiergeschaeft::aufteilung(3103.17, ['kapitalertragsteuer' => 84.83]); + $this->assertEquals(3188.0, $verkauf['kurswert'], 'Beim Verkauf kommen sie zum Zufluss hinzu'); + $this->assertEquals(84.83, $verkauf['steuern'], 'Und zählen als Steuern'); + $this->assertEquals(0.0, $verkauf['gebuehren'], 'Nicht als Gebühren'); + + $this->assertEquals('kauf', Wertpapiergeschaeft::vorgang(-10.0), 'Ein Abfluss ist immer ein Kauf'); + $this->assertEquals( + 'kauf', + Wertpapiergeschaeft::vorgang(-10.0, 'dividende'), + 'Auch wenn die Maske etwas anderes sagt' + ); + $this->assertEquals('verkauf', Wertpapiergeschaeft::vorgang(10.0), 'Ein Zufluss ist ein Verkauf'); + $this->assertEquals( + 'dividende', + Wertpapiergeschaeft::vorgang(10.0, 'dividende'), + 'Oder eine Dividende, wenn die Maske es sagt' + ); + + // Ohne Depot kein Geschäft. + $ohneDepot = false; + try { + Wertpapiergeschaeft::buchen($this->buchungAls(-100.0, '2026-08-08'), [ + 'depot_id' => $this->giro, 'wertpapier_name' => 'Kein Depot AG', 'stueck' => 1, + ]); + } catch (\Throwable $e) { + $ohneDepot = true; + } + $this->assert($ohneDepot, 'Ein Girokonto ist kein Depot'); + + $ohneStueck = false; + try { + Wertpapiergeschaeft::buchen($this->buchungAls(-100.0, '2026-08-09'), [ + 'depot_id' => $this->depot, 'wertpapier_name' => 'Ohne Stück AG', + ]); + } catch (\Throwable $e) { + $ohneStueck = true; + } + $this->assert($ohneStueck, 'Ein Kauf ohne Stückzahl wird abgewiesen'); + } +} diff --git a/tests/e2e/specs/wertpapiergeschaeft.spec.js b/tests/e2e/specs/wertpapiergeschaeft.spec.js new file mode 100644 index 0000000..16de60d --- /dev/null +++ b/tests/e2e/specs/wertpapiergeschaeft.spec.js @@ -0,0 +1,173 @@ +// @ts-check +const { test, expect } = require('@playwright/test') + +/* + * Der Weg, den die Wertpapierabrechnung nimmt: Sie kommt als Buchung aufs + * Girokonto, in der Zuordnungsliste wird das Depot gewählt, und dann folgen die + * beiden Dialoge – vorne das Geschäft, dahinter die Kosten. + */ + +const BENUTZER = process.env.E2E_USER || 'e2e' +const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' + +const MARKE = Date.now().toString().slice(-6) +const GIRO = `E2E WP Giro ${MARKE}` +const DEPOT = `E2E WP Depot ${MARKE}` +const KAT_GEBUEHR = `E2E Bankprovision ${MARKE}` +const KAT_STEUER = `E2E Kapitalertragsteuer ${MARKE}` + +const ZWECK = + 'Depot 0502022353 Wertp.Abrechn. 03.08.2026 WKN LYX01W Gesch.Art KV ' + + 'MUL-AMUN ST600 BANK ETF A ISIN LU1834983477 Ihr Wertpapie' + +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(/\/$/) +} + +async function kontoAnlegen(page, name, kontoart, anfangssaldo) { + await page.goto('/konten/neu') + await page.fill('#name', name) + await page.selectOption('#kontoart', kontoart) + await page.fill('#anfangssaldo', anfangssaldo) + await page.click('.formular button[type="submit"]') + await expect(page).toHaveURL(/\/konten\/\d+\/bearbeiten/) + + const treffer = page.url().match(/\/konten\/(\d+)\//) + return treffer ? treffer[1] : null +} + +async function kategorieAnlegen(page, name) { + await page.goto('/verwaltung/kategorien/neu') + await page.fill('#name', name) + await page.selectOption('#art', 'ausgabe') + await page.click('.formular button[type="submit"]') +} + +/** Eine unzugeordnete Buchung auf dem Girokonto – wie sie der Abruf hinterlässt. */ +async function buchungAnlegen(page, giroId, betrag, empfaenger) { + await page.goto(`/konten/${giroId}/buchung`) + await page.selectOption('#art', betrag < 0 ? 'ausgabe' : 'einnahme') + await page.fill('#datum', '2026-08-03') + await page.fill('#betrag', String(Math.abs(betrag)).replace('.', ',')) + await page.fill('#empfaenger', empfaenger) + await page.fill('#buchungstext', ZWECK) + await page.click('.buchungsmaske button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toBeVisible() +} + +test.describe('Wertpapiergeschäft aus dem Kontoblatt', () => { + let giroId = null + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + await anmelden(page) + + giroId = await kontoAnlegen(page, GIRO, 'bank', '10.000,00') + await kontoAnlegen(page, DEPOT, 'depot', '0,00') + await kategorieAnlegen(page, KAT_GEBUEHR) + await kategorieAnlegen(page, KAT_STEUER) + + // Die Kostenarten bekommen ihre feste Kategorie – danach fragt der + // Kostendialog nur noch Beträge ab. + await page.goto('/einstellungen?bereich=wertpapiere') + await page.selectOption('#wertpapiere_kategorie_bankprovision', { label: KAT_GEBUEHR }) + await page.selectOption('#wertpapiere_kategorie_kapitalertragsteuer', { label: KAT_STEUER }) + await page.click('.formular button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + await page.close() + }) + + test.beforeEach(async ({ page }) => { + await anmelden(page) + }) + + test('Kauf mit Kosten wird zur Depotbuchung und zur Splitbuchung', async ({ page }) => { + const empfaenger = `Wertpapierkauf ${MARKE}` + await buchungAnlegen(page, giroId, -255, empfaenger) + + const zeile = page.locator('tr', { hasText: empfaenger }) + await zeile.locator('a.zuordnungsfeld').click() + await zeile.locator('#zuordnungsauswahl select').selectOption({ label: `[${DEPOT}] …` }) + + const dialog = page.locator('#wertpapierdialog') + await expect(dialog).toBeVisible() + await expect(dialog).toContainText('-255,00') + await expect(dialog).toContainText(DEPOT) + + // Ein Abfluss kann nur ein Kauf sein. + await expect(page.locator('#wp_vorgang')).toHaveValue('kauf') + + // ISIN und WKN stehen im Verwendungszweck und sind schon eingetragen. + await expect(page.locator('#wp_isin')).toHaveValue('LU1834983477') + await expect(page.locator('#wp_wkn')).toHaveValue('LYX01W') + + await page.fill('#wp_name', `LYXOR ETF ${MARKE}`) + await page.selectOption('#wp_art', 'fonds') + await page.fill('#wp_stueck', '3,3593') + await page.fill('#wp_kurs', '74,42026613') + + // Ohne Kosten ist der ganze Betrag Einstand. + await expect(page.locator('#wp_einstand')).toContainText('255,00') + + // Der zweite Dialog: die Nebenkosten. + await page.click('#wp_kostenknopf') + const kosten = page.locator('#kostendialog') + await expect(kosten).toBeVisible() + await expect(kosten).toContainText(KAT_GEBUEHR) + + await kosten.locator('#kosten_bankprovision').fill('4,00') + await kosten.locator('#kosten_spesen').fill('1,00') + await expect(page.locator('#wp_kostensumme')).toContainText('5,00') + await page.click('#wp_kostenfertig') + await expect(kosten).toBeHidden() + + // Was nach den Kosten übrig bleibt, ist der Einstandspreis. + await expect(page.locator('#wp_einstand')).toContainText('250,00') + + await dialog.locator('button[type="submit"]').click() + await expect(page.locator('.meldung-erfolg')).toContainText('Einstandspreis 250,00') + + // Im Kontoblatt steht jetzt eine Splitbuchung – die eine Buchung der + // Bank, aufgeteilt, nicht eine zweite daneben. + const danach = page.locator('tr', { hasText: empfaenger }).first() + await expect(danach).toContainText('Splitbuchung') + await expect(danach).toContainText('-255,00') + + // Die Depotbuchung führt Stück, Kurs und Nebenkosten. + await page.goto('/wertpapiere/buchungen') + const depotzeile = page.locator('tr', { hasText: `LYXOR ETF ${MARKE}` }) + await expect(depotzeile).toContainText('250,00') + await expect(depotzeile).toContainText('3,3593') + }) + + test('Kauf ohne Kosten bleibt eine Umbuchung ins Depot', async ({ page }) => { + const empfaenger = `Sparplan ${MARKE}` + await buchungAnlegen(page, giroId, -1000, empfaenger) + + const zeile = page.locator('tr', { hasText: empfaenger }) + await zeile.locator('a.zuordnungsfeld').click() + await zeile.locator('#zuordnungsauswahl select').selectOption({ label: `[${DEPOT}] …` }) + + await expect(page.locator('#wertpapierdialog')).toBeVisible() + await page.fill('#wp_name', `Sparplan-Fonds ${MARKE}`) + await page.fill('#wp_stueck', '10') + + await page.locator('#wertpapierdialog button[type="submit"]').click() + await expect(page.locator('.meldung-erfolg')).toContainText('Einstandspreis 1.000,00') + + // Keine Splitbuchung mit einer einzigen Zeile, sondern eine Umbuchung. + await expect(page.locator('tr', { hasText: empfaenger }).first()) + .toContainText(`[${DEPOT}]`) + }) +})