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'); } }