Finanzmanager/tests/50_UmbuchungGegenseiteTest.php
fds2610-bot 42b43090bc Fix n.n.: Umbuchungen finden beide Seiten zueinander
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 13:11:59 +02:00

307 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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