From 851e8204e97eedcdd160aacdacd79f03e1b45053 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 11 Aug 2026 07:31:25 +0200 Subject: [PATCH] Phase 3: Budgetplaner und Monatsabschluss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf, Snapshot und Buchungssperre. Dazu drei Vorarbeiten in der Oberfläche. Budgetplaner - Migration 004: budgets, budget_positionen, monatsabschluesse - Zeilen sind der Kategoriebaum, Spalten die Monate; die ganze Tabelle wird in einem Formular gespeichert, damit mehrere Änderungen zusammen ankommen - Zwei Ansichten: Verlauf (Jahressumme, abgelaufene Monate zusammengefasst, laufender Monat mit Budget/Ist/Saldo, danach Folgemonate) und alle Monate - Ausgaben ohne Vorzeichen eingeben, es kommt aus der Kategorieart. Saldo ist Ist minus Budget: positiv heißt besser als geplant, negativ ist die Überschreitung und wird rot ausgewiesen - Übergeordnete Kategorien summieren ihre Nachfahren und sind nicht beplanbar - Ein Monat ohne Position ist "nicht geplant" und damit etwas anderes als "mit 0,00 geplant" - Wert auf Folgemonate übertragen, Budget als Kopie ins neue Jahr Monatsabschluss - Prüflauf über nicht kategorisierte Buchungen, nicht abgeglichene Umsätze und Splitsummen-Differenzen; er hält niemanden auf, sondern zeigt den Stand - Snapshot der Kontosalden und Kategoriesummen als JSON, bleibt auch nach einer Wiedereröffnung stehen - Abgeschlossene Monate sperren Buchungen in beide Richtungen: weder hinein noch heraus verschieben. Abschaltbar über monatsabschluss_sperrt_buchungen Vorarbeiten - .knopf.primaer trug einen eigenen margin-top und stand dadurch in jeder Knopfleiste tiefer als seine Nachbarn - sichtbar an "Abbrechen" neben "Konto anlegen". Der Abstand liegt jetzt allein bei der Knopfleiste - Kontengruppen werden wie Konten und Kategorien auf einer eigenen Formularseite gepflegt statt inline; dort hängt auch die Zuordnung der Konten (ankreuzen statt jedes Konto einzeln zu öffnen) - Der QIF-Export enthält keine Kontengruppen: kein Feld im !Account-Eintrag, nichts in der QSD-Datei. Import-Oberfläche und Doku sagen das jetzt - Die Baum-Einrückung lief über ein style-Attribut und wurde von der Content-Security-Policy verworfen; sie steht jetzt als data-ebene im Markup. Aus demselben Grund blendete app.js Erfolgsmeldungen nie aus Tests: 11_BudgetTest, 12_MonatsabschlussTest (76 Prüfungen), E2E planung.spec.js. Einzelheiten in docs/planung.md. Co-Authored-By: Claude Opus 5 --- Changes.md | 48 +++ PLAN.md | 21 +- README.md | 8 +- docs/planung.md | 111 +++++++ docs/qif-import.md | 6 + docs/stammdaten.md | 8 + migrations/004_budget_monatsabschluss.sql | 69 ++++ public/index.php | 31 ++ public/static/app.css | 114 ++++++- public/static/app.js | 31 +- src/Controllers/BaseController.php | 15 + src/Controllers/BudgetController.php | 230 +++++++++++++ src/Controllers/MonatsabschlussController.php | 112 +++++++ src/Controllers/PlanungController.php | 31 ++ src/Controllers/PlatzhalterController.php | 5 - src/Controllers/VerwaltungController.php | 48 ++- src/Models/Buchung.php | 14 +- src/Models/Budget.php | 254 ++++++++++++++ src/Models/Kontengruppe.php | 56 ++++ src/Models/Monatsabschluss.php | 312 ++++++++++++++++++ src/Services/Budgetplaner.php | 221 +++++++++++++ src/Views/auth/einrichtung.php | 4 +- src/Views/auth/login.php | 4 +- src/Views/planung/budget-formular.php | 37 +++ src/Views/planung/budget-leer.php | 40 +++ src/Views/planung/budget-verwaltung.php | 123 +++++++ src/Views/planung/budgetplaner.php | 242 ++++++++++++++ src/Views/planung/index.php | 56 ++++ src/Views/planung/monatsabschluss.php | 184 +++++++++++ src/Views/planung/snapshot.php | 84 +++++ src/Views/verwaltung/import.php | 13 + src/Views/verwaltung/kategorien.php | 2 +- .../verwaltung/kontengruppe-formular.php | 88 +++++ src/Views/verwaltung/kontengruppen.php | 80 ++--- tests/07_KontenTest.php | 45 +++ tests/11_BudgetTest.php | 305 +++++++++++++++++ tests/12_MonatsabschlussTest.php | 257 +++++++++++++++ tests/e2e/specs/planung.spec.js | 134 ++++++++ tests/e2e/specs/stammdaten.spec.js | 13 +- 39 files changed, 3360 insertions(+), 96 deletions(-) create mode 100644 docs/planung.md create mode 100644 migrations/004_budget_monatsabschluss.sql create mode 100644 src/Controllers/BudgetController.php create mode 100644 src/Controllers/MonatsabschlussController.php create mode 100644 src/Controllers/PlanungController.php create mode 100644 src/Models/Budget.php create mode 100644 src/Models/Monatsabschluss.php create mode 100644 src/Services/Budgetplaner.php create mode 100644 src/Views/planung/budget-formular.php create mode 100644 src/Views/planung/budget-leer.php create mode 100644 src/Views/planung/budget-verwaltung.php create mode 100644 src/Views/planung/budgetplaner.php create mode 100644 src/Views/planung/index.php create mode 100644 src/Views/planung/monatsabschluss.php create mode 100644 src/Views/planung/snapshot.php create mode 100644 src/Views/verwaltung/kontengruppe-formular.php create mode 100644 tests/11_BudgetTest.php create mode 100644 tests/12_MonatsabschlussTest.php create mode 100644 tests/e2e/specs/planung.spec.js diff --git a/Changes.md b/Changes.md index adf1fab..c0e9e40 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,53 @@ # Änderungen +## Phase 3 – Budgetplaner und Monatsabschluss (11.08.2026) + +**Datenmodell** +- Migration `004_budget_monatsabschluss.sql`: `budgets`, `budget_positionen`, + `monatsabschluesse` +- Ein Budget gilt für ein Jahr, mehrere je Jahr sind möglich, genau eines ist aktiv +- Ein Monat ohne Zeile in `budget_positionen` ist *nicht geplant* – etwas anderes als + *mit 0,00 geplant* + +**Budgetplaner** +- Zeilen sind der Kategoriebaum (auf- und zuklappbar), Spalten die Monate; die ganze + Tabelle wird in einem Formular gespeichert, damit mehrere Änderungen zusammen ankommen +- Zwei Ansichten: *Verlauf* (Jahressumme, abgelaufene Monate zusammengefasst, laufender + Monat mit Budget/Ist/Saldo, danach Folgemonate) und *alle Monate* zum Planen +- Ausgaben werden ohne Vorzeichen eingetragen; das Vorzeichen kommt aus der Kategorieart. + Saldo ist Ist minus Budget – positiv heißt besser als geplant, negativ ist die + Überschreitung und wird rot ausgewiesen +- Übergeordnete Kategorien summieren ihre Nachfahren und sind selbst nicht beplanbar +- Wert auf alle Folgemonate übertragen, Budget als Kopie ins neue Jahr + +**Monatsabschluss** +- Prüflauf über nicht kategorisierte Buchungen, nicht abgeglichene Umsätze und + Splitsummen-Differenzen; er hält niemanden auf, sondern zeigt den Stand +- Snapshot der Kontosalden und Kategoriesummen als JSON, auch nach Wiedereröffnung +- Abgeschlossene Monate sperren Buchungen (`Buchung::anlegen/speichern/loeschen`), in + beide Richtungen: weder hinein- noch herausschieben. Abschaltbar über die Einstellung + `monatsabschluss_sperrt_buchungen` +- Wiedereröffnen bleibt am Datensatz vermerkt + +**Vorarbeiten in der Oberfläche** +- `.knopf.primaer` trug einen eigenen `margin-top` und stand dadurch in jeder Knopfleiste + tiefer als seine Nachbarn – sichtbar etwa an „Abbrechen“ neben „Konto anlegen“. Der + Abstand liegt jetzt allein bei der Knopfleiste. +- Kontengruppen werden wie Konten und Kategorien auf einer eigenen Formularseite gepflegt + statt inline in der Tabelle. Dort hängt auch die Zuordnung der Konten: ankreuzen statt + jedes Konto einzeln zu öffnen (`Kontengruppe::kontenZuordnen()`). +- Der QIF-Export enthält **keine** Kontengruppen – kein Feld im `!Account`-Eintrag, nichts + in der `QSD`-Datei. Import-Oberfläche und Dokumentation sagen das jetzt und verweisen + auf die Zuordnung. +- Die Einrückung des Kategoriebaums lief über ein `style`-Attribut und wurde von der + Content-Security-Policy (`style-src 'self'`) stillschweigend verworfen; sie steht jetzt + als `data-ebene` im Markup. Aus demselben Grund blendete `app.js` Erfolgsmeldungen nie + aus – das läuft jetzt über eine Klasse statt über `element.style`. + +**Tests** +- `11_BudgetTest`, `12_MonatsabschlussTest` (76 Prüfungen), E2E `planung.spec.js` +- Einzelheiten in `docs/planung.md` + ## Korrektur – Σ-Kennzeichen ließ sich nicht abschalten (10.08.2026) Das Kennzeichen *Zählt zur Summe der Kontostände* blieb beim Speichern eines Kontos diff --git a/PLAN.md b/PLAN.md index 1a0f724..01f98b2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -249,7 +249,7 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag * *Ergebnis:* Der vollständige Altbestand ist migriert, Salden stimmen mit dem FinanzManager überein (Abgleich gegen die Kontenübersicht als Test). -### Phase 3 — Budget und Monatsabschluss +### Phase 3 — Budget und Monatsabschluss *(erledigt)* * Migrationen `budgets`, `budget_positionen`, `monatsabschluesse`. * **Budgetplaner** wie im Original: Zeilen = Kategoriebaum (auf-/zuklappbar), Spalten = Gesamt-Saldo, abgelaufene Monate zusammengefasst, aktueller Monat mit Budget/Ist/Saldo, @@ -257,7 +257,12 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag Kopieren eines Budgets ins neue Jahr, Alarm bei Überschreitung, Gesamtzeile. * **Monatsabschluss**: Prüflauf (nicht kategorisierte Buchungen, nicht abgeglichene Umsätze, Splitsummen-Differenzen), Snapshot der Salden, Sperre des Zeitraums, Wiedereröffnung. +* Entschieden während der Umsetzung: Budgetwerte tragen das Vorzeichen der Buchhaltung + (Ausgaben negativ), eingegeben wird der nackte Betrag. Saldo ist **Ist minus Budget** – + positiv heißt besser als geplant. Übergeordnete Kategorien summieren ihre Nachfahren und + werden nicht selbst beplant. * *Ergebnis:* Jahresbudget zum Jahreswechsel planbar, Monatsabschluss durchführbar. + Einzelheiten in `docs/planung.md`. ### Phase 4 — Auswertungen und Cockpit * Berichtsmodul mit gespeicherten Berichten und Berichtsgruppen (wie „Bankgeschäfte", @@ -319,10 +324,16 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag 3. **Σ-Kennzeichen je Konto prüfen**: Welche Konten in die Summe der Kontostände zählen, steht nicht im Export. Nach dem Import einmal durch die Kontenübersicht gehen (Vorgabe: alle zählen mit). -4. **Budgetdaten**: Export aus dem Budgetplaner (Symbol oben rechts in der Werkzeugleiste), - sonst wird das Jahresbudget 2027 einmalig neu erfasst. -5. Zugangsdaten der Bankverbindungen (erst in Phase 5, Eingabe direkt in der Anwendung). -6. **API-Key für den Kursdienst** (Leeway.tech oder FMP, erst in Phase 6, Eingabe im +4. **Kontengruppen anlegen und Konten zuordnen**: Ebenfalls nicht im Export enthalten — + ein `!Account`-Eintrag führt nur Name, Kontoart, Beschreibung und Kreditlimit, und auch + die `QSD`-Datei nennt keine Gruppen. Nach dem Import stehen deshalb alle Konten unter + „Nicht gruppiert“. Laut Screenshot der Kontenübersicht gibt es genau eine Gruppe + („Anlage“); sie wird unter *Verwaltung → Kontengruppen* angelegt, die Konten dort in + einem Zug angekreuzt. +5. **Budgetdaten**: Export aus dem Budgetplaner (Symbol oben rechts in der Werkzeugleiste), + sonst wird das Jahresbudget 2027 einmalig im Budgetplaner erfasst (Phase 3 steht). +6. Zugangsdaten der Bankverbindungen (erst in Phase 5, Eingabe direkt in der Anwendung). +7. **API-Key für den Kursdienst** (Leeway.tech oder FMP, erst in Phase 6, Eingabe im Einstellungsdialog). ## 7. Verifikation diff --git a/README.md b/README.md index 014189b..2251181 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,12 @@ Haushalt wird beides parallel genutzt. ## Stand -Phase 0 bis 2 sind gebaut: Anmeldung, zentraler Einstellungsdialog, Debug-Log, +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. Als Nächstes folgt nach `PLAN.md` der Budgetplaner mit dem -Monatsabschluss (Phase 3). +Stammdaten und Buchungen; Budgetplaner mit Monatswerten je Kategorie und der +Monatsabschluss samt Prüflauf, Snapshot und Sperre. Als Nächstes folgen nach `PLAN.md` +die Auswertungen und das Cockpit (Phase 4). ## Technik @@ -83,4 +84,5 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf. - `Changes.md` – Änderungsprotokoll - `docs/stammdaten.md` – Konten, Kontengruppen, Kategorien, Klassen - `docs/buchungen.md` – Kontoblatt, Umbuchungen, Splitbuchungen, Salden +- `docs/planung.md` – Budgetplaner und Monatsabschluss - `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs diff --git a/docs/planung.md b/docs/planung.md new file mode 100644 index 0000000..efb7da3 --- /dev/null +++ b/docs/planung.md @@ -0,0 +1,111 @@ +# Planung + +Budgetplaner und Monatsabschluss – die beiden Module des Bereichs *Planung*. + +## Budgetplaner + +Ein **Budget** gilt für genau ein Jahr und hält je Kategorie zwölf Monatswerte +(`budgets`, `budget_positionen`). Mehrere Budgets pro Jahr sind möglich, genau eines ist +das aktive; es öffnet sich beim Aufruf des Planers. + +### Vorzeichen und Saldo + +Budgetwerte werden mit dem Vorzeichen der Buchhaltung gespeichert: **Ausgaben negativ, +Einnahmen positiv**. Eingegeben wird trotzdem der nackte Betrag – „400“ bei Lebensmitteln +meint 400 € Ausgabe. `Budget::positionSetzen()` dreht das Vorzeichen anhand der +Kategorieart; eine ausdrücklich negative Eingabe bei einer Ausgabe bleibt eine Ausgabe. + +Daraus folgt eine Saldodefinition, die für Einnahmen und Ausgaben gleich funktioniert: + +``` +saldo = ist − budget +``` + +**Positiv heißt besser als geplant** (weniger ausgegeben oder mehr eingenommen), negativ +ist die Überschreitung. Die Oberfläche färbt negative Salden rot – das ist der Alarm bei +Budgetüberschreitung. + +### Nicht geplant ist nicht null + +Ein Monat ohne Zeile in `budget_positionen` ist **nicht geplant**, ein Monat mit dem Wert +`0.00` ist **mit null geplant**. Der Planer zeigt das eine als leeres Feld, das andere als +`0,00`; ein geleertes Feld löscht die Position wieder. Deshalb füllt `Budgetplaner` die +Reihe `eigenBudget` nicht mit Nullen auf, während `budget` und `ist` als vollständige +Monatsreihen vorliegen. + +### Ist-Werte + +`Budgetplaner::istWerte()` summiert je Kategorie und Monat: + +* Buchungen ohne Splitzeilen über `buchungen.kategorie_id` +* gesplittete Buchungen über `buchung_splits.kategorie_id` + +Beides zusammen zählte doppelt, deshalb die Trennung über `gesplittet`. Umbuchungen tragen +keine Kategorie und bleiben außen vor – wie im FinanzManager. + +### Baum und Summen + +Zeilen sind der Kategoriebaum in Baumreihenfolge. Eine übergeordnete Kategorie zeigt die +**Summe ihrer Nachfahren** (aufaddiert von unten nach oben) und ist deshalb selbst nicht +beplanbar: Zwei Werte in einer Zelle – eigener und aggregierter – wären nicht lesbar. +Geplant wird auf den Blattkategorien. Die Gesamtzeile summiert nur die Wurzeln, sonst +zählten Unterkategorien mehrfach. + +### Ansichten + +| Ansicht | Spalten | +|---|---| +| **Verlauf** (Standard) | Jahressumme, abgelaufene Monate zusammengefasst, laufender Monat mit Budget/Ist/Saldo, danach je Folgemonat eine Budgetspalte | +| **Alle Monate** | Jahressumme und zwölf Budgetspalten – zum Planen des nächsten Jahres oder Nachbessern eines vergangenen | + +Was als abgelaufen gilt, entscheidet `Budgetplaner::abgelaufenBis()`: im laufenden Jahr der +Vormonat, in vergangenen Jahren der Dezember, in künftigen nichts. + +Die ganze Tabelle steht in **einem** Formular und wird mit einem Knopf gespeichert – ohne +JavaScript, damit mehrere geänderte Zeilen zusammen ankommen. Der Knopf **↦** in jeder +Zeile überträgt den Wert des ersten Eingabemonats auf alle Folgemonate +(`Budget::aufFolgemonateUebertragen()`). + +`Budget::kopieren()` legt ein Budget samt aller Monatswerte im neuen Jahr an – der Weg zum +Jahresbudget, ohne 105 Kategorien noch einmal zu tippen. + +## Monatsabschluss + +Der Abschluss friert einen Monat ein (`monatsabschluesse`). + +**Prüflauf** (`Monatsabschluss::pruefen()`) zeigt, was im Zeitraum noch offen ist: + +* Buchungen ohne Kategorie und ohne Gegenkonto +* nicht abgeglichene Buchungen +* Splitbuchungen, deren Zeilen nicht auf den Buchungsbetrag aufgehen + +Keiner dieser Punkte hindert am Abschluss – sie sind Hinweise, der FinanzManager hält es +genauso. + +**Snapshot**: Beim Abschluss werden Kontosalden zum Monatsende (inklusive der +Σ-Kennzeichen und der Summe der Kontostände) sowie die Kategoriesummen des Monats als JSON +gespeichert. Der Snapshot bleibt auch nach einer Wiedereröffnung stehen: Er zeigt, mit +welchen Zahlen einmal abgeschlossen wurde. + +**Sperre**: `Monatsabschluss::verlangeOffen()` greift in `Buchung::anlegen()`, +`speichern()` und `loeschen()`. Eine Buchung darf weder in einen abgeschlossenen Monat +hinein noch aus einem heraus verschoben werden. Die Sperre lässt sich über die Einstellung +`monatsabschluss_sperrt_buchungen` abschalten – etwa für einen Import, der ältere +Zeiträume nachträgt. Der QIF-Import bricht daran ohnehin nicht ab: Er fängt den Fehler je +Buchung ab und schreibt ihn ins Protokoll. + +**Wiedereröffnen** setzt den Status auf `wiedereroeffnet` und vermerkt den Zeitpunkt. Ein +erneuter Abschluss überschreibt den Snapshot mit dem neuen Stand. + +`Monatsabschluss::naechsterOffener()` schlägt den ältesten Monat mit Buchungen vor, der +noch offen ist: Abgeschlossen wird der Reihe nach, deshalb läuft die Suche vom Vormonat +rückwärts und endet am ersten bereits abgeschlossenen Monat. + +## Tests + +* `tests/11_BudgetTest.php` – Vorzeichen, „nicht geplant“, Ist-Werte samt Splits, + Baumsummen, Saldo, Folgemonate, Kopie +* `tests/12_MonatsabschlussTest.php` – Zeiträume, Prüflauf, Snapshot, Sperre, + Wiedereröffnung, Vorschlag +* `tests/e2e/specs/planung.spec.js` – Budget anlegen, Wert eintragen und wiederfinden, + Übertragen auf Folgemonate, Abschluss mit Snapshot und Wiedereröffnung diff --git a/docs/qif-import.md b/docs/qif-import.md index a920153..dda9159 100644 --- a/docs/qif-import.md +++ b/docs/qif-import.md @@ -47,6 +47,12 @@ zweite Schreibweise ab, bei der ein einzelner `!Account` den Kontowechsel bedeut | | `D` | Beschreibung; `Kontonr. 12345` wird als Kontonummer übernommen | | | `L` | Kreditlimit im US-Format | +**Kontengruppen fehlen im Export.** Ein `!Account`-Eintrag führt nur die vier Felder oben – +kein Feld nennt die Gruppe, und auch die `QSD`-Datei enthält weder Gruppennamen noch +Zuordnung. Der Import kann sie deshalb nicht anlegen: nach dem Einlesen stehen alle Konten +unter „Nicht gruppiert“. Die Gruppen werden einmalig unter *Verwaltung → Kontengruppen* +angelegt; dort lassen sich die Konten einer Gruppe in einem Zug ankreuzen. + ## Fallstricke 1. **Zeichensatz ISO-8859-1, Zeilenenden CRLF.** `QifParser::nachUtf8()` prüft erst, ob diff --git a/docs/stammdaten.md b/docs/stammdaten.md index 02082fc..0dac2dd 100644 --- a/docs/stammdaten.md +++ b/docs/stammdaten.md @@ -10,6 +10,14 @@ unter **Nicht gruppiert** – das ist keine eigene Gruppe, sondern `kontengruppe Wird eine Gruppe gelöscht, bleiben ihre Konten erhalten und rutschen dorthin (`ON DELETE SET NULL`). +Gruppen werden wie Konten und Kategorien auf einer eigenen Formularseite gepflegt +(*Verwaltung → Kontengruppen*). Dort hängt auch die Zuordnung: Die Konten der Gruppe werden +angekreuzt, `Kontengruppe::kontenZuordnen()` setzt sie in einem Zug. Ein Konto gehört zu +genau einer Gruppe – wird es hier angehakt, verlässt es seine bisherige. + +Der QIF-Export enthält **keine** Kontengruppen (siehe `docs/qif-import.md`); nach einem +Import stehen deshalb alle Konten unter „Nicht gruppiert“ und werden einmalig zugeordnet. + Kontoarten (`Konto::ARTEN`) und ihre Herkunft aus dem QIF-Export (`Konto::QIF_ARTEN`): | Kontoart | Bezeichnung | QIF-Typ | diff --git a/migrations/004_budget_monatsabschluss.sql b/migrations/004_budget_monatsabschluss.sql new file mode 100644 index 0000000..4900dae --- /dev/null +++ b/migrations/004_budget_monatsabschluss.sql @@ -0,0 +1,69 @@ +-- Phase 3: Budgetplaner und Monatsabschluss. +-- +-- Ein Budget gilt für genau ein Jahr; mehrere Budgets pro Jahr sind erlaubt +-- („2027-sparsam“ neben „2027-normal“), eines davon ist das aktive. Die Werte +-- stehen monatsweise je Kategorie – das entspricht dem Budgetplaner des +-- FinanzManagers, in dem jede Kategoriezeile zwölf Monatsspalten hat. + +CREATE TABLE IF NOT EXISTS budgets ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + benutzer_id INT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + jahr SMALLINT UNSIGNED NOT NULL, + beschreibung VARCHAR(255) NOT NULL DEFAULT '', + -- Genau ein Budget je Benutzer ist aktiv; es öffnet sich beim Aufruf des + -- Budgetplaners und liefert die Werte für Auswertungen (Phase 4). + aktiv TINYINT(1) NOT NULL DEFAULT 0, + 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_budget_name (benutzer_id, name), + KEY idx_budget_jahr (benutzer_id, jahr), + CONSTRAINT fk_budget_benutzer FOREIGN KEY (benutzer_id) + REFERENCES benutzer (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Eine Zeile je Kategorie und Monat. Nicht geplante Monate haben keine Zeile – +-- das hält die Tabelle klein und macht „kein Budget“ von „Budget 0,00“ +-- unterscheidbar. Das Vorzeichen folgt der Buchhaltung: Ausgaben negativ, +-- Einnahmen positiv (Budget::positionSetzen dreht die Eingabe entsprechend). +CREATE TABLE IF NOT EXISTS budget_positionen ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + budget_id INT UNSIGNED NOT NULL, + kategorie_id INT UNSIGNED NOT NULL, + monat TINYINT UNSIGNED NOT NULL, + betrag DECIMAL(15,2) NOT NULL DEFAULT 0.00, + PRIMARY KEY (id), + UNIQUE KEY uq_budget_position (budget_id, kategorie_id, monat), + KEY idx_position_kategorie (kategorie_id), + CONSTRAINT fk_position_budget FOREIGN KEY (budget_id) + REFERENCES budgets (id) ON DELETE CASCADE, + CONSTRAINT fk_position_kategorie FOREIGN KEY (kategorie_id) + REFERENCES kategorien (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Ein abgeschlossener Monat sperrt Buchungen in seinem Zeitraum. Der Snapshot +-- hält Kontosalden und Kategoriesummen als JSON fest, damit später +-- nachvollziehbar bleibt, mit welchen Zahlen abgeschlossen wurde – auch wenn +-- der Monat wiedereröffnet und nachgebucht wird. +CREATE TABLE IF NOT EXISTS monatsabschluesse ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + benutzer_id INT UNSIGNED NOT NULL, + jahr SMALLINT UNSIGNED NOT NULL, + monat TINYINT UNSIGNED NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'abgeschlossen', + notiz VARCHAR(255) NOT NULL DEFAULT '', + snapshot LONGTEXT NULL, + abgeschlossen_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + abgeschlossen_von VARCHAR(120) NOT NULL DEFAULT '', + wiedereroeffnet_am DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_monatsabschluss (benutzer_id, jahr, monat), + CONSTRAINT fk_abschluss_benutzer FOREIGN KEY (benutzer_id) + REFERENCES benutzer (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO einstellungen (schluessel, wert, bereich) VALUES + ('budget_alarm_ab_prozent', '100', 'buchhaltung'), + ('monatsabschluss_sperrt_buchungen', '1', 'buchhaltung') +ON DUPLICATE KEY UPDATE schluessel = schluessel; diff --git a/public/index.php b/public/index.php index 93c5006..55c8249 100644 --- a/public/index.php +++ b/public/index.php @@ -11,12 +11,15 @@ require dirname(__DIR__) . '/vendor/autoload.php'; use Finanzmanager\Auth; use Finanzmanager\Config; use Finanzmanager\Controllers\BuchungenController; +use Finanzmanager\Controllers\BudgetController; use Finanzmanager\Controllers\CockpitController; use Finanzmanager\Controllers\EinstellungenController; use Finanzmanager\Controllers\ImportController; use Finanzmanager\Controllers\KategorienController; use Finanzmanager\Controllers\KontenController; use Finanzmanager\Controllers\LoginController; +use Finanzmanager\Controllers\MonatsabschlussController; +use Finanzmanager\Controllers\PlanungController; use Finanzmanager\Controllers\PlatzhalterController; use Finanzmanager\Controllers\VerwaltungController; use Finanzmanager\Models\Einstellung; @@ -96,12 +99,40 @@ $router->post('/verwaltung/klassen/{id}/loeschen', fn(array $p) => (new VerwaltungController())->klasseLoeschen($p)); $router->get('/verwaltung/kontengruppen', fn() => (new VerwaltungController())->kontengruppen()); +$router->get('/verwaltung/kontengruppen/neu', fn() => (new VerwaltungController())->kontengruppeNeu()); $router->post('/verwaltung/kontengruppen/neu', fn() => (new VerwaltungController())->kontengruppeAnlegen()); +$router->get('/verwaltung/kontengruppen/{id}', + fn(array $p) => (new VerwaltungController())->kontengruppeBearbeiten($p)); $router->post('/verwaltung/kontengruppen/{id}', fn(array $p) => (new VerwaltungController())->kontengruppeSpeichern($p)); $router->post('/verwaltung/kontengruppen/{id}/loeschen', fn(array $p) => (new VerwaltungController())->kontengruppeLoeschen($p)); +// ── Planung: Budgetplaner und Monatsabschluss ─────────────────────────────── +$router->get('/planung', fn() => (new PlanungController())->index()); + +$router->get('/planung/budget', fn() => (new BudgetController())->planer()); +$router->get('/planung/budget/verwaltung', fn() => (new BudgetController())->verwaltung()); +$router->post('/planung/budget/neu', fn() => (new BudgetController())->anlegen()); +$router->post('/planung/budget/{id}/positionen', + fn(array $p) => (new BudgetController())->positionenSpeichern($p)); +$router->get('/planung/budget/{id}/bearbeiten', + fn(array $p) => (new BudgetController())->bearbeiten($p)); +$router->post('/planung/budget/{id}/bearbeiten', + fn(array $p) => (new BudgetController())->speichern($p)); +$router->post('/planung/budget/kopieren', fn() => (new BudgetController())->kopieren()); +$router->post('/planung/budget/{id}/aktivieren', + fn(array $p) => (new BudgetController())->aktivieren($p)); +$router->post('/planung/budget/{id}/loeschen', + fn(array $p) => (new BudgetController())->loeschen($p)); + +$router->get('/planung/monatsabschluss', fn() => (new MonatsabschlussController())->index()); +$router->post('/planung/monatsabschluss', fn() => (new MonatsabschlussController())->abschliessen()); +$router->get('/planung/monatsabschluss/{id}', + fn(array $p) => (new MonatsabschlussController())->snapshot($p)); +$router->post('/planung/monatsabschluss/{id}/wiedereroeffnen', + fn(array $p) => (new MonatsabschlussController())->wiedereroeffnen($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()); diff --git a/public/static/app.css b/public/static/app.css index 225e997..2aa5f22 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -399,13 +399,88 @@ table.liste input[type="number"] { table.liste input.schmal { width: 5rem; } -/* Einrückung des Kategoriebaums. Die Ebene kommt als Custom Property aus der - Vorlage – so bleibt die Tabelle frei von verschachtelten Elementen. */ +/* ── Budgetplaner ──────────────────────────────────────────────────────────── + + Zwölf Monatsspalten passen auf keinen Bildschirm: Die Tabelle scrollt in + ihrem eigenen Rahmen, die Kategoriespalte bleibt dabei stehen. */ +.breittabelle { + overflow-x: auto; + margin-bottom: 1rem; +} + +table.budgetplaner { min-width: 100%; } + +table.budgetplaner th, +table.budgetplaner td { + padding: .3rem .5rem; + white-space: nowrap; +} + +table.budgetplaner .spalte-kategorie { + position: sticky; + left: 0; + z-index: 1; + background: var(--flaeche); + min-width: 16rem; +} + +table.budgetplaner thead .spalte-kategorie, +table.budgetplaner tfoot .spalte-kategorie { background: var(--flaeche-gedaempft); } + +table.budgetplaner tbody tr:hover .spalte-kategorie { background: var(--flaeche-gedaempft); } + +table.budgetplaner th.gruppenkopf { + text-align: center; + border-left: 1px solid var(--rand); +} + +/* Der laufende Monat ist die Spalte, auf die man zuerst schaut. */ +table.budgetplaner .laufend { background: color-mix(in srgb, var(--akzent) 8%, transparent); } + +table.budgetplaner tr.summenzeile-kategorie td { font-weight: 600; } + +table.budgetplaner input.betragsfeld { + width: 6rem; + text-align: right; + font-family: var(--schrift-zahl); + font-variant-numeric: tabular-nums; +} + +table.budgetplaner td.rechts { + font-family: var(--schrift-zahl); + font-variant-numeric: tabular-nums; +} + +/* Aufklappen des Kategoriebaums; ohne JavaScript bleibt alles sichtbar. */ +.klappknopf { + border: none; + background: none; + color: var(--text-gedaempft); + cursor: pointer; + font: inherit; + padding: 0 .3rem 0 0; +} + +.klappknopf[aria-expanded="false"] { transform: rotate(-90deg); display: inline-block; } + +tr.zugeklappt { display: none; } + +/* Einrückung des Kategoriebaums. Die Ebene steht als data-Attribut an der + Zeile – so bleibt die Tabelle frei von verschachtelten Elementen. Kein + style-Attribut: Die Content-Security-Policy (style-src 'self') verwirft + Inline-Styles, die Einrückung fiele stillschweigend aus. */ .baumname { display: inline-block; - padding-left: calc(var(--ebene, 0) * 1.4rem); + padding-left: 0; } +.baumname[data-ebene="1"] { padding-left: 1.4rem; } +.baumname[data-ebene="2"] { padding-left: 2.8rem; } +.baumname[data-ebene="3"] { padding-left: 4.2rem; } +.baumname[data-ebene="4"] { padding-left: 5.6rem; } +.baumname[data-ebene="5"] { padding-left: 7rem; } +.baumname[data-ebene="6"] { padding-left: 8.4rem; } + .nebentext { color: var(--text-gedaempft); font-size: .88rem; @@ -548,6 +623,24 @@ table.splittabelle input.rechts { text-align: right; } font-weight: 500; } +/* Mehrfachauswahl im Formular – etwa die Konten einer Kontengruppe. Bei vielen + Einträgen scrollt die Liste, statt das Formular auseinanderzuziehen. */ +.auswahlliste { + display: flex; + flex-direction: column; + gap: .35rem; + max-height: 22rem; + overflow-y: auto; + padding: .6rem .7rem; + border: 1px solid var(--rand); + border-radius: var(--radius); + background: var(--flaeche-gedaempft); +} + +.auswahlliste .ankreuzfeld { margin: 0; } + +.auswahlliste .nebentext { margin-left: .35rem; } + .formular input[type="text"], .formular input[type="password"], .formular input[type="number"], @@ -601,21 +694,18 @@ table.splittabelle input.rechts { text-align: right; } .knopf:hover { background: var(--flaeche-gedaempft); text-decoration: none; } +/* Kein eigener Außenabstand: der primäre Knopf steht sonst in jeder Knopfleiste + um genau diesen Betrag tiefer als seine Nachbarn. Den Abstand nach oben setzt + allein die .knopfleiste. */ .knopf.primaer { background: var(--akzent); border-color: var(--akzent); color: var(--akzent-text); font-weight: 500; - margin-top: 1.25rem; } .knopf.primaer:hover { filter: brightness(1.08); } -/* Der Abstand oben gilt dem allein stehenden Knopf unter einem Formular – - in Aktionsleisten und Tabellen stört er die Ausrichtung. */ -.seitenaktionen .knopf, -table.liste .knopf { margin-top: 0; } - .knopf.klein { padding: .25rem .6rem; font-size: .85rem; @@ -642,6 +732,12 @@ table.liste .knopf { margin-top: 0; } } .meldung-erfolg { background: var(--akzent-schwach); border-color: var(--akzent); } + +/* Erfolgsmeldungen blenden sich nach einigen Sekunden aus (app.js). */ +.meldung.verblasst { + opacity: 0; + transition: opacity .4s; +} .meldung-fehler { background: color-mix(in srgb, var(--negativ) 12%, var(--flaeche)); border-color: var(--negativ); } .meldung-warnung { background: color-mix(in srgb, var(--warnung) 15%, var(--flaeche)); border-color: var(--warnung); } diff --git a/public/static/app.js b/public/static/app.js index 32acc7a..f91a2aa 100644 --- a/public/static/app.js +++ b/public/static/app.js @@ -27,11 +27,38 @@ } }, true); + // Kategoriebaum im Budgetplaner auf- und zuklappen. Die Zeilen kennen ihre + // Elternkategorie über data-eltern; von dort aus lässt sich der Teilbaum + // einsammeln, ohne ihn im HTML zu verschachteln (in einer Tabelle ginge das + // ohnehin nicht). + document.querySelectorAll('.klappknopf').forEach(function (knopf) { + knopf.setAttribute('aria-expanded', 'true'); + + knopf.addEventListener('click', function () { + const wurzel = knopf.dataset.klappt; + const offen = knopf.getAttribute('aria-expanded') !== 'false'; + knopf.setAttribute('aria-expanded', offen ? 'false' : 'true'); + + const zeilen = Array.from(document.querySelectorAll('tr.kategoriezeile')); + const betroffen = new Set([wurzel]); + + zeilen.forEach(function (zeile) { + const eltern = zeile.dataset.eltern; + if (eltern && betroffen.has(eltern)) { + betroffen.add(zeile.dataset.kategorie); + zeile.classList.toggle('zugeklappt', offen); + } + }); + }); + }); + // 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, + // die Meldung bliebe sonst einfach stehen. document.querySelectorAll('.meldung-erfolg').forEach(function (element) { window.setTimeout(function () { - element.style.transition = 'opacity .4s'; - element.style.opacity = '0'; + element.classList.add('verblasst'); window.setTimeout(function () { element.remove(); }, 400); }, 5000); }); diff --git a/src/Controllers/BaseController.php b/src/Controllers/BaseController.php index a993555..7b8e02b 100644 --- a/src/Controllers/BaseController.php +++ b/src/Controllers/BaseController.php @@ -35,6 +35,21 @@ abstract class BaseController return is_string($wert) ? trim($wert) : $standard; } + /** + * Mehrfachauswahl aus dem Formular (Ankreuzfelder mit Namen wie „konten[]“). + * + * @return list + */ + protected function postIds(string $feld): array + { + $werte = $_POST[$feld] ?? []; + if (!is_array($werte)) { + return []; + } + + return array_values(array_map('intval', array_filter($werte, 'is_scalar'))); + } + protected function query(string $feld, string $standard = ''): string { $wert = $_GET[$feld] ?? $standard; diff --git a/src/Controllers/BudgetController.php b/src/Controllers/BudgetController.php new file mode 100644 index 0000000..9ad6aff --- /dev/null +++ b/src/Controllers/BudgetController.php @@ -0,0 +1,230 @@ +verlangeAnmeldung(); + + $budget = $this->gewaehltesBudget(); + if ($budget === null) { + $this->render('planung/budget-leer.php', ['titel' => 'Budgetplaner']); + return; + } + + $ansicht = $this->query('ansicht') === 'alle' ? 'alle' : 'verlauf'; + $tabelle = Budgetplaner::tabelle($budget); + + $this->render('planung/budgetplaner.php', [ + 'titel' => 'Budgetplaner', + 'budget' => $budget, + 'budgets' => Budget::alle(), + 'tabelle' => $tabelle, + 'ansicht' => $ansicht, + 'abgelaufenBis' => Budgetplaner::abgelaufenBis((int)$budget['jahr']), + 'laufenderMonat' => Budgetplaner::laufenderMonat((int)$budget['jahr']), + 'nurGeplante' => $this->query('nur_geplante') === '1', + ]); + } + + /** + * Speichert die eingegebenen Monatswerte. Ein leeres Feld löscht die + * Position („nicht geplant“), 0 bleibt eine ausdrückliche Null. + * + * @param array $params + */ + public function positionenSpeichern(array $params): void + { + $this->verlangeAnmeldung(); + + $id = (int)$params['id']; + $budget = Budget::find($id); + if ($budget === null) { + View::redirect('/planung/budget', 'Das Budget gibt es nicht.', 'fehler'); + return; + } + + $werte = $_POST['betrag'] ?? []; + $geaendert = 0; + + if (is_array($werte)) { + foreach ($werte as $kategorieId => $monate) { + if (!is_array($monate)) { + continue; + } + foreach ($monate as $monat => $eingabe) { + $betrag = self::betrag((string)$eingabe); + Budget::positionSetzen($id, (int)$kategorieId, (int)$monat, $betrag); + $geaendert++; + } + } + } + + // „Auf Folgemonate übertragen“ kommt als Knopfwert „kategorie:monat“ und + // greift auf den soeben gespeicherten Stand. + $meldung = $geaendert > 0 ? 'Budget gespeichert.' : 'Nichts zu speichern.'; + $uebertragen = $this->post('uebertragen'); + if ($uebertragen !== '' && preg_match('/^(\d+):(\d{1,2})$/', $uebertragen, $treffer) === 1) { + Budget::aufFolgemonateUebertragen($id, (int)$treffer[1], (int)$treffer[2]); + $meldung = 'Wert auf die Folgemonate übertragen.'; + } + + View::redirect($this->zurueckZumPlaner($id), $meldung); + } + + public function verwaltung(): void + { + $this->verlangeAnmeldung(); + + $this->render('planung/budget-verwaltung.php', [ + 'titel' => 'Budgets', + 'budgets' => Budget::alle(), + 'jahr' => (int)date('Y'), + ]); + } + + public function anlegen(): void + { + $this->verlangeAnmeldung(); + + try { + $id = Budget::anlegen( + $this->post('name'), + (int)$this->post('jahr', date('Y')), + $this->post('beschreibung') + ); + } catch (\Throwable $e) { + View::redirect('/planung/budget/verwaltung', $e->getMessage(), 'fehler'); + return; + } + + View::redirect('/planung/budget?budget=' . $id, 'Budget angelegt.'); + } + + /** @param array $params */ + public function bearbeiten(array $params): void + { + $this->verlangeAnmeldung(); + + $budget = Budget::find((int)$params['id']); + if ($budget === null) { + View::redirect('/planung/budget/verwaltung', 'Das Budget gibt es nicht.', 'fehler'); + return; + } + + $this->render('planung/budget-formular.php', [ + 'titel' => 'Budget bearbeiten', + 'budget' => $budget, + ]); + } + + /** @param array $params */ + public function speichern(array $params): void + { + $this->verlangeAnmeldung(); + + try { + Budget::speichern( + (int)$params['id'], + $this->post('name'), + (int)$this->post('jahr', date('Y')), + $this->post('beschreibung') + ); + } catch (\Throwable $e) { + View::redirect('/planung/budget/verwaltung', $e->getMessage(), 'fehler'); + return; + } + + View::redirect('/planung/budget/verwaltung', 'Budget gespeichert.'); + } + + public function kopieren(): void + { + $this->verlangeAnmeldung(); + + try { + $neu = Budget::kopieren( + (int)$this->post('vorlage'), + $this->post('name'), + (int)$this->post('jahr', date('Y')) + ); + } catch (\Throwable $e) { + View::redirect('/planung/budget/verwaltung', $e->getMessage(), 'fehler'); + return; + } + + View::redirect('/planung/budget?budget=' . $neu, 'Budget kopiert – die Werte stehen im neuen Jahr.'); + } + + /** @param array $params */ + public function aktivieren(array $params): void + { + $this->verlangeAnmeldung(); + + Budget::aktivieren((int)$params['id']); + View::redirect('/planung/budget/verwaltung', 'Budget ist jetzt das aktive.'); + } + + /** @param array $params */ + public function loeschen(array $params): void + { + $this->verlangeAnmeldung(); + + Budget::loeschen((int)$params['id']); + View::redirect('/planung/budget/verwaltung', 'Budget gelöscht.'); + } + + /** + * Eingabe aus dem Planer: leeres Feld heißt „nicht geplant“ (null), sonst + * derselbe Zahlenparser wie in der Kontenmaske. + */ + public static function betrag(string $eingabe): ?float + { + return trim($eingabe) === '' ? null : KontenController::zahl($eingabe); + } + + /** @return array|null */ + private function gewaehltesBudget(): ?array + { + $gewaehlt = (int)$this->query('budget'); + if ($gewaehlt > 0) { + $budget = Budget::find($gewaehlt); + if ($budget !== null) { + return $budget; + } + } + + return Budget::aktives(); + } + + /** Nach dem Speichern zurück in dieselbe Ansicht, nicht auf das aktive Budget. */ + private function zurueckZumPlaner(int $id): string + { + $pfad = '/planung/budget?budget=' . $id; + if ($this->post('ansicht') === 'alle') { + $pfad .= '&ansicht=alle'; + } + if ($this->post('nur_geplante') === '1') { + $pfad .= '&nur_geplante=1'; + } + + return $pfad; + } +} diff --git a/src/Controllers/MonatsabschlussController.php b/src/Controllers/MonatsabschlussController.php new file mode 100644 index 0000000..cea1d20 --- /dev/null +++ b/src/Controllers/MonatsabschlussController.php @@ -0,0 +1,112 @@ +verlangeAnmeldung(); + + $vorschlag = Monatsabschluss::naechsterOffener(); + $jahr = (int)($this->query('jahr') ?: $vorschlag['jahr']); + $monat = (int)($this->query('monat') ?: $vorschlag['monat']); + + if ($monat < 1 || $monat > 12) { + $monat = $vorschlag['monat']; + } + + $this->render('planung/monatsabschluss.php', [ + 'titel' => 'Monatsabschluss', + 'jahr' => $jahr, + 'monat' => $monat, + 'pruefung' => Monatsabschluss::pruefen($jahr, $monat), + 'vorhanden' => Monatsabschluss::fuerMonat($jahr, $monat), + 'abschluesse' => Monatsabschluss::alle(), + 'monatsnamen' => Budgetplaner::MONATSNAMEN, + ]); + } + + public function abschliessen(): void + { + $this->verlangeAnmeldung(); + + $jahr = (int)$this->post('jahr'); + $monat = (int)$this->post('monat'); + + try { + $benutzer = Auth::benutzer(); + Monatsabschluss::abschliessen( + $jahr, + $monat, + $this->post('notiz'), + (string)($benutzer['anzeigename'] ?: $benutzer['benutzername'] ?? '') + ); + } catch (\Throwable $e) { + View::redirect(self::pfad($jahr, $monat), $e->getMessage(), 'fehler'); + return; + } + + View::redirect( + self::pfad($jahr, $monat), + sprintf('%s %d ist abgeschlossen.', Budgetplaner::MONATSNAMEN[$monat] ?? '', $jahr) + ); + } + + /** @param array $params */ + public function wiedereroeffnen(array $params): void + { + $this->verlangeAnmeldung(); + + $abschluss = Monatsabschluss::find((int)$params['id']); + if ($abschluss === null) { + View::redirect('/planung/monatsabschluss', 'Den Abschluss gibt es nicht.', 'fehler'); + return; + } + + Monatsabschluss::wiedereroeffnen((int)$abschluss['id']); + View::redirect( + self::pfad((int)$abschluss['jahr'], (int)$abschluss['monat']), + 'Der Monat ist wieder offen. Der Snapshot des Abschlusses bleibt erhalten.' + ); + } + + /** @param array $params */ + public function snapshot(array $params): void + { + $this->verlangeAnmeldung(); + + $abschluss = Monatsabschluss::find((int)$params['id']); + if ($abschluss === null) { + View::redirect('/planung/monatsabschluss', 'Den Abschluss gibt es nicht.', 'fehler'); + return; + } + + $daten = json_decode((string)$abschluss['snapshot'], true); + + $this->render('planung/snapshot.php', [ + 'titel' => 'Abschluss ' . ($abschluss['monat'] . '/' . $abschluss['jahr']), + 'abschluss' => $abschluss, + 'snapshot' => is_array($daten) ? $daten : [], + 'monatsnamen' => Budgetplaner::MONATSNAMEN, + ]); + } + + private static function pfad(int $jahr, int $monat): string + { + return '/planung/monatsabschluss?jahr=' . $jahr . '&monat=' . $monat; + } +} diff --git a/src/Controllers/PlanungController.php b/src/Controllers/PlanungController.php new file mode 100644 index 0000000..cd8e6fe --- /dev/null +++ b/src/Controllers/PlanungController.php @@ -0,0 +1,31 @@ +verlangeAnmeldung(); + + $aktives = Budget::aktives(); + $letzter = Monatsabschluss::alle(); + + $this->render('planung/index.php', [ + 'titel' => 'Planung', + 'budget' => $aktives, + 'anzahl' => Budget::anzahl(), + 'letzter' => $letzter === [] ? null : $letzter[0], + 'naechster' => Monatsabschluss::naechsterOffener(), + ]); + } +} diff --git a/src/Controllers/PlatzhalterController.php b/src/Controllers/PlatzhalterController.php index 1c39c13..314f03e 100644 --- a/src/Controllers/PlatzhalterController.php +++ b/src/Controllers/PlatzhalterController.php @@ -22,11 +22,6 @@ class PlatzhalterController extends BaseController 'phase' => 'Phase 4', 'text' => 'Summen-, Buchungs-, Vergleichs- und Budgetbericht samt Grafiken.', ], - 'planung' => [ - 'titel' => 'Planung', - 'phase' => 'Phase 3', - 'text' => 'Budgetplaner für das Jahresbudget und der Monatsabschluss.', - ], 'wertpapiere' => [ 'titel' => 'Wertpapiere', 'phase' => 'Phase 6', diff --git a/src/Controllers/VerwaltungController.php b/src/Controllers/VerwaltungController.php index 5a3e0fa..e344a4e 100644 --- a/src/Controllers/VerwaltungController.php +++ b/src/Controllers/VerwaltungController.php @@ -92,9 +92,39 @@ class VerwaltungController extends BaseController $this->verlangeAnmeldung(); $this->render('verwaltung/kontengruppen.php', [ - 'titel' => 'Kontengruppen', - 'gruppen' => Kontengruppe::alle(), - 'konten' => Konto::alle(), + 'titel' => 'Kontengruppen', + 'gruppen' => Kontengruppe::alleMitKontenzahl(), + 'ohneGruppe' => Kontengruppe::anzahlOhneGruppe(), + ]); + } + + public function kontengruppeNeu(): void + { + $this->verlangeAnmeldung(); + $this->kontengruppeFormular(null); + } + + /** @param array $params */ + public function kontengruppeBearbeiten(array $params): void + { + $this->verlangeAnmeldung(); + + $gruppe = Kontengruppe::find((int)$params['id']); + if ($gruppe === null) { + View::redirect('/verwaltung/kontengruppen', 'Kontengruppe nicht gefunden.', 'fehler'); + return; + } + + $this->kontengruppeFormular($gruppe); + } + + /** @param array|null $gruppe */ + private function kontengruppeFormular(?array $gruppe): void + { + $this->render('verwaltung/kontengruppe-formular.php', [ + 'titel' => $gruppe === null ? 'Neue Kontengruppe' : 'Kontengruppe bearbeiten', + 'gruppe' => $gruppe, + 'konten' => Konto::alle(), ]); } @@ -103,13 +133,14 @@ class VerwaltungController extends BaseController $this->verlangeAnmeldung(); try { - Kontengruppe::anlegen( + $id = Kontengruppe::anlegen( $this->post('name'), $this->post('beschreibung'), (int)$this->post('sortierung', '0') ); + Kontengruppe::kontenZuordnen($id, $this->postIds('konten')); } catch (\Throwable $e) { - View::redirect('/verwaltung/kontengruppen', $e->getMessage(), 'fehler'); + View::redirect('/verwaltung/kontengruppen/neu', $e->getMessage(), 'fehler'); return; } @@ -121,15 +152,18 @@ class VerwaltungController extends BaseController { $this->verlangeAnmeldung(); + $id = (int)$params['id']; + try { Kontengruppe::speichern( - (int)$params['id'], + $id, $this->post('name'), $this->post('beschreibung'), (int)$this->post('sortierung', '0') ); + Kontengruppe::kontenZuordnen($id, $this->postIds('konten')); } catch (\Throwable $e) { - View::redirect('/verwaltung/kontengruppen', $e->getMessage(), 'fehler'); + View::redirect('/verwaltung/kontengruppen/' . $id, $e->getMessage(), 'fehler'); return; } diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index 1f8530b..bd8977b 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -61,6 +61,7 @@ class Buchung public static function anlegen(array $daten, array $splits = [], array $adressen = []): int { $felder = self::normalisiere($daten, $splits); + Monatsabschluss::verlangeOffen($felder['datum']); return (int)Database::transaktion(static function () use ($felder, $splits, $adressen): int { Database::execute( @@ -91,12 +92,18 @@ class Buchung */ public static function speichern(int $id, array $daten, array $splits = [], array $adressen = []): void { - if (self::find($id) === null) { + $bisher = self::find($id); + if ($bisher === null) { throw new \RuntimeException('Die Buchung gibt es nicht.'); } $felder = self::normalisiere($daten, $splits); + // Beide Seiten prüfen: Eine Buchung darf weder in einen abgeschlossenen + // Monat hinein noch aus einem heraus verschoben werden. + Monatsabschluss::verlangeOffen((string)$bisher['datum']); + Monatsabschluss::verlangeOffen($felder['datum']); + Database::transaktion(static function () use ($id, $felder, $splits, $adressen): void { Database::execute( 'UPDATE buchungen SET konto_id = ?, datum = ?, valuta = ?, betrag = ?, empfaenger = ?, @@ -120,6 +127,11 @@ class Buchung public static function loeschen(int $id): void { + $buchung = self::find($id); + if ($buchung !== null) { + Monatsabschluss::verlangeOffen((string)$buchung['datum']); + } + Database::execute('DELETE FROM buchungen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); } diff --git a/src/Models/Budget.php b/src/Models/Budget.php new file mode 100644 index 0000000..bb70e14 --- /dev/null +++ b/src/Models/Budget.php @@ -0,0 +1,254 @@ +> */ + public static function alle(): array + { + return Database::fetchAll( + 'SELECT * FROM budgets WHERE benutzer_id = ? ORDER BY jahr DESC, name', + [Mandant::id()] + ); + } + + public static function find(int $id): ?array + { + $zeile = Database::fetchOne( + 'SELECT * FROM budgets 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 budgets WHERE name = ? AND benutzer_id = ?', + [trim($name), Mandant::id()] + ); + return $zeile === false ? null : $zeile; + } + + /** Das aktive Budget, ersatzweise das jüngste – oder null, wenn keines existiert. */ + public static function aktives(): ?array + { + $zeile = Database::fetchOne( + 'SELECT * FROM budgets WHERE benutzer_id = ? ORDER BY aktiv DESC, jahr DESC, name LIMIT 1', + [Mandant::id()] + ); + return $zeile === false ? null : $zeile; + } + + public static function anlegen(string $name, int $jahr, string $beschreibung = '', bool $aktiv = false): int + { + $name = trim($name); + if ($name === '') { + throw new \InvalidArgumentException('Das Budget braucht einen Namen.'); + } + if ($jahr < 1900 || $jahr > 2200) { + throw new \InvalidArgumentException('Das Jahr des Budgets ist ungültig.'); + } + if (self::findeNachName($name) !== null) { + throw new \RuntimeException('Das Budget „' . $name . '“ gibt es bereits.'); + } + + Database::execute( + 'INSERT INTO budgets (benutzer_id, name, jahr, beschreibung, aktiv) VALUES (?, ?, ?, ?, 0)', + [Mandant::id(), $name, $jahr, $beschreibung] + ); + $id = Database::lastInsertId(); + + // Das erste Budget ist immer das aktive – sonst zeigt der Planer nichts. + if ($aktiv || self::anzahl() === 1) { + self::aktivieren($id); + } + + return $id; + } + + public static function speichern(int $id, string $name, int $jahr, string $beschreibung): void + { + $name = trim($name); + if ($name === '') { + throw new \InvalidArgumentException('Das Budget braucht einen Namen.'); + } + + $andere = self::findeNachName($name); + if ($andere !== null && (int)$andere['id'] !== $id) { + throw new \RuntimeException('Das Budget „' . $name . '“ gibt es bereits.'); + } + + Database::execute( + 'UPDATE budgets SET name = ?, jahr = ?, beschreibung = ? WHERE id = ? AND benutzer_id = ?', + [$name, $jahr, $beschreibung, $id, Mandant::id()] + ); + } + + public static function aktivieren(int $id): void + { + Database::transaktion(static function () use ($id): void { + Database::execute('UPDATE budgets SET aktiv = 0 WHERE benutzer_id = ?', [Mandant::id()]); + Database::execute( + 'UPDATE budgets SET aktiv = 1 WHERE id = ? AND benutzer_id = ?', + [$id, Mandant::id()] + ); + }); + } + + public static function loeschen(int $id): void + { + $war = self::find($id); + Database::execute('DELETE FROM budgets WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); + + // War es das aktive, rückt das nächste nach – ohne aktives Budget stünde + // der Planer leer da, obwohl es noch Budgets gibt. + if ($war !== null && (int)$war['aktiv'] === 1) { + $naechstes = self::aktives(); + if ($naechstes !== null) { + self::aktivieren((int)$naechstes['id']); + } + } + } + + public static function anzahl(): int + { + return (int)Database::fetchValue( + 'SELECT COUNT(*) FROM budgets WHERE benutzer_id = ?', + [Mandant::id()], + 0 + ); + } + + /** + * Alle Positionen eines Budgets als [kategorie_id][monat] => Betrag. + * + * @return array> + */ + public static function positionen(int $budgetId): array + { + $zeilen = Database::fetchAll( + 'SELECT kategorie_id, monat, betrag FROM budget_positionen WHERE budget_id = ?', + [$budgetId] + ); + + $werte = []; + foreach ($zeilen as $zeile) { + $werte[(int)$zeile['kategorie_id']][(int)$zeile['monat']] = (float)$zeile['betrag']; + } + + return $werte; + } + + /** + * Setzt einen Monatswert. Der Betrag kommt ohne Vorzeichen aus dem Formular + * und bekommt es hier nach der Kategorieart. Ein leeres Feld (null) löscht + * die Position – „nicht geplant“ ist etwas anderes als „0,00 geplant“. + */ + public static function positionSetzen(int $budgetId, int $kategorieId, int $monat, ?float $betrag): void + { + if ($monat < 1 || $monat > 12) { + throw new \InvalidArgumentException('Monat muss zwischen 1 und 12 liegen.'); + } + + if ($betrag === null) { + Database::execute( + 'DELETE FROM budget_positionen WHERE budget_id = ? AND kategorie_id = ? AND monat = ?', + [$budgetId, $kategorieId, $monat] + ); + return; + } + + $betrag = self::mitVorzeichen($kategorieId, $betrag); + + Database::execute( + 'INSERT INTO budget_positionen (budget_id, kategorie_id, monat, betrag) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE betrag = VALUES(betrag)', + [$budgetId, $kategorieId, $monat, $betrag] + ); + } + + /** + * Trägt den Wert eines Monats in alle folgenden Monate des Jahres ein – + * der „auf Folgemonate übertragen“-Knopf des FinanzManagers. + */ + public static function aufFolgemonateUebertragen(int $budgetId, int $kategorieId, int $abMonat): void + { + $betrag = Database::fetchValue( + 'SELECT betrag FROM budget_positionen WHERE budget_id = ? AND kategorie_id = ? AND monat = ?', + [$budgetId, $kategorieId, $abMonat] + ); + + Database::transaktion(static function () use ($budgetId, $kategorieId, $abMonat, $betrag): void { + for ($monat = $abMonat + 1; $monat <= 12; $monat++) { + if ($betrag === null) { + Database::execute( + 'DELETE FROM budget_positionen WHERE budget_id = ? AND kategorie_id = ? AND monat = ?', + [$budgetId, $kategorieId, $monat] + ); + continue; + } + + Database::execute( + 'INSERT INTO budget_positionen (budget_id, kategorie_id, monat, betrag) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE betrag = VALUES(betrag)', + [$budgetId, $kategorieId, $monat, (float)$betrag] + ); + } + }); + } + + /** + * Legt eine Kopie samt aller Positionen an – so entsteht das Budget für das + * neue Jahr aus dem laufenden, statt 105 Kategorien noch einmal zu tippen. + */ + public static function kopieren(int $id, string $neuerName, int $neuesJahr): int + { + $vorlage = self::find($id); + if ($vorlage === null) { + throw new \RuntimeException('Das zu kopierende Budget gibt es nicht.'); + } + + return (int)Database::transaktion(static function () use ($vorlage, $neuerName, $neuesJahr): int { + $neu = self::anlegen($neuerName, $neuesJahr, (string)$vorlage['beschreibung']); + + Database::execute( + 'INSERT INTO budget_positionen (budget_id, kategorie_id, monat, betrag) + SELECT ?, kategorie_id, monat, betrag FROM budget_positionen WHERE budget_id = ?', + [$neu, (int)$vorlage['id']] + ); + + return $neu; + }); + } + + /** Ausgaben negativ, Einnahmen positiv – unabhängig davon, wie eingegeben wurde. */ + private static function mitVorzeichen(int $kategorieId, float $betrag): float + { + $art = Database::fetchValue( + 'SELECT art FROM kategorien WHERE id = ? AND benutzer_id = ?', + [$kategorieId, Mandant::id()] + ); + + $betrag = abs($betrag); + return $art === 'einnahme' ? $betrag : -$betrag; + } +} diff --git a/src/Models/Kontengruppe.php b/src/Models/Kontengruppe.php index 3ebd9ac..8d6b4af 100644 --- a/src/Models/Kontengruppe.php +++ b/src/Models/Kontengruppe.php @@ -25,6 +25,34 @@ class Kontengruppe ); } + /** + * Wie alle(), zusätzlich mit der Zahl der zugeordneten Konten – die Liste + * zeigt sie in einer eigenen Spalte. + * + * @return list> + */ + public static function alleMitKontenzahl(): array + { + return Database::fetchAll( + 'SELECT g.*, COUNT(k.id) AS anzahl_konten + FROM kontengruppen g + LEFT JOIN konten k ON k.kontengruppe_id = g.id + WHERE g.benutzer_id = ? + GROUP BY g.id + ORDER BY g.sortierung, g.name', + [Mandant::id()] + ); + } + + /** Konten, die unter „Nicht gruppiert“ stehen. */ + public static function anzahlOhneGruppe(): int + { + return (int)Database::fetchValue( + 'SELECT COUNT(*) FROM konten WHERE benutzer_id = ? AND kontengruppe_id IS NULL', + [Mandant::id()] + ); + } + public static function find(int $id): ?array { $zeile = Database::fetchOne( @@ -84,6 +112,34 @@ class Kontengruppe ); } + /** + * Legt fest, welche Konten zur Gruppe gehören. Konten, die vorher in der + * Gruppe standen und jetzt fehlen, rutschen zurück zu „Nicht gruppiert“ – + * Konten anderer Gruppen bleiben unberührt. + * + * @param list $kontoIds + */ + public static function kontenZuordnen(int $id, array $kontoIds): void + { + Database::execute( + 'UPDATE konten SET kontengruppe_id = NULL + WHERE kontengruppe_id = ? AND benutzer_id = ?', + [$id, Mandant::id()] + ); + + $kontoIds = array_values(array_unique(array_map('intval', $kontoIds))); + if ($kontoIds === []) { + return; + } + + $platzhalter = implode(', ', array_fill(0, count($kontoIds), '?')); + Database::execute( + 'UPDATE konten SET kontengruppe_id = ? + WHERE benutzer_id = ? AND id IN (' . $platzhalter . ')', + array_merge([$id, Mandant::id()], $kontoIds) + ); + } + /** Die zugeordneten Konten rutschen per ON DELETE SET NULL zu „Nicht gruppiert“. */ public static function loeschen(int $id): void { diff --git a/src/Models/Monatsabschluss.php b/src/Models/Monatsabschluss.php new file mode 100644 index 0000000..8ad3a24 --- /dev/null +++ b/src/Models/Monatsabschluss.php @@ -0,0 +1,312 @@ +> */ + public static function alle(?int $jahr = null): array + { + $sql = 'SELECT * FROM monatsabschluesse WHERE benutzer_id = ?'; + $params = [Mandant::id()]; + + if ($jahr !== null) { + $sql .= ' AND jahr = ?'; + $params[] = $jahr; + } + + return Database::fetchAll($sql . ' ORDER BY jahr DESC, monat DESC', $params); + } + + public static function find(int $id): ?array + { + $zeile = Database::fetchOne( + 'SELECT * FROM monatsabschluesse WHERE id = ? AND benutzer_id = ?', + [$id, Mandant::id()] + ); + return $zeile === false ? null : $zeile; + } + + public static function fuerMonat(int $jahr, int $monat): ?array + { + $zeile = Database::fetchOne( + 'SELECT * FROM monatsabschluesse WHERE benutzer_id = ? AND jahr = ? AND monat = ?', + [Mandant::id(), $jahr, $monat] + ); + return $zeile === false ? null : $zeile; + } + + /** Liegt das Datum in einem abgeschlossenen Monat? */ + public static function istGesperrt(string $datum): bool + { + if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $datum, $treffer)) { + return false; + } + + return (int)Database::fetchValue( + 'SELECT COUNT(*) FROM monatsabschluesse + WHERE benutzer_id = ? AND jahr = ? AND monat = ? AND status = ?', + [Mandant::id(), (int)$treffer[1], (int)$treffer[2], self::STATUS_ABGESCHLOSSEN], + 0 + ) > 0; + } + + /** + * Wirft, wenn in einem abgeschlossenen Monat gebucht werden soll. Die + * Prüfung lässt sich in den Einstellungen abschalten – etwa während eines + * Imports, der ältere Zeiträume nachträgt. + */ + public static function verlangeOffen(string $datum): void + { + if (Einstellung::get('monatsabschluss_sperrt_buchungen', '1') !== '1') { + return; + } + if (!self::istGesperrt($datum)) { + return; + } + + throw new \RuntimeException( + 'Der Monat ' . substr($datum, 5, 2) . '/' . substr($datum, 0, 4) + . ' ist abgeschlossen. Zum Buchen muss er wiedereröffnet werden.' + ); + } + + /** + * Prüflauf vor dem Abschluss: Was im Monat noch klemmt. Jeder Punkt ist ein + * Hinweis, kein Hindernis – abschließen lässt sich trotzdem, der + * FinanzManager hält es genauso. + * + * @return array{buchungen: int, ohne_kategorie: list>, + * nicht_abgeglichen: int, splitdifferenzen: list>} + */ + public static function pruefen(int $jahr, int $monat): array + { + [$von, $bis] = self::zeitraum($jahr, $monat); + $p = [Mandant::id(), $von, $bis]; + + $ohneKategorie = Database::fetchAll( + 'SELECT b.id, b.datum, b.betrag, b.empfaenger, k.name AS konto + FROM buchungen b + JOIN konten k ON k.id = b.konto_id + WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ? + AND b.gesplittet = 0 AND b.kategorie_id IS NULL AND b.gegenkonto_id IS NULL + ORDER BY b.datum, b.id', + $p + ); + + $splitdifferenzen = Database::fetchAll( + 'SELECT b.id, b.datum, b.betrag, b.empfaenger, k.name AS konto, + COALESCE(SUM(s.betrag), 0) AS summe_splits + FROM buchungen b + JOIN konten k ON k.id = b.konto_id + LEFT JOIN buchung_splits s ON s.buchung_id = b.id + WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ? AND b.gesplittet = 1 + GROUP BY b.id + HAVING ABS(COALESCE(SUM(s.betrag), 0) - b.betrag) > 0.004 + ORDER BY b.datum, b.id', + $p + ); + + return [ + 'buchungen' => (int)Database::fetchValue( + 'SELECT COUNT(*) FROM buchungen WHERE benutzer_id = ? AND datum BETWEEN ? AND ?', + $p, + 0 + ), + 'ohne_kategorie' => $ohneKategorie, + 'nicht_abgeglichen' => (int)Database::fetchValue( + 'SELECT COUNT(*) FROM buchungen + WHERE benutzer_id = ? AND datum BETWEEN ? AND ? AND abgeglichen = 0', + $p, + 0 + ), + 'splitdifferenzen' => $splitdifferenzen, + ]; + } + + /** + * Schließt den Monat ab. Der Snapshot hält die Kontosalden zum Monatsende + * und die Kategoriesummen des Monats fest. + */ + public static function abschliessen(int $jahr, int $monat, string $notiz = '', string $von = ''): int + { + self::pruefeMonat($jahr, $monat); + + $vorhanden = self::fuerMonat($jahr, $monat); + if ($vorhanden !== null && $vorhanden['status'] === self::STATUS_ABGESCHLOSSEN) { + throw new \RuntimeException('Der Monat ist bereits abgeschlossen.'); + } + + $snapshot = json_encode(self::snapshot($jahr, $monat), JSON_UNESCAPED_UNICODE); + + Database::execute( + 'INSERT INTO monatsabschluesse + (benutzer_id, jahr, monat, status, notiz, snapshot, abgeschlossen_am, abgeschlossen_von) + VALUES (?, ?, ?, ?, ?, ?, NOW(), ?) + ON DUPLICATE KEY UPDATE + status = VALUES(status), notiz = VALUES(notiz), snapshot = VALUES(snapshot), + abgeschlossen_am = NOW(), abgeschlossen_von = VALUES(abgeschlossen_von), + wiedereroeffnet_am = NULL', + [Mandant::id(), $jahr, $monat, self::STATUS_ABGESCHLOSSEN, $notiz, $snapshot, $von] + ); + + $zeile = self::fuerMonat($jahr, $monat); + return $zeile === null ? 0 : (int)$zeile['id']; + } + + /** + * Hebt die Sperre wieder auf. Der Snapshot bleibt stehen: Er zeigt, mit + * welchen Zahlen der Monat einmal abgeschlossen wurde. + */ + public static function wiedereroeffnen(int $id): void + { + Database::execute( + 'UPDATE monatsabschluesse + SET status = ?, wiedereroeffnet_am = NOW() + WHERE id = ? AND benutzer_id = ?', + [self::STATUS_WIEDEREROEFFNET, $id, Mandant::id()] + ); + } + + /** + * Vorschlag für den nächsten Abschluss: der älteste Monat mit Buchungen, + * der noch offen ist. Abgeschlossen wird der Reihe nach, deshalb läuft die + * Suche vom Vormonat rückwärts und endet am ersten bereits abgeschlossenen + * Monat. Der laufende Monat kommt nie infrage – er ist noch nicht vorbei. + * + * @return array{jahr: int, monat: int} + */ + public static function naechsterOffener(?string $heute = null): array + { + $heute = $heute ?? date('Y-m-d'); + $jahr = (int)substr($heute, 0, 4); + $monat = (int)substr($heute, 5, 2); + + // Vom Vormonat aus rückwärts bis zu zwei Jahre suchen. + [$jahr, $monat] = self::vormonat($jahr, $monat); + + $kandidat = ['jahr' => $jahr, 'monat' => $monat]; + for ($i = 0; $i < 24; $i++) { + $abschluss = self::fuerMonat($jahr, $monat); + if ($abschluss !== null && $abschluss['status'] === self::STATUS_ABGESCHLOSSEN) { + return $kandidat; + } + + $hatBuchungen = (int)Database::fetchValue( + 'SELECT COUNT(*) FROM buchungen WHERE benutzer_id = ? AND datum BETWEEN ? AND ?', + array_merge([Mandant::id()], self::zeitraum($jahr, $monat)), + 0 + ) > 0; + + if ($hatBuchungen) { + $kandidat = ['jahr' => $jahr, 'monat' => $monat]; + } + + [$jahr, $monat] = self::vormonat($jahr, $monat); + } + + return $kandidat; + } + + /** + * Kontosalden zum Monatsende und Kategoriesummen des Monats. + * + * @return array + */ + public static function snapshot(int $jahr, int $monat): array + { + [$von, $bis] = self::zeitraum($jahr, $monat); + + $salden = Buchung::saldenJeKonto($bis); + $konten = []; + foreach (Konto::alle() as $konto) { + $id = (int)$konto['id']; + $konten[] = [ + 'id' => $id, + 'name' => $konto['name'], + 'in_summe' => (int)$konto['in_summe'], + 'saldo' => round((float)$konto['anfangssaldo'] + ($salden[$id] ?? 0.0), 2), + ]; + } + + $kategorien = Database::fetchAll( + 'SELECT kategorie_id, SUM(betrag) AS betrag FROM ( + SELECT b.kategorie_id AS kategorie_id, b.betrag AS betrag + FROM buchungen b + WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ? + AND b.gesplittet = 0 AND b.kategorie_id IS NOT NULL + UNION ALL + SELECT s.kategorie_id AS kategorie_id, s.betrag AS betrag + FROM buchung_splits s + JOIN buchungen b ON b.id = s.buchung_id + WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ? + AND b.gesplittet = 1 AND s.kategorie_id IS NOT NULL + ) AS bewegungen + GROUP BY kategorie_id', + [Mandant::id(), $von, $bis, Mandant::id(), $von, $bis] + ); + + $summen = []; + foreach ($kategorien as $zeile) { + $summen[] = ['kategorie_id' => (int)$zeile['kategorie_id'], 'betrag' => round((float)$zeile['betrag'], 2)]; + } + + $summeKonten = 0.0; + foreach ($konten as $konto) { + if ($konto['in_summe'] === 1) { + $summeKonten += $konto['saldo']; + } + } + + return [ + 'erstellt_am' => date('c'), + 'zeitraum' => ['von' => $von, 'bis' => $bis], + 'konten' => $konten, + 'summe_kontostand' => round($summeKonten, 2), + 'kategorien' => $summen, + ]; + } + + /** @return array{0: string, 1: string} erster und letzter Tag des Monats */ + public static function zeitraum(int $jahr, int $monat): array + { + self::pruefeMonat($jahr, $monat); + + $von = sprintf('%04d-%02d-01', $jahr, $monat); + $bis = date('Y-m-t', (int)strtotime($von)); + + return [$von, $bis]; + } + + /** @return array{0: int, 1: int} */ + private static function vormonat(int $jahr, int $monat): array + { + return $monat === 1 ? [$jahr - 1, 12] : [$jahr, $monat - 1]; + } + + private static function pruefeMonat(int $jahr, int $monat): void + { + if ($monat < 1 || $monat > 12) { + throw new \InvalidArgumentException('Monat muss zwischen 1 und 12 liegen.'); + } + if ($jahr < 1900 || $jahr > 2200) { + throw new \InvalidArgumentException('Das Jahr ist ungültig.'); + } + } +} diff --git a/src/Services/Budgetplaner.php b/src/Services/Budgetplaner.php new file mode 100644 index 0000000..05972e6 --- /dev/null +++ b/src/Services/Budgetplaner.php @@ -0,0 +1,221 @@ + 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April', + 5 => 'Mai', 6 => 'Juni', 7 => 'Juli', 8 => 'August', + 9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember', + ]; + + public const MONATSKUERZEL = [ + 1 => 'Jan', 2 => 'Feb', 3 => 'Mär', 4 => 'Apr', 5 => 'Mai', 6 => 'Jun', + 7 => 'Jul', 8 => 'Aug', 9 => 'Sep', 10 => 'Okt', 11 => 'Nov', 12 => 'Dez', + ]; + + /** + * Ist-Werte je Kategorie und Monat aus den Buchungen eines Jahres. + * + * Gesplittete Buchungen zählen über ihre Splitzeilen, alle anderen über die + * Buchung selbst – sonst würde eine Splitbuchung doppelt oder gar nicht + * erfasst. Umbuchungen tragen keine Kategorie und bleiben damit außen vor, + * wie im FinanzManager auch. + * + * @return array> [kategorie_id][monat] => Betrag + */ + public static function istWerte(int $jahr): array + { + $zeilen = Database::fetchAll( + 'SELECT kategorie_id, monat, SUM(betrag) AS betrag FROM ( + SELECT b.kategorie_id AS kategorie_id, MONTH(b.datum) AS monat, b.betrag AS betrag + FROM buchungen b + WHERE b.benutzer_id = ? AND YEAR(b.datum) = ? + AND b.gesplittet = 0 AND b.kategorie_id IS NOT NULL + UNION ALL + SELECT s.kategorie_id AS kategorie_id, MONTH(b.datum) AS monat, s.betrag AS betrag + FROM buchung_splits s + JOIN buchungen b ON b.id = s.buchung_id + WHERE b.benutzer_id = ? AND YEAR(b.datum) = ? + AND b.gesplittet = 1 AND s.kategorie_id IS NOT NULL + ) AS bewegungen + GROUP BY kategorie_id, monat', + [Mandant::id(), $jahr, Mandant::id(), $jahr] + ); + + $werte = []; + foreach ($zeilen as $zeile) { + $werte[(int)$zeile['kategorie_id']][(int)$zeile['monat']] = (float)$zeile['betrag']; + } + + return $werte; + } + + /** + * Die vollständige Tabelle: eine Zeile je Kategorie in Baumreihenfolge, + * dazu die Gesamtzeile. + * + * @param array $budget + * @return array{zeilen: list>, gesamt: array, jahr: int} + */ + public static function tabelle(array $budget): array + { + $jahr = (int)$budget['jahr']; + $geplant = Budget::positionen((int)$budget['id']); + $ist = self::istWerte($jahr); + + $kategorien = Kategorie::baumFlach(); + + // Erst die eigenen Werte je Kategorie, dann von unten nach oben die + // Summen der Nachfahren aufaddieren. + $zeilen = []; + foreach ($kategorien as $kategorie) { + $id = (int)$kategorie['id']; + + $zeilen[$id] = $kategorie + [ + 'budget' => self::monatsreihe($geplant[$id] ?? []), + 'ist' => self::monatsreihe($ist[$id] ?? []), + // Ungefüllt statt mit Nullen aufgefüllt: Nur so bleibt „nicht + // geplant“ von „mit 0,00 geplant“ unterscheidbar. Der Planer + // zeigt in dem einen Fall ein leeres Feld, im anderen eine Null. + 'eigenBudget' => $geplant[$id] ?? [], + ]; + } + + foreach (array_reverse(array_keys($zeilen)) as $id) { + $eltern = $zeilen[$id]['eltern_id'] === null ? null : (int)$zeilen[$id]['eltern_id']; + if ($eltern === null || !isset($zeilen[$eltern])) { + continue; + } + for ($monat = 1; $monat <= 12; $monat++) { + $zeilen[$eltern]['budget'][$monat] += $zeilen[$id]['budget'][$monat]; + $zeilen[$eltern]['ist'][$monat] += $zeilen[$id]['ist'][$monat]; + } + } + + $gesamtBudget = self::monatsreihe([]); + $gesamtIst = self::monatsreihe([]); + + foreach ($zeilen as &$zeile) { + $zeile['saldo'] = self::differenz($zeile['ist'], $zeile['budget']); + $zeile['jahrBudget'] = array_sum($zeile['budget']); + $zeile['jahrIst'] = array_sum($zeile['ist']); + $zeile['jahrSaldo'] = $zeile['jahrIst'] - $zeile['jahrBudget']; + $zeile['geplant'] = $zeile['jahrBudget'] !== 0.0 || $zeile['jahrIst'] !== 0.0; + + // Nur Wurzeln summieren, sonst zählten Unterkategorien mehrfach. + if ($zeile['eltern_id'] === null) { + for ($monat = 1; $monat <= 12; $monat++) { + $gesamtBudget[$monat] += $zeile['budget'][$monat]; + $gesamtIst[$monat] += $zeile['ist'][$monat]; + } + } + } + unset($zeile); + + return [ + 'jahr' => $jahr, + 'zeilen' => array_values($zeilen), + 'gesamt' => [ + 'budget' => $gesamtBudget, + 'ist' => $gesamtIst, + 'saldo' => self::differenz($gesamtIst, $gesamtBudget), + 'jahrBudget' => array_sum($gesamtBudget), + 'jahrIst' => array_sum($gesamtIst), + 'jahrSaldo' => array_sum($gesamtIst) - array_sum($gesamtBudget), + ], + ]; + } + + /** + * Summiert einen Monatsbereich einer Zeile – für die zusammengefassten + * Spalten „Januar bis Vormonat“ und den Jahresüberblick. + * + * @param array $reihe + */ + public static function summe(array $reihe, int $vonMonat, int $bisMonat): float + { + $summe = 0.0; + for ($monat = max(1, $vonMonat); $monat <= min(12, $bisMonat); $monat++) { + $summe += $reihe[$monat] ?? 0.0; + } + return $summe; + } + + /** + * Bis zu welchem Monat das Jahr abgelaufen ist: im laufenden Jahr der + * Vormonat, in vergangenen Jahren der Dezember, in künftigen gar nichts. + */ + public static function abgelaufenBis(int $jahr, ?string $heute = null): int + { + $heute = $heute ?? date('Y-m-d'); + $jetztJahr = (int)substr($heute, 0, 4); + $jetztMonat = (int)substr($heute, 5, 2); + + if ($jahr < $jetztJahr) { + return 12; + } + if ($jahr > $jetztJahr) { + return 0; + } + return $jetztMonat - 1; + } + + /** Der Monat, der im laufenden Jahr gerade läuft – sonst 0. */ + public static function laufenderMonat(int $jahr, ?string $heute = null): int + { + $heute = $heute ?? date('Y-m-d'); + return $jahr === (int)substr($heute, 0, 4) ? (int)substr($heute, 5, 2) : 0; + } + + /** + * @param array $werte + * @return array + */ + private static function monatsreihe(array $werte): array + { + $reihe = []; + for ($monat = 1; $monat <= 12; $monat++) { + $reihe[$monat] = (float)($werte[$monat] ?? 0.0); + } + return $reihe; + } + + /** + * @param array $ist + * @param array $budget + * @return array + */ + private static function differenz(array $ist, array $budget): array + { + $saldo = []; + for ($monat = 1; $monat <= 12; $monat++) { + $saldo[$monat] = $ist[$monat] - $budget[$monat]; + } + return $saldo; + } +} diff --git a/src/Views/auth/einrichtung.php b/src/Views/auth/einrichtung.php index b18d1c4..98825d0 100644 --- a/src/Views/auth/einrichtung.php +++ b/src/Views/auth/einrichtung.php @@ -26,7 +26,9 @@ ob_start(); - +
+ +
Passwort - +
+ +
$budget */ +/** @var string $csrfToken */ + +include __DIR__ . '/../layout/kopf.php'; +?> + + + +
+ + + + + + + +

