Merge pull request 'Phase 0: Projektgerüst mit nginx, php-fpm und MariaDB' (#1) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/1
This commit is contained in:
commit
67f86bd360
57 changed files with 4192 additions and 6 deletions
13
.env.example
Normal file
13
.env.example
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Kopie dieser Datei als .env anlegen und Werte anpassen.
|
||||||
|
|
||||||
|
# Datenbank
|
||||||
|
DB_PASS=finanzmanager
|
||||||
|
DB_ROOT_PASS=rootpass
|
||||||
|
|
||||||
|
# Schlüssel für die Verschlüsselung von Bank-Zugangsdaten (Phase 5).
|
||||||
|
# Erzeugen mit: openssl rand -hex 32
|
||||||
|
APP_KEY=
|
||||||
|
|
||||||
|
# Debug-Log beim Start aktivieren (0/1). Lässt sich auch im
|
||||||
|
# Einstellungsdialog unter "Diagnose" umschalten.
|
||||||
|
DEBUG_LOG=0
|
||||||
21
.gitignore
vendored
21
.gitignore
vendored
|
|
@ -1,8 +1,19 @@
|
||||||
# ---> Composer
|
|
||||||
composer.phar
|
|
||||||
/vendor/
|
/vendor/
|
||||||
|
/data/mariadb/
|
||||||
|
/data/logs/
|
||||||
|
/data/import/
|
||||||
|
/backups/
|
||||||
|
/.env
|
||||||
|
/composer.lock.bak
|
||||||
|
|
||||||
# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
|
# Altdaten enthalten echte Kontodaten und gehören nicht ins Repository.
|
||||||
# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
|
/Altdaten/
|
||||||
# composer.lock
|
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# E2E-Artefakte (vom Container per Bind-Mount geschrieben)
|
||||||
|
tests/e2e/node_modules/
|
||||||
|
tests/e2e/playwright-report/
|
||||||
|
tests/e2e/test-results/
|
||||||
|
tests/e2e/.auth/
|
||||||
|
|
|
||||||
65
Aktienkurse.md
Normal file
65
Aktienkurse.md
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
Für den kostenlosen, tagesaktuellen Abruf von Aktienkursen mit ISIN-Codes (ca. 20 Positionen pro Tag) gibt es mehrere Möglichkeiten, die sowohl Registrierung als auch API-Key akzeptieren. Hier sind die besten Optionen für deinen Anwendungsfall:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1. **Leeway.tech**
|
||||||
|
- **Angebot:** 50 kostenlose Abfrageeinheiten pro Tag (ausreichend für 20 Positionen).
|
||||||
|
- **ISIN-Unterstützung:** Ja, direkte Suche nach ISIN möglich (z. B. `https://api.leeway.tech/api/v1/public/general/isin/DE000A1JX528?apitoken=YOUR_TOKEN`).
|
||||||
|
- **Daten:** Kursdaten mit 15–20 Minuten Verzögerung für rund 50.000 Aktien weltweit.
|
||||||
|
- **Registrierung:** Kostenlos, API-Key nach Anmeldung verfügbar.
|
||||||
|
- **Dokumentation:** [leeway.tech/data-api](https://leeway.tech/data-api)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **Financial Modeling Prep (FMP)**
|
||||||
|
- **Angebot:** 250 kostenlose Anfragen pro Tag (mehr als ausreichend für 20 Positionen).
|
||||||
|
- **ISIN-Unterstützung:** Ja, ISIN kann für die Suche verwendet werden.
|
||||||
|
- **Daten:** Echtzeit- und historische Kurse, Fundamentaldaten, Dividenden.
|
||||||
|
- **Registrierung:** Kostenlos, API-Key nach Anmeldung.
|
||||||
|
- **Dokumentation:** [fmpcloud.io](https://site.financialmodelingprep.com/de/developer/docs)
|
||||||
|
|
||||||
|
---
|
||||||
|
### 3. **Alpha Vantage**
|
||||||
|
- **Angebot:** 25 kostenlose Anfragen pro Tag (kann für 20 Positionen knapp werden, aber für Testzwecke geeignet).
|
||||||
|
- **ISIN-Unterstützung:** Indirekt über Symbol-Suche, ISIN muss ggf. manuell auf Symbol gemappt werden.
|
||||||
|
- **Daten:** Echtzeit- und historische Kurse, technische Indikatoren.
|
||||||
|
- **Registrierung:** Kostenlos, API-Key nach Anmeldung.
|
||||||
|
- **Dokumentation:** [www.alphavantage.co](https://www.alphavantage.co)
|
||||||
|
|
||||||
|
---
|
||||||
|
### 4. **Yahoo Finance (unoffiziell)**
|
||||||
|
- **Angebot:** Kein offizieller API-Key nötig, aber Nutzung der öffentlichen URL-Struktur (z. B. `https://query1.finance.yahoo.com/v8/finance/chart/ISIN?interval=1d`).
|
||||||
|
- **ISIN-Unterstützung:** Teilweise, aber nicht alle ISINs werden direkt unterstützt. Oft muss die ISIN auf ein Börsenkürzel gemappt werden.
|
||||||
|
- **Daten:** Aktuelle Kurse, aber mit Verzögerung und ohne Garantie auf Stabilität.
|
||||||
|
- **Hinweis:** Keine offizielle API, daher kann die Nutzung eingeschränkt oder blockiert werden .
|
||||||
|
|
||||||
|
---
|
||||||
|
### 5. **Marketstack**
|
||||||
|
- **Angebot:** 1.000 Anfragen pro Monat (kostenlos), also ca. 33 pro Tag.
|
||||||
|
- **ISIN-Unterstützung:** Ja, aber oft muss die ISIN auf ein Symbol gemappt werden.
|
||||||
|
- **Daten:** Echtzeit- und historische Kurse, globale Börsen.
|
||||||
|
- **Registrierung:** Kostenlos, API-Key nach Anmeldung.
|
||||||
|
- **Dokumentation:** [marketstack.com](https://marketstack.com)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. **Tradegate (URL-Abfrage)**
|
||||||
|
- **Angebot:** Kostenlose URL-Abfrage mit ISIN (z. B. für FileMaker oder eigene Skripte).
|
||||||
|
- **ISIN-Unterstützung:** Ja, direkte Abfrage mit ISIN möglich.
|
||||||
|
- **Daten:** Aktuelle Kurse, aber nur für Tradegate-gelistete Werte.
|
||||||
|
- **Hinweis:** Keine offizielle API, sondern manuelles Parsen der Webseite .
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfehlung für deinen Use Case (20 Positionen/Tag)
|
||||||
|
**Am besten geeignet:**
|
||||||
|
- **Leeway.tech** (50 Anfragen/Tag, direkte ISIN-Unterstützung, einfache Integration)
|
||||||
|
- **Financial Modeling Prep (FMP)** (250 Anfragen/Tag, ISIN-Unterstützung, sehr zuverlässig)
|
||||||
|
|
||||||
|
**Alternativen:**
|
||||||
|
- Alpha Vantage (wenn du mit 25 Anfragen/Tag auskommst)
|
||||||
|
- Marketstack (wenn du bis zu 33 Anfragen/Tag brauchst)
|
||||||
|
|
||||||
|
---
|
||||||
|
**Tipp:** Falls du die ISIN auf ein Börsenkürzel mappen musst, kannst du das oft über die API selbst (z. B. Leeway: `/isin/DE000A1JX528`) oder über kostenlose Online-Tools erledigen.
|
||||||
|
|
||||||
118
CLAUDE.md
Normal file
118
CLAUDE.md
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
# Finanzmanager – Entwicklungsregeln
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
PHP 8.3+, MariaDB, PSR-4 (Namespace `Finanzmanager\`), Composer, kein Framework.
|
||||||
|
MVC: `src/Controllers/`, `src/Models/`, `src/Services/`, `src/Views/`.
|
||||||
|
Webserver: **nginx + php-fpm**, alles in Docker Compose.
|
||||||
|
|
||||||
|
## Betriebsregeln
|
||||||
|
|
||||||
|
- **Keine lokale Installation ohne Rückfrage.** Kein PHP, kein Composer, kein MariaDB
|
||||||
|
auf dem Host – alles läuft in Containern.
|
||||||
|
- Lokal erreichbar unter **http://localhost:8000**.
|
||||||
|
- Der **Produktionsserver zieht den Code aus dem Upstream-Repository** und läuft
|
||||||
|
isoliert: kein Zugriff von außen, kein Zugriff durch Claude Code. Deployment dort
|
||||||
|
ausschließlich über `scripts/update.sh`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # starten
|
||||||
|
docker compose exec -u www-data php php tests/run.php # Tests
|
||||||
|
./scripts/run-tests.sh # dasselbe, kürzer
|
||||||
|
./scripts/update.sh # Update (Produktion)
|
||||||
|
./scripts/backup.sh # Backup mit Rotation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coding-Regeln
|
||||||
|
|
||||||
|
- Kein `var_dump`, kein `die()` in Produktionscode.
|
||||||
|
- **Neue Libraries nur nach expliziter Rückfrage.** Wenn eine Bibliothek wirklich
|
||||||
|
Arbeit spart, wird sie auf Codeberg geforkt und über ein VCS-Repository eingebunden –
|
||||||
|
keine Abhängigkeit von fremden Update-Zyklen.
|
||||||
|
- `declare(strict_types=1)`, 4 Leerzeichen Einrückung.
|
||||||
|
- SQL ausschließlich über `Database::fetchAll/fetchOne/fetchValue/execute()` mit
|
||||||
|
Prepared Statements.
|
||||||
|
- Bezeichner und Oberflächentexte auf Deutsch.
|
||||||
|
|
||||||
|
## Begrifflichkeiten – nicht abweichen
|
||||||
|
|
||||||
|
Die Partnerin nutzt weiterhin den Lexware FinanzManager. Alle fachlichen Begriffe
|
||||||
|
bleiben deshalb identisch:
|
||||||
|
|
||||||
|
Kategorie · Klasse · Splitbuchung · Umbuchung · Kontengruppe · Kontoblatt ·
|
||||||
|
Budgetplaner · Monatsabschluss · Auswertung · Buchungsvorlage · Regelmäßige Buchung ·
|
||||||
|
Zahlungsverkehr · Cockpit · Kontenübersicht · Umbuchungskonto
|
||||||
|
|
||||||
|
Keine eigenen Wortschöpfungen, keine englischen Entsprechungen.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
**Regel: Jedes neue Feature bekommt einen eigenen Test.**
|
||||||
|
|
||||||
|
Tests liegen in `tests/` nach dem Schema `NN_NameTest.php`, jede Klasse erweitert
|
||||||
|
`TestCase` und implementiert `run()`. Transaktions-Isolation ist automatisch.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$this->assert(bool, 'Beschreibung');
|
||||||
|
$this->assertEquals($erwartet, $ist, 'Beschreibung');
|
||||||
|
$this->assertNotEmpty($wert, 'Beschreibung');
|
||||||
|
$this->assertNull($wert, 'Beschreibung');
|
||||||
|
$this->assertGreaterThan($minimum, $wert, 'Beschreibung');
|
||||||
|
```
|
||||||
|
|
||||||
|
Dateien und Verzeichnisse immer über `$this->tempPfad()` bzw.
|
||||||
|
`$this->registriereTempPfad()` anlegen – der Rollback greift nur in der Datenbank.
|
||||||
|
|
||||||
|
**Immer `-u www-data`:** sonst laufen die Tests als root und legen unter `data/`
|
||||||
|
Dateien an, die php-fpm später nicht mehr beschreiben kann.
|
||||||
|
|
||||||
|
### E2E-Tests
|
||||||
|
|
||||||
|
Browser-Abläufe gehören nach `tests/e2e/` (Playwright über das Projekt `e2e-runner`,
|
||||||
|
läuft im Container – auf dem Host wird nichts installiert). Rechen- und Model-Logik
|
||||||
|
gehört in die PHP-Suite: schneller und isoliert.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/e2e/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Datenbankmigrationen
|
||||||
|
|
||||||
|
Schemaänderungen kommen **ausschließlich** als nummerierte Datei nach `migrations/`:
|
||||||
|
|
||||||
|
- `NNN_kurze_beschreibung.sql` (nächste freie dreistellige Nummer)
|
||||||
|
- Nie manuell einspielen – `scripts/migrate.php` erledigt das beim Containerstart
|
||||||
|
- Jede Migration idempotent (`IF NOT EXISTS`, `ON DUPLICATE KEY`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls migrations/ | tail -1 # nächste freie Nummer
|
||||||
|
docker compose exec php php scripts/migrate.php # manuell testen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Diagnose
|
||||||
|
|
||||||
|
Das Debug-Log wird im Einstellungsdialog unter *Diagnose* eingeschaltet (oder über
|
||||||
|
`DEBUG_LOG=1`). Es schreibt nach `data/logs/debug-JJJJ-MM-TT.log` und maskiert PINs,
|
||||||
|
Passwörter, TANs und API-Keys. Im Normalbetrieb ausgeschaltet lassen.
|
||||||
|
|
||||||
|
Neue Einstellungen gehören in den **zentralen Einstellungsdialog** (ein Reiter je
|
||||||
|
Bereich aus `Einstellung::BEREICHE`), nicht in eigene Dialoge.
|
||||||
|
|
||||||
|
## Dokumentation aktuell halten
|
||||||
|
|
||||||
|
| Datei | Wann aktualisieren |
|
||||||
|
|---|---|
|
||||||
|
| `Changes.md` | Bei jedem Feature, Bugfix oder strukturellen Änderung |
|
||||||
|
| `PLAN.md` | Wenn sich Phasenzuschnitt oder Grundentscheidungen ändern |
|
||||||
|
| `README.md` | Bei Änderungen an Funktionsumfang oder Tech Stack |
|
||||||
|
| `docs/` | Wenn ein Modul hinzukommt oder sich wesentlich ändert |
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
Codeberg, nicht GitHub. Upstream = persönlicher Account, Fork = BOT-Account
|
||||||
|
(`CODEBERG_BOT_TOKEN`). Keine Feature-Branches, direkt auf `main` im Fork.
|
||||||
|
Ein Feature = ein PR. **Vor jedem Commit prüfen, ob upstream noch ein offener PR
|
||||||
|
vom Fork existiert** – sonst vermischen sich Features.
|
||||||
|
|
||||||
|
```
|
||||||
|
Fix #NR: Kurze Beschreibung der Änderung
|
||||||
|
```
|
||||||
34
Changes.md
Normal file
34
Changes.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Änderungen
|
||||||
|
|
||||||
|
## Phase 0 – Projektgerüst (09.08.2026)
|
||||||
|
|
||||||
|
Grundgerüst der Anwendung. Fachliche Module folgen ab Phase 1.
|
||||||
|
|
||||||
|
**Betrieb**
|
||||||
|
- Docker Compose mit drei Services: `nginx` (Port 8000), `php` (php-fpm 8.3), `db`
|
||||||
|
(MariaDB 11)
|
||||||
|
- `docker-entrypoint.sh` legt Datenverzeichnisse an und spielt Migrationen ein
|
||||||
|
- `scripts/migrate.php` – nummerierte SQL-Migrationen, idempotent, mit Journal-Tabelle
|
||||||
|
- `scripts/update.sh` – Update für den Produktionsserver: Backup, `git pull`,
|
||||||
|
Image-Neubau bei Bedarf, Migrationen, Rechte, Tests
|
||||||
|
- `scripts/backup.sh` – Datenbank- und Dateisicherung mit Rotation (7 Tage / 4 Wochen /
|
||||||
|
12 Monate), gegen Parallelläufe per `flock` abgesichert
|
||||||
|
|
||||||
|
**Anwendung**
|
||||||
|
- `Config`, `Database` (PDO-Wrapper, verschachtelbare Transaktionen), `Router`
|
||||||
|
(Platzhalter-Routen, CSRF-Prüfung für alle verändernden Requests), `View`, `Auth`
|
||||||
|
- Anmeldung mit Session-Härtung, Ersteinrichtung des ersten Benutzers
|
||||||
|
- Zentraler Einstellungsdialog mit Reitern (Allgemein, Darstellung, Buchhaltung,
|
||||||
|
Online-Banking, Import/Export, Wertpapiere, Diagnose); Werte in Tabelle
|
||||||
|
`einstellungen`
|
||||||
|
- Debug-Log, ein- und ausschaltbar, mit Maskierung von PINs, Passwörtern, TANs und
|
||||||
|
API-Keys; Ausnahmen werden immer protokolliert
|
||||||
|
- Layout nach Vorbild des FinanzManagers: feste Navigation links, Werkzeugleiste oben,
|
||||||
|
Tab-Leiste; Farbschema hell/dunkel/System
|
||||||
|
|
||||||
|
**Tests**
|
||||||
|
- Test-Harness mit Transaktions-Rollback und Aufräumen temporärer Pfade
|
||||||
|
- `01_AuthTest` – Anmeldung, Passwort-Hashing, CSRF, Cookie-Attribute
|
||||||
|
- `02_EinstellungenTest` – Lesen/Schreiben, Cache-Invalidierung, Bereichsabdeckung
|
||||||
|
- `03_RouterTest` – Pfad-Muster, Platzhalter, keine Treffer über Pfadgrenzen
|
||||||
|
- `04_DebugLogTest` – Schalter, Maskierung, Fehlerprotokollierung
|
||||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
FROM php:8.3-fpm
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libzip-dev \
|
||||||
|
unzip \
|
||||||
|
git \
|
||||||
|
&& docker-php-ext-install pdo pdo_mysql zip \
|
||||||
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
# Zuerst nur die Composer-Dateien: hält den Layer-Cache stabil, solange sich
|
||||||
|
# die Abhängigkeiten nicht ändern.
|
||||||
|
COPY composer.json composer.lock* ./
|
||||||
|
RUN composer install --no-dev --no-interaction --optimize-autoloader --no-scripts
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
COPY php/php.ini /usr/local/etc/php/conf.d/finanzmanager.ini
|
||||||
|
|
||||||
|
RUN mkdir -p /var/www/html/data/logs /var/www/html/data/import \
|
||||||
|
&& chown -R www-data:www-data /var/www/html
|
||||||
|
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 9000
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
CMD ["php-fpm"]
|
||||||
324
PLAN.md
Normal file
324
PLAN.md
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
# Projektplan: Finanzmanager (Web)
|
||||||
|
|
||||||
|
> Stand: 09.08.2026 — Arbeitsgrundlage, gedacht zum Überarbeiten. Änderungen einfach direkt
|
||||||
|
> in dieser Datei vornehmen; die Umsetzung richtet sich nach dem hier festgehaltenen Stand.
|
||||||
|
|
||||||
|
## 1. Kontext
|
||||||
|
|
||||||
|
Der Lexware **FinanzManager Deluxe 2026** läuft heute in einer Windows-VM unter QEMU und
|
||||||
|
verwaltet die privaten Finanzen (Datei `FELIX2018.QDF`). Er soll durch eine selbst gebaute
|
||||||
|
Web-Anwendung ersetzt werden, die auf einem Debian-Server läuft und vom Browser aus
|
||||||
|
bedient wird — kein Windows, keine VM, keine Client-Installation.
|
||||||
|
|
||||||
|
Randbedingungen aus der Aufgabenstellung:
|
||||||
|
|
||||||
|
* **Haushaltsgemeinschaft:** Die Partnerin nutzt den Lexware FinanzManager weiter. Alle
|
||||||
|
fachlichen Begriffe bleiben deshalb bewusst identisch (Kategorie, Klasse, Splitbuchung,
|
||||||
|
Umbuchung, Kontengruppe, Budgetplaner, Monatsabschluss, Auswertung, Buchungsvorlage,
|
||||||
|
Regelmäßige Buchung, Kontoblatt, Cockpit …). Keine eigenen Wortschöpfungen.
|
||||||
|
* **Optik modern, Layout vertraut:** linke Navigationsleiste mit denselben Menüpunkten,
|
||||||
|
obere Werkzeugleiste, Tab-Leiste, tabellarische Kontoblätter — nur zeitgemäß gestaltet.
|
||||||
|
* **Minimale Fremdabhängigkeiten:** Bibliotheken nur, wo sie echte Arbeit sparen, und dann
|
||||||
|
als eigener Fork unter eigener Kontrolle. Keine Abhängigkeit von fremden Update-Zyklen.
|
||||||
|
* **Wiederverwendung:** Die FinTS-Anbindung aus dem Parallelprojekt `Steuerkram`
|
||||||
|
(`/home/fds/projects/Steuerkram`) wird übernommen, nicht neu erfunden.
|
||||||
|
|
||||||
|
### Ergebnis der Voranalyse (wichtig für die Planung)
|
||||||
|
|
||||||
|
| Befund | Konsequenz |
|
||||||
|
|---|---|
|
||||||
|
| `FELIX2018.QDF` ist **vollständig verschlüsselt** (Entropie ≈ 8 bit/Byte über alle 258 Blöcke, keine Klartext-Treffer für Kategorie- oder Kontonamen) | Aus der QDF ist nichts zu holen. Datenquelle ist der **QIF-Export**. |
|
||||||
|
| `FELIX2018.QSD` enthält im Klartext nur Bankleitzahlen, Spaltenlayouts und Berichtsnamen | Nur als Referenz für Berichtsdefinitionen brauchbar, keine Stammdaten. |
|
||||||
|
| `Export-Migration-Beispiel.QIF` (342 KB, ISO-8859-1, CRLF) enthält alles Nötige: 17 Klassen, 105 Kategorien, 37 Konten, 572 Bank-, 154 Sonstige-, 16 Wertpapierbuchungen, 579 Buchungsvorlagen | **Der QIF-Import ist der Migrationsweg.** |
|
||||||
|
| Der Beispielexport umfasst nur 02.01.–07.08.2026 und enthält **keine Eröffnungssalden** | Für die echte Migration braucht es einen Vollexport über alle Jahre; Anfangssalden werden je Konto aus der Kontenübersicht ergänzt. |
|
||||||
|
| **Budgets sind im QIF nicht enthalten** | Budgetdaten müssen separat aus dem Budgetplaner exportiert (Export-Symbol oben rechts) oder einmalig neu erfasst werden. |
|
||||||
|
| `nemiah/php-fints` hängt nur an `psr/log` | Sehr schlank — als Fork gut beherrschbar. |
|
||||||
|
|
||||||
|
## 2. Fachliches Datenmodell
|
||||||
|
|
||||||
|
Begriffe entsprechen dem FinanzManager. Zwei Auswertungsdimensionen: **Kategorie**
|
||||||
|
(hierarchisch, `:` als Trenner) und **Klasse** (flach, im QIF mit `/` an die Kategorie
|
||||||
|
gehängt).
|
||||||
|
|
||||||
|
**Stammdaten**
|
||||||
|
|
||||||
|
* `konten` — Kontoname, Kontoart, Kontengruppe, Bankname, Kontonummer/IBAN, Beschreibung,
|
||||||
|
Anfangssaldo, Kreditlimit, aktiv/inaktiv, Sortierung.
|
||||||
|
Kontoarten aus dem Export (QIF-Typ → Bezeichnung im FinanzManager):
|
||||||
|
`Bank`→Bank, `CCard`→Kreditkarte, `Cash`→Bar, `Oth T`→Tagesgeld/Sparkonto,
|
||||||
|
`Oth A`→Vermögen, `Oth L`→Verbindlichkeit, `Oth P`→Rentenversicherung,
|
||||||
|
`Oth E`/`Oth F`→Sonstiges Vermögen, `Port`/`Invst`→Depot (Wertpapier).
|
||||||
|
* `kontengruppen` — z. B. „Anlage", „Nicht gruppiert" (Gruppierung der Kontenübersicht).
|
||||||
|
* `kategorien` — Name, Elternkategorie (Selbstreferenz), Art (`einnahme`/`ausgabe`),
|
||||||
|
steuerrelevant (QIF-Feld `T`), Beschreibung, aktiv. Konten erscheinen zusätzlich als
|
||||||
|
**Umbuchungskonten** in der Kategorieauswahl — das wird in der UI abgebildet, nicht als
|
||||||
|
Datensatz dupliziert.
|
||||||
|
* `klassen` — Name, Beschreibung.
|
||||||
|
|
||||||
|
**Bewegungsdaten**
|
||||||
|
|
||||||
|
* `buchungen` — Datum, Konto, Betrag, Zahlungsempfänger (`P`), Buchungstext/Notiz (`M`),
|
||||||
|
Nummer (`N`), Status abgeglichen (`C`), Kategorie **oder** Gegenkonto (Umbuchung),
|
||||||
|
Klasse, Quelle (manuell/QIF-Import/FinTS), externe Referenz für die Dublettenerkennung.
|
||||||
|
* `buchung_splits` — Kategorie, Klasse, Betrag, Notiz je Splitzeile (QIF `S`/`E`/`$`).
|
||||||
|
Eine Buchung ist entweder einfach kategorisiert oder gesplittet; die Summe der Splits
|
||||||
|
muss dem Buchungsbetrag entsprechen (Prüfung im Model + Test).
|
||||||
|
* `buchungsadressen` — die bis zu 6 `A`-Zeilen einer Buchung (Empfängeranschrift).
|
||||||
|
|
||||||
|
**Planung**
|
||||||
|
|
||||||
|
* `budgets` — Name (z. B. „2026-ecclesia"), Jahr, Beschreibung. Mehrere Budgets pro Jahr
|
||||||
|
sind möglich, eines ist aktiv.
|
||||||
|
* `budget_positionen` — Budget, Kategorie, Monat (1–12), Betrag. Der Budgetplaner rechnet
|
||||||
|
Ist-Werte, Saldo und Jahressumme zur Laufzeit aus den Buchungen.
|
||||||
|
* `monatsabschluesse` — Jahr, Monat, Zeitpunkt, Ersteller, Status, JSON-Snapshot der
|
||||||
|
Kontosalden und Kategoriesummen. Ein abgeschlossener Monat sperrt Buchungen in diesem
|
||||||
|
Zeitraum (Wiedereröffnung möglich, wird protokolliert).
|
||||||
|
|
||||||
|
**Online-Banking** (Struktur aus `Steuerkram` übernommen)
|
||||||
|
|
||||||
|
* `bank_providers`, `bank_sources` (Zugangsdaten AES-verschlüsselt, `Crypto`-Klasse),
|
||||||
|
`bank_transactions` (Rohumsätze vor dem Abgleich), `abgleich_regeln` (Regelverwaltung:
|
||||||
|
Muster auf Empfänger/Verwendungszweck → Kategorie/Klasse/Split).
|
||||||
|
|
||||||
|
**Wertpapiere**
|
||||||
|
|
||||||
|
* `wertpapiere` — Name (`Y`), WKN (`~`), ISIN (`@`), Typ (`&`), Währung.
|
||||||
|
* `depot_buchungen` — Datum, Valuta (`V`), Vorgang (`N`: Kauf, Verkauf, DivIn, ReinvDiv …),
|
||||||
|
Stück (`Q`), Kurs (`I`), Betrag (`U`), Gebühren/Steuern (`O`, 12 Felder, `|`-getrennt),
|
||||||
|
Verrechnungskonto (`L[Konto]`).
|
||||||
|
* `kurse` — Wertpapier, Datum, Kurs.
|
||||||
|
|
||||||
|
**Nebenmodule**
|
||||||
|
|
||||||
|
* `buchungsvorlagen` (QIF `!Type:Memorized`), `regelmaessige_buchungen`, `erinnerungen`,
|
||||||
|
`adressen`, `vertraege`, `berichte` (gespeicherte Auswertungen mit Filter-JSON).
|
||||||
|
|
||||||
|
## 3. Technische Architektur
|
||||||
|
|
||||||
|
Bewusst nah an `Steuerkram`, damit Muster, Erfahrung und Code direkt übertragbar sind —
|
||||||
|
aber mit **nginx statt Apache**.
|
||||||
|
|
||||||
|
### Verbindliche Betriebsregeln
|
||||||
|
|
||||||
|
* **Keine lokale Installation ohne Rückfrage.** Alles läuft und wird getestet in
|
||||||
|
Docker-Compose-Containern — kein PHP, kein Composer, kein MariaDB auf dem Host.
|
||||||
|
* **Erreichbarkeit lokal: `http://localhost:8000`** (nginx-Port-Mapping `8000:80`).
|
||||||
|
* **Produktionsserver zieht den Code aus dem Upstream-Repository.** Er läuft isoliert,
|
||||||
|
ohne Zugriff von außen und ohne Zugriff durch Claude Code. Deployment ist dort
|
||||||
|
ausschließlich `git pull` + Update-Skript, niemals ein direkter Eingriff von hier.
|
||||||
|
* **Update-Prozess wie in `Steuerkram`**: `scripts/update.sh` (git pull → Backup →
|
||||||
|
Composer → Image-Rebuild bei Bedarf → Migrationen → Rechte → optionale Tests) plus
|
||||||
|
`scripts/backup.sh` mit Rotation (7 Tage / 4 Wochen / 12 Monate).
|
||||||
|
* **Ein-/ausschaltbares Debug-Log** zur Fehlerrekonstruktion: `src/Services/DebugLog.php`,
|
||||||
|
Schalter in den Einstellungen, Ausgabe nach `data/logs/debug-JJJJ-MM-TT.log` mit
|
||||||
|
Kontext (Request, Nutzer, SQL-Dauer, FinTS-Dialogschritte). Standardmäßig aus,
|
||||||
|
automatische Rotation, Zugangsdaten und PINs werden maskiert.
|
||||||
|
* **Einstellungen zentral in einem einzigen Dialog** mit Reitern (Allgemein, Darstellung,
|
||||||
|
Buchhaltung, Online-Banking, Import/Export, Wertpapiere, Diagnose/Debug) statt über die
|
||||||
|
Module verstreut. Persistenz in einer Tabelle `einstellungen` (Schlüssel/Wert), gelesen
|
||||||
|
über `Setting::get()/set()`.
|
||||||
|
* **Ein Benutzer, aber mandantenfähiges Datenmodell.** Kernobjekte (Konten, Kategorien,
|
||||||
|
Klassen, Buchungen, Budgets) führen von Anfang an `benutzer_id`. Die Oberfläche ist
|
||||||
|
einbenutzerig; die spätere Mitnutzung durch die Partnerin bleibt so ohne Migration möglich.
|
||||||
|
|
||||||
|
### Bausteine
|
||||||
|
|
||||||
|
* **PHP 8.3**, `declare(strict_types=1)`, PSR-4, Namespace `Finanzmanager\`, kein Framework.
|
||||||
|
* **Eigenes MVC**: `src/Controllers/`, `src/Models/`, `src/Services/`, `src/Views/`,
|
||||||
|
zentrale `Router.php`, `Database.php` (PDO, ausschließlich Prepared Statements über
|
||||||
|
`fetchAll/fetchOne/execute`), `Auth.php`, `View.php`, `Config.php`.
|
||||||
|
* **Docker Compose, drei Services:**
|
||||||
|
* `nginx` (Alpine) — Reverse Proxy auf php-fpm, `public/` als Document Root,
|
||||||
|
Port-Mapping **`8000:80`**
|
||||||
|
* `php` (`php:8.3-fpm`) — Anwendungscode, Composer, Cron für geplante Abrufe
|
||||||
|
* `db` (`mariadb:11`) — Daten unter `data/mariadb`
|
||||||
|
* **Migrationen** als nummerierte SQL-Dateien in `migrations/` (`001_…`), idempotent,
|
||||||
|
eingespielt von `scripts/migrate.php` beim Containerstart — genau wie in Steuerkram.
|
||||||
|
* **Frontend ohne Framework:** serverseitig gerendertes HTML, Vanilla JS für
|
||||||
|
Tabellen-Interaktion, Splitbuchungsmaske und Inline-Bearbeitung im Budgetplaner.
|
||||||
|
CSS mit Custom Properties (Light/Dark), Layout: feste linke Navigation, obere
|
||||||
|
Werkzeugleiste, Tab-Leiste, dichte Datentabellen.
|
||||||
|
* **Diagramme als selbst erzeugtes SVG** (Balken, Linie, Torte) in
|
||||||
|
`src/Services/ChartRenderer.php` — keine Chart-Bibliothek. Deckt Vermögensentwicklung,
|
||||||
|
Einnahmen/Ausgaben und Budgetgrafik ab.
|
||||||
|
|
||||||
|
### Umgang mit Bibliotheken
|
||||||
|
|
||||||
|
| Zweck | Vorgehen |
|
||||||
|
|---|---|
|
||||||
|
| FinTS-Bankabruf | `nemiah/php-fints` (nur Abhängigkeit `psr/log`) **auf Codeberg forken** (BOT-Account), in `composer.json` als VCS-Repository auf den Fork zeigen, Version fest gepinnt. `psr/log` ist ein reines Interface-Paket und wird direkt in den Fork übernommen. |
|
||||||
|
| PDF-Export von Berichten | Vorerst **nicht**. Berichte gehen als CSV/HTML raus, Papier über die Druckfunktion des Browsers. mPDF (mit ~10 Transitiv-Abhängigkeiten) nur, falls sich das als unzureichend erweist — dann ebenfalls als Fork. |
|
||||||
|
| QIF-Parser, CSV-Import, Charts, Auth, Routing, Templates | Eigenimplementierung. Alles überschaubar, kein Bibliotheksbedarf. |
|
||||||
|
|
||||||
|
## 4. Der QIF-Import (kritischster Baustein)
|
||||||
|
|
||||||
|
Das Lexware-QIF ist erweitertes Quicken-QIF. Fallstricke, die der Parser beherrschen muss:
|
||||||
|
|
||||||
|
1. **Zustandsbehaftet und sequenziell.** `!Option:AutoSwitch` leitet die Kontenliste ein,
|
||||||
|
`!Clear:AutoSwitch` beendet sie. Danach wechseln sich `!Account` (genau ein Konto) und
|
||||||
|
`!Type:…` (dessen Buchungen) ab. Die Datei darf **nicht** nach Sektionstypen gruppiert
|
||||||
|
gelesen werden — der Kontobezug steckt allein in der Reihenfolge.
|
||||||
|
2. **Datumsformat `MDY`** laut Kopfzeile: `D3.18.26` = 18.03.2026, zweistelliges Jahr.
|
||||||
|
3. **Beträge im US-Format**: `-1,241.04`. Es gibt `T` und `U` (identischer Wert, `U` mit
|
||||||
|
höherer Genauigkeit) — `U` gewinnt.
|
||||||
|
4. **Zeichensatz ISO-8859-1**, Zeilenenden CRLF → Konvertierung nach UTF-8.
|
||||||
|
5. **Kategorie/Klasse in einer Zeile**: `L400 - Leben:B-Essen alleine/Privat`.
|
||||||
|
Umbuchung: `L[DKB Girokonto alt]`, ebenfalls mit optionaler Klasse.
|
||||||
|
6. **Splitbuchungen** als Tripel `S` (Kategorie), `E` (Notiz), `$` (Betrag) — mehrfach je
|
||||||
|
Buchung.
|
||||||
|
7. **Umbuchungen erscheinen doppelt** (einmal je beteiligtem Konto) und müssen zu *einer*
|
||||||
|
Buchung zusammengeführt werden — sonst zählt jeder Übertrag zweimal.
|
||||||
|
8. **Wertpapierbuchungen** (`!Type:Invst`) mit `Y`/`~`/`@`/`&`/`I`/`Q`/`O`/`B`; `O` ist ein
|
||||||
|
Array aus 12 `|`-getrennten Beträgen (Gebühren, Steuern, Zuschläge).
|
||||||
|
9. **Idempotenz:** Wiederholter Import derselben Datei darf keine Dubletten erzeugen
|
||||||
|
(Hash aus Konto + Datum + Betrag + Nummer + Buchungstext).
|
||||||
|
|
||||||
|
Der Import läuft als CLI-Skript **und** über eine Weboberfläche mit Vorschau, Trockenlauf
|
||||||
|
und Protokoll (importiert / übersprungen / fehlerhaft je Zeile). Nach dem Import wird der User pro Konto gefragt, ob der jeweilige Eröffnungssaldo beibehalten oder geändert werden soll. Der User kann den Saldo jeweils eintragen oder übernehmen, wie er aktuell ist.
|
||||||
|
|
||||||
|
## 5. Phasen
|
||||||
|
|
||||||
|
### Phase 0 — Projektgerüst
|
||||||
|
* 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`,
|
||||||
|
`scripts/migrate.php`, `scripts/update.sh`, `scripts/backup.sh`.
|
||||||
|
* Grundgerüst: `Router`, `Database`, `Config`, `View`, `Auth` (Login, Session, CSRF-Token,
|
||||||
|
Passwort-Hash), Basis-Layout mit linker Navigation und Werkzeugleiste.
|
||||||
|
* **Zentraler Einstellungsdialog** mit Reitern und Tabelle `einstellungen` — von Anfang an,
|
||||||
|
damit spätere Module ihre Optionen dort einhängen statt eigene Dialoge zu bauen.
|
||||||
|
* **Debug-Log** (`DebugLog`, Schalter im Reiter Diagnose, Rotation, Maskierung von Secrets).
|
||||||
|
* Test-Harness nach Steuerkram-Vorbild (`tests/run.php`, `TestCase` mit Transaktions-Rollback).
|
||||||
|
* **e2e-runner einbinden** (`tests/e2e/`, Playwright im Container) — ab jetzt bekommt jedes
|
||||||
|
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
|
||||||
|
* 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`.
|
||||||
|
* *Ergebnis:* 17 Klassen, 105 Kategorien und 37 Konten sind aus dem Export übernommen und
|
||||||
|
in der Oberfläche gepflegt.
|
||||||
|
|
||||||
|
### Phase 2 — Buchungen
|
||||||
|
* Migrationen `buchungen`, `buchung_splits`, `buchungsadressen`.
|
||||||
|
* **Kontoblatt** je Konto: Tabelle mit Datum, Nummer, Empfänger, Buchungstext, Kategorie,
|
||||||
|
Klasse, Betrag, Saldo, Abgleich-Status; Sortierung, Filter, Volltextsuche.
|
||||||
|
* Buchungsmaske: Einnahme / Ausgabe / Umbuchung, **Splitbuchung** mit Restbetrags-Anzeige,
|
||||||
|
Kategorie- und Klassenauswahl mit Typeahead, Empfänger-Autovervollständigung.
|
||||||
|
* Saldenberechnung (aktuell / erwartet / abgeglichen), Umbuchungen als eine Buchung mit
|
||||||
|
zwei Sichten.
|
||||||
|
* QIF-Import erweitert um `!Type:Bank|CCard|Cash|Oth *` inkl. Splits, Umbuchungs-Deduplizierung
|
||||||
|
und Import-Oberfläche mit Trockenlauf.
|
||||||
|
* **Eröffnungssaldo-Dialog** im Anschluss an den Import: je Konto den errechneten Saldo
|
||||||
|
anzeigen, übernehmen oder überschreiben; die Differenz wird als Anfangssaldo gebucht.
|
||||||
|
* *Ergebnis:* Der vollständige Altbestand ist migriert, Salden stimmen mit dem
|
||||||
|
FinanzManager überein (Abgleich gegen die Kontenübersicht als Test).
|
||||||
|
|
||||||
|
### Phase 3 — Budget und Monatsabschluss
|
||||||
|
* Migrationen `budgets`, `budget_positionen`, `monatsabschluesse`.
|
||||||
|
* **Budgetplaner** wie im Original: Zeilen = Kategoriebaum (auf-/zuklappbar), Spalten =
|
||||||
|
Gesamt-Saldo, abgelaufene Monate zusammengefasst, aktueller Monat mit Budget/Ist/Saldo,
|
||||||
|
Folgemonate mit Budget. Inline-Bearbeitung, Wert auf alle Folgemonate übertragen,
|
||||||
|
Kopieren eines Budgets ins neue Jahr, Alarm bei Überschreitung, Gesamtzeile.
|
||||||
|
* **Monatsabschluss**: Prüflauf (nicht kategorisierte Buchungen, nicht abgeglichene Umsätze,
|
||||||
|
Splitsummen-Differenzen), Snapshot der Salden, Sperre des Zeitraums, Wiedereröffnung.
|
||||||
|
* *Ergebnis:* Jahresbudget zum Jahreswechsel planbar, Monatsabschluss durchführbar.
|
||||||
|
|
||||||
|
### Phase 4 — Auswertungen und Cockpit
|
||||||
|
* Berichtsmodul mit gespeicherten Berichten und Berichtsgruppen (wie „Bankgeschäfte",
|
||||||
|
„Planung", „Steuern"): **Summenbericht, Buchungsbericht, Vergleichsbericht,
|
||||||
|
Kontostandsbericht, Budgetbericht**, jeweils mit Zeitraum, Spalten-Aufteilung,
|
||||||
|
Zeilen-Summe (Kategorie/Klasse/Konto/Empfänger), Filter auf Konten/Kategorien/Klassen.
|
||||||
|
* Grafiken als SVG: Einnahmen/Ausgaben je Monat, Vermögensentwicklung, Ausgaben nach
|
||||||
|
Kategorie.
|
||||||
|
* Export CSV; Drilldown von der Berichtszeile in die zugrunde liegenden Buchungen.
|
||||||
|
* **Cockpit** mit konfigurierbaren Kacheln (Vermögensentwicklung, Einnahmen/Ausgaben,
|
||||||
|
Meine Ausgaben, Kontostände, offene Vorgänge).
|
||||||
|
* *Ergebnis:* Die bisher genutzten Berichte sind nachgebaut und liefern dieselben Zahlen.
|
||||||
|
|
||||||
|
### Phase 5 — Online-Banking (FinTS)
|
||||||
|
* Fork von `nemiah/php-fints` auf Codeberg, Einbindung über VCS-Repository.
|
||||||
|
* Übernahme aus Steuerkram: `Crypto`, `BankSource` (verschlüsselte Zugangsdaten,
|
||||||
|
`computeStartDate`, `account_filter`), `BankProvider`, FinTS-Abrufdienst,
|
||||||
|
Abruf-Protokoll.
|
||||||
|
* **TAN-Handling** wird aus Steuerkram übernommen, wo es bereits läuft — es wird schon für
|
||||||
|
den reinen Kontoabruf gebraucht (nicht erst für Überweisungen): TAN-Verfahren je Zugang,
|
||||||
|
Dialog-Fortsetzung über mehrere Requests, pushTAN/photoTAN/chipTAN-Anzeige.
|
||||||
|
* **Verwaltung → Bankverbindungen**: Zugänge anlegen, Konten zuordnen, Abrufzeitraum.
|
||||||
|
* Umsatzabruf, Dublettenerkennung, **Abgleich** abgerufener Umsätze gegen vorhandene
|
||||||
|
Buchungen (Vorschlagsliste: übernehmen / neu anlegen / verwerfen).
|
||||||
|
* **Regelverwaltung**: Muster auf Empfänger/Verwendungszweck → Kategorie, Klasse oder
|
||||||
|
festes Split-Schema; Regeln greifen beim Abruf und nachträglich auf Bestandsbuchungen.
|
||||||
|
* *Ergebnis:* Umsätze kommen per Knopfdruck rein und sind großteils automatisch kategorisiert.
|
||||||
|
|
||||||
|
### Phase 6 — Wertpapiere und Depots
|
||||||
|
* Migrationen `wertpapiere`, `depot_buchungen`, `kurse`.
|
||||||
|
* Depotübersicht, Bestände, Einstandswerte, aktueller Wert, Wertentwicklung.
|
||||||
|
* Depotbuchungen (Kauf, Verkauf, Dividende, Reinvestition) inkl. Verrechnungskonto-Buchung.
|
||||||
|
* QIF-Import für `!Type:Invst`, Depotabruf per FinTS.
|
||||||
|
* **Kursabruf**: Bestände und Kurse kommen primär aus dem FinTS-Depotabruf. Ergänzend ein
|
||||||
|
Kursdienst nach `Aktienkurse.md` — Vorzug **Leeway.tech** (direkte ISIN-Abfrage,
|
||||||
|
50 Abrufe/Tag) mit **Financial Modeling Prep** als Alternative (250/Tag). Umgesetzt als
|
||||||
|
`src/Services/Kursabruf.php` mit austauschbarem Anbieter, API-Key und Anbieterwahl im
|
||||||
|
zentralen Einstellungsdialog, täglicher Lauf per Cron, Ablage in `kurse`. Bei ~20
|
||||||
|
Positionen bleibt der Bedarf im Freikontingent.
|
||||||
|
* Auswertungen: Depotübersicht, Wertpapierentwicklung, Veräußerungsgeschäfte, Erträge.
|
||||||
|
* *Ergebnis:* Die drei Depots sind abgebildet und werden mit abgerufen.
|
||||||
|
|
||||||
|
### Phase 7 — Nebenmodule
|
||||||
|
* **Buchungsvorlagen** (`!Type:Memorized`, 579 Stück im Export) inkl. Import.
|
||||||
|
* **Regelmäßige Buchungen** mit Fälligkeitslauf und Finanzkalender.
|
||||||
|
* **Erinnerungen**, **Adressen**, **Verträge**, **Sparziele**, **Kredite** (Tilgungsplan).
|
||||||
|
* **Zahlungsverkehr** (fest eingeplant): Überweisungsformular mit Auftragskonto, Empfänger
|
||||||
|
aus dem Adressbuch, IBAN/BIC-Prüfung, Verwendungszweck, Kategorie- und Klassenzuordnung,
|
||||||
|
Termin- und Einzelüberweisung. Absenden über FinTS mit dem in Phase 5 übernommenen
|
||||||
|
TAN-Handling; Vorgangsliste „Zahlungsvorgänge / Terminüberweisungen" wie im Original.
|
||||||
|
* *Ergebnis:* Funktionsgleichstand mit dem bisher genutzten Umfang.
|
||||||
|
|
||||||
|
## 6. Was du beisteuern musst
|
||||||
|
|
||||||
|
1. **Vollständiger QIF-Export** aus dem laufenden FinanzManager über *alle* Jahre
|
||||||
|
(der vorliegende Beispielexport deckt nur 2026 ab).
|
||||||
|
2. **Anfangssalden je Konto** — werden nach dem Import im Dialog je Konto bestätigt oder
|
||||||
|
korrigiert (siehe Kapitel 4); als Referenz genügt ein Blick in die Kontenübersicht des
|
||||||
|
FinanzManagers.
|
||||||
|
3. **Budgetdaten**: Export aus dem Budgetplaner (Symbol oben rechts in der Werkzeugleiste),
|
||||||
|
sonst wird das Jahresbudget 2027 einmalig neu erfasst.
|
||||||
|
4. Zugangsdaten der Bankverbindungen (erst in Phase 5, Eingabe direkt in der Anwendung).
|
||||||
|
5. **API-Key für den Kursdienst** (Leeway.tech oder FMP, erst in Phase 6, Eingabe im
|
||||||
|
Einstellungsdialog).
|
||||||
|
|
||||||
|
## 7. Verifikation
|
||||||
|
|
||||||
|
Je Phase, nicht erst am Ende:
|
||||||
|
|
||||||
|
* **PHP-Tests** `docker compose exec -u www-data php php tests/run.php` — Rechenlogik:
|
||||||
|
Saldenberechnung, Splitsummen, Budget-Ist-Werte, Monatsabschluss-Snapshot, QIF-Parser
|
||||||
|
gegen Ausschnitte des Beispielexports als Fixture.
|
||||||
|
* **E2E-Tests** `tests/e2e/run.sh` — Login, Buchung anlegen mit Split, Kategorie zuordnen,
|
||||||
|
Budgetwert inline ändern, Monatsabschluss durchführen, Bericht aufrufen.
|
||||||
|
* **Migrations-Abgleich als Härtetest:** Nach dem Vollimport müssen die Kontosalden und die
|
||||||
|
Jahressummen Einnahmen/Ausgaben exakt den Werten aus dem FinanzManager entsprechen
|
||||||
|
(Cockpit 2026: Einnahmen 148.060,18 € / Ausgaben 58.457,07 €; Summe Kontostände
|
||||||
|
503.430,75 €). Ein Testfall vergleicht die berechneten Summen gegen diese Sollwerte.
|
||||||
|
* **Manuell**: Anwendung unter `http://localhost:8000` aufrufen, Kontenübersicht,
|
||||||
|
Kontoblatt, Budgetplaner und Summenbericht mit den Screenshots aus `Altdaten/` vergleichen.
|
||||||
|
|
||||||
|
## 8. Entschiedene Punkte
|
||||||
|
|
||||||
|
Die vormals offenen Fragen sind geklärt und in den Plan eingearbeitet:
|
||||||
|
|
||||||
|
| Frage | Entscheidung | Steht in |
|
||||||
|
|---|---|---|
|
||||||
|
| Mehrbenutzerbetrieb | Ein Benutzer in der Oberfläche, aber `benutzer_id` von Anfang an im Datenmodell | Kapitel 3, Betriebsregeln |
|
||||||
|
| Zahlungsverkehr | Wird umgesetzt, Phase 7, mit dem TAN-Handling aus Steuerkram | Phase 5 (TAN) und Phase 7 |
|
||||||
|
| Wertpapierkurse | FinTS-Depotabruf plus Kursdienst nach `Aktienkurse.md` (Leeway.tech, ersatzweise FMP) | Phase 6 |
|
||||||
|
| Eröffnungssalden | Dialog je Konto direkt nach dem Import | Kapitel 4 |
|
||||||
|
| Lokaler Port | `http://localhost:8000` | Kapitel 3 |
|
||||||
68
README.md
68
README.md
|
|
@ -1,3 +1,69 @@
|
||||||
# Finanzmanager
|
# Finanzmanager
|
||||||
|
|
||||||
Ich manage meine privaten Finanzen
|
Persönliche Finanzverwaltung als Web-Anwendung: Konten, Buchungen mit Kategorien und
|
||||||
|
Klassen, Jahresbudget, Monatsabschluss, Auswertungen, Online-Banking über FinTS und
|
||||||
|
Wertpapierdepots.
|
||||||
|
|
||||||
|
Ersetzt den Lexware FinanzManager Deluxe, läuft auf einem Debian-Server statt auf einem
|
||||||
|
Windows-Client. Die fachlichen Begriffe bleiben bewusst identisch zum Original – im
|
||||||
|
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`.
|
||||||
|
|
||||||
|
## Technik
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Sprache | PHP 8.3, kein Framework, eigenes MVC |
|
||||||
|
| Datenbank | MariaDB 11 |
|
||||||
|
| Webserver | nginx + php-fpm |
|
||||||
|
| Betrieb | Docker Compose (drei Services) |
|
||||||
|
| Frontend | serverseitiges HTML, Vanilla JS, CSS Custom Properties |
|
||||||
|
| Fremdbibliotheken | keine im Kern; FinTS folgt in Phase 5 als eigener Fork |
|
||||||
|
|
||||||
|
Diagramme werden als eigenes SVG erzeugt, PDF-Berichte gehen über die Druckfunktion des
|
||||||
|
Browsers – bewusst keine Chart- oder PDF-Bibliothek.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # Werte anpassen, APP_KEY erzeugen
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach http://localhost:8000 aufrufen. Beim ersten Aufruf führt die Anwendung zur
|
||||||
|
Ersteinrichtung und legt den ersten Benutzer an.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/run-tests.sh # Tests
|
||||||
|
./scripts/backup.sh # Backup mit Rotation
|
||||||
|
./scripts/update.sh # Update (auf dem Produktionsserver)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verzeichnisse
|
||||||
|
|
||||||
|
```
|
||||||
|
src/ Anwendungscode (Controllers, Models, Services, Views)
|
||||||
|
public/ Front-Controller und statische Dateien
|
||||||
|
migrations/ nummerierte SQL-Migrationen, laufen beim Containerstart
|
||||||
|
scripts/ Migration, Update, Backup, Tests
|
||||||
|
tests/ PHP-Testsuite; tests/e2e/ für Playwright
|
||||||
|
data/ Nutzdaten: mariadb, logs, import (nicht im Repository)
|
||||||
|
Altdaten/ Screenshots und QIF-Export der Altanwendung (nicht im Repository)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration der Altdaten
|
||||||
|
|
||||||
|
Datenquelle ist der **QIF-Export** aus dem FinanzManager – die `.QDF`-Datei ist
|
||||||
|
verschlüsselt und nicht auswertbar. Der Importer liest Klassen, Kategorien, Konten,
|
||||||
|
Buchungen samt Splits sowie Wertpapierbuchungen. Einzelheiten zum Format und den
|
||||||
|
Fallstricken stehen in `PLAN.md`, Kapitel 4.
|
||||||
|
|
||||||
|
## Dokumentation
|
||||||
|
|
||||||
|
- `PLAN.md` – Projektplan mit allen Phasen
|
||||||
|
- `CLAUDE.md` – Entwicklungsregeln
|
||||||
|
- `Changes.md` – Änderungsprotokoll
|
||||||
|
|
|
||||||
17
composer.json
Normal file
17
composer.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"name": "finanzmanager/finanzmanager",
|
||||||
|
"description": "Persönlicher Finanzmanager – Konten, Kategorien, Budget, Monatsabschluss",
|
||||||
|
"type": "project",
|
||||||
|
"license": "MIT",
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.3"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Finanzmanager\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true
|
||||||
|
}
|
||||||
|
}
|
||||||
54
docker-compose.yml
Normal file
54
docker-compose.yml
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:1.27-alpine
|
||||||
|
ports:
|
||||||
|
# Lokal erreichbar unter http://localhost:8000
|
||||||
|
- "8000:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- ./public:/var/www/html/public:ro
|
||||||
|
depends_on:
|
||||||
|
- php
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
php:
|
||||||
|
build: .
|
||||||
|
volumes:
|
||||||
|
- ./public:/var/www/html/public
|
||||||
|
- ./src:/var/www/html/src
|
||||||
|
- ./migrations:/var/www/html/migrations
|
||||||
|
- ./scripts:/var/www/html/scripts
|
||||||
|
- ./tests:/var/www/html/tests
|
||||||
|
# Persistente Nutzdaten außerhalb des Web-Roots. data/mariadb wird hier
|
||||||
|
# bewusst NICHT gemountet – das gehört allein dem db-Container.
|
||||||
|
- ./data/logs:/var/www/html/data/logs
|
||||||
|
- ./data/import:/var/www/html/data/import
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
DB_HOST: db
|
||||||
|
DB_NAME: finanzmanager
|
||||||
|
DB_USER: finanzmanager
|
||||||
|
DB_PASS: ${DB_PASS:-finanzmanager}
|
||||||
|
APP_KEY: ${APP_KEY:-}
|
||||||
|
DEBUG_LOG: ${DEBUG_LOG:-0}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mariadb:11
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASS:-rootpass}
|
||||||
|
MARIADB_DATABASE: finanzmanager
|
||||||
|
MARIADB_USER: finanzmanager
|
||||||
|
MARIADB_PASSWORD: ${DB_PASS:-finanzmanager}
|
||||||
|
volumes:
|
||||||
|
- ./data/mariadb:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
17
docker-entrypoint.sh
Executable file
17
docker-entrypoint.sh
Executable file
|
|
@ -0,0 +1,17 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Datenverzeichnisse existieren auch dann, wenn sie per Bind-Mount frisch
|
||||||
|
# hereinkommen – und gehören www-data, sonst scheitert das erste Schreiben.
|
||||||
|
mkdir -p /var/www/html/data/logs /var/www/html/data/import
|
||||||
|
chown -R www-data:www-data /var/www/html/data
|
||||||
|
|
||||||
|
# Migrationen laufen bei jedem Start. Sie sind idempotent, ein zweiter Lauf
|
||||||
|
# ändert nichts.
|
||||||
|
if [ -f /var/www/html/scripts/migrate.php ]; then
|
||||||
|
php /var/www/html/scripts/migrate.php || {
|
||||||
|
echo "Migration fehlgeschlagen – Container startet trotzdem, damit man nachsehen kann." >&2
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
35
migrations/001_grundgeruest.sql
Normal file
35
migrations/001_grundgeruest.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
-- Grundgerüst: Benutzer und zentrale Einstellungen.
|
||||||
|
--
|
||||||
|
-- Fachliche Tabellen (Konten, Kategorien, Buchungen …) kommen in Phase 1 und 2
|
||||||
|
-- als eigene Migrationen dazu.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS benutzer (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
benutzername VARCHAR(64) NOT NULL,
|
||||||
|
anzeigename VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
passwort_hash VARCHAR(255) NOT NULL,
|
||||||
|
aktiv TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
letzte_anmeldung DATETIME NULL,
|
||||||
|
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_benutzername (benutzername)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Alle Programmeinstellungen an einer Stelle; der Einstellungsdialog hängt
|
||||||
|
-- seine Reiter über das Feld "bereich" ein.
|
||||||
|
CREATE TABLE IF NOT EXISTS einstellungen (
|
||||||
|
schluessel VARCHAR(64) NOT NULL,
|
||||||
|
wert TEXT NULL,
|
||||||
|
bereich VARCHAR(32) NOT NULL DEFAULT 'allgemein',
|
||||||
|
geaendert_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (schluessel)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT INTO einstellungen (schluessel, wert, bereich) VALUES
|
||||||
|
('mandant_name', 'Privat', 'allgemein'),
|
||||||
|
('waehrung', 'EUR', 'allgemein'),
|
||||||
|
('erster_monat', '1', 'buchhaltung'),
|
||||||
|
('darstellung_thema', 'system', 'darstellung'),
|
||||||
|
('zeilen_pro_seite', '100', 'darstellung'),
|
||||||
|
('debug_log_aktiv', '0', 'diagnose')
|
||||||
|
ON DUPLICATE KEY UPDATE schluessel = schluessel;
|
||||||
45
nginx/default.conf
Normal file
45
nginx/default.conf
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /var/www/html/public;
|
||||||
|
index index.php;
|
||||||
|
|
||||||
|
charset utf-8;
|
||||||
|
client_max_body_size 32m;
|
||||||
|
|
||||||
|
# Sicherheits-Header. Content-Security-Policy ist bewusst streng: die
|
||||||
|
# Anwendung lädt weder externe Skripte noch externe Stylesheets.
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "same-origin" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; frame-ancestors 'self'" always;
|
||||||
|
|
||||||
|
# Statische Dateien direkt ausliefern, alles andere an den Front-Controller.
|
||||||
|
location /static/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
access_log off;
|
||||||
|
expires 1h;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.php$is_args$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/index\.php$ {
|
||||||
|
fastcgi_pass php:9000;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_read_timeout 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kein direkter Aufruf anderer PHP-Dateien.
|
||||||
|
location ~ \.php$ {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
php/php.ini
Normal file
22
php/php.ini
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
; Finanzmanager – PHP-Einstellungen
|
||||||
|
date.timezone = Europe/Berlin
|
||||||
|
|
||||||
|
; Fehler landen im Log, nie in der Ausgabe: eine PHP-Warnung mitten im HTML
|
||||||
|
; zerschießt sonst Tabellen und verrät Pfade.
|
||||||
|
display_errors = Off
|
||||||
|
display_startup_errors = Off
|
||||||
|
log_errors = On
|
||||||
|
error_log = /var/www/html/data/logs/php-error.log
|
||||||
|
error_reporting = E_ALL
|
||||||
|
|
||||||
|
upload_max_filesize = 32M
|
||||||
|
post_max_size = 32M
|
||||||
|
memory_limit = 512M
|
||||||
|
max_execution_time = 300
|
||||||
|
|
||||||
|
; Der QIF-Vollimport hält viele Buchungen gleichzeitig im Speicher.
|
||||||
|
; 512M ist großzügig, aber der Import läuft nur selten.
|
||||||
|
|
||||||
|
session.cookie_httponly = 1
|
||||||
|
session.cookie_samesite = Lax
|
||||||
|
session.use_strict_mode = 1
|
||||||
66
public/index.php
Normal file
66
public/index.php
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Front-Controller. nginx leitet jeden Request hierher.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
use Finanzmanager\Auth;
|
||||||
|
use Finanzmanager\Config;
|
||||||
|
use Finanzmanager\Controllers\CockpitController;
|
||||||
|
use Finanzmanager\Controllers\EinstellungenController;
|
||||||
|
use Finanzmanager\Controllers\LoginController;
|
||||||
|
use Finanzmanager\Controllers\PlatzhalterController;
|
||||||
|
use Finanzmanager\Models\Einstellung;
|
||||||
|
use Finanzmanager\Router;
|
||||||
|
use Finanzmanager\Services\DebugLog;
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
Config::load();
|
||||||
|
|
||||||
|
// Die Einstellung aus der Datenbank hat Vorrang vor dem Env-Schalter – sie ist
|
||||||
|
// der Weg, den der Nutzer im Dialog bedient. Schlägt der Zugriff fehl, bleibt
|
||||||
|
// es beim Env-Wert; das Log soll gerade dann funktionieren, wenn die Datenbank
|
||||||
|
// das Problem ist.
|
||||||
|
try {
|
||||||
|
DebugLog::setAktiv(
|
||||||
|
Einstellung::getBool('debug_log_aktiv', (bool)Config::get('debug_log', false))
|
||||||
|
);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Datenbank noch nicht bereit – Env-Schalter gilt weiter.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session vor dem Request-Log starten, sonst stünde dort nie ein Benutzer.
|
||||||
|
Auth::startSession();
|
||||||
|
DebugLog::request();
|
||||||
|
|
||||||
|
$router = new Router();
|
||||||
|
|
||||||
|
$router->get('/login', fn() => (new LoginController())->formular());
|
||||||
|
$router->post('/login', fn() => (new LoginController())->anmelden());
|
||||||
|
$router->post('/logout', fn() => (new LoginController())->abmelden());
|
||||||
|
|
||||||
|
$router->get('/einrichtung', fn() => (new LoginController())->einrichtungFormular());
|
||||||
|
$router->post('/einrichtung', fn() => (new LoginController())->einrichtungSpeichern());
|
||||||
|
|
||||||
|
$router->get('/', fn() => (new CockpitController())->index());
|
||||||
|
|
||||||
|
$router->get('/einstellungen', fn() => (new EinstellungenController())->index());
|
||||||
|
$router->post('/einstellungen', fn() => (new EinstellungenController())->speichern());
|
||||||
|
$router->post('/einstellungen/log-leeren', fn() => (new EinstellungenController())->logLeeren());
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$router->dispatch();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
DebugLog::fehler($e);
|
||||||
|
http_response_code(500);
|
||||||
|
(new View())->render('layout/500.php', []);
|
||||||
|
}
|
||||||
525
public/static/app.css
Normal file
525
public/static/app.css
Normal file
|
|
@ -0,0 +1,525 @@
|
||||||
|
/* Finanzmanager – Grundgestaltung
|
||||||
|
*
|
||||||
|
* Layout wie im Lexware FinanzManager: feste Navigation links, Werkzeugleiste
|
||||||
|
* oben, darunter Tab-Leiste und dichte Datentabellen. Optik moderner: ruhigere
|
||||||
|
* Farben, mehr Weißraum, klare Typografie.
|
||||||
|
*
|
||||||
|
* Farben ausschließlich über Custom Properties. Die helle Palette steht auf
|
||||||
|
* blankem :root, die dunkle wird an zwei Stellen nachgezogen – einmal für die
|
||||||
|
* Systemvorgabe, einmal für die ausdrückliche Wahl im Einstellungsdialog.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--grund: #f4f6f8;
|
||||||
|
--flaeche: #ffffff;
|
||||||
|
--flaeche-gedaempft: #eef1f5;
|
||||||
|
--rand: #d6dce4;
|
||||||
|
--rand-kraeftig: #b8c2cf;
|
||||||
|
--text: #1c2530;
|
||||||
|
--text-gedaempft: #5b6875;
|
||||||
|
--akzent: #1f6feb;
|
||||||
|
--akzent-text: #ffffff;
|
||||||
|
--akzent-schwach: #e5efff;
|
||||||
|
--navi-grund: #24313f;
|
||||||
|
--navi-text: #cdd7e2;
|
||||||
|
--navi-text-aktiv: #ffffff;
|
||||||
|
--navi-aktiv: #1f6feb;
|
||||||
|
--positiv: #1a7f4b;
|
||||||
|
--negativ: #c0392b;
|
||||||
|
--warnung: #9a6700;
|
||||||
|
--schatten: 0 1px 2px rgba(16, 24, 40, .06), 0 1px 3px rgba(16, 24, 40, .1);
|
||||||
|
|
||||||
|
--radius: 6px;
|
||||||
|
--schrift: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--schrift-zahl: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-thema="hell"]) {
|
||||||
|
--grund: #161b22;
|
||||||
|
--flaeche: #1c2330;
|
||||||
|
--flaeche-gedaempft: #232b39;
|
||||||
|
--rand: #303a49;
|
||||||
|
--rand-kraeftig: #44506180;
|
||||||
|
--text: #e5eaf1;
|
||||||
|
--text-gedaempft: #9aa7b6;
|
||||||
|
--akzent: #4d8ef7;
|
||||||
|
--akzent-text: #0d1117;
|
||||||
|
--akzent-schwach: #1d2b45;
|
||||||
|
--navi-grund: #10151c;
|
||||||
|
--navi-text: #9aa7b6;
|
||||||
|
--navi-text-aktiv: #ffffff;
|
||||||
|
--navi-aktiv: #2f6fd0;
|
||||||
|
--positiv: #46b877;
|
||||||
|
--negativ: #f0705f;
|
||||||
|
--warnung: #d9a441;
|
||||||
|
--schatten: 0 1px 2px rgba(0, 0, 0, .4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-thema="dunkel"] {
|
||||||
|
--grund: #161b22;
|
||||||
|
--flaeche: #1c2330;
|
||||||
|
--flaeche-gedaempft: #232b39;
|
||||||
|
--rand: #303a49;
|
||||||
|
--rand-kraeftig: #445061;
|
||||||
|
--text: #e5eaf1;
|
||||||
|
--text-gedaempft: #9aa7b6;
|
||||||
|
--akzent: #4d8ef7;
|
||||||
|
--akzent-text: #0d1117;
|
||||||
|
--akzent-schwach: #1d2b45;
|
||||||
|
--navi-grund: #10151c;
|
||||||
|
--navi-text: #9aa7b6;
|
||||||
|
--navi-text-aktiv: #ffffff;
|
||||||
|
--navi-aktiv: #2f6fd0;
|
||||||
|
--positiv: #46b877;
|
||||||
|
--negativ: #f0705f;
|
||||||
|
--warnung: #d9a441;
|
||||||
|
--schatten: 0 1px 2px rgba(0, 0, 0, .4);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--grund);
|
||||||
|
color: var(--text);
|
||||||
|
font: 15px/1.5 var(--schrift);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--akzent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: var(--schrift-zahl);
|
||||||
|
font-size: .9em;
|
||||||
|
background: var(--flaeche-gedaempft);
|
||||||
|
padding: .1em .35em;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Grundraster ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.rahmen {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 232px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seitenleiste {
|
||||||
|
background: var(--navi-grund);
|
||||||
|
color: var(--navi-text);
|
||||||
|
padding: 0 0 2rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marke {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
padding: 1.25rem 1.25rem 1rem;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seitenleiste ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seitenleiste a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .7rem;
|
||||||
|
padding: .6rem 1.25rem;
|
||||||
|
color: var(--navi-text);
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seitenleiste a:hover {
|
||||||
|
background: rgba(255, 255, 255, .05);
|
||||||
|
color: var(--navi-text-aktiv);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seitenleiste a.aktiv {
|
||||||
|
background: var(--navi-aktiv);
|
||||||
|
border-left-color: #fff;
|
||||||
|
color: var(--navi-text-aktiv);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seitenleiste .icon {
|
||||||
|
width: 1.1em;
|
||||||
|
text-align: center;
|
||||||
|
opacity: .85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hauptbereich {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Werkzeugleiste ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.werkzeugleiste {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: .85rem 1.5rem;
|
||||||
|
background: var(--flaeche);
|
||||||
|
border-bottom: 1px solid var(--rand);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.werkzeugleiste h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--akzent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.werkzeuge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.werkzeug {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text-gedaempft);
|
||||||
|
font: inherit;
|
||||||
|
font-size: .9rem;
|
||||||
|
padding: .35rem .6rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.werkzeug:hover {
|
||||||
|
background: var(--flaeche-gedaempft);
|
||||||
|
border-color: var(--rand);
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benutzer {
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--text-gedaempft);
|
||||||
|
}
|
||||||
|
|
||||||
|
form.inline { display: inline; margin: 0; }
|
||||||
|
|
||||||
|
.inhalt {
|
||||||
|
padding: 1.25rem 1.5rem 3rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab-Leiste ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.tableiste {
|
||||||
|
display: flex;
|
||||||
|
gap: .25rem;
|
||||||
|
border-bottom: 1px solid var(--rand);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: .5rem .9rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: var(--radius) var(--radius) 0 0;
|
||||||
|
color: var(--text-gedaempft);
|
||||||
|
font-size: .93rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover { background: var(--flaeche-gedaempft); text-decoration: none; }
|
||||||
|
|
||||||
|
.tab.aktiv {
|
||||||
|
background: var(--flaeche);
|
||||||
|
border-color: var(--rand);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
padding-bottom: calc(.5rem + 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Kacheln ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.kachelraster {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kachel {
|
||||||
|
background: var(--flaeche);
|
||||||
|
border: 1px solid var(--rand);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--schatten);
|
||||||
|
padding: 1rem 1.15rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kachel.einzeln { max-width: 640px; }
|
||||||
|
|
||||||
|
.kachel header { margin-bottom: .6rem; }
|
||||||
|
|
||||||
|
.kachel h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phasenmarke {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--akzent-schwach);
|
||||||
|
color: var(--akzent);
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: .15rem .5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin: 0 0 .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platzhalter {
|
||||||
|
color: var(--text-gedaempft);
|
||||||
|
font-size: .92rem;
|
||||||
|
margin: .5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkliste {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: .92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkliste li {
|
||||||
|
padding: .3rem 0 .3rem 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
color: var(--text-gedaempft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkliste li::before {
|
||||||
|
content: "○";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkliste li.erledigt { color: var(--text); }
|
||||||
|
.checkliste li.erledigt::before { content: "●"; color: var(--positiv); }
|
||||||
|
|
||||||
|
/* ── Tabellen ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
table.liste {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--flaeche);
|
||||||
|
border: 1px solid var(--rand);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: .92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.liste th,
|
||||||
|
table.liste td {
|
||||||
|
padding: .45rem .7rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--rand);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.liste thead th {
|
||||||
|
background: var(--flaeche-gedaempft);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-gedaempft);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.liste tbody tr:last-child td { border-bottom: none; }
|
||||||
|
table.liste tbody tr:hover { background: var(--flaeche-gedaempft); }
|
||||||
|
|
||||||
|
.rechts { text-align: right; }
|
||||||
|
|
||||||
|
.betrag {
|
||||||
|
font-family: var(--schrift-zahl);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.betrag.negativ { color: var(--negativ); }
|
||||||
|
|
||||||
|
/* ── Formulare ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.formular {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .35rem;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formular.breit {
|
||||||
|
max-width: 620px;
|
||||||
|
background: var(--flaeche);
|
||||||
|
border: 1px solid var(--rand);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: var(--schatten);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formular label {
|
||||||
|
font-size: .9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: .6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formular label.ankreuzfeld {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formular input[type="text"],
|
||||||
|
.formular input[type="password"],
|
||||||
|
.formular input[type="number"],
|
||||||
|
.formular select {
|
||||||
|
font: inherit;
|
||||||
|
padding: .5rem .6rem;
|
||||||
|
border: 1px solid var(--rand-kraeftig);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--flaeche);
|
||||||
|
color: var(--text);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formular input:focus-visible,
|
||||||
|
.formular select:focus-visible,
|
||||||
|
.knopf:focus-visible {
|
||||||
|
outline: 2px solid var(--akzent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formular h3 {
|
||||||
|
margin: 1.5rem 0 .5rem;
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feldhinweis {
|
||||||
|
font-size: .85rem;
|
||||||
|
color: var(--text-gedaempft);
|
||||||
|
margin: .15rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional { font-weight: 400; color: var(--text-gedaempft); }
|
||||||
|
|
||||||
|
.knopfleiste {
|
||||||
|
display: flex;
|
||||||
|
gap: .6rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knopf {
|
||||||
|
display: inline-block;
|
||||||
|
font: inherit;
|
||||||
|
font-size: .93rem;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
border: 1px solid var(--rand-kraeftig);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--flaeche);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knopf:hover { background: var(--flaeche-gedaempft); text-decoration: none; }
|
||||||
|
|
||||||
|
.knopf.primaer {
|
||||||
|
background: var(--akzent);
|
||||||
|
border-color: var(--akzent);
|
||||||
|
color: var(--akzent-text);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knopf.primaer:hover { filter: brightness(1.08); }
|
||||||
|
|
||||||
|
/* ── Meldungen ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.meldung {
|
||||||
|
padding: .65rem .9rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--rand);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: .92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meldung-erfolg { background: var(--akzent-schwach); border-color: var(--akzent); }
|
||||||
|
.meldung-fehler { background: color-mix(in srgb, var(--negativ) 12%, var(--flaeche)); border-color: var(--negativ); }
|
||||||
|
.meldung-warnung { background: color-mix(in srgb, var(--warnung) 15%, var(--flaeche)); border-color: var(--warnung); }
|
||||||
|
|
||||||
|
.hinweis { color: var(--text-gedaempft); font-size: .93rem; }
|
||||||
|
|
||||||
|
/* ── Schlichter Rahmen (Anmeldung, Fehlerseiten) ─────────────────────────── */
|
||||||
|
|
||||||
|
body.schlicht {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.karte-mitte {
|
||||||
|
background: var(--flaeche);
|
||||||
|
border: 1px solid var(--rand);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--schatten);
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marke.gross {
|
||||||
|
padding: 0 0 1.25rem;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Schmale Fenster ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.rahmen { grid-template-columns: 1fr; }
|
||||||
|
|
||||||
|
.seitenleiste {
|
||||||
|
position: static;
|
||||||
|
height: auto;
|
||||||
|
padding-bottom: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seitenleiste ul {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seitenleiste a {
|
||||||
|
border-left: none;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seitenleiste a.aktiv {
|
||||||
|
border-left: none;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
public/static/app.js
Normal file
27
public/static/app.js
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
/* Finanzmanager – Frontend-Hilfen.
|
||||||
|
*
|
||||||
|
* Bewusst schlank und ohne Framework. Ab Phase 2 kommen hier Splitbuchungs-
|
||||||
|
* maske und die Inline-Bearbeitung des Budgetplaners dazu.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Formulare mit data-bestaetigen fragen vor dem Absenden nach.
|
||||||
|
document.addEventListener('submit', function (ereignis) {
|
||||||
|
const formular = ereignis.target;
|
||||||
|
const frage = formular.dataset ? formular.dataset.bestaetigen : null;
|
||||||
|
if (frage && !window.confirm(frage)) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Meldungen nach kurzer Zeit ausblenden – Fehler bleiben stehen.
|
||||||
|
document.querySelectorAll('.meldung-erfolg').forEach(function (element) {
|
||||||
|
window.setTimeout(function () {
|
||||||
|
element.style.transition = 'opacity .4s';
|
||||||
|
element.style.opacity = '0';
|
||||||
|
window.setTimeout(function () { element.remove(); }, 400);
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
})();
|
||||||
77
scripts/backup.sh
Executable file
77
scripts/backup.sh
Executable file
|
|
@ -0,0 +1,77 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Sichert Datenbank und Nutzdaten nach backups/.
|
||||||
|
#
|
||||||
|
# Rotation: 7 Tages-, 4 Wochen-, 12 Monats-Backups. Ein flock verhindert, dass
|
||||||
|
# sich zwei Läufe überholen.
|
||||||
|
#
|
||||||
|
# ./scripts/backup.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
|
||||||
|
ZIEL="backups"
|
||||||
|
mkdir -p "$ZIEL"
|
||||||
|
|
||||||
|
exec 9>"$ZIEL/.lock"
|
||||||
|
if ! flock -n 9; then
|
||||||
|
echo "Ein Backup läuft bereits." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
STEMPEL="$(date +%Y%m%d_%H%M%S)"
|
||||||
|
DB_PASS="${DB_PASS:-finanzmanager}"
|
||||||
|
|
||||||
|
echo "Sichere Datenbank …"
|
||||||
|
docker compose exec -T db mariadb-dump \
|
||||||
|
-u finanzmanager -p"$DB_PASS" \
|
||||||
|
--single-transaction --routines --events \
|
||||||
|
finanzmanager > "$ZIEL/db_${STEMPEL}.sql"
|
||||||
|
|
||||||
|
echo "Sichere Nutzdaten …"
|
||||||
|
# data/mariadb bleibt außen vor – die Datenbank steckt bereits im Dump.
|
||||||
|
tar czf "$ZIEL/daten_${STEMPEL}.tar.gz" \
|
||||||
|
--exclude='data/mariadb' \
|
||||||
|
data 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── Rotation ────────────────────────────────────────────────────────────────
|
||||||
|
# Behalten: die letzten 7 Tage vollständig, danach je ein Backup pro Woche
|
||||||
|
# (4 Wochen) und pro Monat (12 Monate).
|
||||||
|
python3 - "$ZIEL" <<'PY'
|
||||||
|
import os, re, sys, time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
ziel = sys.argv[1]
|
||||||
|
muster = re.compile(r'^(db|daten)_(\d{8})_(\d{6})\.(sql|tar\.gz)$')
|
||||||
|
|
||||||
|
dateien = []
|
||||||
|
for name in os.listdir(ziel):
|
||||||
|
treffer = muster.match(name)
|
||||||
|
if treffer:
|
||||||
|
stempel = datetime.strptime(treffer.group(2) + treffer.group(3), '%Y%m%d%H%M%S')
|
||||||
|
dateien.append((stempel, name))
|
||||||
|
|
||||||
|
dateien.sort(reverse=True)
|
||||||
|
jetzt = datetime.now()
|
||||||
|
|
||||||
|
behalten, wochen, monate = set(), set(), set()
|
||||||
|
for stempel, name in dateien:
|
||||||
|
alter = (jetzt - stempel).days
|
||||||
|
if alter <= 7:
|
||||||
|
behalten.add(name)
|
||||||
|
continue
|
||||||
|
woche = stempel.isocalendar()[:2]
|
||||||
|
monat = (stempel.year, stempel.month)
|
||||||
|
if alter <= 35 and woche not in wochen:
|
||||||
|
wochen.add(woche); behalten.add(name)
|
||||||
|
elif alter <= 400 and monat not in monate:
|
||||||
|
monate.add(monat); behalten.add(name)
|
||||||
|
|
||||||
|
for _, name in dateien:
|
||||||
|
if name not in behalten:
|
||||||
|
os.remove(os.path.join(ziel, name))
|
||||||
|
print(f"Entfernt: {name}")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "Backup abgeschlossen: $ZIEL/db_${STEMPEL}.sql"
|
||||||
34
scripts/e2e-benutzer.php
Executable file
34
scripts/e2e-benutzer.php
Executable file
|
|
@ -0,0 +1,34 @@
|
||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt den Testnutzer für die E2E-Tests an (oder setzt sein Passwort zurück).
|
||||||
|
*
|
||||||
|
* docker compose exec -u www-data php php scripts/e2e-benutzer.php
|
||||||
|
*
|
||||||
|
* Zugangsdaten lassen sich über E2E_USER und E2E_PASSWORD überschreiben; sie
|
||||||
|
* müssen dann beim Testlauf ebenso gesetzt sein.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
use Finanzmanager\Config;
|
||||||
|
use Finanzmanager\Models\Benutzer;
|
||||||
|
|
||||||
|
Config::load();
|
||||||
|
|
||||||
|
$name = getenv('E2E_USER') ?: 'e2e';
|
||||||
|
$passwort = getenv('E2E_PASSWORD') ?: 'e2e-testpasswort';
|
||||||
|
|
||||||
|
$vorhanden = Benutzer::findByName($name);
|
||||||
|
|
||||||
|
if ($vorhanden !== null) {
|
||||||
|
Benutzer::passwortSetzen((int)$vorhanden['id'], $passwort);
|
||||||
|
echo "Passwort für '{$name}' zurückgesetzt.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Benutzer::anlegen($name, $passwort, 'E2E-Testnutzer');
|
||||||
|
echo "Testnutzer '{$name}' angelegt.\n";
|
||||||
78
scripts/migrate.php
Executable file
78
scripts/migrate.php
Executable file
|
|
@ -0,0 +1,78 @@
|
||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spielt alle noch nicht angewandten Migrationen aus migrations/ ein.
|
||||||
|
*
|
||||||
|
* Läuft automatisch beim Containerstart (docker-entrypoint.sh). Ein zweiter
|
||||||
|
* Lauf ändert nichts: bereits eingespielte Dateien stehen in `migrationen`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
use Finanzmanager\Config;
|
||||||
|
use Finanzmanager\Database;
|
||||||
|
|
||||||
|
Config::load();
|
||||||
|
|
||||||
|
// Beim Containerstart kann die Datenbank noch hochfahren – kurz warten.
|
||||||
|
$versuche = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
Database::get();
|
||||||
|
break;
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
if (++$versuche >= 30) {
|
||||||
|
fwrite(STDERR, "Datenbank nicht erreichbar: {$e->getMessage()}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
'CREATE TABLE IF NOT EXISTS migrationen (
|
||||||
|
datei VARCHAR(255) NOT NULL,
|
||||||
|
angewandt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (datei)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
|
||||||
|
);
|
||||||
|
|
||||||
|
$angewandt = array_column(
|
||||||
|
Database::fetchAll('SELECT datei FROM migrationen'),
|
||||||
|
'datei'
|
||||||
|
);
|
||||||
|
|
||||||
|
$dateien = glob(dirname(__DIR__) . '/migrations/*.sql') ?: [];
|
||||||
|
sort($dateien);
|
||||||
|
|
||||||
|
$neu = 0;
|
||||||
|
foreach ($dateien as $pfad) {
|
||||||
|
$name = basename($pfad);
|
||||||
|
if (in_array($name, $angewandt, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = file_get_contents($pfad);
|
||||||
|
if ($sql === false || trim($sql) === '') {
|
||||||
|
fwrite(STDERR, "Übersprungen (leer oder nicht lesbar): {$name}\n");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Mehrere Anweisungen je Datei: PDO::exec verarbeitet sie am Stück.
|
||||||
|
Database::get()->exec($sql);
|
||||||
|
Database::execute('INSERT INTO migrationen (datei) VALUES (?)', [$name]);
|
||||||
|
echo "Eingespielt: {$name}\n";
|
||||||
|
$neu++;
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
fwrite(STDERR, "Fehler in {$name}: {$e->getMessage()}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo $neu === 0
|
||||||
|
? "Keine neuen Migrationen.\n"
|
||||||
|
: "{$neu} Migration(en) eingespielt.\n";
|
||||||
15
scripts/run-tests.sh
Executable file
15
scripts/run-tests.sh
Executable file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Führt die PHP-Testsuite im Container aus.
|
||||||
|
#
|
||||||
|
# ./scripts/run-tests.sh # alle Tests
|
||||||
|
# ./scripts/run-tests.sh --json # Ausgabe als JSON
|
||||||
|
#
|
||||||
|
# Immer als www-data: als root angelegte Dateien unterhalb von data/ kann
|
||||||
|
# php-fpm später nicht mehr beschreiben.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
|
||||||
|
exec docker compose exec -u www-data -T php php tests/run.php "$@"
|
||||||
81
scripts/update.sh
Executable file
81
scripts/update.sh
Executable file
|
|
@ -0,0 +1,81 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Update-Skript für den Produktionsserver.
|
||||||
|
#
|
||||||
|
# Holt den Stand aus dem Upstream-Repository und spielt alles ein, was dazu
|
||||||
|
# nötig ist: Backup, Abhängigkeiten, Image-Neubau bei Bedarf, Migrationen.
|
||||||
|
#
|
||||||
|
# ./scripts/update.sh [--no-backup] [--no-tests] [--force-rebuild]
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
|
||||||
|
OHNE_BACKUP=false
|
||||||
|
OHNE_TESTS=false
|
||||||
|
NEUBAU_ERZWINGEN=false
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--no-backup) OHNE_BACKUP=true ;;
|
||||||
|
--no-tests) OHNE_TESTS=true ;;
|
||||||
|
--force-rebuild) NEUBAU_ERZWINGEN=true ;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Aufruf: $0 [--no-backup] [--no-tests] [--force-rebuild]"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unbekannte Option: $arg" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
melde() { printf '\n\033[1m▸ %s\033[0m\n' "$1"; }
|
||||||
|
|
||||||
|
# ── Backup vor jeder Änderung ───────────────────────────────────────────────
|
||||||
|
if [[ "$OHNE_BACKUP" == false ]]; then
|
||||||
|
melde "Backup"
|
||||||
|
./scripts/backup.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Stand aus dem Upstream holen ────────────────────────────────────────────
|
||||||
|
melde "Code aktualisieren"
|
||||||
|
VORHER="$(git rev-parse HEAD)"
|
||||||
|
git pull --ff-only
|
||||||
|
NACHHER="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
|
if [[ "$VORHER" == "$NACHHER" && "$NEUBAU_ERZWINGEN" == false ]]; then
|
||||||
|
echo "Bereits aktuell – nichts zu tun."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
GEAENDERT="$(git diff --name-only "$VORHER" "$NACHHER" || true)"
|
||||||
|
|
||||||
|
# ── Image neu bauen, wenn sich die Grundlage geändert hat ───────────────────
|
||||||
|
if [[ "$NEUBAU_ERZWINGEN" == true ]] \
|
||||||
|
|| grep -qE '^(Dockerfile|composer\.(json|lock)|php/|nginx/)' <<<"$GEAENDERT"; then
|
||||||
|
melde "Container-Image neu bauen"
|
||||||
|
docker compose build --pull
|
||||||
|
fi
|
||||||
|
|
||||||
|
melde "Container starten"
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# ── Migrationen ─────────────────────────────────────────────────────────────
|
||||||
|
# Der Entrypoint spielt sie beim Start bereits ein; der zweite Lauf ist
|
||||||
|
# idempotent und macht sichtbar, ob etwas fehlgeschlagen ist.
|
||||||
|
melde "Migrationen"
|
||||||
|
docker compose exec -T php php scripts/migrate.php
|
||||||
|
|
||||||
|
# ── Berechtigungen ──────────────────────────────────────────────────────────
|
||||||
|
melde "Berechtigungen"
|
||||||
|
docker compose exec -T php chown -R www-data:www-data /var/www/html/data
|
||||||
|
|
||||||
|
# ── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
if [[ "$OHNE_TESTS" == false ]]; then
|
||||||
|
melde "Tests"
|
||||||
|
docker compose exec -u www-data -T php php tests/run.php
|
||||||
|
fi
|
||||||
|
|
||||||
|
melde "Update abgeschlossen ($(git rev-parse --short HEAD))"
|
||||||
122
src/Auth.php
Normal file
122
src/Auth.php
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager;
|
||||||
|
|
||||||
|
use Finanzmanager\Models\Benutzer;
|
||||||
|
|
||||||
|
class Auth
|
||||||
|
{
|
||||||
|
/** Absolutes Session-Timeout. */
|
||||||
|
private const SESSION_LAUFZEIT = 86400;
|
||||||
|
|
||||||
|
public static function startSession(): void
|
||||||
|
{
|
||||||
|
if (session_status() !== PHP_SESSION_NONE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session_set_cookie_params(self::cookieParams());
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie-Attribute als reine Funktion, getrennt von startSession() – so
|
||||||
|
* lässt sich die Secure-Erkennung testen, ohne eine Session zu starten.
|
||||||
|
*
|
||||||
|
* Secure richtet sich nach dem Request (HTTPS bzw. X-Forwarded-Proto hinter
|
||||||
|
* einem Reverse-Proxy), damit der lokale Betrieb auf http://localhost:8000
|
||||||
|
* ohne TLS funktioniert. SESSION_COOKIE_SECURE erzwingt (1) oder
|
||||||
|
* unterdrückt (0) das Attribut.
|
||||||
|
*/
|
||||||
|
public static function cookieParams(): array
|
||||||
|
{
|
||||||
|
$override = getenv('SESSION_COOKIE_SECURE');
|
||||||
|
if ($override === '1' || $override === '0') {
|
||||||
|
$secure = $override === '1';
|
||||||
|
} else {
|
||||||
|
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||||
|
|| ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'lifetime' => 0,
|
||||||
|
'path' => '/',
|
||||||
|
'domain' => '',
|
||||||
|
'secure' => $secure,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verlange(): void
|
||||||
|
{
|
||||||
|
self::startSession();
|
||||||
|
|
||||||
|
$benutzerId = $_SESSION['user_id'] ?? null;
|
||||||
|
$anmeldung = $_SESSION['login_time'] ?? 0;
|
||||||
|
|
||||||
|
if (!$benutzerId || (time() - (int)$anmeldung) > self::SESSION_LAUFZEIT) {
|
||||||
|
$_SESSION = [];
|
||||||
|
session_destroy();
|
||||||
|
View::redirect('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function anmelden(int $benutzerId): void
|
||||||
|
{
|
||||||
|
self::startSession();
|
||||||
|
session_regenerate_id(true);
|
||||||
|
$_SESSION['user_id'] = $benutzerId;
|
||||||
|
$_SESSION['login_time'] = time();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function abmelden(): void
|
||||||
|
{
|
||||||
|
self::startSession();
|
||||||
|
$_SESSION = [];
|
||||||
|
if (ini_get('session.use_cookies')) {
|
||||||
|
$p = session_get_cookie_params();
|
||||||
|
setcookie(session_name(), '', time() - 42000,
|
||||||
|
$p['path'], $p['domain'], (bool)$p['secure'], (bool)$p['httponly']);
|
||||||
|
}
|
||||||
|
session_destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function benutzerId(): ?int
|
||||||
|
{
|
||||||
|
return isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function benutzer(): ?array
|
||||||
|
{
|
||||||
|
$id = self::benutzerId();
|
||||||
|
return $id === null ? null : Benutzer::find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function csrfToken(): string
|
||||||
|
{
|
||||||
|
self::startSession();
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
return $_SESSION['csrf_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function pruefeCsrfToken(string $token): void
|
||||||
|
{
|
||||||
|
self::startSession();
|
||||||
|
if (!self::istGueltigerCsrfToken($token)) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit('CSRF-Token ungültig.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reiner Vergleich ohne Seiteneffekt, damit isoliert testbar. */
|
||||||
|
public static function istGueltigerCsrfToken(string $token): bool
|
||||||
|
{
|
||||||
|
$gespeichert = $_SESSION['csrf_token'] ?? '';
|
||||||
|
return $gespeichert !== '' && hash_equals($gespeichert, $token);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/Config.php
Normal file
44
src/Config.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager;
|
||||||
|
|
||||||
|
class Config
|
||||||
|
{
|
||||||
|
private static array $data = [];
|
||||||
|
|
||||||
|
public static function load(): void
|
||||||
|
{
|
||||||
|
$wurzel = dirname(__DIR__);
|
||||||
|
|
||||||
|
self::$data = [
|
||||||
|
'db_host' => self::env('DB_HOST', 'db'),
|
||||||
|
'db_name' => self::env('DB_NAME', 'finanzmanager'),
|
||||||
|
'db_user' => self::env('DB_USER', 'finanzmanager'),
|
||||||
|
'db_pass' => self::env('DB_PASS', 'finanzmanager'),
|
||||||
|
'app_key' => self::env('APP_KEY', ''),
|
||||||
|
'debug_log' => self::env('DEBUG_LOG', '0') === '1',
|
||||||
|
'log_dir' => $wurzel . '/data/logs',
|
||||||
|
'import_dir' => $wurzel . '/data/import',
|
||||||
|
'base_url' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return self::$data[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur für Tests: erlaubt das gezielte Überschreiben einzelner Werte. */
|
||||||
|
public static function set(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
self::$data[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function env(string $key, string $default): string
|
||||||
|
{
|
||||||
|
$wert = $_ENV[$key] ?? getenv($key);
|
||||||
|
return ($wert === false || $wert === '') ? $default : (string)$wert;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Controllers/BaseController.php
Normal file
43
src/Controllers/BaseController.php
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager\Controllers;
|
||||||
|
|
||||||
|
use Finanzmanager\Auth;
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
abstract class BaseController
|
||||||
|
{
|
||||||
|
protected View $view;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->view = new View();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seiten hinter der Anmeldung rufen das zuerst auf. */
|
||||||
|
protected function verlangeAnmeldung(): void
|
||||||
|
{
|
||||||
|
Auth::verlange();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function render(string $template, array $daten = []): void
|
||||||
|
{
|
||||||
|
$daten['csrfToken'] = Auth::csrfToken();
|
||||||
|
$daten['benutzer'] = Auth::benutzer();
|
||||||
|
$this->view->render($template, $daten);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function post(string $feld, string $standard = ''): string
|
||||||
|
{
|
||||||
|
$wert = $_POST[$feld] ?? $standard;
|
||||||
|
return is_string($wert) ? trim($wert) : $standard;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function query(string $feld, string $standard = ''): string
|
||||||
|
{
|
||||||
|
$wert = $_GET[$feld] ?? $standard;
|
||||||
|
return is_string($wert) ? trim($wert) : $standard;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/Controllers/CockpitController.php
Normal file
19
src/Controllers/CockpitController.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager\Controllers;
|
||||||
|
|
||||||
|
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.
|
||||||
|
$this->render('cockpit/index.php', [
|
||||||
|
'titel' => 'Cockpit',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/Controllers/EinstellungenController.php
Normal file
108
src/Controllers/EinstellungenController.php
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager\Controllers;
|
||||||
|
|
||||||
|
use Finanzmanager\Models\Einstellung;
|
||||||
|
use Finanzmanager\Services\DebugLog;
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der zentrale Einstellungsdialog. Jeder Bereich aus Einstellung::BEREICHE
|
||||||
|
* bekommt einen Reiter; neue Module ergänzen dort ihre Felder, statt eigene
|
||||||
|
* Dialoge aufzumachen.
|
||||||
|
*/
|
||||||
|
class EinstellungenController extends BaseController
|
||||||
|
{
|
||||||
|
public function index(): void
|
||||||
|
{
|
||||||
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
|
$bereich = $this->query('bereich', 'allgemein');
|
||||||
|
if (!array_key_exists($bereich, Einstellung::BEREICHE)) {
|
||||||
|
$bereich = 'allgemein';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->render('einstellungen/index.php', [
|
||||||
|
'titel' => 'Einstellungen',
|
||||||
|
'bereich' => $bereich,
|
||||||
|
'werte' => Einstellung::alle(),
|
||||||
|
'logDateien' => $this->logDateien(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function speichern(): void
|
||||||
|
{
|
||||||
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
|
$bereich = $this->post('bereich', 'allgemein');
|
||||||
|
if (!array_key_exists($bereich, Einstellung::BEREICHE)) {
|
||||||
|
View::redirect('/einstellungen', 'Unbekannter Bereich.', 'fehler');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur Felder des angezeigten Reiters übernehmen – so überschreibt ein
|
||||||
|
// Speichern nicht die Werte der anderen Reiter mit Leerstrings.
|
||||||
|
foreach ($this->felderFuer($bereich) as $schluessel => $typ) {
|
||||||
|
$wert = match ($typ) {
|
||||||
|
'bool' => isset($_POST[$schluessel]) ? '1' : '0',
|
||||||
|
default => $this->post($schluessel),
|
||||||
|
};
|
||||||
|
Einstellung::set($schluessel, $wert, $bereich);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bereich === 'diagnose') {
|
||||||
|
DebugLog::setAktiv(Einstellung::getBool('debug_log_aktiv'));
|
||||||
|
}
|
||||||
|
|
||||||
|
View::redirect('/einstellungen?bereich=' . urlencode($bereich), 'Einstellungen gespeichert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logLeeren(): void
|
||||||
|
{
|
||||||
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
|
$entfernt = 0;
|
||||||
|
foreach ($this->logDateien() as $datei) {
|
||||||
|
if (@unlink($datei['pfad'])) {
|
||||||
|
$entfernt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
View::redirect(
|
||||||
|
'/einstellungen?bereich=diagnose',
|
||||||
|
$entfernt === 0 ? 'Es gab nichts zu löschen.' : "{$entfernt} Logdatei(en) gelöscht."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welche Felder zu welchem Reiter gehören, inklusive Typ.
|
||||||
|
* Bereiche ohne Einträge sind Platzhalter für spätere Phasen.
|
||||||
|
*/
|
||||||
|
private function felderFuer(string $bereich): array
|
||||||
|
{
|
||||||
|
return match ($bereich) {
|
||||||
|
'allgemein' => ['mandant_name' => 'text', 'waehrung' => 'text'],
|
||||||
|
'darstellung' => ['darstellung_thema' => 'text', 'zeilen_pro_seite' => 'text'],
|
||||||
|
'buchhaltung' => ['erster_monat' => 'text'],
|
||||||
|
'diagnose' => ['debug_log_aktiv' => 'bool'],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{name:string,pfad:string,groesse:int,geaendert:int}> */
|
||||||
|
private function logDateien(): array
|
||||||
|
{
|
||||||
|
$dateien = [];
|
||||||
|
foreach (glob(rtrim((string)\Finanzmanager\Config::get('log_dir'), '/') . '/*.log') ?: [] as $pfad) {
|
||||||
|
$dateien[] = [
|
||||||
|
'name' => basename($pfad),
|
||||||
|
'pfad' => $pfad,
|
||||||
|
'groesse' => (int)filesize($pfad),
|
||||||
|
'geaendert' => (int)filemtime($pfad),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
usort($dateien, fn($a, $b) => $b['geaendert'] <=> $a['geaendert']);
|
||||||
|
return $dateien;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/Controllers/LoginController.php
Normal file
92
src/Controllers/LoginController.php
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager\Controllers;
|
||||||
|
|
||||||
|
use Finanzmanager\Auth;
|
||||||
|
use Finanzmanager\Models\Benutzer;
|
||||||
|
use Finanzmanager\Services\DebugLog;
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
class LoginController extends BaseController
|
||||||
|
{
|
||||||
|
public function formular(): void
|
||||||
|
{
|
||||||
|
Auth::startSession();
|
||||||
|
|
||||||
|
if (Auth::benutzerId() !== null) {
|
||||||
|
View::redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frische Installation: solange kein Benutzer existiert, führt der
|
||||||
|
// Weg zur Ersteinrichtung statt zur Anmeldung.
|
||||||
|
if (Benutzer::anzahl() === 0) {
|
||||||
|
View::redirect('/einrichtung');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->render('auth/login.php', ['titel' => 'Anmeldung']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function anmelden(): void
|
||||||
|
{
|
||||||
|
$benutzername = $this->post('benutzername');
|
||||||
|
$passwort = $_POST['passwort'] ?? '';
|
||||||
|
|
||||||
|
$benutzer = Benutzer::pruefeAnmeldung($benutzername, is_string($passwort) ? $passwort : '');
|
||||||
|
|
||||||
|
if ($benutzer === null) {
|
||||||
|
DebugLog::schreibe('auth', 'Anmeldung fehlgeschlagen', ['benutzername' => $benutzername]);
|
||||||
|
// Bewusst unspezifisch: kein Hinweis darauf, ob der Name existiert.
|
||||||
|
View::redirect('/login', 'Benutzername oder Passwort ist falsch.', 'fehler');
|
||||||
|
}
|
||||||
|
|
||||||
|
Auth::anmelden((int)$benutzer['id']);
|
||||||
|
Benutzer::anmeldungVermerken((int)$benutzer['id']);
|
||||||
|
DebugLog::schreibe('auth', 'Anmeldung erfolgreich', ['benutzer_id' => $benutzer['id']]);
|
||||||
|
|
||||||
|
View::redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function abmelden(): void
|
||||||
|
{
|
||||||
|
Auth::abmelden();
|
||||||
|
View::redirect('/login', 'Du bist abgemeldet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ersteinrichtung: legt den ersten Benutzer an. Danach nicht mehr erreichbar. */
|
||||||
|
public function einrichtungFormular(): void
|
||||||
|
{
|
||||||
|
if (Benutzer::anzahl() > 0) {
|
||||||
|
View::redirect('/login');
|
||||||
|
}
|
||||||
|
$this->render('auth/einrichtung.php', ['titel' => 'Ersteinrichtung']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function einrichtungSpeichern(): void
|
||||||
|
{
|
||||||
|
if (Benutzer::anzahl() > 0) {
|
||||||
|
View::redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$benutzername = $this->post('benutzername');
|
||||||
|
$passwort = (string)($_POST['passwort'] ?? '');
|
||||||
|
$wiederholung = (string)($_POST['passwort_wdh'] ?? '');
|
||||||
|
|
||||||
|
$fehler = match (true) {
|
||||||
|
$benutzername === '' => 'Bitte einen Benutzernamen angeben.',
|
||||||
|
mb_strlen($passwort) < 10 => 'Das Passwort muss mindestens 10 Zeichen lang sein.',
|
||||||
|
$passwort !== $wiederholung => 'Die beiden Passwörter stimmen nicht überein.',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($fehler !== null) {
|
||||||
|
View::redirect('/einrichtung', $fehler, 'fehler');
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = Benutzer::anlegen($benutzername, $passwort, $this->post('anzeigename'));
|
||||||
|
Auth::anmelden($id);
|
||||||
|
|
||||||
|
View::redirect('/', 'Willkommen! Der Zugang ist eingerichtet.');
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/Controllers/PlatzhalterController.php
Normal file
70
src/Controllers/PlatzhalterController.php
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager\Controllers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hält die Navigationspunkte offen, deren Module erst in späteren Phasen
|
||||||
|
* entstehen. Sobald ein Bereich gebaut ist, tritt sein eigener Controller an
|
||||||
|
* diese Stelle und der Eintrag verschwindet hier.
|
||||||
|
*/
|
||||||
|
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',
|
||||||
|
'text' => 'Überweisungen und Terminüberweisungen, abgesendet über FinTS.',
|
||||||
|
],
|
||||||
|
'auswertungen' => [
|
||||||
|
'titel' => 'Auswertungen',
|
||||||
|
'phase' => 'Phase 4',
|
||||||
|
'text' => 'Summen-, Buchungs-, Vergleichs- und Budgetbericht samt Grafiken.',
|
||||||
|
],
|
||||||
|
'planung' => [
|
||||||
|
'titel' => 'Planung',
|
||||||
|
'phase' => 'Phase 3',
|
||||||
|
'text' => 'Budgetplaner für das Jahresbudget und der Monatsabschluss.',
|
||||||
|
],
|
||||||
|
'wertpapiere' => [
|
||||||
|
'titel' => 'Wertpapiere',
|
||||||
|
'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
|
||||||
|
{
|
||||||
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
|
$daten = self::BEREICHE[$bereich] ?? null;
|
||||||
|
if ($daten === null) {
|
||||||
|
http_response_code(404);
|
||||||
|
$this->view->render('layout/404.php', ['pfad' => '/' . $bereich]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->render('layout/platzhalter.php', [
|
||||||
|
'titel' => $daten['titel'],
|
||||||
|
'phase' => $daten['phase'],
|
||||||
|
'text' => $daten['text'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> Alle Pfade, die dieser Controller bedient. */
|
||||||
|
public static function pfade(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::BEREICHE);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/Database.php
Normal file
120
src/Database.php
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager;
|
||||||
|
|
||||||
|
use Finanzmanager\Services\DebugLog;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schmaler PDO-Wrapper. Sämtlicher SQL-Zugriff der Anwendung läuft über
|
||||||
|
* fetchAll/fetchOne/execute – ausschließlich mit Prepared Statements.
|
||||||
|
*/
|
||||||
|
class Database
|
||||||
|
{
|
||||||
|
private static ?PDO $instance = null;
|
||||||
|
private static int $txDepth = 0;
|
||||||
|
|
||||||
|
public static function get(): PDO
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
$host = Config::get('db_host');
|
||||||
|
$name = Config::get('db_name');
|
||||||
|
|
||||||
|
self::$instance = new PDO(
|
||||||
|
"mysql:host={$host};dbname={$name};charset=utf8mb4",
|
||||||
|
(string)Config::get('db_user'),
|
||||||
|
(string)Config::get('db_pass'),
|
||||||
|
[
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function query(string $sql, array $params = []): \PDOStatement
|
||||||
|
{
|
||||||
|
$start = microtime(true);
|
||||||
|
$stmt = self::get()->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
DebugLog::sql($sql, $params, microtime(true) - $start);
|
||||||
|
return $stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fetchAll(string $sql, array $params = []): array
|
||||||
|
{
|
||||||
|
return self::query($sql, $params)->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fetchOne(string $sql, array $params = []): array|false
|
||||||
|
{
|
||||||
|
return self::query($sql, $params)->fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liefert einen Skalarwert der ersten Spalte, oder $default bei leerem Ergebnis. */
|
||||||
|
public static function fetchValue(string $sql, array $params = [], mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
$wert = self::query($sql, $params)->fetchColumn();
|
||||||
|
return $wert === false ? $default : $wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function execute(string $sql, array $params = []): int
|
||||||
|
{
|
||||||
|
return self::query($sql, $params)->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function lastInsertId(): int
|
||||||
|
{
|
||||||
|
return (int)self::get()->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschachtelbare Transaktionen: nur die äußerste öffnet und schließt
|
||||||
|
* wirklich. Nötig, weil Tests bereits eine Transaktion halten, die
|
||||||
|
* Anwendungslogik darin aber ihrerseits welche startet.
|
||||||
|
*/
|
||||||
|
public static function beginTransaction(): void
|
||||||
|
{
|
||||||
|
if (self::$txDepth === 0) {
|
||||||
|
self::get()->beginTransaction();
|
||||||
|
}
|
||||||
|
self::$txDepth++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function commit(): void
|
||||||
|
{
|
||||||
|
if (self::$txDepth > 0) {
|
||||||
|
self::$txDepth--;
|
||||||
|
}
|
||||||
|
if (self::$txDepth === 0 && self::get()->inTransaction()) {
|
||||||
|
self::get()->commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function rollBack(): void
|
||||||
|
{
|
||||||
|
self::$txDepth = 0;
|
||||||
|
if (self::get()->inTransaction()) {
|
||||||
|
self::get()->rollBack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Führt $fn in einer Transaktion aus und rollt bei jeder Ausnahme zurück. */
|
||||||
|
public static function transaktion(callable $fn): mixed
|
||||||
|
{
|
||||||
|
self::beginTransaction();
|
||||||
|
try {
|
||||||
|
$ergebnis = $fn();
|
||||||
|
self::commit();
|
||||||
|
return $ergebnis;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/Models/Benutzer.php
Normal file
89
src/Models/Benutzer.php
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager\Models;
|
||||||
|
|
||||||
|
use Finanzmanager\Database;
|
||||||
|
|
||||||
|
class Benutzer
|
||||||
|
{
|
||||||
|
public static function find(int $id): ?array
|
||||||
|
{
|
||||||
|
$zeile = Database::fetchOne('SELECT * FROM benutzer WHERE id = ?', [$id]);
|
||||||
|
return $zeile ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function findByName(string $benutzername): ?array
|
||||||
|
{
|
||||||
|
$zeile = Database::fetchOne(
|
||||||
|
'SELECT * FROM benutzer WHERE benutzername = ?',
|
||||||
|
[$benutzername]
|
||||||
|
);
|
||||||
|
return $zeile ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function alle(): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll('SELECT * FROM benutzer ORDER BY benutzername');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function anzahl(): int
|
||||||
|
{
|
||||||
|
return (int)Database::fetchValue('SELECT COUNT(*) FROM benutzer', [], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function anlegen(string $benutzername, string $passwort, string $anzeigename = ''): int
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'INSERT INTO benutzer (benutzername, anzeigename, passwort_hash) VALUES (?, ?, ?)',
|
||||||
|
[$benutzername, $anzeigename !== '' ? $anzeigename : $benutzername, self::hash($passwort)]
|
||||||
|
);
|
||||||
|
return Database::lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function passwortSetzen(int $id, string $passwort): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE benutzer SET passwort_hash = ? WHERE id = ?',
|
||||||
|
[self::hash($passwort), $id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft die Anmeldedaten. Liefert den Benutzer oder null.
|
||||||
|
*
|
||||||
|
* Bei unbekanntem Benutzernamen wird trotzdem ein Hash-Vergleich
|
||||||
|
* durchgeführt, damit die Antwortzeit keinen Rückschluss darauf zulässt,
|
||||||
|
* ob der Name existiert.
|
||||||
|
*/
|
||||||
|
public static function pruefeAnmeldung(string $benutzername, string $passwort): ?array
|
||||||
|
{
|
||||||
|
$benutzer = self::findByName($benutzername);
|
||||||
|
|
||||||
|
if ($benutzer === null) {
|
||||||
|
password_verify($passwort, '$2y$12$' . str_repeat('.', 53));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int)$benutzer['aktiv'] !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password_verify($passwort, (string)$benutzer['passwort_hash'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $benutzer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function anmeldungVermerken(int $id): void
|
||||||
|
{
|
||||||
|
Database::execute('UPDATE benutzer SET letzte_anmeldung = NOW() WHERE id = ?', [$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hash(string $passwort): string
|
||||||
|
{
|
||||||
|
return password_hash($passwort, PASSWORD_DEFAULT);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/Models/Einstellung.php
Normal file
84
src/Models/Einstellung.php
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager\Models;
|
||||||
|
|
||||||
|
use Finanzmanager\Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale Programmeinstellungen. Alle Module hängen ihre Optionen hier ein,
|
||||||
|
* angezeigt werden sie gebündelt im Einstellungsdialog (ein Reiter je Bereich).
|
||||||
|
*/
|
||||||
|
class Einstellung
|
||||||
|
{
|
||||||
|
/** Reiter des Einstellungsdialogs, in Anzeigereihenfolge. */
|
||||||
|
public const BEREICHE = [
|
||||||
|
'allgemein' => 'Allgemein',
|
||||||
|
'darstellung' => 'Darstellung',
|
||||||
|
'buchhaltung' => 'Buchhaltung',
|
||||||
|
'banking' => 'Online-Banking',
|
||||||
|
'import' => 'Import/Export',
|
||||||
|
'wertpapiere' => 'Wertpapiere',
|
||||||
|
'diagnose' => 'Diagnose',
|
||||||
|
];
|
||||||
|
|
||||||
|
private static ?array $cache = null;
|
||||||
|
|
||||||
|
public static function get(string $schluessel, ?string $standard = null): ?string
|
||||||
|
{
|
||||||
|
$alle = self::alle();
|
||||||
|
return array_key_exists($schluessel, $alle) ? $alle[$schluessel] : $standard;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getBool(string $schluessel, bool $standard = false): bool
|
||||||
|
{
|
||||||
|
$wert = self::get($schluessel);
|
||||||
|
return $wert === null ? $standard : in_array($wert, ['1', 'true', 'ja'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getInt(string $schluessel, int $standard = 0): int
|
||||||
|
{
|
||||||
|
$wert = self::get($schluessel);
|
||||||
|
return $wert === null || $wert === '' ? $standard : (int)$wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function set(string $schluessel, ?string $wert, string $bereich = 'allgemein'): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'INSERT INTO einstellungen (schluessel, wert, bereich) VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE wert = VALUES(wert), bereich = VALUES(bereich)',
|
||||||
|
[$schluessel, $wert, $bereich]
|
||||||
|
);
|
||||||
|
self::$cache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,string|null> */
|
||||||
|
public static function alle(): array
|
||||||
|
{
|
||||||
|
if (self::$cache === null) {
|
||||||
|
self::$cache = array_column(
|
||||||
|
Database::fetchAll('SELECT schluessel, wert FROM einstellungen'),
|
||||||
|
'wert',
|
||||||
|
'schluessel'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return self::$cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,array<string,string|null>> Einstellungen je Bereich. */
|
||||||
|
public static function nachBereich(): array
|
||||||
|
{
|
||||||
|
$gruppen = [];
|
||||||
|
foreach (Database::fetchAll('SELECT schluessel, wert, bereich FROM einstellungen ORDER BY bereich, schluessel') as $zeile) {
|
||||||
|
$gruppen[$zeile['bereich']][$zeile['schluessel']] = $zeile['wert'];
|
||||||
|
}
|
||||||
|
return $gruppen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nötig in Tests, deren Transaktion zurückgerollt wird. */
|
||||||
|
public static function cacheLeeren(): void
|
||||||
|
{
|
||||||
|
self::$cache = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/Router.php
Normal file
76
src/Router.php
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager;
|
||||||
|
|
||||||
|
class Router
|
||||||
|
{
|
||||||
|
private array $routen = [];
|
||||||
|
|
||||||
|
public function add(string $methode, string $muster, callable $handler): void
|
||||||
|
{
|
||||||
|
$this->routen[] = [
|
||||||
|
'methode' => strtoupper($methode),
|
||||||
|
'muster' => $muster,
|
||||||
|
'handler' => $handler,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $muster, callable $handler): void
|
||||||
|
{
|
||||||
|
$this->add('GET', $muster, $handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function post(string $muster, callable $handler): void
|
||||||
|
{
|
||||||
|
$this->add('POST', $muster, $handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispatch(): void
|
||||||
|
{
|
||||||
|
$methode = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||||
|
|
||||||
|
// Formulare können die Methode über ein verstecktes Feld überschreiben.
|
||||||
|
if ($methode === 'POST') {
|
||||||
|
$override = $_POST['_method'] ?? $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ?? null;
|
||||||
|
if ($override) {
|
||||||
|
$methode = strtoupper((string)$override);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pfad = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||||
|
$pfad = '/' . trim($pfad, '/');
|
||||||
|
|
||||||
|
// Jeder verändernde Request braucht ein gültiges CSRF-Token.
|
||||||
|
if (in_array($methode, ['POST', 'PUT', 'DELETE', 'PATCH'], true)) {
|
||||||
|
Auth::pruefeCsrfToken((string)($_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->routen as $route) {
|
||||||
|
if ($route['methode'] !== $methode) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$params = self::passt($route['muster'], $pfad);
|
||||||
|
if ($params !== null) {
|
||||||
|
($route['handler'])($params);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(404);
|
||||||
|
(new View())->render('layout/404.php', ['pfad' => $pfad]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Übersetzt `/konten/{id}` in einen regulären Ausdruck mit benannten Gruppen. */
|
||||||
|
public static function passt(string $muster, string $pfad): ?array
|
||||||
|
{
|
||||||
|
$regex = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $muster);
|
||||||
|
|
||||||
|
if (preg_match('#^' . $regex . '$#', $pfad, $treffer)) {
|
||||||
|
return array_filter($treffer, 'is_string', ARRAY_FILTER_USE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
190
src/Services/DebugLog.php
Normal file
190
src/Services/DebugLog.php
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager\Services;
|
||||||
|
|
||||||
|
use Finanzmanager\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein- und ausschaltbares Diagnose-Log zur Rekonstruktion von Fehlern.
|
||||||
|
*
|
||||||
|
* Standardmäßig aus. Eingeschaltet wird es im Einstellungsdialog (Reiter
|
||||||
|
* Diagnose) oder über die Umgebungsvariable DEBUG_LOG=1. Geschrieben wird nach
|
||||||
|
* data/logs/debug-JJJJ-MM-TT.log, ältere Dateien räumt rotate() weg.
|
||||||
|
*
|
||||||
|
* Secrets werden vor dem Schreiben maskiert – ein Diagnose-Log darf niemals
|
||||||
|
* eine Banking-PIN enthalten, auch nicht versehentlich über einen SQL-Parameter.
|
||||||
|
*/
|
||||||
|
class DebugLog
|
||||||
|
{
|
||||||
|
/** Aufbewahrungsdauer der Logdateien in Tagen. */
|
||||||
|
private const AUFBEWAHRUNG_TAGE = 14;
|
||||||
|
|
||||||
|
/** Feld- und Parameternamen, deren Werte nie im Klartext ins Log gehen. */
|
||||||
|
private const GEHEIM = [
|
||||||
|
'pin', 'passwort', 'password', 'passwort_hash', 'password_hash',
|
||||||
|
'secret', 'client_id', 'api_key', 'apitoken', 'token', 'tan',
|
||||||
|
'csrf_token', 'app_key',
|
||||||
|
];
|
||||||
|
|
||||||
|
private static ?bool $aktiv = null;
|
||||||
|
private static ?string $requestId = null;
|
||||||
|
|
||||||
|
public static function aktiv(): bool
|
||||||
|
{
|
||||||
|
if (self::$aktiv === null) {
|
||||||
|
// Env-Schalter gewinnt beim Start; die Einstellung aus der Datenbank
|
||||||
|
// wird erst nachgereicht, sobald sie geladen werden kann (siehe
|
||||||
|
// setAktiv()). So funktioniert das Log auch, wenn die Datenbank
|
||||||
|
// gerade das Problem ist.
|
||||||
|
self::$aktiv = (bool)Config::get('debug_log', false);
|
||||||
|
}
|
||||||
|
return self::$aktiv;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function setAktiv(bool $aktiv): void
|
||||||
|
{
|
||||||
|
self::$aktiv = $aktiv;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function schreibe(string $kategorie, string $nachricht, array $kontext = []): void
|
||||||
|
{
|
||||||
|
if (!self::aktiv()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$zeile = sprintf(
|
||||||
|
"%s [%s] %s %s%s\n",
|
||||||
|
date('Y-m-d H:i:s'),
|
||||||
|
$kategorie,
|
||||||
|
self::requestId(),
|
||||||
|
$nachricht,
|
||||||
|
$kontext === [] ? '' : ' ' . self::kontextAlsText($kontext)
|
||||||
|
);
|
||||||
|
|
||||||
|
self::anhaengen($zeile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bequemer Einstieg für den PDO-Wrapper. */
|
||||||
|
public static function sql(string $sql, array $params, float $dauerSekunden): void
|
||||||
|
{
|
||||||
|
if (!self::aktiv()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::schreibe('sql', preg_replace('/\s+/', ' ', trim($sql)) ?? $sql, [
|
||||||
|
'params' => $params,
|
||||||
|
'ms' => round($dauerSekunden * 1000, 1),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function request(): void
|
||||||
|
{
|
||||||
|
if (!self::aktiv()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::schreibe('request', sprintf(
|
||||||
|
'%s %s',
|
||||||
|
$_SERVER['REQUEST_METHOD'] ?? 'CLI',
|
||||||
|
$_SERVER['REQUEST_URI'] ?? '-'
|
||||||
|
), ['benutzer' => $_SESSION['user_id'] ?? null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fehler(\Throwable $e): void
|
||||||
|
{
|
||||||
|
// Ausnahmen werden immer protokolliert – auch bei ausgeschaltetem
|
||||||
|
// Debug-Log, sonst fehlt bei einem Absturz jede Spur.
|
||||||
|
self::anhaengen(sprintf(
|
||||||
|
"%s [fehler] %s %s: %s @ %s:%d\n%s\n",
|
||||||
|
date('Y-m-d H:i:s'),
|
||||||
|
self::requestId(),
|
||||||
|
$e::class,
|
||||||
|
$e->getMessage(),
|
||||||
|
$e->getFile(),
|
||||||
|
$e->getLine(),
|
||||||
|
$e->getTraceAsString()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maskiert Werte zu Schlüsseln, die nach einem Geheimnis aussehen.
|
||||||
|
* Rekursiv, damit auch verschachtelte Parameterlisten erfasst werden.
|
||||||
|
*/
|
||||||
|
public static function maskiere(array $daten): array
|
||||||
|
{
|
||||||
|
$sauber = [];
|
||||||
|
foreach ($daten as $schluessel => $wert) {
|
||||||
|
if (is_string($schluessel) && self::istGeheim($schluessel)) {
|
||||||
|
$sauber[$schluessel] = '***';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$sauber[$schluessel] = is_array($wert) ? self::maskiere($wert) : $wert;
|
||||||
|
}
|
||||||
|
return $sauber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function pfad(?string $tag = null): string
|
||||||
|
{
|
||||||
|
return rtrim((string)Config::get('log_dir'), '/') . '/debug-' . ($tag ?? date('Y-m-d')) . '.log';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt Logdateien, die älter als AUFBEWAHRUNG_TAGE sind. */
|
||||||
|
public static function rotiere(): int
|
||||||
|
{
|
||||||
|
$verzeichnis = (string)Config::get('log_dir');
|
||||||
|
if (!is_dir($verzeichnis)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$grenze = time() - self::AUFBEWAHRUNG_TAGE * 86400;
|
||||||
|
$entfernt = 0;
|
||||||
|
foreach (glob(rtrim($verzeichnis, '/') . '/debug-*.log') ?: [] as $datei) {
|
||||||
|
if (filemtime($datei) < $grenze && @unlink($datei)) {
|
||||||
|
$entfernt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $entfernt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function istGeheim(string $schluessel): bool
|
||||||
|
{
|
||||||
|
$klein = strtolower($schluessel);
|
||||||
|
foreach (self::GEHEIM as $begriff) {
|
||||||
|
if (str_contains($klein, $begriff)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function kontextAlsText(array $kontext): string
|
||||||
|
{
|
||||||
|
return (string)json_encode(
|
||||||
|
self::maskiere($kontext),
|
||||||
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Klammert alle Zeilen eines Requests zusammen. */
|
||||||
|
private static function requestId(): string
|
||||||
|
{
|
||||||
|
if (self::$requestId === null) {
|
||||||
|
self::$requestId = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||||
|
}
|
||||||
|
return self::$requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function anhaengen(string $zeile): void
|
||||||
|
{
|
||||||
|
$verzeichnis = (string)Config::get('log_dir');
|
||||||
|
if ($verzeichnis === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!is_dir($verzeichnis)) {
|
||||||
|
@mkdir($verzeichnis, 0775, true);
|
||||||
|
}
|
||||||
|
@file_put_contents(self::pfad(), $zeile, FILE_APPEND | LOCK_EX);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/View.php
Normal file
89
src/View.php
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager;
|
||||||
|
|
||||||
|
class View
|
||||||
|
{
|
||||||
|
private string $viewDir;
|
||||||
|
private array $flash = [];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->viewDir = __DIR__ . '/Views';
|
||||||
|
|
||||||
|
Auth::startSession();
|
||||||
|
if (isset($_SESSION['flash'])) {
|
||||||
|
$this->flash = $_SESSION['flash'];
|
||||||
|
unset($_SESSION['flash']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(string $template, array $daten = []): void
|
||||||
|
{
|
||||||
|
$daten['flash'] = $this->flash;
|
||||||
|
$daten['aktuellerPfad'] = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||||
|
|
||||||
|
extract($daten, EXTR_SKIP);
|
||||||
|
include $this->viewDir . '/' . $template;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function redirect(string $pfad, string $meldung = '', string $typ = 'erfolg'): void
|
||||||
|
{
|
||||||
|
Auth::startSession();
|
||||||
|
if ($meldung !== '') {
|
||||||
|
$_SESSION['flash'][] = ['typ' => $typ, 'meldung' => $meldung];
|
||||||
|
}
|
||||||
|
header('Location: ' . $pfad);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function h(mixed $wert): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars((string)$wert, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beträge im deutschen Format. Das Leerzeichen vor dem Euro-Zeichen ist
|
||||||
|
* geschützt, sonst rutscht das Zeichen in schmalen Tabellenspalten allein
|
||||||
|
* in die nächste Zeile.
|
||||||
|
*/
|
||||||
|
public static function betrag(mixed $wert, bool $mitWaehrung = true): string
|
||||||
|
{
|
||||||
|
$text = number_format((float)$wert, 2, ',', '.');
|
||||||
|
return $mitWaehrung ? $text . "\u{00A0}€" : $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function datum(?string $datum): string
|
||||||
|
{
|
||||||
|
if (!$datum) {
|
||||||
|
return '–';
|
||||||
|
}
|
||||||
|
$d = \DateTime::createFromFormat('Y-m-d', substr($datum, 0, 10));
|
||||||
|
return $d ? $d->format('d.m.Y') : $datum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSS-Klasse für Beträge: negative Werte werden rot dargestellt. */
|
||||||
|
public static function betragKlasse(mixed $wert): string
|
||||||
|
{
|
||||||
|
return (float)$wert < 0 ? 'betrag negativ' : 'betrag';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Farbschema für das data-thema-Attribut: 'hell', 'dunkel' oder '' für die
|
||||||
|
* Systemvorgabe. Fällt auf die Systemvorgabe zurück, wenn die Einstellung
|
||||||
|
* nicht lesbar ist – die Fehlerseite muss auch ohne Datenbank darstellbar
|
||||||
|
* bleiben.
|
||||||
|
*/
|
||||||
|
public static function thema(): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$thema = \Finanzmanager\Models\Einstellung::get('darstellung_thema', 'system');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($thema, ['hell', 'dunkel'], true) ? $thema : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/Views/auth/einrichtung.php
Normal file
34
src/Views/auth/einrichtung.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
/** @var string $csrfToken */
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<p class="hinweis">
|
||||||
|
Noch kein Zugang vorhanden. Lege jetzt den ersten Benutzer an – danach ist
|
||||||
|
diese Seite nicht mehr erreichbar.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" action="/einrichtung" class="formular">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
|
|
||||||
|
<label for="benutzername">Benutzername</label>
|
||||||
|
<input type="text" id="benutzername" name="benutzername" autocomplete="username" required autofocus>
|
||||||
|
|
||||||
|
<label for="anzeigename">Anzeigename <span class="optional">(optional)</span></label>
|
||||||
|
<input type="text" id="anzeigename" name="anzeigename" autocomplete="name">
|
||||||
|
|
||||||
|
<label for="passwort">Passwort <span class="optional">(mindestens 10 Zeichen)</span></label>
|
||||||
|
<input type="password" id="passwort" name="passwort" autocomplete="new-password" required minlength="10">
|
||||||
|
|
||||||
|
<label for="passwort_wdh">Passwort wiederholen</label>
|
||||||
|
<input type="password" id="passwort_wdh" name="passwort_wdh" autocomplete="new-password" required minlength="10">
|
||||||
|
|
||||||
|
<button type="submit" class="knopf primaer">Zugang einrichten</button>
|
||||||
|
</form>
|
||||||
|
<?php
|
||||||
|
$inhalt = (string)ob_get_clean();
|
||||||
|
|
||||||
|
include __DIR__ . '/../layout/schlicht.php';
|
||||||
23
src/Views/auth/login.php
Normal file
23
src/Views/auth/login.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
/** @var string $csrfToken */
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<form method="post" action="/login" class="formular">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
|
|
||||||
|
<label for="benutzername">Benutzername</label>
|
||||||
|
<input type="text" id="benutzername" name="benutzername" autocomplete="username" required autofocus>
|
||||||
|
|
||||||
|
<label for="passwort">Passwort</label>
|
||||||
|
<input type="password" id="passwort" name="passwort" autocomplete="current-password" required>
|
||||||
|
|
||||||
|
<button type="submit" class="knopf primaer">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
<?php
|
||||||
|
$inhalt = (string)ob_get_clean();
|
||||||
|
|
||||||
|
include __DIR__ . '/../layout/schlicht.php';
|
||||||
40
src/Views/cockpit/index.php
Normal file
40
src/Views/cockpit/index.php
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
include __DIR__ . '/../layout/kopf.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="tableiste">
|
||||||
|
<span class="tab aktiv">Cockpit</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="kachel">
|
||||||
|
<header><h2>Einnahmen und Ausgaben</h2></header>
|
||||||
|
<p class="platzhalter">Monatsvergleich aus den kategorisierten Buchungen (Phase 4).</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="kachel">
|
||||||
|
<header><h2>Kontostände</h2></header>
|
||||||
|
<p class="platzhalter">Übersicht aller Konten mit aktuellem und erwartetem Saldo (Phase 1).</p>
|
||||||
|
</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>Phase 2 – Buchungen, Kontoblatt, Splitbuchungen</li>
|
||||||
|
<li>Phase 3 – Budgetplaner und Monatsabschluss</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||||
123
src/Views/einstellungen/index.php
Normal file
123
src/Views/einstellungen/index.php
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Finanzmanager\Models\Einstellung;
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
/** @var string $bereich */
|
||||||
|
/** @var array<string,string|null> $werte */
|
||||||
|
/** @var list<array{name:string,pfad:string,groesse:int,geaendert:int}> $logDateien */
|
||||||
|
/** @var string $csrfToken */
|
||||||
|
|
||||||
|
$wert = static fn(string $k, string $standard = ''): string => (string)($werte[$k] ?? $standard);
|
||||||
|
|
||||||
|
include __DIR__ . '/../layout/kopf.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="tableiste">
|
||||||
|
<?php foreach (Einstellung::BEREICHE as $schluessel => $beschriftung): ?>
|
||||||
|
<a href="/einstellungen?bereich=<?= View::h($schluessel) ?>"
|
||||||
|
class="tab <?= $bereich === $schluessel ? 'aktiv' : '' ?>"><?= View::h($beschriftung) ?></a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/einstellungen" class="formular breit">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
|
<input type="hidden" name="bereich" value="<?= View::h($bereich) ?>">
|
||||||
|
|
||||||
|
<?php if ($bereich === 'allgemein'): ?>
|
||||||
|
<label for="mandant_name">Bezeichnung</label>
|
||||||
|
<input type="text" id="mandant_name" name="mandant_name" value="<?= View::h($wert('mandant_name', 'Privat')) ?>">
|
||||||
|
<p class="feldhinweis">Erscheint im Kopf von Berichten und Ausdrucken.</p>
|
||||||
|
|
||||||
|
<label for="waehrung">Währung</label>
|
||||||
|
<input type="text" id="waehrung" name="waehrung" value="<?= View::h($wert('waehrung', 'EUR')) ?>" size="6">
|
||||||
|
|
||||||
|
<?php elseif ($bereich === 'darstellung'): ?>
|
||||||
|
<label for="darstellung_thema">Farbschema</label>
|
||||||
|
<select id="darstellung_thema" name="darstellung_thema">
|
||||||
|
<?php foreach (['system' => 'Wie das Betriebssystem', 'hell' => 'Hell', 'dunkel' => 'Dunkel'] as $k => $v): ?>
|
||||||
|
<option value="<?= View::h($k) ?>" <?= $wert('darstellung_thema', 'system') === $k ? 'selected' : '' ?>>
|
||||||
|
<?= View::h($v) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="zeilen_pro_seite">Zeilen je Seite</label>
|
||||||
|
<input type="number" id="zeilen_pro_seite" name="zeilen_pro_seite" min="20" max="1000" step="10"
|
||||||
|
value="<?= View::h($wert('zeilen_pro_seite', '100')) ?>">
|
||||||
|
<p class="feldhinweis">Gilt für Kontoblatt und Berichtslisten.</p>
|
||||||
|
|
||||||
|
<?php elseif ($bereich === 'buchhaltung'): ?>
|
||||||
|
<label for="erster_monat">Erster Monat des Haushaltsjahres</label>
|
||||||
|
<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))) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<p class="feldhinweis">Bestimmt, wo Budgetplaner und Jahresauswertungen beginnen.</p>
|
||||||
|
|
||||||
|
<?php elseif ($bereich === 'diagnose'): ?>
|
||||||
|
<label class="ankreuzfeld">
|
||||||
|
<input type="checkbox" name="debug_log_aktiv" value="1"
|
||||||
|
<?= $wert('debug_log_aktiv') === '1' ? 'checked' : '' ?>>
|
||||||
|
Debug-Log einschalten
|
||||||
|
</label>
|
||||||
|
<p class="feldhinweis">
|
||||||
|
Protokolliert Requests, SQL-Abfragen mit Laufzeit und – ab Phase 5 – die
|
||||||
|
FinTS-Dialogschritte nach <code>data/logs/</code>. Zugangsdaten, PINs und
|
||||||
|
TANs werden dabei maskiert. Im Normalbetrieb ausgeschaltet lassen: das Log
|
||||||
|
wächst schnell.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Vorhandene Logdateien</h3>
|
||||||
|
<?php if ($logDateien === []): ?>
|
||||||
|
<p class="platzhalter">Keine Logdateien vorhanden.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<table class="liste">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Datei</th><th class="rechts">Größe</th><th>Geändert</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($logDateien as $datei): ?>
|
||||||
|
<tr>
|
||||||
|
<td><code><?= View::h($datei['name']) ?></code></td>
|
||||||
|
<td class="rechts"><?= View::h(number_format($datei['groesse'] / 1024, 1, ',', '.')) ?> kB</td>
|
||||||
|
<td><?= View::h(date('d.m.Y H:i', $datei['geaendert'])) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="platzhalter">
|
||||||
|
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)): ?>
|
||||||
|
<div class="knopfleiste">
|
||||||
|
<button type="submit" class="knopf primaer">Speichern</button>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if ($bereich === 'diagnose' && $logDateien !== []): ?>
|
||||||
|
<form method="post" action="/einstellungen/log-leeren" class="formular breit"
|
||||||
|
onsubmit="return confirm('Alle Logdateien löschen?');">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
|
<div class="knopfleiste">
|
||||||
|
<button type="submit" class="knopf">Logdateien löschen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||||
16
src/Views/layout/404.php
Normal file
16
src/Views/layout/404.php
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
/** @var string $pfad */
|
||||||
|
|
||||||
|
$titel = 'Seite nicht gefunden';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<p class="hinweis">Die Adresse <code><?= View::h($pfad) ?></code> gibt es nicht.</p>
|
||||||
|
<p><a href="/" class="knopf">Zum Cockpit</a></p>
|
||||||
|
<?php
|
||||||
|
$inhalt = (string)ob_get_clean();
|
||||||
|
|
||||||
|
include __DIR__ . '/schlicht.php';
|
||||||
14
src/Views/layout/500.php
Normal file
14
src/Views/layout/500.php
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
$titel = 'Fehler';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<p class="hinweis">Da ist etwas schiefgegangen.</p>
|
||||||
|
<p>Einzelheiten stehen im Diagnose-Log, sofern es eingeschaltet ist
|
||||||
|
(Einstellungen → Diagnose).</p>
|
||||||
|
<p><a href="/" class="knopf">Zum Cockpit</a></p>
|
||||||
|
<?php
|
||||||
|
$inhalt = (string)ob_get_clean();
|
||||||
|
|
||||||
|
include __DIR__ . '/schlicht.php';
|
||||||
6
src/Views/layout/fuss.php
Normal file
6
src/Views/layout/fuss.php
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
73
src/Views/layout/kopf.php
Normal file
73
src/Views/layout/kopf.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
/** @var string $titel */
|
||||||
|
/** @var array $flash */
|
||||||
|
/** @var string|null $aktuellerPfad */
|
||||||
|
/** @var array|null $benutzer */
|
||||||
|
|
||||||
|
$navigation = [
|
||||||
|
['pfad' => '/', 'name' => 'Cockpit', 'icon' => '⌂'],
|
||||||
|
['pfad' => '/konten', 'name' => 'Kontenübersicht', 'icon' => '▤'],
|
||||||
|
['pfad' => '/zahlungsverkehr', 'name' => 'Zahlungsverkehr', 'icon' => '⇄'],
|
||||||
|
['pfad' => '/auswertungen', 'name' => 'Auswertungen', 'icon' => '◱'],
|
||||||
|
['pfad' => '/planung', 'name' => 'Planung', 'icon' => '◷'],
|
||||||
|
['pfad' => '/wertpapiere', 'name' => 'Wertpapiere', 'icon' => '↗'],
|
||||||
|
['pfad' => '/verwaltung', 'name' => 'Verwaltung', 'icon' => '⚙'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$istAktiv = static function (string $pfad) use ($aktuellerPfad): bool {
|
||||||
|
if ($pfad === '/') {
|
||||||
|
return $aktuellerPfad === '/' || $aktuellerPfad === '';
|
||||||
|
}
|
||||||
|
return is_string($aktuellerPfad) && str_starts_with($aktuellerPfad, $pfad);
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de"<?= ($thema = View::thema()) !== '' ? ' data-thema="' . View::h($thema) . '"' : '' ?>>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= View::h($titel ?? 'Finanzmanager') ?> – Finanzmanager</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>€</text></svg>">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="rahmen">
|
||||||
|
<nav class="seitenleiste" aria-label="Hauptnavigation">
|
||||||
|
<div class="marke">Finanzmanager</div>
|
||||||
|
<ul>
|
||||||
|
<?php foreach ($navigation as $eintrag): ?>
|
||||||
|
<li>
|
||||||
|
<a href="<?= View::h($eintrag['pfad']) ?>"
|
||||||
|
class="<?= $istAktiv($eintrag['pfad']) ? 'aktiv' : '' ?>">
|
||||||
|
<span class="icon" aria-hidden="true"><?= $eintrag['icon'] ?></span>
|
||||||
|
<?= View::h($eintrag['name']) ?>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="hauptbereich">
|
||||||
|
<header class="werkzeugleiste">
|
||||||
|
<h1><?= View::h($titel ?? 'Finanzmanager') ?></h1>
|
||||||
|
<div class="werkzeuge">
|
||||||
|
<a href="/einstellungen" class="werkzeug" title="Einstellungen">⚙ Einstellungen</a>
|
||||||
|
<?php if (!empty($benutzer)): ?>
|
||||||
|
<span class="benutzer"><?= View::h($benutzer['anzeigename'] ?: $benutzer['benutzername']) ?></span>
|
||||||
|
<form method="post" action="/logout" class="inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken ?? '') ?>">
|
||||||
|
<button type="submit" class="werkzeug">Abmelden</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="inhalt">
|
||||||
|
<?php foreach ($flash ?? [] as $meldung): ?>
|
||||||
|
<div class="meldung meldung-<?= View::h($meldung['typ']) ?>" role="status">
|
||||||
|
<?= View::h($meldung['meldung']) ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
18
src/Views/layout/platzhalter.php
Normal file
18
src/Views/layout/platzhalter.php
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
/** @var string $phase */
|
||||||
|
/** @var string $text */
|
||||||
|
|
||||||
|
include __DIR__ . '/kopf.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="kachel einzeln">
|
||||||
|
<p class="phasenmarke"><?= View::h($phase) ?></p>
|
||||||
|
<p><?= View::h($text) ?></p>
|
||||||
|
<p class="platzhalter">Dieser Bereich ist noch nicht gebaut. Der Ablauf steht in
|
||||||
|
<code>PLAN.md</code>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include __DIR__ . '/fuss.php'; ?>
|
||||||
33
src/Views/layout/schlicht.php
Normal file
33
src/Views/layout/schlicht.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rahmen ohne Navigation – für Anmeldung und Ersteinrichtung.
|
||||||
|
* Erwartet $titel und $inhalt (bereits gerendertes HTML-Fragment).
|
||||||
|
*/
|
||||||
|
/** @var string $titel */
|
||||||
|
/** @var string $inhalt */
|
||||||
|
/** @var array $flash */
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de"<?= ($thema = View::thema()) !== '' ? ' data-thema="' . View::h($thema) . '"' : '' ?>>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= View::h($titel) ?> – Finanzmanager</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>€</text></svg>">
|
||||||
|
</head>
|
||||||
|
<body class="schlicht">
|
||||||
|
<div class="karte-mitte">
|
||||||
|
<div class="marke gross">Finanzmanager</div>
|
||||||
|
<?php foreach ($flash ?? [] as $meldung): ?>
|
||||||
|
<div class="meldung meldung-<?= View::h($meldung['typ']) ?>" role="status">
|
||||||
|
<?= View::h($meldung['meldung']) ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?= $inhalt ?>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
102
tests/01_AuthTest.php
Normal file
102
tests/01_AuthTest.php
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Finanzmanager\Auth;
|
||||||
|
use Finanzmanager\Models\Benutzer;
|
||||||
|
|
||||||
|
class AuthTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 01: Anmeldung und CSRF';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->benutzerAnlegenUndPruefen();
|
||||||
|
$this->falschesPasswort();
|
||||||
|
$this->unbekannterBenutzer();
|
||||||
|
$this->inaktiverBenutzer();
|
||||||
|
$this->csrfToken();
|
||||||
|
$this->cookieAttribute();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function benutzerAnlegenUndPruefen(): void
|
||||||
|
{
|
||||||
|
$name = 'test_' . bin2hex(random_bytes(4));
|
||||||
|
$id = Benutzer::anlegen($name, 'geheim1234567', 'Testnutzer');
|
||||||
|
|
||||||
|
$this->assertGreaterThan(0, $id, 'Benutzer bekommt eine ID');
|
||||||
|
|
||||||
|
$benutzer = Benutzer::pruefeAnmeldung($name, 'geheim1234567');
|
||||||
|
$this->assertNotEmpty($benutzer, 'Anmeldung mit richtigem Passwort gelingt');
|
||||||
|
$this->assertEquals('Testnutzer', $benutzer['anzeigename'] ?? '', 'Anzeigename wird übernommen');
|
||||||
|
|
||||||
|
// Der Hash darf nie das Klartextpasswort enthalten.
|
||||||
|
$this->assert(
|
||||||
|
!str_contains((string)($benutzer['passwort_hash'] ?? ''), 'geheim'),
|
||||||
|
'Passwort wird gehasht abgelegt'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function falschesPasswort(): void
|
||||||
|
{
|
||||||
|
$name = 'test_' . bin2hex(random_bytes(4));
|
||||||
|
Benutzer::anlegen($name, 'richtigespasswort');
|
||||||
|
|
||||||
|
$this->assertNull(
|
||||||
|
Benutzer::pruefeAnmeldung($name, 'falschespasswort'),
|
||||||
|
'Falsches Passwort wird abgewiesen'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unbekannterBenutzer(): void
|
||||||
|
{
|
||||||
|
$this->assertNull(
|
||||||
|
Benutzer::pruefeAnmeldung('gibtesnicht_' . bin2hex(random_bytes(4)), 'egal'),
|
||||||
|
'Unbekannter Benutzername wird abgewiesen'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function inaktiverBenutzer(): void
|
||||||
|
{
|
||||||
|
$name = 'test_' . bin2hex(random_bytes(4));
|
||||||
|
$id = Benutzer::anlegen($name, 'geheim1234567');
|
||||||
|
Finanzmanager\Database::execute('UPDATE benutzer SET aktiv = 0 WHERE id = ?', [$id]);
|
||||||
|
|
||||||
|
$this->assertNull(
|
||||||
|
Benutzer::pruefeAnmeldung($name, 'geheim1234567'),
|
||||||
|
'Deaktivierter Benutzer kann sich nicht anmelden'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function csrfToken(): void
|
||||||
|
{
|
||||||
|
$_SESSION['csrf_token'] = 'abc123';
|
||||||
|
|
||||||
|
$this->assert(Auth::istGueltigerCsrfToken('abc123'), 'Richtiges CSRF-Token wird akzeptiert');
|
||||||
|
$this->assert(!Auth::istGueltigerCsrfToken('falsch'), 'Falsches CSRF-Token wird abgelehnt');
|
||||||
|
$this->assert(!Auth::istGueltigerCsrfToken(''), 'Leeres CSRF-Token wird abgelehnt');
|
||||||
|
|
||||||
|
$_SESSION['csrf_token'] = '';
|
||||||
|
$this->assert(
|
||||||
|
!Auth::istGueltigerCsrfToken(''),
|
||||||
|
'Ohne gespeichertes Token schlägt jede Prüfung fehl'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cookieAttribute(): void
|
||||||
|
{
|
||||||
|
$params = Auth::cookieParams();
|
||||||
|
|
||||||
|
$this->assert($params['httponly'] === true, 'Session-Cookie ist HttpOnly');
|
||||||
|
$this->assertEquals('Lax', $params['samesite'], 'Session-Cookie nutzt SameSite=Lax');
|
||||||
|
|
||||||
|
// Ohne HTTPS kein Secure-Flag – sonst wäre der lokale Betrieb auf
|
||||||
|
// http://localhost:8000 nicht möglich.
|
||||||
|
unset($_SERVER['HTTPS'], $_SERVER['HTTP_X_FORWARDED_PROTO']);
|
||||||
|
$this->assert(Auth::cookieParams()['secure'] === false, 'Ohne HTTPS kein Secure-Flag');
|
||||||
|
|
||||||
|
$_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https';
|
||||||
|
$this->assert(Auth::cookieParams()['secure'] === true, 'Hinter HTTPS-Proxy wird Secure gesetzt');
|
||||||
|
unset($_SERVER['HTTP_X_FORWARDED_PROTO']);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
tests/02_EinstellungenTest.php
Normal file
82
tests/02_EinstellungenTest.php
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Finanzmanager\Models\Einstellung;
|
||||||
|
|
||||||
|
class EinstellungenTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 02: Einstellungen';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->grundwerteVorhanden();
|
||||||
|
$this->schreibenUndLesen();
|
||||||
|
$this->typHelfer();
|
||||||
|
$this->bereicheVollstaendig();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function grundwerteVorhanden(): void
|
||||||
|
{
|
||||||
|
Einstellung::cacheLeeren();
|
||||||
|
|
||||||
|
$this->assertEquals('Privat', Einstellung::get('mandant_name'), 'Grundwert mandant_name aus der Migration');
|
||||||
|
$this->assertEquals('EUR', Einstellung::get('waehrung'), 'Grundwert waehrung aus der Migration');
|
||||||
|
$this->assertEquals('standardwert', Einstellung::get('gibt_es_nicht', 'standardwert'),
|
||||||
|
'Unbekannter Schlüssel liefert den Standardwert');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function schreibenUndLesen(): void
|
||||||
|
{
|
||||||
|
Einstellung::set('mandant_name', 'Haushalt Schepmann', 'allgemein');
|
||||||
|
$this->assertEquals('Haushalt Schepmann', Einstellung::get('mandant_name'),
|
||||||
|
'Geänderter Wert wird zurückgelesen');
|
||||||
|
|
||||||
|
// set() muss den Cache verwerfen, sonst liefert get() den alten Wert.
|
||||||
|
Einstellung::set('mandant_name', 'Zweite Änderung', 'allgemein');
|
||||||
|
$this->assertEquals('Zweite Änderung', Einstellung::get('mandant_name'),
|
||||||
|
'Zweite Änderung umgeht den Cache');
|
||||||
|
|
||||||
|
Einstellung::set('neuer_schluessel', 'wert', 'diagnose');
|
||||||
|
$gruppen = Einstellung::nachBereich();
|
||||||
|
$this->assert(
|
||||||
|
isset($gruppen['diagnose']['neuer_schluessel']),
|
||||||
|
'Neuer Schlüssel landet im richtigen Bereich'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function typHelfer(): void
|
||||||
|
{
|
||||||
|
Einstellung::set('debug_log_aktiv', '1', 'diagnose');
|
||||||
|
$this->assert(Einstellung::getBool('debug_log_aktiv'), 'getBool erkennt "1" als wahr');
|
||||||
|
|
||||||
|
Einstellung::set('debug_log_aktiv', '0', 'diagnose');
|
||||||
|
$this->assert(!Einstellung::getBool('debug_log_aktiv'), 'getBool erkennt "0" als falsch');
|
||||||
|
|
||||||
|
$this->assert(
|
||||||
|
Einstellung::getBool('gibt_es_nicht', true),
|
||||||
|
'getBool nutzt den Standardwert bei unbekanntem Schlüssel'
|
||||||
|
);
|
||||||
|
|
||||||
|
Einstellung::set('zeilen_pro_seite', '250', 'darstellung');
|
||||||
|
$this->assertEquals(250, Einstellung::getInt('zeilen_pro_seite'), 'getInt wandelt in eine Zahl');
|
||||||
|
$this->assertEquals(100, Einstellung::getInt('gibt_es_nicht', 100), 'getInt nutzt den Standardwert');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bereicheVollstaendig(): void
|
||||||
|
{
|
||||||
|
// Jeder in der Datenbank benutzte Bereich braucht einen Reiter im Dialog,
|
||||||
|
// sonst wären die Werte nicht mehr erreichbar.
|
||||||
|
$bereiche = array_column(
|
||||||
|
Finanzmanager\Database::fetchAll('SELECT DISTINCT bereich FROM einstellungen'),
|
||||||
|
'bereich'
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($bereiche as $bereich) {
|
||||||
|
$this->assert(
|
||||||
|
array_key_exists($bereich, Einstellung::BEREICHE),
|
||||||
|
"Bereich '{$bereich}' hat einen Reiter im Einstellungsdialog"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
tests/03_RouterTest.php
Normal file
47
tests/03_RouterTest.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Finanzmanager\Router;
|
||||||
|
|
||||||
|
class RouterTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 03: Routing';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->einfachePfade();
|
||||||
|
$this->platzhalter();
|
||||||
|
$this->keineTeiltreffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function einfachePfade(): void
|
||||||
|
{
|
||||||
|
$this->assertEquals([], Router::passt('/', '/'), 'Wurzelpfad passt');
|
||||||
|
$this->assertEquals([], Router::passt('/einstellungen', '/einstellungen'), 'Fester Pfad passt');
|
||||||
|
$this->assertNull(Router::passt('/einstellungen', '/konten'), 'Anderer Pfad passt nicht');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function platzhalter(): void
|
||||||
|
{
|
||||||
|
$treffer = Router::passt('/konten/{id}', '/konten/42');
|
||||||
|
$this->assertEquals('42', $treffer['id'] ?? null, 'Platzhalter liefert den Wert');
|
||||||
|
|
||||||
|
$treffer = Router::passt('/konten/{id}/buchungen/{buchung}', '/konten/7/buchungen/199');
|
||||||
|
$this->assertEquals('7', $treffer['id'] ?? null, 'Erster Platzhalter stimmt');
|
||||||
|
$this->assertEquals('199', $treffer['buchung'] ?? null, 'Zweiter Platzhalter stimmt');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function keineTeiltreffer(): void
|
||||||
|
{
|
||||||
|
// Der Platzhalter darf keinen Schrägstrich verschlucken – sonst würde
|
||||||
|
// /konten/{id} auch /konten/1/bearbeiten abfangen.
|
||||||
|
$this->assertNull(
|
||||||
|
Router::passt('/konten/{id}', '/konten/1/bearbeiten'),
|
||||||
|
'Platzhalter greift nicht über Pfadgrenzen hinweg'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertNull(Router::passt('/konten', '/konten/1'), 'Fester Pfad matcht keinen Unterpfad');
|
||||||
|
$this->assertNull(Router::passt('/konten', '/xkonten'), 'Kein Treffer bei Präfix davor');
|
||||||
|
}
|
||||||
|
}
|
||||||
93
tests/04_DebugLogTest.php
Normal file
93
tests/04_DebugLogTest.php
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Finanzmanager\Config;
|
||||||
|
use Finanzmanager\Services\DebugLog;
|
||||||
|
|
||||||
|
class DebugLogTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 04: Debug-Log';
|
||||||
|
|
||||||
|
private string $urspruenglichesVerzeichnis = '';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->urspruenglichesVerzeichnis = (string)Config::get('log_dir');
|
||||||
|
|
||||||
|
$verzeichnis = $this->tempPfad('_logs');
|
||||||
|
mkdir($verzeichnis, 0775, true);
|
||||||
|
Config::set('log_dir', $verzeichnis);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->ausgeschaltetSchreibtNichts($verzeichnis);
|
||||||
|
$this->eingeschaltetSchreibt($verzeichnis);
|
||||||
|
$this->maskiertGeheimnisse();
|
||||||
|
$this->fehlerImmerProtokolliert($verzeichnis);
|
||||||
|
} finally {
|
||||||
|
Config::set('log_dir', $this->urspruenglichesVerzeichnis);
|
||||||
|
DebugLog::setAktiv(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ausgeschaltetSchreibtNichts(string $verzeichnis): void
|
||||||
|
{
|
||||||
|
DebugLog::setAktiv(false);
|
||||||
|
DebugLog::schreibe('test', 'darf nicht auftauchen');
|
||||||
|
|
||||||
|
$this->assert(
|
||||||
|
glob($verzeichnis . '/debug-*.log') === [],
|
||||||
|
'Bei ausgeschaltetem Log entsteht keine Datei'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function eingeschaltetSchreibt(string $verzeichnis): void
|
||||||
|
{
|
||||||
|
DebugLog::setAktiv(true);
|
||||||
|
DebugLog::schreibe('test', 'eine Nachricht', ['zahl' => 42]);
|
||||||
|
|
||||||
|
$pfad = DebugLog::pfad();
|
||||||
|
$this->assert(file_exists($pfad), 'Eingeschaltet entsteht eine Logdatei');
|
||||||
|
|
||||||
|
$inhalt = (string)file_get_contents($pfad);
|
||||||
|
$this->assert(str_contains($inhalt, 'eine Nachricht'), 'Die Nachricht steht im Log');
|
||||||
|
$this->assert(str_contains($inhalt, '"zahl":42'), 'Der Kontext steht im Log');
|
||||||
|
$this->assert(str_contains($inhalt, '[test]'), 'Die Kategorie steht im Log');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function maskiertGeheimnisse(): void
|
||||||
|
{
|
||||||
|
$maskiert = DebugLog::maskiere([
|
||||||
|
'benutzer' => 'felix',
|
||||||
|
'pin' => '123456',
|
||||||
|
'passwort' => 'streng geheim',
|
||||||
|
'daten' => ['api_key' => 'abc', 'betrag' => 12.5],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals('felix', $maskiert['benutzer'], 'Harmlose Werte bleiben lesbar');
|
||||||
|
$this->assertEquals('***', $maskiert['pin'], 'PIN wird maskiert');
|
||||||
|
$this->assertEquals('***', $maskiert['passwort'], 'Passwort wird maskiert');
|
||||||
|
$this->assertEquals('***', $maskiert['daten']['api_key'], 'Verschachtelter API-Key wird maskiert');
|
||||||
|
$this->assertEquals(12.5, $maskiert['daten']['betrag'], 'Betrag bleibt unverändert');
|
||||||
|
|
||||||
|
// Der Weg über schreibe() muss ebenfalls maskieren – sonst landet eine
|
||||||
|
// Banking-PIN über einen SQL-Parameter im Klartext im Log.
|
||||||
|
DebugLog::setAktiv(true);
|
||||||
|
DebugLog::schreibe('test', 'mit Geheimnis', ['pin' => '999888']);
|
||||||
|
|
||||||
|
$inhalt = (string)file_get_contents(DebugLog::pfad());
|
||||||
|
$this->assert(!str_contains($inhalt, '999888'), 'Die PIN taucht nicht im Log auf');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fehlerImmerProtokolliert(string $verzeichnis): void
|
||||||
|
{
|
||||||
|
DebugLog::setAktiv(false);
|
||||||
|
DebugLog::fehler(new RuntimeException('Testfehler'));
|
||||||
|
|
||||||
|
$inhalt = (string)file_get_contents(DebugLog::pfad());
|
||||||
|
$this->assert(
|
||||||
|
str_contains($inhalt, 'Testfehler'),
|
||||||
|
'Ausnahmen werden auch bei ausgeschaltetem Log protokolliert'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
218
tests/TestCase.php
Normal file
218
tests/TestCase.php
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basis-Testklasse.
|
||||||
|
*
|
||||||
|
* Jeder Test läuft in einer Transaktion, die in tearDown() zurückgerollt wird –
|
||||||
|
* die Datenbank bleibt danach unverändert.
|
||||||
|
*/
|
||||||
|
abstract class TestCase
|
||||||
|
{
|
||||||
|
private static int $gesamtBestanden = 0;
|
||||||
|
private static int $gesamtFehler = 0;
|
||||||
|
private static array $alleFehler = [];
|
||||||
|
|
||||||
|
protected int $bestanden = 0;
|
||||||
|
protected int $fehlgeschlagen = 0;
|
||||||
|
protected array $fehler = [];
|
||||||
|
protected string $testName = '';
|
||||||
|
|
||||||
|
/** @var string[] Im Test angelegte Pfade, die tearDown() wieder entfernt. */
|
||||||
|
private array $tempPfade = [];
|
||||||
|
|
||||||
|
abstract public function run(): void;
|
||||||
|
|
||||||
|
public function fuehreAus(): void
|
||||||
|
{
|
||||||
|
$this->setUp();
|
||||||
|
try {
|
||||||
|
$this->run();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->fehlgeschlagen++;
|
||||||
|
$this->fehler[] = sprintf(
|
||||||
|
'Ausnahme: %s (%s:%d)',
|
||||||
|
$e->getMessage(),
|
||||||
|
basename($e->getFile()),
|
||||||
|
$e->getLine()
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
$this->tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$gesamtBestanden += $this->bestanden;
|
||||||
|
self::$gesamtFehler += $this->fehlgeschlagen;
|
||||||
|
foreach ($this->fehler as $meldung) {
|
||||||
|
self::$alleFehler[] = ($this->testName ?: static::class) . ': ' . $meldung;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
\Finanzmanager\Database::beginTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
\Finanzmanager\Database::rollBack();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Transaktion war eventuell nicht aktiv.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Rollback dreht nur die Datenbank zurück; im Einstellungs-Cache
|
||||||
|
// stünden sonst Werte, die es nicht mehr gibt.
|
||||||
|
\Finanzmanager\Models\Einstellung::cacheLeeren();
|
||||||
|
|
||||||
|
$this->raeumeTempPfadeAuf();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Assertions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
protected function assert(bool $bedingung, string $beschreibung): void
|
||||||
|
{
|
||||||
|
if ($bedingung) {
|
||||||
|
$this->bestanden++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fehlgeschlagen++;
|
||||||
|
$this->fehler[] = $beschreibung;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertEquals(mixed $erwartet, mixed $ist, string $beschreibung): void
|
||||||
|
{
|
||||||
|
$this->assert(
|
||||||
|
$erwartet === $ist,
|
||||||
|
sprintf('%s (erwartet: %s, war: %s)', $beschreibung, self::alsText($erwartet), self::alsText($ist))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertNotEmpty(mixed $wert, string $beschreibung): void
|
||||||
|
{
|
||||||
|
$this->assert(!empty($wert), $beschreibung . ' (war leer)');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertNull(mixed $wert, string $beschreibung): void
|
||||||
|
{
|
||||||
|
$this->assert($wert === null, $beschreibung . ' (war nicht null: ' . self::alsText($wert) . ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertGreaterThan(float|int $minimum, float|int $wert, string $beschreibung): void
|
||||||
|
{
|
||||||
|
$this->assert($wert > $minimum, sprintf('%s (%s ist nicht größer als %s)', $beschreibung, $wert, $minimum));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Temporäre Pfade ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
protected function registriereTempPfad(string $pfad): string
|
||||||
|
{
|
||||||
|
$this->tempPfade[] = $pfad;
|
||||||
|
return $pfad;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tempPfad(string $endung = ''): string
|
||||||
|
{
|
||||||
|
return $this->registriereTempPfad(
|
||||||
|
sys_get_temp_dir() . '/finanzmanager_test_' . bin2hex(random_bytes(6)) . $endung
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function raeumeTempPfadeAuf(): void
|
||||||
|
{
|
||||||
|
// Umgekehrte Reihenfolge: verschachtelte Pfade vor ihren Elternverzeichnissen.
|
||||||
|
foreach (array_reverse($this->tempPfade) as $pfad) {
|
||||||
|
if (!file_exists($pfad) && !is_link($pfad)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!self::liegtImTempBereich($pfad)) {
|
||||||
|
echo " ! Nicht aufgeräumt (außerhalb der Testverzeichnisse): {$pfad}\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self::entferneRekursiv($pfad);
|
||||||
|
}
|
||||||
|
$this->tempPfade = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sicherheitsnetz: gelöscht wird ausschließlich unterhalb der Temp-Verzeichnisse. */
|
||||||
|
private static function liegtImTempBereich(string $pfad): bool
|
||||||
|
{
|
||||||
|
$echt = realpath($pfad);
|
||||||
|
if ($echt === false) {
|
||||||
|
$eltern = realpath(dirname($pfad));
|
||||||
|
if ($eltern === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$echt = $eltern . '/' . basename($pfad);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([sys_get_temp_dir(), \Finanzmanager\Config::get('import_dir')] as $wurzel) {
|
||||||
|
$wurzelEcht = is_string($wurzel) && $wurzel !== '' ? realpath($wurzel) : false;
|
||||||
|
if ($wurzelEcht !== false && str_starts_with($echt, $wurzelEcht . '/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function entferneRekursiv(string $pfad): void
|
||||||
|
{
|
||||||
|
if (is_dir($pfad) && !is_link($pfad)) {
|
||||||
|
foreach (scandir($pfad) ?: [] as $eintrag) {
|
||||||
|
if ($eintrag !== '.' && $eintrag !== '..') {
|
||||||
|
self::entferneRekursiv($pfad . '/' . $eintrag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@rmdir($pfad);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
@unlink($pfad);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ergebnisse ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return $this->testName !== '' ? $this->testName : static::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bestanden(): int
|
||||||
|
{
|
||||||
|
return $this->bestanden;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fehlgeschlagen(): int
|
||||||
|
{
|
||||||
|
return $this->fehlgeschlagen;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fehlermeldungen(): array
|
||||||
|
{
|
||||||
|
return $this->fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function gesamtBestanden(): int
|
||||||
|
{
|
||||||
|
return self::$gesamtBestanden;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function gesamtFehler(): int
|
||||||
|
{
|
||||||
|
return self::$gesamtFehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function alleFehler(): array
|
||||||
|
{
|
||||||
|
return self::$alleFehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function alsText(mixed $wert): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
is_bool($wert) => $wert ? 'true' : 'false',
|
||||||
|
is_null($wert) => 'null',
|
||||||
|
is_array($wert) => json_encode($wert, JSON_UNESCAPED_UNICODE) ?: 'array',
|
||||||
|
default => (string)$wert,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
57
tests/e2e/README.md
Normal file
57
tests/e2e/README.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# E2E-Tests
|
||||||
|
|
||||||
|
Browser-Tests mit Playwright, ausgeführt über das Projekt
|
||||||
|
[`e2e-runner`](../../../e2e-runner). Auf dem Host wird nichts installiert – der
|
||||||
|
Runner startet je Lauf einen frischen Container und räumt ihn danach weg.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
Die Anwendung muss **vor** dem Testlauf laufen und vom Host erreichbar sein:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Einmalig den Testnutzer anlegen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -u www-data php php scripts/e2e-benutzer.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ausführen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/e2e/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Fehlt Docker, der Runner oder das Playwright-Image, wird mit Exit-Code 0
|
||||||
|
übersprungen statt fehlzuschlagen.
|
||||||
|
|
||||||
|
Abweichende Werte:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE_URL=http://host.docker.internal:8000 tests/e2e/run.sh
|
||||||
|
E2E_USER=e2e E2E_PASSWORD=geheim tests/e2e/run.sh
|
||||||
|
E2E_RUNNER_DIR=/pfad/zum/runner tests/e2e/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lockfile
|
||||||
|
|
||||||
|
`package.json` pinnt `@playwright/test` auf die Version des Runner-Images
|
||||||
|
(aktuell 1.61.1). Das Lockfile wird ohne lokale Node-Installation erzeugt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD/tests/e2e":/work -w /work \
|
||||||
|
mcr.microsoft.com/playwright:v1.61.1-noble npm install --package-lock-only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Welche Ebene wofür
|
||||||
|
|
||||||
|
Hierher gehört nur, was einen Browser braucht: Formularabläufe, Redirects,
|
||||||
|
gerenderte Views, JavaScript. Rechen- und Model-Logik gehört in die PHP-Suite
|
||||||
|
(`tests/NN_*.php`) – schneller und isoliert.
|
||||||
|
|
||||||
|
## Berichte
|
||||||
|
|
||||||
|
HTML-Report, Screenshots und Traces landen nach einem Lauf in
|
||||||
|
`playwright-report/` und `test-results/` (beide nicht im Repository).
|
||||||
76
tests/e2e/package-lock.json
generated
Normal file
76
tests/e2e/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
{
|
||||||
|
"name": "finanzmanager-e2e",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "finanzmanager-e2e",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.61.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
tests/e2e/package.json
Normal file
7
tests/e2e/package.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"name": "finanzmanager-e2e",
|
||||||
|
"private": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.61.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
tests/e2e/playwright.config.js
Normal file
17
tests/e2e/playwright.config.js
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// @ts-check
|
||||||
|
const { defineConfig, devices } = require('@playwright/test')
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: './specs',
|
||||||
|
// Die Tests verändern dieselbe Instanz – seriell, nicht parallel.
|
||||||
|
workers: 1,
|
||||||
|
reporter: [['list'], ['html', { open: 'never' }]],
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.BASE_URL || 'http://host.docker.internal:8000',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||||
|
],
|
||||||
|
})
|
||||||
32
tests/e2e/run.sh
Executable file
32
tests/e2e/run.sh
Executable file
|
|
@ -0,0 +1,32 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# E2E-Tests über den generischen e2e-runner ausführen.
|
||||||
|
#
|
||||||
|
# Die Tests sind OPTIONAL: Fehlt die Infrastruktur (Docker, Runner-Projekt,
|
||||||
|
# gepulltes Playwright-Image), wird nur gewarnt und mit Exit-Code 0
|
||||||
|
# übersprungen — es wird nichts nachinstalliert oder gepullt.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
TEST_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
RUNNER_DIR=${E2E_RUNNER_DIR:-$HOME/projects/e2e-runner}
|
||||||
|
BASE_URL=${BASE_URL:-http://host.docker.internal:8000}
|
||||||
|
|
||||||
|
skip() {
|
||||||
|
echo "WARNUNG: $1 — E2E-Tests werden übersprungen." >&2
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 || skip "Docker nicht gefunden"
|
||||||
|
docker info >/dev/null 2>&1 || skip "Docker-Daemon nicht erreichbar"
|
||||||
|
[ -f "$RUNNER_DIR/docker-compose.yml" ] || skip "e2e-runner-Projekt nicht gefunden ($RUNNER_DIR)"
|
||||||
|
|
||||||
|
IMAGE=$(sed -n 's/^ *image: *//p' "$RUNNER_DIR/docker-compose.yml" | head -n1)
|
||||||
|
[ -n "$IMAGE" ] || skip "Playwright-Image im Compose-File nicht ermittelbar"
|
||||||
|
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|
||||||
|
|| skip "Playwright-Image $IMAGE nicht vorhanden (docker pull $IMAGE)"
|
||||||
|
|
||||||
|
# Zugangsdaten des Testnutzers durchreichen; der Runner kennt nur BASE_URL.
|
||||||
|
cd "$RUNNER_DIR"
|
||||||
|
TEST_DIR="$TEST_DIR" BASE_URL="$BASE_URL" exec docker compose run --rm \
|
||||||
|
${E2E_USER:+-e E2E_USER="$E2E_USER"} \
|
||||||
|
${E2E_PASSWORD:+-e E2E_PASSWORD="$E2E_PASSWORD"} \
|
||||||
|
playwright
|
||||||
88
tests/e2e/specs/smoke.spec.js
Normal file
88
tests/e2e/specs/smoke.spec.js
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
// @ts-check
|
||||||
|
const { test, expect } = require('@playwright/test')
|
||||||
|
|
||||||
|
const BENUTZER = process.env.E2E_USER || 'e2e'
|
||||||
|
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meldet sich an. Die Anwendung leitet nicht angemeldete Aufrufe auf /login um;
|
||||||
|
* bei einer frischen Installation stattdessen auf /einrichtung.
|
||||||
|
*/
|
||||||
|
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('Grundgerüst', () => {
|
||||||
|
test('Anmeldeseite ist erreichbar', async ({ page }) => {
|
||||||
|
await page.goto('/login')
|
||||||
|
await expect(page.locator('h1, .marke')).toContainText(/Finanzmanager|Anmeldung/)
|
||||||
|
await expect(page.locator('#benutzername')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Ohne Anmeldung führt jeder Weg zur Anmeldung', async ({ page }) => {
|
||||||
|
await page.goto('/einstellungen')
|
||||||
|
await expect(page).toHaveURL(/\/(login|einrichtung)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Falsches Passwort wird abgewiesen', async ({ page }) => {
|
||||||
|
await page.goto('/login')
|
||||||
|
test.skip(page.url().includes('/einrichtung'), 'Instanz noch nicht eingerichtet')
|
||||||
|
|
||||||
|
await page.fill('#benutzername', BENUTZER)
|
||||||
|
await page.fill('#passwort', 'ganz sicher falsch')
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
|
||||||
|
await expect(page.locator('.meldung-fehler')).toBeVisible()
|
||||||
|
await expect(page).toHaveURL(/\/login/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Anmeldung führt ins Cockpit', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await expect(page.locator('h1')).toContainText('Cockpit')
|
||||||
|
await expect(page.locator('.seitenleiste')).toContainText('Kontenübersicht')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Einstellungsdialog zeigt alle Reiter', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.click('a[href="/einstellungen"]')
|
||||||
|
|
||||||
|
await expect(page.locator('h1')).toContainText('Einstellungen')
|
||||||
|
for (const reiter of ['Allgemein', 'Darstellung', 'Buchhaltung', 'Diagnose']) {
|
||||||
|
await expect(page.locator('.tableiste')).toContainText(reiter)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Einstellung lässt sich speichern', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/einstellungen?bereich=darstellung')
|
||||||
|
|
||||||
|
await page.fill('#zeilen_pro_seite', '250')
|
||||||
|
// Eindeutig das Einstellungsformular treffen – im Kopfbereich steht
|
||||||
|
// ebenfalls ein Submit-Knopf (Abmelden), und der kommt im DOM zuerst.
|
||||||
|
await page.click('.formular.breit button[type="submit"]')
|
||||||
|
|
||||||
|
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||||
|
await expect(page.locator('#zeilen_pro_seite')).toHaveValue('250')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Abmelden beendet die Sitzung', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.click('form[action="/logout"] button')
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login/)
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page).toHaveURL(/\/login/)
|
||||||
|
})
|
||||||
|
})
|
||||||
86
tests/run.php
Executable file
86
tests/run.php
Executable file
|
|
@ -0,0 +1,86 @@
|
||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-Runner. Führt alle Tests aus tests/NN_*.php aus.
|
||||||
|
*
|
||||||
|
* Exit-Code: 0 = alles bestanden, 1 = mindestens ein Fehler.
|
||||||
|
*
|
||||||
|
* docker compose exec -u www-data php php tests/run.php
|
||||||
|
* docker compose exec -u www-data php php tests/run.php --json
|
||||||
|
*/
|
||||||
|
|
||||||
|
$wurzel = dirname(__DIR__);
|
||||||
|
$autoload = $wurzel . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
if (!file_exists($autoload)) {
|
||||||
|
fwrite(STDERR, "vendor/autoload.php fehlt. Zuerst 'composer install' im Container ausführen.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
require $autoload;
|
||||||
|
require __DIR__ . '/TestCase.php';
|
||||||
|
|
||||||
|
Finanzmanager\Config::load();
|
||||||
|
|
||||||
|
$jsonModus = in_array('--json', $argv ?? [], true);
|
||||||
|
|
||||||
|
$dateien = glob(__DIR__ . '/[0-9][0-9]_*.php') ?: [];
|
||||||
|
sort($dateien);
|
||||||
|
|
||||||
|
$klassen = [];
|
||||||
|
foreach ($dateien as $datei) {
|
||||||
|
$vorher = get_declared_classes();
|
||||||
|
require $datei;
|
||||||
|
foreach (array_diff(get_declared_classes(), $vorher) as $klasse) {
|
||||||
|
if (is_subclass_of($klasse, 'TestCase')) {
|
||||||
|
$klassen[] = $klasse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($klassen === []) {
|
||||||
|
echo "Keine Tests gefunden.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ergebnisse = [];
|
||||||
|
|
||||||
|
foreach ($klassen as $klasse) {
|
||||||
|
/** @var TestCase $test */
|
||||||
|
$test = new $klasse();
|
||||||
|
$test->fuehreAus();
|
||||||
|
|
||||||
|
$ergebnisse[] = [
|
||||||
|
'name' => $test->name(),
|
||||||
|
'bestanden' => $test->bestanden(),
|
||||||
|
'fehlgeschlagen' => $test->fehlgeschlagen(),
|
||||||
|
'fehler' => $test->fehlermeldungen(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$jsonModus) {
|
||||||
|
$zeichen = $test->fehlgeschlagen() === 0 ? "\e[32m✓\e[0m" : "\e[31m✗\e[0m";
|
||||||
|
printf("%s %s (%d bestanden, %d fehlgeschlagen)\n",
|
||||||
|
$zeichen, $test->name(), $test->bestanden(), $test->fehlgeschlagen());
|
||||||
|
foreach ($test->fehlermeldungen() as $meldung) {
|
||||||
|
echo " \e[31m→\e[0m {$meldung}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$fehler = TestCase::gesamtFehler();
|
||||||
|
|
||||||
|
if ($jsonModus) {
|
||||||
|
echo json_encode([
|
||||||
|
'bestanden' => TestCase::gesamtBestanden(),
|
||||||
|
'fehlgeschlagen' => $fehler,
|
||||||
|
'tests' => $ergebnisse,
|
||||||
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "\n";
|
||||||
|
} else {
|
||||||
|
echo str_repeat('─', 60), "\n";
|
||||||
|
printf("%d Prüfungen bestanden, %d fehlgeschlagen.\n", TestCase::gesamtBestanden(), $fehler);
|
||||||
|
}
|
||||||
|
|
||||||
|
exit($fehler === 0 ? 0 : 1);
|
||||||
Loading…
Add table
Reference in a new issue