Merge pull request 'Wertpapiergeschäft aus einer Kontobuchung' (#14) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/14
This commit is contained in:
commit
12b8cf2c10
24 changed files with 1954 additions and 17 deletions
47
Changes.md
47
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
34
migrations/012_wertpapierkosten.sql
Normal file
34
migrations/012_wertpapierkosten.sql
Normal 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;
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
178
public/static/wertpapierbuchung.js
Normal file
178
public/static/wertpapierbuchung.js
Normal 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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
204
src/Services/Wertpapiergeschaeft.php
Normal file
204
src/Services/Wertpapiergeschaeft.php
Normal 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,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
147
src/Services/Wertpapierkosten.php
Normal file
147
src/Services/Wertpapierkosten.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; ?>
|
||||
|
||||
|
|
|
|||
71
src/Views/buchungen/wertpapier.php
Normal file
71
src/Views/buchungen/wertpapier.php
Normal 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'; ?>
|
||||
106
src/Views/buchungen/wertpapierfelder.php
Normal file
106
src/Views/buchungen/wertpapierfelder.php
Normal 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">
|
||||
83
src/Views/buchungen/wertpapierkosten.php
Normal file
83
src/Views/buchungen/wertpapierkosten.php
Normal 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>
|
||||
|
|
@ -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; ?>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
384
tests/25_WertpapiergeschaeftTest.php
Normal file
384
tests/25_WertpapiergeschaeftTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
188
tests/e2e/specs/wertpapiergeschaeft.spec.js
Normal file
188
tests/e2e/specs/wertpapiergeschaeft.spec.js
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
// @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}`
|
||||
|
||||
// ISIN und WKN tragen die Marke des Laufs: Ein Papier wird über seine Kennung
|
||||
// wiedergefunden, nicht über den Namen. Feste Kennungen würden den Test beim
|
||||
// zweiten Lauf an das Papier des ersten hängen.
|
||||
const ISIN = `LU1834${MARKE}`
|
||||
const WKN = MARKE
|
||||
|
||||
const ZWECK =
|
||||
`Depot 0502022353 Wertp.Abrechn. 03.08.2026 WKN ${WKN} Gesch.Art KV ` +
|
||||
`MUL-AMUN ST600 BANK ETF A ISIN ${ISIN} Ihr Wertpapie`
|
||||
|
||||
/** Ein Sparplan bringt keine Wertpapierkennung mit – das Papier trägt nur seinen Namen. */
|
||||
const ZWECK_SPARPLAN = 'Sparplan Rate 03.08.2026 Dauerauftrag Depot 0502022353'
|
||||
|
||||
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, zweck = ZWECK) {
|
||||
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(ISIN)
|
||||
await expect(page.locator('#wp_wkn')).toHaveValue(WKN)
|
||||
|
||||
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, ZWECK_SPARPLAN)
|
||||
|
||||
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}]`)
|
||||
|
||||
// Ohne Kennung im Verwendungszweck entsteht das Papier unter dem
|
||||
// Namen, der in der Maske steht.
|
||||
await page.goto('/wertpapiere/buchungen')
|
||||
await expect(page.locator('tr', { hasText: `Sparplan-Fonds ${MARKE}` }))
|
||||
.toContainText('1.000,00')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue