moduleBasePath = rtrim($moduleBasePath, '/'); $this->config = ModuleConfig::load($this->moduleBasePath); $requestUri = (string) ($_SERVER['REQUEST_URI'] ?? ''); $requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?: ''); $debugConfig = $this->config->debug(); $debugEnabled = filter_var( $_GET['debug'] ?? $_SERVER['HTTP_X_MINING_DEBUG'] ?? $_COOKIE['mining_checker_debug'] ?? ($debugConfig['enabled'] ?? false), FILTER_VALIDATE_BOOL ); $latestDebugFilePath = rtrim($this->config->debugDir(), '/') . '/latest-server.json'; $isLatestDebugRequest = str_ends_with($requestPath, '/api/mining-checker/v1/debug/latest') || $requestPath === 'api/mining-checker/v1/debug/latest' || $requestPath === '/api/mining-checker/v1/debug/latest'; $debugFilePath = ($debugEnabled && !$isLatestDebugRequest) ? $latestDebugFilePath : null; DebugState::setLatestFilePath($latestDebugFilePath); $this->debug = new DebugTrace((bool) $debugEnabled, $debugFilePath); $this->debug->add('router.init', [ 'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET', 'uri' => $requestUri, ]); $this->ocr = new OcrService($this->config); } public function handle(string $relativePath): never { try { $this->configureRuntimeGuards(); $this->releaseSessionLock(); $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); $path = trim($relativePath, '/'); $this->debug->add('router.handle.start', [ 'path' => $path, 'method' => $method, ]); if ($path === 'v1/health') { $this->respond(['ok' => true, 'module' => 'mining-checker']); } if ($path === 'v1/debug/runtime' && $method === 'GET') { $this->respond(['data' => $this->debugRuntime()]); } if ($path === 'v1/debug/pdo' && $method === 'GET') { $this->respond(['data' => $this->debugPdo()]); } if ($path === 'v1/debug/schema-meta' && $method === 'GET') { $this->respond(['data' => $this->debugSchemaMeta()]); } if ($path === 'v1/debug/latest' && $method === 'GET') { $this->respond(['data' => $this->debugLatest()]); } $matches = []; if (!preg_match('~^v1/projects/([a-zA-Z0-9_-]+)(?:/(.*))?$~', $path, $matches)) { throw new ApiException('Unbekannter API-Pfad.', 404, ['path' => $path]); } $projectKey = $matches[1]; $resource = trim((string) ($matches[2] ?? ''), '/'); $this->applyRouteRuntimeGuards($resource); if ($resource === 'schema-status' && $method === 'GET') { $this->respond(['data' => $this->simpleSchemaStatus()]); } if ($resource === 'initialize' && $method === 'POST') { $input = Http::input(); $this->respond([ 'data' => $this->schemaManager()->initializeSchema(!empty($input['drop_existing'])), ], 201); } if ($resource === 'sql-import' && $method === 'POST') { if (!isset($_FILES['sql_file']) || !is_array($_FILES['sql_file'])) { throw new ApiException('Feld sql_file fehlt.', 422); } $this->respond([ 'data' => $this->schemaManager()->importSqlFile($_FILES['sql_file']), ], 201); } if ($resource === 'upgrade' && $method === 'POST') { $this->respond([ 'data' => $this->schemaManager()->upgradeSchemaDirect(), ], 201); } if ($resource === 'rebuild-preserve-core' && $method === 'POST') { $this->respond([ 'data' => $this->rebuildPreservingCoreData($projectKey), ], 201); } if ($resource === 'connection-test' && $method === 'GET') { $this->respond(['data' => $this->connectionStatus()]); } if ($resource === 'fx-refresh' && $method === 'POST') { $this->respond(['data' => $this->refreshFxRates(Http::input())], 201); } if ($resource === 'fx-probe' && $method === 'POST') { $this->respond(['data' => $this->probeFxRates(Http::input())], 200); } if ($resource === 'currencies-refresh' && $method === 'POST') { $this->respond(['data' => $this->refreshCurrencies()], 201); } if ($resource === 'currencies-probe' && $method === 'POST') { $this->respond(['data' => $this->probeCurrencies()], 200); } if ($resource === 'fx-history' && $method === 'GET') { $this->respond(['data' => $this->fxHistory()]); } if ($resource === 'legacy-fx-migrate' && $method === 'POST') { $this->respond(['data' => $this->migrateLegacyFxRates($projectKey)], 201); } if ($resource === 'bootstrap' && $method === 'GET') { $view = trim((string) ($_GET['view'] ?? 'overview')); $this->respond(['data' => $this->bootstrap($projectKey, $view)]); } if ($resource === 'measurements' && $method === 'GET') { Http::json(['data' => $this->measurements($projectKey)]); } if ($resource === 'measurements' && $method === 'POST') { $this->repository()->ensureProject($projectKey); Http::json(['data' => $this->createMeasurement($projectKey, Http::input())], 201); } if ($resource === 'measurements-import' && $method === 'POST') { $this->repository()->ensureProject($projectKey); Http::json(['data' => $this->importMeasurements($projectKey, Http::input())], 201); } if ($resource === 'ocr-preview' && $method === 'POST') { if (!isset($_FILES['image'])) { throw new ApiException('Feld image fehlt.', 422); } $ocrStartedAt = microtime(true); $this->debug->add('ocr.preview.start', [ 'project_key' => $projectKey, 'file_name' => $_FILES['image']['name'] ?? null, 'file_size' => $_FILES['image']['size'] ?? null, ]); $preview = $this->ocr->preview($_FILES['image'], array_merge($_POST, ['project_key' => $projectKey])); $this->debug->add('ocr.preview.end', [ 'project_key' => $projectKey, 'duration_ms' => round((microtime(true) - $ocrStartedAt) * 1000, 2), 'confidence' => $preview['confidence'] ?? null, 'flags' => is_array($preview['flags'] ?? null) ? $preview['flags'] : [], ]); Http::json(['data' => $preview], 201); } if ($resource === 'settings' && $method === 'GET') { Http::json(['data' => $this->settings($projectKey)]); } if ($resource === 'settings' && $method === 'PUT') { $this->repository()->ensureProject($projectKey); Http::json(['data' => $this->saveSettings($projectKey, Http::input())]); } if ($resource === 'targets' && $method === 'GET') { Http::json(['data' => $this->targets($projectKey)]); } if ($resource === 'targets' && $method === 'POST') { $this->repository()->ensureProject($projectKey); Http::json(['data' => $this->saveTarget($projectKey, Http::input())], 201); } if (preg_match('~^targets/(\d+)$~', $resource, $matches) && $method === 'PATCH') { Http::json(['data' => $this->updateTarget($projectKey, (int) $matches[1], Http::input())]); } if (preg_match('~^targets/(\d+)$~', $resource, $matches) && $method === 'DELETE') { $this->deleteTarget($projectKey, (int) $matches[1]); Http::json(['data' => ['deleted' => true]]); } if ($resource === 'dashboards' && $method === 'GET') { Http::json(['data' => $this->dashboards($projectKey)]); } if ($resource === 'dashboards' && $method === 'POST') { $this->repository()->ensureProject($projectKey); Http::json(['data' => $this->saveDashboard($projectKey, Http::input())], 201); } if ($resource === 'dashboard-data' && $method === 'GET') { Http::json(['data' => $this->dashboardData($projectKey, $_GET)]); } if ($resource === 'seed-import' && $method === 'POST') { $this->repository()->ensureProject($projectKey); Http::json(['data' => $this->seedImporter()->import($projectKey)], 201); } if ($resource === 'cost-plans' && $method === 'GET') { Http::json(['data' => $this->costPlans($projectKey)]); } if ($resource === 'cost-plans' && $method === 'POST') { $this->repository()->ensureProject($projectKey); Http::json(['data' => $this->saveCostPlan($projectKey, Http::input())], 201); } if ($resource === 'payouts' && $method === 'GET') { Http::json(['data' => $this->payouts($projectKey)]); } if ($resource === 'payouts' && $method === 'POST') { $this->repository()->ensureProject($projectKey); Http::json(['data' => $this->savePayout($projectKey, Http::input())], 201); } if ($resource === 'miner-offers' && $method === 'GET') { Http::json(['data' => $this->minerOffers($projectKey)]); } if ($resource === 'miner-offers' && $method === 'POST') { $this->repository()->ensureProject($projectKey); Http::json(['data' => $this->saveMinerOffer($projectKey, Http::input())], 201); } if ($resource === 'purchased-miners' && $method === 'GET') { Http::json(['data' => $this->purchasedMiners($projectKey)]); } if (preg_match('~^purchased-miners/(\d+)$~', $resource, $matches) && $method === 'PATCH') { Http::json(['data' => $this->updatePurchasedMiner($projectKey, (int) $matches[1], Http::input())]); } if (preg_match('~^miner-offers/(\d+)/purchase$~', $resource, $matches) && $method === 'POST') { $this->repository()->ensureProject($projectKey); Http::json(['data' => $this->purchaseMiner($projectKey, (int) $matches[1], Http::input())], 201); } if ($resource === 'currencies' && $method === 'GET') { Http::json(['data' => $this->currencies()]); } if ($resource === 'currency-aliases' && $method === 'GET') { Http::json(['data' => $this->currencyAliases()]); } if ($resource === 'currency-aliases' && $method === 'POST') { Http::json(['data' => $this->saveCurrencyAlias(Http::input())], 201); } throw new ApiException('Ressource nicht gefunden.', 404, ['resource' => $resource, 'method' => $method]); } catch (ApiException $exception) { $this->debug->add('router.handle.api_exception', [ 'status' => $exception->statusCode(), 'message' => $exception->getMessage(), 'context' => $exception->context(), ]); Http::json([ 'error' => $exception->getMessage(), 'context' => $exception->context(), ], $exception->statusCode()); } catch (\Throwable $exception) { $this->debug->add('router.handle.error', [ 'type' => get_debug_type($exception), 'message' => $exception->getMessage(), ]); Http::json([ 'error' => 'Unerwarteter Mining-Checker Fehler.', 'context' => ['message' => $exception->getMessage()], ], 500); } } private function bootstrap(string $projectKey, string $view = 'overview'): array { $startedAt = microtime(true); $view = $this->normalizeBootstrapView($view); $this->debug->add('bootstrap.start', [ 'project_key' => $projectKey, 'view' => $view, 'measurement_limit' => self::BOOTSTRAP_MEASUREMENT_LIMIT, 'snapshot_limit' => self::BOOTSTRAP_SNAPSHOT_LIMIT, ]); $settings = $this->safeTimed('bootstrap.settings', fn () => $this->settings($projectKey), [ 'project_key' => $projectKey, 'baseline_measured_at' => null, 'baseline_coins_total' => null, 'daily_cost_amount' => null, 'daily_cost_currency' => 'EUR', 'report_currency' => 'EUR', 'crypto_currency' => 'DOGE', 'display_timezone' => 'Europe/Berlin', 'fx_max_age_hours' => 3, 'module_theme_mode' => 'inherit', 'module_theme_accent' => 'teal', 'preferred_currencies' => ['DOGE', 'USD', 'EUR'], 'cost_plans' => [], 'currencies' => [], 'payouts' => [], 'miner_offers' => [], 'purchased_miners' => [], 'measurement_rates' => [], ], ['project_key' => $projectKey]); $measurements = $this->safeTimed('bootstrap.measurements', fn () => $this->bootstrapMeasurements($projectKey, $settings, $view), [], [ 'project_key' => $projectKey, 'view' => $view, ]); $targets = $this->safeTimed('bootstrap.targets', fn () => $this->bootstrapTargets($projectKey, $view), [], [ 'project_key' => $projectKey, 'view' => $view, ]); $dashboards = $this->safeTimed('bootstrap.dashboards', fn () => $this->bootstrapDashboards($projectKey, $view), [], [ 'project_key' => $projectKey, 'view' => $view, ]); $fxSnapshots = $this->safeTimed('bootstrap.fx_snapshots', fn () => $this->bootstrapFxSnapshots($measurements, $view), [], [ 'view' => $view, 'measurement_count' => is_array($measurements) ? count($measurements) : 0, ]); $summary = $this->safeTimed('bootstrap.summary', fn () => $this->bootstrapSummary($measurements, $settings, $targets, $view), [ 'latest_measurement' => $measurements !== [] ? $measurements[array_key_last($measurements)] : null, 'baseline' => $settings, 'targets' => [], 'payouts' => [], 'miner_offers' => [], ], [ 'view' => $view, 'measurement_count' => is_array($measurements) ? count($measurements) : 0, 'target_count' => is_array($targets) ? count($targets) : 0, ]); $measurementCount = is_array($measurements) ? count($measurements) : 0; $this->debug->add('bootstrap.end', [ 'project_key' => $projectKey, 'duration_ms' => round((microtime(true) - $startedAt) * 1000, 2), 'measurement_count' => $measurementCount, 'target_count' => is_array($targets) ? count($targets) : 0, 'dashboard_count' => is_array($dashboards) ? count($dashboards) : 0, 'snapshot_count' => is_array($fxSnapshots) ? count($fxSnapshots) : 0, ]); return [ 'project' => $this->repository()->getProject($projectKey), 'settings' => $settings, 'measurements' => $measurements, 'targets' => $targets, 'dashboards' => $dashboards, 'fx_snapshots' => $fxSnapshots, 'summary' => $summary, 'bootstrap_meta' => [ 'degraded' => $measurementCount >= self::BOOTSTRAP_MEASUREMENT_LIMIT, 'view' => $view, 'overview_window_days' => self::OVERVIEW_WINDOW_DAYS, 'measurement_limit' => self::BOOTSTRAP_MEASUREMENT_LIMIT, 'snapshot_limit' => self::BOOTSTRAP_SNAPSHOT_LIMIT, 'measurement_count' => $measurementCount, 'duration_ms' => round((microtime(true) - $startedAt) * 1000, 2), ], ]; } private function connectionStatus(): array { $driver = (string) $this->pdo()->getAttribute(PDO::ATTR_DRIVER_NAME); $statement = $this->pdo()->query('SELECT 1'); $ok = (int) $statement->fetchColumn() === 1; return [ 'ok' => $ok, 'driver' => $driver, 'database' => app()->config()->dbConfig['dbname'] ?? null, 'table_prefix' => $this->config->tablePrefix(), ]; } private function debugRuntime(): array { return [ 'ok' => true, 'path' => $_SERVER['REQUEST_URI'] ?? null, 'host' => gethostname() ?: null, 'pid' => function_exists('getmypid') ? getmypid() : null, 'memory_usage' => memory_get_usage(true), 'memory_peak' => memory_get_peak_usage(true), 'time' => date('c'), ]; } private function debugPdo(): array { $startedAt = microtime(true); $pdo = $this->pdo(); $connectedAt = microtime(true); $statement = $pdo->query('SELECT 1'); $ok = (int) $statement->fetchColumn() === 1; $finishedAt = microtime(true); return [ 'ok' => $ok, 'host' => gethostname() ?: null, 'pid' => function_exists('getmypid') ? getmypid() : null, 'driver' => (string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME), 'connect_ms' => round(($connectedAt - $startedAt) * 1000, 2), 'query_ms' => round(($finishedAt - $connectedAt) * 1000, 2), 'memory_usage' => memory_get_usage(true), 'memory_peak' => memory_get_peak_usage(true), ]; } private function debugSchemaMeta(): array { $startedAt = microtime(true); $pdo = $this->pdo(); $connectedAt = microtime(true); $driver = (string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); $prefix = $this->config->tablePrefix(); if ($driver === 'pgsql') { $sql = 'SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_name LIKE :prefix ORDER BY table_name'; } else { $sql = 'SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name LIKE :prefix ORDER BY table_name'; } $statement = $pdo->prepare($sql); $statement->execute(['prefix' => $prefix . '%']); $tables = array_map('strval', $statement->fetchAll(PDO::FETCH_COLUMN) ?: []); $finishedAt = microtime(true); return [ 'ok' => true, 'host' => gethostname() ?: null, 'pid' => function_exists('getmypid') ? getmypid() : null, 'driver' => $driver, 'connect_ms' => round(($connectedAt - $startedAt) * 1000, 2), 'query_ms' => round(($finishedAt - $connectedAt) * 1000, 2), 'table_prefix' => $prefix, 'tables' => $tables, 'memory_usage' => memory_get_usage(true), 'memory_peak' => memory_get_peak_usage(true), ]; } private function simpleSchemaStatus(): array { return $this->schemaManager()->schemaStatus(); } private function rebuildPreservingCoreData(string $projectKey): array { $backup = [ 'project' => $this->repository()->getProject($projectKey), 'settings' => $this->safeRead(fn () => $this->repository()->getSettings($projectKey)), 'currencies' => $this->safeRead(fn () => $this->repository()->listCurrencies(), []), 'currency_aliases' => $this->safeRead(fn () => $this->repository()->listCurrencyAliases(), []), 'cost_plans' => $this->safeRead(fn () => $this->repository()->listCostPlans($projectKey), []), 'measurements' => $this->safeRead(fn () => $this->repository()->listAllMeasurements($projectKey), []), 'measurement_rates' => $this->safeRead(fn () => $this->repository()->listMeasurementRates($projectKey), []), 'payouts' => $this->safeRead(fn () => $this->repository()->listPayouts($projectKey), []), 'targets' => $this->safeRead(fn () => $this->repository()->listTargets($projectKey), []), 'dashboards' => $this->safeRead(fn () => $this->repository()->listDashboards($projectKey), []), 'miner_offers' => $this->safeRead(fn () => $this->repository()->listMinerOffers($projectKey), []), 'purchased_miners' => $this->safeRead(fn () => $this->repository()->listPurchasedMiners($projectKey), []), 'fx_rates' => $this->safeRead(fn () => $this->repository()->listAllFxRates(), []), ]; $result = $this->schemaManager()->rebuildSchemaDirect(); $this->pdo = null; $this->repository = null; $this->schemaManager = null; $this->fx = null; $this->analytics = null; $this->seedImporter = null; $projectName = is_array($backup['project']) ? ($backup['project']['project_name'] ?? null) : null; $this->repository()->ensureProject($projectKey, is_string($projectName) ? $projectName : null); foreach ($backup['currencies'] as $currency) { $this->repository()->saveCurrency([ 'code' => $currency['code'], 'name' => $currency['name'], 'symbol' => $currency['symbol'] ?? null, 'is_active' => !empty($currency['is_active']) ? 1 : 0, 'is_crypto' => !empty($currency['is_crypto']) ? 1 : 0, 'sort_order' => (int) ($currency['sort_order'] ?? 0), ]); } foreach ($backup['currency_aliases'] as $alias) { if (!empty($alias['alias_code']) && !empty($alias['currency_code'])) { $this->repository()->saveCurrencyAlias((string) $alias['alias_code'], (string) $alias['currency_code']); } } if (is_array($backup['settings'])) { $this->repository()->saveSettings($projectKey, [ 'baseline_measured_at' => $backup['settings']['baseline_measured_at'], 'baseline_coins_total' => $backup['settings']['baseline_coins_total'], 'daily_cost_amount' => $backup['settings']['daily_cost_amount'], 'daily_cost_currency' => $backup['settings']['daily_cost_currency'], 'report_currency' => $backup['settings']['report_currency'] ?? 'EUR', 'crypto_currency' => $backup['settings']['crypto_currency'] ?? 'DOGE', 'display_timezone' => $backup['settings']['display_timezone'] ?? 'Europe/Berlin', 'fx_max_age_hours' => $backup['settings']['fx_max_age_hours'] ?? 3, 'module_theme_mode' => $backup['settings']['module_theme_mode'] ?? 'inherit', 'module_theme_accent' => $backup['settings']['module_theme_accent'] ?? 'teal', 'preferred_currencies' => $backup['settings']['preferred_currencies'] ?? ['DOGE', 'USD', 'EUR'], ]); } foreach ($backup['cost_plans'] as $plan) { $this->repository()->saveCostPlan($projectKey, [ 'label' => $plan['label'], 'starts_at' => $plan['starts_at'], 'runtime_months' => $plan['runtime_months'], 'mining_speed_value' => $plan['mining_speed_value'] ?? null, 'mining_speed_unit' => $plan['mining_speed_unit'] ?? null, 'bonus_speed_value' => $plan['bonus_speed_value'] ?? null, 'bonus_speed_unit' => $plan['bonus_speed_unit'] ?? null, 'auto_renew' => !empty($plan['auto_renew']) ? 1 : 0, 'total_cost_amount' => $plan['total_cost_amount'], 'currency' => $plan['currency'], 'note' => $plan['note'] ?? null, 'is_active' => !empty($plan['is_active']) ? 1 : 0, ]); } $measurementRatesByOldId = []; foreach ($backup['measurement_rates'] as $rate) { $oldMeasurementId = (int) ($rate['measurement_id'] ?? 0); if ($oldMeasurementId > 0) { $measurementRatesByOldId[$oldMeasurementId][] = [ 'base_currency' => $rate['base_currency'] ?? null, 'quote_currency' => $rate['quote_currency'] ?? null, 'rate' => $rate['rate'] ?? null, 'provider' => $rate['provider'] ?? 'derived', ]; } } $measurementIdMap = []; $restoredMeasurementRates = 0; foreach ($backup['measurements'] as $measurement) { $created = $this->repository()->createMeasurementIfNotExists($projectKey, [ 'measured_at' => $measurement['measured_at'], 'coins_total' => $measurement['coins_total'], 'price_per_coin' => $measurement['price_per_coin'] ?? null, 'price_currency' => $measurement['price_currency'] ?? null, 'note' => $measurement['note'] ?? null, 'source' => $measurement['source'] ?? 'manual', 'image_path' => $measurement['image_path'] ?? null, 'ocr_raw_text' => $measurement['ocr_raw_text'] ?? null, 'ocr_confidence' => $measurement['ocr_confidence'] ?? null, 'ocr_flags' => $measurement['ocr_flags'] ?? null, ]); if (is_array($created)) { $oldMeasurementId = (int) ($measurement['id'] ?? 0); $newMeasurementId = (int) ($created['id'] ?? 0); if ($oldMeasurementId > 0 && $newMeasurementId > 0) { $measurementIdMap[$oldMeasurementId] = $newMeasurementId; } if ($oldMeasurementId > 0 && isset($measurementRatesByOldId[$oldMeasurementId])) { $this->repository()->replaceMeasurementRates($newMeasurementId, $projectKey, $measurementRatesByOldId[$oldMeasurementId]); $restoredMeasurementRates += count($measurementRatesByOldId[$oldMeasurementId]); } else { $this->captureMeasurementRates($projectKey, $created); } } } foreach ($backup['payouts'] as $payout) { $this->repository()->savePayout($projectKey, [ 'payout_at' => $payout['payout_at'], 'coins_amount' => $payout['coins_amount'], 'payout_currency' => $payout['payout_currency'] ?? 'DOGE', 'note' => $payout['note'] ?? null, ]); } $minerOfferIdMap = []; foreach ($backup['miner_offers'] as $offer) { $savedOffer = $this->repository()->saveMinerOffer($projectKey, [ 'label' => $offer['label'], 'runtime_months' => $offer['runtime_months'] ?? null, 'mining_speed_value' => $offer['mining_speed_value'] ?? null, 'mining_speed_unit' => $offer['mining_speed_unit'] ?? null, 'bonus_speed_value' => $offer['bonus_speed_value'] ?? null, 'bonus_speed_unit' => $offer['bonus_speed_unit'] ?? null, 'base_price_amount' => $offer['base_price_amount'] ?? $offer['reference_price_amount'] ?? $offer['usd_reference_amount'] ?? $offer['price_amount'], 'base_price_currency' => $offer['base_price_currency'] ?? $offer['reference_price_currency'] ?? (is_numeric($offer['usd_reference_amount'] ?? null) ? 'USD' : ($offer['price_currency'] ?? null)), 'payment_type' => $offer['payment_type'] ?? (!empty($offer['price_currency']) && in_array(strtoupper((string) $offer['price_currency']), ['ADA','ARB','BNB','BTC','DAI','DOGE','DOT','ETH','LINK','LTC','SOL','USDC','USDT','XRP'], true) ? 'crypto' : 'fiat'), 'auto_renew' => !empty($offer['auto_renew']) ? 1 : 0, 'note' => $offer['note'] ?? null, 'is_active' => !empty($offer['is_active']) ? 1 : 0, ]); $oldOfferId = (int) ($offer['id'] ?? 0); $newOfferId = (int) ($savedOffer['id'] ?? 0); if ($oldOfferId > 0 && $newOfferId > 0) { $minerOfferIdMap[$oldOfferId] = $newOfferId; } } foreach ($backup['targets'] as $target) { $oldOfferId = (int) ($target['miner_offer_id'] ?? 0); $this->repository()->saveTarget($projectKey, [ 'label' => $target['label'], 'target_amount_fiat' => $target['target_amount_fiat'], 'currency' => $target['currency'], 'miner_offer_id' => $oldOfferId > 0 ? ($minerOfferIdMap[$oldOfferId] ?? null) : null, 'is_active' => !empty($target['is_active']) ? 1 : 0, 'sort_order' => (int) ($target['sort_order'] ?? 0), ]); } foreach ($backup['dashboards'] as $dashboard) { $this->repository()->saveDashboard($projectKey, [ 'name' => $dashboard['name'], 'chart_type' => $dashboard['chart_type'], 'x_field' => $dashboard['x_field'], 'y_field' => $dashboard['y_field'], 'aggregation' => $dashboard['aggregation'], 'filters' => $dashboard['filters'] ?? (is_array($dashboard['filters_json'] ?? null) ? $dashboard['filters_json'] : []), 'is_active' => !empty($dashboard['is_active']) ? 1 : 0, ]); } foreach ($backup['purchased_miners'] as $miner) { $oldOfferId = (int) ($miner['miner_offer_id'] ?? 0); $this->repository()->restorePurchasedMiner($projectKey, [ 'miner_offer_id' => $oldOfferId > 0 ? ($minerOfferIdMap[$oldOfferId] ?? null) : null, 'purchased_at' => $miner['purchased_at'], 'label' => $miner['label'], 'runtime_months' => $miner['runtime_months'] ?? null, 'mining_speed_value' => $miner['mining_speed_value'] ?? null, 'mining_speed_unit' => $miner['mining_speed_unit'] ?? null, 'bonus_speed_value' => $miner['bonus_speed_value'] ?? null, 'bonus_speed_unit' => $miner['bonus_speed_unit'] ?? null, 'total_cost_amount' => $miner['total_cost_amount'], 'currency' => $miner['currency'], 'usd_reference_amount' => $miner['usd_reference_amount'] ?? null, 'reference_price_amount' => $miner['reference_price_amount'] ?? null, 'reference_price_currency' => $miner['reference_price_currency'] ?? null, 'auto_renew' => !empty($miner['auto_renew']) ? 1 : 0, 'note' => $miner['note'] ?? null, 'is_active' => !empty($miner['is_active']) ? 1 : 0, ]); } $fxRatesByFetch = []; foreach ($backup['fx_rates'] as $rate) { $fetchId = (int) ($rate['fetch_id'] ?? 0); $targetCurrency = strtoupper(trim((string) ($rate['target_currency'] ?? ''))); if ($fetchId <= 0 || $targetCurrency === '' || !is_numeric($rate['rate'] ?? null)) { continue; } if (!isset($fxRatesByFetch[$fetchId])) { $fxRatesByFetch[$fetchId] = [ 'base_currency' => $rate['base_currency'] ?? '', 'provider' => $rate['provider'] ?? 'currencyapi', 'rate_date' => $rate['rate_date'] ?? date('Y-m-d'), 'fetched_at' => $rate['fetched_at'] ?? null, 'rates' => [], ]; } $fxRatesByFetch[$fetchId]['rates'][$targetCurrency] = (float) $rate['rate']; } $restoredFxRates = 0; foreach ($fxRatesByFetch as $fetch) { $restored = $this->repository()->restoreFxFetch( (string) $fetch['base_currency'], (string) $fetch['provider'], (string) $fetch['rate_date'], is_string($fetch['fetched_at'] ?? null) ? $fetch['fetched_at'] : null, $fetch['rates'] ); $restoredFxRates += count($restored['rates'] ?? []); } return array_merge($result, [ 'restored' => [ 'measurements' => count($backup['measurements']), 'measurement_rates' => $restoredMeasurementRates, 'purchased_miners' => count($backup['purchased_miners']), 'cost_plans' => count($backup['cost_plans']), 'payouts' => count($backup['payouts']), 'targets' => count($backup['targets']), 'dashboards' => count($backup['dashboards']), 'miner_offers' => count($backup['miner_offers']), 'currency_aliases' => count($backup['currency_aliases']), 'fx_rates' => $restoredFxRates, ], ]); } private function safeRead(callable $callback, mixed $fallback = null): mixed { try { return $callback(); } catch (\Throwable) { return $fallback; } } private function refreshFxRates(array $input): array { $base = strtoupper(trim((string) ($input['base'] ?? 'EUR'))); $this->debug->add('fx.refresh.start', [ 'base' => $base, ]); try { $result = $this->fx()->refreshLatestRates(null, $base); $this->debug->add('fx.refresh.end', [ 'base' => $base, 'fetch_id' => $result['fetch']['id'] ?? null, 'rate_count' => is_array($result['rates'] ?? null) ? count($result['rates']) : null, ]); return $result; } catch (\Throwable $exception) { $this->debug->add('fx.refresh.error', [ 'base' => $base, 'message' => $exception->getMessage(), ]); throw $exception; } } private function probeFxRates(array $input): array { $base = strtoupper(trim((string) ($input['base'] ?? 'EUR'))); return $this->fx()->probeLatestRates($base); } private function refreshCurrencies(): array { $result = $this->fx()->refreshCurrencyCatalog(); $synced = $this->syncLocalCurrencyCatalogFromFxRates(true); return $result + [ 'local_catalog_synced' => count($synced), ]; } private function probeCurrencies(): array { return $this->fx()->probeCurrencyCatalog(); } private function fxHistory(): array { if (!modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'recent_fetches')) { return []; } $fetches = module_fn('fx-rates', 'recent_fetches', 30); if (!is_array($fetches)) { return []; } $rows = []; foreach ($fetches as $fetch) { $fetchId = is_numeric($fetch['id'] ?? null) ? (int) $fetch['id'] : 0; if ($fetchId <= 0) { continue; } $snapshot = $this->fx()->snapshotByFetchId($fetchId, (string) ($fetch['base_currency'] ?? ''), null); $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; foreach ($rates as $currencyCode => $rate) { if (!is_numeric($rate)) { continue; } $rows[] = [ 'fetch_id' => $fetchId, 'base_currency' => strtoupper((string) ($snapshot['base_currency'] ?? $fetch['base_currency'] ?? '')), 'target_currency' => strtoupper((string) $currencyCode), 'rate' => (float) $rate, 'rate_date' => (string) ($snapshot['rate_date'] ?? $fetch['rate_date'] ?? ''), 'provider' => (string) ($snapshot['provider'] ?? $fetch['provider'] ?? ''), 'fetched_at' => (string) ($snapshot['fetched_at'] ?? $fetch['fetched_at'] ?? ''), ]; } } return $rows; } private function migrateLegacyFxRates(string $projectKey): array { if (!modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'repository')) { throw new ApiException('Das Modul fx-rates ist nicht verfuegbar.', 422); } $startedAt = microtime(true); $this->debug->add('legacy_fx_migrate.start', [ 'project_key' => $projectKey, ]); module_fn('fx-rates', 'ensure_schema'); $legacyRows = $this->repository()->listAllFxRates(); $legacyFetches = $this->groupLegacyFxFetches($legacyRows); $fxRepository = module_fn('fx-rates', 'repository'); $this->debug->add('legacy_fx_migrate.loaded', [ 'legacy_rows' => count($legacyRows), 'legacy_fetches' => count($legacyFetches), ]); $fetchIdMap = []; $importedFetches = 0; $reusedFetches = 0; $migratedRates = 0; $legacyFetchIndex = 0; foreach ($legacyFetches as $legacyFetchId => $legacyFetch) { $this->assertRequestWithinBudget($startedAt, 'Legacy-FX-Migration dauert zu lange.'); $legacyFetchIndex++; $existing = $this->findExistingFxFetchForLegacy($fxRepository, $legacyFetch); if (is_array($existing) && !empty($existing['id'])) { $fetchIdMap[$legacyFetchId] = (int) $existing['id']; $reusedFetches++; if ($legacyFetchIndex % 50 === 0) { $this->debug->add('legacy_fx_migrate.fetch_progress', [ 'processed' => $legacyFetchIndex, 'total' => count($legacyFetches), 'reused_fetches' => $reusedFetches, 'imported_fetches' => $importedFetches, ]); } continue; } $saved = $fxRepository->saveFetch( (string) $legacyFetch['base_currency'], (string) $legacyFetch['provider'], (string) $legacyFetch['rate_date'], (array) $legacyFetch['rates'], is_string($legacyFetch['fetched_at'] ?? null) ? $legacyFetch['fetched_at'] : null, 'migration' ); $newFetchId = is_numeric($saved['fetch']['id'] ?? null) ? (int) $saved['fetch']['id'] : 0; if ($newFetchId > 0) { $fetchIdMap[$legacyFetchId] = $newFetchId; } $importedFetches++; $migratedRates += count($saved['rates'] ?? []); if ($legacyFetchIndex % 50 === 0) { $this->debug->add('legacy_fx_migrate.fetch_progress', [ 'processed' => $legacyFetchIndex, 'total' => count($legacyFetches), 'reused_fetches' => $reusedFetches, 'imported_fetches' => $importedFetches, ]); } } $measurements = $this->repository()->listAllMeasurements($projectKey); $measurementRatesById = $this->groupMeasurementRatesByMeasurementId( $this->repository()->listMeasurementRates($projectKey) ); $this->debug->add('legacy_fx_migrate.measurements_loaded', [ 'measurement_count' => count($measurements), 'measurement_rate_groups' => count($measurementRatesById), ]); $updatedMeasurements = 0; $reusedMeasurements = 0; $unresolvedMeasurements = 0; $measurementIndex = 0; foreach ($measurements as $measurement) { $this->assertRequestWithinBudget($startedAt, 'Legacy-FX-Migration dauert zu lange.'); $measurementIndex++; $measurementId = is_numeric($measurement['id'] ?? null) ? (int) $measurement['id'] : 0; if ($measurementId <= 0) { continue; } $currentFetchId = is_numeric($measurement['fx_fetch_id'] ?? null) ? (int) $measurement['fx_fetch_id'] : 0; if ($currentFetchId > 0 && $this->fx()->snapshotByFetchId($currentFetchId, null, null) !== null) { $reusedMeasurements++; if ($measurementIndex % 100 === 0) { $this->debug->add('legacy_fx_migrate.measurement_progress', [ 'processed' => $measurementIndex, 'total' => count($measurements), 'updated' => $updatedMeasurements, 'reused' => $reusedMeasurements, 'unresolved' => $unresolvedMeasurements, ]); } continue; } $legacyFetchId = $this->resolveLegacyMeasurementFetchId( $measurement, $measurementRatesById[$measurementId] ?? [], $legacyFetches ); $resolvedFetchId = $legacyFetchId !== null ? ($fetchIdMap[$legacyFetchId] ?? null) : null; if (!is_numeric($resolvedFetchId) || (int) $resolvedFetchId <= 0) { $unresolvedMeasurements++; if ($measurementIndex % 100 === 0) { $this->debug->add('legacy_fx_migrate.measurement_progress', [ 'processed' => $measurementIndex, 'total' => count($measurements), 'updated' => $updatedMeasurements, 'reused' => $reusedMeasurements, 'unresolved' => $unresolvedMeasurements, ]); } continue; } $this->repository()->setMeasurementFxFetchId($projectKey, $measurementId, (int) $resolvedFetchId); $updatedMeasurements++; if ($measurementIndex % 100 === 0) { $this->debug->add('legacy_fx_migrate.measurement_progress', [ 'processed' => $measurementIndex, 'total' => count($measurements), 'updated' => $updatedMeasurements, 'reused' => $reusedMeasurements, 'unresolved' => $unresolvedMeasurements, ]); } } $this->debug->add('legacy_fx_migrate.end', [ 'duration_ms' => round((microtime(true) - $startedAt) * 1000, 2), 'legacy_fetches_found' => count($legacyFetches), 'legacy_rates_found' => count($legacyRows), 'fx_fetches_imported' => $importedFetches, 'fx_fetches_reused' => $reusedFetches, 'fx_rates_imported' => $migratedRates, 'measurements_checked' => count($measurements), 'measurements_updated' => $updatedMeasurements, 'measurements_reused' => $reusedMeasurements, 'measurements_unresolved' => $unresolvedMeasurements, ]); return [ 'message' => 'Legacy-FX-Rates wurden nach fx-rates migriert und Messpunkte aktualisiert.', 'legacy_fetches_found' => count($legacyFetches), 'legacy_rates_found' => count($legacyRows), 'fx_fetches_imported' => $importedFetches, 'fx_fetches_reused' => $reusedFetches, 'fx_rates_imported' => $migratedRates, 'measurements_checked' => count($measurements), 'measurements_updated' => $updatedMeasurements, 'measurements_reused' => $reusedMeasurements, 'measurements_unresolved' => $unresolvedMeasurements, ]; } private function groupLegacyFxFetches(array $rows): array { $grouped = []; foreach ($rows as $row) { $legacyFetchId = is_numeric($row['fetch_id'] ?? null) ? (int) $row['fetch_id'] : 0; $baseCurrency = strtoupper(trim((string) ($row['base_currency'] ?? ''))); $targetCurrency = strtoupper(trim((string) ($row['target_currency'] ?? ''))); if ($legacyFetchId <= 0 || $baseCurrency === '' || $targetCurrency === '' || !is_numeric($row['rate'] ?? null)) { continue; } if (!isset($grouped[$legacyFetchId])) { $grouped[$legacyFetchId] = [ 'legacy_fetch_id' => $legacyFetchId, 'base_currency' => $baseCurrency, 'provider' => trim((string) ($row['provider'] ?? '')) !== '' ? (string) $row['provider'] : 'currencyapi', 'rate_date' => trim((string) ($row['rate_date'] ?? '')) !== '' ? (string) $row['rate_date'] : date('Y-m-d'), 'fetched_at' => $this->normalizeTimestamp((string) ($row['fetched_at'] ?? '')), 'rates' => [], ]; } $grouped[$legacyFetchId]['rates'][$targetCurrency] = (float) $row['rate']; } return $grouped; } private function groupMeasurementRatesByMeasurementId(array $rows): array { $grouped = []; foreach ($rows as $row) { $measurementId = is_numeric($row['measurement_id'] ?? null) ? (int) $row['measurement_id'] : 0; if ($measurementId <= 0) { continue; } $grouped[$measurementId][] = $row; } return $grouped; } private function findExistingFxFetchForLegacy(object $fxRepository, array $legacyFetch): ?array { $baseCurrency = strtoupper(trim((string) ($legacyFetch['base_currency'] ?? ''))); $fetchedAt = $this->normalizeTimestamp((string) ($legacyFetch['fetched_at'] ?? '')); if ($baseCurrency === '' || $fetchedAt === null) { return null; } if (!method_exists($fxRepository, 'findFetchByBaseAndFetchedAt')) { return null; } $existing = $fxRepository->findFetchByBaseAndFetchedAt($baseCurrency, $fetchedAt); return is_array($existing) ? $existing : null; } private function resolveLegacyMeasurementFetchId(array $measurement, array $measurementRates, array $legacyFetches): ?int { $measuredAt = $this->normalizeTimestamp((string) ($measurement['measured_at'] ?? '')); if ($measuredAt === null || $legacyFetches === []) { return null; } $matches = []; foreach ($legacyFetches as $legacyFetchId => $legacyFetch) { if (!$this->measurementRatesMatchLegacyFetch($measurementRates, $legacyFetch)) { continue; } $legacyFetchedAt = $this->normalizeTimestamp((string) ($legacyFetch['fetched_at'] ?? '')); $distanceSeconds = $this->timestampDistanceSeconds($measuredAt, $legacyFetchedAt); $matches[] = [ 'legacy_fetch_id' => (int) $legacyFetchId, 'distance_seconds' => $distanceSeconds ?? PHP_INT_MAX, ]; } if ($matches === []) { return $this->nearestLegacyFetchId($measuredAt, $legacyFetches); } usort($matches, static function (array $left, array $right): int { return ((int) $left['distance_seconds']) <=> ((int) $right['distance_seconds']); }); return (int) $matches[0]['legacy_fetch_id']; } private function measurementRatesMatchLegacyFetch(array $measurementRates, array $legacyFetch): bool { if ($measurementRates === []) { return true; } foreach ($measurementRates as $measurementRate) { $baseCurrency = strtoupper(trim((string) ($measurementRate['base_currency'] ?? ''))); $quoteCurrency = strtoupper(trim((string) ($measurementRate['quote_currency'] ?? ''))); $expectedRate = is_numeric($measurementRate['rate'] ?? null) ? (float) $measurementRate['rate'] : null; if ($baseCurrency === '' || $quoteCurrency === '' || $expectedRate === null) { return false; } $resolvedRate = $this->resolveLegacyFetchRate($legacyFetch, $baseCurrency, $quoteCurrency); if ($resolvedRate === null || !$this->ratesAreEquivalent($resolvedRate, $expectedRate)) { return false; } } return true; } private function resolveLegacyFetchRate(array $legacyFetch, string $baseCurrency, string $quoteCurrency): ?float { $baseCurrency = strtoupper(trim($baseCurrency)); $quoteCurrency = strtoupper(trim($quoteCurrency)); $fetchBase = strtoupper(trim((string) ($legacyFetch['base_currency'] ?? ''))); $rates = is_array($legacyFetch['rates'] ?? null) ? $legacyFetch['rates'] : []; if ($baseCurrency === '' || $quoteCurrency === '' || $fetchBase === '') { return null; } if ($baseCurrency === $quoteCurrency) { return 1.0; } if ($baseCurrency === $fetchBase && array_key_exists($quoteCurrency, $rates) && is_numeric($rates[$quoteCurrency])) { return (float) $rates[$quoteCurrency]; } if ($quoteCurrency === $fetchBase && array_key_exists($baseCurrency, $rates) && is_numeric($rates[$baseCurrency]) && (float) $rates[$baseCurrency] != 0.0) { return 1.0 / (float) $rates[$baseCurrency]; } if ( array_key_exists($baseCurrency, $rates) && array_key_exists($quoteCurrency, $rates) && is_numeric($rates[$baseCurrency]) && is_numeric($rates[$quoteCurrency]) && (float) $rates[$baseCurrency] != 0.0 ) { return (float) $rates[$quoteCurrency] / (float) $rates[$baseCurrency]; } return null; } private function nearestLegacyFetchId(string $measuredAt, array $legacyFetches): ?int { $nearestFetchId = null; $nearestDistance = null; foreach ($legacyFetches as $legacyFetchId => $legacyFetch) { $legacyFetchedAt = $this->normalizeTimestamp((string) ($legacyFetch['fetched_at'] ?? '')); $distance = $this->timestampDistanceSeconds($measuredAt, $legacyFetchedAt); if ($distance === null) { continue; } if ($nearestDistance === null || $distance < $nearestDistance) { $nearestDistance = $distance; $nearestFetchId = (int) $legacyFetchId; } } return $nearestFetchId; } private function normalizeTimestamp(string $value): ?string { $normalized = trim($value); if ($normalized === '') { return null; } try { return (new \DateTimeImmutable($normalized))->setTimezone($this->utcTimezone())->format('Y-m-d H:i:s'); } catch (\Throwable) { return null; } } private function timestampDistanceSeconds(?string $left, ?string $right): ?int { if ($left === null || $right === null) { return null; } $leftTs = strtotime($left); $rightTs = strtotime($right); if ($leftTs === false || $rightTs === false) { return null; } return abs($leftTs - $rightTs); } private function settings(string $projectKey): array { $settings = $this->repository()->getSettings($projectKey); $base = is_array($settings) ? $settings : [ 'project_key' => $projectKey, 'baseline_measured_at' => null, 'baseline_coins_total' => null, 'daily_cost_amount' => null, 'daily_cost_currency' => 'EUR', 'report_currency' => 'EUR', 'crypto_currency' => 'DOGE', 'display_timezone' => 'Europe/Berlin', 'fx_max_age_hours' => 3, 'module_theme_mode' => 'inherit', 'module_theme_accent' => 'teal', 'preferred_currencies' => $this->preferredCurrencies(), ]; if (!$this->isValidTimezone((string) ($base['display_timezone'] ?? ''))) { $base['display_timezone'] = 'Europe/Berlin'; } if (!is_numeric($base['fx_max_age_hours'] ?? null) || (float) $base['fx_max_age_hours'] <= 0) { $base['fx_max_age_hours'] = 3; } if (!in_array((string) ($base['module_theme_mode'] ?? ''), ['inherit', 'custom'], true)) { $base['module_theme_mode'] = 'inherit'; } if (!in_array((string) ($base['module_theme_accent'] ?? ''), ['teal', 'logo', 'pink', 'cyan', 'orange', 'green'], true)) { $base['module_theme_accent'] = 'teal'; } $base['cost_plans'] = $this->costPlans($projectKey); $base['currencies'] = $this->currencies(); $base['preferred_currencies'] = $this->preferredCurrencies($base['preferred_currencies'] ?? null); $base['payouts'] = $this->payouts($projectKey); $base['miner_offers'] = $this->minerOffers($projectKey); $base['purchased_miners'] = $this->purchasedMiners($projectKey); $base['measurement_rates'] = []; return $base; } private function saveSettings(string $projectKey, array $input): array { $existingSettings = $this->repository()->getSettings($projectKey) ?? []; $displayTimezone = $this->requiredTimezone( $input['display_timezone'] ?? ($existingSettings['display_timezone'] ?? 'Europe/Berlin'), 'display_timezone' ); $settings = [ 'baseline_measured_at' => $this->requiredDateTime($input['baseline_measured_at'] ?? null, 'baseline_measured_at', $displayTimezone), 'baseline_coins_total' => $this->requiredDecimal($input['baseline_coins_total'] ?? null, 'baseline_coins_total'), 'daily_cost_amount' => $this->requiredDecimal($input['daily_cost_amount'] ?? null, 'daily_cost_amount'), 'daily_cost_currency' => $this->requiredCurrency($input['daily_cost_currency'] ?? null, 'daily_cost_currency'), 'report_currency' => $this->requiredCurrency($input['report_currency'] ?? 'EUR', 'report_currency'), 'crypto_currency' => $this->requiredCurrency($input['crypto_currency'] ?? 'DOGE', 'crypto_currency'), 'display_timezone' => $displayTimezone, 'fx_max_age_hours' => $this->requiredPositiveDecimal($input['fx_max_age_hours'] ?? 3, 'fx_max_age_hours'), 'module_theme_mode' => $this->requiredEnum($input['module_theme_mode'] ?? 'inherit', 'module_theme_mode', ['inherit', 'custom']), 'module_theme_accent' => $this->requiredEnum($input['module_theme_accent'] ?? 'teal', 'module_theme_accent', ['teal', 'logo', 'pink', 'cyan', 'orange', 'green']), 'preferred_currencies' => $this->optionalCurrencyList($input['preferred_currencies'] ?? []), ]; $this->assertCurrencyType($settings['report_currency'], false, 'report_currency'); $this->assertCurrencyType($settings['crypto_currency'], true, 'crypto_currency'); $this->repository()->saveSettings($projectKey, $settings); $this->syncFxRatesPreferredCurrencies($settings['preferred_currencies']); return $this->settings($projectKey); } private function measurements(string $projectKey, ?array $settingsOverride = null, bool $resolveFxReferences = true): array { $settings = $settingsOverride ?? $this->settings($projectKey); $rows = $this->repository()->listMeasurements($projectKey, 500); if ($resolveFxReferences) { $rows = $this->ensureMeasurementFxReferences($projectKey, $rows, $settings); } return $this->analytics()->enrichMeasurements($rows, $settings); } private function bootstrapMeasurements(string $projectKey, array $settings, string $view): array { if (in_array($view, ['settings', 'currencies', 'dashboards'], true)) { return []; } $rows = in_array($view, ['overview', 'mining'], true) ? $this->repository()->listRecentMeasurements($projectKey, self::BOOTSTRAP_MEASUREMENT_LIMIT) : $this->repository()->listMeasurements($projectKey, self::BOOTSTRAP_MEASUREMENT_LIMIT); if (in_array($view, ['overview', 'mining'], true)) { $rows = $this->filterMeasurementsToRecentWindow($rows, self::OVERVIEW_WINDOW_DAYS); } $this->debug->add('bootstrap.measurements.loaded', [ 'project_key' => $projectKey, 'view' => $view, 'row_count' => count($rows), 'limit' => self::BOOTSTRAP_MEASUREMENT_LIMIT, ]); return $this->analytics()->enrichMeasurements($rows, $settings); } private function bootstrapTargets(string $projectKey, string $view): array { return in_array($view, ['overview', 'mining'], true) ? $this->targets($projectKey) : []; } private function bootstrapDashboards(string $projectKey, string $view): array { return $view === 'dashboards' ? $this->dashboards($projectKey) : []; } private function bootstrapFxSnapshots(array $measurements, string $view): array { if (!in_array($view, ['overview', 'mining', 'measurements'], true)) { return []; } return $this->measurementFxSnapshots($measurements, self::BOOTSTRAP_SNAPSHOT_LIMIT); } private function bootstrapSummary(array $measurements, array $settings, array $targets, string $view): array { if (!in_array($view, ['overview', 'mining'], true)) { return [ 'latest_measurement' => $measurements !== [] ? $measurements[array_key_last($measurements)] : null, 'baseline' => $settings, 'targets' => [], 'payouts' => [], 'miner_offers' => [], ]; } return $this->analytics()->buildSummary($measurements, $settings, $targets); } private function filterMeasurementsToRecentWindow(array $rows, int $windowDays): array { if ($rows === [] || $windowDays <= 0) { return $rows; } $latest = $rows[array_key_last($rows)] ?? null; $latestTs = is_array($latest) ? strtotime((string) ($latest['measured_at'] ?? '')) : false; if ($latestTs === false) { return $rows; } $minTs = $latestTs - ($windowDays * 86400); $filtered = array_values(array_filter($rows, static function (array $row) use ($minTs): bool { $measuredTs = strtotime((string) ($row['measured_at'] ?? '')); return $measuredTs !== false && $measuredTs >= $minTs; })); $latestRow = $rows[array_key_last($rows)] ?? null; return $filtered !== [] || !is_array($latestRow) ? $filtered : [$latestRow]; } private function normalizeBootstrapView(string $view): string { $normalized = trim(strtolower($view)); return in_array($normalized, ['overview', 'measurements', 'dashboards', 'currencies', 'mining', 'settings'], true) ? $normalized : 'overview'; } private function createMeasurement(string $projectKey, array $input): array { $projectTimezone = $this->projectTimezone($projectKey); $source = $this->enumValue($input['source'] ?? 'manual', ['manual', 'image_ocr', 'seed_import'], 'source'); $payload = [ 'measured_at' => $source === 'seed_import' ? $this->requiredDateTime($input['measured_at'] ?? null, 'measured_at', $projectTimezone) : $this->currentTimestamp(), 'coins_total' => $this->requiredDecimal($input['coins_total'] ?? null, 'coins_total'), 'price_per_coin' => $this->optionalDecimal($input['price_per_coin'] ?? null), 'price_currency' => $this->optionalCurrency($input['price_currency'] ?? null), 'note' => $this->optionalString($input['note'] ?? null, 2000), 'source' => $source, 'image_path' => $this->optionalString($input['image_path'] ?? null, 255), 'ocr_raw_text' => $this->optionalString($input['ocr_raw_text'] ?? null, 65535), 'ocr_confidence' => $this->optionalDecimal($input['ocr_confidence'] ?? null), 'ocr_flags' => $this->optionalArray($input['ocr_flags'] ?? null), 'fx_fetch_id' => null, ]; if (($payload['price_per_coin'] === null) xor ($payload['price_currency'] === null)) { throw new ApiException('Kurs und Kurswaehrung muessen gemeinsam gesetzt oder beide leer sein.', 422); } $this->syncCurrencyCatalogForMeasurement($payload); $payload['fx_fetch_id'] = $this->resolveMeasurementFxFetchId($projectKey, $payload, true); $created = $this->repository()->createMeasurement($projectKey, $payload); $measurements = $this->measurements($projectKey); return $measurements[array_key_last($measurements)]; } private function importMeasurements(string $projectKey, array $input): array { $rawText = trim((string) ($input['rows_text'] ?? '')); if ($rawText === '') { throw new ApiException('rows_text ist erforderlich.', 422); } $defaultCurrency = $this->optionalCurrency($input['default_currency'] ?? null); $defaultSource = $this->enumValue($input['source'] ?? 'manual', ['manual', 'seed_import'], 'source'); $lines = preg_split('/\R/', $rawText) ?: []; $imported = 0; $duplicates = 0; $errors = []; foreach ($lines as $index => $line) { $lineNumber = $index + 1; $trimmed = trim($line); if ($trimmed === '' || str_starts_with($trimmed, '#') || str_starts_with($trimmed, '//')) { continue; } try { $payload = $this->parseImportLine($trimmed, $defaultCurrency, $defaultSource, $this->projectTimezone($projectKey)); $this->syncCurrencyCatalogForMeasurement($payload); $payload['fx_fetch_id'] = $this->resolveMeasurementFxFetchId($projectKey, $payload, false); $result = $this->repository()->createMeasurementIfNotExists($projectKey, $payload); if ($result === null) { $duplicates++; } else { $imported++; } } catch (\Throwable $exception) { $errors[] = [ 'line' => $lineNumber, 'input' => $trimmed, 'message' => $exception instanceof ApiException ? $exception->getMessage() : 'Importzeile konnte nicht verarbeitet werden.', ]; } } return [ 'imported' => $imported, 'duplicates_ignored' => $duplicates, 'errors' => $errors, 'error_count' => count($errors), 'accepted_format' => 'DD.MM.YYYY HH:MM | coins_total | price_per_coin | currency | note', ]; } private function syncCurrencyCatalogForMeasurement(array $payload): void { $priceCurrency = strtoupper(trim((string) ($payload['price_currency'] ?? ''))); if ($priceCurrency === '') { return; } $knownCodes = array_map( static fn (array $currency): string => strtoupper((string) ($currency['code'] ?? '')), $this->currencies() ); if (in_array($priceCurrency, $knownCodes, true)) { return; } $matched = $this->currencyCatalogEntry($priceCurrency); $this->repository()->ensureCurrencyCode($priceCurrency, $matched['name'] ?? $priceCurrency); try { $this->refreshCurrencies(); } catch (\Throwable) { // Measurement save must not fail because the external currency sync is unavailable. } } private function parseImportLine(string $line, ?string $defaultCurrency, string $defaultSource, string $projectTimezone): array { $parts = array_map('trim', explode('|', $line)); if (count($parts) < 2) { throw new ApiException('Zu wenige Felder. Erwartet: Datum/Zeit | Coins | Kurs | Waehrung | Notiz', 422); } $measuredAt = $this->parseImportDateTime($parts[0] ?? '', $projectTimezone); $coinsTotal = $this->requiredDecimal($parts[1] ?? null, 'coins_total'); $pricePerCoin = $this->optionalDecimal($parts[2] ?? null); $priceCurrency = $this->optionalCurrency($parts[3] ?? null) ?? $defaultCurrency; $note = $this->optionalString($parts[4] ?? null, 2000); if (($pricePerCoin === null) xor ($priceCurrency === null)) { throw new ApiException('Kurs und Waehrung muessen gemeinsam gesetzt werden oder beide leer bleiben.', 422); } return [ 'measured_at' => $measuredAt, 'coins_total' => $coinsTotal, 'price_per_coin' => $pricePerCoin, 'price_currency' => $priceCurrency, 'note' => $note, 'source' => $defaultSource, 'image_path' => null, 'ocr_raw_text' => null, 'ocr_confidence' => null, 'ocr_flags' => ['paste_import'], ]; } private function parseImportDateTime(string $value, string $projectTimezone): string { $normalized = trim($value); if ($normalized === '') { throw new ApiException('Datum/Zeit fehlt.', 422); } $patterns = [ 'd.m.Y H:i:s', 'd.m.Y H:i', 'd.m.y H:i:s', 'd.m.y H:i', 'Y-m-d H:i:s', 'Y-m-d H:i', ]; $timezone = new \DateTimeZone($projectTimezone); foreach ($patterns as $pattern) { $date = \DateTimeImmutable::createFromFormat($pattern, $normalized, $timezone); if ($date instanceof \DateTimeImmutable) { return $date->setTimezone($this->utcTimezone())->format('Y-m-d H:i:s'); } } try { $date = new \DateTimeImmutable($normalized, $timezone); } catch (\Throwable) { throw new ApiException('Datum/Zeit konnte nicht gelesen werden.', 422); } return $date->setTimezone($this->utcTimezone())->format('Y-m-d H:i:s'); } private function targets(string $projectKey): array { return $this->repository()->listTargets($projectKey); } private function costPlans(string $projectKey): array { return $this->repository()->listCostPlans($projectKey); } private function payouts(string $projectKey): array { return $this->repository()->listPayouts($projectKey); } private function measurementRates(string $projectKey): array { return []; } private function currencies(): array { $catalog = $this->syncLocalCurrencyCatalogFromFxRates(); return $catalog !== [] ? $catalog : $this->repository()->listCurrencies(); } private function currencyAliases(): array { return $this->repository()->listCurrencyAliases(); } private function saveCurrencyAlias(array $input): array { $aliasCode = strtoupper(trim((string) ($input['alias_code'] ?? ''))); $currencyCode = $this->requiredCurrency($input['currency_code'] ?? null, 'currency_code'); if (!preg_match('/^[A-Z0-9]{3,10}$/', $aliasCode)) { throw new ApiException('Feld alias_code muss ein gueltiger Waehrungscode sein.', 422); } return $this->repository()->saveCurrencyAlias($aliasCode, $currencyCode); } private function minerOffers(string $projectKey): array { return $this->repository()->listMinerOffers($projectKey); } private function purchasedMiners(string $projectKey): array { return $this->repository()->listPurchasedMiners($projectKey); } private function saveTarget(string $projectKey, array $input): array { $payload = [ 'label' => $this->requiredString($input['label'] ?? null, 'label', 120), 'target_amount_fiat' => $this->requiredDecimal($input['target_amount_fiat'] ?? null, 'target_amount_fiat'), 'currency' => $this->requiredCurrency($input['currency'] ?? null, 'currency'), 'miner_offer_id' => $this->optionalPositiveInt($input['miner_offer_id'] ?? null), 'is_active' => !empty($input['is_active']) ? 1 : 0, 'sort_order' => isset($input['sort_order']) ? (int) $input['sort_order'] : 0, ]; $this->assertTargetOfferExists($projectKey, $payload['miner_offer_id']); return $this->repository()->saveTarget($projectKey, $payload); } private function updateTarget(string $projectKey, int $targetId, array $input): array { $payload = [ 'label' => $this->requiredString($input['label'] ?? null, 'label', 120), 'target_amount_fiat' => $this->requiredDecimal($input['target_amount_fiat'] ?? null, 'target_amount_fiat'), 'currency' => $this->requiredCurrency($input['currency'] ?? null, 'currency'), 'miner_offer_id' => $this->optionalPositiveInt($input['miner_offer_id'] ?? null), 'is_active' => !empty($input['is_active']) ? 1 : 0, 'sort_order' => isset($input['sort_order']) ? (int) $input['sort_order'] : 0, ]; $this->assertTargetOfferExists($projectKey, $payload['miner_offer_id']); return $this->repository()->updateTarget($projectKey, $targetId, $payload); } private function deleteTarget(string $projectKey, int $targetId): void { $this->repository()->deleteTarget($projectKey, $targetId); } private function assertTargetOfferExists(string $projectKey, ?int $offerId): void { if ($offerId === null) { return; } if ($this->repository()->getMinerOffer($projectKey, $offerId) === null) { throw new ApiException( 'Verknuepftes Miner-Angebot wurde nicht gefunden.', 422, ['field' => 'miner_offer_id', 'miner_offer_id' => $offerId] ); } } private function dashboards(string $projectKey): array { $dashboards = $this->repository()->listDashboards($projectKey); foreach ($dashboards as &$dashboard) { if (is_array($dashboard['filters_json'] ?? null)) { $dashboard['filters'] = $dashboard['filters_json']; } elseif (is_string($dashboard['filters_json'] ?? null) && $dashboard['filters_json'] !== '') { $decoded = json_decode($dashboard['filters_json'], true); $dashboard['filters'] = is_array($decoded) ? $decoded : []; } else { $dashboard['filters'] = []; } } return $dashboards; } private function saveCostPlan(string $projectKey, array $input): array { $projectTimezone = $this->projectTimezone($projectKey); $payload = [ 'label' => $this->requiredString($input['label'] ?? null, 'label', 120), 'purchased_at' => $this->requiredDateTime($input['starts_at'] ?? null, 'starts_at', $projectTimezone), 'runtime_months' => $this->requiredPositiveInt($input['runtime_months'] ?? null, 'runtime_months'), 'mining_speed_value' => $this->optionalDecimal($input['mining_speed_value'] ?? null), 'mining_speed_unit' => $this->optionalSpeedUnit($input['mining_speed_unit'] ?? null), 'bonus_speed_value' => $this->optionalDecimal($input['bonus_speed_value'] ?? null), 'bonus_speed_unit' => $this->optionalSpeedUnit($input['bonus_speed_unit'] ?? null), 'auto_renew' => !empty($input['auto_renew']) ? 1 : 0, 'base_price_amount' => $this->requiredDecimal($input['base_price_amount'] ?? ($input['total_cost_amount'] ?? null), 'base_price_amount'), 'payment_type' => $this->enumValue($input['payment_type'] ?? 'fiat', ['fiat', 'crypto'], 'payment_type'), 'note' => $this->optionalString($input['note'] ?? null, 1000), 'is_active' => !empty($input['is_active']) ? 1 : 0, ]; if (($payload['mining_speed_value'] === null) xor ($payload['mining_speed_unit'] === null)) { throw new ApiException('Mining-Geschwindigkeit und Einheit muessen gemeinsam gesetzt oder beide leer sein.', 422); } if (($payload['bonus_speed_value'] === null) xor ($payload['bonus_speed_unit'] === null)) { throw new ApiException('Bonus-Geschwindigkeit und Einheit muessen gemeinsam gesetzt oder beide leer sein.', 422); } $settings = $this->settings($projectKey); $fiatCurrency = $this->requiredCurrency($settings['report_currency'] ?? 'EUR', 'report_currency'); $cryptoCurrency = $this->requiredCurrency($settings['crypto_currency'] ?? 'DOGE', 'crypto_currency'); $payload['currency'] = $payload['payment_type'] === 'crypto' ? $cryptoCurrency : $fiatCurrency; $payload['total_cost_amount'] = $payload['base_price_amount']; $payload['reference_price_amount'] = $payload['base_price_amount']; $payload['reference_price_currency'] = $fiatCurrency; $payload['usd_reference_amount'] = $fiatCurrency === 'USD' ? $payload['base_price_amount'] : null; if ($payload['payment_type'] === 'crypto') { $converted = $this->fx()->convert((float) $payload['base_price_amount'], $fiatCurrency, $cryptoCurrency); if ($converted === null) { throw new ApiException( 'Basispreis konnte nicht in die eingestellte Krypto-Waehrung umgerechnet werden.', 422, ['base_currency' => $fiatCurrency, 'crypto_currency' => $cryptoCurrency] ); } $payload['total_cost_amount'] = $converted; } return $this->repository()->restorePurchasedMiner($projectKey, [ 'miner_offer_id' => null, 'purchased_at' => $payload['purchased_at'], 'label' => $payload['label'], 'runtime_months' => $payload['runtime_months'], 'mining_speed_value' => $payload['mining_speed_value'], 'mining_speed_unit' => $payload['mining_speed_unit'], 'bonus_speed_value' => $payload['bonus_speed_value'], 'bonus_speed_unit' => $payload['bonus_speed_unit'], 'total_cost_amount' => $payload['total_cost_amount'], 'currency' => $payload['currency'], 'usd_reference_amount' => $payload['usd_reference_amount'], 'reference_price_amount' => $payload['reference_price_amount'], 'reference_price_currency' => $payload['reference_price_currency'], 'auto_renew' => $payload['auto_renew'], 'note' => $payload['note'], 'is_active' => $payload['is_active'], ]); } private function savePayout(string $projectKey, array $input): array { $payload = [ 'payout_at' => $this->requiredDateTime($input['payout_at'] ?? null, 'payout_at', $this->projectTimezone($projectKey)), 'coins_amount' => $this->requiredDecimal($input['coins_amount'] ?? null, 'coins_amount'), 'payout_currency' => $this->requiredCurrency($input['payout_currency'] ?? 'DOGE', 'payout_currency'), 'note' => $this->optionalString($input['note'] ?? null, 1000), ]; return $this->repository()->savePayout($projectKey, $payload); } private function saveMinerOffer(string $projectKey, array $input): array { $payload = [ 'label' => $this->requiredString($input['label'] ?? null, 'label', 120), 'runtime_months' => $this->optionalPositiveInt($input['runtime_months'] ?? null), 'mining_speed_value' => $this->optionalDecimal($input['mining_speed_value'] ?? null), 'mining_speed_unit' => $this->optionalSpeedUnit($input['mining_speed_unit'] ?? null), 'bonus_speed_value' => $this->optionalDecimal($input['bonus_speed_value'] ?? null), 'bonus_speed_unit' => $this->optionalSpeedUnit($input['bonus_speed_unit'] ?? null), 'base_price_amount' => $this->requiredDecimal($input['base_price_amount'] ?? ($input['reference_price_amount'] ?? $input['price_amount'] ?? null), 'base_price_amount'), 'base_price_currency' => $this->requiredCurrency($input['base_price_currency'] ?? ($input['reference_price_currency'] ?? $input['price_currency'] ?? null), 'base_price_currency'), 'payment_type' => $this->enumValue($input['payment_type'] ?? 'fiat', ['fiat', 'crypto'], 'payment_type'), 'auto_renew' => !empty($input['auto_renew']) ? 1 : 0, 'note' => $this->optionalString($input['note'] ?? null, 1000), 'is_active' => !empty($input['is_active']) ? 1 : 0, ]; $this->assertCurrencyType($payload['base_price_currency'], false, 'base_price_currency'); return $this->repository()->saveMinerOffer($projectKey, $payload); } private function purchaseMiner(string $projectKey, int $offerId, array $input): array { $offer = $this->repository()->getMinerOffer($projectKey, $offerId); if (!is_array($offer)) { throw new ApiException('Miner-Angebot nicht gefunden.', 404); } $purchaseCurrency = $this->optionalCurrency($input['currency'] ?? null) ?? (string) ($offer['effective_price_currency'] ?? $offer['price_currency'] ?? $offer['base_price_currency'] ?? ''); $purchaseCost = $this->optionalDecimal($input['total_cost_amount'] ?? null); if ($purchaseCost === null) { $purchaseCost = $this->resolveOfferPurchaseCost(array_merge($offer, [ 'price_currency' => $purchaseCurrency !== '' ? $purchaseCurrency : ($offer['price_currency'] ?? ''), ])); } $referencePriceAmount = $this->optionalDecimal($input['reference_price_amount'] ?? ($offer['reference_price_amount'] ?? $offer['usd_reference_amount'] ?? null)); $referencePriceCurrency = $this->optionalCurrency($input['reference_price_currency'] ?? ($offer['reference_price_currency'] ?? (is_numeric($offer['usd_reference_amount'] ?? null) ? 'USD' : null))); $purchasedAt = array_key_exists('purchased_at', $input) ? $this->requiredDateTime($input['purchased_at'], 'purchased_at', $this->projectTimezone($projectKey)) : $this->currentTimestamp(); return $this->repository()->purchaseMiner($projectKey, $offerId, [ 'purchased_at' => $purchasedAt, 'label' => $offer['label'], 'runtime_months' => $offer['runtime_months'] ?? null, 'mining_speed_value' => $offer['mining_speed_value'] ?? null, 'mining_speed_unit' => $offer['mining_speed_unit'] ?? null, 'bonus_speed_value' => $offer['bonus_speed_value'] ?? null, 'bonus_speed_unit' => $offer['bonus_speed_unit'] ?? null, 'total_cost_amount' => $purchaseCost, 'currency' => $purchaseCurrency !== '' ? $purchaseCurrency : $offer['price_currency'], 'usd_reference_amount' => $offer['usd_reference_amount'] ?? null, 'reference_price_amount' => $referencePriceAmount, 'reference_price_currency' => $referencePriceCurrency, 'auto_renew' => array_key_exists('auto_renew', $input) ? (!empty($input['auto_renew']) ? 1 : 0) : (!empty($offer['auto_renew']) ? 1 : 0), 'note' => $this->optionalString($input['note'] ?? ($offer['note'] ?? null), 1000), 'is_active' => 1, ]); } private function updatePurchasedMiner(string $projectKey, int $minerId, array $input): array { $miner = $this->repository()->getPurchasedMiner($projectKey, $minerId); if (!is_array($miner)) { throw new ApiException('Gemieteter Miner nicht gefunden.', 404); } if (!array_key_exists('auto_renew', $input)) { throw new ApiException('Es kann aktuell nur auto_renew geaendert werden.', 422, ['field' => 'auto_renew']); } $offerId = is_numeric($miner['miner_offer_id'] ?? null) ? (int) $miner['miner_offer_id'] : null; if ($offerId === null) { throw new ApiException('Dieser Miner kann nicht ueber ein Angebot aktualisiert werden.', 422); } $offer = $this->repository()->getMinerOffer($projectKey, $offerId); if (!is_array($offer) || empty($offer['auto_renew'])) { throw new ApiException('Dieser Miner unterstuetzt keine automatische Verlaengerung.', 422); } return $this->repository()->updatePurchasedMinerAutoRenew( $projectKey, $minerId, !empty($input['auto_renew']) ); } private function saveDashboard(string $projectKey, array $input): array { $payload = [ 'name' => $this->requiredString($input['name'] ?? null, 'name', 160), 'chart_type' => $this->enumValue($input['chart_type'] ?? null, ['line', 'bar', 'area', 'table'], 'chart_type'), 'x_field' => $this->requiredString($input['x_field'] ?? null, 'x_field', 64), 'y_field' => $this->requiredString($input['y_field'] ?? null, 'y_field', 64), 'aggregation' => $this->enumValue($input['aggregation'] ?? 'none', ['none', 'sum', 'avg', 'min', 'max', 'count', 'latest'], 'aggregation'), 'filters' => $this->optionalArray($input['filters'] ?? []) ?? [], 'is_active' => !empty($input['is_active']) ? 1 : 0, ]; return $this->repository()->saveDashboard($projectKey, $payload); } private function dashboardData(string $projectKey, array $query): array { $measurements = $this->measurements($projectKey); return $this->analytics()->dashboardData( $measurements, (string) ($query['x_field'] ?? 'measured_at'), (string) ($query['y_field'] ?? 'coins_total'), (string) ($query['aggregation'] ?? 'none'), [ 'source' => $query['source'] ?? null, 'currency' => $query['currency'] ?? null, 'date_from' => $query['date_from'] ?? null, 'date_to' => $query['date_to'] ?? null, ] ); } private function requiredString(mixed $value, string $field, int $maxLength): string { $normalized = trim((string) $value); if ($normalized === '') { throw new ApiException("Feld {$field} ist erforderlich.", 422); } if (mb_strlen($normalized) > $maxLength) { throw new ApiException("Feld {$field} ist zu lang.", 422); } return $normalized; } private function optionalString(mixed $value, int $maxLength): ?string { $normalized = trim((string) $value); if ($normalized === '') { return null; } if (mb_strlen($normalized) > $maxLength) { throw new ApiException('Textwert ist zu lang.', 422); } return $normalized; } private function requiredDecimal(mixed $value, string $field): float { if ($value === null || $value === '') { throw new ApiException("Feld {$field} ist erforderlich.", 422); } if (!is_numeric((string) $value)) { throw new ApiException("Feld {$field} muss numerisch sein.", 422); } return (float) $value; } private function optionalDecimal(mixed $value): ?float { if ($value === null || $value === '') { return null; } if (!is_numeric((string) $value)) { throw new ApiException('Dezimalwert ist ungueltig.', 422); } return (float) $value; } private function requiredEnum(mixed $value, string $field, array $allowed): string { $normalized = strtolower(trim((string) $value)); if (!in_array($normalized, $allowed, true)) { throw new ApiException("Feld {$field} ist ungueltig.", 422, [ 'field' => $field, 'allowed' => $allowed, ]); } return $normalized; } private function requiredCurrency(mixed $value, string $field): string { $currency = strtoupper(trim((string) $value)); if (!preg_match('/^[A-Z0-9]{3,10}$/', $currency)) { throw new ApiException("Feld {$field} muss ein gueltiger Waehrungscode sein.", 422); } $resolved = $this->repository()->resolveCurrencyCode($currency); if ($resolved !== null && !empty($resolved['code'])) { return (string) $resolved['code']; } $catalogEntry = $this->currencyCatalogEntry($currency); if (is_array($catalogEntry)) { $this->repository()->ensureCurrencyCode($currency, (string) ($catalogEntry['name'] ?? $currency)); return $currency; } throw new ApiException( "Feld {$field} verweist auf keinen vorhandenen Waehrungsrecord.", 422, [ 'field' => $field, 'missing_currency' => $currency, 'hint' => 'Synchronisiere zuerst den Waehrungskatalog aus fx-rates oder hinterlege einen Alias auf einen bestehenden Waehrungsrecord.', 'available_currencies' => array_slice(array_map( static fn (array $item): string => (string) ($item['code'] ?? ''), $this->currencies() ), 0, 50), ] ); } private function optionalCurrency(mixed $value): ?string { if ($value === null || $value === '') { return null; } return $this->requiredCurrency($value, 'currency'); } private function assertCurrencyType(string $code, bool $expectedCrypto, string $field): void { $this->ensureLocalCurrencyRecord($code); $resolved = $this->repository()->resolveCurrencyCode($code); $currency = is_array($resolved) ? ($resolved['currency'] ?? null) : null; $isCrypto = !empty($currency['is_crypto']); if ($isCrypto !== $expectedCrypto) { throw new ApiException( $expectedCrypto ? "Feld {$field} muss auf eine Krypto-Waehrung zeigen." : "Feld {$field} muss auf eine FIAT-Waehrung zeigen.", 422, ['field' => $field, 'currency' => $code, 'expected_crypto' => $expectedCrypto] ); } } private function optionalSpeedUnit(mixed $value): ?string { if ($value === null || $value === '') { return null; } $unit = trim((string) $value); if (!in_array($unit, ['kH/s', 'MH/s'], true)) { throw new ApiException('Geschwindigkeitseinheit ist ungueltig.', 422, ['allowed' => ['kH/s', 'MH/s']]); } return $unit; } private function requiredPositiveInt(mixed $value, string $field): int { if ($value === null || $value === '' || !is_numeric((string) $value)) { throw new ApiException("Feld {$field} muss numerisch sein.", 422); } $intValue = (int) $value; if ($intValue <= 0) { throw new ApiException("Feld {$field} muss groesser als 0 sein.", 422); } return $intValue; } private function requiredPositiveDecimal(mixed $value, string $field): float { if ($value === null || $value === '' || !is_numeric((string) $value)) { throw new ApiException("Feld {$field} muss numerisch sein.", 422); } $floatValue = (float) $value; if ($floatValue <= 0) { throw new ApiException("Feld {$field} muss groesser als 0 sein.", 422); } return $floatValue; } private function optionalPositiveInt(mixed $value): ?int { if ($value === null || $value === '') { return null; } return $this->requiredPositiveInt($value, 'value'); } private function requiredDateTime(mixed $value, string $field, ?string $timezone = null): string { $normalized = trim((string) $value); if ($normalized === '') { throw new ApiException("Feld {$field} ist erforderlich.", 422); } $sourceTimezone = new \DateTimeZone($timezone ?: 'UTC'); $formats = [ 'Y-m-d H:i:s', 'Y-m-d H:i', 'Y-m-d\TH:i:s', 'Y-m-d\TH:i', ]; $date = null; foreach ($formats as $format) { $parsed = \DateTimeImmutable::createFromFormat($format, $normalized, $sourceTimezone); if ($parsed instanceof \DateTimeImmutable) { $date = $parsed; break; } } if (!$date instanceof \DateTimeImmutable) { try { $date = new \DateTimeImmutable($normalized, $sourceTimezone); } catch (\Throwable) { $date = null; } } if (!$date instanceof \DateTimeImmutable) { throw new ApiException("Feld {$field} muss ein gueltiges Datum sein.", 422); } return $date->setTimezone($this->utcTimezone())->format('Y-m-d H:i:s'); } private function currentTimestamp(): string { return (new \DateTimeImmutable('now', $this->utcTimezone()))->format('Y-m-d H:i:s'); } private function requiredTimezone(mixed $value, string $field): string { $timezone = trim((string) $value); if (!$this->isValidTimezone($timezone)) { throw new ApiException("Feld {$field} enthaelt keine gueltige Zeitzone.", 422); } return $timezone; } private function isValidTimezone(string $timezone): bool { return $timezone !== '' && in_array($timezone, \DateTimeZone::listIdentifiers(), true); } private function projectTimezone(string $projectKey): string { $settings = $this->repository()->getSettings($projectKey); $timezone = is_array($settings) ? (string) ($settings['display_timezone'] ?? '') : ''; return $this->isValidTimezone($timezone) ? $timezone : 'Europe/Berlin'; } private function utcTimezone(): \DateTimeZone { static $timezone = null; if (!$timezone instanceof \DateTimeZone) { $timezone = new \DateTimeZone('UTC'); } return $timezone; } private function enumValue(mixed $value, array $allowed, string $field): string { $normalized = trim((string) $value); if (!in_array($normalized, $allowed, true)) { throw new ApiException("Feld {$field} enthaelt einen ungueltigen Wert.", 422, ['allowed' => $allowed]); } return $normalized; } private function optionalArray(mixed $value): ?array { if ($value === null || $value === '') { return null; } if (is_array($value)) { return $value; } if (is_string($value)) { $decoded = json_decode($value, true); if (is_array($decoded)) { return $decoded; } } throw new ApiException('Array-Wert ist ungueltig.', 422); } private function optionalCurrencyList(mixed $value): array { if ($value === null || $value === '') { return []; } if (!is_array($value)) { throw new ApiException('preferred_currencies muss ein Array sein.', 422); } $result = []; foreach ($value as $item) { $currency = $this->requiredCurrency($item, 'preferred_currencies'); if (!in_array($currency, $result, true)) { $result[] = $currency; } } return $result; } private function fxRatesSettings(): array { if ($this->fxRatesSettingsCache !== null) { return $this->fxRatesSettingsCache; } if (!modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'settings')) { return $this->fxRatesSettingsCache = []; } $settings = module_fn('fx-rates', 'settings'); return $this->fxRatesSettingsCache = is_array($settings) ? $settings : []; } private function preferredCurrencies(?array $fallback = null): array { $settings = $this->fxRatesSettings(); $preferred = $settings['preferred_currencies'] ?? $fallback ?? ['DOGE', 'USD', 'EUR']; $normalized = []; foreach (is_array($preferred) ? $preferred : [] as $code) { $normalizedCode = strtoupper(trim((string) $code)); if ($normalizedCode !== '' && !in_array($normalizedCode, $normalized, true)) { $normalized[] = $normalizedCode; } } return $normalized !== [] ? $normalized : ['DOGE', 'USD', 'EUR']; } private function currencyCatalog(): array { if ($this->currencyCatalogCache !== null) { return $this->currencyCatalogCache; } $settings = $this->fxRatesSettings(); $catalog = []; foreach (is_array($settings['currency_catalog'] ?? null) ? $settings['currency_catalog'] : [] as $entry) { if (!is_array($entry)) { continue; } $code = strtoupper(trim((string) ($entry['code'] ?? ''))); $name = trim((string) ($entry['name'] ?? '')); if ($code === '') { continue; } $catalog[] = [ 'code' => $code, 'name' => $name !== '' ? $name : $code, 'symbol' => $code, 'is_active' => 1, 'is_crypto' => $this->isCryptoCurrencyCode($code) ? 1 : 0, 'sort_order' => 1000, ]; } return $this->currencyCatalogCache = $catalog; } private function currencyCatalogEntry(string $code): ?array { $normalizedCode = strtoupper(trim($code)); if ($normalizedCode === '') { return null; } foreach ($this->currencyCatalog() as $entry) { if ($normalizedCode === strtoupper((string) ($entry['code'] ?? ''))) { return $entry; } } return null; } private function syncLocalCurrencyCatalogFromFxRates(bool $forceRefresh = false): array { if ($forceRefresh) { $this->fxRatesSettingsCache = null; $this->currencyCatalogCache = null; } $catalog = $this->currencyCatalog(); if ($catalog === []) { return []; } $this->repository()->saveCurrencies($catalog); return $this->repository()->listCurrencies(); } private function syncFxRatesPreferredCurrencies(array $preferredCurrencies): void { if (!modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'save_runtime_settings')) { return; } module_fn('fx-rates', 'save_runtime_settings', [ 'preferred_currencies' => $preferredCurrencies, ]); $this->fxRatesSettingsCache = null; } private function ensureLocalCurrencyRecord(string $code): void { $resolved = $this->repository()->resolveCurrencyCode($code); if ($resolved !== null) { return; } $catalogEntry = $this->currencyCatalogEntry($code); if ($catalogEntry !== null) { $this->repository()->saveCurrency($catalogEntry); return; } $this->repository()->ensureCurrencyCode($code, $code); } private function isCryptoCurrencyCode(string $code): bool { return in_array(strtoupper(trim($code)), [ 'ADA', 'ARB', 'AVAX', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC', 'MATIC', 'SOL', 'TRX', 'USDC', 'USDT', 'XRP', 'XMR' ], true); } private function resolveMeasurementFxFetchId(string $projectKey, array $payload, bool $allowRefresh, ?float $maxAgeHoursOverride = null): ?int { $measuredAt = trim((string) ($payload['measured_at'] ?? '')); $maxAgeHours = $maxAgeHoursOverride; if ($maxAgeHours === null) { $settings = $this->settings($projectKey); $maxAgeHours = is_numeric($settings['fx_max_age_hours'] ?? null) ? (float) $settings['fx_max_age_hours'] : 3.0; } if ($allowRefresh && $measuredAt !== '' && $this->isRecentTimestamp($measuredAt, $maxAgeHours)) { $fresh = $this->fx()->ensureFreshLatestRates($maxAgeHours, 'USD'); if (is_array($fresh) && is_numeric($fresh['fetch_id'] ?? null)) { return (int) $fresh['fetch_id']; } } if ($measuredAt !== '') { $nearest = $this->fx()->nearestSnapshot('USD', $measuredAt, null, null); if (is_array($nearest) && is_numeric($nearest['id'] ?? null)) { return (int) $nearest['id']; } } $latest = $this->fx()->latestSnapshot('USD', null); if (is_array($latest) && is_numeric($latest['id'] ?? null)) { return (int) $latest['id']; } if ($allowRefresh) { $fresh = $this->fx()->refreshLatestRates(null, 'USD'); if (is_array($fresh) && is_numeric($fresh['fetch_id'] ?? null)) { return (int) $fresh['fetch_id']; } } return null; } private function ensureMeasurementFxReferences(string $projectKey, array $rows, ?array $settings = null): array { $maxAgeHours = is_numeric($settings['fx_max_age_hours'] ?? null) ? (float) $settings['fx_max_age_hours'] : 3.0; $resolvedByTimestamp = []; $resolved = []; foreach ($rows as $row) { if (!is_array($row)) { continue; } $fetchId = is_numeric($row['fx_fetch_id'] ?? null) ? (int) $row['fx_fetch_id'] : 0; if ($fetchId <= 0) { $measuredAt = trim((string) ($row['measured_at'] ?? '')); if ($measuredAt !== '' && array_key_exists($measuredAt, $resolvedByTimestamp)) { $resolvedFetchId = $resolvedByTimestamp[$measuredAt]; } else { $resolvedFetchId = $this->resolveMeasurementFxFetchId($projectKey, $row, false, $maxAgeHours); if ($measuredAt !== '') { $resolvedByTimestamp[$measuredAt] = $resolvedFetchId; } } if ($resolvedFetchId !== null) { $row['fx_fetch_id'] = $resolvedFetchId; } } $resolved[] = $row; } return $resolved; } private function measurementFxSnapshots(array $measurements, ?int $limit = null): array { $snapshots = []; $measurementPool = $measurements; if ($limit !== null && $limit > 0 && count($measurementPool) > $limit) { $measurementPool = array_slice($measurementPool, -$limit); } foreach ($measurementPool as $measurement) { $fetchId = is_numeric($measurement['fx_fetch_id'] ?? null) ? (int) $measurement['fx_fetch_id'] : 0; if ($fetchId <= 0 || isset($snapshots[$fetchId])) { continue; } $snapshot = $this->fx()->snapshotByFetchId($fetchId, null, null); if (!is_array($snapshot)) { continue; } $snapshots[(string) $fetchId] = [ 'id' => is_numeric($snapshot['id'] ?? null) ? (int) $snapshot['id'] : $fetchId, 'base_currency' => (string) ($snapshot['base_currency'] ?? ''), 'rate_date' => (string) ($snapshot['rate_date'] ?? ''), 'provider' => (string) ($snapshot['provider'] ?? ''), 'fetched_at' => (string) ($snapshot['fetched_at'] ?? ''), 'rates' => is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [], ]; } return $snapshots; } private function isRecentTimestamp(string $timestamp, float $maxAgeHours): bool { $parsed = strtotime($timestamp); if ($parsed === false) { return false; } return abs(time() - $parsed) <= (int) round(max(0.25, $maxAgeHours) * 3600); } private function resolveOfferPurchaseCost(array $offer): float { $purchaseCurrency = (string) ($offer['price_currency'] ?? ''); $baseAmount = is_numeric($offer['base_price_amount'] ?? null) ? (float) $offer['base_price_amount'] : (is_numeric($offer['reference_price_amount'] ?? null) ? (float) $offer['reference_price_amount'] : (is_numeric($offer['usd_reference_amount'] ?? null) ? (float) $offer['usd_reference_amount'] : null)); $baseCurrency = (string) ($offer['base_price_currency'] ?? $offer['reference_price_currency'] ?? (is_numeric($offer['usd_reference_amount'] ?? null) ? 'USD' : '')); if ($purchaseCurrency !== '' && $baseAmount !== null && $baseAmount > 0 && $baseCurrency !== '') { $converted = $this->fx()->convert($baseAmount, $baseCurrency, $purchaseCurrency); if (is_numeric($converted) && (float) $converted > 0) { return (float) $converted; } } return (float) ($baseAmount ?? $offer['price_amount'] ?? 0); } private function pdo(): PDO { if ($this->pdo === null) { $this->debug->add('db.connect.start'); $this->pdo = ConnectionFactory::make($this->config); $this->debug->add('db.connect.end', [ 'driver' => (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME), ]); } return $this->pdo; } private function repository(): MiningRepository { if ($this->repository === null) { $this->repository = new MiningRepository($this->pdo(), $this->config->tablePrefix(), $this->debug, $this->ownerSub()); } return $this->repository; } private function ownerSub(): string { $user = app()->auth()->user(); $sub = is_array($user) ? trim((string) ($user['sub'] ?? '')) : ''; if ($sub !== '') { return $sub; } if (app()->auth()->isEnabled()) { throw new ApiException('Keycloak-Sub fehlt. Bitte erneut anmelden.', 401); } return 'local'; } private function schemaManager(): SchemaManager { if ($this->schemaManager === null) { $this->schemaManager = new SchemaManager($this->pdo(), $this->config->tablePrefix(), $this->moduleBasePath); } return $this->schemaManager; } private function fx(): FxService { if ($this->fx === null) { $fxConfig = $this->config->fx(); $this->fx = new FxService( $this->repository(), (string) ($fxConfig['url'] ?? 'https://currencyapi.net'), (string) ($fxConfig['currencies_url'] ?? 'https://currencyapi.net'), (int) ($fxConfig['timeout'] ?? 10), (int) ($fxConfig['cache_ttl'] ?? 21600), (bool) ($fxConfig['auto_fetch_on_miss'] ?? false), (string) ($fxConfig['provider'] ?? 'currencyapi'), (string) ($fxConfig['api_key'] ?? ''), $this->debug ); } return $this->fx; } private function respond(array $payload, int $statusCode = 200): never { $trace = $this->debug->export(); if ($trace !== []) { $payload['debug'] = $trace; } Http::json($payload, $statusCode); } private function configureRuntimeGuards(): void { if (function_exists('ignore_user_abort')) { @ignore_user_abort(false); } if (function_exists('set_time_limit')) { @set_time_limit(15); } $this->debug->add('runtime.guards', [ 'time_limit_sec' => 15, 'budget_sec' => self::LONG_REQUEST_BUDGET_SECONDS, ]); } private function applyRouteRuntimeGuards(string $resource): void { $normalized = trim(strtolower($resource)); $timeLimit = match ($normalized) { 'ocr-preview' => 45, 'measurements-import', 'legacy-fx-migrate', 'sql-import' => 60, default => 15, }; if (function_exists('set_time_limit')) { @set_time_limit($timeLimit); } $this->debug->add('runtime.route_guards', [ 'resource' => $resource, 'time_limit_sec' => $timeLimit, ]); } private function releaseSessionLock(): void { if (PHP_SAPI === 'cli') { return; } if (session_status() === PHP_SESSION_ACTIVE) { session_write_close(); } } private function assertRequestWithinBudget(float $startedAt, string $message): void { $elapsed = microtime(true) - $startedAt; if ($elapsed > self::LONG_REQUEST_BUDGET_SECONDS) { $this->debug->add('request.budget_exceeded', [ 'elapsed_ms' => round($elapsed * 1000, 2), 'budget_ms' => round(self::LONG_REQUEST_BUDGET_SECONDS * 1000, 2), 'message' => $message, ]); throw new ApiException($message, 503, ['timeout' => true, 'elapsed_ms' => round($elapsed * 1000, 2)]); } } private function safeTimed(string $event, callable $callback, mixed $fallback = null, array $context = []): mixed { $startedAt = microtime(true); $this->debug->add($event . '.start', $context); try { $result = $callback(); $meta = [ 'duration_ms' => round((microtime(true) - $startedAt) * 1000, 2), ]; if (is_array($result)) { $meta['count'] = count($result); } $this->debug->add($event . '.end', $context + $meta); return $result; } catch (\Throwable $exception) { $meta = [ 'duration_ms' => round((microtime(true) - $startedAt) * 1000, 2), 'fallback_used' => true, 'error' => $exception->getMessage(), 'type' => get_debug_type($exception), ]; $this->debug->add($event . '.error', $context + $meta); return $fallback; } } private function debugLatest(): array { $filePath = DebugState::latestFilePath(); if ($filePath === null || !is_file($filePath)) { return [ 'entries' => [], 'file' => $filePath, 'exists' => false, ]; } $raw = file_get_contents($filePath); $entries = json_decode($raw ?: '[]', true); return [ 'entries' => is_array($entries) ? $entries : [], 'file' => $filePath, 'exists' => true, 'updated_at' => date('c', filemtime($filePath) ?: time()), ]; } private function analytics(): AnalyticsService { if ($this->analytics === null) { $this->analytics = new AnalyticsService($this->fx()); } return $this->analytics; } private function seedImporter(): SeedImporter { if ($this->seedImporter === null) { $this->seedImporter = new SeedImporter($this->repository()); } return $this->seedImporter; } }