Der Plan ging davon aus, Umbuchungen stünden zweimal im Export und müssten zusammengeführt werden. Der Vollexport zeigt das Gegenteil: Eine Umbuchung steht einmal, in dem Konto, in dem sie erfasst wurde; die Gegenseite ist nur durch L[Zielkonto] benannt und muss beim Import erzeugt werden. Von 965 Umbuchungen hat keine ein zweites Vorkommen. Mit erzeugter Gegenseite stimmen acht gegen den FinanzManager geprüfte Kontosalden auf den Cent. Datenmodell: Migration 003 mit buchungen, buchung_splits und buchungsadressen. Eine Umbuchung ist ein Datensatz mit gegenkonto_id, kein gespiegeltes Paar - zwei Zeilen laufen früher oder später auseinander. Das Kontoblatt des Gegenkontos zeigt sie mit umgekehrtem Vorzeichen. Auch einzelne Splitzeilen dürfen ein Gegenkonto tragen. Konten führen zusätzlich in_summe, die Sigma-Spalte der Kontenübersicht, und saldo_online für Phase 5. Oberfläche: Kontoblatt mit laufendem Saldo, Filter auf Zeitraum, Kategorie, Klasse und Abgleich, Volltextsuche und Seitenblättern. Buchungsmaske für Einnahme, Ausgabe und Umbuchung in beide Richtungen, mit Splitzeilen und Restbetragsanzeige. Import: QifBuchungsImport arbeitet mit Stichtag. Alles davor wird je Konto zum Anfangssaldo aufsummiert, Gegenbuchungen eingerechnet; alles danach bis zum Importtag kommt einzeln in die Datenbank. Spätere Buchungen sind Vorausberechnungen des FinanzManagers und bleiben draußen. Wertpapiergeschäfte schlagen vorerst nur mit ihrem Geldfluss auf dem Verrechnungskonto zu Buche. Am Vollexport geprüft: 833 Buchungen ab 01.01.2026, 7946 in die Anfangssalden von 35 Konten verrechnet. Behoben: Die confirm()-Rückfragen vor dem Löschen waren wirkungslos. public/static/app.js brachte den Mechanismus mit, war aber nie im Layout eingebunden; die ersatzweise gesetzten onsubmit-Attribute verstoßen gegen die eigene Content-Security-Policy und wurden vom Browser verworfen - gelöscht wurde ohne Rückfrage. Ein E2E-Test besteht jetzt auf dem Dialog. Tests: 307 PHP-Prüfungen und 21 Playwright-Tests, alle grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
298 lines
13 KiB
PHP
298 lines
13 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Finanzmanager\Models\Buchung;
|
||
use Finanzmanager\Models\Kategorie;
|
||
use Finanzmanager\Models\Klasse;
|
||
use Finanzmanager\Models\Konto;
|
||
|
||
class BuchungenTest extends TestCase
|
||
{
|
||
protected string $testName = 'Test 09: Buchungen, Umbuchungen und Salden';
|
||
|
||
private int $giro = 0;
|
||
private int $tagesgeld = 0;
|
||
private int $kategorie = 0;
|
||
private int $klasse = 0;
|
||
|
||
public function run(): void
|
||
{
|
||
$this->mandantAnlegen();
|
||
$this->stammdaten();
|
||
|
||
$this->einfacheBuchungen();
|
||
$this->umbuchungHatZweiSichten();
|
||
$this->splitbuchung();
|
||
$this->splitSummeWirdGeprueft();
|
||
$this->splitMitGegenkonto();
|
||
$this->salden();
|
||
$this->kontoblattFilter();
|
||
$this->pruefungen();
|
||
}
|
||
|
||
private function stammdaten(): void
|
||
{
|
||
$this->giro = Konto::anlegen([
|
||
'name' => 'Testgiro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1,
|
||
]);
|
||
$this->tagesgeld = Konto::anlegen([
|
||
'name' => 'Testtagesgeld', 'kontoart' => 'sparkonto', 'anfangssaldo' => 5000, 'aktiv' => 1,
|
||
]);
|
||
$this->kategorie = Kategorie::anlegen(['name' => 'Lebensmittel', 'art' => 'ausgabe']);
|
||
$this->klasse = Klasse::anlegen('Privat');
|
||
}
|
||
|
||
private function einfacheBuchungen(): void
|
||
{
|
||
$id = Buchung::anlegen([
|
||
'konto_id' => $this->giro,
|
||
'datum' => '2026-03-18',
|
||
'betrag' => -49.90,
|
||
'empfaenger' => 'Supermarkt',
|
||
'buchungstext' => 'Wocheneinkauf',
|
||
'kategorie_id' => $this->kategorie,
|
||
'klasse_id' => $this->klasse,
|
||
'abgeglichen' => true,
|
||
]);
|
||
|
||
$buchung = Buchung::find($id);
|
||
$this->assertEquals('-49.90', $buchung['betrag'], 'Betrag wird übernommen');
|
||
$this->assertEquals('2026-03-18', $buchung['datum'], 'Datum wird übernommen');
|
||
$this->assertEquals(1, (int)$buchung['abgeglichen'], 'Abgleichstatus wird übernommen');
|
||
$this->assertEquals(0, (int)$buchung['gesplittet'], 'Ohne Splitzeilen keine Splitbuchung');
|
||
|
||
Buchung::speichern($id, [
|
||
'konto_id' => $this->giro, 'datum' => '2026-03-19', 'betrag' => -59.90,
|
||
'empfaenger' => 'Supermarkt', 'kategorie_id' => $this->kategorie,
|
||
]);
|
||
$this->assertEquals('-59.90', Buchung::find($id)['betrag'], 'Buchung lässt sich ändern');
|
||
|
||
Buchung::loeschen($id);
|
||
$this->assertNull(Buchung::find($id), 'Buchung lässt sich löschen');
|
||
}
|
||
|
||
private function umbuchungHatZweiSichten(): void
|
||
{
|
||
// 500 vom Giro aufs Tagesgeld.
|
||
$id = Buchung::anlegen([
|
||
'konto_id' => $this->giro,
|
||
'datum' => '2026-04-01',
|
||
'betrag' => -500.00,
|
||
'empfaenger' => 'Übertrag',
|
||
'gegenkonto_id' => $this->tagesgeld,
|
||
]);
|
||
|
||
$this->assertEquals(1, Buchung::anzahl(), 'Die Umbuchung ist ein einziger Datensatz');
|
||
|
||
$giroBlatt = Buchung::kontoblatt($this->giro);
|
||
$tagesBlatt = Buchung::kontoblatt($this->tagesgeld);
|
||
|
||
$this->assertEquals(1, count($giroBlatt), 'Sie steht im Kontoblatt des Ausgangskontos');
|
||
$this->assertEquals(1, count($tagesBlatt), 'Und ebenso im Kontoblatt des Gegenkontos');
|
||
$this->assertEquals(-500.0, (float)$giroBlatt[0]['betrag'], 'Auf dem Ausgangskonto negativ');
|
||
$this->assertEquals(500.0, (float)$tagesBlatt[0]['betrag'], 'Auf dem Gegenkonto positiv');
|
||
$this->assert(!$giroBlatt[0]['gegensicht'], 'Auf dem Ausgangskonto ist es die eigene Sicht');
|
||
$this->assert($tagesBlatt[0]['gegensicht'], 'Auf dem Gegenkonto die Gegensicht');
|
||
|
||
$this->assertEquals(500.0, Konto::saldo($this->giro), 'Girosaldo: 1000 - 500');
|
||
$this->assertEquals(5500.0, Konto::saldo($this->tagesgeld), 'Tagesgeldsaldo: 5000 + 500');
|
||
|
||
Buchung::loeschen($id);
|
||
$this->assertEquals(1000.0, Konto::saldo($this->giro), 'Nach dem Löschen ist der Saldo zurück');
|
||
$this->assertEquals(5000.0, Konto::saldo($this->tagesgeld), 'Auch auf dem Gegenkonto');
|
||
}
|
||
|
||
private function splitbuchung(): void
|
||
{
|
||
$versicherung = Kategorie::anlegen(['name' => 'Versicherung', 'art' => 'ausgabe']);
|
||
|
||
$id = Buchung::anlegen(
|
||
[
|
||
'konto_id' => $this->giro,
|
||
'datum' => '2026-01-02',
|
||
'betrag' => -260.49,
|
||
'empfaenger' => 'Versicherung AG',
|
||
],
|
||
[
|
||
['kategorie_id' => $versicherung, 'betrag' => -162.51, 'notiz' => 'Kasko'],
|
||
['kategorie_id' => $versicherung, 'betrag' => -97.98, 'notiz' => 'Haftpflicht'],
|
||
]
|
||
);
|
||
|
||
$buchung = Buchung::find($id);
|
||
$this->assertEquals(1, (int)$buchung['gesplittet'], 'Buchung ist als Splitbuchung gekennzeichnet');
|
||
$this->assertNull($buchung['kategorie_id'], 'Bei Splits steht oben keine Kategorie');
|
||
|
||
$splits = Buchung::splits($id);
|
||
$this->assertEquals(2, count($splits), 'Zwei Splitzeilen');
|
||
$this->assertEquals('Kasko', $splits[0]['notiz'], 'Reihenfolge der Zeilen bleibt erhalten');
|
||
$this->assertEquals('-97.98', $splits[1]['betrag'], 'Betrag der zweiten Zeile');
|
||
|
||
// Beim Speichern werden die Zeilen ersetzt, nicht ergänzt.
|
||
Buchung::speichern($id, [
|
||
'konto_id' => $this->giro, 'datum' => '2026-01-02', 'betrag' => -260.49,
|
||
], [
|
||
['kategorie_id' => $versicherung, 'betrag' => -260.49, 'notiz' => 'zusammengefasst'],
|
||
]);
|
||
$this->assertEquals(1, count(Buchung::splits($id)), 'Splitzeilen werden ersetzt');
|
||
|
||
Buchung::loeschen($id);
|
||
$this->assertEquals(0, count(Buchung::splits($id)), 'Mit der Buchung gehen die Splitzeilen');
|
||
}
|
||
|
||
private function splitSummeWirdGeprueft(): void
|
||
{
|
||
$abgewiesen = false;
|
||
try {
|
||
Buchung::anlegen(
|
||
['konto_id' => $this->giro, 'datum' => '2026-02-01', 'betrag' => -100.00],
|
||
[
|
||
['kategorie_id' => $this->kategorie, 'betrag' => -60.00],
|
||
['kategorie_id' => $this->kategorie, 'betrag' => -30.00],
|
||
]
|
||
);
|
||
} catch (\Throwable) {
|
||
$abgewiesen = true;
|
||
}
|
||
$this->assert($abgewiesen, 'Splitzeilen, die den Betrag verfehlen, werden abgewiesen');
|
||
$this->assertEquals(0, Buchung::anzahl(), 'Es blieb nichts halb Gespeichertes zurück');
|
||
|
||
// Ein Cent Rundungsdifferenz aus der Betragsumrechnung ist zulässig.
|
||
$id = Buchung::anlegen(
|
||
['konto_id' => $this->giro, 'datum' => '2026-02-01', 'betrag' => -100.00],
|
||
[
|
||
['kategorie_id' => $this->kategorie, 'betrag' => -66.67],
|
||
['kategorie_id' => $this->kategorie, 'betrag' => -33.33],
|
||
]
|
||
);
|
||
$this->assertGreaterThan(0, $id, 'Aufgehende Splitzeilen werden angenommen');
|
||
Buchung::loeschen($id);
|
||
}
|
||
|
||
private function splitMitGegenkonto(): void
|
||
{
|
||
// Eine Zeile geht auf ein anderes Konto – das muss dort auftauchen.
|
||
$id = Buchung::anlegen(
|
||
['konto_id' => $this->giro, 'datum' => '2026-05-02', 'betrag' => -300.00],
|
||
[
|
||
['kategorie_id' => $this->kategorie, 'betrag' => -100.00, 'notiz' => 'Einkauf'],
|
||
['gegenkonto_id' => $this->tagesgeld, 'betrag' => -200.00, 'notiz' => 'Rücklage'],
|
||
]
|
||
);
|
||
|
||
$this->assertEquals(700.0, Konto::saldo($this->giro), 'Das Ausgangskonto verliert die volle Summe');
|
||
$this->assertEquals(5200.0, Konto::saldo($this->tagesgeld), 'Nur die eine Zeile landet auf dem Gegenkonto');
|
||
|
||
$blatt = Buchung::kontoblatt($this->tagesgeld);
|
||
$this->assertEquals(1, count($blatt), 'Die Splitzeile erscheint als eigene Zeile im Kontoblatt');
|
||
$this->assertEquals(200.0, (float)$blatt[0]['betrag'], 'Mit umgekehrtem Vorzeichen');
|
||
$this->assertEquals('Rücklage', $blatt[0]['buchungstext'], 'Die Notiz der Zeile wird gezeigt');
|
||
|
||
Buchung::loeschen($id);
|
||
$this->assertEquals(5000.0, Konto::saldo($this->tagesgeld), 'Löschen räumt auch die Gegensicht ab');
|
||
}
|
||
|
||
private function salden(): void
|
||
{
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2026-01-15', 'betrag' => -100.00,
|
||
'kategorie_id' => $this->kategorie,
|
||
]);
|
||
// Eine Buchung in der Zukunft zählt nur zum erwarteten Saldo.
|
||
$zukunft = date('Y-m-d', strtotime('+30 days'));
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => $zukunft, 'betrag' => -250.00,
|
||
'kategorie_id' => $this->kategorie,
|
||
]);
|
||
|
||
$konto = Konto::mitSalden(Konto::find($this->giro));
|
||
$this->assertEquals(900.0, $konto['saldo_aktuell'], 'Aktueller Saldo ohne künftige Buchungen');
|
||
$this->assertEquals(650.0, $konto['saldo_erwartet'], 'Erwarteter Saldo einschließlich');
|
||
|
||
// Nur Konten mit Σ-Kennzeichen zählen zur Summe.
|
||
Konto::speichern($this->tagesgeld, [
|
||
'name' => 'Testtagesgeld', 'kontoart' => 'sparkonto', 'anfangssaldo' => 5000,
|
||
'aktiv' => 1, 'in_summe' => 0,
|
||
]);
|
||
|
||
$uebersicht = Konto::uebersicht();
|
||
$this->assertEquals(650.0, $uebersicht['summe'], 'Das Konto ohne Kennzeichen bleibt außen vor');
|
||
|
||
Konto::speichern($this->tagesgeld, [
|
||
'name' => 'Testtagesgeld', 'kontoart' => 'sparkonto', 'anfangssaldo' => 5000,
|
||
'aktiv' => 1, 'in_summe' => 1,
|
||
]);
|
||
$this->assertEquals(5650.0, Konto::uebersicht()['summe'], 'Mit Kennzeichen zählt es wieder mit');
|
||
}
|
||
|
||
private function kontoblattFilter(): void
|
||
{
|
||
$miete = Kategorie::anlegen(['name' => 'Miete', 'art' => 'ausgabe']);
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2026-06-01', 'betrag' => -800.00,
|
||
'empfaenger' => 'Vermieterin', 'kategorie_id' => $miete, 'abgeglichen' => true,
|
||
]);
|
||
|
||
$alle = Buchung::kontoblatt($this->giro);
|
||
$this->assertGreaterThan(2, count($alle), 'Das Kontoblatt zeigt alle Buchungen');
|
||
|
||
$gesucht = Buchung::kontoblatt($this->giro, ['suche' => 'Vermieter']);
|
||
$this->assertEquals(1, count($gesucht), 'Volltextsuche über den Empfänger');
|
||
|
||
$nachKategorie = Buchung::kontoblatt($this->giro, ['kategorie_id' => $miete]);
|
||
$this->assertEquals(1, count($nachKategorie), 'Filter auf die Kategorie');
|
||
|
||
$zeitraum = Buchung::kontoblatt($this->giro, ['von' => '2026-06-01', 'bis' => '2026-06-30']);
|
||
$this->assertEquals(1, count($zeitraum), 'Filter auf den Zeitraum');
|
||
|
||
$offene = Buchung::kontoblatt($this->giro, ['nur_offene' => true]);
|
||
foreach ($offene as $zeile) {
|
||
$this->assertEquals(0, (int)$zeile['abgeglichen'], 'Filter zeigt nur nicht abgeglichene');
|
||
}
|
||
|
||
// Ohne Filter läuft der Saldo mit, mit Filter wäre er irreführend.
|
||
$this->assertNotEmpty($alle[0]['saldo'], 'Ungefiltert gibt es einen laufenden Saldo');
|
||
$this->assertNull($zeitraum[0]['saldo'], 'Gefiltert bleibt die Saldospalte leer');
|
||
}
|
||
|
||
private function pruefungen(): void
|
||
{
|
||
$ohneKonto = false;
|
||
try {
|
||
Buchung::anlegen(['konto_id' => 0, 'datum' => '2026-01-01', 'betrag' => -1]);
|
||
} catch (\Throwable) {
|
||
$ohneKonto = true;
|
||
}
|
||
$this->assert($ohneKonto, 'Buchung ohne gültiges Konto wird abgewiesen');
|
||
|
||
$schlechtesDatum = false;
|
||
try {
|
||
Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '18.03.2026', 'betrag' => -1]);
|
||
} catch (\Throwable) {
|
||
$schlechtesDatum = true;
|
||
}
|
||
$this->assert($schlechtesDatum, 'Datum im falschen Format wird abgewiesen');
|
||
|
||
$selbst = false;
|
||
try {
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2026-01-01', 'betrag' => -1,
|
||
'gegenkonto_id' => $this->giro,
|
||
]);
|
||
} catch (\Throwable) {
|
||
$selbst = true;
|
||
}
|
||
$this->assert($selbst, 'Eine Umbuchung auf sich selbst wird abgewiesen');
|
||
|
||
$this->assert(Buchung::istDatum('2026-02-28'), 'Gültiges Datum');
|
||
$this->assert(!Buchung::istDatum('2026-02-30'), 'Der 30. Februar wird abgelehnt');
|
||
$this->assert(!Buchung::istDatum('2026-13-01'), 'Monat 13 wird abgelehnt');
|
||
|
||
$a = Buchung::hash(1, '2026-01-01', -10.0, '5', 'Text');
|
||
$b = Buchung::hash(1, '2026-01-01', -10.0, '5', 'Text');
|
||
$c = Buchung::hash(1, '2026-01-01', -10.01, '5', 'Text');
|
||
$this->assertEquals($a, $b, 'Gleiche Merkmale ergeben denselben Fingerabdruck');
|
||
$this->assert($a !== $c, 'Ein anderer Betrag ergibt einen anderen');
|
||
}
|
||
}
|