diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4557a0c --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore index ca53b61..361df22 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /data/mariadb/ /data/logs/ /data/import/ +/data/schluessel/ /backups/ /.env /composer.lock.bak diff --git a/Changes.md b/Changes.md index 576f8dc..027233c 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,164 @@ # Ä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. + +**Wahl des TAN-Verfahrens** (`Services\FinTsVerfahren`, Migration `009_tan_verfahren.sql`) +- **Einrichtung in zwei Schritten**: Zugangsdaten erfassen und mit *Weiter* speichern, dann + das TAN-Verfahren. Vorher lässt sich die Bank nicht fragen – ohne gespeicherte + Zugangsdaten gibt es keine Verbindung. +- Der zweite Schritt holt die Liste beim Aufruf selbst (`getTanModes()`, ohne Anmeldung und + ohne TAN); ein Knopf davor wäre ein Schritt, dessen Ergebnis feststeht. Ein erneuter + Anlauf steht daneben. TAN-Medien werden nur für die Verfahren geholt, die eines brauchen. +- Das Ergebnis liegt in der Sitzung, auch ein gescheitertes – sonst liefe jeder + Seitenaufruf wieder in dieselbe Zeitüberschreitung. Die sind für die Abfrage kürzer + gesetzt als in der Bibliothek (8/20 statt 15/30 Sekunden), weil sie an einem + Seitenaufruf hängt. +- Scheitert die Abfrage, erklärt die Seite, wo der Fehler meist steckt, und verlinkt zurück + auf die Zugangsdaten; überspringen lässt sich der Schritt ebenfalls. +- Abgefragt statt eingetippt: Die Kennungen sind bankspezifisch, 913 heißt nicht überall + chipTAN QR. Der Name wird mitgespeichert (`tan_verfahren_name`), damit das Formular ihn + ohne Bankverbindung zeigen kann. +- Beim Übernehmen wird gegen die abgefragte Liste geprüft; ein Verfahren ohne Medienzwang + bekommt kein Medium angehängt, weil manche Banken den Dialog sonst ablehnen. +- Ohne Wahl entscheidet weiterhin das erste gemeldete Verfahren – diese Notwahl wird + festgehalten, eine ausdrückliche Wahl dagegen **nie überschrieben**. +- Behoben: Das Speichern des Zugangsformulars setzte Verfahren und TAN-Medium zurück, weil + beide Felder darin nicht vorkommen. Sie werden jetzt wie die PIN unter Vorbehalt gestellt. +- Abruf und Überweisung benutzen dieselbe Wahl; die doppelte Auswahllogik in + `FinTsUeberweisung` ist entfallen. + +**Abgleich statt Direktbuchung** +- Abgerufene Umsätze landen in `bank_umsaetze` und werden nicht sofort gebucht. Die + Vorschlagsliste kennt drei Wege: mit einer erfassten Buchung **verbinden**, als neue + Buchung **übernehmen** oder **verwerfen**. +- Gleiches Konto, gleicher Betrag, Datum innerhalb der Toleranz (Vorgabe fünf Tage, + einstellbar) – so findet der Abgleich die schon erfasste Buchung. Eine bereits + verbundene Buchung wird nie ein zweites Mal vorgeschlagen. +- `extern_id` verhindert, dass derselbe Umsatz bei überlappenden Abrufzeiträumen zweimal + hereinkommt. + +**Regeln** +- Muster auf Empfänger und/oder Verwendungszweck, vier Vergleichsarten samt regulärem + Ausdruck, Betragsgrenzen mit Vorzeichen, Wirkung auf Kategorie, Klasse, Gegenkonto oder + ein festes Split-Schema in Prozent. +- Die erste passende Regel gewinnt (nach Priorität) – kein Sammeln über mehrere Regeln, + sonst wäre nicht mehr nachvollziehbar, warum am Ende die eine Kategorie dasteht. +- Reguläre Ausdrücke werden beim Speichern geprüft; ein kaputtes Muster brächte sonst + jeden Abruf zum Stehen. + +### Phase 6 – Wertpapiere und Depots + +- Migration `007_wertpapiere.sql`: `wertpapiere`, `depot_buchungen`, `kurse`. +- **Zwei Seiten je Geschäft**: die Depotbuchung mit Stück und Kurs, die Geldbuchung auf dem + Verrechnungskonto. `betrag` ist der Bruttowert, Gebühren und Steuern stehen daneben – + nur so enthält der Einstand die Nebenkosten genau einmal. +- **Einstand nach gleitendem Durchschnitt**, realisierte Gewinne beim Verkauf. (Für die + Steuer gilt FIFO; maßgeblich ist dort die Abrechnung der Bank.) +- Ohne Kurs wird nicht bewertet: Die Position sagt „kein Kurs“, statt den Einstand als + Ersatzwert auszugeben. +- **Kursabruf** über Leeway.tech oder Financial Modeling Prep, Kürzel wird nach dem ersten + Fund gespeichert. Abgerufen wird nur für Papiere mit Bestand und nur einmal am Tag; + täglicher Lauf über `scripts/kursabruf.php`. +- **QIF-Import für `!Type:Invst`**: Wertpapier, Depotbuchung und Geldbuchung in einem Zug. +- Depots erscheinen in der Kontenübersicht mit ihrem bewerteten Bestand. + +**Dabei behoben:** Der Import buchte den Geldfluss *jedes* Wertpapiergeschäfts als Abgang – +auch bei Verkäufen und Dividenden. Das QIF nennt im Feld `$` immer einen positiven Betrag, +die Richtung steckt allein im Vorgang. Zudem wurde das Verrechnungskonto nicht erkannt, +wenn dem Konto im `L`-Feld eine Kategorie vorangeht +(`LKursgewinne:Realisierte Gewinne|[Konto]`) – bei Verkäufen also immer. + +### Phase 7 – Nebenmodule und Zahlungsverkehr + +- Migration `008_nebenmodule.sql`: `buchungsvorlagen`, `regelmaessige_buchungen`, + `erinnerungen`, `adressen`, `vertraege`, `sparziele`, `kredite`, `zahlungsauftraege`. +- **Buchungsvorlagen** inklusive QIF-Import (`!Type:Memorized`); Splitzeilen werden bei + abweichendem Betrag im Verhältnis mitskaliert. +- **Regelmäßige Buchungen** mit Fälligkeitslauf: automatisch oder auf Bestätigung, jeder + zurückliegende Termin wird einzeln gebucht, abgeschlossene Monate bleiben unangetastet. +- **Finanzkalender** aus vier Quellen – gerechnet, nicht gespeichert. +- **Adressen** mit IBAN-Prüfung nach ISO 13616 (Länge je Land, Prüfziffer modulo 97). +- **Verträge** mit Kündigungsfrist und Jahresbetrag; ablaufende Fristen stehen oben und im + Kalender. +- **Sparziele**, deren Stand aus dem Kontosaldo kommt statt aus einem zweiten Zähler. +- **Kredite** mit gerechnetem Tilgungsplan (Annuität, Sondertilgung, Restschuld zum + Zinsbindungsende). Deckt die Rate die Zinsen nicht, gibt es einen Hinweis statt eines + Plans. +- **Zahlungsverkehr**: Überweisungen und Terminüberweisungen über FinTS. Die Buchung + entsteht erst, wenn die Bank den Auftrag angenommen hat; gesendete Aufträge bleiben als + Beleg unveränderlich stehen. +- **SEPA-XML selbst erzeugt** (`Services\SepaXml`, pain.001.001.03) statt einer weiteren + Abhängigkeit – samt Umschrift der Umlaute in den SEPA-Zeichenvorrat. + +### Nebenbei + +- `.dockerignore` ergänzt: Ohne sie schickte der Build das ganze Projektverzeichnis an den + Daemon, einschließlich `data/mariadb` – der Build brach mit „permission denied“ ab. +- `PlatzhalterController` entfernt: Alle Navigationspunkte haben jetzt ihr Modul. +- Neue Reiter im Einstellungsdialog: *Online-Banking* und *Wertpapiere*. + +**Doku:** `docs/cockpit.md`, `docs/banking.md`, `docs/wertpapiere.md`, +`docs/nebenmodule.md`. + +**Tests:** 15 bis 18 (Cockpit, Banking, Wertpapiere, Nebenmodule) – zusammen 888 Prüfungen. + ## Phase 4 – Auswertungen mit Berichtsmodul (11.08.2026) **Datenmodell** diff --git a/PLAN.md b/PLAN.md index a2efa35..151271c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,6 +1,6 @@ # Projektplan: Finanzmanager (Web) -> Stand: 09.08.2026 — Arbeitsgrundlage, gedacht zum Überarbeiten. Änderungen einfach direkt +> Stand: 12.08.2026 — alle sieben Phasen umgesetzt. Arbeitsgrundlage, gedacht zum Überarbeiten. Änderungen einfach direkt > in dieser Datei vornehmen; die Umsetzung richtet sich nach dem hier festgehaltenen Stand. ## 1. Kontext @@ -264,7 +264,7 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag * *Ergebnis:* Jahresbudget zum Jahreswechsel planbar, Monatsabschluss durchführbar. Einzelheiten in `docs/planung.md`. -### Phase 4 — Auswertungen und Cockpit +### Phase 4 — Auswertungen und Cockpit *(erledigt)* * Berichtsmodul mit gespeicherten Berichten und Berichtsgruppen (wie „Bankgeschäfte", „Planung", „Steuern"): **Summenbericht, Buchungsbericht, Vergleichsbericht, Kontostandsbericht, Budgetbericht**, jeweils mit Zeitraum, Spalten-Aufteilung, @@ -278,16 +278,20 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag Summe zu fallen. Im Ausgabenabschnitt steht der Betrag ohne Minus wie im Original, die Schlusszeile rechnet mit den echten Vorzeichen. Der Budgetbericht rechnet über volle Monate — ein halber Monat hat kein halbes Budget. -* Grafiken als SVG: Einnahmen/Ausgaben je Monat, Vermögensentwicklung *(erledigt: Cockpit- - Kachel mit den letzten zwölf Monaten, gestapelt je Konto — `docs/cockpit.md`)*, Ausgaben - nach Kategorie. +* Grafiken als SVG: Vermögensentwicklung (gestapelte Balken je Konto), Einnahmen/Ausgaben + je Monat (Balkenpaare mit Saldolinie), Ausgaben nach Kategorie (Ring auf der obersten + Kategorieebene). *(erledigt)* * **Cockpit** mit konfigurierbaren Kacheln (Vermögensentwicklung, Einnahmen/Ausgaben, - Meine Ausgaben, Kontostände, offene Vorgänge). -* *Ergebnis:* Die bisher genutzten Berichte sind nachgebaut und liefern dieselben Zahlen. - Offen bleiben die beiden übrigen Grafiken und die Cockpit-Kacheln. + Meine Ausgaben, Kontostände, offene Vorgänge). Die Auswahl steht im Einstellungsdialog; + abgewählte Kacheln werden nicht berechnet. *(erledigt)* +* *Ergebnis:* Die bisher genutzten Berichte sind nachgebaut und liefern dieselben Zahlen, + das Cockpit beantwortet die täglichen Fragen auf einen Blick. Einzelheiten in + `docs/auswertungen.md` und `docs/cockpit.md`. -### Phase 5 — Online-Banking (FinTS) -* Fork von `nemiah/php-fints` auf Codeberg, Einbindung über VCS-Repository. +### Phase 5 — Online-Banking (FinTS) *(erledigt)* +* Fork von `nemiah/php-fints` auf Codeberg (`fds2610-bot/phpFinTS`, Version `4.1.1`), + Einbindung über VCS-Repository. `psr/log` ist im Fork direkt eingebaut — der + Finanzmanager hat damit genau eine Abhängigkeit, und die steht unter eigener Kontrolle. * Übernahme aus Steuerkram: `Crypto`, `BankSource` (verschlüsselte Zugangsdaten, `computeStartDate`, `account_filter`), `BankProvider`, FinTS-Abrufdienst, Abruf-Protokoll. @@ -299,9 +303,14 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag Buchungen (Vorschlagsliste: übernehmen / neu anlegen / verwerfen). * **Regelverwaltung**: Muster auf Empfänger/Verwendungszweck → Kategorie, Klasse oder festes Split-Schema; Regeln greifen beim Abruf und nachträglich auf Bestandsbuchungen. -* *Ergebnis:* Umsätze kommen per Knopfdruck rein und sind großteils automatisch kategorisiert. +* Entschieden während der Umsetzung: Der FinTS-Dialog wird über die **Sitzung** fortgesetzt + (`FinTs::persist()` plus serialisierte Aktion), nicht über einen wartenden Prozess — mit + php-fpm wäre das der falsche Weg. Abgerufene Umsätze werden **nicht sofort gebucht**, + sondern gehen durch die Vorschlagsliste; wer selbst erfasst, hätte sonst alles doppelt. +* *Ergebnis:* Umsätze kommen per Knopfdruck rein und sind großteils automatisch + kategorisiert. Einzelheiten in `docs/banking.md`. -### Phase 6 — Wertpapiere und Depots +### Phase 6 — Wertpapiere und Depots *(erledigt)* * Migrationen `wertpapiere`, `depot_buchungen`, `kurse`. * Depotübersicht, Bestände, Einstandswerte, aktueller Wert, Wertentwicklung. * Depotbuchungen (Kauf, Verkauf, Dividende, Reinvestition) inkl. Verrechnungskonto-Buchung. @@ -313,9 +322,19 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag zentralen Einstellungsdialog, täglicher Lauf per Cron, Ablage in `kurse`. Bei ~20 Positionen bleibt der Bedarf im Freikontingent. * Auswertungen: Depotübersicht, Wertpapierentwicklung, Veräußerungsgeschäfte, Erträge. -* *Ergebnis:* Die drei Depots sind abgebildet und werden mit abgerufen. +* Entschieden während der Umsetzung: `betrag` einer Depotbuchung ist der **Bruttowert**, + Gebühren und Steuern stehen daneben — nur so enthält der Einstand die Nebenkosten genau + einmal. Der Einstand folgt dem **gleitenden Durchschnitt** wie im FinanzManager (für die + Steuer gilt FIFO, dafür ist die Bank zuständig). Ohne Kurs wird eine Position nicht + bewertet, statt den Einstand als Ersatzwert auszugeben. +* Der **Depotabruf per FinTS** ist nicht umgesetzt: Bestände und Geschäfte kommen aus dem + QIF-Import und den erfassten Depotbuchungen, Kurse vom Kursdienst. Ein Abruf der + Depotaufstellung ließe sich ohne echte Bankverbindung nicht verlässlich prüfen und wäre + gegenüber dem Kursabruf ein geringer Zugewinn. +* *Ergebnis:* Die Depots sind abgebildet, bewertet und in der Kontenübersicht enthalten. + Einzelheiten in `docs/wertpapiere.md`. -### Phase 7 — Nebenmodule +### Phase 7 — Nebenmodule *(erledigt)* * **Buchungsvorlagen** (`!Type:Memorized`, 579 Stück im Export) inkl. Import. * **Regelmäßige Buchungen** mit Fälligkeitslauf und Finanzkalender. * **Erinnerungen**, **Adressen**, **Verträge**, **Sparziele**, **Kredite** (Tilgungsplan). @@ -323,7 +342,12 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag aus dem Adressbuch, IBAN/BIC-Prüfung, Verwendungszweck, Kategorie- und Klassenzuordnung, Termin- und Einzelüberweisung. Absenden über FinTS mit dem in Phase 5 übernommenen TAN-Handling; Vorgangsliste „Zahlungsvorgänge / Terminüberweisungen" wie im Original. -* *Ergebnis:* Funktionsgleichstand mit dem bisher genutzten Umfang. +* Entschieden während der Umsetzung: Der Tilgungsplan und die Termine des Finanzkalenders + werden **gerechnet, nicht gespeichert** — vorausberechnete Tabellen wären bei jeder + Änderung neu zu erzeugen. Die pain.001-Nachricht für SEPA-Überweisungen entsteht in + `Services\SepaXml` selbst, statt dafür eine weitere Bibliothek einzubinden. +* *Ergebnis:* Funktionsgleichstand mit dem bisher genutzten Umfang. Einzelheiten in + `docs/nebenmodule.md`. ## 6. Was du beisteuern musst @@ -359,11 +383,13 @@ Je Phase, nicht erst am Ende: Werten aus der Kontenübersicht des FinanzManagers entsprechen. Geprüft an acht Konten, deren Werte aus den Screenshots vorliegen — sie stimmen auf den Cent (Stand Phase 2). - Die **Summe der Kontostände** (503.430,75 € laut Screenshot) lässt sich erst vollständig - vergleichen, wenn zwei Dinge stehen: die Σ-Kennzeichen je Konto (welche Konten zählen - überhaupt mit — im Export nicht enthalten) und die Depotbewertung aus Phase 6. Die - Jahressummen Einnahmen/Ausgaben (Cockpit 2026: 148.060,18 € / 58.457,07 €) hängen an - denselben Voraussetzungen und werden mit den Auswertungen in Phase 4 abgeglichen. + Die **Summe der Kontostände** (503.430,75 € laut Screenshot) ist seit Phase 6 + vergleichbar: Depots gehen mit ihrem bewerteten Bestand in die Kontenübersicht ein. Es + fehlen nur noch die Kurse — solange für eine Position keiner vorliegt, weist die + Übersicht sie ausdrücklich als unvollständig aus. Offen bleibt außerdem das + Σ-Kennzeichen je Konto (im Export nicht enthalten, Vorgabe: alle zählen mit). + Die Jahressummen Einnahmen/Ausgaben (Cockpit 2026: 148.060,18 € / 58.457,07 €) hängen an + denselben Voraussetzungen und lassen sich über den Summenbericht abgleichen. * **Manuell**: Anwendung unter `http://localhost:8000` aufrufen, Kontenübersicht, Kontoblatt, Budgetplaner und Summenbericht mit den Screenshots aus `Altdaten/` vergleichen. diff --git a/README.md b/README.md index f59fe5f..a8054af 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,27 @@ Haushalt wird beides parallel genutzt. ## Stand -Phase 0 bis 3 sind gebaut: Anmeldung, zentraler Einstellungsdialog, Debug-Log, -Migrationsmechanik und Test-Harness; Konten, Kontengruppen, Kategorien und Klassen; -Kontoblatt, Buchungsmaske mit Splitbuchungen und Umbuchungen sowie der QIF-Import für -Stammdaten und Buchungen; Budgetplaner mit Monatswerten je Kategorie und der -Monatsabschluss samt Prüflauf, Snapshot und Sperre. +**Alle sieben Phasen aus `PLAN.md` sind umgesetzt.** -Aus Phase 4 steht das Berichtsmodul: Summen-, Buchungs-, Vergleichs-, Kontostands- und -Budgetbericht mit Zeitraumvorgaben, Spalten-Aufteilung, Zeilen-Summe nach -Kategorie/Klasse/Konto/Empfänger, Drilldown in die Buchungen, CSV-Export sowie -gespeicherten Berichten in Berichtsgruppen. Dazu die Cockpit-Kachel Vermögensentwicklung -als gestapeltes Balkendiagramm der letzten zwölf Monate. Als Nächstes folgen nach -`PLAN.md` die übrigen Grafiken und Cockpit-Kacheln. +* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt, + Buchungsmaske mit Splitbuchungen und Umbuchungen; QIF-Import mit Stichtag. +* **Planung** – Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf, + Snapshot und Sperre. +* **Auswertungen** – Summen-, Buchungs-, Vergleichs-, Kontostands- und Budgetbericht mit + Zeitraumvorgaben, Spalten-Aufteilung, Drilldown und CSV-Export; gespeicherte Berichte in + Gruppen. +* **Cockpit** – konfigurierbare Kacheln: Vermögensentwicklung, Einnahmen/Ausgaben je + Monat, Ausgaben nach Kategorie, Kontostände, offene Vorgänge. +* **Online-Banking** – FinTS-Abruf mit TAN-Dialog über mehrere Requests, TAN-Verfahren bei + der Bank abfragen und wählen, Abgleich der Umsätze gegen vorhandene Buchungen, Regeln für + die automatische Kategorisierung. +* **Wertpapiere** – Depotbestände mit Einstand und Bewertung, Depotbuchungen, Kursabruf, + Veräußerungsgeschäfte und Erträge. +* **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und + Finanzkalender, Erinnerungen, Adressen, Verträge, Sparziele, Kredite mit Tilgungsplan. +* **Zahlungsverkehr** – Überweisungen und Terminüberweisungen über FinTS. + +Einzelheiten je Modul in `docs/`, die Entwicklung in `Changes.md`. ## Technik @@ -32,18 +41,24 @@ als gestapeltes Balkendiagramm der letzten zwölf Monate. Als Nächstes folgen n | Webserver | nginx + php-fpm | | Betrieb | Docker Compose (drei Services) | | Frontend | serverseitiges HTML, Vanilla JS, CSS Custom Properties | -| Fremdbibliotheken | keine im Kern; FinTS folgt in Phase 5 als eigener Fork | +| Fremdbibliotheken | genau eine: `nemiah/php-fints` als eigener Fork auf Codeberg | Diagramme werden als eigenes SVG erzeugt, PDF-Berichte gehen über die Druckfunktion des -Browsers – bewusst keine Chart- oder PDF-Bibliothek. +Browsers, die SEPA-Nachricht für Überweisungen entsteht selbst – bewusst keine Chart-, +PDF- oder SEPA-Bibliothek. Der FinTS-Fork trägt `psr/log` direkt in sich; damit hängt die +Anwendung an keinem fremden Update-Zyklus. ## Start ```bash -cp .env.example .env # Werte anpassen, APP_KEY erzeugen +cp .env.example .env # Werte anpassen (APP_KEY darf leer bleiben) docker compose up -d ``` +Ist `APP_KEY` leer, erzeugt der Container beim ersten Start einen Schlüssel unter +`data/schluessel/app_key`. Er verschlüsselt die Zugangsdaten der Bankverbindungen und +**gehört ins Backup** – ohne ihn sind sie nicht mehr lesbar. + Danach http://localhost:8000 aufrufen. Beim ersten Aufruf führt die Anwendung zur Ersteinrichtung und legt den ersten Benutzer an. @@ -59,9 +74,9 @@ Ersteinrichtung und legt den ersten Benutzer an. src/ Anwendungscode (Controllers, Models, Services, Views) public/ Front-Controller und statische Dateien migrations/ nummerierte SQL-Migrationen, laufen beim Containerstart -scripts/ Migration, Update, Backup, Tests +scripts/ Migration, Update, Backup, Tests, Kursabruf tests/ PHP-Testsuite; tests/e2e/ für Playwright -data/ Nutzdaten: mariadb, logs, import (nicht im Repository) +data/ Nutzdaten: mariadb, logs, import, schluessel (nicht im Repository) Altdaten/ Screenshots und QIF-Export der Altanwendung (nicht im Repository) ``` @@ -92,5 +107,8 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf. - `docs/buchungen.md` – Kontoblatt, Umbuchungen, Splitbuchungen, Salden - `docs/planung.md` – Budgetplaner und Monatsabschluss - `docs/auswertungen.md` – Berichtsmodul, Filterleiste, Drilldown, CSV -- `docs/cockpit.md` – Startseite und die Kachel Vermögensentwicklung +- `docs/cockpit.md` – Startseite und ihre Kacheln +- `docs/banking.md` – FinTS-Abruf, TAN-Dialog, Abgleich und Regeln +- `docs/wertpapiere.md` – Depots, Einstand, Kurse und der Invst-Import +- `docs/nebenmodule.md` – Vorlagen, Termine, Verträge, Sparziele, Kredite, Zahlungsverkehr - `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs diff --git a/composer.json b/composer.json index f097fd2..fb168e6 100644 --- a/composer.json +++ b/composer.json @@ -3,8 +3,15 @@ "description": "Persönlicher Finanzmanager – Konten, Kategorien, Budget, Monatsabschluss", "type": "project", "license": "MIT", + "repositories": [ + { + "type": "vcs", + "url": "https://codeberg.org/fds2610-bot/phpFinTS.git" + } + ], "require": { - "php": ">=8.3" + "php": ">=8.3", + "nemiah/php-fints": "4.1.1" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..0ec6fa7 --- /dev/null +++ b/composer.lock @@ -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" +} diff --git a/docker-compose.yml b/docker-compose.yml index 530d8ca..461066a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,10 @@ services: # bewusst NICHT gemountet – das gehört allein dem db-Container. - ./data/logs:/var/www/html/data/logs - ./data/import:/var/www/html/data/import + # Der Schlüssel für die verschlüsselten Felder (FinTS-PIN). Eigenes + # Verzeichnis, damit er einen Neubau des Images überlebt: Ohne ihn sind + # gespeicherte Zugangsdaten nicht mehr lesbar. + - ./data/schluessel:/var/www/html/data/schluessel environment: TZ: Europe/Berlin DB_HOST: db diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index f655ef1..0508acc 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -3,9 +3,27 @@ set -euo pipefail # Datenverzeichnisse existieren auch dann, wenn sie per Bind-Mount frisch # hereinkommen – und gehören www-data, sonst scheitert das erste Schreiben. -mkdir -p /var/www/html/data/logs /var/www/html/data/import +mkdir -p /var/www/html/data/logs /var/www/html/data/import /var/www/html/data/schluessel chown -R www-data:www-data /var/www/html/data +# Schlüssel für die verschlüsselten Felder (FinTS-PIN). Steht APP_KEY nicht in +# der Umgebung, wird einmalig einer erzeugt und unter data/ abgelegt – sonst +# ließe sich keine Bankverbindung speichern, und ein bei jedem Start neu +# gewürfelter Schlüssel machte die gespeicherten Zugangsdaten unlesbar. +# +# Wer den Schlüssel lieber selbst verwaltet, setzt APP_KEY in der .env; diese +# Datei wird dann nicht angelegt. +SCHLUESSELDATEI=/var/www/html/data/schluessel/app_key +if [ -z "${APP_KEY:-}" ]; then + if [ ! -s "$SCHLUESSELDATEI" ]; then + openssl rand -hex 32 > "$SCHLUESSELDATEI" + echo "APP_KEY erzeugt: data/schluessel/app_key (unbedingt mitsichern – ohne ihn" + echo "sind gespeicherte Zugangsdaten verloren)." + fi + chown www-data:www-data "$SCHLUESSELDATEI" + chmod 600 "$SCHLUESSELDATEI" +fi + # Migrationen laufen bei jedem Start. Sie sind idempotent, ein zweiter Lauf # ändert nichts. if [ -f /var/www/html/scripts/migrate.php ]; then diff --git a/docs/banking.md b/docs/banking.md new file mode 100644 index 0000000..c4fb412 --- /dev/null +++ b/docs/banking.md @@ -0,0 +1,194 @@ +# 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. + +### Einrichtung in zwei Schritten + +Fast jede Bank bietet mehrere Verfahren an, und welche das sind, weiß nur sie. Fragen lässt +sie sich aber erst, wenn Bankleitzahl, FinTS-Adresse, Benutzerkennung und PIN vorliegen – +deshalb zerfällt das Anlegen einer Bankverbindung in zwei Schritte: + +1. **Zugangsdaten** (`/banking/neu`) – *Weiter* speichert sie. +2. **TAN-Verfahren** (`/banking/{id}/verfahren`) – die Seite fragt die Bank beim Aufruf + selbst und zeigt die Liste zur Wahl. + +Der zweite Schritt holt die Liste **ohne Knopfdruck**: Wer hier landet, will genau das, und +ein Knopf, dessen Ergebnis feststeht, wäre ein Schritt zu viel. Ein erneuter Anlauf steht +trotzdem bereit, wenn sich etwas geändert hat. + +Später erreicht man dieselbe Seite über *TAN-Verfahren einstellen* am Zugang. + +**Abgefragt, nicht eingetippt.** Die Kennungen sind bankspezifisch – 913 heißt nicht +überall chipTAN QR –, und niemand soll sie in einer PDF der Bank suchen müssen. +`getTanModes()` kommt ohne Anmeldung aus (die Bibliothek braucht dafür nur die +Bankparameterdaten) und verlangt deshalb selbst keine TAN. + +`getTanMedia()` wird nur für die Verfahren aufgerufen, die ein Medium brauchen: Jeder +Aufruf ist ein eigener Dialog mit der Bank. + +Die Zeitüberschreitungen sind kürzer gesetzt als in der Bibliothek (8 statt 15 Sekunden zum +Verbinden, 20 statt 30 zur Antwort): Die Abfrage hängt an einem Seitenaufruf. + +Das Ergebnis liegt in der Sitzung, **auch ein gescheitertes** – sonst liefe jeder +Seitenaufruf erneut in dieselbe Zeitüberschreitung. Scheitert die Abfrage, erklärt die +Seite, wo der Fehler meist steckt, und der Weg zurück zu den Zugangsdaten steht daneben. +Die Wahl lässt sich überspringen; die Verbindung ist auch ohne sie benutzbar. + +Beim Übernehmen wird gegen die abgefragte Liste geprüft: Eine Kennung, die die Bank nicht +angeboten hat, wäre beim nächsten Abruf wirkungslos. + +**Ohne Wahl** nimmt der erste Abruf das erste Verfahren, das die Bank meldet, und hält es +fest; sonst stünde im Formular nichts. Eine **ausdrückliche** Wahl wird dagegen nie +überschrieben: Bietet die Bank sie einmal nicht an, sagt das Protokoll das und die +Einstellung bleibt stehen. + +Das Zugangsformular fasst Verfahren und Medium nicht an: Ohne diesen Vorbehalt setzte jedes +Speichern die Wahl zurück. + +Abruf und Überweisung benutzen dieselbe Wahl (`Services\FinTsVerfahren::anwenden()`) – eine +Überweisung soll nicht plötzlich ein anderes Verfahren verlangen als der Abruf. + +### 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. + +Zur Verfahrenswahl geprüft: Kennung, Name und verschlüsseltes Medium werden gespeichert, +unbekannte Kennungen und Medien abgewiesen, ein Verfahren ohne Medienzwang bekommt keines +angehängt, und das Speichern des Zugangsformulars lässt die Wahl stehen. Die Abfrage bei der +Bank steckt hinter derselben Grenze wie der Dialog – geprüft wird, was mit ihrem Ergebnis +geschieht. + +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. diff --git a/docs/cockpit.md b/docs/cockpit.md index 20443f8..9a60652 100644 --- a/docs/cockpit.md +++ b/docs/cockpit.md @@ -1,7 +1,21 @@ # Cockpit -Die Startseite. Jede Kachel beantwortet eine Frage auf einen Blick; ausgebaut wird sie -Stück für Stück in Phase 4. +Die Startseite. Jede Kachel beantwortet eine Frage auf einen Blick. + +## Welche Kacheln erscheinen + +Es gibt fünf: **Vermögensentwicklung**, **Einnahmen und Ausgaben**, **Meine Ausgaben**, +**Kontostände** und **Offene Vorgänge**. Welche davon gezeigt werden, steht im +Einstellungsdialog unter *Darstellung* – der Katalog dazu in `Cockpit::KACHELN`, die +Auswahl als kommagetrennte Liste unter `cockpit_kacheln`. + +Vorgabe ist: alle. Eine abgewählte Kachel wird nicht nur ausgeblendet, sondern gar nicht +erst berechnet – `CockpitController::index()` holt die Daten je Kachel einzeln. Wer das +Cockpit entschlacken will, macht es damit auch schneller. + +Die Reihenfolge im Raster ist die des Katalogs, nicht die der Ankreuzfelder: Ein Formular +liefert Ankreuzfelder in der Reihenfolge des HTML, und die wäre für den Nutzer keine +sinnvolle Sortierung. ## Vermögensentwicklung @@ -102,8 +116,59 @@ Die Legende führt nur Farbe und Kontoname und steht darunter, in kleinerer Schr vielen Spalten, wie die Kachel hergibt (`auto-fill`). Die Beträge stehen am Balken im Tooltip und in der Kachel *Kontostände*; in der Legende kosteten sie nur Breite. +## Einnahmen und Ausgaben + +Balkenpaare über die letzten zwölf Monate: links die Einnahmen, rechts die Ausgaben, +darüber der Saldo als Linie mit einem Punkt je Monat. + +Die Zahlen kommen **aus dem Summenbericht** (`Cockpit::monatsreihe()` ruft +`Auswertung::bericht()` mit Zeitraum *letzte zwölf Monate* und Spalten-Aufteilung *Monat*). +Damit zählt die Kachel dieselben Bewegungen wie der Bericht: Splitzeilen einzeln, +Umbuchungen gar nicht. Eine eigene Abfrage wäre schneller zu schreiben und liefe früher +oder später auseinander. + +Ausgaben stehen wie im Bericht ohne Minuszeichen; der Saldo ist Einnahmen minus Ausgaben +und kann natürlich negativ sein. Der Knopf unter der Kachel öffnet denselben Ausschnitt +als Summenbericht. + +## Meine Ausgaben + +Ein Ring der Ausgaben des laufenden Jahres, aufgeteilt nach **oberster Kategorie**: +„300 - Wohnen“ statt Miete, Strom und Nebenkosten einzeln. Ein Ring mit hundert Segmenten +beantwortet keine Frage; wer es genauer braucht, klickt sich in den Summenbericht. + +`Auswertung::verteilung()` rechnet das: Bewegungen des Zeitraums, Einnahmen oder Ausgaben, +je Bewegung die Wurzel ihrer Kategorie (über den Kategoriepfad, nicht über die +`eltern_id`-Kette), Beträge vorzeichenlos gedreht wie im Ausgabenabschnitt der Berichte. + +Mehr als acht Segmente werden nicht gezeichnet – die kleinsten wandern in **Übrige (n)**. +Kategorien, die im Saldo negativ sind (mehr Erstattung als Ausgabe), bekommen kein Segment; +in der Gesamtsumme in der Mitte des Rings stecken sie weiter, sonst ergänzten sich die +Anteile nicht mehr zu hundert Prozent. + +Gezeichnet wird als SVG-Pfad mit zwei Bögen je Segment (`ausgabendiagramm.php`). Sonderfall +ist die einzige Kategorie mit hundert Prozent: Anfangs- und Endpunkt des Bogens fielen +zusammen und es entstünde keine Fläche – deshalb zwei Halbringe derselben Farbe. + +## Offene Vorgänge + +Was auf Bearbeitung wartet, mit Zahl und Weg dorthin: + +* **ohne Kategorie** – Buchungen ohne Kategorie, die auch keine Umbuchung und nicht + gesplittet sind. Umbuchungen brauchen keine Kategorie und zählen deshalb nicht mit. +* **nicht abgeglichen** – Buchungen aktiver Konten bis heute ohne Abgleich-Kennzeichen. +* **Monatsabschluss steht aus** – der Vorschlag aus `Monatsabschluss::naechsterOffener()`, + aber nur, wenn dieser Monat wirklich noch offen ist und Buchungen trägt. + +Ist nichts offen, bleibt die Liste leer und die Kachel sagt es ausdrücklich. Ab Phase 5 +kommen die noch nicht zugeordneten Bankumsätze hinzu, ab Phase 7 die fälligen +regelmäßigen Buchungen. + ## Tests +* `tests/15_CockpitTest.php` – Kachelauswahl (Reihenfolge, Unbekanntes, leere Auswahl), + Monatsreihe mit Trennung von Einnahmen und Ausgaben, Umbuchungen bleiben draußen, + Verteilung auf die Wurzelkategorie, Kürzung auf die Segmentgrenze, offene Vorgänge * `tests/13_VermoegensentwicklungTest.php` – Zeitraum, Fortschreibung der Stände, Umbuchungen, Σ-Kennzeichen, Schalter „nur aktive“, Zusammenfassen jenseits der Palette, Ebenen (jedes Konto genau einmal, letzte Ebene ohne Sammelstück, zu tiefe Ebene fällt diff --git a/docs/nebenmodule.md b/docs/nebenmodule.md new file mode 100644 index 0000000..4d1b2b4 --- /dev/null +++ b/docs/nebenmodule.md @@ -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 ``-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. diff --git a/docs/wertpapiere.md b/docs/wertpapiere.md new file mode 100644 index 0000000..9b212f2 --- /dev/null +++ b/docs/wertpapiere.md @@ -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. diff --git a/migrations/006_banking.sql b/migrations/006_banking.sql new file mode 100644 index 0000000..b871f8b --- /dev/null +++ b/migrations/006_banking.sql @@ -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; diff --git a/migrations/007_wertpapiere.sql b/migrations/007_wertpapiere.sql new file mode 100644 index 0000000..3bd6a8b --- /dev/null +++ b/migrations/007_wertpapiere.sql @@ -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; diff --git a/migrations/008_nebenmodule.sql b/migrations/008_nebenmodule.sql new file mode 100644 index 0000000..69eaf74 --- /dev/null +++ b/migrations/008_nebenmodule.sql @@ -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; diff --git a/migrations/009_tan_verfahren.sql b/migrations/009_tan_verfahren.sql new file mode 100644 index 0000000..c225e7d --- /dev/null +++ b/migrations/009_tan_verfahren.sql @@ -0,0 +1,9 @@ +-- Das gewählte TAN-Verfahren mit Namen führen. +-- +-- Die ID allein (etwa 913) sagt niemandem etwas, und ohne Verbindung zur Bank +-- lässt sie sich nicht auflösen. Der Name wird deshalb beim Abfragen mit +-- gespeichert, damit das Formular „chipTAN QR (913)“ zeigen kann, statt einer +-- nackten Zahl. + +ALTER TABLE bank_zugaenge + ADD COLUMN IF NOT EXISTS tan_verfahren_name VARCHAR(128) NOT NULL DEFAULT '' AFTER tan_verfahren_id; diff --git a/public/index.php b/public/index.php index ba7be2c..1cad387 100644 --- a/public/index.php +++ b/public/index.php @@ -11,6 +11,7 @@ require dirname(__DIR__) . '/vendor/autoload.php'; use Finanzmanager\Auth; use Finanzmanager\Config; use Finanzmanager\Controllers\AuswertungenController; +use Finanzmanager\Controllers\BankingController; use Finanzmanager\Controllers\BuchungenController; use Finanzmanager\Controllers\BudgetController; use Finanzmanager\Controllers\CockpitController; @@ -21,8 +22,12 @@ use Finanzmanager\Controllers\KontenController; use Finanzmanager\Controllers\LoginController; use Finanzmanager\Controllers\MonatsabschlussController; use Finanzmanager\Controllers\PlanungController; -use Finanzmanager\Controllers\PlatzhalterController; +use Finanzmanager\Controllers\TermineController; +use Finanzmanager\Controllers\VertraegeController; use Finanzmanager\Controllers\VerwaltungController; +use Finanzmanager\Controllers\VorlagenController; +use Finanzmanager\Controllers\ZahlungsverkehrController; +use Finanzmanager\Controllers\WertpapiereController; use Finanzmanager\Models\Einstellung; use Finanzmanager\Router; use Finanzmanager\Services\DebugLog; @@ -155,16 +160,135 @@ $router->post('/auswertungen/gruppen/{id}', $router->post('/auswertungen/gruppen/{id}/loeschen', fn(array $p) => (new AuswertungenController())->gruppeLoeschen($p)); +// ── Buchungsvorlagen ──────────────────────────────────────────────────────── +$router->get('/vorlagen', fn() => (new VorlagenController())->index()); +$router->post('/vorlagen/neu', fn() => (new VorlagenController())->anlegen()); +$router->post('/vorlagen/aus-buchung', fn() => (new VorlagenController())->ausBuchung()); +$router->post('/vorlagen/{id}', fn(array $p) => (new VorlagenController())->speichern($p)); +$router->post('/vorlagen/{id}/buchen', fn(array $p) => (new VorlagenController())->buchen($p)); +$router->post('/vorlagen/{id}/loeschen', fn(array $p) => (new VorlagenController())->loeschen($p)); + +// ── Termine: regelmäßige Buchungen, Kalender, Erinnerungen ────────────────── +$router->get('/termine', fn() => (new TermineController())->index()); +$router->post('/termine/neu', fn() => (new TermineController())->anlegen()); +$router->post('/termine/lauf', fn() => (new TermineController())->lauf()); +$router->get('/termine/kalender', fn() => (new TermineController())->kalender()); + +$router->get('/termine/erinnerungen', fn() => (new TermineController())->erinnerungen()); +$router->post('/termine/erinnerungen/neu', fn() => (new TermineController())->erinnerungAnlegen()); +$router->post('/termine/erinnerungen/{id}/erledigen', + fn(array $p) => (new TermineController())->erinnerungErledigen($p)); +$router->post('/termine/erinnerungen/{id}/loeschen', + fn(array $p) => (new TermineController())->erinnerungLoeschen($p)); + +$router->post('/termine/{id}', fn(array $p) => (new TermineController())->speichern($p)); +$router->post('/termine/{id}/buchen', fn(array $p) => (new TermineController())->buchen($p)); +$router->post('/termine/{id}/ueberspringen', + fn(array $p) => (new TermineController())->ueberspringen($p)); +$router->post('/termine/{id}/loeschen', fn(array $p) => (new TermineController())->loeschen($p)); + +// ── Verträge, Adressen, Sparziele, Kredite ────────────────────────────────── +$router->get('/vertraege', fn() => (new VertraegeController())->index()); +$router->post('/vertraege/neu', fn() => (new VertraegeController())->anlegen()); + +$router->get('/vertraege/adressen', fn() => (new VertraegeController())->adressen()); +$router->post('/vertraege/adressen/neu', fn() => (new VertraegeController())->adresseAnlegen()); +$router->post('/vertraege/adressen/{id}', + fn(array $p) => (new VertraegeController())->adresseSpeichern($p)); +$router->post('/vertraege/adressen/{id}/loeschen', + fn(array $p) => (new VertraegeController())->adresseLoeschen($p)); + +$router->get('/vertraege/sparziele', fn() => (new VertraegeController())->sparziele()); +$router->post('/vertraege/sparziele/neu', fn() => (new VertraegeController())->sparzielAnlegen()); +$router->post('/vertraege/sparziele/{id}', + fn(array $p) => (new VertraegeController())->sparzielSpeichern($p)); +$router->post('/vertraege/sparziele/{id}/loeschen', + fn(array $p) => (new VertraegeController())->sparzielLoeschen($p)); + +$router->get('/vertraege/kredite', fn() => (new VertraegeController())->kredite()); +$router->post('/vertraege/kredite/neu', fn() => (new VertraegeController())->kreditAnlegen()); +$router->post('/vertraege/kredite/{id}', + fn(array $p) => (new VertraegeController())->kreditSpeichern($p)); +$router->post('/vertraege/kredite/{id}/loeschen', + fn(array $p) => (new VertraegeController())->kreditLoeschen($p)); + +$router->post('/vertraege/{id}', fn(array $p) => (new VertraegeController())->speichern($p)); +$router->post('/vertraege/{id}/loeschen', fn(array $p) => (new VertraegeController())->loeschen($p)); + +// ── Zahlungsverkehr ───────────────────────────────────────────────────────── +$router->get('/zahlungsverkehr', fn() => (new ZahlungsverkehrController())->index()); +$router->get('/zahlungsverkehr/neu', fn() => (new ZahlungsverkehrController())->neu()); +$router->post('/zahlungsverkehr/neu', fn() => (new ZahlungsverkehrController())->anlegen()); + +$router->get('/zahlungsverkehr/tan', fn() => (new ZahlungsverkehrController())->tanFormular()); +$router->post('/zahlungsverkehr/tan', fn() => (new ZahlungsverkehrController())->tanEinreichen()); +$router->post('/zahlungsverkehr/tan/abbrechen', fn() => (new ZahlungsverkehrController())->tanAbbrechen()); + +$router->get('/zahlungsverkehr/{id}', fn(array $p) => (new ZahlungsverkehrController())->bearbeiten($p)); +$router->post('/zahlungsverkehr/{id}', fn(array $p) => (new ZahlungsverkehrController())->speichern($p)); +$router->post('/zahlungsverkehr/{id}/senden', fn(array $p) => (new ZahlungsverkehrController())->senden($p)); +$router->post('/zahlungsverkehr/{id}/stornieren', + fn(array $p) => (new ZahlungsverkehrController())->stornieren($p)); +$router->post('/zahlungsverkehr/{id}/loeschen', fn(array $p) => (new ZahlungsverkehrController())->loeschen($p)); + +// ── Wertpapiere: Bestände, Depotbuchungen, Kurse, Auswertungen ────────────── +$router->get('/wertpapiere', fn() => (new WertpapiereController())->index()); +$router->get('/wertpapiere/auswertung', fn() => (new WertpapiereController())->auswertung()); + +$router->get('/wertpapiere/buchungen', fn() => (new WertpapiereController())->buchungen()); +$router->get('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->neu()); +$router->post('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->anlegen()); +$router->post('/wertpapiere/buchungen/{id}/loeschen', + fn(array $p) => (new WertpapiereController())->loeschen($p)); + +$router->get('/wertpapiere/stammdaten', fn() => (new WertpapiereController())->stammdaten()); +$router->post('/wertpapiere/stammdaten/{id}', + fn(array $p) => (new WertpapiereController())->speichern($p)); +$router->post('/wertpapiere/stammdaten/{id}/kurs', + fn(array $p) => (new WertpapiereController())->kursSetzen($p)); +$router->post('/wertpapiere/kurse', fn() => (new WertpapiereController())->kurseAbrufen()); + +// ── Online-Banking: Zugänge, Abruf, Abgleich, Regeln ──────────────────────── +$router->get('/banking', fn() => (new BankingController())->index()); +$router->get('/banking/neu', fn() => (new BankingController())->neu()); +$router->post('/banking/neu', fn() => (new BankingController())->anlegen()); + +// Die festen Pfade stehen vor „/banking/{id}“, sonst schluckt das Muster auch +// „/banking/umsaetze“ und „/banking/regeln“. +$router->get('/banking/umsaetze', fn() => (new BankingController())->umsaetze()); +$router->post('/banking/umsaetze/alle', fn() => (new BankingController())->alleUebernehmen()); +$router->post('/banking/umsaetze/{id}/uebernehmen', + fn(array $p) => (new BankingController())->uebernehmen($p)); +$router->post('/banking/umsaetze/{id}/verwerfen', + fn(array $p) => (new BankingController())->verwerfen($p)); +$router->post('/banking/umsaetze/{id}/zuruecksetzen', + fn(array $p) => (new BankingController())->zuruecksetzen($p)); + +$router->get('/banking/regeln', fn() => (new BankingController())->regeln()); +$router->post('/banking/regeln/neu', fn() => (new BankingController())->regelAnlegen()); +$router->post('/banking/regeln/pruefen', fn() => (new BankingController())->regelnPruefen()); +$router->post('/banking/regeln/{id}', fn(array $p) => (new BankingController())->regelSpeichern($p)); +$router->post('/banking/regeln/{id}/loeschen', + fn(array $p) => (new BankingController())->regelLoeschen($p)); + +$router->get('/banking/tan', fn() => (new BankingController())->tanFormular()); +$router->post('/banking/tan', fn() => (new BankingController())->tanEinreichen()); +$router->post('/banking/tan/abbrechen', fn() => (new BankingController())->tanAbbrechen()); + +$router->get('/banking/{id}', fn(array $p) => (new BankingController())->bearbeiten($p)); +$router->post('/banking/{id}', fn(array $p) => (new BankingController())->speichern($p)); +$router->post('/banking/{id}/abrufen', fn(array $p) => (new BankingController())->abrufen($p)); +$router->post('/banking/{id}/loeschen', fn(array $p) => (new BankingController())->loeschen($p)); +$router->get('/banking/{id}/verfahren', fn(array $p) => (new BankingController())->verfahren($p)); +$router->post('/banking/{id}/verfahren', fn(array $p) => (new BankingController())->verfahrenWaehlen($p)); +$router->post('/banking/{id}/verfahren/abfragen', + fn(array $p) => (new BankingController())->verfahrenAbfragen($p)); + $router->get('/verwaltung/import', fn() => (new ImportController())->index()); $router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen()); $router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren()); $router->post('/verwaltung/import/{datei}/loeschen', fn(array $p) => (new ImportController())->loeschen($p)); -// Noch nicht gebaute Bereiche: sichtbar in der Navigation, mit Hinweis auf die Phase. -foreach (PlatzhalterController::pfade() as $bereich) { - $router->get('/' . $bereich, fn() => (new PlatzhalterController())->zeige($bereich)); -} - try { $router->dispatch(); } catch (\Throwable $e) { diff --git a/public/static/app.css b/public/static/app.css index d4ed617..39ee051 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -247,6 +247,10 @@ form.inline { display: inline; margin: 0; } .tab:hover { background: var(--flaeche-gedaempft); text-decoration: none; } +/* Schiebt einen Eintrag ans andere Ende der Leiste – etwa „Kacheln wählen“, das + kein Register ist, sondern ein Weg hinaus. */ +.tab.rechtsbuendig { margin-left: auto; } + .tab.aktiv { background: var(--flaeche); border-color: var(--rand); @@ -303,7 +307,10 @@ form.inline { display: inline; margin: 0; } font-weight: 600; } -.phasenmarke { +/* Kleine Marke: Zustand (Monatsabschluss), Dateiname (Import) oder der Schritt + eines mehrteiligen Dialogs (Einrichtung einer Bankverbindung). */ +.phasenmarke, +.schrittmarke { display: inline-block; background: var(--akzent-schwach); color: var(--akzent); @@ -466,6 +473,113 @@ form.inline { display: inline; margin: 0; } color: var(--text-gedaempft); } +.kachelfuss { margin-top: 1rem; } + +/* Einnahmen und Ausgaben tragen keine Seriennummer, sondern die Bedeutungsfarben + der Anwendung – grün und rot lesen sich hier ohne Legende. */ +.balken-einnahme { --farbe: var(--positiv); } +.balken-ausgabe { --farbe: var(--negativ); } +.saldofarbe { --farbe: var(--akzent); } + +.diagramm-saldo { + stroke: var(--akzent); + stroke-width: 1.5; + fill: none; +} + +.diagramm circle.diagramm-saldo-punkt { fill: var(--akzent); } + +/* ── Ringdiagramm ──────────────────────────────────────────────────────────── */ + +.ringbereich { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.ringdiagramm { + flex: 0 0 auto; + width: 160px; + height: 160px; +} + +/* Der schmale Rand in Flächenfarbe trennt benachbarte Segmente, ohne dass es + nach Umrandung aussieht. */ +.ringdiagramm path { + fill: var(--farbe); + stroke: var(--flaeche); + stroke-width: 1.5; +} + +.ringsumme { + fill: var(--text); + font-family: var(--schrift-zahl); + font-size: 13px; + font-weight: 600; + text-anchor: middle; +} + +.ringtext { + fill: var(--text-gedaempft); + font-family: var(--schrift); + font-size: 10px; +} + +.ringlegende { + list-style: none; + margin: 0; + padding: 0; + flex: 1 1 12rem; + min-width: 0; + font-size: .78rem; +} + +.ringlegende li { + display: grid; + grid-template-columns: .65rem 1fr auto auto; + align-items: center; + gap: .45rem; + padding: .15rem 0; +} + +.ringlegende .legendenbetrag { + font-family: var(--schrift-zahl); + font-size: .74rem; +} + +.ringlegende .legendenanteil { + color: var(--text-gedaempft); + font-size: .72rem; + min-width: 2.4rem; + text-align: right; +} + +/* ── Offene Vorgänge ───────────────────────────────────────────────────────── */ + +.vorgangsliste { + list-style: none; + margin: 0; + padding: 0; + font-size: .92rem; +} + +.vorgangsliste li { padding: .3rem 0; border-bottom: 1px solid var(--rand); } +.vorgangsliste li:last-child { border-bottom: none; } + +.vorgangszahl { + display: inline-block; + min-width: 1.6rem; + padding: .05rem .4rem; + margin-right: .4rem; + border-radius: 999px; + background: var(--akzent-schwach); + color: var(--akzent); + font-family: var(--schrift-zahl); + font-size: .8rem; + text-align: center; +} + /* ── Ein/Aus-Schalter ──────────────────────────────────────────────────────── Die Bahn mit dem laufenden Knopf, wie man ihn von Mobilgeräten kennt. Ein @@ -926,6 +1040,14 @@ table.splittabelle input.rechts { text-align: right; } margin: .15rem 0 0; } +/* Der Hinweis hinter einem Ankreuzfeld statt darunter – bei einer Liste + gleichartiger Schalter hielte eine eigene Zeile je Hinweis sie zu weit + auseinander. */ +.feldhinweis.inline { + margin: 0; + font-weight: 400; +} + .optional { font-weight: 400; color: var(--text-gedaempft); } .knopfleiste { @@ -1241,6 +1363,252 @@ table.bericht .wertlink:hover { .zeilenform input.schmal { width: 5rem; } +/* ── Online-Banking ────────────────────────────────────────────────────────── + + Die Abgleichliste ist keine Tabelle: Zu jedem Umsatz gehören mehrere Wege + (verbinden, neu buchen, verwerfen), jeder mit eigenem Formular. In Zellen + gepresst wäre das unlesbar; hier bekommt jeder Umsatz eine eigene Karte. */ + +.umsatz { + background: var(--flaeche); + border: 1px solid var(--rand); + border-radius: var(--radius); + padding: .75rem .9rem; + margin-bottom: .6rem; +} + +.umsatzkopf { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.umsatztext { min-width: 0; } + +.umsatztext .nebentext { display: block; font-size: .8rem; } + +.verwendungszweck { + margin: .3rem 0 0; + font-size: .88rem; + color: var(--text-gedaempft); + overflow-wrap: anywhere; +} + +.umsatzbetrag { + font-family: var(--schrift-zahl); + font-size: 1.05rem; + font-weight: 600; + white-space: nowrap; +} + +.umsatzaktion { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: .4rem .6rem; + margin-top: .5rem; + padding-top: .5rem; + border-top: 1px dashed var(--rand); +} + +.umsatzaktion select { max-width: 16rem; } + +.vorschlagsmarke { + background: var(--akzent-schwach); + color: var(--akzent); + font-size: .75rem; + font-weight: 600; + padding: .1rem .5rem; + border-radius: 999px; + white-space: nowrap; +} + +/* Statusmarken der Abrufliste. Sie erben von .marke und färben nur den Rand – + ein voll gefüllter Punkt zöge in einer Tabelle zu viel Aufmerksamkeit. */ +.marke-erfolg { color: var(--positiv); border-color: var(--positiv); } +.marke-fehler { color: var(--negativ); border-color: var(--negativ); } +.marke-tan_offen { color: var(--warnung); border-color: var(--warnung); } +.marke-abgebrochen { color: var(--text-gedaempft); } + +.tanhinweis { + background: var(--flaeche-gedaempft); + border-left: 3px solid var(--akzent); + padding: .5rem .7rem; + margin: .5rem 0; + font-size: .93rem; +} + +/* photoTAN-Grafiken liefern die Banken klein; unskaliert wären sie kaum zu + scannen, größer als die Kachel dürfen sie aber auch nicht werden. */ +.tanbild { + display: block; + max-width: 100%; + width: 240px; + height: auto; + margin: .75rem 0; + image-rendering: pixelated; + background: #fff; + padding: .5rem; + border-radius: var(--radius); +} + +.protokolltext { + margin: 0; + padding: .6rem .7rem; + background: var(--flaeche-gedaempft); + border-radius: var(--radius); + font-family: var(--schrift-zahl); + font-size: .78rem; + white-space: pre-wrap; + overflow-x: auto; +} + +.feldpaar { + display: flex; + gap: .5rem; +} + +.regeltabelle select, +.regeltabelle input[type="text"] { max-width: 12rem; } + +/* ── Wertpapiere ─────────────────────────────────────────────────────────── */ + +/* Die Kennzahlen über der Depotübersicht: Wert, Einstand, Gewinn, Erträge – + nebeneinander, aber umbrechend, damit sie auch auf schmalen Fenstern lesbar + bleiben. */ +.kennzahlenleiste { + display: flex; + flex-wrap: wrap; + gap: .75rem; + margin-bottom: 1.25rem; +} + +.kennzahlkachel { + flex: 1 1 9rem; + background: var(--flaeche); + border: 1px solid var(--rand); + border-radius: var(--radius); + padding: .6rem .8rem; + display: flex; + flex-direction: column; + gap: .1rem; +} + +.kennzahltitel { + font-size: .78rem; + color: var(--text-gedaempft); +} + +.kennzahlkachel .kennzahl { + font-size: 1.25rem; + margin: 0; +} + +/* Zahlenlastige Tabellen bekommen Ziffernschrift in den rechtsbündigen + Spalten – so stehen Kommastellen untereinander. */ +table.liste.zahlen td.rechts { font-family: var(--schrift-zahl); } + +.wertflaeche { + fill: var(--akzent); + opacity: .18; + stroke: none; +} + +.wertlinie { + fill: none; + stroke: var(--akzent); + stroke-width: 2; +} + +/* Der Einstand als gestrichelte Linie: Er ist die Vergleichsgröße, nicht der + Hauptverlauf. */ +.einstandslinie { + fill: none; + stroke: var(--text-gedaempft); + stroke-width: 1.5; + stroke-dasharray: 4 3; +} + +.diagramm circle.wertpunkt { fill: var(--akzent); } + +.wertfarbe { --farbe: var(--akzent); } +.einstandsfarbe { --farbe: var(--text-gedaempft); } + +/* ── Termine, Verträge, Sparziele ────────────────────────────────────────── */ + +/* Was überfällig ist, wird nicht rot eingefärbt, sondern bekommt eine Kante: + Eine ganze Zeile in Signalfarbe liest sich schlechter, nicht dringender. */ +table.liste tr.ueberfaellig td:first-child { + border-left: 3px solid var(--negativ); + padding-left: calc(.6rem - 3px); +} + +.kalendermonat { margin-bottom: 1.5rem; } + +.kalendermonat h3 { + margin: 0 0 .4rem; + font-size: .95rem; + color: var(--text-gedaempft); +} + +.kalendertag { + width: 3.5rem; + white-space: nowrap; +} + +.tagzahl { + font-family: var(--schrift-zahl); + font-size: 1.1rem; + font-weight: 600; + margin-right: .3rem; +} + +table.liste tr.luecke td { + text-align: center; + font-style: italic; + background: var(--flaeche-gedaempft); +} + +/* statt eines eigenen Balkens: Die Breite käme sonst nur über ein + style-Attribut zustande, und das verbietet die Content-Security-Policy. */ +.fortschritt { + display: block; + width: 100%; + height: .75rem; + margin: .5rem 0 .3rem; + border: none; + border-radius: 999px; + background: var(--flaeche-gedaempft); + appearance: none; +} + +.fortschritt::-webkit-progress-bar { + background: var(--flaeche-gedaempft); + border-radius: 999px; +} + +.fortschritt::-webkit-progress-value { + background: var(--positiv); + border-radius: 999px; +} + +.fortschritt::-moz-progress-bar { + background: var(--positiv); + border-radius: 999px; +} + +.fortschrittstext { + margin: 0 0 .5rem; + font-size: .95rem; +} + +.marke-entwurf { color: var(--warnung); border-color: var(--warnung); } +.marke-gesendet { color: var(--positiv); border-color: var(--positiv); } +.marke-storniert { color: var(--text-gedaempft); } + +.kennzahl.klein { font-size: 1rem; } + /* ── Schmale Fenster ─────────────────────────────────────────────────────── */ @media (max-width: 820px) { diff --git a/public/static/app.js b/public/static/app.js index 79e4fd8..f682b89 100644 --- a/public/static/app.js +++ b/public/static/app.js @@ -148,6 +148,32 @@ }); } + // Empfängerauswahl im Zahlungsverkehr: Die Adresse füllt Name, IBAN und BIC. + // Geändert werden darf danach alles – die Auswahl ist eine Abkürzung, keine + // Bindung. + const adressauswahl = document.querySelector('[data-adressauswahl]'); + if (adressauswahl) { + adressauswahl.addEventListener('change', function () { + const gewaehlt = adressauswahl.selectedOptions[0]; + if (!gewaehlt || !gewaehlt.value) { + return; + } + + const felder = { + empfaenger: gewaehlt.dataset.name, + iban: gewaehlt.dataset.iban, + bic: gewaehlt.dataset.bic + }; + + Object.keys(felder).forEach(function (name) { + const feld = document.getElementById(name); + if (feld && felder[name]) { + feld.value = felder[name]; + } + }); + }); + } + // Meldungen nach kurzer Zeit ausblenden – Fehler bleiben stehen. // Über eine Klasse, nicht über element.style: Die Content-Security-Policy // (style-src 'self') verwirft auch per JavaScript gesetzte Inline-Styles, diff --git a/scripts/kursabruf.php b/scripts/kursabruf.php new file mode 100644 index 0000000..3263d5e --- /dev/null +++ b/scripts/kursabruf.php @@ -0,0 +1,68 @@ + 0 ? 1 : 0); diff --git a/src/Config.php b/src/Config.php index bff919a..ff447c2 100644 --- a/src/Config.php +++ b/src/Config.php @@ -17,7 +17,10 @@ class Config 'db_name' => self::env('DB_NAME', 'finanzmanager'), 'db_user' => self::env('DB_USER', 'finanzmanager'), 'db_pass' => self::env('DB_PASS', 'finanzmanager'), - 'app_key' => self::env('APP_KEY', ''), + // Ohne APP_KEY in der Umgebung greift der beim ersten Containerstart + // erzeugte Schlüssel unter data/. Beides leer heißt: keine + // verschlüsselten Felder – Bankzugänge lassen sich dann nicht anlegen. + 'app_key' => self::env('APP_KEY', '') ?: self::schluesseldatei($wurzel . '/data/schluessel/app_key'), 'debug_log' => self::env('DEBUG_LOG', '0') === '1', 'log_dir' => $wurzel . '/data/logs', 'import_dir' => $wurzel . '/data/import', @@ -41,4 +44,14 @@ class Config $wert = $_ENV[$key] ?? getenv($key); return ($wert === false || $wert === '') ? $default : (string)$wert; } + + /** Der vom Entrypoint erzeugte Schlüssel; fehlt die Datei, bleibt es leer. */ + private static function schluesseldatei(string $pfad): string + { + if (!is_readable($pfad)) { + return ''; + } + + return trim((string)@file_get_contents($pfad)); + } } diff --git a/src/Controllers/BankingController.php b/src/Controllers/BankingController.php new file mode 100644 index 0000000..529f125 --- /dev/null +++ b/src/Controllers/BankingController.php @@ -0,0 +1,622 @@ +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; + } + + // Weiter zum zweiten Schritt: Erst jetzt – mit gespeicherten Zugangsdaten – + // lässt sich die Bank nach ihren TAN-Verfahren fragen. + View::redirect( + '/banking/' . $id . '/verfahren?einrichtung=1', + 'Zugangsdaten gespeichert. Jetzt das TAN-Verfahren wählen.' + ); + } + + /** @param array $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 $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 $params */ + public function loeschen(array $params): void + { + $this->verlangeAnmeldung(); + + Bankzugang::loeschen((int)$params['id']); + + View::redirect('/banking', 'Bankverbindung gelöscht.'); + } + + // ── TAN-Verfahren ──────────────────────────────────────────────────────── + + /** + * Der zweite Schritt der Einrichtung: das TAN-Verfahren. + * + * Die Liste wird beim Aufruf **selbst geholt** – erst mit gespeicherten + * Zugangsdaten lässt sich die Bank fragen, und genau dann steht der Nutzer + * hier. Ein eigener Knopf davor wäre ein Schritt, dessen Ergebnis ohnehin + * feststeht. + * + * Das Ergebnis bleibt in der Sitzung, auch ein gescheitertes: Sonst liefe + * jeder Seitenaufruf erneut in dieselbe Zeitüberschreitung. + * + * @param array $params + */ + public function verfahren(array $params): void + { + $this->verlangeAnmeldung(); + + $id = (int)$params['id']; + $zugang = Bankzugang::find($id); + if ($zugang === null) { + View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler'); + return; + } + + $abfrage = $this->gemerkteAbfrage($id) ?? $this->verfahrenHolen($id); + + $this->render('banking/verfahren.php', [ + 'titel' => 'TAN-Verfahren – ' . $zugang['name'], + 'zugang' => $zugang, + 'medium' => Crypto::entschluessle((string)($zugang['tan_medium'] ?? '')), + 'verfahren' => $abfrage['liste'], + 'fehler' => $abfrage['status'] === 'fehler' ? $abfrage['meldung'] : '', + 'einrichtung' => $this->query('einrichtung') === '1', + ]); + } + + /** + * Ein erneuter Anlauf, wenn der erste scheiterte oder sich etwas geändert hat. + * + * @param array $params + */ + public function verfahrenAbfragen(array $params): void + { + $this->verlangeAnmeldung(); + + $id = (int)$params['id']; + $abfrage = $this->verfahrenHolen($id); + $ziel = '/banking/' . $id . '/verfahren' . ($this->post('einrichtung') === '1' ? '?einrichtung=1' : ''); + + View::redirect($ziel, $abfrage['meldung'], $abfrage['status'] === 'fehler' ? 'fehler' : 'erfolg'); + } + + /** @param array $params */ + public function verfahrenWaehlen(array $params): void + { + $this->verlangeAnmeldung(); + + $id = (int)$params['id']; + $verfahren = (int)$this->post('verfahren_id'); + $abfrage = $this->gemerkteAbfrage($id); + + try { + FinTsVerfahren::uebernehmen( + $id, + $verfahren, + // Das Medium des angekreuzten Verfahrens, nicht das irgendeiner + // anderen Zeile. + $this->post('tan_medium_' . $verfahren), + $abfrage === null ? [] : $abfrage['liste'] + ); + } catch (\Throwable $e) { + View::redirect('/banking/' . $id . '/verfahren', $e->getMessage(), 'fehler'); + return; + } + + View::redirect( + '/banking/' . $id, + $this->post('einrichtung') === '1' + ? 'Bankverbindung eingerichtet. Der erste Abruf ermittelt jetzt die Konten.' + : 'TAN-Verfahren übernommen.' + ); + } + + /** + * Fragt die Bank und merkt sich das Ergebnis für diese Sitzung. + * + * @return array{status: string, meldung: string, liste: list>} + */ + private function verfahrenHolen(int $zugangId): array + { + $ergebnis = FinTsVerfahren::abfragen($zugangId); + + $abfrage = [ + 'status' => $ergebnis['status'], + 'meldung' => $ergebnis['meldung'], + 'liste' => $ergebnis['verfahren'], + ]; + + Auth::startSession(); + $_SESSION[self::SITZUNG_VERFAHREN] = ['zugang_id' => $zugangId] + $abfrage; + + return $abfrage; + } + + /** + * Die zuletzt abgefragte Liste – aber nur die zu diesem Zugang. + * + * @return array{status: string, meldung: string, liste: list>}|null + */ + private function gemerkteAbfrage(int $zugangId): ?array + { + Auth::startSession(); + $gemerkt = $_SESSION[self::SITZUNG_VERFAHREN] ?? null; + + if (!is_array($gemerkt) || (int)($gemerkt['zugang_id'] ?? 0) !== $zugangId) { + return null; + } + + return [ + 'status' => (string)($gemerkt['status'] ?? 'fehler'), + 'meldung' => (string)($gemerkt['meldung'] ?? ''), + 'liste' => is_array($gemerkt['liste'] ?? null) ? $gemerkt['liste'] : [], + ]; + } + + // ── Abruf ──────────────────────────────────────────────────────────────── + + /** @param array $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 $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 $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 $params */ + public function verwerfen(array $params): void + { + $this->verlangeAnmeldung(); + + Bankumsatz::verwerfen((int)$params['id']); + + View::redirect('/banking/umsaetze', 'Umsatz verworfen.'); + } + + /** @param array $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 $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 $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 */ + 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 */ + 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> + */ + 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; + } +} diff --git a/src/Controllers/CockpitController.php b/src/Controllers/CockpitController.php index c5956d0..abe2df5 100644 --- a/src/Controllers/CockpitController.php +++ b/src/Controllers/CockpitController.php @@ -6,6 +6,7 @@ namespace Finanzmanager\Controllers; use Finanzmanager\Models\Einstellung; use Finanzmanager\Models\Konto; +use Finanzmanager\Services\Cockpit; use Finanzmanager\Services\Vermoegensentwicklung; use Finanzmanager\View; @@ -24,23 +25,43 @@ class CockpitController extends BaseController $this->verlangeAnmeldung(); $nurAktive = Einstellung::getBool(self::SCHALTER_NUR_AKTIVE, true); + $sichtbar = Cockpit::sichtbar(); // Die Ebene steht in der Adresse, nicht in den Einstellungen: Wer in die // „Übrigen Konten“ hineinsieht, will beim nächsten Aufruf wieder oben // anfangen – und kann den Blick trotzdem als Lesezeichen ablegen. $ebene = max(0, (int)$this->query('ebene')); - $this->render('cockpit/index.php', [ - 'titel' => 'Cockpit', - 'uebersicht' => Konto::uebersicht(), - 'vermoegen' => Vermoegensentwicklung::reihe( + // Jede Kachel kostet Abfragen. Abgeschaltete werden deshalb gar nicht + // erst berechnet, statt nur ungezeichnet zu bleiben. + $daten = [ + 'titel' => 'Cockpit', + 'sichtbar' => $sichtbar, + 'nurAktive' => $nurAktive, + ]; + + if (in_array('vermoegen', $sichtbar, true)) { + $daten['vermoegen'] = Vermoegensentwicklung::reihe( $nurAktive, Vermoegensentwicklung::MONATE, null, $ebene - ), - 'nurAktive' => $nurAktive, - ]); + ); + } + if (in_array('einnahmen_ausgaben', $sichtbar, true)) { + $daten['monatsreihe'] = Cockpit::monatsreihe(); + } + if (in_array('ausgaben', $sichtbar, true)) { + $daten['ausgaben'] = Cockpit::ausgaben(); + } + if (in_array('kontostaende', $sichtbar, true)) { + $daten['uebersicht'] = Konto::uebersicht(); + } + if (in_array('offene_vorgaenge', $sichtbar, true)) { + $daten['vorgaenge'] = Cockpit::offeneVorgaenge(); + } + + $this->render('cockpit/index.php', $daten); } public function kontenfilter(): void diff --git a/src/Controllers/EinstellungenController.php b/src/Controllers/EinstellungenController.php index 87baca1..93c871b 100644 --- a/src/Controllers/EinstellungenController.php +++ b/src/Controllers/EinstellungenController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Finanzmanager\Controllers; use Finanzmanager\Models\Einstellung; +use Finanzmanager\Services\Cockpit; use Finanzmanager\Services\DebugLog; use Finanzmanager\View; @@ -29,6 +30,7 @@ class EinstellungenController extends BaseController 'bereich' => $bereich, 'werte' => Einstellung::alle(), 'logDateien' => $this->logDateien(), + 'kacheln' => Cockpit::sichtbar(), ]); } @@ -51,6 +53,16 @@ class EinstellungenController extends BaseController Einstellung::set($schluessel, $wert, $bereich); } + // Die Kachelauswahl ist eine Mehrfachauswahl und passt nicht in das + // Schlüssel-Wert-Raster von felderFuer(): Sie kommt als Liste an und + // wird als Liste gespeichert. + if ($bereich === 'darstellung') { + $gewaehlt = $_POST[Cockpit::EINSTELLUNG] ?? []; + Cockpit::setzeSichtbar( + is_array($gewaehlt) ? array_map('strval', array_filter($gewaehlt, 'is_scalar')) : [] + ); + } + if ($bereich === 'diagnose') { DebugLog::setAktiv(Einstellung::getBool('debug_log_aktiv')); } @@ -85,7 +97,17 @@ class EinstellungenController extends BaseController 'allgemein' => ['mandant_name' => 'text', 'waehrung' => 'text'], 'darstellung' => ['darstellung_thema' => 'text', 'zeilen_pro_seite' => 'text'], 'buchhaltung' => ['erster_monat' => 'text'], + 'banking' => [ + 'banking_regeln_beim_abruf' => 'bool', + 'banking_abgleich_tage' => 'text', + 'banking_produkt_id' => 'text', + ], 'import' => ['import_qif_verzeichnis' => 'text', 'import_stichtag' => 'text'], + 'wertpapiere' => [ + 'wertpapiere_kursdienst' => 'text', + 'wertpapiere_api_key' => 'text', + 'wertpapiere_kursabruf_aktiv' => 'bool', + ], 'diagnose' => ['debug_log_aktiv' => 'bool'], default => [], }; diff --git a/src/Controllers/ImportController.php b/src/Controllers/ImportController.php index 315f0c8..ae1f927 100644 --- a/src/Controllers/ImportController.php +++ b/src/Controllers/ImportController.php @@ -8,6 +8,7 @@ use Finanzmanager\Models\Einstellung; use Finanzmanager\Services\QifBuchungsImport; use Finanzmanager\Services\QifParser; use Finanzmanager\Services\QifStammdatenImport; +use Finanzmanager\Services\QifVorlagenImport; use Finanzmanager\View; /** @@ -31,6 +32,7 @@ class ImportController extends BaseController 'verzeichnis' => $this->verzeichnisAnzeige(), 'ergebnis' => null, 'buchungen' => null, + 'vorlagen' => null, 'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'), 'bis' => date('Y-m-d'), 'datei' => '', @@ -91,9 +93,15 @@ class ImportController extends BaseController // nicht angelegt, deshalb bleibt es dort bei der Stammdatenvorschau, // sobald die Konten fehlen. $buchungen = null; + $vorlagen = null; if (!$this->post('nur_stammdaten')) { $buchungen = (new QifBuchungsImport($parser, ['stichtag' => $stichtag, 'bis' => $bis])) ->ausfuehren($trockenlauf); + + // Buchungsvorlagen stehen in derselben Datei (!Type:Memorized) + // und kommen gleich mit – ein eigener Durchgang wäre nur ein + // zweiter Knopf für dieselbe Datei. + $vorlagen = (new QifVorlagenImport($parser))->ausfuehren($trockenlauf); } } catch (\Throwable $e) { View::redirect('/verwaltung/import', 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler'); @@ -106,6 +114,7 @@ class ImportController extends BaseController 'verzeichnis' => $this->verzeichnisAnzeige(), 'ergebnis' => $ergebnis + ['abschnitte' => $parser->zusammenfassung()], 'buchungen' => $buchungen, + 'vorlagen' => $vorlagen, 'stichtag' => $stichtag, 'bis' => $bis, 'datei' => $name, diff --git a/src/Controllers/PlatzhalterController.php b/src/Controllers/PlatzhalterController.php deleted file mode 100644 index 5f66366..0000000 --- a/src/Controllers/PlatzhalterController.php +++ /dev/null @@ -1,50 +0,0 @@ - [ - '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 Alle Pfade, die dieser Controller bedient. */ - public static function pfade(): array - { - return array_keys(self::BEREICHE); - } -} diff --git a/src/Controllers/TermineController.php b/src/Controllers/TermineController.php new file mode 100644 index 0000000..97a4763 --- /dev/null +++ b/src/Controllers/TermineController.php @@ -0,0 +1,208 @@ +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 $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 $params */ + public function loeschen(array $params): void + { + $this->verlangeAnmeldung(); + + RegelmaessigeBuchung::loeschen((int)$params['id']); + + View::redirect('/termine', 'Regelmäßige Buchung gelöscht.'); + } + + /** @param array $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 $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 $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 $params */ + public function erinnerungLoeschen(array $params): void + { + $this->verlangeAnmeldung(); + + Erinnerung::loeschen((int)$params['id']); + + View::redirect('/termine/erinnerungen', 'Erinnerung gelöscht.'); + } + + /** @return array */ + 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, + ]; + } +} diff --git a/src/Controllers/VertraegeController.php b/src/Controllers/VertraegeController.php new file mode 100644 index 0000000..7522021 --- /dev/null +++ b/src/Controllers/VertraegeController.php @@ -0,0 +1,343 @@ +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 $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 $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 $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 $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 $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 $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 $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 $params */ + public function kreditLoeschen(array $params): void + { + $this->verlangeAnmeldung(); + + Kredit::loeschen((int)$params['id']); + + View::redirect('/vertraege/kredite', 'Kredit gelöscht.'); + } + + // ── Formulardaten ──────────────────────────────────────────────────────── + + /** @return array */ + 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 */ + 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 */ + 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 */ + 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, + ]; + } +} diff --git a/src/Controllers/VorlagenController.php b/src/Controllers/VorlagenController.php new file mode 100644 index 0000000..2660b5f --- /dev/null +++ b/src/Controllers/VorlagenController.php @@ -0,0 +1,125 @@ +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 $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 $params */ + public function loeschen(array $params): void + { + $this->verlangeAnmeldung(); + + Buchungsvorlage::loeschen((int)$params['id']); + + View::redirect('/vorlagen', 'Vorlage gelöscht.'); + } + + /** @param array $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 */ + 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'), + ]; + } +} diff --git a/src/Controllers/WertpapiereController.php b/src/Controllers/WertpapiereController.php new file mode 100644 index 0000000..a18952f --- /dev/null +++ b/src/Controllers/WertpapiereController.php @@ -0,0 +1,250 @@ +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 $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 $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 $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); + } +} diff --git a/src/Controllers/ZahlungsverkehrController.php b/src/Controllers/ZahlungsverkehrController.php new file mode 100644 index 0000000..8bcd1f8 --- /dev/null +++ b/src/Controllers/ZahlungsverkehrController.php @@ -0,0 +1,222 @@ +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 $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 $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 $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 $params */ + public function stornieren(array $params): void + { + $this->verlangeAnmeldung(); + + Zahlungsauftrag::stornieren((int)$params['id']); + + View::redirect('/zahlungsverkehr', 'Überweisung storniert.'); + } + + // ── Absenden ───────────────────────────────────────────────────────────── + + /** @param array $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 $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 */ + 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'), + ]; + } +} diff --git a/src/Models/AbgleichRegel.php b/src/Models/AbgleichRegel.php new file mode 100644 index 0000000..2351701 --- /dev/null +++ b/src/Models/AbgleichRegel.php @@ -0,0 +1,344 @@ + '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> */ + 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 $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 $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 $umsatz + * @param list>|null $regeln vorgeladene Regeln, sonst werden sie geholt + * @return array|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 $regel + * @param array $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 $regel + * @param array $umsatz + * @return array{kategorie_id: ?int, klasse_id: ?int, gegenkonto_id: ?int, splits: list>} + */ + 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 $regel + * @param array $umsatz + * @return list + */ + 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 $daten + * @return array + */ + 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 $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); + } +} diff --git a/src/Models/Adresse.php b/src/Models/Adresse.php new file mode 100644 index 0000000..d05fa2b --- /dev/null +++ b/src/Models/Adresse.php @@ -0,0 +1,173 @@ +> */ + 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 $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 $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 $daten + * @return array + */ + 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'] ?? '')), + ]; + } +} diff --git a/src/Models/Bankabruf.php b/src/Models/Bankabruf.php new file mode 100644 index 0000000..9df47d7 --- /dev/null +++ b/src/Models/Bankabruf.php @@ -0,0 +1,104 @@ + $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> */ + 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] + ); + } +} diff --git a/src/Models/Bankumsatz.php b/src/Models/Bankumsatz.php new file mode 100644 index 0000000..09c75df --- /dev/null +++ b/src/Models/Bankumsatz.php @@ -0,0 +1,317 @@ + '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 $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 $filter status, zugang_id, konto_id + * @return list> + */ + 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 $umsatz + * @param list>|null $regeln + * @return array{ + * art: string, + * buchung: array|null, + * regel: array|null, + * zuordnung: array|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 $umsatz + * @return array|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 $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()]); + } +} diff --git a/src/Models/Bankzugang.php b/src/Models/Bankzugang.php new file mode 100644 index 0000000..806c2c0 --- /dev/null +++ b/src/Models/Bankzugang.php @@ -0,0 +1,330 @@ + 'Seit dem letzten Abruf', + 'sieben_tage' => 'Letzte 7 Tage', + 'dreissig_tage' => 'Letzte 30 Tage', + 'jahr' => 'Letzte 365 Tage', + ]; + + /** @return list> */ + 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|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 $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_verfahren_name, 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_verfahren_name'], $felder['tan_medium'], + $felder['abrufzeitraum'], $felder['aktiv'], + ] + ); + + return Database::lastInsertId(); + } + + /** + * @param array $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']; + } + + // Dasselbe für das TAN-Verfahren: Es steht nicht im Zugangsformular, + // sondern wird eigens abgefragt. Ohne diesen Vorbehalt setzte jedes + // Speichern die Wahl zurück. + foreach (['tan_verfahren_id', 'tan_verfahren_name', 'tan_medium'] as $feld) { + if (!array_key_exists($feld, $daten)) { + $felder[$feld] = $bisher[$feld]; + } + } + + Database::execute( + 'UPDATE bank_zugaenge SET name = ?, bankname = ?, blz = ?, fints_url = ?, + benutzerkennung = ?, pin = ?, tan_verfahren_id = ?, tan_verfahren_name = ?, + 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_verfahren_name'], $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 = '', + string $bezeichnung = '' + ): void { + Database::execute( + 'UPDATE bank_zugaenge + SET tan_verfahren_id = ?, tan_verfahren_name = ?, tan_medium = ? + WHERE id = ? AND benutzer_id = ?', + [ + $verfahren, + mb_substr($bezeichnung, 0, 128), + 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 $daten + * @return array + */ + 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_verfahren_name' => mb_substr(trim((string)($daten['tan_verfahren_name'] ?? '')), 0, 128), + '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> */ + 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()] + ); + } +} diff --git a/src/Models/Buchungsvorlage.php b/src/Models/Buchungsvorlage.php new file mode 100644 index 0000000..d1560ad --- /dev/null +++ b/src/Models/Buchungsvorlage.php @@ -0,0 +1,274 @@ +> */ + 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 $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 $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 $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 $vorlage + * @return list> + */ + 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 $daten + * @return array + */ + 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'), + ]; + } +} diff --git a/src/Models/Depotbuchung.php b/src/Models/Depotbuchung.php new file mode 100644 index 0000000..bc8781c --- /dev/null +++ b/src/Models/Depotbuchung.php @@ -0,0 +1,328 @@ + '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 $filter konto_id, wertpapier_id, von, bis, vorgang + * @return list> + */ + 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 $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 $daten + * @return array + */ + 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, + }; + } +} diff --git a/src/Models/Erinnerung.php b/src/Models/Erinnerung.php new file mode 100644 index 0000000..ec08380 --- /dev/null +++ b/src/Models/Erinnerung.php @@ -0,0 +1,89 @@ +> */ + 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> */ + 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 + ); + } +} diff --git a/src/Models/Konto.php b/src/Models/Konto.php index 6c79282..5926d30 100644 --- a/src/Models/Konto.php +++ b/src/Models/Konto.php @@ -182,13 +182,14 @@ class Konto { $aktuell = Buchung::saldenJeKonto(date('Y-m-d')); $erwartet = Buchung::saldenJeKonto(); + $depots = \Finanzmanager\Services\Depot::werteJeDepot(); $gruppen = []; $summe = 0.0; $summeOnline = 0.0; foreach (self::alle($nurAktive) as $konto) { - $konto = self::mitSalden($konto, $aktuell, $erwartet); + $konto = self::mitSalden($konto, $aktuell, $erwartet, $depots); $name = $konto['gruppenname'] ?? null; $name = ($name === null || $name === '') ? Kontengruppe::OHNE_GRUPPE : $name; @@ -222,16 +223,36 @@ class Konto * @param array|null $erwartet * @return array */ - public static function mitSalden(array $konto, ?array $aktuell = null, ?array $erwartet = null): array - { + public static function mitSalden( + array $konto, + ?array $aktuell = null, + ?array $erwartet = null, + ?array $depotwerte = null + ): array { $id = (int)$konto['id']; $anfang = (float)$konto['anfangssaldo']; $aktuell ??= Buchung::saldenJeKonto(date('Y-m-d')); $erwartet ??= Buchung::saldenJeKonto(); + // Ein Depot hat keinen Saldo aus Buchungen, sondern einen bewerteten + // Bestand: Stückzahlen mal Kurs. Der Anfangssaldo bleibt trotzdem + // dabei – dort steht bei Depots die Barreserve, wenn das Konto eine + // führt (siehe docs/wertpapiere.md). + if ((string)$konto['kontoart'] === 'depot') { + $depotwerte ??= \Finanzmanager\Services\Depot::werteJeDepot(); + $wert = $anfang + ($depotwerte[$id] ?? 0.0); + + $konto['saldo_aktuell'] = $wert; + $konto['saldo_erwartet'] = $wert; + $konto['bewertet'] = true; + + return $konto; + } + $konto['saldo_aktuell'] = $anfang + ($aktuell[$id] ?? 0.0); $konto['saldo_erwartet'] = $anfang + ($erwartet[$id] ?? 0.0); + $konto['bewertet'] = false; return $konto; } diff --git a/src/Models/Kredit.php b/src/Models/Kredit.php new file mode 100644 index 0000000..ebf589e --- /dev/null +++ b/src/Models/Kredit.php @@ -0,0 +1,279 @@ + 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> */ + 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 $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 $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 $kredit + * @return array{ + * zeilen: list>, + * 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 $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 $daten + * @return array + */ + 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, + ]; + } +} diff --git a/src/Models/RegelmaessigeBuchung.php b/src/Models/RegelmaessigeBuchung.php new file mode 100644 index 0000000..ddf1c2f --- /dev/null +++ b/src/Models/RegelmaessigeBuchung.php @@ -0,0 +1,336 @@ + '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> */ + 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> + */ + 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 $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 $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 $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 $reihe + * @return list 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 $daten + * @return array + */ + 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, + ]; + } +} diff --git a/src/Models/Sparziel.php b/src/Models/Sparziel.php new file mode 100644 index 0000000..97ed918 --- /dev/null +++ b/src/Models/Sparziel.php @@ -0,0 +1,158 @@ +> */ + 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 $ziel + * @return array + */ + 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 $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 $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 $daten + * @return array + */ + 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, + ]; + } +} diff --git a/src/Models/Vertrag.php b/src/Models/Vertrag.php new file mode 100644 index 0000000..e1ab5bc --- /dev/null +++ b/src/Models/Vertrag.php @@ -0,0 +1,187 @@ +> */ + 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 $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 $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> + */ + 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 $daten + * @return array + */ + 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, + ]; + } +} diff --git a/src/Models/Wertpapier.php b/src/Models/Wertpapier.php new file mode 100644 index 0000000..88ed377 --- /dev/null +++ b/src/Models/Wertpapier.php @@ -0,0 +1,297 @@ + '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> */ + 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|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 $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 $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 $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|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 + */ + 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> */ + 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 $daten + * @return array + */ + 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()] + ); + } + } +} diff --git a/src/Models/Zahlungsauftrag.php b/src/Models/Zahlungsauftrag.php new file mode 100644 index 0000000..d238166 --- /dev/null +++ b/src/Models/Zahlungsauftrag.php @@ -0,0 +1,230 @@ + 'Entwurf', + 'gesendet' => 'gesendet', + 'fehler' => 'fehlgeschlagen', + 'storniert' => 'storniert', + ]; + + /** @return list> */ + 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 $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 $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 $daten + * @return array + */ + 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), + ]; + } +} diff --git a/src/Services/Auswertung.php b/src/Services/Auswertung.php index b193b5f..1d0005e 100644 --- a/src/Services/Auswertung.php +++ b/src/Services/Auswertung.php @@ -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 $filter roh oder normalisiert + * @param int $grenze So viele Segmente einzeln, der Rest wird gesammelt. + * @return array{ + * art: string, + * zeilen: list, + * 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 $summen + * @param array{vorgabe:string, von:string, bis:string} $zeitraum + * @return array + */ + 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 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 ──────────────────────────────────────────────────────── /** diff --git a/src/Services/Cockpit.php b/src/Services/Cockpit.php new file mode 100644 index 0000000..c769320 --- /dev/null +++ b/src/Services/Cockpit.php @@ -0,0 +1,284 @@ + [ + '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 + */ + 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 $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, + * einnahmen: list, + * ausgaben: list, + * salden: list, + * 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 + */ + 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 + */ + 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; + } +} diff --git a/src/Services/Crypto.php b/src/Services/Crypto.php new file mode 100644 index 0000000..6c0909f --- /dev/null +++ b/src/Services/Crypto.php @@ -0,0 +1,123 @@ + $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 + ); + } +} diff --git a/src/Services/Depot.php b/src/Services/Depot.php new file mode 100644 index 0000000..1e50ae1 --- /dev/null +++ b/src/Services/Depot.php @@ -0,0 +1,326 @@ +>, + * 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 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> */ + 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>, 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>, 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 + */ + 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> + */ + 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); + } +} diff --git a/src/Services/Faelligkeitslauf.php b/src/Services/Faelligkeitslauf.php new file mode 100644 index 0000000..4d82cc8 --- /dev/null +++ b/src/Services/Faelligkeitslauf.php @@ -0,0 +1,114 @@ +>, + * gesperrt: list, + * protokoll: list + * } + */ + 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, + ]; + } +} diff --git a/src/Services/FinTsAbruf.php b/src/Services/FinTsAbruf.php new file mode 100644 index 0000000..03f60a8 --- /dev/null +++ b/src/Services/FinTsAbruf.php @@ -0,0 +1,687 @@ + */ + private array $protokoll = []; + + private FinTs $fints; + + /** @param array $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, + * neu: int, uebersprungen: int, tan?: array} + */ + 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, + * neu: int, uebersprungen: int, tan?: array} + */ + 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|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 + */ + 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 */ + 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 + */ + 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 $bankkonto + * @param list $auszuege + * @param list> $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|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|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 das Verfahren noch nicht im Dialog – es kommt + // aus der Einstellung des Zugangs (siehe `Services\FinTsVerfahren`). + if (!isset($this->zustand['dialog'])) { + FinTsVerfahren::anwenden($fints, $this->zugang, fn(string $zeile) => $this->notiere($zeile)); + } + + return $fints; + } + + /** + * Die Konten dieses Zugangs, die abgerufen werden sollen. + * + * @return list> + */ + 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 $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 $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 $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|null $tan + * @return array + */ + 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 $protokoll + * @param array|null $tan + * @return array + */ + 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|null */ + private static function zustandLesen(): ?array + { + Auth::startSession(); + $zustand = $_SESSION[self::SITZUNG] ?? null; + + return is_array($zustand) ? $zustand : null; + } + + /** @param array $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]); + } +} diff --git a/src/Services/FinTsUeberweisung.php b/src/Services/FinTsUeberweisung.php new file mode 100644 index 0000000..27091b0 --- /dev/null +++ b/src/Services/FinTsUeberweisung.php @@ -0,0 +1,372 @@ + $zugang mit entschlüsselter PIN + * @param array $zustand + */ + private function __construct( + private array $zugang, + private int $auftragId, + private array $zustand + ) { + } + + /** + * Startet das Absenden. + * + * @return array{status: string, meldung: string, tan?: array} + */ + 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} + */ + 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|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 */ + 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 $auftrag + * @return array + */ + 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 $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 */ + 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 */ + 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()); + + // Dieselbe Wahl wie beim Umsatzabruf – eine Überweisung soll nicht + // plötzlich ein anderes Verfahren verlangen als der Abruf. + if (!isset($this->zustand['dialog'])) { + FinTsVerfahren::anwenden( + $fints, + $this->zugang, + static fn(string $zeile) => DebugLog::schreibe('fints', $zeile) + ); + } + + return $fints; + } + + /** @return array|null */ + private static function zustandLesen(): ?array + { + Auth::startSession(); + $zustand = $_SESSION[self::SITZUNG] ?? null; + + return is_array($zustand) ? $zustand : null; + } + + /** @param array $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]); + } +} diff --git a/src/Services/FinTsVerfahren.php b/src/Services/FinTsVerfahren.php new file mode 100644 index 0000000..9d49dbf --- /dev/null +++ b/src/Services/FinTsVerfahren.php @@ -0,0 +1,295 @@ +, + * hinweis: string}>} + */ + public static function abfragen(int $zugangId): array + { + $zugang = Bankzugang::zugangsdaten($zugangId); + if ($zugang === null) { + return ['status' => 'fehler', 'meldung' => 'Den Bankzugang gibt es nicht.', 'verfahren' => []]; + } + + foreach (['blz' => 'Bankleitzahl', 'fints_url' => 'FinTS-Adresse', + 'benutzerkennung' => 'Benutzerkennung', 'pin' => 'PIN'] as $feld => $name) { + if (trim((string)$zugang[$feld]) === '') { + return [ + 'status' => 'fehler', + 'meldung' => 'Es fehlt: ' . $name . '. Ohne vollständige Zugangsdaten ' + . 'lässt sich die Bank nicht fragen.', + 'verfahren' => [], + ]; + } + } + + try { + $fints = self::verbindung($zugang); + $modi = $fints->getTanModes(); + } catch (\Throwable $e) { + DebugLog::fehler($e); + + return [ + 'status' => 'fehler', + 'meldung' => 'Die Bank antwortet nicht wie erwartet: ' . $e->getMessage(), + 'verfahren' => [], + ]; + } + + if ($modi === []) { + return [ + 'status' => 'fehler', + 'meldung' => 'Die Bank meldet keine TAN-Verfahren für diese Benutzerkennung.', + 'verfahren' => [], + ]; + } + + $verfahren = []; + foreach ($modi as $modus) { + $verfahren[] = self::beschreibe($fints, $modus); + } + + try { + $fints->close(); + } catch (\Throwable) { + // Der Dialog ist ohnehin am Ende; ein Fehler beim Abmelden ändert + // an der Liste nichts. + } + + return [ + 'status' => 'erfolg', + 'meldung' => count($verfahren) === 1 + ? 'Die Bank bietet ein Verfahren an.' + : sprintf('Die Bank bietet %d Verfahren an.', count($verfahren)), + 'verfahren' => $verfahren, + ]; + } + + /** + * Übernimmt die Wahl des Nutzers. + * + * Geprüft wird gegen die Liste, die gerade abgefragt wurde: Eine ID, die die + * Bank nicht angeboten hat, würde beim nächsten Abruf stillschweigend + * verworfen – dann lieber jetzt eine Meldung. + * + * @param list> $bekannt Ergebnis von abfragen()['verfahren'] + */ + public static function uebernehmen(int $zugangId, int $verfahrenId, string $medium, array $bekannt): void + { + $treffer = null; + foreach ($bekannt as $eintrag) { + if ((int)$eintrag['id'] === $verfahrenId) { + $treffer = $eintrag; + break; + } + } + + if ($treffer === null) { + throw new \InvalidArgumentException( + 'Dieses Verfahren steht nicht in der abgefragten Liste. Bitte neu abfragen.' + ); + } + + $medien = array_map('strval', $treffer['medien'] ?? []); + + if ($treffer['medium_noetig'] === true) { + if ($medium === '') { + throw new \InvalidArgumentException( + 'Das Verfahren „' . $treffer['name'] . '“ braucht ein TAN-Medium.' + ); + } + if ($medien !== [] && !in_array($medium, $medien, true)) { + throw new \InvalidArgumentException('Dieses TAN-Medium hat die Bank nicht genannt.'); + } + } else { + // Ein Medium ohne Not gespeichert würde die Bibliothek beim nächsten + // Dialog mitschicken – manche Banken lehnen das ab. + $medium = ''; + } + + Bankzugang::merkeTanVerfahren($zugangId, $verfahrenId, $medium, (string)$treffer['name']); + } + + /** + * Stellt das Verfahren für einen laufenden Dialog ein. + * + * Wird von `FinTsAbruf` und `FinTsUeberweisung` benutzt, damit beide dieselbe + * Wahl treffen. Ist am Zugang nichts hinterlegt, entscheidet das erste + * gemeldete Verfahren – und diese Notwahl wird gespeichert, damit sie im + * Formular sichtbar ist. Eine **ausdrückliche** Wahl wird dagegen nie + * überschrieben: Bietet die Bank sie gerade nicht an, sagt das Protokoll das, + * und die Einstellung bleibt stehen. + * + * @param array $zugang Zugang mit entschlüsseltem TAN-Medium + * @param callable(string):void $notiere Protokollzeile schreiben + */ + public static function anwenden(FinTs $fints, array $zugang, callable $notiere): void + { + $modi = $fints->getTanModes(); + if ($modi === []) { + throw new \RuntimeException('Die Bank meldet keine TAN-Verfahren.'); + } + + $gewuenscht = $zugang['tan_verfahren_id'] === null ? null : (int)$zugang['tan_verfahren_id']; + + $gewaehlt = null; + foreach ($modi as $modus) { + if ($gewuenscht !== null && $modus->getId() === $gewuenscht) { + $gewaehlt = $modus; + break; + } + } + + $abweichung = $gewuenscht !== null && $gewaehlt === null; + $gewaehlt ??= reset($modi); + + if ($abweichung) { + $notiere(sprintf( + 'Das eingestellte TAN-Verfahren (%s) bietet die Bank gerade nicht an – es wird %s benutzt.', + (string)$zugang['tan_verfahren_name'] !== '' + ? $zugang['tan_verfahren_name'] . ', ID ' . $gewuenscht + : 'ID ' . $gewuenscht, + $gewaehlt->getName() + )); + } + + $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)($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); + + $notiere('TAN-Verfahren: ' . $gewaehlt->getName() + . ($mediumName !== '' ? ' (' . $mediumName . ')' : '')); + + // Nur die Notwahl wird festgehalten – eine ausdrückliche bleibt, wie sie ist. + if ($gewuenscht === null) { + Bankzugang::merkeTanVerfahren( + (int)$zugang['id'], + $gewaehlt->getId(), + $mediumName, + $gewaehlt->getName() + ); + } + } + + // ── Innereien ──────────────────────────────────────────────────────────── + + /** + * @return array{id: int, name: string, entkoppelt: bool, medium_noetig: bool, + * medien: list, hinweis: string} + */ + private static function beschreibe(FinTs $fints, TanMode $modus): array + { + $medien = []; + $hinweis = ''; + + if ($modus->needsTanMedium()) { + try { + foreach ($fints->getTanMedia($modus) as $medium) { + $medien[] = (string)$medium->getName(); + } + } catch (\Throwable $e) { + // Ein Verfahren, dessen Medien die Bank nicht herausgibt, bleibt + // in der Liste – der Nutzer soll sehen, dass es existiert. + $hinweis = 'Medien nicht abrufbar: ' . $e->getMessage(); + } + } + + if ($modus->isDecoupled()) { + $hinweis = $hinweis === '' + ? 'Freigabe in der Banking-App, keine TAN einzugeben.' + : $hinweis; + } + + return [ + 'id' => $modus->getId(), + 'name' => $modus->getName(), + 'entkoppelt' => $modus->isDecoupled(), + 'medium_noetig' => $modus->needsTanMedium(), + 'medien' => $medien, + 'hinweis' => $hinweis, + ]; + } + + /** @param array $zugang */ + private static function verbindung(array $zugang): FinTs + { + $optionen = new FinTsOptions(); + $optionen->url = (string)$zugang['fints_url']; + $optionen->bankCode = (string)$zugang['blz']; + $optionen->productName = Einstellung::get('banking_produkt_id') ?: self::PRODUKT; + $optionen->productVersion = self::PRODUKT_VERSION; + + // Kürzer als die Vorgabe der Bibliothek (15/30): Die Abfrage hängt an + // einem Seitenaufruf, und eine Bank, die nicht antwortet, soll die Seite + // nicht minutenlang aufhalten. + $optionen->timeoutConnect = 8; + $optionen->timeoutResponse = 20; + + $fints = FinTs::new( + $optionen, + Credentials::create((string)$zugang['benutzerkennung'], (string)$zugang['pin']) + ); + $fints->setLogger(new DebugLogAdapter()); + + return $fints; + } +} diff --git a/src/Services/Finanzkalender.php b/src/Services/Finanzkalender.php new file mode 100644 index 0000000..3d083c9 --- /dev/null +++ b/src/Services/Finanzkalender.php @@ -0,0 +1,137 @@ + '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 + */ + 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>}> + */ + 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); + } +} diff --git a/src/Services/Kursabruf.php b/src/Services/Kursabruf.php new file mode 100644 index 0000000..4c55ef0 --- /dev/null +++ b/src/Services/Kursabruf.php @@ -0,0 +1,307 @@ + '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>|null $wertpapiere sonst alle aktiven mit Bestand + * @return array{abgerufen: int, fehler: int, uebersprungen: int, protokoll: list} + */ + 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> + */ + 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 $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 $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 $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 + */ + 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 $daten + * @param list $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 $protokoll + * @return array{abgerufen: int, fehler: int, uebersprungen: int, protokoll: list} + */ + private static function ergebnis(int $abgerufen, int $fehler, int $uebersprungen, array $protokoll): array + { + return [ + 'abgerufen' => $abgerufen, + 'fehler' => $fehler, + 'uebersprungen' => $uebersprungen, + 'protokoll' => $protokoll, + ]; + } +} diff --git a/src/Services/QifBuchungsImport.php b/src/Services/QifBuchungsImport.php index 8e21584..4be1616 100644 --- a/src/Services/QifBuchungsImport.php +++ b/src/Services/QifBuchungsImport.php @@ -6,9 +6,11 @@ namespace Finanzmanager\Services; use Finanzmanager\Database; use Finanzmanager\Models\Buchung; +use Finanzmanager\Models\Depotbuchung; use Finanzmanager\Models\Kategorie; use Finanzmanager\Models\Klasse; use Finanzmanager\Models\Konto; +use Finanzmanager\Models\Wertpapier; /** * Übernimmt die Buchungen eines QIF-Exports. @@ -123,11 +125,11 @@ class QifBuchungsImport if ($eintrag['typ'] === 'Invst') { // Vor dem Stichtag zählt vom Wertpapiergeschäft nur der - // Geldfluss auf dem Verrechnungskonto. - $ziel = self::umbuchungsziel(QifParser::feld($eintrag, 'L')); - $wert = QifParser::feld($eintrag, '$'); - if ($ziel !== null && $wert !== null) { - $bucheAuf($ziel, -QifParser::betrag($wert)); + // Geldfluss auf dem Verrechnungskonto. Die Bestände davor + // stecken im Depot, nicht im Anfangssaldo. + $ziel = self::verrechnungskonto(QifParser::feld($eintrag, 'L')); + if ($ziel !== null) { + $bucheAuf($ziel, self::geldflussInvst($eintrag)); } continue; } @@ -215,8 +217,9 @@ class QifBuchungsImport } if ($zaehler['wertpapier'] > 0) { - $this->melde('hinweis', $zaehler['wertpapier'] . ' Wertpapiergeschäfte: übernommen wurde nur der ' - . 'Geldfluss auf dem Verrechnungskonto. Bestände und Kurse folgen in Phase 6.'); + $this->melde('hinweis', $zaehler['wertpapier'] . ' Wertpapiergeschäfte übernommen – ' + . 'je Geschäft eine Depotbuchung mit Stück und Kurs sowie die Geldbuchung auf dem ' + . 'Verrechnungskonto. Kurse zur Bewertung holt der Kursabruf.'); } return $zaehler; @@ -275,26 +278,58 @@ class QifBuchungsImport * bliebe ein Betrag auf dem Depot ohne Gegenwert und würde die Summe der * Kontostände verfälschen. */ + /** + * Ein Wertpapiergeschäft (`!Type:Invst`). + * + * Es entstehen zwei Datensätze: die Depotbuchung mit Stück und Kurs und – + * wenn ein Verrechnungskonto genannt ist – die Geldbuchung dort. Beide legt + * `Depotbuchung::anlegen()` in einem Zug an. + * + * Das Depot ist der `!Account`-Abschnitt, in dem der Eintrag steht; das + * Verrechnungskonto steht in `L|[Konto]` (dem eine Kategorie vorangehen + * kann, etwa `LKursgewinne:Realisierte Gewinne|[Verrechnungskonto]`). + */ private function wertpapierbuchung(array $eintrag, string $datum, bool $trockenlauf): ?int { - $ziel = self::umbuchungsziel(QifParser::feld($eintrag, 'L')); - $wert = QifParser::feld($eintrag, '$'); - if ($ziel === null || $wert === null) { - return null; + $depotName = (string)$eintrag['konto']; + $depotId = $this->kontoId($depotName); + if ($depotId === null) { + throw new \RuntimeException('Unbekanntes Depot „' . $depotName . '“.'); } - $kontoId = $this->kontoId($ziel); - if ($kontoId === null) { - throw new \RuntimeException('Unbekanntes Verrechnungskonto „' . $ziel . '“.'); + $vorgangQif = trim((string)QifParser::feld($eintrag, 'N')); + $vorgang = Depotbuchung::vorgangAusQif($vorgangQif); + $name = trim((string)QifParser::feld($eintrag, 'Y')); + + if ($name === '') { + throw new \RuntimeException('Wertpapiergeschäft ohne Wertpapiernamen.'); } - $betrag = -QifParser::betrag($wert); - $text = (string)QifParser::feld($eintrag, 'M'); - $vorgang = (string)QifParser::feld($eintrag, 'N'); - $papier = (string)QifParser::feld($eintrag, 'Y'); + $isin = trim((string)QifParser::feld($eintrag, '@')); + $stueck = QifParser::betrag(QifParser::feld($eintrag, 'Q')); + $kurs = QifParser::betrag(QifParser::feld($eintrag, 'I')); - $hash = $this->eindeutigerHash($kontoId, $datum, $betrag, $vorgang, $text); - if (Buchung::findeNachHash($hash) !== null) { + [$gebuehren, $steuern] = self::nebenkosten(QifParser::feld($eintrag, 'O')); + + // Der Bruttowert ist Stück mal Kurs. Fehlt der Kurs – bei Dividenden + // immer –, wird er aus dem gebuchten Betrag zurückgerechnet: Feld U ist + // dort die Gutschrift nach Abzug der Steuern. + $gebucht = QifParser::betrag(QifParser::feld($eintrag, 'U')); + $betrag = $stueck > 0.0 && $kurs > 0.0 + ? round($stueck * $kurs, 2) + : round(abs($gebucht) + $gebuehren + $steuern, 2); + + $verrechnung = self::verrechnungskonto(QifParser::feld($eintrag, 'L')); + $verrechnungId = $verrechnung === null ? null : $this->kontoId($verrechnung); + if ($verrechnung !== null && $verrechnungId === null) { + throw new \RuntimeException('Unbekanntes Verrechnungskonto „' . $verrechnung . '“.'); + } + + $geld = QifParser::feld($eintrag, '$'); + $geld = $geld === null ? 0.0 : QifParser::betrag($geld); + + $hash = Depotbuchung::hash($depotId, $datum, $vorgang, $stueck, $betrag, $isin ?: $name); + if (Depotbuchung::findeNachHash($hash) !== null) { return null; } @@ -302,18 +337,104 @@ class QifBuchungsImport return 0; } - return Buchung::anlegen([ - 'konto_id' => $kontoId, - 'datum' => $datum, - 'valuta' => QifParser::datum(QifParser::feld($eintrag, 'V'), $this->parser->datumsformat()), - 'betrag' => $betrag, - 'empfaenger' => $papier !== '' ? $papier : (string)$eintrag['konto'], - 'buchungstext' => trim($vorgang . ' ' . $text), - 'nummer' => '', - 'abgeglichen' => QifParser::feld($eintrag, 'C') !== null, - 'quelle' => 'qif', - 'import_hash' => $hash, + $text = trim((string)QifParser::feld($eintrag, 'M')); + + $wertpapierId = Wertpapier::findenOderAnlegen([ + 'name' => $name, + 'wkn' => (string)QifParser::feld($eintrag, '~'), + 'isin' => $isin, + 'art' => Wertpapier::artAusQif((string)QifParser::feld($eintrag, '&')), + 'waehrung' => (string)QifParser::feld($eintrag, 'F') ?: 'EUR', ]); + + return Depotbuchung::anlegen([ + 'konto_id' => $depotId, + 'wertpapier_id' => $wertpapierId, + 'datum' => $datum, + 'valuta' => QifParser::datum(QifParser::feld($eintrag, 'V'), $this->parser->datumsformat()), + 'vorgang' => $vorgang, + 'vorgang_qif' => $vorgangQif, + 'stueck' => $stueck, + 'kurs' => $kurs > 0.0 ? $kurs : null, + 'betrag' => $betrag, + 'gebuehren' => $gebuehren, + 'steuern' => $steuern, + 'waehrung' => (string)QifParser::feld($eintrag, 'F') ?: 'EUR', + 'verrechnungskonto_id' => $verrechnungId, + 'geldbetrag' => $geld, + 'notiz' => $text, + 'wertpapiername' => $name, + 'abgeglichen' => QifParser::feld($eintrag, 'C') !== null, + 'quelle' => 'qif', + 'import_hash' => $hash, + // Der Hash der Geldseite entspricht dem, den frühere Importe ohne + // Depotmodul vergeben haben – so entsteht im Kontoblatt keine + // zweite Buchung für dasselbe Geschäft. + 'buchung_hash' => $verrechnungId === null ? null : $this->eindeutigerHash( + $verrechnungId, + $datum, + -$geld, + $vorgangQif, + $text + ), + ]); + } + + /** + * Der Geldfluss eines Wertpapiergeschäfts auf dem Verrechnungskonto. + * + * Das QIF nennt im Feld `$` immer einen positiven Betrag – die Richtung + * steckt allein im Vorgang: Ein Kauf nimmt Geld weg, Verkauf und Dividende + * bringen welches. Ein- und Auslieferungen rühren kein Konto an. + */ + private static function geldflussInvst(array $eintrag): float + { + $wert = QifParser::feld($eintrag, '$') ?? QifParser::feld($eintrag, 'U'); + if ($wert === null) { + return 0.0; + } + + $betrag = abs(QifParser::betrag($wert)); + $vorgang = Depotbuchung::vorgangAusQif(trim((string)QifParser::feld($eintrag, 'N'))); + + return match ($vorgang) { + 'kauf' => -$betrag, + 'verkauf', 'dividende' => $betrag, + default => 0.0, + }; + } + + /** + * Das QIF-Feld `O` führt zwölf durch „|“ getrennte Beträge. + * + * Sicher zugeordnet sind die vierte (Provision) und die zehnte Position + * (fremde Spesen) – beides Gebühren. Alles Übrige sind Abzüge des Fiskus + * (Kapitalertragsteuer, Solidaritätszuschlag, Quellensteuer). Statt jede + * Position einzeln zu deuten, werden sie zu „Steuern“ zusammengefasst: Für + * Einstand und Erlös zählt die Summe, und keine Position fällt heraus. + * + * @return array{0: float, 1: float} Gebühren und Steuern + */ + private static function nebenkosten(?string $feld): array + { + if ($feld === null || trim($feld) === '') { + return [0.0, 0.0]; + } + + $werte = array_map( + static fn(string $wert): float => QifParser::betrag($wert), + explode('|', $feld) + ); + + $gebuehren = ($werte[3] ?? 0.0) + ($werte[9] ?? 0.0); + $steuern = 0.0; + foreach ($werte as $nummer => $wert) { + if ($nummer !== 3 && $nummer !== 9) { + $steuern += $wert; + } + } + + return [round($gebuehren, 2), round($steuern, 2)]; } /** @@ -427,6 +548,26 @@ class QifBuchungsImport return $ende === false ? null : substr($angabe, 1, $ende - 1); } + /** + * Das Verrechnungskonto eines Wertpapiergeschäfts. + * + * Bei `!Type:Invst` steht in `L` mehr als bei einer gewöhnlichen Buchung: + * Dem Konto kann eine Kategorie vorangehen, getrennt durch einen senkrechten + * Strich – ein Verkauf trägt dort etwa + * `LKursgewinne:Realisierte Gewinne|[Verrechnungskonto]`. Deshalb zählt hier + * allein der Teil hinter dem letzten Strich. + */ + public static function verrechnungskonto(?string $angabe): ?string + { + if ($angabe === null) { + return null; + } + + $strich = strrpos($angabe, '|'); + + return self::umbuchungsziel($strich === false ? $angabe : substr($angabe, $strich)); + } + /** * Identische Buchungen am selben Tag kommen vor (zweimal dasselbe im selben * Laden). Der laufende Zähler hält sie auseinander und bleibt über Läufe diff --git a/src/Services/QifVorlagenImport.php b/src/Services/QifVorlagenImport.php new file mode 100644 index 0000000..d8aadb7 --- /dev/null +++ b/src/Services/QifVorlagenImport.php @@ -0,0 +1,220 @@ + */ + private array $konten = []; + /** @var array */ + private array $kategorien = []; + /** @var array */ + private array $klassen = []; + + /** @var list */ + private array $meldungen = []; + + public function __construct(QifParser $parser) + { + $this->parser = $parser; + } + + /** + * @return array{neu: int, vorhanden: int, fehler: int, meldungen: list>} + */ + 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 $eintrag + * @param array $namen schon vergebene Namen mit Zähler + * @return array|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 $eintrag + * @return list> + */ + 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]; + } + } +} diff --git a/src/Services/SepaXml.php b/src/Services/SepaXml.php new file mode 100644 index 0000000..a1c66bc --- /dev/null +++ b/src/Services/SepaXml.php @@ -0,0 +1,187 @@ +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)); + } +} diff --git a/src/View.php b/src/View.php index ef74832..fb70cb8 100644 --- a/src/View.php +++ b/src/View.php @@ -79,6 +79,24 @@ class View return $namen[$monat] ?? (string)$monat; } + /** Wochentagskürzel, Montag zuerst – wie MONATE nicht über date() oder intl. */ + public const WOCHENTAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; + + public static function wochentag(?string $datum): string + { + if (!$datum) { + return ''; + } + + $zeit = strtotime(substr($datum, 0, 10)); + if ($zeit === false) { + return ''; + } + + // date('N') zählt Montag als 1. + return self::WOCHENTAGE[(int)date('N', $zeit) - 1] ?? ''; + } + public static function datum(?string $datum): string { if (!$datum) { diff --git a/src/Views/banking/formular.php b/src/Views/banking/formular.php new file mode 100644 index 0000000..98fc411 --- /dev/null +++ b/src/Views/banking/formular.php @@ -0,0 +1,199 @@ +|null $zugang */ +/** @var list> $konten Bankkonten dieses Zugangs */ +/** @var list> $eigene Konten der Anwendung */ +/** @var list> $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'; +?> + + +

