Finanzmanager/src/Models/Buchung.php
fds2610-bot 42b43090bc Fix n.n.: Umbuchungen finden beide Seiten zueinander
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 13:11:59 +02:00

858 lines
33 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\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Buchungen samt Splitzeilen.
*
* Eine Umbuchung ist EINE Buchung mit `gegenkonto_id`. Das Kontoblatt des
* Gegenkontos zeigt sie mit umgekehrtem Vorzeichen — es gibt also zwei Sichten
* auf denselben Datensatz, keine zwei Datensätze. Auch einzelne Splitzeilen
* dürfen ein Gegenkonto tragen; dann erscheint auf jenem Konto nur der Betrag
* dieser Zeile.
*/
class Buchung
{
public const QUELLEN = [
'manuell' => '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<array<string,mixed>> */
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<int> $buchungIds
* @return array<int, list<array<string,mixed>>> 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<string> */
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<string,mixed> $daten
* @param list<array<string,mixed>> $splits
* @param list<string> $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 {
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, 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['quelle'], $felder['import_hash'],
]
);
$id = Database::lastInsertId();
self::speichereSplits($id, $splits);
self::speichereAdressen($id, $adressen);
return $id;
});
}
/**
* @param array<string,mixed> $daten
* @param list<array<string,mixed>> $splits
* @param list<string> $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 = ?
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'], $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.
*/
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
WHERE id = ? AND benutzer_id = ?',
[$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<array<string,mixed>> $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<array<string,mixed>> $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::execute('DELETE FROM buchungen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
}
/**
* 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<string,mixed> $filter suche, von, bis, kategorie_id, klasse_id, nur_offene
* @return list<array<string,mixed>>
*/
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.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,
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,
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<int,float> 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<int, array<string, float>> 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). Gezählt wird nur die eigene Sicht:
* Die Gegenseite einer Umbuchung hat per Definition ein Gegenkonto.
*
* @return array<int,int> 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
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.
*
* @return list<array<string,mixed>>
*/
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.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<array<string,mixed>>
*/
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<string,mixed> $daten
* @param list<array<string,mixed>> $splits
* @return array<string,mixed>
*/
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 !== [];
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);
}
$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,
'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<array<string,mixed>> $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<array<string,mixed>> $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<string> $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]);
}
}