+ Das Jahr bestimmt, aus welchen Buchungen die Ist-Werte kommen. Die geplanten + Monatswerte bleiben beim Wechsel erhalten. +

+ + + + +
+ + Abbrechen +
+
+ + diff --git a/src/Views/planung/budget-leer.php b/src/Views/planung/budget-leer.php new file mode 100644 index 0000000..7684042 --- /dev/null +++ b/src/Views/planung/budget-leer.php @@ -0,0 +1,40 @@ + + + + +
+

Noch kein Budget

+

+ Ein Budget gilt für ein Jahr und hält je Kategorie zwölf Monatswerte. Ist und Saldo + rechnet der Planer laufend aus den Buchungen – geplant wird nur der Budgetwert. +

+ +
+ + + + +

Im FinanzManager heißen die Budgets etwa „2026-ecclesia“.

+ + + + + + + +
+ +
+
+
+ + diff --git a/src/Views/planung/budget-verwaltung.php b/src/Views/planung/budget-verwaltung.php new file mode 100644 index 0000000..5222783 --- /dev/null +++ b/src/Views/planung/budget-verwaltung.php @@ -0,0 +1,123 @@ +> $budgets */ +/** @var int $jahr */ +/** @var string $csrfToken */ + +include __DIR__ . '/../layout/kopf.php'; +?> + + + +

