Feature: lesende API für den Buchungsabgleich (Issue #13)

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 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-19 07:31:31 +02:00
parent efae709868
commit 03a6cf774d
7 changed files with 596 additions and 0 deletions

View file

@ -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.

View file

@ -2,3 +2,18 @@
<FilesMatch "\.(css|js)$">
Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
</FilesMatch>
# 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).
<IfModule mod_rewrite.c>
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]
</IfModule>

38
html/api/v1/_auth.php Normal file
View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/**
* Prüft die HTTP-Basic-Auth-Zugangsdaten der lesenden API (Issue #13) gegen
* die bestehende FSCash-Benutzertabelle (dieselben Zugangsdaten wie beim
* Login). Bei fehlender oder falscher Authentifizierung wird sofort eine
* 401-Antwort gesendet und die Ausführung beendet.
*/
function fscash_api_require_auth(PDO $pdo): void
{
$authHeader = $_SERVER['HTTP_AUTHORIZATION']
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
?? null;
$credentials = fscash_parse_basic_auth_header($authHeader);
$authenticated = false;
if ($credentials !== null) {
$stmt = $pdo->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;
}
}

81
html/api/v1/buchungen.php Normal file
View file

@ -0,0 +1,81 @@
<?php
/**
* GET /api/v1/buchungen — Buchungssuche für den Abgleich im Finanzmanager
* (Issue #13). Rein lesend, keine Seiteneffekte.
*
* FSCash führt Bargeldausgaben intern als positiven Betrag; der
* Finanzmanager sendet und erwartet Ausgaben negativ. Die Umrechnung
* übernimmt fscash_signed_betrag() in html/lib/fscash.php.
*/
declare(strict_types=1);
require __DIR__ . '/../../config.php';
require_once __DIR__ . '/../../lib/fscash.php';
require_once __DIR__ . '/_auth.php';
header('Content-Type: application/json; charset=utf-8');
fscash_api_require_auth($pdo);
$validated = fscash_validate_buchungen_query($_GET);
if ($validated['error'] !== null) {
http_response_code(400);
echo json_encode(fscash_api_error($validated['error']), JSON_UNESCAPED_UNICODE);
exit;
}
// FSCash speichert ausschließlich EUR-Beträge; andere Währungen liefern
// definitionsgemäß keine Treffer (kein Fehler, siehe Issue #13 Abschnitt 4).
if ($validated['waehrung'] !== 'EUR') {
echo json_encode(['anzahl' => 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
);
}

22
html/api/v1/status.php Normal file
View file

@ -0,0 +1,22 @@
<?php
/**
* GET /api/v1/status — Verbindungstest für den Finanzmanager (Issue #13).
*
* Rein lesend, keine Seiteneffekte. Meldet nur, ob Adresse und Zugangsdaten
* stimmen; setzt keine Suchabfrage voraus.
*/
declare(strict_types=1);
require __DIR__ . '/../../config.php';
require_once __DIR__ . '/../../lib/fscash.php';
require_once __DIR__ . '/_auth.php';
header('Content-Type: application/json; charset=utf-8');
fscash_api_require_auth($pdo);
echo json_encode([
'name' => 'FSCash',
'version' => FSCASH_API_VERSION,
], JSON_UNESCAPED_UNICODE);

View file

@ -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<string,mixed> $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<string,mixed> $row
* @return array<string,mixed>
*/
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;
}

View file

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