basisUrl = self::basisadresse($basisUrl); } /** * Die Basisadresse ohne den Schnittstellenpfad. Wer die Adresse aus dem * Browser kopiert, hat `/api/v1` schon dabei – ohne diese Kürzung fragte * der Finanzmanager unter `/api/v1/api/v1/status` an und bekam ein 404. */ public static function basisadresse(string $url): string { $url = rtrim(trim($url), '/'); return (string)preg_replace('#/api(?:/v\\d+)?$#i', '', $url); } /** * 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|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]; } }