Fix #134: Buchungen bewusst ohne Kategorie ermöglichen

Neuer Schalter „Bewusst ohne Kategorie“ in der Buchungsmaske für
Abschreibungen und rein rechnerische Solarerträge. 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 und werden von der Regelanwendung sowie dem automatischen
Zusammenführen zu Umbuchungen ausgelassen. Kommt eine Kategorie, ein
Gegenkonto oder eine Splitbuchung hinzu, entfällt die Markierung.

Migration 028_bewusst_ohne_kategorie.sql, Test 59 und E2E-Test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-10-12 16:22:22 +02:00
parent 84ee4d4d04
commit 275acd3285
18 changed files with 361 additions and 23 deletions

View file

@ -6,6 +6,12 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
## [Unreleased] ## [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 ### Changed
- Regeln: Die Splittabellen zeigen die Spalte „Anteil“ ohne Prozentzeichen und darunter die - Regeln: Die Splittabellen zeigen die Spalte „Anteil“ ohne Prozentzeichen und darunter die

View file

@ -1,5 +1,14 @@
# Änderungen # Ä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) ## 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.“. - 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.“.

View file

@ -13,8 +13,8 @@ Haushalt wird beides parallel genutzt.
**Alle sieben Phasen aus `PLAN.md` sind umgesetzt.** **Alle sieben Phasen aus `PLAN.md` sind umgesetzt.**
* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt * **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt
mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen; mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen
QIF-Import mit Stichtag. sowie Buchungen bewusst ohne Kategorie (etwa Abschreibungen); QIF-Import mit Stichtag.
* **Import und Export** – QIF und CSV in beide Richtungen: Stammdaten und Buchungen * **Import und Export** – QIF und CSV in beide Richtungen: Stammdaten und Buchungen
übernehmen, Buchungen nach Zeitraum, Konto, Kategorie und Klasse gefiltert ausgeben. übernehmen, Buchungen nach Zeitraum, Konto, Kategorie und Klasse gefiltert ausgeben.
Exportdateien werden nicht vorgehalten; das Housekeeping räumt hochgeladene Dateien weg. Exportdateien werden nicht vorgehalten; das Housekeeping räumt hochgeladene Dateien weg.

View file

@ -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 sie steht danach mit ihrem Betrag und ohne Kategorie im Kontoblatt, und die Gegenseite
einer Zeile mit Gegenkonto verschwindet von jenem Konto. 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 ## Salden
| Spalte | Bedeutung | | Spalte | Bedeutung |

View file

@ -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: Prüfungen, die gerade nichts melden und daher nicht in der Liste stehen:
* **ohne Kategorie** – Buchungen ohne Kategorie, die auch keine Umbuchung und nicht * **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. * **nicht abgeglichen** – Buchungen aktiver Konten bis heute ohne Abgleich-Kennzeichen.
* **Bargeld-Tool-Prüfung fehlgeschlagen** – Buchungen mit `bargeld_status = 'fehler'`, also * **Bargeld-Tool-Prüfung fehlgeschlagen** – Buchungen mit `bargeld_status = 'fehler'`, also
einem gescheiterten Abruf beim Bargeld-Tool (siehe `docs/buchungen.md`), nicht zu einem gescheiterten Abruf beim Bargeld-Tool (siehe `docs/buchungen.md`), nicht zu

View file

@ -93,7 +93,7 @@ Der Abschluss friert einen Monat ein (`monatsabschluesse`).
**Prüflauf** (`Monatsabschluss::pruefen()`) zeigt, was im Zeitraum noch offen ist: **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 * nicht abgeglichene Buchungen
* Splitbuchungen, deren Zeilen nicht auf den Buchungsbetrag aufgehen * Splitbuchungen, deren Zeilen nicht auf den Buchungsbetrag aufgehen

View file

