Damit ist der geplante Funktionsumfang beisammen. Phase 4 abgeschlossen - Konfigurierbare Cockpit-Kacheln; abgewählte werden nicht berechnet - Einnahmen/Ausgaben je Monat als Balkenpaare mit Saldolinie, aufgesetzt auf den Summenbericht statt auf eine eigene Abfrage - Meine Ausgaben als Ring auf der obersten Kategorieebene - Offene Vorgänge aus allen Modulen Phase 5 – Online-Banking - nemiah/php-fints als Fork auf Codeberg, psr/log direkt eingebaut: die Anwendung hängt an keinem fremden Update-Zyklus - PIN AES-256-GCM-verschlüsselt; Schlüssel aus APP_KEY oder aus data/schluessel/app_key, den der Entrypoint einmalig erzeugt - TAN-Dialog über mehrere Requests (FinTs::persist plus serialisierte Aktion in der Sitzung) – ein wartender Prozess wäre mit php-fpm der falsche Weg - Abgleich statt Direktbuchung: verbinden, übernehmen oder verwerfen - Regeln mit vier Vergleichsarten, Betragsgrenzen und Split-Schema Phase 6 – Wertpapiere - Depotbuchung und Geldbuchung als zwei Seiten eines Geschäfts; betrag ist der Bruttowert, Nebenkosten stehen daneben - Einstand nach gleitendem Durchschnitt, realisierte Gewinne, Erträge - Kursabruf über Leeway oder FMP, täglicher Lauf per Cron - QIF-Import für !Type:Invst Dabei behoben: Der Import buchte den Geldfluss jedes Wertpapiergeschäfts als Abgang – auch bei Verkäufen und Dividenden; und er erkannte das Verrechnungskonto nicht, wenn dem Konto im L-Feld eine Kategorie vorangeht (bei Verkäufen also immer). Phase 7 – Nebenmodule und Zahlungsverkehr - Buchungsvorlagen inklusive QIF-Import, Splits werden mitskaliert - Regelmäßige Buchungen mit Fälligkeitslauf, Finanzkalender, Erinnerungen - Adressen mit IBAN-Prüfung, Verträge mit Kündigungsfrist, Sparziele - Kredite mit gerechnetem Tilgungsplan - Überweisungen über FinTS; die Buchung entsteht erst nach Annahme durch die Bank. Die pain.001-Nachricht erzeugt SepaXml selbst Nebenbei: .dockerignore ergänzt (ohne sie brach der Build an data/mariadb ab), PlatzhalterController entfernt, zwei Inline-Handler beseitigt, die die Content-Security-Policy ohnehin blockierte. 888 PHP-Prüfungen und 45 E2E-Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
652 lines
24 KiB
PHP
652 lines
24 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.
|
||
*/
|
||
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,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 list<array{typ: string, text: string}> */
|
||
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<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,
|
||
'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<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;
|
||
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<string,int>
|
||
*/
|
||
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<array{0: string, 1: string}>} $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<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));
|
||
}
|
||
|
||
/**
|
||
* 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];
|
||
}
|
||
}
|