From 1a464b5b1704c8ac1e73f385f66e71da103bef9e Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Thu, 1 Oct 2026 09:07:07 +0200 Subject: [PATCH 1/4] =?UTF-8?q?Fix=20n.n.:=20F=C3=A4lligkeit=20folgt=20der?= =?UTF-8?q?=20Buchung=20=E2=80=93=20L=C3=B6schen=20=C3=B6ffnet=20den=20Ter?= =?UTF-8?q?min=20wieder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- Changes.md | 4 + migrations/023_buchung_regelmaessig.sql | 14 ++++ src/Models/Buchung.php | 13 +++- src/Models/RegelmaessigeBuchung.php | 39 ++++++++++ tests/52_RegelmaessigeLoeschenTest.php | 99 +++++++++++++++++++++++++ 5 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 migrations/023_buchung_regelmaessig.sql create mode 100644 tests/52_RegelmaessigeLoeschenTest.php 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'); + } +} From ecab07e5dae281786d734e05e2b39f7686df2fa9 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Thu, 1 Oct 2026 09:08:51 +0200 Subject: [PATCH 2/4] =?UTF-8?q?Fix=20n.n.:=20Buchungstext=20bei=20regelm?= =?UTF-8?q?=C3=A4=C3=9Figen=20Buchungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- Changes.md | 4 ++++ src/Views/termine/index.php | 6 +++++- tests/52_RegelmaessigeLoeschenTest.php | 13 +++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Changes.md b/Changes.md index 5ba0e46..3cd7fee 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,9 @@ # Änderungen +## Buchungstext bei regelmäßigen Buchungen (01.10.2026) + +- Das Feld „Buchungstext“ fehlte im Formular für neue regelmäßige Buchungen und war in der Liste nur ein verstecktes Feld. Jetzt beim Anlegen vorhanden und in der Liste unter dem Namen bearbeitbar (`termine/index.php`). + ## 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. diff --git a/src/Views/termine/index.php b/src/Views/termine/index.php index 1201560..6387750 100644 --- a/src/Views/termine/index.php +++ b/src/Views/termine/index.php @@ -102,7 +102,8 @@ include __DIR__ . '/../layout/kopf.php'; - + @@ -188,6 +189,9 @@ include __DIR__ . '/../layout/kopf.php'; + + + - - - + +
+ + +
+
+ + +
- +
+ + +
+
+ + +
- +
+ + +
- +
+ + +
- - +
+ + +
- +
+ + +
- + - + - + @@ -242,3 +291,5 @@ include __DIR__ . '/../layout/kopf.php'; + + diff --git a/tests/52_RegelmaessigeLoeschenTest.php b/tests/52_RegelmaessigeLoeschenTest.php index 7c2f8bb..1ef05f3 100644 --- a/tests/52_RegelmaessigeLoeschenTest.php +++ b/tests/52_RegelmaessigeLoeschenTest.php @@ -29,6 +29,7 @@ class RegelmaessigeLoeschenTest extends TestCase $this->beendeteReiheWirdAktiv(); $this->manuelleBuchungBleibtUnberuehrt(); $this->buchungstextWirdUebernommen(); + $this->naechsterTerminLaesstSichSetzen(); } private function reihe(string $name, string $beginn, ?string $ende = null): int @@ -109,4 +110,19 @@ class RegelmaessigeLoeschenTest extends TestCase $buchung = RegelmaessigeBuchung::buchen($id); $this->assertEquals('Miete Oktober', (string)Buchung::find($buchung)['buchungstext'], 'Und in die Buchung übernommen'); } + + private function naechsterTerminLaesstSichSetzen(): void + { + $id = $this->reihe('Verschiebbar', '2026-10-01'); + $daten = RegelmaessigeBuchung::find($id); + + RegelmaessigeBuchung::speichern($id, $daten); + $this->assertEquals('2026-10-01', $this->faelligkeit($id), 'Speichern ohne Änderung lässt den Termin stehen'); + + RegelmaessigeBuchung::speichern($id, ['naechste_faelligkeit' => '2026-12-15'] + $daten); + $this->assertEquals('2026-12-15', $this->faelligkeit($id), 'Ein gesetzter Termin gilt'); + + RegelmaessigeBuchung::speichern($id, ['naechste_faelligkeit' => 'Unsinn'] + $daten); + $this->assertEquals('2026-12-15', $this->faelligkeit($id), 'Ungültige Angaben werden ignoriert'); + } } diff --git a/tests/e2e/specs/module.spec.js b/tests/e2e/specs/module.spec.js index 68dc959..ff3b5b8 100644 --- a/tests/e2e/specs/module.spec.js +++ b/tests/e2e/specs/module.spec.js @@ -312,6 +312,30 @@ test.describe('Module der Phasen 4 bis 7', () => { await expect(page.locator('.meldung')).toContainText('gelöscht') }) + test('Regelmäßige Buchungen werden per Klick bearbeitbar', async ({ page }) => { + await page.goto('/termine') + await page.fill('#name', 'E2E-Reihe') + await page.fill('#neu_betrag', '-12,50') + await page.fill('#neu_buchungstext', 'E2E-Text') + await page.click('form[action="/termine/neu"] button[type="submit"]') + + const zeile = page.locator('table.reihen tr', { hasText: 'E2E-Reihe' }) + await expect(zeile.locator('input[name="betrag"]')).toBeHidden() + await expect(zeile.locator('[data-speichern]')).toBeHidden() + + await zeile.locator('.feld:has(input[name="betrag"]) .anzeige').click() + await expect(zeile.locator('input[name="betrag"]')).toBeVisible() + await zeile.locator('input[name="betrag"]').fill('-13,00') + await expect(zeile.locator('[data-speichern]')).toBeVisible() + await zeile.locator('[data-speichern]').click() + + await expect(page.locator('table.reihen tr', { hasText: 'E2E-Reihe' })).toContainText('13,00') + + page.once('dialog', (dialog) => dialog.accept()) + await page.locator('table.reihen tr', { hasText: 'E2E-Reihe' }).locator('button:has-text("Löschen")').click() + await expect(page.locator('table.reihen tr', { hasText: 'E2E-Reihe' })).toHaveCount(0) + }) + test('Der Finanzkalender ist erreichbar und lässt sich einstellen', async ({ page }) => { await page.goto('/termine/kalender') await expect(page.locator('h1')).toContainText('Finanzkalender') From e0bfa33982e4e318dc752cfa44cfa4800c0baa62 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Thu, 1 Oct 2026 09:24:53 +0200 Subject: [PATCH 4/4] =?UTF-8?q?Fix=20n.n.:=20E2E-Test=20Mehrfachl=C3=B6sch?= =?UTF-8?q?en=20=C3=BCber=20eindeutige=20Knopf-ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/Views/buchungen/kontoblatt.php | 4 ++-- tests/e2e/specs/buchungen.spec.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Views/buchungen/kontoblatt.php b/src/Views/buchungen/kontoblatt.php index 2dc500a..888b5f1 100644 --- a/src/Views/buchungen/kontoblatt.php +++ b/src/Views/buchungen/kontoblatt.php @@ -184,8 +184,8 @@ include __DIR__ . '/../layout/kopf.php'; ausgewählte Buchungen, die denselben Vorgang doppelt zeigen (eine Umbuchung und ihre einzeln erfasste Dublette), führt es zusammen. */ ?> - - + diff --git a/tests/e2e/specs/buchungen.spec.js b/tests/e2e/specs/buchungen.spec.js index cb99409..f879f25 100644 --- a/tests/e2e/specs/buchungen.spec.js +++ b/tests/e2e/specs/buchungen.spec.js @@ -655,7 +655,7 @@ test.describe('Buchungen', () => { await page.goto(`/konten/${sparId}`) // Ohne Auswahl bleibt der Knopf gesperrt. - const knopf = page.locator('#buchungsauswahl button') + const knopf = page.locator('#auswahl-loeschen') await expect(knopf).toBeDisabled() // Das Ankreuzfeld in der Titelzeile wählt alle angezeigten Buchungen.