From 03a6cf774dc3726bdd3dace4e9fc02b45acecc8e Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 19 Aug 2026 07:31:31 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20lesende=20API=20f=C3=BCr=20den=20Buc?= =?UTF-8?q?hungsabgleich=20(Issue=20#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue, rein lesende HTTP-Schnittstelle für den Finanzmanager: GET /api/v1/buchungen sucht Buchungen anhand Zeitraum, Betrag (mit Toleranz) und Währung; GET /api/v1/status dient als Verbindungstest. Beide sind per HTTP Basic Auth mit den bestehenden FSCash-Zugangsdaten geschützt und rechnen die intern positiv geführten Bargeldausgaben in die vom Finanzmanager erwartete Vorzeichenkonvention (Ausgaben negativ) um. Co-Authored-By: Claude Sonnet 5 --- README.md | 5 + html/.htaccess | 15 +++ html/api/v1/_auth.php | 38 ++++++ html/api/v1/buchungen.php | 81 ++++++++++++ html/api/v1/status.php | 22 ++++ html/lib/fscash.php | 185 ++++++++++++++++++++++++++++ tests/FscashTest.php | 250 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 596 insertions(+) create mode 100644 html/api/v1/_auth.php create mode 100644 html/api/v1/buchungen.php create mode 100644 html/api/v1/status.php diff --git a/README.md b/README.md index 264b642..a3e1386 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ Dokumentiere Ausgaben on-the-go als Webapp - [ ] Zusammenfassen von mehreren Einträgen zu einer Aktie - [ ] Prüfen ob APIKEYS leer sind und ggfs die Aktualisierung ausblenden +## V.2.5 + +- [X] Lesende HTTP-Schnittstelle für den Buchungsabgleich externer Tools (Issue #13): `GET /api/v1/buchungen` sucht Buchungen anhand von Zeitraum, Betrag (mit Toleranz) und Währung; `GET /api/v1/status` dient als Verbindungstest. Beide Endpunkte sind rein lesend, ohne Seiteneffekte, und per HTTP Basic Auth mit den bestehenden FSCash-Zugangsdaten (Tabelle `users`) geschützt. Die Endpunkte benötigen `mod_rewrite` (siehe `html/.htaccess`), da die Pfade ohne `.php`-Endung erreichbar sein müssen. FSCash führt Bargeldausgaben intern als positiven Betrag; die Schnittstelle rechnet das für den Abgleich in die vom Finanzmanager erwartete Konvention um (Ausgaben negativ). + ## V.2.4 - [X] Buchungszeilen im Dashboard haben ein zusätzliches Feld "abgeglichen" (Standard: nicht abgeglichen). Ein Klick auf die Zeile schaltet den Wert um und speichert ihn sofort in der DB. Abgeglichene Zeilen werden mit grünem Hintergrund dargestellt. @@ -114,6 +118,7 @@ Weiteres 4. config_sample.php in config.php umbenennen und DB credentials setzen 5. webroot (z.B. http://localhost/) aufrufen und mit user test / test123 einloggen. 6. obige Schritte sind nur das Nötigste, um die Funktionalität herzustellen. Insbesondere bei Zugriffsmöglichkeiten aus dem Internet ist es wichtig, https Protokolle, vpns und/oder ggfs zusätzliche Sicherungsmassnahmen (hardening) zu ergreifen. +7. für die lesende API unter `/api/v1/` (siehe Abschnitt V.2.5) muss auf dem Apache-Server `mod_rewrite` aktiviert sein (`a2enmod rewrite`) und `AllowOverride All` für den Webroot gesetzt sein, damit die Regeln aus `html/.htaccess` greifen. Ausserdem solltet Ihr User registrieren und ggfs. die Kategorien anpassen. diff --git a/html/.htaccess b/html/.htaccess index 0acedbe..567a813 100644 --- a/html/.htaccess +++ b/html/.htaccess @@ -2,3 +2,18 @@ Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0" + +# Lesende API für den Buchungsabgleich (Issue #13): erlaubt den vom +# Finanzmanager erwarteten erweiterungslosen Pfaden /api/v1/buchungen und +# /api/v1/status, auf die tatsächlichen PHP-Dateien zu zeigen. Manche +# SAPIs (z. B. php-fpm) geben den Authorization-Header nicht automatisch an +# PHP weiter, daher wird er hier explizit durchgereicht (für Basic Auth). + + RewriteEngine On + + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + RewriteRule ^api/v1/buchungen$ api/v1/buchungen.php [L,QSA] + RewriteRule ^api/v1/status$ api/v1/status.php [L,QSA] + diff --git a/html/api/v1/_auth.php b/html/api/v1/_auth.php new file mode 100644 index 0000000..ee9af5c --- /dev/null +++ b/html/api/v1/_auth.php @@ -0,0 +1,38 @@ +prepare('SELECT password FROM users WHERE username = :username'); + $stmt->execute(['username' => $credentials['username']]); + $hash = $stmt->fetchColumn(); + if ($hash !== false && password_verify($credentials['password'], $hash)) { + $authenticated = true; + } + } + + if (!$authenticated) { + header('WWW-Authenticate: Basic realm="FSCash API"'); + http_response_code(401); + echo json_encode( + fscash_api_error('Zugangsdaten fehlen oder stimmen nicht.'), + JSON_UNESCAPED_UNICODE + ); + exit; + } +} diff --git a/html/api/v1/buchungen.php b/html/api/v1/buchungen.php new file mode 100644 index 0000000..bd8fe94 --- /dev/null +++ b/html/api/v1/buchungen.php @@ -0,0 +1,81 @@ + 0, 'buchungen' => []], JSON_UNESCAPED_UNICODE); + exit; +} + +try { + $matchParams = [ + 'von' => $validated['von'], + 'bis' => $validated['bis'], + 'betrag' => $validated['betrag'], + 'toleranz' => $validated['toleranz'], + ]; + + $countStmt = $pdo->prepare(' + SELECT COUNT(*) + FROM entries e + WHERE e.date BETWEEN :von AND :bis + AND ABS((-1 * e.amount) - :betrag) <= :toleranz + '); + $countStmt->execute($matchParams); + $anzahl = (int)$countStmt->fetchColumn(); + + $selectStmt = $pdo->prepare(' + SELECT e.id, e.date AS datum, e.amount AS betrag, e.subject AS zweck, + c.name AS kategorie, pm.name AS konto + FROM entries e + LEFT JOIN categories c ON e.category = c.category_id + LEFT JOIN payment_methods pm ON e.payment_method = pm.payment_method_id + WHERE e.date BETWEEN :von AND :bis + AND ABS((-1 * e.amount) - :betrag) <= :toleranz + ORDER BY e.date DESC, e.id DESC + LIMIT :limit + '); + $selectStmt->bindValue(':von', $validated['von']); + $selectStmt->bindValue(':bis', $validated['bis']); + $selectStmt->bindValue(':betrag', $validated['betrag']); + $selectStmt->bindValue(':toleranz', $validated['toleranz']); + $selectStmt->bindValue(':limit', $validated['limit'], PDO::PARAM_INT); + $selectStmt->execute(); + $rows = $selectStmt->fetchAll(); + + echo json_encode([ + 'anzahl' => $anzahl, + 'buchungen' => array_map('fscash_format_buchung', $rows), + ], JSON_UNESCAPED_UNICODE); +} catch (PDOException $e) { + http_response_code(500); + echo json_encode( + fscash_api_error('Interner Fehler bei der Buchungssuche.'), + JSON_UNESCAPED_UNICODE + ); +} diff --git a/html/api/v1/status.php b/html/api/v1/status.php new file mode 100644 index 0000000..24e9b3e --- /dev/null +++ b/html/api/v1/status.php @@ -0,0 +1,22 @@ + 'FSCash', + 'version' => FSCASH_API_VERSION, +], JSON_UNESCAPED_UNICODE); diff --git a/html/lib/fscash.php b/html/lib/fscash.php index 092f8c0..a8cf959 100644 --- a/html/lib/fscash.php +++ b/html/lib/fscash.php @@ -11,6 +11,11 @@ declare(strict_types=1); +/** + * Von /api/v1/status gemeldete Versionsnummer (Issue #13). + */ +const FSCASH_API_VERSION = '2.5'; + /** * Normalisiert Monat/Jahr, wenn beim Monats-Blättern (< 1 bzw. > 12) die * Jahresgrenze überschritten wird. @@ -231,3 +236,183 @@ function fscash_pending_migrations(array $availableFiles, array $appliedFilename fn(string $f): bool => !in_array(basename($f), $appliedFilenames, true) )); } + +/** + * Baut eine einheitliche Fehler-Antwort für die lesende API (Issue #13). + * + * @return array{fehler:string} + */ +function fscash_api_error(string $message): array +{ + return ['fehler' => $message]; +} + +/** + * Parst den Wert eines Authorization-Headers für HTTP Basic Auth. + * + * @return array{username:string,password:string}|null Null, wenn der Header fehlt oder nicht dem Basic-Schema entspricht. + */ +function fscash_parse_basic_auth_header(?string $headerValue): ?array +{ + if ($headerValue === null || stripos($headerValue, 'Basic ') !== 0) { + return null; + } + + $decoded = base64_decode(trim(substr($headerValue, 6)), true); + if ($decoded === false || !str_contains($decoded, ':')) { + return null; + } + + [$username, $password] = explode(':', $decoded, 2); + + return ['username' => $username, 'password' => $password]; +} + +/** + * Prüft, ob ein String ein gültiges Kalenderdatum im Format JJJJ-MM-TT ist. + */ +function fscash_is_valid_date(string $value): bool +{ + if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $m)) { + return false; + } + + return checkdate((int)$m[2], (int)$m[3], (int)$m[1]); +} + +/** + * Validiert die Query-Parameter der Buchungssuche (/api/v1/buchungen, Issue #13). + * + * Reine Validierung ohne DB-Zugriff: prüft Pflichtfelder, Formate und die + * Reihenfolge von 'von'/'bis'. Bei Erfolg ist 'error' null und die übrigen + * Felder sind typisiert und einsatzbereit; sonst ist 'error' die für den + * Finanzmanager lesbare Fehlermeldung (siehe Abschnitt "Fehlerverhalten"). + * + * @param array $query Roher Query-String, z. B. $_GET. + * @return array{error:?string,von:?string,bis:?string,betrag:?float,toleranz:float,waehrung:string,limit:int} + */ +function fscash_validate_buchungen_query(array $query): array +{ + $result = [ + 'error' => null, + 'von' => null, + 'bis' => null, + 'betrag' => null, + 'toleranz' => 0.0, + 'waehrung' => 'EUR', + 'limit' => 20, + ]; + + $von = isset($query['von']) ? trim((string)$query['von']) : ''; + if ($von === '') { + $result['error'] = "Parameter 'von' fehlt."; + return $result; + } + if (!fscash_is_valid_date($von)) { + $result['error'] = "Parameter 'von' ist unbrauchbar."; + return $result; + } + + $bis = isset($query['bis']) ? trim((string)$query['bis']) : ''; + if ($bis === '') { + $result['error'] = "Parameter 'bis' fehlt."; + return $result; + } + if (!fscash_is_valid_date($bis)) { + $result['error'] = "Parameter 'bis' ist unbrauchbar."; + return $result; + } + + if ($von > $bis) { + $result['error'] = "Parameter 'von' darf nicht nach 'bis' liegen."; + return $result; + } + + $betragRaw = isset($query['betrag']) ? trim((string)$query['betrag']) : ''; + if ($betragRaw === '') { + $result['error'] = "Parameter 'betrag' fehlt."; + return $result; + } + if (!preg_match('/^-?\d+(\.\d+)?$/', $betragRaw)) { + $result['error'] = "Parameter 'betrag' ist unbrauchbar."; + return $result; + } + + if (isset($query['toleranz']) && trim((string)$query['toleranz']) !== '') { + $toleranzRaw = trim((string)$query['toleranz']); + if (!preg_match('/^\d+(\.\d+)?$/', $toleranzRaw)) { + $result['error'] = "Parameter 'toleranz' ist unbrauchbar."; + return $result; + } + $result['toleranz'] = (float)$toleranzRaw; + } + + if (isset($query['waehrung']) && trim((string)$query['waehrung']) !== '') { + $waehrungRaw = trim((string)$query['waehrung']); + if (!preg_match('/^[A-Za-z]{3}$/', $waehrungRaw)) { + $result['error'] = "Parameter 'waehrung' ist unbrauchbar."; + return $result; + } + $result['waehrung'] = strtoupper($waehrungRaw); + } + + if (isset($query['limit']) && trim((string)$query['limit']) !== '') { + $limitRaw = trim((string)$query['limit']); + if (!preg_match('/^\d+$/', $limitRaw) || (int)$limitRaw < 1) { + $result['error'] = "Parameter 'limit' ist unbrauchbar."; + return $result; + } + $result['limit'] = (int)$limitRaw; + } + + $result['von'] = $von; + $result['bis'] = $bis; + $result['betrag'] = (float)$betragRaw; + + return $result; +} + +/** + * Rechnet einen in FSCash als positive Bargeldausgabe gespeicherten Betrag in + * die vom Finanzmanager erwartete Vorzeichenkonvention um (Issue #13). + * + * FSCash kennt intern keine Einnahmen und führt Bargeldausgaben stets als + * positiven Betrag. Der Finanzmanager führt Ausgaben negativ, daher wird das + * Vorzeichen für den Abgleich gedreht: eine gesuchte Ausgabe von -12.50 muss + * auf einen in FSCash gespeicherten Betrag von 12.50 treffen. + */ +function fscash_signed_betrag(float $storedAmount): float +{ + return -$storedAmount; +} + +/** + * Baut aus einer DB-Zeile der Buchungssuche das vom Finanzmanager erwartete + * JSON-Objekt für eine einzelne Buchung (Feldnamen laut Issue #13). + * + * Erwartete Zeilen-Schlüssel: id, datum, betrag (intern positiv gespeichert), + * zweck sowie optional kategorie/konto (null, falls die verknüpfte + * Kategorie/Zahlweise gelöscht wurde). + * + * @param array $row + * @return array + */ +function fscash_format_buchung(array $row): array +{ + $buchung = [ + 'id' => (string)$row['id'], + 'datum' => (string)$row['datum'], + 'betrag' => round(fscash_signed_betrag((float)$row['betrag']), 2), + 'waehrung' => 'EUR', + 'zweck' => (string)$row['zweck'], + ]; + + if (!empty($row['kategorie'])) { + $buchung['kategorie'] = (string)$row['kategorie']; + } + if (!empty($row['konto'])) { + $buchung['konto'] = (string)$row['konto']; + } + + return $buchung; +} diff --git a/tests/FscashTest.php b/tests/FscashTest.php index 164fb9f..361df98 100644 --- a/tests/FscashTest.php +++ b/tests/FscashTest.php @@ -330,4 +330,254 @@ final class FscashTest extends TestCase $this->assertSame([], fscash_pending_migrations($available, $applied)); } + + // --- fscash_parse_basic_auth_header -------------------------------- + + public function testParseBasicAuthHeaderMitGueltigenZugangsdaten(): void + { + $header = 'Basic ' . base64_encode('finanzmanager:geheim123'); + + $this->assertSame( + ['username' => 'finanzmanager', 'password' => 'geheim123'], + fscash_parse_basic_auth_header($header) + ); + } + + public function testParseBasicAuthHeaderErlaubtDoppelpunktImPasswort(): void + { + $header = 'Basic ' . base64_encode('user:pass:mit:doppelpunkt'); + + $this->assertSame( + ['username' => 'user', 'password' => 'pass:mit:doppelpunkt'], + fscash_parse_basic_auth_header($header) + ); + } + + public function testParseBasicAuthHeaderOhneHeaderLiefertNull(): void + { + $this->assertNull(fscash_parse_basic_auth_header(null)); + } + + public function testParseBasicAuthHeaderOhneBasicSchemaLiefertNull(): void + { + $this->assertNull(fscash_parse_basic_auth_header('Bearer irgendwas')); + } + + public function testParseBasicAuthHeaderMitUngueltigemBase64LiefertNull(): void + { + $this->assertNull(fscash_parse_basic_auth_header('Basic ###nicht-base64###')); + } + + public function testParseBasicAuthHeaderOhneDoppelpunktLiefertNull(): void + { + $this->assertNull(fscash_parse_basic_auth_header('Basic ' . base64_encode('ohnedoppelpunkt'))); + } + + // --- fscash_is_valid_date ------------------------------------------- + + public function testIsValidDateMitGueltigemDatum(): void + { + $this->assertTrue(fscash_is_valid_date('2026-08-19')); + $this->assertTrue(fscash_is_valid_date('2024-02-29')); + } + + public function testIsValidDateMitUngueltigemDatumOderFormat(): void + { + $this->assertFalse(fscash_is_valid_date('2026-02-30')); + $this->assertFalse(fscash_is_valid_date('2023-02-29')); + $this->assertFalse(fscash_is_valid_date('19.08.2026')); + $this->assertFalse(fscash_is_valid_date('2026-8-19')); + $this->assertFalse(fscash_is_valid_date('')); + } + + // --- fscash_validate_buchungen_query --------------------------------- + + public function testValidateBuchungenQueryMitPflichtfeldern(): void + { + $result = fscash_validate_buchungen_query([ + 'von' => '2026-08-09', + 'bis' => '2026-08-19', + 'betrag' => '-12.50', + ]); + + $this->assertNull($result['error']); + $this->assertSame('2026-08-09', $result['von']); + $this->assertSame('2026-08-19', $result['bis']); + $this->assertSame(-12.50, $result['betrag']); + $this->assertSame(0.0, $result['toleranz']); + $this->assertSame('EUR', $result['waehrung']); + $this->assertSame(20, $result['limit']); + } + + public function testValidateBuchungenQueryMitAllenOptionalenParametern(): void + { + $result = fscash_validate_buchungen_query([ + 'von' => '2026-08-09', + 'bis' => '2026-08-19', + 'betrag' => '-12.50', + 'toleranz' => '0.50', + 'waehrung' => 'eur', + 'limit' => '5', + ]); + + $this->assertNull($result['error']); + $this->assertSame(0.50, $result['toleranz']); + $this->assertSame('EUR', $result['waehrung']); + $this->assertSame(5, $result['limit']); + } + + public function testValidateBuchungenQueryOhneVonLiefertFehler(): void + { + $result = fscash_validate_buchungen_query(['bis' => '2026-08-19', 'betrag' => '-12.50']); + + $this->assertSame("Parameter 'von' fehlt.", $result['error']); + } + + public function testValidateBuchungenQueryOhneBisLiefertFehler(): void + { + $result = fscash_validate_buchungen_query(['von' => '2026-08-09', 'betrag' => '-12.50']); + + $this->assertSame("Parameter 'bis' fehlt.", $result['error']); + } + + public function testValidateBuchungenQueryMitUngueltigemDatumLiefertFehler(): void + { + $result = fscash_validate_buchungen_query([ + 'von' => '2026-08-32', + 'bis' => '2026-08-19', + 'betrag' => '-12.50', + ]); + + $this->assertSame("Parameter 'von' ist unbrauchbar.", $result['error']); + } + + public function testValidateBuchungenQueryMitVonNachBisLiefertFehler(): void + { + $result = fscash_validate_buchungen_query([ + 'von' => '2026-08-19', + 'bis' => '2026-08-09', + 'betrag' => '-12.50', + ]); + + $this->assertSame("Parameter 'von' darf nicht nach 'bis' liegen.", $result['error']); + } + + public function testValidateBuchungenQueryOhneBetragLiefertFehler(): void + { + $result = fscash_validate_buchungen_query(['von' => '2026-08-09', 'bis' => '2026-08-19']); + + $this->assertSame("Parameter 'betrag' fehlt.", $result['error']); + } + + public function testValidateBuchungenQueryMitUnbrauchbaremBetragLiefertFehler(): void + { + $result = fscash_validate_buchungen_query([ + 'von' => '2026-08-09', + 'bis' => '2026-08-19', + 'betrag' => 'zwölffünfzig', + ]); + + $this->assertSame("Parameter 'betrag' ist unbrauchbar.", $result['error']); + } + + public function testValidateBuchungenQueryMitNegativerToleranzLiefertFehler(): void + { + $result = fscash_validate_buchungen_query([ + 'von' => '2026-08-09', + 'bis' => '2026-08-19', + 'betrag' => '-12.50', + 'toleranz' => '-1', + ]); + + $this->assertSame("Parameter 'toleranz' ist unbrauchbar.", $result['error']); + } + + public function testValidateBuchungenQueryMitUngueltigerWaehrungLiefertFehler(): void + { + $result = fscash_validate_buchungen_query([ + 'von' => '2026-08-09', + 'bis' => '2026-08-19', + 'betrag' => '-12.50', + 'waehrung' => 'EURO', + ]); + + $this->assertSame("Parameter 'waehrung' ist unbrauchbar.", $result['error']); + } + + public function testValidateBuchungenQueryMitUngueltigemLimitLiefertFehler(): void + { + $result = fscash_validate_buchungen_query([ + 'von' => '2026-08-09', + 'bis' => '2026-08-19', + 'betrag' => '-12.50', + 'limit' => '0', + ]); + + $this->assertSame("Parameter 'limit' ist unbrauchbar.", $result['error']); + } + + // --- fscash_signed_betrag --------------------------------------------- + + public function testSignedBetragDrehtVorzeichenVonGespeicherterAusgabe(): void + { + $this->assertSame(-12.50, fscash_signed_betrag(12.50)); + $this->assertSame(0.0, fscash_signed_betrag(0.0)); + } + + // --- fscash_format_buchung --------------------------------------------- + + public function testFormatBuchungMitAllenFeldern(): void + { + $row = [ + 'id' => 42, + 'datum' => '2026-08-14', + 'betrag' => 12.50, + 'zweck' => 'Wocheneinkauf', + 'kategorie' => 'Lebensmittel', + 'konto' => 'Portemonnaie', + ]; + + $this->assertSame( + [ + 'id' => '42', + 'datum' => '2026-08-14', + 'betrag' => -12.50, + 'waehrung' => 'EUR', + 'zweck' => 'Wocheneinkauf', + 'kategorie' => 'Lebensmittel', + 'konto' => 'Portemonnaie', + ], + fscash_format_buchung($row) + ); + } + + public function testFormatBuchungOhneKategorieUndKontoLaesstFelderWeg(): void + { + $row = [ + 'id' => 42, + 'datum' => '2026-08-14', + 'betrag' => 12.50, + 'zweck' => 'Wocheneinkauf', + 'kategorie' => null, + 'konto' => null, + ]; + + $this->assertSame( + [ + 'id' => '42', + 'datum' => '2026-08-14', + 'betrag' => -12.50, + 'waehrung' => 'EUR', + 'zweck' => 'Wocheneinkauf', + ], + fscash_format_buchung($row) + ); + } + + // --- fscash_api_error --------------------------------------------------- + + public function testApiErrorBautFehlerobjekt(): void + { + $this->assertSame(['fehler' => 'Parameter fehlt.'], fscash_api_error('Parameter fehlt.')); + } }