diff --git a/Changes.md b/Changes.md index 67e0414..974c46a 100644 --- a/Changes.md +++ b/Changes.md @@ -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 – diff --git a/README.md b/README.md index 6a7090d..6642db9 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/docs/buchungen.md b/docs/buchungen.md index ae59a85..33a047d 100644 --- a/docs/buchungen.md +++ b/docs/buchungen.md @@ -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. diff --git a/migrations/016_bargeld_abgleich.sql b/migrations/016_bargeld_abgleich.sql new file mode 100644 index 0000000..f30f94b --- /dev/null +++ b/migrations/016_bargeld_abgleich.sql @@ -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; diff --git a/public/index.php b/public/index.php index 7a17d90..b0c1dc9 100644 --- a/public/index.php +++ b/public/index.php @@ -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()); diff --git a/public/static/app.css b/public/static/app.css index f091782..5f652d2 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -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); diff --git a/src/Controllers/BuchungenController.php b/src/Controllers/BuchungenController.php index aed78bd..a276253 100644 --- a/src/Controllers/BuchungenController.php +++ b/src/Controllers/BuchungenController.php @@ -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 $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 $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.'); } diff --git a/src/Controllers/VerwaltungController.php b/src/Controllers/VerwaltungController.php index 18276e0..f142a80 100644 --- a/src/Controllers/VerwaltungController.php +++ b/src/Controllers/VerwaltungController.php @@ -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'); + } } diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index 0e63eb3..94eb82c 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -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 diff --git a/src/Models/Einstellung.php b/src/Models/Einstellung.php index 52eeaa4..e4bfd8b 100644 --- a/src/Models/Einstellung.php +++ b/src/Models/Einstellung.php @@ -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 diff --git a/src/Models/Kategorie.php b/src/Models/Kategorie.php index 81d4695..f305cf9 100644 --- a/src/Models/Kategorie.php +++ b/src/Models/Kategorie.php @@ -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 + */ + 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 $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( diff --git a/src/Services/BargeldAbgleich.php b/src/Services/BargeldAbgleich.php new file mode 100644 index 0000000..f342cfd --- /dev/null +++ b/src/Services/BargeldAbgleich.php @@ -0,0 +1,172 @@ + 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 $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 $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(), + ] + ); + } +} diff --git a/src/Services/BargeldTool.php b/src/Services/BargeldTool.php new file mode 100644 index 0000000..46dc40d --- /dev/null +++ b/src/Services/BargeldTool.php @@ -0,0 +1,300 @@ +|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|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 $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]; + } +} diff --git a/src/Views/buchungen/kontoblatt.php b/src/Views/buchungen/kontoblatt.php index bdc781b..9b106ea 100644 --- a/src/Views/buchungen/kontoblatt.php +++ b/src/Views/buchungen/kontoblatt.php @@ -13,6 +13,7 @@ use Finanzmanager\View; /** @var list> $klassen */ /** @var list> $konten */ /** @var array>> $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 $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 '' . $zeichen . ''; +}; + include __DIR__ . '/../layout/kopf.php'; ?> @@ -199,6 +227,9 @@ include __DIR__ . '/../layout/kopf.php'; Buchungstext Kategorie Klasse + + Bar + Betrag Saldo ✓ @@ -318,6 +349,9 @@ include __DIR__ . '/../layout/kopf.php'; + + + diff --git a/src/Views/verwaltung/bargeld.php b/src/Views/verwaltung/bargeld.php new file mode 100644 index 0000000..8a26bee --- /dev/null +++ b/src/Views/verwaltung/bargeld.php @@ -0,0 +1,125 @@ +> $kategorien Baum, flach mit 'ebene' + * @var list $auswahl + * @var bool $schluessel APP_KEY vorhanden? + * @var string $csrfToken + */ + +$gewaehlt = array_fill_keys($auswahl, true); + +include __DIR__ . '/../layout/kopf.php'; +?> + +
+ Zurück zur Verwaltung + Kategorien + +
+ + +
+ +
+ +

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

+ + +

+ APP_KEY ist nicht gesetzt. Ohne ihn lässt sich kein Passwort + speichern – die übrigen Angaben schon. +

+ + +
+ + + + +

+ Die Basisadresse des Bargeld-Tools ohne Pfad; der Finanzmanager hängt + /api/v1/buchungen selbst an. Leer lassen schaltet den Abgleich ab – + das Kontoblatt zeigt dann überall den grauen Kreis. +

+ + + + + + > +

+ Wird AES-verschlüsselt gespeichert (Schlüssel APP_KEY) und nie wieder + angezeigt. + + Leer lassen heißt: bisheriges Passwort behalten. + +

+ + + + + + + +

+ So viele Tage wird rückwärts ab dem Buchungsdatum gesucht. Bargeld wird + ausgegeben, bevor der Umsatz auf dem Konto ankommt – nie danach. +

+ +

Abzugleichende Kategorien

+

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

+
+ +

Es gibt noch keine Kategorien.

+ + + + + +
+ +
+ + Abbrechen +
+
+ + diff --git a/src/Views/verwaltung/index.php b/src/Views/verwaltung/index.php index 3c0b7f4..620dc9b 100644 --- a/src/Views/verwaltung/index.php +++ b/src/Views/verwaltung/index.php @@ -4,6 +4,7 @@ use Finanzmanager\Services\Dateiformat; use Finanzmanager\View; /** @var array $zahlen */ +/** @var bool $bargeldAktiv */ include __DIR__ . '/../layout/kopf.php'; ?> @@ -83,6 +84,23 @@ include __DIR__ . '/../layout/kopf.php'; +
+

Bargeld-Tool

+

+

+ + Kategorien werden beim Zuweisen gegen das Bargeld-Tool geprüft; das + Ergebnis steht im Kontoblatt. + + Bargeldausgaben aus dem Zusatzprogramm gegenprüfen – dazu fehlt noch + die Adresse. + +

+ +
+

Termine

diff --git a/tests/02_EinstellungenTest.php b/tests/02_EinstellungenTest.php index e4936a0..d9e0ba5 100644 --- a/tests/02_EinstellungenTest.php +++ b/tests/02_EinstellungenTest.php @@ -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" ); } } diff --git a/tests/24_NavigationTest.php b/tests/24_NavigationTest.php index 41e8e09..2f6ffdc 100644 --- a/tests/24_NavigationTest.php +++ b/tests/24_NavigationTest.php @@ -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, '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('Fehlerseite'); + $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('#]*>Bar\s*Betrag#', $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> $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 $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' + '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; + } +} diff --git a/tests/e2e/specs/bargeld.spec.js b/tests/e2e/specs/bargeld.spec.js new file mode 100644 index 0000000..824ae72 --- /dev/null +++ b/tests/e2e/specs/bargeld.spec.js @@ -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() + }) +})