307 lines
13 KiB
PHP
307 lines
13 KiB
PHP
<?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'
|
||
);
|
||
}
|
||
}
|