diff --git a/Changes.md b/Changes.md index a5deecc..7046aae 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,13 @@ # Änderungen +## Regelmäßige Buchungen: automatisch abgleichen (12.10.2026) + +- Anlass (#123): Buchungen aus regelmäßigen Buchungen mussten nach dem Buchen einzeln abgeglichen werden. +- Neuer Schalter „automatisch abgleichen“ unter „automatisch buchen“ im Dialog „Neue regelmäßige Buchung“, in der Vorgabe eingeschaltet. Gesetzt markiert er die entstandene Buchung als abgeglichen – beim Fälligkeitslauf ebenso wie beim Buchen von Hand. +- Die Übersicht der regelmäßigen Buchungen hat eine neue Spalte „Abgleichen“ zwischen „Auto“ und „Aktiv“, per Klick umschaltbar. +- Migration `029_regelmaessig_abgleichen.sql`; bestehende Reihen übernehmen die Vorgabe (eingeschaltet). +- Tests: `tests/60_RegelmaessigAbgleichenTest.php`, `tests/e2e/specs/module.spec.js`. + ## Buchungen: bewusst ohne Kategorie (12.10.2026) - Anlass (#134): Abschreibungen oder rein rechnerische Solarerträge sollen ohne Kategorie gebucht werden können, ohne immer wieder als offen angemahnt zu werden. Mit Kategorie belastete eine Abschreibung die Auswertung zusätzlich zum gesunkenen Kontowert. diff --git a/docs/nebenmodule.md b/docs/nebenmodule.md index bc97ff1..0fbe855 100644 --- a/docs/nebenmodule.md +++ b/docs/nebenmodule.md @@ -35,6 +35,10 @@ Zwei Betriebsarten: * **auf Bestätigung** – wird nur gemeldet und wartet auf Knopfdruck. Für alles, dessen Betrag schwankt. +Unabhängig davon legt **automatisch abgleichen** (Vorgabe: eingeschaltet) fest, ob die +entstandene Buchung gleich als abgeglichen markiert wird. Die Übersicht zeigt den Schalter +in der Spalte „Abgleichen“. + Der Lauf ist gutmütig gegenüber langen Pausen: Liegen mehrere Termine zurück, wird **jeder einzeln** gebucht, nicht einer für alle – nur so stimmen die Monatssummen. Nach dem Buchen rückt die Fälligkeit so weit vor, bis sie in der Zukunft liegt. diff --git a/migrations/029_regelmaessig_abgleichen.sql b/migrations/029_regelmaessig_abgleichen.sql new file mode 100644 index 0000000..f6c9f35 --- /dev/null +++ b/migrations/029_regelmaessig_abgleichen.sql @@ -0,0 +1,11 @@ +-- Regelmäßige Buchungen: automatisch abgleichen (#123). +-- +-- Neben „automatisch buchen“ bekommt jede Reihe den Schalter „automatisch +-- abgleichen“. Ist er gesetzt (Vorgabe), wird die aus der Reihe entstandene +-- Buchung gleich als abgeglichen markiert. Bestehende Reihen übernehmen die +-- Vorgabe. +-- +-- Idempotent über IF NOT EXISTS. + +ALTER TABLE regelmaessige_buchungen + ADD COLUMN IF NOT EXISTS abgleichen TINYINT(1) NOT NULL DEFAULT 1 AFTER automatisch; diff --git a/src/Controllers/TermineController.php b/src/Controllers/TermineController.php index 5db5ef6..e4a19e5 100644 --- a/src/Controllers/TermineController.php +++ b/src/Controllers/TermineController.php @@ -202,6 +202,7 @@ class TermineController extends BaseController 'ende' => $this->post('ende'), 'naechste_faelligkeit' => $this->post('naechste_faelligkeit'), 'automatisch' => isset($_POST['automatisch']) ? 1 : 0, + 'abgleichen' => isset($_POST['abgleichen']) ? 1 : 0, 'vorlauf_tage' => $this->post('vorlauf_tage', '7'), 'aktiv' => isset($_POST['aktiv']) ? 1 : 0, ]; diff --git a/src/Models/RegelmaessigeBuchung.php b/src/Models/RegelmaessigeBuchung.php index 8cddcb8..5186431 100644 --- a/src/Models/RegelmaessigeBuchung.php +++ b/src/Models/RegelmaessigeBuchung.php @@ -14,6 +14,9 @@ use Finanzmanager\Mandant; * ansteht, bucht es und schreibt das Datum fort. So braucht es keine Tabelle * vorausberechneter Termine, die bei jeder Änderung neu zu erzeugen wäre – der * Finanzkalender rechnet die kommenden Termine bei Bedarf aus. + * + * Mit „automatisch abgleichen“ (Vorgabe) ist die entstandene Buchung gleich + * als abgeglichen markiert. */ class RegelmaessigeBuchung { @@ -95,13 +98,13 @@ class RegelmaessigeBuchung '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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + naechste_faelligkeit, automatisch, abgleichen, 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'], + $f['beginn'], $f['automatisch'], $f['abgleichen'], $f['vorlauf_tage'], $f['aktiv'], ] ); @@ -137,13 +140,13 @@ class RegelmaessigeBuchung '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 = ? + automatisch = ?, abgleichen = ?, 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(), + $f['automatisch'], $f['abgleichen'], $f['vorlauf_tage'], $f['aktiv'], $id, Mandant::id(), ] ); } @@ -194,6 +197,7 @@ class RegelmaessigeBuchung 'kategorie_id' => $splits === [] ? $reihe['kategorie_id'] : null, 'klasse_id' => $reihe['klasse_id'], 'gegenkonto_id' => $splits === [] ? $reihe['gegenkonto_id'] : null, + 'abgeglichen' => (int)$reihe['abgleichen'] === 1 ? 1 : 0, ], $splits ); @@ -383,6 +387,8 @@ class RegelmaessigeBuchung 'beginn' => $beginn, 'ende' => $ende, 'automatisch' => !empty($daten['automatisch']) ? 1 : 0, + // Vorgabe eingeschaltet: Fehlt der Schlüssel, wird abgeglichen. + 'abgleichen' => array_key_exists('abgleichen', $daten) && empty($daten['abgleichen']) ? 0 : 1, 'vorlauf_tage' => max(0, min(365, (int)($daten['vorlauf_tage'] ?? 7))), 'aktiv' => array_key_exists('aktiv', $daten) && empty($daten['aktiv']) ? 0 : 1, ]; diff --git a/src/Views/termine/index.php b/src/Views/termine/index.php index 87c97d9..44b6175 100644 --- a/src/Views/termine/index.php +++ b/src/Views/termine/index.php @@ -106,7 +106,7 @@ include __DIR__ . '/../layout/kopf.php'; Name und BuchungstextKonto und KlasseBetragRhythmus - Nächster TerminEndeAutoAktivAktion + Nächster TerminEndeAutoAbgleichenAktivAktion @@ -212,6 +212,13 @@ include __DIR__ . '/../layout/kopf.php'; > + +
+ + > +
+
@@ -297,6 +304,14 @@ include __DIR__ . '/../layout/kopf.php'; dort, wo der Betrag schwankt.

