Phase 1: Stammdaten und QIF-Stammdatenimport

Konten, Kontengruppen, Kategorien und Klassen samt Übernahme aus dem
QIF-Export des Lexware FinanzManagers.

Kontenübersicht nach Kontengruppen gebündelt, mit Gruppensummen und
Gesamtsumme; die drei Saldospalten (Online-Saldo, aktueller und erwarteter
Saldo) sind angelegt und werden bis Phase 2 aus dem Anfangssaldo gespeist.

Kategorien sind hierarchisch mit ":" als Trenner. Der vollständige Pfad
steht redundant in der Tabelle, weil jede Anzeige und der gesamte Import
darüber laufen; Umbenennen und Verschieben schreiben den Teilbaum neu. Die
Art (Einnahme/Ausgabe) gilt für den ganzen Ast. Register wie im Original:
Alle, Einnahmen, Ausgaben, Umbuchungskonten.

QifParser liest zustandsbehaftet und sequenziell, wandelt ISO-8859-1 nach
UTF-8 und versteht US-Beträge sowie das Datumsformat aus der Kopfzeile.
QifStammdatenImport ist idempotent und bietet Trockenlauf und Protokoll -
im Dialog unter Verwaltung sowie über scripts/qif-import.php. Am echten
Export geprüft: 17 Klassen, 105 Kategorien, 37 Konten.

Enthält außerdem die Korrektur der englischen Monatsnamen im
Einstellungsdialog: date('F') ist sprachlich nicht umschaltbar, deshalb
liegen die Namen jetzt zentral als View::MONATE vor. Die Änderung ließ
sich nicht als eigener Commit trennen, weil sie dieselben Dateien berührt
wie Phase 1.

Tests: 227 PHP-Prüfungen und 14 Playwright-Tests, alle grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-09 21:33:40 +02:00
parent 67f86bd360
commit 1e3227ce83
40 changed files with 4669 additions and 27 deletions

View file

@ -1,5 +1,56 @@
# Änderungen
## Phase 1 – Stammdaten und QIF-Stammdatenimport (09.08.2026)
**Datenmodell**
- Migration `002_stammdaten.sql`: `kontengruppen`, `konten`, `kategorien`, `klassen`
- Alle Tabellen führen `benutzer_id`; `Mandant::id()` liefert die Kennung aus der Session
oder – im CLI – vom ersten aktiven Benutzer
**Konten**
- Kontenübersicht nach Kontengruppen gebündelt, mit Gruppensummen und Gesamtsumme
- Die drei Saldospalten des FinanzManagers (Online-Saldo, aktueller und erwarteter Saldo)
sind angelegt und werden bis Phase 2 aus dem Anfangssaldo gespeist
- Kontoarten des FinanzManagers samt Zuordnung zu den QIF-Typen
- Kontenpflege mit Anfangssaldo, Kreditlimit, IBAN/BIC, Kontengruppe, aktiv/inaktiv
- Kontengruppen anlegen, umbenennen, sortieren, löschen – die Konten bleiben erhalten
**Kategorien und Klassen**
- Hierarchische Kategorien mit `:` als Trenner; der vollständige Pfad wird mitgeführt und
beim Umbenennen oder Verschieben für den ganzen Teilbaum neu geschrieben
- Art (Einnahme/Ausgabe) gilt für den ganzen Ast und vererbt sich an Unterkategorien
- Verschieben, Zusammenführen und Löschen; Verschieben in den eigenen Teilbaum wird
abgewiesen
- Register *Alle / Einnahmen / Ausgaben / Umbuchungskonten* wie im Original
- Klassen als flache zweite Auswertungsdimension, in der Liste direkt bearbeitbar
**QIF-Import**
- `QifParser`: zustandsbehaftetes, sequenzielles Lesen; ISO-8859-1 nach UTF-8, US-Beträge,
Datumsformat aus der Kopfzeile, Mehrfachfelder für spätere Splitzeilen
- `QifStammdatenImport` für Klassen, Kategorien und Konten – idempotent, mit Trockenlauf
und Protokoll; fehlende Zwischenstufen im Kategoriepfad entstehen selbsttätig
- Dialog unter *Verwaltung → QIF-Import* mit Upload, Trockenlauf und Übernahme
- `scripts/qif-import.php` für dieselbe Aufgabe auf der Kommandozeile
- Geprüft am echten Export: 17 Klassen, 105 Kategorien, 37 Konten
**Tests**
- `06_KategorienTest` – Hierarchie, Pfadpflege, Artvererbung, Verschieben, Zusammenführen
- `07_KontenTest` – Kontoarten, Gruppierung und Summen, Betragseingabe
- `08_QifImportTest` – Parser, Zeichensatz, Beträge und Datum, Import, Trockenlauf,
Prüfung der Dateinamen
- E2E `stammdaten.spec.js` – Konto, Kategorie mit Unterkategorie, Register, Klasse,
QIF-Upload mit Trockenlauf
## Korrektur – Monatsnamen auf Deutsch (09.08.2026)
- Der Einstellungsdialog zeigte unter *Buchhaltung* englische Monatsnamen, weil
`date('F')` sprachlich nicht umschaltbar ist.
- Monatsnamen liegen jetzt zentral als `View::MONATE` und `View::MONATE_KURZ` vor,
abrufbar über `View::monat()`. Budgetplaner und Auswertungen greifen später darauf zu.
- `05_DarstellungTest` prüft Monatsnamen, Betrags- und Datumsformat gegen eine feste
Sollliste – der Vergleich gegen `date('F')` allein würde April, August, September und
November nicht auffallen lassen.
## Phase 0 – Projektgerüst (09.08.2026)
Grundgerüst der Anwendung. Fachliche Module folgen ab Phase 1.

11
PLAN.md
View file

@ -179,7 +179,7 @@ und Protokoll (importiert / übersprungen / fehlerhaft je Zeile). Nach dem Impor
## 5. Phasen
### Phase 0 — Projektgerüst
### Phase 0 — Projektgerüst *(erledigt)*
* Repository auf Codeberg (Upstream persönlicher Account, Fork BOT-Account, PR-Workflow
gemäß globaler Regeln), `.gitignore`, `LICENSE`, `README.md`, `CLAUDE.md`, `Changes.md`.
* Docker Compose mit nginx (Port 8000) + php-fpm + MariaDB, `docker-entrypoint.sh`,
@ -194,17 +194,18 @@ und Protokoll (importiert / übersprungen / fehlerhaft je Zeile). Nach dem Impor
Feature mit UI einen E2E-Test.
* *Ergebnis:* Login funktioniert, leere Anwendung ist erreichbar, `./scripts/run-tests.sh` läuft grün.
### Phase 1 — Stammdaten und QIF-Stammdatenimport
### Phase 1 — Stammdaten und QIF-Stammdatenimport *(erledigt)*
* Migrationen für `konten`, `kontengruppen`, `kategorien`, `klassen`.
* Verwaltung Kategorien (Baumansicht mit Registern *Alle / Einnahmen / Ausgaben /
Umbuchungskonten*, Anlegen, Bearbeiten, Verschieben, Ersetzen, Zusammenführen).
* Verwaltung Klassen, Kontengruppen, Konten (Anlegen/Bearbeiten, Kontoarten, Anfangssaldo).
* **Kontenübersicht** mit Gruppierung, Spalten Online-Saldo / Aktueller Saldo /
Erwarteter Saldo, Summenzeile.
* `src/Services/QifParser.php` + `QifImporter.php`, zunächst für `!Type:Class`,
`!Type:Cat`, `!Account`.
* `src/Services/QifParser.php` + `QifStammdatenImport.php`, zunächst für `!Type:Class`,
`!Type:Cat`, `!Account` — mit Trockenlauf, Protokoll und `scripts/qif-import.php` für
die Kommandozeile.
* *Ergebnis:* 17 Klassen, 105 Kategorien und 37 Konten sind aus dem Export übernommen und
in der Oberfläche gepflegt.
in der Oberfläche gepflegt. Einzelheiten in `docs/stammdaten.md` und `docs/qif-import.md`.
### Phase 2 — Buchungen
* Migrationen `buchungen`, `buchung_splits`, `buchungsadressen`.

View file

@ -10,8 +10,10 @@ Haushalt wird beides parallel genutzt.
## Stand
Phase 0 (Grundgerüst) ist gebaut: Anmeldung, zentraler Einstellungsdialog, Debug-Log,
Migrationsmechanik, Test-Harness. Die fachlichen Module folgen nach `PLAN.md`.
Phase 0 (Grundgerüst) und Phase 1 (Stammdaten) sind gebaut: Anmeldung, zentraler
Einstellungsdialog, Debug-Log, Migrationsmechanik, Test-Harness sowie Konten,
Kontengruppen, Kategorien, Klassen und der QIF-Stammdatenimport. Buchungen folgen in
Phase 2 nach `PLAN.md`.
## Technik
@ -62,8 +64,20 @@ verschlüsselt und nicht auswertbar. Der Importer liest Klassen, Kategorien, Kon
Buchungen samt Splits sowie Wertpapierbuchungen. Einzelheiten zum Format und den
Fallstricken stehen in `PLAN.md`, Kapitel 4.
Klassen, Kategorien und Konten lassen sich bereits übernehmen – im Dialog unter
*Verwaltung → QIF-Import* oder auf der Kommandozeile:
```bash
docker compose exec -u www-data php php scripts/qif-import.php data/import/Export.QIF
docker compose exec -u www-data php php scripts/qif-import.php --uebernehmen data/import/Export.QIF
```
Ohne `--uebernehmen` läuft nur der Trockenlauf.
## Dokumentation
- `PLAN.md` – Projektplan mit allen Phasen
- `CLAUDE.md` – Entwicklungsregeln
- `Changes.md` – Änderungsprotokoll
- `docs/stammdaten.md` – Konten, Kontengruppen, Kategorien, Klassen
- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs

102
docs/qif-import.md Normal file
View file

@ -0,0 +1,102 @@
# QIF-Import
Der Lexware FinanzManager exportiert erweitertes Quicken-QIF. Die `.QDF`-Datei ist
verschlüsselt und damit keine Migrationsquelle – der QIF-Export ist der einzige Weg.
Stand: Klassen, Kategorien und Konten werden übernommen (Phase 1). Buchungen, Splits,
Umbuchungen und Wertpapierbuchungen folgen in Phase 2 und 6.
## Aufbau der Datei
```
!Option:MDY Datumsformat für die gesamte Datei
!Type:Class Klassen
!Type:Cat Kategorien
!Option:AutoSwitch Beginn der Kontenliste
!Account ein Konto je Eintrag
!Clear:AutoSwitch Ende der Kontenliste
!Account / !Type:… ab hier abwechselnd Konto und dessen Buchungen
```
Ein Eintrag endet mit `^`. Jede Zeile beginnt mit einem Feldbuchstaben, der Rest ist der
Wert. Felder dürfen mehrfach auftreten (Splitzeilen, Adresszeilen), deshalb hält
`QifParser` jeden Eintrag als Liste von Paaren und bietet `feld()`, `felder()` und
`hatFeld()`.
**Die Datei ist zustandsbehaftet.** Der Kontobezug einer Buchung steckt allein darin,
welcher `!Account`-Abschnitt zuletzt kam – nach Sektionstypen gruppiert gelesen wäre er
verloren. Der Parser liefert daher eine flache Liste in Dateireihenfolge, jeder Eintrag mit
Sektionstyp und aktuellem Konto.
Jeder Kontoeintrag setzt zugleich das aktuelle Konto. In der Kontenliste folgen mehrere
aufeinander, ohne dass Buchungen dazwischenstehen; das schadet nicht und deckt zugleich die
zweite Schreibweise ab, bei der ein einzelner `!Account` den Kontowechsel bedeutet.
## Felder der Stammdaten
| Sektion | Feld | Bedeutung |
|---|---|---|
| `!Type:Class` | `N` / `D` | Name, Beschreibung |
| `!Type:Cat` | `N` | vollständiger Pfad mit `:` |
| | `D` | Beschreibung |
| | `I` / `E` | Einnahme bzw. Ausgabe (Marker ohne Wert) |
| | `T` | steuerrelevant (Marker ohne Wert) |
| | `R` | Kennziffer des Steuerformulars |
| `!Account` | `N` / `T` | Name, Kontotyp (siehe `docs/stammdaten.md`) |
| | `D` | Beschreibung; `Kontonr. 12345` wird als Kontonummer übernommen |
| | `L` | Kreditlimit im US-Format |
## Fallstricke
1. **Zeichensatz ISO-8859-1, Zeilenenden CRLF.** `QifParser::nachUtf8()` prüft erst, ob
schon UTF-8 vorliegt – eine zweite Konvertierung zerstörte die Umlaute.
2. **Beträge im US-Format**: `-1,241.04`. Das Komma ist Tausendertrenner.
3. **Datum laut Kopfzeile**, meist `MDY` mit zweistelligem Jahr: `D3.18.26` ist der
18.03.2026. Jahreszahlen ab 70 gelten als 19xx.
4. **Kategorie und Klasse in einer Zeile**: `L400 - Leben:B-Essen alleine/Privat`.
5. **Unterkategorien ohne genannte Elternkategorie.** Der Import legt fehlende
Zwischenstufen selbst an (`Kategorie::findenOderAnlegenNachPfad()`).
6. **Konten stehen zweimal in der Datei** – einmal in der Kontenliste, einmal als Kopf
ihrer Buchungen. Der zweite Treffer ist keine Dublette.
7. **Umbuchungen erscheinen doppelt**, einmal je beteiligtem Konto, und müssen in Phase 2
zu einer Buchung zusammengeführt werden.
8. **Anfangssalden fehlen im Export** und werden je Konto nachgetragen.
## Arbeitsweise des Importeurs
`QifStammdatenImport` ist **idempotent**: Was es schon gibt, wird gezählt und übersprungen,
nie doppelt angelegt. Ein zweiter Lauf derselben Datei ändert nichts.
Der echte Lauf liegt in einer Transaktion – ein Abbruch mitten in der Kategorieliste
hinterließe sonst einen halben Baum. Der **Trockenlauf** schreibt gar nicht, sondern zählt
mit einer Menge bereits bekannter Pfade mit; auch die Zwischenstufen, die der Import selbst
anlegen würde, erscheinen dort in der Vorschau.
## Bedienung
Im Dialog unter *Verwaltung → QIF-Import*: Datei hochladen, Trockenlauf, dann übernehmen.
Hochgeladene Dateien bleiben im Importverzeichnis (`data/import`), damit derselbe Export
erst geprüft und dann übernommen werden kann.
Auf der Kommandozeile:
```bash
docker compose exec -u www-data php php scripts/qif-import.php data/import/Export.QIF
docker compose exec -u www-data php php scripts/qif-import.php --uebernehmen data/import/Export.QIF
```
Ohne `--uebernehmen` läuft nur der Trockenlauf. `--benutzer=NAME` wählt den Mandanten,
sonst gilt der erste aktive Benutzer.
## Prüfung gegen den echten Export
Der Trockenlauf gegen `Export-Migration-Beispiel.QIF` liefert die Zahlen der Voranalyse:
```
Datei enthält Class: 17 · Cat: 105 · Account: 73 · Bank: 572 · Cash: 61 ·
Oth T: 31 · Oth A: 154 · Invst: 16 · Memorized: 579
Klassen 17 neu
Kategorien 105 neu
Konten 37 neu, 36 vorhanden (die Zweitnennung je Konto)
```

76
docs/stammdaten.md Normal file
View file

@ -0,0 +1,76 @@
# Stammdaten
Konten, Kontengruppen, Kategorien und Klassen – die Grundlage aller Buchungen und
Auswertungen. Die Begriffe entsprechen dem Lexware FinanzManager.
## Kontengruppen und Konten
Kontengruppen gliedern die Kontenübersicht (etwa „Anlage“). Konten ohne Gruppe erscheinen
unter **Nicht gruppiert** – das ist keine eigene Gruppe, sondern `kontengruppe_id IS NULL`.
Wird eine Gruppe gelöscht, bleiben ihre Konten erhalten und rutschen dorthin
(`ON DELETE SET NULL`).
Kontoarten (`Konto::ARTEN`) und ihre Herkunft aus dem QIF-Export (`Konto::QIF_ARTEN`):
| Kontoart | Bezeichnung | QIF-Typ |
|---|---|---|
| `bank` | Bank | `Bank` |
| `kreditkarte` | Kreditkarte | `CCard` |
| `bar` | Bar | `Cash` |
| `sparkonto` | Tagesgeld/Sparkonto | `Oth T` |
| `vermoegen` | Vermögen | `Oth A` |
| `verbindlichkeit` | Verbindlichkeit | `Oth L` |
| `rente` | Rentenversicherung | `Oth P` |
| `sonstiges` | Sonstiges Vermögen | `Oth E`, `Oth F` |
| `depot` | Depot | `Port`, `Invst` |
Die **Kontenübersicht** (`Konto::uebersicht()`) bündelt nach Gruppe, bildet je Gruppe eine
Summe und darunter die Gesamtsumme. Sie führt schon jetzt die drei Saldospalten des
FinanzManagers:
* **Online-Saldo** – Stand des letzten Bankabrufs, bleibt bis Phase 5 leer
* **Aktueller Saldo** – gebuchte Umsätze bis heute
* **Erwarteter Saldo** – einschließlich künftig datierter Buchungen
Solange es keine Buchungen gibt (bis Phase 2), entsprechen alle drei dem Anfangssaldo.
Die Werte entstehen zentral in `Konto::mitSalden()`; dort kommen in Phase 2 die Buchungen
dazu, ohne dass die Aufrufer sich ändern.
**Anfangssalden** stehen nicht im QIF-Export. Sie werden nach dem Import je Konto im
Kontoformular nachgetragen.
## Kategorien
Hierarchisch, Trenner ist `:` wie im FinanzManager. `name` hält den Blattnamen, `pfad` den
vollständigen Namen (`400 - Leben:B-Essen alleine`). Der Pfad steht redundant in der
Tabelle, weil praktisch jede Anzeige, jede Auswahl und der gesamte Import darüber laufen;
Umbenennen und Verschieben schreiben die Pfade des ganzen Teilbaums neu
(`Kategorie::pfadeNeuAufbauen()`).
Regeln:
* Die **Art** (Einnahme/Ausgabe) gilt für den ganzen Ast. Unterkategorien erben sie, und
ein Wechsel an der Wurzel zieht alle Nachfahren mit.
* Ein Name darf keinen Doppelpunkt enthalten – der trennt die Ebenen.
* Eine Kategorie kann nicht unter ihren eigenen Teilbaum verschoben werden.
* **Zusammenführen** hängt die Unterkategorien der Quelle unter das Ziel und löscht die
Quelle. Ab Phase 2 wandern dort auch die Buchungen mit.
* **Löschen** nimmt die Unterkategorien mit (`ON DELETE CASCADE`). Wer eine Kategorie nur
aus den Auswahllisten nehmen will, setzt sie auf inaktiv.
Die Oberfläche hat die vier Register des Originals: *Alle*, *Einnahmen*, *Ausgaben*,
*Umbuchungskonten*. Umbuchungskonten sind keine Datensätze – dort stehen die Konten, weil
sie in der Kategorieauswahl einer Buchung an derselben Stelle erscheinen (in eckigen
Klammern).
## Klassen
Flach, ohne Hierarchie – die zweite Auswertungsdimension neben der Kategorie (Ehemann,
Ehefrau, Haus 1, Projekt …). Im QIF hängen sie mit `/` an der Kategorie:
`400 - Leben:B-Essen alleine/Privat`. `Kategorie::zerlegeQifAngabe()` trennt beides.
## Mandantenfähigkeit
Alle Stammdatentabellen führen `benutzer_id`. Die Oberfläche ist einbenutzerig; die
Kennung liefert `Mandant::id()` – aus der Session, im CLI ersatzweise der erste aktive
Benutzer. `Mandant::setze()` gibt sie in Skripten und Tests fest vor.

View file

