diff --git a/Changes.md b/Changes.md index a558d2b..07bbf6b 100644 --- a/Changes.md +++ b/Changes.md @@ -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: diff --git a/README.md b/README.md index 084c54d..6482112 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/buchungen.md b/docs/buchungen.md index 416f817..e281942 100644 --- a/docs/buchungen.md +++ b/docs/buchungen.md @@ -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`). diff --git a/docs/qif-import.md b/docs/qif-import.md index dda9159..36fddf2 100644 --- a/docs/qif-import.md +++ b/docs/qif-import.md @@ -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: diff --git a/docs/wertpapiere.md b/docs/wertpapiere.md index 9b212f2..1abb791 100644 --- a/docs/wertpapiere.md +++ b/docs/wertpapiere.md @@ -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. diff --git a/public/index.php b/public/index.php index 05a1fd5..be4241a 100644 --- a/public/index.php +++ b/public/index.php @@ -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()); diff --git a/public/static/app.css b/public/static/app.css index 8984961..11ada55 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -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 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 { diff --git a/public/static/betrag.js b/public/static/betrag.js new file mode 100644 index 0000000..d6e08e7 --- /dev/null +++ b/public/static/betrag.js @@ -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 }; +})(); diff --git a/public/static/buchungsmaske.js b/public/static/buchungsmaske.js index 310ca8c..f06c32d 100644 --- a/public/static/buchungsmaske.js +++ b/public/static/buchungsmaske.js @@ -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(); })(); diff --git a/public/static/kontoblatt.js b/public/static/kontoblatt.js new file mode 100644 index 0000000..0f5adbc --- /dev/null +++ b/public/static/kontoblatt.js @@ -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 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(); + }); +})(); diff --git a/scripts/qif-import.php b/scripts/qif-import.php index 1722609..1c8acec 100644 --- a/scripts/qif-import.php +++ b/scripts/qif-import.php @@ -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] \n"); + fwrite(STDERR, "Aufruf: qif-import.php [--uebernehmen] [--nur-stammdaten] [--konten] " + . "[--konto=NAME] [--stichtag=JJJJ-MM-TT] [--bis=JJJJ-MM-TT] [--benutzer=NAME] \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'], diff --git a/src/Controllers/BaseController.php b/src/Controllers/BaseController.php index 7b8e02b..904c324 100644 --- a/src/Controllers/BaseController.php +++ b/src/Controllers/BaseController.php @@ -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 + */ + 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; diff --git a/src/Controllers/BuchungenController.php b/src/Controllers/BuchungenController.php index cabf51f..a960994 100644 --- a/src/Controllers/BuchungenController.php +++ b/src/Controllers/BuchungenController.php @@ -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 $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 $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> + */ + 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 diff --git a/src/Controllers/ImportController.php b/src/Controllers/ImportController.php index ae1f927..6afb5ea 100644 --- a/src/Controllers/ImportController.php +++ b/src/Controllers/ImportController.php @@ -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, ]); } diff --git a/src/Controllers/WertpapiereController.php b/src/Controllers/WertpapiereController.php index a18952f..cc8db9f 100644 --- a/src/Controllers/WertpapiereController.php +++ b/src/Controllers/WertpapiereController.php @@ -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 ───────────────────────────────────────────────── diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index f810a51..e90d85a 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -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 $buchungIds + * @return array>> 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 */ 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> $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> $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> $splits */ private static function speichereSplits(int $buchungId, array $splits): void { diff --git a/src/Services/DepotbestandImport.php b/src/Services/DepotbestandImport.php new file mode 100644 index 0000000..ec4fe95 --- /dev/null +++ b/src/Services/DepotbestandImport.php @@ -0,0 +1,494 @@ + '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> */ + private array $zeilen = []; + + private string $erstellt = ''; + private string $depotnummer = ''; + + /** @var list */ + 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 $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>, + * zaehler: array, einstand: float, wert: float, + * meldungen: list + * } + */ + 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 */ + 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 $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 $ueberschriften + * @return array 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 $felder + * @param array $spalten + * @return array|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]; + } +} diff --git a/src/Services/QifBuchungsImport.php b/src/Services/QifBuchungsImport.php index 4be1616..7451db0 100644 --- a/src/Services/QifBuchungsImport.php +++ b/src/Services/QifBuchungsImport.php @@ -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|null Gewählte Kontonamen; null heißt: alle. */ + private ?array $auswahl = null; + /** @var array Kontoname → id */ private array $konten = []; /** @var array Kategoriepfad → id */ @@ -46,12 +54,14 @@ class QifBuchungsImport private array $klassen = []; /** @var array Basis-Hash → wie oft schon gesehen */ private array $hashZaehler = []; + /** @var array Position in der Datei → Fingerabdruck der Geldbuchung */ + private array $hashes = []; /** @var list */ private array $meldungen = []; /** - * @param array{stichtag?: string, bis?: string} $optionen + * @param array{stichtag?: string, bis?: string, konten?: list} $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 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} */ 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} $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 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 + */ + 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 diff --git a/src/Views/buchungen/formular.php b/src/Views/buchungen/formular.php index e89c38b..344f977 100644 --- a/src/Views/buchungen/formular.php +++ b/src/Views/buchungen/formular.php @@ -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'; -
- - -
- -
- - -

- Die Umbuchung wird einmal gespeichert und erscheint auf beiden Kontoblättern – - auf dem Gegenkonto mit umgekehrtem Vorzeichen. -

-
+ + +

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

- - - - + @@ -193,6 +177,7 @@ include __DIR__ . '/../layout/kopf.php'; + diff --git a/src/Views/buchungen/kontoblatt.php b/src/Views/buchungen/kontoblatt.php index 4b0a1a2..867c759 100644 --- a/src/Views/buchungen/kontoblatt.php +++ b/src/Views/buchungen/kontoblatt.php @@ -10,7 +10,8 @@ use Finanzmanager\View; /** @var array $filter */ /** @var list> $kategorien */ /** @var list> $klassen */ -/** @var array $kontenNamen */ +/** @var list> $konten */ +/** @var array>> $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> $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';

- +
- @@ -197,6 +270,90 @@ include __DIR__ . '/../layout/kopf.php'; + + + + + + + + + +
+ +

Splitbuchung

+

+ über + 0,00 +

+ +
@@ -165,13 +205,46 @@ include __DIR__ . '/../layout/kopf.php'; - - Splitbuchung - - [] + 'Splitbuchung', + $gegenkonto !== '' => $gegenkonto, + default => (string)($kategorieNamen[(int)$zeile['kategorie_id']] ?? ''), + }; + ?> + + + + + + - + + + data-splits="" + + title=""> + + Splitbuchung + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
Kategorie / GegenkontoKlasseNotizBetrag
+ + + + + +
Summe der Zeilen / Restbetrag0,00
+ +

+ Die Summe der Zeilen muss dem Buchungsbetrag entsprechen. Bleiben alle + Zeilen leer, wird die Splitbuchung aufgehoben. +

+ +
+ + + + + +
+ +
+ + + diff --git a/src/Views/buchungen/zuordnungsliste.php b/src/Views/buchungen/zuordnungsliste.php new file mode 100644 index 0000000..6a1b6e6 --- /dev/null +++ b/src/Views/buchungen/zuordnungsliste.php @@ -0,0 +1,42 @@ +, 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> $kategorien Kategoriebaum, flach mit Pfad + * @var list> $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) + */ +?> + + + + + + + + + + + + + + + + + diff --git a/src/Views/verwaltung/import.php b/src/Views/verwaltung/import.php index ca24a81..a764cae 100644 --- a/src/Views/verwaltung/import.php +++ b/src/Views/verwaltung/import.php @@ -10,6 +10,8 @@ use Finanzmanager\View; /** @var string $stichtag */ /** @var string $bis */ /** @var string $datei */ +/** @var array $dateikonten */ +/** @var list $auswahl */ /** @var string $csrfToken */ $bereiche = [ @@ -102,6 +104,12 @@ include __DIR__ . '/../layout/kopf.php'; darunter Wertpapiergeschäfte (Depot- und Geldbuchung) + 0): ?> + + gehört zu keinem der gewählten Konten + + + Fehler @@ -113,6 +121,12 @@ include __DIR__ . '/../layout/kopf.php';

Zeitraum bis . Alles davor ist nicht verloren: es steckt im Anfangssaldo der Konten. + + Übernommen wurden nur die Buchungen von + – einschließlich + der Umbuchungen, die auf einem anderen Konto erfasst wurden und hierher zeigen. + Die Anfangssalden der übrigen Konten bleiben unangetastet. +

@@ -168,14 +182,41 @@ include __DIR__ . '/../layout/kopf.php'; -
+ - + + +

Konten

+

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

+
+ $anzahl): ?> + + +
+ +
- + + + +
@@ -219,6 +260,12 @@ include __DIR__ . '/../layout/kopf.php'; Der Stichtag steht in den Einstellungen.

+

+ Soll nur ein Teil der Konten übernommen werden, zuerst den Trockenlauf + starten: danach steht fest, welche Konten die Datei führt, und sie stehen einzeln zur + Wahl. Importieren übernimmt die ganze Datei. +

+

Noch keine Datei in . diff --git a/src/Views/wertpapiere/buchungen.php b/src/Views/wertpapiere/buchungen.php index f2aa4d0..5ca0a3c 100644 --- a/src/Views/wertpapiere/buchungen.php +++ b/src/Views/wertpapiere/buchungen.php @@ -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'; + + +

+ + +
+ Depotbuchung erfassen @@ -62,6 +88,10 @@ include __DIR__ . '/../layout/kopf.php'; + @@ -71,6 +101,11 @@ include __DIR__ . '/../layout/kopf.php'; +
+ + DatumVorgangWertpapierDepot StückKurs BetragKosten
+ + @@ -108,7 +143,8 @@ include __DIR__ . '/../layout/kopf.php'; -
diff --git a/src/Views/wertpapiere/import.php b/src/Views/wertpapiere/import.php new file mode 100644 index 0000000..bc4f491 --- /dev/null +++ b/src/Views/wertpapiere/import.php @@ -0,0 +1,221 @@ +> $depots + * @var list> $geldkonten + * @var array|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'; +?> + + + + + + +
+
+

Vorschau

+ +
+ +

+ Nichts wurde gespeichert. Übernommen würde je Position ein Kauf zum + über die volle Stückzahl + zum Einstiegskurs; der Bewertungskurs wird als Kurs dieses Tages hinterlegt. + + Die Datei nennt die Depotnummer + . + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WertpapierArtStückEinstiegskursBewertungskursEinstandWertStatus
+ + + + + + 0.0 + ? View::h(number_format((float)$position['bewertungskurs'], 2, ',', '.')) + : '–' ?> + + + schon importiert + + + + neu – Wertpapier wird angelegt + + neu + +
+ neu · + schon vorhanden · + neue Wertpapiere · + Kurse +
+ + +

Protokoll

+
    + +
  • + +
+ + + + + + + + + + + + + + +

+ Ohne Verrechnungskonto entsteht keine Geldbuchung – das Geld ist vor Jahren + geflossen, eine Belastung zum heutigen Tag würde das Konto verfälschen. +

+ +
+ + +
+ +
+ + +
+

Bestandsliste hochladen

+ + +

+ Kein Depot vorhanden. Ein Depot ist ein Konto der Kontoart Depot – + anzulegen in der Kontenverwaltung. +

+ + +
+ + + + + +

+ Erwartet wird der Depotexport der Bank mit den Spalten Wertpapierbezeichnung, + WKN, ISIN, Einstiegskurs, Bewertungskurs, Stückzahl und Assetklasse. +

+ + + +

+ Ohne Auswahl sucht der Import das Depot, dessen Kontonummer der Depotnummer + in der Datei entspricht. +

+ +
+ + Abbrechen +
+
+ +
+ +

+ 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 Depotbuchungen + nachtragen. Ein zweiter Import derselben Datei legt nichts doppelt an. +

+ + diff --git a/src/Views/wertpapiere/index.php b/src/Views/wertpapiere/index.php index 7c34709..b2bd14b 100644 --- a/src/Views/wertpapiere/index.php +++ b/src/Views/wertpapiere/index.php @@ -47,6 +47,7 @@ include __DIR__ . '/../layout/kopf.php'; + Import Depotbuchung erfassen @@ -93,7 +94,8 @@ include __DIR__ . '/../layout/kopf.php';

- Keine Bestände. Depotbuchungen kommen aus dem QIF-Import oder werden hier erfasst. + Keine Bestände. Depotbuchungen kommen aus dem QIF-Import, aus einer + Bestandsliste der Bank oder werden hier erfasst.

diff --git a/tests/10_QifBuchungsImportTest.php b/tests/10_QifBuchungsImportTest.php index b3855d3..8eda16e 100644 --- a/tests/10_QifBuchungsImportTest.php +++ b/tests/10_QifBuchungsImportTest.php @@ -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'); diff --git a/tests/21_DepotbestandImportTest.php b/tests/21_DepotbestandImportTest.php new file mode 100644 index 0000000..32b5b8a --- /dev/null +++ b/tests/21_DepotbestandImportTest.php @@ -0,0 +1,202 @@ +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'); + } +} diff --git a/tests/22_DepotbuchungenLoeschenTest.php b/tests/22_DepotbuchungenLoeschenTest.php new file mode 100644 index 0000000..0fe5590 --- /dev/null +++ b/tests/22_DepotbuchungenLoeschenTest.php @@ -0,0 +1,177 @@ +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 + */ + 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'); + } +} diff --git a/tests/23_ZuordnungTest.php b/tests/23_ZuordnungTest.php new file mode 100644 index 0000000..0663235 --- /dev/null +++ b/tests/23_ZuordnungTest.php @@ -0,0 +1,275 @@ +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'); + } +} diff --git a/tests/e2e/specs/buchungen.spec.js b/tests/e2e/specs/buchungen.spec.js index 7ce48f4..d16ebc9 100644 --- a/tests/e2e/specs/buchungen.spec.js +++ b/tests/e2e/specs/buchungen.spec.js @@ -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})`) diff --git a/tests/e2e/specs/module.spec.js b/tests/e2e/specs/module.spec.js index bd722da..397e475 100644 --- a/tests/e2e/specs/module.spec.js +++ b/tests/e2e/specs/module.spec.js @@ -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') diff --git a/tests/e2e/specs/stammdaten.spec.js b/tests/e2e/specs/stammdaten.spec.js index f1ba7f4..09a2b95 100644 --- a/tests/e2e/specs/stammdaten.spec.js +++ b/tests/e2e/specs/stammdaten.spec.js @@ -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) + }) })