Der Import kannte bisher nur den Zeitraum als Filter – ein Export ließ sich nur ganz einlesen. Neu ist die Option "konten": im Dialog eine Ankreuzliste, die erst nach dem Trockenlauf erscheint (vorher ist die Datei ungelesen), auf der Kommandozeile --konto=NAME und --konten. Maßgeblich ist nicht allein der !Account-Abschnitt, sondern jedes Konto, das der Eintrag berührt: das eigene, die Umbuchungsziele aus L und den Splitzeilen und beim Wertpapiergeschäft das Verrechnungskonto. Eine Umbuchung steht nur einmal in der Datei – sonst fehlte dem gewählten Konto die Gegenseite. Die Anfangssalden werden weiter über die ganze Datei gerechnet, aber nur für die gewählten Konten geschrieben; ein Teilimport überschreibt sonst fremde Salden. Die Fingerabdrücke entstehen jetzt vorab in einem eigenen Durchgang: Der laufende Zähler für gleiche Fingerabdrücke hing sonst an der Auswahl, und dieselbe Buchung wäre beim zweiten Lauf mit anderer Auswahl doppelt angelegt worden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
263 lines
10 KiB
PHP
263 lines
10 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Finanzmanager\Models\Buchung;
|
||
use Finanzmanager\Models\Kategorie;
|
||
use Finanzmanager\Models\Konto;
|
||
use Finanzmanager\Services\QifBuchungsImport;
|
||
use Finanzmanager\Services\QifParser;
|
||
use Finanzmanager\Services\QifStammdatenImport;
|
||
|
||
/**
|
||
* Der Ausschnitt bildet die Eigenheiten des echten Exports nach: eine Umbuchung
|
||
* steht nur einmal in der Datei, eine Eröffnungsbuchung verweist auf ihr eigenes
|
||
* Konto, ein Teil der Buchungen liegt vor dem Stichtag, eine dahinter.
|
||
*/
|
||
class QifBuchungsImportTest extends TestCase
|
||
{
|
||
protected string $testName = 'Test 10: QIF-Buchungsimport mit Stichtag';
|
||
|
||
public function run(): void
|
||
{
|
||
$this->mandantAnlegen();
|
||
|
||
$this->importMitStichtag();
|
||
$this->zweiterLaufAendertNichts();
|
||
$this->umbuchungsziel();
|
||
$this->kontenDerDatei();
|
||
$this->nurEinKonto();
|
||
}
|
||
|
||
private function beispiel(): string
|
||
{
|
||
$zukunft = date('m.d.y', strtotime('+40 days'));
|
||
|
||
$zeilen = [
|
||
'!Option:MDY',
|
||
'!Type:Cat',
|
||
'N400 - Leben:Essen',
|
||
'E',
|
||
'^',
|
||
'!Option:AutoSwitch',
|
||
'!Account',
|
||
'NGiro',
|
||
'TBank',
|
||
'^',
|
||
'NTagesgeld',
|
||
'TOth T',
|
||
'^',
|
||
'!Clear:AutoSwitch',
|
||
|
||
// Girokonto
|
||
'!Account',
|
||
'NGiro',
|
||
'TBank',
|
||
'^',
|
||
'!Type:Bank ',
|
||
// Eröffnungsbuchung: verweist auf das eigene Konto
|
||
'D1.2.18',
|
||
'T1,000.00',
|
||
'PEröffnungssaldo',
|
||
'L[Giro]',
|
||
'CX',
|
||
'^',
|
||
// vor dem Stichtag: fließt in den Anfangssaldo
|
||
'D6.15.25',
|
||
'T-100.00',
|
||
'PBäcker',
|
||
'L400 - Leben:Essen',
|
||
'CX',
|
||
'^',
|
||
// Umbuchung vor dem Stichtag – die Gegenseite steht nirgends
|
||
'D7.1.25',
|
||
'T-400.00',
|
||
'PÜbertrag',
|
||
'L[Tagesgeld]',
|
||
'CX',
|
||
'^',
|
||
// im Zeitraum
|
||
'D3.18.26',
|
||
'U-49.90',
|
||
'T-49.90',
|
||
'PSupermarkt',
|
||
'MWocheneinkauf',
|
||
'L400 - Leben:Essen/Privat',
|
||
'CX',
|
||
'N77',
|
||
'^',
|
||
// Umbuchung im Zeitraum
|
||
'D4.1.26',
|
||
'T-250.00',
|
||
'PÜbertrag',
|
||
'L[Tagesgeld]',
|
||
'^',
|
||
// Splitbuchung im Zeitraum
|
||
'D5.2.26',
|
||
'T-300.00',
|
||
'PGemischt',
|
||
'L400 - Leben:Essen',
|
||
'S400 - Leben:Essen',
|
||
'EEinkauf',
|
||
'$-100.00',
|
||
'S[Tagesgeld]',
|
||
'ERücklage',
|
||
'$-200.00',
|
||
'^',
|
||
// nach dem Enddatum: vorausberechnet
|
||
'D' . $zukunft,
|
||
'T-999.00',
|
||
'PVorausschau',
|
||
'L400 - Leben:Essen',
|
||
'^',
|
||
];
|
||
|
||
return implode("\r\n", $zeilen) . "\r\n";
|
||
}
|
||
|
||
private function importMitStichtag(): void
|
||
{
|
||
$parser = new QifParser($this->beispiel());
|
||
(new QifStammdatenImport($parser))->ausfuehren();
|
||
|
||
$ergebnis = (new QifBuchungsImport($parser, [
|
||
'stichtag' => '2026-01-01',
|
||
'bis' => date('Y-m-d'),
|
||
]))->ausfuehren();
|
||
|
||
$this->assertEquals(3, $ergebnis['buchungen']['neu'], 'Drei Buchungen im Zeitraum');
|
||
$this->assertEquals(3, $ergebnis['buchungen']['vor_stichtag'], 'Drei Buchungen davor verrechnet');
|
||
$this->assertEquals(1, $ergebnis['buchungen']['zukunft'], 'Eine vorausberechnete Buchung verworfen');
|
||
$this->assertEquals(0, $ergebnis['buchungen']['fehler'], 'Keine Fehler');
|
||
|
||
$giro = Konto::findeNachName('Giro');
|
||
$tagesgeld = Konto::findeNachName('Tagesgeld');
|
||
|
||
// Anfangssaldo Giro: 1000 (Eröffnung) - 100 (Bäcker) - 400 (Übertrag)
|
||
$this->assertEquals('500.00', $giro['anfangssaldo'], 'Anfangssaldo aus der Vorgeschichte');
|
||
// Die Gegenseite der Umbuchung steht nirgends in der Datei und muss
|
||
// trotzdem auf dem Tagesgeldkonto ankommen.
|
||
$this->assertEquals('400.00', $tagesgeld['anfangssaldo'], 'Erzeugte Gegenbuchung im Anfangssaldo');
|
||
|
||
// Saldo Giro: 500 - 49,90 - 250 - 300
|
||
$this->assertEquals(-99.90, round(Konto::saldo((int)$giro['id']), 2), 'Girosaldo nach dem Import');
|
||
// Tagesgeld: 400 + 250 (Umbuchung) + 200 (Splitzeile)
|
||
$this->assertEquals(850.0, round(Konto::saldo((int)$tagesgeld['id']), 2), 'Tagesgeldsaldo nach dem Import');
|
||
|
||
$blatt = Buchung::kontoblatt((int)$tagesgeld['id']);
|
||
$this->assertEquals(2, count($blatt), 'Beide Gegensichten stehen im Kontoblatt');
|
||
foreach ($blatt as $zeile) {
|
||
$this->assert($zeile['gegensicht'], 'Es sind Gegensichten, keine eigenen Buchungen');
|
||
}
|
||
|
||
$supermarkt = null;
|
||
foreach (Buchung::kontoblatt((int)$giro['id']) as $zeile) {
|
||
if ($zeile['empfaenger'] === 'Supermarkt') {
|
||
$supermarkt = $zeile;
|
||
}
|
||
}
|
||
$this->assertNotEmpty($supermarkt, 'Die Buchung im Zeitraum ist da');
|
||
$this->assertEquals('Wocheneinkauf', $supermarkt['buchungstext'], 'Buchungstext übernommen');
|
||
$this->assertEquals('77', $supermarkt['nummer'], 'Nummer übernommen');
|
||
$this->assertEquals(1, (int)$supermarkt['abgeglichen'], 'Abgleichstatus aus dem C-Feld');
|
||
$this->assertNotEmpty($supermarkt['klasse_id'], 'Die Klasse hinter dem Schrägstrich wurde erkannt');
|
||
$this->assertEquals(
|
||
'400 - Leben:Essen',
|
||
Kategorie::find((int)$supermarkt['kategorie_id'])['pfad'],
|
||
'Kategorie zugeordnet'
|
||
);
|
||
|
||
// Die Eröffnungsbuchung verweist auf ihr eigenes Konto und darf keine
|
||
// Umbuchung geworden sein – sonst hübe sie sich selbst auf.
|
||
$this->assertEquals(
|
||
'500.00',
|
||
Konto::findeNachName('Giro')['anfangssaldo'],
|
||
'Die Eröffnungsbuchung zählt einfach, nicht doppelt'
|
||
);
|
||
}
|
||
|
||
private function zweiterLaufAendertNichts(): void
|
||
{
|
||
$vorher = Buchung::anzahl();
|
||
$giro = (float)Konto::findeNachName('Giro')['anfangssaldo'];
|
||
|
||
$parser = new QifParser($this->beispiel());
|
||
$ergebnis = (new QifBuchungsImport($parser, [
|
||
'stichtag' => '2026-01-01', 'bis' => date('Y-m-d'),
|
||
]))->ausfuehren();
|
||
|
||
$this->assertEquals(0, $ergebnis['buchungen']['neu'], 'Zweiter Lauf legt nichts an');
|
||
$this->assertEquals(3, $ergebnis['buchungen']['vorhanden'], 'Alle drei als vorhanden erkannt');
|
||
$this->assertEquals($vorher, Buchung::anzahl(), 'Die Zahl der Buchungen ist unverändert');
|
||
$this->assertEquals($giro, (float)Konto::findeNachName('Giro')['anfangssaldo'], 'Anfangssaldo bleibt gleich');
|
||
}
|
||
|
||
private function kontenDerDatei(): void
|
||
{
|
||
$konten = QifBuchungsImport::kontenDerDatei(new QifParser($this->beispiel()));
|
||
|
||
$this->assertEquals(['Giro', 'Tagesgeld'], array_keys($konten), 'Beide Konten stehen zur Wahl');
|
||
$this->assertEquals(7, $konten['Giro'], 'Alle Einträge des Girokontos gezählt');
|
||
// Tagesgeld führt keine eigene Buchung: die drei Einträge zeigen von
|
||
// Giro aus hierher (zwei Umbuchungen, eine Splitzeile).
|
||
$this->assertEquals(3, $konten['Tagesgeld'], 'Ein reines Umbuchungsziel steht ebenfalls zur Wahl');
|
||
}
|
||
|
||
/**
|
||
* Teilimport: erst nur das Tagesgeldkonto, dann das Girokonto. Der zweite
|
||
* Lauf darf die Umbuchungen nicht ein zweites Mal anlegen – auch nicht mit
|
||
* anderer Kontoauswahl.
|
||
*/
|
||
private function nurEinKonto(): void
|
||
{
|
||
$this->mandantAnlegen();
|
||
|
||
$parser = new QifParser($this->beispiel());
|
||
(new QifStammdatenImport($parser))->ausfuehren();
|
||
|
||
$ergebnis = (new QifBuchungsImport($parser, [
|
||
'stichtag' => '2026-01-01',
|
||
'bis' => date('Y-m-d'),
|
||
'konten' => ['Tagesgeld'],
|
||
]))->ausfuehren();
|
||
|
||
$this->assertEquals(2, $ergebnis['buchungen']['neu'], 'Nur die beiden Buchungen mit Bezug zum Tagesgeld');
|
||
$this->assertEquals(1, $ergebnis['buchungen']['nicht_gewaehlt'], 'Der Supermarkt gehört allein zum Giro');
|
||
$this->assertEquals(0, $ergebnis['buchungen']['fehler'], 'Keine Fehler beim Teilimport');
|
||
$this->assertEquals(1, $ergebnis['anfangssalden']['konten'], 'Nur ein Anfangssaldo geschrieben');
|
||
|
||
$giro = Konto::findeNachName('Giro');
|
||
$tagesgeld = Konto::findeNachName('Tagesgeld');
|
||
|
||
$this->assertEquals('400.00', $tagesgeld['anfangssaldo'], 'Anfangssaldo des gewählten Kontos');
|
||
$this->assertEquals('0.00', $giro['anfangssaldo'], 'Das nicht gewählte Konto bleibt unangetastet');
|
||
$this->assertEquals(850.0, round(Konto::saldo((int)$tagesgeld['id']), 2), 'Tagesgeldsaldo aus den Gegensichten');
|
||
|
||
// Zweiter Lauf, diesmal das Girokonto: Die Umbuchungen sind schon da –
|
||
// ihr Fingerabdruck darf nicht von der Auswahl abhängen.
|
||
$ergebnis = (new QifBuchungsImport($parser, [
|
||
'stichtag' => '2026-01-01',
|
||
'bis' => date('Y-m-d'),
|
||
'konten' => ['Giro'],
|
||
]))->ausfuehren();
|
||
|
||
$this->assertEquals(1, $ergebnis['buchungen']['neu'], 'Nur der Supermarkt fehlte noch');
|
||
$this->assertEquals(2, $ergebnis['buchungen']['vorhanden'], 'Die Umbuchungen waren schon da');
|
||
$this->assertEquals(3, Buchung::anzahl(), 'Keine Buchung doppelt angelegt');
|
||
$this->assertEquals(
|
||
'500.00',
|
||
Konto::findeNachName('Giro')['anfangssaldo'],
|
||
'Jetzt bekommt auch das Girokonto seinen Anfangssaldo'
|
||
);
|
||
$this->assertEquals(-99.90, round(Konto::saldo((int)$giro['id']), 2), 'Girosaldo wie beim Vollimport');
|
||
}
|
||
|
||
private function umbuchungsziel(): void
|
||
{
|
||
$this->assertEquals('DKB Girokonto', QifBuchungsImport::umbuchungsziel('[DKB Girokonto]'), 'Einfache Umbuchung');
|
||
$this->assertEquals('DKB Girokonto', QifBuchungsImport::umbuchungsziel('|[DKB Girokonto]'), 'Wertpapierschreibweise');
|
||
$this->assertNull(QifBuchungsImport::umbuchungsziel('400 - Leben:Essen'), 'Kategorie ist kein Konto');
|
||
$this->assertNull(QifBuchungsImport::umbuchungsziel(null), 'Fehlende Angabe');
|
||
$this->assertNull(QifBuchungsImport::umbuchungsziel('[unvollständig'), 'Fehlende schließende Klammer');
|
||
}
|
||
}
|