+ Mehrere Budgets je Jahr sind möglich; das aktive (★) öffnet der Planer beim Aufruf. + Ein Budget für das nächste Jahr entsteht am schnellsten als Kopie des laufenden – + dabei werden alle Monatswerte übernommen. +

+ + +

Noch kein Budget angelegt.

+ + + + + + + + + + + + + + + + + + +
NameJahrBeschreibungAktion
+ + + + + ★ aktiv + + + Bearbeiten + +
+ + +
+ +
+ + +
+
+ + +
+
+

Neues Budget

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

Budget kopieren

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

+ Übernommen werden alle Monatswerte der Vorlage. Ist-Werte entstehen im neuen + Jahr aus den Buchungen. +

+
+ +
+ + diff --git a/src/Views/planung/budgetplaner.php b/src/Views/planung/budgetplaner.php new file mode 100644 index 0000000..4e5e600 --- /dev/null +++ b/src/Views/planung/budgetplaner.php @@ -0,0 +1,242 @@ + $budget */ +/** @var list> $budgets */ +/** @var array{zeilen: list>, gesamt: array, jahr: int} $tabelle */ +/** @var string $ansicht */ +/** @var int $abgelaufenBis */ +/** @var int $laufenderMonat */ +/** @var bool $nurGeplante */ +/** @var string $csrfToken */ + +$jahr = (int)$budget['jahr']; + +// Welche Monate bekommen eine Eingabespalte? In der Verlaufsansicht sind das +// der laufende Monat und alles danach – abgelaufene Monate fasst eine einzige +// Spalte zusammen, wie im FinanzManager. „Alle Monate“ zeigt alle zwölf. +$alleMonate = $ansicht === 'alle'; +$erster = $alleMonate ? 1 : max(1, $laufenderMonat === 0 ? 1 : $laufenderMonat); +$eingabe = range($erster, 12); + +$zelle = static function (float $wert): string { + return View::betrag($wert, false); +}; + +$link = static function (array $ersatz) use ($budget, $ansicht, $nurGeplante): string { + $parameter = array_merge([ + 'budget' => (int)$budget['id'], + 'ansicht' => $ansicht, + 'nur_geplante' => $nurGeplante ? '1' : null, + ], $ersatz); + + return '/planung/budget?' . http_build_query(array_filter( + $parameter, + static fn($wert) => $wert !== null && $wert !== '' + )); +}; + +include __DIR__ . '/../layout/kopf.php'; +?> + +
+
+ + + + + + +
+ + + + + + + + + Budgets verwalten + Zurück zur Planung +
+ +

