Merge pull request 'Kontoblatt-Zuordnung, CSV-Bestandsimport, Sammellöschen und Kontoauswahl beim QIF-Import' (#11) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/11
This commit is contained in:
fds2610 2026-08-12 14:52:10 +02:00
commit dd7025805c
32 changed files with 3520 additions and 224 deletions

View file

@ -1,5 +1,148 @@
# Änderungen
## QIF-Import: nur ausgewählte Konten übernehmen (12.08.2026)
Der QIF-Import kannte bisher nur den Zeitraum als Filter – ein Export ließ sich nur ganz
einlesen. Jetzt lassen sich einzelne Konten wählen, etwa um ein Konto nachzuziehen, ohne
die übrigen anzufassen.
**Kontoauswahl im Importeur** (`QifBuchungsImport`)
- Neue Option `konten`. Maßgeblich ist nicht allein der `!Account`-Abschnitt, sondern jedes
Konto, das der Eintrag berührt: das eigene, die Umbuchungsziele aus `L` und den
Splitzeilen und beim Wertpapiergeschäft das Verrechnungskonto. Eine Umbuchung steht nur
einmal in der Datei – sonst fehlte dem gewählten Konto die Gegenseite.
- Die Anfangssalden werden weiter über die ganze Datei gerechnet, aber nur für die
gewählten Konten geschrieben. Ein Teilimport überschreibt so keine fremden Salden.
- Die Fingerabdrücke der Buchungen entstehen vorab in einem eigenen Durchgang. Der
laufende Zähler für gleiche Fingerabdrücke hing sonst an der Auswahl, und dieselbe
Buchung wäre bei einem zweiten Lauf mit anderer Auswahl doppelt angelegt worden.
- `kontenDerDatei()` nennt die Konten eines Exports samt der Zahl der Einträge, die sie
berühren – auch die, die nur Umbuchungsziel sind und keine eigene Zeile führen.
**Dialog** (`Views/verwaltung/import.php`, `ImportController`)
- Nach dem Trockenlauf steht unter *Konten* die Ankreuzliste der Konten dieser Datei; vor
dem ersten Lesen gibt es nichts zu wählen. Von dort geht es weiter zum erneuten
Trockenlauf mit der Auswahl oder zum Übernehmen.
- Die Vorschau zählt mit, wie viele Buchungen zu keinem der gewählten Konten gehören.
- Was im Formular ankommt, wird gegen die Konten der Datei geprüft.
**Kommandozeile** (`scripts/qif-import.php`)
- `--konto=NAME` (mehrfach angebbar) grenzt den Import ein, `--konten` nennt die Konten der
Datei und beendet sich. Ein Name, den die Datei nicht führt, bricht ab – sonst liefe der
Import durch und legte nichts an.
## Umbuchungen: Gegenkonto sichtbar, Zuordnung im Kontoblatt (12.08.2026)
Eine Umbuchung war auf dem Gegenkonto nicht mehr zuzuordnen: Die Zeile trug die Marke
*Umbuchung*, die Kategoriespalte daneben blieb leer. Zu sehen etwa bei den Buchungen
zwischen DKB Girokonto und Paypal – die Daten waren richtig, die Anzeige verschwieg sie.
**Beide Sichten nennen das Gegenüber** (`Views/buchungen/kontoblatt.php`)
- In der Kategoriespalte steht jetzt auch in der Gegensicht das Konto in eckigen Klammern,
von dem die Umbuchung kommt. Die Abfrage lieferte das Konto längst mit; die Ansicht hat
es nur nicht gezeigt.
**Kategorie und Gegenkonto in einem Feld** (`Views/buchungen/zuordnungsliste.php`,
`BuchungenController`)
- Unter den Kategorien stehen die eigenen Konten in eckigen Klammern – wie im
FinanzManager, dessen Kategoriefeld `[Konto]` annimmt. Eines davon macht die Buchung zur
Umbuchung. Die Auswahl *Ausgabe / Einnahme* bestimmt seither nur noch die Richtung; das
eigene Konto fehlt in der Liste.
- Dieselbe Liste steht in den Splitzeilen. Eine Splitzeile mit Gegenkonto ließ sich bisher
nur im Import anlegen und ging beim Bearbeiten der Buchung stillschweigend verloren.
**Schnellzuordnung im Kontoblatt** (`public/static/kontoblatt.js`, `Buchung::zuordnen()`)
- Ein Klick auf die Kategoriespalte öffnet die Liste in der Zelle: leerer Eintrag,
*Splitbuchung*, Kategorien, Konten. Die Auswahl speichert sofort
(`POST /buchungen/{id}/zuordnung`) und kommt in dieselbe Ansicht zurück – Filter und
Seite hängen am Ziel.
- Die Liste gibt es einmal und wandert in die angeklickte Zelle. Eine Liste je Zeile wären
bei hundert Zeilen ebenso viele vollständige Kategoriebäume im HTML.
- Feld und Liste sind gleich breit und gleich hoch, die Zelle zeigt nur, was auch in der
Liste steht – sonst rückte beim Anklicken die halbe Zeile.
- Ohne JavaScript führt derselbe Klick in die Buchungsmaske: Die Zelle ist ein Link.
- Auch die Gegenseite einer fremden Umbuchung ist anklickbar; sie führt zu ihrer Buchung
auf dem anderen Konto, wo sie geändert wird.
**Splitdialog** (`Views/buchungen/kontoblatt.php`, `Buchung::splitsErsetzen()`)
- Der Eintrag *Splitbuchung* öffnet ein modales Fenster mit Zeilen für Kategorie oder
Gegenkonto, Klasse, Notiz und Betrag; der Restbetrag wird mitgerechnet, weitere Zeilen
kommen auf Knopfdruck. Eine bestehende Aufteilung steht schon drin.
- Ein Klick auf eine bereits gesplittete Buchung öffnet direkt den Dialog statt der
Auswahlliste: Für sie hatte die Liste nichts anzubieten außer dem Weg dorthin.
- Rechts unten im Dialog steht *Splitbuchung löschen*. Der Knopf schickt dasselbe
Formular mit `aktion=loeschen`, verwirft alle Zeilen samt ihren Zuordnungen – auch die
Gegenseite einer Splitzeile mit Gegenkonto – und die Buchung steht danach unzugeordnet
im Kontoblatt. Er erscheint nur dort, wo es etwas aufzuheben gibt, und steht abseits
von *Speichern* und *Abbrechen*.
- Bleiben alle Zeilen leer, wird die Splitbuchung ebenso aufgehoben.
- Beide Wege achten den Monatsabschluss und lassen den Rest der Buchung unberührt.
`public/static/betrag.js` bündelt das Lesen deutscher Beträge und die Restbetragsrechnung
für Maske und Kontoblatt. `tests/23_ZuordnungTest.php` prüft Zuordnung, Splitzeilen und
Gegensicht; `tests/e2e/specs/buchungen.spec.js` die drei Wege im Browser.
## Wertpapiere: Depotbuchungen sammelweise löschen (12.08.2026)
Die Liste der Depotbuchungen räumt jetzt so auf wie das Kontoblatt – nach einem Import,
der schiefging, war das Löschen Zeile für Zeile mühsam.
**Auswahl löschen** (`Views/wertpapiere/buchungen.php`, `WertpapiereController`,
`public/index.php`)
- Ankreuzfeld je Zeile, das Feld in der Titelzeile wählt alle angezeigten Buchungen. Der
Knopf steht oben in der Aktionsleiste, nennt die Anzahl und bleibt ohne Auswahl
gesperrt; die Rückfrage nennt sie ebenfalls. Ohne JavaScript funktioniert alles weiter:
Dann ist der Knopf offen und `POST /wertpapiere/buchungen/loeschen` weist eine leere
Auswahl ab.
- Mit jeder Depotbuchung geht ihre Geldbuchung – wie beim Löschen einer einzelnen.
- **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.
- Der eingestellte Filter (Depot, Wertpapier, Vorgang) hängt am Ziel des Formulars,
deshalb steht danach wieder dieselbe Ansicht da. Das gilt jetzt auch für das *Löschen*
je Zeile, das bisher zur ungefilterten Liste zurücksprang.
`tests/22_DepotbuchungenLoeschenTest.php` prüft Geldseite, Bestand und Auswertungen nach
dem Löschen; `tests/e2e/specs/module.spec.js` den Ablauf im Browser.
## Wertpapiere: Depotbestand aus einer CSV-Datei übernehmen (12.08.2026)
Ein Depot, das nie im FinanzManager geführt wurde, lässt sich jetzt in einem Zug
einlesen – aus der Bestandsliste, die die Bank zum Herunterladen anbietet.
**Neuer Knopf *Import*** (`Views/wertpapiere/index.php`, `Views/wertpapiere/import.php`)
- Steht auf der Bestandsseite neben *Depotbuchung erfassen*. Der Ablauf ist zweistufig:
hochladen, Vorschau prüfen, übernehmen. Die Vorschau zeigt je Position Stück, beide
Kurse, Einstand, Wert und ob sie neu ist oder schon einmal importiert wurde.
- Vor der Übernahme lassen sich Depot, Buchungsdatum und Verrechnungskonto noch ändern.
**Der Import** (`Services\DepotbestandImport`, `WertpapiereController`)
- Erwartet die Spalten Wertpapierbezeichnung, WKN, ISIN, Einstiegskurs, Bewertungskurs,
Stückzahl und Assetklasse; Trennzeichen und Zeichensatz erkennt er selbst. Beträge wie
`"1.471,88 €"` und Bruchstückzahlen wie `55,6863` werden gelesen, wie sie kommen.
- **Die Vergangenheit fehlt im Export.** Er beschreibt nur einen Stichtag. Je Position
entsteht deshalb *eine* Depotbuchung: ein Kauf zum Stichtag über die volle Stückzahl zum
Einstiegskurs. Der Einstand stimmt damit auf den Cent; frühere Käufe, Verkäufe und
Dividenden bleiben leer.
- **Der Bewertungskurs wird als Kurs des Stichtags hinterlegt.** Ohne ihn stünde der
frisch übernommene Bestand sofort wieder ohne Bewertung da. Am echten Export
nachgerechnet: 33.176,12 € Einstand, 47.120,25 € Wert – die Bank weist zehn Cent
weniger Gewinn aus, was an ihren auf zwei Stellen gerundeten Kursen liegt.
- **Kein Geldfluss.** Ein Verrechnungskonto ist wählbar, bleibt aber vorbelegt leer: Das
Geld ist vor Jahren geflossen, eine Belastung zum heutigen Tag würde das Girokonto
verfälschen.
- Ohne ausdrückliche Wahl findet der Import das Depot über die **Kontonummer**, die zur
Depotnummer der Datei passt. Ein `import_hash` je Position verhindert, dass dieselbe
Datei zweimal denselben Bestand anlegt.
- Assetklassen werden übersetzt: *Aktien* → Aktie, *ETFs* und *Fonds* → Fonds,
*Anleihen* → Anleihe, *Zertifikate* → Zertifikat.
**Neue Herkunft „CSV-Import“** (`Models\Buchung`)
- `Buchung::QUELLEN` kennt jetzt `csv`; Depot- und Geldbuchungen aus dem Bestandsimport
sind damit als solche erkennbar und fallen nicht mehr auf „manuell erfasst“ zurück.
`tests/21_DepotbestandImportTest.php` prüft das an einem Ausschnitt des echten Exports.
## Online-Banking: Umsätze, die niemand mehr zu sehen bekam (12.08.2026)
Drei Fehler, die zusammen dazu führten, dass abgerufene Umsätze spurlos verschwanden:

View file

@ -12,8 +12,9 @@ Haushalt wird beides parallel genutzt.
**Alle sieben Phasen aus `PLAN.md` sind umgesetzt.**
* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt,
Buchungsmaske mit Splitbuchungen und Umbuchungen; QIF-Import mit Stichtag.
* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt
mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen;
QIF-Import mit Stichtag.
* **Planung** – Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf,
Snapshot und Sperre.
* **Auswertungen** – Summen-, Buchungs-, Vergleichs-, Kontostands- und Budgetbericht mit
@ -26,7 +27,8 @@ Haushalt wird beides parallel genutzt.
abgerufene Umsätze buchen sich auf Wunsch selbst, Abgleich gegen vorhandene Buchungen,
Regeln für die automatische Kategorisierung.
* **Wertpapiere** – Depotbestände mit Einstand und Bewertung, Depotbuchungen, Kursabruf,
Veräußerungsgeschäfte und Erträge.
Veräußerungsgeschäfte und Erträge; Bestandslisten der Bank lassen sich als CSV
übernehmen.
* **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.
@ -97,7 +99,9 @@ docker compose exec -u www-data php php scripts/qif-import.php data/import/Expor
docker compose exec -u www-data php php scripts/qif-import.php --uebernehmen data/import/Export.QIF
```
Ohne `--uebernehmen` läuft nur der Trockenlauf.
Ohne `--uebernehmen` läuft nur der Trockenlauf. Mit `--konto=NAME` (mehrfach angebbar,
im Dialog als Ankreuzliste nach dem Trockenlauf) bleibt der Import auf einzelne Konten
beschränkt; `--konten` nennt die Konten der Datei.
## Dokumentation
@ -110,6 +114,6 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf.
- `docs/auswertungen.md` – Berichtsmodul, Filterleiste, Drilldown, CSV
- `docs/cockpit.md` – Startseite und ihre Kacheln
- `docs/banking.md` – FinTS-Abruf, TAN-Dialog, Abgleich und Regeln
- `docs/wertpapiere.md` – Depots, Einstand, Kurse und der Invst-Import
- `docs/wertpapiere.md` – Depots, Einstand, Kurse, Invst- und CSV-Bestandsimport
- `docs/nebenmodule.md` – Vorlagen, Termine, Verträge, Sparziele, Kredite, Zahlungsverkehr
- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs

View file

@ -21,6 +21,13 @@ Zeilen aus 2 und 3 tragen `gegensicht = true`. Sie sind nicht bearbeitbar – da
dem Ursprungskonto –, damit eine Umbuchung nicht aus zwei Richtungen widersprüchlich
geändert wird.
**Beide Sichten nennen das Gegenüber.** In der Kategoriespalte steht das jeweils andere
Konto in eckigen Klammern: auf dem Ausgangskonto das Ziel, auf dem Gegenkonto das
Ursprungskonto. In der Gegensicht liefert die Abfrage dafür `b.konto_id` als
`gegenkonto_id` – dieselbe Spalte, aus der anderen Richtung gelesen. Ohne diese Angabe
stünde auf dem Gegenkonto eine Zeile, die sich „Umbuchung“ nennt und verschweigt, woher
sie kommt.
Dieselbe Dreiteilung gilt für die Salden (`Buchung::saldenJeKonto()`), die alle Konten mit
sechs Abfragen auf einmal berechnet statt je Konto einzeln.
@ -43,6 +50,56 @@ Filter und Seite hängen an der Abfragezeichenfolge des Formularziels. Der Contr
den Weg zurück aus den bekannten Feldern neu zusammen, statt ein mitgeschicktes Ziel zu
übernehmen; so lässt sich kein fremder Weiterleitungsort einschleusen.
## Kategorie und Gegenkonto in einem Feld
Eine Buchung ist entweder kategorisiert **oder** eine Umbuchung – beides zugleich gibt es
nicht. Deshalb stehen Kategorien und Konten in derselben Auswahlliste; die Konten unten,
in eckigen Klammern, wie das Kategoriefeld im FinanzManager sie schreibt. Das eigene Konto
fehlt in der Liste.
Die Liste steht in `Views/buchungen/zuordnungsliste.php` und wird an vier Stellen
eingebunden: Buchungsmaske, deren Splitzeilen, Schnellzuordnung im Kontoblatt und
Splitdialog. Die Werte tragen ein Präfix, das `BuchungenController::zuordnungAus()` wieder
auseinandernimmt:
| Wert | Bedeutung |
|---|---|
| `` (leer) | keine Zuordnung |
| `kategorie:12` | Kategorie 12 |
| `konto:7` | Umbuchung mit Gegenkonto 7 |
| `split` | Splitdialog öffnen (nur im Kontoblatt) |
Die Auswahl *Ausgabe / Einnahme* bestimmt seither nur noch die Richtung des Betrags.
## Schnellzuordnung im Kontoblatt
Ein Klick auf die Kategoriespalte öffnet die Liste in der Zelle; die Auswahl geht an
`POST /buchungen/{id}/zuordnung`, der Eintrag *Splitbuchung* öffnet stattdessen den
Splitdialog (`POST /buchungen/{id}/splitbuchung`). Beide Wege gehen über
`Buchung::zuordnen()` und `Buchung::splitsErsetzen()`, die den Rest der Buchung unberührt
lassen und den Monatsabschluss achten.
Drei Punkte, die sich nicht ändern dürfen:
* Die Auswahlliste gibt es **einmal**; `kontoblatt.js` hängt sie in die angeklickte Zelle
und danach zurück. Eine Liste je Zeile wären bei hundert Zeilen ebenso viele
vollständige Kategoriebäume im HTML.
* Ohne JavaScript führt derselbe Klick in die Buchungsmaske – die Zelle ist ein Link, kein
Knopf.
* Zeilen mit `gegensicht = true` führen zu ihrer Buchung, statt die Auswahl zu öffnen: Sie
werden auf ihrem Ursprungskonto geändert, wie beim Bearbeiten und Löschen.
`kontoblatt.js` erkennt sie an der Klasse `gegenseite`.
* Feld und Auswahlliste sind **gleich breit und gleich hoch** (`13rem` / `1.35rem`), und
die Zelle zeigt nichts an, was nicht auch in der Liste stünde – kein „zuordnen …“. Sonst
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.
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
im Dialog und ordnet die dann unzugeordnete Buchung anschließend zu – oder geht über die
Buchungsmaske.
## Splitbuchungen
Eine Buchung ist entweder einfach zugeordnet **oder** gesplittet. Sobald Splitzeilen
@ -50,9 +107,17 @@ vorliegen, räumt das Model `kategorie_id` und `gegenkonto_id` der Buchung; die
steckt dann ausschließlich in den Zeilen.
Die Summe der Zeilen muss dem Buchungsbetrag entsprechen – geprüft im Model (Toleranz ein
halber Cent, für Rundungen aus der Betragsumrechnung) und in der Maske als
Restbetragsanzeige. Eine Splitzeile darf ein Gegenkonto tragen; dann erscheint auf jenem
Konto nur der Betrag dieser Zeile.
halber Cent, für Rundungen aus der Betragsumrechnung) und in beiden Masken als
Restbetragsanzeige. Beide rechnen mit `public/static/betrag.js`; die Regel für das
deutsche Zahlenformat steht damit einmal im Browser und einmal in PHP, nicht dreimal. Eine
Splitzeile darf ein Gegenkonto tragen; dann erscheint auf jenem Konto nur der Betrag dieser
Zeile. Auf das eigene Konto darf sie nicht zeigen – die Buchung zählte dort doppelt.
Der Knopf *Splitbuchung löschen* im Dialog schickt `POST /buchungen/{id}/splitbuchung` mit
`aktion=loeschen`; der Controller ruft dann `Buchung::splitsErsetzen($id, [])`, ganz gleich
was in den Feldern steht. Gelöscht werden die Zeilen samt Zuordnung, nicht die Buchung –
sie steht danach mit ihrem Betrag und ohne Kategorie im Kontoblatt, und die Gegenseite
einer Zeile mit Gegenkonto verschwindet von jenem Konto.
## Salden
@ -70,7 +135,7 @@ nicht; es wird im Kontoformular gepflegt.
## Beträge in der Oberfläche
Eingegeben wird immer ein **positiver** Betrag; die Richtung bestimmt die Auswahl
*Ausgabe / Einnahme / Umbuchung*. `KontenController::zahl()` liest das deutsche Format und
*Ausgabe / Einnahme*. `KontenController::zahl()` liest das deutsche Format und
entscheidet bei einem Punkt ohne Komma nach der Stellenzahl: drei Ziffern dahinter heißt
Tausendertrenner (`1.500`), sonst Dezimaltrenner (`1234.56`).

View file

@ -100,6 +100,33 @@ die der Import selbst anlegen würde, erscheinen in der Vorschau.
Verrechnungskonto zu Buche (`L|[Konto]` und `$`). Ein Betrag auf dem Depot ohne
Gegenwert würde die Summe der Kontostände verfälschen; Bestände folgen in Phase 6.
## Nur einzelne Konten übernehmen
Der Import lässt sich auf einzelne Konten begrenzen (Option `konten`, im Dialog eine
Ankreuzliste, auf der Kommandozeile `--konto=NAME`). Drei Dinge sind dabei entschieden:
1. **Maßgeblich ist jedes berührte Konto, nicht nur der `!Account`-Abschnitt.** Eine
Umbuchung steht nur einmal in der Datei, beim erfassenden Konto. Zählte allein dieser
Abschnitt, fehlte dem gewählten Konto die Gegenseite. Deshalb kommt ein Eintrag mit,
sobald sein Konto, ein Umbuchungsziel aus `L` oder `S` oder – beim Wertpapiergeschäft –
das Verrechnungskonto in der Auswahl steht. Umgekehrt heißt das: Wer nur das Zielkonto
wählt, bekommt die Buchung auch auf dem erfassenden Konto zu sehen; sie gehört dort hin
und lässt sich nicht halbieren.
2. **Anfangssalden werden weiterhin über die ganze Datei gerechnet, aber nur für die
gewählten Konten geschrieben.** Der Saldo eines Kontos steckt auch in Buchungen, die
anderswo erfasst wurden – ohne den vollen Durchlauf wäre er falsch. Und ein Teilimport
darf die Salden der übrigen Konten nicht überschreiben.
3. **Die Fingerabdrücke entstehen vorab in einem eigenen Durchgang.** Der laufende Zähler
für gleiche Fingerabdrücke (siehe unten) hinge sonst an der Auswahl: Dieselbe Buchung
bekäme im Teilimport einen anderen Hash als im Vollimport und käme ein zweites Mal in
die Datenbank. So lassen sich die Konten nacheinander einlesen, ohne dass die
Umbuchungen zwischen ihnen doppelt entstehen.
Die auswählbaren Konten liefert `QifBuchungsImport::kontenDerDatei()` – erst nach dem
Lesen steht fest, was die Datei führt. Enthalten sind auch Konten, die selbst keine
Buchung tragen und nur Umbuchungsziel sind; sonst ließe sich genau deren Gegenseite nicht
übernehmen.
Die Dublettenerkennung nutzt einen Fingerabdruck aus Konto, Datum, Betrag, Nummer und
Text. Weil es echte Doppelbuchungen am selben Tag gibt (zweimal dasselbe im selben Laden),
zählt ein laufender Index gleiche Fingerabdrücke durch – stabil über Läufe hinweg, weil
@ -111,6 +138,11 @@ Im Dialog unter *Verwaltung → QIF-Import*: Datei hochladen, Trockenlauf, dann
Hochgeladene Dateien bleiben im Importverzeichnis (`data/import`), damit derselbe Export
erst geprüft und dann übernommen werden kann.
Nach dem Trockenlauf steht unter *Konten* die Ankreuzliste der Konten, die in der Datei
vorkommen – vorher gibt es nichts zu wählen, weil die Datei noch nicht gelesen wurde. Von
dort führen zwei Wege weiter: ein erneuter Trockenlauf mit der Auswahl oder das Übernehmen.
Ohne Häkchen zählt die ganze Datei.
Auf der Kommandozeile:
```bash
@ -125,6 +157,16 @@ sonst gilt der erste aktive Benutzer.
den Einstellungen (Reiter *Import/Export*) und als Enddatum der heutige Tag.
`--nur-stammdaten` lässt die Buchungen aus.
```bash
docker compose exec -u www-data php php scripts/qif-import.php --konten data/import/Export.QIF
docker compose exec -u www-data php php scripts/qif-import.php \
--konto="DKB Girokonto" --konto="Tagesgeld" --uebernehmen data/import/Export.QIF
```
`--konten` nennt die Konten der Datei und beendet sich, ohne etwas anzufassen.
`--konto=` grenzt den Import ein und ist mehrfach angebbar; ein Name, den die Datei nicht
führt, bricht ab – sonst liefe der Import durch und legte nichts an.
## Prüfung gegen den echten Export
Der Vollexport (8780 Buchungen, 2017–2026) ergibt mit Stichtag 01.01.2026:

View file

@ -111,6 +111,62 @@ Wertpapiergeschäfts als Abgang – auch bei Verkäufen und Dividenden. Das QIF
`$` immer einen positiven Betrag; die Richtung steckt allein im Vorgang. Verrechnungskonten
von Depots wiesen dadurch zu niedrige Salden aus.
## CSV-Import einer Bestandsliste
*Wertpapiere → Bestände → Import* übernimmt die Depotbestandsliste, wie Banken sie zum
Herunterladen anbieten (erprobt mit der DKB). Erwartet wird eine Kopfzeile mit den Spalten
*Wertpapierbezeichnung*, *WKN*, *ISIN*, *Einstiegskurs*, *Bewertungskurs*, *Stückzahl* und
*Assetklasse*; *Datum der Erstellung* und *Depotnummer* werden mitgelesen, wenn vorhanden.
Trennzeichen (`;`, `,`, Tabulator) und Zeichensatz (UTF-8 mit oder ohne BOM,
Windows-1252) erkennt der Import selbst.
**Der Export kennt keine Historie.** Er beschreibt einen Stichtag: was im Depot liegt und
was es im Schnitt gekostet hat. Die Geschäfte, die dorthin geführt haben, fehlen. Der
Import legt deshalb je Position **eine** Depotbuchung an – einen Kauf zum Stichtag über
die volle Stückzahl zum Einstiegskurs. Der Einstand stimmt damit auf den Cent, frühere
Käufe, Verkäufe und Dividenden bleiben naturgemäß leer und lassen sich später von Hand
nachtragen.
**Zwei Kurse, zwei Rollen.** Der *Einstiegskurs* ist der durchschnittliche Kaufpreis und
wird zum Kurs der Buchung; der *Bewertungskurs* ist der aktuelle Kurs und landet als Kurs
des Stichtags in `kurse`. Erst dadurch ist der frisch übernommene Bestand sofort bewertbar
und zeigt denselben Gewinn wie die Bank – bis auf wenige Cent, die in den auf zwei Stellen
gerundeten Kursen des Exports stecken.
**Kein Geldfluss.** Ein Verrechnungskonto ist wählbar, bleibt aber in aller Regel leer:
Das Geld ist vor Jahren geflossen, eine Belastung zum heutigen Tag würde das Girokonto
verfälschen. Ohne Verrechnungskonto entsteht keine Geldbuchung.
**Assetklasse → Art**: *Aktien* → Aktie, *ETFs* und *Fonds* → Fonds, *Anleihen* →
Anleihe, *Zertifikate* → Zertifikat, alles Übrige → Sonstiges.
Das Zieldepot lässt sich wählen; ohne Auswahl sucht der Import das Depot, dessen
**Kontonummer** der Depotnummer aus der Datei entspricht. Jede Position bekommt einen
`import_hash` aus Depot, Datum, Vorgang, Stück, Betrag und ISIN – dieselbe Datei zweimal
eingelesen legt nichts doppelt an. Ein *anderer* Stichtag gilt dagegen als neuer Vorgang.
Der Ablauf ist zweistufig: Der Upload zeigt erst eine Vorschau mit allen Positionen,
Einstand, Wert und dem Status je Zeile; erst *Jetzt übernehmen* schreibt. Die Datei bleibt
im Importverzeichnis liegen.
## Depotbuchungen löschen
Die Liste der Depotbuchungen löscht einzeln – *Löschen* je Zeile – oder mehrere auf
einmal, genau wie das Kontoblatt: Ankreuzfeld je Zeile, das Feld in der Titelzeile wählt
alle angezeigten, *Auswahl löschen* in der Aktionsleiste schickt sie an
`POST /wertpapiere/buchungen/loeschen`. Der Knopf nennt die Anzahl und bleibt ohne
Auswahl gesperrt; die Rückfrage kommt vor dem Absenden. Ohne JavaScript funktioniert
alles weiter – dann ist der Knopf offen und der Controller weist eine leere Auswahl ab.
Der eingestellte Filter hängt am Ziel beider Formulare, deshalb steht danach wieder
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
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.
## Kontenübersicht
Depots erscheinen dort mit ihrem **bewerteten Bestand**, nicht mit einem Buchungssaldo. Der
@ -127,3 +183,14 @@ Vorzeichen; Verkauf mit anteiligem Einstand und realisiertem Gewinn; Dividende o
Bestandsänderung; fehlende Kurse; Veräußerungsrechnung über die Historie hinweg; der
QIF-Import eines echten Ausschnitts (Kauf, Dividende, Verkauf) inklusive Idempotenz;
Löschen samt Geldbuchung.
`tests/22_DepotbuchungenLoeschenTest.php` – das Sammellöschen: die Geldbuchungen gehen
mit, das Verrechnungskonto stimmt danach wieder, Bestand, Einstand, realisierter Gewinn,
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/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
Lauf, Zuordnung des Depots über die Depotnummer, Geldseite mit und ohne
Verrechnungskonto, sowie die Abwehr einer fremden oder leeren Datei.

View file

@ -85,6 +85,11 @@ $router->post('/konten/{id}/buchungen/loeschen',
$router->get('/buchungen/{id}', fn(array $p) => (new BuchungenController())->bearbeiten($p));
$router->post('/buchungen/{id}', fn(array $p) => (new BuchungenController())->speichern($p));
$router->post('/buchungen/{id}/loeschen', fn(array $p) => (new BuchungenController())->loeschen($p));
// Schnellzuordnung und Splitdialog aus dem Kontoblatt.
$router->post('/buchungen/{id}/zuordnung',
fn(array $p) => (new BuchungenController())->zuordnung($p));
$router->post('/buchungen/{id}/splitbuchung',
fn(array $p) => (new BuchungenController())->splitbuchung($p));
// Verwaltung: Kategorien, Klassen, Kontengruppen, QIF-Import.
$router->get('/verwaltung', fn() => (new VerwaltungController())->index());
@ -240,6 +245,8 @@ $router->get('/wertpapiere/auswertung', fn() => (new WertpapiereController())->a
$router->get('/wertpapiere/buchungen', fn() => (new WertpapiereController())->buchungen());
$router->get('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->neu());
$router->post('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->anlegen());
$router->post('/wertpapiere/buchungen/loeschen',
fn() => (new WertpapiereController())->auswahlLoeschen());
$router->post('/wertpapiere/buchungen/{id}/loeschen',
fn(array $p) => (new WertpapiereController())->loeschen($p));
@ -250,6 +257,9 @@ $router->post('/wertpapiere/stammdaten/{id}/kurs',
fn(array $p) => (new WertpapiereController())->kursSetzen($p));
$router->post('/wertpapiere/kurse', fn() => (new WertpapiereController())->kurseAbrufen());
$router->get('/wertpapiere/import', fn() => (new WertpapiereController())->importFormular());
$router->post('/wertpapiere/import', fn() => (new WertpapiereController())->importAusfuehren());
// ── Online-Banking: Zugänge, Abruf, Abgleich, Regeln ────────────────────────
$router->get('/banking', fn() => (new BankingController())->index());
$router->get('/banking/neu', fn() => (new BankingController())->neu());

View file

@ -999,6 +999,47 @@ table.kontoblatt tr.gegensicht td { background: color-mix(in srgb, var(--akzent)
margin-top: .8rem;
}
/* Schnellzuordnung: Die Kategoriespalte ist anklickbar wie ein Eingabefeld,
sieht aber nicht danach aus – sonst flimmerte die ganze Spalte. Der
gestrichelte Unterstrich erscheint erst beim Überfahren.
Feld und Auswahlliste sind gleich breit und gleich hoch. Sonst rückte beim
Anklicken die halbe Zeile: Die Spalte wüchse auf die Breite der längsten
Kategorie, und der Empfänger daneben bräche um. */
td.zuordnungszelle { width: 13rem; }
a.zuordnungsfeld {
display: block;
max-width: 13rem;
min-height: 1.35rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: inherit;
text-decoration: none;
border-bottom: 1px dashed transparent;
}
a.zuordnungsfeld:hover,
a.zuordnungsfeld:focus-visible { border-bottom-color: var(--rand-kraeftig); }
.zuordnungsauswahl { display: flex; gap: .3rem; }
.zuordnungsauswahl[hidden] { display: none; }
.zuordnungsauswahl select {
width: 100%;
max-width: 13rem;
height: 1.35rem;
padding: 0 .2rem;
border: 1px solid var(--akzent);
border-radius: 4px;
background: var(--flaeche);
color: var(--text);
font: inherit;
font-family: var(--schrift);
font-size: .85rem;
}
/* Die Splittabelle sitzt im Formular und braucht schmalere Zellen. */
table.splittabelle td { padding: .3rem .4rem; }
table.splittabelle select { max-width: 14rem; width: 100%; }
@ -1006,6 +1047,28 @@ table.splittabelle input.rechts { text-align: right; }
.buchungsmaske [hidden] { display: none; }
/* Der Splitdialog des Kontoblatts. Als <dialog> und nicht als eigene Seite:
Die Aufteilung gehört zur Zeile, aus der sie geöffnet wurde. */
.splitdialog {
width: min(46rem, 92vw);
padding: 1.1rem 1.25rem 1.25rem;
border: 1px solid var(--rand-kraeftig);
border-radius: var(--radius);
background: var(--flaeche);
color: var(--text);
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
}
.splitdialog::backdrop { background: rgb(0 0 0 / 35%); }
.splitdialog h2 { margin: 0 0 .3rem; font-size: 1.05rem; }
.splitdialog .knopfleiste { margin-top: .9rem; }
.splitdialog .splittabelle { margin-top: .6rem; }
/* Das Löschen der ganzen Aufteilung steht rechts außen – weit weg von
„Speichern“ und „Abbrechen“, damit es nicht im Vorbeigehen getroffen wird. */
.splitdialog .knopfleiste .knopf.gefahr { margin-left: auto; }
/* ── Formulare ───────────────────────────────────────────────────────────── */
.formular {

58
public/static/betrag.js Normal file
View file

@ -0,0 +1,58 @@
/* Beträge im deutschen Format lesen und schreiben.
*
* Dieselbe Regel wie KontenController::zahl(): ohne Komma entscheidet die
* Stellenzahl, ob der Punkt Tausender- oder Dezimaltrenner ist. Die Maske und
* das Kontoblatt rechnen beide Restbeträge mit – deshalb steht das hier und
* nicht zweimal.
*/
(function () {
'use strict';
function zahl(text) {
const bereinigt = String(text).replace(/[\s€]/g, '');
if (bereinigt === '') {
return 0;
}
if (bereinigt.indexOf(',') !== -1) {
return parseFloat(bereinigt.replace(/\./g, '').replace(',', '.')) || 0;
}
const punkte = (bereinigt.match(/\./g) || []).length;
const letzter = bereinigt.split('.').pop();
if (punkte > 1 || (punkte === 1 && letzter.length === 3)) {
return parseFloat(bereinigt.replace(/\./g, '')) || 0;
}
return parseFloat(bereinigt) || 0;
}
function formatiere(wert) {
return wert.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
/* Die Summe der Splitzeilen unter `felder` gegen `ziel` – das Ergebnis geht
* so in die Anzeige, wie beide Masken es brauchen. */
function splitsumme(felder, ziel) {
let summe = 0;
let belegt = false;
felder.forEach(function (feld) {
if (feld.value.trim() === '') {
return;
}
belegt = true;
summe += zahl(feld.value);
});
const rest = Math.round((ziel - summe) * 100) / 100;
return {
belegt: belegt,
summe: summe,
rest: rest,
text: belegt ? formatiere(summe) + (rest === 0 ? '' : ' (Rest ' + formatiere(rest) + ')') : '0,00'
};
}
window.FmBetrag = { zahl: zahl, formatiere: formatiere, splitsumme: splitsumme };
})();

View file

@ -1,9 +1,10 @@
/* Buchungsmaske: Felder je nach Art ein- und ausblenden, Restbetrag der
* Splitzeilen mitrechnen.
/* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen.
*
* Nur Komfort – die Maske kommt fertig vom Server. Ohne JavaScript bleibt alles
* bedienbar: dann sind sämtliche Felder sichtbar, und die Splitsumme prüft
* ohnehin das Model.
* bedienbar: Die Splitsumme prüft ohnehin das Model.
*
* Kategorie und Gegenkonto stehen in einem Feld, deshalb gibt es hier nichts
* mehr ein- und auszublenden; die Art bestimmt nur noch die Richtung.
*/
(function () {
@ -11,90 +12,35 @@
const art = document.getElementById('art');
const maske = document.querySelector('.buchungsmaske');
if (!art || !maske) {
if (!art || !maske || !window.FmBetrag) {
return;
}
const kategorie = maske.querySelector('.nur-kategorie');
const umbuchung = maske.querySelector('.nur-umbuchung');
const gegenkonto = document.getElementById('gegenkonto_id');
const summenfeld = document.getElementById('splitsumme');
const betragsfeld = document.getElementById('betrag');
function zeigePassendeFelder() {
const istUmbuchung = art.value.indexOf('umbuchung') === 0;
if (kategorie) {
kategorie.hidden = istUmbuchung;
}
if (umbuchung) {
umbuchung.hidden = !istUmbuchung;
}
if (gegenkonto) {
gegenkonto.required = istUmbuchung;
}
}
/* Beträge stehen im deutschen Format im Feld. Dieselbe Regel wie
* KontenController::zahl(): ohne Komma entscheidet die Stellenzahl, ob der
* Punkt Tausender- oder Dezimaltrenner ist. */
function zahl(text) {
const bereinigt = String(text).replace(/[\s€]/g, '');
if (bereinigt === '') {
return 0;
}
if (bereinigt.indexOf(',') !== -1) {
return parseFloat(bereinigt.replace(/\./g, '').replace(',', '.')) || 0;
}
const punkte = (bereinigt.match(/\./g) || []).length;
const letzter = bereinigt.split('.').pop();
if (punkte > 1 || (punkte === 1 && letzter.length === 3)) {
return parseFloat(bereinigt.replace(/\./g, '')) || 0;
}
return parseFloat(bereinigt) || 0;
}
function formatiere(wert) {
return wert.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function rechneSumme() {
if (!summenfeld) {
return;
}
let summe = 0;
let belegt = false;
maske.querySelectorAll('.splitbetrag').forEach(function (feld) {
if (feld.value.trim() === '') {
return;
}
belegt = true;
summe += zahl(feld.value);
});
if (!belegt) {
summenfeld.textContent = '0,00';
summenfeld.classList.remove('negativ');
return;
}
// Der Buchungsbetrag wird positiv eingegeben; das Vorzeichen ergibt
// sich aus der Art. Für den Vergleich zählt der Betrag mit Richtung.
let ziel = zahl(betragsfeld ? betragsfeld.value : '0');
if (art.value === 'ausgabe' || art.value === 'umbuchung_ab') {
let ziel = window.FmBetrag.zahl(betragsfeld ? betragsfeld.value : '0');
if (art.value !== 'einnahme') {
ziel = -ziel;
}
const rest = Math.round((ziel - summe) * 100) / 100;
summenfeld.textContent = formatiere(summe) + (rest === 0 ? '' : ' (Rest ' + formatiere(rest) + ')');
summenfeld.classList.toggle('negativ', rest !== 0);
const stand = window.FmBetrag.splitsumme(
Array.from(maske.querySelectorAll('.splitbetrag')),
ziel
);
summenfeld.textContent = stand.text;
summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0);
}
art.addEventListener('change', function () {
zeigePassendeFelder();
rechneSumme();
});
art.addEventListener('change', rechneSumme);
maske.addEventListener('input', function (ereignis) {
if (ereignis.target.classList.contains('splitbetrag') || ereignis.target.id === 'betrag') {
@ -102,6 +48,5 @@
}
});
zeigePassendeFelder();
rechneSumme();
})();

218
public/static/kontoblatt.js Normal file
View file

@ -0,0 +1,218 @@
/* Kontoblatt: Kategorie oder Gegenkonto an Ort und Stelle zuordnen.
*
* Ein Klick auf die Kategoriespalte öffnet die Auswahlliste in der Zelle. Sie
* gibt es genau einmal und wandert in die angeklickte Zelle – wie das
* Zahnradmenü im Budgetplaner. Eine Liste je Zeile wären bei hundert Zeilen
* ebenso viele vollständige Kategoriebäume im HTML.
*
* Ohne JavaScript führt derselbe Klick in die Buchungsmaske; die Zuordnung ist
* dort wie bisher zu ändern.
*/
(function () {
'use strict';
const tabelle = document.querySelector('table.kontoblatt');
const auswahl = document.getElementById('zuordnungsauswahl');
if (!tabelle || !auswahl) {
return;
}
const abfrage = tabelle.dataset.abfrage || '';
const liste = auswahl.querySelector('select');
const dialog = document.getElementById('splitdialog');
const splitformular = document.getElementById('splitformular');
// Das Feld, das gerade durch die Auswahlliste ersetzt ist.
let offenesFeld = null;
function ziel(feld, aktion) {
return '/buchungen/' + feld.dataset.buchung + '/' + aktion + abfrage;
}
function schliessen() {
if (!offenesFeld) {
return;
}
offenesFeld.hidden = false;
offenesFeld = null;
auswahl.hidden = true;
document.body.appendChild(auswahl);
}
/* Der Splitdialog zu einer Zeile – oder, wo es kein <dialog> gibt, die
* Buchungsmaske. Die Weiche steht hier oben, weil beide Wege hierher
* führen: der Klick auf eine Splitbuchung und die Wahl „Splitbuchung“. */
function splitOeffnen(feld) {
if (dialog && splitformular && window.FmBetrag) {
splitdialogOeffnen(feld);
} else {
window.location.href = '/buchungen/' + feld.dataset.buchung;
}
}
function oeffnen(feld) {
schliessen();
offenesFeld = feld;
feld.hidden = true;
auswahl.action = ziel(feld, 'zuordnung');
liste.value = feld.dataset.wert || '';
feld.parentNode.appendChild(auswahl);
auswahl.hidden = false;
liste.focus();
// Die Liste soll sofort aufklappen; showPicker gibt es nicht überall.
try {
liste.showPicker();
} catch (fehler) {
/* Dann klappt sie beim ersten Klick auf – kein Verlust. */
}
}
tabelle.addEventListener('click', function (ereignis) {
// Die Gegenseite einer fremden Umbuchung führt zu ihrer Buchung, statt
// hier die Auswahl zu öffnen: Geändert wird sie auf ihrem Konto.
const feld = ereignis.target.closest('a.zuordnungsfeld:not(.gegenseite)');
if (!feld || ereignis.metaKey || ereignis.ctrlKey || ereignis.shiftKey) {
return;
}
ereignis.preventDefault();
// Eine schon aufgeteilte Buchung führt geradewegs in den Splitdialog:
// Die Auswahlliste hätte für sie nichts anzubieten außer dem Weg dorthin.
if (feld.dataset.wert === 'split') {
splitOeffnen(feld);
return;
}
oeffnen(feld);
});
liste.addEventListener('change', function () {
if (!offenesFeld) {
return;
}
const feld = offenesFeld;
if (liste.value === 'split') {
schliessen();
splitOeffnen(feld);
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();
});
// Escape und der Klick daneben verwerfen die Auswahl.
document.addEventListener('keydown', function (ereignis) {
if (ereignis.key === 'Escape' && offenesFeld) {
schliessen();
}
});
document.addEventListener('click', function (ereignis) {
if (!(ereignis.target instanceof Element) || !offenesFeld) {
return;
}
if (!ereignis.target.closest('.zuordnungszelle')) {
schliessen();
}
});
/* ── Splitdialog ─────────────────────────────────────────────────────── */
if (!dialog || !splitformular || !window.FmBetrag) {
return;
}
const zeilenkasten = document.getElementById('splitdialogzeilen');
const summenfeld = document.getElementById('splitdialogsumme');
const loeschknopf = document.getElementById('splitloeschen');
const vorlage = zeilenkasten.innerHTML;
let buchungsbetrag = 0;
function felder() {
return Array.from(zeilenkasten.querySelectorAll('.splitbetrag'));
}
function rechneSumme() {
const stand = window.FmBetrag.splitsumme(felder(), buchungsbetrag);
summenfeld.textContent = stand.text;
summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0);
}
/* Eine weitere Zeile: die letzte kopieren, leeren und neu durchnummerieren.
* Die Namen tragen den Index (`split[3][betrag]`) – ohne Umnummerierung
* überschriebe die Kopie ihre Vorlage. */
function zeileAnhaengen() {
const letzte = zeilenkasten.lastElementChild;
const kopie = letzte.cloneNode(true);
const nr = zeilenkasten.children.length;
kopie.querySelectorAll('[name]').forEach(function (feld) {
feld.name = feld.name.replace(/^split\[\d+\]/, 'split[' + nr + ']');
if (feld.tagName === 'SELECT') {
feld.selectedIndex = 0;
} else {
feld.value = '';
}
});
zeilenkasten.appendChild(kopie);
return kopie;
}
function splitdialogOeffnen(feld) {
splitformular.action = ziel(feld, 'splitbuchung');
buchungsbetrag = Number(feld.dataset.betrag || '0');
document.getElementById('splitbezeichnung').textContent = feld.dataset.bezeichnung || '';
document.getElementById('splitbuchungsbetrag').textContent =
window.FmBetrag.formatiere(buchungsbetrag);
// Immer mit leeren Zeilen beginnen: Der Dialog gilt für alle Buchungen
// der Seite, die Zeilen der vorigen dürfen nicht stehen bleiben.
zeilenkasten.innerHTML = vorlage;
const vorhandene = JSON.parse(feld.dataset.splits || '[]');
// Zu löschen gibt es nur, was schon aufgeteilt ist. Beim erstmaligen
// Aufteilen wäre der Knopf ohne Wirkung – und ohne Sinn.
loeschknopf.hidden = vorhandene.length === 0;
vorhandene.forEach(function (zeile, nr) {
const tr = zeilenkasten.children[nr] || zeileAnhaengen();
tr.querySelector('select[name$="[zuordnung]"]').value = zeile.zuordnung || '';
tr.querySelector('select[name$="[klasse_id]"]').value = zeile.klasse_id
? String(zeile.klasse_id)
: '';
tr.querySelector('input[name$="[notiz]"]').value = zeile.notiz || '';
tr.querySelector('input[name$="[betrag]"]').value = zeile.betrag || '';
});
rechneSumme();
dialog.showModal();
}
zeilenkasten.addEventListener('input', function (ereignis) {
if (ereignis.target.classList.contains('splitbetrag')) {
rechneSumme();
}
});
document.getElementById('splitzeileMehr').addEventListener('click', function () {
zeileAnhaengen().querySelector('select').focus();
});
document.getElementById('splitabbrechen').addEventListener('click', function () {
dialog.close();
});
})();

View file

@ -21,6 +21,9 @@ declare(strict_types=1);
* Einstellungen, sonst 2026-01-01)
* --bis=JJJJ-MM-TT Enddatum; spätere Buchungen sind Vorausberechnungen
* des FinanzManagers (Vorgabe: heute)
* --konto=NAME nur dieses Konto übernehmen; mehrfach angebbar. Ohne
* Angabe zählen alle Konten der Datei.
* --konten nennt die Konten der Datei und beendet sich
* --benutzer=NAME Mandant, sonst der erste aktive Benutzer
*/
@ -39,16 +42,22 @@ Config::load();
$argumente = array_slice($argv, 1);
$uebernehmen = false;
$nurStammdaten = false;
$nurKonten = false;
$benutzer = null;
$datei = null;
$stichtag = null;
$bis = null;
$konten = [];
foreach ($argumente as $argument) {
if ($argument === '--uebernehmen') {
$uebernehmen = true;
} elseif ($argument === '--nur-stammdaten') {
$nurStammdaten = true;
} elseif ($argument === '--konten') {
$nurKonten = true;
} elseif (str_starts_with($argument, '--konto=')) {
$konten[] = substr($argument, 8);
} elseif (str_starts_with($argument, '--benutzer=')) {
$benutzer = substr($argument, 11);
} elseif (str_starts_with($argument, '--stichtag=')) {
@ -64,8 +73,8 @@ foreach ($argumente as $argument) {
}
if ($datei === null) {
fwrite(STDERR, "Aufruf: qif-import.php [--uebernehmen] [--nur-stammdaten] "
. "[--stichtag=JJJJ-MM-TT] [--bis=JJJJ-MM-TT] [--benutzer=NAME] <datei.qif>\n");
fwrite(STDERR, "Aufruf: qif-import.php [--uebernehmen] [--nur-stammdaten] [--konten] "
. "[--konto=NAME] [--stichtag=JJJJ-MM-TT] [--bis=JJJJ-MM-TT] [--benutzer=NAME] <datei.qif>\n");
exit(1);
}
@ -91,14 +100,46 @@ if ($benutzer !== null) {
$stichtag ??= (string)Einstellung::get('import_stichtag', '2026-01-01');
$bis ??= date('Y-m-d');
// Die Konten der Datei nennen, ohne etwas anzufassen – so lässt sich
// nachsehen, welche Namen --konto= überhaupt kennt.
if ($nurKonten) {
try {
$parser = QifParser::ausDatei($datei);
} catch (\Throwable $e) {
fwrite(STDERR, 'Datei nicht lesbar: ' . $e->getMessage() . "\n");
exit(1);
}
echo 'Konten in ' . basename($datei) . ":\n";
foreach (QifBuchungsImport::kontenDerDatei($parser) as $name => $anzahl) {
printf(" %-40s %6d Einträge\n", $name, $anzahl);
}
exit(0);
}
try {
$parser = QifParser::ausDatei($datei);
$stamm = (new QifStammdatenImport($parser))->ausfuehren(!$uebernehmen);
// Ein Tippfehler im Kontonamen bliebe sonst unbemerkt: Der Import liefe
// durch und legte nichts an.
$bekannt = array_keys(QifBuchungsImport::kontenDerDatei($parser));
foreach ($konten as $name) {
if (!in_array($name, $bekannt, true)) {
fwrite(STDERR, "Konto nicht in der Datei: {$name}\n");
fwrite(STDERR, "Vorhanden: " . implode(', ', $bekannt) . "\n");
exit(1);
}
}
$stamm = (new QifStammdatenImport($parser))->ausfuehren(!$uebernehmen);
$buch = null;
if (!$nurStammdaten) {
$buch = (new QifBuchungsImport($parser, ['stichtag' => $stichtag, 'bis' => $bis]))
->ausfuehren(!$uebernehmen);
$buch = (new QifBuchungsImport($parser, [
'stichtag' => $stichtag,
'bis' => $bis,
'konten' => $konten,
]))->ausfuehren(!$uebernehmen);
}
} catch (\Throwable $e) {
fwrite(STDERR, 'Import fehlgeschlagen: ' . $e->getMessage() . "\n");
@ -131,6 +172,10 @@ if ($buch !== null) {
$buch['buchungen']['neu'], $buch['buchungen']['vorhanden'], $buch['buchungen']['fehler']);
echo "\nZeitraum ab {$stichtag} bis {$bis}\n";
if ($buch['konten'] !== null) {
echo ' nur die Konten: ' . implode(', ', $buch['konten']) . "\n";
printf(" wegen der Kontoauswahl übergangen: %4d Buchungen\n", $buch['buchungen']['nicht_gewaehlt']);
}
printf(" vor dem Stichtag verrechnet: %6d Buchungen auf %d Konten (Summe %s)\n",
$buch['buchungen']['vor_stichtag'],
$buch['anfangssalden']['konten'],

View file

@ -50,6 +50,28 @@ abstract class BaseController
return array_values(array_map('intval', array_filter($werte, 'is_scalar')));
}
/**
* Mehrfachauswahl mit Textwerten (Ankreuzfelder wie „konten[]“).
*
* @return list<string>
*/
protected function postTexte(string $feld): array
{
$werte = $_POST[$feld] ?? [];
if (!is_array($werte)) {
return [];
}
$texte = [];
foreach ($werte as $wert) {
if (is_string($wert) && trim($wert) !== '') {
$texte[] = trim($wert);
}
}
return $texte;
}
protected function query(string $feld, string $standard = ''): string
{
$wert = $_GET[$feld] ?? $standard;

View file

@ -50,6 +50,16 @@ class BuchungenController extends BaseController
$seite = max(1, (int)$this->query('seite', '1'));
$zeilen = array_slice($zeilen, ($seite - 1) * $grenze, $grenze);
// Die Splitzeilen der angezeigten Buchungen in einer Abfrage – der
// Splitdialog zeigt damit eine bestehende Aufteilung, ohne sie
// nachzuladen.
$gesplittete = [];
foreach ($zeilen as $zeile) {
if ((int)$zeile['gesplittet'] === 1 && !$zeile['gegensicht']) {
$gesplittete[] = (int)$zeile['id'];
}
}
$this->render('buchungen/kontoblatt.php', [
'titel' => 'Kontoblatt: ' . $konto['name'],
'konto' => Konto::mitSalden($konto),
@ -60,8 +70,8 @@ class BuchungenController extends BaseController
'filter' => $filter,
'kategorien' => Kategorie::baumFlach(),
'klassen' => Klasse::alle(),
// Namen vorab, damit die Tabelle nicht je Zeile nachfragt.
'kontenNamen' => array_column(Konto::alle(), 'name', 'id'),
'konten' => Konto::alle(),
'splits' => Buchung::splitsJeBuchung($gesplittete),
]);
}
@ -153,6 +163,72 @@ class BuchungenController extends BaseController
View::redirect('/konten/' . $buchung['konto_id'], 'Buchung gelöscht.');
}
/**
* Schnellzuordnung aus dem Kontoblatt: Kategorie oder Gegenkonto einer
* Buchung setzen, ohne die Maske zu öffnen.
*
* @param array<string,string> $params
*/
public function zuordnung(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 {
$zuordnung = self::zuordnungAus($this->post('zuordnung'));
Buchung::zuordnen(
(int)$buchung['id'],
$zuordnung['kategorie_id'],
$zuordnung['gegenkonto_id']
);
} catch (\Throwable $e) {
View::redirect($ziel, $e->getMessage(), 'fehler');
return;
}
View::redirect($ziel, 'Zuordnung gespeichert.');
}
/**
* Der Splitdialog des Kontoblatts: die Zeilen einer Buchung ersetzen.
* Ohne Zeilen wird die Splitbuchung aufgehoben.
*
* Der Knopf „Splitbuchung löschen“ schickt dasselbe Formular mit
* `aktion=loeschen`. Seine Zeilen zählen dann nicht: Gelöscht wird die
* Aufteilung, und die Buchung steht danach ohne Zuordnung da.
*
* @param array<string,string> $params
*/
public function splitbuchung(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']);
$loeschen = $this->post('aktion') === 'loeschen';
try {
Buchung::splitsErsetzen((int)$buchung['id'], $loeschen ? [] : $this->splitzeilen());
} catch (\Throwable $e) {
View::redirect($ziel, $e->getMessage(), 'fehler');
return;
}
View::redirect($ziel, $loeschen ? 'Splitbuchung gelöscht.' : 'Splitbuchung gespeichert.');
}
/**
* Mehrere im Kontoblatt angekreuzte Buchungen auf einmal löschen.
*
@ -245,19 +321,17 @@ class BuchungenController extends BaseController
*/
private function formulardaten(int $kontoId): array
{
$art = $this->post('art', 'ausgabe');
$betrag = KontenController::zahl($this->post('betrag', '0'));
// In der Maske wird ein positiver Betrag eingegeben; die Richtung
// bestimmt die Auswahl Einnahme / Ausgabe / Umbuchung.
$betrag = abs($betrag);
if ($art === 'ausgabe' || $art === 'umbuchung_ab') {
// bestimmt die Auswahl Einnahme / Ausgabe.
$betrag = abs(KontenController::zahl($this->post('betrag', '0')));
if ($this->post('art', 'ausgabe') !== 'einnahme') {
$betrag = -$betrag;
}
$gegenkonto = in_array($art, ['umbuchung_ab', 'umbuchung_zu'], true)
? $this->post('gegenkonto_id')
: null;
// Kategorie und Gegenkonto stehen in einem Feld: Ein Konto in der Liste
// macht die Buchung zur Umbuchung – wie das Kategoriefeld im
// FinanzManager, das `[Konto]` annimmt.
$zuordnung = self::zuordnungAus($this->post('zuordnung'));
$daten = [
'konto_id' => $kontoId,
@ -268,33 +342,71 @@ class BuchungenController extends BaseController
'buchungstext' => $this->post('buchungstext'),
'nummer' => $this->post('nummer'),
'abgeglichen' => isset($_POST['abgeglichen']),
'kategorie_id' => $gegenkonto === null ? $this->post('kategorie_id') : null,
'kategorie_id' => $zuordnung['kategorie_id'],
'klasse_id' => $this->post('klasse_id'),
'gegenkonto_id' => $gegenkonto,
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'quelle' => 'manuell',
];
$splits = [];
$zeilen = $_POST['split'] ?? [];
if (is_array($zeilen)) {
foreach ($zeilen as $zeile) {
if (!is_array($zeile)) {
continue;
}
$wert = trim((string)($zeile['betrag'] ?? ''));
if ($wert === '') {
continue;
}
$splits[] = [
'kategorie_id' => $zeile['kategorie_id'] ?? null,
'klasse_id' => $zeile['klasse_id'] ?? null,
'betrag' => KontenController::zahl($wert),
'notiz' => (string)($zeile['notiz'] ?? ''),
];
}
return [$daten, $this->splitzeilen()];
}
/**
* Die ausgefüllten Splitzeilen eines Formulars. Zeilen ohne Betrag zählen
* nicht – so bleiben die leeren Zeilen der Maske folgenlos.
*
* @return list<array<string,mixed>>
*/
private function splitzeilen(): array
{
$zeilen = $_POST['split'] ?? [];
if (!is_array($zeilen)) {
return [];
}
return [$daten, $splits];
$splits = [];
foreach ($zeilen as $zeile) {
if (!is_array($zeile)) {
continue;
}
$wert = trim((string)($zeile['betrag'] ?? ''));
if ($wert === '') {
continue;
}
$zuordnung = self::zuordnungAus((string)($zeile['zuordnung'] ?? ''));
$splits[] = [
'kategorie_id' => $zuordnung['kategorie_id'],
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'klasse_id' => $zeile['klasse_id'] ?? null,
'betrag' => KontenController::zahl($wert),
'notiz' => (string)($zeile['notiz'] ?? ''),
];
}
return $splits;
}
/**
* Ein Auswahlwert aus der gemeinsamen Liste „Kategorie oder Gegenkonto“:
* `kategorie:12`, `konto:7` oder leer.
*
* @return array{kategorie_id: ?int, gegenkonto_id: ?int}
*/
public static function zuordnungAus(string $wert): array
{
$leer = ['kategorie_id' => null, 'gegenkonto_id' => null];
[$art, $id] = array_pad(explode(':', $wert, 2), 2, '');
if ((int)$id <= 0) {
return $leer;
}
return match ($art) {
'kategorie' => ['kategorie_id' => (int)$id, 'gegenkonto_id' => null],
'konto' => ['kategorie_id' => null, 'gegenkonto_id' => (int)$id],
default => $leer,
};
}
private function nichtGefunden(string $pfad): void

View file

@ -36,6 +36,8 @@ class ImportController extends BaseController
'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'),
'bis' => date('Y-m-d'),
'datei' => '',
'dateikonten' => [],
'auswahl' => [],
]);
}
@ -89,14 +91,23 @@ class ImportController extends BaseController
$parser = QifParser::ausDatei($pfad);
$ergebnis = (new QifStammdatenImport($parser))->ausfuehren($trockenlauf);
// Erst nach dem Lesen steht fest, welche Konten die Datei führt –
// nur die stehen zur Wahl. Was im Formular sonst noch ankäme, fiele
// hier heraus.
$dateikonten = QifBuchungsImport::kontenDerDatei($parser);
$auswahl = array_values(array_intersect($this->postTexte('konten'), array_keys($dateikonten)));
// Buchungen brauchen die Stammdaten – im Trockenlauf sind die noch
// nicht angelegt, deshalb bleibt es dort bei der Stammdatenvorschau,
// sobald die Konten fehlen.
$buchungen = null;
$vorlagen = null;
if (!$this->post('nur_stammdaten')) {
$buchungen = (new QifBuchungsImport($parser, ['stichtag' => $stichtag, 'bis' => $bis]))
->ausfuehren($trockenlauf);
$buchungen = (new QifBuchungsImport($parser, [
'stichtag' => $stichtag,
'bis' => $bis,
'konten' => $auswahl,
]))->ausfuehren($trockenlauf);
// Buchungsvorlagen stehen in derselben Datei (!Type:Memorized)
// und kommen gleich mit – ein eigener Durchgang wäre nur ein
@ -118,6 +129,8 @@ class ImportController extends BaseController
'stichtag' => $stichtag,
'bis' => $bis,
'datei' => $name,
'dateikonten' => $dateikonten,
'auswahl' => $auswahl,
]);
}

View file

@ -9,6 +9,7 @@ use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\Depot;
use Finanzmanager\Services\DepotbestandImport;
use Finanzmanager\Services\Kursabruf;
use Finanzmanager\Services\Zeitraum;
use Finanzmanager\View;
@ -127,7 +128,217 @@ class WertpapiereController extends BaseController
Depotbuchung::loeschen((int)$params['id']);
View::redirect('/wertpapiere/buchungen', 'Depotbuchung gelöscht – samt ihrer Geldbuchung.');
View::redirect($this->buchungenPfad(), 'Depotbuchung gelöscht – samt ihrer Geldbuchung.');
}
/**
* Sammellöschen aus der Liste der Depotbuchungen – wie im Kontoblatt.
*
* Der Bestand wird bei jedem Aufruf aus den Depotbuchungen gerechnet; er
* gibt die Löschung deshalb von selbst wieder her, ebenso Veräußerungen und
* Erträge. Fortgeschrieben wird nichts.
*/
public function auswahlLoeschen(): void
{
$this->verlangeAnmeldung();
$ziel = $this->buchungenPfad();
$ids = $this->postIds('buchungen');
if ($ids === []) {
View::redirect($ziel, 'Keine Depotbuchung ausgewählt.', 'warnung');
return;
}
$anzahl = 0;
foreach ($ids as $id) {
// Fremde oder längst gelöschte Nummern still übergehen – der Rest
// der Auswahl soll trotzdem verschwinden.
if (Depotbuchung::find($id) === null) {
continue;
}
try {
Depotbuchung::loeschen($id);
} catch (\Throwable $e) {
View::redirect($ziel, $e->getMessage(), 'fehler');
return;
}
$anzahl++;
}
View::redirect($ziel, $anzahl === 1
? 'Depotbuchung gelöscht – samt ihrer Geldbuchung.'
: $anzahl . ' Depotbuchungen gelöscht – samt ihrer Geldbuchungen.');
}
/**
* Zurück in die Liste, mit demselben Filter.
*
* Die Werte kommen aus der Abfragezeichenfolge des Formularziels und werden
* hier neu zusammengesetzt statt übernommen: So kann kein fremdes Ziel
* eingeschleust werden.
*/
private function buchungenPfad(): string
{
$query = array_filter([
'depot' => $this->query('depot'),
'wertpapier' => $this->query('wertpapier'),
'vorgang' => $this->query('vorgang'),
], static fn(string $wert): bool => $wert !== '');
return '/wertpapiere/buchungen' . ($query === [] ? '' : '?' . http_build_query($query));
}
// ── Bestandsimport aus einer CSV-Datei ───────────────────────────────────
public function importFormular(): void
{
$this->verlangeAnmeldung();
$this->render('wertpapiere/import.php', [
'titel' => 'Depotbestand importieren',
'depots' => Depot::depots(true),
'geldkonten' => $this->geldkonten(),
'ergebnis' => null,
'datei' => '',
'depot' => (int)$this->query('depot'),
'verrechnungskonto' => 0,
'datum' => '',
]);
}
/**
* Vorschau und Übernahme laufen durch dieselbe Tür: Der erste Aufruf bringt
* die hochgeladene Datei mit, der zweite nur noch ihren Namen.
*/
public function importAusfuehren(): void
{
$this->verlangeAnmeldung();
try {
$datei = $this->importDatei();
} catch (\Throwable $e) {
View::redirect('/wertpapiere/import', $e->getMessage(), 'fehler');
return;
}
$trockenlauf = $this->post('modus') !== 'import';
$depotId = (int)$this->post('konto_id');
$verrechnung = (int)$this->post('verrechnungskonto_id');
try {
$import = DepotbestandImport::ausDatei($this->importVerzeichnis() . '/' . $datei, [
'konto_id' => $depotId,
'verrechnungskonto_id' => $verrechnung,
'datum' => $this->post('datum'),
]);
// Ohne ausdrückliche Wahl entscheidet die Depotnummer aus der Datei.
if ($depotId === 0) {
$passend = DepotbestandImport::passendesDepot($import->depotnummer());
$depotId = (int)($passend['id'] ?? 0);
$import->depot($depotId);
}
$ergebnis = $import->ausfuehren($trockenlauf);
} catch (\Throwable $e) {
View::redirect('/wertpapiere/import', 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler');
return;
}
if (!$trockenlauf) {
View::redirect('/wertpapiere?depot=' . $depotId, sprintf(
'%d Positionen übernommen, %d waren schon da%s.',
$ergebnis['zaehler']['neu'],
$ergebnis['zaehler']['vorhanden'],
$ergebnis['zaehler']['fehler'] > 0
? ', ' . $ergebnis['zaehler']['fehler'] . ' fehlgeschlagen' : ''
), $ergebnis['zaehler']['fehler'] > 0 ? 'fehler' : 'erfolg');
return;
}
$this->render('wertpapiere/import.php', [
'titel' => 'Depotbestand importieren',
'depots' => Depot::depots(true),
'geldkonten' => $this->geldkonten(),
'ergebnis' => $ergebnis,
'datei' => $datei,
'depot' => $depotId,
'verrechnungskonto' => $verrechnung,
'datum' => $ergebnis['datum'],
]);
}
/**
* Nimmt die hochgeladene Datei entgegen und legt sie ab – oder greift auf
* die aus der Vorschau zurück.
*/
private function importDatei(): string
{
$hochgeladen = $_FILES['csv'] ?? null;
if (is_array($hochgeladen) && ($hochgeladen['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK) {
$name = self::sichererCsvName((string)$hochgeladen['name']);
if ($name === null) {
throw new \RuntimeException('Nur .CSV-Dateien können hochgeladen werden.');
}
$ziel = $this->importVerzeichnis() . '/' . $name;
if (
!is_uploaded_file((string)$hochgeladen['tmp_name'])
|| !move_uploaded_file((string)$hochgeladen['tmp_name'], $ziel)
) {
throw new \RuntimeException('Die Datei konnte nicht abgelegt werden.');
}
return $name;
}
$name = self::sichererCsvName($this->post('datei'));
if ($name === null || !is_file($this->importVerzeichnis() . '/' . $name)) {
throw new \RuntimeException('Es wurde keine CSV-Datei ausgewählt.');
}
return $name;
}
/** Wie beim QIF-Import: kein Pfadanteil, keine fremde Endung. */
public static function sichererCsvName(string $eingabe): ?string
{
$name = basename(trim($eingabe));
if ($name === '' || $name === '.' || $name === '..' || str_contains($name, "\0")) {
return null;
}
if (strtolower(pathinfo($name, PATHINFO_EXTENSION)) !== 'csv') {
return null;
}
$name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?? '';
return $name === '' ? null : $name;
}
/** Dasselbe Verzeichnis wie beim QIF-Import – es ist das Importverzeichnis. */
private function importVerzeichnis(): string
{
$relativ = (string)Einstellung::get('import_qif_verzeichnis', 'data/import');
$pfad = dirname(__DIR__, 2) . '/' . trim($relativ, '/');
if (!is_dir($pfad)) {
mkdir($pfad, 0o775, true);
}
return $pfad;
}
/** Konten, die als Verrechnungskonto in Frage kommen – alles außer Depots. */
private function geldkonten(): array
{
return array_values(array_filter(
Konto::alle(true),
static fn(array $konto): bool => (string)$konto['kontoart'] !== 'depot'
));
}
// ── Stammdaten und Kurse ─────────────────────────────────────────────────

View file

@ -18,7 +18,12 @@ use Finanzmanager\Mandant;
*/
class Buchung
{
public const QUELLEN = ['manuell' => 'manuell erfasst', 'qif' => 'QIF-Import', 'fints' => 'Bankabruf'];
public const QUELLEN = [
'manuell' => 'manuell erfasst',
'qif' => 'QIF-Import',
'fints' => 'Bankabruf',
'csv' => 'CSV-Import',
];
/** Beträge gelten als gleich, wenn sie sich um weniger als einen halben Cent unterscheiden. */
private const CENT = 0.005;
@ -41,6 +46,36 @@ class Buchung
);
}
/**
* Die Splitzeilen mehrerer Buchungen in einer Abfrage – das Kontoblatt gibt
* sie dem Splitdialog mit, damit er eine bestehende Aufteilung zeigt, ohne
* je Zeile nachzufragen.
*
* @param list<int> $buchungIds
* @return array<int, list<array<string,mixed>>> buchung_id => Zeilen
*/
public static function splitsJeBuchung(array $buchungIds): array
{
$ids = array_values(array_unique(array_map('intval', $buchungIds)));
if ($ids === []) {
return [];
}
$zeilen = Database::fetchAll(
'SELECT * FROM buchung_splits
WHERE buchung_id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')
ORDER BY buchung_id, sortierung, id',
$ids
);
$jeBuchung = [];
foreach ($zeilen as $zeile) {
$jeBuchung[(int)$zeile['buchung_id']][] = $zeile;
}
return $jeBuchung;
}
/** @return list<string> */
public static function adressen(int $buchungId): array
{
@ -125,6 +160,68 @@ class Buchung
});
}
/**
* Kategorie oder Gegenkonto einer Buchung setzen – die Schnellzuordnung aus
* dem Kontoblatt. Alles andere an der Buchung bleibt unberührt.
*
* Beides zugleich gibt es nicht: Eine Buchung ist entweder kategorisiert
* oder eine Umbuchung. War sie gesplittet, fallen die Zeilen weg – die
* Zuordnung steckt danach wieder oben.
*/
public static function zuordnen(int $id, ?int $kategorieId, ?int $gegenkontoId): void
{
$buchung = self::find($id);
if ($buchung === null) {
throw new \RuntimeException('Die Buchung gibt es nicht.');
}
Monatsabschluss::verlangeOffen((string)$buchung['datum']);
if ($gegenkontoId !== null) {
$kategorieId = null;
self::pruefeGegenkonto($gegenkontoId, (int)$buchung['konto_id']);
}
if ($kategorieId !== null && Kategorie::find($kategorieId) === null) {
throw new \InvalidArgumentException('Die Kategorie gibt es nicht.');
}
Database::transaktion(static function () use ($id, $kategorieId, $gegenkontoId): void {
Database::execute('DELETE FROM buchung_splits WHERE buchung_id = ?', [$id]);
Database::execute(
'UPDATE buchungen SET kategorie_id = ?, gegenkonto_id = ?, gesplittet = 0
WHERE id = ? AND benutzer_id = ?',
[$kategorieId, $gegenkontoId, $id, Mandant::id()]
);
});
}
/**
* Die Splitzeilen einer Buchung ersetzen – der Splitdialog des Kontoblatts.
* Eine leere Liste hebt die Splitbuchung auf; die Buchung steht danach ohne
* Zuordnung da.
*
* @param list<array<string,mixed>> $splits
*/
public static function splitsErsetzen(int $id, array $splits): void
{
$buchung = self::find($id);
if ($buchung === null) {
throw new \RuntimeException('Die Buchung gibt es nicht.');
}
Monatsabschluss::verlangeOffen((string)$buchung['datum']);
self::pruefeSplits($splits, (float)$buchung['betrag'], (int)$buchung['konto_id']);
Database::transaktion(static function () use ($id, $splits): void {
Database::execute('DELETE FROM buchung_splits WHERE buchung_id = ?', [$id]);
Database::execute(
'UPDATE buchungen SET kategorie_id = NULL, gegenkonto_id = NULL, gesplittet = ?
WHERE id = ? AND benutzer_id = ?',
[$splits === [] ? 0 : 1, $id, Mandant::id()]
);
self::speichereSplits($id, $splits);
});
}
public static function loeschen(int $id): void
{
$buchung = self::find($id);
@ -425,27 +522,14 @@ class Buchung
$kategorie = self::id($daten['kategorie_id'] ?? null);
$gesplittet = $splits !== [];
if ($gegenkonto !== null && $gegenkonto === $kontoId) {
throw new \InvalidArgumentException('Eine Umbuchung braucht zwei verschiedene Konten.');
if ($gegenkonto !== null) {
self::pruefeGegenkonto($gegenkonto, $kontoId);
}
if ($gesplittet && ($kategorie !== null || $gegenkonto !== null)) {
if ($gesplittet) {
// Bei einer Splitbuchung steckt die Zuordnung in den Zeilen.
$kategorie = null;
$gegenkonto = null;
}
if ($gesplittet) {
$summe = 0.0;
foreach ($splits as $split) {
$summe += round((float)($split['betrag'] ?? 0), 2);
}
if (abs($summe - $betrag) > self::CENT) {
throw new \InvalidArgumentException(sprintf(
'Die Splitzeilen ergeben %s, die Buchung lautet über %s.',
number_format($summe, 2, ',', '.'),
number_format($betrag, 2, ',', '.')
));
}
self::pruefeSplits($splits, $betrag, $kontoId);
}
$valuta = trim((string)($daten['valuta'] ?? ''));
@ -469,6 +553,48 @@ class Buchung
];
}
/** Ein Konto kann nicht sein eigenes Gegenkonto sein. */
private static function pruefeGegenkonto(int $gegenkontoId, int $kontoId): void
{
if ($gegenkontoId === $kontoId) {
throw new \InvalidArgumentException('Eine Umbuchung braucht zwei verschiedene Konten.');
}
if (Konto::find($gegenkontoId) === null) {
throw new \InvalidArgumentException('Das Gegenkonto gibt es nicht.');
}
}
/**
* Die Summe der Zeilen muss den Buchungsbetrag ergeben, und keine Zeile darf
* auf das eigene Konto zeigen – sonst zählte die Buchung dort doppelt.
*
* @param list<array<string,mixed>> $splits
*/
private static function pruefeSplits(array $splits, float $betrag, int $kontoId): void
{
if ($splits === []) {
return;
}
$summe = 0.0;
foreach ($splits as $split) {
$summe += round((float)($split['betrag'] ?? 0), 2);
$gegenkonto = self::id($split['gegenkonto_id'] ?? null);
if ($gegenkonto !== null) {
self::pruefeGegenkonto($gegenkonto, $kontoId);
}
}
if (abs($summe - $betrag) > self::CENT) {
throw new \InvalidArgumentException(sprintf(
'Die Splitzeilen ergeben %s, die Buchung lautet über %s.',
number_format($summe, 2, ',', '.'),
number_format($betrag, 2, ',', '.')
));
}
}
/** @param list<array<string,mixed>> $splits */
private static function speichereSplits(int $buchungId, array $splits): void
{

View file

@ -0,0 +1,494 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
/**
* Übernimmt eine Depotbestandsliste im CSV-Format der DKB.
*
* **Was die Datei enthält – und was nicht.** Der Export beschreibt nur den
* Bestand eines Stichtags: je Position Stückzahl, Einstiegskurs und
* Bewertungskurs. Die Geschäfte, die zu diesem Bestand geführt haben, fehlen
* vollständig. Der Import baut deshalb je Position *eine* Depotbuchung –
* einen Kauf zum Stichtag über die volle Stückzahl zum Einstiegskurs. Der
* Einstand stimmt damit auf den Cent, die Historie bleibt naturgemäß leer.
*
* **Zwei Kurse, zwei Rollen.** Der Einstiegskurs ist der durchschnittliche
* Kaufpreis und wird zum Kurs der Buchung; der Bewertungskurs ist der aktuelle
* Kurs und landet als Kurs des Stichtags in der Kurstabelle. Erst dadurch zeigt
* die Depotübersicht denselben Gewinn wie die Bank.
*
* **Kein Geldfluss.** Ein Verrechnungskonto ist optional und bleibt in aller
* Regel leer: Das Geld ist irgendwann in der Vergangenheit geflossen, eine
* Buchung zum heutigen Tag würde das Girokonto verfälschen.
*
* **Zweiter Lauf.** Jede Position bekommt einen `import_hash` aus Depot, Datum,
* Vorgang, Stück, Betrag und ISIN. Derselbe Export legt deshalb nichts doppelt
* an.
*/
class DepotbestandImport
{
/** Spaltenüberschrift (klein geschrieben) → eigenes Feld. */
private const SPALTEN = [
'datum der erstellung' => 'erstellt',
'depotnummer' => 'depotnummer',
'wertpapierbezeichnung' => 'name',
'wertpapier' => 'name',
'bezeichnung' => 'name',
'wkn' => 'wkn',
'isin' => 'isin',
'einstiegskurs' => 'einstiegskurs',
'bewertungskurs' => 'bewertungskurs',
'stückzahl' => 'stueck',
'stueckzahl' => 'stueck',
'nominal' => 'stueck',
'assetklasse' => 'assetklasse',
];
/** Assetklasse der Bank → eigene Wertpapierart. */
private const ASSETKLASSEN = [
'aktien' => 'aktie',
'aktie' => 'aktie',
'etfs' => 'fonds',
'etf' => 'fonds',
'fonds' => 'fonds',
'investmentfonds' => 'fonds',
'anleihen' => 'anleihe',
'anleihe' => 'anleihe',
'renten' => 'anleihe',
'zertifikate' => 'zertifikat',
'zertifikat' => 'zertifikat',
];
private const TRENNZEICHEN = [';', ',', "\t"];
/** @var list<array<string,mixed>> */
private array $zeilen = [];
private string $erstellt = '';
private string $depotnummer = '';
/** @var list<array{typ: string, text: string}> */
private array $meldungen = [];
private int $kontoId;
private ?int $verrechnungskontoId;
private string $datum;
/**
* @param string $inhalt Der rohe Dateiinhalt
* @param array{konto_id?: int, verrechnungskonto_id?: int|null, datum?: string} $optionen
*/
public function __construct(string $inhalt, array $optionen = [])
{
$this->lies($inhalt);
$this->kontoId = (int)($optionen['konto_id'] ?? 0);
$verrechnung = $optionen['verrechnungskonto_id'] ?? null;
$this->verrechnungskontoId = ($verrechnung === null || (int)$verrechnung === 0)
? null : (int)$verrechnung;
// Ohne Vorgabe gilt der Stichtag der Datei – er passt zum
// Bewertungskurs. Erst wenn auch der fehlt, wird es der heutige Tag.
$datum = trim((string)($optionen['datum'] ?? ''));
$this->datum = preg_match('/^\d{4}-\d{2}-\d{2}$/', $datum) === 1
? $datum
: ($this->erstellt !== '' ? $this->erstellt : date('Y-m-d'));
}
/** @param array<string,mixed> $optionen */
public static function ausDatei(string $pfad, array $optionen = []): self
{
$inhalt = @file_get_contents($pfad);
if ($inhalt === false) {
throw new \RuntimeException('Die Datei „' . basename($pfad) . '“ lässt sich nicht lesen.');
}
return new self($inhalt, $optionen);
}
/**
* Setzt das Zieldepot nachträglich – die Depotnummer steht erst fest,
* wenn die Datei gelesen ist.
*/
public function depot(int $kontoId): self
{
$this->kontoId = $kontoId;
return $this;
}
public function stichtag(): string
{
return $this->datum;
}
public function depotnummer(): string
{
return $this->depotnummer;
}
/**
* Das Depot, dessen Kontonummer zur Depotnummer der Datei passt – als
* Vorauswahl im Formular, damit niemand die Nummern vergleichen muss.
*/
public static function passendesDepot(string $depotnummer): ?array
{
$nummer = trim($depotnummer);
if ($nummer === '') {
return null;
}
foreach (Depot::depots() as $depot) {
if (trim((string)$depot['kontonummer']) === $nummer) {
return $depot;
}
}
return null;
}
/**
* @return array{
* trockenlauf: bool, datum: string, depotnummer: string,
* positionen: list<array<string,mixed>>,
* zaehler: array<string,int>, einstand: float, wert: float,
* meldungen: list<array{typ: string, text: string}>
* }
*/
public function ausfuehren(bool $trockenlauf = false): array
{
$this->meldungen = [];
if ($this->kontoId === 0 || Konto::find($this->kontoId) === null) {
throw new \InvalidArgumentException('Der Import braucht ein gültiges Depot.');
}
$lauf = fn(): array => $this->uebernimm($trockenlauf);
// Beim echten Lauf gilt alles oder nichts: Ein Abbruch in der Mitte
// hinterließe einen halben Bestand.
$ergebnis = $trockenlauf ? $lauf() : Database::transaktion($lauf);
$ergebnis['trockenlauf'] = $trockenlauf;
$ergebnis['datum'] = $this->datum;
$ergebnis['depotnummer'] = $this->depotnummer;
$ergebnis['meldungen'] = $this->meldungen;
return $ergebnis;
}
/** @return array<string,mixed> */
private function uebernimm(bool $trockenlauf): array
{
$zaehler = [
'positionen' => count($this->zeilen),
'neu' => 0,
'vorhanden' => 0,
'fehler' => 0,
'wertpapiereNeu' => 0,
'kurse' => 0,
];
$positionen = [];
$einstand = 0.0;
$wert = 0.0;
foreach ($this->zeilen as $zeile) {
$position = $zeile;
$betrag = round($zeile['stueck'] * $zeile['einstiegskurs'], 2);
$position['einstand'] = $betrag;
$position['wert'] = $zeile['bewertungskurs'] > 0.0
? round($zeile['stueck'] * $zeile['bewertungskurs'], 2)
: null;
$position['gewinn'] = $position['wert'] === null
? null : round($position['wert'] - $betrag, 2);
$hash = Depotbuchung::hash(
$this->kontoId,
$this->datum,
'kauf',
$zeile['stueck'],
$betrag,
$zeile['isin']
);
if (Depotbuchung::findeNachHash($hash) !== null) {
$position['status'] = 'vorhanden';
$zaehler['vorhanden']++;
$positionen[] = $position;
$einstand += $betrag;
$wert += $position['wert'] ?? 0.0;
continue;
}
$vorhandenesPapier = Wertpapier::finde($zeile['isin'], $zeile['wkn'], $zeile['name']);
$position['wertpapier_neu'] = $vorhandenesPapier === null;
if ($vorhandenesPapier === null) {
$zaehler['wertpapiereNeu']++;
}
try {
if (!$trockenlauf) {
$this->buche($zeile, $betrag, $hash);
$zaehler['kurse'] += $zeile['bewertungskurs'] > 0.0 ? 1 : 0;
} elseif ($zeile['bewertungskurs'] > 0.0) {
$zaehler['kurse']++;
}
$position['status'] = 'neu';
$zaehler['neu']++;
} catch (\Throwable $e) {
$position['status'] = 'fehler';
$position['meldung'] = $e->getMessage();
$zaehler['fehler']++;
$this->melde('fehler', $zeile['name'] . ': ' . $e->getMessage());
}
$positionen[] = $position;
$einstand += $betrag;
$wert += $position['wert'] ?? 0.0;
}
return [
'positionen' => $positionen,
'zaehler' => $zaehler,
'einstand' => round($einstand, 2),
'wert' => round($wert, 2),
];
}
/** @param array<string,mixed> $zeile */
private function buche(array $zeile, float $betrag, string $hash): void
{
$wertpapierId = Wertpapier::findenOderAnlegen([
'name' => $zeile['name'],
'isin' => $zeile['isin'],
'wkn' => $zeile['wkn'],
'art' => $zeile['art'],
'waehrung' => 'EUR',
]);
Depotbuchung::anlegen([
'konto_id' => $this->kontoId,
'wertpapier_id' => $wertpapierId,
'datum' => $this->datum,
'vorgang' => 'kauf',
'stueck' => $zeile['stueck'],
'kurs' => $zeile['einstiegskurs'],
'betrag' => $betrag,
'verrechnungskonto_id' => $this->verrechnungskontoId,
'notiz' => 'Bestandsübernahme ' . date('d.m.Y', strtotime($this->datum)),
'wertpapiername' => $zeile['name'],
'quelle' => 'csv',
'import_hash' => $hash,
// Die Geldseite braucht einen eigenen Hash: Beide Tabellen führen
// ihre Kennung in CHAR(40), derselbe Wert zweimal wäre verwirrend.
'buchung_hash' => sha1($hash . '|geld'),
]);
// Der Bewertungskurs ist der Kurs des Stichtags – ohne ihn stünde die
// frisch übernommene Position gleich wieder ohne Bewertung da.
if ($zeile['bewertungskurs'] > 0.0) {
Wertpapier::kursSetzen($wertpapierId, $this->datum, $zeile['bewertungskurs'], 'csv');
}
}
// ── Datei lesen ──────────────────────────────────────────────────────────
private function lies(string $inhalt): void
{
$inhalt = trim($this->zuUtf8($inhalt));
if ($inhalt === '') {
throw new \RuntimeException('Die Datei ist leer.');
}
$trenner = $this->trennzeichen((string)strtok($inhalt, "\r\n"));
// Gelesen wird über einen Strom statt zeilenweise: Ein Wertpapiername
// in Anführungszeichen darf einen Zeilenumbruch enthalten.
$strom = fopen('php://memory', 'r+');
if ($strom === false) {
throw new \RuntimeException('Die Datei lässt sich nicht verarbeiten.');
}
try {
fwrite($strom, $inhalt);
rewind($strom);
$spalten = null;
$nummer = 0;
while (($felder = fgetcsv($strom, 0, $trenner, '"', '\\')) !== false) {
$nummer++;
if ($felder === [null]) {
continue;
}
if ($spalten === null) {
$spalten = $this->kopfzeile(array_map(strval(...), $felder));
continue;
}
$zeile = $this->zeile(array_map(strval(...), $felder), $spalten);
if ($zeile !== null) {
$this->zeilen[] = $zeile + ['zeile' => $nummer];
}
}
} finally {
fclose($strom);
}
if ($this->zeilen === []) {
throw new \RuntimeException('Die Datei enthält keine Positionen.');
}
}
/**
* @param list<string> $ueberschriften
* @return array<string,int> eigenes Feld → Spaltennummer
*/
private function kopfzeile(array $ueberschriften): array
{
$spalten = [];
foreach ($ueberschriften as $nummer => $ueberschrift) {
$name = mb_strtolower(trim((string)$ueberschrift));
if (isset(self::SPALTEN[$name]) && !isset($spalten[self::SPALTEN[$name]])) {
$spalten[self::SPALTEN[$name]] = $nummer;
}
}
foreach (['name', 'einstiegskurs', 'stueck'] as $pflicht) {
if (!isset($spalten[$pflicht])) {
throw new \RuntimeException(
'Die Datei sieht nicht wie eine Depotbestandsliste aus – '
. 'es fehlen die Spalten Wertpapierbezeichnung, Einstiegskurs und Stückzahl.'
);
}
}
return $spalten;
}
/**
* @param list<string> $felder
* @param array<string,int> $spalten
* @return array<string,mixed>|null
*/
private function zeile(array $felder, array $spalten): ?array
{
$wert = static fn(string $feld): string => isset($spalten[$feld])
? trim((string)($felder[$spalten[$feld]] ?? '')) : '';
$name = $wert('name');
$isin = strtoupper($wert('isin'));
$stueck = $this->zahl($wert('stueck'));
// Summenzeilen und leere Zeilen am Dateiende fallen hier heraus.
if ($name === '' || $stueck <= 0.0) {
return null;
}
if ($this->erstellt === '') {
$this->erstellt = $this->datumAusCsv($wert('erstellt'));
}
if ($this->depotnummer === '') {
$this->depotnummer = $wert('depotnummer');
}
return [
'name' => $name,
'isin' => $isin,
'wkn' => strtoupper($wert('wkn')),
'assetklasse' => $wert('assetklasse'),
'art' => self::ASSETKLASSEN[mb_strtolower($wert('assetklasse'))] ?? 'sonstiges',
'stueck' => $stueck,
'einstiegskurs' => $this->zahl($wert('einstiegskurs')),
'bewertungskurs' => $this->zahl($wert('bewertungskurs')),
'status' => 'neu',
'wertpapier_neu' => false,
'meldung' => '',
];
}
/**
* Beträge kommen als „1.471,88 €“ – mit Tausenderpunkt, Komma und einem
* geschützten Leerzeichen vor dem Währungszeichen.
*/
private function zahl(string $wert): float
{
$wert = preg_replace('/[^0-9,.\-]/u', '', $wert) ?? '';
if ($wert === '') {
return 0.0;
}
$wert = str_replace('.', '', $wert);
return (float)str_replace(',', '.', $wert);
}
/** „12.08.2026“ → „2026-08-12“. */
private function datumAusCsv(string $wert): string
{
if (preg_match('/(\d{1,2})\.(\d{1,2})\.(\d{4})/', $wert, $treffer) === 1) {
return sprintf('%04d-%02d-%02d', (int)$treffer[3], (int)$treffer[2], (int)$treffer[1]);
}
if (preg_match('/^\d{4}-\d{2}-\d{2}/', $wert, $treffer) === 1) {
return $treffer[0];
}
return '';
}
/**
* Die DKB trennt mit Semikolon; andere Banken nehmen Komma oder Tabulator.
* Gewählt wird das Zeichen, das in der Kopfzeile am häufigsten vorkommt.
*/
private function trennzeichen(string $kopfzeile): string
{
$bestes = ';';
$treffer = 0;
foreach (self::TRENNZEICHEN as $zeichen) {
$anzahl = substr_count($kopfzeile, $zeichen);
if ($anzahl > $treffer) {
$bestes = $zeichen;
$treffer = $anzahl;
}
}
return $bestes;
}
/**
* Banken exportieren mal UTF-8 (gern mit BOM), mal Windows-1252. Ohne
* Umwandlung stünden Umlaute später als Fragezeichen in den Stammdaten.
*/
private function zuUtf8(string $inhalt): string
{
if (str_starts_with($inhalt, "\xEF\xBB\xBF")) {
return substr($inhalt, 3);
}
if (!mb_check_encoding($inhalt, 'UTF-8')) {
return mb_convert_encoding($inhalt, 'UTF-8', 'Windows-1252');
}
return $inhalt;
}
private function melde(string $typ, string $text): void
{
$this->meldungen[] = ['typ' => $typ, 'text' => $text];
}
}

View file

@ -28,6 +28,11 @@ use Finanzmanager\Models\Wertpapier;
*
* Buchungen nach dem Enddatum sind Vorausberechnungen des FinanzManagers und
* werden übersprungen.
*
* Über die Option `konten` lässt sich der Import auf einzelne Konten begrenzen.
* Maßgeblich ist dann nicht allein der `!Account`-Abschnitt, sondern jedes
* Konto, das die Buchung berührt – sonst fehlten die Umbuchungen, die auf einem
* anderen Konto erfasst wurden und nur über `L[Konto]` hierher zeigen.
*/
class QifBuchungsImport
{
@ -38,6 +43,9 @@ class QifBuchungsImport
private string $stichtag;
private string $bis;
/** @var array<string,true>|null Gewählte Kontonamen; null heißt: alle. */
private ?array $auswahl = null;
/** @var array<string,int> Kontoname → id */
private array $konten = [];
/** @var array<string,int> Kategoriepfad → id */
@ -46,12 +54,14 @@ class QifBuchungsImport
private array $klassen = [];
/** @var array<string,int> Basis-Hash → wie oft schon gesehen */
private array $hashZaehler = [];
/** @var array<int,string> Position in der Datei → Fingerabdruck der Geldbuchung */
private array $hashes = [];
/** @var list<array{typ: string, text: string}> */
private array $meldungen = [];
/**
* @param array{stichtag?: string, bis?: string} $optionen
* @param array{stichtag?: string, bis?: string, konten?: list<string>} $optionen
*/
public function __construct(QifParser $parser, array $optionen = [])
{
@ -62,6 +72,44 @@ class QifBuchungsImport
if (!Buchung::istDatum($this->stichtag) || !Buchung::istDatum($this->bis)) {
throw new \InvalidArgumentException('Stichtag und Enddatum müssen als JJJJ-MM-TT vorliegen.');
}
$auswahl = [];
foreach ($optionen['konten'] ?? [] as $name) {
$name = trim((string)$name);
if ($name !== '') {
$auswahl[$name] = true;
}
}
$this->auswahl = $auswahl === [] ? null : $auswahl;
}
/**
* Welche Konten kommen in der Datei überhaupt vor?
*
* Grundlage der Kontoauswahl im Dialog: auswählbar ist nur, was der Export
* auch hergibt. Gezählt wird je Konto, wie viele Einträge es berühren –
* eigene Buchungen ebenso wie Umbuchungen, die von anderswo hierher zeigen.
* Ein Konto, das nur Umbuchungsziel ist, führt in der Datei keine eigene
* Zeile und stünde sonst nicht zur Wahl.
*
* @return array<string,int> Kontoname → Zahl der Einträge, die es betreffen
*/
public static function kontenDerDatei(QifParser $parser): array
{
$konten = [];
foreach ($parser->eintraege() as $eintrag) {
if (!in_array($eintrag['typ'], self::GELDTYPEN, true) && $eintrag['typ'] !== 'Invst') {
continue;
}
foreach (self::beteiligteKonten($eintrag) as $name) {
$konten[$name] = ($konten[$name] ?? 0) + 1;
}
}
ksort($konten, SORT_NATURAL | SORT_FLAG_CASE);
return $konten;
}
/**
@ -82,6 +130,7 @@ class QifBuchungsImport
'buchungen' => $buchungen,
'stichtag' => $this->stichtag,
'bis' => $this->bis,
'konten' => $this->auswahl === null ? null : array_keys($this->auswahl),
'trockenlauf' => $trockenlauf,
];
};
@ -101,6 +150,11 @@ class QifBuchungsImport
* Summiert alles vor dem Stichtag je Konto auf – die Gegenseiten der
* Umbuchungen eingeschlossen – und schreibt das Ergebnis als Anfangssaldo.
*
* Bei einer Kontoauswahl wird trotzdem die ganze Datei durchgerechnet: Der
* Saldo eines Kontos steckt auch in Buchungen, die auf einem anderen Konto
* erfasst wurden. Nur geschrieben werden die gewählten Konten – sonst
* überschriebe ein Teilimport die Salden aller übrigen.
*
* @return array{konten: int, summe: float, salden: array<string,float>}
*/
private function anfangssalden(bool $trockenlauf): array
@ -142,8 +196,12 @@ class QifBuchungsImport
}
}
$gesetzt = 0;
$gesetzt = 0;
$geschrieben = [];
foreach ($salden as $name => $betrag) {
if (!$this->gewaehlt($name)) {
continue;
}
$kontoId = $this->kontoId($name);
if ($kontoId === null) {
$this->melde('fehler', 'Anfangssaldo für unbekanntes Konto „' . $name . '“ verworfen.');
@ -155,13 +213,14 @@ class QifBuchungsImport
[round($betrag, 2), $kontoId]
);
}
$geschrieben[$name] = $betrag;
$gesetzt++;
}
return [
'konten' => $gesetzt,
'summe' => round(array_sum($salden), 2),
'salden' => $salden,
'summe' => round(array_sum($geschrieben), 2),
'salden' => $geschrieben,
];
}
@ -172,10 +231,12 @@ class QifBuchungsImport
{
$zaehler = [
'neu' => 0, 'vorhanden' => 0, 'vor_stichtag' => 0,
'zukunft' => 0, 'wertpapier' => 0, 'fehler' => 0,
'zukunft' => 0, 'wertpapier' => 0, 'nicht_gewaehlt' => 0, 'fehler' => 0,
];
foreach ($this->parser->eintraege() as $eintrag) {
$this->hashes = $this->hashes();
foreach ($this->parser->eintraege() as $nr => $eintrag) {
$istGeld = $this->istGeldbuchung($eintrag);
if (!$istGeld && $eintrag['typ'] !== 'Invst') {
continue;
@ -195,10 +256,14 @@ class QifBuchungsImport
$zaehler['zukunft']++;
continue;
}
if (!$this->gehoertZurAuswahl($eintrag)) {
$zaehler['nicht_gewaehlt']++;
continue;
}
try {
$angelegt = $istGeld
? $this->geldbuchung($eintrag, $datum, $trockenlauf)
? $this->geldbuchung($eintrag, $nr, $datum, $trockenlauf)
: $this->wertpapierbuchung($eintrag, $datum, $trockenlauf);
if ($angelegt === null) {
@ -227,9 +292,10 @@ class QifBuchungsImport
/**
* @param array{typ: string, konto: ?string, zeilen: list<array{0: string, 1: string}>} $eintrag
* @param int $nr Position in der Datei – dort liegt der vorab vergebene Hash.
* @return int|null Kennung der neuen Buchung, oder null wenn sie schon da war.
*/
private function geldbuchung(array $eintrag, string $datum, bool $trockenlauf): ?int
private function geldbuchung(array $eintrag, int $nr, string $datum, bool $trockenlauf): ?int
{
$kontoName = (string)$eintrag['konto'];
$kontoId = $this->kontoId($kontoName);
@ -241,7 +307,7 @@ class QifBuchungsImport
$text = (string)QifParser::feld($eintrag, 'M');
$nummer = (string)QifParser::feld($eintrag, 'N');
$hash = $this->eindeutigerHash($kontoId, $datum, $betrag, $nummer, $text);
$hash = $this->hashes[$nr] ?? $this->eindeutigerHash($kontoId, $datum, $betrag, $nummer, $text);
if (Buchung::findeNachHash($hash) !== null) {
return null;
}
@ -568,6 +634,113 @@ class QifBuchungsImport
return self::umbuchungsziel($strich === false ? $angabe : substr($angabe, $strich));
}
/**
* Vergibt die Fingerabdrücke aller Geldbuchungen im Zeitraum vorab.
*
* Das geschieht in einem eigenen Durchgang über die ganze Datei, weil der
* laufende Zähler für gleiche Fingerabdrücke sonst von der Kontoauswahl
* abhinge: Dieselbe Buchung bekäme im Teilimport einen anderen Hash als im
* Vollimport und würde ein zweites Mal angelegt.
*
* @return array<int,string> Position in der Datei → Hash
*/
private function hashes(): array
{
$this->hashZaehler = [];
$hashes = [];
foreach ($this->parser->eintraege() as $nr => $eintrag) {
if (!$this->istGeldbuchung($eintrag)) {
continue;
}
$datum = QifParser::datum(QifParser::feld($eintrag, 'D'), $this->parser->datumsformat());
if ($datum === null || $datum < $this->stichtag || $datum > $this->bis) {
continue;
}
$kontoId = $this->kontoId((string)$eintrag['konto']);
if ($kontoId === null) {
continue;
}
$hashes[$nr] = $this->eindeutigerHash(
$kontoId,
$datum,
QifParser::betrag(QifParser::feld($eintrag, 'U') ?? QifParser::feld($eintrag, 'T')),
(string)QifParser::feld($eintrag, 'N'),
(string)QifParser::feld($eintrag, 'M')
);
}
return $hashes;
}
/**
* Berührt der Eintrag eines der gewählten Konten?
*
* Ohne Auswahl gilt alles. Sonst zählt nicht nur der `!Account`-Abschnitt,
* in dem der Eintrag steht: Eine Umbuchung steht nur einmal in der Datei,
* und wäre allein das erfassende Konto maßgeblich, fehlte dem gewählten
* Konto die Gegenseite.
*/
private function gehoertZurAuswahl(array $eintrag): bool
{
if ($this->auswahl === null) {
return true;
}
foreach (self::beteiligteKonten($eintrag) as $name) {
if (isset($this->auswahl[$name])) {
return true;
}
}
return false;
}
private function gewaehlt(string $name): bool
{
return $this->auswahl === null || isset($this->auswahl[trim($name)]);
}
/**
* Alle Konten, auf denen ein Eintrag zu Buche schlägt: das Konto des
* Abschnitts sowie die Umbuchungsziele aus `L` und den Splitzeilen. Beim
* Wertpapiergeschäft sind es Depot und Verrechnungskonto – Depot- und
* Geldbuchung entstehen immer gemeinsam.
*
* @return list<string>
*/
private static function beteiligteKonten(array $eintrag): array
{
$namen = [];
if ($eintrag['konto'] !== null) {
$namen[] = trim((string)$eintrag['konto']);
}
if ($eintrag['typ'] === 'Invst') {
$verrechnung = self::verrechnungskonto(QifParser::feld($eintrag, 'L'));
if ($verrechnung !== null) {
$namen[] = trim($verrechnung);
}
return array_values(array_unique($namen));
}
$angaben = QifParser::felder($eintrag, 'S');
$angaben[] = (string)QifParser::feld($eintrag, 'L');
foreach ($angaben as $angabe) {
$ziel = self::umbuchungsziel($angabe);
if ($ziel !== null) {
$namen[] = trim($ziel);
}
}
return array_values(array_unique($namen));
}
/**
* Identische Buchungen am selben Tag kommen vor (zweimal dasselbe im selben
* Laden). Der laufende Zähler hält sie auseinander und bleibt über Läufe

View file

@ -14,14 +14,23 @@ $neu = $buchung === null;
$ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id'];
$betrag = $neu ? 0.0 : (float)$buchung['betrag'];
$art = 'ausgabe';
if (!$neu) {
if (!empty($buchung['gegenkonto_id'])) {
$art = $betrag < 0 ? 'umbuchung_ab' : 'umbuchung_zu';
} else {
$art = $betrag >= 0 ? 'einnahme' : 'ausgabe';
// Die Art bestimmt nur noch die Richtung. Ob die Buchung eine Umbuchung ist,
// steht im Feld Kategorie / Gegenkonto.
$art = !$neu && $betrag >= 0 ? 'einnahme' : 'ausgabe';
/** Vorauswahl der gemeinsamen Liste Kategorie / Gegenkonto. */
$zuordnungswert = static function (?array $zeile): string {
if (!empty($zeile['gegenkonto_id'])) {
return 'konto:' . (int)$zeile['gegenkonto_id'];
}
}
if (!empty($zeile['kategorie_id'])) {
return 'kategorie:' . (int)$zeile['kategorie_id'];
}
return '';
};
$eigenesKonto = (int)$konto['id'];
$mitSplit = false;
// Immer ein paar leere Splitzeilen anbieten; weitere fügt der Knopf hinzu.
$splitZeilen = $splits;
@ -44,10 +53,8 @@ include __DIR__ . '/../layout/kopf.php';
<label for="art">Art</label>
<select id="art" name="art">
<?php foreach ([
'ausgabe' => 'Ausgabe',
'einnahme' => 'Einnahme',
'umbuchung_ab' => 'Umbuchung auf ein anderes Konto',
'umbuchung_zu' => 'Umbuchung von einem anderen Konto',
'ausgabe' => 'Ausgabe',
'einnahme' => 'Einnahme',
] as $schluessel => $beschriftung): ?>
<option value="<?= View::h($schluessel) ?>" <?= $art === $schluessel ? 'selected' : '' ?>>
<?= View::h($beschriftung) ?>
@ -68,36 +75,16 @@ include __DIR__ . '/../layout/kopf.php';
<input type="text" id="empfaenger" name="empfaenger"
value="<?= View::h((string)($buchung['empfaenger'] ?? '')) ?>">
<div class="nur-kategorie">
<label for="kategorie_id">Kategorie</label>
<select id="kategorie_id" name="kategorie_id">
<option value="">— keine —</option>
<?php foreach ($kategorien as $k): ?>
<option value="<?= (int)$k['id'] ?>"
<?= (int)($buchung['kategorie_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
<?= View::h($k['pfad']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="nur-umbuchung">
<label for="gegenkonto_id">Gegenkonto</label>
<select id="gegenkonto_id" name="gegenkonto_id">
<option value="">— bitte wählen —</option>
<?php foreach ($konten as $k): ?>
<?php if ((int)$k['id'] === (int)$konto['id']) { continue; } ?>
<option value="<?= (int)$k['id'] ?>"
<?= (int)($buchung['gegenkonto_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
<?= View::h($k['name']) ?>
</option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">
Die Umbuchung wird einmal gespeichert und erscheint auf beiden Kontoblättern –
auf dem Gegenkonto mit umgekehrtem Vorzeichen.
</p>
</div>
<label for="zuordnung">Kategorie / Gegenkonto</label>
<select id="zuordnung" name="zuordnung">
<?php $gewaehlt = $zuordnungswert($buchung); ?>
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
</select>
<p class="feldhinweis">
Unter den Kategorien stehen die eigenen Konten in eckigen Klammern. Eines davon
macht die Buchung zur Umbuchung: Sie wird einmal gespeichert und erscheint auf
beiden Kontoblättern – auf dem Gegenkonto mit umgekehrtem Vorzeichen.
</p>
<label for="klasse_id">Klasse</label>
<select id="klasse_id" name="klasse_id">
@ -132,20 +119,17 @@ include __DIR__ . '/../layout/kopf.php';
<table class="liste splittabelle">
<thead>
<tr><th>Kategorie</th><th>Klasse</th><th>Notiz</th><th class="rechts">Betrag</th></tr>
<tr>
<th>Kategorie / Gegenkonto</th><th>Klasse</th><th>Notiz</th><th class="rechts">Betrag</th>
</tr>
</thead>
<tbody id="splitzeilen">
<?php foreach ($splitZeilen as $nr => $zeile): ?>
<tr>
<td>
<select name="split[<?= $nr ?>][kategorie_id]">
<option value="">— keine —</option>
<?php foreach ($kategorien as $k): ?>
<option value="<?= (int)$k['id'] ?>"
<?= (int)($zeile['kategorie_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
<?= View::h($k['pfad']) ?>
</option>
<?php endforeach; ?>
<select name="split[<?= $nr ?>][zuordnung]">
<?php $gewaehlt = $zuordnungswert($zeile); ?>
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
</select>
</td>
<td>
@ -193,6 +177,7 @@ include __DIR__ . '/../layout/kopf.php';
</form>
<?php endif; ?>
<script src="/static/betrag.js" defer></script>
<script src="/static/buchungsmaske.js" defer></script>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -10,7 +10,8 @@ use Finanzmanager\View;
/** @var array<string,mixed> $filter */
/** @var list<array<string,mixed>> $kategorien */
/** @var list<array<string,mixed>> $klassen */
/** @var array<int,string> $kontenNamen */
/** @var list<array<string,mixed>> $konten */
/** @var array<int, list<array<string,mixed>>> $splits */
$kategorieNamen = [];
foreach ($kategorien as $k) {
@ -20,6 +21,9 @@ $klassenNamen = [];
foreach ($klassen as $k) {
$klassenNamen[(int)$k['id']] = $k['name'];
}
$kontenNamen = array_column($konten, 'name', 'id');
$eigenesKonto = (int)$konto['id'];
$gefiltert = ($filter['suche'] ?? '') !== '' || !empty($filter['von']) || !empty($filter['bis'])
|| !empty($filter['kategorie_id']) || !empty($filter['klasse_id']) || !empty($filter['nur_offene']);
@ -39,8 +43,44 @@ $seitenPfad = static function (int $nr) use ($filter): string {
};
// Nach dem Löschen soll dieselbe Ansicht wieder erscheinen – Filter und Seite
// hängen deshalb am Ziel des Löschformulars.
$loeschZiel = '/konten/' . (int)$konto['id'] . '/buchungen/loeschen' . $seitenPfad($seite);
// hängen deshalb am Ziel des Löschformulars. Dasselbe gilt für die
// Schnellzuordnung: Ihre Ziele setzt kontoblatt.js aus dieser Abfrage zusammen.
$abfrage = $seitenPfad($seite);
$loeschZiel = '/konten/' . (int)$konto['id'] . '/buchungen/loeschen' . $abfrage;
/**
* Die Zuordnung einer Zeile als Wert der gemeinsamen Auswahlliste – dieselben
* Präfixe wie in der Buchungsmaske.
*/
$zuordnungswert = static function (array $zeile): string {
if ((int)($zeile['gesplittet'] ?? 0) === 1) {
return 'split';
}
if (!empty($zeile['gegenkonto_id'])) {
return 'konto:' . (int)$zeile['gegenkonto_id'];
}
if (!empty($zeile['kategorie_id'])) {
return 'kategorie:' . (int)$zeile['kategorie_id'];
}
return '';
};
/**
* Die Splitzeilen einer Buchung, wie der Dialog sie braucht. Sie hängen als
* JSON an der Zelle statt nachgeladen zu werden: Es sind die Zeilen der wenigen
* aufgeteilten Buchungen dieser Seite, und ein zweiter Weg zu denselben Daten
* wäre auch ein zweiter Weg, sie falsch zu lesen.
*
* @param list<array<string,mixed>> $splitzeilen
*/
$splitdaten = static function (array $splitzeilen) use ($zuordnungswert): string {
return (string)json_encode(array_map(static fn(array $zeile): array => [
'zuordnung' => $zuordnungswert($zeile),
'klasse_id' => (int)($zeile['klasse_id'] ?? 0),
'notiz' => (string)$zeile['notiz'],
'betrag' => View::betrag($zeile['betrag'], false),
], $splitzeilen), JSON_UNESCAPED_UNICODE);
};
include __DIR__ . '/../layout/kopf.php';
?>
@ -124,7 +164,7 @@ include __DIR__ . '/../layout/kopf.php';
<?= $gefiltert ? 'Keine Buchung passt zum Filter.' : 'Für dieses Konto ist noch nichts gebucht.' ?>
</p>
<?php else: ?>
<table class="liste kontoblatt">
<table class="liste kontoblatt" data-abfrage="<?= View::h($abfrage) ?>">
<thead>
<tr>
<th class="wahlspalte">
@ -165,13 +205,46 @@ include __DIR__ . '/../layout/kopf.php';
<?php endif; ?>
</td>
<td class="nebentext gekuerzt"><?= View::h(mb_substr((string)$zeile['buchungstext'], 0, 90)) ?></td>
<td>
<?php if ((int)$zeile['gesplittet'] === 1): ?>
<span class="marke">Splitbuchung</span>
<?php elseif (!empty($zeile['gegenkonto_id']) && !$zeile['gegensicht']): ?>
[<?= View::h($kontenNamen[(int)$zeile['gegenkonto_id']] ?? '?') ?>]
<?php
// Die Gegenseite einer fremden Umbuchung nennt das Konto, von dem
// sie kommt – und zwar dort, wo sonst die Kategorie steht. Ohne
// diese Angabe stünde auf dem Gegenkonto eine Umbuchung ohne
// erkennbares Gegenüber.
$gegenkonto = empty($zeile['gegenkonto_id'])
? ''
: '[' . ($kontenNamen[(int)$zeile['gegenkonto_id']] ?? '?') . ']';
$beschriftung = match (true) {
(int)$zeile['gesplittet'] === 1 => 'Splitbuchung',
$gegenkonto !== '' => $gegenkonto,
default => (string)($kategorieNamen[(int)$zeile['kategorie_id']] ?? ''),
};
?>
<td class="zuordnungszelle">
<?php if ($zeile['gegensicht']): ?>
<?php /* Auch die Gegenseite führt zu ihrer Buchung – sie liegt
auf dem anderen Konto und wird nur dort geändert. */ ?>
<a href="/buchungen/<?= (int)$zeile['id'] ?>" class="zuordnungsfeld gegenseite"
title="Umbuchung von <?= View::h($kontenNamen[(int)$zeile['gegenkonto_id']] ?? '?') ?> – dort zu ändern">
<?= View::h($beschriftung) ?>
</a>
<?php else: ?>
<?= View::h($kategorieNamen[(int)$zeile['kategorie_id']] ?? '') ?>
<?php /* Ohne JavaScript führt der Klick in die Buchungsmaske;
kontoblatt.js macht daraus die Auswahl an Ort und Stelle. */ ?>
<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-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']])) ?>"
<?php endif; ?>
title="<?= View::h($beschriftung !== '' ? $beschriftung : 'Kategorie oder Gegenkonto zuordnen') ?>">
<?php if ($beschriftung === 'Splitbuchung'): ?>
<span class="marke">Splitbuchung</span>
<?php else: ?>
<?= View::h($beschriftung) ?>
<?php endif; ?>
</a>
<?php endif; ?>
</td>
<td class="nebentext"><?= View::h($klassenNamen[(int)$zeile['klasse_id']] ?? '') ?></td>
@ -197,6 +270,90 @@ include __DIR__ . '/../layout/kopf.php';
<?php endif; ?>
<?php endif; ?>
</div>
<?php /* Die Auswahlliste gibt es einmal; kontoblatt.js hängt sie in die
angeklickte Zelle. Eine Liste je Zeile wären bei hundert Zeilen
ebenso viele vollständige Kategoriebäume. */ ?>
<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 include __DIR__ . '/zuordnungsliste.php'; ?>
</select>
<noscript><button type="submit" class="knopf klein">Übernehmen</button></noscript>
</form>
<dialog id="splitdialog" class="splitdialog">
<form method="post" action="" id="splitformular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h2>Splitbuchung</h2>
<p class="feldhinweis">
<span id="splitbezeichnung"></span> über
<output id="splitbuchungsbetrag" class="betrag">0,00</output>
</p>
<table class="liste splittabelle">
<thead>
<tr>
<th>Kategorie / Gegenkonto</th><th>Klasse</th><th>Notiz</th>
<th class="rechts">Betrag</th>
</tr>
</thead>
<tbody id="splitdialogzeilen">
<?php for ($nr = 0; $nr < 4; $nr++): ?>
<tr>
<td>
<select name="split[<?= $nr ?>][zuordnung]">
<?php $gewaehlt = ''; $mitSplit = false; ?>
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
</select>
</td>
<td>
<select name="split[<?= $nr ?>][klasse_id]">
<option value="">— keine —</option>
<?php foreach ($klassen as $k): ?>
<option value="<?= (int)$k['id'] ?>"><?= View::h($k['name']) ?></option>
<?php endforeach; ?>
</select>
</td>
<td><input type="text" name="split[<?= $nr ?>][notiz]" value=""></td>
<td>
<input type="text" class="splitbetrag rechts" inputmode="decimal"
name="split[<?= $nr ?>][betrag]" value="">
</td>
</tr>
<?php endfor; ?>
</tbody>
<tfoot>
<tr>
<td colspan="3" class="rechts">Summe der Zeilen / Restbetrag</td>
<td class="rechts"><output id="splitdialogsumme" class="betrag">0,00</output></td>
</tr>
</tfoot>
</table>
<p class="feldhinweis">
Die Summe der Zeilen muss dem Buchungsbetrag entsprechen. Bleiben alle
Zeilen leer, wird die Splitbuchung aufgehoben.
</p>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Splitbuchung speichern</button>
<button type="button" class="knopf" id="splitzeileMehr">Zeile hinzufügen</button>
<button type="button" class="knopf" id="splitabbrechen">Abbrechen</button>
<?php /* Rechts außen, abseits der übrigen Knöpfe: hebt die Aufteilung
samt aller Zuordnungen auf. Nur sichtbar, wenn es etwas
aufzuheben gibt – kontoblatt.js blendet ihn ein. */ ?>
<button type="submit" class="knopf gefahr" id="splitloeschen"
name="aktion" value="loeschen" formnovalidate hidden>
Splitbuchung löschen
</button>
</div>
</form>
</dialog>
<script src="/static/betrag.js" defer></script>
<script src="/static/kontoblatt.js" defer></script>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,42 @@
<?php
use Finanzmanager\View;
/**
* Die Einträge der Liste „Kategorie oder Gegenkonto“ – ohne das umgebende
* <select>, damit dieselbe Liste in der Buchungsmaske, in den Splitzeilen und
* in der Schnellzuordnung des Kontoblatts steht.
*
* Kategorie und Gegenkonto teilen sich ein Feld, weil sie sich ausschließen:
* Eine Buchung ist entweder kategorisiert oder eine Umbuchung. Die Werte tragen
* deshalb ein Präfix (`kategorie:12`, `konto:7`), das der Controller in
* BuchungenController::zuordnungAus() wieder auseinandernimmt.
*
* @var list<array<string,mixed>> $kategorien Kategoriebaum, flach mit Pfad
* @var list<array<string,mixed>> $konten alle Konten des Mandanten
* @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)
*/
?>
<option value=""<?= ($gewaehlt ?? '') === '' ? ' selected' : '' ?>>— keine —</option>
<?php if (!empty($mitSplit)): ?>
<option value="split"<?= ($gewaehlt ?? '') === 'split' ? ' selected' : '' ?>>Splitbuchung …</option>
<?php endif; ?>
<optgroup label="Kategorien">
<?php foreach ($kategorien as $eintrag): ?>
<?php $wert = 'kategorie:' . (int)$eintrag['id']; ?>
<option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>>
<?= View::h($eintrag['pfad']) ?>
</option>
<?php endforeach; ?>
</optgroup>
<optgroup label="Umbuchung auf ein Konto">
<?php foreach ($konten as $eintrag): ?>
<?php if ((int)$eintrag['id'] === (int)$eigenesKonto) { continue; } ?>
<?php $wert = 'konto:' . (int)$eintrag['id']; ?>
<option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>>
[<?= View::h($eintrag['name']) ?>]
</option>
<?php endforeach; ?>
</optgroup>

View file

@ -10,6 +10,8 @@ use Finanzmanager\View;
/** @var string $stichtag */
/** @var string $bis */
/** @var string $datei */
/** @var array<string,int> $dateikonten */
/** @var list<string> $auswahl */
/** @var string $csrfToken */
$bereiche = [
@ -102,6 +104,12 @@ include __DIR__ . '/../layout/kopf.php';
<td class="nebentext">darunter Wertpapiergeschäfte (Depot- und Geldbuchung)</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['wertpapier'] ?></td>
</tr>
<?php if ((int)($buchungen['buchungen']['nicht_gewaehlt'] ?? 0) > 0): ?>
<tr>
<td class="nebentext">gehört zu keinem der gewählten Konten</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['nicht_gewaehlt'] ?></td>
</tr>
<?php endif; ?>
<tr>
<td>Fehler</td>
<td class="rechts <?= (int)$buchungen['buchungen']['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
@ -113,6 +121,12 @@ include __DIR__ . '/../layout/kopf.php';
<p class="hinweis">
Zeitraum <?= View::datum($buchungen['stichtag']) ?> bis <?= View::datum($buchungen['bis']) ?>.
Alles davor ist nicht verloren: es steckt im Anfangssaldo der Konten.
<?php if (($buchungen['konten'] ?? null) !== null): ?>
Übernommen wurden nur die Buchungen von
<strong><?= View::h(implode(', ', $buchungen['konten'])) ?></strong> – einschließlich
der Umbuchungen, die auf einem anderen Konto erfasst wurden und hierher zeigen.
Die Anfangssalden der übrigen Konten bleiben unangetastet.
<?php endif; ?>
</p>
<?php endif; ?>
@ -168,14 +182,41 @@ include __DIR__ . '/../layout/kopf.php';
<?php endif; ?>
<?php if ($ergebnis['trockenlauf']): ?>
<form method="post" action="/verwaltung/import/ausfuehren" class="inline">
<form method="post" action="/verwaltung/import/ausfuehren" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="datei" value="<?= View::h($datei) ?>">
<input type="hidden" name="modus" value="import">
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
<input type="hidden" name="bis" value="<?= View::h($bis) ?>">
<?php if ($dateikonten !== []): ?>
<h3>Konten</h3>
<p class="feldhinweis">
Zur Wahl stehen die Konten, die in dieser Datei vorkommen. Ohne Häkchen
zählt die ganze Datei. Eine Umbuchung kommt mit, sobald eines der beiden
Konten gewählt ist – sie steht im Export nur einmal, beim erfassenden
Konto. Die Zahl nennt die Einträge, die das Konto berühren.
</p>
<div class="auswahlliste">
<?php foreach ($dateikonten as $name => $anzahl): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="konten[]" value="<?= View::h((string)$name) ?>"
<?= $auswahl === [] || in_array((string)$name, $auswahl, true) ? 'checked' : '' ?>>
<span>
<?= View::h((string)$name) ?>
<span class="nebentext"><?= (int)$anzahl ?></span>
</span>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Jetzt übernehmen</button>
<button type="submit" name="modus" value="import" class="knopf primaer">Jetzt übernehmen</button>
<?php if ($dateikonten !== []): ?>
<button type="submit" name="modus" value="vorschau" class="knopf">
Trockenlauf mit dieser Auswahl
</button>
<?php endif; ?>
</div>
</form>
<?php else: ?>
@ -219,6 +260,12 @@ include __DIR__ . '/../layout/kopf.php';
Der Stichtag steht in den <a href="/einstellungen?bereich=import">Einstellungen</a>.
</p>
<p class="hinweis">
Soll nur ein Teil der Konten übernommen werden, zuerst den <strong>Trockenlauf</strong>
starten: danach steht fest, welche Konten die Datei führt, und sie stehen einzeln zur
Wahl. <strong>Importieren</strong> übernimmt die ganze Datei.
</p>
<?php if ($dateien === []): ?>
<p class="platzhalter">
Noch keine Datei in <code><?= View::h($verzeichnis) ?></code>.

View file

@ -11,6 +11,18 @@ use Finanzmanager\View;
* @var string $csrfToken
*/
// Nach dem Löschen soll dieselbe Ansicht wieder erscheinen – der Filter hängt
// deshalb am Ziel der Löschformulare.
$filterPfad = static function () use ($filter): string {
$query = array_filter([
'depot' => (string)($filter['konto_id'] ?? ''),
'wertpapier' => (string)($filter['wertpapier_id'] ?? ''),
'vorgang' => (string)($filter['vorgang'] ?? ''),
], static fn(string $wert): bool => $wert !== '');
return $query === [] ? '' : '?' . http_build_query($query);
};
include __DIR__ . '/../layout/kopf.php';
?>
@ -53,6 +65,20 @@ include __DIR__ . '/../layout/kopf.php';
</select>
<button type="submit" class="knopf klein">Filtern</button>
</form>
<?php if ($buchungen !== []): ?>
<!-- Wie im Kontoblatt: Das Formular steht hier, seine Ankreuzfelder
stehen in der Tabelle; verbunden sind sie über das form-Attribut.
Ein Formular um die ganze Tabelle würde das Filterformular
verschachteln. -->
<form method="post" action="/wertpapiere/buchungen/loeschen<?= View::h($filterPfad()) ?>"
id="depotbuchungsauswahl" class="inline"
data-bestaetigen="Die ausgewählten Depotbuchungen wirklich löschen? Die zugehörigen Geldbuchungen werden mitgelöscht."
data-frage-eine="Die ausgewählte Depotbuchung wirklich löschen? Die zugehörige Geldbuchung wird mitgelöscht."
data-frage-mehrere="{anzahl} ausgewählte Depotbuchungen wirklich löschen? Die zugehörigen Geldbuchungen werden mitgelöscht.">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf gefahr">Auswahl löschen</button>
</form>
<?php endif; ?>
<a href="/wertpapiere/buchungen/neu" class="knopf primaer">Depotbuchung erfassen</a>
</div>
@ -62,6 +88,10 @@ include __DIR__ . '/../layout/kopf.php';
<table class="liste zahlen">
<thead>
<tr>
<th class="wahlspalte">
<input type="checkbox" class="alle-waehlen" data-waehlt="depotbuchungsauswahl"
aria-label="Alle angezeigten Depotbuchungen auswählen">
</th>
<th>Datum</th><th>Vorgang</th><th>Wertpapier</th><th>Depot</th>
<th class="rechts">Stück</th><th class="rechts">Kurs</th>
<th class="rechts">Betrag</th><th class="rechts">Kosten</th>
@ -71,6 +101,11 @@ include __DIR__ . '/../layout/kopf.php';
<tbody>
<?php foreach ($buchungen as $buchung): ?>
<tr>
<td class="wahlspalte">
<input type="checkbox" name="buchungen[]" value="<?= (int)$buchung['id'] ?>"
form="depotbuchungsauswahl"
aria-label="Depotbuchung vom <?= View::h(View::datum($buchung['datum'])) ?> auswählen">
</td>
<td><?= View::h(View::datum($buchung['datum'])) ?></td>
<td>
<?= View::h(Depotbuchung::VORGAENGE[$buchung['vorgang']] ?? $buchung['vorgang']) ?>
@ -108,7 +143,8 @@ include __DIR__ . '/../layout/kopf.php';
<?php endif; ?>
</td>
<td class="rechts">
<form method="post" action="/wertpapiere/buchungen/<?= (int)$buchung['id'] ?>/loeschen"
<form method="post"
action="/wertpapiere/buchungen/<?= (int)$buchung['id'] ?>/loeschen<?= View::h($filterPfad()) ?>"
class="inline"
data-bestaetigen="Depotbuchung löschen? Die zugehörige Geldbuchung wird mitgelöscht.">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">

View file

@ -0,0 +1,221 @@
<?php
use Finanzmanager\View;
/**
* Import einer Depotbestandsliste (CSV der Bank): erst Vorschau, dann Übernahme.
*
* @var list<array<string,mixed>> $depots
* @var list<array<string,mixed>> $geldkonten
* @var array<string,mixed>|null $ergebnis
* @var string $datei
* @var int $depot
* @var int $verrechnungskonto
* @var string $datum
* @var string $csrfToken
*/
$stueckAnzeige = static fn(float $wert): string
=> rtrim(rtrim(number_format($wert, 6, ',', '.'), '0'), ',');
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a href="/wertpapiere" class="tab">Bestände</a>
<a href="/wertpapiere/buchungen" class="tab">Depotbuchungen</a>
<a href="/wertpapiere/stammdaten" class="tab">Wertpapiere</a>
<a href="/wertpapiere/auswertung?art=veraeusserungen" class="tab">Veräußerungen</a>
<a href="/wertpapiere/auswertung?art=ertraege" class="tab">Erträge</a>
</div>
<div class="seitenaktionen">
<a href="/wertpapiere" class="knopf">Zurück zu den Beständen</a>
</div>
<?php if ($ergebnis !== null): ?>
<div class="kachel">
<header>
<h2>Vorschau</h2>
<span class="phasenmarke"><?= View::h($datei) ?></span>
</header>
<p class="hinweis">
Nichts wurde gespeichert. Übernommen würde je Position ein <em>Kauf</em> zum
<strong><?= View::datum($ergebnis['datum']) ?></strong> über die volle Stückzahl
zum Einstiegskurs; der Bewertungskurs wird als Kurs dieses Tages hinterlegt.
<?php if ($ergebnis['depotnummer'] !== ''): ?>
Die Datei nennt die Depotnummer
<code><?= View::h($ergebnis['depotnummer']) ?></code>.
<?php endif; ?>
</p>
<table class="liste zahlen">
<thead>
<tr>
<th>Wertpapier</th>
<th>Art</th>
<th class="rechts">Stück</th>
<th class="rechts">Einstiegskurs</th>
<th class="rechts">Bewertungskurs</th>
<th class="rechts">Einstand</th>
<th class="rechts">Wert</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<?php foreach ($ergebnis['positionen'] as $position): ?>
<tr>
<td>
<?= View::h($position['name']) ?>
<span class="nebentext">
<?= View::h(trim($position['isin'] . ' · ' . $position['wkn'], ' ·')) ?>
</span>
</td>
<td><?= View::h($position['assetklasse']) ?></td>
<td class="rechts"><?= View::h($stueckAnzeige((float)$position['stueck'])) ?></td>
<td class="rechts"><?= View::h(number_format((float)$position['einstiegskurs'], 2, ',', '.')) ?></td>
<td class="rechts">
<?= (float)$position['bewertungskurs'] > 0.0
? View::h(number_format((float)$position['bewertungskurs'], 2, ',', '.'))
: '–' ?>
</td>
<td class="rechts"><?= View::betrag($position['einstand']) ?></td>
<td class="rechts"><?= $position['wert'] === null ? '–' : View::betrag($position['wert']) ?></td>
<td>
<?php if ($position['status'] === 'vorhanden'): ?>
<span class="nebentext">schon importiert</span>
<?php elseif ($position['status'] === 'fehler'): ?>
<span class="betrag negativ"><?= View::h($position['meldung']) ?></span>
<?php elseif ($position['wertpapier_neu']): ?>
neu – Wertpapier wird angelegt
<?php else: ?>
neu
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr class="summenzeile">
<td colspan="5">
<?= (int)$ergebnis['zaehler']['neu'] ?> neu ·
<?= (int)$ergebnis['zaehler']['vorhanden'] ?> schon vorhanden ·
<?= (int)$ergebnis['zaehler']['wertpapiereNeu'] ?> neue Wertpapiere ·
<?= (int)$ergebnis['zaehler']['kurse'] ?> Kurse
</td>
<td class="rechts"><?= View::betrag($ergebnis['einstand']) ?></td>
<td class="rechts"><?= View::betrag($ergebnis['wert']) ?></td>
<td></td>
</tr>
</tfoot>
</table>
<?php if (!empty($ergebnis['meldungen'])): ?>
<h3>Protokoll</h3>
<ul class="protokoll">
<?php foreach ($ergebnis['meldungen'] as $meldung): ?>
<li class="<?= View::h($meldung['typ']) ?>"><?= View::h($meldung['text']) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<form method="post" action="/wertpapiere/import" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="datei" value="<?= View::h($datei) ?>">
<label for="konto_id">Depot</label>
<select id="konto_id" name="konto_id" required>
<?php foreach ($depots as $eintrag): ?>
<option value="<?= (int)$eintrag['id'] ?>" <?= $depot === (int)$eintrag['id'] ? 'selected' : '' ?>>
<?= View::h($eintrag['name']) ?><?= trim((string)$eintrag['kontonummer']) !== ''
? ' (' . View::h($eintrag['kontonummer']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
<label for="datum">Buchungsdatum</label>
<input type="date" id="datum" name="datum" value="<?= View::h($datum) ?>" required>
<label for="verrechnungskonto_id">Verrechnungskonto <span class="optional">(optional)</span></label>
<select id="verrechnungskonto_id" name="verrechnungskonto_id">
<option value="">– keines, nur der Bestand –</option>
<?php foreach ($geldkonten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"
<?= $verrechnungskonto === (int)$konto['id'] ? 'selected' : '' ?>>
<?= View::h($konto['name']) ?>
</option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">
Ohne Verrechnungskonto entsteht keine Geldbuchung – das Geld ist vor Jahren
geflossen, eine Belastung zum heutigen Tag würde das Konto verfälschen.
</p>
<div class="knopfleiste">
<button type="submit" name="modus" value="import" class="knopf primaer">
Jetzt übernehmen
</button>
<button type="submit" name="modus" value="vorschau" class="knopf">
Vorschau neu berechnen
</button>
</div>
</form>
</div>
<?php endif; ?>
<div class="kachel">
<header><h2>Bestandsliste hochladen</h2></header>
<?php if ($depots === []): ?>
<p class="platzhalter">
Kein Depot vorhanden. Ein Depot ist ein Konto der Kontoart <em>Depot</em> –
anzulegen in der Kontenverwaltung.
</p>
<div class="knopfleiste">
<a href="/konten/neu" class="knopf primaer">Konto anlegen</a>
</div>
<?php else: ?>
<form method="post" action="/wertpapiere/import" enctype="multipart/form-data" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="modus" value="vorschau">
<label for="csv">CSV-Datei</label>
<input type="file" id="csv" name="csv" accept=".csv,.CSV" required>
<p class="feldhinweis">
Erwartet wird der Depotexport der Bank mit den Spalten Wertpapierbezeichnung,
WKN, ISIN, Einstiegskurs, Bewertungskurs, Stückzahl und Assetklasse.
</p>
<label for="konto_id_neu">Depot</label>
<select id="konto_id_neu" name="konto_id">
<option value="">– nach Depotnummer aus der Datei –</option>
<?php foreach ($depots as $eintrag): ?>
<option value="<?= (int)$eintrag['id'] ?>" <?= $depot === (int)$eintrag['id'] ? 'selected' : '' ?>>
<?= View::h($eintrag['name']) ?><?= trim((string)$eintrag['kontonummer']) !== ''
? ' (' . View::h($eintrag['kontonummer']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">
Ohne Auswahl sucht der Import das Depot, dessen Kontonummer der Depotnummer
in der Datei entspricht.
</p>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Vorschau</button>
<a href="/wertpapiere" class="knopf">Abbrechen</a>
</div>
</form>
<?php endif; ?>
</div>
<p class="hinweis">
Der Export enthält nur den Bestand eines Stichtags, keine Historie. Der Import legt
deshalb je Position einen Kauf an: Stückzahl und Einstiegskurs ergeben den Einstand,
der Bewertungskurs den aktuellen Wert. Frühere Käufe, Verkäufe und Dividenden fehlen
naturgemäß – sie lassen sich später über <a href="/wertpapiere/buchungen/neu">Depotbuchungen</a>
nachtragen. Ein zweiter Import derselben Datei legt nichts doppelt an.
</p>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -47,6 +47,7 @@ include __DIR__ . '/../layout/kopf.php';
</select>
<button type="submit" class="knopf klein">Anzeigen</button>
</form>
<a href="/wertpapiere/import" class="knopf">Import</a>
<a href="/wertpapiere/buchungen/neu" class="knopf primaer">Depotbuchung erfassen</a>
</div>
@ -93,7 +94,8 @@ include __DIR__ . '/../layout/kopf.php';
<?php if ($bestand['positionen'] === []): ?>
<p class="platzhalter">
Keine Bestände. Depotbuchungen kommen aus dem QIF-Import oder werden hier erfasst.
Keine Bestände. Depotbuchungen kommen aus dem QIF-Import, aus einer
<a href="/wertpapiere/import">Bestandsliste der Bank</a> oder werden hier erfasst.
</p>
<?php else: ?>
<table class="liste zahlen">

View file

@ -25,6 +25,8 @@ class QifBuchungsImportTest extends TestCase
$this->importMitStichtag();
$this->zweiterLaufAendertNichts();
$this->umbuchungsziel();
$this->kontenDerDatei();
$this->nurEinKonto();
}
private function beispiel(): string
@ -190,6 +192,66 @@ class QifBuchungsImportTest extends TestCase
$this->assertEquals($giro, (float)Konto::findeNachName('Giro')['anfangssaldo'], 'Anfangssaldo bleibt gleich');
}
private function kontenDerDatei(): void
{
$konten = QifBuchungsImport::kontenDerDatei(new QifParser($this->beispiel()));
$this->assertEquals(['Giro', 'Tagesgeld'], array_keys($konten), 'Beide Konten stehen zur Wahl');
$this->assertEquals(7, $konten['Giro'], 'Alle Einträge des Girokontos gezählt');
// Tagesgeld führt keine eigene Buchung: die drei Einträge zeigen von
// Giro aus hierher (zwei Umbuchungen, eine Splitzeile).
$this->assertEquals(3, $konten['Tagesgeld'], 'Ein reines Umbuchungsziel steht ebenfalls zur Wahl');
}
/**
* Teilimport: erst nur das Tagesgeldkonto, dann das Girokonto. Der zweite
* Lauf darf die Umbuchungen nicht ein zweites Mal anlegen – auch nicht mit
* anderer Kontoauswahl.
*/
private function nurEinKonto(): void
{
$this->mandantAnlegen();
$parser = new QifParser($this->beispiel());
(new QifStammdatenImport($parser))->ausfuehren();
$ergebnis = (new QifBuchungsImport($parser, [
'stichtag' => '2026-01-01',
'bis' => date('Y-m-d'),
'konten' => ['Tagesgeld'],
]))->ausfuehren();
$this->assertEquals(2, $ergebnis['buchungen']['neu'], 'Nur die beiden Buchungen mit Bezug zum Tagesgeld');
$this->assertEquals(1, $ergebnis['buchungen']['nicht_gewaehlt'], 'Der Supermarkt gehört allein zum Giro');
$this->assertEquals(0, $ergebnis['buchungen']['fehler'], 'Keine Fehler beim Teilimport');
$this->assertEquals(1, $ergebnis['anfangssalden']['konten'], 'Nur ein Anfangssaldo geschrieben');
$giro = Konto::findeNachName('Giro');
$tagesgeld = Konto::findeNachName('Tagesgeld');
$this->assertEquals('400.00', $tagesgeld['anfangssaldo'], 'Anfangssaldo des gewählten Kontos');
$this->assertEquals('0.00', $giro['anfangssaldo'], 'Das nicht gewählte Konto bleibt unangetastet');
$this->assertEquals(850.0, round(Konto::saldo((int)$tagesgeld['id']), 2), 'Tagesgeldsaldo aus den Gegensichten');
// Zweiter Lauf, diesmal das Girokonto: Die Umbuchungen sind schon da –
// ihr Fingerabdruck darf nicht von der Auswahl abhängen.
$ergebnis = (new QifBuchungsImport($parser, [
'stichtag' => '2026-01-01',
'bis' => date('Y-m-d'),
'konten' => ['Giro'],
]))->ausfuehren();
$this->assertEquals(1, $ergebnis['buchungen']['neu'], 'Nur der Supermarkt fehlte noch');
$this->assertEquals(2, $ergebnis['buchungen']['vorhanden'], 'Die Umbuchungen waren schon da');
$this->assertEquals(3, Buchung::anzahl(), 'Keine Buchung doppelt angelegt');
$this->assertEquals(
'500.00',
Konto::findeNachName('Giro')['anfangssaldo'],
'Jetzt bekommt auch das Girokonto seinen Anfangssaldo'
);
$this->assertEquals(-99.90, round(Konto::saldo((int)$giro['id']), 2), 'Girosaldo wie beim Vollimport');
}
private function umbuchungsziel(): void
{
$this->assertEquals('DKB Girokonto', QifBuchungsImport::umbuchungsziel('[DKB Girokonto]'), 'Einfache Umbuchung');

View file

@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\Depot;
use Finanzmanager\Services\DepotbestandImport;
/**
* Import einer Depotbestandsliste im CSV-Format der Bank: Kurse, Einstand,
* Dublettenschutz und die Zuordnung des Depots über die Depotnummer.
*/
class DepotbestandImportTest extends TestCase
{
protected string $testName = 'Test 21: Depotbestand aus CSV';
private int $depot = 0;
private int $verrechnung = 0;
/** Ein Ausschnitt im Format des echten DKB-Exports – samt BOM. */
private const CSV = "\xEF\xBB\xBF"
. "Datum der Erstellung;Depotnummer;Wertpapierbezeichnung;WKN;ISIN;Einstiegskurs;"
. "Bewertungskurs;Stückzahl;Absoluter Gewinn;Relativer Gewinn;Assetklasse\r\n"
. "12.08.2026;502022353;ALLIANZ SE NA O.N.;840400;DE0008404005;\"224,60 €\";"
. "\"437,30 €\";60;\"12.762,10 €\";94.7%;Aktien\r\n"
. "12.08.2026;502022353;MUL-AMUN ST600 BANK ETF A;LYX01W;LU1834983477;\"58,36 €\";"
. "\"75,19 €\";\"55,6863\";\"937,05 €\";28.83%;ETFs\r\n"
. "12.08.2026;502022353;\"META PLATF. A DL-,000006\";A1JWVX;US30303M1027;\"648,33 €\";"
. "\"522,50 €\";3;\"-377,50 €\";-19.41%;Aktien\r\n";
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->vorschauLegtNichtsAn();
$this->importLegtBestandUndKurseAn();
$this->zweiterLaufLegtNichtsDoppeltAn();
$this->depotWirdUeberDieDepotnummerGefunden();
$this->verrechnungskontoIstFreiwillig();
$this->fremdeDateiWirdAbgewiesen();
$this->ausDateiLiestVomDatentraeger();
}
private function stammdaten(): void
{
$this->depot = Konto::anlegen([
'name' => 'CSV-Depot', 'kontoart' => 'depot', 'kontonummer' => '502022353',
'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->verrechnung = Konto::anlegen([
'name' => 'CSV-Verrechnung', 'kontoart' => 'bank', 'anfangssaldo' => 5000, 'aktiv' => 1,
]);
}
private function import(array $optionen = []): DepotbestandImport
{
return new DepotbestandImport(self::CSV, $optionen + ['konto_id' => $this->depot]);
}
private function vorschauLegtNichtsAn(): void
{
$ergebnis = $this->import()->ausfuehren(true);
$this->assertEquals(3, $ergebnis['zaehler']['neu'], 'Drei Positionen in der Vorschau');
$this->assertEquals(3, $ergebnis['zaehler']['wertpapiereNeu'], 'Drei Wertpapiere wären neu');
$this->assertEquals('2026-08-12', $ergebnis['datum'], 'Der Stichtag kommt aus der Datei');
$this->assertEquals('502022353', $ergebnis['depotnummer'], 'Die Depotnummer wird gelesen');
$this->assertEquals(0, Depotbuchung::anzahl(), 'Der Trockenlauf hat nichts gespeichert');
$this->assertEquals(0, Wertpapier::anzahl(), 'Und auch kein Wertpapier angelegt');
// 60 × 224,60 + 55,6863 × 58,36 + 3 × 648,33 = 13.476,00 + 3.249,85 + 1.944,99.
$this->assertEquals(18670.84, $ergebnis['einstand'], 'Einstand aus Stück × Einstiegskurs');
// 60 × 437,30 + 55,6863 × 75,19 + 3 × 522,50 = 26.238,00 + 4.187,05 + 1.567,50.
$this->assertEquals(31992.55, $ergebnis['wert'], 'Wert aus Stück × Bewertungskurs');
}
private function importLegtBestandUndKurseAn(): void
{
$ergebnis = $this->import()->ausfuehren();
$this->assertEquals(3, $ergebnis['zaehler']['neu'], 'Drei Positionen übernommen');
$this->assertEquals(3, $ergebnis['zaehler']['kurse'], 'Drei Bewertungskurse hinterlegt');
$this->assertEquals(0, $ergebnis['zaehler']['fehler'], 'Keine Fehler');
$buchungen = Depotbuchung::liste(['konto_id' => $this->depot]);
$this->assertEquals(3, count($buchungen), 'Drei Depotbuchungen');
foreach ($buchungen as $buchung) {
$this->assertEquals('kauf', (string)$buchung['vorgang'], 'Alles wird als Kauf gebucht');
$this->assertEquals('2026-08-12', (string)$buchung['datum'], 'Und zwar zum Stichtag');
$this->assertNull($buchung['buchung_id'], 'Ohne Verrechnungskonto keine Geldbuchung');
}
// Bruchstückzahlen kommen mit Komma und dürfen nicht auf ganze Stücke fallen.
$etf = Wertpapier::finde('LU1834983477');
$this->assertNotEmpty($etf, 'Der ETF wurde angelegt');
$this->assertEquals('fonds', (string)$etf['art'], 'Assetklasse ETFs wird zur Art Fonds');
$bestand = Depot::bestand($this->depot, '2026-08-12');
$this->assertEquals(3, count($bestand['positionen']), 'Drei Positionen im Bestand');
$this->assert($bestand['bewertbar'], 'Der Bestand ist sofort bewertbar');
$this->assertEquals(18670.84, $bestand['einstand'], 'Der Einstand entspricht dem Export');
$this->assertEquals(31992.55, $bestand['wert'], 'Der Wert ebenso');
// Die Bank weist in Summe 13.321,65 € aus – die Differenz von sechs Cent
// steckt in den auf zwei Stellen gerundeten Kursen des Exports.
$this->assertEquals(13321.71, $bestand['gewinn'], 'Und damit auch der Gewinn');
$allianz = Wertpapier::finde('DE0008404005');
$kurs = Wertpapier::letzterKurs((int)$allianz['id'], '2026-08-12');
$this->assertEquals(437.30, (float)$kurs['kurs'], 'Der Bewertungskurs steht als Kurs des Stichtags');
$position = null;
foreach ($bestand['positionen'] as $eintrag) {
$position = $eintrag['isin'] === 'LU1834983477' ? $eintrag : $position;
}
$this->assertEquals(55.6863, (float)$position['stueck'], 'Bruchstücke bleiben erhalten');
}
private function zweiterLaufLegtNichtsDoppeltAn(): void
{
$ergebnis = $this->import()->ausfuehren();
$this->assertEquals(0, $ergebnis['zaehler']['neu'], 'Der zweite Lauf legt nichts an');
$this->assertEquals(3, $ergebnis['zaehler']['vorhanden'], 'Alle drei sind schon da');
$this->assertEquals(3, count(Depotbuchung::liste(['konto_id' => $this->depot])), 'Es bleiben drei');
// Ein anderer Stichtag ist ein anderer Vorgang – dann darf gebucht werden.
$spaeter = $this->import(['datum' => '2026-08-13'])->ausfuehren(true);
$this->assertEquals(3, $spaeter['zaehler']['neu'], 'Zu einem anderen Datum wäre alles neu');
$this->assertEquals(0, $spaeter['zaehler']['wertpapiereNeu'], 'Die Wertpapiere gibt es aber schon');
}
private function depotWirdUeberDieDepotnummerGefunden(): void
{
$passend = DepotbestandImport::passendesDepot('502022353');
$this->assertEquals($this->depot, (int)$passend['id'], 'Die Kontonummer führt zum Depot');
$this->assertNull(DepotbestandImport::passendesDepot('999'), 'Eine fremde Nummer findet nichts');
$this->assertNull(DepotbestandImport::passendesDepot(''), 'Ohne Nummer wird nicht geraten');
}
private function verrechnungskontoIstFreiwillig(): void
{
$zweitesDepot = Konto::anlegen([
'name' => 'CSV-Depot mit Geldseite', 'kontoart' => 'depot', 'aktiv' => 1,
]);
(new DepotbestandImport(self::CSV, [
'konto_id' => $zweitesDepot,
'verrechnungskonto_id' => $this->verrechnung,
]))->ausfuehren();
$buchungen = Depotbuchung::liste(['konto_id' => $zweitesDepot]);
$this->assertEquals(3, count($buchungen), 'Auch hier drei Depotbuchungen');
$this->assertNotEmpty($buchungen[0]['buchung_id'], 'Diesmal mit Geldbuchung');
// Der volle Einstand belastet das Verrechnungskonto.
$saldo = Konto::saldo($this->verrechnung, '2026-08-12');
$this->assertEquals(5000.0 - 18670.84, round($saldo, 2), 'Der Einstand wurde abgebucht');
}
private function fremdeDateiWirdAbgewiesen(): void
{
$fehler = '';
try {
new DepotbestandImport("Datum;Empfänger;Betrag\n01.01.2026;Miete;-800,00\n", [
'konto_id' => $this->depot,
]);
} catch (\Throwable $e) {
$fehler = $e->getMessage();
}
$this->assert(str_contains($fehler, 'Depotbestandsliste'), 'Eine Umsatzliste wird erkannt');
$fehler = '';
try {
new DepotbestandImport('', ['konto_id' => $this->depot]);
} catch (\Throwable $e) {
$fehler = $e->getMessage();
}
$this->assert($fehler !== '', 'Eine leere Datei ebenso');
// Ohne gültiges Depot wird gar nicht erst gelesen.
$fehler = '';
try {
(new DepotbestandImport(self::CSV, ['konto_id' => 0]))->ausfuehren(true);
} catch (\Throwable $e) {
$fehler = $e->getMessage();
}
$this->assert(str_contains($fehler, 'Depot'), 'Ohne Depot bricht der Import ab');
}
private function ausDateiLiestVomDatentraeger(): void
{
$pfad = $this->tempPfad('.csv');
file_put_contents($pfad, self::CSV);
$ergebnis = DepotbestandImport::ausDatei($pfad, ['konto_id' => $this->depot])->ausfuehren(true);
$this->assertEquals(3, count($ergebnis['positionen']), 'Auch aus der Datei kommen drei Positionen');
}
}

View file

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\Depot;
/**
* Sammellöschen in der Liste der Depotbuchungen.
*
* Geprüft wird, was hinter dem Knopf *Auswahl löschen* passiert: Jede gewählte
* Depotbuchung geht samt ihrer Geldbuchung, und Bestand, realisierter Gewinn
* und Erträge stehen danach wieder auf dem Stand, den sie ohne die Buchungen
* hätten – der Bestand wird gerechnet, nicht fortgeschrieben.
*/
class DepotbuchungenLoeschenTest extends TestCase
{
protected string $testName = 'Test 22: Depotbuchungen sammelweise löschen';
private int $depot = 0;
private int $verrechnung = 0;
private int $papier = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->auswahlNimmtDieGeldbuchungenMit();
$this->bestandGibtDieLoeschungWiederHer();
$this->unbekannteNummernBleibenFolgenlos();
}
private function stammdaten(): void
{
$this->depot = Konto::anlegen([
'name' => 'Sammeldepot', 'kontoart' => 'depot', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->verrechnung = Konto::anlegen([
'name' => 'Sammelverrechnungskonto', 'kontoart' => 'bank',
'anfangssaldo' => 10000, 'aktiv' => 1,
]);
$this->papier = Wertpapier::findenOderAnlegen([
'name' => 'Sammelaktie AG', 'isin' => 'DE0007654321', 'art' => 'aktie',
]);
}
/**
* Zwei Käufe, ein Verkauf, eine Dividende – wie eine Auswahl in der Liste.
*
* @return list<int>
*/
private function vierBuchungen(): array
{
return [
Depotbuchung::anlegen([
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
'datum' => '2026-01-15', 'vorgang' => 'kauf',
'stueck' => 10, 'kurs' => 100, 'betrag' => 1000, 'gebuehren' => 10,
'verrechnungskonto_id' => $this->verrechnung,
]),
Depotbuchung::anlegen([
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
'datum' => '2026-02-15', 'vorgang' => 'kauf',
'stueck' => 10, 'kurs' => 120, 'betrag' => 1200, 'gebuehren' => 10,
'verrechnungskonto_id' => $this->verrechnung,
]),
Depotbuchung::anlegen([
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
'datum' => '2026-03-15', 'vorgang' => 'verkauf',
'stueck' => 5, 'kurs' => 150, 'betrag' => 750,
'gebuehren' => 15, 'steuern' => 20,
'verrechnungskonto_id' => $this->verrechnung,
]),
Depotbuchung::anlegen([
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
'datum' => '2026-04-01', 'vorgang' => 'dividende',
'betrag' => 50, 'steuern' => 13.19,
'verrechnungskonto_id' => $this->verrechnung,
]),
];
}
/** So arbeitet der Controller die Auswahl ab: Nummer für Nummer. */
private function auswahlLoeschen(array $ids): int
{
$anzahl = 0;
foreach ($ids as $id) {
if (Depotbuchung::find((int)$id) === null) {
continue;
}
Depotbuchung::loeschen((int)$id);
$anzahl++;
}
return $anzahl;
}
private function auswahlNimmtDieGeldbuchungenMit(): void
{
$ids = $this->vierBuchungen();
$geldIds = [];
foreach ($ids as $id) {
$geldIds[] = (int)Depotbuchung::find($id)['buchung_id'];
}
$this->assertEquals(4, count(array_filter($geldIds)), 'Jede Depotbuchung hat ihre Geldbuchung');
// Die beiden Käufe auswählen – der Rest bleibt stehen.
$this->assertEquals(2, $this->auswahlLoeschen([$ids[0], $ids[1]]), 'Zwei Buchungen gelöscht');
$this->assertNull(Depotbuchung::find($ids[0]), 'Der erste Kauf ist weg');
$this->assertNull(Depotbuchung::find($ids[1]), 'Der zweite Kauf auch');
$this->assertNotEmpty(Depotbuchung::find($ids[2]), 'Der Verkauf steht noch');
$this->assertNotEmpty(Depotbuchung::find($ids[3]), 'Die Dividende ebenso');
$this->assertNull(Buchung::find($geldIds[0]), 'Die Geldbuchung des ersten Kaufs ging mit');
$this->assertNull(Buchung::find($geldIds[1]), 'Die des zweiten Kaufs ebenso');
$this->assertNotEmpty(Buchung::find($geldIds[2]), 'Die des Verkaufs blieb unberührt');
// 10000 − 1010 − 1210 + 715 + 36,81; nach dem Löschen der Käufe fehlen
// deren Belastungen wieder.
$this->assertEquals(
10000.0 + 715.0 + 36.81,
Konto::saldo($this->verrechnung, '2026-12-31'),
'Das Verrechnungskonto trägt nur noch Verkauf und Dividende'
);
// Aufräumen für den nächsten Abschnitt.
$this->auswahlLoeschen([$ids[2], $ids[3]]);
}
private function bestandGibtDieLoeschungWiederHer(): void
{
Wertpapier::kursSetzen($this->papier, '2026-06-30', 130.0, 'test');
$leer = Depot::bestand($this->depot, '2026-12-31');
$this->assertEquals(0, count($leer['positionen']), 'Vorher ist das Depot leer');
$ids = $this->vierBuchungen();
$voll = Depot::bestand($this->depot, '2026-12-31');
$this->assertEquals(15.0, $voll['positionen'][0]['stueck'], 'Fünfzehn Stück nach zwei Käufen und einem Verkauf');
$this->assertGreaterThan(0.0, $voll['realisiert'], 'Der Verkauf hat etwas realisiert');
$this->assertEquals(36.81, $voll['ertraege'], 'Die Dividende zählt als Ertrag');
// Verkauf und Dividende zurücknehmen: Der Bestand steht wieder auf den
// beiden Käufen, realisierter Gewinn und Erträge fallen auf null.
$this->assertEquals(2, $this->auswahlLoeschen([$ids[2], $ids[3]]), 'Zwei Buchungen gelöscht');
$nachher = Depot::bestand($this->depot, '2026-12-31');
$this->assertEquals(20.0, $nachher['positionen'][0]['stueck'], 'Die verkauften Stücke sind zurück');
$this->assertEquals(2220.0, $nachher['positionen'][0]['einstand'], 'Und der volle Einstand beider Käufe');
$this->assertEquals(2600.0, $nachher['wert'], 'Zwanzig Stück zu 130 €');
$this->assertEquals(0.0, $nachher['realisiert'], 'Ohne Verkauf ist nichts realisiert');
$this->assertEquals(0.0, $nachher['ertraege'], 'Ohne Dividende gibt es keinen Ertrag');
$veraeusserungen = Depot::veraeusserungen('2026-01-01', '2026-12-31', $this->depot);
$this->assertEquals(0, count($veraeusserungen['geschaefte']), 'Kein Veräußerungsgeschäft mehr');
$this->assertEquals(0.0, $veraeusserungen['gewinn'], 'Und kein Gewinn in der Auswertung');
$this->auswahlLoeschen([$ids[0], $ids[1]]);
}
private function unbekannteNummernBleibenFolgenlos(): void
{
$id = $this->vierBuchungen()[0];
// Eine Nummer, die es nicht (mehr) gibt, wird übergangen – der Rest der
// Auswahl verschwindet trotzdem.
$this->assertEquals(1, $this->auswahlLoeschen([999999, $id]), 'Nur die vorhandene Buchung zählt');
$this->assertNull(Depotbuchung::find($id), 'Und sie ist gelöscht');
}
}

275
tests/23_ZuordnungTest.php Normal file
View file

@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Monatsabschluss;
/**
* Die Schnellzuordnung aus dem Kontoblatt: Kategorie oder Gegenkonto in einem
* Feld, Splitzeilen aus dem Dialog.
*/
class ZuordnungTest extends TestCase
{
protected string $testName = 'Test 23: Zuordnung im Kontoblatt';
private int $giro = 0;
private int $spar = 0;
private int $kategorie = 0;
private int $klasse = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->auswahlwerteLesen();
$this->kategorieUndGegenkontoWechseln();
$this->gegensichtNenntIhrKonto();
$this->splitzeilenErsetzen();
$this->splitbuchungLoeschen();
$this->splitzeilenJeBuchung();
$this->pruefungen();
}
private function stammdaten(): void
{
$this->giro = Konto::anlegen([
'name' => 'Zuordnung Giro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1,
]);
$this->spar = Konto::anlegen([
'name' => 'Zuordnung Spar', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->kategorie = Kategorie::anlegen(['name' => 'Zuordnung Einkauf', 'art' => 'ausgabe']);
$this->klasse = Klasse::anlegen('Zuordnung Privat');
}
private function buchung(float $betrag = -50.0, string $datum = '2026-03-10'): int
{
return Buchung::anlegen([
'konto_id' => $this->giro,
'datum' => $datum,
'betrag' => $betrag,
'empfaenger' => 'Noch zuzuordnen',
]);
}
/** Kategorie und Gegenkonto stehen in einem Feld – hier wird es gelesen. */
private function auswahlwerteLesen(): void
{
$this->assertEquals(
['kategorie_id' => 12, 'gegenkonto_id' => null],
BuchungenController::zuordnungAus('kategorie:12'),
'Eine Kategorie wird erkannt'
);
$this->assertEquals(
['kategorie_id' => null, 'gegenkonto_id' => 7],
BuchungenController::zuordnungAus('konto:7'),
'Ein Gegenkonto wird erkannt'
);
$leer = ['kategorie_id' => null, 'gegenkonto_id' => null];
$this->assertEquals($leer, BuchungenController::zuordnungAus(''), 'Leer heißt keine Zuordnung');
$this->assertEquals($leer, BuchungenController::zuordnungAus('split'), 'Splitbuchung ist keine Zuordnung');
$this->assertEquals($leer, BuchungenController::zuordnungAus('konto:0'), 'Kennung 0 zählt nicht');
$this->assertEquals($leer, BuchungenController::zuordnungAus('unfug:3'), 'Unbekanntes Präfix zählt nicht');
}
private function kategorieUndGegenkontoWechseln(): void
{
$id = $this->buchung();
Buchung::zuordnen($id, $this->kategorie, null);
$buchung = Buchung::find($id);
$this->assertEquals($this->kategorie, (int)$buchung['kategorie_id'], 'Die Kategorie steht in der Buchung');
$this->assertNull($buchung['gegenkonto_id'], 'Ohne Gegenkonto');
// Aus der Kategorie wird eine Umbuchung: beides zugleich gibt es nicht.
Buchung::zuordnen($id, $this->kategorie, $this->spar);
$buchung = Buchung::find($id);
$this->assertEquals($this->spar, (int)$buchung['gegenkonto_id'], 'Das Gegenkonto steht in der Buchung');
$this->assertNull($buchung['kategorie_id'], 'Die Kategorie weicht dem Gegenkonto');
// Die Umbuchung wirkt sofort auf beiden Seiten.
$this->assertEquals(50.0, Konto::saldo($this->spar), 'Das Gegenkonto bekommt die Gegenseite');
Buchung::zuordnen($id, null, null);
$buchung = Buchung::find($id);
$this->assertNull($buchung['kategorie_id'], 'Die Zuordnung lässt sich wieder leeren');
$this->assertNull($buchung['gegenkonto_id'], 'Auch das Gegenkonto');
$this->assertEquals(0.0, Konto::saldo($this->spar), 'Und das Gegenkonto ist den Betrag wieder los');
}
/**
* Der Anzeigefehler, um den es ging: Auf dem Gegenkonto stand eine Umbuchung
* ohne erkennbares Gegenüber. Die Zeile muss das Ursprungskonto nennen.
*/
private function gegensichtNenntIhrKonto(): void
{
$id = $this->buchung(-120.0, '2026-03-11');
Buchung::zuordnen($id, null, $this->spar);
$zeilen = array_values(array_filter(
Buchung::kontoblatt($this->spar),
static fn(array $zeile): bool => (int)$zeile['id'] === $id
));
$this->assertEquals(1, count($zeilen), 'Die Umbuchung erscheint auf dem Gegenkonto');
$this->assert($zeilen[0]['gegensicht'], 'Und zwar als Gegensicht');
$this->assertEquals(
$this->giro,
(int)$zeilen[0]['gegenkonto_id'],
'Die Gegensicht nennt das Konto, von dem die Umbuchung kommt'
);
$this->assertEquals(120.0, (float)$zeilen[0]['betrag'], 'Mit umgekehrtem Vorzeichen');
Buchung::loeschen($id);
}
private function splitzeilenErsetzen(): void
{
$id = $this->buchung(-100.0, '2026-03-12');
Buchung::zuordnen($id, $this->kategorie, null);
Buchung::splitsErsetzen($id, [
['kategorie_id' => $this->kategorie, 'klasse_id' => $this->klasse, 'betrag' => -60.0, 'notiz' => 'Einkauf'],
['gegenkonto_id' => $this->spar, 'betrag' => -40.0, 'notiz' => 'Rücklage'],
]);
$buchung = Buchung::find($id);
$this->assertEquals(1, (int)$buchung['gesplittet'], 'Die Buchung ist jetzt gesplittet');
$this->assertNull($buchung['kategorie_id'], 'Die Kategorie von oben weicht den Zeilen');
$splits = Buchung::splits($id);
$this->assertEquals(2, count($splits), 'Zwei Zeilen sind gespeichert');
$this->assertEquals($this->klasse, (int)$splits[0]['klasse_id'], 'Die Klasse der ersten Zeile');
$this->assertEquals($this->spar, (int)$splits[1]['gegenkonto_id'], 'Das Gegenkonto der zweiten Zeile');
$this->assertEquals(40.0, Konto::saldo($this->spar), 'Nur der Betrag dieser Zeile geht aufs Gegenkonto');
// Eine einzelne Zuordnung hebt die Aufteilung auf.
Buchung::zuordnen($id, $this->kategorie, null);
$this->assertEquals(0, (int)Buchung::find($id)['gesplittet'], 'Die Splitbuchung ist aufgehoben');
$this->assertEquals([], Buchung::splits($id), 'Und die Zeilen sind weg');
$this->assertEquals(0.0, Konto::saldo($this->spar), 'Das Gegenkonto ist den Anteil wieder los');
// Ohne Zeilen bleibt eine Buchung ohne Zuordnung stehen.
Buchung::splitsErsetzen($id, [
['kategorie_id' => $this->kategorie, 'betrag' => -100.0, 'notiz' => ''],
]);
Buchung::splitsErsetzen($id, []);
$buchung = Buchung::find($id);
$this->assertEquals(0, (int)$buchung['gesplittet'], 'Leere Zeilen heben die Splitbuchung auf');
$this->assertNull($buchung['kategorie_id'], 'Die Buchung steht danach ohne Zuordnung da');
$this->assertEquals(-100.0, (float)$buchung['betrag'], 'Der Buchungsbetrag bleibt unberührt');
Buchung::loeschen($id);
}
/**
* „Splitbuchung löschen“ aus dem Splitdialog: Die Aufteilung fällt weg und
* mit ihr jede Zuordnung – auch die einer Zeile mit Gegenkonto, deren
* Gegenseite auf dem anderen Konto zu verschwinden hat.
*/
private function splitbuchungLoeschen(): void
{
$id = $this->buchung(-90.0, '2026-03-16');
Buchung::splitsErsetzen($id, [
['kategorie_id' => $this->kategorie, 'klasse_id' => $this->klasse, 'betrag' => -55.0, 'notiz' => 'Teil A'],
['gegenkonto_id' => $this->spar, 'betrag' => -35.0, 'notiz' => 'Teil B'],
]);
$this->assertEquals(35.0, Konto::saldo($this->spar), 'Die Zeile mit Gegenkonto wirkt zunächst');
Buchung::splitsErsetzen($id, []);
$buchung = Buchung::find($id);
$this->assertEquals([], Buchung::splits($id), 'Alle Splitzeilen sind gelöscht');
$this->assertEquals(0, (int)$buchung['gesplittet'], 'Die Buchung gilt nicht mehr als gesplittet');
$this->assertNull($buchung['kategorie_id'], 'Sie hat keine Kategorie');
$this->assertNull($buchung['gegenkonto_id'], 'Und kein Gegenkonto');
$this->assertEquals(-90.0, (float)$buchung['betrag'], 'Die Buchung selbst bleibt stehen');
$this->assertEquals(0.0, Konto::saldo($this->spar), 'Das Gegenkonto ist seinen Anteil wieder los');
Buchung::loeschen($id);
}
private function splitzeilenJeBuchung(): void
{
$eine = $this->buchung(-30.0, '2026-03-13');
$keine = $this->buchung(-20.0, '2026-03-14');
Buchung::splitsErsetzen($eine, [
['kategorie_id' => $this->kategorie, 'betrag' => -10.0, 'notiz' => 'A'],
['kategorie_id' => $this->kategorie, 'betrag' => -20.0, 'notiz' => 'B'],
]);
$jeBuchung = Buchung::splitsJeBuchung([$eine, $keine]);
$this->assertEquals(2, count($jeBuchung[$eine]), 'Die Zeilen kommen der Buchung zugeordnet zurück');
$this->assert(!isset($jeBuchung[$keine]), 'Eine Buchung ohne Zeilen fehlt in der Liste');
$this->assertEquals([], Buchung::splitsJeBuchung([]), 'Ohne Kennungen wird nicht abgefragt');
Buchung::loeschen($eine);
Buchung::loeschen($keine);
}
private function pruefungen(): void
{
$id = $this->buchung(-80.0, '2026-03-15');
$aufSichSelbst = false;
try {
Buchung::zuordnen($id, null, $this->giro);
} catch (\Throwable) {
$aufSichSelbst = true;
}
$this->assert($aufSichSelbst, 'Ein Konto kann nicht sein eigenes Gegenkonto sein');
$splitAufSichSelbst = false;
try {
Buchung::splitsErsetzen($id, [['gegenkonto_id' => $this->giro, 'betrag' => -80.0]]);
} catch (\Throwable) {
$splitAufSichSelbst = true;
}
$this->assert($splitAufSichSelbst, 'Auch eine Splitzeile nicht');
$krummeSumme = false;
try {
Buchung::splitsErsetzen($id, [['kategorie_id' => $this->kategorie, 'betrag' => -30.0]]);
} catch (\Throwable) {
$krummeSumme = true;
}
$this->assert($krummeSumme, 'Zeilen, die nicht aufgehen, werden abgewiesen');
$this->assertEquals(0, (int)Buchung::find($id)['gesplittet'], 'Und die Buchung bleibt ungesplittet');
$fremdeKategorie = false;
try {
Buchung::zuordnen($id, 999999, null);
} catch (\Throwable) {
$fremdeKategorie = true;
}
$this->assert($fremdeKategorie, 'Eine Kategorie, die es nicht gibt, wird abgewiesen');
// Ein abgeschlossener Monat ist auch für die Schnellzuordnung zu.
Monatsabschluss::abschliessen(2026, 3);
$zu = false;
try {
Buchung::zuordnen($id, $this->kategorie, null);
} catch (\Throwable) {
$zu = true;
}
$this->assert($zu, 'Im abgeschlossenen Monat wird nichts mehr zugeordnet');
$zuFuerSplits = false;
try {
Buchung::splitsErsetzen($id, []);
} catch (\Throwable) {
$zuFuerSplits = true;
}
$this->assert($zuFuerSplits, 'Auch die Splitzeilen bleiben, wie sie sind');
}
}

View file

@ -72,7 +72,7 @@ test.describe('Buchungen', () => {
await page.fill('#datum', '2026-03-18')
await page.fill('#betrag', '49,90')
await page.fill('#empfaenger', 'Supermarkt')
await page.selectOption('#kategorie_id', { label: KATEGORIE })
await page.selectOption('#zuordnung', { label: KATEGORIE })
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
@ -86,11 +86,13 @@ test.describe('Buchungen', () => {
test('Umbuchung erscheint auf beiden Kontoblättern', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.selectOption('#art', 'umbuchung_ab')
// Die Umbuchung entsteht im Kategoriefeld: Unter den Kategorien stehen
// die eigenen Konten in eckigen Klammern.
await page.selectOption('#art', 'ausgabe')
await page.fill('#datum', '2026-04-01')
await page.fill('#betrag', '500')
await page.fill('#empfaenger', `Übertrag ${MARKE}`)
await page.selectOption('#gegenkonto_id', { label: SPAR })
await page.selectOption('#zuordnung', { label: `[${SPAR}]` })
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
@ -100,12 +102,135 @@ test.describe('Buchungen', () => {
await expect(hin).toContainText('-500,00')
await expect(hin).toContainText(`[${SPAR}]`)
// Gegenkonto: derselbe Vorgang mit umgekehrtem Vorzeichen.
// Gegenkonto: derselbe Vorgang mit umgekehrtem Vorzeichen – und mit dem
// Konto, von dem er kommt. Ohne diese Angabe stünde dort eine Umbuchung
// ohne erkennbares Gegenüber.
await page.goto(`/konten/${sparId}`)
const her = page.locator('tr', { hasText: `Übertrag ${MARKE}` })
await expect(her).toContainText('500,00')
await expect(her).toContainText('Umbuchung')
await expect(her).toContainText(`[${GIRO}]`)
await expect(page.locator('.saldoanzeige').first()).toContainText('5.500,00')
// Auch die Gegenseite ist anklickbar: Sie führt zu ihrer Buchung, die
// auf dem anderen Konto liegt und nur dort geändert wird.
await her.locator('a.zuordnungsfeld.gegenseite').click()
await expect(page).toHaveURL(/\/buchungen\/\d+/)
await expect(page.locator('h1')).toContainText('Buchung bearbeiten')
await expect(page.locator('#zuordnung')).toHaveValue(/^konto:\d+$/)
})
test('Kategorie im Kontoblatt zuordnen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-04-03')
await page.fill('#betrag', '12,30')
await page.fill('#empfaenger', `Ohne Kategorie ${MARKE}`)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
const zeile = page.locator('tr', { hasText: `Ohne Kategorie ${MARKE}` })
await zeile.locator('a.zuordnungsfeld').click()
// Die Auswahlliste gibt es einmal und wandert in die angeklickte Zelle.
const liste = zeile.locator('#zuordnungsauswahl select')
await expect(liste).toBeVisible()
await liste.selectOption({ label: KATEGORIE })
await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung')
await expect(page.locator('tr', { hasText: `Ohne Kategorie ${MARKE}` }))
.toContainText(KATEGORIE)
})
test('Umbuchung im Kontoblatt zuordnen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-04-04')
await page.fill('#betrag', '75')
await page.fill('#empfaenger', `Nachträglich ${MARKE}`)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
const zeile = page.locator('tr', { hasText: `Nachträglich ${MARKE}` })
await zeile.locator('a.zuordnungsfeld').click()
await zeile.locator('#zuordnungsauswahl select').selectOption({ label: `[${SPAR}]` })
await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung')
await expect(page.locator('tr', { hasText: `Nachträglich ${MARKE}` }))
.toContainText(`[${SPAR}]`)
// Und die Gegenseite steht sofort auf dem anderen Konto.
await page.goto(`/konten/${sparId}`)
await expect(page.locator('tr', { hasText: `Nachträglich ${MARKE}` }))
.toContainText(`[${GIRO}]`)
})
test('Splitbuchung aus dem Kontoblatt heraus aufteilen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-04-05')
await page.fill('#betrag', '90')
await page.fill('#empfaenger', `Aufzuteilen ${MARKE}`)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
const zeile = page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` })
await zeile.locator('a.zuordnungsfeld').click()
await zeile.locator('#zuordnungsauswahl select').selectOption('split')
const dialog = page.locator('#splitdialog')
await expect(dialog).toBeVisible()
await expect(dialog).toContainText('-90,00')
await dialog.locator('select[name="split[0][zuordnung]"]').selectOption({ label: KATEGORIE })
await dialog.locator('input[name="split[0][betrag]"]').fill('-50')
await dialog.locator('select[name="split[1][zuordnung]"]').selectOption({ label: `[${SPAR}]` })
await dialog.locator('input[name="split[1][betrag]"]').fill('-40')
// Der Dialog rechnet den Restbetrag mit; er muss aufgehen.
await expect(page.locator('#splitdialogsumme')).not.toContainText('Rest')
// Der Dialog hat zwei Submit-Knöpfe: Speichern und Löschen.
await dialog.locator('button[type="submit"].primaer').click()
await expect(page.locator('.meldung-erfolg')).toContainText('Splitbuchung')
await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first())
.toContainText('Splitbuchung')
// Die Zeile mit Gegenkonto erscheint anteilig auf dem anderen Konto.
await page.goto(`/konten/${sparId}`)
await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` })).toContainText('40,00')
})
test('Splitbuchung öffnet den Dialog und lässt sich dort löschen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-04-06')
await page.fill('#betrag', '70')
await page.fill('#empfaenger', `Wieder einfach ${MARKE}`)
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[0][betrag]"]', '-30')
await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[1][betrag]"]', '-40')
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
// Der Klick führt nicht in die Auswahlliste, sondern in den Dialog –
// mit den vorhandenen Zeilen darin.
const zeile = page.locator('tr', { hasText: `Wieder einfach ${MARKE}` })
await zeile.locator('a.zuordnungsfeld').click()
const dialog = page.locator('#splitdialog')
await expect(dialog).toBeVisible()
await expect(page.locator('#zuordnungsauswahl select')).toBeHidden()
await expect(dialog.locator('input[name="split[0][betrag]"]')).toHaveValue('-30,00')
await dialog.locator('#splitloeschen').click()
await expect(page.locator('.meldung-erfolg')).toContainText('Splitbuchung gelöscht')
const danach = page.locator('tr', { hasText: `Wieder einfach ${MARKE}` })
await expect(danach).not.toContainText('Splitbuchung')
await expect(danach).not.toContainText(KATEGORIE)
// Und die Zuordnung geht jetzt wieder über die Auswahlliste.
await danach.locator('a.zuordnungsfeld').click()
await expect(danach.locator('#zuordnungsauswahl select')).toBeVisible()
})
test('Splitbuchung mit zwei Zeilen', async ({ page }) => {
@ -115,11 +240,11 @@ test.describe('Buchungen', () => {
await page.fill('#betrag', '260,49')
await page.fill('#empfaenger', `Versicherung ${MARKE}`)
await page.selectOption('select[name="split[0][kategorie_id]"]', { label: KATEGORIE })
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[0][notiz]"]', 'Kasko')
await page.fill('input[name="split[0][betrag]"]', '-162,51')
await page.selectOption('select[name="split[1][kategorie_id]"]', { label: KATEGORIE })
await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[1][notiz]"]', 'Haftpflicht')
await page.fill('input[name="split[1][betrag]"]', '-97,98')
@ -140,7 +265,7 @@ test.describe('Buchungen', () => {
await page.fill('#datum', '2026-05-03')
await page.fill('#betrag', '100')
await page.fill('#empfaenger', `Krumm ${MARKE}`)
await page.selectOption('select[name="split[0][kategorie_id]"]', { label: KATEGORIE })
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[0][betrag]"]', '-60')
await expect(page.locator('#splitsumme')).toContainText('Rest')
@ -157,11 +282,13 @@ test.describe('Buchungen', () => {
await page.fill('input[name="suche"]', 'Supermarkt')
await page.click('.filterleiste button[type="submit"]')
await expect(page.locator('tbody tr')).toHaveCount(1)
await expect(page.locator('tbody')).toContainText('Supermarkt')
// Auf der Seite steht noch eine zweite Tabelle: die leeren Zeilen des
// Splitdialogs. Gemeint ist hier das Kontoblatt.
await expect(page.locator('table.kontoblatt tbody tr')).toHaveCount(1)
await expect(page.locator('table.kontoblatt tbody')).toContainText('Supermarkt')
// Mit Filter ist die Saldospalte leer – ein laufender Saldo wäre gelogen.
// Spalte 0 ist das Ankreuzfeld der Mehrfachauswahl.
await expect(page.locator('tbody tr td').nth(8)).toContainText('–')
await expect(page.locator('table.kontoblatt tbody tr td').nth(8)).toContainText('–')
})
test('Eine Buchung ohne Kategorie setzt ihr Konto in der Übersicht fett', async ({ page }) => {
@ -206,7 +333,7 @@ test.describe('Buchungen', () => {
expect(gefragt.join(' ')).toContain('löschen')
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
await expect(page.locator('tbody')).not.toContainText('Supermarkt')
await expect(page.locator('table.kontoblatt tbody')).not.toContainText('Supermarkt')
})
test('Mehrere Buchungen über die Auswahl im Kontoblatt löschen', async ({ page }) => {
@ -227,7 +354,7 @@ test.describe('Buchungen', () => {
await expect(knopf).toBeDisabled()
// Das Ankreuzfeld in der Titelzeile wählt alle angezeigten Buchungen.
const anzahl = await page.locator('tbody input[name="buchungen[]"]').count()
const anzahl = await page.locator('table.kontoblatt tbody input[name="buchungen[]"]').count()
await page.check('.alle-waehlen')
await expect(knopf).toContainText(`(${anzahl})`)

View file

@ -170,6 +170,87 @@ test.describe('Module der Phasen 4 bis 7', () => {
await expect(page.locator('.meldung')).toContainText('gelöscht')
})
test('Depotbuchungen lassen sich über die Auswahl sammelweise löschen', async ({ page }) => {
const marke = Date.now().toString().slice(-6)
const depot = `E2E Depot ${marke}`
const papier = `E2E Papier ${marke}`
await page.goto('/konten/neu')
await page.fill('#name', depot)
await page.selectOption('#kontoart', 'depot')
await page.click('.formular button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
// Zwei Käufe desselben Papiers – der zweite über die Auswahlliste, damit
// kein zweites Wertpapier entsteht.
for (const [nr, stueck] of [[1, '10'], [2, '5']]) {
await page.goto('/wertpapiere/buchungen/neu')
await page.selectOption('#konto_id', { label: depot })
await page.fill('#datum', '2026-05-0' + nr)
if (nr === 1) {
await page.fill('#wertpapier_name', papier)
} else {
await page.selectOption('#wertpapier_id', { label: papier })
}
await page.fill('#stueck', stueck)
await page.fill('#kurs', '100')
await page.click('.formular button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
}
// Der Filter bleibt über das Löschen hinweg stehen – das Formularziel
// trägt ihn mit.
await page.goto('/wertpapiere/buchungen')
await page.selectOption('select[name="depot"]', { label: depot })
await page.click('button:has-text("Filtern")')
await expect(page.locator('tbody tr')).toHaveCount(2)
// Der Bestand kennt beide Käufe.
await page.goto('/wertpapiere')
await page.selectOption('select[name="depot"]', { label: depot })
await page.click('button:has-text("Anzeigen")')
await expect(page.locator('tbody tr', { hasText: papier })).toContainText('15')
await page.goBack()
await page.goBack()
// Ohne Auswahl bleibt der Knopf gesperrt.
const knopf = page.locator('#depotbuchungsauswahl button')
await expect(knopf).toBeDisabled()
await page.check('.alle-waehlen')
await expect(knopf).toContainText('(2)')
const gefragt = []
page.on('dialog', async (dialog) => {
gefragt.push(dialog.message())
await dialog.accept()
})
await knopf.click()
expect(gefragt.join(' ')).toContain('löschen')
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
await expect(page).toHaveURL(/depot=/)
// Der Name steht weiter im Filterfeld – gemeint ist die Liste.
await expect(page.locator('table.liste')).toHaveCount(0)
await expect(page.locator('.platzhalter')).toContainText('Keine Depotbuchungen')
// Und der Bestand ist die Buchungen los – er wird gerechnet.
await page.goto('/wertpapiere')
await page.selectOption('select[name="depot"]', { label: depot })
await page.click('button:has-text("Anzeigen")')
await expect(page.locator('.platzhalter')).toContainText('Keine Bestände')
// Aufräumen: Das Testdepot wieder entfernen. Die Rückfrage übernimmt der
// Dialoghandler von oben.
await page.goto('/konten')
const pfad = await page.locator('a', { hasText: depot }).first().getAttribute('href')
await page.goto(pfad + '/bearbeiten')
await page.locator('button:has-text("Konto löschen")').click()
await expect(page.locator('.meldung')).toContainText('gelöscht')
})
test('Der Finanzkalender ist erreichbar und lässt sich einstellen', async ({ page }) => {
await page.goto('/termine/kalender')
await expect(page.locator('h1')).toContainText('Finanzkalender')

View file

@ -221,4 +221,75 @@ test.describe('Stammdaten', () => {
.getByRole('button', { name: 'Löschen' }).click()
await expect(page.locator('body')).not.toContainText(dateiname)
})
test('Kontoauswahl steht erst nach dem Trockenlauf und grenzt den Import ein', async ({ page }) => {
const dateiname = `e2e-konten-${MARKE}.qif`
const giro = `E2E Giro ${MARKE}`
const tagesgeld = `E2E Tagesgeld ${MARKE}`
const inhalt = [
'!Option:MDY',
'!Option:AutoSwitch',
'!Account',
`N${giro}`,
'TBank',
'^',
`N${tagesgeld}`,
'TOth T',
'^',
'!Clear:AutoSwitch',
'!Account',
`N${giro}`,
'TBank',
'^',
'!Type:Bank',
'D4.1.26',
'T-250.00',
'PÜbertrag',
`L[${tagesgeld}]`,
'^',
'D5.2.26',
'T-49.90',
'PSupermarkt',
'^',
'',
].join('\r\n')
await page.goto('/verwaltung/import')
await page.setInputFiles('#qif', {
name: dateiname,
mimeType: 'application/octet-stream',
buffer: Buffer.from(inhalt, 'latin1'),
})
await page.click('.formular button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toContainText('hochgeladen')
// Vor dem ersten Lesen der Datei gibt es nichts zu wählen.
await expect(page.locator('input[name="konten[]"]')).toHaveCount(0)
await page.locator('tr', { hasText: dateiname })
.getByRole('button', { name: 'Trockenlauf' }).click()
// Danach stehen genau die Konten der Datei zur Wahl – beide angehakt.
const kaestchen = page.locator('input[name="konten[]"]')
await expect(kaestchen).toHaveCount(2)
await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).toBeChecked()
await expect(page.locator(`input[name="konten[]"][value="${tagesgeld}"]`)).toBeChecked()
// Nur das Tagesgeld übernehmen: Die Umbuchung zeigt hierher und bleibt,
// die reine Girobuchung fällt heraus.
await page.locator(`input[name="konten[]"][value="${giro}"]`).uncheck()
await page.getByRole('button', { name: 'Trockenlauf mit dieser Auswahl' }).click()
await expect(page.locator('.kachel h2').first()).toContainText('Trockenlauf')
await expect(page.locator('tr', { hasText: 'gehört zu keinem der gewählten Konten' }))
.toContainText('1')
await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).not.toBeChecked()
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen.
await page.goto('/verwaltung/import')
page.on('dialog', async (dialog) => await dialog.accept())
await page.locator('tr', { hasText: dateiname })
.getByRole('button', { name: 'Löschen' }).click()
await expect(page.locator('body')).not.toContainText(dateiname)
})
})