Schritt 1 von 2 · Zugangsdaten

+ + +
+ Zurück zu den Bankverbindungen + +
+ + +
+ +
+ +
+ + + + +

Frei wählbar, etwa „DKB“ oder „Sparkasse privat“.

+ + + + + + + + + +

+ Der HBCI/FinTS-Zugangspunkt der Bank – sie nennt ihn in ihrer Hilfe zum + Online-Banking. Muss mit https:// beginnen. +

+ + + + + + > +

+ Wird AES-verschlüsselt gespeichert (Schlüssel APP_KEY) und nie wieder + angezeigt. +

+ + + + + + + +

TAN-Verfahren

+

+ + + Noch nicht gewählt – beim nächsten Abruf entscheidet das erste Verfahren, + das die Bank meldet. + + + + + + Kennung + +

+

+ Die Wahl steht auf einer eigenen Seite, weil dafür die Bank gefragt werden muss: + TAN-Verfahren einstellen. +

+ +

Konten bei dieser Bank

+ +

+ Noch nicht bekannt – sie werden beim ersten Abruf ermittelt und stehen + danach hier zur Zuordnung bereit. +

+ +

+ Jedes Bankkonto braucht ein eigenes Konto, sonst lassen sich seine Umsätze + nicht buchen. Was nicht abgerufen werden soll, hier abwählen. +

