Fix #21: Abgleich mit dem Bargeld-Tool

Beim Zuweisen einer Kategorie fragt der Finanzmanager beim Bargeld-Tool
(FSCash) nach, ob es zum Betrag dort schon eine Buchung gibt.

Eingerichtet wird das unter Verwaltung → Bargeld-Tool: Adresse,
Zugangsdaten (Passwort AES-verschlüsselt), Suchzeitraum und die Auswahl
der abzugleichenden Kategorien. Die Vormerkung steht als
`bargeld_abgleich` an der Kategorie selbst, nicht als Nummernliste in den
Einstellungen – so verschwindet sie mit der Kategorie.

Geprüft wird nur, was geprüft werden soll: Kategorie an der Buchung,
Kategorie vorgemerkt, Adresse konfiguriert. Fällt eines weg, verschwindet
der Status. Gesucht wird rückwärts ab dem Buchungsdatum, weil Bargeld
ausgegeben wird, bevor der Umsatz auf dem Konto ankommt.

Im Kontoblatt steht das Ergebnis zwischen Klasse und Betrag: grauer Kreis,
grüner Haken, rotes X, Ausrufezeichen. Das Ausrufezeichen behält die
Textfarbe des Farbschemas – der Fehler liegt beim Abruf, nicht bei der
Buchung. Ohne hinterlegte Adresse entfällt die Spalte.

Ein Fehlschlag ist ein Ergebnis, kein Abbruch: Die Kategorie wird auch
dann gespeichert, wenn das Bargeld-Tool schweigt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-19 07:06:20 +02:00
parent a5e50f8529
commit ddf0638693
20 changed files with 1824 additions and 9 deletions

View file

@ -1,5 +1,56 @@
# Änderungen
## Abgleich mit dem Bargeld-Tool (19.08.2026)
Bargeldausgaben werden in einem zweiten Programm erfasst; im Finanzmanager kommen sie als
Kartenumsatz oder Abhebung an. **Beim Zuweisen einer Kategorie fragt der Finanzmanager
jetzt dort nach**, ob es zum Betrag schon eine Buchung gibt – und zeigt die Antwort im
Kontoblatt.
Eingerichtet wird das unter **Verwaltung → Bargeld-Tool**, einer neuen Kachel: Adresse,
Benutzerkennung, Passwort (AES-verschlüsselt wie die FinTS-PIN), Suchzeitraum in Tagen und
die Auswahl der Kategorien, die überhaupt abgeglichen werden. Die Vormerkung steht als
`bargeld_abgleich` an der Kategorie selbst und nicht als Liste von Nummern in den
Einstellungen – so verschwindet sie mit der Kategorie, statt als tote Nummer liegenzubleiben.
Ein Knopf *Verbindung testen* sagt, ob Adresse und Zugangsdaten stimmen, ohne dass dafür
eine Buchung herhalten muss.
Geprüft wird nur, was geprüft werden soll: eine Kategorie an der Buchung (keine Umbuchung,
keine Splitbuchung), diese Kategorie vorgemerkt, eine Adresse konfiguriert. Fällt eines weg,
verschwindet der Status – auch beim Umkategorisieren und beim Abwählen einer Kategorie in der
Verwaltung, sonst bliebe ein alter Haken an einer Buchung stehen, die niemand mehr abgleicht.
Gesucht wird rückwärts ab dem Buchungsdatum (Vorgabe zehn Tage): Bargeld wird ausgegeben,
bevor der Umsatz auf dem Konto ankommt, nie danach.
Im Kontoblatt steht das Ergebnis in einer schmalen Spalte **zwischen Klasse und Betrag**:
grauer Kreis (wird nicht abgeglichen), grüner Haken (gefunden), rotes X (nicht gefunden),
Ausrufezeichen (Abruf gescheitert). Das Ausrufezeichen behält ausdrücklich die Textfarbe des
Farbschemas – der Fehler liegt beim Abruf, nicht bei der Buchung; rot oder grün wäre eine
Aussage über die Buchung, die niemand treffen kann. Der Tooltip nennt den Treffer bzw. die
Meldung der Gegenstelle. Ohne hinterlegte Adresse entfällt die Spalte ganz; sie zeigte sonst
in jeder Zeile denselben grauen Kreis. Der Status steht an der Buchung, das Kontoblatt geht
also nicht bei jedem Blättern erneut übers Netz.
Ein Fehlschlag ist ein Ergebnis, kein Abbruch: Weder ein abgeschaltetes Bargeld-Tool noch ein
Netzfehler verhindert, dass die Kategorie gespeichert wird. Der Abruf bricht nach acht
Sekunden ab. Null Treffer sind kein Fehler – ein 404 dagegen schon, denn er kann ebenso gut
heißen, dass es den Endpunkt nicht gibt.
Die Anforderung an die Gegenstelle (zwei lesende Aufrufe, Basic Auth, JSON) steht im Ticket
und in `docs/buchungen.md`. Nebenbei: Test 02 verlangte bisher für jeden
Einstellungsbereich einen Reiter im Dialog. Die Bargeld-Einstellungen haben stattdessen eine
eigene Seite – deshalb gibt es jetzt `Einstellung::EIGENE_SEITEN`, und der Test prüft die
Regel, die eigentlich gemeint war: dass jeder Bereich erreichbar bleibt.
Der E2E-Test geht denselben Weg im Browser – Kachel, Formular, Zuordnung im Kontoblatt –
gegen eine Adresse, die es nicht gibt: Genau daran zeigt sich, dass ein gescheiterter Abruf
die Zuordnung nicht aufhält, sondern nur das Ausrufezeichen setzt.
Test 34 prüft den Abruf gegen einen echten HTTP-Server (der eingebaute Server von PHP mit
einem Router, der sich wie das Bargeld-Tool verhält). Nachgebildete Antworten allein würden
genau das übergehen, woran ein Abruf scheitert: Basic Auth, HTTP-Status, ein Rumpf, der kein
JSON ist.
## Berichte: Tabellen so breit wie nötig, Zahlen rechtsbündig (18.08.2026)
Ein Bericht mit zwei oder vier Spalten wurde bisher über die gesamte Fensterbreite gezogen –

View file

@ -45,6 +45,11 @@ Haushalt wird beides parallel genutzt.
* **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und
Finanzkalender, Erinnerungen, Adressen, Verträge, Sparziele, Kredite mit Tilgungsplan.
* **Zahlungsverkehr** – Überweisungen und Terminüberweisungen über FinTS.
* **Abgleich mit dem Bargeld-Tool** – wer Bargeldausgaben in einem zweiten Programm
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*.
Einzelheiten je Modul in `docs/`, die Entwicklung in `Changes.md`.

View file

@ -201,3 +201,64 @@ verwirft sie stillschweigend, und gelöscht wird ohne Nachfrage. Stattdessen:
`public/static/app.js` hängt den Dialog daran. Ein E2E-Test besteht auf dem Dialog, damit
das nicht unbemerkt zurückfällt.
## Abgleich mit dem Bargeld-Tool
Bargeldausgaben werden in einem zweiten Programm (FSCash) erfasst; im Finanzmanager kommen
sie als Kartenumsatz oder Abhebung an. Wer einer solchen Buchung eine Kategorie zuweist,
soll sehen, ob sie dort schon steht.
**Eingerichtet wird das unter Verwaltung → Bargeld-Tool**: Adresse, Benutzerkennung,
Passwort (AES-verschlüsselt wie die FinTS-PIN), Suchzeitraum in Tagen und die Auswahl der
Kategorien, die überhaupt abgeglichen werden. Die Auswahl steht als `bargeld_abgleich` an
der Kategorie selbst und nicht als Liste von Nummern in den Einstellungen – so verschwindet
sie mit der Kategorie, statt als tote Nummer liegenzubleiben. Die übrigen Angaben liegen im
Einstellungsbereich `bargeld`, der bewusst keinen Reiter im Einstellungsdialog hat; er steht
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:
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
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.
Das Ergebnis steht als `bargeld_status`, `bargeld_hinweis` und `bargeld_geprueft_am` an der
Buchung. Das Kontoblatt liest es von dort und geht nicht bei jedem Blättern erneut übers
Netz. In der schmalen Spalte zwischen Klasse und Betrag steht:
| Symbol | Status | Bedeutung |
|---|---|---|
| ● grau | `NULL` | Die Buchung wird nicht abgeglichen |
| ✓ grün | `gefunden` | Im Bargeld-Tool steht eine Buchung mit demselben Betrag |
| ✗ rot | `fehlt` | Dort steht keine |
| ! | `fehler` | Der Abruf ist gescheitert |
Das Ausrufezeichen behält ausdrücklich die Textfarbe des Farbschemas: Der Fehler liegt beim
Abruf, nicht bei der Buchung – rot oder grün wäre eine Aussage über die Buchung, die niemand
treffen kann. Der Tooltip nennt den Treffer bzw. die Meldung der Gegenstelle. Ist keine
Adresse hinterlegt, entfällt die Spalte ganz; sie zeigte sonst in jeder Zeile denselben
grauen Kreis.
**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,
denn er kann ebenso gut heißen, dass es den Endpunkt nicht gibt.
### Die Schnittstelle
Zwei lesende Aufrufe, beschrieben im Ticket „automatischer Abgleich mit Bargeld-Tool“:
```
GET {adresse}/api/v1/buchungen?von=2026-08-09&bis=2026-08-19&betrag=-12.50&waehrung=EUR&limit=20
GET {adresse}/api/v1/status
```
Beide mit HTTP Basic Auth. Die Suche antwortet mit `{"anzahl": n, "buchungen": [...]}`; je
Buchung sind `id`, `datum` und `betrag` nötig, `zweck`, `empfaenger`, `waehrung`, `kategorie`
und `konto` gehen in den Tooltip. Der Statusaufruf ist freiwillig – kennt die Gegenstelle ihn
nicht (404), prüft der Verbindungstest über eine Suchabfrage.
Der Betrag geht vorzeichenbehaftet über die Leitung, so wie er im Finanzmanager steht:
Ausgaben negativ. Führt das Bargeld-Tool seine Beträge positiv, muss es umrechnen.

View file

