Finanzmanager/src/Services/BargeldTool.php
fds2610-bot aeb8254840 Fix #21: Bargeld-Tool auch bei Adresse mit /api/v1 erreichen
Der Verbindungstest meldete „Das Bargeld-Tool kennt die Schnittstelle nicht
(HTTP 404)“, sobald die hinterlegte Adresse den Schnittstellenpfad schon
enthielt: Der Finanzmanager hängt ihn selbst an und fragte damit unter
/api/v1/api/v1/status an.

Die Adresse aus der Browserzeile zu kopieren ist der naheliegende Weg, und
der Pfad ist dort mit dabei. Ein abschließendes /api oder /api/v1 kürzt
BargeldTool::basisadresse() deshalb weg – beim Speichern und noch einmal im
Konstruktor, damit auch schon gespeicherte Adressen greifen. Ein
Unterverzeichnis davor bleibt erhalten, ein Hostname wie apianbieter.example
bleibt unangetastet.

Bereits gespeicherte Adressen räumt Migration 017 auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:15:45 +02:00

315 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
private string $basisUrl;
public function __construct(
string $basisUrl,
private string $benutzer = '',
private string $passwort = ''
) {
$this->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<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];
}
}