Phase 3: Budgetplaner und Monatsabschluss

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 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-11 07:31:25 +02:00
parent bfef887793
commit 851e8204e9
39 changed files with 3360 additions and 96 deletions

View file

@ -1,5 +1,53 @@
# Änderungen # Ä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) ## Korrektur – Σ-Kennzeichen ließ sich nicht abschalten (10.08.2026)
Das Kennzeichen *Zählt zur Summe der Kontostände* blieb beim Speichern eines Kontos Das Kennzeichen *Zählt zur Summe der Kontostände* blieb beim Speichern eines Kontos

21
PLAN.md
View file

@ -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 * *Ergebnis:* Der vollständige Altbestand ist migriert, Salden stimmen mit dem
FinanzManager überein (Abgleich gegen die Kontenübersicht als Test). 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`. * Migrationen `budgets`, `budget_positionen`, `monatsabschluesse`.
* **Budgetplaner** wie im Original: Zeilen = Kategoriebaum (auf-/zuklappbar), Spalten = * **Budgetplaner** wie im Original: Zeilen = Kategoriebaum (auf-/zuklappbar), Spalten =
Gesamt-Saldo, abgelaufene Monate zusammengefasst, aktueller Monat mit Budget/Ist/Saldo, 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. Kopieren eines Budgets ins neue Jahr, Alarm bei Überschreitung, Gesamtzeile.
* **Monatsabschluss**: Prüflauf (nicht kategorisierte Buchungen, nicht abgeglichene Umsätze, * **Monatsabschluss**: Prüflauf (nicht kategorisierte Buchungen, nicht abgeglichene Umsätze,
Splitsummen-Differenzen), Snapshot der Salden, Sperre des Zeitraums, Wiedereröffnung. 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. * *Ergebnis:* Jahresbudget zum Jahreswechsel planbar, Monatsabschluss durchführbar.
Einzelheiten in `docs/planung.md`.
### Phase 4 — Auswertungen und Cockpit ### Phase 4 — Auswertungen und Cockpit
* Berichtsmodul mit gespeicherten Berichten und Berichtsgruppen (wie „Bankgeschäfte", * 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, 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: steht nicht im Export. Nach dem Import einmal durch die Kontenübersicht gehen (Vorgabe:
alle zählen mit). alle zählen mit).
4. **Budgetdaten**: Export aus dem Budgetplaner (Symbol oben rechts in der Werkzeugleiste), 4. **Kontengruppen anlegen und Konten zuordnen**: Ebenfalls nicht im Export enthalten —
sonst wird das Jahresbudget 2027 einmalig neu erfasst. ein `!Account`-Eintrag führt nur Name, Kontoart, Beschreibung und Kreditlimit, und auch
5. Zugangsdaten der Bankverbindungen (erst in Phase 5, Eingabe direkt in der Anwendung). die `QSD`-Datei nennt keine Gruppen. Nach dem Import stehen deshalb alle Konten unter
6. **API-Key für den Kursdienst** (Leeway.tech oder FMP, erst in Phase 6, Eingabe im „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). Einstellungsdialog).
## 7. Verifikation ## 7. Verifikation

View file

@ -10,11 +10,12 @@ Haushalt wird beides parallel genutzt.
## Stand ## 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; Migrationsmechanik und Test-Harness; Konten, Kontengruppen, Kategorien und Klassen;
Kontoblatt, Buchungsmaske mit Splitbuchungen und Umbuchungen sowie der QIF-Import für 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 Stammdaten und Buchungen; Budgetplaner mit Monatswerten je Kategorie und der
Monatsabschluss (Phase 3). Monatsabschluss samt Prüflauf, Snapshot und Sperre. Als Nächstes folgen nach `PLAN.md`
die Auswertungen und das Cockpit (Phase 4).
## Technik ## Technik
@ -83,4 +84,5 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf.
- `Changes.md` – Änderungsprotokoll - `Changes.md` – Änderungsprotokoll
- `docs/stammdaten.md` – Konten, Kontengruppen, Kategorien, Klassen - `docs/stammdaten.md` – Konten, Kontengruppen, Kategorien, Klassen
- `docs/buchungen.md` – Kontoblatt, Umbuchungen, Splitbuchungen, Salden - `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 - `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs

111
docs/planung.md Normal file
View file

@ -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

View file

@ -47,6 +47,12 @@ zweite Schreibweise ab, bei der ein einzelner `!Account` den Kontowechsel bedeut
| | `D` | Beschreibung; `Kontonr. 12345` wird als Kontonummer übernommen | | | `D` | Beschreibung; `Kontonr. 12345` wird als Kontonummer übernommen |
| | `L` | Kreditlimit im US-Format | | | `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 ## Fallstricke
1. **Zeichensatz ISO-8859-1, Zeilenenden CRLF.** `QifParser::nachUtf8()` prüft erst, ob 1. **Zeichensatz ISO-8859-1, Zeilenenden CRLF.** `QifParser::nachUtf8()` prüft erst, ob

View file

@ -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 Wird eine Gruppe gelöscht, bleiben ihre Konten erhalten und rutschen dorthin
(`ON DELETE SET NULL`). (`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`): Kontoarten (`Konto::ARTEN`) und ihre Herkunft aus dem QIF-Export (`Konto::QIF_ARTEN`):
| Kontoart | Bezeichnung | QIF-Typ | | Kontoart | Bezeichnung | QIF-Typ |

View file

@ -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;

View file

@ -11,12 +11,15 @@ require dirname(__DIR__) . '/vendor/autoload.php';
use Finanzmanager\Auth; use Finanzmanager\Auth;
use Finanzmanager\Config; use Finanzmanager\Config;
use Finanzmanager\Controllers\BuchungenController; use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\Controllers\BudgetController;
use Finanzmanager\Controllers\CockpitController; use Finanzmanager\Controllers\CockpitController;
use Finanzmanager\Controllers\EinstellungenController; use Finanzmanager\Controllers\EinstellungenController;
use Finanzmanager\Controllers\ImportController; use Finanzmanager\Controllers\ImportController;
use Finanzmanager\Controllers\KategorienController; use Finanzmanager\Controllers\KategorienController;
use Finanzmanager\Controllers\KontenController; use Finanzmanager\Controllers\KontenController;
use Finanzmanager\Controllers\LoginController; use Finanzmanager\Controllers\LoginController;
use Finanzmanager\Controllers\MonatsabschlussController;
use Finanzmanager\Controllers\PlanungController;
use Finanzmanager\Controllers\PlatzhalterController; use Finanzmanager\Controllers\PlatzhalterController;
use Finanzmanager\Controllers\VerwaltungController; use Finanzmanager\Controllers\VerwaltungController;
use Finanzmanager\Models\Einstellung; use Finanzmanager\Models\Einstellung;
@ -96,12 +99,40 @@ $router->post('/verwaltung/klassen/{id}/loeschen',
fn(array $p) => (new VerwaltungController())->klasseLoeschen($p)); fn(array $p) => (new VerwaltungController())->klasseLoeschen($p));
$router->get('/verwaltung/kontengruppen', fn() => (new VerwaltungController())->kontengruppen()); $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->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}', $router->post('/verwaltung/kontengruppen/{id}',
fn(array $p) => (new VerwaltungController())->kontengruppeSpeichern($p)); fn(array $p) => (new VerwaltungController())->kontengruppeSpeichern($p));
$router->post('/verwaltung/kontengruppen/{id}/loeschen', $router->post('/verwaltung/kontengruppen/{id}/loeschen',
fn(array $p) => (new VerwaltungController())->kontengruppeLoeschen($p)); 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->get('/verwaltung/import', fn() => (new ImportController())->index());
$router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen()); $router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen());
$router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren()); $router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren());

