390 lines
15 KiB
PHP
390 lines
15 KiB
PHP
<?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,
|
||
];
|
||
}
|
||
}
|