mandantAnlegen(); $this->stammdaten(); $this->verschluesselungSchuetztDieZugangsdaten(); $this->zugangHaeltPinBeimSpeichern(); $this->tanVerfahrenLaesstSichWaehlen(); $this->tanVerfahrenUeberstehtDasSpeichern(); $this->zeitraumRichtetSichNachDemLetztenAbruf(); $this->stichtagBestimmtDenAbrufbeginn(); $this->umsaetzeKommenNurEinmalHerein(); $this->regelnGreifenNachPrioritaet(); $this->regelnRechnenSplitsAus(); $this->splitsHaltenDasVerhaeltnis(); $this->ungueltigesMusterWirdAbgewiesen(); $this->abgleichFindetVorhandeneBuchung(); $this->umbuchungWirdAuchVonDerGegenkontoseiteErkannt(); $this->uebernehmenErzeugtEineBuchung(); $this->regelmitSplitsErzeugtEineSplitbuchung(); $this->buchungOhneKategorieWirdGefunden(); $this->regelnKategorisierenBuchungenOhneKategorie(); $this->verwerfenUndZuruecksetzen(); $this->abrufprotokollHaeltDenVerlauf(); } private function stammdaten(): void { $this->giro = Konto::anlegen([ 'name' => 'Bankinggiro', 'kontoart' => 'bank', 'anfangssaldo' => 500, 'aktiv' => 1, 'iban' => 'DE02120300000000202051', ]); $this->sparkonto = Konto::anlegen([ 'name' => 'Bankingsparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1, ]); $this->strom = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Strom', 'ausgabe'); $this->miete = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Miete', 'ausgabe'); $this->zugang = Bankzugang::anlegen([ 'name' => 'Testbank', 'bankname' => 'Testbank AG', 'blz' => '12030000', 'fints_url' => 'https://banking.example.org/fints', 'benutzerkennung' => 'testkunde', 'pin' => 'geheim1234', 'abrufzeitraum' => 'letzter_abruf', 'aktiv' => 1, ]); } /** @param array $daten */ private function umsatzAnlegen(array $daten): ?int { return Bankumsatz::anlegen([ 'zugang_id' => $this->zugang, // array_key_exists, nicht ??: Ein ausdrückliches null steht hier für // „kein Konto zugeordnet“ und darf nicht auf das Giro zurückfallen. 'konto_id' => array_key_exists('konto_id', $daten) ? $daten['konto_id'] : $this->giro, // Dieser Test kennt kein Bankkonto – die Umsätze müssen nur // untereinander eindeutig sein, dafür reicht eine feste Kennung. 'extern_id' => $daten['extern_id'] ?? Bankumsatz::externId( 1, (string)$daten['buchungstag'], (float)$daten['betrag'], (string)($daten['verwendungszweck'] ?? ''), (string)($daten['empfaenger'] ?? '') ), 'buchungstag' => $daten['buchungstag'], 'wertstellung' => $daten['buchungstag'], 'betrag' => $daten['betrag'], 'empfaenger' => $daten['empfaenger'] ?? '', 'verwendungszweck' => $daten['verwendungszweck'] ?? '', 'buchungsart' => 'LASTSCHRIFT', ]); } private function verschluesselungSchuetztDieZugangsdaten(): void { if (!Crypto::einsatzbereit()) { $this->assert(false, 'APP_KEY fehlt – ohne ihn lässt sich die Verschlüsselung nicht prüfen'); return; } $geheim = Crypto::verschluessle('meine-pin-1234'); $this->assert(Crypto::istVerschluesselt($geheim), 'Der Geheimtext trägt seine Kennung'); $this->assert(!str_contains($geheim, 'meine-pin'), 'Die PIN steht nicht im Klartext darin'); $this->assertEquals('meine-pin-1234', Crypto::entschluessle($geheim), 'Und kommt unverändert zurück'); // Zweimal dasselbe verschlüsselt ergibt zwei verschiedene Geheimtexte – // jeder Durchgang zieht einen neuen Initialisierungsvektor. $this->assert( Crypto::verschluessle('meine-pin-1234') !== $geheim, 'Gleicher Klartext, anderer Geheimtext' ); $this->assertEquals('', Crypto::verschluessle(''), 'Leeres bleibt leer'); $this->assertEquals( 'klartext-aus-altbestand', Crypto::entschluessle('klartext-aus-altbestand'), 'Werte ohne Kennung werden durchgereicht' ); $doppelt = Crypto::verschluessle($geheim); $this->assertEquals($geheim, $doppelt, 'Zweimal verschlüsseln verschachtelt nicht'); } private function zugangHaeltPinBeimSpeichern(): void { $roh = Bankzugang::find($this->zugang); $this->assert( !str_contains((string)$roh['pin'], 'geheim1234'), 'In der Datenbank steht die PIN nur verschlüsselt' ); $mitPin = Bankzugang::zugangsdaten($this->zugang); $this->assertEquals('geheim1234', $mitPin['pin'], 'Der Abrufdienst bekommt sie im Klartext'); // Ein leeres PIN-Feld heißt „unverändert“ – sonst verlöre jedes // Speichern des Formulars die Zugangsdaten. Bankzugang::speichern($this->zugang, [ 'name' => 'Testbank', 'blz' => '12030000', 'fints_url' => 'https://banking.example.org/fints', 'benutzerkennung' => 'testkunde', 'pin' => '', 'abrufzeitraum' => 'sieben_tage', 'aktiv' => 1, ]); $nachher = Bankzugang::zugangsdaten($this->zugang); $this->assertEquals('geheim1234', $nachher['pin'], 'Leeres Feld behält die alte PIN'); $this->assertEquals('sieben_tage', $nachher['abrufzeitraum'], 'Der Zeitraum wurde übernommen'); $fehler = false; try { Bankzugang::speichern($this->zugang, [ 'name' => 'Testbank', 'fints_url' => 'http://unverschluesselt.example.org', ]); } catch (\InvalidArgumentException) { $fehler = true; } $this->assert($fehler, 'Eine FinTS-Adresse ohne https wird abgewiesen'); Bankzugang::speichern($this->zugang, [ 'name' => 'Testbank', 'blz' => '12030000', 'fints_url' => 'https://banking.example.org/fints', 'benutzerkennung' => 'testkunde', 'pin' => '', 'abrufzeitraum' => 'letzter_abruf', 'aktiv' => 1, ]); } /** * Die Wahl des TAN-Verfahrens. Die Abfrage bei der Bank lässt sich hier * nicht nachstellen – geprüft wird, was mit ihrem Ergebnis geschieht. */ private function tanVerfahrenLaesstSichWaehlen(): void { // So sieht eine abgefragte Liste aus. $angebot = [ ['id' => 913, 'name' => 'chipTAN QR', 'entkoppelt' => false, 'medium_noetig' => true, 'medien' => ['Karte 1', 'Karte 2'], 'hinweis' => ''], ['id' => 921, 'name' => 'pushTAN 2.0', 'entkoppelt' => true, 'medium_noetig' => false, 'medien' => [], 'hinweis' => ''], ]; FinTsVerfahren::uebernehmen($this->zugang, 913, 'Karte 2', $angebot); $zugang = Bankzugang::zugangsdaten($this->zugang); $this->assertEquals(913, (int)$zugang['tan_verfahren_id'], 'Die Kennung ist gespeichert'); $this->assertEquals('chipTAN QR', $zugang['tan_verfahren_name'], 'Der Name steht daneben'); $this->assertEquals('Karte 2', $zugang['tan_medium'], 'Das gewählte Medium ist gespeichert'); $roh = Bankzugang::find($this->zugang); $this->assert( !str_contains((string)$roh['tan_medium'], 'Karte 2'), 'Das TAN-Medium steht verschlüsselt in der Datenbank' ); // Ein Verfahren, das die Bank nicht angeboten hat, wäre beim nächsten // Abruf wirkungslos – also gleich abweisen. $this->assert( $this->wirftFehler(fn() => FinTsVerfahren::uebernehmen($this->zugang, 942, '', $angebot)), 'Eine unbekannte Verfahrenskennung wird abgewiesen' ); $this->assert( $this->wirftFehler(fn() => FinTsVerfahren::uebernehmen($this->zugang, 913, '', $angebot)), 'Ein Verfahren mit Medienzwang braucht ein Medium' ); $this->assert( $this->wirftFehler(fn() => FinTsVerfahren::uebernehmen($this->zugang, 913, 'Karte 9', $angebot)), 'Ein Medium, das die Bank nicht genannt hat, wird abgewiesen' ); // Braucht das Verfahren kein Medium, wird ein mitgeschicktes verworfen: // Manche Banken lehnen den Dialog ab, wenn eins dabei ist. FinTsVerfahren::uebernehmen($this->zugang, 921, 'Karte 2', $angebot); $zugang = Bankzugang::zugangsdaten($this->zugang); $this->assertEquals(921, (int)$zugang['tan_verfahren_id'], 'Das entkoppelte Verfahren ist gewählt'); $this->assertEquals('', $zugang['tan_medium'], 'Ohne Medienzwang bleibt das Medium leer'); // Ohne vollständige Zugangsdaten wird die Bank gar nicht erst gefragt. $ergebnis = FinTsVerfahren::abfragen(0); $this->assertEquals('fehler', $ergebnis['status'], 'Ein unbekannter Zugang liefert einen Fehler'); $this->assertEquals([], $ergebnis['verfahren'], 'Und keine Liste'); FinTsVerfahren::uebernehmen($this->zugang, 913, 'Karte 1', $angebot); } /** * Das Verfahren steht nicht im Zugangsformular. Ohne Vorbehalt setzte jedes * Speichern die Wahl zurück – und der nächste Abruf verlangte wieder das * falsche Verfahren. */ private function tanVerfahrenUeberstehtDasSpeichern(): void { Bankzugang::speichern($this->zugang, [ 'name' => 'Testbank', 'blz' => '12030000', 'fints_url' => 'https://banking.example.org/fints', 'benutzerkennung' => 'testkunde', 'pin' => '', 'abrufzeitraum' => 'letzter_abruf', 'aktiv' => 1, ]); $zugang = Bankzugang::zugangsdaten($this->zugang); $this->assertEquals(913, (int)$zugang['tan_verfahren_id'], 'Das Speichern lässt die Kennung stehen'); $this->assertEquals('chipTAN QR', $zugang['tan_verfahren_name'], 'Und den Namen'); $this->assertEquals('Karte 1', $zugang['tan_medium'], 'Und das Medium'); } /** Hilfsmittel: Wirft der Aufruf eine Ausnahme? */ private function wirftFehler(callable $aufruf): bool { try { $aufruf(); } catch (\Throwable) { return true; } return false; } private function zeitraumRichtetSichNachDemLetztenAbruf(): void { $heute = new \DateTimeImmutable('2026-08-11'); [$von] = Bankzugang::startdatum('sieben_tage', null, $heute); $this->assertEquals('2026-08-04', $von->format('Y-m-d'), 'Sieben Tage zurück'); [$von] = Bankzugang::startdatum('dreissig_tage', null, $heute); $this->assertEquals('2026-07-12', $von->format('Y-m-d'), 'Dreißig Tage zurück'); [$von, $text] = Bankzugang::startdatum('letzter_abruf', '2026-08-09 22:15:00', $heute); $this->assertEquals('2026-08-09', $von->format('Y-m-d'), 'Ab dem Tag des letzten Abrufs'); $this->assert(str_contains($text, '09.08.2026'), 'Und sagt es auch'); // Ohne vorherigen Abruf muss ein sinnvoller Anfang her, sonst käme // entweder nichts oder alles. [$von] = Bankzugang::startdatum('letzter_abruf', null, $heute); $this->assertEquals('2025-08-11', $von->format('Y-m-d'), 'Beim ersten Mal ein Jahr zurück'); [$von] = Bankzugang::startdatum('quatsch', null, $heute); $this->assertEquals('2025-08-11', $von->format('Y-m-d'), 'Unbekannte Vorgabe fällt zurück'); } /** * Der Stichtag: als Vorgabe des Zugangs und als einmalige Angabe beim Abruf. * * Die einmalige Angabe darf die gespeicherte Einstellung weder verwenden noch * verändern – sonst wäre der nächste reguläre Abruf ein anderer als gedacht. */ private function stichtagBestimmtDenAbrufbeginn(): void { $heute = new \DateTimeImmutable('2026-08-11'); [$von, $text] = Bankzugang::startdatum('stichtag', null, $heute, '2026-01-01'); $this->assertEquals('2026-01-01', $von->format('Y-m-d'), 'Ab dem Stichtag'); $this->assert(str_contains($text, '01.01.2026'), 'Und das Protokoll sagt es'); // Aus der Zukunft liefert keine Bank etwas. [$von] = Bankzugang::startdatum('stichtag', null, $heute, '2027-03-01'); $this->assertEquals('2026-08-11', $von->format('Y-m-d'), 'Ein Stichtag in der Zukunft wird auf heute gesetzt'); // Ohne brauchbares Datum bleibt nur die Regel ohne Vorgabe. [$von] = Bankzugang::startdatum('stichtag', '2026-08-09 22:15:00', $heute, ''); $this->assertEquals('2026-08-09', $von->format('Y-m-d'), 'Ohne Datum wie „seit dem letzten Abruf“'); $this->assertNull(Bankzugang::stichtagDatum('31.02.2026'), 'Den 31. Februar gibt es nicht'); $this->assertNull(Bankzugang::stichtagDatum('irgendwann'), 'Text ist kein Datum'); $this->assertEquals( '2026-08-13', Bankzugang::stichtagDatum('13.08.2026')?->format('Y-m-d'), 'Auch die deutsche Schreibweise wird gelesen' ); // Die Vorgabe des Zugangs … Bankzugang::speichern($this->zugang, [ 'name' => 'Testbank', 'blz' => '12030000', 'fints_url' => 'https://banking.example.org/fints', 'benutzerkennung' => 'testkunde', 'pin' => '', 'abrufzeitraum' => 'stichtag', 'abruf_stichtag' => '2026-01-01', 'aktiv' => 1, ]); $zugang = Bankzugang::find($this->zugang); $this->assertEquals('2026-01-01', (string)$zugang['abruf_stichtag'], 'Der Stichtag wurde gespeichert'); [$von] = Bankzugang::abrufBeginn($zugang, null, $heute); $this->assertEquals('2026-01-01', $von->format('Y-m-d'), 'Der gespeicherte Stichtag gilt'); // … und die einmalige Angabe, die ihr vorgeht. [$von] = Bankzugang::abrufBeginn($zugang, '2026-07-01', $heute); $this->assertEquals('2026-07-01', $von->format('Y-m-d'), 'Der Stichtag des Laufs geht vor'); $this->assertEquals( '2026-01-01', (string)Bankzugang::find($this->zugang)['abruf_stichtag'], 'Und lässt die gespeicherte Vorgabe stehen' ); $this->assert( $this->wirftFehler(fn() => Bankzugang::speichern($this->zugang, [ 'name' => 'Testbank', 'blz' => '12030000', 'fints_url' => 'https://banking.example.org/fints', 'benutzerkennung' => 'testkunde', 'pin' => '', 'abrufzeitraum' => 'stichtag', 'abruf_stichtag' => '', 'aktiv' => 1, ])), '„Ab Stichtag“ ohne Datum wird abgewiesen' ); // Zurück auf die Vorgabe – die folgenden Prüfungen rechnen damit. Bankzugang::speichern($this->zugang, [ 'name' => 'Testbank', 'blz' => '12030000', 'fints_url' => 'https://banking.example.org/fints', 'benutzerkennung' => 'testkunde', 'pin' => '', 'abrufzeitraum' => 'letzter_abruf', 'aktiv' => 1, ]); } private function umsaetzeKommenNurEinmalHerein(): void { $erste = $this->umsatzAnlegen([ 'buchungstag' => '2026-08-03', 'betrag' => -84.20, 'empfaenger' => 'Stadtwerke Musterstadt', 'verwendungszweck' => 'Abschlag Strom 08/2026', ]); $this->assertNotEmpty($erste, 'Der Umsatz wurde angelegt'); $zweite = $this->umsatzAnlegen([ 'buchungstag' => '2026-08-03', 'betrag' => -84.20, 'empfaenger' => 'Stadtwerke Musterstadt', 'verwendungszweck' => 'Abschlag Strom 08/2026', ]); $this->assertNull($zweite, 'Derselbe Umsatz kommt beim nächsten Abruf nicht noch einmal herein'); // Ein anderer Betrag ist ein anderer Umsatz, auch am selben Tag. $dritte = $this->umsatzAnlegen([ 'buchungstag' => '2026-08-03', 'betrag' => -12.00, 'empfaenger' => 'Stadtwerke Musterstadt', 'verwendungszweck' => 'Abschlag Strom 08/2026', ]); $this->assertNotEmpty($dritte, 'Anderer Betrag, anderer Umsatz'); $this->assertEquals(2, Bankumsatz::anzahlOffen(), 'Zwei offene Umsätze'); } private function regelnGreifenNachPrioritaet(): void { // Die unspezifische Regel hat die schlechtere Priorität und darf der // genaueren nicht dazwischenkommen. AbgleichRegel::anlegen([ 'name' => 'Wohnen allgemein', 'feld' => 'beides', 'vergleich' => 'enthaelt', 'muster' => 'stadtwerke', 'kategorie_id' => $this->miete, 'prioritaet' => 200, 'aktiv' => 1, ]); AbgleichRegel::anlegen([ 'name' => 'Strom', 'feld' => 'verwendungszweck', 'vergleich' => 'enthaelt', 'muster' => 'Strom', 'kategorie_id' => $this->strom, 'prioritaet' => 10, 'aktiv' => 1, ]); $umsatz = [ 'empfaenger' => 'Stadtwerke Musterstadt', 'verwendungszweck' => 'Abschlag Strom 08/2026', 'betrag' => -84.20, ]; $regel = AbgleichRegel::passendeRegel($umsatz); $this->assertNotEmpty($regel, 'Eine Regel passt'); $this->assertEquals('Strom', $regel['name'], 'Die mit der besseren Priorität gewinnt'); // Groß- und Kleinschreibung darf nicht entscheiden: Banken schreiben // denselben Empfänger mal so, mal so. $this->assertNotEmpty( AbgleichRegel::passendeRegel(['empfaenger' => 'STADTWERKE MUSTERSTADT', 'verwendungszweck' => '', 'betrag' => -1]), 'Versalien treffen genauso' ); // Das Feld schränkt ein: „nur Empfänger“ sieht den Verwendungszweck nicht. $nurEmpfaenger = AbgleichRegel::anlegen([ 'name' => 'Nur Empfänger', 'feld' => 'empfaenger', 'vergleich' => 'enthaelt', 'muster' => 'Abschlag', 'kategorie_id' => $this->miete, 'prioritaet' => 1, 'aktiv' => 1, ]); $this->assertEquals( 'Strom', AbgleichRegel::passendeRegel($umsatz)['name'], 'Ein Muster im falschen Feld greift nicht' ); AbgleichRegel::loeschen($nurEmpfaenger); // Betragsgrenzen rechnen mit Vorzeichen. $grenze = AbgleichRegel::anlegen([ 'name' => 'Kleine Beträge', 'feld' => 'beides', 'vergleich' => 'enthaelt', 'muster' => 'stadtwerke', 'betrag_von' => -20, 'betrag_bis' => 0, 'kategorie_id' => $this->miete, 'prioritaet' => 1, 'aktiv' => 1, ]); $this->assertEquals( 'Strom', AbgleichRegel::passendeRegel($umsatz)['name'], '−84,20 liegt außerhalb von −20 bis 0' ); $this->assertEquals( 'Kleine Beträge', AbgleichRegel::passendeRegel(['empfaenger' => 'Stadtwerke', 'verwendungszweck' => '', 'betrag' => -12.0])['name'], '−12,00 liegt darin' ); AbgleichRegel::loeschen($grenze); // Eine abgeschaltete Regel greift nicht mehr. $regeln = AbgleichRegel::alle(); foreach ($regeln as $eintrag) { if ($eintrag['name'] === 'Strom') { AbgleichRegel::speichern((int)$eintrag['id'], [ 'name' => 'Strom', 'feld' => 'verwendungszweck', 'vergleich' => 'enthaelt', 'muster' => 'Strom', 'kategorie_id' => $this->strom, 'prioritaet' => 10, 'aktiv' => 0, ]); } } $this->assertEquals( 'Wohnen allgemein', AbgleichRegel::passendeRegel($umsatz)['name'], 'Abgeschaltet rückt die nächste Regel nach' ); foreach (AbgleichRegel::alle() as $eintrag) { if ($eintrag['name'] === 'Strom') { AbgleichRegel::speichern((int)$eintrag['id'], [ 'name' => 'Strom', 'feld' => 'verwendungszweck', 'vergleich' => 'enthaelt', 'muster' => 'Strom', 'kategorie_id' => $this->strom, 'prioritaet' => 10, 'aktiv' => 1, ]); } } } private function regelnRechnenSplitsAus(): void { $id = AbgleichRegel::anlegen([ 'name' => 'Geteilte Rechnung', 'feld' => 'empfaenger', 'vergleich' => 'beginnt', 'muster' => 'Vermieter', 'splits' => [ ['kategorie_id' => $this->miete, 'anteil' => 0.6667], ['kategorie_id' => $this->strom, 'anteil' => 0.3333], ], 'prioritaet' => 5, 'aktiv' => 1, ]); $regel = AbgleichRegel::find($id); $umsatz = ['empfaenger' => 'Vermieter Meier', 'verwendungszweck' => 'Miete', 'betrag' => -1000.00]; $this->assert(AbgleichRegel::passt($regel, $umsatz), '„beginnt mit“ trifft'); $this->assert( !AbgleichRegel::passt($regel, ['empfaenger' => 'Der Vermieter', 'verwendungszweck' => '', 'betrag' => -1]), 'Aber nicht mitten im Text' ); $zuordnung = AbgleichRegel::anwenden($regel, $umsatz); $this->assertEquals(2, count($zuordnung['splits']), 'Zwei Splitzeilen'); $this->assertEquals(-666.70, $zuordnung['splits'][0]['betrag'], 'Erste Zeile nach Anteil'); // Die letzte Zeile bekommt den Rest – sonst ginge bei krummen Anteilen // ein Cent verloren und die Buchung ließe sich nicht speichern. $summe = array_sum(array_column($zuordnung['splits'], 'betrag')); $this->assertEquals(-1000.00, round($summe, 2), 'Die Splits ergeben den Buchungsbetrag'); AbgleichRegel::loeschen($id); } /** * Der eigentliche Zweck des Schemas: Dieselbe Regel trifft bei jedem * Buchungsbetrag dasselbe Verhältnis. Und die Anteile gelten zueinander – * 60 zu 30 sind zwei Drittel zu einem Drittel, nicht 60 Prozent und ein * Rest von 40. */ private function splitsHaltenDasVerhaeltnis(): void { $id = AbgleichRegel::anlegen([ 'name' => 'Nebenkosten geteilt', 'feld' => 'empfaenger', 'vergleich' => 'enthaelt', 'muster' => 'Hausverwaltung', 'splits' => [ ['kategorie_id' => $this->miete, 'anteil' => 0.60], ['kategorie_id' => $this->strom, 'anteil' => 0.30], ], 'aktiv' => 1, ]); $regel = AbgleichRegel::find($id); $klein = AbgleichRegel::anwenden($regel, ['empfaenger' => 'Hausverwaltung', 'betrag' => -300.00]); $this->assertEquals(-200.00, $klein['splits'][0]['betrag'], 'Zwei Drittel von 300'); $this->assertEquals(-100.00, $klein['splits'][1]['betrag'], 'Ein Drittel von 300'); // Derselbe Schlüssel bei anderer Rechnungshöhe: Das Verhältnis bleibt. $gross = AbgleichRegel::anwenden($regel, ['empfaenger' => 'Hausverwaltung', 'betrag' => -450.00]); $this->assertEquals(-300.00, $gross['splits'][0]['betrag'], 'Zwei Drittel von 450'); $this->assertEquals(-150.00, $gross['splits'][1]['betrag'], 'Ein Drittel von 450'); // Auch ein Guthaben – eine Rückzahlung der Nebenkosten – wird geteilt. $zurueck = AbgleichRegel::anwenden($regel, ['empfaenger' => 'Hausverwaltung', 'betrag' => 90.00]); $this->assertEquals(60.00, $zurueck['splits'][0]['betrag'], 'Erstattung nach demselben Schlüssel'); $this->assertEquals( 90.00, round(array_sum(array_column($zurueck['splits'], 'betrag')), 2), 'Auch hier ergeben die Zeilen den Buchungsbetrag' ); // Was die Oberfläche zum Bearbeiten zurückbekommt, ist wieder Prozent. $zeilen = AbgleichRegel::splitzeilen($regel); $this->assertEquals(2, count($zeilen), 'Zwei Zeilen für das Formular'); $this->assertEquals(60.0, $zeilen[0]['prozent'], 'Der Anteil steht als Prozentwert'); $this->assertEquals($this->strom, $zeilen[1]['kategorie_id'], 'Mit seiner Kategorie'); AbgleichRegel::loeschen($id); } private function ungueltigesMusterWirdAbgewiesen(): void { $this->assert( !AbgleichRegel::musterIstGueltig('regex', 'Miete('), 'Eine offene Klammer ist kein gültiger Ausdruck' ); $this->assert( AbgleichRegel::musterIstGueltig('regex', 'Miete|Nebenkosten'), 'Eine Alternative schon' ); $fehler = false; try { AbgleichRegel::anlegen([ 'name' => 'Kaputt', 'feld' => 'beides', 'vergleich' => 'regex', 'muster' => 'Miete(', 'aktiv' => 1, ]); } catch (\InvalidArgumentException) { $fehler = true; } $this->assert($fehler, 'Und wird gar nicht erst gespeichert'); $id = AbgleichRegel::anlegen([ 'name' => 'Ausdruck', 'feld' => 'verwendungszweck', 'vergleich' => 'regex', 'muster' => 'Strom|Gas', 'kategorie_id' => $this->strom, 'prioritaet' => 50, 'aktiv' => 1, ]); $this->assert( AbgleichRegel::passt( AbgleichRegel::find($id), ['empfaenger' => '', 'verwendungszweck' => 'Abschlag Gas 2026', 'betrag' => -50] ), 'Der reguläre Ausdruck trifft die Alternative' ); AbgleichRegel::loeschen($id); } private function abgleichFindetVorhandeneBuchung(): void { Einstellung::set('banking_abgleich_tage', '5', 'banking'); // Von Hand erfasst, drei Tage vor dem Banktag – der Normalfall. $buchungId = Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-08-01', 'betrag' => -84.20, 'empfaenger' => 'Stadtwerke', 'kategorie_id' => $this->strom, ]); $umsatz = Bankumsatz::liste(['status' => 'offen'])[0]; // Die Liste ist absteigend sortiert; gesucht ist der über 84,20. foreach (Bankumsatz::liste(['status' => 'offen']) as $eintrag) { if (abs((float)$eintrag['betrag'] + 84.20) < 0.005) { $umsatz = $eintrag; } } $treffer = Bankumsatz::passendeBuchung($umsatz); $this->assertNotEmpty($treffer, 'Die vorhandene Buchung wird gefunden'); $this->assertEquals($buchungId, (int)$treffer['id'], 'Und zwar genau sie'); $vorschlag = Bankumsatz::vorschlag($umsatz); $this->assertEquals('verbinden', $vorschlag['art'], 'Der Vorschlag lautet: verbinden'); // Außerhalb der Toleranz zählt sie nicht mehr. Einstellung::set('banking_abgleich_tage', '1', 'banking'); $this->assertNull( Bankumsatz::passendeBuchung($umsatz), 'Mit einem Tag Toleranz liegen drei Tage zu weit auseinander' ); Einstellung::set('banking_abgleich_tage', '5', 'banking'); // Ein anderer Betrag passt nie. $this->assertNull( Bankumsatz::passendeBuchung(['konto_id' => $this->giro, 'buchungstag' => '2026-08-03', 'betrag' => -99.99, 'id' => 0]), 'Ein anderer Betrag ist eine andere Buchung' ); Bankumsatz::verbinden((int)$umsatz['id'], $buchungId); $nachher = Bankumsatz::find((int)$umsatz['id']); $this->assertEquals('uebernommen', $nachher['status'], 'Der Umsatz gilt als erledigt'); $this->assertEquals($buchungId, (int)$nachher['buchung_id'], 'Und zeigt auf die Buchung'); $this->assertEquals( 1, (int)Buchung::find($buchungId)['abgeglichen'], 'Die Buchung ist jetzt abgeglichen' ); // Eine schon verbundene Buchung darf kein zweiter Umsatz greifen. $zweiter = $this->umsatzAnlegen([ 'buchungstag' => '2026-08-04', 'betrag' => -84.20, 'empfaenger' => 'Stadtwerke Musterstadt', 'verwendungszweck' => 'Zweite Lastschrift', ]); $this->assertNull( Bankumsatz::passendeBuchung(Bankumsatz::find((int)$zweiter)), 'Eine bereits verbundene Buchung wird nicht noch einmal vorgeschlagen' ); } /** * Eine Umbuchung gehört ihrem Ursprungskonto – hier dem Giro. Der * Kontoauszug des Gegenkontos (Sparbuch) muss sie trotzdem finden, sonst * bucht der automatische Abgleich sie dort ein zweites Mal. */ private function umbuchungWirdAuchVonDerGegenkontoseiteErkannt(): void { Einstellung::set('banking_abgleich_tage', '5', 'banking'); $umbuchungId = Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-08-20', 'betrag' => -40.00, 'empfaenger' => 'Umbuchung Sparbuch', 'gegenkonto_id' => $this->sparkonto, ]); // Die Bank bucht das Sparbuch erst einen Tag später – auf der // Gegenkonto-Seite zählt der Betrag mit umgekehrtem Vorzeichen. $umsatzId = Bankumsatz::anlegen([ 'zugang_id' => $this->zugang, 'konto_id' => $this->sparkonto, 'extern_id' => 'test-gegenkonto-1', 'buchungstag' => '2026-08-21', 'wertstellung' => '2026-08-21', 'betrag' => 40.00, 'empfaenger' => 'Giro', 'buchungsart' => 'GUTSCHRIFT', ]); $umsatz = Bankumsatz::find((int)$umsatzId); $treffer = Bankumsatz::passendeBuchung($umsatz); $this->assertNotEmpty($treffer, 'Die Umbuchung wird auch von der Gegenkontoseite gefunden'); $this->assertEquals($umbuchungId, (int)$treffer['id'], 'Und zwar genau sie'); $vorschlag = Bankumsatz::vorschlag($umsatz); $this->assertEquals('verbinden', $vorschlag['art'], 'Der Vorschlag lautet: verbinden'); Bankumsatz::verbinden((int)$umsatzId, $umbuchungId); $this->assertEquals( 1, (int)Buchung::find($umbuchungId)['abgeglichen'], 'Die Umbuchung gilt jetzt als abgeglichen' ); // Ein anderer Betrag auf der Gegenkontoseite passt nicht. $this->assertNull( Bankumsatz::passendeBuchung([ 'konto_id' => $this->sparkonto, 'buchungstag' => '2026-08-21', 'betrag' => 12.34, 'id' => 0, ]), 'Ein anderer Betrag ist keine Umbuchung von drüben' ); } private function uebernehmenErzeugtEineBuchung(): void { $offen = Bankumsatz::liste(['status' => 'offen']); $this->assertNotEmpty($offen, 'Es gibt noch offene Umsätze'); $umsatz = $offen[0]; $vorher = Buchung::anzahl(); $buchungId = Bankumsatz::uebernehmen((int)$umsatz['id'], [ 'kategorie_id' => $this->strom, ]); $this->assertEquals($vorher + 1, Buchung::anzahl(), 'Es gibt eine Buchung mehr'); $buchung = Buchung::find($buchungId); $this->assertEquals((float)$umsatz['betrag'], (float)$buchung['betrag'], 'Mit dem Betrag des Umsatzes'); $this->assertEquals((string)$umsatz['buchungstag'], (string)$buchung['datum'], 'Und seinem Datum'); $this->assertEquals('fints', $buchung['quelle'], 'Die Quelle ist der Bankabruf'); $this->assertEquals(1, (int)$buchung['abgeglichen'], 'Was von der Bank kommt, gilt als abgeglichen'); $this->assertEquals($this->strom, (int)$buchung['kategorie_id'], 'Die Kategorie kam aus der Zuordnung'); // Ein zweites Übernehmen desselben Umsatzes muss auffallen. $fehler = false; try { Bankumsatz::uebernehmen((int)$umsatz['id'], []); } catch (\RuntimeException) { $fehler = true; } $this->assert($fehler, 'Zweimal übernehmen geht nicht'); // Umsätze ohne Konto lassen sich nicht buchen – das Konto fehlt. $ohneKonto = $this->umsatzAnlegen([ 'konto_id' => null, 'buchungstag' => '2026-08-05', 'betrag' => -5.00, 'empfaenger' => 'Kiosk', 'verwendungszweck' => 'Ohne Kontozuordnung', ]); $fehler = false; try { Bankumsatz::uebernehmen((int)$ohneKonto, []); } catch (\RuntimeException) { $fehler = true; } $this->assert($fehler, 'Ohne zugeordnetes Konto verweigert der Abgleich die Buchung'); // Mit Konto in der Zuordnung geht es dann doch. $mitKonto = Bankumsatz::uebernehmen((int)$ohneKonto, ['konto_id' => $this->sparkonto]); $this->assertEquals( $this->sparkonto, (int)Buchung::find($mitKonto)['konto_id'], 'Das Konto aus der Zuordnung zählt' ); } /** * Der ganze Weg: Eine Regel mit Splitschema trifft einen offenen Umsatz, und * was der Vorschlag ausrechnet, wird auch so gebucht. Der Abgleichdialog * reicht genau diese Zeilen weiter – tat er das nicht, landete der Umsatz * ungeteilt im Kontoblatt, ohne dass jemand etwas davon merkte. */ private function regelmitSplitsErzeugtEineSplitbuchung(): void { $regelId = AbgleichRegel::anlegen([ 'name' => 'Abschlag geteilt', 'feld' => 'empfaenger', 'vergleich' => 'enthaelt', 'muster' => 'Stadtwerke Nord', 'splits' => [ ['kategorie_id' => $this->strom, 'anteil' => 0.75], ['kategorie_id' => $this->miete, 'anteil' => 0.25], ], 'prioritaet' => 1, 'aktiv' => 1, ]); $umsatzId = $this->umsatzAnlegen([ 'konto_id' => $this->giro, 'buchungstag' => '2026-08-07', 'betrag' => -240.00, 'empfaenger' => 'Stadtwerke Nord AG', 'verwendungszweck' => 'Abschlag August', ]); $umsatz = Bankumsatz::find($umsatzId); $vorschlag = Bankumsatz::vorschlag($umsatz); $this->assertEquals('uebernehmen', $vorschlag['art'], 'Ohne passende Buchung wird übernommen'); $this->assertNotEmpty($vorschlag['regel'], 'Die Regel greift'); $this->assertEquals(2, count($vorschlag['zuordnung']['splits']), 'Der Vorschlag bringt zwei Zeilen mit'); $buchungId = Bankumsatz::uebernehmen($umsatzId, $vorschlag['zuordnung']); $buchung = Buchung::find($buchungId); $this->assertEquals(1, (int)$buchung['gesplittet'], 'Es entsteht eine Splitbuchung'); $this->assertNull($buchung['kategorie_id'], 'Die Zuordnung steckt in den Zeilen, nicht in der Buchung'); $splits = Buchung::splits($buchungId); $this->assertEquals(2, count($splits), 'Zwei Splitzeilen im Kontoblatt'); $this->assertEquals(-180.00, (float)$splits[0]['betrag'], 'Drei Viertel von 240'); $this->assertEquals(-60.00, (float)$splits[1]['betrag'], 'Ein Viertel von 240'); $this->assertEquals($this->strom, (int)$splits[0]['kategorie_id'], 'Mit der Kategorie aus der Regel'); AbgleichRegel::loeschen($regelId); } /** * Der Regel-Testlauf soll Lücken zeigen, die eine Regel schließen könnte – * nicht den Abgleichsstatus. Deshalb zählt „ohne Kategorie“ unabhängig * davon, ob eine Buchung aus einem Umsatz entstand oder manuell erfasst * wurde, und eine Splitbuchung zählt nur, wenn wirklich keine Zeile eine * Kategorie trägt. */ private function buchungOhneKategorieWirdGefunden(): void { $ohneKategorie = Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-08-10', 'betrag' => -19.99, 'empfaenger' => 'Unbekannter Laden', ]); $mitKategorie = Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-08-10', 'betrag' => -30.00, 'empfaenger' => 'Bekannter Laden', 'kategorie_id' => $this->strom, ]); $splitOhneKategorie = Buchung::anlegen( ['konto_id' => $this->giro, 'datum' => '2026-08-10', 'betrag' => -50.00, 'empfaenger' => 'Split ohne'], [ ['kategorie_id' => null, 'betrag' => -30.00], ['kategorie_id' => null, 'betrag' => -20.00], ] ); $splitMitKategorie = Buchung::anlegen( ['konto_id' => $this->giro, 'datum' => '2026-08-10', 'betrag' => -50.00, 'empfaenger' => 'Split mit'], [ ['kategorie_id' => $this->strom, 'betrag' => -30.00], ['kategorie_id' => null, 'betrag' => -20.00], ] ); $umbuchung = Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-08-10', 'betrag' => -40.00, 'empfaenger' => 'Umbuchung', 'gegenkonto_id' => $this->sparkonto, ]); $ids = array_column(Buchung::ohneKategorie(), 'id'); $ids = array_map('intval', $ids); $this->assert(in_array($ohneKategorie, $ids, true), 'Buchung ohne jede Kategorie taucht auf'); $this->assert(in_array($splitOhneKategorie, $ids, true), 'Splitbuchung ohne kategorisierte Zeile taucht auf'); $this->assert(!in_array($mitKategorie, $ids, true), 'Kategorisierte Buchung fehlt in der Liste'); $this->assert(!in_array($splitMitKategorie, $ids, true), 'Teilweise kategorisierte Splitbuchung fehlt in der Liste'); $this->assert(!in_array($umbuchung, $ids, true), 'Eine Umbuchung braucht keine Kategorie'); } /** * Der Knopf soll die Lücken nicht nur zählen, sondern gleich schließen: * Jede Buchung ohne Kategorie, auf die eine Regel passt, bekommt deren * Zuordnung nachgetragen. Ohne Treffer bleibt die Buchung unverändert. */ private function regelnKategorisierenBuchungenOhneKategorie(): void { $regelId = AbgleichRegel::anlegen([ 'name' => 'Stadtwerke Nachtrag', 'feld' => 'empfaenger', 'vergleich' => 'enthaelt', 'muster' => 'Stadtwerke Nachtrag', 'kategorie_id' => $this->strom, 'prioritaet' => 1, 'aktiv' => 1, ]); $splitRegelId = AbgleichRegel::anlegen([ 'name' => 'Vermieter Nachtrag geteilt', 'feld' => 'empfaenger', 'vergleich' => 'enthaelt', 'muster' => 'Vermieter Nachtrag', 'splits' => [ ['kategorie_id' => $this->strom, 'anteil' => 0.5], ['kategorie_id' => $this->miete, 'anteil' => 0.5], ], 'prioritaet' => 1, 'aktiv' => 1, ]); $treffervorher = (int)AbgleichRegel::find($regelId)['treffer']; $trifft = Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-08-11', 'betrag' => -55.00, 'empfaenger' => 'Stadtwerke Nachtrag GmbH', ]); $trifftSplit = Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-08-11', 'betrag' => -100.00, 'empfaenger' => 'Vermieter Nachtrag GmbH', ]); $trifftNicht = Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-08-11', 'betrag' => -12.00, 'empfaenger' => 'Ohne jede Regel', ]); $ergebnis = Bankabgleich::regelnAufBuchungenOhneKategorieAnwenden(); $this->assertEquals($this->strom, (int)Buchung::find($trifft)['kategorie_id'], 'Die Kategorie wurde nachgetragen'); $this->assertNull(Buchung::find($trifftNicht)['kategorie_id'], 'Ohne Regel bleibt die Buchung unverändert'); $splitBuchung = Buchung::find($trifftSplit); $this->assertEquals(1, (int)$splitBuchung['gesplittet'], 'Eine Splitregel macht die Buchung zur Splitbuchung'); $splits = Buchung::splits($trifftSplit); $this->assertEquals(2, count($splits), 'Zwei Splitzeilen aus der Regel'); $this->assertEquals(-50.00, (float)$splits[0]['betrag'], 'Je zur Hälfte aufgeteilt'); $luecken = array_map('intval', array_column(Buchung::ohneKategorie(), 'id')); $this->assert(in_array($trifftNicht, $luecken, true), 'Bleibt ohne Regeltreffer in der Lückenliste'); $this->assert(!in_array($trifft, $luecken, true), 'Verlässt die Lückenliste, sobald sie kategorisiert ist'); $this->assertGreaterThan($treffervorher, (int)AbgleichRegel::find($regelId)['treffer'], 'Der Treffer wird gezählt'); $this->assert($ergebnis['kategorisiert'] >= 2, 'Mindestens die zwei passenden Buchungen wurden kategorisiert'); AbgleichRegel::loeschen($regelId); AbgleichRegel::loeschen($splitRegelId); } private function verwerfenUndZuruecksetzen(): void { $id = $this->umsatzAnlegen([ 'buchungstag' => '2026-08-06', 'betrag' => -3.50, 'empfaenger' => 'Doppelbuchung', 'verwendungszweck' => 'Storniert', ]); Bankumsatz::verwerfen((int)$id); $this->assertEquals('verworfen', Bankumsatz::find((int)$id)['status'], 'Verworfen ist verworfen'); Bankumsatz::zuruecksetzen((int)$id); $this->assertEquals('offen', Bankumsatz::find((int)$id)['status'], 'Und lässt sich zurückholen'); $this->assertNull(Bankumsatz::find((int)$id)['buchung_id'], 'Ohne Buchungsverweis'); } private function abrufprotokollHaeltDenVerlauf(): void { $id = Bankabruf::beginnen($this->zugang); $this->assertEquals('laeuft', Bankabruf::find($id)['status'], 'Ein neuer Abruf läuft'); $this->assertNull(Bankabruf::find($id)['beendet_am'], 'Und hat noch kein Ende'); Bankabruf::abschliessen($id, 'tan_offen', 0, 0, 'Wartet auf Freigabe.', ['12:00:00 Angemeldet.']); $wartend = Bankabruf::find($id); $this->assertEquals('tan_offen', $wartend['status'], 'Der Abruf wartet auf eine TAN'); $this->assertNull($wartend['beendet_am'], 'Ein wartender Abruf ist nicht beendet'); Bankabruf::abschliessen($id, 'erfolg', 7, 3, '7 neue Umsätze, 3 bereits bekannt.', ['12:01:00 Fertig.']); $fertig = Bankabruf::find($id); $this->assertEquals('erfolg', $fertig['status'], 'Danach ist er erfolgreich'); $this->assertEquals(7, (int)$fertig['neu'], 'Mit der Zahl der neuen Umsätze'); $this->assertNotEmpty($fertig['beendet_am'], 'Und einem Ende'); $this->assert(str_contains((string)$fertig['protokoll'], 'Fertig'), 'Das Protokoll steht dabei'); // Steckengebliebene Abrufe werden aufgeräumt, sonst blockieren sie die Anzeige. $haenger = Bankabruf::beginnen($this->zugang); \Finanzmanager\Database::execute( 'UPDATE bank_abrufe SET begonnen_am = DATE_SUB(NOW(), INTERVAL 5 HOUR) WHERE id = ?', [$haenger] ); $this->assertGreaterThan(0, Bankabruf::raeumeAlteAuf(2), 'Der Hänger wurde aufgeräumt'); $this->assertEquals( 'abgebrochen', Bankabruf::find($haenger)['status'], 'Und gilt als abgebrochen' ); } }