'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> */ 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> */ 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 $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 $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 $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 $reihe * @return list 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 $daten * @return array */ 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, ]; } }