@ -0,0 +1,33 @@
-- Abgleich mit dem Bargeld-Tool (FSCash).
--
-- Bargeldausgaben werden in einem zweiten Programm erfasst. Beim Zuweisen
-- einer Kategorie fragt der Finanzmanager dort nach, ob es zum Betrag eine
-- Buchung gibt, und merkt sich die Antwort an der Buchung – das Kontoblatt
-- zeigt sie als Symbol, ohne bei jedem Blättern erneut übers Netz zu gehen.
--
-- `bargeld_status` bleibt NULL, solange eine Buchung nichts mit einer zum
-- Abgleich vorgemerkten Kategorie zu tun hat; das ist der graue Kreis.
ALTER TABLE kategorien
-- Welche Kategorien überhaupt abgeglichen werden, steht an der Kategorie
-- selbst und nicht als Liste von IDs in den Einstellungen: So verschwindet
-- die Vormerkung mit der Kategorie, statt als tote Nummer liegenzubleiben.
ADD COLUMN IF NOT EXISTS bargeld_abgleich TINYINT(1) NOT NULL DEFAULT 0 AFTER aktiv;
ALTER TABLE buchungen
ADD COLUMN IF NOT EXISTS bargeld_status ENUM('gefunden','fehlt','fehler') NULL AFTER klasse_id,
-- Der Treffer bzw. die Fehlermeldung im Klartext – das Kontoblatt hängt
-- sie als Tooltip an das Symbol.
ADD COLUMN IF NOT EXISTS bargeld_hinweis VARCHAR(255) NULL AFTER bargeld_status,
ADD COLUMN IF NOT EXISTS bargeld_geprueft_am DATETIME NULL AFTER bargeld_hinweis;
-- Die Zugangsdaten des Bargeld-Tools. Sie stehen nicht im zentralen
-- Einstellungsdialog, sondern unter Verwaltung → Bargeld-Tool: Dort steht
-- auch die Auswahl der abzugleichenden Kategorien, und beides gehört
-- zusammen. Das Passwort ist AES-verschlüsselt (Services\Crypto).
INSERT INTO einstellungen (schluessel, wert, bereich) VALUES
('bargeld_url', '', 'bargeld'),
('bargeld_benutzer', '', 'bargeld'),
('bargeld_passwort', '', 'bargeld'),
('bargeld_tage', '10', 'bargeld')
ON DUPLICATE KEY UPDATE schluessel = schluessel;

View file

@ -132,6 +132,11 @@ $router->post('/verwaltung/kontengruppen/{id}',
$router->post('/verwaltung/kontengruppen/{id}/loeschen',
fn(array $p) => (new VerwaltungController())->kontengruppeLoeschen($p));
// Die Konfiguration des Bargeld-Tools samt Auswahl der abzugleichenden Kategorien.
$router->get('/verwaltung/bargeld', fn() => (new VerwaltungController())->bargeld());
$router->post('/verwaltung/bargeld', fn() => (new VerwaltungController())->bargeldSpeichern());
$router->post('/verwaltung/bargeld/test', fn() => (new VerwaltungController())->bargeldTest());
// Das Bankleitzahlen-Verzeichnis: nachschlagen und die Institutsliste einlesen.
$router->get('/verwaltung/bankinstitute', fn() => (new BankinstituteController())->index());
$router->post('/verwaltung/bankinstitute/import', fn() => (new BankinstituteController())->importieren());

View file

@ -1268,6 +1268,38 @@ table.kostentabelle select { max-width: 16rem; width: 100%; }
table.kostentabelle th[scope="row"] { font-weight: 400; text-align: left; }
table.kostentabelle .gruppenzeile th { padding-top: .7rem; font-weight: 600; }
/* Die Abgleichspalte zwischen Klasse und Betrag: so schmal wie ihr Zeichen.
Sie erscheint nur, wenn ein Bargeld-Tool eingerichtet ist. */
table.liste th.bargeldspalte,
table.liste td.bargeldspalte {
width: 1.8rem;
padding-left: .2rem;
padding-right: .2rem;
text-align: center;
}
table.liste th.bargeldspalte {
font-size: .78rem;
color: var(--text-gedaempft);
}
.bargeldmarke {
font-size: .95rem;
line-height: 1;
cursor: help;
}
.bargeld-gefunden { color: var(--positiv); }
.bargeld-fehlt { color: var(--negativ); }
/* Der graue Kreis ist eine Aussage über nichts und tritt entsprechend zurück. */
.bargeld-neutral { color: var(--text-gedaempft); font-size: .6rem; }
/* Das Ausrufezeichen behält ausdrücklich die Textfarbe des Farbschemas: Der
Fehler liegt beim Abruf, nicht bei der Buchung – rot oder grün wäre eine
Aussage über die Buchung, die niemand treffen kann. */
.bargeld-fehler { color: inherit; font-weight: 700; }
/* ── Formulare ───────────────────────────────────────────────────────────── */
.formular {
@ -1398,6 +1430,16 @@ table.kostentabelle .gruppenzeile th { padding-top: .7rem; font-weight: 600; }
.auswahlliste .nebentext { margin-left: .35rem; }
/* Kategorien in der Auswahlliste stehen als Baum. Eingerückt wird über
data-ebene wie in der Kategorienverwaltung – ein style-Attribut verwirft die
Content-Security-Policy (style-src 'self'). */
.auswahlliste .baumwahl[data-ebene="1"] { padding-left: 1.2rem; }
.auswahlliste .baumwahl[data-ebene="2"] { padding-left: 2.4rem; }
.auswahlliste .baumwahl[data-ebene="3"] { padding-left: 3.6rem; }
.auswahlliste .baumwahl[data-ebene="4"] { padding-left: 4.8rem; }
.auswahlliste .baumwahl[data-ebene="5"] { padding-left: 6rem; }
.auswahlliste .baumwahl[data-ebene="6"] { padding-left: 7.2rem; }
.formular input[type="text"],
.formular input[type="password"],
.formular input[type="number"],
@ -1423,6 +1465,8 @@ table.kostentabelle .gruppenzeile th { padding-top: .7rem; font-weight: 600; }
font-size: .95rem;
}
.feldhinweis.warnend { color: var(--warnung); }
.feldhinweis {
font-size: .85rem;
color: var(--text-gedaempft);

View file

@ -11,6 +11,7 @@ use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\BargeldAbgleich;
use Finanzmanager\Services\Wertpapiergeschaeft;
use Finanzmanager\Services\Wertpapierkosten;
use Finanzmanager\View;
@ -78,6 +79,9 @@ class BuchungenController extends BaseController
'splits' => Buchung::splitsJeBuchung($gesplittete),
// Für den Wertpapierdialog, den die Zuordnung auf ein Depot öffnet.
'wertpapiere' => Wertpapier::alle(true),
// Die Abgleichspalte erscheint nur, wenn es ein Bargeld-Tool gibt –
// sonst stünde dort in jeder Zeile derselbe graue Kreis.
'bargeld' => BargeldAbgleich::eingerichtet(),
]);
}
@ -120,7 +124,9 @@ class BuchungenController extends BaseController
return;
}
View::redirect('/konten/' . $kontoId, 'Buchung angelegt.');
$abgleich = BargeldAbgleich::pruefe($id);
View::redirect('/konten/' . $kontoId, 'Buchung angelegt.' . BargeldAbgleich::rueckmeldung($abgleich));
}
/** @param array<string,string> $params */
@ -166,7 +172,12 @@ class BuchungenController extends BaseController
return;
}
View::redirect('/konten/' . $buchung['konto_id'], 'Buchung gespeichert.');
$abgleich = BargeldAbgleich::pruefe($id);
View::redirect(
'/konten/' . $buchung['konto_id'],
'Buchung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich)
);
}
/** @param array<string,string> $params */
@ -224,7 +235,12 @@ class BuchungenController extends BaseController
return;
}
View::redirect($ziel, 'Zuordnung gespeichert.');
// Die zugewiesene Kategorie kann eine sein, die gegen das Bargeld-Tool
// zu prüfen ist. Das Ergebnis landet an der Buchung und steht danach
// als Symbol im Kontoblatt.
$abgleich = BargeldAbgleich::pruefe((int)$buchung['id']);
View::redirect($ziel, 'Zuordnung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich));
}
/**
@ -286,6 +302,11 @@ class BuchungenController extends BaseController
return;
}
// Eine Splitbuchung trägt keine eigene Kategorie mehr; ein früherer
// Abgleichstatus wäre danach eine Aussage über eine Zuordnung, die es
// nicht mehr gibt. `pruefe()` räumt ihn weg.
BargeldAbgleich::pruefe((int)$buchung['id']);
View::redirect($ziel, $loeschen ? 'Splitbuchung gelöscht.' : 'Splitbuchung gespeichert.');
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Bankinstitut;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Erinnerung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
@ -12,6 +13,9 @@ use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Kontengruppe;
use Finanzmanager\Models\RegelmaessigeBuchung;
use Finanzmanager\Models\Vertrag;
use Finanzmanager\Services\BargeldAbgleich;
use Finanzmanager\Services\BargeldTool;
use Finanzmanager\Services\Crypto;
use Finanzmanager\View;
/**
@ -39,7 +43,9 @@ class VerwaltungController extends BaseController
'erinnerungen' => Erinnerung::anzahlOffen(),
'vertraege' => count(Vertrag::alle(true)),
'fristen' => count(Vertrag::fristenLaufenAb(90)),
'bargeld' => count(Kategorie::bargeldAuswahl()),
],
'bargeldAktiv' => BargeldAbgleich::eingerichtet(),
]);
}
@ -190,4 +196,79 @@ class VerwaltungController extends BaseController
'Kontengruppe gelöscht. Die Konten stehen jetzt unter „' . Kontengruppe::OHNE_GRUPPE . '“.'
);
}
/**
* Die Konfiguration des Bargeld-Tools: Adresse, Zugangsdaten und die
* Auswahl der Kategorien, die damit abgeglichen werden.
*
* Diese Einstellungen stehen bewusst nicht im zentralen
* Einstellungsdialog: Ihr größerer Teil ist die Kategorienauswahl, und die
* gehört in die Verwaltung, wo die Kategorien selbst gepflegt werden.
*/
public function bargeld(): void
{
$this->verlangeAnmeldung();
$this->render('verwaltung/bargeld.php', [
'titel' => 'Bargeld-Tool',
'url' => (string)Einstellung::get('bargeld_url', ''),
// Nicht 'benutzer': So heißt im Layout der angemeldete Mensch.
'benutzerkennung' => (string)Einstellung::get('bargeld_benutzer', ''),
'hatPasswort' => (string)Einstellung::get('bargeld_passwort', '') !== '',
'tage' => BargeldAbgleich::fensterTage(),
'kategorien' => Kategorie::baumFlach(),
'auswahl' => Kategorie::bargeldAuswahl(),
'schluessel' => Crypto::einsatzbereit(),
]);
}
public function bargeldSpeichern(): void
{
$this->verlangeAnmeldung();
$url = trim($this->post('bargeld_url'));
if ($url !== '' && !preg_match('#^https?://#i', $url)) {
View::redirect('/verwaltung/bargeld', 'Die Adresse muss mit http:// oder https:// beginnen.', 'fehler');
return;
}
Einstellung::set('bargeld_url', rtrim($url, '/'), 'bargeld');
Einstellung::set('bargeld_benutzer', trim($this->post('bargeld_benutzer')), 'bargeld');
// Wie bei der FinTS-PIN: Ein leeres Feld heißt „unverändert“, sonst
// ließe sich das Passwort nie speichern, ohne es erneut einzutippen.
$passwort = $this->post('bargeld_passwort');
if ($passwort !== '') {
Einstellung::set('bargeld_passwort', Crypto::verschluessle($passwort), 'bargeld');
}
if ($this->post('passwort_loeschen') !== '') {
Einstellung::set('bargeld_passwort', '', 'bargeld');
}
$tage = (int)$this->post('bargeld_tage', (string)BargeldAbgleich::TAGE_STANDARD);
Einstellung::set('bargeld_tage', (string)($tage > 0 ? min($tage, 365) : BargeldAbgleich::TAGE_STANDARD), 'bargeld');
$auswahl = $this->postIds('kategorien');
Kategorie::bargeldAuswahlSetzen($auswahl);
// Was nicht mehr abgeglichen wird, darf auch keinen Haken mehr tragen.
BargeldAbgleich::statusAufraeumen($auswahl);
View::redirect('/verwaltung/bargeld', 'Einstellungen zum Bargeld-Tool gespeichert.');
}
/** Der Verbindungstest – erreichbar? Zugangsdaten richtig? */
public function bargeldTest(): void
{
$this->verlangeAnmeldung();
$zugang = BargeldTool::ausEinstellungen();
if ($zugang === null) {
View::redirect('/verwaltung/bargeld', 'Es ist keine Adresse hinterlegt.', 'fehler');
return;
}
$ergebnis = $zugang->test();
View::redirect('/verwaltung/bargeld', $ergebnis['meldung'], $ergebnis['erfolg'] ? 'erfolg' : 'fehler');
}
}

