Merge pull request 'Sammel-PR: Umsätze sammelweise löschen, Import/Export in QIF und CSV, Konto- und Buchungsformular zweispaltig' (#32) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/32
This commit is contained in:
fds2610 2026-08-17 23:48:26 +02:00
commit 5072e9d5d8
38 changed files with 4170 additions and 495 deletions

View file

@ -1,5 +1,133 @@
# Änderungen # Änderungen
## Buchungsmaske nennt das Konto und passt auf einen Bildschirm (17.08.2026)
*Neue Buchung* und *Buchung bearbeiten* zeigen oben, in welchem Konto die Buchung liegt
bzw. gespeichert wird; bisher stand das Konto nur klein in der Kopfzeile über dem
Formular. Die Maske steht zweispaltig, die Beschriftungen links neben ihren Eingaben und
die Erklärungen in deren Tooltip – damit endet sie samt Splitzeilen und Knopfleiste
oberhalb der Fensterhöhe, wo sie vorher über 1190 px lang war.
- Kontoangabe als erstes Feld über beide Spalten: Name des Kontos, dahinter die IBAN –
oder, wenn keine hinterlegt ist, Bank und Kontonummer.
- Feldreihenfolge zeilenweise: Art/Datum, Betrag/Empfänger, Kategorie-Gegenkonto/Klasse,
Buchungstext/Nummer, darunter *Abgeglichen*. Namen, Werte und Feld-IDs bleiben
unverändert; der Bereich *Splitbuchung* läuft weiter über die volle Breite.
- Beschriftungen stehen links neben ihrer Eingabe, nicht darüber (neue Variante
`.formular.beschriftung-links`). Das halbiert die Höhe je Feld; Hinweise laufen unter
Beschriftung und Eingabe hinweg, unter 560 px Fensterbreite rutscht die Beschriftung
wieder nach oben.
- Die erklärenden Texte stehen nicht mehr als eigene Zeile zwischen den Feldern, sondern
im `title` ihrer Beschriftung: Konto, Betrag, *Kategorie / Gegenkonto* und
*Splitbuchung*. Eine gepunktete Unterstreichung zeigt, wo ein Tooltip hängt.
- `.formular.zweispaltig` (`public/static/app.css`) stellt jetzt auch Tabellen und
`.feldwert` richtig: Tabellen laufen über beide Spalten, ohne eigenen Rand.
- E2E-Test in `tests/e2e/specs/buchungen.spec.js`: Die Maske nennt das Konto, Datum steht
rechts neben der Art, die Beschriftung links neben ihrer Eingabe, die Erklärungen hängen
in deren Tooltip, und das ganze Formular endet oberhalb der Fensterhöhe.
## Kontoformular zweispaltig (17.08.2026)
*Konto bearbeiten* und *Neues Konto* zeigen ihre Felder in zwei Spalten. Alle Eingaben
stehen damit auf einem Bildschirm, ohne zu scrollen; bisher lagen die vierzehn Felder
untereinander.
- Neue Formularvariante `.formular.zweispaltig` (`public/static/app.css`): ein Grid mit
zwei Spalten, je Feld ein `.feld` aus Beschriftung, Eingabe und Hinweis. Überschriften,
Knopfleiste und `.feld.ganz` laufen über beide Spalten. Unter 720 px Fensterbreite fällt
das Formular auf eine Spalte zurück.
- Feldreihenfolge zeilenweise: Name/Kontoart, Kontengruppe/Beschreibung, Bank/Kontonummer,
IBAN/BIC, Anfangssaldo/Kreditlimit, Sortierung/Kennzeichen. Namen, Werte und Feld-IDs
bleiben unverändert.
- Der Bereich *Konto löschen* darunter bekommt dieselbe Breite, bleibt aber einspaltig.
- E2E-Test in `tests/e2e/specs/stammdaten.spec.js`: Kontoart steht rechts neben dem Namen,
und das Formular endet oberhalb der Fensterhöhe.
## Import und Export in zwei Formaten (17.08.2026)
Aus der Kachel *QIF-Import* wird **Import/Export** mit je einem Knopf für beide Richtungen.
Wer darauf drückt, wählt zuerst das **Dateiformat** – QIF oder CSV –, und erst danach öffnet
sich der Dialog, der genau dieses Format annimmt bzw. schreibt. Bisher gab es nur den einen
Weg hinein: QIF, und nur herein.
**Formatwahl** (`Dateiformat`, `Views/verwaltung/formatwahl.php`)
- Die Formate stehen an einer Stelle (`Dateiformat::ALLE`); Dialoge, Auswahlkarten und die
Prüfung der Dateiendung hängen daran.
- Auf der Verwaltungsseite ist die Auswahl ein `<dialog>`; die Karten liefert die Ansicht
mit, das Skript setzt nur die Ziele. Ohne Skript – oder ohne `<dialog>` – führt derselbe
Knopf auf dieselbe Auswahl als eigene Seite (`/verwaltung/import` bzw. `/verwaltung/export`
ohne `format`).
**CSV-Import** (`CsvBuchungsImport`, `CsvTabelle`)
- Buchungen samt Splitzeilen. Die Kopfzeile bestimmt die Spalten, nicht ihre Reihenfolge –
verglichen wird kleingeschrieben und ohne Umlaute. Unverzichtbar sind `Datum`, `Konto` und
`Betrag`; fehlt eine, bricht der Lauf ab, statt eine halbe Datei einzulesen.
- Splitzeilen folgen unmittelbar auf ihre Buchung und tragen in `Art` das Wort *Splitzeile*.
Eine verwaiste Splitzeile ist ein Fehler und wird gemeldet, nicht stillschweigend fallen
gelassen.
- Kategorien und Klassen legt der Import bei Bedarf an, **Konten nicht**: Ein Konto trägt
Kontoart, Bankverbindung und Anfangssaldo, und nichts davon steht in der Zeile.
- Dieselbe Dublettenerkennung wie beim QIF (Fingerabdruck aus Konto, Datum, Betrag, Nummer
und Text, mit laufendem Index für echte Doppelbuchungen am selben Tag). Trockenlauf und
echter Lauf wie gehabt.
- `CsvTabelle` erkennt Semikolon oder Komma als Trenner und liest Beträge in deutscher wie
englischer Schreibweise; gelesen wird über `fgetcsv()`, damit ein maskiertes Feld einen
Zeilenumbruch enthalten darf.
**Export** (`BuchungsExport`, `ExportController`)
- Filter nach **Zeitraum, Konto, Kategorie und Klasse**; ohne Häkchen zählt jeweils alles.
Über dem Formular steht, wie viele Buchungen der Filter trifft.
- Der Kontofilter greift auf beide Seiten einer Umbuchung, und Kategorie wie Klasse zählen
auch in den Splitzeilen – sonst fielen genau die Buchungen heraus, die man sucht.
- Das QIF trägt den Dialekt des FinanzManagers und lässt sich vom eigenen Import wieder
einlesen; Klassen und Kategorien kommen vollständig mit, die Konten nur, soweit der Filter
sie zulässt. Das CSV ist dasselbe Format, das der CSV-Import liest.
- **Exportdateien werden nicht vorgehalten.** Sie entstehen im Speicher und gehen unmittelbar
an den Browser; ein Exportverzeichnis gibt es bewusst nicht.
- Das Filterformular sendet als POST: Bei vielen angehakten Kategorien wächst eine GET-Adresse
über das, was nginx annimmt – die Antwort wäre „414 Request-URI Too Large“ statt der Datei.
**Housekeeping** (`Housekeeping`, `scripts/housekeeping.php`, Migration `013`)
- Hochgeladene Importdateien bleiben absichtlich liegen (erst Trockenlauf, dann Übernahme)
und sammeln sich sonst unbegrenzt an. Was älter ist als die eingestellte Frist, wird
gelöscht – Vorgabe 14 Tage, `0` schaltet es ab (*Einstellungen → Import/Export*).
- Überwacht werden das Importverzeichnis und `data/tmp`; gelöscht werden nur gewöhnliche
Dateien unmittelbar darin, keine Symlinks und keine Unterverzeichnisse.
- Angestoßen beiläufig beim Öffnen von Import und Export, höchstens einmal am Tag. Ein Fehler
dabei reißt die Seite nicht mit. Für lange ungenutzte Installationen gibt es denselben Lauf
als Skript (`--probe` zählt nur auf, was wegfiele).
**Nebenbei**
- Der Upload nimmt jetzt beide Endungen an; welcher Importeur läuft, entscheidet die Endung
der Datei und nicht ein verstecktes Formularfeld.
- `BerichtExport` (Auswertungen) nutzt die Zeilen- und Zahlenregeln aus `CsvTabelle`, statt
sie ein zweites Mal zu führen.
- `docs/qif-import.md` heißt jetzt `docs/import-export.md` und beschreibt beide Richtungen.
## Umsätze im Banking sammelweise löschen (17.08.2026)
Die Umsatzlisten des Bankings haben jetzt dieselbe Löschfunktion wie ein Kontoblatt: ein
Ankreuzfeld vor jedem Umsatz, eines in der Titelleiste für alle angezeigten und darüber den
Knopf **Auswahl löschen**. Bisher ließ sich ein abgerufener Umsatz nur übernehmen oder
verwerfen – wegräumen ging gar nicht.
- Der Knopf steht in jedem Reiter – *offen*, *übernommen* und *verworfen* –, nennt die Anzahl
der Auswahl und bleibt ohne Auswahl gesperrt. Die Rückfrage vor dem Löschen nennt sie
ebenfalls.
- Gelöscht wird allein die abgerufene Zeile. Eine daraus entstandene Buchung bleibt im
Kontoblatt stehen; mit dem Umsatz geht aber sein Fingerabdruck, sodass er beim nächsten
Abruf wieder hereinkommen kann.
- Neue Route `POST /banking/umsaetze/loeschen` auf `Bankumsatz::mehrereLoeschen()`; sie kehrt
in den Reiter zurück, aus dem gelöscht wurde.
- Die Liste ist keine Tabelle, sondern eine Folge von Karten. Die Kopfzeile, die eine Tabelle
von selbst hätte, gibt es deshalb neu als `.listenkopf`; die Mechanik dahinter ist
dieselbe wie im Kontoblatt (`.alle-waehlen` mit `data-waehlt`).
## Ruhige Buchungszeilen im Kontoblatt (17.08.2026) ## Ruhige Buchungszeilen im Kontoblatt (17.08.2026)
Die Zeilen des Kontoblatts sind jetzt alle gleich hoch – zwei Textzeilen – und ihr Inhalt Die Zeilen des Kontoblatts sind jetzt alle gleich hoch – zwei Textzeilen – und ihr Inhalt

View file

@ -232,7 +232,7 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag
`!Type:Cat`, `!Account` — mit Trockenlauf, Protokoll und `scripts/qif-import.php` für `!Type:Cat`, `!Account` — mit Trockenlauf, Protokoll und `scripts/qif-import.php` für
die Kommandozeile. die Kommandozeile.
* *Ergebnis:* 17 Klassen, 105 Kategorien und 37 Konten sind aus dem Export übernommen und * *Ergebnis:* 17 Klassen, 105 Kategorien und 37 Konten sind aus dem Export übernommen und
in der Oberfläche gepflegt. Einzelheiten in `docs/stammdaten.md` und `docs/qif-import.md`. in der Oberfläche gepflegt. Einzelheiten in `docs/stammdaten.md` und `docs/import-export.md`.
### Phase 2 — Buchungen *(erledigt)* ### Phase 2 — Buchungen *(erledigt)*
* Migrationen `buchungen`, `buchung_splits`, `buchungsadressen`. * Migrationen `buchungen`, `buchung_splits`, `buchungsadressen`.

View file

@ -15,6 +15,9 @@ Haushalt wird beides parallel genutzt.
* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt * **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt
mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen; mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen;
QIF-Import mit Stichtag. QIF-Import mit Stichtag.
* **Import und Export** – QIF und CSV in beide Richtungen: Stammdaten und Buchungen
übernehmen, Buchungen nach Zeitraum, Konto, Kategorie und Klasse gefiltert ausgeben.
Exportdateien werden nicht vorgehalten; das Housekeeping räumt hochgeladene Dateien weg.
* **Planung** – Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf, * **Planung** – Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf,
Snapshot und Sperre. Snapshot und Sperre.
* **Auswertungen** – Summen-, Buchungs-, Vergleichs-, Kontostands- und Budgetbericht mit * **Auswertungen** – Summen-, Buchungs-, Vergleichs-, Kontostands- und Budgetbericht mit
@ -83,7 +86,7 @@ Ersteinrichtung und legt den ersten Benutzer an.
src/ Anwendungscode (Controllers, Models, Services, Views) src/ Anwendungscode (Controllers, Models, Services, Views)
public/ Front-Controller und statische Dateien public/ Front-Controller und statische Dateien
migrations/ nummerierte SQL-Migrationen, laufen beim Containerstart migrations/ nummerierte SQL-Migrationen, laufen beim Containerstart
scripts/ Migration, Update, Backup, Tests, Kursabruf scripts/ Migration, Update, Backup, Tests, Kursabruf, Housekeeping
tests/ PHP-Testsuite; tests/e2e/ für Playwright tests/ PHP-Testsuite; tests/e2e/ für Playwright
data/ Nutzdaten: mariadb, logs, import, schluessel (nicht im Repository) data/ Nutzdaten: mariadb, logs, import, schluessel (nicht im Repository)
Altdaten/ Screenshots und QIF-Export der Altanwendung (nicht im Repository) Altdaten/ Screenshots und QIF-Export der Altanwendung (nicht im Repository)
@ -97,7 +100,7 @@ Buchungen samt Splits sowie Wertpapierbuchungen. Einzelheiten zum Format und den
Fallstricken stehen in `PLAN.md`, Kapitel 4. Fallstricken stehen in `PLAN.md`, Kapitel 4.
Klassen, Kategorien, Konten und Buchungen lassen sich übernehmen – im Dialog unter Klassen, Kategorien, Konten und Buchungen lassen sich übernehmen – im Dialog unter
*Verwaltung → QIF-Import* oder auf der Kommandozeile. Buchungen ab dem Stichtag kommen *Verwaltung → Import/Export* oder auf der Kommandozeile. Buchungen ab dem Stichtag kommen
einzeln in die Datenbank, alles davor wird je Konto zum Anfangssaldo verrechnet: einzeln in die Datenbank, alles davor wird je Konto zum Anfangssaldo verrechnet:
```bash ```bash
@ -109,6 +112,11 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf. Mit `--konto=NAME` (mehrfach an
im Dialog als Ankreuzliste nach dem Trockenlauf) bleibt der Import auf einzelne Konten im Dialog als Ankreuzliste nach dem Trockenlauf) bleibt der Import auf einzelne Konten
beschränkt; `--konten` nennt die Konten der Datei. beschränkt; `--konten` nennt die Konten der Datei.
In die andere Richtung geht es über *Verwaltung → Import/Export → Export*: Buchungen als
QIF oder CSV, gefiltert nach Zeitraum, Konto, Kategorie und Klasse. Die Datei entsteht
erst beim Herunterladen und wird nirgends abgelegt. Der CSV-Import liest dieselbe Datei
wieder ein. Einzelheiten in `docs/import-export.md`.
## Dokumentation ## Dokumentation
- `PLAN.md` – Projektplan mit allen Phasen - `PLAN.md` – Projektplan mit allen Phasen
@ -122,4 +130,4 @@ beschränkt; `--konten` nennt die Konten der Datei.
- `docs/banking.md` – FinTS-Abruf, TAN-Dialog, Abgleich und Regeln - `docs/banking.md` – FinTS-Abruf, TAN-Dialog, Abgleich und Regeln
- `docs/wertpapiere.md` – Depots, Einstand, Kurse, Invst- und CSV-Bestandsimport - `docs/wertpapiere.md` – Depots, Einstand, Kurse, Invst- und CSV-Bestandsimport
- `docs/nebenmodule.md` – Vorlagen, Termine, Verträge, Sparziele, Kredite, Zahlungsverkehr - `docs/nebenmodule.md` – Vorlagen, Termine, Verträge, Sparziele, Kredite, Zahlungsverkehr
- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs - `docs/import-export.md` – QIF und CSV in beide Richtungen, Formatwahl, Housekeeping

View file

@ -242,6 +242,20 @@ Nichts verschwindet von allein: Ein Umsatz, der weder übernommen noch verworfen
offen und steht beim nächsten Öffnen wieder da. Das Cockpit zählt die offenen unter *zum offen und steht beim nächsten Öffnen wieder da. Das Cockpit zählt die offenen unter *zum
Abgleich*. Abgleich*.
### Umsätze löschen
Jeder Reiter der Liste – *offen*, *übernommen*, *verworfen* – hat dieselbe Mehrfachauswahl
wie ein Kontoblatt: ein Ankreuzfeld vor jedem Umsatz, eines in der Titelleiste für alle
angezeigten und darüber der Knopf **Auswahl löschen** (`POST /banking/umsaetze/loeschen`,
`Bankumsatz::mehrereLoeschen()`). Der Knopf nennt die Anzahl und bleibt ohne Auswahl
gesperrt; ohne JavaScript weist der Controller eine leere Auswahl ab.
Gelöscht wird allein die abgerufene Zeile. Eine daraus entstandene Buchung gehört ins
Kontoblatt und bleibt dort stehen – wer sie loswerden will, löscht sie dort, und dann kehrt
ihr Umsatz ohnehin in die Vorschlagsliste zurück. Mit dem Umsatz geht aber sein
Fingerabdruck: Was innerhalb des nächsten Abrufzeitraums liegt, kommt danach wieder herein –
bei einem verworfenen Umsatz also erneut als offen.
## Regeln ## Regeln
Eine Regel prüft ein Muster gegen Empfänger und/oder Verwendungszweck: Eine Regel prüft ein Muster gegen Empfänger und/oder Verwendungszweck:
@ -312,6 +326,12 @@ angehängt, und das Speichern des Zugangsformulars lässt die Wahl stehen. Die A
Bank steckt hinter derselben Grenze wie der Dialog – geprüft wird, was mit ihrem Ergebnis Bank steckt hinter derselben Grenze wie der Dialog – geprüft wird, was mit ihrem Ergebnis
geschieht. geschieht.
`tests/28_UmsaetzeLoeschenTest.php` prüft das Sammellöschen: Es trifft genau die
ausgewählten Zeilen, eine schon erzeugte Buchung überlebt es, jeder Status lässt sich
löschen, leere und unbekannte Auswahl bleiben folgenlos, eine doppelt gesendete Nummer zählt
einmal – und die gerenderte Ansicht trägt in jedem Reiter Ankreuzfelder, Titelleiste und
Knopf, während die leere Liste keinen Knopf zeigt.
`tests/19_KontenzuordnungTest.php` nimmt sich den Weg vor, den ein Umsatz nimmt, wenn die `tests/19_KontenzuordnungTest.php` nimmt sich den Weg vor, den ein Umsatz nimmt, wenn die
Zuordnung erst nach dem Abruf getroffen wird: Offene Umsätze ziehen mit, übernommene nicht, Zuordnung erst nach dem Abruf getroffen wird: Offene Umsätze ziehen mit, übernommene nicht,
und nur das Bankkonto, um das es geht. Dazu das automatische Buchen – mit Regel, ohne Regel, und nur das Bankkonto, um das es geht. Dazu das automatische Buchen – mit Regel, ohne Regel,

View file

