From ecd01cde29e5420a23e4046f1262e75e36b7e99c Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 27 Sep 2026 14:48:08 +0200 Subject: [PATCH] Fix n.n.: Umbuchung wird auch von der Gegenkontoseite erkannt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine Umbuchung gehört ihrem Ursprungskonto; der automatische Abgleich suchte eine passende Buchung bisher nur über deren konto_id. Kam der Kontoauszug der Empfängerseite mit ein paar Tagen Verzug herein, fand er die schon gebuchte Umbuchung nie und legte sie als eigenständige Buchung an – die Umbuchung stand danach doppelt im Kontoblatt und verfälschte den Saldo. Die Suche berücksichtigt jetzt auch die Gegenkonto-Seite einer Umbuchung, mit umgekehrtem Vorzeichen wie im Kontoblatt auch. Co-Authored-By: Claude Sonnet 5 --- src/Models/Bankumsatz.php | 19 +++++++++++--- tests/16_BankingTest.php | 52 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/Models/Bankumsatz.php b/src/Models/Bankumsatz.php index 698a90e..6a4164b 100644 --- a/src/Models/Bankumsatz.php +++ b/src/Models/Bankumsatz.php @@ -200,6 +200,12 @@ class Bankumsatz * Konto, gleicher Betrag, Datum nah beieinander, noch mit keinem anderen * Umsatz verbunden. * + * Eine Umbuchung ist dabei auch dann „dasselbe Konto“, wenn sie auf ihrem + * Ursprungskonto steht und dieses Konto nur ihr Gegenkonto ist – sonst fände + * der Kontoauszug der Empfängerseite seine eigene Umbuchung nie, weil die + * Buchung formal einem anderen Konto gehört. Auf der Gegenkonto-Seite zählt + * der Betrag mit umgekehrtem Vorzeichen, wie im Kontoblatt auch. + * * Die Toleranz in Tagen steht in den Einstellungen (`banking_abgleich_tage`): * Zwischen dem Tag, an dem man eine Zahlung erfasst, und dem Tag, an dem die * Bank sie bucht, liegen erfahrungsgemäß ein paar Tage. @@ -214,13 +220,16 @@ class Bankumsatz return null; } - $tage = max(0, Einstellung::getInt('banking_abgleich_tage', 5)); + $tage = max(0, Einstellung::getInt('banking_abgleich_tage', 5)); + $betrag = round((float)$umsatz['betrag'], 2); $zeile = Database::fetchOne( 'SELECT b.* FROM buchungen b WHERE b.benutzer_id = ? - AND b.konto_id = ? - AND ABS(b.betrag - ?) < 0.005 + AND ( + (b.konto_id = ? AND ABS(b.betrag - ?) < 0.005) + OR (b.gegenkonto_id = ? AND b.gesplittet = 0 AND ABS(b.betrag + ?) < 0.005) + ) AND ABS(DATEDIFF(b.datum, ?)) <= ? AND NOT EXISTS ( SELECT 1 FROM bank_umsaetze u @@ -230,7 +239,9 @@ class Bankumsatz [ Mandant::id(), $kontoId, - round((float)$umsatz['betrag'], 2), + $betrag, + $kontoId, + $betrag, (string)$umsatz['buchungstag'], $tage, (int)($umsatz['id'] ?? 0), diff --git a/tests/16_BankingTest.php b/tests/16_BankingTest.php index 680572d..4496e71 100644 --- a/tests/16_BankingTest.php +++ b/tests/16_BankingTest.php @@ -47,6 +47,7 @@ class BankingTest extends TestCase $this->splitsHaltenDasVerhaeltnis(); $this->ungueltigesMusterWirdAbgewiesen(); $this->abgleichFindetVorhandeneBuchung(); + $this->umbuchungWirdAuchVonDerGegenkontoseiteErkannt(); $this->uebernehmenErzeugtEineBuchung(); $this->regelmitSplitsErzeugtEineSplitbuchung(); $this->buchungOhneKategorieWirdGefunden(); @@ -680,6 +681,57 @@ class BankingTest extends TestCase ); } + /** + * Eine Umbuchung gehört ihrem Ursprungskonto – hier dem Giro. Der + * Kontoauszug des Gegenkontos (Sparbuch) muss sie trotzdem finden, sonst + * bucht der automatische Abgleich sie dort ein zweites Mal. + */ + private function umbuchungWirdAuchVonDerGegenkontoseiteErkannt(): void + { + Einstellung::set('banking_abgleich_tage', '5', 'banking'); + + $umbuchungId = Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-08-20', 'betrag' => -40.00, + 'empfaenger' => 'Umbuchung Sparbuch', 'gegenkonto_id' => $this->sparkonto, + ]); + + // Die Bank bucht das Sparbuch erst einen Tag später – auf der + // Gegenkonto-Seite zählt der Betrag mit umgekehrtem Vorzeichen. + $umsatzId = Bankumsatz::anlegen([ + 'zugang_id' => $this->zugang, + 'konto_id' => $this->sparkonto, + 'extern_id' => 'test-gegenkonto-1', + 'buchungstag' => '2026-08-21', + 'wertstellung' => '2026-08-21', + 'betrag' => 40.00, + 'empfaenger' => 'Giro', + 'buchungsart' => 'GUTSCHRIFT', + ]); + $umsatz = Bankumsatz::find((int)$umsatzId); + + $treffer = Bankumsatz::passendeBuchung($umsatz); + $this->assertNotEmpty($treffer, 'Die Umbuchung wird auch von der Gegenkontoseite gefunden'); + $this->assertEquals($umbuchungId, (int)$treffer['id'], 'Und zwar genau sie'); + + $vorschlag = Bankumsatz::vorschlag($umsatz); + $this->assertEquals('verbinden', $vorschlag['art'], 'Der Vorschlag lautet: verbinden'); + + Bankumsatz::verbinden((int)$umsatzId, $umbuchungId); + $this->assertEquals( + 1, + (int)Buchung::find($umbuchungId)['abgeglichen'], + 'Die Umbuchung gilt jetzt als abgeglichen' + ); + + // Ein anderer Betrag auf der Gegenkontoseite passt nicht. + $this->assertNull( + Bankumsatz::passendeBuchung([ + 'konto_id' => $this->sparkonto, 'buchungstag' => '2026-08-21', 'betrag' => 12.34, 'id' => 0, + ]), + 'Ein anderer Betrag ist keine Umbuchung von drüben' + ); + } + private function uebernehmenErzeugtEineBuchung(): void { $offen = Bankumsatz::liste(['status' => 'offen']);