diff --git a/Changes.md b/Changes.md index 91f8f27..cf4cb6b 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,31 @@ # Änderungen +## Spiegelung der Umbuchungen unter Test (17.08.2026) + +Neuer Test `tests/30_UmbuchungSpiegelungTest.php`. Er nagelt die zwei Zusagen fest, die +sich aus dem Datenmodell ergeben – eine Umbuchung ist EIN Datensatz mit `gegenkonto_id`, +die Gegenseite leitet `Buchung::kontoblatt()` daraus ab: + +1. **Was ein Gegenkonto trägt, steht im Kontoblatt dieses Gegenkontos** – auf allen vier + Wegen, auf denen ein Gegenkonto an eine Buchung kommt: beim Anlegen, über die + Schnellzuordnung im Kontoblatt, über die Buchungsmaske und als einzelne Splitzeile. + Ein gewechseltes Gegenkonto bleibt nicht beim alten hängen, ein entferntes taucht + nirgends mehr auf, und Such-, Zeitraum- und Kategoriefilter verstecken die Gegensicht + nicht. +2. **Wer die Buchung löscht, löscht beide Sichten** – Kontoblatt und Saldo des + Gegenkontos sind danach wieder wie vorher, Splitzeilen bleiben nicht als Waisen + zurück. Eine Gegenseite ohne Ursprungsbuchung kann nicht entstehen: Sie trägt die + Nummer des Originals, ein Löschen „auf dem Gegenkonto“ trifft deshalb dieses. + +Dazu eine Invariantenprüfung über alle Daten des Mandanten, die an sieben Stellen des +Testablaufs läuft: Zu jedem gesetzten Gegenkonto gibt es eine Gegensicht, und keine +Buchung trägt ein Gegenkonto, das durch `gesplittet = 1` verdeckt wäre – das ist die +einzige Konstellation, in der eine Umbuchung unsichtbar würde, weil dann weder der Zweig +für Buchungen (`gesplittet = 0`) noch der für Splitzeilen greift. Gegenprobe: Vier +absichtliche Eingriffe ins Modell (beide Gegensicht-Zweige des Kontoblatts abgeklemmt, +Gegenkonto hinter einer Splitbuchung stehen gelassen, Löschen wirkungslos gemacht) +lassen den Test zuverlässig fehlschlagen. + ## Buchungsmaske nennt das Konto und passt auf einen Bildschirm (17.08.2026) *Neue Buchung* und *Buchung bearbeiten* zeigen oben, in welchem Konto die Buchung liegt diff --git a/tests/30_UmbuchungSpiegelungTest.php b/tests/30_UmbuchungSpiegelungTest.php new file mode 100644 index 0000000..383e1c9 --- /dev/null +++ b/tests/30_UmbuchungSpiegelungTest.php @@ -0,0 +1,461 @@ +mandantAnlegen(); + $this->stammdaten(); + + $this->jederWegErzeugtDieGegensicht(); + $this->gegenkontoWechseltUndVerschwindet(); + $this->splitbuchungVerstecktKeineUmbuchung(); + $this->filterVerstecktDieGegensichtNicht(); + $this->loeschenNimmtDieGegenseiteMit(); + $this->gegenseiteIstNichtEinzelnLoeschbar(); + } + + private function stammdaten(): void + { + $this->giro = Konto::anlegen([ + 'name' => 'Spiegelgiro', 'kontoart' => 'bank', 'anfangssaldo' => 2000, 'aktiv' => 1, + ]); + $this->tagesgeld = Konto::anlegen([ + 'name' => 'Spiegeltagesgeld', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->bargeld = Konto::anlegen([ + 'name' => 'Spiegelkasse', 'kontoart' => 'bar', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->kategorie = Kategorie::anlegen(['name' => 'Spiegelausgabe', 'art' => 'ausgabe']); + } + + /** + * Ein Gegenkonto kann auf vier Wegen an eine Buchung kommen. Jeder davon + * muss im Kontoblatt des Gegenkontos ankommen – sonst gäbe es Umbuchungen, + * die nur auf einer Seite sichtbar sind. + */ + private function jederWegErzeugtDieGegensicht(): void + { + // Weg 1: gleich beim Anlegen. + $beimAnlegen = Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => '2026-07-01', + 'betrag' => -100.00, + 'empfaenger' => 'Übertrag beim Anlegen', + 'gegenkonto_id' => $this->tagesgeld, + ]); + $this->assert( + $this->gegensichtVorhanden($this->tagesgeld, $beimAnlegen), + 'Beim Anlegen gesetztes Gegenkonto erscheint im Gegenkonto' + ); + + // Weg 2: nachträglich über die Schnellzuordnung im Kontoblatt. + $perZuordnung = Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => '2026-07-02', + 'betrag' => -200.00, + 'empfaenger' => 'Übertrag per Zuordnung', + ]); + $this->assert( + !$this->gegensichtVorhanden($this->tagesgeld, $perZuordnung), + 'Ohne Gegenkonto steht die Buchung nur auf ihrem eigenen Konto' + ); + Buchung::zuordnen($perZuordnung, null, $this->tagesgeld); + $this->assert( + $this->gegensichtVorhanden($this->tagesgeld, $perZuordnung), + 'Nachträglich zugeordnetes Gegenkonto erscheint im Gegenkonto' + ); + + // Weg 3: über die Buchungsmaske. + $perSpeichern = Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => '2026-07-03', + 'betrag' => -300.00, + 'empfaenger' => 'Übertrag per Maske', + 'kategorie_id' => $this->kategorie, + ]); + Buchung::speichern($perSpeichern, [ + 'konto_id' => $this->giro, + 'datum' => '2026-07-03', + 'betrag' => -300.00, + 'empfaenger' => 'Übertrag per Maske', + 'gegenkonto_id' => $this->tagesgeld, + ]); + $this->assert( + $this->gegensichtVorhanden($this->tagesgeld, $perSpeichern), + 'Über die Maske gesetztes Gegenkonto erscheint im Gegenkonto' + ); + + // Weg 4: als einzelne Splitzeile über den Splitdialog. + $perSplit = Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => '2026-07-04', + 'betrag' => -400.00, + 'empfaenger' => 'Teils Einkauf, teils Rücklage', + 'kategorie_id' => $this->kategorie, + ]); + Buchung::splitsErsetzen($perSplit, [ + ['kategorie_id' => $this->kategorie, 'betrag' => -150.00, 'notiz' => 'Einkauf'], + ['gegenkonto_id' => $this->tagesgeld, 'betrag' => -250.00, 'notiz' => 'Rücklage'], + ]); + $splitZeile = Buchung::splits($perSplit)[1]; + $this->assert( + $this->gegensichtVorhanden($this->tagesgeld, $perSplit, (int)$splitZeile['id']), + 'Gegenkonto einer Splitzeile erscheint im Gegenkonto' + ); + + $this->assertEquals( + 4, + count($this->gegensichten($this->tagesgeld)), + 'Alle vier Wege stehen im Kontoblatt des Gegenkontos' + ); + $this->assertEquals( + 850.0, + Konto::saldo($this->tagesgeld), + 'Und zwar mit umgekehrtem Vorzeichen: 100 + 200 + 300 + 250' + ); + + $this->pruefeInvariante('nach allen vier Wegen'); + } + + /** + * Ein gewechseltes Gegenkonto darf nicht auf dem alten hängenbleiben, ein + * entferntes nirgends mehr auftauchen. + */ + private function gegenkontoWechseltUndVerschwindet(): void + { + $id = Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => '2026-07-10', + 'betrag' => -50.00, + 'empfaenger' => 'Wanderndes Gegenkonto', + 'gegenkonto_id' => $this->tagesgeld, + ]); + + Buchung::zuordnen($id, null, $this->bargeld); + $this->assert( + !$this->gegensichtVorhanden($this->tagesgeld, $id), + 'Nach dem Wechsel steht die Gegensicht nicht mehr beim alten Gegenkonto' + ); + $this->assert( + $this->gegensichtVorhanden($this->bargeld, $id), + 'Sondern beim neuen' + ); + $this->assertEquals(50.0, Konto::saldo($this->bargeld), 'Der Saldo wandert mit'); + + // Umbuchung zurücknehmen: aus der Umbuchung wird eine Ausgabe. + Buchung::zuordnen($id, $this->kategorie, null); + $this->assert( + !$this->gegensichtVorhanden($this->bargeld, $id), + 'Wird das Gegenkonto entfernt, verschwindet die Gegensicht' + ); + $this->assertEquals(0.0, Konto::saldo($this->bargeld), 'Und der Saldo des Gegenkontos mit ihr'); + + $this->pruefeInvariante('nach Wechsel und Rücknahme des Gegenkontos'); + + Buchung::loeschen($id); + } + + /** + * Die einzige Konstellation, in der eine Umbuchung unsichtbar würde: oben + * ein Gegenkonto, gleichzeitig Splitzeilen. Kein Schreibweg darf sie + * herstellen – bei einer Splitbuchung steckt die Zuordnung in den Zeilen. + */ + private function splitbuchungVerstecktKeineUmbuchung(): void + { + // Beim Anlegen: Splitzeilen schlagen das Gegenkonto oben aus. + $id = Buchung::anlegen( + [ + 'konto_id' => $this->giro, + 'datum' => '2026-07-20', + 'betrag' => -80.00, + 'empfaenger' => 'Split trotz Gegenkonto', + 'gegenkonto_id' => $this->tagesgeld, + ], + [ + ['kategorie_id' => $this->kategorie, 'betrag' => -80.00, 'notiz' => 'alles eine Zeile'], + ] + ); + $buchung = Buchung::find($id); + $this->assertEquals(1, (int)$buchung['gesplittet'], 'Die Buchung ist gesplittet'); + $this->assertNull($buchung['gegenkonto_id'], 'Beim Anlegen fällt das Gegenkonto oben weg'); + $this->pruefeInvariante('nach dem Anlegen einer Splitbuchung mit Gegenkonto'); + + // Beim Speichern über die Maske ebenso. + Buchung::speichern( + $id, + [ + 'konto_id' => $this->giro, + 'datum' => '2026-07-20', + 'betrag' => -80.00, + 'empfaenger' => 'Split trotz Gegenkonto', + 'gegenkonto_id' => $this->tagesgeld, + ], + [ + ['kategorie_id' => $this->kategorie, 'betrag' => -80.00, 'notiz' => 'alles eine Zeile'], + ] + ); + $this->assertNull(Buchung::find($id)['gegenkonto_id'], 'Beim Speichern fällt es ebenfalls weg'); + $this->pruefeInvariante('nach dem Speichern einer Splitbuchung mit Gegenkonto'); + + // Umgekehrt: Wer eine Splitbuchung zur Umbuchung macht, verliert die Zeilen. + Buchung::zuordnen($id, null, $this->tagesgeld); + $nachher = Buchung::find($id); + $this->assertEquals(0, (int)$nachher['gesplittet'], 'Die Zuordnung zur Umbuchung hebt den Split auf'); + $this->assertEquals(0, count(Buchung::splits($id)), 'Die Splitzeilen sind fort'); + $this->assert( + $this->gegensichtVorhanden($this->tagesgeld, $id), + 'Und die Umbuchung steht im Gegenkonto' + ); + + // Und wer aus der Umbuchung eine Splitbuchung macht, verliert das Gegenkonto. + Buchung::splitsErsetzen($id, [ + ['kategorie_id' => $this->kategorie, 'betrag' => -80.00, 'notiz' => 'wieder gesplittet'], + ]); + $this->assertNull(Buchung::find($id)['gegenkonto_id'], 'Der Splitdialog räumt das Gegenkonto ab'); + $this->assert( + !$this->gegensichtVorhanden($this->tagesgeld, $id), + 'Damit ist die Gegensicht fort – sie hätte sonst kein Gegenüber' + ); + + $this->pruefeInvariante('nach dem Wechsel zwischen Split und Umbuchung'); + + Buchung::loeschen($id); + } + + /** + * Ein gesetzter Filter schränkt das Kontoblatt ein – aber er darf die + * Gegenseite nicht anders behandeln als die eigenen Buchungen. + */ + private function filterVerstecktDieGegensichtNicht(): void + { + $id = Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => '2026-07-15', + 'betrag' => -33.00, + 'empfaenger' => 'Findbarer Übertrag', + 'gegenkonto_id' => $this->tagesgeld, + ]); + + $gefunden = Buchung::kontoblatt($this->tagesgeld, ['suche' => 'Findbarer']); + $this->assertEquals(1, count($gefunden), 'Die Volltextsuche findet die Gegensicht'); + $this->assert($gefunden[0]['gegensicht'], 'Und kennzeichnet sie als solche'); + $this->assertEquals(33.0, (float)$gefunden[0]['betrag'], 'Mit umgekehrtem Vorzeichen'); + + $imZeitraum = Buchung::kontoblatt($this->tagesgeld, ['von' => '2026-07-15', 'bis' => '2026-07-15']); + $this->assertEquals(1, count($imZeitraum), 'Der Zeitraumfilter greift auf dem Buchungsdatum'); + + Buchung::loeschen($id); + } + + /** + * Zusage 2: Die Gegenseite überlebt ihre Buchung nicht – weder im + * Kontoblatt noch im Saldo, weder als ganze Buchung noch als Splitzeile. + */ + private function loeschenNimmtDieGegenseiteMit(): void + { + $vorherGiro = Konto::saldo($this->giro); + $vorherSpar = Konto::saldo($this->tagesgeld); + $vorherBlatt = count(Buchung::kontoblatt($this->tagesgeld)); + + $umbuchung = Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => '2026-07-25', + 'betrag' => -600.00, + 'empfaenger' => 'Zu löschende Umbuchung', + 'gegenkonto_id' => $this->tagesgeld, + ]); + $mitSplit = Buchung::anlegen( + ['konto_id' => $this->giro, 'datum' => '2026-07-26', 'betrag' => -700.00], + [ + ['kategorie_id' => $this->kategorie, 'betrag' => -300.00, 'notiz' => 'Einkauf'], + ['gegenkonto_id' => $this->tagesgeld, 'betrag' => -400.00, 'notiz' => 'Rücklage'], + ] + ); + + $this->assertEquals( + $vorherBlatt + 2, + count(Buchung::kontoblatt($this->tagesgeld)), + 'Beide Gegenseiten stehen im Gegenkonto' + ); + + Buchung::loeschen($umbuchung); + $this->assert( + !$this->gegensichtVorhanden($this->tagesgeld, $umbuchung), + 'Mit der Buchung verschwindet ihre Gegenseite aus dem Gegenkonto' + ); + + Buchung::loeschen($mitSplit); + $this->assert( + !$this->gegensichtVorhanden($this->tagesgeld, $mitSplit), + 'Auch die Gegenseite einer Splitzeile verschwindet mit der Buchung' + ); + $this->assertEquals( + 0, + (int)Database::fetchValue('SELECT COUNT(*) FROM buchung_splits WHERE buchung_id = ?', [$mitSplit]), + 'Die Splitzeile selbst bleibt nicht als Waise zurück' + ); + + $this->assertEquals($vorherGiro, Konto::saldo($this->giro), 'Der Saldo des Ausgangskontos ist zurück'); + $this->assertEquals($vorherSpar, Konto::saldo($this->tagesgeld), 'Der des Gegenkontos ebenso'); + $this->assertEquals( + $vorherBlatt, + count(Buchung::kontoblatt($this->tagesgeld)), + 'Und das Kontoblatt des Gegenkontos ist wieder so lang wie vorher' + ); + + $this->pruefeInvariante('nach dem Löschen'); + } + + /** + * Die Gegenseite ist keine eigene Buchung: Sie trägt die Nummer der + * Ursprungsbuchung. Ein Löschen „auf dem Gegenkonto“ trifft deshalb + * zwangsläufig das Original – eine halbe Umbuchung kann nicht entstehen. + * Das Kontoblatt bietet auf solchen Zeilen entsprechend keine Auswahl an + * (`gegensicht` steuert die Kästchen). + */ + private function gegenseiteIstNichtEinzelnLoeschbar(): void + { + $id = Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => '2026-07-28', + 'betrag' => -120.00, + 'empfaenger' => 'Vom Gegenkonto aus gelöscht', + 'gegenkonto_id' => $this->tagesgeld, + ]); + + $gegenzeilen = array_values(array_filter( + $this->gegensichten($this->tagesgeld), + static fn (array $zeile): bool => (string)$zeile['empfaenger'] === 'Vom Gegenkonto aus gelöscht' + )); + $this->assertEquals(1, count($gegenzeilen), 'Auf dem Gegenkonto steht genau eine Gegensicht dazu'); + $this->assertEquals( + $id, + (int)$gegenzeilen[0]['id'], + 'Sie trägt die Nummer der Ursprungsbuchung, keine eigene' + ); + + // Genau diese Nummer bekäme ein Löschbefehl vom Gegenkonto aus in die Hand. + Buchung::loeschen((int)$gegenzeilen[0]['id']); + + $this->assertNull(Buchung::find($id), 'Damit ist die Ursprungsbuchung fort'); + $this->assert( + !$this->gegensichtVorhanden($this->tagesgeld, $id), + 'Und auf dem Gegenkonto bleibt keine verwaiste Gegenseite stehen' + ); + + $this->pruefeInvariante('am Ende'); + } + + // ── Hilfen ─────────────────────────────────────────────────────────────── + + /** + * Die Kernzusage als Invariante über alle Daten dieses Mandanten: Zu jedem + * gesetzten Gegenkonto – ob an der Buchung oder an einer Splitzeile – gibt + * es im Kontoblatt jenes Kontos eine Gegensicht. Und keine Buchung trägt + * ein Gegenkonto, das durch `gesplittet = 1` verdeckt würde. + */ + private function pruefeInvariante(string $kontext): void + { + $verdeckt = (int)Database::fetchValue( + 'SELECT COUNT(*) FROM buchungen + WHERE benutzer_id = ? AND gegenkonto_id IS NOT NULL AND gesplittet = 1', + [Mandant::id()] + ); + $this->assertEquals(0, $verdeckt, "Kein Gegenkonto hinter einer Splitbuchung verdeckt ({$kontext})"); + + $umbuchungen = Database::fetchAll( + 'SELECT id, gegenkonto_id FROM buchungen + WHERE benutzer_id = ? AND gegenkonto_id IS NOT NULL AND gesplittet = 0', + [Mandant::id()] + ); + $splitzeilen = Database::fetchAll( + 'SELECT s.id, s.gegenkonto_id, s.buchung_id FROM buchung_splits s + JOIN buchungen b ON b.id = s.buchung_id + WHERE b.benutzer_id = ? AND s.gegenkonto_id IS NOT NULL', + [Mandant::id()] + ); + + $fehlend = 0; + foreach ($umbuchungen as $zeile) { + if (!$this->gegensichtVorhanden((int)$zeile['gegenkonto_id'], (int)$zeile['id'])) { + $fehlend++; + } + } + foreach ($splitzeilen as $zeile) { + $vorhanden = $this->gegensichtVorhanden( + (int)$zeile['gegenkonto_id'], + (int)$zeile['buchung_id'], + (int)$zeile['id'] + ); + if (!$vorhanden) { + $fehlend++; + } + } + + $this->assertEquals( + 0, + $fehlend, + "Jede Umbuchung steht im Kontoblatt ihres Gegenkontos ({$kontext})" + ); + $this->assertGreaterThan( + 0, + count($umbuchungen) + count($splitzeilen), + "Es gab überhaupt Umbuchungen zu prüfen ({$kontext})" + ); + } + + /** Steht die Buchung (oder eine ihrer Splitzeilen) als Gegensicht im Kontoblatt? */ + private function gegensichtVorhanden(int $kontoId, int $buchungId, ?int $splitId = null): bool + { + foreach ($this->gegensichten($kontoId) as $zeile) { + $passt = (int)$zeile['id'] === $buchungId + && ($splitId === null + ? $zeile['split_id'] === null + : (int)$zeile['split_id'] === $splitId); + if ($passt) { + return true; + } + } + return false; + } + + /** @return list> */ + private function gegensichten(int $kontoId): array + { + return array_values(array_filter( + Buchung::kontoblatt($kontoId), + static fn (array $zeile): bool => (bool)$zeile['gegensicht'] + )); + } +}