mandantAnlegen(); $this->stammdaten(); $this->auswahlwerteLesen(); $this->kategorieUndGegenkontoWechseln(); $this->gegensichtNenntIhrKonto(); $this->splitzeilenErsetzen(); $this->splitbuchungLoeschen(); $this->splitzeilenJeBuchung(); $this->klasseWechseln(); $this->pruefungen(); } private function stammdaten(): void { $this->giro = Konto::anlegen([ 'name' => 'Zuordnung Giro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1, ]); $this->spar = Konto::anlegen([ 'name' => 'Zuordnung Spar', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1, ]); $this->kategorie = Kategorie::anlegen(['name' => 'Zuordnung Einkauf', 'art' => 'ausgabe']); $this->klasse = Klasse::anlegen('Zuordnung Privat'); } private function buchung(float $betrag = -50.0, string $datum = '2026-03-10'): int { return Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => $datum, 'betrag' => $betrag, 'empfaenger' => 'Noch zuzuordnen', ]); } /** Kategorie und Gegenkonto stehen in einem Feld – hier wird es gelesen. */ private function auswahlwerteLesen(): void { $this->assertEquals( ['kategorie_id' => 12, 'gegenkonto_id' => null], BuchungenController::zuordnungAus('kategorie:12'), 'Eine Kategorie wird erkannt' ); $this->assertEquals( ['kategorie_id' => null, 'gegenkonto_id' => 7], BuchungenController::zuordnungAus('konto:7'), 'Ein Gegenkonto wird erkannt' ); $leer = ['kategorie_id' => null, 'gegenkonto_id' => null]; $this->assertEquals($leer, BuchungenController::zuordnungAus(''), 'Leer heißt keine Zuordnung'); $this->assertEquals($leer, BuchungenController::zuordnungAus('split'), 'Splitbuchung ist keine Zuordnung'); $this->assertEquals($leer, BuchungenController::zuordnungAus('konto:0'), 'Kennung 0 zählt nicht'); $this->assertEquals($leer, BuchungenController::zuordnungAus('unfug:3'), 'Unbekanntes Präfix zählt nicht'); } private function kategorieUndGegenkontoWechseln(): void { $id = $this->buchung(); Buchung::zuordnen($id, $this->kategorie, null); $buchung = Buchung::find($id); $this->assertEquals($this->kategorie, (int)$buchung['kategorie_id'], 'Die Kategorie steht in der Buchung'); $this->assertNull($buchung['gegenkonto_id'], 'Ohne Gegenkonto'); // Aus der Kategorie wird eine Umbuchung: beides zugleich gibt es nicht. Buchung::zuordnen($id, $this->kategorie, $this->spar); $buchung = Buchung::find($id); $this->assertEquals($this->spar, (int)$buchung['gegenkonto_id'], 'Das Gegenkonto steht in der Buchung'); $this->assertNull($buchung['kategorie_id'], 'Die Kategorie weicht dem Gegenkonto'); // Die Umbuchung wirkt sofort auf beiden Seiten. $this->assertEquals(50.0, Konto::saldo($this->spar), 'Das Gegenkonto bekommt die Gegenseite'); Buchung::zuordnen($id, null, null); $buchung = Buchung::find($id); $this->assertNull($buchung['kategorie_id'], 'Die Zuordnung lässt sich wieder leeren'); $this->assertNull($buchung['gegenkonto_id'], 'Auch das Gegenkonto'); $this->assertEquals(0.0, Konto::saldo($this->spar), 'Und das Gegenkonto ist den Betrag wieder los'); } /** * Der Anzeigefehler, um den es ging: Auf dem Gegenkonto stand eine Umbuchung * ohne erkennbares Gegenüber. Die Zeile muss das Ursprungskonto nennen. */ private function gegensichtNenntIhrKonto(): void { $id = $this->buchung(-120.0, '2026-03-11'); Buchung::zuordnen($id, null, $this->spar); $zeilen = array_values(array_filter( Buchung::kontoblatt($this->spar), static fn(array $zeile): bool => (int)$zeile['id'] === $id )); $this->assertEquals(1, count($zeilen), 'Die Umbuchung erscheint auf dem Gegenkonto'); $this->assert($zeilen[0]['gegensicht'], 'Und zwar als Gegensicht'); $this->assertEquals( $this->giro, (int)$zeilen[0]['gegenkonto_id'], 'Die Gegensicht nennt das Konto, von dem die Umbuchung kommt' ); $this->assertEquals(120.0, (float)$zeilen[0]['betrag'], 'Mit umgekehrtem Vorzeichen'); Buchung::loeschen($id); } private function splitzeilenErsetzen(): void { $id = $this->buchung(-100.0, '2026-03-12'); Buchung::zuordnen($id, $this->kategorie, null); Buchung::splitsErsetzen($id, [ ['kategorie_id' => $this->kategorie, 'klasse_id' => $this->klasse, 'betrag' => -60.0, 'notiz' => 'Einkauf'], ['gegenkonto_id' => $this->spar, 'betrag' => -40.0, 'notiz' => 'Rücklage'], ]); $buchung = Buchung::find($id); $this->assertEquals(1, (int)$buchung['gesplittet'], 'Die Buchung ist jetzt gesplittet'); $this->assertNull($buchung['kategorie_id'], 'Die Kategorie von oben weicht den Zeilen'); $splits = Buchung::splits($id); $this->assertEquals(2, count($splits), 'Zwei Zeilen sind gespeichert'); $this->assertEquals($this->klasse, (int)$splits[0]['klasse_id'], 'Die Klasse der ersten Zeile'); $this->assertEquals($this->spar, (int)$splits[1]['gegenkonto_id'], 'Das Gegenkonto der zweiten Zeile'); $this->assertEquals(40.0, Konto::saldo($this->spar), 'Nur der Betrag dieser Zeile geht aufs Gegenkonto'); // Eine einzelne Zuordnung hebt die Aufteilung auf. Buchung::zuordnen($id, $this->kategorie, null); $this->assertEquals(0, (int)Buchung::find($id)['gesplittet'], 'Die Splitbuchung ist aufgehoben'); $this->assertEquals([], Buchung::splits($id), 'Und die Zeilen sind weg'); $this->assertEquals(0.0, Konto::saldo($this->spar), 'Das Gegenkonto ist den Anteil wieder los'); // Ohne Zeilen bleibt eine Buchung ohne Zuordnung stehen. Buchung::splitsErsetzen($id, [ ['kategorie_id' => $this->kategorie, 'betrag' => -100.0, 'notiz' => ''], ]); Buchung::splitsErsetzen($id, []); $buchung = Buchung::find($id); $this->assertEquals(0, (int)$buchung['gesplittet'], 'Leere Zeilen heben die Splitbuchung auf'); $this->assertNull($buchung['kategorie_id'], 'Die Buchung steht danach ohne Zuordnung da'); $this->assertEquals(-100.0, (float)$buchung['betrag'], 'Der Buchungsbetrag bleibt unberührt'); Buchung::loeschen($id); } /** * „Splitbuchung löschen“ aus dem Splitdialog: Die Aufteilung fällt weg und * mit ihr jede Zuordnung – auch die einer Zeile mit Gegenkonto, deren * Gegenseite auf dem anderen Konto zu verschwinden hat. */ private function splitbuchungLoeschen(): void { $id = $this->buchung(-90.0, '2026-03-16'); Buchung::splitsErsetzen($id, [ ['kategorie_id' => $this->kategorie, 'klasse_id' => $this->klasse, 'betrag' => -55.0, 'notiz' => 'Teil A'], ['gegenkonto_id' => $this->spar, 'betrag' => -35.0, 'notiz' => 'Teil B'], ]); $this->assertEquals(35.0, Konto::saldo($this->spar), 'Die Zeile mit Gegenkonto wirkt zunächst'); Buchung::splitsErsetzen($id, []); $buchung = Buchung::find($id); $this->assertEquals([], Buchung::splits($id), 'Alle Splitzeilen sind gelöscht'); $this->assertEquals(0, (int)$buchung['gesplittet'], 'Die Buchung gilt nicht mehr als gesplittet'); $this->assertNull($buchung['kategorie_id'], 'Sie hat keine Kategorie'); $this->assertNull($buchung['gegenkonto_id'], 'Und kein Gegenkonto'); $this->assertEquals(-90.0, (float)$buchung['betrag'], 'Die Buchung selbst bleibt stehen'); $this->assertEquals(0.0, Konto::saldo($this->spar), 'Das Gegenkonto ist seinen Anteil wieder los'); Buchung::loeschen($id); } private function splitzeilenJeBuchung(): void { $eine = $this->buchung(-30.0, '2026-03-13'); $keine = $this->buchung(-20.0, '2026-03-14'); Buchung::splitsErsetzen($eine, [ ['kategorie_id' => $this->kategorie, 'betrag' => -10.0, 'notiz' => 'A'], ['kategorie_id' => $this->kategorie, 'betrag' => -20.0, 'notiz' => 'B'], ]); $jeBuchung = Buchung::splitsJeBuchung([$eine, $keine]); $this->assertEquals(2, count($jeBuchung[$eine]), 'Die Zeilen kommen der Buchung zugeordnet zurück'); $this->assert(!isset($jeBuchung[$keine]), 'Eine Buchung ohne Zeilen fehlt in der Liste'); $this->assertEquals([], Buchung::splitsJeBuchung([]), 'Ohne Kennungen wird nicht abgefragt'); Buchung::loeschen($eine); Buchung::loeschen($keine); } /** * Die Schnellauswahl der Klassenspalte: setzen, wechseln, löschen – und die * Zuordnung bleibt dabei, wie sie ist. */ private function klasseWechseln(): void { $zweite = Klasse::anlegen('Zuordnung Beruflich'); $id = $this->buchung(-70.0, '2026-03-17'); Buchung::zuordnen($id, $this->kategorie, null); Buchung::klasseSetzen($id, $this->klasse); $buchung = Buchung::find($id); $this->assertEquals($this->klasse, (int)$buchung['klasse_id'], 'Die Klasse steht in der Buchung'); $this->assertEquals( $this->kategorie, (int)$buchung['kategorie_id'], 'Die Zuordnung bleibt von der Klasse unberührt' ); Buchung::klasseSetzen($id, $zweite); $this->assertEquals($zweite, (int)Buchung::find($id)['klasse_id'], 'Die Klasse lässt sich wechseln'); // Der erste Eintrag der Liste – „keine“ – löscht die vorhandene Klasse. Buchung::klasseSetzen($id, null); $this->assertNull(Buchung::find($id)['klasse_id'], 'Und wieder löschen'); // Eine Splitbuchung behält ihre Zeilen: Die Klasse steht daneben. Buchung::splitsErsetzen($id, [ ['kategorie_id' => $this->kategorie, 'betrag' => -40.0, 'notiz' => 'A'], ['kategorie_id' => $this->kategorie, 'betrag' => -30.0, 'notiz' => 'B'], ]); Buchung::klasseSetzen($id, $this->klasse); $this->assertEquals(2, count(Buchung::splits($id)), 'Die Splitzeilen bleiben erhalten'); $this->assertEquals(1, (int)Buchung::find($id)['gesplittet'], 'Und die Buchung bleibt gesplittet'); $fremdeKlasse = false; try { Buchung::klasseSetzen($id, 999999); } catch (\Throwable) { $fremdeKlasse = true; } $this->assert($fremdeKlasse, 'Eine Klasse, die es nicht gibt, wird abgewiesen'); Buchung::loeschen($id); } private function pruefungen(): void { $id = $this->buchung(-80.0, '2026-03-15'); $aufSichSelbst = false; try { Buchung::zuordnen($id, null, $this->giro); } catch (\Throwable) { $aufSichSelbst = true; } $this->assert($aufSichSelbst, 'Ein Konto kann nicht sein eigenes Gegenkonto sein'); $splitAufSichSelbst = false; try { Buchung::splitsErsetzen($id, [['gegenkonto_id' => $this->giro, 'betrag' => -80.0]]); } catch (\Throwable) { $splitAufSichSelbst = true; } $this->assert($splitAufSichSelbst, 'Auch eine Splitzeile nicht'); $krummeSumme = false; try { Buchung::splitsErsetzen($id, [['kategorie_id' => $this->kategorie, 'betrag' => -30.0]]); } catch (\Throwable) { $krummeSumme = true; } $this->assert($krummeSumme, 'Zeilen, die nicht aufgehen, werden abgewiesen'); $this->assertEquals(0, (int)Buchung::find($id)['gesplittet'], 'Und die Buchung bleibt ungesplittet'); $fremdeKategorie = false; try { Buchung::zuordnen($id, 999999, null); } catch (\Throwable) { $fremdeKategorie = true; } $this->assert($fremdeKategorie, 'Eine Kategorie, die es nicht gibt, wird abgewiesen'); // Ein abgeschlossener Monat ist auch für die Schnellzuordnung zu. Monatsabschluss::abschliessen(2026, 3); $zu = false; try { Buchung::zuordnen($id, $this->kategorie, null); } catch (\Throwable) { $zu = true; } $this->assert($zu, 'Im abgeschlossenen Monat wird nichts mehr zugeordnet'); $zuFuerSplits = false; try { Buchung::splitsErsetzen($id, []); } catch (\Throwable) { $zuFuerSplits = true; } $this->assert($zuFuerSplits, 'Auch die Splitzeilen bleiben, wie sie sind'); $zuFuerKlasse = false; try { Buchung::klasseSetzen($id, $this->klasse); } catch (\Throwable) { $zuFuerKlasse = true; } $this->assert($zuFuerKlasse, 'Und die Klasse ebenso'); } }