From 5d39293328941bf757586aa676a6f48db115db7d Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 29 Sep 2026 11:48:13 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Fix=20n.n.:=20Bekannte=20Ums=C3=A4tze=20bek?= =?UTF-8?q?ommen=20IBAN=20und=20Bank=20der=20Gegenseite=20nachgetragen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- Changes.md | 10 ++ src/Models/Bankumsatz.php | 66 +++++++++++- tests/49_UmsatzHerkunftNachtragenTest.php | 119 ++++++++++++++++++++++ 3 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 tests/49_UmsatzHerkunftNachtragenTest.php diff --git a/Changes.md b/Changes.md index caf890d..e483945 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,15 @@ # Änderungen +## Bekannte Umsätze bekommen IBAN und Bank der Gegenseite nachgetragen (29.09.2026) + +- Der Tooltip im Kontoblatt blieb bei älteren Umsätzen leer: IBAN und Bank der Gegenseite + werden erst seit dem 28.09. gespeichert, und ein erneuter Abruf übersprang bekannte Umsätze + ganz. Jetzt trägt `Bankumsatz::anlegen()` bei einem bekannten Umsatz nach, was ihm fehlt + (`Bankumsatz::ergaenzen()`), und gibt es an die verbundene Buchung weiter. Nur leere Felder + werden gefüllt, vorhandene bleiben. +- Auch `Bankumsatz::verbinden()` überträgt die Angaben auf eine von Hand erfasste Buchung. +- Neu: Test 49. + ## E2E-Läufe lassen die Einstellungen unverändert (29.09.2026) - Die Browser-Tests laufen gegen die echte Instanz; der Bargeld-Test hatte dabei die Adresse diff --git a/src/Models/Bankumsatz.php b/src/Models/Bankumsatz.php index ef2dd69..6d438c7 100644 --- a/src/Models/Bankumsatz.php +++ b/src/Models/Bankumsatz.php @@ -80,11 +80,17 @@ class Bankumsatz /** * @param array $daten * @return int|null Die neue Kennung, oder null wenn der Umsatz schon da war. + * Ein bekannter Umsatz bekommt dabei nachgetragen, was ihm bisher + * fehlte (siehe ergaenzen()). */ public static function anlegen(array $daten): ?int { $externId = (string)($daten['extern_id'] ?? ''); - if ($externId === '' || self::existiert($externId)) { + if ($externId === '') { + return null; + } + if (self::existiert($externId)) { + self::ergaenzen($externId, $daten); return null; } @@ -117,6 +123,53 @@ class Bankumsatz return Database::lastInsertId(); } + /** + * Trägt IBAN und Bank der Gegenseite bei einem bereits bekannten Umsatz + * nach. Ältere Abrufe haben sie noch nicht gespeichert; der erneute Abruf + * liefert sie, und der Fingerabdruck erkennt den Umsatz nur als bekannt. + * Nur leere Felder werden gefüllt – was schon dasteht, bleibt. Hängt der + * Umsatz an einer Buchung, bekommt auch sie die Angaben, soweit sie dort + * fehlen. + * + * @param array $daten + */ + public static function ergaenzen(string $externId, array $daten): void + { + $iban = mb_substr(strtoupper(trim((string)($daten['gegen_iban'] ?? ''))), 0, 34); + $bankname = mb_substr(trim((string)($daten['gegen_bankname'] ?? '')), 0, 128); + if ($iban === '' && $bankname === '') { + return; + } + + Database::execute( + "UPDATE bank_umsaetze + SET gegen_iban = IF(gegen_iban = '', ?, gegen_iban), + gegen_bankname = IF(gegen_bankname = '', ?, gegen_bankname) + WHERE benutzer_id = ? AND extern_id = ?", + [$iban, $bankname, Mandant::id(), $externId] + ); + + $buchungId = Database::fetchValue( + 'SELECT buchung_id FROM bank_umsaetze WHERE benutzer_id = ? AND extern_id = ?', + [Mandant::id(), $externId] + ); + if ($buchungId !== null && $buchungId !== false) { + self::herkunftUebertragen((int)$buchungId, $iban, $bankname); + } + } + + /** Füllt IBAN und Bank einer Buchung, soweit sie dort noch leer sind. */ + private static function herkunftUebertragen(int $buchungId, string $iban, string $bankname): void + { + Database::execute( + "UPDATE buchungen + SET iban = IF(iban = '', ?, iban), + bankname = IF(bankname = '', ?, bankname) + WHERE id = ? AND benutzer_id = ?", + [$iban, $bankname, $buchungId, Mandant::id()] + ); + } + public static function find(int $id): ?array { $zeile = Database::fetchOne( @@ -323,11 +376,20 @@ class Bankumsatz ); // Was von der Bank kommt, ist dort gebucht – das Kennzeichen der - // verbundenen Buchung wird nachgezogen. + // verbundenen Buchung wird nachgezogen, ebenso IBAN und Bank der + // Gegenseite, falls die Buchung sie nicht kennt. Database::execute( 'UPDATE buchungen SET abgeglichen = 1 WHERE id = ? AND benutzer_id = ?', [$buchungId, Mandant::id()] ); + $umsatz = self::find($id); + if ($umsatz !== null) { + self::herkunftUebertragen( + $buchungId, + (string)($umsatz['gegen_iban'] ?? ''), + (string)($umsatz['gegen_bankname'] ?? '') + ); + } } public static function verwerfen(int $id): void diff --git a/tests/49_UmsatzHerkunftNachtragenTest.php b/tests/49_UmsatzHerkunftNachtragenTest.php new file mode 100644 index 0000000..8f149cb --- /dev/null +++ b/tests/49_UmsatzHerkunftNachtragenTest.php @@ -0,0 +1,119 @@ +mandantAnlegen(); + + $this->giro = Konto::anlegen([ + 'name' => 'Nachtragsgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->zugang = Bankzugang::anlegen([ + 'name' => 'Nachtragsbank', 'bankname' => 'Nachtragsbank AG', 'blz' => '20041111', + 'fints_url' => 'https://banking.example.org/fints', 'benutzerkennung' => 'testkunde', + 'pin' => 'geheim1234', 'abrufzeitraum' => 'letzter_abruf', 'aktiv' => 1, + ]); + $this->bankkonto = (int)Bankzugang::findeOderLegeKontoAn( + $this->zugang, + 'DE53200411110847626900', + '847626900', + 'Girokonto' + )['id']; + Bankzugang::ordneKontoZu($this->bankkonto, $this->giro); + + $this->bekannterUmsatzBekommtHerkunft(); + $this->vorhandeneAngabenBleiben(); + $this->verbundeneBuchungBekommtHerkunft(); + $this->verbindenMitErfasstenBuchungUebertraegt(); + } + + /** @return array */ + private function daten(string $zweck, string $iban = '', string $bank = ''): array + { + return [ + 'zugang_id' => $this->zugang, + 'konto_id' => $this->giro, + 'bank_konto_id' => $this->bankkonto, + 'extern_id' => Bankumsatz::externId($this->bankkonto, '2026-09-04', 0.01, $zweck), + 'buchungstag' => '2026-09-04', + 'wertstellung' => '2026-09-04', + 'betrag' => 0.01, + 'empfaenger' => 'Testabsender', + 'verwendungszweck' => $zweck, + 'gegen_iban' => $iban, + 'gegen_bankname' => $bank, + ]; + } + + private function bekannterUmsatzBekommtHerkunft(): void + { + $id = Bankumsatz::anlegen($this->daten('Cent eins')); + $this->assertNotEmpty($id, 'Umsatz ohne Herkunft angelegt'); + + $zweiter = Bankumsatz::anlegen($this->daten('Cent eins', 'de89370400440532013000', 'Testbank')); + $this->assertNull($zweiter, 'Zweiter Abruf legt keinen neuen Umsatz an'); + + $umsatz = Bankumsatz::find((int)$id); + $this->assertEquals('DE89370400440532013000', $umsatz['gegen_iban'], 'IBAN nachgetragen'); + $this->assertEquals('Testbank', $umsatz['gegen_bankname'], 'Bank nachgetragen'); + } + + private function vorhandeneAngabenBleiben(): void + { + $id = Bankumsatz::anlegen($this->daten('Cent zwei', 'DE02120300000000202051', 'Erste Bank')); + Bankumsatz::anlegen($this->daten('Cent zwei', 'DE89370400440532013000', 'Andere Bank')); + + $umsatz = Bankumsatz::find((int)$id); + $this->assertEquals('DE02120300000000202051', $umsatz['gegen_iban'], 'Vorhandene IBAN bleibt'); + $this->assertEquals('Erste Bank', $umsatz['gegen_bankname'], 'Vorhandene Bank bleibt'); + } + + private function verbundeneBuchungBekommtHerkunft(): void + { + $id = (int)Bankumsatz::anlegen($this->daten('Cent drei')); + $buchungId = Bankumsatz::uebernehmen($id, []); + + $vorher = Buchung::find($buchungId); + $this->assertEquals('', (string)$vorher['iban'], 'Buchung zunächst ohne IBAN'); + + Bankumsatz::anlegen($this->daten('Cent drei', 'DE89370400440532013000', 'Testbank')); + + $buchung = Buchung::find($buchungId); + $this->assertEquals('DE89370400440532013000', $buchung['iban'], 'IBAN in der Buchung'); + $this->assertEquals('Testbank', $buchung['bankname'], 'Bank in der Buchung'); + } + + private function verbindenMitErfasstenBuchungUebertraegt(): void + { + $buchungId = Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-09-04', 'betrag' => 0.01, + 'empfaenger' => 'Von Hand', 'buchungstext' => 'Cent vier', + ]); + $id = (int)Bankumsatz::anlegen($this->daten('Cent vier', 'DE02120300000000202051', 'Handbank')); + + Bankumsatz::verbinden($id, $buchungId); + + $buchung = Buchung::find($buchungId); + $this->assertEquals('DE02120300000000202051', $buchung['iban'], 'Verbinden überträgt die IBAN'); + $this->assertEquals('Handbank', $buchung['bankname'], 'Verbinden überträgt die Bank'); + } +} From 42b43090bcaad6e7fb004738b2caf495c48a2e4b Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 29 Sep 2026 13:11:59 +0200 Subject: [PATCH 2/2] Fix n.n.: Umbuchungen finden beide Seiten zueinander Co-Authored-By: Claude Sonnet 5.5 --- Changes.md | 26 ++ src/Models/Bankumsatz.php | 379 ++++++++++++++++++++++++++- src/Models/Buchung.php | 34 ++- src/Services/Bankabgleich.php | 25 +- src/Views/banking/umsaetze.php | 2 +- tests/16_BankingTest.php | 33 +++ tests/50_UmbuchungGegenseiteTest.php | 307 ++++++++++++++++++++++ 7 files changed, 798 insertions(+), 8 deletions(-) create mode 100644 tests/50_UmbuchungGegenseiteTest.php diff --git a/Changes.md b/Changes.md index e483945..3308071 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,31 @@ # Änderungen +## Umbuchungen: beide Seiten finden zueinander (29.09.2026) + +- Eine Umbuchung erscheint auf zwei Kontoauszügen, wurde aber nicht als eine Buchung erkannt: + Der zweite Abruf legte die Gegenseite als eigene Buchung an, der Betrag stand doppelt im + Kontoblatt. Jetzt gilt (`Bankumsatz::gegenbuchung()`): anderes Konto, Betrag mit umgekehrtem + Vorzeichen, Buchungstag höchstens `banking_abgleich_tage` entfernt, keine Kategorie, keine + Splitbuchung, genau ein Umsatz – und die IBANs dürfen sich nicht widersprechen (Gegen-IBAN + muss eine IBAN des anderen Kontos sein). Unter mehreren Kandidaten gewinnt der zeitlich + nächste. +- Konten ohne IBAN (PayPal): Sind die IBANs nicht zu prüfen, wird nur bei einem eindeutigen + Treffer automatisch verbunden; bei mehreren bleibt es ein Vorschlag in der Liste. +- Fall 1: Der zweite Umsatz wird mit der Buchung der Gegenseite verbunden; sie bekommt das + Gegenkonto und wird zur Umbuchung (`Bankumsatz::verbinden()`). +- Fall 2: Haben beide Seiten schon eine eigene Buchung, führt der Abgleich sie zusammen + (`Bankumsatz::umbuchungenZusammenfuehren()`, am Ende von `Bankabgleich::laufenLassen()`). + Die ältere Buchung wird zur Umbuchung, die jüngere fällt weg. Nur Buchungen ohne Kategorie + und Klasse; ein abgeschlossener Monat landet im Bericht. +- Auch beim Zuordnen: Wählt man in der Schnellzuordnung oder im Buchungsformular ein + Gegenkonto, oder setzt eine Regel beim Übernehmen eines Umsatzes eines, sucht das Programm + die Gegenseite (`Bankumsatz::gegenseiteZusammenfuehren()`). Sie darf eine einzeln gebuchte + Buchung (ohne Kategorie und Klasse) oder eine Umbuchung in Gegenrichtung sein; beide werden + zusammengelegt, der Umsatz wandert an die verbleibende Buchung. Bei mehreren Kandidaten + gewinnen bestätigte IBANs, dann der zeitlich nächste. +- `passendeBuchung()` lässt je Kontoseite einen Umsatz zu. +- Neu: Test 50; Test 16 erweitert. + ## Bekannte Umsätze bekommen IBAN und Bank der Gegenseite nachgetragen (29.09.2026) - Der Tooltip im Kontoblatt blieb bei älteren Umsätzen leer: IBAN und Bank der Gegenseite diff --git a/src/Models/Bankumsatz.php b/src/Models/Bankumsatz.php index 6d438c7..653e443 100644 --- a/src/Models/Bankumsatz.php +++ b/src/Models/Bankumsatz.php @@ -227,14 +227,27 @@ class Bankumsatz * art: string, * buchung: array|null, * regel: array|null, - * zuordnung: array|null + * zuordnung: array|null, + * sicher: bool * } + * `sicher` sagt, ob der automatische Lauf den Vorschlag ohne Rückfrage ausführen darf. */ public static function vorschlag(array $umsatz, ?array $regeln = null): array { $buchung = self::passendeBuchung($umsatz); if ($buchung !== null) { - return ['art' => 'verbinden', 'buchung' => $buchung, 'regel' => null, 'zuordnung' => null]; + return [ + 'art' => 'verbinden', 'buchung' => $buchung, 'regel' => null, 'zuordnung' => null, + 'sicher' => true, + ]; + } + + $gegenseite = self::gegenbuchung($umsatz); + if ($gegenseite !== null) { + return [ + 'art' => 'verbinden', 'buchung' => $gegenseite['buchung'], 'regel' => null, 'zuordnung' => null, + 'sicher' => $gegenseite['sicher'], + ]; } $regel = AbgleichRegel::passendeRegel($umsatz, $regeln); @@ -244,10 +257,338 @@ class Bankumsatz 'buchung' => null, 'regel' => $regel, 'zuordnung' => AbgleichRegel::anwenden($regel, $umsatz), + 'sicher' => true, ]; } - return ['art' => 'uebernehmen', 'buchung' => null, 'regel' => null, 'zuordnung' => null]; + return ['art' => 'uebernehmen', 'buchung' => null, 'regel' => null, 'zuordnung' => null, 'sicher' => true]; + } + + /** + * Die IBANs der Bankkonten, die zu einem Konto gehören. Leer bei Konten ohne + * Bankanbindung (Bargeld) und bei PayPal, das keine IBAN liefert. + * + * @return list + */ + private static function kontoIbans(int $kontoId): array + { + $zeilen = Database::fetchAll( + "SELECT DISTINCT UPPER(bk.iban) AS iban + FROM bank_konten bk + JOIN bank_zugaenge z ON z.id = bk.zugang_id + WHERE bk.konto_id = ? AND z.benutzer_id = ? AND bk.iban <> ''", + [$kontoId, Mandant::id()] + ); + + return array_map(static fn (array $z): string => (string)$z['iban'], $zeilen); + } + + /** + * Ob die Gegen-IBANs zweier Umsätze zu den Konten passen, auf denen sie stehen. + * + * Jeder Umsatz nennt die IBAN seiner Gegenseite; sie muss eine IBAN des anderen + * Kontos sein. Das prüft nur, was beide Seiten wissen: Fehlt einem Konto die + * IBAN (PayPal) oder dem Umsatz die Gegen-IBAN, sagt diese Seite nichts. + * + * @param list $ibansA IBANs des Kontos von Umsatz A + * @param list $ibansB IBANs des Kontos von Umsatz B + * @return bool|null false = Widerspruch, true = bestätigt, null = nicht zu entscheiden + */ + private static function ibanUrteil(string $gegenA, array $ibansB, string $gegenB, array $ibansA): ?bool + { + $bestaetigt = false; + foreach ([[$gegenA, $ibansB], [$gegenB, $ibansA]] as [$gegen, $eigene]) { + $gegen = strtoupper(trim((string)$gegen)); + if ($gegen === '' || $eigene === []) { + continue; + } + if (!in_array($gegen, $eigene, true)) { + return false; + } + $bestaetigt = true; + } + + return $bestaetigt ? true : null; + } + + /** + * Sucht zu einem offenen Umsatz die Buchung seiner Gegenseite: die einzeln + * erfasste Buchung, die ein früherer Abruf vom anderen Konto der Umbuchung + * angelegt hat. Sie bekommt beim Verbinden das Gegenkonto und wird so zur + * Umbuchung, statt dass der Betrag im Kontoblatt doppelt steht. + * + * Kriterien: anderes Konto, Betrag mit umgekehrtem Vorzeichen, Buchungstag + * höchstens `banking_abgleich_tage` entfernt, keine Kategorie, keine Splitbuchung, + * hängt an genau einem Bankumsatz ihres Kontos – und die IBANs widersprechen sich + * nicht. Unter mehreren Kandidaten gewinnt der zeitlich nächste. + * + * `sicher` ist wahr, wenn die IBANs sich bestätigen oder es nur einen Kandidaten + * gibt; sonst ist es nur ein Vorschlag für die Liste. + * + * @param array $umsatz + * @return array{buchung: array, sicher: bool}|null + */ + public static function gegenbuchung(array $umsatz): ?array + { + $kontoId = (int)($umsatz['konto_id'] ?? 0); + if ($kontoId === 0) { + return null; + } + + $tage = max(0, Einstellung::getInt('banking_abgleich_tage', 5)); + $betrag = round((float)$umsatz['betrag'], 2); + if (abs($betrag) < 0.005) { + return null; + } + + $kandidaten = Database::fetchAll( + 'SELECT b.*, u2.gegen_iban AS gegenseite_iban + FROM buchungen b + JOIN bank_umsaetze u2 ON u2.buchung_id = b.id AND u2.konto_id = b.konto_id + WHERE b.benutzer_id = ? + AND b.konto_id <> ? + AND b.gegenkonto_id IS NULL AND b.gesplittet = 0 AND b.kategorie_id IS NULL + AND ABS(b.betrag + ?) < 0.005 + AND ABS(DATEDIFF(b.datum, ?)) <= ? + AND u2.id <> ? + AND (SELECT COUNT(*) FROM bank_umsaetze x WHERE x.buchung_id = b.id) = 1 + ORDER BY ABS(DATEDIFF(b.datum, ?)), b.id', + [ + Mandant::id(), + $kontoId, + $betrag, + (string)$umsatz['buchungstag'], + $tage, + (int)($umsatz['id'] ?? 0), + (string)$umsatz['buchungstag'], + ] + ); + + $eigene = self::kontoIbans($kontoId); + $bestaetigt = []; + $neutral = []; + foreach ($kandidaten as $kandidat) { + $urteil = self::ibanUrteil( + (string)($umsatz['gegen_iban'] ?? ''), + self::kontoIbans((int)$kandidat['konto_id']), + (string)$kandidat['gegenseite_iban'], + $eigene + ); + if ($urteil === true) { + $bestaetigt[] = $kandidat; + } elseif ($urteil === null) { + $neutral[] = $kandidat; + } + } + + if ($bestaetigt !== []) { + return ['buchung' => $bestaetigt[0], 'sicher' => true]; + } + if ($neutral !== []) { + return ['buchung' => $neutral[0], 'sicher' => count($neutral) === 1]; + } + + return null; + } + + /** + * Sucht zu einer Umbuchung die Gegenseite, die schon als eigene Buchung im + * Kontoblatt des Gegenkontos steht, und legt beide zusammen. + * + * Die Gegenseite ist entweder eine einzeln erfasste Buchung (ohne Kategorie und + * Klasse) oder eine Umbuchung in umgekehrter Richtung, an der auf diesem Konto + * noch kein Umsatz hängt. Betrag mit umgekehrtem Vorzeichen, Buchungstag + * höchstens `banking_abgleich_tage` entfernt; widersprechen sich die IBANs der + * Umsätze, scheidet der Kandidat aus. Bestätigte IBANs gehen vor, dann gewinnt + * der zeitlich nächste. Der Nutzer hat das Gegenkonto selbst gewählt, deshalb + * genügt hier auch ein mehrdeutiger Treffer. + * + * Die Umbuchung bleibt bestehen; ist die Gegenseite schon eine, bleibt diese + * und die eben zugeordnete fällt weg. Die Umsätze hängen danach an der + * verbliebenen Buchung. + * + * @return bool Ob eine Gegenseite gefunden und zusammengelegt wurde. + */ + public static function gegenseiteZusammenfuehren(int $buchungId): bool + { + $buchung = Buchung::find($buchungId); + if ($buchung === null || $buchung['gegenkonto_id'] === null || (int)$buchung['gesplittet'] !== 0) { + return false; + } + + $konto = (int)$buchung['konto_id']; + $gegen = (int)$buchung['gegenkonto_id']; + $betrag = round((float)$buchung['betrag'], 2); + $tage = max(0, Einstellung::getInt('banking_abgleich_tage', 5)); + if (abs($betrag) < 0.005) { + return false; + } + + $eigenerUmsatzIban = (string)Database::fetchValue( + 'SELECT gegen_iban FROM bank_umsaetze WHERE buchung_id = ? AND konto_id = ? LIMIT 1', + [$buchungId, $konto], + '' + ); + + $kandidaten = Database::fetchAll( + 'SELECT b.*, + (SELECT x.gegen_iban FROM bank_umsaetze x + WHERE x.buchung_id = b.id AND x.konto_id = b.konto_id LIMIT 1) AS umsatz_iban + FROM buchungen b + WHERE b.benutzer_id = ? AND b.konto_id = ? AND b.id <> ? + AND b.gesplittet = 0 + AND ( + (b.gegenkonto_id IS NULL AND b.kategorie_id IS NULL AND b.klasse_id IS NULL) + OR b.gegenkonto_id = ? + ) + AND ABS(b.betrag + ?) < 0.005 + AND ABS(DATEDIFF(b.datum, ?)) <= ? + AND (SELECT COUNT(*) FROM bank_umsaetze x WHERE x.buchung_id = b.id AND x.konto_id = ?) = 0 + AND (SELECT COUNT(*) FROM bank_umsaetze x WHERE x.buchung_id = b.id) <= 1 + ORDER BY ABS(DATEDIFF(b.datum, ?)), b.id', + [ + Mandant::id(), $gegen, $buchungId, $konto, $betrag, + (string)$buchung['datum'], $tage, $konto, (string)$buchung['datum'], + ] + ); + + $eigene = self::kontoIbans($konto); + $gewaehlt = null; + foreach ($kandidaten as $kandidat) { + $urteil = self::ibanUrteil( + $eigenerUmsatzIban, + self::kontoIbans($gegen), + (string)($kandidat['umsatz_iban'] ?? ''), + $eigene + ); + if ($urteil === false) { + continue; + } + if ($urteil === true) { + $gewaehlt = $kandidat; + break; + } + $gewaehlt ??= $kandidat; + } + if ($gewaehlt === null) { + return false; + } + + // Bleibt die Gegenseite, wenn sie schon eine Umbuchung ist. + [$bleibt, $faellt] = $gewaehlt['gegenkonto_id'] !== null + ? [(int)$gewaehlt['id'], $buchungId] + : [$buchungId, (int)$gewaehlt['id']]; + + Database::transaktion(static function () use ($bleibt, $faellt): void { + Database::execute( + 'UPDATE bank_umsaetze SET buchung_id = ? WHERE buchung_id = ? AND benutzer_id = ?', + [$bleibt, $faellt, Mandant::id()] + ); + Database::execute( + 'UPDATE buchungen SET abgeglichen = 1 + WHERE id = ? AND benutzer_id = ? AND EXISTS (SELECT 1 FROM bank_umsaetze WHERE buchung_id = ?)', + [$bleibt, Mandant::id(), $bleibt] + ); + Buchung::loeschen($faellt); + }); + + return true; + } + + /** + * Führt Umbuchungen zusammen, die beide Seiten als eigene Buchung haben: Beide + * Umsätze sind schon abgerufen und einzeln gebucht worden, bevor der Abgleich + * sie als zusammengehörig erkannte. + * + * Kriterien wie bei gegenbuchung(); beide Buchungen ohne Kategorie und Klasse, + * ungesplittet, mit genau einem Umsatz. Bestätigen die IBANs ein Paar, gewinnt + * der zeitlich nächste Partner; ohne Bestätigung nur ein Paar, in dem beide + * einander als einziger Kandidat haben. Die ältere Buchung wird zur Umbuchung, + * die jüngere fällt weg und ihr Umsatz hängt sich an die ältere. + * + * @return array{zusammengefuehrt: int, fehler: list} + */ + public static function umbuchungenZusammenfuehren(): array + { + $tage = max(0, Einstellung::getInt('banking_abgleich_tage', 5)); + + $paare = Database::fetchAll( + 'SELECT a.id AS a_id, b.id AS b_id, a.konto_id AS a_konto, b.konto_id AS b_konto, + a.datum AS a_datum, a.betrag AS a_betrag, + ua.gegen_iban AS a_iban, ub.gegen_iban AS b_iban, + ABS(DATEDIFF(a.datum, b.datum)) AS abstand + FROM buchungen a + JOIN buchungen b ON b.benutzer_id = a.benutzer_id AND b.konto_id <> a.konto_id AND a.id < b.id + JOIN bank_umsaetze ua ON ua.buchung_id = a.id AND ua.konto_id = a.konto_id + JOIN bank_umsaetze ub ON ub.buchung_id = b.id AND ub.konto_id = b.konto_id + WHERE a.benutzer_id = ? + AND a.gegenkonto_id IS NULL AND b.gegenkonto_id IS NULL + AND a.gesplittet = 0 AND b.gesplittet = 0 + AND a.kategorie_id IS NULL AND b.kategorie_id IS NULL + AND a.klasse_id IS NULL AND b.klasse_id IS NULL + AND ABS(a.betrag) >= 0.005 AND ABS(a.betrag + b.betrag) < 0.005 + AND ABS(DATEDIFF(a.datum, b.datum)) <= ? + AND (SELECT COUNT(*) FROM bank_umsaetze x WHERE x.buchung_id = a.id) = 1 + AND (SELECT COUNT(*) FROM bank_umsaetze x WHERE x.buchung_id = b.id) = 1 + ORDER BY abstand, a.id, b.id', + [Mandant::id(), $tage] + ); + + $kanten = []; + $grad = []; + foreach ($paare as $paar) { + $urteil = self::ibanUrteil( + (string)$paar['a_iban'], + self::kontoIbans((int)$paar['b_konto']), + (string)$paar['b_iban'], + self::kontoIbans((int)$paar['a_konto']) + ); + if ($urteil === false) { + continue; + } + $paar['bestaetigt'] = $urteil === true; + $kanten[] = $paar; + $grad[(int)$paar['a_id']] = ($grad[(int)$paar['a_id']] ?? 0) + 1; + $grad[(int)$paar['b_id']] = ($grad[(int)$paar['b_id']] ?? 0) + 1; + } + + // Bestätigte Paare zuerst; innerhalb davon bleibt die Sortierung nach Abstand. + usort($kanten, static fn (array $x, array $y): int => (int)$y['bestaetigt'] <=> (int)$x['bestaetigt']); + + $benutzt = []; + $anzahl = 0; + $fehler = []; + foreach ($kanten as $kante) { + $a = (int)$kante['a_id']; + $b = (int)$kante['b_id']; + if (isset($benutzt[$a]) || isset($benutzt[$b])) { + continue; + } + if (!$kante['bestaetigt'] && ($grad[$a] !== 1 || $grad[$b] !== 1)) { + continue; + } + + try { + Buchung::zuordnen($a, null, (int)$kante['b_konto'], false); + try { + Buchung::verknuepfen((int)$kante['b_konto'], $a, $b); + } catch (\Throwable $e) { + Buchung::zuordnen($a, null, null, false); + throw $e; + } + $benutzt[$a] = $benutzt[$b] = true; + $anzahl++; + } catch (\Throwable $e) { + $fehler[] = sprintf( + '%s, %s: %s', + date('d.m.Y', strtotime((string)$kante['a_datum'])), + number_format((float)$kante['a_betrag'], 2, ',', '.') . ' €', + $e->getMessage() + ); + } + } + + return ['zusammengefuehrt' => $anzahl, 'fehler' => $fehler]; } /** @@ -261,6 +602,11 @@ class Bankumsatz * Buchung formal einem anderen Konto gehört. Auf der Gegenkonto-Seite zählt * der Betrag mit umgekehrtem Vorzeichen, wie im Kontoblatt auch. * + * „Noch frei“ heißt: Auf dieser Kontoseite hängt noch kein anderer Umsatz an + * der Buchung. Eine Umbuchung erscheint auf zwei Kontoauszügen und nimmt + * deshalb je Kontoseite einen Umsatz auf – sonst legte der zweite Abruf die + * Gegenseite als eigene Buchung an, und der Betrag stünde doppelt im Kontoblatt. + * * 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. @@ -288,7 +634,7 @@ class Bankumsatz AND ABS(DATEDIFF(b.datum, ?)) <= ? AND NOT EXISTS ( SELECT 1 FROM bank_umsaetze u - WHERE u.buchung_id = b.id AND u.id <> ? + WHERE u.buchung_id = b.id AND u.id <> ? AND u.konto_id = ? ) ORDER BY ABS(DATEDIFF(b.datum, ?)) LIMIT 1', [ @@ -300,6 +646,7 @@ class Bankumsatz (string)$umsatz['buchungstag'], $tage, (int)($umsatz['id'] ?? 0), + $kontoId, (string)$umsatz['buchungstag'], ] ); @@ -363,12 +710,36 @@ class Bankumsatz self::verbinden($id, $buchungId); + // Eine Regel kann das Gegenkonto setzen: Dann steht die andere Seite der + // Umbuchung vielleicht schon einzeln da. + if (($zuordnung['gegenkonto_id'] ?? null) !== null && $splits === []) { + try { + self::gegenseiteZusammenfuehren($buchungId); + } catch (\Throwable) { + // Bleibt getrennt; der Umsatz ist trotzdem gebucht. + } + } + return $buchungId; } /** Verbindet den Umsatz mit einer bereits erfassten Buchung. */ public static function verbinden(int $id, int $buchungId): void { + // Die Gegenseite einer einzeln erfassten Buchung auf einem anderen Konto: + // Die Buchung wird zur Umbuchung, sonst stünde der Betrag auf beiden Konten. + $vorab = self::find($id); + $buchung = Buchung::find($buchungId); + if ( + $vorab !== null && $buchung !== null + && (int)$vorab['konto_id'] !== 0 + && (int)$buchung['konto_id'] !== (int)$vorab['konto_id'] + && $buchung['gegenkonto_id'] === null + && (int)$buchung['gesplittet'] === 0 + ) { + Buchung::zuordnen($buchungId, null, (int)$vorab['konto_id'], false); + } + Database::execute( "UPDATE bank_umsaetze SET status = 'uebernommen', buchung_id = ? WHERE id = ? AND benutzer_id = ?", diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index 7dfe2b8..a312571 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -159,17 +159,45 @@ class Buchung self::speichereSplits($id, $splits); self::speichereAdressen($id, $adressen); }); + + // Wurde die Buchung eben erst zur Umbuchung (oder bekam ein neues + // Gegenkonto), steht die Gegenseite vielleicht schon einzeln im Kontoblatt. + if ( + $felder['gegenkonto_id'] !== null + && $felder['gesplittet'] === 0 + && ((int)($bisher['gegenkonto_id'] ?? 0) !== $felder['gegenkonto_id'] + || (int)$bisher['konto_id'] !== $felder['konto_id']) + ) { + self::gegenseiteSuchen($id); + } + } + + /** + * Sucht zu einer Umbuchung die Gegenseite, die schon als eigene Buchung + * dasteht, und legt beide zusammen. Ein Fehler (etwa ein abgeschlossener + * Monat) hindert die Zuordnung nicht – die Buchungen bleiben dann getrennt. + */ + private static function gegenseiteSuchen(int $id): void + { + try { + Bankumsatz::gegenseiteZusammenfuehren($id); + } catch (\Throwable) { + // Bleibt getrennt; der Nutzer kann im Kontoblatt verknüpfen. + } } /** * Kategorie oder Gegenkonto einer Buchung setzen – die Schnellzuordnung aus * dem Kontoblatt. Alles andere an der Buchung bleibt unberührt. * + * Wird die Buchung zur Umbuchung, sucht das Programm die Gegenseite, die schon + * einzeln gebucht wurde, und legt beide zusammen (`$gegenseiteSuchen`). + * * Beides zugleich gibt es nicht: Eine Buchung ist entweder kategorisiert * oder eine Umbuchung. War sie gesplittet, fallen die Zeilen weg – die * Zuordnung steckt danach wieder oben. */ - public static function zuordnen(int $id, ?int $kategorieId, ?int $gegenkontoId): void + public static function zuordnen(int $id, ?int $kategorieId, ?int $gegenkontoId, bool $gegenseiteSuchen = true): void { $buchung = self::find($id); if ($buchung === null) { @@ -193,6 +221,10 @@ class Buchung [$kategorieId, $gegenkontoId, $id, Mandant::id()] ); }); + + if ($gegenkontoId !== null && $gegenseiteSuchen) { + self::gegenseiteSuchen($id); + } } /** diff --git a/src/Services/Bankabgleich.php b/src/Services/Bankabgleich.php index 2b9eb36..4a10391 100644 --- a/src/Services/Bankabgleich.php +++ b/src/Services/Bankabgleich.php @@ -55,8 +55,24 @@ class Bankabgleich $vorschlag = Bankumsatz::vorschlag($umsatz, $regeln); if ($vorschlag['art'] === 'verbinden' && $vorschlag['buchung'] !== null) { - Bankumsatz::verbinden((int)$umsatz['id'], (int)$vorschlag['buchung']['id']); - $verbunden++; + // Ein unsicherer Treffer (mehrere Kandidaten ohne IBAN-Bestätigung) + // bleibt für die Vorschlagsliste liegen. + if (!$vorschlag['sicher']) { + $offen++; + continue; + } + try { + Bankumsatz::verbinden((int)$umsatz['id'], (int)$vorschlag['buchung']['id']); + $verbunden++; + } catch (\Throwable $e) { + $offen++; + $fehler[] = sprintf( + '%s, %s: %s', + date('d.m.Y', strtotime((string)$umsatz['buchungstag'])), + number_format((float)$umsatz['betrag'], 2, ',', '.') . ' €', + $e->getMessage() + ); + } continue; } @@ -84,6 +100,11 @@ class Bankabgleich } } + // Umbuchungen, die beide Seiten schon als eigene Buchung haben. + $zusammen = Bankumsatz::umbuchungenZusammenfuehren(); + $verbunden += $zusammen['zusammengefuehrt']; + $fehler = array_merge($fehler, $zusammen['fehler']); + return [ 'verbunden' => $verbunden, 'uebernommen' => $uebernommen, diff --git a/src/Views/banking/umsaetze.php b/src/Views/banking/umsaetze.php index 86b109a..b5e6131 100644 --- a/src/Views/banking/umsaetze.php +++ b/src/Views/banking/umsaetze.php @@ -127,7 +127,7 @@ include __DIR__ . '/../layout/kopf.php';
- Vorschlag: verbinden + Vorschlag: verbinden mit der Buchung vom über diff --git a/tests/16_BankingTest.php b/tests/16_BankingTest.php index 4496e71..8b53dae 100644 --- a/tests/16_BankingTest.php +++ b/tests/16_BankingTest.php @@ -723,6 +723,39 @@ class BankingTest extends TestCase 'Die Umbuchung gilt jetzt als abgeglichen' ); + // Beide Kontoseiten gehören zur selben Umbuchung: Der Umsatz des + // Ursprungskontos findet sie, obwohl drüben schon ein Umsatz hängt. + $ursprung = Bankumsatz::anlegen([ + 'zugang_id' => $this->zugang, + 'konto_id' => $this->giro, + 'extern_id' => 'test-gegenkonto-2', + 'buchungstag' => '2026-08-20', + 'wertstellung' => '2026-08-20', + 'betrag' => -40.00, + 'empfaenger' => 'Sparbuch', + 'buchungsart' => 'UEBERWEISUNG', + ]); + $treffer = Bankumsatz::passendeBuchung(Bankumsatz::find((int)$ursprung)); + $this->assertNotEmpty($treffer, 'Die Ursprungsseite findet die Umbuchung trotz Umsatz auf der Gegenseite'); + $this->assertEquals($umbuchungId, (int)$treffer['id'], 'Und zwar dieselbe Umbuchung'); + Bankumsatz::verbinden((int)$ursprung, $umbuchungId); + + // Pro Kontoseite nur ein Umsatz: ein zweiter von drüben greift nicht mehr. + $doppelt = Bankumsatz::anlegen([ + 'zugang_id' => $this->zugang, + 'konto_id' => $this->sparkonto, + 'extern_id' => 'test-gegenkonto-3', + 'buchungstag' => '2026-08-21', + 'wertstellung' => '2026-08-21', + 'betrag' => 40.00, + 'empfaenger' => 'Giro', + 'buchungsart' => 'GUTSCHRIFT', + ]); + $this->assertNull( + Bankumsatz::passendeBuchung(Bankumsatz::find((int)$doppelt)), + 'Eine Kontoseite nimmt nur einen Umsatz auf' + ); + // Ein anderer Betrag auf der Gegenkontoseite passt nicht. $this->assertNull( Bankumsatz::passendeBuchung([ diff --git a/tests/50_UmbuchungGegenseiteTest.php b/tests/50_UmbuchungGegenseiteTest.php new file mode 100644 index 0000000..10b4bba --- /dev/null +++ b/tests/50_UmbuchungGegenseiteTest.php @@ -0,0 +1,307 @@ +mandantAnlegen(); + + $neu = fn (string $name): int => Konto::anlegen([ + 'name' => $name, 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->kontoA = $neu('Ausgangsgiro'); + $this->kontoB = $neu('Eingangsgiro'); + $this->kontoP = $neu('Zahlungsdienst ohne IBAN'); + + $this->zugang = Bankzugang::anlegen([ + 'name' => 'Umbuchungsbank', 'bankname' => 'Umbuchungsbank AG', 'blz' => '20041111', + 'fints_url' => 'https://banking.example.org/fints', 'benutzerkennung' => 'testkunde', + 'pin' => 'geheim1234', 'abrufzeitraum' => 'letzter_abruf', 'aktiv' => 1, + ]); + $this->bankA = (int)Bankzugang::findeOderLegeKontoAn($this->zugang, self::IBAN_A, '1', 'A')['id']; + $this->bankB = (int)Bankzugang::findeOderLegeKontoAn($this->zugang, self::IBAN_B, '2', 'B')['id']; + Bankzugang::ordneKontoZu($this->bankA, $this->kontoA); + Bankzugang::ordneKontoZu($this->bankB, $this->kontoB); + + $this->zweiterUmsatzWirdMitDerGegenseiteVerbunden(); + $this->zeitlichNaechsterKandidatGewinnt(); + $this->widerspruchDerIbanSchliesstAus(); + $this->ohneIbanNurBeiEindeutigemTreffer(); + $this->beideSeitenSchonEinzelnGebuchtWerdenZusammengefuehrt(); + $this->buchungMitKategorieWirdNichtAngefasst(); + $this->zuordnenAlsUmbuchungSuchtDieGegenseite(); + $this->gegenseiteIstSchonEineUmbuchung(); + $this->zuordnenMitFremderIbanLaesstGetrennt(); + $this->regelMitGegenkontoLegtBeimUebernehmenZusammen(); + } + + private function zaehle(int $konto): int + { + return (int)\Finanzmanager\Database::fetchValue( + 'SELECT COUNT(*) FROM buchungen WHERE konto_id = ?', + [$konto], + 0 + ); + } + + private function zuordnenAlsUmbuchungSuchtDieGegenseite(): void + { + $a = Bankumsatz::uebernehmen( + $this->umsatz($this->kontoA, $this->bankA, '2026-09-14', -0.88, self::IBAN_B, 'Zuordnen'), + [] + ); + $ub = $this->umsatz($this->kontoB, $this->bankB, '2026-09-15', 0.88, self::IBAN_A, 'Zuordnen'); + $b = Bankumsatz::uebernehmen($ub, []); + + // Der Nutzer wählt im Kontoblatt das Gegenkonto. + Buchung::zuordnen($b, null, $this->kontoA); + + $this->assertNull(Buchung::find($a), 'Zuordnen: Die einzelne Gegenseite fällt weg'); + $this->assertEquals($this->kontoA, (int)Buchung::find($b)['gegenkonto_id'], 'Zuordnen: Die Buchung ist die Umbuchung'); + $this->assertEquals( + $b, + (int)\Finanzmanager\Database::fetchValue( + 'SELECT buchung_id FROM bank_umsaetze WHERE extern_id = ?', + [Bankumsatz::externId($this->bankA, '2026-09-14', -0.88, 'Zuordnen', (string)$this->kontoA)], + 0 + ), + 'Zuordnen: Der Umsatz der anderen Seite hängt jetzt an ihr' + ); + } + + private function gegenseiteIstSchonEineUmbuchung(): void + { + // Wie 29274: Die Gegenseite ist schon Umbuchung, die eigene Seite steht einzeln. + $x = Bankumsatz::uebernehmen( + $this->umsatz($this->kontoA, $this->bankA, '2026-09-20', 0.99, '', 'Umgekehrt'), + ['gegenkonto_id' => $this->kontoB] + ); + $yUmsatz = $this->umsatz($this->kontoB, $this->bankB, '2026-09-17', -0.99, self::IBAN_A, 'Umgekehrt'); + $y = Bankumsatz::uebernehmen($yUmsatz, []); + + Buchung::zuordnen($y, null, $this->kontoA); + + $this->assertNull(Buchung::find($y), 'Umgekehrt: Die eben zugeordnete Buchung fällt weg'); + $this->assertEquals($x, (int)Bankumsatz::find($yUmsatz)['buchung_id'], 'Umgekehrt: Ihr Umsatz hängt an der vorhandenen Umbuchung'); + } + + private function zuordnenMitFremderIbanLaesstGetrennt(): void + { + $a = Bankumsatz::uebernehmen( + $this->umsatz($this->kontoA, $this->bankA, '2026-09-24', -1.22, self::IBAN_FREMD, 'Fremd'), + [] + ); + $b = Bankumsatz::uebernehmen( + $this->umsatz($this->kontoB, $this->bankB, '2026-09-24', 1.22, self::IBAN_FREMD, 'Fremd'), + [] + ); + + Buchung::zuordnen($b, null, $this->kontoA); + + $this->assertNotEmpty(Buchung::find($a), 'Fremde IBAN: Die andere Buchung bleibt bestehen'); + } + + private function regelMitGegenkontoLegtBeimUebernehmenZusammen(): void + { + $a = Bankumsatz::uebernehmen( + $this->umsatz($this->kontoA, $this->bankA, '2026-09-26', -1.11, self::IBAN_B, 'Regel'), + [] + ); + $ub = $this->umsatz($this->kontoB, $this->bankB, '2026-09-26', 1.11, self::IBAN_A, 'Regel'); + $b = Bankumsatz::uebernehmen($ub, ['gegenkonto_id' => $this->kontoA]); + + $this->assertNull(Buchung::find($a), 'Regel: Die einzelne Gegenseite fällt beim Übernehmen weg'); + $this->assertEquals(1, $this->zaehle($this->kontoB) > 0 ? 1 : 0, 'Regel: Die Umbuchung steht auf dem Konto des Umsatzes'); + $this->assertEquals($b, (int)Bankumsatz::find($ub)['buchung_id'], 'Regel: Der Umsatz hängt an der Umbuchung'); + } + + private function umsatz(int $konto, ?int $bankKonto, string $tag, float $betrag, string $gegenIban, string $zweck): int + { + return (int)Bankumsatz::anlegen([ + 'zugang_id' => $this->zugang, + 'konto_id' => $konto, + 'bank_konto_id' => $bankKonto, + 'extern_id' => Bankumsatz::externId($bankKonto ?? 0, $tag, $betrag, $zweck, (string)$konto), + 'buchungstag' => $tag, + 'wertstellung' => $tag, + 'betrag' => $betrag, + 'empfaenger' => 'Gegenseite', + 'verwendungszweck' => $zweck, + 'gegen_iban' => $gegenIban, + ]); + } + + private function zweiterUmsatzWirdMitDerGegenseiteVerbunden(): void + { + $aus = $this->umsatz($this->kontoA, $this->bankA, '2026-09-04', -0.11, self::IBAN_B, 'Fall eins'); + $buchungId = Bankumsatz::uebernehmen($aus, []); + + $ein = $this->umsatz($this->kontoB, $this->bankB, '2026-09-05', 0.11, self::IBAN_A, 'Fall eins'); + $vorschlag = Bankumsatz::vorschlag(Bankumsatz::find($ein)); + $this->assertEquals('verbinden', $vorschlag['art'], 'Fall 1: Vorschlag lautet verbinden'); + $this->assertEquals($buchungId, (int)$vorschlag['buchung']['id'], 'Fall 1: Die Buchung der Gegenseite'); + $this->assertEquals(true, $vorschlag['sicher'], 'Fall 1: IBANs bestätigen, also sicher'); + + Bankabgleich::laufenLassen(true); + + $umsatz = Bankumsatz::find($ein); + $this->assertEquals('uebernommen', $umsatz['status'], 'Fall 1: Der Umsatz ist erledigt'); + $this->assertEquals($buchungId, (int)$umsatz['buchung_id'], 'Fall 1: Er hängt an derselben Buchung'); + $this->assertEquals( + $this->kontoB, + (int)Buchung::find($buchungId)['gegenkonto_id'], + 'Fall 1: Die Buchung ist jetzt eine Umbuchung' + ); + $this->assertEquals( + 0, + (int)\Finanzmanager\Database::fetchValue( + 'SELECT COUNT(*) FROM buchungen WHERE konto_id = ?', + [$this->kontoB], + 0 + ), + 'Fall 1: Auf dem Gegenkonto entsteht keine eigene Buchung' + ); + } + + private function zeitlichNaechsterKandidatGewinnt(): void + { + $fern = Bankumsatz::uebernehmen( + $this->umsatz($this->kontoA, $this->bankA, '2026-09-01', -0.22, self::IBAN_B, 'Fern'), + [] + ); + $nah = Bankumsatz::uebernehmen( + $this->umsatz($this->kontoA, $this->bankA, '2026-09-03', -0.22, self::IBAN_B, 'Nah'), + [] + ); + + $ein = $this->umsatz($this->kontoB, $this->bankB, '2026-09-04', 0.22, self::IBAN_A, 'Nächster'); + $treffer = Bankumsatz::gegenbuchung(Bankumsatz::find($ein)); + $this->assertNotEmpty($treffer, 'Nächster: Ein Kandidat wird gefunden'); + $this->assertEquals($nah, (int)$treffer['buchung']['id'], 'Nächster: Der zeitlich nähere gewinnt'); + $this->assertEquals(true, $treffer['sicher'], 'Nächster: Bestätigte IBANs sind sicher'); + $this->assertGreaterThan(0, $fern, 'Nächster: Der fernere bleibt bestehen'); + } + + private function widerspruchDerIbanSchliesstAus(): void + { + Bankumsatz::uebernehmen( + $this->umsatz($this->kontoA, $this->bankA, '2026-09-04', -0.33, self::IBAN_FREMD, 'Fremdzahlung'), + [] + ); + $ein = $this->umsatz($this->kontoB, $this->bankB, '2026-09-04', 0.33, self::IBAN_FREMD, 'Fremdgutschrift'); + + $this->assertNull( + Bankumsatz::gegenbuchung(Bankumsatz::find($ein)), + 'Widerspruch: Eine fremde Gegen-IBAN ist keine Umbuchung zwischen den eigenen Konten' + ); + } + + private function ohneIbanNurBeiEindeutigemTreffer(): void + { + // Eindeutig: PayPal-artiges Konto ohne IBAN, genau ein Kandidat. + $einzig = Bankumsatz::uebernehmen( + $this->umsatz($this->kontoA, $this->bankA, '2026-09-04', -0.44, 'LU89751000135104200E', 'Aufladung'), + [] + ); + $pp = $this->umsatz($this->kontoP, null, '2026-09-04', 0.44, '', 'Aufladung'); + $treffer = Bankumsatz::gegenbuchung(Bankumsatz::find($pp)); + $this->assertEquals($einzig, (int)$treffer['buchung']['id'], 'Ohne IBAN: Der einzige Kandidat wird gefunden'); + $this->assertEquals(true, $treffer['sicher'], 'Ohne IBAN: Ein eindeutiger Treffer ist sicher'); + + // Mehrdeutig: zwei Kandidaten – nur Vorschlag, der Lauf verbindet nicht. + Bankumsatz::uebernehmen( + $this->umsatz($this->kontoA, $this->bankA, '2026-09-02', -0.55, 'LU89751000135104200E', 'Eins'), + [] + ); + Bankumsatz::uebernehmen( + $this->umsatz($this->kontoA, $this->bankA, '2026-09-03', -0.55, 'LU89751000135104200E', 'Zwei'), + [] + ); + $mehr = $this->umsatz($this->kontoP, null, '2026-09-04', 0.55, '', 'Mehrdeutig'); + $treffer = Bankumsatz::gegenbuchung(Bankumsatz::find($mehr)); + $this->assertNotEmpty($treffer, 'Mehrdeutig: Es gibt einen Vorschlag'); + $this->assertEquals(false, $treffer['sicher'], 'Mehrdeutig: Aber keinen sicheren'); + + Bankabgleich::laufenLassen(true); + $this->assertEquals( + 'offen', + Bankumsatz::find($mehr)['status'], + 'Mehrdeutig: Der automatische Lauf lässt ihn für die Vorschlagsliste liegen' + ); + } + + private function beideSeitenSchonEinzelnGebuchtWerdenZusammengefuehrt(): void + { + $a = Bankumsatz::uebernehmen( + $this->umsatz($this->kontoA, $this->bankA, '2026-09-10', -0.66, self::IBAN_B, 'Doppelt'), + [] + ); + $ub = $this->umsatz($this->kontoB, $this->bankB, '2026-09-10', 0.66, self::IBAN_A, 'Doppelt'); + $b = Bankumsatz::uebernehmen($ub, []); + $this->assertEquals(true, $a !== $b, 'Fall 2: Ausgangslage sind zwei getrennte Buchungen'); + + $ergebnis = Bankumsatz::umbuchungenZusammenfuehren(); + $this->assertGreaterThan(0, $ergebnis['zusammengefuehrt'], 'Fall 2: Das Paar wird zusammengeführt'); + + $this->assertEquals( + $this->kontoB, + (int)Buchung::find($a)['gegenkonto_id'], + 'Fall 2: Die ältere Buchung ist jetzt die Umbuchung' + ); + $this->assertNull(Buchung::find($b), 'Fall 2: Die jüngere Buchung fällt weg'); + $this->assertEquals( + $a, + (int)Bankumsatz::find($ub)['buchung_id'], + 'Fall 2: Ihr Umsatz hängt an der Umbuchung' + ); + $this->assertEquals( + 'uebernommen', + Bankumsatz::find($ub)['status'], + 'Fall 2: Der Umsatz bleibt erledigt' + ); + } + + private function buchungMitKategorieWirdNichtAngefasst(): void + { + $a = Bankumsatz::uebernehmen( + $this->umsatz($this->kontoA, $this->bankA, '2026-09-12', -0.77, self::IBAN_B, 'Mit Kategorie'), + [] + ); + $kategorie = Kategorie::anlegen(['name' => 'Testkategorie 50']); + \Finanzmanager\Database::execute('UPDATE buchungen SET kategorie_id = ? WHERE id = ?', [$kategorie, $a]); + + $ein = $this->umsatz($this->kontoB, $this->bankB, '2026-09-12', 0.77, self::IBAN_A, 'Mit Kategorie'); + $this->assertNull( + Bankumsatz::gegenbuchung(Bankumsatz::find($ein)), + 'Eine kategorisierte Buchung ist keine Umbuchung' + ); + } +}