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