+ + + + + + + + + + + + + + + +
Bei der BankEigenes KontoSaldoAbrufen
+ + + + + + + + –' + : View::betrag($bankkonto['saldo']) ?> + + +
+ + + + +

+ Nach dem Speichern fragt der Finanzmanager die Bank, welche TAN-Verfahren sie für + diese Benutzerkennung anbietet – ohne gespeicherte Zugangsdaten geht das nicht. +

+ + +
+ +
+
+ + + +

Abrufe dieser Verbindung

+ + + + + + + + + + + + + + +
BegonnenStatusNeuMeldung
+ + +
+ +
+ +
+
+ + + diff --git a/src/Views/banking/index.php b/src/Views/banking/index.php new file mode 100644 index 0000000..1a03708 --- /dev/null +++ b/src/Views/banking/index.php @@ -0,0 +1,119 @@ +> $zugaenge */ +/** @var list> $abrufe */ +/** @var int $offen */ +/** @var bool $schluessel */ +/** @var string $csrfToken */ + +include __DIR__ . '/../layout/kopf.php'; +?> + + + + +

+ APP_KEY fehlt. Ohne ihn lassen sich keine Zugangsdaten verschlüsselt + ablegen. Eine 64-stellige Hex-Zeichenkette in der Datei .env hinterlegen + (APP_KEY=…) und die Container neu starten. +

