From 0e8000e3dcf2cfd8e80ec1432f0263748922631d Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 12 Oct 2026 17:37:34 +0200 Subject: [PATCH 1/8] =?UTF-8?q?Fix=20#123:=20Regelm=C3=A4=C3=9Fige=20Buchu?= =?UTF-8?q?ngen=20automatisch=20abgleichen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Schalter „automatisch abgleichen“ unter „automatisch buchen“ (Vorgabe: eingeschaltet). Gesetzt wird die aus der Reihe entstandene Buchung als abgeglichen markiert. Die Übersicht erhält die Spalte „Abgleichen“ zwischen „Auto“ und „Aktiv“. Co-Authored-By: Claude Opus 5.5 --- Changes.md | 8 ++ docs/nebenmodule.md | 4 + migrations/029_regelmaessig_abgleichen.sql | 11 +++ src/Controllers/TermineController.php | 1 + src/Models/RegelmaessigeBuchung.php | 16 +++- src/Views/termine/index.php | 17 +++- tests/60_RegelmaessigAbgleichenTest.php | 105 +++++++++++++++++++++ tests/e2e/specs/module.spec.js | 10 ++ 8 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 migrations/029_regelmaessig_abgleichen.sql create mode 100644 tests/60_RegelmaessigAbgleichenTest.php 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() From c58837dee8037dfe3e0a461a0cbebae93c203db5 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 12 Oct 2026 21:30:44 +0200 Subject: [PATCH 2/8] Fix #124: Umbuchungskonten in jeder Kategorieauswahl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unter den Kategorien stehen überall die aktiven Umbuchungskonten, je Kontengruppe gruppiert und in deren Reihenfolge (Konto::umbuchungsgruppen()): Regeln samt Splitzeilen, Umsatzabgleich, Buchungsvorlagen, Regelmäßige Buchungen, Zahlungsverkehr, Verträge sowie die Filter von Buchungsbericht und Export. Die Masken nutzen dafür die gemeinsame Liste Kategorie / Gegenkonto der Buchungsmaske; das getrennte Gegenkonto-Feld der Regel entfällt. Regel-Splitzeilen können auf ein Umbuchungskonto zeigen. Zahlungsaufträge und Verträge bekommen ein Gegenkonto (Migration 030). Ein schon gewähltes inaktives Konto bleibt sichtbar, damit keine Zuordnung verloren geht. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + Changes.md | 11 + docs/auswertungen.md | 7 + docs/banking.md | 14 +- docs/buchungen.md | 23 +- docs/import-export.md | 3 + docs/nebenmodule.md | 10 + migrations/030_gegenkonto_auftrag_vertrag.sql | 19 ++ public/static/kombobox.js | 7 +- src/Controllers/BankingController.php | 41 ++- src/Controllers/BuchungenController.php | 40 +++ src/Controllers/ExportController.php | 3 +- src/Controllers/TermineController.php | 8 +- src/Controllers/VertraegeController.php | 6 +- src/Controllers/VorlagenController.php | 6 +- src/Controllers/ZahlungsverkehrController.php | 8 +- src/Models/AbgleichRegel.php | 50 +-- src/Models/Buchungsvorlage.php | 3 +- src/Models/Konto.php | 39 +++ src/Models/Vertrag.php | 16 +- src/Models/Zahlungsauftrag.php | 35 +- src/Services/Auswertung.php | 46 ++- src/Services/BuchungsExport.php | 34 +- src/Views/auswertungen/bericht.php | 21 +- src/Views/banking/regelfelder.php | 29 +- src/Views/banking/regeln.php | 15 +- src/Views/banking/regelsplits.php | 27 +- src/Views/banking/umsaetze.php | 30 +- src/Views/buchungen/formular.php | 11 +- src/Views/buchungen/zuordnungsliste.php | 73 ++-- src/Views/termine/index.php | 11 +- src/Views/vertraege/index.php | 14 +- src/Views/verwaltung/export.php | 18 +- src/Views/vorlagen/index.php | 18 +- src/Views/zahlungsverkehr/formular.php | 17 +- tests/61_UmbuchungskontenAuswahlTest.php | 313 ++++++++++++++++++ tests/e2e/specs/regelausbuchung.spec.js | 35 +- 37 files changed, 859 insertions(+), 206 deletions(-) create mode 100644 migrations/030_gegenkonto_auftrag_vertrag.sql create mode 100644 tests/61_UmbuchungskontenAuswahlTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a2b38cd..cc42af7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ### Added +- Kategorieauswahl: Unter den Kategorien stehen überall die aktiven Umbuchungskonten, + sortiert und gruppiert nach Kontengruppe – in Regeln samt Splitzeilen, Umsatzabgleich, + Buchungsvorlagen, Regelmäßigen Buchungen, Zahlungsverkehr, Verträgen sowie in den + Filtern von Buchungsbericht und Export (#124) - Buchungsmaske: Schalter „Bewusst ohne Kategorie“ für Abschreibungen und rein rechnerische Erträge; solche Buchungen gelten im Cockpit, im Monatsabschluss und in der Kontenübersicht nicht als offen (#134) diff --git a/Changes.md b/Changes.md index 7046aae..2ddd38c 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,16 @@ # Änderungen +## Umbuchungskonten in jeder Kategorieauswahl (12.10.2026) + +- Anlass (#124): In den Splitzeilen der Regeln, beim Anlegen einer Regel, im Filter des Buchungsberichts und weiteren Listen fehlten die Umbuchungskonten unter den Kategorien. +- Die gemeinsame Liste *Kategorie / Gegenkonto* der Buchungsmaske (`Views/buchungen/zuordnungsliste.php`) steht jetzt überall, wo eine Kategorie gewählt wird: Regeln (Neue Regel, Regeltabelle, Regeldialog im Kontoblatt) samt Splitzeilen, Umsatzabgleich, Buchungsvorlagen, Regelmäßige Buchungen, Zahlungsverkehr und Verträge. Das getrennte Feld „Gegenkonto“ der Regel entfällt; die Regeltabelle verlor bisher beim Speichern ein Gegenkonto, weil sie es gar nicht mitschickte. +- Angeboten werden nur aktive Umbuchungskonten, in eigenen Gruppen je Kontengruppe und in deren Reihenfolge (`Konto::umbuchungsgruppen()`). Ein schon gewähltes inaktives Konto bleibt sichtbar; die übrigen inaktiven stehen verborgen in der Liste, damit bestehende Splitzeilen beim Bearbeiten nicht verloren gehen (`kombobox.js` blendet sie aus). +- Regel-Splitzeilen können auf ein Umbuchungskonto zeigen; daraus wird eine Splitzeile mit Gegenkonto. Auch „Regel anlegen“ aus einer Buchung übernimmt solche Zeilen. +- Buchungsbericht: Umbuchungskonten stehen unter den Kategorien im Filter und bringen ihre Umbuchungen auch ohne „Umbuchungen mitzählen“ mit. Export: Umbuchungskonten unter den Kategorien (`gegenkonten[]`). +- Zahlungsaufträge und Verträge bekommen ein Gegenkonto (Migration `030_gegenkonto_auftrag_vertrag.sql`); eine Überweisung aufs eigene Tagesgeld wird beim Senden als Umbuchung gebucht. +- Nicht betroffen: Auswahllisten, die keine Buchung zuordnen (Wertpapierkosten in den Einstellungen, Bargeld-Tool, Ober-/Zielkategorie in der Kategorieverwaltung, Budgetplaner). +- Tests: `tests/61_UmbuchungskontenAuswahlTest.php`, `tests/e2e/specs/regelausbuchung.spec.js`. + ## Regelmäßige Buchungen: automatisch abgleichen (12.10.2026) - Anlass (#123): Buchungen aus regelmäßigen Buchungen mussten nach dem Buchen einzeln abgeglichen werden. diff --git a/docs/auswertungen.md b/docs/auswertungen.md index 823145f..25c11af 100644 --- a/docs/auswertungen.md +++ b/docs/auswertungen.md @@ -113,6 +113,13 @@ Kategorien zeigen die Summe ihres Astes. Eine gewählte Kategorie **schließt ihre Unterkategorien ein** – wer „400 - Leben“ filtert, meint den ganzen Ast. Nichts ausgewählt heißt alles. +Im **Buchungsbericht** stehen unter den Kategorien zusätzlich die aktiven Umbuchungskonten, +je Kontengruppe (#124). Ihr Wert `konto:7` landet im Filter als `gegenkonten`; eine +Bewegung passt, wenn sie zu einer gewählten Kategorie **oder** einem gewählten +Umbuchungskonto gehört. Ein gewähltes Umbuchungskonto bringt seine Umbuchungen auch ohne +„Umbuchungen mitzählen“ mit – sonst bliebe die Auswahl leer. Die übrigen Berichte zählen +Umbuchungen nicht und bieten die Konten deshalb nicht an. + Zwei Berichte bestimmen ihre Zeilen selbst und sperren das Feld: der Kontostandsbericht führt immer Konten, der Budgetbericht immer Kategorien. Zwei bestimmen ihre Spalten selbst: Vergleichs- und Budgetbericht. diff --git a/docs/banking.md b/docs/banking.md index b7b0b7d..4ff2083 100644 --- a/docs/banking.md +++ b/docs/banking.md @@ -453,13 +453,17 @@ Eine Regel prüft ein Muster gegen Empfänger und/oder Verwendungszweck: so. * **Betragsgrenzen** rechnen mit Vorzeichen: „von −60 bis −40“ trifft Lastschriften zwischen 40 und 60 Euro. -* **Wirkung**: Kategorie, Klasse, Gegenkonto (macht daraus eine Umbuchung) oder eine - **Splitbuchung**. +* **Wirkung**: Kategorie oder Gegenkonto (macht daraus eine Umbuchung) aus der + gemeinsamen Liste *Kategorie / Gegenkonto*, dazu eine Klasse – oder eine + **Splitbuchung**. Die Liste ist dieselbe wie in der Buchungsmaske: unter den Kategorien + die aktiven Umbuchungskonten je Kontengruppe (#124). ### Splitbuchung in einer Regel -Unter jeder Regel klappt *Splitbuchung* die Zeilen auf: je Zeile eine Kategorie, wahlweise -Klasse und Notiz, dazu ein Anteil in Prozent. Greift die Regel, entsteht keine Buchung mit +Unter jeder Regel klappt *Splitbuchung* die Zeilen auf: je Zeile eine Kategorie oder ein +Umbuchungskonto, wahlweise Klasse und Notiz, dazu ein Anteil in Prozent. Eine Zeile mit +Umbuchungskonto wird zur Splitzeile mit Gegenkonto – etwa der Sparanteil einer Überweisung, +die sonst Lebensmittel bezahlt. Greift die Regel, entsteht keine Buchung mit einer Kategorie, sondern eine Splitbuchung mit diesen Zeilen. Die Anteile gelten **zueinander**, nicht als feste Beträge – darin liegt der ganze Zweck: @@ -473,7 +477,7 @@ Fehler wäre in der fertigen Buchung nicht mehr zu sehen. Die letzte Zeile bekom den Rundungsrest, damit die Summe auf den Cent zum Buchungsbetrag passt – bei einem Drittel zu zwei Dritteln ginge sonst ein Cent verloren, und die Buchung ließe sich nicht speichern. -Eine Zeile ohne Kategorie oder ohne Anteil fällt weg; ohne jede Splitzeile bucht die Regel +Eine Zeile ohne Kategorie bzw. Gegenkonto oder ohne Anteil fällt weg; ohne jede Splitzeile bucht die Regel wie zuvor auf ihre Kategorie. Im Abgleichdialog zeigt der Vorschlag die errechneten Zeilen mit ihren Beträgen an und übernimmt sie unverändert – geändert wird eine Aufteilung im Kontoblatt, wo die ganze Buchung samt Summenprüfung vorliegt. diff --git a/docs/buchungen.md b/docs/buchungen.md index c8c51a8..30323fa 100644 --- a/docs/buchungen.md +++ b/docs/buchungen.md @@ -57,10 +57,14 @@ nicht. Deshalb stehen Kategorien und Konten in derselben Auswahlliste; die Konte in eckigen Klammern, wie das Kategoriefeld im FinanzManager sie schreibt. Das eigene Konto fehlt in der Liste. -Die Liste steht in `Views/buchungen/zuordnungsliste.php` und wird an vier Stellen -eingebunden: Buchungsmaske, deren Splitzeilen, Schnellzuordnung im Kontoblatt und -Splitdialog. Die Werte tragen ein Präfix, das `BuchungenController::zuordnungAus()` wieder -auseinandernimmt: +Die Liste steht in `Views/buchungen/zuordnungsliste.php`. Sie wird nicht nur in der +Buchungsmaske, deren Splitzeilen, der Schnellzuordnung im Kontoblatt und im Splitdialog +eingebunden, sondern überall, wo eine Kategorie gewählt wird (#124): Regeln samt ihren +Splitzeilen, Umsatzabgleich, Buchungsvorlagen, Regelmäßige Buchungen, Zahlungsverkehr und +Verträge. Die Werte tragen ein Präfix, das `BuchungenController::zuordnungAus()` wieder +auseinandernimmt (`zuordnungAusFormular()` nimmt ersatzweise die versteckten Einzelfelder +`kategorie_id`/`gegenkonto_id` einer Tabellenzeile, `zuordnungswert()` baut den Wert für die +Vorauswahl): | Wert | Bedeutung | |---|---| @@ -72,6 +76,17 @@ auseinandernimmt: Die Auswahl *Ausgabe / Einnahme* bestimmt seither nur noch die Richtung des Betrags. +Angeboten werden nur **aktive** Umbuchungskonten, je Kontengruppe in einer eigenen Gruppe +(„Umbuchungskonten – Liquide Mittel“, ohne Kontengruppen schlicht „Umbuchungskonten“) und +in der Reihenfolge der Kontengruppen (`Konto::umbuchungsgruppen()`). Ein inaktives Konto, +das schon gewählt ist, bleibt sichtbar stehen – sonst ginge die Zuordnung beim nächsten +Speichern verloren. Alle übrigen inaktiven Konten stehen als verborgene Einträge +(`
-
> +
> Einschränken auf Konten, Kategorien und Klassen
@@ -234,6 +249,10 @@ include __DIR__ . '/../layout/kopf.php';