View file

@ -399,13 +399,88 @@ table.liste input[type="number"] {
table.liste input.schmal { width: 5rem; } table.liste input.schmal { width: 5rem; }
/* Einrückung des Kategoriebaums. Die Ebene kommt als Custom Property aus der /* ── Budgetplaner ────────────────────────────────────────────────────────────
Vorlage – so bleibt die Tabelle frei von verschachtelten Elementen. */
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 { .baumname {
display: inline-block; 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 { .nebentext {
color: var(--text-gedaempft); color: var(--text-gedaempft);
font-size: .88rem; font-size: .88rem;
@ -548,6 +623,24 @@ table.splittabelle input.rechts { text-align: right; }
font-weight: 500; 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="text"],
.formular input[type="password"], .formular input[type="password"],
.formular input[type="number"], .formular input[type="number"],
@ -601,21 +694,18 @@ table.splittabelle input.rechts { text-align: right; }
.knopf:hover { background: var(--flaeche-gedaempft); text-decoration: none; } .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 { .knopf.primaer {
background: var(--akzent); background: var(--akzent);
border-color: var(--akzent); border-color: var(--akzent);
color: var(--akzent-text); color: var(--akzent-text);
font-weight: 500; font-weight: 500;
margin-top: 1.25rem;
} }
.knopf.primaer:hover { filter: brightness(1.08); } .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 { .knopf.klein {
padding: .25rem .6rem; padding: .25rem .6rem;
font-size: .85rem; font-size: .85rem;
@ -642,6 +732,12 @@ table.liste .knopf { margin-top: 0; }
} }
.meldung-erfolg { background: var(--akzent-schwach); border-color: var(--akzent); } .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-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); } .meldung-warnung { background: color-mix(in srgb, var(--warnung) 15%, var(--flaeche)); border-color: var(--warnung); }

View file

@ -27,11 +27,38 @@
} }
}, true); }, 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. // 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) { document.querySelectorAll('.meldung-erfolg').forEach(function (element) {
window.setTimeout(function () { window.setTimeout(function () {
element.style.transition = 'opacity .4s'; element.classList.add('verblasst');
element.style.opacity = '0';
window.setTimeout(function () { element.remove(); }, 400); window.setTimeout(function () { element.remove(); }, 400);
}, 5000); }, 5000);
}); });

View file

