Finanzmanager/src/Services/QifBuchungsImport.php
fds2610-bot f388d04c05 Phasen 4 bis 7: Cockpit, Online-Banking, Wertpapiere, Nebenmodule
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>
2026-08-12 06:59:24 +02:00

652 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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];
}
}