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 $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 */ 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 */ 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 $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 $alphaVantageResponse * @return array */ 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 $entry * @return array */ 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 $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; }