Finanzmanager/src/Models/Buchung.php
fds2610-bot ddf0638693 Fix #21: Abgleich mit dem Bargeld-Tool
Beim Zuweisen einer Kategorie fragt der Finanzmanager beim Bargeld-Tool
(FSCash) nach, ob es zum Betrag dort schon eine Buchung gibt.

Eingerichtet wird das unter Verwaltung → Bargeld-Tool: Adresse,
Zugangsdaten (Passwort AES-verschlüsselt), Suchzeitraum und die Auswahl
der abzugleichenden Kategorien. Die Vormerkung steht als
`bargeld_abgleich` an der Kategorie selbst, nicht als Nummernliste in den
Einstellungen – so verschwindet sie mit der Kategorie.

Geprüft wird nur, was geprüft werden soll: Kategorie an der Buchung,
Kategorie vorgemerkt, Adresse konfiguriert. Fällt eines weg, verschwindet
der Status. Gesucht wird rückwärts ab dem Buchungsdatum, weil Bargeld
ausgegeben wird, bevor der Umsatz auf dem Konto ankommt.

Im Kontoblatt steht das Ergebnis zwischen Klasse und Betrag: grauer Kreis,
grüner Haken, rotes X, Ausrufezeichen. Das Ausrufezeichen behält die
Textfarbe des Farbschemas – der Fehler liegt beim Abruf, nicht bei der
Buchung. Ohne hinterlegte Adresse entfällt die Spalte.

Ein Fehlschlag ist ein Ergebnis, kein Abbruch: Die Kategorie wird auch
dann gespeichert, wenn das Bargeld-Tool schweigt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 07:06:20 +02:00

679 lines
26 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 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 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.bargeld_status, b.bargeld_hinweis,
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,
NULL AS bargeld_status, NULL AS bargeld_hinweis,
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,
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
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]);
}
}