Merge pull request 'Fix #134: Buchungen bewusst ohne Kategorie ermöglichen' (#138) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/138
This commit is contained in:
commit
eb4227aefb
18 changed files with 361 additions and 23 deletions
|
|
@ -6,6 +6,12 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- 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)
|
||||
|
||||
### Changed
|
||||
|
||||
- Regeln: Die Splittabellen zeigen die Spalte „Anteil“ ohne Prozentzeichen und darunter die
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
# Änderungen
|
||||
|
||||
## 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.
|
||||
- Neuer Schalter „Bewusst ohne Kategorie“ in der Buchungsmaske (neben „Abgeglichen“). Gesetzt sperrt er Kategorie / Gegenkonto und die Splitzeilen; mit Zuordnung lässt er sich nicht setzen.
|
||||
- Solche Buchungen erscheinen nicht unter den offenen Vorgängen im Cockpit, nicht im Prüflauf des Monatsabschlusses, setzen das Konto in der Kontenübersicht nicht fett, werden von „Regeln auf Buchungen ohne Kategorie anwenden“ ausgelassen und nicht automatisch zu Umbuchungen zusammengeführt. Das Kontoblatt zeigt „bewusst ohne Kategorie“.
|
||||
- Bekommt die Buchung später doch eine Kategorie, ein Gegenkonto oder Splitzeilen (auch über die Schnellzuordnung), entfällt die Markierung.
|
||||
- Migration `028_bewusst_ohne_kategorie.sql`.
|
||||
- Tests: `tests/59_BewusstOhneKategorieTest.php`, `tests/e2e/specs/buchungen.spec.js`.
|
||||
|
||||
## Kontoumsätze: Empfänger bei Kartenverfügungen (06.10.2026)
|
||||
|
||||
- Anlass (#119): Bei Zahlungen mit der comdirect Visa-Debitkarte (Kartenverfügung, GVC 700) blieb der Empfänger leer – die Bank liefert dafür kein Namensfeld (?32/?33), der Händler steht nur im Verwendungszweck zwischen „KARTENVERFÜGUNG“ und „KARTE NR.“.
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ Haushalt wird beides parallel genutzt.
|
|||
**Alle sieben Phasen aus `PLAN.md` sind umgesetzt.**
|
||||
|
||||
* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt
|
||||
mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen;
|
||||
QIF-Import mit Stichtag.
|
||||
mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen
|
||||
sowie Buchungen bewusst ohne Kategorie (etwa Abschreibungen); QIF-Import mit Stichtag.
|
||||
* **Import und Export** – QIF und CSV in beide Richtungen: Stammdaten und Buchungen
|
||||
übernehmen, Buchungen nach Zeitraum, Konto, Kategorie und Klasse gefiltert ausgeben.
|
||||
Exportdateien werden nicht vorgehalten; das Housekeeping räumt hochgeladene Dateien weg.
|
||||
|
|
|
|||
|
|
@ -199,6 +199,30 @@ was in den Feldern steht. Gelöscht werden die Zeilen samt Zuordnung, nicht die
|
|||
sie steht danach mit ihrem Betrag und ohne Kategorie im Kontoblatt, und die Gegenseite
|
||||
einer Zeile mit Gegenkonto verschwindet von jenem Konto.
|
||||
|
||||
## Bewusst ohne Kategorie
|
||||
|
||||
Manche Buchungen sollen absichtlich keine Kategorie bekommen (#134): Abschreibungen etwa –
|
||||
als Ausgabe kategorisiert belasteten sie die Auswertung ein zweites Mal neben dem
|
||||
gesunkenen Wert des Kontos – oder Solarerträge, die nur rechnerisch existieren. Der
|
||||
Schalter **Bewusst ohne Kategorie** in der Buchungsmaske setzt `buchungen.bewusst_ohne_kategorie`
|
||||
(Migration `028_bewusst_ohne_kategorie.sql`).
|
||||
|
||||
Solche Buchungen gelten nicht als offene Arbeit: Sie fehlen unter den offenen Vorgängen
|
||||
im Cockpit, im Prüflauf des Monatsabschlusses, im Fettdruck der Kontenübersicht
|
||||
(`Buchung::unkategorisierteJeKonto()`), bei *Regeln auf Buchungen ohne Kategorie anwenden*
|
||||
(`Buchung::ohneKategorie()`) und als Kandidat für das automatische Zusammenführen zu einer
|
||||
Umbuchung. Das Kontoblatt zeigt in der Zuordnungsspalte „bewusst ohne Kategorie“. Saldo
|
||||
und Auswertungen bleiben unverändert – im Buchungsbericht stehen sie wie bisher unter
|
||||
„Nicht kategorisiert“.
|
||||
|
||||
Die Markierung gilt nur, solange die Buchung nichts zugeordnet hat. Das Model
|
||||
(`Buchung::normalisiere()`) lässt sie fallen, sobald eine Kategorie, ein Gegenkonto oder
|
||||
Splitzeilen dazukommen – eine Zuordnung geht nie verloren. Dasselbe gilt für die
|
||||
Schnellzuordnung im Kontoblatt (`Buchung::zuordnen()`); wird dort die Zuordnung nur
|
||||
geleert, bleibt die Markierung stehen. In der Maske sperrt der gesetzte Schalter
|
||||
*Kategorie / Gegenkonto* und die Splitzeilen, umgekehrt lässt er sich nur setzen, solange
|
||||
dort nichts steht. Die Klasse bleibt frei.
|
||||
|
||||
## Salden
|
||||
|
||||
| Spalte | Bedeutung |
|
||||
|
|
|
|||
|
|
@ -176,7 +176,8 @@ Was auf Bearbeitung wartet, mit Zahl und Weg dorthin. Der Tooltip der Überschri
|
|||
Prüfungen, die gerade nichts melden und daher nicht in der Liste stehen:
|
||||
|
||||
* **ohne Kategorie** – Buchungen ohne Kategorie, die auch keine Umbuchung und nicht
|
||||
gesplittet sind. Umbuchungen brauchen keine Kategorie und zählen deshalb nicht mit.
|
||||
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.
|
||||
* **Bargeld-Tool-Prüfung fehlgeschlagen** – Buchungen mit `bargeld_status = 'fehler'`, also
|
||||
einem gescheiterten Abruf beim Bargeld-Tool (siehe `docs/buchungen.md`), nicht zu
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ Der Abschluss friert einen Monat ein (`monatsabschluesse`).
|
|||
|
||||
**Prüflauf** (`Monatsabschluss::pruefen()`) zeigt, was im Zeitraum noch offen ist:
|
||||
|
||||
* Buchungen ohne Kategorie und ohne Gegenkonto
|
||||
* Buchungen ohne Kategorie und ohne Gegenkonto (außer bewusst ohne Kategorie gebuchten)
|
||||
* nicht abgeglichene Buchungen
|
||||
* Splitbuchungen, deren Zeilen nicht auf den Buchungsbetrag aufgehen
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ dazu, ohne dass die Aufrufer sich ändern.
|
|||
**Fett gedruckt** steht ein Konto, in dem Buchungen aus dem laufenden Jahr ohne Kategorie
|
||||
liegen (`Buchung::unkategorisierteJeKonto()`, Zahl im Titel des Kontonamens). Gezählt wird
|
||||
wie im Cockpit: keine Kategorie, kein Gegenkonto, nicht gesplittet – eine Umbuchung und eine
|
||||
Splitbuchung sind zugeordnet. Das ist vor allem die Gegenprobe zum Online-Banking, das
|
||||
Splitbuchung sind zugeordnet, eine bewusst ohne Kategorie gebuchte Buchung liegt nicht als
|
||||
Arbeit an. Das ist vor allem die Gegenprobe zum Online-Banking, das
|
||||
abgerufene Umsätze selbst bucht (siehe `docs/banking.md`).
|
||||
|
||||
**Anfangssalden** stehen nicht im QIF-Export. Sie werden nach dem Import je Konto im
|
||||
|
|
|
|||
12
migrations/028_bewusst_ohne_kategorie.sql
Normal file
12
migrations/028_bewusst_ohne_kategorie.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
-- Bewusst ohne Kategorie buchen (#134).
|
||||
--
|
||||
-- Abschreibungen oder rein rechnerische Solarerträge sollen ohne Kategorie
|
||||
-- gebucht werden können – mit Kategorie zählten sie in der Auswertung als
|
||||
-- Ausgabe bzw. Einnahme zusätzlich zur Wertänderung des Kontos. Der Schalter
|
||||
-- in der Buchungsmaske markiert solche Buchungen; Cockpit, Kontenübersicht,
|
||||
-- Monatsabschluss und Regelanwendung mahnen sie dann nicht mehr als offen an.
|
||||
--
|
||||
-- Idempotent über IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE buchungen
|
||||
ADD COLUMN IF NOT EXISTS bewusst_ohne_kategorie TINYINT(1) NOT NULL DEFAULT 0 AFTER gesplittet;
|
||||
|
|
@ -15,6 +15,10 @@
|
|||
* noch stehen mag, geht also nicht in die Buchung ein. Der Knopf
|
||||
* „Splitbuchungen löschen“ leert alle Splitzeilen und gibt oben wieder frei.
|
||||
*
|
||||
* Der Schalter „Bewusst ohne Kategorie“ (#134) sperrt Kategorie / Gegenkonto
|
||||
* und die Splitzeilen; umgekehrt lässt er sich nur setzen, solange dort nichts
|
||||
* steht. Die Klasse bleibt frei – sie ist keine Zuordnung.
|
||||
*
|
||||
* Kategorie und Gegenkonto stehen in einem Feld, deshalb gibt es hier nichts
|
||||
* mehr ein- und auszublenden; die Art bestimmt nur noch die Richtung.
|
||||
*/
|
||||
|
|
@ -65,6 +69,8 @@
|
|||
.filter(Boolean);
|
||||
const splitkasten = document.getElementById('splitzeilen');
|
||||
const leerenknopf = document.getElementById('splitsleeren');
|
||||
const ohneKategorie = document.getElementById('bewusst_ohne_kategorie');
|
||||
const zuordnungsfeld = document.getElementById('zuordnung');
|
||||
|
||||
function splitfelder() {
|
||||
return splitkasten ? Array.from(splitkasten.querySelectorAll('input, select')) : [];
|
||||
|
|
@ -84,6 +90,18 @@
|
|||
direktfelder.forEach(function (feld) { feld.disabled = splitBelegt; });
|
||||
splits.forEach(function (feld) { feld.disabled = direktBelegt; });
|
||||
|
||||
if (ohneKategorie) {
|
||||
const ohne = ohneKategorie.checked;
|
||||
if (ohne) {
|
||||
splits.forEach(function (feld) { feld.disabled = true; });
|
||||
if (zuordnungsfeld) {
|
||||
zuordnungsfeld.disabled = true;
|
||||
}
|
||||
}
|
||||
ohneKategorie.disabled = !ohne
|
||||
&& (splitBelegt || (zuordnungsfeld !== null && zuordnungsfeld.value !== ''));
|
||||
}
|
||||
|
||||
if (leerenknopf) {
|
||||
leerenknopf.hidden = !splitBelegt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -650,6 +650,7 @@ class BuchungenController extends BaseController
|
|||
'klasse_id' => $this->post('klasse_id'),
|
||||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'quelle' => 'manuell',
|
||||
'bewusst_ohne_kategorie' => isset($_POST['bewusst_ohne_kategorie']),
|
||||
];
|
||||
|
||||
return [$daten, $this->splitzeilen()];
|
||||
|
|
|
|||
|
|
@ -353,6 +353,7 @@ class Bankumsatz
|
|||
WHERE b.benutzer_id = ?
|
||||
AND b.konto_id <> ?
|
||||
AND b.gegenkonto_id IS NULL AND b.gesplittet = 0 AND b.kategorie_id IS NULL
|
||||
AND b.bewusst_ohne_kategorie = 0
|
||||
AND ABS(b.betrag + ?) < 0.005
|
||||
AND ABS(DATEDIFF(b.datum, ?)) <= ?
|
||||
AND u2.id <> ?
|
||||
|
|
@ -401,7 +402,7 @@ class Bankumsatz
|
|||
* Kontoblatt des Gegenkontos steht, und legt beide zusammen.
|
||||
*
|
||||
* Die Gegenseite ist entweder eine einzeln erfasste Buchung (ohne Kategorie und
|
||||
* Klasse) oder eine Umbuchung in umgekehrter Richtung, an der auf diesem Konto
|
||||
* Klasse, nicht bewusst ohne Kategorie gebucht) oder eine Umbuchung in umgekehrter Richtung, an der auf diesem Konto
|
||||
* noch kein Umsatz hängt. Betrag mit umgekehrtem Vorzeichen, Buchungstag
|
||||
* höchstens `banking_abgleich_tage` entfernt; widersprechen sich die IBANs der
|
||||
* Umsätze, scheidet der Kandidat aus. Bestätigte IBANs gehen vor, dann gewinnt
|
||||
|
|
@ -443,7 +444,8 @@ class Bankumsatz
|
|||
WHERE b.benutzer_id = ? AND b.konto_id = ? AND b.id <> ?
|
||||
AND b.gesplittet = 0
|
||||
AND (
|
||||
(b.gegenkonto_id IS NULL AND b.kategorie_id IS NULL AND b.klasse_id IS NULL)
|
||||
(b.gegenkonto_id IS NULL AND b.kategorie_id IS NULL AND b.klasse_id IS NULL
|
||||
AND b.bewusst_ohne_kategorie = 0)
|
||||
OR b.gegenkonto_id = ?
|
||||
)
|
||||
AND ABS(b.betrag + ?) < 0.005
|
||||
|
|
@ -506,7 +508,7 @@ class Bankumsatz
|
|||
* sie als zusammengehörig erkannte.
|
||||
*
|
||||
* Kriterien wie bei gegenbuchung(); beide Buchungen ohne Kategorie und Klasse,
|
||||
* ungesplittet, mit genau einem Umsatz. Bestätigen die IBANs ein Paar, gewinnt
|
||||
* nicht bewusst ohne Kategorie gebucht, ungesplittet, mit genau einem Umsatz. Bestätigen die IBANs ein Paar, gewinnt
|
||||
* der zeitlich nächste Partner; ohne Bestätigung nur ein Paar, in dem beide
|
||||
* einander als einziger Kandidat haben. Die ältere Buchung wird zur Umbuchung,
|
||||
* die jüngere fällt weg und ihr Umsatz hängt sich an die ältere.
|
||||
|
|
@ -531,6 +533,7 @@ class Bankumsatz
|
|||
AND a.gesplittet = 0 AND b.gesplittet = 0
|
||||
AND a.kategorie_id IS NULL AND b.kategorie_id IS NULL
|
||||
AND a.klasse_id IS NULL AND b.klasse_id IS NULL
|
||||
AND a.bewusst_ohne_kategorie = 0 AND b.bewusst_ohne_kategorie = 0
|
||||
AND ABS(a.betrag) >= 0.005 AND ABS(a.betrag + b.betrag) < 0.005
|
||||
AND ABS(DATEDIFF(a.datum, b.datum)) <= ?
|
||||
AND (SELECT COUNT(*) FROM bank_umsaetze x WHERE x.buchung_id = a.id) = 1
|
||||
|
|
|
|||
|
|
@ -105,14 +105,16 @@ class Buchung
|
|||
Database::execute(
|
||||
'INSERT INTO buchungen
|
||||
(benutzer_id, konto_id, datum, valuta, betrag, empfaenger, iban, bankname, buchungstext,
|
||||
nummer, abgeglichen, kategorie_id, klasse_id, gegenkonto_id, gesplittet, quelle, import_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
nummer, abgeglichen, kategorie_id, klasse_id, gegenkonto_id, gesplittet,
|
||||
bewusst_ohne_kategorie, 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['quelle'], $felder['import_hash'],
|
||||
$felder['gegenkonto_id'], $felder['gesplittet'], $felder['bewusst_ohne_kategorie'],
|
||||
$felder['quelle'], $felder['import_hash'],
|
||||
]
|
||||
);
|
||||
$id = Database::lastInsertId();
|
||||
|
|
@ -163,13 +165,14 @@ class Buchung
|
|||
Database::execute(
|
||||
'UPDATE buchungen SET konto_id = ?, datum = ?, valuta = ?, betrag = ?, empfaenger = ?,
|
||||
buchungstext = ?, nummer = ?, abgeglichen = ?, kategorie_id = ?, klasse_id = ?,
|
||||
gegenkonto_id = ?, gesplittet = ?
|
||||
gegenkonto_id = ?, gesplittet = ?, bewusst_ohne_kategorie = ?
|
||||
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'], $id, Mandant::id(),
|
||||
$felder['gegenkonto_id'], $felder['gesplittet'], $felder['bewusst_ohne_kategorie'],
|
||||
$id, Mandant::id(),
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -215,6 +218,10 @@ class Buchung
|
|||
* Beides zugleich gibt es nicht: Eine Buchung ist entweder kategorisiert
|
||||
* oder eine Umbuchung. War sie gesplittet, fallen die Zeilen weg – die
|
||||
* Zuordnung steckt danach wieder oben.
|
||||
*
|
||||
* Bekommt eine Buchung, die bewusst ohne Kategorie gebucht war, doch eine
|
||||
* Zuordnung, verliert sie diese Markierung (#134). Wird die Zuordnung nur
|
||||
* geleert, bleibt sie stehen.
|
||||
*/
|
||||
public static function zuordnen(int $id, ?int $kategorieId, ?int $gegenkontoId, bool $gegenseiteSuchen = true): void
|
||||
{
|
||||
|
|
@ -235,9 +242,10 @@ class Buchung
|
|||
Database::transaktion(static function () use ($id, $kategorieId, $gegenkontoId): void {
|
||||
Database::execute('DELETE FROM buchung_splits WHERE buchung_id = ?', [$id]);
|
||||
Database::execute(
|
||||
'UPDATE buchungen SET kategorie_id = ?, gegenkonto_id = ?, gesplittet = 0
|
||||
'UPDATE buchungen SET kategorie_id = ?, gegenkonto_id = ?, gesplittet = 0,
|
||||
bewusst_ohne_kategorie = IF(? IS NULL AND ? IS NULL, bewusst_ohne_kategorie, 0)
|
||||
WHERE id = ? AND benutzer_id = ?',
|
||||
[$kategorieId, $gegenkontoId, $id, Mandant::id()]
|
||||
[$kategorieId, $gegenkontoId, $kategorieId, $gegenkontoId, $id, Mandant::id()]
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -429,7 +437,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.bargeld_status, b.bargeld_hinweis, b.bewusst_ohne_kategorie,
|
||||
b.gegenkonto_id, b.konto_id, 0 AS gegensicht, NULL AS split_id,
|
||||
b.iban, b.bankname
|
||||
FROM buchungen b
|
||||
|
|
@ -437,7 +445,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,
|
||||
NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie,
|
||||
b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, NULL AS split_id,
|
||||
\'\' AS iban, \'\' AS bankname
|
||||
FROM buchungen b
|
||||
|
|
@ -445,7 +453,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,
|
||||
NULL AS bargeld_status, NULL AS bargeld_hinweis, 0 AS bewusst_ohne_kategorie,
|
||||
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
|
||||
|
|
@ -635,7 +643,8 @@ class Buchung
|
|||
*
|
||||
* Unkategorisiert heißt hier dasselbe wie im Cockpit: keine Kategorie, kein
|
||||
* Gegenkonto (eine Umbuchung ist zugeordnet) und nicht gesplittet (dann
|
||||
* steckt die Zuordnung in den Zeilen). Gezählt wird nur die eigene Sicht:
|
||||
* steckt die Zuordnung in den Zeilen). Was bewusst ohne Kategorie gebucht
|
||||
* ist (#134), liegt nicht als Arbeit an. Gezählt wird nur die eigene Sicht:
|
||||
* Die Gegenseite einer Umbuchung hat per Definition ein Gegenkonto.
|
||||
*
|
||||
* @return array<int,int> konto_id => Anzahl
|
||||
|
|
@ -649,6 +658,7 @@ class Buchung
|
|||
FROM buchungen
|
||||
WHERE benutzer_id = ? AND YEAR(datum) = ?
|
||||
AND gesplittet = 0 AND kategorie_id IS NULL AND gegenkonto_id IS NULL
|
||||
AND bewusst_ohne_kategorie = 0
|
||||
GROUP BY konto_id',
|
||||
[Mandant::id(), $jahr]
|
||||
);
|
||||
|
|
@ -669,7 +679,8 @@ class Buchung
|
|||
*
|
||||
* Kein Gegenkonto und nicht gesplittet ohne jede kategorisierte Zeile:
|
||||
* dieselbe Definition wie bei {@see unkategorisierteJeKonto()}, nur als
|
||||
* Liste statt als Zählung je Konto.
|
||||
* Liste statt als Zählung je Konto. Bewusst ohne Kategorie gebuchte
|
||||
* Buchungen (#134) sind keine Lücke, die eine Regel schließen sollte.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
|
|
@ -678,6 +689,7 @@ class Buchung
|
|||
return Database::fetchAll(
|
||||
'SELECT b.* FROM buchungen b
|
||||
WHERE b.benutzer_id = ? AND b.gegenkonto_id IS NULL
|
||||
AND b.bewusst_ohne_kategorie = 0
|
||||
AND (
|
||||
(b.gesplittet = 0 AND b.kategorie_id IS NULL)
|
||||
OR (b.gesplittet = 1 AND NOT EXISTS (
|
||||
|
|
@ -779,6 +791,7 @@ class Buchung
|
|||
$gegenkonto = self::id($daten['gegenkonto_id'] ?? null);
|
||||
$kategorie = self::id($daten['kategorie_id'] ?? null);
|
||||
$gesplittet = $splits !== [];
|
||||
$bewusstOhne = !empty($daten['bewusst_ohne_kategorie']);
|
||||
|
||||
if ($gegenkonto !== null) {
|
||||
self::pruefeGegenkonto($gegenkonto, $kontoId);
|
||||
|
|
@ -790,6 +803,13 @@ class Buchung
|
|||
self::pruefeSplits($splits, $betrag, $kontoId);
|
||||
}
|
||||
|
||||
// „Bewusst ohne Kategorie“ (#134) gilt nur, solange die Buchung auch
|
||||
// wirklich nichts zugeordnet hat. Eine Zuordnung geht nie verloren –
|
||||
// im Zweifel entfällt die Markierung.
|
||||
if ($kategorie !== null || $gegenkonto !== null || $gesplittet) {
|
||||
$bewusstOhne = false;
|
||||
}
|
||||
|
||||
$valuta = trim((string)($daten['valuta'] ?? ''));
|
||||
|
||||
return [
|
||||
|
|
@ -807,6 +827,7 @@ class Buchung
|
|||
'klasse_id' => self::id($daten['klasse_id'] ?? null),
|
||||
'gegenkonto_id' => $gegenkonto,
|
||||
'gesplittet' => $gesplittet ? 1 : 0,
|
||||
'bewusst_ohne_kategorie' => $bewusstOhne ? 1 : 0,
|
||||
'quelle' => isset(self::QUELLEN[(string)($daten['quelle'] ?? '')])
|
||||
? (string)$daten['quelle'] : 'manuell',
|
||||
'import_hash' => ($daten['import_hash'] ?? null) ?: null,
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ class Monatsabschluss
|
|||
JOIN konten k ON k.id = b.konto_id
|
||||
WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ?
|
||||
AND b.gesplittet = 0 AND b.kategorie_id IS NULL AND b.gegenkonto_id IS NULL
|
||||
AND b.bewusst_ohne_kategorie = 0
|
||||
ORDER BY b.datum, b.id',
|
||||
$p
|
||||
);
|
||||
|
|
|
|||
|
|
@ -245,7 +245,8 @@ class Cockpit
|
|||
$ohneKategorie = (int)Database::fetchValue(
|
||||
'SELECT COUNT(*) FROM buchungen
|
||||
WHERE benutzer_id = ? AND gesplittet = 0
|
||||
AND kategorie_id IS NULL AND gegenkonto_id IS NULL',
|
||||
AND kategorie_id IS NULL AND gegenkonto_id IS NULL
|
||||
AND bewusst_ohne_kategorie = 0',
|
||||
[$benutzer],
|
||||
0
|
||||
);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ $klasseWert = (int)($buchung['klasse_id'] ?? 0);
|
|||
$buchungstextWert = (string)($buchung['buchungstext'] ?? '');
|
||||
$nummerWert = (string)($buchung['nummer'] ?? '');
|
||||
$abgeglichenWert = !empty($buchung['abgeglichen']);
|
||||
$ohneKategorieWert = !empty($buchung['bewusst_ohne_kategorie']);
|
||||
|
||||
if ($eingaben !== null) {
|
||||
$art = ($eingaben['art'] ?? '') === 'einnahme' ? 'einnahme' : 'ausgabe';
|
||||
|
|
@ -56,6 +57,7 @@ if ($eingaben !== null) {
|
|||
$buchungstextWert = (string)($eingaben['buchungstext'] ?? '');
|
||||
$nummerWert = (string)($eingaben['nummer'] ?? '');
|
||||
$abgeglichenWert = isset($eingaben['abgeglichen']);
|
||||
$ohneKategorieWert = isset($eingaben['bewusst_ohne_kategorie']);
|
||||
}
|
||||
|
||||
$eigenesKonto = (int)$konto['id'];
|
||||
|
|
@ -192,7 +194,9 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<input type="text" id="nummer" name="nummer" value="<?= View::h($nummerWert) ?>">
|
||||
</div>
|
||||
|
||||
<div class="feld ganz">
|
||||
<?php /* Abgeglichen und „Bewusst ohne Kategorie“ teilen sich eine Zeile –
|
||||
die Maske soll samt Splitzeilen auf einen Bildschirm passen. */ ?>
|
||||
<div class="feld">
|
||||
<label class="ankreuzfeld">
|
||||
<input type="checkbox" name="abgeglichen" value="1"
|
||||
<?= $abgeglichenWert ? 'checked' : '' ?>>
|
||||
|
|
@ -200,6 +204,15 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<div class="feld">
|
||||
<label class="ankreuzfeld"
|
||||
title="Für Buchungen, die absichtlich keine Kategorie bekommen – etwa Abschreibungen oder rein rechnerische Solarerträge. Sie erscheinen nicht unter den offenen Vorgängen im Cockpit, nicht im Monatsabschluss und machen das Konto in der Kontenübersicht nicht fett. Kategorie / Gegenkonto und Splitbuchung sind dann gesperrt.">
|
||||
<input type="checkbox" id="bewusst_ohne_kategorie" name="bewusst_ohne_kategorie" value="1"
|
||||
<?= $ohneKategorieWert ? 'checked' : '' ?>>
|
||||
Bewusst ohne Kategorie
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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.">
|
||||
|
|
|
|||
|
|
@ -325,7 +325,11 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
$beschriftung = match (true) {
|
||||
(int)$zeile['gesplittet'] === 1 => 'Splitbuchung',
|
||||
$gegenkonto !== '' => $gegenkonto,
|
||||
default => (string)($kategorieNamen[(int)$zeile['kategorie_id']] ?? ''),
|
||||
!empty($zeile['kategorie_id']) => (string)($kategorieNamen[(int)$zeile['kategorie_id']] ?? ''),
|
||||
// Absichtlich leer (#134) – sichtbar, damit es nicht wie
|
||||
// vergessen aussieht.
|
||||
!empty($zeile['bewusst_ohne_kategorie']) => 'bewusst ohne Kategorie',
|
||||
default => '',
|
||||
};
|
||||
|
||||
// Eine Splitbuchung nennt im Tooltip ihre Zeilen, alles andere
|
||||
|
|
|
|||
179
tests/59_BewusstOhneKategorieTest.php
Normal file
179
tests/59_BewusstOhneKategorieTest.php
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\Monatsabschluss;
|
||||
use Finanzmanager\Services\Cockpit;
|
||||
|
||||
/**
|
||||
* Bewusst ohne Kategorie buchen (#134).
|
||||
*
|
||||
* Abschreibungen oder rein rechnerische Solarerträge bekommen absichtlich
|
||||
* keine Kategorie. Geprüft wird, dass solche Buchungen weder als offener
|
||||
* Vorgang im Cockpit noch im Monatsabschluss, in der Kontenübersicht oder bei
|
||||
* der Regelanwendung als unkategorisiert gelten – und dass die Markierung
|
||||
* nur gilt, solange die Buchung tatsächlich nichts zugeordnet hat.
|
||||
*/
|
||||
class BewusstOhneKategorieTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 59: Bewusst ohne Kategorie';
|
||||
|
||||
private int $anlage = 0;
|
||||
private int $giro = 0;
|
||||
private int $kategorie = 0;
|
||||
private string $heute = '';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
$this->heute = date('Y-m-d');
|
||||
$this->anlage = Konto::anlegen([
|
||||
'name' => 'Photovoltaikanlage', 'kontoart' => 'bank', 'anfangssaldo' => 12000, 'aktiv' => 1,
|
||||
]);
|
||||
$this->giro = Konto::anlegen([
|
||||
'name' => 'Abschreibungsgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||
]);
|
||||
$this->kategorie = Kategorie::anlegen(['name' => 'Solarertrag', 'art' => 'einnahme']);
|
||||
|
||||
$this->wirdNichtAngemahnt();
|
||||
$this->zuordnungHebtMarkierungAuf();
|
||||
$this->schnellzuordnungHebtMarkierungAuf();
|
||||
$this->geleerteZuordnungBehaeltMarkierung();
|
||||
}
|
||||
|
||||
/** @return array<string,int> */
|
||||
private function vorgaenge(): array
|
||||
{
|
||||
return array_column(Cockpit::offeneVorgaenge($this->heute), 'anzahl', 'schluessel');
|
||||
}
|
||||
|
||||
private function monatsabschlussOffen(): int
|
||||
{
|
||||
return count(Monatsabschluss::pruefen((int)date('Y'), (int)date('n'))['ohne_kategorie']);
|
||||
}
|
||||
|
||||
private function wirdNichtAngemahnt(): void
|
||||
{
|
||||
$cockpit = $this->vorgaenge()['ohne_kategorie'] ?? 0;
|
||||
$abschluss = $this->monatsabschlussOffen();
|
||||
|
||||
$id = Buchung::anlegen([
|
||||
'konto_id' => $this->anlage, 'datum' => $this->heute, 'betrag' => -1200.00,
|
||||
'empfaenger' => 'Abschreibung', 'bewusst_ohne_kategorie' => true,
|
||||
]);
|
||||
|
||||
$this->assertEquals(1, (int)Buchung::find($id)['bewusst_ohne_kategorie'], 'Die Markierung wird gespeichert');
|
||||
$this->assertEquals(
|
||||
$cockpit,
|
||||
$this->vorgaenge()['ohne_kategorie'] ?? 0,
|
||||
'Das Cockpit führt sie nicht unter den offenen Vorgängen'
|
||||
);
|
||||
$this->assertEquals($abschluss, $this->monatsabschlussOffen(), 'Der Monatsabschluss mahnt sie nicht an');
|
||||
$this->assertEquals(
|
||||
0,
|
||||
Buchung::unkategorisierteJeKonto((int)date('Y'))[$this->anlage] ?? 0,
|
||||
'Das Konto wird in der Kontenübersicht nicht fett'
|
||||
);
|
||||
$this->assert(
|
||||
!in_array($id, array_map('intval', array_column(Buchung::ohneKategorie(), 'id')), true),
|
||||
'Die Regelanwendung lässt sie aus'
|
||||
);
|
||||
$this->assertEquals(
|
||||
-1200.0,
|
||||
round(Buchung::saldenJeKonto()[$this->anlage] ?? 0.0, 2),
|
||||
'Der Wert des Kontos sinkt trotzdem'
|
||||
);
|
||||
|
||||
// Zum Vergleich: Ohne den Schalter bleibt alles beim Alten.
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $this->anlage, 'datum' => $this->heute, 'betrag' => -10.00,
|
||||
'empfaenger' => 'Vergessen',
|
||||
]);
|
||||
$this->assertEquals(
|
||||
$cockpit + 1,
|
||||
$this->vorgaenge()['ohne_kategorie'] ?? 0,
|
||||
'Eine vergessene Kategorie meldet das Cockpit weiterhin'
|
||||
);
|
||||
$this->assertEquals(
|
||||
1,
|
||||
Buchung::unkategorisierteJeKonto((int)date('Y'))[$this->anlage] ?? 0,
|
||||
'Eine vergessene Kategorie macht das Konto weiterhin fett'
|
||||
);
|
||||
}
|
||||
|
||||
private function zuordnungHebtMarkierungAuf(): void
|
||||
{
|
||||
$id = Buchung::anlegen([
|
||||
'konto_id' => $this->giro, 'datum' => $this->heute, 'betrag' => 50.00,
|
||||
'empfaenger' => 'Mit Kategorie', 'kategorie_id' => $this->kategorie,
|
||||
'bewusst_ohne_kategorie' => true,
|
||||
]);
|
||||
$this->assertEquals(
|
||||
0,
|
||||
(int)Buchung::find($id)['bewusst_ohne_kategorie'],
|
||||
'Mit Kategorie entfällt die Markierung, die Kategorie bleibt'
|
||||
);
|
||||
$this->assertEquals($this->kategorie, (int)Buchung::find($id)['kategorie_id'], 'Die Kategorie geht nicht verloren');
|
||||
|
||||
$id = Buchung::anlegen(
|
||||
[
|
||||
'konto_id' => $this->giro, 'datum' => $this->heute, 'betrag' => 30.00,
|
||||
'empfaenger' => 'Gesplittet', 'bewusst_ohne_kategorie' => true,
|
||||
],
|
||||
[['kategorie_id' => $this->kategorie, 'betrag' => 30.00]]
|
||||
);
|
||||
$this->assertEquals(0, (int)Buchung::find($id)['bewusst_ohne_kategorie'], 'Eine Splitbuchung trägt keine Markierung');
|
||||
|
||||
$id = Buchung::anlegen([
|
||||
'konto_id' => $this->giro, 'datum' => $this->heute, 'betrag' => -20.00,
|
||||
'empfaenger' => 'Umbuchung', 'gegenkonto_id' => $this->anlage,
|
||||
'bewusst_ohne_kategorie' => true,
|
||||
]);
|
||||
$this->assertEquals(0, (int)Buchung::find($id)['bewusst_ohne_kategorie'], 'Eine Umbuchung trägt keine Markierung');
|
||||
}
|
||||
|
||||
private function schnellzuordnungHebtMarkierungAuf(): void
|
||||
{
|
||||
$id = Buchung::anlegen([
|
||||
'konto_id' => $this->giro, 'datum' => $this->heute, 'betrag' => 5.00,
|
||||
'empfaenger' => 'Später zugeordnet', 'bewusst_ohne_kategorie' => true,
|
||||
]);
|
||||
Buchung::zuordnen($id, $this->kategorie, null);
|
||||
$this->assertEquals(
|
||||
0,
|
||||
(int)Buchung::find($id)['bewusst_ohne_kategorie'],
|
||||
'Die Schnellzuordnung im Kontoblatt hebt die Markierung auf'
|
||||
);
|
||||
|
||||
$id = Buchung::anlegen([
|
||||
'konto_id' => $this->giro, 'datum' => $this->heute, 'betrag' => 5.00,
|
||||
'empfaenger' => 'Bearbeitet', 'bewusst_ohne_kategorie' => true,
|
||||
]);
|
||||
Buchung::speichern($id, [
|
||||
'konto_id' => $this->giro, 'datum' => $this->heute, 'betrag' => 5.00,
|
||||
'empfaenger' => 'Bearbeitet',
|
||||
]);
|
||||
$this->assertEquals(
|
||||
0,
|
||||
(int)Buchung::find($id)['bewusst_ohne_kategorie'],
|
||||
'Wer den Schalter in der Maske ausschaltet, hebt die Markierung auf'
|
||||
);
|
||||
}
|
||||
|
||||
private function geleerteZuordnungBehaeltMarkierung(): void
|
||||
{
|
||||
$id = Buchung::anlegen([
|
||||
'konto_id' => $this->giro, 'datum' => $this->heute, 'betrag' => 7.00,
|
||||
'empfaenger' => 'Bleibt leer', 'bewusst_ohne_kategorie' => true,
|
||||
]);
|
||||
Buchung::zuordnen($id, null, null);
|
||||
$this->assertEquals(
|
||||
1,
|
||||
(int)Buchung::find($id)['bewusst_ohne_kategorie'],
|
||||
'Eine geleerte Zuordnung lässt die Markierung stehen'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -618,6 +618,50 @@ test.describe('Buchungen', () => {
|
|||
.toHaveAttribute('title', /ohne Kategorie/)
|
||||
})
|
||||
|
||||
// Abschreibungen und rein rechnerische Erträge bekommen absichtlich keine
|
||||
// Kategorie (#134) – sie sollen nicht als offene Arbeit gelten.
|
||||
test('Bewusst ohne Kategorie: sperrt die Zuordnung und setzt das Konto nicht fett', async ({ page }) => {
|
||||
const anlage = `E2E Anlage ${MARKE}`
|
||||
const anlageId = await kontoAnlegen(page, anlage, '12.000,00')
|
||||
const heute = new Date().toISOString().slice(0, 10)
|
||||
|
||||
await page.goto(`/konten/${anlageId}/buchung`)
|
||||
const schalter = page.locator('#bewusst_ohne_kategorie')
|
||||
|
||||
// Mit Kategorie lässt sich der Schalter nicht setzen …
|
||||
await page.selectOption('#zuordnung', { label: KATEGORIE })
|
||||
await expect(schalter).toBeDisabled()
|
||||
await page.selectOption('#zuordnung', '')
|
||||
await expect(schalter).toBeEnabled()
|
||||
|
||||
// … und gesetzt sperrt er Kategorie und Splitzeilen.
|
||||
await schalter.check()
|
||||
await expect(page.locator('#zuordnung')).toBeDisabled()
|
||||
await expect(page.locator('select[name="split[0][zuordnung]"]')).toBeDisabled()
|
||||
await expect(page.locator('#klasse_id')).toBeEnabled()
|
||||
|
||||
await page.selectOption('#art', 'ausgabe')
|
||||
await page.fill('#datum', heute)
|
||||
await page.fill('#betrag', '1.200,00')
|
||||
await page.fill('#empfaenger', 'Abschreibung')
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
await page.goto(`/konten/${anlageId}`)
|
||||
await expect(page.locator('table.kontoblatt tbody tr', { hasText: 'Abschreibung' }))
|
||||
.toContainText('bewusst ohne Kategorie')
|
||||
|
||||
await page.goto('/konten')
|
||||
await expect(page.locator('tr', { hasText: anlage })).toHaveCount(1)
|
||||
await expect(page.locator('tr.unkategorisiert', { hasText: anlage })).toHaveCount(0)
|
||||
|
||||
// Beim Bearbeiten steht der Schalter wieder gesetzt da.
|
||||
await page.goto(`/konten/${anlageId}`)
|
||||
await page.locator('table.kontoblatt tbody tr', { hasText: 'Abschreibung' })
|
||||
.getByRole('link', { name: 'Abschreibung' }).click()
|
||||
await expect(page.locator('#bewusst_ohne_kategorie')).toBeChecked()
|
||||
})
|
||||
|
||||
test('Löschen fragt nach – trotz strenger Content-Security-Policy', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}`)
|
||||
await page.locator('tr', { hasText: 'Supermarkt' }).getByRole('link').first().click()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue