mandantAnlegen(); $this->stammdaten(); $this->antwortAuswerten(); $this->basisadresseKuerztDenSchnittstellenpfad(); $this->kategorienauswahl(); $this->ohneAdresseKeinAbgleich(); $this->passwortStehtVerschluesselt(); $this->suchfensterBleibtInGrenzen(); $this->mitServer(); $this->statusAufraeumenNimmtDenHakenWeg(); $this->kontoblattFuehrtDenStatusMit(); $this->kontoblattZeigtDieViervierSymbole(); $this->konfigurationsseiteZeigtDieAuswahl(); $this->fehlerlisteZeigtDieBuchungen(); } 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'); } // ── Basisadresse ───────────────────────────────────────────────────────── /** * Wer die Adresse aus dem Browser kopiert, hat `/api/v1` schon dabei. * Bleibt es stehen, fragt der Finanzmanager unter `/api/v1/api/v1/status` * an und der Verbindungstest scheitert mit einem 404. */ private function basisadresseKuerztDenSchnittstellenpfad(): void { $erwartet = 'https://bargeld.example.org'; foreach ([ 'https://bargeld.example.org', 'https://bargeld.example.org/', 'https://bargeld.example.org/api', 'https://bargeld.example.org/api/v1', 'https://bargeld.example.org/api/v1/', ' https://bargeld.example.org/api/v2 ', ] as $eingabe) { $this->assertEquals( $erwartet, BargeldTool::basisadresse($eingabe), 'Basisadresse aus „' . trim($eingabe) . '“' ); } $this->assertEquals( 'https://example.org/fscash', BargeldTool::basisadresse('https://example.org/fscash/api/v1'), 'Ein Unterverzeichnis vor dem Schnittstellenpfad bleibt erhalten' ); $this->assertEquals( 'https://apianbieter.example.org', BargeldTool::basisadresse('https://apianbieter.example.org'), 'Nur ein eigener Pfadteil „api“ wird gekürzt, kein Namensbestandteil' ); } // ── 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('#