910 lines
36 KiB
PHP
910 lines
36 KiB
PHP
<?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 {
|
||
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, 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;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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<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::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<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;
|
||
}
|
||
|
||
/**
|
||
* Alle Buchungen, an denen die Bargeld-Tool-Prüfung zuletzt einen Fehler
|
||
* festgestellt hat – kontoübergreifend, denn die Prüfung hängt an der
|
||
* Kategorie der Buchung, nicht an ihrem Konto. Der Status 'fehler' steht
|
||
* für einen gescheiterten Abruf (siehe Services\BargeldAbgleich::FEHLER),
|
||
* nicht für eine im Bargeld-Tool fehlende Buchung.
|
||
*
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
public static function mitBargeldFehler(): array
|
||
{
|
||
return Database::fetchAll(
|
||
'SELECT b.id, b.datum, b.betrag, b.empfaenger, b.buchungstext,
|
||
b.bargeld_hinweis, b.bargeld_geprueft_am, b.konto_id, k.name AS konto_name
|
||
FROM buchungen b
|
||
JOIN konten k ON k.id = b.konto_id
|
||
WHERE b.benutzer_id = ? AND b.bargeld_status = \'fehler\'
|
||
ORDER BY b.datum DESC, b.id DESC',
|
||
[Mandant::id()]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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]);
|
||
}
|
||
}
|