Eine gewählte Kategorie schließt ihre Unterkategorien ein. Mehrfachauswahl mit gedrückter Strg-Taste; nichts ausgewählt heißt alles. + + Ein Umbuchungskonto in eckigen Klammern zeigt die Umbuchungen auf dieses Konto, + auch ohne „Umbuchungen mitzählen“. +

diff --git a/src/Views/banking/regelfelder.php b/src/Views/banking/regelfelder.php index c98dc7e..9aea941 100644 --- a/src/Views/banking/regelfelder.php +++ b/src/Views/banking/regelfelder.php @@ -1,5 +1,6 @@ $regel Vorbelegung; leeres Array für eine neue Regel * @var list> $kategorien * @var list> $klassen - * @var list> $konten + * @var list> $konten alle Konten; die Liste zeigt nur die aktiven * @var string $idPrefix Vorsatz für die Feld-ids, damit mehrere solcher * Formulare auf einer Seite nicht kollidieren */ @@ -66,15 +67,12 @@ $betragsfeld = static function (?string $feld) use ($regel): string { zwischen 40 und 60 Euro.

- - + + + @@ -88,17 +86,6 @@ $betragsfeld = static function (?string $feld) use ($regel): string { - - -

Splitbuchung (statt einer einzelnen Kategorie)

diff --git a/src/Views/banking/regeln.php b/src/Views/banking/regeln.php index a64035c..ddf5790 100644 --- a/src/Views/banking/regeln.php +++ b/src/Views/banking/regeln.php @@ -1,5 +1,6 @@ NameFeldVergleichMuster - KategorieKlasse + Kategorie / GegenkontoKlasse PrioTreffer AktivAktion @@ -80,14 +81,10 @@ include __DIR__ . '/../layout/kopf.php'; - + + + diff --git a/src/Views/banking/regelsplits.php b/src/Views/banking/regelsplits.php index e041967..28381f0 100644 --- a/src/Views/banking/regelsplits.php +++ b/src/Views/banking/regelsplits.php @@ -1,5 +1,6 @@ > $kategorien * @var list> $klassen + * @var list> $konten */ // Ein paar leere Zeilen mehr, als belegt sind: So lässt sich eine Kategorie // nachtragen, ohne dass ein Knopf und ein Stück JavaScript dafür nötig wären. $anzahl = max(3, count($splitZeilen) + 2); for ($i = count($splitZeilen); $i < $anzahl; $i++) { - $splitZeilen[] = ['kategorie_id' => null, 'klasse_id' => null, 'prozent' => '', 'notiz' => '']; + $splitZeilen[] = ['kategorie_id' => null, 'gegenkonto_id' => null, 'klasse_id' => null, 'prozent' => '', 'notiz' => '']; } $fuerFormular = $splitForm === '' ? '' : ' form="' . View::h($splitForm) . '"'; @@ -26,21 +28,17 @@ $fuerFormular = $splitForm === '' ? '' : ' form="' . View::h($splitForm) . '"'; - + $zeile): ?> - + @@ -69,7 +70,9 @@ include __DIR__ . '/../layout/kopf.php'; Split - + '), 'Die Spalte Kategorie steht im Bericht'); + $this->assert(str_contains($html, 'Bargeld Haushalt'), 'Die Kategorie der Buchung steht im Bericht'); + $this->assert(str_contains($html, 'Bargeld Giro'), 'Das Konto steht im Bericht'); + $this->assert( + str_contains($html, $status === 'fehler' ? 'Kasse steht still.' : 'Kein Treffer.'), + 'Die Meldung der Gegenstelle steht am Symbol' + ); + $this->assert(str_contains($html, 'stößt die Prüfung erneut an'), 'Der Hinweis zur erneuten Prüfung steht dabei'); } - $this->assertNotEmpty($treffer, 'Die Buchung mit Status „fehler“ steht in der Liste'); - $this->assertEquals('Bargeld Giro', $treffer['konto_name'], 'Der Kontoname kommt mit – die Liste ist kontoübergreifend'); - $this->assertEquals('Kasse steht still.', $treffer['bargeld_hinweis'], 'Die Meldung steht dabei'); - $html = $this->rendere('verwaltung/bargeld_fehler.php', ['buchungen' => $liste]); - - $this->assert(str_contains($html, 'Bargeld Giro'), 'Der Kontoname steht in der Liste'); - $this->assert(str_contains($html, 'Bargeldabhebung'), 'Der Empfänger steht in der Liste'); - $this->assert(str_contains($html, 'Kasse steht still.'), 'Die Meldung der Gegenstelle steht dabei'); $this->assert( - str_contains($html, '/konten/' . $this->konto), - 'Der Kontoname führt ins Kontoblatt' + !is_file(dirname(__DIR__) . '/src/Views/verwaltung/bargeld_fehler.php'), + 'Die frühere eigene Liste ist entfernt' ); - - $leer = $this->rendere('verwaltung/bargeld_fehler.php', ['buchungen' => []]); - $this->assert(str_contains($leer, 'Keine offenen Fehler.'), 'Eine leere Liste sagt das ausdrücklich'); } /** @param list> $zeilen */ diff --git a/tests/e2e/specs/bargeld.spec.js b/tests/e2e/specs/bargeld.spec.js index 1fe6f26..a2d118b 100644 --- a/tests/e2e/specs/bargeld.spec.js +++ b/tests/e2e/specs/bargeld.spec.js @@ -183,5 +183,17 @@ test.describe('Bargeld-Tool', () => { const danach = page.locator('tr', { hasText: empfaenger }) await expect(danach).toContainText(KATEGORIE) await expect(danach.locator('td.bargeldspalte .bargeld-fehler')).toBeVisible() + + // Der offene Vorgang im Cockpit führt in den Buchungsbericht, gefiltert + // auf fehlgeschlagene Prüfungen – samt Kategorie (#131). + await page.goto('/') + await page.locator('.vorgangsliste a', { hasText: 'Bargeld-Tool-Prüfung fehlgeschlagen' }).click() + await expect(page).toHaveURL(/\/auswertungen\/bericht\?.*typ=buchungsbericht/) + await expect(page.locator('select[name="bargeld"]')).toHaveValue('fehler') + await expect(page.locator('table.buchungsgruppen thead')).toContainText('Kategorie') + const imBericht = page.locator('table.buchungsgruppen tr', { hasText: empfaenger }) + await expect(imBericht).toContainText(KATEGORIE) + await expect(imBericht).toContainText(GIRO) + await expect(imBericht.locator('td.bargeldspalte .bargeld-fehler')).toBeVisible() }) }) From a5989eb262764c1a07e4ba83e66a8d21945bd2d9 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 12 Oct 2026 21:58:17 +0200 Subject: [PATCH 7/8] =?UTF-8?q?Fix=20#136:=20Buchungen=20ohne=20Bargeld-To?= =?UTF-8?q?ol-Abgleich=20erm=C3=B6glichen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Schalter „Ohne Bargeld-Abgleich“ in der Buchungsmaske für Sammelbuchungen und Differenzausgleiche. Solche Buchungen werden nicht gegen das Bargeld-Tool geprüft, ein früherer Status wird gelöscht, und sie erscheinen nicht mehr unter den offenen Vorgängen im Cockpit. Kontoblatt und Buchungsbericht zeigen einen leeren grauen Kreis. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + Changes.md | 8 + README.md | 3 +- docs/buchungen.md | 13 +- docs/cockpit.md | 2 + migrations/031_ohne_bargeld_abgleich.sql | 11 ++ src/Controllers/BuchungenController.php | 7 +- src/Models/Buchung.php | 20 ++- src/Services/Auswertung.php | 7 +- src/Services/Bankabgleich.php | 1 + src/Services/BargeldAbgleich.php | 13 +- src/View.php | 9 +- src/Views/auswertungen/teil-buchungen.php | 2 +- src/Views/buchungen/formular.php | 20 +++ src/Views/buchungen/kontoblatt.php | 6 +- tests/65_OhneBargeldAbgleichTest.php | 194 ++++++++++++++++++++++ tests/e2e/specs/bargeld.spec.js | 43 +++++ 17 files changed, 341 insertions(+), 21 deletions(-) create mode 100644 migrations/031_ohne_bargeld_abgleich.sql create mode 100644 tests/65_OhneBargeldAbgleichTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 169f2b3..ea0bc4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ### Added +- Buchungsmaske: Schalter „Ohne Bargeld-Abgleich“ für Sammelbuchungen und + Differenzausgleiche; solche Buchungen werden nicht mit dem Bargeld-Tool abgeglichen und + erscheinen nicht mehr unter den offenen Vorgängen im Cockpit (#136) - Depotbuchung erfassen: Bei Kauf und Verkauf rechnet sich aus zwei der drei Felder Betrag, Stück und Kurs automatisch das dritte (Betrag = Stück × Kurs) (#129) - Wertpapiere: Beim Buchen eines Wertpapiergeschäfts (Dialog im Kontoblatt und diff --git a/Changes.md b/Changes.md index 699af5b..0e9c1e6 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,13 @@ # Änderungen +## Buchen ohne Bargeld-Abgleich (12.10.2026) + +- Anlass (#136): Sammelbuchungen und Differenzausgleiche tragen eine Kategorie, die gegen das Bargeld-Tool geprüft wird, stehen dort aber absichtlich nicht – und wurden deshalb immer wieder als offener Vorgang angemahnt. +- Die Buchungsmaske hat den Schalter „Ohne Bargeld-Abgleich“ (`buchungen.ohne_bargeld_abgleich`, Migration `031_ohne_bargeld_abgleich.sql`). Er erscheint nur, wenn ein Bargeld-Tool eingerichtet ist; sonst trägt die Maske einen gesetzten Wert unsichtbar weiter. +- `BargeldAbgleich::pruefe()` prüft solche Buchungen nicht und löscht einen früheren Status. Sie erscheinen damit weder unter „Bargeld-Tool-Prüfung fehlgeschlagen“ noch unter „im Bargeld-Tool nicht gefunden“. Die Schnellzuordnung im Kontoblatt und die Regelanwendung lassen den Schalter stehen. +- Kontoblatt und Buchungsbericht zeigen in der Spalte „Bar“ einen leeren grauen Kreis mit dem Tooltip „Bewusst ohne Abgleich mit dem Bargeld-Tool“. +- Tests: `tests/65_OhneBargeldAbgleichTest.php`, `tests/e2e/specs/bargeld.spec.js`. + ## Offene Vorgänge des Bargeld-Tools im Buchungsbericht (12.10.2026) - Anlass (#131): Der Liste „Im Bargeld-Tool nicht gefunden“, aufgerufen aus *Offene Vorgänge* im Cockpit, fehlte die Spalte Kategorie/Gegenkonto. Gewünscht war statt einer eigenen Liste ein Filter im Buchungsbericht. diff --git a/README.md b/README.md index be868a5..db9f436 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,8 @@ Haushalt wird beides parallel genutzt. erfasst, sieht beim Zuweisen einer Kategorie sofort, ob die Buchung dort schon steht: grüner Haken, rotes X oder Ausrufezeichen in einer schmalen Spalte des Kontoblatts. Adresse, Zugangsdaten und die abzugleichenden Kategorien stehen unter - *Verwaltung → Bargeld-Tool*. + *Verwaltung → Bargeld-Tool*. Sammelbuchungen und Differenzausgleiche lassen sich in der + Buchungsmaske bewusst ohne Bargeld-Abgleich buchen. Einzelheiten je Modul in `docs/`, die Entwicklung in `Changes.md`. diff --git a/docs/buchungen.md b/docs/buchungen.md index 997fea3..3579e1b 100644 --- a/docs/buchungen.md +++ b/docs/buchungen.md @@ -286,9 +286,9 @@ Einstellungsbereich `bargeld`, der bewusst keinen Reiter im Einstellungsdialog h dafür in `Einstellung::EIGENE_SEITEN` (Test 02 prüft, dass jeder Bereich erreichbar bleibt). **Geprüft wird beim Zuweisen einer Kategorie** – aus der Schnellzuordnung des Kontoblatts -ebenso wie aus der Buchungsmaske. `Services\BargeldAbgleich::pruefe()` verlangt drei Dinge: +ebenso wie aus der Buchungsmaske. `Services\BargeldAbgleich::pruefe()` verlangt vier Dinge: eine Kategorie an der Buchung (keine Umbuchung, keine Splitbuchung), diese Kategorie -vorgemerkt, eine Adresse konfiguriert. Fällt eines weg, wird der Status gelöscht – auch beim +vorgemerkt, die Buchung nicht als „Ohne Bargeld-Abgleich“ markiert, eine Adresse konfiguriert. Fällt eines weg, wird der Status gelöscht – auch beim Umkategorisieren, sonst bliebe ein alter Haken an einer Buchung stehen, die niemand mehr abgleicht. Gesucht wird über `Services\BargeldTool` mit dem Betrag der Buchung, rückwärts ab ihrem Datum: Bargeld wird ausgegeben, bevor der Umsatz auf dem Konto ankommt, nie danach. @@ -300,6 +300,7 @@ Netz. In der schmalen Spalte zwischen Klasse und Betrag steht: | Symbol | Status | Bedeutung | |---|---|---| | ● grau | `NULL` | Die Buchung wird nicht abgeglichen | +| ○ grau | `NULL` | Die Buchung ist bewusst ohne Bargeld-Abgleich gebucht (#136) | | ✓ grün | `gefunden` | Im Bargeld-Tool steht eine Buchung mit demselben Betrag | | ✗ rot | `fehlt` | Dort steht keine | | ! | `fehler` | Der Abruf ist gescheitert | @@ -318,6 +319,14 @@ denselben Bericht mit dem Filter „nicht gefunden“. Der Bericht zeigt Konto, Gegenkonto und Klasse; die Meldung der Gegenstelle steht im Tooltip der Spalte „Bar“. Eine eigene Liste dafür gibt es nicht mehr (#131). +**Ohne Bargeld-Abgleich (#136).** Sammelbuchungen und Differenzausgleiche tragen eine +vorgemerkte Kategorie, stehen aber absichtlich nicht im Bargeld-Tool. Der Schalter **Ohne +Bargeld-Abgleich** in der Buchungsmaske setzt `buchungen.ohne_bargeld_abgleich`; die Buchung +wird dann nicht geprüft, ein früherer Status (`fehlt`, `fehler`) wird beim Speichern +gelöscht, und sie erscheint nicht mehr unter den offenen Vorgängen im Cockpit. Die +Schnellzuordnung lässt den Schalter stehen. Er erscheint nur, wenn eine Adresse hinterlegt +ist; ohne Bargeld-Tool trägt die Maske einen gesetzten Wert unsichtbar weiter. + **Ein Fehlschlag ist ein Ergebnis, kein Abbruch.** Weder ein abgeschaltetes Bargeld-Tool noch ein Netzfehler darf verhindern, dass die Kategorie gespeichert wird; der Abruf bricht nach acht Sekunden ab. Null Treffer sind ausdrücklich kein Fehler – ein 404 dagegen schon, diff --git a/docs/cockpit.md b/docs/cockpit.md index f50e068..8a88cc2 100644 --- a/docs/cockpit.md +++ b/docs/cockpit.md @@ -187,6 +187,8 @@ Prüfungen, die gerade nichts melden und daher nicht in der Liste stehen: * **Im Bargeld-Tool nicht gefunden** – Buchungen mit `bargeld_status = 'fehlt'`. Führt in denselben Buchungsbericht mit dem Filter „Bargeld-Tool: nicht gefunden“ (`Cockpit::bargeldBericht()`). + Buchungen mit dem Schalter „Ohne Bargeld-Abgleich“ werden nicht geprüft und erscheinen + deshalb in keinem der beiden Vorgänge (#136). * **Monatsabschluss steht aus** – der Vorschlag aus `Monatsabschluss::naechsterOffener()`, aber nur, wenn dieser Monat wirklich noch offen ist und Buchungen trägt. diff --git a/migrations/031_ohne_bargeld_abgleich.sql b/migrations/031_ohne_bargeld_abgleich.sql new file mode 100644 index 0000000..184f951 --- /dev/null +++ b/migrations/031_ohne_bargeld_abgleich.sql @@ -0,0 +1,11 @@ +-- Bewusst ohne Abgleich mit dem Bargeld-Tool buchen (#136). +-- +-- Sammelbuchungen oder Differenzausgleiche tragen eine Kategorie, die gegen +-- das Bargeld-Tool geprüft wird, stehen dort aber absichtlich nicht. Der +-- Schalter in der Buchungsmaske nimmt solche Buchungen aus dem Abgleich; sie +-- erscheinen danach nicht mehr als offener Vorgang im Cockpit. +-- +-- Idempotent über IF NOT EXISTS. + +ALTER TABLE buchungen + ADD COLUMN IF NOT EXISTS ohne_bargeld_abgleich TINYINT(1) NOT NULL DEFAULT 0 AFTER bewusst_ohne_kategorie; diff --git a/src/Controllers/BuchungenController.php b/src/Controllers/BuchungenController.php index 414f598..9bee795 100644 --- a/src/Controllers/BuchungenController.php +++ b/src/Controllers/BuchungenController.php @@ -257,7 +257,8 @@ class BuchungenController extends BaseController $frisch = Buchung::find((int)$buchung['id']); $bargeldHtml = View::bargeldMarke( $frisch['bargeld_status'] ?? null, - $frisch['bargeld_hinweis'] ?? null + $frisch['bargeld_hinweis'] ?? null, + !empty($frisch['ohne_bargeld_abgleich']) ); } @@ -614,6 +615,9 @@ class BuchungenController extends BaseController 'kategorien' => Kategorie::baumFlach(), 'klassen' => Klasse::alle(), 'konten' => Konto::alle(), + // Der Schalter „Ohne Bargeld-Abgleich“ erscheint nur, wenn es ein + // Bargeld-Tool gibt (#136). + 'bargeld' => BargeldAbgleich::eingerichtet(), ]; if ($fehler !== '') { $daten['flash'] = [['typ' => 'fehler', 'meldung' => $fehler]]; @@ -653,6 +657,7 @@ class BuchungenController extends BaseController 'gegenkonto_id' => $zuordnung['gegenkonto_id'], 'quelle' => 'manuell', 'bewusst_ohne_kategorie' => isset($_POST['bewusst_ohne_kategorie']), + 'ohne_bargeld_abgleich' => isset($_POST['ohne_bargeld_abgleich']), ]; return [$daten, $this->splitzeilen()]; diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index 2bf8831..1d79c5f 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -106,15 +106,15 @@ class Buchung 'INSERT INTO buchungen (benutzer_id, konto_id, datum, valuta, betrag, empfaenger, iban, bankname, buchungstext, nummer, abgeglichen, kategorie_id, klasse_id, gegenkonto_id, gesplittet, - bewusst_ohne_kategorie, quelle, import_hash) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + bewusst_ohne_kategorie, ohne_bargeld_abgleich, quelle, import_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ Mandant::id(), $felder['konto_id'], $felder['datum'], $felder['valuta'], $felder['betrag'], $felder['empfaenger'], $felder['iban'], $felder['bankname'], $felder['buchungstext'], $felder['nummer'], $felder['abgeglichen'], $felder['kategorie_id'], $felder['klasse_id'], $felder['gegenkonto_id'], $felder['gesplittet'], $felder['bewusst_ohne_kategorie'], - $felder['quelle'], $felder['import_hash'], + $felder['ohne_bargeld_abgleich'], $felder['quelle'], $felder['import_hash'], ] ); $id = Database::lastInsertId(); @@ -165,14 +165,15 @@ class Buchung Database::execute( 'UPDATE buchungen SET konto_id = ?, datum = ?, valuta = ?, betrag = ?, empfaenger = ?, buchungstext = ?, nummer = ?, abgeglichen = ?, kategorie_id = ?, klasse_id = ?, - gegenkonto_id = ?, gesplittet = ?, bewusst_ohne_kategorie = ? + gegenkonto_id = ?, gesplittet = ?, bewusst_ohne_kategorie = ?, + ohne_bargeld_abgleich = ? WHERE id = ? AND benutzer_id = ?', [ $felder['konto_id'], $felder['datum'], $felder['valuta'], $felder['betrag'], $felder['empfaenger'], $felder['buchungstext'], $felder['nummer'], $felder['abgeglichen'], $felder['kategorie_id'], $felder['klasse_id'], $felder['gegenkonto_id'], $felder['gesplittet'], $felder['bewusst_ohne_kategorie'], - $id, Mandant::id(), + $felder['ohne_bargeld_abgleich'], $id, Mandant::id(), ] ); @@ -437,7 +438,7 @@ class Buchung $sql = 'SELECT * FROM ( SELECT b.id, b.datum, b.valuta, b.betrag, b.empfaenger, b.buchungstext, b.nummer, b.abgeglichen, b.kategorie_id, b.klasse_id, b.gesplittet, b.quelle, - b.bargeld_status, b.bargeld_hinweis, b.bewusst_ohne_kategorie, + b.bargeld_status, b.bargeld_hinweis, b.bewusst_ohne_kategorie, b.ohne_bargeld_abgleich, b.gegenkonto_id, b.konto_id, 0 AS gegensicht, NULL AS split_id, b.iban, b.bankname FROM buchungen b @@ -445,7 +446,7 @@ class Buchung UNION ALL SELECT b.id, b.datum, b.valuta, -b.betrag, b.empfaenger, b.buchungstext, b.nummer, b.abgeglichen, b.kategorie_id, b.klasse_id, b.gesplittet, b.quelle, - NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie, + NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie, 0 AS ohne_bargeld_abgleich, b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, NULL AS split_id, \'\' AS iban, \'\' AS bankname FROM buchungen b @@ -453,7 +454,7 @@ class Buchung UNION ALL SELECT b.id, b.datum, b.valuta, -s.betrag, b.empfaenger, s.notiz, b.nummer, b.abgeglichen, s.kategorie_id, s.klasse_id, 0 AS gesplittet, b.quelle, - NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie, + NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie, 0 AS ohne_bargeld_abgleich, b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, s.id AS split_id, \'\' AS iban, \'\' AS bankname FROM buchung_splits s @@ -806,6 +807,9 @@ class Buchung 'gegenkonto_id' => $gegenkonto, 'gesplittet' => $gesplittet ? 1 : 0, 'bewusst_ohne_kategorie' => $bewusstOhne ? 1 : 0, + // Ohne Bargeld-Abgleich (#136): Die Buchung wird nicht gegen das + // Bargeld-Tool geprüft und deshalb auch nicht als offen angemahnt. + 'ohne_bargeld_abgleich' => !empty($daten['ohne_bargeld_abgleich']) ? 1 : 0, 'quelle' => isset(self::QUELLEN[(string)($daten['quelle'] ?? '')]) ? (string)$daten['quelle'] : 'manuell', 'import_hash' => ($daten['import_hash'] ?? null) ?: null, diff --git a/src/Services/Auswertung.php b/src/Services/Auswertung.php index e2c7dbc..ba72ace 100644 --- a/src/Services/Auswertung.php +++ b/src/Services/Auswertung.php @@ -841,6 +841,7 @@ class Auswertung 'betrag' => round((float)$zeile['betrag'], 2), 'bargeld_status' => $zeile['bargeld_status'] ?? null, 'bargeld_hinweis' => $zeile['bargeld_hinweis'] ?? null, + 'ohne_bargeld_abgleich' => !empty($zeile['ohne_bargeld_abgleich']), ]; } @@ -1193,13 +1194,15 @@ class Auswertung $sql = 'SELECT * FROM ( SELECT b.id AS buchung_id, NULL AS split_id, b.datum, b.betrag, b.konto_id, b.kategorie_id, b.klasse_id, b.gegenkonto_id, b.empfaenger, - b.buchungstext AS text, b.bargeld_status, b.bargeld_hinweis + b.buchungstext AS text, b.bargeld_status, b.bargeld_hinweis, + b.ohne_bargeld_abgleich FROM buchungen b WHERE b.benutzer_id = ? AND b.gesplittet = 0 AND b.datum BETWEEN ? AND ? UNION ALL SELECT b.id AS buchung_id, s.id AS split_id, b.datum, s.betrag, b.konto_id, s.kategorie_id, s.klasse_id, s.gegenkonto_id, b.empfaenger, - s.notiz AS text, b.bargeld_status, b.bargeld_hinweis + s.notiz AS text, b.bargeld_status, b.bargeld_hinweis, + b.ohne_bargeld_abgleich FROM buchung_splits s JOIN buchungen b ON b.id = s.buchung_id WHERE b.benutzer_id = ? AND b.gesplittet = 1 AND b.datum BETWEEN ? AND ? diff --git a/src/Services/Bankabgleich.php b/src/Services/Bankabgleich.php index 4a10391..d2f5d45 100644 --- a/src/Services/Bankabgleich.php +++ b/src/Services/Bankabgleich.php @@ -200,6 +200,7 @@ class Bankabgleich 'kategorie_id' => $zuordnung['kategorie_id'], 'klasse_id' => $zuordnung['klasse_id'], 'gegenkonto_id' => $zuordnung['gegenkonto_id'], + 'ohne_bargeld_abgleich' => $buchung['ohne_bargeld_abgleich'] ?? 0, ], $zuordnung['splits'] ); diff --git a/src/Services/BargeldAbgleich.php b/src/Services/BargeldAbgleich.php index f342cfd..1f01f5c 100644 --- a/src/Services/BargeldAbgleich.php +++ b/src/Services/BargeldAbgleich.php @@ -23,9 +23,12 @@ use Finanzmanager\Models\Kategorie; * * 1. Die Buchung trägt eine Kategorie (keine Umbuchung, keine Splitbuchung). * 2. Diese Kategorie ist in `Verwaltung → Bargeld-Tool` vorgemerkt. - * 3. Es ist eine Adresse konfiguriert. + * 3. Die Buchung ist nicht als „Ohne Bargeld-Abgleich“ markiert (#136) – + * Sammelbuchungen und Differenzausgleiche stehen absichtlich nicht im + * Bargeld-Tool und sollen nicht dauerhaft als offen angemahnt werden. + * 4. Es ist eine Adresse konfiguriert. * - * Fällt eine der drei Bedingungen weg, wird der Status gelöscht: Die Buchung + * Fällt eine der Bedingungen weg, wird der Status gelöscht: Die Buchung * hat mit dem Bargeld-Tool nichts zu tun und zeigt im Kontoblatt den grauen * Kreis. Das gilt ausdrücklich auch für eine Umkategorisierung – sonst bliebe * ein alter Haken an einer Buchung stehen, die niemand mehr abgleicht. @@ -141,7 +144,11 @@ class BargeldAbgleich /** @param array $buchung */ private static function istZuPruefen(array $buchung): bool { - if ((int)($buchung['gesplittet'] ?? 0) === 1 || !empty($buchung['gegenkonto_id'])) { + if ( + (int)($buchung['gesplittet'] ?? 0) === 1 + || !empty($buchung['gegenkonto_id']) + || !empty($buchung['ohne_bargeld_abgleich']) + ) { return false; } diff --git a/src/View.php b/src/View.php index 63fb638..0ad0be6 100644 --- a/src/View.php +++ b/src/View.php @@ -135,6 +135,8 @@ class View * Das Symbol der Abgleichspalte im Kontoblatt. Vier Zustände, in dieser * Reihenfolge zu lesen: gefunden, nicht gefunden, Abruf gescheitert – und * der graue Kreis für alles, was mit dem Bargeld-Tool nichts zu tun hat. + * Eine Buchung, die bewusst ohne Bargeld-Abgleich gebucht ist (#136), + * zeigt einen leeren grauen Kreis: nicht geprüft, aber absichtlich. * * Die Zeichen stehen als Text und nicht als Grafik da: Sie erben damit * Schriftgröße und – beim Ausrufezeichen ausdrücklich gewünscht – die @@ -144,15 +146,18 @@ class View * Schnellzuordnung (kontoblatt.js) dasselbe Symbol braucht, ohne die Seite * neu zu laden. */ - public static function bargeldMarke(?string $status, ?string $hinweis): string + public static function bargeldMarke(?string $status, ?string $hinweis, bool $ohneAbgleich = false): string { $hinweis = trim((string)$hinweis); - [$zeichen, $klasse, $standardtitel] = match ($status) { + [$zeichen, $klasse, $standardtitel] = match (true) { + $ohneAbgleich && $status === null => ['○', 'neutral', 'Bewusst ohne Abgleich mit dem Bargeld-Tool'], + default => match ($status) { 'gefunden' => ['✓', 'gefunden', 'Im Bargeld-Tool gefunden'], 'fehlt' => ['✗', 'fehlt', 'Im Bargeld-Tool nicht gefunden'], 'fehler' => ['!', 'fehler', 'Der Abruf beim Bargeld-Tool ist gescheitert'], default => ['●', 'neutral', 'Wird nicht mit dem Bargeld-Tool abgeglichen'], + }, }; return ' +
KategorieKlasseNotizAnteilKategorie / GegenkontoKlasseNotizAnteil
- name="split[][kategorie_id]"> - - - - + name="split[][zuordnung]"> + + + @@ -76,8 +74,9 @@ $fuerFormular = $splitForm === '' ? '' : ' form="' . View::h($splitForm) . '"';

Die Anteile gelten zueinander, nicht als feste Beträge: Bei jedem Buchungsbetrag - werden die Kategorien im selben Verhältnis bebucht. Ob dort 60 und 40 steht oder - 3 und 2, macht deshalb keinen Unterschied. Die letzte Zeile bekommt den Rest, damit - die Summe auf den Cent zum Buchungsbetrag passt. Eine Zeile ohne Kategorie oder ohne - Anteil fällt weg; ohne jede Splitzeile bucht die Regel wie bisher auf ihre Kategorie. + werden die Kategorien und Umbuchungskonten im selben Verhältnis bebucht. Ob dort 60 + und 40 steht oder 3 und 2, macht deshalb keinen Unterschied. Die letzte Zeile bekommt + den Rest, damit die Summe auf den Cent zum Buchungsbetrag passt. Eine Zeile ohne + Kategorie bzw. Gegenkonto oder ohne Anteil fällt weg; ohne jede Splitzeile bucht die + Regel wie bisher auf ihre Kategorie.

diff --git a/src/Views/banking/umsaetze.php b/src/Views/banking/umsaetze.php index d53c7aa..22bc109 100644 --- a/src/Views/banking/umsaetze.php +++ b/src/Views/banking/umsaetze.php @@ -1,5 +1,6 @@ @@ -148,6 +157,7 @@ include __DIR__ . '/../layout/kopf.php'; @@ -162,12 +172,12 @@ include __DIR__ . '/../layout/kopf.php';
    $zeile): ?>
  • - + - +
- + + + - - + + diff --git a/src/Views/vertraege/index.php b/src/Views/vertraege/index.php index f2c32d0..ad240b6 100644 --- a/src/Views/vertraege/index.php +++ b/src/Views/vertraege/index.php @@ -8,6 +8,7 @@ use Finanzmanager\View; * @var list> $fristen * @var list> $adressen * @var list> $kategorien + * @var list> $konten * @var list> $reihen * @var array $intervalle * @var string $csrfToken @@ -74,6 +75,8 @@ include __DIR__ . '/../layout/kopf.php'; value=""> + - - + +
diff --git a/src/Views/verwaltung/export.php b/src/Views/verwaltung/export.php index 2a0f41b..dbbbefb 100644 --- a/src/Views/verwaltung/export.php +++ b/src/Views/verwaltung/export.php @@ -1,10 +1,11 @@ , kategorien: list, klassen: list} $filter */ +/** @var array{von: string, bis: string, konten: list, kategorien: list, gegenkonten: list, klassen: list} $filter */ /** @var list> $konten */ /** @var list> $kategorien */ /** @var list> $klassen */ @@ -87,6 +88,21 @@ include __DIR__ . '/../layout/kopf.php';

