2570 lines
107 KiB
PHP
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;
|
|
}
|
|
}
|