+ Budget für . + Ausgaben werden ohne Vorzeichen eingetragen – die Kategorieart bestimmt es. + Ein leeres Feld heißt „nicht geplant“, eine 0 heißt „mit 0,00 geplant“. + Saldo ist Ist minus Budget: negativ bedeutet über Plan. + Übergeordnete Kategorien summieren ihre Unterkategorien und werden deshalb + nicht selbst beplant. +

+ +
+ + + + + + +
+ +
+ +
+ + + + + + 0): ?> + + + + + + + + + + + + 0): ?> + + + + + + + + + + + + + + + + + + + + + + + + + + 0): ?> + + + + + + + + + + + + + + + + + + + + + + + + + 0): ?> + + + + + + + + + + + + + + + +
KategorieJahr + Januar– + + + ↦
BudgetIstSaldoBudgetIstSaldoBudgetIstSaldoBudget
+ + + + + + + + + + + + + + + + + + + + + + +
Gesamt + + + + + + + +
+
+ +
+ +
+
+ + diff --git a/src/Views/planung/index.php b/src/Views/planung/index.php new file mode 100644 index 0000000..96d956c --- /dev/null +++ b/src/Views/planung/index.php @@ -0,0 +1,56 @@ +|null $budget */ +/** @var int $anzahl */ +/** @var array|null $letzter */ +/** @var array{jahr: int, monat: int} $naechster */ + +include __DIR__ . '/../layout/kopf.php'; +?> + +
+
+

