Files
nexus/modules/mining-checker/src/Api/Router.php
Lars Gebhardt-Kusche de99f21332
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
dsasd
2026-05-09 01:34:41 +02:00

2570 lines
107 KiB
PHP

<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Api;
use Modules\MiningChecker\Domain\AnalyticsService;
use Modules\MiningChecker\Domain\FxService;
use Modules\MiningChecker\Domain\OcrService;
use Modules\MiningChecker\Domain\SeedImporter;
use Modules\MiningChecker\Infrastructure\ConnectionFactory;
use Modules\MiningChecker\Infrastructure\MiningRepository;
use Modules\MiningChecker\Infrastructure\ModuleConfig;
use Modules\MiningChecker\Infrastructure\SchemaManager;
use Modules\MiningChecker\Support\ApiException;
use Modules\MiningChecker\Support\DebugState;
use Modules\MiningChecker\Support\DebugTrace;
use Modules\MiningChecker\Support\Http;
use PDO;
final class Router
{
private const BOOTSTRAP_MEASUREMENT_LIMIT = 150;
private const BOOTSTRAP_SNAPSHOT_LIMIT = 40;
private const LONG_REQUEST_BUDGET_SECONDS = 8.0;
private const OVERVIEW_WINDOW_DAYS = 15;
private const FX_FETCH_MAX_AGE_HOURS = 3.0;
private string $moduleBasePath;
private ModuleConfig $config;
private ?PDO $pdo = null;
private ?MiningRepository $repository = null;
private ?AnalyticsService $analytics = null;
private OcrService $ocr;
private ?SeedImporter $seedImporter = null;
private ?SchemaManager $schemaManager = null;
private ?FxService $fx = null;
private ?array $fxRatesSettingsCache = null;
private ?array $currencyCatalogCache = null;
private DebugTrace $debug;
public function __construct(string $moduleBasePath)
{
$this->moduleBasePath = rtrim($moduleBasePath, '/');
$this->config = ModuleConfig::load($this->moduleBasePath);
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '');
$requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?: '');
$debugEnabled = function_exists('module_debug_enabled') ? module_debug_enabled('mining-checker') : false;
$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-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 (preg_match('~^measurements/(\d+)$~', $resource, $matches) && $method === 'DELETE') {
$this->deleteMeasurement($projectKey, (int) $matches[1]);
Http::json(['data' => ['deleted' => true]]);
}
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 === 'wallet-snapshots' && $method === 'GET') {
Http::json(['data' => $this->walletSnapshots($projectKey)]);
}
if ($resource === 'wallet-snapshots' && $method === 'POST') {
$this->repository()->ensureProject($projectKey);
Http::json(['data' => $this->saveWalletSnapshot($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);
}
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' => self::FX_FETCH_MAX_AGE_HOURS,
'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]);
$walletSnapshots = $this->safeTimed('bootstrap.wallet_snapshots', fn () => $this->bootstrapWalletSnapshots($projectKey, $view), [], [
'project_key' => $projectKey,
'view' => $view,
]);
$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,
'wallet_snapshots' => $walletSnapshots,
'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)),
'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), []),
'wallet_snapshots' => $this->safeRead(fn () => $this->repository()->listWalletSnapshots($projectKey, 500), []),
'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);
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' => self::FX_FETCH_MAX_AGE_HOURS,
'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,
]);
}
foreach ($backup['wallet_snapshots'] as $snapshot) {
$this->repository()->saveWalletSnapshot($projectKey, [
'measured_at' => $snapshot['measured_at'],
'total_value_amount' => $snapshot['total_value_amount'] ?? null,
'total_value_currency' => $snapshot['total_value_currency'] ?? null,
'wallet_balance' => $snapshot['wallet_balance'] ?? null,
'wallet_currency' => $snapshot['wallet_currency'] ?? ($backup['settings']['crypto_currency'] ?? 'DOGE'),
'balances_json' => $snapshot['balances_json'] ?? [],
'note' => $snapshot['note'] ?? null,
'source' => $snapshot['source'] ?? 'manual',
'image_path' => $snapshot['image_path'] ?? null,
'ocr_raw_text' => $snapshot['ocr_raw_text'] ?? null,
'ocr_confidence' => $snapshot['ocr_confidence'] ?? null,
'ocr_flags' => $snapshot['ocr_flags'] ?? [],
]);
}
$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']),
'wallet_snapshots' => count($backup['wallet_snapshots']),
'targets' => count($backup['targets']),
'dashboards' => count($backup['dashboards']),
'miner_offers' => count($backup['miner_offers']),
'fx_rates' => $restoredFxRates,
],
]);
}
private function safeRead(callable $callback, mixed $fallback = null): mixed
{
try {
return $callback();
} catch (\Throwable) {
return $fallback;
}
}
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',
'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 (!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' => self::FX_FETCH_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, ['upload', 'dashboards', 'wallet'], 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 bootstrapWalletSnapshots(string $projectKey, string $view): array
{
if (!in_array($view, ['wallet'], true)) {
return [];
}
return $this->repository()->listWalletSnapshots($projectKey, 50);
}
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', 'upload', 'measurements', 'wallet', 'dashboards', 'mining'], 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'),
'coin_currency' => $this->measurementCoinCurrency($projectKey, $input),
'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($projectKey, $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 deleteMeasurement(string $projectKey, int $measurementId): void
{
if ($measurementId <= 0) {
throw new ApiException('measurement_id ist ungueltig.', 422);
}
$this->repository()->deleteMeasurement($projectKey, $measurementId);
}
private function syncCurrencyCatalogForMeasurement(array $payload): void
{
$coinCurrency = strtoupper(trim((string) ($payload['coin_currency'] ?? '')));
if ($coinCurrency !== '' && $this->currencyCatalogEntry($coinCurrency) === null) {
throw new ApiException(
'Coin-Waehrung ist im fx-rates Katalog nicht vorhanden.',
422,
['currency' => $coinCurrency]
);
}
$priceCurrency = strtoupper(trim((string) ($payload['price_currency'] ?? '')));
if ($priceCurrency === '') {
return;
}
if ($this->currencyCatalogEntry($priceCurrency) === null) {
throw new ApiException(
'Waehrung ist im fx-rates Katalog nicht vorhanden.',
422,
['currency' => $priceCurrency]
);
}
}
private function parseImportLine(string $projectKey, 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,
'coin_currency' => $this->measurementCoinCurrency($projectKey),
'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
{
return $this->currencyCatalog();
}
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 walletSnapshots(string $projectKey): array
{
return $this->repository()->listWalletSnapshots($projectKey, 100);
}
private function saveWalletSnapshot(string $projectKey, array $input): array
{
$balances = $input['balances_json'] ?? [];
if (!is_array($balances)) {
$balances = [];
}
$payload = [
'measured_at' => $this->requiredDateTime($input['measured_at'] ?? null, 'measured_at', $this->projectTimezone($projectKey)),
'total_value_amount' => $this->optionalDecimal($input['total_value_amount'] ?? null),
'total_value_currency' => $this->optionalCurrency($input['total_value_currency'] ?? null),
'wallet_balance' => $this->optionalDecimal($input['wallet_balance'] ?? null),
'wallet_currency' => $this->requiredCurrency($input['wallet_currency'] ?? ($this->settings($projectKey)['crypto_currency'] ?? 'DOGE'), 'wallet_currency'),
'balances_json' => $balances,
'note' => $this->optionalString($input['note'] ?? null, 1000),
'source' => $this->enumValue($input['source'] ?? 'manual', ['manual', 'image_ocr', 'seed_import'], 'source'),
'image_path' => $this->optionalString($input['image_path'] ?? null, 255),
'ocr_raw_text' => $this->optionalString($input['ocr_raw_text'] ?? null, 20000),
'ocr_confidence' => $this->optionalDecimal($input['ocr_confidence'] ?? null),
'ocr_flags' => is_array($input['ocr_flags'] ?? null) ? $input['ocr_flags'] : [],
];
return $this->repository()->saveWalletSnapshot($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);
}
$settings = $this->settings($projectKey);
$cryptoCurrency = $this->requiredCurrency($settings['crypto_currency'] ?? 'DOGE', 'crypto_currency');
$isAutoRenew = array_key_exists('auto_renew', $input) ? !empty($input['auto_renew']) : !empty($offer['auto_renew']);
$purchaseCurrency = $this->optionalCurrency($input['currency'] ?? null) ?? (string) ($offer['effective_price_currency'] ?? $offer['price_currency'] ?? $offer['base_price_currency'] ?? '');
if (!$isAutoRenew) {
$purchaseCurrency = $cryptoCurrency;
}
$purchaseCost = $this->optionalDecimal($input['total_cost_amount'] ?? null);
if ($purchaseCost === null || !$isAutoRenew) {
$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' => $isAutoRenew ? 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);
}
$catalogEntry = $this->currencyCatalogEntry($currency);
if (is_array($catalogEntry)) {
return $currency;
}
throw new ApiException(
"Feld {$field} verweist auf keinen vorhandenen fx-rates Waehrungscode.",
422,
[
'field' => $field,
'missing_currency' => $currency,
'hint' => 'Synchronisiere zuerst den Waehrungskatalog im Modul fx-rates.',
'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
{
if ($this->currencyCatalogEntry($code) === null) {
throw new ApiException(
"Feld {$field} verweist auf keinen vorhandenen fx-rates Waehrungscode.",
422,
['field' => $field, 'currency' => $code]
);
}
$isCrypto = $this->isCryptoCurrencyCode($code);
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 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;
$this->currencyCatalogCache = null;
}
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 ?? self::FX_FETCH_MAX_AGE_HOURS;
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 measurementCoinCurrency(string $projectKey, array $input = []): string
{
$requested = $this->optionalCurrency($input['coin_currency'] ?? null);
if ($requested !== null) {
return $requested;
}
$settings = $this->settings($projectKey);
return $this->requiredCurrency($settings['crypto_currency'] ?? 'DOGE', 'crypto_currency');
}
private function ensureMeasurementFxReferences(string $projectKey, array $rows, ?array $settings = null): array
{
$maxAgeHours = self::FX_FETCH_MAX_AGE_HOURS;
$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;
}
}