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