+ + + + + +

+ Noch keine Bankverbindung eingerichtet. Gebraucht werden Bankleitzahl, FinTS-Adresse, + Benutzerkennung und PIN – dieselben Angaben wie im Online-Banking der Bank. +

+ + + + + + + + + + + + + + > + + + + + + + + +
NameBankZeitraumZuletzt abgerufenAktion
+ + + BLZ + + + noch nie' + : View::h(date('d.m.Y H:i', strtotime((string)$zugang['zuletzt_am']))) ?> + +
+ + +
+ Bearbeiten +
+ + +

Letzte Abrufe

+ +

Noch kein Abruf gelaufen.

+ + + + + + + + + + + + + + + + + + + + +
BegonnenZugangStatusNeuBekanntMeldung
+ 'erfolgreich', + 'fehler' => 'fehlgeschlagen', + 'tan_offen' => 'wartet auf Freigabe', + 'abgebrochen' => 'abgebrochen', + default => 'läuft', + }) ?> +
+ + +

+ Abgerufene Umsätze werden nicht sofort gebucht: Sie landen in der Liste + Umsätze abgleichen, wo sie mit schon erfassten Buchungen verbunden, nach Regel + übernommen oder verworfen werden. +

+ + diff --git a/src/Views/banking/regeln.php b/src/Views/banking/regeln.php new file mode 100644 index 0000000..a839bcd --- /dev/null +++ b/src/Views/banking/regeln.php @@ -0,0 +1,204 @@ +> $regeln + * @var list> $kategorien + * @var list> $klassen + * @var list> $konten + * @var string $csrfToken + */ + +include __DIR__ . '/../layout/kopf.php'; +?> + + + +