@ -0,0 +1,88 @@
-- Phase 1: Stammdaten – Kontengruppen, Konten, Kategorien, Klassen.
--
-- Alle Kernobjekte führen benutzer_id, auch wenn die Oberfläche vorerst
-- einbenutzerig ist (siehe PLAN.md, Kapitel 3).
CREATE TABLE IF NOT EXISTS kontengruppen (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
name VARCHAR(128) NOT NULL,
beschreibung VARCHAR(255) NOT NULL DEFAULT '',
sortierung INT NOT NULL DEFAULT 0,
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_kontengruppe (benutzer_id, name),
CONSTRAINT fk_kontengruppe_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- kontoart entspricht den Kontoarten des FinanzManagers; die Zuordnung zu den
-- QIF-Typen (Bank, CCard, Oth A …) steht in Konto::QIF_ARTEN.
CREATE TABLE IF NOT EXISTS konten (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
kontengruppe_id INT UNSIGNED NULL,
name VARCHAR(128) NOT NULL,
kontoart VARCHAR(32) NOT NULL DEFAULT 'bank',
bankname VARCHAR(128) NOT NULL DEFAULT '',
kontonummer VARCHAR(64) NOT NULL DEFAULT '',
iban VARCHAR(34) NOT NULL DEFAULT '',
bic VARCHAR(11) NOT NULL DEFAULT '',
beschreibung VARCHAR(255) NOT NULL DEFAULT '',
anfangssaldo DECIMAL(15,2) NOT NULL DEFAULT 0.00,
kreditlimit DECIMAL(15,2) NULL,
waehrung CHAR(3) NOT NULL DEFAULT 'EUR',
aktiv TINYINT(1) NOT NULL DEFAULT 1,
sortierung INT NOT NULL DEFAULT 0,
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_konto (benutzer_id, name),
KEY idx_konto_gruppe (kontengruppe_id),
CONSTRAINT fk_konto_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE,
CONSTRAINT fk_konto_gruppe FOREIGN KEY (kontengruppe_id)
REFERENCES kontengruppen (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Kategorien sind hierarchisch. "name" ist nur der Blattname, "pfad" der
-- vollständige Name mit ":" als Trenner ("400 - Leben:B-Essen alleine") – er
-- wird beim Anlegen und Verschieben mitgepflegt, weil praktisch jede Anzeige
-- und der gesamte QIF-Import darüber laufen.
CREATE TABLE IF NOT EXISTS kategorien (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
eltern_id INT UNSIGNED NULL,
name VARCHAR(128) NOT NULL,
pfad VARCHAR(512) NOT NULL,
art ENUM('einnahme','ausgabe') NOT NULL DEFAULT 'ausgabe',
steuerrelevant TINYINT(1) NOT NULL DEFAULT 0,
steuerformular VARCHAR(32) NOT NULL DEFAULT '',
beschreibung VARCHAR(255) NOT NULL DEFAULT '',
aktiv TINYINT(1) NOT NULL DEFAULT 1,
sortierung INT NOT NULL DEFAULT 0,
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_kategorie_pfad (benutzer_id, pfad),
KEY idx_kategorie_eltern (eltern_id),
CONSTRAINT fk_kategorie_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE,
CONSTRAINT fk_kategorie_eltern FOREIGN KEY (eltern_id)
REFERENCES kategorien (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS klassen (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
name VARCHAR(128) NOT NULL,
beschreibung VARCHAR(255) NOT NULL DEFAULT '',
aktiv TINYINT(1) NOT NULL DEFAULT 1,
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_klasse (benutzer_id, name),
CONSTRAINT fk_klasse_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO einstellungen (schluessel, wert, bereich) VALUES
('import_qif_verzeichnis', 'data/import', 'import')
ON DUPLICATE KEY UPDATE schluessel = schluessel;

View file

@ -12,8 +12,12 @@ use Finanzmanager\Auth;
use Finanzmanager\Config;
use Finanzmanager\Controllers\CockpitController;
use Finanzmanager\Controllers\EinstellungenController;
use Finanzmanager\Controllers\ImportController;
use Finanzmanager\Controllers\KategorienController;
use Finanzmanager\Controllers\KontenController;
use Finanzmanager\Controllers\LoginController;
use Finanzmanager\Controllers\PlatzhalterController;
use Finanzmanager\Controllers\VerwaltungController;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Router;
use Finanzmanager\Services\DebugLog;
@ -52,6 +56,48 @@ $router->get('/einstellungen', fn() => (new EinstellungenController())->index()
$router->post('/einstellungen', fn() => (new EinstellungenController())->speichern());
$router->post('/einstellungen/log-leeren', fn() => (new EinstellungenController())->logLeeren());
// Konten. Die festen Pfade stehen vor den Mustern mit {id}, sonst schluckt
// "/konten/{id}/…" die Route "/konten/neu".
$router->get('/konten', fn() => (new KontenController())->index());
$router->get('/konten/neu', fn() => (new KontenController())->neu());
$router->post('/konten/neu', fn() => (new KontenController())->anlegen());
$router->get('/konten/{id}/bearbeiten', fn(array $p) => (new KontenController())->bearbeiten($p));
$router->post('/konten/{id}/bearbeiten', fn(array $p) => (new KontenController())->speichern($p));
$router->post('/konten/{id}/loeschen', fn(array $p) => (new KontenController())->loeschen($p));
// Verwaltung: Kategorien, Klassen, Kontengruppen, QIF-Import.
$router->get('/verwaltung', fn() => (new VerwaltungController())->index());
$router->get('/verwaltung/kategorien', fn() => (new KategorienController())->index());
$router->get('/verwaltung/kategorien/neu', fn() => (new KategorienController())->neu());
$router->post('/verwaltung/kategorien/neu', fn() => (new KategorienController())->anlegen());
$router->get('/verwaltung/kategorien/{id}', fn(array $p) => (new KategorienController())->bearbeiten($p));
$router->post('/verwaltung/kategorien/{id}', fn(array $p) => (new KategorienController())->speichern($p));
$router->post('/verwaltung/kategorien/{id}/verschieben',
fn(array $p) => (new KategorienController())->verschieben($p));
$router->post('/verwaltung/kategorien/{id}/zusammenfuehren',
fn(array $p) => (new KategorienController())->zusammenfuehren($p));
$router->post('/verwaltung/kategorien/{id}/loeschen',
fn(array $p) => (new KategorienController())->loeschen($p));
$router->get('/verwaltung/klassen', fn() => (new VerwaltungController())->klassen());
$router->post('/verwaltung/klassen/neu', fn() => (new VerwaltungController())->klasseAnlegen());
$router->post('/verwaltung/klassen/{id}', fn(array $p) => (new VerwaltungController())->klasseSpeichern($p));
$router->post('/verwaltung/klassen/{id}/loeschen',
fn(array $p) => (new VerwaltungController())->klasseLoeschen($p));
$router->get('/verwaltung/kontengruppen', fn() => (new VerwaltungController())->kontengruppen());
$router->post('/verwaltung/kontengruppen/neu', fn() => (new VerwaltungController())->kontengruppeAnlegen());
$router->post('/verwaltung/kontengruppen/{id}',
fn(array $p) => (new VerwaltungController())->kontengruppeSpeichern($p));
$router->post('/verwaltung/kontengruppen/{id}/loeschen',
fn(array $p) => (new VerwaltungController())->kontengruppeLoeschen($p));
$router->get('/verwaltung/import', fn() => (new ImportController())->index());
$router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen());
$router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren());
$router->post('/verwaltung/import/{datei}/loeschen', fn(array $p) => (new ImportController())->loeschen($p));
// Noch nicht gebaute Bereiche: sichtbar in der Navigation, mit Hinweis auf die Phase.
foreach (PlatzhalterController::pfade() as $bereich) {
$router->get('/' . $bereich, fn() => (new PlatzhalterController())->zeige($bereich));

View file

@ -361,6 +361,93 @@ table.liste tbody tr:hover { background: var(--flaeche-gedaempft); }
.betrag.negativ { color: var(--negativ); }
.nowrap { white-space: nowrap; }
/* Gliederung der Kontenübersicht: Gruppenkopf, Gruppensumme, Gesamtsumme. */
table.liste tr.gruppenzeile th {
background: var(--flaeche-gedaempft);
border-top: 1px solid var(--rand);
font-size: .85rem;
letter-spacing: .03em;
text-transform: uppercase;
color: var(--text-gedaempft);
}
table.liste tr.gruppensumme td {
font-weight: 600;
background: color-mix(in srgb, var(--flaeche-gedaempft) 55%, var(--flaeche));
}
table.liste tfoot tr.summenzeile td {
font-weight: 700;
border-top: 2px solid var(--rand-kraeftig);
background: var(--flaeche-gedaempft);
}
table.liste tr.inaktiv td { color: var(--text-gedaempft); }
table.liste input[type="text"],
table.liste input[type="number"] {
width: 100%;
padding: .25rem .4rem;
border: 1px solid var(--rand);
border-radius: 4px;
background: var(--flaeche);
color: var(--text);
font: inherit;
}
table.liste input.schmal { width: 5rem; }
/* Einrückung des Kategoriebaums. Die Ebene kommt als Custom Property aus der
Vorlage – so bleibt die Tabelle frei von verschachtelten Elementen. */
.baumname {
display: inline-block;
padding-left: calc(var(--ebene, 0) * 1.4rem);
}
.nebentext {
color: var(--text-gedaempft);
font-size: .88rem;
}
.marke {
display: inline-block;
padding: .05rem .4rem;
border: 1px solid var(--rand-kraeftig);
border-radius: 999px;
font-size: .75rem;
color: var(--text-gedaempft);
white-space: nowrap;
}
.kennzahl {
font-family: var(--schrift-zahl);
font-size: 2rem;
font-weight: 600;
margin: .2rem 0;
}
.protokoll {
margin: .4rem 0 0;
padding-left: 1.1rem;
font-size: .9rem;
}
.protokoll li.fehler { color: var(--negativ); }
/* ── Aktionsleiste über den Listen ───────────────────────────────────────── */
.seitenaktionen {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .5rem;
margin-bottom: 1rem;
}
.seitenaktionen .fuellung { flex: 1; }
/* ── Formulare ───────────────────────────────────────────────────────────── */
.formular {
@ -455,6 +542,26 @@ table.liste tbody tr:hover { background: var(--flaeche-gedaempft); }
.knopf.primaer:hover { filter: brightness(1.08); }
/* Der Abstand oben gilt dem allein stehenden Knopf unter einem Formular –
in Aktionsleisten und Tabellen stört er die Ausrichtung. */
.seitenaktionen .knopf,
table.liste .knopf { margin-top: 0; }
.knopf.klein {
padding: .25rem .6rem;
font-size: .85rem;
}
.knopf.gefahr {
border-color: var(--negativ);
color: var(--negativ);
background: var(--flaeche);
}
.knopf.gefahr:hover { background: color-mix(in srgb, var(--negativ) 12%, var(--flaeche)); }
.feldwert { margin: .1rem 0 .3rem; }
/* ── Meldungen ───────────────────────────────────────────────────────────── */
.meldung {

112
scripts/qif-import.php Normal file
View file

@ -0,0 +1,112 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* QIF-Stammdatenimport auf der Kommandozeile.
*
* Dieselbe Logik wie im Dialog unter Verwaltung → QIF-Import, aber ohne Upload –
* praktisch für den großen Vollexport und für die Erstbefüllung.
*
* docker compose exec -u www-data php php scripts/qif-import.php data/import/Export.QIF
* docker compose exec -u www-data php php scripts/qif-import.php --uebernehmen data/import/Export.QIF
*
* Ohne --uebernehmen läuft nur der Trockenlauf: es wird nichts gespeichert.
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use Finanzmanager\Config;
use Finanzmanager\Mandant;
use Finanzmanager\Models\Benutzer;
use Finanzmanager\Services\QifParser;
use Finanzmanager\Services\QifStammdatenImport;
Config::load();
$argumente = array_slice($argv, 1);
$uebernehmen = false;
$benutzer = null;
$datei = null;
foreach ($argumente as $argument) {
if ($argument === '--uebernehmen') {
$uebernehmen = true;
} elseif (str_starts_with($argument, '--benutzer=')) {
$benutzer = substr($argument, 11);
} elseif (str_starts_with($argument, '-')) {
fwrite(STDERR, "Unbekannte Option: {$argument}\n");
exit(1);
} else {
$datei = $argument;
}
}
if ($datei === null) {
fwrite(STDERR, "Aufruf: qif-import.php [--uebernehmen] [--benutzer=NAME] <datei.qif>\n");
exit(1);
}
// Relative Angaben beziehen sich auf das Projektverzeichnis.
if (!str_starts_with($datei, '/')) {
$datei = dirname(__DIR__) . '/' . $datei;
}
if (!is_file($datei)) {
fwrite(STDERR, "Datei nicht gefunden: {$datei}\n");
exit(1);
}
if ($benutzer !== null) {
$zeile = Benutzer::findByName($benutzer);
if ($zeile === null) {
fwrite(STDERR, "Benutzer nicht gefunden: {$benutzer}\n");
exit(1);
}
Mandant::setze((int)$zeile['id']);
}
try {
$parser = QifParser::ausDatei($datei);
$ergebnis = (new QifStammdatenImport($parser))->ausfuehren(!$uebernehmen);
} catch (\Throwable $e) {
fwrite(STDERR, 'Import fehlgeschlagen: ' . $e->getMessage() . "\n");
exit(1);
}
echo ($uebernehmen ? 'Import' : 'Trockenlauf') . ': ' . basename($datei) . "\n";
$abschnitte = [];
foreach ($parser->zusammenfassung() as $typ => $anzahl) {
$abschnitte[] = "{$typ}: {$anzahl}";
}
echo 'Datei enthält ' . implode(' · ', $abschnitte) . "\n\n";
printf("%-12s %8s %12s %8s\n", 'Bereich', $uebernehmen ? 'angelegt' : 'neu', 'vorhanden', 'Fehler');
foreach (['klassen' => 'Klassen', 'kategorien' => 'Kategorien', 'konten' => 'Konten'] as $schluessel => $titel) {
printf(
"%-12s %8d %12d %8d\n",
$titel,
$ergebnis[$schluessel]['neu'],
$ergebnis[$schluessel]['vorhanden'],
$ergebnis[$schluessel]['fehler']
);
}
if ($ergebnis['meldungen'] !== []) {
echo "\nProtokoll:\n";
foreach ($ergebnis['meldungen'] as $meldung) {
echo ' - ' . $meldung['text'] . "\n";
}
}
if (!$uebernehmen) {
echo "\nEs wurde nichts gespeichert. Mit --uebernehmen wird der Import durchgeführt.\n";
} else {
echo "\nAnfangssalden stehen nicht im Export – sie werden je Konto in der\n"
. "Kontenübersicht nachgetragen.\n";
}
$fehler = $ergebnis['klassen']['fehler'] + $ergebnis['kategorien']['fehler'] + $ergebnis['konten']['fehler'];
exit($fehler > 0 ? 1 : 0);

View file

@ -4,16 +4,19 @@ declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Konto;
class CockpitController extends BaseController
{
public function index(): void
{
$this->verlangeAnmeldung();
// Die Kacheln füllen sich ab Phase 2 mit echten Zahlen; bis dahin
// zeigt das Cockpit den Aufbaustand.
// Die Kontostandskachel zeigt schon jetzt echte Zahlen; die übrigen
// Kacheln brauchen Buchungen und füllen sich ab Phase 2.
$this->render('cockpit/index.php', [
'titel' => 'Cockpit',
'uebersicht' => Konto::uebersicht(),
]);
}
}

View file

@ -85,6 +85,7 @@ class EinstellungenController extends BaseController
'allgemein' => ['mandant_name' => 'text', 'waehrung' => 'text'],
'darstellung' => ['darstellung_thema' => 'text', 'zeilen_pro_seite' => 'text'],
'buchhaltung' => ['erster_monat' => 'text'],
'import' => ['import_qif_verzeichnis' => 'text'],
'diagnose' => ['debug_log_aktiv' => 'bool'],
default => [],
};

View file

@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Services\QifParser;
use Finanzmanager\Services\QifStammdatenImport;
use Finanzmanager\View;
/**
* QIF-Import der Stammdaten (Klassen, Kategorien, Konten).
*
* Hochgeladene Dateien landen im Importverzeichnis und bleiben dort – so lässt
* sich derselbe Export erst im Trockenlauf prüfen und danach übernehmen, ohne
* ihn zweimal hochzuladen. Buchungen folgen in Phase 2.
*/
class ImportController extends BaseController
{
private const ERLAUBTE_ENDUNGEN = ['qif'];
public function index(): void
{
$this->verlangeAnmeldung();
$this->render('verwaltung/import.php', [
'titel' => 'QIF-Import',
'dateien' => $this->dateien(),
'verzeichnis' => $this->verzeichnisAnzeige(),
'ergebnis' => null,
'datei' => '',
]);
}
public function hochladen(): void
{
$this->verlangeAnmeldung();
$datei = $_FILES['qif'] ?? null;
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');
return;
}
$name = self::sichererDateiname((string)$datei['name']);
if ($name === null) {
View::redirect('/verwaltung/import', 'Nur .QIF-Dateien können hochgeladen werden.', 'fehler');
return;
}
$ziel = $this->verzeichnis() . '/' . $name;
if (!is_uploaded_file((string)$datei['tmp_name']) || !move_uploaded_file((string)$datei['tmp_name'], $ziel)) {
View::redirect('/verwaltung/import', 'Die Datei konnte nicht abgelegt werden.', 'fehler');
return;
}
View::redirect('/verwaltung/import', 'Datei „' . $name . '“ hochgeladen.');
}
public function ausfuehren(): void
{
$this->verlangeAnmeldung();
$name = self::sichererDateiname($this->post('datei'));
if ($name === null) {
View::redirect('/verwaltung/import', 'Keine Datei ausgewählt.', 'fehler');
return;
}
$pfad = $this->verzeichnis() . '/' . $name;
if (!is_file($pfad)) {
View::redirect('/verwaltung/import', 'Die Datei „' . $name . '“ gibt es nicht mehr.', 'fehler');
return;
}
$trockenlauf = $this->post('modus') !== 'import';
try {
$parser = QifParser::ausDatei($pfad);
$importer = new QifStammdatenImport($parser);
$ergebnis = $importer->ausfuehren($trockenlauf);
} catch (\Throwable $e) {
View::redirect('/verwaltung/import', 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler');
return;
}
$this->render('verwaltung/import.php', [
'titel' => 'QIF-Import',
'dateien' => $this->dateien(),
'verzeichnis' => $this->verzeichnisAnzeige(),
'ergebnis' => $ergebnis + ['abschnitte' => $parser->zusammenfassung()],
'datei' => $name,
]);
}
/** @param array<string,string> $params */
public function loeschen(array $params): void
{
$this->verlangeAnmeldung();
$name = self::sichererDateiname($params['datei'] ?? '');
if ($name !== null) {
$pfad = $this->verzeichnis() . '/' . $name;
if (is_file($pfad)) {
unlink($pfad);
}
}
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}> */
private function dateien(): array
{
$verzeichnis = $this->verzeichnis();
$dateien = [];
foreach (glob($verzeichnis . '/*.[qQ][iI][fF]') ?: [] as $pfad) {
$dateien[] = [
'name' => basename($pfad),
'groesse' => (int)filesize($pfad),
'geaendert' => (int)filemtime($pfad),
];
}
usort($dateien, static fn(array $a, array $b): int => $b['geaendert'] <=> $a['geaendert']);
return $dateien;
}
private function verzeichnis(): string
{
$relativ = (string)Einstellung::get('import_qif_verzeichnis', 'data/import');
$pfad = dirname(__DIR__, 2) . '/' . trim($relativ, '/');
if (!is_dir($pfad)) {
mkdir($pfad, 0o775, true);
}
return $pfad;
}
private function verzeichnisAnzeige(): string
{
return (string)Einstellung::get('import_qif_verzeichnis', 'data/import');
}
private function uploadFehler(int $code): string
{
return match ($code) {
UPLOAD_ERR_NO_FILE => 'Es wurde keine Datei ausgewählt.',
UPLOAD_ERR_INI_SIZE,
UPLOAD_ERR_FORM_SIZE => 'Die Datei ist zu groß.',
UPLOAD_ERR_PARTIAL => 'Die Datei wurde nur teilweise übertragen.',
default => 'Der Upload ist fehlgeschlagen (Code ' . $code . ').',
};
}
}

View file

@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\View;
/**
* Kategorieverwaltung mit den Registern des FinanzManagers: Alle, Einnahmen,
* Ausgaben, Umbuchungskonten. Umbuchungskonten sind keine eigenen Datensätze –
* dort erscheinen die Konten, weil sie in der Kategorieauswahl einer Buchung
* an derselben Stelle stehen.
*/
class KategorienController extends BaseController
{
private const REGISTER = [
'alle' => 'Alle',
'einnahmen' => 'Einnahmen',
'ausgaben' => 'Ausgaben',
'umbuchungskonten' => 'Umbuchungskonten',
];
public function index(): void
{
$this->verlangeAnmeldung();
$register = $this->query('register', 'alle');
if (!isset(self::REGISTER[$register])) {
$register = 'alle';
}
$art = match ($register) {
'einnahmen' => 'einnahme',
'ausgaben' => 'ausgabe',
default => null,
};
$this->render('verwaltung/kategorien.php', [
'titel' => 'Kategorien',
'register' => $register,
'registerListe' => self::REGISTER,
'kategorien' => $register === 'umbuchungskonten' ? [] : Kategorie::baumFlach($art),
'konten' => $register === 'umbuchungskonten' ? Konto::alle() : [],
]);
}
public function neu(): void
{
$this->verlangeAnmeldung();
$this->render('verwaltung/kategorie-formular.php', [
'titel' => 'Neue Kategorie',
'kategorie' => null,
'auswahl' => Kategorie::baumFlach(),
'elternId' => $this->query('eltern') === '' ? null : (int)$this->query('eltern'),
]);
}
public function anlegen(): void
{
$this->verlangeAnmeldung();
try {
$id = Kategorie::anlegen([
'name' => $this->post('name'),
'eltern_id' => $this->post('eltern_id'),
'art' => $this->post('art', 'ausgabe'),
'beschreibung' => $this->post('beschreibung'),
'steuerrelevant' => isset($_POST['steuerrelevant']),
'steuerformular' => $this->post('steuerformular'),
'aktiv' => true,
]);
} catch (\Throwable $e) {
View::redirect('/verwaltung/kategorien/neu', $e->getMessage(), 'fehler');
return;
}
View::redirect('/verwaltung/kategorien/' . $id, 'Kategorie angelegt.');
}
/** @param array<string,string> $params */
public function bearbeiten(array $params): void
{
$this->verlangeAnmeldung();
$kategorie = Kategorie::find((int)$params['id']);
if ($kategorie === null) {
$this->nichtGefunden('/verwaltung/kategorien/' . $params['id']);
return;
}
$this->render('verwaltung/kategorie-formular.php', [
'titel' => 'Kategorie: ' . $kategorie['pfad'],
'kategorie' => $kategorie,
'auswahl' => Kategorie::baumFlach(),
'elternId' => $kategorie['eltern_id'] === null ? null : (int)$kategorie['eltern_id'],
]);
}
/** @param array<string,string> $params */
public function speichern(array $params): void
{
$this->verlangeAnmeldung();
$id = (int)$params['id'];
try {
Kategorie::speichern($id, [
'name' => $this->post('name'),
'art' => $this->post('art', 'ausgabe'),
'beschreibung' => $this->post('beschreibung'),
'steuerrelevant' => isset($_POST['steuerrelevant']),
'steuerformular' => $this->post('steuerformular'),
'aktiv' => isset($_POST['aktiv']),
'sortierung' => (int)$this->post('sortierung', '0'),
]);
} catch (\Throwable $e) {
View::redirect('/verwaltung/kategorien/' . $id, $e->getMessage(), 'fehler');
return;
}
View::redirect('/verwaltung/kategorien', 'Kategorie gespeichert.');
}
/** @param array<string,string> $params */
public function verschieben(array $params): void
{
$this->verlangeAnmeldung();
$id = (int)$params['id'];
$eltern = $this->post('eltern_id');
try {
Kategorie::verschieben($id, $eltern === '' || $eltern === '0' ? null : (int)$eltern);
} catch (\Throwable $e) {
View::redirect('/verwaltung/kategorien/' . $id, $e->getMessage(), 'fehler');
return;
}
View::redirect('/verwaltung/kategorien/' . $id, 'Kategorie verschoben.');
}
/** @param array<string,string> $params */
public function zusammenfuehren(array $params): void
{
$this->verlangeAnmeldung();
$quelleId = (int)$params['id'];
$zielId = (int)$this->post('ziel_id');
try {
Kategorie::zusammenfuehren($quelleId, $zielId);
} catch (\Throwable $e) {
View::redirect('/verwaltung/kategorien/' . $quelleId, $e->getMessage(), 'fehler');
return;
}
View::redirect('/verwaltung/kategorien', 'Kategorien zusammengeführt.');
}
/** @param array<string,string> $params */
public function loeschen(array $params): void
{
$this->verlangeAnmeldung();
$kategorie = Kategorie::find((int)$params['id']);
if ($kategorie === null) {
$this->nichtGefunden('/verwaltung/kategorien/' . $params['id']);
return;
}
Kategorie::loeschen((int)$kategorie['id']);
View::redirect('/verwaltung/kategorien', 'Kategorie „' . $kategorie['pfad'] . '“ gelöscht.');
}
private function nichtGefunden(string $pfad): void
{
http_response_code(404);
$this->render('layout/404.php', ['pfad' => $pfad, 'titel' => 'Nicht gefunden']);
}
}

View file

@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Kontengruppe;
use Finanzmanager\View;
/**
* Kontenübersicht und Kontenpflege. Die Übersicht bildet die Gliederung des
* FinanzManagers nach: Konten nach Kontengruppen gebündelt, je Gruppe eine
* Summe, darunter die Gesamtsumme.
*/
class KontenController extends BaseController
{
public function index(): void
{
$this->verlangeAnmeldung();
$mitInaktiven = $this->query('inaktive') === '1';
$this->render('konten/uebersicht.php', [
'titel' => 'Kontenübersicht',
'uebersicht' => Konto::uebersicht(!$mitInaktiven),
'mitInaktiven' => $mitInaktiven,
]);
}
public function neu(): void
{
$this->verlangeAnmeldung();
$this->render('konten/formular.php', [
'titel' => 'Neues Konto',
'konto' => null,
'gruppen' => Kontengruppe::alle(),
]);
}
public function anlegen(): void
{
$this->verlangeAnmeldung();
try {
$id = Konto::anlegen($this->formulardaten());
} catch (\Throwable $e) {
View::redirect('/konten/neu', $e->getMessage(), 'fehler');
return;
}
View::redirect('/konten/' . $id . '/bearbeiten', 'Konto angelegt.');
}
/** @param array<string,string> $params */
public function bearbeiten(array $params): void
{
$this->verlangeAnmeldung();
$konto = Konto::find((int)$params['id']);
if ($konto === null) {
$this->nichtGefunden('/konten/' . $params['id']);
return;
}
$this->render('konten/formular.php', [
'titel' => 'Konto: ' . $konto['name'],
'konto' => $konto,
'gruppen' => Kontengruppe::alle(),
]);
}
/** @param array<string,string> $params */
public function speichern(array $params): void
{
$this->verlangeAnmeldung();
$id = (int)$params['id'];
if (Konto::find($id) === null) {
$this->nichtGefunden('/konten/' . $params['id']);
return;
}
try {
Konto::speichern($id, $this->formulardaten());
} catch (\Throwable $e) {
View::redirect('/konten/' . $id . '/bearbeiten', $e->getMessage(), 'fehler');
return;
}
View::redirect('/konten', 'Konto gespeichert.');
}
/** @param array<string,string> $params */
public function loeschen(array $params): void
{
$this->verlangeAnmeldung();
$konto = Konto::find((int)$params['id']);
if ($konto === null) {
$this->nichtGefunden('/konten/' . $params['id']);
return;
}
Konto::loeschen((int)$konto['id']);
View::redirect('/konten', 'Konto „' . $konto['name'] . '“ gelöscht.');
}
/** @return array<string,mixed> */
private function formulardaten(): array
{
return [
'name' => $this->post('name'),
'kontoart' => $this->post('kontoart', 'bank'),
'kontengruppe_id' => $this->post('kontengruppe_id'),
'bankname' => $this->post('bankname'),
'kontonummer' => $this->post('kontonummer'),
'iban' => $this->post('iban'),
'bic' => $this->post('bic'),
'beschreibung' => $this->post('beschreibung'),
'anfangssaldo' => self::zahl($this->post('anfangssaldo', '0')),
'kreditlimit' => $this->post('kreditlimit') === '' ? null : self::zahl($this->post('kreditlimit')),
'waehrung' => $this->post('waehrung', 'EUR'),
'aktiv' => isset($_POST['aktiv']),
'sortierung' => (int)$this->post('sortierung', '0'),
];
}
/**
* Beträge kommen im deutschen Format aus dem Formular ("1.234,56"). Ohne
* Komma ist der Punkt mehrdeutig, deshalb entscheidet die Stellenzahl:
* genau drei Ziffern dahinter heißt Tausendertrenner ("1.500"), sonst
* Dezimaltrenner ("1234.56"). So gehen sowohl die deutsche Schreibweise als
* auch eingefügte Werte aus Tabellenkalkulationen durch.
*/
public static function zahl(string $eingabe): float
{
$eingabe = trim(str_replace(["\u{00A0}", ' ', '€'], '', $eingabe));
if ($eingabe === '') {
return 0.0;
}
if (str_contains($eingabe, ',')) {
return (float)str_replace(',', '.', str_replace('.', '', $eingabe));
}
$punkte = substr_count($eingabe, '.');
if ($punkte === 0) {
return (float)$eingabe;
}
$letzterTeil = substr($eingabe, (int)strrpos($eingabe, '.') + 1);
if ($punkte > 1 || strlen($letzterTeil) === 3) {
return (float)str_replace('.', '', $eingabe);
}
return (float)$eingabe;
}
private function nichtGefunden(string $pfad): void
{
http_response_code(404);
$this->render('layout/404.php', ['pfad' => $pfad, 'titel' => 'Nicht gefunden']);
}
}

View file

@ -12,11 +12,6 @@ namespace Finanzmanager\Controllers;
class PlatzhalterController extends BaseController
{
private const BEREICHE = [
'konten' => [
'titel' => 'Kontenübersicht',
'phase' => 'Phase 1',
'text' => 'Konten, Kontengruppen und Salden – mit Übernahme der 37 Konten aus dem QIF-Export.',
],
'zahlungsverkehr' => [
'titel' => 'Zahlungsverkehr',
'phase' => 'Phase 7',
@ -37,11 +32,6 @@ class PlatzhalterController extends BaseController
'phase' => 'Phase 6',
'text' => 'Depotübersicht, Bestände, Kurse und Veräußerungsgeschäfte.',
],
'verwaltung' => [
'titel' => 'Verwaltung',
'phase' => 'Phase 1 und später',
'text' => 'Kategorien, Klassen, Bankverbindungen, Buchungsvorlagen, Regelverwaltung.',
],
];
public function zeige(string $bereich): void

View file

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Kontengruppe;
use Finanzmanager\View;
/**
* Einstiegsseite der Verwaltung sowie die beiden schlanken Stammdatenlisten
* Klassen und Kontengruppen – beide flach, beide mit Anlegen, Bearbeiten und
* Löschen auf einer Seite.
*/
class VerwaltungController extends BaseController
{
public function index(): void
{
$this->verlangeAnmeldung();
$this->render('verwaltung/index.php', [
'titel' => 'Verwaltung',
'zahlen' => [
'kategorien' => Kategorie::anzahl(),
'einnahmen' => Kategorie::anzahl('einnahme'),
'ausgaben' => Kategorie::anzahl('ausgabe'),
'klassen' => Klasse::anzahl(),
'konten' => Konto::anzahl(),
'kontengruppen' => count(Kontengruppe::alle()),
],
]);
}
public function klassen(): void
{
$this->verlangeAnmeldung();
$this->render('verwaltung/klassen.php', [
'titel' => 'Klassen',
'klassen' => Klasse::alle(),
]);
}
public function klasseAnlegen(): void
{
$this->verlangeAnmeldung();
try {
Klasse::anlegen($this->post('name'), $this->post('beschreibung'));
} catch (\Throwable $e) {
View::redirect('/verwaltung/klassen', $e->getMessage(), 'fehler');
return;
}
View::redirect('/verwaltung/klassen', 'Klasse angelegt.');
}
/** @param array<string,string> $params */
public function klasseSpeichern(array $params): void
{
$this->verlangeAnmeldung();
try {
Klasse::speichern(
(int)$params['id'],
$this->post('name'),
$this->post('beschreibung'),
isset($_POST['aktiv'])
);
} catch (\Throwable $e) {
View::redirect('/verwaltung/klassen', $e->getMessage(), 'fehler');
return;
}
View::redirect('/verwaltung/klassen', 'Klasse gespeichert.');
}
/** @param array<string,string> $params */
public function klasseLoeschen(array $params): void
{
$this->verlangeAnmeldung();
Klasse::loeschen((int)$params['id']);
View::redirect('/verwaltung/klassen', 'Klasse gelöscht.');
}
public function kontengruppen(): void
{
$this->verlangeAnmeldung();
$this->render('verwaltung/kontengruppen.php', [
'titel' => 'Kontengruppen',
'gruppen' => Kontengruppe::alle(),
'konten' => Konto::alle(),
]);
}
public function kontengruppeAnlegen(): void
{
$this->verlangeAnmeldung();
try {
Kontengruppe::anlegen(
$this->post('name'),
$this->post('beschreibung'),
(int)$this->post('sortierung', '0')
);
} catch (\Throwable $e) {
View::redirect('/verwaltung/kontengruppen', $e->getMessage(), 'fehler');
return;
}
View::redirect('/verwaltung/kontengruppen', 'Kontengruppe angelegt.');
}
/** @param array<string,string> $params */
public function kontengruppeSpeichern(array $params): void
{
$this->verlangeAnmeldung();
try {
Kontengruppe::speichern(
(int)$params['id'],
$this->post('name'),
$this->post('beschreibung'),
(int)$this->post('sortierung', '0')
);
} catch (\Throwable $e) {
View::redirect('/verwaltung/kontengruppen', $e->getMessage(), 'fehler');
return;
}
View::redirect('/verwaltung/kontengruppen', 'Kontengruppe gespeichert.');
}
/** @param array<string,string> $params */
public function kontengruppeLoeschen(array $params): void
{
$this->verlangeAnmeldung();
Kontengruppe::loeschen((int)$params['id']);
View::redirect(
'/verwaltung/kontengruppen',
'Kontengruppe gelöscht. Die Konten stehen jetzt unter „' . Kontengruppe::OHNE_GRUPPE . '“.'
);
}
}

55
src/Mandant.php Normal file
View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Finanzmanager;
use Finanzmanager\Models\Benutzer;
/**
* Liefert die benutzer_id, unter der Stammdaten und Buchungen geführt werden.
*
* Die Oberfläche ist einbenutzerig, das Datenmodell nicht (PLAN.md, Kapitel 3).
* Im Web kommt die Kennung aus der Session; CLI-Skripte (Import, Migration von
* Altdaten) haben keine – dort greift der erste aktive Benutzer, solange kein
* anderer über setze() vorgegeben wurde.
*/
class Mandant
{
private static ?int $festgelegt = null;
public static function id(): int
{
if (self::$festgelegt !== null) {
return self::$festgelegt;
}
$ausSession = Auth::benutzerId();
if ($ausSession !== null) {
return $ausSession;
}
$id = Database::fetchValue(
'SELECT id FROM benutzer WHERE aktiv = 1 ORDER BY id LIMIT 1'
);
if ($id === null || $id === false) {
throw new \RuntimeException(
'Kein Benutzer vorhanden. Zuerst die Ersteinrichtung durchführen.'
);
}
return (int)$id;
}
/** Für CLI-Skripte und Tests: Mandanten fest vorgeben. */
public static function setze(?int $benutzerId): void
{
self::$festgelegt = $benutzerId;
}
/** Gibt es überhaupt einen Mandanten? Ohne Ausnahme, für Prüfungen vorab. */
public static function vorhanden(): bool
{
return Benutzer::anzahl() > 0;
}
}

464
src/Models/Kategorie.php Normal file
View file

@ -0,0 +1,464 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Kategorien sind hierarchisch, Trenner ist ":" wie im FinanzManager
* ("400 - Leben:B-Essen alleine"). Der vollständige Pfad steht redundant in der
* Spalte "pfad": jede Auswahl, jeder Bericht und der gesamte QIF-Import
* arbeiten damit, und ein rekursiver Aufbau bei jedem Zugriff wäre teuer.
* Umbenennen und Verschieben ziehen die Pfade der Nachfahren mit.
*/
class Kategorie
{
public const ARTEN = ['einnahme' => 'Einnahme', 'ausgabe' => 'Ausgabe'];
public const TRENNER = ':';
/** @return list<array<string,mixed>> */
public static function alle(?string $art = null, bool $nurAktive = false): array
{
$sql = 'SELECT * FROM kategorien WHERE benutzer_id = ?';
$params = [Mandant::id()];
if ($art !== null) {
$sql .= ' AND art = ?';
$params[] = $art;
}
if ($nurAktive) {
$sql .= ' AND aktiv = 1';
}
return Database::fetchAll($sql . ' ORDER BY pfad', $params);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM kategorien WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
public static function findeNachPfad(string $pfad): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM kategorien WHERE pfad = ? AND benutzer_id = ?',
[self::normalisierePfad($pfad), Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/**
* Baut den Kategoriebaum als verschachtelte Liste; jeder Knoten führt
* 'kinder' und 'ebene'.
*
* @return list<array<string,mixed>>
*/
public static function baum(?string $art = null, bool $nurAktive = false): array
{
$flach = self::alle($art, $nurAktive);
$knoten = [];
foreach ($flach as $zeile) {
$zeile['kinder'] = [];
$knoten[(int)$zeile['id']] = $zeile;
}
$wurzeln = [];
foreach ($knoten as $id => $zeile) {
$eltern = $zeile['eltern_id'] === null ? null : (int)$zeile['eltern_id'];
// Beim Filtern nach Art oder aktiv kann der Elternknoten fehlen –
// die Kategorie hängt dann als Wurzel in der Liste, statt zu
// verschwinden.
if ($eltern !== null && isset($knoten[$eltern])) {
continue;
}
$wurzeln[] = $id;
}
$baueTeilbaum = static function (int $id, int $ebene) use (&$baueTeilbaum, &$knoten): array {
$zeile = $knoten[$id];
$zeile['ebene'] = $ebene;
foreach ($knoten as $kindId => $kind) {
if ($kind['eltern_id'] !== null && (int)$kind['eltern_id'] === $id) {
$zeile['kinder'][] = $baueTeilbaum($kindId, $ebene + 1);
}
}
return $zeile;
};
return array_map(static fn(int $id) => $baueTeilbaum($id, 0), $wurzeln);
}
/**
* Flache Liste in Baumreihenfolge, jeder Eintrag mit 'ebene' – für
* Auswahllisten und die Tabellendarstellung des Baums.
*
* @return list<array<string,mixed>>
*/
public static function baumFlach(?string $art = null, bool $nurAktive = false): array
{
$ergebnis = [];
$durchlaufe = static function (array $knoten) use (&$durchlaufe, &$ergebnis): void {
foreach ($knoten as $zeile) {
$kinder = $zeile['kinder'];
unset($zeile['kinder']);
$zeile['hat_kinder'] = $kinder !== [];
$ergebnis[] = $zeile;
$durchlaufe($kinder);
}
};
$durchlaufe(self::baum($art, $nurAktive));
return $ergebnis;
}
/**
* @param array<string,mixed> $daten
*/
public static function anlegen(array $daten): int
{
$name = self::pruefeName((string)($daten['name'] ?? ''));
$elternId = self::elternIdAus($daten);
$eltern = $elternId === null ? null : self::find($elternId);
if ($elternId !== null && $eltern === null) {
throw new \RuntimeException('Die übergeordnete Kategorie gibt es nicht.');
}
// Die Art gilt für den ganzen Ast: eine Unterkategorie von „Einnahme“
// kann keine Ausgabe sein.
$art = $eltern !== null ? (string)$eltern['art'] : self::pruefeArt((string)($daten['art'] ?? 'ausgabe'));
$pfad = $eltern !== null ? $eltern['pfad'] . self::TRENNER . $name : $name;
if (self::findeNachPfad($pfad) !== null) {
throw new \RuntimeException('Die Kategorie „' . $pfad . '“ gibt es bereits.');
}
Database::execute(
'INSERT INTO kategorien
(benutzer_id, eltern_id, name, pfad, art, steuerrelevant, steuerformular,
beschreibung, aktiv, sortierung)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $elternId, $name, $pfad, $art,
!empty($daten['steuerrelevant']) ? 1 : 0,
trim((string)($daten['steuerformular'] ?? '')),
trim((string)($daten['beschreibung'] ?? '')),
array_key_exists('aktiv', $daten) && empty($daten['aktiv']) ? 0 : 1,
(int)($daten['sortierung'] ?? 0),
]
);
return Database::lastInsertId();
}
/**
* Legt den kompletten Pfad an, soweit er fehlt, und liefert die Kennung der
* letzten Stufe. Der QIF-Import braucht das: dort tauchen Unterkategorien
* auch ohne vorher genannte Elternkategorie auf.
*
* @param array<string,mixed> $daten Zusatzfelder nur für die letzte Stufe.
*/
public static function findenOderAnlegenNachPfad(string $pfad, string $art = 'ausgabe', array $daten = []): int
{
$pfad = self::normalisierePfad($pfad);
if ($pfad === '') {
throw new \InvalidArgumentException('Der Kategoriepfad ist leer.');
}
$vorhanden = self::findeNachPfad($pfad);
if ($vorhanden !== null) {
return (int)$vorhanden['id'];
}
$teile = explode(self::TRENNER, $pfad);
$elternId = null;
$bisher = '';
foreach ($teile as $index => $teil) {
$bisher = $bisher === '' ? $teil : $bisher . self::TRENNER . $teil;
$stufe = self::findeNachPfad($bisher);
if ($stufe !== null) {
$elternId = (int)$stufe['id'];
continue;
}
$letzte = $index === count($teile) - 1;
$elternId = self::anlegen(array_merge(
$letzte ? $daten : [],
['name' => $teil, 'eltern_id' => $elternId, 'art' => $art]
));
}
return (int)$elternId;
}
/**
* @param array<string,mixed> $daten
*/
public static function speichern(int $id, array $daten): void
{
$kategorie = self::find($id);
if ($kategorie === null) {
throw new \RuntimeException('Die Kategorie gibt es nicht.');
}
$name = self::pruefeName((string)($daten['name'] ?? ''));
$art = self::pruefeArt((string)($daten['art'] ?? $kategorie['art']));
Database::transaktion(static function () use ($id, $kategorie, $name, $art, $daten): void {
Database::execute(
'UPDATE kategorien SET name = ?, steuerrelevant = ?, steuerformular = ?,
beschreibung = ?, aktiv = ?, sortierung = ?
WHERE id = ? AND benutzer_id = ?',
[
$name,
!empty($daten['steuerrelevant']) ? 1 : 0,
trim((string)($daten['steuerformular'] ?? '')),
trim((string)($daten['beschreibung'] ?? '')),
empty($daten['aktiv']) ? 0 : 1,
(int)($daten['sortierung'] ?? $kategorie['sortierung']),
$id, Mandant::id(),
]
);
// Die Art gilt für den ganzen Ast – Unterkategorien ziehen mit.
if ($kategorie['eltern_id'] === null && $art !== $kategorie['art']) {
self::setzeArtRekursiv($id, $art);
}
if ($name !== $kategorie['name']) {
self::pfadeNeuAufbauen($id);
}
});
}
/**
* Hängt eine Kategorie unter eine andere (oder auf die oberste Ebene).
* Der eigene Teilbaum darf nicht Ziel sein, sonst hinge der Ast in der Luft.
*/
public static function verschieben(int $id, ?int $neuesElternId): void
{
$kategorie = self::find($id);
if ($kategorie === null) {
throw new \RuntimeException('Die Kategorie gibt es nicht.');
}
if ($neuesElternId === $id) {
throw new \RuntimeException('Eine Kategorie kann sich nicht selbst untergeordnet werden.');
}
$eltern = null;
if ($neuesElternId !== null) {
$eltern = self::find($neuesElternId);
if ($eltern === null) {
throw new \RuntimeException('Die übergeordnete Kategorie gibt es nicht.');
}
if (self::istNachfahre($neuesElternId, $id)) {
throw new \RuntimeException(
'Die Kategorie kann nicht unter eine ihrer eigenen Unterkategorien verschoben werden.'
);
}
}
$neuerPfad = $eltern === null
? $kategorie['name']
: $eltern['pfad'] . self::TRENNER . $kategorie['name'];
$belegt = self::findeNachPfad($neuerPfad);
if ($belegt !== null && (int)$belegt['id'] !== $id) {
throw new \RuntimeException('Am Zielort gibt es bereits eine Kategorie „' . $kategorie['name'] . '“.');
}
Database::transaktion(static function () use ($id, $neuesElternId, $eltern): void {
Database::execute(
'UPDATE kategorien SET eltern_id = ? WHERE id = ? AND benutzer_id = ?',
[$neuesElternId, $id, Mandant::id()]
);
if ($eltern !== null) {
self::setzeArtRekursiv($id, (string)$eltern['art']);
}
self::pfadeNeuAufbauen($id);
});
}
/**
* Führt zwei Kategorien zusammen: die Unterkategorien der Quelle hängen sich
* unter das Ziel, danach verschwindet die Quelle.
*
* Ab Phase 2 werden hier zusätzlich die Buchungen und Splitzeilen der Quelle
* auf das Ziel umgeschrieben – solange es keine Buchungstabellen gibt, ist
* das Umhängen des Teilbaums die vollständige Operation.
*/
public static function zusammenfuehren(int $quelleId, int $zielId): void
{
if ($quelleId === $zielId) {
throw new \RuntimeException('Quelle und Ziel sind dieselbe Kategorie.');
}
$quelle = self::find($quelleId);
$ziel = self::find($zielId);
if ($quelle === null || $ziel === null) {
throw new \RuntimeException('Quelle oder Ziel gibt es nicht.');
}
if (self::istNachfahre($zielId, $quelleId)) {
throw new \RuntimeException('Das Ziel liegt innerhalb der Quelle.');
}
Database::transaktion(static function () use ($quelleId, $zielId): void {
foreach (self::kinder($quelleId) as $kind) {
self::verschieben((int)$kind['id'], $zielId);
}
self::loeschen($quelleId);
});
}
/** @return list<array<string,mixed>> */
public static function kinder(int $id): array
{
return Database::fetchAll(
'SELECT * FROM kategorien WHERE eltern_id = ? AND benutzer_id = ? ORDER BY sortierung, name',
[$id, Mandant::id()]
);
}
/**
* Löscht die Kategorie. Unterkategorien hängen per ON DELETE CASCADE mit
* daran – deshalb verlangt der Controller vorher eine Bestätigung.
*/
public static function loeschen(int $id): void
{
Database::execute('DELETE FROM kategorien WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
}
public static function anzahl(?string $art = null): int
{
$sql = 'SELECT COUNT(*) FROM kategorien WHERE benutzer_id = ?';
$params = [Mandant::id()];
if ($art !== null) {
$sql .= ' AND art = ?';
$params[] = $art;
}
return (int)Database::fetchValue($sql, $params, 0);
}
/**
* Zerlegt die QIF-Zeile "L400 - Leben:B-Essen alleine/Privat" in Kategorie
* und Klasse. Umbuchungen ("[Konto]") behandelt der Importeur gesondert;
* hier bleibt die Klammer stehen, damit er sie erkennt.
*
* @return array{kategorie: string, klasse: string}
*/
public static function zerlegeQifAngabe(string $angabe): array
{
$angabe = trim($angabe);
$pos = strrpos($angabe, '/');
if ($pos === false) {
return ['kategorie' => $angabe, 'klasse' => ''];
}
return [
'kategorie' => trim(substr($angabe, 0, $pos)),
'klasse' => trim(substr($angabe, $pos + 1)),
];
}
public static function normalisierePfad(string $pfad): string
{
$teile = array_map('trim', explode(self::TRENNER, trim($pfad)));
$teile = array_values(array_filter($teile, static fn(string $t): bool => $t !== ''));
return implode(self::TRENNER, $teile);
}
private static function pruefeName(string $name): string
{
$name = trim($name);
if ($name === '') {
throw new \InvalidArgumentException('Die Kategorie braucht einen Namen.');
}
if (str_contains($name, self::TRENNER)) {
throw new \InvalidArgumentException(
'Der Name darf keinen Doppelpunkt enthalten – er trennt die Ebenen.'
);
}
return $name;
}
private static function pruefeArt(string $art): string
{
if (!isset(self::ARTEN[$art])) {
throw new \InvalidArgumentException('Unbekannte Kategorieart: ' . $art);
}
return $art;
}
/** @param array<string,mixed> $daten */
private static function elternIdAus(array $daten): ?int
{
$wert = $daten['eltern_id'] ?? null;
if ($wert === null || $wert === '' || (int)$wert === 0) {
return null;
}
return (int)$wert;
}
/** Läuft der Teilbaum von $wurzelId über $id? */
private static function istNachfahre(int $id, int $wurzelId): bool
{
$aktuell = self::find($id);
while ($aktuell !== null && $aktuell['eltern_id'] !== null) {
if ((int)$aktuell['eltern_id'] === $wurzelId) {
return true;
}
$aktuell = self::find((int)$aktuell['eltern_id']);
}
return false;
}
private static function setzeArtRekursiv(int $id, string $art): void
{
Database::execute(
'UPDATE kategorien SET art = ? WHERE id = ? AND benutzer_id = ?',
[$art, $id, Mandant::id()]
);
foreach (self::kinder($id) as $kind) {
self::setzeArtRekursiv((int)$kind['id'], $art);
}
}
/** Schreibt pfad für die Kategorie und alle darunter neu. */
private static function pfadeNeuAufbauen(int $id): void
{
$kategorie = self::find($id);
if ($kategorie === null) {
return;
}
$pfad = $kategorie['name'];
if ($kategorie['eltern_id'] !== null) {
$eltern = self::find((int)$kategorie['eltern_id']);
if ($eltern !== null) {
$pfad = $eltern['pfad'] . self::TRENNER . $kategorie['name'];
}
}
Database::execute(
'UPDATE kategorien SET pfad = ? WHERE id = ? AND benutzer_id = ?',
[$pfad, $id, Mandant::id()]
);
foreach (self::kinder($id) as $kind) {
self::pfadeNeuAufbauen((int)$kind['id']);
}
}
}

107
src/Models/Klasse.php Normal file
View file

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Klassen sind die zweite Auswertungsdimension neben der Kategorie – flach,
* ohne Hierarchie. Im QIF hängen sie mit "/" an der Kategorie
* ("400 - Leben:B-Essen alleine/Privat").
*/
class Klasse
{
/** @return list<array<string,mixed>> */
public static function alle(bool $nurAktive = false): array
{
$sql = 'SELECT * FROM klassen WHERE benutzer_id = ?';
if ($nurAktive) {
$sql .= ' AND aktiv = 1';
}
return Database::fetchAll($sql . ' ORDER BY name', [Mandant::id()]);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM klassen WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
public static function findeNachName(string $name): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM klassen WHERE name = ? AND benutzer_id = ?',
[trim($name), Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
public static function anlegen(string $name, string $beschreibung = ''): int
{
$name = trim($name);
if ($name === '') {
throw new \InvalidArgumentException('Die Klasse braucht einen Namen.');
}
if (self::findeNachName($name) !== null) {
throw new \RuntimeException('Die Klasse „' . $name . '“ gibt es bereits.');
}
Database::execute(
'INSERT INTO klassen (benutzer_id, name, beschreibung) VALUES (?, ?, ?)',
[Mandant::id(), $name, $beschreibung]
);
return Database::lastInsertId();
}
/**
* Legt die Klasse an, falls es sie noch nicht gibt, und liefert in jedem
* Fall ihre Kennung. Der QIF-Import läuft ausschließlich hierüber, damit ein
* zweiter Importlauf keine Dubletten erzeugt.
*/
public static function findenOderAnlegen(string $name, string $beschreibung = ''): int
{
$vorhanden = self::findeNachName($name);
return $vorhanden !== null ? (int)$vorhanden['id'] : self::anlegen($name, $beschreibung);
}
public static function speichern(int $id, string $name, string $beschreibung, bool $aktiv): void
{
$name = trim($name);
if ($name === '') {
throw new \InvalidArgumentException('Die Klasse braucht einen Namen.');
}
$andere = self::findeNachName($name);
if ($andere !== null && (int)$andere['id'] !== $id) {
throw new \RuntimeException('Die Klasse „' . $name . '“ gibt es bereits.');
}
Database::execute(
'UPDATE klassen SET name = ?, beschreibung = ?, aktiv = ? WHERE id = ? AND benutzer_id = ?',
[$name, $beschreibung, $aktiv ? 1 : 0, $id, Mandant::id()]
);
}
public static function loeschen(int $id): void
{
Database::execute(
'DELETE FROM klassen WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
}
public static function anzahl(): int
{
return (int)Database::fetchValue(
'SELECT COUNT(*) FROM klassen WHERE benutzer_id = ?',
[Mandant::id()],
0
);
}
}

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Kontengruppen bündeln Konten in der Kontenübersicht („Anlage“, „Girokonten“ …).
* Konten ohne Gruppe erscheinen unter „Nicht gruppiert“ – das ist keine echte
* Gruppe, sondern kontengruppe_id IS NULL.
*/
class Kontengruppe
{
public const OHNE_GRUPPE = 'Nicht gruppiert';
/** @return list<array<string,mixed>> */
public static function alle(): array
{
return Database::fetchAll(
'SELECT * FROM kontengruppen WHERE benutzer_id = ? ORDER BY sortierung, name',
[Mandant::id()]
);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM kontengruppen WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
public static function findeNachName(string $name): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM kontengruppen WHERE name = ? AND benutzer_id = ?',
[trim($name), Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
public static function anlegen(string $name, string $beschreibung = '', int $sortierung = 0): int
{
$name = trim($name);
if ($name === '') {
throw new \InvalidArgumentException('Die Kontengruppe braucht einen Namen.');
}
if (self::findeNachName($name) !== null) {
throw new \RuntimeException('Die Kontengruppe „' . $name . '“ gibt es bereits.');
}
Database::execute(
'INSERT INTO kontengruppen (benutzer_id, name, beschreibung, sortierung) VALUES (?, ?, ?, ?)',
[Mandant::id(), $name, $beschreibung, $sortierung]
);
return Database::lastInsertId();
}
public static function findenOderAnlegen(string $name): int
{
$vorhanden = self::findeNachName($name);
return $vorhanden !== null ? (int)$vorhanden['id'] : self::anlegen($name);
}
public static function speichern(int $id, string $name, string $beschreibung, int $sortierung): void
{
$name = trim($name);
if ($name === '') {
throw new \InvalidArgumentException('Die Kontengruppe braucht einen Namen.');
}
$andere = self::findeNachName($name);
if ($andere !== null && (int)$andere['id'] !== $id) {
throw new \RuntimeException('Die Kontengruppe „' . $name . '“ gibt es bereits.');
}
Database::execute(
'UPDATE kontengruppen SET name = ?, beschreibung = ?, sortierung = ? WHERE id = ? AND benutzer_id = ?',
[$name, $beschreibung, $sortierung, $id, Mandant::id()]
);
}
/** Die zugeordneten Konten rutschen per ON DELETE SET NULL zu „Nicht gruppiert“. */
public static function loeschen(int $id): void
{
Database::execute(
'DELETE FROM kontengruppen WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
}
}

250
src/Models/Konto.php Normal file
View file

@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
class Konto
{
/** Kontoarten des FinanzManagers, Schlüssel → Bezeichnung in der Oberfläche. */
public const ARTEN = [
'bank' => 'Bank',
'kreditkarte' => 'Kreditkarte',
'bar' => 'Bar',
'sparkonto' => 'Tagesgeld/Sparkonto',
'vermoegen' => 'Vermögen',
'verbindlichkeit' => 'Verbindlichkeit',
'rente' => 'Rentenversicherung',
'sonstiges' => 'Sonstiges Vermögen',
'depot' => 'Depot',
];
/**
* QIF-Kontotyp → Kontoart. "Oth E" und "Oth F" fasst der FinanzManager beim
* Export zu sonstigem Vermögen zusammen, "Port" und "Invst" beide zum Depot.
*/
public const QIF_ARTEN = [
'Bank' => 'bank',
'CCard' => 'kreditkarte',
'Cash' => 'bar',
'Oth T' => 'sparkonto',
'Oth A' => 'vermoegen',
'Oth L' => 'verbindlichkeit',
'Oth P' => 'rente',
'Oth E' => 'sonstiges',
'Oth F' => 'sonstiges',
'Port' => 'depot',
'Invst' => 'depot',
];
/** Konten, deren Saldo üblicherweise eine Schuld ausweist. */
public const SCHULDARTEN = ['kreditkarte', 'verbindlichkeit'];
public static function artAusQif(string $qifTyp): string
{
$typ = trim($qifTyp);
return self::QIF_ARTEN[$typ] ?? 'sonstiges';
}
public static function bezeichnungDerArt(string $art): string
{
return self::ARTEN[$art] ?? $art;
}
/** @return list<array<string,mixed>> */
public static function alle(bool $nurAktive = false): array
{
$sql = 'SELECT k.*, g.name AS gruppenname
FROM konten k
LEFT JOIN kontengruppen g ON g.id = k.kontengruppe_id
WHERE k.benutzer_id = ?';
if ($nurAktive) {
$sql .= ' AND k.aktiv = 1';
}
return Database::fetchAll(
$sql . ' ORDER BY g.sortierung IS NULL, g.sortierung, g.name, k.sortierung, k.name',
[Mandant::id()]
);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM konten WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
public static function findeNachName(string $name): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM konten WHERE name = ? AND benutzer_id = ?',
[trim($name), Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/**
* @param array<string,mixed> $daten
*/
public static function anlegen(array $daten): int
{
$name = trim((string)($daten['name'] ?? ''));
if ($name === '') {
throw new \InvalidArgumentException('Das Konto braucht einen Namen.');
}
if (self::findeNachName($name) !== null) {
throw new \RuntimeException('Das Konto „' . $name . '“ gibt es bereits.');
}
$felder = self::normalisiere($daten);
Database::execute(
'INSERT INTO konten
(benutzer_id, kontengruppe_id, name, kontoart, bankname, kontonummer, iban, bic,
beschreibung, anfangssaldo, kreditlimit, waehrung, aktiv, sortierung)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $felder['kontengruppe_id'], $name, $felder['kontoart'],
$felder['bankname'], $felder['kontonummer'], $felder['iban'], $felder['bic'],
$felder['beschreibung'], $felder['anfangssaldo'], $felder['kreditlimit'],
$felder['waehrung'], $felder['aktiv'], $felder['sortierung'],
]
);
return Database::lastInsertId();
}
/**
* @param array<string,mixed> $daten
*/
public static function speichern(int $id, array $daten): void
{
$name = trim((string)($daten['name'] ?? ''));
if ($name === '') {
throw new \InvalidArgumentException('Das Konto braucht einen Namen.');
}
$andere = self::findeNachName($name);
if ($andere !== null && (int)$andere['id'] !== $id) {
throw new \RuntimeException('Das Konto „' . $name . '“ gibt es bereits.');
}
$felder = self::normalisiere($daten);
Database::execute(
'UPDATE konten SET kontengruppe_id = ?, name = ?, kontoart = ?, bankname = ?,
kontonummer = ?, iban = ?, bic = ?, beschreibung = ?, anfangssaldo = ?,
kreditlimit = ?, waehrung = ?, aktiv = ?, sortierung = ?
WHERE id = ? AND benutzer_id = ?',
[
$felder['kontengruppe_id'], $name, $felder['kontoart'], $felder['bankname'],
$felder['kontonummer'], $felder['iban'], $felder['bic'], $felder['beschreibung'],
$felder['anfangssaldo'], $felder['kreditlimit'], $felder['waehrung'],
$felder['aktiv'], $felder['sortierung'], $id, Mandant::id(),
]
);
}
public static function loeschen(int $id): void
{
Database::execute('DELETE FROM konten WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
}
public static function anzahl(): int
{
return (int)Database::fetchValue(
'SELECT COUNT(*) FROM konten WHERE benutzer_id = ?',
[Mandant::id()],
0
);
}
/**
* Kontenübersicht: Konten nach Kontengruppe gebündelt, je Gruppe eine Summe
* und darunter die Gesamtsumme.
*
* Die drei Saldospalten des FinanzManagers stehen schon jetzt in den Daten:
* Online-Saldo (Stand des letzten Bankabrufs, Phase 5), aktueller Saldo
* (gebuchte Umsätze bis heute) und erwarteter Saldo (einschließlich künftig
* datierter Buchungen). Solange es keine Buchungen gibt, entsprechen alle
* drei dem Anfangssaldo.
*
* @return array{gruppen: list<array<string,mixed>>, summe: float}
*/
public static function uebersicht(bool $nurAktive = true): array
{
$gruppen = [];
$summe = 0.0;
foreach (self::alle($nurAktive) as $konto) {
$konto = self::mitSalden($konto);
$name = $konto['gruppenname'] ?? null;
$name = ($name === null || $name === '') ? Kontengruppe::OHNE_GRUPPE : $name;
if (!isset($gruppen[$name])) {
$gruppen[$name] = ['name' => $name, 'konten' => [], 'summe' => 0.0];
}
$gruppen[$name]['konten'][] = $konto;
$gruppen[$name]['summe'] += $konto['saldo_erwartet'];
$summe += $konto['saldo_erwartet'];
}
return ['gruppen' => array_values($gruppen), 'summe' => $summe];
}
/**
* Ergänzt die Saldospalten. Ab Phase 2 kommen hier die Buchungen dazu –
* die Aufrufer bleiben davon unberührt.
*
* @param array<string,mixed> $konto
* @return array<string,mixed>
*/
public static function mitSalden(array $konto): array
{
$anfang = (float)$konto['anfangssaldo'];
$konto['saldo_online'] = null;
$konto['saldo_aktuell'] = $anfang;
$konto['saldo_erwartet'] = $anfang;
return $konto;
}
/**
* @param array<string,mixed> $daten
* @return array<string,mixed>
*/
private static function normalisiere(array $daten): array
{
$art = (string)($daten['kontoart'] ?? 'bank');
if (!isset(self::ARTEN[$art])) {
throw new \InvalidArgumentException('Unbekannte Kontoart: ' . $art);
}
$gruppe = $daten['kontengruppe_id'] ?? null;
$gruppe = ($gruppe === null || $gruppe === '' || (int)$gruppe === 0) ? null : (int)$gruppe;
$limit = $daten['kreditlimit'] ?? null;
$limit = ($limit === null || $limit === '') ? null : (float)$limit;
return [
'kontengruppe_id' => $gruppe,
'kontoart' => $art,
'bankname' => trim((string)($daten['bankname'] ?? '')),
'kontonummer' => trim((string)($daten['kontonummer'] ?? '')),
'iban' => strtoupper(str_replace(' ', '', (string)($daten['iban'] ?? ''))),
'bic' => strtoupper(trim((string)($daten['bic'] ?? ''))),
'beschreibung' => trim((string)($daten['beschreibung'] ?? '')),
'anfangssaldo' => (float)($daten['anfangssaldo'] ?? 0),
'kreditlimit' => $limit,
'waehrung' => strtoupper(trim((string)($daten['waehrung'] ?? 'EUR'))) ?: 'EUR',
'aktiv' => !empty($daten['aktiv']) ? 1 : 0,
'sortierung' => (int)($daten['sortierung'] ?? 0),
];
}
}

280
src/Services/QifParser.php Normal file
View file

@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
/**
* Parser für das erweiterte QIF des Lexware FinanzManagers.
*
* Die Datei ist zustandsbehaftet und muss in der vorliegenden Reihenfolge
* gelesen werden – der Kontobezug einer Buchung steckt allein darin, welcher
* "!Account"-Abschnitt zuletzt kam. Deshalb liefert der Parser eine flache
* Liste von Einträgen, jeweils mit Sektionstyp und aktuellem Konto, statt die
* Datei nach Typen zu gruppieren.
*
* Aufbau:
* !Option:MDY Datumsformat für die gesamte Datei
* !Type:Class Klassen
* !Type:Cat Kategorien
* !Option:AutoSwitch Beginn der Kontenliste
* !Account ein Konto je Eintrag
* !Clear:AutoSwitch Ende der Kontenliste
* !Account / !Type:… ab hier abwechselnd Konto und dessen Buchungen
*
* Ein Eintrag endet mit "^". Jede Zeile beginnt mit einem Feldbuchstaben,
* der Rest ist der Wert; Felder dürfen mehrfach vorkommen (Splitzeilen,
* Adresszeilen), deshalb wird jeder Eintrag als Liste von Paaren gehalten.
*/
class QifParser
{
/** Feldbuchstabe → Wert(e). */
public const TYP_KLASSE = 'Class';
public const TYP_KATEGORIE = 'Cat';
public const TYP_KONTO = 'Account';
/** Sektionen, die Buchungen enthalten. */
public const BUCHUNGSTYPEN = ['Bank', 'CCard', 'Cash', 'Oth A', 'Oth L', 'Oth T', 'Oth P',
'Oth E', 'Oth F', 'Invst', 'Port', 'Memorized'];
private string $datumsformat = 'MDY';
/** @var list<array{typ: string, konto: ?string, zeilen: list<array{0: string, 1: string}>}> */
private array $eintraege = [];
public function __construct(string $inhalt)
{
$this->lies($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);
}
/**
* @return list<array{typ: string, konto: ?string, zeilen: list<array{0: string, 1: string}>}>
*/
public function eintraege(?string $typ = null): array
{
if ($typ === null) {
return $this->eintraege;
}
return array_values(array_filter(
$this->eintraege,
static fn(array $e): bool => $e['typ'] === $typ
));
}
public function datumsformat(): string
{
return $this->datumsformat;
}
/** Wie viele Einträge je Sektionstyp? Grundlage der Import-Vorschau. */
public function zusammenfassung(): array
{
$zaehler = [];
foreach ($this->eintraege as $eintrag) {
$zaehler[$eintrag['typ']] = ($zaehler[$eintrag['typ']] ?? 0) + 1;
}
return $zaehler;
}
/**
* Erster Wert eines Feldes, oder null.
*
* @param array{zeilen: list<array{0: string, 1: string}>} $eintrag
*/
public static function feld(array $eintrag, string $code): ?string
{
foreach ($eintrag['zeilen'] as [$c, $wert]) {
if ($c === $code) {
return $wert;
}
}
return null;
}
/**
* Alle Werte eines Feldes – Splitzeilen und Adressen kommen mehrfach vor.
*
* @param array{zeilen: list<array{0: string, 1: string}>} $eintrag
* @return list<string>
*/
public static function felder(array $eintrag, string $code): array
{
$werte = [];
foreach ($eintrag['zeilen'] as [$c, $wert]) {
if ($c === $code) {
$werte[] = $wert;
}
}
return $werte;
}
/** Ist das Feld vorhanden? Bei Kategorien sind "I" und "E" reine Marker ohne Wert. */
public static function hatFeld(array $eintrag, string $code): bool
{
foreach ($eintrag['zeilen'] as [$c, $_]) {
if ($c === $code) {
return true;
}
}
return false;
}
/**
* Beträge stehen im US-Format: "-1,241.04". Das Komma ist Tausendertrenner,
* der Punkt trennt die Nachkommastellen.
*/
public static function betrag(?string $wert): float
{
if ($wert === null) {
return 0.0;
}
$bereinigt = str_replace([',', ' ', "\u{00A0}"], '', trim($wert));
return $bereinigt === '' ? 0.0 : (float)$bereinigt;
}
/**
* Datum nach ISO. Der Export nennt das Format im Kopf ("!Option:MDY"), die
* Jahreszahl ist zweistellig: "D3.18.26" ist der 18.03.2026. Zweistellige
* Jahre ab 70 gelten als 19xx – Quicken-Konvention, und private Buchungen
* aus den Sechzigern gibt es nicht.
*/
public static function datum(?string $wert, string $format = 'MDY'): ?string
{
if ($wert === null) {
return null;
}
$teile = preg_split('/[.\/\-]/', trim($wert));
if ($teile === false || count($teile) !== 3) {
return null;
}
[$a, $b, $c] = array_map(static fn(string $t): int => (int)preg_replace('/\D/', '', $t), $teile);
if ($format === 'DMY') {
[$tag, $monat, $jahr] = [$a, $b, $c];
} else {
[$monat, $tag, $jahr] = [$a, $b, $c];
}
if ($jahr < 100) {
$jahr += $jahr >= 70 ? 1900 : 2000;
}
if (!checkdate($monat, $tag, $jahr)) {
return null;
}
return sprintf('%04d-%02d-%02d', $jahr, $monat, $tag);
}
private function lies(string $inhalt): void
{
$inhalt = self::nachUtf8($inhalt);
$zeilen = explode("\n", str_replace(["\r\n", "\r"], "\n", $inhalt));
$typ = '';
$aktuellesKonto = null;
$zeilenPuffer = [];
foreach ($zeilen as $zeile) {
$zeile = rtrim($zeile);
if ($zeile === '') {
continue;
}
if ($zeile[0] === '!') {
$neuerTyp = $this->steuerzeile($zeile);
if ($neuerTyp !== null) {
$typ = $neuerTyp;
}
continue;
}
if ($zeile[0] === '^') {
if ($zeilenPuffer !== []) {
// Jeder Kontoeintrag setzt zugleich das aktuelle Konto. In
// der AutoSwitch-Liste folgen mehrere aufeinander, ohne dass
// Buchungen dazwischenstehen – das schadet nicht, deckt aber
// die zweite Schreibweise mit ab, bei der ein einzelner
// "!Account" den Kontowechsel bedeutet.
if ($typ === self::TYP_KONTO) {
$aktuellesKonto = self::feld(['zeilen' => $zeilenPuffer], 'N');
}
$this->eintraege[] = [
'typ' => $typ,
'konto' => $typ === self::TYP_KONTO ? null : $aktuellesKonto,
'zeilen' => $zeilenPuffer,
];
$zeilenPuffer = [];
}
continue;
}
$zeilenPuffer[] = [$zeile[0], substr($zeile, 1)];
}
// Ein fehlendes abschließendes "^" darf den letzten Eintrag nicht schlucken.
if ($zeilenPuffer !== []) {
$this->eintraege[] = [
'typ' => $typ,
'konto' => $typ === self::TYP_KONTO ? null : $aktuellesKonto,
'zeilen' => $zeilenPuffer,
];
}
}
/** Wertet eine "!"-Zeile aus und liefert den neuen Sektionstyp, falls einer folgt. */
private function steuerzeile(string $zeile): ?string
{
$zeile = trim($zeile);
if (str_starts_with($zeile, '!Option:')) {
$option = trim(substr($zeile, 8));
if (in_array($option, ['MDY', 'DMY', 'YMD'], true)) {
$this->datumsformat = $option;
}
return null; // AutoSwitch ändert den Sektionstyp nicht.
}
if (str_starts_with($zeile, '!Clear:')) {
return null;
}
if (str_starts_with($zeile, '!Type:')) {
return trim(substr($zeile, 6));
}
if (str_starts_with($zeile, '!Account')) {
return self::TYP_KONTO;
}
return null;
}
/**
* Der Export ist ISO-8859-1. Läge schon UTF-8 vor (etwa weil die Datei
* unterwegs konvertiert wurde), würde eine zweite Konvertierung die Umlaute
* zerstören – deshalb erst prüfen.
*/
public static function nachUtf8(string $inhalt): string
{
if (mb_check_encoding($inhalt, 'UTF-8')) {
return $inhalt;
}
return mb_convert_encoding($inhalt, 'UTF-8', 'ISO-8859-1');
}
}

View file

@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
/**
* Übernimmt Klassen, Kategorien und Konten aus einem QIF-Export.
*
* Buchungen bleiben Phase 2 vorbehalten; dieser Importeur ist bewusst
* idempotent: was es schon gibt, wird gezählt und übersprungen, nie doppelt
* angelegt. Ein zweiter Lauf derselben Datei ändert also nichts.
*/
class QifStammdatenImport
{
/** Beschreibungen der Form "Kontonr. 1234567" tragen die Kontonummer. */
private const KONTONUMMER_MUSTER = '/^Kontonr\.?\s*(\S+)$/u';
private QifParser $parser;
/** @var list<array{typ: string, text: string}> */
private array $meldungen = [];
public function __construct(QifParser $parser)
{
$this->parser = $parser;
}
public static function ausDatei(string $pfad): self
{
return new self(QifParser::ausDatei($pfad));
}
/**
* @return array{klassen: array<string,int>, kategorien: array<string,int>,
* konten: array<string,int>, meldungen: list<array{typ: string, text: string}>,
* trockenlauf: bool}
*/
public function ausfuehren(bool $trockenlauf = false): array
{
$this->meldungen = [];
$lauf = function () use ($trockenlauf): array {
return [
'klassen' => $this->klassen($trockenlauf),
'kategorien' => $this->kategorien($trockenlauf),
'konten' => $this->konten($trockenlauf),
'trockenlauf' => $trockenlauf,
];
};
// Beim echten Lauf gilt alles oder nichts – ein Abbruch mitten in der
// Kategorieliste hinterließe sonst einen halben Baum.
$ergebnis = $trockenlauf ? $lauf() : Database::transaktion($lauf);
$ergebnis['meldungen'] = $this->meldungen;
return $ergebnis;
}
/** @return array<string,int> */
private function klassen(bool $trockenlauf): array
{
$zaehler = ['neu' => 0, 'vorhanden' => 0, 'fehler' => 0];
$bekannte = array_column(Klasse::alle(), 'name');
foreach ($this->parser->eintraege(QifParser::TYP_KLASSE) as $eintrag) {
$name = trim((string)QifParser::feld($eintrag, 'N'));
if ($name === '') {
$zaehler['fehler']++;
$this->melde('fehler', 'Klasse ohne Namen übersprungen.');
continue;
}
if (in_array($name, $bekannte, true)) {
$zaehler['vorhanden']++;
continue;
}
try {
if (!$trockenlauf) {
Klasse::anlegen($name, (string)QifParser::feld($eintrag, 'D'));
}
$bekannte[] = $name;
$zaehler['neu']++;
} catch (\Throwable $e) {
$zaehler['fehler']++;
$this->melde('fehler', 'Klasse „' . $name . '“: ' . $e->getMessage());
}
}
return $zaehler;
}
/** @return array<string,int> */
private function kategorien(bool $trockenlauf): array
{
$zaehler = ['neu' => 0, 'vorhanden' => 0, 'fehler' => 0];
// Im Trockenlauf werden auch die Elternstufen mitgezählt, die der
// Import selbst anlegen würde – sonst stimmte die Vorschau nicht.
$bekannte = array_flip(array_column(Kategorie::alle(), 'pfad'));
foreach ($this->parser->eintraege(QifParser::TYP_KATEGORIE) as $eintrag) {
$pfad = Kategorie::normalisierePfad((string)QifParser::feld($eintrag, 'N'));
if ($pfad === '') {
$zaehler['fehler']++;
$this->melde('fehler', 'Kategorie ohne Namen übersprungen.');
continue;
}
if (isset($bekannte[$pfad])) {
$zaehler['vorhanden']++;
continue;
}
$art = QifParser::hatFeld($eintrag, 'I') ? 'einnahme' : 'ausgabe';
$daten = [
'beschreibung' => (string)QifParser::feld($eintrag, 'D'),
'steuerrelevant' => QifParser::hatFeld($eintrag, 'T'),
'steuerformular' => (string)QifParser::feld($eintrag, 'R'),
];
try {
if (!$trockenlauf) {
Kategorie::findenOderAnlegenNachPfad($pfad, $art, $daten);
}
// Fehlende Zwischenstufen zählen als eigene neue Kategorien.
$teile = explode(Kategorie::TRENNER, $pfad);
$bisher = '';
foreach ($teile as $teil) {
$bisher = $bisher === '' ? $teil : $bisher . Kategorie::TRENNER . $teil;
if (!isset($bekannte[$bisher])) {
$bekannte[$bisher] = true;
$zaehler['neu']++;
}
}
} catch (\Throwable $e) {
$zaehler['fehler']++;
$this->melde('fehler', 'Kategorie „' . $pfad . '“: ' . $e->getMessage());
}
}
return $zaehler;
}
/** @return array<string,int> */
private function konten(bool $trockenlauf): array
{
$zaehler = ['neu' => 0, 'vorhanden' => 0, 'fehler' => 0];
$bekannte = array_flip(array_column(Konto::alle(), 'name'));
foreach ($this->parser->eintraege(QifParser::TYP_KONTO) as $eintrag) {
$name = trim((string)QifParser::feld($eintrag, 'N'));
if ($name === '') {
$zaehler['fehler']++;
$this->melde('fehler', 'Konto ohne Namen übersprungen.');
continue;
}
// In der Kontenliste steht jedes Konto einmal, danach leitet
// derselbe Eintrag noch einmal die Buchungen ein – der zweite
// Treffer ist keine Dublette, sondern derselbe Datensatz.
if (isset($bekannte[$name])) {
$zaehler['vorhanden']++;
continue;
}
try {
if (!$trockenlauf) {
Konto::anlegen($this->kontoDaten($eintrag, $name));
}
$bekannte[$name] = true;
$zaehler['neu']++;
} catch (\Throwable $e) {
$zaehler['fehler']++;
$this->melde('fehler', 'Konto „' . $name . '“: ' . $e->getMessage());
}
}
return $zaehler;
}
/**
* @param array{zeilen: list<array{0: string, 1: string}>} $eintrag
* @return array<string,mixed>
*/
private function kontoDaten(array $eintrag, string $name): array
{
$qifTyp = (string)QifParser::feld($eintrag, 'T');
$beschreibung = trim((string)QifParser::feld($eintrag, 'D'));
$kontonummer = '';
if ($beschreibung !== '' && preg_match(self::KONTONUMMER_MUSTER, $beschreibung, $treffer) === 1) {
$kontonummer = $treffer[1];
$beschreibung = '';
}
$limit = QifParser::feld($eintrag, 'L');
return [
'name' => $name,
'kontoart' => Konto::artAusQif($qifTyp),
'beschreibung' => $beschreibung,
'kontonummer' => $kontonummer,
'kreditlimit' => $limit === null ? null : QifParser::betrag($limit),
// Anfangssalden stehen nicht im Export; sie werden nach dem Import
// je Konto nachgetragen (PLAN.md, Kapitel 4).
'anfangssaldo' => 0,
'aktiv' => 1,
];
}
private function melde(string $typ, string $text): void
{
$this->meldungen[] = ['typ' => $typ, 'text' => $text];
}
/** Vorschau ohne Schreibzugriff. */
public function vorschau(): array
{
return $this->ausfuehren(true);
}
}

View file

@ -55,6 +55,30 @@ class View
return $mitWaehrung ? $text . "\u{00A0}€" : $text;
}
/**
* Monatsnamen auf Deutsch. Nicht über date('F') oder IntlDateFormatter –
* date() ist immer englisch und intl ist keine Standarderweiterung. Der
* Budgetplaner und die Auswertungen greifen später ebenfalls hierauf zu.
*/
public const MONATE = [
1 => 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April',
5 => 'Mai', 6 => 'Juni', 7 => 'Juli', 8 => 'August',
9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember',
];
/** Kurzform für schmale Spalten (zwölf Monatsspalten im Budgetplaner). */
public const MONATE_KURZ = [
1 => 'Jan', 2 => 'Feb', 3 => 'Mär', 4 => 'Apr',
5 => 'Mai', 6 => 'Jun', 7 => 'Jul', 8 => 'Aug',
9 => 'Sep', 10 => 'Okt', 11 => 'Nov', 12 => 'Dez',
];
public static function monat(int $monat, bool $kurz = false): string
{
$namen = $kurz ? self::MONATE_KURZ : self::MONATE;
return $namen[$monat] ?? (string)$monat;
}
public static function datum(?string $datum): string
{
if (!$datum) {

View file

@ -2,6 +2,8 @@
use Finanzmanager\View;
/** @var array{gruppen: list<array<string,mixed>>, summe: float} $uebersicht */
include __DIR__ . '/../layout/kopf.php';
?>
@ -12,8 +14,8 @@ include __DIR__ . '/../layout/kopf.php';
<div class="kachelraster">
<section class="kachel">
<header><h2>Vermögensentwicklung</h2></header>
<p class="platzhalter">Zeigt Kontostände über die Zeit, sobald Konten und
Buchungen angelegt sind (Phase 1 und 2).</p>
<p class="platzhalter">Zeigt Kontostände über die Zeit, sobald Buchungen
erfasst sind (Phase 2).</p>
</section>
<section class="kachel">
@ -23,14 +25,46 @@ include __DIR__ . '/../layout/kopf.php';
<section class="kachel">
<header><h2>Kontostände</h2></header>
<p class="platzhalter">Übersicht aller Konten mit aktuellem und erwartetem Saldo (Phase 1).</p>
<?php if ($uebersicht['gruppen'] === []): ?>
<p class="platzhalter">
Noch keine Konten angelegt – einzeln oder gemeinsam aus dem QIF-Export.
</p>
<div class="knopfleiste">
<a href="/konten/neu" class="knopf primaer">Konto anlegen</a>
<a href="/verwaltung/import" class="knopf">QIF-Import</a>
</div>
<?php else: ?>
<table class="liste">
<tbody>
<?php foreach ($uebersicht['gruppen'] as $gruppe): ?>
<tr>
<td><?= View::h($gruppe['name']) ?></td>
<td class="rechts <?= View::betragKlasse($gruppe['summe']) ?>">
<?= View::betrag($gruppe['summe']) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr class="summenzeile">
<td>Summe</td>
<td class="rechts <?= View::betragKlasse($uebersicht['summe']) ?>">
<?= View::betrag($uebersicht['summe']) ?>
</td>
</tr>
</tfoot>
</table>
<div class="knopfleiste">
<a href="/konten" class="knopf">Zur Kontenübersicht</a>
</div>
<?php endif; ?>
</section>
<section class="kachel">
<header><h2>Aufbaustand</h2></header>
<ul class="checkliste">
<li class="erledigt">Phase 0 – Grundgerüst, Anmeldung, Einstellungen</li>
<li>Phase 1 – Konten, Kategorien, Klassen, QIF-Stammdaten</li>
<li class="erledigt">Phase 1 – Konten, Kategorien, Klassen, QIF-Stammdaten</li>
<li>Phase 2 – Buchungen, Kontoblatt, Splitbuchungen</li>
<li>Phase 3 – Budgetplaner und Monatsabschluss</li>
</ul>

View file

@ -52,12 +52,26 @@ include __DIR__ . '/../layout/kopf.php';
<select id="erster_monat" name="erster_monat">
<?php foreach (range(1, 12) as $m): ?>
<option value="<?= $m ?>" <?= (int)$wert('erster_monat', '1') === $m ? 'selected' : '' ?>>
<?= View::h(date('F', mktime(0, 0, 0, $m, 1))) ?>
<?= View::h(View::monat($m)) ?>
</option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">Bestimmt, wo Budgetplaner und Jahresauswertungen beginnen.</p>
<?php elseif ($bereich === 'import'): ?>
<label for="import_qif_verzeichnis">Importverzeichnis</label>
<input type="text" id="import_qif_verzeichnis" name="import_qif_verzeichnis"
value="<?= View::h($wert('import_qif_verzeichnis', 'data/import')) ?>">
<p class="feldhinweis">
Relativ zum Projektverzeichnis. Hochgeladene QIF-Dateien landen hier und bleiben
liegen, damit derselbe Export erst im Trockenlauf geprüft und dann übernommen
werden kann.
</p>
<div class="knopfleiste">
<a href="/verwaltung/import" class="knopf">QIF-Import öffnen</a>
</div>
<?php elseif ($bereich === 'diagnose'): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="debug_log_aktiv" value="1"
@ -96,14 +110,13 @@ include __DIR__ . '/../layout/kopf.php';
Dieser Bereich füllt sich in einer späteren Phase:
<?= View::h(match ($bereich) {
'banking' => 'Bankverbindungen und Abrufeinstellungen (Phase 5)',
'import' => 'QIF-Import und CSV-Export (Phase 1 und 2)',
'wertpapiere' => 'Kursabruf und Depoteinstellungen (Phase 6)',
default => 'noch offen',
}) ?>.
</p>
<?php endif; ?>
<?php if (in_array($bereich, ['allgemein', 'darstellung', 'buchhaltung', 'diagnose'], true)): ?>
<?php if (in_array($bereich, ['allgemein', 'darstellung', 'buchhaltung', 'import', 'diagnose'], true)): ?>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Speichern</button>
</div>

View file

@ -0,0 +1,107 @@
<?php
use Finanzmanager\Models\Konto;
use Finanzmanager\View;
/** @var array<string,mixed>|null $konto */
/** @var list<array<string,mixed>> $gruppen */
/** @var string $csrfToken */
$neu = $konto === null;
$ziel = $neu ? '/konten/neu' : '/konten/' . (int)$konto['id'] . '/bearbeiten';
$wert = static fn(string $feld, string $standard = ''): string
=> View::h((string)($konto[$feld] ?? $standard));
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/konten" class="knopf">Zurück zur Kontenübersicht</a>
</div>
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="name">Name des Kontos</label>
<input type="text" id="name" name="name" value="<?= $wert('name') ?>" required autofocus>
<label for="kontoart">Kontoart</label>
<select id="kontoart" name="kontoart">
<?php foreach (Konto::ARTEN as $schluessel => $beschriftung): ?>
<option value="<?= View::h($schluessel) ?>"
<?= ($konto['kontoart'] ?? 'bank') === $schluessel ? 'selected' : '' ?>>
<?= View::h($beschriftung) ?>
</option>
<?php endforeach; ?>
</select>
<label for="kontengruppe_id">Kontengruppe</label>
<select id="kontengruppe_id" name="kontengruppe_id">
<option value="">Nicht gruppiert</option>
<?php foreach ($gruppen as $gruppe): ?>
<option value="<?= (int)$gruppe['id'] ?>"
<?= (int)($konto['kontengruppe_id'] ?? 0) === (int)$gruppe['id'] ? 'selected' : '' ?>>
<?= View::h($gruppe['name']) ?>
</option>
<?php endforeach; ?>
</select>
<label for="bankname">Bank <span class="optional">(optional)</span></label>
<input type="text" id="bankname" name="bankname" value="<?= $wert('bankname') ?>">
<label for="kontonummer">Kontonummer <span class="optional">(optional)</span></label>
<input type="text" id="kontonummer" name="kontonummer" value="<?= $wert('kontonummer') ?>">
<label for="iban">IBAN <span class="optional">(optional)</span></label>
<input type="text" id="iban" name="iban" value="<?= $wert('iban') ?>">
<label for="bic">BIC <span class="optional">(optional)</span></label>
<input type="text" id="bic" name="bic" value="<?= $wert('bic') ?>">
<label for="anfangssaldo">Anfangssaldo</label>
<input type="text" id="anfangssaldo" name="anfangssaldo" inputmode="decimal"
value="<?= View::h(View::betrag($konto['anfangssaldo'] ?? 0, false)) ?>">
<p class="feldhinweis">
Kontostand vor der ersten erfassten Buchung. Der QIF-Export enthält ihn nicht –
nach einem Import wird er hier je Konto nachgetragen.
</p>
<label for="kreditlimit">Kreditlimit <span class="optional">(optional)</span></label>
<input type="text" id="kreditlimit" name="kreditlimit" inputmode="decimal"
value="<?= $konto === null || $konto['kreditlimit'] === null
? '' : View::h(View::betrag($konto['kreditlimit'], false)) ?>">
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
<input type="text" id="beschreibung" name="beschreibung" value="<?= $wert('beschreibung') ?>">
<label for="sortierung">Sortierung</label>
<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>
<label class="ankreuzfeld">
<input type="checkbox" name="aktiv" value="1" <?= $neu || (int)$konto['aktiv'] === 1 ? 'checked' : '' ?>>
Konto ist aktiv
</label>
<div class="knopfleiste">
<button type="submit" class="knopf primaer"><?= $neu ? 'Konto anlegen' : 'Speichern' ?></button>
<a href="/konten" class="knopf">Abbrechen</a>
</div>
</form>
<?php if (!$neu): ?>
<form method="post" action="/konten/<?= (int)$konto['id'] ?>/loeschen" class="formular breit"
onsubmit="return confirm('Konto „<?= View::h($konto['name']) ?>“ wirklich löschen?');">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Konto löschen</h3>
<p class="feldhinweis">
Ein Konto, das nur nicht mehr genutzt wird, sollte auf inaktiv gesetzt statt
gelöscht werden – gelöschte Konten nehmen später ihre Buchungen mit.
</p>
<div class="knopfleiste">
<button type="submit" class="knopf gefahr">Konto löschen</button>
</div>
</form>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,97 @@
<?php
use Finanzmanager\Models\Konto;
use Finanzmanager\View;
/** @var array{gruppen: list<array<string,mixed>>, summe: float} $uebersicht */
/** @var bool $mitInaktiven */
/** @var string $csrfToken */
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/konten/neu" class="knopf primaer">Neues Konto</a>
<a href="/verwaltung/kontengruppen" class="knopf">Kontengruppen</a>
<a href="/verwaltung/import" class="knopf">QIF-Import</a>
<span class="fuellung"></span>
<a href="/konten<?= $mitInaktiven ? '' : '?inaktive=1' ?>" class="knopf">
<?= $mitInaktiven ? 'Nur aktive Konten' : 'Inaktive einblenden' ?>
</a>
</div>
<?php if ($uebersicht['gruppen'] === []): ?>
<div class="kachel einzeln">
<h2>Noch keine Konten</h2>
<p class="platzhalter">
Konten lassen sich einzeln anlegen oder gemeinsam mit Kategorien und Klassen
aus einem QIF-Export des FinanzManagers übernehmen.
</p>
<div class="knopfleiste">
<a href="/konten/neu" class="knopf primaer">Konto anlegen</a>
<a href="/verwaltung/import" class="knopf">QIF-Export einlesen</a>
</div>
</div>
<?php else: ?>
<table class="liste konten">
<thead>
<tr>
<th>Konto</th>
<th>Kontoart</th>
<th>Kontonummer</th>
<th class="rechts">Online-Saldo</th>
<th class="rechts">Aktueller Saldo</th>
<th class="rechts">Erwarteter Saldo</th>
</tr>
</thead>
<?php foreach ($uebersicht['gruppen'] as $gruppe): ?>
<tbody>
<tr class="gruppenzeile">
<th colspan="6"><?= View::h($gruppe['name']) ?></th>
</tr>
<?php foreach ($gruppe['konten'] as $konto): ?>
<tr<?= (int)$konto['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
<td>
<a href="/konten/<?= (int)$konto['id'] ?>/bearbeiten"><?= View::h($konto['name']) ?></a>
<?php if ((int)$konto['aktiv'] !== 1): ?>
<span class="marke">inaktiv</span>
<?php endif; ?>
<?php if ($konto['beschreibung'] !== ''): ?>
<span class="nebentext"><?= View::h($konto['beschreibung']) ?></span>
<?php endif; ?>
</td>
<td><?= View::h(Konto::bezeichnungDerArt((string)$konto['kontoart'])) ?></td>
<td><?= View::h($konto['iban'] !== '' ? $konto['iban'] : $konto['kontonummer']) ?></td>
<td class="rechts nebentext">
<?= $konto['saldo_online'] === null ? '–' : View::betrag($konto['saldo_online']) ?>
</td>
<td class="rechts <?= View::betragKlasse($konto['saldo_aktuell']) ?>">
<?= View::betrag($konto['saldo_aktuell']) ?>
</td>
<td class="rechts <?= View::betragKlasse($konto['saldo_erwartet']) ?>">
<?= View::betrag($konto['saldo_erwartet']) ?>
</td>
</tr>
<?php endforeach; ?>
<tr class="gruppensumme">
<td colspan="5">Summe <?= View::h($gruppe['name']) ?></td>
<td class="rechts <?= View::betragKlasse($gruppe['summe']) ?>"><?= View::betrag($gruppe['summe']) ?></td>
</tr>
</tbody>
<?php endforeach; ?>
<tfoot>
<tr class="summenzeile">
<td colspan="5">Summe Kontostände</td>
<td class="rechts <?= View::betragKlasse($uebersicht['summe']) ?>"><?= View::betrag($uebersicht['summe']) ?></td>
</tr>
</tfoot>
</table>
<p class="hinweis">
Online-Saldo und die Trennung zwischen aktuellem und erwartetem Saldo füllen sich,
sobald Buchungen (Phase 2) und der Bankabruf (Phase 5) da sind. Bis dahin zeigen die
Spalten den Anfangssaldo.
</p>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,170 @@
<?php
use Finanzmanager\View;
/** @var list<array{name: string, groesse: int, geaendert: int}> $dateien */
/** @var string $verzeichnis */
/** @var array<string,mixed>|null $ergebnis */
/** @var string $datei */
/** @var string $csrfToken */
$bereiche = [
'klassen' => 'Klassen',
'kategorien' => 'Kategorien',
'konten' => 'Konten',
];
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
<a href="/verwaltung/kategorien" class="knopf">Kategorien</a>
<a href="/konten" class="knopf">Kontenübersicht</a>
</div>
<?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; ?>
</tbody>
</table>
<?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">
Buchungen und Buchungsvorlagen werden in Phase 2 übernommen – dieser Lauf
betrifft ausschließlich Klassen, Kategorien und Konten.
</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="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="datei" value="<?= View::h($datei) ?>">
<input type="hidden" name="modus" value="import">
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Jetzt übernehmen</button>
</div>
</form>
<?php else: ?>
<div class="knopfleiste">
<a href="/verwaltung/kategorien" class="knopf primaer">Kategorien ansehen</a>
<a href="/konten" class="knopf">Konten ansehen</a>
</div>
<p class="hinweis">
Die Anfangssalden stehen nicht im Export. Sie werden je Konto in der
Kontenübersicht nachgetragen.
</p>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="kachel">
<header><h2>Exportdatei hochladen</h2></header>
<form method="post" action="/verwaltung/import/hochladen" enctype="multipart/form-data" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="qif">QIF-Datei</label>
<input type="file" id="qif" name="qif" accept=".qif,.QIF" required>
<p class="feldhinweis">
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.
</p>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Hochladen</button>
</div>
</form>
</div>
<div class="kachel">
<header><h2>Vorhandene Dateien</h2></header>
<?php if ($dateien === []): ?>
<p class="platzhalter">
Noch keine Datei in <code><?= View::h($verzeichnis) ?></code>.
</p>
<?php else: ?>
<table class="liste">
<thead>
<tr><th>Datei</th><th class="rechts">Größe</th><th>Abgelegt</th><th class="rechts">Aktion</th></tr>
</thead>
<tbody>
<?php foreach ($dateien as $eintrag): ?>
<tr>
<td><code><?= View::h($eintrag['name']) ?></code></td>
<td class="rechts"><?= View::h(number_format($eintrag['groesse'] / 1024, 1, ',', '.')) ?> kB</td>
<td><?= View::h(date('d.m.Y H:i', $eintrag['geaendert'])) ?></td>
<td class="rechts nowrap">
<form method="post" action="/verwaltung/import/ausfuehren" class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>">
<input type="hidden" name="modus" value="vorschau">
<button type="submit" class="knopf klein">Trockenlauf</button>
</form>
<form method="post" action="/verwaltung/import/ausfuehren" class="inline"
onsubmit="return confirm('„<?= View::h($eintrag['name']) ?>“ jetzt importieren?');">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>">
<input type="hidden" name="modus" value="import">
<button type="submit" class="knopf klein primaer">Importieren</button>
</form>
<form method="post" action="/verwaltung/import/<?= View::h(rawurlencode($eintrag['name'])) ?>/loeschen"
class="inline"
onsubmit="return confirm('Datei „<?= View::h($eintrag['name']) ?>“ löschen?');">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf klein gefahr">Löschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,70 @@
<?php
use Finanzmanager\View;
/** @var array<string,int> $zahlen */
include __DIR__ . '/../layout/kopf.php';
?>
<div class="kachelraster">
<div class="kachel">
<header><h2>Kategorien</h2></header>
<p class="kennzahl"><?= (int)$zahlen['kategorien'] ?></p>
<p class="platzhalter">
<?= (int)$zahlen['einnahmen'] ?> Einnahmen, <?= (int)$zahlen['ausgaben'] ?> Ausgaben.
Hierarchisch, mit Klassen als zweiter Auswertungsdimension.
</p>
<div class="knopfleiste">
<a href="/verwaltung/kategorien" class="knopf primaer">Kategorien verwalten</a>
</div>
</div>
<div class="kachel">
<header><h2>Klassen</h2></header>
<p class="kennzahl"><?= (int)$zahlen['klassen'] ?></p>
<p class="platzhalter">
Zweite Dimension neben der Kategorie – etwa Ehemann, Ehefrau, Haus 1.
</p>
<div class="knopfleiste">
<a href="/verwaltung/klassen" class="knopf primaer">Klassen verwalten</a>
</div>
</div>
<div class="kachel">
<header><h2>Konten</h2></header>
<p class="kennzahl"><?= (int)$zahlen['konten'] ?></p>
<p class="platzhalter">
In <?= (int)$zahlen['kontengruppen'] ?> Kontengruppen gegliedert.
</p>
<div class="knopfleiste">
<a href="/konten" class="knopf primaer">Kontenübersicht</a>
<a href="/verwaltung/kontengruppen" class="knopf">Kontengruppen</a>
</div>
</div>
<div class="kachel">
<header><h2>QIF-Import</h2></header>
<p class="platzhalter">
Klassen, Kategorien und Konten aus einem Export des Lexware FinanzManagers
übernehmen – erst im Trockenlauf prüfen, dann übernehmen.
</p>
<div class="knopfleiste">
<a href="/verwaltung/import" class="knopf primaer">Import öffnen</a>
</div>
</div>
<div class="kachel">
<header>
<h2>Weitere Bereiche</h2>
<span class="phasenmarke">später</span>
</header>
<ul class="checkliste">
<li>Bankverbindungen und Regelverwaltung – Phase 5</li>
<li>Buchungsvorlagen und regelmäßige Buchungen – Phase 7</li>
<li>Adressen, Verträge, Sparziele – Phase 7</li>
</ul>
</div>
</div>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,164 @@
<?php
use Finanzmanager\Models\Kategorie;
use Finanzmanager\View;
/** @var array<string,mixed>|null $kategorie */
/** @var list<array<string,mixed>> $auswahl */
/** @var int|null $elternId */
/** @var string $csrfToken */
$neu = $kategorie === null;
$id = $neu ? 0 : (int)$kategorie['id'];
$ziel = $neu ? '/verwaltung/kategorien/neu' : '/verwaltung/kategorien/' . $id;
// Beim Verschieben und Zusammenführen darf der eigene Teilbaum nicht als Ziel
// erscheinen – sonst hinge der Ast in sich selbst.
$imEigenenAst = static function (array $eintrag) use ($neu, $id, $kategorie): bool {
if ($neu) {
return false;
}
$pfad = (string)$kategorie['pfad'];
return (int)$eintrag['id'] === $id
|| str_starts_with((string)$eintrag['pfad'], $pfad . Kategorie::TRENNER);
};
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/verwaltung/kategorien" class="knopf">Zurück zur Kategorieliste</a>
</div>
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="name">Name</label>
<input type="text" id="name" name="name" required autofocus
value="<?= View::h((string)($kategorie['name'] ?? '')) ?>">
<p class="feldhinweis">Ohne Doppelpunkt – der trennt die Ebenen im vollständigen Namen.</p>
<?php if ($neu): ?>
<label for="eltern_id">Übergeordnete Kategorie</label>
<select id="eltern_id" name="eltern_id">
<option value="">— oberste Ebene —</option>
<?php foreach ($auswahl as $eintrag): ?>
<option value="<?= (int)$eintrag['id'] ?>"
<?= $elternId === (int)$eintrag['id'] ? 'selected' : '' ?>>
<?= View::h($eintrag['pfad']) ?>
</option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">
Unterkategorien übernehmen die Art der übergeordneten Kategorie.
</p>
<?php else: ?>
<label>Vollständiger Name</label>
<p class="feldwert"><code><?= View::h($kategorie['pfad']) ?></code></p>
<?php endif; ?>
<label for="art">Art</label>
<select id="art" name="art" <?= !$neu && $kategorie['eltern_id'] !== null ? 'disabled' : '' ?>>
<?php foreach (Kategorie::ARTEN as $schluessel => $beschriftung): ?>
<option value="<?= View::h($schluessel) ?>"
<?= ($kategorie['art'] ?? 'ausgabe') === $schluessel ? 'selected' : '' ?>>
<?= View::h($beschriftung) ?>
</option>
<?php endforeach; ?>
</select>
<?php if (!$neu && $kategorie['eltern_id'] !== null): ?>
<p class="feldhinweis">
Die Art richtet sich nach der übergeordneten Kategorie und lässt sich nur dort ändern.
</p>
<input type="hidden" name="art" value="<?= View::h((string)$kategorie['art']) ?>">
<?php endif; ?>
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
<input type="text" id="beschreibung" name="beschreibung"
value="<?= View::h((string)($kategorie['beschreibung'] ?? '')) ?>">
<label class="ankreuzfeld">
<input type="checkbox" name="steuerrelevant" value="1"
<?= !empty($kategorie['steuerrelevant']) ? 'checked' : '' ?>>
Steuerrelevant
</label>
<label for="steuerformular">Steuerformular <span class="optional">(optional)</span></label>
<input type="text" id="steuerformular" name="steuerformular"
value="<?= View::h((string)($kategorie['steuerformular'] ?? '')) ?>">
<p class="feldhinweis">Kennziffer aus dem FinanzManager, etwa <code>16304</code>.</p>
<?php if (!$neu): ?>
<label for="sortierung">Sortierung</label>
<input type="number" id="sortierung" name="sortierung" step="1"
value="<?= (int)$kategorie['sortierung'] ?>">
<label class="ankreuzfeld">
<input type="checkbox" name="aktiv" value="1" <?= (int)$kategorie['aktiv'] === 1 ? 'checked' : '' ?>>
Kategorie ist aktiv
</label>
<?php endif; ?>
<div class="knopfleiste">
<button type="submit" class="knopf primaer"><?= $neu ? 'Kategorie anlegen' : 'Speichern' ?></button>
<a href="/verwaltung/kategorien" class="knopf">Abbrechen</a>
</div>
</form>
<?php if (!$neu): ?>
<form method="post" action="/verwaltung/kategorien/<?= $id ?>/verschieben" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Verschieben</h3>
<label for="ziel_eltern">Neue übergeordnete Kategorie</label>
<select id="ziel_eltern" name="eltern_id">
<option value="">— oberste Ebene —</option>
<?php foreach ($auswahl as $eintrag): ?>
<?php if ($imEigenenAst($eintrag)) { continue; } ?>
<option value="<?= (int)$eintrag['id'] ?>"
<?= (int)($kategorie['eltern_id'] ?? 0) === (int)$eintrag['id'] ? 'selected' : '' ?>>
<?= View::h($eintrag['pfad']) ?>
</option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">Unterkategorien wandern mit und behalten ihre Reihenfolge.</p>
<div class="knopfleiste">
<button type="submit" class="knopf">Verschieben</button>
</div>
</form>
<form method="post" action="/verwaltung/kategorien/<?= $id ?>/zusammenfuehren" class="formular breit"
onsubmit="return confirm('„<?= View::h($kategorie['pfad']) ?>“ in die gewählte Kategorie überführen?');">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Zusammenführen</h3>
<label for="ziel_id">Zielkategorie</label>
<select id="ziel_id" name="ziel_id" required>
<option value="">— bitte wählen —</option>
<?php foreach ($auswahl as $eintrag): ?>
<?php if ($imEigenenAst($eintrag)) { continue; } ?>
<option value="<?= (int)$eintrag['id'] ?>"><?= View::h($eintrag['pfad']) ?></option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">
Die Unterkategorien wechseln zur Zielkategorie, danach verschwindet diese Kategorie.
Ab Phase 2 wandern auch die Buchungen mit.
</p>
<div class="knopfleiste">
<button type="submit" class="knopf">Zusammenführen</button>
</div>
</form>
<form method="post" action="/verwaltung/kategorien/<?= $id ?>/loeschen" class="formular breit"
onsubmit="return confirm('„<?= View::h($kategorie['pfad']) ?>“ mit allen Unterkategorien löschen?');">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Löschen</h3>
<p class="feldhinweis">
Löscht die Kategorie samt allen Unterkategorien. Wer sie nur aus den Auswahllisten
nehmen will, setzt sie stattdessen auf inaktiv.
</p>
<div class="knopfleiste">
<button type="submit" class="knopf gefahr">Kategorie löschen</button>
</div>
</form>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,112 @@
<?php
use Finanzmanager\Models\Konto;
use Finanzmanager\View;
/** @var string $register */
/** @var array<string,string> $registerListe */
/** @var list<array<string,mixed>> $kategorien */
/** @var list<array<string,mixed>> $konten */
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<?php foreach ($registerListe as $schluessel => $beschriftung): ?>
<a href="/verwaltung/kategorien?register=<?= View::h($schluessel) ?>"
class="tab <?= $register === $schluessel ? 'aktiv' : '' ?>"><?= View::h($beschriftung) ?></a>
<?php endforeach; ?>
</div>
<div class="seitenaktionen">
<?php if ($register === 'umbuchungskonten'): ?>
<a href="/konten/neu" class="knopf primaer">Neues Konto</a>
<?php else: ?>
<a href="/verwaltung/kategorien/neu" class="knopf primaer">Neue Kategorie</a>
<?php endif; ?>
<a href="/verwaltung/import" class="knopf">QIF-Import</a>
</div>
<?php if ($register === 'umbuchungskonten'): ?>
<p class="hinweis">
Umbuchungskonten sind keine eigenen Kategorien: Bei einer Umbuchung wird statt einer
Kategorie das Gegenkonto gewählt. Deshalb stehen hier die Konten, so wie sie später in
der Kategorieauswahl einer Buchung erscheinen – in eckigen Klammern.
</p>
<?php if ($konten === []): ?>
<p class="platzhalter">Noch keine Konten angelegt.</p>
<?php else: ?>
<table class="liste">
<thead>
<tr><th>Umbuchungskonto</th><th>Kontoart</th><th>Kontengruppe</th></tr>
</thead>
<tbody>
<?php foreach ($konten as $konto): ?>
<tr<?= (int)$konto['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
<td>
<a href="/konten/<?= (int)$konto['id'] ?>/bearbeiten">[<?= View::h($konto['name']) ?>]</a>
</td>
<td><?= View::h(Konto::bezeichnungDerArt((string)$konto['kontoart'])) ?></td>
<td><?= View::h($konto['gruppenname'] ?? 'Nicht gruppiert') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php elseif ($kategorien === []): ?>
<div class="kachel einzeln">
<h2>Noch keine Kategorien</h2>
<p class="platzhalter">
Kategorien lassen sich einzeln anlegen oder komplett aus einem QIF-Export
des FinanzManagers übernehmen.
</p>
<div class="knopfleiste">
<a href="/verwaltung/kategorien/neu" class="knopf primaer">Kategorie anlegen</a>
<a href="/verwaltung/import" class="knopf">QIF-Export einlesen</a>
</div>
</div>
<?php else: ?>
<table class="liste kategorien">
<thead>
<tr>
<th>Kategorie</th>
<th>Art</th>
<th>Beschreibung</th>
<th>Steuer</th>
<th class="rechts">Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($kategorien as $kategorie): ?>
<tr<?= (int)$kategorie['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
<td>
<span class="baumname" style="--ebene: <?= (int)$kategorie['ebene'] ?>">
<a href="/verwaltung/kategorien/<?= (int)$kategorie['id'] ?>">
<?= View::h($kategorie['name']) ?>
</a>
<?php if (!empty($kategorie['hat_kinder'])): ?>
<span class="nebentext">(Oberkategorie)</span>
<?php endif; ?>
</span>
</td>
<td><?= $kategorie['art'] === 'einnahme' ? 'Einnahme' : 'Ausgabe' ?></td>
<td class="nebentext"><?= View::h($kategorie['beschreibung']) ?></td>
<td>
<?php if ((int)$kategorie['steuerrelevant'] === 1): ?>
<span class="marke">steuerrelevant<?= $kategorie['steuerformular'] !== ''
? ' · ' . View::h($kategorie['steuerformular']) : '' ?></span>
<?php endif; ?>
</td>
<td class="rechts">
<a href="/verwaltung/kategorien/neu?eltern=<?= (int)$kategorie['id'] ?>">Unterkategorie</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,87 @@
<?php
use Finanzmanager\View;
/** @var list<array<string,mixed>> $klassen */
/** @var string $csrfToken */
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
<a href="/verwaltung/import" class="knopf">QIF-Import</a>
</div>
<p class="hinweis">
Klassen sind die zweite Auswertungsdimension neben der Kategorie: dieselbe Ausgabe lässt
sich damit zusätzlich einer Person, einem Haus oder einem Projekt zuordnen.
</p>
<?php if ($klassen === []): ?>
<p class="platzhalter">Noch keine Klassen angelegt.</p>
<?php else: ?>
<?php
// Die Formulare stehen außerhalb der Tabelle und werden über das
// form-Attribut angesprochen – ein <form> zwischen <tr> und <td> wäre
// ungültiges HTML und würde vom Browser aus der Tabelle geschoben.
foreach ($klassen as $klasse): ?>
<form id="klasse-<?= (int)$klasse['id'] ?>" method="post"
action="/verwaltung/klassen/<?= (int)$klasse['id'] ?>"></form>
<form id="klasse-loeschen-<?= (int)$klasse['id'] ?>" method="post"
action="/verwaltung/klassen/<?= (int)$klasse['id'] ?>/loeschen"
onsubmit="return confirm('Klasse „<?= View::h($klasse['name']) ?>“ löschen?');"></form>
<?php endforeach; ?>
<table class="liste">
<thead>
<tr><th>Name</th><th>Beschreibung</th><th>Status</th><th class="rechts">Aktion</th></tr>
</thead>
<tbody>
<?php foreach ($klassen as $klasse): ?>
<?php $f = 'klasse-' . (int)$klasse['id']; ?>
<tr<?= (int)$klasse['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
<td>
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="text" form="<?= $f ?>" name="name"
value="<?= View::h($klasse['name']) ?>" required>
</td>
<td>
<input type="text" form="<?= $f ?>" name="beschreibung"
value="<?= View::h($klasse['beschreibung']) ?>">
</td>
<td>
<label class="ankreuzfeld">
<input type="checkbox" form="<?= $f ?>" name="aktiv" value="1"
<?= (int)$klasse['aktiv'] === 1 ? 'checked' : '' ?>> aktiv
</label>
</td>
<td class="rechts nowrap">
<input type="hidden" form="klasse-loeschen-<?= (int)$klasse['id'] ?>"
name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button>
<button type="submit" form="klasse-loeschen-<?= (int)$klasse['id'] ?>"
class="knopf klein gefahr">Löschen</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<form method="post" action="/verwaltung/klassen/neu" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Neue Klasse</h3>
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
<input type="text" id="beschreibung" name="beschreibung">
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Klasse anlegen</button>
</div>
</form>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,101 @@
<?php
use Finanzmanager\Models\Kontengruppe;
use Finanzmanager\View;
/** @var list<array<string,mixed>> $gruppen */
/** @var list<array<string,mixed>> $konten */
/** @var string $csrfToken */
$anzahlJeGruppe = [];
$ohneGruppe = 0;
foreach ($konten as $konto) {
if ($konto['kontengruppe_id'] === null) {
$ohneGruppe++;
continue;
}
$id = (int)$konto['kontengruppe_id'];
$anzahlJeGruppe[$id] = ($anzahlJeGruppe[$id] ?? 0) + 1;
}
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/konten" class="knopf">Zur Kontenübersicht</a>
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
</div>
<p class="hinweis">
Kontengruppen gliedern die Kontenübersicht. Konten ohne Gruppe erscheinen dort unter
„<?= View::h(Kontengruppe::OHNE_GRUPPE) ?>“ – derzeit <?= (int)$ohneGruppe ?>.
</p>
<?php if ($gruppen === []): ?>
<p class="platzhalter">Noch keine Kontengruppen angelegt.</p>
<?php else: ?>
<?php foreach ($gruppen as $gruppe): ?>
<form id="gruppe-<?= (int)$gruppe['id'] ?>" method="post"
action="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>"></form>
<form id="gruppe-loeschen-<?= (int)$gruppe['id'] ?>" method="post"
action="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>/loeschen"
onsubmit="return confirm('Kontengruppe „<?= View::h($gruppe['name']) ?>“ löschen? Die Konten bleiben erhalten.');"></form>
<?php endforeach; ?>
<table class="liste">
<thead>
<tr>
<th>Name</th><th>Beschreibung</th><th class="rechts">Sortierung</th>
<th class="rechts">Konten</th><th class="rechts">Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($gruppen as $gruppe): ?>
<?php $f = 'gruppe-' . (int)$gruppe['id']; ?>
<tr>
<td>
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" form="gruppe-loeschen-<?= (int)$gruppe['id'] ?>"
name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="text" form="<?= $f ?>" name="name"
value="<?= View::h($gruppe['name']) ?>" required>
</td>
<td>
<input type="text" form="<?= $f ?>" name="beschreibung"
value="<?= View::h($gruppe['beschreibung']) ?>">
</td>
<td class="rechts">
<input type="number" form="<?= $f ?>" name="sortierung" step="1" class="schmal"
value="<?= (int)$gruppe['sortierung'] ?>">
</td>
<td class="rechts"><?= (int)($anzahlJeGruppe[(int)$gruppe['id']] ?? 0) ?></td>
<td class="rechts nowrap">
<button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button>
<button type="submit" form="gruppe-loeschen-<?= (int)$gruppe['id'] ?>"
class="knopf klein gefahr">Löschen</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<form method="post" action="/verwaltung/kontengruppen/neu" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Neue Kontengruppe</h3>
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
<input type="text" id="beschreibung" name="beschreibung">
<label for="sortierung">Sortierung</label>
<input type="number" id="sortierung" name="sortierung" value="0" step="1">
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Kontengruppe anlegen</button>
</div>
</form>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use Finanzmanager\View;
class DarstellungTest extends TestCase
{
protected string $testName = 'Test 05: Darstellung (Formate auf Deutsch)';
public function run(): void
{
$this->monatsnamen();
$this->betraege();
$this->datumsformat();
}
private function monatsnamen(): void
{
$this->assertEquals('Januar', View::monat(1), 'Erster Monat heißt Januar');
$this->assertEquals('März', View::monat(3), 'Umlaut im Monatsnamen bleibt erhalten');
$this->assertEquals('Dezember', View::monat(12), 'Letzter Monat heißt Dezember');
$this->assertEquals('Mär', View::monat(3, true), 'Kurzform mit Umlaut');
$this->assertEquals('Dez', View::monat(12, true), 'Kurzform des letzten Monats');
$this->assertEquals(12, count(View::MONATE), 'Zwölf Monatsnamen');
$this->assertEquals(12, count(View::MONATE_KURZ), 'Zwölf Kurzformen');
// Vollständige Sollliste: date('F') liefert englische Namen, und für
// April, August, September und November fällt das nicht auf, weil sie
// in beiden Sprachen gleich heißen. Deshalb Wert für Wert vergleichen.
$soll = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
foreach ($soll as $i => $name) {
$this->assertEquals($name, View::monat($i + 1), 'Monat ' . ($i + 1) . ' heißt ' . $name);
$this->assertNotEmpty(View::monat($i + 1, true), 'Kurzform für Monat ' . ($i + 1) . ' vorhanden');
}
// Ungültige Werte dürfen die Oberfläche nicht zerlegen.
$this->assertEquals('0', View::monat(0), 'Unbekannter Monat gibt die Zahl zurück');
$this->assertEquals('13', View::monat(13), 'Monat außerhalb des Bereichs gibt die Zahl zurück');
}
private function betraege(): void
{
$this->assertEquals('1.234,50', View::betrag(1234.5, false), 'Deutsches Zahlenformat');
$this->assertEquals("1.234,50\u{00A0}€", View::betrag(1234.5), 'Währung mit geschütztem Leerzeichen');
$this->assertEquals('-58.457,07', View::betrag(-58457.07, false), 'Negativer Betrag');
$this->assertEquals('betrag negativ', View::betragKlasse(-1), 'Negative Beträge werden markiert');
$this->assertEquals('betrag', View::betragKlasse(0), 'Null gilt nicht als negativ');
}
private function datumsformat(): void
{
$this->assertEquals('18.03.2026', View::datum('2026-03-18'), 'Deutsches Datumsformat');
$this->assertEquals('18.03.2026', View::datum('2026-03-18 14:22:01'), 'Zeitanteil wird abgeschnitten');
$this->assertEquals('–', View::datum(null), 'Leeres Datum als Gedankenstrich');
}
}

241
tests/06_KategorienTest.php Normal file
View file

@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
class KategorienTest extends TestCase
{
protected string $testName = 'Test 06: Kategorien und Klassen';
public function run(): void
{
$this->mandantAnlegen();
$this->hierarchieUndPfade();
$this->artVererbtSich();
$this->umbenennenZiehtPfadeMit();
$this->verschieben();
$this->verschiebenInDenEigenenAst();
$this->zusammenfuehren();
$this->baumdarstellung();
$this->pfadImportAnlegen();
$this->qifAngabeZerlegen();
$this->klassen();
}
private function hierarchieUndPfade(): void
{
$obenId = Kategorie::anlegen(['name' => '400 - Leben', 'art' => 'ausgabe']);
$unten = Kategorie::anlegen(['name' => 'B-Essen alleine', 'eltern_id' => $obenId]);
$kategorie = Kategorie::find($unten);
$this->assertEquals('400 - Leben:B-Essen alleine', $kategorie['pfad'], 'Pfad wird zusammengesetzt');
$this->assertEquals('B-Essen alleine', $kategorie['name'], 'Der Name bleibt der Blattname');
$this->assertNotEmpty(
Kategorie::findeNachPfad('400 - Leben:B-Essen alleine'),
'Kategorie ist über den vollständigen Pfad auffindbar'
);
$doppelt = false;
try {
Kategorie::anlegen(['name' => 'B-Essen alleine', 'eltern_id' => $obenId]);
} catch (\Throwable) {
$doppelt = true;
}
$this->assert($doppelt, 'Derselbe Pfad lässt sich nicht zweimal anlegen');
$mitDoppelpunkt = false;
try {
Kategorie::anlegen(['name' => 'Auto:Benzin']);
} catch (\Throwable) {
$mitDoppelpunkt = true;
}
$this->assert($mitDoppelpunkt, 'Ein Doppelpunkt im Namen wird abgewiesen');
}
private function artVererbtSich(): void
{
$einnahmeId = Kategorie::anlegen(['name' => '100 - Gehalt', 'art' => 'einnahme']);
$kindId = Kategorie::anlegen([
'name' => 'Sonderzahlung',
'eltern_id' => $einnahmeId,
'art' => 'ausgabe', // wird ignoriert
]);
$this->assertEquals('einnahme', Kategorie::find($kindId)['art'], 'Unterkategorie erbt die Art');
// Ändert sich die Art oben, zieht der ganze Ast mit.
Kategorie::speichern($einnahmeId, [
'name' => '100 - Gehalt', 'art' => 'ausgabe', 'aktiv' => true,
]);
$this->assertEquals('ausgabe', Kategorie::find($kindId)['art'], 'Artwechsel wirkt auf den ganzen Ast');
}
private function umbenennenZiehtPfadeMit(): void
{
$obenId = Kategorie::anlegen(['name' => 'Wohnen']);
$kindId = Kategorie::anlegen(['name' => 'Strom', 'eltern_id' => $obenId]);
$enkeId = Kategorie::anlegen(['name' => 'Abschlag', 'eltern_id' => $kindId]);
Kategorie::speichern($obenId, ['name' => 'Haus', 'art' => 'ausgabe', 'aktiv' => true]);
$this->assertEquals('Haus', Kategorie::find($obenId)['pfad'], 'Pfad der umbenannten Kategorie');
$this->assertEquals('Haus:Strom', Kategorie::find($kindId)['pfad'], 'Pfad des Kindes wandert mit');
$this->assertEquals('Haus:Strom:Abschlag', Kategorie::find($enkeId)['pfad'], 'Pfad des Enkels wandert mit');
}
private function verschieben(): void
{
$aId = Kategorie::anlegen(['name' => 'Freizeit']);
$bId = Kategorie::anlegen(['name' => 'Urlaub']);
$kindId = Kategorie::anlegen(['name' => 'Flug', 'eltern_id' => $bId]);
Kategorie::verschieben($bId, $aId);
$this->assertEquals('Freizeit:Urlaub', Kategorie::find($bId)['pfad'], 'Verschobene Kategorie hat neuen Pfad');
$this->assertEquals('Freizeit:Urlaub:Flug', Kategorie::find($kindId)['pfad'], 'Unterkategorie folgt');
Kategorie::verschieben($bId, null);
$this->assertEquals('Urlaub', Kategorie::find($bId)['pfad'], 'Verschieben auf die oberste Ebene');
$this->assertEquals('Urlaub:Flug', Kategorie::find($kindId)['pfad'], 'Unterkategorie folgt auch dorthin');
}
private function verschiebenInDenEigenenAst(): void
{
$obenId = Kategorie::anlegen(['name' => 'Versicherungen']);
$kindId = Kategorie::anlegen(['name' => 'Haftpflicht', 'eltern_id' => $obenId]);
$abgewiesen = false;
try {
Kategorie::verschieben($obenId, $kindId);
} catch (\Throwable) {
$abgewiesen = true;
}
$this->assert($abgewiesen, 'Verschieben unter die eigene Unterkategorie wird abgewiesen');
$selbst = false;
try {
Kategorie::verschieben($obenId, $obenId);
} catch (\Throwable) {
$selbst = true;
}
$this->assert($selbst, 'Eine Kategorie kann sich nicht selbst untergeordnet werden');
$this->assertEquals('Versicherungen', Kategorie::find($obenId)['pfad'], 'Der Pfad blieb unverändert');
}
private function zusammenfuehren(): void
{
$quelleId = Kategorie::anlegen(['name' => 'Telefon']);
$zielId = Kategorie::anlegen(['name' => 'Kommunikation']);
$kindId = Kategorie::anlegen(['name' => 'Mobilfunk', 'eltern_id' => $quelleId]);
Kategorie::zusammenfuehren($quelleId, $zielId);
$this->assertNull(Kategorie::find($quelleId), 'Die Quellkategorie ist verschwunden');
$this->assertEquals(
'Kommunikation:Mobilfunk',
Kategorie::find($kindId)['pfad'],
'Die Unterkategorie hängt jetzt am Ziel'
);
}
private function baumdarstellung(): void
{
$obenId = Kategorie::anlegen(['name' => 'Auto']);
Kategorie::anlegen(['name' => 'Benzin', 'eltern_id' => $obenId]);
Kategorie::anlegen(['name' => 'Werkstatt', 'eltern_id' => $obenId]);
$flach = Kategorie::baumFlach();
$nach = [];
foreach ($flach as $eintrag) {
$nach[$eintrag['pfad']] = $eintrag['ebene'];
}
$this->assertEquals(0, $nach['Auto'] ?? -1, 'Oberkategorie liegt auf Ebene 0');
$this->assertEquals(1, $nach['Auto:Benzin'] ?? -1, 'Unterkategorie liegt auf Ebene 1');
$baum = Kategorie::baum();
$auto = null;
foreach ($baum as $knoten) {
if ($knoten['pfad'] === 'Auto') {
$auto = $knoten;
}
}
$this->assertNotEmpty($auto, 'Oberkategorie steht als Wurzel im Baum');
$this->assertEquals(2, count($auto['kinder']), 'Beide Unterkategorien hängen darunter');
}
private function pfadImportAnlegen(): void
{
// Der QIF-Import trifft Unterkategorien auch ohne vorher genannte
// Elternkategorie – die Zwischenstufen müssen entstehen.
$id = Kategorie::findenOderAnlegenNachPfad(
'0 - Freiberuflich:01 - Rückstellungen:010 - Rückst. USt',
'einnahme',
['beschreibung' => 'aus dem Export']
);
$this->assertGreaterThan(0, $id, 'Der Pfad wird angelegt');
$this->assertNotEmpty(Kategorie::findeNachPfad('0 - Freiberuflich'), 'Erste Stufe entstand');
$this->assertNotEmpty(
Kategorie::findeNachPfad('0 - Freiberuflich:01 - Rückstellungen'),
'Zwischenstufe entstand'
);
$this->assertEquals('einnahme', Kategorie::find($id)['art'], 'Art gilt für den ganzen Pfad');
$this->assertEquals('aus dem Export', Kategorie::find($id)['beschreibung'], 'Beschreibung nur auf der letzten Stufe');
$this->assertEquals(
'',
Kategorie::findeNachPfad('0 - Freiberuflich')['beschreibung'],
'Zwischenstufen bleiben ohne Beschreibung'
);
// Zweiter Aufruf liefert dieselbe Kategorie, legt nichts Neues an.
$vorher = Kategorie::anzahl();
$zweite = Kategorie::findenOderAnlegenNachPfad('0 - Freiberuflich:01 - Rückstellungen:010 - Rückst. USt');
$this->assertEquals($id, $zweite, 'Zweiter Aufruf liefert dieselbe Kategorie');
$this->assertEquals($vorher, Kategorie::anzahl(), 'Es kam nichts hinzu');
}
private function qifAngabeZerlegen(): void
{
$ohne = Kategorie::zerlegeQifAngabe('400 - Leben:B-Essen alleine');
$this->assertEquals('400 - Leben:B-Essen alleine', $ohne['kategorie'], 'Kategorie ohne Klasse');
$this->assertEquals('', $ohne['klasse'], 'Keine Klasse angegeben');
$mit = Kategorie::zerlegeQifAngabe('400 - Leben:B-Essen alleine/Privat');
$this->assertEquals('400 - Leben:B-Essen alleine', $mit['kategorie'], 'Kategorie wird abgetrennt');
$this->assertEquals('Privat', $mit['klasse'], 'Klasse wird erkannt');
$umbuchung = Kategorie::zerlegeQifAngabe('[DKB Girokonto alt]/Privat');
$this->assertEquals('[DKB Girokonto alt]', $umbuchung['kategorie'], 'Umbuchung behält die Klammern');
$this->assertEquals('Privat', $umbuchung['klasse'], 'Auch Umbuchungen können eine Klasse tragen');
}
private function klassen(): void
{
$id = Klasse::anlegen('Ehemann', 'Unterscheidet Ausgaben einzelner Familienmitglieder');
$this->assertGreaterThan(0, $id, 'Klasse bekommt eine ID');
$doppelt = false;
try {
Klasse::anlegen('Ehemann');
} catch (\Throwable) {
$doppelt = true;
}
$this->assert($doppelt, 'Klassennamen sind eindeutig');
$this->assertEquals($id, Klasse::findenOderAnlegen('Ehemann'), 'Vorhandene Klasse wird wiederverwendet');
Klasse::speichern($id, 'Ehefrau', 'geändert', false);
$klasse = Klasse::find($id);
$this->assertEquals('Ehefrau', $klasse['name'], 'Klasse lässt sich umbenennen');
$this->assertEquals(0, (int)$klasse['aktiv'], 'Klasse lässt sich stilllegen');
Klasse::loeschen($id);
$this->assertNull(Klasse::find($id), 'Klasse lässt sich löschen');
}
}

158
tests/07_KontenTest.php Normal file
View file

@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
use Finanzmanager\Controllers\KontenController;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Kontengruppe;
class KontenTest extends TestCase
{
protected string $testName = 'Test 07: Konten und Kontengruppen';
public function run(): void
{
$this->mandantAnlegen();
$this->anlegenUndFinden();
$this->kontoarten();
$this->uebersichtGruppiertUndSummiert();
$this->betragsEingabe();
}
private function anlegenUndFinden(): void
{
$id = Konto::anlegen([
'name' => 'DKB Girokonto',
'kontoart' => 'bank',
'kontonummer' => '1010903027',
'anfangssaldo' => 1234.56,
'aktiv' => 1,
]);
$konto = Konto::find($id);
$this->assertEquals('DKB Girokonto', $konto['name'], 'Konto wird angelegt');
$this->assertEquals('1234.56', $konto['anfangssaldo'], 'Anfangssaldo wird übernommen');
$doppelt = false;
try {
Konto::anlegen(['name' => 'DKB Girokonto']);
} catch (\Throwable) {
$doppelt = true;
}
$this->assert($doppelt, 'Kontonamen sind eindeutig');
$ohneName = false;
try {
Konto::anlegen(['name' => ' ']);
} catch (\Throwable) {
$ohneName = true;
}
$this->assert($ohneName, 'Ein Konto ohne Namen wird abgewiesen');
// Leerzeichen in der IBAN sind beim Abtippen normal und müssen weg.
Konto::speichern($id, [
'name' => 'DKB Girokonto',
'kontoart' => 'bank',
'iban' => 'de89 3704 0044 0532 0130 00',
'aktiv' => 1,
]);
$this->assertEquals(
'DE89370400440532013000',
Konto::find($id)['iban'],
'IBAN wird ohne Leerzeichen und in Großbuchstaben abgelegt'
);
Konto::loeschen($id);
$this->assertNull(Konto::find($id), 'Konto lässt sich löschen');
}
private function kontoarten(): void
{
$this->assertEquals('bank', Konto::artAusQif('Bank'), 'QIF-Typ Bank');
$this->assertEquals('kreditkarte', Konto::artAusQif('CCard'), 'QIF-Typ CCard');
$this->assertEquals('sparkonto', Konto::artAusQif('Oth T'), 'QIF-Typ Oth T');
$this->assertEquals('depot', Konto::artAusQif('Port'), 'QIF-Typ Port');
$this->assertEquals('depot', Konto::artAusQif('Invst'), 'QIF-Typ Invst');
$this->assertEquals('sonstiges', Konto::artAusQif('Unbekannt'), 'Unbekannter Typ fällt auf Sonstiges');
$this->assertEquals('Kreditkarte', Konto::bezeichnungDerArt('kreditkarte'), 'Bezeichnung auf Deutsch');
$unbekannt = false;
try {
Konto::anlegen(['name' => 'Testkonto', 'kontoart' => 'gibtesnicht']);
} catch (\Throwable) {
$unbekannt = true;
}
$this->assert($unbekannt, 'Unbekannte Kontoart wird abgewiesen');
}
private function uebersichtGruppiertUndSummiert(): void
{
$anlage = Kontengruppe::anlegen('Anlage', '', 1);
Konto::anlegen(['name' => 'Tagesgeld', 'kontoart' => 'sparkonto',
'kontengruppe_id' => $anlage, 'anfangssaldo' => 10000, 'aktiv' => 1]);
Konto::anlegen(['name' => 'Depot', 'kontoart' => 'depot',
'kontengruppe_id' => $anlage, 'anfangssaldo' => 5000, 'aktiv' => 1]);
Konto::anlegen(['name' => 'Girokonto', 'kontoart' => 'bank',
'anfangssaldo' => 1500.50, 'aktiv' => 1]);
Konto::anlegen(['name' => 'Kreditkarte', 'kontoart' => 'kreditkarte',
'anfangssaldo' => -320.25, 'aktiv' => 1]);
Konto::anlegen(['name' => 'Altes Konto', 'kontoart' => 'bank',
'anfangssaldo' => 999, 'aktiv' => 0]);
$uebersicht = Konto::uebersicht();
$summeErwartet = 10000 + 5000 + 1500.50 - 320.25;
$this->assertEquals($summeErwartet, $uebersicht['summe'], 'Gesamtsumme ohne inaktive Konten');
$this->assertEquals(2, count($uebersicht['gruppen']), 'Zwei Gruppen: Anlage und Nicht gruppiert');
$nachName = [];
foreach ($uebersicht['gruppen'] as $gruppe) {
$nachName[$gruppe['name']] = $gruppe;
}
$this->assertEquals(15000.0, $nachName['Anlage']['summe'] ?? 0.0, 'Summe der Gruppe Anlage');
$this->assertEquals(
2,
count($nachName[Kontengruppe::OHNE_GRUPPE]['konten'] ?? []),
'Konten ohne Gruppe stehen unter „Nicht gruppiert“'
);
$mitInaktiven = Konto::uebersicht(false);
$this->assertEquals(
$summeErwartet + 999,
$mitInaktiven['summe'],
'Inaktive Konten zählen mit, wenn sie eingeblendet werden'
);
// Die Saldospalten existieren schon, gespeist aus dem Anfangssaldo.
$giro = null;
foreach ($nachName[Kontengruppe::OHNE_GRUPPE]['konten'] as $konto) {
if ($konto['name'] === 'Girokonto') {
$giro = $konto;
}
}
$this->assertEquals(1500.50, $giro['saldo_aktuell'], 'Aktueller Saldo entspricht dem Anfangssaldo');
$this->assertEquals(1500.50, $giro['saldo_erwartet'], 'Erwarteter Saldo entspricht dem Anfangssaldo');
$this->assertNull($giro['saldo_online'], 'Online-Saldo bleibt leer bis zum ersten Bankabruf');
// Wird die Gruppe gelöscht, bleiben die Konten erhalten.
Kontengruppe::loeschen($anlage);
$danach = Konto::uebersicht();
$this->assertEquals(1, count($danach['gruppen']), 'Nach dem Löschen bleibt nur „Nicht gruppiert“');
$this->assertEquals($summeErwartet, $danach['summe'], 'Die Summe ändert sich dadurch nicht');
}
private function betragsEingabe(): void
{
$this->assertEquals(1234.56, KontenController::zahl('1.234,56'), 'Deutsches Format mit Tausenderpunkt');
$this->assertEquals(-320.25, KontenController::zahl('-320,25'), 'Negativer Betrag');
$this->assertEquals(1234.56, KontenController::zahl('1234.56'), 'Punkt vor zwei Ziffern ist Dezimaltrenner');
$this->assertEquals(1500.0, KontenController::zahl('1.500'), 'Punkt vor drei Ziffern ist Tausendertrenner');
$this->assertEquals(1234567.0, KontenController::zahl('1.234.567'), 'Mehrere Punkte sind Tausendertrenner');
$this->assertEquals(0.5, KontenController::zahl('0.5'), 'Einzelne Nachkommastelle');
$this->assertEquals(0.0, KontenController::zahl(''), 'Leere Eingabe ergibt null');
$this->assertEquals(99.9, KontenController::zahl(' 99,90 € '), 'Währungszeichen und Leerzeichen stören nicht');
}
}

255
tests/08_QifImportTest.php Normal file
View file

@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\QifParser;
use Finanzmanager\Services\QifStammdatenImport;
use Finanzmanager\Controllers\ImportController;
/**
* Der Ausschnitt entspricht dem Aufbau des echten Exports: Kopfzeile mit
* Datumsformat, Klassen, Kategorien, die Kontenliste zwischen !Option:AutoSwitch
* und !Clear:AutoSwitch und danach ein Konto mit seinen Buchungen.
*/
class QifImportTest extends TestCase
{
protected string $testName = 'Test 08: QIF-Parser und Stammdatenimport';
public function run(): void
{
$this->mandantAnlegen();
$this->parserLiestSektionen();
$this->parserKonvertiertZeichensatz();
$this->betragUndDatum();
$this->stammdatenImport();
$this->zweiterLaufAendertNichts();
$this->trockenlaufSchreibtNicht();
$this->dateinamenPruefung();
}
private function beispiel(): string
{
$zeilen = [
'!Option:MDY',
'!Type:Class',
'NEhemann',
'DUnterscheidet Ausgaben einzelner Familienmitglieder',
'^',
'NPrivat',
'^',
'!Type:Cat',
'N100 - Gehalt',
'DGleason et al',
'T',
'I',
'^',
'N400 - Leben:B-Essen alleine',
'E',
'^',
'N400 - Leben:A-Krankheitskosten',
'DVorbeugung+Heilung',
'T',
'R16304',
'E',
'^',
'!Option:AutoSwitch',
'!Account',
'NDKB Girokonto alt',
'TBank',
'DKontonr. 1010903027',
'^',
'NADAC LBB Kreditkarte',
'TCCard',
'L3,000.00',
'^',
'NDepot comdirect',
'TPort',
'^',
'!Clear:AutoSwitch',
'!Option:AutoSwitch',
'!Account',
'NDKB Girokonto alt',
'TBank',
'DKontonr. 1010903027',
'^',
'!Type:Bank ',
'D3.18.26',
'T-1,241.04',
'PStadtwerke',
'L400 - Leben:B-Essen alleine/Privat',
'^',
];
// Der echte Export ist ISO-8859-1 mit CRLF – genau so wird hier geprüft.
return mb_convert_encoding(implode("\r\n", $zeilen) . "\r\n", 'ISO-8859-1', 'UTF-8');
}
private function parserLiestSektionen(): void
{
$parser = new QifParser($this->beispiel());
$this->assertEquals('MDY', $parser->datumsformat(), 'Datumsformat aus der Kopfzeile');
$this->assertEquals(2, count($parser->eintraege(QifParser::TYP_KLASSE)), 'Zwei Klassen');
$this->assertEquals(3, count($parser->eintraege(QifParser::TYP_KATEGORIE)), 'Drei Kategorien');
// Das Girokonto steht zweimal in der Datei: in der Kontenliste und als
// Kopf seiner Buchungen.
$this->assertEquals(4, count($parser->eintraege(QifParser::TYP_KONTO)), 'Vier Kontoeinträge');
$buchungen = $parser->eintraege('Bank');
$this->assertEquals(1, count($buchungen), 'Eine Buchung');
$this->assertEquals(
'DKB Girokonto alt',
$buchungen[0]['konto'],
'Die Buchung trägt das zuletzt genannte Konto'
);
$this->assertEquals('Stadtwerke', QifParser::feld($buchungen[0], 'P'), 'Zahlungsempfänger');
$kategorien = $parser->eintraege(QifParser::TYP_KATEGORIE);
$this->assert(QifParser::hatFeld($kategorien[0], 'I'), 'Erste Kategorie ist eine Einnahme');
$this->assert(QifParser::hatFeld($kategorien[0], 'T'), 'Erste Kategorie ist steuerrelevant');
$this->assert(!QifParser::hatFeld($kategorien[1], 'T'), 'Zweite Kategorie ist nicht steuerrelevant');
$this->assertEquals('16304', QifParser::feld($kategorien[2], 'R'), 'Steuerformular wird gelesen');
$this->assertEquals(
['Class' => 2, 'Cat' => 3, 'Account' => 4, 'Bank' => 1],
$parser->zusammenfassung(),
'Zusammenfassung zählt je Sektionstyp'
);
}
private function parserKonvertiertZeichensatz(): void
{
$iso = mb_convert_encoding("!Type:Class\r\nNRückstellungen\r\n^\r\n", 'ISO-8859-1', 'UTF-8');
$parser = new QifParser($iso);
$name = QifParser::feld($parser->eintraege(QifParser::TYP_KLASSE)[0], 'N');
$this->assertEquals('Rückstellungen', $name, 'ISO-8859-1 wird nach UTF-8 gewandelt');
// Eine bereits als UTF-8 vorliegende Datei darf nicht ein zweites Mal
// konvertiert werden.
$utf8 = new QifParser("!Type:Class\nNRückstellungen\n^\n");
$this->assertEquals(
'Rückstellungen',
QifParser::feld($utf8->eintraege(QifParser::TYP_KLASSE)[0], 'N'),
'UTF-8 bleibt unverändert'
);
}
private function betragUndDatum(): void
{
$this->assertEquals(-1241.04, QifParser::betrag('-1,241.04'), 'US-Format mit Tausenderkomma');
$this->assertEquals(3000.0, QifParser::betrag('3,000.00'), 'Kreditlimit');
$this->assertEquals(0.0, QifParser::betrag(null), 'Fehlender Betrag ist null');
$this->assertEquals('2026-03-18', QifParser::datum('3.18.26'), 'MDY mit zweistelligem Jahr');
$this->assertEquals('2026-01-02', QifParser::datum('1.2.26'), 'Einstellige Monats- und Tagesangabe');
$this->assertEquals('1998-12-31', QifParser::datum('12.31.98'), 'Jahr ab 70 liegt im letzten Jahrhundert');
$this->assertEquals('2026-03-18', QifParser::datum('18.3.26', 'DMY'), 'DMY wird berücksichtigt');
$this->assertNull(QifParser::datum('13.45.26'), 'Unmögliches Datum ergibt null');
$this->assertNull(QifParser::datum('Unfug'), 'Unlesbares Datum ergibt null');
}
private function stammdatenImport(): void
{
$ergebnis = (new QifStammdatenImport(new QifParser($this->beispiel())))->ausfuehren();
$this->assertEquals(2, $ergebnis['klassen']['neu'], 'Beide Klassen angelegt');
// Drei genannte Kategorien plus die Oberkategorie "400 - Leben",
// die im Export nur als Pfadbestandteil vorkommt.
$this->assertEquals(4, $ergebnis['kategorien']['neu'], 'Kategorien samt Zwischenstufe angelegt');
$this->assertEquals(3, $ergebnis['konten']['neu'], 'Drei Konten angelegt');
$this->assertEquals(1, $ergebnis['konten']['vorhanden'], 'Der zweite Eintrag desselben Kontos zählt als vorhanden');
$this->assertEquals(0, $ergebnis['kategorien']['fehler'], 'Keine Fehler');
$this->assertEquals([], $ergebnis['meldungen'], 'Kein Protokolleintrag');
$gehalt = Kategorie::findeNachPfad('100 - Gehalt');
$this->assertEquals('einnahme', $gehalt['art'], 'Einnahmekategorie erkannt');
$this->assertEquals(1, (int)$gehalt['steuerrelevant'], 'Steuerkennzeichen übernommen');
$this->assertEquals('Gleason et al', $gehalt['beschreibung'], 'Beschreibung übernommen');
$krank = Kategorie::findeNachPfad('400 - Leben:A-Krankheitskosten');
$this->assertEquals('16304', $krank['steuerformular'], 'Steuerformular übernommen');
$this->assertEquals('ausgabe', $krank['art'], 'Ausgabekategorie erkannt');
$oben = Kategorie::findeNachPfad('400 - Leben');
$this->assertNotEmpty($oben, 'Die Oberkategorie entstand selbsttätig');
$this->assertEquals('ausgabe', $oben['art'], 'Sie übernimmt die Art ihres Astes');
$giro = Konto::findeNachName('DKB Girokonto alt');
$this->assertEquals('bank', $giro['kontoart'], 'Kontoart aus dem QIF-Typ');
$this->assertEquals('1010903027', $giro['kontonummer'], 'Kontonummer aus der Beschreibung gezogen');
$this->assertEquals('', $giro['beschreibung'], 'Die reine Kontonummer bleibt nicht als Beschreibung stehen');
$karte = Konto::findeNachName('ADAC LBB Kreditkarte');
$this->assertEquals('kreditkarte', $karte['kontoart'], 'Kreditkarte erkannt');
$this->assertEquals('3000.00', $karte['kreditlimit'], 'Kreditlimit übernommen');
$this->assertEquals('depot', Konto::findeNachName('Depot comdirect')['kontoart'], 'Depot erkannt');
$this->assertNotEmpty(Klasse::findeNachName('Ehemann'), 'Klasse angelegt');
}
private function zweiterLaufAendertNichts(): void
{
$vorher = [Kategorie::anzahl(), Konto::anzahl(), Klasse::anzahl()];
$ergebnis = (new QifStammdatenImport(new QifParser($this->beispiel())))->ausfuehren();
$this->assertEquals(0, $ergebnis['kategorien']['neu'], 'Zweiter Lauf legt keine Kategorie an');
$this->assertEquals(0, $ergebnis['konten']['neu'], 'Zweiter Lauf legt kein Konto an');
$this->assertEquals(0, $ergebnis['klassen']['neu'], 'Zweiter Lauf legt keine Klasse an');
$this->assertEquals(3, $ergebnis['kategorien']['vorhanden'], 'Alle Kategorien als vorhanden gezählt');
$this->assertEquals(
$vorher,
[Kategorie::anzahl(), Konto::anzahl(), Klasse::anzahl()],
'Die Bestände sind unverändert'
);
}
private function trockenlaufSchreibtNicht(): void
{
$zusatz = "!Type:Class\r\nNProjekt 3\r\n^\r\n!Type:Cat\r\nNNeue Kategorie\r\nE\r\n^\r\n"
. "!Option:AutoSwitch\r\n!Account\r\nNNeues Konto\r\nTBank\r\n^\r\n!Clear:AutoSwitch\r\n";
$vorher = [Kategorie::anzahl(), Konto::anzahl(), Klasse::anzahl()];
$ergebnis = (new QifStammdatenImport(new QifParser($zusatz)))->vorschau();
$this->assert($ergebnis['trockenlauf'], 'Ergebnis ist als Trockenlauf gekennzeichnet');
$this->assertEquals(1, $ergebnis['klassen']['neu'], 'Vorschau zählt die neue Klasse');
$this->assertEquals(1, $ergebnis['kategorien']['neu'], 'Vorschau zählt die neue Kategorie');
$this->assertEquals(1, $ergebnis['konten']['neu'], 'Vorschau zählt das neue Konto');
$this->assertEquals(
$vorher,
[Kategorie::anzahl(), Konto::anzahl(), Klasse::anzahl()],
'Der Trockenlauf hat nichts gespeichert'
);
}
private function dateinamenPruefung(): void
{
$this->assertEquals(
'Export.QIF',
ImportController::sichererDateiname('Export.QIF'),
'Gültiger Dateiname bleibt erhalten'
);
$this->assertEquals(
'passwd.qif',
ImportController::sichererDateiname('../../etc/passwd.qif'),
'Pfadanteile werden abgeschnitten'
);
$this->assertNull(ImportController::sichererDateiname('böse.php'), 'Fremde Endung wird abgewiesen');
$this->assertNull(ImportController::sichererDateiname(''), 'Leerer Name wird abgewiesen');
$this->assertNull(ImportController::sichererDateiname('..'), 'Verzeichniswechsel wird abgewiesen');
$this->assertEquals(
'mein_export_2026.qif',
ImportController::sichererDateiname('mein export 2026.qif'),
'Leerzeichen werden ersetzt'
);
}
}

View file

@ -64,10 +64,27 @@ abstract class TestCase
// Der Rollback dreht nur die Datenbank zurück; im Einstellungs-Cache
// stünden sonst Werte, die es nicht mehr gibt.
\Finanzmanager\Models\Einstellung::cacheLeeren();
\Finanzmanager\Mandant::setze(null);
$this->raeumeTempPfadeAuf();
}
/**
* Legt einen Benutzer an und macht ihn zum Mandanten. Stammdatentests
* brauchen das: Konten, Kategorien und Klassen hängen an einer benutzer_id.
* Der Rollback in tearDown() räumt ihn wieder weg.
*/
protected function mandantAnlegen(): int
{
$id = \Finanzmanager\Models\Benutzer::anlegen(
'test_' . bin2hex(random_bytes(4)),
'testpasswort-' . bin2hex(random_bytes(4))
);
\Finanzmanager\Mandant::setze($id);
return $id;
}
// ── Assertions ───────────────────────────────────────────────────────────
protected function assert(bool $bedingung, string $beschreibung): void

View file

@ -0,0 +1,181 @@
// @ts-check
const { test, expect } = require('@playwright/test')
const BENUTZER = process.env.E2E_USER || 'e2e'
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
// Eindeutige Namen je Lauf: die Tests arbeiten auf einer bestehenden Instanz
// und dürfen sich nicht an Resten eines früheren Laufs stoßen.
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(/\/$/)
}
test.describe('Stammdaten', () => {
test.beforeEach(async ({ page }) => {
await anmelden(page)
})
test('Kontenübersicht ist erreichbar', async ({ page }) => {
await page.click('.seitenleiste a[href="/konten"]')
await expect(page.locator('h1')).toContainText('Kontenübersicht')
await expect(page.locator('a[href="/konten/neu"]').first()).toBeVisible()
})
test('Konto anlegen und in der Übersicht wiederfinden', async ({ page }) => {
const name = `E2E Girokonto ${MARKE}`
await page.goto('/konten/neu')
await page.fill('#name', name)
await page.selectOption('#kontoart', 'bank')
await page.fill('#anfangssaldo', '1.234,56')
await page.click('.formular button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
await page.goto('/konten')
const zeile = page.locator('tr', { hasText: name })
await expect(zeile).toContainText('Bank')
// Deutsches Betragsformat, geschütztes Leerzeichen vor dem Euro-Zeichen.
await expect(zeile).toContainText('1.234,56')
await expect(page.locator('.summenzeile')).toContainText('Summe Kontostände')
})
test('Kategorie mit Unterkategorie anlegen', async ({ page }) => {
const oben = `E2E Wohnen ${MARKE}`
const unten = `Strom ${MARKE}`
await page.goto('/verwaltung/kategorien/neu')
await page.fill('#name', oben)
await page.selectOption('#art', 'ausgabe')
await page.click('.formular button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
// Nach dem Anlegen steht man im Bearbeitungsformular der Kategorie.
await expect(page.locator('.feldwert code')).toContainText(oben)
await page.goto('/verwaltung/kategorien/neu')
await page.fill('#name', unten)
await page.selectOption('#eltern_id', { label: oben })
await page.click('.formular button[type="submit"]')
await expect(page.locator('.feldwert code')).toContainText(`${oben}:${unten}`)
await page.goto('/verwaltung/kategorien')
await expect(page.locator('tr', { hasText: oben }).first()).toContainText('Oberkategorie')
// Die Unterkategorie ist um eine Ebene eingerückt.
await expect(page.locator(`tr:has-text("${unten}") .baumname`))
.toHaveAttribute('style', /--ebene: 1/)
})
test('Register filtern nach Einnahmen und Ausgaben', async ({ page }) => {
const einnahme = `E2E Gehalt ${MARKE}`
await page.goto('/verwaltung/kategorien/neu')
await page.fill('#name', einnahme)
await page.selectOption('#art', 'einnahme')
await page.click('.formular button[type="submit"]')
await page.goto('/verwaltung/kategorien?register=einnahmen')
await expect(page.locator('.tab.aktiv')).toContainText('Einnahmen')
await expect(page.locator('tbody')).toContainText(einnahme)
await page.goto('/verwaltung/kategorien?register=ausgaben')
await expect(page.locator('tbody')).not.toContainText(einnahme)
})
test('Umbuchungskonten zeigen die Konten in eckigen Klammern', async ({ page }) => {
await page.goto('/verwaltung/kategorien?register=umbuchungskonten')
await expect(page.locator('.tab.aktiv')).toContainText('Umbuchungskonten')
await expect(page.locator('tbody a').first()).toContainText(/^\[.+\]$/)
})
test('Klasse anlegen und wieder löschen', async ({ page }) => {
const name = `E2E Projekt ${MARKE}`
await page.goto('/verwaltung/klassen')
await page.fill('.formular #name', name)
await page.fill('.formular #beschreibung', 'aus dem E2E-Test')
await page.click('.formular button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
// Die Liste ist bearbeitbar: Name und Beschreibung stehen in
// Eingabefeldern, nicht als Text – deshalb über den Attributwert suchen.
const zeile = page.locator('tr').filter({
has: page.locator(`input[name="name"][value="${name}"]`),
})
await expect(zeile).toHaveCount(1)
await expect(zeile.locator('input[name="beschreibung"]')).toHaveValue('aus dem E2E-Test')
page.once('dialog', (dialog) => dialog.accept())
await zeile.getByRole('button', { name: 'Löschen' }).click()
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
})
test('QIF-Trockenlauf zeigt an, was ankäme, ohne zu speichern', async ({ page }) => {
const dateiname = `e2e-probe-${MARKE}.qif`
const klasse = `E2E Klasse ${MARKE}`
const inhalt = [
'!Option:MDY',
'!Type:Class',
`N${klasse}`,
'^',
'!Type:Cat',
`NE2E Kategorie ${MARKE}`,
'E',
'^',
'!Option:AutoSwitch',
'!Account',
`NE2E Konto ${MARKE}`,
'TBank',
'^',
'!Clear:AutoSwitch',
'',
].join('\r\n')
await page.goto('/verwaltung/import')
await page.setInputFiles('#qif', {
name: dateiname,
mimeType: 'application/octet-stream',
buffer: Buffer.from(inhalt, 'latin1'),
})
await page.click('.formular button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toContainText('hochgeladen')
const zeile = page.locator('tr', { hasText: dateiname })
await zeile.getByRole('button', { name: 'Trockenlauf' }).click()
await expect(page.locator('.kachel h2').first()).toContainText('Trockenlauf')
const uebersicht = page.locator('.kachel table.liste').first()
await expect(uebersicht.locator('tr', { hasText: 'Klassen' })).toContainText('1')
await expect(uebersicht.locator('tr', { hasText: 'Konten' })).toContainText('1')
// Nichts gespeichert: die Klasse darf noch nicht in der Liste stehen.
await page.goto('/verwaltung/klassen')
await expect(page.locator('body')).not.toContainText(klasse)
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen.
await page.goto('/verwaltung/import')
page.once('dialog', (dialog) => dialog.accept())
await page.locator('tr', { hasText: dateiname })
.getByRole('button', { name: 'Löschen' }).click()
await expect(page.locator('body')).not.toContainText(dateiname)
})
})