Der Plan ging davon aus, Umbuchungen stünden zweimal im Export und müssten zusammengeführt werden. Der Vollexport zeigt das Gegenteil: Eine Umbuchung steht einmal, in dem Konto, in dem sie erfasst wurde; die Gegenseite ist nur durch L[Zielkonto] benannt und muss beim Import erzeugt werden. Von 965 Umbuchungen hat keine ein zweites Vorkommen. Mit erzeugter Gegenseite stimmen acht gegen den FinanzManager geprüfte Kontosalden auf den Cent. Datenmodell: Migration 003 mit buchungen, buchung_splits und buchungsadressen. Eine Umbuchung ist ein Datensatz mit gegenkonto_id, kein gespiegeltes Paar - zwei Zeilen laufen früher oder später auseinander. Das Kontoblatt des Gegenkontos zeigt sie mit umgekehrtem Vorzeichen. Auch einzelne Splitzeilen dürfen ein Gegenkonto tragen. Konten führen zusätzlich in_summe, die Sigma-Spalte der Kontenübersicht, und saldo_online für Phase 5. Oberfläche: Kontoblatt mit laufendem Saldo, Filter auf Zeitraum, Kategorie, Klasse und Abgleich, Volltextsuche und Seitenblättern. Buchungsmaske für Einnahme, Ausgabe und Umbuchung in beide Richtungen, mit Splitzeilen und Restbetragsanzeige. Import: QifBuchungsImport arbeitet mit Stichtag. Alles davor wird je Konto zum Anfangssaldo aufsummiert, Gegenbuchungen eingerechnet; alles danach bis zum Importtag kommt einzeln in die Datenbank. Spätere Buchungen sind Vorausberechnungen des FinanzManagers und bleiben draußen. Wertpapiergeschäfte schlagen vorerst nur mit ihrem Geldfluss auf dem Verrechnungskonto zu Buche. Am Vollexport geprüft: 833 Buchungen ab 01.01.2026, 7946 in die Anfangssalden von 35 Konten verrechnet. Behoben: Die confirm()-Rückfragen vor dem Löschen waren wirkungslos. public/static/app.js brachte den Mechanismus mit, war aber nie im Layout eingebunden; die ersatzweise gesetzten onsubmit-Attribute verstoßen gegen die eigene Content-Security-Policy und wurden vom Browser verworfen - gelöscht wurde ohne Rückfrage. Ein E2E-Test besteht jetzt auf dem Dialog. Tests: 307 PHP-Prüfungen und 21 Playwright-Tests, alle grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
7.3 KiB
PHP
201 lines
7.3 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();
|
||
}
|
||
|
||
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 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');
|
||
}
|
||
}
|