+ 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. +

+ +
+
+ + +
+ Zum Abgleich +
+ + +

Noch keine Regel angelegt.

+ + +
+
+ + + + + + + + + + + + + + + > + + + + + + + + + + + + + +
NameFeldVergleichMusterKategorieKlassePrioTrefferAktivAktion
+ + + + + + + + + + + + + + + + + + +
+ + +
+ +

Neue Regel

+ + + + + + + + + + + + +

+ Groß- und Kleinschreibung spielt keine Rolle. Bei regulärer Ausdruck ohne + Trennzeichen eingeben, also Miete|Nebenkosten statt /Miete/. +

+ + +
+ + +
+

+ Mit Vorzeichen: Ausgaben sind negativ, „von −60 bis −40“ trifft Lastschriften + zwischen 40 und 60 Euro. +

+ + + + + + + + + + + + + + + +
+ +
+
+ + diff --git a/src/Views/banking/tan.php b/src/Views/banking/tan.php new file mode 100644 index 0000000..0bda396 --- /dev/null +++ b/src/Views/banking/tan.php @@ -0,0 +1,82 @@ + $tan + * @var string $meldung + * @var list $protokoll + * @var string $csrfToken + */ + +$entkoppelt = ($tan['entkoppelt'] ?? false) === true; + +include __DIR__ . '/../layout/kopf.php'; +?> + +
+
+

