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:
parent
90bba06585
commit
f388d04c05
95 changed files with 15934 additions and 261 deletions
23
.dockerignore
Normal file
23
.dockerignore
Normal 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
1
.gitignore
vendored
|
|
@ -2,6 +2,7 @@
|
||||||
/data/mariadb/
|
/data/mariadb/
|
||||||
/data/logs/
|
/data/logs/
|
||||||
/data/import/
|
/data/import/
|
||||||
|
/data/schluessel/
|
||||||
/backups/
|
/backups/
|
||||||
/.env
|
/.env
|
||||||
/composer.lock.bak
|
/composer.lock.bak
|
||||||
|
|
|
||||||
134
Changes.md
134
Changes.md
|
|
@ -1,5 +1,139 @@
|
||||||
# Änderungen
|
# Ä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)
|
## Phase 4 – Auswertungen mit Berichtsmodul (11.08.2026)
|
||||||
|
|
||||||
**Datenmodell**
|
**Datenmodell**
|
||||||
|
|
|
||||||
66
PLAN.md
66
PLAN.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Projektplan: Finanzmanager (Web)
|
# 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.
|
> in dieser Datei vornehmen; die Umsetzung richtet sich nach dem hier festgehaltenen Stand.
|
||||||
|
|
||||||
## 1. Kontext
|
## 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.
|
* *Ergebnis:* Jahresbudget zum Jahreswechsel planbar, Monatsabschluss durchführbar.
|
||||||
Einzelheiten in `docs/planung.md`.
|
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",
|
* Berichtsmodul mit gespeicherten Berichten und Berichtsgruppen (wie „Bankgeschäfte",
|
||||||
„Planung", „Steuern"): **Summenbericht, Buchungsbericht, Vergleichsbericht,
|
„Planung", „Steuern"): **Summenbericht, Buchungsbericht, Vergleichsbericht,
|
||||||
Kontostandsbericht, Budgetbericht**, jeweils mit Zeitraum, Spalten-Aufteilung,
|
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
|
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
|
Schlusszeile rechnet mit den echten Vorzeichen. Der Budgetbericht rechnet über volle
|
||||||
Monate — ein halber Monat hat kein halbes Budget.
|
Monate — ein halber Monat hat kein halbes Budget.
|
||||||
* Grafiken als SVG: Einnahmen/Ausgaben je Monat, Vermögensentwicklung *(erledigt: Cockpit-
|
* Grafiken als SVG: Vermögensentwicklung (gestapelte Balken je Konto), Einnahmen/Ausgaben
|
||||||
Kachel mit den letzten zwölf Monaten, gestapelt je Konto — `docs/cockpit.md`)*, Ausgaben
|
je Monat (Balkenpaare mit Saldolinie), Ausgaben nach Kategorie (Ring auf der obersten
|
||||||
nach Kategorie.
|
Kategorieebene). *(erledigt)*
|
||||||
* **Cockpit** mit konfigurierbaren Kacheln (Vermögensentwicklung, Einnahmen/Ausgaben,
|
* **Cockpit** mit konfigurierbaren Kacheln (Vermögensentwicklung, Einnahmen/Ausgaben,
|
||||||
Meine Ausgaben, Kontostände, offene Vorgänge).
|
Meine Ausgaben, Kontostände, offene Vorgänge). Die Auswahl steht im Einstellungsdialog;
|
||||||
* *Ergebnis:* Die bisher genutzten Berichte sind nachgebaut und liefern dieselben Zahlen.
|
abgewählte Kacheln werden nicht berechnet. *(erledigt)*
|
||||||
Offen bleiben die beiden übrigen Grafiken und die Cockpit-Kacheln.
|
* *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)
|
### Phase 5 — Online-Banking (FinTS) *(erledigt)*
|
||||||
* Fork von `nemiah/php-fints` auf Codeberg, Einbindung über VCS-Repository.
|
* 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,
|
* Übernahme aus Steuerkram: `Crypto`, `BankSource` (verschlüsselte Zugangsdaten,
|
||||||
`computeStartDate`, `account_filter`), `BankProvider`, FinTS-Abrufdienst,
|
`computeStartDate`, `account_filter`), `BankProvider`, FinTS-Abrufdienst,
|
||||||
Abruf-Protokoll.
|
Abruf-Protokoll.
|
||||||
|
|
@ -299,9 +303,14 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag
|
||||||
Buchungen (Vorschlagsliste: übernehmen / neu anlegen / verwerfen).
|
Buchungen (Vorschlagsliste: übernehmen / neu anlegen / verwerfen).
|
||||||
* **Regelverwaltung**: Muster auf Empfänger/Verwendungszweck → Kategorie, Klasse oder
|
* **Regelverwaltung**: Muster auf Empfänger/Verwendungszweck → Kategorie, Klasse oder
|
||||||
festes Split-Schema; Regeln greifen beim Abruf und nachträglich auf Bestandsbuchungen.
|
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`.
|
* Migrationen `wertpapiere`, `depot_buchungen`, `kurse`.
|
||||||
* Depotübersicht, Bestände, Einstandswerte, aktueller Wert, Wertentwicklung.
|
* Depotübersicht, Bestände, Einstandswerte, aktueller Wert, Wertentwicklung.
|
||||||
* Depotbuchungen (Kauf, Verkauf, Dividende, Reinvestition) inkl. Verrechnungskonto-Buchung.
|
* 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
|
zentralen Einstellungsdialog, täglicher Lauf per Cron, Ablage in `kurse`. Bei ~20
|
||||||
Positionen bleibt der Bedarf im Freikontingent.
|
Positionen bleibt der Bedarf im Freikontingent.
|
||||||
* Auswertungen: Depotübersicht, Wertpapierentwicklung, Veräußerungsgeschäfte, Erträge.
|
* 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.
|
* **Buchungsvorlagen** (`!Type:Memorized`, 579 Stück im Export) inkl. Import.
|
||||||
* **Regelmäßige Buchungen** mit Fälligkeitslauf und Finanzkalender.
|
* **Regelmäßige Buchungen** mit Fälligkeitslauf und Finanzkalender.
|
||||||
* **Erinnerungen**, **Adressen**, **Verträge**, **Sparziele**, **Kredite** (Tilgungsplan).
|
* **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,
|
aus dem Adressbuch, IBAN/BIC-Prüfung, Verwendungszweck, Kategorie- und Klassenzuordnung,
|
||||||
Termin- und Einzelüberweisung. Absenden über FinTS mit dem in Phase 5 übernommenen
|
Termin- und Einzelüberweisung. Absenden über FinTS mit dem in Phase 5 übernommenen
|
||||||
TAN-Handling; Vorgangsliste „Zahlungsvorgänge / Terminüberweisungen" wie im Original.
|
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
|
## 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,
|
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).
|
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
|
Die **Summe der Kontostände** (503.430,75 € laut Screenshot) ist seit Phase 6
|
||||||
vergleichen, wenn zwei Dinge stehen: die Σ-Kennzeichen je Konto (welche Konten zählen
|
vergleichbar: Depots gehen mit ihrem bewerteten Bestand in die Kontenübersicht ein. Es
|
||||||
überhaupt mit — im Export nicht enthalten) und die Depotbewertung aus Phase 6. Die
|
fehlen nur noch die Kurse — solange für eine Position keiner vorliegt, weist die
|
||||||
Jahressummen Einnahmen/Ausgaben (Cockpit 2026: 148.060,18 € / 58.457,07 €) hängen an
|
Übersicht sie ausdrücklich als unvollständig aus. Offen bleibt außerdem das
|
||||||
denselben Voraussetzungen und werden mit den Auswertungen in Phase 4 abgeglichen.
|
Σ-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,
|
* **Manuell**: Anwendung unter `http://localhost:8000` aufrufen, Kontenübersicht,
|
||||||
Kontoblatt, Budgetplaner und Summenbericht mit den Screenshots aus `Altdaten/` vergleichen.
|
Kontoblatt, Budgetplaner und Summenbericht mit den Screenshots aus `Altdaten/` vergleichen.
|
||||||
|
|
||||||
|
|
|
||||||
51
README.md
51
README.md
|
|
@ -10,18 +10,26 @@ Haushalt wird beides parallel genutzt.
|
||||||
|
|
||||||
## Stand
|
## Stand
|
||||||
|
|
||||||
Phase 0 bis 3 sind gebaut: Anmeldung, zentraler Einstellungsdialog, Debug-Log,
|
**Alle sieben Phasen aus `PLAN.md` sind umgesetzt.**
|
||||||
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.
|
|
||||||
|
|
||||||
Aus Phase 4 steht das Berichtsmodul: Summen-, Buchungs-, Vergleichs-, Kontostands- und
|
* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt,
|
||||||
Budgetbericht mit Zeitraumvorgaben, Spalten-Aufteilung, Zeilen-Summe nach
|
Buchungsmaske mit Splitbuchungen und Umbuchungen; QIF-Import mit Stichtag.
|
||||||
Kategorie/Klasse/Konto/Empfänger, Drilldown in die Buchungen, CSV-Export sowie
|
* **Planung** – Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf,
|
||||||
gespeicherten Berichten in Berichtsgruppen. Dazu die Cockpit-Kachel Vermögensentwicklung
|
Snapshot und Sperre.
|
||||||
als gestapeltes Balkendiagramm der letzten zwölf Monate. Als Nächstes folgen nach
|
* **Auswertungen** – Summen-, Buchungs-, Vergleichs-, Kontostands- und Budgetbericht mit
|
||||||
`PLAN.md` die übrigen Grafiken und Cockpit-Kacheln.
|
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
|
## Technik
|
||||||
|
|
||||||
|
|
@ -32,18 +40,24 @@ als gestapeltes Balkendiagramm der letzten zwölf Monate. Als Nächstes folgen n
|
||||||
| Webserver | nginx + php-fpm |
|
| Webserver | nginx + php-fpm |
|
||||||
| Betrieb | Docker Compose (drei Services) |
|
| Betrieb | Docker Compose (drei Services) |
|
||||||
| Frontend | serverseitiges HTML, Vanilla JS, CSS Custom Properties |
|
| 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
|
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
|
## Start
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
Danach http://localhost:8000 aufrufen. Beim ersten Aufruf führt die Anwendung zur
|
||||||
Ersteinrichtung und legt den ersten Benutzer an.
|
Ersteinrichtung und legt den ersten Benutzer an.
|
||||||
|
|
||||||
|
|
@ -59,9 +73,9 @@ Ersteinrichtung und legt den ersten Benutzer an.
|
||||||
src/ Anwendungscode (Controllers, Models, Services, Views)
|
src/ Anwendungscode (Controllers, Models, Services, Views)
|
||||||
public/ Front-Controller und statische Dateien
|
public/ Front-Controller und statische Dateien
|
||||||
migrations/ nummerierte SQL-Migrationen, laufen beim Containerstart
|
migrations/ nummerierte SQL-Migrationen, laufen beim Containerstart
|
||||||
scripts/ Migration, Update, Backup, Tests
|
scripts/ Migration, Update, Backup, Tests, Kursabruf
|
||||||
tests/ PHP-Testsuite; tests/e2e/ für Playwright
|
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)
|
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/buchungen.md` – Kontoblatt, Umbuchungen, Splitbuchungen, Salden
|
||||||
- `docs/planung.md` – Budgetplaner und Monatsabschluss
|
- `docs/planung.md` – Budgetplaner und Monatsabschluss
|
||||||
- `docs/auswertungen.md` – Berichtsmodul, Filterleiste, Drilldown, CSV
|
- `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
|
- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,15 @@
|
||||||
"description": "Persönlicher Finanzmanager – Konten, Kategorien, Budget, Monatsabschluss",
|
"description": "Persönlicher Finanzmanager – Konten, Kategorien, Budget, Monatsabschluss",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"type": "vcs",
|
||||||
|
"url": "https://codeberg.org/fds2610-bot/phpFinTS.git"
|
||||||
|
}
|
||||||
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.3"
|
"php": ">=8.3",
|
||||||
|
"nemiah/php-fints": "4.1.1"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|
|
||||||
87
composer.lock
generated
Normal file
87
composer.lock
generated
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,10 @@ services:
|
||||||
# bewusst NICHT gemountet – das gehört allein dem db-Container.
|
# bewusst NICHT gemountet – das gehört allein dem db-Container.
|
||||||
- ./data/logs:/var/www/html/data/logs
|
- ./data/logs:/var/www/html/data/logs
|
||||||
- ./data/import:/var/www/html/data/import
|
- ./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:
|
environment:
|
||||||
TZ: Europe/Berlin
|
TZ: Europe/Berlin
|
||||||
DB_HOST: db
|
DB_HOST: db
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,27 @@ set -euo pipefail
|
||||||
|
|
||||||
# Datenverzeichnisse existieren auch dann, wenn sie per Bind-Mount frisch
|
# Datenverzeichnisse existieren auch dann, wenn sie per Bind-Mount frisch
|
||||||
# hereinkommen – und gehören www-data, sonst scheitert das erste Schreiben.
|
# 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
|
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
|
# Migrationen laufen bei jedem Start. Sie sind idempotent, ein zweiter Lauf
|
||||||
# ändert nichts.
|
# ändert nichts.
|
||||||
if [ -f /var/www/html/scripts/migrate.php ]; then
|
if [ -f /var/www/html/scripts/migrate.php ]; then
|
||||||
|
|
|
||||||
145
docs/banking.md
Normal file
145
docs/banking.md
Normal 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.
|
||||||
|
|
@ -1,7 +1,21 @@
|
||||||
# Cockpit
|
# Cockpit
|
||||||
|
|
||||||
Die Startseite. Jede Kachel beantwortet eine Frage auf einen Blick; ausgebaut wird sie
|
Die Startseite. Jede Kachel beantwortet eine Frage auf einen Blick.
|
||||||
Stück für Stück in Phase 4.
|
|
||||||
|
## 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
|
## 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
|
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.
|
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
|
||||||
|
|
||||||
|
* `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,
|
* `tests/13_VermoegensentwicklungTest.php` – Zeitraum, Fortschreibung der Stände,
|
||||||
Umbuchungen, Σ-Kennzeichen, Schalter „nur aktive“, Zusammenfassen jenseits der Palette,
|
Umbuchungen, Σ-Kennzeichen, Schalter „nur aktive“, Zusammenfassen jenseits der Palette,
|
||||||
Ebenen (jedes Konto genau einmal, letzte Ebene ohne Sammelstück, zu tiefe Ebene fällt
|
Ebenen (jedes Konto genau einmal, letzte Ebene ohne Sammelstück, zu tiefe Ebene fällt
|
||||||
|
|
|
||||||
145
docs/nebenmodule.md
Normal file
145
docs/nebenmodule.md
Normal 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
129
docs/wertpapiere.md
Normal 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
158
migrations/006_banking.sql
Normal 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;
|
||||||
100
migrations/007_wertpapiere.sql
Normal file
100
migrations/007_wertpapiere.sql
Normal 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;
|
||||||
248
migrations/008_nebenmodule.sql
Normal file
248
migrations/008_nebenmodule.sql
Normal 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;
|
||||||
132
public/index.php
132
public/index.php
|
|
@ -11,6 +11,7 @@ require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
use Finanzmanager\Auth;
|
use Finanzmanager\Auth;
|
||||||
use Finanzmanager\Config;
|
use Finanzmanager\Config;
|
||||||
use Finanzmanager\Controllers\AuswertungenController;
|
use Finanzmanager\Controllers\AuswertungenController;
|
||||||
|
use Finanzmanager\Controllers\BankingController;
|
||||||
use Finanzmanager\Controllers\BuchungenController;
|
use Finanzmanager\Controllers\BuchungenController;
|
||||||
use Finanzmanager\Controllers\BudgetController;
|
use Finanzmanager\Controllers\BudgetController;
|
||||||
use Finanzmanager\Controllers\CockpitController;
|
use Finanzmanager\Controllers\CockpitController;
|
||||||
|
|
@ -21,8 +22,12 @@ use Finanzmanager\Controllers\KontenController;
|
||||||
use Finanzmanager\Controllers\LoginController;
|
use Finanzmanager\Controllers\LoginController;
|
||||||
use Finanzmanager\Controllers\MonatsabschlussController;
|
use Finanzmanager\Controllers\MonatsabschlussController;
|
||||||
use Finanzmanager\Controllers\PlanungController;
|
use Finanzmanager\Controllers\PlanungController;
|
||||||
use Finanzmanager\Controllers\PlatzhalterController;
|
use Finanzmanager\Controllers\TermineController;
|
||||||
|
use Finanzmanager\Controllers\VertraegeController;
|
||||||
use Finanzmanager\Controllers\VerwaltungController;
|
use Finanzmanager\Controllers\VerwaltungController;
|
||||||
|
use Finanzmanager\Controllers\VorlagenController;
|
||||||
|
use Finanzmanager\Controllers\ZahlungsverkehrController;
|
||||||
|
use Finanzmanager\Controllers\WertpapiereController;
|
||||||
use Finanzmanager\Models\Einstellung;
|
use Finanzmanager\Models\Einstellung;
|
||||||
use Finanzmanager\Router;
|
use Finanzmanager\Router;
|
||||||
use Finanzmanager\Services\DebugLog;
|
use Finanzmanager\Services\DebugLog;
|
||||||
|
|
@ -155,16 +160,131 @@ $router->post('/auswertungen/gruppen/{id}',
|
||||||
$router->post('/auswertungen/gruppen/{id}/loeschen',
|
$router->post('/auswertungen/gruppen/{id}/loeschen',
|
||||||
fn(array $p) => (new AuswertungenController())->gruppeLoeschen($p));
|
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->get('/verwaltung/import', fn() => (new ImportController())->index());
|
||||||
$router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen());
|
$router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen());
|
||||||
$router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren());
|
$router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren());
|
||||||
$router->post('/verwaltung/import/{datei}/loeschen', fn(array $p) => (new ImportController())->loeschen($p));
|
$router->post('/verwaltung/import/{datei}/loeschen', fn(array $p) => (new ImportController())->loeschen($p));
|
||||||
|
|
||||||
// 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 {
|
try {
|
||||||
$router->dispatch();
|
$router->dispatch();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,10 @@ form.inline { display: inline; margin: 0; }
|
||||||
|
|
||||||
.tab:hover { background: var(--flaeche-gedaempft); text-decoration: none; }
|
.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 {
|
.tab.aktiv {
|
||||||
background: var(--flaeche);
|
background: var(--flaeche);
|
||||||
border-color: var(--rand);
|
border-color: var(--rand);
|
||||||
|
|
@ -466,6 +470,113 @@ form.inline { display: inline; margin: 0; }
|
||||||
color: var(--text-gedaempft);
|
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 ────────────────────────────────────────────────────────
|
/* ── Ein/Aus-Schalter ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Die Bahn mit dem laufenden Knopf, wie man ihn von Mobilgeräten kennt. Ein
|
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;
|
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); }
|
.optional { font-weight: 400; color: var(--text-gedaempft); }
|
||||||
|
|
||||||
.knopfleiste {
|
.knopfleiste {
|
||||||
|
|
@ -1241,6 +1360,252 @@ table.bericht .wertlink:hover {
|
||||||
|
|
||||||
.zeilenform input.schmal { width: 5rem; }
|
.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 ─────────────────────────────────────────────────────── */
|
/* ── Schmale Fenster ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// Meldungen nach kurzer Zeit ausblenden – Fehler bleiben stehen.
|
||||||
// Über eine Klasse, nicht über element.style: Die Content-Security-Policy
|
// Über eine Klasse, nicht über element.style: Die Content-Security-Policy
|
||||||
// (style-src 'self') verwirft auch per JavaScript gesetzte Inline-Styles,
|
// (style-src 'self') verwirft auch per JavaScript gesetzte Inline-Styles,
|
||||||
|
|
|
||||||
68
scripts/kursabruf.php
Normal file
68
scripts/kursabruf.php
Normal 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);
|
||||||
|
|
@ -17,7 +17,10 @@ class Config
|
||||||
'db_name' => self::env('DB_NAME', 'finanzmanager'),
|
'db_name' => self::env('DB_NAME', 'finanzmanager'),
|
||||||
'db_user' => self::env('DB_USER', 'finanzmanager'),
|
'db_user' => self::env('DB_USER', 'finanzmanager'),
|
||||||
'db_pass' => self::env('DB_PASS', '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',
|
'debug_log' => self::env('DEBUG_LOG', '0') === '1',
|
||||||
'log_dir' => $wurzel . '/data/logs',
|
'log_dir' => $wurzel . '/data/logs',
|
||||||
'import_dir' => $wurzel . '/data/import',
|
'import_dir' => $wurzel . '/data/import',
|
||||||
|
|
@ -41,4 +44,14 @@ class Config
|
||||||
$wert = $_ENV[$key] ?? getenv($key);
|
$wert = $_ENV[$key] ?? getenv($key);
|
||||||
return ($wert === false || $wert === '') ? $default : (string)$wert;
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
488
src/Controllers/BankingController.php
Normal file
488
src/Controllers/BankingController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ namespace Finanzmanager\Controllers;
|
||||||
|
|
||||||
use Finanzmanager\Models\Einstellung;
|
use Finanzmanager\Models\Einstellung;
|
||||||
use Finanzmanager\Models\Konto;
|
use Finanzmanager\Models\Konto;
|
||||||
|
use Finanzmanager\Services\Cockpit;
|
||||||
use Finanzmanager\Services\Vermoegensentwicklung;
|
use Finanzmanager\Services\Vermoegensentwicklung;
|
||||||
use Finanzmanager\View;
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
|
@ -24,23 +25,43 @@ class CockpitController extends BaseController
|
||||||
$this->verlangeAnmeldung();
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
$nurAktive = Einstellung::getBool(self::SCHALTER_NUR_AKTIVE, true);
|
$nurAktive = Einstellung::getBool(self::SCHALTER_NUR_AKTIVE, true);
|
||||||
|
$sichtbar = Cockpit::sichtbar();
|
||||||
|
|
||||||
// Die Ebene steht in der Adresse, nicht in den Einstellungen: Wer in die
|
// Die Ebene steht in der Adresse, nicht in den Einstellungen: Wer in die
|
||||||
// „Übrigen Konten“ hineinsieht, will beim nächsten Aufruf wieder oben
|
// „Übrigen Konten“ hineinsieht, will beim nächsten Aufruf wieder oben
|
||||||
// anfangen – und kann den Blick trotzdem als Lesezeichen ablegen.
|
// anfangen – und kann den Blick trotzdem als Lesezeichen ablegen.
|
||||||
$ebene = max(0, (int)$this->query('ebene'));
|
$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',
|
'titel' => 'Cockpit',
|
||||||
'uebersicht' => Konto::uebersicht(),
|
'sichtbar' => $sichtbar,
|
||||||
'vermoegen' => Vermoegensentwicklung::reihe(
|
'nurAktive' => $nurAktive,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (in_array('vermoegen', $sichtbar, true)) {
|
||||||
|
$daten['vermoegen'] = Vermoegensentwicklung::reihe(
|
||||||
$nurAktive,
|
$nurAktive,
|
||||||
Vermoegensentwicklung::MONATE,
|
Vermoegensentwicklung::MONATE,
|
||||||
null,
|
null,
|
||||||
$ebene
|
$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
|
public function kontenfilter(): void
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Finanzmanager\Controllers;
|
namespace Finanzmanager\Controllers;
|
||||||
|
|
||||||
use Finanzmanager\Models\Einstellung;
|
use Finanzmanager\Models\Einstellung;
|
||||||
|
use Finanzmanager\Services\Cockpit;
|
||||||
use Finanzmanager\Services\DebugLog;
|
use Finanzmanager\Services\DebugLog;
|
||||||
use Finanzmanager\View;
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
|
@ -29,6 +30,7 @@ class EinstellungenController extends BaseController
|
||||||
'bereich' => $bereich,
|
'bereich' => $bereich,
|
||||||
'werte' => Einstellung::alle(),
|
'werte' => Einstellung::alle(),
|
||||||
'logDateien' => $this->logDateien(),
|
'logDateien' => $this->logDateien(),
|
||||||
|
'kacheln' => Cockpit::sichtbar(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,6 +53,16 @@ class EinstellungenController extends BaseController
|
||||||
Einstellung::set($schluessel, $wert, $bereich);
|
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') {
|
if ($bereich === 'diagnose') {
|
||||||
DebugLog::setAktiv(Einstellung::getBool('debug_log_aktiv'));
|
DebugLog::setAktiv(Einstellung::getBool('debug_log_aktiv'));
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +97,17 @@ class EinstellungenController extends BaseController
|
||||||
'allgemein' => ['mandant_name' => 'text', 'waehrung' => 'text'],
|
'allgemein' => ['mandant_name' => 'text', 'waehrung' => 'text'],
|
||||||
'darstellung' => ['darstellung_thema' => 'text', 'zeilen_pro_seite' => 'text'],
|
'darstellung' => ['darstellung_thema' => 'text', 'zeilen_pro_seite' => 'text'],
|
||||||
'buchhaltung' => ['erster_monat' => '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'],
|
'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'],
|
'diagnose' => ['debug_log_aktiv' => 'bool'],
|
||||||
default => [],
|
default => [],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use Finanzmanager\Models\Einstellung;
|
||||||
use Finanzmanager\Services\QifBuchungsImport;
|
use Finanzmanager\Services\QifBuchungsImport;
|
||||||
use Finanzmanager\Services\QifParser;
|
use Finanzmanager\Services\QifParser;
|
||||||
use Finanzmanager\Services\QifStammdatenImport;
|
use Finanzmanager\Services\QifStammdatenImport;
|
||||||
|
use Finanzmanager\Services\QifVorlagenImport;
|
||||||
use Finanzmanager\View;
|
use Finanzmanager\View;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -31,6 +32,7 @@ class ImportController extends BaseController
|
||||||
'verzeichnis' => $this->verzeichnisAnzeige(),
|
'verzeichnis' => $this->verzeichnisAnzeige(),
|
||||||
'ergebnis' => null,
|
'ergebnis' => null,
|
||||||
'buchungen' => null,
|
'buchungen' => null,
|
||||||
|
'vorlagen' => null,
|
||||||
'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'),
|
'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'),
|
||||||
'bis' => date('Y-m-d'),
|
'bis' => date('Y-m-d'),
|
||||||
'datei' => '',
|
'datei' => '',
|
||||||
|
|
@ -91,9 +93,15 @@ class ImportController extends BaseController
|
||||||
// nicht angelegt, deshalb bleibt es dort bei der Stammdatenvorschau,
|
// nicht angelegt, deshalb bleibt es dort bei der Stammdatenvorschau,
|
||||||
// sobald die Konten fehlen.
|
// sobald die Konten fehlen.
|
||||||
$buchungen = null;
|
$buchungen = null;
|
||||||
|
$vorlagen = null;
|
||||||
if (!$this->post('nur_stammdaten')) {
|
if (!$this->post('nur_stammdaten')) {
|
||||||
$buchungen = (new QifBuchungsImport($parser, ['stichtag' => $stichtag, 'bis' => $bis]))
|
$buchungen = (new QifBuchungsImport($parser, ['stichtag' => $stichtag, 'bis' => $bis]))
|
||||||
->ausfuehren($trockenlauf);
|
->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) {
|
} catch (\Throwable $e) {
|
||||||
View::redirect('/verwaltung/import', 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler');
|
View::redirect('/verwaltung/import', 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler');
|
||||||
|
|
@ -106,6 +114,7 @@ class ImportController extends BaseController
|
||||||
'verzeichnis' => $this->verzeichnisAnzeige(),
|
'verzeichnis' => $this->verzeichnisAnzeige(),
|
||||||
'ergebnis' => $ergebnis + ['abschnitte' => $parser->zusammenfassung()],
|
'ergebnis' => $ergebnis + ['abschnitte' => $parser->zusammenfassung()],
|
||||||
'buchungen' => $buchungen,
|
'buchungen' => $buchungen,
|
||||||
|
'vorlagen' => $vorlagen,
|
||||||
'stichtag' => $stichtag,
|
'stichtag' => $stichtag,
|
||||||
'bis' => $bis,
|
'bis' => $bis,
|
||||||
'datei' => $name,
|
'datei' => $name,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
208
src/Controllers/TermineController.php
Normal file
208
src/Controllers/TermineController.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
343
src/Controllers/VertraegeController.php
Normal file
343
src/Controllers/VertraegeController.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/Controllers/VorlagenController.php
Normal file
125
src/Controllers/VorlagenController.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
250
src/Controllers/WertpapiereController.php
Normal file
250
src/Controllers/WertpapiereController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
222
src/Controllers/ZahlungsverkehrController.php
Normal file
222
src/Controllers/ZahlungsverkehrController.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
344
src/Models/AbgleichRegel.php
Normal file
344
src/Models/AbgleichRegel.php
Normal 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
173
src/Models/Adresse.php
Normal 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
104
src/Models/Bankabruf.php
Normal 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
317
src/Models/Bankumsatz.php
Normal 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
307
src/Models/Bankzugang.php
Normal 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()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
274
src/Models/Buchungsvorlage.php
Normal file
274
src/Models/Buchungsvorlage.php
Normal 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
328
src/Models/Depotbuchung.php
Normal 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
89
src/Models/Erinnerung.php
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -182,13 +182,14 @@ class Konto
|
||||||
{
|
{
|
||||||
$aktuell = Buchung::saldenJeKonto(date('Y-m-d'));
|
$aktuell = Buchung::saldenJeKonto(date('Y-m-d'));
|
||||||
$erwartet = Buchung::saldenJeKonto();
|
$erwartet = Buchung::saldenJeKonto();
|
||||||
|
$depots = \Finanzmanager\Services\Depot::werteJeDepot();
|
||||||
|
|
||||||
$gruppen = [];
|
$gruppen = [];
|
||||||
$summe = 0.0;
|
$summe = 0.0;
|
||||||
$summeOnline = 0.0;
|
$summeOnline = 0.0;
|
||||||
|
|
||||||
foreach (self::alle($nurAktive) as $konto) {
|
foreach (self::alle($nurAktive) as $konto) {
|
||||||
$konto = self::mitSalden($konto, $aktuell, $erwartet);
|
$konto = self::mitSalden($konto, $aktuell, $erwartet, $depots);
|
||||||
$name = $konto['gruppenname'] ?? null;
|
$name = $konto['gruppenname'] ?? null;
|
||||||
$name = ($name === null || $name === '') ? Kontengruppe::OHNE_GRUPPE : $name;
|
$name = ($name === null || $name === '') ? Kontengruppe::OHNE_GRUPPE : $name;
|
||||||
|
|
||||||
|
|
@ -222,16 +223,36 @@ class Konto
|
||||||
* @param array<int,float>|null $erwartet
|
* @param array<int,float>|null $erwartet
|
||||||
* @return array<string,mixed>
|
* @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'];
|
$id = (int)$konto['id'];
|
||||||
$anfang = (float)$konto['anfangssaldo'];
|
$anfang = (float)$konto['anfangssaldo'];
|
||||||
|
|
||||||
$aktuell ??= Buchung::saldenJeKonto(date('Y-m-d'));
|
$aktuell ??= Buchung::saldenJeKonto(date('Y-m-d'));
|
||||||
$erwartet ??= Buchung::saldenJeKonto();
|
$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_aktuell'] = $anfang + ($aktuell[$id] ?? 0.0);
|
||||||
$konto['saldo_erwartet'] = $anfang + ($erwartet[$id] ?? 0.0);
|
$konto['saldo_erwartet'] = $anfang + ($erwartet[$id] ?? 0.0);
|
||||||
|
$konto['bewertet'] = false;
|
||||||
|
|
||||||
return $konto;
|
return $konto;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
279
src/Models/Kredit.php
Normal file
279
src/Models/Kredit.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
336
src/Models/RegelmaessigeBuchung.php
Normal file
336
src/Models/RegelmaessigeBuchung.php
Normal 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
158
src/Models/Sparziel.php
Normal 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
187
src/Models/Vertrag.php
Normal 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
297
src/Models/Wertpapier.php
Normal 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()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
230
src/Models/Zahlungsauftrag.php
Normal file
230
src/Models/Zahlungsauftrag.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ────────────────────────────────────────────────────────
|
// ── Summenbericht ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
284
src/Services/Cockpit.php
Normal file
284
src/Services/Cockpit.php
Normal 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
123
src/Services/Crypto.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/Services/DebugLogAdapter.php
Normal file
40
src/Services/DebugLogAdapter.php
Normal 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
326
src/Services/Depot.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/Services/Faelligkeitslauf.php
Normal file
114
src/Services/Faelligkeitslauf.php
Normal 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
739
src/Services/FinTsAbruf.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
400
src/Services/FinTsUeberweisung.php
Normal file
400
src/Services/FinTsUeberweisung.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/Services/Finanzkalender.php
Normal file
137
src/Services/Finanzkalender.php
Normal 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
307
src/Services/Kursabruf.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,9 +6,11 @@ namespace Finanzmanager\Services;
|
||||||
|
|
||||||
use Finanzmanager\Database;
|
use Finanzmanager\Database;
|
||||||
use Finanzmanager\Models\Buchung;
|
use Finanzmanager\Models\Buchung;
|
||||||
|
use Finanzmanager\Models\Depotbuchung;
|
||||||
use Finanzmanager\Models\Kategorie;
|
use Finanzmanager\Models\Kategorie;
|
||||||
use Finanzmanager\Models\Klasse;
|
use Finanzmanager\Models\Klasse;
|
||||||
use Finanzmanager\Models\Konto;
|
use Finanzmanager\Models\Konto;
|
||||||
|
use Finanzmanager\Models\Wertpapier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Übernimmt die Buchungen eines QIF-Exports.
|
* Übernimmt die Buchungen eines QIF-Exports.
|
||||||
|
|
@ -123,11 +125,11 @@ class QifBuchungsImport
|
||||||
|
|
||||||
if ($eintrag['typ'] === 'Invst') {
|
if ($eintrag['typ'] === 'Invst') {
|
||||||
// Vor dem Stichtag zählt vom Wertpapiergeschäft nur der
|
// Vor dem Stichtag zählt vom Wertpapiergeschäft nur der
|
||||||
// Geldfluss auf dem Verrechnungskonto.
|
// Geldfluss auf dem Verrechnungskonto. Die Bestände davor
|
||||||
$ziel = self::umbuchungsziel(QifParser::feld($eintrag, 'L'));
|
// stecken im Depot, nicht im Anfangssaldo.
|
||||||
$wert = QifParser::feld($eintrag, '$');
|
$ziel = self::verrechnungskonto(QifParser::feld($eintrag, 'L'));
|
||||||
if ($ziel !== null && $wert !== null) {
|
if ($ziel !== null) {
|
||||||
$bucheAuf($ziel, -QifParser::betrag($wert));
|
$bucheAuf($ziel, self::geldflussInvst($eintrag));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -215,8 +217,9 @@ class QifBuchungsImport
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($zaehler['wertpapier'] > 0) {
|
if ($zaehler['wertpapier'] > 0) {
|
||||||
$this->melde('hinweis', $zaehler['wertpapier'] . ' Wertpapiergeschäfte: übernommen wurde nur der '
|
$this->melde('hinweis', $zaehler['wertpapier'] . ' Wertpapiergeschäfte übernommen – '
|
||||||
. 'Geldfluss auf dem Verrechnungskonto. Bestände und Kurse folgen in Phase 6.');
|
. 'je Geschäft eine Depotbuchung mit Stück und Kurs sowie die Geldbuchung auf dem '
|
||||||
|
. 'Verrechnungskonto. Kurse zur Bewertung holt der Kursabruf.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $zaehler;
|
return $zaehler;
|
||||||
|
|
@ -275,26 +278,58 @@ class QifBuchungsImport
|
||||||
* bliebe ein Betrag auf dem Depot ohne Gegenwert und würde die Summe der
|
* bliebe ein Betrag auf dem Depot ohne Gegenwert und würde die Summe der
|
||||||
* Kontostände verfälschen.
|
* 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
|
private function wertpapierbuchung(array $eintrag, string $datum, bool $trockenlauf): ?int
|
||||||
{
|
{
|
||||||
$ziel = self::umbuchungsziel(QifParser::feld($eintrag, 'L'));
|
$depotName = (string)$eintrag['konto'];
|
||||||
$wert = QifParser::feld($eintrag, '$');
|
$depotId = $this->kontoId($depotName);
|
||||||
if ($ziel === null || $wert === null) {
|
if ($depotId === null) {
|
||||||
return null;
|
throw new \RuntimeException('Unbekanntes Depot „' . $depotName . '“.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$kontoId = $this->kontoId($ziel);
|
$vorgangQif = trim((string)QifParser::feld($eintrag, 'N'));
|
||||||
if ($kontoId === null) {
|
$vorgang = Depotbuchung::vorgangAusQif($vorgangQif);
|
||||||
throw new \RuntimeException('Unbekanntes Verrechnungskonto „' . $ziel . '“.');
|
$name = trim((string)QifParser::feld($eintrag, 'Y'));
|
||||||
|
|
||||||
|
if ($name === '') {
|
||||||
|
throw new \RuntimeException('Wertpapiergeschäft ohne Wertpapiernamen.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$betrag = -QifParser::betrag($wert);
|
$isin = trim((string)QifParser::feld($eintrag, '@'));
|
||||||
$text = (string)QifParser::feld($eintrag, 'M');
|
$stueck = QifParser::betrag(QifParser::feld($eintrag, 'Q'));
|
||||||
$vorgang = (string)QifParser::feld($eintrag, 'N');
|
$kurs = QifParser::betrag(QifParser::feld($eintrag, 'I'));
|
||||||
$papier = (string)QifParser::feld($eintrag, 'Y');
|
|
||||||
|
|
||||||
$hash = $this->eindeutigerHash($kontoId, $datum, $betrag, $vorgang, $text);
|
[$gebuehren, $steuern] = self::nebenkosten(QifParser::feld($eintrag, 'O'));
|
||||||
if (Buchung::findeNachHash($hash) !== null) {
|
|
||||||
|
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -302,20 +337,106 @@ class QifBuchungsImport
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Buchung::anlegen([
|
$text = trim((string)QifParser::feld($eintrag, 'M'));
|
||||||
'konto_id' => $kontoId,
|
|
||||||
|
$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,
|
'datum' => $datum,
|
||||||
'valuta' => QifParser::datum(QifParser::feld($eintrag, 'V'), $this->parser->datumsformat()),
|
'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,
|
'betrag' => $betrag,
|
||||||
'empfaenger' => $papier !== '' ? $papier : (string)$eintrag['konto'],
|
'gebuehren' => $gebuehren,
|
||||||
'buchungstext' => trim($vorgang . ' ' . $text),
|
'steuern' => $steuern,
|
||||||
'nummer' => '',
|
'waehrung' => (string)QifParser::feld($eintrag, 'F') ?: 'EUR',
|
||||||
|
'verrechnungskonto_id' => $verrechnungId,
|
||||||
|
'geldbetrag' => $geld,
|
||||||
|
'notiz' => $text,
|
||||||
|
'wertpapiername' => $name,
|
||||||
'abgeglichen' => QifParser::feld($eintrag, 'C') !== null,
|
'abgeglichen' => QifParser::feld($eintrag, 'C') !== null,
|
||||||
'quelle' => 'qif',
|
'quelle' => 'qif',
|
||||||
'import_hash' => $hash,
|
'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
|
* Welche Konten bekommen aus dieser Buchung eine Gegenbewegung, und mit
|
||||||
* welchem Betrag? Ohne Splits genau eines, mit Splits je Zeile eines.
|
* 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);
|
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
|
* 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
|
* Laden). Der laufende Zähler hält sie auseinander und bleibt über Läufe
|
||||||
|
|
|
||||||
220
src/Services/QifVorlagenImport.php
Normal file
220
src/Services/QifVorlagenImport.php
Normal 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
187
src/Services/SepaXml.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/View.php
18
src/View.php
|
|
@ -79,6 +79,24 @@ class View
|
||||||
return $namen[$monat] ?? (string)$monat;
|
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
|
public static function datum(?string $datum): string
|
||||||
{
|
{
|
||||||
if (!$datum) {
|
if (!$datum) {
|
||||||
|
|
|
||||||
167
src/Views/banking/formular.php
Normal file
167
src/Views/banking/formular.php
Normal 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
119
src/Views/banking/index.php
Normal 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'; ?>
|
||||||
204
src/Views/banking/regeln.php
Normal file
204
src/Views/banking/regeln.php
Normal 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
82
src/Views/banking/tan.php
Normal 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'; ?>
|
||||||
153
src/Views/banking/umsaetze.php
Normal file
153
src/Views/banking/umsaetze.php
Normal 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'; ?>
|
||||||
111
src/Views/cockpit/ausgabendiagramm.php
Normal file
111
src/Views/cockpit/ausgabendiagramm.php
Normal 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>
|
||||||
|
|
@ -1,20 +1,46 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Finanzmanager\Services\Cockpit;
|
||||||
use Finanzmanager\View;
|
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 */
|
* Das Cockpit zeichnet nur die Kacheln, die im Einstellungsdialog (Reiter
|
||||||
/** @var bool $nurAktive */
|
* Darstellung) angekreuzt sind – in der Reihenfolge des Katalogs.
|
||||||
/** @var string $csrfToken */
|
*
|
||||||
|
* @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';
|
include __DIR__ . '/../layout/kopf.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="tableiste">
|
<div class="tableiste">
|
||||||
<span class="tab aktiv">Cockpit</span>
|
<span class="tab aktiv">Cockpit</span>
|
||||||
|
<a href="/einstellungen?bereich=darstellung" class="tab rechtsbuendig">Kacheln wählen</a>
|
||||||
</div>
|
</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">
|
<div class="kachelraster">
|
||||||
|
<?php foreach ($sichtbar as $kachel): ?>
|
||||||
|
<?php if ($kachel === 'vermoegen'): ?>
|
||||||
<section class="kachel">
|
<section class="kachel">
|
||||||
<header class="mitschalter">
|
<header class="mitschalter">
|
||||||
<h2>Vermögensentwicklung</h2>
|
<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
|
<p class="kachelhinweis">Kontostände der letzten zwölf Monate, je Monatsende
|
||||||
gestapelt – ein Farbton pro Konto.</p>
|
gestapelt – ein Farbton pro Konto.</p>
|
||||||
</header>
|
</header>
|
||||||
<?php if (!$vermoegen['hatWerte']): ?>
|
<?php if (!($vermoegen['hatWerte'] ?? false)): ?>
|
||||||
<p class="platzhalter">
|
<p class="platzhalter">
|
||||||
Zeigt die Kontostände über die Zeit, sobald <?= $nurAktive ? 'aktive ' : '' ?>Konten
|
Zeigt die Kontostände über die Zeit, sobald <?= $nurAktive ? 'aktive ' : '' ?>Konten
|
||||||
mit Σ-Kennzeichen Buchungen haben.
|
mit Σ-Kennzeichen Buchungen haben.
|
||||||
|
|
@ -45,21 +71,52 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<?php elseif ($kachel === 'einnahmen_ausgaben'): ?>
|
||||||
<section class="kachel">
|
<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">
|
<p class="platzhalter">
|
||||||
Der Monatsvergleich als Grafik folgt noch. Die Zahlen dazu stehen bereits im
|
Zeigt Einnahmen und Ausgaben je Monat, sobald kategorisierte Buchungen
|
||||||
Summenbericht – mit Spalten-Aufteilung nach Monat.
|
vorliegen. Umbuchungen zählen wie überall in den Auswertungen nicht mit.
|
||||||
</p>
|
</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php $reihe = $monatsreihe; include __DIR__ . '/monatsdiagramm.php'; ?>
|
||||||
|
<?php endif; ?>
|
||||||
<div class="knopfleiste">
|
<div class="knopfleiste">
|
||||||
<a href="/auswertungen/bericht?typ=summenbericht&zeitraum=aktuelles_jahr&aufteilung=monat&angepasst=1"
|
<a href="/auswertungen/bericht?typ=summenbericht&zeitraum=letzte_12_monate&aufteilung=monat&angepasst=1"
|
||||||
class="knopf">Summenbericht öffnen</a>
|
class="knopf">Summenbericht öffnen</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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&zeitraum=aktuelles_jahr&angepasst=1"
|
||||||
|
class="knopf">Nach Kategorie aufschlüsseln</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php elseif ($kachel === 'kontostaende'): ?>
|
||||||
<section class="kachel">
|
<section class="kachel">
|
||||||
<header><h2>Kontostände</h2></header>
|
<header><h2>Kontostände</h2></header>
|
||||||
<?php if ($uebersicht['gruppen'] === []): ?>
|
<?php if (($uebersicht['gruppen'] ?? []) === []): ?>
|
||||||
<p class="platzhalter">
|
<p class="platzhalter">
|
||||||
Noch keine Konten angelegt – einzeln oder gemeinsam aus dem QIF-Export.
|
Noch keine Konten angelegt – einzeln oder gemeinsam aus dem QIF-Export.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -94,17 +151,50 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<?php elseif ($kachel === 'offene_vorgaenge'): ?>
|
||||||
<section class="kachel">
|
<section class="kachel">
|
||||||
<header><h2>Aufbaustand</h2></header>
|
<header><h2>Offene Vorgänge</h2></header>
|
||||||
<ul class="checkliste">
|
<?php if (($vorgaenge ?? []) === []): ?>
|
||||||
<li class="erledigt">Phase 0 – Grundgerüst, Anmeldung, Einstellungen</li>
|
<p class="platzhalter">
|
||||||
<li class="erledigt">Phase 1 – Konten, Kategorien, Klassen, QIF-Stammdaten</li>
|
Nichts offen: Alle Buchungen sind kategorisiert und abgeglichen,
|
||||||
<li class="erledigt">Phase 2 – Buchungen, Kontoblatt, Splitbuchungen</li>
|
der Monatsabschluss ist auf dem Laufenden.
|
||||||
<li class="erledigt">Phase 3 – Budgetplaner und Monatsabschluss</li>
|
</p>
|
||||||
<li class="erledigt">Phase 4 – Berichtsmodul mit fünf Berichtstypen</li>
|
<?php else: ?>
|
||||||
<li>Phase 4 – die übrigen Grafiken und Cockpit-Kacheln</li>
|
<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>
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
</div>
|
</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'; ?>
|
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||||
|
|
|
||||||
138
src/Views/cockpit/monatsdiagramm.php
Normal file
138
src/Views/cockpit/monatsdiagramm.php
Normal 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>
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Finanzmanager\Models\Einstellung;
|
use Finanzmanager\Models\Einstellung;
|
||||||
|
use Finanzmanager\Services\Cockpit;
|
||||||
|
use Finanzmanager\Services\Kursabruf;
|
||||||
use Finanzmanager\View;
|
use Finanzmanager\View;
|
||||||
|
|
||||||
/** @var string $bereich */
|
/** @var string $bereich */
|
||||||
/** @var array<string,string|null> $werte */
|
/** @var array<string,string|null> $werte */
|
||||||
/** @var list<array{name:string,pfad:string,groesse:int,geaendert:int}> $logDateien */
|
/** @var list<array{name:string,pfad:string,groesse:int,geaendert:int}> $logDateien */
|
||||||
|
/** @var list<string> $kacheln */
|
||||||
/** @var string $csrfToken */
|
/** @var string $csrfToken */
|
||||||
|
|
||||||
$wert = static fn(string $k, string $standard = ''): string => (string)($werte[$k] ?? $standard);
|
$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')) ?>">
|
value="<?= View::h($wert('zeilen_pro_seite', '100')) ?>">
|
||||||
<p class="feldhinweis">Gilt für Kontoblatt und Berichtslisten.</p>
|
<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'): ?>
|
<?php elseif ($bereich === 'buchhaltung'): ?>
|
||||||
<label for="erster_monat">Erster Monat des Haushaltsjahres</label>
|
<label for="erster_monat">Erster Monat des Haushaltsjahres</label>
|
||||||
<select id="erster_monat" name="erster_monat">
|
<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>
|
<a href="/verwaltung/import" class="knopf">QIF-Import öffnen</a>
|
||||||
</div>
|
</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'): ?>
|
<?php elseif ($bereich === 'diagnose'): ?>
|
||||||
<label class="ankreuzfeld">
|
<label class="ankreuzfeld">
|
||||||
<input type="checkbox" name="debug_log_aktiv" value="1"
|
<input type="checkbox" name="debug_log_aktiv" value="1"
|
||||||
|
|
@ -118,15 +206,11 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<p class="platzhalter">
|
<p class="platzhalter">
|
||||||
Dieser Bereich füllt sich in einer späteren Phase:
|
Dieser Bereich füllt sich in einer späteren Phase:
|
||||||
<?= View::h(match ($bereich) {
|
<?= View::h('noch offen') ?>.
|
||||||
'banking' => 'Bankverbindungen und Abrufeinstellungen (Phase 5)',
|
|
||||||
'wertpapiere' => 'Kursabruf und Depoteinstellungen (Phase 6)',
|
|
||||||
default => 'noch offen',
|
|
||||||
}) ?>.
|
|
||||||
</p>
|
</p>
|
||||||
<?php endif; ?>
|
<?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">
|
<div class="knopfleiste">
|
||||||
<button type="submit" class="knopf primaer">Speichern</button>
|
<button type="submit" class="knopf primaer">Speichern</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,12 @@ use Finanzmanager\View;
|
||||||
$navigation = [
|
$navigation = [
|
||||||
['pfad' => '/', 'name' => 'Cockpit', 'icon' => '⌂'],
|
['pfad' => '/', 'name' => 'Cockpit', 'icon' => '⌂'],
|
||||||
['pfad' => '/konten', 'name' => 'Kontenübersicht', 'icon' => '▤'],
|
['pfad' => '/konten', 'name' => 'Kontenübersicht', 'icon' => '▤'],
|
||||||
|
['pfad' => '/banking', 'name' => 'Online-Banking', 'icon' => '⇊'],
|
||||||
['pfad' => '/zahlungsverkehr', 'name' => 'Zahlungsverkehr', 'icon' => '⇄'],
|
['pfad' => '/zahlungsverkehr', 'name' => 'Zahlungsverkehr', 'icon' => '⇄'],
|
||||||
['pfad' => '/auswertungen', 'name' => 'Auswertungen', 'icon' => '◱'],
|
['pfad' => '/auswertungen', 'name' => 'Auswertungen', 'icon' => '◱'],
|
||||||
['pfad' => '/planung', 'name' => 'Planung', 'icon' => '◷'],
|
['pfad' => '/planung', 'name' => 'Planung', 'icon' => '◷'],
|
||||||
|
['pfad' => '/termine', 'name' => 'Termine', 'icon' => '⏱'],
|
||||||
|
['pfad' => '/vertraege', 'name' => 'Verträge', 'icon' => '§'],
|
||||||
['pfad' => '/wertpapiere', 'name' => 'Wertpapiere', 'icon' => '↗'],
|
['pfad' => '/wertpapiere', 'name' => 'Wertpapiere', 'icon' => '↗'],
|
||||||
['pfad' => '/verwaltung', 'name' => 'Verwaltung', 'icon' => '⚙'],
|
['pfad' => '/verwaltung', 'name' => 'Verwaltung', 'icon' => '⚙'],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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'; ?>
|
|
||||||
86
src/Views/termine/erinnerungen.php
Normal file
86
src/Views/termine/erinnerungen.php
Normal 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
240
src/Views/termine/index.php
Normal 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'; ?>
|
||||||
72
src/Views/termine/kalender.php
Normal file
72
src/Views/termine/kalender.php
Normal 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'; ?>
|
||||||
151
src/Views/vertraege/adressen.php
Normal file
151
src/Views/vertraege/adressen.php
Normal 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'; ?>
|
||||||
210
src/Views/vertraege/index.php
Normal file
210
src/Views/vertraege/index.php
Normal 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'; ?>
|
||||||
258
src/Views/vertraege/kredite.php
Normal file
258
src/Views/vertraege/kredite.php
Normal 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'; ?>
|
||||||
138
src/Views/vertraege/sparziele.php
Normal file
138
src/Views/vertraege/sparziele.php
Normal 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'; ?>
|
||||||
|
|
@ -6,6 +6,7 @@ use Finanzmanager\View;
|
||||||
/** @var string $verzeichnis */
|
/** @var string $verzeichnis */
|
||||||
/** @var array<string,mixed>|null $ergebnis */
|
/** @var array<string,mixed>|null $ergebnis */
|
||||||
/** @var array<string,mixed>|null $buchungen */
|
/** @var array<string,mixed>|null $buchungen */
|
||||||
|
/** @var array<string,mixed>|null $vorlagen */
|
||||||
/** @var string $stichtag */
|
/** @var string $stichtag */
|
||||||
/** @var string $bis */
|
/** @var string $bis */
|
||||||
/** @var string $datei */
|
/** @var string $datei */
|
||||||
|
|
@ -98,7 +99,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['zukunft'] ?></td>
|
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['zukunft'] ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
<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>
|
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['wertpapier'] ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -115,6 +116,33 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</p>
|
</p>
|
||||||
<?php endif; ?>
|
<?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'])): ?>
|
<?php if (!empty($ergebnis['abschnitte'])): ?>
|
||||||
<h3>Inhalt der Datei</h3>
|
<h3>Inhalt der Datei</h3>
|
||||||
<p class="hinweis">
|
<p class="hinweis">
|
||||||
|
|
|
||||||
|
|
@ -55,15 +55,15 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kachel">
|
<div class="kachel">
|
||||||
<header>
|
<header><h2>Online-Banking</h2></header>
|
||||||
<h2>Weitere Bereiche</h2>
|
<p class="platzhalter">
|
||||||
<span class="phasenmarke">später</span>
|
Bankverbindungen über FinTS, Umsatzabruf mit Abgleich gegen vorhandene
|
||||||
</header>
|
Buchungen und Regeln für die automatische Kategorisierung.
|
||||||
<ul class="checkliste">
|
</p>
|
||||||
<li>Bankverbindungen und Regelverwaltung – Phase 5</li>
|
<div class="knopfleiste">
|
||||||
<li>Buchungsvorlagen und regelmäßige Buchungen – Phase 7</li>
|
<a href="/banking" class="knopf primaer">Bankverbindungen</a>
|
||||||
<li>Adressen, Verträge, Sparziele – Phase 7</li>
|
<a href="/banking/regeln" class="knopf">Regeln</a>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
154
src/Views/vorlagen/index.php
Normal file
154
src/Views/vorlagen/index.php
Normal 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'; ?>
|
||||||
145
src/Views/wertpapiere/auswertung.php
Normal file
145
src/Views/wertpapiere/auswertung.php
Normal 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'; ?>
|
||||||
124
src/Views/wertpapiere/buchungen.php
Normal file
124
src/Views/wertpapiere/buchungen.php
Normal 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'; ?>
|
||||||
99
src/Views/wertpapiere/entwicklungsdiagramm.php
Normal file
99
src/Views/wertpapiere/entwicklungsdiagramm.php
Normal 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>
|
||||||
136
src/Views/wertpapiere/formular.php
Normal file
136
src/Views/wertpapiere/formular.php
Normal 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'; ?>
|
||||||
176
src/Views/wertpapiere/index.php
Normal file
176
src/Views/wertpapiere/index.php
Normal 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'; ?>
|
||||||
123
src/Views/wertpapiere/stammdaten.php
Normal file
123
src/Views/wertpapiere/stammdaten.php
Normal 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'; ?>
|
||||||
173
src/Views/zahlungsverkehr/formular.php
Normal file
173
src/Views/zahlungsverkehr/formular.php
Normal 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'; ?>
|
||||||
102
src/Views/zahlungsverkehr/index.php
Normal file
102
src/Views/zahlungsverkehr/index.php
Normal 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'; ?>
|
||||||
75
src/Views/zahlungsverkehr/tan.php
Normal file
75
src/Views/zahlungsverkehr/tan.php
Normal 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
252
tests/15_CockpitTest.php
Normal 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
544
tests/16_BankingTest.php
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
315
tests/17_WertpapiereTest.php
Normal file
315
tests/17_WertpapiereTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
616
tests/18_NebenmoduleTest.php
Normal file
616
tests/18_NebenmoduleTest.php
Normal 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, '&'), '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');
|
||||||
|
}
|
||||||
|
}
|
||||||
164
tests/e2e/specs/module.spec.js
Normal file
164
tests/e2e/specs/module.spec.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Reference in a new issue