> */ public static function alle(?int $jahr = null): array { $sql = 'SELECT * FROM monatsabschluesse WHERE benutzer_id = ?'; $params = [Mandant::id()]; if ($jahr !== null) { $sql .= ' AND jahr = ?'; $params[] = $jahr; } return Database::fetchAll($sql . ' ORDER BY jahr DESC, monat DESC', $params); } public static function find(int $id): ?array { $zeile = Database::fetchOne( 'SELECT * FROM monatsabschluesse WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()] ); return $zeile === false ? null : $zeile; } public static function fuerMonat(int $jahr, int $monat): ?array { $zeile = Database::fetchOne( 'SELECT * FROM monatsabschluesse WHERE benutzer_id = ? AND jahr = ? AND monat = ?', [Mandant::id(), $jahr, $monat] ); return $zeile === false ? null : $zeile; } /** Liegt das Datum in einem abgeschlossenen Monat? */ public static function istGesperrt(string $datum): bool { if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $datum, $treffer)) { return false; } return (int)Database::fetchValue( 'SELECT COUNT(*) FROM monatsabschluesse WHERE benutzer_id = ? AND jahr = ? AND monat = ? AND status = ?', [Mandant::id(), (int)$treffer[1], (int)$treffer[2], self::STATUS_ABGESCHLOSSEN], 0 ) > 0; } /** * Wirft, wenn in einem abgeschlossenen Monat gebucht werden soll. Die * Prüfung lässt sich in den Einstellungen abschalten – etwa während eines * Imports, der ältere Zeiträume nachträgt. */ public static function verlangeOffen(string $datum): void { if (Einstellung::get('monatsabschluss_sperrt_buchungen', '1') !== '1') { return; } if (!self::istGesperrt($datum)) { return; } throw new \RuntimeException( 'Der Monat ' . substr($datum, 5, 2) . '/' . substr($datum, 0, 4) . ' ist abgeschlossen. Zum Buchen muss er wiedereröffnet werden.' ); } /** * Prüflauf vor dem Abschluss: Was im Monat noch klemmt. Jeder Punkt ist ein * Hinweis, kein Hindernis – abschließen lässt sich trotzdem, der * FinanzManager hält es genauso. * * @return array{buchungen: int, ohne_kategorie: list>, * nicht_abgeglichen: int, splitdifferenzen: list>} */ public static function pruefen(int $jahr, int $monat): array { [$von, $bis] = self::zeitraum($jahr, $monat); $p = [Mandant::id(), $von, $bis]; $ohneKategorie = Database::fetchAll( 'SELECT b.id, b.datum, b.betrag, b.empfaenger, k.name AS konto FROM buchungen b JOIN konten k ON k.id = b.konto_id WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ? AND b.gesplittet = 0 AND b.kategorie_id IS NULL AND b.gegenkonto_id IS NULL AND b.bewusst_ohne_kategorie = 0 ORDER BY b.datum, b.id', $p ); $splitdifferenzen = Database::fetchAll( 'SELECT b.id, b.datum, b.betrag, b.empfaenger, k.name AS konto, COALESCE(SUM(s.betrag), 0) AS summe_splits FROM buchungen b JOIN konten k ON k.id = b.konto_id LEFT JOIN buchung_splits s ON s.buchung_id = b.id WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ? AND b.gesplittet = 1 GROUP BY b.id HAVING ABS(COALESCE(SUM(s.betrag), 0) - b.betrag) > 0.004 ORDER BY b.datum, b.id', $p ); return [ 'buchungen' => (int)Database::fetchValue( 'SELECT COUNT(*) FROM buchungen WHERE benutzer_id = ? AND datum BETWEEN ? AND ?', $p, 0 ), 'ohne_kategorie' => $ohneKategorie, 'nicht_abgeglichen' => (int)Database::fetchValue( 'SELECT COUNT(*) FROM buchungen WHERE benutzer_id = ? AND datum BETWEEN ? AND ? AND abgeglichen = 0', $p, 0 ), 'splitdifferenzen' => $splitdifferenzen, ]; } /** * Schließt den Monat ab. Der Snapshot hält die Kontosalden zum Monatsende * und die Kategoriesummen des Monats fest. */ public static function abschliessen(int $jahr, int $monat, string $notiz = '', string $von = ''): int { self::pruefeMonat($jahr, $monat); $vorhanden = self::fuerMonat($jahr, $monat); if ($vorhanden !== null && $vorhanden['status'] === self::STATUS_ABGESCHLOSSEN) { throw new \RuntimeException('Der Monat ist bereits abgeschlossen.'); } $snapshot = json_encode(self::snapshot($jahr, $monat), JSON_UNESCAPED_UNICODE); Database::execute( 'INSERT INTO monatsabschluesse (benutzer_id, jahr, monat, status, notiz, snapshot, abgeschlossen_am, abgeschlossen_von) VALUES (?, ?, ?, ?, ?, ?, NOW(), ?) ON DUPLICATE KEY UPDATE status = VALUES(status), notiz = VALUES(notiz), snapshot = VALUES(snapshot), abgeschlossen_am = NOW(), abgeschlossen_von = VALUES(abgeschlossen_von), wiedereroeffnet_am = NULL', [Mandant::id(), $jahr, $monat, self::STATUS_ABGESCHLOSSEN, $notiz, $snapshot, $von] ); $zeile = self::fuerMonat($jahr, $monat); return $zeile === null ? 0 : (int)$zeile['id']; } /** * Hebt die Sperre wieder auf. Der Snapshot bleibt stehen: Er zeigt, mit * welchen Zahlen der Monat einmal abgeschlossen wurde. */ public static function wiedereroeffnen(int $id): void { Database::execute( 'UPDATE monatsabschluesse SET status = ?, wiedereroeffnet_am = NOW() WHERE id = ? AND benutzer_id = ?', [self::STATUS_WIEDEREROEFFNET, $id, Mandant::id()] ); } /** * Vorschlag für den nächsten Abschluss: der älteste Monat mit Buchungen, * der noch offen ist. Abgeschlossen wird der Reihe nach, deshalb läuft die * Suche vom Vormonat rückwärts und endet am ersten bereits abgeschlossenen * Monat. Der laufende Monat kommt nie infrage – er ist noch nicht vorbei. * * @return array{jahr: int, monat: int} */ public static function naechsterOffener(?string $heute = null): array { $heute = $heute ?? date('Y-m-d'); $jahr = (int)substr($heute, 0, 4); $monat = (int)substr($heute, 5, 2); // Vom Vormonat aus rückwärts bis zu zwei Jahre suchen. [$jahr, $monat] = self::vormonat($jahr, $monat); $kandidat = ['jahr' => $jahr, 'monat' => $monat]; for ($i = 0; $i < 24; $i++) { $abschluss = self::fuerMonat($jahr, $monat); if ($abschluss !== null && $abschluss['status'] === self::STATUS_ABGESCHLOSSEN) { return $kandidat; } $hatBuchungen = (int)Database::fetchValue( 'SELECT COUNT(*) FROM buchungen WHERE benutzer_id = ? AND datum BETWEEN ? AND ?', array_merge([Mandant::id()], self::zeitraum($jahr, $monat)), 0 ) > 0; if ($hatBuchungen) { $kandidat = ['jahr' => $jahr, 'monat' => $monat]; } [$jahr, $monat] = self::vormonat($jahr, $monat); } return $kandidat; } /** * Kontosalden zum Monatsende und Kategoriesummen des Monats. * * @return array */ public static function snapshot(int $jahr, int $monat): array { [$von, $bis] = self::zeitraum($jahr, $monat); $salden = Buchung::saldenJeKonto($bis); $konten = []; foreach (Konto::alle() as $konto) { $id = (int)$konto['id']; $konten[] = [ 'id' => $id, 'name' => $konto['name'], 'in_summe' => (int)$konto['in_summe'], 'saldo' => round((float)$konto['anfangssaldo'] + ($salden[$id] ?? 0.0), 2), ]; } $kategorien = Database::fetchAll( 'SELECT kategorie_id, SUM(betrag) AS betrag FROM ( SELECT b.kategorie_id AS kategorie_id, b.betrag AS betrag FROM buchungen b WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ? AND b.gesplittet = 0 AND b.kategorie_id IS NOT NULL UNION ALL SELECT s.kategorie_id AS kategorie_id, s.betrag AS betrag FROM buchung_splits s JOIN buchungen b ON b.id = s.buchung_id WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ? AND b.gesplittet = 1 AND s.kategorie_id IS NOT NULL ) AS bewegungen GROUP BY kategorie_id', [Mandant::id(), $von, $bis, Mandant::id(), $von, $bis] ); $summen = []; foreach ($kategorien as $zeile) { $summen[] = ['kategorie_id' => (int)$zeile['kategorie_id'], 'betrag' => round((float)$zeile['betrag'], 2)]; } $summeKonten = 0.0; foreach ($konten as $konto) { if ($konto['in_summe'] === 1) { $summeKonten += $konto['saldo']; } } return [ 'erstellt_am' => date('c'), 'zeitraum' => ['von' => $von, 'bis' => $bis], 'konten' => $konten, 'summe_kontostand' => round($summeKonten, 2), 'kategorien' => $summen, ]; } /** @return array{0: string, 1: string} erster und letzter Tag des Monats */ public static function zeitraum(int $jahr, int $monat): array { self::pruefeMonat($jahr, $monat); $von = sprintf('%04d-%02d-01', $jahr, $monat); $bis = date('Y-m-t', (int)strtotime($von)); return [$von, $bis]; } /** @return array{0: int, 1: int} */ private static function vormonat(int $jahr, int $monat): array { return $monat === 1 ? [$jahr - 1, 12] : [$jahr, $monat - 1]; } private static function pruefeMonat(int $jahr, int $monat): void { if ($monat < 1 || $monat > 12) { throw new \InvalidArgumentException('Monat muss zwischen 1 und 12 liegen.'); } if ($jahr < 1900 || $jahr > 2200) { throw new \InvalidArgumentException('Das Jahr ist ungültig.'); } } }