Fix n.n.: Umbuchungen finden beide Seiten zueinander
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5d39293328
commit
42b43090bc
7 changed files with 798 additions and 8 deletions
26
Changes.md
26
Changes.md
|
|
@ -1,5 +1,31 @@
|
||||||
# Änderungen
|
# Ä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)
|
## 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
|
- Der Tooltip im Kontoblatt blieb bei älteren Umsätzen leer: IBAN und Bank der Gegenseite
|
||||||
|
|
|
||||||
|
|
@ -227,14 +227,27 @@ class Bankumsatz
|
||||||
* art: string,
|
* art: string,
|
||||||
* buchung: array<string,mixed>|null,
|
* buchung: array<string,mixed>|null,
|
||||||
* regel: 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
|
public static function vorschlag(array $umsatz, ?array $regeln = null): array
|
||||||
{
|
{
|
||||||
$buchung = self::passendeBuchung($umsatz);
|
$buchung = self::passendeBuchung($umsatz);
|
||||||
if ($buchung !== null) {
|
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);
|
$regel = AbgleichRegel::passendeRegel($umsatz, $regeln);
|
||||||
|
|
@ -244,10 +257,338 @@ class Bankumsatz
|
||||||
'buchung' => null,
|
'buchung' => null,
|
||||||
'regel' => $regel,
|
'regel' => $regel,
|
||||||
'zuordnung' => AbgleichRegel::anwenden($regel, $umsatz),
|
'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];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -261,6 +602,11 @@ class Bankumsatz
|
||||||
* Buchung formal einem anderen Konto gehört. Auf der Gegenkonto-Seite zählt
|
* Buchung formal einem anderen Konto gehört. Auf der Gegenkonto-Seite zählt
|
||||||
* der Betrag mit umgekehrtem Vorzeichen, wie im Kontoblatt auch.
|
* 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`):
|
* 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
|
* Zwischen dem Tag, an dem man eine Zahlung erfasst, und dem Tag, an dem die
|
||||||
* Bank sie bucht, liegen erfahrungsgemäß ein paar Tage.
|
* Bank sie bucht, liegen erfahrungsgemäß ein paar Tage.
|
||||||
|
|
@ -288,7 +634,7 @@ class Bankumsatz
|
||||||
AND ABS(DATEDIFF(b.datum, ?)) <= ?
|
AND ABS(DATEDIFF(b.datum, ?)) <= ?
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM bank_umsaetze u
|
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',
|
ORDER BY ABS(DATEDIFF(b.datum, ?)) LIMIT 1',
|
||||||
[
|
[
|
||||||
|
|
@ -300,6 +646,7 @@ class Bankumsatz
|
||||||
(string)$umsatz['buchungstag'],
|
(string)$umsatz['buchungstag'],
|
||||||
$tage,
|
$tage,
|
||||||
(int)($umsatz['id'] ?? 0),
|
(int)($umsatz['id'] ?? 0),
|
||||||
|
$kontoId,
|
||||||
(string)$umsatz['buchungstag'],
|
(string)$umsatz['buchungstag'],
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
@ -363,12 +710,36 @@ class Bankumsatz
|
||||||
|
|
||||||
self::verbinden($id, $buchungId);
|
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;
|
return $buchungId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verbindet den Umsatz mit einer bereits erfassten Buchung. */
|
/** Verbindet den Umsatz mit einer bereits erfassten Buchung. */
|
||||||
public static function verbinden(int $id, int $buchungId): void
|
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(
|
Database::execute(
|
||||||
"UPDATE bank_umsaetze SET status = 'uebernommen', buchung_id = ?
|
"UPDATE bank_umsaetze SET status = 'uebernommen', buchung_id = ?
|
||||||
WHERE id = ? AND benutzer_id = ?",
|
WHERE id = ? AND benutzer_id = ?",
|
||||||
|
|
|
||||||
|
|
@ -159,17 +159,45 @@ class Buchung
|
||||||
self::speichereSplits($id, $splits);
|
self::speichereSplits($id, $splits);
|
||||||
self::speichereAdressen($id, $adressen);
|
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
|
* Kategorie oder Gegenkonto einer Buchung setzen – die Schnellzuordnung aus
|
||||||
* dem Kontoblatt. Alles andere an der Buchung bleibt unberührt.
|
* 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
|
* Beides zugleich gibt es nicht: Eine Buchung ist entweder kategorisiert
|
||||||
* oder eine Umbuchung. War sie gesplittet, fallen die Zeilen weg – die
|
* oder eine Umbuchung. War sie gesplittet, fallen die Zeilen weg – die
|
||||||
* Zuordnung steckt danach wieder oben.
|
* 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);
|
$buchung = self::find($id);
|
||||||
if ($buchung === null) {
|
if ($buchung === null) {
|
||||||
|
|
@ -193,6 +221,10 @@ class Buchung
|
||||||
[$kategorieId, $gegenkontoId, $id, Mandant::id()]
|
[$kategorieId, $gegenkontoId, $id, Mandant::id()]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if ($gegenkontoId !== null && $gegenseiteSuchen) {
|
||||||
|
self::gegenseiteSuchen($id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,24 @@ class Bankabgleich
|
||||||
$vorschlag = Bankumsatz::vorschlag($umsatz, $regeln);
|
$vorschlag = Bankumsatz::vorschlag($umsatz, $regeln);
|
||||||
|
|
||||||
if ($vorschlag['art'] === 'verbinden' && $vorschlag['buchung'] !== null) {
|
if ($vorschlag['art'] === 'verbinden' && $vorschlag['buchung'] !== null) {
|
||||||
Bankumsatz::verbinden((int)$umsatz['id'], (int)$vorschlag['buchung']['id']);
|
// Ein unsicherer Treffer (mehrere Kandidaten ohne IBAN-Bestätigung)
|
||||||
$verbunden++;
|
// 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;
|
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 [
|
return [
|
||||||
'verbunden' => $verbunden,
|
'verbunden' => $verbunden,
|
||||||
'uebernommen' => $uebernommen,
|
'uebernommen' => $uebernommen,
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<form method="post" action="/banking/umsaetze/<?= $id ?>/uebernehmen" class="umsatzaktion">
|
<form method="post" action="/banking/umsaetze/<?= $id ?>/uebernehmen" class="umsatzaktion">
|
||||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
<input type="hidden" name="buchung_id" value="<?= (int)$buchung['id'] ?>">
|
<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">
|
<span class="nebentext">
|
||||||
mit der Buchung vom <?= View::h(View::datum($buchung['datum'])) ?>
|
mit der Buchung vom <?= View::h(View::datum($buchung['datum'])) ?>
|
||||||
über <?= View::betrag($buchung['betrag']) ?>
|
über <?= View::betrag($buchung['betrag']) ?>
|
||||||
|
|
|
||||||
|
|
@ -723,6 +723,39 @@ class BankingTest extends TestCase
|
||||||
'Die Umbuchung gilt jetzt als abgeglichen'
|
'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.
|
// Ein anderer Betrag auf der Gegenkontoseite passt nicht.
|
||||||
$this->assertNull(
|
$this->assertNull(
|
||||||
Bankumsatz::passendeBuchung([
|
Bankumsatz::passendeBuchung([
|
||||||
|
|
|
||||||
307
tests/50_UmbuchungGegenseiteTest.php
Normal file
307
tests/50_UmbuchungGegenseiteTest.php
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue