diff --git a/Changes.md b/Changes.md index d9935aa..5ba0e46 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,9 @@ # Änderungen +## Fälligkeit folgt der Buchung (01.10.2026) + +- Eine per Fälligkeitslauf oder „Buchen“ erzeugte Buchung merkt sich jetzt ihre regelmäßige Buchung und den Soll-Termin (`buchungen.regelmaessige_id`, `faelligkeit`, Migration 023). Wird sie gelöscht, rückt die Fälligkeit auf diesen Termin zurück, eine ausgelaufene Reihe wird wieder aktiv, `zuletzt_am` folgt der jüngsten verbleibenden Buchung (`RegelmaessigeBuchung::terminWiederOeffnen()`). Bereits vorhandene Buchungen sind nicht verknüpft; bei ihnen bleibt es beim bisherigen Verhalten. + ## Darkmode-Farben nach Mockup (01.10.2026) - Dunkle Palette bläulicher (Navy) statt grau-blau, Seitenleiste, Flächen, Ränder und Akzent angepasst. Der aktive Menüeintrag ist wie im hellen Modus blau hinterlegt (`--navi-aktiv`), der Balken links bleibt weiß (neue Variable `--navi-balken`). Nur Farben, nur im Darkmode (`app.css`). diff --git a/migrations/023_buchung_regelmaessig.sql b/migrations/023_buchung_regelmaessig.sql new file mode 100644 index 0000000..7b82f84 --- /dev/null +++ b/migrations/023_buchung_regelmaessig.sql @@ -0,0 +1,14 @@ +-- Verknüpfung einer Buchung mit der regelmäßigen Buchung, aus der sie entstand. +-- +-- Bisher war „naechste_faelligkeit“ ein reiner Zähler: Wurde eine per +-- Fälligkeitslauf erzeugte Buchung gelöscht, blieb der Termin als erledigt +-- stehen. „faelligkeit“ hält den Soll-Termin fest (nicht das Buchungsdatum, das +-- abweichen kann), damit Buchung::loeschen() die Fälligkeit zurücksetzen kann. + +ALTER TABLE buchungen ADD COLUMN IF NOT EXISTS + regelmaessige_id INT UNSIGNED NULL AFTER quelle; +ALTER TABLE buchungen ADD COLUMN IF NOT EXISTS + faelligkeit DATE NULL AFTER regelmaessige_id; +ALTER TABLE buchungen ADD INDEX IF NOT EXISTS idx_buchung_regelmaessig (regelmaessige_id); +ALTER TABLE buchungen ADD CONSTRAINT fk_buchung_regelmaessig FOREIGN KEY IF NOT EXISTS (regelmaessige_id) + REFERENCES regelmaessige_buchungen (id) ON DELETE SET NULL; diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index e898f0d..a6c2882 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -322,7 +322,18 @@ class Buchung // übersprungen. Bankumsatz::loeseVonBuchung($id); - Database::execute('DELETE FROM buchungen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); + Database::transaktion(static function () use ($id, $buchung): void { + Database::execute('DELETE FROM buchungen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); + + // Entstand die Buchung aus einer regelmäßigen Buchung, ist deren + // Termin wieder offen. + if ($buchung !== null && !empty($buchung['regelmaessige_id']) && !empty($buchung['faelligkeit'])) { + RegelmaessigeBuchung::terminWiederOeffnen( + (int)$buchung['regelmaessige_id'], + (string)$buchung['faelligkeit'] + ); + } + }); } /** diff --git a/src/Models/RegelmaessigeBuchung.php b/src/Models/RegelmaessigeBuchung.php index ddf1c2f..e7c0bee 100644 --- a/src/Models/RegelmaessigeBuchung.php +++ b/src/Models/RegelmaessigeBuchung.php @@ -183,12 +183,51 @@ class RegelmaessigeBuchung $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. diff --git a/tests/52_RegelmaessigeLoeschenTest.php b/tests/52_RegelmaessigeLoeschenTest.php new file mode 100644 index 0000000..adfeac2 --- /dev/null +++ b/tests/52_RegelmaessigeLoeschenTest.php @@ -0,0 +1,99 @@ +mandantAnlegen(); + + $this->giro = Konto::anlegen([ + 'name' => 'Fälligkeitsgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + + $this->loeschenOeffnetTerminWieder(); + $this->aeltereVonZweienGeloescht(); + $this->beendeteReiheWirdAktiv(); + $this->manuelleBuchungBleibtUnberuehrt(); + } + + private function reihe(string $name, string $beginn, ?string $ende = null): int + { + return RegelmaessigeBuchung::anlegen([ + 'name' => $name, 'konto_id' => $this->giro, 'betrag' => -10, + 'intervall' => 'monatlich', 'beginn' => $beginn, 'ende' => $ende, 'aktiv' => 1, + ]); + } + + private function faelligkeit(int $id): string + { + return (string)RegelmaessigeBuchung::find($id)['naechste_faelligkeit']; + } + + private function loeschenOeffnetTerminWieder(): void + { + $id = $this->reihe('Löschtest', '2026-06-01'); + $buchung = RegelmaessigeBuchung::buchen($id); + + $this->assertEquals($id, (int)Buchung::find($buchung)['regelmaessige_id'], 'Die Buchung kennt ihre Reihe'); + $this->assertEquals('2026-06-01', (string)Buchung::find($buchung)['faelligkeit'], 'Und ihren Termin'); + $this->assert($this->faelligkeit($id) > '2026-06-01', 'Nach dem Buchen ist der Termin fortgeschrieben'); + + Buchung::loeschen($buchung); + + $this->assertEquals('2026-06-01', $this->faelligkeit($id), 'Nach dem Löschen steht der Termin wieder an'); + $this->assertNull(RegelmaessigeBuchung::find($id)['zuletzt_am'], 'Ohne verbleibende Buchung kein letztes Datum'); + } + + private function aeltereVonZweienGeloescht(): void + { + $id = $this->reihe('Zwei Termine', '2026-03-01'); + $erste = RegelmaessigeBuchung::buchen($id); + $zweite = RegelmaessigeBuchung::buchen($id); + + Buchung::loeschen($erste); + + $this->assertEquals('2026-03-01', $this->faelligkeit($id), 'Es gilt der älteste offene Termin'); + $this->assertEquals( + (string)Buchung::find($zweite)['datum'], + (string)RegelmaessigeBuchung::find($id)['zuletzt_am'], + 'zuletzt_am folgt der verbleibenden Buchung' + ); + } + + private function beendeteReiheWirdAktiv(): void + { + $id = $this->reihe('Befristet', '2026-07-01', '2026-07-31'); + $buchung = RegelmaessigeBuchung::buchen($id); + $this->assertEquals(0, (int)RegelmaessigeBuchung::find($id)['aktiv'], 'Die Reihe ist ausgelaufen'); + + Buchung::loeschen($buchung); + + $this->assertEquals(1, (int)RegelmaessigeBuchung::find($id)['aktiv'], 'Das Löschen öffnet sie wieder'); + $this->assertEquals('2026-07-01', $this->faelligkeit($id), 'Mit dem gelöschten Termin'); + } + + private function manuelleBuchungBleibtUnberuehrt(): void + { + $id = $this->reihe('Unbeteiligt', '2026-09-01'); + $vorher = $this->faelligkeit($id); + + $manuell = Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2026-08-01', 'betrag' => -5]); + Buchung::loeschen($manuell); + + $this->assertEquals($vorher, $this->faelligkeit($id), 'Eine freie Buchung ändert keine Fälligkeit'); + } +}