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>
418 lines
13 KiB
PHP
418 lines
13 KiB
PHP
<?php
|
|
/**
|
|
* Reine, DB- und netzwerkfreie Hilfsfunktionen für FSCash.
|
|
*
|
|
* Diese Datei enthält bewusst nur Funktionen ohne Seiteneffekte (keine
|
|
* Session-, DB- oder HTTP-Zugriffe), damit sie ohne Testumgebung
|
|
* (Datenbank, Mock-Server) per Unit-Test geprüft werden können.
|
|
* Die aufrufenden Seiten unter html/ und migrations/ binden diese Datei ein
|
|
* und übernehmen weiterhin die Seiteneffekte (DB-Query, HTTP-Request, ...).
|
|
*/
|
|
|
|
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.
|
|
*
|
|
* @return array{month:int,year:int}
|
|
*/
|
|
function fscash_normalize_month_year(int $month, int $year): array
|
|
{
|
|
if ($month < 1) {
|
|
$month = 12;
|
|
$year--;
|
|
} elseif ($month > 12) {
|
|
$month = 1;
|
|
$year++;
|
|
}
|
|
|
|
return ['month' => $month, 'year' => $year];
|
|
}
|
|
|
|
/**
|
|
* Ermittelt anhand der konfigurierten ID-Listen, ob ein Benutzer Admin- bzw.
|
|
* Aktien-Rechte hat.
|
|
*
|
|
* @param int[] $adminIds
|
|
* @param int[] $aktienIds
|
|
* @return array{is_admin:bool,is_aktien:bool}
|
|
*/
|
|
function fscash_user_roles(int $userId, array $adminIds, array $aktienIds): array
|
|
{
|
|
return [
|
|
'is_admin' => in_array($userId, $adminIds, true),
|
|
'is_aktien' => in_array($userId, $aktienIds, true),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Berechnet Kaufsumme, aktuellen Wert, Gewinn/Verlust (GuV) und die
|
|
* zugehörige CSS-Klasse für einen Aktien-Eintrag.
|
|
*
|
|
* @return array{kaufSumme:float,aktuellerWert:float,guv:float,guvClass:string}
|
|
*/
|
|
function fscash_aktie_guv(float $kaufpreis, float $anzahl, float $kurs): array
|
|
{
|
|
$kaufSumme = $kaufpreis * $anzahl;
|
|
$aktuellerWert = $anzahl * $kurs;
|
|
$guv = $aktuellerWert - $kaufSumme;
|
|
|
|
$guvClass = '';
|
|
if ($guv > 0) {
|
|
$guvClass = 'guv-positive';
|
|
} elseif ($guv < 0) {
|
|
$guvClass = 'guv-negative';
|
|
}
|
|
|
|
return [
|
|
'kaufSumme' => $kaufSumme,
|
|
'aktuellerWert' => $aktuellerWert,
|
|
'guv' => $guv,
|
|
'guvClass' => $guvClass,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Wählt aus den von der OpenFIGI-API gelieferten Mapping-Einträgen das
|
|
* passende Symbol aus. Bevorzugt wird ein Eintrag an der Deutschen Börse
|
|
* Xetra (XETR), ansonsten wird der erste Eintrag verwendet.
|
|
*
|
|
* @param array<int,array{ticker?:string,exchCode?:string}> $figiEntries Der "data"-Teil der OpenFIGI-Antwort.
|
|
*/
|
|
function fscash_select_figi_symbol(array $figiEntries): ?string
|
|
{
|
|
if (empty($figiEntries)) {
|
|
return null;
|
|
}
|
|
|
|
$symbolEntry = null;
|
|
foreach ($figiEntries as $entry) {
|
|
if (isset($entry['exchCode']) && $entry['exchCode'] === 'XETR') {
|
|
$symbolEntry = $entry;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($symbolEntry === null) {
|
|
$symbolEntry = $figiEntries[0];
|
|
}
|
|
|
|
if (isset($symbolEntry['ticker']) && !empty($symbolEntry['ticker'])) {
|
|
$exchange = $symbolEntry['exchCode'] ?? '';
|
|
return $exchange ? "{$symbolEntry['ticker']}.{$exchange}" : $symbolEntry['ticker'];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Ermittelt die im Personenreport ausgewählten Kategorie-IDs: die per
|
|
* Request übergebenen, sofern vorhanden, sonst alle verfügbaren.
|
|
*
|
|
* @param int[] $allCategoryIds Alle in der DB vorhandenen Kategorie-IDs.
|
|
* @param mixed $requestedCategoryIds Roher Wert aus $_GET['categories'] (oder null, falls nicht gesetzt).
|
|
* @return array<int,mixed>
|
|
*/
|
|
function fscash_resolve_selected_categories(array $allCategoryIds, $requestedCategoryIds): array
|
|
{
|
|
if (is_array($requestedCategoryIds) && $requestedCategoryIds !== []) {
|
|
return $requestedCategoryIds;
|
|
}
|
|
|
|
return $allCategoryIds;
|
|
}
|
|
|
|
/**
|
|
* Ermittelt die im Personenreport ausgewählten Zahlweise-IDs: die per
|
|
* Request übergebenen, sofern vorhanden, sonst alle verfügbaren.
|
|
*
|
|
* @param int[] $allPaymentMethodIds Alle in der DB vorhandenen Zahlweise-IDs.
|
|
* @param mixed $requestedPaymentMethodIds Roher Wert aus $_GET['payment_methods'] (oder null, falls nicht gesetzt).
|
|
* @return array<int,mixed>
|
|
*/
|
|
function fscash_resolve_selected_payment_methods(array $allPaymentMethodIds, $requestedPaymentMethodIds): array
|
|
{
|
|
if (is_array($requestedPaymentMethodIds) && $requestedPaymentMethodIds !== []) {
|
|
return $requestedPaymentMethodIds;
|
|
}
|
|
|
|
return $allPaymentMethodIds;
|
|
}
|
|
|
|
/**
|
|
* Extrahiert den aktuellen Kurs aus einer Alpha-Vantage GLOBAL_QUOTE-Antwort.
|
|
*
|
|
* @param array<string,mixed> $alphaVantageResponse
|
|
*/
|
|
function fscash_extract_quote_price(array $alphaVantageResponse): ?float
|
|
{
|
|
$price = $alphaVantageResponse['Global Quote']['05. price'] ?? null;
|
|
|
|
if ($price === null || $price === '') {
|
|
return null;
|
|
}
|
|
|
|
return (float)$price;
|
|
}
|
|
|
|
/**
|
|
* Wandelt die Alpha-Vantage SYMBOL_SEARCH-Antwort in die vom Frontend
|
|
* erwartete, vereinfachte Liste (symbol/name/region) um.
|
|
*
|
|
* @param array<string,mixed> $alphaVantageResponse
|
|
* @return array<int,array{symbol:string,name:string,region:string}>
|
|
*/
|
|
function fscash_map_symbol_search_results(array $alphaVantageResponse): array
|
|
{
|
|
$matches = $alphaVantageResponse['bestMatches'] ?? [];
|
|
|
|
$result = [];
|
|
foreach ($matches as $match) {
|
|
$result[] = [
|
|
'symbol' => $match['1. symbol'] ?? '',
|
|
'name' => $match['2. name'] ?? '',
|
|
'region' => $match['4. region'] ?? '',
|
|
];
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Prüft, ob die per JSON-Body übermittelten Daten zum Speichern eines
|
|
* Aktien-Symbols gültig sind (id und symbol müssen "truthy" sein).
|
|
*
|
|
* @param mixed $id
|
|
* @param mixed $symbol
|
|
*/
|
|
function fscash_is_valid_symbol_payload($id, $symbol): bool
|
|
{
|
|
return (bool)$id && (bool)$symbol;
|
|
}
|
|
|
|
/**
|
|
* Baut aus einem DB-Eintrag des Monatsreports die Zeile für den CSV-Export.
|
|
*
|
|
* @param array<string,mixed> $entry
|
|
* @return array<int,string>
|
|
*/
|
|
function fscash_format_report_month_csv_row(array $entry): array
|
|
{
|
|
return [
|
|
(string)($entry['id'] ?? ''),
|
|
(string)($entry['datum'] ?? ''),
|
|
(string)($entry['benutzer_name'] ?? ''),
|
|
(string)($entry['kategorie_id'] ?? ''),
|
|
(string)($entry['kategorie_name'] ?? ''),
|
|
(string)($entry['betreff'] ?? ''),
|
|
number_format((float)($entry['betrag'] ?? 0), 2, ',', '.'),
|
|
(string)($entry['zahlweise'] ?? $entry['zahlungsmethode'] ?? ''),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Ermittelt aus allen verfügbaren Migrations-Dateien diejenigen, die laut
|
|
* schema_migrations-Tabelle noch nicht angewendet wurden. Die Rückgabe ist
|
|
* alphabetisch/numerisch sortiert, damit Migrationen in der richtigen
|
|
* Reihenfolge angewendet werden.
|
|
*
|
|
* @param string[] $availableFiles Absolute oder relative Pfade der *.sql-Dateien.
|
|
* @param string[] $appliedFilenames Basenamen der bereits angewendeten Migrationen.
|
|
* @return string[] Pfade der noch ausstehenden Migrationen, sortiert.
|
|
*/
|
|
function fscash_pending_migrations(array $availableFiles, array $appliedFilenames): array
|
|
{
|
|
$files = $availableFiles;
|
|
sort($files, SORT_STRING);
|
|
|
|
return array_values(array_filter(
|
|
$files,
|
|
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;
|
|
}
|