Budgetplaner

+

+

+ + Noch kein Budget angelegt. Der Planer stellt je Kategorie zwölf Monatswerte + ein und rechnet Ist und Saldo aus den Buchungen. + + Aktives Budget: . + Budget insgesamt. + +

+ +
+ +
+

Monatsabschluss

+

+ + +

+

+ + Noch kein Monat abgeschlossen. Der Prüflauf zeigt vorher, was im Zeitraum + noch offen ist. + + Zuletzt abgeschlossen: + + + . + +

+ +
+
+ + diff --git a/src/Views/planung/monatsabschluss.php b/src/Views/planung/monatsabschluss.php new file mode 100644 index 0000000..71a5116 --- /dev/null +++ b/src/Views/planung/monatsabschluss.php @@ -0,0 +1,184 @@ +>, + * nicht_abgeglichen: int, splitdifferenzen: list>} $pruefung */ +/** @var array|null $vorhanden */ +/** @var list> $abschluesse */ +/** @var array $monatsnamen */ +/** @var string $csrfToken */ + +$abgeschlossen = $vorhanden !== null && $vorhanden['status'] === Monatsabschluss::STATUS_ABGESCHLOSSEN; +$offen = count($pruefung['ohne_kategorie']) + count($pruefung['splitdifferenzen']); + +include __DIR__ . '/../layout/kopf.php'; +?> + +
+
+ + + +
+ + Zurück zur Planung +
+ +
+
+