@ -1,4 +1,19 @@
# QIF-Import # Import und Export
Unter *Verwaltung → Import/Export* führen zwei Knöpfe hinein. Beide fragen zuerst nach dem
**Dateiformat** (QIF oder CSV) und öffnen danach den passenden Dialog – der Import weiß
dann, welche Dateien er annimmt, der Export, was er schreiben soll.
| | QIF | CSV |
|---|---|---|
| Import | Klassen, Kategorien, Konten, Buchungen, Buchungsvorlagen | Buchungen samt Splitzeilen |
| Export | Stammdaten und Buchungen | Buchungen samt Splitzeilen |
Die Formate stehen an einer Stelle: `Dateiformat::ALLE`. Kommt ein drittes hinzu, ist das
die einzige Liste, die es zu ergänzen gilt – Dialoge, Auswahlkarten und die Prüfung des
Dateinamens hängen daran.
## QIF-Import
Der Lexware FinanzManager exportiert erweitertes Quicken-QIF. Die `.QDF`-Datei ist Der Lexware FinanzManager exportiert erweitertes Quicken-QIF. Die `.QDF`-Datei ist
verschlüsselt und damit keine Migrationsquelle – der QIF-Export ist der einzige Weg. verschlüsselt und damit keine Migrationsquelle – der QIF-Export ist der einzige Weg.
@ -7,7 +22,7 @@ Stand: Klassen, Kategorien, Konten (Phase 1) sowie Buchungen samt Splitzeilen un
Umbuchungen (Phase 2) werden übernommen. Wertpapierbestände folgen in Phase 6, Umbuchungen (Phase 2) werden übernommen. Wertpapierbestände folgen in Phase 6,
Buchungsvorlagen in Phase 7. Buchungsvorlagen in Phase 7.
## Aufbau der Datei ### Aufbau der Datei
``` ```
!Option:MDY Datumsformat für die gesamte Datei !Option:MDY Datumsformat für die gesamte Datei
@ -33,7 +48,7 @@ Jeder Kontoeintrag setzt zugleich das aktuelle Konto. In der Kontenliste folgen
aufeinander, ohne dass Buchungen dazwischenstehen; das schadet nicht und deckt zugleich die aufeinander, ohne dass Buchungen dazwischenstehen; das schadet nicht und deckt zugleich die
zweite Schreibweise ab, bei der ein einzelner `!Account` den Kontowechsel bedeutet. zweite Schreibweise ab, bei der ein einzelner `!Account` den Kontowechsel bedeutet.
## Felder der Stammdaten ### Felder der Stammdaten
| Sektion | Feld | Bedeutung | | Sektion | Feld | Bedeutung |
|---|---|---| |---|---|---|
@ -53,7 +68,7 @@ Zuordnung. Der Import kann sie deshalb nicht anlegen: nach dem Einlesen stehen a
unter „Nicht gruppiert“. Die Gruppen werden einmalig unter *Verwaltung → Kontengruppen* unter „Nicht gruppiert“. Die Gruppen werden einmalig unter *Verwaltung → Kontengruppen*
angelegt; dort lassen sich die Konten einer Gruppe in einem Zug ankreuzen. angelegt; dort lassen sich die Konten einer Gruppe in einem Zug ankreuzen.
## Fallstricke ### Fallstricke
1. **Zeichensatz ISO-8859-1, Zeilenenden CRLF.** `QifParser::nachUtf8()` prüft erst, ob 1. **Zeichensatz ISO-8859-1, Zeilenenden CRLF.** `QifParser::nachUtf8()` prüft erst, ob
schon UTF-8 vorliegt – eine zweite Konvertierung zerstörte die Umlaute. schon UTF-8 vorliegt – eine zweite Konvertierung zerstörte die Umlaute.
@ -76,7 +91,7 @@ angelegt; dort lassen sich die Konten einer Gruppe in einem Zug ankreuzen.
9. **Anfangssalden stehen nicht als eigenes Feld im Export.** Sie ergeben sich aus den 9. **Anfangssalden stehen nicht als eigenes Feld im Export.** Sie ergeben sich aus den
Buchungen vor dem Stichtag, Gegenbuchungen eingerechnet. Buchungen vor dem Stichtag, Gegenbuchungen eingerechnet.
## Arbeitsweise der Importeure ### Arbeitsweise der Importeure
Beide sind **idempotent**: Was es schon gibt, wird gezählt und übersprungen, nie doppelt Beide sind **idempotent**: Was es schon gibt, wird gezählt und übersprungen, nie doppelt
angelegt. Ein zweiter Lauf derselben Datei ändert nichts. Der echte Lauf liegt jeweils in angelegt. Ein zweiter Lauf derselben Datei ändert nichts. Der echte Lauf liegt jeweils in
@ -100,7 +115,7 @@ die der Import selbst anlegen würde, erscheinen in der Vorschau.
Verrechnungskonto zu Buche (`L|[Konto]` und `$`). Ein Betrag auf dem Depot ohne 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. Gegenwert würde die Summe der Kontostände verfälschen; Bestände folgen in Phase 6.
## Nur einzelne Konten übernehmen ### Nur einzelne Konten übernehmen
Der Import lässt sich auf einzelne Konten begrenzen (Option `konten`, im Dialog eine 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: Ankreuzliste, auf der Kommandozeile `--konto=NAME`). Drei Dinge sind dabei entschieden:
@ -132,11 +147,12 @@ Text. Weil es echte Doppelbuchungen am selben Tag gibt (zweimal dasselbe im selb
zählt ein laufender Index gleiche Fingerabdrücke durch – stabil über Läufe hinweg, weil zählt ein laufender Index gleiche Fingerabdrücke durch – stabil über Läufe hinweg, weil
die Datei immer dieselbe Reihenfolge hat. die Datei immer dieselbe Reihenfolge hat.
## Bedienung ### Bedienung
Im Dialog unter *Verwaltung → QIF-Import*: Datei hochladen, Trockenlauf, dann übernehmen. Im Dialog unter *Verwaltung → Import/Export → Import → QIF*: Datei hochladen, Trockenlauf,
Hochgeladene Dateien bleiben im Importverzeichnis (`data/import`), damit derselbe Export dann übernehmen. Hochgeladene Dateien bleiben im Importverzeichnis (`data/import`), damit
erst geprüft und dann übernommen werden kann. derselbe Export erst geprüft und dann übernommen werden kann; das
[Housekeeping](#housekeeping) räumt sie später weg.
Nach dem Trockenlauf steht unter *Konten* die Ankreuzliste der Konten, die in der Datei 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 vorkommen – vorher gibt es nichts zu wählen, weil die Datei noch nicht gelesen wurde. Von
@ -167,7 +183,7 @@ docker compose exec -u www-data php php scripts/qif-import.php \
`--konto=` grenzt den Import ein und ist mehrfach angebbar; ein Name, den die Datei nicht `--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. führt, bricht ab – sonst liefe der Import durch und legte nichts an.
## Prüfung gegen den echten Export ### Prüfung gegen den echten Export
Der Vollexport (8780 Buchungen, 2017–2026) ergibt mit Stichtag 01.01.2026: Der Vollexport (8780 Buchungen, 2017–2026) ergibt mit Stichtag 01.01.2026:
@ -185,3 +201,123 @@ darunter Wertpapiergeschäfte: 16 (nur Geldfluss)
Der Härtetest sind die Salden: Acht Konten, deren Werte aus der Kontenübersicht des Der Härtetest sind die Salden: Acht Konten, deren Werte aus der Kontenübersicht des
FinanzManagers vorliegen, stimmen danach **auf den Cent** – von „Whg Besigheim“ FinanzManagers vorliegen, stimmen danach **auf den Cent** – von „Whg Besigheim“
(210.601,99 €) bis „Auslagen Eltern Wohnungskauf“ (−22.410,94 €). (210.601,99 €) bis „Auslagen Eltern Wohnungskauf“ (−22.410,94 €).
## CSV-Import
Das Gegenstück zum CSV-Export: Buchungen samt Splitzeilen, sonst nichts. Konten,
Kontengruppen und Buchungsvorlagen bleiben dem QIF vorbehalten.
**Die Kopfzeile bestimmt die Spalten, nicht ihre Reihenfolge.** Eine Datei aus einem
anderen Programm darf sie umsortieren oder eigene ergänzen; verglichen wird
kleingeschrieben und ohne Umlaute (`CsvTabelle::schluessel()`), „Empfänger“ und
„empfaenger“ meinen dasselbe. Unverzichtbar sind `Datum`, `Konto` und `Betrag` – fehlt
eine davon, bricht der Lauf ab, statt eine halbe Datei einzulesen.
| Spalte | Inhalt |
|---|---|
| `Art` | `Buchung` oder `Splitzeile` |
| `Datum` / `Valuta` | `TT.MM.JJJJ` oder `JJJJ-MM-TT` |
| `Konto` | Name eines vorhandenen Kontos |
| `Nummer`, `Empfänger`, `Buchungstext` | wie im Kontoblatt; bei Splitzeilen ist `Buchungstext` die Notiz |
| `Kategorie` | vollständiger Pfad mit `:` |
| `Klasse` | Name |
| `Gegenkonto` | statt einer Kategorie: macht die Zeile zur Umbuchung |
| `Betrag` | deutsch (`-84,00`) oder englisch (`-84.00`) |
| `Abgeglichen` | `ja`/`nein` |
Entschieden ist dabei:
1. **Splitzeilen folgen unmittelbar auf ihre Buchung** und tragen in `Art` das Wort
`Splitzeile`. Alles bis zur nächsten Buchungszeile gehört dazu. Eine Splitzeile ohne
Buchung davor ist ein Fehler und wird gemeldet – stillschweigend fallen zu lassen wäre
ein unbemerkter Verlust. Die Beträge der Zeilen müssen den Buchungsbetrag ergeben; das
prüft `Buchung::anlegen()`.
2. **Kategorien und Klassen legt der Import bei Bedarf an, Konten nicht.** Eine Kategorie
ist ein Pfad und mehr nicht; ein Konto trägt Kontoart, Bankverbindung und Anfangssaldo,
und nichts davon steht in der Zeile. Ein unbekanntes Konto ist deshalb ein Fehler.
3. **Dieselbe Dublettenerkennung wie beim QIF**: Fingerabdruck aus Konto, Datum, Betrag,
Nummer und Text, mit laufendem Index für echte Doppelbuchungen am selben Tag. Ein
zweiter Lauf derselben Datei ändert nichts.
4. **Zahlen und Trenner werden erkannt, nicht vorausgesetzt.** `CsvTabelle` sieht auf der
Kopfzeile nach, ob Semikolon oder Komma trennt, und liest Beträge in beiden
Schreibweisen: Maßgeblich ist, welches Zeichen zuletzt steht – es trennt die
Nachkommastellen, das andere ist Tausendertrenner. Gelesen wird über einen
Speicherstrom und `fgetcsv()`, damit ein maskiertes Feld auch einen Zeilenumbruch
enthalten darf.
## Export
*Verwaltung → Import/Export → Export*, dann das Format, dann der Filter: **Zeitraum,
Konto, Kategorie und Klasse**. Ohne Häkchen zählt jeweils alles. Über dem Formular steht,
wie viele Buchungen der Filter trifft – sonst lädt man eine leere Datei herunter und merkt
es erst in der Tabellenkalkulation.
Zwei Eigenheiten des Datenmodells schlagen auf den Filter durch:
* **Der Kontofilter greift auf beide Seiten.** Eine Umbuchung ist EINE Buchung mit
`gegenkonto_id` und steht nur beim erfassenden Konto. Zählte allein dieses, fehlte dem
Auszug des Gegenkontos seine einzige Bewegung.
* **Kategorie und Klasse zählen auch in den Splitzeilen.** Sonst fiele jede Splitbuchung
aus dem Filter, obwohl sie die gesuchte Kategorie führt.
Die Kontenliste der Datei enthält zusätzlich jedes Konto, das nur Umbuchungsziel ist. Ohne
seinen Eintrag bezöge sich die Datei auf ein Konto, das der Import nicht kennt, und die
Umbuchung ginge verloren.
**Die Datei wird nicht vorgehalten.** Sie entsteht im Speicher und geht mit
`Content-Disposition: attachment` unmittelbar an den Browser; ein Exportverzeichnis gibt es
bewusst nicht. Ein Auszug der eigenen Finanzen, der irgendwo im Dateisystem liegen bleibt,
ist eine Altlast, die niemand aufräumt.
Das Filterformular sendet als **POST**. Bei vielen angehakten Kategorien wächst eine
GET-Adresse über das, was nginx annimmt – die Antwort wäre dann „414 Request-URI Too Large“
statt der Datei. Die GET-Route bleibt für kurze Adressen bestehen, die man sich merken mag.
### QIF-Export
Derselbe Dialekt, den der Import liest: erst `!Type:Class`, dann `!Type:Cat`, dann die
Kontenliste zwischen `!Option:AutoSwitch` und `!Clear:AutoSwitch`, danach je Konto ein
`!Account`-Kopf und dessen Buchungen. Datumsformat ist `DMY` mit vierstelligem Jahr
(`!Option:DMY`), Beträge stehen im US-Format.
Klassen und Kategorien kommen **vollständig** mit, auch wenn der Filter enger ist: Ohne sie
ließe sich die Datei auf einer leeren Installation nicht einspielen. Die Konten dagegen nur,
soweit der Filter sie zulässt – sonst wäre es kein Auszug mehr.
Wertpapiergeschäfte (`!Type:Invst`) stehen nicht darin; sie hängen am Depot und werden über
die Wertpapierverwaltung gepflegt.
### CSV-Export
Deutsches Tabellenkalkulations-CSV: Semikolon als Trenner, Komma als Dezimalzeichen, kein
Tausenderpunkt (der macht aus der Zahl einen Text), CRLF am Zeilenende und ein BOM vorweg –
sonst zeigen Excel und LibreOffice beim Doppelklick „Übrige“ statt „Übrige“. Die Spalten
sind die des CSV-Imports; dieselbe Datei liest er wieder ein.
`CsvTabelle` hält das Format an einer Stelle; `BerichtExport` (Auswertungen) nutzt dieselben
Zeilen- und Zahlenregeln.
## Housekeeping
Exportdateien entstehen im Speicher und hinterlassen nichts. Die hochgeladenen
Importdateien bleiben dagegen absichtlich liegen – erst Trockenlauf, dann Übernahme, ohne
zweiten Upload. Genau dadurch sammeln sich im Importverzeichnis mit der Zeit Kontoauszüge
an, die niemand mehr braucht.
`Housekeeping` löscht deshalb, was älter ist als die eingestellte Frist (*Einstellungen →
Import/Export*, Vorgabe 14 Tage; `0` schaltet es ab). Überwacht werden das
Importverzeichnis und `data/tmp`, der Platz für Arbeitsdateien; gelöscht werden nur
gewöhnliche Dateien unmittelbar darin – ein Symlink zeigte womöglich aus dem überwachten
Bereich heraus.
Angestoßen wird der Lauf **beiläufig beim Öffnen von Import und Export**, höchstens einmal
am Tag (`housekeeping_letzter_lauf`). Ein Fehler dabei reißt die Seite nicht mit: Wer den
Import öffnet, will importieren, nicht eine Fehlermeldung übers Aufräumen lesen. Für
Installationen, die wochenlang nicht geöffnet werden, gibt es denselben Lauf als Skript:
```bash
docker compose exec -u www-data php php scripts/housekeeping.php --probe
docker compose exec -u www-data php php scripts/housekeeping.php --still
```
`--probe` zählt nur auf, was wegfiele.

View file

@ -15,7 +15,7 @@ Gruppen werden wie Konten und Kategorien auf einer eigenen Formularseite gepfleg
angekreuzt, `Kontengruppe::kontenZuordnen()` setzt sie in einem Zug. Ein Konto gehört zu angekreuzt, `Kontengruppe::kontenZuordnen()` setzt sie in einem Zug. Ein Konto gehört zu
genau einer Gruppe – wird es hier angehakt, verlässt es seine bisherige. genau einer Gruppe – wird es hier angehakt, verlässt es seine bisherige.
Der QIF-Export enthält **keine** Kontengruppen (siehe `docs/qif-import.md`); nach einem Der QIF-Export enthält **keine** Kontengruppen (siehe `docs/import-export.md`); nach einem
Import stehen deshalb alle Konten unter „Nicht gruppiert“ und werden einmalig zugeordnet. Import stehen deshalb alle Konten unter „Nicht gruppiert“ und werden einmalig zugeordnet.
Kontoarten (`Konto::ARTEN`) und ihre Herkunft aus dem QIF-Export (`Konto::QIF_ARTEN`): Kontoarten (`Konto::ARTEN`) und ihre Herkunft aus dem QIF-Export (`Konto::QIF_ARTEN`):

View file

@ -0,0 +1,11 @@
-- Aufräumen der temporären Import-Dateien.
--
-- Exportdateien werden nicht vorgehalten – sie entstehen im Speicher und gehen
-- direkt an den Browser. Die hochgeladenen Importdateien bleiben dagegen
-- absichtlich liegen (erst Trockenlauf, dann Übernahme) und sammeln sich sonst
-- unbegrenzt an. housekeeping_tage nennt die Frist, 0 schaltet das Aufräumen ab.
INSERT INTO einstellungen (schluessel, wert, bereich) VALUES
('housekeeping_tage', '14', 'import'),
('housekeeping_letzter_lauf', NULL, 'import')
ON DUPLICATE KEY UPDATE schluessel = schluessel;

View file

@ -16,6 +16,7 @@ use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\Controllers\BudgetController; use Finanzmanager\Controllers\BudgetController;
use Finanzmanager\Controllers\CockpitController; use Finanzmanager\Controllers\CockpitController;
use Finanzmanager\Controllers\EinstellungenController; use Finanzmanager\Controllers\EinstellungenController;
use Finanzmanager\Controllers\ExportController;
use Finanzmanager\Controllers\ImportController; use Finanzmanager\Controllers\ImportController;
use Finanzmanager\Controllers\KategorienController; use Finanzmanager\Controllers\KategorienController;
use Finanzmanager\Controllers\KontenController; use Finanzmanager\Controllers\KontenController;
@ -278,6 +279,7 @@ $router->post('/banking/neu', fn() => (new BankingController())->anlegen());
// „/banking/umsaetze“ und „/banking/regeln“. // „/banking/umsaetze“ und „/banking/regeln“.
$router->get('/banking/umsaetze', fn() => (new BankingController())->umsaetze()); $router->get('/banking/umsaetze', fn() => (new BankingController())->umsaetze());
$router->post('/banking/umsaetze/alle', fn() => (new BankingController())->alleUebernehmen()); $router->post('/banking/umsaetze/alle', fn() => (new BankingController())->alleUebernehmen());
$router->post('/banking/umsaetze/loeschen', fn() => (new BankingController())->umsaetzeLoeschen());
$router->post('/banking/umsaetze/{id}/uebernehmen', $router->post('/banking/umsaetze/{id}/uebernehmen',
fn(array $p) => (new BankingController())->uebernehmen($p)); fn(array $p) => (new BankingController())->uebernehmen($p));
$router->post('/banking/umsaetze/{id}/verwerfen', $router->post('/banking/umsaetze/{id}/verwerfen',
@ -314,6 +316,14 @@ $router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->
$router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren()); $router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren());
$router->post('/verwaltung/import/{datei}/loeschen', fn(array $p) => (new ImportController())->loeschen($p)); $router->post('/verwaltung/import/{datei}/loeschen', fn(array $p) => (new ImportController())->loeschen($p));
// Der Filter kommt als POST: Sind viele Kategorien angehakt, wird eine
// GET-Adresse länger, als der Webserver annimmt. Die GET-Route bleibt für den
// ersten Aufruf und für kurze Adressen, die man sich merken mag.
$router->get('/verwaltung/export', fn() => (new ExportController())->index());
$router->post('/verwaltung/export', fn() => (new ExportController())->index());
$router->get('/verwaltung/export/datei', fn() => (new ExportController())->datei());
$router->post('/verwaltung/export/datei', fn() => (new ExportController())->datei());
try { try {
$router->dispatch(); $router->dispatch();
} catch (\Throwable $e) { } catch (\Throwable $e) {

View file

@ -1149,6 +1149,50 @@ table.splittabelle input.rechts { text-align: right; }
„Speichern“ und „Abbrechen“, damit es nicht im Vorbeigehen getroffen wird. */ „Speichern“ und „Abbrechen“, damit es nicht im Vorbeigehen getroffen wird. */
.splitdialog .knopfleiste .knopf.gefahr { margin-left: auto; } .splitdialog .knopfleiste .knopf.gefahr { margin-left: auto; }
/* Die Formatwahl vor Import und Export – als Dialog auf der Verwaltungsseite
und als Kachelinhalt auf der Ausweichseite, deshalb zwei Ebenen: der Dialog
trägt den Rahmen, .formatwahl die Karten. */
.formatdialog {
width: min(34rem, 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%);
}
.formatdialog::backdrop { background: rgb(0 0 0 / 35%); }
.formatdialog h2 { margin: 0 0 .3rem; font-size: 1.05rem; }
.formatdialog .knopfleiste { margin-top: .9rem; }
.formatwahl {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: .7rem;
margin-top: .8rem;
}
.formatkarte {
display: flex;
flex-direction: column;
gap: .25rem;
padding: .8rem .9rem;
border: 1px solid var(--rand-kraeftig);
border-radius: var(--radius);
background: var(--flaeche-gedaempft);
color: inherit;
text-decoration: none;
}
.formatkarte:hover,
.formatkarte:focus-visible {
border-color: var(--akzent);
background: color-mix(in srgb, var(--akzent) 8%, transparent);
}
.formatname { font-weight: 600; font-size: 1.05rem; }
/* Der Wertpapierdialog trägt ein Formular und wird dadurch länger als der /* Der Wertpapierdialog trägt ein Formular und wird dadurch länger als der
Splitdialog; bei kleinen Fenstern scrollt er. */ Splitdialog; bei kleinen Fenstern scrollt er. */
.wertpapierdialog { max-height: 88vh; overflow-y: auto; } .wertpapierdialog { max-height: 88vh; overflow-y: auto; }
@ -1187,6 +1231,87 @@ table.kostentabelle .gruppenzeile th { padding-top: .7rem; font-weight: 600; }
margin-top: .6rem; margin-top: .6rem;
} }
/* Erklärungen zu einem Feld stehen im Tooltip seiner Beschriftung statt als
eigene Zeile darunter – das spart in langen Masken sichtbar Höhe. Die
gepunktete Linie zeigt, dass dort etwas zu holen ist. */
.formular label[title],
.formular h3 span[title] {
text-decoration: underline dotted;
text-underline-offset: .25em;
cursor: help;
}
/* Zweispaltiges Formular: je Feld ein .feld mit Beschriftung, Eingabe und
Hinweis, zwei davon nebeneinander. Lange Formulare – das Kontoformular etwa –
bleiben so ohne vertikales Scrollen einsehbar. */
.formular.zweispaltig {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 1rem 1.5rem;
}
.formular.breit.zweispaltig { max-width: 900px; }
.formular.zweispaltig .feld {
display: flex;
flex-direction: column;
gap: .3rem;
}
.formular.zweispaltig .feld label { margin-top: 0; }
.formular.zweispaltig .feld .feldhinweis { margin-top: 0; }
.formular.zweispaltig .feld .feldwert { margin: 0; }
.formular.zweispaltig .feld label.ankreuzfeld { margin-top: 0; }
/* Über beide Spalten: verstecktes CSRF-Feld, Überschriften, Knopfleiste,
Tabellen – die Splitzeilen der Buchungsmaske etwa – und Felder, die für eine
halbe Zeile zu breit sind. */
.formular.zweispaltig > input[type="hidden"],
.formular.zweispaltig > h3,
.formular.zweispaltig > p,
.formular.zweispaltig > table,
.formular.zweispaltig > .knopfleiste,
.formular.zweispaltig .feld.ganz { grid-column: 1 / -1; }
/* Die Abstände kommen im Grid aus dem gap – eigene Ränder addierten sich dazu. */
.formular.zweispaltig > h3 { margin: 0; }
.formular.zweispaltig > p { margin: 0; }
.formular.zweispaltig > table { margin: 0; }
.formular.zweispaltig > .knopfleiste { margin-top: .5rem; }
@media (max-width: 720px) {
.formular.zweispaltig { grid-template-columns: 1fr; }
}
/* Beschriftung links neben der Eingabe statt darüber. Das halbiert die Höhe je
Feld – die Buchungsmaske passt damit samt Splitzeilen auf einen Bildschirm.
Hinweise stehen unter der Eingabe, also in derselben Spalte wie diese. */
.formular.beschriftung-links .feld {
display: grid;
grid-template-columns: minmax(0, 10rem) minmax(0, 1fr);
align-items: center;
gap: .25rem .7rem;
}
.formular.beschriftung-links .feld > label { grid-column: 1; }
.formular.beschriftung-links .feld > :not(label) { grid-column: 2; }
/* Das Ankreuzfeld trägt seine Beschriftung selbst und braucht keine Spalte.
Hinweise laufen über beide Spalten: in der schmalen Eingabespalte allein
würden sie zu einem hohen Textblock, der die Zeile auseinanderzieht. */
.formular.beschriftung-links .feld > label.ankreuzfeld,
.formular.beschriftung-links .feld > .feldhinweis { grid-column: 1 / -1; }
@media (max-width: 560px) {
.formular.beschriftung-links .feld { grid-template-columns: 1fr; }
.formular.beschriftung-links .feld > label,
.formular.beschriftung-links .feld > :not(label) { grid-column: 1; }
}
.formular label.ankreuzfeld { .formular label.ankreuzfeld {
display: flex; display: flex;
align-items: center; align-items: center;
@ -1637,6 +1762,34 @@ table.bericht .wertlink:hover {
gap: 1rem; gap: 1rem;
} }
/* Das Ankreuzfeld der Mehrfachauswahl steht vor dem Text und richtet sich an
der ersten Zeile aus, nicht an der Mitte der Karte. */
.umsatzwahl {
flex: none;
margin-top: .25rem;
}
/* Titelleiste einer Kartenliste: Sie vertritt die Kopfzeile, die eine Tabelle
von selbst hätte – hier steht das Ankreuzfeld „alle wählen“. */
.listenkopf {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: .35rem .9rem;
margin-bottom: .4rem;
border-bottom: 1px solid var(--rand);
font-size: .85rem;
color: var(--text-gedaempft);
}
.listenkopf .ankreuzfeld {
display: flex;
align-items: center;
gap: .5rem;
font-weight: 500;
}
.umsatztext { min-width: 0; } .umsatztext { min-width: 0; }
.umsatztext .nebentext { display: block; font-size: .8rem; } .umsatztext .nebentext { display: block; font-size: .8rem; }

View file

@ -237,6 +237,35 @@
anpassen(); anpassen();
}); });
// Formatwahl von Import und Export. Die Kachel in der Verwaltung führt zwei
// Knöpfe; welches Dateiformat gemeint ist, fragt dieser Dialog ab und hängt
// es dann an die Zieladresse. Die Karten liefert die Ansicht mit – hier
// stehen nur die Ziele.
//
// Kennt der Browser <dialog> nicht, bleibt der Knopf ein gewöhnlicher Link
// und führt auf dieselbe Auswahl als eigene Seite.
const formatdialog = document.getElementById('formatwahl');
if (formatdialog && typeof formatdialog.showModal === 'function') {
const titel = formatdialog.querySelector('[data-formatdialog-titel]');
document.querySelectorAll('[data-formatwahl]').forEach(function (knopf) {
knopf.addEventListener('click', function (ereignis) {
ereignis.preventDefault();
const ziel = knopf.getAttribute('href');
if (titel) {
titel.textContent = knopf.dataset.formatwahl + ': Format wählen';
}
formatdialog.querySelectorAll('.formatkarte').forEach(function (karte) {
karte.setAttribute('href', ziel + '?format=' + karte.dataset.format);
});
formatdialog.showModal();
});
});
}
// Meldungen ausblenden. Über eine Klasse, nicht über element.style: Die // Meldungen ausblenden. Über eine Klasse, nicht über element.style: Die
// Content-Security-Policy (style-src 'self') verwirft auch per JavaScript // Content-Security-Policy (style-src 'self') verwirft auch per JavaScript
// gesetzte Inline-Styles, die Meldung bliebe sonst einfach stehen. // gesetzte Inline-Styles, die Meldung bliebe sonst einfach stehen.

57
scripts/housekeeping.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/**
* Räumt die temporären Dateien von Import und Export weg.
*
* Gedacht für einen Cron-Eintrag:
*
* docker compose exec -u www-data php php scripts/housekeeping.php --still
*
* Nötig ist der Eintrag nicht: Beim Öffnen von Import und Export läuft dasselbe
* Aufräumen beiläufig mit, höchstens einmal am Tag. Wer den Finanzmanager
* wochenlang nicht öffnet, hat mit dem Cron trotzdem ein leeres
* Importverzeichnis.
*
* Mit `--probe` wird nur aufgezählt, was wegfiele; gelöscht wird dann nichts.
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use Finanzmanager\Config;
use Finanzmanager\Services\Housekeeping;
Config::load();
$still = in_array('--still', $argv, true);
$probe = in_array('--probe', $argv, true);
$sage = static function (string $zeile) use ($still): void {
if (!$still) {
echo $zeile . PHP_EOL;
}
};
$tage = Housekeeping::tage();
if ($tage === 0) {
$sage('Das Aufräumen ist abgeschaltet (Einstellungen → Import/Export, Frist 0).');
exit(0);
}
$ergebnis = Housekeeping::ausfuehren($probe);
$sage(sprintf(
'%s: %d von %d Dateien älter als %d Tage (%s).',
$probe ? 'Probelauf' : 'Aufgeräumt',
$ergebnis['geloescht'],
$ergebnis['geprueft'],
$ergebnis['tage'],
number_format($ergebnis['bytes'] / 1024, 1, ',', '.') . ' kB'
));
foreach ($ergebnis['dateien'] as $datei) {
$sage(' ' . $datei);
}
exit(0);

View file

@ -557,6 +557,36 @@ class BankingController extends BaseController
View::redirect('/banking/umsaetze?status=offen', 'Umsatz steht wieder zum Abgleich bereit.'); View::redirect('/banking/umsaetze?status=offen', 'Umsatz steht wieder zum Abgleich bereit.');
} }
/**
* Mehrere in der Abgleichliste angekreuzte Umsätze auf einmal löschen.
*
* Gelöscht wird nur die abgerufene Zeile. Eine daraus entstandene Buchung
* bleibt im Kontoblatt stehen – wer sie loswerden will, löscht sie dort.
*/
public function umsaetzeLoeschen(): void
{
$this->verlangeAnmeldung();
// Zurück in denselben Reiter, aus dem gelöscht wurde.
$status = $this->query('status', 'offen');
if (!array_key_exists($status, Bankumsatz::STATUS)) {
$status = 'offen';
}
$ziel = '/banking/umsaetze?status=' . $status;
$ids = $this->postIds('umsaetze');
if ($ids === []) {
View::redirect($ziel, 'Kein Umsatz ausgewählt.', 'warnung');
return;
}
$anzahl = Bankumsatz::mehrereLoeschen($ids);
View::redirect($ziel, $anzahl === 1
? 'Umsatz gelöscht.'
: $anzahl . ' Umsätze gelöscht.');
}
/** /**
* Alles übernehmen, was einen eindeutigen Vorschlag hat. * Alles übernehmen, was einen eindeutigen Vorschlag hat.
* *

View file

@ -106,7 +106,11 @@ class EinstellungenController extends BaseController
'banking_abgleich_tage' => 'text', 'banking_abgleich_tage' => 'text',
'banking_produkt_id' => 'text', 'banking_produkt_id' => 'text',
], ],
'import' => ['import_qif_verzeichnis' => 'text', 'import_stichtag' => 'text'], 'import' => [
'import_qif_verzeichnis' => 'text',
'import_stichtag' => 'text',
'housekeeping_tage' => 'text',
],
'wertpapiere' => [ 'wertpapiere' => [
'wertpapiere_kursdienst' => 'text', 'wertpapiere_kursdienst' => 'text',
'wertpapiere_api_key' => 'text', 'wertpapiere_api_key' => 'text',

View file

@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\BuchungsExport;
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\Services\Housekeeping;
use Finanzmanager\View;
/**
* Export der Buchungen als QIF oder CSV.
*
* Wie beim Import steht die Formatwahl vorweg; ohne `format` in der Adresse
* erscheint sie als Seite. Danach folgt der Filter nach Zeitraum, Konto,
* Kategorie und Klasse, und der Knopf liefert die Datei unmittelbar aus.
*
* Die Datei wird nirgends zwischengespeichert – sie entsteht im Speicher und
* geht direkt an den Browser. Ein Exportverzeichnis gäbe es sonst zu pflegen,
* und darin lägen Auszüge der eigenen Finanzen herum.
*/
class ExportController extends BaseController
{
public function index(): void
{
$this->verlangeAnmeldung();
Housekeeping::beiGelegenheit();
$format = Dateiformat::pruefe($this->text('format'));
if ($format === null) {
$this->render('verwaltung/formatwahl.php', [
'titel' => 'Export',
'richtung' => 'Export',
'ziel' => '/verwaltung/export',
'einleitung' => 'In welchem Format soll die Datei entstehen? Danach folgt der Filter '
. 'nach Zeitraum, Konto, Kategorie und Klasse.',
]);
return;
}
$filter = $this->filter();
$this->render('verwaltung/export.php', [
'titel' => 'Export (' . Dateiformat::name($format) . ')',
'format' => $format,
'filter' => $filter,
'konten' => Konto::alle(),
'kategorien' => Kategorie::baumFlach(),
'klassen' => Klasse::alle(),
// Wie viele Buchungen der Filter trifft, steht schon vor dem
// Herunterladen da – sonst lädt man eine leere Datei und merkt es
// erst in der Tabellenkalkulation.
'anzahl' => BuchungsExport::anzahl($filter),
]);
}
/** Erzeugt die Datei und schickt sie als Download. */
public function datei(): void
{
$this->verlangeAnmeldung();
$format = Dateiformat::pruefe($this->text('format'));
if ($format === null) {
View::redirect('/verwaltung/export', 'Kein Format gewählt.', 'fehler');
return;
}
try {
$datei = BuchungsExport::datei($format, $this->filter());
} catch (\Throwable $e) {
View::redirect(
'/verwaltung/export?format=' . $format,
'Export fehlgeschlagen: ' . $e->getMessage(),
'fehler'
);
return;
}
header('Content-Type: ' . $datei['typ']);
header('Content-Disposition: attachment; filename="' . $datei['name'] . '"');
header('Content-Length: ' . strlen($datei['inhalt']));
// Ein Auszug der eigenen Finanzen gehört in keinen Zwischenspeicher.
header('Cache-Control: no-store');
echo $datei['inhalt'];
}
/**
* Filterwerte, gleichgültig ob sie aus der Adresse oder aus dem Formular
* kommen.
*
* Das Formular sendet als POST: Bei vielen angehakten Kategorien wächst
* eine GET-Adresse über das, was der Webserver annimmt – nginx antwortet
* dann mit „414 Request-URI Too Large“ statt mit der Datei. Ein kurzer
* Aufruf mit wenigen Parametern bleibt trotzdem als Adresse möglich,
* deshalb zählen beide Quellen; POST sticht, weil es die frischere Eingabe
* ist.
*
* @return array<string,mixed>
*/
private function eingabe(): array
{
return $_POST + $_GET;
}
/**
* @return array{von: string, bis: string, konten: list<int>,
* kategorien: list<int>, klassen: list<int>}
*/
private function filter(): array
{
$eingabe = $this->eingabe();
return [
'von' => $this->datum($this->text('von')),
'bis' => $this->datum($this->text('bis')),
'konten' => $this->ids($eingabe['konten'] ?? null),
'kategorien' => $this->ids($eingabe['kategorien'] ?? null),
'klassen' => $this->ids($eingabe['klassen'] ?? null),
];
}
/** Ein einzelner Textwert; ein Feld, das als Liste ankommt, gilt als leer. */
private function text(string $feld): string
{
$wert = $this->eingabe()[$feld] ?? '';
return is_string($wert) ? trim($wert) : '';
}
/** @return list<int> */
private function ids(mixed $werte): array
{
if (!is_array($werte)) {
return [];
}
$ids = [];
foreach ($werte as $wert) {
if (is_scalar($wert) && (int)$wert > 0) {
$ids[] = (int)$wert;
}
}
return array_values(array_unique($ids));
}
private function datum(string $wert): string
{
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $wert) === 1 ? $wert : '';
}
}

View file

@ -5,6 +5,9 @@ declare(strict_types=1);
namespace Finanzmanager\Controllers; namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Einstellung; use Finanzmanager\Models\Einstellung;
use Finanzmanager\Services\CsvBuchungsImport;
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\Services\Housekeeping;
use Finanzmanager\Services\QifBuchungsImport; use Finanzmanager\Services\QifBuchungsImport;
use Finanzmanager\Services\QifParser; use Finanzmanager\Services\QifParser;
use Finanzmanager\Services\QifStammdatenImport; use Finanzmanager\Services\QifStammdatenImport;
@ -12,58 +15,75 @@ use Finanzmanager\Services\QifVorlagenImport;
use Finanzmanager\View; use Finanzmanager\View;
/** /**
* QIF-Import: Stammdaten (Klassen, Kategorien, Konten) und Buchungen. * Import: QIF (Stammdaten, Buchungen, Buchungsvorlagen) und CSV (Buchungen).
*
* Vor dem Hochladen steht die Formatwahl – erst danach weiß der Dialog, welche
* Dateien er annimmt und welchen Ablauf er zeigt. Ohne `format` in der Adresse
* erscheint deshalb die Auswahl; die Kachel in der Verwaltung nimmt sie über
* ihren Auswahldialog vorweg.
* *
* Hochgeladene Dateien landen im Importverzeichnis und bleiben dort – so lässt * Hochgeladene Dateien landen im Importverzeichnis und bleiben dort – so lässt
* sich derselbe Export erst im Trockenlauf prüfen und danach übernehmen, ohne * sich derselbe Export erst im Trockenlauf prüfen und danach übernehmen, ohne
* ihn zweimal hochzuladen. * ihn zweimal hochzuladen. Was zu lange liegen bleibt, räumt das Housekeeping
* weg.
*/ */
class ImportController extends BaseController class ImportController extends BaseController
{ {
private const ERLAUBTE_ENDUNGEN = ['qif']; /** Die Felder, die jede Ansicht kennt – ein Lauf füllt davon nur seine eigenen. */
private const LEER = [
'ergebnis' => null,
'buchungen' => null,
'vorlagen' => null,
'ueberblick' => null,
'datei' => '',
'dateikonten' => [],
'auswahl' => [],
];
public function index(): void public function index(): void
{ {
$this->verlangeAnmeldung(); $this->verlangeAnmeldung();
Housekeeping::beiGelegenheit();
$this->render('verwaltung/import.php', [ $format = Dateiformat::pruefe($this->query('format'));
'titel' => 'QIF-Import', if ($format === null) {
'dateien' => $this->dateien(), $this->formatwahl();
'verzeichnis' => $this->verzeichnisAnzeige(), return;
'ergebnis' => null, }
'buchungen' => null,
'vorlagen' => null, $this->render('verwaltung/import.php', array_merge(self::LEER, $this->grunddaten($format)));
'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'),
'bis' => date('Y-m-d'),
'datei' => '',
'dateikonten' => [],
'auswahl' => [],
]);
} }
public function hochladen(): void public function hochladen(): void
{ {
$this->verlangeAnmeldung(); $this->verlangeAnmeldung();
$datei = $_FILES['qif'] ?? null; $format = Dateiformat::pruefe($this->post('format')) ?? 'qif';
$ziel = '/verwaltung/import?format=' . $format;
$datei = $_FILES['datei'] ?? null;
if (!is_array($datei) || ($datei['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { if (!is_array($datei) || ($datei['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
View::redirect('/verwaltung/import', $this->uploadFehler($datei['error'] ?? UPLOAD_ERR_NO_FILE), 'fehler'); View::redirect($ziel, $this->uploadFehler($datei['error'] ?? UPLOAD_ERR_NO_FILE), 'fehler');
return; return;
} }
$name = self::sichererDateiname((string)$datei['name']); $name = self::sichererDateiname((string)$datei['name']);
if ($name === null) { if ($name === null || Dateiformat::ausDateiname($name) !== $format) {
View::redirect('/verwaltung/import', 'Nur .QIF-Dateien können hochgeladen werden.', 'fehler'); View::redirect(
$ziel,
'Für diesen Import werden nur .' . strtoupper($format) . '-Dateien angenommen.',
'fehler'
);
return; return;
} }
$ziel = $this->verzeichnis() . '/' . $name; $pfad = $this->verzeichnis() . '/' . $name;
if (!is_uploaded_file((string)$datei['tmp_name']) || !move_uploaded_file((string)$datei['tmp_name'], $ziel)) { if (!is_uploaded_file((string)$datei['tmp_name']) || !move_uploaded_file((string)$datei['tmp_name'], $pfad)) {
View::redirect('/verwaltung/import', 'Die Datei konnte nicht abgelegt werden.', 'fehler'); View::redirect($ziel, 'Die Datei konnte nicht abgelegt werden.', 'fehler');
return; return;
} }
View::redirect('/verwaltung/import', 'Datei „' . $name . '“ hochgeladen.'); View::redirect($ziel, 'Datei „' . $name . '“ hochgeladen.');
} }
public function ausfuehren(): void public function ausfuehren(): void
@ -76,18 +96,88 @@ class ImportController extends BaseController
return; return;
} }
// Maßgeblich ist die Endung der Datei, nicht das Feld im Formular: Was
// eingelesen wird, bestimmt der Inhalt – sonst liefe ein CSV durch den
// QIF-Parser, bloß weil ein verstecktes Feld das behauptet.
$format = Dateiformat::ausDateiname($name) ?? 'qif';
$ziel = '/verwaltung/import?format=' . $format;
$pfad = $this->verzeichnis() . '/' . $name; $pfad = $this->verzeichnis() . '/' . $name;
if (!is_file($pfad)) { if (!is_file($pfad)) {
View::redirect('/verwaltung/import', 'Die Datei „' . $name . '“ gibt es nicht mehr.', 'fehler'); View::redirect($ziel, 'Die Datei „' . $name . '“ gibt es nicht mehr.', 'fehler');
return; return;
} }
$trockenlauf = $this->post('modus') !== 'import'; $trockenlauf = $this->post('modus') !== 'import';
try {
$daten = $format === 'csv'
? $this->csvLauf($pfad, $trockenlauf)
: $this->qifLauf($pfad, $trockenlauf);
} catch (\Throwable $e) {
View::redirect($ziel, 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler');
return;
}
// Reihenfolge zählt: Was der Lauf liefert, sticht die Vorbelegung –
// etwa der im Formular gewählte Stichtag den aus den Einstellungen.
$this->render('verwaltung/import.php', array_merge(
self::LEER,
$this->grunddaten($format),
$daten,
['datei' => $name]
));
}
/** @param array<string,string> $params */
public function loeschen(array $params): void
{
$this->verlangeAnmeldung();
$name = self::sichererDateiname($params['datei'] ?? '');
$format = $name === null ? 'qif' : (Dateiformat::ausDateiname($name) ?? 'qif');
if ($name !== null) {
$pfad = $this->verzeichnis() . '/' . $name;
if (is_file($pfad)) {
unlink($pfad);
}
}
View::redirect('/verwaltung/import?format=' . $format, 'Datei gelöscht.');
}
/**
* Nimmt nur den Dateinamen ohne Pfadanteile und lässt allein die erlaubten
* Endungen durch – der Name landet ungeprüft in einer Pfadangabe.
*/
public static function sichererDateiname(string $eingabe): ?string
{
$name = basename(trim($eingabe));
if ($name === '' || $name === '.' || $name === '..' || str_contains($name, "\0")) {
return null;
}
if (Dateiformat::ausDateiname($name) === null) {
return null;
}
// Alles außer Buchstaben, Ziffern, Punkt, Strich und Unterstrich fällt
// weg; damit bleiben keine Sonderzeichen für die Shell oder das
// Dateisystem übrig.
$name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?? '';
return $name === '' ? null : $name;
}
// ── Läufe je Format ──────────────────────────────────────────────────────
/** @return array<string,mixed> */
private function qifLauf(string $pfad, bool $trockenlauf): array
{
$stichtag = $this->post('stichtag') ?: (string)Einstellung::get('import_stichtag', '2026-01-01'); $stichtag = $this->post('stichtag') ?: (string)Einstellung::get('import_stichtag', '2026-01-01');
$bis = $this->post('bis') ?: date('Y-m-d'); $bis = $this->post('bis') ?: date('Y-m-d');
try {
$parser = QifParser::ausDatei($pfad); $parser = QifParser::ausDatei($pfad);
$ergebnis = (new QifStammdatenImport($parser))->ausfuehren($trockenlauf); $ergebnis = (new QifStammdatenImport($parser))->ausfuehren($trockenlauf);
@ -114,73 +204,66 @@ class ImportController extends BaseController
// zweiter Knopf für dieselbe Datei. // zweiter Knopf für dieselbe Datei.
$vorlagen = (new QifVorlagenImport($parser))->ausfuehren($trockenlauf); $vorlagen = (new QifVorlagenImport($parser))->ausfuehren($trockenlauf);
} }
} catch (\Throwable $e) {
View::redirect('/verwaltung/import', 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler');
return;
}
$this->render('verwaltung/import.php', [ return [
'titel' => 'QIF-Import',
'dateien' => $this->dateien(),
'verzeichnis' => $this->verzeichnisAnzeige(),
'ergebnis' => $ergebnis + ['abschnitte' => $parser->zusammenfassung()], 'ergebnis' => $ergebnis + ['abschnitte' => $parser->zusammenfassung()],
'buchungen' => $buchungen, 'buchungen' => $buchungen,
'vorlagen' => $vorlagen, 'vorlagen' => $vorlagen,
'stichtag' => $stichtag, 'stichtag' => $stichtag,
'bis' => $bis, 'bis' => $bis,
'datei' => $name,
'dateikonten' => $dateikonten, 'dateikonten' => $dateikonten,
'auswahl' => $auswahl, 'auswahl' => $auswahl,
];
}
/** @return array<string,mixed> */
private function csvLauf(string $pfad, bool $trockenlauf): array
{
$import = CsvBuchungsImport::ausDatei($pfad);
return [
'buchungen' => $import->ausfuehren($trockenlauf),
'ueberblick' => $import->ueberblick(),
];
}
// ── Ansicht ──────────────────────────────────────────────────────────────
private function formatwahl(): void
{
$this->render('verwaltung/formatwahl.php', [
'titel' => 'Import',
'richtung' => 'Import',
'ziel' => '/verwaltung/import',
'einleitung' => 'In welchem Format liegt die Datei vor? Danach öffnet sich der Dialog, '
. 'der genau dieses Format annimmt.',
]); ]);
} }
/** @param array<string,string> $params */ /** @return array<string,mixed> */
public function loeschen(array $params): void private function grunddaten(string $format): array
{ {
$this->verlangeAnmeldung(); return [
'titel' => 'Import (' . Dateiformat::name($format) . ')',
$name = self::sichererDateiname($params['datei'] ?? ''); 'format' => $format,
if ($name !== null) { 'dateien' => $this->dateien($format),
$pfad = $this->verzeichnis() . '/' . $name; 'verzeichnis' => $this->verzeichnisAnzeige(),
if (is_file($pfad)) { 'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'),
unlink($pfad); 'bis' => date('Y-m-d'),
} 'aufbewahrung' => Housekeeping::tage(),
} ];
View::redirect('/verwaltung/import', 'Datei gelöscht.');
}
/**
* Nimmt nur den Dateinamen ohne Pfadanteile und lässt allein die erlaubten
* Endungen durch – der Name landet ungeprüft in einer Pfadangabe.
*/
public static function sichererDateiname(string $eingabe): ?string
{
$name = basename(trim($eingabe));
if ($name === '' || $name === '.' || $name === '..' || str_contains($name, "\0")) {
return null;
}
$endung = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!in_array($endung, self::ERLAUBTE_ENDUNGEN, true)) {
return null;
}
// Alles außer Buchstaben, Ziffern, Punkt, Strich und Unterstrich fällt
// weg; damit bleiben keine Sonderzeichen für die Shell oder das
// Dateisystem übrig.
$name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?? '';
return $name === '' ? null : $name;
} }
/** @return list<array{name: string, groesse: int, geaendert: int}> */ /** @return list<array{name: string, groesse: int, geaendert: int}> */
private function dateien(): array private function dateien(string $format): array
{ {
$verzeichnis = $this->verzeichnis(); $verzeichnis = $this->verzeichnis();
$dateien = []; $dateien = [];
foreach (glob($verzeichnis . '/*.[qQ][iI][fF]') ?: [] as $pfad) { foreach (glob($verzeichnis . '/*') ?: [] as $pfad) {
if (!is_file($pfad) || Dateiformat::ausDateiname($pfad) !== $format) {
continue;
}
$dateien[] = [ $dateien[] = [
'name' => basename($pfad), 'name' => basename($pfad),
'groesse' => (int)filesize($pfad), 'groesse' => (int)filesize($pfad),

View file

@ -363,4 +363,30 @@ class Bankumsatz
{ {
Database::execute('DELETE FROM bank_umsaetze WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); Database::execute('DELETE FROM bank_umsaetze WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
} }
/**
* Mehrere Umsätze auf einmal löschen – die Mehrfachauswahl der Abgleichliste.
*
* Eine schon erzeugte Buchung bleibt stehen: Sie gehört ins Kontoblatt und
* wird auch nur dort gelöscht. Weg ist allein die abgerufene Zeile – und mit
* ihr ihr Fingerabdruck, weshalb ein gelöschter Umsatz beim nächsten Abruf
* wieder hereinkommen kann.
*
* @param list<int> $ids
* @return int Wie viele Umsätze tatsächlich gelöscht wurden.
*/
public static function mehrereLoeschen(array $ids): int
{
$ids = array_values(array_unique(array_filter($ids, static fn (int $id): bool => $id > 0)));
if ($ids === []) {
return 0;
}
$platzhalter = implode(', ', array_fill(0, count($ids), '?'));
return Database::execute(
'DELETE FROM bank_umsaetze WHERE benutzer_id = ? AND id IN (' . $platzhalter . ')',
array_merge([Mandant::id()], $ids)
);
}
} }

View file

@ -11,15 +11,11 @@ use Finanzmanager\View;
* Vorzeichen wie am Bildschirm. Wer den Export mit der Anzeige vergleicht, soll * Vorzeichen wie am Bildschirm. Wer den Export mit der Anzeige vergleicht, soll
* nicht rechnen müssen. * nicht rechnen müssen.
* *
* Deutsches Tabellenkalkulations-CSV: Semikolon als Trenner, Komma als * Das Dateiformat selbst – Trenner, Maskierung, Zahlenschreibweise – kommt aus
* Dezimalzeichen, kein Tausenderpunkt (der macht aus der Zahl einen Text). * CsvTabelle; hier steht allein, welche Zeilen ein Bericht hergibt.
* Vorangestellt ein BOM, sonst zeigen Excel und LibreOffice beim Doppelklick
* „Übrige“ statt „Übrige“.
*/ */
class BerichtExport class BerichtExport
{ {
private const BOM = "\u{FEFF}";
/** @param array<string,mixed> $bericht Rückgabe von Auswertung::bericht() */ /** @param array<string,mixed> $bericht Rückgabe von Auswertung::bericht() */
public static function csv(array $bericht): string public static function csv(array $bericht): string
{ {
@ -36,12 +32,7 @@ class BerichtExport
[], [],
]; ];
$text = ''; return CsvTabelle::schreibe(array_merge($kopf, $zeilen));
foreach (array_merge($kopf, $zeilen) as $zeile) {
$text .= self::zeile($zeile);
}
return self::BOM . $text;
} }
/** Dateiname ohne Pfad, aus Berichtsname und Zeitraum. */ /** Dateiname ohne Pfad, aus Berichtsname und Zeitraum. */
@ -182,29 +173,9 @@ class BerichtExport
return $zeilen; return $zeilen;
} }
/** @param list<string> $felder */
private static function zeile(array $felder): string
{
$maskiert = array_map(
// Nur maskieren, wo es sein muss: Ein Feld in Anführungszeichen
// liest sich schlechter, wenn es keinen Trenner enthält.
static function (string $feld): string {
if (preg_match('/[;"\r\n]/', $feld) !== 1) {
return $feld;
}
return '"' . str_replace('"', '""', $feld) . '"';
},
array_map('strval', $felder)
);
// CRLF: Das erwarten Tabellenkalkulationen unter Windows, und der Rest
// kommt damit ebenfalls zurecht.
return implode(';', $maskiert) . "\r\n";
}
private static function zahl(float $wert, int $stellen = 2): string private static function zahl(float $wert, int $stellen = 2): string
{ {
return number_format($wert, $stellen, ',', ''); return CsvTabelle::zahl($wert, $stellen);
} }
/** Umlaute im Dateinamen ausschreiben, statt sie zu Bindestrichen zu zermahlen. */ /** Umlaute im Dateinamen ausschreiben, statt sie zu Bindestrichen zu zermahlen. */

View file

@ -0,0 +1,551 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
/**
* Buchungen herausgeben – als QIF oder als CSV, gefiltert nach Zeitraum,
* Konto, Kategorie und Klasse.
*
* Die Datei entsteht im Speicher und geht unmittelbar an den Browser; auf der
* Platte bleibt nichts liegen. Ein Exportverzeichnis gibt es deshalb bewusst
* nicht: Ein Auszug der eigenen Finanzen, der irgendwo im Dateisystem liegen
* bleibt, ist eine Altlast, die niemand aufräumt.
*
* Eine Umbuchung steht – wie im Datenmodell und im QIF des FinanzManagers –
* genau einmal in der Datei, beim erfassenden Konto; die Gegenseite ist als
* `[Kontoname]` benannt. Deshalb greift der Kontofilter auch auf das
* Gegenkonto: Wer ein Konto exportiert, will die Umbuchungen dorthin mithaben.
*/
class BuchungsExport
{
/** Spalten des CSV-Exports, in dieser Reihenfolge. */
public const SPALTEN = [
'Art', 'Datum', 'Valuta', 'Konto', 'Nummer', 'Empfänger', 'Buchungstext',
'Kategorie', 'Klasse', 'Gegenkonto', 'Betrag', 'Abgeglichen',
];
/** Zeilenart in der ersten Spalte des CSV. */
public const ART_BUCHUNG = 'Buchung';
public const ART_SPLIT = 'Splitzeile';
/**
* Die Datei, fertig zum Ausliefern.
*
* @param array<string,mixed> $filter
* @return array{name: string, typ: string, inhalt: string, anzahl: int}
*/
public static function datei(string $format, array $filter): array
{
if (!Dateiformat::gueltig($format)) {
throw new \InvalidArgumentException('Unbekanntes Exportformat: ' . $format);
}
$buchungen = self::buchungen($filter);
return [
'name' => self::dateiname($format, $filter),
'typ' => $format === 'qif' ? 'application/qif' : 'text/csv; charset=utf-8',
'inhalt' => $format === 'qif' ? self::qif($buchungen, $filter) : self::csv($buchungen),
'anzahl' => count($buchungen),
];
}
/**
* Wie viele Buchungen trifft der Filter? Für die Anzeige im Dialog – die
* Buchungen selbst zu laden, nur um sie zu zählen, wäre bei einem Bestand
* über mehrere Jahre die ganze Datei umsonst.
*
* @param array<string,mixed> $filter
*/
public static function anzahl(array $filter): int
{
[$wo, $params] = self::bedingung($filter);
return (int)Database::fetchValue(
'SELECT COUNT(*) FROM buchungen b WHERE ' . $wo,
$params,
0
);
}
/**
* Die gefilterten Buchungen samt Splitzeilen und Adressen.
*
* @param array<string,mixed> $filter von, bis, konten[], kategorien[], klassen[]
* @return list<array<string,mixed>>
*/
public static function buchungen(array $filter): array
{
[$wo, $params] = self::bedingung($filter);
$buchungen = Database::fetchAll(
'SELECT b.*, k.name AS kontoname, k.kontoart,
g.name AS gegenkontoname, kat.pfad AS kategoriepfad, kl.name AS klassenname
FROM buchungen b
JOIN konten k ON k.id = b.konto_id
LEFT JOIN konten g ON g.id = b.gegenkonto_id
LEFT JOIN kategorien kat ON kat.id = b.kategorie_id
LEFT JOIN klassen kl ON kl.id = b.klasse_id
WHERE ' . $wo . '
ORDER BY k.name, b.datum, b.id',
$params
);
if ($buchungen === []) {
return [];
}
$ids = array_map(static fn(array $b): int => (int)$b['id'], $buchungen);
$splits = self::splits($ids);
$adressen = self::adressen($ids);
foreach ($buchungen as $nr => $buchung) {
$id = (int)$buchung['id'];
$buchungen[$nr]['splits'] = $splits[$id] ?? [];
$buchungen[$nr]['adressen'] = $adressen[$id] ?? [];
}
return $buchungen;
}
/**
* Die WHERE-Klausel des Filters samt Parametern – einmal geschrieben,
* damit Zählen und Laden nie auseinanderlaufen.
*
* @param array<string,mixed> $filter
* @return array{0: string, 1: list<mixed>}
*/
private static function bedingung(array $filter): array
{
$wo = ['b.benutzer_id = ?'];
$params = [Mandant::id()];
$von = self::datum($filter['von'] ?? null);
$bis = self::datum($filter['bis'] ?? null);
if ($von !== null) {
$wo[] = 'b.datum >= ?';
$params[] = $von;
}
if ($bis !== null) {
$wo[] = 'b.datum <= ?';
$params[] = $bis;
}
$konten = self::ids($filter['konten'] ?? []);
if ($konten !== []) {
$platzhalter = self::platzhalter($konten);
$wo[] = '(b.konto_id IN (' . $platzhalter . ') OR b.gegenkonto_id IN (' . $platzhalter . ')
OR EXISTS (SELECT 1 FROM buchung_splits s WHERE s.buchung_id = b.id
AND s.gegenkonto_id IN (' . $platzhalter . ')))';
$params = array_merge($params, $konten, $konten, $konten);
}
// Kategorie und Klasse können in der Buchung oder in einer ihrer
// Splitzeilen stehen – beides zählt, sonst fiele jede Splitbuchung aus
// dem Filter, obwohl sie die gesuchte Kategorie führt.
foreach ([['kategorien', 'kategorie_id'], ['klassen', 'klasse_id']] as [$schluessel, $spalte]) {
$ids = self::ids($filter[$schluessel] ?? []);
if ($ids === []) {
continue;
}
$platzhalter = self::platzhalter($ids);
$wo[] = '(b.' . $spalte . ' IN (' . $platzhalter . ')
OR EXISTS (SELECT 1 FROM buchung_splits s WHERE s.buchung_id = b.id
AND s.' . $spalte . ' IN (' . $platzhalter . ')))';
$params = array_merge($params, $ids, $ids);
}
return [implode(' AND ', $wo), $params];
}
/**
* @param list<array<string,mixed>> $buchungen
*/
public static function csv(array $buchungen): string
{
$zeilen = [self::SPALTEN];
foreach ($buchungen as $buchung) {
$zeilen[] = [
self::ART_BUCHUNG,
self::datumDeutsch((string)$buchung['datum']),
self::datumDeutsch((string)($buchung['valuta'] ?? '')),
(string)$buchung['kontoname'],
(string)$buchung['nummer'],
(string)$buchung['empfaenger'],
(string)($buchung['buchungstext'] ?? ''),
(string)($buchung['kategoriepfad'] ?? ''),
(string)($buchung['klassenname'] ?? ''),
(string)($buchung['gegenkontoname'] ?? ''),
CsvTabelle::zahl((float)$buchung['betrag']),
(int)$buchung['abgeglichen'] === 1 ? 'ja' : 'nein',
];
// Die Splitzeilen folgen unmittelbar auf ihre Buchung und lassen
// alles leer, was schon oben steht. So bleibt die Zuordnung auch in
// einer Tabellenkalkulation erkennbar, ohne eine zweite Datei.
foreach ($buchung['splits'] as $split) {
$zeilen[] = [
self::ART_SPLIT,
'', '', '', '', '',
(string)$split['notiz'],
(string)($split['kategoriepfad'] ?? ''),
(string)($split['klassenname'] ?? ''),
(string)($split['gegenkontoname'] ?? ''),
CsvTabelle::zahl((float)$split['betrag']),
'',
];
}
}
return CsvTabelle::schreibe($zeilen);
}
/**
* QIF im Dialekt des Lexware FinanzManagers – dieselbe Datei, die der
* eigene Import wieder liest.
*
* Stammdaten kommen vollständig mit (Klassen und Kategorien), die Konten
* dagegen nur, soweit der Filter sie zulässt: Ohne die Stammdaten ließe
* sich die Datei auf einer leeren Installation nicht einspielen, mit
* fremden Konten wäre sie kein Auszug mehr.
*
* @param list<array<string,mixed>> $buchungen
* @param array<string,mixed> $filter
*/
public static function qif(array $buchungen, array $filter = []): string
{
$text = "!Option:DMY\n";
$text .= "!Type:Class\n";
foreach (Klasse::alle() as $klasse) {
$text .= 'N' . $klasse['name'] . "\n";
if (($klasse['beschreibung'] ?? '') !== '') {
$text .= 'D' . $klasse['beschreibung'] . "\n";
}
$text .= "^\n";
}
$text .= "!Type:Cat\n";
foreach (Kategorie::alle() as $kategorie) {
$text .= 'N' . $kategorie['pfad'] . "\n";
if (($kategorie['beschreibung'] ?? '') !== '') {
$text .= 'D' . $kategorie['beschreibung'] . "\n";
}
$text .= ($kategorie['art'] === 'einnahme' ? 'I' : 'E') . "\n";
if ((int)$kategorie['steuerrelevant'] === 1) {
$text .= "T\n";
}
$text .= "^\n";
}
$konten = self::kontenDesExports($buchungen, $filter);
// Erst die Kontenliste, dann je Konto der Abschnitt mit den Buchungen –
// dieselbe Reihenfolge wie im Export des FinanzManagers. Der Parser
// braucht sie so: Der Kontobezug einer Buchung steckt allein darin,
// welcher !Account-Abschnitt zuletzt kam.
$text .= "!Option:AutoSwitch\n!Account\n";
foreach ($konten as $konto) {
$text .= self::kontoblock($konto);
}
$text .= "!Clear:AutoSwitch\n";
$jeKonto = [];
foreach ($buchungen as $buchung) {
$jeKonto[(string)$buchung['kontoname']][] = $buchung;
}
foreach ($konten as $konto) {
$eigene = $jeKonto[(string)$konto['name']] ?? [];
if ($eigene === []) {
continue;
}
$text .= "!Account\n" . self::kontoblock($konto);
$text .= '!Type:' . self::qifTyp((string)$konto['kontoart']) . "\n";
foreach ($eigene as $buchung) {
$text .= self::buchungsblock($buchung);
}
}
return $text;
}
/** Dateiname ohne Pfad, aus Format und Zeitraum. */
public static function dateiname(string $format, array $filter): string
{
$von = self::datum($filter['von'] ?? null);
$bis = self::datum($filter['bis'] ?? null);
$teile = ['buchungen'];
if ($von !== null) {
$teile[] = $von;
}
if ($bis !== null) {
$teile[] = 'bis-' . $bis;
}
if ($von === null && $bis === null) {
$teile[] = date('Y-m-d');
}
return implode('-', $teile) . '.' . $format;
}
// ── QIF-Bausteine ────────────────────────────────────────────────────────
/** @param array<string,mixed> $konto */
private static function kontoblock(array $konto): string
{
$block = 'N' . $konto['name'] . "\n"
. 'T' . self::qifTyp((string)$konto['kontoart']) . "\n";
// Der FinanzManager legt die Kontonummer in die Beschreibung; der
// eigene Import liest sie dort wieder heraus.
if (($konto['kontonummer'] ?? '') !== '') {
$block .= 'DKontonr. ' . $konto['kontonummer'] . "\n";
} elseif (($konto['beschreibung'] ?? '') !== '') {
$block .= 'D' . $konto['beschreibung'] . "\n";
}
if (($konto['kreditlimit'] ?? null) !== null) {
$block .= 'L' . self::zahlQif((float)$konto['kreditlimit']) . "\n";
}
return $block . "^\n";
}
/** @param array<string,mixed> $buchung */
private static function buchungsblock(array $buchung): string
{
$block = 'D' . self::datumDeutsch((string)$buchung['datum']) . "\n";
if (($buchung['valuta'] ?? null) !== null) {
$block .= 'V' . self::datumDeutsch((string)$buchung['valuta']) . "\n";
}
$betrag = self::zahlQif((float)$buchung['betrag']);
$block .= 'T' . $betrag . "\n" . 'U' . $betrag . "\n";
if ((string)$buchung['empfaenger'] !== '') {
$block .= 'P' . $buchung['empfaenger'] . "\n";
}
if ((string)($buchung['buchungstext'] ?? '') !== '') {
$block .= 'M' . self::einzeilig((string)$buchung['buchungstext']) . "\n";
}
if ((string)$buchung['nummer'] !== '') {
$block .= 'N' . $buchung['nummer'] . "\n";
}
if ((int)$buchung['abgeglichen'] === 1) {
$block .= "C*\n";
}
$ziel = self::zielangabe(
(string)($buchung['gegenkontoname'] ?? ''),
(string)($buchung['kategoriepfad'] ?? ''),
(string)($buchung['klassenname'] ?? '')
);
if ($ziel !== '') {
$block .= 'L' . $ziel . "\n";
}
foreach ($buchung['splits'] as $split) {
$block .= 'S' . self::zielangabe(
(string)($split['gegenkontoname'] ?? ''),
(string)($split['kategoriepfad'] ?? ''),
(string)($split['klassenname'] ?? '')
) . "\n";
$block .= 'E' . self::einzeilig((string)$split['notiz']) . "\n";
$block .= '$' . self::zahlQif((float)$split['betrag']) . "\n";
}
foreach ($buchung['adressen'] as $adresse) {
$block .= 'A' . $adresse . "\n";
}
return $block . "^\n";
}
/**
* Die L- oder S-Zeile: entweder ein Gegenkonto in eckigen Klammern oder ein
* Kategoriepfad – die Klasse hängt mit Schrägstrich daran.
*/
private static function zielangabe(string $gegenkonto, string $kategorie, string $klasse): string
{
$ziel = $gegenkonto !== '' ? '[' . $gegenkonto . ']' : $kategorie;
if ($klasse === '') {
return $ziel;
}
return $ziel . '/' . $klasse;
}
/**
* Kontoart → QIF-Typ, die Umkehrung von Konto::QIF_ARTEN. Zwei Arten stehen
* dort doppelt („Oth E“/„Oth F“ und „Port“/„Invst“); genommen wird der
* erste Treffer, damit die Zuordnung eindeutig bleibt.
*/
private static function qifTyp(string $kontoart): string
{
$typ = array_search($kontoart, Konto::QIF_ARTEN, true);
return $typ === false ? 'Bank' : $typ;
}
/** Beträge stehen im QIF im US-Format, ohne Tausendertrenner. */
private static function zahlQif(float $wert): string
{
return number_format($wert, 2, '.', '');
}
/**
* Ein Zeilenumbruch im Buchungstext beendete im QIF den Eintrag. Er wird
* durch ein Leerzeichen ersetzt – der Text bleibt lesbar, die Datei heil.
*/
private static function einzeilig(string $text): string
{
return trim((string)preg_replace('/\s*[\r\n]+\s*/', ' ', $text));
}
// ── Hilfen ───────────────────────────────────────────────────────────────
/**
* Welche Konten führt die Exportdatei?
*
* Die Buchungen liefern die Namen, und zwar von beiden Seiten: Ein Konto,
* das nur Umbuchungsziel ist, führt in der Datei keine eigene Zeile – ohne
* seinen Eintrag in der Kontenliste bezöge sich die Datei aber auf ein
* Konto, das der Import nicht kennt, und die Umbuchung ginge verloren.
*
* Ein Kontofilter zieht die gewählten Konten hinzu, damit auch ein leer
* ausgefallener Auszug die Kontenliste mitbringt.
*
* @param list<array<string,mixed>> $buchungen
* @return list<array<string,mixed>>
*/
private static function kontenDesExports(array $buchungen, array $filter): array
{
$gewaehlt = self::ids($filter['konten'] ?? []);
$namen = [];
foreach ($buchungen as $buchung) {
$namen[(string)$buchung['kontoname']] = true;
if (($buchung['gegenkontoname'] ?? null) !== null) {
$namen[(string)$buchung['gegenkontoname']] = true;
}
foreach ($buchung['splits'] as $split) {
if (($split['gegenkontoname'] ?? null) !== null) {
$namen[(string)$split['gegenkontoname']] = true;
}
}
}
$konten = [];
foreach (Konto::alle() as $konto) {
if (isset($namen[(string)$konto['name']]) || in_array((int)$konto['id'], $gewaehlt, true)) {
$konten[] = $konto;
}
}
return $konten;
}
/**
* @param list<int> $buchungIds
* @return array<int, list<array<string,mixed>>>
*/
private static function splits(array $buchungIds): array
{
$zeilen = Database::fetchAll(
'SELECT s.*, kat.pfad AS kategoriepfad, kl.name AS klassenname, g.name AS gegenkontoname
FROM buchung_splits s
LEFT JOIN kategorien kat ON kat.id = s.kategorie_id
LEFT JOIN klassen kl ON kl.id = s.klasse_id
LEFT JOIN konten g ON g.id = s.gegenkonto_id
WHERE s.buchung_id IN (' . self::platzhalter($buchungIds) . ')
ORDER BY s.buchung_id, s.sortierung, s.id',
$buchungIds
);
$jeBuchung = [];
foreach ($zeilen as $zeile) {
$jeBuchung[(int)$zeile['buchung_id']][] = $zeile;
}
return $jeBuchung;
}
/**
* @param list<int> $buchungIds
* @return array<int, list<string>>
*/
private static function adressen(array $buchungIds): array
{
$zeilen = Database::fetchAll(
'SELECT buchung_id, text FROM buchungsadressen
WHERE buchung_id IN (' . self::platzhalter($buchungIds) . ')
ORDER BY buchung_id, zeile',
$buchungIds
);
$jeBuchung = [];
foreach ($zeilen as $zeile) {
$jeBuchung[(int)$zeile['buchung_id']][] = (string)$zeile['text'];
}
return $jeBuchung;
}
/** @param mixed $werte */
private static function ids(mixed $werte): array
{
if (!is_array($werte)) {
return [];
}
$ids = [];
foreach ($werte as $wert) {
$id = (int)$wert;
if ($id > 0) {
$ids[$id] = $id;
}
}
return array_values($ids);
}
private static function platzhalter(array $werte): string
{
return implode(',', array_fill(0, max(1, count($werte)), '?'));
}
private static function datum(mixed $wert): ?string
{
$wert = trim((string)$wert);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $wert) === 1 ? $wert : null;
}
private static function datumDeutsch(string $iso): string
{
if ($iso === '') {
return '';
}
$zeit = strtotime($iso);
return $zeit === false ? '' : date('d.m.Y', $zeit);
}
}

View file

@ -0,0 +1,396 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
/**
* Übernimmt Buchungen aus einer CSV-Datei – dem Gegenstück zum CSV-Export.
*
* Die Spalten werden über die Kopfzeile angesprochen, nicht über ihre Position:
* Eine Datei aus einem anderen Programm darf sie umsortieren oder eigene
* ergänzen, solange die Überschriften stimmen. Fehlt eine Spalte, bleibt das
* Feld leer; nur Datum, Konto und Betrag sind unverzichtbar.
*
* Splitzeilen stehen unmittelbar hinter ihrer Buchung und tragen in der Spalte
* „Art“ das Wort „Splitzeile“. Alles bis zur nächsten Buchungszeile gehört
* dazu.
*
* Wie beim QIF-Import gilt: derselbe Fingerabdruck heißt dieselbe Buchung. Ein
* zweiter Lauf derselben Datei legt also nichts doppelt an.
*/
class CsvBuchungsImport
{
/** Ohne diese drei Spalten lässt sich keine Buchung bilden. */
private const PFLICHTSPALTEN = ['datum', 'konto', 'betrag'];
/** @var list<array<string,string>> */
private array $saetze;
/** @var array<string,int> Kontoname → id */
private array $konten = [];
/** @var array<string,int> Kategoriepfad → id */
private array $kategorien = [];
/** @var array<string,int> Klassenname → id */
private array $klassen = [];
/** @var array<string,int> Basis-Hash → wie oft schon gesehen */
private array $hashZaehler = [];
/** @var list<array{typ: string, text: string}> */
private array $meldungen = [];
public function __construct(string $inhalt)
{
$this->saetze = CsvTabelle::datensaetze($inhalt);
}
public static function ausDatei(string $pfad): self
{
if (!is_readable($pfad)) {
throw new \RuntimeException('Die Datei ist nicht lesbar: ' . $pfad);
}
$inhalt = file_get_contents($pfad);
if ($inhalt === false) {
throw new \RuntimeException('Die Datei konnte nicht gelesen werden: ' . $pfad);
}
return new self($inhalt);
}
/**
* Was steht in der Datei? Grundlage der Vorschau, bevor überhaupt gelesen
* wird: Zeilenzahl und die Konten, die vorkommen.
*
* @return array{zeilen: int, splitzeilen: int, konten: array<string,int>, spalten: list<string>}
*/
public function ueberblick(): array
{
$konten = [];
$zeilen = 0;
$splitzeilen = 0;
foreach ($this->saetze as $satz) {
if (self::istSplitzeile($satz)) {
$splitzeilen++;
continue;
}
$zeilen++;
$name = trim($satz['konto'] ?? '');
if ($name !== '') {
$konten[$name] = ($konten[$name] ?? 0) + 1;
}
}
ksort($konten, SORT_NATURAL | SORT_FLAG_CASE);
return [
'zeilen' => $zeilen,
'splitzeilen' => $splitzeilen,
'konten' => $konten,
'spalten' => $this->saetze === [] ? [] : array_keys($this->saetze[0]),
];
}
/**
* @return array{buchungen: array<string,int>, trockenlauf: bool,
* meldungen: list<array{typ: string, text: string}>}
*/
public function ausfuehren(bool $trockenlauf = false): array
{
$this->meldungen = [];
$this->hashZaehler = [];
$this->ladeStammdaten();
$fehlend = $this->fehlendePflichtspalten();
if ($fehlend !== []) {
throw new \RuntimeException(
'Der Datei fehlen unverzichtbare Spalten: ' . implode(', ', $fehlend) . '.'
);
}
$lauf = fn(): array => $this->zeilen($trockenlauf);
// Beim echten Lauf gilt alles oder nichts – eine halb eingelesene Datei
// ließe sich nicht wiederholen, ohne Dubletten zu riskieren.
$ergebnis = $trockenlauf ? $lauf() : Database::transaktion($lauf);
return [
'buchungen' => $ergebnis,
'trockenlauf' => $trockenlauf,
'meldungen' => $this->meldungen,
];
}
public function vorschau(): array
{
return $this->ausfuehren(true);
}
/**
* @return array<string,int>
*/
private function zeilen(bool $trockenlauf): array
{
$zaehler = ['neu' => 0, 'vorhanden' => 0, 'fehler' => 0, 'splitzeilen' => 0];
// Eine Buchung ist erst vollständig, wenn feststeht, dass keine
// Splitzeile mehr folgt – deshalb wird sie zurückgehalten und erst beim
// nächsten Buchungskopf (oder am Dateiende) geschrieben.
$offen = null;
$offeneZeile = 0;
$splits = [];
$schreibe = function () use (&$offen, &$offeneZeile, &$splits, &$zaehler, $trockenlauf): void {
if ($offen === null) {
return;
}
try {
$angelegt = $this->buchung($offen, $splits, $trockenlauf);
$angelegt === null ? $zaehler['vorhanden']++ : $zaehler['neu']++;
} catch (\Throwable $e) {
$zaehler['fehler']++;
$this->melde('fehler', 'Zeile ' . $offeneZeile . ': ' . $e->getMessage());
}
$offen = null;
$splits = [];
};
foreach ($this->saetze as $nr => $satz) {
// +2: die Kopfzeile zählt mit, und Tabellenprogramme beginnen bei 1.
$zeilennummer = $nr + 2;
if (self::istSplitzeile($satz)) {
if ($offen === null) {
$zaehler['fehler']++;
$this->melde('fehler', 'Zeile ' . $zeilennummer . ': Splitzeile ohne Buchung davor.');
continue;
}
$splits[] = $satz;
$zaehler['splitzeilen']++;
continue;
}
$schreibe();
$offen = $satz;
$offeneZeile = $zeilennummer;
}
$schreibe();
return $zaehler;
}
/**
* @param array<string,string> $satz
* @param list<array<string,string>> $splitzeilen
* @return int|null Kennung der neuen Buchung, oder null wenn sie schon da war.
*/
private function buchung(array $satz, array $splitzeilen, bool $trockenlauf): ?int
{
$kontoName = trim($satz['konto'] ?? '');
$kontoId = $this->kontoId($kontoName);
if ($kontoId === null) {
throw new \RuntimeException(
'Unbekanntes Konto „' . $kontoName . '“ – Konten legt der Import nicht an.'
);
}
$datum = CsvTabelle::datum($satz['datum'] ?? '');
if ($datum === null) {
throw new \RuntimeException('Kein lesbares Datum („' . ($satz['datum'] ?? '') . '“).');
}
$betrag = CsvTabelle::betrag($satz['betrag'] ?? '');
$text = trim($satz['buchungstext'] ?? '');
$nummer = trim($satz['nummer'] ?? '');
$hash = $this->eindeutigerHash($kontoId, $datum, $betrag, $nummer, $text);
if (Buchung::findeNachHash($hash) !== null) {
return null;
}
$zuordnung = $this->zuordnung($satz, $kontoId);
$splits = [];
foreach ($splitzeilen as $zeile) {
$splits[] = $this->zuordnung($zeile, $kontoId) + [
'betrag' => CsvTabelle::betrag($zeile['betrag'] ?? ''),
'notiz' => trim($zeile['buchungstext'] ?? ''),
];
}
$daten = [
'konto_id' => $kontoId,
'datum' => $datum,
'valuta' => CsvTabelle::datum($satz['valuta'] ?? ''),
'betrag' => $betrag,
'empfaenger' => trim($satz['empfaenger'] ?? ''),
'buchungstext' => $text,
'nummer' => $nummer,
'abgeglichen' => self::jaNein($satz['abgeglichen'] ?? ''),
'kategorie_id' => $zuordnung['kategorie_id'],
'klasse_id' => $zuordnung['klasse_id'],
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'quelle' => 'csv',
'import_hash' => $hash,
];
if ($trockenlauf) {
return 0;
}
return Buchung::anlegen($daten, $splits);
}
/**
* Kategorie, Klasse und Gegenkonto einer Zeile.
*
* Kategorien und Klassen legt der Import bei Bedarf an – eine Exportdatei
* bringt sie oft mit, ohne dass sie hier schon bestünden. Konten dagegen
* nicht: Sie tragen Kontoart, Bankverbindung und Anfangssaldo, und nichts
* davon steht in der Zeile.
*
* @param array<string,string> $satz
* @return array{kategorie_id: ?int, klasse_id: ?int, gegenkonto_id: ?int}
*/
private function zuordnung(array $satz, int $kontoId): array
{
$gegenkonto = trim($satz['gegenkonto'] ?? '');
$klasseId = $this->klasseId(trim($satz['klasse'] ?? ''));
if ($gegenkonto !== '') {
$gegenId = $this->kontoId($gegenkonto);
if ($gegenId === null) {
$this->melde('fehler', 'Umbuchung auf unbekanntes Konto „' . $gegenkonto
. '“ – ohne Gegenkonto übernommen.');
}
// Eine Umbuchung auf das eigene Konto höbe sich auf; der QIF-Import
// verwirft sie aus demselben Grund.
return [
'kategorie_id' => null,
'klasse_id' => $klasseId,
'gegenkonto_id' => $gegenId === $kontoId ? null : $gegenId,
];
}
return [
'kategorie_id' => $this->kategorieId(trim($satz['kategorie'] ?? '')),
'klasse_id' => $klasseId,
'gegenkonto_id' => null,
];
}
/** @return list<string> */
private function fehlendePflichtspalten(): array
{
if ($this->saetze === []) {
return [];
}
$vorhanden = array_keys($this->saetze[0]);
return array_values(array_filter(
self::PFLICHTSPALTEN,
static fn(string $spalte): bool => !in_array($spalte, $vorhanden, true)
));
}
/** @param array<string,string> $satz */
private static function istSplitzeile(array $satz): bool
{
$art = CsvTabelle::schluessel($satz['art'] ?? '');
return $art === 'splitzeile' || $art === 'split';
}
private static function jaNein(string $wert): bool
{
return in_array(CsvTabelle::schluessel($wert), ['ja', 'j', '1', 'x', 'wahr', 'true'], true);
}
/**
* Identische Buchungen am selben Tag kommen vor. Der laufende Zähler hält
* sie auseinander und bleibt über Läufe hinweg stabil, weil die Datei immer
* dieselbe Reihenfolge hat – dieselbe Regel wie beim QIF-Import.
*/
private function eindeutigerHash(int $kontoId, string $datum, float $betrag, string $nummer, string $text): string
{
$basis = Buchung::hash($kontoId, $datum, $betrag, $nummer, $text);
$n = $this->hashZaehler[$basis] = ($this->hashZaehler[$basis] ?? 0) + 1;
return $n === 1 ? $basis : sha1($basis . '#' . $n);
}
private function ladeStammdaten(): void
{
$this->konten = [];
$this->kategorien = [];
$this->klassen = [];
foreach (Konto::alle() as $konto) {
$this->konten[$konto['name']] = (int)$konto['id'];
}
foreach (Kategorie::alle() as $kategorie) {
$this->kategorien[$kategorie['pfad']] = (int)$kategorie['id'];
}
foreach (Klasse::alle() as $klasse) {
$this->klassen[$klasse['name']] = (int)$klasse['id'];
}
}
private function kontoId(string $name): ?int
{
return $this->konten[trim($name)] ?? null;
}
private function kategorieId(string $pfad): ?int
{
$pfad = Kategorie::normalisierePfad($pfad);
if ($pfad === '') {
return null;
}
if (isset($this->kategorien[$pfad])) {
return $this->kategorien[$pfad];
}
$id = Kategorie::findenOderAnlegenNachPfad($pfad);
$this->kategorien[$pfad] = $id;
$this->melde('hinweis', 'Kategorie „' . $pfad . '“ war nicht angelegt und wurde ergänzt.');
return $id;
}
private function klasseId(string $name): ?int
{
$name = trim($name);
if ($name === '') {
return null;
}
if (!isset($this->klassen[$name])) {
$this->klassen[$name] = Klasse::findenOderAnlegen($name);
}
return $this->klassen[$name];
}
private function melde(string $typ, string $text): void
{
// Dieselbe Meldung nicht hundertfach wiederholen.
foreach ($this->meldungen as $vorhanden) {
if ($vorhanden['text'] === $text) {
return;
}
}
$this->meldungen[] = ['typ' => $typ, 'text' => $text];
}
}

233
src/Services/CsvTabelle.php Normal file
View file

@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
/**
* Lesen und Schreiben von deutschem Tabellenkalkulations-CSV.
*
* Semikolon als Trenner, Komma als Dezimalzeichen, kein Tausenderpunkt (der
* macht aus der Zahl einen Text), CRLF am Zeilenende und ein BOM vorweg – sonst
* zeigen Excel und LibreOffice beim Doppelklick „Übrige“ statt „Übrige“.
*
* Beim Lesen wird großzügiger verfahren als beim Schreiben: Eine Datei, die
* anderswo entstanden ist, trennt womöglich mit Komma und schreibt Beträge im
* englischen Format. Beides wird erkannt, statt den Import daran scheitern zu
* lassen.
*/
class CsvTabelle
{
public const BOM = "\u{FEFF}";
public const TRENNER = ';';
/** @param list<list<string>> $zeilen */
public static function schreibe(array $zeilen): string
{
$text = '';
foreach ($zeilen as $zeile) {
$text .= self::zeile($zeile);
}
return self::BOM . $text;
}
/** Eine einzelne Zeile, fertig maskiert und mit CRLF abgeschlossen. */
public static function zeile(array $felder): string
{
$maskiert = array_map(
// Nur maskieren, wo es sein muss: Ein Feld in Anführungszeichen
// liest sich schlechter, wenn es keinen Trenner enthält.
static function (string $feld): string {
if (preg_match('/[;"\r\n]/', $feld) !== 1) {
return $feld;
}
return '"' . str_replace('"', '""', $feld) . '"';
},
array_map('strval', $felder)
);
// CRLF: Das erwarten Tabellenkalkulationen unter Windows, und der Rest
// kommt damit ebenfalls zurecht.
return implode(self::TRENNER, $maskiert) . "\r\n";
}
/**
* Zerlegt eine ganze Datei in Zeilen und Felder.
*
* Gearbeitet wird über einen Speicherstrom und `fgetcsv()`, nicht Zeile für
* Zeile: Ein maskiertes Feld darf einen Zeilenumbruch enthalten, und den
* überlebt nur ein Leser, der die Anführungszeichen mitzählt.
*
* @return list<list<string>>
*/
public static function lies(string $inhalt): array
{
// Der Export ist UTF-8, eine von Hand gepflegte Datei aber oft
// ISO-8859-1. Dieselbe Prüfung wie beim QIF – zweimal konvertieren
// zerstörte die Umlaute.
$inhalt = QifParser::nachUtf8($inhalt);
if (str_starts_with($inhalt, self::BOM)) {
$inhalt = substr($inhalt, strlen(self::BOM));
}
$trenner = self::trennerVon($inhalt);
$strom = fopen('php://memory', 'r+');
if ($strom === false) {
throw new \RuntimeException('Die CSV-Datei konnte nicht gelesen werden.');
}
fwrite($strom, $inhalt);
rewind($strom);
$zeilen = [];
while (($felder = fgetcsv($strom, 0, $trenner, '"', '')) !== false) {
// Eine Leerzeile liefert [null]; sie trägt nichts bei.
if ($felder === [null]) {
continue;
}
$zeilen[] = array_map(static fn($feld): string => trim((string)$feld), $felder);
}
fclose($strom);
return $zeilen;
}
/**
* Zerlegt eine Datei in Kopfzeile und Datensätze: jeder Datensatz wird über
* die Spaltenüberschriften angesprochen, nicht über seine Position. So darf
* die Datei die Spalten umsortieren oder eigene ergänzen.
*
* Die Überschriften werden kleingeschrieben und von Akzenten befreit
* verglichen – „Empfänger“, „empfaenger“ und „EMPFÄNGER“ meinen dasselbe.
*
* @return list<array<string,string>>
*/
public static function datensaetze(string $inhalt): array
{
$zeilen = self::lies($inhalt);
if ($zeilen === []) {
return [];
}
$kopf = array_map(self::schluessel(...), array_shift($zeilen));
$saetze = [];
foreach ($zeilen as $zeile) {
// Eine Zeile, in der nichts steht, ist keine – Tabellenprogramme
// hängen gern eine ans Ende.
if (implode('', $zeile) === '') {
continue;
}
$satz = [];
foreach ($kopf as $nr => $name) {
if ($name !== '') {
$satz[$name] = $zeile[$nr] ?? '';
}
}
$saetze[] = $satz;
}
return $saetze;
}
/** Spaltenname → Schlüssel: klein, ohne Umlaute, ohne Sonderzeichen. */
public static function schluessel(string $name): string
{
$name = strtr(mb_strtolower(trim($name)), [
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss',
]);
return preg_replace('/[^a-z0-9]/', '', $name) ?? '';
}
/** Zahl im deutschen Format, ohne Tausenderpunkt. */
public static function zahl(float $wert, int $stellen = 2): string
{
return number_format($wert, $stellen, ',', '');
}
/**
* Betrag aus einem Feld – deutsch („1.234,56“) wie englisch („1,234.56“).
* Maßgeblich ist, welches Zeichen zuletzt steht: Es trennt die
* Nachkommastellen, das andere ist Tausendertrenner.
*/
public static function betrag(string $wert): float
{
$wert = str_replace(["\u{00A0}", ' ', '€', "'"], '', trim($wert));
if ($wert === '') {
return 0.0;
}
$komma = strrpos($wert, ',');
$punkt = strrpos($wert, '.');
if ($komma !== false && ($punkt === false || $komma > $punkt)) {
$wert = str_replace(',', '.', str_replace('.', '', $wert));
} else {
$wert = str_replace(',', '', $wert);
}
return (float)$wert;
}
/**
* Datum aus einem Feld nach ISO. Angenommen werden „TT.MM.JJJJ“ (so
* schreibt es der Export) und „JJJJ-MM-TT“ (so kommt es aus fremden
* Programmen). Zweistellige Jahre ab 70 gelten als 19xx.
*/
public static function datum(string $wert): ?string
{
$wert = trim($wert);
if ($wert === '') {
return null;
}
if (preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $wert, $t) === 1) {
[$jahr, $monat, $tag] = [(int)$t[1], (int)$t[2], (int)$t[3]];
} elseif (preg_match('/^(\d{1,2})\.(\d{1,2})\.(\d{2,4})$/', $wert, $t) === 1) {
[$tag, $monat, $jahr] = [(int)$t[1], (int)$t[2], (int)$t[3]];
if ($jahr < 100) {
$jahr += $jahr >= 70 ? 1900 : 2000;
}
} else {
return null;
}
if (!checkdate($monat, $tag, $jahr)) {
return null;
}
return sprintf('%04d-%02d-%02d', $jahr, $monat, $tag);
}
/**
* Semikolon oder Komma? Gezählt wird auf der Kopfzeile außerhalb von
* Anführungszeichen – ein Empfängername mit Komma soll die Erkennung nicht
* kippen.
*/
private static function trennerVon(string $inhalt): string
{
$ende = strcspn($inhalt, "\r\n");
$kopf = substr($inhalt, 0, $ende);
$zaehle = static function (string $zeichen) use ($kopf): int {
$anzahl = 0;
$inKlammern = false;
for ($i = 0, $n = strlen($kopf); $i < $n; $i++) {
if ($kopf[$i] === '"') {
$inKlammern = !$inKlammern;
} elseif (!$inKlammern && $kopf[$i] === $zeichen) {
$anzahl++;
}
}
return $anzahl;
};
return $zaehle(',') > $zaehle(self::TRENNER) ? ',' : self::TRENNER;
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
/**
* Die Dateiformate, die Import und Export beherrschen.
*
* Beide Richtungen kennen dieselben Formate, deshalb stehen sie hier und nicht
* je einmal im Importeur und im Exporteur. Kommt ein drittes hinzu, ist das die
* einzige Liste, die es zu ergänzen gilt.
*/
class Dateiformat
{
/** Schlüssel → Bezeichnung in der Oberfläche. Der Schlüssel ist zugleich die Dateiendung. */
public const ALLE = [
'qif' => 'QIF',
'csv' => 'CSV',
];
/** Kurze Beschreibung für die Formatwahl. */
public const BESCHREIBUNGEN = [
'qif' => 'Das Format des Lexware FinanzManagers – Klassen, Kategorien, Konten und Buchungen in einer Datei.',
'csv' => 'Tabelle mit Semikolon als Trenner, für Tabellenkalkulationen. Nur Buchungen, dafür überall lesbar.',
];
public static function gueltig(string $format): bool
{
return isset(self::ALLE[strtolower(trim($format))]);
}
/** Der geprüfte Schlüssel, oder null. */
public static function pruefe(string $format): ?string
{
$format = strtolower(trim($format));
return isset(self::ALLE[$format]) ? $format : null;
}
public static function name(string $format): string
{
return self::ALLE[strtolower(trim($format))] ?? strtoupper($format);
}
/** Format zu einem Dateinamen – anhand der Endung. */
public static function ausDateiname(string $name): ?string
{
return self::pruefe(pathinfo($name, PATHINFO_EXTENSION));
}
}

View file

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Models\Einstellung;
/**
* Räumt die temporären Dateien weg, die Import und Export hinterlassen.
*
* Exportdateien entstehen im Speicher und gehen unmittelbar an den Browser –
* dort bleibt nichts liegen. Beim Import ist es umgekehrt: Die hochgeladene
* Datei bleibt absichtlich stehen, damit sich derselbe Export erst im
* Trockenlauf prüfen und danach übernehmen lässt, ohne ihn zweimal hochzuladen.
* Genau dadurch sammeln sich dort mit der Zeit Kontoauszüge an – Dateien mit
* allen Buchungen des Haushalts, die niemand mehr braucht.
*
* Deshalb dieses Aufräumen: Was älter ist als die eingestellte Frist, wird
* gelöscht. Angestoßen wird es beiläufig beim Öffnen von Import und Export
* (höchstens einmal am Tag) und zusätzlich von `scripts/housekeeping.php`, das
* sich in einen Cron hängen lässt.
*/
class Housekeeping
{
public const EINSTELLUNG_TAGE = 'housekeeping_tage';
public const EINSTELLUNG_LAUF = 'housekeeping_letzter_lauf';
/** Zwei Wochen: lang genug für einen Import, der über ein Wochenende liegt. */
public const STANDARD_TAGE = 14;
/**
* Wie viele Tage bleiben Dateien liegen? 0 schaltet das Aufräumen ab –
* dann bleibt alles stehen, bis es von Hand gelöscht wird.
*/
public static function tage(): int
{
return max(0, Einstellung::getInt(self::EINSTELLUNG_TAGE, self::STANDARD_TAGE));
}
/**
* Die überwachten Verzeichnisse, absolut. Das Importverzeichnis ist
* einstellbar; `data/tmp` ist der Platz für Arbeitsdateien, die ein Modul
* unterwegs braucht – es muss nicht existieren.
*
* @return array<string,string> Anzeigename → absoluter Pfad
*/
public static function verzeichnisse(): array
{
$wurzel = dirname(__DIR__, 2);
$import = trim((string)Einstellung::get('import_qif_verzeichnis', 'data/import'), '/');
return [
$import => $wurzel . '/' . $import,
'data/tmp' => $wurzel . '/data/tmp',
];
}
/** Verzeichnis für Arbeitsdateien; wird bei Bedarf angelegt. */
public static function tempVerzeichnis(): string
{
$pfad = dirname(__DIR__, 2) . '/data/tmp';
if (!is_dir($pfad)) {
mkdir($pfad, 0o775, true);
}
return $pfad;
}
/** Lief das Aufräumen heute schon? */
public static function faellig(?string $heute = null): bool
{
if (self::tage() === 0) {
return false;
}
$heute = $heute ?? date('Y-m-d');
$letzter = (string)Einstellung::get(self::EINSTELLUNG_LAUF, '');
return substr($letzter, 0, 10) !== $heute;
}
/**
* Beiläufiger Lauf: höchstens einmal am Tag, und ohne dass ein Fehler die
* aufrufende Seite mitreißt – wer den Import öffnet, will importieren, nicht
* eine Fehlermeldung übers Aufräumen lesen.
*
* @return array<string,mixed>|null Das Ergebnis, oder null wenn nichts anstand.
*/
public static function beiGelegenheit(): ?array
{
if (!self::faellig()) {
return null;
}
try {
return self::ausfuehren();
} catch (\Throwable $e) {
DebugLog::schreibe('housekeeping', 'Aufräumen fehlgeschlagen: ' . $e->getMessage());
return null;
}
}
/**
* @return array{tage: int, geprueft: int, geloescht: int, bytes: int,
* dateien: list<string>, trockenlauf: bool}
*/
public static function ausfuehren(bool $trockenlauf = false): array
{
$tage = self::tage();
$grenze = time() - $tage * 86400;
$ergebnis = [
'tage' => $tage,
'geprueft' => 0,
'geloescht' => 0,
'bytes' => 0,
'dateien' => [],
'trockenlauf' => $trockenlauf,
];
if ($tage === 0) {
return $ergebnis;
}
foreach (self::verzeichnisse() as $name => $pfad) {
$echt = realpath($pfad);
if ($echt === false || !is_dir($echt)) {
continue;
}
foreach (scandir($echt) ?: [] as $eintrag) {
if ($eintrag === '.' || $eintrag === '..') {
continue;
}
$datei = $echt . '/' . $eintrag;
// Nur gewöhnliche Dateien direkt im Verzeichnis: ein Symlink
// zeigte womöglich aus dem überwachten Bereich heraus, und ein
// Unterverzeichnis hat hier niemand angelegt.
if (is_link($datei) || !is_file($datei)) {
continue;
}
$ergebnis['geprueft']++;
$alter = filemtime($datei);
if ($alter === false || $alter > $grenze) {
continue;
}
$groesse = (int)filesize($datei);
if (!$trockenlauf && !@unlink($datei)) {
continue;
}
$ergebnis['geloescht']++;
$ergebnis['bytes'] += $groesse;
$ergebnis['dateien'][] = $name . '/' . $eintrag;
}
}
if (!$trockenlauf) {
Einstellung::set(self::EINSTELLUNG_LAUF, date('Y-m-d H:i:s'), 'import');
}
return $ergebnis;
}
}

View file

@ -38,14 +38,28 @@ include __DIR__ . '/../layout/kopf.php';
<a href="/banking/regeln" class="tab rechtsbuendig">Regeln</a> <a href="/banking/regeln" class="tab rechtsbuendig">Regeln</a>
</div> </div>
<?php if ($status === 'offen' && $umsaetze !== []): ?> <?php if ($umsaetze !== []): ?>
<div class="seitenaktionen"> <div class="seitenaktionen">
<?php if ($status === 'offen'): ?>
<form method="post" action="/banking/umsaetze/alle" class="inline" <form method="post" action="/banking/umsaetze/alle" class="inline"
data-bestaetigen="Alle eindeutigen Vorschläge übernehmen? Umsätze ohne Regel bleiben offen."> data-bestaetigen="Alle eindeutigen Vorschläge übernehmen? Umsätze ohne Regel bleiben offen.">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf primaer">Alle eindeutigen übernehmen</button> <button type="submit" class="knopf primaer">Alle eindeutigen übernehmen</button>
</form> </form>
<a href="/banking/regeln" class="knopf">Regeln pflegen</a> <a href="/banking/regeln" class="knopf">Regeln pflegen</a>
<?php endif; ?>
<?php /* Wie im Kontoblatt: Das Formular steht in der Aktionsleiste, seine
Ankreuzfelder stehen in den Umsatzkarten – verbunden über das
form-Attribut. Ein Formular um die ganze Liste verschachtelte
sich mit den Formularen der einzelnen Umsätze. */ ?>
<form method="post" action="/banking/umsaetze/loeschen?status=<?= View::h($status) ?>"
id="umsatzauswahl" class="inline"
data-bestaetigen="Die ausgewählten Umsätze wirklich löschen?"
data-frage-eine="Den ausgewählten Umsatz wirklich löschen? Eine daraus erzeugte Buchung bleibt im Kontoblatt stehen."
data-frage-mehrere="{anzahl} ausgewählte Umsätze wirklich löschen? Daraus erzeugte Buchungen bleiben im Kontoblatt stehen.">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf gefahr">Auswahl löschen</button>
</form>
</div> </div>
<?php endif; ?> <?php endif; ?>
@ -56,6 +70,19 @@ include __DIR__ . '/../layout/kopf.php';
: 'Keine Umsätze mit diesem Status.' ?> : 'Keine Umsätze mit diesem Status.' ?>
</p> </p>
<?php else: ?> <?php else: ?>
<?php /* Die Titelleiste der Liste. Eine Tabelle mit Kopfzeile gibt es hier
nicht, das Ankreuzfeld „alle wählen“ braucht aber seinen Platz. */ ?>
<div class="listenkopf">
<label class="ankreuzfeld">
<input type="checkbox" class="alle-waehlen" data-waehlt="umsatzauswahl"
aria-label="Alle angezeigten Umsätze auswählen">
Alle auswählen
</label>
<span class="nebentext">
<?= count($umsaetze) ?> <?= count($umsaetze) === 1 ? 'Umsatz' : 'Umsätze' ?>
</span>
</div>
<?php foreach ($umsaetze as $umsatz): ?> <?php foreach ($umsaetze as $umsatz): ?>
<?php <?php
$id = (int)$umsatz['id']; $id = (int)$umsatz['id'];
@ -65,6 +92,9 @@ include __DIR__ . '/../layout/kopf.php';
?> ?>
<section class="umsatz"> <section class="umsatz">
<div class="umsatzkopf"> <div class="umsatzkopf">
<input type="checkbox" name="umsaetze[]" value="<?= $id ?>" form="umsatzauswahl"
class="umsatzwahl"
aria-label="Umsatz vom <?= View::h(View::datum($umsatz['buchungstag'])) ?> über <?= View::h(View::betrag($umsatz['betrag'])) ?> auswählen">
<div class="umsatztext"> <div class="umsatztext">
<strong><?= View::h($umsatz['empfaenger'] ?: 'Ohne Empfänger') ?></strong> <strong><?= View::h($umsatz['empfaenger'] ?: 'Ohne Empfänger') ?></strong>
<span class="nebentext"><?= View::h(View::datum($umsatz['buchungstag'])) ?> <span class="nebentext"><?= View::h(View::datum($umsatz['buchungstag'])) ?>

View file

@ -33,6 +33,15 @@ $eigenesKonto = (int)$konto['id'];
$mitSplit = false; $mitSplit = false;
$mitDepot = true; $mitDepot = true;
// Zusatz hinter dem Kontonamen: was das Konto eindeutig macht, ohne die Zeile
// zu überladen. IBAN sticht, sonst Bank und Kontonummer.
$kontozusatz = trim((string)($konto['iban'] ?? '')) !== ''
? (string)$konto['iban']
: trim(implode(' · ', array_filter([
trim((string)($konto['bankname'] ?? '')),
trim((string)($konto['kontonummer'] ?? '')),
])));
// Immer ein paar leere Splitzeilen anbieten; weitere fügt der Knopf hinzu. // Immer ein paar leere Splitzeilen anbieten; weitere fügt der Knopf hinzu.
$splitZeilen = $splits; $splitZeilen = $splits;
for ($i = count($splitZeilen); $i < 4; $i++) { for ($i = count($splitZeilen); $i < 4; $i++) {
@ -48,9 +57,22 @@ include __DIR__ . '/../layout/kopf.php';
<span class="saldoanzeige">Konto <strong><?= View::h($konto['name']) ?></strong></span> <span class="saldoanzeige">Konto <strong><?= View::h($konto['name']) ?></strong></span>
</div> </div>
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit buchungsmaske"> <form method="post" action="<?= View::h($ziel) ?>" class="formular breit zweispaltig beschriftung-links buchungsmaske">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<div class="feld ganz">
<label title="<?= View::h($neu
? 'In diesem Konto wird die Buchung gespeichert.'
: 'In diesem Konto ist die Buchung gespeichert.') ?>">Konto</label>
<p class="feldwert">
<strong><?= View::h($konto['name']) ?></strong>
<?php if ($kontozusatz !== ''): ?>
<span class="optional"><?= View::h($kontozusatz) ?></span>
<?php endif; ?>
</p>
</div>
<div class="feld">
<label for="art">Art</label> <label for="art">Art</label>
<select id="art" name="art"> <select id="art" name="art">
<?php foreach ([ <?php foreach ([
@ -62,31 +84,35 @@ include __DIR__ . '/../layout/kopf.php';
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div>
<div class="feld">
<label for="datum">Datum</label> <label for="datum">Datum</label>
<input type="date" id="datum" name="datum" required <input type="date" id="datum" name="datum" required
value="<?= View::h($neu ? date('Y-m-d') : substr((string)$buchung['datum'], 0, 10)) ?>"> value="<?= View::h($neu ? date('Y-m-d') : substr((string)$buchung['datum'], 0, 10)) ?>">
</div>
<label for="betrag">Betrag</label> <div class="feld">
<label for="betrag" title="Immer positiv eingeben – die Richtung bestimmt die Art.">Betrag</label>
<input type="text" id="betrag" name="betrag" inputmode="decimal" required <input type="text" id="betrag" name="betrag" inputmode="decimal" required
value="<?= View::h($neu ? '' : View::betrag(abs($betrag), false)) ?>"> value="<?= View::h($neu ? '' : View::betrag(abs($betrag), false)) ?>">
<p class="feldhinweis">Immer positiv eingeben – die Richtung bestimmt die Art oben.</p> </div>
<div class="feld">
<label for="empfaenger">Empfänger</label> <label for="empfaenger">Empfänger</label>
<input type="text" id="empfaenger" name="empfaenger" <input type="text" id="empfaenger" name="empfaenger"
value="<?= View::h((string)($buchung['empfaenger'] ?? '')) ?>"> value="<?= View::h((string)($buchung['empfaenger'] ?? '')) ?>">
</div>
<label for="zuordnung">Kategorie / Gegenkonto</label> <div class="feld">
<label for="zuordnung" title="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.">Kategorie / Gegenkonto</label>
<select id="zuordnung" name="zuordnung"> <select id="zuordnung" name="zuordnung">
<?php $gewaehlt = $zuordnungswert($buchung); ?> <?php $gewaehlt = $zuordnungswert($buchung); ?>
<?php include __DIR__ . '/zuordnungsliste.php'; ?> <?php include __DIR__ . '/zuordnungsliste.php'; ?>
</select> </select>
<p class="feldhinweis"> </div>
Unter den Kategorien stehen die eigenen Konten in eckigen Klammern. Eines davon
macht die Buchung zur Umbuchung: Sie wird einmal gespeichert und erscheint auf
beiden Kontoblättern – auf dem Gegenkonto mit umgekehrtem Vorzeichen.
</p>
<div class="feld">
<label for="klasse_id">Klasse</label> <label for="klasse_id">Klasse</label>
<select id="klasse_id" name="klasse_id"> <select id="klasse_id" name="klasse_id">
<option value="">— keine —</option> <option value="">— keine —</option>
@ -97,26 +123,29 @@ include __DIR__ . '/../layout/kopf.php';
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div>
<div class="feld">
<label for="buchungstext">Buchungstext</label> <label for="buchungstext">Buchungstext</label>
<input type="text" id="buchungstext" name="buchungstext" <input type="text" id="buchungstext" name="buchungstext"
value="<?= View::h((string)($buchung['buchungstext'] ?? '')) ?>"> value="<?= View::h((string)($buchung['buchungstext'] ?? '')) ?>">
</div>
<div class="feld">
<label for="nummer">Nummer <span class="optional">(optional)</span></label> <label for="nummer">Nummer <span class="optional">(optional)</span></label>
<input type="text" id="nummer" name="nummer" value="<?= View::h((string)($buchung['nummer'] ?? '')) ?>"> <input type="text" id="nummer" name="nummer" value="<?= View::h((string)($buchung['nummer'] ?? '')) ?>">
</div>
<div class="feld ganz">
<label class="ankreuzfeld"> <label class="ankreuzfeld">
<input type="checkbox" name="abgeglichen" value="1" <input type="checkbox" name="abgeglichen" value="1"
<?= !empty($buchung['abgeglichen']) ? 'checked' : '' ?>> <?= !empty($buchung['abgeglichen']) ? 'checked' : '' ?>>
Abgeglichen Abgeglichen
</label> </label>
</div>
<h3>Splitbuchung <span class="optional">(optional)</span></h3> <h3><span title="Sobald hier Beträge stehen, gilt die Zuordnung aus den Zeilen; Kategorie und Gegenkonto oben bleiben dann außen vor. Die Summe muss dem Buchungsbetrag entsprechen.">Splitbuchung</span>
<p class="feldhinweis"> <span class="optional">(optional)</span></h3>
Sobald hier Beträge stehen, gilt die Zuordnung aus den Zeilen; Kategorie und
Gegenkonto oben bleiben dann außen vor. Die Summe muss dem Buchungsbetrag
entsprechen.
</p>
<table class="liste splittabelle"> <table class="liste splittabelle">
<thead> <thead>

View file

@ -2,6 +2,7 @@
use Finanzmanager\Models\Einstellung; use Finanzmanager\Models\Einstellung;
use Finanzmanager\Services\Cockpit; use Finanzmanager\Services\Cockpit;
use Finanzmanager\Services\Housekeeping;
use Finanzmanager\Services\Kursabruf; use Finanzmanager\Services\Kursabruf;
use Finanzmanager\Services\Wertpapierkosten; use Finanzmanager\Services\Wertpapierkosten;
use Finanzmanager\View; use Finanzmanager\View;
@ -15,6 +16,10 @@ use Finanzmanager\View;
$wert = static fn(string $k, string $standard = ''): string => (string)($werte[$k] ?? $standard); $wert = static fn(string $k, string $standard = ''): string => (string)($werte[$k] ?? $standard);
// Der Zeitstempel des letzten Aufräumens ist kein Feld zum Bearbeiten, sondern
// eine Auskunft – er steht deshalb im Hinweistext unter der Frist.
$letzterLauf = $wert(Housekeeping::EINSTELLUNG_LAUF);
include __DIR__ . '/../layout/kopf.php'; include __DIR__ . '/../layout/kopf.php';
?> ?>
@ -83,9 +88,9 @@ include __DIR__ . '/../layout/kopf.php';
<input type="text" id="import_qif_verzeichnis" name="import_qif_verzeichnis" <input type="text" id="import_qif_verzeichnis" name="import_qif_verzeichnis"
value="<?= View::h($wert('import_qif_verzeichnis', 'data/import')) ?>"> value="<?= View::h($wert('import_qif_verzeichnis', 'data/import')) ?>">
<p class="feldhinweis"> <p class="feldhinweis">
Relativ zum Projektverzeichnis. Hochgeladene QIF-Dateien landen hier und bleiben Relativ zum Projektverzeichnis. Hochgeladene QIF- und CSV-Dateien landen hier und
liegen, damit derselbe Export erst im Trockenlauf geprüft und dann übernommen bleiben liegen, damit derselbe Export erst im Trockenlauf geprüft und dann
werden kann. übernommen werden kann.
</p> </p>
<label for="import_stichtag">Stichtag für Buchungen</label> <label for="import_stichtag">Stichtag für Buchungen</label>
@ -98,8 +103,22 @@ include __DIR__ . '/../layout/kopf.php';
FinanzManagers und bleiben draußen. FinanzManagers und bleiben draußen.
</p> </p>
<label for="housekeeping_tage">Importdateien aufbewahren (Tage)</label>
<input type="text" id="housekeeping_tage" name="housekeeping_tage" inputmode="numeric"
value="<?= View::h($wert('housekeeping_tage', (string)Housekeeping::STANDARD_TAGE)) ?>">
<p class="feldhinweis">
Das Housekeeping räumt ältere Dateien aus dem Importverzeichnis und aus
<code>data/tmp</code> weg – höchstens einmal am Tag, angestoßen beim Öffnen von
Import und Export. <code>0</code> schaltet es ab. Exportdateien entstehen im
Speicher und werden ohnehin nicht abgelegt.
<?php if ($letzterLauf !== ''): ?>
Zuletzt gelaufen: <?= View::h($letzterLauf) ?>.
<?php endif; ?>
</p>
<div class="knopfleiste"> <div class="knopfleiste">
<a href="/verwaltung/import" class="knopf">QIF-Import öffnen</a> <a href="/verwaltung/import" class="knopf">Import öffnen</a>
<a href="/verwaltung/export" class="knopf">Export öffnen</a>
</div> </div>
<?php elseif ($bereich === 'banking'): ?> <?php elseif ($bereich === 'banking'): ?>

View file

@ -19,12 +19,15 @@ include __DIR__ . '/../layout/kopf.php';
<a href="/konten" class="knopf">Zurück zur Kontenübersicht</a> <a href="/konten" class="knopf">Zurück zur Kontenübersicht</a>
</div> </div>
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit"> <form method="post" action="<?= View::h($ziel) ?>" class="formular breit zweispaltig">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<div class="feld">
<label for="name">Name des Kontos</label> <label for="name">Name des Kontos</label>
<input type="text" id="name" name="name" value="<?= $wert('name') ?>" required autofocus> <input type="text" id="name" name="name" value="<?= $wert('name') ?>" required autofocus>
</div>
<div class="feld">
<label for="kontoart">Kontoart</label> <label for="kontoart">Kontoart</label>
<select id="kontoart" name="kontoart"> <select id="kontoart" name="kontoart">
<?php foreach (Konto::ARTEN as $schluessel => $beschriftung): ?> <?php foreach (Konto::ARTEN as $schluessel => $beschriftung): ?>
@ -34,7 +37,9 @@ include __DIR__ . '/../layout/kopf.php';
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div>
<div class="feld">
<label for="kontengruppe_id">Kontengruppe</label> <label for="kontengruppe_id">Kontengruppe</label>
<select id="kontengruppe_id" name="kontengruppe_id"> <select id="kontengruppe_id" name="kontengruppe_id">
<option value="">Nicht gruppiert</option> <option value="">Nicht gruppiert</option>
@ -45,19 +50,34 @@ include __DIR__ . '/../layout/kopf.php';
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div>
<div class="feld">
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
<input type="text" id="beschreibung" name="beschreibung" value="<?= $wert('beschreibung') ?>">
</div>
<div class="feld">
<label for="bankname">Bank <span class="optional">(optional)</span></label> <label for="bankname">Bank <span class="optional">(optional)</span></label>
<input type="text" id="bankname" name="bankname" value="<?= $wert('bankname') ?>"> <input type="text" id="bankname" name="bankname" value="<?= $wert('bankname') ?>">
</div>
<div class="feld">
<label for="kontonummer">Kontonummer <span class="optional">(optional)</span></label> <label for="kontonummer">Kontonummer <span class="optional">(optional)</span></label>
<input type="text" id="kontonummer" name="kontonummer" value="<?= $wert('kontonummer') ?>"> <input type="text" id="kontonummer" name="kontonummer" value="<?= $wert('kontonummer') ?>">
</div>
<div class="feld">
<label for="iban">IBAN <span class="optional">(optional)</span></label> <label for="iban">IBAN <span class="optional">(optional)</span></label>
<input type="text" id="iban" name="iban" value="<?= $wert('iban') ?>"> <input type="text" id="iban" name="iban" value="<?= $wert('iban') ?>">
</div>
<div class="feld">
<label for="bic">BIC <span class="optional">(optional)</span></label> <label for="bic">BIC <span class="optional">(optional)</span></label>
<input type="text" id="bic" name="bic" value="<?= $wert('bic') ?>"> <input type="text" id="bic" name="bic" value="<?= $wert('bic') ?>">
</div>
<div class="feld">
<label for="anfangssaldo">Anfangssaldo</label> <label for="anfangssaldo">Anfangssaldo</label>
<input type="text" id="anfangssaldo" name="anfangssaldo" inputmode="decimal" <input type="text" id="anfangssaldo" name="anfangssaldo" inputmode="decimal"
value="<?= View::h(View::betrag($konto['anfangssaldo'] ?? 0, false)) ?>"> value="<?= View::h(View::betrag($konto['anfangssaldo'] ?? 0, false)) ?>">
@ -65,19 +85,22 @@ include __DIR__ . '/../layout/kopf.php';
Kontostand vor der ersten erfassten Buchung. Der QIF-Export enthält ihn nicht – Kontostand vor der ersten erfassten Buchung. Der QIF-Export enthält ihn nicht –
nach einem Import wird er hier je Konto nachgetragen. nach einem Import wird er hier je Konto nachgetragen.
</p> </p>
</div>
<div class="feld">
<label for="kreditlimit">Kreditlimit <span class="optional">(optional)</span></label> <label for="kreditlimit">Kreditlimit <span class="optional">(optional)</span></label>
<input type="text" id="kreditlimit" name="kreditlimit" inputmode="decimal" <input type="text" id="kreditlimit" name="kreditlimit" inputmode="decimal"
value="<?= $konto === null || $konto['kreditlimit'] === null value="<?= $konto === null || $konto['kreditlimit'] === null
? '' : View::h(View::betrag($konto['kreditlimit'], false)) ?>"> ? '' : View::h(View::betrag($konto['kreditlimit'], false)) ?>">
</div>
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label> <div class="feld">
<input type="text" id="beschreibung" name="beschreibung" value="<?= $wert('beschreibung') ?>">
<label for="sortierung">Sortierung</label> <label for="sortierung">Sortierung</label>
<input type="number" id="sortierung" name="sortierung" value="<?= $wert('sortierung', '0') ?>" step="1"> <input type="number" id="sortierung" name="sortierung" value="<?= $wert('sortierung', '0') ?>" step="1">
<p class="feldhinweis">Kleinere Werte stehen in der Kontenübersicht weiter oben.</p> <p class="feldhinweis">Kleinere Werte stehen in der Kontenübersicht weiter oben.</p>
</div>
<div class="feld">
<label class="ankreuzfeld"> <label class="ankreuzfeld">
<input type="checkbox" name="aktiv" value="1" <?= $neu || (int)$konto['aktiv'] === 1 ? 'checked' : '' ?>> <input type="checkbox" name="aktiv" value="1" <?= $neu || (int)$konto['aktiv'] === 1 ? 'checked' : '' ?>>
Konto ist aktiv Konto ist aktiv
@ -93,6 +116,7 @@ include __DIR__ . '/../layout/kopf.php';
darstellen – durchlaufende Auslagen etwa –, bleiben so aus der Gesamtsumme heraus, darstellen – durchlaufende Auslagen etwa –, bleiben so aus der Gesamtsumme heraus,
erscheinen aber weiter in der Übersicht. erscheinen aber weiter in der Übersicht.
</p> </p>
</div>
<div class="knopfleiste"> <div class="knopfleiste">
<button type="submit" class="knopf primaer"><?= $neu ? 'Konto anlegen' : 'Speichern' ?></button> <button type="submit" class="knopf primaer"><?= $neu ? 'Konto anlegen' : 'Speichern' ?></button>
@ -101,7 +125,7 @@ include __DIR__ . '/../layout/kopf.php';
</form> </form>
<?php if (!$neu): ?> <?php if (!$neu): ?>
<form method="post" action="/konten/<?= (int)$konto['id'] ?>/loeschen" class="formular breit" <form method="post" action="/konten/<?= (int)$konto['id'] ?>/loeschen" class="formular breit zweispaltig"
data-bestaetigen="Konto „<?= View::h($konto['name']) ?>“ wirklich löschen?"> data-bestaetigen="Konto „<?= View::h($konto['name']) ?>“ wirklich löschen?">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Konto löschen</h3> <h3>Konto löschen</h3>

View file

@ -0,0 +1,148 @@
<?php
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\View;
/** @var string $format */
/** @var array{von: string, bis: string, konten: list<int>, kategorien: list<int>, klassen: list<int>} $filter */
/** @var list<array<string,mixed>> $konten */
/** @var list<array<string,mixed>> $kategorien */
/** @var list<array<string,mixed>> $klassen */
/** @var int $anzahl */
/** @var string $csrfToken */
// Kein Häkchen heißt „alles“ – deshalb ist angehakt nur, was ausdrücklich
// gewählt wurde. Alles vorzuhaken sähe gefälliger aus, schickte aber bei jedem
// Klick die Kennung jeder Kategorie mit.
$gewaehlt = static fn(array $ids, int $id): bool => in_array($id, $ids, true);
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
<a href="/verwaltung/export" class="knopf">Anderes Format</a>
<a href="/verwaltung/import?format=<?= View::h($format) ?>" class="knopf">Import</a>
</div>
<div class="kachel">
<header>
<h2>Buchungen als <?= View::h(Dateiformat::name($format)) ?> ausgeben</h2>
<span class="phasenmarke"><?= (int)$anzahl ?> Buchungen</span>
</header>
<p class="hinweis">
Ohne Häkchen zählt alles. Eine <strong>Umbuchung</strong> steht nur einmal in der
Datei, beim erfassenden Konto – sie kommt deshalb auch dann mit, wenn allein das
Gegenkonto gewählt ist. Bei <strong>Splitbuchungen</strong> zählt die Kategorie und
die Klasse jeder einzelnen Zeile.
</p>
<form method="post" action="/verwaltung/export" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="format" value="<?= View::h($format) ?>">
<div class="filterzeile">
<label>
Buchungen ab
<input type="date" name="von" value="<?= View::h($filter['von']) ?>">
</label>
<label>
bis
<input type="date" name="bis" value="<?= View::h($filter['bis']) ?>">
</label>
</div>
<h3>Konten</h3>
<?php if ($konten === []): ?>
<p class="platzhalter">Noch keine Konten angelegt.</p>
<?php else: ?>
<div class="auswahlliste">
<?php foreach ($konten as $konto): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="konten[]" value="<?= (int)$konto['id'] ?>"
<?= $gewaehlt($filter['konten'], (int)$konto['id']) ? 'checked' : '' ?>>
<span><?= View::h((string)$konto['name']) ?></span>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?>
<h3>Kategorien</h3>
<?php if ($kategorien === []): ?>
<p class="platzhalter">Noch keine Kategorien angelegt.</p>
<?php else: ?>
<div class="auswahlliste">
<?php foreach ($kategorien as $kategorie): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="kategorien[]" value="<?= (int)$kategorie['id'] ?>"
<?= $gewaehlt($filter['kategorien'], (int)$kategorie['id']) ? 'checked' : '' ?>>
<span><?= View::h(str_repeat('· ', (int)$kategorie['ebene']) . $kategorie['name']) ?></span>
</label>
<?php endforeach; ?>
</div>
<p class="feldhinweis">
Gewählt wird jede Kategorie für sich – eine Oberkategorie zieht ihre
Unterkategorien nicht mit.
</p>
<?php endif; ?>
<h3>Klassen</h3>
<?php if ($klassen === []): ?>
<p class="platzhalter">Noch keine Klassen angelegt.</p>
<?php else: ?>
<div class="auswahlliste">
<?php foreach ($klassen as $klasse): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="klassen[]" value="<?= (int)$klasse['id'] ?>"
<?= $gewaehlt($filter['klassen'], (int)$klasse['id']) ? 'checked' : '' ?>>
<span><?= View::h((string)$klasse['name']) ?></span>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="knopfleiste">
<button type="submit" formaction="/verwaltung/export/datei" class="knopf primaer">
<?= View::h(Dateiformat::name($format)) ?> herunterladen
</button>
<button type="submit" class="knopf">Treffer zählen</button>
</div>
</form>
</div>
<div class="kachel">
<header><h2>Was in der Datei steht</h2></header>
<?php if ($format === 'qif'): ?>
<p class="hinweis">
Das QIF folgt dem Dialekt des Lexware FinanzManagers: erst die Klassen, dann die
Kategorien, dann die Konten, danach je Konto dessen Buchungen. Klassen und
Kategorien kommen vollständig mit – ohne sie ließe sich die Datei auf einer leeren
Installation nicht einspielen. Die Konten dagegen nur, soweit der Filter sie
zulässt.
</p>
<p class="hinweis">
Wertpapiergeschäfte stehen nicht darin; sie hängen am Depot und werden über die
<a href="/wertpapiere">Wertpapierverwaltung</a> gepflegt.
</p>
<?php else: ?>
<p class="hinweis">
Eine Tabelle mit Semikolon als Trenner, Komma als Dezimalzeichen und einem BOM
vorweg – so öffnet sie Excel wie LibreOffice per Doppelklick, ohne dass die
Umlaute zerfallen. Splitzeilen folgen unmittelbar auf ihre Buchung und tragen in
der ersten Spalte das Wort <code>Splitzeile</code>.
</p>
<p class="hinweis">
Dieselbe Datei liest der <a href="/verwaltung/import?format=csv">CSV-Import</a>
wieder ein.
</p>
<?php endif; ?>
<p class="hinweis">
Die Datei entsteht erst beim Herunterladen und wird nirgends abgelegt: Ein Auszug der
eigenen Finanzen soll nicht im Dateisystem des Servers liegen bleiben.
</p>
</div>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,32 @@
<?php
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\View;
/** @var string $richtung Import oder Export – nur für die Beschriftung. */
/** @var string $ziel Adresse, an die das gewählte Format angehängt wird. */
/** @var string $einleitung */
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
</div>
<div class="kachel">
<header><h2><?= View::h($richtung) ?>: Format wählen</h2></header>
<p class="platzhalter"><?= View::h($einleitung) ?></p>
<div class="formatwahl">
<?php foreach (Dateiformat::ALLE as $schluessel => $name): ?>
<a class="formatkarte" href="<?= View::h($ziel) ?>?format=<?= View::h($schluessel) ?>">
<span class="formatname"><?= View::h($name) ?></span>
<span class="nebentext"><?= View::h(Dateiformat::BESCHREIBUNGEN[$schluessel] ?? '') ?></span>
</a>
<?php endforeach; ?>
</div>
</div>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -1,248 +1,55 @@
<?php <?php
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\View; use Finanzmanager\View;
/** @var string $format */
/** @var list<array{name: string, groesse: int, geaendert: int}> $dateien */ /** @var list<array{name: string, groesse: int, geaendert: int}> $dateien */
/** @var string $verzeichnis */ /** @var string $verzeichnis */
/** @var array<string,mixed>|null $ergebnis */ /** @var array<string,mixed>|null $ergebnis */
/** @var array<string,mixed>|null $buchungen */ /** @var array<string,mixed>|null $buchungen */
/** @var array<string,mixed>|null $vorlagen */ /** @var array<string,mixed>|null $vorlagen */
/** @var array<string,mixed>|null $ueberblick */
/** @var string $stichtag */ /** @var string $stichtag */
/** @var string $bis */ /** @var string $bis */
/** @var string $datei */ /** @var string $datei */
/** @var array<string,int> $dateikonten */ /** @var array<string,int> $dateikonten */
/** @var list<string> $auswahl */ /** @var list<string> $auswahl */
/** @var int $aufbewahrung */
/** @var string $csrfToken */ /** @var string $csrfToken */
$bereiche = [ $endung = '.' . $format;
'klassen' => 'Klassen',
'kategorien' => 'Kategorien',
'konten' => 'Konten',
];
include __DIR__ . '/../layout/kopf.php'; include __DIR__ . '/../layout/kopf.php';
?> ?>
<div class="seitenaktionen"> <div class="seitenaktionen">
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a> <a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
<a href="/verwaltung/kategorien" class="knopf">Kategorien</a> <a href="/verwaltung/import" class="knopf">Anderes Format</a>
<a href="/konten" class="knopf">Kontenübersicht</a> <a href="/verwaltung/export?format=<?= View::h($format) ?>" class="knopf">Export</a>
</div> </div>
<?php if ($ergebnis !== null): ?> <?php if ($format === 'csv'): ?>
<div class="kachel"> <?php include __DIR__ . '/teil-import-csv.php'; ?>
<header>
<h2><?= $ergebnis['trockenlauf'] ? 'Trockenlauf' : 'Import abgeschlossen' ?></h2>
<span class="phasenmarke"><?= View::h($datei) ?></span>
</header>
<?php if ($ergebnis['trockenlauf']): ?>
<p class="hinweis">
Nichts wurde gespeichert. Die Zahlen zeigen, was ein Import anlegen würde.
</p>
<?php endif; ?>
<table class="liste">
<thead>
<tr>
<th>Bereich</th>
<th class="rechts"><?= $ergebnis['trockenlauf'] ? 'Neu' : 'Angelegt' ?></th>
<th class="rechts">Schon vorhanden</th>
<th class="rechts">Fehler</th>
</tr>
</thead>
<tbody>
<?php foreach ($bereiche as $schluessel => $beschriftung): ?>
<tr>
<td><?= View::h($beschriftung) ?></td>
<td class="rechts"><?= (int)$ergebnis[$schluessel]['neu'] ?></td>
<td class="rechts nebentext"><?= (int)$ergebnis[$schluessel]['vorhanden'] ?></td>
<td class="rechts <?= (int)$ergebnis[$schluessel]['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
<?= (int)$ergebnis[$schluessel]['fehler'] ?>
</td>
</tr>
<?php endforeach; ?>
<tr>
<td>Kontengruppen</td>
<td class="rechts nebentext" colspan="3">
nicht im QIF-Export enthalten – <a href="/verwaltung/kontengruppen">hier zuordnen</a>
</td>
</tr>
</tbody>
</table>
<p class="hinweis">
Der FinanzManager exportiert je Konto nur Name, Kontoart, Beschreibung und
Kreditlimit. Die Zuordnung zu einer Kontengruppe steht weder im QIF noch in der
<code>QSD</code>-Datei; sie wird deshalb einmalig in der Verwaltung nachgetragen.
</p>
<?php if ($buchungen !== null): ?>
<h3>Buchungen</h3>
<table class="liste">
<tbody>
<tr>
<td><?= $buchungen['trockenlauf'] ? 'Neu' : 'Angelegt' ?></td>
<td class="rechts"><?= (int)$buchungen['buchungen']['neu'] ?></td>
</tr>
<tr>
<td class="nebentext">schon vorhanden</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['vorhanden'] ?></td>
</tr>
<tr>
<td class="nebentext">
vor dem Stichtag – in den Anfangssaldo von
<?= (int)$buchungen['anfangssalden']['konten'] ?> Konten verrechnet
</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['vor_stichtag'] ?></td>
</tr>
<tr>
<td class="nebentext">nach dem Enddatum – vorausberechnet, verworfen</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['zukunft'] ?></td>
</tr>
<tr>
<td class="nebentext">darunter Wertpapiergeschäfte (Depot- und Geldbuchung)</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['wertpapier'] ?></td>
</tr>
<?php if ((int)($buchungen['buchungen']['nicht_gewaehlt'] ?? 0) > 0): ?>
<tr>
<td class="nebentext">gehört zu keinem der gewählten Konten</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['nicht_gewaehlt'] ?></td>
</tr>
<?php endif; ?>
<tr>
<td>Fehler</td>
<td class="rechts <?= (int)$buchungen['buchungen']['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
<?= (int)$buchungen['buchungen']['fehler'] ?>
</td>
</tr>
</tbody>
</table>
<p class="hinweis">
Zeitraum <?= View::datum($buchungen['stichtag']) ?> bis <?= View::datum($buchungen['bis']) ?>.
Alles davor ist nicht verloren: es steckt im Anfangssaldo der Konten.
<?php if (($buchungen['konten'] ?? null) !== null): ?>
Übernommen wurden nur die Buchungen von
<strong><?= View::h(implode(', ', $buchungen['konten'])) ?></strong> – einschließlich
der Umbuchungen, die auf einem anderen Konto erfasst wurden und hierher zeigen.
Die Anfangssalden der übrigen Konten bleiben unangetastet.
<?php endif; ?>
</p>
<?php endif; ?>
<?php if (($vorlagen ?? null) !== null): ?>
<h3>Buchungsvorlagen</h3>
<table class="liste">
<tbody>
<tr>
<td><?= $buchungen !== null && $buchungen['trockenlauf'] ? 'Neu' : 'Angelegt' ?></td>
<td class="rechts"><?= (int)$vorlagen['neu'] ?></td>
</tr>
<tr>
<td class="nebentext">bereits vorhanden</td>
<td class="rechts nebentext"><?= (int)$vorlagen['vorhanden'] ?></td>
</tr>
<tr>
<td>Fehler</td>
<td class="rechts <?= (int)$vorlagen['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
<?= (int)$vorlagen['fehler'] ?>
</td>
</tr>
</tbody>
</table>
<p class="hinweis">
Der FinanzManager merkt sich jede erfasste Zahlung als Vorlage – im
Beispielexport sind das mehrere hundert. Sie stehen danach unter
<a href="/vorlagen">Buchungsvorlagen</a>.
</p>
<?php endif; ?>
<?php if (!empty($ergebnis['abschnitte'])): ?>
<h3>Inhalt der Datei</h3>
<p class="hinweis">
<?php $teile = [];
foreach ($ergebnis['abschnitte'] as $typ => $anzahl) {
$teile[] = View::h((string)$typ) . ': ' . (int)$anzahl;
}
echo implode(' · ', $teile); ?>
</p>
<p class="hinweis">
Buchungsvorlagen (<code>Memorized</code>) folgen in Phase 7, Wertpapierbestände
in Phase 6.
</p>
<?php endif; ?>
<?php if (!empty($ergebnis['meldungen'])): ?>
<h3>Protokoll</h3>
<ul class="protokoll">
<?php foreach ($ergebnis['meldungen'] as $meldung): ?>
<li class="<?= View::h($meldung['typ']) ?>"><?= View::h($meldung['text']) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($ergebnis['trockenlauf']): ?>
<form method="post" action="/verwaltung/import/ausfuehren" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="datei" value="<?= View::h($datei) ?>">
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
<input type="hidden" name="bis" value="<?= View::h($bis) ?>">
<?php if ($dateikonten !== []): ?>
<h3>Konten</h3>
<p class="feldhinweis">
Zur Wahl stehen die Konten, die in dieser Datei vorkommen. Ohne Häkchen
zählt die ganze Datei. Eine Umbuchung kommt mit, sobald eines der beiden
Konten gewählt ist – sie steht im Export nur einmal, beim erfassenden
Konto. Die Zahl nennt die Einträge, die das Konto berühren.
</p>
<div class="auswahlliste">
<?php foreach ($dateikonten as $name => $anzahl): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="konten[]" value="<?= View::h((string)$name) ?>"
<?= $auswahl === [] || in_array((string)$name, $auswahl, true) ? 'checked' : '' ?>>
<span>
<?= View::h((string)$name) ?>
<span class="nebentext"><?= (int)$anzahl ?></span>
</span>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="knopfleiste">
<button type="submit" name="modus" value="import" class="knopf primaer">Jetzt übernehmen</button>
<?php if ($dateikonten !== []): ?>
<button type="submit" name="modus" value="vorschau" class="knopf">
Trockenlauf mit dieser Auswahl
</button>
<?php endif; ?>
</div>
</form>
<?php else: ?> <?php else: ?>
<div class="knopfleiste"> <?php include __DIR__ . '/teil-import-qif.php'; ?>
<a href="/konten" class="knopf primaer">Kontenübersicht</a>
<a href="/verwaltung/kontengruppen" class="knopf">Kontengruppen</a>
<a href="/verwaltung/kategorien" class="knopf">Kategorien</a>
</div>
<p class="hinweis">
Die Anfangssalden wurden aus dem Zeitraum vor dem Stichtag berechnet. In der
Kontenübersicht lässt sich jeder Wert nachträglich ändern – ebenso das
Kennzeichen, ob ein Konto zur Summe der Kontostände zählt.
</p>
<?php endif; ?>
</div>
<?php endif; ?> <?php endif; ?>
<div class="kachel"> <div class="kachel">
<header><h2>Exportdatei hochladen</h2></header> <header><h2><?= View::h(Dateiformat::name($format)) ?>-Datei hochladen</h2></header>
<form method="post" action="/verwaltung/import/hochladen" enctype="multipart/form-data" class="formular"> <form method="post" action="/verwaltung/import/hochladen" enctype="multipart/form-data" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="qif">QIF-Datei</label> <input type="hidden" name="format" value="<?= View::h($format) ?>">
<input type="file" id="qif" name="qif" accept=".qif,.QIF" required> <label for="datei"><?= View::h(Dateiformat::name($format)) ?>-Datei</label>
<input type="file" id="datei" name="datei"
accept="<?= View::h($endung . ',' . strtoupper($endung)) ?>" required>
<p class="feldhinweis"> <p class="feldhinweis">
Die Datei wird unter <code><?= View::h($verzeichnis) ?></code> abgelegt und bleibt Die Datei wird unter <code><?= View::h($verzeichnis) ?></code> abgelegt und bleibt
dort – so lässt sich derselbe Export erst prüfen und dann übernehmen. dort – so lässt sich derselbe Export erst prüfen und dann übernehmen.
<?php if ($aufbewahrung > 0): ?>
Nach <?= (int)$aufbewahrung ?> Tagen räumt das Housekeeping sie weg; die Frist steht
in den <a href="/einstellungen?bereich=import">Einstellungen</a>.
<?php endif; ?>
</p> </p>
<div class="knopfleiste"> <div class="knopfleiste">
<button type="submit" class="knopf primaer">Hochladen</button> <button type="submit" class="knopf primaer">Hochladen</button>
@ -251,24 +58,12 @@ include __DIR__ . '/../layout/kopf.php';
</div> </div>
<div class="kachel"> <div class="kachel">
<header><h2>Vorhandene Dateien</h2></header> <header><h2>Vorhandene <?= View::h(Dateiformat::name($format)) ?>-Dateien</h2></header>
<p class="hinweis">
Übernommen werden Buchungen ab <strong><?= View::datum($stichtag) ?></strong> bis
<strong><?= View::datum($bis) ?></strong>. Alles davor fließt in den Anfangssaldo der
Konten, alles danach gilt als Vorausberechnung des FinanzManagers und bleibt draußen.
Der Stichtag steht in den <a href="/einstellungen?bereich=import">Einstellungen</a>.
</p>
<p class="hinweis">
Soll nur ein Teil der Konten übernommen werden, zuerst den <strong>Trockenlauf</strong>
starten: danach steht fest, welche Konten die Datei führt, und sie stehen einzeln zur
Wahl. <strong>Importieren</strong> übernimmt die ganze Datei.
</p>
<?php if ($dateien === []): ?> <?php if ($dateien === []): ?>
<p class="platzhalter"> <p class="platzhalter">
Noch keine Datei in <code><?= View::h($verzeichnis) ?></code>. Noch keine <?= View::h(Dateiformat::name($format)) ?>-Datei in
<code><?= View::h($verzeichnis) ?></code>.
</p> </p>
<?php else: ?> <?php else: ?>
<table class="liste"> <table class="liste">
@ -286,8 +81,10 @@ include __DIR__ . '/../layout/kopf.php';
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>"> <input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>">
<input type="hidden" name="modus" value="vorschau"> <input type="hidden" name="modus" value="vorschau">
<?php if ($format === 'qif'): ?>
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>"> <input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
<input type="hidden" name="bis" value="<?= View::h($bis) ?>"> <input type="hidden" name="bis" value="<?= View::h($bis) ?>">
<?php endif; ?>
<button type="submit" class="knopf klein">Trockenlauf</button> <button type="submit" class="knopf klein">Trockenlauf</button>
</form> </form>
<form method="post" action="/verwaltung/import/ausfuehren" class="inline" <form method="post" action="/verwaltung/import/ausfuehren" class="inline"
@ -295,8 +92,10 @@ include __DIR__ . '/../layout/kopf.php';
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>"> <input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>">
<input type="hidden" name="modus" value="import"> <input type="hidden" name="modus" value="import">
<?php if ($format === 'qif'): ?>
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>"> <input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
<input type="hidden" name="bis" value="<?= View::h($bis) ?>"> <input type="hidden" name="bis" value="<?= View::h($bis) ?>">
<?php endif; ?>
<button type="submit" class="knopf klein primaer">Importieren</button> <button type="submit" class="knopf klein primaer">Importieren</button>
</form> </form>
<form method="post" action="/verwaltung/import/<?= View::h(rawurlencode($eintrag['name'])) ?>/loeschen" <form method="post" action="/verwaltung/import/<?= View::h(rawurlencode($eintrag['name'])) ?>/loeschen"

View file

@ -1,5 +1,6 @@
<?php <?php
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\View; use Finanzmanager\View;
/** @var array<string,int> $zahlen */ /** @var array<string,int> $zahlen */
@ -44,13 +45,16 @@ include __DIR__ . '/../layout/kopf.php';
</div> </div>
<div class="kachel"> <div class="kachel">
<header><h2>QIF-Import</h2></header> <header><h2>Import/Export</h2></header>
<p class="platzhalter"> <p class="platzhalter">
Klassen, Kategorien und Konten aus einem Export des Lexware FinanzManagers Stammdaten und Buchungen aus dem Lexware FinanzManager übernehmen oder den
übernehmen – erst im Trockenlauf prüfen, dann übernehmen. eigenen Bestand herausgeben – wahlweise als QIF oder als CSV.
</p> </p>
<div class="knopfleiste"> <div class="knopfleiste">
<a href="/verwaltung/import" class="knopf klein primaer">Import öffnen</a> <?php /* data-formatwahl öffnet den Auswahldialog; ohne Skript führt der Link
auf dieselbe Auswahl als Seite. */ ?>
<a href="/verwaltung/import" class="knopf klein primaer" data-formatwahl="Import">Import</a>
<a href="/verwaltung/export" class="knopf klein" data-formatwahl="Export">Export</a>
</div> </div>
</div> </div>
@ -94,4 +98,25 @@ include __DIR__ . '/../layout/kopf.php';
</div> </div>
</div> </div>
<?php /*
Die Formatwahl von Import und Export. Die Karten stehen hier im Markup und
nicht im Skript: Die Liste der Formate gehört Dateiformat::ALLE, und ein
Inline-Skript verböte ohnehin die Content-Security-Policy. Das Skript setzt
nur die Ziele und öffnet den Dialog; ohne Skript führen die Knöpfe auf
dieselbe Auswahl als eigene Seite. */ ?>
<dialog class="formatdialog" id="formatwahl">
<h2 data-formatdialog-titel>Format wählen</h2>
<div class="formatwahl">
<?php foreach (Dateiformat::ALLE as $schluessel => $name): ?>
<a class="formatkarte" href="#" data-format="<?= View::h($schluessel) ?>">
<span class="formatname"><?= View::h($name) ?></span>
<span class="nebentext"><?= View::h(Dateiformat::BESCHREIBUNGEN[$schluessel] ?? '') ?></span>
</a>
<?php endforeach; ?>
</div>
<form method="dialog" class="knopfleiste">
<button type="submit" class="knopf klein">Abbrechen</button>
</form>
</dialog>
<?php include __DIR__ . '/../layout/fuss.php'; ?> <?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,127 @@
<?php
use Finanzmanager\Services\BuchungsExport;
use Finanzmanager\View;
/**
* Ergebnis und Erläuterungen des CSV-Imports. Eingebunden aus import.php,
* das die Datei- und Uploadkacheln beisteuert.
*/
/** @var array<string,mixed>|null $buchungen */
/** @var array<string,mixed>|null $ueberblick */
/** @var string $datei */
?>
<?php if ($buchungen !== null): ?>
<div class="kachel">
<header>
<h2><?= $buchungen['trockenlauf'] ? 'Trockenlauf' : 'Import abgeschlossen' ?></h2>
<span class="phasenmarke"><?= View::h($datei) ?></span>
</header>
<?php if ($buchungen['trockenlauf']): ?>
<p class="hinweis">
Nichts wurde gespeichert. Die Zahlen zeigen, was ein Import anlegen würde.
</p>
<?php endif; ?>
<table class="liste">
<tbody>
<tr>
<td><?= $buchungen['trockenlauf'] ? 'Neu' : 'Angelegt' ?></td>
<td class="rechts"><?= (int)$buchungen['buchungen']['neu'] ?></td>
</tr>
<tr>
<td class="nebentext">schon vorhanden – am Fingerabdruck erkannt</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['vorhanden'] ?></td>
</tr>
<tr>
<td class="nebentext">darunter Splitzeilen</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['splitzeilen'] ?></td>
</tr>
<tr>
<td>Fehler</td>
<td class="rechts <?= (int)$buchungen['buchungen']['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
<?= (int)$buchungen['buchungen']['fehler'] ?>
</td>
</tr>
</tbody>
</table>
<?php if (!empty($ueberblick['konten'])): ?>
<h3>Konten in der Datei</h3>
<p class="hinweis">
<?php $teile = [];
foreach ($ueberblick['konten'] as $name => $anzahl) {
$teile[] = View::h((string)$name) . ': ' . (int)$anzahl;
}
echo implode(' · ', $teile); ?>
</p>
<?php endif; ?>
<?php if (!empty($buchungen['meldungen'])): ?>
<h3>Protokoll</h3>
<ul class="protokoll">
<?php foreach ($buchungen['meldungen'] as $meldung): ?>
<li class="<?= View::h($meldung['typ']) ?>"><?= View::h($meldung['text']) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<div class="knopfleiste">
<a href="/konten" class="knopf primaer">Kontenübersicht</a>
<a href="/buchungen" class="knopf">Buchungen</a>
</div>
</div>
<?php endif; ?>
<div class="kachel">
<header><h2>So muss die CSV aussehen</h2></header>
<p class="hinweis">
Semikolon als Trenner, Komma als Dezimalzeichen, Datum als <code>TT.MM.JJJJ</code> –
genau so, wie der Export sie schreibt. Eine Datei aus einem anderen Programm darf die
Spalten umsortieren: Zugeordnet wird über die Kopfzeile, nicht über die Position.
Unverzichtbar sind <strong>Datum</strong>, <strong>Konto</strong> und
<strong>Betrag</strong>.
</p>
<table class="liste">
<thead>
<tr><?php foreach (BuchungsExport::SPALTEN as $spalte): ?>
<th><?= View::h($spalte) ?></th>
<?php endforeach; ?></tr>
</thead>
<tbody>
<tr>
<td>Buchung</td><td>18.03.2026</td><td></td><td>Girokonto</td><td>042</td>
<td>Stadtwerke</td><td>Abschlag März</td><td>Wohnen:Strom</td><td>Haus 1</td>
<td></td><td class="rechts">-84,00</td><td>ja</td>
</tr>
<tr>
<td>Buchung</td><td>20.03.2026</td><td></td><td>Girokonto</td><td></td>
<td>Eigenübertrag</td><td>Rücklage</td><td></td><td></td>
<td>Tagesgeld</td><td class="rechts">-500,00</td><td>nein</td>
</tr>
<tr class="nebentext">
<td>Splitzeile</td><td></td><td></td><td></td><td></td>
<td></td><td>Anteil Wasser</td><td>Wohnen:Wasser</td><td>Haus 1</td>
<td></td><td class="rechts">-30,00</td><td></td>
</tr>
</tbody>
</table>
<p class="hinweis">
Eine <strong>Umbuchung</strong> trägt statt der Kategorie ein Gegenkonto und steht –
wie im Datenmodell – nur einmal in der Datei, beim erfassenden Konto.
<strong>Splitzeilen</strong> folgen unmittelbar auf ihre Buchung; ihre Beträge müssen
deren Summe ergeben. Konten legt der Import nicht an, Kategorien und Klassen schon.
</p>
<p class="hinweis">
Was schon vorhanden ist, wird übersprungen: Gleiches Konto, Datum, Betrag, Nummer und
Buchungstext gelten als dieselbe Buchung. Ein zweiter Lauf derselben Datei ändert also
nichts.
</p>
</div>

View file

@ -0,0 +1,244 @@
<?php
use Finanzmanager\View;
/**
* Ergebnis und Erläuterungen des QIF-Imports. Eingebunden aus import.php,
* das die Datei- und Uploadkacheln beisteuert.
*/
/** @var array<string,mixed>|null $ergebnis */
/** @var array<string,mixed>|null $buchungen */
/** @var array<string,mixed>|null $vorlagen */
/** @var string $stichtag */
/** @var string $bis */
/** @var string $datei */
/** @var array<string,int> $dateikonten */
/** @var list<string> $auswahl */
/** @var string $csrfToken */
$bereiche = [
'klassen' => 'Klassen',
'kategorien' => 'Kategorien',
'konten' => 'Konten',
];
?>
<?php if ($ergebnis !== null): ?>
<div class="kachel">
<header>
<h2><?= $ergebnis['trockenlauf'] ? 'Trockenlauf' : 'Import abgeschlossen' ?></h2>
<span class="phasenmarke"><?= View::h($datei) ?></span>
</header>
<?php if ($ergebnis['trockenlauf']): ?>
<p class="hinweis">
Nichts wurde gespeichert. Die Zahlen zeigen, was ein Import anlegen würde.
</p>
<?php endif; ?>
<table class="liste">
<thead>
<tr>
<th>Bereich</th>
<th class="rechts"><?= $ergebnis['trockenlauf'] ? 'Neu' : 'Angelegt' ?></th>
<th class="rechts">Schon vorhanden</th>
<th class="rechts">Fehler</th>
</tr>
</thead>
<tbody>
<?php foreach ($bereiche as $schluessel => $beschriftung): ?>
<tr>
<td><?= View::h($beschriftung) ?></td>
<td class="rechts"><?= (int)$ergebnis[$schluessel]['neu'] ?></td>
<td class="rechts nebentext"><?= (int)$ergebnis[$schluessel]['vorhanden'] ?></td>
<td class="rechts <?= (int)$ergebnis[$schluessel]['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
<?= (int)$ergebnis[$schluessel]['fehler'] ?>
</td>
</tr>
<?php endforeach; ?>
<tr>
<td>Kontengruppen</td>
<td class="rechts nebentext" colspan="3">
nicht im QIF-Export enthalten – <a href="/verwaltung/kontengruppen">hier zuordnen</a>
</td>
</tr>
</tbody>
</table>
<p class="hinweis">
Der FinanzManager exportiert je Konto nur Name, Kontoart, Beschreibung und
Kreditlimit. Die Zuordnung zu einer Kontengruppe steht weder im QIF noch in der
<code>QSD</code>-Datei; sie wird deshalb einmalig in der Verwaltung nachgetragen.
</p>
<?php if ($buchungen !== null): ?>
<h3>Buchungen</h3>
<table class="liste">
<tbody>
<tr>
<td><?= $buchungen['trockenlauf'] ? 'Neu' : 'Angelegt' ?></td>
<td class="rechts"><?= (int)$buchungen['buchungen']['neu'] ?></td>
</tr>
<tr>
<td class="nebentext">schon vorhanden</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['vorhanden'] ?></td>
</tr>
<tr>
<td class="nebentext">
vor dem Stichtag – in den Anfangssaldo von
<?= (int)$buchungen['anfangssalden']['konten'] ?> Konten verrechnet
</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['vor_stichtag'] ?></td>
</tr>
<tr>
<td class="nebentext">nach dem Enddatum – vorausberechnet, verworfen</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['zukunft'] ?></td>
</tr>
<tr>
<td class="nebentext">darunter Wertpapiergeschäfte (Depot- und Geldbuchung)</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['wertpapier'] ?></td>
</tr>
<?php if ((int)($buchungen['buchungen']['nicht_gewaehlt'] ?? 0) > 0): ?>
<tr>
<td class="nebentext">gehört zu keinem der gewählten Konten</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['nicht_gewaehlt'] ?></td>
</tr>
<?php endif; ?>
<tr>
<td>Fehler</td>
<td class="rechts <?= (int)$buchungen['buchungen']['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
<?= (int)$buchungen['buchungen']['fehler'] ?>
</td>
</tr>
</tbody>
</table>
<p class="hinweis">
Zeitraum <?= View::datum($buchungen['stichtag']) ?> bis <?= View::datum($buchungen['bis']) ?>.
Alles davor ist nicht verloren: es steckt im Anfangssaldo der Konten.
<?php if (($buchungen['konten'] ?? null) !== null): ?>
Übernommen wurden nur die Buchungen von
<strong><?= View::h(implode(', ', $buchungen['konten'])) ?></strong> – einschließlich
der Umbuchungen, die auf einem anderen Konto erfasst wurden und hierher zeigen.
Die Anfangssalden der übrigen Konten bleiben unangetastet.
<?php endif; ?>
</p>
<?php endif; ?>
<?php if (($vorlagen ?? null) !== null): ?>
<h3>Buchungsvorlagen</h3>
<table class="liste">
<tbody>
<tr>
<td><?= $buchungen !== null && $buchungen['trockenlauf'] ? 'Neu' : 'Angelegt' ?></td>
<td class="rechts"><?= (int)$vorlagen['neu'] ?></td>
</tr>
<tr>
<td class="nebentext">bereits vorhanden</td>
<td class="rechts nebentext"><?= (int)$vorlagen['vorhanden'] ?></td>
</tr>
<tr>
<td>Fehler</td>
<td class="rechts <?= (int)$vorlagen['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
<?= (int)$vorlagen['fehler'] ?>
</td>
</tr>
</tbody>
</table>
<p class="hinweis">
Der FinanzManager merkt sich jede erfasste Zahlung als Vorlage – im
Beispielexport sind das mehrere hundert. Sie stehen danach unter
<a href="/vorlagen">Buchungsvorlagen</a>.
</p>
<?php endif; ?>
<?php if (!empty($ergebnis['abschnitte'])): ?>
<h3>Inhalt der Datei</h3>
<p class="hinweis">
<?php $teile = [];
foreach ($ergebnis['abschnitte'] as $typ => $anzahl) {
$teile[] = View::h((string)$typ) . ': ' . (int)$anzahl;
}
echo implode(' · ', $teile); ?>
</p>
<?php endif; ?>
<?php if (!empty($ergebnis['meldungen'])): ?>
<h3>Protokoll</h3>
<ul class="protokoll">
<?php foreach ($ergebnis['meldungen'] as $meldung): ?>
<li class="<?= View::h($meldung['typ']) ?>"><?= View::h($meldung['text']) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($ergebnis['trockenlauf']): ?>
<form method="post" action="/verwaltung/import/ausfuehren" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="datei" value="<?= View::h($datei) ?>">
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
<input type="hidden" name="bis" value="<?= View::h($bis) ?>">
<?php if ($dateikonten !== []): ?>
<h3>Konten</h3>
<p class="feldhinweis">
Zur Wahl stehen die Konten, die in dieser Datei vorkommen. Ohne Häkchen
zählt die ganze Datei. Eine Umbuchung kommt mit, sobald eines der beiden
Konten gewählt ist – sie steht im Export nur einmal, beim erfassenden
Konto. Die Zahl nennt die Einträge, die das Konto berühren.
</p>
<div class="auswahlliste">
<?php foreach ($dateikonten as $name => $anzahl): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="konten[]" value="<?= View::h((string)$name) ?>"
<?= $auswahl === [] || in_array((string)$name, $auswahl, true) ? 'checked' : '' ?>>
<span>
<?= View::h((string)$name) ?>
<span class="nebentext"><?= (int)$anzahl ?></span>
</span>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="knopfleiste">
<button type="submit" name="modus" value="import" class="knopf primaer">Jetzt übernehmen</button>
<?php if ($dateikonten !== []): ?>
<button type="submit" name="modus" value="vorschau" class="knopf">
Trockenlauf mit dieser Auswahl
</button>
<?php endif; ?>
</div>
</form>
<?php else: ?>
<div class="knopfleiste">
<a href="/konten" class="knopf primaer">Kontenübersicht</a>
<a href="/verwaltung/kontengruppen" class="knopf">Kontengruppen</a>
<a href="/verwaltung/kategorien" class="knopf">Kategorien</a>
</div>
<p class="hinweis">
Die Anfangssalden wurden aus dem Zeitraum vor dem Stichtag berechnet. In der
Kontenübersicht lässt sich jeder Wert nachträglich ändern – ebenso das
Kennzeichen, ob ein Konto zur Summe der Kontostände zählt.
</p>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="kachel">
<header><h2>So läuft der QIF-Import</h2></header>
<p class="hinweis">
Übernommen werden Buchungen ab <strong><?= View::datum($stichtag) ?></strong> bis
<strong><?= View::datum($bis) ?></strong>. Alles davor fließt in den Anfangssaldo der
Konten, alles danach gilt als Vorausberechnung des FinanzManagers und bleibt draußen.
Der Stichtag steht in den <a href="/einstellungen?bereich=import">Einstellungen</a>.
</p>
<p class="hinweis">
Soll nur ein Teil der Konten übernommen werden, zuerst den <strong>Trockenlauf</strong>
starten: danach steht fest, welche Konten die Datei führt, und sie stehen einzeln zur
Wahl. <strong>Importieren</strong> übernimmt die ganze Datei – Klassen, Kategorien,
Konten, Buchungen und Buchungsvorlagen in einem Zug.
</p>
</div>

View file

@ -161,7 +161,7 @@ class NavigationTest extends TestCase
// Letzter Punkt: keine weitere Kachel hinter der von Verträge. // Letzter Punkt: keine weitere Kachel hinter der von Verträge.
$letzte = (int)strrpos($kacheln, '<h2>Verträge</h2>'); $letzte = (int)strrpos($kacheln, '<h2>Verträge</h2>');
foreach (['Kategorien', 'Klassen', 'Konten', 'QIF-Import', 'Online-Banking', 'Termine'] as $vorher) { foreach (['Kategorien', 'Klassen', 'Konten', 'Import/Export', 'Online-Banking', 'Termine'] as $vorher) {
$this->assert( $this->assert(
$letzte > (int)strrpos($kacheln, '<h2>' . $vorher . '</h2>'), $letzte > (int)strrpos($kacheln, '<h2>' . $vorher . '</h2>'),
'Verträge stehen hinter der Kachel ' . $vorher 'Verträge stehen hinter der Kachel ' . $vorher

View file

@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
use Finanzmanager\Database;
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
/**
* Sammellöschen in den Umsatzlisten des Bankings.
*
* Geprüft wird, was hinter dem Knopf *Auswahl löschen* passiert: Die gewählten
* Umsätze verschwinden – und zwar nur sie. Eine schon erzeugte Buchung gehört
* ins Kontoblatt und bleibt dort stehen; sie verliert lediglich den Umsatz, der
* sie belegt hat. Der Knopf steht in jedem Reiter, also müssen offene,
* übernommene und verworfene Umsätze gleichermaßen gehen.
*/
class UmsaetzeLoeschenTest extends TestCase
{
protected string $testName = 'Test 28: Bankumsätze sammelweise löschen';
private int $giro = 0;
private int $zugang = 0;
private int $bankkonto = 0;
private int $laufendeNummer = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->auswahlVerschwindetAusDerListe();
$this->buchungUeberlebtDenGeloeschtenUmsatz();
$this->jederStatusLaesstSichLoeschen();
$this->leereUndUnbekannteAuswahlBleibtFolgenlos();
$this->jedeAnsichtBietetDieAuswahlAn();
}
private function stammdaten(): void
{
$this->giro = Konto::anlegen([
'name' => 'Sammelgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->zugang = Bankzugang::anlegen([
'name' => 'Sammelbank',
'bankname' => 'Sammelbank AG',
'blz' => '20041112',
'fints_url' => 'https://banking.example.org/fints',
'benutzerkennung' => 'testkunde',
'pin' => 'geheim1234',
'abrufzeitraum' => 'letzter_abruf',
'aktiv' => 1,
]);
$this->bankkonto = (int)Bankzugang::findeOderLegeKontoAn(
$this->zugang,
'DE53200411120847626901',
'847626901',
'Girokonto'
)['id'];
Bankzugang::ordneKontoZu($this->bankkonto, $this->giro);
}
private function umsatzAnlegen(string $zweck, float $betrag = -19.99): int
{
// Jeder Umsatz bekommt einen eigenen Tag: Sonst fiele der Fingerabdruck
// zweier gleich lautender Zeilen zusammen und der zweite käme nie an.
$tag = str_pad((string)(++$this->laufendeNummer), 2, '0', STR_PAD_LEFT);
$id = Bankumsatz::anlegen([
'zugang_id' => $this->zugang,
'konto_id' => $this->giro,
'bank_konto_id' => $this->bankkonto,
'extern_id' => Bankumsatz::externId($this->bankkonto, '2026-07-' . $tag, $betrag, $zweck),
'buchungstag' => '2026-07-' . $tag,
'wertstellung' => '2026-07-' . $tag,
'betrag' => $betrag,
'empfaenger' => 'Sammelempfänger',
'verwendungszweck' => $zweck,
'buchungsart' => 'LASTSCHRIFT',
]);
$this->assertNotEmpty($id, 'Umsatz angelegt: ' . $zweck);
return (int)$id;
}
private function existiert(int $umsatzId): bool
{
return Database::fetchValue('SELECT 1 FROM bank_umsaetze WHERE id = ?', [$umsatzId]) !== null;
}
/** Die Mehrfachauswahl trifft genau die angekreuzten Zeilen. */
private function auswahlVerschwindetAusDerListe(): void
{
$eins = $this->umsatzAnlegen('Sammelstrom');
$zwei = $this->umsatzAnlegen('Sammelwasser');
$drei = $this->umsatzAnlegen('Sammelgas');
$anzahl = Bankumsatz::mehrereLoeschen([$eins, $drei]);
$this->assertEquals(2, $anzahl, 'Zwei ausgewählte Umsätze gelöscht');
$this->assert(!$this->existiert($eins), 'Der erste ausgewählte Umsatz ist weg');
$this->assert(!$this->existiert($drei), 'Der zweite ausgewählte Umsatz ist weg');
$this->assert($this->existiert($zwei), 'Der nicht ausgewählte Umsatz steht noch da');
$offene = array_map('intval', array_column(Bankumsatz::liste(['status' => 'offen']), 'id'));
$this->assert(!in_array($eins, $offene, true), 'Der gelöschte Umsatz steht nicht mehr in der Liste');
$this->assert(in_array($zwei, $offene, true), 'Der übrige Umsatz steht weiter zum Abgleich bereit');
}
/**
* Der Unterschied zum Kontoblatt: Hier wird die abgerufene Zeile gelöscht,
* nicht die Buchung. Was einmal gebucht ist, bleibt gebucht.
*/
private function buchungUeberlebtDenGeloeschtenUmsatz(): void
{
$umsatz = $this->umsatzAnlegen('Sammelmiete', -750.00);
$buchungId = Bankumsatz::uebernehmen($umsatz, []);
$this->assertEquals(1, Bankumsatz::mehrereLoeschen([$umsatz]), 'Der übernommene Umsatz wurde gelöscht');
$this->assert(!$this->existiert($umsatz), 'Der übernommene Umsatz ist weg');
$buchung = Buchung::find((int)$buchungId);
$this->assertNotEmpty($buchung, 'Die Buchung steht weiter im Kontoblatt');
$this->assertEquals(-750.00, (float)$buchung['betrag'], 'Und zwar unverändert');
}
/** Der Knopf steht in jedem Reiter – offen, übernommen und verworfen. */
private function jederStatusLaesstSichLoeschen(): void
{
$offen = $this->umsatzAnlegen('Sammelversicherung', -44.00);
$uebernommen = $this->umsatzAnlegen('Sammelrundfunk', -55.08);
$verworfen = $this->umsatzAnlegen('Sammelfehlbuchung', -1.00);
Bankumsatz::uebernehmen($uebernommen, []);
Bankumsatz::verwerfen($verworfen);
$anzahl = Bankumsatz::mehrereLoeschen([$offen, $uebernommen, $verworfen]);
$this->assertEquals(3, $anzahl, 'Aus jedem Reiter lässt sich löschen');
$this->assert(!$this->existiert($offen), 'Offener Umsatz gelöscht');
$this->assert(!$this->existiert($uebernommen), 'Übernommener Umsatz gelöscht');
$this->assert(!$this->existiert($verworfen), 'Verworfener Umsatz gelöscht');
}
/**
* Ohne Auswahl darf nichts geschehen – der Controller weist den leeren Fall
* ab, das Modell darf ihn trotzdem nicht als „alles löschen“ lesen.
*/
private function leereUndUnbekannteAuswahlBleibtFolgenlos(): void
{
$bleibt = $this->umsatzAnlegen('Sammelabo', -9.99);
$this->assertEquals(0, Bankumsatz::mehrereLoeschen([]), 'Leere Auswahl löscht nichts');
$this->assertEquals(0, Bankumsatz::mehrereLoeschen([0, -3]), 'Unbrauchbare Nummern löschen nichts');
$this->assertEquals(0, Bankumsatz::mehrereLoeschen([$bleibt + 100000]), 'Unbekannte Nummer löscht nichts');
$this->assert($this->existiert($bleibt), 'Der vorhandene Umsatz ist unangetastet');
// Doppelt angekreuzt gibt es nicht, doppelt gesendet schon – die Anzahl
// in der Meldung darf dadurch nicht steigen.
$this->assertEquals(1, Bankumsatz::mehrereLoeschen([$bleibt, $bleibt]), 'Doppelte Nummer zählt einmal');
}
/**
* Die Oberfläche: In jedem Reiter stehen die Ankreuzfelder vor den Umsätzen,
* das Feld „alle wählen“ in der Titelleiste und darüber der Knopf.
*/
private function jedeAnsichtBietetDieAuswahlAn(): void
{
$offen = $this->umsatzAnlegen('Ansichtsstrom', -31.00);
$uebernommen = $this->umsatzAnlegen('Ansichtsmiete', -420.00);
$verworfen = $this->umsatzAnlegen('Ansichtsdublette', -2.50);
Bankumsatz::uebernehmen($uebernommen, []);
Bankumsatz::verwerfen($verworfen);
$erwartet = ['offen' => $offen, 'uebernommen' => $uebernommen, 'verworfen' => $verworfen];
foreach ($erwartet as $status => $umsatzId) {
$html = $this->ansicht($status);
$this->assert(
str_contains($html, 'action="/banking/umsaetze/loeschen?status=' . $status . '"'),
'Reiter „' . $status . '“: Das Löschformular kehrt in denselben Reiter zurück'
);
$this->assert(
str_contains($html, '>Auswahl löschen</button>'),
'Reiter „' . $status . '“: Der Knopf „Auswahl löschen“ steht über der Liste'
);
$this->assert(
str_contains($html, 'class="alle-waehlen" data-waehlt="umsatzauswahl"'),
'Reiter „' . $status . '“: Die Titelleiste hat ein Feld „alle wählen“'
);
$this->assert(
str_contains($html, 'name="umsaetze[]" value="' . $umsatzId . '" form="umsatzauswahl"'),
'Reiter „' . $status . '“: Vor dem Umsatz steht sein Ankreuzfeld'
);
}
// Ohne Umsätze steht auch kein Löschknopf da – sonst zeigte die leere
// Liste eine Schaltfläche, die nichts treffen kann.
Bankumsatz::mehrereLoeschen(array_values($erwartet));
$this->assert(
!str_contains($this->ansicht('verworfen'), '>Auswahl löschen</button>'),
'Ohne Umsätze gibt es nichts zu löschen'
);
}
/** Rendert die Abgleichliste eines Reiters so, wie der Controller es täte. */
private function ansicht(string $status): string
{
$umsaetze = [];
foreach (Bankumsatz::liste(['status' => $status]) as $umsatz) {
$umsatz['vorschlag'] = $status === 'offen' ? Bankumsatz::vorschlag($umsatz) : null;
$umsaetze[] = $umsatz;
}
$titel = 'Umsätze abgleichen';
$flash = [];
$benutzer = null;
$csrfToken = 'x';
$kategorien = Kategorie::baumFlach(null, true);
$klassen = Klasse::alle();
$konten = Konto::alle(true);
$offen = Bankumsatz::anzahlOffen();
ob_start();
include dirname(__DIR__) . '/src/Views/banking/umsaetze.php';
return (string)ob_get_clean();
}
}

View file

@ -0,0 +1,559 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\BuchungsExport;
use Finanzmanager\Services\CsvBuchungsImport;
use Finanzmanager\Services\CsvTabelle;
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\Services\Housekeeping;
use Finanzmanager\Services\QifBuchungsImport;
use Finanzmanager\Services\QifParser;
use Finanzmanager\Services\QifStammdatenImport;
use Finanzmanager\Controllers\ImportController;
/**
* Import und Export in beiden Formaten.
*
* Der Prüfstein ist der Rundlauf: Was der Export schreibt, muss der eigene
* Import wieder einlesen können – und dabei dieselben Buchungen ergeben, nicht
* Dubletten. Dazu die Filter (Zeitraum, Konto, Kategorie, Klasse), die
* Formatwahl vor beiden Dialogen und das Housekeeping, das die hochgeladenen
* Dateien wieder wegräumt.
*/
class ImportExportTest extends TestCase
{
protected string $testName = 'Test 29: Import und Export (QIF und CSV)';
private int $giro = 0;
private int $tagesgeld = 0;
private int $strom = 0;
private int $wasser = 0;
private int $haus = 0;
private int $privat = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->buchungen();
$this->csvTabelleLiestUndSchreibt();
$this->csvExportFuehrtSplitsUndUmbuchungen();
$this->filterGreifenEinzelnUndGemeinsam();
$this->qifExportTraegtDenDialektDesFinanzManagers();
$this->qifRundlaufUeberEinenZweitenMandanten();
$this->csvRundlaufUeberEinenDrittenMandanten();
$this->csvImportErkenntDublettenUndFehler();
$this->housekeepingRaeumtAlteDateienWeg();
$this->dateinamenUndFormate();
$this->exportLegtKeineDateiAb();
$this->verwaltungFuehrtImportUndExport();
}
// ── Aufbau ───────────────────────────────────────────────────────────────
private function stammdaten(): void
{
$this->giro = Konto::anlegen([
'name' => 'Exportgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->tagesgeld = Konto::anlegen([
'name' => 'Exportrücklage', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->strom = Kategorie::findenOderAnlegenNachPfad('Wohnen:Strom');
$this->wasser = Kategorie::findenOderAnlegenNachPfad('Wohnen:Wasser');
$this->haus = Klasse::findenOderAnlegen('Haus 1');
$this->privat = Klasse::findenOderAnlegen('Privat');
}
private function buchungen(): void
{
// Eine gewöhnliche Buchung mit Kategorie und Klasse …
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-03-18', 'betrag' => -84.00,
'empfaenger' => 'Stadtwerke', 'buchungstext' => 'Abschlag März', 'nummer' => '042',
'kategorie_id' => $this->strom, 'klasse_id' => $this->haus, 'abgeglichen' => 1,
]);
// … eine Umbuchung auf das zweite Konto …
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-03-20', 'betrag' => -500.00,
'empfaenger' => 'Eigenübertrag', 'buchungstext' => 'Rücklage',
'gegenkonto_id' => $this->tagesgeld,
]);
// … und eine Splitbuchung, deren Zeilen zwei Kategorien tragen.
Buchung::anlegen(
[
'konto_id' => $this->giro, 'datum' => '2026-04-02', 'betrag' => -110.00,
'empfaenger' => 'Versorger', 'buchungstext' => 'Jahresabrechnung',
],
[
['kategorie_id' => $this->strom, 'klasse_id' => $this->haus, 'betrag' => -80.00, 'notiz' => 'Strom'],
['kategorie_id' => $this->wasser, 'klasse_id' => $this->privat, 'betrag' => -30.00, 'notiz' => 'Wasser'],
]
);
}
// ── Das Dateiformat selbst ───────────────────────────────────────────────
private function csvTabelleLiestUndSchreibt(): void
{
$text = CsvTabelle::schreibe([
['Feld', 'Mit;Trenner', 'Mit"Zitat'],
['Wert', 'zwei', 'drei'],
]);
$this->assert(str_starts_with($text, CsvTabelle::BOM), 'Die Datei beginnt mit einem BOM');
$this->assert(str_contains($text, '"Mit;Trenner"'), 'Ein Feld mit Trenner wird maskiert');
$this->assert(str_contains($text, '"Mit""Zitat"'), 'Anführungszeichen werden verdoppelt');
$this->assert(str_contains($text, "\r\n"), 'Zeilen enden mit CRLF');
$zeilen = CsvTabelle::lies($text);
$this->assertEquals(2, count($zeilen), 'Die geschriebene Datei liest sich wieder ein');
$this->assertEquals('Mit;Trenner', $zeilen[0][1], 'Der maskierte Trenner überlebt den Rundlauf');
$this->assertEquals('Mit"Zitat', $zeilen[0][2], 'Das Anführungszeichen überlebt den Rundlauf');
// Fremde Dateien: Komma als Trenner, Punkt als Dezimalzeichen.
$fremd = CsvTabelle::datensaetze("Datum,Konto,Betrag\r\n2026-03-18,Giro,\"1,234.56\"\r\n");
$this->assertEquals(1, count($fremd), 'Auch eine Datei mit Komma-Trenner wird gelesen');
$this->assertEquals('2026-03-18', $fremd[0]['datum'], 'Die Kopfzeile wird zum Schlüssel');
$this->assertEquals(1234.56, CsvTabelle::betrag('1,234.56'), 'Betrag im englischen Format');
$this->assertEquals(-1234.56, CsvTabelle::betrag('-1.234,56'), 'Betrag im deutschen Format');
$this->assertEquals(-84.0, CsvTabelle::betrag('-84,00 €'), 'Währungszeichen stört nicht');
$this->assertEquals(0.0, CsvTabelle::betrag(''), 'Ein leeres Feld ist null');
$this->assertEquals('2026-03-18', CsvTabelle::datum('18.03.2026'), 'Datum als TT.MM.JJJJ');
$this->assertEquals('2026-03-18', CsvTabelle::datum('2026-03-18'), 'Datum als ISO');
$this->assertEquals('1998-03-18', CsvTabelle::datum('18.03.98'), 'Zweistellige Jahre ab 70 sind 19xx');
$this->assertNull(CsvTabelle::datum('31.02.2026'), 'Ein Datum, das es nicht gibt, fällt heraus');
$this->assertNull(CsvTabelle::datum('irgendwann'), 'Text ist kein Datum');
}
// ── Export ───────────────────────────────────────────────────────────────
private function csvExportFuehrtSplitsUndUmbuchungen(): void
{
$csv = BuchungsExport::csv(BuchungsExport::buchungen([]));
$zeilen = CsvTabelle::lies($csv);
$this->assertEquals(BuchungsExport::SPALTEN, $zeilen[0], 'Die Kopfzeile nennt alle Spalten');
// Drei Buchungen, dazu zwei Splitzeilen – plus Kopfzeile.
$arten = array_count_values(array_column($zeilen, 0));
$this->assertEquals(3, $arten[BuchungsExport::ART_BUCHUNG] ?? 0, 'Drei Buchungen im Export');
$this->assertEquals(2, $arten[BuchungsExport::ART_SPLIT] ?? 0, 'Zwei Splitzeilen im Export');
$this->assert(str_contains($csv, '18.03.2026'), 'Das Datum steht deutsch in der Datei');
$this->assert(str_contains($csv, '-84,00'), 'Der Betrag steht mit Komma in der Datei');
$this->assert(str_contains($csv, 'Wohnen:Strom'), 'Die Kategorie steht als Pfad in der Datei');
$this->assert(str_contains($csv, 'Exportrücklage'), 'Die Umbuchung nennt ihr Gegenkonto');
// Die Splitzeilen folgen unmittelbar auf ihre Buchung.
$split = null;
foreach ($zeilen as $nr => $zeile) {
if ($zeile[0] === BuchungsExport::ART_SPLIT) {
$split = $nr;
break;
}
}
$this->assertNotEmpty($split, 'Es gibt eine Splitzeile');
$this->assertEquals(
BuchungsExport::ART_BUCHUNG,
$zeilen[$split - 1][0],
'Über der ersten Splitzeile steht ihre Buchung'
);
}
private function filterGreifenEinzelnUndGemeinsam(): void
{
// Gezählt wird über COUNT, geladen über dieselbe Bedingung – geprüft
// wird deshalb beides gegeneinander.
$anzahl = function (array $filter): int {
$geladen = count(BuchungsExport::buchungen($filter));
$this->assertEquals($geladen, BuchungsExport::anzahl($filter), 'Zählen und Laden sind sich einig');
return $geladen;
};
$this->assertEquals(3, $anzahl([]), 'Ohne Filter kommt alles mit');
$this->assertEquals(2, $anzahl(['von' => '2026-03-19']), 'Der Zeitraum grenzt nach vorn ab');
$this->assertEquals(2, $anzahl(['bis' => '2026-03-31']), 'Der Zeitraum grenzt nach hinten ab');
$this->assertEquals(1, $anzahl(['von' => '2026-03-19', 'bis' => '2026-03-31']), 'Beide Grenzen zusammen');
// Das Tagesgeld führt selbst keine Buchung – die Umbuchung steht beim
// Girokonto. Sie muss trotzdem kommen, sonst fehlte dem Auszug die
// einzige Bewegung des Kontos.
$this->assertEquals(
1,
$anzahl(['konten' => [$this->tagesgeld]]),
'Der Kontofilter holt die Umbuchung von der anderen Seite'
);
$this->assertEquals(3, $anzahl(['konten' => [$this->giro]]), 'Das erfassende Konto führt alle drei');
// Die Kategorie steckt bei der Splitbuchung allein in den Zeilen.
$this->assertEquals(2, $anzahl(['kategorien' => [$this->strom]]), 'Kategorie: Buchung und Splitzeile');
$this->assertEquals(1, $anzahl(['kategorien' => [$this->wasser]]), 'Kategorie nur in einer Splitzeile');
$this->assertEquals(2, $anzahl(['klassen' => [$this->haus]]), 'Klasse: Buchung und Splitzeile');
$this->assertEquals(1, $anzahl(['klassen' => [$this->privat]]), 'Klasse nur in einer Splitzeile');
$this->assertEquals(
1,
$anzahl(['kategorien' => [$this->strom], 'bis' => '2026-03-31']),
'Kategorie und Zeitraum wirken zusammen'
);
$this->assertEquals(0, $anzahl(['von' => '2027-01-01']), 'Ein leerer Zeitraum liefert nichts');
}
private function qifExportTraegtDenDialektDesFinanzManagers(): void
{
$qif = BuchungsExport::qif(BuchungsExport::buchungen([]));
$this->assert(str_contains($qif, '!Type:Class'), 'Das QIF führt die Klassen');
$this->assert(str_contains($qif, '!Type:Cat'), 'Das QIF führt die Kategorien');
$this->assert(str_contains($qif, '!Option:AutoSwitch'), 'Das QIF führt die Kontenliste');
$this->assert(str_contains($qif, 'T-84.00'), 'Beträge stehen im QIF im US-Format');
$this->assert(str_contains($qif, 'D18.03.2026'), 'Das Datum folgt der Angabe !Option:DMY');
$this->assert(str_contains($qif, 'LWohnen:Strom/Haus 1'), 'Kategorie und Klasse stehen in einer L-Zeile');
$this->assert(str_contains($qif, 'L[Exportrücklage]'), 'Die Umbuchung nennt ihr Gegenkonto in Klammern');
$this->assert(str_contains($qif, 'SWohnen:Wasser/Privat'), 'Die Splitzeile trägt Kategorie und Klasse');
$this->assert(str_contains($qif, '$-30.00'), 'Die Splitzeile trägt ihren Betrag');
$parser = new QifParser($qif);
$konten = QifBuchungsImport::kontenDerDatei($parser);
$this->assertEquals(3, $parser->zusammenfassung()['Bank'] ?? 0, 'Der eigene Parser findet drei Buchungen');
$this->assert(isset($konten['Exportgiro']), 'Der Parser erkennt das Girokonto');
$this->assert(
isset($konten['Exportrücklage']),
'Auch das Umbuchungsziel steht in der Kontenliste des Parsers'
);
}
// ── Import ───────────────────────────────────────────────────────────────
/**
* Der Rundlauf über einen frischen Mandanten: Stammdaten und Buchungen
* müssen sich aus der Exportdatei allein wiederherstellen lassen.
*/
private function qifRundlaufUeberEinenZweitenMandanten(): void
{
$qif = BuchungsExport::qif(BuchungsExport::buchungen([]));
$this->mandantAnlegen();
$stammdaten = (new QifStammdatenImport(new QifParser($qif)))->ausfuehren();
$this->assertEquals(2, $stammdaten['konten']['neu'], 'Beide Konten entstehen aus der Datei');
$this->assertEquals(2, $stammdaten['klassen']['neu'], 'Beide Klassen entstehen aus der Datei');
$this->assertGreaterThan(2, $stammdaten['kategorien']['neu'], 'Der Kategoriebaum entsteht mit');
$lauf = ['stichtag' => '2026-01-01', 'bis' => '2026-12-31'];
$ergebnis = (new QifBuchungsImport(new QifParser($qif), $lauf))->ausfuehren();
$this->assertEquals(3, $ergebnis['buchungen']['neu'], 'Alle drei Buchungen kommen an');
$this->assertEquals(0, $ergebnis['buchungen']['fehler'], 'Der Rundlauf läuft fehlerfrei');
$this->assertEquals(3, Buchung::anzahl(), 'Drei Buchungen stehen in der Datenbank');
// Der zweite Lauf derselben Datei darf nichts verdoppeln.
$zweiter = (new QifBuchungsImport(new QifParser($qif), $lauf))->ausfuehren();
$this->assertEquals(0, $zweiter['buchungen']['neu'], 'Der zweite Lauf legt nichts an');
$this->assertEquals(3, $zweiter['buchungen']['vorhanden'], 'Alle drei gelten als vorhanden');
// Umbuchung und Splitbuchung müssen den Weg heil überstanden haben.
[$umbuchung, $split] = $this->umbuchungUndSplit();
$this->assertNotEmpty($umbuchung, 'Die Umbuchung kam als Umbuchung an');
$this->assertEquals(2, count($split['splits'] ?? []), 'Die Splitbuchung hat wieder zwei Zeilen');
$this->assertEquals(
'Wohnen:Wasser',
$split['splits'][1]['kategoriepfad'],
'Die zweite Splitzeile trägt ihre Kategorie'
);
}
private function csvRundlaufUeberEinenDrittenMandanten(): void
{
$csv = BuchungsExport::csv(BuchungsExport::buchungen([]));
// Ein weiterer Mandant mit gleichnamigen Konten: So lässt sich prüfen,
// dass der Import wirklich anlegt, statt nur Dubletten zu zählen.
$this->mandantAnlegen();
Konto::anlegen(['name' => 'Exportgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1]);
Konto::anlegen(['name' => 'Exportrücklage', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1]);
$vorschau = (new CsvBuchungsImport($csv))->vorschau();
$this->assertEquals(3, $vorschau['buchungen']['neu'], 'Der Trockenlauf zählt drei neue Buchungen');
$this->assertEquals(0, Buchung::anzahl(), 'Der Trockenlauf hat nichts gespeichert');
$ergebnis = (new CsvBuchungsImport($csv))->ausfuehren();
$this->assertEquals(3, $ergebnis['buchungen']['neu'], 'Der echte Lauf legt drei Buchungen an');
$this->assertEquals(2, $ergebnis['buchungen']['splitzeilen'], 'Beide Splitzeilen kommen mit');
$this->assertEquals(0, $ergebnis['buchungen']['fehler'], 'Der Import läuft fehlerfrei');
$this->assertEquals(3, Buchung::anzahl(), 'Drei Buchungen stehen in der Datenbank');
// Kategorien und Klassen bringt der Import mit, Konten nicht.
$this->assertNotEmpty(Kategorie::findeNachPfad('Wohnen:Strom'), 'Die Kategorie wurde ergänzt');
$this->assertNotEmpty(Klasse::findeNachName('Haus 1'), 'Die Klasse wurde ergänzt');
$wieder = BuchungsExport::csv(BuchungsExport::buchungen([]));
$this->assertEquals(
count(CsvTabelle::lies($csv)),
count(CsvTabelle::lies($wieder)),
'Der erneute Export hat genauso viele Zeilen wie der erste'
);
// Umbuchung und Splitbuchung müssen den Weg heil überstanden haben.
[$umbuchung, $split] = $this->umbuchungUndSplit();
$this->assertNotEmpty($umbuchung, 'Die Umbuchung kam als Umbuchung an');
$this->assertNotEmpty($split, 'Die Splitbuchung kam mit Zeilen an');
$this->assertEquals(2, count($split['splits'] ?? []), 'Die Splitbuchung hat wieder zwei Zeilen');
$this->assertEquals('Wohnen:Wasser', $split['splits'][1]['kategoriepfad'], 'Die zweite Zeile trägt ihre Kategorie');
$this->assertEquals('Privat', $split['splits'][1]['klassenname'], 'Die zweite Zeile trägt ihre Klasse');
}
private function csvImportErkenntDublettenUndFehler(): void
{
$csv = BuchungsExport::csv(BuchungsExport::buchungen([]));
$zweiter = (new CsvBuchungsImport($csv))->ausfuehren();
$this->assertEquals(0, $zweiter['buchungen']['neu'], 'Der zweite Lauf legt nichts an');
$this->assertEquals(3, $zweiter['buchungen']['vorhanden'], 'Alle drei gelten als vorhanden');
$this->assertEquals(3, Buchung::anzahl(), 'Die Zahl der Buchungen bleibt gleich');
// Ein unbekanntes Konto legt der Import nicht an – die Zeile fällt aus.
$fremd = "Art;Datum;Konto;Betrag\r\nBuchung;01.05.2026;Gibtesnicht;-1,00\r\n";
$lauf = (new CsvBuchungsImport($fremd))->vorschau();
$this->assertEquals(1, $lauf['buchungen']['fehler'], 'Ein unbekanntes Konto ist ein Fehler');
$this->assertEquals(0, $lauf['buchungen']['neu'], 'Aus der Zeile wird keine Buchung');
$this->assertNotEmpty($lauf['meldungen'], 'Das Protokoll nennt den Grund');
// Ohne Pflichtspalte lässt sich gar nichts einlesen.
$ohneKonto = "Art;Datum;Betrag\r\nBuchung;01.05.2026;-1,00\r\n";
$meldung = '';
try {
(new CsvBuchungsImport($ohneKonto))->vorschau();
} catch (\Throwable $e) {
$meldung = $e->getMessage();
}
$this->assert(str_contains($meldung, 'Spalten'), 'Eine fehlende Pflichtspalte bricht den Lauf ab');
// Eine Splitzeile ohne Buchung davor ist ein Fehler, kein stiller Verlust.
$verwaist = "Art;Datum;Konto;Betrag\r\nSplitzeile;;;-1,00\r\n";
$lauf = (new CsvBuchungsImport($verwaist))->vorschau();
$this->assertEquals(1, $lauf['buchungen']['fehler'], 'Eine verwaiste Splitzeile wird gemeldet');
}
// ── Housekeeping ─────────────────────────────────────────────────────────
private function housekeepingRaeumtAlteDateienWeg(): void
{
// Ein eigenes Verzeichnis unterhalb des Importverzeichnisses: Housekeeping
// arbeitet auf einem Pfad relativ zum Projekt, und der Aufräumer der
// Testbasis löscht nur dort (und im Temp-Verzeichnis) etwas.
$name = 'hk_' . bin2hex(random_bytes(4));
$verzeichnis = $this->registriereTempPfad(\Finanzmanager\Config::get('import_dir') . '/' . $name);
mkdir($verzeichnis, 0o775, true);
$alt = $this->registriereTempPfad($verzeichnis . '/alt.qif');
$neu = $this->registriereTempPfad($verzeichnis . '/neu.qif');
file_put_contents($alt, '!Type:Bank');
file_put_contents($neu, '!Type:Bank');
touch($alt, time() - 30 * 86400);
// Das überwachte Verzeichnis kommt aus den Einstellungen; für den Test
// zeigt es auf den frisch angelegten Pfad. Der Rollback dreht die
// Einstellung wieder zurück.
Einstellung::set('import_qif_verzeichnis', 'data/import/' . $name, 'import');
Einstellung::set(Housekeeping::EINSTELLUNG_TAGE, '14', 'import');
Einstellung::set(Housekeeping::EINSTELLUNG_LAUF, '', 'import');
$this->assertEquals(14, Housekeeping::tage(), 'Die Frist kommt aus den Einstellungen');
$this->assert(Housekeeping::faellig(), 'Ohne vorherigen Lauf ist das Aufräumen fällig');
$probe = Housekeeping::ausfuehren(true);
$this->assertEquals(1, $probe['geloescht'], 'Der Probelauf zählt die alte Datei');
$this->assert(is_file($alt), 'Der Probelauf löscht nichts');
$ergebnis = Housekeeping::ausfuehren();
$this->assertEquals(1, $ergebnis['geloescht'], 'Der echte Lauf löscht die alte Datei');
$this->assertGreaterThan(0, $ergebnis['bytes'], 'Der Lauf meldet den freigewordenen Platz');
$this->assert(!is_file($alt), 'Die alte Datei ist weg');
$this->assert(is_file($neu), 'Die frische Datei bleibt liegen');
$this->assert(!Housekeeping::faellig(), 'Nach dem Lauf steht heute nichts mehr an');
$this->assertNull(Housekeeping::beiGelegenheit(), 'Der beiläufige Lauf hält sich daran');
// Frist 0 schaltet das Aufräumen ab.
Einstellung::set(Housekeeping::EINSTELLUNG_TAGE, '0', 'import');
touch($neu, time() - 400 * 86400);
$aus = Housekeeping::ausfuehren();
$this->assertEquals(0, $aus['geloescht'], 'Mit Frist 0 wird nichts gelöscht');
$this->assert(is_file($neu), 'Auch eine uralte Datei bleibt dann liegen');
$this->assert(!Housekeeping::faellig(), 'Abgeschaltet ist nie etwas fällig');
}
// ── Formate, Dateinamen und Oberfläche ───────────────────────────────────
private function dateinamenUndFormate(): void
{
$this->assertEquals(['qif', 'csv'], array_keys(Dateiformat::ALLE), 'Zwei Formate, QIF zuerst');
$this->assertEquals('qif', Dateiformat::pruefe('QIF'), 'Die Prüfung ist unempfindlich für Großschreibung');
$this->assertNull(Dateiformat::pruefe('xlsx'), 'Ein fremdes Format fällt heraus');
$this->assertEquals('csv', Dateiformat::ausDateiname('Export 2026.CSV'), 'Das Format steckt in der Endung');
$this->assertNull(Dateiformat::ausDateiname('böse.php'), 'Eine fremde Endung ergibt kein Format');
$this->assertEquals(
'buchungen-2026-01-01-bis-2026-12-31.csv',
BuchungsExport::dateiname('csv', ['von' => '2026-01-01', 'bis' => '2026-12-31']),
'Der Dateiname nennt Format und Zeitraum'
);
$this->assert(
str_ends_with(BuchungsExport::dateiname('qif', []), '.qif'),
'Ohne Zeitraum bleibt wenigstens die Endung richtig'
);
// Der Upload nimmt jetzt beide Formate an – und nur die.
$this->assertEquals('Export.CSV', ImportController::sichererDateiname('Export.CSV'), 'CSV wird angenommen');
$this->assertEquals('Export.QIF', ImportController::sichererDateiname('Export.QIF'), 'QIF bleibt erlaubt');
$this->assertNull(ImportController::sichererDateiname('böse.php'), 'Fremde Endung wird abgewiesen');
$this->assertEquals(
'passwd.csv',
ImportController::sichererDateiname('../../etc/passwd.csv'),
'Pfadanteile fallen auch beim CSV weg'
);
}
private function exportLegtKeineDateiAb(): void
{
$vorher = $this->dateienImImportverzeichnis();
$datei = BuchungsExport::datei('csv', []);
$this->assertNotEmpty($datei['inhalt'], 'Die Datei entsteht im Speicher');
$this->assertEquals(3, $datei['anzahl'], 'Sie meldet, wie viele Buchungen sie führt');
$this->assert(str_contains($datei['typ'], 'text/csv'), 'Der Inhaltstyp passt zum Format');
$this->assertEquals(
$vorher,
$this->dateienImImportverzeichnis(),
'Der Export hinterlässt keine Datei auf der Platte'
);
$meldung = '';
try {
BuchungsExport::datei('xlsx', []);
} catch (\Throwable $e) {
$meldung = $e->getMessage();
}
$this->assertNotEmpty($meldung, 'Ein unbekanntes Format wird abgewiesen');
}
private function verwaltungFuehrtImportUndExport(): void
{
$seite = $this->verwaltungsseite();
$this->assert(str_contains($seite, '<h2>Import/Export</h2>'), 'Die Kachel heißt Import/Export');
$this->assert(!str_contains($seite, '<h2>QIF-Import</h2>'), 'Die alte Beschriftung ist weg');
$this->assert(
(bool)preg_match('#href="/verwaltung/import" class="knopf klein[^"]*" data-formatwahl="Import"#', $seite),
'Der Importknopf öffnet die Formatwahl'
);
$this->assert(
(bool)preg_match('#href="/verwaltung/export" class="knopf klein[^"]*" data-formatwahl="Export"#', $seite),
'Der Exportknopf öffnet die Formatwahl'
);
// Die Karten stehen im Markup, damit das Skript ohne Formatliste auskommt.
foreach (array_keys(Dateiformat::ALLE) as $format) {
$this->assert(
str_contains($seite, 'data-format="' . $format . '"'),
'Der Auswahldialog führt eine Karte für ' . strtoupper($format)
);
}
$skript = (string)file_get_contents(dirname(__DIR__) . '/public/static/app.js');
$this->assert(str_contains($skript, 'data-formatwahl'), 'Das Skript kennt die Formatknöpfe');
$this->assert(str_contains($skript, 'showModal'), 'Es öffnet den Dialog');
// Ohne Skript führt derselbe Knopf auf die Auswahl als eigene Seite.
$wahl = $this->rendere('verwaltung/formatwahl.php', [
'richtung' => 'Export',
'ziel' => '/verwaltung/export',
'einleitung' => 'Welches Format?',
]);
$this->assert(str_contains($wahl, 'href="/verwaltung/export?format=qif"'), 'Die Seite führt zum QIF-Export');
$this->assert(str_contains($wahl, 'href="/verwaltung/export?format=csv"'), 'Die Seite führt zum CSV-Export');
}
// ── Hilfen ───────────────────────────────────────────────────────────────
/**
* Die Umbuchung und die Splitbuchung des aktuellen Mandanten.
*
* @return array{0: array<string,mixed>|null, 1: array<string,mixed>|null}
*/
private function umbuchungUndSplit(): array
{
$umbuchung = null;
$split = null;
foreach (BuchungsExport::buchungen([]) as $buchung) {
if (($buchung['gegenkontoname'] ?? '') === 'Exportrücklage') {
$umbuchung = $buchung;
}
if ($buchung['splits'] !== []) {
$split = $buchung;
}
}
return [$umbuchung, $split];
}
/** @return list<string> */
private function dateienImImportverzeichnis(): array
{
$pfad = dirname(__DIR__) . '/' . trim(
(string)Einstellung::get('import_qif_verzeichnis', 'data/import'),
'/'
);
$dateien = glob($pfad . '/*') ?: [];
sort($dateien);
return $dateien;
}
private function verwaltungsseite(): string
{
$seite = $this->rendere('verwaltung/index.php', [
'aktuellerPfad' => '/verwaltung',
'zahlen' => [
'kategorien' => 0, 'einnahmen' => 0, 'ausgaben' => 0, 'klassen' => 0,
'konten' => 0, 'kontengruppen' => 0, 'reihen' => 0, 'erinnerungen' => 0,
'vertraege' => 0, 'fristen' => 0,
],
]);
return substr($seite, (int)strpos($seite, '<main'));
}
/** @param array<string,mixed> $daten */
private function rendere(string $view, array $daten): string
{
$daten += ['titel' => 'Test', 'flash' => [], 'benutzer' => null, 'csrfToken' => 'x'];
extract($daten, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/src/Views/' . $view;
return (string)ob_get_clean();
}
}

View file

@ -70,6 +70,41 @@ test.describe('Buchungen', () => {
await expect(page.locator('.saldoanzeige').first()).toContainText('1.000,00') await expect(page.locator('.saldoanzeige').first()).toContainText('1.000,00')
}) })
// Die Maske nennt oben das Konto, in dem die Buchung liegt, und stellt ihre
// Felder zweispaltig – beides, damit man beim Erfassen weder rätselt noch
// vertikal scrollt.
test('Buchungsmaske nennt das Konto und passt auf einen Bildschirm', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 })
await page.goto(`/konten/${giroId}/buchung`)
// Das Konto steht in der Maske selbst, nicht nur in der Kopfzeile.
await expect(page.locator('.buchungsmaske .feldwert')).toContainText(GIRO)
// Gleiche Zeile, zweite Spalte.
const art = await page.locator('#art').boundingBox()
const datum = await page.locator('#datum').boundingBox()
expect(Math.abs(art.y - datum.y)).toBeLessThan(5)
expect(datum.x).toBeGreaterThan(art.x + art.width)
// Und die Beschriftung steht links neben der Eingabe, nicht darüber.
const beschriftung = await page.locator('label[for="betrag"]').boundingBox()
const betrag = await page.locator('#betrag').boundingBox()
expect(beschriftung.x + beschriftung.width).toBeLessThanOrEqual(betrag.x)
expect(Math.abs(
(beschriftung.y + beschriftung.height / 2) - (betrag.y + betrag.height / 2)
)).toBeLessThan(5)
// Die Erklärungen stehen im Tooltip der Beschriftung, nicht als eigene
// Zeile darunter – sonst passte die Maske nicht auf einen Bildschirm.
await expect(page.locator('label[for="zuordnung"]')).toHaveAttribute('title', /Umbuchung/)
await expect(page.locator('label[for="betrag"]')).toHaveAttribute('title', /positiv/)
// Und genau das ist der Zweck: Die ganze Maske samt Splitzeilen und
// Knopfleiste endet oberhalb der Fensterhöhe.
const maske = await page.locator('.buchungsmaske').boundingBox()
expect(maske.y + maske.height).toBeLessThan(900)
})
test('Ausgabe buchen und Saldo fortschreiben', async ({ page }) => { test('Ausgabe buchen und Saldo fortschreiben', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`) await page.goto(`/konten/${giroId}/buchung`)

View file

@ -0,0 +1,127 @@
// @ts-check
const { test, expect } = require('@playwright/test')
const BENUTZER = process.env.E2E_USER || 'e2e'
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
const MARKE = Date.now().toString().slice(-6)
async function anmelden(page) {
await page.goto('/login')
if (page.url().includes('/einrichtung')) {
throw new Error(
'Instanz ist noch nicht eingerichtet. Zuerst einen Benutzer anlegen: ' +
'docker compose exec -u www-data php php scripts/e2e-benutzer.php'
)
}
await page.fill('#benutzername', BENUTZER)
await page.fill('#passwort', PASSWORT)
await page.click('button[type="submit"]')
await expect(page).toHaveURL(/\/$/)
}
// Formatwahl und Download laufen im Browser ab – der Dialog braucht das Skript,
// der Download den Antwortkopf. Beides lässt sich in der PHP-Suite nicht prüfen.
test.describe('Import und Export', () => {
test.beforeEach(async ({ page }) => {
await anmelden(page)
})
test('Die Kachel fragt erst nach dem Format und öffnet dann den Dialog', async ({ page }) => {
await page.goto('/verwaltung')
const kachel = page.locator('.kachel', { hasText: 'Import/Export' })
await expect(kachel.getByRole('link', { name: 'Import', exact: true })).toBeVisible()
await expect(kachel.getByRole('link', { name: 'Export', exact: true })).toBeVisible()
// Vor dem Klick ist der Auswahldialog zu.
const dialog = page.locator('dialog#formatwahl')
await expect(dialog).toBeHidden()
await kachel.getByRole('link', { name: 'Export', exact: true }).click()
await expect(dialog).toBeVisible()
await expect(dialog.locator('h2')).toContainText('Export')
await dialog.getByRole('link', { name: 'CSV' }).click()
await expect(page).toHaveURL(/\/verwaltung\/export\?format=csv$/)
await expect(page.locator('h1')).toContainText('Export (CSV)')
// Derselbe Weg für den Import – der Dialog merkt sich die Richtung nicht.
await page.goto('/verwaltung')
await kachel.getByRole('link', { name: 'Import', exact: true }).click()
await expect(dialog.locator('h2')).toContainText('Import')
await dialog.getByRole('link', { name: 'QIF' }).click()
await expect(page).toHaveURL(/\/verwaltung\/import\?format=qif$/)
})
test('Ohne Skript führt derselbe Knopf auf die Auswahl als Seite', async ({ page }) => {
await page.goto('/verwaltung/export')
await expect(page.locator('h2').first()).toContainText('Format wählen')
await expect(page.locator('a[href="/verwaltung/export?format=qif"]')).toBeVisible()
await expect(page.locator('a[href="/verwaltung/export?format=csv"]')).toBeVisible()
})
test('Der gefilterte Export lädt eine Datei herunter', async ({ page }) => {
await page.goto('/verwaltung/export?format=csv')
await page.fill('input[name="von"]', '2026-01-01')
await page.fill('input[name="bis"]', '2026-12-31')
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'CSV herunterladen' }).click(),
])
expect(download.suggestedFilename()).toMatch(/^buchungen-2026-01-01-bis-2026-12-31\.csv$/)
// Die Kopfzeile muss stehen, sonst wäre es keine Buchungstabelle.
const strom = await download.createReadStream()
const teile = []
for await (const stueck of strom) {
teile.push(stueck)
}
expect(Buffer.concat(teile).toString('utf8')).toContain('Buchungstext')
})
test('Eine CSV lässt sich hochladen und im Trockenlauf prüfen', async ({ page }) => {
const dateiname = `e2e-buchungen-${MARKE}.csv`
const konto = `E2E CSV Konto ${MARKE}`
// Das Konto muss es geben – Konten legt der CSV-Import nicht an.
await page.goto('/konten/neu')
await page.fill('#name', konto)
await page.click('.formular button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
const inhalt = [
'Art;Datum;Konto;Empfänger;Buchungstext;Betrag',
`Buchung;15.06.2026;${konto};E2E Laden;Testeinkauf ${MARKE};-12,34`,
'',
].join('\r\n')
await page.goto('/verwaltung/import?format=csv')
await page.setInputFiles('#datei', {
name: dateiname,
mimeType: 'text/csv',
buffer: Buffer.from(inhalt, 'utf8'),
})
await page.click('.formular button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toContainText('hochgeladen')
await page.locator('tr', { hasText: dateiname })
.getByRole('button', { name: 'Trockenlauf' }).click()
await expect(page.locator('.kachel h2').first()).toContainText('Trockenlauf')
await expect(page.locator('tr', { hasText: 'Neu' }).first()).toContainText('1')
// Aufräumen – die Datei bliebe sonst bis zum Housekeeping liegen.
await page.goto('/verwaltung/import?format=csv')
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)
})
})

