Kontoname → id */ private array $konten = []; /** @var array Kategoriepfad → id */ private array $kategorien = []; /** @var array Klassenname → id */ private array $klassen = []; /** @var array Basis-Hash → wie oft schon gesehen */ private array $hashZaehler = []; /** @var list */ private array $meldungen = []; /** * @param array{stichtag?: string, bis?: string} $optionen */ public function __construct(QifParser $parser, array $optionen = []) { $this->parser = $parser; $this->stichtag = $optionen['stichtag'] ?? '2026-01-01'; $this->bis = $optionen['bis'] ?? date('Y-m-d'); if (!Buchung::istDatum($this->stichtag) || !Buchung::istDatum($this->bis)) { throw new \InvalidArgumentException('Stichtag und Enddatum müssen als JJJJ-MM-TT vorliegen.'); } } /** * @return array */ public function ausfuehren(bool $trockenlauf = false): array { $this->meldungen = []; $this->hashZaehler = []; $this->ladeStammdaten(); $lauf = function () use ($trockenlauf): array { $anfang = $this->anfangssalden($trockenlauf); $buchungen = $this->buchungen($trockenlauf); return [ 'anfangssalden' => $anfang, 'buchungen' => $buchungen, 'stichtag' => $this->stichtag, 'bis' => $this->bis, 'trockenlauf' => $trockenlauf, ]; }; $ergebnis = $trockenlauf ? $lauf() : Database::transaktion($lauf); $ergebnis['meldungen'] = $this->meldungen; return $ergebnis; } public function vorschau(): array { return $this->ausfuehren(true); } /** * Summiert alles vor dem Stichtag je Konto auf – die Gegenseiten der * Umbuchungen eingeschlossen – und schreibt das Ergebnis als Anfangssaldo. * * @return array{konten: int, summe: float, salden: array} */ private function anfangssalden(bool $trockenlauf): array { $salden = []; $bucheAuf = static function (string $konto, float $betrag) use (&$salden): void { $salden[$konto] = ($salden[$konto] ?? 0.0) + $betrag; }; foreach ($this->parser->eintraege() as $eintrag) { if (!$this->istGeldbuchung($eintrag) && $eintrag['typ'] !== 'Invst') { continue; } $datum = QifParser::datum(QifParser::feld($eintrag, 'D'), $this->parser->datumsformat()); if ($datum === null || $datum >= $this->stichtag) { continue; } $konto = (string)$eintrag['konto']; if ($eintrag['typ'] === 'Invst') { // Vor dem Stichtag zählt vom Wertpapiergeschäft nur der // Geldfluss auf dem Verrechnungskonto. Die Bestände davor // stecken im Depot, nicht im Anfangssaldo. $ziel = self::verrechnungskonto(QifParser::feld($eintrag, 'L')); if ($ziel !== null) { $bucheAuf($ziel, self::geldflussInvst($eintrag)); } continue; } $betrag = QifParser::betrag(QifParser::feld($eintrag, 'U') ?? QifParser::feld($eintrag, 'T')); $bucheAuf($konto, $betrag); foreach ($this->gegenbuchungen($eintrag, $konto, $betrag) as [$zielKonto, $zielBetrag]) { $bucheAuf($zielKonto, $zielBetrag); } } $gesetzt = 0; foreach ($salden as $name => $betrag) { $kontoId = $this->kontoId($name); if ($kontoId === null) { $this->melde('fehler', 'Anfangssaldo für unbekanntes Konto „' . $name . '“ verworfen.'); continue; } if (!$trockenlauf) { Database::execute( 'UPDATE konten SET anfangssaldo = ? WHERE id = ?', [round($betrag, 2), $kontoId] ); } $gesetzt++; } return [ 'konten' => $gesetzt, 'summe' => round(array_sum($salden), 2), 'salden' => $salden, ]; } /** * @return array */ private function buchungen(bool $trockenlauf): array { $zaehler = [ 'neu' => 0, 'vorhanden' => 0, 'vor_stichtag' => 0, 'zukunft' => 0, 'wertpapier' => 0, 'fehler' => 0, ]; foreach ($this->parser->eintraege() as $eintrag) { $istGeld = $this->istGeldbuchung($eintrag); if (!$istGeld && $eintrag['typ'] !== 'Invst') { continue; } $datum = QifParser::datum(QifParser::feld($eintrag, 'D'), $this->parser->datumsformat()); if ($datum === null) { $zaehler['fehler']++; $this->melde('fehler', 'Buchung ohne lesbares Datum übersprungen (' . $eintrag['konto'] . ').'); continue; } if ($datum < $this->stichtag) { $zaehler['vor_stichtag']++; continue; } if ($datum > $this->bis) { $zaehler['zukunft']++; continue; } try { $angelegt = $istGeld ? $this->geldbuchung($eintrag, $datum, $trockenlauf) : $this->wertpapierbuchung($eintrag, $datum, $trockenlauf); if ($angelegt === null) { $zaehler['vorhanden']++; continue; } $zaehler['neu']++; if (!$istGeld) { $zaehler['wertpapier']++; } } catch (\Throwable $e) { $zaehler['fehler']++; $this->melde('fehler', $datum . ' ' . $eintrag['konto'] . ': ' . $e->getMessage()); } } if ($zaehler['wertpapier'] > 0) { $this->melde('hinweis', $zaehler['wertpapier'] . ' Wertpapiergeschäfte übernommen – ' . 'je Geschäft eine Depotbuchung mit Stück und Kurs sowie die Geldbuchung auf dem ' . 'Verrechnungskonto. Kurse zur Bewertung holt der Kursabruf.'); } return $zaehler; } /** * @param array{typ: string, konto: ?string, zeilen: list} $eintrag * @return int|null Kennung der neuen Buchung, oder null wenn sie schon da war. */ private function geldbuchung(array $eintrag, string $datum, bool $trockenlauf): ?int { $kontoName = (string)$eintrag['konto']; $kontoId = $this->kontoId($kontoName); if ($kontoId === null) { throw new \RuntimeException('Unbekanntes Konto „' . $kontoName . '“.'); } $betrag = QifParser::betrag(QifParser::feld($eintrag, 'U') ?? QifParser::feld($eintrag, 'T')); $text = (string)QifParser::feld($eintrag, 'M'); $nummer = (string)QifParser::feld($eintrag, 'N'); $hash = $this->eindeutigerHash($kontoId, $datum, $betrag, $nummer, $text); if (Buchung::findeNachHash($hash) !== null) { return null; } $zuordnung = $this->zuordnung((string)QifParser::feld($eintrag, 'L'), $kontoId); $splits = $this->splitzeilen($eintrag, $kontoId); $daten = [ 'konto_id' => $kontoId, 'datum' => $datum, 'valuta' => QifParser::datum(QifParser::feld($eintrag, 'V'), $this->parser->datumsformat()), 'betrag' => $betrag, 'empfaenger' => (string)QifParser::feld($eintrag, 'P'), 'buchungstext' => $text, 'nummer' => $nummer, 'abgeglichen' => QifParser::feld($eintrag, 'C') !== null, 'kategorie_id' => $zuordnung['kategorie_id'], 'klasse_id' => $zuordnung['klasse_id'], 'gegenkonto_id' => $zuordnung['gegenkonto_id'], 'quelle' => 'qif', 'import_hash' => $hash, ]; if ($trockenlauf) { return 0; } return Buchung::anlegen($daten, $splits, QifParser::felder($eintrag, 'A')); } /** * Wertpapiergeschäfte schlagen in Phase 2 nur auf dem Verrechnungskonto zu * Buche. Bestand, Einstandswert und Kurse folgen in Phase 6 – bis dahin * bliebe ein Betrag auf dem Depot ohne Gegenwert und würde die Summe der * Kontostände verfälschen. */ /** * Ein Wertpapiergeschäft (`!Type:Invst`). * * Es entstehen zwei Datensätze: die Depotbuchung mit Stück und Kurs und – * wenn ein Verrechnungskonto genannt ist – die Geldbuchung dort. Beide legt * `Depotbuchung::anlegen()` in einem Zug an. * * Das Depot ist der `!Account`-Abschnitt, in dem der Eintrag steht; das * Verrechnungskonto steht in `L|[Konto]` (dem eine Kategorie vorangehen * kann, etwa `LKursgewinne:Realisierte Gewinne|[Verrechnungskonto]`). */ private function wertpapierbuchung(array $eintrag, string $datum, bool $trockenlauf): ?int { $depotName = (string)$eintrag['konto']; $depotId = $this->kontoId($depotName); if ($depotId === null) { throw new \RuntimeException('Unbekanntes Depot „' . $depotName . '“.'); } $vorgangQif = trim((string)QifParser::feld($eintrag, 'N')); $vorgang = Depotbuchung::vorgangAusQif($vorgangQif); $name = trim((string)QifParser::feld($eintrag, 'Y')); if ($name === '') { throw new \RuntimeException('Wertpapiergeschäft ohne Wertpapiernamen.'); } $isin = trim((string)QifParser::feld($eintrag, '@')); $stueck = QifParser::betrag(QifParser::feld($eintrag, 'Q')); $kurs = QifParser::betrag(QifParser::feld($eintrag, 'I')); [$gebuehren, $steuern] = self::nebenkosten(QifParser::feld($eintrag, 'O')); // Der Bruttowert ist Stück mal Kurs. Fehlt der Kurs – bei Dividenden // immer –, wird er aus dem gebuchten Betrag zurückgerechnet: Feld U ist // dort die Gutschrift nach Abzug der Steuern. $gebucht = QifParser::betrag(QifParser::feld($eintrag, 'U')); $betrag = $stueck > 0.0 && $kurs > 0.0 ? round($stueck * $kurs, 2) : round(abs($gebucht) + $gebuehren + $steuern, 2); $verrechnung = self::verrechnungskonto(QifParser::feld($eintrag, 'L')); $verrechnungId = $verrechnung === null ? null : $this->kontoId($verrechnung); if ($verrechnung !== null && $verrechnungId === null) { throw new \RuntimeException('Unbekanntes Verrechnungskonto „' . $verrechnung . '“.'); } $geld = QifParser::feld($eintrag, '$'); $geld = $geld === null ? 0.0 : QifParser::betrag($geld); $hash = Depotbuchung::hash($depotId, $datum, $vorgang, $stueck, $betrag, $isin ?: $name); if (Depotbuchung::findeNachHash($hash) !== null) { return null; } if ($trockenlauf) { return 0; } $text = trim((string)QifParser::feld($eintrag, 'M')); $wertpapierId = Wertpapier::findenOderAnlegen([ 'name' => $name, 'wkn' => (string)QifParser::feld($eintrag, '~'), 'isin' => $isin, 'art' => Wertpapier::artAusQif((string)QifParser::feld($eintrag, '&')), 'waehrung' => (string)QifParser::feld($eintrag, 'F') ?: 'EUR', ]); return Depotbuchung::anlegen([ 'konto_id' => $depotId, 'wertpapier_id' => $wertpapierId, 'datum' => $datum, 'valuta' => QifParser::datum(QifParser::feld($eintrag, 'V'), $this->parser->datumsformat()), 'vorgang' => $vorgang, 'vorgang_qif' => $vorgangQif, 'stueck' => $stueck, 'kurs' => $kurs > 0.0 ? $kurs : null, 'betrag' => $betrag, 'gebuehren' => $gebuehren, 'steuern' => $steuern, 'waehrung' => (string)QifParser::feld($eintrag, 'F') ?: 'EUR', 'verrechnungskonto_id' => $verrechnungId, 'geldbetrag' => $geld, 'notiz' => $text, 'wertpapiername' => $name, 'abgeglichen' => QifParser::feld($eintrag, 'C') !== null, 'quelle' => 'qif', 'import_hash' => $hash, // Der Hash der Geldseite entspricht dem, den frühere Importe ohne // Depotmodul vergeben haben – so entsteht im Kontoblatt keine // zweite Buchung für dasselbe Geschäft. 'buchung_hash' => $verrechnungId === null ? null : $this->eindeutigerHash( $verrechnungId, $datum, -$geld, $vorgangQif, $text ), ]); } /** * Der Geldfluss eines Wertpapiergeschäfts auf dem Verrechnungskonto. * * Das QIF nennt im Feld `$` immer einen positiven Betrag – die Richtung * steckt allein im Vorgang: Ein Kauf nimmt Geld weg, Verkauf und Dividende * bringen welches. Ein- und Auslieferungen rühren kein Konto an. */ private static function geldflussInvst(array $eintrag): float { $wert = QifParser::feld($eintrag, '$') ?? QifParser::feld($eintrag, 'U'); if ($wert === null) { return 0.0; } $betrag = abs(QifParser::betrag($wert)); $vorgang = Depotbuchung::vorgangAusQif(trim((string)QifParser::feld($eintrag, 'N'))); return match ($vorgang) { 'kauf' => -$betrag, 'verkauf', 'dividende' => $betrag, default => 0.0, }; } /** * Das QIF-Feld `O` führt zwölf durch „|“ getrennte Beträge. * * Sicher zugeordnet sind die vierte (Provision) und die zehnte Position * (fremde Spesen) – beides Gebühren. Alles Übrige sind Abzüge des Fiskus * (Kapitalertragsteuer, Solidaritätszuschlag, Quellensteuer). Statt jede * Position einzeln zu deuten, werden sie zu „Steuern“ zusammengefasst: Für * Einstand und Erlös zählt die Summe, und keine Position fällt heraus. * * @return array{0: float, 1: float} Gebühren und Steuern */ private static function nebenkosten(?string $feld): array { if ($feld === null || trim($feld) === '') { return [0.0, 0.0]; } $werte = array_map( static fn(string $wert): float => QifParser::betrag($wert), explode('|', $feld) ); $gebuehren = ($werte[3] ?? 0.0) + ($werte[9] ?? 0.0); $steuern = 0.0; foreach ($werte as $nummer => $wert) { if ($nummer !== 3 && $nummer !== 9) { $steuern += $wert; } } return [round($gebuehren, 2), round($steuern, 2)]; } /** * Welche Konten bekommen aus dieser Buchung eine Gegenbewegung, und mit * welchem Betrag? Ohne Splits genau eines, mit Splits je Zeile eines. * * @return list */ private function gegenbuchungen(array $eintrag, string $konto, float $betrag): array { $ziele = []; $splits = QifParser::felder($eintrag, 'S'); if ($splits !== []) { $betraege = QifParser::felder($eintrag, '$'); foreach ($splits as $nr => $angabe) { $ziel = self::umbuchungsziel($angabe); if ($ziel !== null && $ziel !== $konto) { $ziele[] = [$ziel, -QifParser::betrag($betraege[$nr] ?? '0')]; } } return $ziele; } $ziel = self::umbuchungsziel(QifParser::feld($eintrag, 'L')); if ($ziel !== null && $ziel !== $konto) { $ziele[] = [$ziel, -$betrag]; } return $ziele; } /** * @return list> */ private function splitzeilen(array $eintrag, int $kontoId): array { $kategorien = QifParser::felder($eintrag, 'S'); if ($kategorien === []) { return []; } $betraege = QifParser::felder($eintrag, '$'); $notizen = QifParser::felder($eintrag, 'E'); $zeilen = []; foreach ($kategorien as $nr => $angabe) { $zuordnung = $this->zuordnung($angabe, $kontoId); $zeilen[] = [ 'kategorie_id' => $zuordnung['kategorie_id'], 'klasse_id' => $zuordnung['klasse_id'], 'gegenkonto_id' => $zuordnung['gegenkonto_id'], 'betrag' => QifParser::betrag($betraege[$nr] ?? '0'), 'notiz' => $notizen[$nr] ?? '', ]; } return $zeilen; } /** * Löst eine L- oder S-Angabe auf: Kategorie und Klasse, oder ein Gegenkonto. * * Verweist die Buchung auf ihr eigenes Konto, ist es die Eröffnungsbuchung * des FinanzManagers – daraus wird keine Umbuchung, sonst hübe sie sich auf. * * @return array{kategorie_id: ?int, klasse_id: ?int, gegenkonto_id: ?int} */ private function zuordnung(string $angabe, int $kontoId): array { $leer = ['kategorie_id' => null, 'klasse_id' => null, 'gegenkonto_id' => null]; if (trim($angabe) === '') { return $leer; } $teile = Kategorie::zerlegeQifAngabe($angabe); $klasseId = $teile['klasse'] !== '' ? $this->klasseId($teile['klasse']) : null; $ziel = self::umbuchungsziel($teile['kategorie']); if ($ziel !== null) { $gegen = $this->kontoId($ziel); if ($gegen === null) { $this->melde('fehler', 'Umbuchung auf unbekanntes Konto „' . $ziel . '“ – ohne Gegenkonto übernommen.'); return ['kategorie_id' => null, 'klasse_id' => $klasseId, 'gegenkonto_id' => null]; } return [ 'kategorie_id' => null, 'klasse_id' => $klasseId, 'gegenkonto_id' => $gegen === $kontoId ? null : $gegen, ]; } return [ 'kategorie_id' => $teile['kategorie'] !== '' ? $this->kategorieId($teile['kategorie']) : null, 'klasse_id' => $klasseId, 'gegenkonto_id' => null, ]; } /** `[Konto]` oder `|[Konto]` (Wertpapiere) → Kontoname, sonst null. */ public static function umbuchungsziel(?string $angabe): ?string { if ($angabe === null) { return null; } $angabe = ltrim(trim($angabe), '|'); if (!str_starts_with($angabe, '[')) { return null; } $ende = strpos($angabe, ']'); return $ende === false ? null : substr($angabe, 1, $ende - 1); } /** * Das Verrechnungskonto eines Wertpapiergeschäfts. * * Bei `!Type:Invst` steht in `L` mehr als bei einer gewöhnlichen Buchung: * Dem Konto kann eine Kategorie vorangehen, getrennt durch einen senkrechten * Strich – ein Verkauf trägt dort etwa * `LKursgewinne:Realisierte Gewinne|[Verrechnungskonto]`. Deshalb zählt hier * allein der Teil hinter dem letzten Strich. */ public static function verrechnungskonto(?string $angabe): ?string { if ($angabe === null) { return null; } $strich = strrpos($angabe, '|'); return self::umbuchungsziel($strich === false ? $angabe : substr($angabe, $strich)); } /** * Identische Buchungen am selben Tag kommen vor (zweimal dasselbe im selben * Laden). Der laufende Zähler hält sie auseinander und bleibt über Läufe * hinweg stabil, weil die Datei immer dieselbe Reihenfolge hat. */ private function eindeutigerHash(int $kontoId, string $datum, float $betrag, string $nummer, string $text): string { $basis = Buchung::hash($kontoId, $datum, $betrag, $nummer, $text); $n = $this->hashZaehler[$basis] = ($this->hashZaehler[$basis] ?? 0) + 1; return $n === 1 ? $basis : sha1($basis . '#' . $n); } private function ladeStammdaten(): void { $this->konten = []; $this->kategorien = []; $this->klassen = []; foreach (Konto::alle() as $konto) { $this->konten[$konto['name']] = (int)$konto['id']; } foreach (Kategorie::alle() as $kategorie) { $this->kategorien[$kategorie['pfad']] = (int)$kategorie['id']; } foreach (Klasse::alle() as $klasse) { $this->klassen[$klasse['name']] = (int)$klasse['id']; } } private function kontoId(string $name): ?int { return $this->konten[trim($name)] ?? null; } /** Fehlt die Kategorie, wird sie angelegt – der Export nennt nicht immer alle. */ private function kategorieId(string $pfad): ?int { $pfad = Kategorie::normalisierePfad($pfad); if ($pfad === '') { return null; } if (isset($this->kategorien[$pfad])) { return $this->kategorien[$pfad]; } $id = Kategorie::findenOderAnlegenNachPfad($pfad); $this->kategorien[$pfad] = $id; $this->melde('hinweis', 'Kategorie „' . $pfad . '“ war nicht angelegt und wurde ergänzt.'); return $id; } private function klasseId(string $name): ?int { $name = trim($name); if ($name === '') { return null; } if (!isset($this->klassen[$name])) { $this->klassen[$name] = Klasse::findenOderAnlegen($name); } return $this->klassen[$name]; } private function istGeldbuchung(array $eintrag): bool { return in_array($eintrag['typ'], self::GELDTYPEN, true) && $eintrag['konto'] !== null; } private function melde(string $typ, string $text): void { // Dieselbe Meldung nicht hundertfach wiederholen. foreach ($this->meldungen as $vorhanden) { if ($vorhanden['text'] === $text) { return; } } $this->meldungen[] = ['typ' => $typ, 'text' => $text]; } }