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