@ -35,6 +35,21 @@ abstract class BaseController
return is_string($wert) ? trim($wert) : $standard; return is_string($wert) ? trim($wert) : $standard;
} }
/**
* Mehrfachauswahl aus dem Formular (Ankreuzfelder mit Namen wie „konten[]“).
*
* @return list<int>
*/
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 protected function query(string $feld, string $standard = ''): string
{ {
$wert = $_GET[$feld] ?? $standard; $wert = $_GET[$feld] ?? $standard;

View file

@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Budget;
use Finanzmanager\Services\Budgetplaner;
use Finanzmanager\View;
/**
* Der Budgetplaner: Kategoriezeilen mal zwölf Monatsspalten, dazu die
* Budgetverwaltung (anlegen, kopieren, aktivieren).
*
* Die Monatswerte werden nicht einzeln gespeichert, sondern die ganze Tabelle
* auf einmal – ein Formular um die Tabelle herum, ein POST. Das kommt ohne
* JavaScript aus und hält die Eingabe zusammen, wenn mehrere Zeilen geändert
* wurden.
*/
class BudgetController extends BaseController
{
public function planer(): void
{
$this->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<string,string> $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<string,string> $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<string,string> $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<string,string> $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<string,string> $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<string,mixed>|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;
}
}

View file

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Auth;
use Finanzmanager\Models\Monatsabschluss;
use Finanzmanager\Services\Budgetplaner;
use Finanzmanager\View;
/**
* Monatsabschluss: Prüflauf, Abschluss mit Snapshot, Wiedereröffnung.
*
* Der Prüflauf hält niemanden auf – er zeigt, was im Monat noch offen ist, und
* überlässt die Entscheidung dem Nutzer. Genauso arbeitet der FinanzManager.
*/
class MonatsabschlussController extends BaseController
{
public function index(): void
{
$this->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<string,string> $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<string,string> $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;
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Budget;
use Finanzmanager\Models\Monatsabschluss;
/**
* Einstieg in den Bereich Planung – von hier führen die Wege in den
* Budgetplaner und in den Monatsabschluss.
*/
class PlanungController extends BaseController
{
public function index(): void
{
$this->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(),
]);
}
}

View file

@ -22,11 +22,6 @@ class PlatzhalterController extends BaseController
'phase' => 'Phase 4', 'phase' => 'Phase 4',
'text' => 'Summen-, Buchungs-, Vergleichs- und Budgetbericht samt Grafiken.', 'text' => 'Summen-, Buchungs-, Vergleichs- und Budgetbericht samt Grafiken.',
], ],
'planung' => [
'titel' => 'Planung',
'phase' => 'Phase 3',
'text' => 'Budgetplaner für das Jahresbudget und der Monatsabschluss.',
],
'wertpapiere' => [ 'wertpapiere' => [
'titel' => 'Wertpapiere', 'titel' => 'Wertpapiere',
'phase' => 'Phase 6', 'phase' => 'Phase 6',

View file

@ -92,9 +92,39 @@ class VerwaltungController extends BaseController
$this->verlangeAnmeldung(); $this->verlangeAnmeldung();
$this->render('verwaltung/kontengruppen.php', [ $this->render('verwaltung/kontengruppen.php', [
'titel' => 'Kontengruppen', 'titel' => 'Kontengruppen',
'gruppen' => Kontengruppe::alle(), 'gruppen' => Kontengruppe::alleMitKontenzahl(),
'konten' => Konto::alle(), 'ohneGruppe' => Kontengruppe::anzahlOhneGruppe(),
]);
}
public function kontengruppeNeu(): void
{
$this->verlangeAnmeldung();
$this->kontengruppeFormular(null);
}
/** @param array<string,string> $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<string,mixed>|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(); $this->verlangeAnmeldung();
try { try {
Kontengruppe::anlegen( $id = Kontengruppe::anlegen(
$this->post('name'), $this->post('name'),
$this->post('beschreibung'), $this->post('beschreibung'),
(int)$this->post('sortierung', '0') (int)$this->post('sortierung', '0')
); );
Kontengruppe::kontenZuordnen($id, $this->postIds('konten'));
} catch (\Throwable $e) { } catch (\Throwable $e) {
View::redirect('/verwaltung/kontengruppen', $e->getMessage(), 'fehler'); View::redirect('/verwaltung/kontengruppen/neu', $e->getMessage(), 'fehler');
return; return;
} }
@ -121,15 +152,18 @@ class VerwaltungController extends BaseController
{ {
$this->verlangeAnmeldung(); $this->verlangeAnmeldung();
$id = (int)$params['id'];
try { try {
Kontengruppe::speichern( Kontengruppe::speichern(
(int)$params['id'], $id,
$this->post('name'), $this->post('name'),
$this->post('beschreibung'), $this->post('beschreibung'),
(int)$this->post('sortierung', '0') (int)$this->post('sortierung', '0')
); );
Kontengruppe::kontenZuordnen($id, $this->postIds('konten'));
} catch (\Throwable $e) { } catch (\Throwable $e) {
View::redirect('/verwaltung/kontengruppen', $e->getMessage(), 'fehler'); View::redirect('/verwaltung/kontengruppen/' . $id, $e->getMessage(), 'fehler');
return; return;
} }

View file

@ -61,6 +61,7 @@ class Buchung
public static function anlegen(array $daten, array $splits = [], array $adressen = []): int public static function anlegen(array $daten, array $splits = [], array $adressen = []): int
{ {
$felder = self::normalisiere($daten, $splits); $felder = self::normalisiere($daten, $splits);
Monatsabschluss::verlangeOffen($felder['datum']);
return (int)Database::transaktion(static function () use ($felder, $splits, $adressen): int { return (int)Database::transaktion(static function () use ($felder, $splits, $adressen): int {
Database::execute( Database::execute(
@ -91,12 +92,18 @@ class Buchung
*/ */
public static function speichern(int $id, array $daten, array $splits = [], array $adressen = []): void 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.'); throw new \RuntimeException('Die Buchung gibt es nicht.');
} }
$felder = self::normalisiere($daten, $splits); $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::transaktion(static function () use ($id, $felder, $splits, $adressen): void {
Database::execute( Database::execute(
'UPDATE buchungen SET konto_id = ?, datum = ?, valuta = ?, betrag = ?, empfaenger = ?, 'UPDATE buchungen SET konto_id = ?, datum = ?, valuta = ?, betrag = ?, empfaenger = ?,
@ -120,6 +127,11 @@ class Buchung
public static function loeschen(int $id): void 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()]); Database::execute('DELETE FROM buchungen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
} }

254
src/Models/Budget.php Normal file
View file

@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Ein Budget ist die Jahresplanung je Kategorie und Monat – der Budgetplaner
* des FinanzManagers. Mehrere Budgets pro Jahr sind möglich, genau eines ist
* das aktive.
*
* Vorzeichen wie in der Buchhaltung: Ausgaben negativ, Einnahmen positiv. Der
* Nutzer tippt trotzdem den nackten Betrag ein („400“ bei Lebensmitteln);
* positionSetzen() dreht ihn anhand der Kategorieart. So stimmen Budget und Ist
* ohne Umrechnung überein.
*/
class Budget
{
/** @return list<array<string,mixed>> */
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<int, array<int, float>>
*/
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;
}
}

View file

@ -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<array<string,mixed>>
*/
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 public static function find(int $id): ?array
{ {
$zeile = Database::fetchOne( $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<int> $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“. */ /** Die zugeordneten Konten rutschen per ON DELETE SET NULL zu „Nicht gruppiert“. */
public static function loeschen(int $id): void public static function loeschen(int $id): void
{ {

View file

@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Der Monatsabschluss friert einen Monat ein: Er hält Kontosalden und
* Kategoriesummen als Snapshot fest und sperrt Buchungen in diesem Zeitraum.
*
* Die Sperre ist eine fachliche, keine technische: Sie greift in
* Buchung::anlegen/speichern/loeschen und lässt sich durch Wiedereröffnen des
* Monats aufheben – der Vorgang bleibt in der Zeile vermerkt.
*/
class Monatsabschluss
{
public const STATUS_ABGESCHLOSSEN = 'abgeschlossen';
public const STATUS_WIEDEREROEFFNET = 'wiedereroeffnet';
/** @return list<array<string,mixed>> */
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<array<string,mixed>>,
* nicht_abgeglichen: int, splitdifferenzen: list<array<string,mixed>>}
*/
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<string,mixed>
*/
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.');
}
}
}

View file

@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
use Finanzmanager\Models\Budget;
use Finanzmanager\Models\Kategorie;
/**
* Stellt die Tabelle des Budgetplaners zusammen: Zeilen sind der Kategoriebaum,
* Spalten die zwölf Monate mit Budget, Ist und Saldo.
*
* Vorzeichen durchgehend wie in der Buchhaltung (Ausgaben negativ). Daraus
* folgt eine Definition, die für Einnahmen wie Ausgaben gleich funktioniert:
*
* saldo = ist - budget
*
* Positiv heißt „besser als geplant“ – bei Ausgaben weniger ausgegeben, bei
* Einnahmen mehr eingenommen. Negativ ist die Überschreitung, die der Planer
* rot ausweist.
*
* Elternkategorien zeigen die Summe ihrer Nachfahren zusätzlich zu eigenen
* Buchungen; im FinanzManager verhält sich der Baum genauso.
*/
class Budgetplaner
{
public const MONATSNAMEN = [
1 => '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<int, array<int, float>> [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<string,mixed> $budget
* @return array{zeilen: list<array<string,mixed>>, gesamt: array<string,mixed>, 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<int, float> $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<int, float> $werte
* @return array<int, float>
*/
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<int, float> $ist
* @param array<int, float> $budget
* @return array<int, float>
*/
private static function differenz(array $ist, array $budget): array
{
$saldo = [];
for ($monat = 1; $monat <= 12; $monat++) {
$saldo[$monat] = $ist[$monat] - $budget[$monat];
}
return $saldo;
}
}

View file

@ -26,7 +26,9 @@ ob_start();
<label for="passwort_wdh">Passwort wiederholen</label> <label for="passwort_wdh">Passwort wiederholen</label>
<input type="password" id="passwort_wdh" name="passwort_wdh" autocomplete="new-password" required minlength="10"> <input type="password" id="passwort_wdh" name="passwort_wdh" autocomplete="new-password" required minlength="10">
<button type="submit" class="knopf primaer">Zugang einrichten</button> <div class="knopfleiste">
<button type="submit" class="knopf primaer">Zugang einrichten</button>
</div>
</form> </form>
<?php <?php
$inhalt = (string)ob_get_clean(); $inhalt = (string)ob_get_clean();

View file

@ -15,7 +15,9 @@ ob_start();
<label for="passwort">Passwort</label> <label for="passwort">Passwort</label>
<input type="password" id="passwort" name="passwort" autocomplete="current-password" required> <input type="password" id="passwort" name="passwort" autocomplete="current-password" required>
<button type="submit" class="knopf primaer">Anmelden</button> <div class="knopfleiste">
<button type="submit" class="knopf primaer">Anmelden</button>
</div>
</form> </form>
<?php <?php
$inhalt = (string)ob_get_clean(); $inhalt = (string)ob_get_clean();

View file

@ -0,0 +1,37 @@
<?php
use Finanzmanager\View;
/** @var array<string,mixed> $budget */
/** @var string $csrfToken */
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/planung/budget/verwaltung" class="knopf">Zurück zu den Budgets</a>
</div>
<form method="post" action="/planung/budget/<?= (int)$budget['id'] ?>/bearbeiten" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="<?= View::h($budget['name']) ?>" required autofocus>
<label for="jahr">Jahr</label>
<input type="number" id="jahr" name="jahr" value="<?= (int)$budget['jahr'] ?>" step="1" required>
<p class="feldhinweis">
Das Jahr bestimmt, aus welchen Buchungen die Ist-Werte kommen. Die geplanten
Monatswerte bleiben beim Wechsel erhalten.
</p>
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
<input type="text" id="beschreibung" name="beschreibung" value="<?= View::h($budget['beschreibung']) ?>">
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Speichern</button>
<a href="/planung/budget/verwaltung" class="knopf">Abbrechen</a>
</div>
</form>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,40 @@
<?php
use Finanzmanager\View;
/** @var string $csrfToken */
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/planung" class="knopf">Zurück zur Planung</a>
</div>
<div class="kachel einzeln">
<h2>Noch kein Budget</h2>
<p class="platzhalter">
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.
</p>
<form method="post" action="/planung/budget/neu" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="<?= (int)date('Y') + 1 ?>" required autofocus>
<p class="feldhinweis">Im FinanzManager heißen die Budgets etwa „2026-ecclesia“.</p>
<label for="jahr">Jahr</label>
<input type="number" id="jahr" name="jahr" value="<?= (int)date('Y') + 1 ?>" step="1" required>
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
<input type="text" id="beschreibung" name="beschreibung">
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Budget anlegen</button>
</div>
</form>
</div>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,123 @@
<?php
use Finanzmanager\View;
/** @var list<array<string,mixed>> $budgets */
/** @var int $jahr */
/** @var string $csrfToken */
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/planung/budget" class="knopf primaer">Budgetplaner öffnen</a>
<a href="/planung" class="knopf">Zurück zur Planung</a>
</div>
<p class="hinweis">
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.
</p>
<?php if ($budgets === []): ?>
<p class="platzhalter">Noch kein Budget angelegt.</p>
<?php else: ?>
<table class="liste">
<thead>
<tr>
<th>Name</th><th class="rechts">Jahr</th><th>Beschreibung</th>
<th class="rechts">Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($budgets as $eintrag): ?>
<tr>
<td>
<a href="/planung/budget?budget=<?= (int)$eintrag['id'] ?>">
<?= View::h($eintrag['name']) ?>
</a>
<?php if ((int)$eintrag['aktiv'] === 1): ?>
<span class="marke" title="Aktives Budget">★ aktiv</span>
<?php endif; ?>
</td>
<td class="rechts"><?= (int)$eintrag['jahr'] ?></td>
<td class="nebentext"><?= View::h($eintrag['beschreibung']) ?></td>
<td class="rechts nowrap">
<a href="/planung/budget/<?= (int)$eintrag['id'] ?>/bearbeiten"
class="knopf klein">Bearbeiten</a>
<?php if ((int)$eintrag['aktiv'] !== 1): ?>
<form method="post" action="/planung/budget/<?= (int)$eintrag['id'] ?>/aktivieren"
class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf klein">Aktiv setzen</button>
</form>
<?php endif; ?>
<form method="post" action="/planung/budget/<?= (int)$eintrag['id'] ?>/loeschen"
class="inline"
data-bestaetigen="Budget „<?= View::h($eintrag['name']) ?>“ mit allen Monatswerten löschen?">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf klein gefahr">Löschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<div class="kachelraster">
<div class="kachel">
<header><h2>Neues Budget</h2></header>
<form method="post" action="/planung/budget/neu" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
<label for="jahr">Jahr</label>
<input type="number" id="jahr" name="jahr" value="<?= $jahr + 1 ?>" step="1" required>
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
<input type="text" id="beschreibung" name="beschreibung">
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Budget anlegen</button>
</div>
</form>
</div>
<?php if ($budgets !== []): ?>
<div class="kachel">
<header><h2>Budget kopieren</h2></header>
<form method="post" action="/planung/budget/kopieren" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="vorlage">Vorlage</label>
<select id="vorlage" name="vorlage">
<?php foreach ($budgets as $eintrag): ?>
<option value="<?= (int)$eintrag['id'] ?>">
<?= View::h($eintrag['name']) ?> (<?= (int)$eintrag['jahr'] ?>)
</option>
<?php endforeach; ?>
</select>
<label for="kopie_name">Name der Kopie</label>
<input type="text" id="kopie_name" name="name" required>
<label for="kopie_jahr">Jahr</label>
<input type="number" id="kopie_jahr" name="jahr" value="<?= $jahr + 1 ?>" step="1" required>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Kopie anlegen</button>
</div>
</form>
<p class="feldhinweis">
Übernommen werden alle Monatswerte der Vorlage. Ist-Werte entstehen im neuen
Jahr aus den Buchungen.
</p>
</div>
<?php endif; ?>
</div>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,242 @@
<?php
use Finanzmanager\Services\Budgetplaner;
use Finanzmanager\View;
/** @var array<string,mixed> $budget */
/** @var list<array<string,mixed>> $budgets */
/** @var array{zeilen: list<array<string,mixed>>, gesamt: array<string,mixed>, 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';
?>
<div class="seitenaktionen">
<form method="get" action="/planung/budget" class="inline">
<select name="budget" aria-label="Budget wählen">
<?php foreach ($budgets as $eintrag): ?>
<option value="<?= (int)$eintrag['id'] ?>"
<?= (int)$eintrag['id'] === (int)$budget['id'] ? 'selected' : '' ?>>
<?= View::h($eintrag['name']) ?> (<?= (int)$eintrag['jahr'] ?>)<?= (int)$eintrag['aktiv'] === 1 ? ' ★' : '' ?>
</option>
<?php endforeach; ?>
</select>
<input type="hidden" name="ansicht" value="<?= View::h($ansicht) ?>">
<?php if ($nurGeplante): ?>
<input type="hidden" name="nur_geplante" value="1">
<?php endif; ?>
<button type="submit" class="knopf klein">Öffnen</button>
</form>
<a href="<?= View::h($link(['ansicht' => $alleMonate ? 'verlauf' : 'alle'])) ?>" class="knopf">
<?= $alleMonate ? 'Ansicht: Verlauf' : 'Ansicht: alle Monate' ?>
</a>
<a href="<?= View::h($link(['nur_geplante' => $nurGeplante ? null : '1'])) ?>" class="knopf">
<?= $nurGeplante ? 'Alle Kategorien' : 'Nur geplante Kategorien' ?>
</a>
<span class="fuellung"></span>
<a href="/planung/budget/verwaltung" class="knopf">Budgets verwalten</a>
<a href="/planung" class="knopf">Zurück zur Planung</a>
</div>
<p class="hinweis">
Budget <strong><?= View::h($budget['name']) ?></strong> für <?= $jahr ?>.
Ausgaben werden ohne Vorzeichen eingetragen – die Kategorieart bestimmt es.
Ein leeres Feld heißt „nicht geplant“, eine 0 heißt „mit 0,00 geplant“.
<strong>Saldo</strong> ist Ist minus Budget: negativ bedeutet über Plan.
Übergeordnete Kategorien summieren ihre Unterkategorien und werden deshalb
nicht selbst beplant.
</p>
<form method="post" action="/planung/budget/<?= (int)$budget['id'] ?>/positionen">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="ansicht" value="<?= View::h($ansicht) ?>">
<?php if ($nurGeplante): ?>
<input type="hidden" name="nur_geplante" value="1">
<?php endif; ?>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Budget speichern</button>
</div>
<div class="breittabelle">
<table class="liste budgetplaner">
<thead>
<tr>
<th rowspan="2" class="spalte-kategorie">Kategorie</th>
<th colspan="3" class="gruppenkopf">Jahr <?= $jahr ?></th>
<?php if (!$alleMonate && $abgelaufenBis > 0): ?>
<th colspan="3" class="gruppenkopf">
Januar–<?= View::h(Budgetplaner::MONATSKUERZEL[$abgelaufenBis]) ?>
</th>
<?php endif; ?>
<?php foreach ($eingabe as $monat): ?>
<th class="gruppenkopf<?= $monat === $laufenderMonat ? ' laufend' : '' ?>"
colspan="<?= !$alleMonate && $monat === $laufenderMonat ? 3 : 1 ?>">
<?= View::h(Budgetplaner::MONATSKUERZEL[$monat]) ?>
</th>
<?php endforeach; ?>
<th rowspan="2" class="rechts">↦</th>
</tr>
<tr>
<th class="rechts">Budget</th>
<th class="rechts">Ist</th>
<th class="rechts">Saldo</th>
<?php if (!$alleMonate && $abgelaufenBis > 0): ?>
<th class="rechts">Budget</th>
<th class="rechts">Ist</th>
<th class="rechts">Saldo</th>
<?php endif; ?>
<?php foreach ($eingabe as $monat): ?>
<?php if (!$alleMonate && $monat === $laufenderMonat): ?>
<th class="rechts laufend">Budget</th>
<th class="rechts laufend">Ist</th>
<th class="rechts laufend">Saldo</th>
<?php else: ?>
<th class="rechts">Budget</th>
<?php endif; ?>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php foreach ($tabelle['zeilen'] as $zeile): ?>
<?php
if ($nurGeplante && !$zeile['geplant']) {
continue;
}
$id = (int)$zeile['id'];
$blatt = !$zeile['hat_kinder'];
$klassen = $blatt ? '' : ' summenzeile-kategorie';
?>
<tr class="kategoriezeile<?= $klassen ?>" data-kategorie="<?= $id ?>"
data-eltern="<?= $zeile['eltern_id'] === null ? '' : (int)$zeile['eltern_id'] ?>">
<td class="spalte-kategorie">
<span class="baumname" data-ebene="<?= min(6, (int)$zeile['ebene']) ?>">
<?php if (!$blatt): ?>
<button type="button" class="klappknopf" data-klappt="<?= $id ?>"
aria-label="Unterkategorien ein-/ausblenden">▾</button>
<?php endif; ?>
<?= View::h($zeile['name']) ?>
</span>
</td>
<td class="rechts"><?= $zelle($zeile['jahrBudget']) ?></td>
<td class="rechts"><?= $zelle($zeile['jahrIst']) ?></td>
<td class="rechts <?= View::betragKlasse($zeile['jahrSaldo']) ?>">
<?= $zelle($zeile['jahrSaldo']) ?>
</td>
<?php if (!$alleMonate && $abgelaufenBis > 0): ?>
<?php
$budgetBis = Budgetplaner::summe($zeile['budget'], 1, $abgelaufenBis);
$istBis = Budgetplaner::summe($zeile['ist'], 1, $abgelaufenBis);
?>
<td class="rechts nebentext"><?= $zelle($budgetBis) ?></td>
<td class="rechts nebentext"><?= $zelle($istBis) ?></td>
<td class="rechts <?= View::betragKlasse($istBis - $budgetBis) ?>">
<?= $zelle($istBis - $budgetBis) ?>
</td>
<?php endif; ?>
<?php foreach ($eingabe as $monat): ?>
<td class="rechts<?= $monat === $laufenderMonat ? ' laufend' : '' ?>">
<?php if ($blatt): ?>
<input type="text" inputmode="decimal" class="betragsfeld"
name="betrag[<?= $id ?>][<?= $monat ?>]"
value="<?= isset($zeile['eigenBudget'][$monat])
? View::h(View::betrag(abs($zeile['eigenBudget'][$monat]), false)) : '' ?>"
aria-label="<?= View::h($zeile['name'] . ' ' . Budgetplaner::MONATSNAMEN[$monat]) ?>">
<?php else: ?>
<?= $zelle($zeile['budget'][$monat]) ?>
<?php endif; ?>
</td>
<?php if (!$alleMonate && $monat === $laufenderMonat): ?>
<td class="rechts laufend"><?= $zelle($zeile['ist'][$monat]) ?></td>
<td class="rechts laufend <?= View::betragKlasse($zeile['saldo'][$monat]) ?>">
<?= $zelle($zeile['saldo'][$monat]) ?>
</td>
<?php endif; ?>
<?php endforeach; ?>
<td class="rechts">
<?php if ($blatt): ?>
<button type="submit" name="uebertragen" class="knopf klein"
value="<?= $id ?>:<?= $erster ?>"
title="Wert aus <?= View::h(Budgetplaner::MONATSNAMEN[$erster]) ?> auf alle Folgemonate übertragen">↦</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr class="summenzeile">
<td class="spalte-kategorie">Gesamt</td>
<td class="rechts"><?= $zelle($tabelle['gesamt']['jahrBudget']) ?></td>
<td class="rechts"><?= $zelle($tabelle['gesamt']['jahrIst']) ?></td>
<td class="rechts <?= View::betragKlasse($tabelle['gesamt']['jahrSaldo']) ?>">
<?= $zelle($tabelle['gesamt']['jahrSaldo']) ?>
</td>
<?php if (!$alleMonate && $abgelaufenBis > 0): ?>
<?php
$budgetBis = Budgetplaner::summe($tabelle['gesamt']['budget'], 1, $abgelaufenBis);
$istBis = Budgetplaner::summe($tabelle['gesamt']['ist'], 1, $abgelaufenBis);
?>
<td class="rechts"><?= $zelle($budgetBis) ?></td>
<td class="rechts"><?= $zelle($istBis) ?></td>
<td class="rechts <?= View::betragKlasse($istBis - $budgetBis) ?>">
<?= $zelle($istBis - $budgetBis) ?>
</td>
<?php endif; ?>
<?php foreach ($eingabe as $monat): ?>
<td class="rechts<?= $monat === $laufenderMonat ? ' laufend' : '' ?>">
<?= $zelle($tabelle['gesamt']['budget'][$monat]) ?>
</td>
<?php if (!$alleMonate && $monat === $laufenderMonat): ?>
<td class="rechts laufend"><?= $zelle($tabelle['gesamt']['ist'][$monat]) ?></td>
<td class="rechts laufend <?= View::betragKlasse($tabelle['gesamt']['saldo'][$monat]) ?>">
<?= $zelle($tabelle['gesamt']['saldo'][$monat]) ?>
</td>
<?php endif; ?>
<?php endforeach; ?>
<td></td>
</tr>
</tfoot>
</table>
</div>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Budget speichern</button>
</div>
</form>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,56 @@
<?php
use Finanzmanager\Services\Budgetplaner;
use Finanzmanager\View;
/** @var array<string,mixed>|null $budget */
/** @var int $anzahl */
/** @var array<string,mixed>|null $letzter */
/** @var array{jahr: int, monat: int} $naechster */
include __DIR__ . '/../layout/kopf.php';
?>
<div class="kachelraster">
<div class="kachel">
<header><h2>Budgetplaner</h2></header>
<p class="kennzahl"><?= $budget === null ? '–' : (int)$budget['jahr'] ?></p>
<p class="platzhalter">
<?php if ($budget === null): ?>
Noch kein Budget angelegt. Der Planer stellt je Kategorie zwölf Monatswerte
ein und rechnet Ist und Saldo aus den Buchungen.
<?php else: ?>
Aktives Budget: <strong><?= View::h($budget['name']) ?></strong>.
<?= (int)$anzahl ?> Budget<?= $anzahl === 1 ? '' : 's' ?> insgesamt.
<?php endif; ?>
</p>
<div class="knopfleiste">
<a href="/planung/budget" class="knopf primaer">Budgetplaner öffnen</a>
<a href="/planung/budget/verwaltung" class="knopf">Budgets verwalten</a>
</div>
</div>
<div class="kachel">
<header><h2>Monatsabschluss</h2></header>
<p class="kennzahl">
<?= View::h(Budgetplaner::MONATSKUERZEL[$naechster['monat']] ?? '–') ?>
<?= (int)$naechster['jahr'] ?>
</p>
<p class="platzhalter">
<?php if ($letzter === null): ?>
Noch kein Monat abgeschlossen. Der Prüflauf zeigt vorher, was im Zeitraum
noch offen ist.
<?php else: ?>
Zuletzt abgeschlossen:
<strong><?= View::h(Budgetplaner::MONATSNAMEN[(int)$letzter['monat']] ?? '') ?>
<?= (int)$letzter['jahr'] ?></strong>
<?= $letzter['status'] === 'wiedereroeffnet' ? '(wieder geöffnet)' : '' ?>.
<?php endif; ?>
</p>
<div class="knopfleiste">
<a href="/planung/monatsabschluss" class="knopf primaer">Monatsabschluss</a>
</div>
</div>
</div>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,184 @@
<?php
use Finanzmanager\Models\Monatsabschluss;
use Finanzmanager\View;
/** @var int $jahr */
/** @var int $monat */
/** @var array{buchungen: int, ohne_kategorie: list<array<string,mixed>>,
* nicht_abgeglichen: int, splitdifferenzen: list<array<string,mixed>>} $pruefung */
/** @var array<string,mixed>|null $vorhanden */
/** @var list<array<string,mixed>> $abschluesse */
/** @var array<int,string> $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';
?>
<div class="seitenaktionen">
<form method="get" action="/planung/monatsabschluss" class="inline filterleiste">
<select name="monat" aria-label="Monat">
<?php foreach ($monatsnamen as $nummer => $name): ?>
<option value="<?= (int)$nummer ?>" <?= $nummer === $monat ? 'selected' : '' ?>>
<?= View::h($name) ?>
</option>
<?php endforeach; ?>
</select>
<input type="number" name="jahr" value="<?= $jahr ?>" step="1" class="schmal" aria-label="Jahr">
<button type="submit" class="knopf klein">Anzeigen</button>
</form>
<span class="fuellung"></span>
<a href="/planung" class="knopf">Zurück zur Planung</a>
</div>
<div class="kachel">
<header>
<h2>Prüflauf <?= View::h($monatsnamen[$monat] ?? '') ?> <?= $jahr ?></h2>
<?php if ($abgeschlossen): ?>
<span class="phasenmarke">abgeschlossen</span>
<?php endif; ?>
</header>
<table class="liste">
<tbody>
<tr>
<td>Buchungen im Zeitraum</td>
<td class="rechts"><?= (int)$pruefung['buchungen'] ?></td>
</tr>
<tr>
<td>ohne Kategorie oder Gegenkonto</td>
<td class="rechts <?= count($pruefung['ohne_kategorie']) > 0 ? 'betrag negativ' : 'nebentext' ?>">
<?= count($pruefung['ohne_kategorie']) ?>
</td>
</tr>
<tr>
<td>nicht abgeglichen</td>
<td class="rechts nebentext"><?= (int)$pruefung['nicht_abgeglichen'] ?></td>
</tr>
<tr>
<td>Splitbuchungen mit Differenz</td>
<td class="rechts <?= count($pruefung['splitdifferenzen']) > 0 ? 'betrag negativ' : 'nebentext' ?>">
<?= count($pruefung['splitdifferenzen']) ?>
</td>
</tr>
</tbody>
</table>
<?php if ($pruefung['ohne_kategorie'] !== []): ?>
<h3>Nicht kategorisiert</h3>
<table class="liste">
<thead>
<tr><th>Datum</th><th>Konto</th><th>Empfänger</th><th class="rechts">Betrag</th></tr>
</thead>
<tbody>
<?php foreach ($pruefung['ohne_kategorie'] as $zeile): ?>
<tr>
<td><?= View::datum($zeile['datum']) ?></td>
<td><?= View::h($zeile['konto']) ?></td>
<td><?= View::h($zeile['empfaenger']) ?></td>
<td class="rechts <?= View::betragKlasse($zeile['betrag']) ?>">
<?= View::betrag($zeile['betrag']) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if ($pruefung['splitdifferenzen'] !== []): ?>
<h3>Splitsummen weichen ab</h3>
<table class="liste">
<thead>
<tr>
<th>Datum</th><th>Konto</th><th>Empfänger</th>
<th class="rechts">Buchung</th><th class="rechts">Summe der Splits</th>
</tr>
</thead>
<tbody>
<?php foreach ($pruefung['splitdifferenzen'] as $zeile): ?>
<tr>
<td><?= View::datum($zeile['datum']) ?></td>
<td><?= View::h($zeile['konto']) ?></td>
<td><?= View::h($zeile['empfaenger']) ?></td>
<td class="rechts"><?= View::betrag($zeile['betrag']) ?></td>
<td class="rechts betrag negativ"><?= View::betrag($zeile['summe_splits']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if ($abgeschlossen): ?>
<p class="hinweis">
Abgeschlossen am <?= View::datum(substr((string)$vorhanden['abgeschlossen_am'], 0, 10)) ?>
<?= $vorhanden['abgeschlossen_von'] !== '' ? 'von ' . View::h($vorhanden['abgeschlossen_von']) : '' ?>.
Buchungen in diesem Zeitraum sind gesperrt.
</p>
<div class="knopfleiste">
<a href="/planung/monatsabschluss/<?= (int)$vorhanden['id'] ?>" class="knopf">Snapshot ansehen</a>
<form method="post" action="/planung/monatsabschluss/<?= (int)$vorhanden['id'] ?>/wiedereroeffnen"
class="inline"
data-bestaetigen="Monat wieder öffnen? Buchungen sind danach wieder änderbar.">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf gefahr">Monat wiedereröffnen</button>
</form>
</div>
<?php else: ?>
<form method="post" action="/planung/monatsabschluss" class="formular"
<?= $offen > 0 ? 'data-bestaetigen="Es sind noch Punkte offen. Monat trotzdem abschließen?"' : '' ?>>
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="jahr" value="<?= $jahr ?>">
<input type="hidden" name="monat" value="<?= $monat ?>">
<label for="notiz">Notiz <span class="optional">(optional)</span></label>
<input type="text" id="notiz" name="notiz" maxlength="255">
<p class="feldhinweis">
Der Abschluss hält Kontosalden und Kategoriesummen als Snapshot fest und sperrt
Buchungen im Zeitraum. Offene Punkte hindern nicht daran – sie sind ein Hinweis.
</p>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Monat abschließen</button>
</div>
</form>
<?php endif; ?>
</div>
<?php if ($abschluesse !== []): ?>
<h3>Bisherige Abschlüsse</h3>
<table class="liste">
<thead>
<tr>
<th>Monat</th><th>Status</th><th>Abgeschlossen am</th><th>Von</th>
<th>Notiz</th><th class="rechts">Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($abschluesse as $eintrag): ?>
<tr>
<td>
<?= View::h($monatsnamen[(int)$eintrag['monat']] ?? '') ?> <?= (int)$eintrag['jahr'] ?>
</td>
<td>
<?php if ($eintrag['status'] === Monatsabschluss::STATUS_ABGESCHLOSSEN): ?>
<span class="marke">abgeschlossen</span>
<?php else: ?>
<span class="marke">wieder geöffnet</span>
<?php endif; ?>
</td>
<td><?= View::datum(substr((string)$eintrag['abgeschlossen_am'], 0, 10)) ?></td>
<td class="nebentext"><?= View::h($eintrag['abgeschlossen_von']) ?></td>
<td class="nebentext"><?= View::h($eintrag['notiz']) ?></td>
<td class="rechts nowrap">
<a href="/planung/monatsabschluss/<?= (int)$eintrag['id'] ?>" class="knopf klein">Snapshot</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,84 @@
<?php
use Finanzmanager\Models\Kategorie;
use Finanzmanager\View;
/** @var array<string,mixed> $abschluss */
/** @var array<string,mixed> $snapshot */
/** @var array<int,string> $monatsnamen */
$namen = [];
foreach (Kategorie::alle() as $kategorie) {
$namen[(int)$kategorie['id']] = (string)$kategorie['pfad'];
}
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/planung/monatsabschluss?jahr=<?= (int)$abschluss['jahr'] ?>&monat=<?= (int)$abschluss['monat'] ?>"
class="knopf">Zurück zum Monatsabschluss</a>
</div>
<p class="hinweis">
Stand von <?= View::h($monatsnamen[(int)$abschluss['monat']] ?? '') ?> <?= (int)$abschluss['jahr'] ?>
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.
</p>
<?php if ($snapshot === []): ?>
<p class="platzhalter">Zu diesem Abschluss ist kein Snapshot gespeichert.</p>
<?php else: ?>
<h3>Kontosalden</h3>
<table class="liste">
<thead>
<tr><th>Konto</th><th class="rechts">Saldo</th></tr>
</thead>
<tbody>
<?php foreach ($snapshot['konten'] ?? [] as $konto): ?>
<tr>
<td>
<?= View::h($konto['name']) ?>
<?php if ((int)$konto['in_summe'] !== 1): ?>
<span class="marke" title="Zählt nicht zur Summe der Kontostände">ohne Σ</span>
<?php endif; ?>
</td>
<td class="rechts <?= View::betragKlasse($konto['saldo']) ?>">
<?= View::betrag($konto['saldo']) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr class="summenzeile">
<td>Summe Kontostände</td>
<td class="rechts <?= View::betragKlasse($snapshot['summe_kontostand'] ?? 0) ?>">
<?= View::betrag($snapshot['summe_kontostand'] ?? 0) ?>
</td>
</tr>
</tfoot>
</table>
<h3>Kategoriesummen des Monats</h3>
<?php if (($snapshot['kategorien'] ?? []) === []): ?>
<p class="platzhalter">Keine kategorisierten Buchungen im Zeitraum.</p>
<?php else: ?>
<table class="liste">
<thead>
<tr><th>Kategorie</th><th class="rechts">Betrag</th></tr>
</thead>
<tbody>
<?php foreach ($snapshot['kategorien'] as $zeile): ?>
<tr>
<td><?= View::h($namen[(int)$zeile['kategorie_id']] ?? '(gelöschte Kategorie)') ?></td>
<td class="rechts <?= View::betragKlasse($zeile['betrag']) ?>">
<?= View::betrag($zeile['betrag']) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -59,9 +59,21 @@ include __DIR__ . '/../layout/kopf.php';
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<tr>
<td>Kontengruppen</td>
<td class="rechts nebentext" colspan="3">
nicht im QIF-Export enthalten – <a href="/verwaltung/kontengruppen">hier zuordnen</a>
</td>
</tr>
</tbody> </tbody>
</table> </table>
<p class="hinweis">
Der FinanzManager exportiert je Konto nur Name, Kontoart, Beschreibung und
Kreditlimit. Die Zuordnung zu einer Kontengruppe steht weder im QIF noch in der
<code>QSD</code>-Datei; sie wird deshalb einmalig in der Verwaltung nachgetragen.
</p>
<?php if ($buchungen !== null): ?> <?php if ($buchungen !== null): ?>
<h3>Buchungen</h3> <h3>Buchungen</h3>
<table class="liste"> <table class="liste">
@ -141,6 +153,7 @@ include __DIR__ . '/../layout/kopf.php';
<?php else: ?> <?php else: ?>
<div class="knopfleiste"> <div class="knopfleiste">
<a href="/konten" class="knopf primaer">Kontenübersicht</a> <a href="/konten" class="knopf primaer">Kontenübersicht</a>
<a href="/verwaltung/kontengruppen" class="knopf">Kontengruppen</a>
<a href="/verwaltung/kategorien" class="knopf">Kategorien</a> <a href="/verwaltung/kategorien" class="knopf">Kategorien</a>
</div> </div>
<p class="hinweis"> <p class="hinweis">

View file

@ -83,7 +83,7 @@ include __DIR__ . '/../layout/kopf.php';
<?php foreach ($kategorien as $kategorie): ?> <?php foreach ($kategorien as $kategorie): ?>
<tr<?= (int)$kategorie['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>> <tr<?= (int)$kategorie['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
<td> <td>
<span class="baumname" style="--ebene: <?= (int)$kategorie['ebene'] ?>"> <span class="baumname" data-ebene="<?= min(6, (int)$kategorie['ebene']) ?>">
<a href="/verwaltung/kategorien/<?= (int)$kategorie['id'] ?>"> <a href="/verwaltung/kategorien/<?= (int)$kategorie['id'] ?>">
<?= View::h($kategorie['name']) ?> <?= View::h($kategorie['name']) ?>
</a> </a>

View file

@ -0,0 +1,88 @@
<?php
use Finanzmanager\Models\Konto;
use Finanzmanager\View;
/** @var array<string,mixed>|null $gruppe */
/** @var list<array<string,mixed>> $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';
?>
<div class="seitenaktionen">
<a href="/verwaltung/kontengruppen" class="knopf">Zurück zu den Kontengruppen</a>
</div>
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="name">Name der Kontengruppe</label>
<input type="text" id="name" name="name" value="<?= $wert('name') ?>" required autofocus>
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
<input type="text" id="beschreibung" name="beschreibung" value="<?= $wert('beschreibung') ?>">
<label for="sortierung">Sortierung</label>
<input type="number" id="sortierung" name="sortierung" value="<?= $wert('sortierung', '0') ?>" step="1">
<p class="feldhinweis">Kleinere Werte stehen in der Kontenübersicht weiter oben.</p>
<h3>Konten in dieser Gruppe</h3>
<?php if ($konten === []): ?>
<p class="feldhinweis">Noch keine Konten angelegt.</p>
<?php else: ?>
<p class="feldhinweis">
Ein Konto gehört zu genau einer Gruppe. Wird hier eines angehakt, das derzeit in
einer anderen Gruppe steht, wechselt es hierher.
</p>
<div class="auswahlliste">
<?php foreach ($konten as $konto): ?>
<?php
$eigene = !$neu && (int)($konto['kontengruppe_id'] ?? 0) === (int)$gruppe['id'];
$fremde = !$eigene && $konto['gruppenname'] !== null;
?>
<label class="ankreuzfeld">
<input type="checkbox" name="konten[]" value="<?= (int)$konto['id'] ?>"
<?= $eigene ? 'checked' : '' ?>>
<span>
<?= View::h($konto['name']) ?>
<span class="nebentext">
<?= View::h(Konto::bezeichnungDerArt((string)$konto['kontoart'])) ?>
<?= $fremde ? '· derzeit: ' . View::h((string)$konto['gruppenname']) : '' ?>
</span>
</span>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">
<?= $neu ? 'Kontengruppe anlegen' : 'Speichern' ?>
</button>
<a href="/verwaltung/kontengruppen" class="knopf">Abbrechen</a>
</div>
</form>
<?php if (!$neu): ?>
<form method="post" action="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>/loeschen"
class="formular breit"
data-bestaetigen="Kontengruppe „<?= View::h($gruppe['name']) ?>“ löschen? Die Konten bleiben erhalten.">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Kontengruppe löschen</h3>
<p class="feldhinweis">
Die zugeordneten Konten bleiben bestehen und stehen anschließend unter
„<?= View::h(\Finanzmanager\Models\Kontengruppe::OHNE_GRUPPE) ?>“.
</p>
<div class="knopfleiste">
<button type="submit" class="knopf gefahr">Kontengruppe löschen</button>
</div>
</form>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -4,24 +4,13 @@ use Finanzmanager\Models\Kontengruppe;
use Finanzmanager\View; use Finanzmanager\View;
/** @var list<array<string,mixed>> $gruppen */ /** @var list<array<string,mixed>> $gruppen */
/** @var list<array<string,mixed>> $konten */ /** @var int $ohneGruppe */
/** @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;
}
include __DIR__ . '/../layout/kopf.php'; include __DIR__ . '/../layout/kopf.php';
?> ?>
<div class="seitenaktionen"> <div class="seitenaktionen">
<a href="/verwaltung/kontengruppen/neu" class="knopf primaer">Neue Kontengruppe</a>
<a href="/konten" class="knopf">Zur Kontenübersicht</a> <a href="/konten" class="knopf">Zur Kontenübersicht</a>
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a> <a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
</div> </div>
@ -29,19 +18,22 @@ include __DIR__ . '/../layout/kopf.php';
<p class="hinweis"> <p class="hinweis">
Kontengruppen gliedern die Kontenübersicht. Konten ohne Gruppe erscheinen dort unter Kontengruppen gliedern die Kontenübersicht. Konten ohne Gruppe erscheinen dort unter
„<?= View::h(Kontengruppe::OHNE_GRUPPE) ?>“ – derzeit <?= (int)$ohneGruppe ?>. „<?= View::h(Kontengruppe::OHNE_GRUPPE) ?>“ – derzeit <?= (int)$ohneGruppe ?>.
Der QIF-Export des FinanzManagers enthält keine Kontengruppen; sie werden hier
angelegt und die Konten ihnen zugeordnet.
</p> </p>
<?php if ($gruppen === []): ?> <?php if ($gruppen === []): ?>
<p class="platzhalter">Noch keine Kontengruppen angelegt.</p> <div class="kachel einzeln">
<h2>Noch keine Kontengruppen</h2>
<p class="platzhalter">
Ohne Gruppen steht in der Kontenübersicht nur der Block
„<?= View::h(Kontengruppe::OHNE_GRUPPE) ?>“.
</p>
<div class="knopfleiste">
<a href="/verwaltung/kontengruppen/neu" class="knopf primaer">Kontengruppe anlegen</a>
</div>
</div>
<?php else: ?> <?php else: ?>
<?php foreach ($gruppen as $gruppe): ?>
<form id="gruppe-<?= (int)$gruppe['id'] ?>" method="post"
action="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>"></form>
<form id="gruppe-loeschen-<?= (int)$gruppe['id'] ?>" method="post"
action="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>/loeschen"
data-bestaetigen="Kontengruppe „<?= View::h($gruppe['name']) ?>“ löschen? Die Konten bleiben erhalten."></form>
<?php endforeach; ?>
<table class="liste"> <table class="liste">
<thead> <thead>
<tr> <tr>
@ -51,28 +43,18 @@ include __DIR__ . '/../layout/kopf.php';
</thead> </thead>
<tbody> <tbody>
<?php foreach ($gruppen as $gruppe): ?> <?php foreach ($gruppen as $gruppe): ?>
<?php $f = 'gruppe-' . (int)$gruppe['id']; ?>
<tr> <tr>
<td> <td>
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <a href="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>">
<input type="hidden" form="gruppe-loeschen-<?= (int)$gruppe['id'] ?>" <?= View::h($gruppe['name']) ?>
name="csrf_token" value="<?= View::h($csrfToken) ?>"> </a>
<input type="text" form="<?= $f ?>" name="name"
value="<?= View::h($gruppe['name']) ?>" required>
</td> </td>
<td> <td><?= View::h($gruppe['beschreibung']) ?></td>
<input type="text" form="<?= $f ?>" name="beschreibung" <td class="rechts"><?= (int)$gruppe['sortierung'] ?></td>
value="<?= View::h($gruppe['beschreibung']) ?>"> <td class="rechts"><?= (int)$gruppe['anzahl_konten'] ?></td>
</td>
<td class="rechts">
<input type="number" form="<?= $f ?>" name="sortierung" step="1" class="schmal"
value="<?= (int)$gruppe['sortierung'] ?>">
</td>
<td class="rechts"><?= (int)($anzahlJeGruppe[(int)$gruppe['id']] ?? 0) ?></td>
<td class="rechts nowrap"> <td class="rechts nowrap">
<button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button> <a href="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>"
<button type="submit" form="gruppe-loeschen-<?= (int)$gruppe['id'] ?>" class="knopf klein">Bearbeiten</a>
class="knopf klein gefahr">Löschen</button>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
@ -80,22 +62,4 @@ include __DIR__ . '/../layout/kopf.php';
</table> </table>
<?php endif; ?> <?php endif; ?>
<form method="post" action="/verwaltung/kontengruppen/neu" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Neue Kontengruppe</h3>
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
<input type="text" id="beschreibung" name="beschreibung">
<label for="sortierung">Sortierung</label>
<input type="number" id="sortierung" name="sortierung" value="0" step="1">
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Kontengruppe anlegen</button>
</div>
</form>
<?php include __DIR__ . '/../layout/fuss.php'; ?> <?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -19,6 +19,51 @@ class KontenTest extends TestCase
$this->uebersichtGruppiertUndSummiert(); $this->uebersichtGruppiertUndSummiert();
$this->betragsEingabe(); $this->betragsEingabe();
$this->ankreuzfelderAusDemFormular(); $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 private function anlegenUndFinden(): void

305
tests/11_BudgetTest.php Normal file
View file

@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
use Finanzmanager\Controllers\BudgetController;
use Finanzmanager\Models\Budget;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Budgetplaner;
class BudgetTest extends TestCase
{
protected string $testName = 'Test 11: Budgetplaner';
private const JAHR = 2026;
private int $konto = 0;
private int $lebensmittel = 0;
private int $essenAuswaerts = 0;
private int $leben = 0;
private int $gehalt = 0;
public function run(): void
{
$this->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');
}
}

View file

@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Monatsabschluss;
class MonatsabschlussTest extends TestCase
{
protected string $testName = 'Test 12: Monatsabschluss';
private int $giro = 0;
private int $tagesgeld = 0;
private int $auslagen = 0;
private int $kategorie = 0;
public function run(): void
{
$this->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');
}
}

View file

@ -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()
})
})

View file

@ -111,9 +111,11 @@ test.describe('Stammdaten', () => {
await page.goto('/verwaltung/kategorien') await page.goto('/verwaltung/kategorien')
await expect(page.locator('tr', { hasText: oben }).first()).toContainText('Oberkategorie') 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`)) 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 }) => { 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') await expect(page.locator('.kachel h2').first()).toContainText('Trockenlauf')
const uebersicht = page.locator('.kachel table.liste').first() const uebersicht = page.locator('.kachel table.liste').first()
await expect(uebersicht.locator('tr', { hasText: 'Klassen' })).toContainText('1') 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. // Nichts gespeichert: die Klasse darf noch nicht in der Liste stehen.
await page.goto('/verwaltung/klassen') await page.goto('/verwaltung/klassen')