Merge pull request 'Fix n.n.: Umbuchungen finden beide Seiten zueinander' (#93) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/93
This commit is contained in:
fds2610 2026-09-29 16:02:08 +02:00
commit 30b46a4561
8 changed files with 991 additions and 10 deletions

View file

@ -1,5 +1,41 @@
# Ä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
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

View file

@ -80,11 +80,17 @@ class Bankumsatz
/**
* @param array<string,mixed> $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<string,mixed> $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(
@ -174,14 +227,27 @@ class Bankumsatz
* art: string,
* buchung: array<string,mixed>|null,
* regel: array<string,mixed>|null,
* zuordnung: array<string,mixed>|null
* zuordnung: array<string,mixed>|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);
@ -191,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<string>
*/
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<string> $ibansA IBANs des Kontos von Umsatz A
* @param list<string> $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<string,mixed> $umsatz
* @return array{buchung: array<string,mixed>, 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<string>}
*/
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];
}
/**
@ -208,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.
@ -235,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',
[
@ -247,6 +646,7 @@ class Bankumsatz
(string)$umsatz['buchungstag'],
$tage,
(int)($umsatz['id'] ?? 0),
$kontoId,
(string)$umsatz['buchungstag'],
]
);
@ -310,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 = ?",
@ -323,11 +747,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

View file

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

View file

@ -55,8 +55,24 @@ class Bankabgleich
$vorschlag = Bankumsatz::vorschlag($umsatz, $regeln);
if ($vorschlag['art'] === 'verbinden' && $vorschlag['buchung'] !== null) {
// 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,

View file

@ -127,7 +127,7 @@ include __DIR__ . '/../layout/kopf.php';
<form method="post" action="/banking/umsaetze/<?= $id ?>/uebernehmen" class="umsatzaktion">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="buchung_id" value="<?= (int)$buchung['id'] ?>">
<span class="vorschlagsmarke">Vorschlag: verbinden</span>
<span class="vorschlagsmarke">Vorschlag: verbinden<?= (int)$buchung['konto_id'] !== (int)$umsatz['konto_id'] ? ' (Gegenseite einer Umbuchung)' : '' ?></span>
<span class="nebentext">
mit der Buchung vom <?= View::h(View::datum($buchung['datum'])) ?>
über <?= View::betrag($buchung['betrag']) ?>

View file

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

View file

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
use Finanzmanager\Database;
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Konto;
/**
* IBAN und Bank der Gegenseite werden beim erneuten Abruf nachgetragen – auch
* wenn der Umsatz schon bekannt ist und deshalb nicht neu angelegt wird.
*/
class UmsatzHerkunftNachtragenTest extends TestCase
{
protected string $testName = 'Test 49: Herkunft eines bekannten Umsatzes nachtragen';
private int $giro = 0;
private int $zugang = 0;
private int $bankkonto = 0;
public function run(): void
{
$this->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<string,mixed> */
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');
}
}

View file

@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Bankabgleich;
/**
* Die zwei Seiten einer Umbuchung finden zueinander: Betrag mit umgekehrtem
* Vorzeichen, Buchungstag nah beieinander, die IBANs widersprechen sich nicht –
* ob der zweite Umsatz noch offen ist (Fall 1) oder beide schon einzeln gebucht
* wurden (Fall 2).
*/
class UmbuchungGegenseiteTest extends TestCase
{
protected string $testName = 'Test 50: Gegenseite einer Umbuchung finden';
private const IBAN_A = 'DE53200411110847626900';
private const IBAN_B = 'DE36120300001010903027';
private const IBAN_FREMD = 'DE02120300000000202051';
private int $kontoA = 0;
private int $kontoB = 0;
private int $kontoP = 0;
private int $zugang = 0;
private int $bankA = 0;
private int $bankB = 0;
public function run(): void
{
$this->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'
);
}
}