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>
825 lines
30 KiB
PHP
825 lines
30 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Services;
|
||
|
||
use Finanzmanager\Database;
|
||
use Finanzmanager\Models\Buchung;
|
||
use Finanzmanager\Models\Depotbuchung;
|
||
use Finanzmanager\Models\Kategorie;
|
||
use Finanzmanager\Models\Klasse;
|
||
use Finanzmanager\Models\Konto;
|
||
use Finanzmanager\Models\Wertpapier;
|
||
|
||
/**
|
||
* Übernimmt die Buchungen eines QIF-Exports.
|
||
*
|
||
* Zwei Eigenheiten des Formats bestimmen den Ablauf:
|
||
*
|
||
* 1. **Umbuchungen stehen nur einmal in der Datei** – in dem Konto, in dem sie
|
||
* erfasst wurden; die Gegenseite ist allein durch `L[Zielkonto]` benannt.
|
||
* Hier wird daraus eine Buchung mit `gegenkonto_id`, die auf beiden Konten
|
||
* erscheint (auf dem Gegenkonto mit umgekehrtem Vorzeichen).
|
||
* 2. **Der Export reicht weiter zurück, als gebraucht wird.** Alles vor dem
|
||
* Stichtag fließt nicht als Buchung ein, sondern wird je Konto zum
|
||
* Anfangssaldo aufsummiert – einschließlich der Gegenseiten. Die Salden
|
||
* stimmen dadurch exakt, ohne die ganze Historie mitzuschleppen.
|
||
*
|
||
* Buchungen nach dem Enddatum sind Vorausberechnungen des FinanzManagers und
|
||
* werden übersprungen.
|
||
*
|
||
* Über die Option `konten` lässt sich der Import auf einzelne Konten begrenzen.
|
||
* Maßgeblich ist dann nicht allein der `!Account`-Abschnitt, sondern jedes
|
||
* Konto, das die Buchung berührt – sonst fehlten die Umbuchungen, die auf einem
|
||
* anderen Konto erfasst wurden und nur über `L[Konto]` hierher zeigen.
|
||
*/
|
||
class QifBuchungsImport
|
||
{
|
||
/** Sektionen mit Geldbuchungen. Invst wird gesondert behandelt. */
|
||
private const GELDTYPEN = ['Bank', 'CCard', 'Cash', 'Oth A', 'Oth L', 'Oth T', 'Oth P', 'Oth E', 'Oth F'];
|
||
|
||
private QifParser $parser;
|
||
private string $stichtag;
|
||
private string $bis;
|
||
|
||
/** @var array<string,true>|null Gewählte Kontonamen; null heißt: alle. */
|
||
private ?array $auswahl = null;
|
||
|
||
/** @var array<string,int> Kontoname → id */
|
||
private array $konten = [];
|
||
/** @var array<string,int> Kategoriepfad → id */
|
||
private array $kategorien = [];
|
||
/** @var array<string,int> Klassenname → id */
|
||
private array $klassen = [];
|
||
/** @var array<string,int> Basis-Hash → wie oft schon gesehen */
|
||
private array $hashZaehler = [];
|
||
/** @var array<int,string> Position in der Datei → Fingerabdruck der Geldbuchung */
|
||
private array $hashes = [];
|
||
|
||
/** @var list<array{typ: string, text: string}> */
|
||
private array $meldungen = [];
|
||
|
||
/**
|
||
* @param array{stichtag?: string, bis?: string, konten?: list<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.');
|
||
}
|
||
|
||
$auswahl = [];
|
||
foreach ($optionen['konten'] ?? [] as $name) {
|
||
$name = trim((string)$name);
|
||
if ($name !== '') {
|
||
$auswahl[$name] = true;
|
||
}
|
||
}
|
||
$this->auswahl = $auswahl === [] ? null : $auswahl;
|
||
}
|
||
|
||
/**
|
||
* Welche Konten kommen in der Datei überhaupt vor?
|
||
*
|
||
* Grundlage der Kontoauswahl im Dialog: auswählbar ist nur, was der Export
|
||
* auch hergibt. Gezählt wird je Konto, wie viele Einträge es berühren –
|
||
* eigene Buchungen ebenso wie Umbuchungen, die von anderswo hierher zeigen.
|
||
* Ein Konto, das nur Umbuchungsziel ist, führt in der Datei keine eigene
|
||
* Zeile und stünde sonst nicht zur Wahl.
|
||
*
|
||
* @return array<string,int> Kontoname → Zahl der Einträge, die es betreffen
|
||
*/
|
||
public static function kontenDerDatei(QifParser $parser): array
|
||
{
|
||
$konten = [];
|
||
|
||
foreach ($parser->eintraege() as $eintrag) {
|
||
if (!in_array($eintrag['typ'], self::GELDTYPEN, true) && $eintrag['typ'] !== 'Invst') {
|
||
continue;
|
||
}
|
||
foreach (self::beteiligteKonten($eintrag) as $name) {
|
||
$konten[$name] = ($konten[$name] ?? 0) + 1;
|
||
}
|
||
}
|
||
|
||
ksort($konten, SORT_NATURAL | SORT_FLAG_CASE);
|
||
|
||
return $konten;
|
||
}
|
||
|
||
/**
|
||
* @return array<string,mixed>
|
||
*/
|
||
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,
|
||
'konten' => $this->auswahl === null ? null : array_keys($this->auswahl),
|
||
'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.
|
||
*
|
||
* Bei einer Kontoauswahl wird trotzdem die ganze Datei durchgerechnet: Der
|
||
* Saldo eines Kontos steckt auch in Buchungen, die auf einem anderen Konto
|
||
* erfasst wurden. Nur geschrieben werden die gewählten Konten – sonst
|
||
* überschriebe ein Teilimport die Salden aller übrigen.
|
||
*
|
||
* @return array{konten: int, summe: float, salden: array<string,float>}
|
||
*/
|
||
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;
|
||
$geschrieben = [];
|
||
foreach ($salden as $name => $betrag) {
|
||
if (!$this->gewaehlt($name)) {
|
||
continue;
|
||
}
|
||
$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]
|
||
);
|
||
}
|
||
$geschrieben[$name] = $betrag;
|
||
$gesetzt++;
|
||
}
|
||
|
||
return [
|
||
'konten' => $gesetzt,
|
||
'summe' => round(array_sum($geschrieben), 2),
|
||
'salden' => $geschrieben,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array<string,int>
|
||
*/
|
||
private function buchungen(bool $trockenlauf): array
|
||
{
|
||
$zaehler = [
|
||
'neu' => 0, 'vorhanden' => 0, 'vor_stichtag' => 0,
|
||
'zukunft' => 0, 'wertpapier' => 0, 'nicht_gewaehlt' => 0, 'fehler' => 0,
|
||
];
|
||
|
||
$this->hashes = $this->hashes();
|
||
|
||
foreach ($this->parser->eintraege() as $nr => $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;
|
||
}
|
||
if (!$this->gehoertZurAuswahl($eintrag)) {
|
||
$zaehler['nicht_gewaehlt']++;
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
$angelegt = $istGeld
|
||
? $this->geldbuchung($eintrag, $nr, $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<array{0: string, 1: string}>} $eintrag
|
||
* @param int $nr Position in der Datei – dort liegt der vorab vergebene Hash.
|
||
* @return int|null Kennung der neuen Buchung, oder null wenn sie schon da war.
|
||
*/
|
||
private function geldbuchung(array $eintrag, int $nr, 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->hashes[$nr] ?? $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<array{0: string, 1: float}>
|
||
*/
|
||
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<array<string,mixed>>
|
||
*/
|
||
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));
|
||
}
|
||
|
||
/**
|
||
* Vergibt die Fingerabdrücke aller Geldbuchungen im Zeitraum vorab.
|
||
*
|
||
* Das geschieht in einem eigenen Durchgang über die ganze Datei, weil der
|
||
* laufende Zähler für gleiche Fingerabdrücke sonst von der Kontoauswahl
|
||
* abhinge: Dieselbe Buchung bekäme im Teilimport einen anderen Hash als im
|
||
* Vollimport und würde ein zweites Mal angelegt.
|
||
*
|
||
* @return array<int,string> Position in der Datei → Hash
|
||
*/
|
||
private function hashes(): array
|
||
{
|
||
$this->hashZaehler = [];
|
||
$hashes = [];
|
||
|
||
foreach ($this->parser->eintraege() as $nr => $eintrag) {
|
||
if (!$this->istGeldbuchung($eintrag)) {
|
||
continue;
|
||
}
|
||
|
||
$datum = QifParser::datum(QifParser::feld($eintrag, 'D'), $this->parser->datumsformat());
|
||
if ($datum === null || $datum < $this->stichtag || $datum > $this->bis) {
|
||
continue;
|
||
}
|
||
|
||
$kontoId = $this->kontoId((string)$eintrag['konto']);
|
||
if ($kontoId === null) {
|
||
continue;
|
||
}
|
||
|
||
$hashes[$nr] = $this->eindeutigerHash(
|
||
$kontoId,
|
||
$datum,
|
||
QifParser::betrag(QifParser::feld($eintrag, 'U') ?? QifParser::feld($eintrag, 'T')),
|
||
(string)QifParser::feld($eintrag, 'N'),
|
||
(string)QifParser::feld($eintrag, 'M')
|
||
);
|
||
}
|
||
|
||
return $hashes;
|
||
}
|
||
|
||
/**
|
||
* Berührt der Eintrag eines der gewählten Konten?
|
||
*
|
||
* Ohne Auswahl gilt alles. Sonst zählt nicht nur der `!Account`-Abschnitt,
|
||
* in dem der Eintrag steht: Eine Umbuchung steht nur einmal in der Datei,
|
||
* und wäre allein das erfassende Konto maßgeblich, fehlte dem gewählten
|
||
* Konto die Gegenseite.
|
||
*/
|
||
private function gehoertZurAuswahl(array $eintrag): bool
|
||
{
|
||
if ($this->auswahl === null) {
|
||
return true;
|
||
}
|
||
|
||
foreach (self::beteiligteKonten($eintrag) as $name) {
|
||
if (isset($this->auswahl[$name])) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function gewaehlt(string $name): bool
|
||
{
|
||
return $this->auswahl === null || isset($this->auswahl[trim($name)]);
|
||
}
|
||
|
||
/**
|
||
* Alle Konten, auf denen ein Eintrag zu Buche schlägt: das Konto des
|
||
* Abschnitts sowie die Umbuchungsziele aus `L` und den Splitzeilen. Beim
|
||
* Wertpapiergeschäft sind es Depot und Verrechnungskonto – Depot- und
|
||
* Geldbuchung entstehen immer gemeinsam.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private static function beteiligteKonten(array $eintrag): array
|
||
{
|
||
$namen = [];
|
||
if ($eintrag['konto'] !== null) {
|
||
$namen[] = trim((string)$eintrag['konto']);
|
||
}
|
||
|
||
if ($eintrag['typ'] === 'Invst') {
|
||
$verrechnung = self::verrechnungskonto(QifParser::feld($eintrag, 'L'));
|
||
if ($verrechnung !== null) {
|
||
$namen[] = trim($verrechnung);
|
||
}
|
||
|
||
return array_values(array_unique($namen));
|
||
}
|
||
|
||
$angaben = QifParser::felder($eintrag, 'S');
|
||
$angaben[] = (string)QifParser::feld($eintrag, 'L');
|
||
|
||
foreach ($angaben as $angabe) {
|
||
$ziel = self::umbuchungsziel($angabe);
|
||
if ($ziel !== null) {
|
||
$namen[] = trim($ziel);
|
||
}
|
||
}
|
||
|
||
return array_values(array_unique($namen));
|
||
}
|
||
|
||
/**
|
||
* 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];
|
||
}
|
||
}
|