Prüflauf

+ + abgeschlossen + +
+ + + + + + + + + + + + + + + + + + + + +
Buchungen im Zeitraum
ohne Kategorie oder Gegenkonto + +
nicht abgeglichen
Splitbuchungen mit Differenz + +
+ + +

Nicht kategorisiert

+ + + + + + + + + + + + + + +
DatumKontoEmpfängerBetrag
+ +
+ + + +

Splitsummen weichen ab

+ + + + + + + + + + + + + + + + + + +
DatumKontoEmpfängerBuchungSumme der Splits
+ + + +

+ Abgeschlossen am + . + Buchungen in diesem Zeitraum sind gesperrt. +

+
+ Snapshot ansehen +
+ + +
+
+ +
0 ? 'data-bestaetigen="Es sind noch Punkte offen. Monat trotzdem abschließen?"' : '' ?>> + + + + + + +

+ Der Abschluss hält Kontosalden und Kategoriesummen als Snapshot fest und sperrt + Buchungen im Zeitraum. Offene Punkte hindern nicht daran – sie sind ein Hinweis. +

+ +
+ +
+
+ +
+ + +

Bisherige Abschlüsse

+ + + + + + + + + + + + + + + + + + + +
MonatStatusAbgeschlossen amVonNotizAktion
+ + + + abgeschlossen + + wieder geöffnet + + + Snapshot +
+ + + diff --git a/src/Views/planung/snapshot.php b/src/Views/planung/snapshot.php new file mode 100644 index 0000000..fe835f6 --- /dev/null +++ b/src/Views/planung/snapshot.php @@ -0,0 +1,84 @@ + $abschluss */ +/** @var array $snapshot */ +/** @var array $monatsnamen */ + +$namen = []; +foreach (Kategorie::alle() as $kategorie) { + $namen[(int)$kategorie['id']] = (string)$kategorie['pfad']; +} + +include __DIR__ . '/../layout/kopf.php'; +?> + + + +

+ Stand von + zum Zeitpunkt des Abschlusses. Die Zahlen bleiben stehen, auch wenn der Monat später + wiedereröffnet und nachgebucht wird – so lässt sich vergleichen, was sich geändert hat. +

+ + +

Zu diesem Abschluss ist kein Snapshot gespeichert.

+ +

Kontosalden

+ + + + + + + + + + + + + + + + + + +
KontoSaldo
+ + + ohne Σ + + + +
Summe Kontostände + +
+ +

Kategoriesummen des Monats

+ +

Keine kategorisierten Buchungen im Zeitraum.

+ + + + + + + + + + + + + +
KategorieBetrag
+ +
+ + + + diff --git a/src/Views/verwaltung/import.php b/src/Views/verwaltung/import.php index c544dad..ec5c946 100644 --- a/src/Views/verwaltung/import.php +++ b/src/Views/verwaltung/import.php @@ -59,9 +59,21 @@ include __DIR__ . '/../layout/kopf.php'; + + Kontengruppen + + nicht im QIF-Export enthalten – hier zuordnen + + +

+ Der FinanzManager exportiert je Konto nur Name, Kontoart, Beschreibung und + Kreditlimit. Die Zuordnung zu einer Kontengruppe steht weder im QIF noch in der + QSD-Datei; sie wird deshalb einmalig in der Verwaltung nachgetragen. +

+

Buchungen

@@ -141,6 +153,7 @@ include __DIR__ . '/../layout/kopf.php';

diff --git a/src/Views/verwaltung/kategorien.php b/src/Views/verwaltung/kategorien.php index 6b246d4..16d1c46 100644 --- a/src/Views/verwaltung/kategorien.php +++ b/src/Views/verwaltung/kategorien.php @@ -83,7 +83,7 @@ include __DIR__ . '/../layout/kopf.php'; >

- + diff --git a/src/Views/verwaltung/kontengruppe-formular.php b/src/Views/verwaltung/kontengruppe-formular.php new file mode 100644 index 0000000..3efb06e --- /dev/null +++ b/src/Views/verwaltung/kontengruppe-formular.php @@ -0,0 +1,88 @@ +|null $gruppe */ +/** @var list> $konten */ +/** @var string $csrfToken */ + +$neu = $gruppe === null; +$ziel = $neu ? '/verwaltung/kontengruppen/neu' : '/verwaltung/kontengruppen/' . (int)$gruppe['id']; +$wert = static fn(string $feld, string $standard = ''): string + => View::h((string)($gruppe[$feld] ?? $standard)); + +include __DIR__ . '/../layout/kopf.php'; +?> + + + +
+ + + + + + + + + + +

Kleinere Werte stehen in der Kontenübersicht weiter oben.

+ +

Konten in dieser Gruppe

+ +

Noch keine Konten angelegt.

+ +

+ Ein Konto gehört zu genau einer Gruppe. Wird hier eines angehakt, das derzeit in + einer anderen Gruppe steht, wechselt es hierher. +

+
+ + + + +
+ + +
+ + Abbrechen +
+
+ + +
+ +

Kontengruppe löschen

+

+ Die zugeordneten Konten bleiben bestehen und stehen anschließend unter + „“. +

+
+ +
+
+ + + diff --git a/src/Views/verwaltung/kontengruppen.php b/src/Views/verwaltung/kontengruppen.php index adacb95..c044141 100644 --- a/src/Views/verwaltung/kontengruppen.php +++ b/src/Views/verwaltung/kontengruppen.php @@ -4,24 +4,13 @@ use Finanzmanager\Models\Kontengruppe; use Finanzmanager\View; /** @var list> $gruppen */ -/** @var list> $konten */ -/** @var string $csrfToken */ - -$anzahlJeGruppe = []; -$ohneGruppe = 0; -foreach ($konten as $konto) { - if ($konto['kontengruppe_id'] === null) { - $ohneGruppe++; - continue; - } - $id = (int)$konto['kontengruppe_id']; - $anzahlJeGruppe[$id] = ($anzahlJeGruppe[$id] ?? 0) + 1; -} +/** @var int $ohneGruppe */ include __DIR__ . '/../layout/kopf.php'; ?> @@ -29,19 +18,22 @@ include __DIR__ . '/../layout/kopf.php';

Kontengruppen gliedern die Kontenübersicht. Konten ohne Gruppe erscheinen dort unter „“ – derzeit . + Der QIF-Export des FinanzManagers enthält keine Kontengruppen; sie werden hier + angelegt und die Konten ihnen zugeordnet.

-

Noch keine Kontengruppen angelegt.

+
+

Noch keine Kontengruppen

+

+ Ohne Gruppen steht in der Kontenübersicht nur der Block + „“. +

+ +
- -
-
- - @@ -51,28 +43,18 @@ include __DIR__ . '/../layout/kopf.php'; - - - - + + + @@ -80,22 +62,4 @@ include __DIR__ . '/../layout/kopf.php';
- - - + + + - - - - - - + Bearbeiten
-
- -

Neue Kontengruppe