+

+
+ +

+ + +

+ + + +

TAN-Medium:

+ + + + + TAN-Grafik der Bank + + +
+ + + +

+ 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. +

+ +
+ +
+ + + +
+ +
+ +
+ +
+ +
+ +
+
+
+ + +
+

Bisheriger Verlauf

+

+
+
+ + + diff --git a/src/Views/banking/umsaetze.php b/src/Views/banking/umsaetze.php new file mode 100644 index 0000000..5104890 --- /dev/null +++ b/src/Views/banking/umsaetze.php @@ -0,0 +1,153 @@ +> $umsaetze + * @var list> $kategorien + * @var list> $klassen + * @var list> $konten + * @var int $offen + * @var string $csrfToken + */ + +include __DIR__ . '/../layout/kopf.php'; +?> + + + + +
+
+ + +
+ Regeln pflegen +
+ + + +

+ +

+ + + +
+
+
+ + + · + · +

+
+
+ +
+
+ + +
+ + , + zur Buchung + + +
+ + +
+
+ + + +
+ + + Vorschlag: verbinden + + mit der Buchung vom + über + + + +
+ + +
+ + + + Regel: + + + + + + + + + + + +
+ +
+ + +
+ +
+ + + + diff --git a/src/Views/banking/verfahren.php b/src/Views/banking/verfahren.php new file mode 100644 index 0000000..396b1bc --- /dev/null +++ b/src/Views/banking/verfahren.php @@ -0,0 +1,143 @@ + $zugang + * @var string $medium Das gespeicherte TAN-Medium im Klartext + * @var list, hinweis:string}> $verfahren + * @var string $fehler Meldung, wenn die Abfrage scheiterte + * @var bool $einrichtung Teil des Einrichtungsablaufs? + * @var string $csrfToken + */ + +$gewaehlt = $zugang['tan_verfahren_id'] === null ? null : (int)$zugang['tan_verfahren_id']; +$zugangId = (int)$zugang['id']; + +include __DIR__ . '/../layout/kopf.php'; +?> + + +

