From 2223ab4e06f84c5c93cc3dbde132e06f2453f500 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 27 Sep 2026 15:46:51 +0200 Subject: [PATCH] Fix n.n.: Kontoblatt zeigt IBAN und Bank der Gegenseite im Verwendungszweck-Tooltip Co-Authored-By: Claude Sonnet 5 --- Changes.md | 19 ++++ migrations/020_buchung_herkunft.sql | 20 ++++ public/static/kontoblatt.js | 16 +++- src/Models/Bankinstitut.php | 42 +++++++++ src/Models/Bankumsatz.php | 8 +- src/Models/Buchung.php | 20 ++-- src/Services/FinTsAbruf.php | 12 +++ src/Views/buchungen/kontoblatt.php | 23 ++++- tests/45_BuchungHerkunftTest.php | 136 ++++++++++++++++++++++++++++ 9 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 migrations/020_buchung_herkunft.sql create mode 100644 tests/45_BuchungHerkunftTest.php diff --git a/Changes.md b/Changes.md index c3b5e6b..2216d61 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,24 @@ # Änderungen +## IBAN und Bank der Gegenseite im Kontoblatt (27.09.2026) + +Bei einer Buchung aus dem Bankabruf ließ sich oft nicht mehr erkennen, woher sie +stammt – der Empfängername allein reicht nicht immer (z. B. bei Überweisungen +zwischen eigenen Konten verschiedener Banken). + +- `Services\FinTsAbruf` löst IBAN/Kontonummer und Bankleitzahl bzw. BIC der Gegenseite + schon beim Abruf gegen das Bankleitzahlen-Verzeichnis auf (neu: + `Bankinstitut::findeNachBic()` / `findeNachBicOderBlz()`) und schreibt beides nach + `bank_umsaetze.gegen_iban` / `gegen_bankname`. +- `Bankumsatz::uebernehmen()` trägt beides auf die neue Buchung ein (`buchungen.iban`, + `buchungen.bankname`), Migration `020_buchung_herkunft.sql`. +- Im Kontoblatt zeigt der Verwendungszweck beim Mouseover IBAN und Bank, sofern + bekannt (`kontoblatt.js`, `data-herkunft`). +- Gilt nur für künftige Bankabrufe – bereits vorhandene Buchungen aus früheren + Abrufen (auch die den Anstoß gebende Buchung 31178) kennen die Gegenseite nicht + nachträglich, weil sie beim damaligen Abruf nicht mitgeschrieben wurde. +- Test in `tests/45_BuchungHerkunftTest.php`. + ## Umbuchung wird auch von der Gegenkontoseite erkannt (27.09.2026) Eine Umbuchung gehört ihrem Ursprungskonto; der automatische Abgleich suchte eine diff --git a/migrations/020_buchung_herkunft.sql b/migrations/020_buchung_herkunft.sql new file mode 100644 index 0000000..2b3c4f5 --- /dev/null +++ b/migrations/020_buchung_herkunft.sql @@ -0,0 +1,20 @@ +-- IBAN und Bank der Gegenseite, soweit der Bankabruf sie liefert. +-- +-- MT940 und CAMT nennen zur Gegenseite einer Buchung deren Kontonummer (bei +-- CAMT die IBAN) und deren Bankleitzahl bzw. BIC – bisher landete das nirgends, +-- Nutzer sahen im Kontoblatt nur den Namen. Bei Buchung 31178 („Felix David +-- Schepmann“) reichte der Name nicht, um die Herkunft zu erkennen. +-- +-- Aufgelöst wird der Bankname schon beim Abruf (Services\FinTsAbruf) über das +-- Bankleitzahlenverzeichnis (Models\Bankinstitut) – nicht erst bei jeder +-- Anzeige im Kontoblatt. + +ALTER TABLE bank_umsaetze ADD COLUMN IF NOT EXISTS + gegen_iban VARCHAR(34) NOT NULL DEFAULT '' AFTER verwendungszweck; +ALTER TABLE bank_umsaetze ADD COLUMN IF NOT EXISTS + gegen_bankname VARCHAR(128) NOT NULL DEFAULT '' AFTER gegen_iban; + +ALTER TABLE buchungen ADD COLUMN IF NOT EXISTS + iban VARCHAR(34) NOT NULL DEFAULT '' AFTER empfaenger; +ALTER TABLE buchungen ADD COLUMN IF NOT EXISTS + bankname VARCHAR(128) NOT NULL DEFAULT '' AFTER iban; diff --git a/public/static/kontoblatt.js b/public/static/kontoblatt.js index 3cea4a0..78c00e9 100644 --- a/public/static/kontoblatt.js +++ b/public/static/kontoblatt.js @@ -28,15 +28,29 @@ * * Kategorie und Klasse tragen ihren Tooltip vom Server; er nennt bei * Splitbuchungen mehr, als in der Zelle steht. + * + * Der Buchungstext trägt zusätzlich `data-herkunft`: IBAN und Bank der + * Gegenseite, sofern der Bankabruf sie kennt. Die stehen unabhängig davon + * im Tooltip, ob der Text selbst abgeschnitten ist – sie stecken sonst + * nirgends in der Zelle. */ function tooltipsSetzen() { tabelle.querySelectorAll('.zellentext').forEach(function (kasten) { const abgeschnitten = kasten.scrollHeight > kasten.clientHeight + 1 || kasten.scrollWidth > kasten.clientWidth + 1; const text = kasten.textContent.trim(); + const herkunft = kasten.dataset.herkunft || ''; + const zeilen = []; if (abgeschnitten && text !== '') { - kasten.title = text; + zeilen.push(text); + } + if (herkunft !== '') { + zeilen.push(herkunft); + } + + if (zeilen.length > 0) { + kasten.title = zeilen.join('\n'); } else { kasten.removeAttribute('title'); } diff --git a/src/Models/Bankinstitut.php b/src/Models/Bankinstitut.php index 7292587..30bd154 100644 --- a/src/Models/Bankinstitut.php +++ b/src/Models/Bankinstitut.php @@ -71,6 +71,48 @@ class Bankinstitut return $zeile === false ? null : $zeile; } + /** + * Der Eintrag zu einer BIC – für die Gegenseite eines FinTS-Umsatzes: + * CAMT nennt dort meist die BIC statt der Bankleitzahl. + * + * @return array|null + */ + public static function findeNachBic(string $bic): ?array + { + $bic = strtoupper(trim($bic)); + if ($bic === '') { + return null; + } + + $zeile = Database::fetchOne( + "SELECT * FROM bankinstitute + WHERE bic = ? + ORDER BY (pintan_url <> '') DESC, nr + LIMIT 1", + [$bic] + ); + + return $zeile === false ? null : $zeile; + } + + /** + * Der Eintrag zur Gegenseite eines Umsatzes, deren Kennung mal eine + * Bankleitzahl ist (MT940), mal eine BIC (CAMT) – nur Ziffern + * unterscheidet zuverlässig zwischen beiden, eine BIC beginnt immer mit + * vier Buchstaben. + * + * @return array|null + */ + public static function findeNachBicOderBlz(string $code): ?array + { + $code = trim($code); + if ($code === '') { + return null; + } + + return ctype_digit($code) ? self::findeNachBlz($code) : self::findeNachBic($code); + } + /** * Suche über Bankleitzahl, BIC, Name und Ort. * diff --git a/src/Models/Bankumsatz.php b/src/Models/Bankumsatz.php index 6a4164b..ef2dd69 100644 --- a/src/Models/Bankumsatz.php +++ b/src/Models/Bankumsatz.php @@ -91,8 +91,8 @@ class Bankumsatz Database::execute( 'INSERT INTO bank_umsaetze (benutzer_id, zugang_id, konto_id, bank_konto_id, extern_id, buchungstag, wertstellung, - betrag, waehrung, empfaenger, verwendungszweck, buchungsart, roh) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + betrag, waehrung, empfaenger, verwendungszweck, gegen_iban, gegen_bankname, buchungsart, roh) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ Mandant::id(), (int)$daten['zugang_id'], @@ -107,6 +107,8 @@ class Bankumsatz mb_substr((string)($daten['waehrung'] ?? 'EUR'), 0, 3), mb_substr(trim((string)($daten['empfaenger'] ?? '')), 0, 255), (string)($daten['verwendungszweck'] ?? ''), + mb_substr(strtoupper(trim((string)($daten['gegen_iban'] ?? ''))), 0, 34), + mb_substr(trim((string)($daten['gegen_bankname'] ?? '')), 0, 128), mb_substr(trim((string)($daten['buchungsart'] ?? '')), 0, 100), ($daten['roh'] ?? null) ?: null, ] @@ -286,6 +288,8 @@ class Bankumsatz 'betrag' => (float)$umsatz['betrag'], 'empfaenger' => (string)$umsatz['empfaenger'], 'buchungstext' => (string)$umsatz['verwendungszweck'], + 'iban' => (string)($umsatz['gegen_iban'] ?? ''), + 'bankname' => (string)($umsatz['gegen_bankname'] ?? ''), 'kategorie_id' => $zuordnung['kategorie_id'] ?? null, 'klasse_id' => $zuordnung['klasse_id'] ?? null, 'gegenkonto_id' => $zuordnung['gegenkonto_id'] ?? null, diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index acb4437..5c14dfa 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -101,12 +101,13 @@ class Buchung return (int)Database::transaktion(static function () use ($felder, $splits, $adressen): int { Database::execute( 'INSERT INTO buchungen - (benutzer_id, konto_id, datum, valuta, betrag, empfaenger, buchungstext, nummer, - abgeglichen, kategorie_id, klasse_id, gegenkonto_id, gesplittet, quelle, import_hash) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + (benutzer_id, konto_id, datum, valuta, betrag, empfaenger, iban, bankname, buchungstext, + nummer, abgeglichen, kategorie_id, klasse_id, gegenkonto_id, gesplittet, quelle, import_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ Mandant::id(), $felder['konto_id'], $felder['datum'], $felder['valuta'], - $felder['betrag'], $felder['empfaenger'], $felder['buchungstext'], $felder['nummer'], + $felder['betrag'], $felder['empfaenger'], $felder['iban'], $felder['bankname'], + $felder['buchungstext'], $felder['nummer'], $felder['abgeglichen'], $felder['kategorie_id'], $felder['klasse_id'], $felder['gegenkonto_id'], $felder['gesplittet'], $felder['quelle'], $felder['import_hash'], ] @@ -341,21 +342,24 @@ class Buchung SELECT b.id, b.datum, b.valuta, b.betrag, b.empfaenger, b.buchungstext, b.nummer, b.abgeglichen, b.kategorie_id, b.klasse_id, b.gesplittet, b.quelle, b.bargeld_status, b.bargeld_hinweis, - b.gegenkonto_id, b.konto_id, 0 AS gegensicht, NULL AS split_id + b.gegenkonto_id, b.konto_id, 0 AS gegensicht, NULL AS split_id, + b.iban, b.bankname FROM buchungen b WHERE b.konto_id = ? UNION ALL SELECT b.id, b.datum, b.valuta, -b.betrag, b.empfaenger, b.buchungstext, b.nummer, b.abgeglichen, b.kategorie_id, b.klasse_id, b.gesplittet, b.quelle, NULL AS bargeld_status, NULL AS bargeld_hinweis, - b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, NULL AS split_id + b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, NULL AS split_id, + \'\' AS iban, \'\' AS bankname FROM buchungen b WHERE b.gegenkonto_id = ? AND b.gesplittet = 0 UNION ALL SELECT b.id, b.datum, b.valuta, -s.betrag, b.empfaenger, s.notiz, b.nummer, b.abgeglichen, s.kategorie_id, s.klasse_id, 0 AS gesplittet, b.quelle, NULL AS bargeld_status, NULL AS bargeld_hinweis, - b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, s.id AS split_id + b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, s.id AS split_id, + \'\' AS iban, \'\' AS bankname FROM buchung_splits s JOIN buchungen b ON b.id = s.buchung_id WHERE s.gegenkonto_id = ? @@ -684,6 +688,8 @@ class Buchung 'valuta' => self::istDatum($valuta) ? $valuta : null, 'betrag' => $betrag, 'empfaenger' => mb_substr(trim((string)($daten['empfaenger'] ?? '')), 0, 255), + 'iban' => mb_substr(strtoupper(trim((string)($daten['iban'] ?? ''))), 0, 34), + 'bankname' => mb_substr(trim((string)($daten['bankname'] ?? '')), 0, 128), 'buchungstext' => trim((string)($daten['buchungstext'] ?? '')), 'nummer' => mb_substr(trim((string)($daten['nummer'] ?? '')), 0, 32), 'abgeglichen' => !empty($daten['abgeglichen']) ? 1 : 0, diff --git a/src/Services/FinTsAbruf.php b/src/Services/FinTsAbruf.php index bf42753..5982d42 100644 --- a/src/Services/FinTsAbruf.php +++ b/src/Services/FinTsAbruf.php @@ -17,6 +17,7 @@ use Fhp\Options\FinTsOptions; use Finanzmanager\Auth; use Finanzmanager\Models\AbgleichRegel; use Finanzmanager\Models\Bankabruf; +use Finanzmanager\Models\Bankinstitut; use Finanzmanager\Models\Bankprotokoll; use Finanzmanager\Models\Bankumsatz; use Finanzmanager\Models\Bankzugang; @@ -387,6 +388,15 @@ class FinTsAbruf $zweck = trim($umsatz->getMainDescription()); $empfaenger = trim($umsatz->getName()); + // Die Kontonummer der Gegenseite – bei CAMT ihre IBAN, bei + // MT940 die alte Kontonummer; die Bankleitzahl bzw. BIC daneben + // löst sich sofort gegen das Institutsverzeichnis auf, statt bei + // jeder Anzeige im Kontoblatt neu nachzuschlagen. + $gegenIban = trim($umsatz->getAccountNumber()); + $gegenBankname = (string)( + Bankinstitut::findeNachBicOderBlz($umsatz->getBankCode())['institut'] ?? '' + ); + $externId = Bankumsatz::externId( (int)$bankkonto['id'], $buchungstag->format('Y-m-d'), @@ -405,6 +415,8 @@ class FinTsAbruf 'betrag' => $betrag, 'empfaenger' => $empfaenger, 'verwendungszweck' => $zweck, + 'gegen_iban' => $gegenIban, + 'gegen_bankname' => $gegenBankname, 'buchungsart' => trim($umsatz->getBookingText()), 'roh' => json_encode([ 'iban' => $bankkonto['iban'], diff --git a/src/Views/buchungen/kontoblatt.php b/src/Views/buchungen/kontoblatt.php index b83cc0b..59dd83e 100644 --- a/src/Views/buchungen/kontoblatt.php +++ b/src/Views/buchungen/kontoblatt.php @@ -1,5 +1,6 @@ View::bargeldMarke($zeile['bargeld_status'] ?? null, $zeile['bargeld_hinweis'] ?? null); +/** + * IBAN und Bank der Gegenseite, soweit der Bankabruf sie geliefert hat – der + * Tooltip über dem Verwendungszweck. kontoblatt.js hängt ihn zusätzlich zum + * (nur bei abgeschnittenem Text nötigen) vollständigen Text an; ohne + * JavaScript bleibt er unsichtbar, wie schon der Tooltip für abgeschnittenen + * Text. + * + * @param array $zeile + */ +$herkunft = static function (array $zeile): string { + $teile = []; + if (($zeile['iban'] ?? '') !== '') { + $teile[] = 'IBAN ' . Adresse::ibanFormatiert((string)$zeile['iban']); + } + if (($zeile['bankname'] ?? '') !== '') { + $teile[] = (string)$zeile['bankname']; + } + return implode("\n", $teile); +}; + include __DIR__ . '/../layout/kopf.php'; ?> @@ -273,7 +294,7 @@ include __DIR__ . '/../layout/kopf.php'; -
+
mandantAnlegen(); + + $this->giro = Konto::anlegen([ + 'name' => 'Herkunftsgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->zugang = Bankzugang::anlegen([ + 'name' => 'Herkunftsbank', + 'bankname' => 'Herkunftsbank AG', + 'blz' => '12030000', + 'fints_url' => 'https://banking.example.org/fints', + 'benutzerkennung' => 'testkunde', + 'pin' => 'geheim1234', + 'abrufzeitraum' => 'letzter_abruf', + 'aktiv' => 1, + ]); + + $this->findeNachBicUndBlz(); + $this->uebernehmenTraegtHerkunftEin(); + $this->ohneGegenseiteBleibtLeer(); + } + + private function findeNachBicUndBlz(): void + { + Bankinstitut::ersetze([[ + 'nr' => 1, 'blz' => '12030000', 'bic' => 'BYLADEM1001', + 'institut' => 'Deutsche Kreditbank Testinstitut', 'ort' => 'Berlin', + 'rechenzentrum' => '', 'organisation' => '', 'hbci_adresse' => '', + 'hbci_version' => '', 'pintan_url' => '', 'pintan_version' => '', 'stand' => null, + ]]); + + $nachBic = Bankinstitut::findeNachBic('BYLADEM1001'); + $this->assertNotEmpty($nachBic, 'Die BIC findet das Institut'); + $this->assertEquals('Deutsche Kreditbank Testinstitut', $nachBic['institut'], 'Mit dem richtigen Namen'); + + $this->assertEquals( + 'Deutsche Kreditbank Testinstitut', + Bankinstitut::findeNachBicOderBlz('12030000')['institut'] ?? '', + 'Eine reine Ziffernfolge gilt als Bankleitzahl' + ); + $this->assertEquals( + 'Deutsche Kreditbank Testinstitut', + Bankinstitut::findeNachBicOderBlz('BYLADEM1001')['institut'] ?? '', + 'Buchstaben am Anfang gelten als BIC' + ); + $this->assertNull(Bankinstitut::findeNachBicOderBlz(''), 'Ohne Angabe gibt es keinen Treffer'); + $this->assertNull(Bankinstitut::findeNachBic('XXXXXXXX'), 'Eine unbekannte BIC bleibt ohne Treffer'); + + Bankinstitut::leeren(); + } + + private function uebernehmenTraegtHerkunftEin(): void + { + $umsatzId = Bankumsatz::anlegen([ + 'zugang_id' => $this->zugang, + 'konto_id' => $this->giro, + 'extern_id' => 'test-herkunft-1', + 'buchungstag' => '2026-09-07', + 'wertstellung' => '2026-09-07', + 'betrag' => -5000.00, + 'empfaenger' => 'Felix David Schepmann', + 'verwendungszweck' => 'Privatentnahme', + 'gegen_iban' => 'de36120300001010903027', + 'gegen_bankname' => 'Deutsche Kreditbank Testinstitut', + ]); + $this->assertNotEmpty($umsatzId, 'Der Umsatz wurde angelegt'); + + $umsatz = Bankumsatz::find((int)$umsatzId); + $this->assertEquals( + 'DE36120300001010903027', + $umsatz['gegen_iban'], + 'Die IBAN steht in Großbuchstaben im Umsatz' + ); + + $buchungId = Bankumsatz::uebernehmen((int)$umsatzId, []); + $buchung = Buchung::find($buchungId); + + $this->assertEquals('DE36120300001010903027', $buchung['iban'], 'Die IBAN steht auf der Buchung'); + $this->assertEquals( + 'Deutsche Kreditbank Testinstitut', + $buchung['bankname'], + 'Und der aufgelöste Bankname daneben' + ); + + $zeilen = Buchung::kontoblatt($this->giro); + $gefunden = null; + foreach ($zeilen as $zeile) { + if ((int)$zeile['id'] === $buchungId) { + $gefunden = $zeile; + } + } + $this->assertNotEmpty($gefunden, 'Die Buchung steht im Kontoblatt'); + $this->assertEquals('DE36120300001010903027', $gefunden['iban'], 'Mit IBAN im Kontoblatt'); + $this->assertEquals( + 'Deutsche Kreditbank Testinstitut', + $gefunden['bankname'], + 'Und Bankname im Kontoblatt' + ); + } + + private function ohneGegenseiteBleibtLeer(): void + { + $buchungId = Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-09-08', 'betrag' => -12.00, + 'empfaenger' => 'Kiosk', 'quelle' => 'manuell', + ]); + + $buchung = Buchung::find($buchungId); + $this->assertEquals('', $buchung['iban'], 'Ohne Bankabruf bleibt die IBAN leer'); + $this->assertEquals('', $buchung['bankname'], 'Und der Bankname auch'); + } +}