@ -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 **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 liegen (`Buchung::unkategorisierteJeKonto()`, Zahl im Titel des Kontonamens). Gezählt wird
wie im Cockpit: keine Kategorie, kein Gegenkonto, nicht gesplittet – eine Umbuchung und eine 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`). abgerufene Umsätze selbst bucht (siehe `docs/banking.md`).
**Anfangssalden** stehen nicht im QIF-Export. Sie werden nach dem Import je Konto im **Anfangssalden** stehen nicht im QIF-Export. Sie werden nach dem Import je Konto im

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

View file

@ -15,6 +15,10 @@
* noch stehen mag, geht also nicht in die Buchung ein. Der Knopf * noch stehen mag, geht also nicht in die Buchung ein. Der Knopf
* „Splitbuchungen löschen“ leert alle Splitzeilen und gibt oben wieder frei. * „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 * Kategorie und Gegenkonto stehen in einem Feld, deshalb gibt es hier nichts
* mehr ein- und auszublenden; die Art bestimmt nur noch die Richtung. * mehr ein- und auszublenden; die Art bestimmt nur noch die Richtung.
*/ */
@ -65,6 +69,8 @@
.filter(Boolean); .filter(Boolean);
const splitkasten = document.getElementById('splitzeilen'); const splitkasten = document.getElementById('splitzeilen');
const leerenknopf = document.getElementById('splitsleeren'); const leerenknopf = document.getElementById('splitsleeren');
const ohneKategorie = document.getElementById('bewusst_ohne_kategorie');
const zuordnungsfeld = document.getElementById('zuordnung');
function splitfelder() { function splitfelder() {
return splitkasten ? Array.from(splitkasten.querySelectorAll('input, select')) : []; return splitkasten ? Array.from(splitkasten.querySelectorAll('input, select')) : [];
@ -84,6 +90,18 @@
direktfelder.forEach(function (feld) { feld.disabled = splitBelegt; }); direktfelder.forEach(function (feld) { feld.disabled = splitBelegt; });
splits.forEach(function (feld) { feld.disabled = direktBelegt; }); 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) { if (leerenknopf) {
leerenknopf.hidden = !splitBelegt; leerenknopf.hidden = !splitBelegt;
} }

View file

@ -650,6 +650,7 @@ class BuchungenController extends BaseController
'klasse_id' => $this->post('klasse_id'), 'klasse_id' => $this->post('klasse_id'),
'gegenkonto_id' => $zuordnung['gegenkonto_id'], 'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'quelle' => 'manuell', 'quelle' => 'manuell',
'bewusst_ohne_kategorie' => isset($_POST['bewusst_ohne_kategorie']),
]; ];
return [$daten, $this->splitzeilen()]; return [$daten, $this->splitzeilen()];

View file

@ -353,6 +353,7 @@ class Bankumsatz
WHERE b.benutzer_id = ? WHERE b.benutzer_id = ?
AND b.konto_id <> ? AND b.konto_id <> ?
AND b.gegenkonto_id IS NULL AND b.gesplittet = 0 AND b.kategorie_id IS NULL 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(b.betrag + ?) < 0.005
AND ABS(DATEDIFF(b.datum, ?)) <= ? AND ABS(DATEDIFF(b.datum, ?)) <= ?
AND u2.id <> ? AND u2.id <> ?
@ -401,7 +402,7 @@ class Bankumsatz
* Kontoblatt des Gegenkontos steht, und legt beide zusammen. * Kontoblatt des Gegenkontos steht, und legt beide zusammen.
* *
* Die Gegenseite ist entweder eine einzeln erfasste Buchung (ohne Kategorie und * 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 * noch kein Umsatz hängt. Betrag mit umgekehrtem Vorzeichen, Buchungstag
* höchstens `banking_abgleich_tage` entfernt; widersprechen sich die IBANs der * höchstens `banking_abgleich_tage` entfernt; widersprechen sich die IBANs der
* Umsätze, scheidet der Kandidat aus. Bestätigte IBANs gehen vor, dann gewinnt * 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 <> ? WHERE b.benutzer_id = ? AND b.konto_id = ? AND b.id <> ?
AND b.gesplittet = 0 AND b.gesplittet = 0
AND ( 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 = ? OR b.gegenkonto_id = ?
) )
AND ABS(b.betrag + ?) < 0.005 AND ABS(b.betrag + ?) < 0.005
@ -506,7 +508,7 @@ class Bankumsatz
* sie als zusammengehörig erkannte. * sie als zusammengehörig erkannte.
* *
* Kriterien wie bei gegenbuchung(); beide Buchungen ohne Kategorie und Klasse, * 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 * 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, * 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. * 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.gesplittet = 0 AND b.gesplittet = 0
AND a.kategorie_id IS NULL AND b.kategorie_id IS NULL 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.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(a.betrag) >= 0.005 AND ABS(a.betrag + b.betrag) < 0.005
AND ABS(DATEDIFF(a.datum, b.datum)) <= ? AND ABS(DATEDIFF(a.datum, b.datum)) <= ?
AND (SELECT COUNT(*) FROM bank_umsaetze x WHERE x.buchung_id = a.id) = 1 AND (SELECT COUNT(*) FROM bank_umsaetze x WHERE x.buchung_id = a.id) = 1

View file

@ -105,14 +105,16 @@ class Buchung
Database::execute( Database::execute(
'INSERT INTO buchungen 'INSERT INTO buchungen
(benutzer_id, konto_id, datum, valuta, betrag, empfaenger, iban, bankname, buchungstext, (benutzer_id, konto_id, datum, valuta, betrag, empfaenger, iban, bankname, buchungstext,
nummer, abgeglichen, kategorie_id, klasse_id, gegenkonto_id, gesplittet, quelle, import_hash) nummer, abgeglichen, kategorie_id, klasse_id, gegenkonto_id, gesplittet,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', bewusst_ohne_kategorie, quelle, import_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ [
Mandant::id(), $felder['konto_id'], $felder['datum'], $felder['valuta'], Mandant::id(), $felder['konto_id'], $felder['datum'], $felder['valuta'],
$felder['betrag'], $felder['empfaenger'], $felder['iban'], $felder['bankname'], $felder['betrag'], $felder['empfaenger'], $felder['iban'], $felder['bankname'],
$felder['buchungstext'], $felder['nummer'], $felder['buchungstext'], $felder['nummer'],
$felder['abgeglichen'], $felder['kategorie_id'], $felder['klasse_id'], $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(); $id = Database::lastInsertId();
@ -163,13 +165,14 @@ class Buchung
Database::execute( Database::execute(
'UPDATE buchungen SET konto_id = ?, datum = ?, valuta = ?, betrag = ?, empfaenger = ?, 'UPDATE buchungen SET konto_id = ?, datum = ?, valuta = ?, betrag = ?, empfaenger = ?,
buchungstext = ?, nummer = ?, abgeglichen = ?, kategorie_id = ?, klasse_id = ?, buchungstext = ?, nummer = ?, abgeglichen = ?, kategorie_id = ?, klasse_id = ?,
gegenkonto_id = ?, gesplittet = ? gegenkonto_id = ?, gesplittet = ?, bewusst_ohne_kategorie = ?
WHERE id = ? AND benutzer_id = ?', WHERE id = ? AND benutzer_id = ?',
[ [
$felder['konto_id'], $felder['datum'], $felder['valuta'], $felder['betrag'], $felder['konto_id'], $felder['datum'], $felder['valuta'], $felder['betrag'],
$felder['empfaenger'], $felder['buchungstext'], $felder['nummer'], $felder['empfaenger'], $felder['buchungstext'], $felder['nummer'],
$felder['abgeglichen'], $felder['kategorie_id'], $felder['klasse_id'], $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 * Beides zugleich gibt es nicht: Eine Buchung ist entweder kategorisiert
* oder eine Umbuchung. War sie gesplittet, fallen die Zeilen weg – die * oder eine Umbuchung. War sie gesplittet, fallen die Zeilen weg – die
* Zuordnung steckt danach wieder oben. * 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 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::transaktion(static function () use ($id, $kategorieId, $gegenkontoId): void {
Database::execute('DELETE FROM buchung_splits WHERE buchung_id = ?', [$id]); Database::execute('DELETE FROM buchung_splits WHERE buchung_id = ?', [$id]);
Database::execute( 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 = ?', 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 ( $sql = 'SELECT * FROM (
SELECT b.id, b.datum, b.valuta, b.betrag, b.empfaenger, b.buchungstext, b.nummer, 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.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.gegenkonto_id, b.konto_id, 0 AS gegensicht, NULL AS split_id,
b.iban, b.bankname b.iban, b.bankname
FROM buchungen b FROM buchungen b
@ -437,7 +445,7 @@ class Buchung
UNION ALL UNION ALL
SELECT b.id, b.datum, b.valuta, -b.betrag, b.empfaenger, b.buchungstext, b.nummer, 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.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, b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, NULL AS split_id,
\'\' AS iban, \'\' AS bankname \'\' AS iban, \'\' AS bankname
FROM buchungen b FROM buchungen b
@ -445,7 +453,7 @@ class Buchung
UNION ALL UNION ALL
SELECT b.id, b.datum, b.valuta, -s.betrag, b.empfaenger, s.notiz, b.nummer, 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, 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, b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, s.id AS split_id,
\'\' AS iban, \'\' AS bankname \'\' AS iban, \'\' AS bankname
FROM buchung_splits s FROM buchung_splits s
@ -635,7 +643,8 @@ class Buchung
* *
* Unkategorisiert heißt hier dasselbe wie im Cockpit: keine Kategorie, kein * Unkategorisiert heißt hier dasselbe wie im Cockpit: keine Kategorie, kein
* Gegenkonto (eine Umbuchung ist zugeordnet) und nicht gesplittet (dann * 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. * Die Gegenseite einer Umbuchung hat per Definition ein Gegenkonto.
* *
* @return array<int,int> konto_id => Anzahl * @return array<int,int> konto_id => Anzahl
@ -649,6 +658,7 @@ class Buchung
FROM buchungen FROM buchungen
WHERE benutzer_id = ? AND YEAR(datum) = ? WHERE benutzer_id = ? AND YEAR(datum) = ?
AND gesplittet = 0 AND kategorie_id IS NULL AND gegenkonto_id IS NULL AND gesplittet = 0 AND kategorie_id IS NULL AND gegenkonto_id IS NULL
AND bewusst_ohne_kategorie = 0
GROUP BY konto_id', GROUP BY konto_id',
[Mandant::id(), $jahr] [Mandant::id(), $jahr]
); );
@ -669,7 +679,8 @@ class Buchung
* *
* Kein Gegenkonto und nicht gesplittet ohne jede kategorisierte Zeile: * Kein Gegenkonto und nicht gesplittet ohne jede kategorisierte Zeile:
* dieselbe Definition wie bei {@see unkategorisierteJeKonto()}, nur als * 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>> * @return list<array<string,mixed>>
*/ */
@ -678,6 +689,7 @@ class Buchung
return Database::fetchAll( return Database::fetchAll(
'SELECT b.* FROM buchungen b 'SELECT b.* FROM buchungen b
WHERE b.benutzer_id = ? AND b.gegenkonto_id IS NULL WHERE b.benutzer_id = ? AND b.gegenkonto_id IS NULL
AND b.bewusst_ohne_kategorie = 0
AND ( AND (
(b.gesplittet = 0 AND b.kategorie_id IS NULL) (b.gesplittet = 0 AND b.kategorie_id IS NULL)
OR (b.gesplittet = 1 AND NOT EXISTS ( OR (b.gesplittet = 1 AND NOT EXISTS (
@ -779,6 +791,7 @@ class Buchung
$gegenkonto = self::id($daten['gegenkonto_id'] ?? null); $gegenkonto = self::id($daten['gegenkonto_id'] ?? null);
$kategorie = self::id($daten['kategorie_id'] ?? null); $kategorie = self::id($daten['kategorie_id'] ?? null);
$gesplittet = $splits !== []; $gesplittet = $splits !== [];
$bewusstOhne = !empty($daten['bewusst_ohne_kategorie']);
if ($gegenkonto !== null) { if ($gegenkonto !== null) {
self::pruefeGegenkonto($gegenkonto, $kontoId); self::pruefeGegenkonto($gegenkonto, $kontoId);
@ -790,6 +803,13 @@ class Buchung
self::pruefeSplits($splits, $betrag, $kontoId); 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'] ?? '')); $valuta = trim((string)($daten['valuta'] ?? ''));
return [ return [
@ -807,6 +827,7 @@ class Buchung
'klasse_id' => self::id($daten['klasse_id'] ?? null), 'klasse_id' => self::id($daten['klasse_id'] ?? null),
'gegenkonto_id' => $gegenkonto, 'gegenkonto_id' => $gegenkonto,
'gesplittet' => $gesplittet ? 1 : 0, 'gesplittet' => $gesplittet ? 1 : 0,
'bewusst_ohne_kategorie' => $bewusstOhne ? 1 : 0,
'quelle' => isset(self::QUELLEN[(string)($daten['quelle'] ?? '')]) 'quelle' => isset(self::QUELLEN[(string)($daten['quelle'] ?? '')])
? (string)$daten['quelle'] : 'manuell', ? (string)$daten['quelle'] : 'manuell',
'import_hash' => ($daten['import_hash'] ?? null) ?: null, 'import_hash' => ($daten['import_hash'] ?? null) ?: null,

View file

@ -106,6 +106,7 @@ class Monatsabschluss
JOIN konten k ON k.id = b.konto_id JOIN konten k ON k.id = b.konto_id
WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ? 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.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', ORDER BY b.datum, b.id',
$p $p
); );

View file

@ -245,7 +245,8 @@ class Cockpit
$ohneKategorie = (int)Database::fetchValue( $ohneKategorie = (int)Database::fetchValue(
'SELECT COUNT(*) FROM buchungen 'SELECT COUNT(*) FROM buchungen
WHERE benutzer_id = ? AND gesplittet = 0 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], [$benutzer],
0 0
); );

View file

@ -45,6 +45,7 @@ $klasseWert = (int)($buchung['klasse_id'] ?? 0);
$buchungstextWert = (string)($buchung['buchungstext'] ?? ''); $buchungstextWert = (string)($buchung['buchungstext'] ?? '');
$nummerWert = (string)($buchung['nummer'] ?? ''); $nummerWert = (string)($buchung['nummer'] ?? '');
$abgeglichenWert = !empty($buchung['abgeglichen']); $abgeglichenWert = !empty($buchung['abgeglichen']);
$ohneKategorieWert = !empty($buchung['bewusst_ohne_kategorie']);
if ($eingaben !== null) { if ($eingaben !== null) {
$art = ($eingaben['art'] ?? '') === 'einnahme' ? 'einnahme' : 'ausgabe'; $art = ($eingaben['art'] ?? '') === 'einnahme' ? 'einnahme' : 'ausgabe';
@ -56,6 +57,7 @@ if ($eingaben !== null) {
$buchungstextWert = (string)($eingaben['buchungstext'] ?? ''); $buchungstextWert = (string)($eingaben['buchungstext'] ?? '');
$nummerWert = (string)($eingaben['nummer'] ?? ''); $nummerWert = (string)($eingaben['nummer'] ?? '');
$abgeglichenWert = isset($eingaben['abgeglichen']); $abgeglichenWert = isset($eingaben['abgeglichen']);
$ohneKategorieWert = isset($eingaben['bewusst_ohne_kategorie']);
} }
$eigenesKonto = (int)$konto['id']; $eigenesKonto = (int)$konto['id'];
@ -192,7 +194,9 @@ include __DIR__ . '/../layout/kopf.php';
<input type="text" id="nummer" name="nummer" value="<?= View::h($nummerWert) ?>"> <input type="text" id="nummer" name="nummer" value="<?= View::h($nummerWert) ?>">
</div> </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"> <label class="ankreuzfeld">
<input type="checkbox" name="abgeglichen" value="1" <input type="checkbox" name="abgeglichen" value="1"
<?= $abgeglichenWert ? 'checked' : '' ?>> <?= $abgeglichenWert ? 'checked' : '' ?>>
@ -200,6 +204,15 @@ include __DIR__ . '/../layout/kopf.php';
</label> </label>
</div> </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"> <div class="feld ganz">
<label class="ankreuzfeld" <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."> 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

@ -325,7 +325,11 @@ include __DIR__ . '/../layout/kopf.php';
$beschriftung = match (true) { $beschriftung = match (true) {
(int)$zeile['gesplittet'] === 1 => 'Splitbuchung', (int)$zeile['gesplittet'] === 1 => 'Splitbuchung',
$gegenkonto !== '' => $gegenkonto, $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 // Eine Splitbuchung nennt im Tooltip ihre Zeilen, alles andere

View 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'
);
}
}

View file

@ -618,6 +618,50 @@ test.describe('Buchungen', () => {
.toHaveAttribute('title', /ohne Kategorie/) .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 }) => { test('Löschen fragt nach – trotz strenger Content-Security-Policy', async ({ page }) => {
await page.goto(`/konten/${giroId}`) await page.goto(`/konten/${giroId}`)
await page.locator('tr', { hasText: 'Supermarkt' }).getByRole('link').first().click() await page.locator('tr', { hasText: 'Supermarkt' }).getByRole('link').first().click()