mandantAnlegen(); $this->stammdaten(); $this->verschluesselungSchuetztDieZugangsdaten(); $this->zugangHaeltPinBeimSpeichern(); $this->tanVerfahrenLaesstSichWaehlen(); $this->tanVerfahrenUeberstehtDasSpeichern(); $this->zeitraumRichtetSichNachDemLetztenAbruf(); $this->stichtagBestimmtDenAbrufbeginn(); $this->umsaetzeKommenNurEinmalHerein(); $this->regelnGreifenNachPrioritaet(); $this->regelnRechnenSplitsAus(); $this->ungueltigesMusterWirdAbgewiesen(); $this->abgleichFindetVorhandeneBuchung(); $this->uebernehmenErzeugtEineBuchung(); $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); } 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' ); } 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' ); } 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' ); } }