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>
305 lines
12 KiB
PHP
305 lines
12 KiB
PHP
<?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');
|
||
}
|
||
}
|