View file

@ -280,18 +280,21 @@ 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.gegenkonto_id, b.konto_id, 0 AS gegensicht, NULL AS split_id
FROM buchungen b
WHERE b.konto_id = ?
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,
b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, NULL AS split_id
FROM buchungen b
WHERE b.gegenkonto_id = ? AND b.gesplittet = 0
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,
b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, s.id AS split_id
FROM buchung_splits s
JOIN buchungen b ON b.id = s.buchung_id

View file

@ -23,6 +23,18 @@ class Einstellung
'diagnose' => 'Diagnose',
];
/**
* Bereiche, die keinen Reiter im Dialog haben, weil sie eine eigene Seite
* bekommen haben – dort steht mehr als nur ein paar Felder.
*
* Die Zuordnung ist nicht bloß Dokumentation: Test 02 prüft, dass jeder in
* der Datenbank benutzte Bereich entweder einen Reiter oder hier eine Seite
* hat. Sonst lägen Werte in der Datenbank, an die niemand mehr herankommt.
*/
public const EIGENE_SEITEN = [
'bargeld' => '/verwaltung/bargeld',
];
private static ?array $cache = null;
public static function get(string $schluessel, ?string $standard = null): ?string

View file

@ -37,6 +37,46 @@ class Kategorie
return Database::fetchAll($sql . ' ORDER BY pfad', $params);
}
/**
* Die Kategorien, die mit dem Bargeld-Tool abgeglichen werden.
*
* @return list<int>
*/
public static function bargeldAuswahl(): array
{
return array_map('intval', array_column(Database::fetchAll(
'SELECT id FROM kategorien WHERE benutzer_id = ? AND bargeld_abgleich = 1 ORDER BY pfad',
[Mandant::id()]
), 'id'));
}
/**
* Die Auswahl der abzugleichenden Kategorien ersetzen – die Liste ist
* vollständig, was fehlt, wird abgewählt.
*
* @param list<int> $ids
*/
public static function bargeldAuswahlSetzen(array $ids): void
{
$benutzer = Mandant::id();
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
Database::transaktion(static function () use ($ids, $benutzer): void {
Database::execute(
'UPDATE kategorien SET bargeld_abgleich = 0 WHERE benutzer_id = ?',
[$benutzer]
);
if ($ids === []) {
return;
}
Database::execute(
'UPDATE kategorien SET bargeld_abgleich = 1
WHERE benutzer_id = ? AND id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')',
array_merge([$benutzer], $ids)
);
});
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(

View file

@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Kategorie;
/**
* Der Abgleich einer Buchung gegen das Bargeld-Tool.
*
* Bargeldausgaben werden dort erfasst, im Finanzmanager kommen sie als
* Kartenumsatz oder Abhebung an. Wer einer solchen Buchung eine Kategorie
* zuweist, will wissen, ob sie im Bargeld-Tool schon steht – genau das prüft
* diese Klasse und schreibt das Ergebnis an die Buchung. Das Kontoblatt liest
* es von dort und geht nicht bei jedem Blättern erneut übers Netz.
*
* Geprüft wird nur, was geprüft werden soll:
*
* 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.
*
* Fällt eine der drei 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.
*
* Ein Fehlschlag ist ein Ergebnis, kein Abbruch: Weder ein abgeschaltetes
* Bargeld-Tool noch ein Netzfehler darf verhindern, dass die Kategorie
* gespeichert wird.
*/
class BargeldAbgleich
{
public const GEFUNDEN = 'gefunden';
public const FEHLT = 'fehlt';
public const FEHLER = 'fehler';
/** Standardbreite des Suchfensters in Tagen, wenn nichts eingestellt ist. */
public const TAGE_STANDARD = 10;
/** Ist überhaupt ein Bargeld-Tool hinterlegt? */
public static function eingerichtet(): bool
{
return trim((string)Einstellung::get('bargeld_url', '')) !== '';
}
/** Das Suchfenster in Tagen, rückwärts ab dem Buchungsdatum. */
public static function fensterTage(): int
{
$tage = Einstellung::getInt('bargeld_tage', self::TAGE_STANDARD);
return $tage > 0 ? min($tage, 365) : self::TAGE_STANDARD;
}
/**
* Eine Buchung prüfen und das Ergebnis an ihr festhalten.
*
* @return string|null der gesetzte Status, oder null, wenn die Buchung
* nichts mit dem Bargeld-Tool zu tun hat
*/
public static function pruefe(int $buchungId): ?string
{
$buchung = Buchung::find($buchungId);
if ($buchung === null || !self::istZuPruefen($buchung)) {
self::merke($buchungId, null, null);
return null;
}
$zugang = BargeldTool::ausEinstellungen();
if ($zugang === null) {
self::merke($buchungId, null, null);
return null;
}
// Rückwärts ab dem Buchungsdatum: Bargeld wird ausgegeben, bevor der
// Umsatz auf dem Konto ankommt – nie danach.
$bis = substr((string)$buchung['datum'], 0, 10);
$von = date('Y-m-d', strtotime($bis . ' -' . self::fensterTage() . ' days'));
try {
$ergebnis = $zugang->suche((float)$buchung['betrag'], $von, $bis);
} catch (\Throwable $e) {
self::merke($buchungId, self::FEHLER, $e->getMessage());
return self::FEHLER;
}
$status = match (true) {
$ergebnis['fehler'] => self::FEHLER,
$ergebnis['gefunden'] => self::GEFUNDEN,
default => self::FEHLT,
};
self::merke($buchungId, $status, $ergebnis['meldung']);
return $status;
}
/**
* Die Rückmeldung an den Benutzer, angehängt an die Meldung der Zuordnung.
* Ohne Abgleich bleibt sie leer – wer kein Bargeld-Tool benutzt, soll
* davon auch nichts lesen.
*/
public static function rueckmeldung(?string $status): string
{
return match ($status) {
self::GEFUNDEN => ' Im Bargeld-Tool gefunden.',
self::FEHLT => ' Im Bargeld-Tool nicht gefunden.',
self::FEHLER => ' Der Abgleich mit dem Bargeld-Tool ist fehlgeschlagen.',
default => '',
};
}
/**
* Alle Buchungen einer Kategorie zurücksetzen – nötig, wenn eine Kategorie
* aus der Auswahl genommen wird: Ihre Haken und Kreuze wären danach
* Aussagen über einen Abgleich, den es nicht mehr gibt.
*
* @param list<int> $kategorieIds Kategorien, die weiter abgeglichen werden
*/
public static function statusAufraeumen(array $kategorieIds): void
{
$sql = 'UPDATE buchungen SET bargeld_status = NULL, bargeld_hinweis = NULL,
bargeld_geprueft_am = NULL
WHERE benutzer_id = ? AND bargeld_status IS NOT NULL';
$params = [Mandant::id()];
if ($kategorieIds !== []) {
$sql .= ' AND (kategorie_id IS NULL OR kategorie_id NOT IN ('
. implode(',', array_fill(0, count($kategorieIds), '?')) . '))';
$params = array_merge($params, $kategorieIds);
}
Database::execute($sql, $params);
}
/** @param array<string,mixed> $buchung */
private static function istZuPruefen(array $buchung): bool
{
if ((int)($buchung['gesplittet'] ?? 0) === 1 || !empty($buchung['gegenkonto_id'])) {
return false;
}
$kategorieId = (int)($buchung['kategorie_id'] ?? 0);
if ($kategorieId === 0) {
return false;
}
$kategorie = Kategorie::find($kategorieId);
return $kategorie !== null && (int)($kategorie['bargeld_abgleich'] ?? 0) === 1;
}
private static function merke(int $buchungId, ?string $status, ?string $hinweis): void
{
Database::execute(
'UPDATE buchungen SET bargeld_status = ?, bargeld_hinweis = ?, bargeld_geprueft_am = ?
WHERE id = ? AND benutzer_id = ?',
[
$status,
$hinweis === null ? null : mb_substr($hinweis, 0, 255),
$status === null ? null : date('Y-m-d H:i:s'),
$buchungId,
Mandant::id(),
]
);
}
}

View file

@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Models\Einstellung;
/**
* Der Zugang zum Bargeld-Tool (FSCash), in dem Bargeldausgaben erfasst werden.
*
* Die Schnittstelle ist strikt lesend und besteht aus zwei Aufrufen: einer
* Buchungssuche über Zeitraum und Betrag und einem Statusaufruf für den
* Verbindungstest. Beschrieben ist sie in `docs/bargeld-abgleich.md`; die
* abgestimmte Fassung steht im Ticket „automatischer Abgleich mit
* Bargeld-Tool“.
*
* Diese Klasse macht nichts als fragen und antworten. Ob überhaupt gefragt
* wird und was mit der Antwort geschieht, entscheidet `BargeldAbgleich`.
*
* Zwei Dinge sind hier wichtig:
*
* - **Null Treffer sind kein Fehler.** Die Suche antwortet mit HTTP 200 und
* leerer Liste; ein 404 gilt als Abrufproblem, weil er ebenso gut heißen
* kann, dass es den Endpunkt nicht gibt.
* - **Nichts fliegt nach oben.** Ein Netzfehler darf das Zuweisen einer
* Kategorie nicht scheitern lassen; jeder Weg endet in einem Ergebnis mit
* Status `fehler` und lesbarer Meldung.
*
* Die Auswertung der Antwort steht in statischen Methoden ohne Netzzugriff –
* nur so lässt sie sich prüfen, ohne ein laufendes Bargeld-Tool zu haben.
*/
class BargeldTool
{
public const PFAD_BUCHUNGEN = '/api/v1/buchungen';
public const PFAD_STATUS = '/api/v1/status';
/** Zeitgrenze einer Abfrage in Sekunden – die Zuordnung wartet darauf. */
private const ZEITGRENZE = 8;
/** Höchstzahl der Treffer, die die Gegenstelle liefern soll. */
private const GRENZE = 20;
public function __construct(
private string $basisUrl,
private string $benutzer = '',
private string $passwort = ''
) {
}
/**
* Der konfigurierte Zugang – oder null, wenn keine Adresse hinterlegt ist.
* Ohne Adresse findet kein Abgleich statt; das ist der Normalfall für
* alle, die kein Bargeld-Tool benutzen.
*/
public static function ausEinstellungen(): ?self
{
$url = trim((string)Einstellung::get('bargeld_url', ''));
if ($url === '') {
return null;
}
$passwort = (string)Einstellung::get('bargeld_passwort', '');
return new self(
$url,
trim((string)Einstellung::get('bargeld_benutzer', '')),
$passwort === '' ? '' : Crypto::entschluessle($passwort)
);
}
/**
* Eine Buchung mit diesem Betrag im Zeitraum – beide Tage zählen mit.
*
* @return array{gefunden: bool, treffer: array<string,mixed>|null,
* anzahl: int, meldung: string, fehler: bool}
*/
public function suche(float $betrag, string $von, string $bis): array
{
$antwort = $this->hole(self::PFAD_BUCHUNGEN, [
'von' => $von,
'bis' => $bis,
'betrag' => number_format($betrag, 2, '.', ''),
'waehrung' => (string)Einstellung::get('waehrung', 'EUR'),
'limit' => (string)self::GRENZE,
]);
if ($antwort['fehler'] !== '') {
return self::misserfolg($antwort['fehler']);
}
return self::trefferAus($antwort['rumpf']);
}
/**
* Der Verbindungstest der Konfigurationsseite. Er fragt den Statusaufruf
* ab; kennt die Gegenstelle ihn nicht, gilt eine beantwortete Suchabfrage
* als Beweis genug – der Statusaufruf ist laut Anforderung freiwillig.
*
* @return array{erfolg: bool, meldung: string}
*/
public function test(): array
{
$antwort = $this->hole(self::PFAD_STATUS, []);
if ($antwort['fehler'] === '') {
$daten = json_decode($antwort['rumpf'], true);
$name = is_array($daten) ? trim((string)($daten['name'] ?? '')) : '';
$fassung = is_array($daten) ? trim((string)($daten['version'] ?? '')) : '';
return [
'erfolg' => true,
'meldung' => 'Verbindung steht'
. ($name === '' ? '' : ' – ' . $name)
. ($fassung === '' ? '' : ' ' . $fassung) . '.',
];
}
// Kein Statusaufruf vorhanden: dann eben über die Suche, die es geben
// muss. Alles andere (401, Verbindungsfehler) ist ein echtes Problem
// und wird unverändert gemeldet.
if ($antwort['status'] !== 404) {
return ['erfolg' => false, 'meldung' => $antwort['fehler']];
}
$heute = date('Y-m-d');
$probe = $this->hole(self::PFAD_BUCHUNGEN, [
'von' => $heute,
'bis' => $heute,
'betrag' => '0.00',
'limit' => '1',
]);
return $probe['fehler'] === ''
? ['erfolg' => true, 'meldung' => 'Verbindung steht (ohne Statusaufruf, über die Buchungssuche geprüft).']
: ['erfolg' => false, 'meldung' => $probe['fehler']];
}
/**
* Die Antwort der Buchungssuche auswerten. Ohne Netz, damit prüfbar.
*
* @return array{gefunden: bool, treffer: array<string,mixed>|null,
* anzahl: int, meldung: string, fehler: bool}
*/
public static function trefferAus(string $rumpf): array
{
$daten = json_decode($rumpf, true);
if (!is_array($daten)) {
return self::misserfolg('Die Antwort des Bargeld-Tools ist kein JSON.');
}
$buchungen = $daten['buchungen'] ?? null;
if (!is_array($buchungen)) {
return self::misserfolg('Die Antwort des Bargeld-Tools enthält kein Feld „buchungen“.');
}
// Die Zahl im Feld „anzahl“ gilt: Sie nennt alle Treffer des
// Zeitraums, auch die, die das Limit abgeschnitten hat.
$anzahl = array_key_exists('anzahl', $daten) ? (int)$daten['anzahl'] : count($buchungen);
$erster = null;
foreach ($buchungen as $eintrag) {
if (is_array($eintrag)) {
$erster = $eintrag;
break;
}
}
if ($erster === null) {
return [
'gefunden' => false,
'treffer' => null,
'anzahl' => 0,
'meldung' => 'Keine Buchung mit diesem Betrag im Bargeld-Tool.',
'fehler' => false,
];
}
return [
'gefunden' => true,
'treffer' => $erster,
'anzahl' => max(1, $anzahl),
'meldung' => self::trefferText($erster, max(1, $anzahl)),
'fehler' => false,
];
}
/** Der Treffer als eine Zeile für den Tooltip im Kontoblatt. */
private static function trefferText(array $treffer, int $anzahl): string
{
$teile = [];
$datum = trim((string)($treffer['datum'] ?? ''));
if ($datum !== '') {
$zeit = strtotime($datum);
$teile[] = $zeit === false ? $datum : date('d.m.Y', $zeit);
}
if (isset($treffer['betrag']) && is_numeric($treffer['betrag'])) {
$teile[] = number_format((float)$treffer['betrag'], 2, ',', '.') . ' '
. trim((string)($treffer['waehrung'] ?? 'EUR'));
}
foreach (['zweck', 'empfaenger'] as $feld) {
$wert = trim((string)($treffer[$feld] ?? ''));
if ($wert !== '') {
$teile[] = $wert;
break;
}
}
$text = 'Im Bargeld-Tool gefunden: ' . ($teile === [] ? 'Buchung ohne Angaben' : implode(' · ', $teile));
return $anzahl > 1 ? $text . ' (' . $anzahl . ' Treffer)' : $text;
}
/**
* Ein Aufruf der Schnittstelle.
*
* @param array<string,string> $parameter
* @return array{rumpf: string, status: int, fehler: string}
*/
private function hole(string $pfad, array $parameter): array
{
$adresse = rtrim($this->basisUrl, '/') . $pfad;
if ($parameter !== []) {
$adresse .= '?' . http_build_query($parameter);
}
$verbindung = curl_init($adresse);
if ($verbindung === false) {
return ['rumpf' => '', 'status' => 0, 'fehler' => 'Die Adresse des Bargeld-Tools ist unbrauchbar.'];
}
$optionen = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_TIMEOUT => self::ZEITGRENZE,
CURLOPT_CONNECTTIMEOUT => self::ZEITGRENZE,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
];
if ($this->benutzer !== '' || $this->passwort !== '') {
$optionen[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC;
$optionen[CURLOPT_USERPWD] = $this->benutzer . ':' . $this->passwort;
}
curl_setopt_array($verbindung, $optionen);
$rumpf = curl_exec($verbindung);
$status = (int)curl_getinfo($verbindung, CURLINFO_HTTP_CODE);
$curlFehler = curl_error($verbindung);
curl_close($verbindung);
if (!is_string($rumpf)) {
DebugLog::schreibe('bargeld', 'Abruf fehlgeschlagen: ' . $curlFehler);
return [
'rumpf' => '',
'status' => $status,
'fehler' => 'Das Bargeld-Tool ist nicht erreichbar' . ($curlFehler === '' ? '.' : ': ' . $curlFehler),
];
}
if ($status < 200 || $status >= 300) {
DebugLog::schreibe('bargeld', 'HTTP-Status ' . $status . ' vom Bargeld-Tool.');
return [
'rumpf' => $rumpf,
'status' => $status,
'fehler' => self::fehlertext($status, $rumpf),
];
}
return ['rumpf' => $rumpf, 'status' => $status, 'fehler' => ''];
}
/**
* Die Meldung zu einem HTTP-Fehler. Nennt die Gegenstelle im Feld `fehler`
* einen Grund, hat der Vorrang – sie weiß mehr über sich als wir.
*/
private static function fehlertext(int $status, string $rumpf): string
{
$daten = json_decode($rumpf, true);
$grund = is_array($daten) ? trim((string)($daten['fehler'] ?? '')) : '';
if ($grund !== '') {
return 'Bargeld-Tool: ' . mb_substr($grund, 0, 180) . ' (HTTP ' . $status . ')';
}
return match (true) {
$status === 401 || $status === 403 => 'Das Bargeld-Tool weist die Zugangsdaten ab (HTTP ' . $status . ').',
$status === 404 => 'Das Bargeld-Tool kennt die Schnittstelle nicht (HTTP 404).',
$status === 429 => 'Das Bargeld-Tool bremst die Abfragen aus (HTTP 429).',
$status >= 500 => 'Das Bargeld-Tool meldet einen Fehler (HTTP ' . $status . ').',
default => 'Unerwartete Antwort des Bargeld-Tools (HTTP ' . $status . ').',
};
}
/**
* @return array{gefunden: bool, treffer: null, anzahl: int, meldung: string, fehler: bool}
*/
private static function misserfolg(string $meldung): array
{
return ['gefunden' => false, 'treffer' => null, 'anzahl' => 0, 'meldung' => $meldung, 'fehler' => true];
}
}

View file

@ -13,6 +13,7 @@ use Finanzmanager\View;
/** @var list<array<string,mixed>> $klassen */
/** @var list<array<string,mixed>> $konten */
/** @var array<int, list<array<string,mixed>>> $splits */
/** @var bool $bargeld Ist ein Bargeld-Tool eingerichtet? */
$kategorieNamen = [];
foreach ($kategorien as $k) {
@ -104,6 +105,33 @@ $splituebersicht = static function (array $splitzeilen) use ($kategorieNamen, $k
return implode("\n", $texte);
};
/**
* Das Symbol der Abgleichspalte. 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.
*
* Die Zeichen stehen als Text und nicht als Grafik da: Sie erben damit
* Schriftgröße und – beim Ausrufezeichen ausdrücklich gewünscht – die Farbe
* des übrigen Textes, in jedem Farbschema.
*
* @param array<string,mixed> $zeile
*/
$bargeldmarke = static function (array $zeile): string {
$status = (string)($zeile['bargeld_status'] ?? '');
$hinweis = trim((string)($zeile['bargeld_hinweis'] ?? ''));
[$zeichen, $klasse, $standardtitel] = 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::h($hinweis !== '' ? $hinweis : $standardtitel) . '" role="img" aria-label="'
. View::h($standardtitel) . '">' . $zeichen . '</span>';
};
include __DIR__ . '/../layout/kopf.php';
?>
@ -199,6 +227,9 @@ include __DIR__ . '/../layout/kopf.php';
<th>Buchungstext</th>
<th>Kategorie</th>
<th>Klasse</th>
<?php if ($bargeld): ?>
<th class="bargeldspalte" title="Abgleich mit dem Bargeld-Tool">Bar</th>
<?php endif; ?>
<th class="rechts">Betrag</th>
<th class="rechts">Saldo</th>
<th>✓</th>
@ -318,6 +349,9 @@ include __DIR__ . '/../layout/kopf.php';
</a>
<?php endif; ?>
</td>
<?php if ($bargeld): ?>
<td class="bargeldspalte"><?= $bargeldmarke($zeile) ?></td>
<?php endif; ?>
<td class="rechts <?= View::betragKlasse($zeile['betrag']) ?>"><?= View::betrag($zeile['betrag']) ?></td>
<td class="rechts <?= $zeile['saldo'] === null ? 'nebentext' : View::betragKlasse($zeile['saldo']) ?>">
<?= $zeile['saldo'] === null ? '–' : View::betrag($zeile['saldo']) ?>

View file

@ -0,0 +1,125 @@
<?php
use Finanzmanager\View;
/**
* Die Konfiguration des Bargeld-Tools: Adresse, Zugangsdaten und die Auswahl
* der Kategorien, deren Buchungen dort nachgeschlagen werden.
*
* @var string $url
* @var string $benutzerkennung
* @var bool $hatPasswort
* @var int $tage
* @var list<array<string,mixed>> $kategorien Baum, flach mit 'ebene'
* @var list<int> $auswahl
* @var bool $schluessel APP_KEY vorhanden?
* @var string $csrfToken
*/
$gewaehlt = array_fill_keys($auswahl, true);
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
<a href="/verwaltung/kategorien" class="knopf">Kategorien</a>
<?php if ($url !== ''): ?>
<form method="post" action="/verwaltung/bargeld/test" class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf">Verbindung testen</button>
</form>
<?php endif; ?>
</div>
<p class="feldhinweis">
Bargeldausgaben werden in einem eigenen Programm erfasst. Ist hier eine Adresse
hinterlegt, fragt der Finanzmanager beim Zuweisen einer der unten gewählten
Kategorien dort nach, ob es im Suchzeitraum vor dem Buchungsdatum eine Buchung
mit demselben Betrag gibt. Das Ergebnis steht danach als Symbol im Kontoblatt –
grüner Haken gefunden, rotes X nicht gefunden, Ausrufezeichen Abruf gescheitert.
</p>
<?php if (!$schluessel): ?>
<p class="feldhinweis warnend">
<code>APP_KEY</code> ist nicht gesetzt. Ohne ihn lässt sich kein Passwort
speichern – die übrigen Angaben schon.
</p>
<?php endif; ?>
<form method="post" action="/verwaltung/bargeld" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="bargeld_url">Adresse</label>
<input type="text" id="bargeld_url" name="bargeld_url" value="<?= View::h($url) ?>"
placeholder="https://…" inputmode="url">
<p class="feldhinweis">
Die Basisadresse des Bargeld-Tools ohne Pfad; der Finanzmanager hängt
<code>/api/v1/buchungen</code> selbst an. Leer lassen schaltet den Abgleich ab –
das Kontoblatt zeigt dann überall den grauen Kreis.
</p>
<label for="bargeld_benutzer">Benutzerkennung</label>
<input type="text" id="bargeld_benutzer" name="bargeld_benutzer"
value="<?= View::h($benutzerkennung) ?>" autocomplete="off">
<label for="bargeld_passwort">Passwort</label>
<input type="password" id="bargeld_passwort" name="bargeld_passwort" autocomplete="off"
<?= $hatPasswort ? 'placeholder="unverändert lassen"' : '' ?>>
<p class="feldhinweis">
Wird AES-verschlüsselt gespeichert (Schlüssel <code>APP_KEY</code>) und nie wieder
angezeigt.
<?php if ($hatPasswort): ?>
Leer lassen heißt: bisheriges Passwort behalten.
<?php endif; ?>
</p>
<?php if ($hatPasswort): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="passwort_loeschen" value="1">
<span>Gespeichertes Passwort löschen</span>
</label>
<?php endif; ?>
<label for="bargeld_tage">Suchzeitraum in Tagen</label>
<input type="number" id="bargeld_tage" name="bargeld_tage" value="<?= (int)$tage ?>"
min="1" max="365" class="schmal">
<p class="feldhinweis">
So viele Tage wird rückwärts ab dem Buchungsdatum gesucht. Bargeld wird
ausgegeben, bevor der Umsatz auf dem Konto ankommt – nie danach.
</p>
<h3>Abzugleichende Kategorien</h3>
<p class="feldhinweis">
Nur Buchungen dieser Kategorien werden nachgeschlagen. Alle anderen bleiben
unberührt und zeigen im Kontoblatt den grauen Kreis. Wird eine Kategorie hier
abgewählt, verlieren ihre Buchungen den bisherigen Abgleichstatus.
</p>
<div class="auswahlliste">
<?php if ($kategorien === []): ?>
<p class="platzhalter">Es gibt noch keine Kategorien.</p>
<?php endif; ?>
<?php foreach ($kategorien as $kategorie): ?>
<?php /* Eingerückt über data-ebene, nicht über ein style-Attribut:
Das verwirft die Content-Security-Policy (style-src 'self'). */ ?>
<label class="ankreuzfeld baumwahl" data-ebene="<?= min(6, (int)$kategorie['ebene']) ?>">
<input type="checkbox" name="kategorien[]" value="<?= (int)$kategorie['id'] ?>"
<?= isset($gewaehlt[(int)$kategorie['id']]) ? 'checked' : '' ?>>
<span>
<?= View::h($kategorie['name']) ?>
<span class="nebentext">
<?= (string)$kategorie['art'] === 'einnahme' ? 'Einnahme' : 'Ausgabe' ?>
<?= (int)$kategorie['aktiv'] === 1 ? '' : '· inaktiv' ?>
</span>
</span>
</label>
<?php endforeach; ?>
</div>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Speichern</button>
<a href="/verwaltung" class="knopf">Abbrechen</a>
</div>
</form>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -4,6 +4,7 @@ use Finanzmanager\Services\Dateiformat;
use Finanzmanager\View;
/** @var array<string,int> $zahlen */
/** @var bool $bargeldAktiv */
include __DIR__ . '/../layout/kopf.php';
?>
@ -83,6 +84,23 @@ include __DIR__ . '/../layout/kopf.php';
</div>
</div>
<div class="kachel">
<header><h2>Bargeld-Tool</h2></header>
<p class="kennzahl"><?= (int)$zahlen['bargeld'] ?></p>
<p class="platzhalter">
<?php if ($bargeldAktiv): ?>
Kategorien werden beim Zuweisen gegen das Bargeld-Tool geprüft; das
Ergebnis steht im Kontoblatt.
<?php else: ?>
Bargeldausgaben aus dem Zusatzprogramm gegenprüfen – dazu fehlt noch
die Adresse.
<?php endif; ?>
</p>
<div class="knopfleiste">
<a href="/verwaltung/bargeld" class="knopf klein primaer">Bargeld-Tool einrichten</a>
</div>
</div>
<div class="kachel">
<header><h2>Termine</h2></header>
<p class="kennzahl"><?= (int)$zahlen['reihen'] ?></p>

View file

@ -65,8 +65,9 @@ class EinstellungenTest extends TestCase
private function bereicheVollstaendig(): void
{
// Jeder in der Datenbank benutzte Bereich braucht einen Reiter im Dialog,
// sonst wären die Werte nicht mehr erreichbar.
// Jeder in der Datenbank benutzte Bereich braucht einen Reiter im Dialog
// oder eine eigene Seite (Einstellung::EIGENE_SEITEN), sonst wären die
// Werte nicht mehr erreichbar.
$bereiche = array_column(
Finanzmanager\Database::fetchAll('SELECT DISTINCT bereich FROM einstellungen'),
'bereich'
@ -74,8 +75,9 @@ class EinstellungenTest extends TestCase
foreach ($bereiche as $bereich) {
$this->assert(
array_key_exists($bereich, Einstellung::BEREICHE),
"Bereich '{$bereich}' hat einen Reiter im Einstellungsdialog"
array_key_exists($bereich, Einstellung::BEREICHE)
|| array_key_exists($bereich, Einstellung::EIGENE_SEITEN),
"Bereich '{$bereich}' ist erreichbar – als Reiter oder über eine eigene Seite"
);
}
}

View file

@ -13,8 +13,8 @@ class NavigationTest extends TestCase
/** Die Zahlen, die die Verwaltungsseite für ihre Kacheln erwartet. */
private const ZAHLEN = [
'kategorien' => 0, 'einnahmen' => 0, 'ausgaben' => 0, 'klassen' => 0,
'konten' => 0, 'kontengruppen' => 0, 'reihen' => 3, 'erinnerungen' => 2,
'vertraege' => 7, 'fristen' => 1,
'konten' => 0, 'kontengruppen' => 0, 'bankinstitute' => 0, 'bargeld' => 0,
'reihen' => 3, 'erinnerungen' => 2, 'vertraege' => 7, 'fristen' => 1,
];
public function run(): void
@ -180,6 +180,7 @@ class NavigationTest extends TestCase
$seite = $this->rendere('verwaltung/index.php', [
'aktuellerPfad' => '/verwaltung',
'zahlen' => self::ZAHLEN,
'bargeldAktiv' => false,
]);
return substr($seite, (int)strpos($seite, '<main'));

View file

@ -0,0 +1,627 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\BargeldAbgleich;
use Finanzmanager\Services\BargeldTool;
use Finanzmanager\Services\Crypto;
/**
* Der Abgleich mit dem Bargeld-Tool: Konfiguration, Kategorienauswahl, der
* Abruf selbst und der Status, den das Kontoblatt als Symbol zeigt.
*
* Der Abruf wird gegen einen echten HTTP-Server geprüft – den eingebauten
* Server von PHP mit einem Router, der sich wie das Bargeld-Tool verhält.
* Nachgebildete Antworten allein würden genau das übergehen, woran ein Abruf
* scheitert: Basic Auth, HTTP-Status, ein Rumpf, der kein JSON ist. Lässt sich
* der Server nicht starten, entfallen die Netzprüfungen; alles Übrige läuft
* ohne ihn.
*/
class BargeldAbgleichTest extends TestCase
{
protected string $testName = 'Test 34: Abgleich mit dem Bargeld-Tool';
private int $konto = 0;
private int $bargeldKategorie = 0;
private int $andereKategorie = 0;
/** @var resource|null */
private $server = null;
private string $adresse = '';
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->antwortAuswerten();
$this->kategorienauswahl();
$this->ohneAdresseKeinAbgleich();
$this->passwortStehtVerschluesselt();
$this->suchfensterBleibtInGrenzen();
$this->mitServer();
$this->statusAufraeumenNimmtDenHakenWeg();
$this->kontoblattFuehrtDenStatusMit();
$this->kontoblattZeigtDieViervierSymbole();
$this->konfigurationsseiteZeigtDieAuswahl();
}
protected function tearDown(): void
{
$this->serverBeenden();
parent::tearDown();
}
private function stammdaten(): void
{
$this->konto = Konto::anlegen([
'name' => 'Bargeld Giro', 'kontoart' => 'bank', 'anfangssaldo' => 500, 'aktiv' => 1,
]);
$this->bargeldKategorie = Kategorie::anlegen(['name' => 'Bargeld Haushalt', 'art' => 'ausgabe']);
$this->andereKategorie = Kategorie::anlegen(['name' => 'Bargeld Versicherung', 'art' => 'ausgabe']);
Kategorie::bargeldAuswahlSetzen([$this->bargeldKategorie]);
}
private function buchung(float $betrag = -12.50, string $datum = '2026-03-10'): int
{
return Buchung::anlegen([
'konto_id' => $this->konto,
'datum' => $datum,
'betrag' => $betrag,
'empfaenger' => 'Bargeldabhebung',
]);
}
// ── Auswertung der Antwort, ohne Netz ────────────────────────────────────
private function antwortAuswerten(): void
{
$treffer = BargeldTool::trefferAus(
'{"anzahl":1,"buchungen":[{"id":"a1","datum":"2026-03-08","betrag":-12.50,'
. '"waehrung":"EUR","zweck":"Wocheneinkauf"}]}'
);
$this->assert($treffer['gefunden'], 'Eine Buchung in der Antwort gilt als gefunden');
$this->assert(!$treffer['fehler'], 'Ein Treffer ist kein Fehler');
$this->assertEquals(1, $treffer['anzahl'], 'Die Trefferzahl steht im Ergebnis');
$this->assert(
str_contains($treffer['meldung'], '08.03.2026') && str_contains($treffer['meldung'], 'Wocheneinkauf'),
'Die Meldung nennt Datum und Zweck des Treffers'
);
$leer = BargeldTool::trefferAus('{"anzahl":0,"buchungen":[]}');
$this->assert(!$leer['gefunden'], 'Eine leere Liste heißt: nicht gefunden');
$this->assert(!$leer['fehler'], 'Null Treffer sind kein Fehler');
$mehrere = BargeldTool::trefferAus(
'{"anzahl":3,"buchungen":[{"id":"a1","datum":"2026-03-08","betrag":-12.50}]}'
);
$this->assertEquals(3, $mehrere['anzahl'], 'Das Feld „anzahl“ zählt auch abgeschnittene Treffer');
$this->assert(str_contains($mehrere['meldung'], '3 Treffer'), 'Mehrere Treffer stehen in der Meldung');
$kaputt = BargeldTool::trefferAus('<html>Fehlerseite</html>');
$this->assert($kaputt['fehler'], 'Eine Antwort ohne JSON ist ein Fehler');
$this->assert(!$kaputt['gefunden'], 'Eine kaputte Antwort gilt nicht als Treffer');
$ohneFeld = BargeldTool::trefferAus('{"anzahl":1}');
$this->assert($ohneFeld['fehler'], 'Ohne Feld „buchungen“ ist die Antwort unbrauchbar');
}
// ── Auswahl der Kategorien ───────────────────────────────────────────────
private function kategorienauswahl(): void
{
$this->assertEquals(
[$this->bargeldKategorie],
Kategorie::bargeldAuswahl(),
'Die vorgemerkte Kategorie steht in der Auswahl'
);
$kategorie = Kategorie::find($this->bargeldKategorie);
$this->assertEquals(1, (int)$kategorie['bargeld_abgleich'], 'Die Vormerkung steht an der Kategorie');
Kategorie::bargeldAuswahlSetzen([$this->bargeldKategorie, $this->andereKategorie]);
$this->assertEquals(2, count(Kategorie::bargeldAuswahl()), 'Die Auswahl lässt sich erweitern');
Kategorie::bargeldAuswahlSetzen([]);
$this->assertEquals([], Kategorie::bargeldAuswahl(), 'Eine leere Liste wählt alles ab');
Kategorie::bargeldAuswahlSetzen([$this->bargeldKategorie]);
$this->assertEquals(
[$this->bargeldKategorie],
Kategorie::bargeldAuswahl(),
'Die Auswahl wird ersetzt, nicht ergänzt'
);
}
// ── Ohne Konfiguration bleibt alles ruhig ────────────────────────────────
private function ohneAdresseKeinAbgleich(): void
{
Einstellung::set('bargeld_url', '', 'bargeld');
Einstellung::cacheLeeren();
$this->assert(!BargeldAbgleich::eingerichtet(), 'Ohne Adresse gilt der Abgleich als nicht eingerichtet');
$id = $this->buchung();
Buchung::zuordnen($id, $this->bargeldKategorie, null);
$this->assertNull(BargeldAbgleich::pruefe($id), 'Ohne Adresse wird nicht abgeglichen');
$this->assertNull(Buchung::find($id)['bargeld_status'], 'Ohne Adresse bleibt der Status leer');
}
private function passwortStehtVerschluesselt(): void
{
if (!Crypto::einsatzbereit()) {
return;
}
Einstellung::set('bargeld_passwort', Crypto::verschluessle('geheim123'), 'bargeld');
Einstellung::cacheLeeren();
$gespeichert = (string)Einstellung::get('bargeld_passwort');
$this->assert(
Crypto::istVerschluesselt($gespeichert),
'Das Passwort des Bargeld-Tools steht verschlüsselt in der Datenbank'
);
$this->assertEquals('geheim123', Crypto::entschluessle($gespeichert), 'Es lässt sich wieder lesen');
}
private function suchfensterBleibtInGrenzen(): void
{
Einstellung::set('bargeld_tage', '', 'bargeld');
Einstellung::cacheLeeren();
$this->assertEquals(10, BargeldAbgleich::fensterTage(), 'Ohne Angabe gilt das Fenster von zehn Tagen');
Einstellung::set('bargeld_tage', '0', 'bargeld');
Einstellung::cacheLeeren();
$this->assertEquals(10, BargeldAbgleich::fensterTage(), 'Null Tage ergäben kein Fenster – der Standard greift');
Einstellung::set('bargeld_tage', '9999', 'bargeld');
Einstellung::cacheLeeren();
$this->assertEquals(365, BargeldAbgleich::fensterTage(), 'Das Fenster wird bei einem Jahr gekappt');
Einstellung::set('bargeld_tage', '5', 'bargeld');
Einstellung::cacheLeeren();
$this->assertEquals(5, BargeldAbgleich::fensterTage(), 'Eine eigene Angabe gilt');
}
// ── Mit laufendem Bargeld-Tool ───────────────────────────────────────────
private function mitServer(): void
{
if (!$this->serverStarten()) {
return;
}
Einstellung::set('bargeld_url', $this->adresse, 'bargeld');
Einstellung::set('bargeld_benutzer', 'kasse', 'bargeld');
Einstellung::set(
'bargeld_passwort',
Crypto::einsatzbereit() ? Crypto::verschluessle('offen') : 'offen',
'bargeld'
);
Einstellung::cacheLeeren();
$this->assert(BargeldAbgleich::eingerichtet(), 'Mit Adresse gilt der Abgleich als eingerichtet');
$this->verbindungstest();
$this->gefunden();
$this->nichtGefunden();
$this->serverfehler();
$this->falscheZugangsdaten();
$this->umbuchungBleibtUnberuehrt();
$this->andereKategorieBleibtUnberuehrt();
$this->suchfensterWirdMitgeschickt();
}
private function verbindungstest(): void
{
$zugang = BargeldTool::ausEinstellungen();
$this->assertNotEmpty($zugang, 'Aus den Einstellungen entsteht ein Zugang');
$ergebnis = $zugang->test();
$this->assert($ergebnis['erfolg'], 'Der Verbindungstest erreicht das Bargeld-Tool');
$this->assert(
str_contains($ergebnis['meldung'], 'FSCash-Test'),
'Der Test nennt Namen und Fassung der Gegenstelle'
);
}
private function gefunden(): void
{
$id = $this->buchung(-12.50);
Buchung::zuordnen($id, $this->bargeldKategorie, null);
$this->assertEquals(BargeldAbgleich::GEFUNDEN, BargeldAbgleich::pruefe($id), 'Der Betrag wird gefunden');
$buchung = Buchung::find($id);
$this->assertEquals('gefunden', $buchung['bargeld_status'], 'Der Status steht an der Buchung');
$this->assert(
str_contains((string)$buchung['bargeld_hinweis'], 'Wocheneinkauf'),
'Der Treffer steht als Hinweis an der Buchung'
);
$this->assertNotEmpty($buchung['bargeld_geprueft_am'], 'Der Zeitpunkt der Prüfung wird festgehalten');
}
private function nichtGefunden(): void
{
$id = $this->buchung(-99.00);
Buchung::zuordnen($id, $this->bargeldKategorie, null);
$this->assertEquals(BargeldAbgleich::FEHLT, BargeldAbgleich::pruefe($id), 'Ein unbekannter Betrag fehlt');
$this->assertEquals('fehlt', Buchung::find($id)['bargeld_status'], 'Der Status „fehlt“ steht an der Buchung');
}
private function serverfehler(): void
{
$id = $this->buchung(-500.00);
Buchung::zuordnen($id, $this->bargeldKategorie, null);
$this->assertEquals(BargeldAbgleich::FEHLER, BargeldAbgleich::pruefe($id), 'HTTP 500 ergibt den Status Fehler');
$buchung = Buchung::find($id);
$this->assert(
str_contains((string)$buchung['bargeld_hinweis'], 'Kasse steht still'),
'Die Meldung der Gegenstelle steht im Hinweis'
);
}
private function falscheZugangsdaten(): void
{
Einstellung::set('bargeld_benutzer', 'niemand', 'bargeld');
Einstellung::cacheLeeren();
$id = $this->buchung(-12.50);
Buchung::zuordnen($id, $this->bargeldKategorie, null);
$this->assertEquals(
BargeldAbgleich::FEHLER,
BargeldAbgleich::pruefe($id),
'Abgewiesene Zugangsdaten ergeben einen Fehler, kein „nicht gefunden“'
);
Einstellung::set('bargeld_benutzer', 'kasse', 'bargeld');
Einstellung::cacheLeeren();
}
private function umbuchungBleibtUnberuehrt(): void
{
$spar = Konto::anlegen([
'name' => 'Bargeld Spar', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$id = $this->buchung(-12.50);
Buchung::zuordnen($id, null, $spar);
$this->assertNull(BargeldAbgleich::pruefe($id), 'Eine Umbuchung wird nicht abgeglichen');
$this->assertNull(Buchung::find($id)['bargeld_status'], 'Eine Umbuchung trägt keinen Status');
}
private function andereKategorieBleibtUnberuehrt(): void
{
$id = $this->buchung(-12.50);
Buchung::zuordnen($id, $this->andereKategorie, null);
$this->assertNull(BargeldAbgleich::pruefe($id), 'Eine nicht vorgemerkte Kategorie wird nicht abgeglichen');
$this->assertNull(Buchung::find($id)['bargeld_status'], 'Sie bleibt ohne Status – der graue Kreis');
}
/**
* Das Fenster reicht rückwärts ab dem Buchungsdatum. Der Testserver
* antwortet nur, wenn `von` und `bis` genau dazu passen.
*/
private function suchfensterWirdMitgeschickt(): void
{
Einstellung::set('bargeld_tage', '10', 'bargeld');
Einstellung::cacheLeeren();
$id = $this->buchung(-7.77, '2026-03-20');
Buchung::zuordnen($id, $this->bargeldKategorie, null);
$this->assertEquals(
BargeldAbgleich::GEFUNDEN,
BargeldAbgleich::pruefe($id),
'Gesucht wird von 2026-03-10 bis 2026-03-20 – zehn Tage rückwärts ab dem Buchungsdatum'
);
}
// ── Aufräumen und Kontoblatt ─────────────────────────────────────────────
private function statusAufraeumenNimmtDenHakenWeg(): void
{
$id = $this->buchung(-12.50);
Buchung::zuordnen($id, $this->bargeldKategorie, null);
Finanzmanager\Database::execute(
"UPDATE buchungen SET bargeld_status = 'gefunden', bargeld_hinweis = 'x' WHERE id = ?",
[$id]
);
BargeldAbgleich::statusAufraeumen([$this->bargeldKategorie]);
$this->assertEquals(
'gefunden',
Buchung::find($id)['bargeld_status'],
'Eine weiterhin vorgemerkte Kategorie behält ihren Status'
);
BargeldAbgleich::statusAufraeumen([$this->andereKategorie]);
$this->assertNull(
Buchung::find($id)['bargeld_status'],
'Wird die Kategorie abgewählt, verschwindet der Status'
);
$this->assertNull(Buchung::find($id)['bargeld_hinweis'], 'Der Hinweis verschwindet mit');
}
private function kontoblattFuehrtDenStatusMit(): void
{
$id = $this->buchung(-33.00, '2026-04-01');
Buchung::zuordnen($id, $this->bargeldKategorie, null);
Finanzmanager\Database::execute(
"UPDATE buchungen SET bargeld_status = 'fehlt', bargeld_hinweis = 'nicht da' WHERE id = ?",
[$id]
);
$zeilen = Buchung::kontoblatt($this->konto, ['von' => '2026-04-01', 'bis' => '2026-04-01']);
$this->assertEquals(1, count($zeilen), 'Die Buchung steht im Kontoblatt');
$this->assertEquals('fehlt', $zeilen[0]['bargeld_status'], 'Das Kontoblatt liefert den Abgleichstatus mit');
$this->assertEquals('nicht da', $zeilen[0]['bargeld_hinweis'], 'Der Hinweis kommt für den Tooltip mit');
}
// ── Die Anzeige ──────────────────────────────────────────────────────────
/**
* Die schmale Spalte zwischen Klasse und Betrag trägt genau vier Zustände.
* Geprüft wird am gerenderten Kontoblatt, nicht an der Absicht.
*/
private function kontoblattZeigtDieViervierSymbole(): void
{
$zeilen = [];
foreach ([null, 'gefunden', 'fehlt', 'fehler'] as $nr => $status) {
$zeilen[] = [
'id' => 900 + $nr, 'datum' => '2026-05-0' . ($nr + 1), 'valuta' => null,
'betrag' => -10.0, 'empfaenger' => 'Zeile ' . $nr, 'buchungstext' => '',
'nummer' => '', 'abgeglichen' => 0, 'kategorie_id' => $this->bargeldKategorie,
'klasse_id' => null, 'gesplittet' => 0, 'quelle' => 'manuell',
'bargeld_status' => $status, 'bargeld_hinweis' => $status === 'fehler' ? 'Kasse steht still.' : null,
'gegenkonto_id' => null, 'konto_id' => $this->konto, 'gegensicht' => false,
'split_id' => null, 'saldo' => 100.0,
];
}
$mitSpalte = $this->kontoblattHtml($zeilen, true);
$this->assert(
(bool)preg_match('#<th class="bargeldspalte"[^>]*>Bar</th>\s*<th class="rechts">Betrag</th>#', $mitSpalte),
'Die Abgleichspalte steht zwischen Klasse und Betrag'
);
$this->assert((bool)preg_match('#bargeld-neutral"[^>]*>●<#u', $mitSpalte), 'Ohne Status steht dort der graue Kreis');
$this->assert((bool)preg_match('#bargeld-gefunden"[^>]*>✓<#u', $mitSpalte), 'Gefunden ergibt den Haken');
$this->assert((bool)preg_match('#bargeld-fehlt"[^>]*>✗<#u', $mitSpalte), 'Nicht gefunden ergibt das X');
$this->assert((bool)preg_match('#bargeld-fehler"[^>]*>!<#u', $mitSpalte), 'Ein Abrufproblem ergibt das Ausrufezeichen');
$this->assert(
str_contains($mitSpalte, 'title="Kasse steht still."'),
'Die Meldung der Gegenstelle steht im Tooltip'
);
$stil = (string)file_get_contents(dirname(__DIR__) . '/public/static/app.css');
$this->assert(
(bool)preg_match('#\.bargeld-gefunden\s*\{[^}]*var\(--positiv\)#', $stil),
'Der Haken nimmt die Farbe des Farbschemas für Positives'
);
$this->assert(
(bool)preg_match('#\.bargeld-fehlt\s*\{[^}]*var\(--negativ\)#', $stil),
'Das X nimmt die Farbe für Negatives'
);
$this->assert(
(bool)preg_match('#\.bargeld-fehler\s*\{[^}]*color:\s*inherit#', $stil),
'Das Ausrufezeichen erbt die Textfarbe – je nach Farbschema'
);
$ohneSpalte = $this->kontoblattHtml($zeilen, false);
$this->assert(
!str_contains($ohneSpalte, 'bargeldspalte'),
'Ohne eingerichtetes Bargeld-Tool fehlt die Spalte ganz'
);
}
private function konfigurationsseiteZeigtDieAuswahl(): void
{
$html = $this->rendere('verwaltung/bargeld.php', [
'url' => 'https://kasse.example.net',
'benutzerkennung' => 'kasse',
'hatPasswort' => true,
'tage' => 10,
'kategorien' => Kategorie::baumFlach(),
'auswahl' => [$this->bargeldKategorie],
'schluessel' => true,
]);
foreach (['bargeld_url', 'bargeld_benutzer', 'bargeld_passwort', 'bargeld_tage'] as $feld) {
$this->assert(str_contains($html, 'name="' . $feld . '"'), 'Das Feld ' . $feld . ' steht im Formular');
}
$this->assert(
(bool)preg_match(
'#name="kategorien\[\]" value="' . $this->bargeldKategorie . '"\s+checked#',
$html
),
'Die vorgemerkte Kategorie ist angekreuzt'
);
$this->assert(
(bool)preg_match(
'#name="kategorien\[\]" value="' . $this->andereKategorie . '"\s*>#',
$html
),
'Die übrigen Kategorien stehen zur Wahl, aber ohne Kreuz'
);
$this->assert(
!str_contains($html, 'style="'),
'Kein style-Attribut – die Content-Security-Policy verwirft es'
);
$this->assert(
str_contains($html, 'action="/verwaltung/bargeld/test"'),
'Der Verbindungstest ist erreichbar'
);
}
/** @param list<array<string,mixed>> $zeilen */
private function kontoblattHtml(array $zeilen, bool $bargeld): string
{
return $this->rendere('buchungen/kontoblatt.php', [
'konto' => Konto::mitSalden(Konto::find($this->konto)),
'zeilen' => $zeilen,
'gesamt' => count($zeilen),
'seite' => 1,
'seiten' => 1,
'filter' => [],
'kategorien' => Kategorie::baumFlach(),
'klassen' => [],
'konten' => Konto::alle(),
'splits' => [],
'wertpapiere' => [],
'bargeld' => $bargeld,
]);
}
/**
* Eine View rendern wie der Controller es täte – ohne Webserver.
*
* @param array<string,mixed> $daten
*/
private function rendere(string $view, array $daten): string
{
$daten += ['titel' => 'Test', 'flash' => [], 'benutzer' => null, 'csrfToken' => 'x',
'aktuellerPfad' => '/verwaltung'];
extract($daten, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/src/Views/' . $view;
return (string)ob_get_clean();
}
// ── Der Testserver ───────────────────────────────────────────────────────
/**
* Der eingebaute PHP-Server mit einem Router, der sich wie das Bargeld-Tool
* verhält: Basic Auth, Statusaufruf, Buchungssuche, ein Betrag, der einen
* Serverfehler auslöst.
*/
private function serverStarten(): bool
{
$verzeichnis = $this->tempPfad();
if (!mkdir($verzeichnis, 0o777, true) && !is_dir($verzeichnis)) {
return false;
}
file_put_contents($verzeichnis . '/router.php', $this->routerQuelltext());
$port = random_int(18000, 18999);
$prozess = @proc_open(
sprintf('exec php -S 127.0.0.1:%d -t %s %s/router.php', $port, escapeshellarg($verzeichnis), escapeshellarg($verzeichnis)),
[1 => ['file', '/dev/null', 'w'], 2 => ['file', '/dev/null', 'w']],
$rohre
);
if (!is_resource($prozess)) {
return false;
}
$this->server = $prozess;
$this->adresse = 'http://127.0.0.1:' . $port;
// Der Server braucht einen Augenblick, bis er den Port annimmt.
for ($versuch = 0; $versuch < 50; $versuch++) {
$verbindung = @fsockopen('127.0.0.1', $port, $fehler, $meldung, 0.2);
if (is_resource($verbindung)) {
fclose($verbindung);
return true;
}
usleep(100_000);
}
$this->serverBeenden();
return false;
}
private function serverBeenden(): void
{
if (is_resource($this->server)) {
proc_terminate($this->server);
proc_close($this->server);
}
$this->server = null;
}
private function routerQuelltext(): string
{
return <<<'PHP'
<?php
// Ein Bargeld-Tool im Kleinformat – nur so viel, wie der Abgleich sieht.
$pfad = (string)parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
header('Content-Type: application/json; charset=utf-8');
$benutzer = $_SERVER['PHP_AUTH_USER'] ?? '';
if ($benutzer === '' && isset($_SERVER['HTTP_AUTHORIZATION'])) {
$roh = base64_decode(substr((string)$_SERVER['HTTP_AUTHORIZATION'], 6), true);
$benutzer = $roh === false ? '' : (string)strstr($roh, ':', true);
}
if ($benutzer !== 'kasse') {
http_response_code(401);
echo json_encode(['fehler' => 'Unbekannte Kennung.']);
return true;
}
if ($pfad === '/api/v1/status') {
echo json_encode(['name' => 'FSCash-Test', 'version' => '0.1']);
return true;
}
if ($pfad !== '/api/v1/buchungen') {
http_response_code(404);
echo json_encode(['fehler' => 'Unbekannter Pfad.']);
return true;
}
$betrag = (string)($_GET['betrag'] ?? '');
$von = (string)($_GET['von'] ?? '');
$bis = (string)($_GET['bis'] ?? '');
if ($betrag === '-500.00') {
http_response_code(500);
echo json_encode(['fehler' => 'Kasse steht still.']);
return true;
}
// Der Betrag mit dem Prüfdatum: Er wird nur gefunden, wenn das Fenster
// genau zehn Tage rückwärts ab dem Buchungsdatum reicht.
if ($betrag === '-7.77') {
$treffer = ($von === '2026-03-10' && $bis === '2026-03-20')
? [['id' => 'f1', 'datum' => '2026-03-15', 'betrag' => -7.77]]
: [];
echo json_encode(['anzahl' => count($treffer), 'buchungen' => $treffer]);
return true;
}
if ($betrag === '-12.50') {
echo json_encode(['anzahl' => 1, 'buchungen' => [[
'id' => 'a1',
'datum' => '2026-03-08',
'betrag' => -12.50,
'zweck' => 'Wocheneinkauf',
]]]);
return true;
}
echo json_encode(['anzahl' => 0, 'buchungen' => []]);
return true;
PHP;
}
}

View file

@ -0,0 +1,180 @@
// @ts-check
const { test, expect } = require('@playwright/test')
/*
* Der Abgleich mit dem Bargeld-Tool im Browser: die Kachel in der Verwaltung,
* das Formular dahinter und die Wirkung im Kontoblatt.
*
* Als Gegenstelle dient eine Adresse, die es nicht gibt. Das genügt: Der Abruf
* selbst steckt in der PHP-Suite, wo ein Testserver antwortet – hier geht es um
* den Weg und darum, dass ein gescheiterter Abruf die Zuordnung nicht aufhält.
*/
const BENUTZER = process.env.E2E_USER || 'e2e'
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
const MARKE = Date.now().toString().slice(-6)
const GIRO = `E2E Bargeld Giro ${MARKE}`
const KATEGORIE = `E2E Bargeld Haushalt ${MARKE}`
const ADRESSE = 'https://kasse.beispiel.invalid'
async function anmelden(page) {
await page.goto('/login')
if (page.url().includes('/einrichtung')) {
throw new Error(
'Instanz ist noch nicht eingerichtet. Zuerst einen Benutzer anlegen: ' +
'docker compose exec -u www-data php php scripts/e2e-benutzer.php'
)
}
await page.fill('#benutzername', BENUTZER)
await page.fill('#passwort', PASSWORT)
await page.click('button[type="submit"]')
await expect(page).toHaveURL(/\/$/)
}
/** Adresse und Kategorienauswahl zurücksetzen – die Instanz gehört nicht dem Test. */
async function zuruecksetzen(page) {
await page.goto('/verwaltung/bargeld')
await page.fill('#bargeld_url', '')
for (const feld of await page.locator('input[name="kategorien[]"]:checked').all()) {
await feld.uncheck()
}
await page.getByRole('button', { name: 'Speichern' }).click()
await expect(page.locator('.meldung')).toContainText('gespeichert')
}
test.describe('Bargeld-Tool', () => {
let giroId = null
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage()
await anmelden(page)
await page.goto('/konten/neu')
await page.fill('#name', GIRO)
await page.selectOption('#kontoart', 'bank')
await page.fill('#anfangssaldo', '1.000,00')
await page.click('.formular button[type="submit"]')
await expect(page).toHaveURL(/\/konten\/\d+\/bearbeiten/)
const treffer = page.url().match(/\/konten\/(\d+)\//)
giroId = treffer ? treffer[1] : null
await page.goto('/verwaltung/kategorien/neu')
await page.fill('#name', KATEGORIE)
await page.selectOption('#art', 'ausgabe')
await page.click('.formular button[type="submit"]')
await page.close()
})
test.beforeEach(async ({ page }) => {
await anmelden(page)
})
test.afterEach(async ({ page }) => {
await zuruecksetzen(page)
})
test('Die Verwaltung führt zur Konfiguration', async ({ page }) => {
await page.goto('/verwaltung')
const kachel = page.locator('.kachel', { hasText: 'Bargeld-Tool' })
await kachel.getByRole('link', { name: /einrichten/ }).click()
await expect(page).toHaveURL(/\/verwaltung\/bargeld$/)
await expect(page.locator('h1')).toContainText('Bargeld-Tool')
await expect(page.locator('#bargeld_url')).toBeVisible()
await expect(page.locator('#bargeld_benutzer')).toBeVisible()
await expect(page.locator('#bargeld_passwort')).toHaveAttribute('type', 'password')
await expect(page.getByRole('checkbox', { name: KATEGORIE })).not.toBeChecked()
})
test('Eine Adresse ohne Schema wird abgewiesen', async ({ page }) => {
await page.goto('/verwaltung/bargeld')
await page.fill('#bargeld_url', 'kasse.beispiel.invalid')
await page.getByRole('button', { name: 'Speichern' }).click()
await expect(page.locator('.meldung')).toContainText('http://')
await expect(page.locator('#bargeld_url')).toHaveValue('')
})
test('Der Verbindungstest meldet eine unerreichbare Gegenstelle', async ({ page }) => {
await page.goto('/verwaltung/bargeld')
await page.fill('#bargeld_url', ADRESSE)
await page.getByRole('button', { name: 'Speichern' }).click()
await expect(page.locator('.meldung')).toContainText('gespeichert')
await page.getByRole('button', { name: 'Verbindung testen' }).click()
await expect(page.locator('.meldung-fehler')).toContainText('nicht erreichbar')
})
test('Erst mit Adresse zeigt das Kontoblatt die Abgleichspalte', async ({ page }) => {
const empfaenger = `Ohne Bargeld ${MARKE}`
await page.goto(`/konten/${giroId}/buchung`)
await page.selectOption('#art', 'ausgabe')
await page.fill('#datum', '2026-04-03')
await page.fill('#betrag', '12,30')
await page.fill('#empfaenger', empfaenger)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
// Ohne Adresse gibt es die Spalte nicht – sie zeigte nur graue Kreise.
await expect(page.locator('th.bargeldspalte')).toHaveCount(0)
await page.goto('/verwaltung/bargeld')
await page.fill('#bargeld_url', ADRESSE)
await page.fill('#bargeld_tage', '10')
await page.getByRole('button', { name: 'Speichern' }).click()
await expect(page.locator('.meldung')).toContainText('gespeichert')
await page.goto(`/konten/${giroId}`)
await expect(page.locator('th.bargeldspalte')).toHaveCount(1)
// Die Spalte steht zwischen Klasse und Betrag.
const spalten = await page.locator('table.kontoblatt thead th').allTextContents()
expect(spalten.indexOf('Bar')).toBe(spalten.indexOf('Klasse') + 1)
expect(spalten.indexOf('Betrag')).toBe(spalten.indexOf('Bar') + 1)
// Ohne vorgemerkte Kategorie steht dort der graue Kreis.
const zeile = page.locator('tr', { hasText: empfaenger })
await expect(zeile.locator('td.bargeldspalte .bargeld-neutral')).toBeVisible()
})
test('Eine vorgemerkte Kategorie wird beim Zuordnen geprüft', async ({ page }) => {
// Der Abruf läuft ins Leere; genau das soll das Ausrufezeichen zeigen –
// und die Zuordnung muss trotzdem stehen.
test.setTimeout(60_000)
const empfaenger = `Bargeldabhebung ${MARKE}`
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`)
await page.selectOption('#art', 'ausgabe')
await page.fill('#datum', '2026-04-04')
await page.fill('#betrag', '50,00')
await page.fill('#empfaenger', empfaenger)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
const zeile = page.locator('tr', { hasText: empfaenger })
await zeile.locator('a.zuordnungsfeld').click()
await zeile.locator('#zuordnungsauswahl select').selectOption({ label: KATEGORIE })
// Die Zuordnung steht, der gescheiterte Abruf hat sie nicht aufgehalten.
await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung gespeichert')
await expect(page.locator('.meldung-erfolg')).toContainText('fehlgeschlagen')
const danach = page.locator('tr', { hasText: empfaenger })
await expect(danach).toContainText(KATEGORIE)
await expect(danach.locator('td.bargeldspalte .bargeld-fehler')).toBeVisible()
})
})