Finanzmanager/src/Models/Buchung.php
fds2610-bot fe40a29f35 Buchungen: Gegenkonto sichtbar, Zuordnung direkt im Kontoblatt
Eine Umbuchung war auf dem Gegenkonto nicht mehr zuzuordnen: Die Zeile trug die
Marke „Umbuchung“, die Kategoriespalte daneben blieb leer. Die Daten waren
richtig, die Anzeige verschwieg sie. In der Kategoriespalte steht jetzt auch in
der Gegensicht das Konto in eckigen Klammern, von dem die Umbuchung kommt – die
Abfrage lieferte es längst mit.

Kategorie und Gegenkonto stehen in einem Feld (`Views/buchungen/zuordnungsliste.php`):
Unter den Kategorien stehen die eigenen Konten in eckigen Klammern, wie im
FinanzManager, dessen Kategoriefeld `[Konto]` annimmt. Eines davon macht die
Buchung zur Umbuchung; die Auswahl „Ausgabe / Einnahme“ bestimmt seither nur
noch die Richtung. Dieselbe Liste steht in den Splitzeilen – eine Splitzeile mit
Gegenkonto ließ sich bisher nur im Import anlegen und ging beim Bearbeiten der
Buchung stillschweigend verloren.

Schnellzuordnung: Ein Klick auf die Kategoriespalte öffnet die Liste in der
Zelle, die Auswahl speichert sofort (POST /buchungen/{id}/zuordnung) und kommt
in dieselbe Ansicht zurück. Die Liste gibt es einmal und wandert in die
angeklickte Zelle – eine Liste je Zeile wären bei hundert Zeilen ebenso viele
vollständige Kategoriebäume im HTML. Feld und Liste sind gleich breit und gleich
hoch, sonst rückte beim Anklicken die halbe Zeile. Ohne JavaScript führt
derselbe Klick in die Buchungsmaske: Die Zelle ist ein Link. Die Gegenseite
einer fremden Umbuchung führt zu ihrer Buchung auf dem anderen Konto.

Der Eintrag „Splitbuchung“ öffnet ein modales Fenster mit Zeilen für Kategorie
oder Gegenkonto, Klasse, Notiz und Betrag; der Restbetrag wird mitgerechnet. Ein
Klick auf eine bereits gesplittete Buchung öffnet direkt den Dialog. „Splitbuchung
löschen“ verwirft alle Zeilen samt ihren Zuordnungen – auch die Gegenseite einer
Splitzeile mit Gegenkonto –, die Buchung steht danach unzugeordnet im Kontoblatt.
Beide Wege gehen über `Buchung::zuordnen()` und `Buchung::splitsErsetzen()`,
achten den Monatsabschluss und lassen den Rest der Buchung unberührt.

`public/static/betrag.js` bündelt das Lesen deutscher Beträge und die
Restbetragsrechnung für Maske und Kontoblatt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:28:33 +02:00

650 lines
25 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, 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['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);
});
}
/**
* Kategorie oder Gegenkonto einer Buchung setzen – die Schnellzuordnung aus
* dem Kontoblatt. Alles andere an der Buchung bleibt unberührt.
*
* 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): 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()]
);
});
}
/**
* 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()]);
}
/**
* 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.gegenkonto_id, b.konto_id, 0 AS gegensicht, NULL AS split_id
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,
b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, NULL AS split_id
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,
b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, s.id AS split_id
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;
}
/** 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),
'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]);
}
}