mandantAnlegen(); $this->stammdaten(); $this->zuordnungZiehtOffeneUmsaetzeNach(); $this->automatischesBuchenLegtBuchungenAn(); $this->automatischesBuchenVerbindetStattZuDoppeln(); $this->umsaetzeOhneKontoBleibenLiegen(); $this->einZeitverriegelterUmsatzBrichtDenLaufNichtAb(); $this->abgeschalteteAutomatikBuchtNicht(); $this->allesUebernehmenBleibtZurueckhaltend(); $this->unkategorisierteZaehlenJeKonto(); $this->kontenuebersichtKenntDieOffenenBuchungen(); $this->dieEinrichtungEndetInDerUebersicht(); } private function stammdaten(): void { $this->giro = Konto::anlegen([ 'name' => 'Zuordnungsgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, ]); $this->zweitkonto = Konto::anlegen([ 'name' => 'Zuordnungssparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1, ]); $this->strom = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Strom Zuordnung', 'ausgabe'); $this->zugang = Bankzugang::anlegen([ 'name' => 'Zuordnungsbank', 'bankname' => 'Zuordnungsbank AG', 'blz' => '12030000', 'fints_url' => 'https://banking.example.org/fints', 'benutzerkennung' => 'testkunde', 'pin' => 'geheim1234', 'abrufzeitraum' => 'letzter_abruf', 'aktiv' => 1, ]); // So legt der Abruf ein bei der Bank gefundenes Konto an: erst einmal // ohne Zuordnung, denn welches eigene Konto gemeint ist, weiß nur der // Nutzer. $this->bankkonto = (int)Bankzugang::findeOderLegeKontoAn( $this->zugang, 'DE29120300000000202052', '202052', 'Girokonto' )['id']; $this->zweitesBankkonto = (int)Bankzugang::findeOderLegeKontoAn( $this->zugang, 'DE44120300000000202053', '202053', 'Sparkonto' )['id']; Einstellung::set('banking_automatisch_buchen', '1', 'banking'); Einstellung::set('banking_abgleich_tage', '5', 'banking'); } /** @param array $daten */ private function umsatzAnlegen(array $daten): int { $id = Bankumsatz::anlegen([ 'zugang_id' => $this->zugang, 'konto_id' => $daten['konto_id'] ?? null, 'bank_konto_id' => $daten['bank_konto_id'] ?? $this->bankkonto, 'extern_id' => Bankumsatz::externId( (int)($daten['bank_konto_id'] ?? $this->bankkonto), (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', ]); $this->assertNotEmpty($id, 'Umsatz angelegt: ' . ($daten['verwendungszweck'] ?? '')); return (int)$id; } private function heute(string $tage = ''): string { return (new \DateTimeImmutable($tage === '' ? 'today' : 'today ' . $tage))->format('Y-m-d'); } /** * Der Kern des dritten Einrichtungsschritts: Umsätze, die vor der Zuordnung * hereinkamen, dürfen nicht ohne Konto stehen bleiben – sonst ließen sie * sich nie buchen. */ private function zuordnungZiehtOffeneUmsaetzeNach(): void { $offen = $this->umsatzAnlegen([ 'buchungstag' => $this->heute('-3 days'), 'betrag' => -49.90, 'empfaenger' => 'Stadtwerke Zuordnung', 'verwendungszweck' => 'Abschlag Strom', ]); $this->assertNull( Bankumsatz::find($offen)['konto_id'], 'Vor der Zuordnung hat der Umsatz kein Konto' ); Bankzugang::ordneKontoZu($this->bankkonto, $this->giro); $this->assertEquals( $this->giro, (int)Bankumsatz::find($offen)['konto_id'], 'Mit der Zuordnung bekommt der offene Umsatz sein Konto' ); // Ein Umsatz des anderen Bankkontos darf davon nichts abbekommen. $fremd = $this->umsatzAnlegen([ 'bank_konto_id' => $this->zweitesBankkonto, 'buchungstag' => $this->heute('-3 days'), 'betrag' => -7.10, 'empfaenger' => 'Sparkonto', 'verwendungszweck' => 'Anderes Bankkonto', ]); $this->assertNull( Bankumsatz::find($fremd)['konto_id'], 'Die Zuordnung greift nur auf das Bankkonto, um das es geht' ); Bankzugang::ordneKontoZu($this->zweitesBankkonto, $this->zweitkonto); $this->assertEquals( $this->zweitkonto, (int)Bankumsatz::find($fremd)['konto_id'], 'Und dann auch dort' ); // Übernommene bleiben, wo sie sind: Ihre Buchung steht bereits in einem // Kontoblatt, ein Kontowechsel würde sie dort wegziehen. $gebucht = $this->umsatzAnlegen([ 'konto_id' => $this->giro, 'buchungstag' => $this->heute('-2 days'), 'betrag' => -11.00, 'empfaenger' => 'Bäcker', 'verwendungszweck' => 'Schon gebucht', ]); Bankumsatz::uebernehmen($gebucht, ['kategorie_id' => $this->strom]); Bankzugang::ordneKontoZu($this->bankkonto, $this->zweitkonto); $this->assertEquals( $this->giro, (int)Bankumsatz::find($gebucht)['konto_id'], 'Ein übernommener Umsatz wandert nicht mit' ); Bankzugang::ordneKontoZu($this->bankkonto, $this->giro); } private function automatischesBuchenLegtBuchungenAn(): void { AbgleichRegel::anlegen([ 'name' => 'Strom Zuordnung', 'feld' => 'verwendungszweck', 'vergleich' => 'enthaelt', 'muster' => 'Abschlag Strom', 'kategorie_id' => $this->strom, 'prioritaet' => 10, 'aktiv' => 1, ]); $ohneRegel = $this->umsatzAnlegen([ 'konto_id' => $this->giro, 'buchungstag' => $this->heute('-1 day'), 'betrag' => -23.45, 'empfaenger' => 'Unbekannt GmbH', 'verwendungszweck' => 'Keine Regel trifft', ]); $vorher = Buchung::anzahl(); $ergebnis = Bankabgleich::automatisch(); $this->assertNotEmpty($ergebnis, 'Eingeschaltet läuft der automatische Abgleich'); $this->assertEquals(0, $ergebnis['offen'], 'Nichts bleibt liegen'); $this->assertEquals( $vorher + $ergebnis['uebernommen'], Buchung::anzahl(), 'Für jeden übernommenen Umsatz steht eine Buchung mehr da' ); // Der Umsatz mit Regel bekommt seine Kategorie … $mitRegel = Bankumsatz::liste(['status' => 'uebernommen']); $strom = null; foreach ($mitRegel as $eintrag) { if (str_contains((string)$eintrag['verwendungszweck'], 'Abschlag Strom')) { $strom = $eintrag; } } $this->assertNotEmpty($strom, 'Der Umsatz mit Regel ist übernommen'); $this->assertEquals( $this->strom, (int)Buchung::find((int)$strom['buchung_id'])['kategorie_id'], 'Die Kategorie kommt aus der Regel' ); // … der ohne Regel wird trotzdem gebucht. Genau dafür ist die Automatik // da: Der Umsatz soll im Kontoblatt stehen, die Kategorie kommt später. $buchungOhneRegel = Buchung::find((int)Bankumsatz::find($ohneRegel)['buchung_id']); $this->assertEquals( 'uebernommen', Bankumsatz::find($ohneRegel)['status'], 'Auch ohne Regel wird gebucht' ); $this->assertNull($buchungOhneRegel['kategorie_id'], 'Aber ohne Kategorie'); $this->assertEquals($this->giro, (int)$buchungOhneRegel['konto_id'], 'Im zugeordneten Konto'); $this->assertEquals('fints', $buchungOhneRegel['quelle'], 'Und mit dem Bankabruf als Quelle'); } /** Wer seine Buchungen selbst erfasst, will sie nicht doppelt sehen. */ private function automatischesBuchenVerbindetStattZuDoppeln(): void { $buchungId = Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => $this->heute('-4 days'), 'betrag' => -128.00, 'empfaenger' => 'Versicherung', 'kategorie_id' => $this->strom, ]); $umsatz = $this->umsatzAnlegen([ 'konto_id' => $this->giro, 'buchungstag' => $this->heute('-2 days'), 'betrag' => -128.00, 'empfaenger' => 'Versicherung AG', 'verwendungszweck' => 'Beitrag', ]); $vorher = Buchung::anzahl(); $ergebnis = Bankabgleich::automatisch(); $this->assertEquals(1, $ergebnis['verbunden'], 'Der Umsatz wurde verbunden'); $this->assertEquals($vorher, Buchung::anzahl(), 'Und keine zweite Buchung angelegt'); $this->assertEquals( $buchungId, (int)Bankumsatz::find($umsatz)['buchung_id'], 'Er zeigt auf die selbst erfasste Buchung' ); $this->assertEquals( 1, (int)Buchung::find($buchungId)['abgeglichen'], 'Die gilt damit als abgeglichen' ); } private function umsaetzeOhneKontoBleibenLiegen(): void { // Ein Bankkonto ohne Zuordnung – etwa ein Depot, das nicht mitgeführt wird. $ohne = Bankzugang::findeOderLegeKontoAn($this->zugang, 'DE60120300000000202054', '202054', 'Ohne'); $umsatz = $this->umsatzAnlegen([ 'bank_konto_id' => (int)$ohne['id'], 'buchungstag' => $this->heute('-1 day'), 'betrag' => -9.99, 'empfaenger' => 'Ohne Zuordnung', 'verwendungszweck' => 'Bleibt offen', ]); $ergebnis = Bankabgleich::automatisch(); $this->assertEquals(1, $ergebnis['offen'], 'Ein Umsatz ohne Konto bleibt offen'); $this->assertEquals( 'offen', Bankumsatz::find($umsatz)['status'], 'Und steht weiter in der Vorschlagsliste' ); $this->assertEquals([], $ergebnis['fehler'], 'Ohne Fehlermeldung – das ist kein Fehler'); } /** * Ein Umsatz, der sich nicht buchen lässt, darf die übrigen nicht mitreißen. * Der abgeschlossene Monat ist der greifbarste Fall davon. */ private function einZeitverriegelterUmsatzBrichtDenLaufNichtAb(): void { $jahr = (int)date('Y') - 1; $datum = sprintf('%d-03-15', $jahr); Monatsabschluss::abschliessen($jahr, 3, 'Test'); $verriegelt = $this->umsatzAnlegen([ 'konto_id' => $this->giro, 'buchungstag' => $datum, 'betrag' => -31.00, 'empfaenger' => 'Aus dem Archiv', 'verwendungszweck' => 'Abgeschlossener Monat', ]); $daneben = $this->umsatzAnlegen([ 'konto_id' => $this->giro, 'buchungstag' => $this->heute(), 'betrag' => -6.60, 'empfaenger' => 'Heute', 'verwendungszweck' => 'Geht durch', ]); $ergebnis = Bankabgleich::automatisch(); $this->assertEquals(1, count($ergebnis['fehler']), 'Ein Umsatz ließ sich nicht buchen'); $this->assertEquals( 'offen', Bankumsatz::find($verriegelt)['status'], 'Er bleibt offen und wartet auf die Wiedereröffnung' ); $this->assertEquals( 'uebernommen', Bankumsatz::find($daneben)['status'], 'Der Umsatz daneben wurde trotzdem gebucht' ); $this->assert( str_contains(Bankabgleich::bericht($ergebnis), 'Nicht gebucht'), 'Der Bericht nennt den Fall' ); $abschluss = Monatsabschluss::fuerMonat($jahr, 3); Monatsabschluss::wiedereroeffnen((int)$abschluss['id']); } private function abgeschalteteAutomatikBuchtNicht(): void { Einstellung::set('banking_automatisch_buchen', '0', 'banking'); $umsatz = $this->umsatzAnlegen([ 'konto_id' => $this->giro, 'buchungstag' => $this->heute(), 'betrag' => -4.20, 'empfaenger' => 'Kiosk', 'verwendungszweck' => 'Von Hand abgleichen', ]); $this->assertNull(Bankabgleich::automatisch(), 'Abgeschaltet läuft nichts'); $this->assertEquals( 'offen', Bankumsatz::find($umsatz)['status'], 'Der Umsatz wartet auf den Abgleich' ); Einstellung::set('banking_automatisch_buchen', '1', 'banking'); } /** * Der Knopf „Alles übernehmen“ ist zurückhaltender als die Automatik: Ohne * Regel bleibt der Umsatz liegen, damit die Zuordnung beim Nutzer bleibt. */ private function allesUebernehmenBleibtZurueckhaltend(): void { $ergebnis = Bankabgleich::laufenLassen(false); $this->assertEquals(0, $ergebnis['uebernommen'], 'Ohne Regel wird nichts gebucht'); $this->assertGreaterThan(0, $ergebnis['offen'], 'Die Umsätze bleiben offen'); // Mit Regel greift derselbe Lauf dann doch. AbgleichRegel::anlegen([ 'name' => 'Kiosk Zuordnung', 'feld' => 'empfaenger', 'vergleich' => 'enthaelt', 'muster' => 'Kiosk', 'kategorie_id' => $this->strom, 'prioritaet' => 10, 'aktiv' => 1, ]); $mitRegel = Bankabgleich::laufenLassen(false); $this->assertEquals(1, $mitRegel['uebernommen'], 'Mit Regel wird gebucht'); } private function unkategorisierteZaehlenJeKonto(): void { $jahr = (int)date('Y'); $vorher = Buchung::unkategorisierteJeKonto($jahr)[$this->giro] ?? 0; Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => $this->heute(), 'betrag' => -15.00, 'empfaenger' => 'Ohne Kategorie', ]); $this->assertEquals( $vorher + 1, Buchung::unkategorisierteJeKonto($jahr)[$this->giro] ?? 0, 'Eine Buchung ohne Kategorie zählt mit' ); // Eine Umbuchung ist zugeordnet, auch ohne Kategorie. Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => $this->heute(), 'betrag' => -100.00, 'empfaenger' => 'Umbuchung', 'gegenkonto_id' => $this->zweitkonto, ]); // Eine Splitbuchung trägt ihre Zuordnung in den Zeilen. Buchung::anlegen( [ 'konto_id' => $this->giro, 'datum' => $this->heute(), 'betrag' => -50.00, 'empfaenger' => 'Gesplittet', ], [ ['kategorie_id' => $this->strom, 'betrag' => -30.00], ['kategorie_id' => $this->strom, 'betrag' => -20.00], ] ); $this->assertEquals( $vorher + 1, Buchung::unkategorisierteJeKonto($jahr)[$this->giro] ?? 0, 'Umbuchung und Splitbuchung zählen nicht als unkategorisiert' ); // Das Vorjahr bleibt außen vor – die Übersicht zeigt das laufende Jahr. Database::execute( 'UPDATE buchungen SET datum = ? WHERE benutzer_id = ? AND konto_id = ? AND kategorie_id IS NULL AND gegenkonto_id IS NULL AND gesplittet = 0', [($jahr - 1) . '-06-15', \Finanzmanager\Mandant::id(), $this->giro] ); $this->assertEquals( 0, Buchung::unkategorisierteJeKonto($jahr)[$this->giro] ?? 0, 'Buchungen aus dem Vorjahr zählen im laufenden Jahr nicht' ); $this->assertGreaterThan( 0, Buchung::unkategorisierteJeKonto($jahr - 1)[$this->giro] ?? 0, 'Dafür im Vorjahr' ); } private function kontenuebersichtKenntDieOffenenBuchungen(): void { Buchung::anlegen([ 'konto_id' => $this->zweitkonto, 'datum' => $this->heute(), 'betrag' => -8.00, 'empfaenger' => 'Frisch abgerufen', 'quelle' => 'fints', ]); $uebersicht = Konto::uebersicht(); $gefunden = []; foreach ($uebersicht['gruppen'] as $gruppe) { foreach ($gruppe['konten'] as $konto) { $gefunden[(int)$konto['id']] = $konto; } } $this->assertNotEmpty($gefunden[$this->zweitkonto] ?? null, 'Das Konto steht in der Übersicht'); $this->assertGreaterThan( 0, (int)$gefunden[$this->zweitkonto]['unkategorisiert'], 'Und meldet seine Buchung ohne Kategorie – die Übersicht setzt es fett' ); $this->assertEquals( 0, (int)($gefunden[$this->giro]['unkategorisiert'] ?? -1), 'Das aufgeräumte Konto bleibt normal' ); } /** * Der letzte Schritt der Einrichtung führt zurück in die Übersicht der * Bankverbindungen, nicht in die Zugangsmaske: Dort ist alles erledigt, was * sie anbietet. */ private function dieEinrichtungEndetInDerUebersicht(): void { $this->assertEquals( '/banking', BankingController::zielNachZuordnung($this->zugang, true), 'Nach der Einrichtung geht es in die Übersicht der Bankverbindungen' ); $this->assertEquals( '/banking/' . $this->zugang . '/konten', BankingController::zielNachZuordnung($this->zugang, false), 'Die einzeln aufgerufene Zuordnung bleibt, wo sie ist' ); } }