+ +

+ Mit Haken ist die entstandene Buchung gleich als abgeglichen markiert. +

+ diff --git a/tests/60_RegelmaessigAbgleichenTest.php b/tests/60_RegelmaessigAbgleichenTest.php new file mode 100644 index 0000000..5992e42 --- /dev/null +++ b/tests/60_RegelmaessigAbgleichenTest.php @@ -0,0 +1,105 @@ +mandantAnlegen(); + + $this->giro = Konto::anlegen([ + 'name' => 'Abgleichgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + + $this->vorgabeIstEingeschaltet(); + $this->schalterLaesstSichSpeichern(); + $this->buchenGleichtAb(); + $this->ohneSchalterBleibtOffen(); + $this->faelligkeitslaufGleichtAb(); + } + + /** @param array $zusatz */ + private function reihe(string $name, array $zusatz = []): int + { + return RegelmaessigeBuchung::anlegen($zusatz + [ + 'name' => $name, 'konto_id' => $this->giro, 'betrag' => -25, + 'intervall' => 'monatlich', 'beginn' => '2026-06-01', 'aktiv' => 1, + ]); + } + + private function vorgabeIstEingeschaltet(): void + { + $id = $this->reihe('Ohne Angabe'); + $this->assertEquals(1, (int)RegelmaessigeBuchung::find($id)['abgleichen'], 'Ohne Angabe wird abgeglichen'); + + Database::execute( + 'INSERT INTO regelmaessige_buchungen (benutzer_id, name, konto_id, beginn) VALUES (?, ?, ?, ?)', + [Mandant::id(), 'Spaltenvorgabe', $this->giro, '2026-06-01'] + ); + $this->assertEquals( + 1, + (int)RegelmaessigeBuchung::find(Database::lastInsertId())['abgleichen'], + 'Die Spalte steht in der Vorgabe auf eingeschaltet' + ); + } + + private function schalterLaesstSichSpeichern(): void + { + $daten = [ + 'name' => 'Umschalten', 'konto_id' => $this->giro, 'betrag' => -5, + 'intervall' => 'monatlich', 'beginn' => '2026-06-01', 'aktiv' => 1, 'abgleichen' => 0, + ]; + $id = RegelmaessigeBuchung::anlegen($daten); + $this->assertEquals(0, (int)RegelmaessigeBuchung::find($id)['abgleichen'], 'Ausgeschaltet wird gespeichert'); + + RegelmaessigeBuchung::speichern($id, ['abgleichen' => 1] + $daten); + $this->assertEquals(1, (int)RegelmaessigeBuchung::find($id)['abgleichen'], 'Einschalten wird gespeichert'); + } + + private function buchenGleichtAb(): void + { + $buchung = RegelmaessigeBuchung::buchen($this->reihe('Miete', ['abgleichen' => 1])); + $this->assertEquals(1, (int)Buchung::find($buchung)['abgeglichen'], 'Die Buchung ist abgeglichen'); + } + + private function ohneSchalterBleibtOffen(): void + { + $buchung = RegelmaessigeBuchung::buchen($this->reihe('Strom', ['abgleichen' => 0])); + $this->assertEquals(0, (int)Buchung::find($buchung)['abgeglichen'], 'Ohne Schalter bleibt sie offen'); + } + + private function faelligkeitslaufGleichtAb(): void + { + $mit = $this->reihe('Lauf mit', ['automatisch' => 1, 'abgleichen' => 1, 'beginn' => date('Y-m-d')]); + $ohne = $this->reihe('Lauf ohne', ['automatisch' => 1, 'abgleichen' => 0, 'beginn' => date('Y-m-d')]); + + Faelligkeitslauf::ausfuehren(); + + $status = static fn(int $reihe): array => array_map('intval', array_column(Database::fetchAll( + 'SELECT abgeglichen FROM buchungen WHERE regelmaessige_id = ? AND benutzer_id = ?', + [$reihe, Mandant::id()] + ), 'abgeglichen')); + + $this->assertEquals([1], $status($mit), 'Der Fälligkeitslauf bucht abgeglichen'); + $this->assertEquals([0], $status($ohne), 'Ohne Schalter bucht der Lauf offen'); + } +} diff --git a/tests/e2e/specs/module.spec.js b/tests/e2e/specs/module.spec.js index bbbbdf9..13236c2 100644 --- a/tests/e2e/specs/module.spec.js +++ b/tests/e2e/specs/module.spec.js @@ -320,9 +320,19 @@ test.describe('Module der Phasen 4 bis 7', () => { await page.fill('#name', 'E2E-Reihe') await page.fill('#neu_betrag', '-12,50') await page.fill('#neu_buchungstext', 'E2E-Text') + await expect(page.locator('#neudialog input[name="abgleichen"]')).toBeChecked() await page.click('form[action="/termine/neu"] button.primaer') const zeile = page.locator('table.reihen tr', { hasText: 'E2E-Reihe' }) + await expect(page.locator('table.reihen thead th').nth(7)).toHaveText('Abgleichen') + const abgleichen = zeile.locator('.feld:has(input[name="abgleichen"])') + await expect(abgleichen.locator('.anzeige')).toHaveText('Ja') + await abgleichen.locator('.anzeige').click() + await expect(abgleichen.locator('.anzeige')).toHaveText('Nein') + await zeile.locator('[data-speichern]').click() + await expect(page.locator('table.reihen tr', { hasText: 'E2E-Reihe' }) + .locator('.feld:has(input[name="abgleichen"]) .anzeige')).toHaveText('Nein') + await expect(zeile.locator('input[name="betrag"]')).toBeHidden() await expect(zeile.locator('[data-speichern]')).toBeHidden()