mandantAnlegen(); $this->parserLiestSektionen(); $this->parserKonvertiertZeichensatz(); $this->betragUndDatum(); $this->stammdatenImport(); $this->zweiterLaufAendertNichts(); $this->trockenlaufSchreibtNicht(); $this->dateinamenPruefung(); } private function beispiel(): string { $zeilen = [ '!Option:MDY', '!Type:Class', 'NEhemann', 'DUnterscheidet Ausgaben einzelner Familienmitglieder', '^', 'NPrivat', '^', '!Type:Cat', 'N100 - Gehalt', 'DGleason et al', 'T', 'I', '^', 'N400 - Leben:B-Essen alleine', 'E', '^', 'N400 - Leben:A-Krankheitskosten', 'DVorbeugung+Heilung', 'T', 'R16304', 'E', '^', '!Option:AutoSwitch', '!Account', 'NDKB Girokonto alt', 'TBank', 'DKontonr. 1010903027', '^', 'NADAC LBB Kreditkarte', 'TCCard', 'L3,000.00', '^', 'NDepot comdirect', 'TPort', '^', '!Clear:AutoSwitch', '!Option:AutoSwitch', '!Account', 'NDKB Girokonto alt', 'TBank', 'DKontonr. 1010903027', '^', '!Type:Bank ', 'D3.18.26', 'T-1,241.04', 'PStadtwerke', 'L400 - Leben:B-Essen alleine/Privat', '^', ]; // Der echte Export ist ISO-8859-1 mit CRLF – genau so wird hier geprüft. return mb_convert_encoding(implode("\r\n", $zeilen) . "\r\n", 'ISO-8859-1', 'UTF-8'); } private function parserLiestSektionen(): void { $parser = new QifParser($this->beispiel()); $this->assertEquals('MDY', $parser->datumsformat(), 'Datumsformat aus der Kopfzeile'); $this->assertEquals(2, count($parser->eintraege(QifParser::TYP_KLASSE)), 'Zwei Klassen'); $this->assertEquals(3, count($parser->eintraege(QifParser::TYP_KATEGORIE)), 'Drei Kategorien'); // Das Girokonto steht zweimal in der Datei: in der Kontenliste und als // Kopf seiner Buchungen. $this->assertEquals(4, count($parser->eintraege(QifParser::TYP_KONTO)), 'Vier Kontoeinträge'); $buchungen = $parser->eintraege('Bank'); $this->assertEquals(1, count($buchungen), 'Eine Buchung'); $this->assertEquals( 'DKB Girokonto alt', $buchungen[0]['konto'], 'Die Buchung trägt das zuletzt genannte Konto' ); $this->assertEquals('Stadtwerke', QifParser::feld($buchungen[0], 'P'), 'Zahlungsempfänger'); $kategorien = $parser->eintraege(QifParser::TYP_KATEGORIE); $this->assert(QifParser::hatFeld($kategorien[0], 'I'), 'Erste Kategorie ist eine Einnahme'); $this->assert(QifParser::hatFeld($kategorien[0], 'T'), 'Erste Kategorie ist steuerrelevant'); $this->assert(!QifParser::hatFeld($kategorien[1], 'T'), 'Zweite Kategorie ist nicht steuerrelevant'); $this->assertEquals('16304', QifParser::feld($kategorien[2], 'R'), 'Steuerformular wird gelesen'); $this->assertEquals( ['Class' => 2, 'Cat' => 3, 'Account' => 4, 'Bank' => 1], $parser->zusammenfassung(), 'Zusammenfassung zählt je Sektionstyp' ); } private function parserKonvertiertZeichensatz(): void { $iso = mb_convert_encoding("!Type:Class\r\nNRückstellungen\r\n^\r\n", 'ISO-8859-1', 'UTF-8'); $parser = new QifParser($iso); $name = QifParser::feld($parser->eintraege(QifParser::TYP_KLASSE)[0], 'N'); $this->assertEquals('Rückstellungen', $name, 'ISO-8859-1 wird nach UTF-8 gewandelt'); // Eine bereits als UTF-8 vorliegende Datei darf nicht ein zweites Mal // konvertiert werden. $utf8 = new QifParser("!Type:Class\nNRückstellungen\n^\n"); $this->assertEquals( 'Rückstellungen', QifParser::feld($utf8->eintraege(QifParser::TYP_KLASSE)[0], 'N'), 'UTF-8 bleibt unverändert' ); } private function betragUndDatum(): void { $this->assertEquals(-1241.04, QifParser::betrag('-1,241.04'), 'US-Format mit Tausenderkomma'); $this->assertEquals(3000.0, QifParser::betrag('3,000.00'), 'Kreditlimit'); $this->assertEquals(0.0, QifParser::betrag(null), 'Fehlender Betrag ist null'); $this->assertEquals('2026-03-18', QifParser::datum('3.18.26'), 'MDY mit zweistelligem Jahr'); $this->assertEquals('2026-01-02', QifParser::datum('1.2.26'), 'Einstellige Monats- und Tagesangabe'); $this->assertEquals('1998-12-31', QifParser::datum('12.31.98'), 'Jahr ab 70 liegt im letzten Jahrhundert'); $this->assertEquals('2026-03-18', QifParser::datum('18.3.26', 'DMY'), 'DMY wird berücksichtigt'); $this->assertNull(QifParser::datum('13.45.26'), 'Unmögliches Datum ergibt null'); $this->assertNull(QifParser::datum('Unfug'), 'Unlesbares Datum ergibt null'); } private function stammdatenImport(): void { $ergebnis = (new QifStammdatenImport(new QifParser($this->beispiel())))->ausfuehren(); $this->assertEquals(2, $ergebnis['klassen']['neu'], 'Beide Klassen angelegt'); // Drei genannte Kategorien plus die Oberkategorie "400 - Leben", // die im Export nur als Pfadbestandteil vorkommt. $this->assertEquals(4, $ergebnis['kategorien']['neu'], 'Kategorien samt Zwischenstufe angelegt'); $this->assertEquals(3, $ergebnis['konten']['neu'], 'Drei Konten angelegt'); $this->assertEquals(1, $ergebnis['konten']['vorhanden'], 'Der zweite Eintrag desselben Kontos zählt als vorhanden'); $this->assertEquals(0, $ergebnis['kategorien']['fehler'], 'Keine Fehler'); $this->assertEquals([], $ergebnis['meldungen'], 'Kein Protokolleintrag'); $gehalt = Kategorie::findeNachPfad('100 - Gehalt'); $this->assertEquals('einnahme', $gehalt['art'], 'Einnahmekategorie erkannt'); $this->assertEquals(1, (int)$gehalt['steuerrelevant'], 'Steuerkennzeichen übernommen'); $this->assertEquals('Gleason et al', $gehalt['beschreibung'], 'Beschreibung übernommen'); $krank = Kategorie::findeNachPfad('400 - Leben:A-Krankheitskosten'); $this->assertEquals('16304', $krank['steuerformular'], 'Steuerformular übernommen'); $this->assertEquals('ausgabe', $krank['art'], 'Ausgabekategorie erkannt'); $oben = Kategorie::findeNachPfad('400 - Leben'); $this->assertNotEmpty($oben, 'Die Oberkategorie entstand selbsttätig'); $this->assertEquals('ausgabe', $oben['art'], 'Sie übernimmt die Art ihres Astes'); $giro = Konto::findeNachName('DKB Girokonto alt'); $this->assertEquals('bank', $giro['kontoart'], 'Kontoart aus dem QIF-Typ'); $this->assertEquals('1010903027', $giro['kontonummer'], 'Kontonummer aus der Beschreibung gezogen'); $this->assertEquals('', $giro['beschreibung'], 'Die reine Kontonummer bleibt nicht als Beschreibung stehen'); $karte = Konto::findeNachName('ADAC LBB Kreditkarte'); $this->assertEquals('kreditkarte', $karte['kontoart'], 'Kreditkarte erkannt'); $this->assertEquals('3000.00', $karte['kreditlimit'], 'Kreditlimit übernommen'); $this->assertEquals('depot', Konto::findeNachName('Depot comdirect')['kontoart'], 'Depot erkannt'); $this->assertNotEmpty(Klasse::findeNachName('Ehemann'), 'Klasse angelegt'); } private function zweiterLaufAendertNichts(): void { $vorher = [Kategorie::anzahl(), Konto::anzahl(), Klasse::anzahl()]; $ergebnis = (new QifStammdatenImport(new QifParser($this->beispiel())))->ausfuehren(); $this->assertEquals(0, $ergebnis['kategorien']['neu'], 'Zweiter Lauf legt keine Kategorie an'); $this->assertEquals(0, $ergebnis['konten']['neu'], 'Zweiter Lauf legt kein Konto an'); $this->assertEquals(0, $ergebnis['klassen']['neu'], 'Zweiter Lauf legt keine Klasse an'); $this->assertEquals(3, $ergebnis['kategorien']['vorhanden'], 'Alle Kategorien als vorhanden gezählt'); $this->assertEquals( $vorher, [Kategorie::anzahl(), Konto::anzahl(), Klasse::anzahl()], 'Die Bestände sind unverändert' ); } private function trockenlaufSchreibtNicht(): void { $zusatz = "!Type:Class\r\nNProjekt 3\r\n^\r\n!Type:Cat\r\nNNeue Kategorie\r\nE\r\n^\r\n" . "!Option:AutoSwitch\r\n!Account\r\nNNeues Konto\r\nTBank\r\n^\r\n!Clear:AutoSwitch\r\n"; $vorher = [Kategorie::anzahl(), Konto::anzahl(), Klasse::anzahl()]; $ergebnis = (new QifStammdatenImport(new QifParser($zusatz)))->vorschau(); $this->assert($ergebnis['trockenlauf'], 'Ergebnis ist als Trockenlauf gekennzeichnet'); $this->assertEquals(1, $ergebnis['klassen']['neu'], 'Vorschau zählt die neue Klasse'); $this->assertEquals(1, $ergebnis['kategorien']['neu'], 'Vorschau zählt die neue Kategorie'); $this->assertEquals(1, $ergebnis['konten']['neu'], 'Vorschau zählt das neue Konto'); $this->assertEquals( $vorher, [Kategorie::anzahl(), Konto::anzahl(), Klasse::anzahl()], 'Der Trockenlauf hat nichts gespeichert' ); } private function dateinamenPruefung(): void { $this->assertEquals( 'Export.QIF', ImportController::sichererDateiname('Export.QIF'), 'Gültiger Dateiname bleibt erhalten' ); $this->assertEquals( 'passwd.qif', ImportController::sichererDateiname('../../etc/passwd.qif'), 'Pfadanteile werden abgeschnitten' ); $this->assertNull(ImportController::sichererDateiname('böse.php'), 'Fremde Endung wird abgewiesen'); $this->assertNull(ImportController::sichererDateiname(''), 'Leerer Name wird abgewiesen'); $this->assertNull(ImportController::sichererDateiname('..'), 'Verzeichniswechsel wird abgewiesen'); $this->assertEquals( 'mein_export_2026.qif', ImportController::sichererDateiname('mein export 2026.qif'), 'Leerzeichen werden ersetzt' ); } }