View file

@ -89,6 +89,24 @@ test.describe('Stammdaten', () => {
await expect(page.locator('tr', { hasText: name })).not.toContainText('ohne Σ') await expect(page.locator('tr', { hasText: name })).not.toContainText('ohne Σ')
}) })
// Das Kontoformular steht zweispaltig, damit alle Felder ohne vertikales
// Scrollen sichtbar sind. Geprüft wird an den Kästen: Kontoart steht rechts
// neben dem Namen, und das Formular endet oberhalb der Fensterhöhe.
test('Kontoformular steht zweispaltig und ohne Scrollen', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 })
await page.goto('/konten/neu')
const name = await page.locator('#name').boundingBox()
const kontoart = await page.locator('#kontoart').boundingBox()
// Gleiche Zeile, zweite Spalte.
expect(Math.abs(name.y - kontoart.y)).toBeLessThan(5)
expect(kontoart.x).toBeGreaterThan(name.x + name.width)
const knopf = await page.locator('.formular button[type="submit"]').first().boundingBox()
expect(knopf.y + knopf.height).toBeLessThan(900)
})
test('Kategorie mit Unterkategorie anlegen', async ({ page }) => { test('Kategorie mit Unterkategorie anlegen', async ({ page }) => {
const oben = `E2E Wohnen ${MARKE}` const oben = `E2E Wohnen ${MARKE}`
const unten = `Strom ${MARKE}` const unten = `Strom ${MARKE}`
@ -188,8 +206,8 @@ test.describe('Stammdaten', () => {
'', '',
].join('\r\n') ].join('\r\n')
await page.goto('/verwaltung/import') await page.goto('/verwaltung/import?format=qif')
await page.setInputFiles('#qif', { await page.setInputFiles('#datei', {
name: dateiname, name: dateiname,
mimeType: 'application/octet-stream', mimeType: 'application/octet-stream',
buffer: Buffer.from(inhalt, 'latin1'), buffer: Buffer.from(inhalt, 'latin1'),
@ -215,7 +233,7 @@ test.describe('Stammdaten', () => {
await expect(page.locator('body')).not.toContainText(klasse) await expect(page.locator('body')).not.toContainText(klasse)
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen. // Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen.
await page.goto('/verwaltung/import') await page.goto('/verwaltung/import?format=qif')
page.on('dialog', async (dialog) => await dialog.accept()) page.on('dialog', async (dialog) => await dialog.accept())
await page.locator('tr', { hasText: dateiname }) await page.locator('tr', { hasText: dateiname })
.getByRole('button', { name: 'Löschen' }).click() .getByRole('button', { name: 'Löschen' }).click()
@ -254,8 +272,8 @@ test.describe('Stammdaten', () => {
'', '',
].join('\r\n') ].join('\r\n')
await page.goto('/verwaltung/import') await page.goto('/verwaltung/import?format=qif')
await page.setInputFiles('#qif', { await page.setInputFiles('#datei', {
name: dateiname, name: dateiname,
mimeType: 'application/octet-stream', mimeType: 'application/octet-stream',
buffer: Buffer.from(inhalt, 'latin1'), buffer: Buffer.from(inhalt, 'latin1'),
@ -286,7 +304,7 @@ test.describe('Stammdaten', () => {
await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).not.toBeChecked() await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).not.toBeChecked()
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen. // Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen.
await page.goto('/verwaltung/import') await page.goto('/verwaltung/import?format=qif')
page.on('dialog', async (dialog) => await dialog.accept()) page.on('dialog', async (dialog) => await dialog.accept())
await page.locator('tr', { hasText: dateiname }) await page.locator('tr', { hasText: dateiname })
.getByRole('button', { name: 'Löschen' }).click() .getByRole('button', { name: 'Löschen' }).click()