mandantAnlegen(); $this->stammdaten(); $this->buchungen(); $this->csvTabelleLiestUndSchreibt(); $this->csvExportFuehrtSplitsUndUmbuchungen(); $this->filterGreifenEinzelnUndGemeinsam(); $this->qifExportTraegtDenDialektDesFinanzManagers(); $this->qifRundlaufUeberEinenZweitenMandanten(); $this->csvRundlaufUeberEinenDrittenMandanten(); $this->csvImportErkenntDublettenUndFehler(); $this->housekeepingRaeumtAlteDateienWeg(); $this->dateinamenUndFormate(); $this->exportLegtKeineDateiAb(); $this->verwaltungFuehrtImportUndExport(); } // ── Aufbau ─────────────────────────────────────────────────────────────── private function stammdaten(): void { $this->giro = Konto::anlegen([ 'name' => 'Exportgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, ]); $this->tagesgeld = Konto::anlegen([ 'name' => 'Exportrücklage', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1, ]); $this->strom = Kategorie::findenOderAnlegenNachPfad('Wohnen:Strom'); $this->wasser = Kategorie::findenOderAnlegenNachPfad('Wohnen:Wasser'); $this->haus = Klasse::findenOderAnlegen('Haus 1'); $this->privat = Klasse::findenOderAnlegen('Privat'); } private function buchungen(): void { // Eine gewöhnliche Buchung mit Kategorie und Klasse … Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-03-18', 'betrag' => -84.00, 'empfaenger' => 'Stadtwerke', 'buchungstext' => 'Abschlag März', 'nummer' => '042', 'kategorie_id' => $this->strom, 'klasse_id' => $this->haus, 'abgeglichen' => 1, ]); // … eine Umbuchung auf das zweite Konto … Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-03-20', 'betrag' => -500.00, 'empfaenger' => 'Eigenübertrag', 'buchungstext' => 'Rücklage', 'gegenkonto_id' => $this->tagesgeld, ]); // … und eine Splitbuchung, deren Zeilen zwei Kategorien tragen. Buchung::anlegen( [ 'konto_id' => $this->giro, 'datum' => '2026-04-02', 'betrag' => -110.00, 'empfaenger' => 'Versorger', 'buchungstext' => 'Jahresabrechnung', ], [ ['kategorie_id' => $this->strom, 'klasse_id' => $this->haus, 'betrag' => -80.00, 'notiz' => 'Strom'], ['kategorie_id' => $this->wasser, 'klasse_id' => $this->privat, 'betrag' => -30.00, 'notiz' => 'Wasser'], ] ); } // ── Das Dateiformat selbst ─────────────────────────────────────────────── private function csvTabelleLiestUndSchreibt(): void { $text = CsvTabelle::schreibe([ ['Feld', 'Mit;Trenner', 'Mit"Zitat'], ['Wert', 'zwei', 'drei'], ]); $this->assert(str_starts_with($text, CsvTabelle::BOM), 'Die Datei beginnt mit einem BOM'); $this->assert(str_contains($text, '"Mit;Trenner"'), 'Ein Feld mit Trenner wird maskiert'); $this->assert(str_contains($text, '"Mit""Zitat"'), 'Anführungszeichen werden verdoppelt'); $this->assert(str_contains($text, "\r\n"), 'Zeilen enden mit CRLF'); $zeilen = CsvTabelle::lies($text); $this->assertEquals(2, count($zeilen), 'Die geschriebene Datei liest sich wieder ein'); $this->assertEquals('Mit;Trenner', $zeilen[0][1], 'Der maskierte Trenner überlebt den Rundlauf'); $this->assertEquals('Mit"Zitat', $zeilen[0][2], 'Das Anführungszeichen überlebt den Rundlauf'); // Fremde Dateien: Komma als Trenner, Punkt als Dezimalzeichen. $fremd = CsvTabelle::datensaetze("Datum,Konto,Betrag\r\n2026-03-18,Giro,\"1,234.56\"\r\n"); $this->assertEquals(1, count($fremd), 'Auch eine Datei mit Komma-Trenner wird gelesen'); $this->assertEquals('2026-03-18', $fremd[0]['datum'], 'Die Kopfzeile wird zum Schlüssel'); $this->assertEquals(1234.56, CsvTabelle::betrag('1,234.56'), 'Betrag im englischen Format'); $this->assertEquals(-1234.56, CsvTabelle::betrag('-1.234,56'), 'Betrag im deutschen Format'); $this->assertEquals(-84.0, CsvTabelle::betrag('-84,00 €'), 'Währungszeichen stört nicht'); $this->assertEquals(0.0, CsvTabelle::betrag(''), 'Ein leeres Feld ist null'); $this->assertEquals('2026-03-18', CsvTabelle::datum('18.03.2026'), 'Datum als TT.MM.JJJJ'); $this->assertEquals('2026-03-18', CsvTabelle::datum('2026-03-18'), 'Datum als ISO'); $this->assertEquals('1998-03-18', CsvTabelle::datum('18.03.98'), 'Zweistellige Jahre ab 70 sind 19xx'); $this->assertNull(CsvTabelle::datum('31.02.2026'), 'Ein Datum, das es nicht gibt, fällt heraus'); $this->assertNull(CsvTabelle::datum('irgendwann'), 'Text ist kein Datum'); } // ── Export ─────────────────────────────────────────────────────────────── private function csvExportFuehrtSplitsUndUmbuchungen(): void { $csv = BuchungsExport::csv(BuchungsExport::buchungen([])); $zeilen = CsvTabelle::lies($csv); $this->assertEquals(BuchungsExport::SPALTEN, $zeilen[0], 'Die Kopfzeile nennt alle Spalten'); // Drei Buchungen, dazu zwei Splitzeilen – plus Kopfzeile. $arten = array_count_values(array_column($zeilen, 0)); $this->assertEquals(3, $arten[BuchungsExport::ART_BUCHUNG] ?? 0, 'Drei Buchungen im Export'); $this->assertEquals(2, $arten[BuchungsExport::ART_SPLIT] ?? 0, 'Zwei Splitzeilen im Export'); $this->assert(str_contains($csv, '18.03.2026'), 'Das Datum steht deutsch in der Datei'); $this->assert(str_contains($csv, '-84,00'), 'Der Betrag steht mit Komma in der Datei'); $this->assert(str_contains($csv, 'Wohnen:Strom'), 'Die Kategorie steht als Pfad in der Datei'); $this->assert(str_contains($csv, 'Exportrücklage'), 'Die Umbuchung nennt ihr Gegenkonto'); // Die Splitzeilen folgen unmittelbar auf ihre Buchung. $split = null; foreach ($zeilen as $nr => $zeile) { if ($zeile[0] === BuchungsExport::ART_SPLIT) { $split = $nr; break; } } $this->assertNotEmpty($split, 'Es gibt eine Splitzeile'); $this->assertEquals( BuchungsExport::ART_BUCHUNG, $zeilen[$split - 1][0], 'Über der ersten Splitzeile steht ihre Buchung' ); } private function filterGreifenEinzelnUndGemeinsam(): void { // Gezählt wird über COUNT, geladen über dieselbe Bedingung – geprüft // wird deshalb beides gegeneinander. $anzahl = function (array $filter): int { $geladen = count(BuchungsExport::buchungen($filter)); $this->assertEquals($geladen, BuchungsExport::anzahl($filter), 'Zählen und Laden sind sich einig'); return $geladen; }; $this->assertEquals(3, $anzahl([]), 'Ohne Filter kommt alles mit'); $this->assertEquals(2, $anzahl(['von' => '2026-03-19']), 'Der Zeitraum grenzt nach vorn ab'); $this->assertEquals(2, $anzahl(['bis' => '2026-03-31']), 'Der Zeitraum grenzt nach hinten ab'); $this->assertEquals(1, $anzahl(['von' => '2026-03-19', 'bis' => '2026-03-31']), 'Beide Grenzen zusammen'); // Das Tagesgeld führt selbst keine Buchung – die Umbuchung steht beim // Girokonto. Sie muss trotzdem kommen, sonst fehlte dem Auszug die // einzige Bewegung des Kontos. $this->assertEquals( 1, $anzahl(['konten' => [$this->tagesgeld]]), 'Der Kontofilter holt die Umbuchung von der anderen Seite' ); $this->assertEquals(3, $anzahl(['konten' => [$this->giro]]), 'Das erfassende Konto führt alle drei'); // Die Kategorie steckt bei der Splitbuchung allein in den Zeilen. $this->assertEquals(2, $anzahl(['kategorien' => [$this->strom]]), 'Kategorie: Buchung und Splitzeile'); $this->assertEquals(1, $anzahl(['kategorien' => [$this->wasser]]), 'Kategorie nur in einer Splitzeile'); $this->assertEquals(2, $anzahl(['klassen' => [$this->haus]]), 'Klasse: Buchung und Splitzeile'); $this->assertEquals(1, $anzahl(['klassen' => [$this->privat]]), 'Klasse nur in einer Splitzeile'); $this->assertEquals( 1, $anzahl(['kategorien' => [$this->strom], 'bis' => '2026-03-31']), 'Kategorie und Zeitraum wirken zusammen' ); $this->assertEquals(0, $anzahl(['von' => '2027-01-01']), 'Ein leerer Zeitraum liefert nichts'); } private function qifExportTraegtDenDialektDesFinanzManagers(): void { $qif = BuchungsExport::qif(BuchungsExport::buchungen([])); $this->assert(str_contains($qif, '!Type:Class'), 'Das QIF führt die Klassen'); $this->assert(str_contains($qif, '!Type:Cat'), 'Das QIF führt die Kategorien'); $this->assert(str_contains($qif, '!Option:AutoSwitch'), 'Das QIF führt die Kontenliste'); $this->assert(str_contains($qif, 'T-84.00'), 'Beträge stehen im QIF im US-Format'); $this->assert(str_contains($qif, 'D18.03.2026'), 'Das Datum folgt der Angabe !Option:DMY'); $this->assert(str_contains($qif, 'LWohnen:Strom/Haus 1'), 'Kategorie und Klasse stehen in einer L-Zeile'); $this->assert(str_contains($qif, 'L[Exportrücklage]'), 'Die Umbuchung nennt ihr Gegenkonto in Klammern'); $this->assert(str_contains($qif, 'SWohnen:Wasser/Privat'), 'Die Splitzeile trägt Kategorie und Klasse'); $this->assert(str_contains($qif, '$-30.00'), 'Die Splitzeile trägt ihren Betrag'); $parser = new QifParser($qif); $konten = QifBuchungsImport::kontenDerDatei($parser); $this->assertEquals(3, $parser->zusammenfassung()['Bank'] ?? 0, 'Der eigene Parser findet drei Buchungen'); $this->assert(isset($konten['Exportgiro']), 'Der Parser erkennt das Girokonto'); $this->assert( isset($konten['Exportrücklage']), 'Auch das Umbuchungsziel steht in der Kontenliste des Parsers' ); } // ── Import ─────────────────────────────────────────────────────────────── /** * Der Rundlauf über einen frischen Mandanten: Stammdaten und Buchungen * müssen sich aus der Exportdatei allein wiederherstellen lassen. */ private function qifRundlaufUeberEinenZweitenMandanten(): void { $qif = BuchungsExport::qif(BuchungsExport::buchungen([])); $this->mandantAnlegen(); $stammdaten = (new QifStammdatenImport(new QifParser($qif)))->ausfuehren(); $this->assertEquals(2, $stammdaten['konten']['neu'], 'Beide Konten entstehen aus der Datei'); $this->assertEquals(2, $stammdaten['klassen']['neu'], 'Beide Klassen entstehen aus der Datei'); $this->assertGreaterThan(2, $stammdaten['kategorien']['neu'], 'Der Kategoriebaum entsteht mit'); $lauf = ['stichtag' => '2026-01-01', 'bis' => '2026-12-31']; $ergebnis = (new QifBuchungsImport(new QifParser($qif), $lauf))->ausfuehren(); $this->assertEquals(3, $ergebnis['buchungen']['neu'], 'Alle drei Buchungen kommen an'); $this->assertEquals(0, $ergebnis['buchungen']['fehler'], 'Der Rundlauf läuft fehlerfrei'); $this->assertEquals(3, Buchung::anzahl(), 'Drei Buchungen stehen in der Datenbank'); // Der zweite Lauf derselben Datei darf nichts verdoppeln. $zweiter = (new QifBuchungsImport(new QifParser($qif), $lauf))->ausfuehren(); $this->assertEquals(0, $zweiter['buchungen']['neu'], 'Der zweite Lauf legt nichts an'); $this->assertEquals(3, $zweiter['buchungen']['vorhanden'], 'Alle drei gelten als vorhanden'); // Umbuchung und Splitbuchung müssen den Weg heil überstanden haben. [$umbuchung, $split] = $this->umbuchungUndSplit(); $this->assertNotEmpty($umbuchung, 'Die Umbuchung kam als Umbuchung an'); $this->assertEquals(2, count($split['splits'] ?? []), 'Die Splitbuchung hat wieder zwei Zeilen'); $this->assertEquals( 'Wohnen:Wasser', $split['splits'][1]['kategoriepfad'], 'Die zweite Splitzeile trägt ihre Kategorie' ); } private function csvRundlaufUeberEinenDrittenMandanten(): void { $csv = BuchungsExport::csv(BuchungsExport::buchungen([])); // Ein weiterer Mandant mit gleichnamigen Konten: So lässt sich prüfen, // dass der Import wirklich anlegt, statt nur Dubletten zu zählen. $this->mandantAnlegen(); Konto::anlegen(['name' => 'Exportgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1]); Konto::anlegen(['name' => 'Exportrücklage', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1]); $vorschau = (new CsvBuchungsImport($csv))->vorschau(); $this->assertEquals(3, $vorschau['buchungen']['neu'], 'Der Trockenlauf zählt drei neue Buchungen'); $this->assertEquals(0, Buchung::anzahl(), 'Der Trockenlauf hat nichts gespeichert'); $ergebnis = (new CsvBuchungsImport($csv))->ausfuehren(); $this->assertEquals(3, $ergebnis['buchungen']['neu'], 'Der echte Lauf legt drei Buchungen an'); $this->assertEquals(2, $ergebnis['buchungen']['splitzeilen'], 'Beide Splitzeilen kommen mit'); $this->assertEquals(0, $ergebnis['buchungen']['fehler'], 'Der Import läuft fehlerfrei'); $this->assertEquals(3, Buchung::anzahl(), 'Drei Buchungen stehen in der Datenbank'); // Kategorien und Klassen bringt der Import mit, Konten nicht. $this->assertNotEmpty(Kategorie::findeNachPfad('Wohnen:Strom'), 'Die Kategorie wurde ergänzt'); $this->assertNotEmpty(Klasse::findeNachName('Haus 1'), 'Die Klasse wurde ergänzt'); $wieder = BuchungsExport::csv(BuchungsExport::buchungen([])); $this->assertEquals( count(CsvTabelle::lies($csv)), count(CsvTabelle::lies($wieder)), 'Der erneute Export hat genauso viele Zeilen wie der erste' ); // Umbuchung und Splitbuchung müssen den Weg heil überstanden haben. [$umbuchung, $split] = $this->umbuchungUndSplit(); $this->assertNotEmpty($umbuchung, 'Die Umbuchung kam als Umbuchung an'); $this->assertNotEmpty($split, 'Die Splitbuchung kam mit Zeilen an'); $this->assertEquals(2, count($split['splits'] ?? []), 'Die Splitbuchung hat wieder zwei Zeilen'); $this->assertEquals('Wohnen:Wasser', $split['splits'][1]['kategoriepfad'], 'Die zweite Zeile trägt ihre Kategorie'); $this->assertEquals('Privat', $split['splits'][1]['klassenname'], 'Die zweite Zeile trägt ihre Klasse'); } private function csvImportErkenntDublettenUndFehler(): void { $csv = BuchungsExport::csv(BuchungsExport::buchungen([])); $zweiter = (new CsvBuchungsImport($csv))->ausfuehren(); $this->assertEquals(0, $zweiter['buchungen']['neu'], 'Der zweite Lauf legt nichts an'); $this->assertEquals(3, $zweiter['buchungen']['vorhanden'], 'Alle drei gelten als vorhanden'); $this->assertEquals(3, Buchung::anzahl(), 'Die Zahl der Buchungen bleibt gleich'); // Ein unbekanntes Konto legt der Import nicht an – die Zeile fällt aus. $fremd = "Art;Datum;Konto;Betrag\r\nBuchung;01.05.2026;Gibtesnicht;-1,00\r\n"; $lauf = (new CsvBuchungsImport($fremd))->vorschau(); $this->assertEquals(1, $lauf['buchungen']['fehler'], 'Ein unbekanntes Konto ist ein Fehler'); $this->assertEquals(0, $lauf['buchungen']['neu'], 'Aus der Zeile wird keine Buchung'); $this->assertNotEmpty($lauf['meldungen'], 'Das Protokoll nennt den Grund'); // Ohne Pflichtspalte lässt sich gar nichts einlesen. $ohneKonto = "Art;Datum;Betrag\r\nBuchung;01.05.2026;-1,00\r\n"; $meldung = ''; try { (new CsvBuchungsImport($ohneKonto))->vorschau(); } catch (\Throwable $e) { $meldung = $e->getMessage(); } $this->assert(str_contains($meldung, 'Spalten'), 'Eine fehlende Pflichtspalte bricht den Lauf ab'); // Eine Splitzeile ohne Buchung davor ist ein Fehler, kein stiller Verlust. $verwaist = "Art;Datum;Konto;Betrag\r\nSplitzeile;;;-1,00\r\n"; $lauf = (new CsvBuchungsImport($verwaist))->vorschau(); $this->assertEquals(1, $lauf['buchungen']['fehler'], 'Eine verwaiste Splitzeile wird gemeldet'); } // ── Housekeeping ───────────────────────────────────────────────────────── private function housekeepingRaeumtAlteDateienWeg(): void { // Ein eigenes Verzeichnis unterhalb des Importverzeichnisses: Housekeeping // arbeitet auf einem Pfad relativ zum Projekt, und der Aufräumer der // Testbasis löscht nur dort (und im Temp-Verzeichnis) etwas. $name = 'hk_' . bin2hex(random_bytes(4)); $verzeichnis = $this->registriereTempPfad(\Finanzmanager\Config::get('import_dir') . '/' . $name); mkdir($verzeichnis, 0o775, true); $alt = $this->registriereTempPfad($verzeichnis . '/alt.qif'); $neu = $this->registriereTempPfad($verzeichnis . '/neu.qif'); file_put_contents($alt, '!Type:Bank'); file_put_contents($neu, '!Type:Bank'); touch($alt, time() - 30 * 86400); // Das überwachte Verzeichnis kommt aus den Einstellungen; für den Test // zeigt es auf den frisch angelegten Pfad. Der Rollback dreht die // Einstellung wieder zurück. Einstellung::set('import_qif_verzeichnis', 'data/import/' . $name, 'import'); Einstellung::set(Housekeeping::EINSTELLUNG_TAGE, '14', 'import'); Einstellung::set(Housekeeping::EINSTELLUNG_LAUF, '', 'import'); $this->assertEquals(14, Housekeeping::tage(), 'Die Frist kommt aus den Einstellungen'); $this->assert(Housekeeping::faellig(), 'Ohne vorherigen Lauf ist das Aufräumen fällig'); $probe = Housekeeping::ausfuehren(true); $this->assertEquals(1, $probe['geloescht'], 'Der Probelauf zählt die alte Datei'); $this->assert(is_file($alt), 'Der Probelauf löscht nichts'); $ergebnis = Housekeeping::ausfuehren(); $this->assertEquals(1, $ergebnis['geloescht'], 'Der echte Lauf löscht die alte Datei'); $this->assertGreaterThan(0, $ergebnis['bytes'], 'Der Lauf meldet den freigewordenen Platz'); $this->assert(!is_file($alt), 'Die alte Datei ist weg'); $this->assert(is_file($neu), 'Die frische Datei bleibt liegen'); $this->assert(!Housekeeping::faellig(), 'Nach dem Lauf steht heute nichts mehr an'); $this->assertNull(Housekeeping::beiGelegenheit(), 'Der beiläufige Lauf hält sich daran'); // Frist 0 schaltet das Aufräumen ab. Einstellung::set(Housekeeping::EINSTELLUNG_TAGE, '0', 'import'); touch($neu, time() - 400 * 86400); $aus = Housekeeping::ausfuehren(); $this->assertEquals(0, $aus['geloescht'], 'Mit Frist 0 wird nichts gelöscht'); $this->assert(is_file($neu), 'Auch eine uralte Datei bleibt dann liegen'); $this->assert(!Housekeeping::faellig(), 'Abgeschaltet ist nie etwas fällig'); } // ── Formate, Dateinamen und Oberfläche ─────────────────────────────────── private function dateinamenUndFormate(): void { $this->assertEquals(['qif', 'csv'], array_keys(Dateiformat::ALLE), 'Zwei Formate, QIF zuerst'); $this->assertEquals('qif', Dateiformat::pruefe('QIF'), 'Die Prüfung ist unempfindlich für Großschreibung'); $this->assertNull(Dateiformat::pruefe('xlsx'), 'Ein fremdes Format fällt heraus'); $this->assertEquals('csv', Dateiformat::ausDateiname('Export 2026.CSV'), 'Das Format steckt in der Endung'); $this->assertNull(Dateiformat::ausDateiname('böse.php'), 'Eine fremde Endung ergibt kein Format'); $this->assertEquals( 'buchungen-2026-01-01-bis-2026-12-31.csv', BuchungsExport::dateiname('csv', ['von' => '2026-01-01', 'bis' => '2026-12-31']), 'Der Dateiname nennt Format und Zeitraum' ); $this->assert( str_ends_with(BuchungsExport::dateiname('qif', []), '.qif'), 'Ohne Zeitraum bleibt wenigstens die Endung richtig' ); // Der Upload nimmt jetzt beide Formate an – und nur die. $this->assertEquals('Export.CSV', ImportController::sichererDateiname('Export.CSV'), 'CSV wird angenommen'); $this->assertEquals('Export.QIF', ImportController::sichererDateiname('Export.QIF'), 'QIF bleibt erlaubt'); $this->assertNull(ImportController::sichererDateiname('böse.php'), 'Fremde Endung wird abgewiesen'); $this->assertEquals( 'passwd.csv', ImportController::sichererDateiname('../../etc/passwd.csv'), 'Pfadanteile fallen auch beim CSV weg' ); } private function exportLegtKeineDateiAb(): void { $vorher = $this->dateienImImportverzeichnis(); $datei = BuchungsExport::datei('csv', []); $this->assertNotEmpty($datei['inhalt'], 'Die Datei entsteht im Speicher'); $this->assertEquals(3, $datei['anzahl'], 'Sie meldet, wie viele Buchungen sie führt'); $this->assert(str_contains($datei['typ'], 'text/csv'), 'Der Inhaltstyp passt zum Format'); $this->assertEquals( $vorher, $this->dateienImImportverzeichnis(), 'Der Export hinterlässt keine Datei auf der Platte' ); $meldung = ''; try { BuchungsExport::datei('xlsx', []); } catch (\Throwable $e) { $meldung = $e->getMessage(); } $this->assertNotEmpty($meldung, 'Ein unbekanntes Format wird abgewiesen'); } private function verwaltungFuehrtImportUndExport(): void { $seite = $this->verwaltungsseite(); $this->assert(str_contains($seite, '