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}]`) + }) +})