Finanzmanager/tests/30_UmbuchungSpiegelungTest.php
fds2610-bot 066879d61f Test: Umbuchungen spiegeln sich zuverlässig
Neuer Test 30. Er hält die zwei Zusagen des Datenmodells fest: Was ein
Gegenkonto trägt, steht im Kontoblatt dieses Gegenkontos – auf allen vier
Wegen, auf denen ein Gegenkonto an eine Buchung kommt – und wer die Buchung
löscht, löscht beide Sichten.

Dazu eine Invariantenprüfung über alle Daten des Mandanten an sieben Stellen
des Ablaufs. Vier absichtliche Eingriffe ins Modell lassen den Test
zuverlässig fehlschlagen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 06:52:33 +02:00

461 lines
18 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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