- - - - - - - - - - -
- -
-
- diff --git a/tests/07_KontenTest.php b/tests/07_KontenTest.php index 22c5618..dbd6f92 100644 --- a/tests/07_KontenTest.php +++ b/tests/07_KontenTest.php @@ -19,6 +19,51 @@ class KontenTest extends TestCase $this->uebersichtGruppiertUndSummiert(); $this->betragsEingabe(); $this->ankreuzfelderAusDemFormular(); + $this->kontenEinerGruppeZuordnen(); + } + + /** + * Die Kontengruppen stehen nicht im QIF-Export; sie werden nach dem Import + * angelegt und die Konten in einem Zug angekreuzt. Abgehakte Konten müssen + * dabei zurück nach „Nicht gruppiert“ fallen, Konten anderer Gruppen aber + * unberührt bleiben. + */ + private function kontenEinerGruppeZuordnen(): void + { + $anlage = Kontengruppe::anlegen('Anlage', '', 1); + $liquide = Kontengruppe::anlegen('Liquide Mittel', '', 2); + + $haus = Konto::anlegen(['name' => 'Whg Besigheim', 'kontoart' => 'vermoegen', 'aktiv' => 1]); + $auto = Konto::anlegen(['name' => 'Skoda Kamiq', 'kontoart' => 'vermoegen', 'aktiv' => 1]); + $giro = Konto::anlegen(['name' => 'VR Weil Girokonto', 'kontoart' => 'bank', 'aktiv' => 1]); + + Kontengruppe::kontenZuordnen($liquide, [$giro]); + Kontengruppe::kontenZuordnen($anlage, [$haus, $auto]); + + $this->assertEquals($anlage, (int)Konto::find($haus)['kontengruppe_id'], 'Konto landet in der Gruppe'); + $this->assertEquals($anlage, (int)Konto::find($auto)['kontengruppe_id'], 'Auch das zweite Konto'); + $this->assertEquals( + $liquide, + (int)Konto::find($giro)['kontengruppe_id'], + 'Konten anderer Gruppen bleiben, wo sie sind' + ); + + // Zweiter Durchgang ohne das Auto: es fällt zurück zu „Nicht gruppiert“. + Kontengruppe::kontenZuordnen($anlage, [$haus]); + $this->assertNull(Konto::find($auto)['kontengruppe_id'], 'Abgehaktes Konto steht wieder ohne Gruppe'); + $this->assertEquals($anlage, (int)Konto::find($haus)['kontengruppe_id'], 'Das andere bleibt zugeordnet'); + + // Leere Auswahl leert die Gruppe, ohne sie zu löschen. + Kontengruppe::kontenZuordnen($anlage, []); + $this->assertNull(Konto::find($haus)['kontengruppe_id'], 'Leere Auswahl räumt die Gruppe'); + $this->assertNotEmpty(Kontengruppe::find($anlage), 'Die Gruppe selbst bleibt bestehen'); + + $mitZahl = []; + foreach (Kontengruppe::alleMitKontenzahl() as $gruppe) { + $mitZahl[$gruppe['name']] = (int)$gruppe['anzahl_konten']; + } + $this->assertEquals(0, $mitZahl['Anlage'], 'Kontenzahl der geleerten Gruppe'); + $this->assertEquals(1, $mitZahl['Liquide Mittel'], 'Kontenzahl der anderen Gruppe'); } private function anlegenUndFinden(): void diff --git a/tests/11_BudgetTest.php b/tests/11_BudgetTest.php new file mode 100644 index 0000000..f073f81 --- /dev/null +++ b/tests/11_BudgetTest.php @@ -0,0 +1,305 @@ +mandantAnlegen(); + $this->stammdaten(); + + $this->vorzeichenFolgtDerKategorieart(); + $this->leeresFeldLoeschtDiePosition(); + $this->istWerteAusBuchungenUndSplits(); + $this->tabelleSummiertDenBaum(); + $this->saldoZeigtUeberschreitung(); + $this->folgemonateUebertragen(); + $this->kopieFuerDasNaechsteJahr(); + $this->zeitraeumeDerAnsicht(); + } + + private function stammdaten(): void + { + $this->konto = Konto::anlegen([ + 'name' => 'Budgetgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + + $this->leben = Kategorie::findenOderAnlegenNachPfad('400 - Leben', 'ausgabe'); + $this->lebensmittel = Kategorie::findenOderAnlegenNachPfad('400 - Leben:Lebensmittel', 'ausgabe'); + $this->essenAuswaerts = Kategorie::findenOderAnlegenNachPfad('400 - Leben:Essen auswärts', 'ausgabe'); + $this->gehalt = Kategorie::findenOderAnlegenNachPfad('100 - Gehalt', 'einnahme'); + } + + private function budgetAnlegen(string $name = 'Test 2026', int $jahr = self::JAHR): int + { + return Budget::anlegen($name . '-' . bin2hex(random_bytes(3)), $jahr); + } + + /** + * Der Nutzer tippt „400“ ein und meint 400 € Ausgabe. Das Vorzeichen kommt + * aus der Kategorieart – sonst müsste er es bei jeder Zeile mitdenken. + */ + private function vorzeichenFolgtDerKategorieart(): void + { + $budget = $this->budgetAnlegen(); + + Budget::positionSetzen($budget, $this->lebensmittel, 1, 400.0); + Budget::positionSetzen($budget, $this->gehalt, 1, 3000.0); + + $positionen = Budget::positionen($budget); + $this->assertEquals(-400.0, $positionen[$this->lebensmittel][1], 'Ausgabe wird negativ gespeichert'); + $this->assertEquals(3000.0, $positionen[$this->gehalt][1], 'Einnahme bleibt positiv'); + + // Auch eine ausdrücklich negative Eingabe darf die Ausgabe nicht umdrehen. + Budget::positionSetzen($budget, $this->lebensmittel, 2, -400.0); + $this->assertEquals( + -400.0, + Budget::positionen($budget)[$this->lebensmittel][2], + 'Negative Eingabe bei einer Ausgabe bleibt negativ' + ); + } + + /** + * „Nicht geplant“ und „mit 0,00 geplant“ sind zweierlei: Das eine zeigt der + * Planer als leeres Feld, das andere als Null. + */ + private function leeresFeldLoeschtDiePosition(): void + { + $budget = $this->budgetAnlegen(); + + Budget::positionSetzen($budget, $this->lebensmittel, 3, 250.0); + $this->assert( + isset(Budget::positionen($budget)[$this->lebensmittel][3]), + 'Gesetzter Wert steht in den Positionen' + ); + + Budget::positionSetzen($budget, $this->lebensmittel, 3, 0.0); + $this->assertEquals( + 0.0, + Budget::positionen($budget)[$this->lebensmittel][3], + 'Eine ausdrückliche Null bleibt erhalten' + ); + + Budget::positionSetzen($budget, $this->lebensmittel, 3, null); + $this->assert( + !isset(Budget::positionen($budget)[$this->lebensmittel][3]), + 'Leeres Feld entfernt die Position' + ); + + // Und derselbe Weg über die Formulareingabe. + $this->assertNull(BudgetController::betrag(' '), 'Leere Eingabe heißt: nicht geplant'); + $this->assertEquals(0.0, BudgetController::betrag('0'), 'Die Null kommt als Null an'); + $this->assertEquals(1234.56, BudgetController::betrag('1.234,56'), 'Deutsches Zahlenformat'); + } + + /** + * Der Ist-Wert kommt aus den Buchungen – bei Splitbuchungen aus den Zeilen, + * sonst aus der Buchung selbst. Beides zusammen zählte doppelt. + */ + private function istWerteAusBuchungenUndSplits(): void + { + Buchung::anlegen([ + 'konto_id' => $this->konto, 'datum' => '2026-01-15', 'betrag' => -120.50, + 'kategorie_id' => $this->lebensmittel, 'empfaenger' => 'Supermarkt', + ]); + Buchung::anlegen([ + 'konto_id' => $this->konto, 'datum' => '2026-02-03', 'betrag' => -80.00, + 'kategorie_id' => $this->lebensmittel, 'empfaenger' => 'Supermarkt', + ]); + Buchung::anlegen([ + 'konto_id' => $this->konto, 'datum' => '2026-01-31', 'betrag' => 3200.00, + 'kategorie_id' => $this->gehalt, 'empfaenger' => 'Arbeitgeber', + ], []); + + // Splitbuchung: 60 auf Lebensmittel, 40 auf Essen auswärts. + Buchung::anlegen( + [ + 'konto_id' => $this->konto, 'datum' => '2026-01-20', 'betrag' => -100.00, + 'empfaenger' => 'Tankstelle mit Bistro', + ], + [ + ['kategorie_id' => $this->lebensmittel, 'betrag' => -60.00], + ['kategorie_id' => $this->essenAuswaerts, 'betrag' => -40.00], + ] + ); + + // Ein anderes Jahr darf nicht hineinrutschen. + Buchung::anlegen([ + 'konto_id' => $this->konto, 'datum' => '2025-01-10', 'betrag' => -999.00, + 'kategorie_id' => $this->lebensmittel, 'empfaenger' => 'Vorjahr', + ]); + + $ist = Budgetplaner::istWerte(self::JAHR); + + $this->assertEquals(-180.50, round($ist[$this->lebensmittel][1], 2), 'Januar: Buchung plus Splitzeile'); + $this->assertEquals(-80.0, round($ist[$this->lebensmittel][2], 2), 'Februar: einzelne Buchung'); + $this->assertEquals(-40.0, round($ist[$this->essenAuswaerts][1], 2), 'Splitzeile der zweiten Kategorie'); + $this->assertEquals(3200.0, round($ist[$this->gehalt][1], 2), 'Einnahme im Januar'); + $this->assert(!isset($ist[$this->lebensmittel][13]), 'Es gibt keinen 13. Monat'); + } + + /** Elternkategorien zeigen die Summe ihrer Unterkategorien. */ + private function tabelleSummiertDenBaum(): void + { + $budget = $this->budgetAnlegen(); + Budget::positionSetzen($budget, $this->lebensmittel, 1, 200.0); + Budget::positionSetzen($budget, $this->essenAuswaerts, 1, 50.0); + + $tabelle = Budgetplaner::tabelle(Budget::find($budget)); + + $zeilen = []; + foreach ($tabelle['zeilen'] as $zeile) { + $zeilen[(int)$zeile['id']] = $zeile; + } + + $this->assertEquals( + -250.0, + round($zeilen[$this->leben]['budget'][1], 2), + 'Die Elternkategorie summiert die Budgets ihrer Kinder' + ); + $this->assertEquals( + -220.50, + round($zeilen[$this->leben]['ist'][1], 2), + 'Und ebenso die Ist-Werte (180,50 + 40,00)' + ); + $this->assertEquals( + 0.0, + round($zeilen[$this->leben]['eigenBudget'][1] ?? 0.0, 2), + 'Der eigene Wert der Elternkategorie bleibt davon unberührt' + ); + + // Die Gesamtzeile summiert nur die Wurzeln, sonst zählten Kinder doppelt. + $this->assertEquals( + -250.0 + 0.0, + round($tabelle['gesamt']['budget'][1], 2), + 'Gesamt: nur die Wurzelkategorien' + ); + $this->assertEquals( + round(-220.50 + 3200.0, 2), + round($tabelle['gesamt']['ist'][1], 2), + 'Gesamt-Ist über Einnahmen und Ausgaben' + ); + } + + /** Saldo = Ist minus Budget: negativ heißt über Plan. */ + private function saldoZeigtUeberschreitung(): void + { + $budget = $this->budgetAnlegen(); + Budget::positionSetzen($budget, $this->lebensmittel, 1, 150.0); // geplant: -150 + Budget::positionSetzen($budget, $this->lebensmittel, 2, 200.0); // geplant: -200 + Budget::positionSetzen($budget, $this->gehalt, 1, 3000.0); + + $tabelle = Budgetplaner::tabelle(Budget::find($budget)); + $zeilen = []; + foreach ($tabelle['zeilen'] as $zeile) { + $zeilen[(int)$zeile['id']] = $zeile; + } + + // Januar: 180,50 ausgegeben bei 150 geplant → 30,50 über Plan. + $this->assertEquals( + -30.50, + round($zeilen[$this->lebensmittel]['saldo'][1], 2), + 'Überschreitung ergibt einen negativen Saldo' + ); + + // Februar: 80 ausgegeben bei 200 geplant → 120 übrig. + $this->assertEquals( + 120.0, + round($zeilen[$this->lebensmittel]['saldo'][2], 2), + 'Unterschreitung ergibt einen positiven Saldo' + ); + + // Einnahmen: 3200 statt 3000 geplant → 200 besser als geplant. + $this->assertEquals( + 200.0, + round($zeilen[$this->gehalt]['saldo'][1], 2), + 'Mehr eingenommen als geplant ist ebenfalls positiv' + ); + } + + private function folgemonateUebertragen(): void + { + $budget = $this->budgetAnlegen(); + Budget::positionSetzen($budget, $this->lebensmittel, 3, 300.0); + Budget::positionSetzen($budget, $this->lebensmittel, 12, 999.0); + + Budget::aufFolgemonateUebertragen($budget, $this->lebensmittel, 3); + + $positionen = Budget::positionen($budget)[$this->lebensmittel]; + $this->assert(!isset($positionen[2]), 'Der Vormonat bleibt unangetastet'); + $this->assertEquals(-300.0, $positionen[3], 'Der Ausgangsmonat bleibt stehen'); + $this->assertEquals(-300.0, $positionen[4], 'April übernimmt den Wert'); + $this->assertEquals(-300.0, $positionen[12], 'Auch ein vorhandener Dezemberwert wird überschrieben'); + $this->assertEquals(10, count($positionen), 'März bis Dezember sind gefüllt'); + } + + private function kopieFuerDasNaechsteJahr(): void + { + $budget = $this->budgetAnlegen('Vorlage'); + Budget::positionSetzen($budget, $this->lebensmittel, 1, 400.0); + Budget::positionSetzen($budget, $this->gehalt, 1, 3000.0); + + $kopie = Budget::kopieren($budget, 'Kopie-' . bin2hex(random_bytes(3)), self::JAHR + 1); + + $this->assertEquals(self::JAHR + 1, (int)Budget::find($kopie)['jahr'], 'Die Kopie liegt im neuen Jahr'); + $this->assertEquals( + -400.0, + Budget::positionen($kopie)[$this->lebensmittel][1], + 'Die Monatswerte sind mitgekommen' + ); + + // Die Vorlage bleibt unverändert – die Kopie ist eigenständig. + Budget::positionSetzen($kopie, $this->lebensmittel, 1, 500.0); + $this->assertEquals( + -400.0, + Budget::positionen($budget)[$this->lebensmittel][1], + 'Änderungen an der Kopie schlagen nicht auf die Vorlage durch' + ); + + $doppelt = false; + try { + Budget::anlegen(Budget::find($kopie)['name'], self::JAHR); + } catch (\Throwable) { + $doppelt = true; + } + $this->assert($doppelt, 'Zwei Budgets mit demselben Namen gehen nicht'); + } + + /** Welche Monate gelten als abgelaufen, welcher läuft gerade? */ + private function zeitraeumeDerAnsicht(): void + { + $this->assertEquals(7, Budgetplaner::abgelaufenBis(2026, '2026-08-11'), 'Im August sind sieben Monate durch'); + $this->assertEquals(0, Budgetplaner::abgelaufenBis(2026, '2026-01-15'), 'Im Januar ist noch nichts abgelaufen'); + $this->assertEquals(12, Budgetplaner::abgelaufenBis(2025, '2026-08-11'), 'Ein vergangenes Jahr ist ganz durch'); + $this->assertEquals(0, Budgetplaner::abgelaufenBis(2027, '2026-08-11'), 'Ein künftiges Jahr ist offen'); + + $this->assertEquals(8, Budgetplaner::laufenderMonat(2026, '2026-08-11'), 'Der laufende Monat ist der August'); + $this->assertEquals(0, Budgetplaner::laufenderMonat(2027, '2026-08-11'), 'Andere Jahre haben keinen laufenden Monat'); + + $reihe = []; + for ($monat = 1; $monat <= 12; $monat++) { + $reihe[$monat] = 10.0; + } + $this->assertEquals(70.0, Budgetplaner::summe($reihe, 1, 7), 'Summe über die abgelaufenen Monate'); + $this->assertEquals(120.0, Budgetplaner::summe($reihe, 1, 99), 'Über den Dezember hinaus wird nicht gezählt'); + $this->assertEquals(0.0, Budgetplaner::summe($reihe, 5, 4), 'Ein leerer Bereich ergibt null'); + } +} diff --git a/tests/12_MonatsabschlussTest.php b/tests/12_MonatsabschlussTest.php new file mode 100644 index 0000000..c79c073 --- /dev/null +++ b/tests/12_MonatsabschlussTest.php @@ -0,0 +1,257 @@ +mandantAnlegen(); + $this->stammdaten(); + + $this->zeitraumEinesMonats(); + $this->pruefungFindetOffenePunkte(); + $this->snapshotHaeltSaldenFest(); + $this->abschlussSperrtBuchungen(); + $this->wiedereroeffnenGibtDenMonatFrei(); + $this->vorschlagFuerDenNaechstenMonat(); + } + + private function stammdaten(): void + { + $this->giro = Konto::anlegen([ + 'name' => 'Abschlussgiro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1, + ]); + $this->tagesgeld = Konto::anlegen([ + 'name' => 'Abschlusstagesgeld', 'kontoart' => 'sparkonto', 'anfangssaldo' => 5000, 'aktiv' => 1, + ]); + // Zählt nicht zur Summe – durchlaufende Auslagen sind kein Vermögen. + $this->auslagen = Konto::anlegen([ + 'name' => 'Abschlussauslagen', 'kontoart' => 'sonstiges', 'anfangssaldo' => 300, + 'aktiv' => 1, 'in_summe' => 0, + ]); + + $this->kategorie = Kategorie::findenOderAnlegenNachPfad('500 - Abschlusstest', 'ausgabe'); + } + + private function zeitraumEinesMonats(): void + { + $this->assertEquals(['2026-02-01', '2026-02-28'], Monatsabschluss::zeitraum(2026, 2), 'Februar 2026'); + $this->assertEquals(['2024-02-01', '2024-02-29'], Monatsabschluss::zeitraum(2024, 2), 'Schaltjahr'); + $this->assertEquals(['2026-12-01', '2026-12-31'], Monatsabschluss::zeitraum(2026, 12), 'Dezember'); + + $ungueltig = false; + try { + Monatsabschluss::zeitraum(2026, 13); + } catch (\Throwable) { + $ungueltig = true; + } + $this->assert($ungueltig, 'Ein 13. Monat wird abgewiesen'); + } + + private function pruefungFindetOffenePunkte(): void + { + // Sauber kategorisiert und abgeglichen. + Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-03-05', 'betrag' => -50.0, + 'kategorie_id' => $this->kategorie, 'empfaenger' => 'Sauber', 'abgeglichen' => 1, + ]); + + // Ohne Kategorie und ohne Gegenkonto – der Klassiker nach einem Bankabruf. + Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-03-06', 'betrag' => -25.0, + 'empfaenger' => 'Unbekannt', + ]); + + // Umbuchung: hat keine Kategorie, ist aber vollständig zugeordnet. + Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-03-07', 'betrag' => -200.0, + 'gegenkonto_id' => $this->tagesgeld, 'empfaenger' => 'Sparen', + ]); + + $pruefung = Monatsabschluss::pruefen(2026, 3); + + $this->assertEquals(3, $pruefung['buchungen'], 'Alle Buchungen des Monats werden gezählt'); + $this->assertEquals(1, count($pruefung['ohne_kategorie']), 'Nur die unzugeordnete Buchung fehlt'); + $this->assertEquals( + 'Unbekannt', + $pruefung['ohne_kategorie'][0]['empfaenger'], + 'Und zwar die richtige' + ); + $this->assertEquals(2, $pruefung['nicht_abgeglichen'], 'Zwei Buchungen sind nicht abgeglichen'); + $this->assertEquals([], $pruefung['splitdifferenzen'], 'Keine Splitdifferenzen'); + + // Ein anderer Monat bleibt außen vor. + $this->assertEquals(0, Monatsabschluss::pruefen(2026, 4)['buchungen'], 'April ist leer'); + } + + private function snapshotHaeltSaldenFest(): void + { + $snapshot = Monatsabschluss::snapshot(2026, 3); + + $salden = []; + foreach ($snapshot['konten'] as $konto) { + $salden[$konto['name']] = $konto['saldo']; + } + + // 1000 Anfangssaldo - 50 - 25 - 200 (Umbuchung) = 725 + $this->assertEquals(725.0, $salden['Abschlussgiro'], 'Kontosaldo zum Monatsende'); + // 5000 + 200 aus der Umbuchung + $this->assertEquals(5200.0, $salden['Abschlusstagesgeld'], 'Die Gegenseite der Umbuchung zählt mit'); + $this->assertEquals(300.0, $salden['Abschlussauslagen'], 'Konto ohne Bewegung behält seinen Anfangssaldo'); + + // Das Auslagenkonto zählt nicht zur Summe der Kontostände. + $this->assertEquals(5925.0, $snapshot['summe_kontostand'], 'Summe nur über Konten mit Σ-Kennzeichen'); + + $kategorien = []; + foreach ($snapshot['kategorien'] as $zeile) { + $kategorien[$zeile['kategorie_id']] = $zeile['betrag']; + } + $this->assertEquals(-50.0, $kategorien[$this->kategorie], 'Kategoriesumme des Monats'); + + $this->assertEquals('2026-03-01', $snapshot['zeitraum']['von'], 'Zeitraum im Snapshot'); + $this->assertEquals('2026-03-31', $snapshot['zeitraum']['bis'], 'Zeitraum im Snapshot'); + } + + private function abschlussSperrtBuchungen(): void + { + $id = Monatsabschluss::abschliessen(2026, 3, 'Testabschluss', 'Testnutzer'); + $this->assertGreaterThan(0, $id, 'Der Abschluss wird gespeichert'); + + $this->assert(Monatsabschluss::istGesperrt('2026-03-15'), 'Der abgeschlossene Monat ist gesperrt'); + $this->assert(!Monatsabschluss::istGesperrt('2026-04-01'), 'Der Folgemonat bleibt offen'); + + $abgelehnt = false; + try { + Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-03-20', 'betrag' => -10.0, + 'kategorie_id' => $this->kategorie, 'empfaenger' => 'Zu spät', + ]); + } catch (\Throwable) { + $abgelehnt = true; + } + $this->assert($abgelehnt, 'In einem abgeschlossenen Monat lässt sich nicht mehr buchen'); + + // Eine bestehende Buchung darf auch nicht aus dem Monat herausgeschoben werden. + $vorhanden = Buchung::kontoblatt($this->giro, ['von' => '2026-03-01', 'bis' => '2026-03-31']); + $erste = (int)$vorhanden[0]['id']; + $this->assertGreaterThan(0, $erste, 'Eine Buchung des Monats ist auffindbar'); + + $verschoben = false; + try { + Buchung::speichern($erste, [ + 'konto_id' => $this->giro, 'datum' => '2026-04-02', 'betrag' => -50.0, + 'kategorie_id' => $this->kategorie, 'empfaenger' => 'Verschoben', + ]); + } catch (\Throwable) { + $verschoben = true; + } + $this->assert($verschoben, 'Eine Buchung lässt sich nicht aus dem gesperrten Monat schieben'); + + $geloescht = false; + try { + Buchung::loeschen($erste); + } catch (\Throwable) { + $geloescht = true; + } + $this->assert($geloescht, 'Löschen im gesperrten Monat wird abgewiesen'); + + // Der Zweitabschluss desselben Monats ist ein Fehler, kein zweiter Datensatz. + $doppelt = false; + try { + Monatsabschluss::abschliessen(2026, 3); + } catch (\Throwable) { + $doppelt = true; + } + $this->assert($doppelt, 'Ein Monat lässt sich nicht zweimal abschließen'); + + // Die Sperre ist abschaltbar – etwa für einen Import, der Altdaten nachträgt. + Einstellung::set('monatsabschluss_sperrt_buchungen', '0'); + $trotzdem = true; + try { + Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-03-21', 'betrag' => -5.0, + 'kategorie_id' => $this->kategorie, 'empfaenger' => 'Nachtrag', + ]); + } catch (\Throwable) { + $trotzdem = false; + } + $this->assert($trotzdem, 'Mit abgeschalteter Sperre geht die Buchung durch'); + Einstellung::set('monatsabschluss_sperrt_buchungen', '1'); + } + + private function wiedereroeffnenGibtDenMonatFrei(): void + { + $abschluss = Monatsabschluss::fuerMonat(2026, 3); + $this->assertNotEmpty($abschluss, 'Der Abschluss ist auffindbar'); + + Monatsabschluss::wiedereroeffnen((int)$abschluss['id']); + + $this->assert(!Monatsabschluss::istGesperrt('2026-03-15'), 'Nach der Wiedereröffnung ist der Monat offen'); + + $danach = Monatsabschluss::fuerMonat(2026, 3); + $this->assertEquals( + Monatsabschluss::STATUS_WIEDEREROEFFNET, + $danach['status'], + 'Der Vorgang bleibt am Datensatz vermerkt' + ); + $this->assertNotEmpty($danach['snapshot'], 'Der Snapshot des Abschlusses bleibt erhalten'); + + $ging = true; + try { + Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-03-25', 'betrag' => -15.0, + 'kategorie_id' => $this->kategorie, 'empfaenger' => 'Nachgebucht', + ]); + } catch (\Throwable) { + $ging = false; + } + $this->assert($ging, 'Im wiedereröffneten Monat lässt sich wieder buchen'); + + // Und danach wieder abschließen – mit dem neuen Stand. + Monatsabschluss::abschliessen(2026, 3, 'Zweiter Anlauf'); + $this->assert(Monatsabschluss::istGesperrt('2026-03-15'), 'Der zweite Abschluss sperrt erneut'); + Monatsabschluss::wiedereroeffnen((int)Monatsabschluss::fuerMonat(2026, 3)['id']); + } + + private function vorschlagFuerDenNaechstenMonat(): void + { + // Buchungen im Januar und Februar 2026, März ist wiedereröffnet. + Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-01-10', 'betrag' => -30.0, + 'kategorie_id' => $this->kategorie, 'empfaenger' => 'Januar', + ]); + + $vorschlag = Monatsabschluss::naechsterOffener('2026-04-15'); + $this->assertEquals(2026, $vorschlag['jahr'], 'Der Vorschlag bleibt im Jahr'); + $this->assertEquals( + 1, + $vorschlag['monat'], + 'Vorgeschlagen wird der älteste offene Monat mit Buchungen – abgeschlossen wird der Reihe nach' + ); + + // Ist ein Monat abgeschlossen, endet die Suche dort: Was davor liegt, + // ist erledigt. + Monatsabschluss::abschliessen(2026, 1, 'Fest'); + $nach = Monatsabschluss::naechsterOffener('2026-04-15'); + $this->assertEquals(3, $nach['monat'], 'Danach ist der März der älteste offene Monat mit Buchungen'); + + // Ohne Buchungen bleibt es beim Vormonat als Vorschlag. + Monatsabschluss::abschliessen(2026, 3, 'Auch fest'); + $leer = Monatsabschluss::naechsterOffener('2026-04-15'); + $this->assertEquals(3, $leer['monat'], 'Ist alles abgeschlossen, steht der Vormonat da'); + } +} diff --git a/tests/e2e/specs/planung.spec.js b/tests/e2e/specs/planung.spec.js new file mode 100644 index 0000000..f77a7d0 --- /dev/null +++ b/tests/e2e/specs/planung.spec.js @@ -0,0 +1,134 @@ +// @ts-check +const { test, expect } = require('@playwright/test') + +const BENUTZER = process.env.E2E_USER || 'e2e' +const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' + +// Eindeutige Namen je Lauf: die Tests arbeiten auf einer bestehenden Instanz +// und dürfen sich nicht an Resten eines früheren Laufs stoßen. +const MARKE = Date.now().toString().slice(-6) + +// Ein Jahr weit in der Zukunft: dort sind alle zwölf Monate Folgemonate, die +// Spaltenaufteilung des Planers bleibt damit über die Jahre stabil. +const JAHR = new Date().getFullYear() + 5 + +async function anmelden(page) { + await page.goto('/login') + + if (page.url().includes('/einrichtung')) { + throw new Error( + 'Instanz ist noch nicht eingerichtet. Zuerst einen Benutzer anlegen: ' + + 'docker compose exec -u www-data php php scripts/e2e-benutzer.php' + ) + } + + await page.fill('#benutzername', BENUTZER) + await page.fill('#passwort', PASSWORT) + await page.click('button[type="submit"]') + await expect(page).toHaveURL(/\/$/) +} + +/** + * Legt ein Budget an und liefert dessen Id. Nach dem Anlegen steht sie in der + * URL des Planers – die Auswahlliste enthält alle Budgets der Instanz und + * taugt deshalb nicht zum Wiederfinden. + */ +async function budgetAnlegen(page, name) { + await page.goto('/planung/budget/verwaltung') + await page.fill('#name', name) + await page.fill('#jahr', String(JAHR)) + await page.click('.formular button:has-text("Budget anlegen")') + await expect(page.locator('.meldung-erfolg')).toBeVisible() + await expect(page).toHaveURL(/budget=\d+/) + + return new URL(page.url()).searchParams.get('budget') +} + +/** Die erste Kategoriezeile mit Eingabefeldern – eine Blattkategorie. */ +function ersteBeplanbareZeile(page) { + return page.locator('tr.kategoriezeile:not(.summenzeile-kategorie)').first() +} + +test.describe('Planung', () => { + test.beforeEach(async ({ page }) => { + await anmelden(page) + }) + + test('Planungsübersicht führt zu Budget und Monatsabschluss', async ({ page }) => { + await page.click('.seitenleiste a[href="/planung"]') + + await expect(page.locator('h1')).toContainText('Planung') + await expect(page.locator('a[href="/planung/budget"]').first()).toBeVisible() + await expect(page.locator('a[href="/planung/monatsabschluss"]').first()).toBeVisible() + }) + + test('Budget anlegen, Monatswert eintragen und wiederfinden', async ({ page }) => { + const id = await budgetAnlegen(page, `E2E-Budget ${MARKE}`) + + await page.goto(`/planung/budget?budget=${id}&ansicht=alle`) + + const zeile = ersteBeplanbareZeile(page) + const kategorie = (await zeile.locator('.baumname').innerText()).trim() + + await zeile.locator('input.betragsfeld').first().fill('250,00') + await page.click('button:has-text("Budget speichern")') + await expect(page.locator('.meldung-erfolg')).toContainText('Budget gespeichert') + + // Nach dem Speichern steht der Wert wieder im Feld – und in der Jahressumme. + const wieder = page.locator('tr.kategoriezeile', { hasText: kategorie }).first() + await expect(wieder.locator('input.betragsfeld').first()).toHaveValue('250,00') + await expect(wieder).toContainText('250,00') + }) + + test('Wert auf die Folgemonate übertragen', async ({ page }) => { + const id = await budgetAnlegen(page, `E2E-Folgemonate ${MARKE}`) + + await page.goto(`/planung/budget?budget=${id}&ansicht=alle`) + + const zeile = ersteBeplanbareZeile(page) + const kategorie = (await zeile.locator('.baumname').innerText()).trim() + + await zeile.locator('input.betragsfeld').first().fill('100,00') + await zeile.locator('button[name="uebertragen"]').click() + await expect(page.locator('.meldung-erfolg')).toContainText('Folgemonate') + + const wieder = page.locator('tr.kategoriezeile', { hasText: kategorie }).first() + const felder = wieder.locator('input.betragsfeld') + await expect(felder.nth(0)).toHaveValue('100,00') + await expect(felder.nth(11)).toHaveValue('100,00') + // Zwölf Monate à 100,00 stehen als Jahressumme in der Zeile. + await expect(wieder).toContainText('1.200,00') + }) + + test('Monat abschließen, Snapshot ansehen und wiedereröffnen', async ({ page }) => { + // Ein Monat weit in der Vergangenheit, den kein anderer Test anfasst. + const jahr = new Date().getFullYear() - 9 + + // Rückfragen bejahen: Wiedereröffnen und Abschließen fragen beide nach. + page.on('dialog', d => d.accept()) + + await page.goto(`/planung/monatsabschluss?jahr=${jahr}&monat=6`) + await expect(page.locator('h2').first()).toContainText('Prüflauf') + + // Ein früherer Lauf kann den Monat abgeschlossen zurückgelassen haben. + if (await page.locator('button:has-text("Monat wiedereröffnen")').count()) { + await page.click('button:has-text("Monat wiedereröffnen")') + await expect(page.locator('button:has-text("Monat abschließen")')).toBeVisible() + } + + await page.fill('#notiz', `E2E ${MARKE}`) + await page.click('button:has-text("Monat abschließen")') + await expect(page.locator('.meldung-erfolg')).toContainText('abgeschlossen') + + // Der abgeschlossene Monat weist die Sperre aus und bietet den Snapshot an. + await expect(page.locator('.phasenmarke')).toContainText('abgeschlossen') + await page.click('a:has-text("Snapshot ansehen")') + await expect(page.locator('h1')).toContainText('Abschluss') + await expect(page.locator('.summenzeile')).toContainText('Summe Kontostände') + + await page.goto(`/planung/monatsabschluss?jahr=${jahr}&monat=6`) + await page.click('button:has-text("Monat wiedereröffnen")') + await expect(page.locator('.meldung-erfolg')).toContainText('wieder offen') + await expect(page.locator('button:has-text("Monat abschließen")')).toBeVisible() + }) +}) diff --git a/tests/e2e/specs/stammdaten.spec.js b/tests/e2e/specs/stammdaten.spec.js index 55f98c5..f1ba7f4 100644 --- a/tests/e2e/specs/stammdaten.spec.js +++ b/tests/e2e/specs/stammdaten.spec.js @@ -111,9 +111,11 @@ test.describe('Stammdaten', () => { await page.goto('/verwaltung/kategorien') await expect(page.locator('tr', { hasText: oben }).first()).toContainText('Oberkategorie') - // Die Unterkategorie ist um eine Ebene eingerückt. + // Die Unterkategorie ist um eine Ebene eingerückt. Die Ebene steht als + // data-Attribut da: Ein style-Attribut würde die Content-Security-Policy + // verwerfen, die Einrückung fiele stillschweigend aus. await expect(page.locator(`tr:has-text("${unten}") .baumname`)) - .toHaveAttribute('style', /--ebene: 1/) + .toHaveAttribute('data-ebene', '1') }) test('Register filtern nach Einnahmen und Ausgaben', async ({ page }) => { @@ -201,7 +203,12 @@ test.describe('Stammdaten', () => { await expect(page.locator('.kachel h2').first()).toContainText('Trockenlauf') const uebersicht = page.locator('.kachel table.liste').first() await expect(uebersicht.locator('tr', { hasText: 'Klassen' })).toContainText('1') - await expect(uebersicht.locator('tr', { hasText: 'Konten' })).toContainText('1') + // Genau die Zeile „Konten“ – „Kontengruppen“ steht direkt darunter und + // enthält denselben Wortanfang, deshalb über die erste Zelle filtern. + const kontenzeile = uebersicht.locator('tr').filter({ + has: page.getByRole('cell', { name: 'Konten', exact: true }), + }) + await expect(kontenzeile).toContainText('1') // Nichts gespeichert: die Klasse darf noch nicht in der Liste stehen. await page.goto('/verwaltung/klassen')