Fix #136: Buchungen ohne Bargeld-Tool-Abgleich ermöglichen

Neuer Schalter „Ohne Bargeld-Abgleich“ in der Buchungsmaske für
Sammelbuchungen und Differenzausgleiche. Solche Buchungen werden nicht
gegen das Bargeld-Tool geprüft, ein früherer Status wird gelöscht, und sie
erscheinen nicht mehr unter den offenen Vorgängen im Cockpit. Kontoblatt
und Buchungsbericht zeigen einen leeren grauen Kreis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-10-12 21:58:17 +02:00
parent c6d5babb41
commit a5989eb262
17 changed files with 341 additions and 21 deletions

View file

@ -8,6 +8,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
### Added
- Buchungsmaske: Schalter „Ohne Bargeld-Abgleich“ für Sammelbuchungen und
Differenzausgleiche; solche Buchungen werden nicht mit dem Bargeld-Tool abgeglichen und
erscheinen nicht mehr unter den offenen Vorgängen im Cockpit (#136)
- Depotbuchung erfassen: Bei Kauf und Verkauf rechnet sich aus zwei der drei Felder
Betrag, Stück und Kurs automatisch das dritte (Betrag = Stück × Kurs) (#129)
- Wertpapiere: Beim Buchen eines Wertpapiergeschäfts (Dialog im Kontoblatt und

View file

@ -1,5 +1,13 @@
# Änderungen
## Buchen ohne Bargeld-Abgleich (12.10.2026)
- Anlass (#136): Sammelbuchungen und Differenzausgleiche tragen eine Kategorie, die gegen das Bargeld-Tool geprüft wird, stehen dort aber absichtlich nicht – und wurden deshalb immer wieder als offener Vorgang angemahnt.
- Die Buchungsmaske hat den Schalter „Ohne Bargeld-Abgleich“ (`buchungen.ohne_bargeld_abgleich`, Migration `031_ohne_bargeld_abgleich.sql`). Er erscheint nur, wenn ein Bargeld-Tool eingerichtet ist; sonst trägt die Maske einen gesetzten Wert unsichtbar weiter.
- `BargeldAbgleich::pruefe()` prüft solche Buchungen nicht und löscht einen früheren Status. Sie erscheinen damit weder unter „Bargeld-Tool-Prüfung fehlgeschlagen“ noch unter „im Bargeld-Tool nicht gefunden“. Die Schnellzuordnung im Kontoblatt und die Regelanwendung lassen den Schalter stehen.
- Kontoblatt und Buchungsbericht zeigen in der Spalte „Bar“ einen leeren grauen Kreis mit dem Tooltip „Bewusst ohne Abgleich mit dem Bargeld-Tool“.
- Tests: `tests/65_OhneBargeldAbgleichTest.php`, `tests/e2e/specs/bargeld.spec.js`.
## Offene Vorgänge des Bargeld-Tools im Buchungsbericht (12.10.2026)
- Anlass (#131): Der Liste „Im Bargeld-Tool nicht gefunden“, aufgerufen aus *Offene Vorgänge* im Cockpit, fehlte die Spalte Kategorie/Gegenkonto. Gewünscht war statt einer eigenen Liste ein Filter im Buchungsbericht.

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

@ -286,9 +286,9 @@ Einstellungsbereich `bargeld`, der bewusst keinen Reiter im Einstellungsdialog h
dafür in `Einstellung::EIGENE_SEITEN` (Test 02 prüft, dass jeder Bereich erreichbar bleibt).
**Geprüft wird beim Zuweisen einer Kategorie** – aus der Schnellzuordnung des Kontoblatts
ebenso wie aus der Buchungsmaske. `Services\BargeldAbgleich::pruefe()` verlangt drei Dinge:
ebenso wie aus der Buchungsmaske. `Services\BargeldAbgleich::pruefe()` verlangt vier Dinge:
eine Kategorie an der Buchung (keine Umbuchung, keine Splitbuchung), diese Kategorie
vorgemerkt, eine Adresse konfiguriert. Fällt eines weg, wird der Status gelöscht – auch beim
vorgemerkt, die Buchung nicht als „Ohne Bargeld-Abgleich“ markiert, eine Adresse konfiguriert. Fällt eines weg, wird der Status gelöscht – auch beim
Umkategorisieren, sonst bliebe ein alter Haken an einer Buchung stehen, die niemand mehr
abgleicht. Gesucht wird über `Services\BargeldTool` mit dem Betrag der Buchung, rückwärts ab
ihrem Datum: Bargeld wird ausgegeben, bevor der Umsatz auf dem Konto ankommt, nie danach.
@ -300,6 +300,7 @@ Netz. In der schmalen Spalte zwischen Klasse und Betrag steht:
| Symbol | Status | Bedeutung |
|---|---|---|
| ● grau | `NULL` | Die Buchung wird nicht abgeglichen |
| ○ grau | `NULL` | Die Buchung ist bewusst ohne Bargeld-Abgleich gebucht (#136) |
| ✓ grün | `gefunden` | Im Bargeld-Tool steht eine Buchung mit demselben Betrag |
| ✗ rot | `fehlt` | Dort steht keine |
| ! | `fehler` | Der Abruf ist gescheitert |
@ -318,6 +319,14 @@ denselben Bericht mit dem Filter „nicht gefunden“. Der Bericht zeigt Konto,
Gegenkonto und Klasse; die Meldung der Gegenstelle steht im Tooltip der Spalte „Bar“. Eine
eigene Liste dafür gibt es nicht mehr (#131).
**Ohne Bargeld-Abgleich (#136).** Sammelbuchungen und Differenzausgleiche tragen eine
vorgemerkte Kategorie, stehen aber absichtlich nicht im Bargeld-Tool. Der Schalter **Ohne
Bargeld-Abgleich** in der Buchungsmaske setzt `buchungen.ohne_bargeld_abgleich`; die Buchung
wird dann nicht geprüft, ein früherer Status (`fehlt`, `fehler`) wird beim Speichern
gelöscht, und sie erscheint nicht mehr unter den offenen Vorgängen im Cockpit. Die
Schnellzuordnung lässt den Schalter stehen. Er erscheint nur, wenn eine Adresse hinterlegt
ist; ohne Bargeld-Tool trägt die Maske einen gesetzten Wert unsichtbar weiter.
**Ein Fehlschlag ist ein Ergebnis, kein Abbruch.** Weder ein abgeschaltetes Bargeld-Tool
noch ein Netzfehler darf verhindern, dass die Kategorie gespeichert wird; der Abruf bricht
nach acht Sekunden ab. Null Treffer sind ausdrücklich kein Fehler – ein 404 dagegen schon,

View file

@ -187,6 +187,8 @@ Prüfungen, die gerade nichts melden und daher nicht in der Liste stehen:
* **Im Bargeld-Tool nicht gefunden** – Buchungen mit `bargeld_status = 'fehlt'`. Führt in
denselben Buchungsbericht mit dem Filter „Bargeld-Tool: nicht gefunden“
(`Cockpit::bargeldBericht()`).
Buchungen mit dem Schalter „Ohne Bargeld-Abgleich“ werden nicht geprüft und erscheinen
deshalb in keinem der beiden Vorgänge (#136).
* **Monatsabschluss steht aus** – der Vorschlag aus `Monatsabschluss::naechsterOffener()`,
aber nur, wenn dieser Monat wirklich noch offen ist und Buchungen trägt.

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

@ -257,7 +257,8 @@ class BuchungenController extends BaseController
$frisch = Buchung::find((int)$buchung['id']);
$bargeldHtml = View::bargeldMarke(
$frisch['bargeld_status'] ?? null,
$frisch['bargeld_hinweis'] ?? null
$frisch['bargeld_hinweis'] ?? null,
!empty($frisch['ohne_bargeld_abgleich'])
);
}
@ -614,6 +615,9 @@ class BuchungenController extends BaseController
'kategorien' => Kategorie::baumFlach(),
'klassen' => Klasse::alle(),
'konten' => Konto::alle(),
// Der Schalter „Ohne Bargeld-Abgleich“ erscheint nur, wenn es ein
// Bargeld-Tool gibt (#136).
'bargeld' => BargeldAbgleich::eingerichtet(),
];
if ($fehler !== '') {
$daten['flash'] = [['typ' => 'fehler', 'meldung' => $fehler]];
@ -653,6 +657,7 @@ class BuchungenController extends BaseController
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'quelle' => 'manuell',
'bewusst_ohne_kategorie' => isset($_POST['bewusst_ohne_kategorie']),
'ohne_bargeld_abgleich' => isset($_POST['ohne_bargeld_abgleich']),
];
return [$daten, $this->splitzeilen()];

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
@ -806,6 +807,9 @@ class Buchung
'gegenkonto_id' => $gegenkonto,
'gesplittet' => $gesplittet ? 1 : 0,
'bewusst_ohne_kategorie' => $bewusstOhne ? 1 : 0,
// Ohne Bargeld-Abgleich (#136): Die Buchung wird nicht gegen das
// Bargeld-Tool geprüft und deshalb auch nicht als offen angemahnt.
'ohne_bargeld_abgleich' => !empty($daten['ohne_bargeld_abgleich']) ? 1 : 0,
'quelle' => isset(self::QUELLEN[(string)($daten['quelle'] ?? '')])
? (string)$daten['quelle'] : 'manuell',
'import_hash' => ($daten['import_hash'] ?? null) ?: null,

View file

@ -841,6 +841,7 @@ class Auswertung
'betrag' => round((float)$zeile['betrag'], 2),
'bargeld_status' => $zeile['bargeld_status'] ?? null,
'bargeld_hinweis' => $zeile['bargeld_hinweis'] ?? null,
'ohne_bargeld_abgleich' => !empty($zeile['ohne_bargeld_abgleich']),
];
}
@ -1193,13 +1194,15 @@ class Auswertung
$sql = 'SELECT * FROM (
SELECT b.id AS buchung_id, NULL AS split_id, b.datum, b.betrag, b.konto_id,
b.kategorie_id, b.klasse_id, b.gegenkonto_id, b.empfaenger,
b.buchungstext AS text, b.bargeld_status, b.bargeld_hinweis
b.buchungstext AS text, b.bargeld_status, b.bargeld_hinweis,
b.ohne_bargeld_abgleich
FROM buchungen b
WHERE b.benutzer_id = ? AND b.gesplittet = 0 AND b.datum BETWEEN ? AND ?
UNION ALL
SELECT b.id AS buchung_id, s.id AS split_id, b.datum, s.betrag, b.konto_id,
s.kategorie_id, s.klasse_id, s.gegenkonto_id, b.empfaenger,
s.notiz AS text, b.bargeld_status, b.bargeld_hinweis
s.notiz AS text, b.bargeld_status, b.bargeld_hinweis,
b.ohne_bargeld_abgleich
FROM buchung_splits s
JOIN buchungen b ON b.id = s.buchung_id
WHERE b.benutzer_id = ? AND b.gesplittet = 1 AND b.datum BETWEEN ? AND ?

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

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

@ -53,7 +53,7 @@ use Finanzmanager\View;
</td>
<td><?= View::h($buchung['klasse']) ?></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) ?>

View file

@ -11,12 +11,14 @@ 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.
@ -39,6 +41,7 @@ $buchungstextWert = (string)($buchung['buchungstext'] ?? '');
$nummerWert = (string)($buchung['nummer'] ?? '');
$abgeglichenWert = !empty($buchung['abgeglichen']);
$ohneKategorieWert = !empty($buchung['bewusst_ohne_kategorie']);
$ohneBargeldWert = !empty($buchung['ohne_bargeld_abgleich']);
if ($eingaben !== null) {
$art = ($eingaben['art'] ?? '') === 'einnahme' ? 'einnahme' : 'ausgabe';
@ -51,6 +54,7 @@ if ($eingaben !== null) {
$nummerWert = (string)($eingaben['nummer'] ?? '');
$abgeglichenWert = isset($eingaben['abgeglichen']);
$ohneKategorieWert = isset($eingaben['bewusst_ohne_kategorie']);
$ohneBargeldWert = isset($eingaben['ohne_bargeld_abgleich']);
}
$eigenesKonto = (int)$konto['id'];
@ -206,6 +210,22 @@ include __DIR__ . '/../layout/kopf.php';
</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

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

@ -196,4 +196,47 @@ test.describe('Bargeld-Tool', () => {
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()
})
})