diff --git a/Changes.md b/Changes.md index 86ae944..c3b5e6b 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,32 @@ # Änderungen +## Umbuchung wird auch von der Gegenkontoseite erkannt (27.09.2026) + +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. + +- `Bankumsatz::passendeBuchung()` berücksichtigt jetzt auch die Gegenkonto-Seite einer + Umbuchung, mit umgekehrtem Vorzeichen wie im Kontoblatt auch. +- Test in `tests/16_BankingTest.php` + (`umbuchungWirdAuchVonDerGegenkontoseiteErkannt`). + +## Buchungen im Kontoblatt verknüpfen (27.09.2026) + +Liegen zwei Buchungen, die eigentlich derselbe Vorgang sind, zu weit auseinander (oder +gehörte eine ursprünglich einem anderen Ursprungskonto), findet der automatische +Abgleich sie nicht – die Umbuchung und ihre einzeln erfasste Dublette stehen dann +dauerhaft nebeneinander im Kontoblatt. + +- Neu: `Buchung::verknuepfen()` führt eine Umbuchung (eigene Seite oder Gegenseite auf + diesem Konto) und eine einzeln erfasste Buchung mit passendem Betrag zusammen – die + Umbuchung bleibt, die Dublette fällt weg, ihr Bankumsatz hängt sich an die Umbuchung. +- Im Kontoblatt lässt sich dafür auch die Gegenseite einer fremden Umbuchung ankreuzen; + der Knopf „Verknüpfen“ neben „Auswahl löschen“ nutzt dieselbe Auswahl. +- Test in `tests/44_BuchungenVerknuepfenTest.php`. + ## Buchungsbericht: Splitbuchung nach Konto/Empfänger zusammengefasst (27.09.2026) Nach Kategorie oder Klasse gruppiert erscheint eine Splitbuchung weiterhin mit jeder diff --git a/public/index.php b/public/index.php index e5eb1ef..4d47f00 100644 --- a/public/index.php +++ b/public/index.php @@ -85,6 +85,8 @@ $router->get('/konten/{id}/buchung', fn(array $p) => (new BuchungenController() $router->post('/konten/{id}/buchung', fn(array $p) => (new BuchungenController())->anlegen($p)); $router->post('/konten/{id}/buchungen/loeschen', fn(array $p) => (new BuchungenController())->auswahlLoeschen($p)); +$router->post('/konten/{id}/buchungen/verknuepfen', + fn(array $p) => (new BuchungenController())->verknuepfen($p)); $router->get('/buchungen/{id}', fn(array $p) => (new BuchungenController())->bearbeiten($p)); $router->post('/buchungen/{id}', fn(array $p) => (new BuchungenController())->speichern($p)); $router->post('/buchungen/{id}/loeschen', fn(array $p) => (new BuchungenController())->loeschen($p)); diff --git a/public/static/app.js b/public/static/app.js index 7df8d62..eb21996 100644 --- a/public/static/app.js +++ b/public/static/app.js @@ -29,7 +29,10 @@ }); } - // Formulare mit data-bestaetigen fragen vor dem Absenden nach. + // Formulare mit data-bestaetigen fragen vor dem Absenden nach. Trägt der + // gedrückte Knopf selbst ein data-bestaetigen, gilt seine Frage – so + // können mehrere Knöpfe im selben Formular (verschiedene Ziele über + // formaction) je ihre eigene Rückfrage stellen. // In der Capture-Phase, damit die Rückfrage auch dann greift, wenn ein // anderer Handler das Ereignis unterwegs stoppt. document.addEventListener('submit', function (ereignis) { @@ -38,7 +41,8 @@ return; } - const frage = formular.dataset.bestaetigen; + const frage = (ereignis.submitter && ereignis.submitter.dataset.bestaetigen) + || formular.dataset.bestaetigen; if (frage && !window.confirm(frage)) { ereignis.preventDefault(); ereignis.stopPropagation(); diff --git a/src/Controllers/BuchungenController.php b/src/Controllers/BuchungenController.php index f79cdcf..d2ce1ff 100644 --- a/src/Controllers/BuchungenController.php +++ b/src/Controllers/BuchungenController.php @@ -500,6 +500,41 @@ class BuchungenController extends BaseController : $anzahl . ' Buchungen gelöscht.'); } + /** + * Führt zwei angehakte Buchungen dieses Kontoblatts zusammen: eine schon + * fertige Umbuchung und eine einzeln erfasste Buchung, die denselben + * Vorgang doppelt zeigen, weil sie zu weit auseinanderlagen, um sich + * automatisch zu finden. + * + * @param array $params + */ + public function verknuepfen(array $params): void + { + $this->verlangeAnmeldung(); + + $kontoId = (int)$params['id']; + if (Konto::find($kontoId) === null) { + $this->nichtGefunden('/konten/' . $params['id']); + return; + } + + $ziel = $this->kontoblattPfad($kontoId); + $ids = $this->postIds('buchungen'); + if (count($ids) !== 2) { + View::redirect($ziel, 'Zum Verknüpfen bitte genau zwei Buchungen ankreuzen.', 'warnung'); + return; + } + + try { + Buchung::verknuepfen($kontoId, $ids[0], $ids[1]); + } catch (\Throwable $e) { + View::redirect($ziel, $e->getMessage(), 'fehler'); + return; + } + + View::redirect($ziel, 'Buchungen verknüpft.'); + } + /** * Weg zurück ins Kontoblatt – mit demselben Filter und derselben Seite. * diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index 89a4054..acb4437 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -266,6 +266,66 @@ class Buchung Database::execute('DELETE FROM buchungen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); } + /** + * Führt zwei Buchungen zusammen, die auf diesem Konto dieselbe Umbuchung + * doppelt zeigen: einmal als schon fertige Umbuchung (eigene Seite oder + * Gegenseite), einmal als einzeln erfasste Buchung mit demselben Betrag – + * etwa weil die Bank sie erst Tage später gebucht hat und der automatische + * Abgleich sie deshalb nicht zusammenführte. + * + * Die Umbuchung bleibt bestehen, die einzelne Buchung fällt weg; ihr + * Bankumsatz hängt sich stattdessen an die Umbuchung, wie es ein + * automatischer Abgleich auch täte. + */ + public static function verknuepfen(int $kontoId, int $idA, int $idB): void + { + $a = self::find($idA); + $b = self::find($idB); + if ($a === null || $b === null) { + throw new \RuntimeException('Eine der beiden Buchungen gibt es nicht.'); + } + + $istUmbuchungHier = static fn(array $z): bool => $z['gegenkonto_id'] !== null + && (int)$z['gesplittet'] === 0 + && ((int)$z['konto_id'] === $kontoId || (int)$z['gegenkonto_id'] === $kontoId); + + $istEinzelnHier = static fn(array $z): bool => $z['gegenkonto_id'] === null + && (int)$z['gesplittet'] === 0 + && (int)$z['konto_id'] === $kontoId; + + [$umbuchung, $einzeln] = match (true) { + $istUmbuchungHier($a) && $istEinzelnHier($b) => [$a, $b], + $istUmbuchungHier($b) && $istEinzelnHier($a) => [$b, $a], + default => throw new \RuntimeException( + 'Verknüpfen geht nur zwischen einer Umbuchung und einer einzeln erfassten ' + . 'Buchung auf diesem Konto.' + ), + }; + + // Auf der Gegenkontoseite zählt der Betrag mit umgekehrtem Vorzeichen, + // wie im Kontoblatt auch – ein grober Kontrollblick, kein Ersatz für die + // eigene Prüfung der Auswahl. + $vorzeichen = (int)$umbuchung['konto_id'] === $kontoId ? 1 : -1; + $effektiv = $vorzeichen * (float)$umbuchung['betrag']; + if (abs($effektiv - (float)$einzeln['betrag']) >= self::CENT) { + throw new \RuntimeException( + 'Die Beträge der beiden Buchungen passen nicht zusammen – bitte die Auswahl prüfen.' + ); + } + + Database::transaktion(static function () use ($umbuchung, $einzeln): void { + Database::execute( + 'UPDATE bank_umsaetze SET buchung_id = ? WHERE buchung_id = ? AND benutzer_id = ?', + [(int)$umbuchung['id'], (int)$einzeln['id'], Mandant::id()] + ); + Database::execute( + 'UPDATE buchungen SET abgeglichen = 1 WHERE id = ? AND benutzer_id = ?', + [(int)$umbuchung['id'], Mandant::id()] + ); + self::loeschen((int)$einzeln['id']); + }); + } + /** * Das Kontoblatt: eigene Buchungen und die Gegenseiten fremder Umbuchungen, * chronologisch mit laufendem Saldo. diff --git a/src/Views/buchungen/kontoblatt.php b/src/Views/buchungen/kontoblatt.php index fd22c3d..b83cc0b 100644 --- a/src/Views/buchungen/kontoblatt.php +++ b/src/Views/buchungen/kontoblatt.php @@ -47,8 +47,9 @@ $seitenPfad = static function (int $nr) use ($filter): string { // Nach dem Löschen soll dieselbe Ansicht wieder erscheinen – Filter und Seite // hängen deshalb am Ziel des Löschformulars. Dasselbe gilt für die // Schnellzuordnung: Ihre Ziele setzt kontoblatt.js aus dieser Abfrage zusammen. -$abfrage = $seitenPfad($seite); -$loeschZiel = '/konten/' . (int)$konto['id'] . '/buchungen/loeschen' . $abfrage; +$abfrage = $seitenPfad($seite); +$loeschZiel = '/konten/' . (int)$konto['id'] . '/buchungen/loeschen' . $abfrage; +$verknuepfenZiel = '/konten/' . (int)$konto['id'] . '/buchungen/verknuepfen' . $abfrage; /** * Die Zuordnung einer Zeile als Wert der gemeinsamen Auswahlliste – dieselben @@ -135,7 +136,19 @@ include __DIR__ . '/../layout/kopf.php'; data-frage-eine="Die ausgewählte Buchung wirklich löschen?" data-frage-mehrere="{anzahl} ausgewählte Buchungen wirklich löschen?"> + + @@ -222,11 +235,19 @@ include __DIR__ . '/../layout/kopf.php'; - - - + + diff --git a/tests/44_BuchungenVerknuepfenTest.php b/tests/44_BuchungenVerknuepfenTest.php new file mode 100644 index 0000000..0fddbae --- /dev/null +++ b/tests/44_BuchungenVerknuepfenTest.php @@ -0,0 +1,210 @@ +mandantAnlegen(); + $this->stammdaten(); + + $this->gegenseiteUndDublette(); + $this->eigeneUmbuchungUndDublette(); + $this->reihenfolgeDerAuswahlSpieltKeineRolle(); + $this->zweiEinzelneLassenSichNichtVerknuepfen(); + $this->zweiUmbuchungenLassenSichNichtVerknuepfen(); + $this->unpassenderBetragWirdAbgewiesen(); + } + + private function stammdaten(): void + { + $this->paypal = Konto::anlegen([ + 'name' => 'Paypal', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->dkb = Konto::anlegen([ + 'name' => 'DKB Giro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->zugang = Bankzugang::anlegen([ + 'name' => 'Testbank', 'bankname' => 'Testbank AG', 'blz' => '12030000', + 'fints_url' => 'https://banking.example.org/fints', 'benutzerkennung' => 'testkunde', + 'pin' => 'geheim1234', 'abrufzeitraum' => 'letzter_abruf', 'aktiv' => 1, + ]); + } + + /** @param array $daten */ + private function umsatzAnlegen(array $daten): int + { + return (int)Bankumsatz::anlegen(array_merge([ + 'zugang_id' => $this->zugang, + 'extern_id' => 'verknuepfen-' . bin2hex(random_bytes(4)), + 'wertstellung' => $daten['buchungstag'], + 'empfaenger' => '', + 'verwendungszweck' => '', + 'buchungsart' => 'GUTSCHRIFT', + ], $daten)); + } + + /** + * Der Fall aus der Praxis: Die Umbuchung wurde von Paypal aus gebucht (ihr + * Ursprungskonto), im DKB-Kontoblatt steht sie nur als Gegenseite. Die + * echte Bankbuchung kommt einen Tag später herein und wird – weil der + * automatische Abgleich sie nicht fand – als eigene Buchung übernommen. + */ + private function gegenseiteUndDublette(): void + { + $umbuchungId = Buchung::anlegen([ + 'konto_id' => $this->paypal, 'datum' => '2026-09-23', 'betrag' => 26.00, + 'empfaenger' => 'Umbuchung', 'gegenkonto_id' => $this->dkb, + ]); + + $dubletteId = Buchung::anlegen([ + 'konto_id' => $this->dkb, 'datum' => '2026-09-24', 'betrag' => -26.00, + 'empfaenger' => 'PayPal Europe', + ]); + $umsatzId = $this->umsatzAnlegen([ + 'konto_id' => $this->dkb, 'buchungstag' => '2026-09-24', 'betrag' => -26.00, + ]); + Bankumsatz::verbinden($umsatzId, $dubletteId); + + Buchung::verknuepfen($this->dkb, $umbuchungId, $dubletteId); + + $this->assertNull(Buchung::find($dubletteId), 'Die einzeln erfasste Dublette ist weg'); + $this->assertNotEmpty(Buchung::find($umbuchungId), 'Die Umbuchung bleibt bestehen'); + $this->assertEquals( + 1, + (int)Buchung::find($umbuchungId)['abgeglichen'], + 'Die Umbuchung gilt jetzt als abgeglichen' + ); + + $umsatz = Bankumsatz::find($umsatzId); + $this->assertEquals($umbuchungId, (int)$umsatz['buchung_id'], 'Ihr Bankumsatz zeigt jetzt auf die Umbuchung'); + $this->assertEquals('uebernommen', $umsatz['status'], 'Und gilt weiter als erledigt'); + } + + /** + * Gespiegelter Fall: Die Umbuchung wurde direkt im DKB-Konto gebucht (ihr + * Ursprungskonto liegt hier), die Dublette ebenfalls hier. + */ + private function eigeneUmbuchungUndDublette(): void + { + $umbuchungId = Buchung::anlegen([ + 'konto_id' => $this->dkb, 'datum' => '2026-09-23', 'betrag' => -75.20, + 'empfaenger' => 'Umbuchung', 'gegenkonto_id' => $this->paypal, + ]); + $dubletteId = Buchung::anlegen([ + 'konto_id' => $this->dkb, 'datum' => '2026-09-26', 'betrag' => -75.20, + 'empfaenger' => 'Reservix', + ]); + + Buchung::verknuepfen($this->dkb, $umbuchungId, $dubletteId); + + $this->assertNull(Buchung::find($dubletteId), 'Die Dublette ist weg'); + $this->assertEquals( + $this->paypal, + (int)Buchung::find($umbuchungId)['gegenkonto_id'], + 'Die Umbuchung bleibt, was sie war' + ); + } + + private function reihenfolgeDerAuswahlSpieltKeineRolle(): void + { + $umbuchungId = Buchung::anlegen([ + 'konto_id' => $this->paypal, 'datum' => '2026-09-23', 'betrag' => 12.00, + 'empfaenger' => 'Umbuchung', 'gegenkonto_id' => $this->dkb, + ]); + $dubletteId = Buchung::anlegen([ + 'konto_id' => $this->dkb, 'datum' => '2026-09-24', 'betrag' => -12.00, + 'empfaenger' => 'Dublette', + ]); + + // Zuerst die Dublette, dann die Umbuchung ausgewählt. + Buchung::verknuepfen($this->dkb, $dubletteId, $umbuchungId); + + $this->assertNull(Buchung::find($dubletteId), 'Auch andersherum verschwindet die Dublette'); + $this->assertNotEmpty(Buchung::find($umbuchungId), 'Und die Umbuchung bleibt'); + } + + private function zweiEinzelneLassenSichNichtVerknuepfen(): void + { + $ersteId = Buchung::anlegen([ + 'konto_id' => $this->dkb, 'datum' => '2026-09-25', 'betrag' => -10.00, 'empfaenger' => 'Erste', + ]); + $zweiteId = Buchung::anlegen([ + 'konto_id' => $this->dkb, 'datum' => '2026-09-25', 'betrag' => -10.00, 'empfaenger' => 'Zweite', + ]); + + $this->assert( + $this->wirftFehler(fn() => Buchung::verknuepfen($this->dkb, $ersteId, $zweiteId)), + 'Zwei einzeln erfasste Buchungen lassen sich nicht verknüpfen' + ); + $this->assertNotEmpty(Buchung::find($ersteId), 'Keine der beiden wurde gelöscht'); + $this->assertNotEmpty(Buchung::find($zweiteId), 'Auch die zweite nicht'); + } + + private function zweiUmbuchungenLassenSichNichtVerknuepfen(): void + { + $ersteId = Buchung::anlegen([ + 'konto_id' => $this->paypal, 'datum' => '2026-09-25', 'betrag' => 5.00, + 'empfaenger' => 'Umbuchung 1', 'gegenkonto_id' => $this->dkb, + ]); + $zweiteId = Buchung::anlegen([ + 'konto_id' => $this->dkb, 'datum' => '2026-09-25', 'betrag' => -7.00, + 'empfaenger' => 'Umbuchung 2', 'gegenkonto_id' => $this->paypal, + ]); + + $this->assert( + $this->wirftFehler(fn() => Buchung::verknuepfen($this->dkb, $ersteId, $zweiteId)), + 'Zwei Umbuchungen lassen sich nicht verknüpfen' + ); + } + + private function unpassenderBetragWirdAbgewiesen(): void + { + $umbuchungId = Buchung::anlegen([ + 'konto_id' => $this->paypal, 'datum' => '2026-09-23', 'betrag' => 26.00, + 'empfaenger' => 'Umbuchung', 'gegenkonto_id' => $this->dkb, + ]); + $dubletteId = Buchung::anlegen([ + 'konto_id' => $this->dkb, 'datum' => '2026-09-24', 'betrag' => -19.99, + 'empfaenger' => 'Anderer Betrag', + ]); + + $this->assert( + $this->wirftFehler(fn() => Buchung::verknuepfen($this->dkb, $umbuchungId, $dubletteId)), + 'Unterschiedliche Beträge werden abgewiesen' + ); + $this->assertNotEmpty(Buchung::find($dubletteId), 'Die Dublette bleibt unangetastet'); + } + + /** Hilfsmittel: Wirft der Aufruf eine Ausnahme? */ + private function wirftFehler(callable $aufruf): bool + { + try { + $aufruf(); + } catch (\Throwable) { + return true; + } + + return false; + } +}