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>
255 lines
11 KiB
PHP
255 lines
11 KiB
PHP
<?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'
|
||
);
|
||
}
|
||
}
|