Fix n.n.: Kontoblatt zeigt IBAN und Bank der Gegenseite im Verwendungszweck-Tooltip

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-09-27 15:46:51 +02:00
parent e02bac22c0
commit 2223ab4e06
9 changed files with 285 additions and 11 deletions

View file

@ -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

View file

@ -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;

View file

@ -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');
}

View file

@ -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<string,mixed>|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<string,mixed>|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.
*

View file

@ -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,

View file

@ -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,

View file

@ -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'],

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Models\Adresse;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\View;
@ -116,6 +117,26 @@ $splituebersicht = static function (array $splitzeilen) use ($kategorieNamen, $k
$bargeldmarke = static fn(array $zeile): string
=> 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<string,mixed> $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';
<?php /* Ungekürzt: Was nicht auf zwei Zeilen passt, schneidet das
Stylesheet ab – der vollständige Text bleibt für den
Tooltip erhalten. */ ?>
<td class="nebentext buchungstext"><div class="zellentext"><?= View::h($zeile['buchungstext']) ?></div></td>
<td class="nebentext buchungstext"><div class="zellentext" data-herkunft="<?= View::h($herkunft($zeile)) ?>"><?= View::h($zeile['buchungstext']) ?></div></td>
<?php
// Die Gegenseite einer fremden Umbuchung nennt das Konto, von dem
// sie kommt – und zwar dort, wo sonst die Kategorie steht. Ohne

View file

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Bankinstitut;
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Konto;
/**
* IBAN und Bank der Gegenseite: Der Bankabruf löst die Bankleitzahl bzw. BIC
* der Gegenseite schon beim Schreiben des Umsatzes gegen das Institutsverzeichnis
* auf (Models\Bankinstitut); beim Übernehmen wandert beides auf die Buchung, wo
* das Kontoblatt es im Tooltip über dem Verwendungszweck zeigt.
*/
class BuchungHerkunftTest extends TestCase
{
protected string $testName = 'Test 45: IBAN und Bank der Gegenseite';
private int $giro = 0;
private int $zugang = 0;
public function run(): void
{
$this->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');
}
}