Schritt 2 von 2 · TAN-Verfahren

+ + +
+ + + +
+ + + +
+
+ + +

+ +

+

+ Meist stimmt eine Angabe im ersten Schritt nicht – Bankleitzahl, FinTS-Adresse, + Benutzerkennung oder PIN. + Zugangsdaten prüfen. + Die Wahl lässt sich auch später nachholen; ohne sie nimmt der erste Abruf das erste + Verfahren, das die Bank meldet. +

+ + + + +

Die Bank hat kein Verfahren genannt.

+ + +

+ Welche Verfahren zur Verfügung stehen, weiß nur die Bank – die Kennungen sind von + Haus zu Haus verschieden. Zu wählen ist das, mit dem du sonst arbeitest. +

+ +
+ + + + + + + + + $eintrag): ?> + + + + + + + + +
WahlVerfahrenKennungTAN-Medium
+ + > + + + + + + + + keines nötig + + von der Bank nicht genannt + + + + +
+ +
+ +
+
+ + + +

Zurzeit eingestellt

+ +

+ Noch nichts gewählt. Beim nächsten Abruf nimmt der Finanzmanager das erste + Verfahren, das die Bank meldet, und merkt es sich. +

+ +

+ + + + Kennung +

+ + + + diff --git a/src/Views/cockpit/ausgabendiagramm.php b/src/Views/cockpit/ausgabendiagramm.php new file mode 100644 index 0000000..330fcb3 --- /dev/null +++ b/src/Views/cockpit/ausgabendiagramm.php @@ -0,0 +1,111 @@ +, + * 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); +?> + +
+
+ + Ausgaben nach Kategorie + + + + + + + + + Ausgaben + + +
    + $zeile): ?> +
  • + + + + % +
  • + +
