Phasen 4 bis 7: Cockpit, Online-Banking, Wertpapiere, Nebenmodule

Damit ist der geplante Funktionsumfang beisammen.

Phase 4 abgeschlossen
- Konfigurierbare Cockpit-Kacheln; abgewählte werden nicht berechnet
- Einnahmen/Ausgaben je Monat als Balkenpaare mit Saldolinie, aufgesetzt auf
  den Summenbericht statt auf eine eigene Abfrage
- Meine Ausgaben als Ring auf der obersten Kategorieebene
- Offene Vorgänge aus allen Modulen

Phase 5 – Online-Banking
- nemiah/php-fints als Fork auf Codeberg, psr/log direkt eingebaut: die
  Anwendung hängt an keinem fremden Update-Zyklus
- PIN AES-256-GCM-verschlüsselt; Schlüssel aus APP_KEY oder aus
  data/schluessel/app_key, den der Entrypoint einmalig erzeugt
- TAN-Dialog über mehrere Requests (FinTs::persist plus serialisierte Aktion
  in der Sitzung) – ein wartender Prozess wäre mit php-fpm der falsche Weg
- Abgleich statt Direktbuchung: verbinden, übernehmen oder verwerfen
- Regeln mit vier Vergleichsarten, Betragsgrenzen und Split-Schema

Phase 6 – Wertpapiere
- Depotbuchung und Geldbuchung als zwei Seiten eines Geschäfts; betrag ist
  der Bruttowert, Nebenkosten stehen daneben
- Einstand nach gleitendem Durchschnitt, realisierte Gewinne, Erträge
- Kursabruf über Leeway oder FMP, täglicher Lauf per Cron
- QIF-Import für !Type:Invst

Dabei behoben: Der Import buchte den Geldfluss jedes Wertpapiergeschäfts als
Abgang – auch bei Verkäufen und Dividenden; und er erkannte das
Verrechnungskonto nicht, wenn dem Konto im L-Feld eine Kategorie vorangeht
(bei Verkäufen also immer).

Phase 7 – Nebenmodule und Zahlungsverkehr
- Buchungsvorlagen inklusive QIF-Import, Splits werden mitskaliert
- Regelmäßige Buchungen mit Fälligkeitslauf, Finanzkalender, Erinnerungen
- Adressen mit IBAN-Prüfung, Verträge mit Kündigungsfrist, Sparziele
- Kredite mit gerechnetem Tilgungsplan
- Überweisungen über FinTS; die Buchung entsteht erst nach Annahme durch die
  Bank. Die pain.001-Nachricht erzeugt SepaXml selbst

Nebenbei: .dockerignore ergänzt (ohne sie brach der Build an data/mariadb ab),
PlatzhalterController entfernt, zwei Inline-Handler beseitigt, die die
Content-Security-Policy ohnehin blockierte.

888 PHP-Prüfungen und 45 E2E-Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-12 06:59:24 +02:00
parent 90bba06585
commit f388d04c05
95 changed files with 15934 additions and 261 deletions

23
.dockerignore Normal file
View file

@ -0,0 +1,23 @@
# Was nicht in den Build-Kontext gehört.
#
# Ohne diese Liste schickt Docker das ganze Projektverzeichnis an den Daemon –
# einschließlich data/mariadb, das dem Datenbank-Container gehört und für den
# Bauenden nicht lesbar ist. Der Build bricht dann mit „permission denied“ ab,
# noch bevor die erste Zeile des Dockerfiles läuft.
/data/
/backups/
/vendor/
/.git/
/.env
# Echte Kontodaten – gehören weder ins Repository noch ins Image.
/Altdaten/
/tests/e2e/node_modules/
/tests/e2e/playwright-report/
/tests/e2e/test-results/
/tests/e2e/.auth/
.DS_Store
*.swp

1
.gitignore vendored
View file

@ -2,6 +2,7 @@
/data/mariadb/
/data/logs/
/data/import/
/data/schluessel/
/backups/
/.env
/composer.lock.bak

View file

@ -1,5 +1,139 @@
# Änderungen
## Phasen 4 bis 7 – Cockpit, Online-Banking, Wertpapiere, Nebenmodule (12.08.2026)
Damit ist der geplante Funktionsumfang beisammen: Von der Kontenübersicht bis zur
Überweisung läuft alles in der Anwendung.
### Phase 4 abgeschlossen – die übrigen Grafiken und das Cockpit
**Konfigurierbare Kacheln** (`src/Services/Cockpit.php`)
- Fünf Kacheln: Vermögensentwicklung, Einnahmen und Ausgaben, Meine Ausgaben,
Kontostände, Offene Vorgänge. Die Auswahl steht im Einstellungsdialog unter
*Darstellung*; abgewählte Kacheln werden nicht nur ausgeblendet, sondern gar nicht
erst berechnet.
- Die Reihenfolge im Raster kommt aus dem Katalog, nicht aus der Reihenfolge der
Ankreuzfelder – letztere wäre für den Nutzer keine sinnvolle Sortierung.
**Einnahmen und Ausgaben je Monat**
- Balkenpaare über zwölf Monate, darüber der Saldo als Linie.
- Die Zahlen kommen aus dem Summenbericht, nicht aus einer eigenen Abfrage: So zählt die
Kachel dieselben Bewegungen wie der Bericht und beide laufen nie auseinander.
**Meine Ausgaben als Ring**
- `Auswertung::verteilung()` fasst auf der **obersten Kategorie** zusammen – „300 - Wohnen“
statt fünfzehn Unterkategorien. Höchstens acht Segmente, der Rest wird gesammelt.
- Negative Kategoriesalden bekommen kein Segment, zählen aber in der Summe mit; sonst
ergänzten sich die Anteile nicht mehr zu hundert Prozent.
- Sonderfall Vollkreis: Bei einer einzigen Kategorie fielen Anfangs- und Endpunkt des
Bogens zusammen und es entstünde keine Fläche – deshalb zwei Halbringe.
**Offene Vorgänge**
- Nicht kategorisierte Buchungen, nicht abgeglichene Buchungen, fälliger Monatsabschluss –
später ergänzt um offene Bankumsätze, fällige Termine und Erinnerungen.
### Phase 5 – Online-Banking über FinTS
**Die Bibliothek als eigener Fork**
- `nemiah/php-fints` liegt als Fork auf Codeberg (`fds2610-bot/phpFinTS`), eingebunden über
ein VCS-Repository, Version fest auf `4.1.1` gepinnt.
- **psr/log ist dort direkt eingebaut** (acht Interface-Dateien unter `psr-log/`, eigener
Autoload-Eintrag, kein `require` mehr). Der Finanzmanager hängt damit an keinem fremden
Update-Zyklus; wie der Eingriff beim Nachziehen zu wiederholen ist, steht im Fork.
**Zugangsdaten**
- Migration `006_banking.sql`: `bank_zugaenge`, `bank_konten`, `bank_umsaetze`,
`abgleich_regeln`, `bank_abrufe`.
- PIN und TAN-Medium AES-256-GCM-verschlüsselt (`Services\Crypto`, übernommen aus
`Steuerkram`). Der Schlüssel kommt aus `APP_KEY` oder aus `data/schluessel/app_key`, den
der Entrypoint beim ersten Start erzeugt – eigenes Bind-Mount, damit er einen Neubau des
Images überlebt.
- Ein leeres PIN-Feld heißt „unverändert“, nicht „PIN löschen“.
**Abruf über mehrere Requests**
- Fast jede Bank verlangt schon beim Login eine TAN. `Services\FinTsAbruf` sichert den
Dialog (`FinTs::persist()`) und die laufende Aktion in der Sitzung und macht nach der
Eingabe an derselben Stelle weiter. Ein wartender Prozess wäre mit php-fpm der falsche
Weg.
- Klassische Verfahren zeigen die photoTAN-Grafik, entkoppelte (pushTAN) fragen auf
Knopfdruck nach, statt zu warten.
- Umsätze, die vor einer TAN-Unterbrechung geschrieben wurden, bleiben erhalten.
**Abgleich statt Direktbuchung**
- Abgerufene Umsätze landen in `bank_umsaetze` und werden nicht sofort gebucht. Die
Vorschlagsliste kennt drei Wege: mit einer erfassten Buchung **verbinden**, als neue
Buchung **übernehmen** oder **verwerfen**.
- Gleiches Konto, gleicher Betrag, Datum innerhalb der Toleranz (Vorgabe fünf Tage,
einstellbar) – so findet der Abgleich die schon erfasste Buchung. Eine bereits
verbundene Buchung wird nie ein zweites Mal vorgeschlagen.
- `extern_id` verhindert, dass derselbe Umsatz bei überlappenden Abrufzeiträumen zweimal
hereinkommt.
**Regeln**
- Muster auf Empfänger und/oder Verwendungszweck, vier Vergleichsarten samt regulärem
Ausdruck, Betragsgrenzen mit Vorzeichen, Wirkung auf Kategorie, Klasse, Gegenkonto oder
ein festes Split-Schema in Prozent.
- Die erste passende Regel gewinnt (nach Priorität) – kein Sammeln über mehrere Regeln,
sonst wäre nicht mehr nachvollziehbar, warum am Ende die eine Kategorie dasteht.
- Reguläre Ausdrücke werden beim Speichern geprüft; ein kaputtes Muster brächte sonst
jeden Abruf zum Stehen.
### Phase 6 – Wertpapiere und Depots
- Migration `007_wertpapiere.sql`: `wertpapiere`, `depot_buchungen`, `kurse`.
- **Zwei Seiten je Geschäft**: die Depotbuchung mit Stück und Kurs, die Geldbuchung auf dem
Verrechnungskonto. `betrag` ist der Bruttowert, Gebühren und Steuern stehen daneben –
nur so enthält der Einstand die Nebenkosten genau einmal.
- **Einstand nach gleitendem Durchschnitt**, realisierte Gewinne beim Verkauf. (Für die
Steuer gilt FIFO; maßgeblich ist dort die Abrechnung der Bank.)
- Ohne Kurs wird nicht bewertet: Die Position sagt „kein Kurs“, statt den Einstand als
Ersatzwert auszugeben.
- **Kursabruf** über Leeway.tech oder Financial Modeling Prep, Kürzel wird nach dem ersten
Fund gespeichert. Abgerufen wird nur für Papiere mit Bestand und nur einmal am Tag;
täglicher Lauf über `scripts/kursabruf.php`.
- **QIF-Import für `!Type:Invst`**: Wertpapier, Depotbuchung und Geldbuchung in einem Zug.
- Depots erscheinen in der Kontenübersicht mit ihrem bewerteten Bestand.
**Dabei behoben:** Der Import buchte den Geldfluss *jedes* Wertpapiergeschäfts als Abgang –
auch bei Verkäufen und Dividenden. Das QIF nennt im Feld `$` immer einen positiven Betrag,
die Richtung steckt allein im Vorgang. Zudem wurde das Verrechnungskonto nicht erkannt,
wenn dem Konto im `L`-Feld eine Kategorie vorangeht
(`LKursgewinne:Realisierte Gewinne|[Konto]`) – bei Verkäufen also immer.
### Phase 7 – Nebenmodule und Zahlungsverkehr
- Migration `008_nebenmodule.sql`: `buchungsvorlagen`, `regelmaessige_buchungen`,
`erinnerungen`, `adressen`, `vertraege`, `sparziele`, `kredite`, `zahlungsauftraege`.
- **Buchungsvorlagen** inklusive QIF-Import (`!Type:Memorized`); Splitzeilen werden bei
abweichendem Betrag im Verhältnis mitskaliert.
- **Regelmäßige Buchungen** mit Fälligkeitslauf: automatisch oder auf Bestätigung, jeder
zurückliegende Termin wird einzeln gebucht, abgeschlossene Monate bleiben unangetastet.
- **Finanzkalender** aus vier Quellen – gerechnet, nicht gespeichert.
- **Adressen** mit IBAN-Prüfung nach ISO 13616 (Länge je Land, Prüfziffer modulo 97).
- **Verträge** mit Kündigungsfrist und Jahresbetrag; ablaufende Fristen stehen oben und im
Kalender.
- **Sparziele**, deren Stand aus dem Kontosaldo kommt statt aus einem zweiten Zähler.
- **Kredite** mit gerechnetem Tilgungsplan (Annuität, Sondertilgung, Restschuld zum
Zinsbindungsende). Deckt die Rate die Zinsen nicht, gibt es einen Hinweis statt eines
Plans.
- **Zahlungsverkehr**: Überweisungen und Terminüberweisungen über FinTS. Die Buchung
entsteht erst, wenn die Bank den Auftrag angenommen hat; gesendete Aufträge bleiben als
Beleg unveränderlich stehen.
- **SEPA-XML selbst erzeugt** (`Services\SepaXml`, pain.001.001.03) statt einer weiteren
Abhängigkeit – samt Umschrift der Umlaute in den SEPA-Zeichenvorrat.
### Nebenbei
- `.dockerignore` ergänzt: Ohne sie schickte der Build das ganze Projektverzeichnis an den
Daemon, einschließlich `data/mariadb` – der Build brach mit „permission denied“ ab.
- `PlatzhalterController` entfernt: Alle Navigationspunkte haben jetzt ihr Modul.
- Neue Reiter im Einstellungsdialog: *Online-Banking* und *Wertpapiere*.
**Doku:** `docs/cockpit.md`, `docs/banking.md`, `docs/wertpapiere.md`,
`docs/nebenmodule.md`.
**Tests:** 15 bis 18 (Cockpit, Banking, Wertpapiere, Nebenmodule) – zusammen 888 Prüfungen.
## Phase 4 – Auswertungen mit Berichtsmodul (11.08.2026)
**Datenmodell**

66
PLAN.md
View file

@ -1,6 +1,6 @@
# Projektplan: Finanzmanager (Web)
> Stand: 09.08.2026 — Arbeitsgrundlage, gedacht zum Überarbeiten. Änderungen einfach direkt
> Stand: 12.08.2026 — alle sieben Phasen umgesetzt. Arbeitsgrundlage, gedacht zum Überarbeiten. Änderungen einfach direkt
> in dieser Datei vornehmen; die Umsetzung richtet sich nach dem hier festgehaltenen Stand.
## 1. Kontext
@ -264,7 +264,7 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag
* *Ergebnis:* Jahresbudget zum Jahreswechsel planbar, Monatsabschluss durchführbar.
Einzelheiten in `docs/planung.md`.
### Phase 4 — Auswertungen und Cockpit
### Phase 4 — Auswertungen und Cockpit *(erledigt)*
* Berichtsmodul mit gespeicherten Berichten und Berichtsgruppen (wie „Bankgeschäfte",
„Planung", „Steuern"): **Summenbericht, Buchungsbericht, Vergleichsbericht,
Kontostandsbericht, Budgetbericht**, jeweils mit Zeitraum, Spalten-Aufteilung,
@ -278,16 +278,20 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag
Summe zu fallen. Im Ausgabenabschnitt steht der Betrag ohne Minus wie im Original, die
Schlusszeile rechnet mit den echten Vorzeichen. Der Budgetbericht rechnet über volle
Monate — ein halber Monat hat kein halbes Budget.
* Grafiken als SVG: Einnahmen/Ausgaben je Monat, Vermögensentwicklung *(erledigt: Cockpit-
Kachel mit den letzten zwölf Monaten, gestapelt je Konto — `docs/cockpit.md`)*, Ausgaben
nach Kategorie.
* Grafiken als SVG: Vermögensentwicklung (gestapelte Balken je Konto), Einnahmen/Ausgaben
je Monat (Balkenpaare mit Saldolinie), Ausgaben nach Kategorie (Ring auf der obersten
Kategorieebene). *(erledigt)*
* **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.
Offen bleiben die beiden übrigen Grafiken und die Cockpit-Kacheln.
Meine Ausgaben, Kontostände, offene Vorgänge). Die Auswahl steht im Einstellungsdialog;
abgewählte Kacheln werden nicht berechnet. *(erledigt)*
* *Ergebnis:* Die bisher genutzten Berichte sind nachgebaut und liefern dieselben Zahlen,
das Cockpit beantwortet die täglichen Fragen auf einen Blick. Einzelheiten in
`docs/auswertungen.md` und `docs/cockpit.md`.
### Phase 5 — Online-Banking (FinTS)
* Fork von `nemiah/php-fints` auf Codeberg, Einbindung über VCS-Repository.
### Phase 5 — Online-Banking (FinTS) *(erledigt)*
* Fork von `nemiah/php-fints` auf Codeberg (`fds2610-bot/phpFinTS`, Version `4.1.1`),
Einbindung über VCS-Repository. `psr/log` ist im Fork direkt eingebaut — der
Finanzmanager hat damit genau eine Abhängigkeit, und die steht unter eigener Kontrolle.
* Übernahme aus Steuerkram: `Crypto`, `BankSource` (verschlüsselte Zugangsdaten,
`computeStartDate`, `account_filter`), `BankProvider`, FinTS-Abrufdienst,
Abruf-Protokoll.
@ -299,9 +303,14 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag
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.
* Entschieden während der Umsetzung: Der FinTS-Dialog wird über die **Sitzung** fortgesetzt
(`FinTs::persist()` plus serialisierte Aktion), nicht über einen wartenden Prozess — mit
php-fpm wäre das der falsche Weg. Abgerufene Umsätze werden **nicht sofort gebucht**,
sondern gehen durch die Vorschlagsliste; wer selbst erfasst, hätte sonst alles doppelt.
* *Ergebnis:* Umsätze kommen per Knopfdruck rein und sind großteils automatisch
kategorisiert. Einzelheiten in `docs/banking.md`.
### Phase 6 — Wertpapiere und Depots
### Phase 6 — Wertpapiere und Depots *(erledigt)*
* Migrationen `wertpapiere`, `depot_buchungen`, `kurse`.
* Depotübersicht, Bestände, Einstandswerte, aktueller Wert, Wertentwicklung.
* Depotbuchungen (Kauf, Verkauf, Dividende, Reinvestition) inkl. Verrechnungskonto-Buchung.
@ -313,9 +322,19 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag
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.
* Entschieden während der Umsetzung: `betrag` einer Depotbuchung ist der **Bruttowert**,
Gebühren und Steuern stehen daneben — nur so enthält der Einstand die Nebenkosten genau
einmal. Der Einstand folgt dem **gleitenden Durchschnitt** wie im FinanzManager (für die
Steuer gilt FIFO, dafür ist die Bank zuständig). Ohne Kurs wird eine Position nicht
bewertet, statt den Einstand als Ersatzwert auszugeben.
* Der **Depotabruf per FinTS** ist nicht umgesetzt: Bestände und Geschäfte kommen aus dem
QIF-Import und den erfassten Depotbuchungen, Kurse vom Kursdienst. Ein Abruf der
Depotaufstellung ließe sich ohne echte Bankverbindung nicht verlässlich prüfen und wäre
gegenüber dem Kursabruf ein geringer Zugewinn.
* *Ergebnis:* Die Depots sind abgebildet, bewertet und in der Kontenübersicht enthalten.
Einzelheiten in `docs/wertpapiere.md`.
### Phase 7 — Nebenmodule
### Phase 7 — Nebenmodule *(erledigt)*
* **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).
@ -323,7 +342,12 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag
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.
* Entschieden während der Umsetzung: Der Tilgungsplan und die Termine des Finanzkalenders
werden **gerechnet, nicht gespeichert** — vorausberechnete Tabellen wären bei jeder
Änderung neu zu erzeugen. Die pain.001-Nachricht für SEPA-Überweisungen entsteht in
`Services\SepaXml` selbst, statt dafür eine weitere Bibliothek einzubinden.
* *Ergebnis:* Funktionsgleichstand mit dem bisher genutzten Umfang. Einzelheiten in
`docs/nebenmodule.md`.
## 6. Was du beisteuern musst
@ -359,11 +383,13 @@ Je Phase, nicht erst am Ende:
Werten aus der Kontenübersicht des FinanzManagers entsprechen. Geprüft an acht Konten,
deren Werte aus den Screenshots vorliegen — sie stimmen auf den Cent (Stand Phase 2).
Die **Summe der Kontostände** (503.430,75 € laut Screenshot) lässt sich erst vollständig
vergleichen, wenn zwei Dinge stehen: die Σ-Kennzeichen je Konto (welche Konten zählen
überhaupt mit — im Export nicht enthalten) und die Depotbewertung aus Phase 6. Die
Jahressummen Einnahmen/Ausgaben (Cockpit 2026: 148.060,18 € / 58.457,07 €) hängen an
denselben Voraussetzungen und werden mit den Auswertungen in Phase 4 abgeglichen.
Die **Summe der Kontostände** (503.430,75 € laut Screenshot) ist seit Phase 6
vergleichbar: Depots gehen mit ihrem bewerteten Bestand in die Kontenübersicht ein. Es
fehlen nur noch die Kurse — solange für eine Position keiner vorliegt, weist die
Übersicht sie ausdrücklich als unvollständig aus. Offen bleibt außerdem das
Σ-Kennzeichen je Konto (im Export nicht enthalten, Vorgabe: alle zählen mit).
Die Jahressummen Einnahmen/Ausgaben (Cockpit 2026: 148.060,18 € / 58.457,07 €) hängen an
denselben Voraussetzungen und lassen sich über den Summenbericht abgleichen.
* **Manuell**: Anwendung unter `http://localhost:8000` aufrufen, Kontenübersicht,
Kontoblatt, Budgetplaner und Summenbericht mit den Screenshots aus `Altdaten/` vergleichen.

View file

@ -10,18 +10,26 @@ Haushalt wird beides parallel genutzt.
## Stand
Phase 0 bis 3 sind gebaut: Anmeldung, zentraler Einstellungsdialog, Debug-Log,
Migrationsmechanik und Test-Harness; Konten, Kontengruppen, Kategorien und Klassen;
Kontoblatt, Buchungsmaske mit Splitbuchungen und Umbuchungen sowie der QIF-Import für
Stammdaten und Buchungen; Budgetplaner mit Monatswerten je Kategorie und der
Monatsabschluss samt Prüflauf, Snapshot und Sperre.
**Alle sieben Phasen aus `PLAN.md` sind umgesetzt.**
Aus Phase 4 steht das Berichtsmodul: Summen-, Buchungs-, Vergleichs-, Kontostands- und
Budgetbericht mit Zeitraumvorgaben, Spalten-Aufteilung, Zeilen-Summe nach
Kategorie/Klasse/Konto/Empfänger, Drilldown in die Buchungen, CSV-Export sowie
gespeicherten Berichten in Berichtsgruppen. Dazu die Cockpit-Kachel Vermögensentwicklung
als gestapeltes Balkendiagramm der letzten zwölf Monate. Als Nächstes folgen nach
`PLAN.md` die übrigen Grafiken und Cockpit-Kacheln.
* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt,
Buchungsmaske mit Splitbuchungen und Umbuchungen; QIF-Import mit Stichtag.
* **Planung** – Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf,
Snapshot und Sperre.
* **Auswertungen** – Summen-, Buchungs-, Vergleichs-, Kontostands- und Budgetbericht mit
Zeitraumvorgaben, Spalten-Aufteilung, Drilldown und CSV-Export; gespeicherte Berichte in
Gruppen.
* **Cockpit** – konfigurierbare Kacheln: Vermögensentwicklung, Einnahmen/Ausgaben je
Monat, Ausgaben nach Kategorie, Kontostände, offene Vorgänge.
* **Online-Banking** – FinTS-Abruf mit TAN-Dialog über mehrere Requests, Abgleich der
Umsätze gegen vorhandene Buchungen, Regeln für die automatische Kategorisierung.
* **Wertpapiere** – Depotbestände mit Einstand und Bewertung, Depotbuchungen, Kursabruf,
Veräußerungsgeschäfte und Erträge.
* **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und
Finanzkalender, Erinnerungen, Adressen, Verträge, Sparziele, Kredite mit Tilgungsplan.
* **Zahlungsverkehr** – Überweisungen und Terminüberweisungen über FinTS.
Einzelheiten je Modul in `docs/`, die Entwicklung in `Changes.md`.
## Technik
@ -32,18 +40,24 @@ als gestapeltes Balkendiagramm der letzten zwölf Monate. Als Nächstes folgen n
| 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 |
| Fremdbibliotheken | genau eine: `nemiah/php-fints` als eigener Fork auf Codeberg |
Diagramme werden als eigenes SVG erzeugt, PDF-Berichte gehen über die Druckfunktion des
Browsers – bewusst keine Chart- oder PDF-Bibliothek.
Browsers, die SEPA-Nachricht für Überweisungen entsteht selbst – bewusst keine Chart-,
PDF- oder SEPA-Bibliothek. Der FinTS-Fork trägt `psr/log` direkt in sich; damit hängt die
Anwendung an keinem fremden Update-Zyklus.
## Start
```bash
cp .env.example .env # Werte anpassen, APP_KEY erzeugen
cp .env.example .env # Werte anpassen (APP_KEY darf leer bleiben)
docker compose up -d
```
Ist `APP_KEY` leer, erzeugt der Container beim ersten Start einen Schlüssel unter
`data/schluessel/app_key`. Er verschlüsselt die Zugangsdaten der Bankverbindungen und
**gehört ins Backup** – ohne ihn sind sie nicht mehr lesbar.
Danach http://localhost:8000 aufrufen. Beim ersten Aufruf führt die Anwendung zur
Ersteinrichtung und legt den ersten Benutzer an.
@ -59,9 +73,9 @@ Ersteinrichtung und legt den ersten Benutzer an.
src/ Anwendungscode (Controllers, Models, Services, Views)
public/ Front-Controller und statische Dateien
migrations/ nummerierte SQL-Migrationen, laufen beim Containerstart
scripts/ Migration, Update, Backup, Tests
scripts/ Migration, Update, Backup, Tests, Kursabruf
tests/ PHP-Testsuite; tests/e2e/ für Playwright
data/ Nutzdaten: mariadb, logs, import (nicht im Repository)
data/ Nutzdaten: mariadb, logs, import, schluessel (nicht im Repository)
Altdaten/ Screenshots und QIF-Export der Altanwendung (nicht im Repository)
```
@ -92,5 +106,8 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf.
- `docs/buchungen.md` – Kontoblatt, Umbuchungen, Splitbuchungen, Salden
- `docs/planung.md` – Budgetplaner und Monatsabschluss
- `docs/auswertungen.md` – Berichtsmodul, Filterleiste, Drilldown, CSV
- `docs/cockpit.md` – Startseite und die Kachel Vermögensentwicklung
- `docs/cockpit.md` – Startseite und ihre Kacheln
- `docs/banking.md` – FinTS-Abruf, TAN-Dialog, Abgleich und Regeln
- `docs/wertpapiere.md` – Depots, Einstand, Kurse und der Invst-Import
- `docs/nebenmodule.md` – Vorlagen, Termine, Verträge, Sparziele, Kredite, Zahlungsverkehr
- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs

View file

@ -3,8 +3,15 @@
"description": "Persönlicher Finanzmanager – Konten, Kategorien, Budget, Monatsabschluss",
"type": "project",
"license": "MIT",
"repositories": [
{
"type": "vcs",
"url": "https://codeberg.org/fds2610-bot/phpFinTS.git"
}
],
"require": {
"php": ">=8.3"
"php": ">=8.3",
"nemiah/php-fints": "4.1.1"
},
"autoload": {
"psr-4": {

87
composer.lock generated Normal file
View file

@ -0,0 +1,87 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c9c68471574e1ff2ea7b78c3d71cdfd3",
"packages": [
{
"name": "nemiah/php-fints",
"version": "v4.1.1",
"source": {
"type": "git",
"url": "https://codeberg.org/fds2610-bot/phpFinTS.git",
"reference": "6ebd8e0ec1287dac384d404ca6311ba8169ee545"
},
"dist": {
"type": "zip",
"url": "https://codeberg.org/api/v1/repos/fds2610-bot/phpFinTS/archive/6ebd8e0ec1287dac384d404ca6311ba8169ee545.zip",
"reference": "6ebd8e0ec1287dac384d404ca6311ba8169ee545",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-mbstring": "*",
"php": ">=8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
"php-mock/php-mock-phpunit": "^2.6",
"phpunit/phpunit": "^9.5"
},
"suggest": {
"abcaeffchen/sephpa": "1.*",
"monolog/monolog": "Allow sending log messages to a variety of different handlers",
"nemiah/php-sepa-xml": "dev-master"
},
"type": "library",
"autoload": {
"psr-4": {
"Fhp\\": "src/",
"Psr\\Log\\": "psr-log/Psr/Log/"
}
},
"autoload-dev": {
"psr-4": {
"Fhp\\Tests\\": "Tests/"
}
},
"scripts": {
"test": [
"phpunit"
],
"check": [
"@cs"
],
"cs": [
"php-cs-fixer fix -v --diff --dry-run"
],
"cs-fix": [
"php-cs-fixer fix -v --diff"
]
},
"license": [
"MIT"
],
"description": "PHP Library for the protocols fints and hbci",
"homepage": "https://codeberg.org/fds2610-bot/phpFinTS",
"support": {
"source": "https://codeberg.org/fds2610-bot/phpFinTS/tag/v4.1.1",
"issues": "https://codeberg.org/fds2610-bot/phpFinTS/issues"
},
"time": "2026-08-11T18:48:38+02:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=8.3"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
}

View file

@ -23,6 +23,10 @@ services:
# bewusst NICHT gemountet – das gehört allein dem db-Container.
- ./data/logs:/var/www/html/data/logs
- ./data/import:/var/www/html/data/import
# Der Schlüssel für die verschlüsselten Felder (FinTS-PIN). Eigenes
# Verzeichnis, damit er einen Neubau des Images überlebt: Ohne ihn sind
# gespeicherte Zugangsdaten nicht mehr lesbar.
- ./data/schluessel:/var/www/html/data/schluessel
environment:
TZ: Europe/Berlin
DB_HOST: db

View file

@ -3,9 +3,27 @@ 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
mkdir -p /var/www/html/data/logs /var/www/html/data/import /var/www/html/data/schluessel
chown -R www-data:www-data /var/www/html/data
# Schlüssel für die verschlüsselten Felder (FinTS-PIN). Steht APP_KEY nicht in
# der Umgebung, wird einmalig einer erzeugt und unter data/ abgelegt – sonst
# ließe sich keine Bankverbindung speichern, und ein bei jedem Start neu
# gewürfelter Schlüssel machte die gespeicherten Zugangsdaten unlesbar.
#
# Wer den Schlüssel lieber selbst verwaltet, setzt APP_KEY in der .env; diese
# Datei wird dann nicht angelegt.
SCHLUESSELDATEI=/var/www/html/data/schluessel/app_key
if [ -z "${APP_KEY:-}" ]; then
if [ ! -s "$SCHLUESSELDATEI" ]; then
openssl rand -hex 32 > "$SCHLUESSELDATEI"
echo "APP_KEY erzeugt: data/schluessel/app_key (unbedingt mitsichern – ohne ihn"
echo "sind gespeicherte Zugangsdaten verloren)."
fi
chown www-data:www-data "$SCHLUESSELDATEI"
chmod 600 "$SCHLUESSELDATEI"
fi
# Migrationen laufen bei jedem Start. Sie sind idempotent, ein zweiter Lauf
# ändert nichts.
if [ -f /var/www/html/scripts/migrate.php ]; then

145
docs/banking.md Normal file
View file

@ -0,0 +1,145 @@
# Online-Banking (FinTS)
Umsätze kommen per Knopfdruck von der Bank, werden gegen die vorhandenen Buchungen
abgeglichen und über Regeln weitgehend selbst kategorisiert.
## Die Bibliothek: ein eigener Fork
Verwendet wird `nemiah/php-fints`, aber nicht von Packagist, sondern als **Fork auf
Codeberg** (`fds2610-bot/phpFinTS`, eingebunden über ein VCS-Repository in der
`composer.json`, Version fest auf `4.1.1` gepinnt).
Im Fork ist `psr/log` **direkt eingebaut**: Die acht Interface-Dateien liegen unter
`psr-log/Psr/Log/` und werden über einen eigenen Autoload-Eintrag geladen, der
`require`-Eintrag ist entfallen. Damit hängt der Finanzmanager an keinem fremden
Update-Zyklus – die Anwendung hat genau eine Abhängigkeit, und die steht unter eigener
Kontrolle.
Beim Nachziehen eines neuen Upstream-Standes muss dieser Eingriff wiederholt werden; wie,
steht in `psr-log/README.md` des Forks.
## Der Schlüssel für die Zugangsdaten
PIN und TAN-Medium stehen AES-256-GCM-verschlüsselt in der Datenbank (`Services\Crypto`,
übernommen aus `Steuerkram`). Der Schlüssel ist eine 64-stellige Hex-Zeichenkette:
* aus der Umgebung (`APP_KEY` in der `.env`), oder
* aus `data/schluessel/app_key` – die Datei erzeugt der Container-Entrypoint beim ersten
Start, wenn `APP_KEY` leer ist.
Das Verzeichnis ist ein eigenes Bind-Mount, damit der Schlüssel einen Neubau des Images
überlebt. **Er gehört ins Backup**: Ohne ihn sind gespeicherte Zugangsdaten nicht mehr
lesbar (neu eingeben hilft, verloren ist sonst nichts).
## Ablauf eines Abrufs
Der Abruf ist eine Zustandsmaschine über mehrere Requests:
```
anmelden → konten → umsaetze (je Konto) → fertig
```
Jeder Schritt kann eine TAN verlangen – bei den meisten Banken schon der Login, auch für
reines Lesen. Dann sichert `Services\FinTsAbruf` den Dialog (`FinTs::persist()`) und die
serialisierte Aktion in der **Sitzung**, zeigt die TAN-Maske und macht nach der Eingabe an
derselben Stelle weiter.
Ein wartender Prozess wäre der naheliegende, aber falsche Weg: php-fpm hat dafür keinen
Platz, und ein Request, der fünf Minuten auf eine TAN wartet, blockiert einen Worker.
Was in der Sitzung liegt, enthält keine PIN – die Bibliothek schließt sie beim Persistieren
ausdrücklich aus. Sie wird bei jedem Request neu aus der Datenbank geholt und entschlüsselt.
### TAN-Verfahren
* **Klassisch** (chipTAN, photoTAN): Die Maske zeigt den Hinweistext der Bank und, wenn
vorhanden, die photoTAN-Grafik als Datenadresse. Der Nutzer tippt die TAN ein.
* **Entkoppelt** (pushTAN-App): Die Freigabe passiert im Gerät des Nutzers. Statt zu warten,
fragt die Anwendung auf Knopfdruck nach (`checkDecoupledSubmission`) – steht die Freigabe
noch aus, bleibt die Maske stehen.
Verfahren und TAN-Medium werden beim ersten Abruf gewählt und am Zugang gespeichert, damit
die Bank nicht jedes Mal danach fragt.
### Konten
Beim ersten Abruf legt die Anwendung für jedes Konto, das die Bank meldet, eine Zeile in
`bank_konten` an. Trägt ein eigenes Konto dieselbe IBAN, wird es gleich zugeordnet; sonst
geschieht das im Formular des Zugangs. **Ohne Zuordnung lassen sich die Umsätze nicht
buchen** – das Konto fehlt dann schlicht.
Was nicht abgerufen werden soll, wird dort abgewählt; das ist der Kontenfilter.
Der Saldo wird mitgeholt und landet in der Spalte *Online-Saldo* der Kontenübersicht.
Verlangt die Bank dafür eine eigene TAN, entfällt er – eine zweite TAN-Runde nur für eine
Zahl wäre niemandem zu erklären.
## Abgleich statt Direktbuchung
Abgerufene Umsätze landen in `bank_umsaetze` und werden **nicht sofort gebucht**. Wer seine
Zahlungen selbst erfasst, hat sie beim Abruf meist schon im Kontoblatt; ohne Zwischenschritt
stünde alles doppelt da.
Die Vorschlagsliste (*Umsätze abgleichen*) kennt drei Wege:
| Vorschlag | Wann | Wirkung |
|---|---|---|
| **Verbinden** | Es gibt eine erfasste Buchung mit gleichem Konto, gleichem Betrag und nahem Datum | Der Umsatz wird mit ihr verknüpft, die Buchung gilt als abgeglichen |
| **Als neue Buchung** | Keine passende Buchung vorhanden | Neue Buchung mit Quelle *fints*, Kennzeichen abgeglichen |
| **Verwerfen** | Der Umsatz soll nicht ins Kontoblatt | Bleibt als „verworfen“ liegen und kommt nicht wieder |
Wie weit Buchungs- und Banktag auseinanderliegen dürfen, steht als *Toleranz beim Abgleich*
in den Einstellungen (Vorgabe fünf Tage). Eine Buchung, die schon mit einem anderen Umsatz
verbunden ist, wird nie ein zweites Mal vorgeschlagen.
Dass derselbe Umsatz bei überlappenden Abrufzeiträumen nur einmal hereinkommt, sichert
`extern_id` – ein Fingerabdruck aus IBAN, Datum, Betrag, Verwendungszweck und Empfänger.
**Alle eindeutigen übernehmen** erledigt in einem Zug, was ohne Rückfrage geht: verbinden,
wo eine Buchung passt, und übernehmen, wo eine Regel greift. Umsätze ohne Regel bleiben
offen – sie sind Arbeit, die der Nutzer sehen soll.
## Regeln
Eine Regel prüft ein Muster gegen Empfänger und/oder Verwendungszweck:
* **Vergleich**: enthält, beginnt mit, endet auf, regulärer Ausdruck. Groß- und
Kleinschreibung spielt nie eine Rolle – Banken schreiben denselben Empfänger mal so, mal
so.
* **Betragsgrenzen** rechnen mit Vorzeichen: „von −60 bis −40“ trifft Lastschriften
zwischen 40 und 60 Euro.
* **Wirkung**: Kategorie, Klasse, Gegenkonto (macht daraus eine Umbuchung) oder ein festes
**Split-Schema** in Prozent. Die letzte Splitzeile bekommt den Rest, damit die Summe auf
den Cent zum Buchungsbetrag passt.
Die **erste passende Regel gewinnt**, sortiert nach Priorität (kleinere Zahl zuerst). Kein
Sammeln über mehrere Regeln: Wenn zwei verschiedene Kategorien setzen wollten, wäre am Ende
nicht mehr nachvollziehbar, warum die eine dasteht.
Reguläre Ausdrücke werden beim Speichern geprüft – ein kaputtes Muster brächte sonst jeden
Abruf zum Stehen. Eingegeben wird ohne Trennzeichen: `Miete|Nebenkosten`, nicht `/Miete/`.
Der Zähler *Treffer* zeigt, welche Regeln im Alltag überhaupt greifen.
## Protokoll und Diagnose
Jeder Abruf schreibt eine Zeile nach `bank_abrufe` mit Status, Zahlen und Verlauf – ohne das
ist ein fehlgeschlagener Abruf nicht mehr zu rekonstruieren, denn FinTS-Meldungen der Banken
sind selten selbsterklärend. Läufe, die länger als zwei Stunden auf eine TAN warten, gelten
als abgebrochen.
Bei eingeschaltetem Debug-Log (Einstellungen → Diagnose) landen zusätzlich die
FinTS-Dialogschritte in `data/logs/` – über `Services\DebugLogAdapter`, der den PSR-3-Logger
der Bibliothek an das eigene Log hängt. PIN und TAN maskieren beide Seiten.
## Tests
`tests/16_BankingTest.php` prüft alles um den Dialog herum: Verschlüsselung (Kennung, kein
Klartext, wechselnder Geheimtext, kein Verschachteln), PIN bleibt beim Speichern erhalten,
Abrufzeiträume, Dublettenerkennung, Regeln nach Priorität und Feld, Splitschema mit
Restausgleich, Abweisung ungültiger Ausdrücke, Abgleich gegen vorhandene Buchungen samt
Toleranz sowie Übernehmen, Verwerfen und Zurücksetzen.
Der FinTS-Dialog selbst braucht eine echte Bankverbindung und lässt sich nicht nachstellen.
Geprüft wurde er gegen den Fehlerpfad: Eine unerreichbare Adresse bricht sauber ab,
protokolliert und meldet verständlich.

View file

@ -1,7 +1,21 @@
# Cockpit
Die Startseite. Jede Kachel beantwortet eine Frage auf einen Blick; ausgebaut wird sie
Stück für Stück in Phase 4.
Die Startseite. Jede Kachel beantwortet eine Frage auf einen Blick.
## Welche Kacheln erscheinen
Es gibt fünf: **Vermögensentwicklung**, **Einnahmen und Ausgaben**, **Meine Ausgaben**,
**Kontostände** und **Offene Vorgänge**. Welche davon gezeigt werden, steht im
Einstellungsdialog unter *Darstellung* – der Katalog dazu in `Cockpit::KACHELN`, die
Auswahl als kommagetrennte Liste unter `cockpit_kacheln`.
Vorgabe ist: alle. Eine abgewählte Kachel wird nicht nur ausgeblendet, sondern gar nicht
erst berechnet – `CockpitController::index()` holt die Daten je Kachel einzeln. Wer das
Cockpit entschlacken will, macht es damit auch schneller.
Die Reihenfolge im Raster ist die des Katalogs, nicht die der Ankreuzfelder: Ein Formular
liefert Ankreuzfelder in der Reihenfolge des HTML, und die wäre für den Nutzer keine
sinnvolle Sortierung.
## Vermögensentwicklung
@ -102,8 +116,59 @@ Die Legende führt nur Farbe und Kontoname und steht darunter, in kleinerer Schr
vielen Spalten, wie die Kachel hergibt (`auto-fill`). Die Beträge stehen am Balken im
Tooltip und in der Kachel *Kontostände*; in der Legende kosteten sie nur Breite.
## Einnahmen und Ausgaben
Balkenpaare über die letzten zwölf Monate: links die Einnahmen, rechts die Ausgaben,
darüber der Saldo als Linie mit einem Punkt je Monat.
Die Zahlen kommen **aus dem Summenbericht** (`Cockpit::monatsreihe()` ruft
`Auswertung::bericht()` mit Zeitraum *letzte zwölf Monate* und Spalten-Aufteilung *Monat*).
Damit zählt die Kachel dieselben Bewegungen wie der Bericht: Splitzeilen einzeln,
Umbuchungen gar nicht. Eine eigene Abfrage wäre schneller zu schreiben und liefe früher
oder später auseinander.
Ausgaben stehen wie im Bericht ohne Minuszeichen; der Saldo ist Einnahmen minus Ausgaben
und kann natürlich negativ sein. Der Knopf unter der Kachel öffnet denselben Ausschnitt
als Summenbericht.
## Meine Ausgaben
Ein Ring der Ausgaben des laufenden Jahres, aufgeteilt nach **oberster Kategorie**:
„300 - Wohnen“ statt Miete, Strom und Nebenkosten einzeln. Ein Ring mit hundert Segmenten
beantwortet keine Frage; wer es genauer braucht, klickt sich in den Summenbericht.
`Auswertung::verteilung()` rechnet das: Bewegungen des Zeitraums, Einnahmen oder Ausgaben,
je Bewegung die Wurzel ihrer Kategorie (über den Kategoriepfad, nicht über die
`eltern_id`-Kette), Beträge vorzeichenlos gedreht wie im Ausgabenabschnitt der Berichte.
Mehr als acht Segmente werden nicht gezeichnet – die kleinsten wandern in **Übrige (n)**.
Kategorien, die im Saldo negativ sind (mehr Erstattung als Ausgabe), bekommen kein Segment;
in der Gesamtsumme in der Mitte des Rings stecken sie weiter, sonst ergänzten sich die
Anteile nicht mehr zu hundert Prozent.
Gezeichnet wird als SVG-Pfad mit zwei Bögen je Segment (`ausgabendiagramm.php`). Sonderfall
ist die einzige Kategorie mit hundert Prozent: Anfangs- und Endpunkt des Bogens fielen
zusammen und es entstünde keine Fläche – deshalb zwei Halbringe derselben Farbe.
## Offene Vorgänge
Was auf Bearbeitung wartet, mit Zahl und Weg dorthin:
* **ohne Kategorie** – Buchungen ohne Kategorie, die auch keine Umbuchung und nicht
gesplittet sind. Umbuchungen brauchen keine Kategorie und zählen deshalb nicht mit.
* **nicht abgeglichen** – Buchungen aktiver Konten bis heute ohne Abgleich-Kennzeichen.
* **Monatsabschluss steht aus** – der Vorschlag aus `Monatsabschluss::naechsterOffener()`,
aber nur, wenn dieser Monat wirklich noch offen ist und Buchungen trägt.
Ist nichts offen, bleibt die Liste leer und die Kachel sagt es ausdrücklich. Ab Phase 5
kommen die noch nicht zugeordneten Bankumsätze hinzu, ab Phase 7 die fälligen
regelmäßigen Buchungen.
## Tests
* `tests/15_CockpitTest.php` – Kachelauswahl (Reihenfolge, Unbekanntes, leere Auswahl),
Monatsreihe mit Trennung von Einnahmen und Ausgaben, Umbuchungen bleiben draußen,
Verteilung auf die Wurzelkategorie, Kürzung auf die Segmentgrenze, offene Vorgänge
* `tests/13_VermoegensentwicklungTest.php` – Zeitraum, Fortschreibung der Stände,
Umbuchungen, Σ-Kennzeichen, Schalter „nur aktive“, Zusammenfassen jenseits der Palette,
Ebenen (jedes Konto genau einmal, letzte Ebene ohne Sammelstück, zu tiefe Ebene fällt

145
docs/nebenmodule.md Normal file
View file

@ -0,0 +1,145 @@
# Nebenmodule und Zahlungsverkehr
Was in Phase 7 dazugekommen ist: Buchungsvorlagen, regelmäßige Buchungen mit
Finanzkalender, Erinnerungen, Adressen, Verträge, Sparziele, Kredite und der
Zahlungsverkehr.
## Buchungsvorlagen
Eine Vorlage ist eine fertige Buchung ohne Datum. Sie kommt aus dem QIF
(`!Type:Memorized` – im Beispielexport 579 Stück, vom FinanzManager automatisch gemerkt)
oder entsteht aus einer vorhandenen Buchung.
**Splitzeilen werden mitskaliert.** Weicht der Betrag beim Buchen von dem der Vorlage ab,
ziehen die Splitzeilen im gleichen Verhältnis mit; die letzte bekommt den Rest, damit die
Summe auf den Cent zum Buchungsbetrag passt. Ohne das ließe sich eine Vorlage mit Splits
nur zu ihrem ursprünglichen Betrag buchen.
Die Liste sortiert nach Häufigkeit: Wer eine Vorlage sucht, sucht meist eine, die er schon
oft gebraucht hat.
Beim Import entsteht der **Name** aus Empfänger und Betrag – die QIF-Sektion führt keinen.
Kommt derselbe Name mehrfach vor, zählt ein Zähler hoch, statt Vorlagen stillschweigend zu
verschlucken.
## Regelmäßige Buchungen
Jede Reihe führt ihre **nächste Fälligkeit** mit sich. Der Fälligkeitslauf holt, was
ansteht, bucht es und schreibt das Datum fort. Eine Tabelle vorausberechneter Termine gibt
es bewusst nicht: Sie wäre bei jeder Änderung neu zu erzeugen.
Zwei Betriebsarten:
* **automatisch** – wird ohne Rückfrage gebucht. Für Miete und Gehalt, wo Betrag und
Termin feststehen.
* **auf Bestätigung** – wird nur gemeldet und wartet auf Knopfdruck. Für alles, dessen
Betrag schwankt.
Der Lauf ist gutmütig gegenüber langen Pausen: Liegen mehrere Termine zurück, wird **jeder
einzeln** gebucht, nicht einer für alle – nur so stimmen die Monatssummen. Nach dem Buchen
rückt die Fälligkeit so weit vor, bis sie in der Zukunft liegt.
In einen **abgeschlossenen Monat** wird nicht hineingebucht: Die Reihe bleibt stehen und
meldet sich, statt den ganzen Lauf mit einer Ausnahme abzubrechen.
*Überspringen* rückt vor, ohne zu buchen – für den Monat, in dem die Zahlung einmal
ausgesetzt wurde.
## Finanzkalender
Zusammengetragen aus vier Quellen: fällige regelmäßige Buchungen, offene Erinnerungen,
ablaufende Kündigungsfristen und terminierte Überweisungen. Alles gerechnet, nichts
gespeichert – eine Änderung an einer Reihe wirkt sich sofort aus.
Überfälliges bleibt sichtbar und wird markiert, statt aus der Liste zu fallen.
## Adressen und IBAN-Prüfung
Das Adressbuch speist die Empfängerauswahl im Zahlungsverkehr. **IBANs werden geprüft**
(ISO 13616: Länge je Land, Prüfziffer modulo 97). Das fängt Zahlendreher ab, bevor eine
Überweisung hinausgeht – die häufigste und ärgerlichste Fehlerquelle im Zahlungsverkehr.
Gerechnet wird stückweise mit `%` statt mit `bcmod`: Die BC-Erweiterung ist keine
Standardausstattung, und ein Modulo über sieben Ziffern lange Blöcke kommt ohne sie aus.
## Verträge
Der Nutzen liegt in der **Kündigungsfrist**: Die Übersicht zeigt, bis wann gekündigt sein
muss, damit sich ein Vertrag nicht stillschweigend verlängert. Verträge, deren Frist in den
nächsten 90 Tagen abläuft, stehen oben auf der Seite und im Finanzkalender.
Der **Jahresbetrag** rechnet verschieden getaktete Verträge vergleichbar: monatlich × 12,
vierteljährlich × 4 und so fort.
Ein Vertrag kann mit einer regelmäßigen Buchung verbunden sein, die seine Zahlung erledigt.
## Sparziele
Der erreichte Stand kommt **aus dem Kontosaldo**, nicht aus einem eigenen Zähler – ein
zweiter, gepflegter Wert liefe früher oder später von den Buchungen weg. Der Startbetrag
zieht ab, was zu Beginn schon auf dem Konto lag.
Zum Zieldatum rechnet die Anwendung aus, welche Monatsrate nötig wäre, um den Termin noch
zu halten.
Der Fortschrittsbalken ist ein `<progress>`-Element: Ein eigener Balken bräuchte seine
Breite als `style`-Attribut, und das verbietet die Content-Security-Policy.
## Kredite
Gerechnet wird als **Annuitätendarlehen**: gleichbleibende Rate, sinkender Zinsanteil,
wachsende Tilgung. Zinsen fallen je Periode auf die Restschuld an.
Der Tilgungsplan wird **gerechnet, nicht gespeichert** – er ergibt sich vollständig aus
Betrag, Zins, Rate und Beginn. Eine jährliche Sondertilgung wird im Dezember verrechnet.
Deckt die Rate nicht einmal die Zinsen der ersten Periode, entsteht kein Plan, sondern ein
Hinweis: So ein Kredit würde wachsen statt zu schrumpfen, und eine Endlosschleife wäre die
schlechtere Antwort darauf.
Bei langen Laufzeiten zeigt die Tabelle die nächsten zwei Jahre und das Ende; dazwischen
steht, wie viele Raten ausgelassen wurden.
## Zahlungsverkehr
**Erst die Bank, dann die Buchung.** Ein Zahlungsauftrag ist zunächst ein Entwurf. Die
Buchung auf dem Auftragskonto entsteht erst, wenn die Bank den Auftrag angenommen hat –
vorher wäre sie eine Behauptung, die man nach einer abgelehnten TAN wieder einsammeln
müsste.
Ein gesendeter Auftrag lässt sich weder ändern noch löschen: Er bleibt als Beleg stehen.
Fehlgeschlagene tragen die Meldung der Bank daneben.
Ohne Ausführungstermin geht die Überweisung sofort hinaus, mit Termin als
Terminüberweisung; sie erscheint dann im Finanzkalender.
### SEPA-XML von Hand
php-fints erwartet die fertige **pain.001.001.03**-Nachricht und erzeugt sie nicht. Die
üblichen Erzeuger wären zwei weitere Abhängigkeiten für ein Dokument von sechzig Zeilen –
`Services\SepaXml` schreibt es deshalb selbst.
Der **SEPA-Zeichenvorrat** ist eng: `a–z A–Z 0–9 / - ? : ( ) . , ' +` und Leerzeichen.
Umlaute werden umgeschrieben (ä → ae), alles Übrige fällt weg. Ohne diese Umschrift weist
die Bank den Auftrag zurück – und zwar erst nach dem Absenden.
Der Verwendungszweck endet bei 140 Zeichen; das ist das SEPA-Maß, alles darüber würde die
Bank ohnehin abschneiden.
### TAN
Für Überweisungen verlangt jede Bank eine TAN. Der Dialog wird genauso über die Sitzung
fortgesetzt wie beim Abruf (siehe `docs/banking.md`): Zustand sichern, Maske zeigen, nach
der Eingabe weitermachen.
Beim Abbrechen bleibt der Auftrag als Entwurf stehen und lässt sich erneut senden.
## Tests
`tests/18_NebenmoduleTest.php` – Vorlagen mit skalierten Splits und aus Buchungen,
QIF-Vorlagenimport samt Idempotenz, Terminrechnung und Fortschreiben der Fälligkeit,
Fälligkeitslauf (nur automatische Reihen, wartende rücken nicht vor), Erinnerungen,
IBAN-Prüfung mit gültigen und falschen Nummern, Kündigungsfristen und Jahresbeträge,
Sparfortschritt aus dem Konto, Tilgungsplan (Annuität, letzte Rate, Sondertilgung,
zu kleine Rate), Zahlungsaufträge (Buchung erst nach Annahme, Unveränderlichkeit,
Eingabeprüfungen), SEPA-XML (Schema, Zeichensatz, Wohlgeformtheit) und der Finanzkalender.

129
docs/wertpapiere.md Normal file
View file

@ -0,0 +1,129 @@
# Wertpapiere und Depots
Ein Depot ist ein Konto der Kontoart **Depot**. Was darin liegt, steht nicht in
`buchungen`, sondern in `depot_buchungen` – dort gehören Stückzahl und Kurs dazu, und der
Saldo ist keine Summe von Beträgen, sondern der bewertete Bestand.
## Zwei Seiten je Geschäft
Jedes Wertpapiergeschäft erzeugt bis zu zwei Datensätze:
* die **Depotbuchung** mit Stück, Kurs, Gebühren und Steuern,
* die **Geldbuchung** auf dem Verrechnungskonto – eine gewöhnliche Buchung, die im
Kontoblatt steht wie jede andere.
Verbunden sind sie über `buchung_id`. So bleibt das Girokonto stimmig, ohne dass
Kontoblätter Stückzahlen führen müssten. Wird die Depotbuchung gelöscht, geht die
Geldbuchung mit; sie hat ohne das Geschäft keinen Sinn.
Ein- und Auslieferungen rühren kein Konto an und haben deshalb keine Geldbuchung.
## Was in welchem Feld steht
`betrag` ist der **Bruttowert**: Stück × Kurs, bei Dividenden der Bruttoertrag. Gebühren
und Steuern stehen *daneben*, nicht darin. Daraus folgt der Geldfluss:
| Vorgang | Verrechnungskonto |
|---|---|
| Kauf | −(Betrag + Gebühren + Steuern) |
| Verkauf | +(Betrag − Gebühren − Steuern) |
| Dividende | +(Betrag − Steuern) |
Am echten Export nachgerechnet: Ein Verkauf über 4 × 797,00 € mit 15,37 € Gebühren und
69,46 € Steuern ergibt 3.103,17 € Gutschrift – genau der Wert, den das QIF im Feld `$`
nennt. Diese Trennung ist nötig, damit der Einstand die Nebenkosten **genau einmal**
enthält.
`stueck` trägt die Richtung: Zugänge positiv, Abgänge negativ, Dividenden null.
## Einstand nach gleitendem Durchschnitt
Bei jedem Kauf steigt der Einstand um Kaufpreis samt Gebühren, bei jedem Verkauf sinkt er
anteilig um den bis dahin durchschnittlichen Stückpreis. So rechnet auch der
FinanzManager, und die Frage, *welches* Stück verkauft wurde, stellt sich gar nicht.
Der **realisierte Gewinn** eines Verkaufs ist Erlös minus Kosten minus anteiligem
Einstand.
> Für die Steuererklärung gilt in Deutschland FIFO. Maßgeblich ist dort die Abrechnung der
> Bank – diese Übersicht ersetzt sie nicht.
Vollständig verkaufte Positionen verschwinden aus der Bestandsliste; ihr realisierter
Gewinn zählt weiter mit.
## Kurse
Ein Kurs je Wertpapier und Tag. Mehr Auflösung braucht eine Vermögensübersicht nicht, und
die Freikontingente der Kursdienste geben nicht mehr her.
**Ohne Kurs wird nicht bewertet**: Die Position zeigt „kein Kurs“ und fällt aus der
Wertsumme; die Übersicht sagt ausdrücklich, dass sie unvollständig ist. Der Einstand als
Ersatzwert wäre bequem, verstellte aber den Blick.
### Kursabruf
`Services\Kursabruf` kennt zwei Anbieter (siehe `Aktienkurse.md`):
* **Leeway.tech** – 50 Abrufe/Tag, kennt ISINs direkt
* **Financial Modeling Prep** – 250 Abrufe/Tag
Beide arbeiten in zwei Schritten: ISIN → Kürzel, Kürzel → Kurs. Das gefundene Kürzel wird
am Wertpapier gespeichert, damit der erste Schritt künftig entfällt – das halbiert den
Verbrauch. Abgerufen wird nur für Papiere **mit Bestand**, und nur, wenn für heute noch
kein Kurs vorliegt.
Anbieter und Schlüssel stehen im Einstellungsdialog unter *Wertpapiere*. Der tägliche Lauf
gehört in einen Cron-Eintrag:
```bash
docker compose exec -u www-data php php scripts/kursabruf.php
```
Das Skript bricht ab, solange der automatische Abruf in den Einstellungen nicht
eingeschaltet ist – ein vergessener Cron-Eintrag kostet so kein Kontingent. Der Knopf auf
der Wertpapierseite arbeitet unabhängig davon.
Die Feldnamen der Antworten werden nachsichtig gelesen (`close`, `price`, `last` …): Die
Dienste benennen sie unterschiedlich und gelegentlich um. Der API-Schlüssel steht in der
Adresse und wird vor jedem Logeintrag maskiert.
## QIF-Import (`!Type:Invst`)
Der Import legt Wertpapier, Depotbuchung und Geldbuchung in einem Zug an. Zwei
Eigenheiten des Formats:
* **Das `L`-Feld trägt bei Wertpapiergeschäften mehr als sonst.** Dem Verrechnungskonto
kann eine Kategorie vorangehen: `LKursgewinne:Realisierte Gewinne|[Verrechnungskonto]`.
Gelesen wird deshalb nur der Teil hinter dem letzten senkrechten Strich.
* **Das Feld `O` führt zwölf Beträge.** Sicher zugeordnet sind Position 4 (Provision) und
10 (fremde Spesen) – beides Gebühren. Alles Übrige sind Abzüge des Fiskus und werden zu
„Steuern“ zusammengefasst. Damit fällt keine Position heraus, ohne dass jede einzeln
gedeutet werden müsste.
Fehlt der Kurs – bei Dividenden immer –, wird der Bruttobetrag aus der Gutschrift plus den
Abzügen zurückgerechnet.
`DivIn` und `DivAus` landen beide auf „dividende“; die ursprüngliche Bezeichnung bleibt in
`vorgang_qif` erhalten, weil die Zuordnung nicht in jedem Fall umkehrbar ist.
**Dabei behoben:** Bis Phase 5 buchte der Import den Geldfluss *jedes*
Wertpapiergeschäfts als Abgang – auch bei Verkäufen und Dividenden. Das QIF nennt im Feld
`$` immer einen positiven Betrag; die Richtung steckt allein im Vorgang. Verrechnungskonten
von Depots wiesen dadurch zu niedrige Salden aus.
## Kontenübersicht
Depots erscheinen dort mit ihrem **bewerteten Bestand**, nicht mit einem Buchungssaldo. Der
Anfangssaldo bleibt dabei: Führt ein Depotkonto eine Barreserve, steht sie dort.
Damit ist die Voraussetzung erfüllt, um die Summe der Kontostände mit dem FinanzManager zu
vergleichen (`PLAN.md`, Kapitel 7) – sobald Kurse für alle Positionen vorliegen.
## Tests
`tests/17_WertpapiereTest.php` – Wiederfinden über ISIN, WKN und Name samt Nachtragen
fehlender Felder; Bestand und Einstand über mehrere Käufe; Geldseite mit richtigem
Vorzeichen; Verkauf mit anteiligem Einstand und realisiertem Gewinn; Dividende ohne
Bestandsänderung; fehlende Kurse; Veräußerungsrechnung über die Historie hinweg; der
QIF-Import eines echten Ausschnitts (Kauf, Dividende, Verkauf) inklusive Idempotenz;
Löschen samt Geldbuchung.

158
migrations/006_banking.sql Normal file
View file

@ -0,0 +1,158 @@
-- Phase 5: Online-Banking über FinTS.
--
-- Ein Bankzugang bündelt die Zugangsdaten einer Bank; ihm hängen die Konten an,
-- die dort geführt werden (Zuordnung IBAN → eigenes Konto). Abgerufene Umsätze
-- landen zunächst als Rohdaten in bank_umsaetze und werden erst beim Abgleich zu
-- Buchungen – so lässt sich jeder Abruf ansehen, bevor er im Kontoblatt steht.
CREATE TABLE IF NOT EXISTS bank_zugaenge (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
name VARCHAR(128) NOT NULL,
bankname VARCHAR(128) NOT NULL DEFAULT '',
blz VARCHAR(16) NOT NULL DEFAULT '',
fints_url VARCHAR(255) NOT NULL DEFAULT '',
benutzerkennung VARCHAR(128) NOT NULL DEFAULT '',
-- PIN und TAN-Medium sind AES-verschlüsselt (Services\Crypto), niemals im
-- Klartext. Der Schlüssel steht als APP_KEY in der Umgebung, nicht hier.
pin TEXT NULL,
tan_verfahren_id INT NULL,
tan_medium VARCHAR(128) NOT NULL DEFAULT '',
-- Abrufzeitraum: seit dem letzten Abruf, letzte 7/30/365 Tage.
abrufzeitraum VARCHAR(24) NOT NULL DEFAULT 'letzter_abruf',
zuletzt_am DATETIME NULL,
aktiv TINYINT(1) NOT NULL DEFAULT 1,
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
geaendert_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_bankzugang_name (benutzer_id, name),
CONSTRAINT fk_bankzugang_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Welches Bankkonto zu welchem eigenen Konto gehört. Eine eigene Tabelle statt
-- einer JSON-Liste am Zugang: Die Zuordnung wird bei jedem Abruf gebraucht, und
-- ein Fremdschlüssel auf konten hält sie sauber, wenn ein Konto verschwindet.
CREATE TABLE IF NOT EXISTS bank_konten (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
zugang_id INT UNSIGNED NOT NULL,
konto_id INT UNSIGNED NULL,
iban VARCHAR(34) NOT NULL DEFAULT '',
kontonummer VARCHAR(34) NOT NULL DEFAULT '',
bezeichnung VARCHAR(128) NOT NULL DEFAULT '',
-- Abgeschaltete Zuordnungen werden beim Abruf übersprungen; das ist der
-- Kontenfilter aus PLAN.md, nur eine Zeile statt eines JSON-Feldes.
abrufen TINYINT(1) NOT NULL DEFAULT 1,
saldo DECIMAL(15,2) NULL,
saldo_am DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_bankkonto (zugang_id, iban, kontonummer),
KEY idx_bankkonto_konto (konto_id),
CONSTRAINT fk_bankkonto_zugang FOREIGN KEY (zugang_id)
REFERENCES bank_zugaenge (id) ON DELETE CASCADE,
CONSTRAINT fk_bankkonto_konto FOREIGN KEY (konto_id)
REFERENCES konten (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Rohumsätze vor dem Abgleich. extern_id ist der Fingerabdruck des Umsatzes bei
-- der Bank; er verhindert, dass derselbe Umsatz bei überlappenden Abrufzeiträumen
-- zweimal hereinkommt.
CREATE TABLE IF NOT EXISTS bank_umsaetze (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
zugang_id INT UNSIGNED NOT NULL,
konto_id INT UNSIGNED NULL,
extern_id CHAR(40) NOT NULL,
buchungstag DATE NOT NULL,
wertstellung DATE NULL,
betrag DECIMAL(15,2) NOT NULL DEFAULT 0.00,
waehrung CHAR(3) NOT NULL DEFAULT 'EUR',
empfaenger VARCHAR(255) NOT NULL DEFAULT '',
verwendungszweck TEXT NULL,
buchungsart VARCHAR(100) NOT NULL DEFAULT '',
-- offen: wartet auf den Abgleich; uebernommen: wurde zur Buchung;
-- verworfen: bewusst liegen gelassen (etwa doppelt gelieferte Altumsätze).
status ENUM('offen','uebernommen','verworfen') NOT NULL DEFAULT 'offen',
buchung_id INT UNSIGNED NULL,
roh LONGTEXT NULL,
abgerufen_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_umsatz_extern (benutzer_id, extern_id),
KEY idx_umsatz_status (benutzer_id, status, buchungstag),
KEY idx_umsatz_konto (konto_id, buchungstag),
CONSTRAINT fk_umsatz_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE,
CONSTRAINT fk_umsatz_zugang FOREIGN KEY (zugang_id)
REFERENCES bank_zugaenge (id) ON DELETE CASCADE,
CONSTRAINT fk_umsatz_konto FOREIGN KEY (konto_id)
REFERENCES konten (id) ON DELETE SET NULL,
-- Wird die Buchung gelöscht, ist der Umsatz wieder offen; er verschwindet
-- nicht mit ihr, sonst fehlte er beim nächsten Abgleich.
CONSTRAINT fk_umsatz_buchung FOREIGN KEY (buchung_id)
REFERENCES buchungen (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Regeln für die automatische Kategorisierung. Sie greifen beim Abruf und auf
-- Wunsch nachträglich auf schon vorhandene Buchungen.
CREATE TABLE IF NOT EXISTS abgleich_regeln (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
name VARCHAR(128) NOT NULL,
-- Worauf das Muster passt: Empfänger, Verwendungszweck oder beides.
feld ENUM('empfaenger','verwendungszweck','beides') NOT NULL DEFAULT 'beides',
-- Wie verglichen wird. „regex“ ist der Notausgang für die Fälle, in denen
-- „enthält“ nicht reicht; Muster werden vor dem Speichern geprüft.
vergleich ENUM('enthaelt','beginnt','endet','regex') NOT NULL DEFAULT 'enthaelt',
muster VARCHAR(255) NOT NULL,
betrag_von DECIMAL(15,2) NULL,
betrag_bis DECIMAL(15,2) NULL,
kategorie_id INT UNSIGNED NULL,
klasse_id INT UNSIGNED NULL,
gegenkonto_id INT UNSIGNED NULL,
-- Festes Split-Schema als JSON: [{"kategorie_id":1,"anteil":0.7}, …].
splits LONGTEXT NULL,
-- Kleinere Zahl greift zuerst; bei Gleichstand entscheidet die Reihenfolge.
prioritaet INT NOT NULL DEFAULT 100,
aktiv TINYINT(1) NOT NULL DEFAULT 1,
treffer INT UNSIGNED NOT NULL DEFAULT 0,
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_regel_name (benutzer_id, name),
KEY idx_regel_reihenfolge (benutzer_id, aktiv, prioritaet),
CONSTRAINT fk_regel_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE,
CONSTRAINT fk_regel_kategorie FOREIGN KEY (kategorie_id)
REFERENCES kategorien (id) ON DELETE CASCADE,
CONSTRAINT fk_regel_klasse FOREIGN KEY (klasse_id)
REFERENCES klassen (id) ON DELETE SET NULL,
CONSTRAINT fk_regel_gegenkonto FOREIGN KEY (gegenkonto_id)
REFERENCES konten (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Abrufprotokoll: was wann lief, wie viel dabei herauskam und woran es
-- gegebenenfalls scheiterte. Ohne das ist ein fehlgeschlagener Abruf nicht mehr
-- nachzuvollziehen – die FinTS-Meldungen der Banken sind selten selbsterklärend.
CREATE TABLE IF NOT EXISTS bank_abrufe (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
zugang_id INT UNSIGNED NOT NULL,
begonnen_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
beendet_am DATETIME NULL,
status ENUM('laeuft','tan_offen','erfolg','fehler','abgebrochen') NOT NULL DEFAULT 'laeuft',
neu INT UNSIGNED NOT NULL DEFAULT 0,
uebersprungen INT UNSIGNED NOT NULL DEFAULT 0,
meldung TEXT NULL,
protokoll LONGTEXT NULL,
PRIMARY KEY (id),
KEY idx_abruf_zugang (zugang_id, begonnen_am),
CONSTRAINT fk_abruf_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE,
CONSTRAINT fk_abruf_zugang FOREIGN KEY (zugang_id)
REFERENCES bank_zugaenge (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO einstellungen (schluessel, wert, bereich) VALUES
('banking_produkt_id', '', 'banking'),
('banking_regeln_beim_abruf', '1', 'banking'),
('banking_abgleich_tage', '5', 'banking')
ON DUPLICATE KEY UPDATE schluessel = schluessel;

View file

@ -0,0 +1,100 @@
-- Phase 6: Wertpapiere, Depotbuchungen und Kurse.
--
-- Ein Depot ist ein Konto der Kontoart „depot“. Was darin liegt, steht nicht in
-- `buchungen`, sondern in `depot_buchungen`: Dort gehören Stückzahl und Kurs
-- dazu, und der Saldo eines Depots ist keine Summe von Beträgen, sondern der
-- bewertete Bestand.
--
-- Die Geldseite läuft weiter über gewöhnliche Buchungen auf dem
-- Verrechnungskonto – ein Kauf nimmt dort Geld weg, eine Dividende bringt
-- welches. Beide Seiten sind über `buchung_id` verbunden.
CREATE TABLE IF NOT EXISTS wertpapiere (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
name VARCHAR(255) NOT NULL,
wkn VARCHAR(16) NOT NULL DEFAULT '',
isin VARCHAR(16) NOT NULL DEFAULT '',
-- Wertpapierart aus dem QIF-Feld „&“: 1 Aktie, 2 Fonds, 3 Anleihe …
art VARCHAR(32) NOT NULL DEFAULT 'aktie',
waehrung CHAR(3) NOT NULL DEFAULT 'EUR',
-- Kürzel für den Kursabruf, falls der Anbieter die ISIN nicht kennt.
symbol VARCHAR(32) NOT NULL DEFAULT '',
aktiv TINYINT(1) NOT NULL DEFAULT 1,
notiz VARCHAR(255) NOT NULL DEFAULT '',
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
-- Die ISIN ist der verlässliche Schlüssel; Papiere ohne ISIN werden über
-- den Namen wiedergefunden, deshalb ist auch der eindeutig.
UNIQUE KEY uq_wertpapier_isin (benutzer_id, isin, name),
KEY idx_wertpapier_wkn (benutzer_id, wkn),
CONSTRAINT fk_wertpapier_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS depot_buchungen (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
konto_id INT UNSIGNED NOT NULL,
wertpapier_id INT UNSIGNED NOT NULL,
datum DATE NOT NULL,
valuta DATE NULL,
-- kauf, verkauf, dividende, einlage, entnahme, split
vorgang VARCHAR(24) NOT NULL DEFAULT 'kauf',
-- Der Vorgang, wie ihn der FinanzManager nennt (DivIn/DivAus/WP-Einlage …).
-- Er bleibt erhalten, weil die Zuordnung auf die eigenen Vorgänge nicht in
-- jedem Fall umkehrbar ist.
vorgang_qif VARCHAR(24) NOT NULL DEFAULT '',
stueck DECIMAL(18,6) NOT NULL DEFAULT 0.000000,
kurs DECIMAL(18,6) NULL,
-- Der Betrag der Wertpapierseite (QIF-Feld U).
betrag DECIMAL(15,2) NOT NULL DEFAULT 0.00,
gebuehren DECIMAL(15,2) NOT NULL DEFAULT 0.00,
steuern DECIMAL(15,2) NOT NULL DEFAULT 0.00,
waehrung CHAR(3) NOT NULL DEFAULT 'EUR',
-- Das Konto, über das die Geldseite läuft, und die dort erzeugte Buchung.
verrechnungskonto_id INT UNSIGNED NULL,
buchung_id INT UNSIGNED NULL,
notiz TEXT NULL,
quelle VARCHAR(16) NOT NULL DEFAULT 'manuell',
import_hash CHAR(40) NULL,
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_depot_konto (konto_id, datum),
KEY idx_depot_wertpapier (wertpapier_id, datum),
UNIQUE KEY uq_depot_import (benutzer_id, import_hash),
CONSTRAINT fk_depot_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE,
CONSTRAINT fk_depot_konto FOREIGN KEY (konto_id)
REFERENCES konten (id) ON DELETE CASCADE,
CONSTRAINT fk_depot_wertpapier FOREIGN KEY (wertpapier_id)
REFERENCES wertpapiere (id) ON DELETE CASCADE,
CONSTRAINT fk_depot_verrechnung FOREIGN KEY (verrechnungskonto_id)
REFERENCES konten (id) ON DELETE SET NULL,
-- Wird die Geldbuchung gelöscht, bleibt die Depotbuchung bestehen; sie ist
-- das eigentliche Geschäft.
CONSTRAINT fk_depot_buchung FOREIGN KEY (buchung_id)
REFERENCES buchungen (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Ein Kurs je Wertpapier und Tag. Mehr Auflösung braucht eine Vermögensübersicht
-- nicht, und der Kursdienst liefert im Freikontingent ohnehin nur Tagesschlüsse.
CREATE TABLE IF NOT EXISTS kurse (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
wertpapier_id INT UNSIGNED NOT NULL,
datum DATE NOT NULL,
kurs DECIMAL(18,6) NOT NULL,
waehrung CHAR(3) NOT NULL DEFAULT 'EUR',
quelle VARCHAR(32) NOT NULL DEFAULT 'manuell',
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_kurs (wertpapier_id, datum),
CONSTRAINT fk_kurs_wertpapier FOREIGN KEY (wertpapier_id)
REFERENCES wertpapiere (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO einstellungen (schluessel, wert, bereich) VALUES
('wertpapiere_kursdienst', 'leeway', 'wertpapiere'),
('wertpapiere_api_key', '', 'wertpapiere'),
('wertpapiere_kursabruf_aktiv', '0', 'wertpapiere')
ON DUPLICATE KEY UPDATE schluessel = schluessel;

View file

@ -0,0 +1,248 @@
-- Phase 7: Nebenmodule und Zahlungsverkehr.
--
-- Buchungsvorlagen, regelmäßige Buchungen, Erinnerungen, Adressen, Verträge,
-- Sparziele, Kredite und die Zahlungsaufträge des Zahlungsverkehrs.
-- Buchungsvorlagen (QIF !Type:Memorized). Sie füllen die Buchungsmaske vor;
-- die Splitzeilen stehen als JSON dabei, weil sie nur als Ganzes gebraucht
-- werden und keine Auswertung darauf läuft.
CREATE TABLE IF NOT EXISTS buchungsvorlagen (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
name VARCHAR(255) NOT NULL,
konto_id INT UNSIGNED NULL,
betrag DECIMAL(15,2) NOT NULL DEFAULT 0.00,
empfaenger VARCHAR(255) NOT NULL DEFAULT '',
buchungstext TEXT NULL,
nummer VARCHAR(32) NOT NULL DEFAULT '',
kategorie_id INT UNSIGNED NULL,
klasse_id INT UNSIGNED NULL,
gegenkonto_id INT UNSIGNED NULL,
splits LONGTEXT NULL,
quelle VARCHAR(16) NOT NULL DEFAULT 'manuell',
verwendet INT UNSIGNED NOT NULL DEFAULT 0,
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_vorlage_name (benutzer_id, name),
KEY idx_vorlage_empfaenger (benutzer_id, empfaenger),
CONSTRAINT fk_vorlage_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE,
CONSTRAINT fk_vorlage_konto FOREIGN KEY (konto_id)
REFERENCES konten (id) ON DELETE SET NULL,
CONSTRAINT fk_vorlage_kategorie FOREIGN KEY (kategorie_id)
REFERENCES kategorien (id) ON DELETE SET NULL,
CONSTRAINT fk_vorlage_klasse FOREIGN KEY (klasse_id)
REFERENCES klassen (id) ON DELETE SET NULL,
CONSTRAINT fk_vorlage_gegenkonto FOREIGN KEY (gegenkonto_id)
REFERENCES konten (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Regelmäßige Buchungen. „naechste_faelligkeit“ wird beim Buchen fortgeschrieben;
-- daran hängen der Fälligkeitslauf und der Finanzkalender.
CREATE TABLE IF NOT EXISTS regelmaessige_buchungen (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
name VARCHAR(255) NOT NULL,
konto_id INT UNSIGNED NOT NULL,
betrag DECIMAL(15,2) NOT NULL DEFAULT 0.00,
empfaenger VARCHAR(255) NOT NULL DEFAULT '',
buchungstext TEXT NULL,
kategorie_id INT UNSIGNED NULL,
klasse_id INT UNSIGNED NULL,
gegenkonto_id INT UNSIGNED NULL,
splits LONGTEXT NULL,
-- taeglich, woechentlich, zweiwoechentlich, monatlich, zweimonatlich,
-- vierteljaehrlich, halbjaehrlich, jaehrlich
intervall VARCHAR(24) NOT NULL DEFAULT 'monatlich',
-- Wie oft das Intervall übersprungen wird: 1 = jedes Mal, 2 = jedes zweite.
faktor TINYINT UNSIGNED NOT NULL DEFAULT 1,
beginn DATE NOT NULL,
ende DATE NULL,
naechste_faelligkeit DATE NULL,
-- Automatisch buchen oder nur erinnern und auf Bestätigung warten.
automatisch TINYINT(1) NOT NULL DEFAULT 0,
-- So viele Tage im Voraus taucht sie im Kalender und in den offenen
-- Vorgängen auf.
vorlauf_tage SMALLINT UNSIGNED NOT NULL DEFAULT 7,
aktiv TINYINT(1) NOT NULL DEFAULT 1,
zuletzt_am DATE NULL,
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_regelmaessig_faellig (benutzer_id, aktiv, naechste_faelligkeit),
CONSTRAINT fk_regelmaessig_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE,
CONSTRAINT fk_regelmaessig_konto FOREIGN KEY (konto_id)
REFERENCES konten (id) ON DELETE CASCADE,
CONSTRAINT fk_regelmaessig_kategorie FOREIGN KEY (kategorie_id)
REFERENCES kategorien (id) ON DELETE SET NULL,
CONSTRAINT fk_regelmaessig_klasse FOREIGN KEY (klasse_id)
REFERENCES klassen (id) ON DELETE SET NULL,
CONSTRAINT fk_regelmaessig_gegenkonto FOREIGN KEY (gegenkonto_id)
REFERENCES konten (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS erinnerungen (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
text VARCHAR(255) NOT NULL,
faellig_am DATE NOT NULL,
notiz TEXT NULL,
erledigt TINYINT(1) NOT NULL DEFAULT 0,
erledigt_am DATETIME NULL,
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_erinnerung_faellig (benutzer_id, erledigt, faellig_am),
CONSTRAINT fk_erinnerung_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Adressbuch. Es speist die Empfängerauswahl im Zahlungsverkehr.
CREATE TABLE IF NOT EXISTS adressen (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
name VARCHAR(255) NOT NULL,
zusatz VARCHAR(255) NOT NULL DEFAULT '',
strasse VARCHAR(255) NOT NULL DEFAULT '',
plz VARCHAR(16) NOT NULL DEFAULT '',
ort VARCHAR(128) NOT NULL DEFAULT '',
land VARCHAR(64) NOT NULL DEFAULT '',
iban VARCHAR(34) NOT NULL DEFAULT '',
bic VARCHAR(11) NOT NULL DEFAULT '',
email VARCHAR(190) NOT NULL DEFAULT '',
telefon VARCHAR(64) NOT NULL DEFAULT '',
notiz TEXT NULL,
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_adresse_name (benutzer_id, name),
KEY idx_adresse_iban (benutzer_id, iban),
CONSTRAINT fk_adresse_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS vertraege (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
name VARCHAR(255) NOT NULL,
partner VARCHAR(255) NOT NULL DEFAULT '',
adresse_id INT UNSIGNED NULL,
vertragsnummer VARCHAR(64) NOT NULL DEFAULT '',
kategorie_id INT UNSIGNED NULL,
betrag DECIMAL(15,2) NOT NULL DEFAULT 0.00,
-- Zahlungsrhythmus wie bei den regelmäßigen Buchungen.
intervall VARCHAR(24) NOT NULL DEFAULT 'monatlich',
beginn DATE NULL,
ende DATE NULL,
kuendigungsfrist_tage SMALLINT UNSIGNED NOT NULL DEFAULT 0,
-- Ein Vertrag kann seine Zahlung selbst buchen lassen.
regelmaessige_id INT UNSIGNED NULL,
notiz TEXT NULL,
aktiv TINYINT(1) NOT NULL DEFAULT 1,
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_vertrag_ende (benutzer_id, aktiv, ende),
CONSTRAINT fk_vertrag_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE,
CONSTRAINT fk_vertrag_adresse FOREIGN KEY (adresse_id)
REFERENCES adressen (id) ON DELETE SET NULL,
CONSTRAINT fk_vertrag_kategorie FOREIGN KEY (kategorie_id)
REFERENCES kategorien (id) ON DELETE SET NULL,
CONSTRAINT fk_vertrag_regelmaessig FOREIGN KEY (regelmaessige_id)
REFERENCES regelmaessige_buchungen (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Sparziele: ein Betrag, ein Termin und das Konto, auf dem gespart wird. Der
-- Stand wird nicht gespeichert, sondern aus dem Konto gerechnet – sonst liefe
-- er auseinander.
CREATE TABLE IF NOT EXISTS sparziele (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
name VARCHAR(255) NOT NULL,
konto_id INT UNSIGNED NULL,
zielbetrag DECIMAL(15,2) NOT NULL DEFAULT 0.00,
-- Was zu Beginn schon da war und nicht zum Ziel zählt.
startbetrag DECIMAL(15,2) NOT NULL DEFAULT 0.00,
zieldatum DATE NULL,
monatsrate DECIMAL(15,2) NULL,
notiz TEXT NULL,
erreicht TINYINT(1) NOT NULL DEFAULT 0,
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_sparziel_name (benutzer_id, name),
CONSTRAINT fk_sparziel_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE,
CONSTRAINT fk_sparziel_konto FOREIGN KEY (konto_id)
REFERENCES konten (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Kredite mit Tilgungsplan. Der Plan wird gerechnet, nicht gespeichert: Er
-- ergibt sich vollständig aus Betrag, Zins, Rate und Beginn.
CREATE TABLE IF NOT EXISTS kredite (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
name VARCHAR(255) NOT NULL,
konto_id INT UNSIGNED NULL,
darlehensbetrag DECIMAL(15,2) NOT NULL DEFAULT 0.00,
zinssatz DECIMAL(6,3) NOT NULL DEFAULT 0.000,
rate DECIMAL(15,2) NOT NULL DEFAULT 0.00,
-- monatlich, vierteljaehrlich, halbjaehrlich, jaehrlich
rhythmus VARCHAR(24) NOT NULL DEFAULT 'monatlich',
beginn DATE NOT NULL,
zinsbindung_bis DATE NULL,
sondertilgung DECIMAL(15,2) NOT NULL DEFAULT 0.00,
notiz TEXT NULL,
aktiv TINYINT(1) NOT NULL DEFAULT 1,
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_kredit_name (benutzer_id, name),
CONSTRAINT fk_kredit_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE,
CONSTRAINT fk_kredit_konto FOREIGN KEY (konto_id)
REFERENCES konten (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Zahlungsaufträge: Überweisungen, die über FinTS abgesendet werden. Der
-- Auftrag bleibt als Beleg stehen, auch wenn die Ausführung scheitert.
CREATE TABLE IF NOT EXISTS zahlungsauftraege (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
benutzer_id INT UNSIGNED NOT NULL,
konto_id INT UNSIGNED NOT NULL,
zugang_id INT UNSIGNED NULL,
empfaenger VARCHAR(255) NOT NULL,
iban VARCHAR(34) NOT NULL,
bic VARCHAR(11) NOT NULL DEFAULT '',
betrag DECIMAL(15,2) NOT NULL,
waehrung CHAR(3) NOT NULL DEFAULT 'EUR',
verwendungszweck VARCHAR(500) NOT NULL DEFAULT '',
-- Leer für sofort, sonst Terminüberweisung.
ausfuehrung_am DATE NULL,
kategorie_id INT UNSIGNED NULL,
klasse_id INT UNSIGNED NULL,
-- entwurf: erfasst, noch nicht abgesendet
-- gesendet: von der Bank angenommen
-- fehler: abgelehnt, Meldung steht daneben
-- storniert: vom Nutzer zurückgezogen
status ENUM('entwurf','gesendet','fehler','storniert') NOT NULL DEFAULT 'entwurf',
meldung TEXT NULL,
gesendet_am DATETIME NULL,
buchung_id INT UNSIGNED NULL,
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_auftrag_status (benutzer_id, status, ausfuehrung_am),
CONSTRAINT fk_auftrag_benutzer FOREIGN KEY (benutzer_id)
REFERENCES benutzer (id) ON DELETE CASCADE,
CONSTRAINT fk_auftrag_konto FOREIGN KEY (konto_id)
REFERENCES konten (id) ON DELETE CASCADE,
CONSTRAINT fk_auftrag_zugang FOREIGN KEY (zugang_id)
REFERENCES bank_zugaenge (id) ON DELETE SET NULL,
CONSTRAINT fk_auftrag_kategorie FOREIGN KEY (kategorie_id)
REFERENCES kategorien (id) ON DELETE SET NULL,
CONSTRAINT fk_auftrag_klasse FOREIGN KEY (klasse_id)
REFERENCES klassen (id) ON DELETE SET NULL,
CONSTRAINT fk_auftrag_buchung FOREIGN KEY (buchung_id)
REFERENCES buchungen (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO einstellungen (schluessel, wert, bereich) VALUES
('termine_vorlauf_tage', '14', 'buchhaltung'),
('termine_automatisch_buchen', '0', 'buchhaltung')
ON DUPLICATE KEY UPDATE schluessel = schluessel;

View file

@ -11,6 +11,7 @@ require dirname(__DIR__) . '/vendor/autoload.php';
use Finanzmanager\Auth;
use Finanzmanager\Config;
use Finanzmanager\Controllers\AuswertungenController;
use Finanzmanager\Controllers\BankingController;
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\Controllers\BudgetController;
use Finanzmanager\Controllers\CockpitController;
@ -21,8 +22,12 @@ use Finanzmanager\Controllers\KontenController;
use Finanzmanager\Controllers\LoginController;
use Finanzmanager\Controllers\MonatsabschlussController;
use Finanzmanager\Controllers\PlanungController;
use Finanzmanager\Controllers\PlatzhalterController;
use Finanzmanager\Controllers\TermineController;
use Finanzmanager\Controllers\VertraegeController;
use Finanzmanager\Controllers\VerwaltungController;
use Finanzmanager\Controllers\VorlagenController;
use Finanzmanager\Controllers\ZahlungsverkehrController;
use Finanzmanager\Controllers\WertpapiereController;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Router;
use Finanzmanager\Services\DebugLog;
@ -155,16 +160,131 @@ $router->post('/auswertungen/gruppen/{id}',
$router->post('/auswertungen/gruppen/{id}/loeschen',
fn(array $p) => (new AuswertungenController())->gruppeLoeschen($p));
// ── Buchungsvorlagen ────────────────────────────────────────────────────────
$router->get('/vorlagen', fn() => (new VorlagenController())->index());
$router->post('/vorlagen/neu', fn() => (new VorlagenController())->anlegen());
$router->post('/vorlagen/aus-buchung', fn() => (new VorlagenController())->ausBuchung());
$router->post('/vorlagen/{id}', fn(array $p) => (new VorlagenController())->speichern($p));
$router->post('/vorlagen/{id}/buchen', fn(array $p) => (new VorlagenController())->buchen($p));
$router->post('/vorlagen/{id}/loeschen', fn(array $p) => (new VorlagenController())->loeschen($p));
// ── Termine: regelmäßige Buchungen, Kalender, Erinnerungen ──────────────────
$router->get('/termine', fn() => (new TermineController())->index());
$router->post('/termine/neu', fn() => (new TermineController())->anlegen());
$router->post('/termine/lauf', fn() => (new TermineController())->lauf());
$router->get('/termine/kalender', fn() => (new TermineController())->kalender());
$router->get('/termine/erinnerungen', fn() => (new TermineController())->erinnerungen());
$router->post('/termine/erinnerungen/neu', fn() => (new TermineController())->erinnerungAnlegen());
$router->post('/termine/erinnerungen/{id}/erledigen',
fn(array $p) => (new TermineController())->erinnerungErledigen($p));
$router->post('/termine/erinnerungen/{id}/loeschen',
fn(array $p) => (new TermineController())->erinnerungLoeschen($p));
$router->post('/termine/{id}', fn(array $p) => (new TermineController())->speichern($p));
$router->post('/termine/{id}/buchen', fn(array $p) => (new TermineController())->buchen($p));
$router->post('/termine/{id}/ueberspringen',
fn(array $p) => (new TermineController())->ueberspringen($p));
$router->post('/termine/{id}/loeschen', fn(array $p) => (new TermineController())->loeschen($p));
// ── Verträge, Adressen, Sparziele, Kredite ──────────────────────────────────
$router->get('/vertraege', fn() => (new VertraegeController())->index());
$router->post('/vertraege/neu', fn() => (new VertraegeController())->anlegen());
$router->get('/vertraege/adressen', fn() => (new VertraegeController())->adressen());
$router->post('/vertraege/adressen/neu', fn() => (new VertraegeController())->adresseAnlegen());
$router->post('/vertraege/adressen/{id}',
fn(array $p) => (new VertraegeController())->adresseSpeichern($p));
$router->post('/vertraege/adressen/{id}/loeschen',
fn(array $p) => (new VertraegeController())->adresseLoeschen($p));
$router->get('/vertraege/sparziele', fn() => (new VertraegeController())->sparziele());
$router->post('/vertraege/sparziele/neu', fn() => (new VertraegeController())->sparzielAnlegen());
$router->post('/vertraege/sparziele/{id}',
fn(array $p) => (new VertraegeController())->sparzielSpeichern($p));
$router->post('/vertraege/sparziele/{id}/loeschen',
fn(array $p) => (new VertraegeController())->sparzielLoeschen($p));
$router->get('/vertraege/kredite', fn() => (new VertraegeController())->kredite());
$router->post('/vertraege/kredite/neu', fn() => (new VertraegeController())->kreditAnlegen());
$router->post('/vertraege/kredite/{id}',
fn(array $p) => (new VertraegeController())->kreditSpeichern($p));
$router->post('/vertraege/kredite/{id}/loeschen',
fn(array $p) => (new VertraegeController())->kreditLoeschen($p));
$router->post('/vertraege/{id}', fn(array $p) => (new VertraegeController())->speichern($p));
$router->post('/vertraege/{id}/loeschen', fn(array $p) => (new VertraegeController())->loeschen($p));
// ── Zahlungsverkehr ─────────────────────────────────────────────────────────
$router->get('/zahlungsverkehr', fn() => (new ZahlungsverkehrController())->index());
$router->get('/zahlungsverkehr/neu', fn() => (new ZahlungsverkehrController())->neu());
$router->post('/zahlungsverkehr/neu', fn() => (new ZahlungsverkehrController())->anlegen());
$router->get('/zahlungsverkehr/tan', fn() => (new ZahlungsverkehrController())->tanFormular());
$router->post('/zahlungsverkehr/tan', fn() => (new ZahlungsverkehrController())->tanEinreichen());
$router->post('/zahlungsverkehr/tan/abbrechen', fn() => (new ZahlungsverkehrController())->tanAbbrechen());
$router->get('/zahlungsverkehr/{id}', fn(array $p) => (new ZahlungsverkehrController())->bearbeiten($p));
$router->post('/zahlungsverkehr/{id}', fn(array $p) => (new ZahlungsverkehrController())->speichern($p));
$router->post('/zahlungsverkehr/{id}/senden', fn(array $p) => (new ZahlungsverkehrController())->senden($p));
$router->post('/zahlungsverkehr/{id}/stornieren',
fn(array $p) => (new ZahlungsverkehrController())->stornieren($p));
$router->post('/zahlungsverkehr/{id}/loeschen', fn(array $p) => (new ZahlungsverkehrController())->loeschen($p));
// ── Wertpapiere: Bestände, Depotbuchungen, Kurse, Auswertungen ──────────────
$router->get('/wertpapiere', fn() => (new WertpapiereController())->index());
$router->get('/wertpapiere/auswertung', fn() => (new WertpapiereController())->auswertung());
$router->get('/wertpapiere/buchungen', fn() => (new WertpapiereController())->buchungen());
$router->get('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->neu());
$router->post('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->anlegen());
$router->post('/wertpapiere/buchungen/{id}/loeschen',
fn(array $p) => (new WertpapiereController())->loeschen($p));
$router->get('/wertpapiere/stammdaten', fn() => (new WertpapiereController())->stammdaten());
$router->post('/wertpapiere/stammdaten/{id}',
fn(array $p) => (new WertpapiereController())->speichern($p));
$router->post('/wertpapiere/stammdaten/{id}/kurs',
fn(array $p) => (new WertpapiereController())->kursSetzen($p));
$router->post('/wertpapiere/kurse', fn() => (new WertpapiereController())->kurseAbrufen());
// ── Online-Banking: Zugänge, Abruf, Abgleich, Regeln ────────────────────────
$router->get('/banking', fn() => (new BankingController())->index());
$router->get('/banking/neu', fn() => (new BankingController())->neu());
$router->post('/banking/neu', fn() => (new BankingController())->anlegen());
// Die festen Pfade stehen vor „/banking/{id}“, sonst schluckt das Muster auch
// „/banking/umsaetze“ und „/banking/regeln“.
$router->get('/banking/umsaetze', fn() => (new BankingController())->umsaetze());
$router->post('/banking/umsaetze/alle', fn() => (new BankingController())->alleUebernehmen());
$router->post('/banking/umsaetze/{id}/uebernehmen',
fn(array $p) => (new BankingController())->uebernehmen($p));
$router->post('/banking/umsaetze/{id}/verwerfen',
fn(array $p) => (new BankingController())->verwerfen($p));
$router->post('/banking/umsaetze/{id}/zuruecksetzen',
fn(array $p) => (new BankingController())->zuruecksetzen($p));
$router->get('/banking/regeln', fn() => (new BankingController())->regeln());
$router->post('/banking/regeln/neu', fn() => (new BankingController())->regelAnlegen());
$router->post('/banking/regeln/pruefen', fn() => (new BankingController())->regelnPruefen());
$router->post('/banking/regeln/{id}', fn(array $p) => (new BankingController())->regelSpeichern($p));
$router->post('/banking/regeln/{id}/loeschen',
fn(array $p) => (new BankingController())->regelLoeschen($p));
$router->get('/banking/tan', fn() => (new BankingController())->tanFormular());
$router->post('/banking/tan', fn() => (new BankingController())->tanEinreichen());
$router->post('/banking/tan/abbrechen', fn() => (new BankingController())->tanAbbrechen());
$router->get('/banking/{id}', fn(array $p) => (new BankingController())->bearbeiten($p));
$router->post('/banking/{id}', fn(array $p) => (new BankingController())->speichern($p));
$router->post('/banking/{id}/abrufen', fn(array $p) => (new BankingController())->abrufen($p));
$router->post('/banking/{id}/loeschen', fn(array $p) => (new BankingController())->loeschen($p));
$router->get('/verwaltung/import', fn() => (new ImportController())->index());
$router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen());
$router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren());
$router->post('/verwaltung/import/{datei}/loeschen', fn(array $p) => (new ImportController())->loeschen($p));
// Noch nicht gebaute Bereiche: sichtbar in der Navigation, mit Hinweis auf die Phase.
foreach (PlatzhalterController::pfade() as $bereich) {
$router->get('/' . $bereich, fn() => (new PlatzhalterController())->zeige($bereich));
}
try {
$router->dispatch();
} catch (\Throwable $e) {

View file

@ -247,6 +247,10 @@ form.inline { display: inline; margin: 0; }
.tab:hover { background: var(--flaeche-gedaempft); text-decoration: none; }
/* Schiebt einen Eintrag ans andere Ende der Leiste – etwa „Kacheln wählen“, das
kein Register ist, sondern ein Weg hinaus. */
.tab.rechtsbuendig { margin-left: auto; }
.tab.aktiv {
background: var(--flaeche);
border-color: var(--rand);
@ -466,6 +470,113 @@ form.inline { display: inline; margin: 0; }
color: var(--text-gedaempft);
}
.kachelfuss { margin-top: 1rem; }
/* Einnahmen und Ausgaben tragen keine Seriennummer, sondern die Bedeutungsfarben
der Anwendung – grün und rot lesen sich hier ohne Legende. */
.balken-einnahme { --farbe: var(--positiv); }
.balken-ausgabe { --farbe: var(--negativ); }
.saldofarbe { --farbe: var(--akzent); }
.diagramm-saldo {
stroke: var(--akzent);
stroke-width: 1.5;
fill: none;
}
.diagramm circle.diagramm-saldo-punkt { fill: var(--akzent); }
/* ── Ringdiagramm ──────────────────────────────────────────────────────────── */
.ringbereich {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.ringdiagramm {
flex: 0 0 auto;
width: 160px;
height: 160px;
}
/* Der schmale Rand in Flächenfarbe trennt benachbarte Segmente, ohne dass es
nach Umrandung aussieht. */
.ringdiagramm path {
fill: var(--farbe);
stroke: var(--flaeche);
stroke-width: 1.5;
}
.ringsumme {
fill: var(--text);
font-family: var(--schrift-zahl);
font-size: 13px;
font-weight: 600;
text-anchor: middle;
}
.ringtext {
fill: var(--text-gedaempft);
font-family: var(--schrift);
font-size: 10px;
}
.ringlegende {
list-style: none;
margin: 0;
padding: 0;
flex: 1 1 12rem;
min-width: 0;
font-size: .78rem;
}
.ringlegende li {
display: grid;
grid-template-columns: .65rem 1fr auto auto;
align-items: center;
gap: .45rem;
padding: .15rem 0;
}
.ringlegende .legendenbetrag {
font-family: var(--schrift-zahl);
font-size: .74rem;
}
.ringlegende .legendenanteil {
color: var(--text-gedaempft);
font-size: .72rem;
min-width: 2.4rem;
text-align: right;
}
/* ── Offene Vorgänge ───────────────────────────────────────────────────────── */
.vorgangsliste {
list-style: none;
margin: 0;
padding: 0;
font-size: .92rem;
}
.vorgangsliste li { padding: .3rem 0; border-bottom: 1px solid var(--rand); }
.vorgangsliste li:last-child { border-bottom: none; }
.vorgangszahl {
display: inline-block;
min-width: 1.6rem;
padding: .05rem .4rem;
margin-right: .4rem;
border-radius: 999px;
background: var(--akzent-schwach);
color: var(--akzent);
font-family: var(--schrift-zahl);
font-size: .8rem;
text-align: center;
}
/* ── Ein/Aus-Schalter ────────────────────────────────────────────────────────
Die Bahn mit dem laufenden Knopf, wie man ihn von Mobilgeräten kennt. Ein
@ -926,6 +1037,14 @@ table.splittabelle input.rechts { text-align: right; }
margin: .15rem 0 0;
}
/* Der Hinweis hinter einem Ankreuzfeld statt darunter – bei einer Liste
gleichartiger Schalter hielte eine eigene Zeile je Hinweis sie zu weit
auseinander. */
.feldhinweis.inline {
margin: 0;
font-weight: 400;
}
.optional { font-weight: 400; color: var(--text-gedaempft); }
.knopfleiste {
@ -1241,6 +1360,252 @@ table.bericht .wertlink:hover {
.zeilenform input.schmal { width: 5rem; }
/* ── Online-Banking ──────────────────────────────────────────────────────────
Die Abgleichliste ist keine Tabelle: Zu jedem Umsatz gehören mehrere Wege
(verbinden, neu buchen, verwerfen), jeder mit eigenem Formular. In Zellen
gepresst wäre das unlesbar; hier bekommt jeder Umsatz eine eigene Karte. */
.umsatz {
background: var(--flaeche);
border: 1px solid var(--rand);
border-radius: var(--radius);
padding: .75rem .9rem;
margin-bottom: .6rem;
}
.umsatzkopf {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.umsatztext { min-width: 0; }
.umsatztext .nebentext { display: block; font-size: .8rem; }
.verwendungszweck {
margin: .3rem 0 0;
font-size: .88rem;
color: var(--text-gedaempft);
overflow-wrap: anywhere;
}
.umsatzbetrag {
font-family: var(--schrift-zahl);
font-size: 1.05rem;
font-weight: 600;
white-space: nowrap;
}
.umsatzaktion {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: .4rem .6rem;
margin-top: .5rem;
padding-top: .5rem;
border-top: 1px dashed var(--rand);
}
.umsatzaktion select { max-width: 16rem; }
.vorschlagsmarke {
background: var(--akzent-schwach);
color: var(--akzent);
font-size: .75rem;
font-weight: 600;
padding: .1rem .5rem;
border-radius: 999px;
white-space: nowrap;
}
/* Statusmarken der Abrufliste. Sie erben von .marke und färben nur den Rand –
ein voll gefüllter Punkt zöge in einer Tabelle zu viel Aufmerksamkeit. */
.marke-erfolg { color: var(--positiv); border-color: var(--positiv); }
.marke-fehler { color: var(--negativ); border-color: var(--negativ); }
.marke-tan_offen { color: var(--warnung); border-color: var(--warnung); }
.marke-abgebrochen { color: var(--text-gedaempft); }
.tanhinweis {
background: var(--flaeche-gedaempft);
border-left: 3px solid var(--akzent);
padding: .5rem .7rem;
margin: .5rem 0;
font-size: .93rem;
}
/* photoTAN-Grafiken liefern die Banken klein; unskaliert wären sie kaum zu
scannen, größer als die Kachel dürfen sie aber auch nicht werden. */
.tanbild {
display: block;
max-width: 100%;
width: 240px;
height: auto;
margin: .75rem 0;
image-rendering: pixelated;
background: #fff;
padding: .5rem;
border-radius: var(--radius);
}
.protokolltext {
margin: 0;
padding: .6rem .7rem;
background: var(--flaeche-gedaempft);
border-radius: var(--radius);
font-family: var(--schrift-zahl);
font-size: .78rem;
white-space: pre-wrap;
overflow-x: auto;
}
.feldpaar {
display: flex;
gap: .5rem;
}
.regeltabelle select,
.regeltabelle input[type="text"] { max-width: 12rem; }
/* ── Wertpapiere ─────────────────────────────────────────────────────────── */
/* Die Kennzahlen über der Depotübersicht: Wert, Einstand, Gewinn, Erträge –
nebeneinander, aber umbrechend, damit sie auch auf schmalen Fenstern lesbar
bleiben. */
.kennzahlenleiste {
display: flex;
flex-wrap: wrap;
gap: .75rem;
margin-bottom: 1.25rem;
}
.kennzahlkachel {
flex: 1 1 9rem;
background: var(--flaeche);
border: 1px solid var(--rand);
border-radius: var(--radius);
padding: .6rem .8rem;
display: flex;
flex-direction: column;
gap: .1rem;
}
.kennzahltitel {
font-size: .78rem;
color: var(--text-gedaempft);
}
.kennzahlkachel .kennzahl {
font-size: 1.25rem;
margin: 0;
}
/* Zahlenlastige Tabellen bekommen Ziffernschrift in den rechtsbündigen
Spalten – so stehen Kommastellen untereinander. */
table.liste.zahlen td.rechts { font-family: var(--schrift-zahl); }
.wertflaeche {
fill: var(--akzent);
opacity: .18;
stroke: none;
}
.wertlinie {
fill: none;
stroke: var(--akzent);
stroke-width: 2;
}
/* Der Einstand als gestrichelte Linie: Er ist die Vergleichsgröße, nicht der
Hauptverlauf. */
.einstandslinie {
fill: none;
stroke: var(--text-gedaempft);
stroke-width: 1.5;
stroke-dasharray: 4 3;
}
.diagramm circle.wertpunkt { fill: var(--akzent); }
.wertfarbe { --farbe: var(--akzent); }
.einstandsfarbe { --farbe: var(--text-gedaempft); }
/* ── Termine, Verträge, Sparziele ────────────────────────────────────────── */
/* Was überfällig ist, wird nicht rot eingefärbt, sondern bekommt eine Kante:
Eine ganze Zeile in Signalfarbe liest sich schlechter, nicht dringender. */
table.liste tr.ueberfaellig td:first-child {
border-left: 3px solid var(--negativ);
padding-left: calc(.6rem - 3px);
}
.kalendermonat { margin-bottom: 1.5rem; }
.kalendermonat h3 {
margin: 0 0 .4rem;
font-size: .95rem;
color: var(--text-gedaempft);
}
.kalendertag {
width: 3.5rem;
white-space: nowrap;
}
.tagzahl {
font-family: var(--schrift-zahl);
font-size: 1.1rem;
font-weight: 600;
margin-right: .3rem;
}
table.liste tr.luecke td {
text-align: center;
font-style: italic;
background: var(--flaeche-gedaempft);
}
/* <progress> statt eines eigenen Balkens: Die Breite käme sonst nur über ein
style-Attribut zustande, und das verbietet die Content-Security-Policy. */
.fortschritt {
display: block;
width: 100%;
height: .75rem;
margin: .5rem 0 .3rem;
border: none;
border-radius: 999px;
background: var(--flaeche-gedaempft);
appearance: none;
}
.fortschritt::-webkit-progress-bar {
background: var(--flaeche-gedaempft);
border-radius: 999px;
}
.fortschritt::-webkit-progress-value {
background: var(--positiv);
border-radius: 999px;
}
.fortschritt::-moz-progress-bar {
background: var(--positiv);
border-radius: 999px;
}
.fortschrittstext {
margin: 0 0 .5rem;
font-size: .95rem;
}
.marke-entwurf { color: var(--warnung); border-color: var(--warnung); }
.marke-gesendet { color: var(--positiv); border-color: var(--positiv); }
.marke-storniert { color: var(--text-gedaempft); }
.kennzahl.klein { font-size: 1rem; }
/* ── Schmale Fenster ─────────────────────────────────────────────────────── */
@media (max-width: 820px) {

View file

@ -148,6 +148,32 @@
});
}
// Empfängerauswahl im Zahlungsverkehr: Die Adresse füllt Name, IBAN und BIC.
// Geändert werden darf danach alles – die Auswahl ist eine Abkürzung, keine
// Bindung.
const adressauswahl = document.querySelector('[data-adressauswahl]');
if (adressauswahl) {
adressauswahl.addEventListener('change', function () {
const gewaehlt = adressauswahl.selectedOptions[0];
if (!gewaehlt || !gewaehlt.value) {
return;
}
const felder = {
empfaenger: gewaehlt.dataset.name,
iban: gewaehlt.dataset.iban,
bic: gewaehlt.dataset.bic
};
Object.keys(felder).forEach(function (name) {
const feld = document.getElementById(name);
if (feld && felder[name]) {
feld.value = felder[name];
}
});
});
}
// Meldungen nach kurzer Zeit ausblenden – Fehler bleiben stehen.
// Über eine Klasse, nicht über element.style: Die Content-Security-Policy
// (style-src 'self') verwirft auch per JavaScript gesetzte Inline-Styles,

68
scripts/kursabruf.php Normal file
View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/**
* Täglicher Kursabruf für die Wertpapiere mit Bestand.
*
* Gedacht für einen Cron-Eintrag:
*
* docker compose exec -u www-data php php scripts/kursabruf.php
*
* Der Lauf bricht ab, wenn der automatische Abruf in den Einstellungen nicht
* eingeschaltet ist – so kostet ein vergessener Cron-Eintrag kein Kontingent.
* Der Knopf auf der Wertpapierseite arbeitet unabhängig davon.
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use Finanzmanager\Config;
use Finanzmanager\Mandant;
use Finanzmanager\Models\Benutzer;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Services\Kursabruf;
Config::load();
$still = in_array('--still', $argv, true);
$sage = static function (string $zeile) use ($still): void {
if (!$still) {
echo $zeile . PHP_EOL;
}
};
if (!Einstellung::getBool('wertpapiere_kursabruf_aktiv')) {
$sage('Der automatische Kursabruf ist ausgeschaltet (Einstellungen → Wertpapiere).');
exit(0);
}
$fehlerGesamt = 0;
// Über alle Benutzer, nicht nur den ersten: Das Datenmodell ist
// mandantenfähig, und ein Cron-Lauf hat keine Sitzung, aus der ein Mandant
// hervorginge.
foreach (Benutzer::alle() as $benutzer) {
Mandant::setze((int)$benutzer['id']);
$ergebnis = Kursabruf::laufen();
$sage(sprintf(
'%s: %d abgerufen, %d übersprungen, %d fehlgeschlagen.',
$benutzer['benutzername'],
$ergebnis['abgerufen'],
$ergebnis['uebersprungen'],
$ergebnis['fehler']
));
foreach ($ergebnis['protokoll'] as $zeile) {
$sage(' ' . $zeile);
}
$fehlerGesamt += $ergebnis['fehler'];
}
Mandant::setze(null);
// Ein Rückgabewert ungleich null macht den Cron-Daemon aufmerksam.
exit($fehlerGesamt > 0 ? 1 : 0);

View file

@ -17,7 +17,10 @@ class Config
'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', ''),
// Ohne APP_KEY in der Umgebung greift der beim ersten Containerstart
// erzeugte Schlüssel unter data/. Beides leer heißt: keine
// verschlüsselten Felder – Bankzugänge lassen sich dann nicht anlegen.
'app_key' => self::env('APP_KEY', '') ?: self::schluesseldatei($wurzel . '/data/schluessel/app_key'),
'debug_log' => self::env('DEBUG_LOG', '0') === '1',
'log_dir' => $wurzel . '/data/logs',
'import_dir' => $wurzel . '/data/import',
@ -41,4 +44,14 @@ class Config
$wert = $_ENV[$key] ?? getenv($key);
return ($wert === false || $wert === '') ? $default : (string)$wert;
}
/** Der vom Entrypoint erzeugte Schlüssel; fehlt die Datei, bleibt es leer. */
private static function schluesseldatei(string $pfad): string
{
if (!is_readable($pfad)) {
return '';
}
return trim((string)@file_get_contents($pfad));
}
}

View file

@ -0,0 +1,488 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\Models\Bankabruf;
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Crypto;
use Finanzmanager\Services\FinTsAbruf;
use Finanzmanager\View;
/**
* Online-Banking: Bankverbindungen verwalten, Umsätze abrufen, abgleichen und
* Regeln pflegen.
*
* Der Abruf selbst steckt in `Services\FinTsAbruf`; dieser Controller reicht nur
* durch und übersetzt das Ergebnis in Seiten – vor allem in die TAN-Maske, die
* zwischen zwei Requests stehen bleibt.
*/
class BankingController extends BaseController
{
// ── Bankverbindungen ─────────────────────────────────────────────────────
public function index(): void
{
$this->verlangeAnmeldung();
Bankabruf::raeumeAlteAuf();
$this->render('banking/index.php', [
'titel' => 'Bankverbindungen',
'zugaenge' => Bankzugang::alle(),
'abrufe' => Bankabruf::letzte(10),
'offen' => Bankumsatz::anzahlOffen(),
'schluessel' => Crypto::einsatzbereit(),
]);
}
public function neu(): void
{
$this->verlangeAnmeldung();
$this->render('banking/formular.php', [
'titel' => 'Bankverbindung anlegen',
'zugang' => null,
'konten' => [],
'eigene' => Konto::alle(),
]);
}
public function anlegen(): void
{
$this->verlangeAnmeldung();
try {
$id = Bankzugang::anlegen($this->formulardaten());
} catch (\Throwable $e) {
View::redirect('/banking/neu', $e->getMessage(), 'fehler');
return;
}
View::redirect(
'/banking/' . $id,
'Bankverbindung angelegt. Beim ersten Abruf werden die Konten der Bank ermittelt.'
);
}
/** @param array<string,string> $params */
public function bearbeiten(array $params): void
{
$this->verlangeAnmeldung();
$zugang = Bankzugang::find((int)$params['id']);
if ($zugang === null) {
View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler');
return;
}
$this->render('banking/formular.php', [
'titel' => $zugang['name'],
'zugang' => $zugang,
'konten' => Bankzugang::konten((int)$zugang['id']),
'eigene' => Konto::alle(),
'abrufe' => Bankabruf::letzte(5, (int)$zugang['id']),
]);
}
/** @param array<string,string> $params */
public function speichern(array $params): void
{
$this->verlangeAnmeldung();
$id = (int)$params['id'];
try {
Bankzugang::speichern($id, $this->formulardaten());
$this->kontenzuordnungSpeichern();
} catch (\Throwable $e) {
View::redirect('/banking/' . $id, $e->getMessage(), 'fehler');
return;
}
View::redirect('/banking/' . $id, 'Bankverbindung gespeichert.');
}
/** @param array<string,string> $params */
public function loeschen(array $params): void
{
$this->verlangeAnmeldung();
Bankzugang::loeschen((int)$params['id']);
View::redirect('/banking', 'Bankverbindung gelöscht.');
}
// ── Abruf ────────────────────────────────────────────────────────────────
/** @param array<string,string> $params */
public function abrufen(array $params): void
{
$this->verlangeAnmeldung();
$ergebnis = FinTsAbruf::starten((int)$params['id']);
$this->zeigeAbruf($ergebnis, (int)$params['id']);
}
/** Die TAN-Maske nach einem Neuladen der Seite. */
public function tanFormular(): void
{
$this->verlangeAnmeldung();
$tan = FinTsAbruf::offeneTanAnfrage();
if ($tan === null) {
View::redirect('/banking', 'Es wartet kein Abruf auf eine Freigabe.');
return;
}
$this->render('banking/tan.php', [
'titel' => 'Freigabe erforderlich',
'tan' => $tan,
'meldung' => 'Die Bank verlangt eine Freigabe.',
'protokoll' => [],
]);
}
public function tanEinreichen(): void
{
$this->verlangeAnmeldung();
$ergebnis = FinTsAbruf::tanEinreichen($this->post('tan'));
$this->zeigeAbruf($ergebnis, null);
}
public function tanAbbrechen(): void
{
$this->verlangeAnmeldung();
FinTsAbruf::abbrechen();
View::redirect('/banking', 'Abruf abgebrochen.');
}
/**
* Ein Abrufergebnis anzeigen: entweder die TAN-Maske oder zurück zur
* Übersicht mit Meldung.
*
* @param array<string,mixed> $ergebnis
*/
private function zeigeAbruf(array $ergebnis, ?int $zugangId): void
{
if ($ergebnis['status'] === 'tan') {
$this->render('banking/tan.php', [
'titel' => 'Freigabe erforderlich',
'tan' => $ergebnis['tan'] ?? [],
'meldung' => $ergebnis['meldung'],
'protokoll' => $ergebnis['protokoll'],
]);
return;
}
if ($ergebnis['status'] === 'fehler') {
View::redirect(
$zugangId === null ? '/banking' : '/banking/' . $zugangId,
'Abruf fehlgeschlagen: ' . $ergebnis['meldung'],
'fehler'
);
return;
}
View::redirect(
$ergebnis['neu'] > 0 ? '/banking/umsaetze' : '/banking',
$ergebnis['meldung']
);
}
// ── Abgleich ─────────────────────────────────────────────────────────────
public function umsaetze(): void
{
$this->verlangeAnmeldung();
$status = $this->query('status', 'offen');
if (!array_key_exists($status, Bankumsatz::STATUS)) {
$status = 'offen';
}
$regeln = AbgleichRegel::alle(true);
$umsaetze = [];
foreach (Bankumsatz::liste(['status' => $status]) as $umsatz) {
$umsatz['vorschlag'] = $status === 'offen'
? Bankumsatz::vorschlag($umsatz, $regeln)
: null;
$umsaetze[] = $umsatz;
}
$this->render('banking/umsaetze.php', [
'titel' => 'Umsätze abgleichen',
'status' => $status,
'umsaetze' => $umsaetze,
'kategorien' => Kategorie::baumFlach(null, true),
'klassen' => Klasse::alle(),
'konten' => Konto::alle(true),
'offen' => Bankumsatz::anzahlOffen(),
]);
}
/** @param array<string,string> $params */
public function uebernehmen(array $params): void
{
$this->verlangeAnmeldung();
$id = (int)$params['id'];
try {
// Eine vorgeschlagene Buchung wird verbunden statt neu angelegt –
// sonst stünde dieselbe Zahlung zweimal im Kontoblatt.
$buchungId = (int)$this->post('buchung_id');
if ($buchungId > 0) {
Bankumsatz::verbinden($id, $buchungId);
} else {
Bankumsatz::uebernehmen($id, [
'konto_id' => $this->post('konto_id') !== '' ? (int)$this->post('konto_id') : null,
'kategorie_id' => $this->post('kategorie_id') !== '' ? (int)$this->post('kategorie_id') : null,
'klasse_id' => $this->post('klasse_id') !== '' ? (int)$this->post('klasse_id') : null,
'gegenkonto_id' => $this->post('gegenkonto_id') !== '' ? (int)$this->post('gegenkonto_id') : null,
'splits' => $this->splitsAusFormular(),
]);
}
} catch (\Throwable $e) {
View::redirect('/banking/umsaetze', $e->getMessage(), 'fehler');
return;
}
View::redirect('/banking/umsaetze', 'Umsatz übernommen.');
}
/** @param array<string,string> $params */
public function verwerfen(array $params): void
{
$this->verlangeAnmeldung();
Bankumsatz::verwerfen((int)$params['id']);
View::redirect('/banking/umsaetze', 'Umsatz verworfen.');
}
/** @param array<string,string> $params */
public function zuruecksetzen(array $params): void
{
$this->verlangeAnmeldung();
Bankumsatz::zuruecksetzen((int)$params['id']);
View::redirect('/banking/umsaetze?status=offen', 'Umsatz steht wieder zum Abgleich bereit.');
}
/** Alles übernehmen, was einen eindeutigen Vorschlag hat. */
public function alleUebernehmen(): void
{
$this->verlangeAnmeldung();
$regeln = AbgleichRegel::alle(true);
$verbunden = 0;
$uebernommen = 0;
$offen = 0;
foreach (Bankumsatz::liste(['status' => 'offen']) as $umsatz) {
$vorschlag = Bankumsatz::vorschlag($umsatz, $regeln);
if ($vorschlag['art'] === 'verbinden' && $vorschlag['buchung'] !== null) {
Bankumsatz::verbinden((int)$umsatz['id'], (int)$vorschlag['buchung']['id']);
$verbunden++;
continue;
}
// Ohne Regel bliebe die Buchung ohne Kategorie – das ist Arbeit, die
// der Nutzer sehen und selbst tun soll.
if ($vorschlag['zuordnung'] === null || (int)$umsatz['konto_id'] === 0) {
$offen++;
continue;
}
Bankumsatz::uebernehmen((int)$umsatz['id'], $vorschlag['zuordnung']);
$uebernommen++;
}
View::redirect('/banking/umsaetze', sprintf(
'%d mit vorhandenen Buchungen verbunden, %d nach Regel übernommen, %d bleiben offen.',
$verbunden,
$uebernommen,
$offen
));
}
// ── Regeln ───────────────────────────────────────────────────────────────
public function regeln(): void
{
$this->verlangeAnmeldung();
$this->render('banking/regeln.php', [
'titel' => 'Regeln für den Abgleich',
'regeln' => AbgleichRegel::alle(),
'kategorien' => Kategorie::baumFlach(null, true),
'klassen' => Klasse::alle(),
'konten' => Konto::alle(true),
]);
}
public function regelAnlegen(): void
{
$this->verlangeAnmeldung();
try {
AbgleichRegel::anlegen($this->regeldaten());
} catch (\Throwable $e) {
View::redirect('/banking/regeln', $e->getMessage(), 'fehler');
return;
}
View::redirect('/banking/regeln', 'Regel angelegt.');
}
/** @param array<string,string> $params */
public function regelSpeichern(array $params): void
{
$this->verlangeAnmeldung();
try {
AbgleichRegel::speichern((int)$params['id'], $this->regeldaten());
} catch (\Throwable $e) {
View::redirect('/banking/regeln', $e->getMessage(), 'fehler');
return;
}
View::redirect('/banking/regeln', 'Regel gespeichert.');
}
/** @param array<string,string> $params */
public function regelLoeschen(array $params): void
{
$this->verlangeAnmeldung();
AbgleichRegel::loeschen((int)$params['id']);
View::redirect('/banking/regeln', 'Regel gelöscht.');
}
/**
* Wendet die Regeln auf die offenen Umsätze an, ohne sie zu übernehmen –
* damit lässt sich eine neue Regel ausprobieren, bevor sie bucht.
*/
public function regelnPruefen(): void
{
$this->verlangeAnmeldung();
$regeln = AbgleichRegel::alle(true);
$treffer = 0;
$gesamt = 0;
foreach (Bankumsatz::liste(['status' => 'offen']) as $umsatz) {
$gesamt++;
if (AbgleichRegel::passendeRegel($umsatz, $regeln) !== null) {
$treffer++;
}
}
View::redirect('/banking/regeln', sprintf(
'Von %d offenen Umsätzen treffen die Regeln auf %d.',
$gesamt,
$treffer
));
}
// ── Formulardaten ────────────────────────────────────────────────────────
/** @return array<string,mixed> */
private function formulardaten(): array
{
return [
'name' => $this->post('name'),
'bankname' => $this->post('bankname'),
'blz' => $this->post('blz'),
'fints_url' => $this->post('fints_url'),
'benutzerkennung' => $this->post('benutzerkennung'),
'pin' => $this->post('pin'),
'abrufzeitraum' => $this->post('abrufzeitraum'),
'aktiv' => isset($_POST['aktiv']) ? 1 : 0,
];
}
/** @return array<string,mixed> */
private function regeldaten(): array
{
return [
'name' => $this->post('name'),
'feld' => $this->post('feld'),
'vergleich' => $this->post('vergleich'),
'muster' => $this->post('muster'),
'betrag_von' => $this->post('betrag_von'),
'betrag_bis' => $this->post('betrag_bis'),
'kategorie_id' => $this->post('kategorie_id'),
'klasse_id' => $this->post('klasse_id'),
'gegenkonto_id' => $this->post('gegenkonto_id'),
'prioritaet' => $this->post('prioritaet', '100'),
'aktiv' => isset($_POST['aktiv']) ? 1 : 0,
];
}
/** Die Zuordnung Bankkonto → eigenes Konto aus dem Formular des Zugangs. */
private function kontenzuordnungSpeichern(): void
{
$zuordnung = $_POST['bankkonto'] ?? [];
if (!is_array($zuordnung)) {
return;
}
foreach ($zuordnung as $bankKontoId => $werte) {
if (!is_array($werte)) {
continue;
}
Bankzugang::ordneKontoZu(
(int)$bankKontoId,
($werte['konto_id'] ?? '') === '' ? null : (int)$werte['konto_id'],
!empty($werte['abrufen'])
);
}
}
/**
* Splitzeilen aus der Abgleichmaske.
*
* @return list<array<string,mixed>>
*/
private function splitsAusFormular(): array
{
$roh = $_POST['split'] ?? [];
if (!is_array($roh)) {
return [];
}
$splits = [];
foreach ($roh as $zeile) {
if (!is_array($zeile) || ($zeile['betrag'] ?? '') === '') {
continue;
}
$splits[] = [
'kategorie_id' => ($zeile['kategorie_id'] ?? '') === '' ? null : (int)$zeile['kategorie_id'],
'klasse_id' => ($zeile['klasse_id'] ?? '') === '' ? null : (int)$zeile['klasse_id'],
'betrag' => (float)str_replace(',', '.', (string)$zeile['betrag']),
'notiz' => (string)($zeile['notiz'] ?? ''),
];
}
return $splits;
}
}

View file

@ -6,6 +6,7 @@ namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Cockpit;
use Finanzmanager\Services\Vermoegensentwicklung;
use Finanzmanager\View;
@ -24,23 +25,43 @@ class CockpitController extends BaseController
$this->verlangeAnmeldung();
$nurAktive = Einstellung::getBool(self::SCHALTER_NUR_AKTIVE, true);
$sichtbar = Cockpit::sichtbar();
// Die Ebene steht in der Adresse, nicht in den Einstellungen: Wer in die
// „Übrigen Konten“ hineinsieht, will beim nächsten Aufruf wieder oben
// anfangen – und kann den Blick trotzdem als Lesezeichen ablegen.
$ebene = max(0, (int)$this->query('ebene'));
$this->render('cockpit/index.php', [
// Jede Kachel kostet Abfragen. Abgeschaltete werden deshalb gar nicht
// erst berechnet, statt nur ungezeichnet zu bleiben.
$daten = [
'titel' => 'Cockpit',
'uebersicht' => Konto::uebersicht(),
'vermoegen' => Vermoegensentwicklung::reihe(
'sichtbar' => $sichtbar,
'nurAktive' => $nurAktive,
];
if (in_array('vermoegen', $sichtbar, true)) {
$daten['vermoegen'] = Vermoegensentwicklung::reihe(
$nurAktive,
Vermoegensentwicklung::MONATE,
null,
$ebene
),
'nurAktive' => $nurAktive,
]);
);
}
if (in_array('einnahmen_ausgaben', $sichtbar, true)) {
$daten['monatsreihe'] = Cockpit::monatsreihe();
}
if (in_array('ausgaben', $sichtbar, true)) {
$daten['ausgaben'] = Cockpit::ausgaben();
}
if (in_array('kontostaende', $sichtbar, true)) {
$daten['uebersicht'] = Konto::uebersicht();
}
if (in_array('offene_vorgaenge', $sichtbar, true)) {
$daten['vorgaenge'] = Cockpit::offeneVorgaenge();
}
$this->render('cockpit/index.php', $daten);
}
public function kontenfilter(): void

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Services\Cockpit;
use Finanzmanager\Services\DebugLog;
use Finanzmanager\View;
@ -29,6 +30,7 @@ class EinstellungenController extends BaseController
'bereich' => $bereich,
'werte' => Einstellung::alle(),
'logDateien' => $this->logDateien(),
'kacheln' => Cockpit::sichtbar(),
]);
}
@ -51,6 +53,16 @@ class EinstellungenController extends BaseController
Einstellung::set($schluessel, $wert, $bereich);
}
// Die Kachelauswahl ist eine Mehrfachauswahl und passt nicht in das
// Schlüssel-Wert-Raster von felderFuer(): Sie kommt als Liste an und
// wird als Liste gespeichert.
if ($bereich === 'darstellung') {
$gewaehlt = $_POST[Cockpit::EINSTELLUNG] ?? [];
Cockpit::setzeSichtbar(
is_array($gewaehlt) ? array_map('strval', array_filter($gewaehlt, 'is_scalar')) : []
);
}
if ($bereich === 'diagnose') {
DebugLog::setAktiv(Einstellung::getBool('debug_log_aktiv'));
}
@ -85,7 +97,17 @@ class EinstellungenController extends BaseController
'allgemein' => ['mandant_name' => 'text', 'waehrung' => 'text'],
'darstellung' => ['darstellung_thema' => 'text', 'zeilen_pro_seite' => 'text'],
'buchhaltung' => ['erster_monat' => 'text'],
'banking' => [
'banking_regeln_beim_abruf' => 'bool',
'banking_abgleich_tage' => 'text',
'banking_produkt_id' => 'text',
],
'import' => ['import_qif_verzeichnis' => 'text', 'import_stichtag' => 'text'],
'wertpapiere' => [
'wertpapiere_kursdienst' => 'text',
'wertpapiere_api_key' => 'text',
'wertpapiere_kursabruf_aktiv' => 'bool',
],
'diagnose' => ['debug_log_aktiv' => 'bool'],
default => [],
};

View file

@ -8,6 +8,7 @@ use Finanzmanager\Models\Einstellung;
use Finanzmanager\Services\QifBuchungsImport;
use Finanzmanager\Services\QifParser;
use Finanzmanager\Services\QifStammdatenImport;
use Finanzmanager\Services\QifVorlagenImport;
use Finanzmanager\View;
/**
@ -31,6 +32,7 @@ class ImportController extends BaseController
'verzeichnis' => $this->verzeichnisAnzeige(),
'ergebnis' => null,
'buchungen' => null,
'vorlagen' => null,
'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'),
'bis' => date('Y-m-d'),
'datei' => '',
@ -91,9 +93,15 @@ class ImportController extends BaseController
// nicht angelegt, deshalb bleibt es dort bei der Stammdatenvorschau,
// sobald die Konten fehlen.
$buchungen = null;
$vorlagen = null;
if (!$this->post('nur_stammdaten')) {
$buchungen = (new QifBuchungsImport($parser, ['stichtag' => $stichtag, 'bis' => $bis]))
->ausfuehren($trockenlauf);
// Buchungsvorlagen stehen in derselben Datei (!Type:Memorized)
// und kommen gleich mit – ein eigener Durchgang wäre nur ein
// zweiter Knopf für dieselbe Datei.
$vorlagen = (new QifVorlagenImport($parser))->ausfuehren($trockenlauf);
}
} catch (\Throwable $e) {
View::redirect('/verwaltung/import', 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler');
@ -106,6 +114,7 @@ class ImportController extends BaseController
'verzeichnis' => $this->verzeichnisAnzeige(),
'ergebnis' => $ergebnis + ['abschnitte' => $parser->zusammenfassung()],
'buchungen' => $buchungen,
'vorlagen' => $vorlagen,
'stichtag' => $stichtag,
'bis' => $bis,
'datei' => $name,

View file

@ -1,50 +0,0 @@
<?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 = [
'zahlungsverkehr' => [
'titel' => 'Zahlungsverkehr',
'phase' => 'Phase 7',
'text' => 'Überweisungen und Terminüberweisungen, abgesendet über FinTS.',
],
'wertpapiere' => [
'titel' => 'Wertpapiere',
'phase' => 'Phase 6',
'text' => 'Depotübersicht, Bestände, Kurse und Veräußerungsgeschäfte.',
],
];
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);
}
}

View file

@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Erinnerung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\RegelmaessigeBuchung;
use Finanzmanager\Services\Faelligkeitslauf;
use Finanzmanager\Services\Finanzkalender;
use Finanzmanager\View;
/**
* Regelmäßige Buchungen, Erinnerungen und der Finanzkalender – alles, was einen
* Termin hat.
*/
class TermineController extends BaseController
{
public function index(): void
{
$this->verlangeAnmeldung();
$this->render('termine/index.php', [
'titel' => 'Regelmäßige Buchungen',
'reihen' => RegelmaessigeBuchung::alle(),
'faellig' => RegelmaessigeBuchung::faellige(),
'vorschau' => Faelligkeitslauf::vorschau(),
'konten' => Konto::alle(true),
'kategorien' => Kategorie::baumFlach(null, true),
'klassen' => Klasse::alle(),
'intervalle' => RegelmaessigeBuchung::INTERVALLE,
]);
}
public function anlegen(): void
{
$this->verlangeAnmeldung();
try {
RegelmaessigeBuchung::anlegen($this->formulardaten());
} catch (\Throwable $e) {
View::redirect('/termine', $e->getMessage(), 'fehler');
return;
}
View::redirect('/termine', 'Regelmäßige Buchung angelegt.');
}
/** @param array<string,string> $params */
public function speichern(array $params): void
{
$this->verlangeAnmeldung();
try {
RegelmaessigeBuchung::speichern((int)$params['id'], $this->formulardaten());
} catch (\Throwable $e) {
View::redirect('/termine', $e->getMessage(), 'fehler');
return;
}
View::redirect('/termine', 'Regelmäßige Buchung gespeichert.');
}
/** @param array<string,string> $params */
public function loeschen(array $params): void
{
$this->verlangeAnmeldung();
RegelmaessigeBuchung::loeschen((int)$params['id']);
View::redirect('/termine', 'Regelmäßige Buchung gelöscht.');
}
/** @param array<string,string> $params */
public function buchen(array $params): void
{
$this->verlangeAnmeldung();
try {
RegelmaessigeBuchung::buchen((int)$params['id'], [
'datum' => $this->post('datum'),
'betrag' => $this->post('betrag'),
]);
} catch (\Throwable $e) {
View::redirect('/termine', $e->getMessage(), 'fehler');
return;
}
View::redirect('/termine', 'Termin gebucht.');
}
/** @param array<string,string> $params */
public function ueberspringen(array $params): void
{
$this->verlangeAnmeldung();
RegelmaessigeBuchung::ueberspringen((int)$params['id']);
View::redirect('/termine', 'Termin übersprungen – die Reihe läuft weiter.');
}
/** Alle automatischen Reihen buchen, die fällig sind. */
public function lauf(): void
{
$this->verlangeAnmeldung();
$ergebnis = Faelligkeitslauf::ausfuehren();
$meldung = $ergebnis['gebucht'] === 0
? 'Nichts zu buchen.'
: $ergebnis['gebucht'] . ' Buchung(en) angelegt.';
if ($ergebnis['wartend'] !== []) {
$meldung .= ' ' . count($ergebnis['wartend']) . ' warten auf Bestätigung.';
}
if ($ergebnis['gesperrt'] !== []) {
$meldung .= ' Übersprungen wegen Monatsabschluss: ' . implode(', ', $ergebnis['gesperrt']) . '.';
}
View::redirect('/termine', $meldung, $ergebnis['gesperrt'] === [] ? 'erfolg' : 'hinweis');
}
// ── Kalender ─────────────────────────────────────────────────────────────
public function kalender(): void
{
$this->verlangeAnmeldung();
$tage = max(7, min(365, (int)($this->query('tage') ?: 60)));
$this->render('termine/kalender.php', [
'titel' => 'Finanzkalender',
'monate' => Finanzkalender::nachMonat($tage),
'tage' => $tage,
]);
}
// ── Erinnerungen ─────────────────────────────────────────────────────────
public function erinnerungen(): void
{
$this->verlangeAnmeldung();
$this->render('termine/erinnerungen.php', [
'titel' => 'Erinnerungen',
'erinnerungen' => Erinnerung::alle(),
]);
}
public function erinnerungAnlegen(): void
{
$this->verlangeAnmeldung();
try {
Erinnerung::anlegen($this->post('text'), $this->post('faellig_am'), $this->post('notiz'));
} catch (\Throwable $e) {
View::redirect('/termine/erinnerungen', $e->getMessage(), 'fehler');
return;
}
View::redirect('/termine/erinnerungen', 'Erinnerung angelegt.');
}
/** @param array<string,string> $params */
public function erinnerungErledigen(array $params): void
{
$this->verlangeAnmeldung();
Erinnerung::erledigen((int)$params['id'], $this->post('erledigt', '1') === '1');
View::redirect('/termine/erinnerungen');
}
/** @param array<string,string> $params */
public function erinnerungLoeschen(array $params): void
{
$this->verlangeAnmeldung();
Erinnerung::loeschen((int)$params['id']);
View::redirect('/termine/erinnerungen', 'Erinnerung gelöscht.');
}
/** @return array<string,mixed> */
private function formulardaten(): array
{
return [
'name' => $this->post('name'),
'konto_id' => $this->post('konto_id'),
'betrag' => str_replace(',', '.', $this->post('betrag')),
'empfaenger' => $this->post('empfaenger'),
'buchungstext' => $this->post('buchungstext'),
'kategorie_id' => $this->post('kategorie_id'),
'klasse_id' => $this->post('klasse_id'),
'gegenkonto_id' => $this->post('gegenkonto_id'),
'intervall' => $this->post('intervall'),
'faktor' => $this->post('faktor', '1'),
'beginn' => $this->post('beginn'),
'ende' => $this->post('ende'),
'automatisch' => isset($_POST['automatisch']) ? 1 : 0,
'vorlauf_tage' => $this->post('vorlauf_tage', '7'),
'aktiv' => isset($_POST['aktiv']) ? 1 : 0,
];
}
}

View file

@ -0,0 +1,343 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Adresse;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Kredit;
use Finanzmanager\Models\RegelmaessigeBuchung;
use Finanzmanager\Models\Sparziel;
use Finanzmanager\Models\Vertrag;
use Finanzmanager\View;
/**
* Verträge, Adressen, Sparziele und Kredite – die Stammdaten der Nebenmodule.
*
* Sie teilen sich einen Controller, weil sie sich sehr ähnlich verhalten: eine
* Liste, ein Formular, kein eigener Ablauf. Ihre Rechenlogik – Kündigungsfrist,
* Sparfortschritt, Tilgungsplan – steckt in den Modellen.
*/
class VertraegeController extends BaseController
{
// ── Verträge ─────────────────────────────────────────────────────────────
public function index(): void
{
$this->verlangeAnmeldung();
$vertraege = Vertrag::alle();
$jahr = 0.0;
foreach ($vertraege as $nummer => $vertrag) {
$vertraege[$nummer]['jahresbetrag'] = Vertrag::jahresbetrag($vertrag);
$vertraege[$nummer]['kuendigen_bis'] = Vertrag::kuendigenBis($vertrag);
if ((int)$vertrag['aktiv'] === 1) {
$jahr += $vertraege[$nummer]['jahresbetrag'];
}
}
$this->render('vertraege/index.php', [
'titel' => 'Verträge',
'vertraege' => $vertraege,
'jahressumme' => round($jahr, 2),
'fristen' => Vertrag::fristenLaufenAb(90),
'adressen' => Adresse::alle(),
'kategorien' => Kategorie::baumFlach(null, true),
'reihen' => RegelmaessigeBuchung::alle(),
'intervalle' => RegelmaessigeBuchung::INTERVALLE,
]);
}
public function anlegen(): void
{
$this->verlangeAnmeldung();
try {
Vertrag::anlegen($this->vertragsdaten());
} catch (\Throwable $e) {
View::redirect('/vertraege', $e->getMessage(), 'fehler');
return;
}
View::redirect('/vertraege', 'Vertrag angelegt.');
}
/** @param array<string,string> $params */
public function speichern(array $params): void
{
$this->verlangeAnmeldung();
try {
Vertrag::speichern((int)$params['id'], $this->vertragsdaten());
} catch (\Throwable $e) {
View::redirect('/vertraege', $e->getMessage(), 'fehler');
return;
}
View::redirect('/vertraege', 'Vertrag gespeichert.');
}
/** @param array<string,string> $params */
public function loeschen(array $params): void
{
$this->verlangeAnmeldung();
Vertrag::loeschen((int)$params['id']);
View::redirect('/vertraege', 'Vertrag gelöscht.');
}
// ── Adressen ─────────────────────────────────────────────────────────────
public function adressen(): void
{
$this->verlangeAnmeldung();
$suche = $this->query('suche');
$this->render('vertraege/adressen.php', [
'titel' => 'Adressen',
'adressen' => Adresse::alle($suche),
'suche' => $suche,
]);
}
public function adresseAnlegen(): void
{
$this->verlangeAnmeldung();
try {
Adresse::anlegen($this->adressdaten());
} catch (\Throwable $e) {
View::redirect('/vertraege/adressen', $e->getMessage(), 'fehler');
return;
}
View::redirect('/vertraege/adressen', 'Adresse angelegt.');
}
/** @param array<string,string> $params */
public function adresseSpeichern(array $params): void
{
$this->verlangeAnmeldung();
try {
Adresse::speichern((int)$params['id'], $this->adressdaten());
} catch (\Throwable $e) {
View::redirect('/vertraege/adressen', $e->getMessage(), 'fehler');
return;
}
View::redirect('/vertraege/adressen', 'Adresse gespeichert.');
}
/** @param array<string,string> $params */
public function adresseLoeschen(array $params): void
{
$this->verlangeAnmeldung();
Adresse::loeschen((int)$params['id']);
View::redirect('/vertraege/adressen', 'Adresse gelöscht.');
}
// ── Sparziele ────────────────────────────────────────────────────────────
public function sparziele(): void
{
$this->verlangeAnmeldung();
$this->render('vertraege/sparziele.php', [
'titel' => 'Sparziele',
'ziele' => Sparziel::alle(),
'konten' => Konto::alle(true),
]);
}
public function sparzielAnlegen(): void
{
$this->verlangeAnmeldung();
try {
Sparziel::anlegen($this->sparzieldaten());
} catch (\Throwable $e) {
View::redirect('/vertraege/sparziele', $e->getMessage(), 'fehler');
return;
}
View::redirect('/vertraege/sparziele', 'Sparziel angelegt.');
}
/** @param array<string,string> $params */
public function sparzielSpeichern(array $params): void
{
$this->verlangeAnmeldung();
try {
Sparziel::speichern((int)$params['id'], $this->sparzieldaten());
} catch (\Throwable $e) {
View::redirect('/vertraege/sparziele', $e->getMessage(), 'fehler');
return;
}
View::redirect('/vertraege/sparziele', 'Sparziel gespeichert.');
}
/** @param array<string,string> $params */
public function sparzielLoeschen(array $params): void
{
$this->verlangeAnmeldung();
Sparziel::loeschen((int)$params['id']);
View::redirect('/vertraege/sparziele', 'Sparziel gelöscht.');
}
// ── Kredite ──────────────────────────────────────────────────────────────
public function kredite(): void
{
$this->verlangeAnmeldung();
$id = (int)$this->query('kredit');
$kredite = Kredit::alle();
$gewaehlt = null;
foreach ($kredite as $nummer => $kredit) {
$kredite[$nummer]['restschuld'] = Kredit::restschuld($kredit);
if ($id === (int)$kredit['id']) {
$gewaehlt = $kredit;
}
}
// Ohne Auswahl den ersten Kredit zeigen – ein leerer Plan neben einer
// gefüllten Liste sieht nach Fehler aus.
$gewaehlt ??= $kredite[0] ?? null;
$this->render('vertraege/kredite.php', [
'titel' => 'Kredite',
'kredite' => $kredite,
'kredit' => $gewaehlt,
'plan' => $gewaehlt === null ? null : Kredit::tilgungsplan($gewaehlt),
'konten' => Konto::alle(true),
]);
}
public function kreditAnlegen(): void
{
$this->verlangeAnmeldung();
try {
$id = Kredit::anlegen($this->kreditdaten());
} catch (\Throwable $e) {
View::redirect('/vertraege/kredite', $e->getMessage(), 'fehler');
return;
}
View::redirect('/vertraege/kredite?kredit=' . $id, 'Kredit angelegt.');
}
/** @param array<string,string> $params */
public function kreditSpeichern(array $params): void
{
$this->verlangeAnmeldung();
$id = (int)$params['id'];
try {
Kredit::speichern($id, $this->kreditdaten());
} catch (\Throwable $e) {
View::redirect('/vertraege/kredite?kredit=' . $id, $e->getMessage(), 'fehler');
return;
}
View::redirect('/vertraege/kredite?kredit=' . $id, 'Kredit gespeichert.');
}
/** @param array<string,string> $params */
public function kreditLoeschen(array $params): void
{
$this->verlangeAnmeldung();
Kredit::loeschen((int)$params['id']);
View::redirect('/vertraege/kredite', 'Kredit gelöscht.');
}
// ── Formulardaten ────────────────────────────────────────────────────────
/** @return array<string,mixed> */
private function vertragsdaten(): array
{
return [
'name' => $this->post('name'),
'partner' => $this->post('partner'),
'adresse_id' => $this->post('adresse_id'),
'vertragsnummer' => $this->post('vertragsnummer'),
'kategorie_id' => $this->post('kategorie_id'),
'betrag' => str_replace(',', '.', $this->post('betrag')),
'intervall' => $this->post('intervall'),
'beginn' => $this->post('beginn'),
'ende' => $this->post('ende'),
'kuendigungsfrist_tage' => $this->post('kuendigungsfrist_tage', '0'),
'regelmaessige_id' => $this->post('regelmaessige_id'),
'notiz' => $this->post('notiz'),
'aktiv' => isset($_POST['aktiv']) ? 1 : 0,
];
}
/** @return array<string,mixed> */
private function adressdaten(): array
{
return [
'name' => $this->post('name'),
'zusatz' => $this->post('zusatz'),
'strasse' => $this->post('strasse'),
'plz' => $this->post('plz'),
'ort' => $this->post('ort'),
'land' => $this->post('land'),
'iban' => $this->post('iban'),
'bic' => $this->post('bic'),
'email' => $this->post('email'),
'telefon' => $this->post('telefon'),
'notiz' => $this->post('notiz'),
];
}
/** @return array<string,mixed> */
private function sparzieldaten(): array
{
return [
'name' => $this->post('name'),
'konto_id' => $this->post('konto_id'),
'zielbetrag' => str_replace(',', '.', $this->post('zielbetrag')),
'startbetrag' => str_replace(',', '.', $this->post('startbetrag')),
'zieldatum' => $this->post('zieldatum'),
'monatsrate' => str_replace(',', '.', $this->post('monatsrate')),
'notiz' => $this->post('notiz'),
'erreicht' => isset($_POST['erreicht']) ? 1 : 0,
];
}
/** @return array<string,mixed> */
private function kreditdaten(): array
{
return [
'name' => $this->post('name'),
'konto_id' => $this->post('konto_id'),
'darlehensbetrag' => str_replace(',', '.', $this->post('darlehensbetrag')),
'zinssatz' => str_replace(',', '.', $this->post('zinssatz')),
'rate' => str_replace(',', '.', $this->post('rate')),
'rhythmus' => $this->post('rhythmus'),
'beginn' => $this->post('beginn'),
'zinsbindung_bis' => $this->post('zinsbindung_bis'),
'sondertilgung' => str_replace(',', '.', $this->post('sondertilgung')),
'notiz' => $this->post('notiz'),
'aktiv' => isset($_POST['aktiv']) ? 1 : 0,
];
}
}

View file

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Buchungsvorlage;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\View;
/**
* Buchungsvorlagen: suchen, buchen, pflegen.
*/
class VorlagenController extends BaseController
{
public function index(): void
{
$this->verlangeAnmeldung();
$suche = $this->query('suche');
$this->render('vorlagen/index.php', [
'titel' => 'Buchungsvorlagen',
'vorlagen' => Buchungsvorlage::alle($suche),
'suche' => $suche,
'anzahl' => Buchungsvorlage::anzahl(),
'konten' => Konto::alle(true),
'kategorien' => Kategorie::baumFlach(null, true),
'klassen' => Klasse::alle(),
]);
}
public function anlegen(): void
{
$this->verlangeAnmeldung();
try {
Buchungsvorlage::anlegen($this->formulardaten());
} catch (\Throwable $e) {
View::redirect('/vorlagen', $e->getMessage(), 'fehler');
return;
}
View::redirect('/vorlagen', 'Vorlage angelegt.');
}
/** @param array<string,string> $params */
public function speichern(array $params): void
{
$this->verlangeAnmeldung();
try {
Buchungsvorlage::speichern((int)$params['id'], $this->formulardaten());
} catch (\Throwable $e) {
View::redirect('/vorlagen', $e->getMessage(), 'fehler');
return;
}
View::redirect('/vorlagen', 'Vorlage gespeichert.');
}
/** @param array<string,string> $params */
public function loeschen(array $params): void
{
$this->verlangeAnmeldung();
Buchungsvorlage::loeschen((int)$params['id']);
View::redirect('/vorlagen', 'Vorlage gelöscht.');
}
/** @param array<string,string> $params */
public function buchen(array $params): void
{
$this->verlangeAnmeldung();
try {
$buchungId = Buchungsvorlage::buchen((int)$params['id'], [
'datum' => $this->post('datum') ?: date('Y-m-d'),
'betrag' => $this->post('betrag'),
'konto_id' => $this->post('konto_id'),
]);
} catch (\Throwable $e) {
View::redirect('/vorlagen', $e->getMessage(), 'fehler');
return;
}
View::redirect('/buchungen/' . $buchungId, 'Buchung aus der Vorlage angelegt.');
}
/** Aus einer vorhandenen Buchung eine Vorlage machen. */
public function ausBuchung(): void
{
$this->verlangeAnmeldung();
$buchungId = (int)$this->post('buchung_id');
try {
Buchungsvorlage::ausBuchung($buchungId, $this->post('name'));
} catch (\Throwable $e) {
View::redirect('/buchungen/' . $buchungId, $e->getMessage(), 'fehler');
return;
}
View::redirect('/buchungen/' . $buchungId, 'Vorlage aus dieser Buchung angelegt.');
}
/** @return array<string,mixed> */
private function formulardaten(): array
{
return [
'name' => $this->post('name'),
'konto_id' => $this->post('konto_id'),
'betrag' => str_replace(',', '.', $this->post('betrag')),
'empfaenger' => $this->post('empfaenger'),
'buchungstext' => $this->post('buchungstext'),
'nummer' => $this->post('nummer'),
'kategorie_id' => $this->post('kategorie_id'),
'klasse_id' => $this->post('klasse_id'),
'gegenkonto_id' => $this->post('gegenkonto_id'),
];
}
}

View file

@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\Depot;
use Finanzmanager\Services\Kursabruf;
use Finanzmanager\Services\Zeitraum;
use Finanzmanager\View;
/**
* Depots: Bestände, Buchungen, Kurse und die beiden Auswertungen
* *Veräußerungsgeschäfte* und *Erträge*.
*/
class WertpapiereController extends BaseController
{
public function index(): void
{
$this->verlangeAnmeldung();
$depots = Depot::depots();
$kontoId = (int)$this->query('depot');
$kontoId = $kontoId > 0 ? $kontoId : null;
$this->render('wertpapiere/index.php', [
'titel' => 'Wertpapiere',
'depots' => $depots,
'depot' => $kontoId,
'bestand' => Depot::bestand($kontoId),
'entwicklung' => Depot::entwicklung($kontoId),
'kursabruf' => Einstellung::get('wertpapiere_api_key', '') !== '',
]);
}
public function buchungen(): void
{
$this->verlangeAnmeldung();
$filter = [
'konto_id' => (int)$this->query('depot') ?: null,
'wertpapier_id' => (int)$this->query('wertpapier') ?: null,
'vorgang' => $this->query('vorgang'),
];
$this->render('wertpapiere/buchungen.php', [
'titel' => 'Depotbuchungen',
'buchungen' => Depotbuchung::liste(array_filter($filter)),
'depots' => Depot::depots(),
'wertpapiere' => Wertpapier::alle(),
'konten' => Konto::alle(true),
'filter' => $filter,
]);
}
public function neu(): void
{
$this->verlangeAnmeldung();
$this->render('wertpapiere/formular.php', [
'titel' => 'Depotbuchung erfassen',
'depots' => Depot::depots(true),
'wertpapiere' => Wertpapier::alle(true),
'konten' => Konto::alle(true),
]);
}
public function anlegen(): void
{
$this->verlangeAnmeldung();
try {
$wertpapierId = (int)$this->post('wertpapier_id');
// Ein neues Papier lässt sich im selben Formular anlegen – sonst
// müsste man für jeden Erstkauf erst in die Stammdaten wechseln.
if ($wertpapierId === 0) {
$wertpapierId = Wertpapier::findenOderAnlegen([
'name' => $this->post('wertpapier_name'),
'isin' => $this->post('isin'),
'wkn' => $this->post('wkn'),
'art' => $this->post('art', 'aktie'),
'waehrung' => $this->post('waehrung', 'EUR'),
]);
}
$stueck = $this->zahl($this->post('stueck'));
$kurs = $this->zahl($this->post('kurs'));
$betrag = $this->zahl($this->post('betrag'));
// Zwei von drei Angaben genügen: Der dritte Wert ergibt sich.
if ($betrag <= 0.0 && $stueck > 0.0 && $kurs > 0.0) {
$betrag = round($stueck * $kurs, 2);
}
Depotbuchung::anlegen([
'konto_id' => (int)$this->post('konto_id'),
'wertpapier_id' => $wertpapierId,
'datum' => $this->post('datum'),
'valuta' => $this->post('valuta'),
'vorgang' => $this->post('vorgang', 'kauf'),
'stueck' => $stueck,
'kurs' => $kurs > 0.0 ? $kurs : null,
'betrag' => $betrag,
'gebuehren' => $this->zahl($this->post('gebuehren')),
'steuern' => $this->zahl($this->post('steuern')),
'verrechnungskonto_id' => $this->post('verrechnungskonto_id'),
'notiz' => $this->post('notiz'),
'wertpapiername' => $this->post('wertpapier_name') ?: null,
]);
} catch (\Throwable $e) {
View::redirect('/wertpapiere/buchungen/neu', $e->getMessage(), 'fehler');
return;
}
View::redirect('/wertpapiere/buchungen', 'Depotbuchung erfasst.');
}
/** @param array<string,string> $params */
public function loeschen(array $params): void
{
$this->verlangeAnmeldung();
Depotbuchung::loeschen((int)$params['id']);
View::redirect('/wertpapiere/buchungen', 'Depotbuchung gelöscht – samt ihrer Geldbuchung.');
}
// ── Stammdaten und Kurse ─────────────────────────────────────────────────
public function stammdaten(): void
{
$this->verlangeAnmeldung();
$kurse = Wertpapier::letzteKurse();
$liste = [];
foreach (Wertpapier::alle() as $wertpapier) {
$wertpapier['kurs'] = $kurse[(int)$wertpapier['id']]['kurs'] ?? null;
$wertpapier['kurs_datum'] = $kurse[(int)$wertpapier['id']]['datum'] ?? null;
$liste[] = $wertpapier;
}
$this->render('wertpapiere/stammdaten.php', [
'titel' => 'Wertpapiere verwalten',
'wertpapiere' => $liste,
'anbieter' => Einstellung::get('wertpapiere_kursdienst', 'leeway'),
'schluessel' => Einstellung::get('wertpapiere_api_key', '') !== '',
]);
}
/** @param array<string,string> $params */
public function speichern(array $params): void
{
$this->verlangeAnmeldung();
try {
Wertpapier::speichern((int)$params['id'], [
'name' => $this->post('name'),
'wkn' => $this->post('wkn'),
'isin' => $this->post('isin'),
'art' => $this->post('art'),
'waehrung' => $this->post('waehrung', 'EUR'),
'symbol' => $this->post('symbol'),
'aktiv' => isset($_POST['aktiv']) ? 1 : 0,
]);
} catch (\Throwable $e) {
View::redirect('/wertpapiere/stammdaten', $e->getMessage(), 'fehler');
return;
}
View::redirect('/wertpapiere/stammdaten', 'Wertpapier gespeichert.');
}
/** @param array<string,string> $params */
public function kursSetzen(array $params): void
{
$this->verlangeAnmeldung();
$kurs = $this->zahl($this->post('kurs'));
$datum = $this->post('datum') ?: date('Y-m-d');
if ($kurs <= 0.0) {
View::redirect('/wertpapiere/stammdaten', 'Der Kurs muss größer als null sein.', 'fehler');
return;
}
Wertpapier::kursSetzen((int)$params['id'], $datum, $kurs, 'manuell');
View::redirect('/wertpapiere/stammdaten', 'Kurs eingetragen.');
}
public function kurseAbrufen(): void
{
$this->verlangeAnmeldung();
$ergebnis = Kursabruf::laufen();
$meldung = sprintf(
'%d Kurse abgerufen, %d übersprungen, %d fehlgeschlagen.',
$ergebnis['abgerufen'],
$ergebnis['uebersprungen'],
$ergebnis['fehler']
);
if ($ergebnis['fehler'] > 0) {
$meldung .= ' ' . implode(' · ', array_slice($ergebnis['protokoll'], 0, 3));
}
View::redirect(
'/wertpapiere/stammdaten',
$meldung,
$ergebnis['fehler'] > 0 && $ergebnis['abgerufen'] === 0 ? 'fehler' : 'erfolg'
);
}
// ── Auswertungen ─────────────────────────────────────────────────────────
public function auswertung(): void
{
$this->verlangeAnmeldung();
$art = $this->query('art', 'veraeusserungen');
$vorgabe = $this->query('zeitraum', 'aktuelles_jahr');
$zeitraum = Zeitraum::berechne($vorgabe, $this->query('von'), $this->query('bis'));
$kontoId = (int)$this->query('depot') ?: null;
$this->render('wertpapiere/auswertung.php', [
'titel' => $art === 'ertraege' ? 'Erträge' : 'Veräußerungsgeschäfte',
'art' => $art === 'ertraege' ? 'ertraege' : 'veraeusserungen',
'zeitraum' => $zeitraum,
'depots' => Depot::depots(),
'depot' => $kontoId,
'daten' => $art === 'ertraege'
? Depot::ertraege($zeitraum['von'], $zeitraum['bis'], $kontoId)
: Depot::veraeusserungen($zeitraum['von'], $zeitraum['bis'], $kontoId),
]);
}
/** Beträge kommen mit Komma aus dem Formular. */
private function zahl(string $wert): float
{
$wert = trim(str_replace(['.', ' '], '', $wert));
return $wert === '' ? 0.0 : (float)str_replace(',', '.', $wert);
}
}

View file

@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Adresse;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Zahlungsauftrag;
use Finanzmanager\Services\FinTsUeberweisung;
use Finanzmanager\View;
/**
* Zahlungsverkehr: Überweisungen erfassen, prüfen und über FinTS absenden.
*/
class ZahlungsverkehrController extends BaseController
{
public function index(): void
{
$this->verlangeAnmeldung();
$status = $this->query('status');
$this->render('zahlungsverkehr/index.php', [
'titel' => 'Zahlungsverkehr',
'auftraege' => Zahlungsauftrag::alle($status),
'status' => $status,
'offen' => Zahlungsauftrag::anzahlOffen(),
'wartetTan' => FinTsUeberweisung::wartetAufTan(),
]);
}
public function neu(): void
{
$this->verlangeAnmeldung();
$this->render('zahlungsverkehr/formular.php', [
'titel' => 'Überweisung erfassen',
'auftrag' => null,
'konten' => Konto::alle(true),
'zugaenge' => Bankzugang::alle(true),
'adressen' => Adresse::alle(),
'kategorien' => Kategorie::baumFlach(null, true),
'klassen' => Klasse::alle(),
]);
}
public function anlegen(): void
{
$this->verlangeAnmeldung();
try {
$id = Zahlungsauftrag::anlegen($this->formulardaten());
} catch (\Throwable $e) {
View::redirect('/zahlungsverkehr/neu', $e->getMessage(), 'fehler');
return;
}
// Auf Wunsch eine neue Adresse aus dem Empfänger anlegen – so wächst
// das Adressbuch beim Arbeiten, nicht durch Pflege.
if (isset($_POST['adresse_merken'])) {
try {
Adresse::anlegen([
'name' => $this->post('empfaenger'),
'iban' => $this->post('iban'),
'bic' => $this->post('bic'),
]);
} catch (\Throwable) {
// Gibt es die Adresse schon, ist nichts zu tun.
}
}
View::redirect('/zahlungsverkehr/' . $id, 'Überweisung erfasst. Sie ist noch nicht abgesendet.');
}
/** @param array<string,string> $params */
public function bearbeiten(array $params): void
{
$this->verlangeAnmeldung();
$auftrag = Zahlungsauftrag::find((int)$params['id']);
if ($auftrag === null) {
View::redirect('/zahlungsverkehr', 'Den Zahlungsauftrag gibt es nicht.', 'fehler');
return;
}
$this->render('zahlungsverkehr/formular.php', [
'titel' => 'Überweisung an ' . $auftrag['empfaenger'],
'auftrag' => $auftrag,
'konten' => Konto::alle(true),
'zugaenge' => Bankzugang::alle(true),
'adressen' => Adresse::alle(),
'kategorien' => Kategorie::baumFlach(null, true),
'klassen' => Klasse::alle(),
]);
}
/** @param array<string,string> $params */
public function speichern(array $params): void
{
$this->verlangeAnmeldung();
$id = (int)$params['id'];
try {
Zahlungsauftrag::speichern($id, $this->formulardaten());
} catch (\Throwable $e) {
View::redirect('/zahlungsverkehr/' . $id, $e->getMessage(), 'fehler');
return;
}
View::redirect('/zahlungsverkehr/' . $id, 'Überweisung gespeichert.');
}
/** @param array<string,string> $params */
public function loeschen(array $params): void
{
$this->verlangeAnmeldung();
try {
Zahlungsauftrag::loeschen((int)$params['id']);
} catch (\Throwable $e) {
View::redirect('/zahlungsverkehr', $e->getMessage(), 'fehler');
return;
}
View::redirect('/zahlungsverkehr', 'Überweisung gelöscht.');
}
/** @param array<string,string> $params */
public function stornieren(array $params): void
{
$this->verlangeAnmeldung();
Zahlungsauftrag::stornieren((int)$params['id']);
View::redirect('/zahlungsverkehr', 'Überweisung storniert.');
}
// ── Absenden ─────────────────────────────────────────────────────────────
/** @param array<string,string> $params */
public function senden(array $params): void
{
$this->verlangeAnmeldung();
$this->zeige(FinTsUeberweisung::senden((int)$params['id']));
}
public function tanFormular(): void
{
$this->verlangeAnmeldung();
$tan = FinTsUeberweisung::offeneTanAnfrage();
if ($tan === null) {
View::redirect('/zahlungsverkehr', 'Es wartet keine Überweisung auf eine Freigabe.');
return;
}
$this->render('zahlungsverkehr/tan.php', [
'titel' => 'Überweisung freigeben',
'tan' => $tan,
'meldung' => 'Die Bank verlangt eine Freigabe.',
]);
}
public function tanEinreichen(): void
{
$this->verlangeAnmeldung();
$this->zeige(FinTsUeberweisung::tanEinreichen($this->post('tan')));
}
public function tanAbbrechen(): void
{
$this->verlangeAnmeldung();
FinTsUeberweisung::abbrechen();
View::redirect('/zahlungsverkehr', 'Vorgang abgebrochen. Der Auftrag bleibt als Entwurf stehen.');
}
/** @param array<string,mixed> $ergebnis */
private function zeige(array $ergebnis): void
{
if ($ergebnis['status'] === 'tan') {
$this->render('zahlungsverkehr/tan.php', [
'titel' => 'Überweisung freigeben',
'tan' => $ergebnis['tan'] ?? [],
'meldung' => $ergebnis['meldung'],
]);
return;
}
View::redirect(
'/zahlungsverkehr',
$ergebnis['meldung'],
$ergebnis['status'] === 'erfolg' ? 'erfolg' : 'fehler'
);
}
/** @return array<string,mixed> */
private function formulardaten(): array
{
return [
'konto_id' => $this->post('konto_id'),
'zugang_id' => $this->post('zugang_id'),
'empfaenger' => $this->post('empfaenger'),
'iban' => $this->post('iban'),
'bic' => $this->post('bic'),
'betrag' => str_replace(',', '.', $this->post('betrag')),
'waehrung' => $this->post('waehrung', 'EUR'),
'verwendungszweck' => $this->post('verwendungszweck'),
'ausfuehrung_am' => $this->post('ausfuehrung_am'),
'kategorie_id' => $this->post('kategorie_id'),
'klasse_id' => $this->post('klasse_id'),
];
}
}

View file

@ -0,0 +1,344 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Regeln, die abgerufenen Umsätzen ihre Kategorie geben.
*
* Eine Regel prüft ein Muster gegen Empfänger und/oder Verwendungszweck und
* hängt bei einem Treffer Kategorie, Klasse, Gegenkonto oder ein festes
* Split-Schema an. Die erste passende Regel gewinnt – sortiert nach Priorität,
* bei Gleichstand nach Name. Kein Sammeln über mehrere Regeln: Wenn zwei Regeln
* verschiedene Kategorien setzen wollten, wäre nicht mehr nachvollziehbar,
* warum am Ende die eine dasteht.
*/
class AbgleichRegel
{
public const FELDER = [
'beides' => 'Empfänger und Verwendungszweck',
'empfaenger' => 'Nur Empfänger',
'verwendungszweck' => 'Nur Verwendungszweck',
];
public const VERGLEICHE = [
'enthaelt' => 'enthält',
'beginnt' => 'beginnt mit',
'endet' => 'endet auf',
'regex' => 'regulärer Ausdruck',
];
/** @return list<array<string,mixed>> */
public static function alle(bool $nurAktive = false): array
{
$sql = 'SELECT r.*, k.pfad AS kategorie_pfad, kl.name AS klassenname, g.name AS gegenkontoname
FROM abgleich_regeln r
LEFT JOIN kategorien k ON k.id = r.kategorie_id
LEFT JOIN klassen kl ON kl.id = r.klasse_id
LEFT JOIN konten g ON g.id = r.gegenkonto_id
WHERE r.benutzer_id = ?';
if ($nurAktive) {
$sql .= ' AND r.aktiv = 1';
}
return Database::fetchAll($sql . ' ORDER BY r.prioritaet, r.name', [Mandant::id()]);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM abgleich_regeln WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/** @param array<string,mixed> $daten */
public static function anlegen(array $daten): int
{
$f = self::normalisiere($daten);
Database::execute(
'INSERT INTO abgleich_regeln
(benutzer_id, name, feld, vergleich, muster, betrag_von, betrag_bis,
kategorie_id, klasse_id, gegenkonto_id, splits, prioritaet, aktiv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $f['name'], $f['feld'], $f['vergleich'], $f['muster'],
$f['betrag_von'], $f['betrag_bis'], $f['kategorie_id'], $f['klasse_id'],
$f['gegenkonto_id'], $f['splits'], $f['prioritaet'], $f['aktiv'],
]
);
return Database::lastInsertId();
}
/** @param array<string,mixed> $daten */
public static function speichern(int $id, array $daten): void
{
$f = self::normalisiere($daten);
Database::execute(
'UPDATE abgleich_regeln SET name = ?, feld = ?, vergleich = ?, muster = ?,
betrag_von = ?, betrag_bis = ?, kategorie_id = ?, klasse_id = ?,
gegenkonto_id = ?, splits = ?, prioritaet = ?, aktiv = ?
WHERE id = ? AND benutzer_id = ?',
[
$f['name'], $f['feld'], $f['vergleich'], $f['muster'], $f['betrag_von'],
$f['betrag_bis'], $f['kategorie_id'], $f['klasse_id'], $f['gegenkonto_id'],
$f['splits'], $f['prioritaet'], $f['aktiv'], $id, Mandant::id(),
]
);
}
public static function loeschen(int $id): void
{
Database::execute('DELETE FROM abgleich_regeln WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
}
/**
* Die erste Regel, die auf den Umsatz passt.
*
* @param array<string,mixed> $umsatz
* @param list<array<string,mixed>>|null $regeln vorgeladene Regeln, sonst werden sie geholt
* @return array<string,mixed>|null
*/
public static function passendeRegel(array $umsatz, ?array $regeln = null): ?array
{
foreach ($regeln ?? self::alle(true) as $regel) {
if (self::passt($regel, $umsatz)) {
return $regel;
}
}
return null;
}
/**
* @param array<string,mixed> $regel
* @param array<string,mixed> $umsatz
*/
public static function passt(array $regel, array $umsatz): bool
{
$betrag = round((float)($umsatz['betrag'] ?? 0), 2);
// Betragsgrenzen vergleichen den Wert mit Vorzeichen: „von −60 bis −40“
// trifft Lastschriften zwischen 40 und 60 Euro.
if ($regel['betrag_von'] !== null && $betrag < (float)$regel['betrag_von']) {
return false;
}
if ($regel['betrag_bis'] !== null && $betrag > (float)$regel['betrag_bis']) {
return false;
}
$muster = (string)$regel['muster'];
if ($muster === '') {
return false;
}
foreach (self::felderZumVergleich($regel, $umsatz) as $text) {
if (self::trifft((string)$regel['vergleich'], $muster, $text)) {
return true;
}
}
return false;
}
/**
* Was eine Regel dem Umsatz mitgibt. Splits werden erst hier aus dem
* Prozentschema in Beträge umgerechnet – dieselbe Regel passt so auf
* Rechnungen jeder Höhe.
*
* @param array<string,mixed> $regel
* @param array<string,mixed> $umsatz
* @return array{kategorie_id: ?int, klasse_id: ?int, gegenkonto_id: ?int, splits: list<array<string,mixed>>}
*/
public static function anwenden(array $regel, array $umsatz): array
{
$betrag = round((float)($umsatz['betrag'] ?? 0), 2);
$splits = [];
$schema = json_decode((string)($regel['splits'] ?? ''), true);
if (is_array($schema) && $schema !== []) {
$summe = 0.0;
foreach ($schema as $nummer => $zeile) {
$anteil = (float)($zeile['anteil'] ?? 0);
$teil = round($betrag * $anteil, 2);
// Die letzte Zeile bekommt den Rest, damit die Summe der Splits
// auf den Cent zum Buchungsbetrag passt – bei 1/3 zu 2/3 ginge
// sonst ein Cent verloren, und die Buchung ließe sich nicht
// speichern.
if ($nummer === array_key_last($schema)) {
$teil = round($betrag - $summe, 2);
}
$summe += $teil;
$splits[] = [
'kategorie_id' => ($zeile['kategorie_id'] ?? null) ?: null,
'klasse_id' => ($zeile['klasse_id'] ?? null) ?: null,
'betrag' => $teil,
'notiz' => (string)($zeile['notiz'] ?? ''),
];
}
}
return [
'kategorie_id' => $regel['kategorie_id'] === null ? null : (int)$regel['kategorie_id'],
'klasse_id' => $regel['klasse_id'] === null ? null : (int)$regel['klasse_id'],
'gegenkonto_id' => $regel['gegenkonto_id'] === null ? null : (int)$regel['gegenkonto_id'],
'splits' => $splits,
];
}
public static function zaehleTreffer(int $id): void
{
Database::execute('UPDATE abgleich_regeln SET treffer = treffer + 1 WHERE id = ?', [$id]);
}
/**
* Prüft ein Muster, bevor es gespeichert wird. Ein ungültiger regulärer
* Ausdruck brächte sonst jeden Abruf zum Stehen.
*/
public static function musterIstGueltig(string $vergleich, string $muster): bool
{
if ($vergleich !== 'regex') {
return $muster !== '';
}
return @preg_match(self::alsRegex($muster), '') !== false;
}
/**
* @param array<string,mixed> $regel
* @param array<string,mixed> $umsatz
* @return list<string>
*/
private static function felderZumVergleich(array $regel, array $umsatz): array
{
$empfaenger = (string)($umsatz['empfaenger'] ?? '');
$zweck = (string)($umsatz['verwendungszweck'] ?? '');
return match ((string)$regel['feld']) {
'empfaenger' => [$empfaenger],
'verwendungszweck' => [$zweck],
default => [$empfaenger, $zweck],
};
}
private static function trifft(string $vergleich, string $muster, string $text): bool
{
if ($text === '') {
return false;
}
// Groß- und Kleinschreibung spielt keine Rolle: Banken schreiben denselben
// Empfänger mal in Versalien, mal gemischt.
$textKlein = mb_strtolower($text);
$musterKlein = mb_strtolower($muster);
return match ($vergleich) {
'beginnt' => str_starts_with($textKlein, $musterKlein),
'endet' => str_ends_with($textKlein, $musterKlein),
'regex' => @preg_match(self::alsRegex($muster), $text) === 1,
default => str_contains($textKlein, $musterKlein),
};
}
/** Das Muster wird als Ausdruck ohne Trenner erwartet – die setzt erst diese Methode. */
private static function alsRegex(string $muster): string
{
return '/' . str_replace('/', '\/', $muster) . '/iu';
}
/**
* @param array<string,mixed> $daten
* @return array<string,mixed>
*/
private static function normalisiere(array $daten): array
{
$name = trim((string)($daten['name'] ?? ''));
if ($name === '') {
throw new \InvalidArgumentException('Die Regel braucht einen Namen.');
}
$feld = (string)($daten['feld'] ?? 'beides');
$vergleich = (string)($daten['vergleich'] ?? 'enthaelt');
$muster = trim((string)($daten['muster'] ?? ''));
if ($muster === '') {
throw new \InvalidArgumentException('Die Regel braucht ein Muster.');
}
if (!self::musterIstGueltig($vergleich, $muster)) {
throw new \InvalidArgumentException('Das Muster ist kein gültiger regulärer Ausdruck.');
}
$grenze = static function (mixed $wert): ?float {
return ($wert === null || $wert === '') ? null : round((float)$wert, 2);
};
$id = static function (mixed $wert): ?int {
return ($wert === null || $wert === '' || (int)$wert === 0) ? null : (int)$wert;
};
$splits = $daten['splits'] ?? null;
if (is_array($splits)) {
$splits = self::splitschema($splits);
} elseif (is_string($splits) && trim($splits) !== '') {
$splits = self::splitschema((array)json_decode($splits, true));
} else {
$splits = null;
}
return [
'name' => mb_substr($name, 0, 128),
'feld' => array_key_exists($feld, self::FELDER) ? $feld : 'beides',
'vergleich' => array_key_exists($vergleich, self::VERGLEICHE) ? $vergleich : 'enthaelt',
'muster' => mb_substr($muster, 0, 255),
'betrag_von' => $grenze($daten['betrag_von'] ?? null),
'betrag_bis' => $grenze($daten['betrag_bis'] ?? null),
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
'klasse_id' => $id($daten['klasse_id'] ?? null),
'gegenkonto_id' => $id($daten['gegenkonto_id'] ?? null),
'splits' => $splits,
'prioritaet' => (int)($daten['prioritaet'] ?? 100),
'aktiv' => !empty($daten['aktiv']) ? 1 : 0,
];
}
/**
* Bringt ein Split-Schema in Form: je Zeile eine Kategorie und ein Anteil
* zwischen 0 und 1. Zeilen ohne Kategorie oder ohne Anteil fallen weg.
*
* @param array<int|string, mixed> $roh
*/
private static function splitschema(array $roh): ?string
{
$zeilen = [];
foreach ($roh as $zeile) {
if (!is_array($zeile)) {
continue;
}
$kategorie = (int)($zeile['kategorie_id'] ?? 0);
$anteil = (float)($zeile['anteil'] ?? 0);
if ($kategorie === 0 || $anteil <= 0.0) {
continue;
}
$zeilen[] = [
'kategorie_id' => $kategorie,
'klasse_id' => (int)($zeile['klasse_id'] ?? 0) ?: null,
'anteil' => round(min($anteil, 1.0), 4),
'notiz' => mb_substr(trim((string)($zeile['notiz'] ?? '')), 0, 255),
];
}
return $zeilen === [] ? null : json_encode($zeilen, JSON_UNESCAPED_UNICODE);
}
}

173
src/Models/Adresse.php Normal file
View file

@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Das Adressbuch. Es speist die Empfängerauswahl im Zahlungsverkehr und hängt
* an den Verträgen.
*/
class Adresse
{
/** @return list<array<string,mixed>> */
public static function alle(string $suche = ''): array
{
$params = [Mandant::id()];
$sql = 'SELECT * FROM adressen WHERE benutzer_id = ?';
if ($suche !== '') {
$sql .= ' AND (name LIKE ? OR ort LIKE ? OR iban LIKE ?)';
$muster = '%' . $suche . '%';
$params[] = $muster;
$params[] = $muster;
$params[] = $muster;
}
return Database::fetchAll($sql . ' ORDER BY name', $params);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM adressen WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/** @param array<string,mixed> $daten */
public static function anlegen(array $daten): int
{
$f = self::normalisiere($daten);
Database::execute(
'INSERT INTO adressen
(benutzer_id, name, zusatz, strasse, plz, ort, land, iban, bic, email, telefon, notiz)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $f['name'], $f['zusatz'], $f['strasse'], $f['plz'], $f['ort'],
$f['land'], $f['iban'], $f['bic'], $f['email'], $f['telefon'], $f['notiz'],
]
);
return Database::lastInsertId();
}
/** @param array<string,mixed> $daten */
public static function speichern(int $id, array $daten): void
{
$f = self::normalisiere($daten);
Database::execute(
'UPDATE adressen SET name = ?, zusatz = ?, strasse = ?, plz = ?, ort = ?, land = ?,
iban = ?, bic = ?, email = ?, telefon = ?, notiz = ?
WHERE id = ? AND benutzer_id = ?',
[
$f['name'], $f['zusatz'], $f['strasse'], $f['plz'], $f['ort'], $f['land'],
$f['iban'], $f['bic'], $f['email'], $f['telefon'], $f['notiz'], $id, Mandant::id(),
]
);
}
public static function loeschen(int $id): void
{
Database::execute('DELETE FROM adressen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
}
public static function anzahl(): int
{
return (int)Database::fetchValue(
'SELECT COUNT(*) FROM adressen WHERE benutzer_id = ?',
[Mandant::id()],
0
);
}
/**
* Prüft eine IBAN nach ISO 13616: Länge je Land, Prüfziffer modulo 97.
*
* Die Prüfung fängt Zahlendreher ab, bevor eine Überweisung an die Bank
* geht – die häufigste und ärgerlichste Fehlerquelle im Zahlungsverkehr.
*/
public static function ibanIstGueltig(string $iban): bool
{
$iban = strtoupper(preg_replace('/\s+/', '', $iban) ?? '');
if (!preg_match('/^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/', $iban)) {
return false;
}
$laengen = self::IBAN_LAENGEN[substr($iban, 0, 2)] ?? null;
if ($laengen !== null && strlen($iban) !== $laengen) {
return false;
}
// Die ersten vier Zeichen nach hinten, Buchstaben in Zahlen (A=10 …),
// dann modulo 97 – das Ergebnis muss 1 sein.
$umgestellt = substr($iban, 4) . substr($iban, 0, 4);
$ziffern = '';
foreach (str_split($umgestellt) as $zeichen) {
$ziffern .= ctype_alpha($zeichen) ? (string)(ord($zeichen) - 55) : $zeichen;
}
// bcmod wäre bequemer, ist aber keine Standarderweiterung: stückweise
// rechnen kommt ohne aus.
$rest = 0;
foreach (str_split($ziffern, 7) as $block) {
$rest = (int)(((string)$rest . $block) % 97);
}
return $rest === 1;
}
/** Längen der IBAN je Land – nur die im Alltag vorkommenden. */
private const IBAN_LAENGEN = [
'DE' => 22, 'AT' => 20, 'CH' => 21, 'FR' => 27, 'IT' => 27, 'ES' => 24,
'NL' => 18, 'BE' => 16, 'LU' => 20, 'DK' => 18, 'SE' => 24, 'NO' => 15,
'FI' => 18, 'PL' => 28, 'CZ' => 24, 'PT' => 25, 'GB' => 22, 'IE' => 22,
'LI' => 21, 'HU' => 28, 'GR' => 27, 'SK' => 24, 'SI' => 19, 'HR' => 21,
];
/** Formatiert eine IBAN in Vierergruppen. */
public static function ibanFormatiert(string $iban): string
{
$iban = strtoupper(preg_replace('/\s+/', '', $iban) ?? '');
return trim(chunk_split($iban, 4, ' '));
}
/**
* @param array<string,mixed> $daten
* @return array<string,mixed>
*/
private static function normalisiere(array $daten): array
{
$name = trim((string)($daten['name'] ?? ''));
if ($name === '') {
throw new \InvalidArgumentException('Die Adresse braucht einen Namen.');
}
$iban = strtoupper(str_replace(' ', '', (string)($daten['iban'] ?? '')));
if ($iban !== '' && !self::ibanIstGueltig($iban)) {
throw new \InvalidArgumentException('Die IBAN ist nicht gültig (Prüfziffer stimmt nicht).');
}
return [
'name' => mb_substr($name, 0, 255),
'zusatz' => mb_substr(trim((string)($daten['zusatz'] ?? '')), 0, 255),
'strasse' => mb_substr(trim((string)($daten['strasse'] ?? '')), 0, 255),
'plz' => mb_substr(trim((string)($daten['plz'] ?? '')), 0, 16),
'ort' => mb_substr(trim((string)($daten['ort'] ?? '')), 0, 128),
'land' => mb_substr(trim((string)($daten['land'] ?? '')), 0, 64),
'iban' => mb_substr($iban, 0, 34),
'bic' => mb_substr(strtoupper(trim((string)($daten['bic'] ?? ''))), 0, 11),
'email' => mb_substr(trim((string)($daten['email'] ?? '')), 0, 190),
'telefon' => mb_substr(trim((string)($daten['telefon'] ?? '')), 0, 64),
'notiz' => trim((string)($daten['notiz'] ?? '')),
];
}
}

104
src/Models/Bankabruf.php Normal file
View file

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Das Protokoll eines Abrufs: was lief, wie viel dabei herauskam und woran es
* gegebenenfalls scheiterte.
*
* Ohne diese Aufzeichnung ist ein fehlgeschlagener Bankabruf nicht mehr zu
* rekonstruieren – FinTS-Meldungen der Banken sind selten selbsterklärend, und
* beim nächsten Versuch sieht der Dialog schon wieder anders aus.
*/
class Bankabruf
{
public static function beginnen(int $zugangId): int
{
Database::execute(
'INSERT INTO bank_abrufe (benutzer_id, zugang_id, status) VALUES (?, ?, ?)',
[Mandant::id(), $zugangId, 'laeuft']
);
return Database::lastInsertId();
}
/** @param list<string> $protokoll */
public static function abschliessen(
int $id,
string $status,
int $neu = 0,
int $uebersprungen = 0,
string $meldung = '',
array $protokoll = []
): void {
Database::execute(
'UPDATE bank_abrufe
SET status = ?, neu = ?, uebersprungen = ?, meldung = ?, protokoll = ?,
beendet_am = CASE WHEN ? = \'tan_offen\' THEN NULL ELSE NOW() END
WHERE id = ? AND benutzer_id = ?',
[
$status,
$neu,
$uebersprungen,
mb_substr($meldung, 0, 2000),
implode("\n", $protokoll),
$status,
$id,
Mandant::id(),
]
);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM bank_abrufe WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/** @return list<array<string,mixed>> */
public static function letzte(int $anzahl = 20, ?int $zugangId = null): array
{
$params = [Mandant::id()];
$sql = 'SELECT a.*, z.name AS zugangsname
FROM bank_abrufe a
JOIN bank_zugaenge z ON z.id = a.zugang_id
WHERE a.benutzer_id = ?';
if ($zugangId !== null) {
$sql .= ' AND a.zugang_id = ?';
$params[] = $zugangId;
}
// Die Grenze steckt fest im SQL statt als Platzhalter: MariaDB nimmt in
// LIMIT keinen gebundenen Wert an, und die Zahl kommt ohnehin nie aus
// einer Eingabe.
return Database::fetchAll(
$sql . ' ORDER BY a.begonnen_am DESC LIMIT ' . max(1, min($anzahl, 100)),
$params
);
}
/**
* Abrüche, die niemand mehr fortsetzt: Ein Lauf, der auf eine TAN wartet,
* blockiert sonst für immer die Anzeige „läuft gerade“.
*/
public static function raeumeAlteAuf(int $stunden = 2): int
{
return Database::execute(
"UPDATE bank_abrufe
SET status = 'abgebrochen', beendet_am = NOW(),
meldung = CONCAT(COALESCE(meldung, ''), ' (abgebrochen: keine Fortsetzung)')
WHERE benutzer_id = ? AND status IN ('laeuft','tan_offen')
AND begonnen_am < DATE_SUB(NOW(), INTERVAL ? HOUR)",
[Mandant::id(), $stunden]
);
}
}

317
src/Models/Bankumsatz.php Normal file
View file

@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
use Finanzmanager\Models\Einstellung;
/**
* Ein abgerufener Bankumsatz, solange er noch keine Buchung ist.
*
* Der Abruf schreibt Rohdaten hierher; erst der Abgleich macht daraus eine
* Buchung im Kontoblatt. Dazwischen liegt die Vorschlagsliste: Zu jedem Umsatz
* schlägt die Anwendung vor, ihn zu **übernehmen** (neue Buchung), mit einer
* bereits erfassten Buchung zu **verbinden** oder zu **verwerfen**.
*
* Warum nicht direkt buchen: Wer seine Buchungen selbst erfasst, hat sie beim
* Abruf meist schon im Kontoblatt stehen. Ohne Zwischenschritt stünde alles
* doppelt da.
*/
class Bankumsatz
{
public const STATUS = [
'offen' => 'offen',
'uebernommen' => 'übernommen',
'verworfen' => 'verworfen',
];
/**
* Fingerabdruck eines Umsatzes bei der Bank. Er entscheidet, ob ein Umsatz
* schon einmal abgerufen wurde – Abrufzeiträume überlappen sich fast immer.
*/
public static function externId(
string $iban,
string $datum,
float $betrag,
string $zweck,
string $empfaenger = ''
): string {
return sha1(implode('|', [
strtoupper($iban),
$datum,
number_format($betrag, 2, '.', ''),
trim($zweck),
trim($empfaenger),
]));
}
public static function existiert(string $externId): bool
{
return Database::fetchValue(
'SELECT 1 FROM bank_umsaetze WHERE benutzer_id = ? AND extern_id = ?',
[Mandant::id(), $externId]
) !== null;
}
/**
* @param array<string,mixed> $daten
* @return int|null Die neue Kennung, oder null wenn der Umsatz schon da war.
*/
public static function anlegen(array $daten): ?int
{
$externId = (string)($daten['extern_id'] ?? '');
if ($externId === '' || self::existiert($externId)) {
return null;
}
Database::execute(
'INSERT INTO bank_umsaetze
(benutzer_id, zugang_id, konto_id, extern_id, buchungstag, wertstellung,
betrag, waehrung, empfaenger, verwendungszweck, buchungsart, roh)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(),
(int)$daten['zugang_id'],
($daten['konto_id'] ?? null) ?: null,
$externId,
(string)$daten['buchungstag'],
($daten['wertstellung'] ?? null) ?: null,
round((float)($daten['betrag'] ?? 0), 2),
mb_substr((string)($daten['waehrung'] ?? 'EUR'), 0, 3),
mb_substr(trim((string)($daten['empfaenger'] ?? '')), 0, 255),
(string)($daten['verwendungszweck'] ?? ''),
mb_substr(trim((string)($daten['buchungsart'] ?? '')), 0, 100),
($daten['roh'] ?? null) ?: null,
]
);
return Database::lastInsertId();
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM bank_umsaetze WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/**
* @param array<string,mixed> $filter status, zugang_id, konto_id
* @return list<array<string,mixed>>
*/
public static function liste(array $filter = []): array
{
$params = [Mandant::id()];
$sql = 'SELECT u.*, k.name AS kontoname, z.name AS zugangsname
FROM bank_umsaetze u
LEFT JOIN konten k ON k.id = u.konto_id
JOIN bank_zugaenge z ON z.id = u.zugang_id
WHERE u.benutzer_id = ?';
if (!empty($filter['status'])) {
$sql .= ' AND u.status = ?';
$params[] = (string)$filter['status'];
}
if (!empty($filter['zugang_id'])) {
$sql .= ' AND u.zugang_id = ?';
$params[] = (int)$filter['zugang_id'];
}
if (!empty($filter['konto_id'])) {
$sql .= ' AND u.konto_id = ?';
$params[] = (int)$filter['konto_id'];
}
return Database::fetchAll($sql . ' ORDER BY u.buchungstag DESC, u.id DESC', $params);
}
public static function anzahlOffen(): int
{
return (int)Database::fetchValue(
"SELECT COUNT(*) FROM bank_umsaetze WHERE benutzer_id = ? AND status = 'offen'",
[Mandant::id()],
0
);
}
/**
* Der Vorschlag zu einem Umsatz: eine passende bereits erfasste Buchung –
* oder, wenn es keine gibt, die Zuordnung aus der ersten passenden Regel.
*
* @param array<string,mixed> $umsatz
* @param list<array<string,mixed>>|null $regeln
* @return array{
* art: string,
* buchung: array<string,mixed>|null,
* regel: array<string,mixed>|null,
* zuordnung: array<string,mixed>|null
* }
*/
public static function vorschlag(array $umsatz, ?array $regeln = null): array
{
$buchung = self::passendeBuchung($umsatz);
if ($buchung !== null) {
return ['art' => 'verbinden', 'buchung' => $buchung, 'regel' => null, 'zuordnung' => null];
}
$regel = AbgleichRegel::passendeRegel($umsatz, $regeln);
if ($regel !== null) {
return [
'art' => 'uebernehmen',
'buchung' => null,
'regel' => $regel,
'zuordnung' => AbgleichRegel::anwenden($regel, $umsatz),
];
}
return ['art' => 'uebernehmen', 'buchung' => null, 'regel' => null, 'zuordnung' => null];
}
/**
* Sucht eine schon erfasste Buchung, die dieser Umsatz sein könnte: gleiches
* Konto, gleicher Betrag, Datum nah beieinander, noch mit keinem anderen
* Umsatz verbunden.
*
* Die Toleranz in Tagen steht in den Einstellungen (`banking_abgleich_tage`):
* Zwischen dem Tag, an dem man eine Zahlung erfasst, und dem Tag, an dem die
* Bank sie bucht, liegen erfahrungsgemäß ein paar Tage.
*
* @param array<string,mixed> $umsatz
* @return array<string,mixed>|null
*/
public static function passendeBuchung(array $umsatz): ?array
{
$kontoId = (int)($umsatz['konto_id'] ?? 0);
if ($kontoId === 0) {
return null;
}
$tage = max(0, Einstellung::getInt('banking_abgleich_tage', 5));
$zeile = Database::fetchOne(
'SELECT b.* FROM buchungen b
WHERE b.benutzer_id = ?
AND b.konto_id = ?
AND ABS(b.betrag - ?) < 0.005
AND ABS(DATEDIFF(b.datum, ?)) <= ?
AND NOT EXISTS (
SELECT 1 FROM bank_umsaetze u
WHERE u.buchung_id = b.id AND u.id <> ?
)
ORDER BY ABS(DATEDIFF(b.datum, ?)) LIMIT 1',
[
Mandant::id(),
$kontoId,
round((float)$umsatz['betrag'], 2),
(string)$umsatz['buchungstag'],
$tage,
(int)($umsatz['id'] ?? 0),
(string)$umsatz['buchungstag'],
]
);
return $zeile === false ? null : $zeile;
}
/**
* Macht aus dem Umsatz eine Buchung. `$zuordnung` überschreibt, was die
* Regel vorgeschlagen hat – der Nutzer hat in der Vorschlagsliste das
* letzte Wort.
*
* @param array<string,mixed> $zuordnung kategorie_id, klasse_id, gegenkonto_id, splits
*/
public static function uebernehmen(int $id, array $zuordnung = []): int
{
$umsatz = self::find($id);
if ($umsatz === null) {
throw new \RuntimeException('Den Umsatz gibt es nicht.');
}
if ($umsatz['status'] === 'uebernommen') {
throw new \RuntimeException('Dieser Umsatz wurde bereits übernommen.');
}
$kontoId = (int)($zuordnung['konto_id'] ?? $umsatz['konto_id'] ?? 0);
if ($kontoId === 0) {
throw new \RuntimeException(
'Der Umsatz hängt an keinem Konto. Zuerst das Bankkonto einem eigenen Konto zuordnen.'
);
}
$splits = $zuordnung['splits'] ?? [];
$buchungId = Buchung::anlegen(
[
'konto_id' => $kontoId,
'datum' => (string)$umsatz['buchungstag'],
'valuta' => $umsatz['wertstellung'],
'betrag' => (float)$umsatz['betrag'],
'empfaenger' => (string)$umsatz['empfaenger'],
'buchungstext' => (string)$umsatz['verwendungszweck'],
'kategorie_id' => $zuordnung['kategorie_id'] ?? null,
'klasse_id' => $zuordnung['klasse_id'] ?? null,
'gegenkonto_id' => $zuordnung['gegenkonto_id'] ?? null,
// Abgerufene Umsätze sind bei der Bank gebucht – damit gelten sie
// als abgeglichen, genau wie das C-Kennzeichen im QIF.
'abgeglichen' => 1,
'quelle' => 'fints',
'import_hash' => Buchung::hash(
$kontoId,
(string)$umsatz['buchungstag'],
(float)$umsatz['betrag'],
'',
(string)$umsatz['verwendungszweck']
),
],
is_array($splits) ? $splits : []
);
self::verbinden($id, $buchungId);
return $buchungId;
}
/** Verbindet den Umsatz mit einer bereits erfassten Buchung. */
public static function verbinden(int $id, int $buchungId): void
{
Database::execute(
"UPDATE bank_umsaetze SET status = 'uebernommen', buchung_id = ?
WHERE id = ? AND benutzer_id = ?",
[$buchungId, $id, Mandant::id()]
);
// Was von der Bank kommt, ist dort gebucht – das Kennzeichen der
// verbundenen Buchung wird nachgezogen.
Database::execute(
'UPDATE buchungen SET abgeglichen = 1 WHERE id = ? AND benutzer_id = ?',
[$buchungId, Mandant::id()]
);
}
public static function verwerfen(int $id): void
{
Database::execute(
"UPDATE bank_umsaetze SET status = 'verworfen', buchung_id = NULL
WHERE id = ? AND benutzer_id = ?",
[$id, Mandant::id()]
);
}
/** Zurück in die Vorschlagsliste – etwa wenn die Buchung doch falsch war. */
public static function zuruecksetzen(int $id): void
{
Database::execute(
"UPDATE bank_umsaetze SET status = 'offen', buchung_id = NULL
WHERE id = ? AND benutzer_id = ?",
[$id, Mandant::id()]
);
}
public static function loeschen(int $id): void
{
Database::execute('DELETE FROM bank_umsaetze WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
}
}

307
src/Models/Bankzugang.php Normal file
View file

@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
use Finanzmanager\Services\Crypto;
/**
* Ein Bankzugang: Zugangsdaten einer Bank samt der Konten, die dort geführt
* werden.
*
* Die PIN steht verschlüsselt in der Datenbank (`Services\Crypto`) und wird
* ausschließlich über `zugangsdaten()` wieder herausgereicht – wer die Zeile
* über `find()` holt, bekommt sie im Geheimtext.
*/
class Bankzugang
{
/** Vorgaben für den Abrufzeitraum, Schlüssel → Beschriftung. */
public const ZEITRAEUME = [
'letzter_abruf' => 'Seit dem letzten Abruf',
'sieben_tage' => 'Letzte 7 Tage',
'dreissig_tage' => 'Letzte 30 Tage',
'jahr' => 'Letzte 365 Tage',
];
/** @return list<array<string,mixed>> */
public static function alle(bool $nurAktive = false): array
{
$sql = 'SELECT * FROM bank_zugaenge WHERE benutzer_id = ?';
if ($nurAktive) {
$sql .= ' AND aktiv = 1';
}
return Database::fetchAll($sql . ' ORDER BY name', [Mandant::id()]);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM bank_zugaenge WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/**
* Der Zugang mit entschlüsselter PIN. Nur der Abrufdienst ruft das auf.
*
* @return array<string,mixed>|null
*/
public static function zugangsdaten(int $id): ?array
{
$zugang = self::find($id);
if ($zugang === null) {
return null;
}
$zugang['pin'] = Crypto::entschluessle((string)($zugang['pin'] ?? ''));
$zugang['tan_medium'] = Crypto::entschluessle((string)($zugang['tan_medium'] ?? ''));
return $zugang;
}
/**
* @param array<string,mixed> $daten
*/
public static function anlegen(array $daten): int
{
$felder = self::normalisiere($daten);
Database::execute(
'INSERT INTO bank_zugaenge
(benutzer_id, name, bankname, blz, fints_url, benutzerkennung, pin,
tan_verfahren_id, tan_medium, abrufzeitraum, aktiv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $felder['name'], $felder['bankname'], $felder['blz'],
$felder['fints_url'], $felder['benutzerkennung'], $felder['pin'],
$felder['tan_verfahren_id'], $felder['tan_medium'],
$felder['abrufzeitraum'], $felder['aktiv'],
]
);
return Database::lastInsertId();
}
/**
* @param array<string,mixed> $daten
*/
public static function speichern(int $id, array $daten): void
{
$bisher = self::find($id);
if ($bisher === null) {
throw new \RuntimeException('Den Bankzugang gibt es nicht.');
}
$felder = self::normalisiere($daten);
// Ein leeres PIN-Feld heißt „unverändert lassen“, nicht „PIN löschen“ –
// sonst verlöre jedes Speichern des Formulars die Zugangsdaten.
if ($felder['pin'] === null) {
$felder['pin'] = $bisher['pin'];
}
Database::execute(
'UPDATE bank_zugaenge SET name = ?, bankname = ?, blz = ?, fints_url = ?,
benutzerkennung = ?, pin = ?, tan_verfahren_id = ?, tan_medium = ?,
abrufzeitraum = ?, aktiv = ?
WHERE id = ? AND benutzer_id = ?',
[
$felder['name'], $felder['bankname'], $felder['blz'], $felder['fints_url'],
$felder['benutzerkennung'], $felder['pin'], $felder['tan_verfahren_id'],
$felder['tan_medium'], $felder['abrufzeitraum'], $felder['aktiv'],
$id, Mandant::id(),
]
);
}
public static function loeschen(int $id): void
{
Database::execute('DELETE FROM bank_zugaenge WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
}
public static function merkeAbruf(int $id): void
{
Database::execute(
'UPDATE bank_zugaenge SET zuletzt_am = NOW() WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
}
public static function merkeTanVerfahren(int $id, ?int $verfahren, string $medium = ''): void
{
Database::execute(
'UPDATE bank_zugaenge SET tan_verfahren_id = ?, tan_medium = ? WHERE id = ? AND benutzer_id = ?',
[$verfahren, Crypto::verschluessle($medium), $id, Mandant::id()]
);
}
/**
* Ab wann abgerufen wird. „Seit dem letzten Abruf“ nimmt den Tag des letzten
* Laufs – bewusst den ganzen Tag und nicht die Uhrzeit: Banken buchen
* nachträglich, und ein Umsatz, der schon da war, fällt ohnehin über seine
* Kennung heraus.
*
* @return array{0: \DateTimeImmutable, 1: string} Startdatum und Beschreibung
*/
public static function startdatum(
?string $zeitraum,
?string $zuletztAm,
?\DateTimeImmutable $heute = null
): array {
$heute = $heute ?? new \DateTimeImmutable('today');
$zeitraum = array_key_exists((string)$zeitraum, self::ZEITRAEUME) ? (string)$zeitraum : 'letzter_abruf';
return match ($zeitraum) {
'sieben_tage' => [$heute->modify('-7 days'), 'letzte 7 Tage'],
'dreissig_tage' => [$heute->modify('-30 days'), 'letzte 30 Tage'],
'jahr' => [$heute->modify('-365 days'), 'letzte 365 Tage'],
default => self::seitLetztemAbruf($heute, $zuletztAm),
};
}
/** @return array{0: \DateTimeImmutable, 1: string} */
private static function seitLetztemAbruf(\DateTimeImmutable $heute, ?string $zuletztAm): array
{
if ($zuletztAm === null || $zuletztAm === '') {
return [$heute->modify('-365 days'), 'erster Abruf – die letzten 365 Tage'];
}
$start = new \DateTimeImmutable(substr($zuletztAm, 0, 10));
return [$start, 'seit dem letzten Abruf am ' . $start->format('d.m.Y')];
}
/**
* @param array<string,mixed> $daten
* @return array<string,mixed>
*/
private static function normalisiere(array $daten): array
{
$name = trim((string)($daten['name'] ?? ''));
if ($name === '') {
throw new \InvalidArgumentException('Der Bankzugang braucht einen Namen.');
}
$url = trim((string)($daten['fints_url'] ?? ''));
if ($url !== '' && !str_starts_with($url, 'https://')) {
throw new \InvalidArgumentException('Die FinTS-Adresse muss mit https:// beginnen.');
}
// Ein leeres PIN-Feld bedeutet beim Bearbeiten „unverändert“; beim
// Anlegen wird daraus schlicht eine leere PIN.
$pin = (string)($daten['pin'] ?? '');
$pin = $pin === '' ? null : Crypto::verschluessle($pin);
$verfahren = $daten['tan_verfahren_id'] ?? null;
$verfahren = ($verfahren === null || $verfahren === '') ? null : (int)$verfahren;
$zeitraum = (string)($daten['abrufzeitraum'] ?? 'letzter_abruf');
return [
'name' => mb_substr($name, 0, 128),
'bankname' => mb_substr(trim((string)($daten['bankname'] ?? '')), 0, 128),
'blz' => preg_replace('/\D/', '', (string)($daten['blz'] ?? '')) ?? '',
'fints_url' => mb_substr($url, 0, 255),
'benutzerkennung' => mb_substr(trim((string)($daten['benutzerkennung'] ?? '')), 0, 128),
'pin' => $pin,
'tan_verfahren_id' => $verfahren,
'tan_medium' => Crypto::verschluessle(trim((string)($daten['tan_medium'] ?? ''))),
'abrufzeitraum' => array_key_exists($zeitraum, self::ZEITRAEUME) ? $zeitraum : 'letzter_abruf',
'aktiv' => !empty($daten['aktiv']) ? 1 : 0,
];
}
// ── Zugeordnete Bankkonten ───────────────────────────────────────────────
/** @return list<array<string,mixed>> */
public static function konten(int $zugangId): array
{
return Database::fetchAll(
'SELECT bk.*, k.name AS kontoname
FROM bank_konten bk
LEFT JOIN konten k ON k.id = bk.konto_id
WHERE bk.zugang_id = ?
ORDER BY bk.iban, bk.kontonummer',
[$zugangId]
);
}
/**
* Legt ein bei der Bank gefundenes Konto an oder findet es wieder. Beim
* ersten Auftauchen bleibt es ohne Zuordnung – der Nutzer entscheidet, auf
* welches eigene Konto die Umsätze laufen.
*/
public static function findeOderLegeKontoAn(
int $zugangId,
string $iban,
string $kontonummer,
string $bezeichnung = ''
): array {
$iban = strtoupper(str_replace(' ', '', $iban));
$kontonummer = trim($kontonummer);
$zeile = Database::fetchOne(
'SELECT * FROM bank_konten WHERE zugang_id = ? AND iban = ? AND kontonummer = ?',
[$zugangId, $iban, $kontonummer]
);
if ($zeile !== false) {
return $zeile;
}
// Ein Konto mit passender IBAN gibt es vielleicht schon in der eigenen
// Kontenliste – dann ist die Zuordnung offensichtlich und wird gleich
// mit angelegt.
$konto = $iban === '' ? null : Database::fetchOne(
'SELECT id FROM konten WHERE benutzer_id = ? AND iban = ? LIMIT 1',
[Mandant::id(), $iban]
);
Database::execute(
'INSERT INTO bank_konten (zugang_id, konto_id, iban, kontonummer, bezeichnung)
VALUES (?, ?, ?, ?, ?)',
[
$zugangId,
$konto === false || $konto === null ? null : (int)$konto['id'],
$iban,
mb_substr($kontonummer, 0, 34),
mb_substr($bezeichnung, 0, 128),
]
);
return Database::fetchOne('SELECT * FROM bank_konten WHERE id = ?', [Database::lastInsertId()]) ?: [];
}
public static function ordneKontoZu(int $bankKontoId, ?int $kontoId, bool $abrufen = true): void
{
Database::execute(
'UPDATE bank_konten bk
JOIN bank_zugaenge z ON z.id = bk.zugang_id
SET bk.konto_id = ?, bk.abrufen = ?
WHERE bk.id = ? AND z.benutzer_id = ?',
[$kontoId, $abrufen ? 1 : 0, $bankKontoId, Mandant::id()]
);
}
public static function merkeSaldo(int $bankKontoId, float $saldo): void
{
Database::execute(
'UPDATE bank_konten SET saldo = ?, saldo_am = NOW() WHERE id = ?',
[$saldo, $bankKontoId]
);
// Der Online-Saldo der Kontenübersicht kommt aus genau diesem Wert.
Database::execute(
'UPDATE konten k
JOIN bank_konten bk ON bk.konto_id = k.id
SET k.saldo_online = ?, k.saldo_online_am = NOW()
WHERE bk.id = ? AND k.benutzer_id = ?',
[$saldo, $bankKontoId, Mandant::id()]
);
}
}

View file

@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Buchungsvorlagen: fertige Buchungen, die nur noch Datum und gegebenenfalls
* Betrag brauchen.
*
* Sie kommen aus dem QIF (`!Type:Memorized`, im Beispielexport 579 Stück) oder
* werden aus einer vorhandenen Buchung erzeugt. Splitzeilen stehen als JSON
* dabei: Sie werden immer als Ganzes gebraucht, und keine Auswertung greift
* darauf zu.
*/
class Buchungsvorlage
{
/** @return list<array<string,mixed>> */
public static function alle(string $suche = ''): array
{
$params = [Mandant::id()];
$sql = 'SELECT v.*, k.name AS kontoname, kat.pfad AS kategorie_pfad
FROM buchungsvorlagen v
LEFT JOIN konten k ON k.id = v.konto_id
LEFT JOIN kategorien kat ON kat.id = v.kategorie_id
WHERE v.benutzer_id = ?';
if ($suche !== '') {
$sql .= ' AND (v.name LIKE ? OR v.empfaenger LIKE ?)';
$params[] = '%' . $suche . '%';
$params[] = '%' . $suche . '%';
}
// Häufig genutzte zuerst: Wer eine Vorlage sucht, sucht meist eine,
// die er schon oft gebraucht hat.
return Database::fetchAll($sql . ' ORDER BY v.verwendet DESC, v.name LIMIT 500', $params);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM buchungsvorlagen WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
public static function findeNachName(string $name): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM buchungsvorlagen WHERE name = ? AND benutzer_id = ?',
[mb_substr(trim($name), 0, 255), Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/** @param array<string,mixed> $daten */
public static function anlegen(array $daten): int
{
$f = self::normalisiere($daten);
Database::execute(
'INSERT INTO buchungsvorlagen
(benutzer_id, name, konto_id, betrag, empfaenger, buchungstext, nummer,
kategorie_id, klasse_id, gegenkonto_id, splits, quelle)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $f['name'], $f['konto_id'], $f['betrag'], $f['empfaenger'],
$f['buchungstext'], $f['nummer'], $f['kategorie_id'], $f['klasse_id'],
$f['gegenkonto_id'], $f['splits'], $f['quelle'],
]
);
return Database::lastInsertId();
}
/** @param array<string,mixed> $daten */
public static function speichern(int $id, array $daten): void
{
$f = self::normalisiere($daten);
Database::execute(
'UPDATE buchungsvorlagen SET name = ?, konto_id = ?, betrag = ?, empfaenger = ?,
buchungstext = ?, nummer = ?, kategorie_id = ?, klasse_id = ?,
gegenkonto_id = ?, splits = ?
WHERE id = ? AND benutzer_id = ?',
[
$f['name'], $f['konto_id'], $f['betrag'], $f['empfaenger'], $f['buchungstext'],
$f['nummer'], $f['kategorie_id'], $f['klasse_id'], $f['gegenkonto_id'],
$f['splits'], $id, Mandant::id(),
]
);
}
public static function loeschen(int $id): void
{
Database::execute('DELETE FROM buchungsvorlagen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
}
public static function anzahl(): int
{
return (int)Database::fetchValue(
'SELECT COUNT(*) FROM buchungsvorlagen WHERE benutzer_id = ?',
[Mandant::id()],
0
);
}
/**
* Bucht die Vorlage. Datum, Konto und Betrag lassen sich überschreiben –
* alles andere kommt aus der Vorlage.
*
* @param array<string,mixed> $abweichungen
*/
public static function buchen(int $id, array $abweichungen = []): int
{
$vorlage = self::find($id);
if ($vorlage === null) {
throw new \RuntimeException('Die Buchungsvorlage gibt es nicht.');
}
$kontoId = (int)($abweichungen['konto_id'] ?? $vorlage['konto_id']);
if ($kontoId === 0) {
throw new \RuntimeException('Die Vorlage hat kein Konto – bitte eines wählen.');
}
$betrag = array_key_exists('betrag', $abweichungen) && $abweichungen['betrag'] !== ''
? round((float)$abweichungen['betrag'], 2)
: (float)$vorlage['betrag'];
$splits = self::splitsFuerBetrag($vorlage, $betrag);
$buchungId = Buchung::anlegen(
[
'konto_id' => $kontoId,
'datum' => (string)($abweichungen['datum'] ?? date('Y-m-d')),
'betrag' => $betrag,
'empfaenger' => (string)$vorlage['empfaenger'],
'buchungstext' => (string)$vorlage['buchungstext'],
'nummer' => (string)$vorlage['nummer'],
'kategorie_id' => $splits === [] ? $vorlage['kategorie_id'] : null,
'klasse_id' => $vorlage['klasse_id'],
'gegenkonto_id' => $splits === [] ? $vorlage['gegenkonto_id'] : null,
],
$splits
);
Database::execute(
'UPDATE buchungsvorlagen SET verwendet = verwendet + 1 WHERE id = ?',
[$id]
);
return $buchungId;
}
/**
* Die Splitzeilen der Vorlage, auf den tatsächlichen Betrag umgerechnet.
*
* Weicht der Betrag von dem der Vorlage ab, werden die Zeilen im gleichen
* Verhältnis skaliert – sonst ließe sich die Buchung nicht speichern, weil
* die Splitsumme nicht mehr passt. Die letzte Zeile bekommt den Rest.
*
* @param array<string,mixed> $vorlage
* @return list<array<string,mixed>>
*/
public static function splitsFuerBetrag(array $vorlage, float $betrag): array
{
$roh = json_decode((string)($vorlage['splits'] ?? ''), true);
if (!is_array($roh) || $roh === []) {
return [];
}
$vorlagenbetrag = round((float)$vorlage['betrag'], 2);
$faktor = abs($vorlagenbetrag) > 0.005 ? $betrag / $vorlagenbetrag : 1.0;
$splits = [];
$summe = 0.0;
$letzte = array_key_last($roh);
foreach ($roh as $nummer => $zeile) {
$teil = round((float)($zeile['betrag'] ?? 0) * $faktor, 2);
if ($nummer === $letzte) {
$teil = round($betrag - $summe, 2);
}
$summe += $teil;
$splits[] = [
'kategorie_id' => ($zeile['kategorie_id'] ?? null) ?: null,
'klasse_id' => ($zeile['klasse_id'] ?? null) ?: null,
'gegenkonto_id' => ($zeile['gegenkonto_id'] ?? null) ?: null,
'betrag' => $teil,
'notiz' => (string)($zeile['notiz'] ?? ''),
];
}
return $splits;
}
/** Macht aus einer vorhandenen Buchung eine Vorlage. */
public static function ausBuchung(int $buchungId, string $name): int
{
$buchung = Buchung::find($buchungId);
if ($buchung === null) {
throw new \RuntimeException('Die Buchung gibt es nicht.');
}
$splits = [];
foreach (Buchung::splits($buchungId) as $split) {
$splits[] = [
'kategorie_id' => $split['kategorie_id'],
'klasse_id' => $split['klasse_id'],
'gegenkonto_id' => $split['gegenkonto_id'],
'betrag' => (float)$split['betrag'],
'notiz' => (string)$split['notiz'],
];
}
return self::anlegen([
'name' => $name,
'konto_id' => $buchung['konto_id'],
'betrag' => $buchung['betrag'],
'empfaenger' => $buchung['empfaenger'],
'buchungstext' => $buchung['buchungstext'],
'nummer' => $buchung['nummer'],
'kategorie_id' => $buchung['kategorie_id'],
'klasse_id' => $buchung['klasse_id'],
'gegenkonto_id' => $buchung['gegenkonto_id'],
'splits' => $splits,
]);
}
/**
* @param array<string,mixed> $daten
* @return array<string,mixed>
*/
private static function normalisiere(array $daten): array
{
$name = trim((string)($daten['name'] ?? ''));
if ($name === '') {
$name = trim((string)($daten['empfaenger'] ?? ''));
}
if ($name === '') {
throw new \InvalidArgumentException('Die Vorlage braucht einen Namen.');
}
$id = static fn(mixed $wert): ?int =>
($wert === null || $wert === '' || (int)$wert === 0) ? null : (int)$wert;
$splits = $daten['splits'] ?? null;
if (is_array($splits) && $splits !== []) {
$splits = json_encode(array_values($splits), JSON_UNESCAPED_UNICODE);
} elseif (!is_string($splits) || trim((string)$splits) === '') {
$splits = null;
}
return [
'name' => mb_substr($name, 0, 255),
'konto_id' => $id($daten['konto_id'] ?? null),
'betrag' => round((float)($daten['betrag'] ?? 0), 2),
'empfaenger' => mb_substr(trim((string)($daten['empfaenger'] ?? '')), 0, 255),
'buchungstext' => trim((string)($daten['buchungstext'] ?? '')),
'nummer' => mb_substr(trim((string)($daten['nummer'] ?? '')), 0, 32),
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
'klasse_id' => $id($daten['klasse_id'] ?? null),
'gegenkonto_id' => $id($daten['gegenkonto_id'] ?? null),
'splits' => $splits,
'quelle' => (string)($daten['quelle'] ?? 'manuell'),
];
}
}

328
src/Models/Depotbuchung.php Normal file
View file

@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Eine Depotbuchung: Kauf, Verkauf, Dividende, Ein- oder Auslieferung.
*
* **Zwei Seiten.** Die Wertpapierseite steht hier (Stück, Kurs, Gebühren), die
* Geldseite als gewöhnliche Buchung auf dem Verrechnungskonto – verbunden über
* `buchung_id`. So bleibt das Girokonto stimmig, ohne dass die Kontoblätter
* Stückzahlen führen müssten.
*
* **Vorzeichen.** `stueck` trägt die Richtung: Kauf und Einlieferung positiv,
* Verkauf und Auslieferung negativ. `betrag` ist immer der Bruttowert des
* Geschäfts ohne Vorzeichen – ob er zu- oder abfließt, sagt der Vorgang.
*
* **Was in `betrag` steht.** Der Kurswert (Stück × Kurs) beziehungsweise bei
* Dividenden der Bruttoertrag – Gebühren und Steuern stehen *daneben*, nicht
* darin. Daraus folgt der Geldfluss auf dem Verrechnungskonto:
*
* Kauf → −(Betrag + Gebühren + Steuern)
* Verkauf → +(Betrag − Gebühren − Steuern)
* Dividende → +(Betrag − Steuern)
*
* Am Export nachgerechnet: Ein Verkauf über 4 × 797,00 € mit 15,37 € Gebühren
* und 69,46 € Steuern ergibt die Gutschrift von 3.103,17 €, die im QIF im Feld
* `$` steht. Genauso beim Kauf. Diese Trennung ist nötig, damit der Einstand
* die Nebenkosten genau einmal enthält.
*/
class Depotbuchung
{
/** Vorgang → Beschriftung. */
public const VORGAENGE = [
'kauf' => 'Kauf',
'verkauf' => 'Verkauf',
'dividende' => 'Dividende',
'einlage' => 'Einlieferung',
'entnahme' => 'Auslieferung',
];
/**
* Vorgang des FinanzManagers → eigener Vorgang.
*
* `DivIn` und `DivAus` landen beide auf „dividende“: Beide führen keine
* Stückzahl und schreiben dem Verrechnungskonto einen Ertrag gut. Die
* ursprüngliche Bezeichnung bleibt in `vorgang_qif` erhalten.
*/
public const QIF_VORGAENGE = [
'Kauf' => 'kauf',
'Verkauf' => 'verkauf',
'DivIn' => 'dividende',
'DivAus' => 'dividende',
'ReinvDiv' => 'dividende',
'WP-Einlage' => 'einlage',
'WP-Entnahme' => 'entnahme',
];
/** Vorgänge, die den Bestand vergrößern beziehungsweise verkleinern. */
public const ZUGAENGE = ['kauf', 'einlage'];
public const ABGAENGE = ['verkauf', 'entnahme'];
public static function vorgangAusQif(string $vorgang): string
{
return self::QIF_VORGAENGE[trim($vorgang)] ?? 'kauf';
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM depot_buchungen WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/**
* @param array<string,mixed> $filter konto_id, wertpapier_id, von, bis, vorgang
* @return list<array<string,mixed>>
*/
public static function liste(array $filter = []): array
{
$params = [Mandant::id()];
$sql = 'SELECT d.*, w.name AS wertpapier, w.isin, w.wkn, k.name AS depot,
v.name AS verrechnungskonto
FROM depot_buchungen d
JOIN wertpapiere w ON w.id = d.wertpapier_id
JOIN konten k ON k.id = d.konto_id
LEFT JOIN konten v ON v.id = d.verrechnungskonto_id
WHERE d.benutzer_id = ?';
foreach ([
'konto_id' => 'd.konto_id = ?',
'wertpapier_id' => 'd.wertpapier_id = ?',
'vorgang' => 'd.vorgang = ?',
] as $feld => $bedingung) {
if (!empty($filter[$feld])) {
$sql .= ' AND ' . $bedingung;
$params[] = $filter[$feld];
}
}
if (!empty($filter['von'])) {
$sql .= ' AND d.datum >= ?';
$params[] = $filter['von'];
}
if (!empty($filter['bis'])) {
$sql .= ' AND d.datum <= ?';
$params[] = $filter['bis'];
}
return Database::fetchAll($sql . ' ORDER BY d.datum DESC, d.id DESC', $params);
}
/**
* Legt eine Depotbuchung an und – wenn ein Verrechnungskonto angegeben ist –
* die zugehörige Geldbuchung gleich mit.
*
* @param array<string,mixed> $daten
*/
public static function anlegen(array $daten): int
{
$f = self::normalisiere($daten);
return (int)Database::transaktion(static function () use ($f, $daten): int {
$buchungId = null;
if ($f['verrechnungskonto_id'] !== null && abs($f['geldbetrag']) > 0.005) {
$buchungId = Buchung::anlegen([
'konto_id' => $f['verrechnungskonto_id'],
'datum' => $f['datum'],
'valuta' => $f['valuta'],
'betrag' => $f['geldbetrag'],
'empfaenger' => mb_substr((string)($daten['wertpapiername'] ?? 'Wertpapiergeschäft'), 0, 255),
'buchungstext' => (string)$f['notiz'],
'kategorie_id' => ($daten['kategorie_id'] ?? null) ?: null,
'quelle' => (string)$f['quelle'],
'abgeglichen' => !empty($daten['abgeglichen']) ? 1 : 0,
// Eigener Hash für die Geldseite: Er erkennt Buchungen
// wieder, die ein früherer Import ohne Depotmodul angelegt
// hat, und verhindert Dubletten im Kontoblatt.
'import_hash' => ($daten['buchung_hash'] ?? null) ?: null,
]);
}
Database::execute(
'INSERT INTO depot_buchungen
(benutzer_id, konto_id, wertpapier_id, datum, valuta, vorgang, vorgang_qif,
stueck, kurs, betrag, gebuehren, steuern, waehrung,
verrechnungskonto_id, buchung_id, notiz, quelle, import_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $f['konto_id'], $f['wertpapier_id'], $f['datum'], $f['valuta'],
$f['vorgang'], $f['vorgang_qif'], $f['stueck'], $f['kurs'], $f['betrag'],
$f['gebuehren'], $f['steuern'], $f['waehrung'], $f['verrechnungskonto_id'],
$buchungId, $f['notiz'], $f['quelle'], $f['import_hash'],
]
);
return Database::lastInsertId();
});
}
public static function loeschen(int $id): void
{
$buchung = self::find($id);
Database::transaktion(static function () use ($id, $buchung): void {
Database::execute(
'DELETE FROM depot_buchungen WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
// Die Geldbuchung gehört zum Geschäft und geht mit – sie stünde
// sonst ohne Zusammenhang im Kontoblatt.
if ($buchung !== null && $buchung['buchung_id'] !== null) {
Buchung::loeschen((int)$buchung['buchung_id']);
}
});
}
public static function findeNachHash(string $hash): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM depot_buchungen WHERE import_hash = ? AND benutzer_id = ?',
[$hash, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
public static function hash(
int $kontoId,
string $datum,
string $vorgang,
float $stueck,
float $betrag,
string $isin
): string {
return sha1(implode('|', [
$kontoId,
$datum,
$vorgang,
number_format($stueck, 6, '.', ''),
number_format($betrag, 2, '.', ''),
strtoupper($isin),
]));
}
public static function anzahl(): int
{
return (int)Database::fetchValue(
'SELECT COUNT(*) FROM depot_buchungen WHERE benutzer_id = ?',
[Mandant::id()],
0
);
}
/**
* @param array<string,mixed> $daten
* @return array<string,mixed>
*/
private static function normalisiere(array $daten): array
{
$kontoId = (int)($daten['konto_id'] ?? 0);
if ($kontoId === 0 || Konto::find($kontoId) === null) {
throw new \InvalidArgumentException('Die Depotbuchung braucht ein gültiges Depot.');
}
$wertpapierId = (int)($daten['wertpapier_id'] ?? 0);
if ($wertpapierId === 0) {
throw new \InvalidArgumentException('Die Depotbuchung braucht ein Wertpapier.');
}
$datum = trim((string)($daten['datum'] ?? ''));
if (!Buchung::istDatum($datum)) {
throw new \InvalidArgumentException('Die Depotbuchung braucht ein gültiges Datum (JJJJ-MM-TT).');
}
$vorgang = (string)($daten['vorgang'] ?? 'kauf');
if (!array_key_exists($vorgang, self::VORGAENGE)) {
throw new \InvalidArgumentException('Unbekannter Vorgang: ' . $vorgang);
}
$stueck = round(abs((float)($daten['stueck'] ?? 0)), 6);
$betrag = round(abs((float)($daten['betrag'] ?? 0)), 2);
$gebuehren = round(abs((float)($daten['gebuehren'] ?? 0)), 2);
$steuern = round(abs((float)($daten['steuern'] ?? 0)), 2);
// Abgänge zählen negativ, damit sich der Bestand einfach aufsummieren
// lässt. Eine Dividende bewegt keine Stücke.
$stueck = match (true) {
in_array($vorgang, self::ABGAENGE, true) => -$stueck,
$vorgang === 'dividende' => 0.0,
default => $stueck,
};
$valuta = trim((string)($daten['valuta'] ?? ''));
$verrechnung = $daten['verrechnungskonto_id'] ?? null;
$verrechnung = ($verrechnung === null || $verrechnung === '' || (int)$verrechnung === 0)
? null : (int)$verrechnung;
return [
'konto_id' => $kontoId,
'wertpapier_id' => $wertpapierId,
'datum' => $datum,
'valuta' => Buchung::istDatum($valuta) ? $valuta : null,
'vorgang' => $vorgang,
'vorgang_qif' => mb_substr(trim((string)($daten['vorgang_qif'] ?? '')), 0, 24),
'stueck' => $stueck,
'kurs' => ($daten['kurs'] ?? null) === null || $daten['kurs'] === ''
? null : round((float)$daten['kurs'], 6),
'betrag' => $betrag,
'gebuehren' => $gebuehren,
'steuern' => $steuern,
'waehrung' => mb_substr(strtoupper(trim((string)($daten['waehrung'] ?? 'EUR'))) ?: 'EUR', 0, 3),
'verrechnungskonto_id' => $verrechnung,
'notiz' => trim((string)($daten['notiz'] ?? '')),
'quelle' => isset(Buchung::QUELLEN[(string)($daten['quelle'] ?? '')])
? (string)$daten['quelle'] : 'manuell',
'import_hash' => ($daten['import_hash'] ?? null) ?: null,
// Was auf dem Verrechnungskonto passiert: Ein Kauf nimmt Geld weg,
// Verkauf und Dividende bringen welches. Ein- und Auslieferungen
// rühren kein Konto an.
'geldbetrag' => self::geldbetrag(
$vorgang,
$betrag,
$gebuehren,
$steuern,
(float)($daten['geldbetrag'] ?? 0)
),
];
}
/**
* Der Betrag auf dem Verrechnungskonto.
*
* Steht im QIF ein eigener Wert (Feld `$`), gilt der: Er ist das, was die
* Bank tatsächlich gebucht hat, und kann in Sonderfällen von der Rechnung
* abweichen – etwa wenn eine Teilsteuer separat erstattet wurde. Sonst wird
* er aus Bruttowert und Nebenkosten gebildet.
*/
private static function geldbetrag(
string $vorgang,
float $betrag,
float $gebuehren,
float $steuern,
float $vorgabe
): float {
if (!in_array($vorgang, ['kauf', 'verkauf', 'dividende'], true)) {
return 0.0;
}
if (abs($vorgabe) > 0.005) {
return $vorgang === 'kauf' ? -abs($vorgabe) : abs($vorgabe);
}
return match ($vorgang) {
'kauf' => -($betrag + $gebuehren + $steuern),
'verkauf' => $betrag - $gebuehren - $steuern,
default => $betrag - $gebuehren - $steuern,
};
}
}

89
src/Models/Erinnerung.php Normal file
View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Erinnerungen: alles, was zu einem Datum ansteht, aber keine Buchung ist –
* „Versicherung kündigen“, „Steuererklärung abgeben“.
*
* Sie erscheinen im Finanzkalender neben den fälligen Buchungen.
*/
class Erinnerung
{
/** @return list<array<string,mixed>> */
public static function alle(bool $nurOffene = false): array
{
$sql = 'SELECT * FROM erinnerungen WHERE benutzer_id = ?';
if ($nurOffene) {
$sql .= ' AND erledigt = 0';
}
return Database::fetchAll($sql . ' ORDER BY erledigt, faellig_am, id', [Mandant::id()]);
}
/** @return list<array<string,mixed>> */
public static function faellige(?string $bis = null): array
{
return Database::fetchAll(
'SELECT * FROM erinnerungen
WHERE benutzer_id = ? AND erledigt = 0 AND faellig_am <= ?
ORDER BY faellig_am, id',
[Mandant::id(), $bis ?? date('Y-m-d')]
);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM erinnerungen WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
public static function anlegen(string $text, string $faelligAm, string $notiz = ''): int
{
$text = trim($text);
if ($text === '') {
throw new \InvalidArgumentException('Die Erinnerung braucht einen Text.');
}
if (!Buchung::istDatum($faelligAm)) {
throw new \InvalidArgumentException('Das Datum muss als JJJJ-MM-TT vorliegen.');
}
Database::execute(
'INSERT INTO erinnerungen (benutzer_id, text, faellig_am, notiz) VALUES (?, ?, ?, ?)',
[Mandant::id(), mb_substr($text, 0, 255), $faelligAm, trim($notiz)]
);
return Database::lastInsertId();
}
public static function erledigen(int $id, bool $erledigt = true): void
{
Database::execute(
'UPDATE erinnerungen SET erledigt = ?, erledigt_am = ?
WHERE id = ? AND benutzer_id = ?',
[$erledigt ? 1 : 0, $erledigt ? date('Y-m-d H:i:s') : null, $id, Mandant::id()]
);
}
public static function loeschen(int $id): void
{
Database::execute('DELETE FROM erinnerungen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
}
public static function anzahlOffen(): int
{
return (int)Database::fetchValue(
'SELECT COUNT(*) FROM erinnerungen WHERE benutzer_id = ? AND erledigt = 0',
[Mandant::id()],
0
);
}
}

View file

@ -182,13 +182,14 @@ class Konto
{
$aktuell = Buchung::saldenJeKonto(date('Y-m-d'));
$erwartet = Buchung::saldenJeKonto();
$depots = \Finanzmanager\Services\Depot::werteJeDepot();
$gruppen = [];
$summe = 0.0;
$summeOnline = 0.0;
foreach (self::alle($nurAktive) as $konto) {
$konto = self::mitSalden($konto, $aktuell, $erwartet);
$konto = self::mitSalden($konto, $aktuell, $erwartet, $depots);
$name = $konto['gruppenname'] ?? null;
$name = ($name === null || $name === '') ? Kontengruppe::OHNE_GRUPPE : $name;
@ -222,16 +223,36 @@ class Konto
* @param array<int,float>|null $erwartet
* @return array<string,mixed>
*/
public static function mitSalden(array $konto, ?array $aktuell = null, ?array $erwartet = null): array
{
public static function mitSalden(
array $konto,
?array $aktuell = null,
?array $erwartet = null,
?array $depotwerte = null
): array {
$id = (int)$konto['id'];
$anfang = (float)$konto['anfangssaldo'];
$aktuell ??= Buchung::saldenJeKonto(date('Y-m-d'));
$erwartet ??= Buchung::saldenJeKonto();
// Ein Depot hat keinen Saldo aus Buchungen, sondern einen bewerteten
// Bestand: Stückzahlen mal Kurs. Der Anfangssaldo bleibt trotzdem
// dabei – dort steht bei Depots die Barreserve, wenn das Konto eine
// führt (siehe docs/wertpapiere.md).
if ((string)$konto['kontoart'] === 'depot') {
$depotwerte ??= \Finanzmanager\Services\Depot::werteJeDepot();
$wert = $anfang + ($depotwerte[$id] ?? 0.0);
$konto['saldo_aktuell'] = $wert;
$konto['saldo_erwartet'] = $wert;
$konto['bewertet'] = true;
return $konto;
}
$konto['saldo_aktuell'] = $anfang + ($aktuell[$id] ?? 0.0);
$konto['saldo_erwartet'] = $anfang + ($erwartet[$id] ?? 0.0);
$konto['bewertet'] = false;
return $konto;
}

279
src/Models/Kredit.php Normal file
View file

@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Kredite mit Tilgungsplan.
*
* Der Plan wird **gerechnet, nicht gespeichert**: Er ergibt sich vollständig aus
* Darlehensbetrag, Zinssatz, Rate und Beginn. Eine Tabelle mit hundert
* vorausberechneten Zeilen wäre bei jeder Änderung neu zu erzeugen und stünde
* doch nur da, wo die Formel steht.
*
* Gerechnet wird als **Annuitätendarlehen**: Die Rate bleibt gleich, der
* Zinsanteil sinkt mit der Restschuld, der Tilgungsanteil wächst entsprechend.
* Zinsen fallen je Periode auf die Restschuld an (nachschüssig).
*/
class Kredit
{
/** Zahlungsrhythmus → Perioden im Jahr. */
public const RHYTHMEN = [
'monatlich' => 12,
'vierteljaehrlich' => 4,
'halbjaehrlich' => 2,
'jaehrlich' => 1,
];
public const RHYTHMUS_NAMEN = [
'monatlich' => 'monatlich',
'vierteljaehrlich' => 'vierteljährlich',
'halbjaehrlich' => 'halbjährlich',
'jaehrlich' => 'jährlich',
];
/** Mehr Perioden rechnet der Plan nicht – 50 Jahre monatlich sind genug. */
private const MAX_PERIODEN = 600;
/** @return list<array<string,mixed>> */
public static function alle(bool $nurAktive = false): array
{
$sql = 'SELECT kr.*, k.name AS kontoname
FROM kredite kr
LEFT JOIN konten k ON k.id = kr.konto_id
WHERE kr.benutzer_id = ?';
if ($nurAktive) {
$sql .= ' AND kr.aktiv = 1';
}
return Database::fetchAll($sql . ' ORDER BY kr.aktiv DESC, kr.name', [Mandant::id()]);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM kredite WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/** @param array<string,mixed> $daten */
public static function anlegen(array $daten): int
{
$f = self::normalisiere($daten);
Database::execute(
'INSERT INTO kredite
(benutzer_id, name, konto_id, darlehensbetrag, zinssatz, rate, rhythmus,
beginn, zinsbindung_bis, sondertilgung, notiz, aktiv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $f['name'], $f['konto_id'], $f['darlehensbetrag'], $f['zinssatz'],
$f['rate'], $f['rhythmus'], $f['beginn'], $f['zinsbindung_bis'],
$f['sondertilgung'], $f['notiz'], $f['aktiv'],
]
);
return Database::lastInsertId();
}
/** @param array<string,mixed> $daten */
public static function speichern(int $id, array $daten): void
{
$f = self::normalisiere($daten);
Database::execute(
'UPDATE kredite SET name = ?, konto_id = ?, darlehensbetrag = ?, zinssatz = ?, rate = ?,
rhythmus = ?, beginn = ?, zinsbindung_bis = ?, sondertilgung = ?, notiz = ?, aktiv = ?
WHERE id = ? AND benutzer_id = ?',
[
$f['name'], $f['konto_id'], $f['darlehensbetrag'], $f['zinssatz'], $f['rate'],
$f['rhythmus'], $f['beginn'], $f['zinsbindung_bis'], $f['sondertilgung'],
$f['notiz'], $f['aktiv'], $id, Mandant::id(),
]
);
}
public static function loeschen(int $id): void
{
Database::execute('DELETE FROM kredite WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
}
/**
* Der Tilgungsplan.
*
* @param array<string,mixed> $kredit
* @return array{
* zeilen: list<array<string,mixed>>,
* zinsen: float,
* tilgung: float,
* laufzeit_monate: int,
* letzte_rate_am: ?string,
* restschuld_zinsbindung: ?float,
* tilgt: bool
* }
*/
public static function tilgungsplan(array $kredit): array
{
$rest = round((float)$kredit['darlehensbetrag'], 2);
$rate = round((float)$kredit['rate'], 2);
$zinssatz = (float)$kredit['zinssatz'] / 100.0;
$rhythmus = (string)$kredit['rhythmus'];
$perioden = self::RHYTHMEN[$rhythmus] ?? 12;
$sonder = round((float)$kredit['sondertilgung'], 2);
$bindung = (string)($kredit['zinsbindung_bis'] ?? '');
$zinsFaktor = $zinssatz / $perioden;
$schritt = match ($rhythmus) {
'vierteljaehrlich' => '+3 months',
'halbjaehrlich' => '+6 months',
'jaehrlich' => '+1 year',
default => '+1 month',
};
// Die erste Rate reicht nicht einmal für die Zinsen: Der Kredit wüchse
// statt zu schrumpfen. Dann gibt es keinen Plan, sondern einen Hinweis.
$ersteZinsen = round($rest * $zinsFaktor, 2);
if ($rate <= $ersteZinsen || $rest <= 0.0 || $rate <= 0.0) {
return [
'zeilen' => [],
'zinsen' => 0.0,
'tilgung' => 0.0,
'laufzeit_monate' => 0,
'letzte_rate_am' => null,
'restschuld_zinsbindung' => null,
'tilgt' => false,
];
}
$datum = new \DateTimeImmutable((string)$kredit['beginn']);
$zeilen = [];
$zinsenGesamt = 0.0;
$jahr = (int)$datum->format('Y');
$restBindung = null;
for ($nummer = 1; $nummer <= self::MAX_PERIODEN && $rest > 0.005; $nummer++) {
$zinsen = round($rest * $zinsFaktor, 2);
$tilgung = round($rate - $zinsen, 2);
// Die letzte Rate ist krumm: Sie tilgt nur noch den Rest.
if ($tilgung > $rest) {
$tilgung = $rest;
}
$zahlung = round($zinsen + $tilgung, 2);
$rest = round($rest - $tilgung, 2);
// Sondertilgung am Jahresende, solange noch etwas offen ist.
$jahresende = (int)$datum->format('Y') !== $jahr;
if ($jahresende) {
$jahr = (int)$datum->format('Y');
}
if ($sonder > 0.0 && $rest > 0.005 && (int)$datum->format('n') === 12) {
$extra = min($sonder, $rest);
$rest = round($rest - $extra, 2);
$tilgung = round($tilgung + $extra, 2);
$zahlung = round($zahlung + $extra, 2);
}
$zeilen[] = [
'nummer' => $nummer,
'datum' => $datum->format('Y-m-d'),
'zahlung' => $zahlung,
'zinsen' => $zinsen,
'tilgung' => $tilgung,
'restschuld' => $rest,
];
$zinsenGesamt += $zinsen;
if ($bindung !== '' && $restBindung === null && $datum->format('Y-m-d') >= $bindung) {
$restBindung = $rest;
}
$datum = $datum->modify($schritt);
}
$letzte = $zeilen === [] ? null : $zeilen[count($zeilen) - 1];
return [
'zeilen' => $zeilen,
'zinsen' => round($zinsenGesamt, 2),
'tilgung' => round((float)$kredit['darlehensbetrag'], 2),
'laufzeit_monate' => (int)round(count($zeilen) * (12 / $perioden)),
'letzte_rate_am' => $letzte['datum'] ?? null,
'restschuld_zinsbindung' => $restBindung,
'tilgt' => true,
];
}
/**
* Die Restschuld zu einem Stichtag – für die Kontenübersicht und die
* Vermögensrechnung.
*
* @param array<string,mixed> $kredit
*/
public static function restschuld(array $kredit, ?string $stichtag = null): float
{
$stichtag = $stichtag ?? date('Y-m-d');
$plan = self::tilgungsplan($kredit);
if (!$plan['tilgt']) {
return round((float)$kredit['darlehensbetrag'], 2);
}
$rest = round((float)$kredit['darlehensbetrag'], 2);
foreach ($plan['zeilen'] as $zeile) {
if ($zeile['datum'] > $stichtag) {
break;
}
$rest = $zeile['restschuld'];
}
return $rest;
}
/**
* @param array<string,mixed> $daten
* @return array<string,mixed>
*/
private static function normalisiere(array $daten): array
{
$name = trim((string)($daten['name'] ?? ''));
if ($name === '') {
throw new \InvalidArgumentException('Der Kredit braucht einen Namen.');
}
$beginn = trim((string)($daten['beginn'] ?? ''));
if (!Buchung::istDatum($beginn)) {
throw new \InvalidArgumentException('Der Beginn muss als JJJJ-MM-TT vorliegen.');
}
$betrag = round((float)($daten['darlehensbetrag'] ?? 0), 2);
if ($betrag <= 0.0) {
throw new \InvalidArgumentException('Der Darlehensbetrag muss größer als null sein.');
}
$rhythmus = (string)($daten['rhythmus'] ?? 'monatlich');
$bindung = trim((string)($daten['zinsbindung_bis'] ?? ''));
return [
'name' => mb_substr($name, 0, 255),
'konto_id' => ($daten['konto_id'] ?? null) ?: null,
'darlehensbetrag' => $betrag,
'zinssatz' => round(max(0.0, (float)($daten['zinssatz'] ?? 0)), 3),
'rate' => round(max(0.0, (float)($daten['rate'] ?? 0)), 2),
'rhythmus' => array_key_exists($rhythmus, self::RHYTHMEN) ? $rhythmus : 'monatlich',
'beginn' => $beginn,
'zinsbindung_bis' => Buchung::istDatum($bindung) ? $bindung : null,
'sondertilgung' => round(max(0.0, (float)($daten['sondertilgung'] ?? 0)), 2),
'notiz' => trim((string)($daten['notiz'] ?? '')),
'aktiv' => array_key_exists('aktiv', $daten) && empty($daten['aktiv']) ? 0 : 1,
];
}
}

View file

@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Regelmäßige Buchungen: Miete, Gehalt, Versicherungsbeiträge.
*
* Jede führt ihre **nächste Fälligkeit** mit sich. Der Fälligkeitslauf holt, was
* ansteht, bucht es und schreibt das Datum fort. So braucht es keine Tabelle
* vorausberechneter Termine, die bei jeder Änderung neu zu erzeugen wäre – der
* Finanzkalender rechnet die kommenden Termine bei Bedarf aus.
*/
class RegelmaessigeBuchung
{
/** Intervall → Beschriftung. */
public const INTERVALLE = [
'taeglich' => 'täglich',
'woechentlich' => 'wöchentlich',
'zweiwoechentlich' => 'alle zwei Wochen',
'monatlich' => 'monatlich',
'zweimonatlich' => 'alle zwei Monate',
'vierteljaehrlich' => 'vierteljährlich',
'halbjaehrlich' => 'halbjährlich',
'jaehrlich' => 'jährlich',
];
/** Intervall → Schritt für DateTime::modify(). */
private const SCHRITTE = [
'taeglich' => '+1 day',
'woechentlich' => '+1 week',
'zweiwoechentlich' => '+2 weeks',
'monatlich' => '+1 month',
'zweimonatlich' => '+2 months',
'vierteljaehrlich' => '+3 months',
'halbjaehrlich' => '+6 months',
'jaehrlich' => '+1 year',
];
/** @return list<array<string,mixed>> */
public static function alle(bool $nurAktive = false): array
{
$sql = 'SELECT r.*, k.name AS kontoname, kat.pfad AS kategorie_pfad
FROM regelmaessige_buchungen r
JOIN konten k ON k.id = r.konto_id
LEFT JOIN kategorien kat ON kat.id = r.kategorie_id
WHERE r.benutzer_id = ?';
if ($nurAktive) {
$sql .= ' AND r.aktiv = 1';
}
return Database::fetchAll($sql . ' ORDER BY r.naechste_faelligkeit IS NULL, r.naechste_faelligkeit, r.name',
[Mandant::id()]);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM regelmaessige_buchungen WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/**
* Was bis zu einem Stichtag fällig ist (Vorgabe: heute).
*
* @return list<array<string,mixed>>
*/
public static function faellige(?string $bis = null): array
{
return Database::fetchAll(
'SELECT r.*, k.name AS kontoname
FROM regelmaessige_buchungen r
JOIN konten k ON k.id = r.konto_id
WHERE r.benutzer_id = ? AND r.aktiv = 1
AND r.naechste_faelligkeit IS NOT NULL
AND r.naechste_faelligkeit <= ?
AND (r.ende IS NULL OR r.naechste_faelligkeit <= r.ende)
ORDER BY r.naechste_faelligkeit, r.id',
[Mandant::id(), $bis ?? date('Y-m-d')]
);
}
/** @param array<string,mixed> $daten */
public static function anlegen(array $daten): int
{
$f = self::normalisiere($daten);
Database::execute(
'INSERT INTO regelmaessige_buchungen
(benutzer_id, name, konto_id, betrag, empfaenger, buchungstext, kategorie_id,
klasse_id, gegenkonto_id, splits, intervall, faktor, beginn, ende,
naechste_faelligkeit, automatisch, vorlauf_tage, aktiv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $f['name'], $f['konto_id'], $f['betrag'], $f['empfaenger'],
$f['buchungstext'], $f['kategorie_id'], $f['klasse_id'], $f['gegenkonto_id'],
$f['splits'], $f['intervall'], $f['faktor'], $f['beginn'], $f['ende'],
$f['beginn'], $f['automatisch'], $f['vorlauf_tage'], $f['aktiv'],
]
);
return Database::lastInsertId();
}
/** @param array<string,mixed> $daten */
public static function speichern(int $id, array $daten): void
{
$bisher = self::find($id);
if ($bisher === null) {
throw new \RuntimeException('Die regelmäßige Buchung gibt es nicht.');
}
$f = self::normalisiere($daten);
// Die nächste Fälligkeit bleibt stehen, solange sie zum neuen Beginn
// passt – sonst zöge jedes Speichern die Reihe zurück auf den Anfang.
$naechste = (string)($bisher['naechste_faelligkeit'] ?? '');
if ($naechste === '' || $naechste < $f['beginn'] || $bisher['intervall'] !== $f['intervall']) {
$naechste = $f['beginn'];
}
Database::execute(
'UPDATE regelmaessige_buchungen SET name = ?, konto_id = ?, betrag = ?, empfaenger = ?,
buchungstext = ?, kategorie_id = ?, klasse_id = ?, gegenkonto_id = ?, splits = ?,
intervall = ?, faktor = ?, beginn = ?, ende = ?, naechste_faelligkeit = ?,
automatisch = ?, vorlauf_tage = ?, aktiv = ?
WHERE id = ? AND benutzer_id = ?',
[
$f['name'], $f['konto_id'], $f['betrag'], $f['empfaenger'], $f['buchungstext'],
$f['kategorie_id'], $f['klasse_id'], $f['gegenkonto_id'], $f['splits'],
$f['intervall'], $f['faktor'], $f['beginn'], $f['ende'], $naechste,
$f['automatisch'], $f['vorlauf_tage'], $f['aktiv'], $id, Mandant::id(),
]
);
}
public static function loeschen(int $id): void
{
Database::execute(
'DELETE FROM regelmaessige_buchungen WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
}
/**
* Bucht einen fälligen Termin und schreibt die Fälligkeit fort.
*
* @param array<string,mixed> $abweichungen datum, betrag
* @return int Die angelegte Buchung
*/
public static function buchen(int $id, array $abweichungen = []): int
{
$reihe = self::find($id);
if ($reihe === null) {
throw new \RuntimeException('Die regelmäßige Buchung gibt es nicht.');
}
$datum = (string)($abweichungen['datum'] ?? $reihe['naechste_faelligkeit'] ?? date('Y-m-d'));
$betrag = array_key_exists('betrag', $abweichungen) && $abweichungen['betrag'] !== ''
? round((float)$abweichungen['betrag'], 2)
: (float)$reihe['betrag'];
$splits = Buchungsvorlage::splitsFuerBetrag($reihe, $betrag);
return (int)Database::transaktion(static function () use ($reihe, $id, $datum, $betrag, $splits): int {
$buchungId = Buchung::anlegen(
[
'konto_id' => (int)$reihe['konto_id'],
'datum' => $datum,
'betrag' => $betrag,
'empfaenger' => (string)$reihe['empfaenger'],
'buchungstext' => (string)$reihe['buchungstext'],
'kategorie_id' => $splits === [] ? $reihe['kategorie_id'] : null,
'klasse_id' => $reihe['klasse_id'],
'gegenkonto_id' => $splits === [] ? $reihe['gegenkonto_id'] : null,
],
$splits
);
self::rueckeVor($id, (string)$reihe['naechste_faelligkeit'], $datum);
return $buchungId;
});
}
/**
* Überspringt einen Termin, ohne zu buchen – etwa wenn die Miete einmal
* ausgesetzt wurde.
*/
public static function ueberspringen(int $id): void
{
$reihe = self::find($id);
if ($reihe === null) {
return;
}
self::rueckeVor($id, (string)$reihe['naechste_faelligkeit'], null);
}
/**
* Die kommenden Termine einer Reihe – für den Finanzkalender. Gerechnet,
* nicht gespeichert.
*
* @param array<string,mixed> $reihe
* @return list<string> Datumsangaben
*/
public static function kommendeTermine(array $reihe, string $bis, int $grenze = 60): array
{
$termin = (string)($reihe['naechste_faelligkeit'] ?? '');
if ($termin === '' || (int)$reihe['aktiv'] !== 1) {
return [];
}
$ende = (string)($reihe['ende'] ?? '');
$termine = [];
while ($termin <= $bis && count($termine) < $grenze) {
if ($ende !== '' && $termin > $ende) {
break;
}
$termine[] = $termin;
$termin = self::naechsterTermin($termin, (string)$reihe['intervall'], (int)$reihe['faktor']);
}
return $termine;
}
/** Das nächste Datum nach `$datum` gemäß Intervall und Faktor. */
public static function naechsterTermin(string $datum, string $intervall, int $faktor = 1): string
{
$schritt = self::SCHRITTE[$intervall] ?? '+1 month';
$faktor = max(1, $faktor);
$zeit = new \DateTimeImmutable($datum);
for ($lauf = 0; $lauf < $faktor; $lauf++) {
$zeit = $zeit->modify($schritt);
}
return $zeit->format('Y-m-d');
}
/**
* Schreibt die Fälligkeit fort. Liegt der neue Termin immer noch in der
* Vergangenheit – etwa weil die Anwendung länger nicht lief –, wird so
* lange weitergerückt, bis er in der Zukunft liegt; sonst stünde beim
* nächsten Lauf derselbe Termin wieder an.
*/
private static function rueckeVor(int $id, string $bisher, ?string $gebuchtAm): void
{
$reihe = self::find($id);
if ($reihe === null) {
return;
}
$termin = $bisher !== '' ? $bisher : date('Y-m-d');
$heute = date('Y-m-d');
do {
$termin = self::naechsterTermin($termin, (string)$reihe['intervall'], (int)$reihe['faktor']);
} while ($termin <= $heute && $termin < '2100-01-01');
$ende = (string)($reihe['ende'] ?? '');
$aktiv = ($ende !== '' && $termin > $ende) ? 0 : (int)$reihe['aktiv'];
Database::execute(
'UPDATE regelmaessige_buchungen
SET naechste_faelligkeit = ?, zuletzt_am = ?, aktiv = ?
WHERE id = ? AND benutzer_id = ?',
[$termin, $gebuchtAm, $aktiv, $id, Mandant::id()]
);
}
/**
* @param array<string,mixed> $daten
* @return array<string,mixed>
*/
private static function normalisiere(array $daten): array
{
$name = trim((string)($daten['name'] ?? ''));
if ($name === '') {
throw new \InvalidArgumentException('Die regelmäßige Buchung braucht einen Namen.');
}
$kontoId = (int)($daten['konto_id'] ?? 0);
if ($kontoId === 0 || Konto::find($kontoId) === null) {
throw new \InvalidArgumentException('Es fehlt ein gültiges Konto.');
}
$beginn = trim((string)($daten['beginn'] ?? ''));
if (!Buchung::istDatum($beginn)) {
throw new \InvalidArgumentException('Der Beginn muss als JJJJ-MM-TT vorliegen.');
}
$ende = trim((string)($daten['ende'] ?? ''));
$ende = Buchung::istDatum($ende) ? $ende : null;
if ($ende !== null && $ende < $beginn) {
throw new \InvalidArgumentException('Das Ende liegt vor dem Beginn.');
}
$intervall = (string)($daten['intervall'] ?? 'monatlich');
$id = static fn(mixed $wert): ?int =>
($wert === null || $wert === '' || (int)$wert === 0) ? null : (int)$wert;
$splits = $daten['splits'] ?? null;
if (is_array($splits) && $splits !== []) {
$splits = json_encode(array_values($splits), JSON_UNESCAPED_UNICODE);
} elseif (!is_string($splits) || trim((string)$splits) === '') {
$splits = null;
}
return [
'name' => mb_substr($name, 0, 255),
'konto_id' => $kontoId,
'betrag' => round((float)($daten['betrag'] ?? 0), 2),
'empfaenger' => mb_substr(trim((string)($daten['empfaenger'] ?? '')), 0, 255),
'buchungstext' => trim((string)($daten['buchungstext'] ?? '')),
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
'klasse_id' => $id($daten['klasse_id'] ?? null),
'gegenkonto_id' => $id($daten['gegenkonto_id'] ?? null),
'splits' => $splits,
'intervall' => array_key_exists($intervall, self::INTERVALLE) ? $intervall : 'monatlich',
'faktor' => max(1, min(12, (int)($daten['faktor'] ?? 1))),
'beginn' => $beginn,
'ende' => $ende,
'automatisch' => !empty($daten['automatisch']) ? 1 : 0,
'vorlauf_tage' => max(0, min(365, (int)($daten['vorlauf_tage'] ?? 7))),
'aktiv' => array_key_exists('aktiv', $daten) && empty($daten['aktiv']) ? 0 : 1,
];
}
}

158
src/Models/Sparziel.php Normal file
View file

@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Sparziele: ein Betrag, ein Termin, ein Konto.
*
* Der erreichte Stand wird **nicht gespeichert**, sondern aus dem Kontosaldo
* gerechnet – ein zweiter, gepflegter Wert liefe früher oder später von den
* Buchungen weg. Der Startbetrag zieht ab, was zu Beginn schon dalag.
*/
class Sparziel
{
/** @return list<array<string,mixed>> */
public static function alle(): array
{
$ziele = Database::fetchAll(
'SELECT s.*, k.name AS kontoname
FROM sparziele s
LEFT JOIN konten k ON k.id = s.konto_id
WHERE s.benutzer_id = ?
ORDER BY s.erreicht, s.zieldatum IS NULL, s.zieldatum, s.name',
[Mandant::id()]
);
foreach ($ziele as $nummer => $ziel) {
$ziele[$nummer] = self::mitStand($ziel);
}
return $ziele;
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM sparziele WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : self::mitStand($zeile);
}
/**
* Ergänzt Stand, Fortschritt und die Rate, die bis zum Ziel nötig wäre.
*
* @param array<string,mixed> $ziel
* @return array<string,mixed>
*/
public static function mitStand(array $ziel, ?string $heute = null): array
{
$heute = $heute ?? date('Y-m-d');
$ziel['stand'] = $ziel['konto_id'] === null
? 0.0
: round(Konto::saldo((int)$ziel['konto_id'], $heute) - (float)$ziel['startbetrag'], 2);
$zielbetrag = (float)$ziel['zielbetrag'];
$ziel['fehlt'] = round(max(0.0, $zielbetrag - $ziel['stand']), 2);
$ziel['fortschritt'] = $zielbetrag > 0.0
? max(0.0, min(1.0, $ziel['stand'] / $zielbetrag))
: 0.0;
// Wie viel monatlich noch nötig wäre, um den Termin zu halten.
$ziel['noetige_rate'] = null;
$zieldatum = (string)($ziel['zieldatum'] ?? '');
if ($zieldatum !== '' && $zieldatum > $heute && $ziel['fehlt'] > 0.0) {
$monate = self::monateBis($heute, $zieldatum);
$ziel['noetige_rate'] = $monate > 0 ? round($ziel['fehlt'] / $monate, 2) : $ziel['fehlt'];
}
return $ziel;
}
/** @param array<string,mixed> $daten */
public static function anlegen(array $daten): int
{
$f = self::normalisiere($daten);
Database::execute(
'INSERT INTO sparziele
(benutzer_id, name, konto_id, zielbetrag, startbetrag, zieldatum, monatsrate, notiz)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $f['name'], $f['konto_id'], $f['zielbetrag'], $f['startbetrag'],
$f['zieldatum'], $f['monatsrate'], $f['notiz'],
]
);
return Database::lastInsertId();
}
/** @param array<string,mixed> $daten */
public static function speichern(int $id, array $daten): void
{
$f = self::normalisiere($daten);
Database::execute(
'UPDATE sparziele SET name = ?, konto_id = ?, zielbetrag = ?, startbetrag = ?,
zieldatum = ?, monatsrate = ?, notiz = ?, erreicht = ?
WHERE id = ? AND benutzer_id = ?',
[
$f['name'], $f['konto_id'], $f['zielbetrag'], $f['startbetrag'], $f['zieldatum'],
$f['monatsrate'], $f['notiz'], $f['erreicht'], $id, Mandant::id(),
]
);
}
public static function loeschen(int $id): void
{
Database::execute('DELETE FROM sparziele WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
}
/** Volle Monate zwischen zwei Daten, mindestens einer. */
private static function monateBis(string $von, string $bis): int
{
$a = new \DateTimeImmutable(substr($von, 0, 7) . '-01');
$b = new \DateTimeImmutable(substr($bis, 0, 7) . '-01');
$differenz = $a->diff($b);
return max(1, $differenz->y * 12 + $differenz->m);
}
/**
* @param array<string,mixed> $daten
* @return array<string,mixed>
*/
private static function normalisiere(array $daten): array
{
$name = trim((string)($daten['name'] ?? ''));
if ($name === '') {
throw new \InvalidArgumentException('Das Sparziel braucht einen Namen.');
}
$zielbetrag = round((float)($daten['zielbetrag'] ?? 0), 2);
if ($zielbetrag <= 0.0) {
throw new \InvalidArgumentException('Der Zielbetrag muss größer als null sein.');
}
$zieldatum = trim((string)($daten['zieldatum'] ?? ''));
$rate = $daten['monatsrate'] ?? null;
return [
'name' => mb_substr($name, 0, 255),
'konto_id' => ($daten['konto_id'] ?? null) ?: null,
'zielbetrag' => $zielbetrag,
'startbetrag' => round((float)($daten['startbetrag'] ?? 0), 2),
'zieldatum' => Buchung::istDatum($zieldatum) ? $zieldatum : null,
'monatsrate' => ($rate === null || $rate === '') ? null : round((float)$rate, 2),
'notiz' => trim((string)($daten['notiz'] ?? '')),
'erreicht' => !empty($daten['erreicht']) ? 1 : 0,
];
}
}

187
src/Models/Vertrag.php Normal file
View file

@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Verträge: was laufend kostet und wann es kündbar ist.
*
* Der Nutzen liegt in der **Kündigungsfrist**: Die Übersicht zeigt, bis wann
* gekündigt werden muss, damit ein Vertrag nicht stillschweigend weiterläuft.
* Ein Vertrag kann mit einer regelmäßigen Buchung verbunden sein, die seine
* Zahlung erledigt.
*/
class Vertrag
{
/** @return list<array<string,mixed>> */
public static function alle(bool $nurAktive = false): array
{
$sql = 'SELECT v.*, a.name AS adressname, k.pfad AS kategorie_pfad,
r.name AS buchungsname
FROM vertraege v
LEFT JOIN adressen a ON a.id = v.adresse_id
LEFT JOIN kategorien k ON k.id = v.kategorie_id
LEFT JOIN regelmaessige_buchungen r ON r.id = v.regelmaessige_id
WHERE v.benutzer_id = ?';
if ($nurAktive) {
$sql .= ' AND v.aktiv = 1';
}
return Database::fetchAll($sql . ' ORDER BY v.ende IS NULL, v.ende, v.name', [Mandant::id()]);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM vertraege WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/** @param array<string,mixed> $daten */
public static function anlegen(array $daten): int
{
$f = self::normalisiere($daten);
Database::execute(
'INSERT INTO vertraege
(benutzer_id, name, partner, adresse_id, vertragsnummer, kategorie_id, betrag,
intervall, beginn, ende, kuendigungsfrist_tage, regelmaessige_id, notiz, aktiv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $f['name'], $f['partner'], $f['adresse_id'], $f['vertragsnummer'],
$f['kategorie_id'], $f['betrag'], $f['intervall'], $f['beginn'], $f['ende'],
$f['kuendigungsfrist_tage'], $f['regelmaessige_id'], $f['notiz'], $f['aktiv'],
]
);
return Database::lastInsertId();
}
/** @param array<string,mixed> $daten */
public static function speichern(int $id, array $daten): void
{
$f = self::normalisiere($daten);
Database::execute(
'UPDATE vertraege SET name = ?, partner = ?, adresse_id = ?, vertragsnummer = ?,
kategorie_id = ?, betrag = ?, intervall = ?, beginn = ?, ende = ?,
kuendigungsfrist_tage = ?, regelmaessige_id = ?, notiz = ?, aktiv = ?
WHERE id = ? AND benutzer_id = ?',
[
$f['name'], $f['partner'], $f['adresse_id'], $f['vertragsnummer'], $f['kategorie_id'],
$f['betrag'], $f['intervall'], $f['beginn'], $f['ende'], $f['kuendigungsfrist_tage'],
$f['regelmaessige_id'], $f['notiz'], $f['aktiv'], $id, Mandant::id(),
]
);
}
public static function loeschen(int $id): void
{
Database::execute('DELETE FROM vertraege WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
}
/**
* Der letzte Tag, an dem noch gekündigt werden kann: Vertragsende minus
* Frist. Ohne Ende oder ohne Frist gibt es keinen.
*/
public static function kuendigenBis(array $vertrag): ?string
{
$ende = (string)($vertrag['ende'] ?? '');
$tage = (int)($vertrag['kuendigungsfrist_tage'] ?? 0);
if ($ende === '' || $tage <= 0) {
return null;
}
return (new \DateTimeImmutable($ende))->modify('-' . $tage . ' days')->format('Y-m-d');
}
/**
* Verträge, deren Kündigungsfrist innerhalb der nächsten Tage abläuft –
* genau die, die man leicht verpasst.
*
* @return list<array<string,mixed>>
*/
public static function fristenLaufenAb(int $tage = 60, ?string $heute = null): array
{
$heute = $heute ?? date('Y-m-d');
$grenze = (new \DateTimeImmutable($heute))->modify('+' . $tage . ' days')->format('Y-m-d');
$treffer = [];
foreach (self::alle(true) as $vertrag) {
$stichtag = self::kuendigenBis($vertrag);
if ($stichtag === null || $stichtag < $heute || $stichtag > $grenze) {
continue;
}
$vertrag['kuendigen_bis'] = $stichtag;
$treffer[] = $vertrag;
}
usort($treffer, static fn(array $a, array $b): int => strcmp($a['kuendigen_bis'], $b['kuendigen_bis']));
return $treffer;
}
/** Was der Vertrag im Jahr kostet – zum Vergleich verschieden getakteter Verträge. */
public static function jahresbetrag(array $vertrag): float
{
$betrag = (float)($vertrag['betrag'] ?? 0);
return round($betrag * match ((string)($vertrag['intervall'] ?? 'monatlich')) {
'taeglich' => 365,
'woechentlich' => 52,
'zweiwoechentlich' => 26,
'monatlich' => 12,
'zweimonatlich' => 6,
'vierteljaehrlich' => 4,
'halbjaehrlich' => 2,
default => 1,
}, 2);
}
/**
* @param array<string,mixed> $daten
* @return array<string,mixed>
*/
private static function normalisiere(array $daten): array
{
$name = trim((string)($daten['name'] ?? ''));
if ($name === '') {
throw new \InvalidArgumentException('Der Vertrag braucht einen Namen.');
}
$datum = static function (mixed $wert): ?string {
$wert = trim((string)$wert);
return Buchung::istDatum($wert) ? $wert : null;
};
$id = static fn(mixed $wert): ?int =>
($wert === null || $wert === '' || (int)$wert === 0) ? null : (int)$wert;
$intervall = (string)($daten['intervall'] ?? 'monatlich');
return [
'name' => mb_substr($name, 0, 255),
'partner' => mb_substr(trim((string)($daten['partner'] ?? '')), 0, 255),
'adresse_id' => $id($daten['adresse_id'] ?? null),
'vertragsnummer' => mb_substr(trim((string)($daten['vertragsnummer'] ?? '')), 0, 64),
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
'betrag' => round((float)($daten['betrag'] ?? 0), 2),
'intervall' => array_key_exists($intervall, RegelmaessigeBuchung::INTERVALLE)
? $intervall : 'monatlich',
'beginn' => $datum($daten['beginn'] ?? ''),
'ende' => $datum($daten['ende'] ?? ''),
'kuendigungsfrist_tage' => max(0, min(999, (int)($daten['kuendigungsfrist_tage'] ?? 0))),
'regelmaessige_id' => $id($daten['regelmaessige_id'] ?? null),
'notiz' => trim((string)($daten['notiz'] ?? '')),
'aktiv' => array_key_exists('aktiv', $daten) && empty($daten['aktiv']) ? 0 : 1,
];
}
}

297
src/Models/Wertpapier.php Normal file
View file

@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Ein Wertpapier – Name, WKN, ISIN – samt seiner Kurse.
*
* Die Stammdaten kommen aus dem QIF-Export (Felder `Y`, `~`, `@`, `&`) oder aus
* dem Depotabruf; angelegt werden sie beim ersten Auftauchen einer Buchung.
*/
class Wertpapier
{
/** Wertpapierart, Schlüssel → Beschriftung. */
public const ARTEN = [
'aktie' => 'Aktie',
'fonds' => 'Fonds',
'anleihe' => 'Anleihe',
'zertifikat' => 'Zertifikat',
'sonstiges' => 'Sonstiges',
];
/** QIF-Feld „&“ → eigene Art. Der FinanzManager zählt schlicht durch. */
public const QIF_ARTEN = [
'1' => 'aktie',
'2' => 'fonds',
'3' => 'anleihe',
'4' => 'zertifikat',
];
/** @return list<array<string,mixed>> */
public static function alle(bool $nurAktive = false): array
{
$sql = 'SELECT * FROM wertpapiere WHERE benutzer_id = ?';
if ($nurAktive) {
$sql .= ' AND aktiv = 1';
}
return Database::fetchAll($sql . ' ORDER BY name', [Mandant::id()]);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM wertpapiere WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/**
* Sucht über ISIN, sonst über WKN, sonst über den Namen. Die ISIN ist der
* verlässliche Schlüssel; sie fehlt aber bei manchen Altbeständen.
*
* @return array<string,mixed>|null
*/
public static function finde(string $isin, string $wkn = '', string $name = ''): ?array
{
$isin = strtoupper(trim($isin));
$wkn = strtoupper(trim($wkn));
$name = trim($name);
foreach ([
['isin = ?', $isin],
['wkn = ?', $wkn],
['name = ?', $name],
] as [$bedingung, $wert]) {
if ($wert === '') {
continue;
}
$zeile = Database::fetchOne(
'SELECT * FROM wertpapiere WHERE benutzer_id = ? AND ' . $bedingung . ' LIMIT 1',
[Mandant::id(), $wert]
);
if ($zeile !== false) {
return $zeile;
}
}
return null;
}
/** @param array<string,mixed> $daten */
public static function findenOderAnlegen(array $daten): int
{
$vorhanden = self::finde(
(string)($daten['isin'] ?? ''),
(string)($daten['wkn'] ?? ''),
(string)($daten['name'] ?? '')
);
if ($vorhanden !== null) {
// Was beim ersten Mal fehlte, wird nachgetragen – eine Buchung
// führt die ISIN, die nächste vielleicht zusätzlich die WKN.
self::ergaenze((int)$vorhanden['id'], $daten);
return (int)$vorhanden['id'];
}
return self::anlegen($daten);
}
/** @param array<string,mixed> $daten */
public static function anlegen(array $daten): int
{
$felder = self::normalisiere($daten);
Database::execute(
'INSERT INTO wertpapiere (benutzer_id, name, wkn, isin, art, waehrung, symbol, aktiv, notiz)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $felder['name'], $felder['wkn'], $felder['isin'], $felder['art'],
$felder['waehrung'], $felder['symbol'], $felder['aktiv'], $felder['notiz'],
]
);
return Database::lastInsertId();
}
/** @param array<string,mixed> $daten */
public static function speichern(int $id, array $daten): void
{
$felder = self::normalisiere($daten);
Database::execute(
'UPDATE wertpapiere SET name = ?, wkn = ?, isin = ?, art = ?, waehrung = ?,
symbol = ?, aktiv = ?, notiz = ?
WHERE id = ? AND benutzer_id = ?',
[
$felder['name'], $felder['wkn'], $felder['isin'], $felder['art'],
$felder['waehrung'], $felder['symbol'], $felder['aktiv'], $felder['notiz'],
$id, Mandant::id(),
]
);
}
public static function loeschen(int $id): void
{
Database::execute('DELETE FROM wertpapiere WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
}
public static function anzahl(): int
{
return (int)Database::fetchValue(
'SELECT COUNT(*) FROM wertpapiere WHERE benutzer_id = ?',
[Mandant::id()],
0
);
}
public static function artAusQif(string $wert): string
{
return self::QIF_ARTEN[trim($wert)] ?? 'sonstiges';
}
// ── Kurse ────────────────────────────────────────────────────────────────
public static function kursSetzen(
int $wertpapierId,
string $datum,
float $kurs,
string $quelle = 'manuell',
string $waehrung = 'EUR'
): void {
Database::execute(
'INSERT INTO kurse (wertpapier_id, datum, kurs, waehrung, quelle)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE kurs = VALUES(kurs), quelle = VALUES(quelle),
waehrung = VALUES(waehrung)',
[$wertpapierId, $datum, $kurs, $waehrung, $quelle]
);
}
/**
* Der letzte bekannte Kurs bis zu einem Stichtag. Ohne Stichtag der
* aktuellste überhaupt.
*
* @return array<string,mixed>|null
*/
public static function letzterKurs(int $wertpapierId, ?string $bis = null): ?array
{
$sql = 'SELECT * FROM kurse WHERE wertpapier_id = ?';
$params = [$wertpapierId];
if ($bis !== null) {
$sql .= ' AND datum <= ?';
$params[] = $bis;
}
$zeile = Database::fetchOne($sql . ' ORDER BY datum DESC LIMIT 1', $params);
return $zeile === false ? null : $zeile;
}
/**
* Die letzten Kurse aller Wertpapiere in einer Abfrage – die Depotübersicht
* braucht sie für jede Position und soll nicht je Zeile fragen.
*
* @return array<int, array{kurs: float, datum: string}>
*/
public static function letzteKurse(?string $bis = null): array
{
$params = [Mandant::id()];
$grenze = '';
if ($bis !== null) {
$grenze = ' AND k.datum <= ?';
$params[] = $bis;
$params[] = $bis;
}
$zeilen = Database::fetchAll(
'SELECT k.wertpapier_id, k.kurs, k.datum
FROM kurse k
JOIN wertpapiere w ON w.id = k.wertpapier_id
WHERE w.benutzer_id = ?' . $grenze . '
AND k.datum = (
SELECT MAX(k2.datum) FROM kurse k2
WHERE k2.wertpapier_id = k.wertpapier_id' . ($bis === null ? '' : ' AND k2.datum <= ?') . '
)',
$params
);
$kurse = [];
foreach ($zeilen as $zeile) {
$kurse[(int)$zeile['wertpapier_id']] = [
'kurs' => (float)$zeile['kurs'],
'datum' => (string)$zeile['datum'],
];
}
return $kurse;
}
/** @return list<array<string,mixed>> */
public static function kursverlauf(int $wertpapierId, int $tage = 365): array
{
return Database::fetchAll(
'SELECT * FROM kurse
WHERE wertpapier_id = ? AND datum >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
ORDER BY datum',
[$wertpapierId, $tage]
);
}
/**
* @param array<string,mixed> $daten
* @return array<string,mixed>
*/
private static function normalisiere(array $daten): array
{
$name = trim((string)($daten['name'] ?? ''));
if ($name === '') {
throw new \InvalidArgumentException('Das Wertpapier braucht einen Namen.');
}
$art = (string)($daten['art'] ?? 'aktie');
return [
'name' => mb_substr($name, 0, 255),
'wkn' => mb_substr(strtoupper(trim((string)($daten['wkn'] ?? ''))), 0, 16),
'isin' => mb_substr(strtoupper(str_replace(' ', '', (string)($daten['isin'] ?? ''))), 0, 16),
'art' => array_key_exists($art, self::ARTEN) ? $art : 'sonstiges',
'waehrung' => mb_substr(strtoupper(trim((string)($daten['waehrung'] ?? 'EUR'))) ?: 'EUR', 0, 3),
'symbol' => mb_substr(trim((string)($daten['symbol'] ?? '')), 0, 32),
'aktiv' => array_key_exists('aktiv', $daten) && empty($daten['aktiv']) ? 0 : 1,
'notiz' => mb_substr(trim((string)($daten['notiz'] ?? '')), 0, 255),
];
}
/** Trägt fehlende Felder nach, ohne vorhandene zu überschreiben. */
private static function ergaenze(int $id, array $daten): void
{
$vorhanden = self::find($id);
if ($vorhanden === null) {
return;
}
$nachtrag = [];
foreach (['wkn', 'isin'] as $feld) {
$neu = strtoupper(trim((string)($daten[$feld] ?? '')));
if ($neu !== '' && (string)$vorhanden[$feld] === '') {
$nachtrag[$feld] = $neu;
}
}
foreach ($nachtrag as $feld => $wert) {
Database::execute(
'UPDATE wertpapiere SET ' . $feld . ' = ? WHERE id = ? AND benutzer_id = ?',
[$wert, $id, Mandant::id()]
);
}
}
}

View file

@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Überweisungen: erfasst, geprüft, über FinTS abgesendet.
*
* Der Auftrag bleibt als Beleg stehen, auch wenn die Bank ihn ablehnt – mit der
* Meldung daneben. Erst wenn die Bank ihn angenommen hat, entsteht die
* zugehörige Buchung auf dem Auftragskonto; vorher wäre sie eine Behauptung.
*/
class Zahlungsauftrag
{
public const STATUS = [
'entwurf' => 'Entwurf',
'gesendet' => 'gesendet',
'fehler' => 'fehlgeschlagen',
'storniert' => 'storniert',
];
/** @return list<array<string,mixed>> */
public static function alle(string $status = ''): array
{
$params = [Mandant::id()];
$sql = 'SELECT z.*, k.name AS kontoname, kat.pfad AS kategorie_pfad
FROM zahlungsauftraege z
JOIN konten k ON k.id = z.konto_id
LEFT JOIN kategorien kat ON kat.id = z.kategorie_id
WHERE z.benutzer_id = ?';
if ($status !== '' && array_key_exists($status, self::STATUS)) {
$sql .= ' AND z.status = ?';
$params[] = $status;
}
return Database::fetchAll(
$sql . ' ORDER BY z.status = \'gesendet\', z.ausfuehrung_am IS NULL,
z.ausfuehrung_am, z.erstellt_am DESC',
$params
);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM zahlungsauftraege WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/** @param array<string,mixed> $daten */
public static function anlegen(array $daten): int
{
$f = self::normalisiere($daten);
Database::execute(
'INSERT INTO zahlungsauftraege
(benutzer_id, konto_id, zugang_id, empfaenger, iban, bic, betrag, waehrung,
verwendungszweck, ausfuehrung_am, kategorie_id, klasse_id, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $f['konto_id'], $f['zugang_id'], $f['empfaenger'], $f['iban'],
$f['bic'], $f['betrag'], $f['waehrung'], $f['verwendungszweck'],
$f['ausfuehrung_am'], $f['kategorie_id'], $f['klasse_id'], 'entwurf',
]
);
return Database::lastInsertId();
}
/** @param array<string,mixed> $daten */
public static function speichern(int $id, array $daten): void
{
$auftrag = self::find($id);
if ($auftrag === null) {
throw new \RuntimeException('Den Zahlungsauftrag gibt es nicht.');
}
if ($auftrag['status'] === 'gesendet') {
throw new \RuntimeException('Ein gesendeter Auftrag lässt sich nicht mehr ändern.');
}
$f = self::normalisiere($daten);
Database::execute(
'UPDATE zahlungsauftraege SET konto_id = ?, zugang_id = ?, empfaenger = ?, iban = ?,
bic = ?, betrag = ?, waehrung = ?, verwendungszweck = ?, ausfuehrung_am = ?,
kategorie_id = ?, klasse_id = ?, status = \'entwurf\', meldung = NULL
WHERE id = ? AND benutzer_id = ?',
[
$f['konto_id'], $f['zugang_id'], $f['empfaenger'], $f['iban'], $f['bic'],
$f['betrag'], $f['waehrung'], $f['verwendungszweck'], $f['ausfuehrung_am'],
$f['kategorie_id'], $f['klasse_id'], $id, Mandant::id(),
]
);
}
public static function loeschen(int $id): void
{
$auftrag = self::find($id);
if ($auftrag !== null && $auftrag['status'] === 'gesendet') {
throw new \RuntimeException('Ein gesendeter Auftrag bleibt als Beleg stehen.');
}
Database::execute('DELETE FROM zahlungsauftraege WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
}
public static function stornieren(int $id): void
{
Database::execute(
"UPDATE zahlungsauftraege SET status = 'storniert'
WHERE id = ? AND benutzer_id = ? AND status <> 'gesendet'",
[$id, Mandant::id()]
);
}
/**
* Vermerkt, dass die Bank den Auftrag angenommen hat, und bucht ihn auf dem
* Auftragskonto.
*/
public static function alsGesendetVermerken(int $id, string $meldung = ''): void
{
$auftrag = self::find($id);
if ($auftrag === null) {
return;
}
Database::transaktion(static function () use ($auftrag, $id, $meldung): void {
// Eine Terminüberweisung wird zum Ausführungstag gebucht, eine
// sofortige heute.
$datum = (string)($auftrag['ausfuehrung_am'] ?? '') ?: date('Y-m-d');
$buchungId = Buchung::anlegen([
'konto_id' => (int)$auftrag['konto_id'],
'datum' => $datum,
// Eine Überweisung geht ab: Der Betrag wird negativ gebucht.
'betrag' => -abs((float)$auftrag['betrag']),
'empfaenger' => (string)$auftrag['empfaenger'],
'buchungstext' => (string)$auftrag['verwendungszweck'],
'kategorie_id' => $auftrag['kategorie_id'],
'klasse_id' => $auftrag['klasse_id'],
'quelle' => 'fints',
]);
Database::execute(
"UPDATE zahlungsauftraege
SET status = 'gesendet', gesendet_am = NOW(), meldung = ?, buchung_id = ?
WHERE id = ? AND benutzer_id = ?",
[mb_substr($meldung, 0, 2000), $buchungId, $id, Mandant::id()]
);
});
}
public static function alsFehlerVermerken(int $id, string $meldung): void
{
Database::execute(
"UPDATE zahlungsauftraege SET status = 'fehler', meldung = ?
WHERE id = ? AND benutzer_id = ?",
[mb_substr($meldung, 0, 2000), $id, Mandant::id()]
);
}
public static function anzahlOffen(): int
{
return (int)Database::fetchValue(
"SELECT COUNT(*) FROM zahlungsauftraege WHERE benutzer_id = ? AND status = 'entwurf'",
[Mandant::id()],
0
);
}
/**
* @param array<string,mixed> $daten
* @return array<string,mixed>
*/
private static function normalisiere(array $daten): array
{
$kontoId = (int)($daten['konto_id'] ?? 0);
if ($kontoId === 0 || Konto::find($kontoId) === null) {
throw new \InvalidArgumentException('Es fehlt ein gültiges Auftragskonto.');
}
$empfaenger = trim((string)($daten['empfaenger'] ?? ''));
if ($empfaenger === '') {
throw new \InvalidArgumentException('Es fehlt der Empfänger.');
}
$iban = strtoupper(str_replace(' ', '', (string)($daten['iban'] ?? '')));
if (!Adresse::ibanIstGueltig($iban)) {
throw new \InvalidArgumentException('Die IBAN des Empfängers ist nicht gültig.');
}
$betrag = round((float)($daten['betrag'] ?? 0), 2);
if ($betrag <= 0.0) {
throw new \InvalidArgumentException('Der Betrag muss größer als null sein.');
}
$termin = trim((string)($daten['ausfuehrung_am'] ?? ''));
if ($termin !== '' && !Buchung::istDatum($termin)) {
throw new \InvalidArgumentException('Der Ausführungstermin muss als JJJJ-MM-TT vorliegen.');
}
if ($termin !== '' && $termin < date('Y-m-d')) {
throw new \InvalidArgumentException('Der Ausführungstermin liegt in der Vergangenheit.');
}
$id = static fn(mixed $wert): ?int =>
($wert === null || $wert === '' || (int)$wert === 0) ? null : (int)$wert;
return [
'konto_id' => $kontoId,
'zugang_id' => $id($daten['zugang_id'] ?? null),
'empfaenger' => mb_substr($empfaenger, 0, 255),
'iban' => $iban,
'bic' => mb_substr(strtoupper(trim((string)($daten['bic'] ?? ''))), 0, 11),
'betrag' => $betrag,
'waehrung' => mb_substr(strtoupper(trim((string)($daten['waehrung'] ?? 'EUR'))) ?: 'EUR', 0, 3),
// 140 Zeichen sind das SEPA-Maß für den Verwendungszweck; was
// darüber steht, würde die Bank ohnehin abschneiden.
'verwendungszweck' => mb_substr(trim((string)($daten['verwendungszweck'] ?? '')), 0, 140),
'ausfuehrung_am' => $termin !== '' ? $termin : null,
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
'klasse_id' => $id($daten['klasse_id'] ?? null),
];
}
}

View file

@ -162,6 +162,148 @@ class Auswertung
);
}
// ── Verteilung ───────────────────────────────────────────────────────────
/**
* Wie sich Einnahmen oder Ausgaben eines Zeitraums auf die obersten
* Kategorien verteilen – die Zahlen hinter der Cockpit-Kachel *Meine
* Ausgaben*.
*
* Zusammengefasst wird auf der obersten Ebene: „400 - Leben“ statt fünfzehn
* Unterkategorien. Ein Ring mit hundert Segmenten sagt nichts; wer es genauer
* braucht, klickt sich in den Summenbericht.
*
* Die Beträge kommen vorzeichenlos zurück – gedreht wie im Ausgabenabschnitt
* der Berichte, damit die Kachel 636,39 zeigt und nicht −636,39.
*
* @param array<string,mixed> $filter roh oder normalisiert
* @param int $grenze So viele Segmente einzeln, der Rest wird gesammelt.
* @return array{
* art: string,
* zeilen: list<array{id:int, name:string, betrag:float, anteil:float}>,
* summe: float,
* zeitraum: array{vorgabe:string, von:string, bis:string},
* beschriftung: string,
* leer: bool
* }
*/
public static function verteilung(
array $filter,
string $art = 'ausgabe',
int $grenze = 8,
?string $heute = null
): array {
$art = $art === 'einnahme' ? 'einnahme' : 'ausgabe';
$filter = self::normalisiere($filter);
$zeitraum = self::zeitraum($filter, $heute);
$filter['von'] = $zeitraum['von'];
$filter['bis'] = $zeitraum['bis'];
$arten = self::kategorienArten();
$wurzeln = self::wurzeln();
$faktor = $art === 'ausgabe' ? -1.0 : 1.0;
$summen = [];
foreach (self::bewegungen($filter) as $bewegung) {
if (self::artDerBewegung($bewegung, $arten) !== $art) {
continue;
}
$kategorie = (int)($bewegung['kategorie_id'] ?? 0);
$wurzel = $wurzeln[$kategorie] ?? 0;
$summen[$wurzel] = ($summen[$wurzel] ?? 0.0) + (float)$bewegung['betrag'] * $faktor;
}
return self::segmente($summen, $art, $grenze, $zeitraum);
}
/**
* Aus den Rohsummen je Wurzelkategorie werden die Segmente der Kachel:
* absteigend sortiert, auf `$grenze` gekürzt, mit Anteil an der Summe.
*
* Negative Werte fallen heraus – eine Erstattung, die eine Kategorie unter
* null drückt, hätte im Ring kein sinnvolles Segment. In der Summe steht sie
* weiter drin, sonst ergänzten sich die Anteile nicht mehr zu hundert.
*
* @param array<int,float> $summen
* @param array{vorgabe:string, von:string, bis:string} $zeitraum
* @return array<string,mixed>
*/
private static function segmente(array $summen, string $art, int $grenze, array $zeitraum): array
{
$namen = array_column(Kategorie::alle(), 'pfad', 'id');
$gesamt = round(array_sum($summen), 2);
arsort($summen);
$zeilen = [];
foreach ($summen as $id => $betrag) {
$betrag = round($betrag, 2);
if ($betrag <= 0.0) {
continue;
}
$zeilen[] = [
'id' => (int)$id,
'name' => $id === 0 ? self::OHNE_KATEGORIE : (string)($namen[$id] ?? self::OHNE_KATEGORIE),
'betrag' => $betrag,
'anteil' => 0.0,
];
}
if (count($zeilen) > $grenze) {
$rest = array_slice($zeilen, $grenze - 1);
$zeilen = array_slice($zeilen, 0, $grenze - 1);
$zeilen[] = [
'id' => 0,
'name' => 'Übrige (' . count($rest) . ')',
'betrag' => round(array_sum(array_column($rest, 'betrag')), 2),
'anteil' => 0.0,
];
}
$bezug = array_sum(array_column($zeilen, 'betrag'));
foreach ($zeilen as $nummer => $zeile) {
$zeilen[$nummer]['anteil'] = $bezug > 0.0 ? $zeile['betrag'] / $bezug : 0.0;
}
return [
'art' => $art,
'zeilen' => $zeilen,
'summe' => $gesamt,
'zeitraum' => $zeitraum,
'beschriftung' => Zeitraum::beschriftung($zeitraum['von'], $zeitraum['bis']),
'leer' => $zeilen === [],
];
}
/**
* Jede Kategorie auf ihre oberste Elternkategorie abgebildet. Über den Pfad
* statt über die eltern_id-Kette: Der Pfad trägt die Hierarchie ohnehin und
* erspart das Nachlaufen bei tiefen Bäumen.
*
* @return array<int,int> kategorie_id => id der Wurzel
*/
private static function wurzeln(): array
{
$alle = Kategorie::alle();
$nachPfad = [];
foreach ($alle as $kategorie) {
$nachPfad[(string)$kategorie['pfad']] = (int)$kategorie['id'];
}
$wurzeln = [];
foreach ($alle as $kategorie) {
$id = (int)$kategorie['id'];
$wurzel = explode(Kategorie::TRENNER, (string)$kategorie['pfad'])[0];
$wurzeln[$id] = $nachPfad[$wurzel] ?? $id;
}
return $wurzeln;
}
// ── Summenbericht ────────────────────────────────────────────────────────
/**

284
src/Services/Cockpit.php Normal file
View file

@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Erinnerung;
use Finanzmanager\Models\Monatsabschluss;
use Finanzmanager\View;
/**
* Das Cockpit: welche Kacheln es gibt, welche davon der Nutzer sehen will und
* woher ihre Zahlen kommen.
*
* Die Auswahl steht als kommagetrennte Liste in den Einstellungen (Reiter
* *Darstellung*) – die Reihenfolge der Liste ist die Reihenfolge im Raster.
* Eine eigene Tabelle wäre für eine Handvoll Schlüssel zu viel Apparat, und der
* Einstellungsdialog ist ohnehin der Ort, an dem Module ihre Optionen einhängen.
*/
class Cockpit
{
public const EINSTELLUNG = 'cockpit_kacheln';
/** Der Katalog, in der Reihenfolge der Vorgabe. */
public const KACHELN = [
'vermoegen' => [
'name' => 'Vermögensentwicklung',
'hinweis' => 'Kontostände der letzten zwölf Monate, je Monatsende gestapelt.',
],
'einnahmen_ausgaben' => [
'name' => 'Einnahmen und Ausgaben',
'hinweis' => 'Monatsvergleich der letzten zwölf Monate mit Saldo je Monat.',
],
'ausgaben' => [
'name' => 'Meine Ausgaben',
'hinweis' => 'Ausgaben des laufenden Jahres nach oberster Kategorie.',
],
'kontostaende' => [
'name' => 'Kontostände',
'hinweis' => 'Summe je Kontengruppe wie in der Kontenübersicht.',
],
'offene_vorgaenge' => [
'name' => 'Offene Vorgänge',
'hinweis' => 'Was auf Bearbeitung wartet: nicht kategorisiert, nicht abgeglichen, Monatsabschluss.',
],
];
/**
* Die sichtbaren Kacheln in ihrer Reihenfolge. Ohne gespeicherte Auswahl
* sind alle sichtbar – ein leeres Cockpit beim ersten Aufruf wäre nur
* verwirrend.
*
* @return list<string>
*/
public static function sichtbar(): array
{
$wert = Einstellung::get(self::EINSTELLUNG);
if ($wert === null) {
return array_keys(self::KACHELN);
}
$gewaehlt = [];
foreach (explode(',', $wert) as $schluessel) {
$schluessel = trim($schluessel);
if (isset(self::KACHELN[$schluessel]) && !in_array($schluessel, $gewaehlt, true)) {
$gewaehlt[] = $schluessel;
}
}
return $gewaehlt;
}
/**
* Speichert die Auswahl. Unbekannte Schlüssel fallen weg; die Reihenfolge
* ergibt sich aus dem Katalog und nicht aus der Reihenfolge im Formular –
* Ankreuzfelder kommen so, wie sie im HTML stehen.
*
* @param list<string> $schluessel
*/
public static function setzeSichtbar(array $schluessel): void
{
$gewaehlt = [];
foreach (array_keys(self::KACHELN) as $bekannt) {
if (in_array($bekannt, $schluessel, true)) {
$gewaehlt[] = $bekannt;
}
}
Einstellung::set(self::EINSTELLUNG, implode(',', $gewaehlt), 'darstellung');
}
public static function zeigt(string $kachel): bool
{
return in_array($kachel, self::sichtbar(), true);
}
/**
* Einnahmen, Ausgaben und Saldo je Monat der letzten zwölf Monate.
*
* Aufgesetzt auf den Summenbericht, nicht auf eine eigene Abfrage: Damit
* zählt die Kachel dieselben Bewegungen wie der Bericht – Splitzeilen
* einzeln, Umbuchungen gar nicht –, und beide Zahlen weichen nie voneinander
* ab.
*
* @return array{
* monate: list<array{schluessel:string, kurz:string, titel:string}>,
* einnahmen: list<float>,
* ausgaben: list<float>,
* salden: list<float>,
* summe_einnahmen: float,
* summe_ausgaben: float,
* beschriftung: string,
* hatWerte: bool
* }
*/
public static function monatsreihe(?string $heute = null): array
{
$bericht = Auswertung::bericht([
'typ' => 'summenbericht',
'zeitraum' => 'letzte_12_monate',
'aufteilung' => 'monat',
], $heute);
// Beide Abschnitte liefern ihre Summen bereits vorzeichenlos.
$reihen = ['einnahme' => [], 'ausgabe' => []];
foreach ($bericht['abschnitte'] as $abschnitt) {
foreach ($bericht['spalten'] as $spalte) {
$reihen[$abschnitt['schluessel']][] =
(float)($abschnitt['werte'][$spalte['schluessel']] ?? 0.0);
}
}
$einnahmen = $reihen['einnahme'];
$ausgaben = $reihen['ausgabe'];
$monate = [];
$salden = [];
foreach ($bericht['spalten'] as $nummer => $spalte) {
$monate[] = [
'schluessel' => $spalte['schluessel'],
'kurz' => $spalte['kurz'],
'titel' => $spalte['titel'],
];
$salden[] = round(($einnahmen[$nummer] ?? 0.0) - ($ausgaben[$nummer] ?? 0.0), 2);
}
return [
'monate' => $monate,
'einnahmen' => $einnahmen,
'ausgaben' => $ausgaben,
'salden' => $salden,
'summe_einnahmen' => round(array_sum($einnahmen), 2),
'summe_ausgaben' => round(array_sum($ausgaben), 2),
'beschriftung' => $bericht['beschriftung'],
'hatWerte' => array_sum($einnahmen) + array_sum($ausgaben) > 0.0,
];
}
/**
* Die Ausgaben des laufenden Jahres nach oberster Kategorie.
*
* @return array<string,mixed>
*/
public static function ausgaben(?string $heute = null): array
{
return Auswertung::verteilung(
['typ' => 'summenbericht', 'zeitraum' => 'aktuelles_jahr'],
'ausgabe',
8,
$heute
);
}
/**
* Was auf Bearbeitung wartet. Jeder Eintrag trägt seine Zahl und den Weg
* dorthin; ist nichts offen, bleibt die Liste leer und die Kachel meldet
* Ruhe.
*
* @return list<array{schluessel:string, text:string, anzahl:int, pfad:string}>
*/
public static function offeneVorgaenge(?string $heute = null): array
{
$benutzer = Mandant::id();
$vorgaenge = [];
$ohneKategorie = (int)Database::fetchValue(
'SELECT COUNT(*) FROM buchungen
WHERE benutzer_id = ? AND gesplittet = 0
AND kategorie_id IS NULL AND gegenkonto_id IS NULL',
[$benutzer],
0
);
if ($ohneKategorie > 0) {
$vorgaenge[] = [
'schluessel' => 'ohne_kategorie',
'text' => 'ohne Kategorie',
'anzahl' => $ohneKategorie,
// Der Buchungsbericht über den gesamten Zeitraum führt sie auf;
// „Nicht kategorisiert“ steht dort als eigene Zeile.
'pfad' => '/auswertungen/bericht?typ=buchungsbericht&zeitraum=alles&angepasst=1',
];
}
$nichtAbgeglichen = (int)Database::fetchValue(
'SELECT COUNT(*) FROM buchungen b
JOIN konten k ON k.id = b.konto_id
WHERE b.benutzer_id = ? AND b.abgeglichen = 0 AND k.aktiv = 1 AND b.datum <= ?',
[$benutzer, $heute ?? date('Y-m-d')],
0
);
if ($nichtAbgeglichen > 0) {
$vorgaenge[] = [
'schluessel' => 'nicht_abgeglichen',
'text' => 'nicht abgeglichen',
'anzahl' => $nichtAbgeglichen,
'pfad' => '/konten',
];
}
// Abgerufene Bankumsätze, die noch keiner Buchung zugeordnet sind.
$umsaetze = Bankumsatz::anzahlOffen();
if ($umsaetze > 0) {
$vorgaenge[] = [
'schluessel' => 'bankumsaetze',
'text' => 'zum Abgleich',
'anzahl' => $umsaetze,
'pfad' => '/banking/umsaetze',
];
}
// Fällige regelmäßige Buchungen – die automatischen zählen mit, denn
// gebucht sind sie erst nach dem Fälligkeitslauf.
$termine = Faelligkeitslauf::vorschau($heute);
if ($termine['faellig'] > 0) {
$vorgaenge[] = [
'schluessel' => 'termine',
'text' => 'fällige regelmäßige Buchungen',
'anzahl' => $termine['faellig'],
'pfad' => '/termine',
];
}
$erinnerungen = count(Erinnerung::faellige($heute));
if ($erinnerungen > 0) {
$vorgaenge[] = [
'schluessel' => 'erinnerungen',
'text' => 'fällige Erinnerungen',
'anzahl' => $erinnerungen,
'pfad' => '/termine/erinnerungen',
];
}
// Der Vorschlag ist immer ein Monat – fällig ist er nur, wenn er noch
// nicht abgeschlossen wurde und überhaupt Buchungen trägt.
$naechster = Monatsabschluss::naechsterOffener($heute);
$jahr = (int)$naechster['jahr'];
$monat = (int)$naechster['monat'];
$abschluss = Monatsabschluss::fuerMonat($jahr, $monat);
if ($abschluss === null || $abschluss['status'] !== Monatsabschluss::STATUS_ABGESCHLOSSEN) {
$zeitraum = Monatsabschluss::zeitraum($jahr, $monat);
$buchungen = (int)Database::fetchValue(
'SELECT COUNT(*) FROM buchungen WHERE benutzer_id = ? AND datum BETWEEN ? AND ?',
[$benutzer, $zeitraum[0], $zeitraum[1]],
0
);
if ($buchungen > 0) {
$vorgaenge[] = [
'schluessel' => 'monatsabschluss',
'text' => 'Monatsabschluss ' . View::monat($monat) . ' ' . $jahr . ' steht aus',
'anzahl' => 1,
'pfad' => '/planung/monatsabschluss',
];
}
}
return $vorgaenge;
}
}

123
src/Services/Crypto.php Normal file
View file

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Config;
use RuntimeException;
/**
* Symmetrische Verschlüsselung der Felder, die niemals im Klartext in der
* Datenbank stehen dürfen: FinTS-PIN und TAN-Medium.
*
* Übernommen aus dem Parallelprojekt `Steuerkram`, wo es sich bewährt hat.
*
* Format: `enc:v1:` + base64( iv(12) ‖ tag(16) ‖ Geheimtext ), Verfahren
* AES-256-GCM. Der Schlüssel ist `APP_KEY` aus der Umgebung – eine 64-stellige
* Hex-Zeichenkette. GCM authentifiziert mit: Ein verändertes Feld lässt sich
* nicht entschlüsseln, statt stillschweigend Unsinn zu liefern.
*
* `entschluessle()` reicht Werte ohne Kennung unverändert durch. Das ist der
* Weg für Bestandsdaten, die vor der Verschlüsselung angelegt wurden; neue
* Werte laufen immer durch `verschluessle()`.
*/
class Crypto
{
private const KENNUNG = 'enc:v1:';
private const VERFAHREN = 'aes-256-gcm';
private const IV_LAENGE = 12;
private const TAG_LAENGE = 16;
public static function verschluessle(string $klartext): string
{
if ($klartext === '') {
return '';
}
if (self::istVerschluesselt($klartext)) {
return $klartext;
}
$iv = random_bytes(self::IV_LAENGE);
$tag = '';
$geheim = openssl_encrypt(
$klartext,
self::VERFAHREN,
self::schluessel(),
OPENSSL_RAW_DATA,
$iv,
$tag,
'',
self::TAG_LAENGE
);
if ($geheim === false) {
throw new RuntimeException('Verschlüsseln fehlgeschlagen.');
}
return self::KENNUNG . base64_encode($iv . $tag . $geheim);
}
public static function entschluessle(string $wert): string
{
if ($wert === '' || !self::istVerschluesselt($wert)) {
return $wert;
}
$roh = base64_decode(substr($wert, strlen(self::KENNUNG)), true);
if ($roh === false || strlen($roh) < self::IV_LAENGE + self::TAG_LAENGE) {
throw new RuntimeException('Entschlüsseln: unbrauchbares Format.');
}
$klartext = openssl_decrypt(
substr($roh, self::IV_LAENGE + self::TAG_LAENGE),
self::VERFAHREN,
self::schluessel(),
OPENSSL_RAW_DATA,
substr($roh, 0, self::IV_LAENGE),
substr($roh, self::IV_LAENGE, self::TAG_LAENGE)
);
if ($klartext === false) {
throw new RuntimeException(
'Entschlüsseln fehlgeschlagen – falscher APP_KEY oder beschädigte Daten.'
);
}
return $klartext;
}
public static function istVerschluesselt(string $wert): bool
{
return str_starts_with($wert, self::KENNUNG);
}
/** Ist überhaupt ein brauchbarer Schlüssel hinterlegt? Für die Anzeige im Dialog. */
public static function einsatzbereit(): bool
{
try {
self::schluessel();
return true;
} catch (RuntimeException) {
return false;
}
}
private static function schluessel(): string
{
$hex = (string)Config::get('app_key', '');
if ($hex === '') {
throw new RuntimeException(
'APP_KEY ist nicht gesetzt. Ohne ihn lassen sich keine Zugangsdaten speichern.'
);
}
$schluessel = @hex2bin($hex);
if ($schluessel === false || strlen($schluessel) !== 32) {
throw new RuntimeException('APP_KEY muss eine 64-stellige Hex-Zeichenkette sein (32 Byte).');
}
return $schluessel;
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Psr\Log\AbstractLogger;
use Stringable;
/**
* Leitet die Meldungen der FinTS-Bibliothek in das Debug-Log der Anwendung.
*
* php-fints erwartet einen PSR-3-Logger; die Anwendung hat mit `DebugLog` einen
* eigenen, schlichteren. Dieser Adapter ist die Naht dazwischen: Er schreibt
* unter der Kategorie `fints` und nur, wenn das Debug-Log überhaupt
* eingeschaltet ist – ein FinTS-Dialog erzeugt sonst mehrere hundert Zeilen je
* Abruf.
*
* Die Bibliothek maskiert PIN und TAN bereits selbst (`SanitizingLogger`),
* `DebugLog` tut dasselbe noch einmal für den Kontext.
*/
class DebugLogAdapter extends AbstractLogger
{
/**
* @param mixed $level
* @param array<string,mixed> $context
*/
public function log($level, string|Stringable $message, array $context = []): void
{
if (!DebugLog::aktiv()) {
return;
}
DebugLog::schreibe(
'fints',
'[' . (is_scalar($level) ? (string)$level : 'info') . '] ' . (string)$message,
$context
);
}
}

326
src/Services/Depot.php Normal file
View file

@ -0,0 +1,326 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
/**
* Die Rechenmaschine hinter der Depotübersicht: aus Depotbuchungen und Kursen
* werden Bestände, Einstandswerte und die Wertentwicklung.
*
* **Einstandswert nach gleitendem Durchschnitt.** Bei jedem Kauf steigt der
* Einstand um Kaufpreis samt Gebühren, bei jedem Verkauf sinkt er anteilig um
* den bis dahin durchschnittlichen Stückpreis. Das ist die Rechnung, die auch
* der FinanzManager zeigt; sie kommt ohne die Frage aus, welches Stück
* eigentlich verkauft wurde. (Für die Steuer gilt in Deutschland FIFO – dafür
* ist die Bank zuständig, nicht diese Übersicht.)
*
* **Realisierte Gewinne** entstehen beim Verkauf: Erlös abzüglich des
* anteiligen Einstands.
*/
class Depot
{
/**
* Die Bestände eines Depots (oder aller Depots) zum Stichtag.
*
* @return array{
* positionen: list<array<string,mixed>>,
* einstand: float,
* wert: float,
* gewinn: float,
* realisiert: float,
* ertraege: float,
* bewertbar: bool
* }
*/
public static function bestand(?int $kontoId = null, ?string $stichtag = null): array
{
$stichtag ??= date('Y-m-d');
$kurse = Wertpapier::letzteKurse($stichtag);
$positionen = [];
$realisiert = 0.0;
$ertraege = 0.0;
foreach (self::buchungen($kontoId, $stichtag) as $buchung) {
$schluessel = (int)$buchung['wertpapier_id'] . ':' . (int)$buchung['konto_id'];
$position = $positionen[$schluessel] ?? [
'wertpapier_id' => (int)$buchung['wertpapier_id'],
'konto_id' => (int)$buchung['konto_id'],
'name' => (string)$buchung['wertpapier'],
'isin' => (string)$buchung['isin'],
'wkn' => (string)$buchung['wkn'],
'depot' => (string)$buchung['depot'],
'stueck' => 0.0,
'einstand' => 0.0,
'realisiert' => 0.0,
'ertraege' => 0.0,
];
$stueck = (float)$buchung['stueck'];
$betrag = (float)$buchung['betrag'];
$kosten = (float)$buchung['gebuehren'] + (float)$buchung['steuern'];
switch ((string)$buchung['vorgang']) {
case 'kauf':
case 'einlage':
// Nebenkosten gehören zum Einstand: Sie sind Teil dessen,
// was das Papier gekostet hat.
$position['stueck'] += $stueck;
$position['einstand'] += $betrag + $kosten;
break;
case 'verkauf':
case 'entnahme':
$abgang = abs($stueck);
$anteil = $position['stueck'] > 0.0
? min(1.0, $abgang / $position['stueck'])
: 1.0;
$anteiligerEinstand = round($position['einstand'] * $anteil, 2);
$position['stueck'] -= $abgang;
$position['einstand'] -= $anteiligerEinstand;
$position['realisiert'] += round($betrag - $kosten - $anteiligerEinstand, 2);
break;
case 'dividende':
$position['ertraege'] += $betrag - $kosten;
break;
}
$positionen[$schluessel] = $position;
}
$einstandGesamt = 0.0;
$wertGesamt = 0.0;
$bewertbar = true;
$fertig = [];
foreach ($positionen as $position) {
$realisiert += $position['realisiert'];
$ertraege += $position['ertraege'];
// Vollständig verkaufte Positionen verschwinden aus der Übersicht;
// ihr realisierter Gewinn zählt weiter mit.
if (round($position['stueck'], 6) <= 0.0) {
continue;
}
$kurs = $kurse[$position['wertpapier_id']] ?? null;
$position['stueck'] = round($position['stueck'], 6);
$position['einstand'] = round($position['einstand'], 2);
$position['kurs'] = $kurs['kurs'] ?? null;
$position['kurs_datum'] = $kurs['datum'] ?? null;
$position['einstand_je_stueck'] = $position['stueck'] > 0.0
? round($position['einstand'] / $position['stueck'], 4)
: null;
if ($kurs === null) {
// Ohne Kurs ist der Wert unbekannt. Der Einstand als Ersatz wäre
// bequem, verstellte aber den Blick: Die Übersicht sagt lieber,
// dass ein Kurs fehlt.
$position['wert'] = null;
$position['gewinn'] = null;
$bewertbar = false;
} else {
$position['wert'] = round($position['stueck'] * $kurs['kurs'], 2);
$position['gewinn'] = round($position['wert'] - $position['einstand'], 2);
$wertGesamt += $position['wert'];
}
$einstandGesamt += $position['einstand'];
$fertig[] = $position;
}
usort($fertig, static fn(array $a, array $b): int => strcasecmp($a['name'], $b['name']));
return [
'positionen' => $fertig,
'einstand' => round($einstandGesamt, 2),
'wert' => round($wertGesamt, 2),
'gewinn' => round($wertGesamt - $einstandGesamt, 2),
'realisiert' => round($realisiert, 2),
'ertraege' => round($ertraege, 2),
'bewertbar' => $bewertbar,
];
}
/**
* Der bewertete Depotwert je Konto – die Kontenübersicht braucht ihn für
* die Depotzeilen, wo ein Saldo aus Buchungen keinen Sinn ergäbe.
*
* @return array<int,float> konto_id => Wert
*/
public static function werteJeDepot(?string $stichtag = null): array
{
$werte = [];
foreach (self::depots() as $depot) {
$bestand = self::bestand((int)$depot['id'], $stichtag);
$werte[(int)$depot['id']] = $bestand['wert'];
}
return $werte;
}
/** Alle Konten der Kontoart „depot“. @return list<array<string,mixed>> */
public static function depots(bool $nurAktive = false): array
{
return array_values(array_filter(
Konto::alle($nurAktive),
static fn(array $konto): bool => (string)$konto['kontoart'] === 'depot'
));
}
/**
* Veräußerungsgeschäfte eines Zeitraums: je Verkauf der Erlös, der
* anteilige Einstand und der Gewinn.
*
* Gerechnet wird über die ganze Historie bis zum Verkaufstag – der Einstand
* eines im Mai verkauften Papiers hängt an allen Käufen davor, nicht nur an
* denen im Berichtszeitraum.
*
* @return array{geschaefte: list<array<string,mixed>>, gewinn: float, erloes: float}
*/
public static function veraeusserungen(string $von, string $bis, ?int $kontoId = null): array
{
$bestaende = [];
$geschaefte = [];
$gewinn = 0.0;
$erloes = 0.0;
foreach (self::buchungen($kontoId, $bis) as $buchung) {
$schluessel = (int)$buchung['wertpapier_id'] . ':' . (int)$buchung['konto_id'];
$stand = $bestaende[$schluessel] ?? ['stueck' => 0.0, 'einstand' => 0.0];
$betrag = (float)$buchung['betrag'];
$kosten = (float)$buchung['gebuehren'] + (float)$buchung['steuern'];
$stueck = (float)$buchung['stueck'];
if (in_array((string)$buchung['vorgang'], Depotbuchung::ZUGAENGE, true)) {
$stand['stueck'] += $stueck;
$stand['einstand'] += $betrag + $kosten;
} elseif (in_array((string)$buchung['vorgang'], Depotbuchung::ABGAENGE, true)) {
$abgang = abs($stueck);
$anteil = $stand['stueck'] > 0.0 ? min(1.0, $abgang / $stand['stueck']) : 1.0;
$anteiligerEinstand = round($stand['einstand'] * $anteil, 2);
$stand['stueck'] -= $abgang;
$stand['einstand'] -= $anteiligerEinstand;
$datum = (string)$buchung['datum'];
if ($datum >= $von && $datum <= $bis && (string)$buchung['vorgang'] === 'verkauf') {
$einzel = round($betrag - $kosten - $anteiligerEinstand, 2);
$geschaefte[] = [
'datum' => $datum,
'name' => (string)$buchung['wertpapier'],
'isin' => (string)$buchung['isin'],
'depot' => (string)$buchung['depot'],
'stueck' => $abgang,
'erloes' => round($betrag - $kosten, 2),
'einstand' => $anteiligerEinstand,
'gewinn' => $einzel,
];
$gewinn += $einzel;
$erloes += round($betrag - $kosten, 2);
}
}
$bestaende[$schluessel] = $stand;
}
return [
'geschaefte' => $geschaefte,
'gewinn' => round($gewinn, 2),
'erloes' => round($erloes, 2),
];
}
/**
* Erträge (Dividenden und Ausschüttungen) eines Zeitraums, je Wertpapier.
*
* @return array{zeilen: list<array<string,mixed>>, summe: float}
*/
public static function ertraege(string $von, string $bis, ?int $kontoId = null): array
{
$params = [Mandant::id(), $von, $bis];
$sql = "SELECT w.name, w.isin, SUM(d.betrag - d.gebuehren - d.steuern) AS summe,
COUNT(*) AS anzahl
FROM depot_buchungen d
JOIN wertpapiere w ON w.id = d.wertpapier_id
WHERE d.benutzer_id = ? AND d.vorgang = 'dividende'
AND d.datum BETWEEN ? AND ?";
if ($kontoId !== null) {
$sql .= ' AND d.konto_id = ?';
$params[] = $kontoId;
}
$zeilen = Database::fetchAll($sql . ' GROUP BY w.id ORDER BY summe DESC', $params);
$summe = 0.0;
foreach ($zeilen as $nummer => $zeile) {
$zeilen[$nummer]['summe'] = round((float)$zeile['summe'], 2);
$summe += $zeilen[$nummer]['summe'];
}
return ['zeilen' => $zeilen, 'summe' => round($summe, 2)];
}
/**
* Die Wertentwicklung eines Depots über die letzten Monate: je Monatsende
* der bewertete Bestand.
*
* @return list<array{monat: string, kuerzel: string, wert: float, einstand: float}>
*/
public static function entwicklung(?int $kontoId = null, int $monate = 12, ?string $heute = null): array
{
$reihe = [];
foreach (Vermoegensentwicklung::monate($monate, $heute) as $monat) {
$bestand = self::bestand($kontoId, $monat['bis']);
$reihe[] = [
'monat' => sprintf('%04d-%02d', $monat['jahr'], $monat['monat']),
'kuerzel' => $monat['kuerzel'],
'wert' => $bestand['wert'],
'einstand' => $bestand['einstand'],
];
}
return $reihe;
}
/**
* Alle Depotbuchungen bis zum Stichtag, aufsteigend – die Reihenfolge
* entscheidet über den gleitenden Durchschnitt.
*
* @return list<array<string,mixed>>
*/
private static function buchungen(?int $kontoId, string $bis): array
{
$params = [Mandant::id(), $bis];
$sql = 'SELECT d.*, w.name AS wertpapier, w.isin, w.wkn, k.name AS depot
FROM depot_buchungen d
JOIN wertpapiere w ON w.id = d.wertpapier_id
JOIN konten k ON k.id = d.konto_id
WHERE d.benutzer_id = ? AND d.datum <= ?';
if ($kontoId !== null) {
$sql .= ' AND d.konto_id = ?';
$params[] = $kontoId;
}
return Database::fetchAll($sql . ' ORDER BY d.datum, d.id', $params);
}
}

View file

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Models\Monatsabschluss;
use Finanzmanager\Models\RegelmaessigeBuchung;
/**
* Der Fälligkeitslauf: bucht, was ansteht.
*
* Zwei Betriebsarten je Reihe:
*
* * **automatisch** – die Buchung entsteht ohne Rückfrage. Für Miete und
* Gehalt, wo Betrag und Termin feststehen.
* * **auf Bestätigung** – die Reihe wird nur gemeldet; gebucht wird sie erst
* auf Knopfdruck. Für alles, dessen Betrag schwankt.
*
* Der Lauf ist gutmütig gegenüber langen Pausen: Liegen mehrere Termine
* zurück, wird jeder einzeln gebucht, nicht einer für alle. Nur so stimmen die
* Monatssummen.
*/
class Faelligkeitslauf
{
/** Sicherung gegen Endlosschleifen bei kaputten Reihen. */
private const MAX_BUCHUNGEN_JE_REIHE = 60;
/**
* @return array{
* gebucht: int,
* wartend: list<array<string,mixed>>,
* gesperrt: list<string>,
* protokoll: list<string>
* }
*/
public static function ausfuehren(?string $bis = null): array
{
$bis = $bis ?? date('Y-m-d');
$gebucht = 0;
$wartend = [];
$gesperrt = [];
$protokoll = [];
foreach (RegelmaessigeBuchung::faellige($bis) as $reihe) {
if ((int)$reihe['automatisch'] !== 1) {
$wartend[] = $reihe;
continue;
}
$id = (int)$reihe['id'];
$laeufe = 0;
// Je Reihe so lange buchen, wie Termine zurückliegen.
while ($laeufe < self::MAX_BUCHUNGEN_JE_REIHE) {
$aktuell = RegelmaessigeBuchung::find($id);
$termin = (string)($aktuell['naechste_faelligkeit'] ?? '');
if ($aktuell === null || (int)$aktuell['aktiv'] !== 1 || $termin === '' || $termin > $bis) {
break;
}
// In einen abgeschlossenen Monat darf nichts hineingebucht
// werden. Die Reihe bleibt stehen und meldet sich, statt den
// Lauf mit einer Ausnahme abzubrechen.
if (Monatsabschluss::istGesperrt($termin)) {
$gesperrt[] = $aktuell['name'] . ' (' . $termin . ')';
break;
}
try {
RegelmaessigeBuchung::buchen($id);
$gebucht++;
$protokoll[] = $termin . ' ' . $aktuell['name'] . ': gebucht.';
} catch (\Throwable $e) {
$protokoll[] = $termin . ' ' . $aktuell['name'] . ': ' . $e->getMessage();
break;
}
$laeufe++;
}
}
return [
'gebucht' => $gebucht,
'wartend' => $wartend,
'gesperrt' => $gesperrt,
'protokoll' => $protokoll,
];
}
/**
* Was ansteht, ohne zu buchen – für die Anzeige in den offenen Vorgängen.
*
* @return array{faellig: int, automatisch: int, wartend: int}
*/
public static function vorschau(?string $bis = null): array
{
$faellig = RegelmaessigeBuchung::faellige($bis);
$automatisch = 0;
foreach ($faellig as $reihe) {
if ((int)$reihe['automatisch'] === 1) {
$automatisch++;
}
}
return [
'faellig' => count($faellig),
'automatisch' => $automatisch,
'wartend' => count($faellig) - $automatisch,
];
}
}

739
src/Services/FinTsAbruf.php Normal file
View file

@ -0,0 +1,739 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Fhp\Action\GetBalance;
use Fhp\Action\GetSEPAAccounts;
use Fhp\Action\GetStatementOfAccount;
use Fhp\BaseAction;
use Fhp\FinTs;
use Fhp\Model\SEPAAccount;
use Fhp\Model\StatementOfAccount\Transaction;
use Fhp\Model\TanRequestChallengeImage;
use Fhp\Options\Credentials;
use Fhp\Options\FinTsOptions;
use Finanzmanager\Auth;
use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\Models\Bankabruf;
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Einstellung;
/**
* Der Umsatzabruf über FinTS.
*
* **Warum das über mehrere Requests läuft.** Fast jede Bank verlangt beim Login
* eine TAN – schon für den reinen Lesezugriff. Zwischen Anforderung und Eingabe
* liegt eine Interaktion des Nutzers, also mindestens ein weiterer Request. Die
* Bibliothek sieht dafür `FinTs::persist()` und das Serialisieren der laufenden
* Aktion vor; beides wird hier in der Sitzung abgelegt und beim nächsten Request
* wieder geladen. Ein Warteschleifen-Prozess, der die TAN abwartet, käme mit
* php-fpm nicht weit.
*
* **Was in der Sitzung liegt.** Der persistierte Dialog (er enthält keine PIN –
* die Bibliothek schließt sie ausdrücklich aus), die serialisierte Aktion und
* der Punkt, an dem weiterzumachen ist. Die PIN selbst wird bei jedem Request
* neu aus der Datenbank geholt und entschlüsselt.
*
* **Der Ablauf** ist eine kleine Zustandsmaschine:
*
* anmelden → konten → umsaetze (je Konto) → fertig
*
* Jeder Schritt kann eine TAN verlangen. Dann wird der Zustand gesichert und der
* Aufrufer bekommt `tan`; nach `tanEinreichen()` läuft es an derselben Stelle
* weiter. Umsätze, die vorher schon geschrieben wurden, bleiben in der Datenbank
* – ein Abbruch mittendrin verliert also keine Arbeit.
*/
class FinTsAbruf
{
/** Schlüssel des Abrufzustands in der Sitzung. */
public const SITZUNG = 'fints_abruf';
/** Produktkennung gegenüber der Bank. Ohne eigene Registrierung die von phpFinTS. */
private const PRODUKT = 'F399A6377D4D60E0A222D677A';
private const PRODUKT_VERSION = '1.0';
/** @var list<string> */
private array $protokoll = [];
private FinTs $fints;
/** @param array<string,mixed> $zugang mit entschlüsselter PIN */
private function __construct(
private array $zugang,
private int $abrufId,
private array $zustand
) {
}
/**
* Startet einen Abruf.
*
* @return array{status: string, meldung: string, protokoll: list<string>,
* neu: int, uebersprungen: int, tan?: array<string,mixed>}
*/
public static function starten(int $zugangId): array
{
$zugang = Bankzugang::zugangsdaten($zugangId);
if ($zugang === null) {
return self::ergebnis('fehler', 'Den Bankzugang gibt es nicht.', []);
}
foreach (['blz' => 'Bankleitzahl', 'fints_url' => 'FinTS-Adresse',
'benutzerkennung' => 'Benutzerkennung', 'pin' => 'PIN'] as $feld => $name) {
if (trim((string)$zugang[$feld]) === '') {
return self::ergebnis('fehler', 'Es fehlt: ' . $name . '.', []);
}
}
self::vergessen();
$abruf = new self($zugang, Bankabruf::beginnen($zugangId), [
'schritt' => 'anmelden',
'konto_nummer' => 0,
'neu' => 0,
'uebersprungen' => 0,
'protokoll' => [],
]);
return $abruf->lauf(null);
}
/**
* Setzt einen Abruf fort, der auf eine TAN wartete.
*
* @return array{status: string, meldung: string, protokoll: list<string>,
* neu: int, uebersprungen: int, tan?: array<string,mixed>}
*/
public static function tanEinreichen(string $tan): array
{
$zustand = self::zustandLesen();
if ($zustand === null) {
return self::ergebnis('fehler', 'Es wartet kein Abruf auf eine TAN.', []);
}
$zugang = Bankzugang::zugangsdaten((int)$zustand['zugang_id']);
if ($zugang === null) {
self::vergessen();
return self::ergebnis('fehler', 'Den Bankzugang gibt es nicht mehr.', []);
}
$abruf = new self($zugang, (int)$zustand['abruf_id'], $zustand);
return $abruf->lauf(trim($tan));
}
/** Wartet gerade ein Abruf auf eine TAN? */
public static function wartetAufTan(): bool
{
return self::zustandLesen() !== null;
}
/** @return array<string,mixed>|null Die Angaben für die TAN-Maske. */
public static function offeneTanAnfrage(): ?array
{
$zustand = self::zustandLesen();
return $zustand === null ? null : ($zustand['tan'] ?? null);
}
public static function abbrechen(): void
{
$zustand = self::zustandLesen();
if ($zustand !== null) {
Bankabruf::abschliessen(
(int)$zustand['abruf_id'],
'abgebrochen',
(int)$zustand['neu'],
(int)$zustand['uebersprungen'],
'Vom Nutzer abgebrochen.',
$zustand['protokoll'] ?? []
);
}
self::vergessen();
}
// ── Ablauf ───────────────────────────────────────────────────────────────
/**
* Ein Durchlauf: Verbindung herstellen (oder wiederherstellen), gegebenenfalls
* die TAN einreichen und dann so weit laufen, wie es ohne Rückfrage geht.
*
* @return array<string,mixed>
*/
private function lauf(?string $tan): array
{
$this->protokoll = $this->zustand['protokoll'] ?? [];
try {
$this->fints = $this->verbindung();
} catch (\Throwable $e) {
return $this->beenden('fehler', 'Verbindung fehlgeschlagen: ' . $e->getMessage());
}
// Eine wartende Aktion zuerst abschließen – erst danach geht es im
// Ablauf weiter.
if ($tan !== null && isset($this->zustand['aktion'])) {
$antwort = $this->tanAbschliessen($tan);
if ($antwort !== null) {
return $antwort;
}
}
try {
return $this->abarbeiten();
} catch (\Throwable $e) {
DebugLog::fehler($e);
return $this->beenden('fehler', $e->getMessage());
}
}
/** @return array<string,mixed> */
private function abarbeiten(): array
{
if ($this->zustand['schritt'] === 'anmelden') {
$anmeldung = $this->fints->login();
$wartet = $this->pruefeTan($anmeldung, 'Anmeldung', 'anmelden');
if ($wartet !== null) {
return $wartet;
}
$this->notiere('Angemeldet bei ' . ($this->zugang['bankname'] ?: $this->zugang['name']) . '.');
$this->zustand['schritt'] = 'konten';
}
if ($this->zustand['schritt'] === 'konten') {
$aktion = GetSEPAAccounts::create();
$this->fints->execute($aktion);
$wartet = $this->pruefeTan($aktion, 'Kontenabfrage', 'konten');
if ($wartet !== null) {
return $wartet;
}
$this->merkeKonten($aktion->getAccounts());
$this->zustand['schritt'] = 'umsaetze';
}
return $this->umsaetzeAbrufen();
}
/**
* Läuft die Konten des Zugangs durch. Der Index steht im Zustand: Verlangt
* ein Konto eine TAN, geht es danach genau dort weiter.
*
* @return array<string,mixed>
*/
private function umsaetzeAbrufen(): array
{
$konten = $this->abzurufendeKonten();
[$von, $beschreibung] = Bankzugang::startdatum(
(string)$this->zugang['abrufzeitraum'],
$this->zugang['zuletzt_am'] === null ? null : (string)$this->zugang['zuletzt_am']
);
if ($this->zustand['konto_nummer'] === 0) {
$this->notiere('Zeitraum: ' . $beschreibung . ' (ab ' . $von->format('d.m.Y') . ').');
}
$regeln = Einstellung::getBool('banking_regeln_beim_abruf', true) ? AbgleichRegel::alle(true) : [];
for ($nummer = (int)$this->zustand['konto_nummer']; $nummer < count($konten); $nummer++) {
$this->zustand['konto_nummer'] = $nummer;
$bankkonto = $konten[$nummer];
$sepa = $this->sepaKonto($bankkonto);
if ($sepa === null) {
$this->notiere('Übersprungen: ' . ($bankkonto['iban'] ?: $bankkonto['kontonummer'])
. ' – die Bank führt dieses Konto nicht mehr.');
continue;
}
$aktion = GetStatementOfAccount::create(
$sepa,
\DateTime::createFromImmutable($von),
new \DateTime('today'),
false,
true
);
$this->fints->execute($aktion);
$wartet = $this->pruefeTan($aktion, 'Umsätze ' . ($bankkonto['iban'] ?: $bankkonto['kontonummer']), 'umsaetze');
if ($wartet !== null) {
return $wartet;
}
$this->schreibeUmsaetze($bankkonto, $aktion->getStatement()->getStatements(), $regeln);
$this->saldoHolen($sepa, (int)$bankkonto['id']);
}
Bankzugang::merkeAbruf((int)$this->zugang['id']);
return $this->beenden('erfolg', sprintf(
'%d neue Umsätze, %d bereits bekannt.',
$this->zustand['neu'],
$this->zustand['uebersprungen']
));
}
/**
* Schreibt die Umsätze eines Kontos weg.
*
* @param array<string,mixed> $bankkonto
* @param list<mixed> $auszuege
* @param list<array<string,mixed>> $regeln
*/
private function schreibeUmsaetze(array $bankkonto, array $auszuege, array $regeln): void
{
$neu = 0;
$alt = 0;
foreach ($auszuege as $auszug) {
foreach ($auszug->getTransactions() as $umsatz) {
/** @var Transaction $umsatz */
$buchungstag = $umsatz->getBookingDate() ?: $umsatz->getValutaDate() ?: new \DateTime();
$valuta = $umsatz->getValutaDate() ?: $buchungstag;
// Die Bibliothek liefert den Betrag ohne Vorzeichen; die Richtung
// steht in getCreditDebit().
$betrag = round(
(float)$umsatz->getAmount() * ($umsatz->getCreditDebit() === Transaction::CD_DEBIT ? -1 : 1),
2
);
$zweck = trim($umsatz->getMainDescription());
$empfaenger = trim($umsatz->getName());
$externId = Bankumsatz::externId(
(string)($bankkonto['iban'] ?: $bankkonto['kontonummer']),
$buchungstag->format('Y-m-d'),
$betrag,
$zweck,
$empfaenger
);
$id = Bankumsatz::anlegen([
'zugang_id' => (int)$this->zugang['id'],
'konto_id' => $bankkonto['konto_id'],
'extern_id' => $externId,
'buchungstag' => $buchungstag->format('Y-m-d'),
'wertstellung' => $valuta->format('Y-m-d'),
'betrag' => $betrag,
'empfaenger' => $empfaenger,
'verwendungszweck' => $zweck,
'buchungsart' => trim($umsatz->getBookingText()),
'roh' => json_encode([
'iban' => $bankkonto['iban'],
'eref' => $umsatz->getEndToEndID(),
'code' => $umsatz->getBookingCode(),
'name' => $empfaenger,
'zweck' => $zweck,
], JSON_UNESCAPED_UNICODE),
]);
if ($id === null) {
$alt++;
continue;
}
$neu++;
// Die Regel zählt ihren Treffer schon beim Abruf: Die Liste der
// Regeln zeigt damit, welche im Alltag überhaupt greifen.
if ($regeln !== []) {
$regel = AbgleichRegel::passendeRegel(
['empfaenger' => $empfaenger, 'verwendungszweck' => $zweck, 'betrag' => $betrag],
$regeln
);
if ($regel !== null) {
AbgleichRegel::zaehleTreffer((int)$regel['id']);
}
}
}
}
$this->zustand['neu'] += $neu;
$this->zustand['uebersprungen'] += $alt;
$this->notiere(sprintf(
'%s: %d neu, %d bereits bekannt.',
$bankkonto['iban'] ?: $bankkonto['kontonummer'],
$neu,
$alt
));
}
private function saldoHolen(SEPAAccount $sepa, int $bankKontoId): void
{
try {
$aktion = GetBalance::create($sepa);
$this->fints->execute($aktion);
// Für den Saldo wird keine TAN erfragt: Er ist eine Zugabe, und eine
// zweite TAN-Runde nur für eine Zahl wäre dem Nutzer nicht zu erklären.
if ($aktion->needsTan()) {
$this->notiere(' Saldo übersprungen – die Bank verlangt dafür eine eigene TAN.');
return;
}
$saldo = null;
foreach ($aktion->getBalances() as $stand) {
$saldo = ($saldo ?? 0.0) + $stand->getGebuchterSaldo()->getAmount();
}
if ($saldo !== null) {
Bankzugang::merkeSaldo($bankKontoId, round($saldo, 2));
$this->notiere(' Kontostand: ' . number_format($saldo, 2, ',', '.') . ' €');
}
} catch (\Throwable $e) {
$this->notiere(' Kontostand nicht abrufbar: ' . $e->getMessage());
}
}
// ── TAN ──────────────────────────────────────────────────────────────────
/**
* Verlangt die Aktion eine TAN, wird der Zustand gesichert und der Aufrufer
* bekommt die Angaben für die Eingabemaske.
*
* @return array<string,mixed>|null null, wenn es ohne TAN weitergeht
*/
private function pruefeTan(BaseAction $aktion, string $bezeichnung, string $schritt): ?array
{
if (!$aktion->needsTan()) {
return null;
}
$verfahren = $this->fints->getSelectedTanMode();
$anfrage = $aktion->getTanRequest();
// Entkoppelte Verfahren (pushTAN-App): Die Freigabe passiert im Gerät des
// Nutzers, wir fragen nur nach. Der Nutzer drückt in der Maske auf
// „Freigabe prüfen“, statt eine Nummer abzutippen.
$entkoppelt = $verfahren !== null && $verfahren->isDecoupled();
$tan = [
'bezeichnung' => $bezeichnung,
'hinweis' => $anfrage?->getChallenge() ?? '',
'medium' => $anfrage?->getTanMediumName() ?? '',
'entkoppelt' => $entkoppelt,
'bild_typ' => null,
'bild_daten' => null,
];
if (!$entkoppelt && $anfrage?->getChallengeHhdUc()) {
try {
$bild = new TanRequestChallengeImage($anfrage->getChallengeHhdUc());
$tan['bild_typ'] = $bild->getMimeType();
$tan['bild_daten'] = base64_encode($bild->getData());
} catch (\Throwable) {
// Kein photoTAN-Bild (etwa chipTAN-Flicker) – dann eben ohne.
}
}
$this->notiere('TAN erforderlich: ' . $bezeichnung . '.');
$this->zustand['schritt'] = $schritt;
$this->zustand['tan'] = $tan;
$this->zustand['aktion'] = serialize($aktion);
return $this->beenden('tan', 'Die Bank verlangt eine Freigabe.', $tan);
}
/**
* Reicht die TAN für die wartende Aktion nach.
*
* @return array<string,mixed>|null Fehlerergebnis, oder null bei Erfolg
*/
private function tanAbschliessen(string $tan): ?array
{
$aktion = unserialize((string)$this->zustand['aktion'], ['allowed_classes' => true]);
if (!$aktion instanceof BaseAction) {
return $this->beenden('fehler', 'Der wartende Vorgang ist nicht mehr lesbar. Bitte neu abrufen.');
}
$entkoppelt = ($this->zustand['tan']['entkoppelt'] ?? false) === true;
try {
if ($entkoppelt) {
// Kein Warten im Request: Ein Klick, eine Nachfrage. Steht die
// Freigabe noch aus, bleibt die Maske stehen.
if (!$this->fints->checkDecoupledSubmission($aktion)) {
$this->zustand['aktion'] = serialize($aktion);
return $this->beenden(
'tan',
'Noch keine Freigabe eingegangen. Im Banking-Gerät bestätigen und erneut prüfen.',
$this->zustand['tan']
);
}
} else {
if ($tan === '') {
return $this->beenden('tan', 'Bitte die TAN eingeben.', $this->zustand['tan']);
}
$this->fints->submitTan($aktion, $tan);
}
} catch (\Throwable $e) {
return $this->beenden('fehler', 'Die Freigabe wurde abgelehnt: ' . $e->getMessage());
}
$this->notiere('Freigabe angenommen.');
unset($this->zustand['aktion'], $this->zustand['tan']);
// Die Aktion war der letzte Schritt vor dem Weitermachen: Ihr Ergebnis
// wird gebraucht, wenn es die Kontenliste war.
if ($this->zustand['schritt'] === 'konten' && $aktion instanceof GetSEPAAccounts) {
$this->merkeKonten($aktion->getAccounts());
$this->zustand['schritt'] = 'umsaetze';
} elseif ($this->zustand['schritt'] === 'anmelden') {
$this->zustand['schritt'] = 'konten';
} elseif ($this->zustand['schritt'] === 'umsaetze' && $aktion instanceof GetStatementOfAccount) {
$konten = $this->abzurufendeKonten();
$konto = $konten[(int)$this->zustand['konto_nummer']] ?? null;
if ($konto !== null) {
$regeln = Einstellung::getBool('banking_regeln_beim_abruf', true) ? AbgleichRegel::alle(true) : [];
$this->schreibeUmsaetze($konto, $aktion->getStatement()->getStatements(), $regeln);
$this->zustand['konto_nummer'] = (int)$this->zustand['konto_nummer'] + 1;
}
}
return null;
}
// ── Verbindung und Zustand ───────────────────────────────────────────────
private function verbindung(): FinTs
{
$optionen = new FinTsOptions();
$optionen->url = (string)$this->zugang['fints_url'];
$optionen->bankCode = (string)$this->zugang['blz'];
$optionen->productName = Einstellung::get('banking_produkt_id') ?: self::PRODUKT;
$optionen->productVersion = self::PRODUKT_VERSION;
$zugangsdaten = Credentials::create(
(string)$this->zugang['benutzerkennung'],
(string)$this->zugang['pin']
);
$fints = FinTs::new($optionen, $zugangsdaten, $this->zustand['dialog'] ?? null);
$fints->setLogger(new DebugLogAdapter());
// Beim ersten Lauf steht noch kein Verfahren fest. Gespeichert wird die
// Wahl am Zugang, damit die Bank nicht bei jedem Abruf danach fragt.
if (!isset($this->zustand['dialog'])) {
$this->waehleTanVerfahren($fints);
}
return $fints;
}
private function waehleTanVerfahren(FinTs $fints): void
{
$verfahren = $fints->getTanModes();
if ($verfahren === []) {
throw new \RuntimeException('Die Bank meldet keine TAN-Verfahren.');
}
$gewuenscht = $this->zugang['tan_verfahren_id'] === null
? null
: (int)$this->zugang['tan_verfahren_id'];
$gewaehlt = null;
foreach ($verfahren as $modus) {
if ($gewuenscht !== null && $modus->getId() === $gewuenscht) {
$gewaehlt = $modus;
break;
}
}
$gewaehlt ??= reset($verfahren);
$medium = null;
$mediumName = '';
if ($gewaehlt->needsTanMedium()) {
$medien = $fints->getTanMedia($gewaehlt);
if ($medien === []) {
throw new \RuntimeException(
'Das Verfahren „' . $gewaehlt->getName() . '“ braucht ein TAN-Medium, '
. 'die Bank nennt aber keines.'
);
}
$gespeichert = (string)$this->zugang['tan_medium'];
foreach ($medien as $kandidat) {
if ($gespeichert !== '' && $kandidat->getName() === $gespeichert) {
$medium = $kandidat;
break;
}
}
$medium ??= reset($medien);
$mediumName = (string)$medium->getName();
}
$fints->selectTanMode($gewaehlt, $medium);
$this->notiere('TAN-Verfahren: ' . $gewaehlt->getName()
. ($mediumName !== '' ? ' (' . $mediumName . ')' : ''));
if ($gewuenscht !== $gewaehlt->getId() || $mediumName !== (string)$this->zugang['tan_medium']) {
Bankzugang::merkeTanVerfahren((int)$this->zugang['id'], $gewaehlt->getId(), $mediumName);
}
}
/**
* Die Konten dieses Zugangs, die abgerufen werden sollen.
*
* @return list<array<string,mixed>>
*/
private function abzurufendeKonten(): array
{
return array_values(array_filter(
Bankzugang::konten((int)$this->zugang['id']),
static fn(array $konto): bool => (int)$konto['abrufen'] === 1
));
}
/** @param list<SEPAAccount> $konten */
private function merkeKonten(array $konten): void
{
$this->zustand['sepa'] = [];
foreach ($konten as $konto) {
$eintrag = Bankzugang::findeOderLegeKontoAn(
(int)$this->zugang['id'],
(string)$konto->getIban(),
(string)$konto->getAccountNumber(),
(string)$konto->getIban() ?: (string)$konto->getAccountNumber()
);
// Die SEPAAccount-Objekte werden für den nächsten Request gebraucht,
// wenn eine TAN dazwischenkommt – sie sind schlichte Wertobjekte und
// lassen sich gefahrlos serialisieren.
$this->zustand['sepa'][self::kennung($eintrag)] = serialize($konto);
}
$this->notiere(count($konten) . ' Konto/Konten bei der Bank gefunden.');
}
/** @param array<string,mixed> $bankkonto */
private function sepaKonto(array $bankkonto): ?SEPAAccount
{
$roh = $this->zustand['sepa'][self::kennung($bankkonto)] ?? null;
if (!is_string($roh)) {
return null;
}
$konto = unserialize($roh, ['allowed_classes' => [SEPAAccount::class]]);
return $konto instanceof SEPAAccount ? $konto : null;
}
/** @param array<string,mixed> $konto */
private static function kennung(array $konto): string
{
return strtoupper((string)($konto['iban'] ?? '')) . '|' . (string)($konto['kontonummer'] ?? '');
}
private function notiere(string $zeile): void
{
$this->protokoll[] = date('H:i:s') . ' ' . $zeile;
DebugLog::schreibe('fints', $zeile);
}
/**
* Beendet den Durchlauf: Protokoll wegschreiben und den Zustand entweder
* sichern (wenn eine TAN aussteht) oder vergessen.
*
* @param array<string,mixed>|null $tan
* @return array<string,mixed>
*/
private function beenden(string $status, string $meldung, ?array $tan = null): array
{
$this->protokoll[] = date('H:i:s') . ' ' . $meldung;
$this->zustand['protokoll'] = $this->protokoll;
Bankabruf::abschliessen(
$this->abrufId,
$status === 'tan' ? 'tan_offen' : $status,
(int)$this->zustand['neu'],
(int)$this->zustand['uebersprungen'],
$meldung,
$this->protokoll
);
if ($status === 'tan') {
$this->zustand['dialog'] = $this->fints->persist();
$this->zustand['zugang_id'] = (int)$this->zugang['id'];
$this->zustand['abruf_id'] = $this->abrufId;
self::zustandSchreiben($this->zustand);
} else {
self::vergessen();
try {
$this->fints->close();
} catch (\Throwable) {
// Der Dialog ist ohnehin zu Ende; ein Fehler beim Abmelden
// ändert am Ergebnis nichts.
}
}
return self::ergebnis(
$status,
$meldung,
$this->protokoll,
(int)$this->zustand['neu'],
(int)$this->zustand['uebersprungen'],
$tan
);
}
/**
* @param list<string> $protokoll
* @param array<string,mixed>|null $tan
* @return array<string,mixed>
*/
private static function ergebnis(
string $status,
string $meldung,
array $protokoll,
int $neu = 0,
int $uebersprungen = 0,
?array $tan = null
): array {
$ergebnis = [
'status' => $status,
'meldung' => $meldung,
'protokoll' => $protokoll,
'neu' => $neu,
'uebersprungen' => $uebersprungen,
];
if ($tan !== null) {
$ergebnis['tan'] = $tan;
}
return $ergebnis;
}
/** @return array<string,mixed>|null */
private static function zustandLesen(): ?array
{
Auth::startSession();
$zustand = $_SESSION[self::SITZUNG] ?? null;
return is_array($zustand) ? $zustand : null;
}
/** @param array<string,mixed> $zustand */
private static function zustandSchreiben(array $zustand): void
{
Auth::startSession();
$_SESSION[self::SITZUNG] = $zustand;
}
private static function vergessen(): void
{
Auth::startSession();
unset($_SESSION[self::SITZUNG]);
}
}

View file

@ -0,0 +1,400 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Fhp\Action\GetSEPAAccounts;
use Fhp\Action\SendSEPATransfer;
use Fhp\BaseAction;
use Fhp\FinTs;
use Fhp\Model\SEPAAccount;
use Fhp\Model\TanRequestChallengeImage;
use Fhp\Options\Credentials;
use Fhp\Options\FinTsOptions;
use Finanzmanager\Auth;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Zahlungsauftrag;
/**
* Sendet einen Zahlungsauftrag über FinTS ab.
*
* Aufgebaut wie `FinTsAbruf`, nur kürzer: anmelden, Konto suchen, Überweisung
* einreichen. Eine TAN ist hier die Regel und nicht die Ausnahme – für
* Überweisungen verlangt sie jede Bank. Der Dialog wird deshalb genauso über
* die Sitzung fortgesetzt.
*
* **Erst die Bank, dann die Buchung.** Die Buchung auf dem Auftragskonto
* entsteht erst, wenn die Bank den Auftrag angenommen hat. Vorher wäre sie eine
* Behauptung – und eine, die man nach einer abgelehnten TAN wieder einsammeln
* müsste.
*/
class FinTsUeberweisung
{
public const SITZUNG = 'fints_ueberweisung';
private const PRODUKT = 'F399A6377D4D60E0A222D677A';
private const PRODUKT_VERSION = '1.0';
/**
* @param array<string,mixed> $zugang mit entschlüsselter PIN
* @param array<string,mixed> $zustand
*/
private function __construct(
private array $zugang,
private int $auftragId,
private array $zustand
) {
}
/**
* Startet das Absenden.
*
* @return array{status: string, meldung: string, tan?: array<string,mixed>}
*/
public static function senden(int $auftragId): array
{
$auftrag = Zahlungsauftrag::find($auftragId);
if ($auftrag === null) {
return ['status' => 'fehler', 'meldung' => 'Den Zahlungsauftrag gibt es nicht.'];
}
if ($auftrag['status'] === 'gesendet') {
return ['status' => 'fehler', 'meldung' => 'Dieser Auftrag wurde bereits gesendet.'];
}
$zugangId = (int)($auftrag['zugang_id'] ?? 0);
if ($zugangId === 0) {
return [
'status' => 'fehler',
'meldung' => 'Dem Auftrag ist keine Bankverbindung zugeordnet.',
];
}
$zugang = Bankzugang::zugangsdaten($zugangId);
if ($zugang === null) {
return ['status' => 'fehler', 'meldung' => 'Die Bankverbindung gibt es nicht.'];
}
self::vergessen();
return (new self($zugang, $auftragId, ['schritt' => 'anmelden']))->lauf(null);
}
/**
* Setzt nach der TAN-Eingabe fort.
*
* @return array{status: string, meldung: string, tan?: array<string,mixed>}
*/
public static function tanEinreichen(string $tan): array
{
$zustand = self::zustandLesen();
if ($zustand === null) {
return ['status' => 'fehler', 'meldung' => 'Es wartet keine Überweisung auf eine Freigabe.'];
}
$zugang = Bankzugang::zugangsdaten((int)$zustand['zugang_id']);
if ($zugang === null) {
self::vergessen();
return ['status' => 'fehler', 'meldung' => 'Die Bankverbindung gibt es nicht mehr.'];
}
return (new self($zugang, (int)$zustand['auftrag_id'], $zustand))->lauf(trim($tan));
}
public static function wartetAufTan(): bool
{
return self::zustandLesen() !== null;
}
/** @return array<string,mixed>|null */
public static function offeneTanAnfrage(): ?array
{
$zustand = self::zustandLesen();
return $zustand === null ? null : ($zustand['tan'] ?? null);
}
public static function abbrechen(): void
{
self::vergessen();
}
// ── Ablauf ───────────────────────────────────────────────────────────────
/** @return array<string,mixed> */
private function lauf(?string $tan): array
{
$auftrag = Zahlungsauftrag::find($this->auftragId);
if ($auftrag === null) {
self::vergessen();
return ['status' => 'fehler', 'meldung' => 'Den Zahlungsauftrag gibt es nicht mehr.'];
}
try {
$fints = $this->verbindung();
} catch (\Throwable $e) {
return $this->fehler('Verbindung fehlgeschlagen: ' . $e->getMessage());
}
try {
// Eine wartende Aktion zuerst abschließen.
if ($tan !== null && isset($this->zustand['aktion'])) {
$aktion = unserialize((string)$this->zustand['aktion'], ['allowed_classes' => true]);
if (!$aktion instanceof BaseAction) {
return $this->fehler('Der wartende Vorgang ist nicht mehr lesbar.');
}
if ($tan === '') {
return $this->tanNoetig($fints, $aktion, 'Überweisung', $this->zustand['schritt']);
}
$fints->submitTan($aktion, $tan);
if ($this->zustand['schritt'] === 'senden') {
return $this->angenommen($fints);
}
$this->zustand['schritt'] = $this->zustand['schritt'] === 'anmelden' ? 'konten' : 'senden';
unset($this->zustand['aktion'], $this->zustand['tan']);
}
if ($this->zustand['schritt'] === 'anmelden') {
$anmeldung = $fints->login();
if ($anmeldung->needsTan()) {
return $this->tanNoetig($fints, $anmeldung, 'Anmeldung', 'anmelden');
}
$this->zustand['schritt'] = 'konten';
}
if ($this->zustand['schritt'] === 'konten' || !isset($this->zustand['sepa'])) {
$konten = GetSEPAAccounts::create();
$fints->execute($konten);
if ($konten->needsTan()) {
return $this->tanNoetig($fints, $konten, 'Kontenabfrage', 'konten');
}
$sepa = $this->passendesKonto($konten->getAccounts(), (int)$auftrag['konto_id']);
if ($sepa === null) {
return $this->fehler(
'Die Bank führt kein Konto mit der IBAN des Auftragskontos. '
. 'Bitte die IBAN im Konto prüfen.'
);
}
$this->zustand['sepa'] = serialize($sepa);
$this->zustand['schritt'] = 'senden';
}
if ($this->zustand['schritt'] === 'senden') {
$sepa = unserialize((string)$this->zustand['sepa'], ['allowed_classes' => [SEPAAccount::class]]);
if (!$sepa instanceof SEPAAccount) {
return $this->fehler('Das Auftragskonto ist nicht mehr lesbar. Bitte erneut senden.');
}
$xml = SepaXml::ueberweisung($this->auftragsdaten($auftrag, $sepa));
$aktion = SendSEPATransfer::create($sepa, $xml);
$fints->execute($aktion);
if ($aktion->needsTan()) {
return $this->tanNoetig($fints, $aktion, 'Überweisung', 'senden');
}
return $this->angenommen($fints);
}
} catch (\Throwable $e) {
DebugLog::fehler($e);
return $this->fehler($e->getMessage());
}
return $this->fehler('Der Auftrag konnte nicht abgeschlossen werden.');
}
/**
* @param array<string,mixed> $auftrag
* @return array<string,mixed>
*/
private function auftragsdaten(array $auftrag, SEPAAccount $sepa): array
{
$konto = Konto::find((int)$auftrag['konto_id']);
return [
'absender_name' => (string)(Einstellung::get('mandant_name') ?: ($konto['name'] ?? 'Auftraggeber')),
'absender_iban' => (string)($sepa->getIban() ?: ($konto['iban'] ?? '')),
'absender_bic' => (string)($sepa->getBic() ?? ''),
'empfaenger_name' => (string)$auftrag['empfaenger'],
'empfaenger_iban' => (string)$auftrag['iban'],
'empfaenger_bic' => (string)$auftrag['bic'],
'betrag' => (float)$auftrag['betrag'],
'waehrung' => (string)$auftrag['waehrung'],
'verwendungszweck' => (string)$auftrag['verwendungszweck'],
'ausfuehrung_am' => $auftrag['ausfuehrung_am'],
];
}
/**
* @param list<SEPAAccount> $konten
*/
private function passendesKonto(array $konten, int $kontoId): ?SEPAAccount
{
$konto = Konto::find($kontoId);
$iban = strtoupper(str_replace(' ', '', (string)($konto['iban'] ?? '')));
foreach ($konten as $kandidat) {
if ($iban !== '' && strtoupper((string)$kandidat->getIban()) === $iban) {
return $kandidat;
}
}
// Ohne IBAN am Konto: Hat die Bank nur ein Konto, ist es eindeutig.
return $iban === '' && count($konten) === 1 ? $konten[0] : null;
}
/** @return array<string,mixed> */
private function angenommen(FinTs $fints): array
{
Zahlungsauftrag::alsGesendetVermerken($this->auftragId, 'Von der Bank angenommen.');
self::vergessen();
try {
$fints->close();
} catch (\Throwable) {
// Der Dialog ist zu Ende; ein Fehler beim Abmelden ändert nichts.
}
return ['status' => 'erfolg', 'meldung' => 'Die Überweisung wurde an die Bank übergeben.'];
}
/** @return array<string,mixed> */
private function tanNoetig(FinTs $fints, BaseAction $aktion, string $bezeichnung, string $schritt): array
{
$verfahren = $fints->getSelectedTanMode();
$anfrage = $aktion->getTanRequest();
$tan = [
'bezeichnung' => $bezeichnung,
'hinweis' => $anfrage?->getChallenge() ?? '',
'medium' => $anfrage?->getTanMediumName() ?? '',
'entkoppelt' => $verfahren !== null && $verfahren->isDecoupled(),
'bild_typ' => null,
'bild_daten' => null,
];
if (!$tan['entkoppelt'] && $anfrage?->getChallengeHhdUc()) {
try {
$bild = new TanRequestChallengeImage($anfrage->getChallengeHhdUc());
$tan['bild_typ'] = $bild->getMimeType();
$tan['bild_daten'] = base64_encode($bild->getData());
} catch (\Throwable) {
// Kein Bild – dann eben ohne.
}
}
self::zustandSchreiben([
'schritt' => $schritt,
'aktion' => serialize($aktion),
'dialog' => $fints->persist(),
'sepa' => $this->zustand['sepa'] ?? null,
'tan' => $tan,
'zugang_id' => (int)$this->zugang['id'],
'auftrag_id' => $this->auftragId,
]);
return [
'status' => 'tan',
'meldung' => 'Die Bank verlangt eine Freigabe.',
'tan' => $tan,
];
}
/** @return array{status: string, meldung: string} */
private function fehler(string $meldung): array
{
Zahlungsauftrag::alsFehlerVermerken($this->auftragId, $meldung);
self::vergessen();
return ['status' => 'fehler', 'meldung' => $meldung];
}
private function verbindung(): FinTs
{
$optionen = new FinTsOptions();
$optionen->url = (string)$this->zugang['fints_url'];
$optionen->bankCode = (string)$this->zugang['blz'];
$optionen->productName = Einstellung::get('banking_produkt_id') ?: self::PRODUKT;
$optionen->productVersion = self::PRODUKT_VERSION;
$fints = FinTs::new(
$optionen,
Credentials::create(
(string)$this->zugang['benutzerkennung'],
(string)$this->zugang['pin']
),
$this->zustand['dialog'] ?? null
);
$fints->setLogger(new DebugLogAdapter());
if (!isset($this->zustand['dialog'])) {
$verfahren = $fints->getTanModes();
if ($verfahren === []) {
throw new \RuntimeException('Die Bank meldet keine TAN-Verfahren.');
}
$gewuenscht = $this->zugang['tan_verfahren_id'] === null
? null : (int)$this->zugang['tan_verfahren_id'];
$gewaehlt = null;
foreach ($verfahren as $modus) {
if ($gewuenscht !== null && $modus->getId() === $gewuenscht) {
$gewaehlt = $modus;
break;
}
}
$gewaehlt ??= reset($verfahren);
$medium = null;
if ($gewaehlt->needsTanMedium()) {
$medien = $fints->getTanMedia($gewaehlt);
if ($medien === []) {
throw new \RuntimeException('Das TAN-Verfahren braucht ein Medium, die Bank nennt keines.');
}
$gespeichert = (string)$this->zugang['tan_medium'];
foreach ($medien as $kandidat) {
if ($gespeichert !== '' && $kandidat->getName() === $gespeichert) {
$medium = $kandidat;
break;
}
}
$medium ??= reset($medien);
}
$fints->selectTanMode($gewaehlt, $medium);
}
return $fints;
}
/** @return array<string,mixed>|null */
private static function zustandLesen(): ?array
{
Auth::startSession();
$zustand = $_SESSION[self::SITZUNG] ?? null;
return is_array($zustand) ? $zustand : null;
}
/** @param array<string,mixed> $zustand */
private static function zustandSchreiben(array $zustand): void
{
Auth::startSession();
$_SESSION[self::SITZUNG] = $zustand;
}
private static function vergessen(): void
{
Auth::startSession();
unset($_SESSION[self::SITZUNG]);
}
}

View file

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Models\Erinnerung;
use Finanzmanager\Models\RegelmaessigeBuchung;
use Finanzmanager\Models\Vertrag;
use Finanzmanager\Models\Zahlungsauftrag;
use Finanzmanager\View;
/**
* Der Finanzkalender: was in den nächsten Wochen ansteht.
*
* Zusammengetragen aus vier Quellen – fällige regelmäßige Buchungen,
* Erinnerungen, Kündigungsfristen von Verträgen und terminierte Überweisungen.
* Nichts davon ist vorausberechnet gespeichert; die Termine entstehen bei
* jedem Aufruf neu aus den Reihen. Eine Tabelle künftiger Termine wäre bei
* jeder Änderung neu zu erzeugen und liefe doch irgendwann weg.
*/
class Finanzkalender
{
/** Die Arten von Einträgen, mit ihrer Beschriftung. */
public const ARTEN = [
'buchung' => 'Regelmäßige Buchung',
'erinnerung' => 'Erinnerung',
'vertrag' => 'Kündigungsfrist',
'ueberweisung' => 'Terminüberweisung',
];
/**
* Alle Termine bis `$tage` in der Zukunft, chronologisch. Überfällige
* stehen vorn – sie sind das, was zuerst Aufmerksamkeit braucht.
*
* @return list<array{
* datum: string, art: string, titel: string, betrag: ?float,
* pfad: string, ueberfaellig: bool, id: int
* }>
*/
public static function termine(int $tage = 30, ?string $heute = null): array
{
$heute = $heute ?? date('Y-m-d');
$bis = (new \DateTimeImmutable($heute))->modify('+' . max(1, $tage) . ' days')->format('Y-m-d');
$termine = [];
foreach (RegelmaessigeBuchung::alle(true) as $reihe) {
foreach (RegelmaessigeBuchung::kommendeTermine($reihe, $bis) as $datum) {
$termine[] = [
'id' => (int)$reihe['id'],
'datum' => $datum,
'art' => 'buchung',
'titel' => (string)$reihe['name'],
'betrag' => (float)$reihe['betrag'],
'pfad' => '/termine',
'ueberfaellig' => $datum < $heute,
];
}
}
foreach (Erinnerung::alle(true) as $erinnerung) {
$datum = (string)$erinnerung['faellig_am'];
if ($datum > $bis) {
continue;
}
$termine[] = [
'id' => (int)$erinnerung['id'],
'datum' => $datum,
'art' => 'erinnerung',
'titel' => (string)$erinnerung['text'],
'betrag' => null,
'pfad' => '/termine/erinnerungen',
'ueberfaellig' => $datum < $heute,
];
}
foreach (Vertrag::fristenLaufenAb($tage, $heute) as $vertrag) {
$termine[] = [
'id' => (int)$vertrag['id'],
'datum' => (string)$vertrag['kuendigen_bis'],
'art' => 'vertrag',
'titel' => 'Kündigungsfrist: ' . $vertrag['name'],
'betrag' => (float)$vertrag['betrag'],
'pfad' => '/vertraege',
'ueberfaellig' => false,
];
}
foreach (Zahlungsauftrag::alle('entwurf') as $auftrag) {
$datum = (string)($auftrag['ausfuehrung_am'] ?? '');
if ($datum === '' || $datum > $bis) {
continue;
}
$termine[] = [
'id' => (int)$auftrag['id'],
'datum' => $datum,
'art' => 'ueberweisung',
'titel' => 'Überweisung an ' . $auftrag['empfaenger'],
'betrag' => -abs((float)$auftrag['betrag']),
'pfad' => '/zahlungsverkehr',
'ueberfaellig' => $datum < $heute,
];
}
usort($termine, static fn(array $a, array $b): int => strcmp($a['datum'], $b['datum']));
return $termine;
}
/**
* Dieselben Termine, nach Monaten gebündelt – so liest sich der Kalender
* wie ein Kalender und nicht wie eine Liste.
*
* @return list<array{monat: string, titel: string, termine: list<array<string,mixed>>}>
*/
public static function nachMonat(int $tage = 60, ?string $heute = null): array
{
$monate = [];
foreach (self::termine($tage, $heute) as $termin) {
$schluessel = substr($termin['datum'], 0, 7);
if (!isset($monate[$schluessel])) {
$monate[$schluessel] = [
'monat' => $schluessel,
'titel' => View::monat((int)substr($schluessel, 5, 2)) . ' ' . substr($schluessel, 0, 4),
'termine' => [],
];
}
$monate[$schluessel]['termine'][] = $termin;
}
return array_values($monate);
}
}

307
src/Services/Kursabruf.php Normal file
View file

@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Wertpapier;
/**
* Holt Tageskurse von einem Kursdienst und legt sie in `kurse` ab.
*
* Zwei Anbieter, beide mit kostenlosem Kontingent (siehe `Aktienkurse.md`):
* **Leeway.tech** (50 Abrufe/Tag, kennt ISINs direkt) und **Financial Modeling
* Prep** (250/Tag). Bei rund zwanzig Positionen reicht beides.
*
* Der Anbieter ist austauschbar: Jeder liefert zu einem Wertpapier einen Kurs
* oder null, alles andere – Reihenfolge, Ablage, Protokoll – ist gemeinsam.
*
* Die Kurse sind Tagesschlusskurse beziehungsweise um 15–20 Minuten verzögerte
* Realtime-Werte. Für eine Vermögensübersicht genügt das; wer auf den Cent
* genaue Momentaufnahmen braucht, ist bei einem Finanzmanager ohnehin falsch.
*/
class Kursabruf
{
public const ANBIETER = [
'leeway' => 'Leeway.tech (50 Abrufe/Tag)',
'fmp' => 'Financial Modeling Prep (250 Abrufe/Tag)',
];
/** Wie lange auf den Kursdienst gewartet wird, in Sekunden. */
private const ZEITGRENZE = 15;
/**
* Holt Kurse für alle Wertpapiere mit Bestand.
*
* @param list<array<string,mixed>>|null $wertpapiere sonst alle aktiven mit Bestand
* @return array{abgerufen: int, fehler: int, uebersprungen: int, protokoll: list<string>}
*/
public static function laufen(?array $wertpapiere = null): array
{
$anbieter = Einstellung::get('wertpapiere_kursdienst', 'leeway') ?? 'leeway';
$key = (string)Einstellung::get('wertpapiere_api_key', '');
$protokoll = [];
$abgerufen = 0;
$fehler = 0;
$uebersprungen = 0;
if (!array_key_exists($anbieter, self::ANBIETER)) {
return self::ergebnis(0, 1, 0, ['Unbekannter Kursdienst: ' . $anbieter]);
}
if ($key === '') {
return self::ergebnis(0, 1, 0, [
'Kein API-Schlüssel hinterlegt (Einstellungen → Wertpapiere).',
]);
}
$wertpapiere ??= self::mitBestand();
$heute = date('Y-m-d');
foreach ($wertpapiere as $wertpapier) {
$name = (string)$wertpapier['name'];
// Was heute schon einen Kurs hat, wird nicht erneut abgefragt – das
// Freikontingent ist knapp, und mehr als einen Tageskurs speichert
// die Anwendung ohnehin nicht.
$vorhanden = Wertpapier::letzterKurs((int)$wertpapier['id'], $heute);
if ($vorhanden !== null && (string)$vorhanden['datum'] === $heute) {
$uebersprungen++;
continue;
}
try {
$kurs = self::kurs($anbieter, $key, $wertpapier);
} catch (\Throwable $e) {
$fehler++;
$protokoll[] = $name . ': ' . $e->getMessage();
continue;
}
if ($kurs === null) {
$fehler++;
$protokoll[] = $name . ': kein Kurs geliefert.';
continue;
}
Wertpapier::kursSetzen(
(int)$wertpapier['id'],
$heute,
$kurs,
$anbieter,
(string)($wertpapier['waehrung'] ?: 'EUR')
);
$abgerufen++;
$protokoll[] = $name . ': ' . number_format($kurs, 2, ',', '.');
}
return self::ergebnis($abgerufen, $fehler, $uebersprungen, $protokoll);
}
/**
* Wertpapiere, die überhaupt einen Kurs brauchen: solche mit Bestand.
* Verkaufte Papiere kosten sonst jeden Tag Abrufe, ohne dass ihr Kurs
* irgendwo auftauchte.
*
* @return list<array<string,mixed>>
*/
public static function mitBestand(): array
{
$bestand = Depot::bestand();
$ids = array_column($bestand['positionen'], 'wertpapier_id');
return array_values(array_filter(
Wertpapier::alle(true),
static fn(array $wertpapier): bool => in_array((int)$wertpapier['id'], $ids, true)
));
}
/**
* Ein einzelner Kurs.
*
* @param array<string,mixed> $wertpapier
*/
public static function kurs(string $anbieter, string $key, array $wertpapier): ?float
{
return match ($anbieter) {
'fmp' => self::fmp($key, $wertpapier),
default => self::leeway($key, $wertpapier),
};
}
/**
* Leeway.tech kennt ISINs direkt: Die Stammdatenabfrage liefert das Kürzel,
* mit dem der Kurs geholt wird. Ist das Kürzel am Wertpapier hinterlegt,
* entfällt der erste Aufruf – das halbiert den Verbrauch.
*
* @param array<string,mixed> $wertpapier
*/
private static function leeway(string $key, array $wertpapier): ?float
{
$symbol = trim((string)$wertpapier['symbol']);
if ($symbol === '') {
$isin = trim((string)$wertpapier['isin']);
if ($isin === '') {
throw new \RuntimeException('Weder Kürzel noch ISIN hinterlegt.');
}
$stamm = self::hole(
'https://api.leeway.tech/api/v1/public/general/isin/' . rawurlencode($isin)
. '?apitoken=' . rawurlencode($key)
);
$symbol = (string)self::ersterWert($stamm, ['Code', 'code', 'Ticker', 'ticker', 'symbol']);
if ($symbol === '') {
throw new \RuntimeException('Zur ISIN ' . $isin . ' liefert der Dienst kein Kürzel.');
}
// Gefundene Kürzel werden gemerkt, damit der Umweg einmalig bleibt.
Wertpapier::speichern((int)$wertpapier['id'], array_merge($wertpapier, ['symbol' => $symbol]));
}
$antwort = self::hole(
'https://api.leeway.tech/api/v1/public/realtime/' . rawurlencode($symbol)
. '?apitoken=' . rawurlencode($key)
);
return self::zahl(self::ersterWert($antwort, ['close', 'price', 'last', 'previousClose']));
}
/**
* Financial Modeling Prep sucht die ISIN erst zu einem Kürzel, dann kommt
* der Kurs über die Kurzabfrage.
*
* @param array<string,mixed> $wertpapier
*/
private static function fmp(string $key, array $wertpapier): ?float
{
$symbol = trim((string)$wertpapier['symbol']);
if ($symbol === '') {
$isin = trim((string)$wertpapier['isin']);
if ($isin === '') {
throw new \RuntimeException('Weder Kürzel noch ISIN hinterlegt.');
}
$treffer = self::hole(
'https://financialmodelingprep.com/api/v3/search?query=' . rawurlencode($isin)
. '&limit=1&apikey=' . rawurlencode($key)
);
$symbol = (string)self::ersterWert($treffer[0] ?? [], ['symbol', 'Symbol']);
if ($symbol === '') {
throw new \RuntimeException('Zur ISIN ' . $isin . ' findet der Dienst kein Kürzel.');
}
Wertpapier::speichern((int)$wertpapier['id'], array_merge($wertpapier, ['symbol' => $symbol]));
}
$antwort = self::hole(
'https://financialmodelingprep.com/api/v3/quote-short/' . rawurlencode($symbol)
. '?apikey=' . rawurlencode($key)
);
return self::zahl(self::ersterWert($antwort[0] ?? $antwort, ['price', 'Price']));
}
/**
* Ein GET gegen den Kursdienst, Antwort als Feld.
*
* @return array<mixed>
*/
private static function hole(string $url): array
{
$kanal = curl_init($url);
if ($kanal === false) {
throw new \RuntimeException('Verbindung konnte nicht aufgebaut werden.');
}
curl_setopt_array($kanal, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => self::ZEITGRENZE,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_USERAGENT => 'Finanzmanager Kursabruf',
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$antwort = curl_exec($kanal);
$status = (int)curl_getinfo($kanal, CURLINFO_RESPONSE_CODE);
$fehler = curl_error($kanal);
curl_close($kanal);
// Der Schlüssel steht in der Adresse – er darf nirgends im Klartext
// auftauchen, auch nicht in einer Fehlermeldung.
$sichtbar = preg_replace('/(apitoken|apikey)=[^&]*/', '$1=…', $url) ?? $url;
DebugLog::schreibe('kursabruf', 'GET ' . $sichtbar . ' → ' . $status);
if ($antwort === false) {
throw new \RuntimeException('Kursdienst nicht erreichbar: ' . $fehler);
}
if ($status === 401 || $status === 403) {
throw new \RuntimeException('Der API-Schlüssel wurde abgelehnt (HTTP ' . $status . ').');
}
if ($status === 429) {
throw new \RuntimeException('Das Tageskontingent des Kursdienstes ist aufgebraucht.');
}
if ($status < 200 || $status >= 300) {
throw new \RuntimeException('Kursdienst antwortet mit HTTP ' . $status . '.');
}
$daten = json_decode((string)$antwort, true);
if (!is_array($daten)) {
throw new \RuntimeException('Antwort des Kursdienstes ist kein JSON.');
}
return $daten;
}
/**
* Der erste belegte der genannten Schlüssel.
*
* Mehrere Namen, weil die Dienste ihre Felder unterschiedlich benennen und
* gelegentlich umbenennen – ein fehlender Kurs soll nicht am Feldnamen
* scheitern.
*
* @param array<mixed> $daten
* @param list<string> $schluessel
*/
private static function ersterWert(array $daten, array $schluessel): mixed
{
foreach ($schluessel as $name) {
if (isset($daten[$name]) && $daten[$name] !== '' && $daten[$name] !== null) {
return $daten[$name];
}
}
return null;
}
private static function zahl(mixed $wert): ?float
{
if ($wert === null || !is_numeric($wert)) {
return null;
}
$kurs = round((float)$wert, 6);
return $kurs > 0.0 ? $kurs : null;
}
/**
* @param list<string> $protokoll
* @return array{abgerufen: int, fehler: int, uebersprungen: int, protokoll: list<string>}
*/
private static function ergebnis(int $abgerufen, int $fehler, int $uebersprungen, array $protokoll): array
{
return [
'abgerufen' => $abgerufen,
'fehler' => $fehler,
'uebersprungen' => $uebersprungen,
'protokoll' => $protokoll,
];
}
}

View file

@ -6,9 +6,11 @@ namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
/**
* Übernimmt die Buchungen eines QIF-Exports.
@ -123,11 +125,11 @@ class QifBuchungsImport
if ($eintrag['typ'] === 'Invst') {
// Vor dem Stichtag zählt vom Wertpapiergeschäft nur der
// Geldfluss auf dem Verrechnungskonto.
$ziel = self::umbuchungsziel(QifParser::feld($eintrag, 'L'));
$wert = QifParser::feld($eintrag, '$');
if ($ziel !== null && $wert !== null) {
$bucheAuf($ziel, -QifParser::betrag($wert));
// Geldfluss auf dem Verrechnungskonto. Die Bestände davor
// stecken im Depot, nicht im Anfangssaldo.
$ziel = self::verrechnungskonto(QifParser::feld($eintrag, 'L'));
if ($ziel !== null) {
$bucheAuf($ziel, self::geldflussInvst($eintrag));
}
continue;
}
@ -215,8 +217,9 @@ class QifBuchungsImport
}
if ($zaehler['wertpapier'] > 0) {
$this->melde('hinweis', $zaehler['wertpapier'] . ' Wertpapiergeschäfte: übernommen wurde nur der '
. 'Geldfluss auf dem Verrechnungskonto. Bestände und Kurse folgen in Phase 6.');
$this->melde('hinweis', $zaehler['wertpapier'] . ' Wertpapiergeschäfte übernommen – '
. 'je Geschäft eine Depotbuchung mit Stück und Kurs sowie die Geldbuchung auf dem '
. 'Verrechnungskonto. Kurse zur Bewertung holt der Kursabruf.');
}
return $zaehler;
@ -275,26 +278,58 @@ class QifBuchungsImport
* bliebe ein Betrag auf dem Depot ohne Gegenwert und würde die Summe der
* Kontostände verfälschen.
*/
/**
* Ein Wertpapiergeschäft (`!Type:Invst`).
*
* Es entstehen zwei Datensätze: die Depotbuchung mit Stück und Kurs und –
* wenn ein Verrechnungskonto genannt ist – die Geldbuchung dort. Beide legt
* `Depotbuchung::anlegen()` in einem Zug an.
*
* Das Depot ist der `!Account`-Abschnitt, in dem der Eintrag steht; das
* Verrechnungskonto steht in `L|[Konto]` (dem eine Kategorie vorangehen
* kann, etwa `LKursgewinne:Realisierte Gewinne|[Verrechnungskonto]`).
*/
private function wertpapierbuchung(array $eintrag, string $datum, bool $trockenlauf): ?int
{
$ziel = self::umbuchungsziel(QifParser::feld($eintrag, 'L'));
$wert = QifParser::feld($eintrag, '$');
if ($ziel === null || $wert === null) {
return null;
$depotName = (string)$eintrag['konto'];
$depotId = $this->kontoId($depotName);
if ($depotId === null) {
throw new \RuntimeException('Unbekanntes Depot „' . $depotName . '“.');
}
$kontoId = $this->kontoId($ziel);
if ($kontoId === null) {
throw new \RuntimeException('Unbekanntes Verrechnungskonto „' . $ziel . '“.');
$vorgangQif = trim((string)QifParser::feld($eintrag, 'N'));
$vorgang = Depotbuchung::vorgangAusQif($vorgangQif);
$name = trim((string)QifParser::feld($eintrag, 'Y'));
if ($name === '') {
throw new \RuntimeException('Wertpapiergeschäft ohne Wertpapiernamen.');
}
$betrag = -QifParser::betrag($wert);
$text = (string)QifParser::feld($eintrag, 'M');
$vorgang = (string)QifParser::feld($eintrag, 'N');
$papier = (string)QifParser::feld($eintrag, 'Y');
$isin = trim((string)QifParser::feld($eintrag, '@'));
$stueck = QifParser::betrag(QifParser::feld($eintrag, 'Q'));
$kurs = QifParser::betrag(QifParser::feld($eintrag, 'I'));
$hash = $this->eindeutigerHash($kontoId, $datum, $betrag, $vorgang, $text);
if (Buchung::findeNachHash($hash) !== null) {
[$gebuehren, $steuern] = self::nebenkosten(QifParser::feld($eintrag, 'O'));
// Der Bruttowert ist Stück mal Kurs. Fehlt der Kurs – bei Dividenden
// immer –, wird er aus dem gebuchten Betrag zurückgerechnet: Feld U ist
// dort die Gutschrift nach Abzug der Steuern.
$gebucht = QifParser::betrag(QifParser::feld($eintrag, 'U'));
$betrag = $stueck > 0.0 && $kurs > 0.0
? round($stueck * $kurs, 2)
: round(abs($gebucht) + $gebuehren + $steuern, 2);
$verrechnung = self::verrechnungskonto(QifParser::feld($eintrag, 'L'));
$verrechnungId = $verrechnung === null ? null : $this->kontoId($verrechnung);
if ($verrechnung !== null && $verrechnungId === null) {
throw new \RuntimeException('Unbekanntes Verrechnungskonto „' . $verrechnung . '“.');
}
$geld = QifParser::feld($eintrag, '$');
$geld = $geld === null ? 0.0 : QifParser::betrag($geld);
$hash = Depotbuchung::hash($depotId, $datum, $vorgang, $stueck, $betrag, $isin ?: $name);
if (Depotbuchung::findeNachHash($hash) !== null) {
return null;
}
@ -302,20 +337,106 @@ class QifBuchungsImport
return 0;
}
return Buchung::anlegen([
'konto_id' => $kontoId,
$text = trim((string)QifParser::feld($eintrag, 'M'));
$wertpapierId = Wertpapier::findenOderAnlegen([
'name' => $name,
'wkn' => (string)QifParser::feld($eintrag, '~'),
'isin' => $isin,
'art' => Wertpapier::artAusQif((string)QifParser::feld($eintrag, '&')),
'waehrung' => (string)QifParser::feld($eintrag, 'F') ?: 'EUR',
]);
return Depotbuchung::anlegen([
'konto_id' => $depotId,
'wertpapier_id' => $wertpapierId,
'datum' => $datum,
'valuta' => QifParser::datum(QifParser::feld($eintrag, 'V'), $this->parser->datumsformat()),
'vorgang' => $vorgang,
'vorgang_qif' => $vorgangQif,
'stueck' => $stueck,
'kurs' => $kurs > 0.0 ? $kurs : null,
'betrag' => $betrag,
'empfaenger' => $papier !== '' ? $papier : (string)$eintrag['konto'],
'buchungstext' => trim($vorgang . ' ' . $text),
'nummer' => '',
'gebuehren' => $gebuehren,
'steuern' => $steuern,
'waehrung' => (string)QifParser::feld($eintrag, 'F') ?: 'EUR',
'verrechnungskonto_id' => $verrechnungId,
'geldbetrag' => $geld,
'notiz' => $text,
'wertpapiername' => $name,
'abgeglichen' => QifParser::feld($eintrag, 'C') !== null,
'quelle' => 'qif',
'import_hash' => $hash,
// Der Hash der Geldseite entspricht dem, den frühere Importe ohne
// Depotmodul vergeben haben – so entsteht im Kontoblatt keine
// zweite Buchung für dasselbe Geschäft.
'buchung_hash' => $verrechnungId === null ? null : $this->eindeutigerHash(
$verrechnungId,
$datum,
-$geld,
$vorgangQif,
$text
),
]);
}
/**
* Der Geldfluss eines Wertpapiergeschäfts auf dem Verrechnungskonto.
*
* Das QIF nennt im Feld `$` immer einen positiven Betrag – die Richtung
* steckt allein im Vorgang: Ein Kauf nimmt Geld weg, Verkauf und Dividende
* bringen welches. Ein- und Auslieferungen rühren kein Konto an.
*/
private static function geldflussInvst(array $eintrag): float
{
$wert = QifParser::feld($eintrag, '$') ?? QifParser::feld($eintrag, 'U');
if ($wert === null) {
return 0.0;
}
$betrag = abs(QifParser::betrag($wert));
$vorgang = Depotbuchung::vorgangAusQif(trim((string)QifParser::feld($eintrag, 'N')));
return match ($vorgang) {
'kauf' => -$betrag,
'verkauf', 'dividende' => $betrag,
default => 0.0,
};
}
/**
* Das QIF-Feld `O` führt zwölf durch „|“ getrennte Beträge.
*
* Sicher zugeordnet sind die vierte (Provision) und die zehnte Position
* (fremde Spesen) – beides Gebühren. Alles Übrige sind Abzüge des Fiskus
* (Kapitalertragsteuer, Solidaritätszuschlag, Quellensteuer). Statt jede
* Position einzeln zu deuten, werden sie zu „Steuern“ zusammengefasst: Für
* Einstand und Erlös zählt die Summe, und keine Position fällt heraus.
*
* @return array{0: float, 1: float} Gebühren und Steuern
*/
private static function nebenkosten(?string $feld): array
{
if ($feld === null || trim($feld) === '') {
return [0.0, 0.0];
}
$werte = array_map(
static fn(string $wert): float => QifParser::betrag($wert),
explode('|', $feld)
);
$gebuehren = ($werte[3] ?? 0.0) + ($werte[9] ?? 0.0);
$steuern = 0.0;
foreach ($werte as $nummer => $wert) {
if ($nummer !== 3 && $nummer !== 9) {
$steuern += $wert;
}
}
return [round($gebuehren, 2), round($steuern, 2)];
}
/**
* Welche Konten bekommen aus dieser Buchung eine Gegenbewegung, und mit
* welchem Betrag? Ohne Splits genau eines, mit Splits je Zeile eines.
@ -427,6 +548,26 @@ class QifBuchungsImport
return $ende === false ? null : substr($angabe, 1, $ende - 1);
}
/**
* Das Verrechnungskonto eines Wertpapiergeschäfts.
*
* Bei `!Type:Invst` steht in `L` mehr als bei einer gewöhnlichen Buchung:
* Dem Konto kann eine Kategorie vorangehen, getrennt durch einen senkrechten
* Strich – ein Verkauf trägt dort etwa
* `LKursgewinne:Realisierte Gewinne|[Verrechnungskonto]`. Deshalb zählt hier
* allein der Teil hinter dem letzten Strich.
*/
public static function verrechnungskonto(?string $angabe): ?string
{
if ($angabe === null) {
return null;
}
$strich = strrpos($angabe, '|');
return self::umbuchungsziel($strich === false ? $angabe : substr($angabe, $strich));
}
/**
* Identische Buchungen am selben Tag kommen vor (zweimal dasselbe im selben
* Laden). Der laufende Zähler hält sie auseinander und bleibt über Läufe

View file

@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Models\Buchungsvorlage;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
/**
* Übernimmt die Buchungsvorlagen eines QIF-Exports (`!Type:Memorized`).
*
* Der Beispielexport führt 579 Stück – im Wesentlichen jede jemals erfasste
* Zahlung, vom FinanzManager automatisch gemerkt. Die Sektion hat kein
* Kontobezug und keinen Namen: Ein Eintrag besteht aus Empfänger (`P`), Betrag
* (`U`/`T`), Text (`M`), Kategorie oder Konto (`L`) und gegebenenfalls
* Splitzeilen (`S`/`E`/`$`).
*
* **Der Name entsteht hier**, aus Empfänger und – wenn nötig – Betrag: Ohne
* Namen ließe sich eine Vorlage nicht suchen, und der Empfänger allein ist bei
* mehreren Vorlagen desselben Ladens nicht eindeutig.
*/
class QifVorlagenImport
{
private QifParser $parser;
/** @var array<string,int> */
private array $konten = [];
/** @var array<string,int> */
private array $kategorien = [];
/** @var array<string,int> */
private array $klassen = [];
/** @var list<array{typ: string, text: string}> */
private array $meldungen = [];
public function __construct(QifParser $parser)
{
$this->parser = $parser;
}
/**
* @return array{neu: int, vorhanden: int, fehler: int, meldungen: list<array<string,string>>}
*/
public function ausfuehren(bool $trockenlauf = false): array
{
$this->meldungen = [];
$this->ladeStammdaten();
$lauf = function () use ($trockenlauf): array {
$zaehler = ['neu' => 0, 'vorhanden' => 0, 'fehler' => 0];
$namen = [];
foreach ($this->parser->eintraege('Memorized') as $eintrag) {
try {
$vorlage = $this->vorlage($eintrag, $namen);
if ($vorlage === null) {
$zaehler['fehler']++;
continue;
}
if (Buchungsvorlage::findeNachName($vorlage['name']) !== null) {
$zaehler['vorhanden']++;
continue;
}
if (!$trockenlauf) {
Buchungsvorlage::anlegen($vorlage);
}
$zaehler['neu']++;
} catch (\Throwable $e) {
$zaehler['fehler']++;
$this->melde('fehler', $e->getMessage());
}
}
return $zaehler;
};
$zaehler = $trockenlauf ? $lauf() : Database::transaktion($lauf);
$zaehler['meldungen'] = $this->meldungen;
return $zaehler;
}
public function vorschau(): array
{
return $this->ausfuehren(true);
}
/**
* @param array<string,mixed> $eintrag
* @param array<string,int> $namen schon vergebene Namen mit Zähler
* @return array<string,mixed>|null
*/
private function vorlage(array $eintrag, array &$namen): ?array
{
$empfaenger = trim((string)QifParser::feld($eintrag, 'P'));
$text = trim((string)QifParser::feld($eintrag, 'M'));
$betrag = QifParser::betrag(
QifParser::feld($eintrag, 'U') ?? QifParser::feld($eintrag, 'T')
);
if ($empfaenger === '' && $text === '') {
return null;
}
$basis = $empfaenger !== '' ? $empfaenger : mb_substr($text, 0, 60);
$name = $basis . ' (' . number_format($betrag, 2, ',', '.') . ')';
// Gleiche Vorlage, gleicher Betrag, mehrfach in der Datei: Der Zähler
// hält sie auseinander, statt sie stillschweigend zu verschlucken.
$namen[$name] = ($namen[$name] ?? 0) + 1;
if ($namen[$name] > 1) {
$name .= ' #' . $namen[$name];
}
$zuordnung = $this->zuordnung((string)QifParser::feld($eintrag, 'L'));
return [
'name' => mb_substr($name, 0, 255),
'betrag' => $betrag,
'empfaenger' => $empfaenger,
'buchungstext' => $text,
'nummer' => '',
'kategorie_id' => $zuordnung['kategorie_id'],
'klasse_id' => $zuordnung['klasse_id'],
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'splits' => $this->splitzeilen($eintrag),
'quelle' => 'qif',
];
}
/**
* Die Splitzeilen einer Vorlage (`S` Kategorie, `E` Notiz, `$` Betrag).
*
* @param array<string,mixed> $eintrag
* @return list<array<string,mixed>>
*/
private function splitzeilen(array $eintrag): array
{
$kategorien = QifParser::felder($eintrag, 'S');
if ($kategorien === []) {
return [];
}
$notizen = QifParser::felder($eintrag, 'E');
$betraege = QifParser::felder($eintrag, '$');
$splits = [];
foreach ($kategorien as $nummer => $angabe) {
$zuordnung = $this->zuordnung($angabe);
$splits[] = [
'kategorie_id' => $zuordnung['kategorie_id'],
'klasse_id' => $zuordnung['klasse_id'],
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'betrag' => QifParser::betrag($betraege[$nummer] ?? null),
'notiz' => mb_substr(trim((string)($notizen[$nummer] ?? '')), 0, 255),
];
}
return $splits;
}
/**
* Löst eine `L`- oder `S`-Angabe auf: Umbuchungskonto, Kategorie und Klasse.
*
* @return array{kategorie_id: ?int, klasse_id: ?int, gegenkonto_id: ?int}
*/
private function zuordnung(string $angabe): array
{
$angabe = trim($angabe);
if ($angabe === '') {
return ['kategorie_id' => null, 'klasse_id' => null, 'gegenkonto_id' => null];
}
$ziel = QifBuchungsImport::umbuchungsziel($angabe);
if ($ziel !== null) {
return [
'kategorie_id' => null,
'klasse_id' => null,
'gegenkonto_id' => $this->konten[$ziel] ?? null,
];
}
$zerlegt = Kategorie::zerlegeQifAngabe($angabe);
$pfad = Kategorie::normalisierePfad($zerlegt['kategorie']);
$klasse = $zerlegt['klasse'];
return [
'kategorie_id' => $pfad === '' ? null : ($this->kategorien[$pfad] ?? null),
'klasse_id' => $klasse === '' ? null : ($this->klassen[$klasse] ?? null),
'gegenkonto_id' => null,
];
}
private function ladeStammdaten(): void
{
$this->konten = array_column(Konto::alle(), 'id', 'name');
$this->kategorien = array_column(Kategorie::alle(), 'id', 'pfad');
$this->klassen = array_column(Klasse::alle(), 'id', 'name');
$this->konten = array_map('intval', $this->konten);
$this->kategorien = array_map('intval', $this->kategorien);
$this->klassen = array_map('intval', $this->klassen);
}
private function melde(string $typ, string $text): void
{
// Nicht jede der 579 Vorlagen soll ihre eigene Meldung bekommen – bei
// vielen gleichartigen Fehlern reicht die erste Handvoll.
if (count($this->meldungen) < 20) {
$this->meldungen[] = ['typ' => $typ, 'text' => $text];
}
}
}

187
src/Services/SepaXml.php Normal file
View file

@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
/**
* Erzeugt die pain.001-Nachricht einer SEPA-Überweisung.
*
* **Warum selbst geschrieben.** php-fints erwartet die fertige XML-Nachricht und
* erzeugt sie nicht; die üblichen Erzeuger (sephpa, php-sepa-xml) wären zwei
* weitere Abhängigkeiten für ein Dokument von sechzig Zeilen. Für die eine
* Einzelüberweisung, um die es hier geht, ist das Format überschaubar und in der
* Spezifikation der Deutschen Kreditwirtschaft vollständig beschrieben.
*
* Erzeugt wird **pain.001.001.03** – die Fassung, die die deutschen Banken für
* FinTS-Überweisungen erwarten.
*/
class SepaXml
{
private const SCHEMA = 'urn:iso:std:iso:20022:tech:xsd:pain.001.001.03';
/**
* @param array{
* absender_name: string, absender_iban: string, absender_bic?: string,
* empfaenger_name: string, empfaenger_iban: string, empfaenger_bic?: string,
* betrag: float, waehrung?: string, verwendungszweck?: string,
* ausfuehrung_am?: ?string
* } $auftrag
*/
public static function ueberweisung(array $auftrag): string
{
$betrag = number_format(abs((float)$auftrag['betrag']), 2, '.', '');
if ((float)$betrag <= 0.0) {
throw new \InvalidArgumentException('Der Betrag muss größer als null sein.');
}
// Eindeutige Kennungen: Die Bank lehnt eine Nachricht ab, deren Kennung
// sie schon kennt.
$kennung = 'FM' . date('YmdHis') . substr(bin2hex(random_bytes(4)), 0, 6);
$zeitpunkt = date('Y-m-d\TH:i:s');
// Ohne Termin führt die Bank sofort aus. Der Wert muss trotzdem
// dastehen; das Format verlangt ihn.
$ausfuehrung = (string)($auftrag['ausfuehrung_am'] ?? '') ?: date('Y-m-d');
$waehrung = strtoupper((string)($auftrag['waehrung'] ?? 'EUR'));
$zweck = self::text((string)($auftrag['verwendungszweck'] ?? ''), 140);
$dokument = new \DOMDocument('1.0', 'UTF-8');
$dokument->formatOutput = true;
$wurzel = $dokument->createElementNS(self::SCHEMA, 'Document');
$dokument->appendChild($wurzel);
$nachricht = $dokument->createElement('CstmrCdtTrfInitn');
$wurzel->appendChild($nachricht);
// ── Kopf ────────────────────────────────────────────────────────────
$kopf = $dokument->createElement('GrpHdr');
$nachricht->appendChild($kopf);
self::kind($dokument, $kopf, 'MsgId', $kennung);
self::kind($dokument, $kopf, 'CreDtTm', $zeitpunkt);
self::kind($dokument, $kopf, 'NbOfTxs', '1');
self::kind($dokument, $kopf, 'CtrlSum', $betrag);
$auftraggeber = $dokument->createElement('InitgPty');
$kopf->appendChild($auftraggeber);
self::kind($dokument, $auftraggeber, 'Nm', self::text((string)$auftrag['absender_name'], 70));
// ── Sammler (eine Zahlung) ──────────────────────────────────────────
$sammler = $dokument->createElement('PmtInf');
$nachricht->appendChild($sammler);
self::kind($dokument, $sammler, 'PmtInfId', $kennung);
self::kind($dokument, $sammler, 'PmtMtd', 'TRF');
self::kind($dokument, $sammler, 'BtchBookg', 'false');
self::kind($dokument, $sammler, 'NbOfTxs', '1');
self::kind($dokument, $sammler, 'CtrlSum', $betrag);
$art = $dokument->createElement('PmtTpInf');
$sammler->appendChild($art);
$ebene = $dokument->createElement('SvcLvl');
$art->appendChild($ebene);
self::kind($dokument, $ebene, 'Cd', 'SEPA');
self::kind($dokument, $sammler, 'ReqdExctnDt', $ausfuehrung);
$schuldner = $dokument->createElement('Dbtr');
$sammler->appendChild($schuldner);
self::kind($dokument, $schuldner, 'Nm', self::text((string)$auftrag['absender_name'], 70));
$schuldnerKonto = $dokument->createElement('DbtrAcct');
$sammler->appendChild($schuldnerKonto);
$kennungA = $dokument->createElement('Id');
$schuldnerKonto->appendChild($kennungA);
self::kind($dokument, $kennungA, 'IBAN', self::iban((string)$auftrag['absender_iban']));
$schuldnerBank = $dokument->createElement('DbtrAgt');
$sammler->appendChild($schuldnerBank);
$bankKennung = $dokument->createElement('FinInstnId');
$schuldnerBank->appendChild($bankKennung);
// Ohne BIC gilt „Other/NOTPROVIDED“ – im SEPA-Raum ist die IBAN genug.
if (($auftrag['absender_bic'] ?? '') !== '') {
self::kind($dokument, $bankKennung, 'BIC', strtoupper(trim((string)$auftrag['absender_bic'])));
} else {
$anders = $dokument->createElement('Othr');
$bankKennung->appendChild($anders);
self::kind($dokument, $anders, 'Id', 'NOTPROVIDED');
}
self::kind($dokument, $sammler, 'ChrgBr', 'SLEV');
// ── Die Zahlung ─────────────────────────────────────────────────────
$zahlung = $dokument->createElement('CdtTrfTxInf');
$sammler->appendChild($zahlung);
$zahlungsId = $dokument->createElement('PmtId');
$zahlung->appendChild($zahlungsId);
self::kind($dokument, $zahlungsId, 'EndToEndId', 'NOTPROVIDED');
$summe = $dokument->createElement('Amt');
$zahlung->appendChild($summe);
$wert = $dokument->createElement('InstdAmt', $betrag);
$wert->setAttribute('Ccy', $waehrung);
$summe->appendChild($wert);
if (($auftrag['empfaenger_bic'] ?? '') !== '') {
$empfaengerBank = $dokument->createElement('CdtrAgt');
$zahlung->appendChild($empfaengerBank);
$empfaengerBankId = $dokument->createElement('FinInstnId');
$empfaengerBank->appendChild($empfaengerBankId);
self::kind($dokument, $empfaengerBankId, 'BIC', strtoupper(trim((string)$auftrag['empfaenger_bic'])));
}
$empfaenger = $dokument->createElement('Cdtr');
$zahlung->appendChild($empfaenger);
self::kind($dokument, $empfaenger, 'Nm', self::text((string)$auftrag['empfaenger_name'], 70));
$empfaengerKonto = $dokument->createElement('CdtrAcct');
$zahlung->appendChild($empfaengerKonto);
$empfaengerId = $dokument->createElement('Id');
$empfaengerKonto->appendChild($empfaengerId);
self::kind($dokument, $empfaengerId, 'IBAN', self::iban((string)$auftrag['empfaenger_iban']));
if ($zweck !== '') {
$verwendung = $dokument->createElement('RmtInf');
$zahlung->appendChild($verwendung);
self::kind($dokument, $verwendung, 'Ustrd', $zweck);
}
return (string)$dokument->saveXML();
}
private static function kind(\DOMDocument $dokument, \DOMElement $eltern, string $name, string $wert): void
{
// Über createTextNode statt als zweites Argument von createElement:
// Letzteres maskiert kaufmännische Und-Zeichen nicht.
$element = $dokument->createElement($name);
$element->appendChild($dokument->createTextNode($wert));
$eltern->appendChild($element);
}
/**
* SEPA lässt nur einen eingeschränkten Zeichensatz zu. Umlaute werden
* umschrieben, alles Übrige fällt weg – sonst weist die Bank den Auftrag
* zurück, und zwar erst nach dem Absenden.
*/
public static function text(string $text, int $laenge): string
{
$ersatz = [
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue',
'ß' => 'ss', 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'á' => 'a', 'à' => 'a',
'â' => 'a', 'ç' => 'c', 'ñ' => 'n', 'ô' => 'o', 'ù' => 'u', 'û' => 'u',
];
$text = strtr($text, $ersatz);
$text = preg_replace("/[^A-Za-z0-9\\/\\-?:().,'+ ]/", ' ', $text) ?? '';
$text = trim(preg_replace('/\s+/', ' ', $text) ?? '');
return mb_substr($text, 0, $laenge);
}
private static function iban(string $iban): string
{
return strtoupper(str_replace(' ', '', $iban));
}
}

View file

@ -79,6 +79,24 @@ class View
return $namen[$monat] ?? (string)$monat;
}
/** Wochentagskürzel, Montag zuerst – wie MONATE nicht über date() oder intl. */
public const WOCHENTAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
public static function wochentag(?string $datum): string
{
if (!$datum) {
return '';
}
$zeit = strtotime(substr($datum, 0, 10));
if ($zeit === false) {
return '';
}
// date('N') zählt Montag als 1.
return self::WOCHENTAGE[(int)date('N', $zeit) - 1] ?? '';
}
public static function datum(?string $datum): string
{
if (!$datum) {

View file

@ -0,0 +1,167 @@
<?php
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\View;
/** @var array<string,mixed>|null $zugang */
/** @var list<array<string,mixed>> $konten Bankkonten dieses Zugangs */
/** @var list<array<string,mixed>> $eigene Konten der Anwendung */
/** @var list<array<string,mixed>> $abrufe */
/** @var string $csrfToken */
$neu = $zugang === null;
$ziel = $neu ? '/banking/neu' : '/banking/' . (int)$zugang['id'];
$wert = static fn(string $feld, string $standard = ''): string => (string)($zugang[$feld] ?? $standard);
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/banking" class="knopf">Zurück zu den Bankverbindungen</a>
<?php if (!$neu): ?>
<form method="post" action="/banking/<?= (int)$zugang['id'] ?>/abrufen" class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf primaer">Umsätze abrufen</button>
</form>
<?php endif; ?>
</div>
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="<?= View::h($wert('name')) ?>" required>
<p class="feldhinweis">Frei wählbar, etwa „DKB“ oder „Sparkasse privat“.</p>
<label for="bankname">Bank</label>
<input type="text" id="bankname" name="bankname" value="<?= View::h($wert('bankname')) ?>">
<label for="blz">Bankleitzahl</label>
<input type="text" id="blz" name="blz" value="<?= View::h($wert('blz')) ?>" inputmode="numeric" required>
<label for="fints_url">FinTS-Adresse</label>
<input type="text" id="fints_url" name="fints_url" value="<?= View::h($wert('fints_url')) ?>"
placeholder="https://…" required>
<p class="feldhinweis">
Der HBCI/FinTS-Zugangspunkt der Bank – sie nennt ihn in ihrer Hilfe zum
Online-Banking. Muss mit <code>https://</code> beginnen.
</p>
<label for="benutzerkennung">Benutzerkennung</label>
<input type="text" id="benutzerkennung" name="benutzerkennung"
value="<?= View::h($wert('benutzerkennung')) ?>" required>
<label for="pin">PIN</label>
<input type="password" id="pin" name="pin" autocomplete="off"
<?= $neu ? 'required' : 'placeholder="unverändert lassen"' ?>>
<p class="feldhinweis">
Wird AES-verschlüsselt gespeichert (Schlüssel <code>APP_KEY</code>) und nie wieder
angezeigt.<?= $neu ? '' : ' Leer lassen heißt: bisherige PIN behalten.' ?>
</p>
<label for="abrufzeitraum">Abrufzeitraum</label>
<select id="abrufzeitraum" name="abrufzeitraum">
<?php foreach (Bankzugang::ZEITRAEUME as $schluessel => $beschriftung): ?>
<option value="<?= View::h($schluessel) ?>"
<?= $wert('abrufzeitraum', 'letzter_abruf') === $schluessel ? 'selected' : '' ?>>
<?= View::h($beschriftung) ?>
</option>
<?php endforeach; ?>
</select>
<label class="ankreuzfeld">
<input type="checkbox" name="aktiv" value="1" <?= $neu || (int)$zugang['aktiv'] === 1 ? 'checked' : '' ?>>
aktiv
</label>
<?php if (!$neu): ?>
<h3>Konten bei dieser Bank</h3>
<?php if ($konten === []): ?>
<p class="platzhalter">
Noch nicht bekannt – sie werden beim ersten Abruf ermittelt und stehen
danach hier zur Zuordnung bereit.
</p>
<?php else: ?>
<p class="feldhinweis">
Jedes Bankkonto braucht ein eigenes Konto, sonst lassen sich seine Umsätze
nicht buchen. Was nicht abgerufen werden soll, hier abwählen.
</p>
<table class="liste">
<thead>
<tr><th>Bei der Bank</th><th>Eigenes Konto</th><th class="rechts">Saldo</th><th>Abrufen</th></tr>
</thead>
<tbody>
<?php foreach ($konten as $bankkonto): ?>
<?php $id = (int)$bankkonto['id']; ?>
<tr>
<td>
<?= View::h($bankkonto['iban'] ?: $bankkonto['kontonummer']) ?>
<?php if ($bankkonto['bezeichnung'] !== '' && $bankkonto['bezeichnung'] !== $bankkonto['iban']): ?>
<span class="nebentext"><?= View::h($bankkonto['bezeichnung']) ?></span>
<?php endif; ?>
</td>
<td>
<select name="bankkonto[<?= $id ?>][konto_id]">
<option value="">– nicht zugeordnet –</option>
<?php foreach ($eigene as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"
<?= (int)$bankkonto['konto_id'] === (int)$konto['id'] ? 'selected' : '' ?>>
<?= View::h($konto['name']) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td class="rechts">
<?= $bankkonto['saldo'] === null
? '<span class="nebentext">–</span>'
: View::betrag($bankkonto['saldo']) ?>
</td>
<td>
<label class="ankreuzfeld">
<input type="checkbox" name="bankkonto[<?= $id ?>][abrufen]" value="1"
<?= (int)$bankkonto['abrufen'] === 1 ? 'checked' : '' ?>>
ja
</label>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php endif; ?>
<div class="knopfleiste">
<button type="submit" class="knopf primaer"><?= $neu ? 'Anlegen' : 'Speichern' ?></button>
</div>
</form>
<?php if (!$neu): ?>
<?php if (($abrufe ?? []) !== []): ?>
<h3>Abrufe dieser Verbindung</h3>
<table class="liste">
<thead>
<tr><th>Begonnen</th><th>Status</th><th class="rechts">Neu</th><th>Meldung</th></tr>
</thead>
<tbody>
<?php foreach ($abrufe as $abruf): ?>
<tr>
<td><?= View::h(date('d.m.Y H:i', strtotime((string)$abruf['begonnen_am']))) ?></td>
<td><?= View::h($abruf['status']) ?></td>
<td class="rechts"><?= (int)$abruf['neu'] ?></td>
<td class="klein"><?= View::h($abruf['meldung'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<form method="post" action="/banking/<?= (int)$zugang['id'] ?>/loeschen" class="formular breit"
data-bestaetigen="Bankverbindung „<?= View::h($zugang['name']) ?>“ mit allen abgerufenen Umsätzen löschen?">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<div class="knopfleiste">
<button type="submit" class="knopf gefahr">Bankverbindung löschen</button>
</div>
</form>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

119
src/Views/banking/index.php Normal file
View file

@ -0,0 +1,119 @@
<?php
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\View;
/** @var list<array<string,mixed>> $zugaenge */
/** @var list<array<string,mixed>> $abrufe */
/** @var int $offen */
/** @var bool $schluessel */
/** @var string $csrfToken */
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<span class="tab aktiv">Bankverbindungen</span>
<a href="/banking/umsaetze" class="tab">Umsätze abgleichen<?= $offen > 0 ? ' (' . $offen . ')' : '' ?></a>
<a href="/banking/regeln" class="tab">Regeln</a>
</div>
<?php if (!$schluessel): ?>
<p class="meldung meldung-fehler" role="status">
<strong>APP_KEY fehlt.</strong> Ohne ihn lassen sich keine Zugangsdaten verschlüsselt
ablegen. Eine 64-stellige Hex-Zeichenkette in der Datei <code>.env</code> hinterlegen
(<code>APP_KEY=…</code>) und die Container neu starten.
</p>
<?php endif; ?>
<div class="seitenaktionen">
<a href="/banking/neu" class="knopf primaer">Bankverbindung anlegen</a>
<a href="/banking/umsaetze" class="knopf">Umsätze abgleichen</a>
</div>
<?php if ($zugaenge === []): ?>
<p class="platzhalter">
Noch keine Bankverbindung eingerichtet. Gebraucht werden Bankleitzahl, FinTS-Adresse,
Benutzerkennung und PIN – dieselben Angaben wie im Online-Banking der Bank.
</p>
<?php else: ?>
<table class="liste">
<thead>
<tr>
<th>Name</th>
<th>Bank</th>
<th>Zeitraum</th>
<th>Zuletzt abgerufen</th>
<th class="rechts">Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($zugaenge as $zugang): ?>
<tr<?= (int)$zugang['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
<td><a href="/banking/<?= (int)$zugang['id'] ?>"><?= View::h($zugang['name']) ?></a></td>
<td>
<?= View::h($zugang['bankname'] ?: '–') ?>
<?php if ($zugang['blz'] !== ''): ?>
<span class="nebentext">BLZ <?= View::h($zugang['blz']) ?></span>
<?php endif; ?>
</td>
<td><?= View::h(Bankzugang::ZEITRAEUME[$zugang['abrufzeitraum']] ?? $zugang['abrufzeitraum']) ?></td>
<td>
<?= $zugang['zuletzt_am'] === null
? '<span class="nebentext">noch nie</span>'
: View::h(date('d.m.Y H:i', strtotime((string)$zugang['zuletzt_am']))) ?>
</td>
<td class="rechts nowrap">
<form method="post" action="/banking/<?= (int)$zugang['id'] ?>/abrufen" class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf klein primaer">Umsätze abrufen</button>
</form>
<a href="/banking/<?= (int)$zugang['id'] ?>" class="knopf klein">Bearbeiten</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<h3>Letzte Abrufe</h3>
<?php if ($abrufe === []): ?>
<p class="platzhalter">Noch kein Abruf gelaufen.</p>
<?php else: ?>
<table class="liste">
<thead>
<tr>
<th>Begonnen</th><th>Zugang</th><th>Status</th>
<th class="rechts">Neu</th><th class="rechts">Bekannt</th><th>Meldung</th>
</tr>
</thead>
<tbody>
<?php foreach ($abrufe as $abruf): ?>
<tr>
<td><?= View::h(date('d.m.Y H:i', strtotime((string)$abruf['begonnen_am']))) ?></td>
<td><?= View::h($abruf['zugangsname']) ?></td>
<td>
<span class="marke marke-<?= View::h($abruf['status']) ?>"><?= View::h(match ($abruf['status']) {
'erfolg' => 'erfolgreich',
'fehler' => 'fehlgeschlagen',
'tan_offen' => 'wartet auf Freigabe',
'abgebrochen' => 'abgebrochen',
default => 'läuft',
}) ?></span>
</td>
<td class="rechts"><?= (int)$abruf['neu'] ?></td>
<td class="rechts"><?= (int)$abruf['uebersprungen'] ?></td>
<td class="klein"><?= View::h($abruf['meldung'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<p class="hinweis">
Abgerufene Umsätze werden nicht sofort gebucht: Sie landen in der Liste
<em>Umsätze abgleichen</em>, wo sie mit schon erfassten Buchungen verbunden, nach Regel
übernommen oder verworfen werden.
</p>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,204 @@
<?php
use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\View;
/**
* @var list<array<string,mixed>> $regeln
* @var list<array<string,mixed>> $kategorien
* @var list<array<string,mixed>> $klassen
* @var list<array<string,mixed>> $konten
* @var string $csrfToken
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a href="/banking" class="tab">Bankverbindungen</a>
<a href="/banking/umsaetze" class="tab">Umsätze abgleichen</a>
<span class="tab aktiv">Regeln</span>
</div>
<p class="hinweis">
Eine Regel prüft ein Muster gegen Empfänger und Verwendungszweck und hängt bei einem
Treffer Kategorie, Klasse oder ein Gegenkonto an. Die erste passende Regel gewinnt –
sortiert nach Priorität, kleinere Zahl zuerst.
</p>
<div class="seitenaktionen">
<form method="post" action="/banking/regeln/pruefen" class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf">Gegen offene Umsätze prüfen</button>
</form>
<a href="/banking/umsaetze" class="knopf">Zum Abgleich</a>
</div>
<?php if ($regeln === []): ?>
<p class="platzhalter">Noch keine Regel angelegt.</p>
<?php else: ?>
<?php foreach ($regeln as $regel): ?>
<form id="regel-<?= (int)$regel['id'] ?>" method="post"
action="/banking/regeln/<?= (int)$regel['id'] ?>"></form>
<form id="regel-loeschen-<?= (int)$regel['id'] ?>" method="post"
action="/banking/regeln/<?= (int)$regel['id'] ?>/loeschen"
data-bestaetigen="Regel „<?= View::h($regel['name']) ?>“ löschen?"></form>
<?php endforeach; ?>
<table class="liste regeltabelle">
<thead>
<tr>
<th>Name</th><th>Feld</th><th>Vergleich</th><th>Muster</th>
<th>Kategorie</th><th>Klasse</th>
<th class="rechts">Prio</th><th class="rechts">Treffer</th>
<th>Aktiv</th><th class="rechts">Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($regeln as $regel): ?>
<?php $f = 'regel-' . (int)$regel['id']; ?>
<tr<?= (int)$regel['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
<td>
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="text" form="<?= $f ?>" name="name" value="<?= View::h($regel['name']) ?>" required>
</td>
<td>
<select form="<?= $f ?>" name="feld">
<?php foreach (AbgleichRegel::FELDER as $k => $v): ?>
<option value="<?= View::h($k) ?>" <?= $regel['feld'] === $k ? 'selected' : '' ?>>
<?= View::h($v) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td>
<select form="<?= $f ?>" name="vergleich">
<?php foreach (AbgleichRegel::VERGLEICHE as $k => $v): ?>
<option value="<?= View::h($k) ?>" <?= $regel['vergleich'] === $k ? 'selected' : '' ?>>
<?= View::h($v) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td><input type="text" form="<?= $f ?>" name="muster" value="<?= View::h($regel['muster']) ?>" required></td>
<td>
<select form="<?= $f ?>" name="kategorie_id">
<option value="">–</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= (int)$regel['kategorie_id'] === (int)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td>
<select form="<?= $f ?>" name="klasse_id">
<option value="">–</option>
<?php foreach ($klassen as $klasse): ?>
<option value="<?= (int)$klasse['id'] ?>"
<?= (int)$regel['klasse_id'] === (int)$klasse['id'] ? 'selected' : '' ?>>
<?= View::h($klasse['name']) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td class="rechts">
<input type="number" form="<?= $f ?>" name="prioritaet" class="schmal"
value="<?= (int)$regel['prioritaet'] ?>">
</td>
<td class="rechts"><?= (int)$regel['treffer'] ?></td>
<td>
<label class="ankreuzfeld">
<input type="checkbox" form="<?= $f ?>" name="aktiv" value="1"
<?= (int)$regel['aktiv'] === 1 ? 'checked' : '' ?>>
</label>
</td>
<td class="rechts nowrap">
<input type="hidden" form="regel-loeschen-<?= (int)$regel['id'] ?>"
name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button>
<button type="submit" form="regel-loeschen-<?= (int)$regel['id'] ?>"
class="knopf klein gefahr">Löschen</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<form method="post" action="/banking/regeln/neu" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Neue Regel</h3>
<label for="name">Name</label>
<input type="text" id="name" name="name" required placeholder="z. B. Stadtwerke Strom">
<label for="feld">Prüfen auf</label>
<select id="feld" name="feld">
<?php foreach (AbgleichRegel::FELDER as $k => $v): ?>
<option value="<?= View::h($k) ?>"><?= View::h($v) ?></option>
<?php endforeach; ?>
</select>
<label for="vergleich">Vergleich</label>
<select id="vergleich" name="vergleich">
<?php foreach (AbgleichRegel::VERGLEICHE as $k => $v): ?>
<option value="<?= View::h($k) ?>"><?= View::h($v) ?></option>
<?php endforeach; ?>
</select>
<label for="muster">Muster</label>
<input type="text" id="muster" name="muster" required>
<p class="feldhinweis">
Groß- und Kleinschreibung spielt keine Rolle. Bei <em>regulärer Ausdruck</em> ohne
Trennzeichen eingeben, also <code>Miete|Nebenkosten</code> statt <code>/Miete/</code>.
</p>
<label for="betrag_von">Betrag von / bis <span class="optional">(optional)</span></label>
<div class="feldpaar">
<input type="text" id="betrag_von" name="betrag_von" inputmode="decimal" placeholder="z. B. -60">
<input type="text" name="betrag_bis" inputmode="decimal" placeholder="z. B. -40">
</div>
<p class="feldhinweis">
Mit Vorzeichen: Ausgaben sind negativ, „von −60 bis −40“ trifft Lastschriften
zwischen 40 und 60 Euro.
</p>
<label for="neu_kategorie">Kategorie</label>
<select id="neu_kategorie" name="kategorie_id">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"><?= View::h($kategorie['pfad']) ?></option>
<?php endforeach; ?>
</select>
<label for="neu_klasse">Klasse</label>
<select id="neu_klasse" name="klasse_id">
<option value="">– keine –</option>
<?php foreach ($klassen as $klasse): ?>
<option value="<?= (int)$klasse['id'] ?>"><?= View::h($klasse['name']) ?></option>
<?php endforeach; ?>
</select>
<label for="neu_gegenkonto">Gegenkonto <span class="optional">(macht daraus eine Umbuchung)</span></label>
<select id="neu_gegenkonto" name="gegenkonto_id">
<option value="">– keines –</option>
<?php foreach ($konten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"><?= View::h($konto['name']) ?></option>
<?php endforeach; ?>
</select>
<label for="prioritaet">Priorität</label>
<input type="number" id="prioritaet" name="prioritaet" value="100">
<label class="ankreuzfeld">
<input type="checkbox" name="aktiv" value="1" checked> aktiv
</label>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Regel anlegen</button>
</div>
</form>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

82
src/Views/banking/tan.php Normal file
View file

@ -0,0 +1,82 @@
<?php
use Finanzmanager\View;
/**
* Die TAN-Maske. Sie steht zwischen zwei Requests: Der Dialog mit der Bank ist
* offen und liegt so lange in der Sitzung, bis die Freigabe da ist oder der
* Abruf abgebrochen wird.
*
* @var array<string,mixed> $tan
* @var string $meldung
* @var list<string> $protokoll
* @var string $csrfToken
*/
$entkoppelt = ($tan['entkoppelt'] ?? false) === true;
include __DIR__ . '/../layout/kopf.php';
?>
<div class="kachel einzeln">
<header>
<h2><?= $entkoppelt ? 'Freigabe im Banking-Gerät' : 'TAN eingeben' ?></h2>
<p class="kachelhinweis"><?= View::h($tan['bezeichnung'] ?? 'Abruf') ?></p>
</header>
<p class="hinweis"><?= View::h($meldung) ?></p>
<?php if (($tan['hinweis'] ?? '') !== ''): ?>
<p class="tanhinweis"><?= View::h($tan['hinweis']) ?></p>
<?php endif; ?>
<?php if (($tan['medium'] ?? '') !== ''): ?>
<p class="feldhinweis">TAN-Medium: <?= View::h($tan['medium']) ?></p>
<?php endif; ?>
<?php if (!empty($tan['bild_daten'])): ?>
<?php /* photoTAN: Das Bild kommt als Datenadresse, nicht als Datei –
es gehört zu genau diesem Dialog und ist danach wertlos. */ ?>
<img class="tanbild" alt="TAN-Grafik der Bank"
src="data:<?= View::h($tan['bild_typ'] ?? 'image/png') ?>;base64,<?= View::h($tan['bild_daten']) ?>">
<?php endif; ?>
<form method="post" action="/banking/tan" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<?php if ($entkoppelt): ?>
<p class="feldhinweis">
Die Bank hat die Freigabe an dein Banking-Gerät geschickt. Dort bestätigen und
anschließend hier prüfen lassen – der Abruf läuft dann weiter.
</p>
<input type="hidden" name="tan" value="">
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Freigabe prüfen</button>
</div>
<?php else: ?>
<label for="tan">TAN</label>
<input type="text" id="tan" name="tan" inputmode="numeric" autocomplete="one-time-code"
autofocus required>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">TAN senden</button>
</div>
<?php endif; ?>
</form>
<form method="post" action="/banking/tan/abbrechen" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<div class="knopfleiste">
<button type="submit" class="knopf">Abruf abbrechen</button>
</div>
</form>
</div>
<?php if ($protokoll !== []): ?>
<div class="kachel einzeln">
<header><h2>Bisheriger Verlauf</h2></header>
<pre class="protokolltext"><?php foreach ($protokoll as $zeile): ?><?= View::h($zeile) ?>
<?php endforeach; ?></pre>
</div>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,153 @@
<?php
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\View;
/**
* Die Vorschlagsliste des Abgleichs. Zu jedem offenen Umsatz steht daneben, was
* die Anwendung vorschlägt: mit einer vorhandenen Buchung verbinden, nach Regel
* übernehmen oder von Hand zuordnen.
*
* @var string $status
* @var list<array<string,mixed>> $umsaetze
* @var list<array<string,mixed>> $kategorien
* @var list<array<string,mixed>> $klassen
* @var list<array<string,mixed>> $konten
* @var int $offen
* @var string $csrfToken
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a href="/banking" class="tab">Bankverbindungen</a>
<?php foreach (Bankumsatz::STATUS as $schluessel => $beschriftung): ?>
<a href="/banking/umsaetze?status=<?= View::h($schluessel) ?>"
class="tab <?= $status === $schluessel ? 'aktiv' : '' ?>">
<?= View::h(ucfirst($beschriftung)) ?><?= $schluessel === 'offen' && $offen > 0 ? ' (' . $offen . ')' : '' ?>
</a>
<?php endforeach; ?>
<a href="/banking/regeln" class="tab rechtsbuendig">Regeln</a>
</div>
<?php if ($status === 'offen' && $umsaetze !== []): ?>
<div class="seitenaktionen">
<form method="post" action="/banking/umsaetze/alle" class="inline"
data-bestaetigen="Alle eindeutigen Vorschläge übernehmen? Umsätze ohne Regel bleiben offen.">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf primaer">Alle eindeutigen übernehmen</button>
</form>
<a href="/banking/regeln" class="knopf">Regeln pflegen</a>
</div>
<?php endif; ?>
<?php if ($umsaetze === []): ?>
<p class="platzhalter">
<?= $status === 'offen'
? 'Nichts abzugleichen – alle abgerufenen Umsätze sind erledigt.'
: 'Keine Umsätze mit diesem Status.' ?>
</p>
<?php else: ?>
<?php foreach ($umsaetze as $umsatz): ?>
<?php
$id = (int)$umsatz['id'];
$vorschlag = $umsatz['vorschlag'] ?? null;
$zuordnung = $vorschlag['zuordnung'] ?? null;
$buchung = $vorschlag['buchung'] ?? null;
?>
<section class="umsatz">
<div class="umsatzkopf">
<div class="umsatztext">
<strong><?= View::h($umsatz['empfaenger'] ?: 'Ohne Empfänger') ?></strong>
<span class="nebentext"><?= View::h(View::datum($umsatz['buchungstag'])) ?>
· <?= View::h($umsatz['kontoname'] ?? 'Konto nicht zugeordnet') ?>
· <?= View::h($umsatz['zugangsname']) ?></span>
<p class="verwendungszweck"><?= View::h($umsatz['verwendungszweck'] ?? '') ?></p>
</div>
<div class="umsatzbetrag <?= View::betragKlasse($umsatz['betrag']) ?>">
<?= View::betrag($umsatz['betrag']) ?>
</div>
</div>
<?php if ($status !== 'offen'): ?>
<div class="umsatzaktion">
<span class="nebentext">
<?= View::h(Bankumsatz::STATUS[$umsatz['status']]) ?><?php
if ($umsatz['buchung_id'] !== null): ?>,
<a href="/buchungen/<?= (int)$umsatz['buchung_id'] ?>">zur Buchung</a>
<?php endif; ?>
</span>
<form method="post" action="/banking/umsaetze/<?= $id ?>/zuruecksetzen" class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf klein">Wieder offen</button>
</form>
</div>
<?php else: ?>
<?php if ($buchung !== null): ?>
<?php /* Der stärkste Vorschlag: Die Buchung steht schon im
Kontoblatt, der Umsatz bestätigt sie nur. */ ?>
<form method="post" action="/banking/umsaetze/<?= $id ?>/uebernehmen" class="umsatzaktion">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="buchung_id" value="<?= (int)$buchung['id'] ?>">
<span class="vorschlagsmarke">Vorschlag: verbinden</span>
<span class="nebentext">
mit der Buchung vom <?= View::h(View::datum($buchung['datum'])) ?>
über <?= View::betrag($buchung['betrag']) ?>
<?= $buchung['empfaenger'] !== '' ? '(' . View::h($buchung['empfaenger']) . ')' : '' ?>
</span>
<button type="submit" class="knopf klein primaer">Verbinden</button>
</form>
<?php endif; ?>
<form method="post" action="/banking/umsaetze/<?= $id ?>/uebernehmen" class="umsatzaktion">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<?php if ($vorschlag['regel'] !== null): ?>
<span class="vorschlagsmarke">Regel: <?= View::h($vorschlag['regel']['name']) ?></span>
<?php endif; ?>
<?php if ((int)$umsatz['konto_id'] === 0): ?>
<select name="konto_id" required>
<option value="">– Konto wählen –</option>
<?php foreach ($konten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"><?= View::h($konto['name']) ?></option>
<?php endforeach; ?>
</select>
<?php endif; ?>
<select name="kategorie_id">
<option value="">– ohne Kategorie –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= (int)($zuordnung['kategorie_id'] ?? 0) === (int)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="klasse_id">
<option value="">– ohne Klasse –</option>
<?php foreach ($klassen as $klasse): ?>
<option value="<?= (int)$klasse['id'] ?>"
<?= (int)($zuordnung['klasse_id'] ?? 0) === (int)$klasse['id'] ? 'selected' : '' ?>>
<?= View::h($klasse['name']) ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="knopf klein <?= $buchung === null ? 'primaer' : '' ?>">
Als neue Buchung
</button>
</form>
<form method="post" action="/banking/umsaetze/<?= $id ?>/verwerfen" class="umsatzaktion">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf klein">Verwerfen</button>
</form>
<?php endif; ?>
</section>
<?php endforeach; ?>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,111 @@
<?php
use Finanzmanager\View;
/**
* Ausgaben nach oberster Kategorie als Ring, daneben die Legende mit Betrag und
* Anteil.
*
* Ein Ring statt einer vollen Torte: Die Mitte trägt die Gesamtsumme, und
* Flächen am Rand lassen sich besser vergleichen als spitze Tortenstücke.
*
* @var array{
* zeilen: list<array{id:int, name:string, betrag:float, anteil:float}>,
* summe: float,
* beschriftung: string,
* leer: bool
* } $verteilung
*/
$zeilen = $verteilung['zeilen'];
$mitteX = 80.0;
$mitteY = 80.0;
$aussen = 70.0;
$innen = 44.0;
$punkt = static function (float $anteil, float $radius) use ($mitteX, $mitteY): array {
// Bei null Grad oben beginnen und im Uhrzeigersinn laufen – so liest sich
// der Ring wie eine Uhr.
$winkel = $anteil * 2 * M_PI - M_PI / 2;
return [
round($mitteX + $radius * cos($winkel), 3),
round($mitteY + $radius * sin($winkel), 3),
];
};
/** Ein Ringsegment von $von bis $bis (beides Anteile zwischen 0 und 1). */
$segment = static function (float $von, float $bis) use ($punkt, $aussen, $innen): string {
$gross = ($bis - $von) > 0.5 ? 1 : 0;
[$ax, $ay] = $punkt($von, $aussen);
[$bx, $by] = $punkt($bis, $aussen);
[$cx, $cy] = $punkt($bis, $innen);
[$dx, $dy] = $punkt($von, $innen);
return "M {$ax} {$ay} A {$aussen} {$aussen} 0 {$gross} 1 {$bx} {$by}"
. " L {$cx} {$cy} A {$innen} {$innen} 0 {$gross} 0 {$dx} {$dy} Z";
};
// Ein einziges Segment füllte den ganzen Ring – dann fielen Anfangs- und
// Endpunkt des Bogens zusammen und es entstünde gar keine Fläche. Zwei Hälften
// derselben Farbe zeichnen den vollen Ring.
$pfade = [];
$gelaufen = 0.0;
foreach ($zeilen as $nummer => $zeile) {
$anteil = $zeile['anteil'];
if ($anteil >= 0.9999) {
$pfade[] = ['nummer' => $nummer, 'zeile' => $zeile, 'd' => $segment(0.0, 0.5)];
$pfade[] = ['nummer' => $nummer, 'zeile' => $zeile, 'd' => $segment(0.5, 1.0)];
break;
}
if ($anteil > 0.0005) {
$pfade[] = [
'nummer' => $nummer,
'zeile' => $zeile,
'd' => $segment($gelaufen, $gelaufen + $anteil),
];
}
$gelaufen += $anteil;
}
$serie = static fn(int $nummer): string => 'serie-' . ($nummer % 12 + 1);
?>
<div class="diagrammbereich">
<div class="ringbereich">
<svg class="ringdiagramm" viewBox="0 0 160 160" role="img"
aria-label="Ausgaben nach Kategorie, <?= View::h($verteilung['beschriftung']) ?>">
<title>Ausgaben nach Kategorie <?= View::h($verteilung['beschriftung']) ?></title>
<?php foreach ($pfade as $pfad): ?>
<path class="<?= $serie($pfad['nummer']) ?>" d="<?= View::h($pfad['d']) ?>">
<title><?= View::h(
$pfad['zeile']['name'] . ': ' . View::betrag($pfad['zeile']['betrag'])
. ' (' . number_format($pfad['zeile']['anteil'] * 100, 1, ',', '.') . ' %)'
) ?></title>
</path>
<?php endforeach; ?>
<text class="ringsumme" x="80" y="78" text-anchor="middle"><?= View::h(
View::betrag($verteilung['summe'])
) ?></text>
<text class="ringtext" x="80" y="94" text-anchor="middle">Ausgaben</text>
</svg>
<ul class="ringlegende">
<?php foreach ($zeilen as $nummer => $zeile): ?>
<li>
<span class="legendenfarbe <?= $serie($nummer) ?>" aria-hidden="true"></span>
<span class="legendenname" title="<?= View::h($zeile['name']) ?>"><?= View::h($zeile['name']) ?></span>
<span class="legendenbetrag betrag"><?= View::h(View::betrag($zeile['betrag'])) ?></span>
<span class="legendenanteil"><?= View::h(
number_format($zeile['anteil'] * 100, 0, ',', '.')
) ?> %</span>
</li>
<?php endforeach; ?>
</ul>
</div>
</div>

View file

@ -1,20 +1,46 @@
<?php
use Finanzmanager\Services\Cockpit;
use Finanzmanager\View;
/** @var array{gruppen: list<array<string,mixed>>, summe: float} $uebersicht */
/** @var array{monate: list<array<string,mixed>>, konten: list<array<string,mixed>>, summen: list<float>, hatWerte: bool} $vermoegen */
/** @var bool $nurAktive */
/** @var string $csrfToken */
/**
* Das Cockpit zeichnet nur die Kacheln, die im Einstellungsdialog (Reiter
* Darstellung) angekreuzt sind – in der Reihenfolge des Katalogs.
*
* @var list<string> $sichtbar
* @var bool $nurAktive
* @var string $csrfToken
* @var array|null $vermoegen
* @var array|null $monatsreihe
* @var array|null $ausgaben
* @var array|null $uebersicht
* @var list<array{schluessel:string, text:string, anzahl:int, pfad:string}>|null $vorgaenge
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<span class="tab aktiv">Cockpit</span>
<a href="/einstellungen?bereich=darstellung" class="tab rechtsbuendig">Kacheln wählen</a>
</div>
<?php if ($sichtbar === []): ?>
<section class="kachel einzeln">
<header><h2>Keine Kachel gewählt</h2></header>
<p class="platzhalter">
Im Einstellungsdialog unter <em>Darstellung</em> steht, welche Kacheln das
Cockpit zeigt. Zurzeit ist keine angekreuzt.
</p>
<div class="knopfleiste">
<a href="/einstellungen?bereich=darstellung" class="knopf primaer">Kacheln wählen</a>
</div>
</section>
<?php endif; ?>
<div class="kachelraster">
<?php foreach ($sichtbar as $kachel): ?>
<?php if ($kachel === 'vermoegen'): ?>
<section class="kachel">
<header class="mitschalter">
<h2>Vermögensentwicklung</h2>
@ -35,7 +61,7 @@ include __DIR__ . '/../layout/kopf.php';
<p class="kachelhinweis">Kontostände der letzten zwölf Monate, je Monatsende
gestapelt – ein Farbton pro Konto.</p>
</header>
<?php if (!$vermoegen['hatWerte']): ?>
<?php if (!($vermoegen['hatWerte'] ?? false)): ?>
<p class="platzhalter">
Zeigt die Kontostände über die Zeit, sobald <?= $nurAktive ? 'aktive ' : '' ?>Konten
mit Σ-Kennzeichen Buchungen haben.
@ -45,21 +71,52 @@ include __DIR__ . '/../layout/kopf.php';
<?php endif; ?>
</section>
<?php elseif ($kachel === 'einnahmen_ausgaben'): ?>
<section class="kachel">
<header><h2>Einnahmen und Ausgaben</h2></header>
<header>
<h2>Einnahmen und Ausgaben</h2>
<p class="kachelhinweis">Monatsvergleich der letzten zwölf Monate,
die Linie ist der Saldo je Monat.</p>
</header>
<?php if (!($monatsreihe['hatWerte'] ?? false)): ?>
<p class="platzhalter">
Der Monatsvergleich als Grafik folgt noch. Die Zahlen dazu stehen bereits im
Summenbericht – mit Spalten-Aufteilung nach Monat.
Zeigt Einnahmen und Ausgaben je Monat, sobald kategorisierte Buchungen
vorliegen. Umbuchungen zählen wie überall in den Auswertungen nicht mit.
</p>
<?php else: ?>
<?php $reihe = $monatsreihe; include __DIR__ . '/monatsdiagramm.php'; ?>
<?php endif; ?>
<div class="knopfleiste">
<a href="/auswertungen/bericht?typ=summenbericht&amp;zeitraum=aktuelles_jahr&amp;aufteilung=monat&amp;angepasst=1"
<a href="/auswertungen/bericht?typ=summenbericht&amp;zeitraum=letzte_12_monate&amp;aufteilung=monat&amp;angepasst=1"
class="knopf">Summenbericht öffnen</a>
</div>
</section>
<?php elseif ($kachel === 'ausgaben'): ?>
<section class="kachel">
<header>
<h2>Meine Ausgaben</h2>
<p class="kachelhinweis">Laufendes Jahr, nach oberster Kategorie –
<?= View::h($ausgaben['beschriftung'] ?? '') ?>.</p>
</header>
<?php if ($ausgaben['leer'] ?? true): ?>
<p class="platzhalter">
Zeigt die Verteilung der Ausgaben, sobald das laufende Jahr
kategorisierte Buchungen hat.
</p>
<?php else: ?>
<?php $verteilung = $ausgaben; include __DIR__ . '/ausgabendiagramm.php'; ?>
<?php endif; ?>
<div class="knopfleiste">
<a href="/auswertungen/bericht?typ=summenbericht&amp;zeitraum=aktuelles_jahr&amp;angepasst=1"
class="knopf">Nach Kategorie aufschlüsseln</a>
</div>
</section>
<?php elseif ($kachel === 'kontostaende'): ?>
<section class="kachel">
<header><h2>Kontostände</h2></header>
<?php if ($uebersicht['gruppen'] === []): ?>
<?php if (($uebersicht['gruppen'] ?? []) === []): ?>
<p class="platzhalter">
Noch keine Konten angelegt – einzeln oder gemeinsam aus dem QIF-Export.
</p>
@ -94,17 +151,50 @@ include __DIR__ . '/../layout/kopf.php';
<?php endif; ?>
</section>
<?php elseif ($kachel === 'offene_vorgaenge'): ?>
<section class="kachel">
<header><h2>Aufbaustand</h2></header>
<ul class="checkliste">
<li class="erledigt">Phase 0 – Grundgerüst, Anmeldung, Einstellungen</li>
<li class="erledigt">Phase 1 – Konten, Kategorien, Klassen, QIF-Stammdaten</li>
<li class="erledigt">Phase 2 – Buchungen, Kontoblatt, Splitbuchungen</li>
<li class="erledigt">Phase 3 – Budgetplaner und Monatsabschluss</li>
<li class="erledigt">Phase 4 – Berichtsmodul mit fünf Berichtstypen</li>
<li>Phase 4 – die übrigen Grafiken und Cockpit-Kacheln</li>
<header><h2>Offene Vorgänge</h2></header>
<?php if (($vorgaenge ?? []) === []): ?>
<p class="platzhalter">
Nichts offen: Alle Buchungen sind kategorisiert und abgeglichen,
der Monatsabschluss ist auf dem Laufenden.
</p>
<?php else: ?>
<ul class="vorgangsliste">
<?php foreach ($vorgaenge as $vorgang): ?>
<li>
<?php /* Die beiden Buchungszähler bekommen ihr Substantiv
hier, die übrigen Arten tragen es im Text – „7
fällige Erinnerungen“ liest sich besser als „7
Buchungen fällige Erinnerungen“. */ ?>
<a href="<?= View::h($vorgang['pfad']) ?>">
<?php if ($vorgang['schluessel'] === 'monatsabschluss'): ?>
<?= View::h($vorgang['text']) ?>
<?php else: ?>
<span class="vorgangszahl"><?= (int)$vorgang['anzahl'] ?></span>
<?php if (in_array($vorgang['schluessel'], ['ohne_kategorie', 'nicht_abgeglichen'], true)): ?>
<?= View::h($vorgang['anzahl'] === 1 ? 'Buchung ' : 'Buchungen ') ?>
<?php endif; ?>
<?= View::h($vorgang['text']) ?>
<?php endif; ?>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</section>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php /* Der Katalog steht im Service, damit Cockpit und Einstellungsdialog
dieselbe Liste kennen; hier wird er nur gebraucht, wenn eine Kachel
abgewählt wurde. */ ?>
<?php if (count($sichtbar) < count(Cockpit::KACHELN) && $sichtbar !== []): ?>
<p class="diagrammpfad kachelfuss">
<?= count(Cockpit::KACHELN) - count($sichtbar) ?> weitere Kachel(n) sind abgeschaltet –
<a href="/einstellungen?bereich=darstellung">in den Einstellungen wählen</a>.
</p>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,138 @@
<?php
use Finanzmanager\Services\Vermoegensentwicklung;
use Finanzmanager\View;
/**
* Einnahmen und Ausgaben je Monat als Balkenpaar, darüber der Saldo als Linie.
*
* Aufbau wie beim Vermögensdiagramm: Achsenbeschriftung als festes SVG links,
* Zeichenfläche daneben mit waagerechten Prozentpositionen – so rücken die
* Balken in einer breiten Kachel auseinander, statt breiter zu werden.
*
* @var array{
* monate: list<array{schluessel:string, kurz:string, titel:string}>,
* einnahmen: list<float>,
* ausgaben: list<float>,
* salden: list<float>,
* summe_einnahmen: float,
* summe_ausgaben: float,
* beschriftung: string,
* hatWerte: bool
* } $reihe
*/
$monate = $reihe['monate'];
$anzahl = max(1, count($monate));
$skala = Vermoegensentwicklung::skala(
array_merge($reihe['einnahmen'], $reihe['ausgaben'], $reihe['salden'], [0.0])
);
$balken = 8;
$luecke = 2;
$achse = 56;
$oben = 10;
$plotH = 150;
$unten = 32;
$hoehe = $oben + $plotH + $unten;
$y = static function (float $wert) use ($skala, $oben, $plotH): float {
return round($oben + $plotH * ($skala['max'] - $wert) / ($skala['max'] - $skala['min']), 2);
};
$mitte = static function (int $nummer) use ($anzahl): string {
return round(($nummer + 0.5) / $anzahl * 100, 3) . '%';
};
$achsenzahl = static fn(float $wert): string => number_format($wert, 0, ',', '.');
$nulllinie = $y(0.0);
/** Ein Balken von der Nulllinie bis zum Wert – y und Höhe in einem. */
$saeule = static function (float $wert) use ($y, $nulllinie): array {
$spitze = $y($wert);
return ['y' => min($spitze, $nulllinie), 'h' => round(abs($nulllinie - $spitze), 2)];
};
?>
<div class="diagrammbereich">
<div class="diagrammflaeche">
<svg class="diagrammachse" width="<?= $achse ?>" height="<?= $hoehe ?>" aria-hidden="true">
<?php for ($wert = $skala['min']; $wert <= $skala['max'] + 0.001; $wert += $skala['schritt']): ?>
<text class="diagramm-achse" x="<?= $achse - 8 ?>" y="<?= $y($wert) + 4 ?>"
text-anchor="end"><?= View::h($achsenzahl($wert)) ?></text>
<?php endfor; ?>
</svg>
<svg class="diagramm" height="<?= $hoehe ?>" role="img"
aria-label="Einnahmen und Ausgaben je Monat, <?= View::h($reihe['beschriftung']) ?>">
<title>Einnahmen und Ausgaben <?= View::h($reihe['beschriftung']) ?></title>
<?php for ($wert = $skala['min']; $wert <= $skala['max'] + 0.001; $wert += $skala['schritt']): ?>
<line class="diagramm-gitter" x1="0" x2="100%"
y1="<?= $y($wert) ?>" y2="<?= $y($wert) ?>"></line>
<?php endfor; ?>
<line class="diagramm-nulllinie" x1="0" x2="100%" y1="<?= $nulllinie ?>" y2="<?= $nulllinie ?>"></line>
<?php foreach ($monate as $nummer => $monat): ?>
<?php
$einnahme = $reihe['einnahmen'][$nummer] ?? 0.0;
$ausgabe = $reihe['ausgaben'][$nummer] ?? 0.0;
$e = $saeule($einnahme);
$a = $saeule($ausgabe);
?>
<?php /* Das Paar sitzt mittig über der Spalte: eine halbe Paarbreite
nach links, dann stehen beide Balken um die Mitte herum. */ ?>
<g transform="translate(-<?= $balken + $luecke / 2 ?> 0)">
<rect class="balken-einnahme" x="<?= $mitte($nummer) ?>" y="<?= $e['y'] ?>"
width="<?= $balken ?>" height="<?= $e['h'] ?>">
<title><?= View::h('Einnahmen ' . $monat['titel'] . ': ' . View::betrag($einnahme)) ?></title>
</rect>
</g>
<g transform="translate(<?= $luecke / 2 ?> 0)">
<rect class="balken-ausgabe" x="<?= $mitte($nummer) ?>" y="<?= $a['y'] ?>"
width="<?= $balken ?>" height="<?= $a['h'] ?>">
<title><?= View::h('Ausgaben ' . $monat['titel'] . ': ' . View::betrag($ausgabe)) ?></title>
</rect>
</g>
<text class="diagramm-beschriftung" x="<?= $mitte($nummer) ?>" y="<?= $hoehe - 18 ?>"
text-anchor="middle"><?= View::h(substr($monat['kurz'], 0, 3)) ?></text>
<?php if (str_ends_with($monat['schluessel'], '-01') || $nummer === 0): ?>
<text class="diagramm-beschriftung schwach" x="<?= $mitte($nummer) ?>" y="<?= $hoehe - 5 ?>"
text-anchor="middle"><?= View::h(substr($monat['schluessel'], 2, 2)) ?></text>
<?php endif; ?>
<?php endforeach; ?>
<?php /* Der Saldo als Linie über den Balken: Ein dritter Balken machte
die Spalte eng, die Linie zeigt den Verlauf ohnehin besser. */ ?>
<?php foreach ($reihe['salden'] as $nummer => $saldo): ?>
<?php if ($nummer > 0): ?>
<line class="diagramm-saldo" x1="<?= $mitte($nummer - 1) ?>" y1="<?= $y($reihe['salden'][$nummer - 1]) ?>"
x2="<?= $mitte($nummer) ?>" y2="<?= $y($saldo) ?>"></line>
<?php endif; ?>
<circle class="diagramm-saldo-punkt" cx="<?= $mitte($nummer) ?>" cy="<?= $y($saldo) ?>" r="2.5">
<title><?= View::h('Saldo ' . $monate[$nummer]['titel'] . ': ' . View::betrag($saldo)) ?></title>
</circle>
<?php endforeach; ?>
</svg>
</div>
<ul class="diagrammlegende">
<li>
<span class="legendenfarbe balken-einnahme" aria-hidden="true"></span>
<span class="legendenname">Einnahmen <?= View::h(View::betrag($reihe['summe_einnahmen'])) ?></span>
</li>
<li>
<span class="legendenfarbe balken-ausgabe" aria-hidden="true"></span>
<span class="legendenname">Ausgaben <?= View::h(View::betrag($reihe['summe_ausgaben'])) ?></span>
</li>
<li>
<span class="legendenfarbe saldofarbe" aria-hidden="true"></span>
<span class="legendenname">Saldo
<?= View::h(View::betrag($reihe['summe_einnahmen'] - $reihe['summe_ausgaben'])) ?></span>
</li>
</ul>
</div>

View file

@ -1,11 +1,14 @@
<?php
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Services\Cockpit;
use Finanzmanager\Services\Kursabruf;
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 list<string> $kacheln */
/** @var string $csrfToken */
$wert = static fn(string $k, string $standard = ''): string => (string)($werte[$k] ?? $standard);
@ -47,6 +50,21 @@ include __DIR__ . '/../layout/kopf.php';
value="<?= View::h($wert('zeilen_pro_seite', '100')) ?>">
<p class="feldhinweis">Gilt für Kontoblatt und Berichtslisten.</p>
<h3>Kacheln des Cockpits</h3>
<p class="feldhinweis">
Was abgewählt ist, wird auch nicht berechnet – das Cockpit lädt dann schneller.
Die Reihenfolge im Raster ist die dieser Liste.
</p>
<?php foreach (Cockpit::KACHELN as $schluessel => $kachel): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="<?= View::h(Cockpit::EINSTELLUNG) ?>[]"
value="<?= View::h($schluessel) ?>"
<?= in_array($schluessel, $kacheln, true) ? 'checked' : '' ?>>
<?= View::h($kachel['name']) ?>
<span class="feldhinweis inline"><?= View::h($kachel['hinweis']) ?></span>
</label>
<?php endforeach; ?>
<?php elseif ($bereich === 'buchhaltung'): ?>
<label for="erster_monat">Erster Monat des Haushaltsjahres</label>
<select id="erster_monat" name="erster_monat">
@ -82,6 +100,76 @@ include __DIR__ . '/../layout/kopf.php';
<a href="/verwaltung/import" class="knopf">QIF-Import öffnen</a>
</div>
<?php elseif ($bereich === 'banking'): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="banking_regeln_beim_abruf" value="1"
<?= $wert('banking_regeln_beim_abruf', '1') === '1' ? 'checked' : '' ?>>
Regeln schon beim Abruf anwenden
</label>
<p class="feldhinweis">
Die Vorschlagsliste zeigt dann gleich die Kategorie aus der passenden Regel.
Gebucht wird trotzdem erst, wenn der Umsatz übernommen wird.
</p>
<label for="banking_abgleich_tage">Toleranz beim Abgleich (Tage)</label>
<input type="number" id="banking_abgleich_tage" name="banking_abgleich_tage"
min="0" max="30" value="<?= View::h($wert('banking_abgleich_tage', '5')) ?>">
<p class="feldhinweis">
Wie weit Buchungsdatum und Banktag auseinanderliegen dürfen, damit ein Umsatz
einer schon erfassten Buchung zugeordnet wird. Bei 0 muss der Tag genau stimmen.
</p>
<label for="banking_produkt_id">Produktkennung <span class="optional">(optional)</span></label>
<input type="text" id="banking_produkt_id" name="banking_produkt_id"
value="<?= View::h($wert('banking_produkt_id')) ?>">
<p class="feldhinweis">
Kennung, mit der sich die Anwendung bei der Bank meldet. Leer lassen genügt in
aller Regel; eine eigene Kennung vergibt die Deutsche Kreditwirtschaft auf Antrag.
</p>
<div class="knopfleiste">
<a href="/banking" class="knopf">Bankverbindungen verwalten</a>
</div>
<?php elseif ($bereich === 'wertpapiere'): ?>
<label for="wertpapiere_kursdienst">Kursdienst</label>
<select id="wertpapiere_kursdienst" name="wertpapiere_kursdienst">
<?php foreach (Kursabruf::ANBIETER as $schluessel => $name): ?>
<option value="<?= View::h($schluessel) ?>"
<?= $wert('wertpapiere_kursdienst', 'leeway') === $schluessel ? 'selected' : '' ?>>
<?= View::h($name) ?>
</option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">
Beide kennen ISINs und haben ein kostenloses Tageskontingent, das für rund zwanzig
Positionen reicht.
</p>
<label for="wertpapiere_api_key">API-Schlüssel</label>
<input type="text" id="wertpapiere_api_key" name="wertpapiere_api_key" autocomplete="off"
value="<?= View::h($wert('wertpapiere_api_key')) ?>">
<p class="feldhinweis">
Nach kostenloser Anmeldung beim gewählten Dienst. Ohne Schlüssel lassen sich Kurse
nur von Hand eintragen.
</p>
<label class="ankreuzfeld">
<input type="checkbox" name="wertpapiere_kursabruf_aktiv" value="1"
<?= $wert('wertpapiere_kursabruf_aktiv') === '1' ? 'checked' : '' ?>>
Kurse täglich automatisch abrufen
</label>
<p class="feldhinweis">
Der Lauf gehört in einen Cron-Eintrag:
<code>docker compose exec -u www-data php php scripts/kursabruf.php</code>.
Ohne diesen Haken bricht das Skript ab – der Knopf auf der Wertpapierseite
funktioniert weiterhin.
</p>
<div class="knopfleiste">
<a href="/wertpapiere/stammdaten" class="knopf">Wertpapiere verwalten</a>
</div>
<?php elseif ($bereich === 'diagnose'): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="debug_log_aktiv" value="1"
@ -118,15 +206,11 @@ include __DIR__ . '/../layout/kopf.php';
<?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)',
'wertpapiere' => 'Kursabruf und Depoteinstellungen (Phase 6)',
default => 'noch offen',
}) ?>.
<?= View::h('noch offen') ?>.
</p>
<?php endif; ?>
<?php if (in_array($bereich, ['allgemein', 'darstellung', 'buchhaltung', 'import', 'diagnose'], true)): ?>
<?php if (in_array($bereich, ['allgemein', 'darstellung', 'buchhaltung', 'banking', 'import', 'wertpapiere', 'diagnose'], true)): ?>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Speichern</button>
</div>

View file

@ -10,9 +10,12 @@ use Finanzmanager\View;
$navigation = [
['pfad' => '/', 'name' => 'Cockpit', 'icon' => '⌂'],
['pfad' => '/konten', 'name' => 'Kontenübersicht', 'icon' => '▤'],
['pfad' => '/banking', 'name' => 'Online-Banking', 'icon' => '⇊'],
['pfad' => '/zahlungsverkehr', 'name' => 'Zahlungsverkehr', 'icon' => '⇄'],
['pfad' => '/auswertungen', 'name' => 'Auswertungen', 'icon' => '◱'],
['pfad' => '/planung', 'name' => 'Planung', 'icon' => '◷'],
['pfad' => '/termine', 'name' => 'Termine', 'icon' => '⏱'],
['pfad' => '/vertraege', 'name' => 'Verträge', 'icon' => '§'],
['pfad' => '/wertpapiere', 'name' => 'Wertpapiere', 'icon' => '↗'],
['pfad' => '/verwaltung', 'name' => 'Verwaltung', 'icon' => '⚙'],
];

View file

@ -1,18 +0,0 @@
<?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'; ?>

View file

@ -0,0 +1,86 @@
<?php
use Finanzmanager\View;
/**
* @var list<array<string,mixed>> $erinnerungen
* @var string $csrfToken
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a href="/vorlagen" class="tab">Buchungsvorlagen</a>
<a href="/termine" class="tab">Regelmäßige Buchungen</a>
<a href="/termine/kalender" class="tab">Finanzkalender</a>
<span class="tab aktiv">Erinnerungen</span>
</div>
<p class="hinweis">
Für alles, was zu einem Datum ansteht, aber keine Buchung ist – „Versicherung
kündigen“, „Steuerbescheid prüfen“. Offene Erinnerungen erscheinen im Finanzkalender.
</p>
<?php if ($erinnerungen === []): ?>
<p class="platzhalter">Keine Erinnerungen.</p>
<?php else: ?>
<table class="liste">
<thead>
<tr><th>Fällig am</th><th>Erinnerung</th><th>Notiz</th><th class="rechts">Aktion</th></tr>
</thead>
<tbody>
<?php foreach ($erinnerungen as $erinnerung): ?>
<?php
$offen = (int)$erinnerung['erledigt'] === 0;
$ueberfaellig = $offen && (string)$erinnerung['faellig_am'] < date('Y-m-d');
?>
<tr class="<?= $offen ? ($ueberfaellig ? 'ueberfaellig' : '') : 'inaktiv' ?>">
<td><?= View::h(View::datum($erinnerung['faellig_am'])) ?></td>
<td>
<?= View::h($erinnerung['text']) ?>
<?php if ($ueberfaellig): ?>
<span class="marke marke-fehler">überfällig</span>
<?php endif; ?>
</td>
<td class="klein"><?= View::h($erinnerung['notiz'] ?? '') ?></td>
<td class="rechts nowrap">
<form method="post" action="/termine/erinnerungen/<?= (int)$erinnerung['id'] ?>/erledigen"
class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="erledigt" value="<?= $offen ? '1' : '0' ?>">
<button type="submit" class="knopf klein">
<?= $offen ? 'Erledigt' : 'Wieder offen' ?>
</button>
</form>
<form method="post" action="/termine/erinnerungen/<?= (int)$erinnerung['id'] ?>/loeschen"
class="inline" data-bestaetigen="Erinnerung löschen?">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf klein gefahr">Löschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<form method="post" action="/termine/erinnerungen/neu" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Neue Erinnerung</h3>
<label for="text">Erinnerung</label>
<input type="text" id="text" name="text" required>
<label for="faellig_am">Fällig am</label>
<input type="date" id="faellig_am" name="faellig_am" value="<?= View::h(date('Y-m-d')) ?>" required>
<label for="notiz">Notiz <span class="optional">(optional)</span></label>
<input type="text" id="notiz" name="notiz">
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Erinnerung anlegen</button>
</div>
</form>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

240
src/Views/termine/index.php Normal file
View file

@ -0,0 +1,240 @@
<?php
use Finanzmanager\View;
/**
* @var list<array<string,mixed>> $reihen
* @var list<array<string,mixed>> $faellig
* @var array{faellig:int, automatisch:int, wartend:int} $vorschau
* @var list<array<string,mixed>> $konten
* @var list<array<string,mixed>> $kategorien
* @var list<array<string,mixed>> $klassen
* @var array<string,string> $intervalle
* @var string $csrfToken
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a href="/vorlagen" class="tab">Buchungsvorlagen</a>
<span class="tab aktiv">Regelmäßige Buchungen</span>
<a href="/termine/kalender" class="tab">Finanzkalender</a>
<a href="/termine/erinnerungen" class="tab">Erinnerungen</a>
</div>
<?php if ($vorschau['faellig'] > 0): ?>
<div class="seitenaktionen">
<span class="hinweis">
<?= (int)$vorschau['faellig'] ?> Termin(e) fällig,
davon <?= (int)$vorschau['automatisch'] ?> automatisch.
</span>
<?php if ($vorschau['automatisch'] > 0): ?>
<form method="post" action="/termine/lauf" class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf primaer">Fälligkeitslauf starten</button>
</form>
<?php endif; ?>
</div>
<h3>Jetzt fällig</h3>
<table class="liste">
<thead>
<tr><th>Fällig am</th><th>Name</th><th>Konto</th><th class="rechts">Betrag</th>
<th>Art</th><th class="rechts">Aktion</th></tr>
</thead>
<tbody>
<?php foreach ($faellig as $reihe): ?>
<tr<?= (string)$reihe['naechste_faelligkeit'] < date('Y-m-d') ? ' class="ueberfaellig"' : '' ?>>
<td><?= View::h(View::datum($reihe['naechste_faelligkeit'])) ?></td>
<td><?= View::h($reihe['name']) ?></td>
<td><?= View::h($reihe['kontoname']) ?></td>
<td class="rechts <?= View::betragKlasse($reihe['betrag']) ?>">
<?= View::betrag($reihe['betrag']) ?>
</td>
<td>
<?= (int)$reihe['automatisch'] === 1
? '<span class="marke">automatisch</span>'
: '<span class="marke">auf Bestätigung</span>' ?>
</td>
<td class="rechts nowrap">
<form method="post" action="/termine/<?= (int)$reihe['id'] ?>/buchen" class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf klein primaer">Buchen</button>
</form>
<form method="post" action="/termine/<?= (int)$reihe['id'] ?>/ueberspringen" class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf klein">Überspringen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<p class="hinweis">Zurzeit ist nichts fällig.</p>
<?php endif; ?>
<h3>Alle Reihen</h3>
<?php if ($reihen === []): ?>
<p class="platzhalter">Noch keine regelmäßige Buchung angelegt.</p>
<?php else: ?>
<?php foreach ($reihen as $reihe): ?>
<form id="reihe-<?= (int)$reihe['id'] ?>" method="post"
action="/termine/<?= (int)$reihe['id'] ?>"></form>
<form id="reihe-loeschen-<?= (int)$reihe['id'] ?>" method="post"
action="/termine/<?= (int)$reihe['id'] ?>/loeschen"
data-bestaetigen="Reihe „<?= View::h($reihe['name']) ?>“ löschen? Gebuchte Zahlungen bleiben erhalten."></form>
<?php endforeach; ?>
<table class="liste">
<thead>
<tr>
<th>Name</th><th>Konto</th><th class="rechts">Betrag</th><th>Rhythmus</th>
<th>Nächster Termin</th><th>Ende</th><th>Auto</th><th>Aktiv</th><th class="rechts">Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($reihen as $reihe): ?>
<?php $f = 'reihe-' . (int)$reihe['id']; ?>
<tr<?= (int)$reihe['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
<td>
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="text" form="<?= $f ?>" name="name" value="<?= View::h($reihe['name']) ?>" required>
<input type="hidden" form="<?= $f ?>" name="empfaenger" value="<?= View::h($reihe['empfaenger']) ?>">
<input type="hidden" form="<?= $f ?>" name="buchungstext" value="<?= View::h($reihe['buchungstext'] ?? '') ?>">
<input type="hidden" form="<?= $f ?>" name="kategorie_id" value="<?= View::h((string)$reihe['kategorie_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="klasse_id" value="<?= View::h((string)$reihe['klasse_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="gegenkonto_id" value="<?= View::h((string)$reihe['gegenkonto_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="vorlauf_tage" value="<?= (int)$reihe['vorlauf_tage'] ?>">
<input type="hidden" form="<?= $f ?>" name="faktor" value="<?= (int)$reihe['faktor'] ?>">
</td>
<td>
<select form="<?= $f ?>" name="konto_id">
<?php foreach ($konten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"
<?= (int)$reihe['konto_id'] === (int)$konto['id'] ? 'selected' : '' ?>>
<?= View::h($konto['name']) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td class="rechts">
<input type="text" form="<?= $f ?>" name="betrag" class="schmal" inputmode="decimal"
value="<?= View::h(number_format((float)$reihe['betrag'], 2, ',', '')) ?>">
</td>
<td>
<select form="<?= $f ?>" name="intervall">
<?php foreach ($intervalle as $schluessel => $name): ?>
<option value="<?= View::h($schluessel) ?>"
<?= $reihe['intervall'] === $schluessel ? 'selected' : '' ?>>
<?= View::h($name) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td>
<?= View::h(View::datum($reihe['naechste_faelligkeit'])) ?>
<input type="hidden" form="<?= $f ?>" name="beginn"
value="<?= View::h((string)$reihe['beginn']) ?>">
</td>
<td>
<input type="date" form="<?= $f ?>" name="ende"
value="<?= View::h((string)($reihe['ende'] ?? '')) ?>">
</td>
<td>
<label class="ankreuzfeld">
<input type="checkbox" form="<?= $f ?>" name="automatisch" value="1"
<?= (int)$reihe['automatisch'] === 1 ? 'checked' : '' ?>>
</label>
</td>
<td>
<label class="ankreuzfeld">
<input type="checkbox" form="<?= $f ?>" name="aktiv" value="1"
<?= (int)$reihe['aktiv'] === 1 ? 'checked' : '' ?>>
</label>
</td>
<td class="rechts nowrap">
<input type="hidden" form="reihe-loeschen-<?= (int)$reihe['id'] ?>"
name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button>
<button type="submit" form="reihe-loeschen-<?= (int)$reihe['id'] ?>"
class="knopf klein gefahr">Löschen</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<form method="post" action="/termine/neu" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Neue regelmäßige Buchung</h3>
<label for="name">Name</label>
<input type="text" id="name" name="name" required placeholder="z. B. Miete">
<label for="neu_konto">Konto</label>
<select id="neu_konto" name="konto_id" required>
<?php foreach ($konten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"><?= View::h($konto['name']) ?></option>
<?php endforeach; ?>
</select>
<label for="neu_betrag">Betrag</label>
<input type="text" id="neu_betrag" name="betrag" inputmode="decimal" required
placeholder="Ausgaben mit Minus, z. B. -850,00">
<label for="neu_empfaenger">Empfänger</label>
<input type="text" id="neu_empfaenger" name="empfaenger">
<label for="neu_kategorie">Kategorie</label>
<select id="neu_kategorie" name="kategorie_id">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"><?= View::h($kategorie['pfad']) ?></option>
<?php endforeach; ?>
</select>
<label for="neu_klasse">Klasse</label>
<select id="neu_klasse" name="klasse_id">
<option value="">– keine –</option>
<?php foreach ($klassen as $klasse): ?>
<option value="<?= (int)$klasse['id'] ?>"><?= View::h($klasse['name']) ?></option>
<?php endforeach; ?>
</select>
<label for="neu_intervall">Rhythmus</label>
<select id="neu_intervall" name="intervall">
<?php foreach ($intervalle as $schluessel => $name): ?>
<option value="<?= View::h($schluessel) ?>" <?= $schluessel === 'monatlich' ? 'selected' : '' ?>>
<?= View::h($name) ?>
</option>
<?php endforeach; ?>
</select>
<label for="neu_beginn">Erster Termin</label>
<input type="date" id="neu_beginn" name="beginn" value="<?= View::h(date('Y-m-d')) ?>" required>
<label for="neu_ende">Letzter Termin <span class="optional">(optional)</span></label>
<input type="date" id="neu_ende" name="ende">
<label class="ankreuzfeld">
<input type="checkbox" name="automatisch" value="1">
automatisch buchen
</label>
<p class="feldhinweis">
Ohne Haken meldet sich die Reihe nur und wartet auf Bestätigung – richtig überall
dort, wo der Betrag schwankt.
</p>
<label class="ankreuzfeld">
<input type="checkbox" name="aktiv" value="1" checked> aktiv
</label>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Reihe anlegen</button>
</div>
</form>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,72 @@
<?php
use Finanzmanager\Services\Finanzkalender;
use Finanzmanager\View;
/**
* @var list<array{monat:string, titel:string, termine:list<array<string,mixed>>}> $monate
* @var int $tage
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a href="/vorlagen" class="tab">Buchungsvorlagen</a>
<a href="/termine" class="tab">Regelmäßige Buchungen</a>
<span class="tab aktiv">Finanzkalender</span>
<a href="/termine/erinnerungen" class="tab">Erinnerungen</a>
</div>
<form method="get" action="/termine/kalender" class="filterleiste">
<label for="tage">Vorausschau</label>
<?php /* Kein onchange="this.form.submit()": Inline-Skripte verbietet die
Content-Security-Policy, der Zeitraum ließe sich dann nicht ändern.
Der Knopf daneben schickt das Formular ab. */ ?>
<select id="tage" name="tage">
<?php foreach ([30 => '30 Tage', 60 => '60 Tage', 90 => '3 Monate', 180 => '6 Monate', 365 => '1 Jahr'] as $wert => $name): ?>
<option value="<?= $wert ?>" <?= $tage === $wert ? 'selected' : '' ?>><?= View::h($name) ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="knopf">Anzeigen</button>
</form>
<p class="hinweis">
Zusammengetragen aus regelmäßigen Buchungen, Erinnerungen, Kündigungsfristen und
terminierten Überweisungen. Die Termine sind gerechnet, nicht gespeichert – eine
Änderung an einer Reihe wirkt sich sofort aus.
</p>
<?php if ($monate === []): ?>
<p class="platzhalter">In diesem Zeitraum steht nichts an.</p>
<?php else: ?>
<?php foreach ($monate as $monat): ?>
<section class="kalendermonat">
<h3><?= View::h($monat['titel']) ?></h3>
<table class="liste">
<tbody>
<?php foreach ($monat['termine'] as $termin): ?>
<tr<?= $termin['ueberfaellig'] ? ' class="ueberfaellig"' : '' ?>>
<td class="kalendertag">
<span class="tagzahl"><?= View::h(date('d', strtotime($termin['datum']))) ?></span>
<span class="nebentext"><?= View::h(View::wochentag($termin['datum'])) ?></span>
</td>
<td>
<a href="<?= View::h($termin['pfad']) ?>"><?= View::h($termin['titel']) ?></a>
<span class="marke"><?= View::h(Finanzkalender::ARTEN[$termin['art']] ?? '') ?></span>
<?php if ($termin['ueberfaellig']): ?>
<span class="marke marke-fehler">überfällig</span>
<?php endif; ?>
</td>
<td class="rechts <?= $termin['betrag'] === null ? '' : View::betragKlasse($termin['betrag']) ?>">
<?= $termin['betrag'] === null ? '' : View::betrag($termin['betrag']) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</section>
<?php endforeach; ?>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,151 @@
<?php
use Finanzmanager\Models\Adresse;
use Finanzmanager\View;
/**
* @var list<array<string,mixed>> $adressen
* @var string $suche
* @var string $csrfToken
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a href="/vertraege" class="tab">Verträge</a>
<span class="tab aktiv">Adressen</span>
<a href="/vertraege/sparziele" class="tab">Sparziele</a>
<a href="/vertraege/kredite" class="tab">Kredite</a>
</div>
<p class="hinweis">
Das Adressbuch speist die Empfängerauswahl im Zahlungsverkehr. IBANs werden beim
Speichern geprüft – die Prüfziffer fängt Zahlendreher ab, bevor eine Überweisung
hinausgeht.
</p>
<form method="get" action="/vertraege/adressen" class="filterleiste">
<label for="suche">Suchen</label>
<input type="search" id="suche" name="suche" value="<?= View::h($suche) ?>"
placeholder="Name, Ort oder IBAN">
<button type="submit" class="knopf">Suchen</button>
<?php if ($suche !== ''): ?>
<a href="/vertraege/adressen" class="knopf">Alle zeigen</a>
<?php endif; ?>
</form>
<?php if ($adressen === []): ?>
<p class="platzhalter">
<?= $suche === '' ? 'Noch keine Adressen erfasst.' : 'Keine Adresse passt zur Suche.' ?>
</p>
<?php else: ?>
<?php foreach ($adressen as $adresse): ?>
<form id="adresse-<?= (int)$adresse['id'] ?>" method="post"
action="/vertraege/adressen/<?= (int)$adresse['id'] ?>"></form>
<form id="adresse-loeschen-<?= (int)$adresse['id'] ?>" method="post"
action="/vertraege/adressen/<?= (int)$adresse['id'] ?>/loeschen"
data-bestaetigen="Adresse „<?= View::h($adresse['name']) ?>“ löschen?"></form>
<?php endforeach; ?>
<table class="liste">
<thead>
<tr><th>Name</th><th>Ort</th><th>IBAN</th><th>BIC</th><th>Kontakt</th><th class="rechts">Aktion</th></tr>
</thead>
<tbody>
<?php foreach ($adressen as $adresse): ?>
<?php $f = 'adresse-' . (int)$adresse['id']; ?>
<tr>
<td>
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="text" form="<?= $f ?>" name="name" value="<?= View::h($adresse['name']) ?>" required>
<input type="hidden" form="<?= $f ?>" name="zusatz" value="<?= View::h($adresse['zusatz']) ?>">
<input type="hidden" form="<?= $f ?>" name="strasse" value="<?= View::h($adresse['strasse']) ?>">
<input type="hidden" form="<?= $f ?>" name="land" value="<?= View::h($adresse['land']) ?>">
<input type="hidden" form="<?= $f ?>" name="notiz" value="<?= View::h($adresse['notiz'] ?? '') ?>">
</td>
<td class="nowrap">
<input type="text" form="<?= $f ?>" name="plz" class="schmal"
value="<?= View::h($adresse['plz']) ?>">
<input type="text" form="<?= $f ?>" name="ort" value="<?= View::h($adresse['ort']) ?>">
</td>
<td>
<input type="text" form="<?= $f ?>" name="iban"
value="<?= View::h(Adresse::ibanFormatiert($adresse['iban'])) ?>">
</td>
<td>
<input type="text" form="<?= $f ?>" name="bic" class="schmal"
value="<?= View::h($adresse['bic']) ?>">
</td>
<td>
<input type="text" form="<?= $f ?>" name="email" value="<?= View::h($adresse['email']) ?>"
placeholder="E-Mail">
<input type="hidden" form="<?= $f ?>" name="telefon" value="<?= View::h($adresse['telefon']) ?>">
</td>
<td class="rechts nowrap">
<input type="hidden" form="adresse-loeschen-<?= (int)$adresse['id'] ?>"
name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button>
<button type="submit" form="adresse-loeschen-<?= (int)$adresse['id'] ?>"
class="knopf klein gefahr">Löschen</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<form method="post" action="/vertraege/adressen/neu" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Neue Adresse</h3>
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
<label for="zusatz">Zusatz <span class="optional">(optional)</span></label>
<input type="text" id="zusatz" name="zusatz">
<label for="strasse">Straße</label>
<input type="text" id="strasse" name="strasse">
<div class="feldpaar">
<div>
<label for="plz">PLZ</label>
<input type="text" id="plz" name="plz">
</div>
<div>
<label for="ort">Ort</label>
<input type="text" id="ort" name="ort">
</div>
<div>
<label for="land">Land</label>
<input type="text" id="land" name="land">
</div>
</div>
<label for="iban">IBAN</label>
<input type="text" id="iban" name="iban" placeholder="DE00 0000 0000 0000 0000 00">
<label for="bic">BIC</label>
<input type="text" id="bic" name="bic">
<div class="feldpaar">
<div>
<label for="email">E-Mail</label>
<input type="text" id="email" name="email">
</div>
<div>
<label for="telefon">Telefon</label>
<input type="text" id="telefon" name="telefon">
</div>
</div>
<label for="notiz">Notiz</label>
<input type="text" id="notiz" name="notiz">
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Adresse anlegen</button>
</div>
</form>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,210 @@
<?php
use Finanzmanager\View;
/**
* @var list<array<string,mixed>> $vertraege
* @var float $jahressumme
* @var list<array<string,mixed>> $fristen
* @var list<array<string,mixed>> $adressen
* @var list<array<string,mixed>> $kategorien
* @var list<array<string,mixed>> $reihen
* @var array<string,string> $intervalle
* @var string $csrfToken
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<span class="tab aktiv">Verträge</span>
<a href="/vertraege/adressen" class="tab">Adressen</a>
<a href="/vertraege/sparziele" class="tab">Sparziele</a>
<a href="/vertraege/kredite" class="tab">Kredite</a>
</div>
<?php if ($fristen !== []): ?>
<div class="kachel einzeln">
<header><h2>Kündigungsfristen laufen ab</h2></header>
<ul class="vorgangsliste">
<?php foreach ($fristen as $vertrag): ?>
<li>
<strong><?= View::h($vertrag['name']) ?></strong> –
kündbar bis <?= View::h(View::datum($vertrag['kuendigen_bis'])) ?>
<span class="nebentext">(Vertragsende <?= View::h(View::datum($vertrag['ende'])) ?>)</span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<p class="hinweis">
Laufende Verträge kosten zusammen <strong><?= View::betrag($jahressumme) ?></strong> im Jahr.
Der Nutzen dieser Liste liegt in der Kündigungsfrist: Sie zeigt, bis wann gekündigt sein
muss, damit sich ein Vertrag nicht stillschweigend verlängert.
</p>
<?php if ($vertraege === []): ?>
<p class="platzhalter">Noch keine Verträge erfasst.</p>
<?php else: ?>
<?php foreach ($vertraege as $vertrag): ?>
<form id="vertrag-<?= (int)$vertrag['id'] ?>" method="post"
action="/vertraege/<?= (int)$vertrag['id'] ?>"></form>
<form id="vertrag-loeschen-<?= (int)$vertrag['id'] ?>" method="post"
action="/vertraege/<?= (int)$vertrag['id'] ?>/loeschen"
data-bestaetigen="Vertrag „<?= View::h($vertrag['name']) ?>“ löschen?"></form>
<?php endforeach; ?>
<table class="liste">
<thead>
<tr>
<th>Vertrag</th><th>Partner</th><th class="rechts">Betrag</th><th>Rhythmus</th>
<th class="rechts">im Jahr</th><th>Ende</th><th>Kündigen bis</th>
<th>Aktiv</th><th class="rechts">Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($vertraege as $vertrag): ?>
<?php $f = 'vertrag-' . (int)$vertrag['id']; ?>
<tr<?= (int)$vertrag['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
<td>
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="text" form="<?= $f ?>" name="name" value="<?= View::h($vertrag['name']) ?>" required>
<input type="hidden" form="<?= $f ?>" name="vertragsnummer"
value="<?= View::h($vertrag['vertragsnummer']) ?>">
<input type="hidden" form="<?= $f ?>" name="adresse_id"
value="<?= View::h((string)$vertrag['adresse_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="kategorie_id"
value="<?= View::h((string)$vertrag['kategorie_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="regelmaessige_id"
value="<?= View::h((string)$vertrag['regelmaessige_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="beginn"
value="<?= View::h((string)($vertrag['beginn'] ?? '')) ?>">
<input type="hidden" form="<?= $f ?>" name="notiz" value="<?= View::h($vertrag['notiz'] ?? '') ?>">
</td>
<td>
<input type="text" form="<?= $f ?>" name="partner"
value="<?= View::h($vertrag['partner']) ?>">
</td>
<td class="rechts">
<input type="text" form="<?= $f ?>" name="betrag" class="schmal" inputmode="decimal"
value="<?= View::h(number_format((float)$vertrag['betrag'], 2, ',', '')) ?>">
</td>
<td>
<select form="<?= $f ?>" name="intervall">
<?php foreach ($intervalle as $schluessel => $name): ?>
<option value="<?= View::h($schluessel) ?>"
<?= $vertrag['intervall'] === $schluessel ? 'selected' : '' ?>>
<?= View::h($name) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td class="rechts"><?= View::betrag($vertrag['jahresbetrag']) ?></td>
<td>
<input type="date" form="<?= $f ?>" name="ende"
value="<?= View::h((string)($vertrag['ende'] ?? '')) ?>">
</td>
<td class="nowrap">
<input type="number" form="<?= $f ?>" name="kuendigungsfrist_tage" class="schmal"
value="<?= (int)$vertrag['kuendigungsfrist_tage'] ?>" min="0" title="Frist in Tagen">
<?php if ($vertrag['kuendigen_bis'] !== null): ?>
<span class="nebentext"><?= View::h(View::datum($vertrag['kuendigen_bis'])) ?></span>
<?php endif; ?>
</td>
<td>
<label class="ankreuzfeld">
<input type="checkbox" form="<?= $f ?>" name="aktiv" value="1"
<?= (int)$vertrag['aktiv'] === 1 ? 'checked' : '' ?>>
</label>
</td>
<td class="rechts nowrap">
<input type="hidden" form="vertrag-loeschen-<?= (int)$vertrag['id'] ?>"
name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button>
<button type="submit" form="vertrag-loeschen-<?= (int)$vertrag['id'] ?>"
class="knopf klein gefahr">Löschen</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<form method="post" action="/vertraege/neu" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Neuer Vertrag</h3>
<label for="name">Bezeichnung</label>
<input type="text" id="name" name="name" required placeholder="z. B. Hausratversicherung">
<label for="partner">Vertragspartner</label>
<input type="text" id="partner" name="partner">
<label for="adresse_id">Adresse <span class="optional">(optional)</span></label>
<select id="adresse_id" name="adresse_id">
<option value="">– keine –</option>
<?php foreach ($adressen as $adresse): ?>
<option value="<?= (int)$adresse['id'] ?>"><?= View::h($adresse['name']) ?></option>
<?php endforeach; ?>
</select>
<label for="vertragsnummer">Vertragsnummer</label>
<input type="text" id="vertragsnummer" name="vertragsnummer">
<label for="betrag">Betrag je Zahlung</label>
<input type="text" id="betrag" name="betrag" inputmode="decimal" value="0,00">
<label for="intervall">Rhythmus</label>
<select id="intervall" name="intervall">
<?php foreach ($intervalle as $schluessel => $name): ?>
<option value="<?= View::h($schluessel) ?>" <?= $schluessel === 'monatlich' ? 'selected' : '' ?>>
<?= View::h($name) ?>
</option>
<?php endforeach; ?>
</select>
<label for="kategorie_id">Kategorie</label>
<select id="kategorie_id" name="kategorie_id">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"><?= View::h($kategorie['pfad']) ?></option>
<?php endforeach; ?>
</select>
<div class="feldpaar">
<div>
<label for="beginn">Beginn</label>
<input type="date" id="beginn" name="beginn">
</div>
<div>
<label for="ende">Ende</label>
<input type="date" id="ende" name="ende">
</div>
<div>
<label for="kuendigungsfrist_tage">Kündigungsfrist (Tage)</label>
<input type="number" id="kuendigungsfrist_tage" name="kuendigungsfrist_tage" value="0" min="0">
</div>
</div>
<label for="regelmaessige_id">Zahlung über <span class="optional">(optional)</span></label>
<select id="regelmaessige_id" name="regelmaessige_id">
<option value="">– keine regelmäßige Buchung –</option>
<?php foreach ($reihen as $reihe): ?>
<option value="<?= (int)$reihe['id'] ?>"><?= View::h($reihe['name']) ?></option>
<?php endforeach; ?>
</select>
<label for="notiz">Notiz</label>
<input type="text" id="notiz" name="notiz">
<label class="ankreuzfeld">
<input type="checkbox" name="aktiv" value="1" checked> aktiv
</label>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Vertrag anlegen</button>
</div>
</form>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,258 @@
<?php
use Finanzmanager\Models\Kredit;
use Finanzmanager\View;
/**
* @var list<array<string,mixed>> $kredite
* @var array<string,mixed>|null $kredit
* @var array<string,mixed>|null $plan
* @var list<array<string,mixed>> $konten
* @var string $csrfToken
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a href="/vertraege" class="tab">Verträge</a>
<a href="/vertraege/adressen" class="tab">Adressen</a>
<a href="/vertraege/sparziele" class="tab">Sparziele</a>
<span class="tab aktiv">Kredite</span>
</div>
<?php if ($kredite !== []): ?>
<table class="liste zahlen">
<thead>
<tr>
<th>Kredit</th><th class="rechts">Darlehen</th><th class="rechts">Zins</th>
<th class="rechts">Rate</th><th>Rhythmus</th><th class="rechts">Restschuld heute</th>
<th class="rechts">Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($kredite as $eintrag): ?>
<tr<?= (int)$eintrag['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
<td>
<a href="/vertraege/kredite?kredit=<?= (int)$eintrag['id'] ?>">
<?= View::h($eintrag['name']) ?>
</a>
</td>
<td class="rechts"><?= View::betrag($eintrag['darlehensbetrag']) ?></td>
<td class="rechts"><?= View::h(number_format((float)$eintrag['zinssatz'], 2, ',', '.')) ?> %</td>
<td class="rechts"><?= View::betrag($eintrag['rate']) ?></td>
<td><?= View::h(Kredit::RHYTHMUS_NAMEN[$eintrag['rhythmus']] ?? $eintrag['rhythmus']) ?></td>
<td class="rechts"><?= View::betrag($eintrag['restschuld']) ?></td>
<td class="rechts">
<form method="post" action="/vertraege/kredite/<?= (int)$eintrag['id'] ?>/loeschen"
class="inline" data-bestaetigen="Kredit „<?= View::h($eintrag['name']) ?>“ löschen?">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf klein gefahr">Löschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if ($kredit !== null && $plan !== null): ?>
<h3>Tilgungsplan: <?= View::h($kredit['name']) ?></h3>
<?php if (!$plan['tilgt']): ?>
<p class="meldung meldung-fehler" role="status">
Die Rate deckt nicht einmal die Zinsen der ersten Periode – so würde die
Restschuld wachsen statt zu sinken. Bitte Rate oder Zinssatz prüfen.
</p>
<?php else: ?>
<div class="kennzahlenleiste">
<div class="kennzahlkachel">
<span class="kennzahltitel">Laufzeit</span>
<span class="kennzahl"><?= (int)round($plan['laufzeit_monate'] / 12) ?> J.</span>
<span class="nebentext"><?= (int)$plan['laufzeit_monate'] ?> Monate</span>
</div>
<div class="kennzahlkachel">
<span class="kennzahltitel">Letzte Rate</span>
<span class="kennzahl klein"><?= View::h(View::datum($plan['letzte_rate_am'])) ?></span>
</div>
<div class="kennzahlkachel">
<span class="kennzahltitel">Zinsen gesamt</span>
<span class="kennzahl negativ"><?= View::betrag($plan['zinsen']) ?></span>
</div>
<?php if ($plan['restschuld_zinsbindung'] !== null): ?>
<div class="kennzahlkachel">
<span class="kennzahltitel">Rest bei Zinsbindungsende</span>
<span class="kennzahl"><?= View::betrag($plan['restschuld_zinsbindung']) ?></span>
</div>
<?php endif; ?>
</div>
<?php
// Bei langen Laufzeiten sind alle Raten zu viel; gezeigt werden die
// nächsten zwei Jahre und das Ende.
$zeilen = $plan['zeilen'];
$gekuerzt = count($zeilen) > 30;
$anzeige = $gekuerzt
? array_merge(array_slice($zeilen, 0, 24), array_slice($zeilen, -3))
: $zeilen;
?>
<table class="liste zahlen">
<thead>
<tr>
<th class="rechts">Nr.</th><th>Fällig</th><th class="rechts">Zahlung</th>
<th class="rechts">Zinsen</th><th class="rechts">Tilgung</th><th class="rechts">Restschuld</th>
</tr>
</thead>
<tbody>
<?php $vorige = 0; ?>
<?php foreach ($anzeige as $zeile): ?>
<?php if ($gekuerzt && $zeile['nummer'] > $vorige + 1): ?>
<tr class="luecke">
<td colspan="6" class="nebentext">
… <?= (int)($zeile['nummer'] - $vorige - 1) ?> weitere Raten …
</td>
</tr>
<?php endif; ?>
<tr>
<td class="rechts"><?= (int)$zeile['nummer'] ?></td>
<td><?= View::h(View::datum($zeile['datum'])) ?></td>
<td class="rechts"><?= View::betrag($zeile['zahlung']) ?></td>
<td class="rechts"><?= View::betrag($zeile['zinsen']) ?></td>
<td class="rechts"><?= View::betrag($zeile['tilgung']) ?></td>
<td class="rechts"><?= View::betrag($zeile['restschuld']) ?></td>
</tr>
<?php $vorige = (int)$zeile['nummer']; ?>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<form method="post" action="/vertraege/kredite/<?= (int)$kredit['id'] ?>" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Kredit bearbeiten</h3>
<label for="b_name">Name</label>
<input type="text" id="b_name" name="name" value="<?= View::h($kredit['name']) ?>" required>
<div class="feldpaar">
<div>
<label for="b_betrag">Darlehensbetrag</label>
<input type="text" id="b_betrag" name="darlehensbetrag" inputmode="decimal"
value="<?= View::h(number_format((float)$kredit['darlehensbetrag'], 2, ',', '')) ?>">
</div>
<div>
<label for="b_zins">Zinssatz (%)</label>
<input type="text" id="b_zins" name="zinssatz" inputmode="decimal"
value="<?= View::h(number_format((float)$kredit['zinssatz'], 3, ',', '')) ?>">
</div>
<div>
<label for="b_rate">Rate</label>
<input type="text" id="b_rate" name="rate" inputmode="decimal"
value="<?= View::h(number_format((float)$kredit['rate'], 2, ',', '')) ?>">
</div>
</div>
<div class="feldpaar">
<div>
<label for="b_rhythmus">Rhythmus</label>
<select id="b_rhythmus" name="rhythmus">
<?php foreach (Kredit::RHYTHMUS_NAMEN as $schluessel => $name): ?>
<option value="<?= View::h($schluessel) ?>"
<?= $kredit['rhythmus'] === $schluessel ? 'selected' : '' ?>>
<?= View::h($name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label for="b_beginn">Beginn</label>
<input type="date" id="b_beginn" name="beginn" value="<?= View::h((string)$kredit['beginn']) ?>">
</div>
<div>
<label for="b_bindung">Zinsbindung bis</label>
<input type="date" id="b_bindung" name="zinsbindung_bis"
value="<?= View::h((string)($kredit['zinsbindung_bis'] ?? '')) ?>">
</div>
</div>
<label for="b_sonder">Jährliche Sondertilgung <span class="optional">(im Dezember)</span></label>
<input type="text" id="b_sonder" name="sondertilgung" inputmode="decimal"
value="<?= View::h(number_format((float)$kredit['sondertilgung'], 2, ',', '')) ?>">
<label for="b_konto">Konto</label>
<select id="b_konto" name="konto_id">
<option value="">– keines –</option>
<?php foreach ($konten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"
<?= (int)$kredit['konto_id'] === (int)$konto['id'] ? 'selected' : '' ?>>
<?= View::h($konto['name']) ?>
</option>
<?php endforeach; ?>
</select>
<label class="ankreuzfeld">
<input type="checkbox" name="aktiv" value="1" <?= (int)$kredit['aktiv'] === 1 ? 'checked' : '' ?>>
aktiv
</label>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Speichern</button>
</div>
</form>
<?php endif; ?>
<form method="post" action="/vertraege/kredite/neu" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Neuer Kredit</h3>
<p class="feldhinweis">
Gerechnet wird als Annuitätendarlehen: gleichbleibende Rate, sinkender Zinsanteil.
Der Tilgungsplan entsteht aus diesen Angaben und wird nicht gespeichert.
</p>
<label for="name">Name</label>
<input type="text" id="name" name="name" required placeholder="z. B. Baufinanzierung">
<div class="feldpaar">
<div>
<label for="darlehensbetrag">Darlehensbetrag</label>
<input type="text" id="darlehensbetrag" name="darlehensbetrag" inputmode="decimal" required>
</div>
<div>
<label for="zinssatz">Zinssatz (%)</label>
<input type="text" id="zinssatz" name="zinssatz" inputmode="decimal" required>
</div>
<div>
<label for="rate">Rate</label>
<input type="text" id="rate" name="rate" inputmode="decimal" required>
</div>
</div>
<div class="feldpaar">
<div>
<label for="rhythmus">Rhythmus</label>
<select id="rhythmus" name="rhythmus">
<?php foreach (Kredit::RHYTHMUS_NAMEN as $schluessel => $name): ?>
<option value="<?= View::h($schluessel) ?>"><?= View::h($name) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label for="beginn">Beginn</label>
<input type="date" id="beginn" name="beginn" value="<?= View::h(date('Y-m-d')) ?>" required>
</div>
<div>
<label for="zinsbindung_bis">Zinsbindung bis</label>
<input type="date" id="zinsbindung_bis" name="zinsbindung_bis">
</div>
</div>
<label class="ankreuzfeld">
<input type="checkbox" name="aktiv" value="1" checked> aktiv
</label>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Kredit anlegen</button>
</div>
</form>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,138 @@
<?php
use Finanzmanager\View;
/**
* @var list<array<string,mixed>> $ziele
* @var list<array<string,mixed>> $konten
* @var string $csrfToken
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a href="/vertraege" class="tab">Verträge</a>
<a href="/vertraege/adressen" class="tab">Adressen</a>
<span class="tab aktiv">Sparziele</span>
<a href="/vertraege/kredite" class="tab">Kredite</a>
</div>
<p class="hinweis">
Der erreichte Stand kommt aus dem Kontosaldo, nicht aus einem eigenen Zähler – so kann
er nicht von den Buchungen abweichen. Der Startbetrag zieht ab, was zu Beginn schon
auf dem Konto lag.
</p>
<?php if ($ziele === []): ?>
<p class="platzhalter">Noch kein Sparziel angelegt.</p>
<?php else: ?>
<div class="kachelraster">
<?php foreach ($ziele as $ziel): ?>
<section class="kachel">
<header>
<h2><?= View::h($ziel['name']) ?></h2>
<p class="kachelhinweis">
<?= View::h($ziel['kontoname'] ?? 'ohne Konto') ?>
<?php if ($ziel['zieldatum'] !== null): ?>
· bis <?= View::h(View::datum($ziel['zieldatum'])) ?>
<?php endif; ?>
</p>
</header>
<?php /* <progress> statt eines eigenen Balkens: Die Breite käme sonst
nur über ein style-Attribut zustande, und das verbietet die
Content-Security-Policy. */ ?>
<?php $prozent = (int)round($ziel['fortschritt'] * 100); ?>
<progress class="fortschritt" value="<?= $prozent ?>" max="100">
<?= $prozent ?> %
</progress>
<p class="fortschrittstext">
<strong><?= View::betrag($ziel['stand']) ?></strong>
von <?= View::betrag($ziel['zielbetrag']) ?>
<span class="nebentext">(<?= $prozent ?> %)</span>
</p>
<?php if ($ziel['fehlt'] > 0.0): ?>
<p class="platzhalter">
Es fehlen <?= View::betrag($ziel['fehlt']) ?>.
<?php if ($ziel['noetige_rate'] !== null): ?>
Um den Termin zu halten, wären
<strong><?= View::betrag($ziel['noetige_rate']) ?></strong> im Monat nötig.
<?php endif; ?>
</p>
<?php else: ?>
<p class="platzhalter">Ziel erreicht.</p>
<?php endif; ?>
<form method="post" action="/vertraege/sparziele/<?= (int)$ziel['id'] ?>" class="zeilenform">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="name" value="<?= View::h($ziel['name']) ?>">
<input type="hidden" name="konto_id" value="<?= View::h((string)$ziel['konto_id']) ?>">
<input type="hidden" name="startbetrag"
value="<?= View::h(number_format((float)$ziel['startbetrag'], 2, '.', '')) ?>">
<input type="hidden" name="notiz" value="<?= View::h($ziel['notiz'] ?? '') ?>">
<label for="ziel-<?= (int)$ziel['id'] ?>">Zielbetrag</label>
<input type="text" id="ziel-<?= (int)$ziel['id'] ?>" name="zielbetrag" inputmode="decimal"
value="<?= View::h(number_format((float)$ziel['zielbetrag'], 2, ',', '')) ?>">
<input type="date" name="zieldatum" value="<?= View::h((string)($ziel['zieldatum'] ?? '')) ?>">
<input type="text" name="monatsrate" class="schmal" inputmode="decimal"
placeholder="Rate"
value="<?= $ziel['monatsrate'] === null ? '' : View::h(number_format((float)$ziel['monatsrate'], 2, ',', '')) ?>">
<button type="submit" class="knopf klein">Speichern</button>
</form>
<form method="post" action="/vertraege/sparziele/<?= (int)$ziel['id'] ?>/loeschen"
class="inline" data-bestaetigen="Sparziel „<?= View::h($ziel['name']) ?>“ löschen?">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf klein gefahr">Löschen</button>
</form>
</section>
<?php endforeach; ?>
</div>
<?php endif; ?>
<form method="post" action="/vertraege/sparziele/neu" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Neues Sparziel</h3>
<label for="name">Name</label>
<input type="text" id="name" name="name" required placeholder="z. B. Neues Auto">
<label for="konto_id">Konto</label>
<select id="konto_id" name="konto_id">
<option value="">– keines –</option>
<?php foreach ($konten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"><?= View::h($konto['name']) ?></option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">Der Stand des Ziels ist der Saldo dieses Kontos.</p>
<div class="feldpaar">
<div>
<label for="zielbetrag">Zielbetrag</label>
<input type="text" id="zielbetrag" name="zielbetrag" inputmode="decimal" required>
</div>
<div>
<label for="startbetrag">Startbetrag</label>
<input type="text" id="startbetrag" name="startbetrag" inputmode="decimal" value="0,00">
</div>
<div>
<label for="zieldatum">Zieldatum</label>
<input type="date" id="zieldatum" name="zieldatum">
</div>
</div>
<label for="monatsrate">Geplante Monatsrate <span class="optional">(optional)</span></label>
<input type="text" id="monatsrate" name="monatsrate" inputmode="decimal">
<label for="notiz">Notiz</label>
<input type="text" id="notiz" name="notiz">
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Sparziel anlegen</button>
</div>
</form>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -6,6 +6,7 @@ use Finanzmanager\View;
/** @var string $verzeichnis */
/** @var array<string,mixed>|null $ergebnis */
/** @var array<string,mixed>|null $buchungen */
/** @var array<string,mixed>|null $vorlagen */
/** @var string $stichtag */
/** @var string $bis */
/** @var string $datei */
@ -98,7 +99,7 @@ include __DIR__ . '/../layout/kopf.php';
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['zukunft'] ?></td>
</tr>
<tr>
<td class="nebentext">darunter Wertpapiergeschäfte (nur Geldfluss)</td>
<td class="nebentext">darunter Wertpapiergeschäfte (Depot- und Geldbuchung)</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['wertpapier'] ?></td>
</tr>
<tr>
@ -115,6 +116,33 @@ include __DIR__ . '/../layout/kopf.php';
</p>
<?php endif; ?>
<?php if (($vorlagen ?? null) !== null): ?>
<h3>Buchungsvorlagen</h3>
<table class="liste">
<tbody>
<tr>
<td><?= $buchungen !== null && $buchungen['trockenlauf'] ? 'Neu' : 'Angelegt' ?></td>
<td class="rechts"><?= (int)$vorlagen['neu'] ?></td>
</tr>
<tr>
<td class="nebentext">bereits vorhanden</td>
<td class="rechts nebentext"><?= (int)$vorlagen['vorhanden'] ?></td>
</tr>
<tr>
<td>Fehler</td>
<td class="rechts <?= (int)$vorlagen['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
<?= (int)$vorlagen['fehler'] ?>
</td>
</tr>
</tbody>
</table>
<p class="hinweis">
Der FinanzManager merkt sich jede erfasste Zahlung als Vorlage – im
Beispielexport sind das mehrere hundert. Sie stehen danach unter
<a href="/vorlagen">Buchungsvorlagen</a>.
</p>
<?php endif; ?>
<?php if (!empty($ergebnis['abschnitte'])): ?>
<h3>Inhalt der Datei</h3>
<p class="hinweis">

View file

@ -55,15 +55,15 @@ include __DIR__ . '/../layout/kopf.php';
</div>
<div class="kachel">
<header>
<h2>Weitere Bereiche</h2>
<span class="phasenmarke">später</span>
</header>
<ul class="checkliste">
<li>Bankverbindungen und Regelverwaltung – Phase 5</li>
<li>Buchungsvorlagen und regelmäßige Buchungen – Phase 7</li>
<li>Adressen, Verträge, Sparziele – Phase 7</li>
</ul>
<header><h2>Online-Banking</h2></header>
<p class="platzhalter">
Bankverbindungen über FinTS, Umsatzabruf mit Abgleich gegen vorhandene
Buchungen und Regeln für die automatische Kategorisierung.
</p>
<div class="knopfleiste">
<a href="/banking" class="knopf primaer">Bankverbindungen</a>
<a href="/banking/regeln" class="knopf">Regeln</a>
</div>
</div>
</div>

View file

@ -0,0 +1,154 @@
<?php
use Finanzmanager\View;
/**
* @var list<array<string,mixed>> $vorlagen
* @var string $suche
* @var int $anzahl
* @var list<array<string,mixed>> $konten
* @var list<array<string,mixed>> $kategorien
* @var list<array<string,mixed>> $klassen
* @var string $csrfToken
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<span class="tab aktiv">Buchungsvorlagen</span>
<a href="/termine" class="tab">Regelmäßige Buchungen</a>
<a href="/termine/kalender" class="tab">Finanzkalender</a>
</div>
<p class="hinweis">
Eine Vorlage füllt die Buchung vor: Empfänger, Kategorie und Splitzeilen stehen schon,
einzutragen bleibt das Datum – und der Betrag, wenn er schwankt. Weicht er ab, werden
die Splitzeilen im gleichen Verhältnis mitgezogen.
</p>
<form method="get" action="/vorlagen" class="filterleiste">
<label for="suche">Suchen</label>
<input type="search" id="suche" name="suche" value="<?= View::h($suche) ?>"
placeholder="Name oder Empfänger">
<button type="submit" class="knopf">Suchen</button>
<?php if ($suche !== ''): ?>
<a href="/vorlagen" class="knopf">Alle zeigen</a>
<?php endif; ?>
<span class="nebentext"><?= (int)$anzahl ?> Vorlagen insgesamt</span>
</form>
<?php if ($vorlagen === []): ?>
<p class="platzhalter">
<?= $suche === ''
? 'Noch keine Vorlagen. Sie kommen aus dem QIF-Import oder entstehen aus einer vorhandenen Buchung.'
: 'Keine Vorlage passt zur Suche.' ?>
</p>
<?php else: ?>
<?php foreach ($vorlagen as $vorlage): ?>
<form id="vorlage-buchen-<?= (int)$vorlage['id'] ?>" method="post"
action="/vorlagen/<?= (int)$vorlage['id'] ?>/buchen"></form>
<form id="vorlage-loeschen-<?= (int)$vorlage['id'] ?>" method="post"
action="/vorlagen/<?= (int)$vorlage['id'] ?>/loeschen"
data-bestaetigen="Vorlage „<?= View::h($vorlage['name']) ?>“ löschen?"></form>
<?php endforeach; ?>
<table class="liste">
<thead>
<tr>
<th>Vorlage</th><th>Kategorie</th><th>Konto</th>
<th class="rechts">Betrag</th><th>Buchen am</th>
<th class="rechts">Genutzt</th><th class="rechts">Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($vorlagen as $vorlage): ?>
<?php $f = 'vorlage-buchen-' . (int)$vorlage['id']; ?>
<tr>
<td>
<?= View::h($vorlage['name']) ?>
<?php if (($vorlage['splits'] ?? null) !== null): ?>
<span class="marke">Split</span>
<?php endif; ?>
</td>
<td><?= View::h($vorlage['kategorie_pfad'] ?? '–') ?></td>
<td>
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<?php if ($vorlage['konto_id'] === null): ?>
<select form="<?= $f ?>" name="konto_id" required>
<option value="">– Konto wählen –</option>
<?php foreach ($konten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"><?= View::h($konto['name']) ?></option>
<?php endforeach; ?>
</select>
<?php else: ?>
<?= View::h($vorlage['kontoname']) ?>
<?php endif; ?>
</td>
<td class="rechts">
<input type="text" form="<?= $f ?>" name="betrag" class="schmal" inputmode="decimal"
value="<?= View::h(number_format((float)$vorlage['betrag'], 2, ',', '')) ?>">
</td>
<td>
<input type="date" form="<?= $f ?>" name="datum" value="<?= View::h(date('Y-m-d')) ?>">
</td>
<td class="rechts"><?= (int)$vorlage['verwendet'] ?></td>
<td class="rechts nowrap">
<input type="hidden" form="vorlage-loeschen-<?= (int)$vorlage['id'] ?>"
name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" form="<?= $f ?>" class="knopf klein primaer">Buchen</button>
<button type="submit" form="vorlage-loeschen-<?= (int)$vorlage['id'] ?>"
class="knopf klein gefahr">Löschen</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<form method="post" action="/vorlagen/neu" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Neue Vorlage</h3>
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
<label for="empfaenger">Empfänger</label>
<input type="text" id="empfaenger" name="empfaenger">
<label for="konto_id">Konto</label>
<select id="konto_id" name="konto_id">
<option value="">– beim Buchen wählen –</option>
<?php foreach ($konten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"><?= View::h($konto['name']) ?></option>
<?php endforeach; ?>
</select>
<label for="betrag">Betrag</label>
<input type="text" id="betrag" name="betrag" inputmode="decimal" value="0,00">
<label for="kategorie_id">Kategorie</label>
<select id="kategorie_id" name="kategorie_id">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"><?= View::h($kategorie['pfad']) ?></option>
<?php endforeach; ?>
</select>
<label for="klasse_id">Klasse</label>
<select id="klasse_id" name="klasse_id">
<option value="">– keine –</option>
<?php foreach ($klassen as $klasse): ?>
<option value="<?= (int)$klasse['id'] ?>"><?= View::h($klasse['name']) ?></option>
<?php endforeach; ?>
</select>
<label for="buchungstext">Buchungstext</label>
<input type="text" id="buchungstext" name="buchungstext">
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Vorlage anlegen</button>
</div>
</form>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,145 @@
<?php
use Finanzmanager\Services\Zeitraum;
use Finanzmanager\View;
/**
* Veräußerungsgeschäfte und Erträge – die beiden Depotauswertungen.
*
* @var string $art
* @var array{vorgabe:string, von:string, bis:string} $zeitraum
* @var list<array<string,mixed>> $depots
* @var int|null $depot
* @var array<string,mixed> $daten
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a href="/wertpapiere" class="tab">Bestände</a>
<a href="/wertpapiere/buchungen" class="tab">Depotbuchungen</a>
<a href="/wertpapiere/stammdaten" class="tab">Wertpapiere</a>
<a href="/wertpapiere/auswertung?art=veraeusserungen"
class="tab <?= $art === 'veraeusserungen' ? 'aktiv' : '' ?>">Veräußerungen</a>
<a href="/wertpapiere/auswertung?art=ertraege"
class="tab <?= $art === 'ertraege' ? 'aktiv' : '' ?>">Erträge</a>
</div>
<form method="get" action="/wertpapiere/auswertung" class="filterleiste">
<input type="hidden" name="art" value="<?= View::h($art) ?>">
<label for="zeitraum">Zeitraum</label>
<select id="zeitraum" name="zeitraum">
<?php foreach (Zeitraum::VORGABEN as $schluessel => $name): ?>
<option value="<?= View::h($schluessel) ?>" <?= $zeitraum['vorgabe'] === $schluessel ? 'selected' : '' ?>>
<?= View::h($name) ?>
</option>
<?php endforeach; ?>
</select>
<label for="von">von</label>
<input type="date" id="von" name="von" value="<?= View::h($zeitraum['von']) ?>">
<label for="bis">bis</label>
<input type="date" id="bis" name="bis" value="<?= View::h($zeitraum['bis']) ?>">
<label for="depot">Depot</label>
<select id="depot" name="depot">
<option value="">Alle</option>
<?php foreach ($depots as $eintrag): ?>
<option value="<?= (int)$eintrag['id'] ?>" <?= $depot === (int)$eintrag['id'] ? 'selected' : '' ?>>
<?= View::h($eintrag['name']) ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="knopf primaer">Anzeigen</button>
</form>
<p class="berichtskopf"><?= View::h(Zeitraum::beschriftung($zeitraum['von'], $zeitraum['bis'])) ?></p>
<?php if ($art === 'ertraege'): ?>
<?php if ($daten['zeilen'] === []): ?>
<p class="platzhalter">Keine Erträge in diesem Zeitraum.</p>
<?php else: ?>
<table class="liste zahlen">
<thead>
<tr><th>Wertpapier</th><th>ISIN</th><th class="rechts">Zahlungen</th><th class="rechts">Ertrag</th></tr>
</thead>
<tbody>
<?php foreach ($daten['zeilen'] as $zeile): ?>
<tr>
<td><?= View::h($zeile['name']) ?></td>
<td><?= View::h($zeile['isin'] ?: '–') ?></td>
<td class="rechts"><?= (int)$zeile['anzahl'] ?></td>
<td class="rechts"><?= View::betrag($zeile['summe']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr class="summenzeile">
<td colspan="3">Summe</td>
<td class="rechts"><?= View::betrag($daten['summe']) ?></td>
</tr>
</tfoot>
</table>
<p class="hinweis klein">
Ausgewiesen ist der Nettoertrag: Bruttoausschüttung abzüglich einbehaltener
Steuern und Spesen – also das, was auf dem Verrechnungskonto ankam.
</p>
<?php endif; ?>
<?php else: ?>
<?php if ($daten['geschaefte'] === []): ?>
<p class="platzhalter">Keine Verkäufe in diesem Zeitraum.</p>
<?php else: ?>
<table class="liste zahlen">
<thead>
<tr>
<th>Datum</th><th>Wertpapier</th><th>Depot</th>
<th class="rechts">Stück</th><th class="rechts">Erlös</th>
<th class="rechts">Einstand</th><th class="rechts">Gewinn</th>
</tr>
</thead>
<tbody>
<?php foreach ($daten['geschaefte'] as $geschaeft): ?>
<tr>
<td><?= View::h(View::datum($geschaeft['datum'])) ?></td>
<td>
<?= View::h($geschaeft['name']) ?>
<?php if ($geschaeft['isin'] !== ''): ?>
<span class="nebentext"><?= View::h($geschaeft['isin']) ?></span>
<?php endif; ?>
</td>
<td><?= View::h($geschaeft['depot']) ?></td>
<td class="rechts">
<?= View::h(rtrim(rtrim(number_format($geschaeft['stueck'], 6, ',', '.'), '0'), ',')) ?>
</td>
<td class="rechts"><?= View::betrag($geschaeft['erloes']) ?></td>
<td class="rechts"><?= View::betrag($geschaeft['einstand']) ?></td>
<td class="rechts <?= View::betragKlasse($geschaeft['gewinn']) ?>">
<?= View::betrag($geschaeft['gewinn']) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr class="summenzeile">
<td colspan="4">Summe</td>
<td class="rechts"><?= View::betrag($daten['erloes']) ?></td>
<td></td>
<td class="rechts <?= View::betragKlasse($daten['gewinn']) ?>">
<?= View::betrag($daten['gewinn']) ?>
</td>
</tr>
</tfoot>
</table>
<p class="hinweis klein">
Der Einstand folgt dem <strong>gleitenden Durchschnitt</strong> über alle Käufe bis
zum Verkaufstag – so rechnet auch der FinanzManager. Für die Steuererklärung gilt in
Deutschland dagegen FIFO; maßgeblich ist dort die Abrechnung der Bank.
</p>
<?php endif; ?>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,124 @@
<?php
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\View;
/**
* @var list<array<string,mixed>> $buchungen
* @var list<array<string,mixed>> $depots
* @var list<array<string,mixed>> $wertpapiere
* @var array<string,mixed> $filter
* @var string $csrfToken
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a href="/wertpapiere" class="tab">Bestände</a>
<span class="tab aktiv">Depotbuchungen</span>
<a href="/wertpapiere/stammdaten" class="tab">Wertpapiere</a>
<a href="/wertpapiere/auswertung?art=veraeusserungen" class="tab">Veräußerungen</a>
<a href="/wertpapiere/auswertung?art=ertraege" class="tab">Erträge</a>
</div>
<div class="seitenaktionen">
<form method="get" action="/wertpapiere/buchungen" class="inline">
<select name="depot">
<option value="">Alle Depots</option>
<?php foreach ($depots as $eintrag): ?>
<option value="<?= (int)$eintrag['id'] ?>"
<?= (int)($filter['konto_id'] ?? 0) === (int)$eintrag['id'] ? 'selected' : '' ?>>
<?= View::h($eintrag['name']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="wertpapier">
<option value="">Alle Wertpapiere</option>
<?php foreach ($wertpapiere as $eintrag): ?>
<option value="<?= (int)$eintrag['id'] ?>"
<?= (int)($filter['wertpapier_id'] ?? 0) === (int)$eintrag['id'] ? 'selected' : '' ?>>
<?= View::h($eintrag['name']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="vorgang">
<option value="">Alle Vorgänge</option>
<?php foreach (Depotbuchung::VORGAENGE as $schluessel => $name): ?>
<option value="<?= View::h($schluessel) ?>"
<?= ($filter['vorgang'] ?? '') === $schluessel ? 'selected' : '' ?>>
<?= View::h($name) ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="knopf klein">Filtern</button>
</form>
<a href="/wertpapiere/buchungen/neu" class="knopf primaer">Depotbuchung erfassen</a>
</div>
<?php if ($buchungen === []): ?>
<p class="platzhalter">Keine Depotbuchungen zu diesen Angaben.</p>
<?php else: ?>
<table class="liste zahlen">
<thead>
<tr>
<th>Datum</th><th>Vorgang</th><th>Wertpapier</th><th>Depot</th>
<th class="rechts">Stück</th><th class="rechts">Kurs</th>
<th class="rechts">Betrag</th><th class="rechts">Kosten</th>
<th>Verrechnung</th><th class="rechts">Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($buchungen as $buchung): ?>
<tr>
<td><?= View::h(View::datum($buchung['datum'])) ?></td>
<td>
<?= View::h(Depotbuchung::VORGAENGE[$buchung['vorgang']] ?? $buchung['vorgang']) ?>
<?php if ($buchung['vorgang_qif'] !== ''): ?>
<span class="nebentext"><?= View::h($buchung['vorgang_qif']) ?></span>
<?php endif; ?>
</td>
<td>
<?= View::h($buchung['wertpapier']) ?>
<?php if ($buchung['isin'] !== ''): ?>
<span class="nebentext"><?= View::h($buchung['isin']) ?></span>
<?php endif; ?>
</td>
<td><?= View::h($buchung['depot']) ?></td>
<td class="rechts">
<?= (float)$buchung['stueck'] === 0.0
? '–'
: View::h(rtrim(rtrim(number_format((float)$buchung['stueck'], 6, ',', '.'), '0'), ',')) ?>
</td>
<td class="rechts">
<?= $buchung['kurs'] === null ? '–' : View::h(number_format((float)$buchung['kurs'], 2, ',', '.')) ?>
</td>
<td class="rechts"><?= View::betrag($buchung['betrag']) ?></td>
<td class="rechts">
<?php $kosten = (float)$buchung['gebuehren'] + (float)$buchung['steuern']; ?>
<?= $kosten > 0.0 ? View::betrag($kosten) : '–' ?>
</td>
<td>
<?php if ($buchung['buchung_id'] !== null): ?>
<a href="/buchungen/<?= (int)$buchung['buchung_id'] ?>">
<?= View::h($buchung['verrechnungskonto'] ?? 'Buchung') ?>
</a>
<?php else: ?>
<span class="nebentext">–</span>
<?php endif; ?>
</td>
<td class="rechts">
<form method="post" action="/wertpapiere/buchungen/<?= (int)$buchung['id'] ?>/loeschen"
class="inline"
data-bestaetigen="Depotbuchung löschen? Die zugehörige Geldbuchung wird mitgelöscht.">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf klein gefahr">Löschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,99 @@
<?php
use Finanzmanager\Services\Vermoegensentwicklung;
use Finanzmanager\View;
/**
* Wertentwicklung eines Depots: der bewertete Bestand als Fläche, der Einstand
* als Linie darüber. Wo die Fläche über der Linie liegt, steht das Depot im
* Plus – das ist auf einen Blick zu sehen, ohne eine Zahl zu lesen.
*
* @var list<array{monat:string, kuerzel:string, wert:float, einstand:float}> $reihe
*/
$anzahl = max(1, count($reihe));
$skala = Vermoegensentwicklung::skala(
array_merge(array_column($reihe, 'wert'), array_column($reihe, 'einstand'), [0.0])
);
$achse = 56;
$oben = 10;
$plotH = 150;
$unten = 28;
$hoehe = $oben + $plotH + $unten;
$y = static function (float $wert) use ($skala, $oben, $plotH): float {
return round($oben + $plotH * ($skala['max'] - $wert) / ($skala['max'] - $skala['min']), 2);
};
$x = static function (int $nummer) use ($anzahl): string {
// Bei einem einzigen Punkt läge die Linie auf 0 % – dann lieber mittig.
return $anzahl === 1 ? '50%' : round($nummer / ($anzahl - 1) * 100, 3) . '%';
};
$achsenzahl = static fn(float $wert): string => number_format($wert, 0, ',', '.');
?>
<div class="diagrammbereich">
<div class="diagrammflaeche">
<svg class="diagrammachse" width="<?= $achse ?>" height="<?= $hoehe ?>" aria-hidden="true">
<?php for ($wert = $skala['min']; $wert <= $skala['max'] + 0.001; $wert += $skala['schritt']): ?>
<text class="diagramm-achse" x="<?= $achse - 8 ?>" y="<?= $y($wert) + 4 ?>"
text-anchor="end"><?= View::h($achsenzahl($wert)) ?></text>
<?php endfor; ?>
</svg>
<svg class="diagramm" height="<?= $hoehe ?>" role="img"
aria-label="Wertentwicklung des Depots über die letzten Monate">
<title>Wertentwicklung</title>
<?php for ($wert = $skala['min']; $wert <= $skala['max'] + 0.001; $wert += $skala['schritt']): ?>
<line class="diagramm-gitter" x1="0" x2="100%"
y1="<?= $y($wert) ?>" y2="<?= $y($wert) ?>"></line>
<?php endfor; ?>
<?php /* Die Fläche als Polygon: Verlauf, dann an der Nulllinie zurück. */ ?>
<?php
$punkte = [];
foreach ($reihe as $nummer => $punkt) {
$punkte[] = $x($nummer) . ',' . $y($punkt['wert']);
}
$flaeche = array_merge(
[$x(0) . ',' . $y(0.0)],
$punkte,
[$x($anzahl - 1) . ',' . $y(0.0)]
);
?>
<polygon class="wertflaeche" points="<?= View::h(implode(' ', $flaeche)) ?>"></polygon>
<polyline class="wertlinie" points="<?= View::h(implode(' ', $punkte)) ?>"></polyline>
<?php
$einstand = [];
foreach ($reihe as $nummer => $punkt) {
$einstand[] = $x($nummer) . ',' . $y($punkt['einstand']);
}
?>
<polyline class="einstandslinie" points="<?= View::h(implode(' ', $einstand)) ?>"></polyline>
<?php foreach ($reihe as $nummer => $punkt): ?>
<circle class="wertpunkt" cx="<?= $x($nummer) ?>" cy="<?= $y($punkt['wert']) ?>" r="2.5">
<title><?= View::h($punkt['kuerzel'] . ': ' . View::betrag($punkt['wert'])
. ' (Einstand ' . View::betrag($punkt['einstand']) . ')') ?></title>
</circle>
<text class="diagramm-beschriftung" x="<?= $x($nummer) ?>" y="<?= $hoehe - 12 ?>"
text-anchor="middle"><?= View::h($punkt['kuerzel']) ?></text>
<?php endforeach; ?>
</svg>
</div>
<ul class="diagrammlegende">
<li>
<span class="legendenfarbe wertfarbe" aria-hidden="true"></span>
<span class="legendenname">Aktueller Wert</span>
</li>
<li>
<span class="legendenfarbe einstandsfarbe" aria-hidden="true"></span>
<span class="legendenname">Einstandswert</span>
</li>
</ul>
</div>

View file

@ -0,0 +1,136 @@
<?php
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\View;
/**
* @var list<array<string,mixed>> $depots
* @var list<array<string,mixed>> $wertpapiere
* @var list<array<string,mixed>> $konten
* @var string $csrfToken
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/wertpapiere/buchungen" class="knopf">Zurück zu den Depotbuchungen</a>
</div>
<form method="post" action="/wertpapiere/buchungen/neu" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="konto_id">Depot</label>
<select id="konto_id" name="konto_id" required>
<?php foreach ($depots as $depot): ?>
<option value="<?= (int)$depot['id'] ?>"><?= View::h($depot['name']) ?></option>
<?php endforeach; ?>
</select>
<?php if ($depots === []): ?>
<p class="feldhinweis">
Kein Depot vorhanden – zuerst ein Konto der Kontoart <em>Depot</em> anlegen.
</p>
<?php endif; ?>
<label for="vorgang">Vorgang</label>
<select id="vorgang" name="vorgang">
<?php foreach (Depotbuchung::VORGAENGE as $schluessel => $name): ?>
<option value="<?= View::h($schluessel) ?>"><?= View::h($name) ?></option>
<?php endforeach; ?>
</select>
<label for="datum">Datum</label>
<input type="date" id="datum" name="datum" value="<?= View::h(date('Y-m-d')) ?>" required>
<label for="valuta">Valuta <span class="optional">(optional)</span></label>
<input type="date" id="valuta" name="valuta">
<h3>Wertpapier</h3>
<label for="wertpapier_id">Vorhandenes Wertpapier</label>
<select id="wertpapier_id" name="wertpapier_id">
<option value="">– neues Wertpapier anlegen –</option>
<?php foreach ($wertpapiere as $papier): ?>
<option value="<?= (int)$papier['id'] ?>">
<?= View::h($papier['name']) ?><?= $papier['isin'] !== '' ? ' (' . View::h($papier['isin']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
<label for="wertpapier_name">Name <span class="optional">(nur für ein neues Wertpapier)</span></label>
<input type="text" id="wertpapier_name" name="wertpapier_name">
<div class="feldpaar">
<div>
<label for="isin">ISIN</label>
<input type="text" id="isin" name="isin">
</div>
<div>
<label for="wkn">WKN</label>
<input type="text" id="wkn" name="wkn">
</div>
<div>
<label for="art">Art</label>
<select id="art" name="art">
<?php foreach (Wertpapier::ARTEN as $schluessel => $name): ?>
<option value="<?= View::h($schluessel) ?>"><?= View::h($name) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<h3>Beträge</h3>
<p class="feldhinweis">
Zwei von drei Angaben genügen: Fehlt der Betrag, ergibt er sich aus Stück mal Kurs.
Gebühren und Steuern stehen <em>neben</em> dem Betrag – auf dem Verrechnungskonto
wird bei einem Kauf die Summe belastet, bei einem Verkauf die Differenz gutgeschrieben.
</p>
<div class="feldpaar">
<div>
<label for="stueck">Stück</label>
<input type="text" id="stueck" name="stueck" inputmode="decimal">
</div>
<div>
<label for="kurs">Kurs</label>
<input type="text" id="kurs" name="kurs" inputmode="decimal">
</div>
<div>
<label for="betrag">Betrag</label>
<input type="text" id="betrag" name="betrag" inputmode="decimal">
</div>
</div>
<div class="feldpaar">
<div>
<label for="gebuehren">Gebühren</label>
<input type="text" id="gebuehren" name="gebuehren" inputmode="decimal">
</div>
<div>
<label for="steuern">Steuern</label>
<input type="text" id="steuern" name="steuern" inputmode="decimal">
</div>
</div>
<label for="verrechnungskonto_id">Verrechnungskonto</label>
<select id="verrechnungskonto_id" name="verrechnungskonto_id">
<option value="">– keine Geldbuchung –</option>
<?php foreach ($konten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"><?= View::h($konto['name']) ?></option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">
Hier entsteht die Geldbuchung zum Geschäft. Ohne Angabe bleibt es bei der
Depotbuchung – richtig etwa bei einer Ein- oder Auslieferung.
</p>
<label for="notiz">Notiz</label>
<input type="text" id="notiz" name="notiz">
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Buchung erfassen</button>
</div>
</form>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,176 @@
<?php
use Finanzmanager\View;
/**
* Die Depotübersicht: Bestände mit Einstand, aktuellem Wert und Gewinn.
*
* @var list<array<string,mixed>> $depots
* @var int|null $depot
* @var array<string,mixed> $bestand
* @var list<array{monat:string, kuerzel:string, wert:float, einstand:float}> $entwicklung
* @var bool $kursabruf
* @var string $csrfToken
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<span class="tab aktiv">Bestände</span>
<a href="/wertpapiere/buchungen" class="tab">Depotbuchungen</a>
<a href="/wertpapiere/stammdaten" class="tab">Wertpapiere</a>
<a href="/wertpapiere/auswertung?art=veraeusserungen" class="tab">Veräußerungen</a>
<a href="/wertpapiere/auswertung?art=ertraege" class="tab">Erträge</a>
</div>
<?php if ($depots === []): ?>
<p class="platzhalter">
Noch kein Depot vorhanden. Ein Depot ist ein Konto der Kontoart <em>Depot</em> –
anzulegen in der Kontenverwaltung oder über den QIF-Import.
</p>
<div class="knopfleiste">
<a href="/konten/neu" class="knopf primaer">Konto anlegen</a>
</div>
<?php else: ?>
<div class="seitenaktionen">
<form method="get" action="/wertpapiere" class="inline">
<?php /* Ohne Inline-Handler – die Content-Security-Policy verbietet
sie; abgeschickt wird über den Knopf daneben. */ ?>
<select name="depot">
<option value="">Alle Depots</option>
<?php foreach ($depots as $eintrag): ?>
<option value="<?= (int)$eintrag['id'] ?>" <?= $depot === (int)$eintrag['id'] ? 'selected' : '' ?>>
<?= View::h($eintrag['name']) ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="knopf klein">Anzeigen</button>
</form>
<a href="/wertpapiere/buchungen/neu" class="knopf primaer">Depotbuchung erfassen</a>
</div>
<div class="kennzahlenleiste">
<div class="kennzahlkachel">
<span class="kennzahltitel">Aktueller Wert</span>
<span class="kennzahl"><?= View::betrag($bestand['wert']) ?></span>
</div>
<div class="kennzahlkachel">
<span class="kennzahltitel">Einstandswert</span>
<span class="kennzahl"><?= View::betrag($bestand['einstand']) ?></span>
</div>
<div class="kennzahlkachel">
<span class="kennzahltitel">Nicht realisiert</span>
<span class="kennzahl <?= View::betragKlasse($bestand['gewinn']) ?>">
<?= View::betrag($bestand['gewinn']) ?>
</span>
</div>
<div class="kennzahlkachel">
<span class="kennzahltitel">Realisiert</span>
<span class="kennzahl <?= View::betragKlasse($bestand['realisiert']) ?>">
<?= View::betrag($bestand['realisiert']) ?>
</span>
</div>
<div class="kennzahlkachel">
<span class="kennzahltitel">Erträge</span>
<span class="kennzahl"><?= View::betrag($bestand['ertraege']) ?></span>
</div>
</div>
<?php if (!$bestand['bewertbar']): ?>
<p class="meldung meldung-hinweis" role="status">
Für mindestens ein Papier fehlt ein Kurs – der ausgewiesene Wert ist deshalb
unvollständig.
<?php if ($kursabruf): ?>
<a href="/wertpapiere/stammdaten">Kurse abrufen</a>.
<?php else: ?>
Kurse lassen sich unter <a href="/wertpapiere/stammdaten">Wertpapiere</a> von Hand
eintragen oder über den Kursdienst holen
(<a href="/einstellungen?bereich=wertpapiere">Einstellungen</a>).
<?php endif; ?>
</p>
<?php endif; ?>
<?php if ($bestand['positionen'] === []): ?>
<p class="platzhalter">
Keine Bestände. Depotbuchungen kommen aus dem QIF-Import oder werden hier erfasst.
</p>
<?php else: ?>
<table class="liste zahlen">
<thead>
<tr>
<th>Wertpapier</th>
<th>Depot</th>
<th class="rechts">Stück</th>
<th class="rechts">Einstand je Stück</th>
<th class="rechts">Kurs</th>
<th class="rechts">Einstand</th>
<th class="rechts">Wert</th>
<th class="rechts">Gewinn</th>
</tr>
</thead>
<tbody>
<?php foreach ($bestand['positionen'] as $position): ?>
<tr>
<td>
<?= View::h($position['name']) ?>
<?php if ($position['isin'] !== ''): ?>
<span class="nebentext"><?= View::h($position['isin']) ?></span>
<?php endif; ?>
</td>
<td><?= View::h($position['depot']) ?></td>
<td class="rechts"><?= View::h(rtrim(rtrim(number_format($position['stueck'], 6, ',', '.'), '0'), ',')) ?></td>
<td class="rechts">
<?= $position['einstand_je_stueck'] === null
? '–' : View::h(number_format($position['einstand_je_stueck'], 2, ',', '.')) ?>
</td>
<td class="rechts">
<?php if ($position['kurs'] === null): ?>
<span class="nebentext">kein Kurs</span>
<?php else: ?>
<?= View::h(number_format((float)$position['kurs'], 2, ',', '.')) ?>
<span class="nebentext"><?= View::h(View::datum($position['kurs_datum'])) ?></span>
<?php endif; ?>
</td>
<td class="rechts"><?= View::betrag($position['einstand']) ?></td>
<td class="rechts">
<?= $position['wert'] === null ? '–' : View::betrag($position['wert']) ?>
</td>
<td class="rechts <?= $position['gewinn'] === null ? '' : View::betragKlasse($position['gewinn']) ?>">
<?= $position['gewinn'] === null ? '–' : View::betrag($position['gewinn']) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr class="summenzeile">
<td colspan="5">Summe</td>
<td class="rechts"><?= View::betrag($bestand['einstand']) ?></td>
<td class="rechts"><?= View::betrag($bestand['wert']) ?></td>
<td class="rechts <?= View::betragKlasse($bestand['gewinn']) ?>">
<?= View::betrag($bestand['gewinn']) ?>
</td>
</tr>
</tfoot>
</table>
<?php endif; ?>
<?php
// Die Entwicklung braucht Kurshistorie; ohne sie stünde eine Linie auf null.
$hatEntwicklung = false;
foreach ($entwicklung as $punkt) {
$hatEntwicklung = $hatEntwicklung || $punkt['wert'] > 0.0;
}
?>
<?php if ($hatEntwicklung): ?>
<section class="kachel">
<header>
<h2>Wertentwicklung</h2>
<p class="kachelhinweis">Bewerteter Bestand je Monatsende, dazu der Einstand.</p>
</header>
<?php $reihe = $entwicklung; include __DIR__ . '/entwicklungsdiagramm.php'; ?>
</section>
<?php endif; ?>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,123 @@
<?php
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\Kursabruf;
use Finanzmanager\View;
/**
* @var list<array<string,mixed>> $wertpapiere
* @var string|null $anbieter
* @var bool $schluessel
* @var string $csrfToken
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a href="/wertpapiere" class="tab">Bestände</a>
<a href="/wertpapiere/buchungen" class="tab">Depotbuchungen</a>
<span class="tab aktiv">Wertpapiere</span>
<a href="/wertpapiere/auswertung?art=veraeusserungen" class="tab">Veräußerungen</a>
<a href="/wertpapiere/auswertung?art=ertraege" class="tab">Erträge</a>
</div>
<div class="seitenaktionen">
<?php if ($schluessel): ?>
<form method="post" action="/wertpapiere/kurse" class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf primaer">Kurse jetzt abrufen</button>
</form>
<span class="nebentext">
über <?= View::h(Kursabruf::ANBIETER[$anbieter] ?? (string)$anbieter) ?>
</span>
<?php else: ?>
<a href="/einstellungen?bereich=wertpapiere" class="knopf">Kursdienst einrichten</a>
<span class="nebentext">Ohne API-Schlüssel bleibt nur die Eingabe von Hand.</span>
<?php endif; ?>
</div>
<?php if ($wertpapiere === []): ?>
<p class="platzhalter">
Noch keine Wertpapiere. Sie entstehen beim QIF-Import oder beim Erfassen der ersten
Depotbuchung.
</p>
<?php else: ?>
<?php foreach ($wertpapiere as $papier): ?>
<form id="wp-<?= (int)$papier['id'] ?>" method="post"
action="/wertpapiere/stammdaten/<?= (int)$papier['id'] ?>"></form>
<form id="kurs-<?= (int)$papier['id'] ?>" method="post"
action="/wertpapiere/stammdaten/<?= (int)$papier['id'] ?>/kurs"></form>
<?php endforeach; ?>
<table class="liste">
<thead>
<tr>
<th>Name</th><th>ISIN</th><th>WKN</th><th>Art</th><th>Kürzel</th>
<th>Letzter Kurs</th><th>Kurs eintragen</th><th>Aktiv</th><th class="rechts">Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($wertpapiere as $papier): ?>
<?php $f = 'wp-' . (int)$papier['id']; $k = 'kurs-' . (int)$papier['id']; ?>
<tr<?= (int)$papier['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
<td>
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="text" form="<?= $f ?>" name="name" value="<?= View::h($papier['name']) ?>" required>
</td>
<td><input type="text" form="<?= $f ?>" name="isin" class="schmal"
value="<?= View::h($papier['isin']) ?>"></td>
<td><input type="text" form="<?= $f ?>" name="wkn" class="schmal"
value="<?= View::h($papier['wkn']) ?>"></td>
<td>
<select form="<?= $f ?>" name="art">
<?php foreach (Wertpapier::ARTEN as $schluesselArt => $name): ?>
<option value="<?= View::h($schluesselArt) ?>"
<?= $papier['art'] === $schluesselArt ? 'selected' : '' ?>>
<?= View::h($name) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td>
<input type="text" form="<?= $f ?>" name="symbol" class="schmal"
value="<?= View::h($papier['symbol']) ?>"
title="Kürzel beim Kursdienst – wird beim ersten Abruf selbst ermittelt">
</td>
<td class="rechts">
<?php if ($papier['kurs'] === null): ?>
<span class="nebentext">–</span>
<?php else: ?>
<?= View::h(number_format((float)$papier['kurs'], 2, ',', '.')) ?>
<span class="nebentext"><?= View::h(View::datum($papier['kurs_datum'])) ?></span>
<?php endif; ?>
</td>
<td class="nowrap">
<input type="hidden" form="<?= $k ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" form="<?= $k ?>" name="datum" value="<?= View::h(date('Y-m-d')) ?>">
<input type="text" form="<?= $k ?>" name="kurs" class="schmal" inputmode="decimal"
placeholder="Kurs">
<button type="submit" form="<?= $k ?>" class="knopf klein">Setzen</button>
</td>
<td>
<label class="ankreuzfeld">
<input type="checkbox" form="<?= $f ?>" name="aktiv" value="1"
<?= (int)$papier['aktiv'] === 1 ? 'checked' : '' ?>>
</label>
</td>
<td class="rechts">
<button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p class="hinweis klein">
Das <strong>Kürzel</strong> ist der Name des Papiers beim Kursdienst. Es wird beim
ersten Abruf aus der ISIN ermittelt und gemerkt – das spart bei jedem weiteren Abruf
eine Anfrage, und das Freikontingent ist knapp.
</p>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,173 @@
<?php
use Finanzmanager\Models\Adresse;
use Finanzmanager\View;
/**
* @var array<string,mixed>|null $auftrag
* @var list<array<string,mixed>> $konten
* @var list<array<string,mixed>> $zugaenge
* @var list<array<string,mixed>> $adressen
* @var list<array<string,mixed>> $kategorien
* @var list<array<string,mixed>> $klassen
* @var string $csrfToken
*/
$neu = $auftrag === null;
$ziel = $neu ? '/zahlungsverkehr/neu' : '/zahlungsverkehr/' . (int)$auftrag['id'];
$wert = static fn(string $feld, string $standard = ''): string => (string)($auftrag[$feld] ?? $standard);
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/zahlungsverkehr" class="knopf">Zurück zur Übersicht</a>
<?php if (!$neu && $auftrag['status'] !== 'gesendet'): ?>
<form method="post" action="/zahlungsverkehr/<?= (int)$auftrag['id'] ?>/senden" class="inline"
data-bestaetigen="Überweisung jetzt an die Bank senden?">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf primaer">An die Bank senden</button>
</form>
<?php endif; ?>
</div>
<?php if (!$neu && $auftrag['status'] === 'gesendet'): ?>
<p class="meldung meldung-erfolg" role="status">
Am <?= View::h(date('d.m.Y H:i', strtotime((string)$auftrag['gesendet_am']))) ?> an die Bank
übergeben. Ein gesendeter Auftrag lässt sich nicht mehr ändern.
</p>
<?php elseif (!$neu && ($auftrag['meldung'] ?? '') !== ''): ?>
<p class="meldung meldung-fehler" role="status"><?= View::h($auftrag['meldung']) ?></p>
<?php endif; ?>
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="konto_id">Auftragskonto</label>
<select id="konto_id" name="konto_id" required>
<?php foreach ($konten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"
<?= (int)$wert('konto_id') === (int)$konto['id'] ? 'selected' : '' ?>>
<?= View::h($konto['name']) ?><?= $konto['iban'] !== '' ? ' (' . View::h($konto['iban']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">
Die IBAN des Kontos muss zu einem Konto bei der Bank passen – daran erkennt der
Auftrag, von wo er abgeht.
</p>
<label for="zugang_id">Bankverbindung</label>
<?php /* Nicht required: Ein Auftrag darf erfasst werden, bevor eine
Bankverbindung eingerichtet ist. Beim Senden wird sie ohnehin
verlangt – dort mit einer Meldung, die erklärt, was fehlt. */ ?>
<select id="zugang_id" name="zugang_id">
<option value="">– beim Senden wählen –</option>
<?php foreach ($zugaenge as $zugang): ?>
<option value="<?= (int)$zugang['id'] ?>"
<?= (int)$wert('zugang_id') === (int)$zugang['id'] ? 'selected' : '' ?>>
<?= View::h($zugang['name']) ?>
</option>
<?php endforeach; ?>
</select>
<?php if ($zugaenge === []): ?>
<p class="feldhinweis">
Noch keine Bankverbindung eingerichtet – <a href="/banking/neu">jetzt anlegen</a>.
</p>
<?php endif; ?>
<h3>Empfänger</h3>
<?php if ($adressen !== []): ?>
<label for="adresse">Aus dem Adressbuch</label>
<select id="adresse" name="adresse" data-adressauswahl>
<option value="">– von Hand eingeben –</option>
<?php foreach ($adressen as $adresse): ?>
<option value="<?= (int)$adresse['id'] ?>"
data-name="<?= View::h($adresse['name']) ?>"
data-iban="<?= View::h($adresse['iban']) ?>"
data-bic="<?= View::h($adresse['bic']) ?>">
<?= View::h($adresse['name']) ?><?= $adresse['ort'] !== '' ? ', ' . View::h($adresse['ort']) : '' ?>
</option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">Die Auswahl füllt Name, IBAN und BIC – ändern lässt sich alles.</p>
<?php endif; ?>
<label for="empfaenger">Name</label>
<input type="text" id="empfaenger" name="empfaenger" value="<?= View::h($wert('empfaenger')) ?>" required>
<label for="iban">IBAN</label>
<input type="text" id="iban" name="iban"
value="<?= View::h($neu ? '' : Adresse::ibanFormatiert($wert('iban'))) ?>" required>
<p class="feldhinweis">Wird beim Speichern geprüft (Prüfziffer nach ISO 13616).</p>
<label for="bic">BIC <span class="optional">(im SEPA-Raum nicht nötig)</span></label>
<input type="text" id="bic" name="bic" value="<?= View::h($wert('bic')) ?>">
<?php if ($neu): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="adresse_merken" value="1">
Empfänger ins Adressbuch übernehmen
</label>
<?php endif; ?>
<h3>Zahlung</h3>
<label for="betrag">Betrag</label>
<input type="text" id="betrag" name="betrag" inputmode="decimal" required
value="<?= $neu ? '' : View::h(number_format((float)$wert('betrag', '0'), 2, ',', '')) ?>">
<label for="verwendungszweck">Verwendungszweck</label>
<input type="text" id="verwendungszweck" name="verwendungszweck" maxlength="140"
value="<?= View::h($wert('verwendungszweck')) ?>">
<p class="feldhinweis">
Höchstens 140 Zeichen. Umlaute werden für SEPA umgeschrieben (ä → ae), Zeichen
außerhalb des erlaubten Vorrats fallen weg – sonst weist die Bank den Auftrag ab.
</p>
<label for="ausfuehrung_am">Ausführung am <span class="optional">(leer = sofort)</span></label>
<input type="date" id="ausfuehrung_am" name="ausfuehrung_am"
value="<?= View::h($wert('ausfuehrung_am')) ?>">
<label for="kategorie_id">Kategorie</label>
<select id="kategorie_id" name="kategorie_id">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= (int)$wert('kategorie_id') === (int)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">Wird auf die Buchung übertragen, sobald die Bank den Auftrag annimmt.</p>
<label for="klasse_id">Klasse</label>
<select id="klasse_id" name="klasse_id">
<option value="">– keine –</option>
<?php foreach ($klassen as $klasse): ?>
<option value="<?= (int)$klasse['id'] ?>"
<?= (int)$wert('klasse_id') === (int)$klasse['id'] ? 'selected' : '' ?>>
<?= View::h($klasse['name']) ?>
</option>
<?php endforeach; ?>
</select>
<?php if ($neu || $auftrag['status'] !== 'gesendet'): ?>
<div class="knopfleiste">
<button type="submit" class="knopf primaer"><?= $neu ? 'Erfassen' : 'Speichern' ?></button>
</div>
<?php endif; ?>
</form>
<?php if (!$neu && $auftrag['status'] !== 'gesendet'): ?>
<form method="post" action="/zahlungsverkehr/<?= (int)$auftrag['id'] ?>/loeschen" class="formular breit"
data-bestaetigen="Zahlungsauftrag löschen?">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<div class="knopfleiste">
<button type="submit" class="knopf gefahr">Auftrag löschen</button>
</div>
</form>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,102 @@
<?php
use Finanzmanager\Models\Adresse;
use Finanzmanager\Models\Zahlungsauftrag;
use Finanzmanager\View;
/**
* @var list<array<string,mixed>> $auftraege
* @var string $status
* @var int $offen
* @var bool $wartetTan
* @var string $csrfToken
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a href="/zahlungsverkehr" class="tab <?= $status === '' ? 'aktiv' : '' ?>">Alle</a>
<?php foreach (Zahlungsauftrag::STATUS as $schluessel => $name): ?>
<a href="/zahlungsverkehr?status=<?= View::h($schluessel) ?>"
class="tab <?= $status === $schluessel ? 'aktiv' : '' ?>">
<?= View::h(ucfirst($name)) ?><?= $schluessel === 'entwurf' && $offen > 0 ? ' (' . $offen . ')' : '' ?>
</a>
<?php endforeach; ?>
</div>
<?php if ($wartetTan): ?>
<p class="meldung meldung-hinweis" role="status">
Eine Überweisung wartet auf die Freigabe.
<a href="/zahlungsverkehr/tan">Jetzt freigeben</a>.
</p>
<?php endif; ?>
<div class="seitenaktionen">
<a href="/zahlungsverkehr/neu" class="knopf primaer">Überweisung erfassen</a>
<a href="/vertraege/adressen" class="knopf">Adressbuch</a>
</div>
<p class="hinweis">
Eine Überweisung wird erst gebucht, wenn die Bank sie angenommen hat – vorher steht sie
als Entwurf hier. Ohne Ausführungstermin geht sie sofort hinaus, mit Termin als
Terminüberweisung.
</p>
<?php if ($auftraege === []): ?>
<p class="platzhalter">Keine Zahlungsaufträge.</p>
<?php else: ?>
<table class="liste">
<thead>
<tr>
<th>Empfänger</th><th>IBAN</th><th>Konto</th><th class="rechts">Betrag</th>
<th>Ausführung</th><th>Status</th><th class="rechts">Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($auftraege as $auftrag): ?>
<tr<?= $auftrag['status'] === 'storniert' ? ' class="inaktiv"' : '' ?>>
<td>
<a href="/zahlungsverkehr/<?= (int)$auftrag['id'] ?>"><?= View::h($auftrag['empfaenger']) ?></a>
<?php if (($auftrag['verwendungszweck'] ?? '') !== ''): ?>
<span class="nebentext"><?= View::h($auftrag['verwendungszweck']) ?></span>
<?php endif; ?>
</td>
<td class="klein"><?= View::h(Adresse::ibanFormatiert($auftrag['iban'])) ?></td>
<td><?= View::h($auftrag['kontoname']) ?></td>
<td class="rechts negativ"><?= View::betrag($auftrag['betrag']) ?></td>
<td>
<?= $auftrag['ausfuehrung_am'] === null
? '<span class="nebentext">sofort</span>'
: View::h(View::datum($auftrag['ausfuehrung_am'])) ?>
</td>
<td>
<span class="marke marke-<?= View::h($auftrag['status']) ?>">
<?= View::h(Zahlungsauftrag::STATUS[$auftrag['status']]) ?>
</span>
<?php if (($auftrag['meldung'] ?? '') !== '' && $auftrag['status'] === 'fehler'): ?>
<span class="nebentext"><?= View::h($auftrag['meldung']) ?></span>
<?php endif; ?>
</td>
<td class="rechts nowrap">
<?php if ($auftrag['status'] === 'gesendet'): ?>
<?php if ($auftrag['buchung_id'] !== null): ?>
<a href="/buchungen/<?= (int)$auftrag['buchung_id'] ?>" class="knopf klein">Buchung</a>
<?php endif; ?>
<?php else: ?>
<form method="post" action="/zahlungsverkehr/<?= (int)$auftrag['id'] ?>/senden"
class="inline"
data-bestaetigen="Überweisung über <?= View::h(View::betrag($auftrag['betrag'])) ?> an <?= View::h($auftrag['empfaenger']) ?> jetzt an die Bank senden?">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf klein primaer">Senden</button>
</form>
<a href="/zahlungsverkehr/<?= (int)$auftrag['id'] ?>" class="knopf klein">Bearbeiten</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,75 @@
<?php
use Finanzmanager\View;
/**
* Freigabe einer Überweisung. Wie die TAN-Maske des Abrufs, nur mit deutlich
* mehr Nachdruck: Hier geht Geld hinaus.
*
* @var array<string,mixed> $tan
* @var string $meldung
* @var string $csrfToken
*/
$entkoppelt = ($tan['entkoppelt'] ?? false) === true;
include __DIR__ . '/../layout/kopf.php';
?>
<div class="kachel einzeln">
<header>
<h2><?= $entkoppelt ? 'Freigabe im Banking-Gerät' : 'TAN eingeben' ?></h2>
<p class="kachelhinweis"><?= View::h($tan['bezeichnung'] ?? 'Überweisung') ?></p>
</header>
<p class="hinweis"><?= View::h($meldung) ?></p>
<?php if (($tan['hinweis'] ?? '') !== ''): ?>
<p class="tanhinweis"><?= View::h($tan['hinweis']) ?></p>
<?php endif; ?>
<?php if (($tan['medium'] ?? '') !== ''): ?>
<p class="feldhinweis">TAN-Medium: <?= View::h($tan['medium']) ?></p>
<?php endif; ?>
<?php if (!empty($tan['bild_daten'])): ?>
<img class="tanbild" alt="TAN-Grafik der Bank"
src="data:<?= View::h($tan['bild_typ'] ?? 'image/png') ?>;base64,<?= View::h($tan['bild_daten']) ?>">
<?php endif; ?>
<form method="post" action="/zahlungsverkehr/tan" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<?php if ($entkoppelt): ?>
<p class="feldhinweis">
Die Bank hat die Freigabe an dein Banking-Gerät geschickt. Dort bestätigen und
anschließend hier prüfen lassen.
</p>
<input type="hidden" name="tan" value="">
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Freigabe prüfen</button>
</div>
<?php else: ?>
<label for="tan">TAN</label>
<input type="text" id="tan" name="tan" inputmode="numeric" autocomplete="one-time-code"
autofocus required>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Überweisung freigeben</button>
</div>
<?php endif; ?>
</form>
<form method="post" action="/zahlungsverkehr/tan/abbrechen" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<div class="knopfleiste">
<button type="submit" class="knopf">Abbrechen</button>
</div>
</form>
<p class="feldhinweis">
Beim Abbrechen bleibt der Auftrag als Entwurf stehen und lässt sich später erneut
senden. Ob die Bank ihn schon angenommen hat, steht danach in der Übersicht.
</p>
</div>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

252
tests/15_CockpitTest.php Normal file
View file

@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Auswertung;
use Finanzmanager\Services\Cockpit;
class CockpitTest extends TestCase
{
protected string $testName = 'Test 15: Cockpit-Kacheln';
/** Fester Stichtag – sonst hinge das Ergebnis am Tag des Testlaufs. */
private const HEUTE = '2026-08-11';
private int $giro = 0;
private int $sparkonto = 0;
private int $gehalt = 0;
private int $wohnen = 0;
private int $miete = 0;
private int $strom = 0;
private int $freizeit = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->buchungen();
$this->kachelauswahlMerktSich();
$this->monatsreiheTrenntEinnahmenUndAusgaben();
$this->monatsreiheLaesstUmbuchungenAus();
$this->verteilungFasstAufDerWurzelZusammen();
$this->verteilungKuerztAufDieGrenze();
$this->offeneVorgaengeZaehlenWasWartet();
}
private function stammdaten(): void
{
$this->giro = Konto::anlegen([
'name' => 'Cockpitgiro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1,
]);
$this->sparkonto = Konto::anlegen([
'name' => 'Cockpitsparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->gehalt = Kategorie::findenOderAnlegenNachPfad('100 - Einkommen:Gehalt', 'einnahme');
$this->wohnen = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen', 'ausgabe');
$this->miete = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Miete', 'ausgabe');
$this->strom = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Strom', 'ausgabe');
$this->freizeit = Kategorie::findenOderAnlegenNachPfad('500 - Freizeit', 'ausgabe');
}
private function buchungen(): void
{
// Zwei Monate des laufenden Jahres, beide im Zwölfmonatsfenster.
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-07-01', 'betrag' => 3000,
'empfaenger' => 'Arbeitgeber', 'kategorie_id' => $this->gehalt, 'abgeglichen' => 1,
]);
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-07-03', 'betrag' => -900,
'empfaenger' => 'Vermieter', 'kategorie_id' => $this->miete, 'abgeglichen' => 1,
]);
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-07-15', 'betrag' => -100,
'empfaenger' => 'Stadtwerke', 'kategorie_id' => $this->strom, 'abgeglichen' => 1,
]);
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-08-01', 'betrag' => 3000,
'empfaenger' => 'Arbeitgeber', 'kategorie_id' => $this->gehalt, 'abgeglichen' => 1,
]);
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-08-05', 'betrag' => -200,
'empfaenger' => 'Kino', 'kategorie_id' => $this->freizeit, 'abgeglichen' => 1,
]);
}
private function kachelauswahlMerktSich(): void
{
$this->assertEquals(
array_keys(Cockpit::KACHELN),
Cockpit::sichtbar(),
'Ohne gespeicherte Auswahl sind alle Kacheln sichtbar'
);
Cockpit::setzeSichtbar(['ausgaben', 'vermoegen', 'gibtesnicht']);
$this->assertEquals(
['vermoegen', 'ausgaben'],
Cockpit::sichtbar(),
'Gespeichert wird in Katalogreihenfolge, Unbekanntes fällt weg'
);
$this->assert(Cockpit::zeigt('vermoegen'), 'Die gewählte Kachel wird gezeigt');
$this->assert(!Cockpit::zeigt('kontostaende'), 'Die abgewählte nicht');
// Alles abwählen muss möglich bleiben – sonst käme man aus einer leeren
// Auswahl nie heraus, weil sie als „nie gespeichert“ gälte.
Cockpit::setzeSichtbar([]);
$this->assertEquals([], Cockpit::sichtbar(), 'Auch die leere Auswahl bleibt leer');
Cockpit::setzeSichtbar(array_keys(Cockpit::KACHELN));
}
private function monatsreiheTrenntEinnahmenUndAusgaben(): void
{
$reihe = Cockpit::monatsreihe(self::HEUTE);
$this->assertEquals(12, count($reihe['monate']), 'Zwölf Monatsspalten');
$this->assertEquals('2026-08', $reihe['monate'][11]['schluessel'], 'Die letzte Spalte ist der laufende Monat');
$this->assertEquals('2025-09', $reihe['monate'][0]['schluessel'], 'Die erste liegt elf Monate davor');
$this->assertEquals(3000.0, $reihe['einnahmen'][10], 'Juli: das Gehalt');
$this->assertEquals(1000.0, $reihe['ausgaben'][10], 'Juli: Miete und Strom, vorzeichenlos');
$this->assertEquals(2000.0, $reihe['salden'][10], 'Juli: Saldo ist Einnahmen minus Ausgaben');
$this->assertEquals(2800.0, $reihe['salden'][11], 'August: 3000 minus 200');
$this->assertEquals(6000.0, $reihe['summe_einnahmen'], 'Summe der Einnahmen über alle Monate');
$this->assertEquals(1200.0, $reihe['summe_ausgaben'], 'Summe der Ausgaben über alle Monate');
$this->assert($reihe['hatWerte'], 'Es gibt etwas zu zeichnen');
}
/** Wie überall in den Auswertungen: Umbuchungen sind weder Einnahme noch Ausgabe. */
private function monatsreiheLaesstUmbuchungenAus(): void
{
$vorher = Cockpit::monatsreihe(self::HEUTE);
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-08-06', 'betrag' => -500,
'empfaenger' => 'Aufs Sparbuch', 'gegenkonto_id' => $this->sparkonto,
]);
$nachher = Cockpit::monatsreihe(self::HEUTE);
$this->assertEquals(
$vorher['summe_ausgaben'],
$nachher['summe_ausgaben'],
'Die Umbuchung taucht in den Ausgaben nicht auf'
);
$this->assertEquals(
$vorher['salden'][11],
$nachher['salden'][11],
'Und ändert den Saldo des Monats nicht'
);
}
/**
* Die Kachel zeigt oberste Kategorien: „300 - Wohnen“ statt Miete und Strom
* einzeln – sonst hätte der Ring so viele Segmente wie es Kategorien gibt.
*/
private function verteilungFasstAufDerWurzelZusammen(): void
{
$verteilung = Cockpit::ausgaben(self::HEUTE);
$namen = array_column($verteilung['zeilen'], 'betrag', 'name');
$this->assertEquals(1000.0, $namen['300 - Wohnen'] ?? 0.0, 'Miete und Strom stehen unter ihrer Wurzel');
$this->assertEquals(200.0, $namen['500 - Freizeit'] ?? 0.0, 'Freizeit hat keine Kinder und steht für sich');
$this->assert(!isset($namen['300 - Wohnen:Miete']), 'Unterkategorien haben kein eigenes Segment');
$this->assertEquals(1200.0, $verteilung['summe'], 'Die Summe sind alle Ausgaben des Jahres');
$this->assertEquals('ausgabe', $verteilung['art'], 'Gefragt war nach Ausgaben');
$this->assert(!$verteilung['leer'], 'Die Kachel hat etwas zu zeigen');
$anteile = 0.0;
foreach ($verteilung['zeilen'] as $zeile) {
$this->assertGreaterThan(0.0, $zeile['betrag'], 'Jedes Segment trägt einen positiven Betrag');
$anteile += $zeile['anteil'];
}
$this->assertEquals(1.0, round($anteile, 6), 'Die Anteile ergeben zusammen den ganzen Ring');
// Einnahmen gehen genauso, nur andersherum.
$einnahmen = Auswertung::verteilung(
['typ' => 'summenbericht', 'zeitraum' => 'aktuelles_jahr'],
'einnahme',
8,
self::HEUTE
);
$this->assertEquals(6000.0, $einnahmen['summe'], 'Die Einnahmenseite zählt das Gehalt');
}
private function verteilungKuerztAufDieGrenze(): void
{
for ($nummer = 1; $nummer <= 12; $nummer++) {
$kategorie = Kategorie::findenOderAnlegenNachPfad('9' . $nummer . ' - Klein ' . $nummer, 'ausgabe');
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-06-10', 'betrag' => -$nummer,
'empfaenger' => 'Kleinbetrag ' . $nummer, 'kategorie_id' => $kategorie,
]);
}
$verteilung = Auswertung::verteilung(
['typ' => 'summenbericht', 'zeitraum' => 'aktuelles_jahr'],
'ausgabe',
5,
self::HEUTE
);
$this->assertEquals(5, count($verteilung['zeilen']), 'Nicht mehr Segmente als die Grenze erlaubt');
$letzte = $verteilung['zeilen'][4];
$this->assert(str_starts_with($letzte['name'], 'Übrige ('), 'Das letzte Segment sammelt den Rest');
// Gekürzt wird die Darstellung, nicht die Summe.
$gezeichnet = array_sum(array_column($verteilung['zeilen'], 'betrag'));
$this->assertEquals(
$verteilung['summe'],
round($gezeichnet, 2),
'Die Segmente ergeben zusammen die Gesamtsumme'
);
}
private function offeneVorgaengeZaehlenWasWartet(): void
{
$schluessel = static fn(array $vorgaenge): array => array_column($vorgaenge, 'anzahl', 'schluessel');
$vorher = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE));
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-08-07', 'betrag' => -42,
'empfaenger' => 'Unbekannt',
]);
$nachher = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE));
$this->assertEquals(
($vorher['ohne_kategorie'] ?? 0) + 1,
$nachher['ohne_kategorie'] ?? 0,
'Eine Buchung ohne Kategorie erscheint als offener Vorgang'
);
$this->assertGreaterThan(
0,
$nachher['nicht_abgeglichen'] ?? 0,
'Nicht abgeglichene Buchungen werden mitgezählt'
);
// Umbuchungen brauchen keine Kategorie – sie sind keine offene Arbeit.
$mitUmbuchung = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE));
$this->assertEquals(
$nachher['ohne_kategorie'] ?? 0,
$mitUmbuchung['ohne_kategorie'] ?? 0,
'Die Umbuchung von vorhin zählt nicht als unkategorisiert'
);
foreach (Cockpit::offeneVorgaenge(self::HEUTE) as $vorgang) {
$this->assertNotEmpty($vorgang['pfad'], 'Jeder Vorgang führt irgendwohin');
$this->assertNotEmpty($vorgang['text'], 'Und trägt einen Text');
}
}
}

544
tests/16_BankingTest.php Normal file
View file

@ -0,0 +1,544 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\Models\Bankabruf;
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Crypto;
/**
* Online-Banking ohne Bank: Geprüft wird alles, was um den FinTS-Dialog herum
* liegt – Verschlüsselung, Zeiträume, Dublettenerkennung, Regeln und der
* Abgleich. Der Dialog selbst braucht eine echte Bankverbindung und lässt sich
* hier nicht nachstellen.
*/
class BankingTest extends TestCase
{
protected string $testName = 'Test 16: Online-Banking und Abgleich';
private int $giro = 0;
private int $sparkonto = 0;
private int $strom = 0;
private int $miete = 0;
private int $zugang = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->verschluesselungSchuetztDieZugangsdaten();
$this->zugangHaeltPinBeimSpeichern();
$this->zeitraumRichtetSichNachDemLetztenAbruf();
$this->umsaetzeKommenNurEinmalHerein();
$this->regelnGreifenNachPrioritaet();
$this->regelnRechnenSplitsAus();
$this->ungueltigesMusterWirdAbgewiesen();
$this->abgleichFindetVorhandeneBuchung();
$this->uebernehmenErzeugtEineBuchung();
$this->verwerfenUndZuruecksetzen();
$this->abrufprotokollHaeltDenVerlauf();
}
private function stammdaten(): void
{
$this->giro = Konto::anlegen([
'name' => 'Bankinggiro', 'kontoart' => 'bank', 'anfangssaldo' => 500,
'aktiv' => 1, 'iban' => 'DE02120300000000202051',
]);
$this->sparkonto = Konto::anlegen([
'name' => 'Bankingsparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->strom = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Strom', 'ausgabe');
$this->miete = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Miete', 'ausgabe');
$this->zugang = Bankzugang::anlegen([
'name' => 'Testbank',
'bankname' => 'Testbank AG',
'blz' => '12030000',
'fints_url' => 'https://banking.example.org/fints',
'benutzerkennung' => 'testkunde',
'pin' => 'geheim1234',
'abrufzeitraum' => 'letzter_abruf',
'aktiv' => 1,
]);
}
/** @param array<string,mixed> $daten */
private function umsatzAnlegen(array $daten): ?int
{
return Bankumsatz::anlegen([
'zugang_id' => $this->zugang,
// array_key_exists, nicht ??: Ein ausdrückliches null steht hier für
// „kein Konto zugeordnet“ und darf nicht auf das Giro zurückfallen.
'konto_id' => array_key_exists('konto_id', $daten) ? $daten['konto_id'] : $this->giro,
'extern_id' => $daten['extern_id'] ?? Bankumsatz::externId(
'DE02120300000000202051',
(string)$daten['buchungstag'],
(float)$daten['betrag'],
(string)($daten['verwendungszweck'] ?? ''),
(string)($daten['empfaenger'] ?? '')
),
'buchungstag' => $daten['buchungstag'],
'wertstellung' => $daten['buchungstag'],
'betrag' => $daten['betrag'],
'empfaenger' => $daten['empfaenger'] ?? '',
'verwendungszweck' => $daten['verwendungszweck'] ?? '',
'buchungsart' => 'LASTSCHRIFT',
]);
}
private function verschluesselungSchuetztDieZugangsdaten(): void
{
if (!Crypto::einsatzbereit()) {
$this->assert(false, 'APP_KEY fehlt – ohne ihn lässt sich die Verschlüsselung nicht prüfen');
return;
}
$geheim = Crypto::verschluessle('meine-pin-1234');
$this->assert(Crypto::istVerschluesselt($geheim), 'Der Geheimtext trägt seine Kennung');
$this->assert(!str_contains($geheim, 'meine-pin'), 'Die PIN steht nicht im Klartext darin');
$this->assertEquals('meine-pin-1234', Crypto::entschluessle($geheim), 'Und kommt unverändert zurück');
// Zweimal dasselbe verschlüsselt ergibt zwei verschiedene Geheimtexte –
// jeder Durchgang zieht einen neuen Initialisierungsvektor.
$this->assert(
Crypto::verschluessle('meine-pin-1234') !== $geheim,
'Gleicher Klartext, anderer Geheimtext'
);
$this->assertEquals('', Crypto::verschluessle(''), 'Leeres bleibt leer');
$this->assertEquals(
'klartext-aus-altbestand',
Crypto::entschluessle('klartext-aus-altbestand'),
'Werte ohne Kennung werden durchgereicht'
);
$doppelt = Crypto::verschluessle($geheim);
$this->assertEquals($geheim, $doppelt, 'Zweimal verschlüsseln verschachtelt nicht');
}
private function zugangHaeltPinBeimSpeichern(): void
{
$roh = Bankzugang::find($this->zugang);
$this->assert(
!str_contains((string)$roh['pin'], 'geheim1234'),
'In der Datenbank steht die PIN nur verschlüsselt'
);
$mitPin = Bankzugang::zugangsdaten($this->zugang);
$this->assertEquals('geheim1234', $mitPin['pin'], 'Der Abrufdienst bekommt sie im Klartext');
// Ein leeres PIN-Feld heißt „unverändert“ – sonst verlöre jedes
// Speichern des Formulars die Zugangsdaten.
Bankzugang::speichern($this->zugang, [
'name' => 'Testbank',
'blz' => '12030000',
'fints_url' => 'https://banking.example.org/fints',
'benutzerkennung' => 'testkunde',
'pin' => '',
'abrufzeitraum' => 'sieben_tage',
'aktiv' => 1,
]);
$nachher = Bankzugang::zugangsdaten($this->zugang);
$this->assertEquals('geheim1234', $nachher['pin'], 'Leeres Feld behält die alte PIN');
$this->assertEquals('sieben_tage', $nachher['abrufzeitraum'], 'Der Zeitraum wurde übernommen');
$fehler = false;
try {
Bankzugang::speichern($this->zugang, [
'name' => 'Testbank', 'fints_url' => 'http://unverschluesselt.example.org',
]);
} catch (\InvalidArgumentException) {
$fehler = true;
}
$this->assert($fehler, 'Eine FinTS-Adresse ohne https wird abgewiesen');
Bankzugang::speichern($this->zugang, [
'name' => 'Testbank',
'blz' => '12030000',
'fints_url' => 'https://banking.example.org/fints',
'benutzerkennung' => 'testkunde',
'pin' => '',
'abrufzeitraum' => 'letzter_abruf',
'aktiv' => 1,
]);
}
private function zeitraumRichtetSichNachDemLetztenAbruf(): void
{
$heute = new \DateTimeImmutable('2026-08-11');
[$von] = Bankzugang::startdatum('sieben_tage', null, $heute);
$this->assertEquals('2026-08-04', $von->format('Y-m-d'), 'Sieben Tage zurück');
[$von] = Bankzugang::startdatum('dreissig_tage', null, $heute);
$this->assertEquals('2026-07-12', $von->format('Y-m-d'), 'Dreißig Tage zurück');
[$von, $text] = Bankzugang::startdatum('letzter_abruf', '2026-08-09 22:15:00', $heute);
$this->assertEquals('2026-08-09', $von->format('Y-m-d'), 'Ab dem Tag des letzten Abrufs');
$this->assert(str_contains($text, '09.08.2026'), 'Und sagt es auch');
// Ohne vorherigen Abruf muss ein sinnvoller Anfang her, sonst käme
// entweder nichts oder alles.
[$von] = Bankzugang::startdatum('letzter_abruf', null, $heute);
$this->assertEquals('2025-08-11', $von->format('Y-m-d'), 'Beim ersten Mal ein Jahr zurück');
[$von] = Bankzugang::startdatum('quatsch', null, $heute);
$this->assertEquals('2025-08-11', $von->format('Y-m-d'), 'Unbekannte Vorgabe fällt zurück');
}
private function umsaetzeKommenNurEinmalHerein(): void
{
$erste = $this->umsatzAnlegen([
'buchungstag' => '2026-08-03', 'betrag' => -84.20,
'empfaenger' => 'Stadtwerke Musterstadt', 'verwendungszweck' => 'Abschlag Strom 08/2026',
]);
$this->assertNotEmpty($erste, 'Der Umsatz wurde angelegt');
$zweite = $this->umsatzAnlegen([
'buchungstag' => '2026-08-03', 'betrag' => -84.20,
'empfaenger' => 'Stadtwerke Musterstadt', 'verwendungszweck' => 'Abschlag Strom 08/2026',
]);
$this->assertNull($zweite, 'Derselbe Umsatz kommt beim nächsten Abruf nicht noch einmal herein');
// Ein anderer Betrag ist ein anderer Umsatz, auch am selben Tag.
$dritte = $this->umsatzAnlegen([
'buchungstag' => '2026-08-03', 'betrag' => -12.00,
'empfaenger' => 'Stadtwerke Musterstadt', 'verwendungszweck' => 'Abschlag Strom 08/2026',
]);
$this->assertNotEmpty($dritte, 'Anderer Betrag, anderer Umsatz');
$this->assertEquals(2, Bankumsatz::anzahlOffen(), 'Zwei offene Umsätze');
}
private function regelnGreifenNachPrioritaet(): void
{
// Die unspezifische Regel hat die schlechtere Priorität und darf der
// genaueren nicht dazwischenkommen.
AbgleichRegel::anlegen([
'name' => 'Wohnen allgemein', 'feld' => 'beides', 'vergleich' => 'enthaelt',
'muster' => 'stadtwerke', 'kategorie_id' => $this->miete, 'prioritaet' => 200, 'aktiv' => 1,
]);
AbgleichRegel::anlegen([
'name' => 'Strom', 'feld' => 'verwendungszweck', 'vergleich' => 'enthaelt',
'muster' => 'Strom', 'kategorie_id' => $this->strom, 'prioritaet' => 10, 'aktiv' => 1,
]);
$umsatz = [
'empfaenger' => 'Stadtwerke Musterstadt',
'verwendungszweck' => 'Abschlag Strom 08/2026',
'betrag' => -84.20,
];
$regel = AbgleichRegel::passendeRegel($umsatz);
$this->assertNotEmpty($regel, 'Eine Regel passt');
$this->assertEquals('Strom', $regel['name'], 'Die mit der besseren Priorität gewinnt');
// Groß- und Kleinschreibung darf nicht entscheiden: Banken schreiben
// denselben Empfänger mal so, mal so.
$this->assertNotEmpty(
AbgleichRegel::passendeRegel(['empfaenger' => 'STADTWERKE MUSTERSTADT', 'verwendungszweck' => '', 'betrag' => -1]),
'Versalien treffen genauso'
);
// Das Feld schränkt ein: „nur Empfänger“ sieht den Verwendungszweck nicht.
$nurEmpfaenger = AbgleichRegel::anlegen([
'name' => 'Nur Empfänger', 'feld' => 'empfaenger', 'vergleich' => 'enthaelt',
'muster' => 'Abschlag', 'kategorie_id' => $this->miete, 'prioritaet' => 1, 'aktiv' => 1,
]);
$this->assertEquals(
'Strom',
AbgleichRegel::passendeRegel($umsatz)['name'],
'Ein Muster im falschen Feld greift nicht'
);
AbgleichRegel::loeschen($nurEmpfaenger);
// Betragsgrenzen rechnen mit Vorzeichen.
$grenze = AbgleichRegel::anlegen([
'name' => 'Kleine Beträge', 'feld' => 'beides', 'vergleich' => 'enthaelt',
'muster' => 'stadtwerke', 'betrag_von' => -20, 'betrag_bis' => 0,
'kategorie_id' => $this->miete, 'prioritaet' => 1, 'aktiv' => 1,
]);
$this->assertEquals(
'Strom',
AbgleichRegel::passendeRegel($umsatz)['name'],
'−84,20 liegt außerhalb von −20 bis 0'
);
$this->assertEquals(
'Kleine Beträge',
AbgleichRegel::passendeRegel(['empfaenger' => 'Stadtwerke', 'verwendungszweck' => '', 'betrag' => -12.0])['name'],
'−12,00 liegt darin'
);
AbgleichRegel::loeschen($grenze);
// Eine abgeschaltete Regel greift nicht mehr.
$regeln = AbgleichRegel::alle();
foreach ($regeln as $eintrag) {
if ($eintrag['name'] === 'Strom') {
AbgleichRegel::speichern((int)$eintrag['id'], [
'name' => 'Strom', 'feld' => 'verwendungszweck', 'vergleich' => 'enthaelt',
'muster' => 'Strom', 'kategorie_id' => $this->strom, 'prioritaet' => 10, 'aktiv' => 0,
]);
}
}
$this->assertEquals(
'Wohnen allgemein',
AbgleichRegel::passendeRegel($umsatz)['name'],
'Abgeschaltet rückt die nächste Regel nach'
);
foreach (AbgleichRegel::alle() as $eintrag) {
if ($eintrag['name'] === 'Strom') {
AbgleichRegel::speichern((int)$eintrag['id'], [
'name' => 'Strom', 'feld' => 'verwendungszweck', 'vergleich' => 'enthaelt',
'muster' => 'Strom', 'kategorie_id' => $this->strom, 'prioritaet' => 10, 'aktiv' => 1,
]);
}
}
}
private function regelnRechnenSplitsAus(): void
{
$id = AbgleichRegel::anlegen([
'name' => 'Geteilte Rechnung',
'feld' => 'empfaenger',
'vergleich' => 'beginnt',
'muster' => 'Vermieter',
'splits' => [
['kategorie_id' => $this->miete, 'anteil' => 0.6667],
['kategorie_id' => $this->strom, 'anteil' => 0.3333],
],
'prioritaet' => 5,
'aktiv' => 1,
]);
$regel = AbgleichRegel::find($id);
$umsatz = ['empfaenger' => 'Vermieter Meier', 'verwendungszweck' => 'Miete', 'betrag' => -1000.00];
$this->assert(AbgleichRegel::passt($regel, $umsatz), '„beginnt mit“ trifft');
$this->assert(
!AbgleichRegel::passt($regel, ['empfaenger' => 'Der Vermieter', 'verwendungszweck' => '', 'betrag' => -1]),
'Aber nicht mitten im Text'
);
$zuordnung = AbgleichRegel::anwenden($regel, $umsatz);
$this->assertEquals(2, count($zuordnung['splits']), 'Zwei Splitzeilen');
$this->assertEquals(-666.70, $zuordnung['splits'][0]['betrag'], 'Erste Zeile nach Anteil');
// Die letzte Zeile bekommt den Rest – sonst ginge bei krummen Anteilen
// ein Cent verloren und die Buchung ließe sich nicht speichern.
$summe = array_sum(array_column($zuordnung['splits'], 'betrag'));
$this->assertEquals(-1000.00, round($summe, 2), 'Die Splits ergeben den Buchungsbetrag');
AbgleichRegel::loeschen($id);
}
private function ungueltigesMusterWirdAbgewiesen(): void
{
$this->assert(
!AbgleichRegel::musterIstGueltig('regex', 'Miete('),
'Eine offene Klammer ist kein gültiger Ausdruck'
);
$this->assert(
AbgleichRegel::musterIstGueltig('regex', 'Miete|Nebenkosten'),
'Eine Alternative schon'
);
$fehler = false;
try {
AbgleichRegel::anlegen([
'name' => 'Kaputt', 'feld' => 'beides', 'vergleich' => 'regex',
'muster' => 'Miete(', 'aktiv' => 1,
]);
} catch (\InvalidArgumentException) {
$fehler = true;
}
$this->assert($fehler, 'Und wird gar nicht erst gespeichert');
$id = AbgleichRegel::anlegen([
'name' => 'Ausdruck', 'feld' => 'verwendungszweck', 'vergleich' => 'regex',
'muster' => 'Strom|Gas', 'kategorie_id' => $this->strom, 'prioritaet' => 50, 'aktiv' => 1,
]);
$this->assert(
AbgleichRegel::passt(
AbgleichRegel::find($id),
['empfaenger' => '', 'verwendungszweck' => 'Abschlag Gas 2026', 'betrag' => -50]
),
'Der reguläre Ausdruck trifft die Alternative'
);
AbgleichRegel::loeschen($id);
}
private function abgleichFindetVorhandeneBuchung(): void
{
Einstellung::set('banking_abgleich_tage', '5', 'banking');
// Von Hand erfasst, drei Tage vor dem Banktag – der Normalfall.
$buchungId = Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-08-01', 'betrag' => -84.20,
'empfaenger' => 'Stadtwerke', 'kategorie_id' => $this->strom,
]);
$umsatz = Bankumsatz::liste(['status' => 'offen'])[0];
// Die Liste ist absteigend sortiert; gesucht ist der über 84,20.
foreach (Bankumsatz::liste(['status' => 'offen']) as $eintrag) {
if (abs((float)$eintrag['betrag'] + 84.20) < 0.005) {
$umsatz = $eintrag;
}
}
$treffer = Bankumsatz::passendeBuchung($umsatz);
$this->assertNotEmpty($treffer, 'Die vorhandene Buchung wird gefunden');
$this->assertEquals($buchungId, (int)$treffer['id'], 'Und zwar genau sie');
$vorschlag = Bankumsatz::vorschlag($umsatz);
$this->assertEquals('verbinden', $vorschlag['art'], 'Der Vorschlag lautet: verbinden');
// Außerhalb der Toleranz zählt sie nicht mehr.
Einstellung::set('banking_abgleich_tage', '1', 'banking');
$this->assertNull(
Bankumsatz::passendeBuchung($umsatz),
'Mit einem Tag Toleranz liegen drei Tage zu weit auseinander'
);
Einstellung::set('banking_abgleich_tage', '5', 'banking');
// Ein anderer Betrag passt nie.
$this->assertNull(
Bankumsatz::passendeBuchung(['konto_id' => $this->giro, 'buchungstag' => '2026-08-03', 'betrag' => -99.99, 'id' => 0]),
'Ein anderer Betrag ist eine andere Buchung'
);
Bankumsatz::verbinden((int)$umsatz['id'], $buchungId);
$nachher = Bankumsatz::find((int)$umsatz['id']);
$this->assertEquals('uebernommen', $nachher['status'], 'Der Umsatz gilt als erledigt');
$this->assertEquals($buchungId, (int)$nachher['buchung_id'], 'Und zeigt auf die Buchung');
$this->assertEquals(
1,
(int)Buchung::find($buchungId)['abgeglichen'],
'Die Buchung ist jetzt abgeglichen'
);
// Eine schon verbundene Buchung darf kein zweiter Umsatz greifen.
$zweiter = $this->umsatzAnlegen([
'buchungstag' => '2026-08-04', 'betrag' => -84.20,
'empfaenger' => 'Stadtwerke Musterstadt', 'verwendungszweck' => 'Zweite Lastschrift',
]);
$this->assertNull(
Bankumsatz::passendeBuchung(Bankumsatz::find((int)$zweiter)),
'Eine bereits verbundene Buchung wird nicht noch einmal vorgeschlagen'
);
}
private function uebernehmenErzeugtEineBuchung(): void
{
$offen = Bankumsatz::liste(['status' => 'offen']);
$this->assertNotEmpty($offen, 'Es gibt noch offene Umsätze');
$umsatz = $offen[0];
$vorher = Buchung::anzahl();
$buchungId = Bankumsatz::uebernehmen((int)$umsatz['id'], [
'kategorie_id' => $this->strom,
]);
$this->assertEquals($vorher + 1, Buchung::anzahl(), 'Es gibt eine Buchung mehr');
$buchung = Buchung::find($buchungId);
$this->assertEquals((float)$umsatz['betrag'], (float)$buchung['betrag'], 'Mit dem Betrag des Umsatzes');
$this->assertEquals((string)$umsatz['buchungstag'], (string)$buchung['datum'], 'Und seinem Datum');
$this->assertEquals('fints', $buchung['quelle'], 'Die Quelle ist der Bankabruf');
$this->assertEquals(1, (int)$buchung['abgeglichen'], 'Was von der Bank kommt, gilt als abgeglichen');
$this->assertEquals($this->strom, (int)$buchung['kategorie_id'], 'Die Kategorie kam aus der Zuordnung');
// Ein zweites Übernehmen desselben Umsatzes muss auffallen.
$fehler = false;
try {
Bankumsatz::uebernehmen((int)$umsatz['id'], []);
} catch (\RuntimeException) {
$fehler = true;
}
$this->assert($fehler, 'Zweimal übernehmen geht nicht');
// Umsätze ohne Konto lassen sich nicht buchen – das Konto fehlt.
$ohneKonto = $this->umsatzAnlegen([
'konto_id' => null, 'buchungstag' => '2026-08-05', 'betrag' => -5.00,
'empfaenger' => 'Kiosk', 'verwendungszweck' => 'Ohne Kontozuordnung',
]);
$fehler = false;
try {
Bankumsatz::uebernehmen((int)$ohneKonto, []);
} catch (\RuntimeException) {
$fehler = true;
}
$this->assert($fehler, 'Ohne zugeordnetes Konto verweigert der Abgleich die Buchung');
// Mit Konto in der Zuordnung geht es dann doch.
$mitKonto = Bankumsatz::uebernehmen((int)$ohneKonto, ['konto_id' => $this->sparkonto]);
$this->assertEquals(
$this->sparkonto,
(int)Buchung::find($mitKonto)['konto_id'],
'Das Konto aus der Zuordnung zählt'
);
}
private function verwerfenUndZuruecksetzen(): void
{
$id = $this->umsatzAnlegen([
'buchungstag' => '2026-08-06', 'betrag' => -3.50,
'empfaenger' => 'Doppelbuchung', 'verwendungszweck' => 'Storniert',
]);
Bankumsatz::verwerfen((int)$id);
$this->assertEquals('verworfen', Bankumsatz::find((int)$id)['status'], 'Verworfen ist verworfen');
Bankumsatz::zuruecksetzen((int)$id);
$this->assertEquals('offen', Bankumsatz::find((int)$id)['status'], 'Und lässt sich zurückholen');
$this->assertNull(Bankumsatz::find((int)$id)['buchung_id'], 'Ohne Buchungsverweis');
}
private function abrufprotokollHaeltDenVerlauf(): void
{
$id = Bankabruf::beginnen($this->zugang);
$this->assertEquals('laeuft', Bankabruf::find($id)['status'], 'Ein neuer Abruf läuft');
$this->assertNull(Bankabruf::find($id)['beendet_am'], 'Und hat noch kein Ende');
Bankabruf::abschliessen($id, 'tan_offen', 0, 0, 'Wartet auf Freigabe.', ['12:00:00 Angemeldet.']);
$wartend = Bankabruf::find($id);
$this->assertEquals('tan_offen', $wartend['status'], 'Der Abruf wartet auf eine TAN');
$this->assertNull($wartend['beendet_am'], 'Ein wartender Abruf ist nicht beendet');
Bankabruf::abschliessen($id, 'erfolg', 7, 3, '7 neue Umsätze, 3 bereits bekannt.', ['12:01:00 Fertig.']);
$fertig = Bankabruf::find($id);
$this->assertEquals('erfolg', $fertig['status'], 'Danach ist er erfolgreich');
$this->assertEquals(7, (int)$fertig['neu'], 'Mit der Zahl der neuen Umsätze');
$this->assertNotEmpty($fertig['beendet_am'], 'Und einem Ende');
$this->assert(str_contains((string)$fertig['protokoll'], 'Fertig'), 'Das Protokoll steht dabei');
// Steckengebliebene Abrufe werden aufgeräumt, sonst blockieren sie die Anzeige.
$haenger = Bankabruf::beginnen($this->zugang);
\Finanzmanager\Database::execute(
'UPDATE bank_abrufe SET begonnen_am = DATE_SUB(NOW(), INTERVAL 5 HOUR) WHERE id = ?',
[$haenger]
);
$this->assertGreaterThan(0, Bankabruf::raeumeAlteAuf(2), 'Der Hänger wurde aufgeräumt');
$this->assertEquals(
'abgebrochen',
Bankabruf::find($haenger)['status'],
'Und gilt als abgebrochen'
);
}
}

View file

@ -0,0 +1,315 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\Depot;
use Finanzmanager\Services\QifBuchungsImport;
use Finanzmanager\Services\QifParser;
/**
* Depots: Bestandsrechnung, Einstand nach gleitendem Durchschnitt, realisierte
* Gewinne, Erträge und der QIF-Import von Wertpapiergeschäften.
*/
class WertpapiereTest extends TestCase
{
protected string $testName = 'Test 17: Wertpapiere und Depots';
private int $depot = 0;
private int $verrechnung = 0;
private int $papier = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->wertpapierWirdWiedergefunden();
$this->kaufErhoehtBestandUndEinstand();
$this->geldseiteLandetAufDemVerrechnungskonto();
$this->verkaufRealisiertDenGewinn();
$this->dividendeAendertDenBestandNicht();
$this->ohneKursKeineBewertung();
$this->veraeusserungenRechnenUeberDieHistorie();
$this->qifImportLegtDepotbuchungenAn();
$this->loeschenNimmtDieGeldbuchungMit();
}
private function stammdaten(): void
{
$this->depot = Konto::anlegen([
'name' => 'Testdepot', 'kontoart' => 'depot', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->verrechnung = Konto::anlegen([
'name' => 'Testverrechnungskonto', 'kontoart' => 'bank', 'anfangssaldo' => 10000, 'aktiv' => 1,
]);
$this->papier = Wertpapier::findenOderAnlegen([
'name' => 'Testaktie AG', 'isin' => 'DE0001234567', 'wkn' => '123456', 'art' => 'aktie',
]);
}
private function wertpapierWirdWiedergefunden(): void
{
// Über die ISIN, auch wenn der Name abweicht – Banken schreiben ihn
// unterschiedlich lang.
$nochmal = Wertpapier::findenOderAnlegen([
'name' => 'Testaktie AG Namensaktien o.N.', 'isin' => 'DE0001234567',
]);
$this->assertEquals($this->papier, $nochmal, 'Dieselbe ISIN ist dasselbe Papier');
// Über die WKN, wenn keine ISIN mitkommt.
$ueberWkn = Wertpapier::findenOderAnlegen(['name' => 'Irgendwas', 'wkn' => '123456']);
$this->assertEquals($this->papier, $ueberWkn, 'Auch die WKN findet es wieder');
// Ein wirklich anderes Papier bekommt einen eigenen Datensatz.
$anderes = Wertpapier::findenOderAnlegen(['name' => 'Zweitaktie SE', 'isin' => 'US0009876543']);
$this->assert($anderes !== $this->papier, 'Andere ISIN, anderes Papier');
// Fehlende Angaben werden nachgetragen, vorhandene nicht überschrieben.
$ohneIsin = Wertpapier::findenOderAnlegen(['name' => 'Drittaktie NV']);
Wertpapier::findenOderAnlegen(['name' => 'Drittaktie NV', 'isin' => 'NL0001111111']);
$this->assertEquals(
'NL0001111111',
Wertpapier::find($ohneIsin)['isin'],
'Eine später gelieferte ISIN wird nachgetragen'
);
}
private function kaufErhoehtBestandUndEinstand(): void
{
// 10 Stück zu 100 € plus 10 € Gebühren.
Depotbuchung::anlegen([
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
'datum' => '2026-01-15', 'vorgang' => 'kauf',
'stueck' => 10, 'kurs' => 100, 'betrag' => 1000, 'gebuehren' => 10,
'verrechnungskonto_id' => $this->verrechnung,
]);
$bestand = Depot::bestand($this->depot, '2026-12-31');
$this->assertEquals(1, count($bestand['positionen']), 'Eine Position im Depot');
$position = $bestand['positionen'][0];
$this->assertEquals(10.0, $position['stueck'], 'Zehn Stück im Bestand');
$this->assertEquals(1010.0, $position['einstand'], 'Die Gebühren gehören zum Einstand');
$this->assertEquals(101.0, $position['einstand_je_stueck'], 'Also 101 € je Stück');
// Zweiter Kauf, teurer: Der Durchschnitt wandert nach oben.
Depotbuchung::anlegen([
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
'datum' => '2026-02-15', 'vorgang' => 'kauf',
'stueck' => 10, 'kurs' => 120, 'betrag' => 1200, 'gebuehren' => 10,
'verrechnungskonto_id' => $this->verrechnung,
]);
$position = Depot::bestand($this->depot, '2026-12-31')['positionen'][0];
$this->assertEquals(20.0, $position['stueck'], 'Jetzt zwanzig Stück');
$this->assertEquals(2220.0, $position['einstand'], 'Einstand ist die Summe beider Käufe');
$this->assertEquals(111.0, $position['einstand_je_stueck'], 'Im Schnitt 111 € je Stück');
}
private function geldseiteLandetAufDemVerrechnungskonto(): void
{
// Zwei Käufe: 1010 + 1210 = 2220 vom Verrechnungskonto.
$saldo = Konto::saldo($this->verrechnung, '2026-12-31');
$this->assertEquals(10000.0 - 2220.0, $saldo, 'Beide Käufe haben das Konto belastet');
$buchungen = Depotbuchung::liste(['konto_id' => $this->depot]);
$this->assertNotEmpty($buchungen[0]['buchung_id'], 'Jede Depotbuchung hat ihre Geldbuchung');
$geld = Buchung::find((int)$buchungen[0]['buchung_id']);
$this->assertEquals(
-1210.0,
(float)$geld['betrag'],
'Beim Kauf wird Kurswert plus Gebühren belastet'
);
}
private function verkaufRealisiertDenGewinn(): void
{
// 10 von 20 Stück zu 150 €, 15 € Gebühren, 20 € Steuern.
Depotbuchung::anlegen([
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
'datum' => '2026-03-15', 'vorgang' => 'verkauf',
'stueck' => 10, 'kurs' => 150, 'betrag' => 1500,
'gebuehren' => 15, 'steuern' => 20,
'verrechnungskonto_id' => $this->verrechnung,
]);
$bestand = Depot::bestand($this->depot, '2026-12-31');
$position = $bestand['positionen'][0];
$this->assertEquals(10.0, $position['stueck'], 'Zehn Stück bleiben liegen');
// Die Hälfte des Einstands geht mit: 2220 / 2 = 1110.
$this->assertEquals(1110.0, $position['einstand'], 'Der halbe Einstand ist abgegangen');
$this->assertEquals(111.0, $position['einstand_je_stueck'], 'Der Durchschnitt bleibt gleich');
// Erlös 1500 − 35 Kosten = 1465, abzüglich 1110 Einstand.
$this->assertEquals(355.0, $bestand['realisiert'], 'Realisierter Gewinn nach Kosten');
$saldo = Konto::saldo($this->verrechnung, '2026-12-31');
$this->assertEquals(10000.0 - 2220.0 + 1465.0, $saldo, 'Die Gutschrift kam an');
}
private function dividendeAendertDenBestandNicht(): void
{
Depotbuchung::anlegen([
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
'datum' => '2026-04-01', 'vorgang' => 'dividende',
'betrag' => 50, 'steuern' => 13.19,
'verrechnungskonto_id' => $this->verrechnung,
]);
$bestand = Depot::bestand($this->depot, '2026-12-31');
$this->assertEquals(10.0, $bestand['positionen'][0]['stueck'], 'Der Bestand bleibt unberührt');
$this->assertEquals(36.81, $bestand['ertraege'], 'Der Ertrag ist die Gutschrift nach Steuer');
$ertraege = Depot::ertraege('2026-01-01', '2026-12-31', $this->depot);
$this->assertEquals(36.81, $ertraege['summe'], 'Die Ertragsauswertung zeigt dasselbe');
$this->assertEquals(1, count($ertraege['zeilen']), 'Eine Zeile je Wertpapier');
}
private function ohneKursKeineBewertung(): void
{
$bestand = Depot::bestand($this->depot, '2026-12-31');
$this->assert(!$bestand['bewertbar'], 'Ohne Kurs ist der Bestand nicht bewertbar');
$this->assertNull($bestand['positionen'][0]['wert'], 'Und die Position hat keinen Wert');
$this->assertEquals(0.0, $bestand['wert'], 'Die Summe bleibt bei null statt zu raten');
Wertpapier::kursSetzen($this->papier, '2026-06-30', 130.0, 'test');
$bestand = Depot::bestand($this->depot, '2026-12-31');
$this->assert($bestand['bewertbar'], 'Mit Kurs ist er bewertbar');
$this->assertEquals(1300.0, $bestand['wert'], 'Zehn Stück zu 130 €');
$this->assertEquals(190.0, $bestand['gewinn'], 'Wert minus Einstand von 1110');
// Ein älterer Kurs gilt weiter, bis ein neuerer kommt.
$this->assertEquals(
130.0,
(float)Wertpapier::letzterKurs($this->papier, '2026-08-01')['kurs'],
'Der letzte bekannte Kurs vor dem Stichtag zählt'
);
$this->assertNull(
Wertpapier::letzterKurs($this->papier, '2026-01-01'),
'Vor dem ersten Kurs gibt es keinen'
);
Wertpapier::kursSetzen($this->papier, '2026-06-30', 140.0, 'test');
$this->assertEquals(
140.0,
(float)Wertpapier::letzterKurs($this->papier)['kurs'],
'Ein zweiter Kurs am selben Tag ersetzt den ersten'
);
}
private function veraeusserungenRechnenUeberDieHistorie(): void
{
// Der Verkauf lag im März; der Einstand hängt an den Käufen im Januar
// und Februar, die außerhalb des Berichtszeitraums liegen.
$maerz = Depot::veraeusserungen('2026-03-01', '2026-03-31', $this->depot);
$this->assertEquals(1, count($maerz['geschaefte']), 'Ein Verkauf im März');
$this->assertEquals(1465.0, $maerz['erloes'], 'Erlös nach Kosten');
$this->assertEquals(1110.0, $maerz['geschaefte'][0]['einstand'], 'Einstand aus den Käufen davor');
$this->assertEquals(355.0, $maerz['gewinn'], 'Und derselbe Gewinn wie in der Übersicht');
$februar = Depot::veraeusserungen('2026-02-01', '2026-02-28', $this->depot);
$this->assertEquals([], $februar['geschaefte'], 'Im Februar wurde nichts verkauft');
}
private function qifImportLegtDepotbuchungenAn(): void
{
// Ein Ausschnitt im Format des echten Exports: Kauf, Verkauf, Dividende.
$qif = "!Option:MDY\r\n!Account\r\nNImportdepot\r\nTInvst\r\n^\r\n"
. "!Type:Invst\r\n"
. "D7.3.26\r\nV7.5.26\r\nNKauf\r\nFEUR\r\nMWERTPAPIERE TESTPAPIER\r\n"
. "YTestpapier NV\r\n~A1J4U4\r\n@NL0010273215\r\n&1\r\n"
. "I100.00000000\r\nQ4.000000\r\nU403.90\r\n"
. "O0.00|0.00|0.00|3.90|0.00|0.00|0.00|0.00|0.00|0.00|0.00|0.00\r\n"
. "L|[Importverrechnung]\r\n\$403.90\r\n^\r\n"
. "D8.10.26\r\nV8.14.26\r\nNDivAus\r\nFEUR\r\nMERTRAEGNISGUTSCHRIFT\r\n"
. "YTestpapier NV\r\n~A1J4U4\r\n@NL0010273215\r\n&1\r\n"
. "Q0.000000\r\nU4.32\r\n"
. "O0.00|0.58|0.00|0.00|0.00|0.87|0.00|0.00|0.03|0.00|0.00|0.00\r\n"
. "L|[Importverrechnung]\r\n\$4.32\r\n^\r\n"
. "D9.1.26\r\nV9.3.26\r\nNVerkauf\r\nFEUR\r\nMVERKAUF\r\n"
. "YTestpapier NV\r\n~A1J4U4\r\n@NL0010273215\r\n&1\r\n"
. "I150.00000000\r\nQ2.000000\r\nU287.13\r\n"
. "O0.00|9.37|0.00|3.50|0.00|0.00|0.00|0.00|0.00|0.00|0.00|0.00\r\n"
. "LKursgewinne:Realisierte Gewinne|[Importverrechnung]\r\n\$287.13\r\n^\r\n";
$depotId = Konto::anlegen(['name' => 'Importdepot', 'kontoart' => 'depot', 'aktiv' => 1]);
Konto::anlegen(['name' => 'Importverrechnung', 'kontoart' => 'bank', 'aktiv' => 1]);
$import = new QifBuchungsImport(
new QifParser($qif),
['stichtag' => '2026-01-01', 'bis' => '2026-12-31']
);
$ergebnis = $import->ausfuehren();
$this->assertEquals(3, $ergebnis['buchungen']['wertpapier'], 'Drei Wertpapiergeschäfte übernommen');
$buchungen = Depotbuchung::liste(['konto_id' => $depotId]);
$this->assertEquals(3, count($buchungen), 'Drei Depotbuchungen');
$nachVorgang = array_column($buchungen, null, 'vorgang');
// Kauf: 4 × 100 = 400 Kurswert, 3,90 Gebühren → 403,90 belastet.
$kauf = $nachVorgang['kauf'];
$this->assertEquals(4.0, (float)$kauf['stueck'], 'Vier Stück gekauft');
$this->assertEquals(400.0, (float)$kauf['betrag'], 'Kurswert ohne Nebenkosten');
$this->assertEquals(3.90, (float)$kauf['gebuehren'], 'Provision als Gebühr erkannt');
$this->assertEquals('Kauf', $kauf['vorgang_qif'], 'Die Bezeichnung des FinanzManagers bleibt erhalten');
// Dividende: Gutschrift 4,32 plus 1,48 Abzüge = 5,80 brutto.
$dividende = $nachVorgang['dividende'];
$this->assertEquals(0.0, (float)$dividende['stueck'], 'Eine Dividende bewegt keine Stücke');
$this->assertEquals(5.80, (float)$dividende['betrag'], 'Bruttoertrag zurückgerechnet');
$this->assertEquals(1.48, (float)$dividende['steuern'], 'Die Abzüge sind Steuern');
$this->assertEquals('DivAus', $dividende['vorgang_qif'], 'DivAus bleibt als Herkunft vermerkt');
// Verkauf: 2 × 150 = 300, minus 3,50 Gebühren und 9,37 Steuern = 287,13.
$verkauf = $nachVorgang['verkauf'];
$this->assertEquals(-2.0, (float)$verkauf['stueck'], 'Der Abgang zählt negativ');
$this->assertEquals(300.0, (float)$verkauf['betrag'], 'Kurswert des Verkaufs');
// Die Geldseite: −403,90 + 4,32 + 287,13 = −112,45.
$saldo = Konto::saldo(Konto::findeNachName('Importverrechnung')['id'], '2026-12-31');
$this->assertEquals(-112.45, round($saldo, 2), 'Alle drei Geldflüsse mit richtigem Vorzeichen');
// Ein zweiter Lauf darf nichts verdoppeln.
$zweiter = (new QifBuchungsImport(
new QifParser($qif),
['stichtag' => '2026-01-01', 'bis' => '2026-12-31']
))->ausfuehren();
$this->assertEquals(0, $zweiter['buchungen']['wertpapier'], 'Der zweite Import legt nichts an');
$this->assertEquals(3, count(Depotbuchung::liste(['konto_id' => $depotId])), 'Es bleiben drei');
// Und der Bestand stimmt: 4 gekauft, 2 verkauft.
$bestand = Depot::bestand($depotId, '2026-12-31');
$this->assertEquals(2.0, $bestand['positionen'][0]['stueck'], 'Zwei Stück im Bestand');
$this->assertEquals(201.95, $bestand['positionen'][0]['einstand'], 'Halber Einstand von 403,90');
}
private function loeschenNimmtDieGeldbuchungMit(): void
{
$id = Depotbuchung::anlegen([
'konto_id' => $this->depot, 'wertpapier_id' => $this->papier,
'datum' => '2026-05-02', 'vorgang' => 'kauf',
'stueck' => 1, 'kurs' => 100, 'betrag' => 100,
'verrechnungskonto_id' => $this->verrechnung,
]);
$buchungId = (int)Depotbuchung::find($id)['buchung_id'];
$this->assertNotEmpty($buchungId, 'Die Geldbuchung wurde angelegt');
Depotbuchung::loeschen($id);
$this->assertNull(Depotbuchung::find($id), 'Die Depotbuchung ist weg');
$this->assertNull(Buchung::find($buchungId), 'Und ihre Geldbuchung auch');
}
}

View file

@ -0,0 +1,616 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Adresse;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Buchungsvorlage;
use Finanzmanager\Models\Erinnerung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Kredit;
use Finanzmanager\Models\RegelmaessigeBuchung;
use Finanzmanager\Models\Sparziel;
use Finanzmanager\Models\Vertrag;
use Finanzmanager\Models\Zahlungsauftrag;
use Finanzmanager\Services\Faelligkeitslauf;
use Finanzmanager\Services\Finanzkalender;
use Finanzmanager\Services\QifParser;
use Finanzmanager\Services\QifVorlagenImport;
use Finanzmanager\Services\SepaXml;
/**
* Die Nebenmodule aus Phase 7: Vorlagen, regelmäßige Buchungen mit
* Fälligkeitslauf, Erinnerungen, Adressen mit IBAN-Prüfung, Verträge,
* Sparziele, Kredite mit Tilgungsplan und die Zahlungsaufträge.
*/
class NebenmoduleTest extends TestCase
{
protected string $testName = 'Test 18: Nebenmodule und Zahlungsverkehr';
private const HEUTE = '2026-08-11';
private int $giro = 0;
private int $sparkonto = 0;
private int $miete = 0;
private int $strom = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->vorlageBuchtMitAbweichendemBetrag();
$this->vorlageAusBuchung();
$this->qifVorlagenImport();
$this->regelmaessigeBuchungRuecktVor();
$this->faelligkeitslaufBuchtNurAutomatische();
$this->erinnerungenWerdenFaellig();
$this->ibanPruefungFaengtZahlendreher();
$this->vertragKenntSeineFrist();
$this->sparzielRechnetAusDemKonto();
$this->tilgungsplanTilgtBisNull();
$this->zahlungsauftragBuchtErstNachDerBank();
$this->sepaXmlIstVollstaendig();
$this->kalenderSammeltAlleQuellen();
}
private function stammdaten(): void
{
$this->giro = Konto::anlegen([
'name' => 'Nebengiro', 'kontoart' => 'bank', 'anfangssaldo' => 5000,
'aktiv' => 1, 'iban' => 'DE02120300000000202051',
]);
$this->sparkonto = Konto::anlegen([
'name' => 'Nebensparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 1000, 'aktiv' => 1,
]);
$this->miete = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Miete', 'ausgabe');
$this->strom = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Strom', 'ausgabe');
}
private function vorlageBuchtMitAbweichendemBetrag(): void
{
$id = Buchungsvorlage::anlegen([
'name' => 'Stadtwerke Abschlag',
'konto_id' => $this->giro,
'betrag' => -100.00,
'empfaenger' => 'Stadtwerke',
'splits' => [
['kategorie_id' => $this->strom, 'betrag' => -70.00],
['kategorie_id' => $this->miete, 'betrag' => -30.00],
],
]);
// Zum Vorlagenbetrag: Die Splitzeilen bleiben, wie sie sind.
$buchungId = Buchungsvorlage::buchen($id, ['datum' => '2026-08-01']);
$splits = Buchung::splits($buchungId);
$this->assertEquals(2, count($splits), 'Zwei Splitzeilen übernommen');
$this->assertEquals(-70.0, (float)$splits[0]['betrag'], 'Erste Zeile unverändert');
// Mit abweichendem Betrag: Die Zeilen werden im Verhältnis mitgezogen,
// sonst passte die Splitsumme nicht mehr zum Buchungsbetrag.
$zweite = Buchungsvorlage::buchen($id, ['datum' => '2026-08-02', 'betrag' => -150.00]);
$splits = Buchung::splits($zweite);
$this->assertEquals(-105.0, (float)$splits[0]['betrag'], 'Erste Zeile anteilig erhöht');
$this->assertEquals(-45.0, (float)$splits[1]['betrag'], 'Zweite Zeile ebenso');
$this->assertEquals(
-150.0,
round((float)$splits[0]['betrag'] + (float)$splits[1]['betrag'], 2),
'Und zusammen ergeben sie den Buchungsbetrag'
);
// Krumme Verhältnisse: Die letzte Zeile bekommt den Rest.
$dritte = Buchungsvorlage::buchen($id, ['datum' => '2026-08-03', 'betrag' => -33.33]);
$splits = Buchung::splits($dritte);
$this->assertEquals(
-33.33,
round((float)$splits[0]['betrag'] + (float)$splits[1]['betrag'], 2),
'Auch bei krummen Beträgen stimmt die Summe auf den Cent'
);
$this->assertEquals(3, (int)Buchungsvorlage::find($id)['verwendet'], 'Der Zähler wurde geführt');
}
private function vorlageAusBuchung(): void
{
$buchungId = Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-07-15', 'betrag' => -49.90,
'empfaenger' => 'Fitnessstudio', 'kategorie_id' => $this->miete,
]);
$vorlageId = Buchungsvorlage::ausBuchung($buchungId, 'Fitnessstudio Beitrag');
$vorlage = Buchungsvorlage::find($vorlageId);
$this->assertEquals(-49.90, (float)$vorlage['betrag'], 'Der Betrag kam mit');
$this->assertEquals('Fitnessstudio', $vorlage['empfaenger'], 'Der Empfänger auch');
$this->assertEquals($this->miete, (int)$vorlage['kategorie_id'], 'Und die Kategorie');
}
private function qifVorlagenImport(): void
{
$qif = "!Option:MDY\r\n!Type:Memorized\r\n"
. "KP\r\nU-11.99\r\nT-11.99\r\nP00455 MCDONALDS\r\nMFilialbesuch\r\n"
. "L300 - Wohnen:Strom\r\n^\r\n"
. "KP\r\nU-1,000.00\r\nT-1,000.00\r\nP.Deutsche.Bank.AG\r\nMVISA Debitkartenumsatz\r\n"
. "L[Nebensparbuch]\r\n^\r\n";
$ergebnis = (new QifVorlagenImport(new QifParser($qif)))->ausfuehren();
$this->assertEquals(2, $ergebnis['neu'], 'Zwei Vorlagen übernommen');
$vorlagen = Buchungsvorlage::alle('MCDONALDS');
$this->assertEquals(1, count($vorlagen), 'Die erste ist wiederzufinden');
$this->assertEquals(-11.99, (float)$vorlagen[0]['betrag'], 'Mit ihrem Betrag');
$this->assertEquals($this->strom, (int)$vorlagen[0]['kategorie_id'], 'Und aufgelöster Kategorie');
// Ein Umbuchungsziel wird zum Gegenkonto, nicht zur Kategorie.
$bank = Buchungsvorlage::alle('Deutsche.Bank');
$this->assertEquals($this->sparkonto, (int)$bank[0]['gegenkonto_id'], 'L[Konto] wird zum Gegenkonto');
$this->assertNull($bank[0]['kategorie_id'], 'Und nicht zur Kategorie');
// Ein zweiter Lauf legt nichts doppelt an.
$zweiter = (new QifVorlagenImport(new QifParser($qif)))->ausfuehren();
$this->assertEquals(0, $zweiter['neu'], 'Der zweite Import legt nichts an');
$this->assertEquals(2, $zweiter['vorhanden'], 'Beide gelten als vorhanden');
}
private function regelmaessigeBuchungRuecktVor(): void
{
$this->assertEquals(
'2026-09-15',
RegelmaessigeBuchung::naechsterTermin('2026-08-15', 'monatlich'),
'Monatlich springt einen Monat weiter'
);
$this->assertEquals(
'2026-10-15',
RegelmaessigeBuchung::naechsterTermin('2026-08-15', 'monatlich', 2),
'Der Faktor überspringt Intervalle'
);
$this->assertEquals(
'2027-08-15',
RegelmaessigeBuchung::naechsterTermin('2026-08-15', 'jaehrlich'),
'Jährlich springt ein Jahr'
);
$id = RegelmaessigeBuchung::anlegen([
'name' => 'Testmiete', 'konto_id' => $this->giro, 'betrag' => -850,
'empfaenger' => 'Vermieter', 'kategorie_id' => $this->miete,
'intervall' => 'monatlich', 'beginn' => '2026-06-01', 'aktiv' => 1,
]);
$this->assertEquals(
'2026-06-01',
(string)RegelmaessigeBuchung::find($id)['naechste_faelligkeit'],
'Der erste Termin ist der Beginn'
);
$buchungId = RegelmaessigeBuchung::buchen($id);
$buchung = Buchung::find($buchungId);
$this->assertEquals('2026-06-01', (string)$buchung['datum'], 'Gebucht wird zum Termin');
$this->assertEquals(-850.0, (float)$buchung['betrag'], 'Mit dem Betrag der Reihe');
// Nach dem Buchen steht der nächste Termin in der Zukunft – auch wenn
// mehrere Termine zurücklagen, sonst käme derselbe immer wieder.
$termin = (string)RegelmaessigeBuchung::find($id)['naechste_faelligkeit'];
$this->assert($termin > date('Y-m-d'), 'Der nächste Termin liegt in der Zukunft');
// Überspringen bucht nicht, rückt aber vor.
$vorher = Buchung::anzahl();
RegelmaessigeBuchung::ueberspringen($id);
$this->assertEquals($vorher, Buchung::anzahl(), 'Überspringen bucht nichts');
$this->assert(
(string)RegelmaessigeBuchung::find($id)['naechste_faelligkeit'] > $termin,
'Aber der Termin rückt weiter'
);
// Eine Reihe mit Ende endet auch.
$befristet = RegelmaessigeBuchung::anlegen([
'name' => 'Befristete Rate', 'konto_id' => $this->giro, 'betrag' => -50,
'intervall' => 'monatlich', 'beginn' => '2026-07-01', 'ende' => '2026-07-31', 'aktiv' => 1,
]);
RegelmaessigeBuchung::buchen($befristet);
$this->assertEquals(
0,
(int)RegelmaessigeBuchung::find($befristet)['aktiv'],
'Nach dem letzten Termin schaltet sich die Reihe ab'
);
}
private function faelligkeitslaufBuchtNurAutomatische(): void
{
$automatisch = RegelmaessigeBuchung::anlegen([
'name' => 'Automatikbeitrag', 'konto_id' => $this->giro, 'betrag' => -20,
'intervall' => 'monatlich', 'beginn' => '2026-08-01',
'automatisch' => 1, 'aktiv' => 1,
]);
$manuell = RegelmaessigeBuchung::anlegen([
'name' => 'Stromabschlag', 'konto_id' => $this->giro, 'betrag' => -95,
'intervall' => 'monatlich', 'beginn' => '2026-08-01',
'automatisch' => 0, 'aktiv' => 1,
]);
$vorschau = Faelligkeitslauf::vorschau();
$this->assertGreaterThan(0, $vorschau['faellig'], 'Es ist etwas fällig');
$vorher = Buchung::anzahl();
$ergebnis = Faelligkeitslauf::ausfuehren();
$this->assertGreaterThan(0, $ergebnis['gebucht'], 'Die automatische Reihe wurde gebucht');
$this->assertGreaterThan($vorher - 1, Buchung::anzahl(), 'Es gibt mehr Buchungen');
$namen = array_column($ergebnis['wartend'], 'name');
$this->assert(in_array('Stromabschlag', $namen, true), 'Die manuelle Reihe wartet auf Bestätigung');
// Ihr Termin ist unverändert – sie wurde ja nicht gebucht.
$this->assertEquals(
'2026-08-01',
(string)RegelmaessigeBuchung::find($manuell)['naechste_faelligkeit'],
'Die wartende Reihe rückt nicht vor'
);
$this->assert(
(string)RegelmaessigeBuchung::find($automatisch)['naechste_faelligkeit'] > date('Y-m-d'),
'Die automatische schon'
);
}
private function erinnerungenWerdenFaellig(): void
{
$id = Erinnerung::anlegen('Versicherung kündigen', '2026-08-01');
$this->assertEquals(1, count(Erinnerung::faellige(self::HEUTE)), 'Eine Erinnerung ist fällig');
$this->assertEquals(
0,
count(Erinnerung::faellige('2026-07-01')),
'Vor ihrem Datum noch nicht'
);
Erinnerung::erledigen($id);
$this->assertEquals(0, count(Erinnerung::faellige(self::HEUTE)), 'Erledigte sind nicht mehr fällig');
$this->assertEquals(0, Erinnerung::anzahlOffen(), 'Und zählen nicht mehr als offen');
Erinnerung::erledigen($id, false);
$this->assertEquals(1, Erinnerung::anzahlOffen(), 'Zurückgeholt zählt sie wieder');
}
private function ibanPruefungFaengtZahlendreher(): void
{
$this->assert(Adresse::ibanIstGueltig('DE02120300000000202051'), 'Eine gültige IBAN wird angenommen');
$this->assert(Adresse::ibanIstGueltig('DE02 1203 0000 0000 2020 51'), 'Auch mit Leerzeichen');
$this->assert(!Adresse::ibanIstGueltig('DE02120300000000202052'), 'Eine falsche Prüfziffer fällt auf');
$this->assert(!Adresse::ibanIstGueltig('DE0212030000000020205'), 'Zu kurz ist auch falsch');
$this->assert(!Adresse::ibanIstGueltig('XX02120300000000202051'), 'Unbekanntes Land ohne Prüfziffer');
$this->assert(!Adresse::ibanIstGueltig(''), 'Leer ist nicht gültig');
$this->assertEquals(
'DE02 1203 0000 0000 2020 51',
Adresse::ibanFormatiert('DE02120300000000202051'),
'Formatiert in Vierergruppen'
);
$id = Adresse::anlegen([
'name' => 'Stadtwerke Musterstadt',
'iban' => 'DE02 1203 0000 0000 2020 51',
'ort' => 'Musterstadt',
]);
$this->assertEquals(
'DE02120300000000202051',
Adresse::find($id)['iban'],
'Gespeichert wird ohne Leerzeichen'
);
$fehler = false;
try {
Adresse::anlegen(['name' => 'Falsche IBAN', 'iban' => 'DE02120300000000202052']);
} catch (\InvalidArgumentException) {
$fehler = true;
}
$this->assert($fehler, 'Eine ungültige IBAN wird abgewiesen');
}
private function vertragKenntSeineFrist(): void
{
$id = Vertrag::anlegen([
'name' => 'Hausratversicherung',
'partner' => 'Versicherung AG',
'betrag' => 12.50,
'intervall' => 'monatlich',
'beginn' => '2025-01-01',
'ende' => '2026-09-30',
'kuendigungsfrist_tage' => 90,
'aktiv' => 1,
]);
$vertrag = Vertrag::find($id);
$this->assertEquals(150.0, Vertrag::jahresbetrag($vertrag), 'Monatlich mal zwölf');
$this->assertEquals(
'2026-07-02',
Vertrag::kuendigenBis($vertrag),
'Kündigungstermin ist Ende minus Frist'
);
// Ohne Ende oder ohne Frist gibt es keinen Termin.
$this->assertNull(
Vertrag::kuendigenBis(['ende' => null, 'kuendigungsfrist_tage' => 90]),
'Ohne Vertragsende kein Kündigungstermin'
);
// Vierteljährlich gerechnet ergibt denselben Jahresbetrag bei viermal
// dem Betrag.
$this->assertEquals(
50.0,
Vertrag::jahresbetrag(['betrag' => 12.50, 'intervall' => 'vierteljaehrlich']),
'Vierteljährlich mal vier'
);
// Die Fristenliste greift nur innerhalb des Vorlaufs.
$fristen = Vertrag::fristenLaufenAb(365, '2026-06-01');
$this->assertEquals(1, count($fristen), 'Innerhalb eines Jahres läuft die Frist ab');
$this->assertEquals(
[],
Vertrag::fristenLaufenAb(10, '2026-06-01'),
'In den nächsten zehn Tagen nicht'
);
$this->assertEquals(
[],
Vertrag::fristenLaufenAb(365, '2026-08-01'),
'Und ein verpasster Termin taucht nicht mehr auf'
);
}
private function sparzielRechnetAusDemKonto(): void
{
$id = Sparziel::anlegen([
'name' => 'Neues Auto',
'konto_id' => $this->sparkonto,
'zielbetrag' => 5000,
'startbetrag' => 1000,
'zieldatum' => '2027-08-01',
]);
// Das Sparbuch trägt 1000 Anfangssaldo; der Startbetrag zieht sie ab.
$ziel = Sparziel::mitStand(Sparziel::find($id), self::HEUTE);
$this->assertEquals(0.0, $ziel['stand'], 'Zu Beginn ist nichts gespart');
Buchung::anlegen([
'konto_id' => $this->sparkonto, 'datum' => '2026-07-01', 'betrag' => 1500,
'empfaenger' => 'Sparrate',
]);
$ziel = Sparziel::mitStand(Sparziel::find($id), self::HEUTE);
$this->assertEquals(1500.0, $ziel['stand'], 'Der Stand kommt aus dem Konto');
$this->assertEquals(3500.0, $ziel['fehlt'], 'Es fehlen 3500');
$this->assertEquals(0.3, round($ziel['fortschritt'], 2), 'Das sind 30 Prozent');
$this->assertNotEmpty($ziel['noetige_rate'], 'Und es gibt eine nötige Monatsrate');
// Zwölf Monate bis zum Ziel: 3500 / 12.
$this->assertEquals(291.67, $ziel['noetige_rate'], 'Rate, um den Termin zu halten');
}
private function tilgungsplanTilgtBisNull(): void
{
$id = Kredit::anlegen([
'name' => 'Testdarlehen',
'darlehensbetrag' => 100000,
'zinssatz' => 3.0,
'rate' => 500,
'rhythmus' => 'monatlich',
'beginn' => '2026-01-01',
'zinsbindung_bis' => '2036-01-01',
'aktiv' => 1,
]);
$plan = Kredit::tilgungsplan(Kredit::find($id));
$this->assert($plan['tilgt'], 'Der Kredit tilgt');
$this->assertGreaterThan(0, count($plan['zeilen']), 'Der Plan hat Zeilen');
$erste = $plan['zeilen'][0];
// 100000 × 3 % / 12 = 250 Zinsen, Rest tilgt.
$this->assertEquals(250.0, $erste['zinsen'], 'Erste Rate: 250 Zinsen');
$this->assertEquals(250.0, $erste['tilgung'], 'Und 250 Tilgung');
$this->assertEquals(99750.0, $erste['restschuld'], 'Restschuld sinkt entsprechend');
$letzte = $plan['zeilen'][count($plan['zeilen']) - 1];
$this->assertEquals(0.0, $letzte['restschuld'], 'Am Ende ist die Restschuld null');
$this->assert($letzte['zahlung'] <= 500.0, 'Die letzte Rate ist höchstens eine volle');
// Der Zinsanteil sinkt, der Tilgungsanteil wächst – das Wesen der Annuität.
$this->assert(
$plan['zeilen'][12]['zinsen'] < $erste['zinsen'],
'Nach einem Jahr sind die Zinsen niedriger'
);
$this->assert(
$plan['zeilen'][12]['tilgung'] > $erste['tilgung'],
'Und der Tilgungsanteil höher'
);
$this->assertNotEmpty($plan['restschuld_zinsbindung'], 'Die Restschuld zum Bindungsende steht dabei');
// Restschuld zu einem Stichtag.
$this->assertEquals(
99750.0,
Kredit::restschuld(Kredit::find($id), '2026-01-31'),
'Nach der ersten Rate'
);
$this->assertEquals(
100000.0,
Kredit::restschuld(Kredit::find($id), '2025-12-31'),
'Vor der ersten Rate steht das volle Darlehen'
);
// Eine Rate unter den Zinsen tilgt nie – das muss auffallen, statt in
// eine Endlosschleife zu laufen.
$schlecht = Kredit::anlegen([
'name' => 'Zu kleine Rate', 'darlehensbetrag' => 100000,
'zinssatz' => 5.0, 'rate' => 100, 'beginn' => '2026-01-01',
]);
$planB = Kredit::tilgungsplan(Kredit::find($schlecht));
$this->assert(!$planB['tilgt'], 'Eine zu kleine Rate tilgt nicht');
$this->assertEquals([], $planB['zeilen'], 'Und erzeugt keinen Plan');
// Sondertilgung verkürzt die Laufzeit.
$mitSonder = Kredit::anlegen([
'name' => 'Mit Sondertilgung', 'darlehensbetrag' => 100000,
'zinssatz' => 3.0, 'rate' => 500, 'beginn' => '2026-01-01',
'sondertilgung' => 5000,
]);
$planC = Kredit::tilgungsplan(Kredit::find($mitSonder));
$this->assert(
count($planC['zeilen']) < count($plan['zeilen']),
'Mit Sondertilgung ist der Kredit früher abbezahlt'
);
$this->assert($planC['zinsen'] < $plan['zinsen'], 'Und kostet weniger Zinsen');
}
private function zahlungsauftragBuchtErstNachDerBank(): void
{
$id = Zahlungsauftrag::anlegen([
'konto_id' => $this->giro,
'empfaenger' => 'Stadtwerke Musterstadt',
'iban' => 'DE02120300000000202051',
'betrag' => 84.20,
'verwendungszweck' => 'Abschlag Strom 08/2026',
'kategorie_id' => $this->strom,
]);
$auftrag = Zahlungsauftrag::find($id);
$this->assertEquals('entwurf', $auftrag['status'], 'Ein neuer Auftrag ist ein Entwurf');
$this->assertNull($auftrag['buchung_id'], 'Und hat noch keine Buchung');
$vorher = Buchung::anzahl();
Zahlungsauftrag::alsGesendetVermerken($id, 'Von der Bank angenommen.');
$auftrag = Zahlungsauftrag::find($id);
$this->assertEquals('gesendet', $auftrag['status'], 'Danach gilt er als gesendet');
$this->assertEquals($vorher + 1, Buchung::anzahl(), 'Erst jetzt entsteht die Buchung');
$buchung = Buchung::find((int)$auftrag['buchung_id']);
$this->assertEquals(-84.20, (float)$buchung['betrag'], 'Eine Überweisung geht ab');
$this->assertEquals($this->strom, (int)$buchung['kategorie_id'], 'Die Kategorie kam mit');
// Ein gesendeter Auftrag ist unveränderlich.
$fehler = false;
try {
Zahlungsauftrag::speichern($id, [
'konto_id' => $this->giro, 'empfaenger' => 'Anders',
'iban' => 'DE02120300000000202051', 'betrag' => 1,
]);
} catch (\RuntimeException) {
$fehler = true;
}
$this->assert($fehler, 'Ein gesendeter Auftrag lässt sich nicht ändern');
$fehler = false;
try {
Zahlungsauftrag::loeschen($id);
} catch (\RuntimeException) {
$fehler = true;
}
$this->assert($fehler, 'Und nicht löschen – er bleibt als Beleg');
// Ungültige Eingaben werden schon beim Erfassen abgewiesen.
foreach ([
['iban' => 'DE00000000000000000000', 'grund' => 'ungültige IBAN'],
['betrag' => 0, 'grund' => 'Betrag null'],
['ausfuehrung_am' => '2020-01-01', 'grund' => 'Termin in der Vergangenheit'],
] as $fall) {
$daten = [
'konto_id' => $this->giro,
'empfaenger' => 'Test',
'iban' => 'DE02120300000000202051',
'betrag' => 10.0,
];
unset($fall['grund']);
$daten = array_merge($daten, $fall);
$abgewiesen = false;
try {
Zahlungsauftrag::anlegen($daten);
} catch (\InvalidArgumentException) {
$abgewiesen = true;
}
$this->assert($abgewiesen, 'Abgewiesen: ' . json_encode($fall, JSON_UNESCAPED_UNICODE));
}
}
private function sepaXmlIstVollstaendig(): void
{
$xml = SepaXml::ueberweisung([
'absender_name' => 'Max Mustermann',
'absender_iban' => 'DE02120300000000202051',
'empfaenger_name' => 'Stadtwerke Größenwahn',
'empfaenger_iban' => 'DE02500105170137075030',
'betrag' => 84.20,
'verwendungszweck' => 'Abschlag Strom & Wasser 08/2026',
]);
$this->assert(str_contains($xml, 'pain.001.001.03'), 'Das erwartete Schema');
$this->assert(str_contains($xml, '<InstdAmt Ccy="EUR">84.20</InstdAmt>'), 'Betrag mit Währung');
$this->assert(str_contains($xml, 'DE02500105170137075030'), 'Empfänger-IBAN steht drin');
$this->assert(str_contains($xml, 'NOTPROVIDED'), 'Ohne BIC steht die Ersatzkennung');
// SEPA lässt keine Umlaute zu – sie werden umgeschrieben, nicht entfernt.
$this->assert(str_contains($xml, 'Stadtwerke Groessenwahn'), 'Umlaute werden umgeschrieben');
$this->assert(!str_contains($xml, 'ö'), 'Und stehen nirgends mehr im Dokument');
// Das kaufmännische Und gehört nicht zum SEPA-Zeichenvorrat und fällt
// weg – die Bank wiese den Auftrag sonst zurück, und zwar erst nach dem
// Absenden.
$this->assert(!str_contains($xml, '&amp;'), 'Das kaufmännische Und fällt weg');
$this->assert(
str_contains($xml, 'Abschlag Strom Wasser 08/2026'),
'Der Rest des Verwendungszwecks bleibt erhalten'
);
$this->assertNotEmpty(simplexml_load_string($xml), 'Das Dokument ist wohlgeformtes XML');
$this->assertEquals('Test AG', SepaXml::text('Test AG', 70), 'Erlaubte Zeichen bleiben');
$this->assertEquals('Muellers Laden', SepaXml::text('Müllers Laden', 70), 'ü wird zu ue');
$this->assertEquals('Kurz', SepaXml::text('Kurz und knapp', 4), 'Und gekürzt wird auch');
$fehler = false;
try {
SepaXml::ueberweisung([
'absender_name' => 'A', 'absender_iban' => 'DE02120300000000202051',
'empfaenger_name' => 'B', 'empfaenger_iban' => 'DE02500105170137075030',
'betrag' => 0,
]);
} catch (\InvalidArgumentException) {
$fehler = true;
}
$this->assert($fehler, 'Ein Betrag von null wird abgewiesen');
}
private function kalenderSammeltAlleQuellen(): void
{
$termine = Finanzkalender::termine(90, self::HEUTE);
$arten = array_unique(array_column($termine, 'art'));
$this->assertGreaterThan(1, count($arten), 'Der Kalender führt mehrere Arten zusammen');
// Chronologisch sortiert.
$daten = array_column($termine, 'datum');
$sortiert = $daten;
sort($sortiert);
$this->assertEquals($sortiert, $daten, 'Die Termine stehen chronologisch');
foreach ($termine as $termin) {
$this->assertNotEmpty($termin['titel'], 'Jeder Termin hat einen Titel');
$this->assertNotEmpty($termin['pfad'], 'Und führt irgendwohin');
}
$monate = Finanzkalender::nachMonat(90, self::HEUTE);
$this->assertGreaterThan(0, count($monate), 'Nach Monaten gebündelt');
$gesamt = 0;
foreach ($monate as $monat) {
$this->assertNotEmpty($monat['titel'], 'Jeder Monat hat eine Überschrift');
$gesamt += count($monat['termine']);
}
$this->assertEquals(count($termine), $gesamt, 'Die Bündelung verliert keinen Termin');
}
}

View file

@ -0,0 +1,164 @@
// @ts-check
const { test, expect } = require('@playwright/test')
const BENUTZER = process.env.E2E_USER || 'e2e'
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
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(/\/$/)
}
/**
* Die Module aus den Phasen 4 bis 7: Cockpit-Kacheln, Online-Banking,
* Wertpapiere und die Nebenmodule.
*
* Geprüft wird, dass jeder Bereich erreichbar ist und seine Kernansicht
* aufbaut – nicht die Rechenlogik. Die steckt in der PHP-Suite, wo sie
* schneller und isoliert läuft.
*/
test.describe('Module der Phasen 4 bis 7', () => {
test.beforeEach(async ({ page }) => {
await anmelden(page)
})
test('Jeder Navigationspunkt führt in ein gebautes Modul', async ({ page }) => {
const punkte = [
['/konten', 'Kontenübersicht'],
['/banking', 'Bankverbindungen'],
['/zahlungsverkehr', 'Zahlungsverkehr'],
['/auswertungen', 'Auswertungen'],
['/planung', 'Planung'],
['/termine', 'Regelmäßige Buchungen'],
['/vertraege', 'Verträge'],
['/wertpapiere', 'Wertpapiere'],
['/verwaltung', 'Verwaltung'],
]
for (const [pfad, titel] of punkte) {
await page.goto(pfad)
await expect(page.locator('h1')).toContainText(titel)
// Kein Platzhalter mehr: Alle Bereiche haben ihr Modul.
await expect(page.locator('.phasenmarke')).toHaveCount(0)
}
})
test('Das Cockpit zeichnet seine Kacheln', async ({ page }) => {
await page.goto('/')
// Über die Überschriften, nicht über den Kacheltext: „Kontostände“ kommt
// auch im Hinweis der Vermögenskachel vor.
const ueberschriften = page.locator('.kachel h2')
for (const titel of [
'Vermögensentwicklung', 'Einnahmen und Ausgaben', 'Meine Ausgaben', 'Kontostände',
]) {
await expect(ueberschriften.filter({ hasText: titel })).toHaveCount(1)
}
})
test('Abgewählte Kacheln verschwinden und kommen zurück', async ({ page }) => {
await page.goto('/einstellungen?bereich=darstellung')
const kachel = page.locator('input[name="cockpit_kacheln[]"][value="ausgaben"]')
await kachel.uncheck()
await page.click('button:has-text("Speichern")')
await page.goto('/')
await expect(page.locator('.kachel h2', { hasText: 'Meine Ausgaben' })).toHaveCount(0)
await expect(page.locator('.kachelfuss')).toContainText('abgeschaltet')
await page.goto('/einstellungen?bereich=darstellung')
await page.locator('input[name="cockpit_kacheln[]"][value="ausgaben"]').check()
await page.click('button:has-text("Speichern")')
await page.goto('/')
await expect(page.locator('.kachel h2', { hasText: 'Meine Ausgaben' })).toHaveCount(1)
})
test('Eine Bankverbindung lässt sich anlegen und wieder löschen', async ({ page }) => {
await page.goto('/banking/neu')
await page.fill('#name', 'E2E-Testbank')
await page.fill('#blz', '12030000')
await page.fill('#fints_url', 'https://banking.example.org/fints')
await page.fill('#benutzerkennung', 'e2e-kunde')
await page.fill('#pin', 'geheim')
await page.click('button:has-text("Anlegen")')
await expect(page.locator('.meldung')).toContainText('angelegt')
await expect(page.locator('h1')).toContainText('E2E-Testbank')
// Die PIN wird nie wieder angezeigt.
await expect(page.locator('#pin')).toHaveValue('')
await expect(page.locator('#pin')).toHaveAttribute('placeholder', /unverändert/)
page.once('dialog', dialog => dialog.accept())
await page.click('button:has-text("Bankverbindung löschen")')
await expect(page.locator('.meldung')).toContainText('gelöscht')
})
test('Eine falsche IBAN wird beim Anlegen einer Adresse abgewiesen', async ({ page }) => {
await page.goto('/vertraege/adressen')
await page.fill('#name', 'E2E-Falschtest')
await page.fill('#iban', 'DE02120300000000202052')
await page.click('button:has-text("Adresse anlegen")')
await expect(page.locator('.meldung-fehler')).toContainText('IBAN')
})
test('Ein Kredit zeigt seinen gerechneten Tilgungsplan', async ({ page }) => {
await page.goto('/vertraege/kredite')
await page.fill('#name', 'E2E-Darlehen')
await page.fill('#darlehensbetrag', '100000')
await page.fill('#zinssatz', '3,0')
await page.fill('#rate', '500')
await page.click('button:has-text("Kredit anlegen")')
await expect(page.locator('h3', { hasText: 'Tilgungsplan' })).toContainText('E2E-Darlehen')
await expect(page.locator('.kennzahltitel', { hasText: 'Zinsen gesamt' })).toHaveCount(1)
// Erste Rate: 250 Zinsen, 250 Tilgung. Die erste Tabelle ist die
// Kreditliste, der Plan steht in der zweiten.
const ersteRate = page.locator('table.liste').last().locator('tbody tr').first()
await expect(ersteRate).toContainText('250,00')
page.once('dialog', dialog => dialog.accept())
await page.locator('tr', { hasText: 'E2E-Darlehen' }).locator('button:has-text("Löschen")').first().click()
await expect(page.locator('.meldung')).toContainText('gelöscht')
})
test('Der Finanzkalender ist erreichbar und lässt sich einstellen', async ({ page }) => {
await page.goto('/termine/kalender')
await expect(page.locator('h1')).toContainText('Finanzkalender')
await page.selectOption('#tage', '90')
await page.click('button:has-text("Anzeigen")')
await expect(page).toHaveURL(/tage=90/)
})
test('Der Zahlungsverkehr weist eine falsche IBAN ab', async ({ page }) => {
await page.goto('/zahlungsverkehr/neu')
await page.fill('#empfaenger', 'E2E-Empfänger')
await page.fill('#iban', 'DE00000000000000000000')
await page.fill('#betrag', '10,00')
await page.click('button:has-text("Erfassen")')
// Die Bankverbindung ist bewusst nicht Pflicht: Ein Auftrag darf erfasst
// werden, bevor eine eingerichtet ist. Geprüft wird sie erst beim Senden.
await expect(page.locator('.meldung-fehler')).toContainText('IBAN')
})
})