Finanzmanager/src/Models/RegelmaessigeBuchung.php
fds2610-bot 1fc78abc11 Fix n.n.: Regelmäßige Buchungen – Buchen zum gewählten Datum
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:48:51 +02:00

390 lines
15 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;
/**
* Regelmäßige Buchungen: Miete, Gehalt, Versicherungsbeiträge.
*
* Jede führt ihre **nächste Fälligkeit** mit sich. Der Fälligkeitslauf holt, was
* ansteht, bucht es und schreibt das Datum fort. So braucht es keine Tabelle
* vorausberechneter Termine, die bei jeder Änderung neu zu erzeugen wäre – der
* Finanzkalender rechnet die kommenden Termine bei Bedarf aus.
*/
class RegelmaessigeBuchung
{
/** Intervall → Beschriftung. */
public const INTERVALLE = [
'taeglich' => 'täglich',
'woechentlich' => 'wöchentlich',
'zweiwoechentlich' => 'alle zwei Wochen',
'monatlich' => 'monatlich',
'zweimonatlich' => 'alle zwei Monate',
'vierteljaehrlich' => 'vierteljährlich',
'halbjaehrlich' => 'halbjährlich',
'jaehrlich' => 'jährlich',
];
/** Intervall → Schritt für DateTime::modify(). */
private const SCHRITTE = [
'taeglich' => '+1 day',
'woechentlich' => '+1 week',
'zweiwoechentlich' => '+2 weeks',
'monatlich' => '+1 month',
'zweimonatlich' => '+2 months',
'vierteljaehrlich' => '+3 months',
'halbjaehrlich' => '+6 months',
'jaehrlich' => '+1 year',
];
/** @return list<array<string,mixed>> */
public static function alle(bool $nurAktive = false): array
{
$sql = 'SELECT r.*, k.name AS kontoname, kat.pfad AS kategorie_pfad
FROM regelmaessige_buchungen r
JOIN konten k ON k.id = r.konto_id
LEFT JOIN kategorien kat ON kat.id = r.kategorie_id
WHERE r.benutzer_id = ?';
if ($nurAktive) {
$sql .= ' AND r.aktiv = 1';
}
return Database::fetchAll($sql . ' ORDER BY r.naechste_faelligkeit IS NULL, r.naechste_faelligkeit, r.name',
[Mandant::id()]);
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM regelmaessige_buchungen WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/**
* Was bis zu einem Stichtag fällig ist (Vorgabe: heute).
*
* @return list<array<string,mixed>>
*/
public static function faellige(?string $bis = null): array
{
return Database::fetchAll(
'SELECT r.*, k.name AS kontoname
FROM regelmaessige_buchungen r
JOIN konten k ON k.id = r.konto_id
WHERE r.benutzer_id = ? AND r.aktiv = 1
AND r.naechste_faelligkeit IS NOT NULL
AND r.naechste_faelligkeit <= ?
AND (r.ende IS NULL OR r.naechste_faelligkeit <= r.ende)
ORDER BY r.naechste_faelligkeit, r.id',
[Mandant::id(), $bis ?? date('Y-m-d')]
);
}
/** @param array<string,mixed> $daten */
public static function anlegen(array $daten): int
{
$f = self::normalisiere($daten);
Database::execute(
'INSERT INTO regelmaessige_buchungen
(benutzer_id, name, konto_id, betrag, empfaenger, buchungstext, kategorie_id,
klasse_id, gegenkonto_id, splits, intervall, faktor, beginn, ende,
naechste_faelligkeit, automatisch, vorlauf_tage, aktiv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $f['name'], $f['konto_id'], $f['betrag'], $f['empfaenger'],
$f['buchungstext'], $f['kategorie_id'], $f['klasse_id'], $f['gegenkonto_id'],
$f['splits'], $f['intervall'], $f['faktor'], $f['beginn'], $f['ende'],
$f['beginn'], $f['automatisch'], $f['vorlauf_tage'], $f['aktiv'],
]
);
return Database::lastInsertId();
}
/** @param array<string,mixed> $daten */
public static function speichern(int $id, array $daten): void
{
$bisher = self::find($id);
if ($bisher === null) {
throw new \RuntimeException('Die regelmäßige Buchung gibt es nicht.');
}
$f = self::normalisiere($daten);
// Die nächste Fälligkeit bleibt stehen, solange sie zum neuen Beginn
// passt – sonst zöge jedes Speichern die Reihe zurück auf den Anfang.
$naechste = (string)($bisher['naechste_faelligkeit'] ?? '');
if ($naechste === '' || $naechste < $f['beginn'] || $bisher['intervall'] !== $f['intervall']) {
$naechste = $f['beginn'];
}
// Ein ausdrücklich gesetzter Termin gilt – so lässt sich die Fälligkeit
// von Hand verschieben.
$gewuenscht = (string)($daten['naechste_faelligkeit'] ?? '');
$zeit = \DateTimeImmutable::createFromFormat('!Y-m-d', $gewuenscht);
if ($zeit !== false && $zeit->format('Y-m-d') === $gewuenscht) {
$naechste = $gewuenscht;
}
Database::execute(
'UPDATE regelmaessige_buchungen SET name = ?, konto_id = ?, betrag = ?, empfaenger = ?,
buchungstext = ?, kategorie_id = ?, klasse_id = ?, gegenkonto_id = ?, splits = ?,
intervall = ?, faktor = ?, beginn = ?, ende = ?, naechste_faelligkeit = ?,
automatisch = ?, vorlauf_tage = ?, aktiv = ?
WHERE id = ? AND benutzer_id = ?',
[
$f['name'], $f['konto_id'], $f['betrag'], $f['empfaenger'], $f['buchungstext'],
$f['kategorie_id'], $f['klasse_id'], $f['gegenkonto_id'], $f['splits'],
$f['intervall'], $f['faktor'], $f['beginn'], $f['ende'], $naechste,
$f['automatisch'], $f['vorlauf_tage'], $f['aktiv'], $id, Mandant::id(),
]
);
}
public static function loeschen(int $id): void
{
Database::execute(
'DELETE FROM regelmaessige_buchungen WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
}
/**
* Bucht einen fälligen Termin und schreibt die Fälligkeit fort.
*
* @param array<string,mixed> $abweichungen datum, betrag
* @return int Die angelegte Buchung
*/
public static function buchen(int $id, array $abweichungen = []): int
{
$reihe = self::find($id);
if ($reihe === null) {
throw new \RuntimeException('Die regelmäßige Buchung gibt es nicht.');
}
$datum = trim((string)($abweichungen['datum'] ?? ''));
if ($datum === '') {
$datum = (string)($reihe['naechste_faelligkeit'] ?? date('Y-m-d'));
}
$pruef = \DateTimeImmutable::createFromFormat('!Y-m-d', $datum);
if ($pruef === false || $pruef->format('Y-m-d') !== $datum) {
throw new \InvalidArgumentException('Ungültiges Datum.');
}
$betrag = array_key_exists('betrag', $abweichungen) && $abweichungen['betrag'] !== ''
? round((float)$abweichungen['betrag'], 2)
: (float)$reihe['betrag'];
$splits = Buchungsvorlage::splitsFuerBetrag($reihe, $betrag);
return (int)Database::transaktion(static function () use ($reihe, $id, $datum, $betrag, $splits): int {
$buchungId = Buchung::anlegen(
[
'konto_id' => (int)$reihe['konto_id'],
'datum' => $datum,
'betrag' => $betrag,
'empfaenger' => (string)$reihe['empfaenger'],
'buchungstext' => (string)$reihe['buchungstext'],
'kategorie_id' => $splits === [] ? $reihe['kategorie_id'] : null,
'klasse_id' => $reihe['klasse_id'],
'gegenkonto_id' => $splits === [] ? $reihe['gegenkonto_id'] : null,
],
$splits
);
// Merken, aus welchem Termin die Buchung entstand – beim Löschen
// wird die Fälligkeit darüber wieder geöffnet.
Database::execute(
'UPDATE buchungen SET regelmaessige_id = ?, faelligkeit = ? WHERE id = ? AND benutzer_id = ?',
[$id, $reihe['naechste_faelligkeit'], $buchungId, Mandant::id()]
);
self::rueckeVor($id, (string)$reihe['naechste_faelligkeit'], $datum);
return $buchungId;
});
}
/**
* Öffnet einen Termin wieder, dessen Buchung gelöscht wurde: Die Fälligkeit
* rückt auf diesen Termin zurück (falls er vor der aktuellen liegt), eine
* ausgelaufene Reihe wird wieder aktiv, solange der Termin im Zeitraum liegt.
* `zuletzt_am` folgt der jüngsten verbleibenden Buchung der Reihe.
* Aufzurufen, wenn die Buchung schon gelöscht ist.
*/
public static function terminWiederOeffnen(int $id, string $termin): void
{
$reihe = self::find($id);
if ($reihe === null || $termin === '') {
return;
}
$aktuell = (string)($reihe['naechste_faelligkeit'] ?? '');
$neu = ($aktuell === '' || $termin < $aktuell) ? $termin : $aktuell;
$ende = (string)($reihe['ende'] ?? '');
$aktiv = ($ende === '' || $neu <= $ende) ? 1 : (int)$reihe['aktiv'];
$zuletzt = Database::fetchValue(
'SELECT MAX(datum) FROM buchungen WHERE regelmaessige_id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
Database::execute(
'UPDATE regelmaessige_buchungen
SET naechste_faelligkeit = ?, zuletzt_am = ?, aktiv = ?
WHERE id = ? AND benutzer_id = ?',
[$neu, $zuletzt !== null && $zuletzt !== false ? $zuletzt : null, $aktiv, $id, Mandant::id()]
);
}
/**
* Überspringt einen Termin, ohne zu buchen – etwa wenn die Miete einmal
* ausgesetzt wurde.
*/
public static function ueberspringen(int $id): void
{
$reihe = self::find($id);
if ($reihe === null) {
return;
}
self::rueckeVor($id, (string)$reihe['naechste_faelligkeit'], null);
}
/**
* Die kommenden Termine einer Reihe – für den Finanzkalender. Gerechnet,
* nicht gespeichert.
*
* @param array<string,mixed> $reihe
* @return list<string> Datumsangaben
*/
public static function kommendeTermine(array $reihe, string $bis, int $grenze = 60): array
{
$termin = (string)($reihe['naechste_faelligkeit'] ?? '');
if ($termin === '' || (int)$reihe['aktiv'] !== 1) {
return [];
}
$ende = (string)($reihe['ende'] ?? '');
$termine = [];
while ($termin <= $bis && count($termine) < $grenze) {
if ($ende !== '' && $termin > $ende) {
break;
}
$termine[] = $termin;
$termin = self::naechsterTermin($termin, (string)$reihe['intervall'], (int)$reihe['faktor']);
}
return $termine;
}
/** Das nächste Datum nach `$datum` gemäß Intervall und Faktor. */
public static function naechsterTermin(string $datum, string $intervall, int $faktor = 1): string
{
$schritt = self::SCHRITTE[$intervall] ?? '+1 month';
$faktor = max(1, $faktor);
$zeit = new \DateTimeImmutable($datum);
for ($lauf = 0; $lauf < $faktor; $lauf++) {
$zeit = $zeit->modify($schritt);
}
return $zeit->format('Y-m-d');
}
/**
* Schreibt die Fälligkeit fort. Liegt der neue Termin immer noch in der
* Vergangenheit – etwa weil die Anwendung länger nicht lief –, wird so
* lange weitergerückt, bis er in der Zukunft liegt; sonst stünde beim
* nächsten Lauf derselbe Termin wieder an.
*/
private static function rueckeVor(int $id, string $bisher, ?string $gebuchtAm): void
{
$reihe = self::find($id);
if ($reihe === null) {
return;
}
$termin = $bisher !== '' ? $bisher : date('Y-m-d');
$heute = date('Y-m-d');
do {
$termin = self::naechsterTermin($termin, (string)$reihe['intervall'], (int)$reihe['faktor']);
} while ($termin <= $heute && $termin < '2100-01-01');
$ende = (string)($reihe['ende'] ?? '');
$aktiv = ($ende !== '' && $termin > $ende) ? 0 : (int)$reihe['aktiv'];
Database::execute(
'UPDATE regelmaessige_buchungen
SET naechste_faelligkeit = ?, zuletzt_am = ?, aktiv = ?
WHERE id = ? AND benutzer_id = ?',
[$termin, $gebuchtAm, $aktiv, $id, Mandant::id()]
);
}
/**
* @param array<string,mixed> $daten
* @return array<string,mixed>
*/
private static function normalisiere(array $daten): array
{
$name = trim((string)($daten['name'] ?? ''));
if ($name === '') {
throw new \InvalidArgumentException('Die regelmäßige Buchung braucht einen Namen.');
}
$kontoId = (int)($daten['konto_id'] ?? 0);
if ($kontoId === 0 || Konto::find($kontoId) === null) {
throw new \InvalidArgumentException('Es fehlt ein gültiges Konto.');
}
$beginn = trim((string)($daten['beginn'] ?? ''));
if (!Buchung::istDatum($beginn)) {
throw new \InvalidArgumentException('Der Beginn muss als JJJJ-MM-TT vorliegen.');
}
$ende = trim((string)($daten['ende'] ?? ''));
$ende = Buchung::istDatum($ende) ? $ende : null;
if ($ende !== null && $ende < $beginn) {
throw new \InvalidArgumentException('Das Ende liegt vor dem Beginn.');
}
$intervall = (string)($daten['intervall'] ?? 'monatlich');
$id = static fn(mixed $wert): ?int =>
($wert === null || $wert === '' || (int)$wert === 0) ? null : (int)$wert;
$splits = $daten['splits'] ?? null;
if (is_array($splits) && $splits !== []) {
$splits = json_encode(array_values($splits), JSON_UNESCAPED_UNICODE);
} elseif (!is_string($splits) || trim((string)$splits) === '') {
$splits = null;
}
return [
'name' => mb_substr($name, 0, 255),
'konto_id' => $kontoId,
'betrag' => round((float)($daten['betrag'] ?? 0), 2),
'empfaenger' => mb_substr(trim((string)($daten['empfaenger'] ?? '')), 0, 255),
'buchungstext' => trim((string)($daten['buchungstext'] ?? '')),
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
'klasse_id' => $id($daten['klasse_id'] ?? null),
'gegenkonto_id' => $id($daten['gegenkonto_id'] ?? null),
'splits' => $splits,
'intervall' => array_key_exists($intervall, self::INTERVALLE) ? $intervall : 'monatlich',
'faktor' => max(1, min(12, (int)($daten['faktor'] ?? 1))),
'beginn' => $beginn,
'ende' => $ende,
'automatisch' => !empty($daten['automatisch']) ? 1 : 0,
'vorlauf_tage' => max(0, min(365, (int)($daten['vorlauf_tage'] ?? 7))),
'aktiv' => array_key_exists('aktiv', $daten) && empty($daten['aktiv']) ? 0 : 1,
];
}
}