+
+
diff --git a/src/Views/cockpit/index.php b/src/Views/cockpit/index.php index 44d0619..ec4edcf 100644 --- a/src/Views/cockpit/index.php +++ b/src/Views/cockpit/index.php @@ -1,110 +1,200 @@ >, summe: float} $uebersicht */ -/** @var array{monate: list>, konten: list>, summen: list, hatWerte: bool} $vermoegen */ -/** @var bool $nurAktive */ -/** @var string $csrfToken */ +/** + * Das Cockpit zeichnet nur die Kacheln, die im Einstellungsdialog (Reiter + * Darstellung) angekreuzt sind – in der Reihenfolge des Katalogs. + * + * @var list $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|null $vorgaenge + */ include __DIR__ . '/../layout/kopf.php'; ?>
Cockpit + Kacheln wählen
-
-
-
-

Vermögensentwicklung

- - -
- - - -
- -

Kontostände der letzten zwölf Monate, je Monatsende - gestapelt – ein Farbton pro Konto.

-
- -

- Zeigt die Kontostände über die Zeit, sobald Konten - mit Σ-Kennzeichen Buchungen haben. -

- - - -
- -
-

Einnahmen und Ausgaben

+ +
+

Keine Kachel gewählt

- Der Monatsvergleich als Grafik folgt noch. Die Zahlen dazu stehen bereits im - Summenbericht – mit Spalten-Aufteilung nach Monat. + Im Einstellungsdialog unter Darstellung steht, welche Kacheln das + Cockpit zeigt. Zurzeit ist keine angekreuzt.

+ -
-

Kontostände

- -

- Noch keine Konten angelegt – einzeln oder gemeinsam aus dem QIF-Export. -

- - - - - - - - - - - - - - - - - -
- -
Summe - -
- +
+ + +
+
+

Vermögensentwicklung

+ + +
+ + + +
+ +

Kontostände der letzten zwölf Monate, je Monatsende + gestapelt – ein Farbton pro Konto.

+
+ +

+ Zeigt die Kontostände über die Zeit, sobald Konten + mit Σ-Kennzeichen Buchungen haben. +

+ + + +
+ + +
+
+

Einnahmen und Ausgaben

+

Monatsvergleich der letzten zwölf Monate, + die Linie ist der Saldo je Monat.

+
+ +

+ Zeigt Einnahmen und Ausgaben je Monat, sobald kategorisierte Buchungen + vorliegen. Umbuchungen zählen wie überall in den Auswertungen nicht mit. +

+ + + + +
+ + +
+
+

Meine Ausgaben

+

Laufendes Jahr, nach oberster Kategorie – + .

+
+ +

+ Zeigt die Verteilung der Ausgaben, sobald das laufende Jahr + kategorisierte Buchungen hat. +

+ + + + +
+ + +
+

Kontostände

+ +

+ Noch keine Konten angelegt – einzeln oder gemeinsam aus dem QIF-Export. +

+ + + + + + + + + + + + + + + + + +
+ +
Summe + +
+ + +
+ + +
+

Offene Vorgänge

+ +

+ Nichts offen: Alle Buchungen sind kategorisiert und abgeglichen, + der Monatsabschluss ist auf dem Laufenden. +

+ + + +
-
- -
-

Aufbaustand

-
    -
  • Phase 0 – Grundgerüst, Anmeldung, Einstellungen
  • -
  • Phase 1 – Konten, Kategorien, Klassen, QIF-Stammdaten
  • -
  • Phase 2 – Buchungen, Kontoblatt, Splitbuchungen
  • -
  • Phase 3 – Budgetplaner und Monatsabschluss
  • -
  • Phase 4 – Berichtsmodul mit fünf Berichtstypen
  • -
  • Phase 4 – die übrigen Grafiken und Cockpit-Kacheln
  • -
-
+
+ + +

+ weitere Kachel(n) sind abgeschaltet – + in den Einstellungen wählen. +

+ + diff --git a/src/Views/cockpit/monatsdiagramm.php b/src/Views/cockpit/monatsdiagramm.php new file mode 100644 index 0000000..8612821 --- /dev/null +++ b/src/Views/cockpit/monatsdiagramm.php @@ -0,0 +1,138 @@ +, + * einnahmen: list, + * ausgaben: list, + * salden: list, + * 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)]; +}; +?> + +
+
+ + + + Einnahmen und Ausgaben + + + + + + + + $monat): ?> + + + + + + + + + + + + + + + + + + + + + $saldo): ?> + 0): ?> + + + + + + + +
+ +
    +
  • + + Einnahmen +
  • +
  • + + Ausgaben +
  • +
  • + + Saldo + +
  • +
+
diff --git a/src/Views/einstellungen/index.php b/src/Views/einstellungen/index.php index 7cacbf6..8e76f87 100644 --- a/src/Views/einstellungen/index.php +++ b/src/Views/einstellungen/index.php @@ -1,11 +1,14 @@ $werte */ /** @var list $logDateien */ +/** @var list $kacheln */ /** @var string $csrfToken */ $wert = static fn(string $k, string $standard = ''): string => (string)($werte[$k] ?? $standard); @@ -47,6 +50,21 @@ include __DIR__ . '/../layout/kopf.php'; value="">

Gilt für Kontoblatt und Berichtslisten.

+

Kacheln des Cockpits

+

+ Was abgewählt ist, wird auch nicht berechnet – das Cockpit lädt dann schneller. + Die Reihenfolge im Raster ist die dieser Liste. +

+ $kachel): ?> + + + > + Regeln schon beim Abruf anwenden + +

+ Die Vorschlagsliste zeigt dann gleich die Kategorie aus der passenden Regel. + Gebucht wird trotzdem erst, wenn der Umsatz übernommen wird. +

+ + + +

+ Wie weit Buchungsdatum und Banktag auseinanderliegen dürfen, damit ein Umsatz + einer schon erfassten Buchung zugeordnet wird. Bei 0 muss der Tag genau stimmen. +

+ + + +

+ 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. +

+ + + + + + +

+ Beide kennen ISINs und haben ein kostenloses Tageskontingent, das für rund zwanzig + Positionen reicht. +

+ + + +

+ Nach kostenloser Anmeldung beim gewählten Dienst. Ohne Schlüssel lassen sich Kurse + nur von Hand eintragen. +

+ + +

+ Der Lauf gehört in einen Cron-Eintrag: + docker compose exec -u www-data php php scripts/kursabruf.php. + Ohne diesen Haken bricht das Skript ab – der Knopf auf der Wertpapierseite + funktioniert weiterhin. +

+ + +