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.'));
+ }
}