mandantAnlegen(); $this->stammdaten(); $this->vorzeichenFolgtDerKategorieart(); $this->leeresFeldLoeschtDiePosition(); $this->istWerteAusBuchungenUndSplits(); $this->tabelleSummiertDenBaum(); $this->saldoZeigtUeberschreitung(); $this->kopieFuerDasNaechsteJahr(); $this->ansichtDesPlaners(); } 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 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'); } /** * Der laufende Monat wird im Planer hervorgehoben; die Ansicht entscheidet, * welche Wertspalten je Monat erscheinen. */ private function ansichtDesPlaners(): void { $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'); $this->assertEquals('alles', BudgetController::ansicht('alles'), 'Alle drei Wertspalten'); $this->assertEquals('budget', BudgetController::ansicht('budget'), 'Nur die Budgetspalte'); $this->assertEquals('ist', BudgetController::ansicht('ist'), 'Nur die Ist-Spalte'); $this->assertEquals('alles', BudgetController::ansicht(''), 'Ohne Angabe stehen alle Spalten da'); $this->assertEquals('alles', BudgetController::ansicht('verlauf'), 'Eine alte URL fällt auf „alles“ zurück'); } }