+ + $gruppenkonten): ?> +

+
+ + + +
+ +

Klassen

Noch keine Klassen angelegt.

diff --git a/src/Views/vorlagen/index.php b/src/Views/vorlagen/index.php index 45c4819..cceb845 100644 --- a/src/Views/vorlagen/index.php +++ b/src/Views/vorlagen/index.php @@ -1,5 +1,6 @@
VorlageKategorieKontoVorlageKategorie / GegenkontoKonto BetragBuchen am GenutztAktion
@@ -126,12 +129,11 @@ include __DIR__ . '/../layout/kopf.php'; - - + + diff --git a/src/Views/zahlungsverkehr/formular.php b/src/Views/zahlungsverkehr/formular.php index 9eae25f..54efb09 100644 --- a/src/Views/zahlungsverkehr/formular.php +++ b/src/Views/zahlungsverkehr/formular.php @@ -1,5 +1,6 @@ > $konten * @var list> $adressen * @var list> $kategorien + * @var list> $umbuchungskonten * @var list> $klassen * @var string $csrfToken * @var bool|null $hatProtokoll @@ -141,15 +143,12 @@ include __DIR__ . '/../layout/kopf.php'; Auftrag abgewiesen und bleibt als Entwurf stehen.

- - + + +

Wird auf die Buchung übertragen, sobald die Bank den Auftrag annimmt.

