Finanzmanager/src/Models/Monatsabschluss.php
fds2610-bot 275acd3285 Fix #134: Buchungen bewusst ohne Kategorie ermöglichen
Neuer Schalter „Bewusst ohne Kategorie“ in der Buchungsmaske für
Abschreibungen und rein rechnerische Solarerträge. Solche Buchungen
erscheinen nicht unter den offenen Vorgängen im Cockpit, nicht im
Prüflauf des Monatsabschlusses, setzen das Konto in der Kontenübersicht
nicht fett und werden von der Regelanwendung sowie dem automatischen
Zusammenführen zu Umbuchungen ausgelassen. Kommt eine Kategorie, ein
Gegenkonto oder eine Splitbuchung hinzu, entfällt die Markierung.

Migration 028_bewusst_ohne_kategorie.sql, Test 59 und E2E-Test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:22:22 +02:00

313 lines
11 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;
/**
* Der Monatsabschluss friert einen Monat ein: Er hält Kontosalden und
* Kategoriesummen als Snapshot fest und sperrt Buchungen in diesem Zeitraum.
*
* Die Sperre ist eine fachliche, keine technische: Sie greift in
* Buchung::anlegen/speichern/loeschen und lässt sich durch Wiedereröffnen des
* Monats aufheben – der Vorgang bleibt in der Zeile vermerkt.
*/
class Monatsabschluss
{
public const STATUS_ABGESCHLOSSEN = 'abgeschlossen';
public const STATUS_WIEDEREROEFFNET = 'wiedereroeffnet';
/** @return list<array<string,mixed>> */
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<array<string,mixed>>,
* nicht_abgeglichen: int, splitdifferenzen: list<array<string,mixed>>}
*/
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<string,mixed>
*/
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.');
}
}
}