Finanzmanager/tests/08_QifImportTest.php
fds2610-bot 1e3227ce83 Phase 1: Stammdaten und QIF-Stammdatenimport
Konten, Kontengruppen, Kategorien und Klassen samt Übernahme aus dem
QIF-Export des Lexware FinanzManagers.

Kontenübersicht nach Kontengruppen gebündelt, mit Gruppensummen und
Gesamtsumme; die drei Saldospalten (Online-Saldo, aktueller und erwarteter
Saldo) sind angelegt und werden bis Phase 2 aus dem Anfangssaldo gespeist.

Kategorien sind hierarchisch mit ":" als Trenner. Der vollständige Pfad
steht redundant in der Tabelle, weil jede Anzeige und der gesamte Import
darüber laufen; Umbenennen und Verschieben schreiben den Teilbaum neu. Die
Art (Einnahme/Ausgabe) gilt für den ganzen Ast. Register wie im Original:
Alle, Einnahmen, Ausgaben, Umbuchungskonten.

QifParser liest zustandsbehaftet und sequenziell, wandelt ISO-8859-1 nach
UTF-8 und versteht US-Beträge sowie das Datumsformat aus der Kopfzeile.
QifStammdatenImport ist idempotent und bietet Trockenlauf und Protokoll -
im Dialog unter Verwaltung sowie über scripts/qif-import.php. Am echten
Export geprüft: 17 Klassen, 105 Kategorien, 37 Konten.

Enthält außerdem die Korrektur der englischen Monatsnamen im
Einstellungsdialog: date('F') ist sprachlich nicht umschaltbar, deshalb
liegen die Namen jetzt zentral als View::MONATE vor. Die Änderung ließ
sich nicht als eigener Commit trennen, weil sie dieselben Dateien berührt
wie Phase 1.

Tests: 227 PHP-Prüfungen und 14 Playwright-Tests, alle grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:33:40 +02:00

255 lines
11 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\QifParser;
use Finanzmanager\Services\QifStammdatenImport;
use Finanzmanager\Controllers\ImportController;
/**
* Der Ausschnitt entspricht dem Aufbau des echten Exports: Kopfzeile mit
* Datumsformat, Klassen, Kategorien, die Kontenliste zwischen !Option:AutoSwitch
* und !Clear:AutoSwitch und danach ein Konto mit seinen Buchungen.
*/
class QifImportTest extends TestCase
{
protected string $testName = 'Test 08: QIF-Parser und Stammdatenimport';
public function run(): void
{
$this->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'
);
}
}