Merge pull request 'Fix #123: Regelmäßige Buchungen automatisch abgleichen' (#139) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/139
This commit is contained in:
fds2610 2026-10-12 22:58:27 +02:00
commit 57c6253997
77 changed files with 2450 additions and 433 deletions

View file

@ -8,6 +8,22 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
### Added
- Buchungsbericht: Spalte „✓“ (abgeglichen) vor der Spalte „Bar“ des Bargeld-Tools und
Filter „Abgeglichen“ (alle / abgeglichen / nicht abgeglichen); „Buchungen nicht
abgeglichen“ unter den offenen Vorgängen im Cockpit öffnet den Buchungsbericht mit
diesem Filter statt der Kontenübersicht (#137)
- 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
„Depotbuchung erfassen“) lässt sich eine Klasse wählen; sie gilt für die Buchung samt
aller Splitzeilen der Nebenkosten (#125)
- 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)
@ -22,6 +38,12 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
### Fixed
- Offene Vorgänge: „Im Bargeld-Tool nicht gefunden“ und „Bargeld-Tool-Prüfung fehlgeschlagen“
führen jetzt in den Buchungsbericht mit dem passenden Filter „Bargeld-Tool“ – dort steht die
Spalte Kategorie (bzw. das Gegenkonto) mit; die eigene Liste unter
`/verwaltung/bargeld/fehler` entfällt (#131)
- Anmeldeseite: Die Marke zeigt wie die Seitenleiste nach dem Anmelden das Coinstack-Symbol
und den fetten, zweifarbigen Schriftzug „Finanzmanager“ statt einer grauen Statusmarke (#130)
- Buchungsmaske: Eine falsche Splitsumme zeigte zwar die Fehlermeldung, riss aber über die
Weiterleitung zur leeren Maske alle gerade erfassten Splitzeilen heraus; jetzt bleibt die
Maske mit den eigenen Eingaben stehen, bis die Summe stimmt (#95)

View file

@ -1,5 +1,71 @@
# Änderungen
## Buchungsbericht: Spalte und Filter „abgeglichen“ (12.10.2026)
- Anlass (#137): Der Buchungsbericht zeigte nicht, ob eine Buchung abgeglichen ist, und ließ sich nicht danach filtern.
- Neue Spalte „✓“ (Tooltip „abgeglichen“) vor der Spalte „Bar“ des Bargeld-Tools – wie im Kontoblatt. Splitzeilen erben den Haken ihrer Buchung.
- Neuer Filter „Abgeglichen“ (alle / abgeglichen / nicht abgeglichen), Filterfeld `abgeglichen` mit den Werten `ja`/`nein`; wie der Bargeld-Filter wirkt er nur im Buchungsbericht und wird mit gespeicherten Berichten gesichert.
- Cockpit, *Offene Vorgänge*: „Buchungen nicht abgeglichen“ führt jetzt in den Buchungsbericht mit dem Filter „nicht abgeglichen“ statt in die Kontenübersicht (`Cockpit::nichtAbgeglichenBericht()`). Zeitraum von der ersten betroffenen Buchung bis heute, Umbuchungen eingeschlossen; liegen nicht abgeglichene Buchungen auf aufgelösten Konten, beschränkt sich der Bericht auf die aktiven Konten – so zeigt er genau die gezählten Buchungen.
- Tests: `tests/14_AuswertungTest.php`, `tests/15_CockpitTest.php`, `tests/e2e/specs/auswertungen.spec.js`.
## 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.
- Den Filter „Bargeld-Tool“ hat der Buchungsbericht schon; die beiden offenen Vorgänge „Bargeld-Tool-Prüfung fehlgeschlagen“ und „im Bargeld-Tool nicht gefunden“ führen jetzt dorthin (`Cockpit::bargeldBericht()`): gesamter Zeitraum, Umbuchungen eingeschlossen, Filter „Abruf gescheitert“ bzw. „nicht gefunden“. Der Bericht zeigt Konto, Kategorie bzw. Gegenkonto in eckigen Klammern und Klasse; die Meldung der Gegenstelle steht im Tooltip der Spalte „Bar“.
- Ist einer dieser beiden Filter gesetzt, erklärt der Hinweis unter dem Bericht den Status und dass eine erneute Zuordnung der Kategorie die Prüfung erneut anstößt – der Text der bisherigen Liste.
- Entfernt: die Seite `/verwaltung/bargeld/fehler` (`VerwaltungController::bargeldFehler()`, `Views/verwaltung/bargeld_fehler.php`) und `Buchung::mitBargeldFehler()`.
- Tests: `tests/34_BargeldAbgleichTest.php`, `tests/15_CockpitTest.php`, `tests/e2e/specs/bargeld.spec.js`.
## Anmeldeseite: Marke wie in der Seitenleiste (12.10.2026)
- Anlass (#130): Icon und Schrift des Logos auf der Anmeldeseite wichen von der Marke im angemeldeten Zustand ab.
- Der schlichte Rahmen (`src/Views/layout/schlicht.php` – Anmeldung, Ersteinrichtung, Fehlerseite) zeigt jetzt das Coinstack-Symbol und den Schriftzug „Finanz**manager**“ im Schriftschnitt der Seitenleiste. Bisher erbte der Schriftzug die Regeln der kleinen Statusmarke (`.marke`) und stand grau mit Rahmen da.
- Weil die Karte hell ist, trägt die erste Worthälfte die Textfarbe und die zweite die lesbare Akzentfarbe (`--akzent-schrift`); im dunklen Thema entspricht das dem Blau der Seitenleiste.
- Tests: `tests/64_AnmeldeMarkeTest.php`, `tests/e2e/specs/smoke.spec.js`.
## Depotbuchung erfassen: Stück, Kurs und Betrag rechnen sich (12.10.2026)
- Anlass (#129): Bei Kauf und Verkauf sollten sich Betrag, Stück und Kurs gegenseitig ergeben – Betrag = Stück × Kurs.
- Die Maske *Depotbuchung erfassen* rechnet bei Kauf und Verkauf das dritte Feld aus, sobald zwei eingetragen sind (`public/static/depotbuchung.js`): den Betrag auf zwei, Kurs und Stück auf sechs Nachkommastellen. Der ausgerechnete Wert ist farblich hinterlegt und zieht nach, wenn sich eines der anderen Felder ändert; selbst umgetippt gilt er als eingetragen, beim Leeren eines der anderen Felder verschwindet er. Bei Dividende, Ein- und Auslieferung wird nicht gerechnet; wechselt der Vorgang auf Kauf oder Verkauf, rechnet die Maske nach.
- Serverseitig dieselbe Regel für den Weg ohne JavaScript (`Depotbuchung::dritterWert()`): Neben dem Betrag ergeben sich jetzt auch Kurs bzw. Stück bei Kauf und Verkauf.
- Der Dialog des Wertpapiergeschäfts im Kontoblatt bleibt unverändert: Dort ist der Buchungsbetrag die feste Größe.
- Tests: `tests/63_DepotbuchungDritterWertTest.php`, `tests/e2e/specs/depotbuchung.spec.js`.
## Wertpapiere: Klasse beim Buchen wählen (12.10.2026)
- Anlass (#125): Beim Buchen von Wertpapieren fehlte die Auswahl einer Klasse.
- Die Wertpapiermaske (Dialog im Kontoblatt und Seite `/buchungen/{id}/wertpapier`) hat ein Feld „Klasse“, vorbelegt mit der bisherigen Klasse der Buchung. Die Klasse steht danach an der Buchung und an jeder Splitzeile der Nebenkosten – das Kontoblatt zeigt so die eine Klasse statt „Splitbuchung“. Eine leere Auswahl entfernt die Klasse.
- Auch „Depotbuchung erfassen“ unter *Wertpapiere* bietet die Klasse an; sie gilt der dort erzeugten Geldbuchung auf dem Verrechnungskonto.
- Eine unbekannte Klasse wird abgewiesen, ohne dass ein Geschäft entsteht.
- Tests: `tests/62_WertpapierKlasseTest.php`, `tests/e2e/specs/wertpapiergeschaeft.spec.js`.
## 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.
- 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.

View file

@ -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`.

View file

@ -48,7 +48,7 @@ Zwei Ausnahmen führen das Vorzeichen mit, weil sie keine Abschnitte haben: der
| Typ | Zeilen | Spalten |
|---|---|---|
| **Summenbericht** | Zeilen-Summe, nach Einnahmen und Ausgaben getrennt | Zeitraum, je nach Spalten-Aufteilung |
| **Buchungsbericht** | Einzelbuchungen, gruppiert nach Zeilen-Summe; bei eingerichtetem Bargeld-Tool mit Spalte „Bar“ und Filter „Bargeld-Tool“ | feste Buchungsspalten |
| **Buchungsbericht** | Einzelbuchungen, gruppiert nach Zeilen-Summe; Spalte „✓“ (abgeglichen) mit Filter „Abgeglichen“ (#137); bei eingerichtetem Bargeld-Tool mit Spalte „Bar“ und Filter „Bargeld-Tool“ | feste Buchungsspalten |
| **Vergleichsbericht** | wie Summenbericht | Zeitraum, Vorjahreszeitraum, Differenz, Veränderung |
| **Kontostandsbericht** | Konten | Salden zu den Enden der Spaltenzeiträume |
| **Budgetbericht** | Kategoriebaum | Budget, Ist, Saldo |
@ -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.

View file

@ -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.

View file

@ -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
(`<option hidden>`) in der Liste, damit `kontoblatt.js` eine bestehende Splitzeile damit
vorbelegen kann; die Kombobox zeigt sie nur an, solange sie gewählt sind.
Die Filter von Buchungsbericht und Export führen die Umbuchungskonten ebenfalls unter den
Kategorien (siehe `docs/auswertungen.md` und `docs/import-export.md`).
Ein Depot steht **zweimal** in der Liste: als gewöhnliche Umbuchung (`konto:7`) und in der
Gruppe *Wertpapiergeschäft im Depot* (`depot:7`). Beides gibt es wirklich – auf ein Depot
lässt sich Geld überweisen, ohne dass etwas gekauft wird. `depot:7` speichert keine
@ -271,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.
@ -285,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 |
@ -296,11 +312,20 @@ Adresse hinterlegt, entfällt die Spalte ganz; sie zeigte sonst in jeder Zeile d
grauen Kreis.
Buchungen mit dem Status `fehler` zählen im Cockpit als offener Vorgang „Bargeld-Tool-Prüfung
fehlgeschlagen“ (siehe `docs/cockpit.md`) – ein Klick führt auf
`/verwaltung/bargeld/fehler`, die kontoübergreifende Liste dieser Buchungen
(`Buchung::mitBargeldFehler()`). Buchungen mit dem Status `fehlt` zählen als eigener offener
Vorgang „im Bargeld-Tool nicht gefunden“ und führen auf dieselbe Seite mit `?status=fehlt`
(`Buchung::mitBargeldFehler('fehlt')`).
fehlgeschlagen“ (siehe `docs/cockpit.md`) – ein Klick führt in den Buchungsbericht über den
gesamten Zeitraum, gefiltert auf „Bargeld-Tool: Abruf gescheitert“. Buchungen mit dem Status
`fehlt` zählen als eigener offener Vorgang „im Bargeld-Tool nicht gefunden“ und führen in
denselben Bericht mit dem Filter „nicht gefunden“. Der Bericht zeigt Konto, Kategorie bzw.
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

View file

@ -179,13 +179,19 @@ Prüfungen, die gerade nichts melden und daher nicht in der Liste stehen:
gesplittet sind. Umbuchungen brauchen keine Kategorie und zählen deshalb nicht mit,
ebenso wenig Buchungen, die bewusst ohne Kategorie gebucht sind (siehe `docs/buchungen.md`).
* **nicht abgeglichen** – Buchungen aktiver Konten bis heute ohne Abgleich-Kennzeichen.
Führt in den Buchungsbericht mit dem Filter „Abgeglichen: nicht abgeglichen“, von der
ersten betroffenen Buchung bis heute und mit Umbuchungen; liegen auch auf aufgelösten
Konten solche Buchungen, ist der Bericht auf die aktiven Konten eingeschränkt (#137).
* **Bargeld-Tool-Prüfung fehlgeschlagen** – Buchungen mit `bargeld_status = 'fehler'`, also
einem gescheiterten Abruf beim Bargeld-Tool (siehe `docs/buchungen.md`), nicht zu
verwechseln mit „im Bargeld-Tool nicht gefunden“. Führt auf
`/verwaltung/bargeld/fehler`, die Liste dieser Buchungen kontoübergreifend
(`Buchung::mitBargeldFehler()`), denn die Prüfung hängt an der Kategorie, nicht am Konto.
* **Im Bargeld-Tool nicht gefunden** – Buchungen mit `bargeld_status = 'fehlt'`. Führt auf
`/verwaltung/bargeld/fehler?status=fehlt` (`Buchung::mitBargeldFehler('fehlt')`).
verwechseln mit „im Bargeld-Tool nicht gefunden“. Führt in den Buchungsbericht über den
gesamten Zeitraum mit dem Filter „Bargeld-Tool: Abruf gescheitert“ – kontoübergreifend,
denn die Prüfung hängt an der Kategorie, nicht am Konto, und samt Spalte Kategorie (#131).
* **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.

View file

@ -259,6 +259,9 @@ Zwei Eigenheiten des Datenmodells schlagen auf den Filter durch:
Auszug des Gegenkontos seine einzige Bewegung.
* **Kategorie und Klasse zählen auch in den Splitzeilen.** Sonst fiele jede Splitbuchung
aus dem Filter, obwohl sie die gesuchte Kategorie führt.
* **Unter den Kategorien stehen die aktiven Umbuchungskonten** (#124, `gegenkonten[]`).
Ein gewähltes Konto nimmt jede Buchung oder Splitzeile mit diesem Gegenkonto mit;
Kategorien und Umbuchungskonten verbinden sich als „oder“, wie in einer Auswahlliste.
Die Kontenliste der Datei enthält zusätzlich jedes Konto, das nur Umbuchungsziel ist. Ohne
seinen Eintrag bezöge sich die Datei auf ein Konto, das der Import nicht kennt, und die

View file

@ -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.
@ -91,6 +95,10 @@ vierteljährlich × 4 und so fort.
Ein Vertrag kann mit einer regelmäßigen Buchung verbunden sein, die seine Zahlung erledigt.
Statt einer Kategorie kann ein Vertrag auf ein Umbuchungskonto zeigen – ein Bauspar- oder
Sparvertrag etwa (#124, Spalte `vertraege.gegenkonto_id`). Die Auswahl ist dieselbe Liste
*Kategorie / Gegenkonto* wie in der Buchungsmaske.
## Sparziele
Der erreichte Stand kommt **aus dem Kontosaldo**, nicht aus einem eigenen Zähler – ein
@ -131,6 +139,12 @@ Fehlgeschlagene tragen die Meldung der Bank daneben.
Ohne Ausführungstermin geht die Überweisung sofort hinaus, mit Termin als
Terminüberweisung; sie erscheint dann im Finanzkalender.
**Kategorie / Gegenkonto.** Wie in der Buchungsmaske stehen unter den Kategorien die
aktiven Umbuchungskonten (#124, Spalte `zahlungsauftraege.gegenkonto_id`). Geht die
Überweisung auf ein eigenes Konto – etwa aufs Tagesgeld –, wird sie beim Senden gleich als
Umbuchung gebucht. Das Auftragskonto selbst als Gegenkonto weist schon das Erfassen ab;
erst beim Senden fiele es sonst auf, wenn die Bank das Geld längst hat.
**Echtzeitüberweisung.** Das Häkchen „Echtzeitüberweisung“ im Überweisungsdialog
(Spalte `zahlungsauftraege.echtzeit`) macht aus dem Auftrag eine SEPA-Echtzeitüberweisung
(SCT Inst): Das Geld ist binnen Sekunden beim Empfänger. SEPA behandelt sie als eigenes

View file

@ -59,6 +59,13 @@ ihrer Kategorie, der Rest als Umbuchung ins Depot. Die Zeilen ergeben genau den
Buchungsbetrag. Ohne Nebenkosten bleibt es bei der schlichten Umbuchung – eine
Splitbuchung mit einer einzigen Zeile wäre nur umständlicher zu lesen.
**Klasse.** Die Maske bietet eine Klasse an, vorbelegt mit der bisherigen Klasse der
Buchung. Sie gilt für das ganze Geschäft: an der Buchung und an jeder Splitzeile, damit
das Kontoblatt keine gemischten Klassen zeigt und die Nebenkosten in der Auswertung nach
Klassen nicht fehlen. Eine leere Auswahl entfernt die Klasse. Auch die Maske *Depotbuchung
erfassen* unter *Wertpapiere* hat das Feld; dort steht die Klasse an der Geldbuchung auf
dem Verrechnungskonto.
### Kostenarten und ihre Kategorien
`Services\Wertpapierkosten` kennt sechs Gebühren- und sechs Steuerarten – dieselben, die
@ -101,6 +108,13 @@ enthält.
`stueck` trägt die Richtung: Zugänge positiv, Abgänge negativ, Dividenden null.
In der Maske *Depotbuchung erfassen* genügen bei Kauf und Verkauf zwei der drei Angaben
Stück, Kurs und Betrag: Sobald zwei eingetragen sind, rechnet `public/static/depotbuchung.js`
das dritte aus (Betrag auf zwei, Stück und Kurs auf sechs Nachkommastellen) und hebt es
farblich hervor. Ein ausgerechneter Wert wird nachgezogen, wenn sich eines der anderen
Felder ändert; tippt man ihn selbst um, gilt er als eingetragen. Ohne JavaScript rechnet
`Depotbuchung::dritterWert()` dieselbe Regel beim Speichern.
## Einstand nach gleitendem Durchschnitt
Bei jedem Kauf steigt der Einstand um Kaufpreis samt Gebühren, bei jedem Verkauf sinkt er
@ -309,7 +323,8 @@ eigener Wert der Zuordnungsliste, ISIN und WKN aus dem Verwendungszweck, Kauf mi
Kauf ohne Kosten als schlichte Umbuchung mit errechnetem Kurs, Verkauf mit Gebühren und
Steuern, das Löschen, das die Bankbuchung stehen lässt, sowie die Abweisungen: doppeltes
Geschäft, Kosten in Höhe der Buchung, Konto statt Depot, Kauf ohne Stückzahl. Die beiden
Dialoge prüft `tests/e2e/specs/wertpapiergeschaeft.spec.js`.
Dialoge prüft `tests/e2e/specs/wertpapiergeschaeft.spec.js`, die Klasse des Geschäfts
`tests/62_WertpapierKlasseTest.php`.
`tests/26_WertpapierDetailTest.php` – die Detailseite: der auf ein Papier eingegrenzte
Bestand (fremde Papiere bleiben draußen, mehrere Depots werden je Position geführt, Depot

View file

@ -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;

View file

@ -0,0 +1,19 @@
-- Umbuchungskonten in jeder Kategorieauswahl (#124).
--
-- Überall, wo eine Kategorie gewählt wird, stehen darunter jetzt auch die
-- aktiven Umbuchungskonten. Buchungen, Splitzeilen, Vorlagen, regelmäßige
-- Buchungen und Regeln kennen das Gegenkonto schon; Zahlungsaufträge und
-- Verträge bekommen es hier. Eine Überweisung auf das eigene Tagesgeldkonto
-- wird so beim Senden gleich als Umbuchung gebucht.
--
-- Idempotent über IF NOT EXISTS.
ALTER TABLE zahlungsauftraege ADD COLUMN IF NOT EXISTS
gegenkonto_id INT UNSIGNED NULL AFTER kategorie_id;
ALTER TABLE zahlungsauftraege ADD CONSTRAINT fk_auftrag_gegenkonto FOREIGN KEY IF NOT EXISTS (gegenkonto_id)
REFERENCES konten (id) ON DELETE SET NULL;
ALTER TABLE vertraege ADD COLUMN IF NOT EXISTS
gegenkonto_id INT UNSIGNED NULL AFTER kategorie_id;
ALTER TABLE vertraege ADD CONSTRAINT fk_vertrag_gegenkonto FOREIGN KEY IF NOT EXISTS (gegenkonto_id)
REFERENCES konten (id) ON DELETE SET NULL;

View file

@ -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;

View file

@ -139,7 +139,6 @@ $router->post('/verwaltung/kontengruppen/{id}/loeschen',
$router->get('/verwaltung/bargeld', fn() => (new VerwaltungController())->bargeld());
$router->post('/verwaltung/bargeld', fn() => (new VerwaltungController())->bargeldSpeichern());
$router->post('/verwaltung/bargeld/test', fn() => (new VerwaltungController())->bargeldTest());
$router->get('/verwaltung/bargeld/fehler', fn() => (new VerwaltungController())->bargeldFehler());
// Das Bankleitzahlen-Verzeichnis: nachschlagen und die Institutsliste einlesen.
$router->get('/verwaltung/bankinstitute', fn() => (new BankinstituteController())->index());

View file

@ -1412,17 +1412,21 @@ table.kostentabelle select { max-width: 16rem; width: 100%; }
table.kostentabelle th[scope="row"] { font-weight: 400; text-align: left; }
table.kostentabelle .gruppenzeile th { padding-top: .7rem; font-weight: 600; }
/* Die Abgleichspalte zwischen Klasse und Betrag: so schmal wie ihr Zeichen.
Sie erscheint nur, wenn ein Bargeld-Tool eingerichtet ist. */
/* Die Abgleichspalten zwischen Klasse und Betrag: so schmal wie ihr Zeichen.
„✓“ (abgeglichen) steht immer davor, „Bar“ erscheint nur, wenn ein
Bargeld-Tool eingerichtet ist. */
table.liste th.bargeldspalte,
table.liste td.bargeldspalte {
table.liste td.bargeldspalte,
table.liste th.abgleichspalte,
table.liste td.abgleichspalte {
width: 1.8rem;
padding-left: .2rem;
padding-right: .2rem;
text-align: center;
}
table.liste th.bargeldspalte {
table.liste th.bargeldspalte,
table.liste th.abgleichspalte {
font-size: .78rem;
color: var(--text-gedaempft);
}
@ -1622,6 +1626,10 @@ table.liste th.bargeldspalte {
margin: .15rem 0 0;
}
/* Ein Feld, dessen Wert die Maske aus den anderen ausgerechnet hat
* (Stück, Kurs, Betrag in „Depotbuchung erfassen“). */
input.berechnet { background: var(--akzent-schwach); }
/* Der Hinweis hinter einem Ankreuzfeld statt darunter – bei einer Liste
gleichartiger Schalter hielte eine eigene Zeile je Hinweis sie zu weit
auseinander. */
@ -1778,12 +1786,23 @@ body.schlicht {
max-width: 400px;
}
.marke.gross {
/* Die Marke der Anmeldung gleicht der Seitenleiste: Symbol, Schriftschnitt und
zweite Worthälfte in Akzentfarbe. Weiß auf der hellen Karte wäre unsichtbar,
deshalb trägt die erste Hälfte die Textfarbe und die zweite die lesbare
Variante des Akzents. */
.anmelde-marke {
display: flex;
align-items: center;
gap: .6rem;
padding: 0 0 1.25rem;
font-size: 1.3rem;
color: var(--text);
font-weight: 700;
letter-spacing: .01em;
}
.anmelde-marke .marken-text { color: var(--text); }
.anmelde-marke .marken-text span { color: var(--akzent-schrift); }
/* ── Auswertungen ────────────────────────────────────────────────────────── */
/* Aufklappmenü ohne JavaScript: <details> in der Aktionsleiste. Ein eigenes

View file

@ -0,0 +1,111 @@
/* Stück, Kurs und Betrag in der Maske „Depotbuchung erfassen“.
*
* Bei Kauf und Verkauf gilt Betrag = Stück × Kurs: Sobald zwei der drei Felder
* eingetragen sind, ergibt sich das dritte. Der ausgerechnete Wert bleibt
* ausgerechnet – ändert man danach eines der beiden anderen Felder, wird er
* nachgezogen. Tippt man ihn selbst um, gilt er als eingetragen; leert man eines
* der beiden anderen Felder, verschwindet er wieder, weil nichts mehr dahinter
* steht.
*
* Der Server rechnet dieselbe Regel noch einmal (Depotbuchung::dritterWert) –
* für den Weg ohne JavaScript.
*/
(function () {
'use strict';
const formular = document.getElementById('depotbuchungsformular');
if (!formular || !window.FmBetrag) {
return;
}
const vorgang = document.getElementById('vorgang');
const felder = {
stueck: document.getElementById('stueck'),
kurs: document.getElementById('kurs'),
betrag: document.getElementById('betrag')
};
if (!vorgang || !felder.stueck || !felder.kurs || !felder.betrag) {
return;
}
// Welches Feld zuletzt ausgerechnet wurde – null, solange alle eingetragen sind.
let berechnet = null;
function wert(name) {
return window.FmBetrag.zahl(felder[name].value);
}
function leer(name) {
return felder[name].value.trim() === '';
}
function formatiere(name, zahl) {
if (name === 'betrag') {
return window.FmBetrag.formatiere(zahl);
}
// Stück und Kurs führt die Datenbank mit sechs Nachkommastellen.
return zahl.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 6 });
}
function setze(name, zahl) {
felder[name].value = zahl === null ? '' : formatiere(name, zahl);
felder[name].classList.toggle('berechnet', zahl !== null);
berechnet = zahl === null ? null : name;
}
function ausrechnen(name) {
const stueck = wert('stueck');
const kurs = wert('kurs');
const betrag = wert('betrag');
switch (name) {
case 'betrag':
return stueck > 0 && kurs > 0 ? Math.round(stueck * kurs * 100) / 100 : null;
case 'kurs':
return stueck > 0 && betrag > 0 ? Math.round(betrag / stueck * 1e6) / 1e6 : null;
default:
return kurs > 0 && betrag > 0 ? Math.round(betrag / kurs * 1e6) / 1e6 : null;
}
}
function rechne(geaendert) {
if (geaendert !== null && geaendert === berechnet) {
felder[geaendert].classList.remove('berechnet');
berechnet = null;
}
if (vorgang.value !== 'kauf' && vorgang.value !== 'verkauf') {
return;
}
if (geaendert !== null && leer(geaendert) && berechnet !== null) {
setze(berechnet, null);
return;
}
let ziel = berechnet;
if (ziel === null) {
const offen = Object.keys(felder).filter(leer);
if (offen.length !== 1) {
return;
}
ziel = offen[0];
}
const ergebnis = ausrechnen(ziel);
if (ergebnis !== null) {
setze(ziel, ergebnis);
} else if (ziel === berechnet) {
setze(ziel, null);
}
}
Object.keys(felder).forEach(function (name) {
felder[name].addEventListener('input', function () {
rechne(name);
});
});
vorgang.addEventListener('change', function () {
rechne(null);
});
})();

View file

@ -43,8 +43,10 @@
return gruppen;
}
/* Ein verborgener Eintrag (etwa ein inaktives Umbuchungskonto) bleibt als
* Wert gültig, erscheint in der Liste aber nur, solange er gewählt ist. */
function eintragAus(option) {
return { wert: option.value, text: option.textContent.trim() };
return { wert: option.value, text: option.textContent.trim(), verborgen: option.hidden };
}
function passt(text, filter) {
@ -130,7 +132,8 @@
gruppen.forEach(function (gruppe) {
const treffer = gruppe.optionen.filter(function (eintrag) {
return passt(eintrag.text, filterText);
return (!eintrag.verborgen || eintrag.wert === select.value)
&& passt(eintrag.text, filterText);
});
if (treffer.length === 0) {
return;

View file

@ -168,6 +168,13 @@
document.getElementById('wp_isin').value = isin;
document.getElementById('wp_wkn').value = wkn;
// Die Klasse der Buchung steht schon in ihrer Zeile – sie ist die
// Vorgabe für das Geschäft.
const zeile = feld.closest('tr');
const klassenfeld = zeile && zeile.querySelector('a.klassenfeld');
const klasse = klassenfeld ? (klassenfeld.dataset.klasse || '') : '';
document.getElementById('wp_klasse').value = klasse === '0' ? '' : klasse;
papierWaehlen(isin, wkn);
vorgaengeStellen();
rechne();

View file

@ -825,7 +825,7 @@ class BankingController extends BaseController
'umsaetze' => $umsaetze,
'kategorien' => Kategorie::baumFlach(null, true),
'klassen' => Klasse::alle(),
'konten' => Konto::alle(true),
'konten' => Konto::alle(),
'offen' => Bankumsatz::anzahlOffen(),
]);
}
@ -844,11 +844,12 @@ class BankingController extends BaseController
if ($buchungId > 0) {
Bankumsatz::verbinden($id, $buchungId);
} else {
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
Bankumsatz::uebernehmen($id, [
'konto_id' => $this->post('konto_id') !== '' ? (int)$this->post('konto_id') : null,
'kategorie_id' => $this->post('kategorie_id') !== '' ? (int)$this->post('kategorie_id') : null,
'kategorie_id' => $zuordnung['kategorie_id'],
'klasse_id' => $this->post('klasse_id') !== '' ? (int)$this->post('klasse_id') : null,
'gegenkonto_id' => $this->post('gegenkonto_id') !== '' ? (int)$this->post('gegenkonto_id') : null,
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'splits' => $this->splitsAusFormular(),
]);
}
@ -938,7 +939,7 @@ class BankingController extends BaseController
'regeln' => AbgleichRegel::alle(),
'kategorien' => Kategorie::baumFlach(null, true),
'klassen' => Klasse::alle(),
'konten' => Konto::alle(true),
'konten' => Konto::alle(),
]);
}
@ -1122,6 +1123,8 @@ class BankingController extends BaseController
/** @return array<string,mixed> */
private function regeldaten(): array
{
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
return [
'name' => $this->post('name'),
'feld' => $this->post('feld'),
@ -1129,9 +1132,9 @@ class BankingController extends BaseController
'muster' => $this->post('muster'),
'betrag_von' => $this->post('betrag_von'),
'betrag_bis' => $this->post('betrag_bis'),
'kategorie_id' => $this->post('kategorie_id'),
'kategorie_id' => $zuordnung['kategorie_id'],
'klasse_id' => $this->post('klasse_id'),
'gegenkonto_id' => $this->post('gegenkonto_id'),
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'splits' => $this->regelsplits(),
'prioritaet' => $this->post('prioritaet', '100'),
'aktiv' => isset($_POST['aktiv']) ? 1 : 0,
@ -1161,12 +1164,14 @@ class BankingController extends BaseController
}
$prozent = KontenController::zahl((string)($zeile['prozent'] ?? ''));
if (($zeile['kategorie_id'] ?? '') === '' || $prozent <= 0.0) {
$zuordnung = BuchungenController::zuordnungAusFormular($zeile);
if (($zuordnung['kategorie_id'] === null && $zuordnung['gegenkonto_id'] === null) || $prozent <= 0.0) {
continue;
}
$splits[] = [
'kategorie_id' => (int)$zeile['kategorie_id'],
'kategorie_id' => $zuordnung['kategorie_id'],
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'klasse_id' => ($zeile['klasse_id'] ?? '') === '' ? null : (int)$zeile['klasse_id'],
'anteil' => $prozent / 100,
'notiz' => (string)($zeile['notiz'] ?? ''),
@ -1280,8 +1285,10 @@ class BankingController extends BaseController
continue;
}
$zuordnung = BuchungenController::zuordnungAusFormular($zeile);
$splits[] = [
'kategorie_id' => ($zeile['kategorie_id'] ?? '') === '' ? null : (int)$zeile['kategorie_id'],
'kategorie_id' => $zuordnung['kategorie_id'],
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'klasse_id' => ($zeile['klasse_id'] ?? '') === '' ? null : (int)$zeile['klasse_id'],
'betrag' => (float)str_replace(',', '.', (string)$zeile['betrag']),
'notiz' => (string)($zeile['notiz'] ?? ''),

View file

@ -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'])
);
}
@ -392,6 +393,7 @@ class BuchungenController extends BaseController
'depot' => $depot,
'wertpapiere' => Wertpapier::alle(true),
'kategorien' => Kategorie::baumFlach(),
'klassen' => Klasse::alle(),
'vorschlag' => Wertpapier::ausText((string)$buchung['buchungstext'] . ' ' . (string)$buchung['empfaenger']),
]);
}
@ -464,6 +466,7 @@ class BuchungenController extends BaseController
'notiz' => $this->post('notiz'),
'kosten' => $kosten,
'kosten_sonstige_kategorie' => (int)$this->post('kosten_sonstige_kategorie') ?: null,
'klasse_id' => (int)$this->post('klasse_id') ?: null,
];
}
@ -612,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]];
@ -651,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()];
@ -692,6 +699,46 @@ class BuchungenController extends BaseController
return $splits;
}
/**
* Der umgekehrte Weg zu zuordnungAus(): der Auswahlwert für eine Zeile mit
* `kategorie_id` und `gegenkonto_id` – das Gegenkonto sticht, wie in der
* Buchungsmaske.
*
* @param array<string,mixed>|null $zeile
*/
public static function zuordnungswert(?array $zeile): string
{
if (!empty($zeile['gegenkonto_id'])) {
return 'konto:' . (int)$zeile['gegenkonto_id'];
}
if (!empty($zeile['kategorie_id'])) {
return 'kategorie:' . (int)$zeile['kategorie_id'];
}
return '';
}
/**
* Kategorie und Gegenkonto aus einem Formular, das entweder die gemeinsame
* Liste `zuordnung` schickt oder – etwa aus versteckten Feldern einer
* Tabellenzeile – die beiden Kennungen einzeln.
*
* @param array<string,mixed> $daten meist $_POST oder eine Splitzeile daraus
* @return array{kategorie_id: ?int, gegenkonto_id: ?int}
*/
public static function zuordnungAusFormular(array $daten): array
{
if (array_key_exists('zuordnung', $daten)) {
return self::zuordnungAus((string)$daten['zuordnung']);
}
$id = static fn(mixed $wert): ?int => is_scalar($wert) && (int)$wert > 0 ? (int)$wert : null;
return [
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
'gegenkonto_id' => $id($daten['gegenkonto_id'] ?? null),
];
}
/**
* Ein Auswahlwert aus der gemeinsamen Liste „Kategorie oder Gegenkonto“:
* `kategorie:12`, `konto:7` oder leer.

View file

@ -108,7 +108,7 @@ class ExportController extends BaseController
/**
* @return array{von: string, bis: string, konten: list<int>,
* kategorien: list<int>, klassen: list<int>}
* kategorien: list<int>, gegenkonten: list<int>, klassen: list<int>}
*/
private function filter(): array
{
@ -119,6 +119,7 @@ class ExportController extends BaseController
'bis' => $this->datum($this->text('bis')),
'konten' => $this->ids($eingabe['konten'] ?? null),
'kategorien' => $this->ids($eingabe['kategorien'] ?? null),
'gegenkonten' => $this->ids($eingabe['gegenkonten'] ?? null),
'klassen' => $this->ids($eingabe['klassen'] ?? null),
];
}

View file

@ -187,21 +187,26 @@ class TermineController extends BaseController
/** @return array<string,mixed> */
private function formulardaten(): array
{
// Der Dialog „Neue Reihe“ schickt die gemeinsame Liste, die Tabellenzeilen
// Kategorie und Gegenkonto als versteckte Felder.
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
return [
'name' => $this->post('name'),
'konto_id' => $this->post('konto_id'),
'betrag' => str_replace(',', '.', $this->post('betrag')),
'empfaenger' => $this->post('empfaenger'),
'buchungstext' => $this->post('buchungstext'),
'kategorie_id' => $this->post('kategorie_id'),
'kategorie_id' => $zuordnung['kategorie_id'],
'klasse_id' => $this->post('klasse_id'),
'gegenkonto_id' => $this->post('gegenkonto_id'),
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'intervall' => $this->post('intervall'),
'faktor' => $this->post('faktor', '1'),
'beginn' => $this->post('beginn'),
'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,
];

View file

@ -47,6 +47,7 @@ class VertraegeController extends BaseController
'fristen' => Vertrag::fristenLaufenAb(90),
'adressen' => Adresse::alle(),
'kategorien' => Kategorie::baumFlach(null, true),
'konten' => Konto::alle(true),
'reihen' => RegelmaessigeBuchung::alle(),
'intervalle' => RegelmaessigeBuchung::INTERVALLE,
]);
@ -281,12 +282,15 @@ class VertraegeController extends BaseController
/** @return array<string,mixed> */
private function vertragsdaten(): array
{
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
return [
'name' => $this->post('name'),
'partner' => $this->post('partner'),
'adresse_id' => $this->post('adresse_id'),
'vertragsnummer' => $this->post('vertragsnummer'),
'kategorie_id' => $this->post('kategorie_id'),
'kategorie_id' => $zuordnung['kategorie_id'],
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'betrag' => str_replace(',', '.', $this->post('betrag')),
'intervall' => $this->post('intervall'),
'beginn' => $this->post('beginn'),

View file

@ -6,7 +6,6 @@ namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Adresse;
use Finanzmanager\Models\Bankinstitut;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Erinnerung;
use Finanzmanager\Models\Kategorie;
@ -278,26 +277,6 @@ class VerwaltungController extends BaseController
View::redirect('/verwaltung/bargeld', $ergebnis['meldung'], $ergebnis['erfolg'] ? 'erfolg' : 'fehler');
}
/**
* Alle Buchungen, an denen die Bargeld-Tool-Prüfung zuletzt einen Fehler
* festgestellt hat – der Weg, den die Kachel „Offene Vorgänge“ des
* Cockpit anbietet, sobald es solche Buchungen gibt.
*/
public function bargeldFehler(): void
{
$this->verlangeAnmeldung();
$fehlt = ($_GET['status'] ?? '') === 'fehlt';
$this->render('verwaltung/bargeld_fehler.php', [
'titel' => $fehlt
? 'Bargeld-Tool: nicht gefundene Buchungen'
: 'Bargeld-Tool: fehlgeschlagene Prüfungen',
'fehlt' => $fehlt,
'buchungen' => Buchung::mitBargeldFehler($fehlt ? 'fehlt' : 'fehler'),
]);
}
/** Die gesamte Datenbank als Download. */
public function sicherungHerunterladen(): void
{

View file

@ -110,6 +110,8 @@ class VorlagenController extends BaseController
/** @return array<string,mixed> */
private function formulardaten(): array
{
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
return [
'name' => $this->post('name'),
'konto_id' => $this->post('konto_id'),
@ -117,9 +119,9 @@ class VorlagenController extends BaseController
'empfaenger' => $this->post('empfaenger'),
'buchungstext' => $this->post('buchungstext'),
'nummer' => $this->post('nummer'),
'kategorie_id' => $this->post('kategorie_id'),
'kategorie_id' => $zuordnung['kategorie_id'],
'klasse_id' => $this->post('klasse_id'),
'gegenkonto_id' => $this->post('gegenkonto_id'),
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
];
}
}

View file

@ -7,6 +7,7 @@ namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\Depot;
@ -117,6 +118,7 @@ class WertpapiereController extends BaseController
'depots' => Depot::depots(true),
'wertpapiere' => Wertpapier::alle(true),
'konten' => Konto::alle(true),
'klassen' => Klasse::alle(),
]);
}
@ -139,27 +141,32 @@ class WertpapiereController extends BaseController
]);
}
$stueck = $this->zahl($this->post('stueck'));
$kurs = $this->zahl($this->post('kurs'));
$betrag = $this->zahl($this->post('betrag'));
// Zwei von drei Angaben genügen: Der dritte Wert ergibt sich.
if ($betrag <= 0.0 && $stueck > 0.0 && $kurs > 0.0) {
$betrag = round($stueck * $kurs, 2);
}
$vorgang = $this->post('vorgang', 'kauf');
[
'stueck' => $stueck,
'kurs' => $kurs,
'betrag' => $betrag,
] = Depotbuchung::dritterWert(
$vorgang,
$this->zahl($this->post('stueck')),
$this->zahl($this->post('kurs')),
$this->zahl($this->post('betrag'))
);
Depotbuchung::anlegen([
'konto_id' => (int)$this->post('konto_id'),
'wertpapier_id' => $wertpapierId,
'datum' => $this->post('datum'),
'valuta' => $this->post('valuta'),
'vorgang' => $this->post('vorgang', 'kauf'),
'vorgang' => $vorgang,
'stueck' => $stueck,
'kurs' => $kurs > 0.0 ? $kurs : null,
'betrag' => $betrag,
'gebuehren' => $this->zahl($this->post('gebuehren')),
'steuern' => $this->zahl($this->post('steuern')),
'verrechnungskonto_id' => $this->post('verrechnungskonto_id'),
'klasse_id' => (int)$this->post('klasse_id') ?: null,
'notiz' => $this->post('notiz'),
'wertpapiername' => $this->post('wertpapier_name') ?: null,
]);

View file

@ -96,6 +96,7 @@ class ZahlungsverkehrController extends BaseController
'konten' => $this->kontenMitBank(),
'adressen' => Adresse::alle(),
'kategorien' => Kategorie::baumFlach(null, true),
'umbuchungskonten' => Konto::alle(),
'klassen' => Klasse::alle(),
]);
}
@ -158,6 +159,7 @@ class ZahlungsverkehrController extends BaseController
'konten' => $konten,
'adressen' => Adresse::alle(),
'kategorien' => Kategorie::baumFlach(null, true),
'umbuchungskonten' => Konto::alle(),
'klassen' => Klasse::alle(),
]);
}
@ -322,6 +324,7 @@ class ZahlungsverkehrController extends BaseController
private function formulardaten(): array
{
$kontoId = (int)$this->post('konto_id');
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
return [
'konto_id' => $kontoId,
@ -336,7 +339,8 @@ class ZahlungsverkehrController extends BaseController
'verwendungszweck' => $this->post('verwendungszweck'),
'ausfuehrung_am' => $this->post('ausfuehrung_am'),
'echtzeit' => isset($_POST['echtzeit']),
'kategorie_id' => $this->post('kategorie_id'),
'kategorie_id' => $zuordnung['kategorie_id'],
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'klasse_id' => $this->post('klasse_id'),
];
}

View file

@ -129,6 +129,7 @@ class AbgleichRegel
$splitzeilen = array_values(array_filter(
Buchung::splits($buchungId),
static fn(array $zeile): bool => (int)($zeile['kategorie_id'] ?? 0) !== 0
|| (int)($zeile['gegenkonto_id'] ?? 0) !== 0
));
// Anteile am Buchungsbetrag statt roher Beträge: So zeigt der Dialog
@ -142,7 +143,8 @@ class AbgleichRegel
$splits = [];
foreach ($splitzeilen as $zeile) {
$splits[] = [
'kategorie_id' => (int)$zeile['kategorie_id'],
'kategorie_id' => $zeile['kategorie_id'],
'gegenkonto_id' => $zeile['gegenkonto_id'] ?? null,
'klasse_id' => $zeile['klasse_id'],
'anteil' => $gesamt > 0.0 ? round(abs((float)$zeile['betrag']) / $gesamt, 4) : 0,
'notiz' => (string)($zeile['notiz'] ?? ''),
@ -256,6 +258,7 @@ class AbgleichRegel
$splits[] = [
'kategorie_id' => ($zeile['kategorie_id'] ?? null) ?: null,
'gegenkonto_id' => ($zeile['gegenkonto_id'] ?? null) ?: null,
'klasse_id' => ($zeile['klasse_id'] ?? null) ?: null,
'betrag' => $teil,
'notiz' => (string)($zeile['notiz'] ?? ''),
@ -293,6 +296,7 @@ class AbgleichRegel
$zeilen[] = [
'kategorie_id' => (int)($zeile['kategorie_id'] ?? 0) ?: null,
'gegenkonto_id' => (int)($zeile['gegenkonto_id'] ?? 0) ?: null,
'klasse_id' => (int)($zeile['klasse_id'] ?? 0) ?: null,
'prozent' => round((float)($zeile['anteil'] ?? 0) * 100, 2),
'notiz' => (string)($zeile['notiz'] ?? ''),
@ -418,8 +422,10 @@ class AbgleichRegel
}
/**
* Bringt ein Split-Schema in Form: je Zeile eine Kategorie und ein Anteil.
* Zeilen ohne Kategorie oder ohne Anteil fallen weg.
* Bringt ein Split-Schema in Form: je Zeile eine Kategorie oder ein
* Gegenkonto (Umbuchung) und ein Anteil. Zeilen ohne beides oder ohne
* Anteil fallen weg; steht beides da, sticht das Gegenkonto – wie in der
* gemeinsamen Auswahlliste der Buchungsmaske.
*
* Der Anteil wird nicht auf 1 gedeckelt: Er gilt im Verhältnis zu den
* übrigen Zeilen (siehe `anwenden()`), 100 Prozent sind also keine Grenze,
@ -435,14 +441,16 @@ class AbgleichRegel
continue;
}
$kategorie = (int)($zeile['kategorie_id'] ?? 0);
$gegenkonto = (int)($zeile['gegenkonto_id'] ?? 0);
$anteil = (float)($zeile['anteil'] ?? 0);
if ($kategorie === 0 || $anteil <= 0.0) {
if (($kategorie === 0 && $gegenkonto === 0) || $anteil <= 0.0) {
continue;
}
$zeilen[] = [
'kategorie_id' => $kategorie,
'kategorie_id' => $gegenkonto === 0 ? $kategorie : null,
'gegenkonto_id' => $gegenkonto === 0 ? null : $gegenkonto,
'klasse_id' => (int)($zeile['klasse_id'] ?? 0) ?: null,
'anteil' => round($anteil, 4),
'notiz' => mb_substr(trim((string)($zeile['notiz'] ?? '')), 0, 255),

View file

@ -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
@ -510,28 +511,6 @@ class Buchung
return $zeilen;
}
/**
* Alle Buchungen, an denen die Bargeld-Tool-Prüfung zuletzt einen Fehler
* ('fehler') oder keinen Treffer ('fehlt') ergeben hat – kontoübergreifend, denn die Prüfung hängt an der
* Kategorie der Buchung, nicht an ihrem Konto. Der Status 'fehler' steht
* für einen gescheiterten Abruf (siehe Services\BargeldAbgleich::FEHLER),
* 'fehlt' für eine im Bargeld-Tool nicht gefundene Buchung.
*
* @return list<array<string,mixed>>
*/
public static function mitBargeldFehler(string $status = 'fehler'): array
{
return Database::fetchAll(
'SELECT b.id, b.datum, b.betrag, b.empfaenger, b.buchungstext,
b.bargeld_hinweis, b.bargeld_geprueft_am, b.konto_id, k.name AS konto_name
FROM buchungen b
JOIN konten k ON k.id = b.konto_id
WHERE b.benutzer_id = ? AND b.bargeld_status = ?
ORDER BY b.datum DESC, b.id DESC',
[Mandant::id(), $status === 'fehlt' ? 'fehlt' : 'fehler']
);
}
/**
* Salden aller Konten in einem Rutsch: Anfangssaldo plus Buchungen, plus die
* Gegenseiten fremder Umbuchungen. Ohne Stichtag zählt alles (erwarteter
@ -828,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,

View file

@ -22,10 +22,11 @@ class Buchungsvorlage
public static function alle(string $suche = ''): array
{
$params = [Mandant::id()];
$sql = 'SELECT v.*, k.name AS kontoname, kat.pfad AS kategorie_pfad
$sql = 'SELECT v.*, k.name AS kontoname, kat.pfad AS kategorie_pfad, g.name AS gegenkontoname
FROM buchungsvorlagen v
LEFT JOIN konten k ON k.id = v.konto_id
LEFT JOIN kategorien kat ON kat.id = v.kategorie_id
LEFT JOIN konten g ON g.id = v.gegenkonto_id
WHERE v.benutzer_id = ?';
if ($suche !== '') {

View file

@ -69,6 +69,32 @@ class Depotbuchung
return self::QIF_VORGAENGE[trim($vorgang)] ?? 'kauf';
}
/**
* Zwei von drei Angaben genügen: Betrag = Stück × Kurs.
*
* Die Maske „Depotbuchung erfassen“ rechnet den dritten Wert schon im
* Browser aus (`depotbuchung.js`); hier steht dieselbe Regel für den Weg
* ohne JavaScript. Der Betrag ergibt sich bei jedem Vorgang, Kurs und Stück
* nur bei Kauf und Verkauf – nur dort hängen die drei sicher zusammen.
* Eine fehlende Angabe ist 0.
*
* @return array{stueck:float,kurs:float,betrag:float}
*/
public static function dritterWert(string $vorgang, float $stueck, float $kurs, float $betrag): array
{
if ($betrag <= 0.0 && $stueck > 0.0 && $kurs > 0.0) {
$betrag = round($stueck * $kurs, 2);
} elseif (in_array($vorgang, ['kauf', 'verkauf'], true) && $betrag > 0.0) {
if ($kurs <= 0.0 && $stueck > 0.0) {
$kurs = round($betrag / $stueck, 6);
} elseif ($stueck <= 0.0 && $kurs > 0.0) {
$stueck = round($betrag / $kurs, 6);
}
}
return ['stueck' => $stueck, 'kurs' => $kurs, 'betrag' => $betrag];
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
@ -172,7 +198,14 @@ class Depotbuchung
{
$f = self::normalisiere($daten);
return (int)Database::transaktion(static function () use ($f, $daten): int {
// Die Klasse gilt der Geldbuchung – sie ist die Seite, die in den
// Auswertungen nach Klassen auftaucht.
$klasseId = (int)($daten['klasse_id'] ?? 0) ?: null;
if ($klasseId !== null && Klasse::find($klasseId) === null) {
throw new \InvalidArgumentException('Die Klasse gibt es nicht.');
}
return (int)Database::transaktion(static function () use ($f, $daten, $klasseId): int {
$buchungId = $f['buchung_id'];
$erzeugt = 0;
@ -186,6 +219,7 @@ class Depotbuchung
'empfaenger' => mb_substr((string)($daten['wertpapiername'] ?? 'Wertpapiergeschäft'), 0, 255),
'buchungstext' => (string)$f['notiz'],
'kategorie_id' => ($daten['kategorie_id'] ?? null) ?: null,
'klasse_id' => $klasseId,
'quelle' => (string)$f['quelle'],
'abgeglichen' => !empty($daten['abgeglichen']) ? 1 : 0,
// Eigener Hash für die Geldseite: Er erkennt Buchungen

View file

@ -71,6 +71,45 @@ class Konto
);
}
/**
* Die Umbuchungskonten, wie sie unter den Kategorien jeder Auswahlliste
* stehen (#124): nur aktive Konten, je Kontengruppe beschriftet und in der
* Reihenfolge von alle(). Ein inaktives Konto erscheint nur, wenn es schon
* gewählt ist – sonst ginge eine bestehende Zuordnung beim Speichern
* verloren.
*
* @param list<array<string,mixed>> $konten meist alle()
* @param list<int> $gewaehlt Kennungen, die immer bleiben
* @return array<string, list<array<string,mixed>>> Beschriftung => Konten
*/
public static function umbuchungsgruppen(array $konten, array $gewaehlt = []): array
{
$gewaehlt = array_flip(array_map('intval', $gewaehlt));
$gruppen = [];
$mitGruppen = false;
foreach ($konten as $konto) {
if ((int)($konto['aktiv'] ?? 1) !== 1 && !isset($gewaehlt[(int)$konto['id']])) {
continue;
}
$gruppe = $konto['gruppenname'] ?? null;
$mitGruppen = $mitGruppen || $gruppe !== null;
$gruppen[(string)$gruppe][] = $konto;
}
// Ohne eine einzige Kontengruppe reicht eine Überschrift.
if (!$mitGruppen) {
return $gruppen === [] ? [] : ['Umbuchungskonten' => $gruppen['']];
}
$beschriftet = [];
foreach ($gruppen as $gruppe => $eintraege) {
$beschriftet['Umbuchungskonten – ' . ($gruppe === '' ? 'nicht gruppiert' : $gruppe)] = $eintraege;
}
return $beschriftet;
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(

View file

@ -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,
];

View file

@ -50,12 +50,12 @@ class Vertrag
Database::execute(
'INSERT INTO vertraege
(benutzer_id, name, partner, adresse_id, vertragsnummer, kategorie_id, betrag,
intervall, beginn, ende, kuendigungsfrist_tage, regelmaessige_id, notiz, aktiv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
(benutzer_id, name, partner, adresse_id, vertragsnummer, kategorie_id, gegenkonto_id,
betrag, intervall, beginn, ende, kuendigungsfrist_tage, regelmaessige_id, notiz, aktiv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $f['name'], $f['partner'], $f['adresse_id'], $f['vertragsnummer'],
$f['kategorie_id'], $f['betrag'], $f['intervall'], $f['beginn'], $f['ende'],
$f['kategorie_id'], $f['gegenkonto_id'], $f['betrag'], $f['intervall'], $f['beginn'], $f['ende'],
$f['kuendigungsfrist_tage'], $f['regelmaessige_id'], $f['notiz'], $f['aktiv'],
]
);
@ -70,12 +70,12 @@ class Vertrag
Database::execute(
'UPDATE vertraege SET name = ?, partner = ?, adresse_id = ?, vertragsnummer = ?,
kategorie_id = ?, betrag = ?, intervall = ?, beginn = ?, ende = ?,
kategorie_id = ?, gegenkonto_id = ?, betrag = ?, intervall = ?, beginn = ?, ende = ?,
kuendigungsfrist_tage = ?, regelmaessige_id = ?, notiz = ?, aktiv = ?
WHERE id = ? AND benutzer_id = ?',
[
$f['name'], $f['partner'], $f['adresse_id'], $f['vertragsnummer'], $f['kategorie_id'],
$f['betrag'], $f['intervall'], $f['beginn'], $f['ende'], $f['kuendigungsfrist_tage'],
$f['gegenkonto_id'], $f['betrag'], $f['intervall'], $f['beginn'], $f['ende'], $f['kuendigungsfrist_tage'],
$f['regelmaessige_id'], $f['notiz'], $f['aktiv'], $id, Mandant::id(),
]
);
@ -172,7 +172,9 @@ class Vertrag
'partner' => mb_substr(trim((string)($daten['partner'] ?? '')), 0, 255),
'adresse_id' => $id($daten['adresse_id'] ?? null),
'vertragsnummer' => mb_substr(trim((string)($daten['vertragsnummer'] ?? '')), 0, 64),
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
// Kategorie oder Umbuchungskonto (etwa ein Bausparvertrag) – nie beides.
'kategorie_id' => $id($daten['gegenkonto_id'] ?? null) === null ? $id($daten['kategorie_id'] ?? null) : null,
'gegenkonto_id' => $id($daten['gegenkonto_id'] ?? null),
'betrag' => round((float)($daten['betrag'] ?? 0), 2),
'intervall' => array_key_exists($intervall, RegelmaessigeBuchung::INTERVALLE)
? $intervall : 'monatlich',

View file

@ -30,11 +30,12 @@ class Zahlungsauftrag
public static function alle(string $status = ''): array
{
$params = [Mandant::id()];
$sql = 'SELECT z.*, k.name AS kontoname, kat.pfad AS kategorie_pfad,
$sql = 'SELECT z.*, k.name AS kontoname, kat.pfad AS kategorie_pfad, g.name AS gegenkontoname,
(SELECT COUNT(*) FROM bank_protokolle p WHERE p.auftrag_id = z.id) AS protokolle
FROM zahlungsauftraege z
JOIN konten k ON k.id = z.konto_id
LEFT JOIN kategorien kat ON kat.id = z.kategorie_id
LEFT JOIN konten g ON g.id = z.gegenkonto_id
WHERE z.benutzer_id = ?';
if ($status !== '' && array_key_exists($status, self::STATUS)) {
@ -66,12 +67,13 @@ class Zahlungsauftrag
Database::execute(
'INSERT INTO zahlungsauftraege
(benutzer_id, konto_id, zugang_id, empfaenger, iban, bic, betrag, waehrung,
verwendungszweck, ausfuehrung_am, echtzeit, kategorie_id, klasse_id, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
verwendungszweck, ausfuehrung_am, echtzeit, kategorie_id, gegenkonto_id, klasse_id, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $f['konto_id'], $f['zugang_id'], $f['empfaenger'], $f['iban'],
$f['bic'], $f['betrag'], $f['waehrung'], $f['verwendungszweck'],
$f['ausfuehrung_am'], $f['echtzeit'], $f['kategorie_id'], $f['klasse_id'], 'entwurf',
$f['ausfuehrung_am'], $f['echtzeit'], $f['kategorie_id'], $f['gegenkonto_id'],
$f['klasse_id'], 'entwurf',
]
);
@ -94,13 +96,13 @@ class Zahlungsauftrag
Database::execute(
'UPDATE zahlungsauftraege SET konto_id = ?, zugang_id = ?, empfaenger = ?, iban = ?,
bic = ?, betrag = ?, waehrung = ?, verwendungszweck = ?, ausfuehrung_am = ?,
echtzeit = ?, kategorie_id = ?, klasse_id = ?, status = \'entwurf\', meldung = NULL,
echtzeit = ?, kategorie_id = ?, gegenkonto_id = ?, klasse_id = ?, status = \'entwurf\', meldung = NULL,
bank_auftrag_id = NULL, echtzeit_status = NULL, status_abfrage_ab = NULL
WHERE id = ? AND benutzer_id = ?',
[
$f['konto_id'], $f['zugang_id'], $f['empfaenger'], $f['iban'], $f['bic'],
$f['betrag'], $f['waehrung'], $f['verwendungszweck'], $f['ausfuehrung_am'],
$f['echtzeit'], $f['kategorie_id'], $f['klasse_id'], $id, Mandant::id(),
$f['echtzeit'], $f['kategorie_id'], $f['gegenkonto_id'], $f['klasse_id'], $id, Mandant::id(),
]
);
}
@ -154,6 +156,7 @@ class Zahlungsauftrag
'empfaenger' => (string)$auftrag['empfaenger'],
'buchungstext' => (string)$auftrag['verwendungszweck'],
'kategorie_id' => $auftrag['kategorie_id'],
'gegenkonto_id' => $auftrag['gegenkonto_id'] ?? null,
'klasse_id' => $auftrag['klasse_id'],
'quelle' => 'fints',
]);
@ -380,6 +383,16 @@ class Zahlungsauftrag
$id = static fn(mixed $wert): ?int =>
($wert === null || $wert === '' || (int)$wert === 0) ? null : (int)$wert;
// Erst beim Senden entsteht die Buchung – ein Gegenkonto, das sie
// ablehnen würde, fällt besser schon beim Erfassen auf.
$gegenkonto = $id($daten['gegenkonto_id'] ?? null);
if ($gegenkonto !== null && $gegenkonto === $kontoId) {
throw new \InvalidArgumentException('Eine Umbuchung braucht zwei verschiedene Konten.');
}
if ($gegenkonto !== null && Konto::find($gegenkonto) === null) {
throw new \InvalidArgumentException('Das Gegenkonto gibt es nicht.');
}
return [
'konto_id' => $kontoId,
'zugang_id' => $id($daten['zugang_id'] ?? null),
@ -393,7 +406,9 @@ class Zahlungsauftrag
'verwendungszweck' => mb_substr(trim((string)($daten['verwendungszweck'] ?? '')), 0, 140),
'ausfuehrung_am' => $termin !== '' && !$echtzeit ? $termin : null,
'echtzeit' => $echtzeit ? 1 : 0,
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
// Kategorie oder Gegenkonto – nie beides, wie bei der Buchung.
'kategorie_id' => $gegenkonto === null ? $id($daten['kategorie_id'] ?? null) : null,
'gegenkonto_id' => $gegenkonto,
'klasse_id' => $id($daten['klasse_id'] ?? null),
];
}

View file

@ -56,6 +56,9 @@ class Auswertung
/** Zulässige Werte des Bargeld-Filters; 'keiner' = nicht abgeglichen. */
public const BARGELD_FILTER = ['gefunden', 'fehlt', 'fehler', 'keiner'];
/** Zulässige Werte des Abgleich-Filters im Buchungsbericht (#137). */
public const ABGEGLICHEN_FILTER = ['ja', 'nein'];
/** Wonach die Zeilen zusammengefasst werden. */
public const ZEILENSUMMEN = [
'kategorie' => 'Kategorie',
@ -115,12 +118,22 @@ class Auswertung
'umbuchungen' => self::bool($roh['umbuchungen'] ?? false),
'konten' => self::ids($roh['konten'] ?? []),
'kategorien' => self::ids($roh['kategorien'] ?? []),
// Umbuchungskonten stehen im Buchungsbericht mit in der
// Kategorieauswahl (`konto:7`, #124) – nur dort werden Umbuchungen
// überhaupt aufgeführt.
'gegenkonten' => $typ === 'buchungsbericht'
? self::gegenkonten($roh['gegenkonten'] ?? [], $roh['kategorien'] ?? [])
: [],
'klassen' => self::ids($roh['klassen'] ?? []),
'suche' => mb_substr(trim((string)($roh['suche'] ?? '')), 0, 120),
// Nur der Buchungsbericht führt die Abgleichspalte und filtert danach.
'bargeld' => $typ === 'buchungsbericht' && in_array($roh['bargeld'] ?? '', self::BARGELD_FILTER, true)
? (string)$roh['bargeld']
: '',
// Ebenso nur dort: der Haken „abgeglichen“ der Buchung (#137).
'abgeglichen' => $typ === 'buchungsbericht' && in_array($roh['abgeglichen'] ?? '', self::ABGEGLICHEN_FILTER, true)
? (string)$roh['abgeglichen']
: '',
];
}
@ -835,6 +848,8 @@ 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']),
'abgeglichen' => !empty($zeile['abgeglichen']),
];
}
@ -1187,13 +1202,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, b.abgeglichen
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, b.abgeglichen
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 ?
@ -1203,8 +1220,16 @@ class Auswertung
// Umbuchungen sind Vermögensverschiebungen, keine Einnahmen oder
// Ausgaben – nur der Buchungsbericht darf sie auf Wunsch mitführen.
// Ein ausdrücklich gewähltes Umbuchungskonto holt seine Umbuchungen
// auch ohne diesen Schalter herein.
$gegenkonten = $filter['gegenkonten'] ?? [];
if (empty($filter['umbuchungen'])) {
if ($gegenkonten === []) {
$wo[] = 'gegenkonto_id IS NULL';
} else {
$wo[] = '(gegenkonto_id IS NULL OR gegenkonto_id IN (' . self::platzhalter($gegenkonten) . '))';
$params = array_merge($params, $gegenkonten);
}
}
if (!empty($filter['konten'])) {
@ -1212,11 +1237,21 @@ class Auswertung
$params = array_merge($params, $filter['konten']);
}
// Kategorien und Umbuchungskonten stammen aus derselben Auswahl: Eine
// Bewegung passt, wenn sie zu einem der beiden gehört.
$zuordnung = [];
if (!empty($filter['kategorien'])) {
$ids = self::mitNachfahren($filter['kategorien']);
$wo[] = 'kategorie_id IN (' . self::platzhalter($ids) . ')';
$zuordnung[] = 'kategorie_id IN (' . self::platzhalter($ids) . ')';
$params = array_merge($params, $ids);
}
if ($gegenkonten !== []) {
$zuordnung[] = 'gegenkonto_id IN (' . self::platzhalter($gegenkonten) . ')';
$params = array_merge($params, $gegenkonten);
}
if ($zuordnung !== []) {
$wo[] = '(' . implode(' OR ', $zuordnung) . ')';
}
if (!empty($filter['klassen'])) {
$wo[] = 'klasse_id IN (' . self::platzhalter($filter['klassen']) . ')';
@ -1231,6 +1266,11 @@ class Auswertung
$params[] = $bargeld;
}
$abgeglichen = (string)($filter['abgeglichen'] ?? '');
if ($abgeglichen !== '') {
$wo[] = $abgeglichen === 'ja' ? 'abgeglichen = 1' : 'abgeglichen = 0';
}
if (($filter['suche'] ?? '') !== '') {
$wo[] = '(empfaenger LIKE ? OR text LIKE ?)';
$muster = '%' . $filter['suche'] . '%';
@ -1443,6 +1483,24 @@ class Auswertung
return implode(', ', array_fill(0, count($werte), '?'));
}
/**
* Die gewählten Umbuchungskonten: als eigene Liste (aus der Adresse oder
* einem gespeicherten Bericht) oder als `konto:7` aus der Kategorieauswahl.
*
* @return list<int>
*/
private static function gegenkonten(mixed $liste, mixed $auswahl): array
{
$ids = self::ids($liste);
foreach (is_array($auswahl) ? $auswahl : [] as $eintrag) {
if (is_string($eintrag) && preg_match('/^konto:(\d+)$/', $eintrag, $treffer) === 1 && (int)$treffer[1] > 0) {
$ids[] = (int)$treffer[1];
}
}
return array_values(array_unique($ids));
}
/** @return list<int> */
private static function ids(mixed $wert): array
{

View file

@ -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']
);

View file

@ -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<string,mixed> $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;
}

View file

@ -79,7 +79,7 @@ class BuchungsExport
/**
* Die gefilterten Buchungen samt Splitzeilen und Adressen.
*
* @param array<string,mixed> $filter von, bis, konten[], kategorien[], klassen[]
* @param array<string,mixed> $filter von, bis, konten[], kategorien[], gegenkonten[], klassen[]
* @return list<array<string,mixed>>
*/
public static function buchungen(array $filter): array
@ -151,18 +151,30 @@ class BuchungsExport
// Kategorie und Klasse können in der Buchung oder in einer ihrer
// Splitzeilen stehen – beides zählt, sonst fiele jede Splitbuchung aus
// dem Filter, obwohl sie die gesuchte Kategorie führt.
foreach ([['kategorien', 'kategorie_id'], ['klassen', 'klasse_id']] as [$schluessel, $spalte]) {
// dem Filter, obwohl sie die gesuchte Kategorie führt. Kategorien und
// Umbuchungskonten stammen aus derselben Auswahl: Eine Buchung passt,
// wenn sie zu einem von beiden gehört.
$gruppen = [
[['kategorien', 'kategorie_id'], ['gegenkonten', 'gegenkonto_id']],
[['klassen', 'klasse_id']],
];
foreach ($gruppen as $gruppe) {
$oder = [];
foreach ($gruppe as [$schluessel, $spalte]) {
$ids = self::ids($filter[$schluessel] ?? []);
if ($ids === []) {
continue;
}
$platzhalter = self::platzhalter($ids);
$wo[] = '(b.' . $spalte . ' IN (' . $platzhalter . ')
$oder[] = 'b.' . $spalte . ' IN (' . $platzhalter . ')
OR EXISTS (SELECT 1 FROM buchung_splits s WHERE s.buchung_id = b.id
AND s.' . $spalte . ' IN (' . $platzhalter . ')))';
AND s.' . $spalte . ' IN (' . $platzhalter . '))';
$params = array_merge($params, $ids, $ids);
}
if ($oder !== []) {
$wo[] = '(' . implode(' OR ', $oder) . ')';
}
}
return [implode(' AND ', $wo), $params];
}

View file

@ -261,19 +261,19 @@ class Cockpit
];
}
$nichtAbgeglichen = (int)Database::fetchValue(
'SELECT COUNT(*) FROM buchungen b
$stichtag = $heute ?? date('Y-m-d');
$nichtAbgeglichen = Database::fetchOne(
'SELECT COUNT(*) AS anzahl, MIN(b.datum) AS erste FROM buchungen b
JOIN konten k ON k.id = b.konto_id
WHERE b.benutzer_id = ? AND b.abgeglichen = 0 AND k.aktiv = 1 AND b.datum <= ?',
[$benutzer, $heute ?? date('Y-m-d')],
0
[$benutzer, $stichtag]
);
if ($nichtAbgeglichen > 0) {
if ((int)($nichtAbgeglichen['anzahl'] ?? 0) > 0) {
$vorgaenge[] = [
'schluessel' => 'nicht_abgeglichen',
'text' => 'nicht abgeglichen',
'anzahl' => $nichtAbgeglichen,
'pfad' => '/konten',
'anzahl' => (int)$nichtAbgeglichen['anzahl'],
'pfad' => self::nichtAbgeglichenBericht((string)$nichtAbgeglichen['erste'], $stichtag),
];
}
@ -301,7 +301,7 @@ class Cockpit
'schluessel' => 'bargeld_fehler',
'text' => 'Bargeld-Tool-Prüfung fehlgeschlagen',
'anzahl' => $bargeldFehler,
'pfad' => '/verwaltung/bargeld/fehler',
'pfad' => self::bargeldBericht(BargeldAbgleich::FEHLER),
];
}
@ -317,7 +317,7 @@ class Cockpit
'schluessel' => 'bargeld_fehlt',
'text' => 'im Bargeld-Tool nicht gefunden',
'anzahl' => $bargeldFehlt,
'pfad' => '/verwaltung/bargeld/fehler?status=fehlt',
'pfad' => self::bargeldBericht(BargeldAbgleich::FEHLT),
];
}
@ -370,4 +370,56 @@ class Cockpit
return $vorgaenge;
}
/**
* Der Buchungsbericht über den gesamten Zeitraum, gefiltert auf einen
* Status des Bargeld-Tools – mit Kategorie bzw. Gegenkonto in eckigen
* Klammern (#131). Umbuchungen zählen mit, damit die Liste genau die
* Buchungen zeigt, die der offene Vorgang zählt.
*/
private static function bargeldBericht(string $status): string
{
return '/auswertungen/bericht?' . http_build_query([
'typ' => 'buchungsbericht',
'zeitraum' => 'alles',
'umbuchungen' => '1',
'bargeld' => $status,
'angepasst' => '1',
]);
}
/**
* Der Buchungsbericht, gefiltert auf nicht abgeglichene Buchungen (#137) –
* mit denselben Grenzen wie der offene Vorgang: von der ersten betroffenen
* Buchung bis heute, Umbuchungen eingeschlossen. Liegen auch auf
* aufgelösten Konten nicht abgeglichene Buchungen, beschränkt sich der
* Bericht auf die aktiven Konten, denn nur die zählt der Vorgang.
*/
private static function nichtAbgeglichenBericht(string $erste, string $stichtag): string
{
$parameter = [
'typ' => 'buchungsbericht',
'zeitraum' => 'benutzerdefiniert',
'von' => $erste,
'bis' => $stichtag,
'umbuchungen' => '1',
'abgeglichen' => 'nein',
];
$inaktiv = (int)Database::fetchValue(
'SELECT COUNT(*) FROM buchungen b
JOIN konten k ON k.id = b.konto_id
WHERE b.benutzer_id = ? AND b.abgeglichen = 0 AND k.aktiv = 0 AND b.datum BETWEEN ? AND ?',
[Mandant::id(), $erste, $stichtag],
0
);
if ($inaktiv > 0) {
$parameter['konten'] = array_map('intval', array_column(Database::fetchAll(
'SELECT id FROM konten WHERE benutzer_id = ? AND aktiv = 1 ORDER BY id',
[Mandant::id()]
), 'id'));
}
return '/auswertungen/bericht?' . http_build_query($parameter + ['angepasst' => '1']);
}
}

View file

@ -7,6 +7,7 @@ namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
@ -35,6 +36,10 @@ use Finanzmanager\Models\Wertpapier;
* Kategorie, der Rest als Umbuchung ins Depot. Ohne Nebenkosten bleibt es bei
* der schlichten Umbuchung – eine Splitbuchung mit einer einzigen Zeile wäre nur
* umständlicher zu lesen.
*
* **Die Klasse gilt für das ganze Geschäft.** Sie steht an der Buchung und an
* jeder ihrer Splitzeilen – sonst zeigte das Kontoblatt eine Splitbuchung mit
* gemischten Klassen, und die Auswertung fände die Nebenkosten ohne Klasse.
*/
class Wertpapiergeschaeft
{
@ -84,7 +89,7 @@ class Wertpapiergeschaeft
* @param array<string,mixed> $buchung die Buchung des Verrechnungskontos
* @param array<string,mixed> $eingaben depot_id, wertpapier_id, wertpapier_name, isin,
* wkn, art, vorgang, datum, valuta, stueck, kurs,
* notiz, kosten, kosten_sonstige_kategorie
* notiz, kosten, kosten_sonstige_kategorie, klasse_id
* @return array{depotbuchung_id:int,vorgang:string,betrag:float,gesplittet:bool}
*/
public static function buchen(array $buchung, array $eingaben): array
@ -132,8 +137,17 @@ class Wertpapiergeschaeft
$kurs = round(abs($kurswert) / $stueck, 6);
}
$klasseId = (int)($eingaben['klasse_id'] ?? 0) ?: null;
if (!array_key_exists('klasse_id', $eingaben)) {
// Splitzeilen erben dann die Klasse, die schon an der Buchung steht.
$klasseId = ($buchung['klasse_id'] ?? null) !== null ? (int)$buchung['klasse_id'] : null;
}
if ($klasseId !== null && Klasse::find($klasseId) === null) {
throw new \InvalidArgumentException('Die Klasse gibt es nicht.');
}
return Database::transaktion(static function () use (
$buchung, $depot, $eingaben, $aufteilung, $kurswert, $vorgang, $stueck, $kurs
$buchung, $depot, $eingaben, $aufteilung, $kurswert, $vorgang, $stueck, $kurs, $klasseId
): array {
$wertpapierId = (int)($eingaben['wertpapier_id'] ?? 0);
$name = trim((string)($eingaben['wertpapier_name'] ?? ''));
@ -178,12 +192,14 @@ class Wertpapiergeschaeft
} else {
$splits = [[
'gegenkonto_id' => (int)$depot['id'],
'klasse_id' => $klasseId,
'betrag' => $kurswert,
'notiz' => $bezeichnung,
]];
foreach ($aufteilung['kosten'] as $zeile) {
$splits[] = [
'kategorie_id' => $zeile['kategorie_id'],
'klasse_id' => $klasseId,
// Nebenkosten sind Aufwand – beim Kauf wie beim Verkauf.
'betrag' => -$zeile['betrag'],
'notiz' => $zeile['name'],
@ -193,6 +209,13 @@ class Wertpapiergeschaeft
Buchung::splitsErsetzen((int)$buchung['id'], $splits);
}
// Die Auswahl der Maske gilt, auch wenn sie leer ist: Sie war mit
// der bisherigen Klasse der Buchung vorbelegt. Wer gar keine
// Klasse mitgibt, lässt die vorhandene stehen.
if (array_key_exists('klasse_id', $eingaben)) {
Buchung::klasseSetzen((int)$buchung['id'], $klasseId);
}
return [
'depotbuchung_id' => $depotbuchungId,
'vorgang' => $vorgang,

View file

@ -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 '<span class="bargeldmarke bargeld-' . $klasse . '" title="'

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Models\Konto;
use Finanzmanager\View;
/** @var array<string,mixed> $bericht */
@ -174,6 +175,18 @@ include __DIR__ . '/../layout/kopf.php';
</label>
<?php endif; ?>
<?php if ($bericht['typ'] === 'buchungsbericht'): ?>
<label>Abgeglichen
<select name="abgeglichen">
<?php foreach (['' => 'alle', 'ja' => 'abgeglichen', 'nein' => 'nicht abgeglichen'] as $wert => $text): ?>
<option value="<?= View::h($wert) ?>" <?= $filter['abgeglichen'] === $wert ? 'selected' : '' ?>>
<?= View::h($text) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<?php endif; ?>
<?php if ($bericht['typ'] === 'buchungsbericht' && ($bericht['bargeld'] ?? false)): ?>
<label>Bargeld-Tool
<select name="bargeld">
@ -195,7 +208,7 @@ include __DIR__ . '/../layout/kopf.php';
<button type="submit" class="knopf primaer">Anzeigen</button>
</div>
<details class="einschraenkung" <?= $filter['konten'] || $filter['kategorien'] || $filter['klassen'] ? 'open' : '' ?>>
<details class="einschraenkung" <?= $filter['konten'] || $filter['kategorien'] || $filter['gegenkonten'] || $filter['klassen'] ? 'open' : '' ?>>
<summary>Einschränken auf Konten, Kategorien und Klassen</summary>
<div class="filterzeile">
<label>Konten
@ -217,6 +230,20 @@ include __DIR__ . '/../layout/kopf.php';
<?= View::h(str_repeat(' ', (int)$kategorie['ebene']) . $kategorie['name']) ?>
</option>
<?php endforeach; ?>
<?php /* Nur der Buchungsbericht führt Umbuchungen – dort stehen
die Umbuchungskonten unter den Kategorien (#124). */ ?>
<?php if ($bericht['typ'] === 'buchungsbericht'): ?>
<?php foreach (Konto::umbuchungsgruppen($konten, $filter['gegenkonten']) as $kontengruppe => $gruppenkonten): ?>
<optgroup label="<?= View::h($kontengruppe) ?>">
<?php foreach ($gruppenkonten as $umbuchungskonto): ?>
<option value="konto:<?= (int)$umbuchungskonto['id'] ?>"
<?= in_array((int)$umbuchungskonto['id'], $filter['gegenkonten'], true) ? 'selected' : '' ?>>
[<?= View::h($umbuchungskonto['name']) ?>]
</option>
<?php endforeach; ?>
</optgroup>
<?php endforeach; ?>
<?php endif; ?>
</select>
</label>
@ -234,6 +261,10 @@ include __DIR__ . '/../layout/kopf.php';
<p class="hinweis klein">
Eine gewählte Kategorie schließt ihre Unterkategorien ein. Mehrfachauswahl mit
gedrückter Strg-Taste; nichts ausgewählt heißt alles.
<?php if ($bericht['typ'] === 'buchungsbericht'): ?>
Ein Umbuchungskonto in eckigen Klammern zeigt die Umbuchungen auf dieses Konto,
auch ohne „Umbuchungen mitzählen“.
<?php endif; ?>
</p>
</details>
</form>

View file

@ -10,7 +10,7 @@ use Finanzmanager\View;
*/
?>
<?php $spalten = $bericht['bargeld'] ? 8 : 7; ?>
<?php $spalten = $bericht['bargeld'] ? 9 : 8; ?>
<table class="liste bericht buchungsgruppen">
<thead>
<tr>
@ -20,6 +20,7 @@ use Finanzmanager\View;
<th>Buchungstext</th>
<th>Kategorie</th>
<th>Klasse</th>
<th class="abgleichspalte" title="abgeglichen">✓</th>
<?php if ($bericht['bargeld']): ?>
<th class="bargeldspalte" title="Abgleich mit dem Bargeld-Tool">Bar</th>
<?php endif; ?>
@ -52,8 +53,9 @@ use Finanzmanager\View;
<?php endif; ?>
</td>
<td><?= View::h($buchung['klasse']) ?></td>
<td class="abgleichspalte nebentext"><?= $buchung['abgeglichen'] ? '✓' : '' ?></td>
<?php if ($bericht['bargeld']): ?>
<td class="bargeldspalte"><?= View::bargeldMarke($buchung['bargeld_status'], $buchung['bargeld_hinweis']) ?></td>
<td class="bargeldspalte"><?= View::bargeldMarke($buchung['bargeld_status'], $buchung['bargeld_hinweis'], !empty($buchung['ohne_bargeld_abgleich'])) ?></td>
<?php endif; ?>
<td class="rechts <?= View::betragKlasse($buchung['betrag']) ?>">
<?= View::betrag($buchung['betrag'], false) ?>
@ -89,4 +91,17 @@ use Finanzmanager\View;
dem Summenbericht überein.
<?php endif; ?>
Der Empfänger führt in die Buchungsmaske.
<?php if ($bericht['filter']['abgeglichen'] === 'nein'): ?>
Den Haken „abgeglichen“ setzt die Buchungsmaske oder der Abgleich im Kontoblatt.
<?php endif; ?>
<?php if (in_array($bericht['filter']['bargeld'], ['fehlt', 'fehler'], true)): ?>
<?php if ($bericht['filter']['bargeld'] === 'fehlt'): ?>
Zu diesen Buchungen wurde im Bargeld-Tool keine Ausgabe mit demselben Betrag gefunden.
<?php else: ?>
Bei diesen Buchungen ist der letzte Abruf beim Bargeld-Tool gescheitert – ein
Abrufproblem, keine Aussage darüber, ob die Buchung dort steht.
<?php endif; ?>
Die Meldung steht am Symbol in der Spalte „Bar“; eine erneute Zuordnung der Kategorie
in der Buchungsmaske oder im Kontoblatt stößt die Prüfung erneut an.
<?php endif; ?>
</p>

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\View;
@ -11,7 +12,7 @@ use Finanzmanager\View;
* @var array<string,mixed> $regel Vorbelegung; leeres Array für eine neue Regel
* @var list<array<string,mixed>> $kategorien
* @var list<array<string,mixed>> $klassen
* @var list<array<string,mixed>> $konten
* @var list<array<string,mixed>> $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.
</p>
<label for="<?= $id('kategorie_id') ?>">Kategorie</label>
<select id="<?= $id('kategorie_id') ?>" name="kategorie_id">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= (int)($regel['kategorie_id'] ?? 0) === (int)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
<label for="<?= $id('zuordnung') ?>"
title="Unter den Kategorien stehen die aktiven Umbuchungskonten in eckigen Klammern. Eines davon macht aus der Buchung eine Umbuchung.">Kategorie / Gegenkonto</label>
<select id="<?= $id('zuordnung') ?>" name="zuordnung">
<?php $gewaehlt = BuchungenController::zuordnungswert($regel); ?>
<?php $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
</select>
<label for="<?= $id('klasse_id') ?>">Klasse</label>
@ -88,17 +86,6 @@ $betragsfeld = static function (?string $feld) use ($regel): string {
<?php endforeach; ?>
</select>
<label for="<?= $id('gegenkonto_id') ?>">Gegenkonto <span class="optional">(macht daraus eine Umbuchung)</span></label>
<select id="<?= $id('gegenkonto_id') ?>" name="gegenkonto_id">
<option value="">– keines –</option>
<?php foreach ($konten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"
<?= (int)($regel['gegenkonto_id'] ?? 0) === (int)$konto['id'] ? 'selected' : '' ?>>
<?= View::h($konto['name']) ?>
</option>
<?php endforeach; ?>
</select>
<h4>Splitbuchung <span class="optional">(statt einer einzelnen Kategorie)</span></h4>
<?php $splitZeilen = AbgleichRegel::splitzeilen($regel); $splitForm = ''; ?>
<?php include __DIR__ . '/regelsplits.php'; ?>

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\View;
@ -47,7 +48,7 @@ include __DIR__ . '/../layout/kopf.php';
<thead>
<tr>
<th>Name</th><th>Feld</th><th>Vergleich</th><th>Muster</th>
<th>Kategorie</th><th>Klasse</th>
<th>Kategorie / Gegenkonto</th><th>Klasse</th>
<th class="rechts">Prio</th><th class="rechts">Treffer</th>
<th>Aktiv</th><th class="rechts">Aktion</th>
</tr>
@ -80,14 +81,10 @@ include __DIR__ . '/../layout/kopf.php';
</td>
<td><input type="text" form="<?= $f ?>" name="muster" value="<?= View::h($regel['muster']) ?>" required></td>
<td>
<select form="<?= $f ?>" name="kategorie_id">
<option value="">–</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= (int)$regel['kategorie_id'] === (int)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
<select form="<?= $f ?>" name="zuordnung">
<?php $gewaehlt = BuchungenController::zuordnungswert($regel); ?>
<?php $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
</select>
</td>
<td>

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\View;
/**
@ -11,13 +12,14 @@ use Finanzmanager\View;
* Felder ohnehin im umgebenden Formular stehen
* @var list<array<string,mixed>> $kategorien
* @var list<array<string,mixed>> $klassen
* @var list<array<string,mixed>> $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) . '"';
<table class="liste splittabelle" data-anteilssumme>
<thead>
<tr>
<th>Kategorie</th><th>Klasse</th><th>Notiz</th><th class="rechts">Anteil</th>
<th>Kategorie / Gegenkonto</th><th>Klasse</th><th>Notiz</th><th class="rechts">Anteil</th>
</tr>
</thead>
<tbody>
<?php foreach ($splitZeilen as $nr => $zeile): ?>
<tr>
<td>
<select<?= $fuerFormular ?> name="split[<?= (int)$nr ?>][kategorie_id]">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= (int)($zeile['kategorie_id'] ?? 0) === (int)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
<select<?= $fuerFormular ?> name="split[<?= (int)$nr ?>][zuordnung]">
<?php $gewaehlt = BuchungenController::zuordnungswert($zeile); ?>
<?php $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
</select>
</td>
<td>
@ -76,8 +74,9 @@ $fuerFormular = $splitForm === '' ? '' : ' form="' . View::h($splitForm) . '"';
<p class="feldhinweis">
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.
</p>

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\View;
@ -18,11 +19,19 @@ use Finanzmanager\View;
*/
// Für die Splitzeilen einer Regel: Die Vorschläge tragen nur Kennungen, hier
// soll der Pfad der Kategorie stehen.
// soll der Pfad der Kategorie stehen – bei einer Umbuchung das Gegenkonto in
// eckigen Klammern.
$kategoriepfad = [];
foreach ($kategorien as $eintrag) {
$kategoriepfad[(int)$eintrag['id']] = (string)$eintrag['pfad'];
}
$kontoname = array_column($konten, 'name', 'id');
$splitbezeichnung = static function (array $zeile) use ($kategoriepfad, $kontoname): string {
if (!empty($zeile['gegenkonto_id'])) {
return '[' . ($kontoname[(int)$zeile['gegenkonto_id']] ?? '?') . ']';
}
return $kategoriepfad[(int)($zeile['kategorie_id'] ?? 0)] ?? 'ohne Kategorie';
};
include __DIR__ . '/../layout/kopf.php';
?>
@ -148,6 +157,7 @@ include __DIR__ . '/../layout/kopf.php';
<select name="konto_id" required>
<option value="">– Konto wählen –</option>
<?php foreach ($konten as $konto): ?>
<?php if ((int)$konto['aktiv'] !== 1) { continue; } ?>
<option value="<?= (int)$konto['id'] ?>"><?= View::h($konto['name']) ?></option>
<?php endforeach; ?>
</select>
@ -162,12 +172,12 @@ include __DIR__ . '/../layout/kopf.php';
<ul class="vorschlagssplits">
<?php foreach ($regelsplits as $nr => $zeile): ?>
<li>
<span><?= View::h($kategoriepfad[(int)($zeile['kategorie_id'] ?? 0)] ?? 'ohne Kategorie') ?></span>
<span><?= View::h($splitbezeichnung($zeile)) ?></span>
<span class="betrag <?= View::betragKlasse($zeile['betrag']) ?>">
<?= View::betrag($zeile['betrag']) ?>
</span>
<input type="hidden" name="split[<?= (int)$nr ?>][kategorie_id]"
value="<?= (int)($zeile['kategorie_id'] ?? 0) ?>">
<input type="hidden" name="split[<?= (int)$nr ?>][zuordnung]"
value="<?= View::h(BuchungenController::zuordnungswert($zeile)) ?>">
<input type="hidden" name="split[<?= (int)$nr ?>][klasse_id]"
value="<?= (int)($zeile['klasse_id'] ?? 0) ?: '' ?>">
<input type="hidden" name="split[<?= (int)$nr ?>][notiz]"
@ -178,14 +188,10 @@ include __DIR__ . '/../layout/kopf.php';
<?php endforeach; ?>
</ul>
<?php else: ?>
<select name="kategorie_id">
<option value="">– ohne Kategorie –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= (int)($zuordnung['kategorie_id'] ?? 0) === (int)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
<select name="zuordnung" aria-label="Kategorie oder Gegenkonto">
<?php $gewaehlt = BuchungenController::zuordnungswert($zuordnung); ?>
<?php $eigenesKonto = (int)$umsatz['konto_id']; $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
</select>
<select name="klasse_id">

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\View;
/** @var array<string,mixed> $konto */
@ -10,27 +11,21 @@ use Finanzmanager\View;
/** @var list<array<string,mixed>> $kategorien */
/** @var list<array<string,mixed>> $klassen */
/** @var list<array<string,mixed>> $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.
$art = !$neu && $betrag >= 0 ? 'einnahme' : 'ausgabe';
/** Vorauswahl der gemeinsamen Liste Kategorie / Gegenkonto. */
$zuordnungswert = static function (?array $zeile): string {
if (!empty($zeile['gegenkonto_id'])) {
return 'konto:' . (int)$zeile['gegenkonto_id'];
}
if (!empty($zeile['kategorie_id'])) {
return 'kategorie:' . (int)$zeile['kategorie_id'];
}
return '';
};
$zuordnungswert = BuchungenController::zuordnungswert(...);
// Die Werte, die die Felder zeigen: normalerweise die gespeicherte Buchung
// (oder eine leere Maske), nach einem gescheiterten Speichern aber die zuletzt
@ -46,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';
@ -58,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'];
@ -213,6 +210,22 @@ include __DIR__ . '/../layout/kopf.php';
</label>
</div>
<?php /* Ohne Bargeld-Tool hat der Schalter keine Bedeutung und bleibt
unsichtbar; ein gesetzter Wert geht beim Speichern trotzdem nicht
verloren (#136). */ ?>
<?php if ($bargeld): ?>
<div class="feld">
<label class="ankreuzfeld"
title="Für Buchungen, die absichtlich nicht im Bargeld-Tool stehen – etwa Sammelbuchungen oder Differenzausgleiche. Sie werden nicht mit dem Bargeld-Tool abgeglichen und erscheinen nicht unter den offenen Vorgängen im Cockpit.">
<input type="checkbox" id="ohne_bargeld_abgleich" name="ohne_bargeld_abgleich" value="1"
<?= $ohneBargeldWert ? 'checked' : '' ?>>
Ohne Bargeld-Abgleich
</label>
</div>
<?php elseif ($ohneBargeldWert): ?>
<input type="hidden" name="ohne_bargeld_abgleich" value="1">
<?php endif; ?>
<div class="feld ganz">
<label class="ankreuzfeld"
title="Erzeugt beim Speichern zusätzlich eine Regel für den automatischen Abgleich aus dieser Buchung. Der nächste Bildschirm zeigt die neue Regel zum Prüfen an.">

View file

@ -134,7 +134,11 @@ $splitklassen = static function (array $splitzeilen) use ($splitname, $klassenNa
* @param array<string,mixed> $zeile
*/
$bargeldmarke = static fn(array $zeile): string
=> View::bargeldMarke($zeile['bargeld_status'] ?? null, $zeile['bargeld_hinweis'] ?? null);
=> View::bargeldMarke(
$zeile['bargeld_status'] ?? null,
$zeile['bargeld_hinweis'] ?? null,
!empty($zeile['ohne_bargeld_abgleich'])
);
/**
* IBAN und Bank der Gegenseite, soweit der Bankabruf sie geliefert hat – der
@ -555,6 +559,7 @@ include __DIR__ . '/../layout/kopf.php';
<?php $datumsvorgabe = date('Y-m-d'); ?>
<?php $vorschlag = ['isin' => '', 'wkn' => '']; ?>
<?php $klassenvorgabe = 0; ?>
<?php include __DIR__ . '/wertpapierfelder.php'; ?>
<div class="knopfleiste">

View file

@ -14,12 +14,14 @@ use Finanzmanager\View;
* @var array<string,mixed> $depot
* @var list<array<string,mixed>> $wertpapiere
* @var list<array<string,mixed>> $kategorien
* @var list<array<string,mixed>> $klassen
* @var array{isin:string,wkn:string} $vorschlag
* @var string $csrfToken
*/
$betrag = (float)$buchung['betrag'];
$datumsvorgabe = substr((string)$buchung['datum'], 0, 10);
$klassenvorgabe = (int)($buchung['klasse_id'] ?? 0);
include __DIR__ . '/../layout/kopf.php';
?>

View file

@ -16,6 +16,8 @@ use Finanzmanager\View;
* rechnet ihn vor, gespeichert wird er aus denselben Zahlen noch einmal.
*
* @var list<array<string,mixed>> $wertpapiere
* @var list<array<string,mixed>> $klassen
* @var int $klassenvorgabe die bisherige Klasse der Buchung, 0 für keine
* @var array{isin:string,wkn:string} $vorschlag aus dem Verwendungszweck gelesen
* @var string $datumsvorgabe
*/
@ -102,5 +104,18 @@ use Finanzmanager\View;
das nicht: Der Betrag der Buchung steht fest, wie die Bank ihn geliefert hat.
</p>
<label for="wp_klasse">Klasse <span class="optional">(optional)</span></label>
<select id="wp_klasse" name="klasse_id">
<option value="">— keine —</option>
<?php foreach ($klassen as $k): ?>
<option value="<?= (int)$k['id'] ?>" <?= (int)($klassenvorgabe ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
<?= View::h($k['name']) ?>
</option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">
Die Klasse gilt für das ganze Geschäft – auch für die Splitzeilen der Kosten.
</p>
<label for="wp_notiz">Notiz</label>
<input type="text" id="wp_notiz" name="notiz">

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Models\Konto;
use Finanzmanager\View;
/**
@ -14,6 +15,8 @@ use Finanzmanager\View;
*
* @var list<array<string,mixed>> $kategorien Kategoriebaum, flach mit Pfad
* @var list<array<string,mixed>> $konten alle Konten des Mandanten
* @var list<array<string,mixed>>|null $umbuchungskonten stattdessen, wo `$konten`
* in der Seite etwas anderes meint
* @var int $eigenesKonto Konto der Buchung – kein Gegenkonto seiner selbst
* @var string $gewaehlt vorausgewählter Wert, '' für keine Zuordnung
* @var bool $mitSplit Eintrag „Splitbuchung“ anbieten (nur im Kontoblatt)
@ -29,40 +32,68 @@ use Finanzmanager\View;
*
* In den Splitzeilen fehlt die Gruppe. Dort steht der Betrag der Zeile schon
* fest, und die Umbuchung ins Depot ist genau das, was gebraucht wird.
*
* Die Umbuchungskonten stehen unter den Kategorien, je Kontengruppe in einer
* eigenen Gruppe und in deren Reihenfolge (Konto::umbuchungsgruppen()).
* Angeboten werden nur aktive Konten (#124). Ein inaktives Konto bleibt aber
* als verborgener Eintrag in der Liste: Ist es schon gewählt, steht es
* sichtbar da – sonst ginge die Zuordnung beim nächsten Speichern verloren –,
* und kontoblatt.js kann eine bestehende Splitzeile damit vorbelegen.
*
* Außerhalb der Buchungsmaske (Regeln, Vorlagen, Zahlungsverkehr …) wird die
* Liste mit `$eigenesKonto = 0` und ohne Split- und Depotgruppe eingebunden.
*/
$kontenliste = $umbuchungskonten ?? $konten;
$istDepot = static fn(array $konto): bool => (string)($konto['kontoart'] ?? '') === 'depot';
$depots = !empty($mitDepot) ? array_filter($konten, $istDepot) : [];
$depots = !empty($mitDepot) ? array_filter($kontenliste, $istDepot) : [];
// Umbuchungskonten je Kontengruppe (Konto::umbuchungsgruppen()); inaktive,
// nicht gewählte Konten getrennt und verborgen.
$umbuchungsziele = array_values(array_filter(
$kontenliste,
static fn(array $k): bool => (int)$k['id'] !== (int)$eigenesKonto && !($depots !== [] && $istDepot($k))
));
$gewaehltesKonto = str_starts_with((string)($gewaehlt ?? ''), 'konto:') ? (int)substr((string)$gewaehlt, 6) : 0;
$umbuchungsgruppen = Konto::umbuchungsgruppen($umbuchungsziele, [$gewaehltesKonto]);
$verborgeneKonten = array_filter(
$umbuchungsziele,
static fn(array $k): bool => (int)($k['aktiv'] ?? 1) !== 1 && (int)$k['id'] !== $gewaehltesKonto
);
?>
<option value=""<?= ($gewaehlt ?? '') === '' ? ' selected' : '' ?>>— keine —</option>
<?php if (!empty($mitSplit)): ?>
<option value="split"<?= ($gewaehlt ?? '') === 'split' ? ' selected' : '' ?>>Splitbuchung …</option>
<?php endif; ?>
<optgroup label="Kategorien">
<?php foreach ($kategorien as $eintrag): ?>
<?php $wert = 'kategorie:' . (int)$eintrag['id']; ?>
<option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>>
<?= View::h($eintrag['pfad']) ?>
<?php foreach ($kategorien as $listeneintrag): ?>
<?php $optionswert = 'kategorie:' . (int)$listeneintrag['id']; ?>
<option value="<?= View::h($optionswert) ?>"<?= ($gewaehlt ?? '') === $optionswert ? ' selected' : '' ?>>
<?= View::h($listeneintrag['pfad']) ?>
</option>
<?php endforeach; ?>
</optgroup>
<optgroup label="Umbuchung auf ein Konto">
<?php foreach ($konten as $eintrag): ?>
<?php if ((int)$eintrag['id'] === (int)$eigenesKonto) { continue; } ?>
<?php if ($depots !== [] && $istDepot($eintrag)) { continue; } ?>
<?php $wert = 'konto:' . (int)$eintrag['id']; ?>
<option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>>
[<?= View::h($eintrag['name']) ?>]
<?php foreach ($umbuchungsgruppen as $kontengruppe => $gruppeneintraege): ?>
<optgroup label="<?= View::h($kontengruppe) ?>">
<?php foreach ($gruppeneintraege as $listeneintrag): ?>
<?php $optionswert = 'konto:' . (int)$listeneintrag['id']; ?>
<option value="<?= View::h($optionswert) ?>"<?= ($gewaehlt ?? '') === $optionswert ? ' selected' : '' ?>>
[<?= View::h($listeneintrag['name']) ?>]
</option>
<?php endforeach; ?>
</optgroup>
<?php endforeach; ?>
<?php foreach ($verborgeneKonten as $listeneintrag): ?>
<option value="konto:<?= (int)$listeneintrag['id'] ?>" hidden>[<?= View::h($listeneintrag['name']) ?>]</option>
<?php endforeach; ?>
<?php if ($depots !== []): ?>
<optgroup label="Wertpapiergeschäft im Depot">
<?php foreach ($depots as $eintrag): ?>
<?php if ((int)$eintrag['id'] === (int)$eigenesKonto) { continue; } ?>
<?php $wert = 'depot:' . (int)$eintrag['id']; ?>
<option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>>
[<?= View::h($eintrag['name']) ?>] …
<?php foreach ($depots as $listeneintrag): ?>
<?php if ((int)$listeneintrag['id'] === (int)$eigenesKonto) { continue; } ?>
<?php $optionswert = 'depot:' . (int)$listeneintrag['id']; ?>
<?php if ((int)($listeneintrag['aktiv'] ?? 1) !== 1 && ($gewaehlt ?? '') !== $optionswert) { continue; } ?>
<option value="<?= View::h($optionswert) ?>"<?= ($gewaehlt ?? '') === $optionswert ? ' selected' : '' ?>>
[<?= View::h($listeneintrag['name']) ?>] …
</option>
<?php endforeach; ?>
</optgroup>

View file

@ -22,7 +22,12 @@ use Finanzmanager\View;
<body class="schlicht">
<?php include __DIR__ . '/meldungen.php'; ?>
<div class="karte-mitte">
<div class="marke gross">Finanzmanager</div>
<!-- Dieselbe Marke wie oben in der Seitenleiste: Coinstack-Symbol und
zweifarbiger Schriftzug. -->
<div class="anmelde-marke">
<img src="/static/coinstack.png" alt="" class="marken-symbol">
<span class="marken-text">Finanz<span>manager</span></span>
</div>
<?= $inhalt ?>
</div>
</body>

View file

@ -106,7 +106,7 @@ include __DIR__ . '/../layout/kopf.php';
<thead>
<tr>
<th>Name und Buchungstext</th><th>Konto und Klasse</th><th class="rechts">Betrag</th><th>Rhythmus</th>
<th>Nächster Termin</th><th>Ende</th><th>Auto</th><th>Aktiv</th><th class="rechts">Aktion</th>
<th>Nächster Termin</th><th>Ende</th><th>Auto</th><th>Abgleichen</th><th>Aktiv</th><th class="rechts">Aktion</th>
</tr>
</thead>
<tbody>
@ -212,6 +212,13 @@ include __DIR__ . '/../layout/kopf.php';
<?= (int)$reihe['automatisch'] === 1 ? 'checked' : '' ?>>
</div>
</td>
<td>
<div class="feld" data-schalter>
<span class="anzeige"><?= (int)$reihe['abgleichen'] === 1 ? 'Ja' : 'Nein' ?></span>
<input type="checkbox" form="<?= $f ?>" name="abgleichen" value="1" hidden
<?= (int)$reihe['abgleichen'] === 1 ? 'checked' : '' ?>>
</div>
</td>
<td>
<div class="feld" data-schalter>
<span class="anzeige"><?= (int)$reihe['aktiv'] === 1 ? 'Ja' : 'Nein' ?></span>
@ -257,12 +264,11 @@ include __DIR__ . '/../layout/kopf.php';
<label for="neu_buchungstext">Buchungstext</label>
<input type="text" id="neu_buchungstext" name="buchungstext">
<label for="neu_kategorie">Kategorie</label>
<select id="neu_kategorie" name="kategorie_id">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"><?= View::h($kategorie['pfad']) ?></option>
<?php endforeach; ?>
<label for="neu_zuordnung"
title="Unter den Kategorien stehen die aktiven Umbuchungskonten in eckigen Klammern. Eines davon macht aus der Buchung eine Umbuchung.">Kategorie / Gegenkonto</label>
<select id="neu_zuordnung" name="zuordnung">
<?php $gewaehlt = ''; $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
</select>
<label for="neu_klasse">Klasse</label>
@ -297,6 +303,14 @@ include __DIR__ . '/../layout/kopf.php';
dort, wo der Betrag schwankt.
</p>
<label class="ankreuzfeld">
<input type="checkbox" name="abgleichen" value="1" checked>
automatisch abgleichen
</label>
<p class="feldhinweis">
Mit Haken ist die entstandene Buchung gleich als abgeglichen markiert.
</p>
<label class="ankreuzfeld">
<input type="checkbox" name="aktiv" value="1" checked> aktiv
</label>

View file

@ -8,6 +8,7 @@ use Finanzmanager\View;
* @var list<array<string,mixed>> $fristen
* @var list<array<string,mixed>> $adressen
* @var list<array<string,mixed>> $kategorien
* @var list<array<string,mixed>> $konten
* @var list<array<string,mixed>> $reihen
* @var array<string,string> $intervalle
* @var string $csrfToken
@ -74,6 +75,8 @@ include __DIR__ . '/../layout/kopf.php';
value="<?= View::h((string)$vertrag['adresse_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="kategorie_id"
value="<?= View::h((string)$vertrag['kategorie_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="gegenkonto_id"
value="<?= View::h((string)$vertrag['gegenkonto_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="regelmaessige_id"
value="<?= View::h((string)$vertrag['regelmaessige_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="beginn"
@ -163,12 +166,11 @@ include __DIR__ . '/../layout/kopf.php';
<?php endforeach; ?>
</select>
<label for="kategorie_id">Kategorie</label>
<select id="kategorie_id" name="kategorie_id">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"><?= View::h($kategorie['pfad']) ?></option>
<?php endforeach; ?>
<label for="zuordnung"
title="Unter den Kategorien stehen die aktiven Umbuchungskonten in eckigen Klammern – etwa für einen Bauspar- oder Sparvertrag.">Kategorie / Gegenkonto</label>
<select id="zuordnung" name="zuordnung">
<?php $gewaehlt = ''; $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
</select>
<div class="feldpaar">

View file

@ -1,69 +0,0 @@
<?php
use Finanzmanager\View;
/**
* Alle Buchungen, an denen die Bargeld-Tool-Prüfung zuletzt einen Fehler
* festgestellt hat – der Ziel der Kachel „Offene Vorgänge“ im Cockpit.
*
* @var bool $fehlt true: im Bargeld-Tool nicht gefunden, sonst Abruf gescheitert
* @var list<array<string,mixed>> $buchungen
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/verwaltung/bargeld" class="knopf">Bargeld-Tool</a>
<a href="/" class="knopf">Cockpit</a>
</div>
<p class="feldhinweis">
<?php if ($fehlt): ?>
Zu diesen Buchungen wurde im Bargeld-Tool keine Ausgabe mit demselben Betrag
gefunden. Eine erneute Zuordnung der Kategorie in der Buchungsmaske oder im
Kontoblatt stößt die Prüfung erneut an.
<?php else: ?>
Bei diesen Buchungen ist der letzte Abruf beim Bargeld-Tool gescheitert – ein
Abrufproblem, keine Aussage darüber, ob die Buchung dort steht. Eine erneute
Zuordnung der Kategorie in der Buchungsmaske oder im Kontoblatt stößt die
Prüfung erneut an.
<?php endif; ?>
</p>
<?php if ($buchungen === []): ?>
<p class="platzhalter"><?= $fehlt ? 'Keine nicht gefundenen Buchungen.' : 'Keine offenen Fehler.' ?></p>
<?php else: ?>
<table class="liste">
<thead>
<tr>
<th>Datum</th>
<th>Konto</th>
<th>Empfänger / Buchungstext</th>
<th class="rechts">Betrag</th>
<th>Meldung</th>
</tr>
</thead>
<tbody>
<?php foreach ($buchungen as $buchung): ?>
<tr>
<td><?= View::h(View::datum($buchung['datum'])) ?></td>
<td>
<a href="/konten/<?= (int)$buchung['konto_id'] ?>">
<?= View::h($buchung['konto_name']) ?>
</a>
</td>
<td>
<?= View::h($buchung['empfaenger'] !== '' ? $buchung['empfaenger'] : $buchung['buchungstext']) ?>
</td>
<td class="rechts <?= View::betragKlasse($buchung['betrag']) ?>">
<?= View::betrag($buchung['betrag']) ?>
</td>
<td class="klein"><?= View::h($buchung['bargeld_hinweis'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -1,10 +1,11 @@
<?php
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\View;
/** @var string $format */
/** @var array{von: string, bis: string, konten: list<int>, kategorien: list<int>, klassen: list<int>} $filter */
/** @var array{von: string, bis: string, konten: list<int>, kategorien: list<int>, gegenkonten: list<int>, klassen: list<int>} $filter */
/** @var list<array<string,mixed>> $konten */
/** @var list<array<string,mixed>> $kategorien */
/** @var list<array<string,mixed>> $klassen */
@ -87,6 +88,21 @@ include __DIR__ . '/../layout/kopf.php';
</p>
<?php endif; ?>
<?php /* Unter den Kategorien die aktiven Umbuchungskonten (#124): Ein
gewähltes Konto nimmt die Umbuchungen darauf mit. */ ?>
<?php foreach (Konto::umbuchungsgruppen($konten, $filter['gegenkonten']) as $kontengruppe => $gruppenkonten): ?>
<h4><?= View::h($kontengruppe) ?></h4>
<div class="auswahlliste">
<?php foreach ($gruppenkonten as $umbuchungskonto): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="gegenkonten[]" value="<?= (int)$umbuchungskonto['id'] ?>"
<?= $gewaehlt($filter['gegenkonten'], (int)$umbuchungskonto['id']) ? 'checked' : '' ?>>
<span>[<?= View::h((string)$umbuchungskonto['name']) ?>]</span>
</label>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<h3>Klassen</h3>
<?php if ($klassen === []): ?>
<p class="platzhalter">Noch keine Klassen angelegt.</p>

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\View;
/**
@ -54,7 +55,7 @@ include __DIR__ . '/../layout/kopf.php';
<table class="liste">
<thead>
<tr>
<th>Vorlage</th><th>Kategorie</th><th>Konto</th>
<th>Vorlage</th><th>Kategorie / Gegenkonto</th><th>Konto</th>
<th class="rechts">Betrag</th><th>Buchen am</th>
<th class="rechts">Genutzt</th><th class="rechts">Aktion</th>
</tr>
@ -69,7 +70,9 @@ include __DIR__ . '/../layout/kopf.php';
<span class="marke">Split</span>
<?php endif; ?>
</td>
<td><?= View::h($vorlage['kategorie_pfad'] ?? '–') ?></td>
<td><?= View::h($vorlage['gegenkontoname'] !== null
? '[' . $vorlage['gegenkontoname'] . ']'
: ($vorlage['kategorie_pfad'] ?? '–')) ?></td>
<td>
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<?php if ($vorlage['konto_id'] === null): ?>
@ -126,12 +129,11 @@ include __DIR__ . '/../layout/kopf.php';
<label for="betrag">Betrag</label>
<input type="text" id="betrag" name="betrag" inputmode="decimal" value="0,00">
<label for="kategorie_id">Kategorie</label>
<select id="kategorie_id" name="kategorie_id">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"><?= View::h($kategorie['pfad']) ?></option>
<?php endforeach; ?>
<label for="zuordnung"
title="Unter den Kategorien stehen die aktiven Umbuchungskonten in eckigen Klammern. Eines davon macht aus der Buchung eine Umbuchung.">Kategorie / Gegenkonto</label>
<select id="zuordnung" name="zuordnung">
<?php $gewaehlt = ''; $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
</select>
<label for="klasse_id">Klasse</label>

View file

@ -8,6 +8,7 @@ use Finanzmanager\View;
* @var list<array<string,mixed>> $depots
* @var list<array<string,mixed>> $wertpapiere
* @var list<array<string,mixed>> $konten
* @var list<array<string,mixed>> $klassen
* @var string $csrfToken
*/
@ -18,7 +19,7 @@ include __DIR__ . '/../layout/kopf.php';
<a href="/wertpapiere/buchungen" class="knopf">Zurück zu den Depotbuchungen</a>
</div>
<form method="post" action="/wertpapiere/buchungen/neu" class="formular breit">
<form method="post" action="/wertpapiere/buchungen/neu" class="formular breit" id="depotbuchungsformular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="konto_id">Depot</label>
@ -82,7 +83,8 @@ include __DIR__ . '/../layout/kopf.php';
<h3>Beträge</h3>
<p class="feldhinweis">
Zwei von drei Angaben genügen: Fehlt der Betrag, ergibt er sich aus Stück mal Kurs.
Zwei von drei Angaben genügen: Betrag = Stück × Kurs. Bei Kauf und Verkauf rechnet
die Maske den dritten Wert aus, sobald zwei eingetragen sind.
Gebühren und Steuern stehen <em>neben</em> dem Betrag – auf dem Verrechnungskonto
wird bei einem Kauf die Summe belastet, bei einem Verkauf die Differenz gutgeschrieben.
</p>
@ -125,6 +127,15 @@ include __DIR__ . '/../layout/kopf.php';
Depotbuchung – richtig etwa bei einer Ein- oder Auslieferung.
</p>
<label for="klasse_id">Klasse <span class="optional">(optional)</span></label>
<select id="klasse_id" name="klasse_id">
<option value="">— keine —</option>
<?php foreach ($klassen as $k): ?>
<option value="<?= (int)$k['id'] ?>"><?= View::h($k['name']) ?></option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">Die Klasse steht an der Geldbuchung auf dem Verrechnungskonto.</p>
<label for="notiz">Notiz</label>
<input type="text" id="notiz" name="notiz">
@ -133,4 +144,7 @@ include __DIR__ . '/../layout/kopf.php';
</div>
</form>
<script src="/static/betrag.js" defer></script>
<script src="/static/depotbuchung.js" defer></script>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\Models\Adresse;
use Finanzmanager\View;
@ -8,6 +9,7 @@ use Finanzmanager\View;
* @var list<array<string,mixed>> $konten
* @var list<array<string,mixed>> $adressen
* @var list<array<string,mixed>> $kategorien
* @var list<array<string,mixed>> $umbuchungskonten
* @var list<array<string,mixed>> $klassen
* @var string $csrfToken
* @var bool|null $hatProtokoll
@ -141,15 +143,12 @@ include __DIR__ . '/../layout/kopf.php';
Auftrag abgewiesen und bleibt als Entwurf stehen.
</p>
<label for="kategorie_id">Kategorie</label>
<select id="kategorie_id" name="kategorie_id">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= (int)$wert('kategorie_id') === (int)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
<label for="zuordnung"
title="Unter den Kategorien stehen die aktiven Umbuchungskonten in eckigen Klammern. Geht die Überweisung auf ein eigenes Konto, wird sie damit als Umbuchung gebucht.">Kategorie / Gegenkonto</label>
<select id="zuordnung" name="zuordnung">
<?php $gewaehlt = BuchungenController::zuordnungswert($auftrag); ?>
<?php $eigenesKonto = (int)$wert('konto_id'); $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
</select>
<p class="feldhinweis">Wird auf die Buchung übertragen, sobald die Bank den Auftrag annimmt.</p>

View file

@ -53,6 +53,7 @@ class AuswertungTest extends TestCase
$this->buchungsbericht();
$this->buchungsberichtBargeld();
$this->buchungsberichtAbgeglichen();
$this->vergleichsbericht();
$this->kontostandsbericht();
$this->budgetbericht();
@ -505,6 +506,42 @@ class AuswertungTest extends TestCase
Einstellung::set('bargeld_url', (string)$vorher);
}
/** Spalte und Filter „abgeglichen“ im Buchungsbericht (#137). */
private function buchungsberichtAbgeglichen(): void
{
Database::execute(
"UPDATE buchungen SET abgeglichen = CASE WHEN empfaenger IN ('Edeka', 'Rewe') THEN 1 ELSE 0 END
WHERE konto_id = ?",
[$this->giro]
);
$alle = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto']);
$markiert = [];
foreach ($alle['gruppen'][0]['buchungen'] as $buchung) {
$markiert[$buchung['empfaenger']] = $buchung['abgeglichen'];
}
$this->assert($markiert['Edeka'] && $markiert['Rewe'], 'Abgeglichene Buchungen tragen den Haken');
$this->assert(!$markiert['Kino'], 'Nicht abgeglichene Buchungen tragen keinen');
$ja = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto', 'abgeglichen' => 'ja']);
$this->assertEquals(2, $ja['anzahl'], 'Der Filter „abgeglichen“ lässt Edeka und die Splitbuchung übrig');
$nein = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto', 'abgeglichen' => 'nein']);
$this->assertEquals($alle['anzahl'] - 2, $nein['anzahl'], 'Der Filter „nicht abgeglichen“ lässt beide weg');
// Splitzeilen erben den Haken ihrer Buchung.
$nachKategorie = $this->bericht(['typ' => 'buchungsbericht', 'abgeglichen' => 'ja']);
$this->assertEquals(3, $nachKategorie['anzahl'], 'Nach Kategorie: Edeka und beide Splitzeilen');
$unbekannt = $this->bericht(['typ' => 'buchungsbericht', 'abgeglichen' => 'vielleicht']);
$this->assertEquals('', $unbekannt['filter']['abgeglichen'], 'Ein unbekannter Wert filtert nicht');
$summe = $this->bericht(['typ' => 'summenbericht', 'abgeglichen' => 'nein']);
$this->assertEquals('', $summe['filter']['abgeglichen'], 'Außerhalb des Buchungsberichts wirkt der Filter nicht');
Database::execute('UPDATE buchungen SET abgeglichen = 0 WHERE konto_id = ?', [$this->giro]);
}
private function buchungsbericht(): void
{
$bericht = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto']);

View file

@ -37,6 +37,7 @@ class CockpitTest extends TestCase
$this->verteilungKuerztAufDieGrenze();
$this->offeneVorgaengeZaehlenWasWartet();
$this->bargeldFehlerErscheintAlsOffenerVorgang();
$this->nichtAbgeglichenFuehrtInDenBuchungsbericht();
$this->legendenFuehrenZuDenBuchungen();
$this->vorgangspruefungenKennenJedenVorgang();
}
@ -301,19 +302,9 @@ class CockpitTest extends TestCase
$this->assertNotEmpty($eintrag, 'Der offene Vorgang erscheint jetzt');
$this->assertEquals(1, $eintrag['anzahl'], 'Genau die eine betroffene Buchung wird gezählt');
$this->assertEquals(
'/verwaltung/bargeld/fehler',
'/auswertungen/bericht?typ=buchungsbericht&zeitraum=alles&umbuchungen=1&bargeld=fehler&angepasst=1',
$eintrag['pfad'],
'Der Weg führt zur Liste der fehlgeschlagenen Prüfungen'
);
$liste = Buchung::mitBargeldFehler();
$this->assertEquals(1, count($liste), 'Die Liste enthält genau diese Buchung');
$this->assertEquals($id, $liste[0]['id'], 'Es ist dieselbe Buchung');
$this->assertEquals('Cockpitgiro', $liste[0]['konto_name'], 'Der Kontoname kommt mit');
$this->assertEquals(
'Kasse nicht erreichbar.',
$liste[0]['bargeld_hinweis'],
'Die Meldung der Gegenstelle steht dabei'
'Der Weg führt in den Buchungsbericht, gefiltert auf fehlgeschlagene Prüfungen (#131)'
);
// Ein Status ungleich 'fehler' (z. B. 'gefunden') zählt nicht mit.
@ -321,17 +312,70 @@ class CockpitTest extends TestCase
$ohne = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE));
$this->assert(!isset($ohne['bargeld_fehler']), 'Ein gefundener Treffer ist kein offener Vorgang mehr');
// Nicht gefunden: eigener Vorgang mit eigener Liste.
// Nicht gefunden: eigener Vorgang mit eigenem Filter im Buchungsbericht.
Database::execute("UPDATE buchungen SET bargeld_status = 'fehlt' WHERE id = ?", [$id]);
$fehlt = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE));
$this->assertEquals(1, $fehlt['bargeld_fehlt'] ?? 0, 'Eine im Bargeld-Tool nicht gefundene Buchung ist ein offener Vorgang');
$this->assert(!isset($fehlt['bargeld_fehler']), 'Nicht gefunden zählt nicht als gescheiterter Abruf');
$this->assertEquals(1, count(Buchung::mitBargeldFehler('fehlt')), 'Die Liste „nicht gefunden“ enthält die Buchung');
$this->assertEquals(0, count(Buchung::mitBargeldFehler()), 'Die Fehlerliste enthält sie nicht');
$pfade = array_column(Cockpit::offeneVorgaenge(self::HEUTE), 'pfad', 'schluessel');
$this->assert(
str_contains($pfade['bargeld_fehlt'] ?? '', 'typ=buchungsbericht') && str_contains($pfade['bargeld_fehlt'] ?? '', 'bargeld=fehlt'),
'Der Weg führt in den Buchungsbericht, gefiltert auf „nicht gefunden“ (#131)'
);
Database::execute("UPDATE buchungen SET bargeld_status = 'fehler' WHERE id = ?", [$id]);
}
/**
* „Buchungen nicht abgeglichen“ öffnet den Buchungsbericht, gefiltert auf
* nicht abgeglichene Buchungen – und der zeigt genau die gezählten (#137).
*/
private function nichtAbgeglichenFuehrtInDenBuchungsbericht(): void
{
$pruefen = function (string $fall): void {
$vorgang = null;
foreach (Cockpit::offeneVorgaenge(self::HEUTE) as $eintrag) {
if ($eintrag['schluessel'] === 'nicht_abgeglichen') {
$vorgang = $eintrag;
}
}
$this->assertNotEmpty($vorgang, $fall . ': Der Vorgang erscheint');
$this->assert(str_starts_with($vorgang['pfad'], '/auswertungen/bericht?'), $fall . ': Der Weg führt in den Buchungsbericht');
parse_str((string)parse_url($vorgang['pfad'], PHP_URL_QUERY), $filter);
$this->assertEquals('buchungsbericht', $filter['typ'] ?? '', $fall . ': Typ Buchungsbericht');
$this->assertEquals('nein', $filter['abgeglichen'] ?? '', $fall . ': Gefiltert auf nicht abgeglichen');
$bericht = Auswertung::bericht($filter, self::HEUTE);
$ids = [];
foreach ($bericht['gruppen'] as $gruppe) {
foreach ($gruppe['buchungen'] as $buchung) {
$this->assert(!$buchung['abgeglichen'], $fall . ': Nur nicht abgeglichene Buchungen');
$ids[$buchung['buchung_id']] = true;
}
}
$this->assertEquals($vorgang['anzahl'], count($ids), $fall . ': Der Bericht zeigt genau die gezählten Buchungen');
};
// Eine künftige Buchung zählt der Vorgang nicht – der Bericht auch nicht.
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-12-24', 'betrag' => -40,
'empfaenger' => 'Zukunft', 'kategorie_id' => $this->freizeit,
]);
$pruefen('Aktive Konten');
// Ein aufgelöstes Konto zählt nicht mit – der Bericht beschränkt sich
// dann auf die aktiven Konten.
$alt = Konto::anlegen([
'name' => 'Cockpitaltkonto', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 0,
]);
Buchung::anlegen([
'konto_id' => $alt, 'datum' => '2026-08-01', 'betrag' => -5,
'empfaenger' => 'Altlast', 'kategorie_id' => $this->freizeit,
]);
$pruefen('Mit aufgelöstem Konto');
}
/**
* Jeder Legendeneintrag führt zu einer Buchungsliste, deren Summe genau
* seinen Wert trifft – auch „Nicht kategorisiert“ und das Sammelsegment.

View file

@ -7,8 +7,10 @@ use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Auswertung;
use Finanzmanager\Services\BargeldAbgleich;
use Finanzmanager\Services\BargeldTool;
use Finanzmanager\Services\Cockpit;
use Finanzmanager\Services\Crypto;
/**
@ -52,7 +54,7 @@ class BargeldAbgleichTest extends TestCase
$this->kontoblattFuehrtDenStatusMit();
$this->kontoblattZeigtDieViervierSymbole();
$this->konfigurationsseiteZeigtDieAuswahl();
$this->fehlerlisteZeigtDieBuchungen();
$this->buchungsberichtFiltertNachBargeldStatus();
}
protected function tearDown(): void
@ -512,42 +514,72 @@ class BargeldAbgleichTest extends TestCase
}
/**
* Die Liste unter „Verwaltung → Bargeld-Tool → fehlgeschlagene Prüfungen“,
* das Ziel der Kachel „Offene Vorgänge“ im Cockpit. Buchung::mitBargeldFehler()
* ist kontoübergreifend – geprüft am Datenmodell, nicht am Kontoblatt.
* Statt einer eigenen Liste führt der offene Vorgang des Cockpit in den
* Buchungsbericht, gefiltert auf den Status des Bargeld-Tools (#131).
* Dort steht – anders als in der früheren Liste – die Kategorie dabei.
*/
private function fehlerlisteZeigtDieBuchungen(): void
private function buchungsberichtFiltertNachBargeldStatus(): void
{
$id = $this->buchung(-27.30, '2026-06-01');
Buchung::zuordnen($id, $this->bargeldKategorie, null);
Einstellung::set('bargeld_url', 'https://bargeld.example.org', 'bargeld');
$fehler = $this->buchung(-27.30, '2026-06-01');
Buchung::zuordnen($fehler, $this->bargeldKategorie, null);
$fehlt = $this->buchung(-8.40, '2026-06-02');
Buchung::zuordnen($fehlt, $this->bargeldKategorie, null);
Database::execute(
"UPDATE buchungen SET bargeld_status = 'fehler', bargeld_hinweis = 'Kasse steht still.' WHERE id = ?",
[$id]
[$fehler]
);
Database::execute(
"UPDATE buchungen SET bargeld_status = 'fehlt', bargeld_hinweis = 'Kein Treffer.' WHERE id = ?",
[$fehlt]
);
$liste = Buchung::mitBargeldFehler();
$treffer = null;
foreach ($liste as $zeile) {
if ((int)$zeile['id'] === $id) {
$treffer = $zeile;
}
}
$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');
$pfade = array_column(Cockpit::offeneVorgaenge('2026-06-30'), 'pfad', 'schluessel');
foreach (['bargeld_fehler' => 'fehler', 'bargeld_fehlt' => 'fehlt'] as $schluessel => $status) {
$this->assertNotEmpty($pfade[$schluessel] ?? '', "Der offene Vorgang $schluessel erscheint");
parse_str((string)parse_url($pfade[$schluessel] ?? '', PHP_URL_QUERY), $query);
$this->assertEquals(
'/auswertungen/bericht',
parse_url($pfade[$schluessel] ?? '', PHP_URL_PATH),
"Der Vorgang $schluessel führt in die Auswertung"
);
$this->assertEquals('buchungsbericht', $query['typ'] ?? '', "Der Vorgang $schluessel öffnet den Buchungsbericht");
$this->assertEquals('alles', $query['zeitraum'] ?? '', 'Über den gesamten Zeitraum');
$this->assertEquals($status, $query['bargeld'] ?? '', "Gefiltert auf den Status $status");
$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');
$bericht = Auswertung::bericht(Auswertung::normalisiere($query), '2026-06-30');
$buchungen = array_merge(...array_column($bericht['gruppen'], 'buchungen'));
$ids = array_column($buchungen, 'buchung_id');
$this->assert(
str_contains($html, '/konten/' . $this->konto),
'Der Kontoname führt ins Kontoblatt'
in_array($status === 'fehler' ? $fehler : $fehlt, $ids, true),
"Der Bericht enthält die Buchung mit Status $status"
);
$this->assert(
!in_array($status === 'fehler' ? $fehlt : $fehler, $ids, true),
'Die Buchung mit dem anderen Status fehlt'
);
$this->assertEquals(
['Bargeld Haushalt'],
array_values(array_unique(array_column($buchungen, 'kategorie'))),
'Die Kategorie steht an jeder Buchung'
);
$leer = $this->rendere('verwaltung/bargeld_fehler.php', ['buchungen' => []]);
$this->assert(str_contains($leer, 'Keine offenen Fehler.'), 'Eine leere Liste sagt das ausdrücklich');
$html = $this->rendere('auswertungen/teil-buchungen.php', ['bericht' => $bericht]);
$this->assert(str_contains($html, '<th>Kategorie</th>'), '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->assert(
!is_file(dirname(__DIR__) . '/src/Views/verwaltung/bargeld_fehler.php'),
'Die frühere eigene Liste ist entfernt'
);
}
/** @param list<array<string,mixed>> $zeilen */

View file

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
use Finanzmanager\Database;
use Finanzmanager\Mandant;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\RegelmaessigeBuchung;
use Finanzmanager\Services\Faelligkeitslauf;
/**
* Regelmäßige Buchungen: automatisch abgleichen (#123).
*
* Der Schalter ist in der Vorgabe eingeschaltet; die aus der Reihe entstandene
* Buchung ist dann gleich abgeglichen – beim Fälligkeitslauf wie beim Buchen
* von Hand. Ausgeschaltet bleibt sie offen.
*/
class RegelmaessigAbgleichenTest extends TestCase
{
protected string $testName = 'Test 60: Regelmäßige Buchungen automatisch abgleichen';
private int $giro = 0;
public function run(): void
{
$this->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<string,mixed> $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');
}
}

View file

@ -0,0 +1,313 @@
<?php
declare(strict_types=1);
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Buchungsvorlage;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Kontengruppe;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Vertrag;
use Finanzmanager\Models\Zahlungsauftrag;
use Finanzmanager\Services\Auswertung;
use Finanzmanager\Services\BuchungsExport;
/**
* Umbuchungskonten in jeder Kategorieauswahl (#124).
*
* Unter den Kategorien stehen überall die aktiven Umbuchungskonten, sortiert
* und beschriftet nach Kontengruppe – in der Buchungsmaske, bei den Regeln
* samt ihren Splitzeilen, im Umsatzabgleich, bei Vorlagen, regelmäßigen
* Buchungen, Überweisungen und Verträgen sowie in den Filtern von
* Buchungsbericht und Export.
*/
class UmbuchungskontenAuswahlTest extends TestCase
{
protected string $testName = 'Test 61: Umbuchungskonten in der Kategorieauswahl';
private int $giro = 0;
private int $tagesgeld = 0;
private int $bauspar = 0;
private int $alt = 0;
private int $lebensmittel = 0;
public function run(): void
{
$this->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, '<optgroup label="Kategorien">'), '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, '<optgroup label="Umbuchungskonten – Sparen">'),
'Die Umbuchungskonten sind nach Kontengruppe beschriftet'
);
$this->assert(!str_contains($html, '[Auswahl Giro]'), 'Das eigene Konto wird nicht angeboten');
$this->assert(
(bool)preg_match('#<option value="konto:' . $this->alt . '" hidden>#', $html),
'Ein inaktives Konto bleibt nur als verborgener Eintrag für bestehende Zuordnungen'
);
$gewaehlt = $this->auswahlliste('konto:' . $this->alt, 0);
$this->assert(
(bool)preg_match('#<option value="konto:' . $this->alt . '" selected>#', $gewaehlt),
'Ein schon gewähltes inaktives Konto steht sichtbar und gewählt da'
);
}
private function formularwerteHinUndZurueck(): void
{
$this->assertEquals('konto:7', BuchungenController::zuordnungswert(['kategorie_id' => 3, 'gegenkonto_id' => 7]),
'Das Gegenkonto sticht die Kategorie');
$this->assertEquals('kategorie:3', BuchungenController::zuordnungswert(['kategorie_id' => 3, 'gegenkonto_id' => null]),
'Ohne Gegenkonto die Kategorie');
$this->assertEquals('', BuchungenController::zuordnungswert(null), 'Ohne Zeile kein Wert');
$this->assertEquals(
['kategorie_id' => null, 'gegenkonto_id' => 7],
BuchungenController::zuordnungAusFormular(['zuordnung' => 'konto:7', 'kategorie_id' => '3']),
'Die gemeinsame Liste hat Vorrang vor einzelnen Feldern'
);
$this->assertEquals(
['kategorie_id' => 3, 'gegenkonto_id' => null],
BuchungenController::zuordnungAusFormular(['kategorie_id' => '3', 'gegenkonto_id' => '']),
'Ohne Liste gelten die versteckten Einzelfelder einer Tabellenzeile'
);
}
private function regelsplitsMitGegenkonto(): void
{
$id = AbgleichRegel::anlegen([
'name' => 'Sparplan aufteilen', 'feld' => 'empfaenger', 'vergleich' => 'enthaelt', 'muster' => 'Sparplan',
'splits' => [
['kategorie_id' => $this->lebensmittel, 'anteil' => 0.25],
['gegenkonto_id' => $this->tagesgeld, 'anteil' => 0.75],
['kategorie_id' => $this->lebensmittel, 'gegenkonto_id' => $this->bauspar, 'anteil' => 0.0],
],
]);
$regel = AbgleichRegel::find($id);
$zeilen = AbgleichRegel::splitzeilen($regel);
$this->assertEquals(2, count($zeilen), 'Eine Zeile ohne Anteil fällt weg, eine mit Gegenkonto bleibt');
$this->assertEquals($this->tagesgeld, $zeilen[1]['gegenkonto_id'], 'Die Splitzeile behält ihr Gegenkonto');
$this->assertNull($zeilen[1]['kategorie_id'], 'Die Umbuchungszeile führt keine Kategorie');
$zuordnung = AbgleichRegel::anwenden($regel, ['betrag' => -100.0]);
$this->assertEquals($this->tagesgeld, $zuordnung['splits'][1]['gegenkonto_id'], 'anwenden() reicht das Gegenkonto weiter');
$this->assertEquals(-75.0, $zuordnung['splits'][1]['betrag'], 'Der Anteil wird auch für die Umbuchung umgerechnet');
$buchung = Buchung::anlegen(
['konto_id' => $this->giro, 'datum' => date('Y-m-d'), 'betrag' => -100.0, 'empfaenger' => 'Sparplan'],
$zuordnung['splits']
);
$splits = Buchung::splits($buchung);
$this->assertEquals($this->tagesgeld, (int)$splits[1]['gegenkonto_id'], 'Die Splitbuchung aus der Regel bucht auf das Umbuchungskonto');
$neu = AbgleichRegel::find((int)AbgleichRegel::ausBuchung($buchung));
$this->assertEquals(
$this->tagesgeld,
AbgleichRegel::splitzeilen($neu)[1]['gegenkonto_id'],
'Eine Regel aus der Buchung übernimmt die Umbuchungszeile'
);
}
private function regelmaskeHatGemeinsameListe(): void
{
$regel = AbgleichRegel::find(AbgleichRegel::anlegen([
'name' => 'Umbuchung', 'muster' => 'Tagesgeld', 'gegenkonto_id' => $this->tagesgeld,
]));
$daten = [
'regel' => $regel,
'kategorien' => Kategorie::baumFlach(null, true),
'klassen' => Klasse::alle(),
'konten' => Konto::alle(),
'idPrefix' => 'test-',
];
extract($daten, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/src/Views/banking/regelfelder.php';
$html = (string)ob_get_clean();
$this->assert(str_contains($html, 'name="zuordnung"'), 'Regel anlegen: eine Liste Kategorie / Gegenkonto');
$this->assert(!str_contains($html, 'name="gegenkonto_id"'), 'Das getrennte Gegenkonto-Feld entfällt');
$this->assert(
(bool)preg_match('#<option value="konto:' . $this->tagesgeld . '" selected>#', $html),
'Das Gegenkonto der Regel ist vorausgewählt'
);
$this->assert(
str_contains($html, 'name="split[0][zuordnung]"'),
'Die Splitzeilen der Regel bieten ebenfalls Umbuchungskonten an'
);
}
private function buchungsberichtFiltertNachUmbuchungskonto(): void
{
$datum = '2026-03-10';
Buchung::anlegen(['konto_id' => $this->giro, 'datum' => $datum, 'betrag' => -50.0,
'empfaenger' => 'Sparrate', 'gegenkonto_id' => $this->tagesgeld]);
Buchung::anlegen(['konto_id' => $this->giro, 'datum' => $datum, 'betrag' => -20.0,
'empfaenger' => 'Markt', 'kategorie_id' => $this->lebensmittel]);
Buchung::anlegen(['konto_id' => $this->giro, 'datum' => $datum, 'betrag' => -30.0,
'empfaenger' => 'Bausparrate', 'gegenkonto_id' => $this->bauspar]);
$filter = Auswertung::normalisiere([
'typ' => 'buchungsbericht', 'kategorien' => ['konto:' . $this->tagesgeld, (string)$this->lebensmittel],
]);
$this->assertEquals([$this->tagesgeld], $filter['gegenkonten'], 'konto:… aus der Kategorieauswahl wird zum Umbuchungskonto');
$this->assertEquals([$this->lebensmittel], $filter['kategorien'], 'Die Kategorien bleiben daneben erhalten');
$summe = Auswertung::normalisiere(['typ' => 'summenbericht', 'kategorien' => ['konto:' . $this->tagesgeld]]);
$this->assertEquals([], $summe['gegenkonten'], 'Nur der Buchungsbericht kennt Umbuchungskonten im Filter');
$bericht = Auswertung::bericht(['zeitraum' => 'benutzerdefiniert', 'von' => $datum, 'bis' => $datum] + $filter);
$empfaenger = [];
foreach ($bericht['gruppen'] as $gruppe) {
$empfaenger = array_merge($empfaenger, array_column($gruppe['buchungen'], 'empfaenger'));
}
sort($empfaenger);
$this->assertEquals(
['Markt', 'Sparrate'],
$empfaenger,
'Der Buchungsbericht zeigt Kategorie und gewähltes Umbuchungskonto – auch ohne „Umbuchungen mitzählen“'
);
}
private function exportFiltertNachUmbuchungskonto(): void
{
$basis = ['von' => '2026-03-10', 'bis' => '2026-03-10'];
$this->assertEquals(1, BuchungsExport::anzahl($basis + ['gegenkonten' => [$this->bauspar]]),
'Export: ein Umbuchungskonto filtert seine Umbuchungen');
$this->assertEquals(
2,
BuchungsExport::anzahl($basis + ['kategorien' => [$this->lebensmittel], 'gegenkonten' => [$this->bauspar]]),
'Export: Kategorie und Umbuchungskonto aus derselben Auswahl verbinden sich mit „oder“'
);
}
private function ueberweisungAufEigenesKonto(): void
{
$id = Zahlungsauftrag::anlegen([
'konto_id' => $this->giro, 'empfaenger' => 'Ich selbst', 'iban' => 'DE02120300000000202051',
'betrag' => 100.0, 'kategorie_id' => $this->lebensmittel, 'gegenkonto_id' => $this->tagesgeld,
]);
$auftrag = Zahlungsauftrag::find($id);
$this->assertEquals($this->tagesgeld, (int)$auftrag['gegenkonto_id'], 'Die Überweisung merkt sich das Umbuchungskonto');
$this->assertNull($auftrag['kategorie_id'], 'Kategorie und Gegenkonto schließen sich aus');
Zahlungsauftrag::alsGesendetVermerken($id, 'Angenommen.');
$buchung = Buchung::find((int)Zahlungsauftrag::find($id)['buchung_id']);
$this->assertEquals($this->tagesgeld, (int)$buchung['gegenkonto_id'], 'Gesendet wird sie als Umbuchung gebucht');
$abgewiesen = false;
try {
Zahlungsauftrag::anlegen([
'konto_id' => $this->giro, 'empfaenger' => 'Ich selbst', 'iban' => 'DE02120300000000202051',
'betrag' => 1.0, 'gegenkonto_id' => $this->giro,
]);
} catch (\InvalidArgumentException) {
$abgewiesen = true;
}
$this->assert($abgewiesen, 'Eine Überweisung auf das Auftragskonto selbst wird schon beim Erfassen abgewiesen');
}
private function vertragUndVorlageMitGegenkonto(): void
{
$vertrag = Vertrag::find(Vertrag::anlegen([
'name' => 'Bausparvertrag', 'betrag' => 50, 'gegenkonto_id' => $this->bauspar,
]));
$this->assertEquals($this->bauspar, (int)$vertrag['gegenkonto_id'], 'Ein Vertrag kann auf ein Umbuchungskonto zeigen');
Buchungsvorlage::anlegen(['name' => 'Auswahl Sparrate', 'konto_id' => $this->giro, 'betrag' => -50,
'gegenkonto_id' => $this->tagesgeld]);
$vorlagen = array_column(Buchungsvorlage::alle('Auswahl Sparrate'), 'gegenkontoname', 'name');
$this->assertEquals('Auswahl Tagesgeld', $vorlagen['Auswahl Sparrate'] ?? null,
'Die Vorlagenliste kennt den Namen des Umbuchungskontos');
}
/** Die Auswahlliste rendern, wie sie jede Maske einbindet. */
private function auswahlliste(string $gewaehlt, int $eigenesKonto): string
{
$kategorien = Kategorie::baumFlach(null, true);
$konten = Konto::alle();
$mitSplit = false;
$mitDepot = false;
ob_start();
include dirname(__DIR__) . '/src/Views/buchungen/zuordnungsliste.php';
return (string)ob_get_clean();
}
}

View file

@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\Wertpapiergeschaeft;
use Finanzmanager\Services\Wertpapierkosten;
/**
* Fix #125: Beim Buchen von Wertpapieren lässt sich eine Klasse wählen – im
* Wertpapiergeschäft aus dem Kontoblatt wie in der Depotbuchung mit eigener
* Geldbuchung.
*/
class WertpapierKlasseTest extends TestCase
{
protected string $testName = 'Test 62: Klasse beim Buchen von Wertpapieren';
private int $giro = 0;
private int $depot = 0;
private int $klasse = 0;
private int $andereKlasse = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->giro = Konto::anlegen([
'name' => 'KL Giro', 'kontoart' => 'bank', 'anfangssaldo' => 10000, 'aktiv' => 1,
]);
$this->depot = Konto::anlegen([
'name' => 'KL Depot', 'kontoart' => 'depot', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->klasse = Klasse::anlegen('KL Altersvorsorge');
$this->andereKlasse = Klasse::anlegen('KL Haushalt');
Einstellung::set(
Wertpapierkosten::schluessel('bankprovision'),
(string)Kategorie::anlegen(['name' => 'KL Bankprovision', 'art' => 'ausgabe']),
'wertpapiere'
);
$this->geschaeftOhneKosten();
$this->geschaeftMitKosten();
$this->leereAuswahlLoeschtDieKlasse();
$this->ohneAngabeBleibtDieKlasse();
$this->unbekannteKlasse();
$this->depotbuchungMitGeldbuchung();
}
private function buchung(float $betrag, string $datum, ?int $klasse = null): int
{
return Buchung::anlegen([
'konto_id' => $this->giro,
'datum' => $datum,
'betrag' => $betrag,
'klasse_id' => $klasse,
'quelle' => 'banking',
]);
}
private function geschaeftOhneKosten(): void
{
$id = $this->buchung(-500.0, '2026-09-01');
Wertpapiergeschaeft::buchen(Buchung::find($id), [
'depot_id' => $this->depot,
'wertpapier_name' => 'KL Ohne Kosten AG',
'stueck' => 5,
'klasse_id' => $this->klasse,
]);
$buchung = Buchung::find($id);
$this->assertEquals($this->depot, (int)$buchung['gegenkonto_id'], 'Die Umbuchung ins Depot entsteht');
$this->assertEquals($this->klasse, (int)$buchung['klasse_id'], 'Die gewählte Klasse steht an der Buchung');
}
private function geschaeftMitKosten(): void
{
$id = $this->buchung(-255.0, '2026-09-02');
Wertpapiergeschaeft::buchen(Buchung::find($id), [
'depot_id' => $this->depot,
'wertpapier_name' => 'KL Mit Kosten AG',
'stueck' => 2,
'kosten' => ['bankprovision' => 5.0],
'klasse_id' => $this->klasse,
]);
$buchung = Buchung::find($id);
$this->assertEquals($this->klasse, (int)$buchung['klasse_id'], 'Auch die Splitbuchung trägt die Klasse');
$splits = Buchung::splits($id);
$this->assertEquals(2, count($splits), 'Depot und Bankprovision');
foreach ($splits as $nr => $zeile) {
$this->assertEquals($this->klasse, (int)$zeile['klasse_id'], 'Splitzeile ' . $nr . ' trägt die Klasse');
}
$anzeige = Buchung::splitKlasse($splits, (int)$buchung['klasse_id']);
$this->assert(!$anzeige['gemischt'], 'Das Kontoblatt zeigt keine gemischten Klassen');
$this->assertEquals($this->klasse, $anzeige['klasse_id'], 'Sondern die eine gewählte Klasse');
}
/** Die Maske ist mit der Klasse der Buchung vorbelegt – leer heißt: keine. */
private function leereAuswahlLoeschtDieKlasse(): void
{
$id = $this->buchung(-100.0, '2026-09-03', $this->andereKlasse);
Wertpapiergeschaeft::buchen(Buchung::find($id), [
'depot_id' => $this->depot,
'wertpapier_name' => 'KL Leer AG',
'stueck' => 1,
'klasse_id' => null,
]);
$this->assertNull(Buchung::find($id)['klasse_id'], 'Die leere Auswahl entfernt die Klasse');
}
private function ohneAngabeBleibtDieKlasse(): void
{
$id = $this->buchung(-110.0, '2026-09-04', $this->andereKlasse);
Wertpapiergeschaeft::buchen(Buchung::find($id), [
'depot_id' => $this->depot,
'wertpapier_name' => 'KL Ohne Angabe AG',
'stueck' => 1,
'kosten' => ['bankprovision' => 10.0],
]);
$this->assertEquals(
$this->andereKlasse,
(int)Buchung::find($id)['klasse_id'],
'Ohne Angabe bleibt die Klasse der Buchung'
);
foreach (Buchung::splits($id) as $zeile) {
$this->assertEquals($this->andereKlasse, (int)$zeile['klasse_id'], 'Und die Splitzeilen übernehmen sie');
}
}
private function unbekannteKlasse(): void
{
$id = $this->buchung(-120.0, '2026-09-05');
$abgewiesen = false;
try {
Wertpapiergeschaeft::buchen(Buchung::find($id), [
'depot_id' => $this->depot,
'wertpapier_name' => 'KL Unbekannt AG',
'stueck' => 1,
'klasse_id' => 999999999,
]);
} catch (\InvalidArgumentException $e) {
$abgewiesen = true;
}
$this->assert($abgewiesen, 'Eine unbekannte Klasse wird abgewiesen');
$this->assertNull(Depotbuchung::findeZuBuchung($id), 'Und es entsteht kein Geschäft');
}
/** Die Depotbuchung unter Wertpapiere legt die Geldbuchung selbst an. */
private function depotbuchungMitGeldbuchung(): void
{
$papier = Wertpapier::findenOderAnlegen(['name' => 'KL Depot AG', 'isin' => 'DE000KL00001']);
$depotbuchungId = Depotbuchung::anlegen([
'konto_id' => $this->depot,
'wertpapier_id' => $papier,
'datum' => '2026-09-06',
'vorgang' => 'kauf',
'stueck' => 10,
'kurs' => 20,
'betrag' => 200,
'verrechnungskonto_id' => $this->giro,
'klasse_id' => $this->klasse,
]);
$geldbuchung = Buchung::find((int)Depotbuchung::find($depotbuchungId)['buchung_id']);
$this->assertNotEmpty($geldbuchung, 'Die Geldbuchung entsteht');
$this->assertEquals($this->klasse, (int)$geldbuchung['klasse_id'], 'Mit der gewählten Klasse');
$abgewiesen = false;
try {
Depotbuchung::anlegen([
'konto_id' => $this->depot,
'wertpapier_id' => $papier,
'datum' => '2026-09-07',
'vorgang' => 'kauf',
'stueck' => 1,
'betrag' => 20,
'verrechnungskonto_id' => $this->giro,
'klasse_id' => 999999999,
]);
} catch (\InvalidArgumentException $e) {
$abgewiesen = true;
}
$this->assert($abgewiesen, 'Auch hier wird eine unbekannte Klasse abgewiesen');
}
}

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Depotbuchung;
/**
* Fix #129: Bei Kauf und Verkauf ergibt sich aus zwei der drei Angaben Stück,
* Kurs und Betrag die dritte – Betrag = Stück × Kurs. Die Maske rechnet im
* Browser, der Server dieselbe Regel für den Weg ohne JavaScript.
*/
class DepotbuchungDritterWertTest extends TestCase
{
protected string $testName = 'Test 63: Depotbuchung – dritten Wert aus Stück, Kurs und Betrag';
public function run(): void
{
$werte = Depotbuchung::dritterWert('kauf', 12.0, 45.67, 0.0);
$this->assertEquals(548.04, $werte['betrag'], 'Kauf: Betrag = Stück × Kurs');
$this->assertEquals(12.0, $werte['stueck'], 'Kauf: Stück bleibt');
$this->assertEquals(45.67, $werte['kurs'], 'Kauf: Kurs bleibt');
$werte = Depotbuchung::dritterWert('verkauf', 8.0, 0.0, 1000.0);
$this->assertEquals(125.0, $werte['kurs'], 'Verkauf: Kurs = Betrag / Stück');
$werte = Depotbuchung::dritterWert('kauf', 0.0, 3.0, 100.0);
$this->assertEquals(33.333333, $werte['stueck'], 'Kauf: Stück = Betrag / Kurs, sechs Nachkommastellen');
$werte = Depotbuchung::dritterWert('kauf', 10.0, 20.0, 150.0);
$this->assertEquals(
['stueck' => 10.0, 'kurs' => 20.0, 'betrag' => 150.0],
$werte,
'Alle drei angegeben: nichts wird überschrieben'
);
$werte = Depotbuchung::dritterWert('kauf', 0.0, 0.0, 150.0);
$this->assertEquals(
['stueck' => 0.0, 'kurs' => 0.0, 'betrag' => 150.0],
$werte,
'Nur eine Angabe: nichts zu rechnen'
);
$werte = Depotbuchung::dritterWert('dividende', 0.0, 2.0, 50.0);
$this->assertEquals(0.0, $werte['stueck'], 'Dividende: keine Stückzahl aus Betrag und Kurs');
$werte = Depotbuchung::dritterWert('einlage', 5.0, 0.0, 500.0);
$this->assertEquals(0.0, $werte['kurs'], 'Einlieferung: kein Kurs aus Betrag und Stück');
$werte = Depotbuchung::dritterWert('einlage', 5.0, 10.0, 0.0);
$this->assertEquals(50.0, $werte['betrag'], 'Einlieferung: Betrag aus Stück × Kurs wie bisher');
}
}

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* Die Anmeldeseite (und jede andere Seite im schlichten Rahmen) trägt dieselbe
* Marke wie die Seitenleiste nach dem Anmelden: Coinstack-Symbol und
* zweifarbiger Schriftzug.
*/
class AnmeldeMarkeTest extends TestCase
{
protected string $testName = 'Test 64: Marke der Anmeldeseite';
public function run(): void
{
$this->anmeldungZeigtSymbolUndSchriftzug();
$this->stilblattGleichtDerSeitenleiste();
}
private function anmeldungZeigtSymbolUndSchriftzug(): void
{
$seite = $this->rendereAnmeldung();
$this->assert(
str_contains($seite, '<div class="anmelde-marke">'),
'Die Anmeldeseite hat eine eigene Marke'
);
$this->assert(
str_contains($seite, '<img src="/static/coinstack.png" alt="" class="marken-symbol">'),
'Die Marke führt das Coinstack-Symbol wie die Seitenleiste'
);
$this->assert(
str_contains($seite, '<span class="marken-text">Finanz<span>manager</span></span>'),
'Der Schriftzug ist wie in der Seitenleiste zweigeteilt'
);
$this->assert(
!str_contains($seite, 'class="marke gross"'),
'Die alte Marke im Stil einer Statusmarke ist verschwunden'
);
}
private function stilblattGleichtDerSeitenleiste(): void
{
$css = (string)file_get_contents(dirname(__DIR__) . '/public/static/app.css');
$this->assert(
(bool)preg_match('/\.anmelde-marke\s*\{[^}]*font-weight:\s*700;/', $css),
'Der Schriftzug ist fett wie in der Seitenleiste'
);
$this->assert(
(bool)preg_match('/\.anmelde-marke\s*\{[^}]*display:\s*flex;[^}]*align-items:\s*center;/', $css),
'Symbol und Schriftzug stehen nebeneinander auf einer Linie'
);
$this->assert(
(bool)preg_match('/\.anmelde-marke \.marken-text span\s*\{\s*color:\s*var\(--akzent-schrift\);/', $css),
'Die zweite Worthälfte trägt die Akzentfarbe'
);
$this->assert(
!preg_match('/\.marke\.gross\s*\{/', $css),
'Die alte Regel für die große Statusmarke ist entfernt'
);
}
private function rendereAnmeldung(): string
{
$csrfToken = 'test';
$titel = 'Anmeldung';
$flash = [];
ob_start();
try {
include dirname(__DIR__) . '/src/Views/auth/login.php';
} finally {
$html = (string)ob_get_clean();
}
return $html;
}
}

View file

@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
use Finanzmanager\Database;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Auswertung;
use Finanzmanager\Services\BargeldAbgleich;
use Finanzmanager\Services\Cockpit;
use Finanzmanager\View;
/**
* Bewusst ohne Abgleich mit dem Bargeld-Tool buchen (#136).
*
* Sammelbuchungen und Differenzausgleiche tragen eine Kategorie, die gegen
* das Bargeld-Tool geprüft wird, stehen dort aber absichtlich nicht. Geprüft
* wird, dass der Schalter gespeichert wird, den Abgleich unterbindet und die
* Buchung damit aus den offenen Vorgängen im Cockpit nimmt – auch dann, wenn
* sie vorher schon als „nicht gefunden“ angemahnt war.
*/
class OhneBargeldAbgleichTest extends TestCase
{
protected string $testName = 'Test 65: Ohne Bargeld-Abgleich';
private int $konto = 0;
private int $kategorie = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->konto = Konto::anlegen([
'name' => 'Haushaltsgiro', 'kontoart' => 'bank', 'anfangssaldo' => 500, 'aktiv' => 1,
]);
$this->kategorie = Kategorie::anlegen(['name' => 'Haushalt bar', 'art' => 'ausgabe']);
Kategorie::bargeldAuswahlSetzen([$this->kategorie]);
// Eine Adresse, unter der niemand antwortet: Jeder echte Abruf endet
// als Fehler – ein Abruf, der gar nicht erst stattfindet, nicht.
Einstellung::set('bargeld_url', 'http://127.0.0.1:9', 'bargeld');
Einstellung::cacheLeeren();
$this->schalterWirdGespeichert();
$this->keinAbgleichUndKeinOffenerVorgang();
$this->nachtraeglichSetzenRaeumtDenStatusWeg();
$this->schnellzuordnungBehaeltDenSchalter();
$this->anzeigeImKontoblattUndBericht();
$this->maskeZeigtDenSchalter();
}
/** @param array<string,mixed> $zusatz */
private function buchung(string $datum, array $zusatz = []): int
{
return Buchung::anlegen($zusatz + [
'konto_id' => $this->konto,
'datum' => $datum,
'betrag' => -45.10,
'empfaenger' => 'Sammelbuchung Bargeld',
'kategorie_id' => $this->kategorie,
]);
}
/** @return array<string,int> */
private function vorgaenge(): array
{
return array_column(Cockpit::offeneVorgaenge('2026-09-30'), 'anzahl', 'schluessel');
}
private function schalterWirdGespeichert(): void
{
$id = $this->buchung('2026-09-01', ['ohne_bargeld_abgleich' => true]);
$this->assertEquals(1, (int)Buchung::find($id)['ohne_bargeld_abgleich'], 'Der Schalter wird gespeichert');
$normal = $this->buchung('2026-09-02');
$this->assertEquals(0, (int)Buchung::find($normal)['ohne_bargeld_abgleich'], 'Ohne Schalter bleibt er aus');
}
private function keinAbgleichUndKeinOffenerVorgang(): void
{
$vorher = $this->vorgaenge()['bargeld_fehler'] ?? 0;
$ohne = $this->buchung('2026-09-05', ['ohne_bargeld_abgleich' => true]);
$this->assertNull(BargeldAbgleich::pruefe($ohne), 'Eine Buchung ohne Bargeld-Abgleich wird nicht geprüft');
$this->assertNull(Buchung::find($ohne)['bargeld_status'], 'Sie trägt keinen Abgleichstatus');
$this->assertEquals($vorher, $this->vorgaenge()['bargeld_fehler'] ?? 0, 'Sie erscheint nicht unter den offenen Vorgängen');
// Gegenprobe: Dieselbe Buchung ohne Schalter wird geprüft und angemahnt.
$mit = $this->buchung('2026-09-06');
$this->assertEquals(BargeldAbgleich::FEHLER, BargeldAbgleich::pruefe($mit), 'Ohne Schalter wird abgeglichen');
$this->assertEquals($vorher + 1, $this->vorgaenge()['bargeld_fehler'] ?? 0, 'Und als offener Vorgang angemahnt');
}
private function nachtraeglichSetzenRaeumtDenStatusWeg(): void
{
$id = $this->buchung('2026-09-10');
Database::execute("UPDATE buchungen SET bargeld_status = 'fehlt', bargeld_hinweis = 'Kein Treffer.' WHERE id = ?", [$id]);
$vorher = $this->vorgaenge()['bargeld_fehlt'] ?? 0;
$this->assertGreaterThan(0, $vorher, 'Die nicht gefundene Buchung wird angemahnt');
$buchung = Buchung::find($id);
Buchung::speichern($id, ['ohne_bargeld_abgleich' => 1] + $buchung);
BargeldAbgleich::pruefe($id);
$frisch = Buchung::find($id);
$this->assertEquals(1, (int)$frisch['ohne_bargeld_abgleich'], 'Der Schalter lässt sich nachträglich setzen');
$this->assertNull($frisch['bargeld_status'], 'Der alte Abgleichstatus verschwindet');
$this->assertEquals($vorher - 1, $this->vorgaenge()['bargeld_fehlt'] ?? 0, 'Die Buchung wird nicht mehr angemahnt');
Buchung::speichern($id, ['ohne_bargeld_abgleich' => 0] + $frisch);
$this->assertEquals(0, (int)Buchung::find($id)['ohne_bargeld_abgleich'], 'Abgeschaltet gilt der Abgleich wieder');
}
private function schnellzuordnungBehaeltDenSchalter(): void
{
$id = $this->buchung('2026-09-12', ['ohne_bargeld_abgleich' => true, 'kategorie_id' => null]);
Buchung::zuordnen($id, $this->kategorie, null);
$this->assertNull(BargeldAbgleich::pruefe($id), 'Auch nach der Schnellzuordnung wird nicht abgeglichen');
$this->assertEquals(
1,
(int)Buchung::find($id)['ohne_bargeld_abgleich'],
'Die Schnellzuordnung lässt den Schalter stehen'
);
}
private function anzeigeImKontoblattUndBericht(): void
{
$marke = View::bargeldMarke(null, null, true);
$this->assert(str_contains($marke, 'Bewusst ohne Abgleich mit dem Bargeld-Tool'), 'Die Marke nennt den Grund');
$this->assert(str_contains($marke, '○'), 'Die Marke ist ein leerer Kreis');
$this->assert(str_contains(View::bargeldMarke(null, null), '●'), 'Ohne Schalter bleibt der volle Kreis');
$zeilen = Buchung::kontoblatt($this->konto, ['von' => '2026-09-01', 'bis' => '2026-09-01']);
$this->assertEquals(1, (int)($zeilen[0]['ohne_bargeld_abgleich'] ?? 0), 'Das Kontoblatt liefert den Schalter mit');
$bericht = Auswertung::bericht(Auswertung::normalisiere([
'typ' => 'buchungsbericht', 'zeitraum' => 'alles',
]), '2026-09-30');
$buchungen = array_merge(...array_column($bericht['gruppen'], 'buchungen'));
$treffer = array_values(array_filter(
$buchungen,
static fn(array $b): bool => $b['datum'] === '2026-09-01' && $b['ohne_bargeld_abgleich']
));
$this->assertEquals(1, count($treffer), 'Der Buchungsbericht liefert den Schalter mit');
}
private function maskeZeigtDenSchalter(): void
{
$mit = $this->maske(true, ['ohne_bargeld_abgleich' => 1]);
$this->assert(
(bool)preg_match('#name="ohne_bargeld_abgleich" value="1"\s*checked#', $mit),
'Mit Bargeld-Tool steht der Schalter gesetzt in der Maske'
);
$this->assert(str_contains($mit, 'Ohne Bargeld-Abgleich'), 'Er ist beschriftet');
$ohne = $this->maske(false, ['ohne_bargeld_abgleich' => 1]);
$this->assert(!str_contains($ohne, 'Ohne Bargeld-Abgleich'), 'Ohne Bargeld-Tool ist der Schalter unsichtbar');
$this->assert(
str_contains($ohne, '<input type="hidden" name="ohne_bargeld_abgleich" value="1">'),
'Ein gesetzter Wert geht trotzdem nicht verloren'
);
$leer = $this->maske(false, []);
$this->assert(!str_contains($leer, 'ohne_bargeld_abgleich'), 'Ohne Bargeld-Tool und ohne Wert steht nichts in der Maske');
}
/** @param array<string,mixed> $buchung */
private function maske(bool $bargeld, array $buchung): string
{
$daten = [
'titel' => 'Buchung bearbeiten',
'konto' => Konto::find($this->konto),
'buchung' => $buchung + [
'id' => 1, 'konto_id' => $this->konto, 'datum' => '2026-09-01', 'betrag' => -10.0,
],
'splits' => [],
'eingaben' => null,
'kategorien' => Kategorie::baumFlach(),
'klassen' => [],
'konten' => Konto::alle(),
'bargeld' => $bargeld,
'flash' => [],
'benutzer' => null,
'csrfToken' => 'x',
'aktuellerPfad' => '/konten',
];
extract($daten, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/src/Views/buchungen/formular.php';
return (string)ob_get_clean();
}
}

View file

@ -67,6 +67,27 @@ test.describe('Auswertungen', () => {
await expect(page.locator('thead th', { hasText: 'Saldo' })).toHaveCount(1)
})
test('Buchungsbericht führt die Spalte „abgeglichen“ und filtert danach (#137)', async ({ page }) => {
await vorlage(page, 'buchungsbericht')
await expect(page.locator('table.bericht thead th.abgleichspalte')).toHaveCount(1)
await page.selectOption('select[name="abgeglichen"]', 'nein')
await page.click('button:has-text("Anzeigen")')
await expect(page).toHaveURL(/abgeglichen=nein/)
await expect(page.locator('select[name="abgeglichen"]')).toHaveValue('nein')
await expect(page.locator('table.bericht tbody td.abgleichspalte', { hasText: '✓' })).toHaveCount(0)
})
test('Cockpit: „nicht abgeglichen“ öffnet den gefilterten Buchungsbericht (#137)', async ({ page }) => {
await page.goto('/')
const eintrag = page.locator('.vorgangsliste a', { hasText: 'nicht abgeglichen' })
test.skip(await eintrag.count() === 0, 'Keine nicht abgeglichenen Buchungen auf dieser Instanz')
await eintrag.first().click()
await expect(page).toHaveURL(/typ=buchungsbericht/)
await expect(page.locator('select[name="abgeglichen"]')).toHaveValue('nein')
})
test('Spalten-Aufteilung macht aus einer Spalte zwölf Monate', async ({ page }) => {
await vorlage(page, 'summenbericht')
await expect(page.locator('table.bericht thead th')).toHaveCount(2)

View file

@ -183,5 +183,60 @@ 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()
})
// Sammelbuchungen und Differenzausgleiche stehen absichtlich nicht im
// Bargeld-Tool (#136): Der Schalter nimmt sie aus dem Abgleich.
test('Ohne Bargeld-Abgleich wird nicht geprüft und nicht angemahnt', async ({ page }) => {
const empfaenger = `Sammelbuchung ${MARKE}`
// Ohne Adresse gibt es den Schalter nicht.
await page.goto(`/konten/${giroId}/buchung`)
await expect(page.locator('#ohne_bargeld_abgleich')).toHaveCount(0)
await page.goto('/verwaltung/bargeld')
await page.fill('#bargeld_url', ADRESSE)
await page.getByRole('checkbox', { name: KATEGORIE }).check()
await page.getByRole('button', { name: 'Speichern' }).click()
await expect(page.locator('.meldung')).toContainText('gespeichert')
await page.goto(`/konten/${giroId}/buchung`)
const schalter = page.locator('#ohne_bargeld_abgleich')
await expect(schalter).toBeVisible()
await expect(schalter).not.toBeChecked()
await page.selectOption('#art', 'ausgabe')
await page.fill('#datum', '2026-04-05')
await page.fill('#betrag', '73,20')
await page.fill('#empfaenger', empfaenger)
await page.selectOption('#zuordnung', { label: KATEGORIE })
await schalter.check()
await page.click('.buchungsmaske button[type="submit"]')
// Kein Abruf – also auch keine Meldung über einen gescheiterten.
await expect(page.locator('.meldung-erfolg')).toContainText('Buchung angelegt')
await expect(page.locator('.meldung-erfolg')).not.toContainText('Bargeld-Tool')
const zeile = page.locator('table.kontoblatt tbody tr', { hasText: empfaenger })
await expect(zeile).toContainText(KATEGORIE)
const marke = zeile.locator('td.bargeldspalte .bargeld-neutral')
await expect(marke).toHaveText('○')
await expect(marke).toHaveAttribute('title', 'Bewusst ohne Abgleich mit dem Bargeld-Tool')
// Beim Bearbeiten steht der Schalter wieder gesetzt da.
await zeile.getByRole('link', { name: empfaenger }).click()
await expect(page.locator('#ohne_bargeld_abgleich')).toBeChecked()
})
})

View file

@ -0,0 +1,79 @@
// @ts-check
const { test, expect } = require('@playwright/test')
/*
* Fix #129: In „Depotbuchung erfassen“ hängen bei Kauf und Verkauf Stück, Kurs
* und Betrag zusammen – Betrag = Stück × Kurs. Sobald zwei Felder eingetragen
* sind, rechnet die Maske das dritte aus.
*/
const BENUTZER = process.env.E2E_USER || 'e2e'
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
async function anmelden(page) {
await page.goto('/login')
if (page.url().includes('/einrichtung')) {
throw new Error(
'Instanz ist noch nicht eingerichtet: ' +
'docker compose exec -u www-data php php scripts/e2e-benutzer.php'
)
}
await page.fill('#benutzername', BENUTZER)
await page.fill('#passwort', PASSWORT)
await page.click('button[type="submit"]')
await expect(page).toHaveURL(/\/$/)
}
test.describe('Depotbuchung erfassen: Stück, Kurs und Betrag', () => {
test.beforeEach(async ({ page }) => {
await anmelden(page)
await page.goto('/wertpapiere/buchungen/neu')
})
test('Aus Stück und Kurs ergibt sich der Betrag', async ({ page }) => {
await page.selectOption('#vorgang', 'kauf')
await page.fill('#stueck', '12')
await page.fill('#kurs', '45,67')
await expect(page.locator('#betrag')).toHaveValue('548,04')
await expect(page.locator('#betrag')).toHaveClass(/berechnet/)
// Der ausgerechnete Betrag zieht nach, wenn sich ein Feld ändert.
await page.fill('#stueck', '10')
await expect(page.locator('#betrag')).toHaveValue('456,70')
// Wird ein Feld geleert, steht hinter dem Betrag nichts mehr.
await page.fill('#kurs', '')
await expect(page.locator('#betrag')).toHaveValue('')
})
test('Aus Betrag und Stück ergibt sich der Kurs', async ({ page }) => {
await page.selectOption('#vorgang', 'verkauf')
await page.fill('#betrag', '1.000,00')
await page.fill('#stueck', '8')
await expect(page.locator('#kurs')).toHaveValue('125')
})
test('Aus Betrag und Kurs ergibt sich die Stückzahl', async ({ page }) => {
await page.selectOption('#vorgang', 'kauf')
await page.fill('#kurs', '3')
await page.fill('#betrag', '100')
await expect(page.locator('#stueck')).toHaveValue('33,333333')
// Selbst eingetragen gilt der Wert als fest und wird nicht mehr überschrieben.
await page.fill('#stueck', '30')
await page.fill('#kurs', '4')
await expect(page.locator('#stueck')).toHaveValue('30')
await expect(page.locator('#betrag')).toHaveValue('100')
})
test('Bei einer Dividende wird nichts gerechnet', async ({ page }) => {
await page.selectOption('#vorgang', 'dividende')
await page.fill('#stueck', '12')
await page.fill('#kurs', '2')
await expect(page.locator('#betrag')).toHaveValue('')
// Wechselt der Vorgang auf Kauf, rechnet die Maske nach.
await page.selectOption('#vorgang', 'kauf')
await expect(page.locator('#betrag')).toHaveValue('24,00')
})
})

View file

@ -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()

View file

@ -7,6 +7,7 @@ const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
const MARKE = Date.now().toString().slice(-6)
const GIRO = `E2E RegelBuchung ${MARKE}`
const KATEGORIE = `E2E RegelBuchung Kategorie ${MARKE}`
const TAGESGELD = `E2E RegelBuchung Tagesgeld ${MARKE}`
async function anmelden(page) {
await page.goto('/login')
@ -21,6 +22,7 @@ async function anmelden(page) {
// erzeugen; ein Dialog zeigt sie danach zum Prüfen.
test.describe('Regel aus einer Buchung anlegen', () => {
let giroId = null
let tagesgeldId = null
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage()
@ -38,6 +40,12 @@ test.describe('Regel aus einer Buchung anlegen', () => {
await page.selectOption('#art', 'ausgabe')
await page.click('.formular button[type="submit"]')
await page.goto('/konten/neu')
await page.fill('#name', TAGESGELD)
await page.click('.formular button[type="submit"]')
await expect(page).toHaveURL(/\/konten\/\d+\/bearbeiten/)
tagesgeldId = page.url().match(/\/konten\/(\d+)\//)[1]
await page.close()
})
@ -75,7 +83,7 @@ test.describe('Regel aus einer Buchung anlegen', () => {
await expect(dialog).toBeVisible()
await expect(dialog.locator('input[name="name"]')).toHaveValue(empfaenger)
await expect(dialog.locator('input[name="muster"]')).toHaveValue(empfaenger)
await expect(dialog.locator('select[name="kategorie_id"]')).toHaveValue(/^\d+$/)
await expect(dialog.locator('select[name="zuordnung"]')).toHaveValue(/^kategorie:\d+$/)
await dialog.getByRole('button', { name: 'Abbruch' }).click()
@ -118,6 +126,31 @@ test.describe('Regel aus einer Buchung anlegen', () => {
await expect(zeile.locator('input[name="muster"]')).toHaveValue(angepasstesMuster)
})
// #124: Unter den Kategorien stehen auch die Umbuchungskonten – in der
// Regel selbst wie in ihren Splitzeilen.
test('Regel-Splitzeile bucht auf ein Umbuchungskonto', async ({ page }) => {
const name = `Sparregel ${MARKE}`
await page.goto('/banking/regeln')
await page.click('#neu-knopf')
const dialog = page.locator('#neudialog')
await expect(dialog).toBeVisible()
await expect(dialog.locator('#neu_zuordnung option', { hasText: `[${TAGESGELD}]` })).toHaveCount(1)
await dialog.locator('#neu_name').fill(name)
await dialog.locator('#neu_muster').fill(name)
await dialog.locator('select[name="split[0][zuordnung]"]').selectOption({ label: KATEGORIE })
await dialog.locator('input[name="split[0][prozent]"]').fill('25')
await dialog.locator('select[name="split[1][zuordnung]"]').selectOption({ label: `[${TAGESGELD}]` })
await dialog.locator('input[name="split[1][prozent]"]').fill('75')
await dialog.getByRole('button', { name: 'Regel anlegen' }).click()
await expect(page.locator('.meldung-erfolg')).toBeVisible()
const zeile = page.locator('tr').filter({ has: page.locator(`input[name="name"][value="${name}"]`) })
const splits = zeile.locator('xpath=following-sibling::tr[1]')
await expect(splits.locator('select[name="split[1][zuordnung]"]')).toHaveValue(`konto:${tagesgeldId}`)
})
test('Escape im Dialog verwirft die Regel wie ein Klick auf Abbruch', async ({ page }) => {
const empfaenger = `Escaperegel ${MARKE}`

View file

@ -27,10 +27,33 @@ async function anmelden(page) {
test.describe('Grundgerüst', () => {
test('Anmeldeseite ist erreichbar', async ({ page }) => {
await page.goto('/login')
await expect(page.locator('h1, .marke')).toContainText(/Finanzmanager|Anmeldung/)
await expect(page.locator('h1, .anmelde-marke')).toContainText(/Finanzmanager|Anmeldung/)
await expect(page.locator('#benutzername')).toBeVisible()
})
test('Anmeldeseite zeigt dieselbe Marke wie die Seitenleiste', async ({ page }) => {
const stil = loc => loc.evaluate(k => {
const s = getComputedStyle(k)
return { familie: s.fontFamily, gewicht: s.fontWeight }
})
await page.goto('/login')
const marke = page.locator('.anmelde-marke')
await expect(marke.locator('img.marken-symbol')).toHaveAttribute('src', '/static/coinstack.png')
await expect(marke.locator('img.marken-symbol')).toBeVisible()
await expect(marke.locator('.marken-text')).toHaveText('Finanzmanager')
const anmeldung = await stil(marke.locator('.marken-text'))
// Die zweite Worthälfte hebt sich farblich ab, wie in der Seitenleiste.
const farben = await marke.locator('.marken-text').evaluate(k => [
getComputedStyle(k).color, getComputedStyle(k.querySelector('span')).color,
])
expect(farben[0]).not.toBe(farben[1])
await anmelden(page)
const leiste = await stil(page.locator('.seitenleisten-marke .marken-text'))
expect(anmeldung).toEqual(leiste)
})
test('Ohne Anmeldung führt jeder Weg zur Anmeldung', async ({ page }) => {
await page.goto('/einstellungen')
await expect(page).toHaveURL(/\/(login|einrichtung)/)

View file

@ -16,6 +16,7 @@ const GIRO = `E2E WP Giro ${MARKE}`
const DEPOT = `E2E WP Depot ${MARKE}`
const KAT_GEBUEHR = `E2E Bankprovision ${MARKE}`
const KAT_STEUER = `E2E Kapitalertragsteuer ${MARKE}`
const KLASSE = `E2E WP Klasse ${MARKE}`
// ISIN und WKN tragen die Marke des Laufs: Ein Papier wird über seine Kennung
// wiedergefunden, nicht über den Namen. Feste Kennungen würden den Test beim
@ -87,6 +88,11 @@ test.describe('Wertpapiergeschäft aus dem Kontoblatt', () => {
await kategorieAnlegen(page, KAT_GEBUEHR)
await kategorieAnlegen(page, KAT_STEUER)
await page.goto('/verwaltung/klassen')
await page.click('#neu-knopf')
await page.fill('form[action="/verwaltung/klassen/neu"] #name', KLASSE)
await page.click('form[action="/verwaltung/klassen/neu"] button.primaer')
// Die Kostenarten bekommen ihre feste Kategorie – danach fragt der
// Kostendialog nur noch Beträge ab.
await page.goto('/einstellungen?bereich=wertpapiere')
@ -173,12 +179,17 @@ test.describe('Wertpapiergeschäft aus dem Kontoblatt', () => {
await page.fill('#wp_name', `Sparplan-Fonds ${MARKE}`)
await page.fill('#wp_stueck', '10')
// Die Klasse wird im Dialog gleich mitgewählt (#125).
await expect(page.locator('#wp_klasse')).toHaveValue('')
await page.selectOption('#wp_klasse', { label: KLASSE })
await page.locator('#wertpapierdialog button[type="submit"]').click()
await expect(page.locator('.meldung-erfolg')).toContainText('Einstandspreis 1.000,00')
// Keine Splitbuchung mit einer einzigen Zeile, sondern eine Umbuchung.
await expect(page.locator('tr', { hasText: empfaenger }).first())
.toContainText(`[${DEPOT}]`)
const danach = page.locator('tr', { hasText: empfaenger }).first()
await expect(danach).toContainText(`[${DEPOT}]`)
await expect(danach.locator('a.klassenfeld')).toHaveText(KLASSE)
// Ohne Kennung im Verwendungszweck entsteht das Papier unter dem
// Namen, der in der Maske steht.