Merge pull request 'Test: Umbuchungen spiegeln sich zuverlässig' (#33) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/33
This commit is contained in:
fds2610 2026-08-18 06:54:16 +02:00
commit ff022baf3a
2 changed files with 487 additions and 0 deletions

View file

@ -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

View file

@ -0,0 +1,461 @@
<?php
declare(strict_types=1);
use Finanzmanager\Database;
use Finanzmanager\Mandant;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
/**
* Zwei Zusagen des Datenmodells, festgenagelt:
*
* 1. Was ein Gegenkonto trägt, steht auch im Kontoblatt dieses Gegenkontos –
* egal, auf welchem Weg das Gegenkonto dorthin kam.
* 2. Wer die Buchung löscht, löscht beide Sichten. Eine Gegenseite ohne
* Ursprungsbuchung kann es nicht geben.
*
* Beides folgt daraus, dass eine Umbuchung EIN Datensatz mit `gegenkonto_id`
* ist und die Gegenseite in `Buchung::kontoblatt()` abgeleitet wird. Diese
* Tests halten die Ableitung dicht: Die einzige Lücke, durch die eine
* Umbuchung unsichtbar würde, wäre eine Buchung, die zugleich `gesplittet`
* ist und oben ein Gegenkonto trägt – dann griffe weder der Zweig für
* Buchungen (`gesplittet = 0`) noch der für Splitzeilen.
*/
class UmbuchungSpiegelungTest extends TestCase
{
protected string $testName = 'Test 30: Umbuchungen spiegeln sich zuverlässig';
private int $giro = 0;
private int $tagesgeld = 0;
private int $bargeld = 0;
private int $kategorie = 0;
public function run(): void
{
$this->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<array<string,mixed>> */
private function gegensichten(int $kontoId): array
{
return array_values(array_filter(
Buchung::kontoblatt($kontoId),
static fn (array $zeile): bool => (bool)$zeile['gegensicht']
));
}
}