From b81de785acecf0d46cc878e09187576a0d48ecf9 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Sun, 21 Jun 2026 00:14:19 +0200 Subject: [PATCH] mining checker --- config/apps.php | 38 +- docs/ANLEITUNG.md | 11 + docs/CONTENT.md | 11 + docs/README.md | 6 + docs/UMSETZUNGSSTATUS.md | 32 +- docs/WEITERENTWICKLUNG.md | 1 + modules/README.md | 11 + modules/mining-checker/api/index.php | 17 + modules/mining-checker/assets/css/.gitkeep | 0 .../mining-checker/assets/css}/app.css | 0 modules/mining-checker/assets/js/.gitkeep | 0 .../mining-checker/assets/js}/app.js | 216 +- modules/mining-checker/bootstrap.php | 17 + .../mining-checker/config/example.config.php | 24 + modules/mining-checker/config/module.php | 46 + modules/mining-checker/design.json | 17 + modules/mining-checker/desktop.php | 57 + modules/mining-checker/docs/README.md | 114 + modules/mining-checker/module.json | 30 + modules/mining-checker/pages/index.php | 97 + modules/mining-checker/partials/.gitkeep | 0 .../sql/migrations/001_init.sql | 124 + .../sql/migrations/002_seed_doge_main.sql | 75 + .../sql/migrations/003_timezone_utc.sql | 34 + ...merge_cost_plans_into_purchased_miners.sql | 72 + .../migrations/005_module_theme_settings.sql | 15 + .../migrations/006_user_scope_owner_sub.sql | 182 ++ modules/mining-checker/sql/schema.mysql.sql | 223 ++ modules/mining-checker/sql/schema.pgsql.sql | 243 ++ modules/mining-checker/sql/schema.sql | 223 ++ modules/mining-checker/sql/seed.sql | 75 + modules/mining-checker/src/Api/Router.php | 2774 +++++++++++++++++ .../src/Domain/AnalyticsService.php | 1661 ++++++++++ .../mining-checker/src/Domain/FxService.php | 950 ++++++ .../mining-checker/src/Domain/OcrService.php | 665 ++++ .../mining-checker/src/Domain/SeedData.php | 79 + .../src/Domain/SeedImporter.php | 62 + .../src/Infrastructure/ConnectionFactory.php | 65 + .../src/Infrastructure/MiningRepository.php | 1401 +++++++++ .../src/Infrastructure/ModuleConfig.php | 66 + .../src/Infrastructure/SchemaManager.php | 1482 +++++++++ .../src/Legacy}/LegacyModuleStore.php | 2 +- .../mining-checker/src/Legacy/UserScope.php | 15 + .../src/Support/ApiException.php | 29 + .../mining-checker/src/Support/DebugState.php | 35 + .../mining-checker/src/Support/DebugTrace.php | 60 + modules/mining-checker/src/Support/Http.php | 27 + .../mining-checker/storage/uploads/.gitkeep | 0 partials/desktop/shell.php | 43 +- public/api/mining-checker/index.php | 825 +---- .../api/module-auth/mining-checker/index.php | 32 +- public/api/user-self-management/index.php | 4 +- public/apps/mining-checker/index.php | 126 +- public/assets/desktop/desktop.js | 34 +- public/module-assets/index.php | 53 + src/App/App.php | 4 +- src/App/bootstrap.php | 2 +- src/Desktop/AppRegistry.php | 7 +- src/MiningChecker/MiningCheckerCalculator.php | 84 - src/MiningChecker/MiningCheckerStore.php | 92 - src/MiningChecker/MiningCheckerUserScope.php | 21 - src/ModulesCore/ModuleHttp.php | 71 + src/ModulesCore/ModuleRegistry.php | 105 + 63 files changed, 11549 insertions(+), 1338 deletions(-) create mode 100644 modules/mining-checker/api/index.php create mode 100644 modules/mining-checker/assets/css/.gitkeep rename {public/assets/apps/mining-checker => modules/mining-checker/assets/css}/app.css (100%) create mode 100644 modules/mining-checker/assets/js/.gitkeep rename {public/assets/apps/mining-checker => modules/mining-checker/assets/js}/app.js (96%) create mode 100644 modules/mining-checker/bootstrap.php create mode 100644 modules/mining-checker/config/example.config.php create mode 100644 modules/mining-checker/config/module.php create mode 100644 modules/mining-checker/design.json create mode 100644 modules/mining-checker/desktop.php create mode 100644 modules/mining-checker/docs/README.md create mode 100644 modules/mining-checker/module.json create mode 100644 modules/mining-checker/pages/index.php create mode 100644 modules/mining-checker/partials/.gitkeep create mode 100644 modules/mining-checker/sql/migrations/001_init.sql create mode 100644 modules/mining-checker/sql/migrations/002_seed_doge_main.sql create mode 100644 modules/mining-checker/sql/migrations/003_timezone_utc.sql create mode 100644 modules/mining-checker/sql/migrations/004_merge_cost_plans_into_purchased_miners.sql create mode 100644 modules/mining-checker/sql/migrations/005_module_theme_settings.sql create mode 100644 modules/mining-checker/sql/migrations/006_user_scope_owner_sub.sql create mode 100644 modules/mining-checker/sql/schema.mysql.sql create mode 100644 modules/mining-checker/sql/schema.pgsql.sql create mode 100644 modules/mining-checker/sql/schema.sql create mode 100644 modules/mining-checker/sql/seed.sql create mode 100644 modules/mining-checker/src/Api/Router.php create mode 100644 modules/mining-checker/src/Domain/AnalyticsService.php create mode 100644 modules/mining-checker/src/Domain/FxService.php create mode 100644 modules/mining-checker/src/Domain/OcrService.php create mode 100644 modules/mining-checker/src/Domain/SeedData.php create mode 100644 modules/mining-checker/src/Domain/SeedImporter.php create mode 100644 modules/mining-checker/src/Infrastructure/ConnectionFactory.php create mode 100644 modules/mining-checker/src/Infrastructure/MiningRepository.php create mode 100644 modules/mining-checker/src/Infrastructure/ModuleConfig.php create mode 100644 modules/mining-checker/src/Infrastructure/SchemaManager.php rename {src/MiningChecker => modules/mining-checker/src/Legacy}/LegacyModuleStore.php (99%) create mode 100644 modules/mining-checker/src/Legacy/UserScope.php create mode 100644 modules/mining-checker/src/Support/ApiException.php create mode 100644 modules/mining-checker/src/Support/DebugState.php create mode 100644 modules/mining-checker/src/Support/DebugTrace.php create mode 100644 modules/mining-checker/src/Support/Http.php create mode 100644 modules/mining-checker/storage/uploads/.gitkeep create mode 100644 public/module-assets/index.php delete mode 100644 src/MiningChecker/MiningCheckerCalculator.php delete mode 100644 src/MiningChecker/MiningCheckerStore.php delete mode 100644 src/MiningChecker/MiningCheckerUserScope.php create mode 100644 src/ModulesCore/ModuleHttp.php create mode 100644 src/ModulesCore/ModuleRegistry.php diff --git a/config/apps.php b/config/apps.php index b8cbc0ae..4521d710 100644 --- a/config/apps.php +++ b/config/apps.php @@ -56,6 +56,18 @@ return [ 'supports_tray' => false, 'module_name' => 'desktop', 'summary' => 'Persoenliche Einstellungen fuer Desktop-Typ, Benutzerdaten, Apps und Widgets in einem gemeinsamen Setup-Bereich.', + 'native_module' => [ + 'initializer' => 'initUserSelfManagementApp', + 'options' => [], + 'assets' => [ + 'styles' => [ + ['href' => '/assets/apps/user-self-management/app.css'], + ], + 'scripts' => [ + ['src' => '/assets/apps/user-self-management/app.js'], + ], + ], + ], ], [ 'app_id' => 'admin-apps', @@ -98,30 +110,4 @@ return [ 'module_name' => 'auth', 'summary' => 'Uebergabepunkt fuer das spaetere Keycloak-Theme im Desktop-Look.', ], - [ - 'app_id' => 'mining-checker', - 'title' => 'Mining-Checker', - 'icon' => 'MC', - 'entry_route' => '/apps/mining-checker', - 'window_mode' => 'window', - 'content_mode' => 'native-module', - 'default_width' => 1180, - 'default_height' => 760, - 'supports_widget' => false, - 'supports_tray' => true, - 'module_name' => 'finance', - 'summary' => 'Vollstaendiger Rechner fuer Mining-Profitabilitaet, Stromkosten, ROI und Snapshot-Historie.', - 'module_options' => [ - 'default_project_key' => 'doge-main', - 'active_view' => 'overview', - 'sections' => [ - ['key' => 'overview', 'label' => 'Übersicht'], - ['key' => 'upload', 'label' => 'Upload'], - ['key' => 'measurements', 'label' => 'Mining-History'], - ['key' => 'wallet', 'label' => 'Wallet'], - ['key' => 'mining', 'label' => 'Miner-Daten'], - ['key' => 'dashboards', 'label' => 'Dashboards'], - ], - ], - ], ]; diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index b60a9001..f20bde4d 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -36,6 +36,7 @@ Das Startmenue ist in drei Bereiche gegliedert: - Desktop-Icons koennen direkt geoeffnet werden. - Im Startmenue lassen sich Programme ueber den `Auswahlbereich` starten. - Fenster koennen verschoben, minimiert, maximiert und geschlossen werden. +- Programme koennen sowohl globale System-Apps als auch klassische Module sein. ## Einstellungen oeffnen @@ -48,6 +49,16 @@ Dort koennen derzeit insbesondere verwaltet werden: - sichtbare Apps - Inhalte des `Infobereichs` +## Module + +Klassische Module liegen unter `modules//` und koennen als normale `App` im Desktop erscheinen. + +Aktuell gilt: + +- der `Mining-Checker` ist das erste echte Modul in dieser Form +- Modul-Businesslogik bleibt im Modul und wird nicht in den Desktop-Core verschoben +- gemeinsame Desktop-Mechaniken wie Fenster, Asset-Einbindung und Zugriffsschutz werden global bereitgestellt + ## Benutzerdaten Aktuell gelten folgende Regeln: diff --git a/docs/CONTENT.md b/docs/CONTENT.md index 548be95c..8acbec1c 100644 --- a/docs/CONTENT.md +++ b/docs/CONTENT.md @@ -80,6 +80,8 @@ Stand dieser Datei: - Startmenue mit `User Setting Bereich`, `Funktion-Bereich` und `Auswahlbereich` - rechter `Infobereich` mit benutzerbezogener Sichtbarkeit - `User Self Management` als Setup-App +- erster echter Modulpfad fuer klassische Module mit Modul-Discovery aus `modules//` +- `Mining-Checker` als erstes angebundenes klassisches Modul - Benutzereinstellungen fuer Desktop-Skin, App-Auswahl, Infobereich und Profildaten in einer eigenen Datenbanktabelle mit `_user_data`-Suffix - vorhandene JSON-Dateien dienen nur noch als Fallback oder Uebergang fuer lokale Entwicklung und Altbestaende - LDAP-Synchronisierung fuer Standardfelder wie Name, E-Mail, Telefon, Titel und Ort @@ -105,6 +107,14 @@ Aus [modules/README.md](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/m - klassische Module bleiben unter `modules//` - Businesslogik wird nicht in den Desktop-Core verschoben +- ein Desktop-Modul kann eigene `desktop.php`, `pages/`, `api/` und `assets/` Strukturen mitbringen +- Modul-Assets muessen nicht in den globalen Desktop-Asset-Baum kopiert werden + +### Aktueller Modulstand + +- `Mining-Checker` ist das erste Modul, das als echte Desktop-App aus `modules/mining-checker/` angebunden wird +- die Desktop-Shell kann Moduldefinitionen zentral erkennen und als `App` bereitstellen +- wiederverwendbare Modul-Helfer liegen im globalen Kern, die Fachlogik bleibt im Modul ### Skins @@ -125,6 +135,7 @@ Fuer einen spaeteren Hilfebereich sollen Inhalte aus dieser Datei in Themenblcke - `Fenster verwenden` - `Infobereich konfigurieren` - `Apps und Desktop Type verwalten` +- `Module verstehen und starten` - `Benutzerdaten und Synchronisierung` Die Inhalte sollen spaeter moeglichst nicht neu erfunden, sondern aus den zentral gepflegten Dateien abgeleitet werden. diff --git a/docs/README.md b/docs/README.md index 31acfe39..f60e0676 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ Zentraler Dokumentationsindex fuer das Projekt. - `public/` Web-Root mit Desktop-Shell auf `/` - `src/Desktop/` zentrale Desktop-Mechaniken +- `src/ModulesCore/` gemeinsame Helfer fuer Modul-Discovery, Modul-HTTP und wiederverwendbare Modul-Anbindung - `partials/desktop/` Shell-Template - `modules/` Zielort fuer klassische Module - `temp/nexus-module-import/` Rohbasis fuer importierte Nexus-Module @@ -43,3 +44,8 @@ Es gilt: - wichtige Inhalte aus Unterordner-`README.md`-Dateien werden zentral in `docs/CONTENT.md` gespiegelt - Nutzungswissen wird in `docs/ANLEITUNG.md` gepflegt - Entwicklungsregeln werden in `docs/WEITERENTWICKLUNG.md` gepflegt + +Aktuell wichtig: + +- der Mining-Checker ist das erste als echtes Desktop-Modul angebundene Modul unter `modules/mining-checker/` +- Modul-Assets koennen ueber einen gemeinsamen Auslieferungsweg aus dem Modul selbst geladen werden diff --git a/docs/UMSETZUNGSSTATUS.md b/docs/UMSETZUNGSSTATUS.md index 01d4107e..6d87693e 100644 --- a/docs/UMSETZUNGSSTATUS.md +++ b/docs/UMSETZUNGSSTATUS.md @@ -1,6 +1,6 @@ # Umsetzungsstatus Desktop UI -Stand: 2026-06-07 +Stand: 2026-06-21 Diese Datei ist die laufende Arbeitsgrundlage fuer die Umsetzung der Anweisungen aus `docs/Umsetzungsanweisung/`. @@ -23,8 +23,10 @@ Aktuell ist das Projekt auf einem V1-Scaffold-Stand: - Skin-System `Windows` / `Apple` / `Linux` ist vorbereitet - einfacher Fenster-Manager ist vorhanden - App-Registry, Widget-Registry und Import-Basis sind angelegt +- erster echter Modulmechanismus fuer klassische Module ist vorhanden +- `Mining-Checker` ist als erstes klassisches Modul angebunden - Keycloak ist nur konzeptionell vorbereitet, nicht integriert -- Admin-Bereiche, persistente User-Desktops und echte Modul-Anbindung sind noch offen +- Admin-Bereiche und persistente User-Desktops sind noch offen ## Verbindliche Regeln @@ -49,7 +51,7 @@ Erfuellt: Offen: - globale Verwaltungsseiten nur als Platzhalter vorhanden -- klassische Module sind noch nicht schrittweise an die Shell angebunden +- weitere klassische Module sind noch nicht schrittweise an die Shell angebunden - persoenliche Dashboards sind noch nicht funktional Nachweise: @@ -98,7 +100,7 @@ Erfuellt: Offen: -- `modules//` im neuen Projekt enthalten noch keine wirklich angebundenen Module +- weitere `modules//` sind noch nicht angebunden - `partials/landingpages/` und `partials/structure/` sind nur minimal vorbereitet - `tools/` und `debug/` sind noch nicht mit Funktion befuellt - Skin-Ordnerkonzept mit `base`-Fallback sowie pro Skin getrennten Layout-/Asset-Dateien fachlich fertigziehen und abnehmen @@ -119,18 +121,24 @@ Erfuellt: - Rohkopie der Altmodule liegt in `temp/nexus-module-import/modules/` - relevante Doku aus der Altbasis wurde in den Import-Ordner uebernommen - Produktivcode referenziert den Import-Ordner nicht zur Laufzeit +- `Mining-Checker` wurde als erstes Modul aus der Projektstruktur `modules/mining-checker/` an die Shell angebunden +- Modul-Assets werden aus dem Modul selbst geladen statt ueber eine alte Sonderkopie +- gemeinsame Modul-Helfer fuer Discovery und Zugriffsschutz sind im globalen Kern vorbereitet Offen: - keine Modulpruefung je Modul durchgefuehrt -- keine App-Registry-Eintraege fuer echte importierte Module vorhanden -- keine Modulansichten in die Fensterlogik integriert +- weitere App-Registry-Eintraege fuer echte importierte Module fehlen noch +- Fenstertauglichkeit weiterer Module ist nicht geprueft - kein Skin-Test je Modul erfolgt Nachweise: - [temp/nexus-module-import](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/temp/nexus-module-import:1) - [config/apps.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/config/apps.php:1) +- [modules/mining-checker](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/modules/mining-checker:1) +- [src/ModulesCore/ModuleRegistry.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/src/ModulesCore/ModuleRegistry.php:1) +- [public/module-assets/index.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/public/module-assets/index.php:1) ### 5. `04_UMSETZUNGSPLAN_V1.md` @@ -168,7 +176,7 @@ Phase 4 Inhaltssystem: - `ERLEDIGT`: App Registry als Grundstruktur - `TEILWEISE`: Widget Registry als Grundstruktur, noch nicht abgenommen -- `OFFEN`: Seitenmodule +- `TEILWEISE`: Seitenmodule, erster echter Modulpfad vorhanden - `OFFEN`: persoenliche Linklisten - `TEILWEISE`: oeffentliches Home-Dashboard nur als Placeholder - `TEILWEISE`: persoenliche Workspaces nur als Placeholder @@ -185,8 +193,8 @@ Phase 5 Admin-Bereiche: Phase 6 Modul-Anbindung: - `TEILWEISE`: importierte Nexus-Module liegen vor -- `OFFEN`: erste Module als Apps in der Shell -- `OFFEN`: Fenstertauglichkeit je Modul pruefen +- `TEILWEISE`: erstes Modul als App in der Shell +- `OFFEN`: Fenstertauglichkeit weiterer Module pruefen - `OFFEN`: langfristige UX-Anpassung je Modul V1-Minimalziel: @@ -244,17 +252,23 @@ Vorhanden: - JSON-Payload fuer Frontend per `api/desktop.php` - PHP-Autoload-Basis - App-Registry +- Modul-Registry fuer klassische Module - Widget-Registry - Skin-Resolver - Default-Workspace- und Widget-State - einfacher Window-Manager - Window-Controls fuer minimieren, maximieren und schliessen - CSS/JS fuer Shell, Fenster, Taskbar, Startmenue, Widgets und Uhr +- gemeinsamer Modul-Asset-Endpoint +- `Mining-Checker` als erstes echtes Modul unter `modules/` Hauptdateien: - [public/index.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/public/index.php:1) - [api/desktop.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/api/desktop.php:1) - [src/App/App.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/src/App/App.php:1) +- [src/ModulesCore/ModuleRegistry.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/src/ModulesCore/ModuleRegistry.php:1) +- [src/ModulesCore/ModuleHttp.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/src/ModulesCore/ModuleHttp.php:1) - [src/Desktop/DesktopState.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/src/Desktop/DesktopState.php:1) - [public/assets/desktop/desktop.js](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/public/assets/desktop/desktop.js:1) +- [public/module-assets/index.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/public/module-assets/index.php:1) diff --git a/docs/WEITERENTWICKLUNG.md b/docs/WEITERENTWICKLUNG.md index 6e399907..9f4c417f 100644 --- a/docs/WEITERENTWICKLUNG.md +++ b/docs/WEITERENTWICKLUNG.md @@ -48,6 +48,7 @@ Verbindlich ist: - `modules//` bleibt Ort fuer klassische Module - globale Desktop-Mechaniken liegen im gemeinsamen Kern +- globale Modul-Helfer duerfen im gemeinsamen Kern liegen, Fachlogik aber nicht - Skins definieren Darstellung und Interaktionsdetails, nicht die Fachlogik - Hilfe- und Inhaltsdateien sollen spaeter maschinenlesbar oder zumindest klar strukturierbar in einen Hilfebereich ueberfuehrt werden koennen diff --git a/modules/README.md b/modules/README.md index d51ed468..766e27e1 100644 --- a/modules/README.md +++ b/modules/README.md @@ -4,6 +4,17 @@ Klassische Module bleiben in diesem Projekt unter `modules//`. Die neue Desktop-Shell ist nur die UI-Schicht. Modul-Businesslogik wird nicht in den Desktop-Core verschoben. +## Aktuelle Modulstruktur + +Ein Desktop-Modul kann zusaetzlich diese Projektdateien besitzen: + +- `desktop.php` fuer die Desktop-App-Metadaten und Asset-Definitionen +- `pages/` fuer Standalone-Seiten oder iframe/native Einstiegspunkte +- `api/` fuer modulinterne HTTP-Endpunkte +- `assets/` fuer modulnahe CSS- und JS-Dateien + +Module-Assets werden in diesem Projekt nicht direkt aus `public/assets/apps/...` dupliziert, sondern ueber den generischen Endpoint `/module-assets/index.php` aus dem jeweiligen Modul ausgeliefert. + ## Pflegehinweis Diese Datei muss gepflegt bleiben, wenn sich Modulstruktur oder Modulregeln aendern. diff --git a/modules/mining-checker/api/index.php b/modules/mining-checker/api/index.php new file mode 100644 index 00000000..9b570f45 --- /dev/null +++ b/modules/mining-checker/api/index.php @@ -0,0 +1,17 @@ +handle($relativePath); diff --git a/modules/mining-checker/assets/css/.gitkeep b/modules/mining-checker/assets/css/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/public/assets/apps/mining-checker/app.css b/modules/mining-checker/assets/css/app.css similarity index 100% rename from public/assets/apps/mining-checker/app.css rename to modules/mining-checker/assets/css/app.css diff --git a/modules/mining-checker/assets/js/.gitkeep b/modules/mining-checker/assets/js/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/public/assets/apps/mining-checker/app.js b/modules/mining-checker/assets/js/app.js similarity index 96% rename from public/assets/apps/mining-checker/app.js rename to modules/mining-checker/assets/js/app.js index b739b510..e6596d9b 100644 --- a/public/assets/apps/mining-checker/app.js +++ b/modules/mining-checker/assets/js/app.js @@ -1,33 +1,32 @@ (function () { - function boot(root) { - if (!root || !window.React || !window.ReactDOM) { - return false; - } + const root = document.getElementById('mining-checker-app'); + if (!root || !window.React || !window.ReactDOM) { + return; + } - const h = React.createElement; - const { useEffect, useMemo, useRef, useState } = React; - const apiBase = root.dataset.apiBase || '/api/mining-checker/v1'; - const initialProjectKey = root.dataset.defaultProjectKey || 'doge-main'; - const initialActiveTab = String(root.dataset.activeView || 'overview').trim() || 'overview'; - const configuredSections = (() => { - try { - const parsed = JSON.parse(root.dataset.sectionsJson || '[]'); - if (!Array.isArray(parsed)) { - return []; - } - return parsed - .map((section) => { - const key = section && typeof section.key === 'string' ? section.key.trim() : ''; - const label = section && typeof section.label === 'string' ? section.label.trim() : ''; - return key && label ? [key, label] : null; - }) - .filter(Boolean); - } catch (error) { + const h = React.createElement; + const { useEffect, useMemo, useRef, useState } = React; + const apiBase = root.dataset.apiBase || '/api/mining-checker/v1'; + const initialProjectKey = root.dataset.defaultProjectKey || 'doge-main'; + const initialActiveTab = String(root.dataset.activeView || 'overview').trim() || 'overview'; + const configuredSections = (() => { + try { + const parsed = JSON.parse(root.dataset.sectionsJson || '[]'); + if (!Array.isArray(parsed)) { return []; } - })(); - const initialDebugMode = root.dataset.moduleDebugEnabled === '1' - || (document.body && document.body.dataset.moduleDebugEnabled === '1'); + return parsed + .map((section) => { + const key = section && typeof section.key === 'string' ? section.key.trim() : ''; + const label = section && typeof section.label === 'string' ? section.label.trim() : ''; + return key && label ? [key, label] : null; + }) + .filter(Boolean); + } catch (error) { + return []; + } + })(); + const initialDebugMode = document.body && document.body.dataset.moduleDebugEnabled === '1'; function getCookie(name) { const pattern = `; ${document.cookie}`; const parts = pattern.split(`; ${name}=`); @@ -260,8 +259,8 @@ const requestOptions = options && typeof options === 'object' ? { ...options } : {}; const debugEnabled = !!debugBus.enabled; const timeoutMs = typeof requestOptions.timeoutMs === 'number' - ? (debugEnabled ? Math.max(requestOptions.timeoutMs, 20000) : requestOptions.timeoutMs) - : (debugEnabled ? 20000 : 8000); + ? (debugEnabled ? Math.max(requestOptions.timeoutMs, 30000) : requestOptions.timeoutMs) + : (debugEnabled ? 30000 : 20000); delete requestOptions.timeoutMs; const controller = new AbortController(); @@ -1204,7 +1203,8 @@ } } - async function loadBootstrap(key) { + async function loadBootstrap(key, options) { + const suppressError = !!(options && options.suppressError); const cacheKey = `${key}:${activeTab || 'overview'}`; const cachedPayload = bootstrapCacheRef.current.get(cacheKey) || null; @@ -1220,18 +1220,20 @@ loadGuardTriggered = true; setLoading(false); setPayload((previous) => previous || cachedPayload || normalizeBootstrap(null, key)); - setError((previous) => previous || 'Bootstrap-Request haengt oder braucht zu lange.'); - }, 12000); + if (!suppressError) { + setError((previous) => previous || 'Bootstrap-Request haengt oder braucht zu lange.'); + } + }, 30000); try { if (schemaStatus.missing_count > 0 || schemaStatus.pending_upgrade_count > 0) { setPayload(normalizeBootstrap(null, key)); setError('Mining-Checker Schema ist noch nicht initialisiert. Bitte im Tab Settings die Datenbank initialisieren.'); - return; + return false; } const params = new URLSearchParams({ view: activeTab || 'overview' }); - const data = await request(`${apiBase}/projects/${encodeURIComponent(key)}/bootstrap?${params.toString()}`, { timeoutMs: 10000 }); + const data = await request(`${apiBase}/projects/${encodeURIComponent(key)}/bootstrap?${params.toString()}`, { timeoutMs: 25000 }); const normalized = normalizeBootstrap(data, key); bootstrapCacheRef.current.set(cacheKey, normalized); setPayload(normalized); @@ -1252,9 +1254,13 @@ ...previous, currency: normalized.settings.currencies?.[0]?.code || previous.currency || 'EUR', })); + return true; } catch (err) { - setError(err.message); + if (!suppressError) { + setError(err.message); + } setPayload(normalizeBootstrap(null, key)); + return false; } finally { window.clearTimeout(loadGuard); if (!loadGuardTriggered) { @@ -1263,6 +1269,62 @@ } } + async function reloadBootstrapAfterMutation(successMessage) { + const refreshed = await loadBootstrap(projectKey, { suppressError: true }); + if (!refreshed) { + setError('Speichern war erfolgreich, aber die Ansicht konnte nicht automatisch aktualisiert werden.'); + if (successMessage) { + setMessage(successMessage); + } + } + return refreshed; + } + + function invalidateProjectBootstrapCache(key) { + const prefix = `${key}:`; + Array.from(bootstrapCacheRef.current.keys()).forEach((cacheKey) => { + if (String(cacheKey).startsWith(prefix)) { + bootstrapCacheRef.current.delete(cacheKey); + } + }); + } + + function applySavedPayout(savedPayout) { + if (!savedPayout || typeof savedPayout !== 'object') { + return; + } + + setPayload((previous) => { + const current = previous || normalizeBootstrap(null, projectKey); + const previousPayouts = Array.isArray(current.settings?.payouts) ? current.settings.payouts : []; + const savedId = String(savedPayout.id ?? ''); + const payouts = previousPayouts + .filter((row) => savedId === '' || String(row?.id ?? '') !== savedId) + .concat(savedPayout) + .sort((left, right) => String(left?.payout_at || '').localeCompare(String(right?.payout_at || ''))); + const totalCoins = payouts.reduce((sum, row) => { + const amount = Number(row?.coins_amount); + return Number.isFinite(amount) ? sum + amount : sum; + }, 0); + + return { + ...current, + settings: { + ...(current.settings || {}), + payouts, + }, + summary: { + ...(current.summary || {}), + payouts: { + ...(current.summary?.payouts || {}), + total_count: payouts.length, + total_coins: totalCoins, + }, + }, + }; + }); + } + useEffect(() => { loadBootstrap(projectKey); }, [projectKey, activeTab]); @@ -1416,7 +1478,7 @@ source: 'manual', }); setOcrPreview(null); - await loadBootstrap(projectKey); + await reloadBootstrapAfterMutation(fromPreview ? 'OCR-Vorschlag bestaetigt und gespeichert.' : 'Messpunkt gespeichert.'); } catch (err) { setError(err.message); } finally { @@ -1445,7 +1507,7 @@ }); setMessage('Wallet-Snapshot gespeichert.'); setOcrPreview(null); - await loadBootstrap(projectKey); + await reloadBootstrapAfterMutation('Wallet-Snapshot gespeichert.'); } catch (err) { setError(err.message); } finally { @@ -1470,7 +1532,7 @@ method: 'DELETE', }); setMessage('Messpunkt geloescht.'); - await loadBootstrap(projectKey); + await reloadBootstrapAfterMutation('Messpunkt geloescht.'); } catch (err) { setError(err.message); } finally { @@ -1501,7 +1563,7 @@ if (!result.error_count) { setImportForm((previous) => ({ ...previous, rows_text: '' })); } - await loadBootstrap(projectKey); + await reloadBootstrapAfterMutation(`Import abgeschlossen: ${summary}.`); } catch (err) { setError(err.message); } finally { @@ -1562,7 +1624,7 @@ }), }); setMessage('Dashboard gespeichert.'); - await loadBootstrap(projectKey); + await reloadBootstrapAfterMutation('Dashboard gespeichert.'); } catch (err) { setError(err.message); } finally { @@ -1591,7 +1653,7 @@ }), }); setMessage('Settings gespeichert.'); - await loadBootstrap(projectKey); + await reloadBootstrapAfterMutation('Settings gespeichert.'); } catch (err) { setError(err.message); } finally { @@ -1648,7 +1710,7 @@ setMessage('Ziel gespeichert.'); setTargetForm({ label: '', target_amount_fiat: '', currency: currencies[0]?.code || 'EUR', miner_offer_id: '', is_active: true, sort_order: 0 }); setTargetModalOpen(false); - await loadBootstrap(projectKey); + await reloadBootstrapAfterMutation('Ziel gespeichert.'); } catch (err) { setError(err.message); } finally { @@ -1669,7 +1731,7 @@ method: 'DELETE', }); setMessage('Ziel geloescht.'); - await loadBootstrap(projectKey); + await reloadBootstrapAfterMutation('Ziel geloescht.'); } catch (err) { setError(err.message); } finally { @@ -1691,7 +1753,7 @@ body: JSON.stringify({ auto_renew: !row.auto_renew }), }); setMessage(`Automatische Verlängerung ${row.auto_renew ? 'deaktiviert' : 'aktiviert'}.`); - await loadBootstrap(projectKey); + await reloadBootstrapAfterMutation(`Automatische Verlaengerung ${row.auto_renew ? 'deaktiviert' : 'aktiviert'}.`); } catch (err) { setError(err.message); } finally { @@ -1726,7 +1788,7 @@ is_active: true, }); setCostPlanModalOpen(false); - await loadBootstrap(projectKey); + await reloadBootstrapAfterMutation('Miner gespeichert.'); } catch (err) { setError(err.message); } finally { @@ -1738,16 +1800,19 @@ event.preventDefault(); setSaving(true); setError(''); + setMessage(''); try { - await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/payouts`, { + const savedPayout = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/payouts`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payoutForm), + timeoutMs: 8000, }); + applySavedPayout(savedPayout); + invalidateProjectBootstrapCache(projectKey); setMessage('Auszahlung gespeichert.'); setPayoutForm({ payout_at: '', coins_amount: '', payout_currency: currentSettings.crypto_currency || 'DOGE', note: '' }); setPayoutModalOpen(false); - await loadBootstrap(projectKey); } catch (err) { setError(err.message); } finally { @@ -2203,6 +2268,16 @@ value: latest ? fmtNumber(latest.doge_per_day_interval, 4) : 'n/a', sub: payload?.summary?.current_hashrate_mh ? `Hashrate ${fmtNumber(payload.summary.current_hashrate_mh, 4)} MH/s` : (latest ? `Trend ${latest.trend_label}` : ''), }), + h(StatCard, { + key: 'perday-since-payout', + label: `${currentCoinCurrency} pro Tag seit letzter Auszahlung`, + value: latest && latest.doge_per_day_since_last_payout !== null && latest.doge_per_day_since_last_payout !== undefined + ? fmtNumber(latest.doge_per_day_since_last_payout, 4) + : 'n/a', + sub: latest && latest.last_payout_at + ? `Seit ${fmtDate(latest.last_payout_at)} · ${fmtNumber(latest.coins_since_last_payout, 6)} ${currentCoinCurrency}` + : 'Noch keine Auszahlung vor dem letzten Upload', + }), h(StatCard, { key: 'value', label: 'Aktueller Gegenwert', @@ -2273,7 +2348,7 @@ panel('Mining-History', 'Die letzten 10 Mining-Uploads inkl. Performance-Werten und OCR-Metadaten.', h('div', { className: 'mc-table-shell' }, [ h('table', { key: 'table', className: 'mc-table' }, [ h('thead', { key: 'thead' }, h('tr', null, [ - 'Zeit', 'Coins', 'Kurs', 'Quelle', perDayLabel, 'Trend', 'Notiz', 'Aktion' + 'Zeit', 'Coins', 'Kurs', 'Quelle', perDayLabel, 'Seit Auszahlung/Tag', 'Trend', 'Notiz', 'Aktion' ].map((label) => h('th', { key: label }, label)))), h('tbody', { key: 'tbody' }, measurements.slice(-10).reverse().map((row) => h('tr', { key: row.id }, [ @@ -2282,6 +2357,9 @@ h('td', { key: 'price' }, row.price_per_coin ? `${fmtNumber(row.price_per_coin, 6)} ${row.price_currency}` : 'n/a'), h('td', { key: 'source' }, row.source), h('td', { key: 'rate' }, fmtNumber(row.doge_per_day_interval, 4)), + h('td', { key: 'rate-payout' }, row.doge_per_day_since_last_payout !== null && row.doge_per_day_since_last_payout !== undefined + ? `${fmtNumber(row.doge_per_day_since_last_payout, 4)}${row.last_payout_at ? ` seit ${fmtDate(row.last_payout_at)}` : ''}` + : 'n/a'), h('td', { key: 'trend' }, row.trend_label), h('td', { key: 'note' }, row.note || row.ocr_flags.join(', ') || '—'), h('td', { key: 'action' }, h('button', { @@ -3185,47 +3263,5 @@ } } - ReactDOM.createRoot(root).render(h(App)); - return true; - } - - window.initMiningCheckerApp = function initMiningCheckerApp(root, options) { - if (!(root instanceof HTMLElement)) { - return false; - } - - const config = options && typeof options === 'object' ? options : {}; - - if (config.apiBase) { - root.dataset.apiBase = String(config.apiBase); - } - if (config.defaultProjectKey) { - root.dataset.defaultProjectKey = String(config.defaultProjectKey); - } - if (config.activeView) { - root.dataset.activeView = String(config.activeView); - } - if (config.sections) { - root.dataset.sectionsJson = JSON.stringify(config.sections); - } - if (config.moduleDebugEnabled !== undefined) { - root.dataset.moduleDebugEnabled = config.moduleDebugEnabled ? '1' : '0'; - } - - if (root.dataset.miningCheckerMounted === '1') { - return true; - } - - const mounted = boot(root); - if (mounted) { - root.dataset.miningCheckerMounted = '1'; - } - - return mounted; - }; - - const root = document.getElementById('mining-checker-app'); - if (root) { - window.initMiningCheckerApp(root); - } + ReactDOM.createRoot(root).render(h(App)); })(); diff --git a/modules/mining-checker/bootstrap.php b/modules/mining-checker/bootstrap.php new file mode 100644 index 00000000..94a99fa0 --- /dev/null +++ b/modules/mining-checker/bootstrap.php @@ -0,0 +1,17 @@ + 'doge-main', + 'MINING_CHECKER_OCR_PROVIDERS' => 'ocrspace,tesseract', + 'MINING_CHECKER_OCR_SPACE_URL' => 'https://api.ocr.space/parse/image', + 'MINING_CHECKER_OCR_SPACE_API_KEY' => 'K83150278888957', + 'MINING_CHECKER_OCR_SPACE_LANGUAGE' => 'eng', + 'MINING_CHECKER_OCR_SPACE_ENGINE' => '2', + 'MINING_CHECKER_OCR_SPACE_SCALE' => 'true', + 'MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION' => 'true', + 'MINING_CHECKER_OCR_SPACE_IS_TABLE' => 'false', + 'MINING_CHECKER_OCR_SPACE_TIMEOUT' => '25', + 'MINING_CHECKER_TESSERACT_BIN' => '/usr/bin/tesseract', + 'MINING_CHECKER_TESSERACT_LANG' => 'eng', + 'MINING_CHECKER_FX_PROVIDER' => 'currencyapi', + 'MINING_CHECKER_FX_URL' => 'https://currencyapi.net', + 'MINING_CHECKER_FX_CURRENCIES_URL' => 'https://currencyapi.net', + 'MINING_CHECKER_FX_API_KEY' => 'eb18ce459ffb0461c59229b478f2e00388d1', + 'MINING_CHECKER_FX_TIMEOUT' => '10', + 'MINING_CHECKER_FX_CACHE_TTL' => '21600', + 'MINING_CHECKER_FX_AUTO_FETCH_ON_MISS' => 'false', +]; diff --git a/modules/mining-checker/config/module.php b/modules/mining-checker/config/module.php new file mode 100644 index 00000000..b04af692 --- /dev/null +++ b/modules/mining-checker/config/module.php @@ -0,0 +1,46 @@ + getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main', + 'use_project_database' => true, + 'table_prefix' => 'miningcheck_', + 'uploads_dir' => dirname(__DIR__, 3) . '/data/mining-checker/uploads', + 'uploads_public_prefix' => '/data/mining-checker/uploads', + 'ocr' => [ + 'providers' => array_values(array_filter(array_map( + static fn (string $provider): string => trim(strtolower($provider)), + explode(',', getenv('MINING_CHECKER_OCR_PROVIDERS') ?: 'ocrspace,tesseract') + ))), + 'ocrspace' => [ + 'url' => getenv('MINING_CHECKER_OCR_SPACE_URL') ?: 'https://api.ocr.space/parse/image', + 'api_key' => getenv('MINING_CHECKER_OCR_SPACE_API_KEY') ?: 'K83150278888957', + 'language' => getenv('MINING_CHECKER_OCR_SPACE_LANGUAGE') ?: 'eng', + 'engine' => (int) (getenv('MINING_CHECKER_OCR_SPACE_ENGINE') ?: 2), + 'scale' => getenv('MINING_CHECKER_OCR_SPACE_SCALE') ?: 'true', + 'detect_orientation' => getenv('MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION') ?: 'true', + 'is_table' => getenv('MINING_CHECKER_OCR_SPACE_IS_TABLE') ?: 'false', + 'timeout' => (int) (getenv('MINING_CHECKER_OCR_SPACE_TIMEOUT') ?: 25), + ], + 'tesseract' => [ + 'binary' => getenv('MINING_CHECKER_TESSERACT_BIN') ?: 'tesseract', + 'language' => getenv('MINING_CHECKER_TESSERACT_LANG') ?: 'eng', + ], + ], + 'fx' => [ + 'provider' => getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi', + 'url' => getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net', + 'currencies_url' => getenv('MINING_CHECKER_FX_CURRENCIES_URL') ?: 'https://currencyapi.net', + 'api_key' => getenv('MINING_CHECKER_FX_API_KEY') ?: 'eb18ce459ffb0461c59229b478f2e00388d1', + 'timeout' => (int) (getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10), + 'cache_ttl' => (int) (getenv('MINING_CHECKER_FX_CACHE_TTL') ?: 21600), + 'auto_fetch_on_miss' => filter_var( + getenv('MINING_CHECKER_FX_AUTO_FETCH_ON_MISS') ?: 'false', + FILTER_VALIDATE_BOOL + ), + ], + 'debug' => [ + 'enabled' => filter_var(getenv('MINING_CHECKER_DEBUG') ?: 'false', FILTER_VALIDATE_BOOL), + 'dir' => getenv('MINING_CHECKER_DEBUG_DIR') ?: dirname(__DIR__, 3) . '/data/mining-checker/debug', + ], +]; diff --git a/modules/mining-checker/design.json b/modules/mining-checker/design.json new file mode 100644 index 00000000..a0335736 --- /dev/null +++ b/modules/mining-checker/design.json @@ -0,0 +1,17 @@ +{ + "eyebrow": "Modul", + "title": "Mining-Checker", + "description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.", + "actions": [ + { "label": "Nexus Übersicht", "href": "/modules" }, + { "label": "Setup", "href": "/modules/setup/mining-checker", "variant": "secondary" } + ], + "sections": [ + { "key": "overview", "label": "Übersicht" }, + { "key": "upload", "label": "Upload" }, + { "key": "measurements", "label": "Mining-History" }, + { "key": "wallet", "label": "Wallet" }, + { "key": "mining", "label": "Miner-Daten" }, + { "key": "dashboards", "label": "Dashboards" } + ] +} diff --git a/modules/mining-checker/desktop.php b/modules/mining-checker/desktop.php new file mode 100644 index 00000000..9c71a63a --- /dev/null +++ b/modules/mining-checker/desktop.php @@ -0,0 +1,57 @@ + is_array($section) +)); + +return [ + 'app_id' => 'mining-checker', + 'title' => (string) ($manifest['title'] ?? 'Mining-Checker'), + 'icon' => 'MC', + 'entry_route' => '/apps/mining-checker', + 'window_mode' => 'window', + 'content_mode' => 'native-module', + 'default_width' => 1180, + 'default_height' => 760, + 'supports_widget' => false, + 'supports_tray' => true, + 'module_name' => 'finance', + 'summary' => (string) ($manifest['description'] ?? 'Vollstaendiger Rechner fuer Mining-Profitabilitaet, Stromkosten, ROI und Snapshot-Historie.'), + 'native_module' => [ + 'initializer' => 'initMiningCheckerApp', + 'options' => [ + 'apiBase' => '/api/mining-checker/index.php/v1', + 'defaultProjectKey' => getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main', + 'activeView' => 'overview', + 'sections' => $sections, + 'moduleDebugEnabled' => false, + ], + 'assets' => [ + 'styles' => [ + ['href' => '/module-assets/index.php?module=mining-checker&path=assets/css/app.css'], + ], + 'scripts' => [ + ['src' => 'https://unpkg.com/react@18/umd/react.production.min.js', 'crossorigin' => true], + ['src' => 'https://unpkg.com/react-dom@18/umd/react-dom.production.min.js', 'crossorigin' => true], + ['src' => '/module-assets/index.php?module=mining-checker&path=assets/js/app.js'], + ], + ], + ], +]; diff --git a/modules/mining-checker/docs/README.md b/modules/mining-checker/docs/README.md new file mode 100644 index 00000000..89af70a6 --- /dev/null +++ b/modules/mining-checker/docs/README.md @@ -0,0 +1,114 @@ +# Mining-Checker Modul + +## Zweck + +Das Modul erfasst DOGE-Mining-Messpunkte, analysiert OCR-Vorschlaege aus Screenshots, speichert Messreihen projektbezogen und berechnet Performance-, Kurs- und Zielmetriken. + +## Ordnerstruktur + +```text +modules/mining-checker/ +|-- api/ +|-- assets/ +| |-- css/ +| `-- js/ +|-- config/ +|-- docs/ +|-- pages/ +|-- partials/ +|-- sql/ +| `-- migrations/ +|-- src/ +| |-- Api/ +| |-- Domain/ +| |-- Infrastructure/ +| `-- Support/ +|-- storage/uploads/ +|-- bootstrap.php +`-- module.json +``` + +## API-Endpunkte + +- `GET /api/mining-checker/v1/health` +- `GET /api/mining-checker/v1/projects/{projectKey}/bootstrap` +- `GET /api/mining-checker/v1/projects/{projectKey}/measurements` +- `POST /api/mining-checker/v1/projects/{projectKey}/measurements` +- `POST /api/mining-checker/v1/projects/{projectKey}/ocr-preview` +- `GET /api/mining-checker/v1/projects/{projectKey}/settings` +- `PUT /api/mining-checker/v1/projects/{projectKey}/settings` +- `GET /api/mining-checker/v1/projects/{projectKey}/targets` +- `POST /api/mining-checker/v1/projects/{projectKey}/targets` +- `PATCH /api/mining-checker/v1/projects/{projectKey}/targets/{targetId}` +- `GET /api/mining-checker/v1/projects/{projectKey}/dashboards` +- `POST /api/mining-checker/v1/projects/{projectKey}/dashboards` +- `GET /api/mining-checker/v1/projects/{projectKey}/dashboard-data` +- `POST /api/mining-checker/v1/projects/{projectKey}/seed-import` +- `GET /api/mining-checker/v1/projects/{projectKey}/schema-status` +- `POST /api/mining-checker/v1/projects/{projectKey}/initialize` +- `POST /api/mining-checker/v1/projects/{projectKey}/upgrade` +- `GET /api/mining-checker/v1/projects/{projectKey}/connection-test` +- `GET /api/mining-checker/v1/projects/{projectKey}/fx-history` +- `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate` + +## Integration + +1. SQL aus dem passenden Dialekt-Schema ausfuehren: + - MySQL/MariaDB: `sql/schema.mysql.sql` + - PostgreSQL: `sql/schema.pgsql.sql` + - `sql/schema.sql` bleibt der Rueckfall fuer bestehende Setups +2. Das Modul nutzt bewusst dieselbe Projekt-Datenbank wie die Anwendung und legt seine Tabellen mit dem Praefix `miningcheck_` an. +3. Modulroute ueber `/module/mining-checker` aufrufen. +4. REST-API wird ueber `/api/mining-checker/...` vom Hauptprojekt geroutet. + +Hinweis: +Wenn beim ersten API-Zugriff noch keine `miningcheck_*` Tabellen vorhanden sind, importiert das Modul automatisch das zum aktiven PDO-Treiber passende Schema. +Seed-Daten werden dabei nicht automatisch eingespielt. +Fuer eine manuelle Initialisierung, ein inkrementelles Upgrade oder einen Reset gibt es zusaetzlich `schema-status`, `upgrade` und `initialize`. Mit `{ "drop_existing": true }` werden vorhandene `miningcheck_*` Tabellen inklusive Daten geloescht und das Schema neu angelegt. + +## OCR-Hinweis + +Das Modul unterstuetzt einen OCR-Provider-Stack. Standardmaessig wird zuerst `ocr.space` verwendet und danach optional auf lokales `tesseract` zurueckgefallen. + +Empfohlene Umgebungsvariablen: + +- `MINING_CHECKER_OCR_PROVIDERS=ocrspace,tesseract` +- `MINING_CHECKER_OCR_SPACE_URL=https://api.ocr.space/parse/image` +- `MINING_CHECKER_OCR_SPACE_API_KEY=...` +- `MINING_CHECKER_OCR_SPACE_LANGUAGE=eng` +- `MINING_CHECKER_OCR_SPACE_ENGINE=2` +- `MINING_CHECKER_OCR_SPACE_SCALE=true` +- `MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION=true` +- `MINING_CHECKER_OCR_SPACE_IS_TABLE=false` +- `MINING_CHECKER_OCR_SPACE_TIMEOUT=25` +- `MINING_CHECKER_TESSERACT_BIN=/usr/bin/tesseract` +- `MINING_CHECKER_TESSERACT_LANG=eng` + +Laut OCR.space-Doku wird `POST https://api.ocr.space/parse/image` mit `file`, Header-`apikey`, optional `language`, `scale`, `detectOrientation`, `isTable` und `OCREngine` verwendet. Der Modulparser wertet die OCR.space-Felder `ParsedResults`, `ParsedText`, `IsErroredOnProcessing`, `ErrorMessage` und `OCRExitCode` aus. Quellen: https://ocr.space/ocrapi + +## Wechselkurse und Waehrungen + +Der Mining-Checker speichert keine eigenen FX-Snapshots mehr, sondern referenziert die `fetch_id` aus `fx-rates`. +Auch der Waehrungskatalog und die bevorzugten Waehrungen kommen ausschliesslich aus `fx-rates`. Der Mining-Checker fuehrt keine eigene Waehrungstabelle und keine eigene Alias-Verwaltung mehr im Laufzeitpfad. +- `MINING_CHECKER_FX_AUTO_FETCH_ON_MISS=false` + +Optionaler JSON-Body: + +- `base`: Standard `EUR` +- `symbols`: wird aktuell ignoriert; der Mining-Checker speichert immer den kompletten Waehrungssatz des Fetches + +Beispiel: + +```json +{ + "base": "EUR" +} +``` + +`currencyapi.net` wird ueber das Modul `fx-rates` abgefragt. Aus dem Response werden `base`, `rates` und `updated` uebernommen; `valid` muss `true` sein. Die eigentlichen Fetches und Raten liegen im Modul `fx-rates`. + +Pro Abruf entsteht genau ein Datensatz in `fx-rates` mit Basiswaehrung, Provider und Stichtag. Neue Mining-Messpunkte pruefen beim Speichern, ob ein neuer FX-Fetch noetig ist; falls nicht, wird die letzte passende `fetch_id` wiederverwendet. + +Falls noch historische Mining-Checker-Fetches in `miningcheck_fx_fetches` und `miningcheck_fx_rates` liegen, kann `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate` diese nach `fx-rates` ueberfuehren. Danach werden bestehende Messpunkte soweit moeglich auf die passende `fx_fetch_id` aktualisiert. + +Fuer Auswertungen, Berichte und Listen speichert der Mining-Checker pro Messpunkt die damals passende `fx_fetch_id`. Historische Umrechnungen laufen damit gegen genau den zugeordneten `fx-rates`-Snapshot. diff --git a/modules/mining-checker/module.json b/modules/mining-checker/module.json new file mode 100644 index 00000000..05fd56d5 --- /dev/null +++ b/modules/mining-checker/module.json @@ -0,0 +1,30 @@ +{ + "name": "Mining-Checker", + "title": "Mining-Checker", + "version": "0.3.0", + "description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.", + "enabled_by_default": true, + "setup": { + "sections": { + "database": true + }, + "fields": [ + { "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." }, + { "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql oder mysql" }, + { "name": "db.host", "label": "DB Host", "type": "text", "required": false }, + { "name": "db.port", "label": "DB Port", "type": "number", "required": false }, + { "name": "db.dbname", "label": "DB Name", "type": "text", "required": false }, + { "name": "db.schema", "label": "DB Schema", "type": "text", "required": false }, + { "name": "db.user", "label": "DB User", "type": "text", "required": false }, + { "name": "db.password", "label": "DB Passwort", "type": "password", "required": false }, + { "name": "baseline_measured_at", "label": "Baseline Zeitpunkt", "type": "datetime-local", "required": false, "help": "Referenzzeitpunkt fuer die erste Baseline-Messung." }, + { "name": "baseline_coins_total", "label": "Baseline Coins", "type": "number", "required": false, "help": "Coin-Bestand zum Baseline-Zeitpunkt." }, + { "name": "report_currency", "label": "Standard-Berichtswaehrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Kennzahlen und Berichte, z.B. EUR." } + ] + }, + "auth": { + "required": true, + "users": [], + "groups": [] + } +} diff --git a/modules/mining-checker/pages/index.php b/modules/mining-checker/pages/index.php new file mode 100644 index 00000000..3682d2b4 --- /dev/null +++ b/modules/mining-checker/pages/index.php @@ -0,0 +1,97 @@ + is_array($section) ? trim((string) ($section['key'] ?? '')) : '', + $sections +))); + +if ($sectionKeys === []) { + $sectionKeys = ['overview']; +} + +if (!in_array($activeView, $sectionKeys, true)) { + $activeView = $sectionKeys[0]; +} + +$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +?> + + + + + Mining-Checker + + + + +
+
+
+ + + + + + diff --git a/modules/mining-checker/partials/.gitkeep b/modules/mining-checker/partials/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/modules/mining-checker/sql/migrations/001_init.sql b/modules/mining-checker/sql/migrations/001_init.sql new file mode 100644 index 00000000..5a1fba97 --- /dev/null +++ b/modules/mining-checker/sql/migrations/001_init.sql @@ -0,0 +1,124 @@ +CREATE TABLE IF NOT EXISTS miningcheck_projects ( + project_key VARCHAR(64) NOT NULL PRIMARY KEY, + project_name VARCHAR(160) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS miningcheck_settings ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + baseline_measured_at DATETIME NOT NULL, + baseline_coins_total DECIMAL(20,6) NOT NULL, + daily_cost_amount DECIMAL(20,10) NOT NULL, + daily_cost_currency VARCHAR(10) NOT NULL, + report_currency VARCHAR(10) NULL, + crypto_currency VARCHAR(10) NULL, + display_timezone VARCHAR(64) NULL, + fx_max_age_hours DECIMAL(10,2) NULL, + module_theme_mode VARCHAR(16) NULL, + module_theme_accent VARCHAR(16) NULL, + preferred_currencies JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_settings_project UNIQUE (project_key) +); + +CREATE TABLE IF NOT EXISTS miningcheck_cost_plans ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + label VARCHAR(120) NOT NULL, + starts_at DATETIME NOT NULL, + runtime_months INT NOT NULL, + auto_renew TINYINT(1) NOT NULL DEFAULT 0, + base_price_amount DECIMAL(20,8) NULL, + payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat', + total_cost_amount DECIMAL(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + note TEXT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE INDEX idx_miningcheck_cost_plans_project_start + ON miningcheck_cost_plans(project_key, starts_at); + +CREATE TABLE IF NOT EXISTS miningcheck_measurements ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + measured_at DATETIME NOT NULL, + coins_total DECIMAL(20,6) NOT NULL, + coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE', + price_per_coin DECIMAL(20,8) NULL, + price_currency VARCHAR(10) NULL, + note TEXT NULL, + source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL, + image_path VARCHAR(255) NULL, + ocr_raw_text MEDIUMTEXT NULL, + ocr_confidence DECIMAL(6,4) NULL, + ocr_flags JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total) +); + +CREATE INDEX idx_miningcheck_measurements_project_measured_at + ON miningcheck_measurements(project_key, measured_at); + +CREATE TABLE IF NOT EXISTS miningcheck_targets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + label VARCHAR(120) NOT NULL, + target_amount_fiat DECIMAL(20,2) NOT NULL, + currency VARCHAR(10) NOT NULL, + miner_offer_id BIGINT UNSIGNED NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL, + CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label) +); + +CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + measured_at TIMESTAMP NOT NULL, + total_value_amount DECIMAL(20,8) NULL, + total_value_currency VARCHAR(10) NULL, + wallet_balance DECIMAL(28,10) NULL, + wallet_currency VARCHAR(10) NOT NULL, + balances_json JSON NULL, + note TEXT NULL, + source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL, + image_path VARCHAR(255) NULL, + ocr_raw_text MEDIUMTEXT NULL, + ocr_confidence DECIMAL(6,4) NULL, + ocr_flags JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at) +); + +CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + name VARCHAR(160) NOT NULL, + chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL, + x_field VARCHAR(64) NOT NULL, + y_field VARCHAR(64) NOT NULL, + aggregation VARCHAR(32) NOT NULL DEFAULT 'none', + filters_json JSON NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name) +); diff --git a/modules/mining-checker/sql/migrations/002_seed_doge_main.sql b/modules/mining-checker/sql/migrations/002_seed_doge_main.sql new file mode 100644 index 00000000..71dc8fad --- /dev/null +++ b/modules/mining-checker/sql/migrations/002_seed_doge_main.sql @@ -0,0 +1,75 @@ +INSERT INTO miningcheck_projects (project_key, project_name) +VALUES ('doge-main', 'DOGE Mining Main') +ON DUPLICATE KEY UPDATE project_name = VALUES(project_name); + +INSERT INTO miningcheck_settings ( + project_key, + baseline_measured_at, + baseline_coins_total, + daily_cost_amount, + daily_cost_currency, + preferred_currencies +) +VALUES ( + 'doge-main', + '2026-03-16 01:32:00', + 27.617864, + 0.3123287671, + 'EUR', + '["DOGE","USD","EUR"]' +) +ON DUPLICATE KEY UPDATE + baseline_measured_at = VALUES(baseline_measured_at), + baseline_coins_total = VALUES(baseline_coins_total), + daily_cost_amount = VALUES(daily_cost_amount), + daily_cost_currency = VALUES(daily_cost_currency), + preferred_currencies = VALUES(preferred_currencies); + +INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order) +VALUES + ('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10), + ('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20) +ON DUPLICATE KEY UPDATE + target_amount_fiat = VALUES(target_amount_fiat), + currency = VALUES(currency), + is_active = VALUES(is_active), + sort_order = VALUES(sort_order); + +INSERT INTO miningcheck_dashboard_definitions ( + project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active +) +VALUES + ('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1), + ('doge-main', 'Performance-Verlauf', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1), + ('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currency":"EUR"}', 1) +ON DUPLICATE KEY UPDATE + chart_type = VALUES(chart_type), + x_field = VALUES(x_field), + y_field = VALUES(y_field), + aggregation = VALUES(aggregation), + filters_json = VALUES(filters_json), + is_active = VALUES(is_active); + +INSERT INTO miningcheck_measurements ( + project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags +) +VALUES + ('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')), + ('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')), + ('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')), + ('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')) +ON DUPLICATE KEY UPDATE + price_per_coin = VALUES(price_per_coin), + price_currency = VALUES(price_currency), + note = VALUES(note), + source = VALUES(source); diff --git a/modules/mining-checker/sql/migrations/003_timezone_utc.sql b/modules/mining-checker/sql/migrations/003_timezone_utc.sql new file mode 100644 index 00000000..e31b622f --- /dev/null +++ b/modules/mining-checker/sql/migrations/003_timezone_utc.sql @@ -0,0 +1,34 @@ +BEGIN; + +ALTER TABLE miningcheck_settings +ADD COLUMN IF NOT EXISTS display_timezone VARCHAR(64); + +UPDATE miningcheck_settings +SET display_timezone = 'Europe/Berlin' +WHERE display_timezone IS NULL OR BTRIM(display_timezone) = ''; + +UPDATE miningcheck_settings +SET baseline_measured_at = ((baseline_measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC') +WHERE baseline_measured_at IS NOT NULL; + +UPDATE miningcheck_cost_plans +SET starts_at = ((starts_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC') +WHERE starts_at IS NOT NULL; + +UPDATE miningcheck_measurements +SET measured_at = ((measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC') +WHERE measured_at IS NOT NULL; + +UPDATE miningcheck_payouts +SET payout_at = ((payout_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC') +WHERE payout_at IS NOT NULL; + +UPDATE miningcheck_purchased_miners +SET purchased_at = ((purchased_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC') +WHERE purchased_at IS NOT NULL; + +UPDATE miningcheck_fx_fetches +SET fetched_at = ((fetched_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC') +WHERE fetched_at IS NOT NULL; + +COMMIT; diff --git a/modules/mining-checker/sql/migrations/004_merge_cost_plans_into_purchased_miners.sql b/modules/mining-checker/sql/migrations/004_merge_cost_plans_into_purchased_miners.sql new file mode 100644 index 00000000..bc6e4bed --- /dev/null +++ b/modules/mining-checker/sql/migrations/004_merge_cost_plans_into_purchased_miners.sql @@ -0,0 +1,72 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS miningcheck_cost_plans_legacy AS +SELECT * +FROM miningcheck_cost_plans +WHERE 1 = 0; + +INSERT INTO miningcheck_cost_plans_legacy +SELECT cp.* +FROM miningcheck_cost_plans cp +LEFT JOIN miningcheck_cost_plans_legacy legacy + ON legacy.id = cp.id +WHERE legacy.id IS NULL; + +INSERT INTO miningcheck_purchased_miners ( + project_key, + miner_offer_id, + purchased_at, + label, + runtime_months, + mining_speed_value, + mining_speed_unit, + bonus_speed_value, + bonus_speed_unit, + total_cost_amount, + currency, + usd_reference_amount, + reference_price_amount, + reference_price_currency, + auto_renew, + note, + is_active +) +SELECT + cp.project_key, + NULL AS miner_offer_id, + cp.starts_at AS purchased_at, + cp.label, + cp.runtime_months, + cp.mining_speed_value, + cp.mining_speed_unit, + cp.bonus_speed_value, + cp.bonus_speed_unit, + cp.total_cost_amount, + cp.currency, + CASE + WHEN COALESCE(s.report_currency, 'EUR') = 'USD' THEN cp.base_price_amount + ELSE NULL + END AS usd_reference_amount, + cp.base_price_amount AS reference_price_amount, + COALESCE(s.report_currency, 'EUR') AS reference_price_currency, + cp.auto_renew, + CASE + WHEN cp.note IS NULL OR BTRIM(cp.note) = '' THEN 'Migriert aus miningcheck_cost_plans' + ELSE cp.note || ' | Migriert aus miningcheck_cost_plans' + END AS note, + cp.is_active +FROM miningcheck_cost_plans cp +LEFT JOIN miningcheck_settings s + ON s.project_key = cp.project_key +LEFT JOIN miningcheck_purchased_miners pm + ON pm.project_key = cp.project_key + AND pm.miner_offer_id IS NULL + AND pm.purchased_at = cp.starts_at + AND pm.label = cp.label + AND pm.total_cost_amount = cp.total_cost_amount + AND pm.currency = cp.currency +WHERE pm.id IS NULL; + +DELETE FROM miningcheck_cost_plans; + +COMMIT; diff --git a/modules/mining-checker/sql/migrations/005_module_theme_settings.sql b/modules/mining-checker/sql/migrations/005_module_theme_settings.sql new file mode 100644 index 00000000..1caf2fa0 --- /dev/null +++ b/modules/mining-checker/sql/migrations/005_module_theme_settings.sql @@ -0,0 +1,15 @@ +BEGIN; + +ALTER TABLE miningcheck_settings +ADD COLUMN IF NOT EXISTS module_theme_mode VARCHAR(16), +ADD COLUMN IF NOT EXISTS module_theme_accent VARCHAR(16); + +UPDATE miningcheck_settings +SET module_theme_mode = 'inherit' +WHERE module_theme_mode IS NULL OR BTRIM(module_theme_mode) = ''; + +UPDATE miningcheck_settings +SET module_theme_accent = 'teal' +WHERE module_theme_accent IS NULL OR BTRIM(module_theme_accent) = ''; + +COMMIT; diff --git a/modules/mining-checker/sql/migrations/006_user_scope_owner_sub.sql b/modules/mining-checker/sql/migrations/006_user_scope_owner_sub.sql new file mode 100644 index 00000000..9bc3a1ea --- /dev/null +++ b/modules/mining-checker/sql/migrations/006_user_scope_owner_sub.sql @@ -0,0 +1,182 @@ +-- Bestehende benutzerspezifische Mining-Daten werden diesem Keycloak-Sub zugeordnet: +-- adea1766-5d1c-4c2e-98bd-5239861f745f +-- Die Keycloak-Sub ist stabiler als preferred_username und wird fuer alle benutzerspezifischen Mining-Daten genutzt. + +BEGIN; + +ALTER TABLE miningcheck_settings +ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128); + +ALTER TABLE miningcheck_cost_plans +ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128); + +ALTER TABLE miningcheck_measurements +ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128); + +ALTER TABLE miningcheck_measurement_rates +ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128); + +ALTER TABLE miningcheck_payouts +ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128); + +ALTER TABLE miningcheck_targets +ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128); + +ALTER TABLE miningcheck_dashboard_definitions +ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128); + +ALTER TABLE miningcheck_purchased_miners +ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128); + +UPDATE miningcheck_settings +SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f' +WHERE owner_sub IS NULL OR BTRIM(owner_sub) = ''; + +UPDATE miningcheck_cost_plans +SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f' +WHERE owner_sub IS NULL OR BTRIM(owner_sub) = ''; + +UPDATE miningcheck_measurements +SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f' +WHERE owner_sub IS NULL OR BTRIM(owner_sub) = ''; + +UPDATE miningcheck_measurement_rates mr +SET owner_sub = m.owner_sub +FROM miningcheck_measurements m +WHERE mr.measurement_id = m.id + AND (mr.owner_sub IS NULL OR BTRIM(mr.owner_sub) = ''); + +UPDATE miningcheck_measurement_rates +SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f' +WHERE owner_sub IS NULL OR BTRIM(owner_sub) = ''; + +UPDATE miningcheck_payouts +SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f' +WHERE owner_sub IS NULL OR BTRIM(owner_sub) = ''; + +UPDATE miningcheck_targets +SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f' +WHERE owner_sub IS NULL OR BTRIM(owner_sub) = ''; + +UPDATE miningcheck_dashboard_definitions +SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f' +WHERE owner_sub IS NULL OR BTRIM(owner_sub) = ''; + +UPDATE miningcheck_purchased_miners +SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f' +WHERE owner_sub IS NULL OR BTRIM(owner_sub) = ''; + +ALTER TABLE miningcheck_settings +ALTER COLUMN owner_sub SET NOT NULL; + +ALTER TABLE miningcheck_cost_plans +ALTER COLUMN owner_sub SET NOT NULL; + +ALTER TABLE miningcheck_measurements +ALTER COLUMN owner_sub SET NOT NULL; + +ALTER TABLE miningcheck_measurement_rates +ALTER COLUMN owner_sub SET NOT NULL; + +ALTER TABLE miningcheck_payouts +ALTER COLUMN owner_sub SET NOT NULL; + +ALTER TABLE miningcheck_targets +ALTER COLUMN owner_sub SET NOT NULL; + +ALTER TABLE miningcheck_dashboard_definitions +ALTER COLUMN owner_sub SET NOT NULL; + +ALTER TABLE miningcheck_purchased_miners +ALTER COLUMN owner_sub SET NOT NULL; + +ALTER TABLE miningcheck_settings +DROP CONSTRAINT IF EXISTS miningcheck_settings_project_key_key; + +ALTER TABLE miningcheck_measurements +DROP CONSTRAINT IF EXISTS uq_mining_measurements_unique; + +ALTER TABLE miningcheck_targets +DROP CONSTRAINT IF EXISTS uq_mining_targets_project_label; + +ALTER TABLE miningcheck_dashboard_definitions +DROP CONSTRAINT IF EXISTS uq_mining_dashboards_project_name; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE table_schema = current_schema() + AND table_name = 'miningcheck_settings' + AND constraint_name = 'uq_mining_settings_project_owner' + ) THEN + ALTER TABLE miningcheck_settings + ADD CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE table_schema = current_schema() + AND table_name = 'miningcheck_measurements' + AND constraint_name = 'uq_mining_measurements_unique' + ) THEN + ALTER TABLE miningcheck_measurements + ADD CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE table_schema = current_schema() + AND table_name = 'miningcheck_targets' + AND constraint_name = 'uq_mining_targets_project_label' + ) THEN + ALTER TABLE miningcheck_targets + ADD CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE table_schema = current_schema() + AND table_name = 'miningcheck_dashboard_definitions' + AND constraint_name = 'uq_mining_dashboards_project_name' + ) THEN + ALTER TABLE miningcheck_dashboard_definitions + ADD CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_owner_start + ON miningcheck_cost_plans(project_key, owner_sub, starts_at); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_owner_measured_at + ON miningcheck_measurements(project_key, owner_sub, measured_at); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_owner_measurement + ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_owner_payout_at + ON miningcheck_payouts(project_key, owner_sub, payout_at); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_targets_project_owner + ON miningcheck_targets(project_key, owner_sub, sort_order, id); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_dashboards_project_owner + ON miningcheck_dashboard_definitions(project_key, owner_sub, id); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_purchased_miners_project_owner_purchased_at + ON miningcheck_purchased_miners(project_key, owner_sub, purchased_at); + +COMMIT; diff --git a/modules/mining-checker/sql/schema.mysql.sql b/modules/mining-checker/sql/schema.mysql.sql new file mode 100644 index 00000000..7f83b187 --- /dev/null +++ b/modules/mining-checker/sql/schema.mysql.sql @@ -0,0 +1,223 @@ +CREATE TABLE IF NOT EXISTS miningcheck_projects ( + project_key VARCHAR(64) NOT NULL PRIMARY KEY, + project_name VARCHAR(160) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS miningcheck_settings ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + baseline_measured_at DATETIME NOT NULL, + baseline_coins_total DECIMAL(20,6) NOT NULL, + daily_cost_amount DECIMAL(20,10) NOT NULL, + daily_cost_currency VARCHAR(10) NOT NULL, + report_currency VARCHAR(10) NULL, + crypto_currency VARCHAR(10) NULL, + display_timezone VARCHAR(64) NULL, + fx_max_age_hours DECIMAL(10,2) NULL, + module_theme_mode VARCHAR(16) NULL, + module_theme_accent VARCHAR(16) NULL, + preferred_currencies JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_settings_project UNIQUE (project_key) +); + +CREATE TABLE IF NOT EXISTS miningcheck_cost_plans ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + label VARCHAR(120) NOT NULL, + starts_at DATETIME NOT NULL, + runtime_months INT NOT NULL, + mining_speed_value DECIMAL(20,4) NULL, + mining_speed_unit VARCHAR(8) NULL, + bonus_speed_value DECIMAL(20,4) NULL, + bonus_speed_unit VARCHAR(8) NULL, + auto_renew TINYINT(1) NOT NULL DEFAULT 0, + base_price_amount DECIMAL(20,8) NULL, + payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat', + total_cost_amount DECIMAL(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + note TEXT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE INDEX idx_miningcheck_cost_plans_project_start + ON miningcheck_cost_plans(project_key, starts_at); + +CREATE TABLE IF NOT EXISTS miningcheck_measurements ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + measured_at DATETIME NOT NULL, + coins_total DECIMAL(20,6) NOT NULL, + coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE', + price_per_coin DECIMAL(20,8) NULL, + price_currency VARCHAR(10) NULL, + fx_fetch_id BIGINT UNSIGNED NULL, + note TEXT NULL, + source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL, + image_path VARCHAR(255) NULL, + ocr_raw_text MEDIUMTEXT NULL, + ocr_confidence DECIMAL(6,4) NULL, + ocr_flags JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total) +); + +CREATE INDEX idx_miningcheck_measurements_project_measured_at + ON miningcheck_measurements(project_key, measured_at); + +CREATE INDEX idx_miningcheck_measurements_fx_fetch + ON miningcheck_measurements(fx_fetch_id); + +CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + measurement_id BIGINT UNSIGNED NOT NULL, + project_key VARCHAR(64) NOT NULL, + base_currency VARCHAR(10) NOT NULL, + quote_currency VARCHAR(10) NOT NULL, + rate DECIMAL(20,10) NOT NULL, + provider VARCHAR(32) NOT NULL DEFAULT 'derived', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE, + CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency), + KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id) +); + +CREATE TABLE IF NOT EXISTS miningcheck_payouts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + payout_at TIMESTAMP NOT NULL, + coins_amount DECIMAL(20,6) NOT NULL, + payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE', + note TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at) +); + +CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + measured_at TIMESTAMP NOT NULL, + total_value_amount DECIMAL(20,8) NULL, + total_value_currency VARCHAR(10) NULL, + wallet_balance DECIMAL(28,10) NULL, + wallet_currency VARCHAR(10) NOT NULL, + balances_json JSON NULL, + note TEXT NULL, + source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL, + image_path VARCHAR(255) NULL, + ocr_raw_text MEDIUMTEXT NULL, + ocr_confidence DECIMAL(6,4) NULL, + ocr_flags JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at) +); + +CREATE TABLE IF NOT EXISTS miningcheck_miner_offers ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + label VARCHAR(120) NOT NULL, + runtime_months INT NULL, + mining_speed_value DECIMAL(20,4) NULL, + mining_speed_unit VARCHAR(8) NULL, + bonus_speed_value DECIMAL(20,4) NULL, + bonus_speed_unit VARCHAR(8) NULL, + base_price_amount DECIMAL(20,8) NOT NULL, + base_price_currency VARCHAR(10) NOT NULL, + payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat', + auto_renew TINYINT(1) NOT NULL DEFAULT 0, + note TEXT, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS miningcheck_targets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + label VARCHAR(120) NOT NULL, + target_amount_fiat DECIMAL(20,2) NOT NULL, + currency VARCHAR(10) NOT NULL, + miner_offer_id BIGINT UNSIGNED NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL, + CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label) +); + +CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + name VARCHAR(160) NOT NULL, + chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL, + x_field VARCHAR(64) NOT NULL, + y_field VARCHAR(64) NOT NULL, + aggregation VARCHAR(32) NOT NULL DEFAULT 'none', + filters_json JSON NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name) +); + +CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + miner_offer_id BIGINT UNSIGNED NULL, + purchased_at TIMESTAMP NOT NULL, + label VARCHAR(120) NOT NULL, + runtime_months INT NULL, + mining_speed_value DECIMAL(20,4) NULL, + mining_speed_unit VARCHAR(8) NULL, + bonus_speed_value DECIMAL(20,4) NULL, + bonus_speed_unit VARCHAR(8) NULL, + total_cost_amount DECIMAL(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + usd_reference_amount DECIMAL(20,8) NULL, + reference_price_amount DECIMAL(20,8) NULL, + reference_price_currency VARCHAR(10) NULL, + auto_renew TINYINT(1) NOT NULL DEFAULT 0, + note TEXT, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi', + base_currency VARCHAR(10) NOT NULL, + rate_date DATE NOT NULL, + fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at) +); + +CREATE TABLE IF NOT EXISTS miningcheck_fx_rates ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + fetch_id BIGINT UNSIGNED NOT NULL, + currency_code VARCHAR(10) NOT NULL, + current_value DECIMAL(20,10) NOT NULL, + KEY idx_miningcheck_fx_rates_fetch (fetch_id), + KEY idx_miningcheck_fx_rates_currency (currency_code), + CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE +); diff --git a/modules/mining-checker/sql/schema.pgsql.sql b/modules/mining-checker/sql/schema.pgsql.sql new file mode 100644 index 00000000..232489dc --- /dev/null +++ b/modules/mining-checker/sql/schema.pgsql.sql @@ -0,0 +1,243 @@ +CREATE TABLE IF NOT EXISTS miningcheck_projects ( + project_key VARCHAR(64) PRIMARY KEY, + project_name VARCHAR(160) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS miningcheck_settings ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + baseline_measured_at TIMESTAMP NOT NULL, + baseline_coins_total NUMERIC(20,6) NOT NULL, + daily_cost_amount NUMERIC(20,10) NOT NULL, + daily_cost_currency VARCHAR(10) NOT NULL, + report_currency VARCHAR(10), + crypto_currency VARCHAR(10), + display_timezone VARCHAR(64), + fx_max_age_hours NUMERIC(10,2), + module_theme_mode VARCHAR(16), + module_theme_accent VARCHAR(16), + preferred_currencies JSONB, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub) +); + +CREATE TABLE IF NOT EXISTS miningcheck_cost_plans ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + label VARCHAR(120) NOT NULL, + starts_at TIMESTAMP NOT NULL, + runtime_months INTEGER NOT NULL, + mining_speed_value NUMERIC(20,4), + mining_speed_unit VARCHAR(8), + bonus_speed_value NUMERIC(20,4), + bonus_speed_unit VARCHAR(8), + auto_renew BOOLEAN NOT NULL DEFAULT FALSE, + base_price_amount NUMERIC(20,8), + payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat', + total_cost_amount NUMERIC(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + note TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_start + ON miningcheck_cost_plans(project_key, owner_sub, starts_at); + +CREATE TABLE IF NOT EXISTS miningcheck_measurements ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + measured_at TIMESTAMP NOT NULL, + coins_total NUMERIC(20,6) NOT NULL, + coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE', + price_per_coin NUMERIC(20,8), + price_currency VARCHAR(10), + fx_fetch_id BIGINT, + note TEXT, + source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')), + image_path VARCHAR(255), + ocr_raw_text TEXT, + ocr_confidence NUMERIC(6,4), + ocr_flags JSONB, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total) +); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_measured_at + ON miningcheck_measurements(project_key, owner_sub, measured_at); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_fx_fetch + ON miningcheck_measurements(fx_fetch_id); + +CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates ( + id BIGSERIAL PRIMARY KEY, + measurement_id BIGINT NOT NULL, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + base_currency VARCHAR(10) NOT NULL, + quote_currency VARCHAR(10) NOT NULL, + rate NUMERIC(20,10) NOT NULL, + provider VARCHAR(32) NOT NULL DEFAULT 'derived', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE, + CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency) +); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_measurement + ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id); + +CREATE TABLE IF NOT EXISTS miningcheck_payouts ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + payout_at TIMESTAMP NOT NULL, + coins_amount NUMERIC(20,6) NOT NULL, + payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE', + note TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_payout_at + ON miningcheck_payouts(project_key, owner_sub, payout_at); + +CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + measured_at TIMESTAMP NOT NULL, + total_value_amount NUMERIC(20,8), + total_value_currency VARCHAR(10), + wallet_balance NUMERIC(28,10), + wallet_currency VARCHAR(10) NOT NULL, + balances_json JSONB, + note TEXT, + source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')), + image_path VARCHAR(255), + ocr_raw_text TEXT, + ocr_confidence NUMERIC(6,4), + ocr_flags JSONB, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_snapshots_project_measured_at + ON miningcheck_wallet_snapshots(project_key, owner_sub, measured_at); + +CREATE TABLE IF NOT EXISTS miningcheck_miner_offers ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + label VARCHAR(120) NOT NULL, + runtime_months INTEGER, + mining_speed_value NUMERIC(20,4), + mining_speed_unit VARCHAR(8), + bonus_speed_value NUMERIC(20,4), + bonus_speed_unit VARCHAR(8), + base_price_amount NUMERIC(20,8) NOT NULL, + base_price_currency VARCHAR(10) NOT NULL, + payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat', + auto_renew BOOLEAN NOT NULL DEFAULT FALSE, + note TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS miningcheck_targets ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + label VARCHAR(120) NOT NULL, + target_amount_fiat NUMERIC(20,2) NOT NULL, + currency VARCHAR(10) NOT NULL, + miner_offer_id BIGINT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL, + CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label) +); + +CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + name VARCHAR(160) NOT NULL, + chart_type VARCHAR(16) NOT NULL CHECK (chart_type IN ('line', 'bar', 'area', 'table')), + x_field VARCHAR(64) NOT NULL, + y_field VARCHAR(64) NOT NULL, + aggregation VARCHAR(32) NOT NULL DEFAULT 'none', + filters_json JSONB, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name) +); + +CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + miner_offer_id BIGINT, + purchased_at TIMESTAMP NOT NULL, + label VARCHAR(120) NOT NULL, + runtime_months INTEGER, + mining_speed_value NUMERIC(20,4), + mining_speed_unit VARCHAR(8), + bonus_speed_value NUMERIC(20,4), + bonus_speed_unit VARCHAR(8), + total_cost_amount NUMERIC(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + usd_reference_amount NUMERIC(20,8), + reference_price_amount NUMERIC(20,8), + reference_price_currency VARCHAR(10), + auto_renew BOOLEAN NOT NULL DEFAULT FALSE, + note TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches ( + id BIGSERIAL PRIMARY KEY, + provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi', + base_currency VARCHAR(10) NOT NULL, + rate_date DATE NOT NULL, + fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_fetches_base_fetched + ON miningcheck_fx_fetches(base_currency, fetched_at); + +CREATE TABLE IF NOT EXISTS miningcheck_fx_rates ( + id BIGSERIAL PRIMARY KEY, + fetch_id BIGINT NOT NULL, + currency_code VARCHAR(10) NOT NULL, + current_value NUMERIC(20,10) NOT NULL, + CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_fetch + ON miningcheck_fx_rates(fetch_id); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_currency + ON miningcheck_fx_rates(currency_code); diff --git a/modules/mining-checker/sql/schema.sql b/modules/mining-checker/sql/schema.sql new file mode 100644 index 00000000..7f83b187 --- /dev/null +++ b/modules/mining-checker/sql/schema.sql @@ -0,0 +1,223 @@ +CREATE TABLE IF NOT EXISTS miningcheck_projects ( + project_key VARCHAR(64) NOT NULL PRIMARY KEY, + project_name VARCHAR(160) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS miningcheck_settings ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + baseline_measured_at DATETIME NOT NULL, + baseline_coins_total DECIMAL(20,6) NOT NULL, + daily_cost_amount DECIMAL(20,10) NOT NULL, + daily_cost_currency VARCHAR(10) NOT NULL, + report_currency VARCHAR(10) NULL, + crypto_currency VARCHAR(10) NULL, + display_timezone VARCHAR(64) NULL, + fx_max_age_hours DECIMAL(10,2) NULL, + module_theme_mode VARCHAR(16) NULL, + module_theme_accent VARCHAR(16) NULL, + preferred_currencies JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_settings_project UNIQUE (project_key) +); + +CREATE TABLE IF NOT EXISTS miningcheck_cost_plans ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + label VARCHAR(120) NOT NULL, + starts_at DATETIME NOT NULL, + runtime_months INT NOT NULL, + mining_speed_value DECIMAL(20,4) NULL, + mining_speed_unit VARCHAR(8) NULL, + bonus_speed_value DECIMAL(20,4) NULL, + bonus_speed_unit VARCHAR(8) NULL, + auto_renew TINYINT(1) NOT NULL DEFAULT 0, + base_price_amount DECIMAL(20,8) NULL, + payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat', + total_cost_amount DECIMAL(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + note TEXT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE INDEX idx_miningcheck_cost_plans_project_start + ON miningcheck_cost_plans(project_key, starts_at); + +CREATE TABLE IF NOT EXISTS miningcheck_measurements ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + measured_at DATETIME NOT NULL, + coins_total DECIMAL(20,6) NOT NULL, + coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE', + price_per_coin DECIMAL(20,8) NULL, + price_currency VARCHAR(10) NULL, + fx_fetch_id BIGINT UNSIGNED NULL, + note TEXT NULL, + source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL, + image_path VARCHAR(255) NULL, + ocr_raw_text MEDIUMTEXT NULL, + ocr_confidence DECIMAL(6,4) NULL, + ocr_flags JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total) +); + +CREATE INDEX idx_miningcheck_measurements_project_measured_at + ON miningcheck_measurements(project_key, measured_at); + +CREATE INDEX idx_miningcheck_measurements_fx_fetch + ON miningcheck_measurements(fx_fetch_id); + +CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + measurement_id BIGINT UNSIGNED NOT NULL, + project_key VARCHAR(64) NOT NULL, + base_currency VARCHAR(10) NOT NULL, + quote_currency VARCHAR(10) NOT NULL, + rate DECIMAL(20,10) NOT NULL, + provider VARCHAR(32) NOT NULL DEFAULT 'derived', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE, + CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency), + KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id) +); + +CREATE TABLE IF NOT EXISTS miningcheck_payouts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + payout_at TIMESTAMP NOT NULL, + coins_amount DECIMAL(20,6) NOT NULL, + payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE', + note TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at) +); + +CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + measured_at TIMESTAMP NOT NULL, + total_value_amount DECIMAL(20,8) NULL, + total_value_currency VARCHAR(10) NULL, + wallet_balance DECIMAL(28,10) NULL, + wallet_currency VARCHAR(10) NOT NULL, + balances_json JSON NULL, + note TEXT NULL, + source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL, + image_path VARCHAR(255) NULL, + ocr_raw_text MEDIUMTEXT NULL, + ocr_confidence DECIMAL(6,4) NULL, + ocr_flags JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at) +); + +CREATE TABLE IF NOT EXISTS miningcheck_miner_offers ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + label VARCHAR(120) NOT NULL, + runtime_months INT NULL, + mining_speed_value DECIMAL(20,4) NULL, + mining_speed_unit VARCHAR(8) NULL, + bonus_speed_value DECIMAL(20,4) NULL, + bonus_speed_unit VARCHAR(8) NULL, + base_price_amount DECIMAL(20,8) NOT NULL, + base_price_currency VARCHAR(10) NOT NULL, + payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat', + auto_renew TINYINT(1) NOT NULL DEFAULT 0, + note TEXT, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS miningcheck_targets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + label VARCHAR(120) NOT NULL, + target_amount_fiat DECIMAL(20,2) NOT NULL, + currency VARCHAR(10) NOT NULL, + miner_offer_id BIGINT UNSIGNED NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL, + CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label) +); + +CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + name VARCHAR(160) NOT NULL, + chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL, + x_field VARCHAR(64) NOT NULL, + y_field VARCHAR(64) NOT NULL, + aggregation VARCHAR(32) NOT NULL DEFAULT 'none', + filters_json JSON NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name) +); + +CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + miner_offer_id BIGINT UNSIGNED NULL, + purchased_at TIMESTAMP NOT NULL, + label VARCHAR(120) NOT NULL, + runtime_months INT NULL, + mining_speed_value DECIMAL(20,4) NULL, + mining_speed_unit VARCHAR(8) NULL, + bonus_speed_value DECIMAL(20,4) NULL, + bonus_speed_unit VARCHAR(8) NULL, + total_cost_amount DECIMAL(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + usd_reference_amount DECIMAL(20,8) NULL, + reference_price_amount DECIMAL(20,8) NULL, + reference_price_currency VARCHAR(10) NULL, + auto_renew TINYINT(1) NOT NULL DEFAULT 0, + note TEXT, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi', + base_currency VARCHAR(10) NOT NULL, + rate_date DATE NOT NULL, + fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at) +); + +CREATE TABLE IF NOT EXISTS miningcheck_fx_rates ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + fetch_id BIGINT UNSIGNED NOT NULL, + currency_code VARCHAR(10) NOT NULL, + current_value DECIMAL(20,10) NOT NULL, + KEY idx_miningcheck_fx_rates_fetch (fetch_id), + KEY idx_miningcheck_fx_rates_currency (currency_code), + CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE +); diff --git a/modules/mining-checker/sql/seed.sql b/modules/mining-checker/sql/seed.sql new file mode 100644 index 00000000..79975651 --- /dev/null +++ b/modules/mining-checker/sql/seed.sql @@ -0,0 +1,75 @@ +INSERT INTO miningcheck_projects (project_key, project_name) +VALUES ('doge-main', 'DOGE Mining Main') +ON DUPLICATE KEY UPDATE project_name = VALUES(project_name); + +INSERT INTO miningcheck_settings ( + project_key, + baseline_measured_at, + baseline_coins_total, + daily_cost_amount, + daily_cost_currency, + preferred_currencies +) +VALUES ( + 'doge-main', + '2026-03-16 01:32:00', + 27.617864, + 0.3123287671, + 'EUR', + '["DOGE","USD","EUR"]' +) +ON DUPLICATE KEY UPDATE + baseline_measured_at = VALUES(baseline_measured_at), + baseline_coins_total = VALUES(baseline_coins_total), + daily_cost_amount = VALUES(daily_cost_amount), + daily_cost_currency = VALUES(daily_cost_currency), + preferred_currencies = VALUES(preferred_currencies); + +INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order) +VALUES + ('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10), + ('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20) +ON DUPLICATE KEY UPDATE + target_amount_fiat = VALUES(target_amount_fiat), + currency = VALUES(currency), + is_active = VALUES(is_active), + sort_order = VALUES(sort_order); + +INSERT INTO miningcheck_dashboard_definitions ( + project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active +) +VALUES + ('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1), + ('doge-main', 'DOGE pro Tag', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1), + ('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currencies":["EUR","USD"]}', 1) +ON DUPLICATE KEY UPDATE + chart_type = VALUES(chart_type), + x_field = VALUES(x_field), + y_field = VALUES(y_field), + aggregation = VALUES(aggregation), + filters_json = VALUES(filters_json), + is_active = VALUES(is_active); + +INSERT INTO miningcheck_measurements ( + project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags +) +VALUES + ('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')), + ('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')), + ('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')), + ('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')) +ON DUPLICATE KEY UPDATE + price_per_coin = VALUES(price_per_coin), + price_currency = VALUES(price_currency), + note = VALUES(note), + source = VALUES(source); diff --git a/modules/mining-checker/src/Api/Router.php b/modules/mining-checker/src/Api/Router.php new file mode 100644 index 00000000..5e4a3647 --- /dev/null +++ b/modules/mining-checker/src/Api/Router.php @@ -0,0 +1,2774 @@ + ['value' => 50.0, 'unit' => 'kH/s'], + 'crypto' => ['value' => 75.0, 'unit' => 'kH/s'], + ]; + + 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, $this->bootstrapSettingsOptions($view)), [ + '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' => nexus_display_timezone_name(), + '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, $walletSnapshots, $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'] ?? nexus_display_timezone_name(), + '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 $options = []): array + { + $includeCostPlans = !array_key_exists('cost_plans', $options) || (bool) $options['cost_plans']; + $includeCurrencies = !array_key_exists('currencies', $options) || (bool) $options['currencies']; + $includePayouts = !array_key_exists('payouts', $options) || (bool) $options['payouts']; + $includeMinerOffers = !array_key_exists('miner_offers', $options) || (bool) $options['miner_offers']; + $includePurchasedMiners = !array_key_exists('purchased_miners', $options) || (bool) $options['purchased_miners']; + + $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' => nexus_display_timezone_name(), + 'module_theme_mode' => 'inherit', + 'module_theme_accent' => 'teal', + 'preferred_currencies' => $this->preferredCurrencies(), + ]; + if (!$this->isValidTimezone((string) ($base['display_timezone'] ?? ''))) { + $base['display_timezone'] = nexus_display_timezone_name(); + } + 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'] = $includeCostPlans ? $this->costPlans($projectKey) : []; + $base['currencies'] = $includeCurrencies ? $this->currencies() : []; + $base['preferred_currencies'] = $this->preferredCurrencies($base['preferred_currencies'] ?? null); + $base['payouts'] = $includePayouts ? $this->payouts($projectKey) : []; + $base['miner_offers'] = $includeMinerOffers ? $this->minerOffers($projectKey) : []; + $base['purchased_miners'] = $includePurchasedMiners ? $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'] ?? nexus_display_timezone_name()), + '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 []; + } + + if (in_array($view, ['overview', 'mining'], true)) { + $rows = $this->repository()->listRecentMeasurements($projectKey, self::BOOTSTRAP_MEASUREMENT_LIMIT); + } elseif ($view === 'measurements') { + $rows = $this->repository()->listRecentMeasurements($projectKey, 10); + } else { + $rows = $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, [ + 'full_latest_only' => in_array($view, ['overview', 'mining'], true), + ]); + } + + private function bootstrapWalletSnapshots(string $projectKey, string $view): array + { + if ($view === 'wallet') { + return $this->repository()->listWalletSnapshots($projectKey, 50); + } + + if (!in_array($view, ['overview', 'mining'], true)) { + return []; + } + + return $this->repository()->listWalletSnapshots($projectKey, 1); + } + + 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'], true)) { + return []; + } + + $limit = match ($view) { + 'overview' => 1, + default => self::BOOTSTRAP_SNAPSHOT_LIMIT, + }; + + return $this->measurementFxSnapshots($measurements, $limit); + } + + private function bootstrapSummary(array $measurements, array $settings, array $targets, array $walletSnapshots, 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, [ + 'include_offer_scenarios' => $view === 'mining', + 'include_long_term_projection' => $view === 'mining', + 'wallet_snapshots' => $walletSnapshots, + ]); + } + + 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 bootstrapSettingsOptions(string $view): array + { + return match ($view) { + 'overview' => [ + 'cost_plans' => true, + 'currencies' => true, + 'payouts' => true, + 'miner_offers' => false, + 'purchased_miners' => true, + ], + 'upload' => [ + 'cost_plans' => false, + 'currencies' => true, + 'payouts' => false, + 'miner_offers' => false, + 'purchased_miners' => false, + ], + 'measurements' => [ + 'cost_plans' => false, + 'currencies' => false, + 'payouts' => true, + 'miner_offers' => false, + 'purchased_miners' => false, + ], + 'wallet' => [ + 'cost_plans' => false, + 'currencies' => false, + 'payouts' => false, + 'miner_offers' => false, + 'purchased_miners' => false, + ], + 'dashboards' => [ + 'cost_plans' => false, + 'currencies' => false, + 'payouts' => false, + 'miner_offers' => false, + 'purchased_miners' => false, + ], + default => [ + 'cost_plans' => true, + 'currencies' => true, + 'payouts' => true, + 'miner_offers' => true, + 'purchased_miners' => true, + ], + }; + } + + 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); + $miningSpeedValue = $this->optionalDecimal($input['mining_speed_value'] ?? null); + $miningSpeedUnit = $this->optionalSpeedUnit($input['mining_speed_unit'] ?? null); + $bonusPercent = $this->optionalDecimal($input['bonus_percent'] ?? null); + $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' => $miningSpeedValue, + 'mining_speed_unit' => $miningSpeedUnit, + 'bonus_speed_value' => $this->bonusSpeedFromPercent($miningSpeedValue, $miningSpeedUnit, $bonusPercent), + 'bonus_speed_unit' => $bonusPercent !== null ? $miningSpeedUnit : null, + 'bonus_percent' => $bonusPercent, + '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 ($bonusPercent !== null && ($miningSpeedValue === null || $miningSpeedUnit === null)) { + throw new ApiException('Bonus-Prozent setzt eine Mining-Geschwindigkeit voraus.', 422); + } + if ($bonusPercent !== null && $bonusPercent < 0) { + throw new ApiException('Bonus-Prozent darf nicht negativ 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 + { + $paymentType = $this->enumValue($input['payment_type'] ?? 'fiat', ['fiat', 'crypto'], 'payment_type'); + $baseSpeed = self::BASE_OFFER_SPEEDS[$paymentType] ?? self::BASE_OFFER_SPEEDS['fiat']; + $bonusPercent = $this->optionalDecimal($input['bonus_percent'] ?? null); + $basePriceCurrency = $paymentType === 'crypto' + ? 'USD' + : $this->requiredCurrency($input['base_price_currency'] ?? ($input['reference_price_currency'] ?? $input['price_currency'] ?? null), 'base_price_currency'); + $payload = [ + 'label' => $this->requiredString($input['label'] ?? null, 'label', 120), + 'runtime_months' => $this->optionalPositiveInt($input['runtime_months'] ?? null), + 'mining_speed_value' => $baseSpeed['value'], + 'mining_speed_unit' => $baseSpeed['unit'], + 'bonus_speed_value' => $this->bonusSpeedFromPercent((float) $baseSpeed['value'], (string) $baseSpeed['unit'], $bonusPercent), + 'bonus_speed_unit' => $bonusPercent !== null ? (string) $baseSpeed['unit'] : null, + 'bonus_percent' => $bonusPercent, + 'base_price_amount' => $this->requiredDecimal($input['base_price_amount'] ?? ($input['reference_price_amount'] ?? $input['price_amount'] ?? null), 'base_price_amount'), + 'base_price_currency' => $basePriceCurrency, + 'payment_type' => $paymentType, + 'auto_renew' => !empty($input['auto_renew']) ? 1 : 0, + 'note' => $this->optionalString($input['note'] ?? null, 1000), + 'is_active' => !empty($input['is_active']) ? 1 : 0, + ]; + + if ($bonusPercent !== null && $bonusPercent < 0) { + throw new ApiException('Bonus-Prozent darf nicht negativ sein.', 422); + } + + $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']); + $paymentType = $this->enumValue($offer['payment_type'] ?? 'fiat', ['fiat', 'crypto'], 'payment_type'); + $selectedSpeedValue = $this->optionalDecimal($input['mining_speed_value'] ?? null); + $selectedSpeedUnit = $this->optionalSpeedUnit($input['mining_speed_unit'] ?? null); + if (($selectedSpeedValue === null) xor ($selectedSpeedUnit === null)) { + throw new ApiException('Mining-Geschwindigkeit und Einheit muessen gemeinsam gesetzt oder beide leer sein.', 422); + } + + $resolvedSpeedValue = $selectedSpeedValue ?? $this->optionalDecimal($offer['mining_speed_value'] ?? null); + $resolvedSpeedUnit = $selectedSpeedUnit ?? $this->optionalSpeedUnit($offer['mining_speed_unit'] ?? null); + $selectedBonusPercent = $this->optionalDecimal($input['bonus_percent'] ?? null); + if ($selectedBonusPercent !== null && $selectedBonusPercent < 0) { + throw new ApiException('Bonus-Prozent darf nicht negativ sein.', 422); + } + $resolvedBonusPercent = $selectedBonusPercent ?? $this->offerBonusPercent($offer); + $resolvedBonusValue = $this->bonusSpeedFromPercent($resolvedSpeedValue, $resolvedSpeedUnit, $resolvedBonusPercent); + $resolvedBonusUnit = $resolvedBonusValue !== null ? $resolvedSpeedUnit : null; + + $purchaseCurrency = $this->optionalCurrency($input['currency'] ?? null) ?? (string) ($offer['effective_price_currency'] ?? $offer['price_currency'] ?? $offer['base_price_currency'] ?? ''); + if ($paymentType === 'crypto' || !$isAutoRenew) { + $purchaseCurrency = $cryptoCurrency; + } + $purchaseCost = $this->optionalDecimal($input['total_cost_amount'] ?? null); + if ($purchaseCost === null) { + $purchaseCost = $this->resolveOfferPurchaseCost($projectKey, array_merge($offer, [ + 'mining_speed_value' => $resolvedSpeedValue, + 'mining_speed_unit' => $resolvedSpeedUnit, + 'bonus_speed_value' => $resolvedBonusValue, + 'bonus_speed_unit' => $resolvedBonusUnit, + 'base_price_amount' => $this->optionalDecimal($input['reference_price_amount'] ?? null) ?? ($offer['base_price_amount'] ?? null), + 'base_price_currency' => $this->optionalCurrency($input['reference_price_currency'] ?? null) ?? ($offer['base_price_currency'] ?? null), + 'price_currency' => $purchaseCurrency !== '' ? $purchaseCurrency : ($offer['price_currency'] ?? ''), + ])); + } + $referencePriceAmount = $this->optionalDecimal($input['reference_price_amount'] ?? ($offer['base_price_amount'] ?? $offer['reference_price_amount'] ?? $offer['usd_reference_amount'] ?? null)); + $referencePriceCurrency = $this->optionalCurrency($input['reference_price_currency'] ?? ($offer['base_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' => $this->optionalString($input['label'] ?? ($offer['label'] ?? null), 120) ?? $offer['label'], + 'runtime_months' => $offer['runtime_months'] ?? null, + 'mining_speed_value' => $resolvedSpeedValue, + 'mining_speed_unit' => $resolvedSpeedUnit, + 'bonus_speed_value' => $resolvedBonusValue, + 'bonus_speed_unit' => $resolvedBonusUnit, + '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 : nexus_display_timezone_name(); + } + + 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(string $projectKey, 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 !== '') { + $latestConverted = $this->convertWithLatestMeasurement($projectKey, $baseAmount, $baseCurrency, $purchaseCurrency); + if ($latestConverted !== null && $latestConverted > 0) { + return $latestConverted; + } + + $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 convertWithLatestMeasurement(string $projectKey, float $amount, string $fromCurrency, string $toCurrency): ?float + { + $from = strtoupper(trim($fromCurrency)); + $to = strtoupper(trim($toCurrency)); + if ($from === '' || $to === '' || $amount <= 0) { + return null; + } + if ($from === $to) { + return $amount; + } + + $latestRows = $this->repository()->listRecentMeasurements($projectKey, 1); + $latest = $latestRows[0] ?? null; + if (!is_array($latest)) { + return null; + } + + $coinCurrency = strtoupper(trim((string) ($latest['coin_currency'] ?? $this->settings($projectKey)['crypto_currency'] ?? ''))); + $priceCurrency = strtoupper(trim((string) ($latest['price_currency'] ?? ''))); + $pricePerCoin = is_numeric($latest['price_per_coin'] ?? null) ? (float) $latest['price_per_coin'] : null; + if ($coinCurrency === '' || $priceCurrency === '' || $pricePerCoin === null || $pricePerCoin <= 0) { + return null; + } + + if ($from === $priceCurrency && $to === $coinCurrency) { + return $amount / $pricePerCoin; + } + + if ($from === $coinCurrency && $to === $priceCurrency) { + return $amount * $pricePerCoin; + } + + if ($to === $coinCurrency) { + $convertedFiat = $this->fx()->convert($amount, $from, $priceCurrency); + if (is_numeric($convertedFiat) && (float) $convertedFiat > 0) { + return (float) $convertedFiat / $pricePerCoin; + } + } + + if ($from === $coinCurrency) { + $convertedFiat = $amount * $pricePerCoin; + $convertedTarget = $this->fx()->convert($convertedFiat, $priceCurrency, $to); + if (is_numeric($convertedTarget)) { + return (float) $convertedTarget; + } + } + + return null; + } + + private function bonusSpeedFromPercent(?float $speedValue, ?string $speedUnit, ?float $bonusPercent): ?float + { + if ($speedValue === null || $speedUnit === null || $bonusPercent === null) { + return null; + } + + return round($speedValue * ($bonusPercent / 100), 4); + } + + private function offerBonusPercent(array $offer): ?float + { + $speedValue = $this->optionalDecimal($offer['mining_speed_value'] ?? null); + $speedUnit = $this->optionalSpeedUnit($offer['mining_speed_unit'] ?? null); + $bonusValue = $this->optionalDecimal($offer['bonus_speed_value'] ?? null); + $bonusUnit = $this->optionalSpeedUnit($offer['bonus_speed_unit'] ?? null); + if ($speedValue === null || $speedUnit === null || $bonusValue === null || $bonusUnit === null) { + return null; + } + + $speedMh = $this->hashrateToMh($speedValue, $speedUnit); + $bonusMh = $this->hashrateToMh($bonusValue, $bonusUnit); + if ($speedMh <= 0 || $bonusMh <= 0) { + return null; + } + + return ($bonusMh / $speedMh) * 100; + } + + private function hashrateToMh(float $value, string $unit): float + { + return $unit === 'kH/s' ? $value / 1000 : $value; + } + + 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; + } +} diff --git a/modules/mining-checker/src/Domain/AnalyticsService.php b/modules/mining-checker/src/Domain/AnalyticsService.php new file mode 100644 index 00000000..36525ce4 --- /dev/null +++ b/modules/mining-checker/src/Domain/AnalyticsService.php @@ -0,0 +1,1661 @@ + ['value' => 50.0, 'unit' => 'kH/s'], + 'crypto' => ['value' => 75.0, 'unit' => 'kH/s'], + ]; + + private const OFFER_SPEED_MULTIPLIERS = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + ]; + + private ?FxService $fx; + + public function __construct(?FxService $fx = null) + { + $this->fx = $fx; + } + + public function enrichMeasurements(array $measurements, array $settings, array $options = []): array + { + $fullLatestOnly = !empty($options['full_latest_only']); + $baselineCoins = (float) ($settings['baseline_coins_total'] ?? 0.0); + $baselineAt = (string) ($settings['baseline_measured_at'] ?? ''); + $costPlans = is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : []; + $payouts = is_array($settings['payouts'] ?? null) ? $settings['payouts'] : []; + $purchasedMiners = is_array($settings['purchased_miners'] ?? null) ? $settings['purchased_miners'] : []; + $preferredPriceCurrencies = array_values(array_unique(array_filter([ + strtoupper(trim((string) ($settings['report_currency'] ?? ''))), + 'USD', + 'EUR', + 'DOGE', + ]))); + + $baselineTs = $this->utcTimestamp($baselineAt); + $previous = null; + $previousMeasuredTs = null; + $previousIntervalRate = null; + $previousCoinCurrency = null; + $result = []; + $payoutIndex = 0; + $payoutsByAsset = []; + $latestPayoutByAsset = []; + $latestPriceByCurrency = []; + + $lastIndex = count($measurements) - 1; + foreach ($measurements as $index => $row) { + $measuredTs = $this->utcTimestamp((string) $row['measured_at']); + $coinCurrency = strtoupper(trim((string) ($row['coin_currency'] ?? $settings['crypto_currency'] ?? 'DOGE'))); + $includeFullDetail = !$fullLatestOnly || $index === $lastIndex; + while (isset($payouts[$payoutIndex])) { + $payoutTs = $this->utcTimestamp((string) ($payouts[$payoutIndex]['payout_at'] ?? '')); + if ($payoutTs <= 0 || $payoutTs > $measuredTs) { + break; + } + + $payoutAsset = strtoupper(trim((string) ($payouts[$payoutIndex]['payout_currency'] ?? $coinCurrency))); + $payoutAmount = (float) ($payouts[$payoutIndex]['coins_amount'] ?? 0); + $payoutsByAsset[$payoutAsset] = ($payoutsByAsset[$payoutAsset] ?? 0.0) + $payoutAmount; + $latestPayoutByAsset[$payoutAsset] = [ + 'payout_at' => (string) ($payouts[$payoutIndex]['payout_at'] ?? ''), + 'payout_ts' => $payoutTs, + 'coins_amount' => $payoutAmount, + ]; + $payoutIndex++; + } + + $cumulativePayouts = (float) ($payoutsByAsset[$coinCurrency] ?? 0.0); + $visibleCoinsTotal = (float) $row['coins_total']; + $effectiveCoinsTotal = $visibleCoinsTotal + $cumulativePayouts; + $growth = $effectiveCoinsTotal - $baselineCoins; + $hoursSinceBaseline = $baselineTs > 0 && $measuredTs > $baselineTs ? ($measuredTs - $baselineTs) / 3600 : 0.0; + $perHourSinceBaseline = $hoursSinceBaseline > 0 ? $growth / $hoursSinceBaseline : null; + $perDaySinceBaseline = $perHourSinceBaseline !== null ? $perHourSinceBaseline * 24 : null; + $activeHashrateMh = $this->measurementHashrateMh($costPlans, $purchasedMiners, $measuredTs > 0 ? $measuredTs : null); + + $intervalHours = null; + $intervalGrowth = null; + $perHourInterval = null; + $perDayInterval = null; + $perHourPerMhInterval = null; + $perDayPerMhInterval = null; + $hoursSinceLastPayout = null; + $coinsSinceLastPayout = null; + $perHourSinceLastPayout = null; + $perDaySinceLastPayout = null; + $lastPayoutAt = null; + + if (is_array($previous) && $previousMeasuredTs !== null) { + $intervalStartTs = $previousMeasuredTs; + $intervalStartCoins = (float) ($previous['coins_total_effective'] ?? $previous['coins_total'] ?? 0.0); + if ($previousCoinCurrency !== null && $previousCoinCurrency !== $coinCurrency) { + $intervalStartCoins = $effectiveCoinsTotal; + } + + $intervalHours = max(0.0, ($measuredTs - $intervalStartTs) / 3600); + $intervalGrowth = $effectiveCoinsTotal - $intervalStartCoins; + $perHourInterval = $intervalHours > 0 ? $intervalGrowth / $intervalHours : null; + $perDayInterval = $perHourInterval !== null ? $perHourInterval * 24 : null; + if ($perHourInterval !== null && $activeHashrateMh > 0) { + $perHourPerMhInterval = $perHourInterval / $activeHashrateMh; + $perDayPerMhInterval = $perDayInterval !== null ? $perDayInterval / $activeHashrateMh : null; + } + } + + if (isset($latestPayoutByAsset[$coinCurrency]) && is_array($latestPayoutByAsset[$coinCurrency])) { + $lastPayout = $latestPayoutByAsset[$coinCurrency]; + $lastPayoutTs = (int) ($lastPayout['payout_ts'] ?? 0); + if ($lastPayoutTs > 0 && $measuredTs > $lastPayoutTs) { + $hoursSinceLastPayout = ($measuredTs - $lastPayoutTs) / 3600; + $coinsSinceLastPayout = $visibleCoinsTotal; + $perHourSinceLastPayout = $hoursSinceLastPayout > 0 ? $coinsSinceLastPayout / $hoursSinceLastPayout : null; + $perDaySinceLastPayout = $perHourSinceLastPayout !== null ? $perHourSinceLastPayout * 24 : null; + $lastPayoutAt = (string) ($lastPayout['payout_at'] ?? ''); + } + } + + $trendLabel = 'stabil'; + if ($perHourInterval !== null && $previousIntervalRate !== null) { + $delta = $perHourInterval - $previousIntervalRate; + $threshold = max(abs($previousIntervalRate) * 0.05, 0.01); + if ($delta > $threshold) { + $trendLabel = 'steigend'; + } elseif ($delta < -$threshold) { + $trendLabel = 'fallend'; + } + } + + $rawPrice = isset($row['price_per_coin']) && $row['price_per_coin'] !== null ? (float) $row['price_per_coin'] : null; + $rawPriceCurrency = $row['price_currency'] ?: null; + + if ($rawPrice !== null && $rawPriceCurrency !== null) { + $latestPriceByCurrency[(string) $rawPriceCurrency] = $rawPrice; + } + + $measurementDerivedPrices = $this->measurementDerivedPrices($row, $preferredPriceCurrencies); + foreach ($measurementDerivedPrices as $derivedCurrency => $derivedPrice) { + $latestPriceByCurrency[$derivedCurrency] = $derivedPrice; + } + + $priceCurrency = $rawPriceCurrency !== null + ? (string) $rawPriceCurrency + : $this->preferredPriceCurrency($latestPriceByCurrency, $measurementDerivedPrices); + $price = $rawPrice; + if ($price === null && $priceCurrency !== null && isset($measurementDerivedPrices[$priceCurrency])) { + $price = (float) $measurementDerivedPrices[$priceCurrency]; + } + if ($price === null && $priceCurrency !== null && isset($latestPriceByCurrency[$priceCurrency])) { + $price = (float) $latestPriceByCurrency[$priceCurrency]; + } + if ($price === null) { + foreach (['USD', 'EUR'] as $fallbackCurrency) { + $fxPrice = $this->convertAmount(1.0, $coinCurrency, $fallbackCurrency, $row); + if ($fxPrice !== null && $fxPrice > 0) { + $latestPriceByCurrency[$fallbackCurrency] = $fxPrice; + } + } + $priceCurrency = $priceCurrency ?? $this->preferredPriceCurrency($latestPriceByCurrency, $measurementDerivedPrices); + if ($priceCurrency !== null && isset($latestPriceByCurrency[$priceCurrency])) { + $price = (float) $latestPriceByCurrency[$priceCurrency]; + } + } + + $effectiveDailyCost = $includeFullDetail ? $this->effectiveDailyCost($costPlans, $measuredTs, $priceCurrency, $row) : null; + $currentValue = ($includeFullDetail && $price !== null) ? $visibleCoinsTotal * $price : null; + $currentValueEffective = ($includeFullDetail && $price !== null) ? $effectiveCoinsTotal * $price : null; + $theoreticalDailyRevenue = ($includeFullDetail && $price !== null && $perDayInterval !== null) ? $perDayInterval * $price : null; + $theoreticalDailyProfit = ( + $includeFullDetail && + $theoreticalDailyRevenue !== null && + $effectiveDailyCost !== null + ) ? $theoreticalDailyRevenue - $effectiveDailyCost : null; + $breakEvenPricePerCoin = ( + $includeFullDetail && + $effectiveDailyCost !== null && + $perDayInterval !== null && + $perDayInterval > 0 + ) ? $effectiveDailyCost / $perDayInterval : null; + $profitMarginPercent = ( + $includeFullDetail && + $theoreticalDailyRevenue !== null && + $theoreticalDailyRevenue > 0 && + $theoreticalDailyProfit !== null + ) ? ($theoreticalDailyProfit / $theoreticalDailyRevenue) * 100 : null; + + $normalizedFlags = $row['ocr_flags']; + if (is_string($normalizedFlags) && $normalizedFlags !== '') { + $decoded = json_decode($normalizedFlags, true); + $normalizedFlags = is_array($decoded) ? $decoded : [$normalizedFlags]; + } + + $enrichedRow = array_merge($row, [ + 'coins_total_visible' => $this->roundOrNull($visibleCoinsTotal, 6), + 'coins_total_effective' => $this->roundOrNull($effectiveCoinsTotal, 6), + 'coin_currency' => $coinCurrency, + 'payouts_cumulative' => $this->roundOrNull($cumulativePayouts, 6), + 'growth_since_baseline' => $this->roundOrNull($growth, 6), + 'hours_since_baseline' => $this->roundOrNull($hoursSinceBaseline, 4), + 'doge_per_hour_since_baseline' => $this->roundOrNull($perHourSinceBaseline, 6), + 'doge_per_day_since_baseline' => $this->roundOrNull($perDaySinceBaseline, 6), + 'active_hashrate_mh' => $this->roundOrNull($activeHashrateMh > 0 ? $activeHashrateMh : null, 4), + 'interval_hours' => $this->roundOrNull($intervalHours, 4), + 'interval_growth' => $this->roundOrNull($intervalGrowth, 6), + 'doge_per_hour_interval' => $this->roundOrNull($perHourInterval, 6), + 'doge_per_day_interval' => $this->roundOrNull($perDayInterval, 6), + 'doge_per_hour_per_mh_interval' => $this->roundOrNull($perHourPerMhInterval, 8), + 'doge_per_day_per_mh_interval' => $this->roundOrNull($perDayPerMhInterval, 8), + 'last_payout_at' => $lastPayoutAt, + 'hours_since_last_payout' => $this->roundOrNull($hoursSinceLastPayout, 4), + 'coins_since_last_payout' => $this->roundOrNull($coinsSinceLastPayout, 6), + 'doge_per_hour_since_last_payout' => $this->roundOrNull($perHourSinceLastPayout, 6), + 'doge_per_day_since_last_payout' => $this->roundOrNull($perDaySinceLastPayout, 6), + 'trend_label' => $trendLabel, + 'effective_price_per_coin' => $this->roundOrNull($price, 8), + 'effective_price_currency' => $priceCurrency, + 'price_is_fallback' => $rawPrice === null && $price !== null, + 'price_quotes' => $measurementDerivedPrices, + 'current_value' => $this->roundOrNull($currentValue, 8), + 'current_value_effective' => $this->roundOrNull($currentValueEffective, 8), + 'effective_daily_cost' => $this->roundOrNull($effectiveDailyCost, 8), + 'theoretical_daily_revenue' => $this->roundOrNull($theoreticalDailyRevenue, 8), + 'theoretical_daily_profit' => $this->roundOrNull($theoreticalDailyProfit, 8), + 'break_even_price_per_coin' => $this->roundOrNull($breakEvenPricePerCoin, 8), + 'profit_margin_percent' => $this->roundOrNull($profitMarginPercent, 4), + 'measured_date' => substr((string) $row['measured_at'], 0, 10), + 'ocr_flags' => is_array($normalizedFlags) ? $normalizedFlags : [], + ]); + $result[] = $enrichedRow; + + if ($perHourInterval !== null) { + $previousIntervalRate = $perHourInterval; + } + $previous = $enrichedRow; + $previousMeasuredTs = $measuredTs > 0 ? $measuredTs : null; + $previousCoinCurrency = $coinCurrency; + } + + return $result; + } + + public function buildSummary(array $measurements, array $settings, array $targets, array $options = []): array + { + $includeOfferScenarios = !array_key_exists('include_offer_scenarios', $options) || (bool) $options['include_offer_scenarios']; + $includeLongTermProjection = !array_key_exists('include_long_term_projection', $options) || (bool) $options['include_long_term_projection']; + + if ($measurements === []) { + return [ + 'latest_measurement' => null, + 'baseline' => $settings, + 'targets' => [], + 'payouts' => [], + 'miner_offers' => [], + ]; + } + + $latest = $measurements[array_key_last($measurements)]; + $latestPriceByCurrency = []; + foreach ($measurements as $measurement) { + $quotes = is_array($measurement['price_quotes'] ?? null) ? $measurement['price_quotes'] : []; + foreach ($quotes as $currency => $price) { + $normalizedCurrency = strtoupper(trim((string) $currency)); + if ($normalizedCurrency !== '' && is_numeric($price)) { + $latestPriceByCurrency[$normalizedCurrency] = (float) $price; + } + } + } + $latestEffectiveCurrency = strtoupper(trim((string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''))); + if ($latestEffectiveCurrency !== '' && is_numeric($latest['effective_price_per_coin'] ?? null)) { + $latestPriceByCurrency[$latestEffectiveCurrency] = (float) $latest['effective_price_per_coin']; + } + + $payouts = is_array($settings['payouts'] ?? null) ? $settings['payouts'] : []; + $purchasedMiners = is_array($settings['purchased_miners'] ?? null) ? $settings['purchased_miners'] : []; + $minerOffers = is_array($settings['miner_offers'] ?? null) ? $settings['miner_offers'] : []; + $currentHashrateMh = $this->totalHashrateMh(array_merge( + is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], + $purchasedMiners + )); + $offerSummary = []; + foreach ($this->expandOfferVariants($minerOffers, $settings) as $offer) { + $offerSummary[] = $this->evaluateMinerOffer($offer, $latest, $latestPriceByCurrency, $currentHashrateMh, $settings); + } + + $targetSummary = []; + foreach ($targets as $target) { + $currency = (string) $target['currency']; + $targetAmount = is_numeric($target['target_amount_fiat'] ?? null) ? (float) $target['target_amount_fiat'] : null; + $linkedOffer = null; + if (is_numeric($target['miner_offer_id'] ?? null)) { + foreach ($offerSummary as $offer) { + if ((int) ($offer['base_offer_id'] ?? $offer['id'] ?? 0) === (int) $target['miner_offer_id']) { + $linkedOffer = $offer; + break; + } + } + } + + if (is_array($linkedOffer)) { + $currency = (string) ($linkedOffer['reference_price_currency'] ?? $linkedOffer['effective_price_currency'] ?? $currency); + $targetAmount = is_numeric($linkedOffer['reference_price_amount'] ?? null) + ? (float) $linkedOffer['reference_price_amount'] + : (is_numeric($linkedOffer['effective_price_amount'] ?? null) ? (float) $linkedOffer['effective_price_amount'] : $targetAmount); + } + + $price = $latestPriceByCurrency[$currency] ?? $this->convertLatestPrice($latestPriceByCurrency, $currency, $latest); + $requiredDoge = ($price && $targetAmount !== null) ? $targetAmount / $price : null; + $remainingDoge = $requiredDoge !== null ? $requiredDoge - (float) ($latest['coins_total_effective'] ?? $latest['coins_total']) : null; + $remainingDays = ( + $remainingDoge !== null && + $latest['doge_per_day_interval'] !== null && + (float) $latest['doge_per_day_interval'] > 0 + ) ? $remainingDoge / (float) $latest['doge_per_day_interval'] : null; + $targetEtaAt = null; + if ($remainingDays !== null) { + if ($remainingDays <= 0) { + $targetEtaAt = (string) ($latest['measured_at'] ?? ''); + } elseif (!empty($latest['measured_at'])) { + try { + $targetEtaAt = $this->formatUtcTimestamp( + $this->utcTimestamp((string) $latest['measured_at']) + (int) round($remainingDays * 86400) + ); + } catch (\Throwable) { + $targetEtaAt = null; + } + } + } + + $targetSummary[] = array_merge($target, [ + 'effective_target_amount_fiat' => $this->roundOrNull($targetAmount, 2), + 'effective_currency' => $currency, + 'linked_offer_id' => $linkedOffer['id'] ?? ($target['miner_offer_id'] ?? null), + 'linked_offer_label' => $linkedOffer['label'] ?? null, + 'latest_price_for_currency' => $price, + 'required_doge' => $this->roundOrNull($requiredDoge, 6), + 'remaining_doge' => $this->roundOrNull($remainingDoge, 6), + 'remaining_days' => $this->roundOrNull($remainingDays, 4), + 'target_eta_at' => $targetEtaAt, + 'status' => $remainingDoge !== null && $remainingDoge <= 0 ? 'reached' : 'open', + ]); + } + + $latestCurrency = (string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''); + $latestMeasuredTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? '')); + $cashInvestedCapital = $latestCurrency !== '' + ? $this->totalInvestmentBasis( + is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], + $purchasedMiners, + $latestMeasuredTs, + $latestCurrency, + $latest, + 'cash' + ) + : null; + $reinvestedCapital = $latestCurrency !== '' + ? $this->totalInvestmentBasis( + is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], + $purchasedMiners, + $latestMeasuredTs, + $latestCurrency, + $latest, + 'reinvest' + ) + : null; + $walletBalances = $this->walletBalances($payouts, $purchasedMiners, $latestMeasuredTs); + $latestWalletSnapshot = $this->latestWalletSnapshot(is_array($options['wallet_snapshots'] ?? null) ? $options['wallet_snapshots'] : []); + if (is_array($latestWalletSnapshot)) { + $snapshotBalances = $this->walletSnapshotBalances($latestWalletSnapshot); + if ($snapshotBalances !== []) { + $walletBalances = $snapshotBalances; + } + } + $walletBalanceCurrentAsset = (float) ($walletBalances[strtoupper(trim((string) ($latest['coin_currency'] ?? $settings['crypto_currency'] ?? 'DOGE')))] ?? 0.0); + $holdingsCurrentAsset = $walletBalanceCurrentAsset + (float) ($latest['coins_total_visible'] ?? $latest['coins_total'] ?? 0); + $walletValue = $latestCurrency !== '' + ? ( + is_array($latestWalletSnapshot) + ? $this->walletSnapshotValue($latestWalletSnapshot, $latestCurrency, $latest) + : $this->walletBalanceValue($walletBalances, $latestCurrency, $latest) + ) + : null; + $currentVisibleValue = is_numeric($latest['current_value'] ?? null) ? (float) $latest['current_value'] : null; + $totalHoldingsValue = ($walletValue !== null || $currentVisibleValue !== null) + ? (float) ($walletValue ?? 0.0) + (float) ($currentVisibleValue ?? 0.0) + : null; + $currentDailyRevenue = is_numeric($latest['theoretical_daily_revenue'] ?? null) ? (float) $latest['theoretical_daily_revenue'] : null; + $breakEvenRemainingAmount = ($cashInvestedCapital !== null && $totalHoldingsValue !== null) + ? max(0.0, $cashInvestedCapital - $totalHoldingsValue) + : $cashInvestedCapital; + $breakEvenProjection = ( + $cashInvestedCapital !== null && + $breakEvenRemainingAmount !== null + ) ? $this->projectBreakEvenDate( + is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], + $purchasedMiners, + $latest, + $breakEvenRemainingAmount + ) : ['days' => null, 'eta' => null]; + $breakEvenDaysOverall = is_numeric($breakEvenProjection['days'] ?? null) ? (float) $breakEvenProjection['days'] : null; + $latestSummary = array_merge($latest, [ + 'invested_capital' => $this->roundOrNull($cashInvestedCapital, 8), + 'cash_invested_capital' => $this->roundOrNull($cashInvestedCapital, 8), + 'reinvested_capital' => $this->roundOrNull($reinvestedCapital, 8), + 'wallet_balance_current_asset' => $this->roundOrNull($walletBalanceCurrentAsset, 6), + 'holdings_current_asset' => $this->roundOrNull($holdingsCurrentAsset, 6), + 'wallet_value' => $this->roundOrNull($walletValue, 8), + 'wallet_snapshot_measured_at' => is_array($latestWalletSnapshot) ? (string) ($latestWalletSnapshot['measured_at'] ?? '') : null, + 'total_holdings_value' => $this->roundOrNull($totalHoldingsValue, 8), + 'break_even_remaining_amount' => $this->roundOrNull($breakEvenRemainingAmount, 8), + 'break_even_days_overall' => $this->roundOrNull($breakEvenDaysOverall, 4), + 'break_even_eta_at' => $breakEvenProjection['eta'] ?? null, + ]); + if ($includeLongTermProjection) { + $currentProjection = $this->projectPerformance( + is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], + $purchasedMiners, + $latestSummary, + 730 + ); + $latestSummary = array_merge($latestSummary, [ + 'projection_days' => $currentProjection['days'], + 'projection_two_year_revenue' => $this->roundOrNull($currentProjection['revenue'], 8), + 'projection_two_year_cost' => $this->roundOrNull($currentProjection['cost'], 8), + 'projection_two_year_profit' => $this->roundOrNull($currentProjection['profit'], 8), + ]); + } + + if ($includeOfferScenarios) { + $offerSummary = array_map( + fn (array $offer): array => $this->enrichOfferScenario( + $offer, + $latestSummary, + $currentHashrateMh, + is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], + $purchasedMiners + ), + $offerSummary + ); + } else { + $offerSummary = []; + } + + return [ + 'latest_measurement' => $latestSummary, + 'baseline' => $settings, + 'targets' => $targetSummary, + 'payouts' => [ + 'total_count' => count($payouts), + 'total_coins' => $this->roundOrNull(array_sum(array_map(static fn (array $payout): float => (float) ($payout['coins_amount'] ?? 0), $payouts)), 6), + 'current_visible_coins' => $this->roundOrNull((float) ($latest['coins_total_visible'] ?? $latest['coins_total']), 6), + 'current_effective_coins' => $this->roundOrNull((float) ($latest['coins_total_effective'] ?? $latest['coins_total']), 6), + 'wallet_balances' => $walletBalances, + 'wallet_balance_current_asset' => $this->roundOrNull($walletBalanceCurrentAsset, 6), + 'holdings_current_asset' => $this->roundOrNull($holdingsCurrentAsset, 6), + ], + 'current_hashrate_mh' => $this->roundOrNull($currentHashrateMh, 4), + 'miner_offers' => $offerSummary, + ]; + } + + public function dashboardData(array $measurements, string $xField, string $yField, string $aggregation, array $filters = []): array + { + $allowedX = ['measured_at', 'measured_date', 'source', 'price_currency', 'trend_label']; + $allowedY = [ + 'coins_total', + 'price_per_coin', + 'growth_since_baseline', + 'doge_per_hour_since_baseline', + 'doge_per_day_since_baseline', + 'doge_per_hour_interval', + 'doge_per_day_interval', + 'current_value', + 'theoretical_daily_revenue', + 'theoretical_daily_profit', + ]; + + if (!in_array($xField, $allowedX, true) || !in_array($yField, $allowedY, true)) { + throw new ApiException('Dashboard-Felder sind nicht erlaubt.', 422, ['x_field' => $xField, 'y_field' => $yField]); + } + + $filtered = array_values(array_filter($measurements, static function (array $row) use ($filters): bool { + if (!empty($filters['source']) && $row['source'] !== $filters['source']) { + return false; + } + + if (!empty($filters['currency']) && $row['price_currency'] !== $filters['currency']) { + return false; + } + + if (!empty($filters['date_from']) && (string) $row['measured_at'] < (string) $filters['date_from']) { + return false; + } + + if (!empty($filters['date_to']) && (string) $row['measured_at'] > (string) $filters['date_to']) { + return false; + } + + return true; + })); + + if ($aggregation === 'none') { + return array_map(static fn (array $row): array => [ + 'x' => $row[$xField] ?? null, + 'y' => $row[$yField] ?? null, + 'row' => $row, + ], $filtered); + } + + $groups = []; + foreach ($filtered as $row) { + $key = (string) ($row[$xField] ?? 'unknown'); + $groups[$key][] = $row; + } + + $result = []; + foreach ($groups as $key => $rows) { + $values = array_values(array_filter(array_map(static fn (array $row) => $row[$yField] ?? null, $rows), static fn ($value): bool => $value !== null)); + $aggregated = match ($aggregation) { + 'avg' => $values === [] ? null : array_sum($values) / count($values), + 'sum' => $values === [] ? null : array_sum($values), + 'min' => $values === [] ? null : min($values), + 'max' => $values === [] ? null : max($values), + 'count' => count($rows), + 'latest' => $rows[array_key_last($rows)][$yField] ?? null, + default => null, + }; + + $result[] = [ + 'x' => $key, + 'y' => $this->roundOrNull(is_numeric($aggregated) ? (float) $aggregated : null, 6), + 'points' => count($rows), + ]; + } + + return $result; + } + + private function roundOrNull(?float $value, int $precision): ?float + { + return $value === null ? null : round($value, $precision); + } + + private function effectiveDailyCost(array $costPlans, int $measurementTs, ?string $currency, ?array $fxContext = null): ?float + { + if ($currency === null) { + return null; + } + + $dailyTotal = 0.0; + $matched = false; + foreach ($costPlans as $plan) { + if (empty($plan['is_active'])) { + continue; + } + + $startTs = $this->utcTimestamp((string) ($plan['starts_at'] ?? '')); + $runtimeMonths = (int) ($plan['runtime_months'] ?? 0); + if ($startTs <= 0 || $runtimeMonths <= 0 || $measurementTs < $startTs) { + continue; + } + + $runtimeDays = $runtimeMonths * 30.4375; + $endTs = (int) round($startTs + ($runtimeDays * 86400)); + $isCovered = !empty($plan['auto_renew']) || $measurementTs <= $endTs; + if (!$isCovered) { + continue; + } + + $planDailyCost = (float) $plan['total_cost_amount'] / $runtimeDays; + $convertedDailyCost = $this->convertAmount( + $planDailyCost, + (string) ($plan['currency'] ?? ''), + $currency, + $fxContext + ); + if ($convertedDailyCost === null) { + continue; + } + + $matched = true; + $dailyTotal += $convertedDailyCost; + } + + return $matched ? $dailyTotal : null; + } + + private function convertLatestPrice(array $latestPriceByCurrency, string $targetCurrency, ?array $fxContext = null): ?float + { + foreach ($latestPriceByCurrency as $sourceCurrency => $price) { + $converted = $this->convertAmount((float) $price, (string) $sourceCurrency, $targetCurrency, $fxContext); + if ($converted !== null) { + return $converted; + } + } + + return null; + } + + private function preferredPriceCurrency(array $latestPriceByCurrency, array $measurementDerivedPrices = []): ?string + { + foreach (['USD', 'EUR', 'DOGE'] as $preferredCurrency) { + if (array_key_exists($preferredCurrency, $latestPriceByCurrency)) { + return $preferredCurrency; + } + if (array_key_exists($preferredCurrency, $measurementDerivedPrices)) { + return $preferredCurrency; + } + } + + $first = array_key_first($latestPriceByCurrency); + if (is_string($first)) { + return $first; + } + + $derivedFirst = array_key_first($measurementDerivedPrices); + return is_string($derivedFirst) ? $derivedFirst : null; + } + + private function measurementDerivedPrices(array $measurement, array $targetCurrencies): array + { + $rawPrice = is_numeric($measurement['price_per_coin'] ?? null) ? (float) $measurement['price_per_coin'] : null; + $rawCurrency = strtoupper(trim((string) ($measurement['price_currency'] ?? ''))); + + if ($rawPrice === null || $rawPrice <= 0 || $rawCurrency === '') { + return []; + } + + $prices = [$rawCurrency => $rawPrice]; + foreach ($targetCurrencies as $targetCurrency) { + $targetCurrency = strtoupper(trim((string) $targetCurrency)); + if ($targetCurrency === '' || isset($prices[$targetCurrency])) { + continue; + } + + $converted = $this->convertAmount($rawPrice, $rawCurrency, $targetCurrency, $measurement); + if ($converted !== null && $converted > 0) { + $prices[$targetCurrency] = $converted; + } + } + + return $prices; + } + + private function convertAmount(?float $amount, ?string $fromCurrency, ?string $toCurrency, ?array $fxContext = null): ?float + { + if ($amount === null || $fromCurrency === null || $toCurrency === null) { + return null; + } + + $from = strtoupper(trim($fromCurrency)); + $to = strtoupper(trim($toCurrency)); + if ($from === '' || $to === '') { + return null; + } + + if ($from === $to) { + return $amount; + } + + $measurementConverted = $this->convertAmountFromMeasurementPrice($amount, $from, $to, $fxContext); + if ($measurementConverted !== null) { + return $measurementConverted; + } + + if ($this->fx === null) { + return null; + } + + $fetchId = isset($fxContext['fx_fetch_id']) && is_numeric($fxContext['fx_fetch_id']) + ? (int) $fxContext['fx_fetch_id'] + : null; + $at = is_string($fxContext['measured_at'] ?? null) ? (string) $fxContext['measured_at'] : null; + + return $this->fx->convertAt($amount, $from, $to, $at, null, $fetchId); + } + + private function convertAmountFromMeasurementPrice(float $amount, string $from, string $to, ?array $fxContext = null): ?float + { + if (!is_array($fxContext)) { + return null; + } + + $coinCurrency = strtoupper(trim((string) ($fxContext['coin_currency'] ?? ''))); + $priceCurrency = strtoupper(trim((string) ($fxContext['effective_price_currency'] ?? $fxContext['price_currency'] ?? ''))); + $pricePerCoin = is_numeric($fxContext['effective_price_per_coin'] ?? null) + ? (float) $fxContext['effective_price_per_coin'] + : (is_numeric($fxContext['price_per_coin'] ?? null) ? (float) $fxContext['price_per_coin'] : null); + + if ($coinCurrency === '' || $priceCurrency === '' || $pricePerCoin === null || $pricePerCoin <= 0) { + return null; + } + + if ($from === $coinCurrency && $to === $priceCurrency) { + return $amount * $pricePerCoin; + } + + if ($from === $priceCurrency && $to === $coinCurrency) { + return $amount / $pricePerCoin; + } + + if ($to === $coinCurrency && $this->fx !== null) { + $convertedFiat = $this->fx->convertAt( + $amount, + $from, + $priceCurrency, + is_string($fxContext['measured_at'] ?? null) ? (string) $fxContext['measured_at'] : null, + null, + isset($fxContext['fx_fetch_id']) && is_numeric($fxContext['fx_fetch_id']) ? (int) $fxContext['fx_fetch_id'] : null + ); + if (is_numeric($convertedFiat) && (float) $convertedFiat > 0) { + return (float) $convertedFiat / $pricePerCoin; + } + } + + if ($from === $coinCurrency && $this->fx !== null) { + $convertedFiat = $amount * $pricePerCoin; + $convertedTarget = $this->fx->convertAt( + $convertedFiat, + $priceCurrency, + $to, + is_string($fxContext['measured_at'] ?? null) ? (string) $fxContext['measured_at'] : null, + null, + isset($fxContext['fx_fetch_id']) && is_numeric($fxContext['fx_fetch_id']) ? (int) $fxContext['fx_fetch_id'] : null + ); + if (is_numeric($convertedTarget)) { + return (float) $convertedTarget; + } + } + + return null; + } + + private function totalHashrateMh(array $entries): float + { + $total = 0.0; + foreach ($entries as $entry) { + if (array_key_exists('is_active', $entry) && empty($entry['is_active'])) { + continue; + } + if (!$this->entryIsCovered($entry, null)) { + continue; + } + + $total += $this->normalizeHashrateMh($entry['mining_speed_value'] ?? null, $entry['mining_speed_unit'] ?? null); + $total += $this->normalizeHashrateMh($entry['bonus_speed_value'] ?? null, $entry['bonus_speed_unit'] ?? null); + } + + return $total; + } + + private function measurementHashrateMh(array $costPlans, array $purchasedMiners, ?int $measurementTs = null): float + { + $total = 0.0; + + foreach ($costPlans as $plan) { + if (array_key_exists('is_active', $plan) && empty($plan['is_active'])) { + continue; + } + if (!$this->entryIsCovered($plan, $measurementTs)) { + continue; + } + + $total += $this->normalizeHashrateMh($plan['mining_speed_value'] ?? null, $plan['mining_speed_unit'] ?? null); + $total += $this->normalizeHashrateMh($plan['bonus_speed_value'] ?? null, $plan['bonus_speed_unit'] ?? null); + } + + foreach ($purchasedMiners as $miner) { + if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) { + continue; + } + if (!$this->entryIsCovered($miner, $measurementTs, 'purchased_at')) { + continue; + } + + $total += $this->normalizeHashrateMh($miner['mining_speed_value'] ?? null, $miner['mining_speed_unit'] ?? null); + $total += $this->normalizeHashrateMh($miner['bonus_speed_value'] ?? null, $miner['bonus_speed_unit'] ?? null); + } + + return $total; + } + + private function totalPurchasedMinerCost(array $purchasedMiners, string $targetCurrency, ?int $measurementTs = null, ?array $fxContext = null): ?float + { + $target = strtoupper(trim($targetCurrency)); + if ($target === '') { + return null; + } + + $total = 0.0; + $matched = false; + foreach ($purchasedMiners as $miner) { + if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) { + continue; + } + if ($measurementTs > 0 && $this->utcTimestamp((string) ($miner['purchased_at'] ?? '')) > $measurementTs) { + continue; + } + + $amount = is_numeric($miner['total_cost_amount'] ?? null) ? (float) $miner['total_cost_amount'] : null; + $currency = strtoupper(trim((string) ($miner['currency'] ?? ''))); + if ($amount === null || $amount <= 0 || $currency === '') { + continue; + } + + $converted = $this->convertAmount($amount, $currency, $target, $fxContext); + if ($converted === null) { + continue; + } + + $matched = true; + $total += $converted; + } + + return $matched ? $total : null; + } + + private function totalInvestmentBasis(array $costPlans, array $purchasedMiners, int $measurementTs, string $targetCurrency, ?array $fxContext = null, ?string $fundingSource = null): ?float + { + $target = strtoupper(trim($targetCurrency)); + if ($target === '') { + return null; + } + + $total = 0.0; + $matched = false; + + $purchasedTotal = $this->totalPurchasedMinerCost($purchasedMiners, $target, $measurementTs, $fxContext); + if ($fundingSource === null && $purchasedTotal !== null) { + $matched = true; + $total += $purchasedTotal; + } + + foreach ($costPlans as $plan) { + if ($measurementTs > 0 && $this->utcTimestamp((string) ($plan['starts_at'] ?? '')) > $measurementTs) { + continue; + } + if ($fundingSource !== null && $this->entryFundingSource($plan) !== $fundingSource) { + continue; + } + + $amount = is_numeric($plan['total_cost_amount'] ?? null) ? (float) $plan['total_cost_amount'] : null; + $currency = strtoupper(trim((string) ($plan['currency'] ?? ''))); + if ($amount === null || $amount <= 0 || $currency === '') { + continue; + } + + $converted = $this->convertAmount($amount, $currency, $target, $fxContext); + if ($converted === null) { + continue; + } + + $matched = true; + $total += $converted; + } + + if ($fundingSource !== null) { + foreach ($purchasedMiners as $miner) { + if ($measurementTs > 0 && $this->utcTimestamp((string) ($miner['purchased_at'] ?? '')) > $measurementTs) { + continue; + } + if ($this->entryFundingSource($miner) !== $fundingSource) { + continue; + } + + $amount = $this->investmentBasisAmount($miner); + $currency = strtoupper(trim((string) ($miner['reference_price_currency'] ?? $miner['currency'] ?? ''))); + if ($amount === null || $amount <= 0 || $currency === '') { + continue; + } + + $converted = $this->convertAmount($amount, $currency, $target, $fxContext); + if ($converted === null) { + continue; + } + + $matched = true; + $total += $converted; + } + } + + return $matched ? $total : null; + } + + private function entryIsCovered(array $entry, ?int $measurementTs = null, string $startField = 'starts_at'): bool + { + $runtimeMonths = (int) ($entry['runtime_months'] ?? 0); + if ($runtimeMonths <= 0) { + return true; + } + + $startTs = $this->utcTimestamp((string) ($entry[$startField] ?? '')); + if ($startTs <= 0) { + return true; + } + + $checkTs = $measurementTs ?? time(); + if ($checkTs < $startTs) { + return false; + } + + $runtimeDays = $runtimeMonths * 30.4375; + $endTs = (int) round($startTs + ($runtimeDays * 86400)); + return !empty($entry['auto_renew']) || $checkTs <= $endTs; + } + + private function normalizeHashrateMh(mixed $value, mixed $unit): float + { + if (!is_numeric($value) || !is_string($unit) || trim($unit) === '') { + return 0.0; + } + + $numeric = (float) $value; + return match (trim($unit)) { + 'MH/s' => $numeric, + 'kH/s' => $numeric / 1000, + default => 0.0, + }; + } + + private function expandOfferVariants(array $offers, array $settings): array + { + $expanded = []; + foreach ($offers as $offer) { + $paymentType = $this->normalizeOfferPaymentType($offer); + $baseSpeed = self::BASE_OFFER_SPEEDS[$paymentType] ?? self::BASE_OFFER_SPEEDS['fiat']; + $baseSpeedValue = (float) $baseSpeed['value']; + $baseSpeedUnit = (string) $baseSpeed['unit']; + $baseBonusPercent = $this->deriveBonusPercent($offer, $baseSpeedValue, $baseSpeedUnit); + $basePriceAmount = 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'] + : (is_numeric($offer['price_amount'] ?? null) ? (float) $offer['price_amount'] : null))); + + foreach (self::OFFER_SPEED_MULTIPLIERS as $multiplier) { + $derivedSpeedValue = $baseSpeedValue * $multiplier; + $derivedBonusValue = $baseBonusPercent !== null + ? round($derivedSpeedValue * ($baseBonusPercent / 100), 4) + : null; + $derivedPriceAmount = $basePriceAmount !== null + ? round($basePriceAmount * $multiplier, 8) + : null; + + $expanded[] = array_merge($offer, [ + 'id' => sprintf('%s:%s:%s', (string) ($offer['id'] ?? 'offer'), $paymentType, rtrim(rtrim(number_format($derivedSpeedValue, 4, '.', ''), '0'), '.')), + 'base_offer_id' => $offer['id'] ?? null, + 'base_offer_label' => $offer['label'] ?? null, + 'payment_type' => $paymentType, + 'label' => trim(((string) ($offer['label'] ?? 'Miner-Angebot')) . ' · ' . $this->formatSpeedLabel($derivedSpeedValue, $baseSpeedUnit)), + 'mining_speed_value' => $derivedSpeedValue, + 'mining_speed_unit' => $baseSpeedUnit, + 'bonus_speed_value' => $derivedBonusValue, + 'bonus_speed_unit' => $derivedBonusValue !== null ? $baseSpeedUnit : null, + 'bonus_percent' => $baseBonusPercent, + 'speed_factor' => $multiplier, + 'base_speed_value' => $baseSpeedValue, + 'base_speed_unit' => $baseSpeedUnit, + 'base_price_amount' => $derivedPriceAmount, + 'reference_price_amount' => $derivedPriceAmount, + ]); + } + } + + usort($expanded, static function (array $left, array $right): int { + $paymentCompare = strcmp((string) ($left['payment_type'] ?? ''), (string) ($right['payment_type'] ?? '')); + if ($paymentCompare !== 0) { + return $paymentCompare; + } + + $runtimeCompare = ((int) ($left['runtime_months'] ?? 0)) <=> ((int) ($right['runtime_months'] ?? 0)); + if ($runtimeCompare !== 0) { + return $runtimeCompare; + } + + return ((int) ($left['speed_factor'] ?? 0)) <=> ((int) ($right['speed_factor'] ?? 0)); + }); + + return $expanded; + } + + private function normalizeOfferPaymentType(array $offer): string + { + $paymentType = strtolower(trim((string) ($offer['payment_type'] ?? ''))); + if (in_array($paymentType, ['fiat', 'crypto'], true)) { + return $paymentType; + } + + return !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'; + } + + private function deriveBonusPercent(array $offer, float $speedValue, string $speedUnit): ?float + { + if (is_numeric($offer['bonus_percent'] ?? null)) { + $numeric = (float) $offer['bonus_percent']; + return $numeric >= 0 ? $numeric : null; + } + + $baseSpeedMh = $this->normalizeHashrateMh($speedValue, $speedUnit); + $bonusMh = $this->normalizeHashrateMh($offer['bonus_speed_value'] ?? null, $offer['bonus_speed_unit'] ?? null); + if ($baseSpeedMh <= 0 || $bonusMh <= 0) { + return null; + } + + return ($bonusMh / $baseSpeedMh) * 100; + } + + private function formatSpeedLabel(float $value, string $unit): string + { + return rtrim(rtrim(number_format($value, 4, '.', ''), '0'), '.') . ' ' . $unit; + } + + private function evaluateMinerOffer(array $offer, array $latest, array $latestPriceByCurrency, float $currentHashrateMh, array $settings): array + { + $offerHashrateMh = $this->normalizeHashrateMh($offer['mining_speed_value'] ?? null, $offer['mining_speed_unit'] ?? null) + + $this->normalizeHashrateMh($offer['bonus_speed_value'] ?? null, $offer['bonus_speed_unit'] ?? null); + + $paymentType = (string) ($offer['payment_type'] ?? ''); + if ($paymentType === '') { + $paymentType = !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'; + } + $basePriceAmount = 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'] + : (is_numeric($offer['price_amount'] ?? null) ? (float) $offer['price_amount'] : null))); + $basePriceCurrency = (string) ($offer['base_price_currency'] ?? $offer['reference_price_currency'] ?? (is_numeric($offer['usd_reference_amount'] ?? null) ? 'USD' : ($offer['price_currency'] ?? ''))); + $effectivePriceCurrency = $paymentType === 'crypto' + ? (string) ($settings['crypto_currency'] ?? 'DOGE') + : $basePriceCurrency; + $effectivePriceAmount = $basePriceAmount; + if ($basePriceAmount !== null && $basePriceAmount > 0 && $basePriceCurrency !== '' && $effectivePriceCurrency !== '' && strtoupper($basePriceCurrency) !== strtoupper($effectivePriceCurrency)) { + $convertedReference = $this->convertAmount($basePriceAmount, $basePriceCurrency, $effectivePriceCurrency, $latest); + if ($convertedReference !== null && $convertedReference > 0) { + $effectivePriceAmount = $convertedReference; + } else { + $effectivePriceAmount = null; + } + } + $referencePriceAmount = $basePriceAmount; + $referencePriceCurrency = $basePriceCurrency !== '' ? $basePriceCurrency : null; + + $expectedDogePerDay = null; + if ($currentHashrateMh > 0 && $offerHashrateMh > 0 && is_numeric($latest['doge_per_day_interval'] ?? null)) { + $expectedDogePerDay = ((float) $latest['doge_per_day_interval'] / $currentHashrateMh) * $offerHashrateMh; + } + + $offerCurrencyPrice = $effectivePriceCurrency !== '' ? ($latestPriceByCurrency[$effectivePriceCurrency] ?? $this->convertLatestPrice($latestPriceByCurrency, $effectivePriceCurrency, $latest)) : null; + $expectedDailyRevenue = ($expectedDogePerDay !== null && $offerCurrencyPrice !== null) + ? $expectedDogePerDay * $offerCurrencyPrice + : null; + $breakEvenDays = ($effectivePriceAmount !== null && $expectedDailyRevenue !== null && $expectedDailyRevenue > 0) + ? $effectivePriceAmount / $expectedDailyRevenue + : null; + + $recommendation = 'keine Basis'; + if ($breakEvenDays !== null) { + if ($breakEvenDays <= 180) { + $recommendation = 'lohnt eher'; + } elseif ($breakEvenDays <= 365) { + $recommendation = 'abwaegen'; + } else { + $recommendation = 'eher warten'; + } + } + + return array_merge($offer, [ + 'payment_type' => $paymentType, + 'base_price_amount' => $this->roundOrNull($basePriceAmount, 8), + 'base_price_currency' => $basePriceCurrency !== '' ? $basePriceCurrency : null, + 'offer_hashrate_mh' => $this->roundOrNull($offerHashrateMh, 4), + 'effective_price_amount' => $this->roundOrNull($effectivePriceAmount, 8), + 'effective_price_currency' => $effectivePriceCurrency, + 'reference_price_amount' => $this->roundOrNull($referencePriceAmount, 8), + 'reference_price_currency' => $referencePriceCurrency !== '' ? $referencePriceCurrency : null, + 'expected_doge_per_day' => $this->roundOrNull($expectedDogePerDay, 6), + 'expected_daily_revenue' => $this->roundOrNull($expectedDailyRevenue, 8), + 'break_even_days' => $this->roundOrNull($breakEvenDays, 2), + 'recommendation' => $recommendation, + ]); + } + + private function enrichOfferScenario(array $offer, array $latest, float $currentHashrateMh, array $costPlans, array $purchasedMiners): array + { + $latestCurrency = (string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''); + $currentDogePerDay = is_numeric($latest['doge_per_day_interval'] ?? null) ? (float) $latest['doge_per_day_interval'] : null; + $currentDailyProfit = is_numeric($latest['theoretical_daily_profit'] ?? null) ? (float) $latest['theoretical_daily_profit'] : null; + $currentDailyCost = is_numeric($latest['effective_daily_cost'] ?? null) ? (float) $latest['effective_daily_cost'] : null; + $investedCapital = is_numeric($latest['invested_capital'] ?? null) ? (float) $latest['invested_capital'] : null; + $effectivePricePerCoin = is_numeric($latest['effective_price_per_coin'] ?? null) ? (float) $latest['effective_price_per_coin'] : null; + $expectedDogePerDay = is_numeric($offer['expected_doge_per_day'] ?? null) ? (float) $offer['expected_doge_per_day'] : null; + $offerPriceAmount = is_numeric($offer['effective_price_amount'] ?? null) ? (float) $offer['effective_price_amount'] : null; + $offerPriceCurrency = (string) ($offer['effective_price_currency'] ?? ''); + + $scenarioOfferCost = ( + $offerPriceAmount !== null && + $offerPriceCurrency !== '' && + $latestCurrency !== '' + ) ? $this->convertAmount($offerPriceAmount, $offerPriceCurrency, $latestCurrency, $latest) : null; + $scenarioDogePerDay = ( + $currentDogePerDay !== null && + $expectedDogePerDay !== null + ) ? ($currentDogePerDay + $expectedDogePerDay) : null; + $scenarioDailyRevenue = ( + $scenarioDogePerDay !== null && + $effectivePricePerCoin !== null + ) ? ($scenarioDogePerDay * $effectivePricePerCoin) : null; + $scenarioDailyProfit = ( + $scenarioDailyRevenue !== null && + $currentDailyCost !== null + ) ? ($scenarioDailyRevenue - $currentDailyCost) : null; + $scenarioInvestedCapital = ( + $investedCapital !== null && + $scenarioOfferCost !== null + ) ? ($investedCapital + $scenarioOfferCost) : null; + $scenarioRemainingAmount = $scenarioInvestedCapital; + $scenarioBreakEvenDays = ( + $scenarioInvestedCapital !== null && + $scenarioDailyRevenue !== null && + $scenarioDailyRevenue > 0 + ) ? ($scenarioInvestedCapital / $scenarioDailyRevenue) : null; + $scenarioDate = null; + $measuredTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? '')); + if ($measuredTs > 0 && $scenarioBreakEvenDays !== null) { + $scenarioDate = $this->formatUtcTimestamp((int) round($measuredTs + ($scenarioBreakEvenDays * 86400))); + } + + $scenarioPurchasedMiners = $purchasedMiners; + if ($scenarioOfferCost !== null && $latestCurrency !== '') { + $scenarioPurchasedMiners[] = [ + 'purchased_at' => $latest['measured_at'] ?? $this->currentUtcDateTime(), + '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' => $scenarioOfferCost, + 'currency' => $latestCurrency, + 'auto_renew' => !empty($offer['auto_renew']) ? 1 : 0, + 'is_active' => 1, + ]; + } + + $currentProjection = $this->projectPerformance($costPlans, $purchasedMiners, $latest, 730); + $scenarioProjection = $this->projectPerformance($costPlans, $scenarioPurchasedMiners, $latest, 730); + + return array_merge($offer, [ + 'scenario_currency' => $latestCurrency !== '' ? $latestCurrency : null, + 'scenario_current_hashrate_mh' => $this->roundOrNull($currentHashrateMh, 4), + 'scenario_hashrate_mh' => $this->roundOrNull($currentHashrateMh + (float) ($offer['offer_hashrate_mh'] ?? 0), 4), + 'scenario_current_doge_per_day' => $this->roundOrNull($currentDogePerDay, 6), + 'scenario_doge_per_day' => $this->roundOrNull($scenarioDogePerDay, 6), + 'scenario_current_daily_profit' => $this->roundOrNull($currentDailyProfit, 8), + 'scenario_daily_profit' => $this->roundOrNull($scenarioDailyProfit, 8), + 'scenario_daily_profit_delta' => ( + $scenarioDailyProfit !== null && + $currentDailyProfit !== null + ) ? $this->roundOrNull($scenarioDailyProfit - $currentDailyProfit, 8) : null, + 'scenario_current_invested_capital' => $this->roundOrNull($investedCapital, 8), + 'scenario_invested_capital' => $this->roundOrNull($scenarioInvestedCapital, 8), + 'scenario_offer_cost' => $this->roundOrNull($scenarioOfferCost, 8), + 'scenario_break_even_remaining_amount' => $this->roundOrNull($scenarioRemainingAmount, 8), + 'scenario_break_even_days' => $this->roundOrNull($scenarioBreakEvenDays, 4), + 'scenario_break_even_date' => $scenarioDate, + 'scenario_projection_days' => $scenarioProjection['days'], + 'scenario_current_two_year_profit' => $this->roundOrNull($currentProjection['profit'], 8), + 'scenario_two_year_profit' => $this->roundOrNull($scenarioProjection['profit'], 8), + 'scenario_two_year_profit_delta' => ( + $scenarioProjection['profit'] !== null && + $currentProjection['profit'] !== null + ) ? $this->roundOrNull($scenarioProjection['profit'] - $currentProjection['profit'], 8) : null, + 'scenario_two_year_revenue' => $this->roundOrNull($scenarioProjection['revenue'], 8), + 'scenario_two_year_cost' => $this->roundOrNull($scenarioProjection['cost'], 8), + ]); + } + + private function projectPerformance(array $costPlans, array $purchasedMiners, array $latest, int $days): array + { + $currency = strtoupper(trim((string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''))); + $pricePerCoin = is_numeric($latest['effective_price_per_coin'] ?? null) ? (float) $latest['effective_price_per_coin'] : null; + $dogePerDay = is_numeric($latest['doge_per_day_interval'] ?? null) ? (float) $latest['doge_per_day_interval'] : null; + $currentHashrateMh = $this->totalHashrateMh(array_merge($costPlans, $purchasedMiners)); + $baseTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? '')); + if ($baseTs <= 0) { + $baseTs = $this->utcTimestamp($this->currentUtcDateTime()); + } + + if ($currency === '' || $pricePerCoin === null || $dogePerDay === null || $currentHashrateMh <= 0 || $days <= 0) { + return ['days' => $days, 'revenue' => null, 'cost' => null, 'profit' => null]; + } + + $dogePerDayPerMh = $dogePerDay / $currentHashrateMh; + $revenue = 0.0; + $cost = 0.0; + $hashrateDayTotal = 0.0; + + foreach ($costPlans as $plan) { + if (empty($plan['is_active'])) { + continue; + } + + $coveredDays = $this->entryCoveredDayCount($plan, $baseTs, $days); + if ($coveredDays <= 0) { + continue; + } + + $entryHashrateMh = $this->normalizeHashrateMh($plan['mining_speed_value'] ?? null, $plan['mining_speed_unit'] ?? null) + + $this->normalizeHashrateMh($plan['bonus_speed_value'] ?? null, $plan['bonus_speed_unit'] ?? null); + $hashrateDayTotal += $entryHashrateMh * $coveredDays; + + $runtimeMonths = (int) ($plan['runtime_months'] ?? 0); + if ($runtimeMonths > 0 && is_numeric($plan['total_cost_amount'] ?? null)) { + $runtimeDays = $runtimeMonths * 30.4375; + $dailyCost = $this->convertAmount((float) $plan['total_cost_amount'] / $runtimeDays, (string) ($plan['currency'] ?? ''), $currency, $latest); + if ($dailyCost !== null) { + $cost += $dailyCost * $coveredDays; + } + } + } + + foreach ($purchasedMiners as $miner) { + if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) { + continue; + } + + $coveredDays = $this->entryCoveredDayCount($miner, $baseTs, $days, 'purchased_at'); + if ($coveredDays <= 0) { + continue; + } + + $entryHashrateMh = $this->normalizeHashrateMh($miner['mining_speed_value'] ?? null, $miner['mining_speed_unit'] ?? null) + + $this->normalizeHashrateMh($miner['bonus_speed_value'] ?? null, $miner['bonus_speed_unit'] ?? null); + $hashrateDayTotal += $entryHashrateMh * $coveredDays; + + $runtimeMonths = (int) ($miner['runtime_months'] ?? 0); + if ($runtimeMonths > 0 && is_numeric($miner['total_cost_amount'] ?? null)) { + $runtimeDays = $runtimeMonths * 30.4375; + $dailyCost = $this->convertAmount((float) $miner['total_cost_amount'] / $runtimeDays, (string) ($miner['currency'] ?? ''), $currency, $latest); + if ($dailyCost !== null) { + $cost += $dailyCost * $coveredDays; + } + } + } + + $revenue = $hashrateDayTotal * $dogePerDayPerMh * $pricePerCoin; + + return [ + 'days' => $days, + 'revenue' => $revenue, + 'cost' => $cost, + 'profit' => $revenue - $cost, + ]; + } + + private function entryCoveredDayCount(array $entry, int $baseTs, int $days, string $startField = 'starts_at'): int + { + if ($days <= 0) { + return 0; + } + + $runtimeMonths = (int) ($entry['runtime_months'] ?? 0); + $startTs = $this->utcTimestamp((string) ($entry[$startField] ?? '')); + if ($startTs <= 0) { + return $days; + } + + $startIndex = max(0, (int) ceil(($startTs - $baseTs) / 86400)); + if ($startIndex >= $days) { + return 0; + } + + if (!empty($entry['auto_renew']) || $runtimeMonths <= 0) { + return $days - $startIndex; + } + + $runtimeDays = $runtimeMonths * 30.4375; + $endTs = (int) round($startTs + ($runtimeDays * 86400)); + $endIndex = (int) floor(($endTs - $baseTs) / 86400); + $endIndex = min($days - 1, $endIndex); + if ($endIndex < $startIndex) { + return 0; + } + + return $endIndex - $startIndex + 1; + } + + private function walletBalances(array $payouts, array $purchasedMiners, int $measurementTs): array + { + $balances = []; + + foreach ($payouts as $payout) { + $payoutTs = $this->utcTimestamp((string) ($payout['payout_at'] ?? '')); + if ($measurementTs > 0 && $payoutTs > $measurementTs) { + continue; + } + + $currency = strtoupper(trim((string) ($payout['payout_currency'] ?? ''))); + $amount = is_numeric($payout['coins_amount'] ?? null) ? (float) $payout['coins_amount'] : null; + if ($currency === '' || $amount === null) { + continue; + } + + $balances[$currency] = ($balances[$currency] ?? 0.0) + $amount; + } + + foreach ($purchasedMiners as $miner) { + $purchaseTs = $this->utcTimestamp((string) ($miner['purchased_at'] ?? '')); + if ($measurementTs > 0 && $purchaseTs > $measurementTs) { + continue; + } + if ($this->entryFundingSource($miner) !== 'reinvest') { + continue; + } + + $currency = strtoupper(trim((string) ($miner['currency'] ?? ''))); + $amount = is_numeric($miner['total_cost_amount'] ?? null) ? (float) $miner['total_cost_amount'] : null; + if ($currency === '' || $amount === null) { + continue; + } + + $balances[$currency] = ($balances[$currency] ?? 0.0) - $amount; + } + + ksort($balances); + return array_map(fn (float $value): float => round($value, 8), $balances); + } + + private function latestWalletSnapshot(array $walletSnapshots): ?array + { + foreach ($walletSnapshots as $snapshot) { + if (is_array($snapshot)) { + return $snapshot; + } + } + + return null; + } + + private function walletSnapshotBalances(array $snapshot): array + { + $balances = []; + $rawBalances = is_array($snapshot['balances_json'] ?? null) ? $snapshot['balances_json'] : []; + foreach ($rawBalances as $code => $asset) { + $normalizedCode = strtoupper(trim((string) $code)); + if ($normalizedCode === '') { + continue; + } + + $balance = is_array($asset) + ? ($asset['balance'] ?? null) + : $asset; + if (!is_numeric($balance)) { + continue; + } + + $balances[$normalizedCode] = round((float) $balance, 8); + } + + ksort($balances); + return $balances; + } + + private function walletSnapshotValue(array $snapshot, string $targetCurrency, ?array $fxContext = null): ?float + { + $target = strtoupper(trim($targetCurrency)); + if ($target === '') { + return null; + } + + $reportedTotal = is_numeric($snapshot['total_value_amount'] ?? null) + ? (float) $snapshot['total_value_amount'] + : null; + $reportedCurrency = strtoupper(trim((string) ($snapshot['total_value_currency'] ?? ''))); + if ($reportedTotal !== null && $reportedTotal >= 0 && $reportedCurrency !== '') { + $convertedTotal = $reportedCurrency === $target + ? $reportedTotal + : $this->convertAmount($reportedTotal, $reportedCurrency, $target, $fxContext); + if ($convertedTotal !== null) { + return $convertedTotal; + } + } + + $balances = is_array($snapshot['balances_json'] ?? null) ? $snapshot['balances_json'] : []; + if ($balances === []) { + return null; + } + + $total = 0.0; + $matched = false; + foreach ($balances as $code => $asset) { + $currency = strtoupper(trim((string) $code)); + if ($currency === '') { + continue; + } + + $balance = is_array($asset) ? ($asset['balance'] ?? null) : $asset; + if (!is_numeric($balance)) { + continue; + } + + $numericBalance = (float) $balance; + if ($currency === $target) { + $total += $numericBalance; + $matched = true; + continue; + } + + $snapshotPrice = is_array($asset) && is_numeric($asset['price_amount'] ?? null) + ? (float) $asset['price_amount'] + : null; + $snapshotPriceCurrency = is_array($asset) + ? strtoupper(trim((string) ($asset['price_currency'] ?? ''))) + : ''; + + if ($snapshotPrice !== null && $snapshotPrice > 0 && $snapshotPriceCurrency !== '') { + $convertedPrice = $snapshotPriceCurrency === $target + ? $snapshotPrice + : $this->convertAmount($snapshotPrice, $snapshotPriceCurrency, $target, $fxContext); + if ($convertedPrice !== null) { + $total += $numericBalance * $convertedPrice; + $matched = true; + continue; + } + } + + $convertedBalance = $this->convertAmount($numericBalance, $currency, $target, $fxContext); + if ($convertedBalance !== null) { + $total += $convertedBalance; + $matched = true; + } + } + + return $matched ? $total : null; + } + + private function walletBalanceValue(array $balances, string $targetCurrency, ?array $fxContext = null): ?float + { + $target = strtoupper(trim($targetCurrency)); + if ($target === '' || $balances === []) { + return null; + } + + $total = 0.0; + $matched = false; + foreach ($balances as $currency => $amount) { + if (!is_numeric($amount)) { + continue; + } + $numericAmount = (float) $amount; + if (strtoupper((string) $currency) === $target) { + $matched = true; + $total += $numericAmount; + continue; + } + + $converted = $this->convertAmount($numericAmount, (string) $currency, $target, $fxContext); + if ($converted === null) { + continue; + } + + $matched = true; + $total += $converted; + } + + return $matched ? $total : null; + } + + private function entryFundingSource(array $entry): string + { + return !empty($entry['auto_renew']) ? 'cash' : 'reinvest'; + } + + private function investmentBasisAmount(array $entry): ?float + { + if (is_numeric($entry['reference_price_amount'] ?? null)) { + return (float) $entry['reference_price_amount']; + } + if (is_numeric($entry['base_price_amount'] ?? null)) { + return (float) $entry['base_price_amount']; + } + if (is_numeric($entry['total_cost_amount'] ?? null)) { + return (float) $entry['total_cost_amount']; + } + return null; + } + + private function projectBreakEvenDate(array $costPlans, array $purchasedMiners, array $latest, float $remainingAmount): array + { + if ($remainingAmount <= 0) { + return ['days' => 0.0, 'eta' => (string) ($latest['measured_at'] ?? null)]; + } + + $currency = strtoupper(trim((string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''))); + $pricePerCoin = is_numeric($latest['effective_price_per_coin'] ?? null) ? (float) $latest['effective_price_per_coin'] : null; + $dogePerDay = is_numeric($latest['doge_per_day_interval'] ?? null) ? (float) $latest['doge_per_day_interval'] : null; + $currentHashrateMh = $this->totalHashrateMh(array_merge($costPlans, $purchasedMiners)); + $baseTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? '')); + + if ($currency === '' || $pricePerCoin === null || $dogePerDay === null || $currentHashrateMh <= 0 || $baseTs <= 0) { + return ['days' => null, 'eta' => null]; + } + + $dogePerDayPerMh = $dogePerDay / $currentHashrateMh; + $maxDays = 3650; + $horizonTs = $baseTs + ($maxDays * 86400); + $entries = []; + + foreach ($costPlans as $plan) { + if (empty($plan['is_active'])) { + continue; + } + $entries[] = ['data' => $plan, 'start_field' => 'starts_at']; + } + + foreach ($purchasedMiners as $miner) { + if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) { + continue; + } + $entries[] = ['data' => $miner, 'start_field' => 'purchased_at']; + } + + $events = [$baseTs, $horizonTs]; + foreach ($entries as $entryMeta) { + $entry = $entryMeta['data']; + $startField = $entryMeta['start_field']; + $startTs = $this->utcTimestamp((string) ($entry[$startField] ?? '')); + if ($startTs > $baseTs && $startTs < $horizonTs) { + $events[] = $startTs; + } + + $endTs = $this->entryCoverageEndTimestamp($entry, $startField); + if ($endTs !== null) { + $afterEndTs = $endTs + 1; + if ($afterEndTs > $baseTs && $afterEndTs < $horizonTs) { + $events[] = $afterEndTs; + } + } + } + + $events = array_values(array_unique(array_map('intval', $events))); + sort($events, SORT_NUMERIC); + + $cumulativeRevenue = 0.0; + for ($index = 0, $maxIndex = count($events) - 1; $index < $maxIndex; $index++) { + $segmentStartTs = $events[$index]; + $segmentEndTs = $events[$index + 1]; + if ($segmentEndTs <= $segmentStartTs) { + continue; + } + + $segmentHashrate = 0.0; + foreach ($entries as $entryMeta) { + $entry = $entryMeta['data']; + $startField = $entryMeta['start_field']; + if (!$this->entryIsCovered($entry, $segmentStartTs, $startField)) { + continue; + } + + $segmentHashrate += $this->normalizeHashrateMh($entry['mining_speed_value'] ?? null, $entry['mining_speed_unit'] ?? null); + $segmentHashrate += $this->normalizeHashrateMh($entry['bonus_speed_value'] ?? null, $entry['bonus_speed_unit'] ?? null); + } + + if ($segmentHashrate <= 0) { + continue; + } + + $segmentDays = ($segmentEndTs - $segmentStartTs) / 86400; + if ($segmentDays <= 0) { + continue; + } + + $segmentRevenuePerDay = $segmentHashrate * $dogePerDayPerMh * $pricePerCoin; + if ($segmentRevenuePerDay <= 0) { + continue; + } + + $segmentRevenue = $segmentRevenuePerDay * $segmentDays; + if ($cumulativeRevenue + $segmentRevenue >= $remainingAmount) { + $remainingSegmentAmount = $remainingAmount - $cumulativeRevenue; + $segmentOffsetDays = $remainingSegmentAmount / $segmentRevenuePerDay; + $etaTs = (int) round($segmentStartTs + ($segmentOffsetDays * 86400)); + return [ + 'days' => round(($etaTs - $baseTs) / 86400, 4), + 'eta' => $this->formatUtcTimestamp($etaTs), + ]; + } + + $cumulativeRevenue += $segmentRevenue; + } + + return ['days' => null, 'eta' => null]; + } + + private function entryCoverageEndTimestamp(array $entry, string $startField = 'starts_at'): ?int + { + if (!empty($entry['auto_renew'])) { + return null; + } + + $runtimeMonths = (int) ($entry['runtime_months'] ?? 0); + if ($runtimeMonths <= 0) { + return null; + } + + $startTs = $this->utcTimestamp((string) ($entry[$startField] ?? '')); + if ($startTs <= 0) { + return null; + } + + $runtimeDays = $runtimeMonths * 30.4375; + return (int) round($startTs + ($runtimeDays * 86400)); + } + + private function utcTimestamp(?string $value): int + { + $normalized = trim((string) $value); + if ($normalized === '') { + return 0; + } + + $utc = new \DateTimeZone('UTC'); + $formats = ['Y-m-d H:i:s', 'Y-m-d H:i', \DateTimeInterface::ATOM]; + foreach ($formats as $format) { + $date = \DateTimeImmutable::createFromFormat($format, $normalized, $utc); + if ($date instanceof \DateTimeImmutable) { + return $date->getTimestamp(); + } + } + + try { + return (new \DateTimeImmutable($normalized, $utc))->getTimestamp(); + } catch (\Throwable) { + return 0; + } + } + + private function formatUtcTimestamp(int $timestamp): string + { + return (new \DateTimeImmutable('@' . $timestamp)) + ->setTimezone(new \DateTimeZone('UTC')) + ->format('Y-m-d H:i:s'); + } + + private function currentUtcDateTime(): string + { + return $this->formatUtcTimestamp(time()); + } +} diff --git a/modules/mining-checker/src/Domain/FxService.php b/modules/mining-checker/src/Domain/FxService.php new file mode 100644 index 00000000..089cc532 --- /dev/null +++ b/modules/mining-checker/src/Domain/FxService.php @@ -0,0 +1,950 @@ +repository = $repository; + $this->provider = trim(strtolower($provider)) !== '' ? trim(strtolower($provider)) : 'currencyapi'; + $this->apiBaseUrl = rtrim($apiBaseUrl, '/'); + $this->currenciesApiBaseUrl = rtrim($currenciesApiBaseUrl, '/'); + $this->apiKey = trim($apiKey); + $this->timeout = max(2, $timeout); + $this->cacheTtl = max(60, $cacheTtl); + $this->autoFetchOnMiss = $autoFetchOnMiss; + $this->debug = $debug; + } + + public function convert(?float $amount, ?string $from, ?string $to): ?float + { + return $this->convertAt($amount, $from, $to, null, null, null); + } + + public function convertAt(?float $amount, ?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float + { + if ($amount === null || $from === null || $to === null) { + return null; + } + + $normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null; + $shared = $this->sharedFxService(); + if ($shared !== null && $normalizedFetchId !== null) { + $snapshot = $this->snapshotByFetchId($normalizedFetchId, strtoupper(trim((string) $from)), [strtoupper(trim((string) $to))]); + if (is_array($snapshot)) { + $resolved = $this->resolveRateFromSnapshot($snapshot, strtoupper(trim((string) $from)), strtoupper(trim((string) $to))); + if ($resolved !== null) { + return $amount * $resolved; + } + } + } + + if ($shared !== null && method_exists($shared, 'convert')) { + $converted = $shared->convert($amount, $from, $to, $at, $windowMinutes); + return is_numeric($converted) ? (float) $converted : null; + } + + $rate = $this->rateAt($from, $to, $at, $windowMinutes, $normalizedFetchId); + return $rate === null ? null : $amount * $rate; + } + + public function rate(?string $from, ?string $to): ?float + { + return $this->rateAt($from, $to, null, null, null); + } + + public function rateAt(?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float + { + $base = strtoupper(trim((string) $from)); + $target = strtoupper(trim((string) $to)); + $normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null; + + if ($base === '' || $target === '') { + return null; + } + + if ($base === $target) { + return 1.0; + } + + $shared = $this->sharedFxService(); + if ($shared !== null && $normalizedFetchId !== null) { + $snapshot = $this->snapshotByFetchId($normalizedFetchId, $base, [$target]); + if (is_array($snapshot)) { + $resolved = $this->resolveRateFromSnapshot($snapshot, $base, $target); + if ($resolved !== null) { + return $resolved; + } + } + } + if ($shared !== null && method_exists($shared, 'findRate')) { + $resolved = $shared->findRate($from, $to, $at, $windowMinutes); + return is_array($resolved) && is_numeric($resolved['rate'] ?? null) ? (float) $resolved['rate'] : null; + } + + $cacheKey = implode(':', [$base, $target, $at ?? '', (string) ($windowMinutes ?? 0), (string) ($normalizedFetchId ?? 0)]); + if (array_key_exists($cacheKey, $this->memoryCache)) { + return $this->memoryCache[$cacheKey]; + } + + $stored = $this->storedRate($base, $target); + if ($stored !== null) { + $this->memoryCache[$cacheKey] = $stored; + return $stored; + } + + $cached = $this->readFileCache($cacheKey); + if ($cached !== null) { + $this->memoryCache[$cacheKey] = $cached; + return $cached; + } + + if (!$this->autoFetchOnMiss) { + return null; + } + + $rate = $this->fetchAndPersistRate($base, $target); + $this->memoryCache[$cacheKey] = $rate; + if ($rate !== null) { + $this->writeFileCache($cacheKey, $rate); + } + + return $rate; + } + + public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array + { + if ($fetchId <= 0) { + return null; + } + + $cacheKey = $this->snapshotCacheKey('fetch', [ + $fetchId, + strtoupper(trim((string) ($baseCurrency ?? ''))), + $this->normalizeSymbolsForCache($symbols), + ]); + if (array_key_exists($cacheKey, $this->snapshotCache)) { + return $this->snapshotCache[$cacheKey]; + } + + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'snapshotByFetchId')) { + $snapshot = $shared->snapshotByFetchId($fetchId, $baseCurrency, $symbols); + return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null); + } + + return $this->snapshotCache[$cacheKey] = null; + } + + public function latestSnapshot(?string $baseCurrency = null, ?array $symbols = null): ?array + { + $cacheKey = $this->snapshotCacheKey('latest', [ + strtoupper(trim((string) ($baseCurrency ?? ''))), + $this->normalizeSymbolsForCache($symbols), + ]); + if (array_key_exists($cacheKey, $this->snapshotCache)) { + return $this->snapshotCache[$cacheKey]; + } + + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'snapshot')) { + $snapshot = $shared->snapshot($baseCurrency, null, $symbols, null); + return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null); + } + + return $this->snapshotCache[$cacheKey] = null; + } + + public function nearestSnapshot(?string $baseCurrency, string $at, ?array $symbols = null, ?int $windowMinutes = null): ?array + { + $cacheKey = $this->snapshotCacheKey('nearest', [ + strtoupper(trim((string) ($baseCurrency ?? ''))), + trim($at), + $windowMinutes ?? 0, + $this->normalizeSymbolsForCache($symbols), + ]); + if (array_key_exists($cacheKey, $this->snapshotCache)) { + return $this->snapshotCache[$cacheKey]; + } + + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'nearestSnapshot')) { + $snapshot = $shared->nearestSnapshot($baseCurrency, $at, $symbols, $windowMinutes); + return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null); + } + + return $this->snapshotCache[$cacheKey] = null; + } + + public function refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array + { + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'refreshLatestRates')) { + return $shared->refreshLatestRates($currencies, $base); + } + + $normalizedBase = strtoupper(trim($base)); + $targets = $currencies === null + ? null + : array_values(array_unique(array_filter(array_map( + static fn ($code): string => strtoupper(trim((string) $code)), + $currencies + ), static fn (string $code): bool => $code !== '' && $code !== $normalizedBase))); + + $payload = $this->fetchLatestPayload($normalizedBase, $targets); + $rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : []; + $rateDate = $this->normalizeRateDate($payload['date'] ?? null); + $forwardRates = []; + foreach ($rates as $target => $rate) { + if (!is_numeric($rate)) { + continue; + } + $targetCode = strtoupper((string) $target); + if ($targetCode === '' || $targetCode === $normalizedBase) { + continue; + } + $forwardRates[$targetCode] = (float) $rate; + } + + $updated = $this->persistRateSet($normalizedBase, $forwardRates, $rateDate); + + return [ + 'base' => $normalizedBase, + 'rate_date' => $rateDate, + 'updated_count' => count($updated), + 'rates' => $updated, + ]; + } + + public function ensureFreshLatestRates(float $maxAgeHours = 3.0, string $base = 'USD'): array + { + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'ensureFreshLatestRates')) { + return $shared->ensureFreshLatestRates($maxAgeHours, $base, null); + } + + $normalizedBase = strtoupper(trim($base)); + $maxAgeHours = $maxAgeHours > 0 ? $maxAgeHours : 3.0; + + if ($this->repository === null) { + return $this->refreshLatestRates(null, $normalizedBase); + } + + $latestFetch = $this->repository->getLatestFxFetch($normalizedBase); + $latestFetchedAt = is_array($latestFetch) ? $this->parseStoredUtcTimestamp((string) ($latestFetch['fetched_at'] ?? '')) : null; + $ageSeconds = $latestFetchedAt !== null ? (time() - $latestFetchedAt) : null; + $maxAgeSeconds = (int) round($maxAgeHours * 3600); + + if ($ageSeconds !== null && $ageSeconds >= 0 && $ageSeconds <= $maxAgeSeconds) { + $this->debug?->add('fx.latest.reuse', [ + 'base' => $normalizedBase, + 'fetched_at' => $latestFetch['fetched_at'] ?? null, + 'age_seconds' => $ageSeconds, + 'max_age_seconds' => $maxAgeSeconds, + ]); + + return [ + 'base' => $normalizedBase, + 'rate_date' => $latestFetch['rate_date'] ?? null, + 'updated_count' => 0, + 'rates' => [], + 'reused' => true, + 'fetched_at' => $latestFetch['fetched_at'] ?? null, + ]; + } + + $this->debug?->add('fx.latest.refresh_required', [ + 'base' => $normalizedBase, + 'previous_fetched_at' => $latestFetch['fetched_at'] ?? null, + 'age_seconds' => $ageSeconds, + 'max_age_seconds' => $maxAgeSeconds, + ]); + + $result = $this->refreshLatestRates(null, $normalizedBase); + $result['reused'] = false; + return $result; + } + + public function probeLatestRates(string $base = 'EUR'): array + { + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'probeLatestRates')) { + return $shared->probeLatestRates($base); + } + + $normalizedBase = strtoupper(trim($base)); + return $this->fetchLatestProbe($normalizedBase); + } + + public function refreshCurrencyCatalog(): array + { + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'refreshCurrencyCatalog')) { + return $shared->refreshCurrencyCatalog(); + } + + $payload = $this->fetchCurrenciesPayload(); + $items = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : []; + if ($items === []) { + return [ + 'synced_count' => 0, + 'currencies' => [], + ]; + } + + $synced = []; + $sortOrder = 1000; + + foreach ($items as $code => $name) { + $normalizedCode = strtoupper(trim((string) $code)); + $normalizedName = trim((string) $name); + if ($normalizedCode === '' || $normalizedName === '') { + continue; + } + + $currency = [ + 'code' => substr($normalizedCode, 0, 10), + 'name' => function_exists('mb_substr') ? mb_substr($normalizedName, 0, 64) : substr($normalizedName, 0, 64), + 'symbol' => substr($normalizedCode, 0, 8), + 'is_active' => 1, + 'is_crypto' => $this->isCryptoCode($normalizedCode) ? 1 : 0, + 'sort_order' => $this->catalogSortOrder($normalizedCode, $sortOrder), + ]; + + $synced[] = $currency; + $sortOrder++; + } + + usort($synced, static function (array $left, array $right): int { + return [$left['sort_order'], $left['code']] <=> [$right['sort_order'], $right['code']]; + }); + + return [ + 'synced_count' => count($synced), + 'currencies' => $synced, + ]; + } + + public function probeCurrencyCatalog(): array + { + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'probeCurrencyCatalog')) { + return $shared->probeCurrencyCatalog(); + } + + return $this->fetchCurrenciesProbe(); + } + + private function fetchAndPersistRate(string $base, string $target): ?float + { + $payload = $this->fetchLatestPayload($base, [$target]); + $rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : []; + $rate = $rates[$target] ?? null; + if (!is_numeric($rate)) { + return null; + } + + $numericRate = (float) $rate; + $rateDate = $this->normalizeRateDate($payload['date'] ?? null); + $this->persistRateSet($base, [$target => $numericRate], $rateDate); + return $numericRate; + } + + private function fetchLatestPayload(string $base, ?array $targets = null): array + { + if (!function_exists('curl_init')) { + return []; + } + + $url = $this->buildLatestUrl($base, $targets); + if ($url === null) { + $this->debug?->add('fx.latest.skip', ['reason' => 'missing_url_or_key', 'base' => $base]); + return []; + } + + $this->debug?->add('fx.latest.request', [ + 'base' => $base, + 'url' => $this->maskUrl($url), + 'targets' => $targets, + ]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + + $response = curl_exec($ch); + $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + $this->debug?->add('fx.latest.response', [ + 'http_status' => $httpStatus, + 'curl_error' => $curlError, + 'response_bytes' => is_string($response) ? strlen($response) : 0, + 'response_preview' => is_string($response) ? substr($response, 0, 1200) : null, + ]); + + if ($response === false || $curlError !== '' || $httpStatus >= 400) { + return []; + } + + $payload = json_decode((string) $response, true); + return $this->normalizePayload($payload, $base, $targets); + } + + private function fetchLatestProbe(string $base): array + { + if (!function_exists('curl_init')) { + return ['ok' => false, 'message' => 'curl_init ist nicht verfuegbar.']; + } + + $url = $this->buildLatestUrl($base, null); + if ($url === null) { + return ['ok' => false, 'message' => 'FX-URL oder API-Key fehlt.']; + } + + $this->debug?->add('fx.latest.probe.request', [ + 'base' => $base, + 'url' => $this->maskUrl($url), + ]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + + $response = curl_exec($ch); + $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $curlError = curl_error($ch); + curl_close($ch); + + $rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : ''; + $body = is_string($response) ? substr($response, $headerSize) : ''; + + $result = [ + 'ok' => $response !== false && $curlError === '' && $httpStatus < 400, + 'url' => $this->maskUrl($url), + 'http_status' => $httpStatus, + 'curl_error' => $curlError, + 'response_headers' => $rawHeaders, + 'response_body' => substr($body, 0, 4000), + ]; + + $this->debug?->add('fx.latest.probe.response', $result); + return $result; + } + + private function fetchCurrenciesPayload(): array + { + if (!function_exists('curl_init') || $this->apiKey === '') { + return []; + } + + $url = sprintf( + '%s/api/v2/currencies?output=json&key=%s', + $this->currenciesApiBaseUrl, + rawurlencode($this->apiKey) + ); + + $this->debug?->add('fx.currencies.request', [ + 'url' => $this->maskUrl($url), + ]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + + $response = curl_exec($ch); + $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + $this->debug?->add('fx.currencies.response', [ + 'http_status' => $httpStatus, + 'curl_error' => $curlError, + 'response_bytes' => is_string($response) ? strlen($response) : 0, + 'response_preview' => is_string($response) ? substr($response, 0, 1200) : null, + ]); + + if ($response === false || $curlError !== '' || $httpStatus >= 400) { + return []; + } + + $payload = json_decode((string) $response, true); + if (!is_array($payload)) { + throw new \RuntimeException('Waehrungskatalog konnte nicht gelesen werden.'); + } + + if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) { + throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.')); + } + + return $payload; + } + + private function fetchCurrenciesProbe(): array + { + if (!function_exists('curl_init') || $this->apiKey === '') { + return ['ok' => false, 'message' => 'curl_init oder API-Key fehlt.']; + } + + $url = sprintf( + '%s/api/v2/currencies?output=json&key=%s', + $this->currenciesApiBaseUrl, + rawurlencode($this->apiKey) + ); + + $this->debug?->add('fx.currencies.probe.request', [ + 'url' => $this->maskUrl($url), + ]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + + $response = curl_exec($ch); + $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $curlError = curl_error($ch); + curl_close($ch); + + $rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : ''; + $body = is_string($response) ? substr($response, $headerSize) : ''; + + $result = [ + 'ok' => $response !== false && $curlError === '' && $httpStatus < 400, + 'url' => $this->maskUrl($url), + 'http_status' => $httpStatus, + 'curl_error' => $curlError, + 'response_headers' => $rawHeaders, + 'response_body' => substr($body, 0, 4000), + ]; + + $this->debug?->add('fx.currencies.probe.response', $result); + return $result; + } + + private function storedRate(string $base, string $target): ?float + { + if ($this->repository === null) { + return null; + } + + try { + $direct = $this->repository->getLatestFxRate($base, $target); + if (is_array($direct) && is_numeric($direct['rate'] ?? null)) { + return (float) $direct['rate']; + } + + $inverse = $this->repository->getLatestFxRate($target, $base); + if (is_array($inverse) && is_numeric($inverse['rate'] ?? null) && (float) $inverse['rate'] > 0) { + return 1 / (float) $inverse['rate']; + } + + $measurementRate = $this->repository->getLatestMeasurementRate($base, $target); + if (is_array($measurementRate) && is_numeric($measurementRate['rate'] ?? null)) { + return (float) $measurementRate['rate']; + } + + $inverseMeasurementRate = $this->repository->getLatestMeasurementRate($target, $base); + if ( + is_array($inverseMeasurementRate) && + is_numeric($inverseMeasurementRate['rate'] ?? null) && + (float) $inverseMeasurementRate['rate'] > 0 + ) { + return 1 / (float) $inverseMeasurementRate['rate']; + } + + foreach (['USD', 'EUR'] as $viaBase) { + if ($base === $viaBase || $target === $viaBase) { + continue; + } + + $fromVia = $this->repository->getLatestFxRate($viaBase, $base); + $toVia = $this->repository->getLatestFxRate($viaBase, $target); + if ( + is_array($fromVia) && is_numeric($fromVia['rate'] ?? null) && + is_array($toVia) && is_numeric($toVia['rate'] ?? null) && + (float) $fromVia['rate'] > 0 + ) { + return (float) $toVia['rate'] / (float) $fromVia['rate']; + } + + $fromViaInverse = $this->repository->getLatestFxRate($base, $viaBase); + $toViaInverse = $this->repository->getLatestFxRate($target, $viaBase); + if ( + is_array($fromViaInverse) && is_numeric($fromViaInverse['rate'] ?? null) && + is_array($toViaInverse) && is_numeric($toViaInverse['rate'] ?? null) && + (float) $toViaInverse['rate'] > 0 + ) { + return (1 / (float) $fromViaInverse['rate']) / (1 / (float) $toViaInverse['rate']); + } + } + } catch (\Throwable) { + return null; + } + + return null; + } + + private function persistRateSet(string $base, array $rates, string $rateDate): array + { + $normalizedBase = strtoupper($base); + $normalizedRates = []; + foreach ($rates as $target => $rate) { + if (!is_numeric($rate)) { + continue; + } + + $normalizedTarget = strtoupper((string) $target); + $normalizedRates[$normalizedTarget] = (float) $rate; + $this->memoryCache[$normalizedBase . ':' . $normalizedTarget] = (float) $rate; + $this->writeFileCache($normalizedBase . ':' . $normalizedTarget, (float) $rate); + } + + if ($this->repository === null) { + $result = []; + foreach ($normalizedRates as $target => $rate) { + $result[] = [ + 'base_currency' => $normalizedBase, + 'target_currency' => $target, + 'rate' => $rate, + 'rate_date' => $rateDate, + 'provider' => $this->provider, + ]; + } + return $result; + } + + try { + $saved = $this->repository->saveFxFetch($normalizedBase, $this->provider, $rateDate, $normalizedRates); + return is_array($saved['rates'] ?? null) ? $saved['rates'] : []; + } catch (\Throwable) { + $result = []; + foreach ($normalizedRates as $target => $rate) { + $result[] = [ + 'base_currency' => $normalizedBase, + 'target_currency' => $target, + 'rate' => $rate, + 'rate_date' => $rateDate, + 'provider' => $this->provider, + ]; + } + return $result; + } + } + + private function buildLatestUrl(string $base, ?array $targets = null): ?string + { + if ($this->provider === 'currencyapi') { + if ($this->apiKey === '') { + return null; + } + + return sprintf( + '%s/api/v2/rates?base=%s&output=json&key=%s', + $this->apiBaseUrl, + rawurlencode($base), + rawurlencode($this->apiKey) + ); + } + + $targets = $targets ?? $this->defaultCurrencies(); + return sprintf( + '%s/latest?base=%s&symbols=%s', + $this->apiBaseUrl, + rawurlencode($base), + rawurlencode(implode(',', $targets)) + ); + } + + private function normalizePayload(mixed $payload, string $base, ?array $targets = null): array + { + if (!is_array($payload)) { + return []; + } + + if ($this->provider === 'currencyapi') { + if (($payload['valid'] ?? false) !== true || !is_array($payload['rates'] ?? null)) { + throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.')); + } + + $allRates = $payload['rates']; + $filteredRates = []; + if ($targets === null) { + foreach ($allRates as $target => $rate) { + $targetCode = strtoupper((string) $target); + if ($targetCode === $base || !is_numeric($rate)) { + continue; + } + $filteredRates[$targetCode] = (float) $rate; + } + } else { + foreach ($targets as $target) { + $targetCode = strtoupper((string) $target); + if ($targetCode === $base) { + continue; + } + + $rate = $allRates[$targetCode] ?? null; + if (is_numeric($rate)) { + $filteredRates[$targetCode] = (float) $rate; + } + } + } + + return [ + 'base' => strtoupper((string) ($payload['base'] ?? $base)), + 'date' => $payload['updated'] ?? null, + 'rates' => $filteredRates, + ]; + } + + if (!is_array($payload['rates'] ?? null)) { + return []; + } + + if (array_key_exists('success', $payload) && $payload['success'] !== true) { + return []; + } + + return $payload; + } + + private function extractProviderError(array $payload, string $fallback): string + { + foreach (['error', 'message', 'msg'] as $field) { + $value = $payload[$field] ?? null; + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + } + + $errors = $payload['errors'] ?? null; + if (is_array($errors)) { + $flat = []; + array_walk_recursive($errors, static function ($value) use (&$flat): void { + if (is_string($value) && trim($value) !== '') { + $flat[] = trim($value); + } + }); + if ($flat !== []) { + return implode(' | ', array_values(array_unique($flat))); + } + } + + return $fallback; + } + + private function defaultCurrencies(): array + { + return ['EUR', 'USD']; + } + + private function normalizeRateDate(mixed $value): string + { + if (is_int($value) || is_float($value) || (is_string($value) && ctype_digit(trim($value)))) { + $timestamp = (int) $value; + if ($timestamp > 0) { + return date('Y-m-d', $timestamp); + } + } + + if (is_string($value) && trim($value) !== '') { + $timestamp = strtotime($value); + if ($timestamp !== false) { + return date('Y-m-d', $timestamp); + } + + if (preg_match('/^\d{4}-\d{2}-\d{2}/', $value, $matches) === 1) { + return $matches[0]; + } + } + + return date('Y-m-d'); + } + + private function parseStoredUtcTimestamp(string $value): ?int + { + $normalized = trim($value); + if ($normalized === '') { + return null; + } + + try { + if (preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $normalized) === 1) { + $date = new \DateTimeImmutable(str_replace(' ', 'T', $normalized), new \DateTimeZone('UTC')); + } else { + $date = new \DateTimeImmutable($normalized); + } + return $date->setTimezone(new \DateTimeZone('UTC'))->getTimestamp(); + } catch (\Throwable) { + return null; + } + } + + private function catalogSortOrder(string $code, int $fallback): int + { + return match (strtoupper($code)) { + 'EUR' => 10, + 'USD' => 20, + 'DOGE' => 30, + 'BTC' => 40, + 'ETH' => 50, + 'USDT' => 60, + 'USDC' => 70, + default => $fallback, + }; + } + + private function isCryptoCode(string $code): bool + { + return in_array(strtoupper($code), [ + 'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC', + 'SOL', 'USDC', 'USDT', 'XRP', + ], true); + } + + private function cacheFile(string $cacheKey): string + { + return rtrim(sys_get_temp_dir(), '/') . '/mining-checker-fx-' . md5($cacheKey) . '.json'; + } + + private function readFileCache(string $cacheKey): ?float + { + $file = $this->cacheFile($cacheKey); + if (!is_file($file) || (time() - filemtime($file)) > $this->cacheTtl) { + return null; + } + + $payload = json_decode((string) file_get_contents($file), true); + $rate = $payload['rate'] ?? null; + return is_numeric($rate) ? (float) $rate : null; + } + + private function writeFileCache(string $cacheKey, float $rate): void + { + @file_put_contents($this->cacheFile($cacheKey), json_encode([ + 'rate' => $rate, + 'cached_at' => time(), + ], JSON_UNESCAPED_UNICODE)); + } + + private function maskUrl(string $url): string + { + return preg_replace_callback('/([?&]key=)([^&]+)/i', static function (array $matches): string { + $key = $matches[2] ?? ''; + if (strlen($key) <= 8) { + return $matches[1] . $key; + } + + return $matches[1] . substr($key, 0, 6) . '...' . substr($key, -4); + }, $url) ?: $url; + } + + private function sharedFxService(): ?object + { + if (!function_exists('modules') || !modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'service')) { + return null; + } + + try { + $service = module_fn('fx-rates', 'service'); + return is_object($service) ? $service : null; + } catch (\Throwable) { + return null; + } + } + + private function resolveRateFromSnapshot(array $snapshot, string $from, string $to): ?float + { + $base = strtoupper(trim((string) ($snapshot['base_currency'] ?? ''))); + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + + if ($base === '' || $from === '' || $to === '') { + return null; + } + + if ($base === $from && is_numeric($rates[$to] ?? null)) { + return (float) $rates[$to]; + } + + if ($base === $to && is_numeric($rates[$from] ?? null) && (float) $rates[$from] > 0) { + return 1 / (float) $rates[$from]; + } + + if (is_numeric($rates[$from] ?? null) && is_numeric($rates[$to] ?? null) && (float) $rates[$from] > 0) { + return (float) $rates[$to] / (float) $rates[$from]; + } + + return null; + } + + private function snapshotCacheKey(string $prefix, array $parts): string + { + return $prefix . ':' . implode(':', array_map(static fn (mixed $part): string => (string) $part, $parts)); + } + + private function normalizeSymbolsForCache(?array $symbols): string + { + if (!is_array($symbols) || $symbols === []) { + return '*'; + } + + $normalized = array_values(array_unique(array_filter(array_map( + static fn (mixed $symbol): string => strtoupper(trim((string) $symbol)), + $symbols + )))); + sort($normalized); + return implode(',', $normalized); + } +} diff --git a/modules/mining-checker/src/Domain/OcrService.php b/modules/mining-checker/src/Domain/OcrService.php new file mode 100644 index 00000000..256e1ca4 --- /dev/null +++ b/modules/mining-checker/src/Domain/OcrService.php @@ -0,0 +1,665 @@ +config = $config; + } + + public function preview(array $file, array $input): array + { + if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + throw new ApiException('Screenshot-Upload fehlt oder ist fehlerhaft.', 422); + } + + $mime = mime_content_type($file['tmp_name']) ?: ''; + if (!in_array($mime, ['image/png', 'image/jpeg', 'image/webp'], true)) { + throw new ApiException('Nur PNG, JPEG und WEBP werden akzeptiert.', 422, ['mime' => $mime]); + } + + $projectKey = (string) ($input['project_key'] ?? $this->config->defaultProjectKey()); + $uploadDir = $this->resolveUploadDir($projectKey); + + $extension = pathinfo((string) ($file['name'] ?? 'upload.png'), PATHINFO_EXTENSION) ?: 'png'; + $filename = date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.' . strtolower($extension); + $targetFile = $uploadDir . '/' . $filename; + if (!move_uploaded_file($file['tmp_name'], $targetFile)) { + throw new ApiException('Bild konnte nicht gespeichert werden.', 500); + } + + $rawText = trim((string) ($input['ocr_hint_text'] ?? '')); + $flags = []; + + if ($rawText === '') { + ['text' => $rawText, 'flags' => $providerFlags] = $this->extractRawText($targetFile); + $flags = array_merge($flags, $providerFlags); + } else { + $flags[] = 'ocr_hint_text_used'; + } + + $parsed = $this->parseText( + $rawText, + (string) ($input['date_context'] ?? date('Y-m-d')), + strtoupper(trim((string) ($input['wallet_currency_hint'] ?? ''))) + ); + $parsed['image_path'] = $targetFile; + $parsed['raw_text'] = $rawText; + $parsed['flags'] = array_values(array_unique(array_merge($flags, $parsed['flags']))); + + return $parsed; + } + + private function resolveUploadDir(string $projectKey): string + { + $safeProjectKey = preg_replace('~[^a-zA-Z0-9_-]~', '-', $projectKey) ?: 'default'; + $candidates = [ + rtrim($this->config->uploadsDir(), '/') . '/' . $safeProjectKey, + rtrim(sys_get_temp_dir(), '/') . '/mining-checker/uploads/' . $safeProjectKey, + ]; + + foreach ($candidates as $candidate) { + if ($this->ensureWritableDirectory($candidate)) { + return $candidate; + } + } + + throw new ApiException('Upload-Verzeichnis konnte nicht erstellt werden.', 500, [ + 'candidates' => $candidates, + ]); + } + + private function ensureWritableDirectory(string $directory): bool + { + if (is_dir($directory)) { + return is_writable($directory); + } + + return @mkdir($directory, 0775, true) || is_dir($directory); + } + + private function extractRawText(string $imagePath): array + { + $ocrConfig = $this->config->ocr(); + $providers = $ocrConfig['providers'] ?? ['tesseract']; + $flags = []; + + if (!is_array($providers) || $providers === []) { + $providers = ['tesseract']; + } + + foreach ($providers as $provider) { + $providerName = strtolower(trim((string) $provider)); + if ($providerName === '') { + continue; + } + + if ($providerName === 'ocrspace') { + $result = $this->runOcrSpace((array) ($ocrConfig['ocrspace'] ?? []), $imagePath); + } elseif ($providerName === 'tesseract') { + $result = $this->runTesseract((array) ($ocrConfig['tesseract'] ?? []), $imagePath); + } else { + $flags[] = 'ocr_provider_unsupported:' . $providerName; + continue; + } + + $flags = array_merge($flags, $result['flags']); + if (($result['text'] ?? '') !== '') { + return [ + 'text' => (string) $result['text'], + 'flags' => array_values(array_unique(array_merge( + $flags, + ['ocr_provider:' . $providerName] + ))), + ]; + } + } + + return [ + 'text' => '', + 'flags' => array_values(array_unique(array_merge($flags, ['ocr_engine_missing']))), + ]; + } + + private function runOcrSpace(array $providerConfig, string $imagePath): array + { + if (!function_exists('curl_init') || !class_exists(\CURLFile::class)) { + return [ + 'text' => '', + 'flags' => ['ocr_provider_missing:ocrspace', 'ocr_transport_missing:curl'], + ]; + } + + $url = trim((string) ($providerConfig['url'] ?? '')); + if ($url === '') { + return [ + 'text' => '', + 'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_url_missing'], + ]; + } + + $apiKey = trim((string) ($providerConfig['api_key'] ?? '')); + if ($apiKey === '') { + return [ + 'text' => '', + 'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_api_key_missing'], + ]; + } + + $postFields = [ + 'file' => new \CURLFile($imagePath), + 'language' => (string) ($providerConfig['language'] ?? 'eng'), + 'OCREngine' => (string) ((int) ($providerConfig['engine'] ?? 2)), + 'scale' => (string) ($providerConfig['scale'] ?? 'true'), + 'detectOrientation' => (string) ($providerConfig['detect_orientation'] ?? 'true'), + 'isTable' => (string) ($providerConfig['is_table'] ?? 'false'), + 'isOverlayRequired' => 'false', + ]; + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $postFields, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => max(5, (int) ($providerConfig['timeout'] ?? 25)), + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + 'apikey: ' . $apiKey, + ], + ]); + + $response = curl_exec($ch); + $curlError = curl_error($ch); + $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($response === false || $curlError !== '') { + return [ + 'text' => '', + 'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_request_failed'], + ]; + } + + $payload = json_decode((string) $response, true); + if (!is_array($payload)) { + return [ + 'text' => '', + 'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_invalid_response'], + ]; + } + + $flags = []; + $rawText = ''; + $parsedResults = $payload['ParsedResults'] ?? null; + if (is_array($parsedResults)) { + $texts = []; + foreach ($parsedResults as $result) { + if (!is_array($result)) { + continue; + } + $fileExitCode = (string) ($result['FileParseExitCode'] ?? ''); + if ($fileExitCode !== '') { + $flags[] = 'ocrspace_file_exit_code:' . $fileExitCode; + } + $parsedText = trim((string) ($result['ParsedText'] ?? '')); + if ($parsedText !== '') { + $texts[] = $parsedText; + } + $resultError = trim((string) ($result['ErrorMessage'] ?? '')); + if ($resultError !== '') { + $flags[] = 'ocrspace_result_error'; + } + } + $rawText = trim(implode("\n", $texts)); + } + + $ocrExitCode = (string) ($payload['OCRExitCode'] ?? ''); + $isErroredOnProcessing = !empty($payload['IsErroredOnProcessing']); + $errorMessage = trim((string) ($payload['ErrorMessage'] ?? '')); + $errorDetails = trim((string) ($payload['ErrorDetails'] ?? '')); + + if ($httpStatus >= 400) { + $flags[] = 'ocrspace_http_error'; + } + + if ($ocrExitCode !== '') { + $flags[] = 'ocrspace_exit_code:' . $ocrExitCode; + } + + $flags[] = 'ocrspace_engine:' . (string) ((int) ($providerConfig['engine'] ?? 2)); + + if ($isErroredOnProcessing) { + $flags[] = 'ocrspace_processing_error'; + } + + if ($errorMessage !== '' || $errorDetails !== '') { + $flags[] = 'ocrspace_error'; + } + + return [ + 'text' => $rawText, + 'flags' => $rawText === '' ? array_values(array_unique(array_merge($flags, ['ocr_provider_empty:ocrspace']))) : array_values(array_unique($flags)), + ]; + } + + private function runTesseract(array $providerConfig, string $imagePath): array + { + $binary = (string) ($providerConfig['binary'] ?? 'tesseract'); + if (!$this->binaryExists($binary)) { + return [ + 'text' => '', + 'flags' => ['ocr_provider_missing:tesseract'], + ]; + } + + $language = (string) ($providerConfig['language'] ?? 'eng'); + $tmpBase = tempnam(sys_get_temp_dir(), 'mc-ocr-'); + if ($tmpBase === false) { + return [ + 'text' => '', + 'flags' => ['ocr_tempfile_failed:tesseract'], + ]; + } + + @unlink($tmpBase); + $command = sprintf( + '%s %s %s -l %s 2>/dev/null', + escapeshellcmd($binary), + escapeshellarg($imagePath), + escapeshellarg($tmpBase), + escapeshellarg($language) + ); + shell_exec($command); + + $txtFile = $tmpBase . '.txt'; + $text = is_file($txtFile) ? (string) file_get_contents($txtFile) : ''; + @unlink($txtFile); + return [ + 'text' => trim($text), + 'flags' => trim($text) === '' ? ['ocr_provider_empty:tesseract'] : [], + ]; + } + + private function binaryExists(string $binary): bool + { + return $binary !== '' && trim((string) shell_exec('command -v ' . escapeshellarg($binary) . ' 2>/dev/null')) !== ''; + } + + private function parseText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array + { + $measurement = $this->parseMeasurementText($rawText, $dateContext); + $wallet = $this->parseWalletText($rawText, $dateContext, $walletCurrencyHint); + + $isWallet = ($wallet['score'] ?? 0) > ($measurement['score'] ?? 0) + && ( + ($wallet['suggested_wallet']['wallet_balance'] ?? null) !== null + || ($wallet['suggested_wallet']['total_value_amount'] ?? null) !== null + ); + + return [ + 'kind' => $isWallet ? 'wallet' : 'measurement', + 'suggested' => $measurement['suggested'], + 'suggested_wallet' => $wallet['suggested_wallet'], + 'confidence' => round((float) ($isWallet ? ($wallet['confidence'] ?? 0.0) : ($measurement['confidence'] ?? 0.0)), 4), + 'flags' => $isWallet ? $wallet['flags'] : $measurement['flags'], + ]; + } + + private function parseMeasurementText(string $rawText, string $dateContext): array + { + $flags = []; + $suggestedTime = null; + $coinsTotal = null; + $price = null; + $currency = null; + $normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: ''; + $lines = array_values(array_filter(array_map( + static fn (string $line): string => trim($line), + preg_split('/\R/u', $rawText) ?: [] + ), static fn (string $line): bool => $line !== '')); + + if ($normalizedText === '') { + $flags[] = 'ocr_raw_text_empty'; + } + + if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) { + $suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]); + } + + preg_match_all('/\b\d+(?:[.,]\d+)?\b/', $normalizedText, $numberMatches); + $decimalCandidates = []; + foreach ($numberMatches[0] ?? [] as $candidate) { + $normalized = (float) str_replace(',', '.', $candidate); + if ($normalized <= 0) { + continue; + } + $decimalCandidates[] = [ + 'raw' => $candidate, + 'value' => $normalized, + 'precision' => str_contains($candidate, ',') || str_contains($candidate, '.') + ? strlen((string) preg_replace('/^\d+[.,]/', '', $candidate)) + : 0, + ]; + } + + if (preg_match('/DOGE\s*\/\s*(USD|EUR|USDT|USDC|BTC|ETH|LTC)/i', $normalizedText, $pairMatch)) { + $currency = strtoupper((string) $pairMatch[1]); + } elseif (preg_match('/\b(EUR|USD|USDT|USDC|BTC|ETH|LTC)\b/i', $normalizedText, $currencyMatch)) { + $currency = strtoupper((string) $currencyMatch[1]); + } elseif (str_contains($normalizedText, '$')) { + $currency = 'USD'; + } else { + $flags[] = 'currency_missing'; + } + + if (preg_match('/DOGE\s*\/\s*(?:USD|EUR|USDT|USDC|BTC|ETH|LTC)[^\d]{0,20}(\d+[.,]\d{3,8})/i', $normalizedText, $priceMatch)) { + $price = round((float) str_replace(',', '.', $priceMatch[1]), 8); + } + + if ($coinsTotal === null) { + foreach ($lines as $line) { + if (!preg_match('/MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE/i', $line)) { + continue; + } + + if (preg_match('/(\d+[.,]\d{4,8})/', $line, $lineCoinsMatch)) { + $coinsTotal = round((float) str_replace(',', '.', $lineCoinsMatch[1]), 6); + $flags[] = 'coins_from_balance_line'; + break; + } + } + } + + if ($coinsTotal === null && preg_match('/(\d+[.,]\d{4,8})\s*(?:DOGE)?\s*(?:MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE)/i', $normalizedText, $coinsMatch)) { + $coinsTotal = round((float) str_replace(',', '.', $coinsMatch[1]), 6); + $flags[] = 'coins_from_balance_context'; + } + + if ($coinsTotal === null) { + $coinsCandidates = array_values(array_filter($decimalCandidates, static function (array $item) use ($price): bool { + if ($item['precision'] < 4) { + return false; + } + if ($item['value'] <= 0 || $item['value'] >= 1000000) { + return false; + } + if ($price !== null && abs($item['value'] - $price) < 0.0000005) { + return false; + } + + return true; + })); + + if ($coinsCandidates !== []) { + usort($coinsCandidates, static function (array $a, array $b): int { + return [$b['precision'], $b['value']] <=> [$a['precision'], $a['value']]; + }); + $coinsTotal = round((float) $coinsCandidates[0]['value'], 6); + if (count($coinsCandidates) > 1) { + $flags[] = 'coins_ambiguous'; + } + } else { + $flags[] = 'coins_missing'; + } + } + + $priceCandidates = array_values(array_filter( + $decimalCandidates, + static fn (array $item): bool => $item['value'] > 0 && $item['value'] < 1 + )); + + if ($price === null && $priceCandidates !== []) { + usort($priceCandidates, static function (array $a, array $b): int { + return [$b['precision'], $a['value']] <=> [$a['precision'], $b['value']]; + }); + $price = round((float) $priceCandidates[0]['value'], 8); + if (count($priceCandidates) > 1 && count(array_filter($priceCandidates, static fn (array $item): bool => $item['precision'] >= 4)) > 1) { + $flags[] = 'price_ambiguous'; + } + } + + if ($price === null && $coinsTotal !== null && preg_match('/~\s*(\d+[.,]\d+)\s*\$/', $normalizedText, $fiatMatch)) { + $fiatValue = (float) str_replace(',', '.', $fiatMatch[1]); + if ($fiatValue > 0) { + $price = round($fiatValue / $coinsTotal, 8); + $flags[] = 'price_derived_from_balance_value'; + $currency = $currency ?? 'USD'; + } + } + + $measurementIndicators = 0; + $normalizedLower = strtolower($normalizedText); + foreach (['mining-guthaben', 'mining guthaben', 'mining-balance', 'mining balance', 'doge /', 'bonus', 'verlauf'] as $indicator) { + if (str_contains($normalizedLower, $indicator)) { + $measurementIndicators++; + } + } + + $matchedFields = 0; + foreach ([$coinsTotal, $price, $currency] as $field) { + if ($field !== null) { + $matchedFields++; + } + } + + $score = $matchedFields + min(3, $measurementIndicators); + $confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $measurementIndicators) * 0.08) - (count($flags) * 0.04))); + + return [ + 'suggested' => [ + 'measured_at' => $suggestedTime, + 'coins_total' => $coinsTotal, + 'price_per_coin' => $price, + 'price_currency' => $currency, + 'note' => null, + 'source' => 'image_ocr', + ], + 'confidence' => round($confidence, 4), + 'flags' => $flags, + 'score' => $score, + ]; + } + + private function parseWalletText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array + { + $flags = []; + $suggestedTime = null; + $totalValueAmount = null; + $totalValueCurrency = null; + $walletBalance = null; + $walletCurrency = $walletCurrencyHint !== '' ? $walletCurrencyHint : 'DOGE'; + $balances = []; + + $normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: ''; + $lines = array_values(array_filter(array_map( + static fn (string $line): string => trim($line), + preg_split('/\R/u', $rawText) ?: [] + ), static fn (string $line): bool => $line !== '')); + + if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) { + $suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]); + } + + if ( + preg_match('/GESAMTSALDO[^\d]{0,24}(\d+(?:[.,]\d+)?)\s*(USD|EUR|USDT|USDC|BTC|ETH|DOGE|CTC|HSH)/i', $normalizedText, $totalMatch) + || preg_match('/(\d+(?:[.,]\d+)?)\s*(USD|EUR)\b.*GESAMTSALDO/i', $normalizedText, $totalMatch) + ) { + $totalValueAmount = round((float) str_replace(',', '.', $totalMatch[1]), 8); + $totalValueCurrency = strtoupper((string) $totalMatch[2]); + } else { + $flags[] = 'wallet_total_missing'; + } + + $assetRows = []; + foreach ($lines as $lineIndex => $line) { + if (!preg_match('/(\d+(?:[.,]\d+)?)\s*([A-Z]{2,10})\b/u', $line, $match)) { + continue; + } + $amount = round((float) str_replace(',', '.', $match[1]), 10); + $currency = strtoupper((string) $match[2]); + if ($amount <= 0 || $currency === '' || in_array($currency, ['USD', 'EUR'], true)) { + continue; + } + + $assetRows[] = [ + 'index' => $lineIndex, + 'currency' => $currency, + 'balance' => $amount, + ]; + } + + foreach ($assetRows as $assetIndex => $assetRow) { + $currency = (string) $assetRow['currency']; + $balanceAmount = (float) $assetRow['balance']; + $startIndex = (int) $assetRow['index']; + $endIndex = isset($assetRows[$assetIndex + 1]['index']) + ? (int) $assetRows[$assetIndex + 1]['index'] + : count($lines); + + $usdCandidates = []; + for ($i = $startIndex; $i < $endIndex; $i++) { + if (!isset($lines[$i])) { + continue; + } + if (preg_match_all('/(\d+(?:[.,]\d+)?)\s*USD\b/u', $lines[$i], $usdMatches, PREG_SET_ORDER)) { + foreach ($usdMatches as $usdMatch) { + $usdCandidates[] = round((float) str_replace(',', '.', $usdMatch[1]), 8); + } + } + } + + $priceAmount = $this->pickWalletUnitPrice( + $balanceAmount, + $usdCandidates, + $totalValueCurrency === 'USD' ? $totalValueAmount : null + ); + $balances[$currency] = [ + 'balance' => $balanceAmount, + 'price_amount' => $priceAmount, + 'price_currency' => $priceAmount !== null ? 'USD' : null, + ]; + } + + if ($walletCurrencyHint !== '' && array_key_exists($walletCurrencyHint, $balances)) { + $walletCurrency = $walletCurrencyHint; + $walletBalance = is_array($balances[$walletCurrencyHint]) + ? (float) ($balances[$walletCurrencyHint]['balance'] ?? 0.0) + : (float) $balances[$walletCurrencyHint]; + } elseif ($balances !== []) { + foreach (['DOGE', 'BTC', 'ETH', 'CTC', 'HSH', 'LTC', 'USDT', 'USDC'] as $preferredCurrency) { + if (array_key_exists($preferredCurrency, $balances)) { + $walletCurrency = $preferredCurrency; + $walletBalance = is_array($balances[$preferredCurrency]) + ? (float) ($balances[$preferredCurrency]['balance'] ?? 0.0) + : (float) $balances[$preferredCurrency]; + break; + } + } + if ($walletBalance === null) { + $firstCurrency = array_key_first($balances); + if (is_string($firstCurrency)) { + $walletCurrency = $firstCurrency; + $walletBalance = is_array($balances[$firstCurrency]) + ? (float) ($balances[$firstCurrency]['balance'] ?? 0.0) + : (float) $balances[$firstCurrency]; + } + } + } else { + $flags[] = 'wallet_balance_missing'; + } + + $walletIndicators = 0; + $normalizedLower = strtolower($normalizedText); + foreach (['wallets', 'gesamtsaldo', 'alle münzen', 'alle munzen', 'letzte transaktion'] as $indicator) { + if (str_contains($normalizedLower, $indicator)) { + $walletIndicators++; + } + } + + $matchedFields = 0; + foreach ([$totalValueAmount, $walletBalance, $walletCurrency] as $field) { + if ($field !== null && $field !== '') { + $matchedFields++; + } + } + $score = $matchedFields + ($walletIndicators * 2); + $confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $walletIndicators) * 0.12) - (count($flags) * 0.03))); + + ksort($balances); + + return [ + 'suggested_wallet' => [ + 'measured_at' => $suggestedTime, + 'total_value_amount' => $totalValueAmount, + 'total_value_currency' => $totalValueCurrency, + 'wallet_balance' => $walletBalance, + 'wallet_currency' => $walletCurrency, + 'balances_json' => $balances, + 'note' => null, + 'source' => 'image_ocr', + ], + 'confidence' => round($confidence, 4), + 'flags' => array_values(array_unique($flags)), + 'score' => $score, + ]; + } + + /** + * @param list $candidates + */ + private function pickWalletUnitPrice(float $balance, array $candidates, ?float $walletTotal = null): ?float + { + $candidates = array_values(array_filter(array_map( + static fn (mixed $value): float => round((float) $value, 8), + $candidates + ), static fn (float $value): bool => $value > 0)); + + if ($balance <= 0 || $candidates === []) { + return null; + } + if (count($candidates) === 1) { + $candidate = $candidates[0]; + if ($walletTotal !== null && $walletTotal > 0 && ($balance * $candidate) > ($walletTotal * 1.5)) { + return round($candidate / $balance, 8); + } + return $candidate; + } + + $bestPrice = null; + $bestError = null; + $candidateCount = count($candidates); + + for ($i = 0; $i < $candidateCount; $i++) { + $priceCandidate = $candidates[$i]; + for ($j = 0; $j < $candidateCount; $j++) { + if ($i === $j) { + continue; + } + $totalCandidate = $candidates[$j]; + $estimatedTotal = $balance * $priceCandidate; + $denominator = max(abs($totalCandidate), 0.00000001); + $error = abs($estimatedTotal - $totalCandidate) / $denominator; + if ($bestError === null || $error < $bestError) { + $bestError = $error; + $bestPrice = $priceCandidate; + } + } + } + + if ($bestPrice !== null && $bestError !== null && $bestError <= 0.2) { + return round($bestPrice, 8); + } + + return round($candidates[count($candidates) - 1], 8); + } +} diff --git a/modules/mining-checker/src/Domain/SeedData.php b/modules/mining-checker/src/Domain/SeedData.php new file mode 100644 index 00000000..5f778264 --- /dev/null +++ b/modules/mining-checker/src/Domain/SeedData.php @@ -0,0 +1,79 @@ + '2026-03-16 01:32:00', + 'baseline_coins_total' => 27.617864, + 'daily_cost_amount' => 0.3123287671, + 'daily_cost_currency' => 'EUR', + 'preferred_currencies' => ['DOGE', 'USD', 'EUR'], + ]; + } + + public static function currencies(): array + { + return [ + ['code' => 'EUR', 'name' => 'Euro', 'symbol' => 'EUR', 'is_active' => 1, 'sort_order' => 10], + ['code' => 'USD', 'name' => 'US-Dollar', 'symbol' => 'USD', 'is_active' => 1, 'sort_order' => 20], + ['code' => 'DOGE', 'name' => 'Dogecoin', 'symbol' => 'DOGE', 'is_active' => 1, 'sort_order' => 100], + ['code' => 'BTC', 'name' => 'Bitcoin', 'symbol' => 'BTC', 'is_active' => 1, 'sort_order' => 110], + ['code' => 'ETH', 'name' => 'Ethereum', 'symbol' => 'ETH', 'is_active' => 1, 'sort_order' => 120], + ['code' => 'LTC', 'name' => 'Litecoin', 'symbol' => 'LTC', 'is_active' => 1, 'sort_order' => 130], + ['code' => 'USDT', 'name' => 'Tether', 'symbol' => 'USDT', 'is_active' => 1, 'sort_order' => 140], + ['code' => 'USDC', 'name' => 'USD Coin', 'symbol' => 'USDC', 'is_active' => 1, 'sort_order' => 150], + ]; + } + + public static function measurements(): array + { + return [ + ['measured_at' => '2026-03-16 01:32:00', 'coins_total' => 27.617864, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Basiswert', 'source' => 'seed_import'], + ['measured_at' => '2026-03-17 02:41:00', 'coins_total' => 33.751904, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'source' => 'seed_import'], + ['measured_at' => '2026-03-17 07:15:00', 'coins_total' => 34.825695, 'price_per_coin' => 0.10037, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'], + ['measured_at' => '2026-03-17 13:21:00', 'coins_total' => 36.328140, 'price_per_coin' => 0.10002, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'], + ['measured_at' => '2026-03-17 18:53:00', 'coins_total' => 37.682757, 'price_per_coin' => 0.10062, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'], + ['measured_at' => '2026-03-18 00:08:00', 'coins_total' => 38.934351, 'price_per_coin' => 0.10097, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'], + ['measured_at' => '2026-03-18 07:40:00', 'coins_total' => 40.782006, 'price_per_coin' => 0.10040, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'], + ['measured_at' => '2026-03-18 13:32:00', 'coins_total' => 42.223449, 'price_per_coin' => 0.09607, 'price_currency' => 'EUR', 'note' => 'Originaleingabe im Chat: 18.6.2026', 'source' => 'seed_import'], + ['measured_at' => '2026-03-18 21:15:00', 'coins_total' => 44.191018, 'price_per_coin' => 0.09446, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'], + ['measured_at' => '2026-03-19 00:09:00', 'coins_total' => 44.908500, 'price_per_coin' => 0.09507, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'], + ['measured_at' => '2026-03-19 02:33:00', 'coins_total' => 45.546924, 'price_per_coin' => 0.09499, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']], + ['measured_at' => '2026-03-19 07:01:00', 'coins_total' => 46.694127, 'price_per_coin' => 0.09460, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']], + ['measured_at' => '2026-03-19 12:24:00', 'coins_total' => 48.056494, 'price_per_coin' => 0.09419, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']], + ['measured_at' => '2026-03-19 21:39:00', 'coins_total' => 50.427943, 'price_per_coin' => 0.09361, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']], + ]; + } + + public static function targets(): array + { + return [ + ['label' => 'Ziel A', 'target_amount_fiat' => 10.82, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 10], + ['label' => 'Ziel B', 'target_amount_fiat' => 19.50, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 20], + ]; + } + + public static function dashboards(): array + { + return [ + ['name' => 'Mining-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'coins_total', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1], + ['name' => 'Performance-Verlauf', 'chart_type' => 'area', 'x_field' => 'measured_date', 'y_field' => 'doge_per_day_interval', 'aggregation' => 'avg', 'filters' => [], 'is_active' => 1], + ['name' => 'Kurs-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'price_per_coin', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1], + ]; + } +} diff --git a/modules/mining-checker/src/Domain/SeedImporter.php b/modules/mining-checker/src/Domain/SeedImporter.php new file mode 100644 index 00000000..b533e2b7 --- /dev/null +++ b/modules/mining-checker/src/Domain/SeedImporter.php @@ -0,0 +1,62 @@ +repository = $repository; + } + + public function import(string $projectKey): array + { + $seedProjectKey = SeedData::projectKey(); + if ($projectKey !== $seedProjectKey) { + return ['inserted' => 0, 'project_key' => $projectKey, 'warning' => 'Seed-Daten sind nur fuer doge-main definiert.']; + } + + $this->repository->ensureProject($projectKey, SeedData::projectName()); + $this->repository->saveSettings($projectKey, SeedData::settings()); + + $insertedMeasurements = 0; + foreach (SeedData::measurements() as $measurement) { + try { + $this->repository->createMeasurement($projectKey, array_merge([ + 'image_path' => null, + 'ocr_raw_text' => null, + 'ocr_confidence' => null, + 'ocr_flags' => null, + ], $measurement)); + $insertedMeasurements++; + } catch (\Throwable $exception) { + // Duplicate seeds are expected on repeated imports. + } + } + + $targetCount = 0; + foreach (SeedData::targets() as $target) { + $this->repository->saveTarget($projectKey, $target); + $targetCount++; + } + + $dashboardCount = 0; + foreach (SeedData::dashboards() as $dashboard) { + $this->repository->saveDashboard($projectKey, $dashboard); + $dashboardCount++; + } + + return [ + 'project_key' => $projectKey, + 'imported_measurements' => $insertedMeasurements, + 'historical_rows_total' => count(SeedData::measurements()), + 'targets_synced' => $targetCount, + 'dashboards_synced' => $dashboardCount, + ]; + } +} diff --git a/modules/mining-checker/src/Infrastructure/ConnectionFactory.php b/modules/mining-checker/src/Infrastructure/ConnectionFactory.php new file mode 100644 index 00000000..cf28e6f2 --- /dev/null +++ b/modules/mining-checker/src/Infrastructure/ConnectionFactory.php @@ -0,0 +1,65 @@ +settings('mining-checker'); + $useSeparateDb = self::usesSeparateDatabase($moduleSettings); + + if ($useSeparateDb) { + $dbConfig = is_array($moduleSettings['db'] ?? null) ? $moduleSettings['db'] : []; + if ($dbConfig === []) { + throw new ApiException('Custom-Datenbank ist aktiviert, aber nicht vollstaendig konfiguriert.', 500); + } + self::assertSupportedDriver($dbConfig); + if (method_exists(AppDatabase::class, 'connectFromConfig')) { + return AppDatabase::connectFromConfig($dbConfig); + } + return AppDatabase::createFromArray($dbConfig); + } + + $dbConfig = app()->config()->dbConfig; + if ($dbConfig === []) { + throw new ApiException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.', 500); + } + + self::assertSupportedDriver($dbConfig); + + if (method_exists(AppDatabase::class, 'connectFromConfig')) { + return AppDatabase::connectFromConfig($dbConfig); + } + + return AppDatabase::createFromArray($dbConfig); + } + + private static function usesSeparateDatabase(array $moduleSettings): bool + { + $raw = $moduleSettings['use_separate_db'] ?? false; + if (is_bool($raw)) { + return $raw; + } + + $normalized = strtolower(trim((string) $raw)); + return in_array($normalized, ['1', 'true', 'yes', 'on', 'custom'], true); + } + + private static function assertSupportedDriver(array $dbConfig): void + { + $driver = strtolower((string) ($dbConfig['driver'] ?? ($dbConfig['dsn'] ?? ''))); + if ($driver !== '' && !in_array($driver, ['mysql', 'pgsql'], true) && !str_starts_with($driver, 'mysql:') && !str_starts_with($driver, 'pgsql:')) { + throw new ApiException( + 'Mining-Checker unterstuetzt aktuell MySQL/MariaDB und PostgreSQL. Stelle den Driver auf mysql oder pgsql.', + 500, + ['driver' => $dbConfig['driver'] ?? 'unknown'] + ); + } + } +} diff --git a/modules/mining-checker/src/Infrastructure/MiningRepository.php b/modules/mining-checker/src/Infrastructure/MiningRepository.php new file mode 100644 index 00000000..78caae71 --- /dev/null +++ b/modules/mining-checker/src/Infrastructure/MiningRepository.php @@ -0,0 +1,1401 @@ +pdo = $pdo; + $this->prefix = $prefix; + $this->driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); + $this->debug = $debug; + $this->ownerSub = trim((string) $ownerSub) !== '' ? trim((string) $ownerSub) : 'local'; + } + + public function ensureProject(string $projectKey, ?string $projectName = null): void + { + $stmt = $this->pdo->prepare($this->driver === 'pgsql' + ? 'INSERT INTO ' . $this->table('projects') . ' (project_key, project_name) + VALUES (:project_key, :project_name) + ON CONFLICT (project_key) DO UPDATE + SET project_name = COALESCE(' . $this->table('projects') . '.project_name, EXCLUDED.project_name)' + : 'INSERT INTO ' . $this->table('projects') . ' (project_key, project_name) + VALUES (:project_key, :project_name) + ON DUPLICATE KEY UPDATE project_name = COALESCE(project_name, VALUES(project_name))' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'project_name' => $projectName ?: strtoupper($projectKey), + ]); + } + + public function getProject(string $projectKey): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM ' . $this->table('projects') . ' WHERE project_key = :project_key LIMIT 1'); + $stmt->execute(['project_key' => $projectKey]); + $project = $stmt->fetch(); + return is_array($project) ? $this->normalizeRow($project) : null; + } + + public function getSettings(string $projectKey): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM ' . $this->table('settings') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub LIMIT 1'); + $stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]); + $row = $stmt->fetch(); + return is_array($row) ? $this->normalizeRow($row) : null; + } + + public function saveSettings(string $projectKey, array $settings): void + { + $stmt = $this->pdo->prepare($this->driver === 'pgsql' + ? 'INSERT INTO ' . $this->table('settings') . ' ( + project_key, owner_sub, baseline_measured_at, baseline_coins_total, daily_cost_amount, daily_cost_currency, report_currency, crypto_currency, display_timezone, fx_max_age_hours, module_theme_mode, module_theme_accent, preferred_currencies + ) VALUES ( + :project_key, :owner_sub, :baseline_measured_at, :baseline_coins_total, :daily_cost_amount, :daily_cost_currency, :report_currency, :crypto_currency, :display_timezone, :fx_max_age_hours, :module_theme_mode, :module_theme_accent, CAST(:preferred_currencies AS jsonb) + ) + ON CONFLICT (project_key, owner_sub) DO UPDATE SET + baseline_measured_at = EXCLUDED.baseline_measured_at, + baseline_coins_total = EXCLUDED.baseline_coins_total, + daily_cost_amount = EXCLUDED.daily_cost_amount, + daily_cost_currency = EXCLUDED.daily_cost_currency, + report_currency = EXCLUDED.report_currency, + crypto_currency = EXCLUDED.crypto_currency, + display_timezone = EXCLUDED.display_timezone, + fx_max_age_hours = EXCLUDED.fx_max_age_hours, + module_theme_mode = EXCLUDED.module_theme_mode, + module_theme_accent = EXCLUDED.module_theme_accent, + preferred_currencies = EXCLUDED.preferred_currencies' + : 'INSERT INTO ' . $this->table('settings') . ' ( + project_key, owner_sub, baseline_measured_at, baseline_coins_total, daily_cost_amount, daily_cost_currency, report_currency, crypto_currency, display_timezone, fx_max_age_hours, module_theme_mode, module_theme_accent, preferred_currencies + ) VALUES ( + :project_key, :owner_sub, :baseline_measured_at, :baseline_coins_total, :daily_cost_amount, :daily_cost_currency, :report_currency, :crypto_currency, :display_timezone, :fx_max_age_hours, :module_theme_mode, :module_theme_accent, :preferred_currencies + ) + ON DUPLICATE KEY UPDATE + baseline_measured_at = VALUES(baseline_measured_at), + baseline_coins_total = VALUES(baseline_coins_total), + daily_cost_amount = VALUES(daily_cost_amount), + daily_cost_currency = VALUES(daily_cost_currency), + report_currency = VALUES(report_currency), + crypto_currency = VALUES(crypto_currency), + display_timezone = VALUES(display_timezone), + fx_max_age_hours = VALUES(fx_max_age_hours), + module_theme_mode = VALUES(module_theme_mode), + module_theme_accent = VALUES(module_theme_accent), + preferred_currencies = VALUES(preferred_currencies)' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'baseline_measured_at' => $settings['baseline_measured_at'], + 'baseline_coins_total' => $settings['baseline_coins_total'], + 'daily_cost_amount' => $settings['daily_cost_amount'], + 'daily_cost_currency' => $settings['daily_cost_currency'], + 'report_currency' => $settings['report_currency'] ?? 'EUR', + 'crypto_currency' => $settings['crypto_currency'] ?? 'DOGE', + 'display_timezone' => $settings['display_timezone'] ?? nexus_display_timezone_name(), + 'fx_max_age_hours' => $settings['fx_max_age_hours'] ?? 3, + 'module_theme_mode' => $settings['module_theme_mode'] ?? 'inherit', + 'module_theme_accent' => $settings['module_theme_accent'] ?? 'teal', + 'preferred_currencies' => json_encode($settings['preferred_currencies'] ?? [], JSON_UNESCAPED_UNICODE), + ]); + } + + public function tableExists(string $logicalName): bool + { + $tableName = $this->table($logicalName); + $schemaCondition = $this->driver === 'pgsql' + ? 'table_schema = current_schema()' + : 'table_schema = DATABASE()'; + $stmt = $this->pdo->prepare( + 'SELECT table_name + FROM information_schema.tables + WHERE ' . $schemaCondition . ' AND table_name = :table_name + LIMIT 1' + ); + $stmt->execute(['table_name' => $tableName]); + return (bool) $stmt->fetchColumn(); + } + + public function listCostPlans(string $projectKey): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('cost_plans') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub ORDER BY starts_at DESC, id DESC' + ); + $stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function saveCostPlan(string $projectKey, array $payload): array + { + if ($this->driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('cost_plans') . ' ( + project_key, owner_sub, label, starts_at, runtime_months, mining_speed_value, mining_speed_unit, + bonus_speed_value, bonus_speed_unit, auto_renew, base_price_amount, payment_type, total_cost_amount, currency, note, is_active + ) VALUES ( + :project_key, :owner_sub, :label, :starts_at, :runtime_months, :mining_speed_value, :mining_speed_unit, + :bonus_speed_value, :bonus_speed_unit, :auto_renew, :base_price_amount, :payment_type, :total_cost_amount, :currency, :note, :is_active + ) + RETURNING *' + ); + $stmt->execute($this->normalizeInsertPayload($projectKey, $payload)); + return $this->normalizeRow($stmt->fetch() ?: []); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('cost_plans') . ' ( + project_key, owner_sub, label, starts_at, runtime_months, mining_speed_value, mining_speed_unit, + bonus_speed_value, bonus_speed_unit, auto_renew, base_price_amount, payment_type, total_cost_amount, currency, note, is_active + ) VALUES ( + :project_key, :owner_sub, :label, :starts_at, :runtime_months, :mining_speed_value, :mining_speed_unit, + :bonus_speed_value, :bonus_speed_unit, :auto_renew, :base_price_amount, :payment_type, :total_cost_amount, :currency, :note, :is_active + )' + ); + $stmt->execute($this->normalizeInsertPayload($projectKey, $payload)); + + $id = (int) $this->pdo->lastInsertId(); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('cost_plans') . ' WHERE id = :id LIMIT 1'); + $fetch->execute(['id' => $id]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function listMeasurements(string $projectKey, int $limit = 200): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('measurements') . ' + WHERE project_key = :project_key AND owner_sub = :owner_sub + ORDER BY measured_at ASC + LIMIT :limit' + ); + $stmt->bindValue(':project_key', $projectKey, PDO::PARAM_STR); + $stmt->bindValue(':owner_sub', $this->ownerSub, PDO::PARAM_STR); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function listRecentMeasurements(string $projectKey, int $limit = 200): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('measurements') . ' + WHERE project_key = :project_key AND owner_sub = :owner_sub + ORDER BY measured_at DESC + LIMIT :limit' + ); + $stmt->bindValue(':project_key', $projectKey, PDO::PARAM_STR); + $stmt->bindValue(':owner_sub', $this->ownerSub, PDO::PARAM_STR); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + $rows = $this->normalizeRows($stmt->fetchAll() ?: []); + return array_reverse($rows); + } + + public function listAllMeasurements(string $projectKey): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('measurements') . ' + WHERE project_key = :project_key AND owner_sub = :owner_sub + ORDER BY measured_at ASC' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + ]); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function createMeasurement(string $projectKey, array $payload): array + { + $this->debug?->add('db.createMeasurement.start', [ + 'project_key' => $projectKey, + 'measured_at' => $payload['measured_at'] ?? null, + 'price_currency' => $payload['price_currency'] ?? null, + 'fx_fetch_id' => $payload['fx_fetch_id'] ?? null, + ]); + $params = [ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'measured_at' => $payload['measured_at'], + 'coins_total' => $payload['coins_total'], + 'coin_currency' => $payload['coin_currency'] ?? 'DOGE', + 'price_per_coin' => $payload['price_per_coin'], + 'price_currency' => $payload['price_currency'], + 'fx_fetch_id' => $payload['fx_fetch_id'] ?? null, + 'note' => $payload['note'], + 'source' => $payload['source'], + 'image_path' => $payload['image_path'], + 'ocr_raw_text' => $payload['ocr_raw_text'], + 'ocr_confidence' => $payload['ocr_confidence'], + 'ocr_flags' => $payload['ocr_flags'] === null ? null : json_encode($payload['ocr_flags'], JSON_UNESCAPED_UNICODE), + ]; + + if ($this->driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('measurements') . ' ( + project_key, owner_sub, measured_at, coins_total, coin_currency, price_per_coin, price_currency, fx_fetch_id, note, + source, image_path, ocr_raw_text, ocr_confidence, ocr_flags + ) VALUES ( + :project_key, :owner_sub, :measured_at, :coins_total, :coin_currency, :price_per_coin, :price_currency, :fx_fetch_id, :note, + :source, :image_path, :ocr_raw_text, :ocr_confidence, CAST(:ocr_flags AS jsonb) + ) + RETURNING *' + ); + $stmt->execute($params); + $row = $this->normalizeRow($stmt->fetch() ?: []); + $this->debug?->add('db.createMeasurement.end', ['id' => $row['id'] ?? null]); + return $row; + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('measurements') . ' ( + project_key, owner_sub, measured_at, coins_total, coin_currency, price_per_coin, price_currency, fx_fetch_id, note, + source, image_path, ocr_raw_text, ocr_confidence, ocr_flags + ) VALUES ( + :project_key, :owner_sub, :measured_at, :coins_total, :coin_currency, :price_per_coin, :price_currency, :fx_fetch_id, :note, + :source, :image_path, :ocr_raw_text, :ocr_confidence, :ocr_flags + )' + ); + + $stmt->execute($params); + + $id = (int) $this->pdo->lastInsertId(); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('measurements') . ' WHERE id = :id LIMIT 1'); + $fetch->execute(['id' => $id]); + $row = $this->normalizeRow($fetch->fetch() ?: []); + $this->debug?->add('db.createMeasurement.end', ['id' => $row['id'] ?? null]); + return $row; + } + + public function createMeasurementIfNotExists(string $projectKey, array $payload): ?array + { + try { + return $this->createMeasurement($projectKey, $payload); + } catch (\PDOException $exception) { + $sqlState = (string) ($exception->getCode() ?? ''); + if (in_array($sqlState, ['23000', '23505'], true)) { + return null; + } + + throw $exception; + } + } + + public function setMeasurementFxFetchId(string $projectKey, int $measurementId, ?int $fxFetchId): ?array + { + if ($measurementId <= 0) { + return null; + } + + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->table('measurements') . ' + SET fx_fetch_id = :fx_fetch_id + WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id' + ); + $stmt->execute([ + 'fx_fetch_id' => $fxFetchId, + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'id' => $measurementId, + ]); + + $fetch = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('measurements') . ' + WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id LIMIT 1' + ); + $fetch->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'id' => $measurementId, + ]); + + $row = $fetch->fetch(); + return is_array($row) ? $this->normalizeRow($row) : null; + } + + public function deleteMeasurement(string $projectKey, int $measurementId): void + { + if ($measurementId <= 0) { + return; + } + + $deleteRates = $this->pdo->prepare( + 'DELETE FROM ' . $this->table('measurement_rates') . ' + WHERE measurement_id = :measurement_id AND owner_sub = :owner_sub' + ); + $deleteRates->execute([ + 'measurement_id' => $measurementId, + 'owner_sub' => $this->ownerSub, + ]); + + $deleteMeasurement = $this->pdo->prepare( + 'DELETE FROM ' . $this->table('measurements') . ' + WHERE id = :id AND project_key = :project_key AND owner_sub = :owner_sub' + ); + $deleteMeasurement->execute([ + 'id' => $measurementId, + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + ]); + } + + public function replaceMeasurementRates(int $measurementId, string $projectKey, array $rates): void + { + $delete = $this->pdo->prepare('DELETE FROM ' . $this->table('measurement_rates') . ' WHERE measurement_id = :measurement_id AND owner_sub = :owner_sub'); + $delete->execute(['measurement_id' => $measurementId, 'owner_sub' => $this->ownerSub]); + + if ($rates === []) { + return; + } + + $insert = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('measurement_rates') . ' ( + measurement_id, project_key, owner_sub, base_currency, quote_currency, rate, provider + ) VALUES ( + :measurement_id, :project_key, :owner_sub, :base_currency, :quote_currency, :rate, :provider + )' + ); + + foreach ($rates as $rate) { + $insert->execute([ + 'measurement_id' => $measurementId, + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'base_currency' => strtoupper((string) $rate['base_currency']), + 'quote_currency' => strtoupper((string) $rate['quote_currency']), + 'rate' => $rate['rate'], + 'provider' => $rate['provider'] ?? 'derived', + ]); + } + } + + public function listMeasurementRates(string $projectKey): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('measurement_rates') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub ORDER BY measurement_id ASC, base_currency ASC, quote_currency ASC' + ); + $stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function listPayouts(string $projectKey): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('payouts') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub ORDER BY payout_at ASC, id ASC' + ); + $stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function savePayout(string $projectKey, array $payload): array + { + if ($this->driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('payouts') . ' ( + project_key, owner_sub, payout_at, coins_amount, payout_currency, note + ) VALUES ( + :project_key, :owner_sub, :payout_at, :coins_amount, :payout_currency, :note + ) + RETURNING *' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'payout_at' => $payload['payout_at'], + 'coins_amount' => $payload['coins_amount'], + 'payout_currency' => $payload['payout_currency'], + 'note' => $payload['note'], + ]); + return $this->normalizeRow($stmt->fetch() ?: []); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('payouts') . ' ( + project_key, owner_sub, payout_at, coins_amount, payout_currency, note + ) VALUES ( + :project_key, :owner_sub, :payout_at, :coins_amount, :payout_currency, :note + )' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'payout_at' => $payload['payout_at'], + 'coins_amount' => $payload['coins_amount'], + 'payout_currency' => $payload['payout_currency'], + 'note' => $payload['note'], + ]); + + $id = (int) $this->pdo->lastInsertId(); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('payouts') . ' WHERE id = :id LIMIT 1'); + $fetch->execute(['id' => $id]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function listWalletSnapshots(string $projectKey, int $limit = 100): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('wallet_snapshots') . ' + WHERE project_key = :project_key AND owner_sub = :owner_sub + ORDER BY measured_at DESC, id DESC + LIMIT :limit' + ); + $stmt->bindValue(':project_key', $projectKey, PDO::PARAM_STR); + $stmt->bindValue(':owner_sub', $this->ownerSub, PDO::PARAM_STR); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function saveWalletSnapshot(string $projectKey, array $payload): array + { + $params = [ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'measured_at' => $payload['measured_at'], + 'total_value_amount' => $payload['total_value_amount'] ?? null, + 'total_value_currency' => $payload['total_value_currency'] ?? null, + 'wallet_balance' => $payload['wallet_balance'] ?? null, + 'wallet_currency' => $payload['wallet_currency'], + 'balances_json' => json_encode($payload['balances_json'] ?? [], JSON_UNESCAPED_UNICODE), + 'note' => $payload['note'] ?? null, + 'source' => $payload['source'] ?? 'manual', + 'image_path' => $payload['image_path'] ?? null, + 'ocr_raw_text' => $payload['ocr_raw_text'] ?? null, + 'ocr_confidence' => $payload['ocr_confidence'] ?? null, + 'ocr_flags' => json_encode($payload['ocr_flags'] ?? [], JSON_UNESCAPED_UNICODE), + ]; + + if ($this->driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('wallet_snapshots') . ' ( + project_key, owner_sub, measured_at, total_value_amount, total_value_currency, wallet_balance, + wallet_currency, balances_json, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags + ) VALUES ( + :project_key, :owner_sub, :measured_at, :total_value_amount, :total_value_currency, :wallet_balance, + :wallet_currency, CAST(:balances_json AS jsonb), :note, :source, :image_path, :ocr_raw_text, :ocr_confidence, CAST(:ocr_flags AS jsonb) + ) + RETURNING *' + ); + $stmt->execute($params); + return $this->normalizeRow($stmt->fetch() ?: []); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('wallet_snapshots') . ' ( + project_key, owner_sub, measured_at, total_value_amount, total_value_currency, wallet_balance, + wallet_currency, balances_json, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags + ) VALUES ( + :project_key, :owner_sub, :measured_at, :total_value_amount, :total_value_currency, :wallet_balance, + :wallet_currency, :balances_json, :note, :source, :image_path, :ocr_raw_text, :ocr_confidence, :ocr_flags + )' + ); + $stmt->execute($params); + $id = (int) $this->pdo->lastInsertId(); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('wallet_snapshots') . ' WHERE id = :id LIMIT 1'); + $fetch->execute(['id' => $id]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function listTargets(string $projectKey): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('targets') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub ORDER BY sort_order ASC, id ASC' + ); + $stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function saveTarget(string $projectKey, array $payload): array + { + $stmt = $this->pdo->prepare($this->driver === 'pgsql' + ? 'INSERT INTO ' . $this->table('targets') . ' (project_key, owner_sub, label, target_amount_fiat, currency, miner_offer_id, is_active, sort_order) + VALUES (:project_key, :owner_sub, :label, :target_amount_fiat, :currency, :miner_offer_id, :is_active, :sort_order) + ON CONFLICT (project_key, owner_sub, label) DO UPDATE SET + target_amount_fiat = EXCLUDED.target_amount_fiat, + currency = EXCLUDED.currency, + miner_offer_id = EXCLUDED.miner_offer_id, + is_active = EXCLUDED.is_active, + sort_order = EXCLUDED.sort_order' + : 'INSERT INTO ' . $this->table('targets') . ' (project_key, owner_sub, label, target_amount_fiat, currency, miner_offer_id, is_active, sort_order) + VALUES (:project_key, :owner_sub, :label, :target_amount_fiat, :currency, :miner_offer_id, :is_active, :sort_order) + ON DUPLICATE KEY UPDATE + target_amount_fiat = VALUES(target_amount_fiat), + currency = VALUES(currency), + miner_offer_id = VALUES(miner_offer_id), + is_active = VALUES(is_active), + sort_order = VALUES(sort_order)' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'label' => $payload['label'], + 'target_amount_fiat' => $payload['target_amount_fiat'], + 'currency' => $payload['currency'], + 'miner_offer_id' => $payload['miner_offer_id'] ?? null, + 'is_active' => $payload['is_active'], + 'sort_order' => $payload['sort_order'], + ]); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('targets') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub AND label = :label LIMIT 1'); + $fetch->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub, 'label' => $payload['label']]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function updateTarget(string $projectKey, int $targetId, array $payload): array + { + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->table('targets') . ' + SET label = :label, target_amount_fiat = :target_amount_fiat, currency = :currency, miner_offer_id = :miner_offer_id, + is_active = :is_active, sort_order = :sort_order + WHERE id = :id AND project_key = :project_key AND owner_sub = :owner_sub' + ); + $stmt->execute([ + 'id' => $targetId, + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'label' => $payload['label'], + 'target_amount_fiat' => $payload['target_amount_fiat'], + 'currency' => $payload['currency'], + 'miner_offer_id' => $payload['miner_offer_id'] ?? null, + 'is_active' => $payload['is_active'], + 'sort_order' => $payload['sort_order'], + ]); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('targets') . ' WHERE id = :id AND owner_sub = :owner_sub LIMIT 1'); + $fetch->execute(['id' => $targetId, 'owner_sub' => $this->ownerSub]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function deleteTarget(string $projectKey, int $targetId): void + { + $stmt = $this->pdo->prepare( + 'DELETE FROM ' . $this->table('targets') . ' WHERE id = :id AND project_key = :project_key AND owner_sub = :owner_sub' + ); + $stmt->execute([ + 'id' => $targetId, + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + ]); + } + + public function listDashboards(string $projectKey): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('dashboard_definitions') . ' + WHERE project_key = :project_key AND owner_sub = :owner_sub + ORDER BY id ASC' + ); + $stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function saveDashboard(string $projectKey, array $payload): array + { + $stmt = $this->pdo->prepare($this->driver === 'pgsql' + ? 'INSERT INTO ' . $this->table('dashboard_definitions') . ' ( + project_key, owner_sub, name, chart_type, x_field, y_field, aggregation, filters_json, is_active + ) VALUES ( + :project_key, :owner_sub, :name, :chart_type, :x_field, :y_field, :aggregation, CAST(:filters_json AS jsonb), :is_active + ) + ON CONFLICT (project_key, owner_sub, name) DO UPDATE SET + chart_type = EXCLUDED.chart_type, + x_field = EXCLUDED.x_field, + y_field = EXCLUDED.y_field, + aggregation = EXCLUDED.aggregation, + filters_json = EXCLUDED.filters_json, + is_active = EXCLUDED.is_active' + : 'INSERT INTO ' . $this->table('dashboard_definitions') . ' ( + project_key, owner_sub, name, chart_type, x_field, y_field, aggregation, filters_json, is_active + ) VALUES ( + :project_key, :owner_sub, :name, :chart_type, :x_field, :y_field, :aggregation, :filters_json, :is_active + ) + ON DUPLICATE KEY UPDATE + chart_type = VALUES(chart_type), + x_field = VALUES(x_field), + y_field = VALUES(y_field), + aggregation = VALUES(aggregation), + filters_json = VALUES(filters_json), + is_active = VALUES(is_active)' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'name' => $payload['name'], + 'chart_type' => $payload['chart_type'], + 'x_field' => $payload['x_field'], + 'y_field' => $payload['y_field'], + 'aggregation' => $payload['aggregation'], + 'filters_json' => json_encode($payload['filters'], JSON_UNESCAPED_UNICODE), + 'is_active' => $payload['is_active'], + ]); + + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('dashboard_definitions') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub AND name = :name LIMIT 1'); + $fetch->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub, 'name' => $payload['name']]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function listMinerOffers(string $projectKey): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('miner_offers') . ' WHERE project_key = :project_key ORDER BY created_at DESC, id DESC' + ); + $stmt->execute(['project_key' => $projectKey]); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function saveMinerOffer(string $projectKey, array $payload): array + { + if ($this->driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('miner_offers') . ' ( + project_key, label, runtime_months, mining_speed_value, mining_speed_unit, + bonus_speed_value, bonus_speed_unit, base_price_amount, base_price_currency, + payment_type, auto_renew, note, is_active + ) VALUES ( + :project_key, :label, :runtime_months, :mining_speed_value, :mining_speed_unit, + :bonus_speed_value, :bonus_speed_unit, :base_price_amount, :base_price_currency, + :payment_type, :auto_renew, :note, :is_active + ) + RETURNING *' + ); + $stmt->execute($this->normalizeOfferPayload($projectKey, $payload)); + return $this->normalizeRow($stmt->fetch() ?: []); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('miner_offers') . ' ( + project_key, label, runtime_months, mining_speed_value, mining_speed_unit, + bonus_speed_value, bonus_speed_unit, base_price_amount, base_price_currency, + payment_type, auto_renew, note, is_active + ) VALUES ( + :project_key, :label, :runtime_months, :mining_speed_value, :mining_speed_unit, + :bonus_speed_value, :bonus_speed_unit, :base_price_amount, :base_price_currency, + :payment_type, :auto_renew, :note, :is_active + )' + ); + $stmt->execute($this->normalizeOfferPayload($projectKey, $payload)); + $id = (int) $this->pdo->lastInsertId(); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('miner_offers') . ' WHERE id = :id LIMIT 1'); + $fetch->execute(['id' => $id]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function listPurchasedMiners(string $projectKey): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('purchased_miners') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub ORDER BY purchased_at DESC, id DESC' + ); + $stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function getPurchasedMiner(string $projectKey, int $minerId): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('purchased_miners') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id LIMIT 1' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'id' => $minerId, + ]); + $row = $stmt->fetch(); + return is_array($row) ? $this->normalizeRow($row) : null; + } + + public function purchaseMiner(string $projectKey, int $offerId, array $payload): array + { + if ($this->driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('purchased_miners') . ' ( + project_key, owner_sub, miner_offer_id, purchased_at, label, runtime_months, + mining_speed_value, mining_speed_unit, bonus_speed_value, bonus_speed_unit, + total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, auto_renew, note, is_active + ) VALUES ( + :project_key, :owner_sub, :miner_offer_id, :purchased_at, :label, :runtime_months, + :mining_speed_value, :mining_speed_unit, :bonus_speed_value, :bonus_speed_unit, + :total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, :auto_renew, :note, :is_active + ) + RETURNING *' + ); + $stmt->execute($this->normalizePurchasedPayload($projectKey, $offerId, $payload)); + return $this->normalizeRow($stmt->fetch() ?: []); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('purchased_miners') . ' ( + project_key, owner_sub, miner_offer_id, purchased_at, label, runtime_months, + mining_speed_value, mining_speed_unit, bonus_speed_value, bonus_speed_unit, + total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, auto_renew, note, is_active + ) VALUES ( + :project_key, :owner_sub, :miner_offer_id, :purchased_at, :label, :runtime_months, + :mining_speed_value, :mining_speed_unit, :bonus_speed_value, :bonus_speed_unit, + :total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, :auto_renew, :note, :is_active + )' + ); + $stmt->execute($this->normalizePurchasedPayload($projectKey, $offerId, $payload)); + $id = (int) $this->pdo->lastInsertId(); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('purchased_miners') . ' WHERE id = :id LIMIT 1'); + $fetch->execute(['id' => $id]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function restorePurchasedMiner(string $projectKey, array $payload): array + { + $params = [ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'miner_offer_id' => $payload['miner_offer_id'] ?? null, + 'purchased_at' => $payload['purchased_at'], + 'label' => $payload['label'], + 'runtime_months' => $payload['runtime_months'] ?? null, + 'mining_speed_value' => $payload['mining_speed_value'] ?? null, + 'mining_speed_unit' => $payload['mining_speed_unit'] ?? null, + 'bonus_speed_value' => $payload['bonus_speed_value'] ?? null, + 'bonus_speed_unit' => $payload['bonus_speed_unit'] ?? null, + 'total_cost_amount' => $payload['total_cost_amount'], + 'currency' => $payload['currency'], + 'usd_reference_amount' => $payload['usd_reference_amount'] ?? null, + 'reference_price_amount' => $payload['reference_price_amount'] ?? null, + 'reference_price_currency' => $payload['reference_price_currency'] ?? null, + 'auto_renew' => $payload['auto_renew'] ?? 0, + 'note' => $payload['note'] ?? null, + 'is_active' => $payload['is_active'] ?? 1, + ]; + + if ($this->driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('purchased_miners') . ' ( + project_key, owner_sub, miner_offer_id, purchased_at, label, runtime_months, + mining_speed_value, mining_speed_unit, bonus_speed_value, bonus_speed_unit, + total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, auto_renew, note, is_active + ) VALUES ( + :project_key, :owner_sub, :miner_offer_id, :purchased_at, :label, :runtime_months, + :mining_speed_value, :mining_speed_unit, :bonus_speed_value, :bonus_speed_unit, + :total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, :auto_renew, :note, :is_active + ) + RETURNING *' + ); + $stmt->execute($params); + return $this->normalizeRow($stmt->fetch() ?: []); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('purchased_miners') . ' ( + project_key, owner_sub, miner_offer_id, purchased_at, label, runtime_months, + mining_speed_value, mining_speed_unit, bonus_speed_value, bonus_speed_unit, + total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, auto_renew, note, is_active + ) VALUES ( + :project_key, :owner_sub, :miner_offer_id, :purchased_at, :label, :runtime_months, + :mining_speed_value, :mining_speed_unit, :bonus_speed_value, :bonus_speed_unit, + :total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, :auto_renew, :note, :is_active + )' + ); + $stmt->execute($params); + $id = (int) $this->pdo->lastInsertId(); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('purchased_miners') . ' WHERE id = :id LIMIT 1'); + $fetch->execute(['id' => $id]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function updatePurchasedMinerAutoRenew(string $projectKey, int $minerId, bool $autoRenew): array + { + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->table('purchased_miners') . ' + SET auto_renew = :auto_renew + WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id' + ); + $stmt->execute([ + 'auto_renew' => $autoRenew ? 1 : 0, + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'id' => $minerId, + ]); + + $fetch = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('purchased_miners') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id LIMIT 1' + ); + $fetch->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'id' => $minerId, + ]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function getMinerOffer(string $projectKey, int $offerId): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM ' . $this->table('miner_offers') . ' WHERE project_key = :project_key AND id = :id LIMIT 1'); + $stmt->execute(['project_key' => $projectKey, 'id' => $offerId]); + $row = $stmt->fetch(); + return is_array($row) ? $this->normalizeRow($row) : null; + } + + public function getLatestFxRate(string $baseCurrency, string $targetCurrency): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT + r.id, + f.id AS fetch_id, + f.base_currency, + r.currency_code AS target_currency, + r.current_value AS rate, + f.rate_date, + f.provider, + f.fetched_at + FROM ' . $this->table('fx_rates') . ' r + INNER JOIN ' . $this->table('fx_fetches') . ' f ON f.id = r.fetch_id + WHERE f.base_currency = :base_currency AND r.currency_code = :target_currency + ORDER BY f.rate_date DESC, f.fetched_at DESC, r.id DESC + LIMIT 1' + ); + $stmt->execute([ + 'base_currency' => strtoupper($baseCurrency), + 'target_currency' => strtoupper($targetCurrency), + ]); + $row = $stmt->fetch(); + return is_array($row) ? $this->normalizeRow($row) : null; + } + + public function getLatestFxFetch(?string $baseCurrency = null): ?array + { + $sql = 'SELECT id, provider, base_currency, rate_date, fetched_at + FROM ' . $this->table('fx_fetches'); + $params = []; + + if ($baseCurrency !== null && trim($baseCurrency) !== '') { + $sql .= ' WHERE base_currency = :base_currency'; + $params['base_currency'] = strtoupper(trim($baseCurrency)); + } + + $sql .= ' ORDER BY fetched_at DESC, id DESC LIMIT 1'; + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + $row = $stmt->fetch(); + return is_array($row) ? $this->normalizeRow($row) : null; + } + + public function listFxRates(int $limit = 30): array + { + $stmt = $this->pdo->prepare( + 'SELECT + r.id, + r.fetch_id, + f.base_currency, + r.currency_code AS target_currency, + r.current_value AS rate, + f.rate_date, + f.provider, + f.fetched_at + FROM ' . $this->table('fx_rates') . ' r + INNER JOIN ' . $this->table('fx_fetches') . ' f ON f.id = r.fetch_id + WHERE f.id IN ( + SELECT id + FROM ' . $this->table('fx_fetches') . ' + ORDER BY fetched_at DESC, id DESC + LIMIT :limit + ) + ORDER BY f.fetched_at DESC, f.id DESC, r.currency_code ASC' + ); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function listAllFxRates(): array + { + $stmt = $this->pdo->query( + 'SELECT + r.id, + r.fetch_id, + f.base_currency, + r.currency_code AS target_currency, + r.current_value AS rate, + f.rate_date, + f.provider, + f.fetched_at + FROM ' . $this->table('fx_rates') . ' r + INNER JOIN ' . $this->table('fx_fetches') . ' f ON f.id = r.fetch_id + ORDER BY f.fetched_at ASC, f.id ASC, r.currency_code ASC' + ); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function restoreFxFetch(string $baseCurrency, string $provider, string $rateDate, ?string $fetchedAt, array $rates): array + { + $baseCurrency = strtoupper(trim($baseCurrency)); + $provider = trim($provider) !== '' ? trim($provider) : 'currencyapi'; + $fetchedAt = trim((string) $fetchedAt) !== '' ? (string) $fetchedAt : $this->currentUtcTimestamp(); + + if ($rates === []) { + return ['fetch' => null, 'rates' => []]; + } + + if ($this->driver === 'pgsql') { + $fetchStmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('fx_fetches') . ' ( + provider, base_currency, rate_date, fetched_at + ) VALUES ( + :provider, :base_currency, :rate_date, :fetched_at + ) + RETURNING *' + ); + $fetchStmt->execute([ + 'provider' => $provider, + 'base_currency' => $baseCurrency, + 'rate_date' => $rateDate, + 'fetched_at' => $fetchedAt, + ]); + $fetch = $this->normalizeRow($fetchStmt->fetch() ?: []); + } else { + $fetchStmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('fx_fetches') . ' ( + provider, base_currency, rate_date, fetched_at + ) VALUES ( + :provider, :base_currency, :rate_date, :fetched_at + )' + ); + $fetchStmt->execute([ + 'provider' => $provider, + 'base_currency' => $baseCurrency, + 'rate_date' => $rateDate, + 'fetched_at' => $fetchedAt, + ]); + $fetchId = (int) $this->pdo->lastInsertId(); + $fetchLookup = $this->pdo->prepare('SELECT * FROM ' . $this->table('fx_fetches') . ' WHERE id = :id LIMIT 1'); + $fetchLookup->execute(['id' => $fetchId]); + $fetch = $this->normalizeRow($fetchLookup->fetch() ?: []); + } + + $placeholders = []; + $params = ['fetch_id' => $fetch['id']]; + $savedRates = []; + $index = 0; + foreach ($rates as $currencyCode => $rate) { + if (!is_numeric($rate)) { + continue; + } + $codeKey = 'currency_code_' . $index; + $valueKey = 'current_value_' . $index; + $targetCurrency = strtoupper(trim((string) $currencyCode)); + if ($targetCurrency === '') { + continue; + } + $placeholders[] = "(:fetch_id, :{$codeKey}, :{$valueKey})"; + $params[$codeKey] = $targetCurrency; + $params[$valueKey] = (float) $rate; + $savedRates[] = [ + 'fetch_id' => $fetch['id'], + 'base_currency' => $baseCurrency, + 'target_currency' => $targetCurrency, + 'rate' => (float) $rate, + 'rate_date' => $rateDate, + 'provider' => $provider, + 'fetched_at' => $fetch['fetched_at'] ?? $fetchedAt, + ]; + $index++; + } + + if ($placeholders !== []) { + $insert = $this->pdo->prepare('INSERT INTO ' . $this->table('fx_rates') . ' (fetch_id, currency_code, current_value) VALUES ' . implode(', ', $placeholders)); + $insert->execute($params); + } + + return [ + 'fetch' => $fetch, + 'rates' => $savedRates, + ]; + } + + public function getLatestMeasurementRate(string $baseCurrency, string $targetCurrency): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT + id, + measurement_id, + base_currency, + quote_currency AS target_currency, + rate, + provider, + created_at + FROM ' . $this->table('measurement_rates') . ' + WHERE owner_sub = :owner_sub AND base_currency = :base_currency AND quote_currency = :target_currency + ORDER BY measurement_id DESC, id DESC + LIMIT 1' + ); + $stmt->execute([ + 'owner_sub' => $this->ownerSub, + 'base_currency' => strtoupper($baseCurrency), + 'target_currency' => strtoupper($targetCurrency), + ]); + $row = $stmt->fetch(); + return is_array($row) ? $this->normalizeRow($row) : null; + } + + public function saveFxRate(string $baseCurrency, string $targetCurrency, float $rate, string $rateDate, string $provider = 'currencyapi'): array + { + $result = $this->saveFxFetch($baseCurrency, $provider, $rateDate, [ + strtoupper($targetCurrency) => $rate, + ]); + return $result['rates'][0] ?? [ + 'fetch_id' => $result['fetch']['id'] ?? null, + 'base_currency' => strtoupper($baseCurrency), + 'target_currency' => strtoupper($targetCurrency), + 'rate' => $rate, + 'rate_date' => $rateDate, + 'provider' => $provider, + ]; + } + + public function saveFxFetch(string $baseCurrency, string $provider, string $rateDate, array $rates): array + { + $baseCurrency = strtoupper(trim($baseCurrency)); + $provider = trim($provider) !== '' ? trim($provider) : 'currencyapi'; + $fetchedAt = $this->currentUtcTimestamp(); + $normalizedRates = []; + + foreach ($rates as $currencyCode => $rate) { + if (!is_numeric($rate)) { + continue; + } + + $normalizedCurrencyCode = strtoupper(trim((string) $currencyCode)); + if ($normalizedCurrencyCode === '' || $normalizedCurrencyCode === $baseCurrency) { + continue; + } + + $normalizedRates[$normalizedCurrencyCode] = (float) $rate; + } + + $this->debug?->add('db.saveFxFetch.start', [ + 'base_currency' => $baseCurrency, + 'provider' => $provider, + 'rate_date' => $rateDate, + 'rate_count' => count($normalizedRates), + 'fetched_at' => $fetchedAt, + ]); + + $startedTransaction = false; + if (!$this->pdo->inTransaction()) { + $this->debug?->add('db.saveFxFetch.transaction.begin.start', [ + 'already_in_transaction' => false, + ]); + $this->pdo->beginTransaction(); + $startedTransaction = true; + $this->debug?->add('db.saveFxFetch.transaction.begin.end', [ + 'started_transaction' => true, + ]); + } else { + $this->debug?->add('db.saveFxFetch.transaction.reuse', [ + 'already_in_transaction' => true, + ]); + } + + try { + if ($this->driver === 'pgsql') { + $this->debug?->add('db.saveFxFetch.insertFetch.start', [ + 'driver' => $this->driver, + ]); + $fetchStmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('fx_fetches') . ' ( + provider, base_currency, rate_date, fetched_at + ) VALUES ( + :provider, :base_currency, :rate_date, :fetched_at + ) + RETURNING *' + ); + $fetchStmt->execute([ + 'provider' => $provider, + 'base_currency' => $baseCurrency, + 'rate_date' => $rateDate, + 'fetched_at' => $fetchedAt, + ]); + $fetch = $this->normalizeRow($fetchStmt->fetch() ?: []); + $this->debug?->add('db.saveFxFetch.insertFetch.end', [ + 'driver' => $this->driver, + 'fetch_id' => $fetch['id'] ?? null, + ]); + } else { + $this->debug?->add('db.saveFxFetch.insertFetch.start', [ + 'driver' => $this->driver, + ]); + $fetchStmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('fx_fetches') . ' ( + provider, base_currency, rate_date, fetched_at + ) VALUES ( + :provider, :base_currency, :rate_date, :fetched_at + )' + ); + $fetchStmt->execute([ + 'provider' => $provider, + 'base_currency' => $baseCurrency, + 'rate_date' => $rateDate, + 'fetched_at' => $fetchedAt, + ]); + $fetchId = (int) $this->pdo->lastInsertId(); + $fetchLookup = $this->pdo->prepare('SELECT * FROM ' . $this->table('fx_fetches') . ' WHERE id = :id LIMIT 1'); + $fetchLookup->execute(['id' => $fetchId]); + $fetch = $this->normalizeRow($fetchLookup->fetch() ?: []); + $this->debug?->add('db.saveFxFetch.insertFetch.end', [ + 'driver' => $this->driver, + 'fetch_id' => $fetch['id'] ?? null, + ]); + } + + if ($normalizedRates === []) { + if ($startedTransaction) { + $this->debug?->add('db.saveFxFetch.commit.start', [ + 'fetch_id' => $fetch['id'] ?? null, + 'rate_count' => 0, + ]); + $this->pdo->commit(); + $this->debug?->add('db.saveFxFetch.commit.end', [ + 'fetch_id' => $fetch['id'] ?? null, + 'rate_count' => 0, + ]); + } + + $this->debug?->add('db.saveFxFetch.end', [ + 'fetch_id' => $fetch['id'] ?? null, + 'rate_count' => 0, + ]); + + return [ + 'fetch' => $fetch, + 'rates' => [], + ]; + } + + $placeholders = []; + $params = ['fetch_id' => $fetch['id']]; + $savedRates = []; + $index = 0; + foreach ($normalizedRates as $currencyCode => $rate) { + $codeKey = 'currency_code_' . $index; + $valueKey = 'current_value_' . $index; + $placeholders[] = "(:fetch_id, :{$codeKey}, :{$valueKey})"; + $params[$codeKey] = $currencyCode; + $params[$valueKey] = $rate; + $savedRates[] = [ + 'fetch_id' => $fetch['id'], + 'base_currency' => $baseCurrency, + 'target_currency' => $currencyCode, + 'rate' => $rate, + 'rate_date' => $rateDate, + 'provider' => $provider, + 'fetched_at' => $fetch['fetched_at'] ?? null, + ]; + $index++; + } + + $this->debug?->add('db.saveFxFetch.insertRates.start', [ + 'fetch_id' => $fetch['id'] ?? null, + 'rate_count' => count($savedRates), + ]); + $sql = 'INSERT INTO ' . $this->table('fx_rates') . ' (fetch_id, currency_code, current_value) VALUES ' . implode(', ', $placeholders); + $insert = $this->pdo->prepare($sql); + $insert->execute($params); + $this->debug?->add('db.saveFxFetch.insertRates.end', [ + 'fetch_id' => $fetch['id'] ?? null, + 'rate_count' => count($savedRates), + ]); + + if ($startedTransaction) { + $this->debug?->add('db.saveFxFetch.commit.start', [ + 'fetch_id' => $fetch['id'] ?? null, + 'rate_count' => count($savedRates), + ]); + $this->pdo->commit(); + $this->debug?->add('db.saveFxFetch.commit.end', [ + 'fetch_id' => $fetch['id'] ?? null, + 'rate_count' => count($savedRates), + ]); + } + + $this->debug?->add('db.saveFxFetch.end', [ + 'fetch_id' => $fetch['id'] ?? null, + 'rate_count' => count($savedRates), + ]); + + return [ + 'fetch' => $fetch, + 'rates' => $savedRates, + ]; + } catch (\Throwable $exception) { + if ($startedTransaction && $this->pdo->inTransaction()) { + $this->debug?->add('db.saveFxFetch.rollback.start', [ + 'message' => $exception->getMessage(), + ]); + $this->pdo->rollBack(); + $this->debug?->add('db.saveFxFetch.rollback.end', [ + 'message' => $exception->getMessage(), + ]); + } + + $this->debug?->add('db.saveFxFetch.error', [ + 'message' => $exception->getMessage(), + ]); + throw $exception; + } + } + + private function table(string $logicalName): string + { + return match ($logicalName) { + 'projects' => $this->prefix . 'projects', + 'settings' => $this->prefix . 'settings', + 'cost_plans' => $this->prefix . 'cost_plans', + 'measurements' => $this->prefix . 'measurements', + 'targets' => $this->prefix . 'targets', + 'dashboard_definitions' => $this->prefix . 'dashboard_definitions', + 'fx_fetches' => $this->prefix . 'fx_fetches', + 'fx_rates' => $this->prefix . 'fx_rates', + 'measurement_rates' => $this->prefix . 'measurement_rates', + 'payouts' => $this->prefix . 'payouts', + 'wallet_snapshots' => $this->prefix . 'wallet_snapshots', + 'miner_offers' => $this->prefix . 'miner_offers', + 'purchased_miners' => $this->prefix . 'purchased_miners', + default => throw new \RuntimeException('Unknown mining table: ' . $logicalName), + }; + } + + private function normalizeInsertPayload(string $projectKey, array $payload): array + { + return [ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'label' => $payload['label'], + 'starts_at' => $payload['starts_at'], + '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'], + 'auto_renew' => $payload['auto_renew'], + 'base_price_amount' => $payload['base_price_amount'] ?? $payload['total_cost_amount'], + 'payment_type' => $payload['payment_type'] ?? 'fiat', + 'total_cost_amount' => $payload['total_cost_amount'], + 'currency' => $payload['currency'], + 'note' => $payload['note'], + 'is_active' => $payload['is_active'], + ]; + } + + private function normalizeOfferPayload(string $projectKey, array $payload): array + { + return [ + 'project_key' => $projectKey, + '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'], + 'base_price_amount' => $payload['base_price_amount'], + 'base_price_currency' => $payload['base_price_currency'], + 'payment_type' => $payload['payment_type'] ?? 'fiat', + 'auto_renew' => $payload['auto_renew'] ?? 0, + 'note' => $payload['note'], + 'is_active' => $payload['is_active'], + ]; + } + + private function normalizePurchasedPayload(string $projectKey, int $offerId, array $payload): array + { + return [ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'miner_offer_id' => $offerId, + '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'] ?? null, + 'reference_price_currency' => $payload['reference_price_currency'] ?? null, + 'auto_renew' => $payload['auto_renew'] ?? 0, + 'note' => $payload['note'], + 'is_active' => $payload['is_active'], + ]; + } + + private function normalizeRows(array $rows): array + { + return array_map(fn (array $row): array => $this->normalizeRow($row), $rows); + } + + private function normalizeRow(array $row): array + { + foreach (['ocr_flags', 'filters_json', 'preferred_currencies', 'balances_json'] as $jsonField) { + if (array_key_exists($jsonField, $row) && is_string($row[$jsonField]) && trim($row[$jsonField]) !== '') { + $decoded = json_decode($row[$jsonField], true); + if (json_last_error() === JSON_ERROR_NONE) { + $row[$jsonField] = $decoded; + } + } + } + + foreach (['is_active', 'auto_renew'] as $booleanField) { + if (array_key_exists($booleanField, $row)) { + $row[$booleanField] = $this->normalizeBoolean($row[$booleanField]); + } + } + + if (array_key_exists('is_crypto', $row)) { + $row['is_crypto'] = (bool) $this->normalizeBoolean($row['is_crypto']); + } + + return $row; + } + + private function isCryptoCode(string $code): bool + { + return in_array(strtoupper($code), [ + 'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC', + 'SOL', 'USDC', 'USDT', 'XRP', + ], true); + } + + private function normalizeBoolean(mixed $value): bool|int|null + { + if ($value === null) { + return null; + } + + if (is_bool($value) || is_int($value)) { + return $value; + } + + if (is_string($value)) { + $normalized = strtolower(trim($value)); + if (in_array($normalized, ['t', 'true', '1', 'y', 'yes'], true)) { + return true; + } + if (in_array($normalized, ['f', 'false', '0', 'n', 'no'], true)) { + return false; + } + } + + return (int) $value; + } + + private function currentUtcTimestamp(): string + { + $timezone = new \DateTimeZone('UTC'); + return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s'); + } +} diff --git a/modules/mining-checker/src/Infrastructure/ModuleConfig.php b/modules/mining-checker/src/Infrastructure/ModuleConfig.php new file mode 100644 index 00000000..261f0722 --- /dev/null +++ b/modules/mining-checker/src/Infrastructure/ModuleConfig.php @@ -0,0 +1,66 @@ +config = $config; + } + + public static function load(string $moduleBasePath): self + { + $config = require $moduleBasePath . '/config/module.php'; + return new self(is_array($config) ? $config : []); + } + + public function defaultProjectKey(): string + { + return (string) ($this->config['default_project_key'] ?? 'doge-main'); + } + + public function useProjectDatabase(): bool + { + return (bool) ($this->config['use_project_database'] ?? true); + } + + public function tablePrefix(): string + { + return (string) ($this->config['table_prefix'] ?? 'miningcheck_'); + } + + public function uploadsDir(): string + { + return (string) ($this->config['uploads_dir'] ?? sys_get_temp_dir()); + } + + public function uploadsPublicPrefix(): string + { + return rtrim((string) ($this->config['uploads_public_prefix'] ?? '/uploads'), '/'); + } + + public function ocr(): array + { + return (array) ($this->config['ocr'] ?? []); + } + + public function fx(): array + { + return (array) ($this->config['fx'] ?? []); + } + + public function debug(): array + { + return (array) ($this->config['debug'] ?? []); + } + + public function debugDir(): string + { + $debug = $this->debug(); + return (string) ($debug['dir'] ?? (dirname($this->uploadsDir()) . '/debug')); + } +} diff --git a/modules/mining-checker/src/Infrastructure/SchemaManager.php b/modules/mining-checker/src/Infrastructure/SchemaManager.php new file mode 100644 index 00000000..c4c40256 --- /dev/null +++ b/modules/mining-checker/src/Infrastructure/SchemaManager.php @@ -0,0 +1,1482 @@ +pdo = $pdo; + $this->prefix = $prefix; + $this->moduleBasePath = rtrim($moduleBasePath, '/'); + $this->driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); + $this->sqlImporter = new SqlDataImporter($this->pdo); + } + + public function ensureSchema(): void + { + $status = $this->schemaStatus(); + if ($status['all_present']) { + return; + } + + if ($status['present_count'] === 0) { + $this->importSchema(); + return; + } + + throw new ApiException( + 'Mining-Checker Tabellen sind nur teilweise vorhanden. Bitte Migration manuell abschliessen.', + 500, + [ + 'missing_tables' => $status['missing_tables'], + 'present_tables' => $status['present_tables'], + ] + ); + } + + public function initializeSchema(bool $dropExisting = false): array + { + $before = $this->schemaStatus(); + $droppedTables = []; + + if ($dropExisting && $before['present_count'] > 0) { + $droppedTables = $this->dropExistingTables(); + } + + $statusBeforeImport = $this->schemaStatus(); + $imported = false; + + if (!$statusBeforeImport['all_present']) { + if ($statusBeforeImport['present_count'] > 0 && !$dropExisting) { + throw new ApiException( + 'Mining-Checker Tabellen sind nur teilweise vorhanden. Fuer eine Neuinitialisierung bitte Reset aktivieren.', + 409, + [ + 'missing_tables' => $statusBeforeImport['missing_tables'], + 'present_tables' => $statusBeforeImport['present_tables'], + ] + ); + } + + $this->importSchema(); + $imported = true; + } + + $after = $this->schemaStatus(); + + return [ + 'dropped_existing' => $dropExisting, + 'dropped_tables' => $droppedTables, + 'schema_imported' => $imported, + 'before' => $before, + 'after' => $after, + 'message' => $after['all_present'] + ? ($imported + ? 'Mining-Checker Schema wurde erfolgreich angelegt.' + : 'Mining-Checker Tabellen waren bereits vollstaendig vorhanden.') + : 'Mining-Checker Schema ist weiterhin unvollstaendig.', + ]; + } + + public function rebuildSchemaDirect(): array + { + $dropped = []; + foreach ($this->knownTablesInDropOrder() as $table) { + try { + if ($this->driver === 'pgsql') { + $this->pdo->exec('DROP TABLE IF EXISTS ' . $table . ' CASCADE'); + } else { + $safeTable = str_replace('`', '``', $table); + $this->pdo->exec('DROP TABLE IF EXISTS `' . $safeTable . '`'); + } + $dropped[] = $table; + } catch (\Throwable $exception) { + throw new ApiException( + 'Mining-Checker Tabellen konnten nicht direkt geloescht werden.', + 500, + ['message' => $exception->getMessage(), 'table' => $table] + ); + } + } + + $this->importSchema(); + + return [ + 'dropped_tables' => $dropped, + 'message' => 'Mining-Checker Tabellen wurden geloescht und das Schema neu aufgebaut.', + ]; + } + + public function schemaStatus(): array + { + $requiredTables = $this->knownTablesInCreateOrder(); + + $presentTables = $this->existingTables($requiredTables); + $missingTables = array_values(array_diff($requiredTables, $presentTables)); + $pendingUpgrades = []; + if ($missingTables === []) { + $pendingUpgrades = $this->detectPendingUpgrades($presentTables); + } + + return [ + 'required_tables' => $requiredTables, + 'present_tables' => $presentTables, + 'missing_tables' => $missingTables, + 'present_count' => count($presentTables), + 'missing_count' => count($missingTables), + 'pending_upgrades' => $pendingUpgrades, + 'pending_upgrade_count' => count($pendingUpgrades), + 'all_present' => $missingTables === [], + ]; + } + + public function upgradeSchema(): array + { + $before = $this->lightweightStatus(); + if ($before['present_count'] === 0) { + $this->importSchema(); + $after = $this->lightweightStatus(); + return [ + 'upgraded' => ['schema_initialized'], + 'before' => $before, + 'after' => $after, + 'message' => 'Mining-Checker Schema war leer und wurde initial angelegt.', + ]; + } + + if (!$before['core_present']) { + throw new ApiException( + 'Schema-Upgrade ist nur moeglich, wenn alle Grundtabellen vorhanden sind. Bitte zuerst initialisieren oder resetten.', + 409, + [ + 'missing_tables' => $before['missing_core_tables'], + 'present_tables' => $before['present_tables'], + ] + ); + } + + $applied = []; + + if ($this->tableExists($this->prefix . 'cost_plans')) { + $requiredColumns = ['mining_speed_value', 'mining_speed_unit', 'bonus_speed_value', 'bonus_speed_unit', 'base_price_amount', 'payment_type']; + $existingColumns = $this->existingColumns($this->prefix . 'cost_plans', $requiredColumns); + if (count($existingColumns) !== count($requiredColumns)) { + $this->upgradeCostPlanColumns(); + $applied[] = 'cost_plan_columns'; + } + } + + $settingsColumns = $this->existingColumns($this->prefix . 'settings', ['preferred_currencies', 'report_currency', 'crypto_currency', 'display_timezone', 'fx_max_age_hours', 'module_theme_mode', 'module_theme_accent']); + if (!in_array('preferred_currencies', $settingsColumns, true) || !in_array('report_currency', $settingsColumns, true) || !in_array('crypto_currency', $settingsColumns, true) || !in_array('display_timezone', $settingsColumns, true) || !in_array('fx_max_age_hours', $settingsColumns, true) || !in_array('module_theme_mode', $settingsColumns, true) || !in_array('module_theme_accent', $settingsColumns, true)) { + $this->upgradeSettingsPreferredCurrenciesColumn(); + $applied[] = 'settings_preferences'; + } + if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'fx_fetch_id')) { + $this->ensureMeasurementFxReferenceColumn(); + $applied[] = 'measurement_fx_reference'; + } + if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'coin_currency')) { + $this->ensureMeasurementCoinCurrencyColumn(); + $applied[] = 'measurement_coin_currency'; + } + + if (!$this->tableExists($this->prefix . 'measurement_rates')) { + $this->ensureMeasurementRatesTable(); + $applied[] = 'measurement_rates_table'; + } + if (!$this->tableExists($this->prefix . 'payouts')) { + $this->ensurePayoutsTable(); + $applied[] = 'payouts_table'; + } + if (!$this->tableExists($this->prefix . 'wallet_snapshots')) { + $this->ensureWalletSnapshotsTable(); + $applied[] = 'wallet_snapshots_table'; + } + if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) { + $this->ensureFxRatesTable(); + $applied[] = 'fx_rates_table'; + } + if (!$this->tableExists($this->prefix . 'miner_offers')) { + $this->upgradeMinerOffersTable(); + $applied[] = 'miner_offers_table'; + } + if (!$this->tableExists($this->prefix . 'purchased_miners')) { + $this->upgradePurchasedMinersTable(); + $applied[] = 'purchased_miners_table'; + } + if ($this->tableExists($this->prefix . 'targets') && !$this->columnExists($this->prefix . 'targets', 'miner_offer_id')) { + $this->upgradeTargetOfferColumn(); + $applied[] = 'target_offer_column'; + } + if ($this->tableExists($this->prefix . 'miner_offers') && ( + !$this->columnExists($this->prefix . 'miner_offers', 'base_price_amount') || + !$this->columnExists($this->prefix . 'miner_offers', 'base_price_currency') || + !$this->columnExists($this->prefix . 'miner_offers', 'payment_type') || + !$this->columnExists($this->prefix . 'miner_offers', 'auto_renew') + )) { + $this->upgradeMinerOfferBasePriceColumns(); + $applied[] = 'miner_offer_base_columns'; + } + if ($this->tableExists($this->prefix . 'purchased_miners') && ( + !$this->columnExists($this->prefix . 'purchased_miners', 'reference_price_amount') || + !$this->columnExists($this->prefix . 'purchased_miners', 'reference_price_currency') || + !$this->columnExists($this->prefix . 'purchased_miners', 'auto_renew') + )) { + $this->upgradePurchasedMinerReferenceColumns(); + $applied[] = 'purchased_miner_reference_columns'; + } + if ($this->tableExists($this->prefix . 'targets') && $this->tableExists($this->prefix . 'miner_offers')) { + $this->ensureTargetOfferForeignKey(); + $applied[] = 'target_offer_foreign_key'; + } + + $after = $this->lightweightStatus(); + $allApplied = array_values(array_unique($applied)); + + return [ + 'upgraded' => $allApplied, + 'before' => $before, + 'after' => $after, + 'message' => $allApplied === [] + ? 'Schema ist bereits auf dem neuesten Stand.' + : 'Schema-Upgrade erfolgreich ausgefuehrt.', + ]; + } + + public function upgradeSchemaDirect(): array + { + $coreTables = [ + $this->prefix . 'projects', + $this->prefix . 'settings', + $this->prefix . 'cost_plans', + $this->prefix . 'measurements', + $this->prefix . 'targets', + $this->prefix . 'dashboard_definitions', + ]; + + $presentCoreTables = $this->existingTables($coreTables); + if ($presentCoreTables === []) { + $this->importSchema(); + return [ + 'upgraded' => ['schema_initialized'], + 'message' => 'Mining-Checker Schema wurde neu angelegt.', + ]; + } + + if (count($presentCoreTables) !== count($coreTables)) { + throw new ApiException( + 'Grundtabellen sind nur teilweise vorhanden. Bitte Schema zuerst sauber initialisieren.', + 409, + ['missing_core_tables' => array_values(array_diff($coreTables, $presentCoreTables))] + ); + } + + $applied = []; + $requiredColumns = ['mining_speed_value', 'mining_speed_unit', 'bonus_speed_value', 'bonus_speed_unit', 'base_price_amount', 'payment_type']; + $existingColumns = $this->existingColumns($this->prefix . 'cost_plans', $requiredColumns); + if (count($existingColumns) !== count($requiredColumns)) { + $this->upgradeCostPlanColumns(); + $applied[] = 'cost_plan_columns'; + } + + $settingsColumns = $this->existingColumns($this->prefix . 'settings', ['preferred_currencies', 'report_currency', 'crypto_currency', 'display_timezone', 'fx_max_age_hours', 'module_theme_mode', 'module_theme_accent']); + if (!in_array('preferred_currencies', $settingsColumns, true) || !in_array('report_currency', $settingsColumns, true) || !in_array('crypto_currency', $settingsColumns, true) || !in_array('display_timezone', $settingsColumns, true) || !in_array('fx_max_age_hours', $settingsColumns, true) || !in_array('module_theme_mode', $settingsColumns, true) || !in_array('module_theme_accent', $settingsColumns, true)) { + $this->upgradeSettingsPreferredCurrenciesColumn(); + $applied[] = 'settings_preferences'; + } + if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'fx_fetch_id')) { + $this->ensureMeasurementFxReferenceColumn(); + $applied[] = 'measurement_fx_reference'; + } + if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'coin_currency')) { + $this->ensureMeasurementCoinCurrencyColumn(); + $applied[] = 'measurement_coin_currency'; + } + + if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) { + $this->ensureFxRatesTable(); + $applied[] = 'fx_rates_table'; + } + + if (!$this->tableExists($this->prefix . 'measurement_rates')) { + $this->ensureMeasurementRatesTable(); + $applied[] = 'measurement_rates_table'; + } + + if (!$this->tableExists($this->prefix . 'payouts')) { + $this->ensurePayoutsTable(); + $applied[] = 'payouts_table'; + } + if (!$this->tableExists($this->prefix . 'wallet_snapshots')) { + $this->ensureWalletSnapshotsTable(); + $applied[] = 'wallet_snapshots_table'; + } + + if (!$this->tableExists($this->prefix . 'miner_offers')) { + $this->upgradeMinerOffersTable(); + $applied[] = 'miner_offers_table'; + } + + if (!$this->tableExists($this->prefix . 'purchased_miners')) { + $this->upgradePurchasedMinersTable(); + $applied[] = 'purchased_miners_table'; + } + if ($this->tableExists($this->prefix . 'targets') && !$this->columnExists($this->prefix . 'targets', 'miner_offer_id')) { + $this->upgradeTargetOfferColumn(); + $applied[] = 'target_offer_column'; + } + if ($this->tableExists($this->prefix . 'miner_offers') && ( + !$this->columnExists($this->prefix . 'miner_offers', 'base_price_amount') || + !$this->columnExists($this->prefix . 'miner_offers', 'base_price_currency') || + !$this->columnExists($this->prefix . 'miner_offers', 'payment_type') || + !$this->columnExists($this->prefix . 'miner_offers', 'auto_renew') + )) { + $this->upgradeMinerOfferBasePriceColumns(); + $applied[] = 'miner_offer_base_columns'; + } + if ($this->tableExists($this->prefix . 'purchased_miners') && ( + !$this->columnExists($this->prefix . 'purchased_miners', 'reference_price_amount') || + !$this->columnExists($this->prefix . 'purchased_miners', 'reference_price_currency') || + !$this->columnExists($this->prefix . 'purchased_miners', 'auto_renew') + )) { + $this->upgradePurchasedMinerReferenceColumns(); + $applied[] = 'purchased_miner_reference_columns'; + } + if ($this->tableExists($this->prefix . 'targets') && $this->tableExists($this->prefix . 'miner_offers')) { + $this->ensureTargetOfferForeignKey(); + $applied[] = 'target_offer_foreign_key'; + } + + return [ + 'upgraded' => $applied, + 'message' => $applied === [] + ? 'Schema ist bereits auf dem neuesten Stand.' + : 'Direktes Schema-Upgrade erfolgreich ausgefuehrt.', + ]; + } + + public function importSqlFile(array $uploadedFile): array + { + try { + $file = UploadedSqlFile::read($uploadedFile); + } catch (\RuntimeException $exception) { + throw new ApiException($exception->getMessage(), 422); + } + + $this->prepareSchemaForSqlImport(); + + $statementCount = $this->executeSqlContent((string) $file['sql'], (string) $file['file']); + + return [ + 'file' => (string) $file['file'], + 'statement_count' => $statementCount, + 'message' => 'SQL-Datei wurde erfolgreich eingespielt.', + ]; + } + + private function prepareSchemaForSqlImport(): void + { + $status = $this->schemaStatus(); + if (!$status['all_present']) { + $this->initializeSchema(false); + } + + $this->upgradeSchemaDirect(); + $this->ensureLegacyImportCompatibility(); + } + + private function ensureLegacyImportCompatibility(): void + { + $this->ensureLegacyMinerOfferImportColumns(); + } + + private function ensureMeasurementFxReferenceColumn(): void + { + $table = $this->prefix . 'measurements'; + if (!$this->tableExists($table)) { + return; + } + + $statements = $this->driver === 'pgsql' + ? [ + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS fx_fetch_id BIGINT', + 'CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_fx_fetch ON ' . $table . ' (fx_fetch_id)', + ] + : [ + 'ALTER TABLE `' . $table . '` ADD COLUMN fx_fetch_id BIGINT UNSIGNED NULL', + 'ALTER TABLE `' . $table . '` ADD INDEX idx_miningcheck_measurements_fx_fetch (fx_fetch_id)', + ]; + + foreach ($statements as $statement) { + try { + $this->executeUpgradeStatements([$statement], 'Messpunkt-FX-Referenz konnte nicht angelegt werden.'); + } catch (\Throwable $exception) { + $message = strtolower($exception->getMessage()); + if ( + ($this->driver === 'mysql' && (str_contains($message, 'duplicate column') || str_contains($message, 'duplicate key name'))) || + ($this->driver === 'pgsql' && str_contains($message, 'already exists')) + ) { + continue; + } + + throw $exception; + } + } + } + + private function ensureMeasurementCoinCurrencyColumn(): void + { + $table = $this->prefix . 'measurements'; + $settingsTable = $this->prefix . 'settings'; + if (!$this->tableExists($table)) { + return; + } + + $statements = $this->driver === 'pgsql' + ? [ + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS coin_currency VARCHAR(10)', + 'UPDATE ' . $table . ' AS m + SET coin_currency = COALESCE(NULLIF(BTRIM(s.crypto_currency), \'\'), \'DOGE\') + FROM ' . $settingsTable . ' AS s + WHERE s.project_key = m.project_key + AND (m.coin_currency IS NULL OR BTRIM(m.coin_currency) = \'\')', + 'UPDATE ' . $table . ' SET coin_currency = \'DOGE\' WHERE coin_currency IS NULL OR BTRIM(coin_currency) = \'\'', + 'ALTER TABLE ' . $table . ' ALTER COLUMN coin_currency SET DEFAULT \'DOGE\'', + 'ALTER TABLE ' . $table . ' ALTER COLUMN coin_currency SET NOT NULL', + ] + : [ + 'ALTER TABLE `' . $table . '` ADD COLUMN coin_currency VARCHAR(10) NOT NULL DEFAULT \'DOGE\' AFTER coins_total', + 'UPDATE `' . $table . '` m + LEFT JOIN `' . $settingsTable . '` s ON s.project_key = m.project_key + SET m.coin_currency = COALESCE(NULLIF(TRIM(s.crypto_currency), \'\'), \'DOGE\') + WHERE m.coin_currency IS NULL OR TRIM(m.coin_currency) = \'\'', + ]; + + foreach ($statements as $index => $statement) { + try { + if ($this->driver === 'mysql' && $index === 0 && $this->columnExists($table, 'coin_currency')) { + continue; + } + $this->executeUpgradeStatements([$statement], 'Messpunkt-Coin-Waehrung konnte nicht angelegt werden.'); + } catch (\Throwable $exception) { + $message = strtolower($exception->getMessage()); + if ( + ($this->driver === 'mysql' && $index === 0 && str_contains($message, 'duplicate column')) || + ($this->driver === 'pgsql' && str_contains($message, 'already exists')) + ) { + continue; + } + + throw $exception; + } + } + } + + private function ensureLegacyMinerOfferImportColumns(): void + { + $table = $this->prefix . 'miner_offers'; + if (!$this->tableExists($table)) { + return; + } + + $statements = $this->driver === 'pgsql' + ? [ + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS price_amount NUMERIC(20,8)', + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS price_currency VARCHAR(10)', + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS usd_reference_amount NUMERIC(20,8)', + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS reference_price_amount NUMERIC(20,8)', + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS reference_price_currency VARCHAR(10)', + ] + : [ + 'ALTER TABLE `' . $table . '` ADD COLUMN price_amount DECIMAL(20,8) NULL', + 'ALTER TABLE `' . $table . '` ADD COLUMN price_currency VARCHAR(10) NULL', + 'ALTER TABLE `' . $table . '` ADD COLUMN usd_reference_amount DECIMAL(20,8) NULL', + 'ALTER TABLE `' . $table . '` ADD COLUMN reference_price_amount DECIMAL(20,8) NULL', + 'ALTER TABLE `' . $table . '` ADD COLUMN reference_price_currency VARCHAR(10) NULL', + ]; + + foreach ($statements as $statement) { + try { + $this->executeUpgradeStatements([$statement], 'Import-Kompatibilitaet fuer Miner-Angebote fehlgeschlagen.'); + } catch (\Throwable $exception) { + if ($this->driver === 'mysql' && str_contains(strtolower($exception->getMessage()), 'duplicate column')) { + continue; + } + + throw $exception; + } + } + } + + private function tableExists(string $table): bool + { + return in_array($table, $this->existingTables([$table]), true); + } + + private function importSchema(): void + { + $schemaFile = $this->resolveSchemaFile(); + if (!is_file($schemaFile)) { + throw new ApiException('Schema-Datei fuer Mining-Checker fehlt.', 500, ['schema_file' => $schemaFile]); + } + + $sql = (string) file_get_contents($schemaFile); + $this->executeSqlContent($sql, $schemaFile, 'Schema-Import fuer Mining-Checker fehlgeschlagen.'); + } + + private function executeSqlContent(string $sql, string $sourceLabel, string $errorMessage = 'SQL-Import fuer Mining-Checker fehlgeschlagen.'): int + { + try { + return $this->sqlImporter->importString($sql); + } catch (\RuntimeException $exception) { + $previous = $exception->getPrevious() ?? $exception; + + throw new ApiException( + $errorMessage, + 500, + [ + 'message' => $previous->getMessage(), + 'source' => $sourceLabel, + 'statement' => substr($exception->getMessage(), 0, 1000), + ] + ); + } catch (\Throwable $exception) { + throw new ApiException( + $errorMessage, + 500, + [ + 'message' => $exception->getMessage(), + 'source' => $sourceLabel, + 'statement' => null, + ] + ); + } + } + + private function dropExistingTables(): array + { + $tables = array_reverse($this->schemaStatus()['present_tables']); + if ($tables === []) { + return []; + } + + try { + if ($this->driver !== 'mysql') { + $this->pdo->beginTransaction(); + } else { + $this->pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + } + + foreach ($tables as $table) { + if ($this->driver === 'pgsql') { + $this->pdo->exec('DROP TABLE IF EXISTS ' . $table . ' CASCADE'); + } else { + $safeTable = str_replace('`', '``', $table); + $this->pdo->exec('DROP TABLE IF EXISTS `' . $safeTable . '`'); + } + } + + if ($this->driver === 'mysql') { + $this->pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } elseif ($this->pdo->inTransaction()) { + $this->pdo->commit(); + } + } catch (\Throwable $exception) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + + try { + if ($this->driver === 'mysql') { + $this->pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + } catch (\Throwable) { + } + + throw new ApiException( + 'Vorhandene Mining-Checker Tabellen konnten nicht geloescht werden.', + 500, + ['message' => $exception->getMessage()] + ); + } + + return $tables; + } + + private function resolveSchemaFile(): string + { + $specificFile = $this->moduleBasePath . '/sql/schema.' . $this->driver . '.sql'; + if (is_file($specificFile)) { + return $specificFile; + } + + return $this->moduleBasePath . '/sql/schema.sql'; + } + + private function detectPendingUpgrades(array $presentTables): array + { + $upgrades = []; + + if (in_array($this->prefix . 'cost_plans', $presentTables, true)) { + $requiredColumns = [ + 'mining_speed_value', + 'mining_speed_unit', + 'bonus_speed_value', + 'bonus_speed_unit', + 'base_price_amount', + 'payment_type', + ]; + + $existingColumns = $this->existingColumns($this->prefix . 'cost_plans', $requiredColumns); + foreach ($requiredColumns as $column) { + if (!in_array($column, $existingColumns, true)) { + $upgrades[] = 'cost_plan_columns'; + break; + } + } + } + + if ($this->tableExists($this->prefix . 'settings')) { + $requiredSettingsColumns = ['preferred_currencies', 'report_currency', 'crypto_currency', 'display_timezone', 'fx_max_age_hours', 'module_theme_mode', 'module_theme_accent']; + $existingSettingsColumns = $this->existingColumns($this->prefix . 'settings', $requiredSettingsColumns); + foreach ($requiredSettingsColumns as $column) { + if (!in_array($column, $existingSettingsColumns, true)) { + $upgrades[] = 'settings_preferences'; + break; + } + } + } + + if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'fx_fetch_id')) { + $upgrades[] = 'measurement_fx_reference'; + } + if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'coin_currency')) { + $upgrades[] = 'measurement_coin_currency'; + } + + if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) { + $upgrades[] = 'fx_rates_table'; + } + if (!$this->tableExists($this->prefix . 'measurement_rates')) { + $upgrades[] = 'measurement_rates_table'; + } + if (!$this->tableExists($this->prefix . 'payouts')) { + $upgrades[] = 'payouts_table'; + } + if (!$this->tableExists($this->prefix . 'wallet_snapshots')) { + $upgrades[] = 'wallet_snapshots_table'; + } + if (!$this->tableExists($this->prefix . 'miner_offers')) { + $upgrades[] = 'miner_offers_table'; + } + if (!$this->tableExists($this->prefix . 'purchased_miners')) { + $upgrades[] = 'purchased_miners_table'; + } + if ($this->tableExists($this->prefix . 'targets') && !$this->columnExists($this->prefix . 'targets', 'miner_offer_id')) { + $upgrades[] = 'target_offer_column'; + } + if ( + $this->tableExists($this->prefix . 'targets') && + $this->tableExists($this->prefix . 'miner_offers') && + !$this->foreignKeyExists('fk_mining_targets_offer') + ) { + $upgrades[] = 'target_offer_foreign_key'; + } + + return array_values(array_unique($upgrades)); + } + + private function columnExists(string $table, string $column): bool + { + return in_array($column, $this->existingColumns($table, [$column]), true); + } + + private function existingTables(array $tableNames): array + { + $tableNames = array_values(array_unique(array_filter($tableNames))); + if ($tableNames === []) { + return []; + } + + $placeholders = []; + $params = []; + foreach ($tableNames as $index => $tableName) { + $placeholder = ':table_' . $index; + $placeholders[] = $placeholder; + $params['table_' . $index] = $tableName; + } + + $schemaCondition = $this->driver === 'pgsql' + ? 'table_schema = current_schema()' + : 'table_schema = DATABASE()'; + + $sql = sprintf( + 'SELECT table_name FROM information_schema.tables WHERE %s AND table_name IN (%s)', + $schemaCondition, + implode(', ', $placeholders) + ); + + $statement = $this->pdo->prepare($sql); + $statement->execute($params); + + $rows = $statement->fetchAll(PDO::FETCH_COLUMN) ?: []; + return array_values(array_intersect($tableNames, array_map('strval', $rows))); + } + + private function existingColumns(string $table, array $columnNames): array + { + $columnNames = array_values(array_unique(array_filter($columnNames))); + if ($columnNames === []) { + return []; + } + + $placeholders = []; + $params = ['table_name' => $table]; + foreach ($columnNames as $index => $columnName) { + $placeholder = ':column_' . $index; + $placeholders[] = $placeholder; + $params['column_' . $index] = $columnName; + } + + $schemaCondition = $this->driver === 'pgsql' + ? 'table_schema = current_schema()' + : 'table_schema = DATABASE()'; + + $sql = sprintf( + 'SELECT column_name FROM information_schema.columns WHERE %s AND table_name = :table_name AND column_name IN (%s)', + $schemaCondition, + implode(', ', $placeholders) + ); + + $statement = $this->pdo->prepare($sql); + $statement->execute($params); + + $rows = $statement->fetchAll(PDO::FETCH_COLUMN) ?: []; + return array_values(array_intersect($columnNames, array_map('strval', $rows))); + } + + private function upgradeCostPlanColumns(): void + { + $table = $this->prefix . 'cost_plans'; + $columns = $this->driver === 'pgsql' + ? [ + 'mining_speed_value' => 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS mining_speed_value NUMERIC(20,4)', + 'mining_speed_unit' => 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS mining_speed_unit VARCHAR(8)', + 'bonus_speed_value' => 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS bonus_speed_value NUMERIC(20,4)', + 'bonus_speed_unit' => 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS bonus_speed_unit VARCHAR(8)', + 'base_price_amount' => 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS base_price_amount NUMERIC(20,8)', + 'payment_type' => 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS payment_type VARCHAR(10) NOT NULL DEFAULT \'fiat\'', + ] + : [ + 'mining_speed_value' => 'ALTER TABLE `' . $table . '` ADD COLUMN mining_speed_value DECIMAL(20,4) NULL', + 'mining_speed_unit' => 'ALTER TABLE `' . $table . '` ADD COLUMN mining_speed_unit VARCHAR(8) NULL', + 'bonus_speed_value' => 'ALTER TABLE `' . $table . '` ADD COLUMN bonus_speed_value DECIMAL(20,4) NULL', + 'bonus_speed_unit' => 'ALTER TABLE `' . $table . '` ADD COLUMN bonus_speed_unit VARCHAR(8) NULL', + 'base_price_amount' => 'ALTER TABLE `' . $table . '` ADD COLUMN base_price_amount DECIMAL(20,8) NULL', + 'payment_type' => 'ALTER TABLE `' . $table . '` ADD COLUMN payment_type VARCHAR(10) NOT NULL DEFAULT \'fiat\'', + ]; + + foreach ($columns as $targetColumn => $statement) { + try { + if ($this->driver === 'mysql') { + if ($this->columnExists($table, $targetColumn)) { + continue; + } + } + $this->pdo->exec($statement); + } catch (\Throwable $exception) { + throw new ApiException( + 'Schema-Upgrade fuer Mining-Checker fehlgeschlagen.', + 500, + ['message' => $exception->getMessage(), 'statement' => $statement] + ); + } + } + + $backfillStatements = $this->driver === 'pgsql' + ? [ + 'UPDATE ' . $table . ' SET base_price_amount = total_cost_amount WHERE base_price_amount IS NULL', + "UPDATE " . $table . " SET payment_type = CASE WHEN UPPER(currency) IN ('ADA','ARB','BNB','BTC','DAI','DOGE','DOT','ETH','LINK','LTC','SOL','USDC','USDT','XRP') THEN 'crypto' ELSE 'fiat' END WHERE payment_type IS NULL OR BTRIM(payment_type) = ''", + ] + : [ + 'UPDATE `' . $table . '` SET base_price_amount = total_cost_amount WHERE base_price_amount IS NULL', + "UPDATE `" . $table . "` SET payment_type = CASE WHEN UPPER(currency) IN ('ADA','ARB','BNB','BTC','DAI','DOGE','DOT','ETH','LINK','LTC','SOL','USDC','USDT','XRP') THEN 'crypto' ELSE 'fiat' END WHERE payment_type IS NULL OR TRIM(payment_type) = ''", + ]; + $this->executeUpgradeStatements($backfillStatements, 'Backfill fuer Kostenplaene fehlgeschlagen.'); + } + + public function ensureFxRatesTable(): void + { + if ($this->tableExists($this->prefix . 'fx_fetches') && $this->tableExists($this->prefix . 'fx_rates')) { + return; + } + + $this->upgradeFxRatesTable(); + } + + public function ensureExtendedTables(): void + { + $this->ensureFxRatesTable(); + $this->ensureMeasurementRatesTable(); + $this->ensurePayoutsTable(); + $this->ensureMinerTables(); + } + + public function ensureMeasurementRatesTable(): void + { + if ($this->tableExists($this->prefix . 'measurement_rates')) { + return; + } + + $table = $this->prefix . 'measurement_rates'; + $measurementTable = $this->prefix . 'measurements'; + $statements = $this->driver === 'pgsql' + ? [ + 'CREATE TABLE IF NOT EXISTS ' . $table . ' ( + id BIGSERIAL PRIMARY KEY, + measurement_id BIGINT NOT NULL, + project_key VARCHAR(64) NOT NULL, + base_currency VARCHAR(10) NOT NULL, + quote_currency VARCHAR(10) NOT NULL, + rate NUMERIC(20,10) NOT NULL, + provider VARCHAR(32) NOT NULL DEFAULT \'derived\', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES ' . $measurementTable . '(id) ON DELETE CASCADE, + CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency) + )', + 'CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_measurement ON ' . $table . ' (project_key, measurement_id)', + ] + : [ + 'CREATE TABLE IF NOT EXISTS `' . $table . '` ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + measurement_id BIGINT UNSIGNED NOT NULL, + project_key VARCHAR(64) NOT NULL, + base_currency VARCHAR(10) NOT NULL, + quote_currency VARCHAR(10) NOT NULL, + rate DECIMAL(20,10) NOT NULL, + provider VARCHAR(32) NOT NULL DEFAULT \'derived\', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES `' . $measurementTable . '`(id) ON DELETE CASCADE, + CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency), + KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id) + )', + ]; + + $this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Messwert-Waehrungen fehlgeschlagen.'); + } + + public function ensurePayoutsTable(): void + { + if ($this->tableExists($this->prefix . 'payouts')) { + return; + } + + $table = $this->prefix . 'payouts'; + $projectTable = $this->prefix . 'projects'; + $statements = $this->driver === 'pgsql' + ? [ + 'CREATE TABLE IF NOT EXISTS ' . $table . ' ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + payout_at TIMESTAMP NOT NULL, + coins_amount NUMERIC(20,6) NOT NULL, + payout_currency VARCHAR(10) NOT NULL DEFAULT \'DOGE\', + note TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES ' . $projectTable . '(project_key) ON DELETE CASCADE + )', + 'CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_payout_at ON ' . $table . ' (project_key, payout_at)', + ] + : [ + 'CREATE TABLE IF NOT EXISTS `' . $table . '` ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + payout_at TIMESTAMP NOT NULL, + coins_amount DECIMAL(20,6) NOT NULL, + payout_currency VARCHAR(10) NOT NULL DEFAULT \'DOGE\', + note TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES `' . $projectTable . '`(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at) + )', + ]; + + $this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Auszahlungen fehlgeschlagen.'); + } + + public function ensureWalletSnapshotsTable(): void + { + if ($this->tableExists($this->prefix . 'wallet_snapshots')) { + return; + } + + $table = $this->prefix . 'wallet_snapshots'; + $projectTable = $this->prefix . 'projects'; + $statements = $this->driver === 'pgsql' + ? [ + 'CREATE TABLE IF NOT EXISTS ' . $table . ' ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + measured_at TIMESTAMP NOT NULL, + total_value_amount NUMERIC(20,8), + total_value_currency VARCHAR(10), + wallet_balance NUMERIC(28,10), + wallet_currency VARCHAR(10) NOT NULL, + balances_json JSONB, + note TEXT, + source VARCHAR(16) NOT NULL, + image_path VARCHAR(255), + ocr_raw_text TEXT, + ocr_confidence NUMERIC(6,4), + ocr_flags JSONB, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES ' . $projectTable . '(project_key) ON DELETE CASCADE + )', + 'CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_snapshots_project_measured_at ON ' . $table . ' (project_key, owner_sub, measured_at)', + ] + : [ + 'CREATE TABLE IF NOT EXISTS `' . $table . '` ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + measured_at TIMESTAMP NOT NULL, + total_value_amount DECIMAL(20,8) NULL, + total_value_currency VARCHAR(10) NULL, + wallet_balance DECIMAL(28,10) NULL, + wallet_currency VARCHAR(10) NOT NULL, + balances_json JSON NULL, + note TEXT NULL, + source ENUM(\'manual\', \'image_ocr\', \'seed_import\') NOT NULL, + image_path VARCHAR(255) NULL, + ocr_raw_text MEDIUMTEXT NULL, + ocr_confidence DECIMAL(6,4) NULL, + ocr_flags JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES `' . $projectTable . '`(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at) + )', + ]; + + $this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Wallet-Snapshots fehlgeschlagen.'); + } + + public function ensureMinerTables(): void + { + if (!$this->tableExists($this->prefix . 'miner_offers')) { + $this->upgradeMinerOffersTable(); + } + + if (!$this->tableExists($this->prefix . 'purchased_miners')) { + $this->upgradePurchasedMinersTable(); + } + } + + private function upgradeFxRatesTable(): void + { + $fetchTable = $this->prefix . 'fx_fetches'; + $rateTable = $this->prefix . 'fx_rates'; + $statements = $this->driver === 'pgsql' + ? [ + 'CREATE TABLE IF NOT EXISTS ' . $fetchTable . ' ( + id BIGSERIAL PRIMARY KEY, + provider VARCHAR(32) NOT NULL DEFAULT \'currencyapi\', + base_currency VARCHAR(10) NOT NULL, + rate_date DATE NOT NULL, + fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )', + 'CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_fetches_base_fetched ON ' . $fetchTable . ' (base_currency, fetched_at)', + 'CREATE TABLE IF NOT EXISTS ' . $rateTable . ' ( + id BIGSERIAL PRIMARY KEY, + fetch_id BIGINT NOT NULL, + currency_code VARCHAR(10) NOT NULL, + current_value NUMERIC(20,10) NOT NULL, + CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES ' . $fetchTable . '(id) ON DELETE CASCADE + )', + 'CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_fetch ON ' . $rateTable . ' (fetch_id)', + 'CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_currency ON ' . $rateTable . ' (currency_code)', + ] + : [ + 'CREATE TABLE IF NOT EXISTS `' . $fetchTable . '` ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + provider VARCHAR(32) NOT NULL DEFAULT \'currencyapi\', + base_currency VARCHAR(10) NOT NULL, + rate_date DATE NOT NULL, + fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at) + )', + 'CREATE TABLE IF NOT EXISTS `' . $rateTable . '` ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + fetch_id BIGINT UNSIGNED NOT NULL, + currency_code VARCHAR(10) NOT NULL, + current_value DECIMAL(20,10) NOT NULL, + KEY idx_miningcheck_fx_rates_fetch (fetch_id), + KEY idx_miningcheck_fx_rates_currency (currency_code), + CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES `' . $fetchTable . '`(id) ON DELETE CASCADE + )', + ]; + + foreach ($statements as $statement) { + $this->executeUpgradeStatements([$statement], 'Schema-Upgrade fuer FX-Kurse fehlgeschlagen.'); + } + } + + private function upgradeSettingsPreferredCurrenciesColumn(): void + { + $table = $this->prefix . 'settings'; + $statements = $this->driver === 'pgsql' + ? [ + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS preferred_currencies JSONB', + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS report_currency VARCHAR(10)', + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS crypto_currency VARCHAR(10)', + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS display_timezone VARCHAR(64)', + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS fx_max_age_hours NUMERIC(10,2)', + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS module_theme_mode VARCHAR(16)', + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS module_theme_accent VARCHAR(16)', + ] + : [ + 'ALTER TABLE `' . $table . '` ADD COLUMN preferred_currencies JSON NULL', + 'ALTER TABLE `' . $table . '` ADD COLUMN report_currency VARCHAR(10) NULL', + 'ALTER TABLE `' . $table . '` ADD COLUMN crypto_currency VARCHAR(10) NULL', + 'ALTER TABLE `' . $table . '` ADD COLUMN display_timezone VARCHAR(64) NULL', + 'ALTER TABLE `' . $table . '` ADD COLUMN fx_max_age_hours DECIMAL(10,2) NULL', + 'ALTER TABLE `' . $table . '` ADD COLUMN module_theme_mode VARCHAR(16) NULL', + 'ALTER TABLE `' . $table . '` ADD COLUMN module_theme_accent VARCHAR(16) NULL', + ]; + + foreach ($statements as $statement) { + try { + $this->executeUpgradeStatements([$statement], 'Schema-Upgrade fuer Settings fehlgeschlagen.'); + } catch (\Throwable $exception) { + if ($this->driver === 'mysql' && str_contains(strtolower($exception->getMessage()), 'duplicate column')) { + continue; + } + + throw $exception; + } + } + } + + private function upgradeMinerOffersTable(): void + { + $table = $this->prefix . 'miner_offers'; + $projectTable = $this->prefix . 'projects'; + $statements = $this->driver === 'pgsql' + ? [ + 'CREATE TABLE IF NOT EXISTS ' . $table . ' ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + label VARCHAR(120) NOT NULL, + runtime_months INTEGER, + mining_speed_value NUMERIC(20,4), + mining_speed_unit VARCHAR(8), + bonus_speed_value NUMERIC(20,4), + bonus_speed_unit VARCHAR(8), + base_price_amount NUMERIC(20,8) NOT NULL, + base_price_currency VARCHAR(10) NOT NULL, + payment_type VARCHAR(10) NOT NULL DEFAULT \'fiat\', + auto_renew BOOLEAN NOT NULL DEFAULT FALSE, + note TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES ' . $projectTable . '(project_key) ON DELETE CASCADE + )', + ] + : [ + 'CREATE TABLE IF NOT EXISTS `' . $table . '` ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + label VARCHAR(120) NOT NULL, + runtime_months INT NULL, + mining_speed_value DECIMAL(20,4) NULL, + mining_speed_unit VARCHAR(8) NULL, + bonus_speed_value DECIMAL(20,4) NULL, + bonus_speed_unit VARCHAR(8) NULL, + base_price_amount DECIMAL(20,8) NOT NULL, + base_price_currency VARCHAR(10) NOT NULL, + payment_type VARCHAR(10) NOT NULL DEFAULT \'fiat\', + auto_renew TINYINT(1) NOT NULL DEFAULT 0, + note TEXT, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES `' . $projectTable . '`(project_key) ON DELETE CASCADE + )', + ]; + + $this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Miner-Angebote fehlgeschlagen.'); + } + + private function upgradePurchasedMinersTable(): void + { + $table = $this->prefix . 'purchased_miners'; + $projectTable = $this->prefix . 'projects'; + $offerTable = $this->prefix . 'miner_offers'; + $statements = $this->driver === 'pgsql' + ? [ + 'CREATE TABLE IF NOT EXISTS ' . $table . ' ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + miner_offer_id BIGINT, + purchased_at TIMESTAMP NOT NULL, + label VARCHAR(120) NOT NULL, + runtime_months INTEGER, + mining_speed_value NUMERIC(20,4), + mining_speed_unit VARCHAR(8), + bonus_speed_value NUMERIC(20,4), + bonus_speed_unit VARCHAR(8), + total_cost_amount NUMERIC(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + usd_reference_amount NUMERIC(20,8), + reference_price_amount NUMERIC(20,8), + reference_price_currency VARCHAR(10), + auto_renew BOOLEAN NOT NULL DEFAULT FALSE, + note TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES ' . $projectTable . '(project_key) ON DELETE CASCADE, + CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES ' . $offerTable . '(id) ON DELETE SET NULL + )', + ] + : [ + 'CREATE TABLE IF NOT EXISTS `' . $table . '` ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + miner_offer_id BIGINT UNSIGNED NULL, + purchased_at TIMESTAMP NOT NULL, + label VARCHAR(120) NOT NULL, + runtime_months INT NULL, + mining_speed_value DECIMAL(20,4) NULL, + mining_speed_unit VARCHAR(8) NULL, + bonus_speed_value DECIMAL(20,4) NULL, + bonus_speed_unit VARCHAR(8) NULL, + total_cost_amount DECIMAL(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + usd_reference_amount DECIMAL(20,8) NULL, + reference_price_amount DECIMAL(20,8) NULL, + reference_price_currency VARCHAR(10) NULL, + auto_renew TINYINT(1) NOT NULL DEFAULT 0, + note TEXT, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES `' . $projectTable . '`(project_key) ON DELETE CASCADE, + CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES `' . $offerTable . '`(id) ON DELETE SET NULL + )', + ]; + + $this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer gekaufte Miner fehlgeschlagen.'); + } + + private function ensureCurrencyForeignKeys(): void + { + $this->seedMissingCurrenciesFromReferences(); + + $constraints = [ + ['table' => $this->prefix . 'settings', 'column' => 'daily_cost_currency', 'name' => 'fk_mining_settings_daily_cost_currency_currency'], + ['table' => $this->prefix . 'settings', 'column' => 'report_currency', 'name' => 'fk_mining_settings_report_currency_currency'], + ['table' => $this->prefix . 'settings', 'column' => 'crypto_currency', 'name' => 'fk_mining_settings_crypto_currency_currency'], + ['table' => $this->prefix . 'cost_plans', 'column' => 'currency', 'name' => 'fk_mining_cost_plans_currency_currency'], + ['table' => $this->prefix . 'measurements', 'column' => 'price_currency', 'name' => 'fk_mining_measurements_price_currency_currency'], + ['table' => $this->prefix . 'measurement_rates', 'column' => 'base_currency', 'name' => 'fk_mining_measurement_rates_base_currency_currency'], + ['table' => $this->prefix . 'measurement_rates', 'column' => 'quote_currency', 'name' => 'fk_mining_measurement_rates_quote_currency_currency'], + ['table' => $this->prefix . 'payouts', 'column' => 'payout_currency', 'name' => 'fk_mining_payouts_payout_currency_currency'], + ['table' => $this->prefix . 'targets', 'column' => 'currency', 'name' => 'fk_mining_targets_currency_currency'], + ['table' => $this->prefix . 'miner_offers', 'column' => 'base_price_currency', 'name' => 'fk_mining_miner_offers_base_price_currency_currency'], + ['table' => $this->prefix . 'purchased_miners', 'column' => 'currency', 'name' => 'fk_mining_purchased_miners_currency_currency'], + ['table' => $this->prefix . 'purchased_miners', 'column' => 'reference_price_currency', 'name' => 'fk_mining_purchased_miners_reference_price_currency_currency'], + ['table' => $this->prefix . 'fx_fetches', 'column' => 'base_currency', 'name' => 'fk_mining_fx_fetches_base_currency_currency'], + ['table' => $this->prefix . 'fx_rates', 'column' => 'currency_code', 'name' => 'fk_mining_fx_rates_currency_code_currency'], + ]; + + foreach ($constraints as $constraint) { + if ( + !$this->tableExists($constraint['table']) || + !$this->columnExists($constraint['table'], $constraint['column']) || + $this->foreignKeyExists($constraint['name']) + ) { + continue; + } + + $statement = $this->driver === 'pgsql' + ? 'ALTER TABLE ' . $constraint['table'] . ' ADD CONSTRAINT ' . $constraint['name'] . ' FOREIGN KEY (' . $constraint['column'] . ') REFERENCES ' . $this->prefix . 'currencies(code)' + : 'ALTER TABLE `' . $constraint['table'] . '` ADD CONSTRAINT ' . $constraint['name'] . ' FOREIGN KEY (`' . $constraint['column'] . '`) REFERENCES `' . $this->prefix . 'currencies`(`code`)'; + + $this->executeUpgradeStatements([$statement], 'Schema-Upgrade fuer Waehrungs-Referenzen fehlgeschlagen.'); + } + } + + private function seedMissingCurrenciesFromReferences(): void + { + $sources = [ + [$this->prefix . 'settings', 'daily_cost_currency'], + [$this->prefix . 'settings', 'report_currency'], + [$this->prefix . 'settings', 'crypto_currency'], + [$this->prefix . 'cost_plans', 'currency'], + [$this->prefix . 'measurements', 'price_currency'], + [$this->prefix . 'measurement_rates', 'base_currency'], + [$this->prefix . 'measurement_rates', 'quote_currency'], + [$this->prefix . 'payouts', 'payout_currency'], + [$this->prefix . 'targets', 'currency'], + [$this->prefix . 'miner_offers', 'base_price_currency'], + [$this->prefix . 'purchased_miners', 'currency'], + [$this->prefix . 'purchased_miners', 'reference_price_currency'], + [$this->prefix . 'fx_fetches', 'base_currency'], + [$this->prefix . 'fx_rates', 'currency_code'], + ]; + + foreach ($sources as [$table, $column]) { + if (!$this->tableExists($table) || !$this->columnExists($table, $column)) { + continue; + } + + $statement = $this->driver === 'pgsql' + ? 'INSERT INTO ' . $this->prefix . 'currencies (code, name, symbol, is_active, sort_order) + SELECT DISTINCT src.' . $column . ', src.' . $column . ', src.' . $column . ', TRUE, 1000 + FROM ' . $table . ' src + LEFT JOIN ' . $this->prefix . 'currencies c ON c.code = src.' . $column . ' + WHERE src.' . $column . ' IS NOT NULL + AND BTRIM(src.' . $column . ') <> \'\' + AND c.code IS NULL' + : 'INSERT INTO `' . $this->prefix . 'currencies` (code, name, symbol, is_active, sort_order) + SELECT DISTINCT src.`' . $column . '`, src.`' . $column . '`, src.`' . $column . '`, 1, 1000 + FROM `' . $table . '` src + LEFT JOIN `' . $this->prefix . 'currencies` c ON c.code = src.`' . $column . '` + WHERE src.`' . $column . '` IS NOT NULL + AND TRIM(src.`' . $column . '`) <> \'\' + AND c.code IS NULL'; + + $this->executeUpgradeStatements([$statement], 'Fehlende Waehrungen konnten nicht vorbereitet werden.'); + } + } + + private function upgradeCurrenciesClassificationColumns(): void + { + $table = $this->prefix . 'currencies'; + $statements = $this->driver === 'pgsql' + ? ['ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS is_crypto BOOLEAN NOT NULL DEFAULT FALSE'] + : ['ALTER TABLE `' . $table . '` ADD COLUMN is_crypto TINYINT(1) NOT NULL DEFAULT 0']; + foreach ($statements as $statement) { + try { + $this->executeUpgradeStatements([$statement], 'Schema-Upgrade fuer Waehrungs-Klassifikation fehlgeschlagen.'); + } catch (\Throwable $exception) { + if ($this->driver === 'mysql' && str_contains(strtolower($exception->getMessage()), 'duplicate column')) { + continue; + } + throw $exception; + } + } + } + + private function upgradeMinerOfferBasePriceColumns(): void + { + $table = $this->prefix . 'miner_offers'; + $statements = $this->driver === 'pgsql' + ? [ + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS base_price_amount NUMERIC(20,8)', + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS base_price_currency VARCHAR(10)', + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS payment_type VARCHAR(10) NOT NULL DEFAULT \'fiat\'', + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS auto_renew BOOLEAN NOT NULL DEFAULT FALSE', + 'UPDATE ' . $table . ' SET base_price_amount = COALESCE(base_price_amount, reference_price_amount, usd_reference_amount, price_amount)', + 'UPDATE ' . $table . ' SET base_price_currency = COALESCE(base_price_currency, reference_price_currency, CASE WHEN usd_reference_amount IS NOT NULL THEN \'USD\' ELSE price_currency END)', + "UPDATE " . $table . " SET payment_type = CASE WHEN payment_type IS NULL OR BTRIM(payment_type) = '' THEN CASE WHEN UPPER(COALESCE(price_currency, '')) IN ('ADA','ARB','BNB','BTC','DAI','DOGE','DOT','ETH','LINK','LTC','SOL','USDC','USDT','XRP') THEN 'crypto' ELSE 'fiat' END ELSE payment_type END", + ] + : [ + 'ALTER TABLE `' . $table . '` ADD COLUMN base_price_amount DECIMAL(20,8) NULL', + 'ALTER TABLE `' . $table . '` ADD COLUMN base_price_currency VARCHAR(10) NULL', + 'ALTER TABLE `' . $table . '` ADD COLUMN payment_type VARCHAR(10) NOT NULL DEFAULT \'fiat\'', + 'ALTER TABLE `' . $table . '` ADD COLUMN auto_renew TINYINT(1) NOT NULL DEFAULT 0', + 'UPDATE `' . $table . '` SET base_price_amount = COALESCE(base_price_amount, reference_price_amount, usd_reference_amount, price_amount)', + 'UPDATE `' . $table . '` SET base_price_currency = COALESCE(base_price_currency, reference_price_currency, CASE WHEN usd_reference_amount IS NOT NULL THEN \'USD\' ELSE price_currency END)', + "UPDATE `" . $table . "` SET payment_type = CASE WHEN payment_type IS NULL OR TRIM(payment_type) = '' THEN CASE WHEN UPPER(COALESCE(price_currency, '')) IN ('ADA','ARB','BNB','BTC','DAI','DOGE','DOT','ETH','LINK','LTC','SOL','USDC','USDT','XRP') THEN 'crypto' ELSE 'fiat' END ELSE payment_type END", + ]; + foreach ($statements as $statement) { + try { + $this->executeUpgradeStatements([$statement], 'Schema-Upgrade fuer Miner-Angebote fehlgeschlagen.'); + } catch (\Throwable $exception) { + if ($this->driver === 'mysql' && str_contains(strtolower($exception->getMessage()), 'duplicate column')) { + continue; + } + throw $exception; + } + } + } + + private function upgradePurchasedMinerReferenceColumns(): void + { + $table = $this->prefix . 'purchased_miners'; + $statements = $this->driver === 'pgsql' + ? [ + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS reference_price_amount NUMERIC(20,8)', + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS reference_price_currency VARCHAR(10)', + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS auto_renew BOOLEAN NOT NULL DEFAULT FALSE', + ] + : [ + 'ALTER TABLE `' . $table . '` ADD COLUMN reference_price_amount DECIMAL(20,8) NULL', + 'ALTER TABLE `' . $table . '` ADD COLUMN reference_price_currency VARCHAR(10) NULL', + 'ALTER TABLE `' . $table . '` ADD COLUMN auto_renew TINYINT(1) NOT NULL DEFAULT 0', + ]; + foreach ($statements as $statement) { + try { + $this->executeUpgradeStatements([$statement], 'Schema-Upgrade fuer gekaufte Miner fehlgeschlagen.'); + } catch (\Throwable $exception) { + if ($this->driver === 'mysql' && str_contains(strtolower($exception->getMessage()), 'duplicate column')) { + continue; + } + throw $exception; + } + } + } + + private function upgradeTargetOfferColumn(): void + { + $table = $this->prefix . 'targets'; + $statements = $this->driver === 'pgsql' + ? ['ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS miner_offer_id BIGINT'] + : ['ALTER TABLE `' . $table . '` ADD COLUMN miner_offer_id BIGINT UNSIGNED NULL']; + foreach ($statements as $statement) { + try { + $this->executeUpgradeStatements([$statement], 'Schema-Upgrade fuer Ziel-Angebots-Verknuepfung fehlgeschlagen.'); + } catch (\Throwable $exception) { + if ($this->driver === 'mysql' && str_contains(strtolower($exception->getMessage()), 'duplicate column')) { + continue; + } + throw $exception; + } + } + } + + private function ensureTargetOfferForeignKey(): void + { + $constraintName = 'fk_mining_targets_offer'; + if ($this->foreignKeyExists($constraintName)) { + return; + } + + $targetTable = $this->prefix . 'targets'; + $offerTable = $this->prefix . 'miner_offers'; + $statement = $this->driver === 'pgsql' + ? 'ALTER TABLE ' . $targetTable . ' ADD CONSTRAINT ' . $constraintName . ' FOREIGN KEY (miner_offer_id) REFERENCES ' . $offerTable . '(id) ON DELETE SET NULL' + : 'ALTER TABLE `' . $targetTable . '` ADD CONSTRAINT ' . $constraintName . ' FOREIGN KEY (miner_offer_id) REFERENCES `' . $offerTable . '`(id) ON DELETE SET NULL'; + + $this->executeUpgradeStatements([$statement], 'Schema-Upgrade fuer Ziel-Angebots-Fremdschluessel fehlgeschlagen.'); + } + + private function foreignKeyExists(string $constraintName): bool + { + $schemaCondition = $this->driver === 'pgsql' + ? 'constraint_schema = current_schema()' + : 'constraint_schema = DATABASE()'; + + $statement = $this->pdo->prepare( + 'SELECT constraint_name + FROM information_schema.table_constraints + WHERE ' . $schemaCondition . ' + AND constraint_type = \'FOREIGN KEY\' + AND constraint_name = :constraint_name + LIMIT 1' + ); + $statement->execute(['constraint_name' => $constraintName]); + + return (bool) $statement->fetchColumn(); + } + + private function executeUpgradeStatements(array $statements, string $message): void + { + foreach ($statements as $statement) { + try { + $this->pdo->exec($statement); + } catch (\Throwable $exception) { + throw new ApiException( + $message, + 500, + ['message' => $exception->getMessage(), 'statement' => $statement] + ); + } + } + } + + private function lightweightStatus(): array + { + $coreTables = $this->coreTables(); + $extraTables = $this->extraTables(); + + $presentTables = $this->existingTables(array_merge($coreTables, $extraTables)); + + return [ + 'present_tables' => $presentTables, + 'present_count' => count($presentTables), + 'core_present' => count(array_intersect($coreTables, $presentTables)) === count($coreTables), + 'missing_core_tables' => array_values(array_diff($coreTables, $presentTables)), + 'missing_extra_tables' => array_values(array_diff($extraTables, $presentTables)), + ]; + } + + private function coreTables(): array + { + return [ + $this->prefix . 'projects', + $this->prefix . 'settings', + $this->prefix . 'cost_plans', + $this->prefix . 'measurements', + $this->prefix . 'targets', + $this->prefix . 'dashboard_definitions', + ]; + } + + private function extraTables(): array + { + return [ + $this->prefix . 'fx_fetches', + $this->prefix . 'fx_rates', + $this->prefix . 'measurement_rates', + $this->prefix . 'payouts', + $this->prefix . 'wallet_snapshots', + $this->prefix . 'miner_offers', + $this->prefix . 'purchased_miners', + ]; + } + + private function knownTablesInDropOrder(): array + { + return array_reverse($this->knownTablesInCreateOrder()); + } + + private function knownTablesInCreateOrder(): array + { + return [ + $this->prefix . 'projects', + $this->prefix . 'settings', + $this->prefix . 'cost_plans', + $this->prefix . 'measurements', + $this->prefix . 'measurement_rates', + $this->prefix . 'payouts', + $this->prefix . 'wallet_snapshots', + $this->prefix . 'miner_offers', + $this->prefix . 'targets', + $this->prefix . 'dashboard_definitions', + $this->prefix . 'purchased_miners', + $this->prefix . 'fx_fetches', + $this->prefix . 'fx_rates', + ]; + } +} diff --git a/src/MiningChecker/LegacyModuleStore.php b/modules/mining-checker/src/Legacy/LegacyModuleStore.php similarity index 99% rename from src/MiningChecker/LegacyModuleStore.php rename to modules/mining-checker/src/Legacy/LegacyModuleStore.php index e7fcae6d..5534fa97 100644 --- a/src/MiningChecker/LegacyModuleStore.php +++ b/modules/mining-checker/src/Legacy/LegacyModuleStore.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace MiningChecker; +namespace Modules\MiningChecker\Legacy; use RuntimeException; diff --git a/modules/mining-checker/src/Legacy/UserScope.php b/modules/mining-checker/src/Legacy/UserScope.php new file mode 100644 index 00000000..435ff278 --- /dev/null +++ b/modules/mining-checker/src/Legacy/UserScope.php @@ -0,0 +1,15 @@ +statusCode = $statusCode; + $this->context = $context; + } + + public function statusCode(): int + { + return $this->statusCode; + } + + public function context(): array + { + return $this->context; + } +} diff --git a/modules/mining-checker/src/Support/DebugState.php b/modules/mining-checker/src/Support/DebugState.php new file mode 100644 index 00000000..7ccda2b7 --- /dev/null +++ b/modules/mining-checker/src/Support/DebugState.php @@ -0,0 +1,35 @@ +enabled = $enabled; + $this->filePath = $enabled ? $filePath : null; + DebugState::replace([]); + if ($this->enabled && $this->filePath !== null) { + $this->persist(); + } + } + + public function enabled(): bool + { + return $this->enabled; + } + + public function add(string $event, array $context = []): void + { + if (!$this->enabled) { + return; + } + + $this->entries[] = [ + 'time' => date('c'), + 'event' => $event, + 'context' => $context, + ]; + DebugState::replace($this->entries); + $this->persist(); + } + + public function export(): array + { + return $this->enabled ? $this->entries : []; + } + + private function persist(): void + { + if (!$this->enabled || $this->filePath === null) { + return; + } + + $directory = dirname($this->filePath); + if (!is_dir($directory)) { + @mkdir($directory, 0775, true); + } + + @file_put_contents($this->filePath, json_encode($this->entries, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + } +} diff --git a/modules/mining-checker/src/Support/Http.php b/modules/mining-checker/src/Support/Http.php new file mode 100644 index 00000000..e90d3bbb --- /dev/null +++ b/modules/mining-checker/src/Support/Http.php @@ -0,0 +1,27 @@ + - - - - - - - - - - + + + + + + diff --git a/public/api/mining-checker/index.php b/public/api/mining-checker/index.php index 9b7f49c2..b68ebf7c 100644 --- a/public/api/mining-checker/index.php +++ b/public/api/mining-checker/index.php @@ -4,830 +4,11 @@ declare(strict_types=1); require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php'; -use App\ConfigLoader; -use App\AccountGate; -use App\KeycloakAuth; -use MiningChecker\LegacyModuleStore; -use MiningChecker\MiningCheckerUserScope; +use ModulesCore\ModuleHttp; session_start(); $projectRoot = dirname(__DIR__, 3); -$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak')); -$accountGate = new AccountGate(ConfigLoader::load($projectRoot, 'registration')); +ModuleHttp::requireDesktopAccess($projectRoot, true); -header('Content-Type: application/json; charset=utf-8'); - -if ($auth->isAuthenticated()) { - $currentUser = is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : []; - $accountCheck = $accountGate->checkUsername((string) ($currentUser['username'] ?? '')); - - if (!($accountCheck['allowed'] ?? false)) { - $auth->logout(); - respond(['error' => (string) ($accountCheck['message'] ?? 'Dieses Konto ist noch nicht freigeschaltet.')], 403); - } -} - -if (!$auth->shouldShowDesktop()) { - respond(['error' => 'Nicht autorisiert.'], 401); -} - -$store = new LegacyModuleStore($projectRoot, MiningCheckerUserScope::current()); -$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); -$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? ''); -$path = (string) (parse_url($requestUri, PHP_URL_PATH) ?: ''); -$prefix = '/api/mining-checker/index.php/'; -$relativePath = str_starts_with($path, $prefix) - ? substr($path, strlen($prefix)) - : ltrim((string) ($_SERVER['PATH_INFO'] ?? ''), '/'); -$relativePath = trim($relativePath, '/'); - -if ($relativePath === '') { - respond(['data' => ['ok' => true, 'module' => 'mining-checker']], 200); -} - -if ($relativePath === 'v1/health') { - respond(['data' => ['ok' => true, 'module' => 'mining-checker']], 200); -} - -if ($relativePath === 'v1/debug/latest') { - respond(['data' => ['entries' => [], 'file' => null]], 200); -} - -if (!preg_match('~^v1/projects/([a-zA-Z0-9_-]+)(?:/(.*))?$~', $relativePath, $matches)) { - respond(['error' => 'Unbekannter API-Pfad.'], 404); -} - -$projectKey = $matches[1]; -$resource = trim((string) ($matches[2] ?? ''), '/'); - -try { - $project = $store->loadProject($projectKey); - $payload = inputPayload(); - - if ($resource === 'schema-status' && $method === 'GET') { - respond(['data' => schemaStatus()], 200); - } - - if ($resource === 'initialize' && $method === 'POST') { - respond(['data' => initializeSchema($payload)], 201); - } - - if ($resource === 'upgrade' && $method === 'POST') { - respond(['data' => upgradeSchema()], 201); - } - - if ($resource === 'rebuild-preserve-core' && $method === 'POST') { - respond(['data' => ['message' => 'JSON-Datenbasis neu aufgebaut.', 'restored' => ['projects' => 1]]], 201); - } - - if ($resource === 'legacy-fx-migrate' && $method === 'POST') { - respond(['data' => [ - 'message' => 'Keine Legacy-FX-Daten vorhanden.', - 'legacy_fetches_found' => 0, - 'fx_fetches_imported' => 0, - 'fx_fetches_reused' => 0, - 'measurements_updated' => 0, - 'measurements_unresolved' => 0, - ]], 201); - } - - if ($resource === 'sql-import' && $method === 'POST') { - $file = is_array($_FILES['sql_file'] ?? null) ? $_FILES['sql_file'] : null; - respond(['data' => [ - 'message' => 'SQL-Datei fuer den JSON-Modus nicht ausgefuehrt, aber Upload erkannt.', - 'statement_count' => 0, - 'file' => (string) ($file['name'] ?? 'upload.sql'), - ]], 201); - } - - if ($resource === 'connection-test' && $method === 'GET') { - respond(['data' => [ - 'driver' => 'json-store', - 'database' => 'data/mining-checker/users', - 'project_key' => $projectKey, - ]], 200); - } - - if ($resource === 'fx-history' && $method === 'GET') { - respond(['data' => array_values($project['fx_history'] ?? [])], 200); - } - - if ($resource === 'bootstrap' && $method === 'GET') { - $view = trim((string) ($_GET['view'] ?? 'overview')); - respond(['data' => bootstrapPayload($projectKey, $project, $view)], 200); - } - - if ($resource === 'measurements' && $method === 'GET') { - respond(['data' => array_values($project['measurements'] ?? [])], 200); - } - - if ($resource === 'measurements' && $method === 'POST') { - $measurement = createMeasurement($store, $projectKey, $project, $payload); - respond(['data' => $measurement], 201); - } - - if (preg_match('~^measurements/(\d+)$~', $resource, $idMatch) && $method === 'DELETE') { - $project['measurements'] = array_values(array_filter( - (array) ($project['measurements'] ?? []), - static fn (array $row): bool => (int) ($row['id'] ?? 0) !== (int) $idMatch[1] - )); - $store->saveProject($projectKey, $project); - respond(['data' => ['deleted' => true]], 200); - } - - if ($resource === 'measurements-import' && $method === 'POST') { - $result = importMeasurements($store, $projectKey, $project, $payload); - respond(['data' => $result], 201); - } - - if ($resource === 'wallet-snapshots' && $method === 'GET') { - respond(['data' => array_values($project['wallet_snapshots'] ?? [])], 200); - } - - if ($resource === 'wallet-snapshots' && $method === 'POST') { - $snapshot = createWalletSnapshot($store, $projectKey, $project, $payload); - respond(['data' => $snapshot], 201); - } - - if ($resource === 'ocr-preview' && $method === 'POST') { - respond(['data' => ocrPreview($payload)], 201); - } - - if ($resource === 'settings' && $method === 'GET') { - respond(['data' => buildSettingsPayload($project)], 200); - } - - if ($resource === 'settings' && $method === 'PUT') { - $project['settings'] = array_replace((array) $project['settings'], normalizeSettingsPayload($payload)); - $store->saveProject($projectKey, $project); - respond(['data' => buildSettingsPayload($project)], 200); - } - - if ($resource === 'targets' && $method === 'GET') { - respond(['data' => array_values($project['targets'] ?? [])], 200); - } - - if ($resource === 'targets' && $method === 'POST') { - $target = normalizeTarget($payload); - $target['id'] = $store->nextId($projectKey, 'targets'); - $project['targets'][] = $target; - $store->saveProject($projectKey, $project); - respond(['data' => $target], 201); - } - - if (preg_match('~^targets/(\d+)$~', $resource, $idMatch) && $method === 'PATCH') { - foreach ($project['targets'] as &$target) { - if ((int) ($target['id'] ?? 0) === (int) $idMatch[1]) { - $target = array_replace($target, normalizeTarget($payload, false)); - break; - } - } - unset($target); - $store->saveProject($projectKey, $project); - respond(['data' => findById((array) $project['targets'], (int) $idMatch[1])], 200); - } - - if (preg_match('~^targets/(\d+)$~', $resource, $idMatch) && $method === 'DELETE') { - $project['targets'] = array_values(array_filter( - (array) ($project['targets'] ?? []), - static fn (array $row): bool => (int) ($row['id'] ?? 0) !== (int) $idMatch[1] - )); - $store->saveProject($projectKey, $project); - respond(['data' => ['deleted' => true]], 200); - } - - if ($resource === 'dashboards' && $method === 'GET') { - respond(['data' => array_values($project['dashboards'] ?? [])], 200); - } - - if ($resource === 'dashboards' && $method === 'POST') { - $dashboard = [ - 'id' => $store->nextId($projectKey, 'dashboards'), - 'name' => trim((string) ($payload['name'] ?? 'Dashboard')), - 'chart_type' => trim((string) ($payload['chart_type'] ?? 'line')), - 'x_field' => trim((string) ($payload['x_field'] ?? 'measured_at')), - 'y_field' => trim((string) ($payload['y_field'] ?? 'coins_total')), - 'aggregation' => trim((string) ($payload['aggregation'] ?? 'none')), - 'filters' => is_array($payload['filters'] ?? null) ? $payload['filters'] : ['source' => '', 'currency' => ''], - ]; - $project['dashboards'][] = $dashboard; - $store->saveProject($projectKey, $project); - respond(['data' => $dashboard], 201); - } - - if ($resource === 'dashboard-data' && $method === 'GET') { - respond(['data' => dashboardData($project, $_GET)], 200); - } - - if ($resource === 'cost-plans' && $method === 'GET') { - respond(['data' => array_values($project['cost_plans'] ?? [])], 200); - } - - if ($resource === 'cost-plans' && $method === 'POST') { - $plan = normalizeCostPlan($payload); - $plan['id'] = $store->nextId($projectKey, 'cost_plans'); - $project['cost_plans'][] = $plan; - $store->saveProject($projectKey, $project); - respond(['data' => $plan], 201); - } - - if ($resource === 'payouts' && $method === 'GET') { - respond(['data' => array_values($project['payouts'] ?? [])], 200); - } - - if ($resource === 'payouts' && $method === 'POST') { - $payout = [ - 'id' => $store->nextId($projectKey, 'payouts'), - 'payout_at' => normalizeDateTime((string) ($payload['payout_at'] ?? gmdate('Y-m-d H:i:s'))), - 'coins_amount' => (float) ($payload['coins_amount'] ?? 0), - 'payout_currency' => strtoupper(trim((string) ($payload['payout_currency'] ?? 'DOGE'))), - 'note' => trim((string) ($payload['note'] ?? '')), - ]; - $project['payouts'][] = $payout; - $store->saveProject($projectKey, $project); - respond(['data' => $payout], 201); - } - - if ($resource === 'miner-offers' && $method === 'GET') { - respond(['data' => array_values($project['miner_offers'] ?? [])], 200); - } - - if ($resource === 'miner-offers' && $method === 'POST') { - $offer = normalizeMinerOffer($payload); - $offer['id'] = $store->nextId($projectKey, 'miner_offers'); - $project['miner_offers'][] = $offer; - $store->saveProject($projectKey, $project); - respond(['data' => $offer], 201); - } - - if (preg_match('~^miner-offers/(\d+)/purchase$~', $resource, $idMatch) && $method === 'POST') { - $offer = findById((array) $project['miner_offers'], (int) $idMatch[1]); - if ($offer === null) { - respond(['error' => 'Miner-Angebot nicht gefunden.'], 404); - } - - $miner = normalizePurchasedMiner($payload, $offer); - $miner['id'] = $store->nextId($projectKey, 'purchased_miners'); - $project['purchased_miners'][] = $miner; - $store->saveProject($projectKey, $project); - respond(['data' => $miner], 201); - } - - if ($resource === 'purchased-miners' && $method === 'GET') { - respond(['data' => array_values($project['purchased_miners'] ?? [])], 200); - } - - if (preg_match('~^purchased-miners/(\d+)$~', $resource, $idMatch) && $method === 'PATCH') { - foreach ($project['purchased_miners'] as &$miner) { - if ((int) ($miner['id'] ?? 0) === (int) $idMatch[1]) { - $miner['auto_renew'] = (bool) ($payload['auto_renew'] ?? $miner['auto_renew'] ?? false); - break; - } - } - unset($miner); - $store->saveProject($projectKey, $project); - respond(['data' => findById((array) $project['purchased_miners'], (int) $idMatch[1])], 200); - } - - respond(['error' => 'Ressource oder Methode nicht implementiert.'], 404); -} catch (Throwable $throwable) { - respond(['error' => $throwable->getMessage()], 500); -} - -function respond(array $payload, int $status): never -{ - http_response_code($status); - echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - exit; -} - -/** - * @return array - */ -function inputPayload(): array -{ - if (stripos((string) ($_SERVER['CONTENT_TYPE'] ?? ''), 'application/json') !== false) { - $raw = file_get_contents('php://input'); - $decoded = json_decode($raw !== false ? $raw : '', true); - return is_array($decoded) ? $decoded : []; - } - - return $_POST; -} - -/** - * @param array $project - * @return array - */ -function bootstrapPayload(string $projectKey, array $project, string $view): array -{ - $measurements = array_values($project['measurements'] ?? []); - usort($measurements, static fn (array $a, array $b): int => strcmp((string) ($a['measured_at'] ?? ''), (string) ($b['measured_at'] ?? ''))); - $walletSnapshots = array_values($project['wallet_snapshots'] ?? []); - usort($walletSnapshots, static fn (array $a, array $b): int => strcmp((string) ($b['measured_at'] ?? ''), (string) ($a['measured_at'] ?? ''))); - $settings = buildSettingsPayload($project); - $dashboards = array_values($project['dashboards'] ?? []); - $targets = array_values($project['targets'] ?? []); - $payouts = array_values($project['payouts'] ?? []); - $minerOffers = evaluateMinerOffers($project); - $purchasedMiners = array_values($project['purchased_miners'] ?? []); - $latestMeasurement = $measurements === [] ? null : $measurements[array_key_last($measurements)]; - $latestWallet = $walletSnapshots[0] ?? null; - $walletBalances = is_array($latestWallet['balances_json'] ?? null) ? $latestWallet['balances_json'] : []; - $payoutCoins = array_reduce($payouts, static fn (float $sum, array $row): float => $sum + (float) ($row['coins_amount'] ?? 0), 0.0); - $latestCoins = (float) ($latestMeasurement['coins_total'] ?? 0); - $currentHashrateMh = currentHashrateMh($project); - - return [ - 'project' => [ - 'project_key' => $projectKey, - 'project_name' => $project['project']['project_name'] ?? strtoupper($projectKey), - ], - 'settings' => $settings, - 'measurements' => $measurements, - 'targets' => $targets, - 'dashboards' => $dashboards, - 'wallet_snapshots' => $walletSnapshots, - 'fx_snapshots' => [], - 'bootstrap_meta' => [ - 'view' => $view, - 'overview_window_days' => 15, - ], - 'summary' => [ - 'latest_measurement' => $latestMeasurement, - 'baseline' => $settings, - 'targets' => $targets, - 'current_hashrate_mh' => $currentHashrateMh, - 'miner_offers' => $minerOffers, - 'payouts' => [ - 'total_count' => count($payouts), - 'total_coins' => $payoutCoins, - 'current_visible_coins' => $latestCoins, - 'current_effective_coins' => $latestCoins + $payoutCoins, - 'wallet_balances' => $walletBalances, - 'wallet_balance_current_asset' => (float) ($walletBalances[strtoupper((string) ($settings['crypto_currency'] ?? 'DOGE'))] ?? 0), - 'holdings_current_asset' => $latestCoins + (float) ($walletBalances[strtoupper((string) ($settings['crypto_currency'] ?? 'DOGE'))] ?? 0), - ], - ], - ]; -} - -/** - * @param array $project - * @return array - */ -function buildSettingsPayload(array $project): array -{ - $settings = array_replace([ - 'baseline_measured_at' => gmdate('Y-m-d H:i:s'), - 'baseline_coins_total' => 0, - 'daily_cost_amount' => 0, - 'daily_cost_currency' => 'EUR', - 'report_currency' => 'EUR', - 'crypto_currency' => 'DOGE', - 'display_timezone' => 'Europe/Berlin', - 'fx_max_age_hours' => 3, - 'module_theme_mode' => 'inherit', - 'module_theme_accent' => 'teal', - 'preferred_currencies' => ['DOGE', 'USD', 'EUR'], - ], (array) ($project['settings'] ?? [])); - - $settings['cost_plans'] = array_values($project['cost_plans'] ?? []); - $settings['payouts'] = array_values($project['payouts'] ?? []); - $settings['miner_offers'] = array_values($project['miner_offers'] ?? []); - $settings['purchased_miners'] = array_values($project['purchased_miners'] ?? []); - $settings['measurement_rates'] = []; - $settings['currencies'] = defaultCurrencies(); - - return $settings; -} - -/** - * @param array $payload - * @return array - */ -function normalizeSettingsPayload(array $payload): array -{ - return [ - 'baseline_measured_at' => normalizeDateTime((string) ($payload['baseline_measured_at'] ?? gmdate('Y-m-d H:i:s'))), - 'baseline_coins_total' => (float) ($payload['baseline_coins_total'] ?? 0), - 'daily_cost_amount' => (float) ($payload['daily_cost_amount'] ?? 0), - 'daily_cost_currency' => strtoupper(trim((string) ($payload['daily_cost_currency'] ?? 'EUR'))), - 'report_currency' => strtoupper(trim((string) ($payload['report_currency'] ?? 'EUR'))), - 'crypto_currency' => strtoupper(trim((string) ($payload['crypto_currency'] ?? 'DOGE'))), - 'preferred_currencies' => array_values(array_filter(array_map( - static fn (mixed $value): string => strtoupper(trim((string) $value)), - is_array($payload['preferred_currencies'] ?? null) ? $payload['preferred_currencies'] : [] - ))), - ]; -} - -function createMeasurement(LegacyModuleStore $store, string $projectKey, array &$project, array $payload): array -{ - $measurement = [ - 'id' => $store->nextId($projectKey, 'measurements'), - 'measured_at' => normalizeDateTime((string) ($payload['measured_at'] ?? gmdate('Y-m-d H:i:s'))), - 'coins_total' => (float) ($payload['coins_total'] ?? 0), - 'coin_currency' => strtoupper(trim((string) ($payload['coin_currency'] ?? $project['settings']['crypto_currency'] ?? 'DOGE'))), - 'price_per_coin' => (float) ($payload['price_per_coin'] ?? 0), - 'price_currency' => strtoupper(trim((string) ($payload['price_currency'] ?? $project['settings']['report_currency'] ?? 'EUR'))), - 'note' => trim((string) ($payload['note'] ?? '')), - 'source' => trim((string) ($payload['source'] ?? 'manual')), - 'image_path' => trim((string) ($payload['image_path'] ?? '')), - 'ocr_raw_text' => trim((string) ($payload['ocr_raw_text'] ?? '')), - 'ocr_confidence' => (float) ($payload['ocr_confidence'] ?? 0), - 'ocr_flags' => is_array($payload['ocr_flags'] ?? null) ? $payload['ocr_flags'] : [], - ]; - $project['measurements'][] = $measurement; - $store->saveProject($projectKey, $project); - - return $measurement; -} - -function createWalletSnapshot(LegacyModuleStore $store, string $projectKey, array &$project, array $payload): array -{ - $balances = is_array($payload['balances_json'] ?? null) ? $payload['balances_json'] : []; - $snapshot = [ - 'id' => $store->nextId($projectKey, 'wallet_snapshots'), - 'measured_at' => normalizeDateTime((string) ($payload['measured_at'] ?? gmdate('Y-m-d H:i:s'))), - 'total_value_amount' => (float) ($payload['total_value_amount'] ?? 0), - 'total_value_currency' => strtoupper(trim((string) ($payload['total_value_currency'] ?? 'EUR'))), - 'wallet_balance' => (float) ($payload['wallet_balance'] ?? 0), - 'wallet_currency' => strtoupper(trim((string) ($payload['wallet_currency'] ?? $project['settings']['crypto_currency'] ?? 'DOGE'))), - 'balances_json' => $balances, - 'note' => trim((string) ($payload['note'] ?? '')), - 'source' => trim((string) ($payload['source'] ?? 'manual')), - 'image_path' => trim((string) ($payload['image_path'] ?? '')), - 'ocr_raw_text' => trim((string) ($payload['ocr_raw_text'] ?? '')), - 'ocr_confidence' => (float) ($payload['ocr_confidence'] ?? 0), - 'ocr_flags' => is_array($payload['ocr_flags'] ?? null) ? $payload['ocr_flags'] : [], - ]; - $project['wallet_snapshots'][] = $snapshot; - $store->saveProject($projectKey, $project); - - return $snapshot; -} - -/** - * @return array - */ -function importMeasurements(LegacyModuleStore $store, string $projectKey, array &$project, array $payload): array -{ - $rowsText = (string) ($payload['rows_text'] ?? ''); - $defaultCurrency = strtoupper(trim((string) ($payload['default_currency'] ?? 'USD'))); - $source = trim((string) ($payload['source'] ?? 'manual-import')); - $lines = preg_split('/\R+/', trim($rowsText)) ?: []; - $imported = 0; - $duplicates = 0; - $errors = 0; - - foreach ($lines as $line) { - if (trim($line) === '') { - continue; - } - - $parts = preg_split('/[;,|\t]+/', $line) ?: []; - if (count($parts) < 2) { - $errors++; - continue; - } - - $measuredAt = normalizeDateTime(trim((string) $parts[0])); - $coinsTotal = (float) str_replace(',', '.', trim((string) $parts[1])); - $price = isset($parts[2]) ? (float) str_replace(',', '.', trim((string) $parts[2])) : 0; - $currency = isset($parts[3]) ? strtoupper(trim((string) $parts[3])) : $defaultCurrency; - - $duplicate = array_filter((array) ($project['measurements'] ?? []), static function (array $row) use ($measuredAt, $coinsTotal): bool { - return (string) ($row['measured_at'] ?? '') === $measuredAt && (float) ($row['coins_total'] ?? 0) === $coinsTotal; - }); - - if ($duplicate !== []) { - $duplicates++; - continue; - } - - createMeasurement($store, $projectKey, $project, [ - 'measured_at' => $measuredAt, - 'coins_total' => $coinsTotal, - 'price_per_coin' => $price, - 'price_currency' => $currency, - 'source' => $source, - 'note' => 'Import', - ]); - $imported++; - } - - return [ - 'imported' => $imported, - 'duplicates_ignored' => $duplicates, - 'error_count' => $errors, - ]; -} - -/** - * @param array $payload - * @return array - */ -function ocrPreview(array $payload): array -{ - $hintText = trim((string) ($payload['ocr_hint_text'] ?? $_POST['ocr_hint_text'] ?? '')); - $dateContext = trim((string) ($payload['date_context'] ?? $_POST['date_context'] ?? date('Y-m-d'))); - preg_match('/(\d+(?:[.,]\d+)?)/', $hintText, $matches); - $coins = isset($matches[1]) ? (float) str_replace(',', '.', $matches[1]) : 0.0; - - return [ - 'kind' => 'measurement', - 'suggested' => [ - 'measured_at' => normalizeDateTime($dateContext . ' 12:00'), - 'coins_total' => $coins, - 'price_per_coin' => 0, - 'price_currency' => 'USD', - 'note' => $hintText, - 'source' => 'image_ocr', - ], - 'suggested_wallet' => [ - 'measured_at' => normalizeDateTime($dateContext . ' 12:00'), - 'total_value_amount' => 0, - 'total_value_currency' => 'EUR', - 'wallet_balance' => $coins, - 'wallet_currency' => 'DOGE', - 'balances_json' => ['DOGE' => $coins], - 'note' => $hintText, - 'source' => 'image_ocr', - ], - 'confidence' => $coins > 0 ? 0.42 : 0.0, - 'flags' => ['ocr_provider_missing:json-preview'], - 'image_path' => '', - 'raw_text' => $hintText, - ]; -} - -/** - * @param array $payload - * @return array - */ -function normalizeTarget(array $payload, bool $isNew = true): array -{ - $target = [ - 'label' => trim((string) ($payload['label'] ?? '')), - 'target_amount_fiat' => (float) ($payload['target_amount_fiat'] ?? 0), - 'currency' => strtoupper(trim((string) ($payload['currency'] ?? 'EUR'))), - 'miner_offer_id' => trim((string) ($payload['miner_offer_id'] ?? '')), - 'is_active' => (bool) ($payload['is_active'] ?? true), - 'sort_order' => (int) ($payload['sort_order'] ?? 0), - ]; - - if (!$isNew) { - return array_filter($target, static fn (mixed $value): bool => $value !== '' && $value !== null); - } - - return $target; -} - -/** - * @param array $payload - * @return array - */ -function normalizeCostPlan(array $payload): array -{ - $speedValue = (float) ($payload['mining_speed_value'] ?? 0); - $speedUnit = trim((string) ($payload['mining_speed_unit'] ?? 'MH/s')); - $bonusPercent = (float) ($payload['bonus_percent'] ?? 0); - $bonusSpeedValue = $speedValue * ($bonusPercent / 100); - - return [ - 'label' => trim((string) ($payload['label'] ?? 'Miner')), - 'starts_at' => normalizeDateTime((string) ($payload['starts_at'] ?? gmdate('Y-m-d H:i:s'))), - 'runtime_months' => (int) ($payload['runtime_months'] ?? 1), - 'mining_speed_value' => $speedValue, - 'mining_speed_unit' => $speedUnit, - 'bonus_percent' => $bonusPercent, - 'bonus_speed_value' => $bonusSpeedValue, - 'bonus_speed_unit' => $speedUnit, - 'auto_renew' => (bool) ($payload['auto_renew'] ?? true), - 'base_price_amount' => (float) ($payload['base_price_amount'] ?? 0), - 'payment_type' => trim((string) ($payload['payment_type'] ?? 'fiat')), - 'total_cost_amount' => (float) ($payload['total_cost_amount'] ?? 0), - 'currency' => strtoupper(trim((string) ($payload['currency'] ?? 'EUR'))), - 'note' => trim((string) ($payload['note'] ?? '')), - 'is_active' => (bool) ($payload['is_active'] ?? true), - ]; -} - -/** - * @param array $payload - * @return array - */ -function normalizeMinerOffer(array $payload): array -{ - $basePrice = (float) ($payload['base_price_amount'] ?? 0); - $runtimeMonths = max(1, (int) ($payload['runtime_months'] ?? 1)); - $paymentType = trim((string) ($payload['payment_type'] ?? 'fiat')); - $hashrateMh = $paymentType === 'crypto' ? 75 : 50; - - return [ - 'label' => trim((string) ($payload['label'] ?? 'Angebot')), - 'runtime_months' => $runtimeMonths, - 'bonus_percent' => (float) ($payload['bonus_percent'] ?? 0), - 'base_price_amount' => $basePrice, - 'base_price_currency' => strtoupper(trim((string) ($payload['base_price_currency'] ?? 'USD'))), - 'payment_type' => $paymentType, - 'auto_renew' => (bool) ($payload['auto_renew'] ?? false), - 'note' => trim((string) ($payload['note'] ?? '')), - 'is_active' => (bool) ($payload['is_active'] ?? true), - 'offer_hashrate_mh' => $hashrateMh, - ]; -} - -/** - * @param array $offer - * @return array - */ -function normalizePurchasedMiner(array $payload, array $offer): array -{ - $speedValue = (float) ($payload['mining_speed_value'] ?? $offer['offer_hashrate_mh'] ?? 0); - $speedUnit = trim((string) ($payload['mining_speed_unit'] ?? 'MH/s')); - $bonusPercent = (float) ($payload['bonus_percent'] ?? $offer['bonus_percent'] ?? 0); - - return [ - 'label' => trim((string) ($payload['label'] ?? $offer['label'] ?? 'Gemieteter Miner')), - 'purchased_at' => normalizeDateTime((string) ($payload['purchased_at'] ?? gmdate('Y-m-d H:i:s'))), - 'runtime_months' => (int) ($offer['runtime_months'] ?? 1), - 'mining_speed_value' => $speedValue, - 'mining_speed_unit' => $speedUnit, - 'bonus_percent' => $bonusPercent, - 'bonus_speed_value' => $speedValue * ($bonusPercent / 100), - 'bonus_speed_unit' => $speedUnit, - 'total_cost_amount' => (float) ($payload['total_cost_amount'] ?? $offer['base_price_amount'] ?? 0), - 'currency' => strtoupper(trim((string) ($payload['currency'] ?? $offer['base_price_currency'] ?? 'USD'))), - 'reference_price_amount' => (float) ($payload['reference_price_amount'] ?? $offer['base_price_amount'] ?? 0), - 'reference_price_currency' => strtoupper(trim((string) ($payload['reference_price_currency'] ?? $offer['base_price_currency'] ?? 'USD'))), - 'auto_renew' => (bool) ($payload['auto_renew'] ?? $offer['auto_renew'] ?? false), - 'note' => trim((string) ($payload['note'] ?? '')), - 'is_active' => true, - 'miner_offer_id' => (int) ($offer['id'] ?? 0), - ]; -} - -/** - * @param array $project - * @return array> - */ -function evaluateMinerOffers(array $project): array -{ - return array_values(array_map(static function (array $offer): array { - return array_replace($offer, [ - 'base_offer_id' => $offer['id'] ?? 0, - 'effective_price_amount' => (float) ($offer['base_price_amount'] ?? 0), - 'effective_price_currency' => strtoupper(trim((string) ($offer['base_price_currency'] ?? 'USD'))), - 'offer_hashrate_mh' => (float) ($offer['offer_hashrate_mh'] ?? 50), - ]); - }, (array) ($project['miner_offers'] ?? []))); -} - -/** - * @param array $project - * @return array - */ -function dashboardData(array $project, array $query): array -{ - $measurements = array_values($project['measurements'] ?? []); - usort($measurements, static fn (array $a, array $b): int => strcmp((string) ($a['measured_at'] ?? ''), (string) ($b['measured_at'] ?? ''))); - $xField = trim((string) ($query['x_field'] ?? 'measured_at')); - $yField = trim((string) ($query['y_field'] ?? 'coins_total')); - - $points = array_map(static function (array $row) use ($xField, $yField): array { - return [ - 'x' => $row[$xField] ?? null, - 'y' => is_numeric($row[$yField] ?? null) ? (float) $row[$yField] : null, - ]; - }, $measurements); - - return [ - 'points' => $points, - 'series' => [ - [ - 'label' => $yField, - 'points' => $points, - ], - ], - ]; -} - -function schemaStatus(): array -{ - return [ - 'required_tables' => ['json_store'], - 'present_tables' => ['json_store'], - 'missing_tables' => [], - 'pending_upgrades' => [], - 'present_count' => 1, - 'missing_count' => 0, - 'pending_upgrade_count' => 0, - 'all_present' => true, - ]; -} - -/** - * @param array $payload - * @return array - */ -function initializeSchema(array $payload): array -{ - return [ - 'message' => !empty($payload['drop_existing']) ? 'JSON-Datenbestand wurde zurueckgesetzt.' : 'JSON-Datenbestand ist bereit.', - 'after' => schemaStatus(), - 'dropped_tables' => !empty($payload['drop_existing']) ? ['json_store'] : [], - ]; -} - -/** - * @return array - */ -function upgradeSchema(): array -{ - return [ - 'message' => 'Keine Schema-Upgrades fuer JSON-Speicher erforderlich.', - 'after' => schemaStatus(), - 'upgraded' => [], - ]; -} - -/** - * @return array> - */ -function defaultCurrencies(): array -{ - return [ - ['code' => 'EUR', 'name' => 'Euro', 'is_crypto' => false], - ['code' => 'USD', 'name' => 'US-Dollar', 'is_crypto' => false], - ['code' => 'DOGE', 'name' => 'Dogecoin', 'is_crypto' => true], - ['code' => 'BTC', 'name' => 'Bitcoin', 'is_crypto' => true], - ['code' => 'ETH', 'name' => 'Ethereum', 'is_crypto' => true], - ['code' => 'LTC', 'name' => 'Litecoin', 'is_crypto' => true], - ['code' => 'USDT', 'name' => 'Tether', 'is_crypto' => true], - ['code' => 'USDC', 'name' => 'USD Coin', 'is_crypto' => true], - ]; -} - -/** - * @param array> $rows - * @return array|null - */ -function findById(array $rows, int $id): ?array -{ - foreach ($rows as $row) { - if ((int) ($row['id'] ?? 0) === $id) { - return $row; - } - } - - return null; -} - -/** - * @param array $project - */ -function currentHashrateMh(array $project): float -{ - $costPlans = array_reduce((array) ($project['cost_plans'] ?? []), static function (float $sum, array $row): float { - return $sum + toMh((float) ($row['mining_speed_value'] ?? 0), (string) ($row['mining_speed_unit'] ?? 'MH/s')) + toMh((float) ($row['bonus_speed_value'] ?? 0), (string) ($row['bonus_speed_unit'] ?? 'MH/s')); - }, 0.0); - $purchased = array_reduce((array) ($project['purchased_miners'] ?? []), static function (float $sum, array $row): float { - return $sum + toMh((float) ($row['mining_speed_value'] ?? 0), (string) ($row['mining_speed_unit'] ?? 'MH/s')) + toMh((float) ($row['bonus_speed_value'] ?? 0), (string) ($row['bonus_speed_unit'] ?? 'MH/s')); - }, 0.0); - - return $costPlans + $purchased; -} - -function toMh(float $value, string $unit): float -{ - return match (strtoupper(trim($unit))) { - 'KH/S' => $value / 1000, - 'MH/S' => $value, - 'GH/S' => $value * 1000, - 'TH/S' => $value * 1000000, - default => $value, - }; -} - -function normalizeDateTime(string $value): string -{ - $trimmed = trim($value); - if ($trimmed === '') { - return gmdate('Y-m-d H:i:s'); - } - - $normalized = str_replace('T', ' ', $trimmed); - - if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $normalized) === 1) { - return $normalized . ' 00:00:00'; - } - - if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $normalized) === 1) { - return $normalized . ':00'; - } - - return $normalized; -} +require $projectRoot . '/modules/mining-checker/api/index.php'; diff --git a/public/api/module-auth/mining-checker/index.php b/public/api/module-auth/mining-checker/index.php index 4c935d5d..03423ef1 100644 --- a/public/api/module-auth/mining-checker/index.php +++ b/public/api/module-auth/mining-checker/index.php @@ -3,40 +3,20 @@ declare(strict_types=1); require_once dirname(__DIR__, 5) . '/src/App/bootstrap.php'; +require_once dirname(__DIR__, 5) . '/modules/mining-checker/bootstrap.php'; -use App\ConfigLoader; -use App\AccountGate; -use App\KeycloakAuth; -use MiningChecker\LegacyModuleStore; -use MiningChecker\MiningCheckerUserScope; +use Modules\MiningChecker\Legacy\LegacyModuleStore; +use Modules\MiningChecker\Legacy\UserScope; +use ModulesCore\ModuleHttp; session_start(); $projectRoot = dirname(__DIR__, 5); -$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak')); -$accountGate = new AccountGate(ConfigLoader::load($projectRoot, 'registration')); +ModuleHttp::requireDesktopAccess($projectRoot, true); header('Content-Type: application/json; charset=utf-8'); -if ($auth->isAuthenticated()) { - $currentUser = is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : []; - $accountCheck = $accountGate->checkUsername((string) ($currentUser['username'] ?? '')); - - if (!($accountCheck['allowed'] ?? false)) { - $auth->logout(); - http_response_code(403); - echo json_encode(['error' => (string) ($accountCheck['message'] ?? 'Dieses Konto ist noch nicht freigeschaltet.')], JSON_UNESCAPED_UNICODE); - exit; - } -} - -if (!$auth->shouldShowDesktop()) { - http_response_code(401); - echo json_encode(['error' => 'Nicht autorisiert.'], JSON_UNESCAPED_UNICODE); - exit; -} - -$store = new LegacyModuleStore($projectRoot, MiningCheckerUserScope::current()); +$store = new LegacyModuleStore($projectRoot, UserScope::current()); $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); if ($method === 'GET') { diff --git a/public/api/user-self-management/index.php b/public/api/user-self-management/index.php index d9e0bc99..208054dc 100644 --- a/public/api/user-self-management/index.php +++ b/public/api/user-self-management/index.php @@ -12,6 +12,7 @@ use Desktop\AppRegistry; use Desktop\AppVisibility; use Desktop\SkinResolver; use Desktop\WidgetRegistry; +use ModulesCore\ModuleRegistry; session_start(); @@ -27,7 +28,8 @@ if ($method === 'OPTIONS') { $keycloakConfig = ConfigLoader::load($projectRoot, 'keycloak'); $auth = new KeycloakAuth($keycloakConfig); -$registry = new AppRegistry($projectRoot . '/config/apps.php'); +$moduleRegistry = new ModuleRegistry($projectRoot . '/modules'); +$registry = new AppRegistry($projectRoot . '/config/apps.php', $moduleRegistry->desktopApps()); $widgetRegistry = new WidgetRegistry($projectRoot . '/config/widgets.php'); $service = new UserSelfManagementService($projectRoot); $registrationConfig = ConfigLoader::load($projectRoot, 'registration'); diff --git a/public/apps/mining-checker/index.php b/public/apps/mining-checker/index.php index bba9bcfa..1b3212c8 100644 --- a/public/apps/mining-checker/index.php +++ b/public/apps/mining-checker/index.php @@ -2,128 +2,4 @@ declare(strict_types=1); -require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php'; - -use App\ConfigLoader; -use App\AccountGate; -use App\KeycloakAuth; - -session_start(); - -$projectRoot = dirname(__DIR__, 3); -$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak')); -$accountGate = new AccountGate(ConfigLoader::load($projectRoot, 'registration')); - -if ($auth->isAuthenticated()) { - $currentUser = is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : []; - $accountCheck = $accountGate->checkUsername((string) ($currentUser['username'] ?? '')); - - if (!($accountCheck['allowed'] ?? false)) { - $auth->logout(); - $target = (string) ($accountCheck['state'] ?? '') === 'pending' - ? '/auth/pending/?username=' . urlencode((string) ($currentUser['username'] ?? '')) - : '/auth/inactive/?message=' . urlencode((string) ($accountCheck['message'] ?? 'Dieses Konto ist noch nicht freigeschaltet.')); - header('Location: ' . $target, true, 302); - exit; - } -} - -if (!$auth->shouldShowDesktop()) { - header('Location: /auth/keycloak', true, 302); - exit; -} - -$assetVersion = static function (string $publicPath) use ($projectRoot): string { - $filesystemPath = $projectRoot . '/public' . $publicPath; - - if (!is_file($filesystemPath)) { - return $publicPath; - } - - $mtime = filemtime($filesystemPath); - - return $mtime === false ? $publicPath : $publicPath . '?v=' . $mtime; -}; - -$sections = [ - ['key' => 'overview', 'label' => 'Übersicht'], - ['key' => 'upload', 'label' => 'Upload'], - ['key' => 'measurements', 'label' => 'Mining-History'], - ['key' => 'wallet', 'label' => 'Wallet'], - ['key' => 'mining', 'label' => 'Miner-Daten'], - ['key' => 'dashboards', 'label' => 'Dashboards'], -]; -$defaultProjectKey = getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main'; -$activeView = trim((string) ($_GET['view'] ?? 'overview')); -$sectionKeys = array_values(array_filter(array_map( - static fn (mixed $section): string => is_array($section) ? trim((string) ($section['key'] ?? '')) : '', - $sections -))); - -if ($sectionKeys === []) { - $sectionKeys = ['overview']; -} - -if (!in_array($activeView, $sectionKeys, true)) { - $activeView = $sectionKeys[0]; -} - -$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); -?> - - - - - Mining-Checker - - - - -
-
-
- - - - - - +require dirname(__DIR__, 3) . '/modules/mining-checker/pages/index.php'; diff --git a/public/assets/desktop/desktop.js b/public/assets/desktop/desktop.js index e2993d23..bb524103 100644 --- a/public/assets/desktop/desktop.js +++ b/public/assets/desktop/desktop.js @@ -1035,29 +1035,20 @@ if (payloadNode) { } const moduleHost = node.querySelector('.window-module-host'); - if (moduleHost && definition.content_mode === 'native-module' && definition.app_id === 'mining-checker') { - const moduleOptions = definition.module_options || {}; - if (typeof window.initMiningCheckerApp === 'function') { - window.initMiningCheckerApp(moduleHost, { - apiBase: '/api/mining-checker/index.php/v1', - defaultProjectKey: moduleOptions.default_project_key || 'doge-main', - activeView: moduleOptions.active_view || 'overview', - sections: Array.isArray(moduleOptions.sections) ? moduleOptions.sections : [], - moduleDebugEnabled: false, - }); - } else { - moduleHost.innerHTML = '
Mining-Checker Assets konnten nicht initialisiert werden.
'; - } - } + if (moduleHost && definition.content_mode === 'native-module') { + const nativeModule = definition.native_module || {}; + const initializer = nativeModule.initializer; + const baseOptions = nativeModule.options && typeof nativeModule.options === 'object' ? nativeModule.options : {}; + const moduleOptions = { + ...baseOptions, + apiBase: baseOptions.apiBase || settingsApi, + activeSkin, + }; - if (moduleHost && definition.content_mode === 'native-module' && definition.app_id === 'user-self-management') { - if (typeof window.initUserSelfManagementApp === 'function') { - window.initUserSelfManagementApp(moduleHost, { - apiBase: settingsApi, - activeSkin, - }); + if (typeof initializer === 'string' && typeof window[initializer] === 'function') { + window[initializer](moduleHost, moduleOptions); } else { - moduleHost.innerHTML = '
User Self Management konnte nicht initialisiert werden.
'; + moduleHost.innerHTML = `
${escapeHtml(definition.title || 'Modul')} konnte nicht initialisiert werden.
`; } } @@ -1113,6 +1104,7 @@ if (payloadNode) { entry_route: app.entry_route || '', content_mode: app.content_mode || 'summary', module_options: app.module_options || null, + native_module: app.native_module || null, }); }; diff --git a/public/module-assets/index.php b/public/module-assets/index.php new file mode 100644 index 00000000..b41be763 --- /dev/null +++ b/public/module-assets/index.php @@ -0,0 +1,53 @@ +modulePath($module); + +if ($modulePath === null) { + http_response_code(404); + exit('Module not found.'); +} + +$assetPath = realpath($modulePath . '/' . ltrim($path, '/')); +$allowedRoot = realpath($modulePath); + +if ($assetPath === false || $allowedRoot === false || !str_starts_with($assetPath, $allowedRoot . DIRECTORY_SEPARATOR) || !is_file($assetPath)) { + http_response_code(404); + exit('Asset not found.'); +} + +$extension = strtolower(pathinfo($assetPath, PATHINFO_EXTENSION)); +$contentType = match ($extension) { + 'css' => 'text/css; charset=utf-8', + 'js' => 'application/javascript; charset=utf-8', + 'json' => 'application/json; charset=utf-8', + 'svg' => 'image/svg+xml', + 'png' => 'image/png', + 'jpg', 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + default => 'application/octet-stream', +}; + +$mtime = filemtime($assetPath); +if ($mtime !== false) { + header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT'); +} +header('Content-Type: ' . $contentType); +header('Cache-Control: public, max-age=300'); +readfile($assetPath); diff --git a/src/App/App.php b/src/App/App.php index 4b163932..e0fa76c9 100644 --- a/src/App/App.php +++ b/src/App/App.php @@ -11,6 +11,7 @@ use Desktop\DesktopState; use Desktop\SkinResolver; use Desktop\WidgetRegistry; use Desktop\WindowManager; +use ModulesCore\ModuleRegistry; final class App { @@ -26,7 +27,8 @@ final class App { $keycloakConfig = ConfigLoader::load($this->projectRoot, 'keycloak'); $auth = new KeycloakAuth($keycloakConfig); - $registry = new AppRegistry($this->projectRoot . '/config/apps.php'); + $moduleRegistry = new ModuleRegistry($this->projectRoot . '/modules'); + $registry = new AppRegistry($this->projectRoot . '/config/apps.php', $moduleRegistry->desktopApps()); $widgetRegistry = new WidgetRegistry($this->projectRoot . '/config/widgets.php'); $skins = SkinResolver::all(); $isAuthenticated = isset($_SESSION['desktop_auth']) && is_array($_SESSION['desktop_auth']); diff --git a/src/App/bootstrap.php b/src/App/bootstrap.php index ccb7359c..c249515b 100644 --- a/src/App/bootstrap.php +++ b/src/App/bootstrap.php @@ -6,7 +6,7 @@ spl_autoload_register(static function (string $class): void { $prefixes = [ 'App\\' => __DIR__ . '/', 'Desktop\\' => dirname(__DIR__) . '/Desktop/', - 'MiningChecker\\' => dirname(__DIR__) . '/MiningChecker/', + 'ModulesCore\\' => dirname(__DIR__) . '/ModulesCore/', ]; foreach ($prefixes as $prefix => $baseDir) { diff --git a/src/Desktop/AppRegistry.php b/src/Desktop/AppRegistry.php index 63ad83f0..c1e38887 100644 --- a/src/Desktop/AppRegistry.php +++ b/src/Desktop/AppRegistry.php @@ -9,11 +9,14 @@ final class AppRegistry /** @var array> */ private array $apps; - public function __construct(string $configPath) + /** + * @param array> $additionalApps + */ + public function __construct(string $configPath, array $additionalApps = []) { /** @var array> $apps */ $apps = require $configPath; - $this->apps = $apps; + $this->apps = array_values(array_merge($apps, $additionalApps)); } /** diff --git a/src/MiningChecker/MiningCheckerCalculator.php b/src/MiningChecker/MiningCheckerCalculator.php deleted file mode 100644 index 00847120..00000000 --- a/src/MiningChecker/MiningCheckerCalculator.php +++ /dev/null @@ -1,84 +0,0 @@ - $settings - * @return array - */ - public function calculate(array $settings): array - { - $hashrate = $this->toHashrateHps( - (float) ($settings['hashrate'] ?? 0), - (string) ($settings['hashrate_unit'] ?? 'MH/s') - ); - $networkHashrate = $this->toHashrateHps( - (float) ($settings['network_hashrate'] ?? 0), - (string) ($settings['network_hashrate_unit'] ?? 'TH/s') - ); - - $powerWatts = max(0.0, (float) ($settings['power_watts'] ?? 0)); - $electricityPrice = max(0.0, (float) ($settings['electricity_price'] ?? 0)); - $poolFeePercent = max(0.0, min(100.0, (float) ($settings['pool_fee_percent'] ?? 0))); - $coinPrice = max(0.0, (float) ($settings['coin_price'] ?? 0)); - $blockReward = max(0.0, (float) ($settings['block_reward'] ?? 0)); - $blockTimeSeconds = max(1.0, (float) ($settings['block_time_seconds'] ?? 60)); - $hardwareCost = max(0.0, (float) ($settings['hardware_cost'] ?? 0)); - - $share = $networkHashrate > 0 ? $hashrate / $networkHashrate : null; - $blocksPerDay = 86400 / $blockTimeSeconds; - $dailyCoinsGross = $share !== null ? $share * $blocksPerDay * $blockReward : null; - $dailyCoinsNet = $dailyCoinsGross !== null ? $dailyCoinsGross * (1 - ($poolFeePercent / 100)) : null; - $dailyRevenue = $dailyCoinsNet !== null ? $dailyCoinsNet * $coinPrice : null; - $dailyPowerCost = ($powerWatts / 1000) * 24 * $electricityPrice; - $dailyProfit = $dailyRevenue !== null ? $dailyRevenue - $dailyPowerCost : null; - $monthlyProfit = $dailyProfit !== null ? $dailyProfit * 30 : null; - $annualProfit = $dailyProfit !== null ? $dailyProfit * 365 : null; - $breakEvenDays = ($dailyProfit !== null && $dailyProfit > 0.0 && $hardwareCost > 0.0) - ? $hardwareCost / $dailyProfit - : null; - $efficiency = $hashrate > 0 ? $powerWatts / ($hashrate / 1000000) : null; - - return [ - 'hashrate_hps' => $hashrate > 0 ? $hashrate : null, - 'network_hashrate_hps' => $networkHashrate > 0 ? $networkHashrate : null, - 'share_ratio' => $share, - 'blocks_per_day' => $blocksPerDay, - 'daily_coins_gross' => $dailyCoinsGross, - 'daily_coins_net' => $dailyCoinsNet, - 'daily_revenue' => $dailyRevenue, - 'daily_power_cost' => $dailyPowerCost, - 'daily_profit' => $dailyProfit, - 'monthly_profit' => $monthlyProfit, - 'annual_profit' => $annualProfit, - 'break_even_days' => $breakEvenDays, - 'efficiency_w_per_mh' => $efficiency, - ]; - } - - private function toHashrateHps(float $value, string $unit): float - { - if ($value <= 0) { - return 0.0; - } - - $normalized = strtoupper(trim($unit)); - $multipliers = [ - 'H/S' => 1, - 'KH/S' => 1000, - 'MH/S' => 1000000, - 'GH/S' => 1000000000, - 'TH/S' => 1000000000000, - 'PH/S' => 1000000000000000, - 'EH/S' => 1000000000000000000, - ]; - - $multiplier = $multipliers[$normalized] ?? 1000000; - - return $value * $multiplier; - } -} diff --git a/src/MiningChecker/MiningCheckerStore.php b/src/MiningChecker/MiningCheckerStore.php deleted file mode 100644 index f406b7fd..00000000 --- a/src/MiningChecker/MiningCheckerStore.php +++ /dev/null @@ -1,92 +0,0 @@ - - */ - public function load(): array - { - $path = $this->path(); - - if (!is_file($path)) { - return $this->defaultState(); - } - - $raw = file_get_contents($path); - if ($raw === false || trim($raw) === '') { - return $this->defaultState(); - } - - $data = json_decode($raw, true); - - return is_array($data) ? array_replace_recursive($this->defaultState(), $data) : $this->defaultState(); - } - - /** - * @param array $state - */ - public function save(array $state): void - { - $directory = dirname($this->path()); - if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { - throw new RuntimeException('Mining-Checker-Verzeichnis konnte nicht erstellt werden.'); - } - - $json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - if ($json === false) { - throw new RuntimeException('Mining-Checker-Daten konnten nicht serialisiert werden.'); - } - - if (file_put_contents($this->path(), $json . PHP_EOL, LOCK_EX) === false) { - throw new RuntimeException('Mining-Checker-Daten konnten nicht gespeichert werden.'); - } - } - - private function path(): string - { - $safeScope = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $this->userScope) ?: 'guest'; - - return $this->projectRoot . '/data/mining-checker/' . $safeScope . '.json'; - } - - /** - * @return array - */ - private function defaultState(): array - { - return [ - 'settings' => [ - 'project_name' => 'DOGE Mining', - 'coin_symbol' => 'DOGE', - 'algorithm' => 'Scrypt', - 'currency' => 'EUR', - 'hashrate' => 950, - 'hashrate_unit' => 'MH/s', - 'network_hashrate' => 920, - 'network_hashrate_unit' => 'TH/s', - 'block_reward' => 10000, - 'block_time_seconds' => 60, - 'coin_price' => 0.14, - 'power_watts' => 3420, - 'electricity_price' => 0.32, - 'pool_fee_percent' => 1.5, - 'hardware_cost' => 8400, - 'notes' => '', - ], - 'history' => [], - ]; - } -} diff --git a/src/MiningChecker/MiningCheckerUserScope.php b/src/MiningChecker/MiningCheckerUserScope.php deleted file mode 100644 index b9713063..00000000 --- a/src/MiningChecker/MiningCheckerUserScope.php +++ /dev/null @@ -1,21 +0,0 @@ -isAuthenticated()) { + $currentUser = is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : []; + $accountCheck = $accountGate->checkUsername((string) ($currentUser['username'] ?? '')); + + if (!($accountCheck['allowed'] ?? false)) { + $auth->logout(); + $message = (string) ($accountCheck['message'] ?? 'Dieses Konto ist noch nicht freigeschaltet.'); + + if ($json) { + self::respondJson(['error' => $message], 403); + } + + $target = (string) ($accountCheck['state'] ?? '') === 'pending' + ? '/auth/pending/?username=' . urlencode((string) ($currentUser['username'] ?? '')) + : '/auth/inactive/?message=' . urlencode($message); + header('Location: ' . $target, true, 302); + exit; + } + } + + if (!$auth->shouldShowDesktop()) { + if ($json) { + self::respondJson(['error' => 'Nicht autorisiert.'], 401); + } + + header('Location: /auth/keycloak', true, 302); + exit; + } + } + + public static function currentUserScope(): string + { + $auth = $_SESSION['desktop_auth'] ?? null; + + if (!is_array($auth)) { + return 'guest'; + } + + $user = is_array($auth['user'] ?? null) ? $auth['user'] : []; + + return (string) ($user['sub'] ?? $user['username'] ?? 'guest'); + } + + /** + * @param array $payload + */ + private static function respondJson(array $payload, int $statusCode): never + { + http_response_code($statusCode); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } +} diff --git a/src/ModulesCore/ModuleRegistry.php b/src/ModulesCore/ModuleRegistry.php new file mode 100644 index 00000000..c7cd1687 --- /dev/null +++ b/src/ModulesCore/ModuleRegistry.php @@ -0,0 +1,105 @@ +>|null */ + private ?array $desktopApps = null; + + public function __construct( + private readonly string $modulesPath, + ) { + } + + /** + * @return array> + */ + public function desktopApps(): array + { + if ($this->desktopApps !== null) { + return $this->desktopApps; + } + + $apps = []; + + foreach ($this->moduleDirectories() as $moduleName => $modulePath) { + $desktopConfigPath = $modulePath . '/desktop.php'; + if (!is_file($desktopConfigPath)) { + continue; + } + + $desktopConfig = require $desktopConfigPath; + if (!is_array($desktopConfig)) { + continue; + } + + $manifest = $this->readJson($modulePath . '/module.json'); + $desktopConfig['module_id'] = $moduleName; + $desktopConfig['title'] ??= (string) ($manifest['title'] ?? ucfirst($moduleName)); + $desktopConfig['summary'] ??= (string) ($manifest['description'] ?? ''); + $desktopConfig['required_groups'] ??= array_values(array_map( + 'strval', + (array) ($manifest['auth']['groups'] ?? []) + )); + $desktopConfig['enabled_by_default'] ??= (bool) ($manifest['enabled_by_default'] ?? false); + $apps[] = $desktopConfig; + } + + return $this->desktopApps = $apps; + } + + public function modulePath(string $moduleName): ?string + { + $directories = $this->moduleDirectories(); + + return $directories[$moduleName] ?? null; + } + + /** + * @return array + */ + private function moduleDirectories(): array + { + $paths = glob(rtrim($this->modulesPath, '/') . '/*', GLOB_ONLYDIR); + if (!is_array($paths)) { + return []; + } + + $directories = []; + + foreach ($paths as $path) { + $moduleName = basename($path); + if ($moduleName === '' || $moduleName[0] === '.') { + continue; + } + + $directories[$moduleName] = $path; + } + + ksort($directories); + + return $directories; + } + + /** + * @return array + */ + private function readJson(string $path): array + { + if (!is_file($path)) { + return []; + } + + $raw = file_get_contents($path); + if ($raw === false || trim($raw) === '') { + return []; + } + + $decoded = json_decode($raw, true); + + return is_array($decoded) ? $decoded : []; + } +}