Wertpapiergeschäft aus einer Kontobuchung

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 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-13 13:27:52 +02:00
parent dfbc9a9b8e
commit e56c21bc9b
24 changed files with 1939 additions and 17 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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_<art>`); 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

View file

@ -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;

View file

@ -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());

View file

@ -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 {

View file

@ -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();

View file

@ -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();
}
};
})();

View file

@ -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<string,string> $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<string,string> $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<string,mixed>
*/
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);

View file

@ -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<string,string>
*/
private function kostenfelder(): array
{
$felder = [];
foreach (array_keys(Wertpapierkosten::arten()) as $art) {
$felder[Wertpapierkosten::schluessel($art)] = 'text';
}
return $felder;
}
/** @return list<array{name:string,pfad:string,groesse:int,geaendert:int}> */
private function logDateien(): array
{

View file

@ -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<string,mixed> $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,

View file

@ -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<string,mixed> $daten */
public static function findenOderAnlegen(array $daten): int
{

View file

@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
/**
* Ein Wertpapiergeschäft aus einer vorhandenen Kontobuchung.
*
* **Warum in dieser Richtung.** Der Bankabruf bringt die Wertpapierabrechnung
* als gewöhnlichen Umsatz auf dem Girokonto – ein Betrag, ein Verwendungszweck,
* sonst nichts. Dass es ein Kauf war, wieviele Stücke zu welchem Kurs und was
* davon Gebühren und Steuern waren, weiß erst der Mensch davor. Diese Klasse
* nimmt seine Angaben und macht aus der einen Buchung beides: die Depotbuchung
* mit dem Einstandspreis und die Aufteilung im Kontoblatt.
*
* **Der Buchungsbetrag ist die feste Größe.** Er kommt von der Bank und wird
* nicht nachgerechnet. Die Nebenkosten werden aus ihm herausgerechnet; was übrig
* bleibt, ist der Kurswert und damit der Einstandspreis:
*
* Kauf −255,00 € + 5,00 € Kosten = −250,00 € → Einstand 250,00 €
* Verkauf 3.103,17 € + 84,83 € Kosten = 3.188,00 € → Erlös 3.188,00 €
*
* Dieselbe Rechnung in beiden Richtungen: Nebenkosten sind Aufwand, ob gekauft
* oder verkauft wird. Der Kurs ist demgegenüber eine Angabe der Abrechnung –
* fehlt er, ergibt er sich aus Einstand und Stückzahl, nie umgekehrt.
*
* **Was im Kontoblatt entsteht.** Je belegter Kostenart eine Splitzeile in ihrer
* Kategorie, der Rest als Umbuchung ins Depot. Ohne Nebenkosten bleibt es bei
* der schlichten Umbuchung – eine Splitbuchung mit einer einzigen Zeile wäre nur
* umständlicher zu lesen.
*/
class Wertpapiergeschaeft
{
/**
* Die Aufteilung eines Buchungsbetrags – ohne Datenbank, damit sie sich
* nachrechnen lässt.
*
* @param float $geld Betrag der Buchung, mit Vorzeichen
* @param array<string,float> $betraege Kostenart => Betrag
* @return array{
* kurswert: float,
* gebuehren: float,
* steuern: float,
* kosten: list<array{art:string,name:string,betrag:float,kategorie_id:?int,steuer:bool}>
* }
*/
public static function aufteilung(float $geld, array $betraege, ?int $sonstigeKategorie = null): array
{
$geld = round($geld, 2);
$zeilen = Wertpapierkosten::zeilen($betraege, $sonstigeKategorie);
$summen = Wertpapierkosten::summen($zeilen);
return [
'kurswert' => round($geld + $summen['summe'], 2),
'gebuehren' => $summen['gebuehren'],
'steuern' => $summen['steuern'],
'kosten' => $zeilen,
];
}
/**
* Der Vorgang zum Geldfluss. Ein Abfluss kann nur ein Kauf sein; ein Zufluss
* ist ein Verkauf oder – wenn die Maske es so sagt – eine Dividende.
*/
public static function vorgang(float $geld, string $gewaehlt = ''): string
{
if ($geld < 0) {
return 'kauf';
}
return $gewaehlt === 'dividende' ? 'dividende' : 'verkauf';
}
/**
* Depotbuchung und Aufteilung in einem Zug.
*
* @param array<string,mixed> $buchung die Buchung des Verrechnungskontos
* @param array<string,mixed> $eingaben depot_id, wertpapier_id, wertpapier_name, isin,
* wkn, art, vorgang, datum, valuta, stueck, kurs,
* notiz, kosten, kosten_sonstige_kategorie
* @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,
];
});
}
}

View file

@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Models\Einstellung;
/**
* Die Nebenkosten eines Wertpapiergeschäfts – Gebühren und Steuern, je Art
* einzeln.
*
* **Warum einzeln.** In der Depotbuchung stehen Gebühren und Steuern nur als
* zwei Summen; für den Einstand genügt das. Im Kontoblatt genügt es nicht: Dort
* will die Kapitalertragsteuer in einer anderen Kategorie stehen als die
* Maklercourtage. Die Aufteilung entsteht deshalb als Splitbuchung auf dem
* Verrechnungskonto, und diese Klasse hält fest, welche Art in welche Kategorie
* gehört.
*
* **Woher die Kategorie kommt.** Aus den Einstellungen, Bereich *Wertpapiere*,
* unter `wertpapiere_kategorie_<art>`. 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<string,string> 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<string,?int>
*/
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<string,float> $betraege art => Betrag
* @param ?int $sonstigeKategorie Kategorie für „Sonstige Kosten“
* @return list<array{art:string,name:string,betrag:float,kategorie_id:?int,steuer:bool}>
*/
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<array{betrag:float,steuer:bool}> $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),
];
}
}

View file

@ -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';
<tr>
<td>
<select name="split[<?= $nr ?>][zuordnung]">
<?php $gewaehlt = $zuordnungswert($zeile); ?>
<?php /* In einer Splitzeile ist das Depot genau das, was
es zu sein hat: ein Gegenkonto. Die Zeile steht
ja schon für einen bestimmten Betrag. */ ?>
<?php $gewaehlt = $zuordnungswert($zeile); $mitDepot = false; ?>
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
</select>
</td>

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\View;
/** @var array<string,mixed> $konto */
@ -230,10 +231,23 @@ include __DIR__ . '/../layout/kopf.php';
<?php else: ?>
<?php /* Ohne JavaScript führt der Klick in die Buchungsmaske;
kontoblatt.js macht daraus die Auswahl an Ort und Stelle. */ ?>
<?php
// Wertpapierabrechnungen führen ISIN und WKN im
// Verwendungszweck mit. Wer die Zeile ins Depot bucht,
// bekommt das Papier deshalb vorgeschlagen, statt es
// in einer langen Liste zu suchen.
$papier = Wertpapier::ausText(
(string)$zeile['buchungstext'] . ' ' . (string)$zeile['empfaenger']
);
?>
<a href="/buchungen/<?= (int)$zeile['id'] ?>" class="zuordnungsfeld"
data-buchung="<?= (int)$zeile['id'] ?>"
data-wert="<?= View::h($zuordnungswert($zeile)) ?>"
data-betrag="<?= View::h(number_format((float)$zeile['betrag'], 2, '.', '')) ?>"
data-datum="<?= View::h(substr((string)$zeile['datum'], 0, 10)) ?>"
data-zweck="<?= View::h((string)$zeile['buchungstext']) ?>"
data-isin="<?= View::h($papier['isin']) ?>"
data-wkn="<?= View::h($papier['wkn']) ?>"
data-bezeichnung="<?= View::h(View::datum($zeile['datum']) . ' · ' . ($zeile['empfaenger'] ?: '(ohne Empfänger)')) ?>"
<?php if (isset($splits[(int)$zeile['id']])): ?>
data-splits="<?= View::h($splitdaten($splits[(int)$zeile['id']])) ?>"
@ -277,7 +291,7 @@ include __DIR__ . '/../layout/kopf.php';
<form method="post" action="" id="zuordnungsauswahl" class="zuordnungsauswahl" hidden>
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<select name="zuordnung" aria-label="Kategorie oder Gegenkonto">
<?php $gewaehlt = ''; $mitSplit = true; ?>
<?php $gewaehlt = ''; $mitSplit = true; $mitDepot = true; ?>
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
</select>
<noscript><button type="submit" class="knopf klein">Übernehmen</button></noscript>
@ -304,7 +318,7 @@ include __DIR__ . '/../layout/kopf.php';
<tr>
<td>
<select name="split[<?= $nr ?>][zuordnung]">
<?php $gewaehlt = ''; $mitSplit = false; ?>
<?php $gewaehlt = ''; $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
</select>
</td>
@ -352,7 +366,54 @@ include __DIR__ . '/../layout/kopf.php';
</form>
</dialog>
<?php /* Ein Wertpapiergeschäft entsteht dort, wo seine Abrechnung ankommt:
am Girokonto. Die Auswahl eines Depots in der Zuordnungsliste öffnet
diesen Dialog; die Nebenkosten liegen dahinter im zweiten. */ ?>
<dialog id="wertpapierdialog" class="splitdialog wertpapierdialog">
<form method="post" action="" id="wertpapierformular" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="depot_id" id="wp_depot" value="">
<h2>Wertpapiergeschäft</h2>
<p class="feldhinweis">
<span id="wp_bezeichnung"></span> über
<output id="wp_buchungsbetrag" class="betrag">0,00</output> ·
Depot <strong id="wp_depotname"></strong>
</p>
<p class="feldhinweis verwendungszweck" id="wp_verwendungszweck"></p>
<?php $datumsvorgabe = date('Y-m-d'); ?>
<?php $vorschlag = ['isin' => '', 'wkn' => '']; ?>
<?php include __DIR__ . '/wertpapierfelder.php'; ?>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Geschäft buchen</button>
<button type="button" class="knopf" id="wp_kostenknopf">
Kosten … (<output id="wp_kostenknopfsumme" class="betrag">0,00</output>)
</button>
<button type="button" class="knopf" id="wp_abbrechen">Abbrechen</button>
<span class="fuellung"></span>
<span class="saldoanzeige">
Einstandspreis <strong><output id="wp_einstand" class="betrag">0,00</output></strong>
</span>
</div>
</form>
</dialog>
<?php /* Die Kostenfelder gehören zum Formular oben, stehen aber in einem
eigenen Dialog – verbunden über das form-Attribut, wie schon die
Ankreuzfelder der Sammellöschung. */ ?>
<dialog id="kostendialog" class="splitdialog">
<h2>Kosten</h2>
<?php $kostenFormular = 'wertpapierformular'; ?>
<?php include __DIR__ . '/wertpapierkosten.php'; ?>
<div class="knopfleiste">
<button type="button" class="knopf primaer" id="wp_kostenfertig">Übernehmen</button>
</div>
</dialog>
<script src="/static/betrag.js" defer></script>
<script src="/static/wertpapierbuchung.js" defer></script>
<script src="/static/kontoblatt.js" defer></script>
<?php endif; ?>

View file

@ -0,0 +1,71 @@
<?php
use Finanzmanager\View;
/**
* Das Wertpapiergeschäft zu einer vorhandenen Buchung – als eigene Seite.
*
* Der übliche Weg ist der Dialog im Kontoblatt; hierher führt die Auswahl eines
* Depots, wenn kein JavaScript läuft. Die Felder sind dieselben, nur stehen die
* Nebenkosten untereinander statt hinter einem Knopf.
*
* @var array<string,mixed> $buchung
* @var array<string,mixed> $konto
* @var array<string,mixed> $depot
* @var list<array<string,mixed>> $wertpapiere
* @var list<array<string,mixed>> $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';
?>
<div class="seitenaktionen">
<a href="/konten/<?= (int)$konto['id'] ?>" class="knopf">Zurück zum Kontoblatt</a>
<span class="fuellung"></span>
<span class="saldoanzeige">
Depot <strong><?= View::h($depot['name']) ?></strong>
</span>
</div>
<form method="post" action="/buchungen/<?= (int)$buchung['id'] ?>/wertpapier" class="formular breit"
id="wertpapierformular" data-betrag="<?= View::h(number_format($betrag, 2, '.', '')) ?>">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="depot_id" value="<?= (int)$depot['id'] ?>">
<h3>Vorgang</h3>
<p class="feldhinweis">
<?= View::h(View::datum($buchung['datum'])) ?>
· <?= View::h($buchung['empfaenger'] ?: '(ohne Empfänger)') ?>
· <strong class="<?= View::betragKlasse($betrag) ?>"><?= View::betrag($betrag) ?></strong>
auf <?= View::h($konto['name']) ?>
</p>
<?php if ((string)$buchung['buchungstext'] !== ''): ?>
<p class="feldhinweis verwendungszweck"><?= View::h($buchung['buchungstext']) ?></p>
<?php endif; ?>
<?php include __DIR__ . '/wertpapierfelder.php'; ?>
<h3>Kosten</h3>
<?php $kostenFormular = ''; ?>
<?php include __DIR__ . '/wertpapierkosten.php'; ?>
<p class="feldhinweis">
Was nach Abzug der Kosten vom Buchungsbetrag übrig bleibt, ist der
Einstandspreis: <output id="wp_einstand" class="betrag"><?= View::betrag(abs($betrag), false) ?></output> €.
</p>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Geschäft buchen</button>
<a href="/konten/<?= (int)$konto['id'] ?>" class="knopf">Abbrechen</a>
</div>
</form>
<script src="/static/betrag.js" defer></script>
<script src="/static/wertpapierbuchung.js" defer></script>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,106 @@
<?php
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\View;
/**
* Die Stammdaten eines Wertpapiergeschäfts: Vorgang, Papier, Stück und Kurs.
*
* Dieselben Felder stehen im Dialog des Kontoblatts und auf der Seite
* `/buchungen/{id}/wertpapier`, die den Weg ohne JavaScript offen hält – deshalb
* hier und nicht zweimal. Die Nebenkosten stehen in `wertpapierkosten.php`; im
* Dialog liegen sie hinter dem Knopf *Kosten …*, auf der Seite darunter.
*
* Kein Feld für den Betrag: Der steht in der Buchung und ist die feste Größe.
* Was nach den Nebenkosten übrig bleibt, ist der Einstandspreis – die Maske
* rechnet ihn vor, gespeichert wird er aus denselben Zahlen noch einmal.
*
* @var list<array<string,mixed>> $wertpapiere
* @var array{isin:string,wkn:string} $vorschlag aus dem Verwendungszweck gelesen
* @var string $datumsvorgabe
*/
?>
<label for="wp_vorgang">Vorgangsart</label>
<select id="wp_vorgang" name="vorgang">
<option value="kauf">Kauf</option>
<option value="verkauf">Verkauf</option>
<option value="dividende">Dividende</option>
</select>
<p class="feldhinweis">
Ein Abfluss vom Konto kann nur ein Kauf sein, ein Zufluss ein Verkauf oder eine
Dividende.
</p>
<div class="feldpaar">
<div>
<label for="wp_datum">Datum</label>
<input type="date" id="wp_datum" name="datum" required
value="<?= View::h($datumsvorgabe) ?>">
</div>
<div>
<label for="wp_valuta">Wertstellung <span class="optional">(optional)</span></label>
<input type="date" id="wp_valuta" name="valuta">
</div>
</div>
<h3>Wertpapier</h3>
<label for="wp_wertpapier">Vorhandenes Wertpapier</label>
<select id="wp_wertpapier" name="wertpapier_id">
<option value="">– neues Wertpapier anlegen –</option>
<?php foreach ($wertpapiere as $papier): ?>
<?php
// Trifft die ISIN oder WKN aus dem Verwendungszweck, ist das Papier
// gefunden – dann muss niemand die Liste durchsuchen.
$passt = ($vorschlag['isin'] !== '' && strtoupper((string)$papier['isin']) === $vorschlag['isin'])
|| ($vorschlag['wkn'] !== '' && strtoupper((string)$papier['wkn']) === $vorschlag['wkn']);
?>
<option value="<?= (int)$papier['id'] ?>" <?= $passt ? 'selected' : '' ?>
data-isin="<?= View::h(strtoupper((string)$papier['isin'])) ?>"
data-wkn="<?= View::h(strtoupper((string)$papier['wkn'])) ?>">
<?= View::h($papier['name']) ?><?= $papier['isin'] !== '' ? ' (' . View::h($papier['isin']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
<label for="wp_name">Name <span class="optional">(nur für ein neues Wertpapier)</span></label>
<input type="text" id="wp_name" name="wertpapier_name">
<div class="feldpaar">
<div>
<label for="wp_isin">ISIN</label>
<input type="text" id="wp_isin" name="isin" value="<?= View::h($vorschlag['isin']) ?>">
</div>
<div>
<label for="wp_wkn">WKN</label>
<input type="text" id="wp_wkn" name="wkn" value="<?= View::h($vorschlag['wkn']) ?>">
</div>
<div>
<label for="wp_art">Art</label>
<select id="wp_art" name="art">
<?php foreach (Wertpapier::ARTEN as $schluessel => $name): ?>
<option value="<?= View::h($schluessel) ?>"><?= View::h($name) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<h3>Auftrag</h3>
<div class="feldpaar">
<div>
<label for="wp_stueck">Stück</label>
<input type="text" id="wp_stueck" name="stueck" inputmode="decimal">
</div>
<div>
<label for="wp_kurs">Kurs <span class="optional">(optional)</span></label>
<input type="text" id="wp_kurs" name="kurs" inputmode="decimal">
</div>
</div>
<p class="feldhinweis">
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.
</p>
<label for="wp_notiz">Notiz</label>
<input type="text" id="wp_notiz" name="notiz">

View file

@ -0,0 +1,83 @@
<?php
use Finanzmanager\Services\Wertpapierkosten;
use Finanzmanager\View;
/**
* Die Nebenkosten eines Wertpapiergeschäfts – Gebühren und Steuern, je Art eine
* Zeile. Aus jeder belegten Zeile wird eine Splitzeile auf dem Verrechnungskonto.
*
* Die Kategorien stehen in den Einstellungen (Bereich *Wertpapiere*), damit hier
* nur Beträge einzugeben sind; einzig „Sonstige Kosten“ bekommt ihre Kategorie
* an Ort und Stelle, weil sie eben nicht immer dieselbe ist.
*
* @var list<array<string,mixed>> $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;
}
}
?>
<tr>
<th scope="row"><?= View::h($name) ?></th>
<td>
<input type="text" class="rechts kostenbetrag" inputmode="decimal"
id="kosten_<?= View::h($art) ?>"
name="kosten_<?= View::h($art) ?>" value=""<?= $formAttribut ?>>
</td>
<td class="nebentext">
<?php if ($art === Wertpapierkosten::SONSTIGE): ?>
<select name="kosten_sonstige_kategorie" aria-label="Kategorie für Sonstige Kosten"<?= $formAttribut ?>>
<option value="<?= (int)$kategorie ?>">
<?= $pfad === '' ? '– Kategorie wählen –' : View::h($pfad) ?>
</option>
<?php foreach ($kategorien as $eintrag): ?>
<?php if ((int)$eintrag['id'] === (int)$kategorie) { continue; } ?>
<option value="<?= (int)$eintrag['id'] ?>"><?= View::h($eintrag['pfad']) ?></option>
<?php endforeach; ?>
</select>
<?php else: ?>
<?= $pfad === '' ? '<em>ohne Kategorie</em>' : View::h($pfad) ?>
<?php endif; ?>
</td>
</tr>
<?php
};
?>
<table class="liste kostentabelle">
<thead>
<tr><th>Art</th><th class="rechts">Betrag</th><th>Kategorie</th></tr>
</thead>
<tbody>
<tr class="gruppenzeile"><th colspan="3" scope="colgroup">Gebühren</th></tr>
<?php foreach (Wertpapierkosten::GEBUEHREN as $art => $name) { $zeile($art, $name); } ?>
<tr class="gruppenzeile"><th colspan="3" scope="colgroup">Steuern</th></tr>
<?php foreach (Wertpapierkosten::STEUERN as $art => $name) { $zeile($art, $name); } ?>
</tbody>
<tfoot>
<tr>
<th scope="row" class="rechts">Kosten</th>
<td class="rechts"><output id="wp_kostensumme" class="betrag">0,00</output></td>
<td></td>
</tr>
</tfoot>
</table>
<p class="feldhinweis">
Arten ohne Kategorie landen als Splitzeile ohne Zuordnung im Kontoblatt. Die
feste Zuordnung steht unter
<a href="/einstellungen?bereich=wertpapiere">Einstellungen → Wertpapiere</a>.
</p>

View file

@ -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) : [];
?>
<option value=""<?= ($gewaehlt ?? '') === '' ? ' selected' : '' ?>>— keine —</option>
<?php if (!empty($mitSplit)): ?>
@ -40,3 +54,14 @@ use Finanzmanager\View;
</option>
<?php endforeach; ?>
</optgroup>
<?php if ($depots !== []): ?>
<optgroup label="Wertpapiergeschäft im Depot">
<?php foreach ($depots as $eintrag): ?>
<?php if ((int)$eintrag['id'] === (int)$eigenesKonto) { continue; } ?>
<?php $wert = 'depot:' . (int)$eintrag['id']; ?>
<option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>>
[<?= View::h($eintrag['name']) ?>] …
</option>
<?php endforeach; ?>
</optgroup>
<?php endif; ?>

View file

@ -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<string,string|null> $werte */
/** @var list<array{name:string,pfad:string,groesse:int,geaendert:int}> $logDateien */
/** @var list<string> $kacheln */
/** @var list<array<string,mixed>> $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.
</p>
<h3>Kategorien für die Kosten eines Wertpapiergeschäfts</h3>
<p class="feldhinweis">
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.
</p>
<?php foreach (Wertpapierkosten::arten() as $art => $name): ?>
<?php $feld = Wertpapierkosten::schluessel($art); ?>
<label for="<?= View::h($feld) ?>"><?= View::h($name) ?></label>
<select id="<?= View::h($feld) ?>" name="<?= View::h($feld) ?>">
<option value="">— keine —</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= $wert($feld) === (string)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
</select>
<?php endforeach; ?>
<p class="feldhinweis">
Für <em>Sonstige Kosten</em> ist das nur die Vorgabe – sie lässt sich im
Kostendialog überschreiben.
</p>
<div class="knopfleiste">
<a href="/wertpapiere/stammdaten" class="knopf">Wertpapiere verwalten</a>
</div>

View file

@ -0,0 +1,384 @@
<?php
declare(strict_types=1);
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\Depot;
use Finanzmanager\Services\Wertpapiergeschaeft;
use Finanzmanager\Services\Wertpapierkosten;
/**
* Das Wertpapiergeschäft aus einer Kontobuchung heraus: Der Bankabruf bringt die
* Abrechnung aufs Girokonto, die Zuordnung auf ein Depot macht daraus die
* Depotbuchung samt Aufteilung der Nebenkosten.
*/
class WertpapiergeschaeftTest extends TestCase
{
protected string $testName = 'Test 25: Wertpapiergeschäft aus einer Kontobuchung';
private int $giro = 0;
private int $depot = 0;
private int $katGebuehren = 0;
private int $katSteuern = 0;
private int $katSonstige = 0;
public function run(): void
{
$this->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');
}
}

View file

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