diff --git a/tests/61_UmbuchungskontenAuswahlTest.php b/tests/61_UmbuchungskontenAuswahlTest.php new file mode 100644 index 0000000..ff0b8fc --- /dev/null +++ b/tests/61_UmbuchungskontenAuswahlTest.php @@ -0,0 +1,313 @@ +mandantAnlegen(); + + // Die Gruppen in umgekehrter Namensreihenfolge sortiert: Die Liste muss + // der Sortierung der Kontengruppen folgen, nicht dem Alphabet. + $sparen = Kontengruppe::anlegen('Sparen', '', 1); + $liquide = Kontengruppe::anlegen('Liquide Mittel', '', 2); + + $this->giro = Konto::anlegen([ + 'name' => 'Auswahl Giro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, + 'kontengruppe_id' => $liquide, + ]); + $this->tagesgeld = Konto::anlegen([ + 'name' => 'Auswahl Tagesgeld', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, + 'kontengruppe_id' => $sparen, + ]); + $this->bauspar = Konto::anlegen([ + 'name' => 'Auswahl Bausparen', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->alt = Konto::anlegen([ + 'name' => 'Auswahl Altkonto', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 0, + 'kontengruppe_id' => $sparen, + ]); + $this->lebensmittel = Kategorie::anlegen(['name' => 'Auswahl Lebensmittel', 'art' => 'ausgabe']); + + $this->gruppenNurAktivNachKontengruppe(); + $this->auswahllisteZeigtUmbuchungskonten(); + $this->formularwerteHinUndZurueck(); + $this->regelsplitsMitGegenkonto(); + $this->regelmaskeHatGemeinsameListe(); + $this->buchungsberichtFiltertNachUmbuchungskonto(); + $this->exportFiltertNachUmbuchungskonto(); + $this->ueberweisungAufEigenesKonto(); + $this->vertragUndVorlageMitGegenkonto(); + } + + private function gruppenNurAktivNachKontengruppe(): void + { + $gruppen = Konto::umbuchungsgruppen(Konto::alle()); + + $this->assertEquals( + ['Umbuchungskonten – Sparen', 'Umbuchungskonten – Liquide Mittel', 'Umbuchungskonten – nicht gruppiert'], + array_keys($gruppen), + 'Die Umbuchungskonten stehen je Kontengruppe in deren Reihenfolge' + ); + $namen = array_merge(...array_map( + static fn(array $konten): array => array_column($konten, 'name'), + array_values($gruppen) + )); + $this->assert(!in_array('Auswahl Altkonto', $namen, true), 'Ein inaktives Konto wird nicht angeboten'); + + $mitAltem = Konto::umbuchungsgruppen(Konto::alle(), [$this->alt]); + $this->assertEquals( + ['Auswahl Altkonto', 'Auswahl Tagesgeld'], + array_column($mitAltem['Umbuchungskonten – Sparen'], 'name'), + 'Ein schon gewähltes inaktives Konto bleibt in seiner Gruppe stehen' + ); + + $ohneGruppen = Konto::umbuchungsgruppen([ + ['id' => 1, 'name' => 'A', 'aktiv' => 1, 'gruppenname' => null], + ]); + $this->assertEquals(['Umbuchungskonten'], array_keys($ohneGruppen), 'Ohne Kontengruppen eine einzige Überschrift'); + } + + private function auswahllisteZeigtUmbuchungskonten(): void + { + $html = $this->auswahlliste('kategorie:' . $this->lebensmittel, $this->giro); + + $this->assert(str_contains($html, ''), 'Die Kategorien stehen oben'); + $kategorien = strpos($html, 'Auswahl Lebensmittel'); + $tagesgeld = strpos($html, '[Auswahl Tagesgeld]'); + $this->assert( + $kategorien !== false && $tagesgeld !== false && $kategorien < $tagesgeld, + 'Die Umbuchungskonten stehen unter den Kategorien' + ); + $this->assert( + str_contains($html, ''), + 'Die Umbuchungskonten sind nach Kontengruppe beschriftet' + ); + $this->assert(!str_contains($html, '[Auswahl Giro]'), 'Das eigene Konto wird nicht angeboten'); + $this->assert( + (bool)preg_match('#
Kategorie diff --git a/src/Views/buchungen/formular.php b/src/Views/buchungen/formular.php index 7916b18..e69ecab 100644 --- a/src/Views/buchungen/formular.php +++ b/src/Views/buchungen/formular.php @@ -11,12 +11,14 @@ use Finanzmanager\View; /** @var list> $kategorien */ /** @var list> $klassen */ /** @var list> $konten */ +/** @var bool $bargeld Ist ein Bargeld-Tool eingerichtet? */ /** @var string $csrfToken */ $neu = $buchung === null; $ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id']; $betrag = $neu ? 0.0 : (float)$buchung['betrag']; $eingaben = $eingaben ?? null; +$bargeld = $bargeld ?? false; // Die Art bestimmt nur noch die Richtung. Ob die Buchung eine Umbuchung ist, // steht im Feld Kategorie / Gegenkonto. @@ -39,6 +41,7 @@ $buchungstextWert = (string)($buchung['buchungstext'] ?? ''); $nummerWert = (string)($buchung['nummer'] ?? ''); $abgeglichenWert = !empty($buchung['abgeglichen']); $ohneKategorieWert = !empty($buchung['bewusst_ohne_kategorie']); +$ohneBargeldWert = !empty($buchung['ohne_bargeld_abgleich']); if ($eingaben !== null) { $art = ($eingaben['art'] ?? '') === 'einnahme' ? 'einnahme' : 'ausgabe'; @@ -51,6 +54,7 @@ if ($eingaben !== null) { $nummerWert = (string)($eingaben['nummer'] ?? ''); $abgeglichenWert = isset($eingaben['abgeglichen']); $ohneKategorieWert = isset($eingaben['bewusst_ohne_kategorie']); + $ohneBargeldWert = isset($eingaben['ohne_bargeld_abgleich']); } $eigenesKonto = (int)$konto['id']; @@ -206,6 +210,22 @@ include __DIR__ . '/../layout/kopf.php'; + + +
+ +
+ + + +
+ + + +