'manuell erfasst', 'qif' => 'QIF-Import', 'fints' => 'Bankabruf', 'csv' => 'CSV-Import', ]; /** Beträge gelten als gleich, wenn sie sich um weniger als einen halben Cent unterscheiden. */ private const CENT = 0.005; public static function find(int $id): ?array { $zeile = Database::fetchOne( 'SELECT * FROM buchungen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()] ); return $zeile === false ? null : $zeile; } /** @return list> */ public static function splits(int $buchungId): array { return Database::fetchAll( 'SELECT * FROM buchung_splits WHERE buchung_id = ? ORDER BY sortierung, id', [$buchungId] ); } /** * Die Splitzeilen mehrerer Buchungen in einer Abfrage – das Kontoblatt gibt * sie dem Splitdialog mit, damit er eine bestehende Aufteilung zeigt, ohne * je Zeile nachzufragen. * * @param list $buchungIds * @return array>> buchung_id => Zeilen */ public static function splitsJeBuchung(array $buchungIds): array { $ids = array_values(array_unique(array_map('intval', $buchungIds))); if ($ids === []) { return []; } $zeilen = Database::fetchAll( 'SELECT * FROM buchung_splits WHERE buchung_id IN (' . implode(',', array_fill(0, count($ids), '?')) . ') ORDER BY buchung_id, sortierung, id', $ids ); $jeBuchung = []; foreach ($zeilen as $zeile) { $jeBuchung[(int)$zeile['buchung_id']][] = $zeile; } return $jeBuchung; } /** @return list */ public static function adressen(int $buchungId): array { return array_column( Database::fetchAll( 'SELECT text FROM buchungsadressen WHERE buchung_id = ? ORDER BY zeile', [$buchungId] ), 'text' ); } /** * @param array $daten * @param list> $splits * @param list $adressen */ public static function anlegen(array $daten, array $splits = [], array $adressen = []): int { $felder = self::normalisiere($daten, $splits); Monatsabschluss::verlangeOffen($felder['datum']); return (int)Database::transaktion(static function () use ($felder, $splits, $adressen): int { if ($felder['nummer'] === '') { $felder['nummer'] = self::naechsteNummer($felder['konto_id']); } Database::execute( 'INSERT INTO buchungen (benutzer_id, konto_id, datum, valuta, betrag, empfaenger, iban, bankname, buchungstext, nummer, abgeglichen, kategorie_id, klasse_id, gegenkonto_id, gesplittet, bewusst_ohne_kategorie, ohne_bargeld_abgleich, quelle, import_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ Mandant::id(), $felder['konto_id'], $felder['datum'], $felder['valuta'], $felder['betrag'], $felder['empfaenger'], $felder['iban'], $felder['bankname'], $felder['buchungstext'], $felder['nummer'], $felder['abgeglichen'], $felder['kategorie_id'], $felder['klasse_id'], $felder['gegenkonto_id'], $felder['gesplittet'], $felder['bewusst_ohne_kategorie'], $felder['ohne_bargeld_abgleich'], $felder['quelle'], $felder['import_hash'], ] ); $id = Database::lastInsertId(); self::speichereSplits($id, $splits); self::speichereAdressen($id, $adressen); return $id; }); } /** * Laufende Nummer je Konto: die höchste rein numerische Nummer plus eins. * Vorhandene Nummern (etwa Schecknummern aus dem QIF-Import) bleiben unberührt. */ private static function naechsteNummer(int $kontoId): string { $hoechste = Database::fetchValue( "SELECT MAX(CAST(nummer AS UNSIGNED)) FROM buchungen WHERE konto_id = ? AND nummer REGEXP '^[0-9]{1,9}$'", [$kontoId], 0 ); return (string)((int)$hoechste + 1); } /** * @param array $daten * @param list> $splits * @param list $adressen */ public static function speichern(int $id, array $daten, array $splits = [], array $adressen = []): void { $bisher = self::find($id); if ($bisher === null) { throw new \RuntimeException('Die Buchung gibt es nicht.'); } $felder = self::normalisiere($daten, $splits); // Beide Seiten prüfen: Eine Buchung darf weder in einen abgeschlossenen // Monat hinein noch aus einem heraus verschoben werden. Monatsabschluss::verlangeOffen((string)$bisher['datum']); Monatsabschluss::verlangeOffen($felder['datum']); Database::transaktion(static function () use ($id, $felder, $splits, $adressen): void { Database::execute( 'UPDATE buchungen SET konto_id = ?, datum = ?, valuta = ?, betrag = ?, empfaenger = ?, buchungstext = ?, nummer = ?, abgeglichen = ?, kategorie_id = ?, klasse_id = ?, gegenkonto_id = ?, gesplittet = ?, bewusst_ohne_kategorie = ?, ohne_bargeld_abgleich = ? WHERE id = ? AND benutzer_id = ?', [ $felder['konto_id'], $felder['datum'], $felder['valuta'], $felder['betrag'], $felder['empfaenger'], $felder['buchungstext'], $felder['nummer'], $felder['abgeglichen'], $felder['kategorie_id'], $felder['klasse_id'], $felder['gegenkonto_id'], $felder['gesplittet'], $felder['bewusst_ohne_kategorie'], $felder['ohne_bargeld_abgleich'], $id, Mandant::id(), ] ); Database::execute('DELETE FROM buchung_splits WHERE buchung_id = ?', [$id]); Database::execute('DELETE FROM buchungsadressen WHERE buchung_id = ?', [$id]); self::speichereSplits($id, $splits); self::speichereAdressen($id, $adressen); }); // Wurde die Buchung eben erst zur Umbuchung (oder bekam ein neues // Gegenkonto), steht die Gegenseite vielleicht schon einzeln im Kontoblatt. if ( $felder['gegenkonto_id'] !== null && $felder['gesplittet'] === 0 && ((int)($bisher['gegenkonto_id'] ?? 0) !== $felder['gegenkonto_id'] || (int)$bisher['konto_id'] !== $felder['konto_id']) ) { self::gegenseiteSuchen($id); } } /** * Sucht zu einer Umbuchung die Gegenseite, die schon als eigene Buchung * dasteht, und legt beide zusammen. Ein Fehler (etwa ein abgeschlossener * Monat) hindert die Zuordnung nicht – die Buchungen bleiben dann getrennt. */ private static function gegenseiteSuchen(int $id): void { try { Bankumsatz::gegenseiteZusammenfuehren($id); } catch (\Throwable) { // Bleibt getrennt; der Nutzer kann im Kontoblatt verknüpfen. } } /** * Kategorie oder Gegenkonto einer Buchung setzen – die Schnellzuordnung aus * dem Kontoblatt. Alles andere an der Buchung bleibt unberührt. * * Wird die Buchung zur Umbuchung, sucht das Programm die Gegenseite, die schon * einzeln gebucht wurde, und legt beide zusammen (`$gegenseiteSuchen`). * * Beides zugleich gibt es nicht: Eine Buchung ist entweder kategorisiert * oder eine Umbuchung. War sie gesplittet, fallen die Zeilen weg – die * Zuordnung steckt danach wieder oben. * * Bekommt eine Buchung, die bewusst ohne Kategorie gebucht war, doch eine * Zuordnung, verliert sie diese Markierung (#134). Wird die Zuordnung nur * geleert, bleibt sie stehen. */ public static function zuordnen(int $id, ?int $kategorieId, ?int $gegenkontoId, bool $gegenseiteSuchen = true): void { $buchung = self::find($id); if ($buchung === null) { throw new \RuntimeException('Die Buchung gibt es nicht.'); } Monatsabschluss::verlangeOffen((string)$buchung['datum']); if ($gegenkontoId !== null) { $kategorieId = null; self::pruefeGegenkonto($gegenkontoId, (int)$buchung['konto_id']); } if ($kategorieId !== null && Kategorie::find($kategorieId) === null) { throw new \InvalidArgumentException('Die Kategorie gibt es nicht.'); } Database::transaktion(static function () use ($id, $kategorieId, $gegenkontoId): void { Database::execute('DELETE FROM buchung_splits WHERE buchung_id = ?', [$id]); Database::execute( 'UPDATE buchungen SET kategorie_id = ?, gegenkonto_id = ?, gesplittet = 0, bewusst_ohne_kategorie = IF(? IS NULL AND ? IS NULL, bewusst_ohne_kategorie, 0) WHERE id = ? AND benutzer_id = ?', [$kategorieId, $gegenkontoId, $kategorieId, $gegenkontoId, $id, Mandant::id()] ); }); if ($gegenkontoId !== null && $gegenseiteSuchen) { self::gegenseiteSuchen($id); } } /** * Die Klasse einer Buchung – die Schnellauswahl im Kontoblatt. `null` * löscht sie. * * Die Zuordnung bleibt unberührt: Die Klasse ist die zweite * Auswertungsdimension neben Kategorie und Gegenkonto, keine Alternative * dazu. Auch eine Splitbuchung behält deshalb ihre Zeilen. */ public static function klasseSetzen(int $id, ?int $klasseId): void { $buchung = self::find($id); if ($buchung === null) { throw new \RuntimeException('Die Buchung gibt es nicht.'); } Monatsabschluss::verlangeOffen((string)$buchung['datum']); if ($klasseId !== null && Klasse::find($klasseId) === null) { throw new \InvalidArgumentException('Die Klasse gibt es nicht.'); } Database::execute( 'UPDATE buchungen SET klasse_id = ? WHERE id = ? AND benutzer_id = ?', [$klasseId, $id, Mandant::id()] ); } /** * Die Klasse, die das Kontoblatt für eine Splitbuchung zeigt. Tragen alle * Splitzeilen dieselbe Klasse, gilt sie für die ganze Buchung; unterscheiden * sie sich, ist die Buchung gemischt und die Spalte zeigt „Splitbuchung“. * Eine Zeile ohne Klasse zählt dabei als eigene Angabe. Hat keine Zeile eine * Klasse, bleibt es bei der Klasse der Buchung selbst – so zeigen ältere * Splitbuchungen weiter, was an ihnen steht. * * @param list> $splitzeilen * @return array{gemischt: bool, klasse_id: ?int} */ public static function splitKlasse(array $splitzeilen, ?int $eigeneKlasse): array { $klassen = []; foreach ($splitzeilen as $zeile) { $klassen[(int)($zeile['klasse_id'] ?? 0)] = true; } if (count($klassen) > 1) { return ['gemischt' => true, 'klasse_id' => null]; } $klasse = (int)array_key_first($klassen); return ['gemischt' => false, 'klasse_id' => $klasse > 0 ? $klasse : $eigeneKlasse]; } /** * Die Splitzeilen einer Buchung ersetzen – der Splitdialog des Kontoblatts. * Eine leere Liste hebt die Splitbuchung auf; die Buchung steht danach ohne * Zuordnung da. * * @param list> $splits */ public static function splitsErsetzen(int $id, array $splits): void { $buchung = self::find($id); if ($buchung === null) { throw new \RuntimeException('Die Buchung gibt es nicht.'); } Monatsabschluss::verlangeOffen((string)$buchung['datum']); self::pruefeSplits($splits, (float)$buchung['betrag'], (int)$buchung['konto_id']); Database::transaktion(static function () use ($id, $splits): void { Database::execute('DELETE FROM buchung_splits WHERE buchung_id = ?', [$id]); Database::execute( 'UPDATE buchungen SET kategorie_id = NULL, gegenkonto_id = NULL, gesplittet = ? WHERE id = ? AND benutzer_id = ?', [$splits === [] ? 0 : 1, $id, Mandant::id()] ); self::speichereSplits($id, $splits); }); } public static function loeschen(int $id): void { $buchung = self::find($id); if ($buchung !== null) { Monatsabschluss::verlangeOffen((string)$buchung['datum']); } // Ein Umsatz, dessen Buchung verschwindet, gehört zurück in die // Vorschlagsliste. Der Fremdschlüssel setzt zwar `buchung_id` auf NULL, // lässt den Status aber auf „uebernommen“ stehen – und damit wäre der // Umsatz unsichtbar: nicht im Kontoblatt, nicht in der Vorschlagsliste, // und beim nächsten Abruf über den Fingerabdruck als schon vorhanden // übersprungen. Bankumsatz::loeseVonBuchung($id); Database::transaktion(static function () use ($id, $buchung): void { Database::execute('DELETE FROM buchungen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); // Entstand die Buchung aus einer regelmäßigen Buchung, ist deren // Termin wieder offen. if ($buchung !== null && !empty($buchung['regelmaessige_id']) && !empty($buchung['faelligkeit'])) { RegelmaessigeBuchung::terminWiederOeffnen( (int)$buchung['regelmaessige_id'], (string)$buchung['faelligkeit'] ); } }); } /** * Führt zwei Buchungen zusammen, die auf diesem Konto dieselbe Umbuchung * doppelt zeigen: einmal als schon fertige Umbuchung (eigene Seite oder * Gegenseite), einmal als einzeln erfasste Buchung mit demselben Betrag – * etwa weil die Bank sie erst Tage später gebucht hat und der automatische * Abgleich sie deshalb nicht zusammenführte. * * Die Umbuchung bleibt bestehen, die einzelne Buchung fällt weg; ihr * Bankumsatz hängt sich stattdessen an die Umbuchung, wie es ein * automatischer Abgleich auch täte. */ public static function verknuepfen(int $kontoId, int $idA, int $idB): void { $a = self::find($idA); $b = self::find($idB); if ($a === null || $b === null) { throw new \RuntimeException('Eine der beiden Buchungen gibt es nicht.'); } $istUmbuchungHier = static fn(array $z): bool => $z['gegenkonto_id'] !== null && (int)$z['gesplittet'] === 0 && ((int)$z['konto_id'] === $kontoId || (int)$z['gegenkonto_id'] === $kontoId); $istEinzelnHier = static fn(array $z): bool => $z['gegenkonto_id'] === null && (int)$z['gesplittet'] === 0 && (int)$z['konto_id'] === $kontoId; [$umbuchung, $einzeln] = match (true) { $istUmbuchungHier($a) && $istEinzelnHier($b) => [$a, $b], $istUmbuchungHier($b) && $istEinzelnHier($a) => [$b, $a], default => throw new \RuntimeException( 'Verknüpfen geht nur zwischen einer Umbuchung und einer einzeln erfassten ' . 'Buchung auf diesem Konto.' ), }; // Auf der Gegenkontoseite zählt der Betrag mit umgekehrtem Vorzeichen, // wie im Kontoblatt auch – ein grober Kontrollblick, kein Ersatz für die // eigene Prüfung der Auswahl. $vorzeichen = (int)$umbuchung['konto_id'] === $kontoId ? 1 : -1; $effektiv = $vorzeichen * (float)$umbuchung['betrag']; if (abs($effektiv - (float)$einzeln['betrag']) >= self::CENT) { throw new \RuntimeException( 'Die Beträge der beiden Buchungen passen nicht zusammen – bitte die Auswahl prüfen.' ); } Database::transaktion(static function () use ($umbuchung, $einzeln): void { Database::execute( 'UPDATE bank_umsaetze SET buchung_id = ? WHERE buchung_id = ? AND benutzer_id = ?', [(int)$umbuchung['id'], (int)$einzeln['id'], Mandant::id()] ); Database::execute( 'UPDATE buchungen SET abgeglichen = 1 WHERE id = ? AND benutzer_id = ?', [(int)$umbuchung['id'], Mandant::id()] ); self::loeschen((int)$einzeln['id']); }); } /** * Das Kontoblatt: eigene Buchungen und die Gegenseiten fremder Umbuchungen, * chronologisch mit laufendem Saldo. * * @param array $filter suche, von, bis, kategorie_id, klasse_id, nur_offene * @return list> */ public static function kontoblatt(int $kontoId, array $filter = []): array { $params = [$kontoId, $kontoId, $kontoId]; $sql = 'SELECT * FROM ( SELECT b.id, b.datum, b.valuta, b.betrag, b.empfaenger, b.buchungstext, b.nummer, b.abgeglichen, b.kategorie_id, b.klasse_id, b.gesplittet, b.quelle, b.bargeld_status, b.bargeld_hinweis, b.bewusst_ohne_kategorie, b.ohne_bargeld_abgleich, b.gegenkonto_id, b.konto_id, 0 AS gegensicht, NULL AS split_id, b.iban, b.bankname FROM buchungen b WHERE b.konto_id = ? UNION ALL SELECT b.id, b.datum, b.valuta, -b.betrag, b.empfaenger, b.buchungstext, b.nummer, b.abgeglichen, b.kategorie_id, b.klasse_id, b.gesplittet, b.quelle, NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie, 0 AS ohne_bargeld_abgleich, b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, NULL AS split_id, \'\' AS iban, \'\' AS bankname FROM buchungen b WHERE b.gegenkonto_id = ? AND b.gesplittet = 0 UNION ALL SELECT b.id, b.datum, b.valuta, -s.betrag, b.empfaenger, s.notiz, b.nummer, b.abgeglichen, s.kategorie_id, s.klasse_id, 0 AS gesplittet, b.quelle, NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie, 0 AS ohne_bargeld_abgleich, b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, s.id AS split_id, \'\' AS iban, \'\' AS bankname FROM buchung_splits s JOIN buchungen b ON b.id = s.buchung_id WHERE s.gegenkonto_id = ? ) AS blatt'; $wo = []; if (!empty($filter['von'])) { $wo[] = 'datum >= ?'; $params[] = $filter['von']; } if (!empty($filter['bis'])) { $wo[] = 'datum <= ?'; $params[] = $filter['bis']; } if (!empty($filter['kategorie_id'])) { $wo[] = 'kategorie_id = ?'; $params[] = (int)$filter['kategorie_id']; } if (!empty($filter['klasse_id'])) { $wo[] = 'klasse_id = ?'; $params[] = (int)$filter['klasse_id']; } if (!empty($filter['nur_offene'])) { $wo[] = 'abgeglichen = 0'; } if (($filter['suche'] ?? '') !== '') { $wo[] = '(empfaenger LIKE ? OR buchungstext LIKE ? OR nummer LIKE ?)'; $muster = '%' . $filter['suche'] . '%'; $params[] = $muster; $params[] = $muster; $params[] = $muster; } if ($wo !== []) { $sql .= ' WHERE ' . implode(' AND ', $wo); } $sql .= ' ORDER BY datum, id, split_id'; $zeilen = Database::fetchAll($sql, $params); // Laufender Saldo. Er beginnt beim Anfangssaldo des Kontos, aber nur in // der ungefilterten Ansicht – bei gesetztem Filter wäre er irreführend. $gefiltert = $wo !== []; $saldo = $gefiltert ? 0.0 : (float)(Konto::find($kontoId)['anfangssaldo'] ?? 0); foreach ($zeilen as $i => $zeile) { $saldo += (float)$zeile['betrag']; $zeilen[$i]['saldo'] = $gefiltert ? null : $saldo; $zeilen[$i]['gegensicht'] = (int)$zeile['gegensicht'] === 1; } return $zeilen; } /** * Salden aller Konten in einem Rutsch: Anfangssaldo plus Buchungen, plus die * Gegenseiten fremder Umbuchungen. Ohne Stichtag zählt alles (erwarteter * Saldo), mit Stichtag nur bis dahin (aktueller Saldo). * * @return array konto_id => Saldo */ public static function saldenJeKonto(?string $bis = null): array { $benutzer = Mandant::id(); $salden = []; $grenze = $bis === null ? '' : ' AND b.datum <= ?'; $p = $bis === null ? [$benutzer] : [$benutzer, $bis]; $quellen = [ 'SELECT b.konto_id AS k, SUM(b.betrag) AS s FROM buchungen b WHERE b.benutzer_id = ?' . $grenze . ' GROUP BY b.konto_id', 'SELECT b.gegenkonto_id AS k, -SUM(b.betrag) AS s FROM buchungen b WHERE b.benutzer_id = ? AND b.gegenkonto_id IS NOT NULL AND b.gesplittet = 0' . $grenze . ' GROUP BY b.gegenkonto_id', 'SELECT s.gegenkonto_id AS k, -SUM(s.betrag) AS s FROM buchung_splits s JOIN buchungen b ON b.id = s.buchung_id WHERE b.benutzer_id = ? AND s.gegenkonto_id IS NOT NULL' . $grenze . ' GROUP BY s.gegenkonto_id', ]; foreach ($quellen as $sql) { foreach (Database::fetchAll($sql, $p) as $zeile) { $id = (int)$zeile['k']; $salden[$id] = ($salden[$id] ?? 0.0) + (float)$zeile['s']; } } return $salden; } /** * Dieselben drei Quellen wie saldenJeKonto(), aber nach Monat getrennt und * auf einen Zeitraum begrenzt. Für die Vermögensentwicklung: Ein Stichtag je * Monat wären zwölf Abfragen, so genügt eine je Quelle. * * @return array> konto_id => ['JJJJ-MM' => Bewegung] */ public static function bewegungenJeKontoUndMonat(string $von, string $bis): array { $benutzer = Mandant::id(); $p = [$benutzer, $von, $bis]; $quellen = [ "SELECT b.konto_id AS k, DATE_FORMAT(b.datum, '%Y-%m') AS m, SUM(b.betrag) AS s FROM buchungen b WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ? GROUP BY b.konto_id, m", "SELECT b.gegenkonto_id AS k, DATE_FORMAT(b.datum, '%Y-%m') AS m, -SUM(b.betrag) AS s FROM buchungen b WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ? AND b.gegenkonto_id IS NOT NULL AND b.gesplittet = 0 GROUP BY b.gegenkonto_id, m", "SELECT s.gegenkonto_id AS k, DATE_FORMAT(b.datum, '%Y-%m') AS m, -SUM(s.betrag) AS s FROM buchung_splits s JOIN buchungen b ON b.id = s.buchung_id WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ? AND s.gegenkonto_id IS NOT NULL GROUP BY s.gegenkonto_id, m", ]; $bewegungen = []; foreach ($quellen as $sql) { foreach (Database::fetchAll($sql, $p) as $zeile) { $id = (int)$zeile['k']; $monat = (string)$zeile['m']; $bewegungen[$id][$monat] = ($bewegungen[$id][$monat] ?? 0.0) + (float)$zeile['s']; } } return $bewegungen; } /** * Frühestes und spätestes Buchungsdatum – die Grenzen für den Zeitraum * „Gesamter Zeitraum“ in den Auswertungen. Ohne Buchungen beides null. * * @return array{von: ?string, bis: ?string} */ public static function datumsgrenzen(): array { $zeile = Database::fetchOne( 'SELECT MIN(datum) AS von, MAX(datum) AS bis FROM buchungen WHERE benutzer_id = ?', [Mandant::id()] ); return [ 'von' => ($zeile['von'] ?? null) === null ? null : (string)$zeile['von'], 'bis' => ($zeile['bis'] ?? null) === null ? null : (string)$zeile['bis'], ]; } /** * Buchungen ohne Zuordnung, je Konto gezählt – die Kontenübersicht hebt * damit die Konten hervor, in denen noch Arbeit liegt. * * Unkategorisiert heißt hier dasselbe wie im Cockpit: keine Kategorie, kein * Gegenkonto (eine Umbuchung ist zugeordnet) und nicht gesplittet (dann * steckt die Zuordnung in den Zeilen). Was bewusst ohne Kategorie gebucht * ist (#134), liegt nicht als Arbeit an. Gezählt wird nur die eigene Sicht: * Die Gegenseite einer Umbuchung hat per Definition ein Gegenkonto. * * @return array konto_id => Anzahl */ public static function unkategorisierteJeKonto(?int $jahr = null): array { $jahr ??= (int)date('Y'); $zeilen = Database::fetchAll( 'SELECT konto_id, COUNT(*) AS anzahl FROM buchungen WHERE benutzer_id = ? AND YEAR(datum) = ? AND gesplittet = 0 AND kategorie_id IS NULL AND gegenkonto_id IS NULL AND bewusst_ohne_kategorie = 0 GROUP BY konto_id', [Mandant::id(), $jahr] ); $anzahlen = []; foreach ($zeilen as $zeile) { $anzahlen[(int)$zeile['konto_id']] = (int)$zeile['anzahl']; } return $anzahlen; } /** * Buchungen ganz ohne Kategorie – weder direkt noch in einer Splitzeile. * Grundlage für den Regel-Testlauf: Er soll zeigen, wie viele Lücken eine * (neue oder geänderte) Regel schließen würde, nicht wie viele Umsätze * noch nicht abgeglichen sind – das sind zwei verschiedene Fragen. * * Kein Gegenkonto und nicht gesplittet ohne jede kategorisierte Zeile: * dieselbe Definition wie bei {@see unkategorisierteJeKonto()}, nur als * Liste statt als Zählung je Konto. Bewusst ohne Kategorie gebuchte * Buchungen (#134) sind keine Lücke, die eine Regel schließen sollte. * * @return list> */ public static function ohneKategorie(): array { return Database::fetchAll( 'SELECT b.* FROM buchungen b WHERE b.benutzer_id = ? AND b.gegenkonto_id IS NULL AND b.bewusst_ohne_kategorie = 0 AND ( (b.gesplittet = 0 AND b.kategorie_id IS NULL) OR (b.gesplittet = 1 AND NOT EXISTS ( SELECT 1 FROM buchung_splits s WHERE s.buchung_id = b.id AND s.kategorie_id IS NOT NULL )) ) ORDER BY b.datum', [Mandant::id()] ); } /** * Die jüngsten Buchungen zu einem Empfängernamen, kontenübergreifend – für * die Adressverwaltung: Wer sich eine Adresse ansieht, will wissen, was * zuletzt mit ihr gebucht wurde. Der Empfänger ist ein Freitextfeld, daher * zählt der exakte Name; die Kollation der Spalte macht das unabhängig von * Groß-/Kleinschreibung. * * @return list> */ public static function letzteFuerEmpfaenger(string $empfaenger, int $limit = 5): array { if ($empfaenger === '') { return []; } return Database::fetchAll( 'SELECT b.id, b.datum, b.betrag, b.buchungstext, k.name AS kontoname FROM buchungen b JOIN konten k ON k.id = b.konto_id WHERE b.benutzer_id = ? AND b.empfaenger = ? ORDER BY b.datum DESC, b.id DESC LIMIT ' . max(1, $limit), [Mandant::id(), $empfaenger] ); } /** Wie viele Buchungen hat das Konto (beide Sichten)? */ public static function anzahlFuerKonto(int $kontoId): int { return (int)Database::fetchValue( 'SELECT (SELECT COUNT(*) FROM buchungen WHERE konto_id = ?) + (SELECT COUNT(*) FROM buchungen WHERE gegenkonto_id = ? AND gesplittet = 0) + (SELECT COUNT(*) FROM buchung_splits WHERE gegenkonto_id = ?)', [$kontoId, $kontoId, $kontoId], 0 ); } public static function anzahl(): int { return (int)Database::fetchValue( 'SELECT COUNT(*) FROM buchungen WHERE benutzer_id = ?', [Mandant::id()], 0 ); } /** * Fingerabdruck für die Dublettenerkennung. Bewusst über die Merkmale, die * eine Bank liefert – eine zweite Buchung mit identischem Konto, Datum, * Betrag, Nummer und Text ist im Alltag dieselbe Buchung. */ public static function hash(int $kontoId, string $datum, float $betrag, string $nummer, string $text): string { return sha1(implode('|', [ $kontoId, $datum, number_format($betrag, 2, '.', ''), $nummer, trim($text), ])); } public static function findeNachHash(string $hash): ?array { $zeile = Database::fetchOne( 'SELECT * FROM buchungen WHERE import_hash = ? AND benutzer_id = ?', [$hash, Mandant::id()] ); return $zeile === false ? null : $zeile; } /** * @param array $daten * @param list> $splits * @return array */ private static function normalisiere(array $daten, array $splits): array { $kontoId = (int)($daten['konto_id'] ?? 0); if ($kontoId === 0 || Konto::find($kontoId) === null) { throw new \InvalidArgumentException('Die Buchung braucht ein gültiges Konto.'); } $datum = trim((string)($daten['datum'] ?? '')); if (!self::istDatum($datum)) { throw new \InvalidArgumentException('Die Buchung braucht ein gültiges Datum (JJJJ-MM-TT).'); } $betrag = round((float)($daten['betrag'] ?? 0), 2); $gegenkonto = self::id($daten['gegenkonto_id'] ?? null); $kategorie = self::id($daten['kategorie_id'] ?? null); $gesplittet = $splits !== []; $bewusstOhne = !empty($daten['bewusst_ohne_kategorie']); if ($gegenkonto !== null) { self::pruefeGegenkonto($gegenkonto, $kontoId); } if ($gesplittet) { // Bei einer Splitbuchung steckt die Zuordnung in den Zeilen. $kategorie = null; $gegenkonto = null; self::pruefeSplits($splits, $betrag, $kontoId); } // „Bewusst ohne Kategorie“ (#134) gilt nur, solange die Buchung auch // wirklich nichts zugeordnet hat. Eine Zuordnung geht nie verloren – // im Zweifel entfällt die Markierung. if ($kategorie !== null || $gegenkonto !== null || $gesplittet) { $bewusstOhne = false; } $valuta = trim((string)($daten['valuta'] ?? '')); return [ 'konto_id' => $kontoId, 'datum' => $datum, 'valuta' => self::istDatum($valuta) ? $valuta : null, 'betrag' => $betrag, 'empfaenger' => mb_substr(trim((string)($daten['empfaenger'] ?? '')), 0, 255), 'iban' => mb_substr(strtoupper(trim((string)($daten['iban'] ?? ''))), 0, 34), 'bankname' => mb_substr(trim((string)($daten['bankname'] ?? '')), 0, 128), 'buchungstext' => trim((string)($daten['buchungstext'] ?? '')), 'nummer' => mb_substr(trim((string)($daten['nummer'] ?? '')), 0, 32), 'abgeglichen' => !empty($daten['abgeglichen']) ? 1 : 0, 'kategorie_id' => $kategorie, 'klasse_id' => self::id($daten['klasse_id'] ?? null), 'gegenkonto_id' => $gegenkonto, 'gesplittet' => $gesplittet ? 1 : 0, 'bewusst_ohne_kategorie' => $bewusstOhne ? 1 : 0, // Ohne Bargeld-Abgleich (#136): Die Buchung wird nicht gegen das // Bargeld-Tool geprüft und deshalb auch nicht als offen angemahnt. 'ohne_bargeld_abgleich' => !empty($daten['ohne_bargeld_abgleich']) ? 1 : 0, 'quelle' => isset(self::QUELLEN[(string)($daten['quelle'] ?? '')]) ? (string)$daten['quelle'] : 'manuell', 'import_hash' => ($daten['import_hash'] ?? null) ?: null, ]; } /** Ein Konto kann nicht sein eigenes Gegenkonto sein. */ private static function pruefeGegenkonto(int $gegenkontoId, int $kontoId): void { if ($gegenkontoId === $kontoId) { throw new \InvalidArgumentException('Eine Umbuchung braucht zwei verschiedene Konten.'); } if (Konto::find($gegenkontoId) === null) { throw new \InvalidArgumentException('Das Gegenkonto gibt es nicht.'); } } /** * Die Summe der Zeilen muss den Buchungsbetrag ergeben, und keine Zeile darf * auf das eigene Konto zeigen – sonst zählte die Buchung dort doppelt. * * @param list> $splits */ private static function pruefeSplits(array $splits, float $betrag, int $kontoId): void { if ($splits === []) { return; } $summe = 0.0; foreach ($splits as $split) { $summe += round((float)($split['betrag'] ?? 0), 2); $gegenkonto = self::id($split['gegenkonto_id'] ?? null); if ($gegenkonto !== null) { self::pruefeGegenkonto($gegenkonto, $kontoId); } } if (abs($summe - $betrag) > self::CENT) { throw new \InvalidArgumentException(sprintf( 'Die Splitzeilen ergeben %s, die Buchung lautet über %s.', number_format($summe, 2, ',', '.'), number_format($betrag, 2, ',', '.') )); } } /** @param list> $splits */ private static function speichereSplits(int $buchungId, array $splits): void { foreach (array_values($splits) as $nr => $split) { Database::execute( 'INSERT INTO buchung_splits (buchung_id, kategorie_id, klasse_id, gegenkonto_id, betrag, notiz, sortierung) VALUES (?, ?, ?, ?, ?, ?, ?)', [ $buchungId, self::id($split['kategorie_id'] ?? null), self::id($split['klasse_id'] ?? null), self::id($split['gegenkonto_id'] ?? null), round((float)($split['betrag'] ?? 0), 2), mb_substr(trim((string)($split['notiz'] ?? '')), 0, 255), $nr, ] ); } } /** @param list $adressen */ private static function speichereAdressen(int $buchungId, array $adressen): void { $zeile = 0; foreach ($adressen as $text) { $text = trim((string)$text); if ($text === '') { continue; } Database::execute( 'INSERT INTO buchungsadressen (buchung_id, zeile, text) VALUES (?, ?, ?)', [$buchungId, ++$zeile, mb_substr($text, 0, 255)] ); } } private static function id(mixed $wert): ?int { if ($wert === null || $wert === '' || (int)$wert === 0) { return null; } return (int)$wert; } public static function istDatum(string $datum): bool { if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $datum, $t)) { return false; } return checkdate((int)$t[2], (int)$t[3], (int)$t[1]); } }